Dynamic Programming (DP)
Scope — The main DP sheet — state design, the pattern catalogue, and one canonical template per must-know DP family; the worked-solution archive, the rare techniques, and the five heaviest sub-topics live in their own sheets and are linked from here. See also — split out of this file: dp_examples.md — problem cards for the classic problems that are not a template’s anchor, and an index of where each common DP problem is worked; dp_advanced.md — game theory, tree DP, interval and string deep dives, probability DP; knapsack.md — 0/1 vs unbounded, subset sum, combinations vs permutations (knapsack_01_zh.md — the 0/1 case explained in Traditional Chinese); dp_string.md — the two-sequence grid family; dp_bitmask.md — state compression; dp_digit.md — counting numbers by digit; dp_monotonic_stack.md — stack-carried DP values; dp_loop_order.md — why a transition forces its loop nesting and direction, with LC 139 worked in five orders. Neighbouring sheets: dp_pattern.md — terse template index, one section per classic pattern; recursion_to_dp.md — converting a working recursion into DP step by step; kadane_algorithm.md — the maximum-subarray family in depth; stock_trading.md — the LC 121/122/188/309/714 state machine.
LeetCode Problem Lists
Overview
Dynamic Programming is an algorithmic paradigm that solves complex problems by breaking them down into simpler subproblems and storing their solutions to avoid redundant computations.
Key Properties
- Time Complexity: Problem-specific, typically O(n²) or O(n³)
- Space Complexity: O(n) to O(n²) for memoization table
- Core Idea: Trade space for time by memoizing overlapping subproblems
- When to Use: Problems with optimal substructure and overlapping subproblems
- Key Techniques: Memoization (top-down) and Tabulation (bottom-up)
Core Characteristics
-
Optimal Substructure: Optimal solution contains optimal solutions to subproblems
-
Overlapping Subproblems: Same subproblems solved multiple times
-
Memoization: Store results to avoid recomputation
-
State Transition: Define relationship between states
-
Core var:
state,transition -
Anything the transition needs to know can go into
state

