backtrack(path, choices) template with state restoration after each recursive call.| Pattern | Time Complexity | Space | Pruning Strategy | Classic Problems |
|---|---|---|---|---|
| Permutations (no dups) | O(n! * n) | O(n) | Skip used elements via visited[] | Permutations (LC 46) |
| Permutations (with dups) | O(n! * n) | O(n) | Sort + skip nums[i] == nums[i-1] if prev unused | Permutations II (LC 47) |
| Combinations | O(C(n,k) * k) | O(k) | Start index prevents revisiting; prune if remaining < needed | Combinations (LC 77) |
| Combination Sum (reuse) | O(n^(t/min)) | O(t/min) | Skip candidates > remaining target | Combination Sum (LC 39) |
| Combination Sum (no reuse) | O(2^n * n) | O(n) | Sort + skip duplicates + start index | Combination Sum II (LC 40) |
| Subsets (no dups) | O(2^n * n) | O(n) | Start index; collect at every node | Subsets (LC 78) |
| Subsets (with dups) | O(2^n * n) | O(n) | Sort + skip nums[i] == nums[i-1] | Subsets II (LC 90) |
| N-Queens | O(n!) | O(n) | Column, diagonal, anti-diagonal sets | N-Queens (LC 51) |
| Sudoku Solver | O(9^m), m = blanks | O(m) | Row/col/box constraint sets | Sudoku Solver (LC 37) |
| Word Search (grid) | O(m*n * 4^L) | O(L) | Bounds check + visited cell marking | Word Search (LC 79) |
| Palindrome Partitioning | O(n * 2^n) | O(n) | Prune non-palindrome prefixes early | Palindrome Partitioning (LC 131) |
START: Do you need to enumerate ALL valid solutions (not just count or optimize)?
├── YES: Is there a constraint that eliminates large branches early?
│ ├── YES → Backtracking with pruning (this unit)
│ └── NO → Backtracking still works but may be slow; check if problem size allows exponential time
├── NO: Do subproblems overlap (same state reachable via different paths)?
│ ├── YES → Dynamic Programming
│ └── NO ↓
├── Need shortest path / minimum steps in state space?
│ ├── YES → BFS (breadth-first search)
│ └── NO ↓
├── Can you prove greedy choice property?
│ ├── YES → Greedy algorithm
│ └── NO ↓
└── DEFAULT → Backtracking with pruning is the safe fallback for constraint satisfaction
Determine what constitutes a "choice" at each step, what the "path" (partial solution) looks like, and when you've reached a complete solution (base case). Draw the decision tree on paper first. [src1]
Three key concepts:
- PATH: choices already made (the partial solution so far)
- CHOICES: options available at the current step
- BASE: condition where path is a complete valid solution
Verify: Can you draw a tree where each level represents one choice? If yes, backtracking applies.
Every backtracking solution follows this skeleton. Customize the three marked sections. [src1]
def backtrack(path, choices):
if is_complete(path): # BASE CASE
result.append(path[:]) # save a copy
return
for choice in choices:
if not is_valid(choice): # PRUNE
continue
path.append(choice) # CHOOSE
backtrack(path, next_choices) # EXPLORE
path.pop() # UNCHOOSE (undo)
Verify: Every append has a matching pop, and the base case copies the path.
Without pruning, you explore every branch. Add constraints that skip invalid choices before recursing. [src3]
# Combination sum: prune when remaining target < 0
def backtrack(path, start, remaining):
if remaining == 0:
result.append(path[:])
return
for i in range(start, len(candidates)):
if candidates[i] > remaining: # PRUNE
break
path.append(candidates[i])
backtrack(path, i, remaining - candidates[i])
path.pop()
Verify: Sort input first when using comparison-based pruning.
Sort the input, then skip consecutive duplicates at the same decision level. [src2]
candidates.sort()
for i in range(start, len(candidates)):
if i > start and candidates[i] == candidates[i - 1]:
continue # skip duplicate at same level
# ... choose, explore, unchoose
Verify: Output should contain no duplicate subsets/combinations. Test with [1, 1, 2].
# Input: nums = [1, 2, 3]
# Output: [[], [1], [1,2], [1,2,3], [1,3], [2], [2,3], [3]]
def subsets(nums):
result = []
def backtrack(start, path):
result.append(path[:]) # collect at every node
for i in range(start, len(nums)):
path.append(nums[i]) # choose
backtrack(i + 1, path) # explore (i+1 prevents reuse)
path.pop() # unchoose
backtrack(0, [])
return result
# Input: n = 4
# Output: all valid board configurations as lists of queen positions
def solve_n_queens(n):
result, cols, diags, anti = [], set(), set(), set()
def backtrack(row, queens):
if row == n:
result.append(queens[:])
return
for col in range(n):
if col in cols or (row-col) in diags or (row+col) in anti:
continue # prune: conflict detected
cols.add(col); diags.add(row-col); anti.add(row+col)
backtrack(row + 1, queens + [col])
cols.remove(col); diags.remove(row-col); anti.remove(row+col)
backtrack(0, [])
return result
// Input: nums = [1, 2, 3]
// Output: [[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]
function permute(nums) {
const result = [];
function backtrack(path, used) {
if (path.length === nums.length) { result.push([...path]); return; }
for (let i = 0; i < nums.length; i++) {
if (used[i]) continue; // skip already-used elements
used[i] = true; path.push(nums[i]); // choose
backtrack(path, used); // explore
path.pop(); used[i] = false; // unchoose
}
}
backtrack([], new Array(nums.length).fill(false));
return result;
}
// Input: candidates = [2,3,6,7], target = 7
// Output: [[2,2,3],[7]]
function combinationSum(candidates, target) {
candidates.sort((a, b) => a - b);
const result = [];
function backtrack(start, path, remaining) {
if (remaining === 0) { result.push([...path]); return; }
for (let i = start; i < candidates.length; i++) {
if (candidates[i] > remaining) break; // prune
path.push(candidates[i]);
backtrack(i, path, remaining - candidates[i]); // i, not i+1 (reuse)
path.pop();
}
}
backtrack(0, [], target);
return result;
}
Full script: SudokuSolver.java (45 lines)
// Input: 9x9 board with '.' for empty cells
// Output: solved board in-place
public void solveSudoku(char[][] board) { solve(board); }
private boolean solve(char[][] board) {
for (int r = 0; r < 9; r++)
for (int c = 0; c < 9; c++)
if (board[r][c] == '.') {
for (char d = '1'; d <= '9'; d++) {
if (isValid(board, r, c, d)) {
board[r][c] = d; // choose
if (solve(board)) return true; // explore
board[r][c] = '.'; // unchoose
}
}
return false; // no valid digit: trigger backtrack
}
return true; // all cells filled
}
// Input: s = "aab"
// Output: [["a","a","b"],["aa","b"]]
public List<List<String>> partition(String s) {
List<List<String>> result = new ArrayList<>();
backtrack(s, 0, new ArrayList<>(), result);
return result;
}
void backtrack(String s, int start, List<String> path, List<List<String>> result) {
if (start == s.length()) { result.add(new ArrayList<>(path)); return; }
for (int end = start + 1; end <= s.length(); end++) {
String sub = s.substring(start, end);
if (!isPalindrome(sub)) continue; // prune non-palindromes
path.add(sub);
backtrack(s, end, path, result);
path.remove(path.size() - 1);
}
}
# BAD -- missing path.pop() after recursion
def backtrack(path, start):
result.append(path[:])
for i in range(start, len(nums)):
path.append(nums[i])
backtrack(path, i + 1)
# forgot path.pop() -- path grows forever, all results wrong
# GOOD -- symmetric choose/unchoose
def backtrack(path, start):
result.append(path[:])
for i in range(start, len(nums)):
path.append(nums[i]) # choose
backtrack(path, i + 1) # explore
path.pop() # unchoose -- MUST match the append
# BAD -- appending the same list object, not a snapshot
def backtrack(path, start):
if len(path) == k:
result.append(path) # all entries point to same list!
# GOOD -- append a shallow copy
def backtrack(path, start):
if len(path) == k:
result.append(path[:]) # path[:] or list(path) creates a copy
# BAD -- tries every candidate even when sum exceeds target
for i in range(start, len(candidates)):
path.append(candidates[i])
backtrack(path, i, remaining - candidates[i])
path.pop()
# GOOD -- sort first, break when too large
candidates.sort()
for i in range(start, len(candidates)):
if candidates[i] > remaining:
break # all subsequent also too large
path.append(candidates[i])
backtrack(path, i, remaining - candidates[i])
path.pop()
# BAD -- produces duplicate subsets for input [1, 1, 2]
for i in range(start, len(nums)):
path.append(nums[i])
backtrack(path, i + 1)
path.pop()
# GOOD -- skip nums[i] if equals nums[i-1] at same decision level
nums.sort()
for i in range(start, len(nums)):
if i > start and nums[i] == nums[i - 1]:
continue # skip duplicate at same tree level
path.append(nums[i])
backtrack(path, i + 1)
path.pop()
sys.setrecursionlimit() or convert to iterative with explicit stack. [src3]result.append(path[:]) inside the base case check. [src1]used[] boolean array, not a start index. Using start produces combinations. Fix: use visited[i] to track used elements. [src2]start parameter. [src4]cols, diags, anti_diags) for O(1) conflict detection. [src7]nums[i] == nums[i-1] skip logic only works on sorted input. Fix: always sort() first. [src2]| Use When | Don't Use When | Use Instead |
|---|---|---|
| Need ALL valid solutions (enumerate every permutation, combination, subset) | Need only the COUNT of solutions | Dynamic Programming |
| Constraint satisfaction (N-Queens, Sudoku, crossword) | Problem has overlapping subproblems with optimal substructure | Dynamic Programming (memoization) |
| Search space can be pruned significantly by constraints | Need shortest path or minimum steps | BFS (breadth-first search) |
| Input size small enough for exponential time (n <= ~20 for subsets) | Input size is large (n > 25 for subsets) | DP, math formulas, or meet-in-the-middle |
| Need to generate actual solutions, not just existence/count | State space is infinite or unbounded | Iterative deepening or bounded search |
| Grid/board puzzles with local constraints (word search, maze) | Graph has no dead ends to prune | Plain DFS suffices |
| Decision tree is finite and bounded | Greedy choice property is provable | Greedy algorithm |