Patterns/Backtracking

Backtracking

Top interview pattern
95 min read
Updated June 2026
What you'll learn
  • Apply the choose/explore/unchoose template to any problem by identifying what each recursion level represents, what the choices are, and when to collect results
  • Distinguish permutation problems (used array, loop from 0) from combination problems (start index, loop from start) and know which mechanism prevents duplicates in each
  • Handle duplicate elements correctly using the sort + skip pattern, understanding why i > start matters for combinations and why not used[i-1] matters for permutations
  • Prune branches safely by identifying constraints that are already violated or cannot possibly be satisfied by any extension of the current partial solution

Backtracking builds candidate solutions one choice at a time, tests each against constraints, and undoes choices that lead to dead ends. It is DFS through a decision tree where state is restored between branches. When a problem says "generate all," "find all valid," or "enumerate," backtracking is the pattern. The template is the same every time. The differences between problems come down to what each recursion level represents, when to collect results, and how to avoid duplicates.

What Is Backtracking?

Decision Tree: Choose / Explore / Unchoose

+1+2+3+2+3+3+3[][1][2][3][1,2][1,3][2,3][1,2,3]
path:
[]
results:0 collected

We start at the root with an empty path []. Three branches ahead: include 1, include 2, or include 3.

Every node in the tree is a decision point: include the next element or skip it.

Backtracking is DFS through a decision tree you never explicitly build. Your recursive function is the traversal. At each node, you make a choice (add an element to the current path), recurse to explore that branch, then undo the choice so the next branch starts clean.

Plain DFS visits nodes in a graph, marking them permanently. Backtracking constructs candidates under constraints, restoring state between branches so elements can be reused on different paths.

Every backtracking problem has three customization points:

  • What is "state"? The current partial solution (a path, a board configuration, a set of placed queens).
  • What are "valid choices"? Which elements can extend the current state (remaining numbers, valid grid directions, safe columns).
  • When is state a "solution"? When to collect the current state as a result (at every node for subsets, at depth k for combinations, at depth n for permutations).
Universal Backtracking Template
1def backtrack(state, choices):
2 if is_solution(state):
3 result.append(state[:]) # save a copy
4 return # or don't return, for subsets
5
6 for choice in choices:
7 if is_valid(choice, state):
8 state.append(choice) # choose
9 backtrack(state, new_choices) # explore
10 state.pop() # unchoose
Interview Tip
Identify three things before coding
Before writing any backtracking code, answer: (1) What does each recursion level represent? (2) What are the branches at each level? (3) When do you collect results? These three answers determine your loop range, your recursive call arguments, and your base case.
Key Insight
Choose, Explore, Unchoose: Why the Order Matters
The choose step modifies shared mutable state. The explore step recurses with that modification in place. The unchoose step removes it so the next sibling branch starts clean. Swapping the order breaks this. If unchoose happens before explore, the modification is gone when the recursion runs. If unchoose is omitted entirely, every subsequent branch inherits the previous branch's choices.

Permutations vs Combinations vs Subsets

Three Families of Backtracking

Subsets (2^n)
[][1][2][3][1,2][1,3][2,3][1,2,3]
Combinations C(n,k)
[][1][2][3][1,2][1,3][2,3][1,2,3]
Permutations (n!)
[][1][2][3][1,2][1,3][2,3][1,2,3]

Three families share the same tree structure but differ in where they collect results.

This difference determines the base case and where you call result.append.

Whether order matters in the output changes everything about the implementation.

QuestionAnswerFamily
Does order matter?NoSubset / Combination
Does order matter?YesPermutation
Fixed length k?No (any length)Subset
Fixed length k?YesCombination or Permutation
Can elements repeat?YesPass i (not i+1)
Can elements repeat?NoPass i+1 or use used[]

Subsets

Given an array of distinct integers, return all possible subsets. For input [1, 2, 3], the answer is eight subsets: [], [1], [2], [3], [1,2], [1,3], [2,3], and [1,2,3]. Notice that the empty set counts and that [1,2] and [2,1] are the same subset, so you only generate [1,2]. That observation about ordering is what shapes the entire implementation.

Model this as a decision tree where each level corresponds to one element in the array. At every node, the branches represent choosing one of the remaining elements to include next. The key constraint is that we enforce an ordering: once we consider element at index 2, we never go back to index 0 or 1 in that branch. This is what the start parameter does. By only considering elements from start onward, we guarantee that [1,2] is generated but [2,1] is not, because after choosing 2 at index 1, the recursion only looks at index 2 and beyond.

The most distinctive feature of subsets compared to other backtracking problems is when results get collected. In permutations you collect only at the leaves (when the path has all n elements). In combinations you collect only at depth k. In subsets, every single node in the tree is a valid result, including the root (the empty set). That means the collection line goes before the for-loop, not inside any length check. The root collects [], the node after choosing 1 collects [1], the node after choosing 1 then 2 collects [1,2], and so on. Every partial path is a valid subset.

