Index Ai Academy
Phase 7 · Software engineering · Data structures & algorithms

Searching for solutions

Backtracking with pruning, Dijkstra's algorithm and A*, ending with an optimal sliding-puzzle solver.

About 45 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

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

  1. Backtracking: try, check, undo
  2. Every load that fits exactly
  3. The cheapest route: Dijkstra's algorithm
  4. A: searching with a sense of direction
  5. Moves on a sliding-tile board
  6. Choosing a search
  7. Worked example: boards that can't be solved
Quick check
A pack mule carries exactly 30 kg. You try bundles one at a time and drop a choice as soon as the total goes over 30. What's that technique called?
  1. Random search: pick bundles until it works
  2. Greedy: always grab the heaviest bundle first
  3. Backtracking: try, check, undo, try the next
  4. Brute force: list every combination before checking
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")
It runs right here in your browser, with nothing to install.

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.

Quick check
Why does pruned check total + sum(bundles[i:]) < 30?
  1. Because the bundles must be sorted by weight before the search can begin
  2. It counts how many bundles are left, so the search knows when it's finished
  3. If even taking every remaining bundle can't reach 30 kg, nothing below this branch can work, so stop now
  4. To make sure the mule isn't overloaded: the total must stay under 30 kg on every branch
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.

Drill

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])
In the lesson, your code is checked as you write it.

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.

Quick check
What does this print? (Costs are minutes between huts.)
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)
  1. {'A': 0, 'B': 4, 'C': 1}
  2. {'A': 0, 'B': 3}
  3. {'A': 0, 'C': 1, 'B': 3}
  4. {'A': 0, 'C': 1, 'B': 4}
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.

Try it now

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.

Quick check
A route planner's heuristic is "straight-line distance × 2". What can go wrong?
  1. Nothing: a bigger guess only makes A* finish faster, with the same route
  2. It can overestimate, so A* may return a route that isn't the shortest
  3. It can't handle one-way streets, since straight lines go both ways
  4. It turns A* into breadth-first search, which ignores the distances
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.

Drill

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)))
In the lesson, your code is checked as you write it.

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

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))
In the lesson you can change this and run it.

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

Create a free account

Try a quiz question

Why can Dijkstra's algorithm treat a place's distance as final once it's taken off the priority queue?

  1. Everything left in the queue costs at least as much, and no cost is negative
  2. By then it has visited every place, so no other route remains to check
  3. The queue is sorted alphabetically, so places come off in a fixed order
  4. It can't; it goes back and updates every distance once the queue is empty

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson