Dynamic programming

Dynamic programming

State, transition, and base cases in plain language — with house-robber and knapsack traces so DP stops feeling like magic.

DP in one paragraph

DP model
Name state → write transition → fill base.

DP is remembering answers to subproblems. You only need it when (1) the naive recursion repeats work and (2) subproblems overlap. Interview move: define dp[...] in words first (‘best money from houses i…n’), then the formula, then code.

Analogy: sticky-note homework

DP sticky notes
Write the answer once; reuse it.

DP is doing homework with sticky notes: when you solve a subproblem, you stick the answer on the fridge. Next time you need it, you read the note instead of redoing the algebra. If subproblems don’t overlap, sticky notes don’t help — that’s plain recursion / divide-and-conquer.

Worked example: house robber

Cannot rob adjacent houses. Let dp[i] = best from index i to end. Transition: max(rob i + dp[i+2], skip i → dp[i+1]). Base: empty = 0.

def rob(nums):
    # bottom-up rolling
    take = skip = 0
    for x in nums:
        take, skip = skip + x, max(skip, take)
    return max(take, skip)

# nums=[2,7,9,3,1]
# walk: after each house update take/skip — final max=12 (2+9+1)
int rob(int[] nums) {
    // bottom-up rolling
    int take = 0, skip = 0;
    for (int x : nums) {
        int newTake = skip + x;
        skip = Math.max(skip, take);
        take = newTake;
    }
    return Math.max(take, skip);
}

// nums=[2,7,9,3,1]
// walk: after each house update take/skip — final max=12 (2+9+1)

0/1 knapsack sketch

def knapsack(weights, values, W):
    dp = [0] * (W + 1)
    for w, v in zip(weights, values):
        for cap in range(W, w - 1, -1):  # backward so each item once
            dp[cap] = max(dp[cap], dp[cap - w] + v)
    return dp[W]
int knapsack(int[] weights, int[] values, int W) {
    int[] dp = new int[W + 1];
    for (int i = 0; i < weights.length; i++) {
        int w = weights[i], v = values[i];
        for (int cap = W; cap >= w; cap--) {  // backward so each item once
            dp[cap] = Math.max(dp[cap], dp[cap - w] + v);
        }
    }
    return dp[W];
}

What a good explanation sounds like

You: ‘State is the best we can do starting at house i. From i I either rob it and skip to i+2, or skip to i+1. I’ll compute bottom-up so I don’t blow the stack and I can roll two variables.’

When to reach for this pattern

Interview order: define state in words, write the transition, fix base cases, then code. Jumping to a 2D array without the sentence is how people get lost.

Core template + worked trace

House robber — rolling two variables after stating state:

def rob(nums: list[int]) -> int:
    # take = best ending by robbing current; skip = best ending by skipping
    take = skip = 0
    for x in nums:
        take, skip = skip + x, max(skip, take)
    return max(take, skip)

# nums=[2,7,9,3,1]
# x=2: take=2  skip=0
# x=7: take=0+7=7  skip=max(0,2)=2
# x=9: take=2+9=11 skip=max(2,7)=7
# x=3: take=7+3=10 skip=max(7,11)=11
# x=1: take=11+1=12 skip=max(11,10)=11 → answer 12
int rob(int[] nums) {
    // take = best ending by robbing current; skip = best ending by skipping
    int take = 0, skip = 0;
    for (int x : nums) {
        int newTake = skip + x;
        skip = Math.max(skip, take);
        take = newTake;
    }
    return Math.max(take, skip);
}

// nums=[2,7,9,3,1]
// x=2: take=2  skip=0
// x=7: take=0+7=7  skip=max(0,2)=2
// x=9: take=2+9=11 skip=max(2,7)=7
// x=3: take=7+3=10 skip=max(7,11)=11
// x=1: take=11+1=12 skip=max(11,10)=11 → answer 12

0/1 knapsack — backward capacity loop:

def knapsack(weights, values, W):
    dp = [0] * (W + 1)
    for w, v in zip(weights, values):
        for cap in range(W, w - 1, -1):  # backward ⇒ each item once
            dp[cap] = max(dp[cap], dp[cap - w] + v)
    return dp[W]

# weights=[2,3,4], values=[3,4,5], W=5
# after item2: can take 2→3 or 3→4; after item4: 2+3=5 → value 3+4=7
int knapsack(int[] weights, int[] values, int W) {
    int[] dp = new int[W + 1];
    for (int i = 0; i < weights.length; i++) {
        int w = weights[i], v = values[i];
        for (int cap = W; cap >= w; cap--) {  // backward ⇒ each item once
            dp[cap] = Math.max(dp[cap], dp[cap - w] + v);
        }
    }
    return dp[W];
}

// weights=[2,3,4], values=[3,4,5], W=5
// after item2: can take 2→3 or 3→4; after item4: 2+3=5 → value 3+4=7

Edge cases & common bugs

Complexity — say it aloud

Interview talk track

You: ‘State is the best we can do starting at house i. From i I either rob it and jump to i+2, or skip to i+1. I’ll compute bottom-up and roll two variables so space is O(1).’

Practice set

  • Climbing Stairs
  • House Robber / House Robber II
  • Coin Change
  • Longest Increasing Subsequence
  • Unique Paths
  • 0/1 Knapsack / Target Sum
  • Edit Distance
  • Longest Common Subsequence

Harder follow-up

Harder variant: Edit Distance — dp[i][j] = min ops to turn a[:i] into b[:j]. Transition: delete / insert / replace. Trace a 3×3 corner on the board before filling the table.

