Ch 26 / 30 Dynamic Programming II: The Classic Patterns 0/0 exercises Exercises ↓

AmouAI Hub/Courses/Data Structures & Algorithms/Chapter 26

Part 6 · Design Paradigms · 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.

Reading
CLRS Ch. 15
Focus
Recognising the shape
Cost
O(n·W), O(n·m)
Needs
Chapter 25

By the end of this chapter you can

  1. Recognise the six recurring DP shapes from the wording of a problem
  2. Write 0/1 knapsack, and explain exactly why the capacity loop runs backwards when rolled
  3. Distinguish 0/1 from unbounded knapsack by a single loop direction
  4. Build a two-sequence table for LCS and edit distance, and reconstruct the answer from it
  5. Turn subset-sum and partition problems into knapsack
  6. 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.

ShapeState looks likeTold by
Knapsackdp[i][budget]A capacity, weight limit or count that choices consume
Two sequencesdp[i][j] over two strings“common”, “align”, “transform A into B”
Subsequence in one arraydp[i] = best ending at i“longest … subsequence”
Partition / subsetdp[sum] boolean“can it be split”, “does a subset reach”
Griddp[r][c]A 2-D board with restricted moves
Intervaldp[i][j] over a range“merge”, “burst”, “the last thing you do”
Chord shapes

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.

knapsack.pyO(n x W) time and space
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.

Rolling it, and why the loop reverses

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.

VariantLoop over capacityMeaning
0/1 knapsackrange(W, w-1, -1)Each item at most once
Unbounded knapsackrange(w, W+1)Each item any number of times
Bounded (k copies)Split into powers of two, then 0/1Each item at most k times
Coin change (fewest)Unbounded, with min instead of maxChapter 25
Counting waysUnbounded, with += instead of maxHow many ways to reach the total
“Pseudo-polynomial” is not a compliment

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”.

edit.py
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:

lcs.py
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]
Three cells, three edits

The three neighbours of a cell are not arbitrary — each corresponds to one operation:

  • up dp[i-1][j]: delete a[i-1]
  • left dp[i][j-1]: insert b[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.

Two queues at a counter

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.

lis_fast.pyO(n log n) - not a DP table at all
# 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 answer

tails 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:

partition.py
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.

The bitset trick

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”:

square.pydp[r][c] = the square ENDING at (r, c)
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 best

5Interval DP

The sixth shape, and the one people miss. When the answer depends on what you do LAST, loop over lengths.

Which brick do you remove last?

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.

matrix_chain.pyO(n^3)
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]
Why the outer loop is over length

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.

Knapsack
O(n·W)
Two sequences
O(n·m)
LIS, table version
O(n²)
Grid
O(rows · cols)
Interval
O(n³)

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.

  1. 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.
  2. Are there two sequences?dp[i][j] over prefixes of each.
  3. One sequence, and the answer is a subsequence or a run?dp[i] = best ending at i.
  4. “Can we reach exactly…” or “split into equal…”? → boolean subset-sum.
  5. A 2-D board with restricted moves? → grid DP.
  6. Does the cost of a choice depend on its neighbours at the time? → interval DP. Ask what happens last.
Two diagnostic questions

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 futuresAdd the missing dimension
The transition needs information you did not storeThat information belongs in the state
The table is too big to fit in memoryRoll it, or check whether a dimension is redundant
You cannot find a valid orderTry top-down memoisation and let recursion find it
Cells depend on cells of the same sizeIt is probably an interval DP: loop over length
What comes next

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 using bisect over a tails array — 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.

scratch.pypython not loaded
Real Python, running in your browser. Nothing is installed or uploaded.
Output appears here. The first run takes a few seconds while Python loads.

Exercises

Checked automatically the moment you submit. Work top to bottom — each one assumes the last. Your answers are saved in this browser.

All Warm-up Core Challenge Reset chapter

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.

Continue →