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 (LCS, Edit Distance), a grid traversed, a line of items with take-or-skip choices, a best contiguous run (Kadane). |
| 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 stores each best(state) the first time it runs. A repeat call
# returns the stored answer at once, so each state costs work only once.
@cache
def best(state: int) -> int:
# is_base(state): True when the answer is known without recursion.
# Climbing Stairs: step <= 2. Coin Change: value == 0.
if is_base(state):
# base_value(state): that known answer.
# Climbing Stairs: step itself (1 way or 2 ways). Coin Change: 0 coins.
return base_value(state)
# transitions(state): the smaller states one choice away.
# Climbing Stairs: step - 1 and step - 2 (last move was 1 or 2 steps).
# Coin Change: value - coin, for every coin that fits.
# combine: how the answers of those smaller states merge into this one.
# Climbing Stairs: sum (ways add up). Coin Change: 1 + min (one more coin).
return combine(best(smaller) for smaller in transitions(state))
# Ask for the whole problem. Recursion solves the smaller states first.
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.
Reading the figure. Green boxes are calls that run their body and store a result. Blue dashed boxes are repeat calls that @cache answers at once. The cache row shows the order the answers land. The base cases 2 and 1 are stored first, and ways(5) = 8 is stored last.
def solve_table(n: int) -> int:
"""Fill a table in dependency order."""
# table[i] holds the answer for subproblem i. Coin Change: amount i.
# initial: the "nothing found yet" value every slot starts with.
# Coin Change: amount + 1, a sentinel no real answer can reach.
# Counting ways: 0, because no ways are found yet.
# (n + 1): +1 so index n exists, slots 0..n.
table = [initial] * (n + 1)
# base_value: the known answer for the empty subproblem in slot 0.
# Coin Change: 0, because zero coins make amount 0.
# Counting ways: 1, because taking nothing is one way to make 0.
table[0] = base_value
# i is the subproblem being solved. Start at 1: slot 0 is the base case.
# n + 1 so that slot n is included.
# Invariant: table[0..i-1] are all final when the pass for i starts.
for i in range(1, n + 1):
# choices(i): every legal LAST move for subproblem i.
# Coin Change: each coin with coin <= i (the coins that fit).
for choice in choices(i):
# table[i - choice]: the smaller subproblem left after this choice.
# Coin Change: pay one coin, and i - coin is still to be made.
# It is final already, because i - choice < i.
# cost(choice): what this one choice adds on top of that answer.
# Coin Change: 1 (one more coin). Counting ways: 0 (adds nothing).
# better: how to pick between the current table[i] and this candidate.
# Coin Change: min (keep the fewest coins).
# Counting ways: + (every route to i - choice is a new route to i).
table[i] = better(table[i], table[i - choice] + cost(choice))
# Slot n holds the answer to the whole problem.
return table[n]
In plain words: solve every amount from small to large. For each amount, try every possible last move. A last move leaves a smaller amount behind, and that smaller amount is already solved, so you just read table[i - choice]. Add what the move itself costs, then keep the better total. This template shows only the tabulate step. The compress step comes after it works: if slot i only ever reads a fixed few earlier slots, keep just those, like the two variables in Climbing Stairs. For a 2D table where each row reads only the row above, keep two rows with Template C. With the counting choices (better is +, cost is 0), this loop order counts orderings. Swap the loops, as in Coin Change II, to count combinations.
Reading the figure. Each box is one slot table[i]. Grey slot 0 is the base case. Blue slots are final. Amber is the slot being filled. The green arrows on top show the winning move into each slot, labelled with the coin. The bottom row zooms in on i = 6. Each arrow starts at table[6 - coin], which is already final because it sits to the left. Red arrows lose. The green one wins with 2 coins, 3 + 3. Notice that every arrow points right. That is why filling in increasing i is safe.
The same template with every placeholder filled in for Coin Change:
def min_coins_template_b(coins: list[int], amount: int) -> int:
"""Template B with every placeholder filled in for Coin Change.
Args:
coins: Distinct positive denominations, unlimited supply of each.
amount: Target sum, amount >= 0.
Returns:
The fewest coins summing to exactly amount, or -1 if impossible.
Example:
>>> min_coins_template_b([1, 3, 4], 6)
2
>>> min_coins_template_b([2], 3)
-1
"""
# initial = amount + 1. A real answer uses at most `amount` coins
# (all 1s), so amount + 1 means "not reachable yet".
initial = amount + 1
# (amount + 1): +1 so index amount exists, slots 0..amount.
table = [initial] * (amount + 1)
# base_value = 0: zero coins make amount 0.
table[0] = 0
# i is the amount being solved. Start at 1: slot 0 is the base case.
# amount + 1 so that slot amount is included.
# Invariant: table[0..i-1] already hold the fewest coins for those amounts.
for i in range(1, amount + 1):
# choices(i) = the coins that fit, so i - coin never drops below 0.
for coin in coins:
if coin <= i:
# table[i - coin]: fewest coins for what is left after this coin.
# cost(coin) = 1: this coin adds one to the count.
# better = min: keep the smaller of the old best and this route.
table[i] = min(table[i], table[i - coin] + 1)
# Still the sentinel means no mix of coins hits amount. -1 signals that.
return -1 if table[amount] == initial else table[amount]
def solve_2d(rows: int, cols: int) -> int:
"""When row i depends only on row i - 1, keep two rows, not n."""
# previous is row i - 1 of the full table. It starts as row 0, the base row.
# [0]: base value 0. LCS: an empty prefix shares 0 letters.
# Edit Distance starts this row as 0, 1, ..., cols instead (j inserts).
# (cols + 1): +1 so index cols exists. Slot 0 is the empty prefix.
previous = [0] * (cols + 1)
# i is the row being built. Start at 1: row 0 is the base row above.
# rows + 1 so that the last row is included.
# Invariant: previous holds the finished row i - 1.
for i in range(1, rows + 1):
# Fresh row. Slot 0 is the base column and keeps its base value, 0.
current = [0] * (cols + 1)
# j is the column. Start at 1: column 0 is the base column.
for j in range(1, cols + 1):
# combine: the recurrence, fed the three cells a 2D table would read.
# previous[j] = above, current[j - 1] = left,
# previous[j - 1] = up-left diagonal (- 1 = one column back).
# LCS: diagonal + 1 if the letters match, else max(above, left).
# Edit Distance: diagonal if they match, else 1 + min of all three.
current[j] = combine(previous[j], current[j - 1], previous[j - 1])
# Row i is done. It becomes the "row above" for row i + 1.
previous = current
# previous is now the last row. Its last slot is the answer.
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, and Longest Common Subsequence shows it filled in.
Reading the figure. The top row is previous, the finished row above. The bottom row is current. Amber is current[j], the cell being filled. The three blue arrows are the only cells it reads: previous[j] above, previous[j - 1] on the diagonal, and current[j - 1] on the left. Nothing older is ever read. So the rest of the table can be thrown away.
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
"""
# Base cases: n = 1 has 1 way, n = 2 has 2 ways (1+1 or 2).
# In both, the answer is n itself.
if n <= 2:
return n
# Only the last two values are ever needed, so keep two variables
# instead of an array. O(1) space.
# Seeds: two_back = ways(1) = 1, one_back = ways(2) = 2.
two_back, one_back = 1, 2
# Each pass computes ways(step) for step 3..n. Start at 3: steps 1 and 2
# are the seeds. n + 1 so that step n is included.
# Invariant: two_back = ways(step - 2), one_back = ways(step - 1).
for _ in range(3, n + 1):
# Slide up one step. New count = sum of the two counts below it.
two_back, one_back = one_back, two_back + one_back
# After the last pass, one_back = ways(n).
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: each step is computed once, so n calls instead of about 2 ** n.
@cache
def ways(step: int) -> int:
# Base: step 1 has 1 way, step 2 has 2 ways. Both equal step itself.
if step <= 2:
return step
# Last move was a 1 (from step - 1) or a 2 (from step - 2). Add both.
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."""
# Same base as above: the answer is n for n = 1 or 2.
if n <= 2:
return n
# (n + 1): +1 so index n exists. 0 is a placeholder. Slot 0 is never read.
ways = [0] * (n + 1)
# Base cases: 1 way to reach step 1, 2 ways to reach step 2.
ways[1], ways[2] = 1, 2
# Start at 3: steps 1 and 2 are base cases. n + 1 so that n is included.
# Invariant: ways[1..i-1] are all final.
for i in range(3, n + 1):
# i - 1: last move was one step. i - 2: last move was two steps.
ways[i] = ways[i - 1] + ways[i - 2]
# Slot n is the top step.
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.
Reading the figure. Grey slots are the base cases, 1 way and 2 ways. Each blue slot gets two arrows, one from i - 1 and one from i - 2. Their values add. The green slot is the answer, ways[5] = 8. The bottom row shows the O(1) version. Each pass shifts the pair right by one step, and the right box ends on 8.
| 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
# (amount + 1): +1 so index amount exists, slots 0..amount.
best = [unreachable] * (amount + 1)
# Base case: 0 coins make amount 0.
best[0] = 0
# value is the amount being solved. Start at 1: slot 0 is the base case.
# amount + 1 so that amount itself is included.
# Invariant: best[0..value-1] are all final.
for value in range(1, amount + 1):
# Try each coin as the LAST coin used.
for coin in coins:
if coin <= value: # coin fits, so value - coin >= 0
# best[value - coin] coins for the rest, + 1 for this coin.
best[value] = min(best[value], best[value - coin] + 1)
# -1 is the required "impossible" answer when the sentinel never moved.
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.
Reading the figure. Each circle is an amount still to pay. Each arrow spends one coin and moves left by its value. The red path is greedy, which always takes the biggest coin that fits. The green path is the DP answer. The numbers under the bottom line are best[v] from the table above. Notice that best[3] = 1. That is why the hop from 6 to 3 wins.
def coin_change_ways(coins: list[int], amount: int) -> int:
"""Coin Change II: how many distinct combinations sum to amount."""
# (amount + 1): +1 so index amount exists. 0: no ways found yet.
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.
# Invariant: after a coin's pass, ways[v] counts the combinations of the
# coins seen so far that sum to v.
for coin in coins:
# Start at coin: smaller values cannot hold this coin.
# amount + 1 so amount is included. Forwards, so a coin can repeat.
for value in range(coin, amount + 1):
# Each way to make value - coin, plus this coin, makes value.
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
"""
# Empty input has no subsequence: length 0. Also guards max() below.
if not nums:
return 0
# best[i] is the LIS length ending exactly at index i.
# 1: every element alone is an increasing run of length 1.
best = [1] * len(nums)
# i is the end index. Start at 1: index 0 has nothing before it.
# Invariant: best[0..i-1] are all final.
for i in range(1, len(nums)):
# j scans every earlier index. range(i) stops at i - 1.
for j in range(i):
if nums[j] < nums[i]: # nums[i] can extend a run ending at j
# + 1 for appending nums[i] to that run.
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.
"""
# Empty: no lengths reached yet.
tails: list[int] = []
# Invariant: tails[k] is the smallest tail of any increasing run of
# length k + 1 among the values seen so far, and tails is sorted.
for value in nums:
# bisect_left: first slot with tails[slot] >= value. Left, not right,
# so an equal value replaces rather than extends (strictly increasing).
index = bisect.bisect_left(tails, value)
if index == len(tails): # value beats every tail: slot is past the end
tails.append(value) # extends the longest run found so far
else:
tails[index] = value # a smaller tail for that length: strictly better
# One slot per length reached, so the count is the LIS length.
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.
Reading the figure. Each column is the state after reading the bold value on top. The stack under it is tails, index 0 at the top. Amber marks the one slot that changed, found by bisect_left. Blue slots stay as they were. A value bigger than every tail appends and makes the stack taller. Any other value replaces a tail with a smaller one. The last column, in green, has height 4.
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)
# best[i][room]: max value from the first i items within budget room.
# n + 1 rows: row 0 is "no items". capacity + 1 columns: rooms 0..capacity.
# 0: no items, or no room, is worth 0.
# A comprehension, NOT [[0] * m] * n, which aliases one row n times.
best = [[0] * (capacity + 1) for _ in range(n + 1)]
# i counts the items considered. Start at 1: row 0 is the base row.
# n + 1 so that item n is included.
# Invariant: row i - 1 is complete when the pass for i starts.
for i in range(1, n + 1):
# i - 1: item number i sits at list index i - 1.
weight, value = weights[i - 1], values[i - 1]
# room is every budget. capacity + 1 so capacity itself is included.
for room in range(capacity + 1):
# Skip item i: same value as the first i - 1 items in the same room.
skip = best[i - 1][room]
if weight <= room: # item fits in this budget
# Take it: best of the first i - 1 items in the leftover room.
take = best[i - 1][room - weight] + value
best[i][room] = max(skip, take)
else:
best[i][room] = skip # too heavy, so skip is the only option
# All n items considered, full budget.
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[room]: max value within budget room. +1 so room = capacity exists.
# 0: before any item, every budget is worth 0.
best = [0] * (capacity + 1)
# Invariant: at the top of each pass, best is the row for the items
# before this one.
for weight, value in zip(weights, values):
# Walk room down from capacity to weight. weight - 1 is the exclusive
# stop, so weight is included. -1 step = backwards: each item used once.
for room in range(capacity, weight - 1, -1):
# Skip (keep best[room]) or take (previous item's best[room - weight],
# plus this value).
best[room] = max(best[room], best[room - weight] + value)
# Full budget, every item considered.
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.
Reading the figure. Each cell is best[i][w]. A straight down arrow means skip, so the value copies from the row above. A slanted arrow means take, so it comes from w - weight columns to the left, plus the item’s value. Amber cells are the trace from the answer back to the base row. The red dashed arrow is the losing take of item 4, worth only 8.
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.Given an integer array that may contain negatives, return the largest sum of any non-empty contiguous subarray.
i” state as Longest Increasing Subsequence.ending[i] is the largest sum of a subarray that ends exactly at index i.i either extends the best one ending at i - 1 or starts fresh at i, so ending[i] = max(nums[i], ending[i - 1] + nums[i]).ending[0] = nums[0].i.max(ending), because the best subarray can end anywhere. Track it as you go.def max_subarray_kadane(nums: list[int]) -> int:
"""Largest sum of any non-empty contiguous subarray (Kadane).
Args:
nums: Integers, may be negative, with at least one element.
Returns:
The maximum subarray sum.
Raises:
ValueError: If nums is empty.
Example:
>>> max_subarray_kadane([-2, 1, -3, 4, -1, 2, 1, -5, 4])
6
>>> max_subarray_kadane([-3, -1, -2])
-1
"""
# The answer must be a non-empty subarray, so an empty input has none.
if not nums:
raise ValueError("nums must be non-empty")
# Two-variable state instead of a table:
# ending_here = best sum of a subarray that ENDS at the current index.
# best = best sum of any subarray seen so far.
# nums[0]: index 0 is the first element. The only subarray ending there
# is [nums[0]], so both variables start at it.
ending_here = best = nums[0]
# nums[1:]: start at index 1, because index 0 seeded both variables.
# Invariant: ending_here is the best sum ending at the previous index,
# and best is the best sum over every subarray seen so far.
for value in nums[1:]:
# Extend the run that ended one step back, or start fresh at value.
# Fresh wins exactly when the old run is negative and only drags.
ending_here = max(value, ending_here + value)
# The best subarray can end anywhere, so keep a running maximum.
best = max(best, ending_here)
return best
nums = [-2, 1, -3, 4, -1, 2, 1, -5, 4]:
ending_here = best = -2.1: fresh 1 beats -2 + 1. ending 1, best 1.-3: extend to -2. best stays 1.4: fresh 4 beats -2 + 4. ending 4, best 4.-1, 2, 1: extend to 3, 5, 6. best 6.-5, 4: ending drops to 1, then 5. best stays 6, from [4, -1, 2, 1].Reading the figure. Read the columns left to right. The middle row is ending_here. Amber marks a fresh start, where the number alone beats extending. That happens at 1 and at 4. Blue cells extend the run. The bottom row is best, the largest ending seen so far. It peaks at 6, in green, and never drops after that.
[-3, -1, -2]: the answer is the largest single element, -1. Seeding with 0 instead of nums[0] would wrongly return 0.best improves.Given two strings, return the length of their longest common subsequence: letters that appear in both, in the same order, not necessarily next to each other. "ace" is a common subsequence of "abcde" and "ace".
table[i][j] is the LCS length of the first i letters of text1 and the first j letters of text2.table[i-1][j-1] + 1. Otherwise drop one last letter or the other: max(table[i-1][j], table[i][j-1]).0. An empty prefix shares nothing.table[len(text1)][len(text2)].def lcs_length_2d(text1: str, text2: str) -> int:
"""Length of the longest common subsequence of two strings.
Args:
text1: First string.
text2: Second string.
Returns:
The length of the longest subsequence present in both strings.
Example:
>>> lcs_length_2d("abcde", "ace")
3
>>> lcs_length_2d("abc", "def")
0
"""
rows, cols = len(text1), len(text2)
# table[i][j]: LCS length of text1[:i] and text2[:j].
# +1 on both sides so row 0 and column 0 exist: the empty prefixes.
# 0: an empty prefix shares nothing, so row 0 and column 0 stay 0.
# A comprehension, so each row is its own list.
table = [[0] * (cols + 1) for _ in range(rows + 1)]
# i and j count letters used from each string. Start at 1: 0 is the base.
# rows + 1 and cols + 1 so the full strings are included.
# Invariant: every row above i is final, and so is row i left of column j.
for i in range(1, rows + 1):
for j in range(1, cols + 1):
# - 1: a prefix of length i ends with the letter at index i - 1.
if text1[i - 1] == text2[j - 1]:
# Match: use both letters. Diagonal answer, + 1 for this letter.
table[i][j] = table[i - 1][j - 1] + 1
else:
# No match: drop text1's last letter (above, i - 1)
# or text2's last letter (left, j - 1). Keep the better.
table[i][j] = max(table[i - 1][j], table[i][j - 1])
# Both full strings.
return table[rows][cols]
def lcs_length_rolling(text1: str, text2: str) -> int:
"""Same answer in O(len(text2)) space, keeping only two rows.
Args:
text1: First string. Its letters drive the rows.
text2: Second string. Its letters are the columns.
Returns:
The length of the longest subsequence present in both strings.
Example:
>>> lcs_length_rolling("abcde", "ace")
3
"""
cols = len(text2)
# previous is the row above. It starts as row 0: empty text1 prefix, all 0.
# (cols + 1): +1 so slot cols exists. Slot 0 is the empty text2 prefix.
previous = [0] * (cols + 1)
# One row per letter of text1.
# Invariant: previous holds the finished row for the letters before this one.
for letter in text1:
# Fresh row. Slot 0 stays 0: an empty text2 prefix shares nothing.
current = [0] * (cols + 1)
# j counts text2 letters. Start at 1: slot 0 is the base.
# cols + 1 so the last letter is included.
for j in range(1, cols + 1):
# j - 1: the j-th letter of text2 sits at index j - 1.
if letter == text2[j - 1]:
# Match: diagonal (previous[j - 1]) + 1 for this letter.
current[j] = previous[j - 1] + 1
else:
# No match: best of above (previous[j]) and left (current[j - 1]).
current[j] = max(previous[j], current[j - 1])
# Row done. It becomes the row above for the next letter.
previous = current
# Last row, last slot: both full strings.
return previous[cols]
This is Template C with combine filled in. The diagonal cell previous[j - 1] is why you need two rows here. Reading it from a single row would see a value already overwritten for this row.
text1 = "abcde", text2 = "ace". Each bullet is one row, for "" a c e:
a: 0 1 1 1. The a matches, and the 1 carries right.b: 0 1 1 1. No match, so values copy down.c: 0 1 2 2. The c matches: diagonal 1, plus 1.d: 0 1 2 2. No match.e: 0 1 2 3. The e matches: diagonal 2, plus 1. Answer 3.Reading the figure. Rows are prefixes of "abcde" and columns are prefixes of "ace". Each amber cell is a letter match, and its green arrow shows it reading the diagonal and adding 1. Blue cells copy the larger of the cell above and the cell to the left. Follow the three green arrows from the top left to the corner. They spell out a, c, e.
0, straight from the base row or column.0.O(min(m, n)) space.Return the fewest single-letter operations that turn word1 into word2. The allowed operations are insert a letter, delete a letter, and replace a letter. This is the Levenshtein distance.
table[i][j] is the fewest edits to turn the first i letters of word1 into the first j letters of word2.table[i-1][j-1]. Otherwise pay 1 and take the cheapest of delete (table[i-1][j]), insert (table[i][j-1]), or replace (table[i-1][j-1]).table[i][0] = i, which is i deletes. table[0][j] = j, which is j inserts.table[len(word1)][len(word2)].def edit_distance_2d(word1: str, word2: str) -> int:
"""Fewest inserts, deletes, or replaces that turn word1 into word2.
Args:
word1: Source string.
word2: Target string.
Returns:
The edit (Levenshtein) distance.
Example:
>>> edit_distance_2d("horse", "ros")
3
>>> edit_distance_2d("", "abc")
3
"""
rows, cols = len(word1), len(word2)
# table[i][j]: fewest edits to turn word1[:i] into word2[:j].
# +1 on both sides so row 0 and column 0 (the empty prefixes) exist.
# 0 is a placeholder. The base loops and main loop overwrite every cell.
table = [[0] * (cols + 1) for _ in range(rows + 1)]
# Base column: turning i letters into "" takes i deletes.
# rows + 1 so that i = rows is included.
for i in range(rows + 1):
table[i][0] = i
# Base row: turning "" into j letters takes j inserts.
# cols + 1 so that j = cols is included.
for j in range(cols + 1):
table[0][j] = j
# Start at 1: row 0 and column 0 are the base cases above.
# Invariant: every row above i is final, and so is row i left of column j.
for i in range(1, rows + 1):
for j in range(1, cols + 1):
# - 1: a prefix of length i ends with the letter at index i - 1.
if word1[i - 1] == word2[j - 1]:
# Last letters already match: no edit, take the diagonal.
table[i][j] = table[i - 1][j - 1]
else:
# 1 for the edit made now, plus the cheapest smaller case.
table[i][j] = 1 + min(
table[i - 1][j], # delete word1's letter (above)
table[i][j - 1], # insert word2's letter (left)
table[i - 1][j - 1], # replace one with the other (diagonal)
)
# Both full words.
return table[rows][cols]
word1 = "horse", word2 = "ros". The table ends at 3. One optimal path:
h with r: horse becomes rorse.r: rorse becomes rose.e: rose becomes ros.table[5][0] = 5, because turning "horse" into "" takes five deletes.Reading the figure. Rows are prefixes of "horse" and columns are prefixes of "ros". Grey cells are the base row and column, pure inserts or deletes. The amber cells and arrows are one cheapest path. A diagonal step is a replace, or free when the letters match. A down step is a delete. The cost rises by 1 only on the three edit steps, and ends at 3 in the green corner.
0, all diagonal moves.0, 1, ..., n, and each new row starts with its row number in slot 0.1 with that operation’s cost. The shape stays the same.m + n - 2 × LCS. Saying that ties the two problems together.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.