AmouAI Hub/Courses/Data Structures & Algorithms/Chapter 25
Dynamic Programming I: The Grammar
DP is not a trick. It is a sentence with four blanks — state, transition, base case, order. Fill those in and the code writes itself.
By the end of this chapter you can
- Recognise overlapping subproblems, and measure the cost of recomputing them
- Convert a recursion into a memoised version mechanically
- Fill in the four blanks — state, transition, base case, order — for a new problem
- Translate between top-down memoisation and bottom-up tabulation, and say why you would choose each
- Compute the running time as the number of states times the cost of one transition
- Reduce the memory to one or two rows when the recurrence allows it, and reconstruct the answer anyway
1The same work, over and over
Write Fibonacci the obvious way and it takes exponential time. Nothing is wrong with the logic. Everything is wrong with the repetition.
You want to know how many people are in a building. You ask each person “how many people are in your department?”, and each of them, not knowing, asks everyone in their department, who ask everyone in their team, and so on.
The answer comes back correct. It also involves the same person being asked the same question four hundred times, because nobody wrote anything down.
Dynamic programming is, at its heart, the decision to write it down.
Here is the canonical example. The recursion is a direct transcription of the mathematical definition, and it is correct:
def fib(n):
if n <= 1:
return n
return fib(n - 1) + fib(n - 2)Count the calls: fib(5) makes 15, fib(30) makes about 2.7 million, and
fib(50) would take roughly a day. The tree has O(φⁿ) nodes but
only n + 1 distinct questions in it. Everything past the first occurrence of
each is waste.
Dynamic programming applies exactly when both of these hold:
- Optimal substructure — the answer to a problem can be built from answers to smaller versions of the same problem.
- Overlapping subproblems — those smaller versions repeat, so the total number of distinct subproblems is much smaller than the number of calls.
The first condition is what recursion needs. The second is what makes DP pay. Merge sort has optimal substructure but no overlap — its two halves are disjoint — which is why it is divide-and-conquer and not DP.
2Memoisation: write it down
Keep a dictionary of answers you have already computed. Two lines, and the exponential collapses.
The fix requires no new insight at all. Before doing the work, check whether you already did it:
def fib(n, memo=None):
if memo is None:
memo = {}
if n in memo: # <-- have we been asked this before?
return memo[n]
if n <= 1:
return n
memo[n] = fib(n - 1, memo) + fib(n - 2, memo) # <-- write it down
return memo[n]Python ships this as a decorator, and in real code that is what you would use:
from functools import lru_cache
@lru_cache(maxsize=None)
def fib(n):
if n <= 1:
return n
return fib(n - 1) + fib(n - 2)- A mutable default argument.
def f(n, memo={})shares one dictionary across every call toffor the lifetime of the program. That is occasionally what you want and usually a bug. - Caching on an incomplete key. The cache key must contain every argument the answer depends on. Miss one and you will return an answer computed for a different question.
- Recursion depth.
fib(10000)memoised is 10,000 frames deep and Python's default limit is around 1,000. Memoisation fixes the time, not the stack. The bottom-up version in §4 has no stack at all, which is often the real reason to prefer it.
3The four blanks
Every DP solution is the same four sentences. Write them in English before you write any code, and the code becomes transcription.
People describe DP as requiring a flash of insight. It does not. It requires filling in a form:
| Blank | The question it answers | For Fibonacci |
|---|---|---|
| State | What does one subproblem look like? What do I need to know to answer it? | f(n) = the n-th Fibonacci number |
| Transition | How is one state built from smaller ones? | f(n) = f(n-1) + f(n-2) |
| Base case | Which states are answered without recursing? | f(0) = 0, f(1) = 1 |
| Order | In what sequence can I compute them so that every dependency is already done? | increasing n |
The single most common cause of a stuck DP is a state that is not quite enough information. Force yourself to complete this sentence:
“dp[i][j] is the <best/count/whether> for
<precise description of the sub-situation>.”
If you cannot finish the sentence precisely, you do not have a state yet, and no amount of fiddling with indices will rescue it. If the sentence needs an extra clause — “… and I have already used k of my budget” — then k is a dimension of your state and you have just discovered the table is 2-D.
The running time then follows arithmetically, with no cleverness required:
time = (number of states) × (cost of one transition)
Fibonacci: n states, O(1) each, so O(n). Coin change with
amount A and c coins: A states, c per transition,
so O(A·c). Edit distance on strings of length n and m:
n·m states, O(1) each. You can quote the complexity before writing a
line.
Consider “how many ways can I climb n stairs taking 1 or 2 at a time?”
State: dp[i] = the number of ways to reach step i.
Transition: you arrived at i either from i-1 or from
i-2, and those are different sets of routes, so dp[i] = dp[i-1] + dp[i-2].
Base: dp[0] = 1 — there is exactly one way to stand still.
Order: increasing i.
That is Fibonacci, and you derived it without noticing. Most DP problems do this: the four sentences are easy and the recurrence turns out to be something you already know.
4Top-down or bottom-up
The same recurrence, computed in two directions. They are equivalent in what they compute and very different to work with.
Top-down (memoisation) starts at the answer you want and recurses down, caching. Bottom-up (tabulation) starts at the base cases and fills a table forward until it reaches the answer.
# Top-down: recursion + cache. Follows the definition literally.
def coins_td(coins, amount, memo=None):
if memo is None:
memo = {}
if amount == 0:
return 0
if amount < 0:
return float("inf")
if amount in memo:
return memo[amount]
memo[amount] = min((coins_td(coins, amount - c, memo) + 1 for c in coins),
default=float("inf"))
return memo[amount]
# Bottom-up: a loop and an array. No stack, and the order is explicit.
def coins_bu(coins, amount):
dp = [0] + [float("inf")] * amount
for x in range(1, amount + 1):
for c in coins:
if c <= x:
dp[x] = min(dp[x], dp[x - c] + 1)
return dp[amount]| Top-down (memo) | Bottom-up (table) | |
|---|---|---|
| How you write it | Write the recursion, add a cache | Work out the order, then loop |
| Order of computation | Discovered automatically by the recursion | You must get it right yourself |
| Unreachable states | Never computed — can be much faster | All computed, reachable or not |
| Stack depth | Proportional to the recursion depth | None |
| Space reduction | Awkward | Easy — keep the last row or two |
| Best when | The state space is sparse or hard to order | The state space is dense and the order is obvious |
A reliable working method: write the plain recursion first (it is easy to convince yourself it
is correct), add @lru_cache to make it fast, and only convert to a
loop if you need the space reduction or the stack depth is a problem.
The conversion is mechanical. The bottom-up loop order is simply a linearisation of the dependency graph the recursion was walking — which is a topological sort, exactly as in Chapter 21. That is not a coincidence; it is the same theorem.
5Less memory, and the actual answer
Two refinements that turn a working DP into a usable one.
Rolling the table. If dp[i] depends only on dp[i-1] and
dp[i-2], there is no reason to keep the other n - 2 entries:
def fib(n):
a, b = 0, 1
for _ in range(n):
a, b = b, a + b
return aThe same trick applies to 2-D tables whose rows depend only on the previous row: keep two rows
instead of n, and edit distance drops from O(n·m) memory to
O(m). On large inputs this is often the difference between running and not.
You cannot reconstruct the answer from a rolled table — the history is gone. If the problem asks for the actual coins, the actual edit script, the actual subsequence, you need either the full table or a separate array of back-pointers.
Decide which you need before optimising. “Return the length” and “return the subsequence” are different problems with different space bounds.
Reconstruction. Two approaches, both common:
# 1. Store a back-pointer alongside each answer.
def min_coins_with_list(coins, amount):
dp = [0] + [float("inf")] * amount
take = [None] * (amount + 1) # which coin produced dp[x]
for x in range(1, amount + 1):
for c in coins:
if c <= x and dp[x - c] + 1 < dp[x]:
dp[x] = dp[x - c] + 1
take[x] = c
if dp[amount] == float("inf"):
return None
out, x = [], amount
while x > 0:
out.append(take[x])
x -= take[x]
return out
# 2. Or keep the full table and walk backwards, re-deriving each choice.
# No extra memory, slightly more thinking at the end.Back-pointers are breadcrumbs: at every cell you note which neighbour you came from, and at the end you follow the trail home. Cheap, obvious, and costs one extra array.
Walking the finished table backwards is reading a map: you stand at the answer and ask “which predecessor could have produced this value?”, then move there. No extra memory, but you have to re-derive the reasoning at each step and it is easier to get wrong.
Use breadcrumbs unless memory is genuinely tight.
6DP, greedy, and divide-and-conquer
Three paradigms that all break a problem into smaller ones. What separates them is how the pieces relate.
| Subproblems | How many options considered | Example | |
|---|---|---|---|
| Divide & conquer | Disjoint, no overlap | All, but each piece once | Merge sort |
| Dynamic programming | Overlapping, reused | All, each distinct one once | Edit distance |
| Greedy | One subproblem after a fixed choice | One | Huffman |
The practical decision procedure, in order:
- Can I make a local choice and prove it safe? If yes, greedy — it is faster and shorter. Chapter 24 is about how to check.
- Do the subproblems repeat? If yes, DP. If no, plain divide-and-conquer or recursion is enough.
- Is the state space too big to tabulate? Then backtracking with pruning (Chapter 27), or an approximation.
These wordings in a problem statement almost always mean DP:
- “the minimum / maximum number of…”
- “how many ways can…”
- “is it possible to reach…”
- “the longest / shortest subsequence such that…”
- anything with a budget, capacity or count that is consumed by choices
The last one is worth internalising: a constraint like “at most k” is almost always a dimension of the state. You saw this in Chapter 22 with cheapest-flights-within-k-stops, which is a shortest-path problem that turned into DP the moment a budget appeared.
And a warning about the reverse direction. Not everything that looks like DP is: if the subproblems do not actually overlap, you have paid for a table you did not need. Check that the number of distinct states is genuinely smaller than the number of calls before reaching for a cache.
Richard Bellman coined “dynamic programming” in the 1950s while working at RAND. By his own account he chose it partly because his employer's director disliked mathematical research, and “dynamic programming” sounded impressive and unobjectionable.
“Programming” here means planning — scheduling, as in a television programme — and has nothing to do with writing code. Nobody should feel bad about finding the name unhelpful.
What to carry forward
- DP applies when a problem has optimal substructure and overlapping subproblems. Without overlap, it is just divide-and-conquer.
- Memoisation is the plain recursion plus a cache. It changes the running time, not the logic.
- Fill in four blanks: state, transition, base case, order. Write them in English first.
- If you cannot finish the sentence “
dp[i]is the best X for exactly Y”, your state is incomplete — and the missing clause is usually a second dimension. - Time = states × transition cost. You can quote the complexity before writing code.
- Top-down discovers the order for you and skips unreachable states; bottom-up has no stack and rolls easily into O(1) rows.
- The bottom-up loop order is a topological sort of the dependency graph — the same theorem as Chapter 21.
- Rolling the table saves memory but destroys the history. Decide whether you need the actual answer before optimising.
- Store back-pointers to reconstruct the choices, not just their value.
- A budget or capacity in the problem statement is almost always a dimension of the state.
>_Playground
Count the calls, watch memoisation collapse them, and compare top-down with bottom-up on the same recurrence.
✓Exercises
Checked automatically the moment you submit. Work top to bottom — each one assumes the last. Your answers are saved in this browser.
Chapter 26 — Dynamic Programming II: The Classic Patterns
Six recurrences cover most of what you will ever meet: knapsack, LCS, edit distance, LIS, coin change and grid paths.