Magic Bookof Algorithms EN

WegfindungPTH-02

A*

Dijkstra mit einer Ahnung: derselbe Weg, ein Bruchteil der Arbeit.

Laufzeit
O((K + N) log N)
Zusatz
Schätzung des Restwegs
Bedingung
Schätzung nie zu hoch
Auf der Beispielkarte
576 statt 903 Felder

01Worum es geht

A ist Dijkstra mit einer Ahnung. Dijkstra breitet sich gleichmäßig in alle Richtungen aus, weil er nichts darüber weiß, wo das Ziel liegt — er würde einen Weg nach Norden genauso gründlich prüfen wie einen nach Süden, obwohl das Ziel im Süden steht. A bekommt eine Schätzung des Restwegs dazu und sortiert die Warteschlange nicht nach „bisher ausgegeben", sondern nach „bisher ausgegeben plus geschätzter Rest". Das zieht die Suche in Richtung Ziel.

Erfunden wurde das 1968 am Stanford Research Institute von Peter Hart, Nils Nilsson und Bertram Raphael, und zwar für Shakey — den ersten Roboter, der sich selbst überlegen musste, wie er durch einen Raum kommt. Seitdem ist A* das Standardverfahren für Wegfindung in Spielen.

Der ganze Unterschied zum Vorgänger sind zwei Zeilen Code. Das Ergebnis ist derselbe Weg bei einem Bruchteil der Arbeit.

Dieselbe Karte, dieselbe Strecke, dasselbe Ergebnis: A* lässt die rechte Hälfte fast unberührt. Erzeugt beim Bauen der Seite vom Code weiter unten.

02So funktioniert es

Die zwei Zahlen

Für jeden Ort gibt es zwei Größen, und sie sauber auseinanderzuhalten ist der ganze Trick:

  • g — was der Weg bis hierher wirklich gekostet hat. Steht im Wörterbuch.
  • h — was der Weg von hier bis zum Ziel vermutlich noch kostet. Kommt von der Schätzfunktion.

Die Warteschlange sortiert nach g + h, das Wörterbuch merkt sich g. Wer die beiden verwechselt und mit g + h weiterrechnet, addiert die Schätzung immer wieder auf und bekommt Unsinn heraus. Das ist der klassische Fehler bei diesem Algorithmus.

Die Bedingung an die Schätzung

Die Schätzung darf nie zu hoch sein. Wer den Restweg unterschätzt, findet garantiert weiterhin den günstigsten Weg — die Suche schaut nur weniger Felder an. Wer ihn überschätzt, kann in eine Sackgasse gelockt werden und einen teureren Weg zurückbekommen. Fachlich heißt eine Schätzung, die nie zu hoch liegt, zulässig (admissible).

Auf einem Raster mit Schritten nach oben, unten, links und rechts ist die Manhattan-Distanz die natürliche Wahl: die Anzahl der Schritte, wenn es weder Wände noch Sumpf gäbe. Weil ein Schritt nie weniger als 1 kostet, liegt sie nie zu hoch. Für Schritte in acht Richtungen nimmt man die Oktil-Distanz, für freie Bewegung die Luftlinie.

Zwei Randfälle sagen viel:

  • Schätzung immer 0 — dann ist g + h gleich g, und A* ist wieder Dijkstra.
  • Schätzung sehr groß — dann zählt nur noch die Schätzung, und die Suche rennt gierig auf das Ziel zu (Greedy Best-First). Sie ist schnell und findet oft einen ziemlich guten Weg, aber eben nicht den besten.

Dazwischen liegt ein einstellbarer Handel, und genau den zeigt das letzte Beispiel: Ein Faktor von 1,5 spart auf dieser Karte zwei Drittel der Arbeit und liefert trotzdem noch den optimalen Weg; ab Faktor 3 wird der gefundene Weg messbar teurer.

Warum es trotzdem viele Felder anschaut

Auf offener Wiese kosten alle Wege gleich viel, die sich nur in der Reihenfolge der Schritte unterscheiden. Für die Schätzung sind sie alle exakt gleich gut, und A* hat keinen Grund, einen davon zu bevorzugen — es entstehen große Plateaus gleichwertiger Felder, die alle angeschaut werden. Das ist der Grund, warum die Ersparnis auf der Beispielkarte „nur" bei einem Drittel liegt. Übliche Gegenmittel: einen Hauch Gewicht auf die Schätzung legen oder Gleichstände über eine winzige Zusatzregel brechen.

03Ausprobieren

04Implementierung

MagicBook.Algorithms/Pathfinding/AStar.csC#
// A* ist Dijkstra mit einer Ahnung. Dijkstra breitet sich gleichmäßig in alle Richtungen
// aus, weil er nicht weiß, wo das Ziel liegt. A* nimmt eine Schätzung des Restwegs dazu
// und sortiert die Warteschlange nach "bisher ausgegeben plus geschätzter Rest".
// Das zieht die Suche zum Ziel, statt sie im Kreis wachsen zu lassen.
//
// Die Schätzung muss in einem Punkt vorsichtig sein: Sie darf nie zu hoch liegen. Solange
// sie unter den echten Restkosten bleibt, findet A* genau denselben günstigsten Weg
// wie Dijkstra - es schaut unterwegs nur weit weniger Orte an. Eine Schätzung, die
// immer null ist, macht aus A* wieder 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>();

        // Die Warteschlange ist nach der geschätzten Summe sortiert, das Wörterbuch hält den
        // echten Preis. Beides zu verwechseln ist der klassische Fehler bei diesem Algorithmus.
        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]);
    }
}

