Skip to the chapter
←DSAGGroundworkΣDSA
DSA/Advanced/A10
44 / 48 written chapters
/

Levels and plan
  1. B1Complexity analysis
  2. B2JavaScript toolkit
  3. B3Arrays & strings
  4. B4Prefix sums
  5. B5Hashing
  6. B6Two pointers
  7. B7Sliding window
  8. B8Binary search
  9. B9Basic recursion
  10. B10Sorting algorithms
  11. B11Stacks & queues
  12. B12Linked lists
  1. I1Trees
  2. I2BST operations
  3. I3Tree problems in depth
  4. I4Heaps & priority queues
  5. I5Graphs: representation
  6. I6BFS on grids
  7. I7Graph problems
  8. I8Topological patterns
  9. I9Backtracking
  10. I10DP: 1D
  11. I11DP: 2D
  12. I12DP state machines
  13. I13Greedy algorithms
  14. I14Intervals
  15. I15Bit manipulation
  16. I16Matrix problems
  17. I17Tries
  18. I18Monotonic stack & queue
  19. I19Math for interviews
  20. I20Design & simulation
  21. I21Divide and conquer
  22. I22Randomization
  1. A1Advanced DP
  2. A2Union-Find
  3. A3Advanced graph algorithms
  4. A4Minimum Spanning Tree
  5. A5Advanced graphs
  6. A6Segment & Fenwick trees
  7. A7Sparse tables
  8. A8String algorithms
  9. A9Design problems
  10. A10Advanced backtracking
  11. A11Games & probability DP
  12. A12Ordered structures
  13. A13Sweep line & geometry
  14. A14Interview strategy

ΣA10 · Part 3 · Advanced

Advanced backtracking

Same three lines as before — the advanced part is saying "no" earlier and storing state in bits.

45 min read2 diagrams16 sections12 exercises
44 of 48

In this chapter

  • What actually separates advanced backtracking from basic
  • Sets are correct; integers are fast
  • N-Queens with bitmasks — the canonical example
  • Sudoku — bitmasks plus constraint propagation
  • Word Search II — a Trie prunes the whole dictionary at once

and 11 more

about 45 min
16On this page
  1. What actually separates advanced backtracking from basic
  2. Sets are correct; integers are fast
  3. N-Queens with bitmasks — the canonical example
  4. Sudoku — bitmasks plus constraint propagation
  5. Word Search II — a Trie prunes the whole dictionary at once
  6. Branch and bound — pruning with a number, not just a rule
  7. Partition to K equal sum subsets: fill the buckets, number by number
  8. Symmetry breaking: never explore the same subtree twice
  9. Expression add operators: carry the last term so multiplication can undo it
  10. Remove invalid parentheses: decide what to remove, then never repeat a result
  11. Unique paths III: a Hamiltonian-path count with a visited grid
  12. Meet in the middle: when 2n is too big and 2n/2 is not
  13. Memoized backtracking: when the state is small, stop re-exploring it
  14. Ordering the search is free speed
  15. Complexity at this level: say the honest thing
  16. Recognizing it in an unseen problem

What actually separates advanced backtracking from basic

The core algorithm does not change. You still choose, explore, un-choose. What changes at this level is how cheap a decision is and how early you can reject one. Every advanced backtracking problem is the same template plus one or both of these upgrades: a smarter state representation (integers and bitmasks instead of sets and arrays) and aggressive pruning (a bound, a constraint propagation step, or a shared prefix structure like a Trie that says "no word in the entire dictionary continues this way").

Both upgrades attack the same number: nodes visited. Constant-factor work per node matters, but cutting a subtree removes 2^k or k! leaves at once. Pruning wins by orders of magnitude; state representation then makes each surviving node 5-10× cheaper.

root feasible bound fails ✗ pruned one O(1) test removes every leaf below it …the half you actually explore…
The prune is a single comparison at an internal node; what it saves is exponential in the depth remaining beneath that node.

Sets are correct; integers are fast

The intermediate N-Queens solution tracked attacks with three Sets. That is O(1) per lookup amortized, but each operation hashes a number, touches a heap-allocated bucket, and may allocate. In a search that visits millions of nodes, that constant factor is the whole runtime. When the domain is small and dense — "which of these ≤32 columns are used," "which of the digits 1-9 are taken" — a single 32-bit integer replaces the set entirely.

OperationWith a SetWith a bitmask
Is x used?s.has(x)(mask >> x) & 1
Mark x useds.add(x)mask | (1 << x)
Un-mark xs.delete(x)mask ^ (1 << x)
All still-legal options at onceloop + 3 lookupsfull & ~(a | b | c) — one expression
Iterate only the legal optionsnot possible directlywhile (free) { bit = free & -free; free ^= bit; }
Pass state to the recursive callmutate + undopass a new integer — nothing to undo

The last two rows are the ones that change how the code reads. With sets you loop over all candidates and skip the illegal ones; with a mask you compute the legal ones as a number and loop over exactly those. And because integers are values, not references, the "un-choose" step disappears — the caller's copy was never modified in the first place.

N-Queens with bitmasks — the canonical example

Three integers replace three sets. cols has bit c set if column c is taken. The diagonals are the clever part: instead of keying by row - col and row + col, store the diagonal attacks projected onto the current row, and shift them by one as you descend. A "" diagonal moves one column right per row down, so its mask shifts left; a "/" diagonal moves one column left, so its mask shifts right.

// all N-Queens boards — O(n!) worst case, but with a tiny constant per node
function solveNQueens(n) {
  const full = (1 << n) - 1; // n low bits set: the whole board width
  const results = [], placement = [];

  function place(row, cols, diag1, diag2) {
    if (row === n) {
      results.push(placement.map(c => ".".repeat(c) + "Q" + ".".repeat(n - c - 1)));
      return;
    }

    let free = full & ~(cols | diag1 | diag2); // every safe column, computed in one step

    while (free) {
      const bit = free & -free;  // isolate the lowest set bit — the next safe column
      free ^= bit;               // consume it so the loop terminates
      const col = 31 - Math.clz32(bit); // bit -> column index, only for the output board

      placement.push(col);
      place(
        row + 1,
        cols | bit,
        ((diag1 | bit) << 1) & full, // "" attacks slide one column right next row
        (diag2 | bit) >> 1           // "/" attacks slide one column left next row
      );
      placement.pop(); // the ONLY thing left to undo — the masks were never mutated
    }
  }

  place(0, 0, 0, 0);
  return results;
}

Dry run: solveNQueens(4) — free masks along the path to its first solution

Callfree = full & ~(cols|diag1|diag2)column taken on this pathplacement
place(0, cols=0000, diag1=0000, diag2=0000)11111[1]
place(1, cols=0010, diag1=0100, diag2=0001)10003[1, 3]
place(2, cols=1010, diag1=1000, diag2=0100)00010[1, 3, 0]
place(3, cols=1011, diag1=0010, diag2=0010)01002[1, 3, 0, 2]
place(4, cols=1111, diag1=1100, diag2=0011)0000row === n → push[1, 3, 0, 2] ✓

