🧮 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.

The flow Can recursion solve it? → What's my state/index? → What choices do I have? → Are states repeating? → Memoize → Tabulate → Optimize space (if the lookback is fixed).
🧠 vs Backtracking Both walk the same recursion tree of states + choices. Backtracking explores the tree. DP notices a state repeats and caches it instead of re-exploring.

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.

The 7 steps 1. State/index → 2. Choices → 3. Write recursion → 4. Repeated states? → 5. Memoize → 6. Tabulate → 7. Optimize space (if the lookback is fixed).
Base case Write it first — empty input, one element, or zero capacity. A wrong base case still "looks right" but silently poisons every value downstream.
Say this out loud "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.)

FlavorState dimsKey intuitionExamples
1D Lineardp[i]"best ending AT i" or "best using first i items"LC 300, 198, 152, 53, 45, 139
2D Grid / Stringdp[i][j]Usually two sequences, or a 2D grid with constraintsLC 1143, 72, 62, 64, 221, 115
Intervaldp[i][j]Sub-array from i to j — fill diagonallyLC 516, 312, 1039, 1547, 1000
Knapsack — 0/1dp[i][w] or dp[w]i items, capacity w — take or skip each itemLC 416, 494, 474, 1049, 1155
Knapsack — unboundeddp[w]Item can be reusedLC 322, 518, 279
Knapsack — multi-dimdp[i][j][k]Multiple constraints at onceLC 474, 879
State Machinedp[i][state]Finite states + transitions — "holding stock", "resting"LC 121-123, 309, 188, 714
Bitmaskdp[mask] or dp[i][mask]Subset of n items (n≤20) — "visited which nodes/chars"LC 847, 864, 943, 1125, 526, 698
Treedp[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 wordingThink
take / skip / chooseDP
min / max / bestDP
number of waysDP
possible / can weDP
sequential decisionsDP
repeated stateMemoization
Core trigger Choices + State + Repeated State → DP.

🚨 Anti-patterns — mistakes that kill candidates

🚨 Vague state? Name it precisely — "max sum ENDING at i", not "the answer at i".
🚨 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

🚨 "min # of intervals to cover"? Greedy (sort by end), not DP.
🚨 "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.

🔑 Invariant The invariant: dp[i] depends only on a bounded window of earlier, already-finalized dp values (dp[i-1], dp[i-2], … dp[i-k]), and once written it never changes again. Why that's safe: filling i in strictly increasing order guarantees every dp value the recurrence reaches backward for already exists and is final — so there is exactly one correct order to compute in, and no dp[i] is ever read before it's written.
🎤 State 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)

⬇ Iterative (bottom-up)
// 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;
}
↻ Recursive + Memo (top-down) FORWARD · suffix
// 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.

⬇ Iterative (bottom-up)
// 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
  );
}
↻ Recursive + Memo (top-down) FORWARD · suffix
// 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.

⬇ Iterative (bottom-up)
// 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
↻ Recursive + Memo (top-down) FORWARD · suffix
// 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 stack

4.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.

⬇ Iterative (bottom-up)
// 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.
↻ Recursive + Memo (top-down) FORWARD · suffix
// 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 work

Super 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.

⬇ Iterative (bottom-up)
// 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
↻ Recursive + Memo (top-down) BACKWARD · prefix
// 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.

⬇ Iterative (bottom-up)
// 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."
↻ Recursive + Memo (top-down) BACKWARD · prefix
// 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.

⬇ Iterative (bottom-up)
// 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
↻ Recursive + Memo (top-down) FORWARD · suffix
// 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).

⬇ Iterative (bottom-up)
// 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.
↻ Recursive + Memo (top-down) FORWARD · suffix
// 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.

🔑 Invariant 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)
⚠️ Trap In the )) 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 recursion

Knapsack 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)).

⬇ Iterative (bottom-up)
// 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];
}
↻ Recursive + Memo (top-down) FORWARD · suffix
// 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.

🔑 Invariant The invariant: dp[i][j] depends only on dp[i-1][j], dp[i][j-1], and dp[i-1][j-1] — cells strictly above and/or to the left — and is finalized the instant those are known. Why that's safe: filling the table row-major (or any order where a cell's up/left/diag neighbors precede it) guarantees no cell is read before it's written, so a single pass computes every cell exactly once.
🎤 Interview lens Probed: the match-vs-mismatch recurrence (LCS / edit distance) and rolling-array space reduction — and why it destroys reconstruction. · Escalation: unique paths → LCS → edit distance → distinct subsequences → maximal square; "now return the actual edits / path" (backtrack the full table).

5.1 Longest Common Subsequence (LCS)

⬇ Iterative (bottom-up)
// 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];
}
↻ Recursive + Memo (top-down) FORWARD · suffix
// 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)

