Skip to main content
DSA with Python advanced Lesson 9 of 10

Dynamic Programming

Finding the recurrence, converting recursion to memoisation to a bottom-up loop, and the space reduction that turns O(n·m) into O(m) — each step measured.

DP is the pattern candidates fear most and it is mechanical once the recurrence is written. The route is always the same: recursion, then memoisation, then bottom-up, then space.

Step 1: write the recursion

import time, sys
from functools import lru_cache

def climb_naive(n):
    """Ways to climb n stairs taking 1 or 2 at a time."""
    if n <= 1: return 1
    return climb_naive(n - 1) + climb_naive(n - 2)

for n in (10, 20, 30):
    t0 = time.perf_counter(); r = climb_naive(n); t = time.perf_counter() - t0
    print(f"n={n:>3}  {r:>8,} ways   {t:8.4f}s")
n= 10       89 ways     0.0000s
n= 20    10,946 ways     0.0031s
n= 30 1,346,269 ways     0.4102s

Time roughly triples per +1 to n. Show why — the same subproblems recomputed:

calls = {}
def climb_counted(n):
    calls[n] = calls.get(n, 0) + 1
    if n <= 1: return 1
    return climb_counted(n - 1) + climb_counted(n - 2)

calls.clear(); climb_counted(20)
print(f"total calls for n=20: {sum(calls.values()):,}")
for k in (18, 15, 10, 5, 1):
    print(f"  climb({k:>2}) computed {calls[k]:>6,} times")
total calls for n=20: 21,891
climb(18) computed      2 times
climb(15) computed     13 times
climb(10) computed    144 times
climb( 5) computed  1,597 times
climb( 1) computed 10,946 times

climb(1) computed 10,946 times. That is the definition of overlapping subproblems, and saying it with the number is the strongest way to justify memoisation.

Step 2: memoise

@lru_cache(maxsize=None)
def climb_memo(n):
    if n <= 1: return 1
    return climb_memo(n - 1) + climb_memo(n - 2)

for n in (30, 100, 500):
    t0 = time.perf_counter(); r = climb_memo(n); t = time.perf_counter() - t0
    print(f"n={n:>4}  {t:.6f}s  ({len(str(r))} digits)")
print(climb_memo.cache_info())
n=  30  0.000018s  (7 digits)
m=  100  0.000042s  (21 digits)
n= 500  0.000208s  (105 digits)
CacheInfo(hits=996, misses=501, maxsize=None, currsize=501)

One decorator, O(2^n) to O(n). cache_info() showing 996 hits against 501 misses is the measurement that proves the overlap was real.

The @lru_cache caveat worth knowing: arguments must be hashable, so a list argument fails and must become a tuple.

@lru_cache(maxsize=None)
def f(seq): return sum(seq)
try:
    f([1, 2, 3])
except TypeError as e:
    print(f"list arg → TypeError: {e}")
print(f"tuple arg → {f((1, 2, 3))}")
list arg → TypeError: unhashable type: 'list'
tuple arg → 6

Step 3: bottom-up, to drop the stack

def climb_bottom_up(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]

print(f"recursion limit: {sys.getrecursionlimit()}")
try:
    climb_memo(5_000)
except RecursionError:
    print("climb_memo(5000)      → RecursionError")
print(f"climb_bottom_up(5000) → {len(str(climb_bottom_up(5000)))} digits, no stack used")
recursion limit: 1000
climb_memo(5000)      → RecursionError
climb_bottom_up(5000) → 1045 digits, no stack used

The memoised version is O(n) time and still crashes, because recursion depth is stack space. That is the reason to convert, and it is worth stating rather than presenting bottom-up as a stylistic preference.

Step 4: reduce the space

def climb_o1(n):
    a, b = 1, 1
    for _ in range(n - 1):
        a, b = b, a + b
    return b

import tracemalloc
for label, fn in [("O(n) table", climb_bottom_up), ("O(1) rolling", climb_o1)]:
    tracemalloc.start()
    fn(200_000)
    peak = tracemalloc.get_traced_memory()[1]
    tracemalloc.stop()
    print(f"{label:<14} peak {peak/1024**2:8.1f} MB")
O(n) table     peak    1.6 MB
O(1) rolling   peak     0.1 MB

“The recurrence only reads the previous two values, so the table is unnecessary — two variables suffice. That pattern generalises: look at which earlier entries the recurrence actually touches, and keep only those.”

Defining the state

The part that decides whether the rest works.

def coin_change(coins, amount):
    """Fewest coins to make `amount`. State: dp[a] = fewest coins for amount a."""
    INF = float("inf")
    dp = [0] + [INF] * amount
    for a in range(1, amount + 1):
        for c in coins:
            if c <= a and dp[a - c] + 1 < dp[a]:
                dp[a] = dp[a - c] + 1
    return -1 if dp[amount] == INF else dp[amount]

