S033 · Dynamic Programming — Memoisation & Tabulation
Break the problem down, cache the answer. Overlapping subproblems + optimal substructure = polynomial time from exponential recursion. Two dialects (top-down memo, bottom-up tabulation), one recipe (state → transition → base case → order), and the interview-favorite classics: Fibonacci, climbing stairs, coin change, LIS, edit distance.
🎯 Convert a naive recursion into a memoised or tabulated DP in under five minutes using the four-step recipe: state, transition, base case, order.
Why this session exists
Dynamic programming is not an algorithm; it is a method for making already-known recursions polynomial. Half the medium-difficulty interview problems and roughly all of sequence alignment, speech decoding, ML beam search, compiler code generation, and OR route planning are DP under the hood. Most beginners bounce off it because they try to memorise problems; the trick is to memorise the recipe, and derive every specific DP in five minutes on paper. That is what this session teaches.
- Recognise the two DP prerequisites (optimal substructure + overlapping subproblems) in a fresh problem.
- Apply the 4-step recipe: define state, write the transition, name the base case, choose the fill order.
- Convert a plain recursion to a memoised version by adding `@lru_cache` and to a tabulated version by inverting the fill order.
- Explain the space-vs-time trade of memo vs tabulation, and when to prefer each.
- Compute Fibonacci, climbing stairs, coin change, LIS (O(n²) and O(n log n)), and edit distance without reference.
Prerequisites
- S027 — Recursion — Call Stack, Base Case — DP is recursion + cache.
- S023 — Arrays & Lists — tabulation lives in a 1-D or 2-D array.
- S032 — Binary Search — the O(n log n) LIS uses it.
(a) Intuition · 5 min
You ask a mathematician "what is fib(30)?" They compute fib(29) + fib(28). For fib(29) they compute fib(28) + fib(27). For that fib(28) — the one they just computed — they start over from scratch. Total work: a billion additions to answer one question.
A note-taking mathematician writes each answer in a notebook. When fib(28) comes up again, they look it up in one glance. Total work: 30 additions. That's the entire idea of DP.
Naive fib(n) is O(φⁿ) ≈ exponential because the call tree revisits the same subproblem exponentially many times. Add a cache keyed by n and each subproblem is computed once; the recursion collapses from exponential to linear.
The formal name for the property that makes this work is overlapping subproblems. Combined with optimal substructure (the answer to the big problem is composed of answers to smaller ones), you have a DP.
- Optimal substructure — the answer to `f(n)` is computable from answers to `f(k)` for some k < n. Fibonacci has it. Sorting a random list does not (no smaller sub-answer helps).
- Overlapping subproblems — the naive recursion revisits the same subproblem many times. Fibonacci has it (fib(28) appears exponentially often). Merge sort does not (each subproblem is visited once, which is why memoising merge sort gains you nothing).
- If you only have optimal substructure without overlap → divide-and-conquer. If you have both → DP. If you have neither → greedy, brute force, or something clever.
- 1953Richard Bellman coins ‘dynamic programming’At RAND, Bellman needed a term that hid ‘mathematical research’ from a defense secretary who hated math. Chose ‘dynamic programming’ because ‘nobody can attack it’.
- 1957Bellman equationFormalises the recursive structure behind optimal control. Underpins Q-learning and modern RL 60 years later.
- 1970Wagner–Fischer edit distanceIndependent rediscovery of the 2-D DP for string alignment. Now inside git, grep, Word spellcheck, and BLAST for DNA.
- 1986Eugene Myers · O(ND) diffShips as the algorithm inside GNU diff and git diff. A DP variant with clever pruning.
- todayBeam search in LLMsEvery large language model uses bounded DP over token positions when generating text with beam search or dynamic programming Viterbi decoders.
(b) Visual walkthrough · 15 min
The naive Fibonacci call tree — an explosion
Red nodes are recomputations. For fib(50), the tree has ~2⁵⁰ ≈ 10¹⁵ nodes. Add a cache and it becomes 51 nodes.
Two dialects of the same idea
Recursion + cache
- Write the natural recursion
- Decorate with `@lru_cache(maxsize=None)`
- Computes only the states you actually need
- Uses stack space (risk of `RecursionError`)
- Easier to write; easier to debug
- Prefer when subproblem space is sparse
Iterative fill of a table
- Allocate a 1-D or 2-D array
- Loop from base cases to the answer
- Computes every state whether you need it or not
- No recursion — no stack limit
- Enables ‘rolling window’ space optimisation
- Prefer when subproblem space is dense and you want O(1) or O(n) memory
The 4-step recipe applied to coin_change(coins, amount)
Problem: given coins [1, 3, 4], find the minimum number of coins summing to amount = 6. Answer: 2 (3 + 3), not 3 (4 + 1 + 1).
`dp[i]` = minimum coins to make amount `i`. One integer parameter → 1-D table.
`dp[i] = 1 + min(dp[i - c] for c in coins if i - c >= 0)`. Try every coin as the last one used.
`dp[0] = 0` (zero coins make amount 0). All others start at `∞`.
Fill `dp[1]`, `dp[2]`, …, `dp[amount]` in ascending order — each state depends only on smaller ones.
Fibonacci: three implementations side by side
The path from exponential to constant-space is four small edits. Every DP problem has an analogous path.
The state-space taxonomy — what kind of DP is this?
Every DP falls into one of these state-space shapes
"Dynamic programming means caching. If I slap @lru_cache on my recursive function, I'm doing DP."
Caching is the mechanism; DP is the precondition. A problem is a DP problem when it has optimal substructure (the optimal answer is built from optimal answers to subproblems) and overlapping subproblems (the same subproblem recurs). Memoising a function without overlap buys nothing but memory. Memoising a problem without optimal substructure gives a fast wrong answer.
Because memoisation genuinely does convert the textbook example — Fibonacci — from exponential to linear, and that transformation is so dramatic it looks like the whole technique. But Fibonacci has both properties by accident of being trivial. The hard part of real DP is never the cache; it is choosing a state definition where the two properties actually hold, and that is a modelling decision the decorator cannot make for you.
Optimal substructure is a property of the problem, not of your code. Longest path in a graph has none — and no amount of caching fixes it:
from functools import lru_cache
# shortest path HAS optimal substructure: a subpath of a shortest path is shortest.
# longest SIMPLE path does NOT: two optimal sub-paths may reuse the same node,
# which the combined path is forbidden to do. The state 'node' is insufficient -
# correctness needs the whole visited set in the state, and then nothing overlaps.
@lru_cache(None)
def fib(n):
return n if n < 2 else fib(n-1) + fib(n-2)
print(fib(200)) # instant: overlap is real, substructure is real
@lru_cache(None)
def merge_sort_len(n): # no overlap - every call sees a distinct slice
return n # the cache here is pure overhead, 0% hit rateWhy can the 0/1 knapsack DP table be collapsed from a 2-D array to a single 1-D array — and why must that 1-D loop then run backwards? Reversing a loop to fix correctness looks like black magic. It is forced.
- 1The recurrence is
dp[i][w] = max(dp[i-1][w], dp[i-1][w-wt] + val)— row i depends only on row i−1.forced by · each item is either taken or not, and both branches consult the state before this item was considered - 2Since only the previous row is ever read, rows 0..i−2 are dead and need not be stored.forced by · nothing in the recurrence reaches back more than one row
- 3So you can overwrite a single array in place — but only if, when computing cell w, the cells you read still hold previous-row values.forced by · the recurrence's right-hand side is defined in terms of row i−1, not row i
- 4The read is at index
w - wt, which is strictly less than w. So the cells you read are to the left of the cell you write.forced by · item weights are positive - 5Iterating w from high to low therefore writes only to cells whose left-hand dependencies have not yet been touched this round; iterating low to high would overwrite them first.forced by · you must consume a value before you clobber it
Therefore the backward loop is not a trick — it is the minimal condition for the in-place array to still represent row i−1 where it is read. Memory drops from O(n·W) to O(W) with zero change to the answer.
And note what this predicts: run the loop forwards and you do not get a bug — you get a different, correct algorithm. A forward loop lets a cell see this-row values, meaning an item can be reused, which is exactly the unbounded knapsack. One loop direction distinguishes "each item once" from "items unlimited". Go verify that; it is the cleanest example anywhere that loop order encodes semantics.
Stop thinking recursively. Think: there is a table. Each cell is a fully answered question about a smaller version of the problem. You fill it in an order that guarantees every dependency is already filled, and the answer to the real problem is sitting in one corner.
Designing a DP is therefore three decisions and nothing else: what does one cell mean (the state), how is a cell computed from other cells (the transition), and what order fills it safely (the topological order of the dependency DAG).
- Define the state as an English sentence first: "dp[i][j] = the best achievable using the first i items with capacity j". If you cannot write that sentence, you do not have a DP yet.
- Complexity is (number of states) × (cost per transition). That is the whole cost model — read it straight off the state definition.
- Top-down memo is easier to write and only visits reachable states; bottom-up is faster and enables space compression. Prototype top-down, ship bottom-up if it matters.
- If the state must include the entire history (a visited set, a full path), there is no overlap and DP does not apply. That is the signal to switch to backtracking or a greedy/heuristic method.
Fire this model the moment you see: "maximum / minimum / count the number of ways" over a sequence of choices · edit distance or sequence alignment · a problem whose brute force is exponential but whose inputs are small integers · Spark's cost-based join reordering (that is DP over subsets) · Viterbi decoding · any optimisation where a choice at step i only interacts with a bounded summary of steps 1..i−1.
The exact DP is O(n·W) and W is huge — a knapsack over budget in paise, or a state space with a continuous dimension. Exact DP, greedy, or approximation?
The senior move is to attack the state definition before attacking the algorithm. Most DP blowups are caused by carrying more state than the transition actually needs — drop one dimension that turns out to be a function of the others and an intractable table becomes trivial.
And be honest about scale. DP is a single-machine, shared-mutable-table algorithm; it does not distribute well because the dependency structure is exactly what parallelism wants to break. At petabyte scale you will almost always be choosing between a provable greedy and a bounded approximation, and the exact DP will be the reference implementation you validate the approximation against on a sample.
(c) Hands-on · 25 min
Save as dp_lab.py, run with python3 dp_lab.py.
"""dp_lab.py — six classic DP problems in one file."""
from __future__ import annotations
import bisect
from functools import lru_cache
from typing import List
# ---------- 1. Fibonacci — three implementations ----------
def fib_naive(n: int) -> int:
"""Exponential. fib_naive(35) already stutters."""
if n < 2:
return n
return fib_naive(n - 1) + fib_naive(n - 2)
@lru_cache(maxsize=None)
def fib_memo(n: int) -> int:
"""Top-down. O(n) time and space."""
if n < 2:
return n
return fib_memo(n - 1) + fib_memo(n - 2)
def fib_tab(n: int) -> int:
"""Bottom-up, O(1) space via rolling variables."""
a, b = 0, 1
for _ in range(n):
a, b = b, a + b
return a
# ---------- 2. Climbing stairs — the ‘hello world’ of DP ----------
def climb_stairs(n: int) -> int:
"""Ways to climb n stairs taking 1 or 2 steps at a time. = fib(n+1)."""
if n <= 2:
return n
dp = [0] * (n + 1)
dp[1], dp[2] = 1, 2
for i in range(3, n + 1):
dp[i] = dp[i - 1] + dp[i - 2]
return dp[n]
# ---------- 3. Coin change — minimum coins ----------
def coin_change(coins: List[int], amount: int) -> int:
"""Fewest coins summing to `amount`, or -1 if impossible."""
INF = float("inf")
dp = [0] + [INF] * amount # dp[0] = 0; rest start unreachable
for i in range(1, amount + 1):
for c in coins:
if c <= i and dp[i - c] + 1 < dp[i]:
dp[i] = dp[i - c] + 1
return dp[amount] if dp[amount] != INF else -1
# ---------- 4. Longest Increasing Subsequence — O(n²) and O(n log n) ----------
def lis_quadratic(nums: List[int]) -> int:
"""dp[i] = LIS ending at i. O(n²) but easy to remember."""
if not nums:
return 0
dp = [1] * len(nums)
for i in range(1, len(nums)):
for j in range(i):
if nums[j] < nums[i]:
dp[i] = max(dp[i], dp[j] + 1)
return max(dp)
def lis_nlogn(nums: List[int]) -> int:
"""Patience-sort DP with binary search — the interview flex."""
tails: List[int] = [] # tails[k] = smallest possible tail of an LIS of length k+1
for x in nums:
i = bisect.bisect_left(tails, x)
if i == len(tails):
tails.append(x) # extend
else:
tails[i] = x # tighten
return len(tails)
# ---------- 5. Edit (Levenshtein) distance — 2-D DP ----------
def edit_distance(a: str, b: str) -> int:
"""Minimum single-character insert/delete/replace to turn `a` into `b`."""
m, n = len(a), len(b)
# dp[i][j] = distance between a[:i] and b[:j]
dp = [[0] * (n + 1) for _ in range(m + 1)]
for i in range(m + 1):
dp[i][0] = i # delete all of a[:i]
for j in range(n + 1):
dp[0][j] = j # insert all of b[:j]
for i in range(1, m + 1):
for j in range(1, n + 1):
if a[i - 1] == b[j - 1]:
dp[i][j] = dp[i - 1][j - 1]
else:
dp[i][j] = 1 + min(
dp[i - 1][j], # delete a[i-1]
dp[i][j - 1], # insert b[j-1]
dp[i - 1][j - 1], # replace
)
return dp[m][n]
# ---------- 6. 0/1 Knapsack — the OR classic ----------
def knapsack(weights: List[int], values: List[int], capacity: int) -> int:
"""Max value packable into capacity; each item usable at most once."""
n = len(weights)
dp = [0] * (capacity + 1) # rolling 1-D over items
for i in range(n):
w, v = weights[i], values[i]
for c in range(capacity, w - 1, -1): # iterate BACKWARDS so we don't reuse item i
dp[c] = max(dp[c], dp[c - w] + v)
return dp[capacity]
# ---------- Demo ----------
if __name__ == "__main__":
print("fib_memo(50) =", fib_memo(50))
print("fib_tab(100) =", fib_tab(100))
print("climb_stairs(10) =", climb_stairs(10)) # 89
print("coin_change([1,3,4], 6) =", coin_change([1, 3, 4], 6)) # 2
print("coin_change([2], 3) =", coin_change([2], 3)) # -1
print("lis_quadratic([10,9,2,5,3,7,101,18]) =", lis_quadratic([10,9,2,5,3,7,101,18]))
print("lis_nlogn([10,9,2,5,3,7,101,18]) =", lis_nlogn([10,9,2,5,3,7,101,18]))
print("edit_distance('kitten', 'sitting') =", edit_distance("kitten", "sitting")) # 3
print("knapsack([2,3,4,5], [3,4,5,6], 5) =", knapsack([2,3,4,5], [3,4,5,6], 5)) # 7Anatomy of the script
Write this in a new file slow_fib.py:
import time, sys
sys.setrecursionlimit(5000)
def fib(n):
if n < 2: return n
return fib(n - 1) + fib(n - 2)
t0 = time.time(); print("fib(35) =", fib(35), "in", time.time() - t0, "s")Run it. Note the ~5–10 second time. Now add exactly ONE line at the top:
from functools import lru_cacheAnd ONE decorator above def fib(n)::
@lru_cache(maxsize=None)
def fib(n):
...Re-run. It's instant. Change fib(35) to fib(1000) — still instant. You've just seen DP.
(d) Production reality · 15 min
Comparing two versions of a file naively (line-by-line diff) is O(n × m) per comparison and can produce noisy, unhelpful diffs when a small change lands amid restructuring.
Every code-review system on the planet uses a DP variant — Myers' O(ND) diff — which is edit distance with a clever pruning that runs in O((n+m) × D) where D is the diff size. Small changes are near-instant; the DP structure guarantees the shortest edit script.
Naive shortest-path (Dijkstra) on a nationwide road graph is too slow to hit sub-100ms ETAs. Preprocessing must trade offline compute for online speed.
Both use contraction hierarchies — a DP-adjacent technique that precomputes shortcut edges via a hierarchical relaxation (Bellman-Ford is a DP on a DAG of iterations). Query time drops from seconds to microseconds by using the precomputed structure.
Greedy decoding of an LLM (pick the highest-probability next token every step) locks in early mistakes and often produces low-quality text. Enumerating all possible sequences is combinatorially infeasible.
Beam search — a bounded DP over token positions. Keep the top-K partial sequences at each step, extend, re-rank, prune. It is Viterbi decoding with a beam width. The state is (position, partial-hypothesis); the transition is "extend by one token"; the base case is the empty sequence.
Where this shows up in the rest of the plan
(e) Recall + stretch · 10 min
Explain-out-loud test
- What is dynamic programming? (one sentence — the word "cache" must appear)
- What are the two things a problem needs for DP to work? (name both)
- What is the 4-step recipe? (say all four in order)
Hub: The 6-Month Learning Plan
Part of a 130-session evergreen learning series. Session structure: intuition → visual → hands-on → production war stories → recall. Duration: 90 minutes.