Why are there exactly 2^n subsets? At each of the n elements, you have two choices: include it or skip it. That gives 2 × 2 × ... × 2 = 2^n total combinations. The decision tree captures this: each level offers branches for including each remaining element, and the structure naturally enumerates all 2^n possibilities without repetition.

Subsets
1def subsets(nums):
2 result = []
3 def backtrack(start, path):
4 result.append(path[:]) # collect at every node
5 for i in range(start, len(nums)):
6 path.append(nums[i])
7 backtrack(i + 1, path) # i+1: no reuse
8 path.pop()
9 backtrack(0, [])
10 return result

The diagram above traces the full [1,2,3] traversal step by step, showing how each node collects a result before exploring deeper. Each pop restores the path so sibling branches start clean.

The time complexity is O(n · 2^n). There are 2^n nodes in the tree, and at each node we copy the current path into result, which costs O(n) in the worst case. The recursion stack depth is n, so space is O(n) excluding the output. This is optimal because you cannot produce 2^n results of size up to n in less time.

Combinations

Given two integers n and k, return all combinations of k numbers chosen from 1 to n. For n=4 and k=2, the answer is [1,2], [1,3], [1,4], [2,3], [2,4], [3,4], six pairs total. Order does not matter, so [2,1] is the same as [1,2] and should not appear separately. If you think of this in terms of the subsets tree, combinations are exactly the nodes that happen to live at depth k. Every other node gets ignored.

The code is nearly identical to subsets with one addition: a base case that collects and returns when len(path) == k. In subsets, every node is a valid result so there is no base case check. In combinations, only nodes at depth k matter, so you add an if-check at the top of the recursive function. The return after collecting is not optional. Without it, the function continues into the for-loop and keeps adding elements past depth k, producing results of length k+1, k+2, and so on. The return is what tells the recursion "this branch is complete, stop going deeper."

The loop range and the recursive argument are identical to subsets. We loop from start to n, passing i+1 to enforce the same ordering constraint that prevents generating [2,1] after [1,2]. The only difference is that the tree gets pruned at depth k instead of being explored all the way to the leaves. This means the recursion tree is shallower: at most k levels deep instead of n levels, which directly affects the complexity.

Combinations
1def combine(n, k):
2 result = []
3 def backtrack(start, path):
4 if len(path) == k:
5 result.append(path[:])
6 return
7 for i in range(start, n + 1):
8 path.append(i)
9 backtrack(i + 1, path)
10 path.pop()
11 backtrack(1, [])
12 return result

Walk through n=4, k=2 to see the pruning in action. The root calls backtrack(1, []). The loop picks 1, recursing to backtrack(2, [1]). That call's loop picks 2, recursing to backtrack(3, [1,2]). Now len(path)==2==k, so [1,2] is collected and we return immediately, never entering the loop. Back at [1], the loop continues with 3 to produce [1,3] and 4 to produce [1,4]. Then back at the root, the loop picks 2, and its children produce [2,3] and [2,4]. Finally 3 produces [3,4], and 4 alone at depth 1 cannot reach depth 2 because there are no elements after 4. The tree never goes deeper than 2 levels.

The time complexity is O(k · C(n,k)). There are C(n,k) results, each of length k, and copying each into result costs O(k). The recursion stack depth is at most k. This is output-sensitive: you cannot produce C(n,k) results of size k in less time.

Permutations

Given an array of distinct integers like [1, 2, 3], return all possible orderings. The answer is six permutations: [1,2,3], [1,3,2], [2,1,3], [2,3,1], [3,1,2], [3,2,1]. The critical difference from subsets and combinations is that order matters. [1,2] and [2,1] are the same subset but different permutations, and both must appear in the result.

This means a start index will not work. In subsets, passing start and looping from start onward prevents generating [2,1] after [1,2], which is exactly what we want because they represent the same subset. But in permutations, [2,1] is a distinct valid result that must appear. If we used a start index, we would only generate orderings where indices are increasing, which is just subsets. Instead, we loop from 0 at every recursion level, giving every element a chance to appear at every position. The mechanism that prevents using the same element twice in a single permutation is a boolean array called used[].

Think about the branching factor at each level of the tree. At depth 0, all n elements are available, so there are n branches. At depth 1, one element is used, leaving n-1 branches. At depth 2, two are used, leaving n-2. This continues down to depth n-1, which has exactly 1 branch. The total number of leaves is n × (n-1) × (n-2) × ... × 1 = n!, which is exactly the number of permutations. This is why permutations grow factorially while subsets grow exponentially.

Results are collected only at the leaves, when len(path) == n. Unlike subsets where every partial path is a valid result, a permutation must include every element exactly once. A partial path like [1,2] is not a valid permutation of [1,2,3] because it is missing 3. So the collection happens inside a base case check at the top of the function, and the return after collecting prevents the loop from running (there are no unused elements left anyway, but the return makes the intent explicit).

Permutations
1def permute(nums):
2 result = []
3 used = [False] * len(nums)
4 def backtrack(path):
5 if len(path) == len(nums):
6 result.append(path[:])
7 return
8 for i in range(len(nums)):
9 if used[i]:
10 continue
11 used[i] = True
12 path.append(nums[i])
13 backtrack(path)
14 path.pop()
15 used[i] = False
16 backtrack([])
17 return result