Step
Step 1. define dp def
Step 2. define dp eq
Step 3. check boundary condition, req, edge case
Step 4. get the result
References
Recognition
The constraint cues come from the ruler in complexity_cheatsheet.md.
| Signal in the statement | Constraint cue | Reach for | Not this if |
|---|---|---|---|
| “maximum sum / product of a contiguous subarray”, “cannot pick two adjacent” | n ≤ 10⁵ | Template 1 1-D linear DP | the predecessor can be any earlier index → Template 7 |
“number of ways to reach step n”, “first i characters”, a base case for nothing |
n ≤ 10⁴ | Template 1a length-indexed table | |
| “segment / break / cut the string into valid pieces” | n ≤ 300 | Template 1b prefix partition | list every split → backtrack.md |
| “min path sum / unique paths”, moves only right and down | m, n ≤ 200 | Template 2 grid DP | four-way moves → Dijkstra / BFS |
| a 2-D table the problem did not hand you (palindromic subsequence, two strings) | n ≤ 1000 | Template 2a shape and fill order | |
| “burst / merge / multiply everything in a range”, cost depends on neighbours | n ≤ 500 | Template 3 interval DP, last split point | only the two ends move → Template 2a, O(n²) |
| “use each element at most once to reach a sum / partition into two equal halves” | sum ≤ 2·10⁴ | Template 4 0/1 knapsack | items reusable → knapsack.md unbounded |
| “buy / sell with cooldown / fee / at most k transactions” | n ≤ 10⁵ | Template 5 state machine | unlimited, no fee → greedy |
| “rob / climb / decode … in O(1) space” | n ≤ 10⁵ | Template 6 rolling variables | dp[i] reads every earlier cell → keep the table |
| “longest increasing / divisible / nesting subsequence” | n ≤ 2500 (n ≤ 10⁵ → tails) |
Template 7 LIS | fixed difference → Template 7a |
“longest subsequence where adjacent elements differ by a given difference” |
n ≤ 10⁵ | Template 7a value-keyed DP | difference not given → (index, diff) map |
| “minimum insert / delete / replace to turn one string into another” | m, n ≤ 500 | Template 8 edit distance | delete only → Template 9 |
| “longest common subsequence”, “min deletions to make two strings equal” | m, n ≤ 1000 | Template 9 LCS | contiguous → reset on mismatch (LC 718) |
| “longest / count palindromic substrings” | n ≤ 1000 | Template 10 palindrome table | subsequence → LC 516 in Template 2a |
| “assign / pair / visit every item once” | n ≤ 20 | Template 11 bitmask DP | n ≥ 40 → greedy, flow or meet in the middle |
| “how many numbers ≤ n have digit property P” | n ≤ 10¹⁸ | Template 12 digit DP | n ≤ 10⁶ → just loop |
| “list all combinations / partitions” | output is exponential | not DP → backtrack.md |
Problem Categories
Category 1: Linear DP
- Description: Single sequence problems with linear dependencies
- Examples: LC 70 (Climbing Stairs), LC 198 (House Robber), LC 300 (LIS)
- Pattern: dp[i] depends on dp[i-1], dp[i-2], etc.
- Sub-shape — prefix partition: dp[i] instead scans every cut point j < i and tests the whole segment s[j:i] — LC 139 (Word Break), LC 132, LC 279. See Template 1b.
- Sub-shape — value-keyed (DP + hash map): the only legal predecessor of x is one known value (x - d), so dp lives in a map keyed by value and the inner loop disappears — LC 1218. See Template 7a.
Category 2: Grid/2D DP
- Description: Problems on 2D grids or matrices
- Examples: LC 62 (Unique Paths), LC 64 (Minimum Path Sum), LC 221 (Maximal Square)
- Pattern: dp[i][j] depends on neighbors
Category 3: Interval DP
- Description: Problems on intervals or subarrays
- Examples: LC 312 (Burst Balloons), LC 1000 (Minimum Cost to Merge Stones)
- Pattern: dp[i][j] for interval [i, j]
Category 3-2: Game Theory / Minimax DP → dp_advanced.md
- Description: Two-player optimal play on arrays; each player picks from either end
- Examples: LC 486 (Predict the Winner), LC 877 (Stone Game), LC 1140 (Stone Game II)
- Pattern:
dp[i][j]= max relative score difference (current player minus opponent) onnums[i..j] - Recurrence:
dp[i][j] = max(nums[i] - dp[i+1][j], nums[j] - dp[i][j-1])— derivation and template in dp_advanced.md
Category 4: Tree DP
-
Description: DP on tree structures
-
Examples: LC 337 (House Robber III), LC 968 (Binary Tree Cameras)
-
Pattern: State at each node depends on children
-
📚 Implementation: Tree DP problems use DFS traversal for implementation. See dfs.md Template 6 (Bottom-up DFS) for the DFS traversal patterns used in tree DP solutions
-
Sub-patterns: bottom-up (post-order) tree DP and re-rooting DP (two-pass DFS) — both in dp_advanced.md
Category 5: State Machine DP
-
Description: Problems with multiple states and transitions
-
Examples: LC 714 (Stock with Fee), LC 309 (Stock with Cooldown), LC 122 (Stock II)
-
Pattern: Multiple DP arrays for different states
-
Key Characteristic: State transitions depend on previous state + action constraints
-
Sub-patterns: 2-state (LC 122), 3-state with cooldown (LC 309), 2k-state for k transactions (LC 123 / 188) — all in stock_trading.md
Category 6: Knapsack DP
- Description: Selection problems with constraints
- Examples: LC 416 (Partition Equal Subset), LC 494 (Target Sum)
- Pattern: dp[i][j] for items and capacity/target
Category 7: String DP
- Description: String matching, transformation, and subsequence problems
- Examples: LC 72 (Edit Distance), LC 1143 (LCS), LC 5 (Longest Palindromic Substring)
- Pattern: dp[i][j] for positions in two strings
Category 8: State Compression DP
- Description: Use bitmask to represent states, optimize space complexity
- Examples: LC 691 (Stickers to Spell Word), LC 847 (Shortest Path Visiting All Nodes)
- Pattern: dp[mask] where mask represents visited/selected items
Category 9: Monotonic Stack + DP → dp_monotonic_stack.md
Signal: a brute-force that removes elements round by round (each element is deleted once a bigger element reaches it), or an area/rectangle question over a histogram. Both are O(n^2) if simulated and O(n) with a monotonic stack that carries a DP value.
| Problem | LC | What the stack carries |
|---|---|---|
| Steps to Make Array Non-decreasing | 2289 | dp[i] = rounds element i survives; a popped chain contributes max(...) |
| Largest Rectangle in Histogram | 84 | index of the previous smaller bar → width of the rectangle ending here |
| Maximal Rectangle | 85 | LC 84 applied row by row over a running histogram |
| Maximal Square / Count Square Submatrices | 221 / 1277 | dp[i][j] = 1 + min(up, left, up-left) (grid DP, not a stack) |
| Flip String to Monotone Increasing | 926 | one-pass counter DP |
dp_monotonic_stack.md covers, in full: the survival-round transition with a worked trace, the LC 2289 template in Java and Python, the histogram area DP, the maximal square recurrence, and the LC 926 one-pass DP.
Templates & Algorithms
Template Comparison Table
| Template Type | Use Case | State Definition | When to Use |
|---|---|---|---|
| 1D Linear | Single sequence | dp[i] = state at position i | Fibonacci-like problems |
| 2D Grid | Matrix paths | dp[i][j] = state at (i,j) | Path counting, min/max |
| Interval | Subarray/substring | dp[i][j] = [i,j] interval | Palindrome, partition |
| Knapsack | Selection with limit | dp[i][w] = items & weight | 0/1, unbounded selection |
| State Machine | Multiple states | dp[i][state] = at i in state | Buy/sell stocks |
| Value-Keyed (Hash Map) | Chain with a fixed step | dp[value] = best chain ending with value | Predecessor is one known value |
Template 1: 1-D Linear DP — LC 53 Priority 5 of 5 — Must know — expect it in almost every loop
Recognise — one array, and the answer at position i depends only on a fixed number of
earlier positions: “maximum sum of a contiguous subarray”, “number of ways to reach step n”,
“cannot take two adjacent”. n ≤ 10⁵ → one O(n) pass is expected.
Key point — dp[i] is a claim about the subarray that ends exactly at i, so it has only
two choices: extend the best run ending at i-1, or restart at i. The answer can end anywhere,
so it is max(dp), not dp[n-1].
- State:
dp[i]= best sum of a subarray ending exactly at indexi - Recurrence:
dp[i] = max(nums[i], dp[i-1] + nums[i]) - Base:
dp[0] = nums[0] - Order:
iascending —dp[i]reads onlydp[i-1] - Answer:
max(dp)
# python
# IDEA: dp[i] = best sum of a subarray that ENDS at i: extend the previous run or restart at i
# time = O(n), space = O(n)
# LC 53 - Maximum Subarray
def maxSubArray(nums):
n = len(nums)
if n == 0:
return 0
# State: dp[i] = max sum of a subarray ending exactly at index i
dp = [0] * n
dp[0] = nums[0]
for i in range(1, n):
# Transition: extend the run ending at i-1, or start a new run at i
dp[i] = max(nums[i], dp[i - 1] + nums[i])
# The answer can end anywhere, so it is the best dp[i], not dp[n-1]
return max(dp)
// java
// LC 53 - Maximum Subarray
// IDEA: dp[i] = best sum of a subarray that ENDS at i: extend the previous run or restart at i
// time = O(n), space = O(n)
public int maxSubArray(int[] nums) {
int n = nums.length;
int[] dp = new int[n];
dp[0] = nums[0];
int best = dp[0];
for (int i = 1; i < n; i++) {
// extend the run ending at i-1, or start a new run at i
dp[i] = Math.max(nums[i], dp[i - 1] + nums[i]);
best = Math.max(best, dp[i]); // the answer can end at any index
}
return best;
}
Complexity
- Time O(n): the single
for i in range(1, n)loop does O(1) work per index. - Space O(n): the
dparray.dp[i]reads onlydp[i-1], so one running variable makes it O(1) — that is Kadane’s algorithm.
Dry run
nums = [-2, 1, -3, 4, -1, 2]
i=0 dp=-2 best=-2
i=1 max(1, -2+1=-1) -> 1 best=1
i=2 max(-3, 1-3=-2) -> -2 best=1
i=3 max(4, -2+4=2) -> 4 restart at 4
i=4 max(-1, 4-1=3) -> 3
i=5 max(2, 3+2=5) -> 5 best=5 ([4, -1, 2])
Traps
dp[i] = max(dp[i-1], nums[i])drops the+ nums[i]and returns the largest element, not the largest sum:[1, 2]gives 2 instead of 3.- Returning
dp[n-1]assumes the best run ends at the last index:[5, -9]returns -4, not 5. - Seeding
best = 0(ordpwith zeros) is wrong when every value is negative:[-3, -1]must return -1.
Not this if — the predecessor can be any earlier index, not just i-1 → LIS
(Template 7). A product instead of a sum
needs both the max and the min ending at i → LC 152 in kadane_algorithm.md.
Drill — LC 53 ⭐ · LC 746 (min instead of max, two steps back) · LC 152 ⭐ (carry max and min) · LC 918 (circular: total − min subarray) · LC 1191 (repeated array)
Recurrence catalogue (1-D)
| Problem Type | Recurrence | Example | Time | Space |
|---|---|---|---|---|
| Fibonacci | dp[i] = dp[i-1] + dp[i-2] | LC 70 Climbing Stairs | O(n) | O(1) |
| House Robber | dp[i] = max(dp[i-1], dp[i-2] + nums[i]) | LC 198 House Robber | O(n) | O(1) |
| Decode Ways | dp[i] = dp[i-1] + dp[i-2] (if valid) | LC 91 Decode Ways | O(n) | O(1) |
| Word Break → Template 1b | dp[i] = OR(dp[j] AND s[j:i] in dict) | LC 139 Word Break | O(n²) cuts, O(n³) with the slice | O(n) |
Maximum subarray (Kadane) → kadane_algorithm.md
dp[i] = max(nums[i], dp[i-1] + nums[i]) is the same 1-D shape with a “restart or extend” choice, so
it needs no table at all. kadane_algorithm.md covers that family in full —
LC 53, LC 152 (max product), LC 918 (circular), LC 1191 (repeated array) — including index tracking
and the divide-and-conquer variant.
Template 1a: Length-Indexed Table (n + 1 Slots) — LC 70
Recognise — the answer is about the first i items or a target of size i, and size 0
has a meaningful answer of its own: “number of ways to reach step n”, “can the first i
characters be …”, “fewest coins to make amount a”. The tell is a base case for nothing.
Key point — dp[i] is a claim about a length (or amount), not about the element at index
i. Slot 0 holds the empty prefix, so the table has n + 1 slots, the element decided at step i
is nums[i-1], and the answer is dp[n].
- State:
dp[i]= number of distinct ways to stand on stepi - Recurrence:
dp[i] = dp[i-1] + dp[i-2]— the last move was one step or two - Base:
dp[0] = 1(one way to stand still),dp[1] = 1 - Order:
iascending,2 → n - Answer:
dp[n]
# python
# LC 70 - Climbing Stairs
# IDEA: dp[i] = ways to reach step i; i is a COUNT of steps, so the table has n + 1 slots
# time = O(n), space = O(n)
def climbStairs(n):
dp = [0] * (n + 1) # dp[0] .. dp[n]: step n is the goal, so it needs a slot
dp[0] = 1 # one way to be at the bottom: take no step
dp[1] = 1
for i in range(2, n + 1):
dp[i] = dp[i - 1] + dp[i - 2] # last move was 1 step or 2 steps
return dp[n]
// java
// LC 70 - Climbing Stairs
// IDEA: dp[i] = ways to reach step i; i is a COUNT of steps, so the table has n + 1 slots
// time = O(n), space = O(n)
public int climbStairs(int n) {
int[] dp = new int[n + 1];
dp[0] = 1; // one way to be at the bottom: take no step
dp[1] = 1;
for (int i = 2; i <= n; i++) {
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[n];
}
Complexity
- Time O(n): one pass of
for i in range(2, n + 1), O(1) per step. - Space O(n): the
n + 1table. Two rolling variables make it O(1) (Template 6).
Traps
- The three tells must agree —
[0] * (n + 1),range(1, n + 1),return dp[n]. Mix the length view with the index view and you reads[n]off the end, or skip the last element. s[i-1]in a length-indexed loop is the current character, not the previous one (below).- Seeding
dp[2]without a guard:n = 1gives a table of 2 slots anddp[2] = …raisesIndexError. Seedingdp[0]anddp[1]only needsn ≥ 1.
Not this if — dp[i] is about the element at index i (“subarray ending at i”) → size n
and Template 1. A whole segment s[j:i] is tested at each
step → Template 1b.
Drill — LC 70 ⭐ · LC 746 (dp[n] is the “top”, one past the last step) · LC 198 ⭐ (n + 1
form below) · LC 91 ⭐ (dp[0] = 1, read s[i-1] and s[i-2:i]) · LC 926 (the s[i-1] trap)
Why two sizes exist at all — the difference comes down to what a single index in your DP array represents. Two reasons cover almost every problem; the third — physical “steps” vs “goals”, LC 746 — is in dp_advanced.md.
1. “Indices” vs “Count” (The Offset)
This is the most frequent reason for the difference.
-
Loop to
n(Array size =n): You are treating the index as a specific element in the input arraydp[i]means “the best result using the i-th element”- Example:
dp[3]represents “result at element index 3”
-
Loop to
n+1(Array size =n+1): You are treating the index as a quantity or lengthdp[i]means “the best result using the first i elements”- Example:
dp[3]represents “result using first 3 elements”
Example: LC 198 (House Robber)
# python
# LC 198 - House Robber, sized both ways
# IDEA: index view (dp[i] = best using house i) vs length view (dp[i] = best over the first i houses)
# time = O(n), space = O(n)
# Approach 1: Array size = n (index-based)
def rob_v1(nums):
n = len(nums)
if n == 0: return 0
if n == 1: return nums[0]
dp = [0] * n
dp[0] = nums[0]
dp[1] = max(nums[0], nums[1])
for i in range(2, n): # Loop to n
dp[i] = max(dp[i-1], dp[i-2] + nums[i])
return dp[n-1] # Answer at last index
# Approach 2: Array size = n+1 (count-based)
def rob_v2(nums):
n = len(nums)
dp = [0] * (n + 1) # Extra space for "0 houses"
dp[0] = 0 # Robbing 0 houses = 0
dp[1] = nums[0] if n > 0 else 0
for i in range(2, n + 1): # Loop to n+1
dp[i] = max(dp[i-1], dp[i-2] + nums[i-1]) # Note: nums[i-1]
return dp[n] # Answer at position n
The s[i-1] trap — it is the current character, not the previous one
Once the table is n+1 long, i stops being a position and becomes a length. That single
change is what makes s[i-1] read wrong at first glance: the -1 looks like “step back one
character”, but it is only the conversion from a length to the index of that length’s last
character.
dp[i] = a claim about the FIRST i CHARACTERS -> i is a LENGTH (0 .. n)
s[j] = the character sitting AT position j -> j is an INDEX (0 .. n-1)
a prefix of length i ends at index i-1
-> s[i-1] is the newest character inside dp[i] -> the CURRENT one
Line the two rows up once and the confusion goes away — they are off by one by construction, because the length row carries an extra “empty prefix” slot at the front:
s = 0 1 0 (n = 3)
index = 0 1 2 <- s is indexed 0..n-1
^ ^ ^
dp slot = 0 1 2 3 <- dp has n+1 slots
| | | |
"" "0" "01" "010" <- the prefix dp[i] talks about
| loop step | i (length) |
prefix s[:i] |
its last char | val = s[i-1] |
|---|---|---|---|---|
| 1st | 1 | "0" |
'0' |
s[0] = '0' ✅ current |
| 2nd | 2 | "01" |
'1' |
s[1] = '1' ✅ current |
| 3rd | 3 | "010" |
'0' |
s[2] = '0' ✅ current |
s[i-1] is never the previous character — the previous character is s[i-2], and you almost
never need it, because everything already decided about it is folded into dp[i-1].
LC 926 (Flip String to Monotone Increasing) written in this view — dp[i] = min flips to make
the first i characters monotone increasing:
# python
# LC 926 - Flip String to Monotone Increasing
# IDEA: dp[i] = min flips over the PREFIX OF LENGTH i, so the char decided at step i is s[i-1]
# time = O(n), space = O(n)
def minFlipsMonoIncr(s):
n = len(s)
dp = [0] * (n + 1) # n+1 slots: dp[0] = empty prefix = 0 flips
one_so_far = 0 # '1's seen so far in s[:i-1]
for i in range(1, n + 1): # i = prefix LENGTH, not a position
val = s[i - 1] # the character being decided right now
if val == '0':
# flip every earlier '1' -> one_so_far
# flip this '0' to '1' -> 1 more than dp[i-1]
dp[i] = min(one_so_far, dp[i - 1] + 1)
else:
dp[i] = dp[i - 1] # a '1' at the end is always safe, costs nothing
one_so_far += 1
return dp[n] # dp[n], NOT dp[n-1]
Trace on s = "010":
i=1 val=s[0]='0' dp[1]=min(one_so_far=0, dp[0]+1=1) = 0
i=2 val=s[1]='1' dp[2]=dp[1]=0 one_so_far -> 1
i=3 val=s[2]='0' dp[3]=min(one_so_far=1, dp[2]+1=1) = 1
answer = dp[3] = 1 ("010" -> "011" or "000")
Three tells put you in the length view, and all three must agree — dp = [0] * (n + 1),
for i in range(1, n + 1), return dp[n]. Mix one of them with the index view and you either
read s[n] off the end of the string or silently skip its last character.
2. Handling the “Empty” Base Case
Many DP problems need a base case representing “nothing” (target sum = 0, empty string, etc.).
Examples:
- Knapsack/Coin Change: Need
dp[target + 1]becausedp[0]represents sum = 0 - Longest Common Subsequence: Use
(n+1) x (m+1)matrix where first row/column = empty string — but a 2-D table indexed by real positions (LC 647’sdp[i][j]=s[i..j]) isn × n; see Sizing the table
# python
# LC 322 - Coin Change
# IDEA: dp[a] = fewest coins for amount a; dp[0] = 0 is the "empty" amount, so amount + 1 slots
# time = O(amount * len(coins)), space = O(amount)
def coinChange(coins, amount):
dp = [float('inf')] * (amount + 1) # Need amount+1
dp[0] = 0 # Base case: 0 coins needed for amount 0
for i in range(1, amount + 1): # Loop to amount+1
for coin in coins:
if i >= coin:
dp[i] = min(dp[i], dp[i - coin] + 1)
return dp[amount] if dp[amount] != float('inf') else -1
Comparison Summary
| Feature | Loop 0 to n |
Loop 0 to n+1 |
|---|---|---|
| Array Size | new int[n] |
new int[n + 1] |
dp[i] meaning |
Result at element i |
Result considering first i items |
| Typical Base Case | dp[0] and dp[1] |
dp[0] is the “empty” state |
| Access Pattern | dp[i] ↔ nums[i] |
dp[i] ↔ nums[i-1] |
| Final Answer | dp[n - 1] |
dp[n] |
| Use Case | Direct element mapping | Count/quantity problems, “goal” beyond array |
Template 1b: Prefix Partition DP — LC 139 Priority 4 of 5 — High value — a gap here costs you rounds
Template 1a’s n+1 sizing exists for exactly this shape. dp[i] is a claim about a prefix
length, never about “the character at index i” — and this is the one 1-D family whose
transition inspects a whole segment s[j:i] instead of a fixed offset like dp[i-1] or
dp[i-2]. Everything else in the family swaps out one thing: the test applied to that segment.
Recognise — “can the string be segmented / broken / cut into pieces that each satisfy …”,
“minimum number of cuts”, “fewest pieces”. n ≤ 300 (LC 139) → an O(n²) cut loop is fine; a
dictionary of short words (L ≤ 20) lets the inner loop stop after L steps.
Key point — guess the last word. If s[:i] splits at all, the split ends with one final
word s[j:i], and everything before it is the smaller problem s[:j] that dp[j] has already
answered. So dp[i] is an OR over every cut point j of dp[j] AND s[j:i] in words.
| Aspect | Detail |
|---|---|
| Pattern | Prefix partition — outer loop over prefix length, inner loop over the cut point |
| State | dp[i] = can s[:i], the first i characters, be segmented into dictionary words? |
| Base | dp[0] = True — the empty prefix is vacuously segmented |
| Transition | dp[i] = OR over j < i of (dp[j] AND s[j:i] in dict) |
| Answer | dp[n], not dp[n-1] — the table has n+1 slots |
j i
s: [ ---- dp[j] ---- ][ s[j:i] ]
already solved one word?
dp[i] is true the moment some j answers yes to both — which is why the inner loop can
break on the first hit. Read i as a boundary, not a character: it is the frontier between
“already segmented” and “not yet looked at”, so it legitimately reaches n — one past the last
character. That is the same frontier the BFS formulation queues up.
# python
# IDEA: PREFIX PARTITION DP — dp[i] = can s[:i] be cut into dictionary words
# LC 139 - Word Break
# time = O(n^2) cuts * O(L) slice+hash, space = O(n + dictionary)
class Solution(object):
def wordBreak(self, s, wordDict):
n = len(s)
words = set(wordDict) # set, not list — see the trap below
dp = [False] * (n + 1) # n+1 slots, indexed by prefix LENGTH
dp[0] = True # the empty prefix is always segmentable
for i in range(1, n + 1): # i = end boundary of the prefix
for j in range(i): # j = start of the candidate last word
if dp[j] and s[j:i] in words:
dp[i] = True
break # one valid cut is enough — it is an OR
return dp[n]
// java
// IDEA: PREFIX PARTITION DP — dp[i] = can s[:i] be cut into dictionary words
// LC 139 - Word Break
// time = O(n^2) cuts * O(L) substring+hash, space = O(n + dictionary)
public boolean wordBreak(String s, List<String> wordDict) {
int n = s.length();
Set<String> words = new HashSet<>(wordDict);
boolean[] dp = new boolean[n + 1]; // n+1 slots, indexed by prefix LENGTH
dp[0] = true; // the empty prefix is always segmentable
for (int i = 1; i <= n; i++) { // i = end boundary of the prefix
for (int j = 0; j < i; j++) { // j = start of the candidate last word
if (dp[j] && words.contains(s.substring(j, i))) {
dp[i] = true;
break; // one valid cut is enough — it is an OR
}
}
}
return dp[n];
}
Complexity — stated honestly. The doubled for i / for j loop is O(n²) iterations, but each
one builds and hashes a substring of length up to n:
| Accounting | Bound | When it is the right one to quote |
|---|---|---|
| Treat slice + hash as O(1) | O(n²) | The usual interview shorthand |
| Charge the slice its real cost | O(n³) | Python s[j:i] / Java substring — the strict bound |
Cap the inner loop at the longest word L |
O(n·L²) | The optimisation below; L ≤ 20 on LC 139 |
Space is O(n) for dp plus the word set. A last word longer than the longest dictionary entry can
never match, so the inner loop only needs to reach back L characters:
# python
# IDEA: same DP, inner loop capped by the longest word — O(n * L^2), effectively linear in n
# time = O(n * L^2), space = O(n)
L = max(map(len, words))
for i in range(1, n + 1):
for j in range(max(0, i - L), i):
if dp[j] and s[j:i] in words:
dp[i] = True
break
A second solution, kept because it is a different algorithm: treat each boundary i as a graph
node and each dictionary word s[i:j] as an edge i → j, then ask whether n is reachable. Same
bound, but it only expands boundaries that are actually reachable, and it is the form to reach for
when the interviewer frames the problem as a path search.
# python
# IDEA: BFS over boundaries — from boundary i, every dictionary word s[i:j] reaches boundary j
# time = O(n * L) cuts * O(L) slice+hash = O(n * L^2), L = longest word; space = O(n)
from collections import deque
def wordBreak(s, wordDict):
words = set(wordDict)
longest = max(len(w) for w in words)
n = len(s)
seen = [False] * (n + 1) # boundary already queued
seen[0] = True
queue = deque([0])
while queue:
i = queue.popleft()
if i == n: # the whole string is cut into words
return True
for j in range(i + 1, min(n, i + longest) + 1): # no word is longer: never slice past it
if not seen[j] and s[i:j] in words:
seen[j] = True
queue.append(j)
return False
Dry run
s = "leetcode", dict = {"leet", "code"}
i=0 dp[0] = True (base: empty prefix)
i=1..3 no j works dp[1..3] = False
i=4 j=0: dp[0] AND "leet" in dict -> dp[4] = True
i=5..7 no j works dp[5..7] = False
i=8 j=4: dp[4] AND "code" in dict -> dp[8] = True
^ answer = dp[n] = dp[8]
Traps
in wordDicton the raw list.s[j:i] in wordDictagainst a list is a linear scan with a string compare at each step — O(k·L) per lookup, so the whole solve degrades to O(n²·k·L) and TLEs.set(wordDict)makes it one hash. This is the single most common reason a correct-looking Word Break times out.- Looping over the dictionary instead of over the cut points. The inner loop is
range(i)— positions — notfor w in wordDict. A word-driven inner loop is fine (it still reads only finished cells); a word-driven outer loop is not, and it silently fails the problem’s own Example 2 —"applepenapple"needs"apple"on both sides of"pen", and one pass per word can only place a word once. Why, and all five orders side by side: dp_loop_order.md. - Returning
dp[-1]after mis-sizing. Withnslots instead ofn+1there is nowhere to put thedp[0] = Truebase case, and every answer collapses toFalse.
Not this if — the problem wants every split, not whether one exists: LC 131 Palindrome Partitioning is backtracking, because DP prunes to a yes/no or an optimum and cannot enumerate exponentially many outputs any faster. LC 140 is the hybrid: DP to prove a split exists, then backtracking to list them.
Drill — LC 139 ⭐ · LC 91 ⭐ (segment capped at 2 digits, combine = sum) · LC 279 (segment = a perfect square, combine = min + 1) · LC 132 (palindrome test, min cuts) · LC 140 (collect sentences) · LC 472 (the dictionary is the input itself)
Similar LeetCode Problems
Every one of these is the same dp[i] = f(dp[j], segment(j, i)) skeleton. Only the segment test
and the combining operator change:
| Problem | Segment test on s[j:i] |
Combine | Note |
|---|---|---|---|
| LC 139 Word Break | is it a dictionary word? | OR | The template above |
| LC 140 Word Break II | is it a dictionary word? | collect | Memoise lists of sentences, not booleans; backtrack from the same table |
| LC 472 Concatenated Words | is it one of the other words? | OR | Sort by length so only strictly shorter words are in the dict |
| LC 132 Palindrome Partitioning II | is it a palindrome? | min(+1) |
Precompute the palindrome table first, then the identical cut loop |
| LC 91 Decode Ways | is it a valid 1–2 digit code? | sum | Segment length is capped at 2, so the inner loop degenerates to dp[i-1] + dp[i-2] |
| LC 279 Perfect Squares | is i - j a perfect square? |
min(+1) |
Same shape over integers rather than characters |
| LC 1043 Partition Array for Max Sum | is the run ≤ k long? |
max |
Carries the running segment max alongside j |
| LC 322 Coin Change | is i - j a coin? |
min(+1) |
The unbounded-knapsack spelling of the same recurrence |
Template 2: 2-D Grid DP — LC 64 Priority 5 of 5 — Must know — expect it in almost every loop
Recognise — a grid is handed to you and movement is only right / down (or any one-way direction): “minimum path sum from top-left to bottom-right”, “number of unique paths”, “largest square of 1s”. m, n ≤ 200 → O(m·n) is expected.
Key point — a one-way move makes the grid a DAG: the best path to (i, j) must have come from
above or left, and row-major order fills both before (i, j). So each cell is computed once,
and no visited set is needed.
| Aspect | Detail |
|---|---|
| State | dp[i][j] = min cost to reach cell (i, j) from (0, 0) |
| Transition | dp[i][j] = grid[i][j] + min(dp[i-1][j], dp[i][j-1]) |
| Base Cases | First row: prefix sum left→right; first column: prefix sum top→bottom |
| Order | Row-major, i then j, both ascending |
| Answer | dp[m-1][n-1] |
# python
# LC 64 - Minimum Path Sum
# IDEA: a cell is reached from above or from the left, so take the cheaper and add the cell
# time = O(m * n), space = O(m * n)
def minPathSum(grid):
m, n = len(grid), len(grid[0])
dp = [[0] * n for _ in range(m)]
dp[0][0] = grid[0][0]
for i in range(1, m): # first column: only from above
dp[i][0] = dp[i - 1][0] + grid[i][0]
for j in range(1, n): # first row: only from the left
dp[0][j] = dp[0][j - 1] + grid[0][j]
for i in range(1, m):
for j in range(1, n):
dp[i][j] = grid[i][j] + min(dp[i - 1][j], dp[i][j - 1])
return dp[m - 1][n - 1]
// java
// LC 64 - Minimum Path Sum
// IDEA: a cell is reached from above or from the left, so take the cheaper and add the cell
// time = O(m * n), space = O(m * n)
public int minPathSum(int[][] grid) {
int m = grid.length;
int n = grid[0].length;
int[][] dp = new int[m][n];
dp[0][0] = grid[0][0]; // base: starting cell
for (int i = 1; i < m; i++) { // first column — only from above
dp[i][0] = dp[i - 1][0] + grid[i][0];
}
for (int j = 1; j < n; j++) { // first row — only from the left
dp[0][j] = dp[0][j - 1] + grid[0][j];
}
for (int i = 1; i < m; i++) { // the rest: min of above vs left
for (int j = 1; j < n; j++) {
dp[i][j] = grid[i][j] + Math.min(dp[i - 1][j], dp[i][j - 1]);
}
}
return dp[m - 1][n - 1];
}
Complexity
- Time O(m·n): the nested
for i/for jvisits each cell once, O(1) per cell. - Space O(m·n): the
dptable. Rowireads only rowi-1, so one row of lengthnis enough — O(n).
Dry run
grid dp
1 3 1 1 4 5
1 5 1 -> 2 7 6
4 2 1 6 8 7 answer dp[2][2] = 7 (1 → 3 → 1 → 1 → 1)
Traps
- Filling the first row / column with the general formula reads
dp[-1][j], which in Python is the last row, not an error: the answer is silently wrong. Seed them separately. grid = [[5]](1 × 1): both seeding loops are empty and the answer isgrid[0][0]; a solution that starts withdp[1][1]indexes out of range.- LC 63 (obstacles): an obstacle in the first row blocks every cell to its right, so the
first-row seed is not
1everywhere.
Not this if — movement in all four directions makes cycles, so “already filled” stops being true → Dijkstra (LC 1631, table below). Two strings instead of one grid → the table is invented, not handed to you → Template 2a.
Drill — LC 64 · LC 62 ⭐ (count instead of min) · LC 63 (obstacles in the seed row) ·
LC 120 (triangle, bottom-up) · LC 221 (1 + min of three neighbours) · LC 174 (fill from the
bottom-right)
DP or Dijkstra — One-Way vs Four-Way Moves
| LC 64 (Min Path Sum) | LC 1631 (Min Effort Path) | |
|---|---|---|
| Movement | Right + Down only | All 4 directions |
| Cost | Accumulative sum | Max of step differences |
| Revisit cells? | No (one direction) | Yes (better path possible) |
| Algorithm | 2D DP | Dijkstra + min-heap |
visited needed? |
No | Yes |
| Why DP works | No cycles, DAG structure | DP fails: can revisit |
Rule: If movement is constrained to one direction (right/down) → use 2D DP. If all 4 directions are allowed → use Dijkstra (or BFS with priority).
Recurrence catalogue (2-D)
| Problem Type | Recurrence | Example | Time | Space |
|---|---|---|---|---|
| Unique Paths | dp[i][j] = dp[i-1][j] + dp[i][j-1] | LC 62 Unique Paths | O(m×n) | O(n) |
| Min Path Sum | dp[i][j] = min(dp[i-1][j], dp[i][j-1]) + grid[i][j] | LC 64 Min Path Sum | O(m×n) | O(n) |
| LCS | dp[i][j] = dp[i-1][j-1] + 1 if match else max(…) | LC 1143 LCS | O(m×n) | O(n) |
| Edit Distance | dp[i][j] = min(insert, delete, replace) | LC 72 Edit Distance | O(m×n) | O(n) |
The Python transcription, the in-place variant, the O(m) rolling-row form, the top-down memoised form, the four-approach comparison table, the similar-problem map and the
[[1,3,1],[1,5,1],[4,2,1]]trace are in dp_advanced.md; the counting twin LC 62 (Unique Paths) is worked in dp_examples.md.
Template 2a: 2-D Table Shape and Fill Order — LC 516 Priority 5 of 5 — Must know — expect it in almost every loop
Template 2 is the easy 2-D case: the table is the input grid, so dp[i][j] sits where the problem
already put it. The other two 2-D families — interval DP (Template 3, Template 10) and two-sequence
DP (Template 8, Template 9) — invent a table the problem never handed you, and there the recurrence
is rarely what sinks people. The table is: what shape is it, which cells does the recurrence read,
and therefore which loop order is legal. This section is the shared map; the templates below
assume it.
Recognise — a 2-D table the problem did not hand you: “longest palindromic subsequence”, “minimum insertions to make a palindrome”, “edit distance / LCS between two strings”, “burst / merge everything in a range”. n ≤ 1000 → O(n²) cells; n ≤ 500 with a split point → O(n³).
Key point — every dp[...] on the right-hand side of the transition is an arrow into
dp[i][j]; a loop order is legal exactly when every arrow lands on a cell that is already filled.
LC 516 reads row i+1 and column j-1, so i descends and j ascends.
- State:
dp[i][j]= longest palindromic subsequence insides[i..j](Shape A,n × n) - Recurrence:
dp[i+1][j-1] + 2ifs[i] == s[j], elsemax(dp[i+1][j], dp[i][j-1]) - Base: the diagonal,
dp[i][i] = 1 - Order:
ifromn-1down to0,jfromi+1up ton-1 - Answer: the top-right corner,
dp[0][n-1]
# python
# LC 516 - Longest Palindromic Subsequence
# IDEA: i descends so row i+1 is already done; j ascends so column j-1 is already done
# time = O(n^2), space = O(n^2)
def longestPalindromeSubseq(s):
n = len(s)
dp = [[0] * n for _ in range(n)]
for i in range(n - 1, -1, -1): # bottom row first
dp[i][i] = 1 # base: one character
for j in range(i + 1, n): # left to right, above the diagonal
if s[i] == s[j]:
dp[i][j] = dp[i + 1][j - 1] + 2
else:
dp[i][j] = max(dp[i + 1][j], dp[i][j - 1])
return dp[0][n - 1]
// java
// LC 516 - Longest Palindromic Subsequence
// IDEA: i descends so row i+1 is already done; j ascends so column j-1 is already done
// time = O(n^2), space = O(n^2)
public int longestPalindromeSubseq(String s) {
int n = s.length();
int[][] dp = new int[n][n];
for (int i = n - 1; i >= 0; i--) { // bottom row first
dp[i][i] = 1; // base: one character
for (int j = i + 1; j < n; j++) { // left to right, above the diagonal
if (s.charAt(i) == s.charAt(j)) {
dp[i][j] = dp[i + 1][j - 1] + 2;
} else {
dp[i][j] = Math.max(dp[i + 1][j], dp[i][j - 1]);
}
}
}
return dp[0][n - 1];
}
Complexity
- Time O(n²): the
for i/for jpair visits each cell of the upper triangle once, O(1) per cell. A split pointknested inside (Template 3) makes it O(n³). - Space O(n²): the
n × ntable. Shape B tables compress to one row; Shape A does not (see Space compression below).
Dry run — LC 516 on s = "bbbab" is worked cell by cell in
Shape A below (Worked table — LC 516); the answer is the corner, 4.
Traps
- Filling in the wrong direction. Shape A’s
imust descend;for i in range(n)readsdp[i+1][...]while it is still0and the answer comes out silently too small — no crash, no wrong-index exception. Derive the direction from the arrow table below instead of recalling it. - Touching the dead triangle. In Shape A with
j == i + 1,dp[i+1][j-1]isdp[i+1][i], below the diagonal. It is0/Falseby construction — which happens to be correct for LC 516 (0 + 2 = 2, a two-character palindrome) and wrong for LC 5, which is exactly why that one needs its explicitj - i <= 2guard. - Copying the base row from another problem.
0,iand1are all correct base rows for different Shape B problems — LCS, edit distance, LC 115 — see the callout under Shape B. - Compressing before knowing you only need the number. The rolling row is free only when the answer is a value; the moment the follow-up is “and which operations?”, you need the table back.
Not this if — the grid is handed to you and i, j are coordinates →
Template 2. The transition needs a split point k inside the
range → only the length-driven order (Order B) is safe → Template 3.
Drill — LC 516 · LC 647 ⭐ (n × n, count the Trues) · LC 5 ⭐ (the j - i <= 2 guard) ·
LC 1143 ⭐ (Shape B) · LC 72 ⭐ (Shape B, three moves) · LC 1312 (min insertions)
The rule that decides the loop order
Write the transition first. Every
dp[...]on its right-hand side is an arrow pointing intodp[i][j]. A loop order is legal exactly when every arrow lands on a cell that is already filled. There is nothing else to it.
| The transition reads | Which neighbour | So the loop must go |
|---|---|---|
dp[i-1][j] |
row above | i ascending (1 → m) |
dp[i+1][j] |
row below | i descending (n-1 → 0) |
dp[i][j-1] |
column left | j ascending (1 → n) |
dp[i][j+1] |
column right | j descending |
dp[i+1][j-1] |
the inner range (both ends move in) | i descending and j ascending |
dp[i][k], dp[k][j] for i < k < j |
shorter ranges | interval length ascending |
That table is the whole decision procedure, and it is why LC 516’s loop runs i backwards while
LC 72’s runs i forwards — not style, not taste, just which neighbours each recurrence reads.
The 1-D sibling of this table — plus the other loop-order axis, whether an item loop may sit outside the state loop at all — is dp_loop_order.md.
Which shape am I building?
What do i and j MEAN in dp[i][j]?
├── ONE sequence; i and j are the two ENDS of a sub-range s[i..j]
│ → Shape A — n × n, only the upper triangle is real,
│ base on the diagonal, answer at the TOP-RIGHT dp[0][n-1]
│ LC 516, 5, 647, 1312, 312, 877
│
├── TWO sequences; i and j are PREFIX LENGTHS (first i of s1, first j of s2)
│ → Shape B — (m+1) × (n+1), row 0 / column 0 are the empty prefixes,
│ answer at the BOTTOM-RIGHT dp[m][n]
│ LC 72, 1143, 115, 97, 10
│
└── a real grid handed to you; i and j are COORDINATES
→ Template 2 above — m × n, answer at dp[m-1][n-1]
LC 62, 64, 221
Sizing the table — n × n or (n+1) × (n+1)?
Worked on LC 647 (Palindromic Substrings). This is Template 1a’s rule applied to two axes. The size comes from what an index means, not from how many dimensions the table has:
dp[i][j] uses… |
i is a… |
Size | Base case lives in | Reads a character as |
|---|---|---|---|---|
real string indexes — the sub-range s[i..j] |
position | n × n |
the diagonal dp[i][i] |
s[i], s[j] |
prefix lengths — the first i / first j characters |
count | (m+1) × (n+1) |
row 0 / column 0 = the empty prefix | s1[i-1], s2[j-1] |
Memory trick: real index → n × n; “first i characters” → n + 1. The extra slot exists only
to hold the empty prefix. A sub-range s[i..j] with i <= j is never empty, so Shape A has
nothing to put there.
# python
# LC 647 - Palindromic Substrings
# IDEA: dp[i][j] = s[i..j] is a palindrome. i and j are REAL indexes, so n x n, not n+1.
# time = O(n^2), space = O(n^2)
def countSubstrings(s):
n = len(s)
dp = [[False] * n for _ in range(n)] # n slots: every index is a character of s
count = 0
for length in range(1, n + 1): # short ranges first: dp[i+1][j-1] is shorter
for i in range(n - length + 1):
j = i + length - 1 # j is a position too, so it stays <= n-1
if s[i] == s[j] and (length <= 2 or dp[i + 1][j - 1]):
dp[i][j] = True
count += 1
return count
Compare LC 1143 (Shape B): dp[i][j] is “first i of s1, first j of s2”, so i runs to m
— one past the last index — and the table needs m + 1 rows to have somewhere to put it.
The tell is consistency, as in 1-D. Index view: [[...] * n for _ in range(n)], loops stay
<= n-1, compare s[i], return dp[0][n-1]. Length view: (m+1) × (n+1), loops run 1 → m,
compare s1[i-1], return dp[m][n]. Size LC 647 at n + 1 while still comparing s[i] and the
code may still pass — but row n is dead weight, and the next edit that loops to n reads s[n]
off the end.
Shape A — one sequence, i/j are the two ends
State: dp[i][j] = the answer for the sub-range s[i..j].
j = 0 1 2 ... n-1
i = 0 [ base ] [ ? ] [ ? ] ... [ 🎯 dp[0][n-1] ] ← the answer
1 [ dead ] [ base ] [ ? ] ... [ ? ]
2 [ dead ] [ dead ] [ base ] ... [ ? ]
:
n-1 [ dead ] [ dead ] [ dead ] ... [ base ]
diagonal i == j : the length-1 base case (dp[i][i] = 1, or True)
below it i > j : an empty range — never written, never read
corner (0, n-1): the whole sequence — this is what you return
Dependency — the three cells a boundary-shrinking transition reads:
the 2 x 2 window, drawn the same way up as the table above:
j-1 j
┌──────────────┬──────────────┐
row i │ dp[i][j-1] │ dp[i][j] │ ← 🎯 being filled
├──────────────┼──────────────┤
row i+1 │ dp[i+1][j-1] │ dp[i+1][j] │
└──────────────┴──────────────┘
dp[i][j] ⟵ dp[i][j-1] → from the LEFT — drop the right end s[j]
dp[i][j] ⟵ dp[i+1][j] ↑ from BELOW — drop the left end s[i]
dp[i][j] ⟵ dp[i+1][j-1] ↗ from BELOW-LEFT — drop BOTH ends
Every arrow travels UP and to the RIGHT. Nothing arrives from above or from
the right, so the table fills from the diagonal OUTWARD, toward the
top-right corner.
Order A — i backward, j forward (write this one)
# python
# LC 516 - Longest Palindromic Subsequence
# IDEA: i descends so row i+1 is already done; j ascends so column j-1 is already done
# time = O(n^2), space = O(n^2)
def longestPalindromeSubseq(s):
n = len(s)
dp = [[0] * n for _ in range(n)]
for i in range(n): # base: one character is a palindrome of length 1
dp[i][i] = 1
for i in range(n - 1, -1, -1): # ↑ bottom row first
for j in range(i + 1, n): # → left to right, staying above the diagonal
if s[i] == s[j]:
dp[i][j] = dp[i + 1][j - 1] + 2
else:
dp[i][j] = max(dp[i + 1][j], dp[i][j - 1])
return dp[0][n - 1]
Order B — by interval length (diagonal sweep)
# python
# IDEA: same table, one diagonal at a time. The only order that works when the transition
# needs a split point k (dp[i][k] and dp[k][j]) instead of the two ends.
# time = O(n^2) for a two-end transition, O(n^3) once a k-loop is nested inside
n = len(s)
dp = [[0] * n for _ in range(n)]
for i in range(n):
dp[i][i] = 1 # base: the length-1 diagonal
for length in range(2, n + 1): # sub-range length: short → long
for i in range(n - length + 1): # left end
j = i + length - 1 # right end is derived, never looped
if s[i] == s[j]: # same transition as Order A (LC 516)
dp[i][j] = dp[i + 1][j - 1] + 2
else:
dp[i][j] = max(dp[i + 1][j], dp[i][j - 1])
Both are legal — here is the order the cells actually get filled (n = 5)
Order A (i: n-1→0, j: i+1→n-1) Order B (length: 2→n, i: 0→n-length)
j0 j1 j2 j3 j4 j0 j1 j2 j3 j4
i0 · 7 8 9 10 i0 · 1 5 8 10
i1 · 4 5 6 i1 · 2 6 9
i2 · 2 3 i2 · 3 7
i3 · 1 i3 · 4
i4 · i4 ·
row by row, bottom → top diagonal by diagonal, ↗
Check the corner cell dp[0][4] — step 10 in both orders.
It reads dp[1][3], dp[1][4], dp[0][3]:
Order A → steps 5, 6, 9 ✅ all filled before step 10
Order B → steps 6, 9, 8 ✅ all filled before step 10
How to pick: Order A when the transition moves the two ends (dp[i+1][j-1], dp[i+1][j],
dp[i][j-1]) — palindromes, LCS against the reverse of the same string. Order B when the transition
needs a split point k strictly inside the range (dp[i][k] + dp[k][j]) — burst balloons, merge
stones, matrix chain — because there “already filled” means strictly shorter, and only a
length-driven loop guarantees that.
Worked table — longest palindromic subsequence on s = "bbbab"
LC 516, filled with the code above:
s = b b b a b
j0 j1 j2 j3 j4
b i0 [ 1 ][ 2 ][ 3 ][ 3 ][ 4 ] ← 🎯 answer = 4
b i1 · [ 1 ][ 2 ][ 2 ][ 3 ]
b i2 · · [ 1 ][ 1 ][ 3 ]
a i3 · · · [ 1 ][ 1 ]
b i4 · · · · [ 1 ]
dp[2][3] = 1 : 'b' != 'a' → max(dp[3][3]=1, dp[2][2]=1) = 1
dp[0][2] = 3 : s[0]=='b'==s[2] → dp[1][1] + 2 = 3 ("bbb")
dp[1][3] = 2 : 'b' != 'a' → max(dp[2][3]=1, dp[1][2]=2) = 2
dp[0][4] = 4 : s[0]=='b'==s[4] → dp[1][3] + 2 = 4 ("bbbb")
Which way did it go? — walking the table backwards
The forward pass stores values. The decisions are recovered afterwards by re-asking, at each cell, which neighbour produced that value — no parent pointers needed, just re-evaluate the transition:
at (0,4) s[0]=='b'==s[4] → matched pair: take both, jump ↘ to (1,3)
at (1,3) 'b' != 'a', and
dp[1][2]=2 > dp[2][3]=1 → the max came from the LEFT: drop s[3], move to (1,2)
at (1,2) s[1]=='b'==s[2] → matched pair: take both, jump ↘ to (2,1)
at (2,1) i > j → empty range: stop
pairs collected, outer → inner: (s0, s4) then (s1, s2)
answer string: b [ b b ] b = "bbbb" ✓ length 4 = dp[0][4]
Shape A problems
| Problem | On s[i] == s[j] |
Otherwise |
|---|---|---|
| LC 516 Longest Palindromic Subsequence | dp[i+1][j-1] + 2 |
max(dp[i+1][j], dp[i][j-1]) |
| LC 5 Longest Palindromic Substring | True if j - i <= 2 or dp[i+1][j-1] |
False |
| LC 647 Palindromic Substrings | same test as LC 5, counting the Trues |
False |
| LC 1312 Min Insertions to Make Palindrome | dp[i+1][j-1] |
1 + min(dp[i+1][j], dp[i][j-1]) |
Two Shape A problems have no character test at all, and they are the ones that pick the fill order for you:
- LC 312 (Burst Balloons) — a split point, so Order B only:
dp[i][j] = max over k in (i, j) of dp[i][k] + dp[k][j] + b[i]*b[k]*b[j]. Worked in Template 3. - LC 877 / LC 486 (Stone Game / Predict the Winner) — same triangle, but
dp[i][j]holds a score difference:max(nums[i] - dp[i+1][j], nums[j] - dp[i][j-1]). Two ends, so Order A. The minimax reasoning is in dp_advanced.md.
Shape B — two sequences, i/j are prefix lengths
State: dp[i][j] = the answer for the first i characters of s1 and the first j
characters of s2. So the characters this cell decides about are s1[i-1] and s2[j-1] — the
off-by-one everyone trips on is just a consequence of row/column 0 meaning the empty prefix.
"" s2[0] s2[1] ... s2[n-1]
j = 0 1 2 ... n
"" i = 0 [base] [base] [base] ... [ base ] ← s1 is empty
s1[0] 1 [base] [ ? ][ ? ] ... [ ? ]
s1[1] 2 [base] [ ? ][ ? ] ... [ ? ]
:
s1[m-1] m [base] [ ? ][ ? ] ... [ 🎯 dp[m][n] ] ← the answer
row 0 / column 0 : one string is empty, so the answer is FORCED, not computed
every other cell : three neighbours, one decision
Dependency — three arrows, all arriving from above / left / above-left:
the same 2 x 2 window, one row higher:
j-1 j
┌──────────────┬──────────────┐
row i-1 │ dp[i-1][j-1] │ dp[i-1][j] │
├──────────────┼──────────────┤
row i │ dp[i][j-1] │ dp[i][j] │ ← 🎯 being filled
└──────────────┴──────────────┘
dp[i][j] ⟵ dp[i-1][j-1] ↘ from ABOVE-LEFT — consume from BOTH strings
dp[i][j] ⟵ dp[i-1][j] ↓ from ABOVE — consume from s1 only
dp[i][j] ⟵ dp[i][j-1] → from the LEFT — consume from s2 only
Nothing arrives from below or from the right, so plain row-major
(i: 1 → m, inner j: 1 → n) is legal — and it is the only order anyone writes.
What each move means — the recurrence changes per problem, the geometry never does:
| Move | Cell read | Meaning | LC 72 (Edit Distance) | LC 1143 (LCS) |
|---|---|---|---|---|
| ↘ diagonal | dp[i-1][j-1] |
consume one char from each string | free if equal, else replace (+1) |
+1 if equal |
| ↓ vertical | dp[i-1][j] |
consume from s1 only |
delete s1[i-1] (+1) |
skip s1[i-1] |
| → horizontal | dp[i][j-1] |
consume from s2 only |
insert s2[j-1] (+1) |
skip s2[j-1] |
# python
# LC 72 - Edit Distance
# IDEA: Shape B — base row/col are the forced answers, then row-major over three neighbours
# time = O(m * n), space = O(m * n) → O(min(m, n)) once rolled
def minDistance(s1, s2):
m, n = len(s1), len(s2)
dp = [[0] * (n + 1) for _ in range(m + 1)]
for i in range(m + 1):
dp[i][0] = i # s2 empty → delete every character of s1
for j in range(n + 1):
dp[0][j] = j # s1 empty → insert every character of s2
for i in range(1, m + 1):
for j in range(1, n + 1):
if s1[i - 1] == s2[j - 1]:
dp[i][j] = dp[i - 1][j - 1] # ↘ free
else:
dp[i][j] = 1 + min(
dp[i - 1][j], # ↓ delete
dp[i][j - 1], # → insert
dp[i - 1][j - 1], # ↘ replace
)
return dp[m][n]
The base row is where the bug lives.
dp[i][0] = ifor edit distance,0for LCS,1for LC 115 — the empty string is a subsequence of anything, exactly once. Derive it, never copy it: puti = 0into your own state sentence and read the answer off in English.
Worked table — edit distance on "horse" → "ros", with the path it chose
LC 72, filled with the code above:
"" r o s
"" [ 0*] 1 2 3
h [ 1 ] [ 1*] 2 3
o [ 2 ] 2 [ 1*] 2
r [ 3 ] 2 [ 2*] 2
s [ 4 ] 3 3 [ 2*]
e [ 5 ] 4 4 [ 3*] ← 🎯 dp[5][3] = 3
* = the cells on the chosen path, recovered by walking backwards from dp[5][3]:
(5,3) 'e' vs 's' ✗ 1 + min(↓ dp[4][3]=2, → dp[5][2]=4, ↘ dp[4][2]=3)
the min came from ↓ ⇒ DELETE 'e'
(4,3) 's' == 's' ✓ free ↘ ⇒ KEEP 's'
(3,2) 'r' vs 'o' ✗ 1 + min(↓ dp[2][2]=1, → dp[3][1]=2, ↘ dp[2][1]=2)
the min came from ↓ ⇒ DELETE 'r'
(2,2) 'o' == 'o' ✓ free ↘ ⇒ KEEP 'o'
(1,1) 'h' vs 'r' ✗ 1 + min(↓ dp[0][1]=1, → dp[1][0]=1, ↘ dp[0][0]=0)
the min came from ↘ ⇒ REPLACE 'h' → 'r'
(0,0) both prefixes empty ⇒ stop
replayed forwards: replace h→r "horse" → "rorse"
keep o
delete r "rorse" → "rose"
keep s
delete e "rose" → "ros" ✓ 3 ops = dp[5][3]
Read the path geometrically: ↘ means a character survives, ↓ means a character of s1 is spent,
→ means a character of s2 is spent. The identical walk over the LC 1143 table spells out the
actual common subsequence instead of an operation list — same table, same backward walk, different
thing recorded on the way.
Space compression — and when it is not available
dp[i][j] reads only row i and row i-1, so Shape B collapses to two rows, then to one plus a
scalar for the diagonal that is about to be overwritten:
# python
# IDEA: rolling row — prev is row i-1, cur is row i. s2 is the inner axis, so pass the
# SHORTER string as s2.
# time = O(m * n), space = O(min(m, n))
def minDistance_rolled(s1, s2):
m, n = len(s1), len(s2)
prev = list(range(n + 1))
for i in range(1, m + 1):
cur = [i] + [0] * n
for j in range(1, n + 1):
cur[j] = prev[j - 1] if s1[i - 1] == s2[j - 1] else 1 + min(prev[j], cur[j - 1], prev[j - 1])
prev = cur
return prev[n]
Shape A does not compress this way. dp[i][j] reads row i+1 at two different columns
(j-1 and j), and the diagonal sweep keeps reaching back across every earlier diagonal — there is
no “last two rows” window to hold. Budget O(n²) for interval DP and move on.
And do not compress at all if you need the traceback: the rolling row throws away exactly the cells the backward walk would have read. Whenever the answer is the path rather than the number, keep the full table (or store the chosen move per cell).
Shape B problems
| Problem | On s1[i-1] == s2[j-1] |
Otherwise |
|---|---|---|
| LC 72 Edit Distance | dp[i-1][j-1] |
1 + min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]) |
| LC 1143 Longest Common Subsequence | dp[i-1][j-1] + 1 |
max(dp[i-1][j], dp[i][j-1]) |
| LC 115 Distinct Subsequences | dp[i-1][j-1] + dp[i-1][j] |
dp[i-1][j] |
Two more live on the same grid without a s1 vs s2 character test:
- LC 97 (Interleaving String) — boolean, and the comparison is against a third string:
dp[i][j] = (dp[i-1][j] and s1[i-1] == s3[i+j-1]) or (dp[i][j-1] and s2[j-1] == s3[i+j-1]). No diagonal at all — only ↓ and →, which is the shape of “every character comes from exactly one of the two sources”. - LC 10 (Regular Expression Matching) — the
jaxis is the pattern. A plain character or.is the ordinary ↘;'*'splits into “zero copies”dp[i][j-2]and “one more copy”dp[i-1][j], both of which still read only left and above, so row-major still holds.
The 2-D DP cheat sheet
| Shape A — one sequence | Shape B — two sequences | Template 2 — real grid | |
|---|---|---|---|
dp[i][j] means |
the range s[i..j] |
first i of s1 vs first j of s2 |
the cell (i, j) |
| Table size | n × n, upper triangle |
(m+1) × (n+1) |
m × n |
| Base case | the diagonal dp[i][i] |
row 0 and column 0 | first row and first column |
| Answer cell | top-right dp[0][n-1] |
bottom-right dp[m][n] |
bottom-right dp[m-1][n-1] |
| Reads | dp[i+1][j-1], dp[i+1][j], dp[i][j-1] |
dp[i-1][j-1], dp[i-1][j], dp[i][j-1] |
dp[i-1][j], dp[i][j-1] |
| Loop order | i: n-1 → 0, j: i+1 → n-1 (or by length) |
i: 1 → m, j: 1 → n |
i: 0 → m-1, j: 0 → n-1 |
| Time / space | O(n²) / O(n²) — O(n³) with a split point | O(m·n) / O(min(m,n)) rolled | O(m·n) / O(n) rolled |
| Canonical | LC 516, 5, 647, 1312, 312 | LC 72, 1143, 115, 97 | LC 62, 64, 221 |
Template 3: Interval DP — LC 312
The table is Shape A —
n × n, upper triangle, answer in the top-right corner. Its skeleton, the two legal fill orders and why a split point forces the length-driven one are in Template 2a.
Recognise — the cost of an operation depends on neighbours that change as you go: “burst balloons, coins = left × this × right”, “merge adjacent piles, cost = their sum”, “multiply a chain of matrices”. n ≤ 300–500 → the O(n³) split-point loop is what the limit was set for.
Key point — think about which element is processed last, not first. When k is the last
balloon burst inside the open interval (i, j), everything in (i, k) and (k, j) is already
gone, so the two sides are independent subproblems and k’s neighbours are exactly i and j.
- State:
dp[i][j]= max coins from bursting every balloon strictly betweeniandj - Recurrence:
dp[i][j] = max over i < k < j of dp[i][k] + dp[k][j] + b[i]·b[k]·b[j] - Base:
dp[i][i+1] = 0— an open interval with nothing inside - Order: interval length ascending (a split point reads strictly shorter ranges)
- Answer:
dp[0][n+1], after paddingb = [1] + nums + [1]
# python
# LC 312 - Burst Balloons
# IDEA: interval DP — pick k as the LAST balloon burst in (left, right); its neighbours are then fixed
# time = O(n^3), space = O(n^2)
def maxCoins(nums):
n = len(nums)
balloons = [1] + nums + [1] # boundary balloons with value 1
dp = [[0] * (n + 2) for _ in range(n + 2)] # dp[i][j]: open interval (i, j)
for length in range(2, n + 2): # short intervals first
for left in range(n + 2 - length):
right = left + length
for k in range(left + 1, right): # k = the LAST balloon burst in (left, right)
coins = balloons[left] * balloons[k] * balloons[right]
total = coins + dp[left][k] + dp[k][right]
dp[left][right] = max(dp[left][right], total)
return dp[0][n + 1]
// java
// LC 312 - Burst Balloons
// IDEA: interval DP — pick k as the LAST balloon burst in (i, j); its neighbours are then fixed
// time = O(n^3), space = O(n^2)
public int maxCoins(int[] nums) {
int n = nums.length;
int[] balloons = new int[n + 2]; // boundaries: [1, ...nums..., 1]
balloons[0] = 1;
balloons[n + 1] = 1;
for (int i = 0; i < n; i++) {
balloons[i + 1] = nums[i];
}
int[][] dp = new int[n + 2][n + 2]; // dp[i][j]: open interval (i, j)
for (int len = 2; len <= n + 1; len++) { // short intervals first
for (int i = 0; i <= n + 1 - len; i++) {
int j = i + len;
for (int k = i + 1; k < j; k++) { // k = the LAST balloon burst in (i, j)
int total = balloons[i] * balloons[k] * balloons[j] + dp[i][k] + dp[k][j];
dp[i][j] = Math.max(dp[i][j], total);
}
}
}
return dp[0][n + 1];
}
Complexity
- Time O(n³):
length×leftis O(n²) intervals, and thefor ksplit loop is O(n) inside each. - Space O(n²): the
(n+2) × (n+2)table. Interval DP does not roll — a split point reaches back across every shorter length.
Dry run
nums = [3, 1, 5] -> balloons = [1, 3, 1, 5, 1] (indexes 0..4)
length 2: every (i, i+2) has one balloon inside
dp[0][2] = 1*3*1 = 3 dp[1][3] = 3*1*5 = 15 dp[2][4] = 1*5*1 = 5
length 3:
dp[0][3] = max(k=1: 1*3*5 + 0 + dp[1][3]=15 -> 30, k=2: 1*1*5 + dp[0][2]=3 + 0 -> 8) = 30
dp[1][4] = max(k=2: 3*1*1 + 0 + dp[2][4]=5 -> 8, k=3: 3*5*1 + dp[1][3]=15 + 0 -> 30) = 30
length 4:
dp[0][4] = max(k=1: 3 + 0 + 30 = 33, k=2: 1 + 3 + 5 = 9, k=3: 5 + 30 + 0 = 35) = 35
Traps
- Choosing the first balloon instead of the last. Bursting
kfirst leaves(i, k)and(k, j)as neighbours of each other, so the subproblems are not independent and no recurrence falls out. - Looping
leftbeforelength.for left … for right …readsdp[k][right]for ak > leftthat has not been filled yet; only an outer length loop guarantees “strictly shorter is done”. - Forgetting the padding. Without
[1] + nums + [1], the first and last balloons have no neighbour to multiply by:nums = [5]must return 5.
Not this if — the transition only moves the two ends (dp[i+1][j-1], dp[i+1][j],
dp[i][j-1]) with no split point → O(n²) Shape A, Order A
(Template 2a). Two players taking from the
ends → minimax DP in dp_advanced.md.
Drill — LC 312 · LC 1039 (polygon triangulation, same split loop) · LC 1547 (cut a stick: sort the cuts, pad the ends) · LC 664 (strange printer) · LC 1000 (merge k stones) · LC 546 (remove boxes, a third state)
The inclusive-boundary variant, the top-down memoised form, the worked
nums = [3,1,5,8]trace, the recognition checklist, the common mistakes, the O(n³)/O(n²) derivation, the abstract split-point / matrix-chain skeletons and the backward-i + forward-j loop-order rule are in dp_advanced.md.
Template 4: 0/1 Knapsack — LC 416 Priority 5 of 5 — Must know — expect it in almost every loop
The knapsack family — LC 416 itself, the subset-sum reduction, unbounded, bounded and group knapsack, and the loop-order rule — is worked in knapsack.md; a Traditional Chinese walkthrough of the 0/1 case is knapsack_01_zh.md. What follows is the shape worth memorising.
Recognise — each element is used at most once, and you are choosing a subset against a budget or a target: “partition into two subsets with equal sum”, “assign + or − to reach target”, “minimise the difference between two groups”, “choose items within a capacity”. The target is small (sum ≤ 10⁴–2·10⁴) → an O(n · target) table is expected.
Key point — with one 1-D array, iterate the capacity backward: when dp[w] is updated for
an item, dp[w - weight] has not been touched yet in this pass, so it still means “without this
item”, and each item is counted at most once.
- State:
dp[w]= the best value of a subset of the items seen so far with total weight ≤w - Recurrence:
dp[w] = max(dp[w], dp[w - weight] + value)— skip the item, or take it - Base:
dp[*] = 0— the empty subset - Order: items outer; capacity inner, backward from
capacitytoweight - Answer:
dp[capacity]
# python
# IDEA: 0/1 knapsack, max value — dp[w] = best value within capacity w; the backward loop uses each item once
# time = O(n * W), space = O(W)
def knapsack_01(weights, values, capacity):
dp = [0] * (capacity + 1) # dp[w] = best value with total weight <= w
for i in range(len(weights)):
for w in range(capacity, weights[i] - 1, -1): # backward: dp[w - weight] is still "without i"
dp[w] = max(dp[w], dp[w - weights[i]] + values[i])
return dp[capacity]
// java
// IDEA: 0/1 knapsack, max value — dp[w] = best value within capacity w; the backward loop uses each item once
// time = O(n * W), space = O(W)
public int knapsack01(int[] weights, int[] values, int capacity) {
int[] dp = new int[capacity + 1]; // dp[w] = best value with total weight <= w
for (int i = 0; i < weights.length; i++) {
for (int w = capacity; w >= weights[i]; w--) { // backward: dp[w - weight] is still "without i"
dp[w] = Math.max(dp[w], dp[w - weights[i]] + values[i]);
}
}
return dp[capacity];
}
The same loop answers three questions; only the transition changes — or for “can some subset
reach w” (LC 416), += for “how many subsets” (LC 494), max(…, + value) for the best value.
Complexity
- Time O(n · W): the outer loop runs n times and the backward inner loop at most
Wtimes. - Space O(W): one row. The 2-D form
dp[i][w]is O(n · W); the backward loop is what lets rowioverwrite rowi-1in place.
Dry run — why the inner loop must go backward, with one item of weight 3 and value 5,
capacity = 6 (the answer is 5: the item can be taken once):
dp = [0, 0, 0, 0, 0, 0, 0]
0 1 2 3 4 5 6
Forward (WRONG): w=3: dp[3] = dp[0] + 5 = 5
w=6: dp[6] = dp[3] + 5 = 10 <- dp[3] already holds this item: taken twice ❌
Backward (RIGHT): w=6: dp[6] = dp[3] + 5 = 5 <- dp[3] is still 0, "without the item"
w=3: dp[3] = dp[0] + 5 = 5
result dp[6] = 5 ✓
Traps
- Forward inner loop. It turns 0/1 into unbounded knapsack: the single item above is taken twice.
- Inner loop running below the weight.
range(capacity, -1, -1)readsdp[w - weight]at a negative index, which in Python is the end of the array, not an error. - The wrong transition for the question. Using
oron a count table loses the number of ways. On a boolean table,maxequalsor, butormakes the intent clearer.
Not this if — an item may be reused (coins, unlimited supply) → unbounded knapsack, inner loop forward, and the loop nesting depends on whether order matters → knapsack.md. Values are huge but n ≤ 20 → enumerate subsets / meet in the middle instead of a sum table.
Drill — LC 416 ⭐ · LC 494 (count subsets: (total + target) / 2) · LC 1049 (largest
reachable sum ≤ total / 2) · LC 474 (two capacities, both backward) · LC 879 (count, 2-D
capacity) — all in knapsack.md.
Template 5: State Machine DP — LC 309
The whole LC 121/122/123/188/309/714 family — every variant, every state count — is worked in stock_trading.md. What follows is the shape worth memorising.
Recognise — at each step you are in one of a few named situations, and a rule says which moves are legal from each: “after you sell, you cannot buy the next day”, “at most k transactions”, “pay a fee per sale”. n ≤ 5000–10⁵ → O(n · states).
Key point — keep one variable per state, holding the best profit ending today in that state. Every transition reads only yesterday’s values, so save them before overwriting.
- State:
hold(own a share),sold(sold today, so tomorrow is cooldown),rest(no share, free to buy) - Recurrence:
hold = max(hold, rest - p),sold = hold + p,rest = max(rest, sold)— all from yesterday’s values - Base: day 0 —
hold = -prices[0],sold = 0,rest = 0 - Order: days ascending
- Answer:
max(sold, rest)— never end holding a share
# python
# LC 309 - Best Time to Buy and Sell Stock with Cooldown
# IDEA: three states per day; each reads yesterday's values, so snapshot before updating
# time = O(n), space = O(1)
def maxProfit(prices):
if not prices:
return 0
hold = -prices[0] # bought on day 0
sold = 0 # cannot have sold on day 0; 0 is the neutral value
rest = 0 # no share, not in cooldown
for i in range(1, len(prices)):
prev_hold, prev_sold, prev_rest = hold, sold, rest
hold = max(prev_hold, prev_rest - prices[i]) # keep holding, or buy (only from rest)
sold = prev_hold + prices[i] # sell what you held
rest = max(prev_rest, prev_sold) # stay idle, or finish the cooldown
return max(sold, rest)
// java
// LC 309 - Best Time to Buy and Sell Stock with Cooldown
// IDEA: three states per day; each reads yesterday's values, so snapshot before updating
// time = O(n), space = O(1)
public int maxProfit(int[] prices) {
if (prices.length == 0) {
return 0;
}
int hold = -prices[0];
int sold = 0;
int rest = 0;
for (int i = 1; i < prices.length; i++) {
int prevHold = hold, prevSold = sold, prevRest = rest;
hold = Math.max(prevHold, prevRest - prices[i]); // keep holding, or buy (only from rest)
sold = prevHold + prices[i]; // sell what you held
rest = Math.max(prevRest, prevSold); // stay idle, or finish the cooldown
}
return Math.max(sold, rest);
}
Complexity
- Time O(n): one pass over
prices; three O(1) updates per day. - Space O(1): three state variables (and their snapshots). With k transactions it is O(k).
Dry run
prices = [1, 2, 3, 0, 2]
day 0 p=1 hold=-1 sold=0 rest=0
day 1 p=2 hold=-1 sold=1 rest=0
day 2 p=3 hold=-1 sold=2 rest=1
day 3 p=0 hold= 1 sold=-1 rest=2 buy at 0, from rest (the day-1 sale has cooled down)
day 4 p=2 hold= 1 sold=3 rest=2 answer max(3, 2) = 3
Traps
- Updating in place without snapshots.
hold = max(hold, rest - p)followed bysold = hold + psells the share bought today: on[1, 2, 3, 0, 2]the answer becomes wrong. - Buying from
sold.hold = max(hold, sold - p)skips the cooldown day; buy only fromrest. - LC 121 (one transaction) is not this code with
rest = 0: there the buy must start from profit 0 every time,hold = max(hold, -p), or a second transaction sneaks in.
Not this if — no cooldown, unlimited transactions, no fee (LC 122) → greedy: sum every positive
day-to-day difference. “At most k transactions” → 2k states, in
stock_trading.md.
Drill — LC 309 ⭐ · LC 121 ⭐ (two states, buy from 0) · LC 122 (two states) · LC 714 (fee on sell) · LC 123 (four states) · LC 188 (2k states)
Template 6: Memoization → Tabulation → Rolling Variables — LC 198 Priority 5 of 5 — Must know — expect it in almost every loop
The space-optimisation ladder: write the recursion, cache it, flip it to a table, then collapse the table to
kvariables. recursion_to_dp.md walks the first two rungs in detail.
Recognise — a 1-D recurrence that reads a fixed window of earlier answers (dp[i-1],
dp[i-2], …): “rob houses, no two adjacent”, “climb 1 or 2 steps”, “decode a digit string”. The
follow-up “can you do it in O(1) space?” is the cue for the last rung.
Key point — if dp[i] reads only the last k cells, only k variables are alive. Keep the
invariant p1 = dp[i-2], p2 = dp[i-1] true at the top of every iteration, and the answer is the
newest rolling variable — which is valid even when the loop runs zero times.
- State:
dp[i]= most money robbed from houses0..i - Recurrence:
dp[i] = max(dp[i-1], dp[i-2] + nums[i])— skip housei, or rob it - Base:
dp[0] = nums[0],dp[1] = max(nums[0], nums[1]) - Order:
iascending; two variables replace the table - Answer:
p2(=dp[n-1]), never the loop temporary
# python
# LC 198 - House Robber
# IDEA: dp[i] = max(dp[i-1], dp[i-2] + nums[i]) -> only 2 vars needed
# time = O(n), space = O(1)
def rob(nums):
if not nums:
return 0
n = len(nums)
if n == 1:
return nums[0] # p2's seed reads nums[1], so n == 1 must leave early
# invariant at the top of iteration i: p1 = dp[i-2], p2 = dp[i-1]
p1 = nums[0] # dp[0]
p2 = max(nums[0], nums[1]) # dp[1]
for i in range(2, n):
cur = max(p1 + nums[i], p2) # dp[i]
p1 = p2 # oldest -> newest
p2 = cur
return p2 # NOT `cur`
// java
// LC 198 - House Robber
// IDEA: same 2-variable rolling window; return the rolling var, not the temp
// time = O(n), space = O(1)
public int rob(int[] nums) {
if (nums == null || nums.length == 0) return 0;
int n = nums.length;
if (n == 1) return nums[0];
int p1 = nums[0]; // dp[0]
int p2 = Math.max(nums[0], nums[1]); // dp[1]
for (int i = 2; i < n; i++) {
int cur = Math.max(p1 + nums[i], p2);
p1 = p2;
p2 = cur;
}
return p2; // hoisting `cur` out of the loop just to return it would need `int cur = 0;`
// -> returns 0 for n == 2 instead of the real answer
}
Complexity
- Time O(n): one
for i in range(2, n)pass, O(1) per house. Every rung of the ladder is O(n); the memoised recursion visits eachionce. - Space O(1):
p1,p2,cur. The table rung is O(n), and the memoised rung adds an O(n) recursion stack on top of its O(n) cache.
Dry run — the input that makes return cur wrong:
n = 2, nums = [2, 7]
p1 = 2 <- dp[0]
p2 = max(2, 7) = 7 <- dp[1]
for i in range(2, 2): <- zero iterations, `cur` never assigned
return p2 -> 7 ✅
return cur -> UnboundLocalError ❌
Traps
return cur. Forn == 2the loop body never runs, socuris unbound (UnboundLocalErrorin Python; in Java a hoistedint cur = 0silently returns 0).p2is seeded before the loop and equalscurwhenever both exist, so it is correct in strictly more cases.- Updating newest → oldest.
p2 = curthenp1 = p2sets both tocur:[2, 1, 1, 2]returns the wrong total. Update oldest → newest, or use one tuple assignment. - Seeds that satisfy the statement but not the recurrence.
p2 = nums[1]instead ofmax(nums[0], nums[1])breaks[2, 1, 1, 2](answer 4). - Deep recursion on the memoised rung. Python’s default limit is 1000 frames, so the cached
recursion raises
RecursionErroron a 10⁴-element input that the table rung handles.
Not this if — dp[i] reads every earlier cell (for j < i) → nothing to roll; keep the
table (Template 7, Template 1b). A 2-D table rolls to one row only when row i reads just row i-1
(Shape B in Template 2a).
Drill — LC 198 ⭐ · LC 70 ⭐ · LC 746 · LC 213 ⭐ (circle: run it twice, skipping each end) · LC 1137 (three variables) · LC 740 (reduce to House Robber over values)
Step 1 — top-down memoization
# python
# LC 198 - House Robber, top-down
# IDEA: dp(i) = best over houses 0..i; cache each i so the two-way recursion is linear
# time = O(n), space = O(n)
def top_down_dp(nums):
"""Top-down DP with memoization"""
memo = {}
def dp(i):
# Base case
if i < 0:
return 0
if i == 0:
return nums[0]
# Check memo
if i in memo:
return memo[i]
# Recurrence relation
result = max(dp(i-1), dp(i-2) + nums[i])
memo[i] = result
return result
return dp(len(nums) - 1)
Step 2 — collapse the table into k rolling variables
# python
# IDEA: a recurrence that reads two cells back needs two variables, prev2 and prev1
# time = O(n), space = O(1)
def fibonacci_variants():
"""Common Fibonacci-like patterns"""
# 1. Classic Fibonacci
def fibonacci(n):
if n <= 1:
return n
prev2, prev1 = 0, 1
for _ in range(2, n + 1):
current = prev1 + prev2
prev2, prev1 = prev1, current
return prev1
# 2. Climbing Stairs
def climbStairs(n):
if n <= 2:
return n
prev2, prev1 = 1, 2
for _ in range(3, n + 1):
current = prev1 + prev2
prev2, prev1 = prev1, current
return prev1
Rolling-variable checklist
| Step | Question to ask |
|---|---|
| 1. Depth | How many steps back does the recurrence read? That’s how many variables. |
| 2. Invariant | Write p1 = dp[i-k] … pk = dp[i-1] as a comment above the loop. |
| 3. Seed | Do the seeds satisfy the recurrence (not just the problem statement)? |
| 4. Start index | Loop from the first i whose whole window is seeded (i = 2 for LC 198, i = k for a k-cell window). |
| 5. Update order | Oldest → newest, or one tuple assignment. Never newest → oldest. |
| 6. Return | Return the newest rolling variable (p3 / p2), never the loop-body temp — the temp is unbound when the loop runs zero times. Make sure the n < start cases returned early. |
The candy-bar / Tribonacci walkthrough, the visual window trace, the update-order bug, the seeding rule, the table of other problems with this update shape and the
k-steps-back (deque / circular buffer) generalisation are in dp_advanced.md.
Template 7: Longest Increasing Subsequence — LC 300 Priority 4 of 5 — High value — a gap here costs you rounds
dp[i]= length of the LIS ending at indexi, answer =max(dp). The O(n log n) follow-up keeps atailsarray and binary-searches the insert position instead.
Recognise — “longest subsequence (not contiguous) such that each element is bigger /
divisible / nests inside the previous one”. n ≤ 2500 → the O(n²) table; n ≤ 10⁵ → the O(n log n)
tails follow-up.
Key point — dp[i] is the longest chain that ends at nums[i], so any earlier j with
nums[j] < nums[i] can be extended by one. Unlike Template 1 the predecessor can be any earlier
index, which is the inner for j < i loop.
- State:
dp[i]= length of the longest increasing subsequence ending exactly ati - Recurrence:
dp[i] = max(dp[i], dp[j] + 1)for everyj < iwithnums[j] < nums[i] - Base:
dp[i] = 1— every element alone is a subsequence - Order:
iascending,jover0..i-1 - Answer:
max(dp)— the chain can end anywhere
# python
# LC 300 - Longest Increasing Subsequence
# IDEA: dp[i] = LIS ending at i; extend every earlier j whose value is strictly smaller
# time = O(n^2), space = O(n)
def lengthOfLIS(nums):
if not nums:
return 0
n = len(nums)
dp = [1] * n # each element alone has length 1
for i in range(1, n):
for j in range(i):
if nums[j] < nums[i]: # strictly smaller: only then can nums[i] extend it
dp[i] = max(dp[i], dp[j] + 1)
return max(dp)
// java
// LC 300 - Longest Increasing Subsequence
// IDEA: dp[i] = LIS ending at i; extend every earlier j whose value is strictly smaller
// time = O(n^2), space = O(n)
public int lengthOfLIS(int[] nums) {
if (nums == null || nums.length == 0) {
return 0;
}
int n = nums.length;
int[] dp = new int[n];
Arrays.fill(dp, 1); // each element alone has length 1
int best = 1;
for (int i = 1; i < n; i++) {
for (int j = 0; j < i; j++) {
if (nums[j] < nums[i]) { // strictly smaller: only then can nums[i] extend it
dp[i] = Math.max(dp[i], dp[j] + 1);
}
}
best = Math.max(best, dp[i]); // the chain can end at any index
}
return best;
}
Complexity
- Time O(n²): the inner
for j in range(i)runsitimes for eachi, so ~n²/2 comparisons. - Space O(n): the
dparray.
Dry run
nums = [10, 9, 2, 5, 3, 7, 101, 18]
dp = [1, 1, 1, 2, 2, 3, 4, 4]
i=3: 2 < 5 -> dp[2]+1 = 2
i=5: 2, 5, 3 < 7 -> max(dp[2], dp[3], dp[4]) + 1 = 3
i=6, i=7: extend dp[5] -> 4
answer = max(dp) = 4 ([2, 3, 7, 101] or [2, 5, 7, 18])
Traps
- Returning
dp[n-1]. The LIS need not end at the last element:[3, 4, 1]hasdp[n-1] = 1, but the answer is 2. <=instead of<. LC 300 wants strictly increasing:[7, 7, 7]must return 1.- Updating the global max only inside the
if. Withn == 1(or a strictly decreasing input) theifnever fires; startbestat 1, or takemax(dp)at the end.
Not this if — the predecessor is one known value (x - d) rather than “any smaller” → the
inner loop becomes a hash lookup (Template 7a).
n = 10⁵ → patience sorting / tails + binary search, O(n log n), in
patience_sorting.md.
Drill — LC 300 ⭐ · LC 673 (carry a count beside the length) · LC 368 (divisible chain: sort first, rebuild the path) · LC 1048 (string chain, sort by length) · LC 354 (sort by width, LIS on height)
Template 7a: Value-Keyed DP (DP + Hash Map) — LC 1218 Priority 4 of 5 — High value — a gap here costs you rounds
Pattern: an LIS-shaped “longest chain ending here” DP, except the element that may come before
xis not “any smaller one” — it is exactly one value,x - d. So the innerfor j < iloop of Template 7 collapses into a single hash-map lookup, and the DP is keyed by value instead of by index:O(n^2)→O(n).
Recognise — “longest subsequence where adjacent elements differ by a fixed difference”,
“a chain where each step is the previous one plus / minus one known thing”. Values span
-10⁴..10⁴ (or larger) and n ≤ 10⁵ → the O(n²) LIS loop is too slow and an array indexed by value
is sparse or negative.
Key point — the predecessor of x is fully determined (x - difference), and scanning left to
right means dp only holds values from earlier indices, so one lookup answers “how long is the
chain I can extend?” and the result is still a subsequence.
- State:
dp[x]= length of the longest valid subsequence ending with valuex, seen so far - Recurrence:
dp[x] = dp.get(x - difference, 0) + 1 - Base: an empty map — a value with no predecessor starts a chain of length 1
- Order: left to right over
arr; a repeated value overwrites its entry - Answer: the largest
dp[x]written
# python
# LC 1218 - Longest Arithmetic Subsequence of Given Difference
# IDEA: dp keyed by value — the only legal predecessor of x is x - difference
# time = O(n), space = O(n)
def longestSubsequence(arr, difference):
dp = {} # value -> longest chain ending with that value
max_len = 0
for x in arr:
prev = x - difference
# read the predecessor BEFORE writing dp[x] (matters when difference == 0)
dp[x] = dp.get(prev, 0) + 1
max_len = max(max_len, dp[x])
return max_len
// java
// LC 1218 - Longest Arithmetic Subsequence of Given Difference
// IDEA: dp keyed by value — the only legal predecessor of x is x - difference
// time = O(n), space = O(n)
public int longestSubsequence(int[] arr, int difference) {
Map<Integer, Integer> dp = new HashMap<>(); // value -> longest chain ending with it
int maxLen = 0;
for (int x : arr) {
int len = dp.getOrDefault(x - difference, 0) + 1;
dp.put(x, len);
maxLen = Math.max(maxLen, len);
}
return maxLen;
}
Complexity
- Time O(n): one
for x in arrpass with one O(1) average map lookup and write each. - Space O(n): the map holds at most one entry per distinct value.
Dry run
arr = [1,5,7,8,5,3,4,2,1], difference = -2 (predecessor of x is x + 2)
x=1 dp[3]? no -> dp[1]=1
x=5 dp[7]? no -> dp[5]=1
x=7 dp[9]? no -> dp[7]=1
x=8 dp[10]? no -> dp[8]=1
x=5 dp[7]=1 -> dp[5]=2 (overwrites the first 5's entry)
x=3 dp[5]=2 -> dp[3]=3
x=4 dp[6]? no -> dp[4]=1
x=2 dp[4]=1 -> dp[2]=2
x=1 dp[3]=3 -> dp[1]=4 7 -> 5 -> 3 -> 1
answer = 4
Traps
- Reaching for the
O(n^2)LIS loop. It is correct and times out atn = 10^5; the fixed difference is exactly what removes the inner loop. - Indexing an array by value.
arr[i]anddifferenceare both negative-capable; a shifted array works only if you size it forx - differencetoo. The map has no such edge. - Writing
dp[x]before readingdp[x - difference]. Withdifference = 0the two keys are the same; read first, or every repeat of a value is counted twice.
Not this if — the difference is not given and is part of the answer → the key becomes
(index, difference) and the cost O(n²) (LC 1027, LC 446; table below).
Drill — LC 1218 · LC 1048 (key = the word, predecessor = the word minus one letter) · LC 1027 (one map per index) · LC 873 (Fibonacci-like: key = the last two values) · LC 446 (count instead of length)
The value-keyed family
| LC | Problem | Key | Cost |
|---|---|---|---|
| 1218 | Longest Arithmetic Subsequence of Given Difference | the value; d is fixed |
one map, O(n) |
| 1027 | Longest Arithmetic Subsequence | (index, difference) — d is free, so one map per index |
O(n^2) |
| 446 | Arithmetic Slices II - Subsequence | (index, difference), storing a count |
O(n^2) |
| 1048 | Longest String Chain | the word; predecessors are the word minus one letter (sort by length first) | O(n · L^2) |
LC 1027 and LC 446 — where the difference is part of the state rather than given — are worked in dp_advanced.md.
Template 8: Edit Distance — LC 72 Priority 4 of 5 — High value — a gap here costs you rounds
Pattern: the minimum number of insert / delete / replace operations that turn word1 into
word2 (Levenshtein distance). The recognition checklist, the three-operation intuition, the
top-down and space-optimised variants and the visual table are in
dp_advanced.md; the whole two-sequence family is in dp_string.md.
The table is Shape B — see Template 2a for its shape, why
row-major is the only legal order, and the backward walk that recovers the operation list.
Recognise — two strings, and the question is the cheapest way to turn one into the other with insert / delete / replace, or “minimum deletions to make them equal”. m, n ≤ 500 → an O(m·n) grid.
Key point — dp[i][j] answers the question for the first i characters of word1 and the
first j of word2, so the last characters decide everything: equal → free diagonal; else one
operation plus the cheapest of the three neighbours it leads to.
- State:
dp[i][j]= minimum operations to convertword1[:i]intoword2[:j] - Recurrence:
dp[i-1][j-1]ifword1[i-1] == word2[j-1], else1 + min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1])(delete, insert, replace) - Base:
dp[i][0] = i(delete everything),dp[0][j] = j(insert everything) - Order: row-major,
i: 1 → m,j: 1 → n - Answer:
dp[m][n]
# python
# LC 72 - Edit Distance
# IDEA: Shape B grid over prefix lengths; equal last chars are free, else 1 + min of three moves
# time = O(m * n), space = O(m * n)
def minDistance(word1, word2):
m, n = len(word1), len(word2)
dp = [[0] * (n + 1) for _ in range(m + 1)]
for i in range(m + 1):
dp[i][0] = i # word2 empty: delete all i characters
for j in range(n + 1):
dp[0][j] = j # word1 empty: insert all j characters
for i in range(1, m + 1):
for j in range(1, n + 1):
if word1[i - 1] == word2[j - 1]:
dp[i][j] = dp[i - 1][j - 1] # no operation
else:
dp[i][j] = 1 + min(
dp[i - 1][j], # delete word1[i-1]
dp[i][j - 1], # insert word2[j-1]
dp[i - 1][j - 1], # replace
)
return dp[m][n]
# Same recurrence, rolling row: row i reads only row i-1 (prev) and the cell to its left
# time = O(m * n), space = O(n)
def minDistance_rolling(word1, word2):
m, n = len(word1), len(word2)
dp = list(range(n + 1)) # row 0: j inserts
for i in range(1, m + 1):
prev = dp[:] # row i-1
dp[0] = i # column 0: i deletes
for j in range(1, n + 1):
if word1[i - 1] == word2[j - 1]:
dp[j] = prev[j - 1]
else:
dp[j] = 1 + min(prev[j], dp[j - 1], prev[j - 1])
return dp[n]
// java
// LC 72 - Edit Distance
// IDEA: Shape B grid over prefix lengths; equal last chars are free, else 1 + min of three moves
// time = O(m * n), space = O(m * n)
public int minDistance(String word1, String word2) {
int m = word1.length();
int n = word2.length();
int[][] dp = new int[m + 1][n + 1];
for (int i = 0; i <= m; i++) {
dp[i][0] = i; // delete all characters
}
for (int j = 0; j <= n; j++) {
dp[0][j] = j; // insert all characters
}
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
if (word1.charAt(i - 1) == word2.charAt(j - 1)) {
dp[i][j] = dp[i - 1][j - 1];
} else {
int delete = dp[i - 1][j];
int insert = dp[i][j - 1];
int replace = dp[i - 1][j - 1];
dp[i][j] = 1 + Math.min(delete, Math.min(insert, replace));
}
}
}
return dp[m][n];
}
Complexity
- Time O(m·n): the nested
for i/for jfills each of the(m+1)(n+1)cells once, O(1) each. - Space O(m·n): the table; row
ireads only rowi-1, so a rolling row makes it O(min(m, n)) (see Space compression in Template 2a).
Dry run — "horse" → "ros" is worked cell by cell, with the path it chose, in
Template 2a’s Shape B (Worked table — LC 72); the answer
is dp[5][3] = 3.
Traps
- Base row of zeros. Copying LCS’s
dp[0][j] = 0makes"" → "abc"cost 0; edit distance’s base row isj. word1[i]instead ofword1[i-1]. The table is length-indexed, so the character decided at rowiisword1[i-1];word1[i]reads past the end on the last row.- Charging the match.
1 + dp[i-1][j-1]on equal characters makes"a" → "a"cost 1.
Not this if — only deletions are allowed (LC 583) or the goal is the longest shared part → LCS
(Template 9); edit = m + n − 2·LCS for
delete-only. One sequence and its two ends → Shape A.
Drill — LC 72 ⭐ · LC 583 (delete only) · LC 712 (delete with ASCII cost) · LC 161 (one edit away, O(n) without a table) · LC 115 (count, not cost)
Comparison: Edit Distance vs LCS
LC 72 against LC 1143, side by side:
| Aspect | LC 72 (Edit Distance) | LC 1143 (LCS) |
|---|---|---|
| Goal | Minimize operations needed | Maximize matching characters |
| Operations | Insert, Delete, Replace | Only match or skip |
| Match | No cost (no operation) | +1 to length |
| Mismatch | 1 + min(3 options) | max(skip left, skip right) |
| DP Transition | dp[i][j] = 1 + min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]) |
dp[i][j] = dp[i-1][j-1] + 1 or max(dp[i-1][j], dp[i][j-1]) |
Template 9: Longest Common Subsequence — LC 1143
Recognise — two sequences and “the longest part they share in order, not necessarily contiguous”: “longest common subsequence”, “minimum deletions to make two strings equal”, “max uncrossed lines”. m, n ≤ 1000 → an O(m·n) grid.
Key point — compare the last characters of the two prefixes: equal → they can both end the common subsequence, so take the diagonal plus one; different → at least one of them is not in it, so drop either and keep the better.
- State:
dp[i][j]= LCS length oftext1[:i]andtext2[:j] - Recurrence:
dp[i-1][j-1] + 1iftext1[i-1] == text2[j-1], elsemax(dp[i-1][j], dp[i][j-1]) - Base: row 0 and column 0 are
0— an empty prefix shares nothing - Order: row-major,
i: 1 → m,j: 1 → n - Answer:
dp[m][n]
# python
# LC 1143 - Longest Common Subsequence
# IDEA: Shape B grid; matching last chars extend the diagonal, otherwise drop one side
# time = O(m * n), space = O(m * n)
def longestCommonSubsequence(text1, text2):
m, n = len(text1), len(text2)
dp = [[0] * (n + 1) for _ in range(m + 1)] # row 0 / column 0 = empty prefix = 0
for i in range(1, m + 1):
for j in range(1, n + 1):
if text1[i - 1] == text2[j - 1]:
dp[i][j] = dp[i - 1][j - 1] + 1
else:
dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])
return dp[m][n]
# Same recurrence, rolling row: keep only the previous row
# time = O(m * n), space = O(n)
def longestCommonSubsequence_rolling(text1, text2):
n = len(text2)
dp = [0] * (n + 1)
for c in text1:
prev, dp = dp, [0] * (n + 1) # prev = row i-1, dp = row i
for j in range(n):
if c == text2[j]:
dp[j + 1] = prev[j] + 1
else:
dp[j + 1] = max(prev[j + 1], dp[j])
return dp[n]
// java
// LC 1143 - Longest Common Subsequence
// IDEA: Shape B grid; matching last chars extend the diagonal, otherwise drop one side
// time = O(m * n), space = O(m * n)
public int longestCommonSubsequence(String text1, String text2) {
int m = text1.length();
int n = text2.length();
int[][] dp = new int[m + 1][n + 1]; // row 0 / column 0 = empty prefix = 0
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
if (text1.charAt(i - 1) == text2.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];
}
Complexity
- Time O(m·n): the nested
for i/for jvisits every cell once. - Space O(m·n): the table; one rolling row (plus a saved diagonal) makes it O(min(m, n)).
Dry run
text1 = "abcde", text2 = "ace"
"" a c e
"" 0 0 0 0
a 0 1 1 1
b 0 1 1 1
c 0 1 2 2
d 0 1 2 2
e 0 1 2 3 answer dp[5][3] = 3 ("ace")
Traps
- Taking the diagonal on a mismatch.
max(dp[i-1][j], dp[i][j-1], dp[i-1][j-1])is correct but redundant;dp[i-1][j-1] + 1on a mismatch is wrong ("ab","ba"would return 2). - Contiguous vs not. “Longest common substring” (LC 718) resets to 0 on a mismatch and takes
the max over all cells; LCS carries the
maxforward. - Rolling one row without saving the diagonal.
dp[i-1][j-1]is overwritten by the time the row-major loop reachesj; keep it in aprev_diagvariable.
Not this if — operations have costs and a replace exists → edit distance (Template 8). The two sequences are one string and its reverse → LC 516 is cleaner as Shape A (Template 2a).
Drill — LC 1143 ⭐ · LC 583 (m + n − 2·LCS) · LC 1035 (uncrossed lines = LCS) · LC 718 (substring: reset on mismatch) · LC 1092 (shortest common supersequence: walk the table back)
The two-sequence grid → dp_string.md
Almost every two-string problem is the same grid: dp[i][j] = the answer for the first i
characters of s1 and the first j characters of s2. Only the transition changes.
| Move | Meaning |
|---|---|
Diagonal dp[i-1][j-1] |
consume a character from both strings (they matched) |
Vertical dp[i-1][j] |
skip / delete a character from s1 |
Horizontal dp[i][j-1] |
skip / insert a character from s2 |
| Problem | LC | On match | On mismatch |
|---|---|---|---|
| Longest Common Subsequence | 1143 | 1 + dp[i-1][j-1] |
max(dp[i-1][j], dp[i][j-1]) |
| Edit Distance | 72 | dp[i-1][j-1] |
1 + min(replace, delete, insert) |
| Distinct Subsequences | 115 | dp[i-1][j-1] + dp[i-1][j] |
dp[i-1][j] |
| Interleaving String | 97 | dp[i-1][j] && s1[i-1]==s3[i+j-1] (or the s2 side) |
False |
The templates for LC 72 and LC 1143 stay above (Template 8 — Edit Distance, Template 9 — LCS).
Template 10: Palindrome Substring DP — LC 5 Priority 5 of 5 — Must know — expect it in almost every loop
Problem archetype: LC 647 (Count Palindromic Substrings), LC 5 (Longest Palindromic Substring)
The table is Shape A — the backward-
i+ forward-jorder below is Order A in Template 2a, and thej - i <= 2guard exists becausedp[i+1][j-1]falls into the dead triangle.
Recognise — “longest / count of palindromic substrings” (contiguous), or a later DP that
needs “is s[i..j] a palindrome?” answered in O(1) (LC 131, LC 132). n ≤ 1000 → O(n²).
Key point — s[i..j] is a palindrome exactly when its ends match and its inside
s[i+1..j-1] is one; a length ≤ 3 range has no inside worth checking. So the table fills from short
ranges outward, with i descending.
- State:
dp[i][j] = Trueifs[i..j]is a palindrome - Recurrence:
dp[i][j] = s[i] == s[j] and (j - i <= 2 or dp[i+1][j-1]) - Base: covered by the
j - i <= 2guard (lengths 1, 2, 3) - Order:
ifromn-1down to0,jfromiup ton-1 - Answer: LC 5 — the
Truecell with the largestj - i; LC 647 — the number ofTruecells
# python
# LC 5 - Longest Palindromic Substring
# IDEA: dp[i][j] = s[i..j] is a palindrome; i descends so dp[i+1][j-1] is already filled
# time = O(n^2), space = O(n^2)
def longestPalindrome(s):
n = len(s)
dp = [[False] * n for _ in range(n)]
best_start, best_len = 0, 1 if n else 0
for i in range(n - 1, -1, -1): # i backward: row i+1 is ready
for j in range(i, n): # j forward: column j-1 is ready
if s[i] == s[j] and (j - i <= 2 or dp[i + 1][j - 1]):
dp[i][j] = True
if j - i + 1 > best_len:
best_start, best_len = i, j - i + 1
return s[best_start:best_start + best_len]
// java
// LC 5 - Longest Palindromic Substring
// IDEA: dp[i][j] = s[i..j] is a palindrome; i descends so dp[i+1][j-1] is already filled
// time = O(n^2), space = O(n^2)
public String longestPalindrome(String s) {
int n = s.length();
boolean[][] dp = new boolean[n][n];
int bestStart = 0, bestLen = n == 0 ? 0 : 1;
for (int i = n - 1; i >= 0; i--) { // i backward: row i+1 is ready
for (int j = i; j < n; j++) { // j forward: column j-1 is ready
if (s.charAt(i) == s.charAt(j) && (j - i <= 2 || dp[i + 1][j - 1])) {
dp[i][j] = true;
if (j - i + 1 > bestLen) {
bestStart = i;
bestLen = j - i + 1;
}
}
}
}
return s.substring(bestStart, bestStart + bestLen);
}
Complexity
- Time O(n²): the
for i/for jpair visits each of the ~n²/2 ranges once, O(1) each. - Space O(n²): the boolean table. Expand-around-center (Approach 2 below) is also O(n²) time but O(1) space, which is why it is the default when the table is not reused.
Dry run
s = "cbbd" (T = s[i..j] is a palindrome)
j=0 j=1 j=2 j=3
i=3 T "d"
i=2 T F "b", "bd"
i=1 T T F "b", "bb" (s[1] == s[2], j - i = 1 <= 2), "bbd"
i=0 T F F F "c", "cb", "cbb", "cbbd"
longest True range = dp[1][2] -> "bb"
Traps
- Checking
dp[i+1][j-1]without thej - i <= 2guard. Whenj - i == 1,dp[i+1][j-1]isdp[i+1][i], below the diagonal and stillFalse:"bb"would not be found. iascending.for i in range(n)reads rowi+1before it is filled;"abba"returns"bb", because its length-4 check reads the inner"bb"before that cell is set ("aba"survives only thanks to thej - i <= 2guard).- Expand-around-center with only odd centres. Always call
expand(i, i)andexpand(i, i + 1);"abba"has no odd centre.
Not this if — the palindrome may skip characters (subsequence) → LC 516, max instead of a boolean (Template 2a). Only the answer is needed, never the table → expand around center, O(1) space.
Drill — LC 5 ⭐ · LC 647 ⭐ (count the Trues) · LC 131 ⭐ (table as an O(1) gate for
backtracking) · LC 132 (table + prefix-partition min cuts) · LC 1745 (three palindromic parts)
The length-based table order, Manacher’s O(n) algorithm and the similar-problem map are in dp_advanced.md; palindrome.md covers the non-DP palindrome toolkit.
Approach Comparison
| Approach | Time | Space | When to Use |
|---|---|---|---|
| Brute Force | O(n³) | O(1) | Never in interviews |
| 2D DP (length-based) — dp_advanced.md | O(n²) | O(n²) | Need full dp table for other queries |
| 2D DP (backward-i) | O(n²) | O(n²) | Same as above, slightly cleaner |
| Two Pointers (expand center) ⭐ | O(n²) | O(1) | Default — simpler, space-optimal |
| Manacher’s Algorithm — dp_advanced.md | O(n) | O(n) | Competitive programming / optimal |
Approach 1: 2D DP — Backward-i + Forward-j Priority 1 of 5 — Nice to have
# IDEA: loop i backward so dp[i+1][...] is always ready when computing dp[i][...]
def countSubstrings_dp_backward(s):
# time = O(n^2), space = O(n^2)
n = len(s)
dp = [[False] * n for _ in range(n)]
count = 0
for i in range(n - 1, -1, -1): # i backward: ensures dp[i+1][j-1] is computed
for j in range(i, n): # j forward: ensures dp[i][j-1] is computed
if s[i] == s[j] and (j - i <= 2 or dp[i+1][j-1]):
dp[i][j] = True
count += 1
return count
Why backward-i?
dp[i][j] depends on dp[i+1][j-1] (the inner substring). To read row i+1 when filling row i, iterate i from n-1 down to 0.
Approach 2: Two Pointers — Expand Around Center (Recommended)Priority 2 of 5 — Niche — read once, revisit only if a company is known to ask
Key insight: every palindrome has a center. Expand outward from each possible center and count.
- Odd-length palindrome: center is one character — expand from
(i, i) - Even-length palindrome: center is between two characters — expand from
(i, i+1)
# IDEA: expand around center — O(1) space, no dp table needed
def countSubstrings_two_pointers(s):
# time = O(n^2), space = O(1)
count = 0
n = len(s)
def expand(l, r):
cnt = 0
while l >= 0 and r < n and s[l] == s[r]:
cnt += 1
l -= 1
r += 1
return cnt
for i in range(n):
count += expand(i, i) # odd-length (center at i)
count += expand(i, i + 1) # even-length (center between i and i+1)
return count
Visual trace for s = "aaa":
Center i=0:
odd (0,0): "a" → count +1
even (0,1): "aa" → count +1
Center i=1:
odd (1,1): "a"→(0,2)"aaa" → count +2 (expands to full string)
even (1,2): "aa" → count +1
Center i=2:
odd (2,2): "a" → count +1
even (2,3): out of bounds → count +0
Total = 6 ✓
Key Decision: DP vs Two Pointers
Need the full dp[i][j] table? (e.g., for partitioning / further DP)
YES → Use 2D DP (Approach 1)
NO → Use Two Pointers (Approach 2) — simpler + O(1) space
Template 11: Bitmask DP over Subsets — LC 1879
Recognise — the state is “which subset of items have I used”, and n is tiny: “assign each
of n workers to one of n jobs”, “visit every node once”, “pair up the elements”. n ≤ 14–20 →
2^n masks is the budget (2²⁰ ≈ 10⁶).
Key point — the mask alone says how far along you are: with items assigned in a fixed order,
popcount(mask) is the index of the next one to place, so dp[mask] needs no second dimension and
every mask is reached only from smaller masks.
- State:
dp[mask]= least XOR sum after pairingnums1[0..k-1]with thenums2elements inmask, wherek = popcount(mask) - Recurrence: for each
jnot inmask,dp[mask | 1<<j] = min(…, dp[mask] + (nums1[k] ^ nums2[j])) - Base:
dp[0] = 0 - Order:
maskascending — adding a bit always makes a bigger number - Answer:
dp[(1 << n) - 1]
# python
# LC 1879 - Minimum XOR Sum of Two Arrays
# IDEA: dp[mask] = best cost having paired nums1[:popcount(mask)] with the nums2 items in mask
# time = O(2^n * n), space = O(2^n)
def minimumXORSum(nums1, nums2):
n = len(nums1)
full = (1 << n) - 1
INF = float('inf')
dp = [INF] * (1 << n)
dp[0] = 0
for mask in range(1 << n):
if dp[mask] == INF:
continue
k = bin(mask).count('1') # next nums1 index to pair
if k == n:
continue
for j in range(n):
if not mask & (1 << j): # nums2[j] still free
nxt = mask | (1 << j)
dp[nxt] = min(dp[nxt], dp[mask] + (nums1[k] ^ nums2[j]))
return dp[full]
// java
// LC 1879 - Minimum XOR Sum of Two Arrays
// IDEA: dp[mask] = best cost having paired nums1[:popcount(mask)] with the nums2 items in mask
// time = O(2^n * n), space = O(2^n)
public int minimumXORSum(int[] nums1, int[] nums2) {
int n = nums1.length;
int[] dp = new int[1 << n];
Arrays.fill(dp, Integer.MAX_VALUE);
dp[0] = 0;
for (int mask = 0; mask < (1 << n); mask++) {
if (dp[mask] == Integer.MAX_VALUE) {
continue;
}
int k = Integer.bitCount(mask); // next nums1 index to pair
if (k == n) {
continue;
}
for (int j = 0; j < n; j++) {
if ((mask & (1 << j)) == 0) { // nums2[j] still free
int nxt = mask | (1 << j);
dp[nxt] = Math.min(dp[nxt], dp[mask] + (nums1[k] ^ nums2[j]));
}
}
}
return dp[(1 << n) - 1];
}
Complexity
- Time O(2ⁿ · n): the outer
for maskruns2ⁿtimes and the innerfor jn times. - Space O(2ⁿ): one cell per mask.
| Bit op | Meaning |
|---|---|
mask & (1 << i) |
is item i in the set? |
mask | (1 << i) |
add item i |
mask ^ (1 << i) |
remove item i (only when it is present) |
sub = (sub - 1) & mask |
enumerate submasks of mask |
bin(mask).count('1') |
popcount = how many items used |
Traps
- A second index dimension you do not need.
dp[i][mask]withi = popcount(mask)is the same table n times over; it multiplies memory by n and can exceed the limit at n = 20. 1 << noverflow in Java. Masks past 31 items needlongand1L << n; at n ≤ 20intis fine, but the habit matters.- Forgetting the unreachable check. Relaxing from a mask still at
INFadds to infinity in Python (harmless) but toInteger.MAX_VALUEin Java, which overflows negative and wins everymin.
Not this if — n is 40+ → 2^n is out; look for a greedy, a matching / flow, or meet in the
middle. The items are interchangeable and only their count matters → the mask collapses to an
integer (plain knapsack).
Drill — LC 1879 · LC 526 (count instead of min) · LC 1986 (sessions: carry the time used) · LC 847 (BFS over (node, mask)) · LC 1125 (the mask is the goal: skills covered)
dp_bitmask.md covers, in full: the complete bit-operation reference, TSP and
assignment templates, submask-enumeration DP, complexity sizing, and the classic pitfalls
(1L << n for n >= 31, wrong submask loop, popcount-as-index).
Template 12: Digit DP — LC 233
Recognise — “how many numbers in [L, R] (or ≤ n) satisfy a digit-level property”:
count a digit, digits strictly increasing, no repeated digit, digit sum divisible by k. n ≤ 10⁹ or
10¹⁸ → the range is far too large to iterate, but the number has only 10–19 digits.
Key point — build the number digit by digit from the left. While the prefix equals n’s prefix
(tight), the next digit is capped at n’s digit; once it is smaller, every later digit is free,
so all the non-tight states with the same (pos, count) have the same answer and can be cached.
| State | Meaning | Why it exists |
|---|---|---|
pos |
which digit position we are filling | the recursion index |
tight |
is the prefix still equal to the bound’s prefix? | if yes, this digit is capped at bound[pos]; if no, 0..9 is free |
started |
have we placed a non-zero digit yet? | separates a real leading digit from padding zeros |
LC 233 counts the digit 1, which a leading zero can never contribute, so it needs only pos,
tight and the running count — started is for properties a padding zero would break
(“no repeated digit”).
# python
# LC 233 - Number of Digit One
# IDEA: digit DP over (pos, ones so far, tight); only tight states depend on n's digits
# time = O(D^2 * 10), space = O(D^2) D = number of digits in n
from functools import lru_cache
def countDigitOne(n):
digits = list(map(int, str(n)))
D = len(digits)
@lru_cache(maxsize=None)
def count(pos, ones, tight):
if pos == D:
return ones # one finished number: its count of 1s
limit = digits[pos] if tight else 9
total = 0
for d in range(limit + 1):
total += count(pos + 1, ones + (d == 1), tight and d == limit)
return total
return count(0, 0, True)
// java
// LC 233 - Number of Digit One
// IDEA: digit DP over (pos, ones so far, tight); only non-tight states are cached
// time = O(D^2 * 10), space = O(D^2) D = number of digits in n
private int[] digits;
private Integer[][] memo; // memo[pos][ones], non-tight states only
public int countDigitOne(int n) {
String s = Integer.toString(n);
digits = new int[s.length()];
for (int i = 0; i < s.length(); i++) {
digits[i] = s.charAt(i) - '0';
}
memo = new Integer[digits.length][digits.length + 1];
return count(0, 0, true);
}
private int count(int pos, int ones, boolean tight) {
if (pos == digits.length) {
return ones; // one finished number: its count of 1s
}
if (!tight && memo[pos][ones] != null) {
return memo[pos][ones];
}
int limit = tight ? digits[pos] : 9;
int total = 0;
for (int d = 0; d <= limit; d++) {
total += count(pos + 1, ones + (d == 1 ? 1 : 0), tight && d == limit);
}
if (!tight) {
memo[pos][ones] = total;
}
return total;
}
Complexity
- Time O(D² · 10):
pos×onesgives O(D²) cached states, and thefor dloop tries ≤ 10 digits in each. The tight path is a single chain of D calls. - Space O(D²): the cache, plus O(D) recursion depth.
Dry run
n = 13 -> digits [1, 3]
pos 0, tight: d = 0 (free) or d = 1 (stays tight)
d=0: numbers 00..09 -> pos 1 free, ones=0 -> d=1 adds one -> 1
d=1: numbers 10..13 -> pos 1 tight (limit 3), ones=1 -> 4 numbers carry 1 each,
and d=1 at pos 1 (the 11) adds one more -> 5
answer = 1 + 5 = 6 (1, 10, 11, 11, 12, 13)
Traps
- Caching tight states together with free ones.
count(pos, ones, True)andcount(pos, ones, False)differ; a memo keyed on(pos, ones)alone returns the free answer on the tight path and over-counts. tight and d == limit, notd == digits[pos]. Once the prefix is belownit stays below; re-checking againstdigits[pos]on a free path wrongly caps later digits.- Range queries.
answer(L, R) = count(R) - count(L - 1);count(L)dropsLitself.
Not this if — the property depends on the number’s value rather than its digits (a prime, a perfect square) → no digit DP; enumerate or sieve. n ≤ 10⁶ → just loop.
Drill — LC 233 · LC 357 (no repeated digit: needs started and a used-mask) · LC 902 (digits
from a set) · LC 1012 (complement of 357) · LC 600 (no consecutive 1s in binary)
dp_digit.md covers, in full: the universal template with commentary, LC 233
(count digit 1), LC 902 (numbers built from a digit set), digit-sum and no-consecutive-digit variants, pruning, and the
memoisation-key pitfalls.
Keywords: DP, dynamic programming, memoization, tabulation, optimal substructure, overlapping subproblems, state transition, knapsack, LCS, LIS, interval DP, tree DP, state machine, bitmask, monotonic stack, mono stack, stack DP
Drill Path
| Stage | Problems (easy → hard) | What it adds |
|---|---|---|
| Core ⭐ | LC 70 ok · LC 198 ok · LC 213 ok · LC 53 ok · LC 152 ok · LC 91 again · LC 139 again · LC 62 ok · LC 300 again · LC 5 again · LC 647 again · LC 1143 ok · LC 72 ok · LC 416 again · LC 322 again · LC 309 again | every core template once: 1-D, length-indexed, prefix partition, grid, LIS, palindrome table, two-sequence grid, knapsack, state machine |
| Practice | LC 746 again · LC 64 ok · LC 516 again · LC 1218 again · LC 494 ok · LC 518 ok · LC 714 no verdict · LC 279 again | the variant each template is usually asked as |
| Stretch | LC 312 again · LC 1879 no verdict · LC 233 no verdict · LC 115 no verdict · LC 10 again · LC 1000 no verdict | split-point interval DP, bitmask, digit DP, the hard two-sequence grids |
Summary & Quick Reference
Is It a DP Problem?
DP Problem Identification Flowchart:
1. Can the problem be broken into subproblems?
├── NO → Not a DP problem
└── YES → Continue to 2
2. Do subproblems overlap?
├── NO → Use Divide & Conquer
└── YES → Continue to 3
3. Does it have optimal substructure?
├── NO → Not a DP problem
└── YES → Use DP, continue to 4
4. What type of DP pattern?
├── Single sequence → Linear DP (Template 1)
├── 2D grid/matrix → Grid DP (Template 2)
├── Interval/substring → Interval DP (Template 3)
├── Selection with limit → Knapsack (Template 4)
└── Multiple states → State Machine (Template 5)
5. Implementation approach?
├── Recursive structure clear → Top-down memoization
└── Iterative structure clear → Bottom-up tabulation
DP vs other approaches
| Problem Type | Use DP | Use Alternative | Alternative |
|---|---|---|---|
| Optimization (min/max) | ✅ | Sometimes | Greedy if optimal |
| Count ways/paths | ✅ | - | - |
| Decision (yes/no) | ✅ | Sometimes | Greedy/DFS |
| All solutions needed | ❌ | ✅ | Backtracking |
| No overlapping subproblems | ❌ | ✅ | Divide & Conquer |
| Greedy choice property | ❌ | ✅ | Greedy |
Which DP Pattern?
Quick Pattern Selection Table
| Problem Type | Recognition Keywords | DP Category | Example Problems |
|---|---|---|---|
| Fibonacci-like | “nth number”, “climbing stairs”, “decode ways” | Linear DP | LC 70, 91, 746 |
| House Robber | “non-adjacent”, “cannot pick consecutive” | Linear DP | LC 198, 213, 337 |
| Prefix Partition | “break/segment a string”, “cut into valid pieces” | Prefix Partition DP | LC 139, 140, 132, 279 |
| Longest Increasing | “longest increasing”, “LIS”, “envelope” | Linear DP | LC 300, 354, 673 |
| Fixed-Step Chain | “subsequence with given difference”, “each step is prev ± d” | Value-keyed DP | LC 1218, 1048 |
| Path Counting | “unique paths”, “number of ways to reach” | Grid DP | LC 62, 63, 980 |
| Path Sum (Min/Max) | “minimum path sum”, “maximum sum” | Grid DP | LC 64, 120, 174 |
| Square/Rectangle | “maximal square”, “largest rectangle” | Grid DP | LC 221, 85 |
| Interval Problems | “burst balloons”, “merge stones”, “palindrome partition” | Interval DP | LC 312, 1000, 516 |
| Tree Problems | “house robber on tree”, “tree cameras” | Tree DP | LC 337, 968 |
| Tree Re-rooting | “for every node compute”, “sum of distances” | Re-rooting DP | LC 834, 2581 |
| Stock Trading | “buy and sell stock”, “transaction”, “cooldown” | State Machine | LC 122, 309, 714 |
| Knapsack (0/1) | “subset sum”, “partition”, “target sum” | Knapsack DP | LC 416, 494 |
| Knapsack (Unbounded) | “coin change”, “unlimited supply” | Knapsack DP | LC 322, 518 |
| Edit Distance | “edit distance”, “minimum operations” | String DP | LC 72, 583, 712 |
| LCS/LPS | “longest common subsequence”, “palindrome” | String DP | LC 1143, 516, 647 |
| Bitmask/Subset | “visit all nodes”, “assign tasks”, “TSP” | State Compression | LC 847, 1723, 691 |
Signal → pattern
| Signal | Pattern |
|---|---|
| “max/min subarray with sliding constraint” | Monotonic queue DP |
| “answer for every node as root” | Re-rooting (2 DFS passes) |
| “largest rectangle / maximal square” | Stack DP or DP on prefix heights |
| “game: two players pick optimally” | Minimax DP: dp[i][j] = score diff |
| “count numbers with digit constraint” | Digit DP: (pos, tight, accumulator) |
| “break string into valid words” | Prefix partition DP: dp[i] over prefix lengths + word set |
| “longest subsequence with a given difference” | Value-keyed DP: dp[x] = dp[x - d] + 1 in a hash map |
| “stock buy/sell variants” | State machine (held/sold/rest) |
| “edit distance, LCS, interleaving” | 2D DP → 1D space optimization |
Complexity Quick Reference
| Pattern | Time Complexity | Space Complexity | Space Optimization |
|---|---|---|---|
| 1D Linear | O(n) | O(n) | O(1) with variables |
| 2D Grid | O(m×n) | O(m×n) | O(n) with rolling array |
| Interval | O(n³) typical | O(n²) | Usually not possible |
| 0/1 Knapsack | O(n×W) | O(n×W) | O(W) with 1D array |
| State Machine | O(n×k) | O(k) | Already optimized |
State Definition Guidelines
# 1D: Position/index based
dp[i] = "optimal value considering first i elements"
# 2D: Two dimensions
dp[i][j] = "optimal value for subproblem (i, j)"
# Interval: Range based
dp[i][j] = "optimal value for interval [i, j]"
# Boolean: Decision problems
dp[i] = "whether target i is achievable"
Problem-Solving Steps
- Identify if DP applicable: Check for overlapping subproblems
- Define state: What does dp[i] represent?
- Find recurrence: How do states relate?
- Identify base cases: Initial values
- Determine iteration order: Bottom-up direction
- Optimize space: Can we use rolling array?
Common Mistakes & Tips
🚫 Common Mistakes:
- Wrong state definition
- Missing base cases
- Incorrect iteration order
- Not handling edge cases
- Integer overflow in large problems
✅ Best Practices:
- Start with recursive solution, then optimize
- Draw small examples to find patterns
- Check array bounds carefully
- Consider space optimization after correctness
- Use meaningful variable names for states
Interview Tips
- Start simple: Write recursive solution first
- Identify subproblems: Draw recursion tree
- Add memoization: Convert to top-down DP
- Consider bottom-up: Often more efficient
- Optimize space: Impress with rolling array
- Test with examples: Trace through small inputs
Related Topics
- Greedy: When local optimal leads to global
- Backtracking: When need all solutions
- Divide & Conquer: No overlapping subproblems
- Graph Algorithms: DP on graphs (shortest path)
- Binary Search: Optimization problems with monotonicity