πŸ”™ Backtracking

A standalone, LC-medium/hard-focused sheet. The whole topic is three beats β€” choose β†’ explore β†’ un-choose. Recognize the flavor, pick the skeleton, prune to make it fast.


🧠 HOW TO THINK ABOUT BACKTRACKING (READ FIRST)

Backtracking = build a solution one choice at a time, and the moment a choice can't lead anywhere good (or a solution is complete), undo it and try the next. It is just DFS over a tree of decisions β€” and the whole skill is being disciplined about one thing: every choice you make, you must be able to un-make.

🧡 The mental model β€” Ariadne's thread You're walking a maze, paying out a ball of string behind you. At each junction you pick a corridor (choose) and walk on (explore). Hit a dead end β€” or find an exit and jot it down β€” then you follow the string back, winding it up as you go (un-choose), until you reach a junction with an untried corridor. That wound-up string is the whole trick: the recursion returns nothing useful; the real work is a single shared path that every call writes to and must erase its own marks from before it returns. Miss one un-choose and the next corridor inherits your footprints.
+1 +2 +3 +2 +3 +3 +3 [ ] [1] [2] [3] [1,2] [1,3] [2,3] [1,2,3] descend = choose (push) ascend  = un-choose (pop) every node is a subset β†’ 2Β³ = 8
Subsets of [1,2,3] (LC 78). Each node is a partial path and is itself a subset, so you collect at every node. Going down a green edge pushes an element (choose); returning pops it (un-choose). The start index β€” each child only adds a larger element β€” is exactly why [2,1] never appears.

Watch subsets get built β€” by hand. Take [1,2,3], an empty path=[], and record it (the empty set is a subset). Now the loop offers 1, then 2, then 3:

  1. Choose 1 β†’ path=[1], record. Recurse (choices now start at 2).
  2. Choose 2 β†’ [1,2], record. Recurse (start at 3). Choose 3 β†’ [1,2,3], record. No choices left β†’ return.
  3. Un-choose 3, un-choose 2 β†’ back to [1]. The loop tries 3 next β†’ [1,3], record. Return; un-choose 3 and 1 β†’ back to [].
  4. The loop tries 2 β†’ [2] β†’ [2,3]; then 3 β†’ [3]. Eight subsets β€” one per node of the tree above.