Permutations [1,2,3]: used[] Mechanics

Path
[ ]
used[]
F1
F2
F3
Available
123
Collected (0/6):

Start: path=[], all elements available. Choose from {1, 2, 3}.

Permutations loop from 0 at every level, using used[] to skip placed elements.

The diagram above traces how used[] toggles on choose and unchoose, showing the available elements shrinking at each depth and restoring on backtrack.

The time complexity is O(n · n!). There are n! leaves (permutations), and at each leaf we copy a path of length n, costing O(n). The recursion stack depth is n. This is optimal because producing n! results of length n requires at least O(n · n!) work.

FamilyLoop RangeDedup MechanismCollect WhenRecursive Arg
Subsetsstart to n-1Start indexEvery nodei + 1
Combinationsstart to n-1Start indexlen == ki + 1
Permutations0 to n-1used[] arraylen == nN/A (no start)
Interview Tip
Order determines the structure
Ask: does order matter in the output? If [2,1] and [1,2] are the same result, you need a start index (combinations/subsets). If they are different results, you need a used[] array (permutations). The answer determines your loop bounds and dedup mechanism.
Common Mistake
Using a start index for permutation problems
A start index prevents generating [2,1] after [1,2] because 2 comes after 1 in the array. But [2,1] is a valid permutation, different from [1,2]. Start index is for combinations where order does not matter. Permutations need a used[] array that allows revisiting earlier indices.

Handling Duplicates

Duplicate Handling: Sort + Skip

nums =
1
2
2
Results (with duplicates):
[][1][1,2]dup[1,2,2][1,2]dup[2]dup[2,2][2]dup

Without dedup: [1,2,2] generates 8 subsets, but [1,2] appears twice and [2] appears twice.

The two 2s are indistinguishable, so [1,2_a] and [1,2_b] are the same subset.

When the input contains duplicate elements, the same combination or subset can be generated multiple times through different index choices. The fix: sort the input, then skip an element if it equals the previous one at the same recursion level.

Subsets II / Combination Sum II Pattern

Subsets II
1def subsetsWithDup(nums):
2 nums.sort() # Step 1: sort to group duplicates
3 result = []
4 def backtrack(start, path):
5 result.append(path[:])
6 for i in range(start, len(nums)):
7 # Step 2: skip duplicate at same level
8 if i > start and nums[i] == nums[i - 1]:
9 continue
10 path.append(nums[i])
11 backtrack(i + 1, path)
12 path.pop()
13 backtrack(0, [])
14 return result

The diagram above traces [1,2,2] step by step, showing which branches the skip condition prunes and which survive. The key insight: the condition uses i > start rather than i > 0, so the first element at each recursion level is always allowed, even if it equals the previous element in the sorted array. This means [1,2,2] is correctly generated (the second 2 is the first choice at its level, so i > start is false and the skip does not fire), while duplicate subtrees at the same level are pruned.

Permutations II

Permutations II [1,1,2]: Duplicate Pruning

Path
[ ]
used[]
idx 0 (1)False
idx 1 (1)False
idx 2 (2)False
Collected (0/3):
none yet

Input [1,1,2] sorted. Start with empty path. Choices: index 0 (1), index 1 (1), index 2 (2).

At depth 0, the loop tries each index. Duplicate pruning will skip index 1.

Given [1,1,2], there are three unique permutations: [1,1,2], [1,2,1], [2,1,1]. The diagram above shows which branches the skip condition prunes and why. The i > start trick from Subsets II does not work here because permutations loop from 0, not from a start index. Instead, the approach is: sort the array, then skip index i if nums[i] == nums[i-1] and not used[i-1]. This forces identical values to be placed in left-to-right index order, producing exactly one copy of each permutation.

Permutations II
1def permuteUnique(nums):
2 nums.sort()
3 result = []
4 used = [False] * len(nums)
5 def backtrack(path):
6 if len(path) == len(nums):
7 result.append(path[:])
8 return
9 for i in range(len(nums)):
10 if used[i]:
11 continue
12 # Skip: same value as previous, and previous not used
13 if i > 0 and nums[i] == nums[i-1] and not used[i-1]:
14 continue
15 used[i] = True
16 path.append(nums[i])
17 backtrack(path)
18 path.pop()
19 used[i] = False
20 backtrack([])
21 return result

The code sorts the input first, then uses the standard permutation template with one added skip. The used[i] check on line 7 prevents reusing the same index in a single path (same as regular permutations). The second condition on lines 9-10 handles the duplicate skipping. Everything else is identical to the standard permutation code: loop from 0, set used[i]=True, recurse, pop, set used[i]=False.

