Backtracking
Build answers one choice at a time, undo the choice, and try the next. One choose, explore, un-choose template for subsets, permutations and puzzles.
On this page
A sandwich shop lets you add any mix of three extras: cheese, egg and tomato. You want to list every sandwich you could order, from plain bread to all three. With three extras you can do it in your head. With twelve there are 4,096, and you need a method that never misses one and never writes one twice.
A careful person does it like this. Add an extra. List everything you can still add on top. When you run out, take that extra back off and try a different one. That "add it, go deeper, take it back" rhythm is backtracking.
The idea: choose, explore, un-choose
Backtracking builds an answer one decision at a time. You keep a single path: the choices made so far. At every point you do three things for each option still available:
- Choose: add the option to the path.
- Explore: call yourself recursively to make the remaining decisions.
- Un-choose: remove the option, so the path is exactly what it was before, ready for the next option.
Each call is one decision point, and the call stack remembers where you were, as in Recursion. Un-choosing lets every branch share one path.
The calls form a tree. Each node is a path and each edge adds one extra. A call only offers extras after the last one chosen, so "egg then cheese" never shows up as a copy of "cheese then egg".
Subsets, traced
Write cheese, egg and tomato as C, E and T. Watch the path grow and shrink. It holds one sandwich at a time; the recorded list collects copies.
explore(start): record a copy of path for i from start to n - 1: path.push(extras[i]) # choose explore(i + 1) # explore path.pop() # un-choose- extras0C1E2Tpathrecorded0∅
- start
- 0
1
explore(0)starts with an empty path. A sandwich with no extras counts, so record it. - extras0Ci1E2Tpath0Crecorded0∅
- start
- 0
2Choose cheese: push C onto the path.
- extras0C1E2Tpath0Crecorded0∅1C
- start
- 1
3
explore(1)records the path. C on its own is a sandwich. - extras0C1Ei2Tpath0C1Erecorded0∅1C
- start
- 1
4The loop in
explore(1)starts at index 1, so the next choice is egg. Push E. - extras0C1E2Tpath0C1Erecorded0∅1C2CE
- start
- 2
5
explore(2)records CE. - extras0C1E2Tipath0C1E2Trecorded0∅1C2CE
- start
- 2
6Choose tomato. Push T.
- extras0C1E2Tpath0C1E2Trecorded0∅1C2CE3CET
- start
- 3
7
explore(3)records CET. Its loop starts past the end, so it returns. - extras0C1E2Tipath0C1Erecorded0∅1C2CE3CET
- start
- 2
8Un-choose: pop T. The path is exactly what it was before T was chosen.
explore(2)has no more options, so it returns. - extras0C1Ei2Tpath0Crecorded0∅1C2CE3CET
- start
- 1
9Pop E. Back in
explore(1), the loop moves on to tomato. - extras0C1E2Tipath0C1Trecorded0∅1C2CE3CET
- start
- 1
10Choose tomato: push T. The path is CT.
- extras0C1E2Tpath0C1Trecorded0∅1C2CE3CET4CT
- start
- 3
11
explore(3)records CT and returns at once. - extras0C1E2Tipath0Crecorded0∅1C2CE3CET4CT
- start
- 1
12Pop T.
explore(1)has tried every extra after cheese, so it returns. - extras0Ci1E2Tpathrecorded0∅1C2CE3CET4CT
- start
- 0
13Pop C. Every sandwich with cheese is recorded.
explore(0)moves on to egg. - extras0C1Ei2Tpath0Erecorded0∅1C2CE3CET4CT
- start
- 0
14Choose egg. Cheese is never offered again, because the loop only looks to the right. That is why EC never appears as a copy of CE.
- extras0C1E2Tpath0Erecorded0∅1C2CE3CET4CT5E
- start
- 2
15
explore(2)records E. - extras0C1E2Tipath0E1Trecorded0∅1C2CE3CET4CT5E
- start
- 2
16Choose tomato. The path is ET.
- extras0C1E2Tpath0E1Trecorded0∅1C2CE3CET4CT5E6ET
- start
- 3
17
explore(3)records ET. - extras0C1E2Tipath0Erecorded0∅1C2CE3CET4CT5E6ET
- start
- 2
18Pop T.
explore(2)is done. - extras0C1Ei2Tpathrecorded0∅1C2CE3CET4CT5E6ET
- start
- 0
19Pop E.
explore(0)moves on to tomato, the last extra. - extras0C1E2Tipath0Trecorded0∅1C2CE3CET4CT5E6ET
- start
- 0
20Choose tomato. The path is T.
- extras0C1E2Tpath0Trecorded0∅1C2CE3CET4CT5E6ET7T
- start
- 3
21
explore(3)records T. - extras0C1E2Tipathrecorded0∅1C2CE3CET4CT5E6ET7T
- start
- 0
22Pop T. Every loop has finished: 8 sandwiches, 2 × 2 × 2 = 2³, because each extra is in or out.
Look at step 8. After popping T, the path is CE again. Every call leaves the path as it found it, so the caller's loop can carry on as if nothing happened.
function subsets(items) {
const result = [];
const path = [];
function explore(start) {
result.push([...path]); // record a copy, not path itself
for (let i = start; i < items.length; i++) {
path.push(items[i]); // choose
explore(i + 1); // explore
path.pop(); // un-choose
}
}
explore(0);
return result;
}
subsets(["C", "E", "T"]);
// [[], ["C"], ["C","E"], ["C","E","T"], ["C","T"], ["E"], ["E","T"], ["T"]]def subsets(items):
result = []
path = []
def explore(start):
result.append(path[:]) # record a copy, not path itself
for i in range(start, len(items)):
path.append(items[i]) # choose
explore(i + 1) # explore
path.pop() # un-choose
explore(0)
return result
subsets(["C", "E", "T"])
# [[], ['C'], ['C', 'E'], ['C', 'E', 'T'], ['C', 'T'], ['E'], ['E', 'T'], ['T']]Permutations: same template, different options
Now three friends, Ana, Ben and Cy, need a speaking order. Order matters here. Two things change:
- The options are everyone not used yet, tracked in a
usedlist. - You record only when the path is full. Half an order is not an answer.
By hand: Ana, Ben, Cy is the first full path. Un-choose Cy and Ben, put Cy second, and you get Ana, Cy, Ben. Then Ben goes first, then Cy.
function permutations(items) {
const result = [];
const path = [];
const used = new Array(items.length).fill(false);
function explore() {
if (path.length === items.length) {
result.push([...path]);
return;
}
for (let i = 0; i < items.length; i++) {
if (used[i]) continue;
used[i] = true; // choose
path.push(items[i]);
explore(); // explore
path.pop(); // un-choose
used[i] = false;
}
}
explore();
return result;
}
permutations(["Ana", "Ben", "Cy"]).length; // 6def permutations(items):
result = []
path = []
used = [False] * len(items)
def explore():
if len(path) == len(items):
result.append(path[:])
return
for i in range(len(items)):
if used[i]:
continue
used[i] = True # choose
path.append(items[i])
explore() # explore
path.pop() # un-choose
used[i] = False
explore()
return result
len(permutations(["Ana", "Ben", "Cy"])) # 6Choosing now makes two updates, so un-choosing undoes both. Three choices for first place, two for second, one for third: 3 × 2 × 1 = 6 orders.
A puzzle: prune early
Backtracking pays off most when some choices break a rule. Place 4 queens on a 4 × 4 board so that no two share a row, column or diagonal. Put one queen per row, so each call picks the column for one row.
The new ingredient is the prune: skip any square that is already attacked. A bad partial board is dropped the moment it goes wrong, along with every board that would have grown from it.
explore(row): if row == n: record placed; return for col from 0 to n - 1: if col or a diagonal is taken: skip # prune place queen at (row, col) # choose explore(row + 1) # explore remove queen from (row, col) # un-choose0 1 2 3 row 0 Q row 1 row 2 row 3 - row
- 0
- placed
- [0]
1Row 0: the board is empty, so column 0 is safe. Place a queen in the corner.
0 1 2 3 row 0 Q row 1 row 2 row 3 - row
- 1
- placed
- [0]
2Row 1: column 0 is taken and column 1 is on a diagonal. Skip both.
0 1 2 3 row 0 Q row 1 Q row 2 row 3 - row
- 1
- placed
- [0, 2]
3Column 2 is safe. Place the second queen.
0 1 2 3 row 0 Q row 1 Q row 2 row 3 - row
- 2
- placed
- [0, 2]
4Row 2: every square is attacked. The loop ends without placing anything. Dead end.
0 1 2 3 row 0 Q row 1 row 2 row 3 - row
- 1
- placed
- [0]
5Un-choose: take the queen off row 1, column 2. The board is back to how it was before that choice.
0 1 2 3 row 0 Q row 1 Q row 2 row 3 - row
- 1
- placed
- [0, 3]
6Row 1 tries column 3. It is safe, so place a queen.
0 1 2 3 row 0 Q row 1 Q row 2 row 3 - row
- 2
- placed
- [0, 3]
7Row 2: column 0 is taken by the first queen. Skip it.
0 1 2 3 row 0 Q row 1 Q row 2 Q row 3 - row
- 2
- placed
- [0, 3, 1]
8Column 1 is safe. Place the third queen.
0 1 2 3 row 0 Q row 1 Q row 2 Q row 3 - row
- 3
- placed
- [0, 3, 1]
9Row 3: all four squares are attacked. Another dead end.
0 1 2 3 row 0 Q row 1 Q row 2 row 3 - row
- 2
- placed
- [0, 3]
10Remove the queen from row 2. Columns 2 and 3 are attacked too, so row 2 gives up.
0 1 2 3 row 0 Q row 1 row 2 row 3 - row
- 1
- placed
- [0]
11Remove the queen from row 1, column 3. Row 1 has no columns left to try.
0 1 2 3 row 0 row 1 row 2 row 3 - row
- 0
- placed
- []
12Remove the corner queen. No answer has a queen in that corner, and you proved it without filling a single full board.
0 1 2 3 row 0 Q row 1 row 2 row 3 - row
- 0
- placed
- [1]
13Row 0 tries column 1.
0 1 2 3 row 0 Q row 1 row 2 row 3 - row
- 1
- placed
- [1]
14Row 1: columns 0 and 2 are on diagonals, column 1 is taken.
0 1 2 3 row 0 Q row 1 Q row 2 row 3 - row
- 1
- placed
- [1, 3]
15Column 3 is safe. Place.
0 1 2 3 row 0 Q row 1 Q row 2 Q row 3 - row
- 2
- placed
- [1, 3, 0]
16Row 2: column 0 is safe on the first try. Place.
0 1 2 3 row 0 Q row 1 Q row 2 Q row 3 - row
- 3
- placed
- [1, 3, 0]
17Row 3: columns 0 and 1 are both taken. Skip them.
0 1 2 3 row 0 Q row 1 Q row 2 Q row 3 Q - row
- 3
- placed
- [1, 3, 0, 2]
18Column 2 is safe. Place the fourth queen.
0 1 2 3 row 0 Q row 1 Q row 2 Q row 3 Q - row
- 4
- placed
- [1, 3, 0, 2]
19
explore(4): every row has a queen, so record the solution. Only 8 queens were placed on the way here. Putting one queen in each row without pruning gives 4 × 4 × 4 × 4 = 256 boards to check.
To check a square in O(1), keep three sets: used columns, used row - col values (down-right diagonals) and used row + col values (down-left diagonals). Squares (0, 0) and (1, 1) both have row - col = 0, which is why step 2 skipped (1, 1).
function solveQueens(n) {
const cols = new Set(), down = new Set(), up = new Set();
const placed = []; // placed[row] = column of that row's queen
const solutions = [];
function explore(row) {
if (row === n) { solutions.push([...placed]); return; }
for (let col = 0; col < n; col++) {
// prune: column or diagonal already taken
if (cols.has(col) || down.has(row - col) || up.has(row + col)) continue;
// choose
cols.add(col); down.add(row - col); up.add(row + col);
placed.push(col);
explore(row + 1); // explore
// un-choose
cols.delete(col); down.delete(row - col); up.delete(row + col);
placed.pop();
}
}
explore(0);
return solutions;
}
solveQueens(4); // [[1, 3, 0, 2], [2, 0, 3, 1]]def solve_queens(n):
cols, down, up = set(), set(), set()
placed = [] # placed[row] = column of that row's queen
solutions = []
def explore(row):
if row == n:
solutions.append(placed[:])
return
for col in range(n):
if col in cols or row - col in down or row + col in up:
continue # prune
cols.add(col) # choose
down.add(row - col)
up.add(row + col)
placed.append(col)
explore(row + 1) # explore
cols.remove(col) # un-choose
down.remove(row - col)
up.remove(row + col)
placed.pop()
explore(0)
return solutions
solve_queens(4) # [[1, 3, 0, 2], [2, 0, 3, 1]]How much does it cost
Backtracking visits every node of its call tree, so count the nodes. Subsets have 2ⁿ, because each item is in or out. Orderings have n! leaves. Copying each answer costs O(n). These numbers explode: 2²⁰ is about a million and 12! is about 479 million. Backtracking is for small inputs, or for problems where pruning cuts most of the tree away.
| Approach | Time | Space | Why |
|---|---|---|---|
| All subsets | O(n · 2ⁿ) | O(n) | 2ⁿ subsets, each copied in O(n). The path and the call stack are at most n deep, not counting the output. |
| All orderings | O(n · n!) | O(n) | n! full paths, each copied in O(n). |
| n queens | O(n!) at most | O(n) | At most n choices for row 0, n − 1 for row 1, and so on. Pruning cuts far below that in practice. |
Common mistakes
Check yourself
In the subsets code, what goes wrong if the loop starts at 0 instead of start?
A permutation search on 5 items records an answer only when the path is full. How many answers does it record?
Why does backtracking un-choose after the recursive call returns?
What to practise next
Backtracking tries every option. When you can prove one option is always best, you can skip the rest: that is Greedy Algorithms. When the same smaller question gets asked again and again inside the search, dynamic programming stores the answers instead.