Ch 25 / 30 Dynamic Programming I: The Grammar 0/0 exercises Exercises ↓

AmouAI Hub/Courses/Data Structures & Algorithms/Chapter 25

Part 6 · Design Paradigms · 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.

Reading
CLRS Ch. 15
Focus
Writing the recurrence
Cost
states × transitions
Needs
Recursion, Ch. 10

By the end of this chapter you can

  1. Recognise overlapping subproblems, and measure the cost of recomputing them
  2. Convert a recursion into a memoised version mechanically
  3. Fill in the four blanks — state, transition, base case, order — for a new problem
  4. Translate between top-down memoisation and bottom-up tabulation, and say why you would choose each
  5. Compute the running time as the number of states times the cost of one transition
  6. 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.

Counting a crowd by asking everyone

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:

fib.pycorrect, and unusable
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.

The two conditions

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:

fib_memo.pyO(n), same logic
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:

fib_cached.pythe same thing, one line
from functools import lru_cache

@lru_cache(maxsize=None)
def fib(n):
    if n <= 1:
        return n
    return fib(n - 1) + fib(n - 2)
Three ways memoisation goes wrong
  • A mutable default argument. def f(n, memo={}) shares one dictionary across every call to f for 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:

BlankThe question it answersFor Fibonacci
StateWhat does one subproblem look like? What do I need to know to answer it?f(n) = the n-th Fibonacci number
TransitionHow is one state built from smaller ones?f(n) = f(n-1) + f(n-2)
Base caseWhich states are answered without recursing?f(0) = 0, f(1) = 1
OrderIn what sequence can I compute them so that every dependency is already done?increasing n
Say the state out loud, in words

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.

A form, not a flash

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.

two_shapes.py
# 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 itWrite the recursion, add a cacheWork out the order, then loop
Order of computationDiscovered automatically by the recursionYou must get it right yourself
Unreachable statesNever computed — can be much fasterAll computed, reachable or not
Stack depthProportional to the recursion depthNone
Space reductionAwkwardEasy — keep the last row or two
Best whenThe state space is sparse or hard to orderThe state space is dense and the order is obvious
Start top-down, finish bottom-up

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:

fib_rolling.pyO(n) time, O(1) space
def fib(n):
    a, b = 0, 1
    for _ in range(n):
        a, b = b, a + b
    return a

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

What you give up

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:

reconstruct.py
# 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.
Breadcrumbs versus a map

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.

SubproblemsHow many options consideredExample
Divide & conquerDisjoint, no overlapAll, but each piece onceMerge sort
Dynamic programmingOverlapping, reusedAll, each distinct one onceEdit distance
GreedyOne subproblem after a fixed choiceOneHuffman

The practical decision procedure, in order:

  1. 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.
  2. Do the subproblems repeat? If yes, DP. If no, plain divide-and-conquer or recursion is enough.
  3. Is the state space too big to tabulate? Then backtracking with pruning (Chapter 27), or an approximation.
The signal phrases

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.

Where the name comes from

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.

scratch.pypython not loaded
Real Python, running in your browser. Nothing is installed or uploaded.
Output appears here. The first run takes a few seconds while Python loads.

Exercises

Checked automatically the moment you submit. Work top to bottom — each one assumes the last. Your answers are saved in this browser.

All Warm-up Core Challenge Reset chapter

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.

Continue →