Dynamic Programming
Fourteen drills that turn exponential recursion into a table filled once. Every one follows the same recipe โ define the state, write the recurrence, pick a fill order, then squeeze the memory โ applied to five shapes: linear sequences, knapsacks, grids, two-string tables and interval tables.
What Dynamic Programming Really Is
Dynamic programming (DP) is recursion with a memory. A problem qualifies when it has two properties: optimal substructure โ the answer for a big input is built from answers for smaller inputs โ and overlapping subproblems โ the same smaller inputs are asked for again and again. The naive recursion for Fibonacci, Climbing Stairs or Coin Change re-solves identical subproblems an exponential number of times (see the tree: f(3) twice, f(2) three times, and it gets worse with every level). DP solves each distinct subproblem exactly once and stores the result, so the running time becomes (number of states) ร (work per state).
There are two equivalent ways to write it. Top-down memoisation keeps the recursive function and adds a cache (a HashMap or array) checked at the top of each call. Bottom-up tabulation allocates the table explicitly and fills it in an order where every dependency is already computed. All fourteen drills below use bottom-up โ no recursion depth limit, no hashing overhead, and the fill order makes it obvious which rows can be thrown away to save memory.
- State: what does
dp[i](ordp[i][j]) mean, in one precise sentence? Most wrong DPs have a vague state. - Recurrence: how is a state built from strictly smaller states? Enumerate the last decision (last step, last coin, last character).
- Base cases & order: which states need no recursion, and in what loop order are all dependencies ready?
- Answer & memory: which cell holds the final answer, and which old rows can be dropped?
How to Spot and Shape a DP
Recognising a DP is mostly about the wording of the question and the shape of the input. The chain below is the reasoning that turns "this looks like brute force" into a table.
Once you know it is DP, the second decision is which shape. Walk down the flow and stop at the first "yes" โ the drills on this page are grouped exactly this way.
Four Reusable DP Templates
Four templates cover all fourteen drills. Learn the loop skeleton and the memory trick of each; per problem you then only change the state meaning and the one-line recurrence.
- Top-down (memoised recursion) is faster to write and only visits reachable states โ good for sparse state spaces.
- Bottom-up has no stack-overflow risk (Java's default stack overflows around 104โ105 frames) and enables row-rolling.
- Interview tip: say the recurrence out loud first, write top-down if stuck, then convert to bottom-up once it is correct.
Linear DP โ Climbing Stairs & House Robber
n steps. Each move climbs either 1 or 2 steps. In how many distinct ways can you reach the top?ways(n) = ways(n-1) + ways(n-2) โ the Fibonacci recurrence.
Brute force recursion climb(n) = climb(n-1) + climb(n-2) is correct but makes about ฯn โ 1.618n calls โ n = 45 already needs over a billion. Every distinct n is solved once if we fill ways(1), ways(2), ... in increasing order, and because each cell reads only the two before it, two variables replace the whole array.
| i | prev2 = ways(i-2) | prev1 = ways(i-1) | cur = ways(i) |
|---|---|---|---|
| 3 | 1 | 2 | 3 |
| 4 | 2 | 3 | 5 |
| 5 | 3 | 5 | 8 |
| Approach | Time | Space | Notes |
|---|---|---|---|
| Plain recursion | O(ฯn) | O(n) stack | Exponential; TLE past n โ 40 |
| Memo / table | O(n) | O(n) | One cell per step |
| Rolling variables | O(n) | O(1) | Only prev1 and prev2 are alive |
- Base cases:
ways(1) = 1,ways(2) = 2(equivalently ways(0) = 1, ways(1) = 1). Returning n for n <= 2 gives 0 for n = 0 โ fine for LeetCode (n >= 1), but state the assumption. - Updating in the wrong order (
prev1 = cur; prev2 = prev1;) makes both variables equal โ always shift the older one first. - The answer overflows
intat n = 46; LeetCode caps n at 45. Uselongif the constraint is larger. - Variant: steps of {1, 3, 5} โ
ways(i) = ฮฃ ways(i - s); the template is identical, only the window widens.
nums[i] money. Robbing two adjacent houses triggers the alarm. Return the maximum amount you can rob.best(i) = max(best(i-1), best(i-2) + nums[i]).
Trying every subset of non-adjacent houses is O(2n). Greedy choices fail โ "always take the largest" picks 9 then cannot combine 7 and 3 in other inputs, and "take every other house" misses patterns like [2, 1, 1, 2] (answer 4 = first + last). The take/skip recurrence looks back only two positions, so two rolling variables give O(n) time and O(1) space.
| Approach | Time | Space | Notes |
|---|---|---|---|
| All subsets | O(2n) | O(n) | Check adjacency per subset |
| dp array | O(n) | O(n) | best[i] for every prefix |
| Rolling variables | O(n) | O(1) | prev2, prev1 |
- Starting both rolling variables at 0 handles empty input and a single house without special cases.
- Writing
dp[i] = dp[i-2] + nums[i]without the max forgets the "skip" option. - Alternating sums (even indices vs odd indices) is a classic wrong answer: [2, 1, 1, 2] โ 3, but the real answer is 4.
- Follow-up House Robber II (circular street): run the same function on nums[0..n-2] and nums[1..n-1] and take the max.
Counting & Minimising โ Coin Change & Decode Ways
coins (unlimited supply of each) and an amount, return the fewest coins that make up exactly that amount, or -1 if it cannot be made.amount - c. So dp[a] = 1 + min over coins c of dp[a - c] โ try every possible last coin.
Greedy "largest coin first" works for [1, 2, 5] but fails for coins [1, 3, 4] and amount 6: greedy gives 4+1+1 (3 coins), the optimum is 3+3 (2 coins). Plain recursion over last coins is exponential because amounts repeat. The table has amount + 1 cells, each trying every coin: O(amount ร coins). Initialise with amount + 1 (more coins than could ever be needed) as a safe "infinity" that cannot overflow when we add 1.
| Approach | Time | Space | Notes |
|---|---|---|---|
| Recursion over last coin | O(kamount) | O(amount) | k = number of coins |
| Greedy largest-first | O(k log k) | O(1) | WRONG for non-canonical coin systems |
| Bottom-up DP | O(amount ร k) | O(amount) | Each amount tries every coin |
- Original drill code called
Arrays.fillwithoutimport java.util.Arraysโ it did not compile. - Using
Integer.MAX_VALUEas infinity overflows ondp[a - c] + 1and turns negative; useamount + 1. amount = 0must return 0 (zero coins), not -1.- Do not confuse with Coin Change II (count combinations): there the coin loop must be OUTER to avoid counting 1+2 and 2+1 twice.
A โ 1 ... Z โ 26. Given a digit string s, return how many ways it can be decoded. A code may not have a leading zero ("06" is invalid).dp[i] = (s[i-1] valid ? dp[i-1] : 0) + (s[i-2..i) valid ? dp[i-2] : 0).
Generating all decodings recursively is exponential (a string of 1s has Fibonacci-many decodings). Index the DP by prefix length so that dp[0] = 1 (the empty prefix has one, empty, decoding) โ that base case makes the two-digit transition at i = 2 work without special code. Zeros are where bugs live: "0" alone is never valid, "10" and "20" are the only ways a zero can be used.
| i | one digit | two digits | dp[i] |
|---|---|---|---|
| 1 | "2" valid โ +dp[0] = 1 | โ | 1 |
| 2 | "2" valid โ +dp[1] = 1 | "22" valid โ +dp[0] = 1 | 2 |
| 3 | "6" valid โ +dp[2] = 2 | "26" valid โ +dp[1] = 1 | 3 |
| Approach | Time | Space | Notes |
|---|---|---|---|
| Enumerate decodings | O(2n) | O(n) | Fibonacci-many for "1111..." |
| dp array | O(n) | O(n) | Shown above โ easiest to reason about |
| Rolling variables | O(n) | O(1) | Keep only dp[i-1], dp[i-2] |
- The original listed the decodings of "226" as "BBF", "VF", "BF" โ "BF" would be 2 6. The third decoding is "BZ" (2 26).
- The original read
s.charAt(0)unguarded and crashed on an empty string; the fixed version returns 0. - Treating "0" as a valid single digit, or "05" as a valid pair (5 < 10), inflates the count.
- Once dp[i] becomes 0 (e.g. "100" at the second 0) every later value is 0 โ that is correct, not a bug.
Scanning Back โ Word Break & LIS
s and a dictionary wordDict, return true if s can be split into a sequence of one or more dictionary words (words may be reused).dp[i] = OR over j < i of (dp[j] AND s[j..i) in dict). It is unbounded knapsack where the "coins" are words.
Recursive splitting re-checks the same suffix many times โ "aaaa...ab" with words {"a", "aa", "aaa"} is exponential. With the boolean table each boundary is decided once. Two refinements: store the dictionary in a HashSet for O(1) membership, and only look back as far as the longest word, which caps the inner loop at L instead of n.
| Approach | Time | Space | Notes |
|---|---|---|---|
| Recursive splitting | O(2n) | O(n) | Every split point tried |
| dp, all j | O(n3) | O(n + dict) | n2 pairs ร O(n) substring/hash |
| dp, j within maxLen | O(n ยท L2) | O(n + dict) | L = longest word; substring costs O(L) |
- The original claimed O(n2);
substringplus hashing costs O(length), so the honest bound is O(n3) (or O(nยทL2) with the maxLen cap). - Forgetting
dp[0] = truemakes every dp value false. - Breaking out of the inner loop as soon as dp[i] is true is safe and saves time; continuing is just wasted work.
- Word Break II (return all sentences) is backtracking + memo โ the boolean table here can prune it.
nums, return the length of the longest strictly increasing subsequence (elements keep their order but need not be contiguous).dp[i] = 1 + max dp[j] over j < i with nums[j] < nums[i] โ the best chain that can be extended by nums[i]. Faster: for each length k remember only the smallest tail value any increasing subsequence of that length can have. A smaller tail is never worse, and the tails array stays sorted, so binary search applies.
All 2n subsequences is hopeless. The O(n2) DP is the version to derive first in an interview. The patience-sorting version processes each x with one binary search: find the first tail โฅ x; if none, x extends the longest chain, otherwise x replaces that tail (same length, smaller ending โ strictly better for the future). Using "โฅ" (lower bound) is what makes the subsequence strictly increasing.
| Approach | Time | Space | Notes |
|---|---|---|---|
| All subsequences | O(2n ยท n) | O(n) | Hopeless past n โ 25 |
| dp ending at i | O(n2) | O(n) | Easy to extend to "reconstruct the LIS" |
| Tails + binary search | O(n log n) | O(n) | Length only; tails is not itself a valid LIS |
- Using upper bound (first tail > x) computes the longest NON-decreasing subsequence: [7, 7, 7] would return 3 instead of 1.
- Returning
dp[n-1]in the quadratic version is wrong โ the LIS may end anywhere; take the max over all i. - Printing
tailsas "the LIS" is a common misconception: after [2, 5, 3] the tails are [2, 3] even though 5 came first. - To reconstruct the actual subsequence in O(n log n), store for each element the index of its predecessor when it is placed.
State Tricks โ Max Product & Jump Game
nums, find the contiguous non-empty subarray with the largest product and return that product.maxEnd and minEnd; the new extremes are among x, maxEnd ยท x, minEnd ยท x.
Checking every subarray is O(n2) with a running product. A single running max fails on [-2, 3, -4]: after 3 it would forget the -6 that becomes 24. Tracking the pair of extremes keeps the state complete in O(1) per element. Zeros need no special case: the candidate x = 0 resets both extremes, and the next element starts fresh.
| x | candidates (x, maxยทx, minยทx) | maxEnd | minEnd | best | why |
|---|---|---|---|---|---|
| - | -2 | -2 | -2 | -2 | start: everything = nums[0] |
| 3 | 3, -6, -6 | 3 | -6 | 3 | -6 is kept as minEnd: it may become huge later |
| -4 | -4, -12, 24 | 24 | -12 | 24 | negative x flips the old min (-6) into the new max 24 |
| Approach | Time | Space | Notes |
|---|---|---|---|
| All subarrays | O(n2) | O(1) | Running product per start |
| Max/min Kadane | O(n) | O(1) | Two extremes per position |
- Updating
maxEndbefore computingminEnduses the NEW max in the min formula โ compute both products from the old values first (the code stores them in a, b). - Initialising best to 0 is wrong for [-3] (answer -3); initialise with nums[0].
- Products can overflow
intfor long arrays of large values; LeetCode guarantees the answer fits in 32 bits, otherwise uselong. - An alternative O(n) proof: the answer is a prefix product or suffix product between zeros โ scan both directions and reset at 0.
nums[i] is the maximum jump length from index i. Starting at index 0, return true if you can reach the last index.good[i] = can index i reach the end" works, but notice the reachable indices always form a contiguous prefix [0, reach]. So the whole table collapses into one number: the furthest index reachable so far.
Backtracking over every jump length is exponential; the boolean DP is O(n2) because each index tries all its jump lengths. The greedy scan visits each index once: if the current index is beyond reach, it can never be visited, so return false; otherwise extend reach with i + nums[i]. This is the "greedy collapse" end of the DP spectrum โ the DP argument is what proves the greedy correct.
| Approach | Time | Space | Notes |
|---|---|---|---|
| Backtracking | O(2n) | O(n) | Tries every jump length |
| Boolean DP | O(n2) | O(n) | good[i] from good[i+1..i+nums[i]] |
| Greedy reach | O(n) | O(1) | Single forward pass |
- Checking
i > reachAFTER updating reach lets an unreachable index extend the frontier. - A single-element array is already at the goal โ return true even if nums[0] = 0.
- Jump Game II (minimum jumps) is a different greedy: BFS-like levels over [start, end] windows.
- Equivalent backward greedy: walk from the end keeping the leftmost "good" index; answer is whether it reaches 0.
Grid DP โ Unique Paths & Minimum Path Sum
m ร n grid and can only move right or down. How many distinct paths reach the bottom-right corner?paths(r, c) = paths(r-1, c) + paths(r, c-1), with 1 along the top row and left column.
Enumerating paths is exponential: there are C(m+n-2, m-1) of them. The grid table fills in O(mn). Since row r only reads row r-1, a single array of length n suffices: before the update dp[c] still holds "above", and dp[c-1] already holds "left". (A pure combinatorics answer also exists: choose which m-1 of the m+n-2 moves are downs.)
| Approach | Time | Space | Notes |
|---|---|---|---|
| Enumerate paths | O(2m+n) | O(m+n) | Recursion right/down |
| 2D table | O(mn) | O(mn) | Clear, easy to debug |
| One row | O(mn) | O(n) | Shown in the code |
| Binomial C(m+n-2, m-1) | O(min(m, n)) | O(1) | Use long and divide as you multiply |
- The original used
Arrays.fillwithoutimport java.util.Arrays, and listed the paths for m = 3, n = 2 as "RRD, RDR, DRR" โ that is 2 rights in a 2-column grid; the real paths are DDR, DRD, RDD. - Mixing up m (rows) and n (columns) gives the right count only because the problem is symmetric โ it bites in the obstacle variant.
- Unique Paths II (obstacles): set dp[c] = 0 on an obstacle and do not pre-fill the first row with 1s past an obstacle.
m ร n grid of non-negative costs, find a path from top-left to bottom-right (moving only right or down) that minimises the sum of the cells on it.min instead of +: the cheapest way into a cell is its own cost plus the cheaper of the two ways in. Greedy "step to the cheaper neighbour" fails because a cheap step can lead into an expensive region.
Brute force tries every monotone path (exponential). The table dp[r][c] = grid[r][c] + min(dp[r-1][c], dp[r][c-1]) is O(mn); edges have only one predecessor. The original drill wrote the sums back into grid to get O(1) extra space โ fast, but it destroys the caller's input. The version below keeps one row instead.
| Approach | Time | Space | Notes |
|---|---|---|---|
| All paths | O(2m+n) | O(m+n) | Exponential |
| In-place on grid | O(mn) | O(1) | Mutates the input (original drill) |
| One row dp | O(mn) | O(n) | Input untouched |
- Mutating the input array is a side effect interviewers will ask about โ say it explicitly if you choose it.
- Taking min over an out-of-range neighbour at the edges: handle row 0 and column 0 separately (or pad with +โ).
- Greedy local choice fails: on [[1, 2, 9], [5, 9, 9], [1, 1, 1]] it steps right to the 2 and pays 14, while going down first costs only 9.
- To print the path, keep the full 2D table and walk back from the end choosing the smaller predecessor (green cells in the diagram).
Grid & Knapsack โ Maximal Square & Partition
m ร n binary matrix of '0' and '1' characters, find the largest square containing only 1s and return its area.dp[r][c] be the side of the largest all-1 square whose bottom-right corner is (r, c). It can only be as big as the weakest of its three neighbours allows: 1 + min(up, left, up-left). One missing 1 in any of those directions caps the square.
Checking every top-left corner and every size is O((mn)2) or worse. The recurrence makes each cell O(1). Padding the table with an extra zero row and column removes all edge checks. Track the maximum side while filling and square it at the end โ the question asks for area.
| Approach | Time | Space | Notes |
|---|---|---|---|
| Every corner ร every size | O((mn)2) | O(1) | Re-scans cells repeatedly |
| Padded 2D dp | O(mn) | O(mn) | Shown in the code |
| One row + saved diagonal | O(mn) | O(n) | Keep the old dp[c-1] of the previous row in a temp |
- Returning the side instead of the area (or vice versa) โ read the question.
- Comparing
matrix[r][c] == 1(int) on achar[][]is always false; compare with'1'. - The original crashed on an empty matrix by reading
matrix[0]; the fixed version returns 0. - Using only up and left (no diagonal) accepts L-shapes as squares.
Trying all 2n subsets is the brute force. The DP keeps dp[s] = "some subset of the items seen so far sums to s". For each item, sweep s from target down to the item value so dp[s - x] still refers to the previous round โ the item is used at most once (Template B). An odd total is rejected immediately.
| Approach | Time | Space | Notes |
|---|---|---|---|
| All subsets | O(2n) | O(n) | Include / exclude recursion |
| 2D dp[i][s] | O(n ยท target) | O(n ยท target) | Classic knapsack table |
| 1D dp, descending s | O(n ยท target) | O(target) | Pseudo-polynomial: depends on the sum, not just n |
- Iterating s upwards silently turns this into the unbounded version: [1, 2, 5] would "reach" 4 via 2 + 2.
- Forgetting the odd-total check works but wastes a full DP pass.
- Pseudo-polynomial: with sums up to 109 this table is infeasible โ then use meet-in-the-middle or a bitset.
- Java bitset trick:
BitSetorlong[]withbits |= bits << xdoes the same sweep 64 sums at a time.
String Tables โ Palindromic Subsequence & Edit Distance
s, return the length of its longest subsequence that reads the same forwards and backwards.dp[i+1][j-1] + 2. If they do not, at least one end is unused, so drop one: max(dp[i+1][j], dp[i][j-1]).
Brute force checks 2n subsequences. The interval table has n2/2 cells, each O(1). The fill order matters: dp[i][j] reads row i+1, so i must go from n-1 down to 0. The original drill instead computed the LCS of s and reverse(s) โ also correct (an LPS is exactly a common subsequence of a string and its reverse) and also O(n2), but it builds an (n+1)2 table and hides the interval idea that generalises to other problems.
| Approach | Time | Space | Notes |
|---|---|---|---|
| All subsequences | O(2n ยท n) | O(n) | Check each for palindrome |
| LCS(s, reverse(s)) | O(n2) | O(n2) | Original drill approach |
| Interval dp[i][j] | O(n2) | O(n2) | Reducible to O(n) with two rows |
- Looping i upwards reads
dp[i+1][...]before it is filled โ all zeros, wrong answers. - Do not confuse with Longest Palindromic SUBSTRING (contiguous, drill 28): there you expand around centres.
- For j = i + 1 with equal chars,
dp[i+1][j-1]is a lower-triangle cell (value 0) โ correct, giving 2. - Empty string: guard n = 0 before indexing
dp[0][n-1], which would otherwise read column -1.
word1 into word2.The recursive version branches three ways per mismatch โ O(3m+n). The table over prefix pairs has (m+1)(n+1) cells: row 0 means "word1 is empty" (insert j chars) and column 0 means "word2 is empty" (delete i chars). Each cell is O(1), and walking back through the table recovers an actual edit script (green cells).
| Approach | Time | Space | Notes |
|---|---|---|---|
| Recursion | O(3m+n) | O(m+n) | Three branches per mismatch |
| Full table | O(mn) | O(mn) | Needed to reconstruct the edit script |
| Two rows | O(mn) | O(min(m, n)) | Length only; keep the diagonal in a temp |
- Forgetting the base row/column (dp[i][0] = i, dp[0][j] = j) treats converting to/from "" as free.
- Adding 1 even when characters match (the match must be free:
dp[i-1][j-1]). - Mixing up which neighbour is insert vs delete does not change the number, but it does produce a wrong edit script.
- Related: LCS uses the same table with +1 on match and max otherwise; one-edit-distance can be checked in O(n) without a table.
All Fourteen Drills at a Glance
Every drill on this page reduces to the same four answers โ state, recurrence, order, memory. The table lists them side by side; if you can reproduce the "pattern" column from the problem statement alone, the code follows.
| # | Drill | Pattern | Time | Space | Level |
|---|---|---|---|---|---|
| 81 | Climbing Stairs | Linear DP (Fibonacci) | O(n) | O(1) | Easy |
| 82 | House Robber | Linear take/skip | O(n) | O(1) | Medium |
| 83 | Coin Change | Unbounded knapsack | O(amount ยท k) | O(amount) | Medium |
| 84 | Decode Ways | Linear DP with validity | O(n) | O(n) โ O(1) | Medium |
| 85 | Word Break | Prefix DP + HashSet | O(n ยท L2) | O(n) | Medium |
| 86 | Longest Increasing Subsequence | Tails + binary search | O(n log n) | O(n) | Medium |
| 87 | Maximum Product Subarray | Kadane with max & min | O(n) | O(1) | Medium |
| 88 | Jump Game | Greedy reach (collapsed DP) | O(n) | O(1) | Medium |
| 89 | Unique Paths | Grid DP, one row | O(mn) | O(n) | Medium |
| 90 | Minimum Path Sum | Grid DP, one row | O(mn) | O(n) | Medium |
| 91 | Maximal Square | Grid DP, 3 neighbours | O(mn) | O(mn) | Medium |
| 92 | Partition Equal Subset Sum | 0/1 knapsack (descending) | O(n ยท sum) | O(sum) | Medium |
| 93 | Longest Palindromic Subsequence | Interval DP | O(n2) | O(n2) | Medium |
| 94 | Edit Distance | Two-sequence DP | O(mn) | O(mn) | Medium |
- Last decision โ every recurrence on this page came from asking "what was the last step / coin / character?"
- Loop direction is semantics โ ascending vs descending sums is the difference between unbounded and 0/1 knapsack.
- Extra state beats a wrong state โ Max Product needed both max and min; LIS needed tails per length.
- Squeeze last โ write the full table first, then roll rows once the answer is right.
- Next page: 10 ยท Backtracking & Design โ when you need every solution, not just the count.
- Previous page: 08 ยท Graphs.
- Theory: Dynamic Programming guide and Greedy (when the table collapses).
- More problems: 1D DP problem set and 2D DP problem set.
- Supporting tools: Binary Search (LIS tails) and HashSet (Word Break dictionary).