Magic Bookof Algorithms DE

PathfindingPTH-02

A*

Dijkstra with a hunch: the same way for a fraction of the work.

Running time
O((E + V) log V)
Addition
estimate of what is left
Condition
estimate never too high
On the example map
576 instead of 903 squares

01What it is about

A is Dijkstra with a hunch. Dijkstra spreads out evenly in all directions because it knows nothing about where the goal lies — it would examine a way north just as thoroughly as one south, even though the goal is in the south. A is handed an estimate of the way that is left and sorts its queue not by "spent so far" but by "spent so far plus guessed remainder". That pulls the search towards the goal.

It was invented in 1968 at the Stanford Research Institute by Peter Hart, Nils Nilsson and Bertram Raphael, for Shakey — the first robot that had to work out for itself how to get across a room. Ever since, A* has been the standard method for pathfinding in games.

The whole difference to its ancestor is two lines of code. The result is the same way for a fraction of the work.

The same map, the same route, the same result: A* leaves the right half almost untouched. Produced while building this page by the code further down.

02How it works

The two numbers

Every place carries two quantities, and keeping them apart is the entire trick:

  • g — what the way up to here really cost. Kept in the dictionary.
  • h — what the way from here to the goal will probably still cost. Comes from the estimate.

The queue sorts by g + h, the dictionary remembers g. Whoever mixes the two up and carries on computing with g + h keeps adding the estimate over and over and gets nonsense out. That is the classic mistake with this algorithm.

The condition on the estimate

The estimate may never be too high. Underestimating what is left still finds the cheapest way for certain — the search merely looks at fewer squares. Overestimating can lure the search down a blind alley and hand back a more expensive way. An estimate that is never too high is called admissible.

On a grid with steps up, down, left and right the Manhattan distance is the natural choice: the number of steps if there were neither walls nor swamp. Because a step never costs less than 1, it is never too high. For eight directions one takes the octile distance, for free movement the straight line.

Two edge cases say a lot:

  • Estimate always 0 — then g + h is just g, and A* is Dijkstra again.
  • Estimate very large — then only the estimate counts and the search runs greedily at the goal (greedy best-first). It is fast and often finds a fairly good way, but not the best one.

In between lies an adjustable trade, and the last example shows exactly that: a factor of 1.5 saves two thirds of the work on this map and still returns the optimal way; from a factor of 3 on, the way found gets measurably more expensive.

Why it still looks at so many squares

On open grass all ways that differ only in the order of their steps cost the same. To the estimate they are all exactly equally good, and A* has no reason to prefer any of them — large plateaus of equivalent squares appear and all of them get looked at. That is why the saving on the example map is "only" about a third. The usual remedies: put a touch of weight on the estimate, or break ties with a tiny extra rule.

03Try it

04Implementation

MagicBook.Algorithms/Pathfinding/AStar.csC#
// A* is Dijkstra with a hunch. Dijkstra spreads out in all directions equally,
// because it knows nothing about where the goal lies. A* adds an estimate of what
// is still to come and sorts the queue by "spent so far plus guessed remainder".
// That pulls the search towards the goal instead of letting it grow in a circle.
//
// The estimate has to be careful in one respect: it may never be too high. As long
// as it stays below the true remaining cost, A* finds exactly the same cheapest way
// as Dijkstra - it only looks at far fewer places on the way there. An estimate that
// is always zero turns A* back into Dijkstra.
public static class AStar
{
    public static Route Search(IGraph graph, int start, int goal, Func<int, double> estimate)
    {
        var cheapest = new Dictionary<int, double> { [start] = 0 };
        var cameFrom = new Dictionary<int, int>();
        var settled = new HashSet<int>();
        var seen = new List<int>();

        // The queue is sorted by the guessed total, the dictionary keeps the real
        // price. Mixing those two up is the classic mistake with this algorithm.
        var queue = new PriorityQueue<int, double>();

        queue.Enqueue(start, estimate(start));

        while (queue.TryDequeue(out var node, out _))
        {
            if (!settled.Add(node))
                continue;

            seen.Add(node);

            var cost = cheapest[node];

            if (node == goal)
                return Route.Backwards(cameFrom, start, goal, cost, [.. seen]);

            foreach (var step in graph.Neighbours(node))
            {
                var price = cost + step.Cost;

                if (cheapest.TryGetValue(step.Node, out var known) && known <= price)
                    continue;

                cheapest[step.Node] = price;
                cameFrom[step.Node] = node;

                queue.Enqueue(step.Node, price + estimate(step.Node));
            }
        }

        return Route.Nothing([.. seen]);
    }
}

05The map

MagicBook.Console/Examples/Maps.csC#
// '#' is a wall, '.' is grass, ',' is sand and '~' is mud. S is where the way
// starts, G is where it should end.
public static readonly string[] Terrain =
[
    "############################################################",
    "#.....................................##...................#",
    "#...........##........................##...................#",
    "#...........##................######..##...................#",
    "#...........##................######..##...................#",
    "#...........##........................##...................#",
    "#...........##.,,,,,,,,...............##...................#",
    "#...........##.~~~~~~~~...............##...................#",
    "#...........##.~~~~~~~~.##............##...................#",
    "#...........##.~~~~~~~~.##............##...................#",
    "#S..........##.~~~~~~~~.##............##..................G#",
    "#...........##.~~~~~~~~.##............##...................#",
    "#...........##.~~~~~~~~.##...............,,,,,,,,,,........#",
    "#..............~~~~~~~~.##..,,,,,,,,,....~~~~~~~~~~........#",
    "#..............,,,,,,,,.##..~~~~~~~~~....~~~~~~~~~~##......#",
    "#.......................##..~~~~~~~~~....~~~~~~~~~~##......#",
    "#.......................##..~~~~~~~~~....~~~~~~~~~~........#",
    "#.......................##..~~~~~~~~~....~~~~~~~~~~........#",
    "#.......................##..~~~~~~~~~....~~~~~~~~~~........#",
    "#.......................##.................................#",
    "############################################################",
];

