Study Notes
Notesapplied

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.

30 min read·verified Content review 2026·reviewed 2026-08-30

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
Discovery EngineInteractive10 Patterns
DSA · The Thinking Course

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.

For: Java solvers who freeze on medium/hard DP Style: first-principles · interactive Goal: solve unseen problems
By the end you will not just know DP solutions — you'll own a repeatable procedure for manufacturing them. When you hit a new problem, you'll have concrete questions to ask, in order, that walk you from "I have no idea" to a working recurrence. That procedure is the whole game.
i

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:

01

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.

02

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.

03

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.

ii

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.


Part I

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:

Dynamic programming is brute force that refuses to answer the same question twice. That's it. You explore every possibility like brute force would — but the instant you notice you're solving a sub-question you've already solved, you look up the old answer instead of recomputing it.

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#

Model A · best
Cached recursion

Write the naive recursive brute force. Notice it recomputes. Bolt on a cache. This is how you'll actually derive solutions.

Model B
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.

Model C
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.

Analogy · the crossword

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.

Reframe

"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.

Quick check
What most separates someone who can solve an unfamiliar DP problem from someone who can't?

Part II

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#

Signal · asks
"Count the ways" / "how many"

Counting distinct paths, sequences, decodings, partitions. Counting almost always decomposes into sums of sub-counts.

Signal · asks
"Max / min / longest / shortest"

Optimization over a huge set of choices, where each choice constrains the next. The word "optimal" plus "sequence of decisions."

Signal · asks
"Is it possible to…"

Feasibility (can we make sum S? can we partition?) — a boolean DP where cells hold reachability.

Signal · structure
Sequential decisions

At each step you pick from options (take/skip, which coin, which cut), and picks interact. A chain of coupled choices.

Signal · structure
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.

Signal · the tell
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.

The reliable test, restated

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.

Q1

Are you asked for a max / min / count / feasibility over a set of choices? (Not "return the array sorted", not "find one element".)

Yes → keep goingThe objective is optimization/counting — DP's home turf.
No → probably not DPSorting, searching for one value, simulation, parsing. Look at other tools.
↓
Q2

Can a greedy rule (always take the locally best choice) provably give the global optimum?

Yes → use greedyIf a simple sort-then-take rule is provably correct, DP is overkill. (Interval scheduling, Huffman, some coin systems.)
No → DP is a strong candidateIf local-best can be beaten by looking ahead, you need to weigh futures — that's DP.
↓
Q3

Does the answer decompose into answers of smaller instances of the same problem? (Optimal substructure.)

Yes → keep goingYou can define solve(smaller) and build up. Half the battle.
No → rethinkMaybe it's a graph/flow problem, or needs a totally different decomposition. Or your subproblem definition is wrong — try another cut.
↓
Q4

In the brute-force recursion, do subproblems repeat? (Overlapping subproblems.)

Yes → it's DP. Memoize.Cache solve() on its arguments and you're done. This is the confirmation.
No → divide & conquerUnique subproblems (merge sort, quickselect, most binary-search-on-answer). Recursion without a cache.

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.

TechniqueCore ideaHow to tell it apart from DP
GreedyTake 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 & conquerSplit 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?"
BacktrackingEnumerate 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 graphsTraverse 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 windowMaintain 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 pointersTwo 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 searchHalve 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.
The most common misread: greedy vs DP

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.

Quick check
Which property, if present, most reliably confirms that a recursive brute force should become DP?
Recognize-it drill

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.


Part III · the core skill

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.

Steps 1–4 are 95% of the difficulty and require zero DP-specific knowledge — just honest brute force plus one observation. Steps 5–7 are mechanical. So when a problem feels impossible, you are almost never stuck on "the DP" — you're stuck on describing the choice and the recursion. Slow down there.

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.

Recursion tree · fib(5)

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.

GreedyTake biggest ≤ 6 → 4. Remaining 2. Biggest ≤ 2 → 1, then 1. Total: 4 + 1 + 1 = 3 coins. Feels fine… but is it optimal?
OptimalTake 3, remaining 3, take 3. Total: 3 + 3 = 2 coins. Greedy lost because picking the locally best coin (4) blocked the globally best pairing (3+3).

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.

Coin Change · coins {1,3,4}, amount 6 · min coins
cell being computed cells it reads final answer
// 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];
}
What just happened

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.