05Die Karte

MagicBook.Console/Examples/Maps.csC#
// '#' ist Wand, '.' Wiese, ',' Sand und '~' Sumpf. Bei S beginnt der Weg,
// bei G soll er enden.
public static readonly string[] Terrain =
[
    "############################################################",
    "#.....................................##...................#",
    "#...........##........................##...................#",
    "#...........##................######..##...................#",
    "#...........##................######..##...................#",
    "#...........##........................##...................#",
    "#...........##.,,,,,,,,...............##...................#",
    "#...........##.~~~~~~~~...............##...................#",
    "#...........##.~~~~~~~~.##............##...................#",
    "#...........##.~~~~~~~~.##............##...................#",
    "#S..........##.~~~~~~~~.##............##..................G#",
    "#...........##.~~~~~~~~.##............##...................#",
    "#...........##.~~~~~~~~.##...............,,,,,,,,,,........#",
    "#..............~~~~~~~~.##..,,,,,,,,,....~~~~~~~~~~........#",
    "#..............,,,,,,,,.##..~~~~~~~~~....~~~~~~~~~~##......#",
    "#.......................##..~~~~~~~~~....~~~~~~~~~~##......#",
    "#.......................##..~~~~~~~~~....~~~~~~~~~~........#",
    "#.......................##..~~~~~~~~~....~~~~~~~~~~........#",
    "#.......................##..~~~~~~~~~....~~~~~~~~~~........#",
    "#.......................##.................................#",
    "############################################################",
];

// Was ein Schritt auf ein Feld kostet. Null heißt: kein Durchkommen.
public static GridGraph Ground() => GridGraph.Parse(Terrain, square => square switch
{
    '#' => 0,
    ',' => 2,
    '~' => 5,
    _ => 1,
});

// Wo das S und das G stehen.
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));
}

// Die Karte noch einmal, mit einem Weg hineingezeichnet.
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)));
}

06Mit und ohne Ahnung

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

// Dijkstra weiß nichts darüber, wo das Ziel liegt, und wächst im Kreis.
// A* bekommt als Schätzung des Rests die Anzahl der Schritte, ohne Wände und
// ohne Sumpf. Sie liegt nie zu hoch, deshalb finden beide denselben Weg.
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");
Ausgabe der Konsole
############################################################
#.....................................##...................#
#...........##........................##...................#
#...........##................######..##...................#
#...........##................######..##...................#
#...........##........................##...................#
#...........##.,,,,,,,,...............##...................#
#...........##.~~~~~~~~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

07Wenn die Schätzung übertreibt

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

// Die Schätzung kann man mehr oder weniger ernst nehmen. Gewicht 0 lässt sie ganz
// weg - das ist Dijkstra. Gewicht 1 ist die ehrliche Schätzung. Alles darüber zieht
// stärker zum Ziel, als erlaubt ist, und der gefundene Weg ist dann
// vielleicht nicht mehr der günstigste.
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}");
}
Ausgabe der Konsole
weight  cost  squares looked at
     0    77                903
     1    77                576
   1.5    77                207
     3    83                119
     8   105                 95

08Gut zu wissen

  • Der Beweis in einem Satz: Nimmt A* das Ziel aus der Warteschlange, dann hat jeder noch wartende Ort ein g + h, das mindestens so groß ist. Weil h den Rest nie überschätzt, kann über keinen dieser Orte ein billigerer Weg zum Ziel führen.
  • Zulässig reicht für den Beweis, wenn man abgehakte Orte nie wieder anfasst; sauber ist zusätzlich Konsistenz (die Dreiecksungleichung für h). Manhattan auf einem Raster erfüllt beides.
  • **Wo A* nicht mehr reicht:** Bei sehr großen Karten nimmt man hierarchische Verfahren (grobes Raster zuerst, dann fein), bei vielen Einheiten mit demselben Ziel ein Flow Field (einmal Dijkstra rückwärts vom Ziel, alle laufen dann bergab), und bei sich ändernden Karten D* Lite, das nur den betroffenen Teil neu rechnet.
  • Der Weg ist nicht das Ende. Was A* liefert, sind Rasterfelder — treppig und für eine Figur unbrauchbar. Danach kommt in Spielen fast immer ein Glättungsschritt, der überflüssige Zwischenpunkte wegwirft, solange die Sichtlinie frei bleibt.
  • Die Karte in den Beispielen ist dieselbe, auf der auch die Demo läuft. Sumpf kostet fünfmal so viel wie Wiese, Sand doppelt so viel — deshalb lohnt der Umweg außen herum, obwohl er in Schritten gezählt viel länger ist.