Pattern bank: more questions + efficient solutions

Five DP staples. Say state → transition → base out loud before writing loops.

Q: Climbing Stairs: you can climb 1 or 2 steps. How many distinct ways to reach the top of n stairs? (Fibonacci-style recurrence.)

# Climbing Stairs — O(n) time, O(1) space
def climb_stairs(n: int) -> int:
    if n <= 2:
        return n
    a, b = 1, 2
    for _ in range(3, n + 1):
        a, b = b, a + b
    return b
// Climbing Stairs — O(n) time, O(1) space
int climbStairs(int n) {
    if (n <= 2) return n;
    int a = 1, b = 2;
    for (int i = 3; i <= n; i++) {
        int c = a + b;
        a = b;
        b = c;
    }
    return b;
}

Q: Coin Change (unbounded): given coin denominations and amount, return the fewest coins needed to make that amount, or −1 if impossible.

# Coin Change unbounded — O(amount * coins)
def coin_change(coins, amount: int) -> int:
    INF = amount + 1
    dp = [INF] * (amount + 1)
    dp[0] = 0
    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] if dp[amount] < INF else -1
// Coin Change unbounded — O(amount * coins)
int coinChange(int[] coins, int amount) {
    int INF = amount + 1;
    int[] dp = new int[amount + 1];
    Arrays.fill(dp, INF);
    dp[0] = 0;
    for (int x = 1; x <= amount; x++)
        for (int c : coins)
            if (c <= x) dp[x] = Math.min(dp[x], dp[x - c] + 1);
    return dp[amount] >= INF ? -1 : dp[amount];
}

Q: Longest Increasing Subsequence: length of the longest strictly increasing subsequence in an integer array.

# LIS — clear O(n^2); note: O(n log n) via patience sorting / bisect tails
def length_of_lis(nums) -> int:
    n = len(nums)
    if n == 0:
        return 0
    dp = [1] * n
    best = 1
    for i in range(n):
        for j in range(i):
            if nums[j] < nums[i]:
                dp[i] = max(dp[i], dp[j] + 1)
        best = max(best, dp[i])
    return best
# O(n log n) sketch: maintain tails[len] = smallest tail of all LIS of that length; bisect_left
// LIS — O(n^2); O(n log n) via patience sorting for follow-up
int lengthOfLIS(int[] nums) {
    int n = nums.length, best = 1;
    int[] dp = new int[n];
    Arrays.fill(dp, 1);
    for (int i = 0; i < n; i++) {
        for (int j = 0; j < i; j++)
            if (nums[j] < nums[i]) dp[i] = Math.max(dp[i], dp[j] + 1);
        best = Math.max(best, dp[i]);
    }
    return n == 0 ? 0 : best;
}

Q: Edit Distance: minimum operations (insert, delete, replace) to convert word1 into word2.

# Edit Distance — O(mn)
def min_distance(word1: str, word2: str) -> int:
    m, n = len(word1), len(word2)
    dp = [[0] * (n + 1) for _ in range(m + 1)]
    for i in range(m + 1):
        dp[i][0] = i
    for j in range(n + 1):
        dp[0][j] = j
    for i in range(1, m + 1):
        for j in range(1, n + 1):
            if word1[i - 1] == word2[j - 1]:
                dp[i][j] = dp[i - 1][j - 1]
            else:
                dp[i][j] = 1 + min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1])
    return dp[m][n]
// Edit Distance — O(mn)
int minDistance(String word1, String word2) {
    int m = word1.length(), n = word2.length();
    int[][] dp = new int[m + 1][n + 1];
    for (int i = 0; i <= m; i++) dp[i][0] = i;
    for (int j = 0; j <= n; j++) dp[0][j] = j;
    for (int i = 1; i <= m; i++)
        for (int j = 1; j <= n; j++)
            if (word1.charAt(i - 1) == word2.charAt(j - 1))
                dp[i][j] = dp[i - 1][j - 1];
            else
                dp[i][j] = 1 + Math.min(dp[i - 1][j],
                    Math.min(dp[i][j - 1], dp[i - 1][j - 1]));
    return dp[m][n];
}

Q: House Robber II: houses in a circle (first and last adjacent). Return max money without robbing two adjacent houses. (Alternate: Unique Paths on an m×n grid — included below as comment twin.)

# House Robber II — O(n) time, O(1) space
def rob(nums) -> int:
    if not nums:
        return 0
    if len(nums) == 1:
        return nums[0]
    def rob_linear(lo, hi):
        prev2 = prev1 = 0
        for i in range(lo, hi + 1):
            prev2, prev1 = prev1, max(prev1, prev2 + nums[i])
        return prev1
    n = len(nums)
    return max(rob_linear(0, n - 2), rob_linear(1, n - 1))
# Unique Paths twin: dp[j] += dp[j-1] across rows — O(mn) time, O(n) space
// House Robber II — O(n) time, O(1) space
int rob(int[] nums) {
    int n = nums.length;
    if (n == 1) return nums[0];
    return Math.max(robLinear(nums, 0, n - 2), robLinear(nums, 1, n - 1));
}
int robLinear(int[] nums, int lo, int hi) {
    int prev2 = 0, prev1 = 0;
    for (int i = lo; i <= hi; i++) {
        int cur = Math.max(prev1, prev2 + nums[i]);
        prev2 = prev1;
        prev1 = cur;
    }
    return prev1;
}

45-minute pattern drill

← Lattice