Now you drive · Climbing Stairs

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:

Wrong statedp[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.
Right stateBoth choices must be in the transition: 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#

GreedyTake the longest matching word at each position: "leet" at 0, "code" at 4. Works here… but try 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".
DPAt each position, try every dictionary word that matches, and check if the remainder is solvable. We compare all futures, not just the longest match.

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];
}
Key insight

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.

Quick check
In the Discovery Engine, where does “the state” come from?

Part IV · mechanics

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:

A state is the minimum information you must carry so that the rest of the problem can be solved without looking at the past. Ask literally: "If a friend took over right now, what few facts would I have to tell them so they could finish — without replaying everything I did?" Those facts are your state.

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 onDimension you addExample
How far along a sequence you arean index iHouse Robber: dp[i]
Position in two sequences at oncetwo indices i, jLCS / Edit Distance: dp[i][j]
A remaining budget / capacitya capacity wKnapsack: dp[i][w]
Which discrete "mode" you're ina small enum kStock with cooldown: dp[i][held?]
Which subset is used (n ≤ ~20)a bitmaskTSP: dp[mask][last]
A running position in a gridrow, colUnique Paths: dp[r][c]

The three state diseases (and their cures)#

Disease · too much
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.

Disease · too little
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.

Disease · too big
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.

Hidden state — the sneaky one, with an example

"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.

Derive-the-state drill

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.

Pitfall 1

Best Time to Buy/Sell Stock with Cooldown#

"maximize profit; after selling, must wait one day before buying"
Wrong state: dp[i] = max profit up to day i

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.

Fix: dp[i][state] where state ∈ {holding, sold_today, resting}

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.

Pitfall 2

Longest Increasing Subsequence#

"find the length of the longest strictly increasing subsequence"
Wrong state: dp[i] = LIS length in the whole array arr[0..i]

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.

Fix: dp[i] = LIS length ending exactly at index i

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.

Pitfall 3

Palindromic Substrings#

"count the number of palindromic substrings in a string"
Wrong state: dp[i] = count of palindromic substrings in s[0..i]

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.

Fix: dp[i][j] = is s[i..j] a palindrome? (boolean)

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.

When your DP gives wrong answers, run these three checks in order: (1) Are two genuinely different situations sharing a cache key? → add a dimension (hidden state). (2) Can the transition access the info it needs to extend? → add a pinning constraint. (3) Is the answer shape the wrong type for composing? → store the building-block info, compute the answer afterward.

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.

For a state 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 asksCombine sub-answers withTransition shape
Minimum / maximumMath.min / Math.maxdp[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 / expectationweighted sumdp[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;
}
Two directions to think a transition — both valid

“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.

Fill-in-the-transition

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.

Look at your recursive 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 operatorIdentity (the "empty" answer)Base value for an empty/degenerate subproblem
sum (counting)… of ways to do nothing = 1dp[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 = truedp[sum 0] = true
The classic base-case bug

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.

Top-down · Memoization

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).
Bottom-up · Tabulation

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.
Always start top-down. It's the direct translation of the Discovery Engine and the fastest route to a correct solution. Convert to bottom-up only when you need to: (a) avoid stack overflow on deep recursion, (b) squeeze constant factors, or (c) apply a rolling-array space cut. Many interviews accept the memoized version outright.

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].

Top-down (Fib)
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
}
Bottom-up (Fib) — mechanical mirror
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.

You can throw away any DP cell that no future cell will ever read again. Look at the transition and note the furthest back it reaches. If 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] only1 previous valueO(1)
dp[i-1], dp[i-2]2 previous valuesO(1)
dp[i-1][*] (previous row)1 previous rowO(cols)
any earlier dp[j], j<ithe whole arrayO(n) — can't shrink
The subtle trap: iteration direction after flattening a 2D table to 1D

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.

0/1 Knapsack — 1D, descending
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]]);
Unbounded / Coin Change — 1D, ascending
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]]);
Quick check
A grid DP has 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.

While filling the DP table, also fill a choice table 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
}
The space trade-off

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.

When you don't have a from[] table

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."


Part V · pattern library

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.

Family 1 · Linear sequence

1D DP over a line#

state: dp[i] = answer for prefix ending at / considering index i

Intuition: 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.

