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
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:
| 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 be the number of calls. Bound the tree depth two ways:
- Shortest branch: subtract 2 each time. Every node splits while its argument is at least 2, so the top of the tree is a full binary tree down to depth .
- Deepest branch: subtract 1 each time, giving depth .
A full binary tree of depth has nodes. So
For
:
.
Both bounds are exponential, so plain fib takes
time. For
the lower bound alone is about 67 million calls. Only
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:
- solved: retrieve the answer in ;
- not solved: solve it, then store the answer.
A purple node costs , 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
(“not solved”). member tests Soln[j] != -1,
store sets Soln[j] = s, and
retrieve returns Soln[j].
Trace for fibDP(6) (.
means still
):
| Event | Soln[0] |
[1] |
[2] |
[3] |
[4] |
[5] |
[6] |
|---|---|---|---|---|---|---|---|
fibDP(1) stores base |
. | 1 | . | . | . | . | . |
fibDP(0) stores base |
0 | 1 | . | . | . | . | . |
fibDP(2) stores
|
0 | 1 | 1 | . | . | . | . |
fibDP(3) stores
|
0 | 1 | 1 | 2 | . | . | . |
fibDP(4) stores
|
0 | 1 | 1 | 2 | 3 | . | . |
fibDP(5) stores
|
0 | 1 | 1 | 2 | 3 | 5 | . |
fibDP(6) stores
|
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
.
Total:
,
against
.
3. The Order of Sub-problems
The subproblem graph
- One vertex for each different sub-problem.
- An edge if solving makes a recursive call directly on .
The call tree had 25 nodes. The graph has 7 vertices.
- It must be a DAG (Chapter 5). A cycle means a sub-problem needs its own answer.
- Fill order: solve each sub-problem after everything it points to: a reverse topological order. For Fibonacci: .
- Vertices = number of sub-problems = dictionary size.
For Fibonacci: . 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];
| 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: 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 is a shortest path, then is a shortest path from to (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. | : the -th Fibonacci number. |
| 2 | Write the recursive function and its base cases. | ; , . |
| 3 | Draw the subproblem graph (a DAG). Find a topological order. | Vertex points to , . Order . |
| 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 and is a subsequence of both. An LCS is a common subsequence of maximum length. It may not be unique.
Problem. For and , find the length . Brute force checks all subsequences of .
Steps 1 and 2: sub-problem and recursion
is the length of an LCS of the prefixes and (, ). Prefixes appear because we look at the last character of each, and what remains is a pair of shorter prefixes. Compare with :
- . Some LCS ends with this match (standard argument, not in the lecture). Before it must come an LCS of and , or cut-and-paste gives a longer one. Length: .
- . They cannot both end a common subsequence. So is unused, giving , or is unused, giving . We do not know which, so compute both and take the larger.
Steps 3 to 5: graph, table, code
Cell depends on up-left , above and 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: cells at each. Time , space .
Recovering the subsequence
Keep a hint array h that records where
each cell’s value came from:
| Hint | Meaning | Came from |
|---|---|---|
\ |
; character is in the LCS | |
| |
above | |
— |
left |
Tie rule: if
,
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 or , so the traceback is .
Example 1 (lecture): = ACGGA, = ACTG
Five cells in fill order:
- : A = A, so .
- : C = C, so .
- : G T. Above 2, left 2. Tie, take above: 2.
- : G = G, so .
- : A G. Above 3, left 2. Take above: 3.
| 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
:
up to
;
\ records G; up from
to
;
left to
;
\ records C; \ at
records A. Reversed, the LCS is ACG,
length 3.
Example 2 (lecture): = CGGTAT, = 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
:
left, left to
;
\ records T; up to
;
\ records T; \ at
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
- Write down the side and across the top. Add a row 0 and a column 0 of zeros.
- Each cell: if the characters match, take the upper-left value . Otherwise take the larger of above and left (tie: above).
- 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 (a positive integer). Object has weight and profit (positive integers). Find the largest total profit of a subset with total weight at most . “0/1” means each object is taken whole or left, never split or repeated. Brute force checks subsets.
Steps 1 and 2: sub-problem and recursion
is the largest profit from the first objects with capacity (, ). The answer is . (The lecture writes for both; is the variable capacity.) Look at the last object . The best solution takes it or leaves it:
- Take (needs ): profit , capacity left , so . The rest must be optimal by cut and paste: a better set of weight at most still fits with object .
- Leave: .
- If , only “leave” is possible.
We do not know which is better, so compute both and take the larger.
Both sub-problems use objects, so each object is decided once and never taken twice.
Steps 3 to 5: graph, table, code
points to (leave) and (take). Every edge lowers , so it is a DAG. Example: with four objects.
| Object | 1 | 2 | 3 | 4 |
|---|---|---|---|---|
| Weight | 4 | 6 | 8 | 6 |
| Profit | 7 | 6 | 9 | 5 |
The table profit[C+1][n+1] has rows =
capacities and columns = objects. Row 0 and
column 0 are 0. Fill so that column
is done before column
.
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):
| Object | 1 | 2 | 3 |
|---|---|---|---|
| Weight | 1 | 2 | 3 |
| Profit | 1 | 4 | 6 |
| 0 | 0 | 0 | 0 | 0 |
| 1 | 0 | 1 | 1 | 1 |
| 2 | 0 | 1 | 4 | 4 |
| 3 | 0 | 1 | 5 | 6 |
- : object 2 weighs 2 > 1, so it is left: 1.
- : leave 1; take . Best 4.
- : leave 1; take . Best 5.
- : leave 5; take . Best 6.
The answer is 6: object 3 alone. Objects 1 and 2 together weigh 3 but give only 5.
Example 2 (lecture):
Rows 0 to 3 are all 0 (the lightest object weighs 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 |
- : leave 0; take . Best 7.
- : leave 7; take . Best 9.
- : leave 7; take . Best 13.
- : leave 13; take . Best 22.
- : leave 22; take . Best 22.
Which objects were taken?
Walk back from . If , object was left: go to column . Otherwise it was taken: go to row , column . Stop at column 0. (Or store parent pointers while filling.)
Example 2: at , $22 = $ value at , so object 4 is left. At , , so object 3 is taken; go to . There , so object 2 is taken; go to , so object 1 is taken. Objects 1, 2, 3: weight , profit 22.
Cost: pseudo-polynomial
The table has cells at each: time and space . This is not polynomial in the input size. The size is the number of bits, and takes only bits to write:
| Bits to write | 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 bits, and the time is about : 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 is small.
8. Shortest Paths in a DAG
(Not from the lecture: the slides were not released. This is the standard method.)
- Sub-problem: , the shortest distance from the source to .
- Recursion: the last edge into comes from some . By optimality, the path to is shortest. Try every incoming edge and take the minimum:
A vertex with no incoming edge (other than ) has distance .
- Order: compute in topological order (Chapter 5), so every is done before .
Cost: each edge is used once, so . 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 | 1-D array | |
| Two prefixes , or a grid | 2-D array | row by row, left to right |
| An interval of one sequence | 2-D array | by increasing length |
| A size and a capacity | 2-D array | by capacity, then by object |
(General guidance, not from the lecture.)
- A cell that reads a fixed number of cells (Fibonacci 2, LCS 3, knapsack 2) costs .
- A cell that takes a max, min or sum over all smaller values costs . Multiply by the number of cells.
- The plain recursion is usually exponential. To confirm, write its cost as a recurrence and solve it with Chapter 8.
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
- Overlap. DP needs overlapping sub-problems. Without overlap, plain divide and conquer is enough.
- Optimality. DP is correct only if the principle of optimality holds.
- Memoization is top-down, tabulation is bottom-up. Same complexity.
- Order. The subproblem graph must be a DAG. Fill dependencies first (reverse topological).
- Two factors. Time (sub-problems) (work per sub-problem). Do not forget the second when a cell loops over many choices.
- Fibonacci. Plain: calls, between and . Memoized: time. Two variables: space.
- Define first. Write the sub-problem in words before step 2. A vague step 1 breaks the rest.
- Subsequence vs substring. A subsequence keeps order but need not be contiguous. A substring must be contiguous.
- LCS rules. Match: diagonal . Mismatch: larger of above and left, never the diagonal. Time and space , traceback . Tie: take above. An LCS may not be unique.
- Memory saving loses the path. Two variables (or two rows) save memory, but you can no longer trace back.
- Knapsack axes. Rows are capacities, columns are
objects:
profit[r][c]. - Take reads the previous column (). Reading the same column lets an object be taken twice.
- Too heavy. If , “take” does not exist. Do not read a negative row.
- Pseudo-polynomial. is not polynomial, because is a value, not a size.
- Greedy fails. Profit per unit weight does not solve 0/1 knapsack.