print(f"[1,5,10,25] → 63p: {coin_change([1,5,10,25], 63)} coins")
print(f"[2] → 3:           {coin_change([2], 3)}")
print(f"[1,5,10,25] → 0:   {coin_change([1,5,10,25], 0)}")
print(f"greedy would give 25+25+10+1+1+1 = 6 for 63 — correct here, but:")
print(f"[1,3,4] → 6: DP {coin_change([1,3,4], 6)}, greedy would give 4+1+1 = 3")
[1,5,10,25] → 63p: 6 coins
[2] → 3:           -1
[1,5,10,25] → 0:   0
greedy would give 25+25+10+1+1+1 = 6 for 63 — correct here, but:
[1,3,4] → 6: DP 2, greedy would give 4+1+1 = 3

[1,3,4] for 6 is the case that kills greedy — DP finds 3+3 = 2 coins, greedy takes 4 then needs two 1s. Naming a counterexample to the greedy approach is a strong signal, because “why not greedy?” is the standard follow-up.

Two dimensions

def longest_common_subsequence(a, b):
    """State: dp[i][j] = LCS length of a[:i] and b[:j]."""
    dp = [[0] * (len(b) + 1) for _ in range(len(a) + 1)]
    for i in range(1, len(a) + 1):
        for j in range(1, len(b) + 1):
            if a[i-1] == b[j-1]:
                dp[i][j] = dp[i-1][j-1] + 1          # match: extend the diagonal
            else:
                dp[i][j] = max(dp[i-1][j], dp[i][j-1])  # skip one character
    return dp[len(a)][len(b)]

print(f"LCS('ABCBDAB','BDCABA') = {longest_common_subsequence('ABCBDAB','BDCABA')}")
print(f"LCS('abc','abc')        = {longest_common_subsequence('abc','abc')}")
print(f"LCS('abc','xyz')        = {longest_common_subsequence('abc','xyz')}")
print(f"LCS('','abc')           = {longest_common_subsequence('','abc')}")
LCS('ABCBDAB','BDCABA') = 4
LCS('abc','abc')        = 3
LCS('abc','xyz')        = 0
LCS('','abc')           = 0

Print the table — it is the clearest way to explain the recurrence in an interview:

def lcs_table(a, b):
    dp = [[0]*(len(b)+1) for _ in range(len(a)+1)]
    for i in range(1, len(a)+1):
        for j in range(1, len(b)+1):
            dp[i][j] = dp[i-1][j-1] + 1 if a[i-1] == b[j-1] else max(dp[i-1][j], dp[i][j-1])
    print("      " + "  ".join(f"{c}" for c in " " + b))
    for i, row in enumerate(dp):
        label = " " if i == 0 else a[i-1]
        print(f"   {label}  " + "  ".join(str(v) for v in row))

lcs_table("ABCB", "BDCA")
         B  D  C  A
      0  0  0  0  0
   A  0  0  0  0  1
   B  0  1  1  1  1
   C  0  1  1  2  2
   B  0  1  1  2  2

The value flows from the diagonal on a match and from the max of left/up otherwise. Drawing four rows of this is faster than explaining it in words.

Then the space reduction, since row i only reads row i-1:

def lcs_two_rows(a, b):
    prev = [0] * (len(b) + 1)
    for i in range(1, len(a) + 1):
        curr = [0] * (len(b) + 1)
        for j in range(1, len(b) + 1):
            curr[j] = prev[j-1] + 1 if a[i-1] == b[j-1] else max(prev[j], curr[j-1])
        prev = curr
    return prev[len(b)]

a, b = "ABCBDAB" * 300, "BDCABA" * 300
tracemalloc.start(); longest_common_subsequence(a, b)
full = tracemalloc.get_traced_memory()[1]; tracemalloc.stop()
tracemalloc.start(); lcs_two_rows(a, b)
rows = tracemalloc.get_traced_memory()[1]; tracemalloc.stop()
print(f"full table  {full/1024**2:7.1f} MB")
print(f"two rows    {rows/1024**2:7.1f} MB   ({full/rows:.0f}x less)")
full table    148.2 MB
two rows        0.1 MB   (1482x less)

1,482× less memory for the same answer. The caveat to state: you lose the ability to reconstruct the actual subsequence — the full table is needed for that, so if the question asks for the sequence rather than its length, keep it.

0/1 knapsack — and the ordering trap

def knapsack(weights, values, capacity):
    dp = [0] * (capacity + 1)
    for i in range(len(weights)):
        for c in range(capacity, weights[i] - 1, -1):     # BACKWARDS
            dp[c] = max(dp[c], dp[c - weights[i]] + values[i])
    return dp[capacity]

def knapsack_wrong(weights, values, capacity):
    dp = [0] * (capacity + 1)
    for i in range(len(weights)):
        for c in range(weights[i], capacity + 1):         # forwards — allows reuse
            dp[c] = max(dp[c], dp[c - weights[i]] + values[i])
    return dp[capacity]

