Backtracking

Backtracking is systematic trial-and-error: build a candidate answer incrementally, recurse when it looks promising, and undo the last choicewhen you hit a dead end. The choose → explore → unchoose loop explores an entire decision tree while pruning subtrees that can't lead to valid solutions.

Topic guide11 problems
The unlock

Backtracking is just DFS walking a tree of half-built answers: at every node you make a choice, recurse deeper, then undo that choice so the next branch starts from a clean slate. Choose → explore → un-choose — the undo is the whole trick.

MENTAL MODEL A DFS over a tree of partial answers you never build

Stop picturing loops. Picture a tree of decisions. The root is the empty answer. Each edge is one choice you could make next (“include this number”, “put a queen here”, “step onto this cell”). Each node is the partial candidate you've built so far. A leaf is a complete candidate— record it if it's valid.

Backtracking is nothing more than a depth-first traversalof that tree. You never build the tree in memory — it's implicit. The call stack is the path from the root to wherever you currently stand, and your mutable path array is the list of choices along that route.

The reframe → don't ask “what loop do I write?” Ask “what does one choicelook like, and what tree do those choices spell out?” Once you see the tree, the code is just a DFS over it.

THE MANTRA Choose → explore → un-choose (the undo is the part people forget)

Inside the recursive helper, the same three beats appear together, every time:

  1. Choose. Commit to one option — path.push(choice). You've just walked one edge down the tree.
  2. Explore. Recurse — backtrack(...). This descends into the whole subtree of everything that choice makes possible.
  3. Un-choose. Undo it — path.pop(). You step back up to the decision point so the next sibling branch starts from a clean path.

That third beat is literally why it's called back-track: you retreat to the last fork and try the next road. Skip the un-choose and every branch inherits the previous branch's choices — the path only ever grows, and the answers go wild.

Mirror rule → every push has exactly one pop, with the recursion sandwiched between them. If you see a push with no matching pop, that's the bug.

SEE IT The decision tree for subsets of [1,2,3]

Watch the tree grow. Each level decides the next number; the marks where we record a candidate and marks the back-track (the un-choose that pops the last number off the path):

subsets([1,2,3]) — at each level decide: include the next number or not?

start = []                                                  ◄ record []
   ├─ +1 → [1]                                              ◄ record [1]
   │      ├─ +2 → [1,2]                                     ◄ record [1,2]
   │      │      └─ +3 → [1,2,3]                            ◄ record [1,2,3]
   │      │              ▲ leaf — un-choose 3, back up
   │      └─ +3 → [1,3]                                     ◄ record [1,3]
   │              ▲ un-choose 3, then un-choose 2, back up
   ├─ +2 → [2]                                              ◄ record [2]
   │      └─ +3 → [2,3]                                     ◄ record [2,3]
   └─ +3 → [3]                                              ◄ record [3]

8 nodes = 2^3 subsets.  ▲ = the back-track: pop the last choice,
so the next sibling branch starts from a CLEAN path.

Now zoom into the one mutable patharray as a single descent and unwind play out. Notice it's the same array the whole time — push to go down, pop to come back up:

path starts []  ── the SAME array, mutated as we walk and un-walk ──

  push 1   →  [1]          (choose 1, recurse)
  push 2   →  [1,2]        (choose 2, recurse)
  push 3   →  [1,2,3]      (choose 3, recurse → leaf → record [...path])
  pop  3   →  [1,2]        (UN-choose 3)        ▲ back up one level
  pop  2   →  [1]          (UN-choose 2)        ▲ back up one level
  push 3   →  [1,3]        (now try the sibling: choose 3 → record)
  pop  3   →  [1]   ...    and so on.

The pop is non-negotiable: skip it and [1] would still hold [2,3]
when you try the [1,3] branch. Every push has its mirror pop.
The aha →the recursion tree isn't a metaphor. Read your code top-to-bottom and it is a pre-order DFS: visit node (record), then for each child push / recurse / pop.

