Chapter 27

Dynamic Programming I: 1D and Knapsack

Chapter 27: Dynamic Programming I โ€” 1D and Knapsack

Dynamic programming (DP) is the most frequently tested algorithmic paradigm in interviews, bar none. Its core idea fits in one sentence: break a big problem into overlapping subproblems, solve each subproblem once, and store the result for reuse.

If DP feels hard, it's probably because you're trying to "think up the transition equation" from scratch. The right learning path is: write brute-force recursion โ†’ spot repeated computation โ†’ add memoization โ†’ rewrite as bottom-up iteration. Once you've walked these four steps, DP loses its mystery.


Level 1 ยท What You Need to Know

27.1 From Brute Force to Dynamic Programming

Classic starter: Climbing Stairs (LeetCode #70)

You're climbing stairs. Each step you can climb 1 or 2 steps. How many distinct ways to reach step n?

Step 1: Brute-force recursion

def climb_stairs_brute(n):
    if n <= 1:
        return 1
    return climb_stairs_brute(n - 1) + climb_stairs_brute(n - 2)

Identical to Fibonacci. Time O(2โฟ) โ€” at n = 45 it takes seconds.

Step 2: Spot repeated computation

Draw the recursion tree and you'll see climb(3) computed many times:

                climb(5)
               /        \
          climb(4)      climb(3)
          /     \        /    \
     climb(3) climb(2) climb(2) climb(1)
      /   \
  climb(2) climb(1)

Step 3: Add memoization (Top-Down)

def climb_stairs_memo(n, memo={}):
    if n <= 1:
        return 1
    if n in memo:
        return memo[n]
    memo[n] = climb_stairs_memo(n - 1) + climb_stairs_memo(n - 2)
    return memo[n]

Cache previously computed results. Time O(n), Space O(n).

Step 4: Bottom-Up

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

Step 5: Space optimization

Since dp[i] only depends on dp[i-1] and dp[i-2], we don't need the full array:

def climb_stairs_optimized(n):
    if n <= 1:
        return 1
    a, b = 1, 1
    for _ in range(2, n + 1):
        a, b = b, a + b
    return b

Time O(n), Space O(1). From O(2โฟ) to O(n) โ€” that's the power of dynamic programming.

27.2 The Three Core Elements of DP

Every DP problem requires defining three things:

Element Meaning Climbing stairs example
State What dp[i] represents dp[i] = number of ways to reach step i
Transition How dp[i] is computed from earlier states dp[i] = dp[i-1] + dp[i-2]
Base case Answers for the smallest subproblems dp[0] = 1, dp[1] = 1

State definition is the hardest part. If you get the state right, the transition equation usually follows naturally.

27.3 Classic 1D DP Problems

House Robber (LeetCode #198)

Houses along a street each contain some money. You can't rob two adjacent houses. Find maximum profit.

def rob(nums):
    if not nums:
        return 0
    if len(nums) == 1:
        return nums[0]
    dp = [0] * len(nums)
    dp[0] = nums[0]
    dp[1] = max(nums[0], nums[1])
    for i in range(2, len(nums)):
        dp[i] = max(dp[i-1], dp[i-2] + nums[i])
    return dp[-1]

# Space-optimized
def rob_optimized(nums):
    if not nums:
        return 0
    prev2, prev1 = 0, 0
    for num in nums:
        prev2, prev1 = prev1, max(prev1, prev2 + num)
    return prev1

Coin Change (LeetCode #322)

Given coin denominations and a total amount, find the minimum number of coins needed.

def coin_change(coins, amount):
    dp = [float('inf')] * (amount + 1)
    dp[0] = 0
    for i in range(1, amount + 1):
        for coin in coins:
            if coin <= i and dp[i - coin] + 1 < dp[i]:
                dp[i] = dp[i - coin] + 1
    return dp[amount] if dp[amount] != float('inf') else -1

State: dp[i] = minimum coins to make amount i Transition: dp[i] = min(dp[i - coin] + 1) over all coins Base: dp[0] = 0

Longest Increasing Subsequence (LeetCode #300)

def length_of_lis(nums):
    if not nums:
        return 0
    n = len(nums)
    dp = [1] * n
    for i in range(1, n):
        for j in range(i):
            if nums[j] < nums[i]:
                dp[i] = max(dp[i], dp[j] + 1)
    return max(dp)

Time O(nยฒ). Can be optimized to O(n log n) with binary search โ€” see Chapter 22.

27.4 When Can DP Be Applied?

DP works when two conditions hold:

  1. Optimal substructure: The optimal solution to the problem contains optimal solutions to subproblems. You can construct the overall optimum from subproblem optima.

  2. Overlapping subproblems: Different recursive paths recompute the same subproblems. If subproblems don't overlap (each solved only once), it's divide-and-conquer, not DP.

Counterexample: Longest simple path in an unweighted graph. This problem lacks optimal substructure because the longest path may need to "detour," and sub-paths aren't necessarily optimal for their subproblems. This problem is NP-hard.

27.5 Memoization vs. Bottom-Up

Feature Memoization (Top-Down) Bottom-Up
Implementation Recursion + cache Loops + array
Computes all subproblems? No (only what's needed) Yes (all of them)
Stack overflow risk Yes (recursion depth) No
Space optimization Harder Easy (rolling array)
Debugging Recursive stack hard to trace Print array
Interview recommendation Quick correct solution When optimizing time/space

Interview strategy: Start with memoization (fast, less error-prone). If the interviewer asks for optimization, convert to bottom-up + space optimization.


Level 2 ยท How It Works

27.6 0-1 Knapsack

Classic problem: n items, each with weight w[i] and value v[i]. Knapsack capacity W. Each item can be chosen at most once. Maximize total value.

def knapsack_01(weights, values, W):
    n = len(weights)
    dp = [[0] * (W + 1) for _ in range(n + 1)]
    for i in range(1, n + 1):
        w, v = weights[i-1], values[i-1]
        for j in range(W + 1):
            dp[i][j] = dp[i-1][j]  # don't take item i
            if j >= w:
                dp[i][j] = max(dp[i][j], dp[i-1][j-w] + v)  # take item i
    return dp[n][W]

Space optimization to 1D:

The transition only uses the previous row dp[i-1][...]. We can use a single array, but must iterate right to left to avoid using values already overwritten in the current round:

def knapsack_01_optimized(weights, values, W):
    dp = [0] * (W + 1)
    for i in range(len(weights)):
        w, v = weights[i], values[i]
        for j in range(W, w - 1, -1):  # right to left!
            dp[j] = max(dp[j], dp[j-w] + v)
    return dp[W]

Why right to left? If we go left to right, dp[j-w] may have already been updated in this round (effectively allowing item i to be selected multiple times). Right to left ensures dp[j-w] is still the previous round's value โ€” each item selected at most once.

27.7 Complete Knapsack

Difference from 0-1: each item can be selected unlimited times.

def knapsack_complete(weights, values, W):
    dp = [0] * (W + 1)
    for i in range(len(weights)):
        w, v = weights[i], values[i]
        for j in range(w, W + 1):  # left to right!
            dp[j] = max(dp[j], dp[j-w] + v)
    return dp[W]

The only difference: inner loop goes left to right. We want to use the current round's updated dp[j-w] (allowing item i to be selected multiple times).

Coin change is a complete knapsack: each coin can be used unlimited times, minimize coins to reach the target amount.

Knapsack type Each item Inner loop direction
0-1 At most once Right to left
Complete Unlimited times Left to right

27.8 Multi-Knapsack

Each item can be selected at most c[i] times.

Naive approach: Expand each item into c[i] independent items, then run 0-1 knapsack. Time O(n ร— W ร— max(c)).

Binary optimization: Split c[i] items into groups of 1, 2, 4, 8, ..., remainder. For example, c = 13 becomes 1 + 2 + 4 + 6 = 13. Now 13 items become 4 groups processed as 0-1 knapsack. Time O(n ร— W ร— log(max(c))).

def knapsack_multi(weights, values, counts, W):
    new_w, new_v = [], []
    for i in range(len(weights)):
        c = counts[i]
        k = 1
        while k <= c:
            new_w.append(weights[i] * k)
            new_v.append(values[i] * k)
            c -= k
            k *= 2
        if c > 0:
            new_w.append(weights[i] * c)
            new_v.append(values[i] * c)
    return knapsack_01_optimized(new_w, new_v, W)

27.9 Knapsack Variants

Counting solutions: Instead of maximum value, count "how many ways to exactly fill the knapsack."

# Target Sum (LeetCode #494)
# Given nums and target, put + or - before each number
# Count ways to achieve target sum
def find_target_sum_ways(nums, target):
    total = sum(nums)
    if (target + total) % 2 != 0 or abs(target) > total:
        return 0
    bag = (target + total) // 2

    dp = [0] * (bag + 1)
    dp[0] = 1  # empty set sums to 0: 1 way
    for num in nums:
        for j in range(bag, num - 1, -1):  # 0-1 knapsack
            dp[j] += dp[j - num]
    return dp[bag]

Exactly fill vs. at most fill:


Level 3 ยท What the Spec Says

27.10 History of Dynamic Programming

The name "Dynamic Programming" was coined by Richard Bellman in 1953 while working at the RAND Corporation. Bellman later explained his choice:

"I chose the name 'dynamic programming' to hide the mathematical nature of my work... The word 'programming' was used in the sense of 'planning' (like military programming), and 'dynamic' was chosen because it sounded impressive and had no precise meaning that could be used against me."

His boss at the time was Secretary of Defense Charles Erwin Wilson, who "had a pathological fear and hatred of the word 'research'." So Bellman used an impressive-sounding but vague name to protect his funding.

Bellman's core contribution is the Bellman equation (Principle of Optimality):

"An optimal policy has the property that whatever the initial state and initial decision are, the remaining decisions must constitute an optimal policy with regard to the state resulting from the first decision."

Translation: any sub-policy of an optimal policy must itself be optimal. This is the formal statement of "optimal substructure."

27.11 DP vs. Greedy

Greedy (Chapter 26) can be seen as a special case of DP: when the locally optimal choice at each step happens to be globally optimal, you don't need to consider all subproblems โ€” just be greedy.

Feature DP Greedy
Consider all choices Yes No (only local optimum)
Correctness proof needed No (exhaustive) Yes (exchange argument / proof by contradiction)
Time complexity Usually higher Usually lower
Applicability Broad (optimal substructure + overlapping subproblems) Narrow (needs greedy-choice property)

Example: Coin change with denominations [1, 5, 10, 25] (US coins) โ€” greedy (largest first) is correct. But with [1, 3, 4], making 6: greedy picks 4+1+1 = 3 coins, DP picks 3+3 = 2 coins. Greedy fails.

27.12 Formal Definition of Optimal Substructure

Let S* be the optimal solution to problem P. If S* decomposes into solutions Sโ‚*, Sโ‚‚*, ..., Sโ‚–* for subproblems Pโ‚, Pโ‚‚, ..., Pโ‚–, and each Sแตข* is optimal for Pแตข, then P has optimal substructure.

Proof technique (Cut-and-Paste): Proof by contradiction. Assume some subproblem solution Sแตข* is not optimal โ€” then a better Sแตข' exists. Replacing Sแตข* with Sแตข' would improve the overall solution, contradicting S*'s optimality.

This proof method comes from CLRS (Cormen, Leiserson, Rivest, Stein), Introduction to Algorithms, Chapter 15.

27.13 State Count and Computational Cost

DP time complexity = number of states ร— transition cost per state.

Problem States Transition cost Total
Climbing stairs O(n) O(1) O(n)
0-1 knapsack O(nW) O(1) O(nW)
LIS O(n) O(n) O(nยฒ)
Edit distance O(nm) O(1) O(nm)
Matrix chain multiplication O(nยฒ) O(n) O(nยณ)

Note that 0-1 knapsack's O(nW) is pseudo-polynomial: W is a numeric value, not input length. If W is represented in b bits, then W = 2^b, and time O(n ร— 2^b) is exponential. This is why the knapsack problem is NP-hard (see Chapter 37 on complexity theory).


Level 4 ยท Edge Cases and Pitfalls

27.14 Common State Definition Errors

Error 1: Not enough state dimensions

# Best Time to Buy and Sell Stock (LeetCode #121) โ€” wrong state
# dp[i] = max profit on day i?
# Problem: don't know whether we're holding stock

# Correct: dp[i][0] = max profit on day i, not holding
#          dp[i][1] = max profit on day i, holding
def max_profit(prices):
    n = len(prices)
    if n < 2:
        return 0
    dp = [[0, 0] for _ in range(n)]
    dp[0][0] = 0
    dp[0][1] = -prices[0]
    for i in range(1, n):
        dp[i][0] = max(dp[i-1][0], dp[i-1][1] + prices[i])  # sell
        dp[i][1] = max(dp[i-1][1], -prices[i])                # buy (only once)
    return dp[n-1][0]

Error 2: Wrong iteration direction

0-1 knapsack inner loop goes right to left; complete knapsack goes left to right. Swap them and you solve a different problem.

Error 3: Missing base cases

# Decode Ways (LeetCode #91)
# "12" can be decoded as "AB" or "L"
def num_decodings(s):
    if not s or s[0] == '0':
        return 0
    n = len(s)
    dp = [0] * (n + 1)
    dp[0] = 1  # empty string has 1 decoding (easy to forget!)
    dp[1] = 1
    for i in range(2, n + 1):
        one = int(s[i-1])
        two = int(s[i-2:i])
        if 1 <= one <= 9:
            dp[i] += dp[i-1]
        if 10 <= two <= 26:
            dp[i] += dp[i-2]
    return dp[n]

27.15 Space Optimization Techniques

Rolling variables: When dp[i] depends only on dp[i-1] and dp[i-2], use two variables instead of an array.

# General template
prev2, prev1 = initial_0, initial_1
for i in range(2, n + 1):
    curr = f(prev1, prev2)
    prev2, prev1 = prev1, curr

2D to 1D: When dp[i][j] depends only on dp[i-1][...], drop the first dimension. Watch iteration direction.

27.16 Interview Problems

Problem 1: Minimum Path Sum (LeetCode #64)

def min_path_sum(grid):
    m, n = len(grid), len(grid[0])
    dp = [float('inf')] * n
    dp[0] = 0
    for i in range(m):
        dp[0] = dp[0] + grid[i][0]
        for j in range(1, n):
            dp[j] = min(dp[j], dp[j-1]) + grid[i][j]
    return dp[n-1]

Problem 2: Word Break (LeetCode #139)

def word_break(s, word_dict):
    words = set(word_dict)
    n = len(s)
    dp = [False] * (n + 1)
    dp[0] = True
    for i in range(1, n + 1):
        for j in range(i):
            if dp[j] and s[j:i] in words:
                dp[i] = True
                break
    return dp[n]

Problem 3: Partition Equal Subset Sum (LeetCode #416)

def can_partition(nums):
    total = sum(nums)
    if total % 2:
        return False
    target = total // 2
    dp = [False] * (target + 1)
    dp[0] = True
    for num in nums:
        for j in range(target, num - 1, -1):  # 0-1 knapsack: right to left
            dp[j] = dp[j] or dp[j - num]
    return dp[target]

This perfectly demonstrates problem transformation: convert "can we split into two equal-sum subsets" into "can we select numbers summing to exactly total/2" โ€” a 0-1 knapsack exact-fill problem.

27.17 Universal DP Problem-Solving Framework

When facing a new DP problem, think through these steps:

  1. Define state: What does dp[i] or dp[i][j] represent? Usually "ending at i" or "considering first i elements"
  2. Find transition: How is dp[i] derived from earlier states? What choices exist (take/skip, come from where)?
  3. Set base cases: What are the answers for the smallest subproblems?
  4. Determine iteration order: Ensure all dependencies are computed before dp[i]
  5. Determine return value: dp[n], max(dp), or dp[n][m]?
  6. Optimize space (optional): Can we reduce dimensions?

In interviews, manually trace a small example to verify the transition equation's correctness โ€” far more reliable than staring at code.


Chapter Summary

Concept Key Point
DP core Overlapping subproblems + optimal substructure โ†’ store and reuse
Learning path Brute force โ†’ spot repetition โ†’ memoize โ†’ bottom-up โ†’ optimize space
Three elements State definition, transition equation, base cases
0-1 knapsack Each item once, inner loop right to left
Complete knapsack Each item unlimited, inner loop left to right
Multi-knapsack Binary split + 0-1 knapsack
Pseudo-polynomial O(nW) looks polynomial, but W is a value not input length
Interview strategy Memoize first for correctness, then optimize to bottom-up

Exercises:

  1. Coin Change II (LeetCode #518): Given coin denominations and total amount, count distinct combinations. (Hint: complete knapsack + counting)
  2. Integer Break (LeetCode #343): Split positive integer n into at least two positive integers. Maximize their product.
  3. Longest Palindromic Substring (LeetCode #5): Solve with DP and compare to Manacher's algorithm.
  4. Modify the 0-1 knapsack to output which items were selected (not just the maximum value).

Next chapter: Chapter 28 enters two-dimensional DP โ€” edit distance, longest common subsequence, and interval DP. State definitions expand from 1D to 2D, transitions are more complex, but the core idea is exactly the same.

Rate this chapter
4.8  / 5  (4 ratings)

๐Ÿ’ฌ Comments