Algorithms

Coin Change & Dynamic Programming — exercises

Recognize a problem as having overlapping sub-problems and optimal substructure; identify a state variable and a recurrence that combines smaller solutions into larger ones; tabulate from the base case upward in O(states × choices) time.

1 worked example · 7 practice problems · 2 check problems

Worked example: minimum coins where greedy fails

Problem. Compute the minimum number of coins needed to make change for amount using denominations . Note: greedy (always pick the largest coin that fits) gives coins. DP gives the optimum.

Diagnosis. The problem has optimal substructure: the best way to make change for amount uses some coin as its last coin, then optimally makes change for . It has overlapping subproblems: amount 4 shows up in computing both amount 5 and amount 6. Both properties together motivate tabulating the answer bottom-up. Define as the minimum coins for amount .

Predict before reading on: predict the answer by trying small denominations first. Does any combination of coins from sum to 6 in fewer than 3 coins?

Recurrence.

Tabulation. Fill from upward:

icandidatesdp[i]last coin used
00
1dp[0]+1 = 111
2dp[1]+1 = 221
3dp[2]+1 = 3, dp[0]+1 = 113
4dp[3]+1 = 2, dp[1]+1 = 2, dp[0]+1 = 114
5dp[4]+1 = 2, dp[2]+1 = 3, dp[1]+1 = 224 or 1
6dp[5]+1 = 3, dp[3]+1 = 2, dp[2]+1 = 323

Result. . The optimal solution uses , beating greedy's .

Predict before reading on: why does greedy fail here? Look at the moment greedy commits to picking the 4-cent coin. What information does it not have, that DP does?

Greedy vs DP. Greedy commits to the locally-best choice (largest coin that fits) without considering what happens to the remaining amount. For amount 6 with greedy: pick 4, leaves 2, two 1-cents → 3 coins. Greedy can't "undo" the choice of 4 to consider 3+3. DP, by examining every possible last coin at each step, finds the global optimum at the cost of work — for this problem, 18 ops.

Verification.

def min_coins(coins, amount):
    INF = float('inf')
    dp = [INF] * (amount + 1)
    dp[0] = 0
    for i in range(1, amount + 1):
        for c in coins:
            if c <= i:
                dp[i] = min(dp[i], dp[i - c] + 1)
    return dp[amount] if dp[amount] != INF else -1

print(min_coins([1, 3, 4], 6))    # 2

Articulate: state in one sentence what the two ingredients of a DP-solvable problem are.


Practice problems

Seven problems across seven different DP "shapes." For each one: identify the state variable(s), write the recurrence, decide the order of computation. The structural move is always the same — the recurrence is what changes.

P.1 count ways (unordered combinations)

Same coins , but instead of asking for the minimum number of coins, count how many distinct ways there are to make change for amount . (Combinations, not permutations — and are the same.)

Find the analogue: the worked example's min recurrence took the minimum over candidate last coins. To count ways, replace min with sum — but be careful to avoid double-counting permutations.

show answer

Answer: 4 ways. They are: .

To avoid double-counting orderings, fix the coin order: process coins one at a time, allowing repeats. State = number of ways to make amount using the coins considered so far.

def count_ways(coins, amount):
    dp = [0] * (amount + 1)
    dp[0] = 1                          # empty combination
    for c in coins:                    # outer loop over coins fixes their order
        for i in range(c, amount + 1):
            dp[i] += dp[i - c]
    return dp[amount]

print(count_ways([1, 2, 5], 5))        # 4

The order of the two loops matters: coins outside, amount inside counts unordered combinations. Amount outside, coins inside counts permutations (often called "compositions") — different problem, different answer.

P.2 0/1 knapsack with capacity constraint

Four items with weights and values . A knapsack with capacity can hold any subset of items whose total weight . Each item can be taken at most once (0/1). What's the maximum total value?

Find the analogue: the state is no longer just "amount remaining" — it's a pair where indexes how many items we've considered and is the remaining capacity. The recurrence at each cell asks "include this item or skip it?"

show answer

Answer: 7 (take items 1 and 2 with weights 2+3 = 5, values 3+4 = 7).

def knapsack(W, V, C):
    n = len(W)
    dp = [[0]*(C+1) for _ in range(n+1)]
    for i in range(1, n+1):
        for c in range(C+1):
            dp[i][c] = dp[i-1][c]                                  # skip item i
            if W[i-1] <= c:
                dp[i][c] = max(dp[i][c],
                               dp[i-1][c - W[i-1]] + V[i-1])       # take item i
    return dp[n][C]

print(knapsack([2,3,4,5], [3,4,5,6], 5))    # 7

