3.7.7 · D4Algorithm Paradigms

Exercises — Memoization (top-down DP) — recursive + memo dict

2,943 words13 min readBack to topic

Quick vocabulary reminder before we start — every symbol earned:


Level 1 — Recognition

L1·Q1 — Does memoization even help?

For each, say whether memoization gives a speedup, and why. (a) Fibonacci fib(n) = fib(n-1) + fib(n-2) (b) Merge sort splitting an array in half, sorting each half (c) gcd(a, b) = gcd(b, a mod b) (Euclid)

Recall Solution

Memoization only helps when subproblems overlap (the same state is asked more than once) and the problem has optimal substructure (its answer is built from smaller subproblem answers — see the vocabulary box).

  • (a) Fibonacci — YES. fib(3) is requested by both fib(4) and fib(5). Massive overlap. Speedup: .
  • (b) Merge sort — NO. Each split produces a distinct slice of the array; you never sort the same slice twice. This is Divide and Conquer, not DP. Caching stores things you never look up again.
  • (c) Euclid gcd — NO (essentially). Each call strictly shrinks the numbers along a single chain — no repeated (a,b) pair arises. It's already recursion with no overlap.

Answer: only (a) benefits.

L1·Q2 — Name the four parts

The code below is a memoized function. Label lines A, B, C, D with which of the four template parts they are.

def solve(n, memo):
    if n in memo: return memo[n]          # A
    if n <= 1: return n                   # B
    ans = solve(n-1, memo) + solve(n-2, memo)  # C
    memo[n] = ans                         # D
    return memo[n]
Recall Solution
  • A = Check the cache ("have I seen this state? return the stored answer" — a cache hit).
  • B = Base case (smallest inputs with a known answer, no recursion).
  • C = Recurse (combine the answers of the smaller subproblems).
  • D = Store (save the computed answer into the cache before returning).

Mnemonic from the parent: C-B-R-SCheck, Base, Recurse, Store. Notice each letter now maps to exactly one line: the R (compute) and the S (save) are deliberately split so you never forget to store before returning.


Level 2 — Application

L2·Q1 — Trace Fibonacci

Using the fib code from L1·Q2, list the exact values stored in memo after fib(6) completes, and give fib(6).

Recall Solution

Distinct states computed are (the values hit the base case and are never stored). Fibonacci sequence: . fib(6) = 8.

L2·Q2 — Climbing Stairs value

Using the parent's ways code (, ), compute the number of ways to climb 7 stairs taking 1 or 2 steps.

Recall Solution

Build up: . ways(7) = 21. (Notice it's the Fibonacci sequence shifted — because it's the same recurrence with different base values.)

L2·Q3 — Grid Unique Paths value

Using the parent's paths(r, c) code, compute the number of unique paths in a grid of cells, i.e. paths(2, 2) (0-indexed bottom-right corner).

Recall Solution

Figure walkthrough (s01): a grid of rounded cells sits on the peach background. The whole top row and the whole left column are shaded orange and every one of them holds the number 1 — because along an edge there is only a single straight-line path (all-right, or all-down). The four interior cells are shaded violet; each interior number is the sum of the orange/violet cell directly above it and the one directly to its left. Two magenta arrows point into the bottom-right cell: one coming down from the cell above (value ) and one coming from the left (value ), and the label reads P(2,2)=6. Reading the interior fill-in: , then and , and finally the corner.

Figure — Memoization (top-down DP) — recursive + memo dict
paths(2,2) = 6.


Level 3 — Analysis

L3·Q1 — Count the distinct states (2-D)

For an grid, paths(r, c) caches by the tuple (r, c). How many distinct states exist, and what is the time and space complexity?

Recall Solution

r ranges over and c over , so distinct states . Each state does work (two lookups + one add). Therefore: The deepest recursion path is the top-left→bottom-right diagonal, length .

L3·Q2 — Why exactly , not ?

In memoized Fibonacci, the call tree still branches into two at each node. Explain precisely why the runtime is and not .

Recall Solution

Count distinct states, not calls. Recall from the vocabulary: a cache miss does real work and recurses; a cache hit just returns the stored value in without branching. The first time a state is reached it is a miss (real work); every later time it is a hit. Figure walkthrough (s02): the full naive call tree for fib(5) is drawn — 15 circular nodes, each labelled with its argument, edges linking a node to its two children. The six magenta filled nodes are the first time each distinct argument () is reached — these are the cache misses that do real work. All the faint violet nodes are repeat occurrences of arguments already computed — these are cache hits and, crucially, they are drawn with no children below them, because a hit returns instantly and never expands. The legend spells out "real work (6 distinct states)" vs "cache hit (pruned, )". Seeing the violet nodes as childless leaves is the whole point: the exponential tree is pruned down to 6 working nodes.

Figure — Memoization (top-down DP) — recursive + memo dict
There are only distinct arguments (). Each is fully computed exactly once at work. Total real work . The cache hits are cheap leaves that never expand.

L3·Q3 — Predict the recursion tree size

Without a cache, how many total calls does naive fib(5) make (count every node in the call tree, including leaves)? With memoization, how many calls do real (cache-miss) work?

Recall Solution

