Part IV · Search, Selection and Optimisation Pattern 12 4 problems

Dynamic Programming

Write the brute-force recursion. Notice it solves the same subproblem over and over. Cache it. That is the whole method, and it works every single time.

DP has a reputation for requiring a flash of insight. It does not. It requires a procedure, applied patiently: define the state, write the recurrence, fix the base cases, choose an evaluation order, and read off the answer. The procedure below turns most DP questions into bookkeeping.

Contents

  1. When to use
  2. Core idea
  3. The five questions
  4. The templates
  5. Common mistakes
  6. Climbing Stairs
  7. Coin Change
  8. Longest Increasing Subsequence
  9. 0/1 Knapsack
  10. Recap

When to use

The trigger. Two properties together. Optimal substructure: the best answer for the whole is built from best answers for parts. Overlapping subproblems: the same part gets asked about many times. Without the second, it is plain recursion or greedy, not DP.
SignalWhat it looks like
Counting“how many ways”, “number of distinct paths”, “number of decodings”.
Optimising“minimum cost”, “maximum profit”, “fewest coins”, “longest subsequence”.
Feasibility“can the array be partitioned into…”, “is this string breakable into dictionary words”.
Sequences and gridsTwo strings compared, a grid traversed, a line of items with take-or-skip choices.
Exponential brute forceYou can see the 2ⁿ recursion immediately, and n is 1000. That gap is the question.

DP or greedy?

Greedy takes the locally best option and never reconsiders. It is correct only when an exchange argument proves the local choice is safe, as in interval scheduling. DP tries every option and keeps the best. The classic tell: Coin Change with coins [1, 3, 4] and amount 6. Greedy takes 4 + 1 + 1, three coins. DP finds 3 + 3, two coins. If you cannot prove the greedy exchange, use DP.

Core idea

Start from the honest brute-force recursion. Draw two levels of its call tree and look for a repeated call. If solve(k) appears in several branches, the exponential cost is pure repetition. Store each result the first time and reuse it. The running time collapses from “number of paths through the tree” to “number of distinct states”.
naive: 2ⁿ calls f5 f4 f3 f3 f2 f2 f1 f2 f1 f3 twice, f2 three times memoised: n states f5 f4 f3 f2 each state computed once, then read from the cache
Figure 12.1 — Same recursion, same answer. Caching turns the tree into a chain.

Top-down or bottom-up?

Top-down (memoisation)Bottom-up (tabulation)
HowRecursion plus a cacheA loop filling a table
Write it whenYou already have the recursion. Fastest path from brute force.The order is obvious, or you want to compress the space.
VisitsOnly the states actually reachableEvery state in the table
RiskRecursion depthGetting the fill order wrong
SpaceCache plus call stackTable, often reducible to one or two rows
The interview move. Write the brute-force recursion, add @cache, and you have a correct memoised solution in one line of extra code. Then convert to bottom-up and compress the space. Narrating that progression, brute force to memoised to tabulated to space-optimised, is worth more than jumping straight to the final answer.

The five questions

Answer these in order, out loud, before writing code. They are the whole method.

#QuestionExample, for Coin Change
1State. What does one subproblem look like, and what does its answer mean?best[v] = fewest coins summing to exactly v.
2Recurrence. How does one state depend on smaller ones?best[v] = 1 + min(best[v - c]) over every coin c ≤ v.
3Base case. Which states are known outright?best[0] = 0.
4Order. In what sequence can the table be filled so dependencies come first?Increasing v, from 1 to amount.
5Answer. Which cell holds the result?best[amount], unless it is still the sentinel.
If you cannot state the recurrence in one English sentence, the state is wrong. Nine times out of ten a stuck DP is a state that carries too little information. Longest Increasing Subsequence is the canonical example: “the LIS of the first i elements” does not compose, but “the LIS ending at i” does. Redefine the state before you fight the recurrence.

The templates

Template A — memoise the brute force
from functools import cache


def solve(n: int) -> int:
    """Brute force plus one decorator."""

    @cache
    def best(state: int) -> int:
        if is_base(state):
            return base_value(state)

        return combine(best(smaller) for smaller in transitions(state))

    return best(n)

functools.cache needs hashable arguments, so pass tuples rather than lists. Defining the helper inside the outer function keeps the cache per call, which avoids results leaking between different inputs.

Template B — tabulate, then compress
def solve_table(n: int) -> int:
    """Fill a table in dependency order."""
    table = [initial] * (n + 1)
    table[0] = base_value

    for i in range(1, n + 1):
        for choice in choices(i):
            table[i] = better(table[i], table[i - choice] + cost(choice))

    return table[n]