The table follows the branch that reaches the first solution. At row 0, free & -free of 1111 is 0001, so the loop tries column 0 first; that branch runs out of legal columns by row 3, and only after backing up does it take column 1, the one shown. From row 1 on, free is a single bit (1000, 0001, 0100): each queen is a forced move, not a choice among several. Every one of these free values costs one AND, one OR, one NOT, and one shift, regardless of how many columns are actually occupied — that constant cost per node is exactly what three Sets could not offer.

If the question only asks how many solutions exist (N-Queens II), delete placement and Math.clz32 entirely and return a counter. The recursion then touches nothing but four integers — no arrays, no allocation, no garbage collection pressure anywhere in the hot path.

cols diag1 << 1 diag2 >> 1 free 1 0 0 1 0 0 1 0 0 0 0 0 0 0 1 0 0 0 ✗ ✓ ✗ ✗ ✓ ✓ free = full & ~(a|b|c) one instruction, not a loop column 2 is blocked only by the diag2 row; the OR merges all three rows
Three independent constraints collapse into one integer, and the loop then iterates only over the columns that survived.
⚠ JavaScript bitwise operators are 32-bit and signed &, |, ^, << and ~ coerce their operands to signed 32-bit integers, so 1 << 31 is negative and 1 << 32 is 1, not 4294967296. Bitmask search is therefore safe up to about n = 30 — which covers every N-Queens or subset-mask problem an interviewer will hand you, since 2³⁰ states is already far past the time limit. Above that you need BigInt (much slower) or an array of words. Also use >>> rather than >> if a mask could ever have bit 31 set, because >> sign-extends.
Say it like this → "The algorithm is still plain backtracking — the change is that the board state is three integers instead of three sets. full & ~(cols | diag1 | diag2) gives me every legal column in one operation, and free & -free lets me iterate only the legal ones instead of looping over all n and rejecting most. Same asymptotics, roughly an order of magnitude on the constant, and the un-choose step disappears because integers are passed by value."

Sudoku — bitmasks plus constraint propagation

Sudoku is where the two upgrades combine. Each row, column and 3×3 box gets a 9-bit mask of digits already used, so the candidate set for any cell is one OR and one AND away. Then comes the real accelerator: most-constrained variable first (MRV). Instead of filling cells left-to-right, always recurse on the empty cell with the fewest candidates. A cell with one candidate is a forced move; a cell with zero candidates means this branch is already dead, and you learn that before spending a single guess.

// solves a 9x9 board of "1".."9" and "." in place
function solveSudoku(board) {
  const rows = new Array(9).fill(0);
  const cols = new Array(9).fill(0);
  const boxes = new Array(9).fill(0);
  const empties = [];
  const boxOf = (r, c) => ((r / 3) | 0) * 3 + ((c / 3) | 0);
  const ALL = 0x1FF; // 9 low bits set = digits 1..9

  for (let r = 0; r < 9; r++) {
    for (let c = 0; c < 9; c++) {
      if (board[r][c] === ".") { empties.push([r, c]); continue; }
      const bit = 1 << (board[r][c].charCodeAt(0) - 49); // "1" -> bit 0
      rows[r] |= bit; cols[c] |= bit; boxes[boxOf(r, c)] |= bit;
    }
  }

  const candidates = (r, c) => ALL & ~(rows[r] | cols[c] | boxes[boxOf(r, c)]);
  const popcount = (x) => { let n = 0; while (x) { x &= x - 1; n++; } return n; };

  function solve(k) {
    if (k === empties.length) return true;

    // MRV: find the most-constrained remaining cell and swap it into slot k
    let pick = k, fewest = 10;
    for (let i = k; i < empties.length; i++) {
      const cnt = popcount(candidates(empties[i][0], empties[i][1]));
      if (cnt < fewest) { fewest = cnt; pick = i; if (cnt <= 1) break; }
    }
    if (fewest === 0) return false; // a cell with no legal digit — dead branch, prune now

    const swap = empties[k]; empties[k] = empties[pick]; empties[pick] = swap;
    const [r, c] = empties[k], b = boxOf(r, c);

    let free = candidates(r, c);
    while (free) {
      const bit = free & -free;
      free ^= bit;

      rows[r] |= bit; cols[c] |= bit; boxes[b] |= bit;
      board[r][c] = String.fromCharCode(49 + (31 - Math.clz32(bit)));

      if (solve(k + 1)) return true;

      rows[r] ^= bit; cols[c] ^= bit; boxes[b] ^= bit; // un-choose: XOR clears the bit we set
      board[r][c] = ".";
    }

    empties[pick] = empties[k]; empties[k] = swap; // restore the ordering before failing upward
    return false;
  }

  solve(0);
  return board;
}

The swap-into-slot-k trick keeps the "remaining cells" set implicit — indices k..end are unfilled, 0..k-1 are done — so MRV costs a linear scan of the remainder instead of a heap, and reordering needs no extra structure. Restoring the swap on the failure path is what keeps the invariant true for the caller.

⚠ Un-choosing with &= ~bit vs ^= bit Both clear a bit, but they differ when the bit is not currently set: &= ~bit is idempotent, ^= bit would set it. Here ^= is correct and self-documenting precisely because we know we just set it. The dangerous case is a problem where the same value can be chosen along two different paths in the same frame — then XOR silently corrupts state and you get answers that are valid-looking but wrong. When in doubt, use &= ~bit.

Real constraint propagation goes one step further than MRV: after placing a digit, repeatedly scan for any cell that now has exactly one candidate and place it too, with no branching at all, until nothing more is forced. Most "hard" published Sudokus solve almost entirely by propagation with a handful of guesses. You will not usually be asked to implement it, but naming it — "this is MRV plus unit propagation, the same idea a SAT solver uses" — is a strong signal.

Word Search II — a Trie prunes the whole dictionary at once

The naive extension of Word Search is to run the single-word grid search once per word: O(W · R · C · 4^L). That re-walks the same board prefixes for every word sharing them. The fix is to invert the loop — walk the board once and carry a Trie node (see the Tries chapter) alongside the position. The moment the current cell's letter has no child in the Trie, no word in the entire dictionary continues this way, and the branch dies immediately.

function findWords(board, words) {
  const root = {};
  for (const w of words) { // build the Trie: shared prefixes are shared work
    let node = root;
    for (const ch of w) node = node[ch] || (node[ch] = {});
    node.word = w; // terminal marker that also carries the answer string
  }

  const R = board.length, C = board[0].length, found = [];

  function dfs(r, c, parent) {
    const ch = board[r][c];
    const node = parent[ch];
    if (!node) return; // THE prune: no dictionary word continues with this letter

    if (node.word) { found.push(node.word); delete node.word; } // delete = dedupe, no Set needed

    board[r][c] = "#"; // "#" is never a Trie key, so revisits prune on the line above
    if (r > 0)     dfs(r - 1, c, node);
    if (r + 1 < R) dfs(r + 1, c, node);
    if (c > 0)     dfs(r, c - 1, node);
    if (c + 1 < C) dfs(r, c + 1, node);
    board[r][c] = ch; // un-choose

    if (Object.keys(node).length === 0) delete parent[ch]; // trim exhausted branches for good
  }

  for (let r = 0; r < R; r++) for (let c = 0; c < C; c++) dfs(r, c, root);
  return found;
}

