Skip to content

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

permutations_backtrack.py
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]))
permutations_backtrack.py
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

diagram Backtracking decision tree: permutations of [1, 2, 3] (partial) mermaid

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 3!=63! = 6 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 O(1)O(1), pruning invalid branches instantly instead of discovering the conflict later.

n_queens.py
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])
n_queens.py
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 — O(n!)O(n!) for permutations, roughly O(nn)O(n^n) 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 O(4nn)O(4^n \cdot n) — at most 4 letters per digit, and joining each word costs O(n)O(n). Space O(n)O(n) 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 shared pathpath list 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?” — 4104^{10} 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 O(rc3L)O(rc \cdot 3^L) where LL is the word length — after the first step only three directions are worth trying, since the fourth is where you came from. Space O(L)O(L) for the recursion.

  • The mark-and-restore is the whole problem. Without the mark, "ABCB""ABCB" returns TrueTrue by bouncing between the BB and the CC. 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" returns TrueTrue — a one-character word matches immediately, and the success check must fire before any neighbour is examined.
  • "ABCESEEEFS""ABCESEEEFS" returns FalseFalse on this board. It is a real path on the common variant board that has EE at row 1 column 2 — here that cell is a CC, 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 matched word[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:

  1. 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 n2n^2 cells to consider.
  2. Diagonals are arithmetic. Every cell on a // diagonal shares r + cr + c; every cell on a \\ diagonal shares r - cr - c. So three sets replace what would otherwise be an O(n)O(n) scan per candidate.

Without pruning there are n!n! 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 n!n! but hugely pruned in practice. Space O(n)O(n) 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 = 2 and n = 3n = 3 have 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, 40 for n = 1..7n = 1..7 are worth recognising — the non-monotonic dip at n = 6n = 6 surprises people and is a good self-check.
  • discarddiscard versus removeremove — either works here since the value is definitely present; discarddiscard just will not mask an ordering bug with a KeyErrorKeyError.

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

#ProblemDifficultyThe twist
46PermutationsMediumThe exact template above
77CombinationsMediumChoose kk of nn, using a startstart index instead of a usedused array
51N-QueensHardPruning with column/diagonal sets, as above
79Word SearchMediumDFS + backtracking on a grid, marking and un-marking visited cells
37Sudoku SolverHardBacktracking with row/column/box constraint pruning
39Combination SumMediumBacktracking 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 (O(n!)O(n!), O(nn)O(n^n), …) — 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 2n2^n subsets.

If this helped you, consider buying me a coffee ☕

Buy me a coffee

Was this page helpful?

Let us know how we did