Template C — rolling rows for a 2D table
def solve_2d(rows: int, cols: int) -> int:
    """When row i depends only on row i - 1, keep two rows, not n."""
    previous = [0] * (cols + 1)

    for i in range(1, rows + 1):
        current = [0] * (cols + 1)
        for j in range(1, cols + 1):
            current[j] = combine(previous[j], current[j - 1], previous[j - 1])
        previous = current

    return previous[cols]

This drops space from O(rows × cols) to O(cols). Offer it after the 2D version works; it is the standard follow-up on every grid and string DP.

Common mistakes

The problems

1. Climbing Stairs Easy

Problem

You climb a staircase of n steps, taking either 1 or 2 steps at a time. How many distinct ways are there to reach the top?

The five questions

  1. State: ways[i] is the number of distinct ways to reach step i.
  2. Recurrence: the last move was either a 1 from step i - 1 or a 2 from step i - 2, and those two sets of routes are disjoint, so ways[i] = ways[i - 1] + ways[i - 2].
  3. Base: ways[1] = 1, ways[2] = 2.
  4. Order: increasing i.
  5. Answer: ways[n].

That is the Fibonacci sequence, offset by one. Saying so immediately is a good start.

Solution

def climb_stairs(n: int) -> int:
    """Number of distinct ways to climb n steps taking 1 or 2 at a time.

    Args:
        n: Number of steps, n >= 1.

    Returns:
        The count of distinct climbs.

    Example:
        >>> climb_stairs(5)
        8
    """
    if n <= 2:
        return n

    # Only the last two values are ever needed, so keep two variables
    # instead of an array. O(1) space.
    two_back, one_back = 1, 2

    for _ in range(3, n + 1):
        two_back, one_back = one_back, two_back + one_back

    return one_back

The progression to show

from functools import cache


def climb_stairs_memo(n: int) -> int:
    """Step 1: the brute force, made linear by one decorator."""

    @cache
    def ways(step: int) -> int:
        if step <= 2:
            return step
        return ways(step - 1) + ways(step - 2)

    return ways(n)


def climb_stairs_table(n: int) -> int:
    """Step 2: same thing bottom-up, O(n) space."""
    if n <= 2:
        return n

    ways = [0] * (n + 1)
    ways[1], ways[2] = 1, 2

    for i in range(3, n + 1):
        ways[i] = ways[i - 1] + ways[i - 2]

    return ways[n]

Brute force to memoised to tabulated to two variables. Walking an interviewer through that chain in ninety seconds demonstrates the method, which is what the question is for.

TimeO(n)SpaceO(1)

Variations you should expect

VariationChange
Steps of 1, 2 or 3Sum the three previous values.
Min Cost Climbing Stairsmin of the two predecessors plus the cost of the step.
Some steps are brokenSet those states to 0 before the loop reaches them.
House RobberSame two-variable shape: max(skip, take + two_back).

Edge cases to raise

Say this out loud: “The last move was either a one or a two, and those two sets of routes are disjoint, so the counts add. That is Fibonacci, and only the last two values matter.”

2. Coin Change Medium

Problem

Given coin denominations and a target amount, return the fewest coins that sum to exactly that amount, or -1 if it is impossible. You have unlimited coins of each denomination.

Why greedy fails

With coins [1, 3, 4] and amount 6, greedy takes the largest coin first: 4 + 1 + 1, three coins. The optimum is 3 + 3, two coins. Greedy happens to be correct for real-world currencies, which are designed to make it so, but not in general. Lead with this counterexample; it justifies the whole solution.

The five questions

  1. State: best[v] is the fewest coins summing to exactly v.
  2. Recurrence: the last coin used was some c ≤ v, so best[v] = 1 + min(best[v - c]) over all such c.
  3. Base: best[0] = 0. Zero coins make zero.
  4. Order: increasing v, so best[v - c] is always ready.
  5. Answer: best[amount], or -1 if it is still the sentinel.

Solution

def coin_change(coins: list[int], amount: int) -> int:
    """Fewest coins summing to exactly amount, or -1 if impossible.

    Args:
        coins: Distinct positive denominations, unlimited supply of each.
        amount: Target sum, amount >= 0.

    Returns:
        The minimum number of coins, or -1.

    Example:
        >>> coin_change([1, 3, 4], 6)
        2
    """
    # Any real answer uses at most `amount` coins (all of value 1), so
    # amount + 1 is unreachable and makes a clean finite sentinel.
    unreachable = amount + 1
    best = [unreachable] * (amount + 1)
    best[0] = 0

    for value in range(1, amount + 1):
        for coin in coins:
            if coin <= value:
                best[value] = min(best[value], best[value - coin] + 1)

    return -1 if best[amount] == unreachable else best[amount]