Three separate prunes are stacked here and each is worth naming out loud: (1) missing child kills a branch in O(1); (2) delete node.word after a hit means a duplicate is never reported and no Set is needed; (3) deleting a Trie node once its subtree is empty shrinks the dictionary permanently, so later starting cells search a strictly smaller structure. Complexity drops from O(W · R · C · 4^L) to O(R · C · 4^L) with L now bounded by the longest word, and in practice far below that because the Trie kills most branches at depth 2-3.

⚠ Mutating the Trie while iterating over it The delete parent[ch] line runs after the recursion, on the way out, which is safe. Deleting a node while a sibling call is still walking it — or trimming node.word before you have pushed the string — produces missing results that are miserable to debug. If the interviewer objects to mutating the input board or the Trie, offer the visited-set variant and note the extra allocation cost; do not argue that mutation is fine.

Branch and bound — pruning with a number, not just a rule

Everything above prunes on feasibility: this branch cannot produce a valid answer. Branch and bound prunes on optimality: this branch cannot produce a better answer than one already found. You need three ingredients — the best answer so far, an optimistic bound on what the current branch could still reach, and the comparison between them.

The simplest form is the one already hiding in Combination Sum. Sort the candidates and break instead of continue:

function combinationSum(input, target) {
  const candidates = [...input].sort((a, b) => a - b); // sorting is what makes the break valid; the copy leaves the caller's array alone
  const results = [], path = [];

  function go(start, remaining) {
    if (remaining === 0) { results.push([...path]); return; }

    for (let i = start; i < candidates.length; i++) {
      if (candidates[i] > remaining) break; // sorted ⇒ every LATER candidate also overshoots
      path.push(candidates[i]);
      go(i, remaining - candidates[i]);
      path.pop();
    }
  }

  go(0, target);
  return results;
}

continue would still be correct and would still terminate — it just wastes the rest of the loop testing values that are provably too large. Turning a continue into a break by first establishing an ordering is the smallest, most repeatable pruning upgrade there is, and it applies to Combination Sum II, Palindrome Partitioning and most "sum to target" variants unchanged.

The general version needs a real bound function. For 0/1 knapsack, the classic optimistic estimate is the fractional relaxation: sort items by value density and pretend you may take a fraction of the item that straddles the capacity limit. That is never worse than the true optimum, so if even it cannot beat the incumbent, the whole subtree is dead.

// 0/1 knapsack by branch and bound — exponential worst case, near-instant in practice
function knapsack(input, capacity) {
  const items = [...input].sort((a, b) => b.value / b.weight - a.value / a.weight); // densest first
  const n = items.length;
  let best = 0;

  // optimistic: allow a fractional last item, so bound >= any real completion
  function bound(i, room, taken) {
    let estimate = taken, left = room;
    for (let j = i; j < n && left > 0; j++) {
      const take = Math.min(items[j].weight, left);
      estimate += items[j].value * (take / items[j].weight);
      left -= take;
    }
    return estimate;
  }

  function go(i, room, taken) {
    if (i === n) { best = Math.max(best, taken); return; }
    if (bound(i, room, taken) <= best) return; // THE bound: cannot beat the incumbent

    // take first: densest-first ordering raises `best` fast, which strengthens every later bound
    if (items[i].weight <= room) go(i + 1, room - items[i].weight, taken + items[i].value);
    go(i + 1, room, taken);
  }

  go(0, capacity, 0);
  return best;
}
Prune typeQuestion it answersTypical problems
FeasibilityCan this partial answer still be completed at all?N-Queens, Sudoku, Word Search
Ordering / breakAre all remaining choices provably worse than this failing one?Combination Sum, Palindrome Partitioning
BoundCan this subtree beat the best answer found so far?Knapsack, TSP, job scheduling, Optimal Account Balancing
Memo / dedupeHave I already explored this exact state?Partition to K Equal Sum Subsets, bitmask DP

The fourth row is the boundary where backtracking turns into DP. If the state reaching a node is fully described by a small key (a bitmask of used elements, an index plus a remainder), memoize it and the exponential tree collapses to a polynomial-in-2^n table — that is exactly the bridge into the bitmask-DP section of the Advanced DP chapter.

Partition to K equal sum subsets: fill the buckets, number by number

Given positive integers nums and a count k, can all of nums be split into k groups with the same sum? Every number is used exactly once. Matchsticks to Square is the same problem with k = 4.

Three cheap checks come before any search, and each is a prune in disguise. The total must be divisible by k, otherwise no split exists. Each group must then sum to target = total / k. And no single number may exceed target, since it has to live in some group.

Now choose what the recursion decides. The tempting formulation is "pick a subset that sums to target, remove it, repeat". It works, but it is awkward to bound. The cleaner one flips the question: process the numbers one at a time and decide which of the k buckets each goes into. The tree has depth n and branching factor k, so kn leaves in the worst case. For n = 16 and k = 4 that is over four billion, and the rest of the section is about why you will never see anything like that many.

Two prunes do the work. Overflow: a bucket whose sum would exceed target is not a choice; since all numbers are positive, a bucket that overflows can never come back down. Order: sort descending. A large number has the fewest buckets it still fits in, so it is the one most likely to fail, and failing at depth 2 prunes far more than failing at depth 14. When the last number is placed, no bucket exceeds target and the buckets sum to k × target, so every bucket equals target; no final check is needed.

function canPartitionKSubsets(nums, k) {
  const total = nums.reduce((a, b) => a + b, 0);
  if (total % k !== 0) return false;
  const target = total / k;
  const sorted = [...nums].sort((a, b) => b - a); // big numbers first: they have the fewest places to go
  if (sorted[0] > target) return false;
  const sums = new Array(k).fill(0); // sums[b] = what bucket b holds so far

  function place(i) {
    if (i === sorted.length) return true; // no bucket exceeds target and the total is k * target, so all equal target
    const tried = new Set();
    for (let b = 0; b < k; b++) {
      const s = sums[b];
      if (s + sorted[i] > target || tried.has(s)) continue; // overflow, or a twin of a bucket already tried
      tried.add(s);
      sums[b] += sorted[i];
      if (place(i + 1)) return true;
      sums[b] -= sorted[i];
    }
    return false;
  }

  return place(0);
}

Dry run: nums = [3, 3, 2, 2, 2, 2], k = 2, so target = 7

Sorted descending the numbers stay 3, 3, 2, 2, 2, 2. The state is the two bucket sums.

StepNumber placedBucket sumsWhat happens
13[3, 0]first bucket tried
23[6, 0]second 3 also goes into bucket 0 (6 <= 7)
3-52, 2, 2[6, 2], [6, 4], [6, 6]bucket 0 would reach 8, so each 2 goes to bucket 1
6last 2[6, 6]8 in either bucket: dead end. Undo the three 2s, then undo the second 3
7second 3[3, 3]the next choice for that number: bucket 1
8-112, 2, 2, 2[5, 3], [7, 3], [7, 5], [7, 7]every number placed, both buckets at 7: true

The failed branch in step 6 is the whole cost model: an early wrong choice (putting both 3s together) is only discovered after the numbers that could have exposed it have been placed. Sorting does not remove such branches, it makes them surface earlier.

