Backtracking
Try something. If it doesn't work, undo it and try something else. Backtracking explores every possible solution by making a choice, recursing deeper, then reversing the choice — abandoning a path the moment it can't lead to a valid answer.
Choose, explore, unchoose
Every backtracking algorithm walks a decision tree. At each node you choose an option, explore (recurse) with that choice in place, then unchoose — restore the state exactly as it was — before trying the next option.
When to reach for it
| Signal in problem | Example problem |
|---|---|
| "Generate ALL permutations / combinations" | Permutations, Subsets |
| "Find ALL valid configurations" | N-Queens, Sudoku |
| "All possible paths / ways" | Word Search, Palindrome Partitioning |
| "Constraint satisfaction" | Sudoku, Crossword |
| Input size is SMALL (n ≤ ~15–20) | Most backtracking problems |
Backtracking vs. DFS vs. DP
| Backtracking | DFS | DP |
|---|---|---|
| Generate ALL valid solutions | Explore a structure (tree, graph) | Find OPTIMAL solution with overlapping subproblems |
| Explicit UNDO step (backtrack) | May not need undo | Stores results (memo / table) |
| Exponential time | O(V + E) typically | Polynomial (often) |
One template, four questions
Every backtracking problem follows the Choose → Explore → Unchoose skeleton:
def backtrack(candidates, path, result, start):
# BASE CASE: when is a solution complete?
if is_complete(path):
result.append(path[:]) # IMPORTANT: append a COPY
return
for i in range(start, len(candidates)):
# PRUNE: skip invalid choices early
if not is_valid(candidates[i], path):
continue
# CHOOSE
path.append(candidates[i])
# EXPLORE (recurse)
backtrack(candidates, path, result, i + 1) # i+1 or i depending on reuse
# UNCHOOSE (backtrack)
path.pop()
- CHOICES — what options do I have at each step? (elements to include, positions to place)
- CONSTRAINTS — when should I prune? (duplicates, exceeded target, invalid state)
- GOAL — when is a solution complete? (path length = n, target reached, board full)
- UNDO — how do I reverse a choice? (
path.pop(), unmark visited, restore cell)
Handling duplicate values
When the input has duplicate values and you need unique results: sort first, then skip consecutive duplicates at the same decision level.
nums.sort() # MUST sort first
def backtrack(start, path):
result.append(path[:])
for i in range(start, len(nums)):
# Skip duplicate at same decision level
if i > start and nums[i] == nums[i - 1]:
continue
path.append(nums[i])
backtrack(i + 1, path)
path.pop()
Which branches get pruned for [1, 2, 2]:
The condition i > start is the subtle part: it allows the first occurrence
of a value at each level (going deeper) but blocks the second occurrence at the same level
(a sibling branch that would rebuild identical subsets).
The canonical problems
LC 78Subsets — include/exclude
def subsets(nums):
result = []
def backtrack(start, path):
result.append(path[:]) # every 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
# Result for [1,2,3]:
# [], [1], [1,2], [1,2,3], [1,3], [2], [2,3], [3] — 2^3 = 8 subsets
LC 90Subsets II — with duplicates
def subsetsWithDup(nums):
nums.sort()
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 at same level
path.append(nums[i])
backtrack(i + 1, path)
path.pop()
backtrack(0, [])
return result
LC 46Permutations
def permute(nums):
result = []
def backtrack(path, remaining):
if not remaining:
result.append(path[:])
return
for i in range(len(remaining)):
path.append(remaining[i])
backtrack(path, remaining[:i] + remaining[i+1:])
path.pop()
backtrack([], nums)
return result
The choices shrink at each level — pick one, recurse on what's left:
LC 47Permutations II — with duplicates
def permuteUnique(nums):
nums.sort()
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: same value, previous wasn't used at this level
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
LC 39Combination Sum — reuse allowed
def combinationSum(candidates, target):
result = []
def backtrack(start, path, remaining):
if remaining == 0:
result.append(path[:])
return
if remaining < 0:
return # PRUNE
for i in range(start, len(candidates)):
path.append(candidates[i])
backtrack(i, path, remaining - candidates[i]) # i, NOT i+1 (reuse!)
path.pop()
backtrack(0, [], target)
return result
LC 40Combination Sum II — no reuse, skip duplicates
def combinationSum2(candidates, target):
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 # PRUNE: sorted, so all future too large
if i > start and candidates[i] == candidates[i - 1]:
continue # skip duplicates
path.append(candidates[i])
backtrack(i + 1, path, remaining - candidates[i])
path.pop()
backtrack(0, [], target)
return result
LC 22Generate Parentheses — constraint counting
def generateParenthesis(n):
result = []
def backtrack(path, open_count, close_count):
if len(path) == 2 * n:
result.append("".join(path))
return
if open_count < n: # can still add '('
path.append("(")
backtrack(path, open_count + 1, close_count)
path.pop()
if close_count < open_count: # can add ')' only if open > close
path.append(")")
backtrack(path, open_count, close_count + 1)
path.pop()
backtrack([], 0, 0)
return result
open_count < n → can add '('.
close_count < open_count → can add ')'.
For n = 2: after "((" only ')' is allowed; after "()"
only '(' is. The valid strings "(())" and "()()" fall out of the constraints —
no validity check needed at the end.
LC 51N-Queens — board constraint sets
def solveNQueens(n):
result = []
cols = set() # columns with queens
diag1 = set() # row - col (anti-diagonal)
diag2 = set() # row + col (main diagonal)
def backtrack(row, board):
if row == n:
result.append(["".join(r) for r in board])
return
for col in range(n):
if col in cols or (row - col) in diag1 or (row + col) in diag2:
continue # PRUNE: conflict
# CHOOSE
board[row][col] = "Q"
cols.add(col)
diag1.add(row - col)
diag2.add(row + col)
backtrack(row + 1, board)
# UNCHOOSE
board[row][col] = "."
cols.remove(col)
diag1.remove(row - col)
diag2.remove(row + col)
board = [["." for _ in range(n)] for _ in range(n)]
backtrack(0, board)
return result
One solution to 4-Queens, with the constraint sets tracked per placement:
Three O(1) set lookups replace scanning the whole board for conflicts — one queen per row is implicit (we recurse row by row), so only columns and the two diagonals need checking.
LC 79Word Search — grid + visited marking
def exist(board, word):
rows, cols = len(board), len(board[0])
def backtrack(r, c, idx):
if idx == len(word):
return True
if (r < 0 or r >= rows or c < 0 or c >= cols
or board[r][c] != word[idx]):
return False
# CHOOSE: mark visited
temp = board[r][c]
board[r][c] = "#"
# EXPLORE all 4 directions
found = (backtrack(r + 1, c, idx + 1) or
backtrack(r - 1, c, idx + 1) or
backtrack(r, c + 1, idx + 1) or
backtrack(r, c - 1, idx + 1))
# UNCHOOSE: restore cell
board[r][c] = temp
return found
for r in range(rows):
for c in range(cols):
if backtrack(r, c, 0):
return True
return False
LC 17Letter Combinations of a Phone Number
def letterCombinations(digits):
if not digits:
return []
phone = {
"2": "abc", "3": "def", "4": "ghi", "5": "jkl",
"6": "mno", "7": "pqrs", "8": "tuv", "9": "wxyz"
}
result = []
def backtrack(idx, path):
if idx == len(digits):
result.append("".join(path))
return
for ch in phone[digits[idx]]:
path.append(ch)
backtrack(idx + 1, path)
path.pop()
backtrack(0, [])
return result
Complexity reference
| Problem | Time | Space | Notes |
|---|---|---|---|
| Subsets | O(n · 2ⁿ) | O(n) | 2ⁿ subsets, copy costs O(n) |
| Subsets II | O(n · 2ⁿ) | O(n) | With pruning |
| Permutations | O(n · n!) | O(n) | n! permutations |
| Permutations II | O(n · n!) | O(n) | With pruning |
| Combination Sum | O(n^(T/M)) | O(T/M) | T = target, M = min candidate |
| Generate Parentheses | O(4ⁿ / √n) | O(n) | Catalan number |
| N-Queens | O(n!) | O(n) | Constraint pruning helps |
| Word Search | O(m·n · 3ᴸ) | O(L) | L = word length, 3 directions |
| Letter Combinations | O(4ⁿ) | O(n) | n = digits length |
Where backtracking goes wrong
result.append(path[:]) NOT result.append(path).
path is a reference that keeps mutating as recursion continues —
without the copy you end up with a list of empty lists.
path.append() needs a matching path.pop().
Every visited.add() needs a matching visited.remove().
An unbalanced choose/unchoose corrupts every sibling branch that follows.
i + 1 (no reuse).
Combination Sum (reuse allowed): recurse with i (same element again).
Permutations: always start at 0, but skip used elements.
nums[i] == nums[i-1] only works when the array is
sorted first — equal values must be adjacent for the comparison to see them.
"#", you MUST restore them on
backtrack — otherwise other starting positions see a vandalized board.
Practice ladder
In this order — easiest first. Tick each one as you solve it, and re-solve anything that took more than 25 minutes two days later.