◈ python mapVol 3 · Ch 40/47
Volume 3 Python, from the metal up · chapter 40

40Dynamic programming — remember to win

In Chapter 39 we let greedy grab the best-looking move and never look back. That was dazzling when we could prove it was right, and quietly wrong when we couldn't. Here we meet the algorithm that never has to gamble. Dynamic programming, or DP, turns problems the universe couldn't finish before it ends into problems your laptop finishes in a blink. And the whole trick is embarrassingly human: never solve the same sub-problem twice. Here's the plan. First we watch naive Fibonacci melt down into a million years of wasted work. Then we hand it a notebook and watch that million years collapse into microseconds. After that we turn the same move loose on coins, cargo, and text. The whole way through, we keep asking the one question that matters — what is the smallest sub-problem I keep re-solving, and what if I just wrote its answer down? By the end you'll look at a fresh problem and decide in seconds whether DP fits. You'll name its state, write its recurrence, and fill a table that hands you the answer. That is the exact skill that separates the engineer the interviewer remembers from the one they forget.

iolinked · chapter 40 — the checkpoints7 steps
$ sections covered in Dynamic programming — remember to win
01The two signals that scream "dynamic programming"
02Two directions, one idea: memoise down, tabulate up
03The recipe: name the state, write the recurrence
040/1 knapsack — the value-under-a-budget grid
05Edit distance — the algorithm inside diff, spell-check, and DNA
06Two more shapes: common subsequence & increasing subsequence
07When to reach for it — and the move you keep

01The two signals that scream "dynamic programming"

Let's start where you already stand, because in the traces you stepped naive Fibonacci (T29) and its memoised twin (T30). Look at the naive one and count what it does: fib(n) returns n when n < 2, and otherwise it returns fib(n-1) + fib(n-2). Those three honest lines are correct on every input you feed them. They are also a catastrophe, and watching why is the whole chapter in miniature.

★ YOU ALREADY RUN THIS · dynamic programmingthe sticky note on the coin jar
There is a jar of coins on your shelf. One evening you tipped the whole thing onto the table and counted it — in tens, twice, because you lost your place the first time. 42.60. Then you did the thing that saves you every year after: you wrote 42.60 on a sticky note and pressed it to the glass. Two weeks later you drop in a 50. You do not tip the jar out. You read the note, add 50, cross it out, write 43.10. Six months and forty coins later you have still never recounted, because the note holds the answer to a question you already paid for once. Nobody taught you that. You worked out on your own that recounting a jar you have already counted is a waste of an evening.
what the jar holds right nowthe sub-problem
tipping it out and counting againthe naive recursive call
the sticky note on the glassthe memo — @cache, or dp[]
reading the note instead of countingthe cache hit
crossing out 42.60, writing 43.10dp[k] = dp[k-1] + new
the note is never stalea filled cell is final — the DP invariant
pin it: You already refuse to recount the jar. Dynamic programming is that refusal, written down — and it is the difference between a million years and a millisecond.

Before we time anything, make sure the sequence itself is boring to you. The Fibonacci sequence starts 0, 1, and every later number is the sum of the two before it. So it runs 0, 1, 1, 2, 3, 5, 8, 13, 21. You can check any step in your head, because 5 + 8 = 13 and 8 + 13 = 21. Nothing about the arithmetic is hard, and that is exactly why it makes the perfect lab rat. When a three-line function takes a full second on maths this easy, the cost cannot be the adding. The cost is hiding in how the function calls itself, and that is where we point the microscope next.

Count the calls, don't guess them. Let C(n) be the number of times fib is invoked to compute fib(n). Every call either stops immediately (n < 2) or spawns two more, so C(n) = 1 + C(n-1) + C(n-2) — watch that: the count itself grows like Fibonacci. The tree of calls nearly doubles at every level, and that is the definition of exponential. Here is the real toll, measured, not imagined:

two_signals.pypython
calls = 0
def fib(n):
    global calls
    calls += 1
    if n < 2:
        return n
    return fib(n-1) + fib(n-2)

for n in [30, 35, 40]:
    calls = 0; v = fib(n)
    print(n, v, calls)
# 30 832040    2692537
# 35 9227465   29860703
# 40 102334155 331160281

Read those counts, and remember they were measured on a real machine, not imagined. Computing fib(35), which is one small number, cost 29,860,703 function calls. Push on to fib(40) and the bill climbs to 331 million. Now extrapolate honestly, using the exact count formula C(n) = 2·fib(n+1) − 1, and fib(100) works out to about 1.15 × 10²¹ calls. At the roughly 30 million calls per second this machine managed, that is on the order of a million years of compute. (That is an order-of-magnitude figure, and timings are machine-dependent, but the scale is honest.) And all of it for a problem a child can do on paper.

So where is all that work actually going, call after call after call? Nowhere new. Trace the call tree for fib(5) and count the repeats with your own eyes: it asks for fib(3) twice, it asks for fib(2) three times, and it asks for fib(1) five times. The same tiny questions get re-asked and re-answered from scratch, every single time, all the way down the tree. That pattern of pure repetition is the first signal, and it has a name.

F5 F4 F3 F3 F2 F2 F1 …more …more …more same sub-problem, solved twice one fib(5) = 15 calls · F3×2 · F2×3 · F1×5 · F0×3 — same colour, same wasted work
Fig — Naive fib(5) re-derives identical answers all over the tree. Same colour = same question, asked again. The redundancy is the exponential.