The recurrence: . The "0/1" comes from using (not ) on the include branch — once you take item , you can't take it again. Total work: .

P.3 edit distance between strings

Compute the Levenshtein distance — minimum number of single-character insertions, deletions, or substitutions — to transform "kitten" into "sitting".

Find the analogue: state is = "we've processed characters of the first string and of the second." Recurrence: at each cell, the three available moves correspond to delete, insert, or substitute/match.

show answer

Answer: 3. (kitten → sitten by substituting k→s; sitten → sittin by substituting e→i; sittin → sitting by inserting g.)

def edit_distance(a, b):
    m, n = len(a), len(b)
    dp = [[0]*(n+1) for _ in range(m+1)]
    for i in range(m+1): dp[i][0] = i        # delete all of a
    for j in range(n+1): dp[0][j] = j        # insert all of b
    for i in range(1, m+1):
        for j in range(1, n+1):
            if a[i-1] == b[j-1]:
                dp[i][j] = dp[i-1][j-1]      # characters match, no edit
            else:
                dp[i][j] = 1 + min(dp[i-1][j],     # delete a[i-1]
                                   dp[i][j-1],     # insert b[j-1]
                                   dp[i-1][j-1])   # substitute
    return dp[m][n]

print(edit_distance("kitten", "sitting"))    # 3

Same shape of recurrence as 0/1 knapsack — 2D state, three transitions per cell. The cost difference (min vs max, +1 vs +value) is what specializes the framework to the problem. time, space (reducible to with rolling rows).

P.4 longest common subsequence

Find the length of the longest common subsequence (LCS) of "ABCBDAB" and "BDCABA". A subsequence preserves order but doesn't need to be contiguous.

Find the analogue: state just like edit distance. Recurrence: if the characters match, extend by 1; if not, take the better of two "skip-one" options. Note the recurrence's max/min direction flips from edit distance because we want the longest rather than the shortest.

show answer

Answer: 4. Examples: BCBA, BDAB, BCAB.

def lcs_length(a, b):
    m, n = len(a), len(b)
    dp = [[0]*(n+1) for _ in range(m+1)]
    for i in range(1, m+1):
        for j in range(1, n+1):
            if a[i-1] == b[j-1]:
                dp[i][j] = dp[i-1][j-1] + 1
            else:
                dp[i][j] = max(dp[i-1][j], dp[i][j-1])
    return dp[m][n]

print(lcs_length("ABCBDAB", "BDCABA"))    # 4

LCS is the workhorse of diff and version-control merge algorithms — what git is computing under the hood when it tells you which lines changed. Generalizations also underpin sequence alignment in bioinformatics (Needleman-Wunsch is essentially LCS with weighted edit costs).

P.5 climbing stairs (linear recurrence / Fibonacci)

You climb a staircase of steps, taking either 1 or 2 steps at a time. How many distinct sequences of moves reach the top?

(a) Compute by hand for .

(b) Identify the recurrence in closed form.

Find the analogue: simplest DP problem there is: 1D state, two transitions. The state is "how many steps remaining," the recurrence is "sum the ways for each possible next move."

show answer

(a) .

(b) with . This is the Fibonacci recurrence shifted by one: .

def stairs(n):
    if n < 2: return 1
    a, b = 1, 1
    for _ in range(n - 1):
        a, b = b, a + b
    return b

print(stairs(10))    # 89

This is the bare minimum DP problem — a teaching example for the technique itself. Generalizations multiply: arbitrary step sets (e.g., 5), forbidden steps, weighted steps. The 1-2 case happens to have a closed form via Binet's formula , but the tabulation is the more general approach for arbitrary step sets.

P.6 subset sum decision

Given the multiset , is there a subset that sums to ?

Find the analogue: same shape as 0/1 knapsack: state = "considering first items, target ." Recurrence: take it or skip it. The answer is boolean — can the target be reached?

show answer

Yes — for example, .

def subset_sum(S, target):
    dp = [False] * (target + 1)
    dp[0] = True
    for x in S:
        for t in range(target, x - 1, -1):     # walk backward to avoid reusing x
            dp[t] = dp[t] or dp[t - x]
    return dp[target]

print(subset_sum([2, 3, 7, 8, 10], 11))    # True

The reverse iteration is the "0/1 trick" for subset sum: walking from target down to x prevents reusing the same element multiple times within one outer-loop iteration. Subset sum is NP-hard in general (target can be exponentially large in the input size), but the DP gives pseudo-polynomial — fine for small targets, infeasible for large ones.

P.7 rod cutting / revenue maximization

A rod of length can be cut into integer-length pieces. Each piece of length sells for the price in prices[ℓ-1]: . What's the maximum revenue from cuts?

