🧮 Dynamic Programming
A standalone, LC-medium/hard-focused sheet. DP = problems that break into smaller versions of themselves that repeat — solve each once, store it, reuse it. Define the state, write the transition, pick the order.
🧠 HOW TO THINK ABOUT DP (READ FIRST)
DP = Recursion + Repeated States + Caching. Solve each state once, store it, reuse it.
1. DECISION FRAMEWORK
Senior Engineer mindset: Before touching the keyboard, spend 3 minutes here. Every minute you spend framing pays back 5 minutes in coding.
Recursion → Memoization → Tabulation → Optimization.
dp[i] = [one sentence]. Recurrence: dp[i] = [formula]. Base case: dp[0] = [value]. I'll go bottom-up from [start] to [end]. Time O([t]), Space O([s])."2. THE DP FLAVORS
9 mainstream flavors. (Two rarer ones — Matrix Exponentiation, Probability/EV — live in 🗂️ Rare Variants near the end, not here, since they're the same idea wearing a costume too thin to earn a full row.)
| Flavor | State dims | Key intuition | Examples |
|---|---|---|---|
| 1D Linear | dp[i] | "best ending AT i" or "best using first i items" | LC 300, 198, 152, 53, 45, 139 |
| 2D Grid / String | dp[i][j] | Usually two sequences, or a 2D grid with constraints | LC 1143, 72, 62, 64, 221, 115 |
| Interval | dp[i][j] | Sub-array from i to j — fill diagonally | LC 516, 312, 1039, 1547, 1000 |
| Knapsack — 0/1 | dp[i][w] or dp[w] | i items, capacity w — take or skip each item | LC 416, 494, 474, 1049, 1155 |
| Knapsack — unbounded | dp[w] | Item can be reused | LC 322, 518, 279 |
| Knapsack — multi-dim | dp[i][j][k] | Multiple constraints at once | LC 474, 879 |
| State Machine | dp[i][state] | Finite states + transitions — "holding stock", "resting" | LC 121-123, 309, 188, 714 |
| Bitmask | dp[mask] or dp[i][mask] | Subset of n items (n≤20) — "visited which nodes/chars" | LC 847, 864, 943, 1125, 526, 698 |
| Tree | dp[node][..] | Post-order; combine children. Reroot = two-pass, for "every node" | LC 124, 337, 543, 834, 310 |
Key insight from 20 years at FAANG: Most DP problems are one of the above flavors wearing a costume. Once you strip the costume, you have a template. Your job in an interview is to recognize the flavor in ≤2 minutes.
3. KEYWORD → PATTERN MAP
| Problem wording | Think |
|---|---|
| take / skip / choose | DP |
| min / max / best | DP |
| number of ways | DP |
| possible / can we | DP |
| sequential decisions | DP |
| repeated state | Memoization |
🚨 Anti-patterns — mistakes that kill candidates
🚨 Count vs. optimize? min/max propagates:
dp[i]=f(dp[i-1],...). Count accumulates: dp[i] += dp[j].🚨 Recursion depth? n > 10K → go bottom-up, top-down will stack-overflow.
🚨 Forgot MOD? Apply
% MOD on every addition, before storing into dp[].🚨 Off-by-one interval base? Set
dp[i][i] and dp[i][i+1] explicitly before the main loop.🚨 Need to reconstruct? Keep the full table — a rolling array can't backtrack.
🚨 Slicing in the inner loop?
.slice() is O(n) per call — precompute bounds instead.🚨 Wrong knapsack loop direction? 0/1 = right-to-left (no reuse). Unbounded = left-to-right (reuse).
🚨 2D array shares rows? Use
Array.from({length:m}, () => ...), never .fill(new Array(...)).
🚨 Trap alerts — looks like DP, isn't
🚨 "meeting rooms / activity selection"? Greedy, not DP.
🚨 "assign tasks to minimize max"? Binary search, not DP.
🚨 "sliding window max/min"? Monotonic deque, not DP.
4. 1D DP TEMPLATES
One evolving index — dp[i] built from a few earlier dp values.
dp[i] = best answer using nums[i..n-1]. Invariant: fixed lookback — dp[i] only ever needs dp[i+1]/dp[i+2]. Escalation: climbing stairs → house robber → house robber II (circular, §4.1b) → LIS → word break / decode ways.4.1 Classic 1D Template (Fibonacci-style)
// dp[i] = best answer using nums[i..n-1]
// LC 70 (Climbing Stairs) / LC 198 (House Robber)
function dp1D(nums: number[]): number {
const n = nums.length;
if (n === 0) return 0;
const dp = new Array(n + 2).fill(0); // dp[n], dp[n+1] = 0 — nothing left to take
for (let i = n - 1; i >= 0; i--) {
const skip = dp[i + 1];
const take = nums[i] + dp[i + 2];
dp[i] = Math.max(skip, take);
}
return dp[0];
}
// Time: O(n), Space: O(n) → can reduce to O(1) with rolling variables
// ⭐ SPACE OPTIMIZATION (dp[i] only ever needs dp[i+1] and dp[i+2] — fixed lookback):
function dp1DSpaceOpt(nums: number[]): number {
let next1 = 0; // dp[i+1]
let next2 = 0; // dp[i+2]
for (let i = nums.length - 1; i >= 0; i--) {
const curr = Math.max(next1, nums[i] + next2);
next2 = next1;
next1 = curr;
}
return next1;
}// dfs(i) = best answer using nums[i..n-1]
// LC 70 / LC 198
function dp1D_rec(nums: number[]): number {
const memo = new Map<number, number>();
function dfs(i: number): number {
// base case
if (i >= nums.length) return 0;
if (memo.has(i)) return memo.get(i)!;
const skip = dfs(i + 1);
const take = nums[i] + dfs(i + 2);
const res = Math.max(skip, take);
memo.set(i, res);
return res;
}
return dfs(0);
}
// ⭐ The iterative version rolls to O(1) space (next1/next2). Memoized recursion can't —
// the memo has to keep every subproblem it might still need, so it stays O(n).4.1b House Robber II — Circular Array (LC 213)
State: same as §4.1. Invariant: first/last house are now neighbors — one of "exclude first" / "exclude last" must be optimal, since any valid circular pick already skips at least one endpoint.
// Run §4.1's rolling-variable solver twice, on each of the two sub-ranges
// Time: O(n), Space: O(1) — called TWICE, constant factor doesn't change the order
function rob(nums: number[]): number {
const n = nums.length;
if (n === 1) return nums[0];
function solve(start: number, end: number): number {
let next1 = 0;
let next2 = 0;
for (let i = end; i >= start; i--) {
const curr = Math.max(next1, nums[i] + next2);
next2 = next1;
next1 = curr;
}
return next1;
}
return Math.max(
solve(0, n - 2), // exclude last house
solve(1, n - 1) // exclude first house
);
}// Same idea, top-down: memoize §4.1's dfs(i), run it on each of the two sub-ranges
// Time: O(n), Space: O(n) — memo + recursion stack per solve() call, called TWICE
function rob_rec(nums: number[]): number {
const n = nums.length;
if (n === 1) return nums[0];
function solve(start: number, end: number): number {
const memo = new Map<number, number>();
function dfs(idx: number): number {
// base case
if (idx > end) return 0;
if (memo.has(idx)) return memo.get(idx)!;
const skip = dfs(idx + 1);
const take = nums[idx] + dfs(idx + 2);
const res = Math.max(skip, take);
memo.set(idx, res);
return res;
}
return dfs(start);
}
return Math.max(
solve(0, n - 2), // exclude last house
solve(1, n - 1) // exclude first house
);
}4.2 Longest Increasing Subsequence (LIS) — O(n²)
State: dp[i][prev] = LIS achievable from nums[i..] given the previously-chosen index. Invariant: prev is stored as an INDEX (sentinel n = "nothing chosen yet"), not a value — avoids -1 headaches in the memo/table. This is the template for multi-dimensional state DP.
// dp[i][prev] = LIS achievable from nums[i..] given prev index chosen so far
// LC 300 — Classic, understand BOTH approaches
function lengthOfLIS_DP(nums: number[]): number {
const n = nums.length;
let nextRow = new Array(n + 1).fill(0); // base case: i === n
for (let i = n - 1; i >= 0; i--) {
const currRow = new Array(n + 1).fill(0);
for (let prev = 0; prev <= n; prev++) {
const skip = nextRow[prev];
let take = 0;
if (prev === n || nums[i] > nums[prev]) {
take = 1 + nextRow[i];
}
currRow[prev] = Math.max(skip, take);
}
nextRow = currRow;
}
return nextRow[n];
}
// Time: O(n²), Space: O(n) — dp[i][prev] only ever needs dp[i+1][prev] (same prev,
// row below) and dp[i+1][i] (row below, different column) — never the same row → 2 rows only// dfs(i, prev) = LIS achievable from nums[i..] given prev index chosen
// LC 300
function lengthOfLIS_rec(nums: number[]): number {
const n = nums.length;
const memo: (number | null)[][] = Array.from({ length: n }, () => new Array(n + 1).fill(null));
function dfs(i: number, prev: number): number {
// base case
if (i === n) return 0;
if (memo[i][prev] !== null) return memo[i][prev];
const skip = dfs(i + 1, prev);
let take = 0;
if (prev === n || nums[i] > nums[prev]) {
take = 1 + dfs(i + 1, i);
}
const res = Math.max(skip, take);
memo[i][prev] = res;
return res;
}
return dfs(0, n);
}
// Time: O(n²) — n × (n+1) distinct (i,prev) states, each computed once
// Space: O(n²) memo + O(n) recursion stack4.2b Count of Longest Increasing Subsequences (LC 673)
State: stores BOTH a value and a count — length[i] = LIS length ending at i, count[i] = how many such LIS end at i. Invariant: a strictly longer path found at j RESETS the count; an equally long path ACCUMULATES it.
// LC 673 — common SDE3 follow-up to LC 300
// Time: O(n²) — nested loop, outer i: 0..n, inner j: 0..i
// Space: O(n) — length[] and count[] arrays only, no recursion stack
function findNumberOfLIS(nums: number[]): number {
const n = nums.length;
const length = Array(n).fill(1); // length of LIS ending at i
const count = Array(n).fill(1); // how many such LIS end at i
for (let i = 0; i < n; i++) {
for (let j = 0; j < i; j++) {
if (nums[j] >= nums[i]) continue;
if (length[j] + 1 > length[i]) {
length[i] = length[j] + 1;
count[i] = count[j];
} else if (length[j] + 1 === length[i]) {
count[i] += count[j];
}
}
}
const maxLen = Math.max(...length);
let total = 0;
for (let i = 0; i < n; i++) {
if (length[i] === maxLen) total += count[i];
}
return total;
}
// SDE3 SIGNAL: "how many optimal solutions exist?" → always think about running
// a parallel count[] array alongside the value array.4.3 LIS — O(n log n) via Binary Search (SDE3 Expected)
State: tails[k] = smallest possible tail value of any increasing subsequence of length k+1. Not the LIS itself — just a tool to find its length. ⭐ SDE3 interviewers WILL ask for this optimization.
// PATIENCE SORTING — Time: O(n log n), Space: O(n)
// Each number does one binary search over tails (size ≤ n) → O(log n) per number
function lengthOfLIS(nums: number[]): number {
const tails: number[] = [];
for (const num of nums) {
let lo = 0, hi = tails.length;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (num > tails[mid]) lo = mid + 1;
else hi = mid;
}
if (lo === tails.length) tails.push(num);
else tails[lo] = num;
}
return tails.length;
}
// To reconstruct the actual LIS (not just its length), you need parent pointers (LC 354, §4.7).4.3b DP + Binary Search (SDE3 Pattern)
States sorted/monotone → binary search finds the transition instead of a linear scan. Common in: job scheduling (last non-overlapping job), LIS-in-2D (sort one dim, LIS the other), egg drop (binary search on the answer).
Russian Doll Envelopes (LC 354) is the same sort-then-O(n log n)-LIS trick already covered in §4.3 — sort ascending by width / descending by height on ties, then run LIS on heights alone.
Maximum Profit in Job Scheduling (LC 1235)
State: dp[i] = max profit from jobs[i..n-1] (sorted by start time). Invariant: after taking job i, binary search jumps straight to the first job whose start ≥ this job's end — skipping every overlapping job in one step.
// LC 1235 — the canonical "DP + binary search on sorted structure" problem
// Time: O(n log n) [sort + n states × O(log n)], Space: O(n)
function jobScheduling(startTime: number[], endTime: number[], profit: number[]): number {
const n = startTime.length;
const jobs = startTime.map((s, i) => [s, endTime[i], profit[i]]);
jobs.sort((a, b) => a[0] - b[0]);
function findNextJob(i: number): number {
let lo = i + 1, hi = n;
while (lo < hi) {
const mid = (lo + hi) >> 1;
// next job's start >= this job's end → compatible
if (jobs[mid][0] >= jobs[i][1]) hi = mid;
else lo = mid + 1;
}
return lo;
}
const dp = Array(n + 1).fill(0);
for (let i = n - 1; i >= 0; i--) {
const skip = dp[i + 1];
const nextJobIdx = findNextJob(i);
const take = jobs[i][2] + dp[nextJobIdx];
dp[i] = Math.max(skip, take);
}
return dp[0];
}
// KEY: sort by START time so findNextJob's binary search has a clean
// "first index ≥ target" (lower_bound) shape.// dfs(i) = max profit from jobs[i..n-1]
// Time: O(n log n) [n states × O(log n) findNextJob], Space: O(n) memo + recursion stack
function jobScheduling_rec(startTime: number[], endTime: number[], profit: number[]): number {
const n = startTime.length;
const jobs = startTime.map((s, i) => [s, endTime[i], profit[i]]);
jobs.sort((a, b) => a[0] - b[0]);
function findNextJob(i: number): number {
let lo = i + 1, hi = n;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (jobs[mid][0] >= jobs[i][1]) hi = mid;
else lo = mid + 1;
}
return lo;
}
const memo = new Map<number, number>();
function dfs(i: number): number {
// base case
if (i === n) return 0;
if (memo.has(i)) return memo.get(i)!;
const skip = dfs(i + 1);
const nextJobIdx = findNextJob(i);
const take = jobs[i][2] + dfs(nextJobIdx);
const res = Math.max(skip, take);
memo.set(i, res);
return res;
}
return dfs(0);
}
// Plain recursion (no memo) would be O(2^n · log n) — 2 choices/call × O(log n) findNextJob workSuper Egg Drop (LC 887) — DP + Binary Search on State
// dp[m][k] = max floors testable with m moves and k eggs
// Binary search insight: instead of dp[k][n] (TLE), flip to dp[m][k].
// LC 887 — Hard. The state flip + binary search is the SDE3 signal.
function superEggDrop(k: number, n: number): number {
// dp[m] = max floors we can check with m moves and k eggs
// Recurrence: with one more move and k eggs, we can check
// dp[m-1][k-1] floors below + 1 current + dp[m-1][k] floors above
// = dp[m-1][k-1] + 1 + dp[m-1][k]
// Keep incrementing moves until dp[moves][k] >= n
let moves = 0;
const dp = new Array(k + 1).fill(0);
while (dp[k] < n) {
moves++;
// Traverse RIGHT TO LEFT to avoid using updated values
for (let eggs = k; eggs >= 1; eggs--) {
dp[eggs] = dp[eggs - 1] + 1 + dp[eggs];
}
}
return moves;
}
// Time: O(k log n), Space: O(k)
// INSIGHT: "minimum moves to guarantee finding critical floor" = flip to
// "how many floors can I test with m moves and k eggs?" Then binary search
// on m (the answer), which is at most O(n).4.4 Maximum Subarray (Kadane’s) — Linear DP
State: dp[i] = best sum ENDING at i. Invariant: at each i, either extend the running subarray or start fresh at nums[i] — whichever is bigger.
// dp[i] = max sum subarray ENDING at i
// LC 53, 918 (circular version)
// Time: O(n), Space: O(1) — already rolling, only one prior value needed
function maxSubArray(nums: number[]): number {
let maxSum = nums[0];
let curr = nums[0];
for (let i = 1; i < nums.length; i++) {
const startFresh = nums[i];
const extend = curr + nums[i];
curr = Math.max(startFresh, extend);
maxSum = Math.max(maxSum, curr);
}
return maxSum;
}
// ⭐ TRAP: LC 918 (Maximum Sum Circular Subarray)
// Answer = max(normal Kadane, totalSum - min subarray)
// Edge case: if all numbers are negative, min subarray = whole array → return normal Kadane// dfs(i) = max subarray sum ENDING exactly at i (looks back to i-1)
// Kadane's own derivation IS this recursion — backward suits it better than forward here
// Time: O(n) — n distinct states, cached. Space: O(n) — memo + recursion stack
function maxSubArray_rec(nums: number[]): number {
const n = nums.length;
const memo: number[] = new Array(n).fill(NaN);
function dfs(i: number): number {
// base case
if (i === 0) return nums[0];
if (!Number.isNaN(memo[i])) return memo[i];
const startFresh = nums[i];
const extend = nums[i] + dfs(i - 1);
const res = Math.max(startFresh, extend);
memo[i] = res;
return res;
}
let best = -Infinity;
for (let i = 0; i < n; i++) best = Math.max(best, dfs(i));
return best;
}4.4b Maximum Product Subarray (Min/Max Tracking)
State: BOTH max and min product ending at i. Invariant: a negative number can flip the running min into the new max — recompute both from {n, max*n, min*n} every step, never just one.
// dp tracks BOTH max and min product ending at i.
// WHY min: a negative number flips min→max. The most-negative product can
// become the largest the moment we multiply by another negative.
// LC 152 — the two-variable trick is the whole point.
// Time: O(n), Space: O(1)
function maxProduct(nums: number[]): number {
let result = nums[0];
let curMax = nums[0]; // max product ending at i
let curMin = nums[0]; // min product ending at i (could flip to max)
for (let i = 1; i < nums.length; i++) {
const n = nums[i];
// ⭐ snapshot both BEFORE overwriting — curMax is used to compute curMin
const cand1 = curMax * n;
const cand2 = curMin * n;
curMax = Math.max(n, cand1, cand2); // start fresh at n, or extend
curMin = Math.min(n, cand1, cand2);
result = Math.max(result, curMax);
}
return result;
}
// TRAP: don't update curMax then use the NEW curMax for curMin — snapshot first.
// SDE3 SIGNAL: "Unlike sum, product isn't monotonic under sign, so I carry the
// running minimum to capture negative-times-negative comebacks."// dfs(i) = [max, min] product PAIR ending exactly at i — same backward shape as Kadane,
// but the memo stores a pair since either half of the pair can become the answer next step
// Time: O(n), Space: O(n) — memo + recursion stack
function maxProduct_rec(nums: number[]): number {
const n = nums.length;
const memo = new Map<number, [number, number]>();
function dfs(i: number): [number, number] {
// base case
if (i === 0) return [nums[0], nums[0]];
if (memo.has(i)) return memo.get(i)!;
const [prevMax, prevMin] = dfs(i - 1);
const ni = nums[i];
const cand1 = prevMax * ni;
const cand2 = prevMin * ni;
const res: [number, number] = [Math.max(ni, cand1, cand2), Math.min(ni, cand1, cand2)];
memo.set(i, res);
return res;
}
let best = -Infinity;
for (let i = 0; i < n; i++) best = Math.max(best, dfs(i)[0]);
return best;
}4.5 Word Break — 1D Boolean DP
State: dp[start] = can s[start..n] be segmented using wordDict? Invariant: variable-length transition — try every possible split end, not a fixed lookback.
// dp[start] = can s[start..n] be segmented using wordDict?
// LC 139
function wordBreak(s: string, wordDict: string[]): boolean {
const n = s.length;
const wordSet = new Set(wordDict);
const dp: boolean[] = Array(n + 1).fill(false);
dp[n] = true; // base case: reached the end cleanly
for (let start = n - 1; start >= 0; start--) {
for (let end = start + 1; end <= n; end++) {
const seg = s.slice(start, end);
if (wordSet.has(seg) && dp[end]) {
dp[start] = true;
break; // one valid split is enough
}
}
}
return dp[0];
}
// Time: O(n² · L) where L = avg slice length, Space: O(n)
// SDE3: precompute max word length to prune the inner loop: end <= start + maxLen// dfs(start) = can s[start..n] be segmented? Try every dictionary word starting at start
// LC 139
function wordBreak_rec(s: string, wordDict: string[]): boolean {
const n = s.length;
const wordSet = new Set(wordDict);
const memo: (boolean | null)[] = Array(n).fill(null);
function dfs(start: number): boolean {
// base case
if (start === n) return true;
if (memo[start] !== null) return memo[start];
for (let end = start + 1; end <= n; end++) {
const seg = s.slice(start, end);
if (wordSet.has(seg) && dfs(end)) {
memo[start] = true;
return true;
}
}
memo[start] = false;
return false;
}
return dfs(0);
}4.6 Decode Ways — Conditional 1D DP
State: dp[i] = number of ways to decode s[i..n-1]. Invariant: fixed lookback (dp[i] only needs dp[i+1]/dp[i+2]) — same shape as §4.1, rolls to O(1).
// dp[i] = number of ways to decode s[i..n-1]
// LC 91 — Hard due to '0' handling
function numDecodings_DP(s: string): number {
const n = s.length;
const dp: number[] = new Array(n + 1).fill(0);
dp[n] = 1; // base case: reached the end cleanly
for (let i = n - 1; i >= 0; i--) {
if (s[i] === "0") { dp[i] = 0; continue; } // lone '0' — no valid decode from here
dp[i] = dp[i + 1]; // take 1 digit
if (i + 1 < n) {
const twoDigit = Number(s.slice(i, i + 2));
if (twoDigit >= 10 && twoDigit <= 26) dp[i] += dp[i + 2]; // take 2 digits
}
}
return dp[0];
}
// Time: O(n), Space: O(n)
// ⭐ SPACE OPTIMIZATION (dp[i] only ever needs dp[i+1] and dp[i+2] — fixed lookback):
function numDecodings(s: string): number {
const n = s.length;
let next1 = 1; // dp[i+1]
let next2 = 0; // dp[i+2]
for (let i = n - 1; i >= 0; i--) {
if (s[i] === "0") {
next2 = next1;
next1 = 0;
continue;
}
const oneDigit = next1;
let twoDigit = 0;
if (i + 1 < n) {
const num = Number(s.slice(i, i + 2));
if (num >= 10 && num <= 26) twoDigit = next2;
}
const curr = oneDigit + twoDigit;
next2 = next1;
next1 = curr;
}
return next1;
}
// Time: O(n), Space: O(1)
// TRAP: '0' cannot be decoded alone — always check before adding dp[i+1] / next1.// dfs(i) = number of ways to decode s[i..n-1]
// LC 91
function numDecodings_rec(s: string): number {
const n = s.length;
const memo: (number | null)[] = new Array(n).fill(null);
function dfs(i: number): number {
// base case
if (i === n) return 1; // successfully decoded
if (s[i] === "0") return 0;
if (memo[i] !== null) return memo[i];
const oneDigit = dfs(i + 1);
let twoDigit = 0;
if (i + 1 < n) {
const num = Number(s.slice(i, i + 2));
if (num >= 10 && num <= 26) twoDigit = dfs(i + 2);
}
const res = oneDigit + twoDigit;
memo[i] = res;
return res;
}
return dfs(0);
}4.6b Longest Valid Parentheses — dp ending at i (LC 32)
“Best valid run ENDING at i” — only a ) can end one, and it either closes a fresh ( or wraps an earlier valid block.
dp[i] = length of the longest valid substring ending exactly at i (0 if s[i] is (). Case (): dp[i] = dp[i-2] + 2. Case )): the block ending at i-1 starts at j+1 where j = i - dp[i-1] - 1; if s[j] is ( it wraps that block: dp[i] = dp[i-1] + 2 + dp[j-1] — the trailing dp[j-1] glues on any valid run just before j.function longestValidParentheses(s: string): number {
const n = s.length;
const dp = new Array(n).fill(0);
let best = 0;
for (let i = 1; i < n; i++) {
if (s[i] !== ')') continue;
if (s[i - 1] === '(') {
dp[i] = (i >= 2 ? dp[i - 2] : 0) + 2;
} else {
const j = i - dp[i - 1] - 1; // char just before the block ending at i-1
if (j >= 0 && s[j] === '(') dp[i] = dp[i - 1] + 2 + (j >= 1 ? dp[j - 1] : 0);
}
best = Math.max(best, dp[i]);
}
return best;
}
// O(1) space follow-up: two passes, reset when the count "breaks"
function longestValidParenthesesO1(s: string): number {
let open = 0, close = 0, best = 0;
for (let i = 0; i < s.length; i++) { // left → right: reset when close > open
if (s[i] === '(') open++; else close++;
if (open === close) best = Math.max(best, 2 * close);
else if (close > open) open = close = 0;
}
open = close = 0;
for (let i = s.length - 1; i >= 0; i--) { // right → left: catches "(()" style leftovers
if (s[i] === '(') open++; else close++;
if (open === close) best = Math.max(best, 2 * open);
else if (open > close) open = close = 0;
}
return best;
}
// Stack alternative: seed [-1]; on '(' push i; on ')' pop, then length = i - stack.top (push i if the stack emptied))) case people forget the trailing + dp[j-1], and skip the i-2 / j-1 bounds guards. Interviewers almost always ask for the O(1)-space version next.4.7 DP Reconstruction — Backtracking Through the Table
The most missed skill: dp[n] gives the optimal VALUE — interviews almost always want the actual solution too. Two techniques: backtrack through the filled table, or track parent[] pointers as you fill it.
LCS Reconstruction → Shortest Common Supersequence (LC 1092)
// Step 1: fill the LCS dp table (same as always)
// Step 2: backtrack from dp[m][n] to dp[0][0] to reconstruct the LCS
// Step 3: for SCS (LC 1092), interleave both strings guided by LCS
function shortestCommonSupersequence(str1: string, str2: string): string {
const m = str1.length, n = str2.length;
const dp: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
for (let i = 1; i <= m; i++)
for (let j = 1; j <= n; j++)
dp[i][j] = str1[i-1] === str2[j-1]
? dp[i-1][j-1] + 1
: Math.max(dp[i-1][j], dp[i][j-1]);
// ⭐ BACKTRACK: start at dp[m][n], walk back to dp[0][0]
let i = m, j = n;
const result: string[] = [];
while (i > 0 && j > 0) {
if (str1[i-1] === str2[j-1]) {
result.push(str1[i-1]); // in LCS → take once
i--; j--;
} else if (dp[i-1][j] > dp[i][j-1]) {
result.push(str1[i-1]); // came from top → str1 char not in LCS
i--;
} else {
result.push(str2[j-1]); // came from left → str2 char not in LCS
j--;
}
}
while (i > 0) { result.push(str1[i-1]); i--; }
while (j > 0) { result.push(str2[j-1]); j--; }
return result.reverse().join('');
}
// Time: O(m*n), Space: O(m*n)LIS Reconstruction (LC 300 / LC 354 follow-up)
// Reconstruct the actual LIS, not just its length.
// Use parent[] array: parent[i] = index j that gave dp[i] its best value.
function lisReconstruct(nums: number[]): number[] {
const n = nums.length;
const dp = new Array(n).fill(1);
const parent = new Array(n).fill(-1); // -1 = no predecessor
for (let i = 1; i < n; i++) {
for (let j = 0; j < i; j++) {
if (nums[j] < nums[i] && dp[j] + 1 > dp[i]) {
dp[i] = dp[j] + 1;
parent[i] = j; // j is the predecessor of i in the LIS
}
}
}
// Find end of LIS (index with maximum dp value)
let endIdx = 0;
for (let i = 1; i < n; i++) if (dp[i] > dp[endIdx]) endIdx = i;
// Walk parent pointers backward
const lis: number[] = [];
for (let cur = endIdx; cur !== -1; cur = parent[cur]) {
lis.push(nums[cur]);
}
return lis.reverse();
}
// Time: O(n²) — same as §4.2's dp[i] fill. Space: O(n) — dp[] + parent[], no recursionKnapsack reconstruction (which items were taken) and Edit Distance reconstruction (which operations were used) are the SAME backward table-diff technique shown above for LCS — walk from the final cell to [0][0], and at each step check which neighbor produced the current value.
4.8 Partition DP — Split Array into K Groups
State: dp[i][k] = best cost to partition arr[0..i-1] into k groups. Invariant: unlike knapsack, every element must be assigned — dp[i][k] = min over j<i of (dp[j][k-1] + cost(j, i)).
// dp[i][j] = min possible maximum sum when splitting nums[0..i-1] into j parts
// LC 410 — Split Array Largest Sum (partition into k parts, minimize max sum)
// Time: O(n²k), Space: O(nk)
function splitArray(nums: number[], k: number): number {
const n = nums.length;
const prefix = new Array(n + 1).fill(0);
for (let i = 0; i < n; i++) prefix[i + 1] = prefix[i] + nums[i];
const dp: number[][] = Array.from({ length: n + 1 }, () =>
new Array(k + 1).fill(Infinity)
);
dp[0][0] = 0;
for (let j = 1; j <= k; j++) {
for (let i = j; i <= n; i++) {
// last group is nums[m..i-1], previous groups cover nums[0..m-1] — try every split point m
for (let m = j - 1; m < i; m++) {
const lastGroupSum = prefix[i] - prefix[m];
dp[i][j] = Math.min(dp[i][j], Math.max(dp[m][j - 1], lastGroupSum));
}
}
}
return dp[n][k];
}
// ⭐ ALTERNATIVE: binary search on the answer + greedy feasibility check → O(n log(sum)).
// For interviews: mention both, implement whichever is easier to explain out loud.
// LC 1043 — Partition Array for Maximum Sum: after partitioning, every element in a
// subarray becomes that subarray's max. dp[i] = max sum for arr[0..i-1].
// Time: O(n*k), Space: O(n)
function maxSumAfterPartitioning(arr: number[], k: number): number {
const n = arr.length;
const dp = new Array(n + 1).fill(0);
for (let i = 1; i <= n; i++) {
let maxVal = 0;
// fix the group's right endpoint at i, try every length 1..k
for (let len = 1; len <= k && i - len >= 0; len++) {
maxVal = Math.max(maxVal, arr[i - len]);
dp[i] = Math.max(dp[i], dp[i - len] + maxVal * len);
}
}
return dp[n];
}// dfs(i, j) = min largest-sum splitting nums[i..n-1] into j groups
// Peels the FIRST group off the front — mirror of the bottom-up prefix table. LC 410
// Time: O(n²k) distinct (i,j) states × O(n) inner scan. Space: O(nk) memo + O(k) stack
function splitArray_rec(nums: number[], k: number): number {
const n = nums.length;
const prefix = new Array(n + 1).fill(0);
for (let i = 0; i < n; i++) prefix[i + 1] = prefix[i] + nums[i];
const rangeSum = (i: number, j: number) => prefix[j] - prefix[i];
const memo = new Map<string, number>();
function dfs(i: number, j: number): number {
// base case
if (j === 1) return rangeSum(i, n);
const key = i + "," + j;
if (memo.has(key)) return memo.get(key)!;
let best = Infinity;
for (let m = i + 1; m <= n - (j - 1); m++) {
// first group is nums[i..m-1], recurse on the rest with one fewer group
best = Math.min(best, Math.max(rangeSum(i, m), dfs(m, j - 1)));
}
memo.set(key, best);
return best;
}
return dfs(0, k);
}5. 2D DP TEMPLATES
Two strings / a grid — dp[i][j] from its three neighbors.
5.1 Longest Common Subsequence (LCS)
// dp[i][j] = LCS of s1[0..i-1] and s2[0..j-1]
// LC 1143 — Foundation for many other problems
function longestCommonSubsequence(text1: string, text2: string): number {
const m = text1.length,
n = text2.length;
// ⭐ Allocate (m+1) x (n+1) for clean base cases
const dp: number[][] = Array.from({ length: m + 1 }, () =>
new Array(n + 1).fill(0),
);
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
if (text1[i - 1] === text2[j - 1]) {
dp[i][j] = dp[i - 1][j - 1] + 1; // characters match: extend
} else {
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]); // skip one char
}
}
}
return dp[m][n];
}
// Time: O(m*n), Space: O(m*n) → reducible to O(min(m,n)) with rolling row
// ⭐ LCS Space Optimization (two-row rolling):
function lcsSpaceOpt(text1: string, text2: string): number {
const m = text1.length,
n = text2.length;
let prev = new Array(n + 1).fill(0);
let curr = new Array(n + 1).fill(0);
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
if (text1[i - 1] === text2[j - 1]) curr[j] = prev[j - 1] + 1;
else curr[j] = Math.max(prev[j], curr[j - 1]);
}
[prev, curr] = [curr, prev]; // swap references
curr.fill(0);
}
return prev[n];
}// TOP-DOWN — rec(i, j) = LCS of text1[i..m-1] and text2[j..n-1] (walk both strings left→right)
// LC 1143
function longestCommonSubsequence_rec(text1: string, text2: string): number {
const m = text1.length, n = text2.length;
const memo: number[][] = Array.from({ length: m }, () => new Array(n).fill(-1));
function rec(i: number, j: number): number {
if (i === m || j === n) return 0;
if (memo[i][j] !== -1) return memo[i][j];
let res: number;
if (text1[i] === text2[j]) res = 1 + rec(i + 1, j + 1);
else res = Math.max(rec(i + 1, j), rec(i, j + 1));
return (memo[i][j] = res);
}
return rec(0, 0);
}
// ⭐ Note: the iterative version rolls to a 2-row table. The memo here needs the full m×n grid —
// same space trade-off as 4.1: recursion can't roll away rows it might still need to revisit.5.2 Edit Distance (Levenshtein)
// dp[i][j] = min ops to convert word1[0..i-1] to word2[0..j-1]
// LC 72 — Three operations: insert, delete, replace
function minDistance(word1: string, word2: string): number {
const m = word1.length,
n = word2.length;
const dp: number[][] = Array.from({ length: m + 1 }, () =>
new Array(n + 1).fill(0),
);
// Base cases: converting to/from empty string
for (let i = 0; i <= m; i++) dp[i][0] = i; // delete all
for (let j = 0; j <= n; j++) dp[0][j] = j; // insert all
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
if (word1[i - 1] === word2[j - 1]) {
dp[i][j] = dp[i - 1][j - 1]; // no op needed
} else {
dp[i][j] =
1 +
Math.min(
dp[i - 1][j], // delete from word1
dp[i][j - 1], // insert into word1
dp[i - 1][j - 1], // replace
);
}
}
}
return dp[m][n];
}
// Time: O(m*n), Space: O(m*n)// TOP-DOWN — rec(i, j) = edit distance between word1[i..m-1] and word2[j..n-1]
// LC 72
function minDistance_rec(word1: string, word2: string): number {
const m = word1.length, n = word2.length;
const memo: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(-1));
function rec(i: number, j: number): number {
if (i === m) return n - j;
if (j === n) return m - i;
if (memo[i][j] !== -1) return memo[i][j];
let res: number;
if (word1[i] === word2[j]) res = rec(i + 1, j + 1);
else res = 1 + Math.min(rec(i + 1, j), rec(i, j + 1), rec(i + 1, j + 1));
return (memo[i][j] = res);
}
return rec(0, 0);
}5.3 Unique Paths (Grid DP)
// dp[i][j] = number of unique paths to reach (i,j) from (0,0)
// LC 62, 63 (with obstacles)
function uniquePaths(m: number, n: number): number {
const dp: number[][] = Array.from({ length: m }, () => new Array(n).fill(1));
// First row and column are all 1 (only one way to move along an edge)
for (let i = 1; i < m; i++) {
for (let j = 1; j < n; j++) {
dp[i][j] = dp[i - 1][j] + dp[i][j - 1]; // from top + from left
}
}
return dp[m - 1][n - 1];
}
// LC 63 — With obstacles:
function uniquePathsWithObstacles(grid: number[][]): number {
const m = grid.length,
n = grid[0].length;
if (grid[0][0] === 1 || grid[m - 1][n - 1] === 1) return 0;
const dp: number[][] = Array.from({ length: m }, () => new Array(n).fill(0));
dp[0][0] = 1;
// Fill first row
for (let j = 1; j < n; j++) dp[0][j] = grid[0][j] === 1 ? 0 : dp[0][j - 1];
// Fill first col
for (let i = 1; i < m; i++) dp[i][0] = grid[i][0] === 1 ? 0 : dp[i - 1][0];
for (let i = 1; i < m; i++) {
for (let j = 1; j < n; j++) {
dp[i][j] = grid[i][j] === 1 ? 0 : dp[i - 1][j] + dp[i][j - 1];
}
}
return dp[m - 1][n - 1];
}// TOP-DOWN — rec(i, j) = # paths from (i, j) to the bottom-right corner (only right/down remain)
// LC 62 — same idea extends to the obstacle grid (LC 63): return 0 the moment a cell is blocked
function uniquePaths_rec(m: number, n: number): number {
const memo: number[][] = Array.from({ length: m }, () => new Array(n).fill(-1));
function rec(i: number, j: number): number {
if (i === m - 1 || j === n - 1) return 1;
if (memo[i][j] !== -1) return memo[i][j];
return (memo[i][j] = rec(i + 1, j) + rec(i, j + 1));
}
return rec(0, 0);
}5.4 Distinct Subsequences
// dp[i][j] = # of times s[0..j-1] appears as subseq in t[0..i-1]
// LC 115 — Tricky recurrence
function numDistinct(s: string, t: string): number {
const m = s.length,
n = t.length;
// dp[i][j]: ways to form t[0..j-1] from s[0..i-1]
const dp: number[][] = Array.from({ length: m + 1 }, () =>
new Array(n + 1).fill(0),
);
// Empty t can always be formed (by taking 0 chars)
for (let i = 0; i <= m; i++) dp[i][0] = 1;
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
dp[i][j] = dp[i - 1][j]; // always: skip s[i-1]
if (s[i - 1] === t[j - 1]) {
dp[i][j] += dp[i - 1][j - 1]; // use s[i-1] to match t[j-1]
}
}
}
return dp[m][n];
}
// Time: O(m*n), Space: O(m*n)
// TRAP: Initialize dp[i][0] = 1 for all i. Never dp[0][j>0] = 1.// TOP-DOWN — rec(i, j) = ways to form t[j..n-1] as a subsequence of s[i..m-1]
// LC 115
function numDistinct_rec(s: string, t: string): number {
const m = s.length, n = t.length;
const memo: number[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(-1));
function rec(i: number, j: number): number {
if (j === n) return 1;
if (i === m) return 0;
if (memo[i][j] !== -1) return memo[i][j];
let res = rec(i + 1, j);
if (s[i] === t[j]) res += rec(i + 1, j + 1);
return (memo[i][j] = res);
}
return rec(0, 0);
}5.5 Maximal Square (2D DP on Grid)
// dp[i][j] = side length of largest all-1 square with bottom-right corner at (i,j)
// LC 221 — Beautiful DP insight
function maximalSquare(matrix: string[][]): number {
const m = matrix.length,
n = matrix[0].length;
const dp: number[][] = Array.from({ length: m + 1 }, () =>
new Array(n + 1).fill(0),
);
let maxSide = 0;
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
if (matrix[i - 1][j - 1] === "1") {
// Bottleneck = smallest of the three neighbors
dp[i][j] = Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]) + 1;
maxSide = Math.max(maxSide, dp[i][j]);
}
}
}
return maxSide * maxSide; // area = side²
}
// Time: O(m*n), Space: O(m*n)
// ⭐ The recurrence min(top, left, top-left) + 1 is the key insight.
// Visualize: you can only extend a square if all three neighbors are squares.// TOP-DOWN — rec(i, j) = side of the largest all-1 square with its TOP-LEFT corner at (i, j)
// Mirror image of the bottom-right recurrence: same min(...)+1 identity, reflected. LC 221
function maximalSquare_rec(matrix: string[][]): number {
const m = matrix.length, n = matrix[0].length;
const memo: number[][] = Array.from({ length: m }, () => new Array(n).fill(-1));
function rec(i: number, j: number): number {
if (i >= m || j >= n || matrix[i][j] === "0") return 0;
if (memo[i][j] !== -1) return memo[i][j];
const side = 1 + Math.min(rec(i + 1, j), rec(i, j + 1), rec(i + 1, j + 1));
return (memo[i][j] = side);
}
let maxSide = 0;
for (let i = 0; i < m; i++)
for (let j = 0; j < n; j++) maxSide = Math.max(maxSide, rec(i, j));
return maxSide * maxSide;
}5.6 DP + Monotonic Stack — O(n) Optimization
WHEN TO COMBINE DP WITH MONOTONIC STACK:
• DP recurrence is dp[i] = dp[j] + cost where j < i and cost depends on a
"nearest smaller / larger" element → stack finds that j in O(1) amortized.
• "For each element, find the nearest smaller element to the left/right" — classic stack signal.
• Results in O(n²) → O(n) optimization over naive DP.Sum of Subarray Minimums (LC 907)
// For each element arr[i], count how many subarrays have arr[i] as minimum.
// Contribution = arr[i] * (left_count) * (right_count)
// Monotonic stack finds nearest smaller element on left and right in O(n).
function sumSubarrayMins(arr: number[]): number {
const MOD = 1_000_000_007n;
const n = arr.length;
const left = new Array(n); // left[i] = distance to nearest smaller on left
const right = new Array(n); // right[i] = distance to nearest smaller on right
const stack: number[] = [];
// Left pass: nearest strictly smaller to the left
for (let i = 0; i < n; i++) {
while (stack.length && arr[stack[stack.length - 1]] >= arr[i]) stack.pop();
left[i] = stack.length === 0 ? i + 1 : i - stack[stack.length - 1];
stack.push(i);
}
stack.length = 0;
// Right pass: nearest smaller or equal to the right (use >= vs > to avoid double-counting)
for (let i = n - 1; i >= 0; i--) {
while (stack.length && arr[stack[stack.length - 1]] > arr[i]) stack.pop();
right[i] = stack.length === 0 ? n - i : stack[stack.length - 1] - i;
stack.push(i);
}
let ans = 0n;
for (let i = 0; i < n; i++) {
ans = (ans + BigInt(arr[i]) * BigInt(left[i]) * BigInt(right[i])) % MOD;
}
return Number(ans);
}
// Time: O(n), Space: O(n)
// ⭐ KEY: left[i] * right[i] = number of subarrays where arr[i] is the minimum.
// Use BigInt for the multiplication to avoid overflow.Min Cost Tree From Leaf Values (LC 1130)
// dp[i][j] = min sum of non-leaf nodes for arr[i..j]
// Naive interval DP: O(n³). Monotonic stack insight: O(n).
// INSIGHT: Remove the smaller of each adjacent pair, cost = pair product.
// Greedy with monotonic stack simulates "always remove smallest leaf first".
function mctFromLeafValues(arr: number[]): number {
let result = 0;
const stack: number[] = [Infinity]; // monotonic decreasing stack
for (const val of arr) {
while (stack[stack.length - 1] <= val) {
const mid = stack.pop()!;
// mid is the smallest leaf adjacent to both stack.top and val
result += mid * Math.min(stack[stack.length - 1], val);
}
stack.push(val);
}
// Remaining elements in stack must be multiplied pairwise
while (stack.length > 2) {
result += stack.pop()! * stack[stack.length - 1];
}
return result;
}
// Time: O(n), Space: O(n)
// Equivalent to O(n³) interval DP but uses the greedy observation that
// the minimum element should always be paired with the smaller of its neighbors.MONOTONIC STACK DP RECOGNITION SIGNALS:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
□ "sum involving subarray mins/maxes" → LC 907, 2104
□ "remove elements, cost = product" → LC 1130
□ "largest rectangle" → LC 84 (stack directly)
□ "max rectangle in histogram" → LC 85 = LC 84 row by row
□ dp[i] depends on "last smaller/larger" → replace inner loop with stack
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━6. INTERVAL DP TEMPLATES
Solve small ranges first; combine over a split point k in (i, j).
O(n³).Mentor note: Interval DP is the hardest flavor to recognize. The giveaway is: “operate on a subarray/substring and combine two pieces”. Always think about what happens LAST (the last balloon popped, the last palindrome cut, etc.).
6.1 Burst Balloons (Interval DP Classic)
// dp[i][j] = max coins from bursting ALL balloons between i and j (exclusive)
// LC 312 — Think "what is the LAST balloon to burst?"
function maxCoins(nums: number[]): number {
// Add sentinel balloons at boundaries (value 1)
const arr = [1, ...nums, 1];
const n = arr.length;
// dp[i][j] = max coins from open interval (i, j), bursting all inside
const dp: number[][] = Array.from({ length: n }, () => new Array(n).fill(0));
// Fill by increasing length of interval
for (let len = 2; len < n; len++) {
// len = gap between endpoints
for (let i = 0; i + len < n; i++) {
const j = i + len;
for (let k = i + 1; k < j; k++) {
// k = LAST balloon to burst in (i,j)
dp[i][j] = Math.max(
dp[i][j],
arr[i] * arr[k] * arr[j] + dp[i][k] + dp[k][j],
);
}
}
}
return dp[0][n - 1];
}
// Time: O(n³), Space: O(n²)
// KEY INSIGHT: By choosing k as the LAST to burst (not first), i and j are
// still present when k bursts, making arr[i]*arr[k]*arr[j] correct.// TOP-DOWN — rec(i, j) = max coins bursting everything strictly between i and j
// Interval DP has no forward/backward axis — recursion picks the LAST balloon to burst,
// same choice the bottom-up fill makes by increasing interval length. LC 312
function maxCoins_rec(nums: number[]): number {
const arr = [1, ...nums, 1];
const n = arr.length;
const memo: number[][] = Array.from({ length: n }, () => new Array(n).fill(-1));
function rec(i: number, j: number): number {
if (j - i < 2) return 0;
if (memo[i][j] !== -1) return memo[i][j];
let best = 0;
for (let k = i + 1; k < j; k++) {
best = Math.max(best, arr[i] * arr[k] * arr[j] + rec(i, k) + rec(k, j));
}
return (memo[i][j] = best);
}
return rec(0, n - 1);
}6.2 Longest Palindromic Substring (LC 5)
// isPalin[i][j] = true if s[i..j] reads the same both ways — the exact table §6.4 builds too.
// LC 5 — track the (start, maxLen) of the longest true cell instead of counting cuts.
function longestPalindrome(s: string): string {
const n = s.length;
const isPalin: boolean[][] = Array.from({ length: n }, () => new Array(n).fill(false));
let start = 0, maxLen = 1;
for (let i = n - 1; i >= 0; i--) {
for (let j = i; j < n; j++) {
isPalin[i][j] = s[i] === s[j] && (j - i <= 2 || isPalin[i + 1][j - 1]);
if (isPalin[i][j] && j - i + 1 > maxLen) {
start = i;
maxLen = j - i + 1;
}
}
}
return s.slice(start, start + maxLen);
}
// Time: O(n²), Space: O(n²) — an O(1)-space alternative is "expand around center"
// (try every i as a center, grow outward while chars match); same complexity, no table.
// NOT the same as Longest Common Substring: substring RESETS to 0 on a mismatch;
// LCS (§5.1) is a subsequence and never resets, it just skips the mismatched char.6.3 Longest Palindromic Subsequence
// dp[i][j] = length of LPS in s[i..j]
// LC 516
function longestPalindromeSubseq(s: string): number {
const n = s.length;
const dp: number[][] = Array.from({ length: n }, () => new Array(n).fill(0));
// Base case: single characters are palindromes of length 1
for (let i = 0; i < n; i++) dp[i][i] = 1;
// Fill for increasing lengths
for (let len = 2; len <= n; len++) {
for (let i = 0; i <= n - len; i++) {
const j = i + len - 1;
if (s[i] === s[j]) {
dp[i][j] = (len === 2 ? 0 : 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];
}
// Time: O(n²), Space: O(n²)
// RELATIONSHIP: LPS(s) = LCS(s, reverse(s))
// For min insertions to make palindrome: n - LPS(s) (LC 1312)// TOP-DOWN — rec(i, j) = LPS length within s[i..j], shrinking inward from both ends
// LC 516
function longestPalindromeSubseq_rec(s: string): number {
const n = s.length;
const memo: number[][] = Array.from({ length: n }, () => new Array(n).fill(-1));
function rec(i: number, j: number): number {
if (i > j) return 0;
if (i === j) return 1;
if (memo[i][j] !== -1) return memo[i][j];
let res: number;
if (s[i] === s[j]) res = 2 + rec(i + 1, j - 1);
else res = Math.max(rec(i + 1, j), rec(i, j - 1));
return (memo[i][j] = res);
}
return rec(0, n - 1);
}6.4 Palindrome Partitioning II — Min Cuts
// cuts[i] = min cuts for s[0..i]
// LC 132 — Combine palindrome check with 1D DP
function minCut(s: string): number {
const n = s.length;
// Precompute isPalin[i][j] using DP (expand around center)
const isPalin: boolean[][] = Array.from({ length: n }, () =>
new Array(n).fill(false),
);
for (let i = n - 1; i >= 0; i--) {
for (let j = i; j < n; j++) {
isPalin[i][j] = s[i] === s[j] && (j - i <= 2 || isPalin[i + 1][j - 1]);
}
}
// cuts[i] = min cuts for s[0..i]
const cuts = new Array(n).fill(0);
for (let i = 0; i < n; i++) {
if (isPalin[0][i]) {
cuts[i] = 0;
continue;
} // whole prefix is palindrome
cuts[i] = i; // worst case: cut after every character
for (let j = 1; j <= i; j++) {
if (isPalin[j][i]) {
cuts[i] = Math.min(cuts[i], cuts[j - 1] + 1);
}
}
}
return cuts[n - 1];
}
// Time: O(n²), Space: O(n²) for isPalin precomputation// TOP-DOWN — rec(i) = min cuts for s[i..n-1]; peels the FIRST palindrome piece off the front
// Sentinel rec(n) = -1 lets a whole-suffix palindrome cost exactly 0 cuts (1 + -1). LC 132
function minCut_rec(s: string): number {
const n = s.length;
const isPalin: boolean[][] = Array.from({ length: n }, () => new Array(n).fill(false));
for (let i = n - 1; i >= 0; i--)
for (let j = i; j < n; j++)
isPalin[i][j] = s[i] === s[j] && (j - i <= 2 || isPalin[i + 1][j - 1]);
const memo = new Array(n).fill(-2);
function rec(i: number): number {
if (i === n) return -1;
if (memo[i] !== -2) return memo[i];
let best = Infinity;
for (let j = i; j < n; j++) {
if (isPalin[i][j]) best = Math.min(best, 1 + rec(j + 1));
}
return (memo[i] = best);
}
return rec(0);
}7. STRING DP TEMPLATES
Two strings, position by position — dp[i][j] asks "does the prefix match so far?"
* collapsing to zero repeats. Why that's safe: every transition consumes at least one character from i or j (or explicitly falls back to a smaller j via *), so the dependency always shrinks — filling i,j in increasing order (or memoizing top-down) guarantees no state is read before it's resolved.* (match zero or more), which is where most candidates freeze. Nail it and they escalate: wildcard matching → regex matching (the harder * semantics, tied to the char before it) → interleaving string (two source pointers into one target) → distinct subsequences (counting, not booleans).7.1 Regex / Wildcard Matching
// dp[i][j] = does p[0..j-1] match s[0..i-1]?
// LC 10 (regex: . and *) — The hardest string DP
function isMatch_regex(s: string, p: string): boolean {
const m = s.length,
n = p.length;
const dp: boolean[][] = Array.from({ length: m + 1 }, () =>
new Array(n + 1).fill(false),
);
dp[0][0] = true;
// Handle patterns like a*, a*b*, a*b*c* matching empty string
for (let j = 2; j <= n; j++) {
if (p[j - 1] === "*") dp[0][j] = dp[0][j - 2];
}
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
if (p[j - 1] === "*") {
// '*' matches zero of preceding char: dp[i][j-2]
// '*' matches one more: dp[i-1][j] if p[j-2] matches s[i-1]
dp[i][j] =
dp[i][j - 2] ||
(dp[i - 1][j] && (p[j - 2] === "." || p[j - 2] === s[i - 1]));
} else if (p[j - 1] === "." || p[j - 1] === s[i - 1]) {
dp[i][j] = dp[i - 1][j - 1]; // direct match
}
}
}
return dp[m][n];
}
// Time: O(m*n), Space: O(m*n)
// LC 44 (wildcard: ? and *) — same table shape, DIFFERENT semantics for '*':
// regex '*' means "zero or more of the PRECEDING char" (always paired: a*, .*).
// wildcard '*' is its OWN token meaning "zero or more of ANY chars" — no pairing.
function isMatch_wildcard(s: string, p: string): boolean {
const m = s.length,
n = p.length;
const dp: boolean[][] = Array.from({ length: m + 1 }, () =>
new Array(n + 1).fill(false),
);
dp[0][0] = true;
for (let j = 1; j <= n; j++) {
if (p[j - 1] === "*") dp[0][j] = dp[0][j - 1]; // '*' alone can match empty
}
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
if (p[j - 1] === "*") {
// '*' matches EMPTY here: dp[i][j-1] OR consumes s[i-1]: dp[i-1][j]
dp[i][j] = dp[i][j - 1] || dp[i - 1][j];
} else if (p[j - 1] === "?" || p[j - 1] === s[i - 1]) {
dp[i][j] = dp[i - 1][j - 1];
}
}
}
return dp[m][n];
}
// Time: O(m*n), Space: O(m*n)// TOP-DOWN — rec(i, j) = does s[i..m-1] match p[j..n-1]? Consume both strings left→right
// LC 10 — the '*' lookahead (p[j+1]) is the only tricky part; same logic as the table, just recursive
function isMatch_regex_rec(s: string, p: string): boolean {
const m = s.length, n = p.length;
const memo = new Map<string, boolean>();
function rec(i: number, j: number): boolean {
if (j === n) return i === m;
const key = i + "," + j;
if (memo.has(key)) return memo.get(key)!;
const firstMatch = i < m && (p[j] === "." || p[j] === s[i]);
let res: boolean;
if (j + 1 < n && p[j + 1] === "*") {
res = rec(i, j + 2) || (firstMatch && rec(i + 1, j));
} else {
res = firstMatch && rec(i + 1, j + 1);
}
memo.set(key, res);
return res;
}
return rec(0, 0);
}7.2 Interleaving String
// dp[i][j] = can s3[0..i+j-1] be formed by interleaving s1[0..i-1] and s2[0..j-1]?
// LC 97
function isInterleave(s1: string, s2: string, s3: string): boolean {
const m = s1.length,
n = s2.length;
if (m + n !== s3.length) return false;
const dp: boolean[][] = Array.from({ length: m + 1 }, () =>
new Array(n + 1).fill(false),
);
dp[0][0] = true;
for (let i = 1; i <= m; i++)
dp[i][0] = dp[i - 1][0] && s1[i - 1] === s3[i - 1];
for (let j = 1; j <= n; j++)
dp[0][j] = dp[0][j - 1] && s2[j - 1] === s3[j - 1];
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
dp[i][j] =
(dp[i - 1][j] && s1[i - 1] === s3[i + j - 1]) ||
(dp[i][j - 1] && s2[j - 1] === s3[i + j - 1]);
}
}
return dp[m][n];
}// TOP-DOWN — rec(i, j) = can s1[i..], s2[j..] interleave to form the rest of s3?
// LC 97
function isInterleave_rec(s1: string, s2: string, s3: string): boolean {
const m = s1.length, n = s2.length;
if (m + n !== s3.length) return false;
const memo: (boolean | undefined)[][] = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(undefined));
function rec(i: number, j: number): boolean {
if (i === m && j === n) return true;
if (memo[i][j] !== undefined) return memo[i][j]!;
const k = i + j;
let res = false;
if (i < m && s1[i] === s3[k]) res = rec(i + 1, j);
if (!res && j < n && s2[j] === s3[k]) res = rec(i, j + 1);
return (memo[i][j] = res);
}
return rec(0, 0);
}8. KNAPSACK FAMILY TEMPLATES
Each item: take or skip, bounded by capacity.
The knapsack is the most disguised DP. “Partition into equal subset”, “target sum”, “coin change”—they’re all knapsack under the hood. Learn to see through the disguise.
8.1 0/1 Knapsack — Classic
// Each item used at most once. Maximize value subject to weight capacity.
// Foundation for LC 416, 494, 1049
function knapsack01(
weights: number[],
values: number[],
capacity: number,
): number {
const n = weights.length;
// dp[w] = max value using capacity w
const dp = new Array(capacity + 1).fill(0);
for (let i = 0; i < n; i++) {
// ⭐ CRITICAL: Traverse RIGHT TO LEFT to avoid using item i twice
for (let w = capacity; w >= weights[i]; w--) {
dp[w] = Math.max(dp[w], dp[w - weights[i]] + values[i]);
}
}
return dp[capacity];
}
// Time: O(n * capacity), Space: O(capacity)
// WHY RIGHT TO LEFT: If we go left-to-right, dp[w - weights[i]] might already
// reflect item i being used, causing it to be used multiple times.// TOP-DOWN — rec(i, w) = max value using items[i..n-1] with remaining capacity w
// ⭐ The (item, capacity) state means no "traverse right-to-left" trick is needed here — each
// item's OWN recursive call already fixes whether it was taken, so it can never be reused.
function knapsack01_rec(weights: number[], values: number[], capacity: number): number {
const n = weights.length;
const memo: number[][] = Array.from({ length: n }, () => new Array(capacity + 1).fill(-1));
function rec(i: number, w: number): number {
if (i === n) return 0;
if (memo[i][w] !== -1) return memo[i][w];
let res = rec(i + 1, w);
if (weights[i] <= w) res = Math.max(res, values[i] + rec(i + 1, w - weights[i]));
return (memo[i][w] = res);
}
return rec(0, capacity);
}8.2 0/1 Knapsack — Boolean Variant (Partition Equal Subset Sum)
// dp[w] = can we achieve sum w using a subset?
// LC 416
function canPartition(nums: number[]): boolean {
const total = nums.reduce((a, b) => a + b, 0);
if (total % 2 !== 0) return false;
const target = total / 2;
const dp = new Array(target + 1).fill(false);
dp[0] = true; // empty subset sums to 0
for (const num of nums) {
for (let w = target; w >= num; w--) {
// Right to left!
dp[w] = dp[w] || dp[w - num];
}
}
return dp[target];
}
// Time: O(n * target), Space: O(target)// TOP-DOWN — rec(i, w) = can nums[i..n-1] form exactly sum w?
// LC 416 — same item-index trick as 8.1: no ordering gotcha to remember
function canPartition_rec(nums: number[]): boolean {
const total = nums.reduce((a, b) => a + b, 0);
if (total % 2 !== 0) return false;
const target = total / 2;
const n = nums.length;
const memo: (boolean | undefined)[][] = Array.from({ length: n + 1 }, () => new Array(target + 1).fill(undefined));
function rec(i: number, w: number): boolean {
if (w === 0) return true;
if (i === n) return false;
if (memo[i][w] !== undefined) return memo[i][w]!;
let res = rec(i + 1, w);
if (!res && nums[i] <= w) res = rec(i + 1, w - nums[i]);
return (memo[i][w] = res);
}
return rec(0, target);
}8.3 0/1 Knapsack — Count Variant (Target Sum)
// Count # subsets that sum to target
// LC 494 (with +/- signs)
function findTargetSumWays(nums: number[], target: number): number {
// Let P = set of positives, N = set of negatives
// P - N = target, P + N = total → P = (total + target) / 2
// Count subsets summing to (total + target) / 2
const total = nums.reduce((a, b) => a + b, 0);
if ((total + target) % 2 !== 0 || Math.abs(target) > total) return 0;
const sum = (total + target) / 2;
const dp = new Array(sum + 1).fill(0);
dp[0] = 1; // one way to reach sum 0: take nothing
for (const num of nums) {
for (let w = sum; w >= num; w--) {
// Right to left!
dp[w] += dp[w - num];
}
}
return dp[sum];
}
// Time: O(n * sum), Space: O(sum)
// ⭐ TRANSFORMATION TRICK: Convert +/- problem to subset sum by algebra.// TOP-DOWN — rec(i, remaining) = ways to assign +/- to nums[i..n-1] so they sum to `remaining`
// ⭐ DIRECT: recursion needs no subset-sum algebra transform — it just branches +nums[i] / -nums[i]
function findTargetSumWays_rec(nums: number[], target: number): number {
const n = nums.length;
const memo = new Map<string, number>();
function rec(i: number, remaining: number): number {
if (i === n) return remaining === 0 ? 1 : 0;
const key = i + "," + remaining;
if (memo.has(key)) return memo.get(key)!;
const res = rec(i + 1, remaining - nums[i]) + rec(i + 1, remaining + nums[i]);
memo.set(key, res);
return res;
}
return rec(0, target);
}8.4 Unbounded Knapsack — Coin Change (Min Coins)
// Each coin can be used unlimited times. Minimize # coins.
// LC 322
function coinChange(coins: number[], amount: number): number {
const dp = new Array(amount + 1).fill(Infinity);
dp[0] = 0;
for (let w = 1; w <= amount; w++) {
for (const coin of coins) {
if (coin <= w && dp[w - coin] !== Infinity) {
dp[w] = Math.min(dp[w], dp[w - coin] + 1);
}
}
}
return dp[amount] === Infinity ? -1 : dp[amount];
}
// Time: O(amount * #coins), Space: O(amount)
// ⭐ UNBOUNDED: Traverse LEFT TO RIGHT (item can be reused)
// vs 0/1 Knapsack: RIGHT TO LEFT (each item once)// TOP-DOWN — rec(rem) = min coins to make exactly `rem`
// Unbounded: no item index needed at all — any coin can repeat, so `rem` alone is the whole state
function coinChange_rec(coins: number[], amount: number): number {
const memo = new Array(amount + 1).fill(-2);
function rec(rem: number): number {
if (rem === 0) return 0;
if (rem < 0) return Infinity;
if (memo[rem] !== -2) return memo[rem];
let best = Infinity;
for (const coin of coins) best = Math.min(best, 1 + rec(rem - coin));
return (memo[rem] = best);
}
const ans = rec(amount);
return ans === Infinity ? -1 : ans;
}8.5 Unbounded Knapsack — Coin Change II (Count Ways)
// Count # ways to make amount using coins (unlimited each). Order doesn't matter.
// LC 518
function change(amount: number, coins: number[]): number {
const dp = new Array(amount + 1).fill(0);
dp[0] = 1;
// ⭐ OUTER LOOP = coins, INNER LOOP = amounts
// This ensures combinations (not permutations) — each coin subset counted once
for (const coin of coins) {
for (let w = coin; w <= amount; w++) {
dp[w] += dp[w - coin];
}
}
return dp[amount];
}
// Time: O(amount * #coins), Space: O(amount)
// COMPARISON:
// Outer=coins, Inner=amounts → COMBINATIONS (each subset once) ← Coin Change II
// Outer=amounts, Inner=coins → PERMUTATIONS (order matters) ← Climbing Stairs style// TOP-DOWN — rec(i, rem) = ways to make `rem` using coins[i..n-1], each coin reusable
// ⭐ Coin index only ever moves i→i+1 (never back) — THAT is what forces combinations, not
// permutations. The recursive mirror of the bottom-up "outer loop = coins" ordering trick.
function change_rec(amount: number, coins: number[]): number {
const n = coins.length;
const memo: number[][] = Array.from({ length: n }, () => new Array(amount + 1).fill(-1));
function rec(i: number, rem: number): number {
if (rem === 0) return 1;
if (i === n) return 0;
if (memo[i][rem] !== -1) return memo[i][rem];
let ways = rec(i + 1, rem);
if (coins[i] <= rem) ways += rec(i, rem - coins[i]);
return (memo[i][rem] = ways);
}
return rec(0, amount);
}9. STATE MACHINE DP TEMPLATES
Track a small set of states; transition each step.
dp[k][hold] generalization).Stock problems are THE state machine interview staple. At Meta and Google, I’ve seen a variant in 30% of DP rounds. Learn the machine, not the specific problem.
9.1 Stock Machine — 1 Transaction (LC 121)
// States: not_holding → holding → sold (done)
function maxProfit_1tx(prices: number[]): number {
let minPrice = Infinity;
let maxProfit = 0;
for (const price of prices) {
minPrice = Math.min(minPrice, price);
maxProfit = Math.max(maxProfit, price - minPrice);
}
return maxProfit;
}
// This is O(n) greedy. Underlying DP: dp[i][0/1] — same result.9.2 Stock Machine — Unlimited Transactions (LC 122)
function maxProfit_unlimited(prices: number[]): number {
let profit = 0;
for (let i = 1; i < prices.length; i++) {
if (prices[i] > prices[i - 1]) profit += prices[i] - prices[i - 1];
}
return profit;
}
// Greedy: capture every upward movement9.3 Stock Machine — With Cooldown (LC 309)
// States: holding, sold (cooldown), rest
// dp_hold[i] = max profit on day i if holding stock
// dp_sold[i] = max profit on day i if just sold (can't buy tomorrow)
// dp_rest[i] = max profit on day i if resting (can buy tomorrow)
function maxProfit_cooldown(prices: number[]): number {
let hold = -Infinity,
sold = 0,
rest = 0;
for (const price of prices) {
const prevHold = hold,
prevSold = sold,
prevRest = rest;
hold = Math.max(prevHold, prevRest - price); // keep holding OR buy from rest
sold = prevHold + price; // sell what we held
rest = Math.max(prevRest, prevSold); // stay resting OR come off cooldown
}
return Math.max(sold, rest);
}
// Time: O(n), Space: O(1)9.4 Stock Machine — With Transaction Fee (LC 714)
function maxProfit_fee(prices: number[], fee: number): number {
let hold = -prices[0]; // bought on day 0
let cash = 0; // not holding
for (let i = 1; i < prices.length; i++) {
hold = Math.max(hold, cash - prices[i]); // keep or buy
cash = Math.max(cash, hold + prices[i] - fee); // keep or sell (minus fee)
}
return cash;
}
// Time: O(n), Space: O(1)9.5 Stock Machine — At Most K Transactions (LC 188)
// dp[k][0/1] = max profit with at most k transactions, not holding / holding
// ⭐ SDE3 PROBLEM — requires correct state transitions
function maxProfit_kTransactions(k: number, prices: number[]): number {
const n = prices.length;
// If k >= n/2, unlimited transactions (edge case optimization)
if (k >= Math.floor(n / 2)) {
let profit = 0;
for (let i = 1; i < n; i++)
profit += Math.max(0, prices[i] - prices[i - 1]);
return profit;
}
// hold[j] = max profit with j transactions started, currently holding
// cash[j] = max profit with j transactions completed, not holding
const hold = new Array(k + 1).fill(-Infinity);
const cash = new Array(k + 1).fill(0);
for (const price of prices) {
for (let j = k; j >= 1; j--) {
cash[j] = Math.max(cash[j], hold[j] + price); // sell
hold[j] = Math.max(hold[j], cash[j - 1] - price); // buy
}
}
return cash[k];
}
// Time: O(n * k), Space: O(k)9.6 Game Theory / Minimax DP (Stone Game Family)
dp[i][j] depends on dp[i+1][j] / dp[i][j-1], filled by increasing length, which breaks §9's own "today depends only on yesterday" invariant above. It sits here because it's a distinct interview *flavor* to recognize ("two players, optimal play" → subtract, don't add), not because the DP shape matches stocks.PATTERN: Two players take turns optimally.
State: dp[i][j] = maximum ADVANTAGE (score_current - score_opponent) the CURRENT
player can achieve from stones/piles[i..j].
Key insight: "current player" changes with each recursive call — dp[i][j] always
represents the current player's best possible lead.
RECURRENCE (for symmetric "take from ends" games):
Take left: piles[i] - dp[i+1][j] ← current takes piles[i], opponent now leads
Take right: piles[j] - dp[i][j-1] ← current takes piles[j], opponent now leads
dp[i][j] = max(take left, take right)// LC 877 — Stone Game (can current player always win?)
// LC 486 — Predict the Winner (same recurrence, different framing)
function predictTheWinner(nums: number[]): boolean {
const n = nums.length;
// dp[i][j] = max score advantage current player can achieve over opponent
// from nums[i..j]
const dp: number[][] = Array.from({ length: n }, () => new Array(n).fill(0));
// Base case: single element, current player takes it all
for (let i = 0; i < n; i++) dp[i][i] = nums[i];
for (let len = 2; len <= n; len++) {
for (let i = 0; i <= n - len; i++) {
const j = i + len - 1;
// Take left: gain nums[i], opponent plays optimally on [i+1..j]
// Take right: gain nums[j], opponent plays optimally on [i..j-1]
dp[i][j] = Math.max(
nums[i] - dp[i + 1][j], // take left, opponent's lead becomes our deficit
nums[j] - dp[i][j - 1] // take right
);
}
}
// Current player wins if their advantage >= 0
return dp[0][n - 1] >= 0;
}
// Time: O(n²), Space: O(n²) → reducible to O(n) with rolling row
// Stone Game VII (LC 1690) — Remove EITHER end; score = SUM of what remains (not what you took)
function stoneGameVII(stones: number[]): number {
const n = stones.length;
const prefix = new Array(n + 1).fill(0);
for (let k = 0; k < n; k++) prefix[k + 1] = prefix[k] + stones[k];
const sum = (i: number, j: number) => prefix[j + 1] - prefix[i]; // sum stones[i..j]
// dp[i][j] = max score advantage current player can get from stones[i..j]
const dp: number[][] = Array.from({ length: n }, () => new Array(n).fill(0));
// Base case dp[i][i] = 0 — one stone left, no more moves, no more score
for (let len = 2; len <= n; len++) {
for (let i = 0; i <= n - len; i++) {
const j = i + len - 1;
dp[i][j] = Math.max(
sum(i + 1, j) - dp[i + 1][j], // remove left, score = sum of the rest
sum(i, j - 1) - dp[i][j - 1] // remove right, score = sum of the rest
);
}
}
return dp[0][n - 1];
}
// Time: O(n²), Space: O(n²)
// GOTCHA: the payoff is the sum of stones LEFT after removal, not the stone removed.Same family, rarer asks — recognize the trick, don't memorize the code:
| Variant | Core trick | LC |
|---|---|---|
| Stone Game II | Move size M grows with use — 3rd dp dimension dp[i][m], take 1..2M piles | 1140 |
| Stone Game III | Take from the FRONT only (not either end) — 1D dp[i], try taking 1/2/3 | 1406 |
| Stone Game VIII | "Choose x>1 stones" reframed as "choose a prefix-sum cut index" — collapses to a 1D suffix-max scan | 1872 |
| Stone Game IV | Not score-optimal, just win/lose — dp[i] = boolean, true if any perfect-square move leaves opponent losing | 1510 |
GAME THEORY DP MENTAL MODEL
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
"Can current player win?" → dp[i] = boolean (win/lose state)
"What is optimal score?" → dp[i][j] = score advantage (current - opponent)
KEY: dp[i][j] = ADVANTAGE of current player, not absolute score.
When you recurse: opponent's advantage = -dp[...], so current player's
optimal is: score_taken - (opponent's best advantage on remaining).
RECOGNIZE IT:
□ Two players alternate taking from array ends / choosing elements
□ Both play optimally
□ "Can player 1 win?" or "What is the maximum score for player 1?"
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━10. BITMASK DP TEMPLATES
A subset of ≤~20 items as an int; dp[mask] (sometimes + a node).
n ≤ ~20 (2ⁿ states), iterating submasks, and dp[mask][i] for TSP-style. · Escalation: shortest path visiting all nodes (847) → optimal assignment (1125) → partition to K equal subsets (698 — key on mask only) → min cost to connect all (TSP bottom-up).When to use: n ≤ 20 (usually ≤ 15 in practice), “visit/select a subset of items/nodes”, “TSP-like”, “collect keys”.
10.1 TSP-Style — Shortest Path Visiting All Nodes (LC 847)
// dp[mask][node] = min steps to reach 'node' having visited nodes in 'mask'
// LC 847
function shortestPathLength(graph: number[][]): number {
const n = graph.length;
const FULL = (1 << n) - 1; // all nodes visited bitmask
if (n === 1) return 0;
// BFS for shortest path (not DFS — we want minimum steps)
// State: [mask, node]
const dist: number[][] = Array.from({ length: 1 << n }, () =>
new Array(n).fill(Infinity),
);
const queue: [number, number][] = []; // [mask, node]
// Start from every node simultaneously (multi-source)
for (let i = 0; i < n; i++) {
dist[1 << i][i] = 0;
queue.push([1 << i, i]);
}
let head = 0;
while (head < queue.length) {
const [mask, node] = queue[head++];
const d = dist[mask][node];
if (mask === FULL) return d; // ✅ Found shortest path visiting all nodes
for (const neighbor of graph[node]) {
const newMask = mask | (1 << neighbor);
if (d + 1 < dist[newMask][neighbor]) {
dist[newMask][neighbor] = d + 1;
queue.push([newMask, neighbor]);
}
}
}
return -1;
}
// Time: O(2^n * n²), Space: O(2^n * n)The classic weighted-edge TSP ("min cost to connect all nodes") is the exact same dp[mask][node] shape as LC 847 above — swap the unit-step BFS cost for a real edge-weight sum in the transition.
10.2 Bitmask DP — Optimal Assignment (LC 1125)
// Assign skills to people to cover all skills with min people
// dp[mask] = min people to cover skill subset represented by mask
// LC 1125
function smallestSufficientTeam(
req_skills: string[],
people: number[][],
): number[] {
const skillIndex = new Map(req_skills.map((s, i) => [s, i]));
const m = req_skills.length;
const n = people.length;
const FULL = (1 << m) - 1;
// Precompute bitmask for each person's skills
const personMask = people.map((skills) =>
skills.reduce((mask, s) => mask | (1 << skillIndex.get(s)!), 0),
);
// dp[mask] = min team indices to cover 'mask' skills; null = not yet reached
const dp: (number[] | null)[] = new Array(1 << m).fill(null);
dp[0] = []; // empty team covers no skills
for (let mask = 0; mask <= FULL; mask++) {
if (dp[mask] === null) continue; // skip states not yet reached
for (let p = 0; p < n; p++) {
const newMask = mask | personMask[p];
const candidate = [...dp[mask], p];
if (dp[newMask] === null || candidate.length < dp[newMask].length) {
dp[newMask] = candidate;
}
}
}
return dp[FULL]!;
}
// Time: O(2^m * n), Space: O(2^m)10.3 Bitmask DP — Partition to K Equal Subsets (LC 698 / 1723)
TRAP: "partition into k equal-sum subsets" is NOT plain subset-sum knapsack when
k > 2. You must track WHICH elements are used across ALL k buckets at once.
State = bitmask of used elements. dp[mask] = remainder of the CURRENT bucket
being filled (sum so far mod target). One pass fills bucket after bucket.// LC 698 — Partition to K Equal Sum Subsets (n ≤ 16 → bitmask)
function canPartitionKSubsets(nums: number[], k: number): boolean {
const total = nums.reduce((a, b) => a + b, 0);
if (total % k !== 0) return false;
const target = total / k;
if (Math.max(...nums) > target) return false;
const n = nums.length;
const FULL = (1 << n) - 1;
// dp[mask] = remaining capacity of the bucket currently being filled,
// given that 'mask' elements are already placed.
// -1 = state unreachable. When mask=FULL, all k buckets filled exactly.
const dp = new Array(1 << n).fill(-1);
dp[0] = 0; // no elements used, current bucket empty (used 0 of target)
for (let mask = 0; mask <= FULL; mask++) {
if (dp[mask] === -1) continue; // unreachable
for (let i = 0; i < n; i++) {
if (mask & (1 << i)) continue; // element i already used
if (dp[mask] + nums[i] > target) continue; // overflows current bucket
const next = mask | (1 << i);
// (dp[mask] + nums[i]) % target: when a bucket hits target it wraps to 0,
// automatically starting the next bucket.
dp[next] = (dp[mask] + nums[i]) % target;
}
}
return dp[FULL] === 0;
}
// Time: O(2^n · n), Space: O(2^n)
// ⭐ KEY INSIGHT: dp[mask] = fill level of the CURRENT bucket (mod target).
// Wrapping mod target seamlessly rolls into the next bucket — no need to
// track bucket index separately.
// SDE3 SIGNAL: "k>2 equal partition isn't subset-sum; I track a used-bitmask
// and the running fill of one bucket at a time, sorting desc to prune faster."
// PRUNE: sort nums descending first → fails fast, large constant-factor speedup.11. TREE DP TEMPLATES
Post-order: combine children's returns; sometimes "return one, record another."
[rob, skip]) → diameter (543) → rerooting DP (834 — two passes: down, then re-root).11.1 Tree DP — Max Path Sum (LC 124)
// ⭐ Two different DPs in one problem:
// 1. maxGain(node) = max contribution from this node going UP (used internally)
// 2. global maxPath updated at each node (the actual answer)
function maxPathSum(root: TreeNode | null): number {
let globalMax = -Infinity;
function maxGain(node: TreeNode | null): number {
if (!node) return 0;
// Only extend if positive (don't take negative branches)
const leftGain = Math.max(0, maxGain(node.left));
const rightGain = Math.max(0, maxGain(node.right));
// ⭐ Update global max: path that TURNS at this node (can't extend further up)
globalMax = Math.max(globalMax, node.val + leftGain + rightGain);
// Return: max linear extension going UP (can only use ONE branch)
return node.val + Math.max(leftGain, rightGain);
}
maxGain(root);
return globalMax;
}
// Time: O(n), Space: O(h) call stack
// KEY: Return value ≠ answer. Return value = contribution to parent. Answer = globalMax.11.2 Tree DP — House Robber III (LC 337)
// dp[node] = [max if not robbed, max if robbed]
function rob_tree(root: TreeNode | null): number {
function dp(node: TreeNode | null): [number, number] {
// [not robbed, robbed]
if (!node) return [0, 0];
const [leftNotRobbed, leftRobbed] = dp(node.left);
const [rightNotRobbed, rightRobbed] = dp(node.right);
// If we DON'T rob this node, children can be robbed or not
const notRobbed =
Math.max(leftNotRobbed, leftRobbed) +
Math.max(rightNotRobbed, rightRobbed);
// If we DO rob this node, children CANNOT be robbed
const robbed = node.val + leftNotRobbed + rightNotRobbed;
return [notRobbed, robbed];
}
const [notRobbed, robbed] = dp(root);
return Math.max(notRobbed, robbed);
}
// Time: O(n), Space: O(h)11.3 Tree DP — Diameter of Binary Tree (LC 543)
function diameterOfBinaryTree(root: TreeNode | null): number {
let maxDiameter = 0;
function height(node: TreeNode | null): number {
if (!node) return 0;
const left = height(node.left);
const right = height(node.right);
maxDiameter = Math.max(maxDiameter, left + right); // diameter through this node
return 1 + Math.max(left, right); // height returned to parent
}
height(root);
return maxDiameter;
}11.4 Tree Rerooting DP (Sum of Distances) — LC 834
PATTERN: compute an answer for EVERY node as root, in O(n) total (not O(n²)).
TWO PASSES on a general tree (adjacency list, n nodes):
Pass 1 (post-order, root at 0): for each node compute
count[v] = size of its subtree, res[0] = sum of distances from node 0.
Pass 2 (pre-order, push down): derive res[child] from res[parent] in O(1):
res[child] = res[parent] - count[child] + (n - count[child])
(moving the root across edge parent→child: count[child] nodes get 1 closer,
the other n - count[child] nodes get 1 farther.)// LC 834 — Sum of Distances in Tree. Answer[i] = sum of dist from i to all nodes.
function sumOfDistancesInTree(n: number, edges: number[][]): number[] {
const g: number[][] = Array.from({ length: n }, () => []);
for (const [a, b] of edges) { g[a].push(b); g[b].push(a); }
const count = new Array(n).fill(1); // subtree sizes (rooted at 0)
const res = new Array(n).fill(0); // answer per node
// Pass 1: post-order from root 0. Accumulate res[0] and subtree counts.
function post(node: number, parent: number): void {
for (const child of g[node]) {
if (child === parent) continue;
post(child, node);
count[node] += count[child];
// distances from node to its subtree = child's contribution + one edge each
res[node] += res[child] + count[child];
}
}
// Pass 2: pre-order. Reroot from parent to child in O(1).
function pre(node: number, parent: number): void {
for (const child of g[node]) {
if (child === parent) continue;
res[child] = res[node] - count[child] + (n - count[child]);
pre(child, node);
}
}
post(0, -1);
pre(0, -1);
return res;
}
// Time: O(n), Space: O(n)
// ⭐ KEY INSIGHT: Don't recompute each root from scratch (O(n²)). Compute ONE root
// fully, then slide the root along each edge with an O(1) update formula.
// SDE3 SIGNAL: "This is a rerooting problem — I compute the answer at one root in a
// post-order pass, then propagate to all other roots in a pre-order pass, O(n)."
// USE WHEN: "answer for every node treated as root", "for each starting node ...".12. DP ON DAG / DIGIT DP
The graph itself is the recursion — memoize per node/position, no explicit "smaller n" needed.
12.1 DP on DAG — Longest Path (LC 329)
// dp[i] = length of longest increasing path starting at cell i
// LC 329 — Memoized DFS on implicit DAG
function longestIncreasingPath(matrix: number[][]): number {
const m = matrix.length,
n = matrix[0].length;
const memo: number[][] = Array.from({ length: m }, () =>
new Array(n).fill(0),
);
const dirs = [
[0, 1],
[0, -1],
[1, 0],
[-1, 0],
];
function dfs(r: number, c: number): number {
if (memo[r][c] !== 0) return memo[r][c]; // cache hit
let best = 1; // min path length is 1 (just this cell)
for (const [dr, dc] of dirs) {
const nr = r + dr,
nc = c + dc;
if (
nr >= 0 &&
nr < m &&
nc >= 0 &&
nc < n &&
matrix[nr][nc] > matrix[r][c]
) {
best = Math.max(best, 1 + dfs(nr, nc));
}
}
memo[r][c] = best;
return best;
}
let ans = 0;
for (let r = 0; r < m; r++) {
for (let c = 0; c < n; c++) {
ans = Math.max(ans, dfs(r, c));
}
}
return ans;
}
// Time: O(m*n) — each cell computed once. Space: O(m*n)
// KEY: No visited set needed because we only move to STRICTLY LARGER values (DAG).12.2 Digit DP — Count Numbers ≤ N with Digit Sum == target
dp(pos, tight, sum) = how many ways to fill the remaining digit positions so the FULL number ends with digit-sum == target. Say the state out loud before coding — that's the whole skill.
// Count integers in [0, n] whose digits sum to exactly `target`. LC-style: 233/902/1397 family.
// tight = true → every digit chosen so far exactly matches n's prefix, so THIS digit
// is capped at digits[pos] (going higher would exceed n).
// tight = false → we already placed a digit strictly below n's prefix, so we're
// free to use any digit 0-9 from here on (can't exceed n anymore).
function countWithDigitSum(n: number, target: number): number {
const digits = String(n).split("").map(Number);
const len = digits.length;
const memo = new Map<string, number>();
function dp(pos: number, tight: boolean, sum: number): number {
if (sum > target) return 0; // prune — no way to recover
if (pos === len) return sum === target ? 1 : 0;
const key = `${pos},${tight},${sum}`;
if (!tight && memo.has(key)) return memo.get(key)!;
const limit = tight ? digits[pos] : 9;
let result = 0;
for (let d = 0; d <= limit; d++) {
result += dp(pos + 1, tight && d === limit, sum + d);
}
if (!tight) memo.set(key, result); // only "free" states repeat — tight states are O(len) total anyway
return result;
}
return dp(0, true, 0);
}
// Time: O(len * target * 10), Space: O(len * target)
// SAME SKELETON solves related asks — just swap what `sum` tracks and the base-case check:
// "count numbers with all distinct digits" → sum becomes a used-digits bitmask.🗂️ RARE VARIANTS — KNOW THE PATTERN, SKIP THE FULL CODE
These show up rarely enough that memorizing the full implementation isn't worth it — but naming the pattern and its core trick the moment you recognize it is exactly the SDE3 signal from §14.
| Pattern | Core insight | LC example |
|---|---|---|
| Matrix Exponentiation DP | Linear recurrence (like Fibonacci) as a k×k matrix power — O(log N) instead of O(N) when N is huge (10¹⁸+). Use BigInt + MOD. | 509, 1137, 790 |
| Interval Cut / Merge Stones | dp[i][j] = min cost to reduce stones[i..j] to 1 pile; same increasing-length interval fill as Burst Balloons (§6.1), extra k-pile constraint. | 1000 |
| Two-Agent 3D Grid DP | Two agents moving through a grid simultaneously — add a 3rd state dimension for the second agent's column; same neighbor-relaxation idea as Unique Paths (§5.3), one more axis. | 1463 |
| Multi-Dimension Knapsack | Two capacity constraints (e.g. weight AND count) instead of one — same 0/1 knapsack shape (§8.1), just loop both capacities right-to-left. | 474 |
| Bounded Knapsack | Each item has a limited count (between 0/1 and unbounded) — binary-split the count into powers of 2, then run 0/1 knapsack on the split items. | LC-variant |
| Probability / Expected-Value DP | Same dp[state] shape as any other DP, but each cell holds a probability (sums to ≤ 1) or an expectation instead of a min/max/count. | 688 |
13. COMPLEXITY CHEAT CARD
| Pattern | Time | Space | Optimization |
|---|---|---|---|
| 1D DP (array len n) | O(n) | O(n) | → O(1) rolling vars |
| 2D DP (m×n) | O(m*n) | O(m*n) | → O(min(m,n)) 1-row |
| Interval DP, no split pt (LPS §6.3, Partition II §6.4) | O(n²) | O(n²) | Usually no further opt |
| Interval DP, w/ split pt (Burst Balloons §6.1 — extra k-loop) | O(n³) | O(n²) | Usually no further opt |
| LIS O(n²) | O(n²) | O(n) | → O(n log n) binary srch |
| 0/1 Knapsack | O(n * W) | O(n * W) | → O(W) 1D array |
| Unbounded Knapsack | O(n * W) | O(n * W) | → O(W) 1D array |
| Stock (k txns) | O(n * k) | O(n * k) | → O(k) two arrays |
| State Machine (s states) | O(n * s) | O(n * s) | → O(s) rolling |
| Bitmask DP | O(2^n * n) | O(2^n * n) | Only n ≤ 20 |
| Tree DP | O(n) | O(h) | h = tree height |
| Digit DP | O(D * range) | O(D * range) | D = # digits |
14. DP OPTIMIZATION TECHNIQUES (SDE3 Tier — Name-Drop These)
These optimize the CONSTANT inside a DP loop from O(n) to O(1) amortized.
You won't implement them cold in an interview, but NAMING and EXPLAINING them
when asked "can you do better?" is what separates SDE3 from SDE2.| Technique | Reduces | Condition | Example |
|---|---|---|---|
| Monotonic Stack (§5.6) | O(n²) → O(n) | "nearest smaller/larger" element in inner loop | LC 907, 1130 |
| Divide & Conquer Optimization | O(n²) → O(n log n) | opt[i][j] is monotone: opt[i][j] ≤ opt[i+1][j] | LC 1235 variant · SMAWK/Aliens trick |
| Knuth's Optimization | O(n³) → O(n²) | interval DP where opt[i][j-1] ≤ opt[i][j] ≤ opt[i+1][j] (quadrangle inequality) | Matrix chain multiplication (classic CS) |
| Convex Hull Trick (CHT) | O(n²) → O(n) | recurrence of the form dp[i] = max(dp[j] + b[j]*a[i]), a[i] monotone (linear cost) | LC 1691, 1235 · Slope trick |
| Segment Tree / BIT DP | O(n²) → O(n log n) | dp[i] = max over range [lo..hi] of dp[j] + val | LC 2407, 1626 |
dp[j], so the optimal j for each i can be maintained on a convex hull. I'll implement the O(n²) version first to verify correctness, then discuss the optimization." Why naming matters: the SDE3 rubric rewards knowing the landscape of CS techniques, not just solving the problem — "I know there's a CHT optimization here" with a brief explanation scores higher than silently grinding O(n²).⚠️ PITFALLS — AND THE TEST THAT CATCHES EACH
Each of these is a real bug people ship under interview pressure. The right column is the smallest input that exposes it — run it in your head before you trust your code.
| Bug | The input that exposes it |
|---|---|
dp array sized n instead of n+1 (no slot for the empty-input base case). | climbingStairs(0) or lcs("", "abc") → index out of range, or the base case silently overwrites dp[0] which should mean "empty prefix." |
Wrong base case value — dp[0] = 0 when it should be 1 (or vice versa) for a counting DP. | numDecodings("1") → if dp[0] (empty prefix = 1 way) is wrong, every downstream count is wrong even though the recurrence itself is correct. |
| 0/1 knapsack space-optimized to 1D but the capacity loop runs ascending instead of descending. | canPartition([1,1]) target 1 → ascending lets the single item "count twice," producing a false positive that a 2D table would never give. |
| Unbounded knapsack (coin change) space-optimized but the capacity loop runs descending instead of ascending. | coinChange([1], 2) → descending forbids reusing a coin, so it reports "impossible" for an amount that clearly needs two 1-coins. |
Interval DP filled by increasing i / j instead of increasing length. | longestPalindromeSubseq("bbbab") → a split point reads dp[i][k] before it's been written (still its initial 0), silently truncating the real answer. |
Bitmask DP: writing mask & 1 << i === 0 to test a bit. === binds tighter than & in JS/TS, so this parses as mask & (1 << i === 0), not (mask & (1 << i)) === 0. | Any mask/i where the two parse differently — e.g. mask=0, i=2: the intended check is "bit unset" (true), the actual parse compares 4 === 0 (false) first, then 0 & false → always parenthesize: (mask & (1 << i)) === 0. |
| Tree DP (max path sum): not clamping a negative child contribution to 0 before adding it in. | A tree with one very negative leaf — without Math.max(0, child), that leaf drags the running max below the correct answer even though excluding it entirely was legal. |
Reusing a memo declared outside the function across multiple test cases / calls (common in interview live-coding). | Call solve(input1) then solve(input2) in the same session → the second call silently returns cached answers keyed by indices that now mean something different. |
Off-by-one between the DP index and the string/array index — using s[i] where the recurrence means "the i-th character," but dp is 1-indexed so it should be s[i-1]. | editDistance("a", "b") with a 1-indexed dp table but a 0-indexed string read → compares the wrong characters and the answer is off by exactly one edit. |
(index, state) reached via different paths with the same future ⇒ cache it); everything in this sheet is what that memo turns into once you formalize state + recurrence + base case.
Tree DP (§11) is the same post-order return-value DFS you built in trees.html — combine children's answers on the way up, just with an explicit recurrence and (sometimes) a memo.
Bitmask DP (§10) and backtracking's bucket-fill partitioning (§12.2 there) solve the same problems — the bitmask table is the memoized version of that exact backtracking search.▶ Coverage — what these families unlock on LeetCode
Recognise the family from §3's keyword map; these are the problems each one unlocks. Bold = Hard tier.
| Family (§) | Representative LeetCode (medium → hard) | Tier |
|---|---|---|
| 1D (§4) | 70 · 198 · 1696 Jump Game VI (dp + 239 deque) · 300 LIS · 152 · 139 · 91 · 354 Russian Doll | Med → Hard |
| 2D (§5) | 62 · 1143 LCS · 72 Edit Distance · 115 · 727 Min Window Subsequence · 514 Freedom Trail · 221 Maximal Square | Med → Hard |
| Interval (§6) | 516 LPS · 1547 Merge Stones · 312 Burst Balloons · 132 Palindrome Part. II | Med → Hard |
| String (§7) | 10 Regex Matching · 97 Interleaving · 44 Wildcard · 32 Longest Valid Parens | Hard |
| Knapsack (§8) | 416 · 494 · 322 · 518 · 474 Ones & Zeroes | Med |
| State machine (§9) | 121 · 122 · 309 · 714 · 188 Stock IV | Med → Hard |
| Bitmask (§10) | 847 Visit All Nodes · 1125 · 698 · 526 Beautiful Arrangement | Med → Hard |
| Tree DP (§11) | 124 Max Path Sum · 337 House Robber III · 543 · 834 Rerooting | Med → Hard |
| DAG / digit / prob (§12) | 329 Longest Increasing Path · 357 Count Numbers w/ Unique Digits · 688 Knight Probability | Med → Hard |
✅ SELF-CHECK — explain each from memory
Reading ≠ understanding. Cover the right column and answer from memory. Anything that stalls you = the section to re-read. Get these and you can set up any DP on an unseen LC medium/hard.
Cover the right column (it's blurred — hover/tap to reveal). Answer from memory; any stall = the section to re-read.
| Prompt | Answer |
|---|---|
| The two signals that mean "use DP"? | Optimal substructure + overlapping subproblems. |
| The 3-step path to any DP? | Brute recursion → add a memo (top-down) → table (bottom-up). |
| The single hardest part of a DP? | Defining the STATE — dp[…] in one sentence. |
| min/max problem vs count problem? | min/max: best of the choices · count: SUM the choices. |
| 0/1 knapsack on a 1-D array: loop direction? | Capacity RIGHT → LEFT (each item once). |
| Unbounded knapsack: loop direction? | LEFT → RIGHT (item is reusable). |
| Problem says "answer mod 1e9+7"? | It's a COUNT dp — apply % on every addition. |
| top-down vs bottom-up? | Memo = easy, only needed states · table = no stack, easy space-opt. |
| When can you NOT shrink to a 1-row array? | When you must reconstruct the actual path / items. |
| First move on an unseen DP? | Name dp[state] in one English sentence. |
Then write these cold (no peeking): (1) climbing stairs / fib, (2) coin change, (3) longest common subsequence, (4) 0/1 knapsack, (5) house robber. All five = you own DP.