Cairn
algorithms · shortest paths · O(k·(V+E) log V) preprocessing, O((V+E) log V) per query

back to Shortest Paths

ALT Algorithm (A*, Landmarks, Triangle Inequality)

A* search only pays off when there's a cheap, admissible estimate of the remaining distance — on the grid A*'s own page uses, that's Manhattan distance, which works because the grid has coordinates and cost correlates with physical distance. Plenty of real graphs have neither: a citation graph, a task-dependency graph, a network where the edge weight is a toll or a latency with no embedding behind it at all. The ALT algorithm (Goldberg & Harrelson, 2005 — A*, Landmarks, Triangle inequality) builds an admissible heuristic from nothing but the graph's own edge weights: pick a handful of landmark nodes, run one Dijkstra pass from each — in both directions — and the triangle inequality alone turns those precomputed distances into a valid lower bound to any goal, for any start, with no coordinates anywhere in the calculation.

Try it

Seven nodes, thirteen directed edges, one landmark (★A) — positions on the canvas below are for layout only and carry no meaning; unlike the Dijkstra/A* grid, dragging a node here wouldn't change a single edge weight. The panel above the graph is preprocessing: two one-time Dijkstra runs from A, computed once at page load and reused for every query below — forward (d(A, x), A's own outgoing distances) and backward (d(x, A), via Dijkstra on the reversed graph). Pick a query and a heuristic mode, then step through: ALT uses h(v) = max(0, d(A,goal) − d(A,v), d(v,A) − d(goal,A)); none zeroes the heuristic out, which is exactly plain Dijkstra. Watch the visited-count line at the end — same answer, every time, just less work to get there.

Press Step or Run.

Why it works

For any landmark L and any pair of nodes v, t, the triangle inequality gives two separate facts for free, just from L's own precomputed distances. First: d(L, v) + d(v, t) ≥ d(L, t), because going straight from L to t can never cost more than detouring through v — rearranged, that's d(v, t) ≥ d(L, t) − d(L, v), a lower bound built entirely from L's forward distances. Second, running the same argument on the reversed graph: d(v, t) + d(t, L) ≥ d(v, L), giving d(v, t) ≥ d(v, L) − d(t, L), a lower bound built from L's backward distances (distance to L, not from it — the two only coincide if the graph happens to be undirected). Either fact alone is a valid, safely-below-the-truth estimate of the remaining distance from v to the goal t; taking the max of both (and of every landmark's own pair of bounds, with more than one landmark) only tightens the bound; clamping below at 0 keeps it from ever going negative. That max is exactly h(v), and because every ingredient is a real shortest-distance value computed by an already-correct Dijkstra run, admissibility isn't an assumption that needs checking per graph the way a hand-picked coordinate heuristic would — it's a direct consequence of the triangle inequality, true for any landmark, any graph, any weights, as long as every edge weight is non-negative (Dijkstra's own precondition, inherited unchanged).

The preprocessing and the query are genuinely separate phases. Computing d(A, x) and d(x, A) for every x costs two full Dijkstra runs, done once, no matter which goal a later query asks about — the same landmark table answers a query to any node in the graph. That's the trade A* itself can't offer with a coordinate heuristic: Manhattan distance is free to compute per cell precisely because it needs no preprocessing at all, but it's only available when coordinates exist in the first place. ALT spends real, upfront work to manufacture a heuristic where none was otherwise available, and gets it back across every query that reuses the same table.

Reference implementation

Preprocessing is two Dijkstra runs per landmark — one on the graph, one on the graph with every edge reversed. The search itself is exactly A*'s own reference implementation, unchanged, just handed this heuristic instead of Manhattan distance:

function dijkstra(numNodes, adj, src) {
  const dist = new Array(numNodes).fill(Infinity);
  const visited = new Array(numNodes).fill(false);
  dist[src] = 0;
  for (let iter = 0; iter < numNodes; iter++) {
    let u = -1, best = Infinity;
    for (let i = 0; i < numNodes; i++) if (!visited[i] && dist[i] < best) { best = dist[i]; u = i; }
    if (u === -1) break;
    visited[u] = true;
    for (const [v, w] of adj[u]) if (dist[u] + w < dist[v]) dist[v] = dist[u] + w;
  }
  return dist;
}

