Skip to content

Dynamic Programming

Illustrative interview

A recursive solution to a small counting problem looks elegant, but the trace recomputes the same state thousands of times. At n = 45, the clock expires before the program does. Write the state and transition first; caching repeated subproblems turns the recursion tree from exponential repetition into bounded work.

Difficulty: Medium–Hard | Pattern Type: Overlapping subproblems

← BFS & DFS | DSA Overview


Why This Pattern Exists

Problem: A staircase has n steps. You can climb 1 or 2 steps at a time. How many distinct ways can you reach the top?

Brute force: from step i, try i+1 and i+2. Recurse.

ways(5) → ways(4)+ways(3)
ways(4) → ways(3)+ways(2)     ← ways(3) computed twice
ways(3) → ways(2)+ways(1)     ← ways(2) computed many times

For n=40 this is tens of millions of calls. The call tree is exponential, but there are only n different questions: ways(k) for k = 0..n.

Clue: the same subproblem shows up more than once (overlapping subproblems), and the answer to n is built only from answers to smaller n (optimal substructure). That pair is DP. Not “a 2D array.” The array is an implementation detail.


Mental Model

Fibonacci cell F5 is the sum of the two cells already stored beside it.

You are filling a table of answers to smaller questions, in an order that guarantees those answers already exist.

Question: ways(i) = ways(i-1) + ways(i-2)
Base:     ways(0)=1, ways(1)=1

i:     0  1  2  3  4  5
ways:  1  1  2  3  5  8
              ↑
         just looked left

If you cannot name the question a cell answers in one sentence, you do not have a recurrence yet. Stop coding.


From brute force to optimal — Fibonacci / climb stairs

def ways(n: int) -> int:
    if n < 0:
        return 0
    if n == 0:
        return 1
    return ways(n - 1) + ways(n - 2)
# Time: ~φ^n   Space: O(n) stack
def ways(n: int, memo: dict | None = None) -> int:
    if memo is None:
        memo = {0: 1, 1: 1}
    if n < 0:
        return 0
    if n not in memo:
        memo[n] = ways(n - 1, memo) + ways(n - 2, memo)
    return memo[n]
# Time: O(n)   Space: O(n)
def ways(n: int) -> int:
    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]
def ways(n: int) -> int:
    a = b = 1  # ways(i-2), ways(i-1)
    for _ in range(n):
        a, b = b, a + b
    return a
# Time: O(n)   Space: O(1)
func ways(n int) int {
    a, b := 1, 1
    for i := 0; i < n; i++ {
        a, b = b, a+b
    }
    return a
}

Observe: memoization and the table compute the same recurrence. Top-down is easier when the state space is sparse; bottom-up is easier to reason about complexity and to shrink space.


Pattern recognition

You hear… State is probably…
“number of ways” dp[i] = sum of ways to reach i
“minimum / maximum cost” dp[i] = min/max over choices
“can you reach / is it possible” boolean DP or BFS
“include or skip this item” 0/1 knapsack, dp[i][w]
“unbounded supply of coins/items” coin change, inner loop over amounts
“longest increasing / common” dp[i] ends at index i
grid, “only right/down” dp[r][c] from top/left

Interview Insight 🎯

Say the state out loud: “dp[i][j] is the minimum coins to make amount j using the first i coin types.” If the interviewer nods, write the loop. If they frown, the state is wrong — not the syntax.


0/1 Knapsack — include or skip

Problem: n items, weights w[i], values v[i], capacity W. Each item at most once. Max value.

Brute force: 2^n subsets.

Structure: after deciding item i, you have a smaller prefix and remaining capacity. Overlap: many prefixes share the same remaining W.

dp[i][c] = max value using items[0..i) with capacity c

dp[i][c] = dp[i-1][c]                          # skip
         = max(that, v[i-1] + dp[i-1][c-w[i-1]])  # take, if w fits

Worked example — items (w=1,v=1), (w=3,v=4), (w=4,v=5), capacity=5. Each row only reads the row above it:

             c:   0    1    2    3    4    5
  i=0 (none)      0    0    0    0    0    0
  i=1 (w1,v1)     0    1    1    1    1    1
  i=2 (w3,v4)     0    1    1    4    5    5
  i=3 (w4,v5)     0    1    1    4    5    6
                                          ↑
                     dp[3][5]=6: skip item2 (w3,v4), take item1+item3 (w1,v1)+(w4,v5)=6

dp[i][c] copies straight up from dp[i-1][c] when skipping, or reaches up and left by w[i-1] when taking — exactly the two terms in the recurrence above.

def knapsack(w: list[int], v: list[int], cap: int) -> int:
    n = len(w)
    dp = [[0] * (cap + 1) for _ in range(n + 1)]
    for i in range(1, n + 1):
        for c in range(cap + 1):
            dp[i][c] = dp[i - 1][c]
            if w[i - 1] <= c:
                take = v[i - 1] + dp[i - 1][c - w[i - 1]]
                if take > dp[i][c]:
                    dp[i][c] = take
    return dp[n][cap]  # O(n·cap) time and space

Space drop: one row of size cap+1, iterate c downward so c - w is still the previous item.

def knapsack_1d(w, v, cap):
    dp = [0] * (cap + 1)
    for wi, vi in zip(w, v):
        for c in range(cap, wi - 1, -1):
            dp[c] = max(dp[c], vi + dp[c - wi])
    return dp[cap]
func knapsack(w, v []int, cap int) int {
    dp := make([]int, cap+1)
    for i, wi := range w {
        for c := cap; c >= wi; c-- {
            if t := v[i] + dp[c-wi]; t > dp[c] {
                dp[c] = t
            }
        }
    }
    return dp[cap]
}

