Backtracking Patterns — Comprehensive Guide
Backtracking is brute force with early termination. You explore a decision tree, and whenever a partial solution can’t possibly lead to a valid complete solution, you prune that branch and backtrack. The power of backtracking comes not from what it explores, but from what it doesn’t explore.
Quick Navigation: “I need to…”
| I need to… | Technique | Section |
|---|---|---|
| Generate all permutations | Swap-based / used-array backtracking | 2 |
| Generate all combinations (choose k from n) | Index-based backtracking | 3 |
| Generate all subsets (power set) | Include/exclude at each step | 4 |
| Handle duplicates in generation | Sort + skip adjacent duplicates | 5 |
| Solve N-Queens / board placement | Row-by-row with column/diagonal tracking | 6 |
| Solve Sudoku | Cell-by-cell with constraint checking | 6 |
| Partition array into k equal subsets | Bucket-filling backtracking | 7 |
| Generate parentheses / valid sequences | Count-based constraints | 8 |
| Search for word in grid | DFS with visited tracking | 6 |
| Optimize with pruning | Feasibility + bound + symmetry pruning | 9 |
| Use bitmask for state | Bitmask replaces boolean arrays | 10 |
Table of Contents
- The Backtracking Framework
- Permutations
- Combinations
- Subsets
- Handling Duplicates
- Grid and Board Problems
- Partitioning Problems
- String Backtracking
- Pruning Techniques
- Backtracking + Bitmask
- Constraint Satisfaction Problems
- Common Patterns Collection
- Pattern Recognition Cheat Sheet
1. The Backtracking Framework
The Idea
Every backtracking problem follows the same skeleton:
1. Choose — pick a candidate for the current position
2. Explore — recurse with that choice
3. Unchoose — undo the choice (backtrack)
The Universal Template
def backtrack(state, choices):
if is_complete(state):
process_solution(state)
return
for candidate in choices:
if is_valid(state, candidate):
# 1. Choose
apply(state, candidate)
# 2. Explore
backtrack(state, next_choices)
# 3. Unchoose
undo(state, candidate)
The Decision Tree
Every backtracking algorithm implicitly explores a tree. Each node is a partial solution, each edge is a decision, and leaves are complete (or pruned) solutions.
Generate all 3-bit binary strings:
""
/ \
"0" "1" ← choose bit 0
/ \ / \
"00" "01" "10" "11" ← choose bit 1
/ \ / \ / \ / \
000 001 010 011 100 101 110 111 ← choose bit 2
Without pruning: visits all 2^3 = 8 leaves
With constraint "no two consecutive 1s":
""
/ \
"0" "1"
/ \ / ╲
"00" "01" "10" "11" ← PRUNE (consecutive 1s)
/ \ / \ / \
000 001 010 011 100 101 ← only 5 leaves!
↑
PRUNE (011→0110/0111 both have "11")
Wait — 011 is 3 bits, it's a leaf. Let me redo:
Actually for 3-bit, leaves ARE the 3-bit strings.
Pruning "11" prefix means we skip "110" and "111".
Result: 000, 001, 010, 100, 101 — 5 valid strings.
Recursion vs State Space
Think of each recursive call as one level of the decision tree:
| Level | Decision | State changes |
|---|---|---|
| 0 | Which element goes first? | Add to path[0] |
| 1 | Which element goes second? | Add to path[1] |
| … | … | … |
| n-1 | Which element goes last? | Add to path[n-1] |
Implementation — Basic Template
def solve(n):
result = []
def backtrack(path):
# Base case: solution is complete
if len(path) == n:
result.append(path[:]) # copy!
return
for candidate in get_candidates(path):
path.append(candidate) # choose
backtrack(path) # explore
path.pop() # unchoose
backtrack([])
return result
⚠️ Critical: Copy the Path!
# WRONG — all entries point to the same list
result.append(path)
# CORRECT — snapshot the current state
result.append(path[:])
result.append(list(path))
result.append(tuple(path))
Since path is mutated during backtracking, you must copy it when recording a solution.
2. Permutations
Problem: Generate All Permutations of [1..n]
Each position chooses from the remaining unused elements.
Approach A: Used-Array
Track which elements are used with a boolean array.
Tree for [1, 2, 3]:
Level 0 (pick 1st): 1 2 3
Level 1 (pick 2nd): 2 3 1 3 1 2
Level 2 (pick 3rd): 3 2 3 1 2 1
Permutations: [1,2,3] [1,3,2] [2,1,3] [2,3,1] [3,1,2] [3,2,1]
def permutations_used_array(nums):
n = len(nums)
result = []
used = [False] * n
def backtrack(path):
if len(path) == n:
result.append(path[:])
return
for i in range(n):
if not used[i]:
used[i] = True
path.append(nums[i])
backtrack(path)
path.pop()
used[i] = False
backtrack([])
return result
Approach B: Swap-Based
Instead of a separate used array, swap elements into position. More memory efficient.
[1, 2, 3]
^ fix position 0: swap with 0, 1, 2
pos=0: [1, 2, 3] → recurse on [_, 2, 3]
[2, 1, 3] → recurse on [_, 1, 3]
[3, 2, 1] → recurse on [_, 2, 1]
def permutations_swap(nums):
result = []
def backtrack(start):
if start == len(nums):
result.append(nums[:])
return
for i in range(start, len(nums)):
nums[start], nums[i] = nums[i], nums[start] # choose
backtrack(start + 1) # explore
nums[start], nums[i] = nums[i], nums[start] # unchoose
backtrack(0)
return result
Complexity
- Number of permutations: n!
- Time: O(n × n!) — n! permutations, each takes O(n) to copy
- Space: O(n) recursion depth (excluding output)
3. Combinations
Problem: Choose k Elements from [1..n]
The key difference from permutations: order doesn’t matter, so we enforce an ordering (e.g., always pick in increasing order) to avoid duplicates.
The Trick: Start Index
C(4, 2) — choose 2 from [1, 2, 3, 4]:
start=1
/ | \ \
1 2 3 4
/|\ /\ |
2 3 4 3 4 4
Combinations: [1,2] [1,3] [1,4] [2,3] [2,4] [3,4]
Notice: we never go backward (e.g., no [2,1]).
def combinations(n, k):
result = []
def backtrack(start, path):
if len(path) == k:
result.append(path[:])
return
# Pruning: need (k - len(path)) more elements,
# and only (n - start + 1) are available
for i in range(start, n + 1):
if n - i + 1 < k - len(path): # not enough remaining
break
path.append(i)
backtrack(i + 1, path) # i+1 to avoid reuse
path.pop()
backtrack(1, [])
return result
Why i + 1 and Not i?
backtrack(i + 1, ...)→ each element used at most once (combinations)backtrack(i, ...)→ elements can repeat (combinations with repetition)backtrack(start, ...)→ ERROR: would repeat same combinations
Combination with Repetition
def combinations_with_repetition(candidates, k):
"""Choose k elements, each can be used multiple times."""
result = []
def backtrack(start, path):
if len(path) == k:
result.append(path[:])
return
for i in range(start, len(candidates)):
path.append(candidates[i])
backtrack(i, path) # i, NOT i+1 — allow reuse
path.pop()
backtrack(0, [])
return result
Complexity
- Number of combinations: C(n, k) = n! / (k!(n-k)!)
- Time: O(k × C(n, k))
- Space: O(k) recursion depth
4. Subsets
Problem: Generate All Subsets (Power Set)
Two classic approaches:
Approach A: Include/Exclude (Binary Decision)
At each element, make a binary choice: include it or skip it.
Array: [1, 2, 3]
{}
/ \
{1} {} ← include/exclude 1
/ \ / \
{1,2} {1} {2} {} ← include/exclude 2
/ \ / \ / \ / \
{1,2,3}{1,2}{1,3}{1}{2,3}{2}{3}{} ← include/exclude 3
def subsets_include_exclude(nums):
result = []
def backtrack(index, path):
if index == len(nums):
result.append(path[:])
return
# Include nums[index]
path.append(nums[index])
backtrack(index + 1, path)
path.pop()
# Exclude nums[index]
backtrack(index + 1, path)
backtrack(0, [])
return result
Approach B: Iterative Growth
At each level, choose any element with index ≥ start.
def subsets_iterative_growth(nums):
result = []
def backtrack(start, path):
result.append(path[:]) # every partial path is a valid subset
for i in range(start, len(nums)):
path.append(nums[i])
backtrack(i + 1, path)
path.pop()
backtrack(0, [])
return result
Approach C: Bitmask (Non-Recursive)
def subsets_bitmask(nums):
n = len(nums)
result = []
for mask in range(1 << n):
subset = []
for i in range(n):
if mask & (1 << i):
subset.append(nums[i])
result.append(subset)
return result
Complexity
- Number of subsets: 2^n
- Time: O(n × 2^n)
- Space: O(n) recursion depth
5. Handling Duplicates
The Problem
If the input has duplicates, naive backtracking generates duplicate solutions:
Input: [1, 1, 2]
Naive subsets: {}, {1}, {1}, {2}, {1,1}, {1,2}, {1,2}, {1,1,2}
^ ^
duplicate! duplicate!
The Fix: Sort + Skip Adjacent
Sort the array first. Then at each decision level, skip a candidate if it equals the previous candidate at the same level.
Sorted: [1, 1, 2]
Level decision for "which element next?":
candidates at start=0: [1, 1, 2]
- pick 1 (index 0) ✓
- pick 1 (index 1) ✗ SKIP (same as previous candidate at this level)
- pick 2 (index 2) ✓
Subsets with Duplicates
def subsets_with_dup(nums):
nums.sort() # critical!
result = []
def backtrack(start, path):
result.append(path[:])
for i in range(start, len(nums)):
# Skip duplicates at the same decision level
if i > start and nums[i] == nums[i - 1]:
continue
path.append(nums[i])
backtrack(i + 1, path)
path.pop()
backtrack(0, [])
return result
Permutations with Duplicates
def permutations_with_dup(nums):
nums.sort() # critical!
result = []
used = [False] * len(nums)
def backtrack(path):
if len(path) == len(nums):
result.append(path[:])
return
for i in range(len(nums)):
if used[i]:
continue
# Skip duplicate: if nums[i] == nums[i-1] and nums[i-1] was NOT used
# at this level, then we'd generate the same subtree
if i > 0 and nums[i] == nums[i - 1] and not used[i - 1]:
continue
used[i] = True
path.append(nums[i])
backtrack(path)
path.pop()
used[i] = False
backtrack([])
return result
Why not used[i-1]?
This is the tricky part. Consider [1a, 1b, 2]:
Without the check, both subtrees are identical:
1a → 1b → 2 and 1b → 1a → 2
Rule: among equal elements, only pick them in the original order.
- 1a before 1b: OK (original order)
- 1b before 1a: SKIP
"not used[i-1]" means: the previous equal element was already returned
(not currently in the path), so we're trying to start a new branch with
this duplicate — skip it.
Combination Sum with Duplicates
def combination_sum_with_dup(candidates, target):
"""Each number used at most once; duplicates in input."""
candidates.sort()
result = []
def backtrack(start, path, remaining):
if remaining == 0:
result.append(path[:])
return
for i in range(start, len(candidates)):
if candidates[i] > remaining:
break # pruning: sorted, so all future too big
if i > start and candidates[i] == candidates[i - 1]:
continue # skip duplicate at same level
path.append(candidates[i])
backtrack(i + 1, path, remaining - candidates[i])
path.pop()
backtrack(0, [], target)
return result
The Duplicate-Handling Rules
| Problem Type | Sort? | Skip Condition |
|---|---|---|
| Subsets with dups | Yes | i > start and nums[i] == nums[i-1] |
| Permutations with dups | Yes | i > 0 and nums[i] == nums[i-1] and not used[i-1] |
| Combinations with dups | Yes | i > start and nums[i] == nums[i-1] |
6. Grid and Board Problems
N-Queens
Place N queens on an N×N board so no two attack each other.
N = 4 solution:
. Q . . . . Q .
. . . Q Q . . .
Q . . . . . . Q
. . Q . . Q . .
Key insight: Place one queen per row. Track which columns and diagonals are occupied.
Diagonals encoding:
- Main diagonal (↘): row - col is constant
- Anti-diagonal (↙): row + col is constant
Board for N=4, showing (row-col) values:
0 -1 -2 -3 ← main diagonal IDs
1 0 -1 -2
2 1 0 -1
3 2 1 0
Board showing (row+col) values:
0 1 2 3 ← anti-diagonal IDs
1 2 3 4
2 3 4 5
3 4 5 6
def solve_n_queens(n):
result = []
cols = set()
diag1 = set() # row - col
diag2 = set() # row + col
def backtrack(row, queens):
if row == n:
# Build board representation
board = []
for r, c in sorted(queens):
board.append('.' * c + 'Q' + '.' * (n - c - 1))
result.append(board)
return
for col in range(n):
if col in cols or (row - col) in diag1 or (row + col) in diag2:
continue
cols.add(col)
diag1.add(row - col)
diag2.add(row + col)
queens.append((row, col))
backtrack(row + 1, queens)
queens.pop()
cols.remove(col)
diag1.remove(row - col)
diag2.remove(row + col)
backtrack(0, [])
return result
Sudoku Solver
Fill empty cells so every row, column, and 3×3 box contains 1–9.
def solve_sudoku(board):
"""board: 9x9 list of lists, 0 = empty."""
rows = [set() for _ in range(9)]
cols = [set() for _ in range(9)]
boxes = [set() for _ in range(9)]
# Initialize constraint sets
empty = []
for r in range(9):
for c in range(9):
if board[r][c] != 0:
val = board[r][c]
rows[r].add(val)
cols[c].add(val)
boxes[(r // 3) * 3 + c // 3].add(val)
else:
empty.append((r, c))
def backtrack(idx):
if idx == len(empty):
return True # all cells filled
r, c = empty[idx]
box_id = (r // 3) * 3 + c // 3
for val in range(1, 10):
if val in rows[r] or val in cols[c] or val in boxes[box_id]:
continue
# Choose
board[r][c] = val
rows[r].add(val)
cols[c].add(val)
boxes[box_id].add(val)
# Explore
if backtrack(idx + 1):
return True
# Unchoose
board[r][c] = 0
rows[r].remove(val)
cols[c].remove(val)
boxes[box_id].remove(val)
return False # no valid digit for this cell
backtrack(0)
return board
Word Search in Grid
Find if a word exists in a grid by moving to adjacent cells (no revisiting).
def word_search(board, word):
R, C = len(board), len(board[0])
def backtrack(r, c, k):
if k == len(word):
return True
if r < 0 or r >= R or c < 0 or c >= C:
return False
if board[r][c] != word[k]:
return False
# Mark visited (in-place trick)
tmp = board[r][c]
board[r][c] = '#'
for dr, dc in [(0, 1), (0, -1), (1, 0), (-1, 0)]:
if backtrack(r + dr, c + dc, k + 1):
return True
board[r][c] = tmp # unchoose
return False
for r in range(R):
for c in range(C):
if backtrack(r, c, 0):
return True
return False
In-place visited trick: Temporarily replace board[r][c] with a sentinel character ('#'), then restore it when backtracking. Saves allocating a separate visited array.
7. Partitioning Problems
Partition into k Equal-Sum Subsets
Given an array and integer k, can you partition the array into k subsets with equal sum?
Input: [4, 3, 2, 3, 5, 2, 1], k = 4
Total = 20, target per bucket = 5
Solution: {5}, {4,1}, {3,2}, {3,2} ✓
def can_partition_k(nums, k):
total = sum(nums)
if total % k != 0:
return False
target = total // k
nums.sort(reverse=True) # pruning: try big elements first
if nums[0] > target:
return False
buckets = [0] * k
def backtrack(index):
if index == len(nums):
return all(b == target for b in buckets)
seen = set() # avoid putting same value in equivalent empty buckets
for i in range(k):
if buckets[i] + nums[index] > target:
continue
if buckets[i] in seen: # symmetry pruning
continue
seen.add(buckets[i])
buckets[i] += nums[index]
if backtrack(index + 1):
return True
buckets[i] -= nums[index]
return False
return backtrack(0)
Palindrome Partitioning
Split a string so every part is a palindrome.
Input: "aab"
Output: [["a","a","b"], ["aa","b"]]
def palindrome_partition(s):
result = []
def is_palindrome(l, r):
while l < r:
if s[l] != s[r]:
return False
l += 1
r -= 1
return True
def backtrack(start, path):
if start == len(s):
result.append(path[:])
return
for end in range(start, len(s)):
if is_palindrome(start, end):
path.append(s[start:end + 1])
backtrack(end + 1, path)
path.pop()
backtrack(0, [])
return result
Optimization: Precompute Palindromes with DP
def palindrome_partition_optimized(s):
n = len(s)
# is_pal[i][j] = True if s[i..j] is palindrome
is_pal = [[False] * n for _ in range(n)]
for i in range(n):
is_pal[i][i] = True
for i in range(n - 1):
is_pal[i][i + 1] = (s[i] == s[i + 1])
for length in range(3, n + 1):
for i in range(n - length + 1):
j = i + length - 1
is_pal[i][j] = (s[i] == s[j] and is_pal[i + 1][j - 1])
result = []
def backtrack(start, path):
if start == n:
result.append(path[:])
return
for end in range(start, n):
if is_pal[start][end]:
path.append(s[start:end + 1])
backtrack(end + 1, path)
path.pop()
backtrack(0, [])
return result
8. String Backtracking
Generate Parentheses
Generate all combinations of n pairs of well-formed parentheses.
n = 3:
((())) (()()) (())() ()(()) ()()()
Key constraint: at every prefix, #open ≥ #close
def generate_parentheses(n):
result = []
def backtrack(path, open_count, close_count):
if len(path) == 2 * n:
result.append(''.join(path))
return
if open_count < n:
path.append('(')
backtrack(path, open_count + 1, close_count)
path.pop()
if close_count < open_count:
path.append(')')
backtrack(path, open_count, close_count + 1)
path.pop()
backtrack([], 0, 0)
return result
Letter Combinations of Phone Number
2 → "abc", 3 → "def", ..., 9 → "wxyz"
Input: "23"
Output: ["ad","ae","af","bd","be","bf","cd","ce","cf"]
def letter_combinations(digits):
if not digits:
return []
mapping = {
'2': 'abc', '3': 'def', '4': 'ghi', '5': 'jkl',
'6': 'mno', '7': 'pqrs', '8': 'tuv', '9': 'wxyz'
}
result = []
def backtrack(index, path):
if index == len(digits):
result.append(''.join(path))
return
for ch in mapping[digits[index]]:
path.append(ch)
backtrack(index + 1, path)
path.pop()
backtrack(0, [])
return result
Restore IP Addresses
Input: "25525511135"
Output: ["255.255.11.135", "255.255.111.35"]
def restore_ip_addresses(s):
result = []
def backtrack(start, parts):
if len(parts) == 4:
if start == len(s):
result.append('.'.join(parts))
return
remaining_digits = len(s) - start
remaining_parts = 4 - len(parts)
# Pruning: each remaining part needs 1-3 digits
if remaining_digits < remaining_parts or remaining_digits > remaining_parts * 3:
return
for length in range(1, 4):
if start + length > len(s):
break
segment = s[start:start + length]
# No leading zeros (except "0" itself)
if len(segment) > 1 and segment[0] == '0':
break
if int(segment) > 255:
break
parts.append(segment)
backtrack(start + length, parts)
parts.pop()
backtrack(0, [])
return result
Expression: Add Operators
Insert +, -, * between digits to reach a target.
def add_operators(num, target):
result = []
def backtrack(index, path, value, prev_operand):
if index == len(num):
if value == target:
result.append(path)
return
for i in range(index, len(num)):
# No leading zeros
if i > index and num[index] == '0':
break
substr = num[index:i + 1]
curr = int(substr)
if index == 0:
# First number, no operator
backtrack(i + 1, substr, curr, curr)
else:
# Addition
backtrack(i + 1, path + '+' + substr,
value + curr, curr)
# Subtraction
backtrack(i + 1, path + '-' + substr,
value - curr, -curr)
# Multiplication (need to undo previous addition/subtraction)
backtrack(i + 1, path + '*' + substr,
value - prev_operand + prev_operand * curr,
prev_operand * curr)
backtrack(0, '', 0, 0)
return result
Why track prev_operand? Multiplication has higher precedence. If the expression so far evaluates to 2 + 3 and we multiply by 4, the result should be 2 + 3*4 = 14, not (2+3)*4 = 20. So we undo +3 and redo +3*4.
9. Pruning Techniques
Pruning is what makes backtracking practical. Without it, you’re just doing brute force.
Type 1: Feasibility Pruning
Stop early when the current path can’t possibly lead to a solution.
# Combination Sum: if remaining < 0, stop
def backtrack(start, path, remaining):
if remaining < 0:
return # PRUNE — overshot the target
if remaining == 0:
result.append(path[:])
return
...
Type 2: Bound Pruning (Branch and Bound)
Estimate the best possible outcome from the current state. If it’s worse than the best known solution, prune.
# Traveling Salesman: prune if current_cost + estimated_remaining > best_known
def backtrack(current_city, visited, cost):
lower_bound = cost + estimate_remaining(visited)
if lower_bound >= best_known:
return # PRUNE — can't beat current best
if len(visited) == n:
best_known = min(best_known, cost + dist[current_city][0])
return
...
Type 3: Symmetry Pruning
If multiple choices lead to equivalent states, try only one.
# N-Queens: the board has vertical symmetry
# Only place first queen in columns 0..n//2, mirror the rest
def backtrack(row, queens):
if row == 0:
end = (n + 1) // 2 # only half the columns
else:
end = n
for col in range(end):
...
# Partition into k subsets: identical empty buckets are interchangeable
seen = set()
for i in range(k):
if buckets[i] in seen:
continue # this bucket state already tried
seen.add(buckets[i])
...
Type 4: Ordering Pruning
Process elements in a smart order to trigger other pruning earlier.
# Sort in decreasing order: large elements fail faster, pruning more branches
nums.sort(reverse=True)
# Sudoku: pick the cell with fewest candidates (MRV heuristic)
def find_best_cell(board):
min_options = 10
best = None
for r in range(9):
for c in range(9):
if board[r][c] == 0:
options = count_valid_digits(r, c)
if options < min_options:
min_options = options
best = (r, c)
return best
Type 5: Memoization (When Subproblems Repeat)
If the same state can be reached via different paths, cache the result. This turns backtracking into dynamic programming.
# Word Break: can we segment string into dictionary words?
from functools import lru_cache
@lru_cache(maxsize=None)
def can_break(start):
if start == len(s):
return True
for end in range(start + 1, len(s) + 1):
if s[start:end] in word_set and can_break(end):
return True
return False
Pruning Impact Summary
Without pruning: O(n!) or O(2^n) or O(k^n)
With pruning: Often orders of magnitude faster
Example — N-Queens:
n=8: Without pruning: 8^8 = 16M states
With col/diag pruning: ~15K states
Speedup: ~1000x
10. Backtracking + Bitmask
Why Bitmask?
A bitmask compresses a set of boolean flags into a single integer. Instead of used = [False, True, True, False], use used = 0b0110 = 6.
Benefits
| Boolean Array | Bitmask |
|---|---|
| O(n) to copy | O(1) to copy |
| O(n) to compare | O(1) to compare |
| Can’t hash easily | Hash as integer |
| Mutable (must undo) | Immutable operations |
Permutations with Bitmask
def permutations_bitmask(nums):
n = len(nums)
result = []
def backtrack(mask, path):
if mask == (1 << n) - 1: # all bits set
result.append(path[:])
return
for i in range(n):
if mask & (1 << i): # already used
continue
path.append(nums[i])
backtrack(mask | (1 << i), path)
path.pop()
backtrack(0, [])
return result
Bitmask DP on Permutations (TSP-Style)
When you need to count or optimize over permutations, combine bitmask with memoization:
from functools import lru_cache
def shortest_hamiltonian_path(dist, n):
"""Find shortest path visiting all n cities."""
@lru_cache(maxsize=None)
def dp(mask, last):
if mask == (1 << n) - 1:
return 0 # or dist[last][0] for cycle
best = float('inf')
for nxt in range(n):
if mask & (1 << nxt):
continue
best = min(best, dist[last][nxt] + dp(mask | (1 << nxt), nxt))
return best
return min(dp(1 << i, i) for i in range(n))
N-Queens with Bitmask
Track column and diagonal conflicts as bitmasks — checking and updating all three in O(1).
def n_queens_bitmask(n):
count = 0
def backtrack(row, cols, diag1, diag2):
nonlocal count
if row == n:
count += 1
return
# Available positions: bits NOT set in any conflict mask
available = ((1 << n) - 1) & ~(cols | diag1 | diag2)
while available:
# Pick lowest set bit
pos = available & (-available)
available &= available - 1
backtrack(
row + 1,
cols | pos,
(diag1 | pos) << 1, # shift left: row-col increases
(diag2 | pos) >> 1 # shift right: row+col decreases
)
backtrack(0, 0, 0, 0)
return count
Why shift the diagonal masks? When moving to the next row:
- A queen’s main diagonal (↘) threat shifts one column right (
<< 1) - A queen’s anti-diagonal (↙) threat shifts one column left (
>> 1)
This is faster than using sets because all operations are bitwise O(1).
11. Constraint Satisfaction Problems
Graph Coloring
Color a graph with at most m colors such that no two adjacent nodes share a color.
def graph_coloring(adj, n, m):
"""Can we color graph with m colors? adj = adjacency list."""
colors = [0] * n # 0 = uncolored
def is_safe(node, color):
for neighbor in adj[node]:
if colors[neighbor] == color:
return False
return True
def backtrack(node):
if node == n:
return True
for color in range(1, m + 1):
if is_safe(node, color):
colors[node] = color
if backtrack(node + 1):
return True
colors[node] = 0
return False
return backtrack(0)
Hamiltonian Path
Find a path that visits every vertex exactly once.
def hamiltonian_path(adj, n):
"""Find a Hamiltonian path if one exists."""
path = []
visited = [False] * n
def backtrack(node):
path.append(node)
if len(path) == n:
return True
visited[node] = True
for neighbor in adj[node]:
if not visited[neighbor]:
if backtrack(neighbor):
return True
visited[node] = False
path.pop()
return False
for start in range(n):
if backtrack(start):
return path
return None
Cryptarithmetic (SEND + MORE = MONEY)
def solve_cryptarithmetic():
"""Solve SEND + MORE = MONEY."""
letters = 'SENDMORY' # 8 unique letters
leading = {'S', 'M'} # can't be 0
def backtrack(idx, assignment, used_digits):
if idx == len(letters):
# Check the equation
s = lambda w: int(''.join(str(assignment[c]) for c in w))
return s('SEND') + s('MORE') == s('MONEY')
letter = letters[idx]
start = 1 if letter in leading else 0
for digit in range(start, 10):
if digit in used_digits:
continue
assignment[letter] = digit
used_digits.add(digit)
if backtrack(idx + 1, assignment, used_digits):
return True
del assignment[letter]
used_digits.discard(digit)
return False
assignment = {}
if backtrack(0, assignment, set()):
return assignment
return None
12. Common Patterns Collection
Pattern A: “Generate All X” → Backtracking
Whenever a problem asks to generate all valid configurations:
Generate all:
- Permutations → Section 2
- Combinations → Section 3
- Subsets → Section 4
- Palindrome splits → Section 7
- Valid parentheses → Section 8
- IP addresses → Section 8
Pattern B: “Can We Achieve X?” → Backtracking with Early Return
When the answer is True/False, return as soon as you find one solution:
def can_achieve(state):
if is_solution(state):
return True # found one!
for choice in candidates:
if is_valid(state, choice):
apply(state, choice)
if can_achieve(state): # propagate True immediately
return True
undo(state, choice)
return False # exhausted all options
Pattern C: “Find Optimal X” → Backtracking with Bound
Track the best solution found so far and prune branches that can’t improve it:
best = float('inf')
def find_optimal(state, cost):
global best
if is_complete(state):
best = min(best, cost)
return
if cost + lower_bound(state) >= best:
return # PRUNE
for choice in candidates:
apply(state, choice)
find_optimal(state, cost + choice_cost)
undo(state, choice)
Pattern D: Counting Solutions → Backtracking with Counter
def count_solutions(state):
if is_complete(state):
return 1
total = 0
for choice in candidates:
if is_valid(state, choice):
apply(state, choice)
total += count_solutions(state)
undo(state, choice)
return total
Pattern E: Incremental Constraint Checking
Don’t re-validate the entire state from scratch. Instead, maintain constraint data structures and update them incrementally:
N-Queens:
BAD: for each new queen, scan entire board for conflicts → O(n) per check
GOOD: maintain sets for cols, diag1, diag2 → O(1) per check
Sudoku:
BAD: for each digit, scan row + col + box → O(n) per check
GOOD: maintain sets for each row, col, box → O(1) per check
13. Pattern Recognition Cheat Sheet
Decision Flowchart
Problem asks to...
│
├── "Generate ALL valid ___"
│ │
│ ├── Order matters? → Permutations (Section 2)
│ ├── Order doesn't matter, fixed size? → Combinations (Section 3)
│ ├── Order doesn't matter, any size? → Subsets (Section 4)
│ ├── Split/partition something? → Partitioning (Section 7)
│ └── Build a string char by char? → String backtracking (Section 8)
│
├── "Can we ___?" (yes/no)
│ → Backtracking with early return (Pattern B)
│
├── "Find the best ___"
│ → Branch and bound (Pattern C)
│
├── "Count how many ___"
│ │
│ ├── State space small? → Backtracking with counter
│ └── Overlapping subproblems? → DP (memoized backtracking)
│
└── "Place items on grid with constraints"
→ Grid backtracking (Section 6)
Complexity Summary
| Problem | # Solutions | Time |
|---|---|---|
| Permutations of n | n! | O(n × n!) |
| Permutations with dups | n! / (k₁! × k₂! × …) | O(n × result) |
| Combinations C(n,k) | C(n,k) | O(k × C(n,k)) |
| Subsets of n | 2^n | O(n × 2^n) |
| N-Queens | varies (~n!/nᵏ) | O(n!) worst |
| Sudoku (9×9) | 1 | O(9^empty) worst |
| Graph coloring (m colors) | varies | O(m^n) worst |
| Hamiltonian path | varies | O(n!) worst |
When Backtracking vs DP
| Use Backtracking | Use DP |
|---|---|
| Need all solutions | Need count or optimal |
| State space has no overlap | Subproblems overlap |
| Constraints are complex (hard to encode as DP state) | State can be encoded as tuple/mask |
| Small input (n ≤ ~20) | Larger input (n ≤ ~1000+) |
When Backtracking vs Greedy
| Use Backtracking | Use Greedy |
|---|---|
| Need to explore multiple paths | Locally optimal = globally optimal |
| No greedy property exists | Matroid / exchange argument works |
| Need exact answer | Need approximate or provably optimal |
Common Mistakes to Avoid
| Mistake | Fix |
|---|---|
Forgetting to copy path when saving |
Use path[:] or list(path) |
| Not undoing the choice (backtrack step) | Always pair choose with unchoose |
| Generating duplicate solutions | Sort + skip (Section 5) |
| Not pruning → TLE | Add feasibility / bound / symmetry pruning |
| Infinite recursion | Ensure state progresses (index increases, mask grows) |
| Modifying iteration variable | Use index-based loop, not iterator over mutable collection |
Template Quick Reference
# Permutations
def perms(nums):
backtrack with used[] or swap
# Combinations
def combs(n, k):
backtrack(start, path) # start ensures no duplicates
# Subsets
def subs(nums):
backtrack(start, path) # record at every node, not just leaves
# With duplicates
nums.sort()
if i > start and nums[i] == nums[i-1]: continue
# Grid placement
for col in range(n):
if valid(row, col): place and recurse on row+1
# String building
for candidate_char in options:
if constraints_met: append and recurse on index+1
Backtracking is systematic trial-and-error. The “trial” part is choosing a candidate; the “error” part is recognizing dead ends and undoing the choice. Master the template, learn the pruning tricks, and know when to switch to DP — that covers 90% of backtracking problems.