A* Search
Point-to-point shortest path that steers Dijkstra toward the goal with a heuristic h(v): pop by f = g + h; optimal when h never overestimates.
Overview
A* finds a shortest path from a source to a specific target. It is Dijkstra's Algorithm with one change: the Priority Queue is ordered by f(v) = g(v) + h(v), where g(v) is the best known cost from the source and h(v) is a heuristic estimate of the remaining cost to the goal. A good heuristic makes the search expand nodes roughly along the straight line to the goal instead of in every direction.
Preconditions: non-negative edge weights, a single target, and a heuristic that is admissible (h(v) ≤ true remaining cost for all v) — then the first time the goal is popped its g is optimal. If h is additionally consistent (h(u) ≤ w(u, v) + h(v) for every edge, a triangle inequality), each node is settled once, exactly like Dijkstra, and the stale-entry skip is enough. With h ≡ 0 A* is exactly Dijkstra; worst-case complexity is the same O((V + E) log V), but the explored region is usually far smaller.
Intuition
A mental model before the formal terms.
Finding a route on a map, you would not explore roads leading away from your destination just because they are close to you. A* keeps Dijkstra's "cheapest so far" accounting (g) but adds "roughly how far is it still?" (h, e.g. straight-line distance) and always continues from the node whose total looks best. As long as the guess never claims a route is longer than it really is, it can never fool you into skipping the true shortest path.
How it works
- Set
g[s] = 0, push(f = h(s), s)into a min-heap. - Pop the entry with the smallest
f. If it is the goal, stop:g[goal]is the answer. If the poppedgis stale (larger than the storedg[u]), skip. - For each edge
u → vwith weightw: ifg[u] + w < g[v], setg[v] = g[u] + w,parent[v] = u, and push(g[v] + h(v), v). - Heuristics for grids: Manhattan distance
|dr| + |dc|for 4-directional moves, Chebyshevmax(|dr|, |dc|)for 8-directional unit moves, Euclidean for continuous movement. All are admissible and consistent because they are exact distances on an obstacle-free grid, and obstacles can only lengthen paths. - Weighted A* uses
f = g + ε·hwithε > 1: faster, but the result may be up toεtimes longer than optimal — a deliberate trade, not a bug.
Why it works
Admissibility ⇒ optimality of the first goal pop: suppose the goal is popped with cost C > C* (the optimum). Some node y on an optimal path is in the open set with g(y) = g*(y), so f(y) = g*(y) + h(y) ≤ g*(y) + h*(y) = C* < C. Then y would have been popped before the goal. Contradiction.
Consistency ⇒ f is non-decreasing along any path: f(v) = g(u) + w + h(v) ≥ g(u) + h(u) = f(u). So nodes are popped in non-decreasing f order and each node's first pop already has optimal g — identical to Dijkstra's argument. Without consistency (only admissibility) a closed node can be rediscovered with a better g and must be reopened; the lazy stale-check version handles this automatically because it never permanently closes nodes.
A* is optimally efficient: among algorithms using the same consistent h, no other one expands fewer nodes and still guarantees optimality.
Recognition
How to tell a problem wants this.
- One source, one target, and a cheap lower-bound estimate of distance exists (geometry, coordinates, Manhattan distance).
- Pathfinding on large grids or maps where Dijkstra explores too much (game AI, robotics, routing).
- Puzzle solving with a state space and a lower-bound cost (15-puzzle with Manhattan tile distance).
Interactive visualization
Play, step, change the input. ← → and space work too.
| cell | g | h | f |
|---|---|---|---|
| (0,0) | 0 | 11 | 11 |
1g[start] = 0; open = {start with f = h(start)}2while open not empty:3 u = cell in open with smallest f = g + h4 if u == target: reconstruct path and stop5 closed.add(u)6 for v in 4-neighbors(u) not wall, not closed:7 if g[u] + 1 < g[v]:8 g[v] = g[u] + 1; parent[v] = u; f[v] = g[v] + h(v); open.add(v)9open empty → no pathPseudocode
1g = {v: INF}; g[s] = 0; heap = [(h(s), s)]2while heap not empty:3 f, u = heap.pop_min(); if u == goal: return g[u]4 if f - h(u) > g[u]: continue # stale5 for (v, w) in adj[u]:6 if g[u] + w < g[v]:7 g[v] = g[u] + w; parent[v] = u8 heap.push((g[v] + h(v), v))Implementations
1import heapq2import math3from typing import Callable4 5 6def astar(7 adj: list[list[tuple[int, float]]],8 start: int,9 goal: int,10 h: Callable[[int], float],11) -> tuple[float, list[int]]:12 """A*: Dijkstra plus a heuristic. The frontier is ordered by f = g + h,13 where g is the cost already paid and h estimates the cost remaining. An14 admissible h means the first pop of the goal is optimal. Returns15 (cost, path); cost is -1 and path is empty when the goal is unreachable."""16 n = len(adj)17 181 · g[v] is the best cost found so far from start to v19 g: list[float] = [math.inf] * n20 parent = [-1] * n21 closed = [False] * n22 23 g[start] = 024 open_heap: list[tuple[float, int]] = [(h(start), start)]25 26 while open_heap:272 · Expand the node with the smallest f; closed nodes are already final28 _, u = heapq.heappop(open_heap)29 if closed[u]:30 continue31 closed[u] = True32 333 · With an admissible heuristic, popping the goal ends the search34 if u == goal:35 path = []36 at = goal37 while at != -1:38 path.append(at)39 at = parent[at]40 return g[goal], path[::-1]41 424 · Relax like Dijkstra, but order the frontier by g + h, not g43 for v, w in adj[u]:44 if closed[v]:45 continue46 if g[u] + w < g[v]:47 g[v] = g[u] + w48 parent[v] = u49 heapq.heappush(open_heap, (g[v] + h(v), v))50 51 return -1, []52 53 545 · A consistent heuristic for a grid: Manhattan distance with unit steps55def manhattan(width: int, goal_index: int) -> Callable[[int], float]:56 gx, gy = goal_index % width, goal_index // width57 58 def h(v: int) -> float:59 return abs(v % width - gx) + abs(v // width - gy)60 61 return hheapqprovides the open set directly; entries are(f, vertex)tuples ordered lexicographically byf._, u = heapq.heappop(open_heap)discards the stalef—g[u]is the authoritative cost.closed[u]implements lazy deletion, so no decrease-key operation is needed.- The goal test is on pop rather than on discovery, which is what preserves optimality.
manhattanreturns a nested function closing overgx/gy;v // widthis integer division for the row, matching the% widthcolumn.
The heuristic is a Python-level call per push, which on a large grid can rival the heap operations in cost — caching it into a list is a common optimisation.
heapqis a min-heap, which is what A* wants with no comparator inversion.//is floor division and/is float division; the grid heuristic needs//for the row and would silently break with/.networkx.astar_pathimplements this directly and takes the heuristic as a two-argument callable(u, v).- Tuple comparison falls through to the second element on an
ftie, so pushing a non-comparable payload as a third element would raiseTypeError.
- Using
/instead of//for the row index, producing a float coordinate and an incorrect heuristic. - Returning as soon as the goal is *discovered* rather than *popped*, which sacrifices optimality for no real speed gain.
- Pushing
(f, vertex, path_list)and hittingTypeErrorwhen two entries tie on bothfand vertex.
- A* is Dijkstra plus one term, so it inherits the same split:
heapqandstd::priority_queuesupply the open set, while JS/TS ship an inline binary heap for the third time. - Grid index arithmetic exposes the division difference — Python needs
//, JS/TS needMath.floor, and C++/onintalready truncates. - Passing the heuristic: C++ pays an indirect call through
std::function(or inlines it via a template), while JS/TS/Python pass a closure with a real call per push. - Library A* exists only in Python (
networkx.astar_path) and, with a dependency, C++ (Boost.Graphastar_search); JS/TS have neither.
Complexity
Best case: a perfect heuristic expands only the L nodes on the optimal path. Worst case (h ≡ 0 or misleading h): identical to Dijkstra. Memory is the practical limit — the open set can hold most of the graph.
Compare growth rates in the Complexity Explorer →When to use — and when not to
- Single-target shortest path on a large graph with a natural distance lower bound (grids, maps, coordinates).
- Real-time pathfinding where Dijkstra explores too much of the map.
- State-space search with a cheap admissible bound (puzzles, planning).
- No meaningful heuristic — then it is Dijkstra with extra function calls.
- Distances to all nodes are needed — the heuristic only helps for one target; use Dijkstra's Algorithm.
- Negative edges — same failure as Dijkstra; use Bellman-Ford.
- Memory-constrained huge searches — the open set can explode; consider IDA* (iterative deepening A*).
Alternatives
Common mistakes
- Using an inadmissible heuristic (e.g. Euclidean distance × 2, or Manhattan on a grid that allows diagonal moves) — the returned path may not be optimal.
- Closing nodes permanently on first pop with an admissible-but-inconsistent heuristic — they may need reopening. The lazy stale-check version avoids the issue.
- Checking for the goal when a node is pushed rather than popped — the first push is not guaranteed optimal.
- Heuristic that ignores obstacles is fine; a heuristic that ignores edge weights while edges cost less than 1 per unit distance is not (it overestimates).
- Floating-point ties: comparing
f - h(u) > g[u]with rounding errors; use an epsilon or storegin the heap entry instead.
Interview patterns
- Grid pathfinding with Manhattan heuristic — usually as a follow-up to a BFS solution ("how would you speed this up for a huge map?").
- Sliding puzzle / 8-puzzle with sum-of-Manhattan-tile-distances heuristic.
- Explain admissible vs consistent and why Dijkstra is A* with
h = 0. - Tie-breaking on larger
g(prefer deeper nodes) to reduce expansions when many paths tie.
- Choosing between BFS, DFS, Dijkstra and DPAdvanced
- Where does O(n log n) come from?Beginner
- Top K from a streamIntermediate
- When a hash map is the wrong choiceIntermediate
- Kth Largest Element in an ArrayIntermediate
- Network Delay TimeAdvanced
- Top K Frequent ElementsIntermediate