Making Change
1. Problem statement
Aziz et al. (2018, p. 253) mentions this problem but uses the analogy of points scored in a sports game, where the game has some number of "plays" defined and each play is worth a fixed number of points. The problem remains the same.
Given some US monetary amount \(A\), count how many different combination of coins can be used to reach that sum. The recognized coins are pennies, nickels, dimes, and quarters.
Input: $0.10 Output: 4 (1 dime, 2 nickels, 1 nickel and 5 pennies, 10 pennies)
2. Insights
This problem is a special case of the integer partitioning problem, where the problem is to find all the ways of partitioning a positive integer into other integers whose sum equals the starting integer. In this case we are given only a particular set of integers \(\{1, 5, 10, 25\}\) that we can use, whereas the integer partitioning problem considers the set of all positive integers \(\{1, 2, 3, 4, 5, \cdots{}\}\).
The key to using dynamic programming in this problem is to recognize that computing the solution for the amount \(A\) is related to finding the solution for the sum \(A-1\) (successively smaller amounts), and also the solution for the cases with fewer and fewer coins available (down to the empty set of coins).
3. Solution
3.1. Brute force
You could rewrite the problem statement as the following equation
\[ A = p + 5n + 10d + 25q \]
such the question could be rephrased as finding all non-negative integer variables \(p\), \(n\), \(d\), \(q\) that sum up to \(A\).
The obvious brute force approach would be to enumerate across each of these coin
types (4 for
loops, nested together), where the lower bound is 0
and the
upper bound is \(A\). And then whenever we find a set of variables that work,
we can save this in a hash table to remember that winning combination. Then when
we're done iterating, we can just count how many entries there are in the hash
table to get the total number of combinations of coins that sum to \(A\).
It's a bit tricky to use a dynamic number of nested loops though (we don't know
how many number of coins we'll be given), so instead we can use a composite
counter variable. For the case of coins \(\{1, 5, 10, 25\}\), we have a list of 4
zeroes (one for each variable). We count from [0, 0, 0, 0]
to the point we
are "maxed out," where "maxed out" means that we have tried out all possible
combinations of coins. Assuming the target amount is 100 cents, we would
increment the variables like this:
[0, 0, 0, 0] [1, 0, 0, 0] [2, 0, 0, 0] [3, 0, 0, 0] ... [100, 0, 0, 0] [0, 1, 0, 0] [1, 1, 0, 0] [2, 1, 0, 0] [3, 1, 0, 0] ... [100, 1, 0, 0] [0, 2, 0, 0] [1, 2, 0, 0] [2, 2, 0, 0] [3, 2, 0, 0] # 3 pennies, 2 nickels ... [100, 20, 0, 0] [0, 0, 1, 0] [1, 0, 1, 0] [2, 0, 1, 0] ... [100, 20, 10, 0] # 100 pennies, 20 nickels, 10 dimes [0, 0, 0, 1] [1, 0, 0, 1] [2, 0, 0, 1] [3, 0, 0, 1] ... [100, 20, 10, 4] # Maxed out (each coin denomination totals 100 cents)
You can see how this solution, while correct, has terrible performance because every additional coin denomination will essentially give us another "nested loop". So the time complexity is \(O(a^d)\) where \(a\) is the target amount and \(d\) is the number of coin denominations.
3.2. Brute force using DFS
This approach treats the problem as a tree problem; you can use depth-first search over a decision tree. The root node of this tree is the target amount. Then you have child nodes equal to the number of coin types, such that picking a coin reduces the value from the root node by the value of the chosen coin. You do this repeatedly at each level, reducing the value you've started with from the root node. When you reach a child node of zero value, the coins you've picked to get there are solutions to the equation.
You can use a hash table to speed up the search here by memoizing (remembering) previously seen results (child nodes). This way, you won't recompute already-computed values all over again.
The time complexity is \(O(a*d)\), because we only do work for each unique
possible (amt, i)
argument for the dfs()
helper function once because
every repeated call with a previously seen argument will be cached. Because
there are \(amt * i\) different possible arguments, the time complexity is also
\(O(a*d)\).
Figure 1: Decision tree (partially drawn) for \(A = 10\) and coins \(\{1, 5, 10\}\).
3.3. Dynamic programming (DP)
The tree-driven approach above gives us a clue about breaking down the original problem into sub-problems. What if starting with the target sum and choosing among all possible coin denominations, we started out with 0 and no coins, successively building up these smaller problems, slowing increasing the sum on one dimension and the number of coin types allowed in the other dimension? This is the key to the dynamic programming approach.
To be fair, the DFS solution above could also be solved the same way (start from amount 0, then build out our tree to find all sums that reach the amount we want) — but the downside is that the shape of the final tree (search paths) is not really predictable. The advantage of the DP solution is that the data structure we use (a 2D array) is always consistent and simple, making it also much easier to reason about. Plus, we don't need to keep a hash table around, further simplifying the data structures involved.
Let's talk about the table. For the case of 10 cents and pennies, nickels, and dimes (we'll skip quarters because the amount of 10 cents is too small to consider quarters), we first begin constructing the table with the columns set to the amounts leading up to 10 cents and the rows describing the different ways in which we can get the amount using the coin.
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | |
---|---|---|---|---|---|---|---|---|---|---|---|
p | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 |
This table says "no matter what amount, if we're only using pennies, there is always only 1 combination of coins we can use to reach that amount — all pennies, every time". Note that also the amount 0 has a value of 1; this encodes the act of being unable to use pennies to get to a sum of 0 cents, by using no pennies.
The mathematics here are subtle, but suffice it to say that we need to use a value of 1 for the case of 0 cents, to act as our base case. In fact, this is how we would start filling in the table for the very first coin — by starting out with this initial cell value of 1 for amount 0. The algorithm is then as follows: for each empty cell on the right, use the column that is penny spaces away on the left. Because the penny has a value of 1, we use the column immediately to the left. This is why all cells have the same value of 1 because the initial 1 value is copied over to the right.
The above description is a mechanical description of how we can construct this table when only pennies are involved, but there's a deeper explanation. The point is that to fill out the current cell \(p_a\) where \(a\) is the amount, we use the following formula:
\[ p_a = p_{a - 1} + x_a \]
where \(p_{a - 1}\) is the number of combinations to make change for an amount reduced by the value of the penny (\(a - 1\) because the penny is worth \(1\)) and \(x_a\) is the answer for the total number of combinations of making change with the set of coins without the penny for the same amount. The \(p_{a - 1}\) is what we described in the previous paragraph — it's the column to the immediate left. But what about \(x_a\)? Well actually, the table starts out with a default row, the empty set of coins (where we have no coins). So the real table looks like this initially:
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | |
---|---|---|---|---|---|---|---|---|---|---|---|
\(\{\}\) | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
This table reads: if there are no coins available, then if the amount we need is 0, there is 1 way to make change — by choosing nothing. However, if we need to make change for some amount greater than 0, we are unable to fulfill that request because we don't have any coins we can use. So because we are unable to honor the request, we put in a \(0\) for those positive sums.
Now let's go back to filling out the row for the pennies.
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | |
---|---|---|---|---|---|---|---|---|---|---|---|
\(\{\}\) | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
\(\{p\}\) | 1 | ? | ? | ? | ? | ? | ? | ? | ? | ? | ? |
For amount 0, the answer is always 1 (as discussed previously). For amount 1, we use the formula \(p_{a - 1} + x_a\), which in this case is \(1 + 0\) (the 1 comes from the answer for amount \(1 - 1 = 0\), and the 0 comes from the row above, for the empty set which does not have the penny available). In this fashion, we can fill out the row again, one cell at a time, and each time the solution is \(1 + 0 = 1\). So now we get the following:
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | |
---|---|---|---|---|---|---|---|---|---|---|---|
\(\{\}\) | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
\(\{p\}\) | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 |
What about nickels? Let's see how nickel behave in isolation first, and then let's consider them in conjunction with pennies.
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | |
---|---|---|---|---|---|---|---|---|---|---|---|
\(\{\}\) | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
\(\{n\}\) | 1 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 0 | 0 | 1 |
The table tells us that nickels can only make change (with itself) if the amount is divisible evenly by 5, or if the amount is 0. If we add in pennies to the mix, then the table gets more interesting because we gain the ability to make change for amounts that are not divisible by 5 (by using pennies).
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | |
---|---|---|---|---|---|---|---|---|---|---|---|
\(\{\}\) | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
\(\{p\}\) | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 |
\(\{p, n\}\) | 1 | 1 | 1 | 1 | 1 | 2 | 2 | 2 | 2 | 2 | 3 |
For nickels, our formula is
\[ n_a = n_{a - 5} + p_a. \]
The \(p_a\) is there because, we want to consider all the ways we can make the amount \(a\) using pennies (we're building on top of that answer). For the \(n_{a - 5}\), we are also building on top of a previous answer — the case where the amount was exactly one nickel's worth smaller than the current amount. Again for the first column (amount 0), the answer is 1 by definition. But for all subsequent amounts we have to use our formula. For amounts 1 through 4, the \(n_{a-5}\) term is zero because there is no corresponding value (it would point to a negative amount also, which is not valid). But for the amount of 5 cents, we can use the nickel as the amount is big enough.
It's worth repeating the meaning of the formula in plain English. The formula written above means, "the count of all combinations of nickels and pennies for an amount \(a\) is equal to (1) the number of combinations without using nickels for the same amount (\(p_a\)) and (2) the number of combinations of using a nickel to reduce the amount by 5 cents and whatever the overall answer is for that reduced amount (\(n_{a - 5}\))." The second step might be a bit unintuitive, but it makes sense if you think about the DFS solution. There, each time we decide to use a coin of some value, we reduced the amount by that value and we had to choose all over again at the next tree depth down. Going left on the table is akin to choosing to use the current coin denomination (because the amount is large enough) in DFS, but which in and of itself doesn't give us any answers (we still need to solve for the subproblem of the reduced amount using nickels and pennies).
We can use the same reasoning to fill out the rest of the table with dimes and quarters. The table below uses increments of 5 to save space, but the ideas are the same as before.
0 | 5 | 10 | 15 | 20 | 25 | 30 | 35 | 40 | 45 | 50 | |
---|---|---|---|---|---|---|---|---|---|---|---|
\(\{\}\) | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
\(\{p\}\) | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 |
\(\{p, n\}\) | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
\(\{p, n, d\}\) | 1 | 2 | 4 | 6 | 9 | 12 | 16 | 20 | 25 | 30 | 36 |
\(\{p, n, d, q\}\) | 1 | 2 | 4 | 6 | 9 | 13 | 18 | 24 | 31 | 39 | 49 |
The time complexity is \(O(a*d)\) because there are two loops, one for the number
of coins \(d\) and another nested one inside it for the amount \(a\). The space
complexity is also \(O(a*d)\) because we create a about \(a*d\) cells (not counting
the initial [1]
cell for each row's first column).
3.4. Using \(O(a)\) space
The solution below only uses one row of the table at a time, essentially collapsing the rows as we build up to the last row.
3.5. Generating functions
Generating functions (Knuth, 1997, sec. 1.2.9) give us a \(O(1)\) time solution, but the implementation is omitted here because the solution boils down to a series of algebraic manipulations. It is purely mathematical and would require implementing functionality found in computer algebra systems, which transcends the original spirit of this problem from a coding sense.