Pebellslearn by building
0%
RoadmapsMatrixCoursesSandboxSign in
Shortest Paths at Scale  ›  Lesson

A* & Heuristic Search

A* & Heuristic Search 32 minSpend the search where the goal is
You're building a piece ofMeridian — the router
This piece — astar_route(): Aims the search at the destination so a single route comes back in milliseconds.
Scenario A driver on the east side taps a pickup on the west side and expects a route before the screen finishes loading. Dijkstra would search the whole city in rings; the live router aims its search at the destination and still guarantees the cheapest route.
Your task
Build astar_route(junctions, roads, start, goal). `junctions` lists [name, x, y] on Meridian's street grid; `roads` are two-way [a, b, minutes], and no road takes fewer minutes than the blocks between its ends (|dx| + |dy|). Use A* with block distance to `goal` as the heuristic. Return {"cost": minutes, "path": junctions} for the cheapest route (every test has exactly one), or {"cost": -1, "path": []} if there is none. Example: astar_route([["depot", 0, 0], ["mill", 2, 0], ["plaza", 2, 2], ["harbour", 4, 4]], [["depot", "mill", 2], ["mill", "plaza", 2], ["plaza", "harbour", 4]], "depot", "harbour") → {"cost": 8, "path": ["depot", "mill", "plaza", "harbour"]}.

A* & Heuristic Search

In short: A* is Dijkstra with one change: instead of ordering the heap by g (cost so far), it orders by f = g + h, where h is a heuristic guess of the cost still to go. As long as h never overestimates the true remaining cost (it is admissible), A* still finds the cheapest route, but expands far fewer junctions, because junctions pointing away from the goal get a large h and wait. An overestimating h breaks the guarantee and can return a worse route.

Two overlapping regions around a depot-to-harbour trip: a large pale circle labelled Dijkstra expanding equally in all directions, and a narrower green ellipse labelled A star stretched toward the goal

A driver at the depot, in the north-west corner of town, taps a pickup at the harbour in the south-east, and Meridian's live map has to answer at once. Dijkstra would get it right, but it settles junctions in rings around the start, spending as much effort away from the harbour as towards it. It has no idea where the harbour is.

You do. The harbour is south-east, and on a street grid you can't get there in fewer minutes than the number of blocks between you. A* is Dijkstra with that one piece of knowledge added, and it still guarantees the cheapest route.

One change to Dijkstra

Dijkstra pops the junction with the smallest g, the cost so far. A* pops the junction with the smallest

f(n)=g(n)+h(n)f(n) = g(n) + h(n)

where h is a heuristic: a guess of the cost still to go from n to the goal. Junctions in the goal's direction get small h and come off the heap first; junctions behind the start get large h and wait, often forever.

The guess has one job, and one rule. It must never overestimate the true remaining cost. A heuristic like that is called admissible. On Meridian's grid, every road takes at least as many minutes as the blocks it spans, so the block distance |x − gx| + |y − gy| is admissible: no real drive can beat it.

Here's A* on the town map, from the depot to the harbour. Each badge shows g + h once a junction has been reached, and the starting badges are just h:

watch it move · graph

A* orders its frontier by g + h. With a guess that never overshoots, it heads for the goal, expands five of nine junctions, and still finds the cheapest route.

Every junction starts with h, its block distance to the harbour. No road is faster than its blocks, so h never overshoots. 9 nodes, 12 edges, undirected.

1 / 6

The picture's claim is easy to check. Count how many junctions each search pops before it reaches the harbour:

What does this print?
import heapq
pos = {"depot": (0, 0), "mill": (2, 0), "quay": (4, 0), "west": (0, 2), "plaza": (2, 2),
       "east": (4, 2), "park": (0, 4), "market": (2, 4), "harbour": (4, 4)}
roads = [("depot", "mill", 2), ("mill", "quay", 3), ("depot", "west", 2), ("mill", "plaza", 2),
         ("quay", "east", 2), ("west", "plaza", 4), ("plaza", "east", 2), ("west", "park", 3),
         ("plaza", "market", 3), ("east", "harbour", 2), ("park", "market", 2), ("market", "harbour", 2)]
graph = {n: [] for n in pos}
for a, b, m in roads:
    graph[a].append((b, m))
    graph[b].append((a, m))

def expanded(weight):
    h = lambda n: weight * (abs(pos[n][0] - 4) + abs(pos[n][1] - 4))
    best, heap, done = {"depot": 0}, [(h("depot"), "depot")], []
    while heap:
        f, n = heapq.heappop(heap)
        if n in done:
            continue
        done.append(n)
        if n == "harbour":
            return len(done)
        for m, w in graph[n]:
            if best[n] + w < best.get(m, 99):
                best[m] = best[n] + w
                heapq.heappush(heap, (best[m] + h(m), m))

print(expanded(1), expanded(0))

Building A*

The code is Dijkstra's with f in the heap, and g kept in the tuple:

