13 min read
Data Structures & Algorithms2,841 words

Dynamic Programming

The most-feared topic, made mechanical. DP is just recursion whose subproblems overlap, so you compute each once and reuse it — and every DP is the same four decisions (state, transition, base, order). Learn to run that recipe and the "families" are just different shapes of state.

Series map: 00 playbook · 01 two pointers & sliding window · 02 binary search, stacks & intervals · 03 trees & graphs · 04 recursion & backtracking · 05 dynamic programming (this file) · 06 staff+ practical & OOD. Companion (non-series): data-structures-java-kotlin-study-guide.md — reach there for why a HashMap / ArrayDeque / array behaves as it does (buckets, boxing, cache locality). This guide uses those as tools and spends its words on the algorithm.


The mental model#

A dynamic program is a recursion whose subproblems overlap, so instead of recomputing them you compute each exactly once and reuse the result. Every DP is exactly four things: (1) a STATE that fully captures a subproblem, (2) a TRANSITION — the recurrence relating a state to smaller states, (3) BASE cases — the smallest states you answer directly, and (4) an ORDER of evaluation so every state's dependencies are ready before it. If you can write the brute-force recursion, the DP is already 90% done: you either "cache it" (top-down memoization) or "fill a table in dependency order" (bottom-up tabulation).

Two facts drive everything:

  • DP ⊂ recursion. The only thing that turns exponential brute force into polynomial DP is overlap: the same subproblem is reached by many paths. Naive Fibonacci recomputes fib(3) a huge number of times; memoizing collapses O(2ⁿ) to O(n). If subproblems don't overlap (e.g. generating all permutations, all distinct), memo buys nothing — that's backtracking (guide 04), not DP. The tell for DP is "count the ways" or "optimize (min/max)" over choices with reused subproblems, not "list all."
  • Top-down and bottom-up are the same recurrence, two directions. Top-down (memoized recursion) is closest to the brute force and easiest to get right — write it first. Bottom-up (a filled array) removes recursion overhead, makes the evaluation order explicit, and is what unlocks space optimization (rolling arrays). In a room: derive top-down, then offer bottom-up + O(1) space as the flourish.

The universal method — run this on any DP prompt:

StepQuestion to answerExample (climbing stairs)
1. StateWhat parameters fully describe a subproblem?i = which step I'm on
2. TransitionHow does a state combine smaller states?ways(i) = ways(i-1) + ways(i-2)
3. BaseSmallest states answered directlyways(0) = ways(1) = 1
4. OrderSequence so dependencies come firsti ascending (top-down handles it automatically)

Everything below is this table with different state shapes.


1. From recursion to DP, concretely — the four-step conversion#

Recognition cue. You can describe the answer as "the best/number of ways for input n, in terms of the same question on smaller inputs," and the recursion tree revisits subproblems.

Take the plainest example. Climbing stairs: you take 1 or 2 steps at a time; how many ways to reach step n? The recurrence is ways(n) = ways(n-1) + ways(n-2) (last move was a 1-step or a 2-step), base ways(0)=ways(1)=1. That is literally Fibonacci.

Stage 0 — brute-force recursion (exponential, but correct). The call tree for ways(5) computes ways(3) twice, ways(2) three times… O(φⁿ) calls. This is the version you should be able to write in your sleep; the DP is a transformation of it.