⚠ What the three checks and the prune assume (1) The numbers are positive. With zeros or negatives a bucket can exceed target and later return to it, so the overflow prune is wrong and so is "no final check needed". (2) Without the total % k test, total / k is a fraction and the search wastes its whole tree before answering false. (3) The sort is on a copy; sorting nums in place mutates the caller's array. (4) The tried set deserves its own section below, because it is the line that turns this from "too slow" into "fast".

Symmetry breaking: never explore the same subtree twice

Look again at the inner loop of the partition search. At the very start every bucket is empty, so the first number has k placements that are all the same in every way that matters: bucket 2 and bucket 3 are just names. Whatever completion works with the number in bucket 0 works equally with it in bucket 1, by swapping what the two buckets are called. The search tries all k and discovers k − 1 copies of one answer, and this repeats at every depth.

The fix needs a precise statement of when two choices are interchangeable. Here the rest of the search depends only on the multiset of bucket sums, because every remaining number can go into any bucket that has room. So two buckets with the same current sum are interchangeable: if placing the number in the first leads to no solution, placing it in the second cannot lead to one either. The tried set records the sums already attempted in this call, and a bucket whose sum is in the set is skipped. That is duplicate-bucket skipping, and it is all that "symmetry breaking" means in practice: cut the choices that lead to subtrees you have already proven equivalent.

Here is the effect, counted by instrumenting the function to tally calls. The input is [1, 3, 6, 3, 9, 3, 8, 7, 3, 3, 3, 7, 6, 6] with k = 4. The total is 68, so the target is 17, and the answer is false, which forces the search to exhaust its tree.

VariantCalls to placeAgainst the plain version
Overflow check only, input order2,589,3291×
Sorted descending153,741about 17× fewer
Skip equal-sum buckets, input order59,591about 43× fewer
Sorted descending and skip equal-sum buckets3,700about 700× fewer

The two ideas multiply because they remove different waste. Sorting makes a doomed branch fail early; skipping twins stops that branch being explored once per equivalent labelling. The worst case is still exponential, so the honest claim is that the number of nodes visited dropped by roughly three orders of magnitude on this input, not that the bound improved. Other inputs show smaller or larger gains.

⚠ Symmetry that is not there Skipping twins is valid only when the choices really are interchangeable for everything that happens later. It is wrong if the buckets have different capacities (bin packing with differently sized bins), if they carry labels the answer depends on (assign tasks to named workers and report who got what), or if you skip on a property that is not the whole state (comparing only the bucket's last item, not its sum). Before adding the line, say which quantity determines the future, and check that equal values of it mean equal futures.

The same move appears wherever identical things are chosen from. In Combination Sum II and Subsets II the guard if (i > start && items[i] === items[i - 1]) continue; after sorting is symmetry breaking between equal numbers: picking either copy of a 2 leads to the same set of results. In N-Queens the board has a mirror image, so you may count only the boards whose first-row queen is in the left half and double that (for n = 8 the left half has 46 boards and the total is 92; for odd n, add the boards with the queen in the middle column once). The pattern is always: find a relabelling of the search that maps solutions to solutions, and explore one representative of each class.

Say it like this → "Two buckets with the same current sum are interchangeable, because everything that follows depends only on the multiset of sums. So at each number I try each distinct sum once and skip any bucket whose sum I have already tried. I'll sort descending as well, so the numbers with the fewest legal buckets fail first. It's still exponential in the worst case; it just visits far fewer nodes."

Expression add operators: carry the last term so multiplication can undo it

Given a string of digits num and an integer target, return every way to put +, - or * between digits, or nothing at all (so digits join into a longer number), such that the expression equals target. For "232" and target 8 the answers are 2*3+2 and 2+3*2.

The choice at each step is clear: how many digits does the next operand take, and which operator goes in front of it? Between two digits there are four options (join, +, -, *), so a string of n digits has 4n-1 candidate expressions when none of them contains a zero; for "232" that is 16, and the search below visits exactly 16 complete expressions. There is no early prune on the value, because a later multiplication can push a small running total up or flip its sign, so this is plain exhaustive search whose entire craft is in making each node cheap and correct.

The difficulty is precedence. If you keep only the running value, then at 2+3 the value is 5, and appending *2 cannot be done by multiplying 5 by 2: the correct result is 2 + 3·2 = 8, not 10. The multiplication binds to the last term, not the whole value. So carry two numbers along: the running value and the lastTerm that was most recently added to it (negated if it was subtracted). A + n or - n adds ±n and makes ±n the new last term. A * n takes the last term back out of the value and puts the multiplied term in: value - lastTerm + lastTerm * n.

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

  function go(pos, expr, value, lastTerm) {
    if (pos === num.length) {
      if (value === target) results.push(expr);
      return;
    }
    for (let end = pos + 1; end <= num.length; end++) {
      if (end > pos + 1 && num[pos] === "0") break; // "05" is not an operand; "0" alone is
      const text = num.slice(pos, end);
      const n = Number(text);
      if (pos === 0) {
        go(end, text, n, n); // the first operand has no operator in front of it
      } else {
        go(end, expr + "+" + text, value + n, n);
        go(end, expr + "-" + text, value - n, -n);
        go(end, expr + "*" + text, value - lastTerm + lastTerm * n, lastTerm * n); // take the last term back out, then multiply it
      }
    }
  }

  go(0, "", 0, 0);
  return results;
}

Dry run: the two accepted expressions for "232", target 8

ExpressionAfter the first operandAfter the operator and the second operandAfter the third operand
2*3+2value 2, last 2*3: value 2 - 2 + 2·3 = 6, last 6+2: value 8, last 2
2+3*2value 2, last 2+3: value 5, last 3*2: value 5 - 3 + 3·2 = 8, last 6

The second row is the precedence rule in action: at *2 the code removes the 3 that +3 contributed and replaces it with 6, which is exactly what the expression means. Of the 16 leaves for "232", only these two have value 8; the others include 2+3+2 = 7, 2*3*2 = 12, 2+32 = 34 and 232 itself.

Cost: at most 4n-1 leaves, and building each result string takes O(n), so the work is O(n · 4n); the recursion is n deep. I checked the function against a brute force that enumerates every operator choice for every digit string up to length 7 over random targets (600 cases), comparing the sorted result lists.

⚠ The four traps in this problem (1) Leading zeros. "05" is not an operand, but "0" alone is. The guard end > pos + 1 && num[pos] === "0" allows the single digit and stops longer ones; forgetting it produces 1*0+5 correctly and also 1*05 wrongly. (2) Multiplication on the whole value instead of the last term, which is wrong whenever an addition or subtraction came before it. (3) Forgetting that the first operand has no operator. The pos === 0 branch is a separate path, not a + with a zero in front. (4) Precision. Number is exact only up to 253 - 1; digit strings of length up to about 10 are safe, but a long string with no operators would silently round, so confirm the length bound or switch to BigInt.
Say it like this → "At each position I choose how many digits the next operand takes and which operator precedes it. To respect precedence I carry the running value and the last term: plus and minus add ±n and set the last term, and multiply subtracts the last term and adds last times n. Operands with a leading zero are rejected unless they are exactly '0'. It's exhaustive, O(n · 4^n), because a later multiplication can change everything, so no value-based prune is sound."