Clues it's this family
▸1D array, decisions made position by position
▸"adjacent"/"can't pick neighbors"/"steps"
▸dp[i] depends on dp[i-1], dp[i-2]
▸Usually O(1)-space optimizable
Template
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];
}
Common mistakes
  • 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.
Problem ladder
  1. Climbing Stairs easyThe pure Fibonacci recurrence — derive it from "how did I arrive?"
  2. House Robber medAdds the take/skip choice with a constraint. The canonical 1D.
  3. House Robber II medCircular array → run the line twice with two windows. Teaches problem reduction.
  4. Decode Ways medCounting variant with validity checks on 1- and 2-digit chunks.
  5. Jump Game II med1D min-jumps; also has a greedy — great greedy-vs-DP contrast.
Family 2 · Knapsack / subset

Choose items under a budget#

state: dp[i][cap] = best using first i items with capacity cap

Intuition: 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.

Clues it's this family
▸Pick a subset to hit/maximize under a capacity
▸"can we reach sum S?", "fewest coins", "max value ≤ W"
▸Each item: use once (0/1) or unlimited (unbounded)
▸Capacity is small enough to be a dimension
Template — 0/1 (each item once), space-optimized
// 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];
}
Template — unbounded (reuse items)
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]]);
Common mistakes
  • 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.
Problem ladder
  1. Partition Equal Subset Sum medBoolean 0/1 knapsack — "reach sum total/2?" First real subset DP.
  2. Coin Change (min) medUnbounded, minimize count. The worked example in Part III.
  3. Coin Change II (count) medUnbounded, count combinations. Teaches the loop-order-controls-meaning subtlety.
  4. Target Sum medSigns +/− → transform to a subset-sum knapsack. Reduction practice.
  5. Ones and Zeroes medTwo capacities at once → 2D knapsack. Scales the idea.
Family 3 · Two-sequence / subsequence

Aligning & comparing sequences#

state: 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.

Clues it's this family
▸Two sequences compared: common/edit/interleave
▸"subsequence", "edit", "transform A into B"
▸Answer built from a match (diagonal) vs mismatch (up/left)
▸Single-sequence LIS is the 1D cousin (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.

Longest Common Subsequence · A="ABC", B="BC"
computing reads (up/left/diag) LCS length
Template — LCS
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];
}
Common mistakes
  • Indexing: dp is 1-bigger than the strings; A.charAt(i-1) pairs with dp[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.
Problem ladder
  1. Longest Increasing Subsequence medThe 1D "ending at i" state; then learn the O(n log n) trick.
  2. Longest Common Subsequence medThe canonical 2D alignment (the widget above).
  3. Edit Distance hardThree operations → three-way transition. The boss of this family.
  4. Distinct Subsequences hardCounting alignments — subtle take/skip on a match.
  5. Interleaving String medFeasibility over two pointers — a boolean cousin.
Family 4 · Grid

Paths & costs on a 2D board#

state: dp[r][c] = answer for reaching cell (r, c)

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.

Clues it's this family
▸2D grid + restricted movement (right/down, diagonals)
▸"count paths", "min/max path sum", "reach corner"
▸Each cell reads a fixed few neighbors (up, left, diag)
▸Obstacles → those cells contribute 0 / are skipped

Watch Unique Paths fill: each cell = paths from above + paths from left. Blue cells are the two it reads.

Unique Paths · 3×3 grid · right/down only
computing up + left paths to corner
Template — Min Path Sum
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];
}
Common mistakes
  • 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)).
Problem ladder
  1. Unique Paths medPure counting grid (the widget). Start here.
  2. Minimum Path Sum medSame structure, min objective + cell costs.
  3. Unique Paths II medObstacles → handling invalid predecessors.
  4. Triangle medNon-rectangular grid; bottom-up shines.
  5. Maximal Square medCell depends on three neighbors (up, left, diag) → clever transition.
Family 5 · Interval

Solve ranges by their split point#

state: 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.

Clues it's this family
▸"merge stones", "burst balloons", "cut/partition a line"
▸Cost depends on the order you combine/remove within a range
▸Answer = best over an internal split k
▸O(n²) states × O(n) split → O(n³) typical
Template — try every split
// 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];
The mental unlock

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.

Common mistakes
  • 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]).
