DSA Coin Change
Coin change asks for the fewest coins that sum to a target amount. Greedy fails on it, brute force repeats the same subproblems, and one table filled bottom up fixes both.
The problem
You have coins of a few values. You have as many of each coin as you need. The question asks for the fewest coins that add up to a target amount.
The answer is a count, not the coins themselves. If no mix of coins reaches the amount, the answer is -1. An amount of 0 needs no coins, so its answer is 0.
The cases used in this article
cases = [
([1, 2, 5], 11, 3),
([1, 3, 4], 6, 2),
([2], 3, -1),
([2, 5, 10], 27, 4),
]
for coins, amount, expected in cases:
print("coins", coins, "amount", amount, "fewest:", expected)Read one row at a time. Coins [1, 2, 5] make 11 as 5 + 5 + 1, which is three coins. Coins [1, 3, 4] make 6 as 3 + 3, which is two coins. A single 2 coin can never make 3, so that row answers -1.
Why greedy fails
The first idea most people try is greedy. Take the largest coin that fits. Repeat until nothing is left.
Greedy is fast, and it works for real money in many countries. It fails on made-up coin sets. The failure is quiet: greedy still returns an answer, just not the smallest one.
Greedy on coins [1, 3, 4], amount 6
coins = [1, 3, 4]
amount = 6
remaining = amount
greedy = []
for coin in sorted(coins, reverse=True):
while remaining >= coin:
greedy.append(coin)
remaining -= coin
print("greedy coins:", greedy)
print("greedy count:", len(greedy))
print("better coins: [3, 3]")
print("better count:", 2)Greedy grabs the 4 first. That one grab blocks the better pair. The rest of the amount can then only be filled with 1s, and the count climbs to three.
The lesson is general. A choice that looks best now can block a better mix later. A correct method has to consider more than the first grab.
Brute force: try every last coin
Every solution for an amount has a last coin. Some coin was placed last, and before it sat a solution for the smaller amount that remains.
That gives a recipe. To solve amount a, try each coin as the last coin. For each try, solve a minus that coin, then add one. Keep the smallest total.
The recursive brute force
calls = 0
def fewest(amount):
global calls
calls += 1
if amount == 0:
return 0
best = None
for coin in [1, 3, 4]:
if coin <= amount:
rest = fewest(amount - coin)
if rest is not None:
best = rest + 1 if best is None else min(best, rest + 1)
return best
print("fewest(6):", fewest(6))
print("calls:", calls)The code finds the right answer: two coins. It also makes many calls to get it. The counter prints the bill.
The base case is amount 0, which needs no coins. An amount that no coin fits returns None. None means this branch cannot be completed, so the caller skips it.
The same subproblems repeat
Look at what the recursion computes. fewest(6) needs fewest(5), fewest(3) and fewest(2). Each of those needs smaller amounts again. The same amounts come back many times.
Repeated subproblems are the signal for dynamic programming. Solve each amount once. Store the answer. Reuse it every later time it is asked for.
The same recursion with a memo
calls = 0
memo = {0: 0}
def fewest(amount):
global calls
calls += 1
if amount in memo:
return memo[amount]
best = None
for coin in [1, 3, 4]:
if coin <= amount:
rest = fewest(amount - coin)
if rest is not None:
best = rest + 1 if best is None else min(best, rest + 1)
memo[amount] = best
return best
print("fewest(6):", fewest(6))
print("calls:", calls)
print("memo:", memo)The memo holds one entry per amount. Each amount is computed once and read many times, so the call count drops even on this tiny amount. The shape of the fix is now visible: the stored answers are a table, and the table can be filled directly, without recursion.
The optimal solution: one table, bottom up
Fill a table in increasing order instead. dp[a] means the fewest coins that make amount a. That one sentence is the state, and the whole solution follows from it.
Start with dp[0] = 0. For each later amount, try every coin as the last coin. The candidate is dp[a - coin] + 1. Keep the smallest candidate.
One problem remains. Some amounts cannot be made at all. The table needs a marker for them, so the code uses a sentinel: amount + 1. No real answer can reach that value, because even an all-smallest-coin solution uses at most amount coins. A cell that still holds the sentinel at the end reports -1.
The bottom-up solution
def coin_change(coins, amount):
sentinel = amount + 1
dp = [sentinel] * (amount + 1)
dp[0] = 0
for a in range(1, amount + 1):
for coin in coins:
if coin <= a:
dp[a] = min(dp[a], dp[a - coin] + 1)
return -1 if dp[amount] == sentinel else dp[amount]
cases = [([1, 2, 5], 11), ([1, 3, 4], 6), ([2], 3), ([2, 5, 10], 27)]
for coins, amount in cases:
print("coins", coins, "amount", amount, "fewest:", coin_change(coins, amount))All four cases from the start of the article pass. The greedy counterexample now answers two. The impossible case answers -1.
Walkthrough: filling the table for 6
Watch the table fill for coins [1, 3, 4] and amounts 0 to 6. The sentinel here is 7. A 7 in a cell means no mix found yet.
The table after each amount
coins = [1, 3, 4]
amount = 6
sentinel = amount + 1
dp = [sentinel] * (amount + 1)
dp[0] = 0
print("start:", dp)
for a in range(1, amount + 1):
for coin in coins:
if coin <= a:
dp[a] = min(dp[a], dp[a - coin] + 1)
print("after amount", a, ":", dp)Amount 1 takes one 1 coin. Amount 2 takes two. Amount 3 drops to one the moment the 3 coin is tried as its last coin.
Amount 6 is the interesting cell. Its three tries read dp[5] + 1 = 3, dp[3] + 1 = 2 and dp[2] + 1 = 3. The smallest is 2, from the 3 coin last. That matches 3 + 3.
Complexity
The table has amount + 1 cells. Each cell tries every coin once. The work is amount times the number of coins: O(amount x coins) time.
The memory is the table alone: O(amount) space. The memoised recursion uses the same table, plus a call stack. The bottom-up version drops the stack.
Counting the work instead of timing it
def dp_steps(coins, amount):
steps = 0
dp = [amount + 1] * (amount + 1)
dp[0] = 0
for a in range(1, amount + 1):
for coin in coins:
steps += 1
if coin <= a:
dp[a] = min(dp[a], dp[a - coin] + 1)
return steps
for amount in [10, 20, 40]:
print("amount", amount, "steps:", dp_steps([1, 3, 4], amount))
calls = 0
def fewest(amount):
global calls
calls += 1
if amount == 0:
return 0
best = None
for coin in [1, 3, 4]:
if coin <= amount:
rest = fewest(amount - coin)
if rest is not None:
best = rest + 1 if best is None else min(best, rest + 1)
return best
fewest(20)
print("brute force calls for amount 20:", calls)Counts beat clocks here. The table's step count doubles when the amount doubles, exactly as the formula says. The brute-force call count for one small amount already dwarfs the table's whole run.
Edge cases and pitfalls
Amount 0 answers 0. The table starts with dp[0] = 0, and the function returns it before any coin is tried.
A coin larger than the amount is simply skipped. The coin <= a check is what skips it. Drop that check and the index a - coin goes negative, which in Python quietly reads the wrong cell instead of crashing.
The sentinel must be larger than any real answer. amount + 1 qualifies. A smaller marker, like 999 on a huge amount, can collide with a real count and report a possible amount as impossible.
Edge cases, run
def coin_change(coins, amount):
sentinel = amount + 1
dp = [sentinel] * (amount + 1)
dp[0] = 0
for a in range(1, amount + 1):
for coin in coins:
if coin <= a:
dp[a] = min(dp[a], dp[a - coin] + 1)
return -1 if dp[amount] == sentinel else dp[amount]
print("amount 0:", coin_change([1, 3, 4], 0))
print("no coin fits:", coin_change([5], 3))
print("odd target, even coins:", coin_change([2], 3))
print("coins unordered:", coin_change([4, 1, 3], 6))
print("one big coin:", coin_change([2, 5, 10], 10))The order of the coins changes nothing. The table tries every coin at every amount, so a shuffled coin list gives the same answers.
Exercise
A coin table for coins [2] shows dp(3) still holding the sentinel. What does the code report for amount 3?