Naive: let = the total number of calls made to compute fib(n). To compute fib(n) we make one call (the current invocation itself) plus all the calls its two children make. That "" is the current node — every node in the tree counts as one call, and this line is where we count the node we are standing on: (For there is just the single base-case call, so .) Evaluating: . 15 total calls. Memoized: cache-miss states are 6 states do real work (the rest are cache hits). This is the collapse in concrete numbers.


Level 4 — Synthesis

L4·Q1 — Design: minimum coin count

Given coins [1, 3, 4] and a target T, find the fewest coins summing to T (unlimited coins each). Write the recurrence and the memoized function, then compute the answer for T = 6.

Recall Solution

State: the remaining amount t. Recurrence: to make t, try each coin c ≤ t, use one of it, then make t-c optimally:

def min_coins(t, coins, memo=None):
    if memo is None: memo = {}
    if t in memo: return memo[t]
    if t == 0: return 0                # base: zero coins for amount 0
    best = float('inf')
    for c in coins:
        if c <= t:
            best = min(best, 1 + min_coins(t - c, coins, memo))
    memo[t] = best
    return memo[t]

Compute for T=6: , , , , , (=4+1), (=3+3). min_coins(6) = 2.

L4·Q2 — Design: longest common subsequence length

Given two strings, define the memoized recurrence for the length of their longest common subsequence (LCS), with state (i, j) = "considering suffix of A from i and B from j". Compute the LCS length of "ABCBDAB" and "BDCAB".

Recall Solution

State = the pair of start indices (i, j) — both must be in the key, or you fetch stale answers.

def lcs(i, j, A, B, memo=None):
    if memo is None: memo = {}
    if (i, j) in memo: return memo[(i, j)]
    if i == len(A) or j == len(B): return 0
    if A[i] == B[j]:
        ans = 1 + lcs(i+1, j+1, A, B, memo)
    else:
        ans = max(lcs(i+1, j, A, B, memo), lcs(i, j+1, A, B, memo))
    memo[(i, j)] = ans
    return memo[(i, j)]

For "ABCBDAB" and "BDCAB" a longest common subsequence is "BCAB" (length 4). lcs = 4.


Level 5 — Mastery

L5·Q1 — The mutable-default bug

This code passes the first test but fails the second. Explain exactly what happens, and give the corrected first line.

def fib(n, memo={}):
    if n in memo: return memo[n]
    if n < 2: return n
    memo[n] = fib(n-1) + fib(n-2)
    return memo[n]
 
print(fib(5))   # test 1
print(fib(5))   # test 2 — same value, but the cache leaked
Recall Solution

Python evaluates memo={} once, when the function is defined — not per call. So the same dictionary object is shared across every top-level fib(...) invocation. Both prints give the correct 5, but the cache silently persists between independent calls. On a competitive judge or a test that mutates state (or a variant where the recurrence depends on external state), this stale cache poisons later results. Fix — any of:

def fib(n, memo=None):
    if memo is None: memo = {}
    ...

or pass an explicit fresh dict from the caller, or use @lru_cache from lru_cache decorator. fib(5) = 5 (correct value both times; the danger is state leakage, not this specific number).

L5·Q2 — The missing-state-variable bug

A knapsack solver caches by index only:

def knap(i, cap, items, memo=None):
    if memo is None: memo = {}
    if i in memo: return memo[i]           # BUG
    if i == len(items) or cap == 0: return 0
    w, v = items[i]
    skip = knap(i+1, cap, items, memo)
    take = 0
    if w <= cap:
        take = v + knap(i+1, cap - w, items, memo)
    memo[i] = max(skip, take)
    return memo[i]

Why is this wrong, and what is the correct cache key? Give the fixed lines.

Recall Solution

The answer at index i depends on both i and the remaining capacity cap. Two different capacities reaching the same i produce different answers, but caching by i alone makes them collide — the first one stored is returned for all later cap values. It runs fast and looks right, which is exactly why it's dangerous. Rule: the memo key must include every variable that changes the answer. Fix:

if (i, cap) in memo: return memo[(i, cap)]
...
memo[(i, cap)] = max(skip, take)
return memo[(i, cap)]

L5·Q3 — Convert top-down to bottom-up

Take the Climbing Stairs recurrence , . Write the bottom-up version and confirm it gives the same ways(7)=21.

Recall Solution

Bottom-up fills a table from the base cases upward — no recursion, so no stack-overflow risk:

def ways(n):
    if n <= 1: return 1
    dp = [0] * (n + 1)
    dp[0] = dp[1] = 1
    for i in range(2, n + 1):
        dp[i] = dp[i-1] + dp[i-2]
    return dp[n]

Table for n=7: [1,1,2,3,5,8,13,21]ways(7) = 21, identical to the memoized answer. Same recurrence, same complexity , different control flow.


Recall Master checklist (reveal after finishing all levels)

One-line summary of the whole page ::: Write the honest recurrence, key the cache by the full state, store before returning, and pass a fresh dict — then cost it by counting distinct states.

Connections

  • Memoization (top-down DP) — recursive + memo dict (index 3.7.7) — parent note
  • Recursion — every solution here is recursion + a cache
  • Dynamic Programming — the paradigm these exercises train
  • Tabulation (Bottom-up DP) — L5·Q3 converts to this
  • Time Complexity Analysis — the L3 counting-states technique
  • Divide and Conquer — L1·Q1 (b),(c) are this, not DP
  • Hash Maps / Dictionaries — the cache
  • lru_cache decorator — the L5·Q1 one-line fix