Back to Graph Theory Series

A* Search

August 30, 2026 Wasil Zafar 27 min read

A* combines the cost already paid with an optimistic estimate of the cost still ahead. When that estimate obeys the right rules, the search aims toward the goal without giving up shortest-path guarantees.

Contents

  1. A Bit of History
  2. The Three Scores
  3. Working Principle
  4. Choosing a Heuristic
  5. Worked Example
  6. Why A* Is Optimal
  7. Complexity Analysis
  8. Implementation
  9. Real-World Applications
  10. Variants & Tradeoffs
  11. Pitfalls & Debugging
  12. Exercises

A Bit of History

A* was developed in 1968 by Peter Hart, Nils Nilsson, and Bertram Raphael at the Stanford Research Institute (SRI), as part of the Shakey project. Shakey the Robot, an early mobile robot able to reason about actions, needed to plan around obstacles using very limited computing resources. Their paper, "A Formal Basis for the Heuristic Determination of Minimum Cost Paths," did more than propose a faster search: it identified conditions under which heuristic guidance preserves optimality. That marriage of practical direction and a formal guarantee is why A* became a foundational tool in pathfinding and planning.

The Three Scores

For every discovered vertex $v$, A* separates what it knows from what it estimates:

g(v)

Cost already paid

The cheapest start-to-$v$ path discovered so far. Edge relaxation can lower it.

h(v)

Estimated cost remaining

A problem-specific lower bound—or optimistic guess—from $v$ to the goal.

f(v)=g(v)+h(v)

Estimated total route

The priority-queue key. A* expands the frontier state with smallest $f$.

A-star compares frontier nodes using g plus h After expanding start S, frontier node X has g 2, h 6, f 8. Frontier node Y has g 5, h 2, f 7. A-star chooses Y because its estimated total is smaller even though reaching Y cost more. After expanding SOpen queue cost 2cost 5cost 7cost 2 SXYG g=2 · h=6 · f=8g=5 · h=2 · f=7 1 · Yf=7 ← pop next 2 · Xf=8 A* does not ask:“Which node is closest so far?”It asks: “Which full route looks cheapest?”
Y costs more to reach than X, but its estimated complete route is cheaper. The heuristic changes exploration order; $g$ still records real path cost.
g = evidence+h = guidance=f = priority

Working Principle

A* is best understood as Dijkstra's relaxation rule with a more informed queue order. Assume finite, nonnegative edge costs and a goal heuristic:

  1. Set $g(start)=0$ and push the start with priority $h(start)$.
  2. Pop the entry with minimum $f$. Skip it if its saved $g$ no longer equals the best known $g$ for that vertex.
  3. If it is the goal, reconstruct the path through parent pointers.
  4. For every edge $(u,v)$ of cost $w\ge 0$, relax $g(v)$ with $g(u)+w$. On improvement, update the parent and push a fresh queue entry.
  5. If the queue empties, the goal is unreachable.

Allowing a better $g$ value to push a vertex again is called reopening. It keeps graph-search A* correct for admissible heuristics even when they are inconsistent. Old heap entries are harmless because the stale-entry test discards them.

SearchPriorityBehavior
Dijkstra$g(v)$Uniformly expands cheapest known distance; equivalent to A* with $h=0$
Greedy best-first$h(v)$Aims at the goal but ignores cost already paid; not generally optimal
A*$g(v)+h(v)$Balances evidence and guidance

Choosing a Heuristic

Admissible: never overestimate

If $h^*(v)$ is the true cheapest remaining cost, admissibility requires $0\le h(v)\le h^*(v)$. The heuristic is optimistic: it may ignore walls, traffic, or terrain, but it cannot invent extra cost.

Consistent: obey a triangle inequality

For every edge $(u,v)$ with cost $w(u,v)$, consistency requires

$$h(u) \le w(u,v)+h(v), \qquad h(goal)=0.$$

Then $f$ never decreases along a path, so once a vertex is expanded with its best queue entry, it never needs reopening. Every consistent heuristic is admissible under the usual goal assumptions; an admissible heuristic need not be consistent.

Movement modelAdmissible heuristicFormula for unit grid costs
4 directionsManhattan$|\Delta x|+|\Delta y|$
8 directions; every move costs 1Chebyshev$\max(|\Delta x|,|\Delta y|)$
8 directions; diagonal costs $\sqrt2$Octile$\max(dx,dy)+(\sqrt2-1)\min(dx,dy)$
Geometric movementEuclidean$\sqrt{(\Delta x)^2+(\Delta y)^2}$

Match the units. If one orthogonal step can cost as little as $c_{min}$, scale Manhattan distance by $c_{min}$. Obstacles may be ignored because ignoring them lowers the estimate. Discounted shortcuts, portals, or cheaper diagonal moves must be reflected or the heuristic may overestimate.