Common Mistake
Forgetting to sort before applying the skip condition
Without sorting, identical elements may not be adjacent. Input [2,1,2]: the two 2s are at indices 0 and 2. The condition nums[i] == nums[i-1] only catches adjacent duplicates. After sorting to [1,2,2], the duplicates are adjacent and the skip works. No error, just duplicate results that are hard to debug.
Common Mistake
Using i > 0 instead of i > start
Using i > 0 skips duplicates globally, not just at the current recursion level. This incorrectly removes valid combinations. For example, with [1,2,2], i > 0 would prevent the second 2 from ever being chosen, even at a deeper level where it is the first choice. Use i > start to limit the skip to the current level only.

State Management and the Undo Step

State Restoration: Append / Pop Symmetry

path =
[
·
·
·
·
]
depth:
0123

Path starts empty. We share a single mutable list across all recursive calls.

The path is not copied at each level. It is modified in place, so every mutation must be reversed.

The most common backtracking bug is saving a reference instead of a copy. In Python, result.append(path) does not save the current contents of path. It saves a reference to the list object. Since path is mutated by append and pop, every entry in result ends up pointing to the same empty list.

Reference vs Copy
1# Bug: saves a reference
2result.append(path) # all entries → same object
3
4# Fix: saves a snapshot
5result.append(path[:]) # Python slice copy
6result.append(list(path)) # explicit copy
7# Java: new ArrayList<>(path)
8# JavaScript: [...path]

The Symmetry Rule

Every state mutation before the recursive call must have an exact inverse after it. If you append, you pop. If you mark True, you mark False. If you overwrite a grid cell, you restore the original value.

Choose (before recurse)Unchoose (after recurse)
path.append(x)path.pop()
used[i] = Trueused[i] = False
board[r][c] = "#"board[r][c] = temp
cols.add(c)cols.remove(c)
remaining -= xremaining += x
Interview Tip
Always copy when saving results
In Python: path[:] or list(path). In Java: new ArrayList<>(path). In JavaScript: [...path]. If your result contains only empty lists or all identical lists, you forgot to copy.

Pruning: When to Cut Branches

Pruning: Combination Sum, Target = 7

Found (sum = target)
Pruned (sum > target)
+2=2+3=3+6=6+7=7sum=0+2=4+3=5+6=8+7=9+2=6+3=7+6=10+7=11+2=8+6=12+7=13

Combination Sum: find combinations of [2, 3, 6, 7] that sum to 7. Each number can be reused.

Without pruning, we explore every possible combination. Pruning skips branches that already exceed the target.

Pruning skips entire subtrees that cannot contain valid solutions. It does not change the worst case complexity, but it often cuts the number of nodes explored by 10x or more.

Safe pruning requires a provable guarantee: no valid solution exists below this branch. Two common reasons to prune:

  • Constraint already violated: the current partial solution has exceeded a budget, used too many resources, or violated a constraint that cannot be undone by future choices.
  • Cannot possibly satisfy constraint: even in the best case, the remaining elements cannot bring the partial solution to a valid state. For sorted arrays: if the smallest remaining element is too large, all subsequent elements are also too large.

Pruning in Combination Sum

Combination Sum asks: given candidates = [2, 3, 6, 7] and target = 7, find all unique combinations where the chosen numbers sum to 7. Each candidate can be used unlimited times. The answer is [[2, 2, 3], [7]]. This is a backtracking problem because we need all valid combinations, not just one or a count.

The key difference from standard combinations is the recursive argument. In regular combinations, we pass i+1 to prevent reusing the same element. Here, reuse is allowed, so we pass i instead. This means the recursion can pick candidates[i] again in the next call. The start index still increases across siblings (the loop goes from start to end), which prevents generating [3, 2, 2] after [2, 2, 3]. Reuse is vertical (same element deeper in the tree), not horizontal (same element at the same level).

Pruning makes this problem tractable. If we sort candidates first, sorting gives a bound: all elements after index i are at least as large as candidates[i]. When candidates[i] exceeds the remaining target, every subsequent candidate also exceeds it. This means we can break out of the loop entirely, not just skip the current candidate. The difference between break and continue here is dramatic. Continue skips one candidate and moves to the next (which is even larger and will also fail). Break skips all remaining candidates at this level in one step, cutting off an entire subtree.

Combination Sum with Pruning
1def combinationSum(candidates, target):
2 candidates.sort() # sort enables pruning
3 result = []
4 def backtrack(start, path, remaining):
5 if remaining == 0:
6 result.append(path[:])
7 return
8 for i in range(start, len(candidates)):
9 if candidates[i] > remaining:
10 break # Prune: all subsequent are larger
11 path.append(candidates[i])
12 backtrack(i, path, remaining - candidates[i])
13 path.pop()
14 backtrack(0, [], target)
15 return result

The diagram above traces candidates=[2,3,6,7] with target=7, showing exactly which branches the break prunes. The critical distinction: break exits the entire loop (not just the current candidate), because with sorted candidates every subsequent value is even larger and will also exceed the budget. Without sorting and pruning, the algorithm still produces correct results but explores many more dead-end branches, recursing all the way to remaining < 0 before failing.

N-Queens Constraint Checking

