Cairn
algorithms · shortest paths · O((V + E) log V) with a heap, visits fewer nodes in practice

back to Shortest Paths

A* Search

Dijkstra's algorithm is thorough but incurious: its priority queue orders cells purely by accumulated cost g(n), so it expands outward in every direction at once with no notion of which way the goal actually is. A* keeps Dijkstra's exact machinery — a priority queue, relax-and-push, finalize-on-pop — but reorders the queue by f(n) = g(n) + h(n) instead, where h(n) is a heuristic estimate of the remaining cost from n to the goal. Cells that merely look close to the goal now cut ahead of cells that are just cheap so far, and the search visibly leans toward the target instead of flooding outward evenly.

Try it

Same grid, same terrain, same start and end cells as the Dijkstra page — click a cell to cycle its cost through 1 → 3 → 9, so the two demos stay directly comparable. The heuristic here is Manhattan distance to the goal (the grid only allows up/down/left/right moves, so that's the fewest steps physically possible, and every step costs at least 1). Step through and watch the queue: each chip now shows g (cost so far), h (heuristic estimate), and f (their sum, what the queue actually sorts by) — and the running visited-count at the end tells you how much less ground A* covered than Dijkstra did on the exact same grid. Switch the heuristic mode to inflated to see what happens when h is allowed to overestimate: still fast, and on this particular terrain still correct — but not for a reason you should trust in general. See Pitfalls below for a grid where inflating h actually returns the wrong answer.

Click cells to change terrain cost, then press Step or Run.

Why it works

Dijkstra's correctness rests on one invariant: whenever a cell is popped, its known distance is already optimal, because the queue always hands back whichever unfinished cell is globally cheapest so far. A* preserves exactly this invariant under one extra condition on the heuristic, called admissibility: h(n) must never overestimate the true remaining cost from n to the goal. When that holds, f(n) = g(n) + h(n) is a lower bound on the true cost of the cheapest path that passes through n — so a cell with a lower f can never be beaten by a cell with a higher one once both their true costs are known, and the "pop it, it's final" argument still goes through. Manhattan distance is admissible here because it's a hard floor: no path can physically take fewer steps than the straight-line grid distance, and no step costs less than 1, so h(n) can never claim more remaining cost than the graph is capable of actually charging.

What admissibility buys is focus, not a different answer. A heuristic of exactly zero everywhere is trivially admissible (it never overestimates a non-negative true cost) and turns A* back into plain Dijkstra — no wasted exploration in the wrong direction, but no bias toward the goal either. A good admissible heuristic sits as close to the true remaining cost as it can without ever exceeding it: tight enough to steer the queue, never so tight it lies.

Reference implementation

Identical to the Dijkstra reference implementation with one change: the priority queue orders by f = g + h instead of by g alone, and each push computes the heuristic for the newly-relaxed cell.

function aStar(grid, rows, cols, start, end) {
  const key = (r, c) => r * cols + c;
  const n = rows * cols;
  const [er, ec] = end;
  const h = (r, c) => Math.abs(r - er) + Math.abs(c - ec); // Manhattan distance, admissible here

  const g = new Array(n).fill(Infinity);
  const visited = new Array(n).fill(false);
  const parent = new Array(n).fill(-1);
  const startK = key(...start), endK = key(...end);
  g[startK] = 0;

  // [f, g, cellKey] triples — same array-backed queue as Dijkstra, just sorted by f now.
  const pq = [[h(start[0], start[1]), 0, startK]];

  while (pq.length) {
    let mi = 0;
    for (let i = 1; i < pq.length; i++) if (pq[i][0] < pq[mi][0]) mi = i;
    const [, d, cur] = pq.splice(mi, 1)[0];
    if (visited[cur]) continue; // stale entry, already finalized cheaper
    visited[cur] = true;
    if (cur === endK) break;

    const r = Math.floor(cur / cols), c = cur % cols;
    for (const [dr, dc] of [[-1, 0], [1, 0], [0, -1], [0, 1]]) {
      const nr = r + dr, nc = c + dc;
      if (nr < 0 || nr >= rows || nc < 0 || nc >= cols) continue;
      const nk = nr * cols + nc;
      if (visited[nk]) continue;
      const ng = d + grid[nk];
      if (ng < g[nk]) {
        g[nk] = ng;
        parent[nk] = cur;
        pq.push([ng + h(nr, nc), ng, nk]);
      }
    }
  }

  if (!visited[endK]) return null;
  const path = [];
  for (let cur = endK; cur !== -1; cur = parent[cur]) path.push(cur);
  return { path: path.reverse(), cost: g[endK] };
}

Pitfalls

An inadmissible heuristic can return the wrong answer outright — not just explore inefficiently, actually report a path that costs more than the cheapest one. Once h can overestimate, f(n) is no longer a reliable lower bound, so a cell on the true cheapest path can be shelved behind a cell that only looks closer to the goal, and finish first without the real winner ever getting a chance to be compared. Here's a small grid, worked out and checked against Dijkstra, where inflating the heuristic to Manhattan distance does exactly that:

3 3 9 1 1
3 1 1 1 1
1 1 1 3 1
1 1 1 9 1

Start top-left, end bottom-right. The true cheapest path costs 9: down the left column once, then straight across row 1, then down to the corner. With h inflated to Manhattan distance, A* instead commits to cutting across row 2 — it costs 11, 22% more than optimal, and the algorithm reports it as if it were the answer, with no indication anything went wrong. The default terrain in the demo above happens not to expose this — its cost structure is uniform enough by column that even a heavily inflated heuristic still lands on an optimal path, just faster — which is itself worth noticing: an inadmissible heuristic can seem to work correctly for a long time before the terrain finally proves it wrong. Admissibility isn't optional insurance against a rare edge case; it's the entire basis for trusting the answer at all.

The heuristic has to be reachable to compute cheaply, or it defeats the point. Manhattan distance is O(1) per cell precisely because it ignores the terrain — it's a lower bound on the best possible case, not a prediction of the actual cost. A heuristic that tried to account for terrain (say, by running a smaller search of its own) could be tighter and admissible, but spending significant work per cell to estimate remaining cost undermines the reason to prefer A* over Dijkstra in the first place.

It's still single-source, single-target. Same limitation as Dijkstra: one run finds the cheapest path between exactly the start and end cells given, not between every pair. Different heuristics can be swapped in per problem, but the algorithm doesn't generalize to all-pairs the way Floyd-Warshall does.

Complexity

Time: O((V + E) log V) with a binary-heap priority queue — same bound as Dijkstra, since in the worst case (a heuristic of zero everywhere) A* degenerates to exactly Dijkstra and explores just as much. The heuristic changes how much of that worst case is actually paid in practice, not the asymptotic ceiling: a good admissible heuristic prunes the search space without changing what the algorithm is allowed to cost when the heuristic gives it no useful information. The array-backed queue in the demo and reference code above is O(V² + E), same as Dijkstra's, for the same reason (linear-scan extraction). Space: O(V) for the cost array, parent array, and queue — identical to Dijkstra, plus O(1) per cell to compute Manhattan distance on demand rather than storing it.

For a decision guide across all eleven of this site's shortest-path entries — including exactly when a heuristic is worth having at all — see Choosing a Shortest-Path Algorithm.