AmouAI Hub/Courses/Data Structures & Algorithms/Chapter 26
Dynamic Programming II: The Classic Patterns
Six recurrences cover most of what you will ever meet: knapsack, two-sequence tables, subsequences, partitions, grids and intervals. Learn the shapes, not the code.
By the end of this chapter you can
- Recognise the six recurring DP shapes from the wording of a problem
- Write 0/1 knapsack, and explain exactly why the capacity loop runs backwards when rolled
- Distinguish 0/1 from unbounded knapsack by a single loop direction
- Build a two-sequence table for LCS and edit distance, and reconstruct the answer from it
- Turn subset-sum and partition problems into knapsack
- Solve an interval DP by looping over lengths rather than over endpoints
1Six shapes
Almost every DP problem you meet is a variation of one of six templates. Recognising which is most of the work.
Chapter 25 gave the grammar: state, transition, base case, order. This chapter is the vocabulary — the handful of state shapes that recur so often that recognising one saves you the entire derivation.
| Shape | State looks like | Told by |
|---|---|---|
| Knapsack | dp[i][budget] | A capacity, weight limit or count that choices consume |
| Two sequences | dp[i][j] over two strings | “common”, “align”, “transform A into B” |
| Subsequence in one array | dp[i] = best ending at i | “longest … subsequence” |
| Partition / subset | dp[sum] boolean | “can it be split”, “does a subset reach” |
| Grid | dp[r][c] | A 2-D board with restricted moves |
| Interval | dp[i][j] over a range | “merge”, “burst”, “the last thing you do” |
A guitarist does not work out each chord from first principles. They know a handful of shapes and where to slide them.
The six templates below play the same role. When a problem mentions a capacity you do not derive knapsack — you recognise it, and the only remaining work is deciding what the axes mean.
You still need the grammar from Chapter 25, exactly as a guitarist still needs to know what the notes are. But the shapes are what make it fast.
2Knapsack
A budget that choices consume. The single most common shape, and the one with the most variants.
0/1 knapsack. Items each have a weight and a value; the bag holds
W; each item is taken whole or not at all. Chapter 24 showed greedy fails here. The DP
does not.
def knapsack(items, W):
"""items: list of (value, weight). Returns the best value that fits in W."""
dp = [[0] * (W + 1) for _ in range(len(items) + 1)]
for i, (v, w) in enumerate(items, start=1):
for cap in range(W + 1):
dp[i][cap] = dp[i - 1][cap] # skip item i
if w <= cap: # or take it, if it fits
dp[i][cap] = max(dp[i][cap], dp[i - 1][cap - w] + v)
return dp[len(items)][W]Read the state out loud, as Chapter 25 insisted: “dp[i][cap] is the best
value obtainable using only the first i items, with a bag of capacity
cap.” Both indices are needed, so the table is 2-D, and that follows from the
sentence rather than from guesswork.
Row i depends only on row i-1, so one array suffices — but
only if you iterate capacity downwards:
for v, w in items:
for cap in range(W, w - 1, -1): # BACKWARDS
dp[cap] = max(dp[cap], dp[cap - w] + v)
Going forwards, dp[cap - w] would already have been updated during this same
item, so the item could be taken twice. Going backwards, dp[cap - w] still holds
the previous row's value, which is exactly the “without this item” answer you want.
Which gives the neatest fact in this chapter: flip the loop direction and you have unbounded knapsack, where each item may be taken any number of times. One character of difference, two different problems.
| Variant | Loop over capacity | Meaning |
|---|---|---|
| 0/1 knapsack | range(W, w-1, -1) | Each item at most once |
| Unbounded knapsack | range(w, W+1) | Each item any number of times |
| Bounded (k copies) | Split into powers of two, then 0/1 | Each item at most k times |
| Coin change (fewest) | Unbounded, with min instead of max | Chapter 25 |
| Counting ways | Unbounded, with += instead of max | How many ways to reach the total |
O(n·W) looks polynomial and is not, in the formal sense: W is
a value, and writing it down takes only log W digits. So the running time is
exponential in the size of the input, and 0/1 knapsack is genuinely NP-hard.
In practice this bites when weights are large or fractional. A capacity of 10⁹ is unreachable; multiplying fractional weights by 100 to make them integers multiplies your running time by 100 too.
3Two sequences
One string down the side, the other along the top, and a rule for each cell. The second most common shape.
Whenever a problem compares two sequences — align them, transform one into the other, find
what they share — the state is almost always “the answer for the first
i of one and the first j of the other”.
def edit_distance(a, b):
n, m = len(a), len(b)
dp = [[0] * (m + 1) for _ in range(n + 1)]
for i in range(n + 1):
dp[i][0] = i # delete every character of a
for j in range(m + 1):
dp[0][j] = j # insert every character of b
for i in range(1, n + 1):
for j in range(1, m + 1):
if a[i - 1] == b[j - 1]:
dp[i][j] = dp[i - 1][j - 1] # free: characters agree
else:
dp[i][j] = 1 + min(dp[i - 1][j], # delete from a
dp[i][j - 1], # insert into a
dp[i - 1][j - 1]) # substitute
return dp[n][m]Longest common subsequence is the same table with a different rule, and is worth putting side by side because the resemblance is the point:
def lcs(a, b):
n, m = len(a), len(b)
dp = [[0] * (m + 1) for _ in range(n + 1)]
for i in range(1, n + 1):
for j in range(1, m + 1):
if a[i - 1] == b[j - 1]:
dp[i][j] = dp[i - 1][j - 1] + 1 # extend the match
else:
dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]) # drop one character
return dp[n][m]The three neighbours of a cell are not arbitrary — each corresponds to one operation:
- up
dp[i-1][j]: deletea[i-1] - left
dp[i][j-1]: insertb[j-1] - diagonal
dp[i-1][j-1]: substitute, or match for free
To recover the actual edit script, start at dp[n][m] and walk backwards, choosing at
each step whichever neighbour justifies the value. The direction you moved names the operation.
Picture the two strings as two queues, and a clerk who at each moment must serve the front of one queue, the other, or both together when they match.
Serving both at once is free if they agree, costs one substitution if they do not. Serving one alone is an insertion or a deletion. The table is a record of the cheapest way to clear both queues from every possible pair of remaining lengths.
Relatives of the same shape, all with dp[i][j] over two sequences: longest common
substring (contiguous — reset to 0 on a mismatch instead of taking a max),
sequence alignment with custom gap penalties (the Needleman–Wunsch algorithm in
bioinformatics), regular-expression and wildcard matching, and “is s an
interleaving of a and b”.
4Subsequences, partitions and grids
One array with a 1-D state; a boolean reachability table; and a board you walk across.
Subsequence in one array. The state is “the best answer ending
at index i”. Chapter 25 used it for longest increasing subsequence, and
the “ending at” clause is what makes the transition possible — you cannot ask
whether nums[i] extends something unless you know what it would extend.
# The O(n log n) LIS: keep the smallest possible tail for each length.
import bisect
def lis_fast(nums):
tails = [] # tails[k] = smallest tail of an increasing run of length k+1
for x in nums:
i = bisect.bisect_left(tails, x)
if i == len(tails):
tails.append(x) # x extends the longest run so far
else:
tails[i] = x # x gives a better (smaller) tail for that length
return len(tails)tails is not the answertails has the right length but its contents are usually not a valid
subsequence of the input — later small values overwrite earlier positions. If you need the
actual subsequence, record a predecessor index alongside each insertion and walk it back.
Partition and subset-sum. “Can any subset reach exactly this total?” is knapsack with a boolean instead of a value:
def can_partition(nums):
"""Can nums be split into two halves of equal sum?"""
total = sum(nums)
if total % 2:
return False # odd total: impossible, no work needed
target = total // 2
reachable = [False] * (target + 1)
reachable[0] = True # the empty subset reaches 0
for x in nums:
for s in range(target, x - 1, -1): # backwards: each item used once
if reachable[s - x]:
reachable[s] = True
return reachable[target]Notice the backwards loop again, and for the same reason. Recognising “split into two equal halves” as “reach exactly total/2” as “0/1 knapsack with boolean values” is the entire solution; the code afterwards is six lines.
Python integers are arbitrary-precision bit vectors, so the whole reachability array fits in one integer and the inner loop becomes a single shift:
bits = 1
for x in nums:
bits |= bits << x
return (bits >> target) & 1
This is the same algorithm — each shift is “every reachable sum, plus x” — executed 64 sums at a time by the CPU. Roughly a 30× speed-up for four lines.
Grids. dp[r][c] built from the cells you could have come from. You
did unique-paths-with-obstacles in Chapter 25; the variants are minimum path sum, maximum gold
collected, and “largest square of 1s”:
def largest_square(grid):
"""Side length of the largest all-ones square."""
if not grid or not grid[0]:
return 0
rows, cols = len(grid), len(grid[0])
dp = [[0] * cols for _ in range(rows)]
best = 0
for r in range(rows):
for c in range(cols):
if grid[r][c] == 1:
if r == 0 or c == 0:
dp[r][c] = 1
else:
# a square ending here is limited by all THREE neighbours
dp[r][c] = 1 + min(dp[r-1][c], dp[r][c-1], dp[r-1][c-1])
best = max(best, dp[r][c])
return best5Interval DP
The sixth shape, and the one people miss. When the answer depends on what you do LAST, loop over lengths.
A wall of bricks must be dismantled, and removing a brick costs something that depends on its current neighbours. Asking “which brick do I remove first?” is hopeless, because that choice changes every later cost in a way you cannot yet evaluate.
Ask instead: “which brick do I remove last?” Whichever it is, at that moment its neighbours are the two original walls either side of it, which are exactly the two subproblems. That inversion is the whole technique.
So the state is a range: “dp[i][j] is the best answer for the elements
strictly between i and j”, and the transition tries every choice
of the last element in that range.
def matrix_chain(dims):
"""dims[i] x dims[i+1] is matrix i. Fewest scalar multiplications to multiply them all."""
n = len(dims) - 1 # number of matrices
dp = [[0] * n for _ in range(n)]
for length in range(2, n + 1): # <-- loop over LENGTH, not over i and j
for i in range(n - length + 1):
j = i + length - 1
dp[i][j] = float("inf")
for k in range(i, j): # the LAST multiplication splits at k
cost = dp[i][k] + dp[k + 1][j] + dims[i] * dims[k + 1] * dims[j + 1]
if cost < dp[i][j]:
dp[i][j] = cost
return dp[0][n - 1]dp[i][j] depends on dp[i][k] and dp[k+1][j] —
both shorter ranges. Iterating i then j in the natural order
would read cells that have not been computed yet.
Looping over length is the topological order for this dependency graph. It is the standard tell:
if you see for length in range(...) as the outer loop, you are looking at an
interval DP.
The family is small but distinctive: matrix chain multiplication, burst balloons, optimal binary search trees, palindrome partitioning, and “merge stones”. All share the “what happens last” inversion and the length-first loop.
6From wording to recurrence
A checklist for turning an unfamiliar problem into one of the six.
Work through these in order. The first one that fits is usually right.
- Is there a budget, capacity or count that choices consume? → knapsack. The budget is a dimension of the state. Ask immediately whether items repeat: that decides the loop direction.
- Are there two sequences? →
dp[i][j]over prefixes of each. - One sequence, and the answer is a subsequence or a run? →
dp[i]= best ending at i. - “Can we reach exactly…” or “split into equal…”? → boolean subset-sum.
- A 2-D board with restricted moves? → grid DP.
- Does the cost of a choice depend on its neighbours at the time? → interval DP. Ask what happens last.
When you are stuck, these two nearly always unstick it:
“What do I need to know to make the next decision?” The complete answer is your state. If the answer contains an “and also”, that is a second dimension.
“What was the last thing I did?” Enumerating the possible last moves gives you the transition, and for interval problems it is the only framing that works.
| If the state feels wrong because… | The fix |
|---|---|
| Two different states give the same answer but different futures | Add the missing dimension |
| The transition needs information you did not store | That information belongs in the state |
| The table is too big to fit in memory | Roll it, or check whether a dimension is redundant |
| You cannot find a valid order | Try top-down memoisation and let recursion find it |
| Cells depend on cells of the same size | It is probably an interval DP: loop over length |
When the state space is too large to tabulate at all — permutations, board positions, configurations — DP stops being an option and you fall back on searching with pruning. That is Chapter 27.
The boundary is worth internalising: DP works when the number of distinct situations is manageable even though the number of paths is astronomical. When the situations themselves are astronomical, no table will save you.
What to carry forward
- Six shapes cover most DP: knapsack, two sequences, subsequence-ending-at-i, subset-sum, grid, interval.
- A budget or capacity in the statement is a dimension of the state. That is the knapsack tell.
- Rolled knapsack loops capacity backwards for 0/1 and forwards for unbounded. One character, two problems.
O(n·W)is pseudo-polynomial: it depends on the capacity's value, not its size, and 0/1 knapsack really is NP-hard.- Two-sequence tables put one string down the side and the other along the top. The three neighbours are delete, insert and substitute.
- For subsequences the state must say “ending at i”, or the transition cannot close.
- LIS has an
O(n log n)version usingbisectover atailsarray — whose contents are not themselves a valid subsequence. - “Split into two equal halves” is boolean knapsack on total/2, and in Python it fits in one big integer as a bitset.
- Interval DP asks what happens last and loops over length as the outer loop — that loop is its signature.
- Stuck? Ask “what do I need to know to decide?” (the state) and “what did I do last?” (the transition).
>_Playground
0/1 versus unbounded knapsack from the same code, edit distance with the actual edit script, and the bitset partition trick.
✓Exercises
Checked automatically the moment you submit. Work top to bottom — each one assumes the last. Your answers are saved in this browser.
Chapter 27 — Backtracking and Constraint Search
Try, fail, undo, try something else. Brute force with a memory and a conscience — and pruning is what turns an impossible search into an instant one.