What backtracking is
Some problems ask you to build an answer out of a series of small choices. “Pick some of these numbers.” “Arrange these letters in every possible order.” “Place eight queens on a chessboard so none attacks another.” In each one you make a choice, then another, then another, until you either have a complete answer or you hit a dead end.
Backtracking is a way of exploring all of those choice sequences without getting lost. You build a solution one choice at a time. After each choice you ask, can this partial solution still lead somewhere valid? If yes, you keep going deeper. If no, or if you have exhausted the options at this point, you undo the last choice and try a different one. Undoing a choice and stepping back is where the name comes from: you back up and track a different route.
A few terms first, since the reader may be new to all of this.
- A function is a named block of code you run by writing its name and parentheses. It can call itself; that is recursion, and backtracking is built on it.
- A list is Python’s ordered, growable sequence, written
[1, 2, 3].path.append(x)addsxto the end;path.pop()removes and returns the last item. - A partial solution is an answer that is not finished yet: some choices made, more still to make.
- To prune means to cut off a whole branch of possibilities early because you can already tell it cannot work, so you never bother exploring it.
The template: choose, explore, un-choose
Almost every backtracking solution has the same three-step shape inside a recursive function.
- Choose: add one option to the current partial solution.
- Explore: recurse to make the next choice on top of it.
- Un-choose: remove the option you just added, restoring the partial solution to exactly what it was before, so the next option starts from a clean state.
Here is the skeleton in plain form:
def backtrack(path, choices):
if is_complete(path): # a full solution
record(path)
return
for option in choices:
if not allowed(option, path):
continue # prune: skip options that cannot work
path.append(option) # 1. choose
backtrack(path, choices) # 2. explore
path.pop() # 3. un-choose
The path.pop() on the last line is the single most important line in backtracking. It is the “un-choose”. If you forget it, every branch leaks its choices into the next one, corrupting all later results. We will see exactly why below.
First worked example: all subsets
A subset of a list is any selection of its items, including the empty selection and the whole list. For [1, 2, 3] there are eight subsets: [], [1], [2], [3], [1,2], [1,3], [2,3], [1,2,3].
The choice at each position is binary: for each number, either include it or skip it. We walk the list index by index. At each index we make both choices, one after the other.
def subsets(nums):
result = []
path = []
def backtrack(start):
result.append(path[:]) # record a copy of the current subset
for i in range(start, len(nums)):
path.append(nums[i]) # choose nums[i]
backtrack(i + 1) # explore with later items only
path.pop() # un-choose
backtrack(0)
return result
print(subsets([1, 2, 3]))
# -> [[], [1], [1, 2], [1, 2, 3], [1, 3], [2], [2, 3], [3]]
Two details matter. First, path[:] makes a copy of the list. If you appended path itself, every entry in result would point at the same list object, and after the final pops they would all show as []. Second, backtrack(i + 1) passes i + 1, not start, so each item is only ever combined with items that come after it. That is what prevents [1, 2] and [2, 1] from both appearing; order does not matter in a subset.
The whole search is easiest to hold in your head as a tree of partial solutions. Each node is a value of path at some moment. Each edge (arrow) is one “choose” step deeper; every arrow is later undone by a matching “un-choose” as the recursion returns. Here is that tree for subsets([1, 2, 3]):
flowchart TD
R["[]"] --> A["[1]"]
R --> B["[2]"]
R --> C["[3]"]
A --> A1["[1, 2]"]
A --> A2["[1, 3]"]
A1 --> A11["[1, 2, 3]"]
B --> B1["[2, 3]"]
Read it top to bottom, left to right. Starting from [] we choose 1, then from [1] we choose 2, then 3, reaching [1, 2, 3]. We then un-choose back up to [1] and try 3 instead, giving [1, 3]. Every node in the tree is one of the eight subsets, and the order the nodes are first reached is exactly the printed order above.
Step-by-step trace
Here is the mechanism line by line for subsets([1, 2, 3]). “Action” is the operation just performed; the other columns show the state right after it. result is shown as it grows.
| Step | Action | path | Just recorded |
|---|---|---|---|
| 1 | record [] | [] | [] |
| 2 | choose 1 | [1] | |
| 3 | record [1] | [1] | [1] |
| 4 | choose 2 | [1, 2] | |
| 5 | record [1, 2] | [1, 2] | [1, 2] |
| 6 | choose 3 | [1, 2, 3] | |
| 7 | record [1, 2, 3] | [1, 2, 3] | [1, 2, 3] |
| 8 | un-choose (pop 3) | [1, 2] | |
| 9 | un-choose (pop 2) | [1] | |
| 10 | choose 3 | [1, 3] | |
| 11 | record [1, 3] | [1, 3] | [1, 3] |
| 12 | un-choose (pop 3) | [1] | |
| 13 | un-choose (pop 1) | [] | |
| 14 | choose 2 | [2] | … continues |
Notice how path always returns to exactly its earlier state after a subtree finishes. That is the un-choose doing its job.
Complexity
There are 2**n subsets for n items, because each item is independently in or out. Building each one costs up to O(n) to copy the path. So the time is O(n * 2**n), which is also the size of the output. The space, not counting the output, is O(n): the recursion goes at most n levels deep, and path holds at most n items. There is no way to beat 2**n here because there are genuinely that many answers to produce.
Second worked example: all permutations
A permutation is an arrangement of all the items in a specific order. For [1, 2, 3] there are six: [1,2,3], [1,3,2], [2,1,3], [2,3,1], [3,1,2], [3,2,1]. Unlike subsets, order matters and every item must be used exactly once.
The choice at each step is: which unused item goes in the next position? We track which items are still available.
def permutations(nums):
result = []
path = []
used = [False] * len(nums) # used[i] is True once nums[i] is placed
def backtrack():
if len(path) == len(nums): # all positions filled
result.append(path[:])
return
for i in range(len(nums)):
if used[i]:
continue # prune: this item is already placed
used[i] = True # choose
path.append(nums[i])
backtrack() # explore
path.pop() # un-choose
used[i] = False # un-choose the "used" mark too
backtrack()
return result
print(permutations([1, 2, 3]))
# -> [[1, 2, 3], [1, 3, 2], [2, 1, 3], [2, 3, 1], [3, 1, 2], [3, 2, 1]]
The if used[i]: continue line is a prune. It skips any item already sitting in path, so we never try to use the same item twice. Note that this function undoes two pieces of state on the way back up: it pops from path and resets used[i] to False. Any state you change on the way down must be restored on the way up, without exception.
Drawn the same way, permutations([1, 2, 3]) branches like this:
flowchart TD
R["[]"] --> A["[1]"]
R --> B["[2]"]
R --> C["[3]"]
A --> A1["[1, 2]"]
A --> A2["[1, 3]"]
A1 --> A11["[1, 2, 3]"]
A2 --> A21["[1, 3, 2]"]
B --> B1["[2, 1]"]
B --> B2["[2, 3]"]
B1 --> B11["[2, 1, 3]"]
B2 --> B21["[2, 3, 1]"]
C --> C1["[3, 1]"]
C --> C2["[3, 2]"]
C1 --> C11["[3, 1, 2]"]
C2 --> C21["[3, 2, 1]"]
The tree is wider than the subset tree: at the top level all three items are available, at the next level two remain, then one. The leaf nodes at the bottom (the six three-item lists) are the answers. A leaf is a node with no children, here meaning a completed permutation.
Complexity
There are n! (n factorial: n * (n-1) * ... * 1) permutations, and copying each costs O(n), so time is O(n * n!). Space excluding the output is O(n) for the recursion depth plus the used array. 6 permutations for n = 3 feels small, but 10! = 3,628,800 and 13! is over six billion. Backtracking explores real work; it does not make an exponential problem cheap.
Pruning: cutting branches early
The prunes above (continue on a used item) skip single options. The bigger win comes when one early choice makes an entire subtree impossible, and you cut all of it at once. The classic example is N-Queens.
N-Queens sketch
Place N queens on an N x N chessboard so that no two share a row, column, or diagonal. We place one queen per row, top to bottom. For each row we try each column. Before committing, we check the queen against all already-placed queens; if it conflicts, we prune that column and never recurse into it.
def solve_n_queens(n):
result = []
cols = [-1] * n # cols[r] = column of the queen in row r
def safe(row, col):
for r in range(row): # check queens already placed above
c = cols[r]
if c == col: # same column
return False
if abs(c - col) == abs(r - row): # same diagonal
return False
return True
def backtrack(row):
if row == n: # placed a queen in every row
result.append(cols[:])
return
for col in range(n):
if not safe(row, col):
continue # prune: this placement attacks another queen
cols[row] = col # choose
backtrack(row + 1) # explore
cols[row] = -1 # un-choose
backtrack(0)
return result
print(len(solve_n_queens(4))) # -> 2
print(solve_n_queens(4)) # -> [[1, 3, 0, 2], [2, 0, 3, 1]]
Each result like [1, 3, 0, 2] reads “row 0 queen in column 1, row 1 in column 3, row 2 in column 0, row 3 in column 2.” The safe check is the prune, and it is doing enormous work: without it we would examine 4**4 = 256 placements for the 4x4 board; with it, most rows have only a couple of viable columns, so the search collapses to a handful of paths.
This tree shows pruning concretely for the first two rows of the 4x4 board. Nodes marked pruned fail the safe check and are never expanded; the search never visits their children.
flowchart TD
R["row 0"] --> C0["col 0"]
R --> C1["col 1"]
R --> C2["col 2"]
R --> C3["col 3"]
C0 --> C0a["row1 col0 pruned (same col)"]
C0 --> C0b["row1 col1 pruned (diagonal)"]
C0 --> C0c["row1 col2 ok"]
C0 --> C0d["row1 col3 ok"]
C1 --> C1a["row1 col3 ok"]
C1 --> C1x["cols 0,1,2 pruned"]
Under col 0 in row 0, two of the four columns in row 1 are killed immediately. Each pruned node stands for a whole subtree, dozens of full-board arrangements, thrown away with one cheap test. That is why backtracking with good pruning can solve boards that a blind enumeration of all N**N placements never could.
Why the complexity is exponential, and how pruning helps
Backtracking explores a tree of choices. If there are b options at each of d levels, the tree has up to b**d nodes; that is exponential growth, meaning the count multiplies rather than adds as the problem grows. Subsets gave 2**n, permutations n!, N-Queens roughly N**N before pruning. These numbers explode fast, and no amount of cleverness changes that when the answer set is genuinely that large (all 2**n subsets really do exist).
Pruning does not change the worst-case Big-O, but it changes the practical running time dramatically. Every branch you cut removes its entire subtree from the search. For N-Queens the difference between N**N and the pruned search is the difference between impossible and instant for boards of a dozen queens. The lesson: pick the strongest, cheapest check you can apply as early as possible, so you reject bad partial solutions before, not after, sinking work into them.
Common pitfalls
- Forgetting to un-choose. If you
path.append(x)and recurse but neverpath.pop(), every sibling branch inheritsx. Results become wrong and grow without bound. The pop must run on every path out of the recursive call, which is why it sits right after the recursive call, not inside anif. - Restoring only some of the state. In the permutation and N-Queens code, more than one variable changes on the way down (
pathandused;cols). Every one must be undone on the way up. Undoingpathbut leavingused[i] = Truesilently drops valid answers. - Storing the live list instead of a copy.
result.append(path)stores a reference to the one list that keeps mutating. After the search finishes, every stored entry reflects the final state ofpath(often empty), not the moment you recorded it. Always storepath[:]orlist(path)ortuple(path). - Shared mutable state across calls. A mutable default argument (
def f(path=[])) is created once and reused across every call, so state leaks between independent runs. Create fresh state inside the function, or pass it explicitly. - Weak or missing base case. As with all recursion, if the “complete” condition is wrong the search either never records answers or never stops. Check the base case first, before the loop.
Practice
- Write
combinations(nums, k)that returns every subset of exactly lengthk. Start from thesubsetsfunction and add a base case that recordspathonly whenlen(path) == k; prune branches that can no longer reach lengthk. - Modify
permutationsso it works whennumscontains duplicates (for example[1, 1, 2]) and returns each distinct arrangement only once. Hint: sort first, then skip an option if it equals the previous option and that previous option is currently unused. - Write
letter_combinations(digits)for phone-keypad text entry, where2maps to"abc",3to"def", and so on. Given"23"it should produce["ad", "ae", "af", "bd", "be", "bf", "cd", "ce", "cf"]. Use the choose / explore / un-choose template with a string or list path.