Production Trap ⚠️

O(n·W) is pseudo-polynomial. W = 10^9 is not “a loop.” If capacity is huge and n is tiny, meet-in-the-middle or branch-and-bound — not a 10^9 array.


Coin change — unbounded knapsack

Problem: coins C, amount A. Fewest coins to make A, unlimited of each. Impossible → -1.

Brute force: recursion on remaining amount — same remainder asked over and over.

Recurrence: dp[x] = min(dp[x], dp[x - coin] + 1) for each coin that fits.

Order: for unbounded, loop coins outer, amount inner ascending — you may reuse the same coin.

Interactive walkthrough

Coins [1, 3, 4], amount 6. Each cell is dp[amount]. ∞ = not yet makeable. Step until dp[6].

DP Walkthrough — Coin Change

Legend: index under cell = amount. Green = finite value. Orange = just relaxed. Blue = dp[0].

The visualizer may nest amount-outer (for each amount, try every coin). The unbounded-knapsack / coin-change code on this page is coins-outer, amount-inner. Loop order is what distinguishes 0/1 (capacity inner, descending so each item is used at most once) from unbounded (amount inner, ascending so a coin can be reused).

def coin_change(coins: list[int], amount: int) -> int:
    inf = amount + 1
    dp = [inf] * (amount + 1)
    dp[0] = 0
    for coin in coins:
        for x in range(coin, amount + 1):
            dp[x] = min(dp[x], dp[x - coin] + 1)
    return dp[amount] if dp[amount] < inf else -1
    # O(len(coins) · amount) time, O(amount) space
func coinChange(coins []int, amount int) int {
    inf := amount + 1
    dp := make([]int, amount+1)
    for i := range dp {
        dp[i] = inf
    }
    dp[0] = 0
    for _, c := range coins {
        for x := c; x <= amount; x++ {
            if dp[x-c]+1 < dp[x] {
                dp[x] = dp[x-c] + 1
            }
        }
    }
    if dp[amount] >= inf {
        return -1
    }
    return dp[amount]
}

Variant — number of combinations (order does not matter): same loop nest, dp[0]=1, dp[x] += dp[x-coin]. Swap loops (amount outer) and you count permutations instead — a classic interview trap.


Unique paths — grid DP

Problem: m × n grid, start top-left, finish bottom-right, only right or down. How many paths?

Brute force: DFS, C(m+n-2, m-1) leaves — but overlapping prefixes.

dp[r][c] = paths to (r,c) = dp[r-1][c] + dp[r][c-1]
dp[0][*] = dp[*][0] = 1
def unique_paths(m: int, n: int) -> int:
    dp = [1] * n
    for _ in range(1, m):
        for c in range(1, n):
            dp[c] += dp[c - 1]
    return dp[-1]  # O(m·n) time, O(n) space

Obstacles: set dp[r][c] = 0 on a blocked cell; first row/col stop propagating after a block.

This is the same shape as climb-stairs in 2D. Once you have seen both, you should smell DP on any “count ways on a DAG of states.”


LIS — dp[i] means “best ending here”

Problem: longest increasing subsequence (not subarray — not sliding window).

def lis_length(a: list[int]) -> int:
    n = len(a)
    dp = [1] * n  # LIS ending at i
    for i in range(n):
        for j in range(i):
            if a[j] < a[i]:
                dp[i] = max(dp[i], dp[j] + 1)
    return max(dp) if dp else 0
    # O(n²). O(n log n): patience sorting / tails binary search

Clue: “subsequence” + “longest” + no contiguous requirement → usually dp[i] or patience, not sliding window.


Edge cases

# Coin change
coin_change([2], 3)        # -1, not a crash on ∞
coin_change([1], 0)        # 0
coin_change([1, 2, 5], 11) # 3

# Knapsack
knapsack_1d([], [], 10)    # 0
knapsack_1d([5], [10], 3)  # 0, item does not fit

# Paths
unique_paths(1, 1)         # 1
# Obstacles on start or end → 0

Integer overflow: Go int is fine for typical LC; “number of ways” problems often need mod 1e9+7. Python ints are unbounded — still apply the modulus when the prompt says so.


Complexity cheat sheet

Problem Time Space after squeeze
Climb stairs / Fibonacci O(n) O(1)
0/1 knapsack O(nW) O(W)
Coin change (min coins) O(nA) O(A)
Unique paths O(mn) O(n)
LIS naive O(n²) O(n)

Interview follow-ups

  1. “Reconstruct the coins, not just the count.” Keep prev[x] = coin that last improved dp[x]; walk back from A.
  2. “What if coins can be negative?” Recurrence can cycle. DP on amounts is the wrong model.
  3. “Top-down vs bottom-up?” Sparse / early exit → memo. Tight loops, space squeeze, interview default → bottom-up.
  4. “Is this greedy?” Coin change is greedy only for canonical coin systems. [1,3,4] amount 6 is 3+3, not 4+1+1. If you cannot prove the greedy choice property, use DP.

Key takeaways

Remember

  1. Two properties: overlapping subproblems + optimal substructure. Missing either → not DP (or not this DP).
  2. Write the English state, then the recurrence, then the loop order (0/1 vs unbounded is the inner-loop direction).
  3. Brute force → memo → table → roll the array. Do not start at step 4 in an interview.
  4. “Contiguous” is sliding window; “subsequence / ways / min cost with reuse” is DP.
  5. Name the cell: if you cannot, you are guessing indices.