Divide and conquer chops, greedy bets, dynamic programming remembers. But there are problems where none of that is enough: constraint problems, where the solution is a combination of decisions that must be mutually compatible — assigning drivers to shifts, squaring timetables, generating valid inspection routes — and where no recurrence or greedy criterion will do. For them there is the module's fourth strategy: backtracking, a systematic exploration of the solution space that advances decision by decision and, as soon as it detects a dead end, undoes the last step and tries another alternative. It is the most expensive of the four strategies — its cost is exponential in the worst case — but, well pruned, it solves problems at which the others just shrug. With it we will close the module by comparing the four design strategies face to face.
Contents
- The idea: exploring a decision tree with going back
- The general scheme: choose, explore, undo
- RutaBus example: assigning drivers to shifts with incompatibilities
- Pruning: why it matters so much
- N-Queens: the compact classic
- Backtracking vs brute force vs DP — and the four strategies face to face
- The exponential cost
The idea: exploring a decision tree with going back
Imagine you build the solution in stages: first decision, second decision, third... Each possible sequence of decisions is a branch of a decision tree; the leaves are complete candidate solutions. Brute force generates all the leaves and checks which ones are valid. Backtracking is smarter:
- It traverses the tree depth-first, taking one decision at a time (the recursion stack from 01-02 and 02-02 keeps track of the current path).
- At each node it checks whether the partial solution can still lead to a valid solution. If it can't, it prunes: it abandons that entire branch without generating it.
- When a node's alternatives run out, it backtracks to the previous node and tries the next option.
The difference with greedy is radical: greedy takes a decision and never comes back; backtracking always reserves the right to regret. The difference with brute force is the pruning: combinations whose failure is already guaranteed halfway through construction are never examined.
The general scheme: choose, explore, undo
Almost every backtracking is written with the same three-beat template. It is worth memorizing:
def backtracking(partial_solution):
# Is the partial solution already complete?
if is_complete(partial_solution):
record(partial_solution) # store it, print it, count it...
return
# Try each alternative for the next decision
for candidate in next_candidates(partial_solution):
if is_valid(candidate, partial_solution): # PRUNING: does it have a future?
partial_solution.append(candidate) # 1. CHOOSE
backtracking(partial_solution) # 2. EXPLORE (recursion)
partial_solution.pop() # 3. UNDO (the going back!)Let's comment on the key pieces:
is_complete: the equivalent of the recursion's base case (01-02). Here a complete solution does not stop the algorithm: it is recorded and the search continues for others (unless we only want one: then we can returnTrueand propagate the cutoff).is_valid: the pruning function. It checks constraints over the partial solution; the earlier it detects infeasibility, the more tree is saved.append/pop: the sacred pair. Everything the "choose" step modifies, the "undo" step must restore exactly. We work on a single shared structure (reference, not copy — remember 02-02) that is woven and unwoven as we go.- The state of the path lives in the call stack: on returning from the recursion we are, automatically, one level up the tree.
RutaBus example: assigning drivers to shifts with incompatibilities
The problem. Tomorrow RutaBus must cover the three shifts of line L1 — Morning, Evening and Night — with its three staff drivers: Anna, Bruno and Carla. Each driver does exactly one shift, but there are availability constraints:
- Anna cannot do the night shift (family care).
- Bruno cannot do the morning one (he finishes today at 23:00: legally required rest).
- Carla cannot do the evening one (mandatory training).
We want all the valid assignments (planning needs alternatives in case someone falls ill).
SHIFTS = ["Morning", "Evening", "Night"]
DRIVERS = ["Anna", "Bruno", "Carla"]
INCOMPATIBLE = { # driver -> shifts they CANNOT do
"Anna": {"Night"},
"Bruno": {"Morning"},
"Carla": {"Evening"},
}
def assign_shifts(assignment=None, solutions=None):
"""Assigns a distinct driver to each shift, honoring INCOMPATIBLE.
assignment: dict shift -> driver (partial solution)."""
if assignment is None:
assignment, solutions = {}, []
if len(assignment) == len(SHIFTS): # complete?
solutions.append(dict(assignment)) # copy: the partial one is reused
return solutions
shift = SHIFTS[len(assignment)] # next decision: this shift
for driver in DRIVERS:
free = driver not in assignment.values()
compatible = shift not in INCOMPATIBLE[driver]
if free and compatible: # double PRUNING
assignment[shift] = driver # CHOOSE
assign_shifts(assignment, solutions) # EXPLORE
del assignment[shift] # UNDO
return solutions
for s in assign_shifts():
print(s)
# {'Morning': 'Anna', 'Evening': 'Bruno', 'Night': 'Carla'}
# {'Morning': 'Carla', 'Evening': 'Anna', 'Night': 'Bruno'}Implementation details that separate a correct backtracking from a treacherous one:
solutions.append(dict(assignment)): we store a copy. If we stored the reference, the laterdelwould empty it — the classic copies-vs-references mistake of 02-02.- The decision at each level is "which driver does shift k": the tree has depth 3 (one per shift) and up to 3 branches per node.
- The pruning is double: driver already busy (
free) and the availability constraint (compatible).
The complete search tree, with the prunings marked:
flowchart TD
R["Morning = ?"] --> A["Anna ✔"]
R -.-> B["Bruno ✘ pruned: no mornings"]
R --> C["Carla ✔"]
A --> AT["Evening = ?"]
AT --> AB["Bruno ✔"]
AT -.-> AC["Carla ✘ pruned: no evenings"]
AB --> ABN["Night = Carla ✔ SOLUTION 1"]
C --> CT["Evening = ?"]
CT --> CA["Anna ✔"]
CT --> CB["Bruno ✔"]
CA --> CAN["Night = Bruno ✔ SOLUTION 2"]
CB --> CBN["Night = Anna ✘ pruned: no nights → BACKTRACK"]
Follow the right branch with your finger: after assigning Morning=Carla and Evening=Bruno, the only free driver for Night is Anna — but Anna doesn't do nights. The branch dies, the algorithm undoes Evening=Bruno, tries the next alternative (there are none left), undoes Morning=Carla... and since there are no options left at the root either, it finishes with the 2 solutions found. That backing-up movement is literally the execution of the del assignment[shift] upon returning from the recursion.
Without pruning, we would have generated the 3! = 6 complete permutations and discarded 4. Here the saving is modest; with 20 shifts and 20 drivers, 20! ≈ 2.4 × 10¹⁸ permutations make the difference between "impossible" and "instant" — if the prunings cut early.
Pruning: why it matters so much
Pruning is the difference between backtracking and combinatorial suicide. Two principles:
- Prune as early as possible. A constraint checked at level 2 eliminates entire subtrees; the same constraint checked at the leaf eliminates only one leaf. That is why
is_validoperates on the partial solution, not the complete one. - Order the decisions to fail early. It pays to decide the most constrained variable first (the shift with the fewest possible drivers): failures surface high in the tree, where pruning is cheap. It is an ordering heuristic; it doesn't change correctness.
| Checking strategy | Nodes explored (idea) | Cost |
|---|---|---|
| Generate everything and filter at the end (brute force) | All the leaves: kⁿ or n! | Unaffordable already at n ≈ 12-15 |
| Prune at every node (backtracking) | Only branches "with a future" | Exponential in the worst case, useful in practice |
Let's be honest: pruning does not change the worst case. An adversary can craft instances where almost nothing gets pruned. Backtracking belongs to the O(2ⁿ)/O(n!) family of the hierarchy of 01-03, and no pruning gets it out of there in general; what pruning does is make it practicable on real instances, which are rarely adversarial.
N-Queens: the compact classic
The N-Queens problem — placing N queens on an N×N board so that none attacks another (same row, column or diagonal) — is the "hello world" of backtracking, and we can afford it here because it belongs to no other module. Decision per level: in which column does the queen of row k go.
def n_queens(n):
"""Returns all the solutions; each one is a list where
solution[row] = column of the queen in that row."""
solutions = []
def is_valid(placed, col):
row = len(placed)
for r, c in enumerate(placed):
if c == col: # same column
return False
if abs(c - col) == abs(r - row): # same diagonal
return False
return True # same row: impossible by design
def explore(placed):
if len(placed) == n: # complete
solutions.append(placed[:])
return
for col in range(n):
if is_valid(placed, col): # PRUNE
placed.append(col) # CHOOSE
explore(placed) # EXPLORE
placed.pop() # UNDO
explore([])
return solutions
print(len(n_queens(4))) # 2
print(n_queens(4)) # [[1, 3, 0, 2], [2, 0, 3, 1]]Note the representation trick: storing only the column per row eliminates at the root the row conflicts (each row has exactly one queen) — choosing the partial solution's structure well is itself a form of pruning. For n = 8, brute force over the 8⁸ ≈ 16.7 million placements (or the 8! = 40,320 permutations, with the good representation) comes down to about ~2,000 nodes explored with pruning: that is the order of magnitude of the saving.
Backtracking vs brute force vs DP — and the four strategies face to face
First the direct duel:
| Brute force | Backtracking | Dynamic programming | |
|---|---|---|---|
| What it explores | All complete combinations | Only viable branches (pruning) | All subproblems, once each |
| Requirement | None | Constraints checkable on partial solutions | Optimal substructure + overlapping subproblems |
| Result | Exact | Exact | Exact |
| Typical cost | kⁿ, n! | Pruned exponential | Polynomial (number of subproblems) |
| When to use it | Never, except tiny n | Constraints with no recurrence structure | When the recurrence exists |
The rule of thumb: if you can define a subproblem and a recurrence, DP turns the exponential tree into a polynomial table — do it. Backtracking is for when the states don't repeat or can't be summarized (each partial solution is unique, like a concrete assignment of drivers): there, there is nothing to memoize and all that remains is to search intelligently.
And the module's closing — the four strategies face to face:
| Strategy | Idea in one sentence | Question that gives the problem away | Typical cost | Guaranteed optimal |
|---|---|---|---|---|
| Divide and conquer (03-01) | Chop into independent subproblems and combine | "Can I split it into independent halves?" | O(n log n) | Yes (if the design is correct) |
| Greedy (03-02) | Decide what's best now, with no going back | "Is there a local criterion that never closes the door on the optimum?" | O(n log n) | Only with a proof |
| Dynamic programming (03-03) | Explore all decisions while memoizing subproblems | "Overlapping subproblems with a recurrence?" | O(n·m) polynomial | Yes |
| Backtracking (03-04) | Build in stages, prune and undo | "Constraints that can be checked midway?" | Pruned exponential | Yes (it explores everything viable) |
Faced with a new RutaBus problem, this is the recommended interrogation order: a provable greedy? (the cheapest); if not, is there a recurrence with overlap? (DP); does it split into independent halves? (divide and conquer); none of the above but there are checkable constraints? (backtracking). And if not even that, perhaps it's time for a heuristic assumed as such — like our veteran supervisor_route.
The exponential cost
Let's analyze backtracking with the tools of Module 2. In the worst case, the decision tree has branching factor b (alternatives per level) and depth n (decisions): up to bⁿ nodes. For permutation-style assignments, the unpruned tree has n! leaves. It is the summit of the hierarchy of 01-03, the zone where adding one element multiplies the time.
- Time: O(bⁿ) or O(n!) in the worst case, multiplied by the cost of
is_validat each node (keep it cheap: ideally O(1) or O(n) as in N-Queens!). - Space: surprisingly modest — O(n) for the partial solution plus O(n) of recursion stack (02-02). Backtracking does not store the tree: it walks it. That is why it can attack astronomical search spaces with pocket-sized memory.
- The best and average case (02-03) depend brutally on the prunings and the exploration order: two correct implementations of the same problem can differ by factors of thousands. In backtracking, the engineering of the pruning is the performance.
Common Mistakes and Tips
- Undoing halfway. If "choose" touches two structures (the assignment and a set of busy drivers), "undo" must restore both. Any asymmetry silently corrupts the following branches. Check that every
append/add/assignment has its mirrorpop/remove/del. - Storing references instead of copies.
solutions.append(assignment)stores an object that will keep mutating; in the end you'll have a list of empty or all-identical solutions. Copy (dict(assignment),some_list[:]) when recording — the subtlety of 02-02 striking again. - Pruning late. Checking validity only at the leaves turns backtracking into brute force with extra steps. Ask yourself which constraints you can evaluate with the solution halfway built.
- Forgetting the return after recording. Without the
returnupon completing the solution, the loop keeps "extending" an already-complete solution, with index errors or ghost solutions. - Recomputing
is_validfrom scratch. In N-Queens,is_validis O(n); maintaining sets of occupied columns and diagonals brings it down to O(1) per query in exchange for some memory — the trade-off of 02-02, once again. (And it adds two more structures that choose/undo must keep in mirror.) - Using backtracking where there is a recurrence. If you detect repeated subproblems, you are paying an exponential price for something DP solves in polynomial time. Review the comparison table before writing a single line.
Exercises
Exercise 1
A fourth shift ("Reinforcement") and a fourth driver ("David") join, with these additional constraints: David can only do Reinforcement or Night, and Bruno cannot do Reinforcement either. Adapt assign_shifts (updating the constants is enough) and compute by hand, drawing the tree, how many valid solutions there are. Then check with the code.
Exercise 2
Write inspection_routes(network, origin, k) that generates all the inspection routes of exactly k stops starting at origin, without repeating a stop, moving only between connected stops. The network is an adjacency dictionary, for example:
network = {
"Main Square": ["North Station", "River Park"],
"North Station": ["Main Square", "Central Hospital"],
"Central Hospital": ["North Station", "River Park"],
"River Park": ["Main Square", "Central Hospital"],
}Exercise 3
In n_queens, the function is_valid walks over the queens already placed: O(n) per check. Rewrite the algorithm keeping three sets — occupied columns, row − col diagonals and row + col diagonals — so that the check is O(1). Don't forget the choose/undo mirror over the three sets.
Solutions
Solution 1
New constants:
SHIFTS = ["Morning", "Evening", "Night", "Reinforcement"]
DRIVERS = ["Anna", "Bruno", "Carla", "David"]
INCOMPATIBLE = {
"Anna": {"Night"},
"Bruno": {"Morning", "Reinforcement"},
"Carla": {"Evening"},
"David": {"Morning", "Evening"}, # only Reinforcement or Night
}Tree: for Morning only Anna or Carla fit. (a) Morning=Anna → Evening ∈ {Bruno} (Carla doesn't do evenings, David doesn't either) → Night ∈ {Carla, David}: with Night=Carla we are left with Reinforcement=David ✔; with Night=David we are left with Reinforcement=Carla ✔. (b) Morning=Carla → Evening ∈ {Anna, Bruno}. With Evening=Anna → Night ∈ {Bruno, David}: Night=Bruno leaves Reinforcement=David ✔; Night=David leaves Reinforcement=Bruno ✘ (Bruno doesn't do reinforcements: prune and backtrack). With Evening=Bruno → Night ∈ {David} (Anna doesn't do nights) → Reinforcement=Anna ✔. Total: 4 solutions. If your manual count differs from the code, the code wins — and the exercise becomes finding the branch you miscounted.
Solution 2
def inspection_routes(network, origin, k):
solutions = []
def explore(route):
if len(route) == k: # complete
solutions.append(route[:]) # copy
return
for neighbor in network[route[-1]]: # candidates: connected stops
if neighbor not in route: # PRUNE: no repeated stops
route.append(neighbor) # CHOOSE
explore(route) # EXPLORE
route.pop() # UNDO
explore([origin])
return solutions
print(inspection_routes(network, "Main Square", 3))
# [['Main Square', 'North Station', 'Central Hospital'],
# ['Main Square', 'River Park', 'Central Hospital']]The three-beat template, intact; only the candidates change (neighbors in the network) and the pruning (no repeats). The neighbor not in route is an O(k) list search (the hidden cost from 02-01); with a parallel set it would drop to O(1). This function is, moreover, our first exploration of the RutaBus network as a graph — a seed of what is coming in Module 4.
Solution 3
def n_queens_fast(n):
solutions = []
cols, diag1, diag2 = set(), set(), set()
def explore(placed):
row = len(placed)
if row == n:
solutions.append(placed[:])
return
for col in range(n):
if col in cols or (row - col) in diag1 or (row + col) in diag2:
continue # PRUNE in O(1)
placed.append(col) # CHOOSE (x4 structures)
cols.add(col); diag1.add(row - col); diag2.add(row + col)
explore(placed) # EXPLORE
placed.pop() # UNDO (x4, in mirror)
cols.remove(col); diag1.remove(row - col); diag2.remove(row + col)
explore([])
return solutionsThe "descending" diagonals share the value row − col and the "ascending" ones the value row + col; three O(1) set lookups replace the O(n) loop. The price: four structures that choose/undo must keep perfectly synchronized — check that every add has its remove.
Conclusion
Backtracking completes our repertoire: build the solution in stages, prune the branches with no future and undo to explore alternatives, with the choose–explore–undo template as universal skeleton and the mirror discipline (everything done gets undone) as the golden rule. We have applied it to RutaBus's driver assignment, to the inspection routes over the network — our first walk through it as a graph — and to the classic N-Queens, and we have accepted its nature: exponential cost in the worst case, which only the quality of the prunings makes practicable. With this, Module 3 is complete: divide and conquer for independent subproblems, greedy for provably safe local decisions, dynamic programming for overlapping subproblems with a recurrence, and backtracking for constraints that demand searching with going back — four molds, four diagnostic questions, and the judgment of Module 2 to subject to analysis any design that comes out of them. In Module 4 we will bring in the harvest: the classic algorithms born from these strategies — binary search and the merge sort and quick sort orderings as children of divide and conquer, Dijkstra as the greedy that is optimal, Floyd-Warshall as dynamic programming on graphs — implemented and analyzed piece by piece, starting with the jewel of logarithmic efficiency: binary search.
