Recursion and Dynamic Programming: Two Conditions, One Filled Table

7 minute read

Published:

TL;DR: A recursive call costs a stack frame, so recursion depth is space and Python raises RecursionError at a default depth of 1000. Dynamic programming applies when two conditions hold: optimal substructure (an optimal solution is built from optimal solutions of subproblems) and overlapping subproblems (the same subproblem recurs). Memoisation is the top-down fix, tabulation the bottom-up one; they have the same asymptotics, and tabulation avoids the stack and allows space compression. Edit distance is \(\Theta(mn)\) time and \(\Theta(\min(m,n))\) space.

Recursion is a stack you did not declare

Every call pushes a frame holding arguments, locals and the return address. Depth \(d\) therefore costs \(\Theta(d)\) memory, and that memory is the call stack โ€” a fixed-size region, not the heap.

A correct recursion needs base cases that are reachable (every call moves strictly towards one) and complete (they cover every way the recursion bottoms out). Miss either and Python raises RecursionError at its default limit of 1000 frames; raising that limit with sys.setrecursionlimit trades a clean exception for a possible hard crash of the C stack. CPython does no tail-call optimisation, so rewrite deep recursions iteratively.

Where the exponential comes from

def fib_naive(n):
    return n if n < 2 else fib_naive(n - 1) + fib_naive(n - 2)

The recurrence is \(T(n) = T(n-1) + T(n-2) + \Theta(1)\), so the call count grows like the Fibonacci numbers themselves: \(\Theta(\varphi^{\,n})\) with \(\varphi = (1+\sqrt5)/2 \approx 1.618\). The waste is structural โ€” fib_naive(n-2) is computed twice, fib_naive(n-3) three times โ€” even though there are only \(n\) distinct subproblems. That gap between distinct subproblems and evaluated subproblems is exactly what dynamic programming closes.

Memoisation and tabulation

Memoisation keeps the recursion and caches results:

from functools import cache

@cache                       # functools.cache, Python 3.9+; lru_cache(None) before that
def fib(n):
    return n if n < 2 else fib(n - 1) + fib(n - 2)

print(fib(50))               # 12586269025, from 51 distinct subproblems

Each of the \(n+1\) distinct arguments is evaluated once, so the cost drops to \(\Theta(n)\) time and \(\Theta(n)\) space โ€” but the recursion depth is still \(\Theta(n)\), so fib(5000) would blow the stack.

Tabulation fills the same table bottom-up in an order where every dependency is already computed:

def fib_table(n):
    a, b = 0, 1
    for _ in range(n):
        a, b = b, a + b
    return a                 # Theta(n) time, Theta(1) space

Same asymptotics, no stack, better constants, and โ€” because the loop makes the dependency structure explicit โ€” it is where space optimisation becomes visible: fib_table keeps two numbers instead of \(n\).

ย Memoisation (top-down)Tabulation (bottom-up)
Control flowrecursion + cacheloops
Computesonly reachable subproblemsall subproblems in the table
Stack depth\(\Theta(\text{depth})\)\(\Theta(1)\)
Space compressionhardeasy (keep the last row)
Best whenthe state space is sparsethe table is dense, or the stack is a risk

The two conditions

DP applies exactly when:

  1. Optimal substructure. An optimal solution contains optimal solutions to subproblems. Shortest paths have it: a prefix of a shortest path is a shortest path. Longest simple paths do not, which is why there is no DP for them.
  2. Overlapping subproblems. The recursion revisits the same subproblem many times. Merge sort has optimal substructure but its subproblems are disjoint, so caching buys nothing โ€” that is divide and conquer, not DP.

Both are needed. Condition 1 makes the recurrence correct; condition 2 makes the table worth building.

Worked example: edit distance

The Levenshtein distance between strings \(s\) and \(t\) is the fewest single-character insertions, deletions and substitutions turning one into the other. Let \(D[i][j]\) be the distance between the first \(i\) characters of \(s\) and the first \(j\) of \(t\):

\[ D[i][j] = \min\begin{cases} D[i-1][j] + 1 & \text{delete } s_i,\\ D[i][j-1] + 1 & \text{insert } t_j,\\ D[i-1][j-1] + \mathbf{1}[s_i \ne t_j] & \text{match or substitute}, \end{cases} \]

with \(D[i][0] = i\) and \(D[0][j] = j\) โ€” turning a prefix into the empty string costs one deletion per character. Filled for \(s =\) kitten, \(t =\) sitting:

