Backtracking
Systematically explore all possible solutions by building candidates incrementally, abandoning paths that fail constraints (backtracking), and collecting valid results. Master the choose-explore-unchoose template for subsets, permutations, combinations, and constraint satisfaction.
Learn & Reference
Understanding the Pattern
Backtracking Pattern
Backtracking is a general algorithmic technique that builds solutions incrementally, one piece at a time, and abandons ("backtracks") a path as soon as it determines the path cannot lead to a valid solution. It's essentially a refined brute-force search with early pruning.
Decision tree for subsets of [1, 2, 3]:
[]
/ \
[1] []
/ \ / \
[1,2] [1] [2] []
/ \ / \ / \ / \
[1,2,3] [1,2] [1,3] [1] [2,3] [2] [3] []
Core Concept
Every backtracking problem follows the same template: choose, explore, unchoose.
function backtrack(state, choices):
if state is a valid solution:
record solution
return
for each choice in choices:
if choice is valid:
make choice ← CHOOSE
backtrack(new state) ← EXPLORE
undo choice ← UNCHOOSE (backtrack)
The key insight is that we're traversing a decision tree where each node represents a partial solution and each branch represents adding one more element. We prune entire subtrees when we detect that a path can't lead to a valid solution.
Key Techniques
1. Subsets (Include/Exclude)
For each element, decide: include it or skip it. This generates all 2^n subsets.
function subsets(nums):
result = []
function backtrack(index, current):
if index == nums.length:
result.add(copy of current)
return
// Include nums[index]
current.push(nums[index])
backtrack(index + 1, current)
current.pop() ← backtrack
// Exclude nums[index]
backtrack(index + 1, current)
backtrack(0, [])
2. Combinations (With Constraints)
Like subsets, but with a target constraint (e.g., sum = target). Prune when the remaining sum is impossible. Elements can sometimes be reused (start from same index instead of next).
function combinationSum(candidates, target):
function backtrack(index, current, remaining):
if remaining == 0: record solution
if remaining < 0: return (prune)
for i from index to end:
current.push(candidates[i])
backtrack(i, current, remaining - candidates[i]) ← same index for reuse
current.pop() ← backtrack
3. Permutations (Ordering Matters)
Generate all orderings. Track which elements are "used" to avoid duplicates.
function permute(nums):
function backtrack(current, used):
if current.length == nums.length: record solution
for i from 0 to end:
if not used[i]:
used[i] = true
current.push(nums[i])
backtrack(current, used)
current.pop() ← backtrack
used[i] = false ← backtrack
4. Grid-Based Backtracking
DFS on a grid with visited tracking. Each cell explores 4 directions and backtracks by unmarking.
function exist(board, word):
function dfs(row, col, index):
if index == word.length: return true
if out of bounds or board[row][col] != word[index]: return false
temp = board[row][col]
board[row][col] = '#' ← mark visited
found = dfs(row+1,col,index+1) or dfs(row-1,col,index+1) or ...
board[row][col] = temp ← backtrack
return found
5. Constraint Satisfaction (N-Queens)
Place items under constraints. Track which positions are invalid and prune aggressively.
function nQueens(n):
function backtrack(row, cols, diag1, diag2):
if row == n: record solution
for col in 0..n-1:
if col not in cols and (row-col) not in diag1 and (row+col) not in diag2:
place queen, add constraints
backtrack(row + 1, ...)
remove queen, remove constraints ← backtrack
Common Mistakes
- Forgetting to undo state changes — The "unchoose" step is critical. If you push to an array but forget to pop after recursion, all branches share corrupted state.
- Passing references instead of copies — When recording a solution, you must copy the current array (e.g.,
result.push([...current])). Otherwise, all recorded solutions point to the same mutated array. - Wrong loop start index — For combinations without duplicates, start the loop at the current index (not 0). Starting at 0 produces duplicate combinations like [1,2] and [2,1].
- Missing base case or pruning — Without proper termination, backtracking becomes brute force. Prune early: if remaining sum < 0, if index out of bounds, if constraints are violated.
- Not restoring grid cells after DFS — In grid-based problems, if you mark a cell visited but don't unmark it after returning, other paths can't use that cell. Always restore:
board[r][c] = temp.
When to Use
Template
1def backtrack(result, current, choices, start):2# Base case: valid solution found3if is_complete(current):4result.append(current[:]) # Copy before recording!5return67for i in range(start, len(choices)):8# Choose9current.append(choices[i])1011# Explore12backtrack(result, current, choices, i + 1)1314# Unchoose (backtrack)15current.pop()1617def solve(choices):18result = []19backtrack(result, [], choices, 0)20return result
Syntax Reference
1# Backtracking Template2# No built-in — implement the choose-explore-unchoose pattern34def backtrack(result, current, choices, start):5"""6result: list to collect valid solutions7current: partial solution being built8choices: available elements to choose from9start: index to avoid duplicate combinations10"""11if is_valid_solution(current):12result.append(current[:]) # COPY before recording13return1415for i in range(start, len(choices)):16current.append(choices[i]) # CHOOSE17backtrack(result, current, choices, i + 1) # EXPLORE18current.pop() # UNCHOOSE1920# Key Python helpers:21# current[:] or list(current) — shallow copy a list22# set() — O(1) lookup for constraint checking23# sorted() — sort candidates to enable pruning
Common Recipes
1# Recipe 1: Generate all subsets2def subsets(nums):3result = []4def backtrack(start, current):5result.append(current[:])6for i in range(start, len(nums)):7current.append(nums[i])8backtrack(i + 1, current)9current.pop()10backtrack(0, [])11return result1213# Recipe 2: Generate all permutations14def permute(nums):15result = []16def backtrack(current, used):17if len(current) == len(nums):18result.append(current[:])19return20for i in range(len(nums)):21if not used[i]:22used[i] = True23current.append(nums[i])24backtrack(current, used)25current.pop()26used[i] = False27backtrack([], [False] * len(nums))28return result2930# Recipe 3: Combination sum (elements reusable)31def combination_sum(candidates, target):32result = []33candidates.sort()34def backtrack(start, current, remaining):35if remaining == 0:36result.append(current[:])37return38for i in range(start, len(candidates)):39if candidates[i] > remaining:40break41current.append(candidates[i])42backtrack(i, current, remaining - candidates[i])43current.pop()44backtrack(0, [], target)45return result4647# Recipe 4: Grid word search (DFS + backtrack)48def exist(board, word):49rows, cols = len(board), len(board[0])50def dfs(r, c, idx):51if idx == len(word):52return True53if r < 0 or r >= rows or c < 0 or c >= cols or board[r][c] != word[idx]:54return False55temp = board[r][c]56board[r][c] = '#'57found = dfs(r+1,c,idx+1) or dfs(r-1,c,idx+1) or dfs(r,c+1,idx+1) or dfs(r,c-1,idx+1)58board[r][c] = temp59return found60for r in range(rows):61for c in range(cols):62if dfs(r, c, 0):63return True64return False
Complexity
| Problem Type | Time | Space | Notes |
|---|---|---|---|
| Subsets | O(n · 2^n) | O(n) | 2^n subsets, O(n) to copy each |
| Combinations (sum) | O(2^(t/m)) | O(t/m) | t = target, m = min candidate |
| Permutations | O(n · n!) | O(n) | n! permutations, O(n) to copy each |
| Word Search | O(m · n · 4^L) | O(L) | L = word length, 4 directions per cell |
| N-Queens | O(n!) | O(n) | Pruning reduces branching factor each row |
Watch Out
- ✗Forgetting to undo state changes — The "unchoose" step is critical. If you push to an array but forget to pop after recursion, all branches share corrupted state.
- ✗Passing references instead of copies — When recording a solution, you must copy the current array (e.g., `result.push([...current])`). Otherwise, all recorded solutions point to the same mutated array.
- ✗Wrong loop start index — For combinations without duplicates, start the loop at the current index (not 0). Starting at 0 produces duplicate combinations like [1,2] and [2,1].
- ✗Missing base case or pruning — Without proper termination, backtracking becomes brute force. Prune early: if remaining sum < 0, if index out of bounds, if constraints are violated.
- ✗Not restoring grid cells after DFS — In grid-based problems, if you mark a cell visited but don't unmark it after returning, other paths can't use that cell. Always restore: `board[r][c] = temp`.