N-Queens uses three sets for O(1) conflict detection. Columns: which columns have queens. Main diagonals: row-col is constant along each diagonal. Anti-diagonals: row+col is constant. Before placing a queen, check all three sets. If any conflict exists, skip that column.

Key Insight
Pruning is safe when you're certain
Pruning is safe when you're certain no valid solution exists below this branch. If you're not sure, don't prune. A wrong prune removes valid solutions with no error message. A missing prune only costs performance. When in doubt, skip the prune.

N-Queens: Constraint Satisfaction

N-Queens (N=4): Constraint Satisfaction

01230123

N-Queens (N=4): place 4 queens so no two share a row, column, or diagonal. One queen per row.

We process one row at a time. For each row, try every column and check constraints.

N-Queens asks you to place n queens on an n×n chessboard such that no two queens attack each other. Queens attack along rows, columns, and both diagonals. For n=4, there are exactly two valid arrangements. This is a constraint satisfaction problem (CSP): unlike enumeration problems where you generate all configurations, here most configurations are invalid and the challenge is pruning the search space efficiently.

The backtracking model assigns one recursion level per row. Since each row must have exactly one queen (two queens in the same row would attack each other), we iterate through columns at each level and try placing a queen. The choices at row r are the columns 0 through n-1. The constraints are enforced by three sets that track which columns, main diagonals, and anti-diagonals are already occupied. If a candidate column conflicts with any set, we skip it immediately. This is the pruning that makes N-Queens tractable.

The diagonal math is worth understanding. On a top-left-to-bottom-right diagonal, as you move one step down and one step right, both row and col increase by 1. That means row minus col stays constant along the entire diagonal. For example, cells (0,0), (1,1), (2,2), (3,3) all have row-col = 0. Cells (0,1), (1,2), (2,3) all have row-col = -1. So if we store row-col in a set, checking whether a main diagonal is occupied costs O(1). Anti-diagonals go from top-right to bottom-left: row increases while col decreases, so row plus col stays constant. Cells (0,3), (1,2), (2,1), (3,0) all have row+col = 3. A second set for row+col handles anti-diagonals. Together with the column set, three O(1) lookups replace what would otherwise be an O(n) scan of the board.

N-Queens
1def solveNQueens(n):
2 result = []
3 cols = set()
4 diags = set() # row - col
5 anti_diags = set() # row + col
6
7 board = [['.' ] * n for _ in range(n)]
8
9 def backtrack(row):
10 if row == n:
11 result.append([''.join(r) for r in board])
12 return
13 for col in range(n):
14 if col in cols or (row - col) in diags or (row + col) in anti_diags:
15 continue # conflict detected, prune
16
17 # Choose
18 cols.add(col)
19 diags.add(row - col)
20 anti_diags.add(row + col)
21 board[row][col] = 'Q'
22
23 backtrack(row + 1) # Explore
24
25 # Unchoose
26 cols.remove(col)
27 diags.remove(row - col)
28 anti_diags.remove(row + col)
29 board[row][col] = '.'
30
31 backtrack(0)
32 return result

The diagram above traces n=4 step by step, showing how the three conflict sets prune invalid placements at each row. Column checking alone reduces the search from O(n^n) to O(n!) because each row eliminates one column. The diagonal constraints reduce the practical runtime further, though the worst-case bound remains O(n!). Space is O(n) for the three sets and the recursion stack.

Interview Tip
O(1) conflict detection with three sets
Track columns, diagonals (row-col), and anti-diagonals (row+col) as sets. This gives O(1) conflict checking per placement instead of O(n) scanning. On a 4x4 board: main diagonal values range from -3 to 3, anti-diagonal values from 0 to 6. Interviewers expect this optimization and may probe if you scan the board instead.

Word Search: Grid Backtracking

Word Search: "ABCCED"

ABCCEDA(0,0)B(0,1)C(0,2)E(0,3)S(1,0)F(1,1)C(1,2)S(1,3)A(2,0)D(2,1)E(2,2)E(2,3)

Start at (0,0)='A', matching word[0]='A'. Mark cell as visited.

We try every cell matching the first character as a starting point.

Word Search gives you a 2D grid of characters and a target word, and asks whether the word can be formed by following a path of adjacent cells (up, down, left, right) without revisiting any cell in the same path.

The approach is to try starting from every cell that matches word[0], then explore all four directions for word[1], and so on. When we step onto a cell, we mark it as visited so the same path does not loop back through it. The marking is done in-place by temporarily replacing the cell's character with a sentinel value like '#'. After exploring all four directions from that cell, we restore the original character. This in-place mark-and-restore is the grid equivalent of path.append and path.pop in array-based backtracking.

What makes this backtracking rather than plain DFS is the restoration step. In a standard graph DFS (like connected components or flood fill), once you visit a cell, it stays visited forever. You never need to revisit it because you are exploring reachability, and if a cell is reachable from one path, that is sufficient. But in Word Search, a cell that was part of one failed path must be available for a different path. If you mark (0,2) as visited while searching for "ABCCED" and the search fails along one direction, the cell needs to be unmarked so a different starting path can use it. Without restoration, the algorithm would miss valid paths because cells get permanently blocked by earlier failed attempts.

