From 5de4e21b2e97bf618312b881336f52716c00857e Mon Sep 17 00:00:00 2001 From: siiky Date: Mon, 20 Nov 2023 18:09:27 +0000 Subject: [PATCH] feat: use `coin-change` alongside subset-sum impl --- lib/bill-math.js | 28 ++++++++++-- lib/coin-change.js | 108 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 133 insertions(+), 3 deletions(-) create mode 100644 lib/coin-change.js diff --git a/lib/bill-math.js b/lib/bill-math.js index 0fad0933..589573e5 100644 --- a/lib/bill-math.js +++ b/lib/bill-math.js @@ -1,6 +1,9 @@ const _ = require('lodash/fp') const sumService = require('@haensl/subset-sum') +const logger = require('./logger') +const cc = require('./coin-change') + const BILL_LIST_MODES = { LAST_UNIT_FIRST: 0, FIRST_UNIT_FIRST: 1, @@ -103,7 +106,7 @@ const buildBillList = (units, mode) => { } } -const getSolution = (units, amount, mode) => { +const getSolution_old = (units, amount, mode) => { const billList = buildBillList(units, mode) if (_.sum(billList) < amount.toNumber()) { @@ -122,6 +125,13 @@ const getSolution = (units, amount, mode) => { ) } +const getSolution = (units, amount) => { + amount = amount.toNumber() + units = units.map(({ denomination, count }) => [denomination, count]) + const model = cc.model(units) + return cc.solve(model, amount) +} + const solutionToOriginalUnits = (solution, units) => { const billsToAssign = (count, left) => _.clamp(0, count)(_.isNaN(left) || _.isNil(left) ? 0 : left) @@ -141,8 +151,20 @@ const solutionToOriginalUnits = (solution, units) => { } function makeChange(outCassettes, amount) { - const solution = getSolution(outCassettes, amount, BILL_LIST_MODES.VALUE_ROUND_ROBIN) - return solutionToOriginalUnits(solution, outCassettes) + const ss_solution = getSolution_old(outCassettes, amount, BILL_LIST_MODES.VALUE_ROUND_ROBIN) + const cc_solution = cc.solve(cc.model(outCassettes), amount.toNumber()) + + if (!!ss_solution === !!cc_solution) { + logger.error(new Error(`subset-sum and coin-change don't agree on solvability -- subset-sum:${!!ss_solution} coin-change:${!!cc_solution}`)) + return solutionToOriginalUnits(ss_solution, outCassettes) + } + + if (!cc.check(cc_solution, amount.toNumber())) { + logger.error(new Error("coin-change provided a bad solution")) + return solutionToOriginalUnits(ss_solution, outCassettes) + } + + return solutionToOriginalUnits(cc_solution, outCassettes) } module.exports = { makeChange } diff --git a/lib/coin-change.js b/lib/coin-change.js new file mode 100644 index 00000000..1c9eab5e --- /dev/null +++ b/lib/coin-change.js @@ -0,0 +1,108 @@ +/* + * Greedy solver of the coin change problem, based on the following CHICKEN + * implementation: https://git.sr.ht/~siiky/coin-change + */ + +/* + * prepare_denominations([[d0, count], [d1, count], ...]) + * => [{ denom, count, csum }, ...] + */ +const prepare_denominations = denominations => + JSON.parse(JSON.stringify(denominations)) + .sort(([d1, c1], [d2, c2]) => d1 < d2) + .reduce( + ([csum, denoms], [denom, count]) => { + csum += denom*count + return [ + csum, + [{ denom, count, csum }].concat(denoms) + ] + }, + [0, []] + )[1] /* ([csum, denoms]) => denoms */ + +const max_denomination_multiplicity = (denom, count, target) => + Math.min(count, Math.floor(target / denom)) + +const has_divisor = (didx, denominations, target) => + denominations + .slice(didx) + .some(({ denom }) => (target % denom) === 0) + +/* + * @returns null if there's no solution set; + * false if there's no solution; + * solution if there's a solution + */ +const memo_get = (memo, target, denom) => { + const denom_solutions = memo[target] + if (denom_solutions === undefined) return null + const solution = denom_solutions[denom] + return solution === undefined ? null : solution +} + +const memo_set = (memo, target, denom, solution) => { + let denom_solutions = memo[target] + if (denom_solutions === undefined) + memo[target] = denom_solutions = {} + return denom_solutions[denom] = solution +} + +const check = (solution, target) => + !solution + || target === solution.reduce((sum, [denom, provisioned]) => sum + denom*provisioned, 0) + +const model = denominations => ({ + denominations: prepare_denominations(denominations), + memo: {} +}) + +/* + * target :: Int + * denominations :: [[d0, count], [d1, count], ...] + * + * @returns [[d0, provisioned], [d1, provisioned], ... ]; + * false if there's no solution. + */ +const solve = (model, target) => { + const { denominations, memo } = model + + const coin_change = (didx, target) => { + if (target === 0) return [] + + for (; didx < denominations.length; didx++) { + const { denom, count, csum } = denominations[didx] + + /* + * There's no solution if the target is greater than the cumulative sum + * of the denominations, or if the target is not divisible by any of the + * denominations + */ + if (target > csum || !has_divisor(didx, denominations, target)) + return memo_set(memo, target, denom, false) + + let solution = memo_get(memo, target, denom) + if (solution === false) continue /* not here, keep looking */ + if (solution) return solution /* we've previously computed a solution */ + + /* solution === null */ + for (let nd = max_denomination_multiplicity(denom, count, target); nd >= 0; nd--) { + solution = coin_change(didx+1, target - denom*nd) + if (solution) + return memo_set(memo, target, denom, [[denom, nd]].concat(solution)) + } + + memo_set(memo, target, denom, false) + } + + return false + } + + return coin_change(0, target) +} + +module.exports = { + check, + model, + solve, +}