Search Tech Journey

Find topics, journeys and posts

6-month learning plan33 / 130
back to blog
pythonbeginner 55m read

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.

🧩DSAM03 · Data Structures & Algorithms· Session 033 of 130 90 min

🎯 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.

You will be able to
  • 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



(a) Intuition · 5 min

The forgetful vs the note-taking mathematician
🌍 Real world

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.

💻 Code world

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.

The two prerequisites — check both before writing 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.
  1. 1953
    Richard 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’.
  2. 1957
    Bellman equation
    Formalises the recursive structure behind optimal control. Underpins Q-learning and modern RL 60 years later.
  3. 1970
    Wagner–Fischer edit distance
    Independent rediscovery of the 2-D DP for string alignment. Now inside git, grep, Word spellcheck, and BLAST for DNA.
  4. 1986
    Eugene Myers · O(ND) diff
    Ships as the algorithm inside GNU diff and git diff. A DP variant with clever pruning.
  5. today
    Beam search in LLMs
    Every 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

Top-down · memoisation

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
Bottom-up · tabulation

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).

1state
1. State

`dp[i]` = minimum coins to make amount `i`. One integer parameter → 1-D table.

2transition
2. Transition

`dp[i] = 1 + min(dp[i - c] for c in coins if i - c >= 0)`. Try every coin as the last one used.

3base
3. Base case

`dp[0] = 0` (zero coins make amount 0). All others start at `∞`.

4order
4. Order

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

1-D over position
Fibonacci, climbing stairs, house robber, coin change. State = one integer.
1D
1-D over subset (bitmask)
Travelling salesman (n ≤ 20), assignment problems. State = which subset of items is done.
bitmask
2-D over two sequences
Edit distance, longest common subsequence, matrix chain. State = (i, j).
2D
2-D over position + resource
Knapsack (item index × remaining capacity), stock problems (day × holding-state).
resource
DP on trees
Diameter of a tree, subtree sums. Recursion on children, memoise per node.
tree
DP on DAGs
Longest path, topological-order relax. Same as 1-D but the order is a topological sort.
graph

Common misconception
✗ What most people think

"Dynamic programming means caching. If I slap @lru_cache on my recursive function, I'm doing DP."

✓ What is actually true

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.

Why the myth is so sticky

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.

Prove it to yourself

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 rate
From first principles
Start with the question

Why 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.

  1. 1
    The 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
  2. 2
    Since 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
  3. 3
    So 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
  4. 4
    The 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
  5. 5
    Iterating 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

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.

Mental modelFill the table, then read one cell

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.
🔔 Fires when you see

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 tradeoff

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?

Exact DP
+ you gain provably optimal, and the answer is auditable — you can reconstruct the exact choices that produced it, which matters when a human must defend the number
− you pay O(states × transition), and the state space explodes with each added dimension; note the table is O(n·W) in the value W, which is exponential in W's bit length — this is why knapsack is NP-hard despite the "polynomial" table
pick when the state space is genuinely bounded and fits in memory — typically when the numeric dimension is in the millions, not the billions
Greedy
+ you gain O(n log n) or better, trivial memory, trivial to explain and to operate
− you pay optimal only when the problem has the matroid / exchange property; otherwise it is arbitrarily bad and gives you no error bound at all, so you cannot even tell how wrong you are
pick when you can actually prove the exchange argument (interval scheduling, Huffman, MST) — never on the grounds that it "seems reasonable"
Scale/round the state, or use an FPTAS
+ you gain bounded, chosen error: divide values by a factor and you shrink the table by that factor while the approximation ratio degrades predictably
− you pay you must be able to state and defend the error bound, and rounding can interact badly with hard constraints (a budget you must not exceed)
pick when the exact table does not fit but a documented ε of error is acceptable — the normal answer for large-scale resource allocation
What a senior engineer actually does

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))    # 7

Anatomy of the script

`@lru_cache(maxsize=None)`
The one-line memoisation. maxsize=None = grow forever. Great for pure functions of hashable args; do NOT use on methods with mutable `self`.
memo
`fib_tab` uses O(1) space
Because each state depends only on the previous two, we keep two variables instead of an n-sized array. This ‘rolling window’ trick appears in half of all 1-D DPs.
rolling
`coin_change` outer loop = amount, inner = coins
Order matters — we're computing dp[i] using previously-filled dp[i-c] values. If the loops were nested the other way, we'd be counting ordered combinations, which is a different problem.
order
`lis_nlogn` — patience sort
`tails` is NOT the LIS itself; it's the smallest-tail of each length. bisect_left gives O(log n) per element for O(n log n) total. The proof it works is worth reading once — it's magic.
trick
`edit_distance` — 2-D table with a border row/column
Border represents ‘transform empty string to X = X inserts’. This trick — pad by 1 to avoid a base-case branch inside the loop — appears in every 2-D DP.
2D
`knapsack` iterates capacity BACKWARDS
So we don't accidentally use item i twice. Iterating forwards would be the unbounded-knapsack DP. Direction encodes ‘can I reuse items?’
direction
Try itConvert a naive recursion into a memoised DP in under 60 seconds

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_cache

And 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.

💡 Hint · This is the fastest way to feel the exponential-to-linear collapse.

(d) Production reality · 15 min

War story Google · git diff / code review· 2010billions of diffs · every code review, everywhere
🔥 What broke

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.

🧯 The fix

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.

🎓 Lesson to steal
Sequence alignment (edit distance / LCS) is the workhorse DP of the software world. If you ever needed a real reason to internalise the 2-D DP recipe, this is it — it's the same math behind git, bioinformatics BLAST, and Word spellcheck.
Post-mortem
War story Uber / Google Maps · ETA and routing· 2018hundreds of millions of ETA queries per day
🔥 What broke

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.

🧯 The fix

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.

🎓 Lesson to steal
DP is not just an interview toy. The Bellman equation ("value at state s = immediate cost + best value over next states") shows up in every routing engine, every Q-learning RL agent, and every operations-research solver.
Post-mortem
War story OpenAI / Anthropic / Google — LLM decoding· 2023every ChatGPT token, every Claude token
🔥 What broke

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.

🧯 The fix

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.

🎓 Lesson to steal
Every generative-model decoder is a DP variant. Recognising the recipe — state, transition, base case, order — lets you read modern ML papers without drowning in notation.
Post-mortem

Where this shows up in the rest of the plan

DP threads through the entire series
S034 · Greedy & Backtracking
The natural counter-strategies. Knowing when NOT to reach for DP.
S066 · Search algorithms
A* is Dijkstra + heuristic; both are DP on the state graph.
S095 · Reinforcement Learning
The Bellman equation IS a DP recurrence. Q-learning is DP with a learned value function.
S105 · NLP · Beam search
The pattern above — DP over token positions with a beam width.
S037 · SQL Joins
The query optimiser uses DP over join orders (System-R's dynamic programming, 1979).
S120 · System design
Cache admission policies, autoscaling decisions — all optimisation problems that decompose into subproblems.

(e) Recall + stretch · 10 min

Quick recall · click to reveal
★ = stretch question

Explain-out-loud test

  1. What is dynamic programming? (one sentence — the word "cache" must appear)
  2. What are the two things a problem needs for DP to work? (name both)
  3. 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.