The outer loop tries every cell as a starting point. For most cells, the very first character check fails immediately (board[r][c] != word[0]) and the call returns False in constant time. Only cells matching the first character proceed deeper. This means the outer loop is cheap in practice even though it visits every cell.

Word Search
1def exist(board, word):
2 rows, cols = len(board), len(board[0])
3
4 def backtrack(r, c, idx):
5 if idx == len(word):
6 return True
7 if (r < 0 or r >= rows or c < 0 or c >= cols
8 or board[r][c] != word[idx]):
9 return False
10
11 # Choose: mark as visited
12 temp = board[r][c]
13 board[r][c] = '#'
14
15 # Explore: try all 4 directions
16 found = (backtrack(r + 1, c, idx + 1) or
17 backtrack(r - 1, c, idx + 1) or
18 backtrack(r, c + 1, idx + 1) or
19 backtrack(r, c - 1, idx + 1))
20
21 # Unchoose: restore cell
22 board[r][c] = temp
23
24 return found
25
26 for r in range(rows):
27 for c in range(cols):
28 if backtrack(r, c, 0):
29 return True
30 return False

The diagram above traces "ABCCED" on this board step by step, including a wrong-direction attempt that backtracks. When a direction fails, the cell's '#' marker is restored to its original value so sibling directions can use it.

The time complexity is O(m · n · 3^L) where m×n is the board size and L is the word length. Each cell can be a starting point (m·n), and from each cell the search branches into at most 3 directions at each step (not 4, because we cannot go back to the cell we just came from since it is marked '#'). The depth of recursion is at most L, so each starting cell explores at most 3^L paths. Space is O(L) for the recursion stack since the in-place marking avoids a separate visited set.

Interview Tip
Word Search II uses a Trie
If the problem asks you to find multiple words on the same board, build a Trie from all target words. Each DFS step checks the Trie node instead of a single character, enabling simultaneous search for all words in one pass. This avoids restarting the grid search from scratch for each word. See the Trie pattern guide.

When Backtracking Breaks

Backtracking generates all valid solutions by exploring every branch of a decision tree. This makes it the right tool when the problem asks for all results. When it asks only for a count, a minimum, or one optimal result, generating all solutions is unnecessary and expensive.

DP is correct when the same subproblem appears repeatedly on different paths through the decision tree. Backtracking solves that subproblem from scratch each time it appears. DP solves it once and reuses the answer. The signal is overlapping subproblems: if two different partial paths can reach the same state and need the same remaining decisions, DP removes the duplicate work. The signal in problem phrasing is usually "count," "minimum," or "maximum" rather than "generate all" or "find all valid."

BFS is correct when the goal is the minimum number of steps. Backtracking explores all paths, including longer ones, before finding a minimum. BFS processes states in increasing distance order, so the first time a target state is reached the path length is already minimal. Problems that ask for fewest moves, shortest transformation, or minimum operations on a graph or grid usually need BFS.

GoalPatternWhy Backtracking Is Wrong
Count combinationsDPBacktracking counts by generating. DP counts by state transitions.
Minimum steps to reach a goalBFSBacktracking explores all path lengths. BFS expands in layer order.
Minimum or maximum costDP or GreedyBacktracking generates all solutions and compares. DP solves by recurrence.
Generate all valid arrangementsBacktrackingOnly backtracking enumerates the complete output.
Interview Tip
Match the goal to the pattern
The phrases "generate all" and "find all valid" are the signals for backtracking. "Minimum," "maximum," "count," and "fewest steps" usually point to DP or BFS. If the problem only needs one answer that satisfies constraints, confirm whether backtracking's exponential runtime is acceptable given the input size.

Debugging Backtracking

Common Mistake
Saving a Reference Instead of a Copy
Wrong vs Right
1# Bug: all entries will be []
2result.append(path)
3
4# Fix: save a snapshot
5result.append(path[:])
6result.append(list(path))
Common Mistake
Forgetting the Undo Step
Wrong vs Right
1# Bug: no undo
2for i in range(start, len(nums)):
3 path.append(nums[i])
4 backtrack(i + 1, path)
5 # path.pop() is missing
6
7# Fix: symmetric
8for i in range(start, len(nums)):
9 path.append(nums[i])
10 backtrack(i + 1, path)
11 path.pop()
Common Mistake
Using the Wrong Loop Range
Using range(0, n) in a combination problem produces permutations. Using range(start, n) in a permutation problem produces subsets.
Common Mistake
Passing i+1 When Reuse Is Allowed
In Combination Sum, each number can be reused. Passing i+1 prevents reuse. Pass i for reuse, i+1 for single use.
Common Mistake
Skipping Duplicates Without Sorting
The skip condition nums[i] == nums[i-1] assumes duplicates are adjacent. Without sorting, non-adjacent duplicates like [2,1,2] are missed.

Time Complexity Analysis