Remove invalid parentheses: decide what to remove, then never repeat a result

Given a string of parentheses and letters, remove the fewest brackets that make it valid, and return every distinct result. For "()())()" the answers are "()()()" and "(())()"; for ")(" the only answer is the empty string.

Two ideas make the search small. First, compute the budget before searching. One left-to-right scan counts how many ( are left unmatched and how many ) have nothing to close. Those two numbers, removeOpen and removeClose, are exactly how many of each bracket must go, and they are the minimum, so the search never has to compare lengths or find "the shortest": every leaf that spends the budget exactly and ends balanced is an answer. For "()())()" the scan finds one stray ), so the budget is (0 opens, 1 close).

Second, remove from a whole run at a time. If you decide for every character "keep or remove", the string "))" gives four branches for two characters, and removing the first ) gives the same string as removing the second. Duplicates are the real danger here, and a Set hides them at the cost of generating all of them first. Instead, treat a run of equal brackets as one decision: how many of this run to remove, from 0 up to the smaller of the run length and the remaining budget. Different amounts leave different numbers of brackets, so those branches cannot build the same text.

function removeInvalidParentheses(s) {
  let removeOpen = 0;
  let removeClose = 0;
  for (const ch of s) {
    if (ch === "(") removeOpen++;
    else if (ch === ")") {
      if (removeOpen > 0) removeOpen--; // this ")" closes an earlier "("
      else removeClose++; // nothing to close: this ")" cannot be saved
    }
  }

  const results = [];

  function go(i, balance, open, close, text) {
    if (i === s.length) {
      if (balance === 0 && open === 0 && close === 0) results.push(text);
      return;
    }
    const ch = s[i];
    if (ch !== "(" && ch !== ")") {
      go(i + 1, balance, open, close, text + ch); // letters are never removed
      return;
    }
    let end = i;
    while (end < s.length && s[end] === ch) end++; // the run of equal brackets s[i..end)
    const run = end - i;
    const budget = ch === "(" ? open : close;
    for (let removed = 0; removed <= Math.min(run, budget); removed++) {
      const kept = run - removed;
      const nextBalance = ch === "(" ? balance + kept : balance - kept;
      if (nextBalance < 0) continue; // a prefix with more ")" than "(" can never be repaired by later characters
      go(
        end,
        nextBalance,
        ch === "(" ? open - removed : open,
        ch === ")" ? close - removed : close,
        text + ch.repeat(kept)
      );
    }
  }

  go(0, 0, removeOpen, removeClose, "");
  return results;
}

Dry run: s = "()())()", budget (0 opens, 1 close)

The runs are (, ), (, )), (, ). Each row is one call: the index, the balance of kept brackets, the remaining budget, and the text built so far.

Callindexbalanceopen leftclose lefttextNote
10001""run "(" has no budget to remove: keep it
21101"("run ")": keep it (removed 0)
32001"()"run "(": keep it
43101"()("run "))" of length 2: removing 0 gives balance -1, skipped; removing 1 keeps one ")"
55000"()()"budget spent; the rest is kept: "()" gives "()()()", a result
6-76, 71, 000"()()(", "()()()"reach the end balanced: result 1
82100"("back at call 2 with 1 removed from the first ")" run: the budget is spent
9-123, 5, 6, 72, 0, 1, 000"((", "(())", "(())(", "(())()"the remaining runs are kept: result 2

The pruning condition nextBalance < 0 is sound for a reason worth stating: a prefix with more ) than ( can never be repaired by later characters: a later ( cannot match a ) that came before it, and a later ) only makes the imbalance worse. Complexity: the number of leaves is at most the number of ways to choose a removal amount from each run within the budget, which is exponential in the worst case (O(2n) is a safe bound for a string of length n), and each result costs O(n) to build.

I checked this exhaustively rather than by sampling: every string of length 0 to 10 over the three characters (, ) and a (88,573 strings), comparing against a brute force that tries every subset to keep and takes the longest valid ones. The results matched every time and no result was ever repeated. If you cannot argue that last part in the moment, wrapping the return in [...new Set(results)] costs little.

⚠ Where it goes wrong (1) Removing letters. Only brackets are candidates; letters are always kept. (2) Counting every bracket as removable, which makes the budget too large and returns invalid or non-minimal strings. Count only the unmatched ones from the scan. (3) Checking validity only at the end, which explores every branch where the balance already went negative. (4) Guessing the budget instead of measuring it. The end test also checks that both budgets are spent; with the exact counts from the scan a balanced leaf always has spent them, so that check is a cheap guard, but a budget that is too large lets the search return strings that are valid yet remove too much.

Unique paths III: a Hamiltonian-path count with a visited grid

A grid has a start square (1), an end square (2), empty squares (0) and obstacles (-1). Count the walks from start to end that step in the four directions and visit every non-obstacle square exactly once. Contrast this with the earlier Unique Paths problems, where you only count routes and a dynamic programme over the grid is enough. "Every square exactly once" is a different shape: the answer depends on which squares you have used, not just where you stand, so the grid-cell DP no longer applies and you search.

The search is the familiar one. From the current square try each of the four neighbours that is in bounds and not yet used, mark the current square as used, recurse, and un-mark. The new ingredient is the accounting that makes the end test correct. Count the walkable squares up front (start, end and empties together) and pass down how many are still unvisited, including the current one. Reaching the end square counts only if exactly one is left: the end square itself. Arriving there earlier is a dead end, not a solution, because squares remain that can no longer be reached.

function uniquePathsIII(grid) {
  const R = grid.length;
  const C = grid[0].length;
  let startR = 0;
  let startC = 0;
  let walkable = 0; // start + end + every empty square
  for (let r = 0; r < R; r++) {
    for (let c = 0; c < C; c++) {
      if (grid[r][c] === 1) {
        startR = r;
        startC = c;
      }
      if (grid[r][c] !== -1) walkable++;
    }
  }

  function walk(r, c, left) {
    if (grid[r][c] === 2) return left === 1 ? 1 : 0; // only the end square itself may remain
    const original = grid[r][c];
    grid[r][c] = -1; // visited behaves exactly like an obstacle
    let paths = 0;
    for (const [dr, dc] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
      const nr = r + dr;
      const nc = c + dc;
      if (nr >= 0 && nr < R && nc >= 0 && nc < C && grid[nr][nc] !== -1) paths += walk(nr, nc, left - 1);
    }
    grid[r][c] = original; // un-choose
    return paths;
  }

  return walk(startR, startC, walkable);
}

Writing -1 into the visited square is the trick that removes a separate visited array: a used square must behave exactly like an obstacle, so make it one, and restore the original value on the way out. Restoring original rather than a hard-coded 0 matters for the start square, which holds 1.

Dry run: the grid [[1,0,0,0],[0,0,0,0],[0,0,2,-1]]

Eleven squares are walkable (twelve cells, one obstacle). The search makes 95 calls to walk and finds two walks, written as (row, column):