Stronger Without Overstepping

Among admissible heuristics, a larger estimate is usually more informative. If $h_1$ and $h_2$ are admissible, $\max(h_1,h_2)$ is also admissible and dominates either one. Multiplying by a factor greater than 1 may be faster, but gives up ordinary A*'s optimality guarantee.

Worked Example

Use a 7×5 grid with unit-cost four-directional movement. Start at $(0,2)$, goal at $(6,2)$, and place a vertical wall in column 3 with one opening at the bottom. Manhattan distance ignores the wall, so it begins at 6 even though the true route cost is 10.

A-star routes around a wall using Manhattan distance A seven by five grid has start on the left and goal on the right. A vertical wall blocks the direct route except for a bottom gap. The optimal path goes down, through the gap, and back up with total cost ten. Grid and one optimal routeScores at key positions WALL SG 10 unit edgesManhattan at S: 6 · true distance: 10The wall is ignored by h, not by g Start (0,2)g=0 · h=6 · f=6Before wall (2,2)g=2 · h=4 · f=6Gap (3,4)g=5 · h=5 · f=10Goal (6,2)g=10 · h=0 · f=10 f rises when reality reveals the detourOptimistic does not mean exact
The heuristic is admissible because ignoring obstacles cannot make the estimated route longer. The displayed score rows are checkpoints on one optimal path, not a claim about every tie-broken expansion.

The early $f=6$ values are lower bounds, not promises. When the search reaches the wall and must move away from the goal, $g$ rises faster than $h$ falls, revealing the true $f=10$ route cost.

Why A* Is Optimal

Assume nonnegative edges, $h(goal)=0$, an admissible heuristic, and an implementation that accepts improved $g$ values even for previously expanded vertices. Let $C^*$ be the optimal start-to-goal cost.

Before an optimal solution is found, some frontier vertex $x$ lies on an optimal path with its optimal prefix cost $g^*(x)$. Because $h$ never overestimates,

$$f(x)=g^*(x)+h(x)\le g^*(x)+h^*(x)=C^*.$$

If A* popped the goal with cost greater than $C^*$, that goal entry would have $f(goal)=g(goal)>C^*$ while $x$ still had priority at most $C^*$. A min-priority queue could not choose the goal first. Therefore the first non-stale goal entry has optimal cost.

What consistency buys you

For a consistent heuristic, every traversed edge satisfies $f(v)\ge f(u)$ after relaxation from $u$. Once a vertex is expanded at minimum $f$, no later route can improve its $g$ value. A permanent closed set is then safe. With an admissible but inconsistent heuristic, reopening may be necessary—the implementation below supports it.

How Overestimation Can Lose the Best Path

Suppose $S\to A\to G$ costs $2+2=4$, while $S\to B\to G$ costs $1+4=5$. If $h(A)=10$ and $h(B)=0$, A* may pop B and then G before considering A, returning cost 5. The inflated estimate hid the true optimum.

Complexity Analysis

With a consistent heuristic, nonnegative edges, and a binary heap, each vertex is expanded at most once and each edge is relaxed once:

$$\text{Time: } O((V+E)\log V) \qquad \text{Space: } O(V+E)$$

The duplicate-entry heap used below may hold multiple entries for a vertex, but at most one per successful relaxation; its logarithmic factor can be written $\log E$, which is $O(\log V)$ for ordinary simple graphs.

Heuristic quality mostly changes the number of states reached before the goal. With $h=0$, A* is Dijkstra. With a perfect heuristic, it can focus tightly on optimal routes. With an inconsistent heuristic, reopening can cause additional expansions and the simple one-expansion bound no longer applies.

Memory Is Often the Real Limit

A* retains frontier scores and parent information for many discovered states. On enormous implicit search spaces, memory can run out before asymptotic runtime becomes the practical bottleneck.

Implementation

The code uses duplicate heap entries instead of a decrease-key operation. Each entry remembers the $g$ value that created it; if a better route has since been found, the entry is stale and skipped.

StateMeaningInvariant
g[v]Best discovered start-to-$v$ costOnly decreases after first discovery
parent[v]Predecessor achieving current g[v]Following parents reconstructs the current best path
heap entry (f,g,v)Snapshot of one candidateProcess only when saved g == g[v]
edge weightReal transition costMust be nonnegative for this A* contract
import heapq
from itertools import count

