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 |
From-Scratch Idea: What Makes a Problem Backtracking
Before any template, the identification test. A problem is backtracking when both hold:
1. The answer is BUILT INCREMENTALLY — one decision at a time
(pick the next element, place the next queen, add the next char).
2. A partial answer can be JUDGED before it's complete — you can tell
"this prefix is already doomed" and abandon it (prune) instead of
finishing it.
If only (1) holds and every prefix is always fine until the end, it’s plain enumeration. If the incremental decisions have overlapping subproblems you’d recompute, it’s DP, not backtracking. Backtracking is the case where you must walk the decision tree but can cut dead branches.
The one mental model: a decision tree you walk depth-first
Every backtracking problem is a tree. A node is a partial answer, an edge is one decision, a leaf is complete-or-pruned. Backtracking = DFS on that tree, undoing each decision on the way back up so the shared path/state is reused instead of copied at every node.
(empty answer) ← root: nothing decided
/ | \
choice A choice B choice C ← level 0: first decision
/ \ |
... ... PRUNE ← branch that can't lead to a
valid/optimal leaf: skip it
The three lines of the template map exactly onto walking one edge:
apply(choice) ── walk DOWN the edge (choose)
backtrack(...) ── explore the subtree
undo(choice) ── walk BACK UP the edge (unchoose) ← the "back" in backtracking
Everything else — permutations, N-Queens, parentheses — is the same DFS with a different “what are my choices at this node?” and a different “is this branch doomed?” test.
★ Insight ─────────────────────────────────────
- The reason we
undoinstead of passing a fresh copy down each edge is cost: one shared mutablepathcosts O(depth) memory total; copying at every node costs O(nodes × depth). Thepop()after the recursive call is what makes the whole tree reuse a single path buffer. - “Can I judge a partial answer early?” is the entire value proposition. Two problems can have the same leaves (e.g. all 2ⁿ subsets) but wildly different runtimes purely from how early the doomed branches get cut.
─────────────────────────────────────────────────
Choices-per-node decides the technique
"pick the NEXT element, order matters, can't reuse" → Permutations (§2) choices = unused elements
"pick a SUBSET of size k, order doesn't matter" → Combinations (§3) choices = elements after index i
"include-or-skip each element" → Subsets (§4) choices = {take, skip}
"same as above but input has repeats" → Duplicates (§5) choices = distinct values at this level
"place one item per row/cell under constraints" → Grid/Board (§6) choices = safe positions
"cut the input into valid pieces" → Partition (§7) choices = valid next segment
"append the next character under a rule" → String (§8) choices = legal next chars
LeetCode ↔ CP translation
The competitive-programming framing and the LeetCode framing are the same tree; only the vocabulary differs.
| Competitive programming | LeetCode | Same underlying tree |
|---|---|---|
| “generate all permutations” | LC 46 / 47 | choices = unused elements |
| “enumerate subsets / power set” | LC 78 / 90 | choices = take/skip per index |
| “place N non-attacking queens” | LC 51 / 52 | choices = safe columns per row |
| “fill grid under row/col/box constraint” | LC 37 Sudoku | choices = legal digits per cell |
| “count / find one feasible assignment” | LC 39/40 Combination Sum | choices = candidates ≥ start |
| “split string into valid tokens” | LC 131 / 93 | choices = valid next segment |
Master LeetCode Comparison Table
Six canonical problems, one per technique. Read the “choices at each node” and “prune when” columns — that pair is the algorithm.
| LC # | Problem | Technique (§) | Difficulty | Choices at each node | Prune / skip when |
|---|---|---|---|---|---|
| 46 | Permutations | Permutations (§2) | Medium | every unused element | element already in path (used[i]) |
| 78 | Subsets | Subsets (§4) | Medium | each element with index ≥ start |
(never prune — record every node) |
| 90 | Subsets II | Duplicates (§5) | Medium | distinct values from start |
i > start and nums[i]==nums[i-1] |
| 79 | Word Search | Grid/Board (§6) | Medium | 4 neighbors of current cell | off-grid, wrong char, or visited |
| 22 | Generate Parentheses | String (§8) | Medium | append ( or ) |
( when open==n; ) when close==open |
| 131 | Palindrome Partitioning | Partition (§7) | Medium | every prefix s[start:end+1] |
prefix isn’t a palindrome |
★ Insight ─────────────────────────────────────
- Notice §4 Subsets has an empty “prune when” column — it records at every node, not just leaves, and never cuts a branch. That’s the cheapest backtracking shape: pure enumeration of 2ⁿ nodes. Every other row earns its speed from the prune column.
- LC 46 vs LC 78 differ only in what counts as a choice: 46 picks from unused elements (order matters, n! leaves); 78 picks from later indices (order fixed, 2ⁿ nodes). Same DFS skeleton, different candidate set — the whole family is one template with a swapped inner loop.
─────────────────────────────────────────────────
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", we prune the "11" prefix
at level 2 — its whole subtree (110, 111) is never generated:
""
/ \
"0" "1"
/ \ / ╲
"00" "01" "10" "11" ← PRUNE here (prefix has "11")
/ \ / \ / \ ✗ (110, 111 never expanded)
000 001 010 011 100 101
Valid results: 000, 001, 010, 100, 101 — 5 strings.
The prune at the "11" prefix saved exploring 2 leaves.
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)
Walkthrough — LC 46 Permutations
This template solves: LC 46 (Permutations), LC 47 (Permutations II — add the §5 duplicate skip), LC 60 (Permutation Sequence — same tree, count instead of collect).
Full statement. Given an array
numsof distinct integers, return all possible permutations in any order. Example:nums = [1,2,3]→[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]].
The pattern-focused view: each tree level fixes one more position; the choices at a node are the elements not yet in path. The used array is the “what’s still available” set.
def permute(nums):
n = len(nums)
result = []
used = [False] * n
def backtrack(path):
if len(path) == n: # leaf: a full permutation
result.append(path[:]) # copy — path keeps mutating
return
for i in range(n):
if used[i]: # prune: element already placed
continue
used[i] = True # choose
path.append(nums[i])
backtrack(path) # explore
path.pop() # unchoose
used[i] = False
backtrack([])
return result
nums = [1,2,3] path grows down, used = which elements are taken
[] used=---
├─ pick 1 path=[1] used=T--
│ ├─ pick 2 [1,2] used=TT-
│ │ └─ pick 3 [1,2,3] ✓ LEAF
│ └─ pick 3 [1,3] used=T-T
│ └─ pick 2 [1,3,2] ✓ LEAF
├─ pick 2 path=[2] used=-T-
│ ├─ pick 1 → [2,1,3] ✓
│ └─ pick 3 → [2,3,1] ✓
└─ pick 3 path=[3] used=--T
├─ pick 1 → [3,1,2] ✓
└─ pick 2 → [3,2,1] ✓
6 leaves = 3! permutations
★ Insight ─────────────────────────────────────
- The
used[i]=Falseon the way back up is the whole trick: after exploring “1 first”, we release element 1 so the “2 first” branch can use it. Forget that line and every permutation starts with 1. - LC 47 (duplicates) is this exact code plus one guard: sort, then
if i>0 and nums[i]==nums[i-1] and not used[i-1]: continue(§5). The base algorithm never changes — you only tighten the candidate set.─────────────────────────────────────────────────
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
Walkthrough — LC 78 Subsets
This template solves: LC 78 (Subsets), LC 77 (Combinations — stop recording until len(path)==k), LC 39 (Combination Sum — recurse on i not i+1 to allow reuse, prune on remaining target).
Full statement. Given an array
numsof unique integers, return all possible subsets (the power set). The solution set must not contain duplicate subsets. Example:nums = [1,2,3]→[[],[1],[2],[3],[1,2],[1,3],[2,3],[1,2,3]](any order).
The pattern-focused view: unlike permutations/combinations, every node of the tree is a valid answer, not just leaves — so you record on entry, before the loop. The start index enforces increasing order, which is what stops [2,1] and [1,2] both appearing.
def subsets(nums):
result = []
def backtrack(start, path):
result.append(path[:]) # record EVERY node, not just leaves
for i in range(start, len(nums)):
path.append(nums[i]) # choose nums[i]
backtrack(i + 1, path) # explore; i+1 = never look back
path.pop() # unchoose
backtrack(0, [])
return result
nums = [1,2,3] record at entry (before the loop)
start=0 path=[] → record []
├─ i=0 [1] → record [1]
│ ├─ i=1 [1,2] → record [1,2]
│ │ └─ i=2 [1,2,3] → record [1,2,3]
│ └─ i=2 [1,3] → record [1,3]
├─ i=1 [2] → record [2]
│ └─ i=2 [2,3] → record [2,3]
└─ i=2 [3] → record [3]
8 records = 2^3 subsets
★ Insight ─────────────────────────────────────
backtrack(i+1, ...)— noti, notstart— is the ordering lock.i+1means “future choices come strictly after me,” which collapses the 3! = 6 orderings of{1,2,3}into the single subset[1,2,3]. Usingiinstead gives combinations-with-repetition; usingstartre-generates the same subset many times.- Subsets is the one family with no prune: it visits all 2ⁿ nodes by design. Turn it into LC 77 Combinations by only recording when
len(path)==k, and into LC 39 Combination Sum by recursing oni(reuse allowed) and pruning onceremaining < 0.─────────────────────────────────────────────────
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] |
Walkthrough — LC 90 Subsets II
This template solves: LC 90 (Subsets II), LC 40 (Combination Sum II — same skip + a remaining-target prune), LC 47 (Permutations II — the used[i-1] variant of the same idea).
Full statement. Given an integer array
numsthat may contain duplicates, return all possible subsets. The solution set must not contain duplicate subsets. Example:nums = [1,2,2]→[[],[1],[1,2],[1,2,2],[2],[2,2]].
The pattern-focused view: it’s LC 78 plus one line. Sort so equal values are adjacent, then at each level skip a value already tried as the first choice at this level. The guard is i > start (not i > 0) — the first occurrence at a level is always allowed; only re-picking an equal value as a sibling is banned.
def subsetsWithDup(nums):
nums.sort() # equal values become adjacent
result = []
def backtrack(start, path):
result.append(path[:])
for i in range(start, len(nums)):
if i > start and nums[i] == nums[i - 1]:
continue # skip duplicate SIBLING at this level
path.append(nums[i])
backtrack(i + 1, path)
path.pop()
backtrack(0, [])
return result
nums = [1,2,2] (already sorted)
start=0 [] → record []
├─ i=0 pick 1 → [1] → record [1]
│ ├─ i=1 pick 2 → [1,2] → record [1,2]
│ │ └─ i=2 pick 2 → [1,2,2] → record [1,2,2]
│ └─ i=2 nums[2]==nums[1] and i(2)>start(1) → SKIP ✗
├─ i=1 pick 2 → [2] → record [2]
│ └─ i=2 pick 2 → [2,2] → record [2,2]
└─ i=2 nums[2]==nums[1] and i(2)>start(0) → SKIP ✗
6 records — the two pruned siblings would have re-made [1,2] and [2]
★ Insight ─────────────────────────────────────
- Why
i > startand noti > 0? The condition must fire only for siblings in the same loop, never across levels. Atstart=1the first2(i=1) is the branch head and must run; the second2(i=2) is its sibling and is the real duplicate.i > startsays exactly “this is not the first candidate at this level.” - Sort is load-bearing, not cosmetic: the skip test compares
nums[i]tonums[i-1], so equal values must be adjacent for it to catch them. Skip the sort and[2,1,2]slips two identical subsets through.─────────────────────────────────────────────────
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.
Walkthrough — LC 79 Word Search
This template solves: LC 79 (Word Search), LC 200 (Number of Islands — same grid DFS, no word to match), LC 212 (Word Search II — this DFS over a Trie of many words).
Full statement. Given an
m × ngrid of characters and a stringword, returnTrueifwordexists in the grid. The word is built from sequentially adjacent cells (horizontal/vertical neighbors); the same cell may not be used twice. Example:board = [["A","B","C","E"],["S","F","C","S"],["A","D","E","E"]],word = "ABCCED"→True;word = "ABCB"→False.
The pattern-focused view: the decision tree branches over the 4 neighbors at each step, matching one character deeper. The prune is aggressive — three fail-fast tests (off-grid, wrong char, already-visited) kill a branch before recursing. “Visited” is encoded by mutating the cell to '#' and restoring on the way back up.
def exist(board, word):
R, C = len(board), len(board[0])
def backtrack(r, c, k):
if k == len(word): # matched every char
return True
if r < 0 or r >= R or c < 0 or c >= C: # prune: off-grid
return False
if board[r][c] != word[k]: # prune: wrong char / visited '#'
return False
tmp = board[r][c]
board[r][c] = '#' # choose: mark visited
for dr, dc in [(0, 1), (0, -1), (1, 0), (-1, 0)]:
if backtrack(r + dr, c + dc, k + 1): # explore neighbors
return True
board[r][c] = tmp # unchoose: restore
return False
return any(backtrack(r, c, 0) for r in range(R) for c in range(C))
word = "ABCCED", start at (0,0)='A'
(0,0)A k0 ✓ mark #
└─ right (0,1)B k1 ✓ mark #
└─ right (0,2)C k2 ✓ mark #
├─ right (0,3)E k3 ✗ (need 'C') ← prune
├─ left (0,1)# k3 ✗ (visited) ← prune
└─ down (1,2)C k3 ✓ mark #
└─ down (2,2)E k4 ✓ mark #
└─ left (2,1)D k5 ✓ (need 'D') mark #
└─ k6 == len → TRUE
path A→B→C→C→E→D ✓
★ Insight ─────────────────────────────────────
- Setting
board[r][c]='#'unifies the visited-check with the char-check: a visited cell now holds'#', which never equalsword[k], so the singleboard[r][c] != word[k]line rejects both “wrong letter” and “already used.” One comparison, two prunes. - The restore
board[r][c] = tmpmust run on every exit path, not just success. If you returnTrueearly you skip it — that’s fine because you’re done — but on failure the cell must be freed so a different starting cell can legally step through it later.─────────────────────────────────────────────────
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
Walkthrough — LC 131 Palindrome Partitioning
This template solves: LC 131 (Palindrome Partitioning), LC 93 (Restore IP Addresses — segments are “≤255, no leading zero” instead of “palindrome”), LC 842 (Split into Fibonacci — segment validity is a running numeric rule).
Full statement. Given a string
s, partition it so that every substring of the partition is a palindrome. Return all possible partitionings. Example:s = "aab"→[["a","a","b"],["aa","b"]].
The pattern-focused view: partitioning is “where do I cut next?” The choices at a node are all prefixes s[start:end+1]; a prefix is only a valid choice if it passes the segment test (here: is-palindrome). Recurse from just past the cut. This is the universal partition shape — swap the validity test and it becomes IP-address or Fibonacci splitting.
def 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): # consumed the whole string
result.append(path[:])
return
for end in range(start, len(s)): # choose a cut point
if is_palindrome(start, end): # prune: non-palindrome prefix
path.append(s[start:end + 1]) # choose the segment
backtrack(end + 1, path) # explore the rest
path.pop() # unchoose
backtrack(0, [])
return result
s = "aab"
start=0
├─ cut "a" (0..0 pal ✓) path=["a"]
│ └─ start=1
│ ├─ cut "a" (1..1 pal ✓) path=["a","a"]
│ │ └─ start=2
│ │ └─ cut "b" (2..2 pal ✓) path=["a","a","b"]
│ │ └─ start=3 == len → RECORD ["a","a","b"]
│ └─ cut "ab" (1..2 pal ✗) ← prune
├─ cut "aa" (0..1 pal ✓) path=["aa"]
│ └─ start=2
│ └─ cut "b" (2..2 pal ✓) path=["aa","b"]
│ └─ start=3 == len → RECORD ["aa","b"]
└─ cut "aab" (0..2 pal ✗) ← prune
results: [["a","a","b"], ["aa","b"]]
★ Insight ─────────────────────────────────────
- Partitioning problems all share this skeleton — the only thing that changes between LC 131, 93, and 842 is the
is_valid(segment)test and, sometimes, a bound on how many pieces are allowed. Learn the shape once; the “prune when segment invalid” line is the pattern’s soul. - Recomputing
is_palindromeon every prefix is O(n) per test → O(n·2ⁿ) overall. The DP-precompute version above fillsis_pal[i][j]once so the test is O(1), a textbook “hoist the repeated sub-check out of the recursion” optimization — same trick DP memoization uses.─────────────────────────────────────────────────
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
Walkthrough — LC 22 Generate Parentheses
This template solves: LC 22 (Generate Parentheses), LC 17 (Letter Combinations — same char-by-char build, choices from a fixed map), LC 784 (Letter Case Permutation — append lower/upper per char).
Full statement. Given
npairs of parentheses, generate all combinations of well-formed parentheses. Example:n = 3→["((()))","(()())","(())()","()(())","()()()"].
The pattern-focused view: build the string one character at a time; the choices are ( and ), but each is guarded by a counting invariant rather than a data structure. ( is legal while opens remain (open < n); ) is legal only while it wouldn’t outnumber opens (close < open). Those two guards make every generated string valid by construction — no post-filter needed.
def generateParenthesis(n):
result = []
def backtrack(path, open_count, close_count):
if len(path) == 2 * n: # used all n pairs
result.append(''.join(path))
return
if open_count < n: # can still open
path.append('(')
backtrack(path, open_count + 1, close_count)
path.pop()
if close_count < open_count: # closing stays balanced
path.append(')')
backtrack(path, open_count, close_count + 1)
path.pop()
backtrack([], 0, 0)
return result
n = 2 (o=open used, c=close used) guards cut invalid branches
"" o0 c0
├─ '(' o1 c0
│ ├─ '(' o2 c0
│ │ └─ ')' o2 c1 → ')' o2 c2 → "(())" ✓
│ └─ ')' o1 c1
│ └─ '(' o2 c1 → ')' o2 c2 → "()()" ✓
└─ ')' ✗ close_count(0) < open_count(0) is FALSE → branch never taken
results (n=2): ["(())","()()"]
★ Insight ─────────────────────────────────────
- The two
ifguards are the pruning — there’s no separate “is this valid?” check at the leaf.close_count < open_countis what forbids")(": you can never close what you haven’t opened. Constraint-by-construction beats generate-then-filter, which would waste time building the 2^(2n) raw strings. - This is the “build a string under a running rule” archetype. Swap the guards for a digit→letters map and it’s LC 17; swap for “lower or upper per alpha char” and it’s LC 784. The append/recurse/pop spine is identical.
─────────────────────────────────────────────────
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.
★ Insight ─────────────────────────────────────
- The five prune types below attack the tree at different points: feasibility cuts a branch the moment it’s provably dead; bound cuts branches that can’t beat the best-so-far (needs a running best); symmetry cuts branches that are mirror images of one already tried; ordering doesn’t cut anything itself but makes the other prunes fire earlier; memoization cuts by never re-solving a state — which is the exact moment backtracking becomes DP.
- Ordering is the cheap force-multiplier: sorting
numsdescending so big elements fail fast (partition, combination-sum) often turns a TLE into an instant answer with zero change to the logic — only the iteration order moves.─────────────────────────────────────────────────
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+) |
★ Insight ─────────────────────────────────────
- The dividing line is overlap. Backtracking and memoized-DP walk the same decision tree; DP wins only when different branches reach the same subproblem so the cache pays off. If every partial state is unique (all permutations, all subsets), there’s nothing to cache and backtracking is already optimal.
- Practical tell: if you can name the state as a small tuple (
(index, remaining),(mask, last)) and two different choice-sequences can land on it, add@lru_cacheand you’ve upgraded to DP for free. If the state is the wholepath(an ordering), it can’t collapse — stay with backtracking.─────────────────────────────────────────────────
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
Pseudocode — shapes to recall, not runnable code.
# 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.
The Backtracking Toolbox at a Glance
Every backtracking algorithm is one DFS. Only two things change per problem:
CHOICES at each node PRUNE / SKIP when
──────────────────── ─────────────────
unused elements (46) already in path
indices ≥ start (78) (none — record every node)
distinct values (90) equals left sibling: i>start & a[i]==a[i-1]
4 grid neighbors (79) off-grid / wrong char / visited
'(' or ')' (22) open==n / close==open
valid prefixes (131) segment fails the validity test
The spine never changes:
choose → recurse → UNCHOOSE (the pop() is the "back" in backtracking)
Then the heavy pruning, when the raw tree is too big:
overshoot the target → feasibility prune (remaining < 0)
can't beat best-so-far → bound prune (branch & bound)
interchangeable branches → symmetry prune (empty-bucket set)
subproblems repeat → memoize → it's now DP
LeetCode Practice Ladder (the 6 walkthroughs)
46 (Medium) ── permutations: choices = unused elements
│
▼
78 (Medium) ── subsets: record every node, i+1 locks order
│
▼
90 (Medium) ── subsets II: sort + skip duplicate sibling
│
▼
22 (Medium) ── generate parens: constraint-by-construction
│
▼
131 (Medium) ── palindrome partition: choices = valid prefixes
│
▼
79 (Medium) ── word search: grid DFS + in-place visited
Then step up: 47/40 add the duplicate skip to permutations/combination-sum; 51 N-Queens adds diagonal pruning; 37 Sudoku adds MRV ordering; 39 Combination Sum swaps i+1→i for reuse.
When This Fails
Backtracking enumerates; that is both its purpose and its limit:
- You only need a count or an optimum. Enumerating every solution to count them is exponential where DP is polynomial. The tell is subproblems recurring with the same state.
- The search space is exponential with no effective pruning. Without a constraint that kills branches early, backtracking is brute force with extra steps. If you cannot prune, reconsider.
- The recursion is too deep. Python’s default limit is 1000 frames.
- State is not restored on the way back up. Every mutation on the way down needs an exact undo on the way up. A missed undo corrupts every sibling branch that follows — and it usually produces almost right answers, which is the hardest kind of bug to spot.
- You appended the path without copying it.
result.append(path)stores a reference that later mutations will change. Usepath[:]— unlesspathis a string, which is immutable.
Self-Test
Answer these from memory, out loud or on paper, before looking. Recognition is not recall: rereading an explanation feels like knowing, and it is not. A question you cannot answer cold names the exact section to revisit — you do not need to reread the guide.
1. What are the three moves in every backtracking function?
Answer
Choose (apply a candidate to the state), explore (recurse), un-choose (undo exactly what you applied). If the undo does not mirror the choose precisely, sibling branches inherit corrupted state.
2. Why result.append(path[:]) and not result.append(path)?
Answer
path is a single list mutated throughout the search. Storing the reference means every recorded solution ends up pointing at the same object, showing whatever it holds at the end. The slice takes a snapshot. Strings need no copy because they are immutable.
3. How do you avoid duplicate results when the input has duplicates?
Answer
Sort first, then within a single recursion level skip a candidate equal to the previous one: if i > start and nums[i] == nums[i-1]: continue. The i > start is essential — it permits duplicates at different depths (a legitimate repeat) while blocking them at the same depth (the same set in a different order).
4. For permutations with duplicates, the skip condition adds and not used[i-1]. Why?
Answer
It enforces that among equal values, the earlier index is always placed first. Without it, swapping two identical values counts as a distinct permutation. The not used[i-1] check means “the previous equal element is not currently in the path, so using this one now would be an out-of-order repeat”.
5. Combinations use i + 1 in the recursive call; combination-sum-with-reuse uses i. Why?
Answer
i + 1 moves past the current element so it cannot be chosen again. i allows reusing the same element. That single character is the entire difference between LC 39 and LC 40.
6. Name three pruning techniques stronger than “check validity at the leaf”.
Answer
Check feasibility on the way down and cut immediately (bound pruning). Order candidates most-constrained-first so failures surface early (MRV, as in Sudoku). Break symmetry by fixing an arbitrary choice — for N-Queens, restricting the first row’s column to the left half halves the search.
7. When should backtracking become DP?
Answer
When the same state recurs with the same future. If two different paths reach an identical (position, resources-used) state, everything below is identical and should be computed once. Memoize; if the state is a subset, that is a bitmask DP.
8. How do you detect a missing undo?
Answer
Solutions come out subtly wrong rather than crashing — an extra element, a stale flag, a board that never fully resets. Print the state at the top of each call and check it matches the state at the same depth on the previous branch.
See Also
- Dynamic Programming — the same decision tree, but when you only need to count or optimize rather than list solutions. Overlapping subproblems mean memoize, not enumerate.
- Bitmask Techniques — replaces the
used[]array with a single integer oncen <= 20, which is what makes the state memoizable. - Bitmask DP — Subset Partition — where §10 leads: once the search repeats states, the recursion becomes a
dp[mask]table. - Graph Patterns — backtracking on an explicit graph (Hamiltonian paths, colouring) sits on the DFS machinery there.
- Pattern Decision Map — the router: which technique does a cold problem call for?
- Pattern Mastery Program — the spaced-repetition schedule, mastery checklist, and drill formats that turn reading into recall.
Pattern mastered — walk the decision tree depth-first, undo each choice on the way back up, and cut every branch you can already prove is doomed. The savings live in the branches you never visit.