Making change via dynamic programming: easy when you see it

I failed to see this is an induction problem, and did not think of an elegant way to represent “we have no solution” for a given amount.

The problem description is “given a set of coins of n denominations, figure out the minimum number of coins needed to make change for a given amount. If you can’t make change, return -1.”

So here’s the induction:

  • Assume it’s impossible to make change for any amount up to the target amount by setting the number of coins to > the max amount. (For this problem it was 2**32, which I’ll refer to as “impossible”).
  • For an amount of 0, it takes 0 coins to make change.
  • For each amount after 0, the number of coins needed to make change for this amount is 1 of the current coin, plus however many coins it took to make change for amount - current coin value.
  • If we couldn’t make change for amount - current coin value (value for that is impossible), then try the next coin.
  • If we run out of coins, we can’t make change for that amount, leave it set to impossible, and we move on to the next amount up.
  • When we reach the final amount, we’ve either found a chain of sub-solutions that reach 0 (change is made with n coins, the sum of all the sub-solutions) or it’s impossible.

Because this builds a table of solutions from the bottom up, we’re always guaranteed that the solution for any amount < the current amount has already been solved, and we always try all the coins for every amount, so we’re guaranteed to find a solution if there is one, even if the coins are not in sorted order.

I chose to use int64‘s and set the FAILURE amount to an amount specified in the problem description as definitely larger than any amount that is possible. You could do it with a map[int]int, checking for “no entry”, but using FAILURE allows the lookback to always work with just a less-than test, so it’s probably faster. I’ve updated the variable names to make it clearer how this works.


func coinChange(coins []int, amount int) int {
    const FAILURE int64 = 5000000000

    // Assume everything is impossible...
    changeFor := make([]int64, amount+1)
    for i := 0; i < len(changeFor); i++ {
        // effectively Inf
        changeFor[i] = FAILURE
    }

    // ...except 0. Change for 0 is 0 (it takes no coins to make change
    // for 0.
    changeFor[0] = 0

    // For every amount up to the target amount (i.e., solve every
    // subproblem):
    for currentAmount := 1; currentAmount <= amount; currentAmount++ {

        // Try every coin we have to solve the problem.
        for _, coin := range(coins) {

            // If this coin might work...
            if coin <= currentAmount {

                // Get the answer for the subproblem.
                lookBack := changeFor[currentAmount - coin]
                
                 // This if statement is doing a lot of elegant
                 // heavy lifting.
                 //
                 // If the subproblem had no solution, the lookback is
                 // FAILURE, and FAILURE + 1 is greater than the current
                 // value (FAILURE or less). This guarantees that we don't
                 // overwrite any solution that was already found, and that
                 // we leave it as FAILURE for this coin!
                 //
                 // If the subproblem DID have a solution, and it's better
                 // (less) than what we have (some number of coins or
                 // FAILURE), then we change the current solution for this
                 // amount to the new count.
                 //
                 // This is why the order of the coins in the coin array
                 // doesn't matter: we will always try ALL the coins for
                 // a given amount, and if a different coin has a better
                 // (smaller) solution, we'll change the solution for the
                 // current amount for the better one!
                 //
                 // This means we're guaranteed to have the best solution
                 // for every amount < the current amount (including 0),
                 // so composing a solution for the current amount from
                 // previous solutions always gets the best one for this
                 // amount.
                 //
                 // The fact that it takes a comment THIS LONG to accurately
                 // explain TWO LINES OF CODE...!
                if lookBack + 1 < changeFor[currentAmount] {
                    // Previous number of coins solving this, plus one more
                    // for the current coin solving this amount better than
                    // we previously did. We never get here if we don't find
                    // a solution for any of the coins, leaving this set to
                    // FAILURE.
                    changeFor[currentAmount] = lookBack + 1
                }
            }
        }
    }
    // If we never found a solution, the caller wants -1.
    if changeFor[amount] == FAILURE {
        return -1
    }
    // Retrieve the solution from the solution cache. If we were
    // going to use this a lot, we'd save the cache.
    return int(changeFor[amount])
}

I hope this commented code shows how disgustingly elegant this is. I didn’t find this solution, but I feel like working through why it works has helped me get a better grasp on dynamic programming.

Lessons learned:

  • What can you solve by inspection?
  • How do you mark things as “not solved” or “insoluble”?
  • Don’t be afraid to solve a lot of seemingly unneeded subproblems if you can use them to break down the problem you have.

I initially tried a greedy solution for this problem, and that is actually optimal if the set of coins you’re given can always make up any number. (E.g., US coins are 1 cent, 5 cents, 10 cents, 25 cents, and 50 cents; you can use those to make any number of cents from 1 to 100.) If the set of coins doesn’t guarantee this, then you have to use the dynamic programming approach to solve it.

The second, recursive try might have worked if I’d cached solutions found along the way, effectively turning the iterative lop here into a recursive one, but I didn’t and it DNF’ed.

Right, stats. 79th percentile on runtime, 80th on memory. Pretty good.

Reply