Problem ladder
  1. Min Cost Tree From Leaf Values medGentle intro to "combine a range."
  2. Palindrome Partitioning II hardMin cuts — range feasibility + partition.
  3. Burst Balloons hardThe "resolved last" reframing. A rite of passage.
  4. Minimum Cost to Merge Stones hardInterval DP with a divisibility twist.
Family 6 · Tree

DP on rooted trees (DFS + combine children)#

state: dp[node][state], computed from children's dp

Intuition: 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).

Clues it's this family
▸Input is a tree / the graph is acyclic & rooted
▸"choose nodes with a parent/child constraint"
▸Answer for a subtree built from child subtrees
▸Node often needs 2 states (take it / don't)
Template — House Robber III (rob tree)
// 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]); }
Common mistakes
  • 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.
Problem ladder
  1. Diameter of Binary Tree easyCombine children heights — the simplest tree DP.
  2. House Robber III medTake/skip state per node (template above).
  3. Binary Tree Max Path Sum hardReturn one value, update a global — a classic tree-DP shape.
  4. Binary Tree Cameras hardThree-state node DP + greedy flavor.
Family 7 · Bitmask

State = which subset is used (n ≤ ~20)#

state: dp[mask][...], mask's bits = elements already used

Intuition: 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.

Clues it's this family
▸Small n (≤ 20–22) with "assign / visit / cover all"
▸"visit every city", "assign tasks to people", "cover all"
▸You'd otherwise permute — but subproblems repeat by subset
▸Answer depends on the set used, not the order
Template — Travelling Salesman (min tour)
// 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]);
    }
Common mistakes
  • 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.
Problem ladder
  1. Shortest Superstring hardTSP-style over strings.
  2. Partition to K Equal Sum Subsets medSubset-cover with a used-mask.
  3. Maximum Students Taking Exam hardRow masks with adjacency constraints — profile DP.
Family 8 · Digit

Count numbers in [L, R] with a property#

state: dp[pos][tight][...property] over the digits

Intuition: 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).

Clues it's this family
▸"how many numbers in [L, R] such that …digits…"
▸Range bounds are astronomically large (up to 10¹⁸)
▸Property is about digits (sum, no repeats, no "13")
▸Answer via f(R) − f(L−1)
Template — skeleton
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;
}
Common mistakes
  • Caching tight states — you generally don't memoize when tight is true (those are unique to N's prefix).
  • Leading zeros: sometimes need a started flag when the property cares about the first significant digit.
Problem ladder
  1. Numbers With Unique Digits medCombinatorial warm-up before full digit DP.
  2. Numbers With Repeated Digits hardComplement + digit DP with a used-mask.
  3. Count of Integers (digit-sum bounds) hardClassic tight + running digit sum.
Family 9 · Game (minimax)

Two optimal players, alternating turns#

state: dp[range/state] = best score for the player to move

Intuition: 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.

Clues it's this family
▸"two players", "both play optimally", "who wins"
▸Alternating turns, take from ends / piles
▸Opponent is adversarial (they minimize your gain)
▸Often an interval DP under the hood
Template — Stone Game (pick from either end)
// 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 margin
The relative-score trick

Instead 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.

Common mistakes
  • 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.
Problem ladder
  1. Stone Game medThe relative-score interval template above.
  2. Predict the Winner medSame idea, general values.
  3. Stone Game II medAdds a variable window (M) → extra state dimension.
Family 10 · Probability / Expectation

Expected values & probabilities over random processes#

state: dp[config] = probability / expected value from here

Intuition: 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.

Clues it's this family
▸"probability that…", "expected number of…"
▸Random moves/steps with known transition probabilities
▸Answer is a real number, transitions are weighted sums
▸Often "after k steps" adds a time dimension
Template — Knight probability on a board
// 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;
}
Common mistakes
  • 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.
Problem ladder
  1. Knight Probability in Chessboard medThe template above — time-stepped probability.
  2. Soup Servings medExpectation with a neat large-N shortcut.
  3. New 21 Game medProbability + sliding-window sum optimization.
Family 11 · String / Segment

DP over positions in a string#

state: 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.

Clues it's this family
▸"segment the string", "can it be formed from dictionary words"
▸"count palindromic substrings", "min cuts to make all parts palindromes"
▸"does the string match this pattern/regex"
▸Transitions try every valid substring starting/ending at i
Template — Word Break (feasibility, right-to-left)
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];
}
Template — Palindrome Partitioning (min cuts, left-to-right)
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];
}
The two-pass pattern

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.