ProblemTimeSpaceResult Count
SubsetsO(n * 2^n)O(n)2^n
Combinations C(n,k)O(k * C(n,k))O(k)C(n,k)
PermutationsO(n * n!)O(n)n!
Combination SumO(2^t) where t = target/minO(t)Varies
N-QueensO(n!)O(n)Varies
Word SearchO(m * n * 3^L)O(L)Boolean
Palindrome PartitioningO(n * 2^n)O(n)Up to 2^n

General formula: total work = (number of nodes) x (work per node). For subsets: 2^n nodes, O(n) per node to copy the path, total O(n * 2^n). For permutations: n! leaves, each copies a path of length n, total O(n * n!). The recursion stack depth equals the tree height, at most n.

Pruning reduces the constant factor but does not change worst case asymptotics. N-Queens pruning makes practical runtime much better than n^n, but the worst case remains O(n!).

Interview Tip
State the complexity, then explain why it is unavoidable
"Generating all subsets takes O(n * 2^n) because there are 2^n subsets and each costs O(n) to copy. This is optimal because you cannot produce 2^n outputs of size up to n in less time." This is output-sensitive complexity: the lower bound matches the output size.

Practice Problems

Subsets / Combinations

ProblemDifficultyApproach
SubsetsMediumCollect at every node, loop from start, pass i+1
Subsets IIMediumSort + skip when i > start and nums[i] == nums[i-1]
CombinationsMediumCollect when len(path) == k. Prune when not enough elements remain.
Combination SumMediumReuse allowed: pass i. Sort + break when candidate > remaining.
Combination Sum IIMediumNo reuse (pass i+1) + sort + skip for duplicates.
Combination Sum IIIMediumFixed range [1,9], k numbers summing to n. Prune on both count and sum.

Permutations

ProblemDifficultyApproach
PermutationsMediumLoop from 0, used[] array, collect at depth n
Permutations IIMediumSort + skip with not used[i-1] for duplicate handling
Next PermutationMediumNot backtracking! Find rightmost ascent, swap, reverse suffix.

Constraint Satisfaction

ProblemDifficultyApproach
N-QueensHardRow by row placement with column/diagonal/anti-diagonal sets
Sudoku SolverHardFill cells left to right. Three constraint sets: rows, cols, boxes.
Word SearchMediumGrid backtracking with cell marking/restoration

String Partitioning

ProblemDifficultyApproach
Palindrome PartitioningMediumTry every prefix as a palindrome. Collect when entire string consumed.
Restore IP AddressesMediumPartition into 4 octets (1-3 digits, 0-255, no leading zeros)
Generate ParenthesesMediumTwo branches: open (if open < n) or close (if close < open)

Advanced

ProblemDifficultyApproach
Word Search IIHardTrie + grid backtracking. Trie prunes impossible prefixes.
Partition to K Equal Sum SubsetsMediumAssign elements to k buckets. Sort descending for early pruning.
Matchsticks to SquareMediumSame as partition to 4 equal subsets. Prune on side length.

Reference Templates

Every backtracking enumeration problem is a controlled modification of the subsets template. Subsets collects at every node, uses a start index, and passes i+1. Changing one or two of those choices produces every other variant.

Collect at every node, start index: any subset size is valid.

Subsets
1def subsets(nums):
2 result = []
3 def backtrack(start, path):
4 result.append(path[:]) # collect at every node
5 for i in range(start, len(nums)):
6 path.append(nums[i])
7 backtrack(i + 1, path) # i+1: no reuse
8 path.pop()
9 backtrack(0, [])
10 return result

Collect at depth k only, all results have the same length.

Combinations
1def combine(n, k):
2 result = []
3 def backtrack(start, path):
4 if len(path) == k:
5 result.append(path[:])
6 return # stop going deeper
7 for i in range(start, n + 1):
8 path.append(i)
9 backtrack(i + 1, path)
10 path.pop()
11 backtrack(1, [])
12 return result

Collect at depth n, loop from 0, used[] prevents reuse in the same path.

Permutations
1def permute(nums):
2 result = []
3 used = [False] * len(nums)
4 def backtrack(path):
5 if len(path) == len(nums):
6 result.append(path[:])
7 return
8 for i in range(len(nums)):
9 if used[i]: continue
10 used[i] = True
11 path.append(nums[i])
12 backtrack(path)
13 path.pop()
14 used[i] = False
15 backtrack([])
16 return result

Pass i (not i+1) for reuse. Sort + break when overshoot is provable.

Combination Sum (reuse allowed)
1def combinationSum(candidates, target):
2 candidates.sort()
3 result = []
4 def backtrack(start, path, remaining):
5 if remaining == 0:
6 result.append(path[:])
7 return
8 for i in range(start, len(candidates)):
9 if candidates[i] > remaining:
10 break # sorted: all subsequent also fail
11 path.append(candidates[i])
12 backtrack(i, path, remaining - candidates[i])
13 path.pop()
14 backtrack(0, [], target)
15 return result

Sort + skip when i > start and adjacent values match. Skips duplicates at the same level only.