def astar(graph, start, goal, heuristic):
    g = {start: 0}
    parent = {start: None}
    sequence = count()  # avoids comparing vertex objects when scores tie
    open_set = [(heuristic(start), 0, next(sequence), start)]

    while open_set:
        _, pushed_g, _, current = heapq.heappop(open_set)
        if pushed_g != g.get(current):
            continue  # a later relaxation made this entry stale

        if current == goal:
            path = []
            while current is not None:
                path.append(current)
                current = parent[current]
            return g[goal], path[::-1]

        for neighbor, weight in graph.get(current, []):
            if weight < 0:
                raise ValueError("A* requires nonnegative edge weights")
            tentative_g = pushed_g + weight
            if neighbor not in g or tentative_g < g[neighbor]:
                g[neighbor] = tentative_g
                parent[neighbor] = current
                heapq.heappush(open_set, (
                    tentative_g + heuristic(neighbor),
                    tentative_g,
                    next(sequence),
                    neighbor,
                ))

    return None, None   # unreachable

# Grid coordinates, Manhattan distance heuristic
positions = {"S": (0, 0), "A": (1, 0), "B": (2, 0), "G": (3, 0)}
def manhattan(v):
    gx, gy = positions["G"]
    x, y = positions[v]
    return abs(gx - x) + abs(gy - y)

graph = {"S": [("A", 1)], "A": [("B", 1)], "B": [("G", 1)], "G": []}
cost, path = astar(graph, "S", "G", manhattan)
print("Cost:", cost, "Path:", path)   # Cost: 3  Path: ['S', 'A', 'B', 'G']
#include <algorithm>
#include <functional>
#include <iostream>
#include <queue>
#include <stdexcept>
#include <string>
#include <tuple>
#include <unordered_map>
#include <utility>
#include <vector>
using namespace std;

using Cost = long long;
using Graph = unordered_map<string, vector<pair<string, Cost>>>;

pair<Cost, vector<string>> astar(
    const Graph& graph, const string& start, const string& goal,
    const function<Cost(const string&)>& heuristic) {

    unordered_map<string, Cost> g{{start, 0}};
    unordered_map<string, string> parent;
    using Entry = tuple<Cost, Cost, string>;  // f, saved g, vertex
    priority_queue<Entry, vector<Entry>, greater<Entry>> open;
    open.push({heuristic(start), 0, start});

    while (!open.empty()) {
        auto [f, pushedG, current] = open.top();
        open.pop();
        (void)f;
        if (!g.count(current) || pushedG != g.at(current)) continue;

        if (current == goal) {
            vector<string> path;
            for (string node = goal;; node = parent.at(node)) {
                path.push_back(node);
                if (node == start) break;
            }
            reverse(path.begin(), path.end());
            return {pushedG, path};
        }

        auto it = graph.find(current);
        if (it == graph.end()) continue;
        for (const auto& [neighbor, weight] : it->second) {
            if (weight < 0) throw invalid_argument("negative edge weight");
            Cost tentativeG = pushedG + weight;
            if (!g.count(neighbor) || tentativeG < g.at(neighbor)) {
                g[neighbor] = tentativeG;
                parent[neighbor] = current;
                open.push({tentativeG + heuristic(neighbor), tentativeG, neighbor});
            }
        }
    }
    return {-1, {}};
}

int main() {
    Graph graph = {
        {"S", {{"A", 1}}}, {"A", {{"B", 1}}},
        {"B", {{"G", 1}}}, {"G", {}}
    };
    unordered_map<string, Cost> h = {{"S", 3}, {"A", 2}, {"B", 1}, {"G", 0}};
    auto [cost, path] = astar(graph, "S", "G", [&](const string& v) { return h.at(v); });
    cout << "Cost: " << cost << " Path:";
    for (const auto& v : path) cout << ' ' << v;
    cout << '\n';
}
import java.util.*;
import java.util.function.ToLongFunction;

class AStar {
    record Edge(String to, long weight) {}
    record State(long f, long g, String node) {}
    record Result(long cost, List<String> path) {}

    static Result astar(Map<String, List<Edge>> graph,
                        String start, String goal,
                        ToLongFunction<String> heuristic) {
        Map<String, Long> g = new HashMap<>();
        Map<String, String> parent = new HashMap<>();
        g.put(start, 0L);

        PriorityQueue<State> open = new PriorityQueue<>(
            Comparator.comparingLong(State::f));
        open.add(new State(heuristic.applyAsLong(start), 0, start));

        while (!open.isEmpty()) {
            State state = open.poll();
            String current = state.node();
            if (!g.containsKey(current) || state.g() != g.get(current)) continue;

            if (current.equals(goal)) {
                LinkedList<String> path = new LinkedList<>();
                for (String node = goal; node != null; node = parent.get(node))
                    path.addFirst(node);
                return new Result(state.g(), path);
            }

            for (Edge e : graph.getOrDefault(current, List.of())) {
                if (e.weight() < 0) throw new IllegalArgumentException("negative edge weight");
                long tentativeG = state.g() + e.weight();
                if (!g.containsKey(e.to()) || tentativeG < g.get(e.to())) {
                    g.put(e.to(), tentativeG);
                    parent.put(e.to(), current);
                    open.add(new State(
                        tentativeG + heuristic.applyAsLong(e.to()),
                        tentativeG,
                        e.to()));
                }
            }
        }
        return null;
    }

