Backtracking: Choose, Explore, Undo
22 min · Intermediate
Need help? Ask a tutor on WhatsApp (opens in a new tab)Some problems ask for every valid answer: all codes, all teams, all routes. You build each answer one choice at a time. When a path is finished or stuck, you take the last choice back and try the next one. That pattern is backtracking.
Choose, explore, undo
Backtracking is a way to build answers one choice at a time. After exploring a choice you undo it and try the next one, so every possibility is visited once.
A lock has 2 digits, each 0 or 1. List every code. Pick a digit, go one level deeper, then take the digit back and try the other one.
def codes(path, length): if len(path) == length: print("".join(path)) return for digit in "01": path.append(digit) # choose codes(path, length) # explore path.pop() # undo
codes([], 2)Example 1 · All codes of length 3
def codes(length): results = []
def walk(path): if len(path) == length: results.append("".join(path)) return for digit in "01": path.append(digit) walk(path) path.pop()
walk([]) return results
print(codes(3))['000', '001', '010', '011', '100', '101', '110', '111']
What this code does
- The inner function
walkreachesresultsandlengthfrom the outer function, so they need no extra arguments. - Eight codes appear because 2 choices at 3 levels give 2 x 2 x 2 leaves.
- The outer function returns a fresh list on every call, so nothing is shared between calls.
path is one list shared by every call. append chooses a digit and pop takes it back, so the list is exactly as before when the next digit is tried.
The search is a tree
Decision tree is a drawing of every choice as a branch. The root is the empty answer, each level adds one more choice, and the leaves are the finished answers.
Every call is a node. Every choice is a branch. A finished answer sits at a leaf. The loop and the recursion together walk the whole tree, left branch first.
def trace(path, length, depth=0): print(" " * depth + "at", path) if len(path) == length: return for digit in "01": path.append(digit) trace(path, length, depth + 1) path.pop()
trace([], 2)Example 2 · Watch the path grow and shrink
def walk(path, length): if len(path) == length: return for letter in "ab": path.append(letter) print("enter", "".join(path)) walk(path, length) print("leave", "".join(path)) path.pop()
walk([], 2)enter a enter aa leave aa enter ab leave ab leave a enter b enter ba leave ba enter bb leave bb leave b
What this code does
- Each
enterline is a choice and eachleaveline is its undo, printed just beforepop. - After
leave aathe path isaagain, and the loop triesb. - Every path appears once as
enterand once asleave.
The indentation shows the depth. The answers are the lines at the deepest level.
Save a copy, not the path
Shared state is data that every recursive call can reach and change, such as one path list. It saves memory, but each change must be undone before the next choice is tried.
To keep answers in a list, copy the path first. The path keeps changing, so saving it directly saves the same list again and again.
results = []
def walk(path, length): if len(path) == length: results.append(path[:]) # a copy return for digit in "01": path.append(digit) walk(path, length) path.pop()
walk([], 2)print(results)Example 3 · The copy bug
bad, good = [], []
def walk(path, length): if len(path) == length: bad.append(path) good.append(path[:]) return for digit in "01": path.append(digit) walk(path, length) path.pop()
walk([], 2)print(bad)print(good)[[], [], [], []] [['0', '0'], ['0', '1'], ['1', '0'], ['1', '1']]
What this code does
badholds four references to the one shared list, which is empty at the end.goodholds four separate copies made at the right moment.- If your answers come out empty or all equal, look for a missing copy first.
Without [:] every item in results would be the same list, and it ends up empty after the last pop. list(path) also makes a copy.
The cost: count the leaves
With 2 choices at each of n levels there are 2 to the power n leaves. The extra memory is only the depth n, because the path is one list. Answers grow fast, so keep n small.
def count(depth, n): if depth == n: return 1 return 1 + 2 * count(depth + 1, n)
print(count(0, 10))Example 4 · Leaves and calls as n grows
def count(depth, n): if depth == n: return 1 return 1 + 2 * count(depth + 1, n)
for n in range(1, 6): print(f"n={n} leaves={2 ** n} calls={count(0, n)}")n=1 leaves=2 calls=3 n=2 leaves=4 calls=7 n=3 leaves=8 calls=15 n=4 leaves=16 calls=31 n=5 leaves=32 calls=63
What this code does
- Leaves double with every level, and the calls are one less than twice the leaves.
- Doubling is why backtracking only works for small inputs unless you prune.
Example 5 · itertools does it in one line
from itertools import product
words = ["".join(p) for p in product("abc", repeat=2)]print(words)print(len(words))['aa', 'ab', 'ac', 'ba', 'bb', 'bc', 'ca', 'cb', 'cc'] 9
What this code does
product("abc", repeat=2)gives every pair, the same as two nested loops.- Use it when every position can take any value, whatever the other positions hold.
- Write the recursion yourself when a choice depends on the earlier ones.
Example 6 · A choice that depends on earlier ones
def words(length): results = []
def walk(path): if len(path) == length: results.append("".join(path)) return for letter in "abc": if path and path[-1] == letter: continue path.append(letter) walk(path) path.pop()
walk([]) return results
print(words(3))['aba', 'abc', 'aca', 'acb', 'bab', 'bac', 'bca', 'bcb', 'cab', 'cac', 'cba', 'cbc']
What this code does
- The
continueskips a letter equal to the previous one, so those branches are never explored. productcannot do this, which is the reason to learn the template.- There are 3 x 2 x 2 = 12 words, not 27.
count returns the number of calls. For n = 10 there are 1024 leaves and 2047 calls in total.
Next step
Check your understanding
11 quick questions