Common mistakes
  • Wrong fill direction: if dp[i] reads dp[i + len] (future positions), fill right-to-left. If it reads dp[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 state dp[i][j] (text pos, pattern pos) must handle "match zero" (skip the x* entirely) vs "match one more" (consume a text char and keep the x*).
Problem ladder
  1. Word Break medFeasibility DP — the template above. First string DP.
  2. Palindromic Substrings medBoolean interval DP — "which substrings are palindromes?"
  3. Palindrome Partitioning II hardTwo-pass: pal table + 1D min-cuts DP. Combines families.
  4. Regular Expression Matching hard. and * — the hardest string DP transition. Worth studying.
Family 12 · DP + Binary Search

When the transition needs a fast lookup#

state: dp[i] + binary search over valid predecessors / the answer

Intuition: 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).

Clues it's this family
▸LIS-like: "for each i, find best j < i with some constraint"
▸"minimize the maximum" or "maximize the minimum" over splits/subarrays
▸O(n²) DP that you need to speed to O(n log n)
▸The feasibility check for a given threshold is itself a DP
Template — LIS in O(n log n) (patience sorting)
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();
}
Template — Split Array Largest Sum (BS + greedy/DP check)
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;
}
Which flavor is this?

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).

Common mistakes
  • In LIS O(n log n), tails is not the actual subsequence — it's an auxiliary array for the length. Reconstructing the actual LIS requires a separate parent[] array (see §09b).
  • In flavor B, setting lo too low (e.g. 0 instead of max(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.
Problem ladder
  1. Longest Increasing Subsequence medFirst do the O(n²) DP, then learn the O(n log n) BS upgrade. Two mindsets in one.
  2. Split Array Largest Sum hardBS on answer + greedy check. The canonical flavor-B problem.
  3. Divide Chocolate hardSame as Split Array but "maximize the minimum" — BS direction flips.
  4. Russian Doll Envelopes hard2D LIS — sort one dimension, BS-LIS on the other. Extends the technique.
When you meet a new problem, don't ask "what's the formula?" Ask "which family do the clues match?" That tells you the state shape to try. Then run the Discovery Engine to fill in the transition. Recognition (family) + derivation (engine) is the entire skill.

Part VI · reference & practice

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 solution
Template discipline

Notice 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.

Technique 1

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.

Technique 2
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).

Technique 3
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.

Technique 4
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.

Technique 5
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.

Technique 6
Watch for overflow & INF arithmetic

INF + cost overflows if INF = Integer.MAX_VALUE. Use MAX_VALUE/2, or guard before adding.

Reading a coin-change table by hand (coins {1,3,4})
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.

Your buggy output — ascending loop (counts permutations, not combinations)
amount:   0   1   2   3   4   5
dp[]:      1   1   2   3   5   9
                         ^???
     expected dp[5] = 4, got 9 — DOUBLE-COUNTED

Step 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.

Fixed — descending loop (each coin once per pass)
amount:   0   1   2   3   4   5
dp[]:      1   1   2   2   3   4   ✓
Debugging checklist (run top to bottom)

① 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.)

Interview meta-moves

• 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#

  1. Fibonacci Number The literal "cache a recursion" exercise. Do it memoized, then bottom-up, then O(1) space — three forms of one idea.
  2. Climbing Stairs Same recurrence, but you must derive it from the problem ("how did I arrive?"). Bridges Fib → real problems.
  3. Min Cost Climbing Stairs Adds an objective (min) and per-step cost — your first optimization DP.

