Backtracking
Interviewer cue: “generate all …”, “find every way to …”, or “place these pieces so that no two conflict” — backtracking is depth-first search over a decision tree of partial choices, where you undo a choice the moment it stops being viable and try the next one instead.
What you’ll learn
- The choose / explore / unchoose framework — three lines that show up in almost every backtracking solution.
- How a backtracking algorithm maps directly onto a decision tree.
- Pruning: skipping entire branches early instead of exploring them and failing later.
- Runnable Permutations and N-Queens solutions built on the same template.
The pattern
def permutations(nums):
results = []
path = []
used = [False] * len(nums)
def backtrack():
if len(path) == len(nums):
results.append(path[:]) # snapshot -- path keeps mutating after this
return
for i, num in enumerate(nums):
if used[i]:
continue
path.append(num) # 1. CHOOSE
used[i] = True
backtrack() # 2. EXPLORE
path.pop() # 3. UNCHOOSE
used[i] = False
backtrack()
return results
print(permutations([1, 2, 3]))def permutations(nums):
results = []
path = []
used = [False] * len(nums)
def backtrack():
if len(path) == len(nums):
results.append(path[:]) # snapshot -- path keeps mutating after this
return
for i, num in enumerate(nums):
if used[i]:
continue
path.append(num) # 1. CHOOSE
used[i] = True
backtrack() # 2. EXPLORE
path.pop() # 3. UNCHOOSE
used[i] = False
backtrack()
return results
print(permutations([1, 2, 3]))Three steps, every time: choose an option and record it, explore by recursing one level deeper, then unchoose — undo that exact choice so the next iteration of the loop starts from a clean slate.
How it works: the decision tree
graph TD
R["[]"] --> A["[1]"]
R --> B["[2]"]
R --> C["[3]"]
A --> A1["[1, 2]"]
A --> A2["[1, 3]"]
B --> B1["[2, 1]"]
B --> B2["[2, 3]"]
C --> C1["[3, 1]"]
C --> C2["[3, 2]"]
A1 --> A1a["[1, 2, 3]"]
B1 --> B1a["[2, 1, 3]"]
C1 --> C1a["[3, 1, 2]"]
Every edge downward is a choose. When a branch bottoms out (a full permutation) or runs out of valid options, the algorithm walks back up — that’s the unchoose — and tries the next sibling edge. The full tree for 3 elements has leaves; this diagram shows enough of it to see the shape.
Worked example: N-Queens
Place nn queens on an n x nn x n board so no two attack each other — no
shared row, column, or diagonal. Track attacked columns and diagonals with
sets so each placement check is , pruning invalid branches instantly
instead of discovering the conflict later.
def solve_n_queens(n):
solutions = []
cols = set()
diag1 = set() # r - c is constant along a "/" diagonal
diag2 = set() # r + c is constant along a "\" diagonal
board = [] # board[r] = column index of the queen placed in row r
def backtrack(row):
if row == n:
solutions.append(board[:])
return
for col in range(n):
if col in cols or (row - col) in diag1 or (row + col) in diag2:
continue # PRUNE: this column is attacked, skip it
cols.add(col); diag1.add(row - col); diag2.add(row + col)
board.append(col) # 1. CHOOSE
backtrack(row + 1) # 2. EXPLORE
board.pop() # 3. UNCHOOSE
cols.remove(col); diag1.remove(row - col); diag2.remove(row + col)
backtrack(0)
return solutions
solutions = solve_n_queens(4)
print("solutions for 4-queens:", len(solutions))
print("first solution (column per row):", solutions[0])def solve_n_queens(n):
solutions = []
cols = set()
diag1 = set() # r - c is constant along a "/" diagonal
diag2 = set() # r + c is constant along a "\" diagonal
board = [] # board[r] = column index of the queen placed in row r
def backtrack(row):
if row == n:
solutions.append(board[:])
return
for col in range(n):
if col in cols or (row - col) in diag1 or (row + col) in diag2:
continue # PRUNE: this column is attacked, skip it
cols.add(col); diag1.add(row - col); diag2.add(row + col)
board.append(col) # 1. CHOOSE
backtrack(row + 1) # 2. EXPLORE
board.pop() # 3. UNCHOOSE
cols.remove(col); diag1.remove(row - col); diag2.remove(row + col)
backtrack(0)
return solutions
solutions = solve_n_queens(4)
print("solutions for 4-queens:", len(solutions))
print("first solution (column per row):", solutions[0])Complexity
Backtracking explores a decision tree that’s exponential in the worst case — for permutations, roughly before pruning for N-Queens. Pruning doesn’t change the worst-case bound, but it’s what makes these algorithms fast enough to actually run: N-Queens with column/diagonal sets finishes instantly for boards where a naive “place all, then check” version would time out.
When to use it
- “Generate all …” / “find every way to …” — permutations, combinations, subsets (next lesson).
- Constraint satisfaction — N-Queens, Sudoku Solver: place pieces one at a time, backing out the moment a constraint is violated.
- Path search with undo — Word Search: mark a grid cell visited while exploring from it, then un-mark it before trying a different path.
If you only need to know whether a valid arrangement exists (not all of
them), the same shape works — just return TrueTrue the instant one is found
instead of collecting every result.
Practice — real LeetCode problems
Three shapes of the same skeleton. The first has a fixed depth and no pruning, the second adds an undo on a mutable grid, and the third adds constraint sets that make the pruning cheap.
LC 17 — Letter Combinations of a Phone Number · Medium
Problem. Given a string of digits 2-9, return all letter combinations the number could spell, using the standard phone keypad. Return them in any order.
Constraints. 0 <= len(digits) <= 40 <= len(digits) <= 4, digits are 22 through 99 only.
Examples. "23""23" gives ["ad","ae","af","bd","be","bf","cd","ce","cf"]["ad","ae","af","bd","be","bf","cd","ce","cf"] ·
"""" gives [][] · "2""2" gives ["a","b","c"]["a","b","c"]
Editorial · approach, complexity, follow-ups
The purest backtracking template there is: fixed depth, no pruning, no constraints. The output is sorted before printing only so the grader can compare it — LeetCode accepts any order.
Time — at most 4 letters per digit, and joining each word costs . Space for the path, excluding the output.
- Empty input returns
[][], not[""][""]. This is the single most-failed test case on the problem. Zero digits spell zero words, not one empty word — and the bare recursion would happily record the empty path. path.pop()path.pop()after the recursive call is what makes the sharedpathpathlist safe. Forgetting it leaks letters into sibling branches.- 7 and 9 have four letters, 2-6 and 8 have three. Hard-coding three breaks
"79""79", which is why it is in the tests. - Appending
path[:]path[:]versus"".join(path)"".join(path)— joining copies anyway, so strings are safe to store. When the answer is a list, you must copy explicitly; that trips people on the next problem.
Follow-ups you should expect: “Iteratively?” — build the answer level by level,
or use itertools.product(*[pad[d] for d in digits])itertools.product(*[pad[d] for d in digits]), which is the one-liner worth
mentioning after you have shown the manual version. “Return only combinations in a
dictionary?” — pass a trie down and prune the moment a prefix is impossible; that
is the T9 autocomplete question. “Digits 1 and 0?” — they map to nothing, so the
answer is empty. “10 digits?” — is a million words, so you would stream
them with a generator rather than materialise a list.
LC 79 — Word Search · Medium
Problem. Given a grid of characters and a word, return TrueTrue if the word can
be built from sequentially adjacent cells (horizontal or vertical neighbours).
The same cell may not be used twice.
Constraints. 1 <= rows, cols <= 61 <= rows, cols <= 6, 1 <= len(word) <= 151 <= len(word) <= 15, letters are
alphanumeric.
Examples. With [["A","B","C","E"],["S","F","C","S"],["A","D","E","E"]][["A","B","C","E"],["S","F","C","S"],["A","D","E","E"]]:
"ABCCED""ABCCED" gives TrueTrue · "SEE""SEE" gives TrueTrue · "ABCB""ABCB" gives FalseFalse
Editorial · approach, complexity, follow-ups
Backtracking on a grid. The difference from the flood-fill DFS in the graph section is the undo: flood fill marks a cell visited forever, because it only cares whether a region is reachable. Here a cell that fails on one path must be available to a different path, so the mark has to be temporary.
Time where is the word length — after the first step only three directions are worth trying, since the fourth is where you came from. Space for the recursion.
- The mark-and-restore is the whole problem. Without the mark,
"ABCB""ABCB"returnsTrueTrueby bouncing between theBBand theCC. Without the restore, the second and later starting cells search a board full of##. - Base cases before bounds arithmetic. Checking
i == len(word)i == len(word)first means a full match never needs the cell to exist. "A""A"returnsTrueTrue— a one-character word matches immediately, and the success check must fire before any neighbour is examined."ABCESEEEFS""ABCESEEEFS"returnsFalseFalseon this board. It is a real path on the common variant board that hasEEat row 1 column 2 — here that cell is aCC, so the path dies. A good reminder to read the grid you were actually given.- Restoring
word[i]word[i], not the original character, is safe only because the cell matchedword[i]word[i]to get here. That is a small proof worth having ready.
Follow-ups you should expect: “Search many words at once (LC 212)?” — build a trie of the words and walk the grid once against the trie; running this solution per word is far too slow. “Prune early?” — if the board’s letter counts cannot cover the word, fail immediately; also search from whichever end of the word is rarer on the board. “Diagonals allowed?” — eight directions instead of four. “Reuse allowed?” — drop the marking, and note the search may then not terminate without a depth bound. “Return the path?” — thread a list of coordinates through the recursion.
LC 51 — N-Queens · Hard
Problem. Place nn queens on an nn by nn board so that no two attack each
other. Return every distinct solution as a list of board strings, using "Q""Q" and
"."".".
Constraints. 1 <= n <= 91 <= n <= 9.
Examples. n = 4n = 4 has 2 solutions · n = 1n = 1 has 1 · n = 2n = 2 and n = 3n = 3
have none
Editorial · approach, complexity, follow-ups
The classic constraint-satisfaction backtrack, and the reason it is a Hard is the encoding, not the recursion.
Two observations collapse the search space:
- One queen per row. Any valid board has exactly one queen per row, so a
partial solution is a list of columns and the recursion depth is
nn— not cells to consider. - Diagonals are arithmetic. Every cell on a
//diagonal sharesr + cr + c; every cell on a\\diagonal sharesr - cr - c. So three sets replace what would otherwise be an scan per candidate.
Without pruning there are column permutations to test; with these three sets
the illegal branches die at the moment they are created, which is what makes
n = 9n = 9 instant.
Time exponential — bounded by but hugely pruned in practice. Space for the sets and path, plus the output.
- Undo everything you did, and note the code undoes in reverse order. Leaving one set dirty silently loses solutions rather than crashing, which is the worst kind of bug.
n = 2n = 2andn = 3n = 3have no solutions, and the answer is an empty list, not an error. Those two are the reason the counts start[1, 0, 0, 2, ...][1, 0, 0, 2, ...].- The counts
1, 0, 0, 2, 10, 4, 401, 0, 0, 2, 10, 4, 40forn = 1..7n = 1..7are worth recognising — the non-monotonic dip atn = 6n = 6surprises people and is a good self-check. discarddiscardversusremoveremove— either works here since the value is definitely present;discarddiscardjust will not mask an ordering bug with aKeyErrorKeyError.
Follow-ups you should expect: “Only the count (LC 52)?” — identical search,
increment a counter instead of building boards, and you can then use bitmasks:
colscols, diagdiag and antianti as integers, with the available squares computed as
~(cols | diag | anti)~(cols | diag | anti). That is the fastest known formulation and a strong signal
in an interview. “Find just one solution?” — return as soon as the first is
complete. “Symmetry?” — restrict the first row to the left half and mirror the
results, roughly halving the work. “Very large nn?” — constructive
formulas exist for placing nn non-attacking queens; enumerating all solutions is
intractable well before n = 30n = 30.
LeetCode problem set
| # | Problem | Difficulty | The twist |
|---|---|---|---|
| 46 | Permutations | Medium | The exact template above |
| 77 | Combinations | Medium | Choose kk of nn, using a startstart index instead of a usedused array |
| 51 | N-Queens | Hard | Pruning with column/diagonal sets, as above |
| 79 | Word Search | Medium | DFS + backtracking on a grid, marking and un-marking visited cells |
| 37 | Sudoku Solver | Hard | Backtracking with row/column/box constraint pruning |
| 39 | Combination Sum | Medium | Backtracking that allows reusing the same candidate |
Recap
- Choose, explore, unchoose — the three-line skeleton behind every backtracking solution.
- Backtracking is DFS over a decision tree; leaves are complete valid (or invalid) candidates.
- Pruning checks partial validity immediately, skipping entire subtrees instead of building and rejecting them.
- Worst case is exponential (, , …) — pruning doesn’t change that bound, but makes real inputs tractable.
Next: Subsets and Combinations — the include/exclude version of this same decision tree, generating all subsets.
If this helped you, consider buying me a coffee ☕
Buy me a coffeeWas this page helpful?
Let us know how we did
