const _ = require('lodash/fp') const uuid = require('uuid') const MAX_AMOUNT_OF_SOLUTIONS = 10000 const MAX_BRUTEFORCE_ITERATIONS = 10000000 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 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) { // Initialize empty cassettes in case of undefined, due to same denomination across all cassettes results in a single merged cassette const small = cassettes[0] ?? { denomination: 0, count: 0 } const large = cassettes[1] ?? { denomination: 0, count: 0 } 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() } ] } return [] } 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 = [] 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; 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; 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 = [] for (let la = 0; la < 4; la++) { arr.push(cassettes[la].count - it[la].provisioned) } if (arr.length < 2) return Infinity return _.max(arr) - _.min(arr) }, solutions) const cleanSolution = _.filter( it => it.denomination > 0, _.head(sortedSolutions) ) 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 }