From 8a4f768957a3add849a7fcde591c3e0bab020029 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Salgado?= Date: Thu, 21 Oct 2021 18:47:10 +0100 Subject: [PATCH] feat: upgrade makeChanges to a more robust version --- lib/bill-math.js | 330 ++++++++++++++++++++++++----------------------- 1 file changed, 172 insertions(+), 158 deletions(-) diff --git a/lib/bill-math.js b/lib/bill-math.js index 66567fe9..d4801c0f 100644 --- a/lib/bill-math.js +++ b/lib/bill-math.js @@ -1,194 +1,193 @@ -const uuid = require('uuid') const _ = require('lodash/fp') +const uuid = require('uuid') -// Custom algorith for two cassettes. For three or more denominations, we'll need -// to rethink this. Greedy algorithm fails to find *any* solution in some cases. -// Dynamic programming may be too inefficient for large amounts. -// -// We can either require canononical denominations for 3+, or try to expand -// this algorithm. -exports.makeChange = function makeChange (cassettes, amount) { - // Note: Everything here is converted to primitive numbers, - // since they're all integers, well within JS number range, - // and this is way more efficient in a tight loop. +const MAX_AMOUNT_OF_SOLUTIONS = 10000 +const MAX_BRUTEFORCE_ITERATIONS = 10000000 - // Another note: While this greedy algorithm possibly works for all major denominations, - // it still requires a fallback for cases where it might not provide any solution. - // Example: Denominations: [3, 5, 10] | User inputs 4 times the [3] button, resulting in a 12 fiat tx - // This algorithm resolves for 1 x [10], and can't resolve the remainder of 2 - - return _.size(cassettes) > 2 ? makeChangeDynamic(cassettes, amount) : makeChangeDuo(cassettes, amount) +function newSolution(cassettes, c0, c1, c2, c3, shouldFlip) { + return [ + { + provisioned: shouldFlip ? cassettes[0].count - c0 : c0, + denomination: cassettes[0].denomination + }, + { + provisioned: shouldFlip ? cassettes[1].count - c1 : c1, + denomination: cassettes[1].denomination + }, + { + provisioned: shouldFlip ? cassettes[2].count - c2 : c2, + denomination: cassettes[2].denomination + }, + { + provisioned: shouldFlip ? cassettes[3].count - c3 : c3, + denomination: cassettes[3].denomination + } + ] } -function makeChangeDuo (cassettes, amount) { +function mergeCassettes(cassettes) { + const map = {} + + _.forEach(it => { + if (!map[it.denomination]) { + map[it.denomination] = 0 + } + map[it.denomination] += it.count + }, cassettes) + + return _.map(it => ({ denomination: it, count: map[it] }), _.keys(map)) +} + +function unmergeCassettes(cassettes, output) { + const map = {} + + _.forEach(it => { + if (!map[it.denomination]) { + map[it.denomination] = 0 + } + map[it.denomination] += it.provisioned + }, output) + + const response = [] + _.forEach(it => { + const value = { + denomination: it.denomination, + id: uuid.v4() + } + + const amountNeeded = map[it.denomination] + if (!amountNeeded) { + return response.push({ provisioned: 0, ...value }) + } + + if (amountNeeded < it.count) { + map[it.denomination] = 0 + return response.push({ provisioned: amountNeeded, ...value }) + } + + map[it.denomination] -= it.count + return response.push({ provisioned: it.count, ...value }) + }, cassettes) + + return response +} + +function makeChangeDuo(cassettes, amount) { const small = cassettes[0] const large = cassettes[1] const largeDenom = large.denomination const smallDenom = small.denomination const largeBills = Math.min(large.count, Math.floor(amount / largeDenom)) - const amountNum = amount.toNumber() - + for (let i = largeBills; i >= 0; i--) { const remainder = amountNum - largeDenom * i if (remainder % smallDenom !== 0) continue - const smallCount = remainder / smallDenom if (smallCount > small.count) continue - return [ - {provisioned: smallCount, denomination: small.denomination, id: uuid.v4()}, - {provisioned: i, denomination: largeDenom, id: uuid.v4()} + { + provisioned: smallCount, + denomination: small.denomination, + id: uuid.v4() + }, + { provisioned: i, denomination: largeDenom, id: uuid.v4() } ] } - return null + return [] } -function makeChangeDynamic (cassettes, amount) { - while (_.size(cassettes) < 4) { - cassettes.push({ denomination: 0, count: 0 }) +function makeChange(outCassettes, amount) { + const available = _.reduce( + (res, val) => res + val.count * val.denomination, + 0, + outCassettes + ) + + if (available < amount) { + console.log(`Tried to dispense more than was available for amount ${amount.toNumber()} with cassettes ${JSON.stringify(cassettes)}`) + return null } + const cassettes = mergeCassettes(outCassettes) + const result = + _.size(cassettes) >= 3 + ? makeChangeDynamic(cassettes, amount, available) + : makeChangeDuo(cassettes, amount) + + if (!result.length) return null + return unmergeCassettes(outCassettes, result) +} + +function makeChangeDynamicBruteForce(outCassettes, amount, available) { const solutions = [] - const amountNum = amount.toNumber() - for (let i = 0; i * cassettes[0].denomination <= amountNum; i++) { - for ( - let j = 0; - i * cassettes[0].denomination + j * cassettes[1].denomination <= - amountNum; - j++ - ) { - if (cassettes[1].denomination === 0) break - if ( - i * cassettes[0].denomination + j * cassettes[1].denomination === - amountNum && - i <= cassettes[0].count && - j <= cassettes[1].count && - i >= 0 && - j >= 0 - ) { - solutions.push([ - { - provisioned: i, - denomination: cassettes[0].denomination, - id: uuid.v4() - }, - { - provisioned: j, - denomination: cassettes[1].denomination, - id: uuid.v4() - }, - { - provisioned: 0, - denomination: cassettes[2].denomination, - id: uuid.v4() - }, - { - provisioned: 0, - denomination: cassettes[3].denomination, - id: uuid.v4() - } - ]) + let x = 0 + + const shouldFlip = amount > _.max(_.map(it => it.denomination * it.count, outCassettes)) + const amountNum = shouldFlip ? available - amount : amount + + const cassettes = shouldFlip ? _.reverse(outCassettes) : outCassettes + const { denomination: denomination0, count: count0 } = cassettes[0] + const { denomination: denomination1, count: count1 } = cassettes[1] + const { denomination: denomination2, count: count2 } = cassettes[2] + const { denomination: denomination3, count: count3 } = cassettes[3] + + const startTime = new Date().getTime() + + loop1: for (let i = 0; i <= count0; i++) { + const firstSum = i * denomination0 + + for (let j = 0; j <= count1; j++) { + const secondSum = firstSum + j * denomination1 + if (secondSum > amountNum) break + + if (secondSum === amountNum) { + solutions.push(newSolution(cassettes, i, j, 0, 0, shouldFlip)) } - for ( - let k = 0; - i * cassettes[0].denomination + - j * cassettes[1].denomination + - k * cassettes[2].denomination <= - amountNum; - k++ - ) { - if (cassettes[2].denomination === 0) break - if ( - i * cassettes[0].denomination + - j * cassettes[1].denomination + - k * cassettes[2].denomination === - amountNum && - i <= cassettes[0].count && - j <= cassettes[1].count && - k <= cassettes[2].count && - i >= 0 && - j >= 0 && - k >= 0 - ) { - solutions.push([ - { - provisioned: i, - denomination: cassettes[0].denomination, - id: uuid.v4() - }, - { - provisioned: j, - denomination: cassettes[1].denomination, - id: uuid.v4() - }, - { - provisioned: k, - denomination: cassettes[2].denomination, - id: uuid.v4() - }, - { - provisioned: 0, - denomination: cassettes[3].denomination, - id: uuid.v4() - } - ]) + + for (let k = 0; k <= count2; k++) { + const thirdSum = secondSum + k * denomination2 + if (thirdSum > amountNum) break + + if (denomination2 === 0) break + + if (thirdSum === amountNum) { + solutions.push(newSolution(cassettes, i, j, k, 0, shouldFlip)) } - for ( - let l = 0; - i * cassettes[0].denomination + - j * cassettes[1].denomination + - k * cassettes[2].denomination + - l * cassettes[3].denomination <= - amountNum; - l++ - ) { - if (cassettes[3].denomination === 0) break - if ( - i * cassettes[0].denomination + - j * cassettes[1].denomination + - k * cassettes[2].denomination + - l * cassettes[3].denomination === - amountNum && - i <= cassettes[0].count && - j <= cassettes[1].count && - k <= cassettes[2].count && - l <= cassettes[3].count && - i >= 0 && - j >= 0 && - k >= 0 && - l >= 0 - ) { - solutions.push([ - { - provisioned: i, - denomination: cassettes[0].denomination, - id: uuid.v4() - }, - { - provisioned: j, - denomination: cassettes[1].denomination, - id: uuid.v4() - }, - { - provisioned: k, - denomination: cassettes[2].denomination, - id: uuid.v4() - }, - { - provisioned: l, - denomination: cassettes[3].denomination, - id: uuid.v4() - } - ]) + + for (let l = 0; l <= count3; l++) { + if ((x > MAX_AMOUNT_OF_SOLUTIONS && solutions.length >= 1) || x > MAX_BRUTEFORCE_ITERATIONS) break loop1 + x++ + const fourthSum = thirdSum + l * denomination3 + if (fourthSum > amountNum) break + + if (denomination3 === 0) break + + if (fourthSum === amountNum) { + solutions.push(newSolution(cassettes, i, j, k, l, shouldFlip)) } } } } } + + const endTime = new Date().getTime() + + console.log(`Exiting bruteforce after ${x} tries. Took ${endTime - startTime} ms`) + return solutions +} + +function makeChangeDynamic(cassettes, amount, available) { + while (_.size(cassettes) < 4) { + cassettes.push({ denomination: 0, count: 0 }) + } + + const amountNum = amount.toNumber() + + const solutions = makeChangeDynamicBruteForce(cassettes, amountNum, available) const sortedSolutions = _.sortBy(it => { const arr = [] @@ -206,5 +205,20 @@ function makeChangeDynamic (cassettes, amount) { _.head(sortedSolutions) ) - return cleanSolution + const response = cleanSolution + + // Final sanity check + let total = 0 + _.forEach(it => { + total += it.provisioned * it.denomination + }, response) + + if (total === amountNum) return response + + console.log( + `Failed to find a solution for ${amountNum} with cassettes ${JSON.stringify(cassettes)}` + ) + return [] } + +module.exports = { makeChange }