WalkSquares in order
1(0,0) (1,0) (2,0) (2,1) (1,1) (0,1) (0,2) (0,3) (1,3) (1,2) (2,2)
2(0,0) (0,1) (0,2) (0,3) (1,3) (1,2) (1,1) (1,0) (2,0) (2,1) (2,2)

Each walk has exactly 11 squares and ends on (2,2). Every other route that reaches (2,2) with squares unvisited returns 0 at that line, which is why the left === 1 test, not merely grid[r][c] === 2, is the correct base case.

Complexity: after the first step a walk has at most three squares to move to (it cannot step back onto the square it came from, which is marked), so the tree has at most about 4 · 3N-1 nodes for N walkable squares, which is O(3N). The problem statement keeps the grid small (at most 20 cells), which is the constraint hint that an exponential search is what is intended. Space is the recursion depth, O(N).

The bridge to dynamic programming is the same one this chapter keeps returning to. The future of the walk depends only on the set of visited squares and the current position. If you pass that set as a bitmask instead of writing into the grid, there are at most 2N · N distinct states, and memoizing on them counts the same walks in O(2N · N) time, at the price of O(2N · N) memory. I used exactly that memoized count as the reference to check this function on 2,912 random grids (874 of them with at least one walk): the two always agreed, and the input grid was always left as it started.

⚠ Three ways this miscounts (1) Counting the walkable squares without the start or the end, so the "one left" test is off by one. (2) Accepting any arrival at the end square, which counts walks that skipped squares. (3) Not restoring the square on the way back, which makes the second branch see a grid with holes in it; the symptom is an answer that is too small and depends on the order you try directions in.

Meet in the middle: when 2n is too big and 2n/2 is not

Backtracking over subsets costs 2n. For n = 20 that is a million and everything above works. For n = 40 it is about 1012, which no pruning rescues in general. If n ranges up to about 40 and the question is about a sum over a subset, there is a third option between brute force and a DP that needs small values: split the items into two halves, enumerate each half's subsets, and combine.

The idea, derived. Any subset of all n items is a subset of the first half joined with a subset of the second. So the sum of the whole subset is a + b, where a is one of the 2n/2 sums of the left half and b one of the 2n/2 sums of the right. The full search considers every pair (a, b), 2n of them. But for a fixed a you do not need to look at every b: you need the b nearest to goal - a, and if the right-hand sums are sorted, a binary search finds it in O(log) time. Total work: build two lists of 2n/2 sums, sort one, and do 2n/2 binary searches.

The problem: choose any subset (possibly empty) of nums so that its sum is as close as possible to goal, and return the smallest possible absolute difference.

function subsetSums(nums) {
  const sums = [0]; // the empty subset
  for (const x of nums) {
    const size = sums.length;
    for (let i = 0; i < size; i++) sums.push(sums[i] + x); // every existing subset, with and without x
  }
  return sums;
}

function minAbsDifference(nums, goal) {
  const mid = nums.length >> 1;
  const left = subsetSums(nums.slice(0, mid));
  const right = Float64Array.from(subsetSums(nums.slice(mid))).sort(); // typed arrays sort numerically
  let best = Infinity;
  for (const a of left) {
    const want = goal - a;
    let lo = 0;
    let hi = right.length;
    while (lo < hi) {
      const m = (lo + hi) >> 1;
      if (right[m] < want) lo = m + 1;
      else hi = m;
    } // lo = first right sum >= want
    if (lo < right.length) best = Math.min(best, Math.abs(a + right[lo] - goal));
    if (lo > 0) best = Math.min(best, Math.abs(a + right[lo - 1] - goal));
  }
  return best;
}

Dry run: nums = [1, 2, 3, 4], goal = 5

The halves are [1, 2] and [3, 4]. Left sums: 0, 1, 2, 3. Right sums, sorted: 0, 3, 4, 7.

a (left sum)want = goal - afirst right sum >= wantright sum before itbest so far
057 (|0 + 7 - 5| = 2)4 (|0 + 4 - 5| = 1)1
144 (|1 + 4 - 5| = 0)3 (|1 + 3 - 5| = 1)0
233 (0)00
323 (|3 + 3 - 5| = 1)0 (|3 + 0 - 5| = 2)0

The answer is 0 (for example 1 + 4, from a = 1 and b = 4). Note that each a checks two candidates, the right sum just at or above the target and the one just below it, because the closest value can be on either side.

Complexity: building each half's sums is O(2n/2); sorting one is O(2n/2 · n/2); the binary searches add O(2n/2 · n/2). The total is O(2n/2 · n) time and O(2n/2) space. At n = 40 each half has 220 = 1,048,576 sums, a few tens of millions of basic steps in all, against about 1012 for brute force; on 40 random values up to 107 in magnitude it finished in well under a second here. The price is memory: both lists must be held at once.

The counting variant needs no sorting. To count the subsets whose sum is exactly target, put the right half's sums into a Map from sum to how many times it occurs, then for each left sum add seen.get(target - a).

function countSubsetsWithSum(nums, target) {
  const mid = nums.length >> 1;
  const left = subsetSums(nums.slice(0, mid));
  const seen = new Map();
  for (const b of subsetSums(nums.slice(mid))) seen.set(b, (seen.get(b) || 0) + 1);
  let count = 0;
  for (const a of left) count += seen.get(target - a) || 0;
  return count;
}
⚠ Pitfalls specific to this technique (1) Array.prototype.sort() without a comparator sorts numbers as strings, so [9, 10, 1].sort() gives [1, 10, 9]; the code uses a Float64Array, whose sort() is numeric. (2) Checking only the right sum at or above the target and not the one below it. (3) Splitting unevenly, which makes one half much larger than 2n/2 and loses the point. (4) Using it when it does not apply: if the values are small, a subset-sum DP over the sums is far cheaper, and if n is large and the values are large, the problem is hard in general and the interviewer is not asking for this.

I checked both functions against brute force on 1,500 random arrays of up to 14 numbers with negatives allowed, comparing the closest difference and the subset count with direct enumeration of all 2n subsets.

Memoized backtracking: when the state is small, stop re-exploring it

The table of prune types above ends with a row that this chapter has only gestured at: memo / dedupe: have I already explored this exact state? Backtracking enumerates paths. If two different paths arrive at a state from which the future is identical, the second arrival repeats the first one's entire subtree. Remember the answer for a state the first time you finish it, and the exponential tree collapses to as many distinct states as there are. That is dynamic programming reached from the search side, and the test for whether it is allowed is one question: does this key contain everything the future depends on, and nothing it does not?

Can I Win: the state is the set of numbers used

Two players take turns choosing an unused integer from 1 to maxChoosable and adding it to a running total. The player who makes the total reach desiredTotal or more wins. Can the first player force a win? Plain backtracking tries every number for each player in turn. But look at what a position is: which numbers are gone. The running total is not an extra fact, because it is determined by the used set (remaining = desiredTotal - sum of used), and whose turn it is is determined by how many are used. So a bitmask of used numbers is a complete key, and there are at most 2maxChoosable of them.

