important mid · part of Skills & Topics · Senior SWE Roadmap · related: Dynamic Programming · Greedy Algorithms · Arrays & Strings · DFS
Why this matters / recognition signals
Backtracking is depth-first search over a tree of choices, where each branch is explored fully before being undone and the next branch tried. It’s how you enumerate — exhaustively but not wastefully — every valid combination, permutation, or arrangement that satisfies a set of constraints, when there’s no formula or greedy rule that shortcuts straight to the answer.
Recognition signals — reach for backtracking when the problem says:
- “generate all subsets / permutations / combinations”
- “find all valid ways to place/arrange X subject to constraints” (N-Queens, Sudoku)
- “does a path/word exist in this grid/graph following adjacency rules” (Word Search)
- The search space is a sequence of decisions, each decision narrows what’s still valid, and an invalid partial choice can be detected before completing it (this last part is what separates backtracking from plain brute-force enumeration — see pruning below)
Core mechanism: choose → explore → un-choose
Every backtracking solution follows the same four-part shape, visible directly in the subsets implementations below: a base/completion check (record the partial solution once it’s valid or complete), a loop over the choices available at this point, a validity/pruning check before committing to each choice, and — critically — undoing that choice after the recursive call returns, before the loop moves to the next sibling choice.
The “undo” step is what makes this backtracking rather than plain forward-only recursion — without it, sibling branches would see contamination from a previous branch’s choices, because most implementations mutate one shared partial-solution structure in place rather than copying it at each level (copying works too, but costs more and is why “add then remove” is the idiomatic form).
Recursion tree: subsets of [1, 2, 3]
Each node is a decision point (“include this element or not”); every root-to-node path is a valid subset in progress.
flowchart TD Root["[ ]"] -->|include 1| N1["[1]"] Root -->|skip 1| N2["[ ]"] N1 -->|include 2| N11["[1,2]"] N1 -->|skip 2| N12["[1]"] N2 -->|include 2| N21["[2]"] N2 -->|skip 2| N22["[ ]"] N11 -->|include 3| N111["[1,2,3]"] N11 -->|skip 3| N112["[1,2]"] N12 -->|include 3| N121["[1,3]"] N12 -->|skip 3| N122["[1]"] N21 -->|include 3| N211["[2,3]"] N21 -->|skip 3| N212["[2]"] N22 -->|include 3| N221["[3]"] N22 -->|skip 3| N222["[ ]"] style N111 fill:#5a3,color:#000 style N112 fill:#5a3,color:#000 style N121 fill:#5a3,color:#000 style N122 fill:#5a3,color:#000 style N211 fill:#5a3,color:#000 style N212 fill:#5a3,color:#000 style N221 fill:#5a3,color:#000 style N222 fill:#5a3,color:#000
Every leaf (green) is a distinct subset — 2^3 = 8 of them, one per root-to-leaf path, since each of the 3 elements independently is “in or out.” This is pure enumeration with no pruning: the whole tree is visited because every leaf is a valid answer.
Pruning: what turns exponential enumeration into something practical
Pure enumeration visits every leaf of the full tree. Pruning cuts off a branch the moment a partial choice is provably invalid — before wasting time completing it. N-Queens is the clean example: placing queen k in a column that shares a row, column, or diagonal with any already-placed queen can never lead to a valid solution, so that whole subtree (every arrangement of queens k+1..n under that placement) is skipped without ever being visited.
flowchart TD Root["place queen 1 (4 columns to try)"] --> A["col 0"] Root --> B["col 1"] Root --> C["col 2"] Root --> D["col 3"] A --> A1["queen2: col0 same-col, invalid — PRUNED"] A --> A2["queen2: col1 diagonal, invalid — PRUNED"] A --> A3["queen2: col2 valid — recurse deeper"] A --> A4["queen2: col3 diagonal, invalid — PRUNED"] style A1 fill:#933,color:#fff style A2 fill:#933,color:#fff style A4 fill:#933,color:#fff style A3 fill:#5a3,color:#000
The constraint check (row/column/diagonal occupied?) is what makes N-Queens tractable at all for the classic interview sizes (n=4 to n=8) — without it, the search would explore n^n placements; with row-by-row placement alone it drops to n!; with full pruning of column and diagonal conflicts as you go, the vast majority of that n! is cut off before ever being generated.
Worked numeric trace: N-Queens, n = 4
State: queens[row] = column of the queen placed in that row. Try columns left to right at each row; skip (prune) any column that conflicts with an already-placed queen.
| Row | Try col | Conflict check vs placed queens | Valid? | Action |
|---|---|---|---|---|
| 0 | 0 | none placed yet | yes | place queens[0]=0 |
| 1 | 0 | same column as row 0 | no | prune |
| 1 | 1 | diagonal with row 0 (col diff 1 = row diff 1) | no | prune |
| 1 | 2 | col diff 2, row diff 1 — safe | yes | place queens[1]=2 |
| 2 | 0 | diagonal with row1(col2)? diff2 vs diff1, no; vs row0(col0): diff0, row diff2 — same col! | no | prune |
| 2 | 1 | vs row0(col0): diagonal (diff1=diff2? no, diff1,rowdiff2 safe); vs row1(col2): diagonal (diff1,rowdiff1) — conflict | no | prune |
| 2 | 2 | same column as row1 | no | prune |
| 2 | 3 | vs row0(col0): diff3,rowdiff2 safe; vs row1(col2): diff1,rowdiff1 — conflict | no | prune |
| 2 | — | all 4 columns pruned at row 2 | — | backtrack to row 1 |
| 1 | 3 | vs row0(col0): diff3, rowdiff1 — safe | yes | place queens[1]=3 |
| 2 | 1 | vs row0(col0): diff1,rowdiff2 safe; vs row1(col3): diff2,rowdiff1 safe | yes | place queens[2]=1 |
| 3 | … | (continue similarly) | yes | queens[3]=… completes a solution |
The key moment is the “all 4 columns pruned at row 2” row: rather than continuing to place queens in rows 3 with an already-invalid row-2 placement (which pure enumeration would do, wasting an entire subtree), backtracking abandons row 2 immediately, undoes queens[1]=2, and retries row 1 with the next column. That single early abandonment is what pruning buys — the invalid subtree under queens[1]=2 is never explored past row 2.
Java — backtracking template (Subsets) and pruning example (N-Queens count)
import java.util.ArrayList;
import java.util.List;
public class BacktrackingExamples {
// Pure enumeration: all subsets of nums.
public List<List<Integer>> subsets(int[] nums) {
List<List<Integer>> result = new ArrayList<>();
backtrack(nums, 0, new ArrayList<>(), result);
return result;
}
private void backtrack(int[] nums, int start, List<Integer> partial, List<List<Integer>> result) {
result.add(new ArrayList<>(partial)); // every partial state along the way is a valid subset
for (int i = start; i < nums.length; i++) {
partial.add(nums[i]); // choose
backtrack(nums, i + 1, partial, result); // explore
partial.remove(partial.size() - 1); // un-choose (backtrack)
}
}
// Enumeration + pruning: count valid N-Queens placements.
public int totalNQueens(int n) {
int[] queens = new int[n]; // queens[row] = column
return solve(queens, 0, n);
}
private int solve(int[] queens, int row, int n) {
if (row == n) {
return 1; // completed a valid full placement
}
int count = 0;
for (int col = 0; col < n; col++) {
if (isSafe(queens, row, col)) { // pruning check — skip invalid branches entirely
queens[row] = col; // choose
count += solve(queens, row + 1, n); // explore
// no explicit undo needed: queens[row] is overwritten on the next iteration
}
}
return count;
}
private boolean isSafe(int[] queens, int row, int col) {
for (int r = 0; r < row; r++) {
int c = queens[r];
if (c == col || Math.abs(c - col) == Math.abs(r - row)) {
return false; // same column, or same diagonal
}
}
return true;
}
}Python — backtracking template (Subsets) and pruning example (N-Queens count)
def subsets(nums: list[int]) -> list[list[int]]:
result: list[list[int]] = []
partial: list[int] = []
def backtrack(start: int) -> None:
result.append(partial.copy()) # every partial state is a valid subset
for i in range(start, len(nums)):
partial.append(nums[i]) # choose
backtrack(i + 1) # explore
partial.pop() # un-choose (backtrack)
backtrack(0)
return result
def total_n_queens(n: int) -> int:
queens = [-1] * n # queens[row] = column
def is_safe(row: int, col: int) -> bool:
for r in range(row):
c = queens[r]
if c == col or abs(c - col) == abs(r - row):
return False # same column, or same diagonal
return True
def solve(row: int) -> int:
if row == n:
return 1 # completed a valid full placement
count = 0
for col in range(n):
if is_safe(row, col): # pruning check
queens[row] = col # choose
count += solve(row + 1) # explore
# no explicit undo needed: queens[row] is overwritten next iteration
return count
return solve(0)A second pruning example: Word Search
N-Queens prunes on a global constraint (no shared row/column/diagonal across all placed queens so far). Word Search prunes differently — on local adjacency and a visited set — which is worth seeing explicitly because it’s the more common shape of pruning in grid/graph backtracking problems: does the next cell match the next required character, and has this cell already been used earlier in the current path?
public class WordSearch {
public boolean exist(char[][] board, String word) {
int rows = board.length, cols = board[0].length;
for (int r = 0; r < rows; r++) {
for (int c = 0; c < cols; c++) {
if (dfs(board, word, r, c, 0)) {
return true;
}
}
}
return false;
}
private boolean dfs(char[][] board, String word, int r, int c, int idx) {
if (idx == word.length()) {
return true; // matched every character
}
if (r < 0 || r >= board.length || c < 0 || c >= board[0].length
|| board[r][c] != word.charAt(idx)) {
return false; // out of bounds, or character mismatch — prune
}
char original = board[r][c];
board[r][c] = '#'; // mark visited in place (choose)
boolean found = dfs(board, word, r + 1, c, idx + 1)
|| dfs(board, word, r - 1, c, idx + 1)
|| dfs(board, word, r, c + 1, idx + 1)
|| dfs(board, word, r, c - 1, idx + 1);
board[r][c] = original; // un-mark before returning (backtrack)
return found;
}
}def exist(board: list[list[str]], word: str) -> bool:
rows, cols = len(board), len(board[0])
def dfs(r: int, c: int, idx: int) -> bool:
if idx == len(word):
return True # matched every character
if r < 0 or r >= rows or c < 0 or c >= cols or board[r][c] != word[idx]:
return False # out of bounds, or character mismatch — prune
original = board[r][c]
board[r][c] = "#" # mark visited in place (choose)
found = (
dfs(r + 1, c, idx + 1)
or dfs(r - 1, c, idx + 1)
or dfs(r, c + 1, idx + 1)
or dfs(r, c - 1, idx + 1)
)
board[r][c] = original # un-mark before returning (backtrack)
return found
return any(dfs(r, c, 0) for r in range(rows) for c in range(cols))The '#' mark-and-restore is doing exactly the same job as queens[row] = col being overwritten and partial.pop() in the earlier examples — record a choice, recurse, then leave the shared structure exactly as the caller found it. The mismatch check (board[r][c] != word.charAt(idx)) is the pruning: a branch dies the instant a single character fails to match, rather than continuing to explore all four directions from a cell that can never complete the word.
Complexity comparison
| Approach | Time | Space | Notes |
|---|---|---|---|
| Pure enumeration, no pruning (Subsets) | O(2^n · n) | O(n) recursion depth + O(2^n · n) output | Every subset must be generated and copied out — this is the theoretical minimum, not overhead to remove |
| Backtracking with pruning (N-Queens) | O(n!) worst case, far less in practice | O(n) for the queens array + recursion depth | Pruning cuts subtrees before generating them — actual runtime is usually orders of magnitude below the n! bound |
| Backtracking with pruning (Word Search) | O(rows·cols·4^L) where L = word length | O(L) recursion depth | Bounded branching factor (4 directions) × early-exit on mismatch, not full grid enumeration |
| Brute force without early termination | Same asymptotic bound as backtracking, but no early exit | Same | The asymptotic worst case is often identical to backtracking’s — pruning’s win is practical constant-factor and average-case, not a better Big-O in the adversarial worst case |
Common pitfalls
- Forgetting to un-choose (the “backtrack” step): mutating a shared list/array/board in place and forgetting to remove the last choice before the next sibling iteration leaves state contamination — sibling branches silently see choices from a previous branch that should have been undone. This is the single most common backtracking bug.
- Copying the partial solution instead of mutating + undoing, without realizing the cost:
result.add(new ArrayList<>(partial))(copy at the leaf/record point) is fine and necessary — Java lists are mutable references, so storingpartialitself instead of a copy means every stored “subset” ends up pointing to the same list, which later mutates out from under all of them. - Pruning too late: checking validity only once a complete candidate is built (generate-then-filter) throws away all of pruning’s benefit — the check has to happen before recursing deeper, not after the leaf is reached, otherwise the algorithm degrades to the pure-enumeration bound.
- Off-by-one in the “start index” for combinations/subsets: using
startinstead ofstart + 1in the recursive call (or vice versa) is the difference between generating combinations without repetition and permitting a element to be reused — a classic source of duplicate or missing results. - Not handling duplicate input values: Subsets II / Permutations II style problems (duplicate elements in the input) need an explicit “skip this branch if it’s the same value as the previous sibling at this level” check, or duplicate results get emitted despite the algorithm being otherwise correct.
- Confusing “no solution found” with “all pruned”: in constraint problems (N-Queens, Sudoku), a
false/empty-result outcome after full exploration is a legitimate valid answer for some inputs (e.g. N-Queens has no solution for n=2 or n=3) — don’t assume a bug just because backtracking returns nothing.
Practice problems
- Subsets (pure enumeration template, worked above)
- Permutations (pure enumeration, “used” tracking instead of a start index since order matters)
- Combination Sum (enumeration + a numeric pruning bound — stop a branch once the running sum exceeds the target)
- N-Queens / N-Queens II (enumeration + row/column/diagonal constraint pruning, worked above)
- Word Search (enumeration + grid-adjacency pruning + visited-cell tracking with explicit un-mark on backtrack)
- Sudoku Solver (enumeration + row/column/box constraint pruning — the natural “next level up” from N-Queens)
- Palindrome Partitioning (enumeration + a validity pruning check per candidate substring)
Interview angles
- “What’s the difference between backtracking and plain DFS?” — DFS explores a graph/tree that already exists; backtracking constructs a solution incrementally and explicitly undoes choices to reuse shared state across branches — the “un-choose” step is the defining feature DFS over a static graph doesn’t need.
- “How would you speed this up?” — name pruning specifically: identify the earliest point in the decision sequence where a partial choice can be proven invalid, and move the validity check there instead of checking only at completed leaves.
- “What’s the actual worst-case time complexity, and does pruning change it?” — be honest that pruning improves practical/average-case runtime enormously but the asymptotic worst case (e.g. N-Queens’ O(n!) bound) is often unchanged, since an adversarial input can still force near-full exploration.
- “Backtracking vs DP — when would you use which here?” — backtracking enumerates all valid solutions (or existence); DP is for counting or optimizing over solutions without enumerating each one explicitly — if the problem only asks “how many” or “what’s the best,” and subproblems overlap, DP is usually the better fit even when a backtracking solution also exists (see Dynamic Programming).
- “How do you avoid duplicate results when the input has duplicate values?” — sort the input first, then at each recursion level skip a candidate if it equals the previous sibling candidate that was already fully explored at that same level.
- “Can this be done iteratively instead of recursively?” — yes, with an explicit stack holding the same state backtracking’s call stack would hold (partial solution + next choice index) — useful to mention if recursion depth is a concern (e.g. very large n), though it’s rarely asked for in practice over the recursive form.