DSA Coin Change II — Counting Ways
Coin Change II counts the distinct combinations of coins that sum to a target amount. A combination ignores order, so the coins 1 and 2 in either order count as one way.
The problem
Coin Change II counts the combinations of coins that sum to a target amount. A combination is a multiset of coins, meaning a set of coins where repeats count but order does not.
Coins may repeat without limit. Each denomination in the input is distinct. Amount zero has exactly one combination, the empty choice of no coins.
The cases used in this article
cases = [
([1, 2, 5], 5, 4),
([1, 3, 4], 6, 4),
([1, 2], 5, 3),
([2], 3, 0),
([1, 2, 5], 0, 1),
]
for coins, amount, expected in cases:
print("coins", coins, "amount", amount, "combinations:", expected)Read the first row by listing its combinations. They are 5, 2 + 2 + 1, 2 + 1 + 1 + 1, and five 1 coins. Four combinations make 5.
Intuition: counting orderings is the trap
The first idea builds amounts one coin at a time, in any order. Each coin extends every smaller amount. That idea counts each ordering of the same coins as its own way.
The any-order count on coins 1 and 2
coins = [1, 2]
amount = 5
dp = [0] * (amount + 1)
dp[0] = 1
for a in range(1, amount + 1):
for coin in coins:
if coin <= a:
dp[a] += dp[a - coin]
print("orderings for 5:", dp[5])
print("combinations for 5:", 3)
print("the combinations:", ["2+2+1", "2+1+1+1", "1+1+1+1+1"])The count 8 is correct for orderings. It is wrong for this problem. The coins 2, 2 and 1 appear in three different orders inside that count, and all three are one combination.
Brute force: recursion over coin types
The brute force decides coin type by coin type. For one type it either takes another copy or moves on to the next type. A call with nothing left to make is one finished combination.
Counting combinations by plain recursion
calls = 0
def ways(idx, left, coins):
global calls
calls += 1
if left == 0:
return 1
if idx == len(coins) or left < 0:
return 0
take = ways(idx, left - coins[idx], coins)
skip = ways(idx + 1, left, coins)
return take + skip
for amount in [5, 11]:
calls = 0
result = ways(0, amount, [1, 2, 5])
print("amount", amount, "combinations:", result, "calls:", calls)The recursion is correct, and it is wasteful. It recomputes the same pair of coin type and remaining amount many times. The call count grows from 47 to 193 while the amount roughly doubles. A table can store each pair once.
The optimal solution: one coin type at a time
The state is dp[a], the number of combinations that make amount a using only the coin types processed so far. That one sentence is the whole method.
The coin loop runs outside and the amount loop runs inside, upward. One coin type is fully added before the next type starts. Each combination is therefore built in one fixed coin order and counted once.
The coin-outer table on every case
def count_ways(coins, amount):
dp = [0] * (amount + 1)
dp[0] = 1
for coin in coins:
for a in range(coin, amount + 1):
dp[a] += dp[a - coin]
return dp[amount]
cases = [
([1, 2, 5], 5, 4),
([1, 3, 4], 6, 4),
([1, 2], 5, 3),
([2], 3, 0),
([1, 2, 5], 0, 1),
]
for coins, amount, expected in cases:
result = count_ways(coins, amount)
print("coins", coins, "amount", amount, "->", result, "expected:", expected, "match:", result == expected)All five cases match. Amount zero keeps its single empty combination, and the unreachable row answers 0. The companion Coin Change lesson counts the fewest coins for these coin sets. This lesson counts how many combinations exist.
Walkthrough: amount 5 with coins 1, 2 and 5
The walk grows the table one coin type at a time. Each printed row is the full table after one type is fully processed. The index of each entry is its amount.
The table after each coin type
coins = [1, 2, 5]
amount = 5
dp = [0] * (amount + 1)
dp[0] = 1
for coin in coins:
for a in range(coin, amount + 1):
dp[a] += dp[a - coin]
print("after coin", coin, "table:", dp)
print("combinations for 5:", dp[5])Read the answer off the last cell. After coin 2 the table already holds 3 combinations for amount 5. Coin 5 adds exactly one more, the single coin 5 itself.
Complexity
Time is O(coins times amount). Each coin type scans the amounts from its own value up to the target amount once.
Space is O(amount). One row holds a count for every amount up to the target.
Counting the table updates
for amount in [5, 10, 20]:
dp = [0] * (amount + 1)
dp[0] = 1
updates = 0
for coin in [1, 2, 5]:
for a in range(coin, amount + 1):
updates += 1
dp[a] += dp[a - coin]
print("amount", amount, "- updates:", updates, " combinations:", dp[amount])The update count grows with the product of coins and amount. It stays in the dozens where the plain recursion needed hundreds of calls. The table trades a small row of memory for that gap.
Edge cases and pitfalls
An unreachable amount answers 0, not an error. A coin larger than the amount is simply never usable. No coins at all still leaves the empty combination for amount zero.
Edge cases, run
def count_ways(coins, amount):
dp = [0] * (amount + 1)
dp[0] = 1
for coin in coins:
for a in range(coin, amount + 1):
dp[a] += dp[a - coin]
return dp[amount]
print("no coins, amount 5:", count_ways([], 5))
print("no coins, amount 0:", count_ways([], 0))
print("coin too big:", count_ways([5], 3))
print("one coin type, repeated:", count_ways([2], 4))
print("odd amount, even coin:", count_ways([2], 3))A repeated denomination is the quiet pitfall. The method treats each list entry as its own coin type, so a duplicated 1 inflates the count.
A duplicated denomination, run
def count_ways(coins, amount):
dp = [0] * (amount + 1)
dp[0] = 1
for coin in coins:
for a in range(coin, amount + 1):
dp[a] += dp[a - coin]
return dp[amount]
print("coins [1, 1], amount 2:", count_ways([1, 1], 2))
print("coins [1], amount 2:", count_ways([1], 2))Swapping the two loops is the other classic mistake. Amounts in the outer loop count orderings, as the intuition section showed. The loop order is not a style choice here. It decides what gets counted.
Exercise
A program must count each set of coins once, ignoring order. Which loop order is correct?