Backtracking

Some problems ask for all the solutions: every subset, every permutation, every way to place 8 queens. Backtracking builds a solution one choice at a time; when it has a complete solution it records it, and when a choice is done it undoes it and tries the next.

The template

def backtrack(path, choices):
    if the path is a complete solution:
        record a copy of path
        return
    for choice in the choices still allowed:
        path.append(choice)          # choose
        backtrack(path, ...)         # explore
        path.pop()                   # un-choose

Subsets

At each position, an element is either in or out. Recording the path at every node of the search, and only moving forward through the list, gives each subset exactly once:

def subsets(items):
    result = []
    path = []

    def backtrack(start):
        result.append(path[:])            # every path is a subset
        for i in range(start, len(items)):
            path.append(items[i])         # choose
            backtrack(i + 1)              # explore
            path.pop()                    # un-choose

    backtrack(0)
    return result

print(subsets(["a", "b", "c"]))
Open in compiler

Step through and watch path grow and shrink: that shrinking is the "back" in backtracking. n elements give 2n subsets, so the time is O(n × 2n): exponential, as any algorithm that lists them all must be. Note path[:]: recording path itself would store the same list, which ends up empty.

Permutations

Every order of n items: at each level, choose any item not used yet. n! of them, so keep n small (8! = 40,320 is fine; 12! is half a billion).

Combination sum, and pruning

All combinations of the numbers that add up to a target, each number usable any number of times.

def combination_sum(candidates, target):
    candidates = sorted(candidates)
    result = []

    def backtrack(start, path, remaining):
        if remaining == 0:
            result.append(path[:])
            return
        for i in range(start, len(candidates)):
            c = candidates[i]
            if c > remaining:
                break                     # pruning: every later candidate is bigger too
            path.append(c)
            backtrack(i, path, remaining - c)   # i, not i + 1: c may be used again
            path.pop()

    backtrack(0, [], target)
    return result

print(combination_sum([2, 3, 6, 7], 7))
print(combination_sum([2, 3, 5], 8))
Open in compiler

Two tricks make it work:

  • No duplicates: only allow choices from the current position onward, so [2, 3] is found but never [3, 2] as well.
  • Pruning: with the candidates sorted, once a candidate is bigger than what is left of the target, every later one is too, so the loop stops (break). Cutting off branches that cannot succeed is what makes backtracking fast enough in practice; the N-queens and Sudoku solvers rely on it.

Quick check

1. What does the 'un-choose' step do?
2. How many subsets does a set of 10 elements have?
3. Why record path[:] instead of path?

Practice problems for this lesson

My notes

Write down what you want to remember from this lesson. Notes are saved on this device as you type, and appear on your dashboard.