Backtracking Patterns: Subsets, Permutations, N-Queens
A practical guide to backtracking: the recursion template, pruning, classic problems with code, and complexity analysis for subsets and permutations.
What you'll learn
- ✓The general backtracking template: choose, explore, un-choose
- ✓How pruning turns exponential search into a feasible algorithm
- ✓How to generate subsets, permutations, and combinations
- ✓How to solve N-Queens with row-by-row placement
- ✓How to reason about backtracking time complexity
Prerequisites
- •Recursion: [Recursion Fundamentals](/blog/recursion-fundamentals)
- •Arrays: [Arrays Introduction](/blog/arrays-introduction)
- •Big-O: [Big-O Notation Explained](/blog/big-o-notation-explained)
Backtracking is the algorithmic shape of “try every possibility, but abandon any path that cannot lead to a valid answer.” It is depth-first search over a decision tree, augmented with pruning. Once you internalize the template, an entire family of problems, subsets, permutations, combinations, N-Queens, Sudoku, word search, palindrome partitioning, collapses into the same three steps.
The template
Every backtracking algorithm has the same skeleton:
def backtrack(state):
if is_solution(state):
record(state)
return
for choice in candidates(state):
if not is_valid(choice, state):
continue
apply(choice, state)
backtrack(state)
undo(choice, state)
The three load-bearing pieces are:
- Choose a candidate and apply it to the current partial state.
- Explore recursively from the new state.
- Un-choose by undoing the change so the next iteration starts clean.
The “un-choose” step is what makes this backtracking rather than plain recursion. It lets you reuse a single mutable state object instead of copying, which is the difference between an elegant solution and a slow one.
Pruning is the whole game
Naive enumeration is O(b^d) where b is the branching factor and d
is the depth. That blows up fast. Pruning means cutting branches early by
checking constraints before recursing. The earlier you prune, the smaller
the search tree.
Two pruning patterns are universal:
- Constraint checks. If the candidate violates a rule (a duplicate, a conflict, an upper bound), skip it.
- Bound checks. If the best result reachable from this state cannot beat the current best, skip it. This is “branch and bound” territory.
Problem 1: Generate all subsets
Given a set of distinct integers, return all possible subsets.
There are 2^n subsets. The decision tree branches twice at each index:
include the element or skip it.
def subsets(nums):
res = []
path = []
def backtrack(i):
if i == len(nums):
res.append(path.copy())
return
# exclude nums[i]
backtrack(i + 1)
# include nums[i]
path.append(nums[i])
backtrack(i + 1)
path.pop()
backtrack(0)
return res
Time is O(n * 2^n) because we generate 2^n subsets and copy each
into the output. Space, excluding output, is O(n) for the recursion
stack.
The same template generates combinations (subsets of fixed size) by
adding if len(path) == k: as the base case, and permutations by
iterating over remaining elements and marking each as used.
Problem 2: Permutations
Given a list of distinct integers, return all permutations.
def permute(nums):
res = []
used = [False] * len(nums)
path = []
def backtrack():
if len(path) == len(nums):
res.append(path.copy())
return
for i in range(len(nums)):
if used[i]:
continue
used[i] = True
path.append(nums[i])
backtrack()
path.pop()
used[i] = False
backtrack()
return res
There are n! permutations, each of length n, so the runtime is
O(n * n!). This is unavoidable for the enumeration itself.
For permutations with duplicates, sort the input first and skip a candidate if it is identical to its previous sibling and that sibling is not currently used. This avoids generating the same arrangement twice.
Problem 3: N-Queens
Place
nqueens on ann x nboard so that none attack each other.
Place one queen per row. For each row, try every column; mark the column, the two diagonals as attacked, recurse, then unmark.
def solve_n_queens(n):
cols = set()
diag1 = set() # r - c
diag2 = set() # r + c
board = [['.'] * n for _ in range(n)]
res = []
def backtrack(r):
if r == n:
res.append([''.join(row) for row in board])
return
for c in range(n):
if c in cols or (r - c) in diag1 or (r + c) in diag2:
continue
cols.add(c); diag1.add(r - c); diag2.add(r + c)
board[r][c] = 'Q'
backtrack(r + 1)
board[r][c] = '.'
cols.remove(c); diag1.remove(r - c); diag2.remove(r + c)
backtrack(0)
return res
The naive bound is n^n, but the three sets prune so aggressively that
the practical runtime for n = 12 finishes in milliseconds. This is the
clearest demonstration that pruning, not theoretical complexity, governs
real-world performance.
Complexity, honestly
Backtracking complexities look scary because they often are. But the useful framing is:
- Output-sensitive bounds. If a problem has
Ksolutions, you cannot do better thanO(K * cost_per_solution). - Worst case vs. typical case. Sudoku is exponential in the worst case, but real puzzles run in microseconds because constraints prune most branches near the root.
- Memoization is not backtracking. If overlapping subproblems exist, switch to dynamic programming; see Dynamic Programming Introduction. Backtracking shines exactly when subproblems are not shared.
Patterns to recognize
- “Return all” problems are almost always backtracking: all subsets, all permutations, all paths, all valid expressions.
- “Count” problems sometimes turn into DP because counting does not need to enumerate.
- “Find one” problems are backtracking with early return on the first success.
- Constraints stated as “no two of X” or “must satisfy Y” are signals to add pruning sets like the ones in N-Queens.
Common pitfalls
- Forgetting to undo. A missing
popor unmarking step silently corrupts state and produces wrong answers. - Appending the live
pathinstead ofpath.copy(). The reference will be mutated by later steps. - Wrong base case. Make sure the recursion terminates at the right depth before reading the partial state as a solution.
- Skipping pruning. Without constraint checks, even small inputs become impossibly slow.
Wrap up
Backtracking is one template applied with discipline: choose, explore,
un-choose, prune. Subsets, permutations, combinations, N-Queens, Sudoku,
and word search all collapse into that pattern with a different
is_valid and a different base case. Practice writing the template from
memory, then focus your effort on the pruning, because pruning is where
the algorithm becomes practical.