Searching for solutions
Backtracking with pruning, Dijkstra's algorithm and A*, ending with an optimal sliding-puzzle solver.
Create a free account Try part of it here
Why it matters
Many problems aren't "compute the answer" but "find one among millions of possibilities": a packing plan, a route, a schedule, a puzzle's solution. Trying everything is hopeless, but three ideas tame most of them. Backtracking abandons a partial answer the moment it can't work. Dijkstra explores the cheapest options first. A* adds a sense of direction. They power route planners, game AI, schedulers and constraint solvers, and they come up in interviews whenever a problem says "minimum number of moves".
What you'll be able to do
- Solve a puzzle by backtracking: try a choice, check it, undo it, and prune hopeless branches early
- Find the cheapest route on a weighted map with Dijkstra's algorithm and a priority queue
- Speed up the search with A* and a heuristic that never overestimates
- Solve a sliding-tile puzzle optimally, and spot the boards that can't be solved
What it covers
- Backtracking: try, check, undo
- Every load that fits exactly
- The cheapest route: Dijkstra's algorithm
- A: searching with a sense of direction
- Moves on a sliding-tile board
- Choosing a search
- Worked example: boards that can't be solved
Show the answer
Backtracking: try, check, undo, try the next
Backtracking builds a solution step by step and abandons a branch as soon as it can't work, which skips huge numbers of hopeless combinations. This lesson adds Dijkstra and A* for routes.
Backtracking: try, check, undo
The club's pack mule can carry exactly 30 kg. Which of the waiting bundles make up exactly that? A backtracking search builds an answer one choice at a time (take this bundle, or skip it) and walks back the moment a partial answer can't lead anywhere. The walking back is what makes it fast: one check high up the tree can rule out thousands of complete answers below it. That's called pruning.
bundles = [12, 9, 7, 7, 6, 5, 4, 3, 2] # kg, heaviest first calls = {"plain": 0, "pruned": 0} def plain(i, chosen, total): calls["plain"] += 1 if i == len(bundles): return chosen if total == 30 else None return plain(i + 1, chosen + [bundles[i]], total + bundles[i]) or plain(i + 1, chosen, total) def pruned(i, chosen, total): calls["pruned"] += 1 if total == 30: return chosen if total > 30 or i == len(bundles) or total + sum(bundles[i:]) < 30: return None # over the limit, or can't reach it with what's left return pruned(i + 1, chosen + [bundles[i]], total + bundles[i]) or pruned(i + 1, chosen, total) print(plain(0, [], 0), calls["plain"], "calls") print(pruned(0, [], 0), calls["pruned"], "calls")
Both find the same load, 12 + 9 + 7 + 2, but the plain version makes 129 calls and the pruned one 15. The plain version wanders down branches that are already too heavy, or can never reach 30 kg with what's left; the pruned one stops as soon as either is true. Sudoku solvers, timetables and seating plans all work this way: fill one slot, check the rules, and back up on a clash.
Two details in the code. The or tries the "take it" branch first and falls back to "skip it" when that returns None; it works because a found load is a non-empty list, which counts as true (an empty list [] would count as false, so a search whose answer could be empty should test is not None instead). And here nothing needs undoing, because chosen + [bundles[i]] builds a new list for each branch. The classic style keeps one list and undoes each choice by hand, which saves copying; the drill below uses it.
pruned check total + sum(bundles[i:]) < 30?Show the answer
If even taking every remaining bundle can't reach 30 kg, nothing below this branch can work, so stop now
It's a bound: the best this branch could possibly do. When even that falls short, the whole subtree is skipped. The total > 30 test is the matching bound from the other side.
Every load that fits exactly
Now find every combination of bundles that weighs exactly the target, using one shared list chosen: append a bundle (try), explore, then chosen.pop() (undo) before exploring without it. When the total hits the target, store a copy, list(chosen), since chosen keeps changing. The starter forgets the undo and never prunes. Fix both: add the pop, and stop when even all the remaining bundles can't reach the target. stats counts the calls, so the checks can see the pruning.
stats = {"calls": 0}
def all_loads(bundles, target):
found, chosen = [], []
def explore(i, total):
stats["calls"] += 1
if total == target:
found.append(list(chosen))
return
if i == len(bundles) or total > target:
return
chosen.append(bundles[i]) # try: take bundle i
explore(i + 1, total + bundles[i])
explore(i + 1, total) # skip bundle i
explore(0, 0)
return found
print(all_loads([12, 9, 7, 7, 6, 5, 4, 3, 2], 30)[:3])
The cheapest route: Dijkstra's algorithm
Breadth-first search, from the trees and graphs lesson, finds the route with the fewest steps. On a trail map the steps have different lengths, and the fewest steps can be the longest walk. Dijkstra's algorithm finds the route with the smallest total cost. It always grows outward from the cheapest place it hasn't finished yet, and a priority queue hands it that place each time.
Python's priority queue is the heapq module, working on a plain list. heappush adds an item and heappop always removes the smallest, whatever order things went in. Tuples compare by their first value, then their second, so pushing (cost, place) pairs makes the cheapest place come out first:
import heapq queue = [] for item in [(7, "ford"), (2, "bridge"), (5, "falls"), (2, "alder")]: heapq.heappush(queue, item) while queue: print(heapq.heappop(queue))
The two 2s tie on cost, so the names decide: "alder" before "bridge". Now Dijkstra. Once a place comes off the queue, its distance is final: anything reached later costs at least as much. (This map lists each trail one way, from the trailhead up, which keeps it short; a real map would list both directions.)
import heapq trails = { # km between junctions "trailhead": {"bridge": 2, "ford": 7}, "bridge": {"falls": 3, "ridge": 9}, "ford": {"ridge": 3}, "falls": {"ridge": 4, "summit": 10}, "ridge": {"summit": 3}, "summit": {}, } def dijkstra(graph, start, goal): queue = [(0, start, [start])] done = set() while queue: dist, place, path = heapq.heappop(queue) if place == goal: return dist, path if place in done: continue done.add(place) for nxt, km in graph[place].items(): if nxt not in done: heapq.heappush(queue, (dist + km, nxt, path + [nxt])) return None print(dijkstra(trails, "trailhead", "summit"))
The answer goes trailhead, bridge, falls, ridge, summit: four trails and 12 km. Every route with only three trails is longer (the best of them, through the ford, is 13 km), so the fewest steps isn't the shortest walk. Dijkstra needs one rule: no negative costs, since it assumes that walking further never makes a route cheaper.
import heapq graph = {"A": {"B": 4, "C": 1}, "C": {"B": 2}, "B": {}} queue, done = [(0, "A")], {} while queue: d, place = heapq.heappop(queue) if place in done: continue done[place] = d for nxt, cost in graph[place].items(): heapq.heappush(queue, (d + cost, nxt)) print(done)
Show the answer
{'A': 0, 'C': 1, 'B': 3}
C comes off the queue first (cost 1), and from C, B costs 1 + 2 = 3, which beats the direct 4. The (4, 'B') entry is popped later and skipped, because B is already done.
Each pop takes the cheapest place in the queue; its distance never changes after that.
A*: searching with a sense of direction
Dijkstra spreads out evenly in every direction, including away from the goal. A star, written A*, adds a guess of the remaining cost, the heuristic, and always expands the place with the lowest cost so far + guessed cost to go. On a map, the straight-line distance is a good guess; on a grid where you can only move up, down, left and right, the Manhattan distance (rows apart plus columns apart) is.
The one rule: the guess must never overestimate. A heuristic that never overestimates is admissible, and with one, A* still finds the cheapest answer, usually after expanding far fewer places (taking a place off the queue and looking at its neighbors). Here's a field with one wall in the way:
import heapq grid = [ "............", "..S.........", "............", "......#.....", "......#.....", "......#..G..", "......#.....", "............", ] rows, cols = len(grid), len(grid[0]) start = next((r, c) for r in range(rows) for c in range(cols) if grid[r][c] == "S") goal = next((r, c) for r in range(rows) for c in range(cols) if grid[r][c] == "G") def search(guess): queue, best, expanded = [(guess(start), 0, start)], {start: 0}, 0 while queue: _, cost, (r, c) = heapq.heappop(queue) if (r, c) == goal: return cost, expanded if cost > best[(r, c)]: continue # a stale entry: this square was reached more cheaply since expanded += 1 for nr, nc in [(r + 1, c), (r - 1, c), (r, c + 1), (r, c - 1)]: if 0 <= nr < rows and 0 <= nc < cols and grid[nr][nc] != "#" and cost + 1 < best.get((nr, nc), 1e9): best[(nr, nc)] = cost + 1 heapq.heappush(queue, (cost + 1 + guess((nr, nc)), cost + 1, (nr, nc))) manhattan = lambda p: abs(p[0] - goal[0]) + abs(p[1] - goal[1]) print("Dijkstra (no guess): steps, places expanded =", search(lambda p: 0)) print("A* with Manhattan: steps, places expanded =", search(manhattan))
Both find the same 11 steps, but Dijkstra expands 79 squares and A* 36, since A* barely looks at the squares behind the start. The saving depends on the map: in a twisty maze, where the straight-line guess keeps being wrong, it shrinks. A guess of zero turns A* back into Dijkstra, and a guess that overestimates can make it settle for a worse route, because it never looks at the branch that seemed too far.
Show the answer
It can overestimate, so A* may return a route that isn't the shortest
Doubling can make the guess bigger than the real remaining distance, so a branch that's actually good looks too expensive and is never explored. The guess must never overestimate for A* to stay optimal.
Moves on a sliding-tile board
A 3 by 3 sliding puzzle is stored as a tuple of 9 numbers read row by row, with 0 for the gap. Write neighbors(state): every board one move away, made by sliding a tile into the gap (up, down, left or right). Order doesn't matter.
def neighbors(state): return [] print(neighbors((1, 2, 3, 4, 0, 5, 6, 7, 8)))
Choosing a search
The searches in this lesson and the trees and graphs lesson form a family, and choosing between them comes down to two questions: do the steps have different costs, and do you have a sense of direction?
- Breadth-first search when every step costs the same: the fewest moves in a puzzle, the fewest hops between two people in a network. It's simple and it's optimal for counting steps.
- Dijkstra's algorithm when steps have different, non-negative costs and you know nothing about where the goal is: delivery routes, the quickest train journey including transfer times, network routing.
- **A star (A*)** when you also have a cheap, honest estimate of the distance left: maps (straight-line distance), grids (Manhattan distance), puzzles (tiles out of place). It's Dijkstra that stops wasting time going the wrong way.
- Backtracking when you're not looking for a path at all but for a combination that satisfies rules: packing, timetables, puzzles like Sudoku. Its speed comes almost entirely from how early it can prune.
- Greedy choices (always take the move that looks best right now) are fast but can be wrong. With coins of 1, 3 and 4 cents, greedy pays 6 cents as 4 + 1 + 1, three coins, when 3 + 3 is two. Use greedy only when you can show the shortcut is safe.
In interviews, saying which one fits, and why, before writing any code is often worth as much as the code itself. And at work, check the library first: networkx has breadth-first search, Dijkstra and A* (nx.shortest_path, nx.astar_path), and for combination puzzles a constraint solver such as Google's OR-Tools does the backtracking and pruning for you.
Worked example: boards that can't be solved
Half of all arrangements of a 3 by 3 sliding puzzle can never reach the solved board, however you slide. A search would explore all 181,440 reachable boards before giving up. A quick count settles it first.
def inversions(state): tiles = [t for t in state if t != 0] return sum(1 for i in range(len(tiles)) for j in range(i + 1, len(tiles)) if tiles[i] > tiles[j]) def solvable(state): return inversions(state) % 2 == 0 goal = (1, 2, 3, 4, 5, 6, 7, 8, 0) swapped = (2, 1, 3, 4, 5, 6, 7, 8, 0) # two tiles pried out and swapped print(solvable(goal), solvable((1, 2, 3, 4, 5, 6, 0, 7, 8)), solvable(swapped))
An inversion is a pair of tiles in the wrong order relative to each other, reading row by row and ignoring the gap. On a 3-wide board, sliding a tile left or right changes nothing about that order, and sliding one up or down moves it past exactly two others, which changes the count by an even number. So whether the count is even or odd never changes, and the solved board has 0 inversions: only boards with an even count can reach it. Swapping two tiles by hand flips it to odd, which is the classic trick for making the puzzle impossible. Checks like this, an invariant that no move can change, often save a search from running forever: without it, A* on that swapped board explores all 181,440 reachable boards before giving up. (The rule is for boards an odd number of tiles wide, like 3; a 4-wide board needs the gap's row in the count too.)
Practice what you just read
- 5 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 5 review cards for spaced repetition
Try a quiz question
Why can Dijkstra's algorithm treat a place's distance as final once it's taken off the priority queue?