Subsets II (duplicates in input)
1def subsetsWithDup(nums):
2 nums.sort()
3 result = []
4 def backtrack(start, path):
5 result.append(path[:])
6 for i in range(start, len(nums)):
7 if i > start and nums[i] == nums[i - 1]:
8 continue # skip duplicate at same level
9 path.append(nums[i])
10 backtrack(i + 1, path)
11 path.pop()
12 backtrack(0, [])
13 return result

Sort + skip when not used[i-1]. Enforces index order for identical values.

Permutations II (duplicates in input)
1def permuteUnique(nums):
2 nums.sort()
3 result = []
4 used = [False] * len(nums)
5 def backtrack(path):
6 if len(path) == len(nums):
7 result.append(path[:])
8 return
9 for i in range(len(nums)):
10 if used[i]: continue
11 if i > 0 and nums[i] == nums[i-1] and not used[i-1]:
12 continue # enforce index order
13 used[i] = True
14 path.append(nums[i])
15 backtrack(path)
16 path.pop()
17 used[i] = False
18 backtrack([])
19 return result

In-place mark and restore. board[r][c] = '#' acts like path.append.

Grid Backtracking
1def backtrack(r, c, idx):
2 if idx == len(word):
3 return True
4 if r < 0 or r >= rows or c < 0 or c >= cols or board[r][c] != word[idx]:
5 return False
6 temp = board[r][c]
7 board[r][c] = '#' # choose: mark visited
8 found = (backtrack(r+1,c,idx+1) or backtrack(r-1,c,idx+1) or
9 backtrack(r,c+1,idx+1) or backtrack(r,c-1,idx+1))
10 board[r][c] = temp # unchoose: restore
11 return found

Row-by-row, O(1) conflict sets replace O(n) board scanning.

Constraint Satisfaction (N-Queens)
1def backtrack(row):
2 if row == n:
3 result.append([''.join(r) for r in board])
4 return
5 for col in range(n):
6 if col in cols or (row-col) in diags or (row+col) in anti_diags:
7 continue # O(1) conflict check
8 cols.add(col); diags.add(row-col); anti_diags.add(row+col)
9 board[row][col] = 'Q'
10 backtrack(row + 1)
11 cols.remove(col); diags.remove(row-col); anti_diags.remove(row+col)
12 board[row][col] = '.'

Pattern Recap

  • Subsets collect at every node. Combinations collect at depth k. Permutations collect at depth n.
  • A start index prevents reordered duplicates by restricting the loop to elements after the current position. It is correct for subsets and combinations where order does not matter.
  • A used[] array allows all orderings. It is required for permutations where [2,1] and [1,2] are distinct valid results.
  • Passing i+1 prevents reuse of the current element. Passing i allows reuse. Combination Sum uses i.
  • The sort-plus-skip dedup works because identical elements are adjacent after sorting. i > start restricts the skip to the current recursion level only.
  • path.pop() restores the mutable path list. board[r][c] = temp restores the mutable grid. Without restoration, sibling branches inherit state from previous branches.
  • Pruning is safe only with a proof that no valid solution lies below the current branch.

Rebuilding the Pattern

When a backtracking problem feels unclear, start from the subsets template and answer three questions. Does the result get collected at every node, or only at a certain depth? Does the loop start at a fixed 0, or at a start parameter that advances? Does the recursive call pass i (reuse the current element) or i+1 (move past it)? Those three answers determine the loop range, the base case, and the recursive argument. Every standard enumeration variant is defined by how it answers those three questions.

The duplicate question comes next. If the input can contain identical values and the output should not repeat, sort the input first. For combination-style problems, add if i > start and nums[i] == nums[i-1]: continue. For permutation-style problems, add if i > 0 and nums[i] == nums[i-1] and not used[i-1]: continue. The condition is asymmetric because i > start works only when there is a start index. Permutations loop from 0 and use the used[] array to distinguish which duplicate occurrence is currently in the path.

The pruning question is about safety. A pruning cut removes an entire subtree. It is safe only when you can prove that no valid solution exists below the current partial state. The two valid proofs are: a constraint is already violated and cannot be repaired by future choices, or the remaining elements cannot bring the partial solution into a valid state even in the best case. If neither proof holds, skip the prune and accept the cost.

The state question applies to every mutation. Every change to shared state before the recursive call must be exactly reversed after it. A mutable path needs pop. A mutable grid cell needs value restoration. A used[] flag needs to be reset to False. A set membership needs removal. The symmetry is not optional. Without it, sibling branches inherit choices from previous branches and produce incorrect results.

Check Your Understanding

Use these questions to check whether you can reason through the pattern without looking at the template.

Take Assessment

12 multiple-choice questions covering choose/explore/unchoose, duplicate handling, pruning, and common bugs. Each question includes an explanation of the correct answer.

Backtracking Assessment

Test your reasoning on 12 questions. Explanations are shown after each answer.

Practice this pattern

Apply the guide to complete interview problems with explanations and code.

Learn Backtracking in a guided sequence

The Interview Course connects this pattern to its prerequisites, worked lessons, and progressively harder problems.

Explore the course