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.
| Signal | What 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 grids | Two strings compared, a grid traversed, a line of items with take-or-skip choices. |
| Exponential brute force | You can see the 2ⁿ recursion immediately, and n is 1000. That gap is the question. |
4 + 1 + 1, three coins. DP finds 3 + 3, two coins. If you cannot prove the greedy exchange, use DP.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”.| Top-down (memoisation) | Bottom-up (tabulation) | |
|---|---|---|
| How | Recursion plus a cache | A loop filling a table |
| Write it when | You already have the recursion. Fastest path from brute force. | The order is obvious, or you want to compress the space. |
| Visits | Only the states actually reachable | Every state in the table |
| Risk | Recursion depth | Getting the fill order wrong |
| Space | Cache plus call stack | Table, often reducible to one or two rows |
@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.Answer these in order, out loud, before writing code. They are the whole method.
| # | Question | Example, for Coin Change |
|---|---|---|
| 1 | State. What does one subproblem look like, and what does its answer mean? | best[v] = fewest coins summing to exactly v. |
| 2 | Recurrence. How does one state depend on smaller ones? | best[v] = 1 + min(best[v - c]) over every coin c ≤ v. |
| 3 | Base case. Which states are known outright? | best[0] = 0. |
| 4 | Order. In what sequence can the table be filled so dependencies come first? | Increasing v, from 1 to amount. |
| 5 | Answer. Which cell holds the result? | best[amount], unless it is still the sentinel. |
i elements” does not compose, but “the LIS ending at i” does. Redefine the state before you fight the recurrence.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.
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]
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.
i”.best[0] = 0 for a minimum, ways[0] = 1 for a count. An empty selection has zero cost but exactly one arrangement.float("inf") then adding to it. Correct but slow and awkward to type-check. A finite sentinel such as amount + 1 is cleaner and impossible to reach legitimately.[[0] * n] * m for a 2D table. That makes m references to one row, so writing to one writes to all. Use a comprehension.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?
ways[i] is the number of distinct ways to reach step i.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].ways[1] = 1, ways[2] = 2.i.ways[n].That is the Fibonacci sequence, offset by one. Saying so immediately is a good start.
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
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.
| Variation | Change |
|---|---|
| Steps of 1, 2 or 3 | Sum the three previous values. |
| Min Cost Climbing Stairs | min of the two predecessors plus the cost of the step. |
| Some steps are broken | Set those states to 0 before the loop reaches them. |
| House Robber | Same two-variable shape: max(skip, take + two_back). |
n = 1 gives 1, n = 2 gives 2. Both handled by the guard.n = 0: arguably 1, the empty climb. The guard returns 0. Ask which is wanted; it is a fair question.n: Python integers grow without limit, so no overflow, but the numbers get long. Worth a sentence.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.
[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.best[v] is the fewest coins summing to exactly v.c ≤ v, so best[v] = 1 + min(best[v - c]) over all such c.best[0] = 0. Zero coins make zero.v, so best[v - c] is always ready.best[amount], or -1 if it is still the sentinel.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]
coins = [1, 3, 4], amount = 6:
| v | candidates | best[v] |
|---|---|---|
| 1 | best[0]+1 = 1 | 1 |
| 2 | best[1]+1 = 2 | 2 |
| 3 | best[2]+1 = 3, best[0]+1 = 1 | 1 |
| 4 | best[3]+1 = 2, best[1]+1 = 2, best[0]+1 = 1 | 1 |
| 5 | best[4]+1 = 2, best[2]+1 = 3, best[1]+1 = 2 | 2 |
| 6 | best[5]+1 = 3, best[3]+1 = 2, best[2]+1 = 3 | 2 |
The winner at v = 6 comes through the 3-coin, which is exactly the case greedy misses.
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]
amount == 0: returns 0, from the base case.coins = [2] with amount = 3: returns -1.coin <= value guard.-1.float("inf") instead of the sentinel also works, but then inf + 1 appears in the arithmetic. The finite sentinel avoids that entirely.Return the length of the longest strictly increasing subsequence of an array. A subsequence may skip elements but must keep the original order.
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.best[i] = LIS length ending exactly at i.best[i] = 1 + max(best[j]) over all j < i with nums[j] < nums[i].best[i] = 1. Every element alone is a subsequence of length one.i.max(best), not best[-1], because the best subsequence need not end at the last element.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)
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)
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.nums = [10, 9, 2, 5, 3, 7, 101, 18]:
| value | action | tails |
|---|---|---|
| 10 | append | [10] |
| 9 | replace index 0 | [9] |
| 2 | replace index 0 | [2] |
| 5 | append | [2, 5] |
| 3 | replace index 1 | [2, 3] |
| 7 | append | [2, 3, 7] |
| 101 | append | [2, 3, 7, 101] |
| 18 | replace 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.
0. The max() in the DP version would raise on an empty list, hence the guard.1.1, because the requirement is strictly increasing.bisect_left to bisect_right in the fast version, and < to <= in the DP. One-character changes, worth flagging.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”.
best[i][w] is the maximum value using only the first i items within weight budget w.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.best[0][w] = 0. No items, no value.best[n][capacity].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]
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]
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.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.
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.| Problem | Mapping |
|---|---|
| Partition Equal Subset Sum | Boolean knapsack with capacity total // 2 and weight equal to value. |
| Target Sum | Assigning plus or minus signs reduces to picking a subset with a fixed sum. |
| Last Stone Weight II | Split into two piles as close as possible, which is the same subset-sum table. |
| Coin Change | Unbounded knapsack, so the inner loop runs forwards. |
capacity == 0: returns 0.0.range bound skips it entirely.i”.@cache. Then tabulate. Then compress.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.