Walkthrough

coins = [1, 3, 4], amount = 6:

vcandidatesbest[v]
1best[0]+1 = 11
2best[1]+1 = 22
3best[2]+1 = 3, best[0]+1 = 11
4best[3]+1 = 2, best[1]+1 = 2, best[0]+1 = 11
5best[4]+1 = 2, best[2]+1 = 3, best[1]+1 = 22
6best[5]+1 = 3, best[3]+1 = 2, best[2]+1 = 32

The winner at v = 6 comes through the 3-coin, which is exactly the case greedy misses.

TimeO(amount × coins)SpaceO(amount)

The sibling: counting combinations

def coin_change_ways(coins: list[int], amount: int) -> int:
    """Coin Change II: how many distinct combinations sum to amount."""
    ways = [0] * (amount + 1)
    ways[0] = 1                      # one way to make zero: take nothing

    # Coins on the OUTSIDE loop. This fixes an order on the coins, so
    # 1+3 and 3+1 are counted once. Swapping the loops counts permutations.
    for coin in coins:
        for value in range(coin, amount + 1):
            ways[value] += ways[value - coin]

    return ways[amount]
The loop order carries meaning. Coins outside, amounts inside, counts combinations, because each coin is considered once and for all. Amounts outside, coins inside, counts permutations, because every ordering is reachable. Two loops in either order, two different problems. Being able to say which is which, and why, is a genuine DP-fluency signal.

Edge cases to raise

Say this out loud: “Greedy fails on coins one, three, four with amount six, so I need DP. The state is the fewest coins for each amount, and I try every coin as the last one used.”

3. Longest Increasing Subsequence Medium

Problem

Return the length of the longest strictly increasing subsequence of an array. A subsequence may skip elements but must keep the original order.

The state that does not work, and the one that does

The natural first attempt, best[i] is the LIS of the first i elements”, does not compose: knowing that length tells you nothing about whether the next element may be appended, because you do not know what the subsequence ended on. Re-anchor the state: best[i] is the length of the longest increasing subsequence that ends at index i. Now the element is pinned, comparisons are possible, and the recurrence writes itself. This re-anchoring move solves a large family of sequence DPs.

The five questions

  1. State: best[i] = LIS length ending exactly at i.
  2. Recurrence: best[i] = 1 + max(best[j]) over all j < i with nums[j] < nums[i].
  3. Base: best[i] = 1. Every element alone is a subsequence of length one.
  4. Order: increasing i.
  5. Answer: max(best), not best[-1], because the best subsequence need not end at the last element.

Solution: O(n²)

def length_of_lis(nums: list[int]) -> int:
    """Length of the longest strictly increasing subsequence.

    Args:
        nums: Integers, in any order.

    Returns:
        The length of the longest strictly increasing subsequence.

    Example:
        >>> length_of_lis([10, 9, 2, 5, 3, 7, 101, 18])
        4
    """
    if not nums:
        return 0

    # best[i] is the LIS length ending exactly at index i.
    best = [1] * len(nums)

    for i in range(1, len(nums)):
        for j in range(i):
            if nums[j] < nums[i]:
                best[i] = max(best[i], best[j] + 1)

    # The optimum can end anywhere, so take the maximum over all endings.
    return max(best)

Solution: O(n log n), patience sorting

import bisect


def length_of_lis_fast(nums: list[int]) -> int:
    """Same answer in O(n log n) by keeping the best tail for each length.

    tails[k] is the smallest possible final value of any increasing
    subsequence of length k + 1 seen so far. It is sorted by construction,
    so each element can be placed with a binary search.
    """
    tails: list[int] = []

    for value in nums:
        index = bisect.bisect_left(tails, value)

        if index == len(tails):
            tails.append(value)      # extends the longest run found so far
        else:
            tails[index] = value     # a smaller tail for that length: strictly better

    return len(tails)
Why the fast version is correct. A smaller final value for a subsequence of a given length is never worse, because it can be extended by strictly more future values. So for each length it is enough to remember the smallest achievable tail. Those tails are strictly increasing in length, which makes the array sorted and the placement a binary search. The tails array is not itself an increasing subsequence, and its length is the answer. Say that last part unprompted; it is the detail that shows you understand rather than remember.

Walkthrough of the fast version

nums = [10, 9, 2, 5, 3, 7, 101, 18]:

valueactiontails
10append[10]
9replace index 0[9]
2replace index 0[2]
5append[2, 5]
3replace index 1[2, 3]
7append[2, 3, 7]
101append[2, 3, 7, 101]
18replace index 3[2, 3, 7, 18]

