Master backtracking — the algorithmic technique for finding all solutions by recursively exploring choices and undoing them when they lead to dead ends. Learn the backtracking template, pruning strategies, and classic examples: N-Queens, Sudoku solver, and combination sum.
What Is Backtracking?
Backtracking is a recursive algorithm that explores all possible solutions by making a choice, recursing forward, and then undoing the choice (backtracking) if it leads to a dead end or an invalid state.
The key insight: backtracking is like exploring a maze. At each junction, you pick a path. If you reach a dead end, you return to the last junction and try a different path. This "try and undo" is the defining characteristic of backtracking.
Example 1 — Subsets (All Subsets of an Array)
def subsets(nums):
"""Generate all 2ⁿ subsets."""
result = []
def backtrack(start, current):
result.append(list(current)) # Every state is a valid subset
for i in range(start, len(nums)):
current.append(nums[i]) # CHOOSE
backtrack(i + 1, current) # EXPLORE
current.pop() # UNDO
backtrack(0, [])
return result
print(subsets([1,2,3]))
# [[], [1], [1,2], [1,2,3], [1,3], [2], [2,3], [3]]
Decision tree:
| Choose 1 | backtrack(1, [1]) |
| Choose 2 | backtrack(2, [1,2]) |
| Choose 3: backtrack(3, [1,2,3]) | save [1,2,3] |
| Undo 2 | save [1,2] |
| Choose 3: backtrack(3, [1,3]) | save [1,3] |
| Undo 1 | save [1] |
| Choose 2 | backtrack(2, [2]) |
Example 2 — Combination Sum
Problem: Find all combinations from candidates that sum to target. Can reuse elements.
def combination_sum(candidates, target):
"""Find all combinations summing to target. Reuse allowed."""
result = []
candidates.sort()
def backtrack(remaining, start, current):
if remaining == 0:
result.append(list(current)) # Found valid combination
return
for i in range(start, len(candidates)):
if candidates[i] > remaining:
break # PRUNE: larger candidates can't help
current.append(candidates[i]) # CHOOSE
backtrack(remaining - candidates[i], i, current) # EXPLORE (i not i+1 = reuse)
current.pop() # UNDO
backtrack(target, 0, [])
return result
print(combination_sum([2,3,6,7], 7))
# [[2,2,3], [7]]
Example 3 — N-Queens
Problem: Place N queens on an N×N chessboard so no two queens attack each other.
def solve_n_queens(n):
"""Place N non-attacking queens on N×N board."""
solutions = []
queens = [] # queens[row] = column where queen is placed in that row
def is_safe(row, col):
for r in range(row):
c = queens[r]
if c == col: return False # Same column
if abs(r - row) == abs(c - col): return False # Same diagonal
return True
def backtrack(row):
if row == n:
# Convert queens array to board representation
board = []
for r in range(n):
board.append('.' * queens[r] + 'Q' + '.' * (n - queens[r] - 1))
solutions.append(board)
return
for col in range(n):
if is_safe(row, col):
queens.append(col) # CHOOSE
backtrack(row + 1) # EXPLORE
queens.pop() # UNDO
backtrack(0)
return solutions
print(f"N=4: {len(solve_n_queens(4))} solutions") # 2 solutions
Example 4 — Sudoku Solver
def solve_sudoku(board):
"""Solve a 9×9 Sudoku puzzle in-place."""
def is_valid(row, col, num):
num_str = str(num)
# Check row
if num_str in board[row]: return False
# Check column
if any(board[r][col] == num_str for r in range(9)): return False
# Check 3×3 box
box_row, box_col = 3 * (row // 3), 3 * (col // 3)
for r in range(box_row, box_row + 3):
for c in range(box_col, box_col + 3):
if board[r][c] == num_str: return False
return True
def backtrack():
for i in range(9):
for j in range(9):
if board[i][j] == '.':
for num in range(1, 10):
if is_valid(i, j, num):
board[i][j] = str(num) # CHOOSE
if backtrack(): return True # EXPLORE
board[i][j] = '.' # UNDO
return False # No valid number found → backtrack
return True # All cells filled
backtrack()
Pruning — Making Backtracking Efficient
Without pruning, backtracking explores all possible states. Pruning cuts branches that cannot lead to valid solutions:
# Without pruning: tries all possibilities
def subsets_no_prune(nums, start=0, current=[]):
yield list(current)
for i in range(start, len(nums)):
current.append(nums[i])
yield from subsets_no_prune(nums, i+1, current)
current.pop()
# With pruning: for combination sum, stop when sum exceeds target
def combination_sum_pruned(candidates, target):
candidates.sort() # Enables pruning: break when candidate > remaining
result = []
def backtrack(remaining, start, current):
if remaining == 0:
result.append(list(current))
return
for i in range(start, len(candidates)):
if candidates[i] > remaining:
break # ← PRUNE: all subsequent candidates are even larger
current.append(candidates[i])
backtrack(remaining - candidates[i], i, current)
current.pop()
backtrack(target, 0, [])
return result
Complexity of Backtracking
Backtracking is exponential in the worst case, but pruning can dramatically reduce actual runtime.
| Problem | Worst Case | With Pruning |
|---|
| Subsets | O(2ⁿ) | O(2ⁿ) (must enumerate all) |
| Permutations | O(n!) | O(n!) (must enumerate all) |
| N-Queens | O(n!) | ~O(n!) but much better in practice |
| Sudoku | O(9^81) | O(1) to O(9^81) depending on puzzle |
| Combination Sum | O(2^t) | Much less with sorted + break |
Summary
Backtracking = recursion + "undo" on backtrack:
- Choose: Make a decision at the current step
- Explore: Recurse with the new state
- Undo: Remove the choice (restore previous state)
- Prune: Skip invalid or unpromising choices early
Template applies to:
- Finding all solutions (permutations, subsets, combinations)
- Constraint satisfaction (N-Queens, Sudoku, crossword)
- Path finding in graphs with constraints
*Recursion folder complete. Next: Searching — Linear Search*