Signal one — overlapping sub-problems: the recursion keeps bumping into the same smaller problem again and again. Contrast that with merge sort from the divide-and-conquer chapter, which splits its input into fresh, non-overlapping halves. No sub-problem ever repeats there, so there is no DP needed. Signal two — optimal substructure: the best answer to the whole is built from best answers to its parts. fib(n) is literally fib(n-1) + fib(n-2), and the shortest route through a city is shortest-route-to-here plus one more step. Richard Bellman, who developed DP in the 1950s, called this the principle of optimality. When both signals fire at once, you are looking at a DP problem. And that means you are about to stop wasting a million years.

One honest aside before we fix anything, because the name trips everyone. Dynamic programming has nothing to do with typing code, and nothing especially dynamic is happening either. In the 1950s, "programming" meant planning with tables, the way a logistics officer programs a schedule. Bellman, by his own account, picked "dynamic" partly because it sounded impressive to the people funding his research. So don't search the name for meaning, because the meaning lives entirely in the method. A better private name is the one we will earn by the end of this chapter: write down every sub-answer, and never pay for it twice.

Wait —
if the only sin is re-answering the same question, the fix is almost insultingly simple: answer it once and write the answer down. What does that do to 29 million calls?

02Two directions, one idea: memoise down, tabulate up

Keep a notebook, and the whole disaster disappears. The first time you compute fib(k), store the answer, and every later time, read it back instead of recomputing it. That habit is called memoisation, and it is top-down DP: still recursive, but each distinct sub-problem is solved exactly once. Count the calls again and the collapse is total.

two_directions.pypython
def fib_memo(n, memo):
    if n in memo:            # already solved? read it back — O(1)
        return memo[n]
    if n < 2:
        return n
    memo[n] = fib_memo(n-1, memo) + fib_memo(n-2, memo)
    return memo[n]           # write the answer down before returning

# fib_memo(35, {}) makes 69 calls, not 29,860,703.
# fib_memo(100, {}) makes 199 calls — the "million years" becomes microseconds.

Line by line: if n is already in the notebook, hand it straight back. That one lookup is the line that kills the redundancy. Otherwise handle the two base cases, compute the two smaller Fibonaccis once, store the sum in memo[n], and return it. Each n from 0 up to the target gets filled exactly once and is only ever read after that. So the number of calls drops from exponential to just 2n − 1. That is 69 calls for fib(35) and 199 for fib(100). Same three-line logic, one extra dictionary, and an astronomically different bill. Measured on this machine, naive fib(35) took about 1.0 s and the memoised version about 18 microseconds. That is roughly a 50,000× gap, and while the timings are machine-dependent, the shape of the gap is not. One more gift: you don't even have to write the notebook by hand, because Python's @lru_cache from functools (Volume 1 ch11) bolts this exact memoisation onto any function with one line.

InteractiveWatch the exponential collapse into a line
naive memo bar length is log-scaled — the numbers on the right are the real toll Same answers exist — the naive tree recomputes them over and over.
×0
The readout is how many times more work the naive version does. Memoising doesn't shave the cost — it changes the growth class.

There is a second way to hold the same idea, and it is often the better one in practice. Instead of recursing down and caching answers on the way back, you walk up from the bottom. You fill a table from the smallest sub-problem to the biggest, in an order where every value you need is already sitting there. That direction is called tabulation, or bottom-up DP.

ex1_out.pypython
def fib_table(n):
    dp = [0, 1] + [0]*(n-1)          # dp[k] will hold fib(k)
    for k in range(2, n+1):
        dp[k] = dp[k-1] + dp[k-2]    # both already filled — just read them
    return dp[n]

Look at what just vanished from the code: no recursion, no call stack, and no dictionary. What remains is one array, filled left to right, with each cell reading its two neighbours. That is O(n) time, and with two rolling variables it becomes O(1) space. It is the exact same recurrence as memoisation, simply run in the opposite direction.

Watch the table grow for fib(5) and the phrase "already sitting there" turns literal. Start the array with 0 and 1, the two base cases. Then each new cell is one addition over its two neighbours: 0 + 1 = 1, then 1 + 1 = 2, then 1 + 2 = 3, then 2 + 3 = 5. Five cells, five cheap additions, and nothing was ever computed twice. There is also a quietly practical reason to prefer this direction in Python. The memoised recursive version still stacks one frame per level, and CPython caps the call stack near 1000 frames by default. Ask top-down for fib(2000) and it dies with a RecursionError before the cache can save it. The bottom-up loop never touches the call stack at all, so it simply keeps walking.

