DP — A Masterclass in Thinking
A deep dive, not a cheat sheet — built to teach how to discover DP solutions on your own. A repeatable 7-step engine (choice → recursion → state → memoize → tabulate), ten pattern families, and interactive widgets: an animated coin-change / LCS / grid table you step through, and a fib recursion tree with a memoization toggle.
Assumes you know
After this you should be able to
- Explain the core idea in DP — A Masterclass in Thinking
- Apply DP — A Masterclass in Thinking in a representative exercise
Most DP notes hand you the finished recurrence and hope it sticks. This one does the opposite: it teaches the internal monologue that produces the recurrence — how experienced solvers recognize DP, invent the state, and derive the transition on a problem they've never seen. The mechanics are all here too, so this can be your only DP resource.
How to actually use this page#
This is long on purpose — it's a course, not a cheat sheet (though the cheat sheets are at the bottom). Read it like this:
Don't skim the widgets#
The animated tables and recursion trees are where intuition actually forms. Step through them. Predict the next cell before clicking.
Answer before revealing#
Every purple “pause & think” box is a real question. Commit to an answer in your head first. Reveals only help if you've guessed.
Return to Part 4 forever#
The Discovery Engine is the reusable procedure. Everything else is practice for it. Re-run it on every new problem you meet.
The map#
Ten parts, front-loaded toward thinking and back-loaded with reference. Jump around with the rail on the right (or the "Sections" button on mobile), but the first read should be in order.
Mindset#
Before a single line of code: what DP is, and why your brain fights it. Fix the mental model here and the mechanics later feel obvious instead of magical.
01 · What DP actually is#
Forget "optimal substructure" and "overlapping subproblems" for sixty seconds. Those are true, but they're the diagnosis after the fact, not the feeling. Here is the feeling:
Everything else — states, transitions, tables, memoization — is just bookkeeping in service of that one refusal. Once you believe that DP is "brute force plus a memory," the mystique drops away. You're not inventing a clever formula out of thin air. You're writing the obvious brute-force search, then catching it repeating itself.
The three mental models, ranked by usefulness#
Cached recursion
Write the naive recursive brute force. Notice it recomputes. Bolt on a cache. This is how you'll actually derive solutions.
A filled table
A grid where each cell is a sub-answer, and each cell is computed from a few earlier cells. Great for seeing and optimizing, once you have the recurrence.
A DAG of subproblems
Subproblems are nodes; "A needs B" is an edge. DP = evaluating this graph in dependency order. The deepest, most portable model.
Beginners are taught Model B first (fill this table with this formula) and it's exactly backwards. The table is the destination. Model A — cached recursion — is the road, and it's the road we'll drive the whole way.
Imagine solving a giant crossword where many clues share letters. A brute-forcer re-derives the same crossing letter every time they revisit a square. A DP-thinker writes each letter in once and just reads it thereafter. DP isn't a different puzzle — it's the discipline of writing your answers down so the puzzle doesn't re-ask.
Why the "overlapping subproblems" phrase matters after all#
Now we can earn the textbook words. Two properties make DP possible and worth it:
- Optimal substructure — the answer to the big problem can be built from answers to smaller versions of the same problem. (If it can't, caching buys you nothing — there's no smaller answer to reuse.)
- Overlapping subproblems — those smaller versions recur, many times, across the search. (If every subproblem is unique, there's nothing to cache — that's just divide & conquer, e.g. merge sort.)
Hold both in mind as a test you run, not a definition you recite. We'll turn them into concrete questions in Part II.
Pause & think: is merge sort dynamic programming? reveal
It has optimal substructure (a sorted whole is built from sorted halves) but no overlapping subproblems — the left half and right half are disjoint, never re-solved. So caching would store answers that are never looked up again. Merge sort is divide & conquer, not DP. This single distinction — do subproblems repeat? — is the hinge the whole page turns on.
02 · Why humans struggle with DP#
You are not bad at DP. You are missing a process, and you've been shown products. Editorials present the final recurrence the way a magician presents the rabbit — fully formed, no sleeves shown. Of course it feels like magic; the derivation was edited out. Let's name the specific gaps so we can close them one by one.
The curse of the clean answer#
A finished dp[i][j] = ... looks inevitable, which makes your own fumbling feel like failure. In reality the author tried three wrong states first and deleted them. You're comparing your bloopers reel to their highlight reel. Fix: this page shows the deleted attempts (Part IV) so the real, messy path is visible.
Jumping straight to the table#
Asked "what's the DP?", beginners try to guess the 2D array and its formula directly — an enormous leap. Experts never leap. They write a recursion, then mechanically read the state and transition off it. Fix: always recursion-first (Part III).
Not trusting the recursive leap of faith#
Defining solve(i) and assuming it already works for smaller i feels illegal, like cheating. It's the single most important habit in DP. Fix: we drill the "assume the smaller answer exists" move until it's reflexive.
No vocabulary for "what should the state be?"#
The scariest question feels unanswerable because you're guessing. But state has a mechanical source: the arguments your recursive function needs to describe a subproblem. Fix: Part V reframes state as "what must I remember to continue?"
Pattern-matching too early — or too late#
Some memorize 200 problems and still freeze on #201 (no transferable process). Others refuse patterns and re-derive everything from zero every time (too slow). Fix: patterns as recognition shortcuts layered on top of a process, not a replacement for it.
"How did anyone think of this?" almost always has the same answer: they didn't think of the final formula. They thought of the brute force, then removed the waste. You already know how to brute-force. So you're much closer than you feel — you're one disciplined procedure away.
Recognition#
The first real skill: looking at a fresh problem and suspecting DP — or confidently ruling it out. Get this wrong and you'll force DP where greedy wins, or grind brute force where DP was waiting.
03 · Recognizing DP in the wild#
You rarely get told "this is a DP problem." You have to smell it. Here are the smells — the surface features that should make your DP-sense tingle. None is proof; together they're strong evidence.
The recognition signals#
"Count the ways" / "how many"
Counting distinct paths, sequences, decodings, partitions. Counting almost always decomposes into sums of sub-counts.
"Max / min / longest / shortest"
Optimization over a huge set of choices, where each choice constrains the next. The word "optimal" plus "sequence of decisions."
"Is it possible to…"
Feasibility (can we make sum S? can we partition?) — a boolean DP where cells hold reachability.
Sequential decisions
At each step you pick from options (take/skip, which coin, which cut), and picks interact. A chain of coupled choices.
Small number of "handles"
The future depends only on a few summarizing quantities (an index, a remaining capacity), not the entire history. That's a small state.
Brute force re-solves the same thing
The definitive test. Sketch the recursion; if two different paths call solve(same args), DP applies. Everything above just makes this likely.
Signals get you to suspect. To confirm, do the honest thing: write the brute-force recursion in your head and ask whether any subproblem repeats. If yes → DP. If every call is unique → not DP (it's plain recursion / divide & conquer / backtracking). This test never lies; the surface signals sometimes do.
The recognition decision tree#
Run a new problem down this, top to bottom. The first "no" that sends you off DP is your answer.
Are you asked for a max / min / count / feasibility over a set of choices? (Not "return the array sorted", not "find one element".)
Can a greedy rule (always take the locally best choice) provably give the global optimum?
Does the answer decompose into answers of smaller instances of the same problem? (Optimal substructure.)
solve(smaller) and build up. Half the battle.In the brute-force recursion, do subproblems repeat? (Overlapping subproblems.)
solve() on its arguments and you're done. This is the confirmation.DP versus its look-alikes#
Half of "recognizing DP" is not confusing it with a neighbor. Here's how each sibling differs, and the one question that separates it from DP.
| Technique | Core idea | How to tell it apart from DP |
|---|---|---|
| Greedy | Take the locally best choice, never reconsider. | Greedy commits and never revisits; DP compares the outcomes of multiple choices. Ask: "can a locally worse choice lead to a globally better result?" If yes → DP. |
| Divide & conquer | Split into independent subproblems, combine. | Same recursion shape as DP but subproblems don't overlap. No cache would ever hit. Ask: "do two branches ever call the same subproblem?" |
| Backtracking | Enumerate all valid configurations, prune dead ends. | Backtracking constructs and outputs actual solutions; DP counts/optimizes over them without listing them. If you must return every arrangement, it's backtracking. If subproblems repeat, add memo → it becomes DP. |
| BFS / DFS on graphs | Traverse an explicit graph of nodes/edges. | DP is a traversal of an implicit DAG of subproblems. If edges have no weights and you want shortest hops → BFS. If the "graph" has cycles, plain DP breaks (order undefined). |
| Sliding window | Maintain an answer over a moving contiguous range in O(1) per move. | Window works when the answer updates incrementally with cheap add/remove. If each position needs to weigh many earlier positions, not just window ends → DP. |
| Two pointers | Two indices sweep a sorted/structured array. | A greedy-family sweep with O(1) state. If you'd need to remember more than "where the two pointers are" → DP. |
| Binary search | Halve a monotonic search space. | Often wraps a DP ("can we do it with budget X?" answered by a feasibility DP, X binary-searched). Not a competitor — frequently a partner. |
Coin Change is the classic trap. With US coins (1,5,10,25), greedily taking the largest coin ≤ remaining works. With coins like {1,3,4} making 6, greedy takes 4+1+1 = 3 coins but the optimum is 3+3 = 2 coins. Greedy fails because a locally worse coin (3 instead of 4) enables a better whole. The moment you can construct a counterexample to greedy, DP is the move. We prove this concretely in Part III.
For each, decide DP / greedy / D&C / other before revealing. Use the decision tree.
“Max sum of non-adjacent house values” (House Robber)
DP. Optimization + coupled sequential choices (robbing a house forbids its neighbor). Greedy "take every richest house" fails on [2, 1, 1, 2]. Subproblems rob(i) repeat. → 1D DP.
“Minimum number of platforms for trains given arrival/departure times”
Greedy (sort events, sweep). Locally optimal counting of overlaps is provably globally optimal — no future-weighing needed. Not DP.
“Number of distinct ways to climb n stairs taking 1 or 2 steps”
DP. "Count the ways" + ways(n) = ways(n-1) + ways(n-2), and those overlap heavily. The canonical starter DP (it's Fibonacci in disguise).
“Sort an array of 10 million integers”
D&C / other. Merge/quick sort: optimal substructure but disjoint subproblems, nothing repeats. No DP.
The Discovery Engine#
This is the chapter to re-read forever. It's a fixed, repeatable procedure that converts “I have no idea” into a working recurrence. Memorize the steps, not the answers.
04 · From blank stare to recurrence#
Here is the pipeline. Every DP solution you will ever write comes out of it. Experienced solvers run it so fast it looks like intuition — but it's just these seven moves, always in this order.
Describe a choice at one step#
Zoom into a single decision point. "Right here, what are my options?" Take it or leave it? Which coin? Where to cut? Don't solve anything yet — just enumerate the branch.
Write brute force as recursion#
Define solve(...) = the answer to the rest of the problem from this point. Make each choice, recurse on what's left, combine. Take the leap of faith: assume the recursive call already returns the right answer.
Find the repeated work#
Sketch the call tree. Do two different choice-paths ever reach solve(same args)? If yes, you've found overlapping subproblems — DP is confirmed and the payoff is real.
Read the state off the arguments#
The arguments that solve needs to describe a subproblem are your state. Nothing more, nothing less. This is where state "comes from" — you don't invent it, you extract it.
Memoize#
Add a cache keyed by the state. First look-up computes; every repeat is instant. You now have a correct, efficient top-down DP with almost no new thinking.
(Optional) Turn it into a table#
Flip recursion into iteration: allocate the table, seed base cases, fill in dependency order. Same recurrence, bottom-up. Do this when you want speed or space wins.
(Optional) Shrink the space#
If dp[i] only reads dp[i-1], you don't need the whole array — keep a rolling window. Justified precisely because of which cells the transition touches.
Seeing the repeated work with your own eyes#
Abstract talk about "overlapping subproblems" convinces no one. Let's see it. Below is the actual call tree of naïve recursive Fibonacci, fib(n) = fib(n-1) + fib(n-2). Flip on memoization and watch how much of the tree was pure waste — every greyed, dashed node is a call whose answer we'd already computed.
Naïvely, fib(5) makes 15 calls; with memoization only 6 distinct subproblems are ever computed (one per value 0–5) and the rest are cache hits. That gap is exponential vs linear — O(2ⁿ) → O(n) — from a one-line change. This is why DP feels like a superpower and is actually just "don't recompute."
Pause & think: before memoizing, why is naïve fib so slow — where exactly is the waste? reveal
Every fib(k) for small k is recomputed an exponential number of times — fib(1) alone is evaluated 5 times in fib(5), and that multiplies as n grows. The subtrees are identical, re-solved from scratch on each visit. Memoization computes each subtree once and reuses it — collapsing the fat tree into a thin path.
A full worked derivation: Coin Change (minimum coins)#
Let's run the entire engine on one problem, out loud, mistakes included. Problem: given coins {1, 3, 4} (unlimited of each), make amount 6 using the fewest coins.
Detour: why greedy fails (and how you'd notice)#
Your first instinct is greedy: always grab the biggest coin that fits. Let's try it and watch it break — seeing greedy fail is how you earn the right to reach for DP.
The counterexample is the tell. When a locally-worse choice can win globally, you cannot commit greedily — you must compare futures. Comparing futures is DP.
Step 1 — 2 · Describe the choice, write the recursion#
At any moment I owe some amount. My single choice: which coin do I use next? If I use a coin c ≤ amount, I've spent one coin and now owe amount - c — a smaller instance of the exact same problem. So:
// solve(amount) = fewest coins to make `amount`, or INF if impossible.
// Leap of faith: assume solve(smaller) is already correct.
int solve(int amount, int[] coins) {
if (amount == 0) return 0; // base case: nothing owed, 0 coins
if (amount < 0) return INF; // overshot — this path is invalid
int best = INF;
for (int c : coins) {
int sub = solve(amount - c, coins); // solve the rest
if (sub != INF) best = Math.min(best, 1 + sub); // +1 for coin c
}
return best;
}That's the whole brute force — and notice we derived it from "which coin next?", not from a formula. The recurrence is just the loop: solve(a) = min over coins c of ( 1 + solve(a − c) ).
Step 3 · Spot the repeated work#
Does solve(2) get called more than once? Absolutely: the path 6→4→2 reaches it, and so does 6→3→2(via −1)… and dozens more as the tree fans out. Same argument, recomputed from scratch each time. Overlapping subproblems confirmed → memoize.
Step 4 · Read the state#
What argument does solve vary over? Just amount. So the state is a single integer, 0…6, and dp[a] = fewest coins to make a. One dimension. We didn't choose that — the recursion's signature told us.
Step 5 · Memoize (top-down)#
Integer[] memo; // memo[a] = cached answer for amount a
int solve(int amount, int[] coins) {
if (amount == 0) return 0;
if (amount < 0) return INF;
if (memo[amount] != null) return memo[amount]; // cache hit — the whole point
int best = INF;
for (int c : coins) if (amount - c >= 0) {
int sub = solve(amount - c, coins);
if (sub != INF) best = Math.min(best, 1 + sub);
}
return memo[amount] = best; // cache before returning
}Step 6 · Tabulate (bottom-up), and watch it fill#
The table version fills dp[0], dp[1], …, dp[6] in order — each cell using only smaller, already-computed cells. Step through it below. Before each step, predict the value. The dependencies it reads (dp[a−1], dp[a−3], dp[a−4]) light up in blue.
// Bottom-up: same recurrence, now as a loop.
int coinChange(int[] coins, int amount) {
int INF = amount + 1; // "impossible" sentinel > any real answer
int[] dp = new int[amount + 1];
Arrays.fill(dp, INF);
dp[0] = 0; // base case: 0 coins make amount 0
for (int a = 1; a <= amount; a++) for (int c : coins) if (c <= a) dp[a] = Math.min(dp[a], 1 + dp[a - c]); // read smaller subproblems
return dp[amount] > amount ? -1 : dp[amount];
}We never "thought up" the formula dp[a] = min(dp[a], 1 + dp[a−c]). We wrote "which coin next?", turned it into recursion, noticed repeats, cached, then unrolled the cache into a loop. The recurrence is a by-product of the process, not the starting point. Run this same seven-step engine on any DP and it will produce the answer.
Problem: you can climb 1 or 2 steps at a time; how many distinct ways to reach step n? Run the engine yourself before revealing each step.
Step 1–2: the choice & recursion
At step n the last move was either a 1-step (from n−1) or a 2-step (from n−2). Those are the only two ways to arrive. So ways(n) = ways(n−1) + ways(n−2), with ways(0)=ways(1)=1. (It's Fibonacci — but you derived it from "how could I have arrived here?", which is the transferable move.)
Step 3–4: repeats & state
ways(n−2) is reached both directly and via ways(n−1) → overlaps. State = a single index n. 1D DP.
Step 5–7: memoize → tabulate → O(1) space
int climbStairs(int n) {
int a = 1, b = 1; // ways(0), ways(1)
for (int i = 2; i <= n; i++) { // dp[i] only needs the last two → O(1) space
int c = a + b;
a = b; b = c;
}
return b;
}Because dp[i] reads only dp[i−1] and dp[i−2], two variables replace the whole array — that's Step 7 in action.
Another full derivation: House Robber (with a dead end)#
Problem: houses in a row with values [2, 7, 9, 3, 1]; can't rob adjacent houses; maximize loot. Let's run the engine and — crucially — hit a dead end first, because that's what really happens.
Step 1 — 2 · The choice and recursion#
At house i: rob it or skip it. If I rob it, I earn nums[i] but can't rob i−1. If I skip it, I move to i−1 with no earnings. So the recursion is:
int solve(int i, int[] nums) {
if (i < 0) return 0; // no houses left
int rob = nums[i] + solve(i - 2, nums); // rob i, skip i-1
int skip = solve(i - 1, nums); // skip i
return Math.max(rob, skip);
}Step 3 · Spot the repeated work#
Does solve(2) repeat? Yes — the path 4→2 reaches it (skip house 3), and so does 3→2 (skip house 4, then... wait, solve(3) calls solve(2) via skip). Same argument, recomputed. Overlaps confirmed → memoize.
Step 4 · Read the state — and hit the dead end#
Here's where beginners get stuck. They define dp[i] = "maximum loot from houses 0..i" and write:
dp[i] = max(nums[i] + dp[i-2], dp[i-1]). This is actually correct for House Robber! But suppose I tried dp[i] = nums[i] + dp[i-2] only (always rob every other house). That's greedy, not DP — I'm not comparing rob vs skip. The mistake is skipping the choice enumeration in Step 1 and jumping to one branch.dp[i] = max(nums[i] + dp[i-2], dp[i-1]). The max over both choices is what makes it DP, not greedy. The state dp[i] = best loot considering houses 0..i is sufficient because the only constraint ("can't rob adjacent") is captured by the i−2 skip in the rob branch.The real dead end for House Robber comes when you add a cooldown (Stock with Cooldown pattern): then dp[i] alone is insufficient — you need to know whether you're holding. We'll see that in §05's hidden-state examples. The takeaway: even a "simple" 1D DP has a wrong version that omits a choice. Always enumerate all options at the decision point.
Step 5 — 7 · Memoize, tabulate, shrink#
int rob(int[] nums) {
int prev2 = 0, prev1 = 0; // dp[i-2], dp[i-1]
for (int x : nums) {
int cur = Math.max(prev1, x + prev2); // skip vs rob
prev2 = prev1; prev1 = cur; // slide the window
}
return prev1;
}Because dp[i] reads only dp[i−1] and dp[i−2], two variables replace the whole array. O(n) time, O(1) space — the full pipeline, start to finish.
A third derivation: Word Break (greedy fails, then DP)#
Problem: given a string s = "leetcode" and a dictionary ["leet", "code"], can you segment s into space-separated dictionary words? Let's run the engine.
Detour: why greedy fails#
s = "aaaaaaa", dict ["aaaa", "aaa"]. Greedy takes "aaaa" first, leaving "aaa" — works. But try s = "aaaa", dict ["aaa", "aa"]: greedy takes "aaa", leaving "a" — fails! The better choice was "aa" + "aa".Step 1 — 2 · The choice and recursion#
At position i in the string: which dictionary word starts here? If word w matches s[i..i+len(w)), then the rest is solve(i + len(w)). If any choice leads to the end, the answer is true.
boolean solve(int i, String s, Set<String> dict) {
if (i == s.length()) return true; // reached the end — segmented!
for (String w : dict) {
if (i + w.length() <= s.length() && s.substring(i, i + w.length()).equals(w)) if (solve(i + w.length(), s, dict)) // leap of faith
return true;
}
return false;
}Step 3 · Spot the repeated work#
Does solve(j) repeat for the same j via different word choices? Absolutely — two different words might land at the same position j from different starting points. Same suffix, recomputed. Overlaps confirmed → memoize.
Step 4 · Read the state#
The only argument is i (position in the string). State = a single index 0..n. dp[i] = can we segment s[i..]? One dimension, boolean DP.
Step 5 — 6 · Memoize and tabulate#
boolean wordBreak(String s, List<String> wordDict) {
Set<String> dict = new HashSet<>(wordDict);
boolean[] dp = new boolean[s.length() + 1];
dp[s.length()] = true; // base: empty suffix is segmentable
for (int i = s.length() - 1; i >= 0; i--) // fill right-to-left
for (String w : dict) if (i + w.length() <= s.length() && s.substring(i, i + w.length()).equals(w) && dp[i + w.length()]) // the remainder is solvable
{ dp[i] = true; break; }
return dp[0];
}Word Break is feasibility DP — combine with || (OR). The base case dp[n] = true is the identity for OR: "the empty suffix is always segmentable (by doing nothing)." The fill order is right-to-left because dp[i] reads dp[i + len] (future positions). Getting the fill direction backwards is the #1 bug in this family.
The mechanics, demystified#
Now the four sub-skills the engine leans on — state, transition, base cases, and the memo/table/space trio — each taught as a derivation, never as a rule to memorize.
05 · State design#
State is the scariest word in DP and the most over-mystified. Here is the whole truth:
Where dimensions come from#
Each independent "fact you must carry" is one dimension. You add a dimension only when the future genuinely depends on that fact.
| What the future depends on | Dimension you add | Example |
|---|---|---|
| How far along a sequence you are | an index i | House Robber: dp[i] |
| Position in two sequences at once | two indices i, j | LCS / Edit Distance: dp[i][j] |
| A remaining budget / capacity | a capacity w | Knapsack: dp[i][w] |
| Which discrete "mode" you're in | a small enum k | Stock with cooldown: dp[i][held?] |
| Which subset is used (n ≤ ~20) | a bitmask | TSP: dp[mask][last] |
| A running position in a grid | row, col | Unique Paths: dp[r][c] |
The three state diseases (and their cures)#
Wasted state
You carried a fact the future doesn't actually use → the table is needlessly huge (or infinite). Cure: for each dimension ask "does the transition read this?" If not, drop it.
Hidden state
Two subproblems look identical by your key but behave differently → wrong answers. The cache collides. Cure: find the fact that makes them differ and add it as a dimension.
State explosion
State is correct but the product of dimensions is astronomically large → TLE/MLE. Cure: compress (bitmask, hashing sparse states) or find a smaller equivalent state.
"Best Time to Buy/Sell Stock with Cooldown." If your state is just dp[i] = best profit up to day i, you get wrong answers — because the future depends on something i alone doesn't capture: do I currently hold a share? Two days with the same index but different holdings lead to different futures. The cure is to add that fact: dp[i][held ∈ {0,1}]. The symptom of hidden state is always the same — your recurrence "feels right" but is off, because two truly-different situations share a cache key.
How to know your state is right — the sufficiency test#
A state is correct iff it satisfies the Markov property for your problem: given the state, the optimal continuation does not depend on how you got there. Concretely, run this check:
- Take two different histories that land in the same state.
- Ask: from here on, would the best decisions differ?
- If they'd differ → state is missing a dimension (hidden state). If they're always identical → your state is sufficient.
Pause & think: for Longest Increasing Subsequence, is dp[i] = "LIS length in the whole array" a valid state? reveal
No — that's not a subproblem, it's the answer with no smaller pieces to build from. The useful state is dp[i] = "length of the longest increasing subsequence ending exactly at index i." That extra constraint ("ending at i") is what lets you extend: dp[i] = 1 + max(dp[j]) over j < i with a[j] < a[i]. Lesson: a good state often needs a pinning constraint ("ending at", "starting from", "using the first i items") so that subproblems compose.
Name the state (dimensions + meaning) for each. Reveal to check.
0/1 Knapsack: n items with weight/value, capacity W, maximize value
dp[i][w] = max value using the first i items with capacity w. Two dims because the future depends on both which items remain (index) and how much room is left (capacity).
Edit Distance between strings A and B
dp[i][j] = min edits to turn A[0..i) into B[0..j). Two indices — one position per string — because the future depends on where you are in each.
Partition array into 2 subsets with equal sum (feasibility)
dp[i][s] = can we pick from the first i items to hit sum s? Boolean DP. (Then answer = dp[n][total/2].) The "remaining target sum" is the carried fact.
State design pitfalls — three wrong states, diagnosed and fixed#
The sufficiency test is useless without practice. Here are three real problems where the "obvious" state is wrong. For each: the wrong state, the symptom, the diagnosis, and the fix.
Best Time to Buy/Sell Stock with Cooldown#
The recurrence dp[i] = max(dp[i-1], prices[i] - minPrice) works for the simple stock problem (one transaction). But with cooldown, selling on day i means you can't buy on day i+1. dp[i] doesn't know whether you just sold or are holding nothing — two different situations, same key.
Run the sufficiency test: "On day 5, I could be holding a share or not — would my best move differ?" Yes! If holding, I should sell; if not, I should consider buying. These are genuinely different futures. Add holding as a dimension. dp[i][0] = best profit on day i not holding; dp[i][1] = best profit holding. Now each situation has its own cell and the recurrence branches correctly.
The takeaway: whenever the problem has a mode or status that constrains your next move (holding, just-sold, on-cooldown, mid-jump), that's a hidden dimension. Ask: "Can I be in two different 'situations' at the same index?" If yes, add a state dimension for the situation.
Longest Increasing Subsequence#
This looks natural — "the answer for the first i elements." But how do you transition? To extend the LIS, you need to know the last element of the current best subsequence so you can check if arr[i] is bigger. dp[i] doesn't store that. You can't compose — the subproblems don't connect.
The pinning constraint "ending at i" tells you the last element (it's arr[i]!). Now the transition composes: dp[i] = 1 + max(dp[j]) over all j < i with arr[j] < arr[i]. The final answer is max(dp[*]), not dp[n]. The constraint is the state — without it, you can't extend.
The takeaway: a good state often needs a pinning constraint ("ending at", "starting from", "using the first i items"). The constraint is what lets subproblems compose. If your transition can't access the information it needs, the state is missing a constraint — add it as part of the definition.
To compute dp[i], you'd need to know: "does adding s[i] create new palindromes?" That depends on whether s[i-1..i], s[i-2..i], etc. are palindromes — but dp[i-1] only stores a count, not which substrings are palindromic. The count doesn't let you check boundaries.
Flip the problem: instead of counting directly, first compute which substrings are palindromes, then count the trues. dp[i][j] = (s[i] == s[j]) && dp[i+1][j-1] for j - i > 1; base: dp[i][i] = true, dp[i][i+1] = (s[i]==s[i+1]). Fill by increasing length. Answer = count of all true cells. The boolean table contains the info; the count is a trivial sum afterward.
The takeaway: sometimes the right state isn't the answer shape (a count) but the building block that the answer is computed from (a boolean per substring). Ask: "What information do I need to extend the solution?" If the answer shape doesn't carry that info, the state should be the info, not the answer. Sum the table at the end.
06 · Transition design#
The transition is how a state's answer is built from smaller states' answers. People treat transitions as arcane. They're not — they're just your Step-1 choice, written as an equation. If you can list the options at one step, you can write the transition.
S: (1) enumerate every choice available at S; (2) for each choice, name the smaller state it leads to; (3) combine the sub-answers with the operator the problem asks for — min/max (optimization), + (counting), OR (feasibility). That combination is the transition.
| Problem asks | Combine sub-answers with | Transition shape |
|---|---|---|
| Minimum / maximum | Math.min / Math.max | dp[S] = best over choices ( cost(choice) + dp[next] ) |
| Count the ways | + (sum) | dp[S] = Σ over choices dp[next] |
| Is it possible | || (OR) | dp[S] = OR over choices dp[next] |
| Probability / expectation | weighted sum | dp[S] = Σ p(choice)·dp[next] |
Deriving a transition, live: House Robber#
Problem: houses in a row with values; robbing adjacent houses trips the alarm; maximize loot. State: dp[i] = max loot considering houses 0..i. What's the choice at house i?
List the choices at house i#
Exactly two: rob house i, or skip house i. That's the entire decision.
Name the smaller state each leads to#
Rob i → I earn nums[i] but can't touch i−1, so the rest is dp[i−2]. Skip i → I earn nothing here, best-so-far is dp[i−1].
Combine with the objective operator (max)#
dp[i] = max( nums[i] + dp[i−2], dp[i−1] ). The transition wrote itself the moment we enumerated the two choices and pointed each at its leftover subproblem.
int rob(int[] nums) {
int prev2 = 0, prev1 = 0; // dp[i-2], dp[i-1]
for (int x : nums) {
int cur = Math.max(prev1, x + prev2); // skip vs rob — the transition
prev2 = prev1; prev1 = cur; // slide the window (Step 7 space cut)
}
return prev1;
}“Pull” (what do I depend on?): dp[i] looks backward and reads earlier cells. Natural for tabulation. “Push” (what do I affect?): from dp[i] I update future cells dp[i+1], dp[i+2]. Natural for some counting/knapsack framings. Same DP, mirror-image code. If a pull transition feels awkward, try phrasing it as a push (or vice-versa) — one direction is often much cleaner.
Complete each recurrence. Reveal to check the reasoning, not just the formula.
Unique Paths: robot moves only right/down on an m×n grid, count paths to bottom-right
To reach cell (r,c) the last move came from above or from the left — the only two options. Count = sum of both: dp[r][c] = dp[r-1][c] + dp[r][c-1], with the first row/col = 1. (Counting → combine with +.)
Min Path Sum: same grid but each cell has a cost; minimize total on a right/down path
Same two predecessors, but objective is min and each cell adds its own cost: dp[r][c] = grid[r][c] + min(dp[r-1][c], dp[r][c-1]). Notice only the combine-operator changed (+→min-of-preds) — the choice structure is identical to Unique Paths.
Longest Common Subsequence of A and B
Choice at (i,j): do the current characters match? If A[i-1]==B[j-1] → they're both in the LCS: 1 + dp[i-1][j-1] (consume both). Else → drop one character: max(dp[i-1][j], dp[i][j-1]). The match/mismatch fork is the choice.
07 · Base cases — discovered, not guessed#
Base cases feel fiddly because people reverse-engineer them. Do it forward instead: a base case is the smallest input where the answer is obvious without any recursion. It's wherever your recursion bottoms out.
solve(). Ask: "For which arguments do I know the answer immediately, with no further calls?" Those inputs — and the values you'd return there — are exactly your base cases. If the recursion can step past them (e.g. index goes negative, amount goes below 0), add a guard returning the identity of your combine operator.
| Combine operator | Identity (the "empty" answer) | Base value for an empty/degenerate subproblem |
|---|---|---|
| sum (counting) | … of ways to do nothing = 1 | dp[empty] = 1 (one way: the empty arrangement) |
| max | −∞ (or 0 if empty is allowed) | dp[empty] = 0 or -INF for "invalid" |
| min | +∞ (0 if empty is allowed) | dp[0 amount] = 0, unreachable = INF |
| OR (feasibility) | false; empty target = true | dp[sum 0] = true |
In counting problems, forgetting that "do nothing" is one valid way (base = 1, not 0) zeroes out your entire table — every sum of zeros is zero. In min problems, seeding unreachable cells with a real number instead of INF lets bogus paths win. Rule of thumb: base value = the identity element of your combine operator, and "impossible" = the operator's annihilator (INF for min, −INF for max, false for OR, 0 for count).
Pause & think: in Coin Change, why is dp[0] = 0 and not, say, dp[0] = 1? reveal
We're minimizing count of coins, and it takes zero coins to make amount 0 — so the answer is literally 0. (Contrast: if the problem were "count the number of ways to make an amount," then the empty selection is one valid way, so the base would be 1.) The base value always mirrors what the cell means under your objective — read it off the definition, don't guess.
08 · Memoization vs Tabulation#
These are two ways to run the same recurrence. They compute identical values; they differ in who drives the order.
Recursion + cache. You write the natural recursion; the call stack discovers subproblems on demand and the cache stops repeats.
- + Mirrors your derivation — least thinking to get correct.
- + Computes only reachable subproblems (great when the state space is sparse).
- + Order handled automatically by recursion.
- − Recursion stack can overflow on deep inputs.
- − Slight overhead per call (hashing/boxing).
Loops fill a table. You pick the fill order by hand so every cell's dependencies are ready before it.
- + No recursion → no stack overflow; usually faster (tight loops).
- + Enables space optimization (rolling arrays) cleanly.
- − You must work out a valid fill order yourself.
- − May compute every cell even if some are never needed.
The 5-step mechanical conversion: memo → table#
Converting is a checklist, not a creative act:
Allocate the table#
One cell per state. Dimensions & sizes come straight from the memo's key ranges.
Seed base cases#
Write the exact values your recursion's base-case branches returned.
Choose a fill order#
Iterate so that when you compute dp[S], every state it reads is already filled. Read the recurrence: if it uses dp[i-1], loop i increasing; if dp[i+1], loop decreasing.
Copy the recurrence into the loop body#
Replace solve(x) calls with dp[x] reads. Same formula, verbatim.
Return the goal cell#
Whatever the top-level call asked for, e.g. dp[n] or dp[n][W].
Integer[] memo;
int fib(int n) {
if (n <= 1) return n; // base
if (memo[n] != null) return memo[n]; // cache
return memo[n] = fib(n-1) + fib(n-2); // recurrence
}int fib(int n) {
if (n <= 1) return n;
int[] dp = new int[n+1];
dp[0] = 0; dp[1] = 1; // base seeds (step 2)
for (int i = 2; i <= n; i++) // order: needs i-1,i-2 → ascending (step 3)
dp[i] = dp[i-1] + dp[i-2]; // same recurrence (step 4)
return dp[n]; // goal (step 5)
}09 · Space optimization — why it's allowed#
Everyone learns "just keep two rows." Nobody explains why it's legal. Here's the principle, and once you have it you'll derive the trick yourself instead of memorizing it per-problem.
dp[i] only ever reads dp[i-1] and dp[i-2], then once you're computing i, everything before i-2 is dead weight — so you only need to remember the last two. The "keep two rows" trick is just this principle applied to a 2D table whose rows only read the row above.
| Transition reaches back… | You must keep… | Space |
|---|---|---|
dp[i-1] only | 1 previous value | O(1) |
dp[i-1], dp[i-2] | 2 previous values | O(1) |
dp[i-1][*] (previous row) | 1 previous row | O(cols) |
any earlier dp[j], j<i | the whole array | O(n) — can't shrink |
When you collapse dp[i][w] to a single row dp[w], the loop direction suddenly matters. In 0/1 Knapsack you must iterate capacity w descending, so that dp[w-weight] still holds the previous item's value (each item used once). In Unbounded Knapsack / Coin Change you iterate w ascending, deliberately reading the current row so an item can be reused. Same flattening, opposite direction — and the direction encodes "use once" vs "reuse." When your 1D knapsack gives wrong answers, this is almost always why.
int[] dp = new int[W+1];
for (int i = 0; i < n; i++)
for (int w = W; w>= wt[i]; w--) // ↓ each item once
dp[w] = Math.max(dp[w], val[i] + dp[w-wt[i]]);int[] dp = new int[W+1];
for (int i = 0; i < n; i++)
for (int w = wt[i]; w <= W; w++) // ↑ reuse allowed
dp[w] = Math.max(dp[w], val[i] + dp[w-wt[i]]);dp[r][c] = grid[r][c] + min(dp[r-1][c], dp[r][c-1]). What's the minimum you must store?09b · Path reconstruction — recovering the actual solution#
So far every DP returns a number: the min cost, the count, the length. But interviews frequently ask "return the actual sequence" — which coins, which path, which words. Here's how to get the solution, not just its measure.
from[S] that records which choice produced the optimal value at each state. After the table is complete, start at the goal state and follow from[] backward to the base case, collecting choices. Reverse the collected list — that's your solution. The DP computes the score; from[] records the moves; the walk-back assembles them.
Worked example: Coin Change — recover which coins#
Recall dp[a] = min coins to make amount a. We add a parallel array usedCoin[a] that stores which coin was picked to achieve dp[a].
int coinChangeWithPath(int[] coins, int amount) {
int INF = amount + 1;
int[] dp = new int[amount + 1];
int[] usedCoin = new int[amount + 1]; // which coin achieved dp[a]
Arrays.fill(dp, INF);
Arrays.fill(usedCoin, -1);
dp[0] = 0;
for (int a = 1; a <= amount; a++) for (int c : coins) if (c <= a && 1 + dp[a - c] < dp[a]) {
dp[a] = 1 + dp[a - c];
usedCoin[a] = c; // remember the choice
}
if (dp[amount] > amount) return -1;
// Walk back from the goal to the base, collecting coins
List<Integer> path = new ArrayList<>();
int a = amount;
while (a > 0) {
path.add(usedCoin[a]); // the coin chosen at this step
a -= usedCoin[a]; // go to the subproblem it came from
}
Collections.reverse(path); // we collected backwards
System.out.println("Coins used: " + path); // e.g. [3, 3] for amount 6
return dp[amount];
}Worked example: LCS — recover the actual subsequence#
For 2D tables, from[i][j] records which predecessor was chosen: diagonal (match), up, or left. Walk from dp[n][m] back to dp[0][0].
String lcsPath(String A, String B) {
int n = A.length(), m = B.length();
int[][] dp = new int[n+1][m+1];
int[][] from = new int[n+1][m+1]; // 0=diag(match), 1=up, 2=left
for (int i = 1; i <= n; i++) for (int j = 1; j <= m; j++) {
if (A.charAt(i-1) == B.charAt(j-1)) {
dp[i][j] = 1 + dp[i-1][j-1];
from[i][j] = 0; // diagonal — this char is in the LCS
} else if (dp[i-1][j] >= dp[i][j-1]) {
dp[i][j] = dp[i-1][j];
from[i][j] = 1; // came from above
} else {
dp[i][j] = dp[i][j-1];
from[i][j] = 2; // came from left
}
}
// Walk back from (n,m) to (0,0), collecting chars on diagonal moves
StringBuilder sb = new StringBuilder();
int i = n, j = m;
while (i > 0 && j > 0) {
if (from[i][j] == 0) { // match — this char is in the LCS
sb.append(A.charAt(i-1));
i--; j--;
} else if (from[i][j] == 1) i--; // came from above, move up
else j--; // came from left, move left
}
return sb.reverse().toString(); // collected backwards
}Path reconstruction requires the full DP table (or at least the from[] table) — you can't space-optimize with a rolling array and reconstruct the path, because the rolling array overwrites the history you need to walk back through. If the problem asks for the actual solution, keep the full table. Some advanced techniques (divide-and-conquer reconstruction) can recover paths from space-optimized DP, but that's beyond the basics.
If you forgot to store choices, you can still reconstruct by re-deriving: at each state, re-run the transition and check which choice matches the stored dp value. For dp[a], test each coin c: if dp[a] == 1 + dp[a-c], that coin was (one of) the optimal choices. Slower but works — handy when you've already submitted a number-only solution and the follow-up is "now return the path."
The pattern library#
Patterns are recognition shortcuts, not replacements for the engine. Each card gives you: the intuition, the tell-tale clues, a reusable template, the mistakes that bite, and a problem ladder to climb. When a new problem matches a card's clues, you skip straight to a known state shape.
10 · Twelve families that cover ~95% of DP#
Learn these state shapes and most DP problems become "oh, it's a variant of X." The remaining 5% are combinations of two of these.
1D DP over a line#
dp[i] = answer for prefix ending at / considering index iIntuition: you walk an array left to right; at each index a small choice (take/skip, jump length) couples to a couple of earlier indices. The future depends only on where you are and one or two neighbors.
dp[i] depends on dp[i-1], dp[i-2]int linear(int[] a) {
int n = a.length;
int[] dp = new int[n + 1];
dp[0] = base; // smallest subproblem
for (int i = 1; i <= n; i++) dp[i] = combine(dp[i-1], dp[i-2], a[i-1]); // your transition
return dp[n];
}- Off-by-one between "index in array" and "index in dp" — pick a convention (dp[i] = first i elements) and hold it.
- Space-optimizing before the O(n) version is correct. Get it right, then shrink.
- Climbing Stairs easyThe pure Fibonacci recurrence — derive it from "how did I arrive?"
- House Robber medAdds the take/skip choice with a constraint. The canonical 1D.
- House Robber II medCircular array → run the line twice with two windows. Teaches problem reduction.
- Decode Ways medCounting variant with validity checks on 1- and 2-digit chunks.
- Jump Game II med1D min-jumps; also has a greedy — great greedy-vs-DP contrast.
Choose items under a budget#
dp[i][cap] = best using first i items with capacity capIntuition: a set of items, each a take-or-leave decision, constrained by a running budget (weight, sum, amount). The two dimensions are "which items are on the table" and "how much budget is left." The most important family to master — coin change, partition, target sum, and dozens more are all knapsack in disguise.
// max value, each item used at most once
int knapsack01(int[] wt, int[] val, int W) {
int[] dp = new int[W + 1]; // dp[c] = best value at capacity c
for (int i = 0; i < wt.length; i++) for (int c = W; c>= wt[i]; c--) // DESCENDING → each item once
dp[c] = Math.max(dp[c], val[i] + dp[c - wt[i]]);
return dp[W];
}for (int i = 0; i < wt.length; i++)
for (int c = wt[i]; c <= W; c++) // ASCENDING → reuse allowed
dp[c] = Math.max(dp[c], val[i] + dp[c - wt[i]]);- Wrong loop direction in the 1D form (see §09) — descending = once, ascending = reuse. The #1 knapsack bug.
- For counting combinations vs permutations: loop items-outer/amount-inner counts combinations; swapping the loops counts permutations. Order of loops changes the meaning!
- Feasibility DP: initialize
dp[0]=true, everything else false.
- Partition Equal Subset Sum medBoolean 0/1 knapsack — "reach sum total/2?" First real subset DP.
- Coin Change (min) medUnbounded, minimize count. The worked example in Part III.
- Coin Change II (count) medUnbounded, count combinations. Teaches the loop-order-controls-meaning subtlety.
- Target Sum medSigns +/− → transform to a subset-sum knapsack. Reduction practice.
- Ones and Zeroes medTwo capacities at once → 2D knapsack. Scales the idea.
Aligning & comparing sequences#
dp[i][j] = answer for A[0..i) and B[0..j)Intuition: two strings/arrays, and you march a pointer through each. At (i,j) you decide based on whether the current elements match, moving one or both pointers. The grid dp[i][j] literally aligns the two sequences.
dp[i] ending at i)Watch the LCS table fill. Green diagonals are character matches (+1); blue reads are the max(up, left) on a mismatch. Predict each cell before stepping.
int lcs(String A, String B) {
int n = A.length(), m = B.length();
int[][] dp = new int[n + 1][m + 1]; // row/col 0 = empty prefix = 0
for (int i = 1; i <= n; i++) for (int j = 1; j <= m; j++) dp[i][j] = A.charAt(i-1) == B.charAt(j-1) ? 1 + dp[i-1][j-1] // match → take both, go diagonal
: Math.max(dp[i-1][j], dp[i][j-1]); // mismatch → drop one
return dp[n][m];
}- Indexing:
dpis 1-bigger than the strings;A.charAt(i-1)pairs withdp[i]. - Edit Distance: three choices (insert/delete/replace) →
1 + min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]); a match costs 0 and goes diagonal. - LIS in O(n log n): use patience sorting / binary search, not the O(n²) DP, for large n.
- Longest Increasing Subsequence medThe 1D "ending at i" state; then learn the O(n log n) trick.
- Longest Common Subsequence medThe canonical 2D alignment (the widget above).
- Edit Distance hardThree operations → three-way transition. The boss of this family.
- Distinct Subsequences hardCounting alignments — subtle take/skip on a match.
- Interleaving String medFeasibility over two pointers — a boolean cousin.
Intuition: movement is restricted (usually right/down), so each cell is reachable only from a small set of neighbors — its predecessors. The grid is the DP table. This is the gentlest 2D DP and the best place to first see a table fill.
Watch Unique Paths fill: each cell = paths from above + paths from left. Blue cells are the two it reads.
int minPathSum(int[][] g) {
int m = g.length, n = g[0].length;
int[][] dp = new int[m][n];
for (int r = 0; r < m; r++) for (int c = 0; c < n; c++) {
if (r == 0 && c == 0) dp[r][c] = g[r][c]; // start
else {
int up = r > 0 ? dp[r-1][c] : Integer.MAX_VALUE; // from above
int left = c > 0 ? dp[r][c-1] : Integer.MAX_VALUE; // from left
dp[r][c] = g[r][c] + Math.min(up, left);
}
}
return dp[m-1][n-1];
}- Forgetting to seed the first row/column (only one predecessor there).
- Obstacles: set blocked cells to 0 paths and guard against reading a blocked predecessor.
- Space: only the previous row is read → collapse to a single 1D row (O(n)).
- Unique Paths medPure counting grid (the widget). Start here.
- Minimum Path Sum medSame structure, min objective + cell costs.
- Unique Paths II medObstacles → handling invalid predecessors.
- Triangle medNon-rectangular grid; bottom-up shines.
- Maximal Square medCell depends on three neighbors (up, left, diag) → clever transition.
Solve ranges by their split point#
dp[i][j] = answer for the sub-range [i..j]Intuition: the answer for a range depends on where you split it or what you resolve last. You try every split point k inside [i..j] and combine the two halves. Fill order is by increasing range length, so both halves are ready.
k// dp[i][j] over ranges, filled by increasing length
for (int len = 2; len <= n; len++)
for (int i = 0; i + len - 1 < n; i++) {
int j = i + len - 1;
dp[i][j] = INF;
for (int k = i; k < j; k++) // split [i..j] at k | k+1
dp[i][j] = Math.min(dp[i][j], dp[i][k] + dp[k+1][j] + costToMerge(i, k, j));
}
return dp[0][n-1];Interval DP is hard because the natural "leftmost choice" framing fails — the right recursion asks "which element/split is resolved last?" (e.g. in Burst Balloons, which balloon bursts last in this range). Reframing from "first" to "last" is the whole trick.
- Wrong fill order — you must loop by range length (or memoize top-down and dodge the issue).
- Off-by-one on the split boundary (
dp[i][k] + dp[k+1][j]).
- Min Cost Tree From Leaf Values medGentle intro to "combine a range."
- Palindrome Partitioning II hardMin cuts — range feasibility + partition.
- Burst Balloons hardThe "resolved last" reframing. A rite of passage.
- Minimum Cost to Merge Stones hardInterval DP with a divisibility twist.
DP on rooted trees (DFS + combine children)#
dp[node][state], computed from children's dpIntuition: a tree has no cycles, so the natural subproblem is "the answer for the subtree rooted at node." You DFS to the leaves and combine children results on the way back up — post-order. Often each node carries a tiny extra state (included / excluded).
// returns {robThis, skipThis} for the subtree rooted at node
int[] dfs(TreeNode node) {
if (node == null) return new int[]{0, 0};
int[] L = dfs(node.left), R = dfs(node.right);
int rob = node.val + L[1] + R[1]; // take node → children skipped
int skip = Math.max(L[0], L[1]) + Math.max(R[0], R[1]); // free to take or not
return new int[]{rob, skip};
}
int rob(TreeNode root) { int[] r = dfs(root); return Math.max(r[0], r[1]); }- Trying to iterate bottom-up by "levels" — just recurse; the call stack gives you post-order for free.
- Forgetting the per-node extra state when a parent/child constraint exists.
- "Rerooting" problems (answer for every node as root) need a second DFS pushing info down — a distinct advanced technique.
- Diameter of Binary Tree easyCombine children heights — the simplest tree DP.
- House Robber III medTake/skip state per node (template above).
- Binary Tree Max Path Sum hardReturn one value, update a global — a classic tree-DP shape.
- Binary Tree Cameras hardThree-state node DP + greedy flavor.
State = which subset is used (n ≤ ~20)#
dp[mask][...], mask's bits = elements already usedIntuition: when the "carried fact" is which subset of a small set you've committed to, encode that subset as the bits of an integer. 2ⁿ masks are tractable for n ≤ ~20. This is how you brute-force permutations/assignments with memoization.
// dp[mask][i] = min cost to visit set `mask`, currently at city i
int[][] dp = new int[1 << n][n];
for (int[] row : dp) Arrays.fill(row, INF);
dp[1][0] = 0; // started at city 0
for (int mask = 1; mask < (1 << n); mask++)
for (int i = 0; i < n; i++) {
if ((mask & (1 << i)) == 0 || dp[mask][i] == INF) continue;
for (int j = 0; j < n; j++) // go to an unvisited city j
if ((mask & (1 << j)) == 0) dp[mask | (1 << j)][j] = Math.min(dp[mask | (1 << j)][j], dp[mask][i] + cost[i][j]);
}- Operator precedence:
mask & (1 << i)needs parentheses in Java. - Using bitmask when n is large —
2ⁿexplodes past n≈22. Check n first. - Iterate masks in increasing order so subsets are computed before supersets.
- Shortest Superstring hardTSP-style over strings.
- Partition to K Equal Sum Subsets medSubset-cover with a used-mask.
- Maximum Students Taking Exam hardRow masks with adjacency constraints — profile DP.
Count numbers in [L, R] with a property#
dp[pos][tight][...property] over the digitsIntuition: to count integers up to N satisfying some digit rule, build the number digit-by-digit from the most significant. The key carried fact is tight — whether the prefix so far equals N's prefix (which caps the next digit) or is already smaller (next digit free 0–9).
f(R) − f(L−1)int[] digits; Integer[][] memo; // memo[pos][property]; index tight separately
// pos: current digit index; tight: bounded by N's prefix; ... : property state
int go(int pos, boolean tight, int prop) {
if (pos == digits.length) return isGood(prop) ? 1 : 0;
if (!tight && memo[pos][prop] != null) return memo[pos][prop]; // cache only when free
int limit = tight ? digits[pos] : 9;
int total = 0;
for (int d = 0; d <= limit; d++) total += go(pos + 1, tight && (d == limit), nextProp(prop, d));
if (!tight) memo[pos][prop] = total;
return total;
}- Caching
tightstates — you generally don't memoize whentightis true (those are unique to N's prefix). - Leading zeros: sometimes need a
startedflag when the property cares about the first significant digit.
- Numbers With Unique Digits medCombinatorial warm-up before full digit DP.
- Numbers With Repeated Digits hardComplement + digit DP with a used-mask.
- Count of Integers (digit-sum bounds) hardClassic
tight+ running digit sum.
Two optimal players, alternating turns#
dp[range/state] = best score for the player to moveIntuition: both players play perfectly, so from any position the mover picks the choice that maximizes their own outcome — knowing the opponent will do the same next. Model the value as "score of current player minus opponent" (relative), so a single min/max captures both sides.
// dp[i][j] = max (myScore - oppScore) the mover can secure on piles [i..j]
int[][] dp = new int[n][n];
for (int i = 0; i < n; i++) dp[i][i] = piles[i]; // one pile: take it
for (int len = 2; len <= n; len++)
for (int i = 0; i + len - 1 < n; i++) {
int j = i + len - 1;
dp[i][j] = Math.max( // I move, then opponent's dp is subtracted
piles[i] - dp[i+1][j], // take left
piles[j] - dp[i][j-1]); // take right
}
return dp[0][n-1] > 0; // first player wins iff positive marginInstead of tracking both players' totals, track the difference (mover minus opponent). Then the opponent's optimal play is just the same function applied to the smaller state, and you subtract it. One recurrence handles both turns — no "whose turn" dimension needed.
- Tracking both players' scores separately — you need a "whose turn" dimension and two recurrences. Use the relative-score trick instead (mover's gain minus opponent's), and one recurrence handles both turns.
- Forgetting that the opponent also plays optimally — they're not random, they minimize your outcome. The subtraction in
piles[i] - dp[i+1][j]encodes "opponent will do their best on the remaining range." - Wrong base case:
dp[i][i] = piles[i](the mover takes the only pile), not 0.
- Stone Game medThe relative-score interval template above.
- Predict the Winner medSame idea, general values.
- Stone Game II medAdds a variable window (M) → extra state dimension.
Expected values & probabilities over random processes#
dp[config] = probability / expected value from hereIntuition: the same decomposition as counting DP, but you weight each branch by its probability and sum (for probability) or take Σ p·value (for expectation). Cells hold doubles in [0,1] or expected numbers, not counts.
// dp[k][r][c] = prob of still being on-board after k moves from (r,c)
double knightProbability(int N, int K, int r, int c) {
double[][] prob = new double[N][N];
prob[r][c] = 1.0;
int[][] moves = {{1,2},{2,1},{2,-1},{1,-2},{-1,-2},{-2,-1},{-2,1},{-1,2}};
for (int step = 0; step < K; step++) {
double[][] next = new double[N][N];
for (int i = 0; i < N; i++) for (int j = 0; j < N; j++) if (prob[i][j]> 0) for (int[] m : moves) {
int ni = i+m[0], nj = j+m[1];
if (ni>=0 && ni<N && nj>=0 && nj<N) next[ni][nj] += prob[i][j] / 8.0; // weighted by 1/8
}
prob = next;
}
double sum = 0;
for (double[] row : prob) for (double p : row) sum += p;
return sum;
}- Mixing up "probability of surviving" vs "expected count" — decide what a cell means first.
- Floating error: fine for most; use logs/fractions only if the problem demands exactness.
- Knight Probability in Chessboard medThe template above — time-stepped probability.
- Soup Servings medExpectation with a neat large-N shortcut.
- New 21 Game medProbability + sliding-window sum optimization.
DP over positions in a string#
dp[i] = answer for the prefix/suffix s[0..i) or s[i..n)Intuition: the string is the sequence; at each position you ask "can I extend / segment / match from here?" The choice is usually "which word/palindrome/chunk starts (or ends) at this position?" It's a 1D DP where the transition tries every valid chunk boundary.
boolean wordBreak(String s, List<String> dict) {
Set<String> words = new HashSet<>(dict);
boolean[] dp = new boolean[s.length() + 1];
dp[s.length()] = true; // empty suffix = segmentable
for (int i = s.length() - 1; i >= 0; i--) for (String w : words) if (i + w.length() <= s.length() && s.startsWith(w, i) && dp[i + w.length()]) { dp[i] = true; break; }
return dp[0];
}int minCut(String s) {
int n = s.length();
boolean[][] pal = isPalTable(s); // pal[i][j] = is s[i..j] palindrome?
int[] dp = new int[n]; // dp[i] = min cuts for s[0..i]
Arrays.fill(dp, Integer.MAX_VALUE);
for (int i = 0; i < n; i++) {
if (pal[0][i]) { dp[i] = 0; continue; } // whole prefix is palindrome
for (int j = 0; j < i; j++) if (pal[j+1][i]) // if s[j+1..i] is palindrome
dp[i] = Math.min(dp[i], dp[j] + 1);
}
return dp[n-1];
}Many string DP problems need a precomputed table — e.g. "which substrings are palindromes?" You first fill pal[i][j] with a separate DP (by increasing length), then use it in the main DP. Two DP passes, one building block for the other. This is how interval DP feeds into 1D DP.
- Wrong fill direction: if
dp[i]readsdp[i + len](future positions), fill right-to-left. If it readsdp[i - len](past positions), fill left-to-right. This is the #1 bug in string DP. - Not precomputing palindromes — checking
isPalindrome(i, j)inside the main loop is O(n) per check, making it O(n³) total. Precompute in O(n²) first. - In Regex Matching (
.and*), the*means "zero or more of the preceding char" — it's not a wildcard. The statedp[i][j](text pos, pattern pos) must handle "match zero" (skip thex*entirely) vs "match one more" (consume a text char and keep thex*).
- Word Break medFeasibility DP — the template above. First string DP.
- Palindromic Substrings medBoolean interval DP — "which substrings are palindromes?"
- Palindrome Partitioning II hardTwo-pass: pal table + 1D min-cuts DP. Combines families.
- Regular Expression Matching hard
.and*— the hardest string DP transition. Worth studying.
When the transition needs a fast lookup#
dp[i] + binary search over valid predecessors / the answerIntuition: two flavors: (A) BS speeds up the DP — the transition scans a range of predecessors, but a monotonic property lets you binary-search instead of linear-scanning (LIS in O(n log n)). (B) DP validates the BS — you binary-search the answer, and the check function is itself a DP (Split Array Largest Sum, Capacity to Ship).
int lengthOfLIS(int[] nums) {
List<Integer> tails = new ArrayList<>(); // tails[i] = smallest tail of an IS of length i+1
for (int x : nums) {
int pos = Collections.binarySearch(tails, x);
if (pos < 0) pos = -(pos + 1); // insertion point
if (pos == tails.size()) tails.add(x); // x extends the longest IS
else tails.set(pos, x); // x improves a shorter IS's tail
}
return tails.size();
}int splitArray(int[] nums, int k) {
long lo = maxElement(nums), hi = sumOf(nums); // answer is in [max, sum]
while (lo < hi) {
long mid = lo + (hi - lo) / 2;
if (canSplit(nums, k, mid)) // can we split into ≤ k parts with max sum ≤ mid?
hi = mid; // yes → try smaller
else lo = mid + 1; // no → need larger budget
}
return (int) lo;
}
boolean canSplit(int[] nums, int k, long maxSum) {
int parts = 1; long running = 0;
for (int x : nums) {
if (running + x > maxSum) { parts++; running = x; }
else running += x;
}
return parts <= k;
}In flavor A (LIS), the BS is inside the DP — each state's transition uses BS to find the best predecessor. In flavor B (Split Array), the BS is outside — it searches the answer space, and the check function (often greedy, sometimes DP) validates each candidate. Recognizing which flavor you need is the first step: "do I need to speed up a transition?" (A) or "do I need to find the optimal threshold?" (B).
- In LIS O(n log n),
tailsis not the actual subsequence — it's an auxiliary array for the length. Reconstructing the actual LIS requires a separateparent[]array (see §09b). - In flavor B, setting
lotoo low (e.g. 0 instead ofmax(nums)) — the answer can't be smaller than the largest single element, since that element must go in some subarray. - The check function in flavor B must be monotonic: if threshold X is feasible, X+1 must also be feasible. Verify this before BS.
- Longest Increasing Subsequence medFirst do the O(n²) DP, then learn the O(n log n) BS upgrade. Two mindsets in one.
- Split Array Largest Sum hardBS on answer + greedy check. The canonical flavor-B problem.
- Divide Chocolate hardSame as Split Array but "maximize the minimum" — BS direction flips.
- Russian Doll Envelopes hard2D LIS — sort one dimension, BS-LIS on the other. Extends the technique.
Reference & practice#
Copy-paste templates, a debugging playbook, the interview monologue, a sequenced roadmap, and one-page cheat sheets you can revisit forever.
11 · Java templates#
Skeletons for every shape. Fill in base, the choices, and the combine operator — the structure never changes.
Top-down memoization — the universal starting point#
// Generic 1-parameter memoized DP. Replace types/logic as needed.
Integer[] memo; // sized to the state range; null = "not computed"
int solve(int i, int[] input) {
if (isBase(i)) return baseValue; // smallest subproblem, answer known outright
if (memo[i] != null) return memo[i];// cache hit — never recompute
int best = IDENTITY; // 0 for count, +INF for min, -INF for max
for (Choice ch : choicesAt(i)) // enumerate the ONE-STEP options
best = combine(best, cost(ch) + solve(next(i, ch), input));
return memo[i] = best; // cache before returning
}
// caller: Arrays.fill(memo, null); return solve(start, input);2D memoization (two-index / knapsack / alignment)#
Integer[][] memo; // memo[i][j]
int solve(int i, int j) {
if (base) return baseValue;
if (memo[i][j] != null) return memo[i][j];
int best = IDENTITY;
// ... combine over the few transitions that read solve(i-1,j), solve(i,j-1), solve(i-1,j-1) ...
return memo[i][j] = best;
}Bottom-up 1D tabulation#
int[] dp = new int[n + 1];
dp[0] = base; // seed base case(s)
for (int i = 1; i <= n; i++) { // order: dependencies (i-1,i-2) already done
int best = IDENTITY;
for (Choice ch : choicesAt(i)) best = combine(best, cost(ch) + dp[prev(i, ch)]);
dp[i] = best;
}
return dp[n];Bottom-up 2D tabulation#
int[][] dp = new int[n + 1][m + 1];
for (int i = 0; i <= n; i++) dp[i][0] = baseCol(i); // seed first column
for (int j = 0; j <= m; j++) dp[0][j] = baseRow(j); // seed first row
for (int i = 1; i <= n; i++)
for (int j = 1; j <= m; j++) dp[i][j] = combine(dp[i-1][j], dp[i][j-1], dp[i-1][j-1] /*, current*/);
return dp[n][m];State compression — 2D → two rows → one row#
// When dp[i][*] only reads dp[i-1][*]: keep one row and update in place.
int[] dp = new int[m + 1];
seedFirstRow(dp);
for (int i = 1; i <= n; i++) {
// iterate j in the direction that preserves the values you still need:
// ascending if you may reuse the current row (unbounded);
// descending if each layer must use only the previous row (0/1).
for (int j = ...; ...; ...) dp[j] = combine(dp[j], dp[jPrev]);
}
return dp[m];Helper utilities you'll reuse#
static final int INF = Integer.MAX_VALUE / 2; // /2 avoids overflow when you do INF + x
// HashMap memo for sparse / non-integer states (e.g. encoded tuples or masks)
Map<Long, Integer> memo = new HashMap<>();
long key(int a, int b) { return ((long) a << 20) | b; } // pack two small ints
// path reconstruction: keep a parent/choice table alongside dp, then walk it back
int[] from = new int[n + 1];
// ...set from[i] = chosenPrev when you update dp[i]...
// then follow `from` from the goal state back to the base to recover the actual solutionNotice every template has the same three holes: base, choices, combine. That's not a coincidence — it's the Discovery Engine crystallized. If you can fill those three holes, you have a DP. The rest is boilerplate you can literally paste.
12 · Debugging DP#
DP bugs are silent — you get a number, it's just wrong. Here's how to find the lie fast.
Print the whole table
Dump dp as a grid and read it by hand for a tiny input you can solve on paper. The first cell that disagrees with your hand-calc localizes the bug to one transition or base case.
Check the base cases in isolation
~50% of DP bugs are wrong seeds. Verify dp[0] / first row / first column mean exactly what your recurrence assumes (identity vs annihilator — see §07).
Brute-force cross-check
Write the naïve exponential recursion (no memo). For small inputs, assert brute(x) == dp(x) over random tests. A mismatch means your transition diverged from the honest brute force.
Suspect the state first
If the table looks internally consistent but the answer's wrong, you likely have hidden state (§05): two situations collided on one key. Add the missing dimension and re-test.
Check fill order / loop direction
Reading a cell before it's computed → you get a stale/zero value. In 1D-compressed knapsack, wrong direction is the usual culprit.
Watch for overflow & INF arithmetic
INF + cost overflows if INF = Integer.MAX_VALUE. Use MAX_VALUE/2, or guard before adding.
amount: 0 1 2 3 4 5 6
dp[]: 0 1 2 1 1 2 2
^ ^
dp[2]=2 (1+1) dp[5]=2 (4+1 or 1+4)
dp[6]=2 = 1 + dp[3] = 1 + dp[2]? → min(1+dp[5], 1+dp[3], 1+dp[2]) = min(3,2,3)=2 ✓If your dp[6] printed 3 instead of 2, you'd immediately look at which coins the transition considered — and discover it forgot the coin 3 branch. The table doesn't lie; read it.
Worked debugging example: wrong loop direction in 1D knapsack#
You're solving Coin Change II (count the number of combinations to make amount 5 with coins {1, 2, 5}). The correct answer is 4: {1,1,1,1,1}, {1,1,1,2}, {1,2,2}, {5}. Your code returns 9. Let's debug by reading the table.
amount: 0 1 2 3 4 5
dp[]: 1 1 2 3 5 9
^???
expected dp[5] = 4, got 9 — DOUBLE-COUNTEDStep through the diagnosis: dp[0] = 1 (one way to make 0: the empty set) is correct — the base case is fine. But dp[2] = 2 when it should be 2 (hmm, that's right: {1,1} and {2})... wait, the real bug shows at dp[3] = 3: {1,1,1}, {1,2}, {2,1} — three arrangements, but only two combinations. The ascending loop let each coin be reused within the same item's pass, counting {1,2} and {2,1} as different. The fix: iterate w descending so each coin is used at most once per pass. After the fix, dp[5] = 4 — exactly the four combinations. The table told you the bug was in what the loop was counting, not in the base cases.
amount: 0 1 2 3 4 5 dp[]: 1 1 2 2 3 4 ✓
① base cases correct? ② fill order valid (deps ready)? ③ loop direction right for reuse/once? ④ does brute force agree on small n? ⑤ any two distinct situations sharing a state key (hidden state)? ⑥ INF overflow? ⑦ off-by-one between input index and dp index? Nine times in ten it's ①, ③, or ⑤.
13 · Interview thinking — the monologue, on a real problem#
Abstract advice doesn't stick. Here's the full internal narration of someone solving Decode Ways (LeetCode medium: "a string of digits, '1'→'A', '26'→'Z', count the number of ways to decode it") — including the wrong turns, because that's what really happens. Notice: they never "recall the formula." They run the engine out loud, hit a dead end, back up, and try again. Steal this script.
"Let me restate and find the objective."#
"I'm given a string of digits like '226', and I need to count how many ways it can be decoded. '2','2','6' → BBF; '22','6' → VF; '2','26' → BZ. So count = 3. That's a counting problem over a set of choices — DP-sense is tingling."
"Can greedy work? Let me try to break it."#
"What if I always take the longest valid chunk? '226' → take '22', then '6' → one decoding. But I missed '2','2','6' and '2','26'. Greedy commits to one parse and misses others. What about a simple left-to-right scan counting valid chunks? That doesn't account for the branching — at each position I can take 1 digit OR 2 digits, and both paths contribute to the count. Greedy is out; I need to compare all parse paths → DP."
"What's the choice at one step? — and a wrong turn."#
"At position i, I can take 1 digit (if valid, i.e. not '0') or take 2 digits (if 10–26). So solve(i) = number of ways to decode s[i..]. I'll assume solve(i+1) and solve(i+2) are already correct — leap of faith."
Wait — wrong turn. My first instinct was solve(i) = solve(i+1) + solve(i+2), like Fibonacci. But that's not right for '06' — taking '0' as a single digit is invalid. I need validity guards: only add solve(i+1) if s[i] is a valid single digit ('1'–'9'), and only add solve(i+2) if s[i..i+1] is a valid two-digit number ('10'–'26'). The recurrence is conditional, not a blind sum."
"Do subproblems repeat? What's the state?"#
"solve(i+1) shows up both via the 1-digit path from i and as the starting point from i+1 itself. Overlaps confirmed. The only varying argument is i, so state is 1D: dp[i] = number of ways to decode s[i..]. Complexity: O(n) states × O(1) choices (two) = O(n)."
"Base cases — let me think carefully."#
"When i reaches the end (empty suffix), there's exactly 1 way to decode nothing — the empty parse. So dp[n] = 1. That's the identity for counting: 'do nothing' is one valid way. If s starts with '0', dp[0] = 0 because '0' can't start any valid decode — and that naturally falls out of the guard."
"Let me code the memoized version first — safest correct thing."#
Integer[] memo;
int numDecodings(String s) {
memo = new Integer[s.length() + 1];
return solve(0, s);
}
int solve(int i, String s) {
if (i == s.length()) return 1; // base: empty suffix
if (memo[i] != null) return memo[i];
int ways = 0;
if (s.charAt(i) != '0') // 1-digit: valid if not '0'
ways += solve(i + 1, s);
if (i + 1 < s.length() && (s.charAt(i) == '1' || (s.charAt(i) == '2' && s.charAt(i+1) <= '6'))) ways += solve(i + 2, s); // 2-digit: valid if 10–26
return memo[i] = ways;
}"This is correct. If the interviewer asks, I can tabulate it bottom-up (fill dp[n] down to dp[0]) and then space-optimize to O(1) since dp[i] only reads dp[i+1] and dp[i+2]."
"Let me state the complexity and offer optimizations."#
"O(n) time, O(n) space for the memo. Since dp[i] reads only dp[i+1] and dp[i+2], I can use two variables instead — O(1) space. Want me to code that version?" (This is how you volunteer the optimization instead of waiting to be asked.)
• Always verbalize the brute force first — it shows structure even if you never finish optimizing. • Narrate wrong turns out loud — "my first thought was X, but that fails because Y" shows exactly the debugging mindset interviewers want. • State the complexity as states × transition-cost. • Start memoized; offer tabulation/space-optimization as "I can further improve…". • If stuck, say "let me try a tiny example by hand" — it un-sticks you and signals method. Interviewers score the process, which is exactly what this whole page trains.
14 · The problem roadmap#
Order matters. Each rung teaches one new idea on top of the last. Don't skip — the "why next" is the point. Do all of a tier before the next.
Tier 0 · Feel the recurrence easy#
- Fibonacci Number The literal "cache a recursion" exercise. Do it memoized, then bottom-up, then O(1) space — three forms of one idea.
- Climbing Stairs Same recurrence, but you must derive it from the problem ("how did I arrive?"). Bridges Fib → real problems.
- Min Cost Climbing Stairs Adds an objective (min) and per-step cost — your first optimization DP.
Tier 1 · The take/skip choice medium#
- House Robber Introduces a constrained choice (can't take neighbors). The prototypical 1D DP.
- Maximum Subarray (Kadane) "extend or restart" — a DP you can compress to one variable. Teaches state = "best ending here."
- Stock with Cooldown Your first hidden-state lesson — add "do I hold a share?" as a dimension. Pivotal.
Tier 2 · The 2D grid & two-sequence jump medium#
- Unique Paths → Minimum Path Sum First 2D table; "predecessor" thinking. Count then min — same skeleton.
- Longest Common Subsequence Two pointers over two strings; match=diagonal vs mismatch=max. The template for a whole family.
- Coin Change Unbounded knapsack; loop-direction / reuse subtlety. The most-tested DP.
- Partition Equal Subset Sum 0/1 knapsack as feasibility; the once-vs-reuse contrast with Coin Change.
Tier 3 · Harder states & new shapes hard#
- Longest Increasing Subsequence "ending at i" state; then the O(n log n) upgrade. Two mindsets in one problem.
- Edit Distance Three-way transition; the peak of the alignment family.
- Word Break DP over string positions with a dictionary — feasibility with a twist.
- Burst Balloons Interval DP + the "resolved last" reframing. If you get this, you've arrived.
Tier 4 · Specialist families hard#
- House Robber III Tree DP with per-node state.
- Stone Game Game/minimax with the relative-score trick.
- Partition to K Equal Sum Subsets Bitmask subset DP.
- Knight Probability Probability DP with a time dimension.
Tier 5 · String DP & DP + Binary Search hard#
- Word Break Feasibility string DP — the canonical entry to string/segment family.
- Palindrome Partitioning II Two-pass: palindromic table + 1D min-cuts. Combines interval and string DP.
- Regular Expression Matching Hardest string DP —
.and*with "match zero or more" semantics. - Split Array Largest Sum BS on answer + greedy check. The canonical DP+BS combo.
For each problem: (1) run the engine from scratch, don't peek; (2) if stuck 20 min, read only the state definition, then derive the transition yourself; (3) after solving, write the recurrence in one English sentence. If you can't sentence it, you pattern-matched instead of understanding — redo it. Understanding is being able to re-derive, cold, a week later.
15 · Cheat sheets#
Everything compressed. Screenshot these; they're the residue of the whole page.
The Discovery Engine, on one line each#
Recognition checklist — "should I reach for DP?"#
- Objective is max / min / count / feasibility over choices — not sort/search/one-value.
- You can break greedy with a small counterexample (local-best ≠ global-best).
- The answer decomposes into smaller instances of the same problem.
- The brute-force recursion re-solves the same subproblem — the confirming test.
- The future depends on only a few summarizing facts → small state.
Objective → combine operator → base value#
| Problem asks | Combine | Base (identity) | "Impossible" (annihilator) |
|---|---|---|---|
| Minimum | Math.min | 0 (empty) | +INF |
| Maximum | Math.max | 0 (empty) | -INF |
| Count ways | + | 1 (empty = one way) | 0 |
| Feasibility | || | true (empty target) | false |
| Expected value | Σ p·v | problem-specific | — |
State shape by family#
| Family | State | Transition heart |
|---|---|---|
| Linear | dp[i] | take vs skip, from dp[i-1], dp[i-2] |
| Knapsack | dp[i][cap] → 1D | use item vs not; direction = once/reuse |
| Two-sequence | dp[i][j] | match→diag; mismatch→up/left |
| Grid | dp[r][c] | from up + left (or 3 neighbors) |
| Interval | dp[i][j] | best over split k; fill by length |
| Tree | dp[node][s] | combine children in post-order |
| Bitmask | dp[mask][i] | add one unused bit |
| Digit | dp[pos][tight][prop] | place next digit ≤ limit |
| Game | dp[i][j] (relative) | myGain − dp[smaller] |
| Probability | dp[step][config] | Σ p·dp[next] |
| String/Segment | dp[i] + pal table | try every valid chunk at position i |
| DP + Binary Search | dp[i] + BS, or BS + check | BS finds predecessor (A) or validates threshold (B) |
State design decision guide — "should I add a dimension?"#
| Symptom you're seeing | What it means | Action |
|---|---|---|
| Wrong answers, but table looks internally consistent | Hidden state — two situations share a key | Add a dimension for the fact that distinguishes them (holding? just-sold? color?) |
| Can't write the transition — missing info to extend | No pinning constraint | Refine the state definition ("ending at i", "starting from i") so the last element is known |
| Correct answers but TLE/MLE | State explosion or O(n²) where O(n log n) exists | Compress (bitmask, BS-inside-DP) or find a smaller equivalent state |
| Answer is a count but you can't compose counts | Wrong answer shape for the state | Store the building block (boolean per substring) and sum at the end |
| Right answer, but you're not sure if it's always right | Insufficient state — haven't proven the Markov property | Run the sufficiency test: two histories → same state → same future? If not, add a dimension |
The dimension-adding checklist#
Does the future depend on something the state doesn't capture?#
If yes — that's a hidden dimension. Common: "am I holding an item?", "did I just sell?", "what was the last color?", "am I on an even or odd step?"
Can two different histories land in the same state but need different futures?#
If yes — the state is insufficient. Add the fact that differentiates them. This is the sufficiency test from §05.
Is the dimension small and discrete?#
Add it freely if it's ≤ 2–3 values (holding yes/no, color 0–2). If it's large (e.g. an index into a big array), it may blow up complexity — look for a way to compress or eliminate it.
After adding: re-derive the transition#
The new dimension changes the transition — you now branch over it. Write the full recurrence with the new state and verify the base cases cover the new dimension's range.
Complexity intuition#
| Shape | States | Per state | Time |
|---|---|---|---|
| 1D | O(n) | O(1) | O(n) |
| 1D, k choices | O(n) | O(k) | O(nk) |
| 2D grid / alignment | O(nm) | O(1) | O(nm) |
| Interval | O(n²) | O(n) split | O(n³) |
| Bitmask + position | O(2ⁿ·n) | O(n) | O(2ⁿ·n²) |
The mistakes that cost the most points#
dp[][]. Space-optimize before it's correct. Seed base cases with the wrong identity. Cache a tight digit-DP state. Iterate 1D knapsack the wrong direction.