    public static void main(String[] args) {
        Map<String, List<Edge>> graph = new HashMap<>();
        graph.put("S", List.of(new Edge("A", 1)));
        graph.put("A", List.of(new Edge("B", 1)));
        graph.put("B", List.of(new Edge("G", 1)));
        graph.put("G", List.of());

        Map<String, Long> h = Map.of("S", 3L, "A", 2L, "B", 1L, "G", 0L);
        Result result = astar(graph, "S", "G", h::get);
        System.out.println(result);  // Result[cost=3, path=[S, A, B, G]]
    }
}

Real-World Applications

A* is most useful when the state space is too large to search blindly, transitions have nonnegative costs, and the domain supplies a meaningful lower bound on the remaining cost.

Games

Grid and navigation-mesh routing

A character can search cells, polygons, or waypoints. Distance to the destination guides the search, while terrain, doors, and hazards contribute to $g$.

Robotics

Motion planning

A state may encode position, heading, or velocity—not just a point. A lower bound that ignores some constraints can still help because the relaxed problem is cheaper to solve.

Routing

Road and transit networks

Straight-line distance divided by a valid maximum speed can lower-bound travel time. Turn restrictions and real travel costs stay in the graph model.

Planning

Puzzles and action sequences

Board configurations or world states become vertices. Heuristics often come from a relaxed puzzle, pattern database, or count of unresolved constraints.

The graph is a modeling choice

Before tuning a heuristic, decide what one state means and which transitions are legal. A grid cell may be enough for a simple map; a car-like robot may need $(x,y,\theta)$ so orientation is part of the state. A* can only optimize paths represented by that model.

Variants & Tradeoffs

VariantPriority / ideaWhy use it?What changes?
Dijkstra$f=g$No useful heuristic is availableOptimal with nonnegative edges, but explores more broadly
Weighted A*$f=g+w h$, $w>1$Prefer a fast good route over the exact optimumMore goal-directed; ordinary optimality is deliberately surrendered
IDA*Depth-first passes under increasing $f$ limitsMemory is the bottleneckSmall memory footprint, but repeated work
Bidirectional A*Search from both endsLong routes with useful forward and reverse heuristicsMeeting and stopping rules require care

Think of the Heuristic as a Steering Signal

$g$ keeps the search honest; $h$ points it forward. Making $h$ more informative can reduce exploration without changing the answer, provided it stays admissible. Multiplying it above 1 pushes the steering harder and changes the problem from exact search to a speed–quality tradeoff.

Pitfalls & Debugging

SymptomLikely causeCheck or fix
A suboptimal route is returnedThe heuristic overestimates, the goal was accepted when generated, or improved nodes were never reopenedTest $h(v)\le h^*(v)$ on small cases; stop only when a non-stale goal entry is popped; use the stale-entry pattern above
Too many states are expandedThe heuristic is weak or badly scaledCompare the units and typical magnitude of $h$ with actual edge costs
Behavior changes across runsMany states tie on $f$Add a deterministic secondary key; tie-breaking changes work performed, not optimal cost under the stated assumptions
The heap grows unexpectedlyDuplicate entries are normal, but stale entries may not be skippedStore the pushed $g$ and compare it with current best $g[v]$ after every pop
The algorithm loops or gives nonsenseNegative edges, overflow, or invalid heuristic valuesReject negative weights; choose a safe numeric type; disallow NaN or infinity unless handled explicitly

Do Not Stop When the Goal Is First Discovered

Generation proves only that one route reached the goal. Another frontier state may still lead to it more cheaply. The safe stopping point for the algorithm presented here is when the goal is removed from the priority queue through a non-stale entry.

Exercises

Build the Intuition in Layers

  1. Trace: Reproduce the worked grid by hand. Record the open set after each pop and state your tie-breaking rule.
  2. Compare: Repeat with $h=0$. Which extra cells does Dijkstra expand, and why?
  3. Reason: Prove Manhattan distance is consistent for four-direction moves of cost 1.
  4. Diagnose: Find an admissible but inconsistent heuristic on a four-vertex graph. Show where a permanent closed set can fail and how reopening repairs it.
  5. Engineer: Add counters for pops, stale entries, relaxations, and peak heap size to one implementation. Compare several heuristics on the same maps.
  6. Explore: Implement weighted A* with $w\in\{1,1.25,1.5,2\}$. Plot path cost against expanded states instead of judging speed from one run.

A compact mental model

A* is Dijkstra with informed ordering. The $g$ score records evidence, the heuristic supplies optimistic guidance, and $f=g+h$ decides what to investigate next. Correctness comes from the heuristic contract and from handling later improvements correctly—not from the priority formula alone.