function canIWin(maxChoosable, desiredTotal) {
  if (desiredTotal <= 0) return true; // the target is already met before anyone moves
  if ((maxChoosable * (maxChoosable + 1)) / 2 < desiredTotal) return false; // even taking every number cannot reach it
  const memo = new Map(); // used-numbers mask -> can the player about to move force a win?

  function wins(used, remaining) {
    if (memo.has(used)) return memo.get(used);
    let result = false;
    for (let n = 1; n <= maxChoosable; n++) {
      const bit = 1 << (n - 1);
      if (used & bit) continue;
      if (n >= remaining || !wins(used | bit, remaining - n)) { // win now, or leave the opponent in a lost position
        result = true;
        break;
      }
    }
    memo.set(used, result);
    return result;
  }

  return wins(0, desiredTotal);
}

The recursion returns "can the player about to move win from here". A move wins immediately if n >= remaining, or if it leaves the opponent in a position the opponent cannot win; that is the entire game logic. Note the two guards before the search: a target of 0 or less is already met, and if even the sum of every number cannot reach the target, nobody can win.

maxChoosable, desiredTotalAnswerCalls, plain recursionCalls with the memoDistinct states stored
10, 40false11,4531,147464
15, 80true6,006,11949,41614,149
18, 79true94,555,252340,17979,339

Counted by instrumenting both versions. The plain version is exponential in the number of numbers; the memoized one stores at most one result per subset and, because it stops as soon as a winning move is found, usually far fewer. Time is O(2m · m) in the worst case for m = maxChoosable, space O(2m). The bitmask limits m to about 30 in JavaScript; the problem states m <= 20.

Word Break II: the state is the index where the next word starts

Given a string and a dictionary, return every sentence that splits the string into dictionary words. The state here is one number: the index start from which the rest of the string still has to be spelled. The set of sentences for s.slice(start) does not depend on how you got to start, so cache it by start.

function wordBreak(s, wordDict) {
  const words = new Set(wordDict);
  const longest = Math.max(0, ...wordDict.map((w) => w.length));
  const memo = new Map(); // start index -> every sentence that spells s.slice(start)

  function sentences(start) {
    if (start === s.length) return [""]; // one way to spell nothing: the empty sentence
    if (memo.has(start)) return memo.get(start);
    const out = [];
    for (let end = start + 1; end <= Math.min(s.length, start + longest); end++) {
      const word = s.slice(start, end);
      if (!words.has(word)) continue;
      for (const rest of sentences(end)) out.push(rest === "" ? word : word + " " + rest);
    }
    memo.set(start, out); // an empty list is stored too: it records a dead end
    return out;
  }

  return sentences(0);
}

Dry run: "catsanddog" with the words cat, cats, and, sand, dog

CallWhat it doesStored
sentences(0)tries "cat" (ends at 3) and recurses into 3
sentences(3)the only word starting at 3 is "sand" (ends at 7): recurses into 7
sentences(7)"dog" reaches the end of the string, the base case7 → ["dog"]
back in 3one sentence found for the suffix "sanddog"3 → ["sand dog"]
sentences(4)back in 0, the word "cats" (ends at 4) recurses into 4; there "and" (ends at 7) recurses into 7
sentences(7)memo hit: the work done for "sand" is reused for "and"no new work
back in 4, then 0combine the pieces4 → ["and dog"], 0 → ["cat sand dog", "cats and dog"]

The memo hit is the point: two different prefixes ("cat sand" and "cats and") both end at index 7, and the suffix "dog" is spelled once. On "catsandog", which cannot be split, the same table ends with 0 → [], and the empty lists stored along the way are dead ends that later calls skip instantly.

⚠ What memoization does and does not fix (1) The output can be exponential and the memo cannot help. For "a" repeated 20 times with the words a, aa, aaa, aaaa there are 283,953 sentences; no algorithm returns them in less time than it takes to write them down. The memo removes repeated searching, not the size of the answer, so state the cost as proportional to the output. (2) A key that is too small gives wrong answers silently: keying Can I Win on the remaining total alone would conflate different used sets. (3) A key that is too big never hits: keying it on the path so far is just the original tree with a hash map attached. (4) Shared results. The cached arrays are returned to several callers, so none of them may mutate what they receive.

The partition problem again, with a memo instead of symmetry skipping

The bucket search earlier carries a state of (index, multiset of bucket sums), which is awkward to use as a key. A cleaner state exists. Fill the buckets one at a time: the set of numbers already used determines the sum so far, and a bucket is finished whenever that sum reaches a multiple of target. So the used-set bitmask, plus the current bucket's partial sum (sum % target, itself determined by the mask), is a complete state, and each mask need be explored at most once.

function canPartitionKSubsetsMemo(nums, k) {
  const total = nums.reduce((a, b) => a + b, 0);
  if (total % k !== 0) return false;
  const target = total / k;
  if (nums.some((x) => x > target)) return false;
  const n = nums.length;
  const full = (1 << n) - 1;
  const known = new Uint8Array(1 << n); // 0 = not computed, 1 = can finish, 2 = cannot

  function finish(used, filled) {
    if (used === full) return true;
    if (known[used]) return known[used] === 1;
    for (let i = 0; i < n; i++) {
      if (used & (1 << i) || filled + nums[i] > target) continue;
      if (finish(used | (1 << i), (filled + nums[i]) % target)) { // reaching target starts a fresh bucket at 0
        known[used] = 1;
        return true;
      }
    }
    known[used] = 2;
    return false;
  }

  return finish(0, 0);
}

This version is O(2n · n) time and O(2n) space, so it suits n up to roughly 16 to 20 where the bucket search with symmetry skipping may or may not finish. Which is faster depends on the input: the twin skip helps most when numbers or sums repeat, while the mask memo's cost depends on n and not on the values. I compared both against a plain kn enumeration on 4,000 random small inputs (1,657 of which could be split), including that neither modifies nums.

Say it like this → "This is backtracking, but many paths reach the same position. The position here is just which numbers are used, since the running total and whose turn it is follow from that. So I key a memo on the bitmask and the tree collapses to at most 2^m states. The rule I check before memoizing is that the key contains everything the future depends on and nothing else."
The one-sentence version Advanced backtracking is the same choose/explore/un-choose loop with two upgrades bolted on: make each node cheap (bits instead of sets) and make each no arrive earlier (feasibility rule, sorted break, bound, or a Trie that speaks for the whole dictionary at once). If you can name which of those four you are applying and why it is valid, you have said everything an interviewer is listening for.

Ordering the search is free speed

Two branches, same subtree size, different order of exploration — the runtimes can differ by 100×. Bound-based pruning only works once best is good, so anything that finds a good answer sooner strengthens every subsequent prune. Three heuristics carry most of the value:

  • Most-constrained variable first — branch on the cell/slot with the fewest options (Sudoku MRV). Fewer children means failure surfaces at a shallower depth.
  • Least-constraining value first — among the options for that slot, try the one that eliminates the fewest options elsewhere, so a solution is reached before the search has to unwind.
  • Greedy-first ordering — sort by value density (knapsack), largest item first (bin packing, Partition to K Equal Sum Subsets), so the incumbent jumps early. Largest-first also fails fast: if the biggest item fits nowhere, you learn it at depth 1 instead of depth n.
