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 6 using denominations {1,3,4}. Note: greedy (always pick the largest coin that fits) gives 4+1+1=3 coins. DP gives the optimum.
Diagnosis. The problem has optimal substructure: the best way to make change for amount n uses some coin c as its last coin, then optimally makes change for n−c. 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 dp[i] as the minimum coins for amount i.
Predict before reading on: predict the answer by trying small denominations first. Does any combination of coins from {1,3,4} sum to 6 in fewer than 3 coins?
Recurrence.
dp[i]=c∈coins,c≤imin(dp[i−c]+1),dp[0]=0
Tabulation. Fill dp from i=0 upward:
i
candidates
dp[i]
last coin used
0
—
0
—
1
dp[0]+1 = 1
1
1
2
dp[1]+1 = 2
2
1
3
dp[2]+1 = 3, dp[0]+1 = 1
1
3
4
dp[3]+1 = 2, dp[1]+1 = 2, dp[0]+1 = 1
1
4
5
dp[4]+1 = 2, dp[2]+1 = 3, dp[1]+1 = 2
2
4 or 1
6
dp[5]+1 = 3, dp[3]+1 = 2, dp[2]+1 = 3
2
3
Result.dp[6]=2. The optimal solution uses 3+3=6, beating greedy's 4+1+1=3.
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 O(amount×coins) 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 -1print(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.1count ways (unordered combinations)
Same coins {1,2,5}, but instead of asking for the minimum number of coins, count how many distinct ways there are to make change for amount 5. (Combinations, not permutations — 2+2+1 and 1+2+2 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: {5},{2,2,1},{2,1,1,1},{1,1,1,1,1}.
To avoid double-counting orderings, fix the coin order: process coins one at a time, allowing repeats. State dp[i] = number of ways to make amount i 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.20/1 knapsack with capacity constraint
Four items with weights [2,3,4,5] and values [3,4,5,6]. A knapsack with capacity 5 can hold any subset of items whose total weight ≤5. 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 (i,c) where i indexes how many items we've considered and c 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: dp[i][c]=max(dp[i−1][c],dp[i−1][c−wi]+vi). The "0/1" comes from using dp[i−1] (not dp[i]) on the include branch — once you take item i, you can't take it again. Total work: O(nC).
P.3edit 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 (i,j) = "we've processed i characters of the first string and j 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. O(mn) time, O(mn) space (reducible to O(min(m,n)) with rolling rows).
P.4longest 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 (i,j) 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).
You climb a staircase of n steps, taking either 1 or 2 steps at a time. How many distinct sequences of moves reach the top?
(a) Compute by hand for n=10.
(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) f(10)=89.
(b) f(n)=f(n−1)+f(n−2) with f(0)=f(1)=1. This is the Fibonacci recurrence shifted by one: f(n)=Fn+1.
def stairs(n): if n < 2: return 1 a, b = 1, 1 for _ in range(n - 1): a, b = b, a + b return bprint(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 Fn=(ϕn−ψn)/5, but the tabulation is the more general approach for arbitrary step sets.
P.6subset sum decision
Given the multiset S={2,3,7,8,10}, is there a subset that sums to 11?
Find the analogue:
same shape as 0/1 knapsack: state (i,t) = "considering first i items, target t." Recurrence: take it or skip it. The answer is boolean — can the target be reached?
show answer
Yes — for example, 3+8=11.
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 O(n⋅target) — fine for small targets, infeasible for large ones.
P.7rod cutting / revenue maximization
A rod of length 8 can be cut into integer-length pieces. Each piece of length ℓ sells for the price in prices[ℓ-1]: [1,5,8,9,10,17,17,20]. 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 5+5+9=19... wait let me re-verify. Optimal is one piece of length 2 + one piece of length 6: 5+17=22. ✓
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 1articulation
The worked example showed that greedy fails for coin change with denominations {1,3,4}, but for U.S. currency {1,5,10,25}, 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 n, the optimal solution can be constructed by repeatedly choosing the largest coin ≤n. Coin systems with this property are called canonical. U.S. currency is canonical; {1,3,4} is not.
Pearson's theorem (1994) gives a constructive test: a system {c1<c2<…<ck} is canonical iff for every consecutive pair of greedy solutions (gci+1,…,gci+ci+1−1), the greedy solution beats or ties any other in length. Practical equivalent: check whether greedy matches DP for every amount up to ck+ck−1; 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 dp[n]=minc(dp[n−c]+1) doesn't require any property of the coin set — it explores the full search space in O(n⋅k) 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 2transfer
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 3×3 grid 114352111. What's the max-sum path?
show solution sketch
(a) State: dp[i][j] = max sum of any path from (0,0) to cell (i,j).
Recurrence: dp[i][j]=grid[i][j]+max(dp[i−1][j],dp[i][j−1]) with dp[0][0]=grid[0][0], boundary rows/columns inheriting from a single direction.
(b) Optimal substructure: the best path to (i,j) must arrive from either (i−1,j) or (i,j−1), and using the best path to either of those predecessors is optimal for the overall path. Overlapping subproblems: cell (i,j) is reached from many longer paths' computations — for an m×n grid, there are (m−1m+n−2) paths in total but only mn distinct cells; the same cell is visited by combinatorially many of them.
(c) DP runs in O(mn). Brute force enumerates (mm+n)≈2m+n/m+n paths, exponential. On a 20×20 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: 1→3→5→1→1 gives 11; 1→3→1→1→1 gives 7; 1→1→5→1→1 gives 9; 1→1→5→2→1 gives 10; 1→1→4→2→1 gives 9. Maximum is 12, via 1→3→5→2→1? 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.