Magic Bookof Algorithms DE

PathfindingPTH-01

Dijkstra

The cheapest way, found without the faintest idea where the goal lies.

Running time
O((E + V) log V)
Data structure
priority queue
Requirement
no negative costs
Finds
always the cheapest way

01What it is about

Edsger Dijkstra thought this method up in 1956, in his head, in about twenty minutes, over a cup of coffee on a café terrace in Amsterdam. It answers a question that sounds harmless and is not: what is the cheapest way from here to there when every step may cost something different?

Cheap does not mean short. On a road map the cost is kilometres, in a network it is delay, in a game it is the effort of wading through swamp instead of walking over grass. The algorithm does not care what the costs mean — only that they are never negative.

The method is the foundation of just about everything that has to do with routes, and it is the direct ancestor of A*, which achieves the same while looking at far less of the map.

Dijkstra on a small map: the search grows evenly in every direction until it happens upon the goal. Produced while building this page by the code further down.

02How it works

The heart of it in one sentence

Of everything reached so far, always carry on with the cheapest place.

That sounds too simple to work, and the reason it does work is remarkably plain: when a place comes off the queue as the cheapest one, no other way can reach it more cheaply later. Any other way would have to pass through a place that is already more expensive — and costs never shrink along the way. So in that moment the place is settled for good.

This is also where the single condition of the method sits: no negative costs. If there were roads that gave kilometres back, the argument would fall apart, and a different method would be needed (Bellman-Ford).

The three ingredients

  1. The queue holds every place that has been reached but not settled, cheapest one at the front. The code uses PriorityQueue from the standard library for it — a binary heap inside, which adds and removes in logarithmic time.
  2. The cheapest known price per place. Only when a new way is cheaper than the known one is it worth remembering.
  3. Where each place was reached from. Every place remembers which neighbour it was reached from. At the end that chain is walked backwards from the goal and turned around — and that is the way.

About those stale entries

The standard library cannot lower the price of an entry that is already in the queue. Instead of hunting for it, the code simply puts the place in a second time with the better price. The cheaper entry comes out first, the stale one later — and is then skipped, because the place is already settled. That is the usual solution: it costs a little memory and saves a lot of code.

What comes back

Route holds the way, what it costs and every place that had to be looked at to find it. That last number is the interesting one: in the example below Dijkstra has to work through practically the whole map to get to Munich. That is exactly where A* comes in.

03Try it

04Implementation