Say it like this → "Worst case this is still exponential and I don't think we can avoid that — the problem is NP-hard. But I'll sort the items densest-first so a good incumbent appears early, and add a fractional-relaxation bound so any subtree whose optimistic estimate can't beat the incumbent gets cut. That's branch and bound; it doesn't change the asymptotics but it's the difference between running and not running at n = 40."

Complexity at this level: say the honest thing

ProblemWorst caseWhat pruning actually buys
N-Queens (sets)O(n!)baseline; ~n = 12 before it drags
N-Queens (bitmask)O(n!)same tree, ~5-10× cheaper per node; n = 15-16 comfortable
Sudoku (plain)O(9^m), m = empty cellscan hang on adversarial boards
Sudoku (bitmask + MRV)O(9^m)milliseconds on real boards; MRV does the heavy lifting
Word Search II (per word)O(W · R · C · 4^L)—
Word Search II (Trie)O(R · C · 4^L)W disappears from the bound entirely
Knapsack (brute)O(2ⁿ)—
Knapsack (branch & bound)O(2ⁿ)typically explores a tiny fraction of nodes; unchanged bound
Partition to K subsets (bucket search)O(kn)sorting and skipping equal-sum buckets cut one 14-number input from 2.6 million calls to 3,700
Expression add operatorsO(n · 4n)nothing value-based is sound; leading zeros are the only prune
Subset sums, meet in the middleO(2n/2 · n)n = 40 becomes about a million sums per half instead of 1012 subsets
Can I Win (memoized on the used mask)O(2m · m)94.6 million calls become 340 thousand for m = 18

Notice that the worst-case column barely moves. Saying "pruning makes this O(n log n)" is wrong and interviewers catch it instantly. The correct framing is: the worst case is unchanged, the expected number of visited nodes collapses, and here is the specific reason a branch dies. Being precise about that distinction reads as senior; over-claiming reads as memorized.

Recognizing it in an unseen problem

  • Basic backtracking is already the obvious approach, but n or the branching factor makes the plain version time out — the question is not "which algorithm" but "which prune."
  • The state is a small dense set (≤ 30 columns, 9 digits, ≤ 20 items used) → replace sets/arrays with a bitmask; full & ~(a|b|c) and x & -x are the two idioms to reach for.
  • Many candidate strings/words are searched against one structure → build a Trie and invert the loops: walk the structure once carrying a Trie node, instead of once per word.
  • The problem asks for the best value rather than all answers → you now have an incumbent, so branch and bound applies; find an optimistic bound (relaxation: drop the integrality constraint, ignore capacity, allow fractions) and prune when bound ≤ best.
  • Candidates can be sorted so failure is monotone → change continue to break; free, and it composes with everything else.
  • The same kind of container or item appears many times (k equal buckets, duplicate numbers) → symmetry breaking: say which quantity decides the future, and try each distinct value of it once.
  • "All ways to insert operators or brackets" with a count of what to remove → carry the extra state the grammar needs (the running value and the last term, the bracket balance), compute any budget with one scan first, and decide whole runs at a time to avoid duplicate results.
  • "Visit every cell exactly once" on a small grid → backtracking with a count of the squares still unvisited; with at most about 20 cells the visited set can also be a bitmask, which makes it memoizable.
  • Subset sums with n around 30 to 40 and large values → meet in the middle: enumerate each half, sort one, and look up the partner for every sum of the other.
  • Different paths reach the same position and the answer depends only on that position → memoize on a small key, after checking that the key holds everything the future depends on.
  • Distinguish from DP: if two different paths reach the same state and the future depends only on that state, memoize and it becomes bitmask DP. Backtracking is the right tool when states are mostly distinct or you must enumerate rather than count.
Before you move on
  • Rewrite N-Queens using three integer bitmasks instead of three sets, and explain why passing masks by value removes the un-choose step from all but the placement array.
  • Explain why free & -free followed by free ^= bit visits only the legal columns, without ever building a list of them.
  • Explain Sudoku's MRV heuristic and why branching on the most-constrained cell first makes failure surface at a shallower depth, not just cheaper per node.
  • Give one example each of a feasibility prune and a bound-based prune from this chapter, and explain what question each one answers.
  • Given a new backtracking problem that times out, decide whether the fix is a bitmask, a Trie, a sort-then-break, or a bound function — and justify the choice from the shape of the state space.
  • Write the bucket search for Partition to K Equal Sum Subsets, explain why sorting descending helps, and say exactly what makes two buckets interchangeable.
  • Explain why Expression Add Operators carries a last term, and work 2+3*2 by hand to show the value it produces.
  • Explain how the budget for Remove Invalid Parentheses is computed in one scan, and why deciding a whole run of equal brackets at once avoids duplicate results.
  • State why Unique Paths III needs the count of unvisited squares at the end test, not just an arrival at the end square.
  • Describe meet in the middle in three sentences, give its time and space for n items, and say when a subset-sum DP is the better tool.
  • For a memoized search, state the key, argue that it holds everything the future depends on, and say why the memo cannot shrink an exponential answer.

12Practice this chapter

Opens in the editor: write it, run it, and check it against real tests.

  • 8 testsN-Queens — Count the Solutionsadvanced
  • 9 testsN-Queens: Return the Boardsadvanced
  • 8 testsSudoku Solveradvanced
  • 9 testsMatchsticks to Squareintermediate
  • 8 testsPartition to K Equal Sum Subsetsadvanced
  • 9 testsExpression Add Operatorsadvanced
  • 8 testsRemove Invalid Parenthesesadvanced
  • 9 testsUnique Paths IIIadvanced
  • 9 testsClosest Subsequence Sumadvanced
  • 7 testsCount Subsets With a Given Sumbeginner
  • 8 testsCan I Winadvanced
  • 8 testsWord Break IIadvanced
All 159 for this level →

✓Check yourself

5 questions, pass at 4. The check runs in your browser and the result stays on this device, so it is a self-check and not a certificate.

A10

Finished reading?

0 of 48 chapters read in this series

Mark as read

→Next stop

  1. A9Previous · A9Design problems
  2. A10you are here
  3. Next · A11Game theory and probability DPWhen the other side is an opponent, take the worst case; when it is chance, take the average. Either way the answer is a table over positions.73 min read

01On this page

16 sections · 0%

  1. 01What actually separates advanced backtracking from basic
  2. 02Sets are correct; integers are fast
  3. 03N-Queens with bitmasks — the canonical example
  4. 04Sudoku — bitmasks plus constraint propagation
  5. 05Word Search II — a Trie prunes the whole dictionary at once
  6. 06Branch and bound — pruning with a number, not just a rule
  7. 07Partition to K equal sum subsets: fill the buckets, number by number
  8. 08Symmetry breaking: never explore the same subtree twice
  9. 09Expression add operators: carry the last term so multiplication can undo it
  10. 10Remove invalid parentheses: decide what to remove, then never repeat a result
  11. 11Unique paths III: a Hamiltonian-path count with a visited grid
  12. 12Meet in the middle: when 2n is too big and 2n/2 is not
  13. 13Memoized backtracking: when the state is small, stop re-exploring it
  14. 14Ordering the search is free speed
  15. 15Complexity at this level: say the honest thing
  16. 16Recognizing it in an unseen problem
Mark as read

[] chapters · t top