// What a step onto a square costs. Zero means there is no way through.
public static GridGraph Ground() => GridGraph.Parse(Terrain, square => square switch
{
    '#' => 0,
    ',' => 2,
    '~' => 5,
    _ => 1,
});

// Where the S and the G stand.
public static int Find(char square)
{
    for (var y = 0; y < Terrain.Length; y++)
    {
        var x = Terrain[y].IndexOf(square);

        if (x >= 0)
            return y * Terrain[0].Length + x;
    }

    throw new ArgumentException($"The map has no '{square}'.", nameof(square));
}

// The map again, with a way drawn into it.
public static string Draw(GridGraph map, int[] path)
{
    var drawn = Terrain.Select(row => row.ToCharArray()).ToArray();

    foreach (var node in path)
    {
        if (drawn[map.Y(node)][map.X(node)] is not ('S' or 'G'))
            drawn[map.Y(node)][map.X(node)] = 'o';
    }

    return string.Join(Environment.NewLine, drawn.Select(row => new string(row)));
}

06With and without a hunch

MagicBook.Console/Examples/AStarGridExample.csC#
var map = Maps.Ground();
var start = Maps.Find('S');
var goal = Maps.Find('G');

// Dijkstra knows nothing about where the goal lies and grows in a circle.
// A* is told to estimate what is left as the distance in steps, walls and
// mud ignored. That estimate is never too high, so both find the same way.
var blind = Dijkstra.Search(map, start, goal);
var guided = AStar.Search(map, start, goal, node => map.Manhattan(node, goal));

Console.WriteLine(Maps.Draw(map, guided.Path));
Console.WriteLine($"Dijkstra  cost {blind.Cost,3}  {blind.Visited,4} squares looked at");
Console.WriteLine($"A*        cost {guided.Cost,3}  {guided.Visited,4} squares looked at");
Console output
############################################################
#.....................................##...................#
#...........##........................##...................#
#...........##................######..##...................#
#...........##................######..##...................#
#...........##........................##...................#
#...........##.,,,,,,,,...............##...................#
#...........##.~~~~~~~~oooooo.........##...................#
#...........##.~~~~~~~~o##..o.........##...................#
#...........##.~~~~~~~~o##..ooooo.....##...................#
#Soooooooooo##.~~~~~~~~o##......oooooo##.....oooooooooooooG#
#..........o##.~~~~~~~~o##...........o##oooooo.............#
#..........o##.~~~~~~~~o##...........oooo,,,,,,,,,,........#
#..........oooo~~~~~~~~o##..,,,,,,,,,....~~~~~~~~~~........#
#.............o,,,,,,,,o##..~~~~~~~~~....~~~~~~~~~~##......#
#.............oooooooooo##..~~~~~~~~~....~~~~~~~~~~##......#
#.......................##..~~~~~~~~~....~~~~~~~~~~........#
#.......................##..~~~~~~~~~....~~~~~~~~~~........#
#.......................##..~~~~~~~~~....~~~~~~~~~~........#
#.......................##.................................#
############################################################
Dijkstra  cost  77   903 squares looked at
A*        cost  77   576 squares looked at

07When the estimate exaggerates

MagicBook.Console/Examples/AStarWeightExample.csC#
var map = Maps.Ground();
var start = Maps.Find('S');
var goal = Maps.Find('G');

// The estimate can be taken more or less seriously. Weight 0 ignores it
// completely - that is Dijkstra. Weight 1 is the honest estimate. Anything
// above that pulls harder towards the goal than is allowed, and the way
// found may no longer be the cheapest one.
Console.WriteLine("weight  cost  squares looked at");

foreach (var weight in new[] { 0, 1, 1.5, 3, 8 })
{
    var route = AStar.Search(map, start, goal, node => map.Manhattan(node, goal) * weight);

    Console.WriteLine($"{weight,6}  {route.Cost,4}  {route.Visited,17}");
}
Console output
weight  cost  squares looked at
     0    77                903
     1    77                576
   1.5    77                207
     3    83                119
     8   105                 95

08Good to know

  • The proof in one sentence: when A* takes the goal off the queue, every place still waiting has a g + h that is at least as large. Because h never overestimates the remainder, no cheaper way to the goal can lead through any of them.
  • Admissible is enough for the proof as long as settled places are never touched again; strictly one also wants consistency (the triangle inequality for h). Manhattan on a grid satisfies both.
  • **Where A* is no longer enough:** very large maps call for hierarchical methods (coarse grid first, then fine), many units heading for the same goal call for a flow field (run Dijkstra backwards from the goal once, then everyone walks downhill), and changing maps call for D* Lite, which recomputes only the affected part.
  • The way is not the end. What A* returns are grid squares — stair-stepped and useless for a character to walk. In games a smoothing pass almost always follows, throwing away intermediate points as long as the line of sight stays clear.
  • The map in the examples is the same one the demo runs on. Swamp costs five times as much as grass, sand twice as much — which is why the detour around the outside pays off even though it is far longer counted in steps.