MagicBook.Algorithms/Pathfinding/Dijkstra.csC#
// Edsger Dijkstra thought this up in 1956, in his head, in twenty minutes, over a
// cup of coffee. It answers a question that sounds harmless and is not: what is the
// cheapest way from here to there, when every step may cost something different?
//
// The idea is to be stubbornly cautious. Of everything that has been reached so far,
// always carry on with the cheapest place. Whatever is taken off the queue that way
// can never be reached more cheaply later on, because every other way starts out
// more expensive already - and costs never go down.
public static class Dijkstra
{
    public static Route Search(IGraph graph, int start, int goal)
    {
        // The cheapest price known so far for every place that has been reached.
        var cheapest = new Dictionary<int, double> { [start] = 0 };

        // Where each place was reached from. This is what the path is built from.
        var cameFrom = new Dictionary<int, int>();

        // Places that have been reached but not looked at yet, cheapest one first.
        var queue = new PriorityQueue<int, double>();

        // Places that are settled: their price cannot get any better. They are also
        // kept in the order they were settled, which is what the pictures show.
        var settled = new HashSet<int>();
        var seen = new List<int>();

        queue.Enqueue(start, 0);

        while (queue.TryDequeue(out var node, out var cost))
        {
            // The queue may still hold older, more expensive entries for this place.
            if (!settled.Add(node))
                continue;

            seen.Add(node);

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

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

                // Only worth remembering when this way is cheaper than the known one.
                if (cheapest.TryGetValue(step.Node, out var known) && known <= price)
                    continue;

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

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

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

05The map that is searched

MagicBook.Algorithms/Pathfinding/Graph.csC#
// One step away from a node: where it leads and what it costs to go there.
public readonly record struct Step(int Node, double Cost);

// Both searches ask a map only ever one question, so this is all a map has to
// answer: which nodes can be reached from here, and what does each of them cost?
public interface IGraph
{
    IEnumerable<Step> Neighbours(int node);
}

// What a search came back with: the way from start to goal, what that way costs,
// and every node it had to take off the queue on the way there, in that order.
// The last one is what makes the difference between the two searches visible.
public sealed record Route(int[] Path, double Cost, int[] Seen)
{
    public bool Found => Path.Length > 0;

    public int Visited => Seen.Length;

    public static Route Nothing(int[] seen) => new([], double.PositiveInfinity, seen);

    // Every node remembers where it was reached from. Walking that chain backwards
    // from the goal and turning it around gives the way there.
    public static Route Backwards(Dictionary<int, int> cameFrom, int start, int goal, double cost, int[] seen)
    {
        var path = new List<int> { goal };

        while (path[^1] != start)
            path.Add(cameFrom[path[^1]]);

        path.Reverse();

        return new Route([.. path], cost, seen);
    }
}

06From Cologne to Munich

MagicBook.Console/Examples/DijkstraRoadsExample.csC#
string[] places =
[
    "Köln", "Düsseldorf", "Dortmund", "Kassel", "Frankfurt", "Mannheim",
    "Karlsruhe", "Stuttgart", "Würzburg", "Nürnberg", "München", "Erfurt",
    "Leipzig", "Berlin", "Hannover",
];

var roads = new RoadMap(places.Length);

// Every road with its length in kilometres, roughly as it really is.
roads.Connect(0, 1, 40);    // Köln - Düsseldorf
roads.Connect(0, 2, 95);    // Köln - Dortmund
roads.Connect(0, 4, 190);   // Köln - Frankfurt
roads.Connect(1, 2, 70);    // Düsseldorf - Dortmund
roads.Connect(2, 3, 165);   // Dortmund - Kassel
roads.Connect(3, 4, 190);   // Kassel - Frankfurt
roads.Connect(3, 11, 155);  // Kassel - Erfurt
roads.Connect(4, 5, 85);    // Frankfurt - Mannheim
roads.Connect(4, 8, 120);   // Frankfurt - Würzburg
roads.Connect(5, 6, 80);    // Mannheim - Karlsruhe
roads.Connect(6, 7, 80);    // Karlsruhe - Stuttgart
roads.Connect(7, 8, 155);   // Stuttgart - Würzburg
roads.Connect(7, 10, 220);  // Stuttgart - München
roads.Connect(8, 9, 110);   // Würzburg - Nürnberg
roads.Connect(9, 10, 170);  // Nürnberg - München
roads.Connect(9, 11, 225);  // Nürnberg - Erfurt
roads.Connect(11, 12, 130); // Erfurt - Leipzig
roads.Connect(12, 13, 190); // Leipzig - Berlin
roads.Connect(2, 14, 210);  // Dortmund - Hannover
roads.Connect(3, 14, 165);  // Kassel - Hannover
roads.Connect(13, 14, 285); // Berlin - Hannover

var route = Dijkstra.Search(roads, start: 0, goal: 10);

Console.WriteLine($"from {places[route.Path[0]]} to {places[route.Path[^1]]}: {route.Cost} km");
Console.WriteLine(string.Join(" - ", route.Path.Select(place => places[place])));
Console.WriteLine($"{route.Visited} of {places.Length} places had to be looked at");

// The same map, a different goal. Dijkstra settles the places in the order
// of their distance from the start, so on the way to Berlin it has already
// worked out the cheapest way to every place that lies closer.
var north = Dijkstra.Search(roads, start: 0, goal: 13);

Console.WriteLine();
Console.WriteLine($"from {places[0]} to {places[13]}: {north.Cost} km");
Console.WriteLine(string.Join(" - ", north.Path.Select(place => places[place])));
Console output
from Köln to München: 590 km
Köln - Frankfurt - Würzburg - Nürnberg - München
15 of 15 places had to be looked at

from Köln to Berlin: 590 km
Köln - Dortmund - Hannover - Berlin

07Good to know

  • The order is the message. Dijkstra settles places in the order of their distance from the start. Whoever does not stop the search at the goal gets the cheapest way to every place — which is precisely what reachability maps need ("everything within 30 minutes").
  • Without costs it becomes breadth-first search. When every step costs the same, the queue degenerates into a plain queue and what is left is the classic breadth-first search.
  • Negative costs are out. For those there is Bellman-Ford (slower, but it tolerates negative edges) or Johnson for all pairs.
  • Nobody really drives Dijkstra. Route planners precompute motorways, sort nodes by importance (contraction hierarchies) and search from both ends at once. The core inside all of that is still this one.
  • Dijkstra on his own algorithm: he never wrote it down at the time, because there were no journals for programming yet; three years later it appeared on two and a half pages. That it came about without pencil and paper was, in his own view, the reason it turned out so simple.