Backtracking Algorithms
Exhaustive search that abandons a branch the instant it can't possibly work
Introduction
Lesson 17's greedy algorithms commit to a choice and never look back. Lessons 18 and 19's dynamic programming avoids repeating work by memoizing overlapping subproblems. Backtracking takes a third path: it's a depth-first search (Lesson 13) over the space of partial solutions, built with plain recursion (Lesson 5), that tries every choice at every step but abandons, or "backtracks" out of, any partial solution the moment it's provably unable to lead anywhere valid. The pattern is always the same three moves: choose a candidate, explore what follows from that choice, then un-choose it before trying the next candidate. That un-choose step is what makes it backtracking rather than plain brute force, it reuses the same array or set across every branch instead of copying state at each step. We'll meet that one template first, then watch three classic problems fill it in, ending with the pruning that lets backtracking leave brute force far behind.
The Backtracking Template
Every backtracking solution is the same recursive skeleton with a few blanks filled in. Read it once here, and the three problems that follow will each be nothing more than this template with specific answers plugged in for three questions: what does a complete solution look like, what choices can extend the current partial solution, and which of those choices are allowed?
# The shared skeleton. Each problem in this lesson is this template
# with the blanks filled in: what counts as complete, what the candidate
# choices are, and which of those choices are allowed.
def backtrack(state):
if is_complete(state):
record(state) # save a copy of the finished solution
return
for choice in candidates(state):
if not allowed(choice, state):
continue # prune: skip a choice that cannot lead to a valid solution
state.append(choice) # choose
backtrack(state) # explore everything that follows from it
state.pop() # un-choose, then try the next candidateThe three comments, choose, explore, un-choose, are the heart of it. Appending a choice commits to it, the recursive call explores every solution that starts with that commitment, and popping it back off restores the state exactly as it was so the next candidate starts clean. That single shared list is why backtracking is so memory-efficient: at any instant it holds just one partial solution, never the whole search tree. The allowed check is the optional fourth blank, the prune, and whether a problem even has a meaningful prune is exactly what separates a mechanical enumeration from a genuinely smart search, as the three examples below show.
Generating Subsets (the Power Set)
Subsets is the template at its simplest: every choice is always allowed, so there's no prune at all. The one twist is in the completion test: a subset can stop growing at any point, so instead of recording only at a leaf, each call records current the instant it begins, before appending anything. That makes every node in the recursion tree, not just the leaves, a complete and valid subset:
def subsets(nums: list[int]) -> list[list[int]]:
result = []
current = []
def backtrack(start: int) -> None:
result.append(current.copy()) # every partial state is itself a valid subset
for i in range(start, len(nums)):
current.append(nums[i]) # choose
backtrack(i + 1) # explore
current.pop() # un-choose (the "backtrack" step)
backtrack(0)
return resultprint(subsets([1, 2, 3])) print(len(subsets([1, 2, 3])))
Expected Output:
[[], [1], [1, 2], [1, 2, 3], [1, 3], [2], [2, 3], [3]] 8
Decision Tree for subsets([1, 2])
Figure 1: Each node is one backtrack() call; the four nodes are exactly the four subsets of {1, 2}
With n numbers, the recursion tree has exactly 2ⁿ nodes, one per subset, since each of the n numbers independently doubles the count. There's no pruning possible here: every subset is a valid answer, so the algorithm's work is inherently exponential no matter how it's organized.
Generating Permutations
Permutations fills in two of the template's blanks. The completion test now fires only when current holds all n numbers, since a partial ordering is not yet an answer, and the allowed rule skips any number already placed, tracked by the used array. That used check is bookkeeping to honor the definition of a permutation, not a real prune: every ordering the recursion is allowed to start still finishes as a valid answer, so no branch is ever wasted:
def permutations(nums: list[int]) -> list[list[int]]:
result = []
current = []
used = [False] * len(nums)
def backtrack() -> None:
if len(current) == len(nums):
result.append(current.copy()) # only complete orderings count as an answer
return
for i in range(len(nums)):
if used[i]:
continue # prune: this number is already placed
used[i] = True
current.append(nums[i])
backtrack()
current.pop()
used[i] = False
backtrack()
return resultprint(permutations([1, 2, 3])) print(len(permutations([1, 2, 3])))
Expected Output:
[[1, 2, 3], [1, 3, 2], [2, 1, 3], [2, 3, 1], [3, 1, 2], [3, 2, 1]] 6
The first position has n choices, the second has n - 1 remaining choices, and so on, giving n! total orderings. Just like subsets, no partial ordering is ever a dead end: the branching factor shrinks naturally as numbers get used up, but every path still reaches a valid permutation, so the leaf count is exactly n!.
A Constraint Problem: The N-Queens Puzzle
Place n chess queens on an n × n board so that no two attack each other, no shared row, column, or diagonal. Placing one queen per row by construction rules out row conflicts entirely, so only columns and the two diagonal directions need tracking. A queen at (row, col) shares a "\\" diagonal with every square where row - col is the same, and a "/" diagonal with every square where row + col is the same, so three sets are enough to check any conflict in O(1):
def solve_n_queens(n: int) -> list[list[int]]:
solutions = []
columns = set()
diagonals = set() # constant along a "\" diagonal: row - col
anti_diagonals = set() # constant along a "/" diagonal: row + col
placement = [-1] * n # placement[row] = column of the queen in that row
def backtrack(row: int) -> None:
if row == n:
solutions.append(placement.copy())
return
for col in range(n):
if col in columns or (row - col) in diagonals or (row + col) in anti_diagonals:
continue # prune: this column is already under attack
columns.add(col)
diagonals.add(row - col)
anti_diagonals.add(row + col)
placement[row] = col
backtrack(row + 1)
columns.remove(col)
diagonals.remove(row - col)
anti_diagonals.remove(row + col)
backtrack(0)
return solutionssolutions = solve_n_queens(4) print(solutions) print(len(solutions))
Expected Output:
[[1, 3, 0, 2], [2, 0, 3, 1]] 2
Those two lists are the only two ways to place four queens on a 4x4 board with none attacking another. Since placement[row] is the column of that row's queen, each list draws directly onto a board:
| ♛ | |||
| ♛ | |||
| ♛ | |||
| ♛ |
| ♛ | |||
| ♛ | |||
| ♛ | |||
| ♛ |
Figure 2: The two solutions to the 4-Queens puzzle. In each, no two queens share a row, a column, or a diagonal. Notice the second board is the mirror image of the first.
Now the allowed check finally earns its keep. Unlike subsets and permutations, most branches here are dead ends: a queen placed in an attacked column can never be part of a valid board, so continue prunes that entire subtree instead of recursing into a branch already known to fail. This is the first genuine constraint-satisfaction problem in the lesson, and the first place pruning actually changes how much work gets done.
Why Pruning Matters
In subsets and permutations, every branch the rules allow still reaches a valid answer, so there is nothing to prune. N-Queens is the opposite: a placement can look fine locally and still doom the rest of the board, and pruning is what abandons those dead ends early. Comparing the exact same recursive structure with and without the conflict check, counting every loop iteration as one "node visited", shows exactly how much that pruning saves on an 8x8 board:
import math
def count_unpruned_nodes(n: int) -> int:
"""Every column at every row, no conflict checking at all."""
nodes = 0
def recurse(row: int) -> None:
nonlocal nodes
if row == n:
return
for col in range(n):
nodes += 1
recurse(row + 1)
recurse(0)
return nodes
def count_pruned_nodes(n: int) -> int:
"""Same recursion, but skips a branch the instant a conflict is found."""
nodes = 0
columns, diagonals, anti_diagonals = set(), set(), set()
def backtrack(row: int) -> None:
nonlocal nodes
if row == n:
return
for col in range(n):
nodes += 1
if col in columns or (row - col) in diagonals or (row + col) in anti_diagonals:
continue
columns.add(col); diagonals.add(row - col); anti_diagonals.add(row + col)
backtrack(row + 1)
columns.remove(col); diagonals.remove(row - col); anti_diagonals.remove(row + col)
backtrack(0)
return nodes
n = 8
print("Unpruned nodes visited:", count_unpruned_nodes(n))
print("Pruned nodes visited: ", count_pruned_nodes(n))
print("Brute-force permutations checked at the end:", math.factorial(n))Expected Output:
Unpruned nodes visited: 19173960 Pruned nodes visited: 15720 Brute-force permutations checked at the end: 40320
Checking conflicts before recursing, rather than only at the end, cuts the search from over 19 million nodes down to under 16 thousand, over a thousandfold reduction. The worst-case bound for N-Queens is still O(n!), the pruning doesn't change what could theoretically happen in the rare case where almost nothing gets pruned, but it changes what actually happens dramatically. This is the core lesson of backtracking: the same exhaustive-search shape can range from useless to highly practical depending entirely on how early invalid branches get detected and abandoned.
Complexity at a Glance
| Problem | Worst-Case Time | Extra Space (recursion depth) | Prunable? |
|---|---|---|---|
| Subsets | O(n · 2n) | O(n) | No, every subset is valid |
| Permutations | O(n · n!) | O(n) | No, every ordering is valid |
| N-Queens | O(n!) | O(n) | Yes, dramatically in practice |
The extra n factor in the subsets and permutations time bounds accounts for the cost of copying current into the result each time a valid answer is found. Extra space excludes the output list itself and counts only the recursion stack, which never grows past one frame per element being placed.
Key Takeaways
- One template solves them all - choose a candidate, recurse, then un-choose it before trying the next one, reusing the same state across every branch; each problem just fills in what counts as complete, what the candidates are, and which are allowed
- Subsets and permutations have no dead ends to prune - every subset and every ordering is a valid answer, so their O(2ⁿ) and O(n!) costs are unavoidable
- N-Queens shows pruning's real power - checking column and diagonal conflicts before recursing cut an 8x8 search from over 19 million nodes to under 16 thousand
- Pruning changes practical performance, not the worst-case bound - N-Queens is still O(n!) in theory, but the vast majority of that theoretical search space is never actually visited
- The choose-explore-un-choose pattern generalizes - the same three-step template solves subset generation, permutation generation, and constraint satisfaction problems alike