python

Step through the first expansion by hand. The frontier sorts by g + h, not by g:

step through it
1g = {"depot": 0}
2h = {"depot": 8, "mill": 6, "west": 6}
3frontier = []
4for nxt, minutes in [("west", 2), ("mill", 2)]:
5 g[nxt] = g["depot"] + minutes
6 frontier.append((g[nxt] + h[nxt], nxt))
7frontier.sort()
8print(frontier)

Both neighbours tie at f = 8, so the tuple's second item, the name, breaks the tie. Any tie-break is correct; it only changes which of two equally promising junctions goes first.

When the guess overshoots

A bigger h makes the search more eager: it expands fewer junctions. So why not multiply it by 3? Here's a small map where the direct way to the harbour crosses a slow bridge:

What does this print?
import heapq
pos = {"depot": (0, 0), "bridge": (2, 0), "harbour": (4, 0), "north": (0, 2), "ne": (4, 2)}
roads = {"depot": [("bridge", 9), ("north", 2)], "bridge": [("harbour", 2)],
         "north": [("ne", 4)], "ne": [("harbour", 2)], "harbour": []}

def h(n):
    return 3 * (abs(pos[n][0] - 4) + abs(pos[n][1] - 0))     # the guess, tripled

best = {"depot": 0}
heap = [(h("depot"), "depot")]
while heap:
    f, node = heapq.heappop(heap)
    if node == "harbour":
        print(best[node])
        break
    for nxt, minutes in roads[node]:
        if best[node] + minutes < best.get(nxt, float("inf")):
            best[nxt] = best[node] + minutes
            heapq.heappush(heap, (best[nxt] + h(nxt), nxt))

An overestimate inflates f along the good route, so a worse route reaches the goal with a smaller f and gets popped first. Admissibility is exactly the condition that stops this: along the cheapest route, f never exceeds the true cheapest cost, so every junction on it comes off the heap before any dearer arrival at the goal.

Dropping g altogether is the same mistake in a purer form. Ordering by the guess alone is greedy best-first search: fast, and wrong whenever the direct way is slow.

broken — fix it

This prints 11, the bridge route. The cheapest route, round by north, costs 8. Make it A* instead of greedy best-first search.

A stronger property, consistency, says h drops by no more than each road's cost: h(u) ≤ minutes(u, v) + h(v). Block distance has it. With a consistent h, a junction's g is final the first time it is popped, exactly like Dijkstra, so the stale-entry check is all the bookkeeping you need.

Your A*, drawn

