Say the recurrence before you code
Ways add. Scores take max or min. The same suffix is asked more than once — that is the overlapping subproblem. Write the recurrence on paper, then memo, then a table, then rolling variables. Notebook Java for named families lives behind the tables, not in this chapter.
Prev: 03 Backtracking · Next: 05 Greedy
Dynamic Programming
Five shapes: Fibonacci ways, 1D skip/take, grid DAG, 0/1 subset sum, two-string LCS. Each card has a simple Java version, an interview version, a tiny example, and a second example that does not skip rows.
Climbing Stairs — recursion → memo → tabulation
Recognize it from
“Number of ways to climb n stairs taking 1 or 2 steps.” Same shape: decode a digit string (one digit or two), tile a 2×n board with 1×2 and 2×1. The clue is ways add, not “max score.”
What it actually does
The last step onto stair n is either a single step from n−1 or a double step from n−2. Those two groups of paths are disjoint, so ways(n) = ways(n−1) + ways(n−2). That is Fibonacci with ways(1)=1, ways(2)=2.
Plain recursion recomputes the same smaller stair over and over — look at climb(2) in Example 1. Memo stores each n once. Tabulation fills 1, 2, 3, … in order. After that you only need two rolling integers, because the recurrence looks two cells back.
Simple — read this first
public int climbStairs(int n) {
int[] memo = new int[n + 1];
return climb(n, memo);
}
private int climb(int n, int[] memo) {
if (n <= 2) return n; // n=1 → one single step; n=2 → 1+1 or 2
if (memo[n] != 0) return memo[n]; // climb(2) is asked from both climb(4) and climb(3)
memo[n] = climb(n - 1, memo) + climb(n - 2, memo); // last step was a 1 or a 2 — ways add
return memo[n];
}
Interview — type this
public int climbStairs(int n) {
if (n <= 2) return n;
int prev2 = 1; // ways(1)
int prev1 = 2; // ways(2)
for (int i = 3; i <= n; i++) {
int cur = prev1 + prev2; // only the last two stairs matter
prev2 = prev1;
prev1 = cur;
}
return prev1;
}
Example 1 — simple
n = 4. Naive recursion (no memo) so you can see the overlap. Five layouts: 1+1+1+1, 1+1+2, 1+2+1, 2+1+1, 2+2.
| Call | Does | Returns |
|---|---|---|
| climb(4) | climb(3) + climb(2) | waits |
| climb(3) | climb(2) + climb(1) | waits |
| climb(2) | base n≤2 | 2 |
| climb(1) | base n≤2 | 1 |
| climb(3) returns | 2 + 1 | 3 |
| climb(2) again | base — not reused | 2 |
| climb(4) returns | 3 + 2 | 5 |
Answer: 5 ways. With memo, the second climb(2) would read memo[2] and skip the base case.
Example 2 — every step
n = 5, interview rolling variables. Start prev2 = ways(1) = 1, prev1 = ways(2) = 2. Every i from 3 through 5:
| i | prev2 (i−2) | prev1 (i−1) | cur = prev1 + prev2 | then prev2, prev1 |
|---|---|---|---|---|
| start | 1 | 2 | — | ways(1), ways(2) |
| 3 | 1 | 2 | 3 | 2, 3 |
| 4 | 2 | 3 | 5 | 3, 5 |
| 5 | 3 | 5 | 8 | 5, 8 |
Answer: 8. The eight layouts of five stairs with 1s and 2s. A memo array would hold the same numbers in cells 1…5; the two variables are that array with the old cells thrown away.
You store answers by stair index because two different paths to stair k share the same suffix. An array (or two ints) is enough — there is no other dimension. Decode-ways is the same recurrence with extra “is this digit legal” guards.
- Returning Fibonacci’s F(n) with F(1)=1, F(2)=1. That under-counts n=2 (there are two ways, not one).
- Memo of size n, then writing memo[n]. Off-by-one: you need index n, so allocate n+1.
- Thinking the answer is n. Ways are not steps. n=4 is 5 ways.
| Problem | Pattern | Links |
|---|---|---|
| Decode Ways II family (same “1 or 2 at the end”) | ways add; extra digit-pair legality | cheat card · clue |
| “Number of ways” on a grid or a string | define the last decision; add incoming counts | clue |
House Robber — 1D skip / take, then the circle
Recognize it from
“Maximum money / points, cannot take two adjacent.” Linear street, then the follow-up: houses stand in a circle so the first and last are neighbors. Not House Robber on a tree (that is a different recurrence on left/right children).
What it actually does
At house i you either skip it and keep the best of i−1, or take it and add the best of i−2. Those are the only two legal endings. A dp array makes that visible; two rolling integers are the same pair of cells.
On a circle, taking house 0 forbids house n−1. You cannot encode that with one linear pass. Run the linear robber on [0 .. n−2] (drop the last) and on [1 .. n−1] (drop the first), then take the max. n = 1 is a special case: both ranges would be empty if you are not careful.
Simple — read this first
public int rob(int[] a) {
int n = a.length;
if (n == 0) return 0;
if (n == 1) return a[0];
int[] dp = new int[n];
dp[0] = a[0];
dp[1] = Math.max(a[0], a[1]); // two houses: take the richer one
for (int i = 2; i < n; i++) {
int skip = dp[i - 1]; // leave a[i] on the table
int take = dp[i - 2] + a[i]; // a[i-1] is then forbidden
dp[i] = Math.max(skip, take);
}
return dp[n - 1];
}
Interview — type this
public int rob(int[] a) {
int n = a.length;
if (n == 0) return 0;
if (n == 1) return a[0]; // a one-house circle is just that house
return Math.max(robLine(a, 0, n - 2), robLine(a, 1, n - 1));
}
private int robLine(int[] a, int lo, int hi) {
int prev2 = 0, prev1 = 0; // best ending two back, one back
for (int i = lo; i <= hi; i++) {
int cur = Math.max(prev1, prev2 + a[i]);
prev2 = prev1;
prev1 = cur;
}
return prev1;
}
Example 1 — simple
Linear street [1, 2, 3, 1]. Best is house 0 + house 2 = 4 (the two 1s at the ends are not adjacent on a line).
| i | a[i] | skip = dp[i−1] | take = dp[i−2]+a[i] | dp[i] |
|---|---|---|---|---|
| 0 | 1 | — | 1 | 1 |
| 1 | 2 | 1 | 2 (no i−2 yet, just a[1]) | 2 |
| 2 | 3 | 2 | 1 + 3 = 4 | 4 |
| 3 | 1 | 4 | 2 + 1 = 3 | 4 |
Answer: 4.
Example 2 — every step
Circle [2, 3, 2]. Taking both ends is 2+2 = 4 — illegal, because index 0 and index 2 touch. Run both linear ranges. n = 3, ranges are [0..1] and [1..2].
| i | a[i] | skip (prev1) | take (prev2+a[i]) | cur | then prev2, prev1 |
|---|---|---|---|---|---|
| 0 | 2 | 0 | 0 + 2 = 2 | 2 | 0, 2 |
| 1 | 3 | 2 | 0 + 3 = 3 | 3 | 2, 3 |
| i | a[i] | skip (prev1) | take (prev2+a[i]) | cur | then prev2, prev1 |
|---|---|---|---|---|---|
| 1 | 3 | 0 | 0 + 3 = 3 | 3 | 0, 3 |
| 2 | 2 | 3 | 0 + 2 = 2 | 3 | 3, 3 |
Answer: max(3, 3) = 3 — the middle house. The illegal 4 never appears in either range, because each range dropped one end.
Adjacent constraint means the state is only “best ending at i.” The circle adds one boolean (did I use house 0?) which splits into two independent lines. Circular Kadane (max wrap-around subarray) is a different circular trick — sums a contiguous block, not skip/take.
- Running one linear pass on a circle. That can take both ends.
- n = 1: calling both ranges and getting max(0, 0). Return a[0].
- Stamping the tree version (rob left/right with a robbed-parent flag) onto a street.
| Problem | Pattern | Links |
|---|---|---|
| House Robber I → II (circular) | skip/take, then max of two linear ranges | Java in ch.05 · cheat card · clue |
| Kadane circular (neighbor, not robber) | wrap-around subarray max, not adjacent-skip | cheat card |
| Buy/sell + rain (neighbor) | one-transaction running min, not skip-take | cheat card |
Unique Paths / grid DP — right and down on a DAG
Recognize it from
“Start cell to end cell, only right and down (maybe left/up forbidden), count paths or max score, skip traps.” Obstacles as 1 or −1. Follow-up is often max reward on the same DAG. Four-direction increasing path is not this — that needs memo DFS.
What it actually does
Right and down only means every cell has at most two parents: above and left. The graph is a DAG, so a table in row-major order is a valid topological fill. Path count adds the two parents. Path value takes max or min, then adds this cell. Do not reuse a count table as if it were a score.
A rolling 1D array is the previous row. When you visit column j you overwrite it; read “up” before the write, and “left” from dp[j−1] which is already this row. An obstacle must be written as 0 (count) or a sentinel (max), or the next cell will add a stale number that walked through the block.
Simple — read this first
public int uniquePaths(int m, int n) {
int[][] dp = new int[m][n];
for (int i = 0; i < m; i++) dp[i][0] = 1; // only down the first column
for (int j = 0; j < n; j++) dp[0][j] = 1; // only right along the first row
for (int i = 1; i < m; i++) {
for (int j = 1; j < n; j++) {
dp[i][j] = dp[i - 1][j] + dp[i][j - 1]; // ways add from above and left
}
}
return dp[m - 1][n - 1];
}
Interview — type this
public int uniquePathsWithObstacles(int[][] g) {
int m = g.length, n = g[0].length;
int[] dp = new int[n]; // dp[j] is "this column, previous row" until we overwrite
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
if (g[i][j] == 1) {
dp[j] = 0; // blocked: contributes no paths; must clear a stale up-count
continue;
}
if (i == 0 && j == 0) {
dp[0] = 1;
continue;
}
int up = i > 0 ? dp[j] : 0; // still the previous row at column j
int left = j > 0 ? dp[j - 1] : 0; // already this row
dp[j] = up + left;
}
}
return dp[n - 1];
}
Example 1 — simple
Empty 2×3 grid. Three paths: right-right-down, right-down-right, down-right-right.
| cell | from up | from left | dp |
|---|---|---|---|
| (0,0) | — | — | 1 (start) |
| (0,1) | none (first row) | 1 | 1 |
| (0,2) | none | 1 | 1 |
| (1,0) | 1 | none (first col) | 1 |
| (1,1) | 1 | 1 | 2 |
| (1,2) | 1 | 2 | 3 |
Answer: 3 paths.
Example 2 — every step
3×3 with one obstacle at the center (1,1). Count paths, obstacle = 1. Every cell:
| cell | obstacle? | up | left | dp |
|---|---|---|---|---|
| (0,0) | no | — | — | 1 |
| (0,1) | no | 0 (no up) | 1 | 1 |
| (0,2) | no | 0 | 1 | 1 |
| (1,0) | no | 1 | 0 (no left) | 1 |
| (1,1) | YES | ignored | ignored | 0 |
| (1,2) | no | 1 | 0 | 1 |
| (2,0) | no | 1 | 0 | 1 |
| (2,1) | no | 0 | 1 | 1 |
| (2,2) | no | 1 | 1 | 2 |
Answer: 2. The four paths that would have stepped on the center die. Max-reward on the same grid is a different recurrence: grid[i][j] + max(up, left), still skipping the zeroed cell — see the notebook max-reward cards, not this count.
Parents of a cell are a constant two (or four on a falling-path DAG). That is a table, not BFS: BFS counts hops, not path combinations. Longest increasing path in four directions is not a DAG from the origin — memoize DFS from each cell instead.
- Forgetting to zero an obstacle on a rolling array. The previous row’s count leaks through the block.
- Using max when they asked for the number of ways, or adding when they asked for max reward.
- Allowing left/up after you already filled as a right/down DAG — then you need a different graph algo.
| Problem | Pattern | Links |
|---|---|---|
| Unique Paths II / obstacle grid | count, zero obstacles, right/down | Java in ch.05 · cheat card · clue |
| Grid: count paths then max reward | two recurrences on the same DAG | Java in ch.05 · cheat card · clue · clue · R4 coding |
| Falling path / Prince right-diag max | DAG max, not path count | cheat card · cheat card |
| Longest increasing path in a matrix | 4-dir memo DFS — not Unique Paths DP | Java in ch.05 · cheat card |
0/1 knapsack / partition — boolean subset sum
Recognize it from
“Can you pick a subset that sums to T?” Partition equal subset sum is T = total/2. Target-sum with +/− signs is the same boolean after algebra. If they want the actual subset, keep parent pointers or backtrack when n is tiny — the boolean table only answers yes/no.
What it actually does
Each number may be used at most once. After considering the first i items, dp[i][w] is whether sum w is possible. Transition: skip the item (copy the previous row) or take it if w ≥ x and dp[i−1][w−x] was already true. That previous-row read is what makes it 0/1 rather than unbounded.
The 1D roll overwrites the same array. Looping capacity downward means dp[w−x] still belongs to the previous item. Looping upward lets this same item satisfy dp[w−x] and then immediately satisfy dp[w] — using x twice. Example 2 is that bug.
Simple — read this first
public boolean canPartition(int[] nums) {
int sum = 0;
for (int x : nums) sum += x;
if (sum % 2 != 0) return false; // odd total cannot split in half
int T = sum / 2;
int n = nums.length;
boolean[][] dp = new boolean[n + 1][T + 1];
for (int i = 0; i <= n; i++) dp[i][0] = true; // sum 0: take nothing
for (int i = 1; i <= n; i++) {
int x = nums[i - 1];
for (int w = 1; w <= T; w++) {
dp[i][w] = dp[i - 1][w]; // skip this item
if (w >= x) dp[i][w] = dp[i][w] || dp[i - 1][w - x]; // 0/1: previous row only
}
}
return dp[n][T];
}
Interview — type this
public boolean canPartition(int[] nums) {
int sum = 0;
for (int x : nums) sum += x;
if ((sum & 1) != 0) return false;
int T = sum / 2;
boolean[] dp = new boolean[T + 1];
dp[0] = true;
for (int x : nums) {
for (int w = T; w >= x; w--) { // reverse: dp[w-x] is still "without this x"
dp[w] = dp[w] || dp[w - x];
}
}
return dp[T];
}
Example 1 — simple
nums = [1, 5, 11, 5], total 22, target T = 11. Two yes subsets: {11} and {1,5,5}.
| item x | loop w = 11 down to x | true capacities after this item |
|---|---|---|
| (start) | dp[0] = true | {{0}} |
| 1 | w=1: dp[1] |= dp[0] | {{0,1}} |
| 5 | w=6 |= dp[1]; w=5 |= dp[0] | {{0,1,5,6}} |
| 11 | w=11 |= dp[0] | {{0,1,5,6,11}} |
| 5 | w=11 already T; w=10 |= dp[5] | {{0,1,5,6,10,11}} |
Answer: true — dp[11] is set after the 11, and stays set.
Example 2 — every step
Why capacity must go down. One item of value 5, capacity 10. 0/1 can make 0 or 5, never 10. Upward reuses the 5.
| w | dp[w] before | dp[w−5] | dp[w] after | meaning |
|---|---|---|---|---|
| 10 | F | dp[5]=F | F | cannot take 5 twice — 5 is not in the table yet |
| 9 | F | dp[4]=F | F | |
| 8 | F | dp[3]=F | F | |
| 7 | F | dp[2]=F | F | |
| 6 | F | dp[1]=F | F | |
| 5 | F | dp[0]=T | T | first (and only) use of this 5 |
| w | dp[w] before | dp[w−5] | dp[w] after | meaning |
|---|---|---|---|---|
| 5 | F | dp[0]=T | T | first use of 5 — this part is legal |
| 6 | F | dp[1]=F | F | |
| 7 | F | dp[2]=F | F | |
| 8 | F | dp[3]=F | F | |
| 9 | F | dp[4]=F | F | |
| 10 | F | dp[5]=T | T | WRONG: uses the same 5 a second time |
Answer of the bug: dp[10] = true even though you owned only one 5. That is unbounded knapsack. 0/1 must not see a write from the same item. On [1,5,11,5] the same upward pass after the first 5 would mark 10 before the second 5 ever runs.
Boolean on achievable sums is enough for yes/no partition. If they want the combination, store a parent (which item last set dp[w]) or backtrack the table, or switch to recursion when n ≤ 20. Coin change that may reuse a coin is the upward / unbounded loop — opposite of this card.
- Looping w from 0 to T for 0/1. That is Example 2.
- Forgetting the odd-sum early false. You then search a half that is not an integer.
- Using the boolean table when they asked you to return the subset. Yes is not the list.
| Problem | Pattern | Links |
|---|---|---|
| Partition / target with combination return | 0/1 subset sum; reconstruct if they want the list | Java in ch.05 · cheat card |
LCS — two strings, three table moves
Recognize it from
“Longest common subsequence (not substring).” Letters may skip. Related speak: shortest common supersequence = n+m−LCS; delete-only distance = n+m−2·LCS. If they allow replace as well, that is edit distance — a third transition, not max of two.
What it actually does
A subsequence keeps order and may drop letters. dp[i][j] is the LCS of the first i letters of s and the first j of t. Empty prefix is 0, which is why the table is (m+1)×(n+1).
Three moves, only two of which fire on a given cell: if the current letters match, you must take the diagonal + 1 (keeping both letters is never worse than dropping one). If they differ, you drop one letter or the other and take the max. There is no “replace” in LCS. Edit distance adds a third option: cost 1 from the diagonal on a mismatch.
Simple — read this first
public int longestCommonSubsequence(String s, String t) {
int m = s.length(), n = t.length();
int[][] dp = new int[m + 1][n + 1]; // extra row/col of zeros for empty prefixes
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
if (s.charAt(i - 1) == t.charAt(j - 1))
dp[i][j] = dp[i - 1][j - 1] + 1;
else
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
}
}
return dp[m][n];
}
Interview — type this
public int longestCommonSubsequence(String s, String t) {
int m = s.length(), n = t.length();
int[][] dp = new int[m + 1][n + 1];
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
if (s.charAt(i - 1) == t.charAt(j - 1)) {
dp[i][j] = dp[i - 1][j - 1] + 1; // take the match; skip the letters in between
} else {
int skipS = dp[i - 1][j]; // drop s[i-1], keep t's prefix
int skipT = dp[i][j - 1]; // drop t[j-1], keep s's prefix
dp[i][j] = Math.max(skipS, skipT); // cannot take a mismatch into the subsequence
}
}
}
return dp[m][n];
}
Example 1 — simple
s = "ace", t = "abcde". The whole of s sits inside t.
| i, s[i−1] | j, t[j−1] | match? | transition | dp[i][j] |
|---|---|---|---|---|
| 1 a | 1 a | yes | dp[0][0]+1 | 1 |
| 1 a | 2 b | no | max(0, 1) | 1 |
| 1 a | 3 c | no | max(0, 1) | 1 |
| 1 a | 4 d | no | max(0, 1) | 1 |
| 1 a | 5 e | no | max(0, 1) | 1 |
| 2 c | 1 a | no | max(1, 0) | 1 |
| 2 c | 2 b | no | max(1, 1) | 1 |
| 2 c | 3 c | yes | dp[1][2]+1 = 1+1 | 2 |
| 2 c | 4 d | no | max(1, 2) | 2 |
| 2 c | 5 e | no | max(1, 2) | 2 |
| 3 e | 1 a | no | max(1, 0) | 1 |
| 3 e | 2 b | no | max(1, 1) | 1 |
| 3 e | 3 c | no | max(2, 1) | 2 |
| 3 e | 4 d | no | max(2, 2) | 2 |
| 3 e | 5 e | yes | dp[2][4]+1 = 2+1 | 3 |
Answer: 3 — ace.
Example 2 — every step
s = "ABCD", t = "ACBD" (length 4). Every cell of the 4×4 interior. LCS length 3 (e.g. ABD or ACD).
| i,s | j,t | match? | from | dp |
|---|---|---|---|---|
| 1 A | 1 A | yes | diag 0+1 | 1 |
| 1 A | 2 C | no | max(0,1) | 1 |
| 1 A | 3 B | no | max(0,1) | 1 |
| 1 A | 4 D | no | max(0,1) | 1 |
| 2 B | 1 A | no | max(1,0) | 1 |
| 2 B | 2 C | no | max(1,1) | 1 |
| 2 B | 3 B | yes | diag 1+1 | 2 |
| 2 B | 4 D | no | max(1,2) | 2 |
| 3 C | 1 A | no | max(1,0) | 1 |
| 3 C | 2 C | yes | diag 1+1 | 2 |
| 3 C | 3 B | no | max(2,2) | 2 |
| 3 C | 4 D | no | max(2,2) | 2 |
| 4 D | 1 A | no | max(1,0) | 1 |
| 4 D | 2 C | no | max(2,1) | 2 |
| 4 D | 3 B | no | max(2,2) | 2 |
| 4 D | 4 D | yes | diag 2+1 | 3 |
Answer: 3.
If they let you insert, delete, or replace, the mismatch cell becomes 1 + min(up, left, diagonal) instead of max(up, left). Classic walk: "horse" → "ros" costs 3 (replace h→r, delete r, delete e). Table bottom-right is 3:
Do not mix the two recurrences in one function. LCS never pays for a replace.
Each pair of prefixes has one optimal LCS length. The table index is (how much of s, how much of t). Count-of-smaller-after-self is a different table-of-pairs story: merge-sort counts, not a string DP.
- Using substring DP (contiguous) when they said subsequence (gaps allowed).
- On a mismatch, taking the diagonal anyway. That pretends unequal letters matched.
- Writing edit-distance min into an LCS max table, or the reverse.
| Problem | Pattern | Links |
|---|---|---|
| Count of smaller after self (neighbor) | merge-count inversions — not LCS | Java in ch.05 |
See also — same notebook cards, different recurrence
Grid count and grid max-reward share a DAG and different operators (add vs max). House Robber II is the circle split, not Kadane circular. Longest increasing path is memo DFS. Partition that must return the subset is this knapsack plus reconstruction, or the backtracking chapter when n is small.