🧩 Data Structures & Algorithms · Chapter 16
Dynamic programming, from recursion
Dynamic programming isn't a new algorithm. It's your slow recursive solution, plus a notebook so it never solves the same subproblem twice.
Study this lesson free in the app →
The basics
Dynamic programming (DP) solves a problem by breaking it into smaller subproblems, solving each once, storing the answer, and reusing it whenever that subproblem comes up again.
The problem it solves: many natural recursive solutions recompute the same thing over and over. Naive fib(40) makes over 300 million calls, yet there are only 41 different questions being asked. Store each answer the first time and the work drops from exponential to linear.
The mental model: someone writes 1+1+1+1+1+1+1+1 on a board and asks for the total. You count: 8. They add "+1" at the end. You answer 9 instantly — you didn't recount, you remembered 8. That's DP.
The recipe: (1) write the recursion, (2) spot repeated calls, (3) cache them (memoisation, top-down) or fill a table from the smallest case up (tabulation, bottom-up), (4) keep only the rows you still need.
Cost: time = number of distinct subproblems × work per subproblem. Space = size of the table (or memo), often reducible.
Words you must know 5
- Overlapping subproblems
- the same smaller question is asked many times
- Optimal substructure
- the best answer is built from best answers to subproblems
- State
- the parameters that identify a subproblem, e.g.
nor(row, col) - Recurrence
- the formula linking a state to smaller states
- Memoisation / tabulation
- top-down cache / bottom-up table
Explained
Naive fib(40): 331 million function calls, several seconds or more in Python.
Same code with one line added — @cache — 79 calls, under a millisecond.
That one line is the whole idea of dynamic programming. The rest is learning to spot when it applies and how to write it without recursion.
The tiled path
A garden path is 5 tiles long. A child crosses it taking either 1 tile or 2 tiles per step. How many different ways can she cross?
She stands before the last tile. She arrived either from tile 4 (a 1-step) or from tile 3 (a 2-step). So:
ways(5) = ways(4) + ways(3)
And ways(4) = ways(3) + ways(2)… Notice ways(3) is needed twice already. Work up from the start instead: ways(1) = 1, ways(2) = 2, then 3, 5, 8. Five additions, each reusing the two answers before it. No one recounts a path they've already counted.
From recursion to DP in four moves
- Define the state — what identifies a subproblem? For stairs:
n, the number of tiles left. - Write the recurrence + base cases —
ways(n) = ways(n-1) + ways(n-2),ways(1) = 1,ways(2) = 2. - Remove repeats
- Top-down (memoisation): keep the recursion, cache results by state. - Bottom-up (tabulation): fill dp[1], dp[2], …, dp[n] in a loop, so every value you need is already computed.
- Shrink the space — if
dp[i]only readsdp[i-1]anddp[i-2], keep two variables.
Check the two ingredients first: overlapping subproblems (otherwise caching does nothing — merge sort never repeats a call) and optimal substructure (the best answer is made of best sub-answers).
The waste: naive fib(6)
25 calls to compute fib(6) = 8. fib(4) is computed twice, fib(3) three times, fib(2) five times. The repeats are the overlapping subproblems — the tree grows by about ×1.6 for each +1 to n.
The fix: memoised fib(6)
Each fib(k) is computed once and written down; later requests are lookups. The counter shows 11 calls, but 4 of them are instant memo hits — only 7 (fib(0) to fib(6)) do real work — O(n) time, O(n) space for the memo and the stack. Same answer, a fraction of the work.
Fibonacci, four ways
from functools import cache
def fib_naive(n): # O(2ⁿ) time, O(n) stack
return n if n <= 1 else fib_naive(n - 1) + fib_naive(n - 2)
@cache
def fib_memo(n): # top-down: O(n) time, O(n) memo + stack
return n if n <= 1 else fib_memo(n - 1) + fib_memo(n - 2)
def fib_table(n): # bottom-up: O(n) time, O(n) table, no recursion
if n <= 1:
return n
dp = [0] * (n + 1)
dp[1] = 1
for i in range(2, n + 1):
dp[i] = dp[i - 1] + dp[i - 2]
return dp[n]
def fib_roll(n): # O(n) time, O(1) space
a, b = 0, 1
for _ in range(n):
a, b = b, a + b
return a
fib_roll(10) # 55Same recurrence each time. Only the bookkeeping changes — and with it, the time and space.
Counting the cost of a DP
Time = number of states × work per state. Space = what you keep.
problem state time space (best)
──────────────────────────────────────────────────────────────
fib / stairs n O(n) O(1) rolling
coin change amount O(amount × k) O(amount)
grid paths m × n (r, c) O(m × n) O(n) one row
0/1 knapsack (item, cap) O(n × W) O(W) one row
longest common (i, j) O(m × n) O(min(m, n))
subsequence(k = number of coin types, W = capacity.)
Watch the hidden size: knapsack with W = 10⁹ is O(n × 10⁹) — 'polynomial' in the number, but far too slow. DP pays when the number of distinct states is small, even if the number of paths through them is astronomically large: a 20 × 20 grid has over 35 billion right/down paths, but only 400 states.
Gotchas
- Caching without overlap —
@cacheon merge sort or a tree walk just wastes memory; no state repeats. - Greedy instead of DP — 'always take the biggest coin' fails for {1, 3, 4} → 6. Prove greedy or use DP.
- Wrong state — if the answer depends on something not in the state (e.g. remaining capacity), cached answers get reused where they don't apply.
- Deep memoised recursion in Python —
fib_memo(5000)from cold raises RecursionError. Tabulate, or warm the cache bottom-up. - Mutable arguments —
@cacheneeds hashable arguments; pass tuples, not lists. - Wrong loop order in tabulation — every
dp[x]you read must already be filled; iterate from base cases outward. - Off-by-one base cases —
dp[0] = 0for coins (zero coins make zero) butways(0) = 1for counting (one way to do nothing).
Words to know
| Dynamic programming | Solving a problem by combining solutions to overlapping subproblems, each computed once and stored. |
|---|---|
| Overlapping subproblems | The same smaller subproblem is needed many times by a recursive solution. |
| Optimal substructure | An optimal solution can be built from optimal solutions to its subproblems. |
| State | The set of parameters that uniquely identifies a subproblem, such as n or (row, col). |
| Recurrence | The formula that expresses a state's answer in terms of smaller states. |
| Memoisation | Top-down DP: recursion plus a cache keyed by the state. |
| Tabulation | Bottom-up DP: a loop that fills a table from the base cases up to the answer. |
In two minutes
- DP = recursion + reuse: solve each distinct subproblem once and store it.
- It applies when subproblems overlap and the best answer is built from best sub-answers (optimal substructure).
- Recipe: define the state, write the recurrence and base cases, then memoise (top-down) or tabulate (bottom-up).
- Time = distinct states × work per state. Fibonacci: n states × O(1) = O(n), down from O(2ⁿ).
- Space = the table. Often only the last row or last two values are needed: fib drops to O(1) space.
- Memoisation is easiest to write but recurses (Python's ~1,000-frame limit); tabulation is a loop with no depth limit.
- Classic shapes: climbing stairs / Fibonacci, coin change O(amount × coins), grid paths O(rows × cols), knapsack O(n × capacity).
Test yourself
1. Which two properties make a problem a good fit for dynamic programming?
- Overlapping subproblems and optimal substructure
- Sorted input and random access
- A greedy choice property and a heap
- A recursive definition and a base case
Show the answer
Overlapping subproblems and optimal substructure — Without overlap, caching saves nothing (merge sort is recursive with a base case but never repeats a subproblem). Without optimal substructure, combining best sub-answers doesn't give the best answer.
2. What is the difference between memoisation and tabulation?
- Memoisation is bottom-up; tabulation is top-down
- They have different time complexities for the same recurrence
- Memoisation is top-down recursion with a cache; tabulation is a bottom-up loop that fills a table
- Tabulation requires recursion
Show the answer
Memoisation is top-down recursion with a cache; tabulation is a bottom-up loop that fills a table — Both compute each state once, so they usually share the same big-O. Memoisation only computes states actually reached and is easier to write; tabulation avoids recursion depth limits and makes space optimisation easy.
3. How do you estimate the time complexity of a DP solution?
- Recursion depth × 2
- Number of distinct states × work done per state
- Input size squared, always
- Number of base cases
Show the answer
Number of distinct states × work done per state — Each state is solved once. Coin change has amount states and tries k coins each → O(amount × k). Grid paths: m × n states × O(1) → O(mn).
4. You can climb 1 or 2 steps at a time. How many distinct ways are there to climb 5 steps?
- 5
- 10
- 13
- 8
Show the answer
8 — ways(n) = ways(n−1) + ways(n−2) with ways(1) = 1, ways(2) = 2 → 1, 2, 3, 5, 8. 13 is the answer for 6 steps — a common off-by-one.
5. Coins are {1, 3, 4}. A greedy method (largest coin first) makes 6 using how many coins?
- 2 — greedy is optimal here
- 1
- 4
- 3 (4 + 1 + 1), although 3 + 3 needs only 2
Show the answer
3 (4 + 1 + 1), although 3 + 3 needs only 2 — Greedy grabs 4, then is left with 2 = 1 + 1. DP compares every last-coin choice: dp[6] = min(dp[5], dp[3], dp[2]) + 1 = dp[3] + 1 = 2. Greedy happens to work for most real currency systems, which is why people trust it too much.
6. fib_table(n) uses an O(n) list. How do you cut the space?
- Switch back to recursion
- Keep only the last two values — O(1) space, still O(n) time
- Store the table in a dictionary
- It can't be reduced
Show the answer
Keep only the last two values — O(1) space, still O(n) time — Each value reads only the previous two, so older entries are never touched again. Recursion uses O(n) stack; a dict is still O(n).
There are 8 questions, 4 puzzles and 6 recall cards for this lesson in the app — the recall cards come back on later days, just before you would forget.
Start this lesson →