Topic 03

Dynamic Programming

Algorithms

Some problems break into subproblems that repeat. The naive recursive Fibonacci function makes 331,160,281 calls to compute the 40th Fibonacci number, and among all those calls there are only 41 different questions. Dynamic programming answers each distinct subproblem once and keeps the answer, trading a table of memory for an exponential amount of time.

The same idea is how Lantern, the Millbrook Public Library's search service, knows that someone who typed "dispossesed" meant "dispossessed". The table that makes this work is small for one pair of words and ruinous for a million and a half, and the way Lantern keeps it small is the lesson of this topic: count the cells before writing the code.

Overlapping Subproblems

Computing fib(n) calls fib(n − 1) and fib(n − 2), and both of those call fib(n − 3), and the repeats compound level after level. The figure draws every call made by fib(6): 25 calls for only seven distinct arguments, 0 to 6. The subtree for fib(4) is computed twice, fib(3) three times and fib(2) five times. At fib(40), counted by running it, the total is 331,160,281 calls.

The calls of fib(6): identical subtrees share a colour
1021310241021351021310246fib(6) makes 25 calls for 7 distinct arguments: fib(4) is computed 2 times, fib(3) 3 times, fib(2) 5 times

Divide and conquer, the first topic of this chapter, splits a problem into independent pieces that share nothing. Dynamic programming applies when the pieces overlap, and the overlap is what makes storing answers pay. Every repeated subtree in the figure is work that one stored number would replace.

Memoization: Remember on the Way Down

The first way to exploit the overlap keeps the recursive code as it is and caches each result by its arguments. In Python one standard decorator, functools.cache, does it, as Chapter 1 showed. The first call with a given argument computes the answer; every later call with the same argument is a dictionary lookup, the average constant-time operation from Chapter 5. With the cache in place, computing fib(40) takes 41 computations instead of 331 million calls.

Two costs remain. The cache holds one entry per distinct argument, which is memory that grows with the input. And the recursion is still recursion: measured on CPython 3.15, the memoized fib computes fib(900) and raises RecursionError on fib(1000), because the first call still descends a thousand levels before any answer is stored, and the default limit of 1,000 frames from Chapter 3 stops it.

The Table: Build From the Bottom Up

The second way turns the recursion inside out. Work out which subproblems each one depends on, then fill a table in an order that computes every dependency first, with a plain loop. For Fibonacci the order is left to right: fib(0), fib(1), fib(2) and onward, each the sum of the two before it. There is no recursion and no depth limit.

Filling the table bottom up, keeping two cells
Filling fib(0) to fib(10) left to right; computing fib(10) reads only the two cells before it0fib(0)1fib(1)1fib(2)2fib(3)3fib(4)5fib(5)8fib(6)13fib(7)21fib(8)34fib(9)55fib(10)21 + 34 = 55: two stored numbers are all the loop ever needs

Often only the last row or two of the table is ever read again. Computing fib(10), which is 55, reads only fib(8) and fib(9), 21 and 34, so the loop can keep two numbers instead of a table and run in constant memory. That observation, that a dynamic program's memory is the part of the table still needed, is where most of its engineering happens.

Edit Distance

The edit distance between two words is the fewest single-letter insertions, deletions and substitutions that turn one into the other. It is computed in a table with a row for each letter of one word and a column for each letter of the other. Each cell holds the distance between two prefixes, and is the cheapest of three neighbours plus one: the cell above means a deletion, the cell to the left an insertion, and the cell diagonally up-left a substitution, which costs nothing when the two letters match.

kitten to sitting: the table and its cheapest chain
kitten (rows) to sitting (columns): each cell is the cheapest of three neighbours plus one; the corner is the answersittingkitten12345671234567222345633223454432234554323466543301111223

The figure fills the table for "kitten" and "sitting". The first row and column count up from zero, the cost of building a prefix from nothing. The bottom-right cell is the answer, 3, and the shaded chain traces the cheapest route to it: substitute k with s, keep "itt", substitute e with i, keep n, insert g.

Edit distance, keeping only the previous row
def edit_distance(a, b):
    prev = list(range(len(b) + 1))
    for i, ca in enumerate(a, 1):
        cur = [i]
        for j, cb in enumerate(b, 1):
            cur.append(min(prev[j] + 1,                # delete
                           cur[j - 1] + 1,             # insert
                           prev[j - 1] + (ca != cb)))   # substitute or keep
        prev = cur
    return prev[-1]

The function above fills the same table one row at a time and keeps only the previous row, since each cell reads only the row above it and the cell to its left. For each letter of the first word it builds a new row, taking the cheapest of the three neighbours for every cell. On CPython 3.15 it returns 3 for "kitten" and "sitting" and 1 for "dispossesed" and "dispossessed". Comparing two 12-letter words fills a 13 by 13 table, 169 cells.

Lantern's Typo Tolerance