HOW TO THINK The cold-start ladder — run this on any new problem

When a problem says “find all…” or “enumerate every…”, don't reach for the template yet. Answer these four questions first — the structure of the recursion falls out of them:

  1. What is one choice? Name the single decision you make to grow the candidate by one step. Subsets: “include this element or not.” Permutations: “which unused element goes next.” N-Queens: “which column in this row.”
  2. What are the options at each step?This is the loop you'll write — the set of edges out of the current node. For combinations it's elements from start onward; for permutations it's every index not yet used.
  3. When is a candidate complete?That's your base case — the leaf test that records a copy. “Path length equals n”, “remaining sum is 0”, “all rows filled”, “reached the last cell”.
  4. What makes a branch invalid → prune? Where can you tell earlythat a subtree is hopeless and skip it? “Sum overshot the target”, “this column/diagonal is attacked”, “cell already visited”.
One choice, then recurse on what's left → almost every backtracking problem yields to “what do I decide right now, and what smaller problem remains after I decide it?” Answer that and the helper writes itself.

RECURSION SHAPE Every backtracker is this skeleton in disguise

Strip away the specific problem and every backtracking solution is the same six moves: base case → loop choices → prune → choose → explore → un-choose. Read it as a sentence, not as code:

backtrack(path, choices):          # path = choices made so far (the route
                                   #        from the root to where I'm standing)

    if path is a complete answer:  # 1. BASE CASE — found a leaf worth keeping
        record a COPY of path      #    results.push([...path])  ← copy, not the
        return                     #    live array!

    for each choice in choices:    # 2. branch into every option at this node

        if choice is invalid:      # 3. PRUNE — if this branch can't lead to a
            continue               #    valid answer, skip the whole subtree

        path.push(choice)          # 4. CHOOSE   — commit to this option
        backtrack(path, rest)      # 5. EXPLORE  — recurse one level deeper
        path.pop()                 # 6. UN-CHOOSE— undo, so the next sibling
                                   #    starts from a clean path

The only things that change between problems are: what counts as a complete answer, what the choices at each node are, and what makes a branch invalid. The choose / explore / un-choose spine never changes — which is exactly why backtracking becomes second nature once this skeleton is in your hands.

PRUNE IT Cut a whole subtree, not a single leaf

Brute force generates every candidate, then throws away the invalid ones at the end. Backtracking does something smarter: it detects a dead branch at an internal node and skips the entire subtree below it in one check. One prune can erase thousands of leaves you never have to visit.

combinationSum([2,3], target = 7)  — remaining = target so far

[] r=7
 ├─ +2 → [2] r=5
 │       ├─ +2 → [2,2] r=3
 │       │       ├─ +2 → [2,2,2] r=1
 │       │       │       └─ +2 → r = -1   ✗ PRUNE (overshot)
 │       │       └─ +3 → [2,2,3] r=0      ✓ record!
 │       └─ +3 → [2,3] r=2
 │               └─ +3 → r = -1           ✗ PRUNE
 └─ +3 → ...

One check — "if (remaining < 0) return" — kills an ENTIRE subtree
the moment a branch can't possibly reach 0. Brute force would walk
every leaf; pruning lops off the dead limbs before you climb them.

Backtracking and brute force share the same worst-case bound — when nothing can be pruned, you still touch every leaf. But on real inputs, pruning early and often is the difference between instant and timing out. Put the prune as the first thing inside the loop (or the top of the call), before you choose, so you never pay for the recursion at all.

Prune before you choose → a check like if (remaining < 0) return or if (col attacked) continue must run before path.push, or you waste a whole descent discovering what you could have known at the fork.

SAY IT Push a copy, undo every choice — say it before you code

