Backtracking
Think of it like this
Imagine navigating a maze. At each junction, you pick a direction. If you hit a dead end, you backtrack to the last junction and try the next direction. You keep doing this until you find the exit — or exhaust all paths.
Backtracking is exactly this: explore a path until it's invalid, then undo and try another.
Maze: S → A → B → dead end
↑
S → A → C → D → exit ✓ (backtracked from B, tried C)This is different from brute force: instead of generating all possible paths and filtering, backtracking prunes paths early the moment they violate a constraint — cutting huge branches of the search tree.
The Framework: Choose → Explore → Unchoose
Every backtracking problem follows this three-step template:
function backtrack(state, choices) {
// Base case: is the current state a complete valid solution?
if (isComplete(state)) {
results.push([...state]); // save a copy (not a reference!)
return;
}
for (const choice of choices) {
if (!isValid(state, choice)) continue; // pruning: skip bad choices
// Choose
state.push(choice);
// Explore: recurse with the updated state
backtrack(state, nextChoices(state, choice));
// Unchoose: undo the choice (backtrack!)
state.pop();
}
}The undo step (state.pop()) is what makes it backtracking, not just DFS. You restore the state to what it was before this choice, allowing other paths to be explored from a clean slate.
Visualizing the Decision Tree
Generate all permutations of [1, 2, 3]:
[]
┌─────────┼─────────┐
[1] [2] [3]
┌───┐ ┌───┐ ┌───┐
[1,2][1,3][2,1][2,3][3,1][3,2]
↓ ↓ ↓ ↓ ↓ ↓
[1,2,3][1,3,2][2,1,3][2,3,1][3,1,2][3,2,1]
6 permutations = 3! ✓
Each leaf is a complete solution.function permutations(nums) {
const results = [];
function backtrack(current, remaining) {
if (!remaining.length) { results.push([...current]); return; }
for (let i = 0; i < remaining.length; i++) {
current.push(remaining[i]); // choose
backtrack(current, remaining.filter((_, j) => j !== i)); // explore
current.pop(); // unchoose
}
}
backtrack([], nums);
return results;
}Subsets — Power Set
Every element has two choices: include or exclude.
nums = [1, 2, 3]
[]
┌───────────┐
exclude 1 include 1
[ ] [1]
┌──┴──┐ ┌──┴──┐
excl2 incl2 excl2 incl2
[] [2] [1] [1,2]
┌─┴─┐ ┌─┴─┐ ┌─┴─┐ ┌─┴─┐
[] [3][2] [2,3][1][1,3][1,2][1,2,3]
Result: [], [3], [2], [2,3], [1], [1,3], [1,2], [1,2,3]function subsets(nums) {
const results = [];
function backtrack(start, current) {
results.push([...current]); // every state (including empty) is valid
for (let i = start; i < nums.length; i++) {
current.push(nums[i]); // choose
backtrack(i + 1, current); // explore (i+1 to avoid reuse)
current.pop(); // unchoose
}
}
backtrack(0, []);
return results;
}Combinations — Choose K from N
function combinations(n, k) {
const results = [];
function backtrack(start, current) {
if (current.length === k) { results.push([...current]); return; }
// Pruning: don't start if there aren't enough numbers left
for (let i = start; i <= n - (k - current.length) + 1; i++) {
current.push(i); // choose
backtrack(i + 1, current); // explore
current.pop(); // unchoose
}
}
backtrack(1, []);
return results;
}N-Queens — Constraint Satisfaction
Place N queens on an N×N chessboard so no two queens threaten each other (same row, column, or diagonal).
function solveNQueens(n) {
const results = [];
const cols = new Set(), diag1 = new Set(), diag2 = new Set();
// diag1: top-left to bottom-right (row - col is constant)
// diag2: top-right to bottom-left (row + col is constant)
function backtrack(row, board) {
if (row === n) {
results.push(board.map(cols => '.'.repeat(n).split('')
.map((_, c) => c === cols ? 'Q' : '.').join('')));
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); board.push(col);
backtrack(row + 1, board); // explore next row
cols.delete(col); diag1.delete(row - col); diag2.delete(row + col); board.pop();
}
}
backtrack(0, []);
return results;
}Pruning — The Key to Efficiency
Without pruning, backtracking is just brute force. Pruning cuts branches early:
Combination Sum: find subsets of [2, 3, 6, 7] that sum to 7
Prune rule: if current sum > target, stop exploring this branch.
[2] → sum=2
[2,2] → sum=4
[2,2,2] → sum=6
[2,2,2,2] → sum=8 > 7 ✗ PRUNE (don't go deeper)
[2,2,2,3] → sum=9 > 7 ✗ PRUNE
[2,2,3] → sum=7 ✓ RESULT
[2,3] → sum=5
[2,3,3] → sum=8 > 7 ✗ PRUNE
...
[7] → sum=7 ✓ RESULTfunction combinationSum(candidates, target) {
candidates.sort((a, b) => a - b); // sort enables early termination
const results = [];
function backtrack(start, current, remaining) {
if (remaining === 0) { results.push([...current]); return; }
for (let i = start; i < candidates.length; i++) {
if (candidates[i] > remaining) break; // pruning: sorted, so rest will also be too big
current.push(candidates[i]);
backtrack(i, current, remaining - candidates[i]); // i (not i+1): can reuse
current.pop();
}
}
backtrack(0, [], target);
return results;
}Backtracking (Decision Tree)
Explore decision to pick 1.
Real-World Frontend Application
- Form builders / drag-and-drop layouts: Generating all valid arrangements of widgets within constraints (min-width, required fields) uses constraint-satisfaction similar to backtracking
- Sudoku solvers: A classic backtracking problem — fill cells one by one, backtrack when a constraint is violated
- CSS grid layout engines: Finding valid placements for auto-placed grid items uses backtracking internally
- Regular expression matching: The NFA engine that powers JS regex uses backtracking when multiple paths are possible
Complexity
| Problem | Time | Why |
|---|---|---|
| Permutations (n elements) | O(n × n!) | n! permutations, each takes O(n) to copy |
| Subsets (n elements) | O(n × 2ⁿ) | 2ⁿ subsets, each takes O(n) to copy |
| Combinations C(n,k) | O(k × C(n,k)) | C(n,k) combinations, each O(k) |
| N-Queens | O(n!) | Much better with pruning |
Backtracking is always exponential in the worst case — that's unavoidable when you need to enumerate all solutions. Pruning just reduces the constant.
Common Mistakes
| Mistake | What Goes Wrong | Fix |
|---|---|---|
| Not copying state at base case | All results point to same array (gets mutated) | results.push([...current]) not results.push(current) |
| Forgetting to undo (no pop) | State accumulates incorrectly across branches | Every push must have a matching pop after recursion |
| Generating duplicates | Same combination in different orders | Sort input; use start index; skip nums[i] === nums[i-1] for dedup |
| No pruning | TLE on large inputs | Add constraints to break/continue early |
Key Problems to Solve
| # | Problem | Technique | Difficulty |
|---|---|---|---|
| 1 | Subsets | Include/exclude each element | Medium |
| 2 | Permutations | All orderings | Medium |
| 3 | Combination Sum | Reuse elements, prune by sum | Medium |
| 4 | Combination Sum II | No reuse, skip duplicates | Medium |
| 5 | Palindrome Partitioning | Prune by palindrome check | Medium |
| 6 | Letter Combinations of Phone | Cartesian product via backtrack | Medium |
| 7 | Word Search | DFS + mark visited | Medium |
| 8 | N-Queens | Constraint satisfaction | Hard |
| 9 | Sudoku Solver | Cell-by-cell constraint filling | Hard |
| 10 | Word Break II | Backtrack with memoization | Hard |