SYNTAX · the two memo idioms — one decorator, or one list you fill@functools.cache bolts the sticky note onto a recursion; the bottom-up table is the same recurrence run the other way
# TOP-DOWN -- keep the recursion, add the note import functools @functools.cache -- 3.9+. same as @lru_cache(maxsize=None) def f(state): -- args must be HASHABLE (tuple, not list) if state is a base case: return the obvious answer return combine(f(smaller), f(smaller), ...) f.cache_info() -- hits / misses / currsize: the audit f.cache_clear() -- the note lives as long as the process # BOTTOM-UP -- no recursion at all: a list, filled in dependency order dp = [base] + [unknown] * n -- seed what you know for free for k in range(first unknown, n + 1): -- smaller states first, always dp[k] = combine(dp[smaller], ...) -- every cell it reads is already FINAL return dp[n] # ROLLING -- if dp[k] only reads dp[k-1] and dp[k-2], the table was 2 cells wide a, b = base0, base1 for _ in range(n): a, b = b, combine(a, b) -- O(1) space
@functools.cachePython 3.9+. An unbounded dict keyed on the argument tuple, wrapped round your function. The body is untouched — same three lines, 2,692,537 calls become 31.
cache_info()The audit, and the proof. hits=28, misses=31 for fib(30) says out loud that only 31 distinct sub-problems ever existed — the other 2.69 million calls were repeats.
cache_clear()The memo is attached to the function object and lives as long as the process. Clear it between benchmarks and between tests, or you will measure a dictionary lookup.
hashable arguments onlyThe key is the args. A list argument raises TypeError: unhashable type: 'list' — pass a tuple, or index into a list held in the enclosing scope.
dp = [0] + [INF] * nThe bottom-up seed: fill in what is knowable for free, mark the rest unknown. float('inf') is the honest “no way found yet” for a minimisation.
the loop orderThe only rule of tabulation: when you write dp[k], every cell it reads must already be final. Get the order right and correctness is automatic.
@lru_cache(maxsize=N)The bounded cousin. @cache never evicts, so on a long-lived process with fat arguments it is a slow leak; maxsize=1024 caps it and evicts least-recently-used.
which direction to pickMemo is lazy — only the states you actually reach. The table is total — every state, in order, no stack, no recursion limit.
you type
$ python memo_idioms.py

import functools, time

calls = 0
def fib(n):
    global calls; calls += 1
    return n if n < 2 else fib(n - 1) + fib(n - 2)

t0 = time.perf_counter()
print(fib(30), calls, round(time.perf_counter() - t0, 3))

calls = 0
@functools.cache                       # <-- the ONE line
def cfib(n):
    global calls; calls += 1
    return n if n < 2 else cfib(n - 1) + cfib(n - 2)

t0 = time.perf_counter()
print(cfib(30), calls, round(time.perf_counter() - t0, 6))
print(cfib.cache_info())
print(cfib(300) % 10 ** 9)
cfib.cache_clear()
print(cfib.cache_info())

# ---------- the same recurrence, bottom-up: a list you fill ----------
def fib_table(n):
    dp = [0, 1] + [0] * (n - 1)
    for k in range(2, n + 1):
        dp[k] = dp[k - 1] + dp[k - 2]
    return dp[n]

print(fib_table(30), fib_table(30) == fib(30))

# ---------- rolling: the table was only ever two cells wide ----------
def fib_rolling(n):
    a, b = 0, 1
    for _ in range(n):
        a, b = b, a + b
    return a

print(fib_rolling(30), fib_rolling(30) == fib_table(30))

# ---------- the sharp edge ----------
@functools.cache
def longest(seq):
    return len(seq)

try:
    longest([1, 2, 3])
except TypeError as e:
    print("TypeError:", e)
print(longest((1, 2, 3)))
you see
832040 2692537 0.391
832040 31 2.7e-05
CacheInfo(hits=28, misses=31, maxsize=None, currsize=31)
990979600
CacheInfo(hits=0, misses=0, maxsize=None, currsize=0)
832040 True
832040 True
TypeError: unhashable type: 'list'
3
where beginners trip
  • 2,692,537 calls became 31. Identical function body, identical output, one decorator line. That is the whole chapter in eight characters.
  • The seconds are machine-dependent and yours will differ. The call counts are not — 2,692,537 and 31 are properties of the algorithm, so compare those.
  • hits + misses = 59, not 2.7 million, because a hit never enters the body. The counter only ticks on a miss.
  • A memoised recursion still uses the call stack: cfib(2000) raises RecursionError: maximum recursion depth exceeded on a default limit of 1000. fib_rolling(2000) returns a 418-digit number without blinking.
  • @cache on a method caches self as part of the key, which keeps every instance alive for the life of the process. Real leak, seen in real code.
  • One CPython detail, flagged as a detail: in this 3.12.7 run f(30) and f(30.0) made two cache entries even though 30 == 30.0, because lru_cache fast-paths a single int or str argument straight to the key. Do not build on it.
  • @cache is not a correctness tool. Decorate a function that reads a file or a clock and you have frozen its first answer forever.
  • Bottom-up wins the constant even when both are O(n): a contiguous list walk is cache-friendly, while the memo pays a dict hash and a Python frame per state.