Two invariants make or break a backtracker. Say them out loud before writing the helper, and the two classic bugs simply never happen:

  • “Push a copy, not the live array.” results.push([...path]), never results.push(path). The path keeps mutating after the push, so storing the reference leaves you with a list of identical (usually empty) arrays — the aliasing bug. Snapshot at the moment of recording.
  • “Every choice I make, I un-make.” The path.pop() after the recursive call is mandatory. It restores the path to exactly what it was before this branch, so the next sibling explores from a clean state.
The one-line test → before coding, finish the sentence “a complete answer is when ___, and I record it by copying the path.” If you can't state the leaf condition cleanly, you don't yet know what tree you're walking.

MNEMONIC Choose · explore · un-choose.

Choose · explore · un-choose. Backtracking is a DFS over a decision tree: make a choice, recurse, then undo it so the next branch starts clean. The undo is the part people forget. The Visualize tab grows the tree and backs out of each node.

PATTERN Choose → Explore → Unchoose

Every backtracking solution has the same three-beat rhythm inside the recursive helper:

  1. Base case — record. If the current path is a complete solution, snapshot it ([...path]) and return.
  2. Loop over choices. For each candidate at this level, optionally prune invalid ones early, then choose (push onto path), explore (recurse), and finally unchoose (pop off path).

The undo step is what gives the technique its name — you back-track to the last decision point and try the next branch. Without it, every recursive call would share and corrupt the same mutable path.

Minimal skeleton → path.push(x)  ·  backtrack(...)  ·  path.pop() — these three lines always appear together.

KEY IDEA The decision tree mental model

Picture the recursion as a tree. Each node is a partial path; each edge is one choice. Leaf nodes are either complete solutions (record them) or dead ends (prune them). A depth-first traversal of this tree — which is exactly what the recursive helper performs — visits every node exactly once.

Pruning is what makes backtracking tractable. If you can determine at an internal node that no descendant can possibly be valid (e.g., the running sum already exceeds the target), you skip the entire subtree in one check. Good pruning transforms an impossibly large tree into a manageable one.

Mental model → you are doing a DFS on an implicit tree that you never build in memory. The call stack is the path from root to the current node.

COST Exponential by nature — pruning is the only lever

Subsets (all 2^n)
O(2ⁿ)
Each element is in or out.
Permutations (all n!)
O(n!)
n choices, then n−1, then…
With pruning
≪ worst
Many subtrees skipped early.

Backtracking is always exponential in the worst case. If the problem only needs the count or the optimum, reach for DP or math instead. Backtracking is justified when the problem literally asks you to enumerate all valid structures.

TEMPLATE start-index vs used[] — choosing the right skeleton

There are two canonical bookkeeping strategies:

  • start-index — pass an integer start so the loop begins at start, not 0. This naturally avoids re-picking earlier elements and is perfect for combinations / subsets where order does not matter.
  • used[] — a boolean array that marks which indices are already in the current path. Every index is available at every level, so this is correct for permutations where order matters and each element is used once.

Mixing them up is the single most common source of wrong answers — pick one based on whether your problem cares about ordering.

RUN IT Choose · explore · un-choose

step 0 / 30
STARTBuild every subset of [1, 2, 3] by deciding each element in turn. Choose · explore · un-choose.
{}
subsets collected
empty
exploring nowcomplete subset (leaf)backed out
slowfast

TRIGGERS When you see ___ → reach for ___

Reach for backtracking whenever the problem asks you to enumerate every valid structure in a search space, and the space is too large to build explicitly but small enough (with pruning) to traverse.

"generate all subsets / combinations / permutations"backtrack with start-index or used[]
"find ALL solutions that satisfy constraints"backtrack, push copy at base case
"partition a string / array all ways"backtrack cutting at each valid split point
"place N items with constraints" (N-Queens, Sudoku)backtrack row-by-row, prune by constraint sets
"explore a grid for a word / path"grid DFS backtracking with visited marking
"combination sum / target with or without reuse"start-index backtrack; reuse → pass i, no reuse → i+1
"phone number letter combinations" / map-and-branch per characterbacktrack branching on each mapped character set