kotlin
fun climbNaive(n: Int): Int = if (n <= 1) 1 else climbNaive(n - 1) + climbNaive(n - 2) // O(2^n) — overlapping calls
java
int climbNaive(int n) { return n <= 1 ? 1 : climbNaive(n - 1) + climbNaive(n - 2); // O(2^n) }

Stage 1 — top-down memo (add a cache; O(n)). One line of thought: "before I recompute a state, check whether I already have it." The memo turns the tree into a DAG — each state solved once.

kotlin
fun climbStairs(n: Int): Int { val memo = IntArray(n + 1) { -1 } // -1 = "not computed yet" fun ways(i: Int): Int { if (i <= 1) return 1 // BASE if (memo[i] != -1) return memo[i] // reuse memo[i] = ways(i - 1) + ways(i - 2) // TRANSITION, memoized return memo[i] } return ways(n) }
java
int climbStairs(int n) { int[] memo = new int[n + 1]; java.util.Arrays.fill(memo, -1); return ways(n, memo); } int ways(int i, int[] memo) { if (i <= 1) return 1; if (memo[i] != -1) return memo[i]; return memo[i] = ways(i - 1, memo) + ways(i - 2, memo); }

Stage 2 — bottom-up tabulation (fill the array in order; still O(n)). The dependency order is obvious (i needs i-1, i-2), so iterate ascending. No recursion, no stack risk.

kotlin
fun climbStairs(n: Int): Int { if (n <= 1) return 1 val dp = IntArray(n + 1) dp[0] = 1; dp[1] = 1 // BASE for (i in 2..n) dp[i] = dp[i - 1] + dp[i - 2] // TRANSITION in ORDER return dp[n] }
java
int climbStairs(int n) { if (n <= 1) return 1; int[] dp = new int[n + 1]; dp[0] = 1; dp[1] = 1; for (int i = 2; i <= n; i++) dp[i] = dp[i - 1] + dp[i - 2]; return dp[n]; }

Stage 3 — space optimization (only the last two states matter; O(1)). The transition reads a fixed window (i-1, i-2), so keep two scalars, not an array. This is the "…and I can reduce it to O(1) space" line that reads as senior.

kotlin
fun climbStairs(n: Int): Int { var prev = 1; var cur = 1 // ways(0), ways(1) for (i in 2..n) { val next = prev + cur; prev = cur; cur = next } return cur }
java
int climbStairs(int n) { int prev = 1, cur = 1; for (int i = 2; i <= n; i++) { int next = prev + cur; prev = cur; cur = next; } return cur; }

This is the whole game. Every DP below is: write the recurrence → memoize → tabulate → (maybe) shrink space. Time is #states × work-per-state; space is #states, reducible when the transition only reaches a bounded window of prior states.

Traps in §1:

  • Sentinel collision. Using 0 as "not computed" fails when 0 is a legal answer. Use -1 for non-negative results, or a separate boolean[] seen.
  • Overlap check. If the recursion tree has no repeats, memoization does nothing — you're in backtracking territory, not DP. Confirm overlap before reaching for a table.
  • Depth on top-down. Deep recursion (n ≈ 10⁵) can StackOverflowError; bottom-up sidesteps it. Say this out loud.

2. How to FIND the state — the actually-hard part#

Given a brute force, the state is the set of arguments the recursive function needs. Ask: "what varies across subproblems, and what's the minimum I must remember to answer one?" Every parameter that changes the answer must be in the state; anything derivable or fixed must not (bloats the table and breaks caching only if you forget something that matters — see the last trap in this guide).

What variesState shapeSignature it fitsCanonical problems
Position in one sequencei (an index)dp[i]climbing stairs, house robber, LIS, decode ways
Two sequences / two ends of onei, jdp[i][j]LCS, edit distance, interleaving
A range [l, r] of one sequencel, rdp[l][r]burst balloons, min cut, matrix chain
Position + remaining budget/capacityi, capdp[i][cap]dp[cap]0/1 knapsack, subset-sum, coin change
Position + last choice / a flagi, k or i, ondp[i][k]stock with cooldown, paint fence, "at most k"
A subset of ≤ ~20 itemsmask (+ maybe last)dp[mask][last]TSP, assignment, team partition
A tree node (answer per subtree)the nodereturn value(s) uprobber III, tree diameter, max path sum

Heuristic: read the state off the constraints (the trick from guide 00). The input size tells you the target complexity, which tells you the state dimensionality:

n (and m)Affordable per testLikely state
n ≤ ~20–24O(2ⁿ·n)bitmask over a subset
n ≤ ~400O(n³)interval dp[l][r] with an inner split
n, m ≤ ~1000–5000O(n·m)2-D table (two sequences / grid / capacity)
n ≤ ~10⁵O(n) or O(n log n)1-D over an index (+ maybe binary search)
n ≤ ~10⁹, few "digits"O(digits · states)digit DP (out of scope; same recipe)

Backend aside (one, then back to algorithms): your accumulative OCPI charging-session aggregate — folding an ordered event stream into a single session object — is a degenerate 1-D DP: the "state" is the running accumulator, the "transition" applies the next event, and each prefix is computed once. Most production code is this fold with no choice at each step; interview DP just adds a min/max/count over choices and asks for the optimum. You already think in states and transitions.

The single most common failure is a state that doesn't capture enough — two genuinely different subproblems collide on one key and the answer is wrong on some inputs. The fix is always: find two inputs that should give different answers but map to the same state, and add the discriminating dimension.


3. Linear / 1-D DP — one index#

Recognition cue. A single sequence; the answer at position i depends on a bounded window of earlier positions. "In how many ways," "min/max cost along a line," "can you reach the end," climb/rob/decode.

House Robber I & II (non-adjacent max; II is circular)#

English recurrence: "best up to house i = max of (skip i: best up to i-1) and (rob i: nums[i] + best up to i-2)." Two rolling scalars.

kotlin
fun rob(nums: IntArray): Int { var prev = 0; var cur = 0 // best up to i-2, i-1 for (n in nums) { val next = maxOf(cur, prev + n); prev = cur; cur = next } return cur }
java
int rob(int[] nums) { int prev = 0, cur = 0; for (int n : nums) { int next = Math.max(cur, prev + n); prev = cur; cur = next; } return cur; }

House Robber II — houses in a circle, so first and last are adjacent. Rob [0, n-2] or [1, n-1], take the max; handle n == 1 separately. Reuse the linear helper:

kotlin
fun robCircular(nums: IntArray): Int { val n = nums.size if (n == 1) return nums[0] fun line(lo: Int, hi: Int): Int { var prev = 0; var cur = 0 for (i in lo..hi) { val next = maxOf(cur, prev + nums[i]); prev = cur; cur = next } return cur } return maxOf(line(0, n - 2), line(1, n - 1)) }

Kadane — maximum subarray; and maximum product subarray#

Kadane's insight: "the best subarray ending at i either extends the best ending at i-1, or restarts at i." O(n) / O(1).

kotlin
fun maxSubArray(nums: IntArray): Int { var best = nums[0]; var cur = nums[0] for (i in 1 until nums.size) { cur = maxOf(nums[i], cur + nums[i]) // extend or restart best = maxOf(best, cur) } return best }
java
int maxSubArray(int[] nums) { int best = nums[0], cur = nums[0]; for (int i = 1; i < nums.length; i++) { cur = Math.max(nums[i], cur + nums[i]); best = Math.max(best, cur); } return best; }

Maximum product subarray adds a twist: a negative flips largest↔smallest, so you must track both a running max and a running min. On a negative element, swap them before updating.

kotlin
fun maxProduct(nums: IntArray): Int { var best = nums[0]; var curMax = nums[0]; var curMin = nums[0] for (i in 1 until nums.size) { val n = nums[i] if (n < 0) { val t = curMax; curMax = curMin; curMin = t } // negative swaps the roles curMax = maxOf(n, curMax * n) curMin = minOf(n, curMin * n) best = maxOf(best, curMax) } return best }
java
int maxProduct(int[] nums) { int best = nums[0], curMax = nums[0], curMin = nums[0]; for (int i = 1; i < nums.length; i++) { int n = nums[i]; if (n < 0) { int t = curMax; curMax = curMin; curMin = t; } curMax = Math.max(n, curMax * n); curMin = Math.min(n, curMin * n); best = Math.max(best, curMax); } return best; }

Min cost climbing stairs · Decode ways · Word break (Kotlin; Java is the mechanical translation)#

Min cost climbing stairs — pay cost[i] to leave step i; reach the top from step 0 or 1. dp[i] = min(dp[i-1]+cost[i-1], dp[i-2]+cost[i-2]), answer dp[n].

kotlin
fun minCostClimbingStairs(cost: IntArray): Int { var prev = 0; var cur = 0 // dp[0], dp[1] for (i in 2..cost.size) { val next = minOf(cur + cost[i - 1], prev + cost[i - 2]) prev = cur; cur = next } return cur }

Decode ways — digit string → letters (A=1…Z=26). "ways to decode the first i chars = (take one digit if non-zero) + (take two digits if in 10..26)." Guard the leading '0' and any invalid two-digit.

kotlin
fun numDecodings(s: String): Int { if (s.isEmpty() || s[0] == '0') return 0 var prev = 1; var cur = 1 // dp[0]=1 (empty), dp[1]=1 for (i in 2..s.length) { var next = 0 if (s[i - 1] != '0') next += cur // last digit stands alone val two = (s[i - 2] - '0') * 10 + (s[i - 1] - '0') if (two in 10..26) next += prev // last two form a letter prev = cur; cur = next } return cur }

Word break — can s be cut into dictionary words? dp[i] = OR over j<i of (dp[j] && s[j..i) ∈ dict). Put the dictionary in a HashSet for O(1) membership.

kotlin
fun wordBreak(s: String, wordDict: List<String>): Boolean { val words = wordDict.toHashSet() val dp = BooleanArray(s.length + 1) dp[0] = true // empty prefix is trivially breakable for (i in 1..s.length) for (j in 0 until i) if (dp[j] && s.substring(j, i) in words) { dp[i] = true; break } return dp[s.length] }

Jump Game I & II — where greedy beats DP#

Jump Game I ("can you reach the end?") has a clean O(n²) DP, but the greedy reachability scan is O(n)/O(1) and strictly better — track the farthest index reachable so far; if you ever stand beyond it, you're stuck.

kotlin
fun canJump(nums: IntArray): Boolean { var reach = 0 for (i in nums.indices) { if (i > reach) return false // fell into a gap reach = maxOf(reach, i + nums[i]) } return true }

Jump Game II ("fewest jumps to the end") is greedy-BFS by layers: within the current jump's reach, precompute the farthest you could next reach; when you hit the current frontier, you must jump, so bump the count and extend the frontier.

kotlin
fun jump(nums: IntArray): Int { var jumps = 0; var curEnd = 0; var farthest = 0 for (i in 0 until nums.size - 1) { // stop before the last index farthest = maxOf(farthest, i + nums[i]) if (i == curEnd) { jumps++; curEnd = farthest } } return jumps }
java
int jump(int[] nums) { int jumps = 0, curEnd = 0, farthest = 0; for (int i = 0; i < nums.length - 1; i++) { farthest = Math.max(farthest, i + nums[i]); if (i == curEnd) { jumps++; curEnd = farthest; } } return jumps; }

Complexity (this section): all O(n) time; O(1) space via rolling scalars, except word break (O(n²·L) from substrings). Trap: Kadane/product/robber seed from nums[0] — guard the empty array if constraints allow it. Trap: greedy is only correct for these specific "reachability / min jumps" shapes; the moment jumps have weights/costs, fall back to real DP.


4. Grid / 2-D DP — two indices over a matrix#

Recognition cue. A 2-D grid where each cell's answer depends on neighbors already computed (usually up and left). "Paths from corner to corner," "min/max path sum," "largest square/region ending here."

Unique Paths I & II#

"Paths to (i,j) = paths from above + paths from the left." Roll the 2-D table into one row (dp[j] after processing row i holds the current row; dp[j-1] is already this row's left neighbor, dp[j] still the row above).

kotlin
fun uniquePaths(m: Int, n: Int): Int { val dp = IntArray(n) { 1 } // top row: exactly one path to each cell for (i in 1 until m) for (j in 1 until n) dp[j] += dp[j - 1] // above (old dp[j]) + left (new dp[j-1]) return dp[n - 1] }
java
int uniquePaths(int m, int n) { int[] dp = new int[n]; java.util.Arrays.fill(dp, 1); for (int i = 1; i < m; i++) for (int j = 1; j < n; j++) dp[j] += dp[j - 1]; return dp[n - 1]; }

Unique Paths II adds obstacles — an obstacle cell contributes 0 paths. Same roll, zero out blocked cells:

kotlin
fun uniquePathsWithObstacles(grid: Array<IntArray>): Int { val n = grid[0].size val dp = IntArray(n) dp[0] = 1 for (row in grid) for (j in 0 until n) if (row[j] == 1) dp[j] = 0 // obstacle: unreachable else if (j > 0) dp[j] += dp[j - 1] // dp[0] carries down the first column return dp[n - 1] }

Minimum Path Sum · Triangle · Maximal Square (Kotlin; Java mechanical)#

Minimum path sum — same roll, min instead of +:

kotlin
fun minPathSum(grid: Array<IntArray>): Int { val n = grid[0].size val dp = IntArray(n) dp[0] = grid[0][0] for (j in 1 until n) dp[j] = dp[j - 1] + grid[0][j] for (i in 1 until grid.size) { dp[0] += grid[i][0] for (j in 1 until n) dp[j] = minOf(dp[j], dp[j - 1]) + grid[i][j] } return dp[n - 1] }

Triangle min path — cleanest bottom-up: start from the last row and fold upward, dp[j] = triangle[i][j] + min(dp[j], dp[j+1]).

kotlin
fun minimumTotal(triangle: List<List<Int>>): Int { val dp = triangle.last().toIntArray() for (i in triangle.size - 2 downTo 0) for (j in 0..i) dp[j] = triangle[i][j] + minOf(dp[j], dp[j + 1]) return dp[0] }

Maximal squaredp[i][j] = side of the largest all-1 square with bottom-right corner at (i,j); if the cell is 1, dp[i][j] = min(up, left, diag) + 1. Rolled to one row, you must stash the diagonal (dp[i-1][j-1]) before overwriting it.

kotlin
fun maximalSquare(matrix: Array<CharArray>): Int { val n = matrix[0].size val dp = IntArray(n + 1) var best = 0 for (i in 1..matrix.size) { var diag = 0 // dp[i-1][j-1] for (j in 1..n) { val up = dp[j] // dp[i-1][j] before overwrite if (matrix[i - 1][j - 1] == '1') { dp[j] = minOf(dp[j], dp[j - 1], diag) + 1 best = maxOf(best, dp[j]) } else dp[j] = 0 diag = up // becomes dp[i-1][j-1] for the next column } } return best * best }

Dungeon Game — why you MUST go bottom-up from the destination#

Recognition cue for "reverse" DP: the constraint (health must stay ≥ 1 at every step) depends on the future, not the past. If you sweep top-left→bottom-right tracking accumulated health, you can't know how much you'll need later, so a forward greedy/DP is wrong. Define dp[i][j] = minimum HP needed entering (i,j) to survive to the princess, and sweep bottom-right→top-left: dp[i][j] = max(1, min(dp[i+1][j], dp[i][j+1]) − dungeon[i][j]).

kotlin
fun calculateMinimumHP(dungeon: Array<IntArray>): Int { val m = dungeon.size; val n = dungeon[0].size val dp = Array(m + 1) { IntArray(n + 1) { Int.MAX_VALUE } } dp[m][n - 1] = 1; dp[m - 1][n] = 1 // one virtual cell past the exit needs HP 1 for (i in m - 1 downTo 0) for (j in n - 1 downTo 0) { val need = minOf(dp[i + 1][j], dp[i][j + 1]) - dungeon[i][j] dp[i][j] = maxOf(1, need) // never let required HP drop below 1 } return dp[0][0] }
java
int calculateMinimumHP(int[][] dungeon) { int m = dungeon.length, n = dungeon[0].length; int[][] dp = new int[m + 1][n + 1]; for (int[] row : dp) java.util.Arrays.fill(row, Integer.MAX_VALUE); dp[m][n - 1] = 1; dp[m - 1][n] = 1; for (int i = m - 1; i >= 0; i--) for (int j = n - 1; j >= 0; j--) { int need = Math.min(dp[i + 1][j], dp[i][j + 1]) - dungeon[i][j]; dp[i][j] = Math.max(1, need); } return dp[0][0]; }

The Int.MAX_VALUE seeds are safe here because for every real cell at least one neighbor toward the exit is finite, so min(...) never picks two sentinels. Complexity: grid DP is O(m·n) time; O(n) space with a rolled row (Dungeon keeps two rows / a full table for clarity). Trap: rolling a grid to one row only works when the transition reaches left and up (and diagonal, if you stash it) — never a cell you've already overwritten this row without saving it.


5. The Knapsack family — capacity as state, and the loop-order that trips everyone#

Recognition cue. You pick a subset of items under a capacity/budget/target, optimizing value or counting ways. "Can I hit this sum," "fewest coins," "how many ways to make change." State is dp[capacity] after considering some prefix of items.

0/1 knapsack — each item at most once → iterate capacity DESCENDING#

dp[c] = best value using capacity c. For each item, dp[c] = max(dp[c], dp[c-w] + v). In the rolled 1-D array you must iterate capacity downward so dp[c-w] still refers to the previous item's row (item not yet taken at this capacity) — that's what enforces "at most once."

kotlin
fun knapsack01(weights: IntArray, values: IntArray, capacity: Int): Int { val dp = IntArray(capacity + 1) for (i in weights.indices) for (c in capacity downTo weights[i]) // DESCENDING → each item once dp[c] = maxOf(dp[c], dp[c - weights[i]] + values[i]) return dp[capacity] }
java
int knapsack01(int[] weights, int[] values, int capacity) { int[] dp = new int[capacity + 1]; for (int i = 0; i < weights.length; i++) for (int c = capacity; c >= weights[i]; c--) // DESCENDING dp[c] = Math.max(dp[c], dp[c - weights[i]] + values[i]); return dp[capacity]; }

Partition Equal Subset Sum is 0/1 subset-sum: total must be even; can we hit sum/2? Boolean knapsack.

kotlin
fun canPartition(nums: IntArray): Boolean { val sum = nums.sum() if (sum % 2 != 0) return false val target = sum / 2 val dp = BooleanArray(target + 1) dp[0] = true // empty subset makes 0 for (n in nums) for (c in target downTo n) // DESCENDING (0/1) dp[c] = dp[c] || dp[c - n] return dp[target] }

Target Sum (assign +/ to reach S) reduces to counting subsets: let P be the positive set; P − (sum−P) = S ⇒ P = (sum+S)/2. Count subsets summing to P (a counting 0/1 knapsack — note +=, not ||).

kotlin
fun findTargetSumWays(nums: IntArray, target: Int): Int { val sum = nums.sum() if (target > sum || target < -sum || (sum + target) % 2 != 0) return 0 val p = (sum + target) / 2 val dp = IntArray(p + 1) dp[0] = 1 for (n in nums) for (c in p downTo n) dp[c] += dp[c - n] // DESCENDING; counts subsets return dp[p] }

Last Stone Weight II — smash stones; minimize the final remainder. That is sum − 2·(largest subset sum ≤ sum/2) — a boolean 0/1 knapsack, then scan down for the best reachable sum.

kotlin
fun lastStoneWeightII(stones: IntArray): Int { val sum = stones.sum(); val target = sum / 2 val dp = BooleanArray(target + 1); dp[0] = true for (s in stones) for (c in target downTo s) dp[c] = dp[c] || dp[c - s] for (c in target downTo 0) if (dp[c]) return sum - 2 * c return sum }

Unbounded knapsack — items reusable → iterate capacity ASCENDING#

Now each item may be used many times, so dp[c-w] should already include the current item at this pass — iterate capacity upward.

Coin Change (fewest coins) — the classic. Use amount+1 as the "impossible" sentinel, not Integer.MAX_VALUE: adding +1 to MAX_VALUE overflows to a negative and corrupts the min.

kotlin
fun coinChange(coins: IntArray, amount: Int): Int { val dp = IntArray(amount + 1) { amount + 1 } // sentinel > any real answer; +1 can't overflow dp[0] = 0 for (coin in coins) for (c in coin..amount) // ASCENDING → reuse allowed dp[c] = minOf(dp[c], dp[c - coin] + 1) return if (dp[amount] > amount) -1 else dp[amount] }
java
int coinChange(int[] coins, int amount) { int[] dp = new int[amount + 1]; java.util.Arrays.fill(dp, amount + 1); // safe sentinel, not Integer.MAX_VALUE dp[0] = 0; for (int coin : coins) for (int c = coin; c <= amount; c++) // ASCENDING dp[c] = Math.min(dp[c], dp[c - coin] + 1); return dp[amount] > amount ? -1 : dp[amount]; }

Rod cutting is the maximizing twin (cut lengths reusable):

kotlin
fun rodCutting(price: IntArray, n: Int): Int { // price[k-1] = value of a piece of length k val dp = IntArray(n + 1) for (len in 1..n) for (cut in 1..minOf(len, price.size)) dp[len] = maxOf(dp[len], price[cut - 1] + dp[len - cut]) return dp[n] }

The Staff-level subtlety: combinations vs permutations = which loop is OUTER#

This is the knapsack question senior interviewers love, because the two look identical but the loop nesting flips the meaning.

Coin Change 2 — count combinations (order irrelevant): coins OUTER, amount INNER. Fixing coins on the outside means once you finish a coin you never place it "before" an earlier coin again, so {1,2} and {2,1} are the same single combination.

kotlin
fun change(amount: Int, coins: IntArray): Int { val dp = IntArray(amount + 1); dp[0] = 1 for (coin in coins) // COINS OUTER → combinations for (c in coin..amount) dp[c] += dp[c - coin] return dp[amount] }
java
int change(int amount, int[] coins) { int[] dp = new int[amount + 1]; dp[0] = 1; for (int coin : coins) // COINS OUTER → combinations for (int c = coin; c <= amount; c++) dp[c] += dp[c - coin]; return dp[amount]; }

Combination Sum IV — count permutations (order matters): amount OUTER, coins INNER. For each target you try every coin as the last step, so 1+2 and 2+1 are counted separately.

kotlin
fun combinationSum4(nums: IntArray, target: Int): Int { val dp = IntArray(target + 1); dp[0] = 1 for (c in 1..target) // AMOUNT OUTER → permutations for (n in nums) if (c >= n) dp[c] += dp[c - n] return dp[target] }
java
int combinationSum4(int[] nums, int target) { int[] dp = new int[target + 1]; dp[0] = 1; for (int c = 1; c <= target; c++) // AMOUNT OUTER → permutations for (int n : nums) if (c >= n) dp[c] += dp[c - n]; return dp[target]; }

Why the order changes the meaning — say this in the room: the outer loop is the dimension you commit to first. Put items outside and you enumerate multisets (a coin can only follow coins already fixed → each unordered combination once). Put target outside and at each amount you branch over all items as the final piece → you count ordered sequences. Same recurrence dp[c] += dp[c-x]; different nesting, different counting semantics. The two capacity directions (descending = use-once, ascending = reuse) and the two nestings (item-outer = combinations, target-outer = permutations) are four independent knobs — know all four.

Complexity: O(#items · capacity) time, O(capacity) space for every variant above. Trap: counting DPs (+=) can overflow int even when the final answer fits — Combination Sum IV and Target Sum are the usual offenders; switch the accumulator to long if constraints are loose.


6. Subsequence & string DP — one or two sequences#

Recognition cue. One or two strings/arrays; you compare characters and either match-and-advance-both or skip one. Almost always dp[i][j] over the two prefixes; index discipline (dp[i] = first i chars, so the char is s[i-1]) is where people bleed points.

Longest Increasing Subsequence — the O(n²) DP and the O(n log n) upgrade#

O(n²): dp[i] = length of the LIS ending at i = 1 + max(dp[j]) over j<i with nums[j] < nums[i].

kotlin
fun lengthOfLISquadratic(nums: IntArray): Int { if (nums.isEmpty()) return 0 val dp = IntArray(nums.size) { 1 } var best = 1 for (i in 1 until nums.size) { for (j in 0 until i) if (nums[j] < nums[i]) dp[i] = maxOf(dp[i], dp[j] + 1) best = maxOf(best, dp[i]) } return best }

O(n log n)patience sorting / tails: keep tails, where tails[k] is the smallest possible tail of any increasing subsequence of length k+1. For each x, binary-search the first tail ≥ x and overwrite it (append if none). The length of tails is the LIS length. (tails itself is not a valid subsequence — don't claim it is.)

kotlin
fun lengthOfLIS(nums: IntArray): Int { val tails = ArrayList<Int>() for (x in nums) { var lo = 0; var hi = tails.size while (lo < hi) { // lower_bound: first tail >= x val mid = lo + (hi - lo) / 2 if (tails[mid] < x) lo = mid + 1 else hi = mid } if (lo == tails.size) tails.add(x) else tails[lo] = x } return tails.size }
java
int lengthOfLIS(int[] nums) { int[] tails = new int[nums.length]; int size = 0; for (int x : nums) { int lo = 0, hi = size; while (lo < hi) { int mid = lo + (hi - lo) / 2; if (tails[mid] < x) lo = mid + 1; else hi = mid; } tails[lo] = x; if (lo == size) size++; } return size; }

For non-strictly increasing (allow equals), search the first tail strictly greater than x (upper_bound) instead. That one comparison flip is a favorite follow-up.

Longest Common Subsequence — the mother of two-string DP#

"If the last chars match, 1 + LCS of both prefixes; else the best of dropping one char from either string."

kotlin
fun longestCommonSubsequence(a: String, b: String): Int { val m = a.length; val n = b.length val dp = Array(m + 1) { IntArray(n + 1) } for (i in 1..m) for (j in 1..n) dp[i][j] = if (a[i - 1] == b[j - 1]) dp[i - 1][j - 1] + 1 else maxOf(dp[i - 1][j], dp[i][j - 1]) return dp[m][n] }
java
int longestCommonSubsequence(String a, String b) { int m = a.length(), n = b.length(); int[][] dp = new int[m + 1][n + 1]; for (int i = 1; i <= m; i++) for (int j = 1; j <= n; j++) dp[i][j] = a.charAt(i - 1) == b.charAt(j - 1) ? dp[i - 1][j - 1] + 1 : Math.max(dp[i - 1][j], dp[i][j - 1]); return dp[m][n]; }

Edit Distance (Levenshtein) — insert / delete / replace#

Non-match cost is 1 + min(replace = diag, delete = up, insert = left); the base row/column are 0..n (turning a string into empty costs one delete per char), which people forget to seed.

kotlin
fun minDistance(w1: String, w2: String): Int { val m = w1.length; val n = w2.length val dp = Array(m + 1) { IntArray(n + 1) } for (i in 0..m) dp[i][0] = i // delete all of w1's prefix for (j in 0..n) dp[0][j] = j // insert all of w2's prefix for (i in 1..m) for (j in 1..n) dp[i][j] = if (w1[i - 1] == w2[j - 1]) dp[i - 1][j - 1] else 1 + minOf(dp[i - 1][j - 1], dp[i - 1][j], dp[i][j - 1]) return dp[m][n] }
java
int minDistance(String w1, String w2) { int m = w1.length(), n = w2.length(); int[][] dp = new int[m + 1][n + 1]; for (int i = 0; i <= m; i++) dp[i][0] = i; for (int j = 0; j <= n; j++) dp[0][j] = j; for (int i = 1; i <= m; i++) for (int j = 1; j <= n; j++) dp[i][j] = w1.charAt(i - 1) == w2.charAt(j - 1) ? dp[i - 1][j - 1] : 1 + Math.min(dp[i - 1][j - 1], Math.min(dp[i - 1][j], dp[i][j - 1])); return dp[m][n]; }

The rest of the string-DP zoo (Kotlin; each is a small variation)#

Longest Palindromic Subsequence = interval DP on one string (or LCS(s, reverse(s))): match the two ends → +2, else drop an end.

kotlin
fun longestPalindromeSubseq(s: String): Int { val n = s.length if (n == 0) return 0 val dp = Array(n) { IntArray(n) } for (i in n - 1 downTo 0) { dp[i][i] = 1 for (j in i + 1 until n) dp[i][j] = if (s[i] == s[j]) dp[i + 1][j - 1] + 2 else maxOf(dp[i + 1][j], dp[i][j - 1]) } return dp[0][n - 1] }

Longest Palindromic Substring (contiguous) — don't reach for a table; expand around each of the 2n−1 centers is O(n²) time, O(1) space and easier to get right.

kotlin
fun longestPalindrome(s: String): String { if (s.length < 2) return s var start = 0; var maxLen = 1 fun expand(l0: Int, r0: Int) { var l = l0; var r = r0 while (l >= 0 && r < s.length && s[l] == s[r]) { l--; r++ } val len = r - l - 1 // window shrank one past both ends if (len > maxLen) { maxLen = len; start = l + 1 } } for (i in s.indices) { expand(i, i); expand(i, i + 1) } // odd and even centers return s.substring(start, start + maxLen) }

Distinct Subsequences — count subsequences of s equal to t. Match → use it (dp[i-1][j-1]) plus skip it (dp[i-1][j]); non-match → only skip. Base dp[i][0] = 1 (one way to form empty t).

kotlin
fun numDistinct(s: String, t: String): Int { val m = s.length; val n = t.length val dp = Array(m + 1) { IntArray(n + 1) } for (i in 0..m) dp[i][0] = 1 for (i in 1..m) for (j in 1..n) dp[i][j] = if (s[i - 1] == t[j - 1]) dp[i - 1][j - 1] + dp[i - 1][j] else dp[i - 1][j] return dp[m][n] }

Interleaving String — is s3 a merge of s1 and s2 keeping order? dp[i][j] true if the next char of s3 can come from s1 (and dp[i-1][j]) or s2 (and dp[i][j-1]). Length check first.

kotlin
fun isInterleave(s1: String, s2: String, s3: String): Boolean { val m = s1.length; val n = s2.length if (m + n != s3.length) return false val dp = Array(m + 1) { BooleanArray(n + 1) } dp[0][0] = true for (i in 0..m) for (j in 0..n) { if (i > 0 && s1[i - 1] == s3[i + j - 1]) dp[i][j] = dp[i][j] || dp[i - 1][j] if (j > 0 && s2[j - 1] == s3[i + j - 1]) dp[i][j] = dp[i][j] || dp[i][j - 1] } return dp[m][n] }

Regular-Expression Matching (. = any char, * = zero-or-more of the preceding). The * branch: zero occurrences (dp[i][j-2]) OR one-more if the preceding pattern char matches s[i-1] (dp[i-1][j]).

kotlin
fun isMatchRegex(s: String, p: String): Boolean { val m = s.length; val n = p.length val dp = Array(m + 1) { BooleanArray(n + 1) } dp[0][0] = true for (j in 2..n) if (p[j - 1] == '*') dp[0][j] = dp[0][j - 2] // a* matches empty for (i in 1..m) for (j in 1..n) { if (p[j - 1] == '*') { dp[i][j] = dp[i][j - 2] // zero of preceding if (p[j - 2] == '.' || p[j - 2] == s[i - 1]) dp[i][j] = dp[i][j] || dp[i - 1][j] // one more of preceding } else if (p[j - 1] == '.' || p[j - 1] == s[i - 1]) { dp[i][j] = dp[i - 1][j - 1] } } return dp[m][n] }

Wildcard Matching (? = one char, * = any sequence). Simpler *: match empty (dp[i][j-1]) or consume one char (dp[i-1][j]).

kotlin
fun isMatchWildcard(s: String, p: String): Boolean { val m = s.length; val n = p.length val dp = Array(m + 1) { BooleanArray(n + 1) } dp[0][0] = true for (j in 1..n) if (p[j - 1] == '*') dp[0][j] = dp[0][j - 1] for (i in 1..m) for (j in 1..n) { val pc = p[j - 1] dp[i][j] = when { pc == '*' -> dp[i - 1][j] || dp[i][j - 1] // consume a char OR match empty pc == '?' || pc == s[i - 1] -> dp[i - 1][j - 1] else -> false } } return dp[m][n] }

Longest Common Substring (contiguous, ≠ subsequence): dp[i][j] = length of the common suffix ending at a[i-1], b[j-1]; reset to 0 on mismatch; track the max.

kotlin
fun longestCommonSubstring(a: String, b: String): Int { val m = a.length; val n = b.length val dp = Array(m + 1) { IntArray(n + 1) } var best = 0 for (i in 1..m) for (j in 1..n) if (a[i - 1] == b[j - 1]) { dp[i][j] = dp[i - 1][j - 1] + 1; best = maxOf(best, dp[i][j]) } return best }

Complexity (this section): two-string DPs are O(m·n) time and O(m·n) space, reducible to O(min(m,n)) with two rolled rows; LIS is O(n log n) / O(n); palindromic-substring by expansion is O(n²) / O(1). Traps: off-by-one (dp[i]s[i-1]); forgetting the base row/column in edit distance / distinct subsequences; using a table for palindromic substring when expansion is simpler; and conflating substring (contiguous, resets on mismatch) with subsequence (may skip).


7. Interval DP — dp[l][r] over a range, split at k#

Recognition cue. The answer for a range [l, r] is built by choosing a split point / last operation k inside it and combining two sub-ranges: "burst/remove the last element," "best way to parenthesize," "min cuts." The order that makes dependencies ready is by increasing interval length (short ranges before the long ranges that contain them).

The template:

kotlin
// for len in 2..n: for l in 0..n-len: r = l+len-1; for k in l..r: dp[l][r] = combine(dp[l][k], dp[k+1][r])

Burst Balloons — the "last to burst" reframe#

The trick that makes it a clean interval DP: don't think about the first balloon to burst (its neighbors keep changing) — think about the last balloon k to burst in the open interval (l, r). When k bursts last, its neighbors are exactly the boundaries l and r, so coins = a[l]*a[k]*a[r] + dp[l][k] + dp[k][r]. Pad the array with virtual 1s at both ends.

kotlin
fun maxCoins(nums: IntArray): Int { val n = nums.size val a = IntArray(n + 2) a[0] = 1; a[n + 1] = 1 for (i in 1..n) a[i] = nums[i - 1] val dp = Array(n + 2) { IntArray(n + 2) } // dp[l][r] = coins bursting all in (l, r) exclusive for (len in 2..n + 1) // gap between boundaries l and r for (l in 0..n + 1 - len) { val r = l + len for (k in l + 1 until r) // k = last balloon burst in (l, r) dp[l][r] = maxOf(dp[l][r], dp[l][k] + a[l] * a[k] * a[r] + dp[k][r]) } return dp[0][n + 1] }
java
int maxCoins(int[] nums) { int n = nums.length; int[] a = new int[n + 2]; a[0] = 1; a[n + 1] = 1; for (int i = 1; i <= n; i++) a[i] = nums[i - 1]; int[][] dp = new int[n + 2][n + 2]; for (int len = 2; len <= n + 1; len++) for (int l = 0; l + len <= n + 1; l++) { int r = l + len; for (int k = l + 1; k < r; k++) dp[l][r] = Math.max(dp[l][r], dp[l][k] + a[l] * a[k] * a[r] + dp[k][r]); } return dp[0][n + 1]; }

The Matrix-Chain-Multiplication shape is identical: dp[l][r] = min over k of dp[l][k] + dp[k+1][r] + cost(l,k,r) — same "iterate by length, split at k" skeleton, minimizing multiplication cost instead of maximizing coins.

Palindrome Partitioning II (min cuts) · Predict the Winner (Kotlin)#

Min cuts to split s into palindromes: precompute an isPal[i][j] table (itself interval DP), then cuts[i] = min over j≤i where s[j..i] is a palindrome of cuts[j-1] + 1.

kotlin
fun minCut(s: String): Int { val n = s.length if (n == 0) return 0 val isPal = Array(n) { BooleanArray(n) } for (i in n - 1 downTo 0) for (j in i until n) if (s[i] == s[j] && (j - i < 2 || isPal[i + 1][j - 1])) isPal[i][j] = true val cuts = IntArray(n) for (i in 0 until n) { if (isPal[0][i]) { cuts[i] = 0; continue } var best = i for (j in 1..i) if (isPal[j][i]) best = minOf(best, cuts[j - 1] + 1) cuts[i] = best } return cuts[n - 1] }

Predict the Winner / Stone Game — two players take from either end optimally. Let dp[i][j] = the best score difference (me − opponent) achievable on nums[i..j]; taking an end gives value − dp[remaining] (the opponent then plays optimally on the rest). First player wins iff dp[0][n-1] ≥ 0.

kotlin
fun predictTheWinner(nums: IntArray): Boolean { val n = nums.size val dp = Array(n) { IntArray(n) } for (i in 0 until n) dp[i][i] = nums[i] for (len in 2..n) for (i in 0..n - len) { val j = i + len - 1 dp[i][j] = maxOf(nums[i] - dp[i + 1][j], nums[j] - dp[i][j - 1]) } return dp[0][n - 1] >= 0 }

Complexity: interval DP is O(n³) time ( states × O(n) split), O(n²) space — which is why the constraint hint says n ≤ ~400. Trap: looping by l/r directly instead of by length evaluates dp[l][r] before its shorter sub-intervals exist. Trap (burst balloons): reasoning about the first balloon instead of the last makes the neighbors non-local and the recurrence wrong.


8. Bitmask DP — a subset as the state (n ≤ ~20)#

Recognition cue. Small n (≤ ~20), and the natural state is "which items have I used/visited?" — a set. Encode the set as the bits of an Int (mask); 2ⁿ states. Often paired with "…and where did I end?" (last).

The two primitives: mask and (1 shl i) != 0 tests membership; mask or (1 shl i) adds item i.

The Assignment Problem — cleanest bitmask template#

Assign n workers to n tasks minimizing total cost. dp[mask] = min cost to assign the first popcount(mask) workers to exactly the tasks in mask. The next worker is popcount(mask); try giving them each still-free task.

kotlin
fun minAssignmentCost(cost: Array<IntArray>): Int { val n = cost.size val INF = Int.MAX_VALUE / 2 val dp = IntArray(1 shl n) { INF } dp[0] = 0 for (mask in 0 until (1 shl n)) { if (dp[mask] == INF) continue val worker = Integer.bitCount(mask) // #tasks already assigned = next worker index if (worker == n) continue for (task in 0 until n) { if ((mask and (1 shl task)) != 0) continue // task taken val nm = mask or (1 shl task) val cand = dp[mask] + cost[worker][task] if (cand < dp[nm]) dp[nm] = cand } } return dp[(1 shl n) - 1] }
java
int minAssignmentCost(int[][] cost) { int n = cost.length; int INF = Integer.MAX_VALUE / 2; int[] dp = new int[1 << n]; java.util.Arrays.fill(dp, INF); dp[0] = 0; for (int mask = 0; mask < (1 << n); mask++) { if (dp[mask] == INF) continue; int worker = Integer.bitCount(mask); if (worker == n) continue; for (int task = 0; task < n; task++) { if ((mask & (1 << task)) != 0) continue; int nm = mask | (1 << task); int cand = dp[mask] + cost[worker][task]; if (cand < dp[nm]) dp[nm] = cand; } } return dp[(1 << n) - 1]; }

Travelling Salesman (Held–Karp) — mask + last city#

dp[mask][last] = min cost of a path that started at city 0, visited exactly the set mask, and currently sits at last. Extend to an unvisited next; finally close the tour back to 0. O(2ⁿ·n²).

kotlin
fun tsp(dist: Array<IntArray>): Int { val n = dist.size if (n == 1) return 0 val INF = Int.MAX_VALUE / 2 val dp = Array(1 shl n) { IntArray(n) { INF } } dp[1][0] = 0 // start at 0, visited = {0} for (mask in 1 until (1 shl n)) { if ((mask and 1) == 0) continue // every path includes city 0 for (last in 0 until n) { if ((mask and (1 shl last)) == 0 || dp[mask][last] == INF) continue for (next in 0 until n) { if ((mask and (1 shl next)) != 0) continue val nm = mask or (1 shl next) val cand = dp[mask][last] + dist[last][next] if (cand < dp[nm][next]) dp[nm][next] = cand } } } val full = (1 shl n) - 1 var ans = INF for (last in 1 until n) ans = minOf(ans, dp[full][last] + dist[last][0]) return ans }

The same mask-as-visited-set idea powers team/group partition ("split people into groups minimizing conflict" — iterate over submasks of the remaining set) and the shortest-superstring sketch (dp[mask][last] = shortest string covering the words in mask ending with word last, transition adds a word by its overlap). Complexity: O(2ⁿ · n) (assignment) to O(2ⁿ · n²) (TSP); space O(2ⁿ)O(2ⁿ·n). Traps: 1 shl n overflows Int past n = 31 (and you'd never allocate 2³¹ anyway — this is strictly small-n); guard dp[mask] == INF before transitioning so unreachable states don't propagate garbage; and use INF = MAX/2 so INF + cost can't overflow.


9. Tree DP — one post-order pass, answer folded up#

Recognition cue. The structure is a tree and each node's answer depends on its children's answers. This is DP where the "order" is a post-order traversal (children before parent) and the "table" is the recursion's return value(s). Cross-reference guide 03 for the traversal mechanics; here the point is what you return.

House Robber III — rob a binary tree, no two adjacent nodes. Return a pair (robThis, skipThis) so the parent can choose without re-descending. rob = val + skipLeft + skipRight; skip = max(left) + max(right).

kotlin
fun rob(root: TreeNode?): Int { fun dfs(node: TreeNode?): Pair<Int, Int> { // (rob this node, skip this node) if (node == null) return 0 to 0 val (lRob, lSkip) = dfs(node.left) val (rRob, rSkip) = dfs(node.right) val robHere = node.`val` + lSkip + rSkip val skipHere = maxOf(lRob, lSkip) + maxOf(rRob, rSkip) return robHere to skipHere } val (r, s) = dfs(root) return maxOf(r, s) }
java
int rob(TreeNode root) { int[] r = robDfs(root); return Math.max(r[0], r[1]); } int[] robDfs(TreeNode node) { // [robThis, skipThis] if (node == null) return new int[]{0, 0}; int[] l = robDfs(node.left), r = robDfs(node.right); int robHere = node.val + l[1] + r[1]; int skipHere = Math.max(l[0], l[1]) + Math.max(r[0], r[1]); return new int[]{robHere, skipHere}; }

Binary Tree Maximum Path Sum — the classic "return one thing up, record another." Each node returns the best downward extension (val + max(leftGain, rightGain, 0)) but records into a global the best path that bends at it (val + leftGain + rightGain). Clamp negative child gains to 0 ("don't take that branch").

kotlin
fun maxPathSum(root: TreeNode?): Int { var best = Int.MIN_VALUE fun gain(node: TreeNode?): Int { if (node == null) return 0 val l = maxOf(gain(node.left), 0) // drop a branch if it hurts val r = maxOf(gain(node.right), 0) best = maxOf(best, node.`val` + l + r) // path bending here (can't be returned upward) return node.`val` + maxOf(l, r) // straight extension for the parent } gain(root) return best }
java
int best; int maxPathSum(TreeNode root) { best = Integer.MIN_VALUE; gain(root); return best; } int gain(TreeNode node) { if (node == null) return 0; int l = Math.max(gain(node.left), 0); int r = Math.max(gain(node.right), 0); best = Math.max(best, node.val + l + r); return node.val + Math.max(l, r); }

Tree Diameter is the same trick with lengths: return 1 + max(childDepths), record leftDepth + rightDepth (edges through the node) into a global. Complexity: all O(n) time, O(h) stack. Trap: trying to return the bending value — you can only return a straight extension, because the parent can attach to at most one of your branches.


10. Space optimization and reconstruction#

Rolling arrays. If the transition only reaches a bounded window of prior states, you don't need the whole table. 1-D linear DPs collapse to a few scalars (§1, §3). A grid or two-string DP where row i only reads row i-1 collapses to one or two rows (§4, §5); when it also reads the diagonal, stash that one value before overwriting (Maximal Square, §4). Knapsack collapses dp[i][c] to dp[c]provided you get the loop direction right (§5). The recipe: space = size of the largest "frontier" the transition ever needs at once.

Reconstruction — recovering the actual solution, not just its value. Two techniques:

(a) Store a choice/parent pointer as you fill the table, then walk it back. Coin Change, recovering which coins:

kotlin
fun coinChangeCoins(coins: IntArray, amount: Int): List<Int> { val dp = IntArray(amount + 1) { amount + 1 } val pick = IntArray(amount + 1) { -1 } // coin chosen to first reach amount c dp[0] = 0 for (coin in coins) for (c in coin..amount) if (dp[c - coin] + 1 < dp[c]) { dp[c] = dp[c - coin] + 1; pick[c] = coin } if (dp[amount] > amount) return emptyList() val res = ArrayList<Int>() var c = amount while (c > 0) { res.add(pick[c]); c -= pick[c] } return res }
java
List<Integer> coinChangeCoins(int[] coins, int amount) { int[] dp = new int[amount + 1]; int[] pick = new int[amount + 1]; java.util.Arrays.fill(dp, amount + 1); java.util.Arrays.fill(pick, -1); dp[0] = 0; for (int coin : coins) for (int c = coin; c <= amount; c++) if (dp[c - coin] + 1 < dp[c]) { dp[c] = dp[c - coin] + 1; pick[c] = coin; } if (dp[amount] > amount) return List.of(); List<Integer> res = new ArrayList<>(); for (int c = amount; c > 0; c -= pick[c]) res.add(pick[c]); return res; }

(b) Backtrack over the filled table by re-deriving each choice from neighbors — no extra storage, but needs the full table (rules out the rolled version). LCS, recovering the subsequence itself:

kotlin
fun lcsString(a: String, b: String): String { val m = a.length; val n = b.length val dp = Array(m + 1) { IntArray(n + 1) } for (i in 1..m) for (j in 1..n) dp[i][j] = if (a[i - 1] == b[j - 1]) dp[i - 1][j - 1] + 1 else maxOf(dp[i - 1][j], dp[i][j - 1]) val sb = StringBuilder() var i = m; var j = n while (i > 0 && j > 0) { when { a[i - 1] == b[j - 1] -> { sb.append(a[i - 1]); i--; j-- } // char is in the LCS dp[i - 1][j] >= dp[i][j - 1] -> i-- // came from above else -> j-- // came from the left } } return sb.reverse().toString() }

The tradeoff to state out loud: reconstruction via parent pointers costs O(states) extra memory but works with a rolled table if you keep the pointers; table-backtrack is zero extra memory but forces you to keep the full O(states) table (so you can't also space-optimize). Interviewers frequently ask "now give me the actual items/string," and this is the answer.


11. How to COMMUNICATE a DP in a room#

The order you present a DP matters as much as the code — per the rubric research (guide 00), seniors are scored on judgment and driving the session, not raw speed.

  1. Say the recurrence in ENGLISH first. "The best for i is the better of taking item i — its value plus the best for i−2 — or skipping it and keeping the best for i−1." If you can say it, you can code it; if you can't, more coding won't help.
  2. Name the family and the state, with complexity, before typing. "This is 0/1 knapsack; state is dp[capacity], O(n·W) time, O(W) space." That one sentence pattern-matches you to a senior.
  3. Write top-down memo first. It's the smallest edit from the brute force you already described, so it's the easiest to get right under pressure and the hardest to bungle the base cases on.
  4. Then offer the upgrades as a flourish. "I can convert this to bottom-up to drop the recursion, and since the transition only looks back two states, roll it to O(1) space." Converting on request shows you understand why it works, not just a snippet.
  5. Pre-empt the classic bug by naming it. "Rolled 0/1 knapsack iterates capacity descending so each item is used once — ascending would silently make it unbounded." Calling the trap before the interviewer does is the strongest possible signal.
  6. Flag version/JVM facts. Kotlin IntArray(n) { init } vs Java new int[n] + Arrays.fill; Kotlin's Int.MAX_VALUE / 2 (or a domain sentinel like amount + 1) to dodge overflow; Integer.bitCount for masks. Mention top-down recursion depth on large n and the bottom-up fallback — that's production-readiness thinking, which 2026 infra rubrics (Snowflake-style) reward, and DP + graphs are exactly Snowflake's reputed emphasis.

Problems & how to solve them#

Problem: the DP is right on the samples but wrong on some inputs. Root cause: the state doesn't capture enough — two subproblems that should differ collide on one key (you forgot a dimension: the last choice, remaining budget, a parity/flag, or which end you took from). Fix: find two inputs that should yield different answers but hit the same state, and add the discriminating parameter. The state must make the subproblem's answer a pure function of the key alone.

Problem: rolled knapsack over- or under-counts (or reuses items it shouldn't). Root cause: wrong loop direction/nesting. 0/1 needs capacity descending (each item once); unbounded needs ascending (reuse). Counting combinations needs items outer; permutations needs target outer. Fix: decide two things explicitly — "can an item repeat?" (direction) and "does order matter?" (nesting) — and set the four knobs accordingly. Verify on coins=[1,2], amount=3: combinations → 2, permutations → 3.

Problem: answers are off by a constant, or the first row/column is wrong. Root cause: base-case / initialization error — dp[0] given the wrong meaning (empty subproblem vs first element), or the base row/column of a 2-D table left unseeded. Fix: define dp[0]/the base explicitly as the answer to the empty subproblem (edit distance: dp[i][0]=i; subset-count: dp[0]=1; reachability: dp[0]=true) and seed it before the loops.

Problem: coinChange/min-DP returns a huge negative or garbage. Root cause: using Integer.MAX_VALUE as "impossible," then computing dp[c-coin] + 1, which overflows to Integer.MIN_VALUE and wins the min. Fix: pick a sentinel that can't overflow under the transition — amount + 1 for min-coins, Int.MAX_VALUE / 2 for path/TSP costs — and check dp[target] > bound for "unreachable." Never +1 a MAX_VALUE.

Problem: string DP throws IndexOutOfBounds or mismatches by one. Root cause: mixing 1-based table indices with 0-based string indices — reading s[i] when the cell dp[i] represents the first i chars (whose last char is s[i-1]). Fix: commit to the convention "dp[i][j] = first i of a, first j of b; the chars are a[i-1], b[j-1]." Size the table (m+1)×(n+1) and let i,j run 1..m, 1..n.

Problem: top-down memo returns stale/wrong values (works recursively, breaks when cached). Root cause: memoizing on an incomplete key — the function's result depends on a parameter you didn't include in the cache key, so two different states share one cached answer. Fix: the memo key must equal the entire state. If a variable changes the return value, it belongs in the key (and thus in the table's dimensions). This is the state-capture bug wearing a caching hat.

Problem: top-down is correct but TLEs / StackOverflowError. Root cause: the memo isn't actually shared (re-allocated per call), or the recursion is O(n) deep on n ≈ 10⁵, or the key is a boxed HashMap where an array would do. Fix: allocate the memo once outside the recursion; convert to bottom-up to remove depth; prefer primitive int[]/int[][] over HashMap<StateKey, …> when the state is small integers (see the companion guide on boxing/cache costs).


Interview framing / how to sound senior#

  • Derive the state from the brute force, don't guess it. "Brute force branches on take/skip at each index and recomputes prefixes, so the state is just the index — 1-D DP." Working forward from the recursion is the reproducible method; pattern-matching to a memorized table is what fails on the twist.
  • Read the state off the constraints. n ≤ 20 → bitmask; n ≤ 400 → interval O(n³); n,m ≤ 10³ → 2-D O(nm); n ≤ 10⁵ → 1-D O(n)/O(n log n). Announcing this before coding is a Staff-level tell.
  • Name the family and both complexities up front. "Unbounded knapsack, O(n·amount) time, O(amount) space." Ties in the hiring committee break toward candidates who are precise and honest over clever.
  • Present top-down → bottom-up → space-optimized as a deliberate arc, and volunteer the loop-direction / base-case traps by name. The person who says "descending capacity, or I'd be solving unbounded by accident" has clearly done this before.
  • Know when it's NOT DP. "Generate all subsets/permutations" is backtracking (guide 04) — no overlap, so no memo. "Reachability / min jumps" is greedy (§3). "Best local choice after sorting" is greedy. Reaching for a table where greedy suffices is an over-engineering ding.
  • Offer reconstruction proactively. If the prompt might want the actual coins/subsequence/path, mention you can recover it with a parent-pointer array and note it blocks full space-optimization — that tradeoff is the senior beat.

Cheat-sheet#

FamilyState shapeTransition (recurrence)Time / SpaceCanonical problem
1-D lineardp[i] over an indexdp[i] = f(dp[i-1], dp[i-2], …)O(n) / O(1)climbing stairs, house robber
Kadanerunning best ending at icur = max(x, cur + x)O(n) / O(1)max subarray (min+max for product)
Griddp[i][j] cellcell + min/max(up, left[, diag])O(mn) / O(n)min path sum, unique paths, maximal square
Reverse griddp[i][j] from the exitsweep bottom-right → top-leftO(mn) / O(mn)dungeon game
0/1 knapsackdp[c], item oncedp[c]=max(dp[c],dp[c-w]+v), cap ↓O(nW) / O(W)partition equal subset, target sum
Unbounded knapsackdp[c], item reusedsame, cap ↑O(nW) / O(W)coin change (min), rod cutting
Count combos vs permsdp[amount]dp[c]+=dp[c-x]; item-outer vs target-outerO(nT) / O(T)coin change 2 / combination sum IV
Two-sequence stringdp[i][j] prefixesmatch → diag(±); else min/max neighborsO(mn) / O(min(m,n))LCS, edit distance, distinct subseq
LIS (patience)tails[] sortedbinary-search insert (lower/upper bound)O(n log n) / O(n)longest increasing subsequence
Intervaldp[l][r] rangesplit at k; iterate by lengthO(n³) / O(n²)burst balloons, min cut, predict winner
Bitmaskdp[mask][last]add an unused bitO(2ⁿ·n[·n]) / O(2ⁿ[·n])TSP (Held–Karp), assignment
Treereturn value(s) per subtreepost-order combine childrenO(n) / O(h)robber III, max path sum, diameter

Self-test#

  1. State the four things every DP is, and run all four on House Robber in one breath.
  2. What single property separates a DP from plain backtracking, and how do you check for it before writing any table?
  3. Convert naive Fibonacci to top-down, bottom-up, and O(1) space. At which step does space optimization become possible, and why?
  4. Give the constraint→state heuristic: what does n ≤ 20, n ≤ 400, n,m ≤ 1000, n ≤ 10⁵ each imply about the likely DP?
  5. In a rolled knapsack, why does 0/1 iterate capacity descending and unbounded ascending? What silently happens if you flip 0/1 to ascending?
  6. Coin Change 2 vs Combination Sum IV: which loop is outer in each, why does that flip combinations↔permutations, and what are the two answers for coins=[1,2], amount=3?
  7. Why must Dungeon Game be filled from the destination backward? What breaks in a forward sweep?
  8. Give both LIS algorithms. In the O(n log n) version, what exactly is tails[k], and why is tails itself not a valid subsequence? What changes for non-strict LIS?
  9. Write the edit-distance recurrence including base cases. Which initialization do people forget?
  10. Explain the Burst Balloons reframe: why "last balloon to burst," not first? What does k mean and what are its neighbors?
  11. In Binary Tree Max Path Sum, what value is returned up vs recorded in the global, and why can't you return the recorded one?
  12. Why is Integer.MAX_VALUE a dangerous "impossible" sentinel in min-DPs, and what do you use instead for coin change vs TSP?
  13. You memoized a recursion and it now returns wrong answers where the plain recursion was right. What's the one-line diagnosis?
  14. Give two reconstruction techniques (value → actual solution) and the space tradeoff each imposes.
  15. Name three problems that look like DP but should be greedy or backtracking instead, and say why.

Lineage / sources#

Dynamic programming is Richard Bellman's (1950s, RAND) — the "principle of optimality" and the name itself. The state/transition/base/order framing, memoization-vs-tabulation, and the knapsack/LCS/edit-distance/matrix-chain treatments are standard CLRS (Introduction to Algorithms, ch. 14–15). Kadane's algorithm is Jay Kadane (c. 1984). Edit distance is Wagner–Fischer (1974), itself from Levenshtein (1965) and Vintsyuk (1968). The O(n log n) LIS is patience sorting (Hammersley; Aldous–Diaconis) via Robinson–Schensted; LCS speedups trace to Hunt–Szymanski (1977). Held–Karp (1962) is the bitmask TSP. Burst Balloons, Dungeon Game, and the combinations-vs-permutations knapsack pair are LeetCode-canonical (LC 312, 174, 518/377). The pattern taxonomy and recognition cues (1-D/2-D/knapsack/interval/bitmask/tree DP) follow Grokking the Coding Interview (DesignGurus/Educative) and NeetCode 150/250; problem numbers reference LeetCode (5, 10, 44, 53, 62, 63, 64, 70, 72, 91, 96, 115, 120, 121, 139, 152, 174, 198, 213, 221, 279, 300, 312, 322, 337, 343, 377, 416, 494, 516, 518, 542, 646, 647, 674, 718, 746, 887, 1049, 1143). Interview-format and rubric notes (round mix by level, the "simulate a system"/DP+graph tilt at infra shops like Snowflake, the four scoring axes) come from Tech Interview Handbook, interviewing.io's 100K-interview study, and the 2025-26 vendor guides synthesized in the series playbook (00); treat exact round counts and Snowflake specifics as typical-not-guaranteed. For the internals and complexity of every structure used here as a tool — HashMap, ArrayList, primitive arrays, boxing and cache behavior — see the companion data-structures-java-kotlin-study-guide.md.