Ch 27 / 30 Backtracking and Constraint Search 0/0 exercises Exercises ↓

AmouAI Hub/Courses/Data Structures & Algorithms/Chapter 27

Part 6 · Design Paradigms · 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.

Reading
CLRS Ch. 34 intro
Focus
Pruning
Cost
exponential, pruned
Needs
Recursion, Ch. 10

By the end of this chapter you can

  1. Write the choose / explore / un-choose skeleton from memory
  2. Generate subsets, permutations and combinations, and handle duplicates without producing duplicates
  3. Explain why pruning, not the search, is the algorithm
  4. Implement N-Queens with incremental constraint sets rather than a full board scan
  5. Apply the most-constrained-variable heuristic to a Sudoku solver
  6. 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.

A maze with chalk

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.

skeleton.pymemorise this
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 forget
The three lines, and the one that matters

Choose, 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:

subsets.py2&#8319; results
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 out

Permutations. Order matters, so every unused element is a candidate at every position. n! results:

permutations.pyn! 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 out
Duplicates: sort, then skip siblings

With 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.

ProblemLoopRecurse withCount
Subsetsfor i in range(start, n)i + 12ⁿ
Combinations of size ksame, and stop at length ki + 1C(n, k)
Combination sum (reuse allowed)samei — not i+1varies
Permutationsfor i in range(n) with a used[] arrayn!

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.

queens.pyO(1) validity check
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 count
The two diagonal identities

Squares 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.

8-Queens, no pruning
16.7 million
8-Queens, one per row
16.7 million
8-Queens, pruned
~2,000 nodes
8-Queens, solutions
92

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.

Most-constrained variable (MRV)

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.

sudoku.py
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()
Filling in a crossword

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 next step up: forward checking

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.

BacktrackingDynamic programming
State spaceAstronomical — cannot be enumeratedManageable — fits in a table
SubproblemsUsually distinctOverlapping
ProducesAll solutions, or one, or the bestOne optimal value
MemoryO(depth) — just the stackO(states)
Speed comes fromPruningNot 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.
They combine

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.

Two practical failure modes
  • 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 with i; permutations loop over everything with a used[] 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.

scratch.pypython not loaded
Real Python, running in your browser. Nothing is installed or uploaded.
Output appears here. The first run takes a few seconds while Python loads.

Exercises

Checked automatically the moment you submit. Work top to bottom — each one assumes the last. Your answers are saved in this browser.

All Warm-up Core Challenge Reset chapter

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.

Continue →