RED FLAGSWhen it's NOT this pattern

  • Only need the COUNT or the OPTIMUM. If the problem asks how many or the max/min — not the actual structures — enumerate nothing. Dynamic programming, greedy, or combinatorics will be orders of magnitude faster.
  • Huge n with overlapping subproblems. If the same sub-state recurs (e.g., memo-able prefix states), plain backtracking will TLE. Add memoization or switch to DP.
  • You only need to confirm existence (one solution). If a single valid path suffices, plain DFS / BFS returning early is simpler — no need to explore every branch.
  • The structure is linear and the choice is binary at each step. Pure include/exclude decisions with a running total are often cleaner as iterative DP (e.g., 0/1 knapsack) than as backtracking.

TEMPLATE Generic backtrack skeleton

When → The universal starting point. Paste this, then fill in isComplete, isValid, and remaining for your specific problem.

generic-backtrack-skeleton.ts
function solve(input: unknown[]): unknown[][] {
  const results: unknown[][] = [];

  function backtrack(path: unknown[], choices: unknown[]): void {
    // BASE CASE — a complete solution: record a COPY and return
    if (isComplete(path)) {
      results.push([...path]);          // snapshot — never push the live ref
      return;
    }

    for (let i = 0; i < choices.length; i++) {
      const choice = choices[i];

      // PRUNE — skip invalid branches early to cut search space
      if (!isValid(path, choice)) continue;

      // CHOOSE — extend the path
      path.push(choice);

      // EXPLORE — recurse deeper
      backtrack(path, remaining(choices, i));

      // UNCHOOSE — undo the choice (this is the "backtrack" step)
      path.pop();
    }
  }

  backtrack([], input);
  return results;
}
Always push a copy → results.push([...path]), never results.push(path). The live array keeps mutating after the push; you'll end up with a list of identical empty arrays.

TEMPLATE Subsets / combinations (start-index)

When → The result is a set of elements where order doesn't matter. Pass a start index so later iterations never re-pick earlier elements. For combination sum with reuse, pass i instead of i + 1.

subsets-combinations-start-index-.ts
function subsets(nums: number[]): number[][] {
  const results: number[][] = [];

  function backtrack(start: number, path: number[]): void {
    results.push([...path]);            // every prefix is a valid subset

    for (let i = start; i < nums.length; i++) {
      path.push(nums[i]);               // choose nums[i]
      backtrack(i + 1, path);           // only look at elements AFTER i
      path.pop();                       // unchoose
    }
  }

  backtrack(0, []);
  return results;
}

// Combination Sum variant: allow reuse by passing i (not i+1)
function combinationSum(candidates: number[], target: number): number[][] {
  const results: number[][] = [];

  function backtrack(start: number, path: number[], remaining: number): void {
    if (remaining === 0) { results.push([...path]); return; }
    if (remaining < 0) return;          // prune: went over the target

    for (let i = start; i < candidates.length; i++) {
      path.push(candidates[i]);
      backtrack(i, path, remaining - candidates[i]); // i, not i+1 → reuse allowed
      path.pop();
    }
  }

  backtrack(0, [], target);
  return results;
}
Reuse vs. no reuse → the only change is backtrack(i, ...) (allow the same index again) vs. backtrack(i + 1, ...) (each element used at most once).

TEMPLATE Permutations (used[] array)

When → The result is an ordered arrangement — every position matters. A used[] boolean array tracks which indices are currently in the path so that any unused element can fill the next slot.

permutations-used-array-.ts
function permutations(nums: number[]): number[][] {
  const results: number[][] = [];
  const used = new Array(nums.length).fill(false);

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

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

      used[i] = true;
      path.push(nums[i]);

      backtrack(path);

      path.pop();
      used[i] = false;                  // restore for sibling branches
    }
  }

  backtrack([]);
  return results;
}

TEMPLATE Deduplication: sort + skip duplicate siblings

When → The input contains duplicate values and the output must have no duplicate results. Sort first, then skip any element that equals its predecessor at the same recursion level (i.e., when i > start and nums[i] === nums[i-1]).

