◐ ai-teacher

🧩 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. n or (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

  1. Define the state — what identifies a subproblem? For stairs: n, the number of tiles left.
  2. Write the recurrence + base cases — ways(n) = ways(n-1) + ways(n-2), ways(1) = 1, ways(2) = 2.
  3. 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.

  1. Shrink the space — if dp[i] only reads dp[i-1] and dp[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)             # 55

Same 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

Words to know

Dynamic programmingSolving a problem by combining solutions to overlapping subproblems, each computed once and stored.
Overlapping subproblemsThe same smaller subproblem is needed many times by a recursive solution.
Optimal substructureAn optimal solution can be built from optimal solutions to its subproblems.
StateThe set of parameters that uniquely identifies a subproblem, such as n or (row, col).
RecurrenceThe formula that expresses a state's answer in terms of smaller states.
MemoisationTop-down DP: recursion plus a cache keyed by the state.
TabulationBottom-up DP: a loop that fills a table from the base cases up to the answer.

In two minutes

Test yourself

1. Which two properties make a problem a good fit for dynamic programming?

  1. Overlapping subproblems and optimal substructure
  2. Sorted input and random access
  3. A greedy choice property and a heap
  4. 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?

  1. Memoisation is bottom-up; tabulation is top-down
  2. They have different time complexities for the same recurrence
  3. Memoisation is top-down recursion with a cache; tabulation is a bottom-up loop that fills a table
  4. 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?

  1. Recursion depth × 2
  2. Number of distinct states × work done per state
  3. Input size squared, always
  4. 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?

  1. 5
  2. 10
  3. 13
  4. 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?

  1. 2 — greedy is optimal here
  2. 1
  3. 4
  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?

  1. Switch back to recursion
  2. Keep only the last two values — O(1) space, still O(n) time
  3. Store the table in a dictionary
  4. 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.

Learn it so it stays. Read it, test yourself, then recall it on two different days. The app schedules that for you, free.

Start this lesson →