dp[0] dp[1] dp[2] dp[3] dp[n] memoise — recurse DOWN from dp[n], cache on the way back tabulate — fill UP from dp[0], each cell reads its neighbours
Fig — Memoisation and tabulation are the same table filled from opposite ends. Top-down is lazy and only touches sub-problems it needs; bottom-up is contiguous and cache-friendly — usually the faster constant.
The machine cares which direction you pick
At equal O(n), tabulation often wins the stopwatch (the memory-hierarchy chapter's lesson). Its dp array is one contiguous block the CPU can prefetch and stream through L1 cache; the top-down version pays for a hash lookup per read and grows the call stack frame by frame. Same Big-O, different constant — and the constant is decided by the memory access pattern.

So the fix is a notebook. But Fibonacci handed us the recurrence for free. The real skill — the thing that actually separates people — is inventing the recurrence for a problem nobody wrote down for you. There's a procedure for that. →

03The recipe: name the state, write the recurrence

Everyone can memoise a function someone else wrote, because that part is mechanical. The rare skill, the one interviewers actually probe, is turning a word problem into a table. And here is the good news: it is not inspiration but a five-step procedure you can run on anything:

  1. Define the state. Decide what a sub-problem is, and write down in one plain sentence what dp[…] means. This is 80% of the work. Get it right and the rest falls out.
  2. Write the recurrence. Express dp[state] using strictly smaller states — the same "best of parts" the problem's optimal substructure promised.
  3. Fix the base cases. The smallest states you can answer with no recurrence at all.
  4. Choose the fill order. Any order where a cell's dependencies are computed before it.
  5. Read off the answer. Name the cell that holds it.

Let's run the recipe on coin change: given a set of coin values and a target amount, find the fewest coins that make the amount exactly. State: dp[a] is the fewest coins that can make amount a. Recurrence: to make a, the last coin you place is some c, and whatever remains is a − c, which we have already solved. So dp[a] = 1 + min(dp[a − c]), taken over every coin c ≤ a in the set. Base: dp[0] = 0, because it takes zero coins to make zero. Order: walk a from small to large, so every smaller answer exists before it is needed. Answer: when the table is full, read off dp[amount].

recipe.pypython
def min_coins(coins, amount):
    INF = float('inf')
    dp = [0] + [INF]*amount          # dp[0]=0; the rest "unknown"
    for a in range(1, amount+1):
        for c in coins:              # try every coin as the LAST coin
            if c <= a:
                dp[a] = min(dp[a], dp[a-c] + 1)
    return dp

print(min_coins([1,3,4], 6))
# [0, 1, 2, 1, 1, 2, 2]  ->  dp[6] = 2   (that's 3+3)

Line by line: dp starts with dp[0]=0 and every other entry infinite, meaning "no way found yet". The outer loop walks the amounts upward, and the inner loop tries each coin as the final coin, keeping the cheapest option it finds. When it finishes, dp[6] = 2, and the two coins are the pair of 3s. Now notice what the table exposes about last chapter. Greedy would grab the biggest coin first and pay 4 + 1 + 1 = three coins, and it would lose. On the denominations [1,3,4], the locally-best move simply isn't the globally best one. DP never guesses a strategy: it quietly considers every possible last coin and remembers the winner.

Before you trust any of that, verify the little table by hand, because it only takes a minute. The coins are [1,3,4], so dp[1] is 1 and dp[2] is 2, built from 1-coins alone. dp[3] drops back to 1, because a single 3-coin beats three 1s. dp[4] is also 1, thanks to the single 4-coin, and dp[5] is 2, a 4-coin plus a 1. Then dp[6] lands on 2, the pair of 3s the code found. So the finished row from dp[0] to dp[6] reads 0, 1, 2, 1, 1, 2, 2, and every entry survived your own checking. That is the quiet superpower of DP tables: each cell is small enough to audit by hand.

NOW WRITE IT YOURSELFwrite coin_min twice — top-down with @cache, bottom-up with a list — and prove they agree
Same recurrence, both directions. Write coin_min_memo(coins, amount) first. Inside it, define a recursive helper best(a) that returns the fewest coins making a, decorate it with @cache, and give it exactly two base cases: a == 0 is 0 coins, and a < 0 is impossible. The recursive case is one line — the minimum, over every coin, of best(a - c) + 1. Then write coin_min_table(coins, amount) with no recursion at all: a list dp seeded [0] + [INF] * amount, filled left to right. Both must return -1 when the amount cannot be made at all. Then prove they are the same algorithm. Run both on three inputs: (1, 3, 4) / 6 — the amount that beat greedy in chapter 39 — then (1, 2, 5) / 11, then (2, 5) / 3, which has no answer. Print both results side by side and whether they agree. Do not accept three data points: sweep every amount from 0 to 200 and assert the two functions never disagree once. Then earn the extra credit. dp[amount] tells you how many coins. Extend the bottom-up version to also tell you which — keep a second list recording, for each amount, the coin that won it, then walk backwards from the target. Answer in one sentence why that reconstruction is trivial in the table and awkward in the memo.
show the solution
from functools import cache

INF = float("inf")


def coin_min_memo(coins, amount):
    """Top-down: the recurrence, plus one decorator."""
    @cache
    def best(a):
        if a == 0:
            return 0
        if a < 0:
            return INF
        return min((best(a - c) + 1 for c in coins), default=INF)
    out = best(amount)
    return -1 if out == INF else out


def coin_min_table(coins, amount):
    """Bottom-up: the same recurrence, run in the other direction."""
    dp = [0] + [INF] * amount
    for a in range(1, amount + 1):
        for c in coins:
            if c <= a:
                dp[a] = min(dp[a], dp[a - c] + 1)
    return -1 if dp[amount] == INF else dp[amount]


cases = [((1, 3, 4), 6), ((1, 2, 5), 11), ((2, 5), 3)]
for coins, amount in cases:
    m, t = coin_min_memo(coins, amount), coin_min_table(coins, amount)
    print(f"coins={coins} amount={amount:>3}  memo={m}  table={t}  agree={m == t}")

agree = all(coin_min_memo((1, 3, 4), a) == coin_min_table((1, 3, 4), a)
            for a in range(0, 201))
print("0..200 all agree:", agree)


# ---------- extra credit: WHICH coins, not just how many ----------
def coin_pick(coins, amount):
    dp = [0] + [INF] * amount
    last = [None] * (amount + 1)            # the winning coin for each amount
    for a in range(1, amount + 1):
        for c in coins:
            if c <= a and dp[a - c] + 1 < dp[a]:
                dp[a] = dp[a - c] + 1
                last[a] = c                 # remember WHY this cell won
    if dp[amount] == INF:
        return None
    out = []
    while amount:
        out.append(last[amount])            # walk the pointers backwards
        amount -= last[amount]
    return out


print(coin_pick((1, 3, 4), 6), coin_pick((1, 2, 5), 11), coin_pick((2, 5), 3))


# ---------- the actual run, python 3.12.7 ----------
#
# coins=(1, 3, 4) amount=  6  memo=2  table=2  agree=True
# coins=(1, 2, 5) amount= 11  memo=3  table=3  agree=True
# coins=(2, 5) amount=  3  memo=-1  table=-1  agree=True
# 0..200 all agree: True
# [3, 3] [1, 5, 5] None
#
#
# ---------- reading it ----------
#
# 1. (1, 3, 4) / 6 -> 2, and the coins are [3, 3]. That is the exact amount
#    greedy lost in chapter 39, where it paid 4 + 1 + 1. Nothing here is
#    cleverer than greedy -- it is only less willing to commit.
#
# 2. (2, 5) / 3 -> -1 in both directions. INF is doing real work: it means
#    "no way found yet", and min() carrying INF upward is how impossibility
#    propagates without a single special case in the loop.
#
# 3. default=INF on the generator matters. For a = 1 with coins (2, 5),
#    every c is larger than a, so best(a - c) is called with a negative a
#    and returns INF -- but if coins were EMPTY the generator would be
#    empty and min() would raise ValueError. default= is the guard.
#
# 4. Why reconstruction is easy in the table and awkward in the memo:
#    the table is a real array that OUTLIVES the fill, so last[] can sit
#    beside it and be walked backwards afterwards. The memo's answers live
#    inside a private dict on the closure; the path is implicit in a call
#    tree that has already collapsed and returned. To recover it you would
#    have to re-derive each step, or memoise the choice as well as the value.
#    Bottom-up keeps the evidence; top-down keeps only the verdict.
#
# 5. Both are O(amount x len(coins)) time. Space differs: the table is
#    O(amount) contiguous ints, the memo is O(amount) dict entries plus one
#    Python stack frame per level -- which is also why the memo is the one
#    that can hit RecursionError on a large amount.
dp 0a=0 1a=1 2a=2 1a=3 1a=4 2a=5 2a=6 last coin = 3 -> dp[3]+1 = 2 dp[6] = 1 + min( dp[5], dp[3], dp[2] ) = 1 + min(2, 1, 2) = 2 try last coin in {1 ->dp[5], 3 ->dp[3], 4 ->dp[2]}; keep the cheapest
Fig — Every cell of the coin-change table is built from cells to its left. dp[6] asks "what was the last coin?" and reuses an answer already computed — the recurrence made literal.
InteractiveFill the coin-change table, one amount at a time
dp[a] = fewest coins to make amount a, with coins {1, 3, 4}. Slide to fill it.
The deeper cut — why coin change needs DP but real cash registers get away with greedy
A coin system is called canonical when greedy (always take the largest coin that fits) is guaranteed optimal — real currencies like US [1,5,10,25] are deliberately canonical, which is why min_coins([1,5,10,25], 63)[63] is 6 — and greedy finds the same 6. The moment a system is non-canonical (like [1,3,4]), greedy can lose, and only DP is safe. The lesson generalises: the algorithm you need depends on structure you can't eyeball — so prove it, or let DP consider everything for you.

Coin change had a one-dimensional state — a single number a. Some problems need the table to grow a second axis. That's where DP starts to feel like real power. →

040/1 knapsack — the value-under-a-budget grid

Here's the setup. You have a weight budget and a set of items, each with a weight and a value; take each item once or not at all; maximise total value without blowing the budget. This is the shape of a thousand real decisions — which features fit in a sprint, which trades fit a risk limit, which files fit on the disk. Greedy by "best value-per-kilo" can fail here too. DP cannot.

Let's make it concrete with the exact cast the code below uses. The bag holds a budget of 4 kg, and the shelf holds four items: a guitar (1 kg, worth 1500), a laptop (3 kg, worth 2000), a stereo (4 kg, worth 3000), and a necklace (2 kg, worth 2500). Why not just try every subset and keep the best one that fits? With four items you honestly could, because 2⁴ is only 16 subsets. But the count doubles with every item you add, and doubling is the same exponential cliff Fibonacci fell off. At 40 items you are staring at 2⁴⁰, which is about 1.1 trillion subsets. So we will not enumerate subsets at all. We will fill a small grid instead, one cell per question.

To describe where you are mid-decision, the state needs two facts: which items you are still allowed to consider, and how much budget is left. State: dp[i][w] is the best value using only the first i items within a budget of w. Recurrence: for item i you make one binary choice. You either skip it and inherit dp[i-1][w], or, if it fits, you take it and score dp[i-1][w − weight_i] + value_i. Keep whichever of those two is larger. Base: zero items or zero budget means a value of 0. Answer: the final cell, dp[n][W].

knapsack.pypython
items = [("guitar",1,1500), ("laptop",3,2000),
         ("stereo",4,3000), ("necklace",2,2500)]   # (name, weight, value)
W = 4
wt  = [w for _,w,_ in items]; val = [v for _,_,v in items]; n = len(items)
dp = [[0]*(W+1) for _ in range(n+1)]

for i in range(1, n+1):
    for w in range(W+1):
        dp[i][w] = dp[i-1][w]                         # skip item i
        if wt[i-1] <= w:                              # if it fits, try taking it
            dp[i][w] = max(dp[i][w], dp[i-1][w-wt[i-1]] + val[i-1])

print(dp[n][W])   # 4000  — guitar (1kg,1500) + necklace (2kg,2500)

Line by line: dp is an (n+1) × (W+1) grid of zeros, which builds in the base row and column for free. For each item at each budget, first assume we skip it and inherit the answer from the row above. Then, if the item fits, compare against taking it: its value plus the best from the remaining budget w − weight using the earlier items. Keep the max of the two. The final cell dp[4][4] comes out to 4000. And look at what won: the light, valuable pair (guitar + necklace), not the single 3000 stereo that a value-greedy grab might chase. Two nested loops give O(n·W) cells at O(1) each, so the whole grid is one cheap sweep.

budget w -> (each row adds one more item to consider) w=01234 none+guitar+laptop+stereo+necklace 00000 01500150015001500 01500150020003500 01500150020003500 0150025004000 4000 skip necklace -> 3500 take necklace -> 1500+2500 max = 4000
Fig — The knapsack grid (real values, run in Python). The winning cell dp[4][4] is max(skip, take) — a green cell above and a purple cell up-and-left combine into the cyan answer.
Where you meet this
Knapsack is the math of allocation under a ceiling: cloud schedulers packing jobs onto a fixed machine, cargo and container loading, ad servers choosing which ads maximise value under a latency budget, and — squint — portfolio and capital-budget selection in finance (pick the projects that maximise return under a spend limit). Whenever you hear "get the most value without exceeding X," a knapsack is hiding inside.
The deeper cut — knapsack is "pseudo-polynomial," and that word matters
O(n·W) looks polynomial, but W is a number's value, not its size. Written in binary, W takes about log₂(W) bits, so the runtime is exponential in the input length. That's why 0/1 knapsack is NP-hard: crank W up to billions and the grid becomes unfillable. DP tamed the overlap, not the fundamental hardness. Knowing the difference is exactly the honesty that stops you promising a table that will never finish.

Coin change and knapsack both optimised a number. The most beautiful DP of all optimises something you touch every hour without noticing: the distance between two pieces of text. →

05Edit distance — the algorithm inside diff, spell-check, and DNA

How different are two words? Let's define it precisely: the edit distance (Levenshtein distance) between strings a and b is the minimum number of single-character inserts, deletes, and substitutions to turn one into the other. "color" → "colour" is 1 (insert a u). "kitten" → "sitting" is 3. And the way we compute it is the same table-filling move — now on a grid.

Hold the target in your hand before we build the machine. To turn kitten into sitting, substitute the k for an s, substitute the e for an i, and insert a final g. Walk it yourself: kitten → sitten → sittin → sitting, three edits, and no shorter path exists. Proving that "no shorter" claim by staring is the hard part, because edits interact in sneaky ways. An insert early in a word shifts every later letter, which changes what still needs fixing downstream. That is why we stop eyeballing whole words and let a table consider every prefix pair for us.

State: dp[i][j] is the edit distance between the first i letters of a and the first j letters of b. Recurrence: look only at the last letters of each prefix. If a[i-1] == b[j-1], they cost nothing, and the answer is just dp[i-1][j-1]. If they differ, you must pay for one edit, choosing the cheapest of three moves. You can delete a's last letter for dp[i-1][j] + 1, insert b's last letter for dp[i][j-1] + 1, or substitute one for the other at dp[i-1][j-1] + 1. Base: turning a length-i word into the empty string costs i deletes, so dp[i][0] = i and dp[0][j] = j. Answer: the bottom-right cell of the grid.

Run one cell by hand and the recurrence loses its sting. Take dp[1][1], which compares the one-letter prefixes "k" and "s". The letters differ, so we pay one edit on top of the cheapest of the three neighbours: min(0, 1, 1) + 1 = 1. And that is obviously right, because turning "k" into "s" is a single substitution. Every one of the 56 cells in the table below is exactly this five-second calculation. The algorithm is not smart, it is relentless bookkeeping, and that is the whole secret.

edit_distance.pypython
def edit_distance(a, b):
    m, n = len(a), len(b)
    dp = [[0]*(n+1) for _ in range(m+1)]
    for i in range(m+1): dp[i][0] = i        # a[:i] -> ""  costs i deletes
    for j in range(n+1): dp[0][j] = j        # "" -> b[:j]  costs j inserts
    for i in range(1, m+1):
        for j in range(1, n+1):
            cost = 0 if a[i-1] == b[j-1] else 1
            dp[i][j] = min(dp[i-1][j] + 1,        # delete
                           dp[i][j-1] + 1,        # insert
                           dp[i-1][j-1] + cost)   # match / substitute
    return dp[m][n]

print(edit_distance("kitten", "sitting"))   # 3
print(edit_distance("recieve", "receive"))  # 2   (the classic typo)
print(edit_distance("color",   "colour"))   # 1

Line by line: build the grid, then seed the first row and column with the "delete/insert everything" base costs. After that, fill each interior cell from its three neighbours: up is a delete, left is an insert, and up-left is a match or substitute, free when the letters agree. The bottom-right cell is the answer, and nothing else in the code is clever. Here is the actual table Python filled for kitten → sitting. Trace the diagonal of small numbers and you can literally read the alignment off the grid:

εsitting εkitten 01234567 11234567 22123456 33212345 44321234 55432234 66543323 the diagonal of low numbers is the cheapest alignment answer = 3
Fig — The real kitten → sitting table (bottom-right = 3). Follow the circled path: k→s sub, e→i sub, insert g — three edits. Every one of the 56 cells came from just three neighbours.

Now build it yourself: drag the slider and watch the grid fill in reading order, cell by cell. Each interior cell lights up its three source neighbours and shows the exact min(…) it evaluates. This is the "fill the grid" mental model made physical, and it is worth sitting with for a minute. Nothing here is clever per cell, yet the whole answer emerges from local moves almost too simple to respect.

InteractiveFill the edit-distance grid, cell by cell
Slide right — the table fills itself, each cell from three neighbours (up = delete, left = insert, up-left = match/sub).
Where you meet this — probably in the last five minutes
Your phone's autocorrect ranks dictionary words by edit distance to what you fumbled. git diff and every "compare files" tool find the cheapest edit script between two versions. Spell-checkers, fuzzy search, and plagiarism detectors all lean on it. And in a different lab entirely, biologists align DNA and protein sequences with Needleman–Wunsch — literally edit distance with a scoring matrix — to measure how related two organisms are. The same 56-cell grid you just filled by hand runs, at galactic scale, inside genomics pipelines and your keyboard.

Notice the human move that unlocked all of this. Comparing two whole strings head-on is hopeless, because there are far too many possible alignments. The insight was to stop comparing the wholes and compare all their prefixes instead. Once you know the distance for every shorter pair of prefixes, the full pair is one cheap step away. That reframing, "solve it for all smaller versions and the big version is trivial," is the beating heart of DP. It was independently discovered in linguistics, in computer science, and in molecular biology. That triple discovery is no accident: it is simply how this class of problem wants to be solved.

Wait —
if a two-string comparison becomes a grid, what happens when the thing you're comparing is a single list against itself, hunting a hidden increasing streak? Same idea, new state.

06Two more shapes: common subsequence & increasing subsequence

Once you can spot the state, new problems stop feeling new. Two you'll meet constantly:

Longest common subsequence (LCS) is the longest run of characters appearing in both strings in order, though not necessarily adjacent. Its state is edit distance's cousin: dp[i][j] is the LCS length of the first i of a and the first j of b. If the last letters match, take dp[i-1][j-1] + 1, and otherwise take max(dp[i-1][j], dp[i][j-1]). This is the engine of git diff. The unchanged lines between two file versions are exactly their LCS, and everything else is an add or a delete.

Longest increasing subsequence (LIS) is the longest run of values that increases, picked from a list in order. The state has a twist worth pausing on: dp[i] is the length of the longest increasing subsequence ending at index i. The recurrence is dp[i] = 1 + max(dp[j]), taken over every earlier j with seq[j] < seq[i]. Both were run in Python:

lcs_lis.pypython
lcs("ABCBDAB", "BDCAB")            # length 4  -> "BCAB"
lis([10, 9, 2, 5, 3, 7, 101, 18]) # length 4  -> [2, 5, 7, 101]
# per-index dp for LIS: [1, 1, 1, 2, 2, 3, 4, 4]

Read the LIS dp array from the run above. At index 6, holding value 101, the best increasing run ending there is length 4, because it chained through 2, 5, 7. The final answer is the max over the whole array, not the last cell. That approach is O(n²) here, since each cell scans all the earlier cells. A sharper version uses binary search (the searching chapter) to reach O(n log n). Take that as a preview of how one technique feeds the next.

10 9 2 5 3 7 101 18 dp 11122344 longest increasing subsequence: 2 -> 5 -> 7 -> 101 (length 4) dp[i] = 1 + max(dp[j]) over earlier j with seq[j] < seq[i]
Fig — The real LIS run. The chain skips past 10, 9, 3, and 18; each dp[i] records the best streak ending at that bar, and the answer is their maximum.

The myth

"DP means grinding 200 problems until you've memorised the tricks. It's a bag of unrelated puzzles."

The reality

The truth is that DP is one recipe: state, recurrence, base, order, answer. It just gets applied to a new state each time you meet a new problem. Fibonacci, coin change, knapsack, edit distance, LCS, LIS — every one of them is the same five steps with different sentences on step one. So learn the recipe once, and let the list take care of itself.

You've now filled five different tables with one procedure. Time to name the mental move you actually installed — and know exactly when it will and won't save you. →

07When to reach for it — and the move you keep

Reach for DP when both signals fire: overlapping sub-problems (you keep re-solving the same smaller thing) and optimal substructure (the best whole is built from best parts). Miss either and DP is the wrong tool. No overlap? You have plain divide-and-conquer (the halving chapter) — merge sort gains nothing from a memo because its halves never repeat. No optimal substructure? Neither greedy nor DP is guaranteed, and you may be forced to search everything (the backtracking chapter).

It helps to place DP among its neighbours. Divide-and-conquer splits into independent pieces and never looks back. Greedy (last chapter) commits to the best-looking move and never reconsiders — fast when it's provably right, wrong on [1,3,4] coins. DP is the careful middle: it considers every choice like brute force, but remembers each sub-answer so it pays for each only once. That's why the one-liner for DP is "brute force that doesn't repeat itself."

And if you take away one practical move for finding the state, take this one. Every recurrence in this chapter came from the same question: what was the last decision? For coin change it was the last coin, for knapsack it was skip-or-take on item i, and for edit distance it was fixing the last letters. Naming that decision tells you what the state must record, because the state describes the leftover problem. So when a new problem stares back blankly, don't hunt for the table first. Ask what the final move could be, and the table tends to introduce itself.

Divide & conquer split into pieces that DON'T overlap merge sort · binary search Dynamic programming consider every choice, REMEMBER each answer overlap + optimal substructure Greedy grab the best move now, never reconsider fast — but PROVE it first
Fig — The three paradigms on one line: split-independent, remember-everything, commit-and-run. DP is brute force with a memory.
↺ The thing people get backwards
People think the DP decision is "recursion vs loops," and they think memoisation and tabulation are rival techniques. They're neither. Memoisation is just recursion holding a notebook; tabulation is the same recurrence written forward. The real object — the thing you invent — is the recurrence over a well-chosen state. Direction (top-down or bottom-up) is a downstream detail you pick for convenience or cache-friendliness. Nail the state and the recurrence; the code is bookkeeping.

And here is the space bonus most people skip. Once you have the recurrence, look at what each cell actually depends on. Fibonacci's dp[k] needs only the last two cells, so keep two variables and drop O(n) memory to O(1). Edit distance's row i depends only on row i-1, so keep one row and drop O(m·n) space to O(min(m,n)). In other words, the recurrence tells you not just the answer but exactly how little you need to remember.

The 1% meta-move
DP installs one reflex you carry into every hard problem: never solve the same sub-problem twice. Before you write a line, ask "am I about to recompute something I already know?" — and if yes, write it down. This is the everyday transfer. You already hash your memory when you keep a notes file instead of re-deriving a fact. You DP your decisions when you record why you rejected an option so you never re-argue it. You build answers from sub-answers when you break a scary project into solved pieces. The engineers who look fast aren't computing faster — they're refusing to repeat work.

So the arc of this chapter really is a single sentence. Spot the overlap, define the state, write the recurrence, and fill the table. Do that, and a problem that would outlast the universe finishes before your coffee cools. That is dynamic programming, and it is, more than any other single idea in this volume, the one interviewers use to separate the 1% from the rest.

InteractiveDrag n until naive Fibonacci outlives the universe
how long the SAME answer takes: naive vs memoised fib(n) 1µs 1 sec 1 hr 1 yr lifetime 5,000 yr universe DP naive fib(100) → 1.2 million years the memoised twin does the identical job in a blink log-time axis · rate ≈ 30M calls/sec (machine-dependent) · calls = 2·fib(n+1) − 1
Both compute the exact same number, fib(n). The only difference is a notebook. Slide right: naive time races across the log axis and blows past the age of the universe near n = 120 — while memoisation never leaves the “blink.” That gap is the whole chapter.

DP works only when sub-problems overlap. But what about the problems where they don't — where every path is unique and the tree of choices is astronomically wide, yet most branches are hopeless the moment you start them? Next: recursion & backtracking — how to search a giant tree of choices, and how pruning a doomed branch turns "explores a billion dead ends" into "explores a thousand live ones." DP was backtracking that remembered; the next chapter is backtracking that knows when to quit. →

PROGRAMS run them line by line — the same ideas, written the many ways Python offers
Chapter 40, in working code

The whole of dynamic programming is one embarrassingly human move — never solve the same sub-problem twice — so let's watch a million years of wasted work collapse into a blink, one runnable table at a time.

Fibonacci: watch the exponential collapse
The same three-line recurrence, run three ways — naive, memoised, tabulated — so you can feel the redundancy appear and then vanish.
The recipe on a number line: coin change
Name the state, write the recurrence, fill the table — and watch DP quietly beat greedy where greedy is doomed to lose.
Grids: when the state grows a second axis
One number wasn't enough — knapsack and edit distance need a two-dimensional table, and each cell is still built from a handful of neighbours.
One more shape, and the payoff
A self-comparison (LIS), the one-line decorator that does all this for you, and a final side-by-side that shows the gap growing WITH n.
end of chapter 40 · seven sections
iolinked
designed & developed by Ajai Raj
Python, from the metal up · a product of iolinked