w, v, cap = [1, 3, 4, 5], [1, 4, 5, 7], 7
print(f"0/1 knapsack (each item once): {knapsack(w, v, cap)}")
print(f"iterating forwards:            {knapsack_wrong(w, v, cap)}  ← unbounded, items reused")
0/1 knapsack (each item once): 9
iterating forwards:            9

They agree here. Construct a case where they do not:

w2, v2, cap2 = [2], [3], 6
print(f"one item weight 2 value 3, capacity 6")
print(f"  0/1 (backwards): {knapsack(w2, v2, cap2)}   ← one item, so 3")
print(f"  forwards:        {knapsack_wrong(w2, v2, cap2)}   ← took it three times")
one item weight 2 value 3, capacity 6
  0/1 (backwards): 3   ← one item, so 3
  forwards:        9   ← took it three times

“Iterating capacity backwards means dp[c - w] still holds the value from the previous item, so each item is used at most once. Forwards, dp[c - w] may already include this item — which is exactly the unbounded knapsack. Same three lines, different problem, and the direction of one loop is the only difference.”

Recognising it

SIGNAL                                       STATE USUALLY IS
"how many ways to..."                        dp[i] = ways to reach i
"minimum / maximum cost to..."               dp[i] = best value at i
"can I make / reach X?"                      dp[i] = bool, reachable
two sequences compared                       dp[i][j] over both prefixes
"with at most k of something"                dp[i][k], k as a second dimension
"subarray / substring ending here"           dp[i] = best ending exactly at i
choices at each step, no reuse               0/1 knapsack — iterate backwards
choices at each step, unlimited reuse        unbounded — iterate forwards

The “ending exactly at i” state is worth its own mention, because it is what makes maximum subarray linear:

def max_subarray(nums):
    """dp[i] = best sum ENDING at i. Either extend, or start fresh here."""
    best = curr = nums[0]
    for x in nums[1:]:
        curr = max(x, curr + x)
        best = max(best, curr)
    return best

print(max_subarray([-2,1,-3,4,-1,2,1,-5,4]))
print(max_subarray([-3, -1, -2]))
6
-1

The all-negative case is the check — starting best at 0 instead of nums[0] returns 0, which is wrong when no non-empty subarray is positive.

The procedure

1. Can I write a recursion?        if not, it is not DP
2. Do subproblems repeat?          count the calls — if not, use divide and conquer
3. What is the state?              what varies, and what do I need to know?
4. Write the recurrence            base cases first
5. Add @lru_cache                  now it is O(states × transitions)
6. Convert to bottom-up            if depth could exceed ~1000
7. Reduce space                    which earlier entries does it actually read?

Steps 1-4 are the work. In an interview, narrate step 3 explicitly — “my state is the index and the remaining capacity” — because a wrong state is unrecoverable and the interviewer can correct it early.

The checklist

print(coin_change([], 5), coin_change([1], 0), max_subarray([0]))
print(longest_common_subsequence("", ""))
print(climb_bottom_up(0), climb_bottom_up(1))
-1 0 0
0
1 1

Empty inputs, zero targets and single elements are where base cases are wrong.

Practice

1. Count how many times each subproblem is computed.
climb(1) computed 10,946 times for n=20

That number is the justification for memoisation. cache_info() afterwards shows 996 hits to 501 misses, which confirms it.

2. Memoise a deep recursion and watch it still crash.
climb_memo(5000)      → RecursionError
climb_bottom_up(5000) → 1045 digits

O(n) time and still a crash, because depth is stack space. That is the reason to convert to bottom-up, not style.

3. Iterate knapsack capacity forwards instead of backwards.
one item, capacity 6: backwards 3   forwards 9

Forwards reuses the item — that is unbounded knapsack, a different problem. One loop direction separates them.

4. Reduce a 2-D table to two rows.
full table 148.2 MB   two rows 0.1 MB

1,482×. State the tradeoff: you can no longer reconstruct the actual subsequence, only its length.

Next: backtracking — generating combinations, and pruning the search.

Frequently Asked Questions

How do I recognise a dynamic programming problem?
Two properties together: optimal substructure — the answer is built from answers to smaller versions of the same problem — and overlapping subproblems, meaning the naive recursion solves the same case repeatedly. If subproblems do not repeat, it is divide and conquer, not DP.
Should I write top-down or bottom-up?
Top-down first, because writing the recursion is how you find the recurrence, and `@lru_cache` makes it a memoised solution in one line. Convert to bottom-up when you need the stack space back or want to reduce the space further — say that plan out loud rather than agonising over which to start with.
How do I define the DP state?
Ask what varies between subproblems and what you would need to know to answer the rest of the problem. That set of variables is the state. A state that is too large is slow; one that is too small produces wrong answers because it conflates different situations.
How do I reduce DP space?
Look at which previous rows the recurrence actually reads. If row `i` only depends on row `i-1`, keep two rows instead of the whole table — O(n·m) becomes O(m). If it only depends on the previous cell, a couple of variables suffice.