Backtracking โ The Ultimate Reference Guide
TL;DR (whole document): Backtracking is systematic, depth-first exploration of a decision tree that prunes dead branches early and undoes each choice before trying the next. Master three archetypes โ Subsets (include/exclude), Permutations (order matters), Constraint Search (place-under-rules) โ and you can attack most combinatorial problems. The same idea powers AI planning, CSP solvers, hyperparameter/architecture search, and constrained LLM decoding.
1. What Is Backtracking?โ
TL;DR: Backtracking = brute force with a conscience. It builds a solution incrementally, abandons ("prunes") any partial candidate the moment it cannot possibly lead to a valid answer, and rewinds to try the next option.
1.1 Definitionโ
Backtracking is an algorithmic paradigm that solves problems by incrementally constructing candidate solutions and abandoning a candidate ("backtracking") as soon as it determines the candidate cannot be extended to a valid solution. It performs a depth-first traversal of an implicit state-space tree, where:
- each node is a partial solution (a sequence of choices made so far),
- each edge is a single choice (a decision that extends the partial solution),
- each leaf is either a complete solution or a dead end.
The defining feature โ and the reason it beats naive enumeration โ is pruning: a is_valid / is_promising check that eliminates entire subtrees before they are explored. If a partial candidate already violates a constraint, every completion of it is invalid too, so the whole subtree is skipped.
Formally, backtracking explores the set of all candidate solutions organized as a tree, and uses two predicates:
reject(partial)โ true ifpartialcannot possibly be completed to a valid solution (enables pruning).accept(partial)โ true ifpartialis itself a complete, valid solution (record it).
๐ก Insight: The power of backtracking is not that it enumerates โ it is that it refuses to enumerate subtrees that are provably hopeless. A problem with 2^40 candidates can become tractable if constraints prune 99.9% of branches near the root.
1.2 Core Analogyโ
"Backtracking is like exploring a maze with a ball of string and an eraser. You commit to a corridor, unspooling string as you go. The moment you hit a wall (a dead end), you don't teleport randomly โ you reel the string back to the last junction (undo your last choice) and take the next unexplored corridor. You never re-walk a corridor you've fully explored, and you never wander into a passage you can already see is bricked up."
A second, equally useful mental model is the decision tree with an eraser: you descend a branch making choices, and when a branch fails you erase your pencil marks back up to the last decision point and try the next branch. The "eraser" is the crucial unchoose step โ without it, state from a failed branch contaminates the next one.
1.3 Comparison with Related Techniquesโ
| Technique | Core idea | Explores | Undoes choices? | Prunes? | When it wins |
|---|---|---|---|---|---|
| Brute force | Enumerate all candidates, then test | Entire space, blindly | No (regenerates) | โ No | Tiny spaces; baseline correctness |
| Backtracking | Build incrementally, prune invalid partials, undo | State-space tree, DFS | โ Yes | โ Yes (constraint-based) | Combinatorial search with checkable constraints |
| Dynamic programming | Solve & memoize overlapping subproblems | DAG of subproblems | N/A | Via reuse, not pruning | Optimal substructure + overlapping subproblems |
| Greedy | Make the locally optimal choice, never reconsider | A single root-to-leaf path | โ No (never revisits) | N/A | Greedy-choice property holds (e.g. MST, Huffman) |
| Branch & Bound | Backtracking + a bound function to prune by objective value | State-space tree, best-first/DFS | โ Yes | โ Yes (bound-based) | Optimization over combinatorial spaces |
Key distinctions to internalize:
- โ ๏ธ Backtracking vs. brute force: Brute force generates every candidate independently and checks it at the end. Backtracking checks partial candidates and kills bad subtrees early. Backtracking's worst case can equal brute force (no constraints prune anything), but in practice constraints make it dramatically faster.
- โ ๏ธ Backtracking vs. DP: DP exploits overlapping subproblems โ the same subproblem is solved once and cached. Backtracking's subproblems are typically distinct paths (different sets of choices), so there is nothing to memoize. If you notice overlapping states, you likely want DP (or memoized backtracking, which is DP in disguise).
- โ ๏ธ Backtracking vs. greedy: Greedy commits irrevocably and is fast but only correct when the greedy-choice property holds. Backtracking reconsiders โ it is the fallback when greedy is wrong.
- ๐ก Branch & Bound is best understood as backtracking specialized for optimization: it adds a numeric bound on the best achievable objective in a subtree and prunes subtrees that cannot beat the incumbent.
2. The Universal Backtracking Templateโ
TL;DR: Almost every backtracking solution is the same three-line skeleton โ choose โ explore โ unchoose โ wrapped in a base case and a validity/pruning check. Learn the skeleton once; specialize the choices per problem.
2.1 Pseudocodeโ
function BACKTRACK(state):
if REJECT(state): # pruning: this partial can't lead anywhere valid
return
if ACCEPT(state): # base case: complete, valid solution
RECORD(state)
return # (or 'continue' if longer solutions exist)
for choice in CANDIDATES(state): # branching
MAKE(state, choice) # CHOOSE โ extend the partial solution
BACKTRACK(state) # EXPLORE โ recurse into the subtree
UNDO(state, choice) # UNCHOOSE โ restore state (backtrack)
The five problem-specific pieces you must define:
CANDIDATES(state)โ what choices are available now (the branching factor).MAKE** / **UNDOโ how to apply and revert a choice (must be exact inverses).REJECTโ the pruning predicate (the single biggest lever on performance).ACCEPTโ when a partial solution is complete.RECORDโ what to do with a found solution (collect, count, or return early).
๐ก Insight: RECORD placement matters. If you record at every node (e.g. subsets), the base case is implicit and every partial is a valid answer. If you record only at leaves (e.g. permutations, N-Queens), you gate recording behind an ACCEPT check.
2.2 The ChooseโExploreโUnchoose Cycleโ
The heartbeat of backtracking is three symmetric operations around the recursive call:
for choice in candidates:
path.append(choice) # 1. CHOOSE โ mutate shared state to reflect the decision
backtrack(path) # 2. EXPLORE โ recurse; all deeper decisions build on this choice
path.pop() # 3. UNCHOOSE โ reverse the mutation so the next iteration starts clean
- Choose โ commit to one option, mutating the shared state (append to a path, mark a cell, set a color).
- Explore โ recurse. Everything discovered in the recursion is conditioned on the choice you just made.
- Unchoose โ the backtrack. Reverse the exact mutation so the loop's next iteration explores a sibling branch from an identical starting state.
โ ๏ธ The #1 backtracking bug: forgetting to unchoose, or making undo not a perfect inverse of make. If make adds to a set and undo clears the whole set, sibling branches inherit corrupted state. make** and undo must be exact mirror images.**
๐ก Snapshot vs. shared mutable state: Two coding styles exist:
- Shared mutable path (shown above) โ one list is mutated and restored; you must snapshot (
path[:]) when recording, or every recorded solution will alias the same list and end up empty/identical. - Immutable extension (
backtrack(path + [choice])) โ passes a fresh copy down, so nounchooseis needed. Cleaner but allocatesO(depth)memory per call; slower for large trees.
3. Subsetsโ
TL;DR: The subset problem is the "hello world" of backtracking. For each element you make a binary decision โ include it or not โ producing all
2^nsubsets. Thestartindex prevents re-choosing earlier elements, which is what stops permutations of the same subset from appearing.
3.1 Theoryโ
Given n distinct elements, there are exactly 2^n** subsets** (each element is independently in or out โ that's the power set). The state-space tree has two natural formulations:
- Binary-choice tree (include/exclude): at depth
i, branch on "includenums[i]" vs "excludenums[i]". Depthn,2^nleaves, each leaf is one subset. - Index-advancing tree (the idiomatic template): at each node, iterate a
startpointer over remaining elements, choosing one to append and recursing withstart = i + 1. Every node is a valid subset (not just leaves), so we record on entry.
The start index is the key invariant: by only ever choosing elements at indices >= start, we generate each subset in strictly increasing index order, so {1,2} is produced but {2,1} never is. This is exactly what distinguishes subsets/combinations (order-agnostic) from permutations (order-sensitive).
Before you code, ask the learner's questions: "Why doesn't this produce duplicates like {1,2} and {2,1}?" โ because start forbids looking backward. "Why is every node recorded, not just leaves?" โ because a subset can end at any length; there is no 'incomplete' subset.
3.2 Implementationโ
from typing import List
def subsets(nums: List[int]) -> List[List[int]]:
"""All 2^n subsets of a list of DISTINCT integers."""
result: List[List[int]] = []
def backtrack(start: int, current: List[int]) -> None:
# Every partial IS a valid subset โ record a SNAPSHOT (current[:]),
# not a reference, or all entries will alias the same mutated list.
result.append(current[:])
# 'start' ensures we only look forward โ no duplicate subsets.
for i in range(start, len(nums)):
current.append(nums[i]) # CHOOSE nums[i]
backtrack(i + 1, current) # EXPLORE with elements after i
current.pop() # UNCHOOSE (backtrack)
backtrack(0, [])
return result
nums = [1, 2, 3]
print(subsets(nums))
# [[], [1], [1, 2], [1, 2, 3], [1, 3], [2], [2, 3], [3]]
โ
Verified output: [[], [1], [1, 2], [1, 2, 3], [1, 3], [2], [2, 3], [3]] โ all 8 = 2^3 subsets.
3.3 Complexityโ
- Time:
O(n ยท 2^n). There are2^nsubsets; copying each intoresultcosts up toO(n). The tree itself has2^nnodes, and the snapshot copy dominates. - Space:
O(n)auxiliary for the recursion stack and thecurrentpath (depth โคn). Excluding the output, it'sO(n); including the output it'sO(n ยท 2^n)to store all subsets.
3.4 Variantsโ
A) Subsets with duplicates โ input may contain repeats (e.g. [1,2,2]). Sort first, then **skip a candidate if it equals its predecessor **at the same tree level (i > start and nums[i] == nums[i-1]). This keeps {2,2} but suppresses the second {2} that would otherwise duplicate the first.
def subsets_with_dup(nums: List[int]) -> List[List[int]]:
nums.sort() # group duplicates together
result: List[List[int]] = []
def backtrack(start: int, current: List[int]) -> None:
result.append(current[:])
for i in range(start, len(nums)):
if i > start and nums[i] == nums[i - 1]:
continue # โ ๏ธ skip duplicate at THIS level only
current.append(nums[i]) # CHOOSE
backtrack(i + 1, current) # EXPLORE
current.pop() # UNCHOOSE
backtrack(0, [])
return result
print(subsets_with_dup([1, 2, 2]))
# [[], [1], [1, 2], [1, 2, 2], [2], [2, 2]]
โ
Verified: [[], [1], [1, 2], [1, 2, 2], [2], [2, 2]] โ no duplicate subsets.
B) Subsets of size k (combinations) โ only record at depth k, and prune branches that can't reach length k:
def subsets_of_size_k(nums: List[int], k: int) -> List[List[int]]:
result: List[List[int]] = []
def backtrack(start: int, current: List[int]) -> None:
if len(current) == k: # ACCEPT: exact size reached
result.append(current[:])
return
need = k - len(current) # elements still required
# โ
PRUNE: stop when fewer than `need` elements remain
for i in range(start, len(nums) - need + 1):
current.append(nums[i]) # CHOOSE
backtrack(i + 1, current) # EXPLORE
current.pop() # UNCHOOSE
backtrack(0, [])
return result
print(subsets_of_size_k([1, 2, 3, 4], 2))
# [[1, 2], [1, 3], [1, 4], [2, 3], [2, 4], [3, 4]]
โ
Verified: all 6 = C(4,2) combinations. ๐ก The len(nums) - need + 1 bound is a textbook pruning trick โ it avoids descending into branches that provably can't fill k slots.
4. Permutationsโ
TL;DR: Permutations are subsets' order-sensitive cousin: order matters and every element is used exactly once. Two idioms dominate โ a
used[]** visited array** (clear, stable order) and in-place swapping (O(1)extra space, unstable order). There aren!permutations.
4.1 Theoryโ
A permutation is an arrangement of all n elements where order is significant, giving n! results. Unlike subsets, there is no start pointer โ at every level you may choose any not-yet-used element, so the tree has branching factor n at the root, n-1 next, and so on (n! leaves).
Two canonical approaches:
- Visited-array approach: maintain a
used[]boolean array. At each level, iterate over all indices and skip those already used. Choose = mark used + append; unchoose = unmark + pop. Preserves input order, so output is lexicographic-ish and stable. UsesO(n)extra space forused[]. - Swap-based approach: fix positions left to right. At position
idx, swap each candidatenums[i](i >= idx) into place, recurse onidx+1, then swap back.O(1)** auxiliary space** (mutates the array in place, noused[]), but the output order is not lexicographic and it's trickier to extend to the duplicates case.
Learner's misconception to preempt: "Why no start index like subsets?" โ Because for permutations we want [1,2] and [2,1] as distinct answers, so we must look backward โ we only forbid reusing the same element, tracked by used[] (or by the swap structure), not by an index floor.
4.2 Implementationโ
Visited-array (recommended for clarity and duplicate handling):
def permute(nums: List[int]) -> List[List[int]]:
result: List[List[int]] = []
used = [False] * len(nums)
def backtrack(current: List[int]) -> None:
if len(current) == len(nums): # ACCEPT: used every element
result.append(current[:]) # snapshot
return
for i in range(len(nums)):
if used[i]: # skip elements already placed
continue
used[i] = True # CHOOSE
current.append(nums[i])
backtrack(current) # EXPLORE
current.pop() # UNCHOOSE
used[i] = False
backtrack([])
return result
print(permute([1, 2, 3]))
# [[1,2,3], [1,3,2], [2,1,3], [2,3,1], [3,1,2], [3,2,1]]
โ
Verified: all 6 = 3! permutations, in stable lexicographic order.
Swap-based (O(1)** auxiliary space):**
def permute_swap(nums: List[int]) -> List[List[int]]:
result: List[List[int]] = []
nums = nums[:] # work on a copy
def backtrack(idx: int) -> None:
if idx == len(nums):
result.append(nums[:])
return
for i in range(idx, len(nums)):
nums[idx], nums[i] = nums[i], nums[idx] # CHOOSE (swap into position)
backtrack(idx + 1) # EXPLORE
nums[idx], nums[i] = nums[i], nums[idx] # UNCHOOSE (swap back)
backtrack(0)
return result
print(permute_swap([1, 2, 3]))
# [[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,2,1],[3,1,2]] (different order, same set)
โ Verified: all 6 permutations (note the order differs from the visited-array version โ swapping is not lexicographic).
4.3 Complexityโ
- Time:
O(n ยท n!). There aren!leaves; each finished permutation costsO(n)to snapshot. (The internal nodes sum to less than the leaf work, so leaves dominate.) - Space:- Visited-array:
O(n)forused[]+O(n)recursion depth =O(n)auxiliary. - Swap-based:
O(n)recursion depth only, **no **used[]โ the constant-factor winner. - Output storage:
O(n ยท n!)to hold all permutations.
4.4 Variantsโ
A) Permutations with duplicates โ sort, then skip a value if it equals the previous value and the previous is not currently used (i > 0 and nums[i]==nums[i-1] and not used[i-1]). This canonical rule forces duplicates to be chosen in a fixed left-to-right order, eliminating repeat arrangements.
def permute_unique(nums: List[int]) -> List[List[int]]:
nums.sort() # group duplicates
result: List[List[int]] = []
used = [False] * len(nums)
def backtrack(current: List[int]) -> None:
if len(current) == len(nums):
result.append(current[:])
return
for i in range(len(nums)):
if used[i]:
continue
# โ ๏ธ skip duplicate values unless the identical predecessor is in use
if i > 0 and nums[i] == nums[i - 1] and not used[i - 1]:
continue
used[i] = True
current.append(nums[i])
backtrack(current)
current.pop()
used[i] = False
backtrack([])
return result
print(permute_unique([1, 1, 2]))
# [[1, 1, 2], [1, 2, 1], [2, 1, 1]]
โ
Verified: exactly 3 distinct permutations (not 3! = 6).
**B) Partial permutations **P(n, k) โ arrangements of k out of n elements (n! / (nโk)! of them). Same as permute, but the base case fires at length k:
def partial_permutations(nums: List[int], k: int) -> List[List[int]]:
result: List[List[int]] = []
used = [False] * len(nums)
def backtrack(current: List[int]) -> None:
if len(current) == k: # ACCEPT at size k, not n
result.append(current[:])
return
for i in range(len(nums)):
if used[i]:
continue
used[i] = True
current.append(nums[i])
backtrack(current)
current.pop()
used[i] = False
backtrack([])
return result
print(partial_permutations([1, 2, 3], 2))
# [[1,2],[1,3],[2,1],[2,3],[3,1],[3,2]]
โ
Verified: all 6 = P(3,2) ordered pairs.
๐ก Subsets vs. permutations at a glance: the only structural difference is start (subsets: look forward, order-free, 2^n) vs. used[]/swap (permutations: look everywhere-unused, order-sensitive, n!).
5. Constraint Search (CSP)โ
TL;DR: A Constraint Satisfaction Problem is defined by variables, their domains, and constraints. Backtracking assigns variables one at a time and prunes the instant a constraint is violated. Smart ordering (MRV) and lookahead (forward checking, arc consistency / AC-3) turn exponential blowups into practical solvers. Classics: N-Queens, Sudoku, Graph Coloring.
5.1 Theoryโ
A CSP is a triple (X, D, C):
- Variables
X = {x1, ..., xn}โ the things to assign (e.g. queen positions, Sudoku cells, region colors). - Domains
D = {D1, ..., Dn}โ the allowed values for each variable (e.g. columns0..n-1, digits1..9, colors{R,G,B}). - Constraints
Cโ relations restricting simultaneous assignments (e.g. "no two queens share a diagonal", "each row of Sudoku holds distinct digits", "adjacent regions differ in color").
A solution is a complete assignment satisfying every constraint. Backtracking search assigns one variable at a time, checking constraints against already-assigned variables and pruning failed partial assignments. Two ideas make this efficient beyond raw pruning:
- Variable ordering โ Minimum Remaining Values (MRV): always assign the most-constrained variable next (the one with the fewest legal values left) to "fail fast" and shrink the tree.
- Value ordering โ Least Constraining Value (LCV): try the value that rules out the fewest options for neighbors, to "succeed fast".
Learner framing: "Why is this different from subsets/permutations?" โ In CSP the tree is shaped by constraints and domains, not by a fixed include/order rule. Pruning is problem-specific and is where nearly all the speed comes from.
5.2 Classic Problemsโ
- N-Queens: place
nqueens on annรnboard so none attack each other. Variables = rows, domains = columns, constraints = distinct columns and distinct diagonals (rowโcolandrow+col).n=8has 92 solutions. - Sudoku: fill a 9ร9 grid so every row, column, and 3ร3 box contains
1..9exactly once. Variables = empty cells, domain =1..9, constraints = all-different per row/column/box. - Graph Coloring (
m-coloring): color vertices withโค mcolors so no edge joins same-colored vertices. A triangle needsโฅ 3colors; this is the abstraction behind register allocation and exam scheduling.
5.3 Pruning Strategiesโ
- โ Constraint check (base pruning): before recursing on a value, verify it violates no constraint against current assignments. This alone converts brute force into backtracking.
- โ
Forward checking: after assigning
x = v, immediately removevfrom the domains of unassigned neighbors. If any neighbor's domain becomes empty, backtrack now instead of discovering the conflict deep in the subtree. - โ
Arc consistency (AC-3): enforce that for every constraint arc
(xi, xj), each value inDihas some compatible value inDj; iteratively delete unsupported values. Applied at each node (MAC โ Maintaining Arc Consistency), it prunes far more aggressively than forward checking, at higher per-node cost. - โ MRV + degree heuristic: pick the variable with the fewest remaining legal values (ties broken by the variable involved in the most constraints).
- ๐ก Constraint propagation โท search tradeoff: more propagation (AC-3/MAC) means fewer nodes but more work per node. The art is balancing them; industrial solvers (e.g. Google OR-Tools CP-SAT) tune this automatically.
5.4 Implementationโ
N-Queens with O(1) conflict checks via three sets (columns, both diagonals):
def solve_n_queens(n: int) -> List[List[int]]:
"""Return every solution as a list where board[r] = column of the queen in row r."""
result: List[List[int]] = []
cols, diag, anti = set(), set(), set() # occupied columns, r-c diagonals, r+c anti-diagonals
board = [-1] * n
def backtrack(row: int) -> None:
if row == n: # ACCEPT: all rows filled
result.append(board[:])
return
for col in range(n): # domain of this row = columns 0..n-1
# โ
PRUNE: reject if column or either diagonal is already attacked
if col in cols or (row - col) in diag or (row + col) in anti:
continue
cols.add(col); diag.add(row - col); anti.add(row + col) # CHOOSE
board[row] = col
backtrack(row + 1) # EXPLORE
cols.remove(col); diag.remove(row - col); anti.remove(row + col) # UNCHOOSE
board[row] = -1
backtrack(0)
return result
solutions = solve_n_queens(8)
print(len(solutions)) # 92
print(solutions[0]) # [0, 4, 7, 5, 2, 6, 1, 3]
โ
Verified: len == 92 (the known count for 8-queens); first solution [0, 4, 7, 5, 2, 6, 1, 3]. Encoding a row's queen as a single column value already enforces "one queen per row", shrinking the domain enormously versus placing queens on arbitrary squares.
Sudoku solver (constraint check + first-empty-cell ordering):
def solve_sudoku(board: List[List[object]]) -> List[List[object]]:
"""Solve 9x9 Sudoku in place. Empty cells are 0; filled are chars/ints '1'..'9'."""
def is_valid(r: int, c: int, v: str) -> bool:
for i in range(9):
if board[r][i] == v or board[i][c] == v: # row & column
return False
br, bc = 3 * (r // 3), 3 * (c // 3) # 3x3 box origin
for i in range(br, br + 3):
for j in range(bc, bc + 3):
if board[i][j] == v:
return False
return True
def backtrack() -> bool:
for r in range(9):
for c in range(9):
if board[r][c] == 0: # next unassigned variable
for v in "123456789": # domain
if is_valid(r, c, v):
board[r][c] = v # CHOOSE
if backtrack(): # EXPLORE
return True
board[r][c] = 0 # UNCHOOSE
return False # โ
PRUNE: no digit fits โ dead end
return True # no empties left โ solved
backtrack()
return board
โ
Verified on a standard puzzle โ produces a valid completed grid (every row/column/box holds 1..9). ๐ก Swapping "first empty cell" for MRV (fill the cell with the fewest legal digits first) can cut runtime by orders of magnitude on hard puzzles.
Graph Coloring (m-coloring decision):
def graph_coloring(adj: List[List[int]], m: int):
"""adj is an n x n 0/1 adjacency matrix. Return a valid coloring or None."""
n = len(adj)
color = [0] * n # 0 = uncolored; colors are 1..m
def is_ok(v: int, c: int) -> bool:
return all(not (adj[v][u] and color[u] == c) for u in range(n))
def backtrack(v: int) -> bool:
if v == n:
return True
for c in range(1, m + 1): # domain = colors 1..m
if is_ok(v, c): # โ
PRUNE on adjacency conflict
color[v] = c # CHOOSE
if backtrack(v + 1): # EXPLORE
return True
color[v] = 0 # UNCHOOSE
return False
return color if backtrack(0) else None
triangle = [[0,1,1],[1,0,1],[1,1,0]]
print(graph_coloring(triangle, 3)) # [1, 2, 3] โ needs 3 colors
print(graph_coloring(triangle, 2)) # None โ 2 colors impossible
โ
Verified: triangle is 3-colorable ([1,2,3]) but not 2-colorable (None).
5.5 Complexityโ
- N-Queens: worst case
O(n!)(branching shrinks each row:n, n-2, ...); the diagonal/column sets prune heavily. SpaceO(n)for the three sets + recursion. Counting all solutions is inherently exponential; finding one is fast for moderaten. - Sudoku: worst case
O(9^m)wheremis the number of empty cells (domain 9 per cell), but constraint propagation makes real puzzles near-instant. SpaceO(1)extra beyond the fixed 81-cell board plus recursion depth. - Graph
m-coloring: worst caseO(m^n)โmchoices for each ofnvertices; decidingm-colorability is NP-complete form โฅ 3. SpaceO(n). - ๐ก General CSP: naive backtracking is
O(d^n)fornvariables with domain sized. Forward checking and arc consistency reduce the effective branching factor, often turning practical instances tractable despite the exponential worst case.
6. Comparison Tableโ
| Problem type | Branching factor | Pruning strategy | Time complexity | Space (aux) | Common use case |
|---|---|---|---|---|---|
| Subsets / power set | 2 (include/exclude) per element; template loops start..n | start index (no revisiting); size bound for size-k | O(n ยท 2^n) | O(n) | Feature selection, generating combinations, subset-sum |
**Combinations **C(n,k) | โค n โ start | len(nums) โ need + 1 bound | O(k ยท C(n,k)) | O(k) | Lottery-style choices, k-subsets, committee selection |
| Permutations | n, then nโ1, ... | used[] / swap (no reuse); dup-skip rule | O(n ยท n!) | O(n) | Ordering/arrangement, TSP brute enumeration, anagrams |
**Partial perms **P(n,k) | n, nโ1, ... | base case at depth k | O(k ยท P(n,k)) | O(n) | Top-k rankings, seating for k seats |
| N-Queens | โค n per row | column + 2 diagonal sets; MRV | O(n!) worst | O(n) | Constraint placement, VLSI/board layout demos |
| Sudoku | โค 9 per empty cell | row/col/box all-different; forward checking; MRV | O(9^m) worst (m empties) | O(1) + stack | Latin-square / grid constraint solving |
Graph m-coloring | m per vertex | adjacency check; forward checking; degree/MRV | O(m^n) worst (NP-complete) | O(n) | Register allocation, scheduling, map coloring |
| General CSP | domain size d | forward checking, AC-3/MAC, MRV+LCV | O(d^n) worst | O(nยทd) for domains | Timetabling, configuration, planning |
7. Backtracking in AI, ML & LLMsโ
TL;DR: Backtracking is not just an interview trope โ it is the search backbone of classical AI (game trees, planning, CSP solvers) and appears, in bounded/relaxed forms, throughout modern ML and LLM systems (NAS, hyperparameter search, constrained and tree-structured decoding).
7.1 Search in AI agents โ game trees & planningโ
- Game-tree search (Minimax / Alpha-Beta): Minimax is a depth-first exploration of a game's decision tree; alpha-beta pruning is precisely backtracking's
rejectpredicate applied to values โ it abandons a branch once it cannot influence the final decision, exactly like a constraint violation kills a subtree. This is the direct ancestor of the search in engines like Stockfish. - Classical planning (STRIPS/PDDL, DFS/backward search): planners search the space of action sequences, backtracking when a partial plan reaches a state from which the goal is unreachable. Backtracking search over partial plans (partial-order planning) is a canonical AI technique.
- ๐ก Modern LLM agents borrow this explicitly: Tree-of-Thoughts (ToT) and Language-Agent-Tree-Search (LATS) have the model generate candidate reasoning steps, evaluate them, and backtrack from dead-end thoughts โ backtracking with an LLM as both the branch generator and the
reject/value function.
7.2 Hyperparameter search & Neural Architecture Search (NAS)โ
- Hyperparameter search: grid/random search are brute force; more sophisticated schedulers (e.g. Hyperband/successive halving) prune unpromising configurations early โ the same "kill hopeless branches" instinct as backtracking's
reject, applied to partially-trained models. - Neural Architecture Search: searching the discrete space of architectures is a combinatorial search problem. Early NAS explored architecture decision trees with pruning; the space (layer types, connections, widths) is exactly a CSP-like structure where invalid/underperforming partial architectures are abandoned.
- โ ๏ธ Caveat: these are usually cast as optimization (find the best, not a valid), so they lean toward branch-and-bound, Bayesian optimization, or RL rather than pure boolean-constraint backtracking. The kinship is the prune-early principle, not identical machinery.
7.3 Beam search & token decoding in LLMsโ
Pro Insight: In LLM inference, beam search is a bounded (breadth-limited) backtracking variant โ it maintains the top-
kpartial sequences (beams) at each step and prunes all lower-probability paths, trading exhaustiveness for tractability. Pure backtracking would explore the entirevocab^lengthtree; beam search caps the frontier atk.
- Constrained decoding / grammar-constrained generation: when you force an LLM to emit valid JSON, SQL, or a regex-conforming string, the decoder masks out tokens that would violate the grammar at each step โ this is forward checking on a CSP whose variables are token positions and whose constraints are the grammar. Some implementations backtrack when a greedy/beam path paints itself into a corner (no valid continuation), exactly mirroring CSP dead-end recovery.
- Structured / tree-of-thoughts decoding: explores multiple continuation branches and prunes by a scoring/value model โ backtracking with a learned heuristic.
- โ ๏ธ Nuance: beam search does not literally
unchooseand rewind a single path; it keepskpaths alive breadth-first. It is best described as bounded search with pruning โ a cousin of backtracking sharing the pruning DNA, not a textbook DFS backtracker.
7.4 Constraint satisfaction in scheduling & ML pipelinesโ
- Scheduling/timetabling: shift scheduling, exam timetabling, and job-shop problems are CSPs solved with backtracking + constraint propagation (the engine behind Google OR-Tools CP-SAT, used in production planning).
- AutoML pipeline configuration: choosing a valid, compatible sequence of preprocessing โ model โ post-processing steps is a constraint problem (e.g. a scaler must precede a distance-based model) โ solvers prune incompatible partial pipelines.
- Feature selection: selecting a subset of features under constraints (max count, must-include groups) is literally the subsets problem with pruning; combinatorial feature search uses backtracking when the space is small enough.
- ๐ก For tabular ML, when a feature-selection search space is large, prefer wrapping a fast learner (e.g. gradient-boosted trees) inside the search and prune by validation score early โ a branch-and-bound flavored feature search rather than exhaustive backtracking.
8. Expert Takeaways & Common Pitfallsโ
TL;DR: Most backtracking bugs are state-management errors (bad
unchoose, aliasing) or missing pruning. Most performance wins come from **stronger **reject, better variable/value ordering, and memoization when subproblems overlap. Know when to not backtrack.
8.1 Common pitfalls (and fixes)โ
- โ ๏ธ Aliasing the result: appending
currentinstead ofcurrent[:]stores references to one mutated list โ you end up with N copies of[]. Fix: always snapshot (current[:]/list(current)) when recording. - โ ๏ธ Asymmetric
make/undo: ifundoisn't the exact inverse ofmake, sibling branches inherit corrupt state. Fix: mirror them line-for-line; prefer adding/removing the same element, not clearing containers. - โ ๏ธ Missing base case / infinite recursion: forgetting the
ACCEPTreturn, or not advancing the index/depth. Fix: ensure every recursive call strictly reduces the remaining problem. - โ ๏ธ Duplicate results with duplicate inputs: the classic subsets/permutations-with-dups trap. Fix: sort + the level-skip rule (
i > start and nums[i]==nums[i-1]) for subsets/combos; thenot used[i-1]rule for permutations. - โ ๏ธ Recording at the wrong place: recording every node when only leaves are valid (or vice versa). Fix: decide explicitly โ "is every partial a valid answer?" (subsets: yes; permutations/N-Queens: only complete ones).
- โ ๏ธ Python recursion limit / deep stacks: deep trees can hit
RecursionError. Fix: raisesys.setrecursionlimit, or convert to an explicit stack for very deep problems.
8.2 Optimization tricks used in productionโ
- โ
Push the
rejectcheck as high as possible. Pruning near the root eliminates exponentially more work than pruning near the leaves. The single biggest lever. - โ
O(1)** incremental constraint checks.** Maintain auxiliary structures (the N-Queens column/diagonal sets, Sudoku per-row/col/box bitmasks) so validity is a set/bitmask test, not anO(n)rescan. - โ
Bitmasks for small domains. Represent used columns/digits/colors as integer bitmasks โ cache-friendly, branch-light, and enables
O(1)"next candidate" via bit tricks. - โ Variable & value ordering (MRV + LCV + degree). Fail fast on the most-constrained variable; succeed fast on the least-constraining value.
- โ Constraint propagation (forward checking, AC-3/MAC). Detect dead ends before descending into them.
- โ Memoize when subproblems overlap โ this turns backtracking into DP (top-down). If distinct paths reach identical states, cache them.
- โ Symmetry breaking. In N-Queens, exploit board symmetry to search a fraction of the tree, then reflect/rotate solutions.
- โ Iterative deepening when you want DFS memory with BFS-like shallow-solution-first behavior (common in AI search).
8.3 When not to use backtrackingโ
- โ When there is optimal substructure + overlapping subproblems โ use DP (backtracking would recompute the same states exponentially).
- โ When a greedy-choice property holds (MST, shortest-path with non-negative weights, Huffman) โ greedy is faster and provably correct; no need to reconsider.
- โ When the state space is enormous with weak pruning โ backtracking degenerates to brute force; prefer heuristic/metaheuristic methods (A*, simulated annealing, genetic algorithms) or dedicated SAT/CP/ILP solvers (MiniSat, OR-Tools, Gurobi).
- โ When you only need one good enough answer, not all/optimal โ local search or sampling is usually cheaper.
- ๐ก Rule of thumb: reach for backtracking when the problem is "construct/enumerate solutions subject to checkable constraints" and constraints prune meaningfully. If constraints are weak or the objective is continuous, look elsewhere.
9. Must-Know Checklistโ
Use this as a final self-audit before you claim mastery.
Fundamentals
- โ I can define backtracking as pruned DFS over a state-space tree, and state the
reject/acceptpredicates. - โ I can explain choose โ explore โ unchoose and why
undomust exactly mirrormake. - โ I can distinguish backtracking from brute force, DP, greedy, and branch & bound โ and pick the right one.
Subsets
- โ I know there are
2^nsubsets and why thestartindex prevents duplicate/out-of-order subsets. - โ I can handle duplicates (sort + level-skip) and size-
k(base case +needpruning bound). - โ I know to snapshot (
current[:]) when recording.
Permutations
- โ I know there are
n!permutations and can implement bothused[]and swap approaches. - โ I can state the space tradeoff: swap is
O(1)aux, visited isO(n)but easier for duplicates. - โ I can handle duplicates (
not used[i-1]rule) and partial permutations (P(n,k)).
Constraint Search (CSP)
- โ I can model a problem as (variables, domains, constraints).
- โ I can implement N-Queens with
O(1)diagonal/column checks (and known=8โ 92 solutions). - โ I can implement a Sudoku solver and a graph
m-coloring solver. - โ I understand forward checking, arc consistency / AC-3 / MAC, and MRV + LCV ordering.
- โ I know graph 3-coloring / general CSP is NP-complete/
O(d^n)in the worst case.
Complexity & Optimization
- โ I can state time/space for subsets (
O(nยท2^n)), permutations (O(nยทn!)), and CSPs (O(d^n)). - โ I know the biggest lever is pruning early/high, plus bitmasks, ordering heuristics, and propagation.
- โ I can recognize when to switch to DP, greedy, or a dedicated solver.
AI / ML / LLM connections
- โ I can explain alpha-beta pruning and planning as backtracking search.
- โ I can explain beam search as bounded backtracking, and grammar-constrained decoding as CSP forward checking.
- โ I can point to CP-SAT scheduling, NAS/AutoML pipeline search, and feature selection as applied backtracking/pruned search.
Final word: Backtracking is deceptively simple as a template but endlessly deep in practice. The template gets you correctness; pruning, ordering, and propagation get you performance; and knowing when to abandon backtracking entirely (DP, greedy, solvers, heuristics) is what separates the engineer from the algorithm-reciter.
Related Guidesโ
Prerequisites: Recursion & The Call Stack ยท BFS & DFS Traversal
See also: Dynamic Programming ยท Graph Theory
Section: Advanced DSA ยท All guides