ย ฮตsitting
ฮต01234567
k11234567
i22123456
t33212345
t44321234
e55432234
n66543323

The answer is the bottom-right cell: 3. Read the bold diagonal backwards and you recover the operations โ€” substitute kโ†’s, keep itt, substitute eโ†’i, keep n, insert g โ€” which is kitten โ†’ sitten โ†’ sittin โ†’ sitting.

Each cell is \(O(1)\) work and there are \((m+1)(n+1)\) of them, so the algorithm is \(\Theta(mn)\) time. Each row depends only on the previous one, so space compresses from \(\Theta(mn)\) to \(\Theta(\min(m,n))\):

def edit_distance(s, t):
    """Levenshtein distance. Theta(m*n) time, Theta(min(m,n)) space."""
    if len(s) < len(t):
        s, t = t, s                        # keep the shorter string as the row
    prev = list(range(len(t) + 1))         # row 0: distance from "" to t[:j]
    for i, cs in enumerate(s, start=1):
        cur = [i] + [0] * len(t)
        for j, ct in enumerate(t, start=1):
            cur[j] = min(prev[j] + 1,              # delete cs
                         cur[j - 1] + 1,           # insert ct
                         prev[j - 1] + (cs != ct)) # match / substitute
        prev = cur
    return prev[-1]

print(edit_distance("kitten", "sitting"))   # 3
print(edit_distance("", "abc"))             # 3
print(edit_distance("abc", "abc"))          # 0

Note that recovering the path needs the full table (or a re-run), so the space compression costs you the reconstruction โ€” a trade the interviewer will often ask about.

Spotting a DP problem

Four signals, and if three fire, build a table: the question asks for a count, minimum or maximum over exponentially many configurations; those configurations are built by a sequence of local decisions; the brute-force recursion revisits states; and the state is describable by a handful of indices.

Then work in a fixed order: state (what does \(D[\cdot]\) mean โ€” say it in words), recurrence, base cases, evaluation order, where the answer sits. Writing the state in English first is what prevents a subtly wrong recurrence, and it is what an interviewer is listening for.

Key Insight โ€” DP is brute force with the repetition removed. The exponential recursion and the polynomial table compute the same values by the same recurrence; the only difference is that one recomputes and the other remembers. So the complexity of a DP is not something to derive from scratch: it is number of distinct states ร— cost per state. Edit distance has \(mn\) states at \(O(1)\) each, hence \(\Theta(mn)\). Get the state space right and the complexity falls out.
Interview trap โ€” the mutable default argument. Writing def solve(n, memo={}) creates that dict once, when the function is defined, and every call shares it โ€” so results leak between independent inputs and the "cache" silently returns answers to another problem.
def collect(x, acc=[]):   # evaluated once, at definition time
    acc.append(x)
    return acc

collect(1)   # [1]
collect(2)   # [1, 2]  -- not [2]
Use memo=None and build the dict inside, or decorate with functools.cache. Two more: memoised recursion still carries \(\Theta(\text{depth})\) stack, so deep chains need tabulation; and greedy is not DP โ€” it needs a separate exchange argument, and "it worked on the examples" is not one.

Recap

  • Recursion depth is memory: \(\Theta(d)\) stack frames, with CPython raising RecursionError near depth 1000 and performing no tail-call optimisation.
  • Naive Fibonacci is \(\Theta(\varphi^{\,n})\) despite having only \(n\) distinct subproblems โ€” the gap between distinct and evaluated subproblems is what DP removes.
  • DP needs both optimal substructure and overlapping subproblems; merge sort has the first without the second, so it is divide and conquer.
  • Complexity of a DP = number of states ร— cost per state. Edit distance: \(\Theta(mn)\) time, \(\Theta(\min(m,n))\) space once rows are rolled, but path reconstruction needs the full table.
  • Never use a mutable default argument as a cache; it is created once at definition and shared across all calls.

References

  1. Bellman, R. Dynamic Programming. Princeton University Press, 1957.
  2. Wagner, R. A., & Fischer, M. J. The string-to-string correction problem. Journal of the ACM 21(1), 168โ€“173, 1974.
  3. Cormen, T. H., Leiserson, C. E., Rivest, R. L., & Stein, C. Introduction to Algorithms, 4th ed. MIT Press, 2022 โ€” ch. 14 on dynamic programming.
  4. Python Software Foundation. functools.cache and lru_cache.
  5. Python Software Foundation. sys.setrecursionlimit.
  6. Python Software Foundation. Default argument values โ€” the tutorialโ€™s own warning about mutable defaults.