Find the analogue: state is the rod length. Recurrence: try each first-cut length and take the best total (first cut's revenue + best from the remaining piece). Same shape as coin change but with values rather than counts.

show answer

Answer: 22. Optimal cutting: two pieces of length 2 + one piece of length 4, giving revenues ... wait let me re-verify. Optimal is one piece of length 2 + one piece of length 6: . ✓

def rod_cutting(prices, n):
    dp = [0] * (n + 1)
    for length in range(1, n + 1):
        for first_cut in range(1, length + 1):
            dp[length] = max(dp[length],
                             prices[first_cut - 1] + dp[length - first_cut])
    return dp[n]

print(rod_cutting([1, 5, 8, 9, 10, 17, 17, 20], 8))    # 22

Notice the recurrence is exactly the unbounded knapsack: each cut length can be reused arbitrarily. Compare with 0/1 knapsack (P.2) where each item is unique. The two-loop ordering is the same as in P.1: iterating amount outer, choice inner gives unbounded; the other order gives 0/1. Choose the order based on whether items are reusable.


Check problems

Two problems that don't pattern-match against the practice set. The first tests the boundary between greedy and DP solvability; the second transfers DP to a problem that doesn't look like a DP problem at first glance.

Check 1 articulation

The worked example showed that greedy fails for coin change with denominations , but for U.S. currency , greedy is optimal. Explain in 150–250 words:

  • What property must a coin system have for greedy to work? (You don't need to prove the theorem rigorously, but state the property precisely.)
  • Why does DP always work, regardless of the coin system?
  • How would you decide, in practice, whether to use greedy or DP for a new currency or denomination set?
show solution sketch

Greedy works on coin systems with the greedy property: for every amount , the optimal solution can be constructed by repeatedly choosing the largest coin . Coin systems with this property are called canonical. U.S. currency is canonical; is not.

Pearson's theorem (1994) gives a constructive test: a system is canonical iff for every consecutive pair of greedy solutions , the greedy solution beats or ties any other in length. Practical equivalent: check whether greedy matches DP for every amount up to ; if yes, the system is canonical and greedy always works.

DP always works because it considers every possible last-coin choice at each amount, not just the greedy choice. The Bellman optimality structure doesn't require any property of the coin set — it explores the full search space in time. Greedy is faster but only correct when the search space happens to be aligned with locally-greedy choices.

In practice: if the denomination set is fixed and standard (like circulating currency), test for canonicity once via Pearson's check, then use greedy. If denominations are arbitrary (e.g., custom puzzles, foreign currencies, abstract reward systems), use DP unconditionally — the asymptotic cost is fine and the correctness guarantee is free.

Check 2 transfer

You're given a 2D grid of integers (with both positive and negative values) and start at the top-left corner. You can move only right or down. Find the maximum sum of values along any path to the bottom-right corner.

This doesn't look like coin change or knapsack — there are no "items" or "amounts." But it's a DP problem.

(a) Identify the state variable(s) and write the recurrence.

(b) Argue why it has both optimal substructure and overlapping subproblems.

(c) State the time complexity and contrast with the brute-force approach of enumerating all paths.

(d) Demonstrate on the grid . What's the max-sum path?

show solution sketch

(a) State: = max sum of any path from to cell .

Recurrence: with , boundary rows/columns inheriting from a single direction.

(b) Optimal substructure: the best path to must arrive from either or , and using the best path to either of those predecessors is optimal for the overall path. Overlapping subproblems: cell is reached from many longer paths' computations — for an grid, there are paths in total but only distinct cells; the same cell is visited by combinatorially many of them.

(c) DP runs in . Brute force enumerates paths, exponential. On a grid that's a 137-billion-path enumeration vs a 400-cell DP — about 8 orders of magnitude faster.

(d) Trace on the example:

grid = [[1, 3, 1],
        [1, 5, 1],
        [4, 2, 1]]
dp = [[1, 4, 5],
      [2, 9, 10],
      [6, 11, 12]]
# Max sum to bottom-right: 12.

Best path traces: gives 11; gives 7; gives 9; gives 10; gives 9. Maximum is 12, via ? Let me re-trace: from (0,0)→(0,1)→(1,1)→(2,1)→(2,2): values 1+3+5+2+1 = 12. ✓

This is the canonical "path in a grid" DP problem, and it generalizes to arbitrary directed acyclic graphs with edge weights — DP on a DAG. From there it's a short step to longest path, shortest path on DAGs, and Viterbi (the most-likely-state-sequence algorithm in hidden Markov models). The DP machinery transfers wholesale.