This runs A* on a block of road works (# is closed) and writes each expanded cell's cost into it. Run it as it is, then set W = 0 and watch Dijkstra flood the whole grid for the same answer. Then try W = 3: fewer cells, and a longer route.

your code, drawn · grid

Your A*. Each number is the cost at which a cell was expanded. A good guess keeps the expanded region narrow; a weight of 0 floods it.

You should see 20 cells expanded for a route of 11 at W = 1, 44 at W = 0, and 17 at W = 3, but with a route of 15. That trade, fewer expansions for a bounded loss of quality, is sometimes worth taking on purpose (it's called weighted A*), but never by accident.

How much does aiming save?

On an open grid, Dijkstra's settled region for a trip of d blocks is a diamond of about 2d² cells. A good heuristic keeps A* to a corridor about 4 cells wide along the route, roughly 4d cells. The ratio is d/2: 5 times the work for a 10-block trip, 50 times for a 100-block cross-town trip, 150 times at 300 blocks. It keeps growing with distance, which is why aiming matters most for exactly the long trips drivers ask for.

When blocks are a weak guess, because some roads are slow, you can precompute a stronger one. Pick a few landmarks, store their distances to every junction, and use the triangle inequality: |d(L, goal) − d(L, n)| never exceeds d(n, goal). The capstone exercise builds that.

A*'s worst case is still Dijkstra's: every push and pop is a heap operation, so it's O((V + E) log V) time, and the heap plus the g/parent maps are O(V) space — a bad heuristic never costs more than plain Dijkstra, it just stops saving you anything.


Interview-style: the road-works permit

A van must cross a grid of blocks, some closed for road works. It carries permits that let it drive through at most k closed blocks. What is the fewest number of blocks it must drive?

The trap is treating a cell as the whole state. Reaching a cell with two permits left is not the same as reaching it with none. The honest state is (row, col, permits used), a grid stacked k + 1 layers deep, and A* with block distance runs on those states unchanged. The boss exercise builds it.

What it costs

Order the heap by Guarantee
Dijkstra g cheapest route
A*, h never overestimates g + h cheapest route, usually far fewer expansions
Weighted A* (w × h, w > 1) g + w·h route at most w times the cheapest
Greedy best-first h none

❓ Cross-question — "Isn't straight-line distance always admissible?" For distance, yes. On Meridian's grid, block distance is better: larger, so fewer expansions, and still never over. For time, divide by the top speed first.


Quick practice

What does this print?
import heapq
pos = {"a": (0, 0), "b": (2, 0), "c": (0, 3), "goal": (2, 3)}
graph = {"a": [("b", 2), ("c", 3)], "b": [("goal", 4)], "c": [("goal", 2)], "goal": []}
h = lambda n: abs(pos[n][0] - 2) + abs(pos[n][1] - 3)
g, heap = {"a": 0}, [(h("a"), 0, "a")]
while heap:
    f, cost, node = heapq.heappop(heap)
    if cost > g.get(node, float("inf")):
        continue
    if node == "goal":
        break
    for nxt, w in graph[node]:
        if cost + w < g.get(nxt, float("inf")):
            g[nxt] = cost + w
            heapq.heappush(heap, (cost + w + h(nxt), cost + w, nxt))
print(g["goal"])
broken — fix itKeyError

Walking parent back from the goal crashes with KeyError. Add the missing parent update.

🎯 Your turn

Write astar_route(junctions, roads, start, goal). junctions lists [name, x, y] on the street grid; roads are two-way [a, b, minutes], never faster than their block distance. Use block distance to the goal as h, and return {"cost": minutes, "path": junctions}, or {"cost": -1, "path": []} with no route:

  • astar_route([["depot", 0, 0], ["mill", 2, 0], ["plaza", 2, 2], ["harbour", 4, 4]], [["depot", "mill", 2], ["mill", "plaza", 2], ["plaza", "harbour", 4]], "depot", "harbour") → {"cost": 8, "path": ["depot", "mill", "plaza", "harbour"]}

Hint: Dijkstra with (g + h, g, junction) in the heap, a stale-entry check, and a parent walk when the goal is popped.

Then press ▶ Run, tap the Live App try chips to call it with different inputs, and hit ✓ Check. Green = this piece of Meridian's router is built. ✅

Practice — level up

0/6 solved

Solve each in its own editor — Run to try, Check to grade. Stuck? Reveal a Hint, or the full Solution + walkthrough.

Warm-upBlocks to the goal

A* needs a guess of the distance still to go. On Meridian's street grid the honest guess is the number of blocks: |x − gx| + |y − gy|. Write manhattan_h(junctions, goal) where junctions is a list of [name, x, y], returning a dict from every junction's name to its block distance from goal. Example: manhattan_h([["depot", 0, 0], ["mill", 2, 0], ["harbour", 4, 4]], "harbour") → {"depot": 8, "mill": 6, "harbour": 0}.

shortest-pathsdict
DrillDoes the guess ever overshoot?

A heuristic someone else wrote is only safe if it never overestimates. Write is_admissible(roads, goal, h): roads are two-way [a, b, minutes], and h is a dict of guesses from junctions to goal. Return True if, for every junction in h that can reach goal, h[junction] is at most the true cheapest drive to goal. Junctions that can't reach the goal don't matter.

dijkstrashortest-paths
BuildFewest blocks through a closed-off grid

Road works have closed some blocks. rows is a list of equal-length strings where # is closed and . is open; a van moves one block up, down, left or right per step. Write grid_steps(rows, start, goal) (both [row, col]) returning the fewest steps from start to goal, or -1 if the goal is walled off. Use A* with block distance as the heuristic.

shortest-pathsheapq
BossThrough the road works, with a permit

An interview-style routing problem. Meridian's vans carry permits that let them drive through a closed block. rows uses # for closed blocks and . for open ones; a van moves one block up, down, left or right per step, and may enter at most passes closed blocks in total. Write squeeze_route(rows, start, goal, passes) returning the fewest steps from start to goal, or -1 if it can't be done.

shortest-pathsheapqbfs
CapstoneLandmark heuristics

Block distance ignores slow roads, so on a real map it is a weak guess. A stronger one comes from landmarks: a few junctions whose distances to everywhere are computed once, in advance. For any landmark L, the triangle inequality gives d(node, goal) >= |d(L, goal) − d(L, node)|. Write alt_route(roads, landmarks, start, goal) for two-way roads [a, b, minutes]: precompute a Dijkstra table from each landmark, use the largest of those bounds as A*'s heuristic (skipping a landmark that can't reach both ends, and using 0 if none can), and return {"bound": h(start), "cost": cheapest cost} with cost -1 if there is no route.

shortest-pathsdijkstraheapq
ExplainWhy the guess must never overshoot

A* orders its frontier by f = g + h. Explain why an h that never overestimates the remaining cost guarantees that the first time the goal is popped its cost is the cheapest, and describe how an h that overestimates can make A* return a dearer route.

astar_route.py
Call the function you wrote — just like your app's frontend would. Edit the inputs and hit Call.
astar_route(junctions, roads, start, goal) → dictThe cheapest route on the live map as {cost, path}, found with A*; cost -1 if there is no route.
try:
returned
Press Run to execute your code.
Press Check to run the tests for this lesson.