Data Structures & Algorithms (DSA) Module 12 – Shortest paths, spanning trees and flows
A* search and heuristics
How A* search uses heuristics that never overestimate to find shortest routes, why consistency matters, and how much less it explores than Dijkstra.
What you will learn
- Implement A* with an admissible heuristic and a priority queue keyed by g + h
- Explain admissible and consistent heuristics and what each one guarantees
- Compare A*, Dijkstra and bidirectional search by the vertices they expand
Before you start
What you’ll learn · Before you start
What you will learn
- Implement A* with an admissible heuristic and a priority queue keyed by g + h
- Explain admissible and consistent heuristics and what each one guarantees
- Compare A*, Dijkstra and bidirectional search by the vertices they expand
Before you start
On this page
A* search finds a shortest route from one start to one goal. It runs like Dijkstra’s algorithm with a single change: the priority queue orders each town by f = g + h, where g is the length of the best route found to the town so far and h is an estimate of the distance still to go. Such estimates are called heuristics. When the estimate never overestimates, the first time A* takes the goal from its queue the route is a shortest one, and the closer the estimate comes to the true distance, the fewer towns the search expands. With h = 0 the order is exactly Dijkstra’s. Peter Hart, Nils Nilsson and Bertram Raphael described the method in 1968. On the made-up map of 9,291 towns measured below, A* search with the straight-line distance expanded 1,319 towns per trip, where Dijkstra expanded 4,515.
How the search chooses the next town
A* keeps Dijkstra’s tables (best distances, parent links, expanded towns) and changes only the key of its queue:
- Put the start in the queue with g = 0 and f = h(start).
- Take the entry with the smallest f. If its town is already expanded, skip the entry: it was pushed before a shorter route to that town turned up.
- If the town is the goal, stop. Its g is the length of a shortest route, and the parent links lead back to the start.
- Otherwise expand the town: for each road to a neighbour, if g plus the road’s length beats the neighbour’s best distance, record the new distance and the parent, and push the neighbour with f = new distance + h(neighbour).
- If the queue runs empty first, no route reaches the goal.
Here it is on ten towns, with h the straight-line distance to G rounded down to whole kilometres:
# A* on a made-up map of ten towns, every step printed: the town taken from the queue, its g + h = f, and the queue
# afterwards. Then Dijkstra's algorithm (the same code with h = 0) on the same trip, for comparison.
import heapq
from math import isqrt
# Coordinates in km. Every road is at least as long as the straight line between its two towns.
TOWNS = {
"A": (0, 3), "B": (2, 6), "C": (3, 3), "D": (2, 0), "E": (6, 6),
"F": (6, 0), "G": (10, 3), "H": (-3, 3), "I": (-2, 6), "J": (-2, 0),
}
ROADS = [
("A", "H", 3), ("A", "I", 4), ("A", "J", 4), ("H", "I", 4), ("H", "J", 4),
("A", "B", 5), ("A", "C", 3), ("A", "D", 4), ("B", "C", 4), ("B", "E", 4),
("C", "E", 5), ("C", "F", 5), ("D", "F", 4), ("E", "G", 5), ("F", "G", 6),
]
graph = {town: [] for town in TOWNS}
for a, b, km in ROADS:
graph[a].append((b, km))
graph[b].append((a, km))
def straight_line(a, b):
"""Straight-line distance in whole km, rounded down, so it never exceeds a road route."""
(x1, y1), (x2, y2) = TOWNS[a], TOWNS[b]
return isqrt((x1 - x2) ** 2 + (y1 - y2) ** 2)
def a_star(start, goal, h, show=False):
g = {start: 0} # the shortest distance found so far
parent = {start: None}
done = set() # towns taken from the queue with their final distance
queue = [(h(start), start)] # entries (f, town); equal f values come out in name order
order = []
while queue:
f, town = heapq.heappop(queue)
if town in done:
continue # a stale entry: this town was reached more cheaply after it was pushed
done.add(town)
order.append(town)
if town != goal:
for nxt, km in graph[town]:
if g[town] + km < g.get(nxt, float("inf")):
g[nxt] = g[town] + km
parent[nxt] = town
heapq.heappush(queue, (g[nxt] + h(nxt), nxt))
if show:
waiting = ", ".join(f"{t} {f2}" for f2, t in sorted(queue) if t not in done)
print(f"{len(order):>2}. take {town}: g {g[town]:>2} + h {h(town):>2} = f {f:>2} | queue: {waiting}")
if town == goal:
route = []
while town is not None:
route.append(town)
town = parent[town]
return g[goal], route[::-1], order
return -1, [], order
print("A* from A to G, h = straight-line km to G")
km, route, order = a_star("A", "G", lambda t: straight_line(t, "G"), show=True)
print(f"route {', '.join(route)}: {km} km; towns expanded: {len(order)} of {len(TOWNS)}")
km, route, order = a_star("A", "G", lambda t: 0)
print(f"Dijkstra (h = 0): {km} km; towns expanded: {len(order)} of {len(TOWNS)}: {', '.join(order)}") Output
A* from A to G, h = straight-line km to G 1. take A: g 0 + h 10 = f 10 | queue: C 10, D 12, B 13, H 16, I 16, J 16 2. take C: g 3 + h 7 = f 10 | queue: D 12, B 13, E 13, F 13, H 16, I 16, J 16 3. take D: g 4 + h 8 = f 12 | queue: B 13, E 13, F 13, H 16, I 16, J 16 4. take B: g 5 + h 8 = f 13 | queue: E 13, F 13, H 16, I 16, J 16 5. take E: g 8 + h 5 = f 13 | queue: F 13, G 13, H 16, I 16, J 16 6. take F: g 8 + h 5 = f 13 | queue: G 13, H 16, I 16, J 16 7. take G: g 13 + h 0 = f 13 | queue: H 16, I 16, J 16 route A, C, E, G: 13 km; towns expanded: 7 of 10 Dijkstra (h = 0): 13 km; towns expanded: 10 of 10: A, C, H, D, I, J, B, E, F, G
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 trace.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
The ten-town map, with h for every town and the route A* returns
Text description of the diagram
The start A sits in the middle of the map, the goal G at its east end. Each town is labelled with h, its straight-line distance to G in whole km: H 13, I 12, J 12, A 10, B 8, D 8, C 7, E 5, F 5 and G 0.
- West of A, three towns that lead away from the goal, drawn dashed: H (road from A 3 km), I (4 km) and J (4 km), with roads H to I and H to J of 4 km each.
- East of A: roads A to B 5 km, A to C 3 km and A to D 4 km; B to C 4 km; B to E 4 km; C to E 5 km; C to F 5 km; D to F 4 km.
- Into the goal: E to G 5 km and F to G 6 km.
The thick lines are the route A* returns, A, C, E, G, 13 km long. The dashed towns H, I and J are never expanded by A*: their g + h is 16, more than the 13 km route. Dijkstra's algorithm, which has no estimate, expands all ten towns.
C follows A at f = 3 + 7 = 10, then D at 12, although D is off the final route: A* expands every town whose f is below the length of the shortest route, 13. B, E, F and G all reach f = 13 and leave in name order, and the search ends when G is taken, with A, C, E, G. H, I and J are only 3 or 4 km from A but lie on the far side from G, so their f is 16 and they are never taken. Dijkstra, ordering by g alone, expands all ten towns.
Admissible and consistent heuristics
An estimate is admissible when it never exceeds the true remaining distance to the goal, and that alone guarantees A* a shortest route. It is consistent when, for every road from u to v of length w, h(u) ≤ w + h(v): driving one road never lowers the estimate by more than the road’s length. A consistent estimate with h(goal) = 0 is admissible too, and it lets A* expand every town at most once, as the code above assumes. Good heuristics come from relaxing the problem: drop the roads and the straight line is left; drop the walls of a grid and the Manhattan distance is left.
| Criterion | h = 0 | Straight line | Manhattan | Octile |
|---|---|---|---|---|
| Formula for dx, dy | 0 | √(dx² + dy²) | |dx| + |dy| | max(|dx|, |dy|) + (√2 − 1) · min(|dx|, |dy|) |
| Road map, no road shorter than the straight line | Never overestimates | Never overestimates | Can overestimate on diagonal roads | Can overestimate on roads at an angle |
| Grid, 4 moves of cost 1 | Never overestimates | Never overestimates | Never overestimates; exact without walls | Never overestimates |
| Grid, 8 moves, diagonals cost √2 | Never overestimates | Never overestimates | Overestimates | Never overestimates; exact without walls |
| When to choose | Many goals, or no coordinates at all | Road and rail networks with coordinates | Grids with 4-way moves | Grids with 8-way moves |
Three refinements build on these estimates. Weighted A* multiplies h by a factor above 1 to expand fewer towns, accepting routes up to that factor longer. Bidirectional search runs Dijkstra from both ends and stops once no unexpanded town can shorten the best route found. Landmark estimates store true distances from a few chosen towns and turn them into lower bounds with the triangle inequality.
How many towns each search expands, measured
The map is made up but shaped like a road network: one town near each point of a 100 × 100 km grid, roads that bend up to 40 % beyond the straight line, a fifth of the possible roads missing, and a lake 30 km across in the middle. Lengths are whole metres, so every run prints the same.
# Dijkstra against A* on a made-up road map of about 9,300 towns around a lake: towns expanded per trip, for 100
# random trips, with every route length checked against Dijkstra's. All lengths are whole metres, so every run and
# every language gives the same numbers.
import heapq
from math import isqrt
ROWS, COLS = 100, 100 # one town near each point of a 100 x 100 km grid
LAKE_ROW, LAKE_COL, LAKE_RADIUS = 50, 45, 15 # no towns or roads under the lake
class Lcg:
"""Park and Miller's small pseudo-random generator: easy to repeat in any language."""
def __init__(self, seed):
self.state = seed
def below(self, n):
self.state = self.state * 48271 % 2147483647
return self.state % n
def build_map(seed):
rng = Lcg(seed)
xs, ys, town_at = [], [], [-1] * (ROWS * COLS)
for r in range(ROWS):
for c in range(COLS):
jx, jy = rng.below(601) - 300, rng.below(601) - 300 # up to 300 m off the grid point
if (r - LAKE_ROW) ** 2 + (c - LAKE_COL) ** 2 <= LAKE_RADIUS**2:
continue
town_at[r * COLS + c] = len(xs)
xs.append(c * 1000 + jx)
ys.append(r * 1000 + jy)
roads = [[] for _ in xs]
count = 0
for r in range(ROWS):
for c in range(COLS):
u = town_at[r * COLS + c]
if u < 0:
continue
diagonal = (r + 1, c + 1) if rng.below(2) == 0 else (r + 1, c - 1)
for nr, nc in ((r, c + 1), (r + 1, c), diagonal):
if not (0 <= nr < ROWS and 0 <= nc < COLS) or town_at[nr * COLS + nc] < 0:
continue
if rng.below(100) >= 80:
continue # one possible road in five is missing: a hill or a river
v = town_at[nr * COLS + nc]
straight = isqrt((xs[u] - xs[v]) ** 2 + (ys[u] - ys[v]) ** 2)
metres = straight + straight * rng.below(41) // 100 + 1 # bends make a road up to 40 % longer
roads[u].append((v, metres))
roads[v].append((u, metres))
count += 1
return xs, ys, roads, count, town_at
def search(xs, ys, roads, start, goal, estimate):
"""Returns (route length in metres or -1, towns expanded). estimate(town) is h; lambda town: 0 is Dijkstra."""
best = {start: 0}
done = set()
queue = [(estimate(start), 0, start)] # entries (f, g, town)
while queue:
_, g, town = heapq.heappop(queue)
if town in done:
continue
done.add(town)
if town == goal:
return g, len(done)
for nxt, metres in roads[town]:
if g + metres < best.get(nxt, 1 << 62):
best[nxt] = g + metres
heapq.heappush(queue, (g + metres + estimate(nxt), g + metres, nxt))
return -1, len(done)
def straight_to(xs, ys, goal):
gx, gy = xs[goal], ys[goal]
return lambda t: isqrt((xs[t] - gx) ** 2 + (ys[t] - gy) ** 2)
xs, ys, roads, count, town_at = build_map(2027)
print(f"map: {len(xs):,} towns and {count:,} roads around a lake")
rng = Lcg(77)
expanded = {"Dijkstra (h = 0)": 0, "A*, straight line to the goal": 0, "A*, half the straight line": 0}
same = 0
for _ in range(100):
s, t = rng.below(len(xs)), rng.below(len(xs))
line = straight_to(xs, ys, t)
d0, e0 = search(xs, ys, roads, s, t, lambda town: 0)
d1, e1 = search(xs, ys, roads, s, t, line)
d2, e2 = search(xs, ys, roads, s, t, lambda town: line(town) // 2)
same += d0 == d1 == d2
for name, e in zip(expanded, (e0, e1, e2)):
expanded[name] += e
print(f"100 random trips: the three searches found the same route length on {same} of them")
print("average towns expanded per trip:")
for name, total in expanded.items():
print(f" {name:<31}{round(total / 100):>6,}")
print("two trips of about 40 km in a straight line:")
for label, (r1, c1, r2, c2) in (("north of the lake", (10, 25, 10, 65)), ("across the lake", (50, 25, 50, 65))):
s, t = town_at[r1 * COLS + c1], town_at[r2 * COLS + c2]
d0, e0 = search(xs, ys, roads, s, t, lambda town: 0)
d1, e1 = search(xs, ys, roads, s, t, straight_to(xs, ys, t))
print(f" {label + ':':<19}road route {d1 / 1000:.1f} km; A* expanded {e1:,} towns, Dijkstra {e0:,}") Output
map: 9,291 towns and 21,914 roads around a lake 100 random trips: the three searches found the same route length on 100 of them average towns expanded per trip: Dijkstra (h = 0) 4,515 A*, straight line to the goal 1,319 A*, half the straight line 2,978 two trips of about 40 km in a straight line: north of the lake: road route 51.0 km; A* expanded 624 towns, Dijkstra 2,653 across the lake: road route 73.5 km; A* expanded 1,424 towns, Dijkstra 6,102
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 measure.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
A* expanded 29 % of the towns Dijkstra did and found the same lengths on all 100 trips. Halving the estimate kept every answer right but expanded 2,978 towns per trip: a weaker lower bound costs work, never correctness. The lake shows where the straight line misleads. Two trips of the same 40 km in a straight line differ: north of the lake the road route is 51.0 km and A* expanded 624 towns, while across the lake it is 73.5 km and A* expanded 1,424, because it must expand every town whose f is below the route’s length and the straight line ignores the water.
| Operation | Time | Extra space | Note |
|---|---|---|---|
| One trip, worst case | O((V + E) log V) | O(V) | the bound of Dijkstra: a useless estimate expands everything |
| One trip, good estimate | far less in practice | O(towns reached) | 1,319 of 9,291 towns per trip on the measured map |
| Straight-line estimate | O(1) per call | O(1) | an integer square root of dx² + dy² |
Exercise and self-check
Write A* for maps with coordinates, then test it on the ten towns, on 200 random maps and on a map of 3,600 towns, where it has to expand fewer than half of them.
Exercise · Medium · Python, JavaScript
The shortest road route with A* and a straight-line estimate
Towns have integer coordinates, and every road joins two towns, can be driven both ways and is never shorter than the straight line between its ends. Write a_star(towns, roads, start, goal) (JavaScript: aStar(towns, roads, start, goal)), where towns maps each name to its (x, y) and roads lists (a, b, length). Use A* with the straight-line distance to the goal, rounded down, as the estimate. Return the length of a shortest route, the route as a list of town names from start to goal, and the number of towns you expanded: towns taken from the queue with their final distance, each counted once. When the goal cannot be reached, return -1, an empty route and the count.
Python returns a tuple (length, route, expanded); JavaScript returns an array [length, route, expanded], and its towns are an object of [x, y] arrays. The tests check the lesson's ten towns, a start that is the goal, a goal on another island, 200 random small maps against a slow method, and a 60 × 60 map of 3,600 towns on which the search must expand fewer than half of them.
Python · Starter code · route.py
def a_star(towns, roads, start, goal):
"""towns: {name: (x, y)}; roads: [(a, b, length)]. Returns (length, route, expanded) or (-1, [], expanded)."""
raise NotImplementedError("write a_star") The sample tests · test_route.py
import random
from math import isqrt
from route import a_star
LESSON_TOWNS = {
"A": (0, 3), "B": (2, 6), "C": (3, 3), "D": (2, 0), "E": (6, 6),
"F": (6, 0), "G": (10, 3), "H": (-3, 3), "I": (-2, 6), "J": (-2, 0),
}
LESSON_ROADS = [
("A", "H", 3), ("A", "I", 4), ("A", "J", 4), ("H", "I", 4), ("H", "J", 4),
("A", "B", 5), ("A", "C", 3), ("A", "D", 4), ("B", "C", 4), ("B", "E", 4),
("C", "E", 5), ("C", "F", 5), ("D", "F", 4), ("E", "G", 5), ("F", "G", 6),
]
def check_route(roads, start, goal, length, route):
"""the route runs from start to goal along roads whose lengths add up to length"""
lengths = {}
for a, b, d in roads:
lengths[(a, b)] = lengths[(b, a)] = d
assert route[0] == start and route[-1] == goal, route
assert sum(lengths[(a, b)] for a, b in zip(route, route[1:])) == length, route
def slow_length(towns, roads, start, goal):
"""relax every road both ways until nothing changes"""
dist = {name: float("inf") for name in towns}
dist[start] = 0
changed = True
while changed:
changed = False
for a, b, d in roads:
for u, v in ((a, b), (b, a)):
if dist[u] + d < dist[v]:
dist[v] = dist[u] + d
changed = True
return -1 if dist[goal] == float("inf") else dist[goal]
def test_lesson_map():
"""the lesson's ten towns: 13 km by A, C, E, G, with at most 7 towns expanded"""
length, route, expanded = a_star(LESSON_TOWNS, LESSON_ROADS, "A", "G")
assert (length, route) == (13, ["A", "C", "E", "G"])
assert expanded <= 7, expanded
def test_start_is_goal():
"""a trip from a town to itself: length 0, one town on the route, one town expanded"""
assert a_star(LESSON_TOWNS, LESSON_ROADS, "D", "D") == (0, ["D"], 1)
def test_goal_on_another_island():
"""no road reaches the goal: -1 and an empty route"""
towns = {"P": (0, 0), "Q": (3, 4), "R": (10, 0), "S": (10, 5)}
roads = [("P", "Q", 5), ("R", "S", 6)]
length, route, _ = a_star(towns, roads, "P", "S")
assert (length, route) == (-1, [])
def test_random_maps():
"""200 random maps of 2 to 9 towns: lengths match a slow method and every route is valid"""
rng = random.Random(8)
for _ in range(200):
names = [f"T{i}" for i in range(rng.randint(2, 9))]
towns = {name: (rng.randint(0, 20), rng.randint(0, 20)) for name in names}
roads = []
for i in range(len(names)):
for j in range(i + 1, len(names)):
if rng.random() < 0.45:
(x1, y1), (x2, y2) = towns[names[i]], towns[names[j]]
d2 = (x1 - x2) ** 2 + (y1 - y2) ** 2
ceiling = isqrt(d2) + (isqrt(d2) ** 2 < d2) # never shorter than the straight line
roads.append((names[i], names[j], max(1, ceiling + rng.randint(0, 6))))
start, goal = rng.choice(names), rng.choice(names)
length, route, _ = a_star(towns, roads, start, goal)
assert length == slow_length(towns, roads, start, goal), (towns, roads, start, goal)
if length == -1:
assert route == []
else:
check_route(roads, start, goal, length, route)
def test_large_map():
"""3,600 towns on a 60 x 60 grid: the route is 6,676 long, and fewer than 1,800 towns are expanded"""
state = 11
def below(k):
nonlocal state
state = state * 48271 % 2147483647
return state % k
n = 60
towns = {}
for r in range(n):
for c in range(n):
towns[f"{r},{c}"] = (c * 100 + below(61) - 30, r * 100 + below(61) - 30)
roads = []
for r in range(n):
for c in range(n):
diagonal = (r + 1, c + 1) if below(2) == 0 else (r + 1, c - 1)
for nr, nc in ((r, c + 1), (r + 1, c), diagonal):
if 0 <= nr < n and 0 <= nc < n and below(100) < 85:
(x1, y1), (x2, y2) = towns[f"{r},{c}"], towns[f"{nr},{nc}"]
straight = isqrt((x1 - x2) ** 2 + (y1 - y2) ** 2)
roads.append((f"{r},{c}", f"{nr},{nc}", straight + straight * below(31) // 100 + 1))
length, route, expanded = a_star(towns, roads, "30,2", "30,57")
assert length == 6676, length
check_route(roads, "30,2", "30,57", length, route)
assert expanded < 1800, f"{expanded} towns expanded: is the estimate used in the order of the queue?" JavaScript · Starter code · route.mjs
/** towns: { name: [x, y] }; roads: [[a, b, length]]. Returns [length, route, expanded] or [-1, [], expanded]. */
export function aStar(towns, roads, start, goal) {
throw new Error('write aStar');
} The sample tests · route.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { aStar } from './route.mjs';
const LESSON_TOWNS = {
A: [0, 3], B: [2, 6], C: [3, 3], D: [2, 0], E: [6, 6], F: [6, 0], G: [10, 3], H: [-3, 3], I: [-2, 6], J: [-2, 0],
};
const LESSON_ROADS = [
['A', 'H', 3], ['A', 'I', 4], ['A', 'J', 4], ['H', 'I', 4], ['H', 'J', 4],
['A', 'B', 5], ['A', 'C', 3], ['A', 'D', 4], ['B', 'C', 4], ['B', 'E', 4],
['C', 'E', 5], ['C', 'F', 5], ['D', 'F', 4], ['E', 'G', 5], ['F', 'G', 6],
];
function isqrt(n) {
let r = Math.floor(Math.sqrt(n));
while (r * r > n) r--;
while ((r + 1) * (r + 1) <= n) r++;
return r;
}
function checkRoute(roads, start, goal, length, route) {
const lengths = new Map();
for (const [a, b, d] of roads) {
lengths.set(`${a}|${b}`, d);
lengths.set(`${b}|${a}`, d);
}
assert.equal(route[0], start, 'the route starts at the start');
assert.equal(route[route.length - 1], goal, 'the route ends at the goal');
let sum = 0;
for (let i = 1; i < route.length; i++) {
const d = lengths.get(`${route[i - 1]}|${route[i]}`);
assert.ok(d !== undefined, `${route[i - 1]} and ${route[i]} are joined by a road`);
sum += d;
}
assert.equal(sum, length, 'the road lengths add up to the length returned');
}
function slowLength(towns, roads, start, goal) {
const dist = {};
for (const name of Object.keys(towns)) dist[name] = Infinity;
dist[start] = 0;
let changed = true;
while (changed) {
changed = false;
for (const [a, b, d] of roads) {
for (const [u, v] of [[a, b], [b, a]]) {
if (dist[u] + d < dist[v]) {
dist[v] = dist[u] + d;
changed = true;
}
}
}
}
return dist[goal] === Infinity ? -1 : dist[goal];
}
function lcg(seed) {
let state = seed;
return (k) => {
state = (state * 48271) % 2147483647;
return state % k;
};
}
test("the lesson's ten towns: 13 km by A, C, E, G, with at most 7 towns expanded", () => {
const [length, route, expanded] = aStar(LESSON_TOWNS, LESSON_ROADS, 'A', 'G');
assert.deepEqual([length, route], [13, ['A', 'C', 'E', 'G']]);
assert.ok(expanded <= 7, `${expanded} towns expanded`);
});
test('a trip from a town to itself: length 0, one town on the route, one town expanded', () => {
assert.deepEqual(aStar(LESSON_TOWNS, LESSON_ROADS, 'D', 'D'), [0, ['D'], 1]);
});
test('no road reaches the goal: -1 and an empty route', () => {
const towns = { P: [0, 0], Q: [3, 4], R: [10, 0], S: [10, 5] };
const [length, route] = aStar(towns, [['P', 'Q', 5], ['R', 'S', 6]], 'P', 'S');
assert.deepEqual([length, route], [-1, []]);
});
test('200 random maps of 2 to 9 towns: lengths match a slow method and every route is valid', () => {
const below = lcg(8);
for (let k = 0; k < 200; k++) {
const names = Array.from({ length: 2 + below(8) }, (_, i) => `T${i}`);
const towns = {};
for (const name of names) towns[name] = [below(21), below(21)];
const roads = [];
for (let i = 0; i < names.length; i++) {
for (let j = i + 1; j < names.length; j++) {
if (below(100) < 45) {
const [x1, y1] = towns[names[i]], [x2, y2] = towns[names[j]];
const d2 = (x1 - x2) ** 2 + (y1 - y2) ** 2;
const ceiling = isqrt(d2) + (isqrt(d2) ** 2 < d2 ? 1 : 0); // never shorter than the straight line
roads.push([names[i], names[j], Math.max(1, ceiling + below(7))]);
}
}
}
const start = names[below(names.length)], goal = names[below(names.length)];
const [length, route] = aStar(towns, roads, start, goal);
assert.equal(length, slowLength(towns, roads, start, goal), JSON.stringify({ towns, roads, start, goal }));
if (length === -1) assert.deepEqual(route, []);
else checkRoute(roads, start, goal, length, route);
}
});
test('3,600 towns on a 60 x 60 grid: the route is 6,676 long, and fewer than 1,800 towns are expanded', () => {
const below = lcg(11);
const n = 60, towns = {}, roads = [];
for (let r = 0; r < n; r++) {
for (let c = 0; c < n; c++) towns[`${r},${c}`] = [c * 100 + below(61) - 30, r * 100 + below(61) - 30];
}
for (let r = 0; r < n; r++) {
for (let c = 0; c < n; c++) {
const diagonal = below(2) === 0 ? [r + 1, c + 1] : [r + 1, c - 1];
for (const [nr, nc] of [[r, c + 1], [r + 1, c], diagonal]) {
if (nr >= 0 && nr < n && nc >= 0 && nc < n && below(100) < 85) {
const [x1, y1] = towns[`${r},${c}`], [x2, y2] = towns[`${nr},${nc}`];
const straight = isqrt((x1 - x2) ** 2 + (y1 - y2) ** 2);
roads.push([`${r},${c}`, `${nr},${nc}`, straight + Math.floor((straight * below(31)) / 100) + 1]);
}
}
}
}
const [length, route, expanded] = aStar(towns, roads, '30,2', '30,57');
assert.equal(length, 6676);
checkRoute(roads, '30,2', '30,57', length, route);
assert.ok(expanded < 1800, `${expanded} towns expanded: is the estimate used in the order of the queue?`);
}); A hint
Build an adjacency list first, then keep a heap of entries (f, g, town) with f = g + h(town). When you take an entry whose town is already expanded, skip it: that entry was pushed before a shorter route was found. Stop the moment you take the goal, and rebuild the route from parent links saved whenever a distance improves.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide, QuickJS): nothing is sent to mysmartcopilot.com. The first run of each language downloads it: Python (about 13.5 MB) or JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Check yourself
5 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
Why admissible estimates give shortest routes, and how to break them Pro: opens with a Pro, Premium or Ultimate pass
Two short arguments explain A*: one shows that an estimate which never overestimates gives a shortest route, the other that a consistent estimate expands each town once. This part proves both, then breaks each condition on purpose and measures what goes wrong.
In this part:
- The proof for admissible estimates
- Consistent estimates: Dijkstra on adjusted lengths
- Estimates that overestimate, measured on 100 trips
- Admissible but not consistent: the closed-set bug
1 runnable example (Python) · about 4 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
A* on the 9,291-town map in five languages, with run records Pro: opens with a Pro, Premium or Ultimate pass
The same search in Python, JavaScript, Java, C++ and Go, on the same map, printing the same counts. Getting five priority queues to break ties alike takes a different trick in each language.
In this part:
- The search in Python and JavaScript
- The search in Java, C++ and Go
- Making five priority queues agree
- Edge cases and pitfalls
2 runnable examples (Build and run all three, C++, Go, Java, JavaScript, Python) · about 3 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
Two-ended search and landmark estimates, measured, with a practice set Pro: opens with a Pro, Premium or Ultimate pass
Two refinements cut the work further on the same map: Dijkstra from both ends at once, and estimates built from distances to a few landmark towns. This part measures both, reproduces the stopping rule that makes a two-ended search wrong, and ends with a practice set with answers.
In this part:
- Two searches that meet, and the rule for stopping
- Landmarks: lower bounds from the triangle inequality
- Choosing a method, with the measured numbers
- Practice set
1 runnable example (Python) · about 4 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
Interview questions
-
Fresher to mid · Warm-up
What does A* add to Dijkstra's algorithm, and what does A* do when its estimate is 0 for every vertex?
A* orders its priority queue by f = g + h instead of g alone: g is the length of the best route found to a vertex so far and h is an estimate of the distance from that vertex to the goal. The estimate pulls the search towards the goal, so vertices that lie away from it come out of the queue late or not at all. As long as h never overestimates the remaining distance, the first time the goal leaves the queue its route is a shortest one. With h = 0 everywhere, f equals g, and A* takes vertices in exactly Dijkstra's order: the same answer for the same work. A* also needs one known goal; for the distances from a source to every vertex, Dijkstra is the tool.
-
Mid to senior
A strategy game's path-finder multiplies its straight-line estimate by 1.5 to save time. Players report units taking visibly longer routes than they would choose by hand. Explain what changed, how much longer a route can get, and what you would ship instead.
-
Fresher to senior
A delivery app runs A* on a road graph whose edge weights are travel times in minutes, using the straight-line distance in kilometres as the estimate. City routes look fine, but some routes that use highways are longer than they need to be. What is wrong, and how do you build an estimate that is safe?
-
Mid to senior
To speed up A*, a team stores exact distances to the depot for 500 popular towns and uses 0 for every other town. The estimate never overestimates, yet tests find a slightly longer route on 2 of 1,000 trips. Why can this happen, and how do you fix it?
Key takeaways
- A* orders its queue by f = g + h; with h = 0 it is Dijkstra’s algorithm.
- An admissible estimate never overestimates, which guarantees a shortest route; a consistent one also expands each town at most once.
- Good heuristics are lower bounds from a relaxed problem: the straight line on roads, Manhattan or octile distance on grids.
- On the measured map A* expanded 1,319 towns per trip against Dijkstra’s 4,515, with the same route lengths.
References
- A Formal Basis for the Heuristic Determination of Minimum Cost Paths (P. E. Hart, N. J. Nilsson and B. Raphael, IEEE Transactions on Systems Science and Cybernetics 4(2), pages 100–107) (IEEE)
- A note on two problems in connexion with graphs (E. W. Dijkstra, Numerische Mathematik 1, pages 269–271) (Springer)
- Heuristic search viewed as path finding in a graph (I. Pohl, Artificial Intelligence 1(3–4), pages 193–204) (Elsevier)
- Computing the Shortest Path: A* Search Meets Graph Theory (A. V. Goldberg and C. Harrelson, Microsoft Research technical report MSR-TR-2004-24) (Microsoft Research)
- Dictionary of Algorithms and Data Structures: best-first search (National Institute of Standards and Technology (NIST))
- heapq: Heap queue algorithm (The Python Standard Library) (Python Software Foundation)
- math.isqrt() (The Python Standard Library) (Python Software Foundation)
- Number.MAX_SAFE_INTEGER (ECMAScript 2026 Language Specification) (Ecma International)
- PriorityQueue (Java SE 25 and JDK 25 API) (Oracle)
- std::priority_queue (cppreference.com)
- Package container/heap (The Go Authors)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress