10. Dynamic Programming

This chapter covers the dynamic programming lectures (Weeks 9 and 10). Like divide and conquer (Chapter 2), dynamic programming (DP) solves a problem through smaller versions of it. The difference: in DP the sub-problems overlap, so plain recursion solves the same one again and again. DP solves each sub-problem once, stores the answer, and reuses it. This often turns exponential time into polynomial time.

Fibonacci numbers show the trouble and the fix. A five-step recipe follows, and then it solves two problems: longest common subsequence and 0/1 knapsack.


1. Why Plain Recursion Fails

Divide and conquer: the sub-problems are independent. Mergesort’s two halves share no work. Dynamic programming: the sub-problems overlap. DP adds one rule:

Never compute the answer to the same sub-problem more than once.

Running example: Fibonacci

F0=0,F1=1,Fn=Fn−1+Fn−2(n≥2).F_0 = 0, \qquad F_1 = 1, \qquad F_n = F_{n-1} + F_{n-2} \quad (n \ge 2).

int fib(n) {
    if (n == 0 || n == 1) return n;
    else return fib(n - 1) + fib(n - 2);
}

Each call makes two more calls. The call tree for fib(6) repeats the same values:

The calls made by fib(6). Each node is one call. fib(2) alone is computed 5 times (the blue nodes).
Call fib(6) fib(5) fib(4) fib(3) fib(2) fib(1) fib(0)
Times made 1 1 2 3 5 8 5

That is 25 calls for only 7 different values. (The counts are read off the tree; they are not in the lecture.)

How many calls?

Let RR be the number of calls. Bound the tree depth two ways:

A full binary tree of depth DD has 1+2+…+2D=2D+1−11 + 2 + \dots + 2^D = 2^{D+1} - 1 nodes. So

2n/2+1−1<R<2n−1.2^{n/2 + 1} - 1 < R < 2^n - 1.

For n=6n = 6: 15<25<6315 < 25 < 63. Both bounds are exponential, so plain fib takes O(2n)O(2^n) time. For n=50n = 50 the lower bound alone is about 67 million calls. Only n+1n + 1 different sub-problems exist, so almost every call repeats work.


2. The Fix: Store Each Answer

Keep a dictionary (usually an array indexed by sub-problem) of solved answers. Before solving a sub-problem, look it up:

The same tree with stored answers. Green: base cases. Yellow: computed for the first time and stored. Purple: retrieved from the dictionary. Grey: never called.

A purple node costs O(1)O(1), and the whole sub-tree under it disappears.

DP replaces an exponential-time computation by a polynomial-time one, by storing solutions and never recomputing them.

This is the idea of DFS (Chapter 5): DFS explores only undiscovered vertices, and DP solves only unsolved sub-problems.

Top-down: memoization

Keep the recursion and add the dictionary. Check it before each recursive call. Store the answer just before returning.

int fibDP(n) {
    int f1, f2;
    if (n == 0 || n == 1) { store(Soln, n, n); return n; }
    if (not member(Soln, n - 1)) f1 = fibDP(n - 1);
    else                         f1 = retrieve(Soln, n - 1);
    if (not member(Soln, n - 2)) f2 = fibDP(n - 2);
    else                         f2 = retrieve(Soln, n - 2);
    f1 = f1 + f2;
    store(Soln, n, f1);
    return f1;
}

Soln[0..n] starts at −1-1 (“not solved”). member tests Soln[j] != -1, store sets Soln[j] = s, and retrieve returns Soln[j].

Trace for fibDP(6) (. means still −1-1):

Event Soln[0] [1] [2] [3] [4] [5] [6]
fibDP(1) stores base . 1 . . . . .
fibDP(0) stores base 0 1 . . . . .
fibDP(2) stores 1+01+0 0 1 1 . . . .
fibDP(3) stores 1+11+1 0 1 1 2 . . .
fibDP(4) stores 2+12+1 0 1 1 2 3 . .
fibDP(5) stores 3+23+2 0 1 1 2 3 5 .
fibDP(6) stores 5+35+3 0 1 1 2 3 5 8

