Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Advanced
  • 30 minutes
  • Examples run with Python 3.14.8, Pyodide 314.0.7, Node.js 24.21.0, quickjs 0.32.0, Java 25, Clang 21.0.0 and Go 1.27.1
  • By MySmartCoPilot
Free core, Pro depth: 9 of 30 minutes free
Free: How the search chooses the next town · Admissible and consistent heuristics · How many towns each search expands, measured · Exercise and self-check. With a pass: 3 in-depth parts and 3 interview answers, about 21 minutes.

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

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:

  1. Put the start in the queue with g = 0 and f = h(start).
  2. 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.
  3. 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.
  4. 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).
  5. 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 ten towns, every step printed, then Dijkstra on the same trip Python · trace/trace.py
# 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

Ten towns joined by roads with their lengths in km, each town labelled with its straight-line distance h to the goal G.Hh = 13Ih = 12Jh = 12A (start)h = 10Bh = 8Ch = 7Dh = 8Eh = 5Fh = 5G (goal)h = 0444345344455456

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.

Common estimates, and where each one never overestimates
Criterion h = 0Straight lineManhattanOctile
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 overestimatesNever overestimatesCan overestimate on diagonal roadsCan overestimate on roads at an angle
Grid, 4 moves of cost 1 Never overestimatesNever overestimatesNever overestimates; exact without wallsNever overestimates
Grid, 8 moves, diagonals cost √2 Never overestimatesNever overestimatesOverestimatesNever overestimates; exact without walls
When to choose Many goals, or no coordinates at allRoad and rail networks with coordinatesGrids with 4-way movesGrids 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.

Towns expanded per trip by Dijkstra and A*, on 100 random trips Python · measure/measure.py
# 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

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.

A* with a consistent estimate on V towns and E roads, with a binary heap
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²
Distance, Midpoint & Section Formula Calculator Check an estimate by hand: from A at (0, 3) to G at (10, 3) the straight line is 10 km, the h of A in the trace.

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.

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.

  1. Question 1 of 5 The best route found so far reaches a town after 12 km, and the town is 9 km from the goal in a straight line. With the straight-line estimate, what is the town's f?

    Type a number.

    Show the answer to question 1

    Answer: 21 km

    f = g + h: the 12 km already driven plus the estimate of 9 km still to go. A* takes towns from its queue in order of f, smallest first.

  2. Question 2 of 5 What does A* do when h is 0 for every town?

    Choose one answer.

    Show the answer to question 2

    Answer: Exactly what Dijkstra's algorithm does, because f is then just g

    With h = 0, the queue is ordered by the distance from the start alone, which is Dijkstra's rule. The zero estimate never overestimates, so the route is still a shortest one; A* just gets no help towards the goal.

  3. Question 3 of 5 On a grid where every move goes one cell up, down, left or right and costs 1, which estimate never overestimates and is the largest of these?

    Choose one answer.

    Show the answer to question 3

    Answer: The Manhattan distance, |dx| + |dy|

    With four directions every route needs at least |dx| + |dy| moves, so the Manhattan distance is a lower bound, and it is never smaller than the other two lower bounds. Twice the Manhattan distance overestimates. A larger estimate that still never overestimates lets A* expand fewer cells.

  4. Question 4 of 5 Roads on the lesson's map are never shorter than the straight line between their ends. Which estimates never overestimate the remaining road distance to the goal?

    Choose every answer that is right.

    Show the answer to question 4

    Answer:

    • Zero
    • Half the straight-line distance to the goal
    • The straight-line distance to the goal

    Any route is at least as long as the straight line, so the straight line, half of it and zero are all lower bounds. The Manhattan distance can exceed a diagonal road route, and 1.5 times the straight line exceeds any route that is nearly straight.

  5. Question 5 of 5 In the ten-town trace, why does A* never take H, I or J from the queue?

    Choose one answer.

    Show the answer to question 5

    Answer: Their f is 16, and A* takes the goal at f = 13 before any of them

    Each of them has f = g + h = 16: for example H is 3 km from A and 13 km from G in a straight line. The queue always gives the smallest f first, and the goal comes out with f = 13, so the search ends before reaching them.

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.

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

  1. 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.

  2. 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.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  3. 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?

    Model answer, Pro. First step and follow-ups

  4. 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?

    Model answer, Pro. First step and follow-ups

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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.