AmouAI Hub/Courses/Data Structures & Algorithms/Chapter 27
Backtracking and Constraint Search
Try, fail, undo, try something else. Brute force with a memory and a conscience — and pruning is what turns an impossible search into an instant one.
By the end of this chapter you can
- Write the choose / explore / un-choose skeleton from memory
- Generate subsets, permutations and combinations, and handle duplicates without producing duplicates
- Explain why pruning, not the search, is the algorithm
- Implement N-Queens with incremental constraint sets rather than a full board scan
- Apply the most-constrained-variable heuristic to a Sudoku solver
- Say when to reach for backtracking instead of DP, and what branch and bound adds
1Try, fail, undo
Build a candidate solution one decision at a time. The moment it cannot possibly work, abandon it and take the last decision back.
You walk into a maze with a piece of chalk. At each junction you pick a corridor and mark it. When you hit a dead end you walk back to the last junction, rub out your mark, and take a different corridor.
The rubbing out is the whole idea. Without it you would be carrying every wrong decision around with you forever; with it, the maze shrinks to only the routes still worth trying.
Now imagine a sign at the entrance to one corridor reading “no exit beyond this point”. You skip that entire branch without walking a step of it. That is pruning, and it is worth more than everything else in this chapter combined.
Backtracking is depth-first search over the tree of partial solutions. The tree is never built — it exists only as the shape of the recursion — and each node is a partial answer that may or may not extend to a real one.
def backtrack(state, choices):
if is_complete(state):
record(state)
return
for choice in choices:
if not is_valid(state, choice):
continue # PRUNE: skip this whole subtree
make(state, choice) # CHOOSE
backtrack(state, choices) # EXPLORE
undo(state, choice) # UN-CHOOSE <-- the line people forgetChoose, explore, un-choose. Every backtracking function you will ever write is those three lines around a loop.
The un-choose is the one that gets forgotten, and the symptom is distinctive: your answers get progressively longer and more polluted as the run continues, because state from an abandoned branch leaks into the next one. If your output looks like each result contains the previous one, you have found your bug.
The alternative is to pass a fresh copy at every level (path + [choice]) and never
mutate at all. That is cleaner and slower, and for small search spaces it is the right trade.
2Subsets, permutations, combinations
Three shapes that between them cover most enumeration problems. The differences are two lines each.
Subsets. Each element is in or out, so the tree is binary and has
2ⁿ leaves. The idiom is to loop from a start index, which prevents you from
producing the same subset in two different orders:
def subsets(nums):
out, path = [], []
def go(start):
out.append(path[:]) # every node is an answer, not just the leaves
for i in range(start, len(nums)):
path.append(nums[i]) # choose
go(i + 1) # explore — i+1, so no element repeats
path.pop() # un-choose
go(0)
return outPermutations. Order matters, so every unused element is a candidate at every
position. n! results:
def permutations(nums):
out, path = [], []
used = [False] * len(nums)
def go():
if len(path) == len(nums):
out.append(path[:])
return
for i in range(len(nums)):
if used[i]:
continue
used[i] = True
path.append(nums[i])
go()
path.pop() # un-choose, both halves of it
used[i] = False
go()
return outWith repeated input values the two generators above produce duplicate answers. The standard fix is one line, and it is the same line in both cases:
nums.sort()
...
if i > start and nums[i] == nums[i - 1]:
continue # this value was already tried at this position
Read the condition carefully: it skips a duplicate only when it is a sibling in the tree
(same position, same value), not when it is a descendant. Using i > 0 instead of
i > start would wrongly forbid [2, 2] entirely.
For permutations with duplicates the sibling test is i > 0 and nums[i] == nums[i-1] and
not used[i-1] — the extra clause pins down which of the equal values is used
first, so only one ordering of the identical elements survives.
| Problem | Loop | Recurse with | Count |
|---|---|---|---|
| Subsets | for i in range(start, n) | i + 1 | 2ⁿ |
| Combinations of size k | same, and stop at length k | i + 1 | C(n, k) |
| Combination sum (reuse allowed) | same | i — not i+1 | varies |
| Permutations | for i in range(n) with a used[] array | — | n! |
That third row is worth pausing on: recursing with i instead of i + 1
allows the same element to be chosen again, which is the entire difference between
“combinations” and “combinations with repetition”. It is the same
one-character distinction as forwards-versus-backwards in knapsack.
3Pruning is the algorithm
Without pruning, backtracking is brute force with extra steps. With it, searches that should take a century take a millisecond.
N-Queens: place n queens on an n × n board so that
none attacks another. Placing one queen per row cuts the space from C(n², n) to
nⁿ before any real work — and then pruning does the rest.
def n_queens(n):
cols = set()
diag = set() # r - c is constant along one diagonal
anti = set() # r + c is constant along the other
count = 0
def place(row):
nonlocal count
if row == n:
count += 1
return
for col in range(n):
if col in cols or (row - col) in diag or (row + col) in anti:
continue # PRUNE
cols.add(col); diag.add(row - col); anti.add(row + col)
place(row + 1)
cols.discard(col); diag.discard(row - col); anti.discard(row + col)
place(0)
return countSquares on the same “╱” diagonal all have the same value of
row + col. Squares on the same “╲” diagonal all have the same value
of row - col.
So “is this square attacked?” becomes three set lookups instead of a scan over every
queen already placed. The search does not visit fewer nodes — it just visits each one in
constant time instead of O(n).
That is the second kind of speed-up available here, and it is worth separating from pruning: prune to visit fewer nodes; use incremental state to make each visit cheaper. Serious solvers do both.
Three kinds of pruning are worth naming, because you will reach for each of them in different problems:
- Constraint pruning — the partial solution already breaks a rule, so no extension can work. N-Queens above.
- Bound pruning (branch and bound) — the best possible completion of this branch is still worse than a solution you already have. Requires an optimistic estimate of what remains, which is the same idea as A*'s heuristic in Chapter 22.
- Symmetry pruning — this branch is a rotation, reflection or relabelling of one already explored. In N-Queens, fixing the first queen to the left half of the first row halves the work immediately.
4Constraint search: Sudoku
The same skeleton, plus one heuristic that changes everything: do not fill in the next cell — fill in the hardest one.
A naive Sudoku solver walks the grid left-to-right, top-to-bottom, trying 1–9 in each empty cell. It works, and on a hard puzzle it can take minutes, because it spends its time on cells with nine possibilities while a cell with exactly one sits untouched.
Always fill the empty cell with the fewest legal candidates.
The reasoning is the same as in any search: a cell with one candidate is a forced move — taking it cannot cost you anything and it constrains its neighbours immediately. A cell with nine candidates branches nine ways, and you want to postpone that until the board has told you more.
If a cell has zero candidates, you have found a contradiction and can abandon the branch straight away, several levels earlier than a left-to-right solver would.
Adding MRV to a naive solver typically turns minutes into milliseconds. It is ten lines.
def solve(board):
"""board: 9x9 list of lists, 0 for empty. Solves in place, returns True on success."""
rows = [set() for _ in range(9)]
cols = [set() for _ in range(9)]
boxes = [set() for _ in range(9)]
empties = []
for r in range(9):
for c in range(9):
v = board[r][c]
if v:
rows[r].add(v); cols[c].add(v); boxes[(r // 3) * 3 + c // 3].add(v)
else:
empties.append((r, c))
def candidates(r, c):
return [v for v in range(1, 10)
if v not in rows[r] and v not in cols[c]
and v not in boxes[(r // 3) * 3 + c // 3]]
def go():
if not empties:
return True
# MRV: pick the empty cell with the fewest options
best_i, best = None, None
for i, (r, c) in enumerate(empties):
opts = candidates(r, c)
if not opts:
return False # contradiction: prune the whole branch
if best is None or len(opts) < len(best):
best_i, best = i, opts
if len(opts) == 1:
break # cannot do better than forced
r, c = empties.pop(best_i)
b = (r // 3) * 3 + c // 3
for v in best:
board[r][c] = v
rows[r].add(v); cols[c].add(v); boxes[b].add(v)
if go():
return True
rows[r].discard(v); cols[c].discard(v); boxes[b].discard(v)
board[r][c] = 0
empties.insert(best_i, (r, c)) # un-choose the cell itself
return False
return go()Nobody solves a crossword strictly by clue number. You scan for the clue you are sure about, write it in, and then re-scan — because those new letters have just made several other clues easier.
MRV is that habit, formalised. Forced moves first; the letters they contribute make more moves forced; and only when nothing is forced do you guess — and then you guess where you have the fewest options, so a wrong guess is discovered fast.
The solver above recomputes candidates on demand. A stronger version maintains a candidate set per cell and removes a value from its neighbours the moment it is placed — forward checking. If any neighbour's set empties, backtrack immediately.
Push that further and you get constraint propagation: repeatedly apply the forced consequences until nothing changes, and only then guess. Peter Norvig's well-known Sudoku solver does exactly this and solves every published puzzle in milliseconds. Most of the code is propagation; the search is almost an afterthought.
5When to backtrack, and when not to
Backtracking is what you use when the state space is too large to tabulate and no local rule is safe.
| Backtracking | Dynamic programming | |
|---|---|---|
| State space | Astronomical — cannot be enumerated | Manageable — fits in a table |
| Subproblems | Usually distinct | Overlapping |
| Produces | All solutions, or one, or the best | One optimal value |
| Memory | O(depth) — just the stack | O(states) |
| Speed comes from | Pruning | Not recomputing |
The signals in a problem statement are fairly reliable:
- “Find all…” — backtracking. DP counts and optimises; it does not enumerate.
- “Is there any assignment satisfying…” — constraint search.
- A permutation, arrangement or board position is the answer — backtracking.
- “How many ways” or “the minimum cost” — DP first; fall back to search only if the state space explodes.
The two are not exclusive. Memoised backtracking — caching on a
canonical description of the partial state — is exactly how travelling-salesman DP over
bitmasks works: the state is (set of cities visited, current city), and
2ⁿ · n states is enormous but finite, which beats n!
paths.
The question is always whether two different search paths can arrive at the same situation. If they can, cache it. If every path leads somewhere genuinely different, there is nothing to cache and pruning is your only lever.
Branch and bound is backtracking plus an optimistic estimate. Keep the best complete solution found so far; at each node, compute an upper bound on what this branch could possibly achieve; if the bound is no better than the incumbent, prune. It is how exact solvers for 0/1 knapsack, TSP and integer programming actually work in practice, and it is why they can handle instances whose brute-force size is astronomically large.
- Recursion depth. Backtracking depth is the length of a solution, which is usually small — but a solver over 10,000 items will hit Python's default limit of about 1,000. Either raise it or convert to an explicit stack.
- Copying at every node.
path[:]at each of a million nodes is a million list copies. Copy only when you record a solution; mutate and un-choose everywhere else.
What to carry forward
- Backtracking is DFS over the tree of partial solutions: choose, explore, un-choose.
- The un-choose is the line people forget. The symptom is answers that accumulate rubbish from abandoned branches.
- Subsets loop from a start index and recurse with
i+1; combinations with repetition recurse withi; permutations loop over everything with aused[]array. - Handle duplicate inputs by sorting and skipping siblings:
if i > start and nums[i] == nums[i-1]: continue. - Pruning is the algorithm. Without it, backtracking is brute force; with it, 8-Queens goes from 16 million checks to about 2,000 nodes.
- Keep incremental state (sets of used columns and diagonals) so each validity check is O(1) rather than a scan.
- Three kinds of pruning: constraint (already illegal), bound (cannot beat the incumbent), symmetry (equivalent to a branch already done).
- Most-constrained variable first turns a naive Sudoku solver from minutes into milliseconds. Forced moves are free.
- Use backtracking when the state space is too large to tabulate; use DP when different paths reach the same situation.
- Branch and bound = backtracking + an optimistic estimate + the best answer so far. It is how real exact solvers work.
>_Playground
The three generators, N-Queens with and without pruning, and a Sudoku solver with and without MRV.
✓Exercises
Checked automatically the moment you submit. Work top to bottom — each one assumes the last. Your answers are saved in this browser.
Chapter 28 — String Algorithms
Find a needle in a haystack without re-reading the haystack. KMP never backs up, Rabin-Karp hashes a rolling window, and both are prettier than they look.