Tier 1 · The take/skip choice medium#

  1. House Robber Introduces a constrained choice (can't take neighbors). The prototypical 1D DP.
  2. Maximum Subarray (Kadane) "extend or restart" — a DP you can compress to one variable. Teaches state = "best ending here."
  3. 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#

  1. Unique Paths → Minimum Path Sum First 2D table; "predecessor" thinking. Count then min — same skeleton.
  2. Longest Common Subsequence Two pointers over two strings; match=diagonal vs mismatch=max. The template for a whole family.
  3. Coin Change Unbounded knapsack; loop-direction / reuse subtlety. The most-tested DP.
  4. Partition Equal Subset Sum 0/1 knapsack as feasibility; the once-vs-reuse contrast with Coin Change.

Tier 3 · Harder states & new shapes hard#

  1. Longest Increasing Subsequence "ending at i" state; then the O(n log n) upgrade. Two mindsets in one problem.
  2. Edit Distance Three-way transition; the peak of the alignment family.
  3. Word Break DP over string positions with a dictionary — feasibility with a twist.
  4. Burst Balloons Interval DP + the "resolved last" reframing. If you get this, you've arrived.

Tier 4 · Specialist families hard#

  1. House Robber III Tree DP with per-node state.
  2. Stone Game Game/minimax with the relative-score trick.
  3. Partition to K Equal Sum Subsets Bitmask subset DP.
  4. Knight Probability Probability DP with a time dimension.

Tier 5 · String DP & DP + Binary Search hard#

  1. Word Break Feasibility string DP — the canonical entry to string/segment family.
  2. Palindrome Partitioning II Two-pass: palindromic table + 1D min-cuts. Combines interval and string DP.
  3. Regular Expression Matching Hardest string DP — . and * with "match zero or more" semantics.
  4. Split Array Largest Sum BS on answer + greedy check. The canonical DP+BS combo.
How to practice, not just solve

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#

1 · one-step choice 2 · brute-force recursion 3 · spot repeats 4 · read state off args 5 · memoize 6 · tabulate 7 · shrink space

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 asksCombineBase (identity)"Impossible" (annihilator)
MinimumMath.min0 (empty)+INF
MaximumMath.max0 (empty)-INF
Count ways+1 (empty = one way)0
Feasibility||true (empty target)false
Expected valueΣ p·vproblem-specific—

State shape by family#

FamilyStateTransition heart
Lineardp[i]take vs skip, from dp[i-1], dp[i-2]
Knapsackdp[i][cap] → 1Duse item vs not; direction = once/reuse
Two-sequencedp[i][j]match→diag; mismatch→up/left
Griddp[r][c]from up + left (or 3 neighbors)
Intervaldp[i][j]best over split k; fill by length
Treedp[node][s]combine children in post-order
Bitmaskdp[mask][i]add one unused bit
Digitdp[pos][tight][prop]place next digit ≤ limit
Gamedp[i][j] (relative)myGain − dp[smaller]
Probabilitydp[step][config]Σ p·dp[next]
String/Segmentdp[i] + pal tabletry every valid chunk at position i
DP + Binary Searchdp[i] + BS, or BS + checkBS finds predecessor (A) or validates threshold (B)

State design decision guide — "should I add a dimension?"#

Symptom you're seeingWhat it meansAction
Wrong answers, but table looks internally consistentHidden state — two situations share a keyAdd a dimension for the fact that distinguishes them (holding? just-sold? color?)
Can't write the transition — missing info to extendNo pinning constraintRefine the state definition ("ending at i", "starting from i") so the last element is known
Correct answers but TLE/MLEState explosion or O(n²) where O(n log n) existsCompress (bitmask, BS-inside-DP) or find a smaller equivalent state
Answer is a count but you can't compose countsWrong answer shape for the stateStore the building block (boolean per substring) and sum at the end
Right answer, but you're not sure if it's always rightInsufficient state — haven't proven the Markov propertyRun 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#

Time = (number of states) × (work per state).   States = product of your dimensions' ranges. Work per state = number of choices in the transition.   Space = states (minus whatever the space-cut principle lets you drop). Say this out loud for every DP and you'll never miscompute complexity.
ShapeStatesPer stateTime
1DO(n)O(1)O(n)
1D, k choicesO(n)O(k)O(nk)
2D grid / alignmentO(nm)O(1)O(nm)
IntervalO(n²)O(n) splitO(n³)
Bitmask + positionO(2ⁿ·n)O(n)O(2ⁿ·n²)

The mistakes that cost the most points#

Don'tJump straight to guessing 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.
DoRecursion-first, always. Get O(n·states) correct, then shrink. Base = identity of your combine op. Cross-check vs brute force on small n. Read the table when it's wrong.
DP was never about clever formulas. It's brute force that refuses to repeat itself, plus a fixed procedure for finding which brute force. You've now seen that procedure a dozen times. The last step is yours: open the roadmap, and on every problem, run the engine before you look at any hint. That habit — not this page — is what makes you someone who solves unseen DP.