deduplication-sort-skip-duplicate-siblings.ts
// Deduplication pattern: sort + skip if same value as previous sibling
function subsetsWithDups(nums: number[]): number[][] {
  nums.sort((a, b) => a - b);           // sort first — groups duplicates together
  const results: number[][] = [];

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

    for (let i = start; i < nums.length; i++) {
      // Skip a duplicate at this level of the tree (same sibling, not same path)
      if (i > start && nums[i] === nums[i - 1]) continue;

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

  backtrack(0, []);
  return results;
}

// For permutations with duplicates, add: if (i > 0 && nums[i] === nums[i-1] && !used[i-1]) continue;
// Grid DFS backtracking (word search, etc.):
//   board[r][c] = '#';   // mark visited
//   backtrack(r+1, c); backtrack(r-1, c); ...
//   board[r][c] = original; // unmark
The guard is level-scoped → i > start (not i > 0). Using i > 0 would incorrectly skip a value the first time it appears at this level.

PITFALL Pushing a reference instead of a copy of path

results.push(path) stores a pointer to the same array you keep mutating. By the time backtracking finishes, every entry in results points to the same final (empty) array. Fix: results.push([...path]) at every base case.

PITFALL Forgetting to unchoose

Omitting path.pop() after the recursive call means the path only ever grows. Sibling branches inherit all previous choices, producing wildly wrong results. The choose and unchoose lines must always mirror each other — one push, one pop, with the recursion in between.

PITFALL Duplicate results without sort + skip

When the input has repeated values (e.g., [1,1,2]), the decision tree has multiple branches that produce the same subset or combination. Sort the input first, then guard with if (i > start && nums[i] === nums[i-1]) continue. The condition must use i > start, not i > 0.

PITFALL Missing or incorrect prune leads to TLE

Without a pruning check, the backtracker visits all exponentially many leaves. For combination-sum problems, add if (remaining < 0) return at the top of the function. For constraint-placement problems (N-Queens, Sudoku), check column and diagonal conflicts before recursing, not after. Pruning should be the first thing inside the loop.

PROBLEMS

#39Combination Sumstart-index recursion with reuse allowed — pass i (not i+1) so the same candidate can be picked again; prune when remaining < 0.#79Word SearchGrid DFS backtracking: mark board[r][c] = '#' before recursing and restore it after — the board itself serves as the visited set.#78SubsetsClassic powerset via start-index: every prefix of the path (including the empty one) is a valid subset — push a copy on every call, not just at a base case.#46Permutationsused[] array: at each level, loop all indices and skip used ones; push a copy when path.length === nums.length.#90Subsets IISort + skip duplicate siblings: same powerset skeleton as Subsets, but add the i > start && nums[i] === nums[i-1] guard to deduplicate.#40Combination Sum IISort + skip + i+1: each element used at most once (advance index), but skip a value that equals its predecessor at the same level to avoid duplicate combinations.#131Palindrome PartitioningAt each position, try every suffix prefix that is a palindrome; push it into the path and recurse on the remainder. Only copy the path when the entire string is consumed.#17Letter Combinations of a Phone NumberMap each digit to its letter set; backtrack through digits one at a time, branching once per letter. start-index is not needed — each level corresponds to one digit position.#51N-QueensPlace one queen per row; prune by tracking occupied cols, diag1 (r−c), and diag2 (r+c) in Sets. O(n!) worst case but heavy pruning makes it feasible for n ≤ 9.#140Word Break IIReturn every sentence formed by inserting spaces so each piece is a dictionary word. Backtrack from each index trying dictionary prefixes, and memoize start-index to all suffix sentences to avoid exponential recompute.#37Sudoku SolverFill a 9×9 Sudoku by backtracking: place a legal digit in the first empty cell (checked against row, column, and 3×3 box sets), recurse, and undo on failure. Used-sets make each legality test O(1).