In each call the first value needs a call and the second is retrieved. That prunes the tree.

Cost: one call does constant work, and fibDP really runs once per value 0…n0 \dots n. Total: O(n)O(n), against O(2n)O(2^n).


3. The Order of Sub-problems

The subproblem graph

The subproblem graph of fib(6). Each vertex n has edges to n-1 and n-2.

The call tree had 25 nodes. The graph has 7 vertices.

time≈(number of sub-problems)×(work per sub-problem).\text{time} \approx (\text{number of sub-problems}) \times (\text{work per sub-problem}).

For Fibonacci: (n+1)×O(1)=O(n)(n+1) \times O(1) = O(n). We reuse this formula for every problem below.

Bottom-up: tabulation

Fill the table in that order with loops. No recursion is needed.

soln[0] = 0;
soln[1] = 1;
for j = 2 to n
    soln[j] = soln[j-1] + soln[j-2];
jj 0 1 2 3 4 5 6
soln[j] 0 1 1 2 3 5 8
Top-down (memoization) Bottom-up (tabulation)
Form Recursion plus a dictionary Loops over a table
Who picks the order? The recursion You, from the subproblem graph
Sub-problems solved Only those reached All of them

Saving memory. Fibonacci looks back two steps, so two variables are enough: O(1)O(1) space. But to rebuild the solution itself (LCS, knapsack), you must keep the whole table.


4. When Is DP Correct? The Principle of Optimality

Many problems ask for the best answer: longest, shortest, most profitable. These are optimization problems. DP is correct for them only if the principle of optimality holds:

The sub-solutions of an optimal solution are themselves optimal solutions of their sub-problems.

Example: if A→B→C→DA \to B \to C \to D is a shortest path, then A→B→CA \to B \to C is a shortest path from AA to CC (Chapter 6).

DP builds the best answer only from the best answers of smaller problems, never from second-best ones. That is safe only if best solutions are made of best parts.

Cut-and-paste proof. Take an optimal solution. Suppose one part is not optimal for its sub-problem. Paste in a better part. The whole is now better and still valid, which contradicts optimality. Check that the pasted part still fits the rules.

The DP selection rule. When the last decision has several possibilities, compute all of them and take the best. A greedy algorithm (Chapter 6) picks what looks best now and never checks the others.


5. The Five-Step Recipe

Step What to do On Fibonacci
1 Define the sub-problems. Say in words what each one means. FjF_j: the jj-th Fibonacci number.
2 Write the recursive function and its base cases. Fj=Fj−1+Fj−2F_j = F_{j-1} + F_{j-2}; F0=0F_0 = 0, F1=1F_1 = 1.
3 Draw the subproblem graph (a DAG). Find a topological order. Vertex jj points to j−1j-1, j−2j-2. Order 0…n0 \dots n.
4 Pick a dictionary (table), one entry per sub-problem. Array soln[0..n].
5 Write an iterative function: set base cases, fill in order, read the answer. Use parent pointers to recover the solution itself. The loop above. Answer soln[n].

Then state the time (formula in Section 3) and the space (table size). Most errors come from step 1: a vague definition leaves step 2 with nothing exact to build on.


6. Longest Common Subsequence (LCS)

A subsequence keeps some characters in their original order, not necessarily adjacent. For ACTTGCG: ACT, AG and ATTC are subsequences. TTA is not (no A after the two Ts). A common subsequence of xx and yy is a subsequence of both. An LCS is a common subsequence of maximum length. It may not be unique.

Problem. For x=⟨x1…xn⟩x = \langle x_1 \dots x_n \rangle and y=⟨y1…ym⟩y = \langle y_1 \dots y_m \rangle, find the length LCS(n,m)LCS(n, m). Brute force checks all 2n2^n subsequences of xx.

Steps 1 and 2: sub-problem and recursion