⬇ Iterative (bottom-up)
// 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)
↻ Recursive + Memo (top-down) FORWARD · suffix
// 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)

⬇ Iterative (bottom-up)
// 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];
}
↻ Recursive + Memo (top-down) FORWARD · suffix
// 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

⬇ Iterative (bottom-up)
// 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.
↻ Recursive + Memo (top-down) FORWARD · suffix
// 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)

⬇ Iterative (bottom-up)
// 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.
↻ Recursive + Memo (top-down) FORWARD · mirrored
// 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).

🔑 Invariant The invariant: dp[i][j] (the answer for subrange i..j) depends only on dp values for strictly shorter subranges nested inside it — never on a range of equal or greater length. Why that's safe: iterating by increasing length (not by increasing i) guarantees that for every split point k, both dp[i][k] and dp[k+1][j] were computed in an earlier pass — the classic bug is looping i,j directly and reading a cell that's still zero.
🎤 Interview lens Probed: iterate by increasing length, and the "which acts LAST" framing (Burst Balloons fixes the last balloon, not the first). · Escalation: longest palindromic subsequence → min cost to merge stones → Burst Balloons (LC 312) → palindrome partitioning II. The inner split loop makes it 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)

⬇ Iterative (bottom-up)
// 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.
↻ Recursive + Memo (top-down) INTERVAL
// 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

⬇ Iterative (bottom-up)
// 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)
↻ Recursive + Memo (top-down) INTERVAL
// 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

⬇ Iterative (bottom-up)
// 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
↻ Recursive + Memo (top-down) FORWARD · suffix
// 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?"

🔑 Invariant The invariant: dp[i][j] (does pattern-prefix of length j match text-prefix of length i?) depends only on dp states with strictly smaller i and/or j — one fewer matched character, or a * 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.
🎤 Interview lens What they're really testing: whether you can hold two shrinking pointers in your head at once — regex/wildcard needs a third rule just for * (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

⬇ Iterative (bottom-up)
// 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)
↻ Recursive + Memo (top-down) FORWARD · suffix
// 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

⬇ Iterative (bottom-up)
// 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];
}
↻ Recursive + Memo (top-down) FORWARD · suffix
// 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.

🔑 Invariant The invariant: dp[w] (or dp[i][w]) is the best value achievable within capacity w using items considered so far, and each item is folded into that state at most once for 0/1, or any number of times for unbounded. Why that's safe: the direction of the capacity loop is what enforces this — looping w descending means dp[w] still reflects "before this item" when you read dp[w-weight], so it's used once; looping ascending lets dp[w-weight] already include this item, so it can be reused. This is not stylistic — swap the direction and you swap the problem.
🎤 Interview lens Probed: 0/1 (loop capacity descending in the 1D rolling array) vs unbounded (ascending), and the boolean / count variants. · Escalation: 0/1 knapsack → partition equal subset (416) → target sum (494) → coin change min (322) → coin change II ways (518 — loop order matters!) → bounded knapsack via binary splitting.
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

⬇ Iterative (bottom-up)
// 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.
↻ Recursive + Memo (top-down) FORWARD · item index
// 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)

⬇ Iterative (bottom-up)
// 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)
↻ Recursive + Memo (top-down) FORWARD · item index
// 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)

⬇ Iterative (bottom-up)
// 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.
↻ Recursive + Memo (top-down) FORWARD · item index
// 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)

⬇ Iterative (bottom-up)
// 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)
↻ Recursive + Memo (top-down) single state · amount
// 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)

⬇ Iterative (bottom-up)
// 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
↻ Recursive + Memo (top-down) FORWARD · coin index
// 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.

🔑 Invariant The invariant: dp[i][state] depends only on dp[i-1][*] — today's states are built purely from yesterday's finalized states, never from another state on the same day. Why that's safe: the transition graph only ever points from day i-1 to day i, so there's no cycle within a day — that's exactly why the whole row can collapse to a handful of scalars (hold/sold/rest) instead of a 2D table.
🎤 Interview lens Probed: naming the states (hold / sold / rest) and drawing the transition graph; the cooldown / fee tweaks. · Escalation: stock I (1 txn) → II (unlimited) → with cooldown (309) → with fee (714) → at most K transactions (188 — the 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 movement

9.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)

⚠️ Filed here by topic, not by shape Don't let the section number fool you: this is Interval DP (§6) wearing a two-player costume — 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:

VariantCore trickLC
Stone Game IIMove size M grows with use — 3rd dp dimension dp[i][m], take 1..2M piles1140
Stone Game IIITake from the FRONT only (not either end) — 1D dp[i], try taking 1/2/31406
Stone Game VIII"Choose x>1 stones" reframed as "choose a prefix-sum cut index" — collapses to a 1D suffix-max scan1872
Stone Game IVNot score-optimal, just win/lose — dp[i] = boolean, true if any perfect-square move leaves opponent losing1510
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).

