Dynamic programming
Recognize optimal substructure and overlapping subproblems, and build DP solutions.
When divide-and-conquer leads to subproblems that overlap, naive recursion re-solves the same piece exponentially many times. Dynamic programming saves each subproblem's answer the first time it is computed and reuses it everywhere it appears[1]. The result is often a polynomial-time algorithm where the recursive formulation alone is exponential[2].
When DP applies
Dynamic programming works when the problem has two properties:
- Optimal substructure. The optimum of the original problem is constructed from the optima of smaller subproblems.
- Overlapping subproblems. The same subproblems appear many times in the recursive decomposition.
If only property 1 holds (and subproblems are disjoint), divide-and-conquer suffices. If only property 2 holds, memoization helps but the algorithm need not give an optimum. Both together unlock DP.
The Fibonacci illustration
The naive recursion takes time because it re-computes for every many times. The recursion tree is huge but contains only distinct subproblems. Memoizing them (saving each the first time it is computed and returning the saved value on subsequent calls) collapses the runtime to . Same algorithm, exponentially better.
Top-down vs bottom-up
The same DP can be written two ways:
- Top-down (memoized recursion). Write the recurrence directly; check a table at the start of each call and store the result before returning. Natural and declarative; the call stack costs are usually negligible.
- Bottom-up (tabulation). Decide an order in which subproblems can be solved with only already-solved smaller ones; fill a table iteratively. Avoids recursion and usually has smaller constants.
Both have the same asymptotic complexity. Top-down is easier to derive and modify; bottom-up is easier to reason about for space optimization and is what gets shipped.
A typical DP: longest common subsequence
Given two strings and , the longest common subsequence (LCS) is the longest sequence that appears in both as a (not necessarily contiguous) subsequence. Let be the LCS length of the prefixes and . The recurrence:
Optimal substructure: the LCS of two prefixes is built from LCSs of one-shorter prefixes. Overlapping subproblems: is reused in many cells. The table has entries each computed in , so the runtime is . The back-pointer trick (recording at each cell which case was used) lets you reconstruct the actual subsequence, not just its length, in .
Knapsack, edit distance, and beyond
The same template handles:
- 0/1 knapsack. = best value using items with weight budget .
- Edit distance. = minimum insertions, deletions, and substitutions to turn into . The recurrence has the same shape as LCS.
- Shortest paths in a DAG. Topological order + DP gives .
- Matrix-chain order. = minimum scalar multiplications for the product .
A DP solution is "find the right table indexing, write the recurrence, prove the order in which cells can be filled".
Check it on arrays
# Fibonacci: naive vs memoized
import time, sys
sys.setrecursionlimit(5_000)
def fib_naive(n):
if n < 2: return n
return fib_naive(n - 1) + fib_naive(n - 2)
def fib_memo(n, cache={}):
if n < 2: return n
if n in cache: return cache[n]
cache[n] = fib_memo(n - 1) + fib_memo(n - 2)
return cache[n]
n = 30
t = time.perf_counter(); fib_naive(n); t_naive = time.perf_counter() - t
t = time.perf_counter(); fib_memo(n); t_memo = time.perf_counter() - t
print(f"fib({n}) naive: {t_naive:.4f}s memo: {t_memo*1e6:.1f} us speedup ~ {t_naive/max(t_memo,1e-9):.0e}x")
# LCS bottom-up tabulation
def lcs(X, Y):
m, n = len(X), len(Y)
L = [[0] * (n + 1) for _ in range(m + 1)]
for i in range(1, m + 1):
for j in range(1, n + 1):
if X[i-1] == Y[j-1]:
L[i][j] = L[i-1][j-1] + 1
else:
L[i][j] = max(L[i-1][j], L[i][j-1])
# Reconstruct subsequence from back-pointers
i, j, out = m, n, []
while i > 0 and j > 0:
if X[i-1] == Y[j-1]:
out.append(X[i-1]); i -= 1; j -= 1
elif L[i-1][j] >= L[i][j-1]:
i -= 1
else:
j -= 1
return L[m][n], "".join(reversed(out))
length, seq = lcs("AGCAT", "GAC")
print(f"LCS length={length}, sequence='{seq}'") # 2, 'GA' or 'AC'
length, seq = lcs("kitten", "sitting")
print(f"LCS length={length}, sequence='{seq}'") # 4, 'ittn'The memoized Fibonacci is orders of magnitude faster than the exponential naive version, and the LCS table both gives the length and reconstructs the sequence.
Exercises
Exercise 1. Fill the LCS table for "ABCBDAB" and "BDCAB" by hand using the recurrence, and read off the LCS length.
Solution
Step 1. Index rows by (length 7) and columns by (length 5), with row 0 and column 0 all zeros. Apply when , else . The completed table (rows , columns ):
| "" | B | D | C | A | B | |
|---|---|---|---|---|---|---|
| "" | 0 | 0 | 0 | 0 | 0 | 0 |
| A | 0 | 0 | 0 | 0 | 1 | 1 |
| B | 0 | 1 | 1 | 1 | 1 | 2 |
| C | 0 | 1 | 1 | 2 | 2 | 2 |
| B | 0 | 1 | 1 | 2 | 2 | 3 |
| D | 0 | 1 | 2 | 2 | 2 | 3 |
| A | 0 | 1 | 2 | 2 | 3 | 3 |
| B | 0 | 1 | 2 | 2 | 3 | 4 |
Step 2. The bottom-right cell is the LCS length. One LCS is "BDAB" (also "BCAB"), of length 4.
Exercise 2. Explain why naive recursive Fibonacci is while it
contains only distinct subproblems, and why memoization drops it to
. Count the number of calls to fib_naive(0) plus fib_naive(1) when
computing to make the redundancy concrete.
Solution
Step 1. Without caching, calls and , which call their own predecessors. The number of leaf calls (those hitting the base cases) is exactly , and the total node count of the call tree grows like where . Each of the distinct values is recomputed many times, once per path that reaches it.
Step 2. Concretely for : the base-case calls number . Counting
by hand, fib_naive(1) is called 5 times and fib_naive(0) 3 times, totaling 8
base hits to produce a single answer.
Step 3. Memoization stores each on first computation. There are distinct subproblems, each solved once at work, so the runtime is . The exponential blowup was entirely repeated work, not distinct work.
Exercise 3 (code). Implement the 0/1 knapsack DP = best value using items within weight budget , with a back-pointer reconstruction of which items are chosen. Verify on weights , values , budget 7, that the optimum is value 9 (items of weight 3 and 4).
Solution
import numpy as np
weights = [1, 3, 4, 5]
values = [1, 4, 5, 7]
budget = 7
nI = len(weights)
K = np.zeros((nI + 1, budget + 1), dtype=int)
for i in range(1, nI + 1):
w, v = weights[i-1], values[i-1]
for c in range(budget + 1):
K[i][c] = K[i-1][c] # skip item i
if w <= c: # or take it
K[i][c] = max(K[i][c], K[i-1][c-w] + v)
# Reconstruct: an item was taken when its row improved on the row above.
chosen, c = [], budget
for i in range(nI, 0, -1):
if K[i][c] != K[i-1][c]:
chosen.append(i - 1)
c -= weights[i-1]
best = int(K[nI][budget])
assert best == 9
assert sorted(weights[j] for j in chosen) == [3, 4]
print("optimum:", best, " items (0-indexed):", sorted(chosen))The table's bottom-right cell holds the optimum 9, and walking the back-pointers recovers the weight-3 and weight-4 items, the pair the greedy value-per-weight rule would miss.
The next lesson examines when a greedy strategy, no table needed, can also reach an optimum.
References
- [1]Bellman, R. (1957). Dynamic Programming. Princeton University Press. The original formulation of the principle of optimality.↩
- [2]Cormen, T. H., Leiserson, C. E., Rivest, R. L., & Stein, C. (2009). Introduction to Algorithms (3rd ed.). MIT Press. Dynamic programming, optimal substructure, and overlapping subproblems (Ch. 15).↩