LCS(i,j)LCS(i, j) is the length of an LCS of the prefixes x1…xix_1 \dots x_i and y1…yjy_1 \dots y_j (0≤i≤n0 \le i \le n, 0≤j≤m0 \le j \le m). Prefixes appear because we look at the last character of each, and what remains is a pair of shorter prefixes. Compare xix_i with yjy_j:

LCS(i,j)={0i=0 or j=0,LCS(i−1,j−1)+1xi=yj,max⁡(LCS(i−1,j),LCS(i,j−1))xi≠yj.LCS(i, j) = \begin{cases} 0 & i = 0 \text{ or } j = 0, \\[4pt] LCS(i-1, j-1) + 1 & x_i = y_j, \\[4pt] \max\big(LCS(i-1, j),\; LCS(i, j-1)\big) & x_i \ne y_j. \end{cases}

Steps 3 to 5: graph, table, code

Cell (i,j)(i, j) depends on up-left (i−1,j−1)(i-1, j-1), above (i−1,j)(i-1, j) and left (i,j−1)(i, j-1).

The subproblem graph of LCS(6, 5). Every cell points up, left and diagonally up-left.

The table c[0..n][0..m] has row 0 and column 0 equal to 0. Fill row by row, left to right, so the three cells are ready.

int LCS(n, m) {
    for i = 0 to n  c[i][0] = 0;
    for j = 1 to m  c[0][j] = 0;
    for i = 1 to n
        for j = 1 to m
            if (x[i] == y[j])
                c[i][j] = c[i-1][j-1] + 1;
            else if (c[i-1][j] >= c[i][j-1])
                c[i][j] = c[i-1][j];
            else
                c[i][j] = c[i][j-1];
    return c[n][m];
}

Cost: (n+1)(m+1)(n+1)(m+1) cells at O(1)O(1) each. Time O(nm)O(nm), space O(nm)O(nm).

Recovering the subsequence

Keep a hint array h that records where each cell’s value came from:

Hint Meaning Came from
\ xi=yjx_i = y_j; character is in the LCS (i−1,j−1)(i-1, j-1)
| c[i][j]=c[i−1][j]c[i][j] = c[i-1][j] above
— c[i][j]=c[i][j−1]c[i][j] = c[i][j-1] left

Tie rule: if c[i−1][j]=c[i][j−1]c[i-1][j] = c[i][j-1], take above (|), because the code tests >=.

Start at h[n][m] and follow the hints back until row 0 or column 0. Each \ gives one character, and they come out last first, so use a stack.

getSequence(n, m) {
    s = empty stack;  i = n;  j = m;
    while (i != 0 and j != 0) {
        if (h[i][j] == '\')       { s.push(x[i]); i--; j--; }
        else if (h[i][j] == '|')  i--;
        else                      j--;
    }
    pop and output everything from s;     // the LCS, in order
}

Each step lowers ii or jj, so the traceback is O(n+m)O(n + m).

Example 1 (lecture): xx = ACGGA, yy = ACTG

Five cells in fill order:

A C T G
0 0 0 0 0
A 0 1 1 1 1
C 0 1 2 2 2
G 0 1 2 2 3
G 0 1 2 2 3
A 0 1 2 2 3

Hint array:

A C T G
A \ — — —
C | \ — —
G | | | \
G | | | \
A \ | | |

Trace back from (5,4)(5,4): up to (4,4)(4,4); \ records G; up from (3,3)(3,3) to (2,3)(2,3); left to (2,2)(2,2); \ records C; \ at (1,1)(1,1) records A. Reversed, the LCS is ACG, length 3.

LCS of x = ACGGA and y = ACTG. Top: the length table; LCS(5, 4) = 3. Bottom: the hint array. Following the highlighted path from the bottom-right corner gives the LCS ACG.

Example 2 (lecture): xx = CGGTAT, yy = AGTTGC

A G T T G C
0 0 0 0 0 0 0
C 0 0 0 0 0 0 1
G 0 0 1 1 1 1 1
G 0 0 1 1 1 2 2
T 0 0 1 2 2 2 2
A 0 1 1 2 2 2 2
T 0 1 1 2 3 3 3

Hint array:

A G T T G C
C | | | | | \
G | \ — — \ |
G | \ | | \ —
T | | \ \ | |
A \ | | | | |
T | | \ \ — —

Trace back from (6,6)(6,6): left, left to (6,4)(6,4); \ records T; up to (4,3)(4,3); \ records T; \ at (3,2)(3,2) records G; then up, up to row 0. Reversed, the LCS is GTT, length 3. Other LCSs of length 3 may exist. The tie rule decides which one is found.

By hand

  1. Write xx down the side and yy across the top. Add a row 0 and a column 0 of zeros.
  2. Each cell: if the characters match, take the upper-left value +1+1. Otherwise take the larger of above and left (tie: above).
  3. The length is the bottom-right cell. Walk back from there for the subsequence.

7. The 0/1 Knapsack Problem

A knapsack holds total weight at most CC (a positive integer). Object ii has weight wiw_i and profit pip_i (positive integers). Find the largest total profit of a subset with total weight at most CC. “0/1” means each object is taken whole or left, never split or repeated. Brute force checks 2n2^n subsets.

Steps 1 and 2: sub-problem and recursion

P(r,j)P(r, j) is the largest profit from the first jj objects with capacity rr (0≤r≤C0 \le r \le C, 0≤j≤n0 \le j \le n). The answer is P(C,n)P(C, n). (The lecture writes CC for both; rr is the variable capacity.) Look at the last object jj. The best solution takes it or leaves it:

We do not know which is better, so compute both and take the larger.

P(r,0)=P(0,j)=0,P(r,j)={max⁡(P(r,j−1),pj+P(r−wj,j−1))wj≤r,P(r,j−1)wj>r.P(r, 0) = P(0, j) = 0, \qquad P(r, j) = \begin{cases} \max\big(P(r, j-1),\; p_j + P(r - w_j, j-1)\big) & w_j \le r, \\[4pt] P(r, j-1) & w_j > r. \end{cases}

Both sub-problems use j−1j - 1 objects, so each object is decided once and never taken twice.

Steps 3 to 5: graph, table, code

(r,j)(r, j) points to (r,j−1)(r, j-1) (leave) and (r−wj,j−1)(r - w_j, j-1) (take). Every edge lowers jj, so it is a DAG. Example: C=20C = 20 with four objects.

Object 1 2 3 4
Weight ww 4 6 8 6
Profit pp 7 6 9 5
The subproblem graph for C = 20 and four objects with weights 4, 6, 8, 6. Each (capacity, j) points to (capacity, j-1) and (capacity - w_j, j-1).

The table profit[C+1][n+1] has rows = capacities and columns = objects. Row 0 and column 0 are 0. Fill so that column c−1c-1 is done before column cc.

int knapsack(int[] w, int[] p, int C, int n) {
    for c = 0 to n  profit[0][c] = 0;
    for r = 1 to C  profit[r][0] = 0;
    for r = 1 to C                         // capacity
        for c = 1 to n {                   // objects 1..c are allowed
            profit[r][c] = profit[r][c-1];                 // leave object c
            if (w[c] <= r)
                if (profit[r][c] < profit[r - w[c]][c-1] + p[c])
                    profit[r][c] = profit[r - w[c]][c-1] + p[c];   // take object c
        }
    return profit[C][n];
}

Example 1 (lecture): C=3C = 3

Object 1 2 3
Weight ww 1 2 3
Profit pp 1 4 6
rr c=0c = 0 c=1c = 1 c=2c = 2 c=3c = 3
0 0 0 0 0
1 0 1 1 1
2 0 1 4 4
3 0 1 5 6

The answer is 6: object 3 alone. Objects 1 and 2 together weigh 3 but give only 5.

Example 2 (lecture): C=20C = 20

Rows 0 to 3 are all 0 (the lightest object weighs 4).

rr c=0c=0 c=1c=1 c=2c=2 c=3c=3 c=4c=4
4 to 7 0 7 7 7 7
8 to 9 0 7 7 9 9
10 to 11 0 7 13 13 13
12 to 15 0 7 13 16 16
16 to 17 0 7 13 16 18
18 to 20 0 7 13 22 22
The completed table (rows 10 to 20) for C = 20, with weights 4, 6, 8, 6 and profits 7, 6, 9, 5. The answer is profit[20][4] = 22.

Which objects were taken?

Walk back from (C,n)(C, n). If profit[r][c]=profit[r][c−1]\text{profit}[r][c] = \text{profit}[r][c-1], object cc was left: go to column c−1c-1. Otherwise it was taken: go to row r−wcr - w_c, column c−1c-1. Stop at column 0. (Or store parent pointers while filling.)

Example 2: at (20,4)(20,4), $22 = $ value at (20,3)(20,3), so object 4 is left. At (20,3)(20,3), 22≠1322 \ne 13, so object 3 is taken; go to (12,2)(12,2). There 13≠713 \ne 7, so object 2 is taken; go to (6,1)=7(6,1) = 7, so object 1 is taken. Objects 1, 2, 3: weight 18≤2018 \le 20, profit 22.

Cost: pseudo-polynomial

The table has (C+1)(n+1)(C+1)(n+1) cells at O(1)O(1) each: time and space O(nC)O(nC). This is not polynomial in the input size. The size is the number of bits, and CC takes only log⁡2C\log_2 C bits to write:

CC Bits to write CC Rows in the table
20 5 21
1,000 10 1,001
1,000,000 20 1,000,001
1,000,000,000 30 1,000,000,001

Ten more bits multiply the rows by about 1000. With bb bits, C≈2bC \approx 2^b and the time is about n⋅2bn \cdot 2^b: exponential in the input size. An algorithm is pseudo-polynomial if its time is polynomial in the numeric value of the input. Knapsack DP is fast only when CC is small.


8. Shortest Paths in a DAG

(Not from the lecture: the slides were not released. This is the standard method.)

  1. Sub-problem: dist(v)dist(v), the shortest distance from the source ss to vv.
  2. Recursion: the last edge into vv comes from some uu. By optimality, the path to uu is shortest. Try every incoming edge and take the minimum:

dist(s)=0,dist(v)=min(u,v)∈E(dist(u)+w(u,v)).dist(s) = 0, \qquad dist(v) = \min_{(u, v) \in E} \big(dist(u) + w(u, v)\big).

A vertex with no incoming edge (other than ss) has distance ∞\infty.

  1. Order: compute in topological order (Chapter 5), so every uu is done before vv.

Cost: each edge is used once, so O(|V|+|E|)O(|V| + |E|). Unlike Dijkstra (Chapter 6), this works with negative weights, since a DAG has no cycle. Replace min by max for the longest path.


9. Choosing the Table and the Cost

The sub-problem is indexed by Table Fill order
One number ii 1-D array i=0,1,2,…i = 0, 1, 2, \dots
Two prefixes (i,j)(i, j), or a grid 2-D array row by row, left to right
An interval (i,j)(i, j) of one sequence 2-D array by increasing length j−ij - i
A size and a capacity 2-D array by capacity, then by object

(General guidance, not from the lecture.)

DP, divide and conquer, and greedy

Statement D&C Greedy DP
Builds larger solutions from smaller ones Yes — Yes
Sub-problems are independent Yes — No
Stores sub-problem solutions and reuses them No No Yes
Divides at fixed points (such as the middle) Yes — No
Picks the locally best choice and never undoes it — Yes No
Computes all choices and selects the best — — Yes
Solves in a fixed order (by size, or topological) — — Yes (bottom-up)
Always optimal — Only with a proof Yes, if optimality holds

10. Common Traps