[ ] [1] [1,2] [1,2,3] push 1 push 2 push 3 ⭐ results.push([...path]) snapshot β€” copy, don't alias un-choose: pop 3 Β· pop 2 Β· pop 1 β†’ path restored to [ ]
One branch of the tree, tracing the single shared path array. It grows as you descend (green pushes) and β€” crucially β€” shrinks back on the way up (violet pops). Two rules fall out: record a copy [...path] (the array itself keeps changing), and always pop so the next branch starts clean.
🧠 Contrast with tree DFS In trees.html the whole game was what each call returns to its parent. Backtracking is the mirror image: a call usually returns nothing β€” it mutates shared state (path, used[], the board) on the way down and restores it on the way up. If return-value DFS is an org chart reporting numbers upward, backtracking is one person exploring a maze and tidying up behind themselves. (Your weak spot is return values β€” here the relief is that there usually isn't one; the discipline moves to undo instead.)

When to reach for it β€” backtracking is for enumerating every possibility, not finding one optimum:

Reach for backtracking when……otherwise use
"all / every / list / generate ___""the single best / longest / min" with overlapping subproblems β†’ DP
"how many ways / count" (small n)"just one valid answer, fast" β†’ greedy
subsets Β· permutations Β· combinations Β· partitions Β· placements"shortest / min steps" β†’ BFS
N-Queens Β· Sudoku Β· generate parentheses Β· word searchcounting at huge n (can't enumerate) β†’ math / DP
The 3-question contract Every backtracking solution is the universal template below with three blanks filled in: (1) CHOICES β€” what are my options at this step? (2) VALID? β€” is this choice still worth exploring, or do I prune it? (3) COMPLETE? β€” am I at a full solution, and if so do I record a copy? Get the rhythm on subsets and every other problem is a variation of it.

1. DECISION FRAMEWORK

Senior Engineer mindset: Before writing a line of code, classify the problem. Every minute spent here saves 5 minutes of wrong-direction coding.
Step 1 Β· Should I backtrack? βœ… Reach for it when β€” you need all solutions / to enumerate possibilities Β· you build the solution incrementally Β· invalid states can be pruned early Β· the shape is subsets, perms, combos, placements, partitions, or paths with constraints.
❌ Skip it when β€” you need one optimal answer β†’ DP / greedy Β· there's nothing to prune β†’ plain DFS / BFS Β· subproblems overlap heavily β†’ memoized DP.
Step 2 Β· What's the choice at each step? Ask: "at this point in building my solution, what options do I have?"
Include element i or skip it→ Subsets
Place an element at the next open position→ Permutations
Pick an element starting from index i→ Combinations
Move in 4 directions from cell (r, c)β†’ Grid path
Place a queen / digit in a row / cell→ Constraint
Cut the string at position j→ Partition
Step 3 Β· What's the termination condition? Base case 1 β€” COLLECT: the solution is complete β†’ push a copy of the current path to results.
Base case 2 β€” PRUNE: the current path is already invalid β†’ return immediately, undo the last choice.
⚠️ Always push a copy β€” results.push([...path]), never results.push(path) (the classic bug).
Step 4 Β· Where can I prune? Good pruning is the difference between TLE and AC: the remaining elements can't reach the target Β· the current sum already exceeds the limit Β· a duplicate element at the same recursion level Β· a cell already visited (grid) Β· a constraint is violated (N-Queens, Sudoku).
Step 5 Β· Do I need to memoize? Only if the same (index, state) pair can be reached via different paths with the same future potential β†’ add a memo: Map<string, result>. Pure backtracking needs none; a hybrid (backtracking-style DP) does β€” see Β§12.

2. UNIVERSAL BACKTRACKING TEMPLATE

// THE UNIVERSAL TEMPLATE β€” memorize this skeleton
function solve(input: any[]): any[][] {
  const results: any[][] = [];
  const path: any[] = [];

  function backtrack(/* state: index, remaining capacity, etc. */) {
    // BASE CASE: solution is complete
    if (/* termination condition */) {
      results.push([...path]);   // ⚠️ COPY, not reference
      return;
    }

    for (const choice of /* choices at this step */) {
      // PRUNE: skip invalid choices early
      if (/* choice is invalid */) continue;

      // CHOOSE
      path.push(choice);
      // mutate state if needed (e.g., mark visited)

      // EXPLORE
      backtrack(/* updated state */);

      // UNCHOOSE (backtrack)
      path.pop();
      // reverse state mutation
    }
  }

  backtrack(/* initial state */);
  return results;
}
The three words to tattoo on your brain: CHOOSE β†’ EXPLORE β†’ UNCHOOSE. Every backtracking solution is just these 3 lines in a loop. Your job in the interview is to fill in: what are the choices, when do I terminate, and where do I prune.

3. THE 5 BACKTRACKING FLAVORS

FlavorKey question at each stepLeetCode
Subsets
order doesn't matter
Include or skip this element?78, 90
Permutations
order matters, each once
Which unused element goes next?46, 47
Combinations
order-free, no re-pick
Pick k from n, starting at index i77, 39, 40, 216
Grid / path
path on a 2D grid
Move in 4 directions, mark visited79, 212, 200, 130
Constraint satisfaction
N-Queens, Sudoku
Place an item that satisfies the rules51, 52, 37
String partitioning
palindromes, IPs, parens
Cut at position j if the piece is valid131, 17, 22, 93
⭐ THE #1 DECISION β€” start index vs used[] array (memorize this β€” it picks your template in 5 seconds):
ORDER DOESN'T MATTER (subsets, combinations) β†’ pass a `start` index,
  loop `for (i = start; ...)`, recurse with i+1 (or i if reuse allowed).
  This prevents [1,2] and [2,1] from both appearing.

ORDER MATTERS (permutations) β†’ use a `used[]` boolean array,
  loop `for (i = 0; ...)`, skip if used[i].
  This lets every element appear at every position.

If you can answer "does order matter?" in 5 seconds, you've already picked the skeleton.

4. KEYWORD β†’ PATTERN MAP

Keyword / phrase in the promptPatternLeetCode
"all subsets" / "power set"Subsets78, 90
"all permutations"Permutations46, 47
"all combinations of k from n"Combinations77
"combination sum / target sum"Combinations + prune39, 40, 216
"generate all valid parentheses"String partition22
"palindrome partitioning"String partition131, 132
"restore IP addresses"String partition93
"letter combinations of a phone number"Permutation / combo17
"word search in a grid"Grid path79, 212
"number of islands / connected region"DFS (not backtracking)200
"N-Queens"Constraint satisfaction51, 52
"Sudoku solver"Constraint satisfaction37
"expression add operators"String partition282
"partition to K equal subsets"Subsets + bitmask698, 1723
"path exists from source to dest in grid"Grid path490, 79
"word break II (all ways)"String partition + memo140
"beautiful arrangement"Permutation + prune526
"combinations with at most / exactly k"Combinations216, 254
"reconstruct itinerary"Graph path (Eulerian)332
"remove invalid parentheses"String partition + BFS301
"split array into fibonacci"String partition842
"max length of concatenated string"Subsets + check1239
"find all paths from source to target"Graph path backtrack797
"concatenated words"Trie + backtrack472

5. TEMPLATE 1: SUBSETS

Each element: include or skip β†’ 2ⁿ subsets.

πŸ”‘ Invariant The invariant: every partial path on the way down is itself a valid subset, and after backtrack(i) returns, path is byte-for-byte what it was before the call. Why that's safe: collecting at every node emits each subset once, and because each recursion starts at i+1, an index is never revisited on the same path β€” so nothing repeats and nothing is missed.

5.1 Basic Subsets β€” LC 78 (no duplicates)

function subsets(nums: number[]): number[][] {
  const results: number[][] = [];
  const path: number[] = [];

  function backtrack(start: number): void {
    results.push([...path]);          // collect at EVERY node, not just leaves

    for (let i = start; i < nums.length; i++) {
      path.push(nums[i]);             // CHOOSE
      backtrack(i + 1);              // EXPLORE (i+1: each element used once)
      path.pop();                    // UNCHOOSE
    }
  }

  backtrack(0);
  return results;
}
// Time: O(n * 2^n)  Space: O(n) recursion depth

5.2 Subsets with Duplicates β€” LC 90

function subsetsWithDup(nums: number[]): number[][] {
  nums.sort((a, b) => a - b);        // MUST sort to group duplicates
  const results: number[][] = [];
  const path: number[] = [];

  function backtrack(start: number): void {
    results.push([...path]);

    for (let i = start; i < nums.length; i++) {
      // Skip duplicate at same recursion level
      if (i > start && nums[i] === nums[i - 1]) continue;

      path.push(nums[i]);
      backtrack(i + 1);
      path.pop();
    }
  }

  backtrack(0);
  return results;
}
Key rule for duplicates: if (i > start && nums[i] === nums[i-1]) continue β€” i > start (not i > 0) means: only skip at THIS level, not globally.

5.3 Pick / Skip Formulation (bridges to 0/1-knapsack DP)

function subsetsPickSkip(nums: number[]): number[][] {
  const results: number[][] = [];
  const path: number[] = [];

  function backtrack(i: number): void {
    if (i === nums.length) {        // leaf: one decision made per element
      results.push([...path]);
      return;
    }
    backtrack(i + 1);               // SKIP nums[i]

    path.push(nums[i]);             // TAKE nums[i]
    backtrack(i + 1);
    path.pop();
  }

  backtrack(0);
  return results;
}
// Two formulations, same answer. for-loop collects at every node;
// pick/skip collects only at leaves. Know both β€” interviewers probe this.

5.4 Subsets via Bitmask Iteration (no recursion, n ≀ 20)

function subsetsBitmask(nums: number[]): number[][] {
  const n = nums.length;
  const results: number[][] = [];
  for (let mask = 0; mask < (1 << n); mask++) {  // each mask = one subset
    const subset: number[] = [];
    for (let i = 0; i < n; i++) {
      if (mask & (1 << i)) subset.push(nums[i]);
    }
    results.push(subset);
  }
  return results;
}
// O(n * 2^n), zero recursion. Clean when n <= 20. Mention as an alternative.
🎀 Interview lens What they're really testing: whether you can generate the power set two ways β€” collect at every node (for-loop + start) vs decide take/skip per element and collect at the leaves β€” and whether your duplicate skip is level-only (i > start), not global. Nail it and they escalate: subsets β†’ subsets II (dups) β†’ "count subsets summing to T" β†’ memoize on (i, target) and you've turned it into 0/1-knapsack DP. For n ≀ 20 they may want the recursion gone entirely β€” offer the O(nΒ·2ⁿ) bitmask.

6. TEMPLATE 2: PERMUTATIONS

Each slot: pick any unused element β†’ n! orderings.

πŸ”‘ Invariant The invariant: used[i] is true for exactly the elements currently sitting in path, and it flips back to false the instant the call unwinds. Why that's safe: each position considers every not-yet-used element, so all n! orderings are reachable; the symmetric set-then-reset keeps used[] pristine for every sibling branch.

6.1 Basic Permutations β€” LC 46 (no duplicates)

function permute(nums: number[]): number[][] {
  const results: number[][] = [];
  const used = new Array(nums.length).fill(false);
  const path: number[] = [];

  function backtrack(): void {
    if (path.length === nums.length) {
      results.push([...path]);
      return;
    }

    for (let i = 0; i < nums.length; i++) {
      if (used[i]) continue;         // PRUNE: already in path

      used[i] = true;                // CHOOSE
      path.push(nums[i]);
      backtrack();                   // EXPLORE
      path.pop();                    // UNCHOOSE
      used[i] = false;
    }
  }

  backtrack();
  return results;
}
// Time: O(n * n!)  Space: O(n)

6.2 Permutations with Duplicates β€” LC 47

function permuteUnique(nums: number[]): number[][] {
  nums.sort((a, b) => a - b);        // sort to group duplicates
  const results: number[][] = [];
  const used = new Array(nums.length).fill(false);
  const path: number[] = [];

  function backtrack(): void {
    if (path.length === nums.length) {
      results.push([...path]);
      return;
    }

    for (let i = 0; i < nums.length; i++) {
      if (used[i]) continue;
      // Skip: same value as prev AND prev not used in this level
      // This ensures we only pick duplicates in a fixed order
      if (i > 0 && nums[i] === nums[i - 1] && !used[i - 1]) continue;

      used[i] = true;
      path.push(nums[i]);
      backtrack();
      path.pop();
      used[i] = false;
    }
  }

  backtrack();
  return results;
}
Duplicate permutation trick: i > 0 && nums[i] === nums[i-1] && !used[i-1] β€” ensures among identical elements they are always picked left-to-right. !used[i-1]: prev identical element was NOT chosen (already backtracked) β†’ skip this one.

6.3 Swap-Based Permutation (space-efficient, no used[] array)

function permuteSwap(nums: number[]): number[][] {
  const results: number[][] = [];

  function backtrack(start: number): void {
    if (start === nums.length) {
      results.push([...nums]);
      return;
    }

    for (let i = start; i < nums.length; i++) {
      [nums[start], nums[i]] = [nums[i], nums[start]];   // CHOOSE (swap)
      backtrack(start + 1);                               // EXPLORE
      [nums[start], nums[i]] = [nums[i], nums[start]];   // UNCHOOSE (swap back)
    }
  }

  backtrack(0);
  return results;
}
// ⚠️  Does NOT handle duplicates cleanly β€” use 6.2 for that
🎀 Interview lens What they're really testing: that you know the two formulations β€” a used[] flag array vs in-place swapping β€” and can explain why the duplicate skip needs !used[i-1] (it forces equal values to be consumed strictly left-to-right, so a multiset is permuted exactly once). Nail it and they escalate: permute β†’ permute II (dups) β†’ next permutation (LC 31) β†’ k-th permutation sequence, where you skip building the tree at all and index straight in with the factorial number system.

6.4 Constrained Permutations β€” Android Unlock Patterns (LC 351)

Permutations of length m..n where a move may only jump OVER a key that is already visited.

πŸ”‘ Invariant Precompute skip[a][b] = the key lying between a and b (0 if none). A move aβ†’b is legal iff b is unvisited and skip[a][b] is 0 or already visited. Count at every length in [m, n]. The keypad's symmetry means corners are all equivalent and edge-centres are all equivalent, so run 3 starts instead of 9.
function numberOfPatterns(m: number, n: number): number {
  const skip = Array.from({ length: 10 }, () => new Array(10).fill(0));   // skip[a][b] = key between a and b
  skip[1][3] = skip[3][1] = 2; skip[4][6] = skip[6][4] = 5; skip[7][9] = skip[9][7] = 8;
  skip[1][7] = skip[7][1] = 4; skip[2][8] = skip[8][2] = 5; skip[3][9] = skip[9][3] = 6;
  skip[1][9] = skip[9][1] = skip[3][7] = skip[7][3] = 5;
  const visited = new Array(10).fill(false);
  function dfs(cur: number, len: number): number {
    let count = len >= m ? 1 : 0;                        // this pattern itself is valid
    if (len === n) return count;
    visited[cur] = true;
    for (let next = 1; next <= 9; next++) {
      const mid = skip[cur][next];
      if (!visited[next] && (mid === 0 || visited[mid])) count += dfs(next, len + 1);
    }
    visited[cur] = false;                                // un-choose
    return count;
  }
  return 4 * dfs(1, 1) + 4 * dfs(2, 1) + dfs(5, 1);      // corners Γ—4 Β· edge-centres Γ—4 Β· centre Γ—1
}
⚠️ Trap Knight-style moves (1β†’6, 2β†’7…) have no middle key and are always legal. Forgetting to count shorter patterns (len β‰₯ m) is the usual bug.

6.5 The k-th Permutation without enumerating β€” LC 60

Each leading digit owns a block of (nβˆ’1)! permutations β€” skip whole blocks instead of generating them.

πŸ”‘ Invariant With k zero-indexed, idx = ⌊k / (nβˆ’1βˆ’i)!βŒ‹ selects the i-th digit from the sorted remaining digits; then k %= (nβˆ’1βˆ’i)!. Contrast with Β§6.1: enumerating is O(n Β· n!), this is O(nΒ²). Recognising when backtracking is unnecessary is the whole point.
function getPermutation(n: number, k: number): string {
  const fact = [1];
  for (let i = 1; i < n; i++) fact.push(fact[i - 1] * i);     // fact[i] = i!
  const digits = Array.from({ length: n }, (_, i) => i + 1);
  k--;                                                         // zero-index FIRST
  let res = '';
  for (let i = 0; i < n; i++) {
    const f = fact[n - 1 - i];                                 // permutations per leading digit
    const idx = Math.floor(k / f);
    res += digits.splice(idx, 1)[0];                           // pick from the REMAINING digits
    k %= f;
  }
  return res;
}

7. TEMPLATE 3: COMBINATIONS

Pick k from n with a start index β€” order-free, no re-pick.

πŸ”‘ Invariant The invariant: picks are strictly increasing in index β€” each recursion starts at i+1 (or i when reuse is allowed) β€” so any given combination is built in exactly one order. Why that's safe: [1,2] and [2,1] can't both occur, because 2 is never chosen before 1 on the same path; that's what makes the enumeration duplicate-free.

7.1 Basic Combinations β€” LC 77

function combine(n: number, k: number): number[][] {
  const results: number[][] = [];
  const path: number[] = [];

  function backtrack(start: number): void {
    if (path.length === k) {
      results.push([...path]);
      return;
    }

    // PRUNE: not enough elements remaining to fill k slots
    const remaining = k - path.length;
    for (let i = start; i <= n - remaining + 1; i++) {  // upper bound prune
      path.push(i);
      backtrack(i + 1);
      path.pop();
    }
  }

  backtrack(1);
  return results;
}

7.2 Combination Sum (reuse allowed) β€” LC 39

function combinationSum(candidates: number[], target: number): number[][] {
  candidates.sort((a, b) => a - b);   // sort enables early termination
  const results: number[][] = [];
  const path: number[] = [];

  function backtrack(start: number, remaining: number): void {
    if (remaining === 0) {
      results.push([...path]);
      return;
    }

    for (let i = start; i < candidates.length; i++) {
      if (candidates[i] > remaining) break;   // PRUNE (sorted β†’ all remaining > target)

      path.push(candidates[i]);
      backtrack(i, remaining - candidates[i]);  // i (not i+1): same element reusable
      path.pop();
    }
  }

  backtrack(0, target);
  return results;
}
// Time: O(n^(T/M)) where T=target, M=min candidate

7.3 Combination Sum II (no reuse, duplicates in input) β€” LC 40

function combinationSum2(candidates: number[], target: number): number[][] {
  candidates.sort((a, b) => a - b);
  const results: number[][] = [];
  const path: number[] = [];

  function backtrack(start: number, remaining: number): void {
    if (remaining === 0) {
      results.push([...path]);
      return;
    }

    for (let i = start; i < candidates.length; i++) {
      if (candidates[i] > remaining) break;                      // PRUNE: sum exceeded
      if (i > start && candidates[i] === candidates[i - 1]) continue; // PRUNE: duplicates

      path.push(candidates[i]);
      backtrack(i + 1, remaining - candidates[i]);   // i+1: no reuse
      path.pop();
    }
  }

  backtrack(0, target);
  return results;
}

7.4 Combination Sum III (exactly k numbers, 1-9) β€” LC 216

function combinationSum3(k: number, n: number): number[][] {
  const results: number[][] = [];
  const path: number[] = [];

  function backtrack(start: number, remaining: number): void {
    if (path.length === k && remaining === 0) {
      results.push([...path]);
      return;
    }
    if (path.length === k || remaining <= 0) return;  // PRUNE

    for (let i = start; i <= 9; i++) {
      if (i > remaining) break;   // PRUNE: sorted 1-9
      path.push(i);
      backtrack(i + 1, remaining - i);
      path.pop();
    }
  }

  backtrack(1, n);
  return results;
}
🎀 Interview lens What they're really testing: why a start index (not a used[] array) is right when order doesn't matter β€” it forces increasing picks so [1,2] and [2,1] can't both appear β€” plus the feasibility prune i ≀ nβˆ’(kβˆ’len)+1 that skips branches too short to reach k. Nail it and they escalate: combine β†’ combination sum (reuse β†’ recurse on i) β†’ combo sum II (no reuse + dedup β†’ i+1) β†’ combo sum III (exactly k). Sorting is what unlocks the break prune in all of them.

8. TEMPLATE 4: GRID / PATH SEARCH

DFS 4 directions, mark visited, unmark on the way back.

πŸ”‘ Invariant The invariant: the set of cells marked # (or in visited) is exactly the cells on the current path; restoring on the way back leaves the board pristine for the next branch. Why that's safe: no cell is reused within one path, and because sibling explorations each start from the same clean board, a cell blocked on one path is free on another.

8.1 Word Search β€” LC 79

function exist(board: string[][], word: string): boolean {
  const rows = board.length, cols = board[0].length;
  const dirs = [[0,1],[0,-1],[1,0],[-1,0]];

  function backtrack(r: number, c: number, idx: number): boolean {
    if (idx === word.length) return true;       // BASE CASE: matched all chars
    if (r < 0 || r >= rows || c < 0 || c >= cols) return false;
    if (board[r][c] !== word[idx]) return false;
    if (board[r][c] === '#') return false;      // already visited

    const temp = board[r][c];
    board[r][c] = '#';                         // CHOOSE: mark visited IN-PLACE

    for (const [dr, dc] of dirs) {
      if (backtrack(r + dr, c + dc, idx + 1)) return true;  // short-circuit
    }

    board[r][c] = temp;                        // UNCHOOSE: restore
    return false;
  }

  for (let r = 0; r < rows; r++) {
    for (let c = 0; c < cols; c++) {
      if (backtrack(r, c, 0)) return true;
    }
  }
  return false;
}
// Time: O(rows*cols * 3^L)  (4 dirs, but ≀3 unexplored after step 1)  Space: O(L)

8.2 Word Search II (multiple words, Trie) β€” LC 212

// Build a Trie, then DFS the board
// Key optimization: delete found words from Trie to avoid re-finding
interface TrieNode {
  children: Map<string, TrieNode>;
  word: string | null;
}

function findWords(board: string[][], words: string[]): string[] {
  const root: TrieNode = { children: new Map(), word: null };
  for (const w of words) {
    let node = root;
    for (const ch of w) {
      if (!node.children.has(ch)) node.children.set(ch, { children: new Map(), word: null });
      node = node.children.get(ch)!;
    }
    node.word = w;
  }

  const result: string[] = [];
  const rows = board.length, cols = board[0].length;
  const dirs = [[0,1],[0,-1],[1,0],[-1,0]];

  function dfs(r: number, c: number, node: TrieNode): void {
    if (r < 0 || r >= rows || c < 0 || c >= cols) return;
    const ch = board[r][c];
    if (ch === '#' || !node.children.has(ch)) return;

    const next = node.children.get(ch)!;
    if (next.word) {
      result.push(next.word);
      next.word = null;             // avoid duplicate results
    }

    board[r][c] = '#';
    for (const [dr, dc] of dirs) dfs(r + dr, c + dc, next);
    board[r][c] = ch;

    // Prune empty Trie nodes (major optimization)
    if (next.children.size === 0) node.children.delete(ch);
  }

  for (let r = 0; r < rows; r++)
    for (let c = 0; c < cols; c++)
      dfs(r, c, root);

  return result;
}

8.3 Find All Paths β€” LC 797

function allPathsSourceTarget(graph: number[][]): number[][] {
  const n = graph.length;
  const results: number[][] = [];
  const path: number[] = [0];   // start at node 0

  function backtrack(node: number): void {
    if (node === n - 1) {
      results.push([...path]);
      return;
    }
    for (const next of graph[node]) {
      path.push(next);
      backtrack(next);
      path.pop();
    }
  }

  backtrack(0);
  return results;
}
// In a DAG: no visited set needed (no cycles).
// In a general graph (cycles possible): add a visited set, mark on enter, UNMARK on exit.
//   visited.add(node); ... dfs(next); ... visited.delete(node);  ← the unmark is backtracking

8.4 Robot Room Cleaner β€” LC 489 (⭐ Google favorite)

// Robot API: move(): boolean, turnLeft(), turnRight(), clean()
// You do NOT get coordinates β€” track them yourself relative to start.
interface Robot { move(): boolean; turnLeft(): void; turnRight(): void; clean(): void; }

function cleanRoom(robot: Robot): void {
  const visited = new Set<string>();
  const dirs = [[-1, 0], [0, 1], [1, 0], [0, -1]];  // up, right, down, left (clockwise)

  function goBack(): void {           // turn 180Β°, move, turn 180Β° back
    robot.turnRight(); robot.turnRight();
    robot.move();
    robot.turnRight(); robot.turnRight();
  }

  function backtrack(row: number, col: number, d: number): void {
    visited.add(`${row},${col}`);
    robot.clean();

    for (let i = 0; i < 4; i++) {                 // try all 4 directions
      const nd = (d + i) % 4;
      const nr = row + dirs[nd][0], nc = col + dirs[nd][1];

      if (!visited.has(`${nr},${nc}`) && robot.move()) {
        backtrack(nr, nc, nd);
        goBack();                                 // UNCHOOSE: physically return
      }
      robot.turnRight();                          // rotate to next direction
    }
  }

  backtrack(0, 0, 0);
}
// Key insight: you can't teleport β€” "backtracking" means physically driving back.

8.5 Unique Paths III β€” LC 980 (grid + must visit ALL empty cells)

function uniquePathsIII(grid: number[][]): number {
  const rows = grid.length, cols = grid[0].length;
  let empty = 0, startR = 0, startC = 0;
  for (let r = 0; r < rows; r++)
    for (let c = 0; c < cols; c++) {
      if (grid[r][c] === 0) empty++;
      else if (grid[r][c] === 1) { startR = r; startC = c; }
    }

  let paths = 0;
  const dirs = [[0, 1], [0, -1], [1, 0], [-1, 0]];

  function backtrack(r: number, c: number, remaining: number): void {
    if (r < 0 || r >= rows || c < 0 || c >= cols || grid[r][c] === -1) return;
    if (grid[r][c] === 2) {
      if (remaining === 0) paths++;     // reached end AND covered every empty cell
      return;
    }

    grid[r][c] = -1;                    // CHOOSE: mark visited
    for (const [dr, dc] of dirs) backtrack(r + dr, c + dc, remaining - 1);
    grid[r][c] = 0;                     // UNCHOOSE: restore
  }

  backtrack(startR, startC, empty + 1); // +1 counts the start cell itself
  return paths;
}

8.6 Reconstruct Itinerary β€” LC 332 (⚠️ NOT actually backtracking)

// LESSON: this LOOKS like backtracking but naive backtracking TLEs.
// Correct answer = Hierholzer's algorithm for an Eulerian path (use each EDGE once).
function findItinerary(tickets: string[][]): string[] {
  const graph = new Map<string, string[]>();
  for (const [from, to] of tickets) {
    if (!graph.has(from)) graph.set(from, []);
    graph.get(from)!.push(to);
  }
  for (const dests of graph.values()) dests.sort((a, b) => b.localeCompare(a)); // pop smallest

  const route: string[] = [];
  function dfs(airport: string): void {
    const dests = graph.get(airport);
    while (dests && dests.length) dfs(dests.pop()!);  // consume edges greedily
    route.push(airport);                              // post-order push
  }

  dfs('JFK');
  return route.reverse();
}
// Interview signal: recognizing "use every edge once" β†’ Eulerian, NOT brute backtracking.

8.7 Word Squares β€” LC 425 (Trie prefix-map + backtracking)

// A word square reads the same across rows and down columns: square[i][j] === square[j][i].
// Place words row by row; row r must start with the known prefix (column r of placed rows).
function wordSquares(words: string[]): string[][] {
  const n = words[0].length;

  const prefixMap = new Map<string, string[]>();   // prefix β†’ words that have it
  for (const w of words) {
    for (let i = 0; i <= w.length; i++) {
      const pre = w.slice(0, i);
      if (!prefixMap.has(pre)) prefixMap.set(pre, []);
      prefixMap.get(pre)!.push(w);
    }
  }

  const results: string[][] = [];
  const square: string[] = [];

  function backtrack(row: number): void {
    if (row === n) {
      results.push([...square]);
      return;
    }
    let prefix = '';
    for (let r = 0; r < row; r++) prefix += square[r][row];

    for (const cand of (prefixMap.get(prefix) ?? [])) {  // PRUNE: only valid prefixes
      square.push(cand);          // CHOOSE
      backtrack(row + 1);         // EXPLORE
      square.pop();               // UNCHOOSE
    }
  }

  for (const w of words) {        // any word can seed row 0
    square.push(w);
    backtrack(1);
    square.pop();
  }
  return results;
}
// The prefix-map is what bounds the branching β€” without it this is hopeless.

8.8 Word Ladder II β€” LC 126 (BFS layering + backtracking reconstruction)

// Two-phase Hard: (1) BFS level-by-level to record every parent on a shortest path,
// then (2) backtrack over those parents to emit ALL shortest paths.
function findLadders(beginWord: string, endWord: string, wordList: string[]): string[][] {
  const dict = new Set(wordList);
  const results: string[][] = [];
  if (!dict.has(endWord)) return results;

  const parents = new Map<string, string[]>();      // word β†’ words that reach it one step earlier
  let current = new Set<string>([beginWord]);
  const visited = new Set<string>([beginWord]);
  let found = false;

  while (current.size > 0 && !found) {
    const next = new Set<string>();
    for (const word of current) {
      for (let i = 0; i < word.length; i++) {
        for (let c = 97; c <= 122; c++) {            // try 'a'..'z' at each position
          const cand = word.slice(0, i) + String.fromCharCode(c) + word.slice(i + 1);
          if (!dict.has(cand) || visited.has(cand)) continue;
          if (!parents.has(cand)) parents.set(cand, []);
          parents.get(cand)!.push(word);             // record edge
          next.add(cand);
          if (cand === endWord) found = true;
        }
      }
    }
    for (const w of next) visited.add(w);  // mark AFTER the level β†’ keeps all same-level parents
    current = next;
  }

  // Phase 2: backtrack from endWord up the parent links, reverse at the base.
  const path: string[] = [endWord];
  function backtrack(word: string): void {
    if (word === beginWord) {
      results.push([...path].reverse());
      return;
    }
    for (const parent of (parents.get(word) ?? [])) {
      path.push(parent);
      backtrack(parent);
      path.pop();
    }
  }

  if (found) backtrack(endWord);
  return results;
}
// Why mark visited per-level (not per-node): a word can have multiple shortest-path
// parents in the SAME layer β€” marking too early drops valid paths.
🎀 Interview lens What they're really testing: the in-place visited trick β€” overwrite the cell, recurse, restore β€” and why it's O(1) extra yet still correct (only cells on the current path are marked). The sharp follow-up is spotting when a grid problem is plain DFS, not backtracking (LC 200 islands never un-marks β€” a filled cell stays filled). Nail it and they escalate: word search β†’ word search II (a trie over the grid + delete found words to prune) β†’ robot cleaner (no coordinates, you physically drive back) β†’ word ladder II (BFS to build the parent DAG, then backtrack it for all shortest paths).

9. TEMPLATE 5: CONSTRAINT SATISFACTION

Place one per row; check validity with O(1) sets.

πŸ”‘ Invariant The invariant: the constraint sets hold exactly the queens/digits placed so far β€” cols, diag1 = rβˆ’c, diag2 = r+c β€” and a placement is legal iff none of the three already contains its key. Why that's safe: the symmetric add-then-delete means row r's choices see precisely the queens above it and nothing stale, so every completed board is valid by construction.

9.1 N-Queens β€” LC 51

function solveNQueens(n: number): string[][] {
  const results: string[][] = [];
  const cols = new Set<number>();
  const diag1 = new Set<number>();  // row - col (top-left to bottom-right)
  const diag2 = new Set<number>();  // row + col (top-right to bottom-left)
  const board: number[] = [];       // board[row] = col where queen is placed

  function backtrack(row: number): void {
    if (row === n) {
      results.push(board.map(col =>
        '.'.repeat(col) + 'Q' + '.'.repeat(n - col - 1)
      ));
      return;
    }

    for (let col = 0; col < n; col++) {
      if (cols.has(col) || diag1.has(row - col) || diag2.has(row + col)) continue; // PRUNE

      cols.add(col); diag1.add(row - col); diag2.add(row + col);  // CHOOSE
      board.push(col);
      backtrack(row + 1);                                           // EXPLORE
      board.pop();
      cols.delete(col); diag1.delete(row - col); diag2.delete(row + col); // UNCHOOSE
    }
  }

  backtrack(0);
  return results;
}
// Time: O(n!)  Space: O(n)

9.2 N-Queens II (count only) β€” LC 52

function totalNQueens(n: number): number {
  let count = 0;
  const cols = new Set<number>(), diag1 = new Set<number>(), diag2 = new Set<number>();

  function backtrack(row: number): void {
    if (row === n) { count++; return; }
    for (let col = 0; col < n; col++) {
      if (cols.has(col) || diag1.has(row - col) || diag2.has(row + col)) continue;
      cols.add(col); diag1.add(row - col); diag2.add(row + col);
      backtrack(row + 1);
      cols.delete(col); diag1.delete(row - col); diag2.delete(row + col);
    }
  }

  backtrack(0);
  return count;
}

9.3 Sudoku Solver β€” LC 37

function solveSudoku(board: string[][]): void {
  const rowUsed = Array.from({ length: 9 }, () => new Set<number>());
  const colUsed = Array.from({ length: 9 }, () => new Set<number>());
  const boxUsed = Array.from({ length: 9 }, () => new Set<number>());

  for (let r = 0; r < 9; r++) {
    for (let c = 0; c < 9; c++) {
      if (board[r][c] !== '.') {
        const val = parseInt(board[r][c]);
        const box = Math.floor(r / 3) * 3 + Math.floor(c / 3);
        rowUsed[r].add(val); colUsed[c].add(val); boxUsed[box].add(val);
      }
    }
  }

  function backtrack(): boolean {
    for (let r = 0; r < 9; r++) {
      for (let c = 0; c < 9; c++) {
        if (board[r][c] !== '.') continue;

        const box = Math.floor(r / 3) * 3 + Math.floor(c / 3);
        for (let val = 1; val <= 9; val++) {
          if (rowUsed[r].has(val) || colUsed[c].has(val) || boxUsed[box].has(val)) continue;

          // CHOOSE
          board[r][c] = String(val);
          rowUsed[r].add(val); colUsed[c].add(val); boxUsed[box].add(val);

          if (backtrack()) return true;  // EXPLORE

          // UNCHOOSE
          board[r][c] = '.';
          rowUsed[r].delete(val); colUsed[c].delete(val); boxUsed[box].delete(val);
        }
        return false;  // no valid digit for this cell β†’ backtrack
      }
    }
    return true;  // all cells filled
  }

  backtrack();
}
// Time: O(9^m) where m = number of empty cells

9.4 N-Queens via Bitmask (⭐ SDE3 optimal β€” O(1) bit-ops)

function totalNQueensBitmask(n: number): number {
  let count = 0;
  const full = (1 << n) - 1;        // n low bits set = all columns occupied

  function backtrack(cols: number, diag1: number, diag2: number): void {
    if (cols === full) { count++; return; }   // every column filled β†’ valid board

    let avail = full & ~(cols | diag1 | diag2); // 1-bits = safe columns this row
    while (avail) {
      const pos = avail & (-avail);   // isolate lowest set bit (column to try)
      avail ^= pos;                   // remove it from candidates
      // diag1 shifts left, diag2 shifts right as we descend one row
      backtrack(cols | pos, (diag1 | pos) << 1, (diag2 | pos) >> 1);
    }
  }

  backtrack(0, 0, 0);
  return count;
}
// Time: O(n!) worst case but with tiny constants. Space: O(n) stack.
🎀 Interview lens What they're really testing: replacing an O(n) "is this square attacked?" scan with three O(1) sets β€” a column set plus the two diagonal keys rβˆ’c and r+c β€” and, for Sudoku, the find-first-empty-cell + return true short-circuit that stops the moment one solution appears. Nail it and they escalate: N-Queens β†’ count-only (LC 52) β†’ the bitmask form where the whole validity check becomes bit-ops (diag1 << 1, diag2 >> 1 as you descend a row) β€” the SDE3 "make it allocation-free" ask.

10. TEMPLATE 6: STRING PARTITIONING

Try every valid cut, recurse on the remaining suffix.

πŸ”‘ Invariant The invariant: path holds a sequence of valid pieces whose concatenation is exactly the prefix s[0..start); each recursion advances start past the piece it just chose. Why that's safe: every character is consumed exactly once, so the pieces always tile the string with no gaps and no overlaps β€” when start === n you have a complete, legal partition.

10.1 Palindrome Partitioning β€” LC 131

function partition(s: string): string[][] {
  const results: string[][] = [];
  const path: string[] = [];
  const n = s.length;

  // Precompute palindrome check with DP (O(n^2) preprocessing)
  const isPalin = Array.from({ length: n }, () => new Array(n).fill(false));
  for (let i = n - 1; i >= 0; i--) {
    for (let j = i; j < n; j++) {
      if (s[i] === s[j] && (j - i <= 2 || isPalin[i + 1][j - 1])) {
        isPalin[i][j] = true;
      }
    }
  }

  function backtrack(start: number): void {
    if (start === n) {
      results.push([...path]);
      return;
    }

    for (let end = start; end < n; end++) {
      if (!isPalin[start][end]) continue;  // PRUNE: not a palindrome

      path.push(s.slice(start, end + 1));  // CHOOSE
      backtrack(end + 1);                  // EXPLORE
      path.pop();                          // UNCHOOSE
    }
  }

  backtrack(0);
  return results;
}
// Time: O(n * 2^n)  Space: O(n^2) for isPalin table

10.2 Generate Parentheses β€” LC 22

function generateParenthesis(n: number): string[] {
  const results: string[] = [];
  const path: string[] = [];

  function backtrack(open: number, close: number): void {
    if (path.length === 2 * n) {
      results.push(path.join(''));
      return;
    }

    if (open < n) {                // can still add open
      path.push('(');
      backtrack(open + 1, close);
      path.pop();
    }
    if (close < open) {            // can close only if more opens exist
      path.push(')');
      backtrack(open, close + 1);
      path.pop();
    }
  }

  backtrack(0, 0);
  return results;
}
// Time: O(4^n / sqrt(n))  Catalan number

10.3 Letter Combinations β€” LC 17

function letterCombinations(digits: string): string[] {
  if (!digits.length) return [];
  const map: Record<string, string> = {
    '2': 'abc', '3': 'def', '4': 'ghi', '5': 'jkl',
    '6': 'mno', '7': 'pqrs', '8': 'tuv', '9': 'wxyz'
  };
  const results: string[] = [];
  const path: string[] = [];

  function backtrack(idx: number): void {
    if (idx === digits.length) {
      results.push(path.join(''));
      return;
    }
    for (const ch of map[digits[idx]]) {
      path.push(ch);
      backtrack(idx + 1);
      path.pop();
    }
  }

  backtrack(0);
  return results;
}

10.4 Restore IP Addresses β€” LC 93

function restoreIpAddresses(s: string): string[] {
  const results: string[] = [];
  const path: string[] = [];

  function backtrack(start: number): void {
    if (path.length === 4 && start === s.length) {
      results.push(path.join('.'));
      return;
    }
    if (path.length === 4 || start === s.length) return;

    for (let len = 1; len <= 3; len++) {
      if (start + len > s.length) break;
      const seg = s.slice(start, start + len);
      if (seg.length > 1 && seg[0] === '0') break;  // PRUNE: leading zero
      if (parseInt(seg) > 255) break;               // PRUNE: > 255

      path.push(seg);
      backtrack(start + len);
      path.pop();
    }
  }

  backtrack(0);
  return results;
}

10.5 Expression Add Operators β€” LC 282

function addOperators(num: string, target: number): string[] {
  const results: string[] = [];

  function backtrack(idx: number, expr: string, value: number, prev: number): void {
    if (idx === num.length) {
      if (value === target) results.push(expr);
      return;
    }

    for (let len = 1; len <= num.length - idx; len++) {
      const seg = num.slice(idx, idx + len);
      if (seg.length > 1 && seg[0] === '0') break;   // no leading zeros
      const cur = parseInt(seg);

      if (idx === 0) {
        backtrack(len, seg, cur, cur);                // first operand, no operator
      } else {
        backtrack(idx + len, expr + '+' + seg, value + cur, cur);
        backtrack(idx + len, expr + '-' + seg, value - cur, -cur);
        // Multiplication: undo the previous term, re-add (prev * cur)
        backtrack(idx + len, expr + '*' + seg, value - prev + prev * cur, prev * cur);
      }
    }
  }

  backtrack(0, '', 0, 0);
  return results;
}
// The "prev" trick for multiplication is a classic interview question

10.6 Remove Invalid Parentheses β€” LC 301 (Hard ⭐)

// Step 1: count MINIMUM removals needed (left/right surplus).
// Step 2: backtrack trying remove-or-keep for each char, only within that budget.
function removeInvalidParentheses(s: string): string[] {
  let lRem = 0, rRem = 0;
  for (const ch of s) {
    if (ch === '(') lRem++;
    else if (ch === ')') { if (lRem > 0) lRem--; else rRem++; }
  }

  const results = new Set<string>();

  function backtrack(idx: number, l: number, r: number, open: number, path: string): void {
    if (idx === s.length) {
      if (l === 0 && r === 0 && open === 0) results.add(path);
      return;
    }
    const ch = s[idx];

    // Option 1: REMOVE this char (only a paren, only if budget remains)
    if (ch === '(' && l > 0) backtrack(idx + 1, l - 1, r, open, path);
    if (ch === ')' && r > 0) backtrack(idx + 1, l, r - 1, open, path);

    // Option 2: KEEP this char
    if (ch === '(') backtrack(idx + 1, l, r, open + 1, path + ch);
    else if (ch === ')') {
      if (open > 0) backtrack(idx + 1, l, r, open - 1, path + ch);
    } else {
      backtrack(idx + 1, l, r, open, path + ch);   // letters always kept
    }
  }

  backtrack(0, lRem, rRem, 0, '');
  return [...results];
}
// Computing the removal budget first is what bounds the search β†’ no TLE.
🎀 Interview lens What they're really testing: that you precompute the palindrome table so each cut-test is O(1) (not O(n) per check), and that your prunes kill dead branches at the source β€” leading-zero and > 255 for IP, valid open/close counts for parentheses. Nail it and they escalate: palindrome partition β†’ restore IP β†’ generate parentheses (carry open/close counts) β†’ expression add operators (carry prev to undo the last term for *) β†’ remove invalid parentheses, where you compute the exact removal budget first to bound the whole tree.

11. PRUNING STRATEGIES

Pruning is what separates a working backtracker from a fast one. This is where Hard LC problems are won or lost.
Pruning techniqueHow to applyUsed in
1. Early termination via sorted inputif (sum > target) break β€” sorted, so all later are worse too (O(1) per node)39, 40, 216
2. Feasibility check (loop upper bound)Not enough items left to fill k: i ≀ n βˆ’ (k βˆ’ path.length) + 177
3. Duplicate skip (same level, sorted)i > start && arr[i] === arr[iβˆ’1] β†’ continue40, 90, 47
4. Constraint sets (O(1) validity)Sets for row / col / diag / box β†’ no O(n) scan per placement51, 37
5. Visited mask (inline for grids)board[r][c] = '#' in place β†’ O(1), restore on backtrack79, 212
6. Dead-subtree bound (lower bound)If remaining candidates can't reach the target β†’ break39
7. Trie pruning (word problems)Delete exhausted trie branches β†’ stop re-exploring dead paths212
8. Symmetry breakingOnly canonical orderings β€” e.g. skip identical empty buckets47, 698
9. Removal budgetCompute the min removals first, stay within it β†’ bounds the tree301
10. Memoization (hybrid)Same (idx, state) reached many times β†’ cache (see Β§12)698, 140

11.1 Pruning Code Patterns

// PATTERN A: sorted + break (most common)
candidates.sort((a, b) => a - b);
for (let i = start; i < candidates.length; i++) {
  if (candidates[i] > remaining) break;   // all further are also > remaining
}

// PATTERN B: feasibility upper bound (combination)
for (let i = start; i <= n - (k - path.length) + 1; i++) {
  // Only loop while enough elements remain to reach k
}

// PATTERN C: duplicate skip (sorted)
for (let i = start; i < arr.length; i++) {
  if (i > start && arr[i] === arr[i - 1]) continue;
}

// PATTERN D: constraint sets for O(1) validity
const rowUsed = new Set<number>();
if (!rowUsed.has(val)) {
  rowUsed.add(val);
  backtrack(row + 1);
  rowUsed.delete(val);
}

// PATTERN E: symmetry-break for bucket-fill (LC 698/473)
for (let b = 0; b < k; b++) {
  if (buckets[b] + nums[idx] > target) continue;
  buckets[b] += nums[idx];
  if (backtrack(idx + 1)) return true;
  buckets[b] -= nums[idx];
  if (buckets[b] === 0) break;   // ⭐ skip identical empty buckets
}

12. BACKTRACKING + MEMO HYBRIDS

Pure backtrackingMemoized backtracking (= top-down DP)
No repeated subproblemsSubproblems repeat across paths
Each path is uniqueDifferent paths β†’ same future state
Can't memoize (the path carries state)Future depends only on (idx, state)
e.g. LC 46, 78, 51, 37e.g. LC 139, 140, 698
When to memoize: If the SAME subproblem (same arguments) can be reached via MULTIPLE different paths, and the answer is always the same regardless of path taken β†’ cache it.

12.1 Word Break II β€” LC 140 (Backtracking + Memo)

function wordBreak(s: string, wordDict: string[]): string[] {
  const wordSet = new Set(wordDict);
  const memo = new Map<number, string[]>();

  function backtrack(start: number): string[] {
    if (memo.has(start)) return memo.get(start)!;
    if (start === s.length) return [''];   // base: empty suffix = one valid split

    const results: string[] = [];
    for (let end = start + 1; end <= s.length; end++) {
      const word = s.slice(start, end);
      if (!wordSet.has(word)) continue;

      const suffixResults = backtrack(end);
      for (const suffix of suffixResults) {
        results.push(suffix ? word + ' ' + suffix : word);
      }
    }

    memo.set(start, results);   // MEMOIZE: same start β†’ same results
    return results;
  }

  return backtrack(0);
}
// Without memo: O(2^n). With memo: subproblems O(n^2), each suffix list cached.

12.2 Partition to K Equal Subsets β€” LC 698 (bucket-fill β€” the expected answer)

// PRIMARY APPROACH: fill k buckets one element at a time.
// The symmetry-break pruning (skip empty buckets) is what turns TLE into AC.
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;

  nums.sort((a, b) => b - a);            // descending: place big items first (prune sooner)
  if (nums[0] > target) return false;

  const buckets = new Array(k).fill(0);

  function backtrack(idx: number): boolean {
    if (idx === nums.length) return true;  // every item placed

    for (let b = 0; b < k; b++) {
      if (buckets[b] + nums[idx] > target) continue;

      buckets[b] += nums[idx];             // CHOOSE
      if (backtrack(idx + 1)) return true; // EXPLORE
      buckets[b] -= nums[idx];             // UNCHOOSE

      // ⭐ SYMMETRY-BREAK: if this bucket was empty, all other empty buckets
      // are identical β€” trying them gives the same failure. Stop here.
      if (buckets[b] === 0) break;
    }
    return false;
  }

  return backtrack(0);
}
// Same template solves LC 473 (Matchsticks β†’ k=4) and LC 1655.
Bitmask + memo variant: define dp[mask] = true if chosen subset can be split into complete buckets. The partial fill is sum(mask) % target β€” fully determined by mask alone. Key the memo on mask ONLY (never mask*1000+sum β€” that causes collisions). O(nΒ·2^n) states.

12.3 When Memoization Does NOT Help

// LC 46 Permutations: path [1,2,_] and path [2,1,_] both need element 3 next,
// BUT the future valid completions differ per path (no overlap).
// Memoization on just `index` won't work β€” need full path state.
// β†’ Pure backtracking is correct here. Don't memoize.

// MEMO TRIGGER CHECKLIST:
// βœ… Same function arguments always produce the same result?   β†’ memoize
// βœ… Same arguments reachable via different paths?             β†’ memoize
// ❌ Path itself influences what's "available" next?           β†’ don't memoize
// ❌ Each recursive call explores a structurally unique path?  β†’ don't memoize
🎀 Interview lens What they're really testing: the single decision that separates pure backtracking from top-down DP β€” can the same (index, state) be reached by different paths with the same future? If yes, memoize and it's DP; if the path itself changes what's available next (permutations), a memo on the index is wrong. Nail it and they escalate: word break β†’ word break II (cache the suffix lists) β†’ partition to k equal subsets (bucket-fill + the empty-bucket symmetry break) β†’ its bitmask-memo form keyed on mask alone. Naming why a memo is or isn't valid is the whole point.

12.4 Game search β€” Flip Game II (LC 294)

β€œCan the player to move force a win?” = exists a move after which the opponent cannot.

πŸ”‘ Invariant canWin(s) is true iff some legal move leads to a string t with canWin(t) === false. No legal move β†’ false. The state is the whole string, so memoise on it. This is backtracking with a boolean return β€” the bridge to Game-Theory DP.
function canWin(s: string, memo: Map<string, boolean> = new Map()): boolean {
  const hit = memo.get(s);
  if (hit !== undefined) return hit;
  for (let i = 0; i + 1 < s.length; i++) {
    if (s[i] === '+' && s[i + 1] === '+') {
      const next = s.slice(0, i) + '--' + s.slice(i + 2);
      if (!canWin(next, memo)) { memo.set(s, true); return true; }   // found a move that leaves the opponent losing
    }
  }
  memo.set(s, false);                                                  // every move hands the opponent a win (or no moves)
  return false;
}
// Follow-up: Sprague–Grundy numbers per run of '+' solve it in O(nΒ²).

12.5 Expression search with floats β€” 24 Game (LC 679)

Merge any two numbers with any operator, recurse on the shorter list; parentheses come for free.

πŸ”‘ Invariant Picking a pair and replacing it with a op b explores every parenthesisation. Fixed size (4 cards) keeps the tree tiny (~4k leaves). Two hazards: compare to 24 with an epsilon, and try both aβˆ’b/bβˆ’a and aΓ·b/bΓ·a (not commutative) while skipping division by β‰ˆ0.
function judgePoint24(cards: number[]): boolean {
  const EPS = 1e-6;
  function solve(a: number[]): boolean {
    if (a.length === 1) return Math.abs(a[0] - 24) < EPS;
    for (let i = 0; i < a.length; i++) {
      for (let j = i + 1; j < a.length; j++) {
        const rest = a.filter((_, k) => k !== i && k !== j);
        const x = a[i], y = a[j];
        const results = [x + y, x * y, x - y, y - x];
        if (Math.abs(y) > EPS) results.push(x / y);
        if (Math.abs(x) > EPS) results.push(y / x);
        for (const v of results) if (solve([...rest, v])) return true;
      }
    }
    return false;
  }
  return solve(cards);
}

13. COMPLEXITY ANALYSIS

Problem typeTimeSpace (excl. output)
Subsets (n elements)O(n · 2ⁿ)O(n) recursion depth
Permutations (n)O(n Β· n!)O(n)
Combinations (n, k)O(k Β· C(n,k))O(k)
Combination sum (no dup)O(n^(T/M))O(T/M) depth
Grid word searchO(mΒ·n Β· 3^L)O(L), L = word length
N-Queens (n)O(n!)O(n)
Sudoku (m empty cells)O(9^m)O(m)
Palindrome partitionO(n · 2ⁿ)O(n²) for the DP table

13.1 The Branching Factor Model

Time = (# leaves) Γ— (work to build each leaf)
     = (branching factor)^depth Γ— depth

For subsets: branching=2 (include/skip), depth=n β†’ 2^n leaves Γ— O(n) = O(nΓ—2^n)
For combos:  branchingβ‰ˆn, depth=k β†’ C(n,k) leaves Γ— O(k) = O(kΓ—C(n,k))
For perms:   branching=n at depth 0, n-1 at depth 1... β†’ n! leaves Γ— O(n)

INTERVIEW TIP: State the tree model first, then derive complexity.
"The recursion tree has branching factor b and depth d,
 giving O(b^d) nodes with O(d) work per node β†’ O(d Γ— b^d)."

⚠️ 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
results.push(path) instead of results.push([...path]).Any input with β‰₯1 result β†’ every stored answer is the same array, which ends up [] after all the pops. Output looks like [[],[],…].
Forgetting the un-choose (no path.pop() / state not restored).subsets([1,2]) β†’ path never shrinks, so a sibling branch inherits [1,2] and you emit duplicates and wrong-length answers.
Duplicate skip written i > 0 instead of i > start.subsetsWithDup([1,2,2]) β†’ i>0 also skips the legit second 2 when it's the first pick of a level, so you lose [2,2].
Mixing up i+1 (no reuse) with i (reuse) in the recurse.combinationSum([2,3], 6) β†’ with i+1 you miss [2,2,2]; using i in a no-reuse problem yields illegal repeats.
Grid: marking visited but never restoring (or restoring the wrong value).Word Search where the answer needs a cell on a different path β†’ the permanently-# cell blocks it, giving a false "not found".
Permutation dedup missing the !used[i-1] clause.permuteUnique([1,1]) β†’ emits [1,1] twice instead of once.
Sorting is required for the dup-skip / break prune, but you forgot to sort.combinationSum2([2,1,2], 3) unsorted β†’ duplicates slip through and the break fires wrongly.
πŸ”— This pattern, elsewhere Backtracking IS DFS β€” the same recursion drilled in recursion.html; choose/un-choose is just work placed before/after the recursive call. Grid & graph search (LC 79, 200, 797, 332) lives next door in graph.html β€” the only difference is whether you un-mark on the way back (backtracking) or not (plain DFS / flood fill). Add a memo and backtracking becomes top-down DP (Β§12) β€” the test is whether the same (index, state) recurs across paths. Word Search II bolts a trie onto the grid walk to prune dead prefixes.

β–Ά Coverage β€” what these 6 templates unlock on LeetCode

Own the six skeletons plus pruning (Β§11) and these are all in range. Bold = Hard tier β€” the escalation once the medium is in hand.

Template (Β§)Representative LeetCode (medium β†’ hard)Tier
Subsets (Β§5)78 Β· 90 Β· 416 Partition Equal Subset Β· 698 K Equal SubsetsMed
Permutations (Β§6)46 Β· 47 Β· 31 Next Permutation Β· 351 Android Unlock Β· 60 Permutation Sequence Β· 996 Squareful ArraysMed β†’ Hard
Combinations (Β§7)77 Β· 39 Β· 40 Β· 216 Β· 17 Letter CombinationsMed
Grid / path (Β§8)79 Β· 200 Β· 130 Β· 212 Word Search II Β· 489 Robot Cleaner Β· 980 Unique Paths IIIMed β†’ Hard
Constraint (Β§9)51 Β· 52 Β· 37 Sudoku SolverMed β†’ Hard
String partition (Β§10)131 Β· 93 Β· 22 Β· 282 Add Operators Β· 301 Remove Invalid ParensMed β†’ Hard
Backtracking + memo (Β§12)139 Word Break Β· 140 Word Break II Β· 698 Β· 473 Matchsticks to Square Β· 294 Flip Game II Β· 679 24 GameMed β†’ 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 start any backtracking LC medium/hard cold.

Cover the right column (it's blurred β€” hover/tap to reveal). Answer from memory; anything that stalls you = the section to re-read.

PromptAnswer
The three-beat rhythm?choose β†’ explore β†’ un-choose
"find ALL / count the ways" β†’ which tool?Backtracking.
"the single best" + overlapping subproblems?DP, not backtracking.
Subsets: collect at every node or only leaves?Every node β€” each path-so-far is itself a subset.
Permutations: how to avoid reusing an element?A used[] / visited array.
Combinations: avoid [1,2] and [2,1]?Pass a start index; loop i = start…
Skip duplicates on one level (sorted input)?if (i > start && a[i]===a[i-1]) continue
Why push([...path]), not push(path)?Arrays are by-reference β€” store a snapshot copy.
What turns a TLE into an AC?Pruning β€” cut dead branches early.
Android Unlock: when is a move between two keys legal?The target is unvisited AND the key between them (if any) is already visited; knight-style jumps have no middle key.
k-th permutation β€” why not just enumerate?Each leading digit owns (nβˆ’1)! permutations: idx = ⌊k / (nβˆ’1)!βŒ‹, k %= (nβˆ’1)!, repeat with 0-indexed k. O(nΒ²) vs O(nΒ·n!).
Flip Game II β€” how do you decide canWin?True iff some move leaves the opponent in a state where canWin is false. Memoise on the whole string.
Backtracking time is roughly?(branching factor)depth Γ— work-per-leaf.

Then write these cold (no peeking): (1) subsets, (2) permutations, (3) combination sum, (4) N-Queens, (5) word search on a grid. All five start-to-finish = you own backtracking.