Thoughts, Ideas, and Intrigue

Dynamic Programming - The Algorithm Behind Making Change

Have you ever gone to a grocery store or a fast food restaurant and paid for your food in cash? And perhaps they got one of those fancy coin boxes that automatically dispenses the coins when the cashier hands you back the bill portion of your change?

Have you also gone on to wonder how those coin changers were able to caculate what's the most efficient way to give back change, i.e. the least number of coins it can give back for a certain amount?

Example

If the box had only pennies, dimes, and quarters left, what coins should it give back for a 30 cent change? Would it first start by giving the highest value coin first, and then work it's way down, i.e. a quarter and 5 pennies, or does it iterate all possible combinations and figure out that by giving 3 dimes, it would effectively give only 3 coins instead of 6 for the same amount?

The Standard Approach

A simple algorithm would simply start giving out change starting with the largest denominator and work its way down.

Using the same constraint as earlier, with only pennies, dimes, and quarters, we can construct the algorithm as follows.

def make_change(amount, coins=[25,10,5,1])  
  coins = coins.sort.reverse # start with the largest coins
  change = []
  coins.each do |coin|
    while (amount - coin) > 0
      change << coin # put coin in the change list
      amount -= coin
    end
  end
  change
end

make_change(30, [25,10,1]) # [25,1,1,1,1,1]

By working from the largest coins first, and working our way down, we would get a solution that is 1 quarter and 5 pennies (no nickels, remember?). This is pretty good. It gives a decent solution, and it's mighty fast. But, as we have noticed, this isn't the optimal solution, since we could have achieved a 30 cent change by giving only three dimes.

Exhaustive Search

Another approach would be to exhaust all possible change conbinations that would give you the right amount and select the combination that requires the least number of coins. I say combination for the sake of generality and coherence, but the algorithm actually solves for all permutations. Combination refers to the fact that order does not matter, much like a set, e.g. {1,2} is the same as {2,1}, whereas in permutations, order does matter, e.g. [1,2] and [2,1] are two different entities.

def make_change(amount, coins=[25,10,5,1])  
  coins = coins.sort.reverse
  possible_combos = []
  iterate_combos(amount, coins, possible_combos)
  possible_combos.min_by(&:length)
end

def iterate_combos(amount, coins, current_change=[], possible_combos)  
  if amount == 0
    return possible_combos << current_change
  end

  coins.each do |coin|
    if coin <= amount
      new_combo = current_change.dup << coin
      new_amount = amount - coin
      iterate_combos(new_amount, coins, new_combo, possible_combos)
    end
  end
end 

p make_change(30, [25,10,1]) # [10,10,10] => 3 dimes  

Since we calculated for all possible combinations, we find that the optimal solutions is 3 dimes for a change of 30 cents.
The efficiency of this algorithm... isn't so efficient. For example, image that in Catland there are three different types of coins, but each valuing only 1 cent. If you had to give a 30 cent change amount, this search algorithm would provide you with 3^30 possible permutations of what coins to give back - that's a lot. The time complexity would be O(n^m), where m is the amount, and n is the number of coin denominations. That's for demonstration purposes as the actual complexity is calculated to be less in the real world when you have higher valued coins like quarters and dimes, which will reduce the coefficient only by a scalar value. However, the time complexity is still polynomal time, making the exhaustive search prohibitively expensive.

Enter Dynamic Programming

You are astute to notice that by calculating all permutations, we were essentially solving for the same combination more than once.
Dynamic programming is a way to break up a big problem into many subproblems. It's very similar to recursions, except that dynamic programming is usually iterative and saves the optimal solutions of the sub problems you have already solved as reference. This is achieved by solving for the smaller or sub problems first and working your way up.

That is, once you already know the optimal solution for a 10 cent change is a dime, you can always refer back to this fact when you you are iterating through possible solutions at 20 or 30 cents that requires the solution to a 10 cent change.

In this next example, I decided to make a CoinBox class that would hold state of some of the variables and allow for the encapsulation of the methods.

class CoinBox  
  def initialize(coins = [1, 5, 10, 25])
    @coins = coins.sort.reverse
    @changes = []
  end

  def make_change(amount)
    return @changes[amount] if @changes[amount]
    @changes.length.upto(amount) do |amt|
      @changes[amt] = _get_min_coins(amt)
    end
    @changes[amount]
  end

  def _get_min_coins(amt)
    possible_combos = {}
    @coins.each do |coin|
      if coin <= amt
        rem = amt - coin
        possible_combos[coin] = [coin] + Array(@changes[rem])
      end
    end
    smallest_combo = possible_combos.values.min_by(&:length)
  end
end


box = CoinBox.new([1,10,25])  
p box.make_change(30) # [10,10,10] => 3 dimes  

The make_change method essentially iterates from 1 to the change amount, and stores the optimal coin combintation at each location referencing the amount.

The juice of the dynamic programming magic lies in the _get_min_coins method where the optimal solution is actually calculated. This method goes through each coin and asks - if I take this coin, what is the optimal solution at the remainder amount? That is, if the amount is 20 cents, if we choose a dime, what is the optimal solution at 10 cents, versus if we choose a penny, what is the optimal solution at 19 cents? Since 10 and 19 cent sub problems have already been solved for by the time we reach this problem, by choosing the dime, we know that the 10 cent sub problem is much better off than the 19 cent sub problem. Therefore, 2 dimes at 20 cents is the optimal solution.

OMG We Solved An NP Hard Problem!

Not really...

This algorithm effectively eliminates the need to iterate through all possible solutions. By working from small problems first and building onto bigger problems, we are able to solve the entire problem in linear time. The linear time complexity is based on O(m*n), where m is the amount, and n and number of coins.

It wasn't that we solved an NP hard problem, it was just that for this particular problem, we were able to sequentailly work through each subproblem, which is much easier than to exhaust all combinations.