🔑 Invariant The invariant: dp[mask] is finalized only after every dp[submask] with strictly fewer set bits has been finalized — a mask never depends on a mask with equal-or-more bits set. Why that's safe: iterating masks in increasing numeric order (or memoizing top-down) guarantees popcount is non-decreasing along any dependency chain, so removing a bit to look "backward" always lands on an already-solved state.
🎤 Interview lens Probed: why 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."

🔑 Invariant The invariant: dp[node] is computed only from dp[child] for node's direct children, and only after every child has already returned. Why that's safe: post-order traversal guarantees a node's children are fully resolved before the node combines them — a node's answer never depends on its parent or siblings. Rerooting DP adds a second top-down pass precisely to answer the one question post-order can't: "what about everything outside my subtree?"
🎤 Interview lens Probed: the return-vs-global split (diameter, max path sum clamps negative children to 0) and rob/skip state pairs. · Escalation: max path sum (124) → house robber III (337 — return [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.

🔑 Invariant The invariant: dp[node] (or dp[position][tight][…] for digit DP) depends only on dp values reachable by one edge/one digit forward, and the underlying graph of states is acyclic. Why that's safe: acyclicity guarantees the recursion always terminates and each state's memo entry is written exactly once — unlike a general graph, you never need cycle detection, because there's no path back to a state that depends on itself.
🎤 Interview lens What they're really testing: whether you can spot a DAG hiding inside a grid or a number's digits — the recursion "looks like" plain graph DFS, and the DP is just adding a memo because the same node/position is reached from many predecessors. Nail it and they escalate: longest increasing path in a matrix (memoize per cell) → digit DP with a "tight" flag (am I still bounded by N's prefix?) → probability/expected-value DP (🗂️ Rare Variants, same shape but sum-of-probabilities instead of max/count).

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.

PatternCore insightLC example
Matrix Exponentiation DPLinear 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 Stonesdp[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 DPTwo 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 KnapsackTwo 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 KnapsackEach 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 DPSame 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

PatternTimeSpaceOptimization
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 KnapsackO(n * W)O(n * W)→ O(W) 1D array
Unbounded KnapsackO(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 DPO(2^n * n)O(2^n * n)Only n ≤ 20
Tree DPO(n)O(h)h = tree height
Digit DPO(D * range)O(D * range)D = # digits
Space optimization rule of thumb dp[i] only needs dp[i-1] → reduce to 2 variables · dp[i][j] only needs dp[i-1][...] → reduce to 1 row (array of size n) · dp[i][j] needs dp[i-1][j-1] → use a single variable "prev_diag" · right-to-left traversal = 0/1 knapsack (no reuse) · left-to-right traversal = unbounded knapsack (allow reuse)

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.
TechniqueReducesConditionExample
Monotonic Stack (§5.6)O(n²) → O(n)"nearest smaller/larger" element in inner loopLC 907, 1130
Divide & Conquer OptimizationO(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 OptimizationO(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 DPO(n²) → O(n log n)dp[i] = max over range [lo..hi] of dp[j] + valLC 2407, 1626
🎤 How to signal SDE3 knowledge without implementing "My current solution is O(n²). I can reduce this to O(n log n) using the Convex Hull Trick — the recurrence is linear in 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.

BugThe 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.
🔗 This pattern, elsewhere Memoized backtracking IS top-down DP — backtracking.html §12 walks the exact decision (same (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 DollMed → Hard
2D (§5)62 · 1143 LCS · 72 Edit Distance · 115 · 727 Min Window Subsequence · 514 Freedom Trail · 221 Maximal SquareMed → Hard
Interval (§6)516 LPS · 1547 Merge Stones · 312 Burst Balloons · 132 Palindrome Part. IIMed → Hard
String (§7)10 Regex Matching · 97 Interleaving · 44 Wildcard · 32 Longest Valid ParensHard
Knapsack (§8)416 · 494 · 322 · 518 · 474 Ones & ZeroesMed
State machine (§9)121 · 122 · 309 · 714 · 188 Stock IVMed → Hard
Bitmask (§10)847 Visit All Nodes · 1125 · 698 · 526 Beautiful ArrangementMed → Hard
Tree DP (§11)124 Max Path Sum · 337 House Robber III · 543 · 834 RerootingMed → Hard
DAG / digit / prob (§12)329 Longest Increasing Path · 357 Count Numbers w/ Unique Digits · 688 Knight ProbabilityMed → 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.

PromptAnswer
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.