function reverseAdj(numNodes, adj) {
  const radj = Array.from({ length: numNodes }, () => []);
  for (let u = 0; u < numNodes; u++) for (const [v, w] of adj[u]) radj[v].push([u, w]);
  return radj;
}

// preprocessing: once per landmark, reused by every later query
function precomputeLandmark(numNodes, adj, landmark) {
  const forward = dijkstra(numNodes, adj, landmark);           // d(landmark, x)
  const backward = dijkstra(numNodes, reverseAdj(numNodes, adj), landmark); // d(x, landmark)
  return { forward, backward };
}

// query time: with k landmarks, take the max bound across all of them —
// a max of several individually-valid lower bounds is still a valid lower bound.
function landmarkHeuristic(landmarks, goal) {
  return function h(node) {
    let best = 0;
    for (const { forward, backward } of landmarks) {
      best = Math.max(best, forward[goal] - forward[node], backward[node] - backward[goal]);
    }
    return best;
  };
}
// A* itself is unmodified from here — see the A* page's reference implementation,
// called with h = landmarkHeuristic(landmarks, goal) in place of Manhattan distance.

Pitfalls

Treating a directed graph as if it were symmetric silently breaks admissibility. The backward bound needs d(v, L) — the distance from v to the landmark — which is a different number from d(L, v) whenever the graph is directed. A version that skips the second Dijkstra run and reuses the forward table for both bounds looks like a reasonable shortcut (half the preprocessing, and it's still just subtracting two numbers), but it substitutes a number that was never a valid backward distance in the first place. On this page's own seven-node graph, that substitution gets the right answer on 27 of the 30 reachable ordered pairs from landmark A by accident — the wrong 3 include start D, goal E, where the correct heuristic (and plain Dijkstra) both agree the cheapest path costs 7, and the symmetric-shortcut heuristic reports 23229% more expensive than optimal, with no indication anything went wrong. Both Dijkstra runs are required for a directed graph, full stop; only an undirected graph gets to skip the second one, because there d(v, L) and d(L, v) are the same value by definition.

The preprocessing cost is real, and it only pays for itself across repeated queries. On this same graph, averaged across all 30 reachable ordered pairs, ALT visits 3.55 nodes per query against plain Dijkstra's 4.50 — a real reduction, but well under one node's worth of work saved per query, while the one-time preprocessing already spent two full Dijkstra traversals computing the landmark table in the first place. A single one-off query is cheaper run as plain Dijkstra outright; ALT wins specifically when the same table answers many queries against different goals, the way a route planner precomputes once and serves years of trip requests. Landmark placement matters too and isn't built into this demo's fixed single-landmark setup — trying each of this graph's seven nodes as the sole landmark swings the average visited-count reduction from 15.3% to 23.8% depending which one is picked, before any query ever runs; the standard practical fix is choosing landmarks far apart from each other (a farthest-first placement), not arbitrarily, and using several of them at once via the max in landmarkHeuristic above.

Complexity

Time: preprocessing costs O(k · (V + E) log V) for k landmarks with a binary heap — two Dijkstra runs each, forward and backward — done once and reused by every subsequent query. Each query after that is exactly A*'s own bound, O((V + E) log V) worst case, typically less in practice as the heuristic prunes the search; a heuristic of zero everywhere (no landmarks, or every landmark equally uninformative for this particular pair) degrades to plain Dijkstra, never worse. The array-backed Dijkstra in the demo and reference code above is O(V² + E) per run instead, the same array-vs-heap trade Dijkstra's own page makes for its demo. Space: O(k · V) for the landmark distance tables — two arrays of length V per landmark — plus A*'s own O(V) for the cost array, parent array, and queue during a query.

For a decision guide across all eleven of this site's single-cheapest-path entries — including exactly when a landmark-built heuristic is worth the preprocessing over Dijkstra's zero-setup default — see Choosing a Shortest-Path Algorithm.