Dynamic Programming Mastery
Optimize recursive solutions using memoization and tabulation for overlapping subproblems
SPACED REPETITION Β· 20 practice questions
Make this lesson stick.
Try 3 questions now. No account needed. Sample answers aren't saved.
or sign in to practice all 20Try every choice, but answer each question only once
Write 12 as a sum of as few perfect squares as possible. The obvious plan is greedy: take the biggest square that fits, then repeat. That gives 9 + 1 + 1 + 1, four squares. But 4 + 4 + 4 needs only three. Grabbing the 9 looked best and still lost.
So try every choice instead. For each square that could come first, solve what is left and keep the best result. That is always correct, and as plain recursion it is hopeless: it makes 104 calls for n = 12, about 60 million for n = 50, and about 2.3 Γ 10ΒΉβ΅ for n = 100.
Yet for n = 100 the recursion can only ever ask 101 different questions: "what is the fewest squares that sum to m?" for m = 0 β¦ 100. It asks the same ones over and over. Answer each one once, write the answer down, and n = 100 takes 625 small steps.
That is dynamic programming (DP) in two sentences. Try every choice, so you are right. Answer each distinct subproblem once, so you are fast. The rest of this lesson is technique for making both halves work on problems that don't hand you their subproblems.
| Section | The question it answers | Anchor problem |
|---|---|---|
| Hear the echo | Does brute force repeat itself? Can a best answer be built from best sub-answers? | Perfect Squares |
| From recursion to a table | How do I cache it (top-down) or fill it in order (bottom-up)? | Perfect Squares |
| Design the state | What must a subproblem remember? | Stock with Cooldown |
| Fill order | Which cells must exist before this one? | Longest Palindromic Subsequence, Burst Balloons |
| Walk it back | Which choices produced the optimum? | Perfect Squares, palindromes |
| Shrink the table | Which rows can I forget? | Partition Equal Subset Sum |
| DP or greedy? | Does one local choice provably suffice? | Coins, meeting schedules |
Before you start. You should be comfortable with recursion: base cases, the call stack, and trusting the recursive call (Advanced Recursion & Backtracking). Memo tables here are dictionaries, sometimes keyed by tuples (Hash Maps & Sets). And the notebook model from Foundation: Arrays & Strings comes back in its most general form: before each step, ask what the next step needs to know about the past, and store only that.
π§ About the name. Richard Bellman coined it in the 1950s. "Programming" meant planning, as in "linear programming", not writing code. By his own later account, he wanted a word for multistage, time-varying decision making that nobody in Washington could object to, and "dynamic" did both jobs. The "multistage decisions" part is the one to remember: a DP makes one decision per stage.
Hear the echo: what makes a problem DP
Smell: a recursive search that tries every choice, and whose calls repeat.
Here is the try-every-choice recursion for LeetCode 279: Perfect Squares. The contract: given n β₯ 1, return the fewest perfect squares (1, 4, 9, β¦) that sum to n.
import math
def num_squares_brute(n):
if n == 0:
return 0 # the empty sum uses zero squares
best = math.inf
for k in range(1, math.isqrt(n) + 1): # every square k*k <= n may go first
best = min(best, 1 + num_squares_brute(n - k * k))
return best
math.isqrt(n) is the integer square root, so the loop tries exactly the squares that fit. The function is correct because every decomposition of n starts with some square, and the loop tries all of them.
Predict first: while computing num_squares_brute(12), how many times is the subproblem for 11 solved? And the subproblem for 0?
Check your answer
An instrumented run of the call above counted:
| subproblem m | 12 | 11 | 10 | 9 | 8 | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| times solved | 1 | 1 | 1 | 1 | 2 | 3 | 4 | 5 | 7 | 11 | 16 | 22 | 30 |
11 is solved once, since only 12 β 1 leads there. 0 is solved 30 times, once for every ordered way of writing 12 as a sum of squares. The smaller the subproblem, the more paths lead to it, and the recursion walks every path. That is 104 calls to answer 13 distinct questions.
That repetition has a name: overlapping subproblems. Different choice sequences (1 then 4, or 4 then 1) lead to the same smaller problem, and plain recursion solves it again each time. The number of calls grows exponentially while the number of distinct questions grows by one:
| n | 12 | 30 | 50 | 60 | 100 |
|---|---|---|---|---|---|
| calls made by the brute force | 104 | 55,778 | 60,034,848 | 1,969,595,863 | β 2.3 Γ 10ΒΉβ΅ |
| distinct subproblems | 13 | 31 | 51 | 61 | 101 |
Not every recursion overlaps. Merge sort splits an array into two halves that share no elements. Every call works on a different slice, so a cache would never get a hit. That is plain divide and conquer. DP pays off only when the same subproblem is reached along different paths.
The cut-and-paste test
Remembering one number per subproblem is only safe if the best answer for 12 can be built from the best answers for smaller amounts. Here it can. Suppose a best decomposition of 12 starts with 4. The remaining squares sum to 8. If they were not a best decomposition of 8, you could cut them out, paste in a shorter one, and get a decomposition of 12 that beats the best. That is impossible. So:
best(12) = 1 + min( best(12 β 1), best(12 β 4), best(12 β 9) )
This is optimal substructure: an optimal solution is made of optimal solutions to its subproblems. It is what lets you replace "every way to decompose 8" with the single number best(8).
It can fail. Take the longest simple path (no city visited twice) between two cities. If the longest simple path from A to C passes through B, its A-to-B part need not be the longest simple path from A to B, because that longer A-to-B path might use cities the B-to-C part needs. The halves interfere, so the best halves don't combine into the best whole, and no small table solves it.
Counting problems need a different guarantee, because there is no optimum to cut and paste. "How many ordered ways can n be written as a sum of squares?" works because splitting by the first square gives cases that are disjoint (no way is counted in two branches) and exhaustive (every way lands in some branch). So the counts simply add. Feasibility problems ("can n be written as β¦?") combine their cases with or.
From recursion to a table
The recursion is right; it just forgets. There are two standard ways to make it remember.
Top-down: a notebook next to the recursion
Keep the recursion exactly as it is and add a dictionary, the memo, from subproblem to answer. Look there before doing any work, and write there before returning. This is memoization.
import math
def num_squares_memo(n):
memo = {0: 0} # base case pre-filled; a fresh memo per call
def solve(m):
if m in memo:
return memo[m]
best = math.inf
for k in range(1, math.isqrt(m) + 1):
best = min(best, 1 + solve(m - k * k))
memo[m] = best # store before returning
return best
return solve(n)
Now each m is worked out once. Every later request for it is a dictionary lookup.
Cost = states Γ work per state. There are n + 1 states, and state m tries ββmβ squares, so the time is O(nβn): 661,750 inner steps for n = 10β΄, where the brute force would never finish. Extra space is O(n) for the memo, plus the recursion stack. solve(n) calls solve(n β 1), which calls solve(n β 2), and so on, so the stack grows n frames deep. In Python that depth is a real problem.
β οΈ Python's recursion limit. CPython allows about 1,000 nested calls by default (sys.getrecursionlimit()), then raises RecursionError. num_squares_memo(1000) already fails, and LeetCode 279 allows n up to 10β΄. sys.setrecursionlimit(20000) gets this dictionary version through n = 10β΄ on CPython 3.14, but it is a patch: in the same test, the functools.cache version below overflowed the interpreter's C stack near depth 8,000 even with the higher limit. When the depth can reach thousands, go bottom-up. Java and C++ have no call-count limit, but a deep enough recursion still overflows the thread's stack there too.
functools.cache does the memo bookkeeping for you:
import math
from functools import cache
def num_squares_cached(n):
@cache # defined inside: each call gets a fresh cache
def solve(m):
if m == 0:
return 0
best = math.inf
for k in range(1, math.isqrt(m) + 1):
best = min(best, 1 + solve(m - k * k))
return best
return solve(n)
Two traps come with any memo:
β οΈ The key must capture everything the answer depends on. The memos above live inside the outer function, so every call starts fresh. The classic def f(amount, memo={}) default argument (or a module-level @cache) survives between calls instead. For Perfect Squares that would be harmless, because best(8) is 2 no matter who asks. It becomes a silent bug as soon as the answer depends on something that is not in the key:
import math
def min_coins(coins, amount, memo={}): # BUG: one memo shared by every call
if amount == 0:
return 0
if amount in memo:
return memo[amount]
best = math.inf
for c in coins:
if c <= amount:
best = min(best, 1 + min_coins(coins, amount - c, memo))
memo[amount] = best
return best
print(min_coins([1, 5], 10)) # 2, correct
print(min_coins([1, 3], 9)) # 5: the cached answer for coins [1, 5]; the truth is 3
The fix is a fresh memo for every top-level call, or a key that includes everything the answer depends on, such as (tuple(coins), amount).
β οΈ "Not computed yet" must not look like an answer. If you preallocate memo = [0] * (n + 1) and treat 0 as "unknown", then state 0, whose real answer is 0, looks uncomputed forever. Mark unknown cells with None (or β1 when real answers are never negative), or use a dictionary and in.
Bottom-up: fill the notebook in order
Top-down lets the recursion discover the order. Bottom-up chooses the order in advance: fill a table from the smallest subproblem up, so every value a cell needs is already there. This is tabulation.
import math
def num_squares(n):
dp = [0] + [math.inf] * n # dp[m] = fewest squares summing to m; n + 1 slots
for m in range(1, n + 1):
for k in range(1, math.isqrt(m) + 1):
dp[m] = min(dp[m], dp[m - k * k] + 1)
return dp[n]
This trace for n = 12 was printed by the loop. Each "try" shows a square and the candidate dp[m β square] + 1 it produces:
| m | tries: square β candidate | dp[m] |
|---|---|---|
| 1 | 1 β 1 | 1 |
| 2 | 1 β 2 | 2 |
| 3 | 1 β 3 | 3 |
| 4 | 1 β 4, 4 β 1 | 1 |
| 5 | 1 β 2, 4 β 2 | 2 |
| 6 | 1 β 3, 4 β 3 | 3 |
| 7 | 1 β 4, 4 β 4 | 4 |
| 8 | 1 β 5, 4 β 2 | 2 |
| 9 | 1 β 3, 4 β 3, 9 β 1 | 1 |
| 10 | 1 β 2, 4 β 4, 9 β 2 | 2 |
| 11 | 1 β 3, 4 β 5, 9 β 3 | 3 |
| 12 | 1 β 4, 4 β 3, 9 β 4 | 3 |
The last row replays the hook: the 9 leads to dp[3] + 1 = 4, while the 4 leads to dp[8] + 1 = 3.
Why it works. The invariant: when the loop reaches m, the entries dp[0] β¦ dp[m β 1] are final. Every square is at least 1, so m β kΒ² < m, and each cell reads only cells the loop has already finished. By induction, dp[m] is final when the loop leaves it.
The cost is O(nβn) time and O(n) extra space, with no recursion at all. The table has n + 1 slots because the base case lives at index 0 and the answer at index n.
β οΈ Start a min table at infinity, not 0. With dp = [0] * (n + 1), every min(dp[m], β¦) keeps the 0 that is already there, and the function returns 0 for every n. The starting value must be the identity of the combine step: inf for min, -inf for max, 0 for adding up counts, False for "or". The base case is a separate decision: dp[0] = 0 because the empty sum uses zero squares.
| Top-down (memo) | Bottom-up (table) | |
|---|---|---|
| Order of evaluation | discovered by the recursion | chosen by you |
| States computed | only those reachable from the start | every cell of the table |
| Python depth limit | a real risk | none |
| Easiest when | states are irregular or sparse | the order is simple, or you want to shrink the table |
"Sparse" matters more than it sounds. If a problem's subproblems are n // 2 and n // 3, then from n = 2 Γ 10βΉ the recursion only ever reaches numbers of the form n // (2α΅Β·3α΅). That is 259 distinct values, while a table indexed 0 β¦ n would need two billion cells. Neither style is faster by rule. In Python, function calls and dictionary lookups cost more than list indexing, so a table usually wins by a constant factor (about 1.9Γ for Perfect Squares at n = 10β΄) unless top-down gets to skip most of the states.
Test the fast version against the slow one. The brute force is too slow for real inputs, but it makes a perfect reference on small ones:
import random
def check(fast, slow, trials=300, max_n=25):
for _ in range(trials):
n = random.randint(0, max_n)
assert fast(n) == slow(n), n
print("all matched")
check(num_squares, num_squares_brute)
check(num_squares_memo, num_squares_brute)
Your turn: count instead of minimize. Write count_sums(n), the number of ordered ways to write n as a sum of perfect squares, so 1 + 4 and 4 + 1 count separately. Keep the same state and the same choices. What changes? What is count_sums(0)? Predict count_sums(5) and count_sums(12).
Check your answer
import math
def count_sums(n):
ways = [1] + [0] * n # ways[0] = 1: the empty sum
for m in range(1, n + 1):
for k in range(1, math.isqrt(m) + 1):
ways[m] += ways[m - k * k] # split by the first square
return ways[n]
Two things change. The combine step becomes + instead of min, so the starting value becomes 0, the identity for +. And the base case becomes 1, because there is exactly one way to write 0: choose nothing. With ways[0] = 0 every count would come out 0.
count_sums(5) is 3 (1+1+1+1+1, 1+4 and 4+1), and count_sums(12) is 30. That is the 30 from the predict table: the brute force solved subproblem 0 once for every ordered sum.
Design the state
Smell: your recurrence needs a fact about the past that the subproblem doesn't record.
Perfect Squares handed you its state: the amount left. Most problems don't, and designing the state is the real skill. A short recipe keeps it honest:
- State in one sentence, with units. "dp[m] = the fewest squares that sum to exactly m." If you can't say it, you can't write the recurrence.
- Recurrence from one decision. Look at the first (or last) choice, list the options, and combine them with min, max, + or
or. - Base cases for states that need no decision, and starting values for the rest: the identity of the combine step. For max and min problems, βinf and inf also mark states that are impossible.
- Order: which states must be ready first (next section).
- Where the answer lives: one cell such as dp[n], or a combination such as max(dp).
- Cost: states Γ work per state, plus the stack for top-down.
Step 5 hides a real design choice. "Best among the first i elements" puts the answer in the last cell. "Best for a solution that ends exactly at i" puts it in max(dp), but it makes the recurrence easier, because you know the last element. Kadane's algorithm (Maximum Subarray Sum) and longest increasing subsequence (1D DP Problems) both use "ends at i".
The stock that needs a nap
In the foundation lesson you solved Best Time to Buy and Sell Stock with a two-number notebook: the cheapest earlier price and the best profit so far. Now change the rules. In LeetCode 309: Best Time to Buy and Sell Stock with Cooldown you may trade as often as you like while holding at most one share, but after a sale you must skip a day before buying again. Prices [1, 2, 3, 0, 2] give 3: buy at 1, sell at 2, wait a day, buy at 0, sell at 2.
Predict first: is "best profit up to day i" enough state?
Check your answer
No. To decide whether you may buy today, you need to know whether you hold a share and whether you sold yesterday. Two histories with the same profit can allow different moves tomorrow. So the state is (day, mode), with three modes. Rule of thumb: anything the rules check must be in the state.
The three modes, and the moves between them from one day to the next:
rest again keep holding
βββββ βββββ
βΌ β buy βΌ β sell
REST ββββββββββββΆ HOLD ββββββββββββΆ SOLD
β² β
βββββββββββββ cooldown day βββββββββββ
HOLD means you own a share. SOLD means you sold today. REST means you own nothing and did not sell today, so you may buy tomorrow. Each mode gets one number: the best profit so far over all legal histories that end today in that mode.
import math
def max_profit_cooldown(prices):
if not prices:
return 0
hold = -prices[0] # bought on day 0
sold = -math.inf # "sold on day 0" is impossible
rest = 0 # did nothing
for price in prices[1:]:
hold, sold, rest = (
max(hold, rest - price), # keep holding, or buy today
hold + price, # sell the share held yesterday
max(rest, sold), # rest again, or sit out the cooldown day
)
return max(sold, rest) # never finish holding a share
End-of-day values on [1, 2, 3, 0, 2], printed by the loop:
| day | price | hold | sold | rest |
|---|---|---|---|---|
| 0 | 1 | β1 | βinf | 0 |
| 1 | 2 | β1 | 1 | 0 |
| 2 | 3 | β1 | 2 | 1 |
| 3 | 0 | 1 | β1 | 2 |
| 4 | 2 | 1 | 3 | 2 |
The answer is max(3, 2) = 3. Watch day 3: hold jumps to 1 because rest was 1 (the day-1 sale sat out its cooldown on day 2), and 1 β 0 = 1.
Why it works. Every legal trading history is a walk through the diagram, one arrow per day. Each variable holds the best profit among the walks that end in its mode today, and each update considers every arrow into that mode. The diagram enforces the cooldown by itself: the only way into HOLD is from HOLD or REST, and SOLD reaches REST only after one day. The cost is O(n) time for n prices and O(1) extra space.
Two traps:
- Update all three from yesterday's values. The tuple assignment evaluates every right-hand side before it assigns anything. Write the updates as three separate lines and
soldreads today'shold, whilerestreads today'ssold. The cooldown silently disappears, and[1, 2, 3, 0, 2]returns 4. - Impossible states start at βinf, not 0. Nobody can have sold on day 0. Here
sold = 0happens to be harmless, because a fake zero-profit sale is never better thanrest, which is also 0. In other state machines a fake 0 is a free lunch. In the two-transaction version of this problem, for example, starting "holding the second share" at 0 lets the code sell a share it never paid for.
Every dimension multiplies the work. Allow at most k transactions and the mode must also count how many you have used: about 2k modes and O(nk) time. Before adding a dimension, check whether two candidate states always allow the same moves. If they do, merge them. Here "rested yesterday" and "sold yesterday, so today is the cooldown day" both end today with no share and no sale today. They allow the same moves tomorrow, so both are just REST.
Your turn. Remove the cooldown and charge a fee of fee per sale instead (LeetCode 714: Best Time to Buy and Sell Stock with Transaction Fee). Which modes do you still need? Write it with O(1) extra space, and trace prices = [1, 3, 2, 8, 4, 9] with fee = 2.
Check your answer
Without a cooldown, "sold today" and "resting" allow exactly the same moves, so they merge into one mode, free. Two modes remain:
def max_profit_fee(prices, fee):
hold, free = -prices[0], 0
for price in prices[1:]:
hold, free = max(hold, free - price), max(free, hold + price - fee)
return free
| day | price | hold | free |
|---|---|---|---|
| 0 | 1 | β1 | 0 |
| 1 | 3 | β1 | 0 |
| 2 | 2 | β1 | 0 |
| 3 | 8 | β1 | 5 |
| 4 | 4 | 1 | 5 |
| 5 | 9 | 1 | 8 |
The answer is 8: buy at 1 and sell at 8 (5 after the fee), then buy at 4 and sell at 9 (3 after the fee). On day 1 a sale would net 3 β 1 β 2 = 0, so free stays 0. The cost is O(n) time and O(1) extra space. LeetCode guarantees at least one price, which prices[0] relies on.
Fill order: a cell only after its inputs
Smell: the bottom-up version returns wrong numbers, often too small because it read cells that were still 0, while the memoized version is right.
Top-down gets its order for free, because a call simply waits for the calls it makes. Bottom-up needs a loop order in which every cell's inputs are filled before the cell. Draw the arrows first, then write the loops.
Longest Palindromic Subsequence
LeetCode 516: Longest Palindromic Subsequence asks for the length of the longest subsequence of s that reads the same in both directions. For "bbbab" it is 4 ("bbbb"). It is a subsequence, so characters may be skipped. The longest palindromic substring, which must be contiguous, is a different problem with a different fast method; see String Algorithms.
The state is dp[i][j], the longest palindromic subsequence inside s[i..j] (both ends inclusive). The decision is about the two end characters:
- If
s[i] == s[j], use them as the outer pair:dp[i][j] = dp[i + 1][j β 1] + 2. This never loses. If a best palindrome usess[i]but nots[j], swap its last character (a copy ofs[i]) fors[j]; if it uses neither end, adding both makes it longer. Either way some best palindrome uses both ends. - Otherwise at least one end is not used:
dp[i][j] = max(dp[i + 1][j], dp[i][j β 1]). - Base cases:
dp[i][i] = 1, and an empty range (i > j) is 0.
Here is what cell X = dp[i][j] reads:
col j-1 col j
row i [ L ] [ X ] L = dp[i][j-1] same row, one left
row i+1 [ D ] [ B ] D = dp[i+1][j-1] row below, one left
B = dp[i+1][j] row below
Every input is in the row below or to the left. So row i + 1 must be finished before row i: rows go bottom to top. Within a row the left cell comes first: columns go left to right.
def longest_pal_subseq(s):
n = len(s)
if n == 0:
return 0
dp = [[0] * n for _ in range(n)] # cells with i > j stay 0: empty ranges
for i in range(n - 1, -1, -1): # rows bottom to top
dp[i][i] = 1
for j in range(i + 1, n): # columns left to right
if s[i] == s[j]:
dp[i][j] = dp[i + 1][j - 1] + 2
else:
dp[i][j] = max(dp[i + 1][j], dp[i][j - 1])
return dp[0][n - 1]
When j = i + 1 and the two characters match, dp[i + 1][j β 1] is dp[i + 1][i], a cell below the diagonal that stays 0. That 0 is the empty middle of a two-letter palindrome.
The finished table for "bbbab", printed from the code (Β· marks unused cells):
| i \ j | 0 b | 1 b | 2 b | 3 a | 4 b |
|---|---|---|---|---|---|
| 0 b | 1 | 2 | 3 | 3 | 4 |
| 1 b | Β· | 1 | 2 | 2 | 3 |
| 2 b | Β· | Β· | 1 | 1 | 3 |
| 3 a | Β· | Β· | Β· | 1 | 1 |
| 4 b | Β· | Β· | Β· | Β· | 1 |
Predict first: a learner writes the outer loop as for i in range(n):, top to bottom, and changes nothing else. What does "bbbab" return?
Check your answer
It returns 2. When row 0 is filled, rows 1 to 4 still hold only zeros, so dp[0][4] = dp[1][3] + 2 = 0 + 2. There is no error and no crash, just a wrong number. On random strings of length 1 to 8 over {a, b, c}, this loop order is wrong about two times in three (1,346 of 2,000 in one run). The memoized version of the same recurrence can't make this mistake, because each call waits for its inputs.
The general rule. Subproblems and their "reads from" arrows form a graph. A bottom-up order exists exactly when that graph has no cycle, and any order that puts every cell after its inputs works. For LPS, filling by range length (every length-2 range, then every length-3 range, and so on) is another valid order, because every input is a shorter range. When a bottom-up version misbehaves, print its table on a tiny input: cells read before they were filled show up as stray zeros.
β οΈ If the arrows form a cycle, it isn't DP. Suppose you may step left or right along a line and want the fewest steps to a target. "Fewest steps from 5" depends on "fewest steps from 6", which depends on "fewest steps from 5". There is no order to fill, and memoized recursion goes around the cycle until the recursion limit stops it. That is a shortest-path problem, solved with BFS and a visited set (Shortest Path Algorithms).
LPS fills about nΒ²/2 cells with O(1) work each, so it takes O(nΒ²) time and O(nΒ²) space for a string of length n. Later sections shrink that space and recover the palindrome itself.
Boss level: Burst Balloons, or choose the last move
In LeetCode 312: Burst Balloons, balloons carry the values nums. Bursting a balloon earns left Γ value Γ right, where left and right are its current neighbours; a missing neighbour counts as 1. Burst them all and maximize the total. [3, 1, 5, 8] gives 167.
Try the natural decision first: which balloon bursts first? After it pops, its two neighbours become adjacent, so the left part's scores depend on what is still on the right. The subproblems are not independent, and the state falls apart.
Now flip it: which balloon in a range bursts last? Put walls at positions left and right, and let k be the last balloon burst between them. Until that moment k stands between the two sides, so balloons on the left only ever neighbour balloons in left β¦ k, and the right side only k β¦ right. The two sides are independent subproblems. When k finally bursts, its neighbours are exactly the walls, so it earns vals[left] Γ vals[k] Γ vals[right].
def max_coins(nums):
vals = [1] + nums + [1] # walls worth 1 at both ends
n = len(vals)
dp = [[0] * n for _ in range(n)] # dp[l][r]: best for balloons strictly between l and r
for gap in range(2, n): # shorter ranges first
for left in range(n - gap):
right = left + gap
for k in range(left + 1, right): # k = the last balloon burst between the walls
dp[left][right] = max(
dp[left][right],
dp[left][k] + vals[left] * vals[k] * vals[right] + dp[k][right],
)
return dp[0][n - 1]
print(max_coins([3, 1, 5, 8])) # 167
The order: dp[left][right] reads dp[left][k] and dp[k][right], and both have a smaller gap, so filling gap by gap works. A gap of 1 has no balloon inside and stays 0. The cost for n balloons is O(nΒ²) ranges Γ O(n) choices of k, so O(nΒ³) time and O(nΒ²) space.
The best order for [3, 1, 5, 8] bursts 1, 5, 3, 8: 3Β·1Β·5 + 3Β·5Β·8 + 1Β·3Β·8 + 1Β·8Β·1 = 15 + 120 + 24 + 8 = 167. A plausible greedy rule, "burst the smallest balloon first", scores only 78.
π‘ When choosing "first" tangles the state, try choosing "last". The last action often splits the problem into parts that were independent all along.
Your turn. A recurrence computes dp[i][j] from dp[i β 1][j] and dp[i][j + 1]. Which way should the row loop run, and which way the column loop?
Check your answer
Rows top to bottom, because row i β 1 must come before row i. Within a row, columns right to left, because column j + 1 must come before column j:
for i in range(rows):
for j in range(cols - 1, -1, -1):
...
The habit of running both loops upward would read dp[i][j + 1] before writing it. Decide from the arrows, not from habit.
Walk it back: recovering the choices
Smell: the problem wants the actual solution (the squares, the palindrome, the trades), not just its size.
A DP table stores the best value of every subproblem, and that is enough to recover the choices. At each state, some option produced the stored value. Follow that option to the next state and repeat until you reach a base case. There are two ways to know which option won:
- Record the winning choice in a second table while you fill the first.
- Re-derive it afterwards: at each state, check which option reproduces the stored value.
import math
def num_squares_with_parts(n):
dp = [0] + [math.inf] * n
choice = [0] * (n + 1) # choice[m] = the square that won at m
for m in range(1, n + 1):
for k in range(1, math.isqrt(m) + 1):
if dp[m - k * k] + 1 < dp[m]: # strict <: keep the first winner
dp[m] = dp[m - k * k] + 1
choice[m] = k * k
parts = []
m = n
while m > 0: # follow the choices down to 0
parts.append(choice[m])
m -= choice[m]
return dp[n], parts
num_squares_with_parts(12) returns (3, [4, 4, 4]). choice[12] is 4 (the winner in the trace table's last row), then choice[8] is 4, then choice[4] is 4. The walk adds only O(number of parts) to the fill. Ties go to the first square tried because of the strict <: at m = 10, the squares 1 and 9 both give 2, so num_squares_with_parts(10) returns (2, [1, 9]).
"DP gives you the value but not the decisions" is a myth; recovering one optimal solution is almost free. The genuine limit is listing all optimal solutions. There can be exponentially many, so that work is as large as its output, and a backtracking walk over the table is the tool.
Your turn. Rebuild the palindrome itself. Assume lps_table(s) is longest_pal_subseq with its last line changed to return dp. Start at (0, n β 1) and at each step let the table tell you which way the optimum went. What do you get for "character"?
Check your answer
def lps_string(s):
if not s:
return ""
dp = lps_table(s)
left, right = [], []
i, j = 0, len(s) - 1
while i <= j:
if i == j: # one middle character
left.append(s[i])
break
if s[i] == s[j]: # the ends form the outer pair
left.append(s[i])
right.append(s[j])
i, j = i + 1, j - 1
elif dp[i + 1][j] >= dp[i][j - 1]: # the optimum drops s[i]
i += 1
else: # the optimum drops s[j]
j -= 1
return "".join(left) + "".join(reversed(right))
"character" gives "carac", length 5, which matches dp[0][8]. "bbbab" gives "bbbb". Every step moves i or j, so the walk is O(n) after the O(nΒ²) fill. Notice that the walk needs the full table. The next section throws most of it away, and this is what you lose.
Shrink the table
Smell: each row or cell reads only a fixed window of earlier ones, but you keep all of them.
Once no future cell will read a row, you can drop it. There are three levels of shrinking.
1. Rolling variables. Fibonacci (LeetCode 509: Fibonacci Number) reads only the previous two values. Plain recursion makes 331,160,281 calls to compute F(40), a table needs 41 cells, and two variables are enough:
def fib(n):
a, b = 0, 1 # F(0), F(1)
for _ in range(n):
a, b = b, a + b # slide the window one step
return a
That is O(n) time and O(1) extra space, and fib(10) is 55. The stock machine already did the same thing: three variables instead of three arrays.
2. Rolling rows. If row i of a 2D table reads only row i + 1 and its own row, keep just two rows. Here is LPS in O(n) space:
def lps_two_rows(s):
n = len(s)
below = [0] * n # row i + 1
for i in range(n - 1, -1, -1):
row = [0] * n # row i, freshly zeroed
row[i] = 1
for j in range(i + 1, n):
if s[i] == s[j]:
row[j] = below[j - 1] + 2
else:
row[j] = max(below[j], row[j - 1])
below = row
return below[n - 1] if n else 0
The time is still O(nΒ²), but only two rows are alive at any moment, so the extra space is O(n).
β οΈ If you recycle the two buffers instead of allocating a fresh row (below, row = row, below), the recycled row still holds values from two rows ago. Assign every cell before anything reads it, and never fold a new value into whatever a recycled cell holds (row[j] = min(row[j], β¦)) unless you reset that cell first.
3. One array, updated in place. When row i reads only row i β 1, you can sometimes overwrite a single array. Then the direction of the inner loop decides what you read: values from the previous row, or values already updated in this one.
Plot twist: the loop direction changes the problem
LeetCode 416: Partition Equal Subset Sum asks whether nums can be split into two groups with equal sums. That is the same as asking whether some subset reaches target = sum(nums) // 2. The 2D state is "using the first i numbers, can I make sum s?" Each number is either skipped (the row above, same s) or used once (the row above, s β x). In one array:
def can_make(nums, target):
can = [True] + [False] * target # can[s]: some subset of the numbers so far sums to s
for x in nums:
for s in range(target, x - 1, -1): # DOWNWARD: can[s - x] is still last row's value
if can[s - x]:
can[s] = True
return can[target]
def can_partition(nums):
total = sum(nums)
return total % 2 == 0 and can_make(nums, total // 2)
can_partition([1, 5, 11, 5]) is True (11 = 1 + 5 + 5), and can_partition([1, 2, 3, 5]) is False because the total, 11, is odd. For n numbers the cost is O(n Β· target) time and O(target) extra space.
Predict first: flip the inner loop to range(x, target + 1), upward. What does can_make([3], 6) return?
Check your answer
True, which is wrong. At s = 3 the loop sets can[3] from can[0]. Then at s = 6 it reads can[3], which was set in this same pass, so the 3 gets used twice. Going upward, can[s β x] may already include x. Going downward it can't, because the smaller indices haven't been touched yet in this pass.
Here is a trace with nums = [2, 3], target = 6, showing can after each number (T = reachable, . = not):
| after number | downward (each number once) | upward (reuse allowed) |
|---|---|---|
| 2 | T.T.... |
T.T.T.T |
| 3 | T.TT.T. |
T.TTTTT |
Downward gives the sums 0, 2, 3 and 5: exactly the subset sums of [2, 3]. Upward also reaches 4 = 2 + 2 and 6 = 2 + 2 + 2. So the upward loop is the right one when every number may be reused, like unlimited coins, and the downward loop is right when each number may be used at most once. Same code, different problem.
The 0/1 knapsack, where each item has a weight and a value, has the same shape, with max in place of or. 2D DP & Grid Problems works through it, along with rolling rows on grids.
What shrinking costs you. A rolled table can't be walked back directly, because the rows the walk needs are gone. (Advanced methods such as Hirschberg's algorithm recompute parts of the table to recover a solution in linear space, at extra cost.) It is also harder to print and debug. Get the full table right and tested first, then shrink it.
Your turn. Turn can_make into count_subsets(nums, target), the number of subsets that sum to target. What changes and what stays? Predict count_subsets([1, 2, 3, 3], 6).
Check your answer
def count_subsets(nums, target):
ways = [1] + [0] * target # one subset (the empty one) sums to 0
for x in nums:
for s in range(target, x - 1, -1): # still downward: each number once
ways[s] += ways[s - x]
return ways[target]
Booleans become counts, True at index 0 becomes 1, and "set to True" becomes +=. The direction stays downward because each number is still used at most once. The answer is 3: 1 + 2 + the first 3, 1 + 2 + the second 3, and 3 + 3. Equal values at different positions make different subsets. A zero is handled too: for x = 0 the loop runs all the way down to s = 0 and doubles every count, once without the zero and once with it.
DP or greedy?
Smell: each step offers several choices, and one of them looks obviously best.
A greedy algorithm takes the obvious choice and never looks back. DP keeps every choice open and lets the table decide. Greedy is faster and simpler, and it is correct when the greedy-choice property holds: some optimal solution starts with the greedy choice. Both approaches need optimal substructure; greedy additionally needs that one choice to be safe. Don't use a greedy rule until you can prove the property. One that passes the examples but has no proof is a guess.
You met one failure in the hook: 12 = 9 + 1 + 1 + 1 against 4 + 4 + 4. Coins behave the same way. With coins [1, 3, 4] and amount 6, largest-first takes 4 + 1 + 1, three coins, while 3 + 3 uses two. With US coins [1, 5, 10, 25], largest-first happens to be optimal; it matched the DP answer for every amount below 2,000. That is a property of that particular coin system, not of coins in general. The full Coin Change DP is in 1D DP Problems.
Predict first: coins [1, 7, 10], amount 14. How many coins does largest-first use, and how many does DP find?
Check your answer
Largest-first takes 10 + 1 + 1 + 1 + 1, five coins. DP finds 7 + 7, two coins. The big coin blocks the pair that works.
Same meetings, two contracts
You have one room and a list of meetings, each with a start and an end time.
Attend as many meetings as possible. Greedy works. Sort by end time, then take every meeting that starts no earlier than the last one you took ends. The proof is an exchange argument. Take any optimal schedule and swap its first meeting for the meeting that ends earliest overall. The new first meeting ends no later, so everything after it still fits. The schedule is still optimal, and now it starts with the greedy choice; repeat the argument on the rest. The cost is O(n log n), for the sort. LeetCode 435: Non-overlapping Intervals asks the mirror question, the fewest meetings to remove, and the same greedy answers it.
Earn as much as possible. Now each meeting pays a different amount, as in LeetCode 1235: Maximum Profit in Job Scheduling. The exchange argument breaks, because the earliest-ending meeting that still fits may pay 40 while the meeting it blocks pays 70. Take the jobs (start, end, profit) = (1, 3, 50), (2, 4, 10), (3, 5, 40) and (3, 6, 70). Earliest-end greedy takes 50 and then 40, for 90. The best is 50 + 70 = 120.
So use DP. Sort the jobs by end time. The state is best[i], the most you can earn using only the first i jobs in that order. The decision is about job i: skip it (best[i β 1]), or take it and add the best you can earn from jobs that end by its start. Because the jobs are sorted by end, "jobs that end by time s" is a prefix of the list, and binary search finds its length.
import bisect
def job_scheduling(start, end, profit):
jobs = sorted(zip(end, start, profit)) # by end time
ends = [e for e, _, _ in jobs]
best = [0] * (len(jobs) + 1) # best[i]: using only the first i jobs
for i, (e, s, p) in enumerate(jobs, 1):
j = bisect.bisect_right(ends, s, 0, i - 1) # how many earlier jobs end by s
best[i] = max(best[i - 1], best[j] + p) # skip job i, or take it
return best[-1]
print(job_scheduling([1, 2, 3, 3], [3, 4, 5, 6], [50, 10, 40, 70])) # 120
A job may start at the exact time another ends, so bisect_right counts an end equal to s as compatible. For n jobs the cost is O(n log n) time (the sort plus n binary searches) and O(n) extra space. Sorting first is itself a state-design move: it turned "every set of compatible earlier jobs" into "a prefix", which one index can describe.
When the table is too big
"States Γ work" is fast only when the number of states is small. can_make takes O(n Β· target) time. That is polynomial in the value of target, not in the size of the input: a single 10-digit target means a table of about 10ΒΉβ° cells. Running time like this is called pseudo-polynomial. When the values are huge but there are few numbers, count the other way: 20 numbers have only 2Β²β°, about a million, subsets.
DP also doesn't help when:
- nothing repeats, as in merge sort or binary search, so a cache never gets a hit;
- the task is to list every solution (all subsets, all orderings). The output itself is exponential, so use backtracking (Advanced Recursion & Backtracking);
- the subproblems depend on each other in a cycle, which calls for a graph search such as BFS instead.
Your turn. DP, greedy, or neither?
- Attend as many non-overlapping talks as possible from a conference schedule.
- Pay 12 with as few coins as possible from
[1, 6, 10]. - Print every way to pay 12 with coins from
[1, 6, 10].
Check your answer
- Greedy: sort by end time and take every talk that fits. The exchange argument proves it.
- DP: largest-first pays 10 + 1 + 1, three coins, while 6 + 6 uses two. No greedy-choice property holds for this coin system.
- Neither: this is listing, not optimizing, so the output size sets the cost. Use backtracking. A DP table can count the ways first if you need the count.
Final round: no label on the problem
Real problems don't say "DP". For each one below, decide whether it is DP and design the state, or whether something simpler is enough. Then write it.
Challenge 1: Minimum Cost For Tickets
LeetCode 983: Minimum Cost For Tickets. You travel on the days in days (strictly increasing, each between 1 and 365). A 1-day pass costs costs[0], a 7-day pass costs[1] and a 30-day pass costs[2]; a 7-day pass bought on day d covers days d to d + 6. Find the minimum total cost. With days = [1, 4, 6, 7, 8, 20] and costs = [2, 7, 15] the answer is 11.
Before peeking, answer these:
- Does a rule such as "buy a 7-day pass whenever at least four trips fall in the next week" come with a proof?
- What is the state? What does day d need to know about the past?
- What happens on a day you don't travel?
Hint
Let dp[d] be the cheapest way to cover every travel day up to and including day d. Look at the pass that covers day d, and assume it ends on day d. A pass that runs past day d covers no more of the days up to d than the same pass shifted to end on d.
Check your answer
def mincost_tickets(days, costs):
travel = set(days)
last = days[-1]
dp = [0] * (last + 1) # dp[d]: cover every travel day <= d
for d in range(1, last + 1):
if d not in travel:
dp[d] = dp[d - 1] # nothing to cover today
continue
dp[d] = min(
dp[d - 1] + costs[0], # a 1-day pass for day d
dp[max(0, d - 7)] + costs[1], # a 7-day pass for days d-6 .. d
dp[max(0, d - 30)] + costs[2], # a 30-day pass for days d-29 .. d
)
return dp[last]
Rules like the one in question 1 depend on the prices: with costs = [2, 20, 30], four trips in one week are cheaper as four 1-day passes (8) than as a 7-day pass (20). So try every pass on every travel day: that is DP. The example costs 11: a 1-day pass for day 1, a 7-day pass covering days 4 to 10, and a 1-day pass for day 20 (2 + 7 + 2). A non-travel day costs nothing extra, so it copies the day before. The max(0, β¦) lets a pass reach back before day 1. For D = the last travel day (at most 365) the cost is O(D) time and O(D) space, and each day does constant work.
Challenge 2: Jump Game
LeetCode 55: Jump Game. You start at index 0, and nums[i] is the longest jump allowed from index i. Can you reach the last index? [2, 3, 1, 1, 4] β True, and [3, 2, 1, 0, 4] β False. There are up to 10β΄ numbers.
- A table
reach[i]("can I get to index i?") works. What does it cost? - At any moment, what shape does the set of reachable indices have?
- So how many numbers do you actually need to remember?
Check your answer
The table costs O(n Γ longest jump), up to O(nΒ²). But the reachable indices always form a prefix. If index i is reachable, the jump that landed there started at some j < i and could have stopped anywhere from j to i; the same argument covers everything before j. So one number describes the whole set: the furthest reachable index.
def can_jump(nums):
reach = 0 # every index <= reach is reachable
for i, step in enumerate(nums):
if i > reach:
return False # stuck before index i
reach = max(reach, i + step)
return True
That is a DP whose state collapsed into one running summary, the notebook move from the foundation lesson: O(n) time, O(1) extra space. Note that can_jump([0]) is True, because you already stand on the last index.
Challenge 3: Target Sum
LeetCode 494: Target Sum. Put + or β in front of every number in nums so that the total equals target, and count the ways. With nums = [1, 1, 1, 1, 1] and target = 3 there are 5 ways.
- Brute force tries 2βΏ sign patterns. What repeats?
- Let P be the numbers that get
+. What mustsum(P)equal? - Which earlier function does that turn this into, and which loop direction does it need?
- What if
total + targetis odd, or|target| > total?
Check your answer
The numbers with + minus the rest must equal target: sum(P) β (total β sum(P)) = target, so sum(P) = (total + target) / 2. Counting sign patterns is the same as counting subsets P with that sum: count_subsets from the shrinking section, with its downward loop because each number is used once.
def find_target_sum_ways(nums, target):
total = sum(nums)
if abs(target) > total or (total + target) % 2:
return 0 # no subset can have that sum
goal = (total + target) // 2
ways = [1] + [0] * goal
for x in nums:
for s in range(goal, x - 1, -1): # downward: each number once
ways[s] += ways[s - x]
return ways[goal]
For the example, total = 5, so goal = 4, and there are five ways to choose which four 1s get +. An odd total + target or |target| > total makes the goal impossible, so the answer is 0. This matches a brute force over every sign pattern on 3,000 random inputs, zeros and negative targets included. For n numbers the cost is O(n Β· goal) time and O(goal) extra space.
Cheat sheet
| When you see⦠| Reach for | Key rule |
|---|---|---|
| a try-every-choice recursion whose calls repeat | memoize by state | the key holds everything the answer depends on |
| recursion thousands of levels deep, in Python | a bottom-up table | each cell's inputs are filled before the cell |
| a fact the rules check that the state doesn't record | another mode or dimension | merge states that always allow the same moves |
| a "first move" that tangles the two sides | decide the last move | the last action splits independent parts |
| a demand for the actual solution | store the choices and walk back | keep the full table until you have walked it |
| rows that read only the previous row | rolling rows, or one array | one array: downward = each item once, upward = reuse |
| one choice that looks obviously best | greedy, only with a proof | no proof, no greedy: use DP |
| values up to 10βΉ used as table indices | count the states first | pseudo-polynomial tables can be too big |
Before moving on, pick one problem from this lesson, such as Perfect Squares, the cooldown stock or LPS, and rebuild it from a blank editor. Say the state in one sentence. Derive the recurrence from one decision. Name the base cases and starting values. Draw the arrows and pick the loop order. Then test it against the brute force on small random inputs. If you can also say which changed rule would break it (items reusable, a cooldown added, "first" instead of "last"), you are ready for the problem families.
Next: 1D DP Problems for the one-index family (Climbing Stairs, House Robber, Coin Change, Word Break, Decode Ways, Longest Increasing Subsequence), and 2D DP & Grid Problems for grids and two-string DP (Unique Paths, Longest Common Subsequence, Edit Distance, knapsack).