Length 4. Notice [2, 3, 7, 18] happens to be a real subsequence here, but that is a coincidence, not a guarantee.

DPO(n²) time, O(n) spacePatienceO(n log n) time, O(n) space

Edge cases to raise

Say this out loud: “The state has to be ending at index i, not within the first i, because otherwise I cannot tell whether the next element can extend it.”

4. 0/1 Knapsack Medium

Problem

Each item has a weight and a value. Choose a subset with total weight at most capacity that maximises total value. Each item may be taken at most once, which is the “0/1”.

The five questions

  1. State: best[i][w] is the maximum value using only the first i items within weight budget w.
  2. Recurrence: for item i there are exactly two options, skip it or take it:
    best[i][w] = max(best[i-1][w], best[i-1][w - weight[i]] + value[i]), the second only when it fits.
  3. Base: best[0][w] = 0. No items, no value.
  4. Order: items outer, capacities inner.
  5. Answer: best[n][capacity].

Solution: 2D table

def knapsack_2d(weights: list[int], values: list[int], capacity: int) -> int:
    """Maximum value from a subset of items fitting inside capacity.

    Args:
        weights: Positive item weights.
        values: Item values, same length as weights.
        capacity: Maximum total weight, capacity >= 0.

    Returns:
        The best achievable total value.

    Raises:
        ValueError: If weights and values have different lengths.

    Example:
        >>> knapsack_2d([1, 3, 4, 5], [1, 4, 5, 7], 7)
        9
    """
    if len(weights) != len(values):
        raise ValueError("weights and values must have the same length")

    n = len(weights)
    # A comprehension, NOT [[0] * m] * n, which aliases one row n times.
    best = [[0] * (capacity + 1) for _ in range(n + 1)]

    for i in range(1, n + 1):
        weight, value = weights[i - 1], values[i - 1]

        for room in range(capacity + 1):
            skip = best[i - 1][room]

            if weight <= room:
                take = best[i - 1][room - weight] + value
                best[i][room] = max(skip, take)
            else:
                best[i][room] = skip

    return best[n][capacity]

Solution: one row, and the direction that matters

def knapsack(weights: list[int], values: list[int], capacity: int) -> int:
    """Same answer in O(capacity) space.

    Row i depends only on row i - 1, so one array suffices. The inner loop
    must run BACKWARDS: it guarantees that best[room - weight] still holds
    the previous item's value, which is what enforces "each item at most once".
    """
    best = [0] * (capacity + 1)

    for weight, value in zip(weights, values):
        for room in range(capacity, weight - 1, -1):
            best[room] = max(best[room], best[room - weight] + value)

    return best[capacity]
The backwards loop is the crux, and interviewers ask about it directly. Iterating forwards would read best[room - weight] after it had already been updated for the current item, so the same item could be taken twice. Backwards, every cell you read is still from the previous item’s row. Reverse the direction and you have solved the unbounded knapsack instead, where unlimited copies are allowed. One loop direction, two classic problems. That is the sentence to have ready.

Walkthrough

weights = [1, 3, 4, 5], values = [1, 4, 5, 7], capacity = 7. The optimum takes items 2 and 3, weights 3 + 4 = 7 and values 4 + 5 = 9. Taking item 4 alone gives 7; items 1 and 4 give 1 + 7 = 8 at weight 6. So the answer is 9.

TimeO(n × capacity)SpaceO(capacity)
“Is this polynomial?” It is pseudo-polynomial. The running time is linear in the numeric value of capacity, but the input only needs log(capacity) bits to write down, so it is exponential in the input size. Knapsack is NP-hard, and this table does not contradict that. Being able to say this is a strong differentiator; most candidates cannot.

The family this unlocks

ProblemMapping
Partition Equal Subset SumBoolean knapsack with capacity total // 2 and weight equal to value.
Target SumAssigning plus or minus signs reduces to picking a subset with a fixed sum.
Last Stone Weight IISplit into two piles as close as possible, which is the same subset-sum table.
Coin ChangeUnbounded knapsack, so the inner loop runs forwards.

Edge cases to raise

Say this out loud: “Each item is a take-or-skip decision, so the state is items considered by capacity used. In the one-row version the inner loop runs backwards, because that is what stops an item being reused.”

Recap

The six things to carry forward

Where this goes next

That closes the four classical parts. Part V adds the four patterns these pages keep pointing at without covering. First is Pattern 13, Prefix Sum and Hash Map, which handles exactly the case the sliding window has to give up on.


11 — Modified Binary Search 13 — Prefix Sum and Hash Map