Lantern matches a query term against an index term when their edit distance is 2 or less. Most of the table cannot matter for that question. A cell more than two steps from the diagonal already records more than two insertions or deletions, so only a band five cells wide around the diagonal can hold a value of 2 or below.

dispossesed to dispossessed: only the band can stay within 2
dispossesed to dispossessed: only the shaded band within 2 of the diagonal can stay at 2 or belowdispossesseddispossesed121122112211221122112211221122122102211222234567891011123456789101134567891033456789433456785433456765433456765433458765433498765433109876543111098765430000000001111

The figure shows the table for "dispossesed" against "dispossessed" with that band shaded. Every cell outside it is 3 or more, and the answer in the corner is 1: one inserted s. Three rules follow. Fill only the band. Skip any pair of words whose lengths differ by more than 2, because the corner then lies outside the band. And stop a comparison the moment every cell in a row exceeds 2, because no later row can come back down.

Even so, checking all 1.5 million index terms one by one costs about 50 band cells each, some 75 million cells for every query term, and autocomplete asks again on every keystroke after the second character. So Lantern walks its terms as the trie from Chapter 6. Terms that share a prefix share the rows for that prefix: one table row per trie edge, computed once for every term below it. A branch is abandoned as soon as its row passes 2, which is the pruning of the backtracking topic that follows. Search libraries compile the same limit further, into a Levenshtein automaton that is a state machine of the kind Chapter 14 describes.

What Dynamic Programming Costs

The table is the bill. Time is the number of cells times the work per cell, and memory is the number of cells kept. A full table comparing two files of 100,000 lines each would hold 10 billion cells, which is why diff tools use a different algorithm, published by Myers in 1986, whose cost grows with the length of the files times the number of differences, not with the product of the two lengths. Two nearly identical files are cheap to diff; two unrelated ones are not.

Counting cells before writing the code is the feasibility test. Rows times columns times the work per cell says whether the program finishes in a millisecond, a minute or never, and a bound on the answer, like Lantern's distance of 2, is the most common way to shrink that count by orders of magnitude.

Misconceptions
  • "Dynamic programming is a trick for contest puzzles." It is caching the answers of repeated subproblems, and it runs in spell-checkers, diff tools, route planners and database query planners the reader already uses.
  • "Memoization and dynamic programming are different techniques." They are one idea in two directions: top-down with a cache, bottom-up with a table. The choice between them is about recursion depth, memory, and whether every subproblem is actually needed.
  • "Memoization always helps." It pays only when arguments repeat. On a recursion whose pieces are all distinct it fills a cache that is never hit, and an unbounded cache in a long-running service is the memory leak Chapter 1 warned about.
  • "Comparing a typo against every term is fine, because each comparison is small." 169 cells is small. 169 cells times 1.5 million terms times every keystroke is not. The feasible version shares work through a trie and prunes by the distance limit.
  • "A dynamic programming table must be kept whole." The value usually needs only the previous row. The whole table is kept only when the answer must say which edits to make, not only how many.
Why It Matters
  • Look for repeated arguments in any slow recursion and cache them. Exponentially many calls collapse to one per distinct subproblem.
  • Switch to the bottom-up table when inputs are long. There is no recursion limit, and memory can be predicted in advance.
  • Count the cells before writing a dynamic program. Rows times columns times the work per cell is its cost.
  • Put a bound on the answer and prune the moment it is exceeded. A limit such as "distance 2" turns a full table into a narrow band.
RelatedDivide and conquer independent pieces, nothing to cacheMemoization the time-space trade of Chapter 1, applied to a functionDamerau–Levenshtein counts two swapped letters as one edit

Knowledge Check

The naive recursive fib(6) makes 25 calls. How many distinct subproblems are among them?

  • Seven, the arguments 0 through 6
  • Twenty-five, one for every call made
  • Thirteen, one for every leaf in the tree
  • Two, since fib(6) calls only fib(5) and fib(4)

A memoized recursive function works for inputs up to 900 and raises RecursionError at 1,000. What fixes it without losing the speed-up?

  • Give the cache a larger maximum size so it holds more results
  • Fill the table bottom up with a loop, in dependency order
  • Remove the cache so each call returns faster and uses less stack
  • Clear the cache after every call so the stack is freed each time

What is the edit distance between "kitten" and "sitting"?

  • 2: substitute k with s and e with i
  • 3: two substitutions and one insertion
  • 7: one edit for every letter of sitting
  • 1: insert the g at the end of the word

Why does Lantern need to fill only a band five cells wide to decide whether two words are within edit distance 2?

  • Because typical index terms are at most five letters long
  • Because any cell more than two steps off the diagonal exceeds 2
  • Because five cells per row is a good enough statistical sample
  • Because substitutions can only move five cells from the diagonal

A tool compares two 100,000-line files by filling the full edit-distance table over lines. What does that cost?

  • About 200,000 cells, one per line of each of the two files
  • About 100,000 cells, since only one row is kept at any time
  • About 10 billion cells, which is why diff tools do it differently
  • About 17 cells per line, since the table is searched by halving

You got correct