Recursion & Backtracking
Solve a problem by reducing it to smaller copies of itself; backtracking explores a tree of partial choices and undoes each one after exploring it.
Overview
A recursive function calls itself on a strictly smaller input and combines the results. Two ingredients are mandatory: a base case that answers the smallest inputs directly, and a recursive case that makes measurable progress toward it. Without either, the function never returns.
Backtracking is recursion applied to search: build a candidate solution one decision at a time, recurse to complete it, and if the partial solution can no longer lead to a valid answer, undo the last decision and try the next option. The set of all partial solutions forms a recursion tree; backtracking is depth-first traversal of that tree, and pruning is refusing to enter subtrees that provably contain no answer.
Almost every enumeration problem — Subsets (Power Set), Permutations, Combinations (n choose k), N-Queens, Sudoku Solver, Word Search (Grid DFS) — is the same template with a different definition of "valid next choice".
Intuition
A mental model before the formal terms.
Picture a maze with many junctions. At each junction you pick a corridor, drop a breadcrumb, and walk on. Hit a dead end and you walk back to the last junction, pick up the breadcrumb, and try the next corridor. The breadcrumbs are your current path (the partial solution); picking one up is the "un-choose" step.
The recursion tree is the map of all corridors: the root is the empty path, each level is one more decision, leaves are complete candidates. A naive search visits every leaf. Pruning is noticing at a junction that every corridor beyond it is walled off — you turn back before walking down.
How it works
- Define the state: what has been chosen so far (usually a path array plus a used-set or the next index to consider).
- Base case: if the state is a complete solution, record or count it and return.
- Choose: for each candidate next choice that keeps the state valid (this check is the pruning), append it to the path and mark it used.
- Explore: recurse on the extended state.
- Un-choose: remove the choice from the path and unmark it, so the next iteration of the loop starts from exactly the same state.
- Recording a solution must copy the path (
path[:],[...path]) — the path array is mutated afterwards by un-choose.
Why it works
Induction on the depth of the recursion: assuming every recursive call on a longer partial state correctly enumerates all completions of that state, the loop over valid next choices covers all completions of the current state exactly once, because each completion has a unique first choice.
Un-choose restores the invariant "state == the path from root to this node". Because every mutation made on the way down is reverted on the way up, siblings in the recursion tree see identical state.
Pruning is sound only if the rejected subtree truly contains no solution, i.e. validity is monotone: once a partial state is invalid, every extension of it stays invalid. Placing two queens on the same diagonal can never be repaired by adding more queens, so pruning there is safe.
Recognition
How to tell a problem wants this.
- The problem asks for all solutions, all combinations/arrangements, or the number of valid configurations with small
n(typicallyn ≤ 20, or a 9×9 board). - The answer is built from a sequence of decisions where each decision has a small set of options and there is a cheap validity check.
- Constraints are exponential-looking (
2^n,n!) and the input sizes are tiny; a DP would not apply because the state must remember the whole path. - Phrases: "generate all", "find any arrangement", "does a placement exist", "count the ways" with tiny bounds.
Interactive visualization
Play, step, change the input. ← → and space work too.
1fact(n):2 if n <= 1: return 1 // base case3 sub = fact(n - 1) // recursive case4 return n * subPseudocode
1backtrack(state):2 if state is complete:3 record(copy of state)4 return5 for choice in candidates(state):6 if not valid(state, choice): continue # prune7 apply(state, choice) # choose8 backtrack(state) # explore9 undo(state, choice) # un-chooseImplementations
1# Enumerate all bit-strings of length n with no two adjacent 1s.2# Shows the choose / explore / un-choose skeleton with a pruning check.3def no_adjacent_ones(n: int) -> list[str]:4 out: list[str] = []5 path: list[str] = []6 7 def backtrack() -> None:81 · Base case9 if len(path) == n:10 out.append("".join(path))11 return12 for bit in ("0", "1"):13 if bit == "1" and path and path[-1] == "1":14 continue # prune: would violate the rule152 · Choose16 path.append(bit)173 · Explore18 backtrack()194 · Un-choose20 path.pop()21 22 backtrack()23 return out24 25 265 · Plain recursion27def factorial(n: int) -> int:28 if n <= 1:29 return 130 return n * factorial(n - 1)backtrackis a nested function closing overpath,outandn; it mutatespathin place (nononlocalneeded since it never rebinds the name).- The base case appends
"".join(path), a fresh string, so the shared list can be reused. if bit == "1" and path and path[-1] == "1"prunes before any state change;path[-1]is the last element.append/ recursive call /popis the choose-explore-un-choose triple.factorialuses arbitrary-precisionint, so there is no overflow; depth, not magnitude, is the limit.
"".join(path) is O(n) per result; Python function calls are comparatively expensive, so deep backtracking is slower than in C++.
- CPython caps recursion at 1000 frames by default (
RecursionError). Raise it withsys.setrecursionlimit(10**6)and, for very deep recursion, alsothreading.stack_size(...)because the C stack can still overflow. - CPython has no tail-call optimisation by design;
math.factorialandfunctools.lru_cacheare the pragmatic stdlib helpers. - Use
nonlocalonly when a nested function rebinds an outer variable (e.g. a counter); mutating a list does not require it.
- Appending
path(the shared list) to results instead ofpath[:]orlist(path); every stored result then aliases the same object. - Hitting
RecursionErroron inputs of a few thousand and assuming the algorithm is wrong. - Rebinding
path = path + [x]inside the helper, which creates a new local list and breaks the un-choose step.
- Recursion depth: CPython stops at 1000 frames by default (
sys.setrecursionlimitraises it); V8 (JS/TS) throwsRangeErroraround 10k frames; C++ is bounded only by the OS thread stack (~8 MB Linux/macOS, ~1 MB Windows) and overflows with a crash instead of an exception. - C++ must pass
pathandoutby reference (string&,vector<string>&); JS/TS/Python closures capture the shared containers by reference automatically. factorialoverflowslong longpast 20! in C++, loses precision past 2^53 in JS/TSNumber, and is exact in Pythonint.- No mainstream runtime here performs tail-call optimisation for backtracking; only C++ compilers may TCO simple tail recursion.
Complexity
b = branching factor, d = depth of the recursion tree. Output-sensitive: the number of leaves visited is bounded by the number of valid partial states, which pruning shrinks dramatically.
Compare growth rates in the Complexity Explorer →When to use — and when not to
- Enumerating or counting all configurations for small
nwhere there is no overlapping-subproblem structure to exploit. - Constraint satisfaction: place items so that every pairwise constraint holds (N-Queens, Sudoku Solver).
- Any problem where the natural recursive definition mirrors the answer (tree traversals, expression parsers, divide-and-conquer).
- The same subproblem recurs many times with the same parameters — that is Dynamic Programming territory (Memoization (Top-Down DP) turns exponential recursion into polynomial).
- You only need one optimal answer and a greedy argument (see Activity Selection) or a Divide and Conquer split exists; exhaustive search is wasteful.
- Depth can reach
10^5+— recursion will overflow the call stack; convert to an explicit Stack.
Alternatives
Common mistakes
- Pushing
path(the live reference) into the results instead of a copy — every recorded answer ends up empty or identical. - Forgetting the un-choose step, or un-choosing something different from what was chosen (e.g.
visitedset updated but not restored). - Base case placed after the loop, so the function keeps recursing past a complete solution.
- Pruning with a check that is not monotone, which silently drops valid solutions.
- Duplicate solutions when the input has repeated elements — sort and skip
a[i] == a[i-1]when the previous copy was not used.
Interview patterns
- Start-index pattern for order-independent choices (Subsets (Power Set), Combinations (n choose k)): only consider indices ≥
startto avoid duplicates. - Used-array pattern for order-dependent choices (Permutations).
- Grid-DFS pattern with in-place marking of visited cells (Word Search (Grid DFS), Maze Search (Grid Backtracking)).
- Constraint-array pattern: bitmask or boolean arrays for columns/diagonals (N-Queens) or rows/cols/boxes (Sudoku Solver).
- Return early with a boolean when any single solution suffices.
- Choosing between BFS, DFS, Dijkstra and DPAdvanced
- Stack versus queueBeginner
- Recursion versus iterationIntermediate
- When space complexity mattersIntermediate
- Course ScheduleIntermediate
- Number of IslandsIntermediate
- Word SearchAdvanced