7. Graphen mit Kantenkosten

7.1 Kürzeste Wege: Dijkstras Algorithmus

Hier sehen Sie einen Graphen mit Kantengewichten:

Was ist der kürzeste Pfad von s nach t in diesem Graphen? Wenn es Ihnen nur um die Zahl der Kanten geht, dann gibt es einen Pfad der Länge 3. Dieser hat aber Gesamtkosten von 12. In der Tat gibt es einen billigeren.

Übungsaufgabe 7.1.1 Finden Sie den günstigsten Pfad von s nach t.

An dieser Stelle benötigen wir ein paar formale Definition. Wir haben einen gerichteten Graphen G=(V,E) und eine Kantenkostenfunktion c:VR0+. Die Kosten eines Pfades p=u0,u1,,uk sind

c(p):=c(u0,u1)+c(u1,u2)++c(uk1,uk) .

Für Knoten u,vV definieren wir

dist(u,v):=min{c(p) | p ist ein Pfad von s nach t} .

Insbesondere gilt dist(u,u)=0, da u selsbt als Pfad der Länge 0 gilt, von u nach u führt und darüberhinaus Gesamtkosten 0 hat. Um nun in einem Graphen mit Kantenkosten den billigsten Pfad von s nach t zu finden, reicht Tiefensuche nicht mehr aus. Allerdings können wir uns von der Idee der Tiefensuche inspirieren lassen. Stellen wir uns vor, wir schicken von s aus eine Armee von Ameisen los, die mit konstanter Geschwindigkeit den Graphen erkunden und für eine Kante von Kosten c genau c Sekunden benötigen. Das Ergebnis ist als Dijkstras Algorithmus bekannt. Hier eine Animation.

Wir sehen: der Zeitpunkt zu welchem die Ameisen einen Knoten erreichen, ist gleich dem Abstand .

Dijkstras Algorithmus

Die Frage ist nun, wie wir diese Ameisenerkundung im Rechner simulieren. Hierfür merken wir uns für jeden Knoten eine estimated time of arrival, die erwartete Ankunftszeit der Ameisen, und kürzen diese mit ab. Diese repräsentiert im Prinzip den besten Schätzwert, den wir aufgrund des bisher erkundeten Teil des Graphen berechnet haben. Darüberhinaus merken wir uns die Menge der Knoten, die bereits von den Ameisen erreicht worden ist. Wir initialisieren und für jeden anderen Knoten; wir initialisieren .

Die folgende Animation ist fast die gleiche wie oben, nur dass wir nun rot markieren und Knoten außerhalb von , die bereits einen endlichen -Wert haben (also im Prinzip die Nachbarn von ), blau markieren. Die Werte stehen als rote oder blaue Zahlen neben den Knoten - außer wenn ist; dann lassen wir sie aus Gründen der Übersichtlichkeit einfach weg:

Die -Werte ändern sich also, weil wir im Verlauf des Prozesses bessere Pfade entdecken. Um diesen Ameisenprozess nun auf einem Rechner simulieren zu können (sprich: um einen Algorithmus implementieren zu können), machen wir folgende Beobachtung: der Knoten mit dem kleinsten -Wert ist der nächste, der von den Ameisen erreicht wird und somit der Menge hinzugefügt wird. In einer Implementierung müssen wir uns natürlich noch für jeden Knoten den Vorgängerknoten merken, über den die Ameisen ihn erreicht haben: .

  1. def Dijkstra (, , )
    1. für alle
    2. für alle
    3. while :
      1. for :
        1. if :
      2. füge der Menge hinzu
    4. return

Eine Kuriosität dieser Implementierung ist, dass schlussendlich jeder Knoten erreicht wird; selbst wenn es von zu ihm gar keinen Pfad gibt. Wenn nämlich alle von aus erreichbaren Knoten in gelandet sind, wird in Zeile 7 einfach in mit genommen. Alternativ können wir uns vorstellen, dass wir für jedes Paar eine Kante einfügen und setzen. Dies sind aber nur kosmetische Änderungen.

Korrektheit

Wenn Sie zufrieden damit sind, wie wir den Ameisenprozess im Rechner simulieren, dann können Sie diesen Abschnitt überspringen. Wenn Ihnen mathematische Korrektheitsbeweise gefallen, dann lesen Sie ihn. Es illustriert sehr schön die Methode der Schleifeninvariante. Auch werden wir im nächsten Teilkapitel - wo wir negative Kantenkosten zulassen - untersuchen, warum Dijkstra das nicht kann und woran der folgende Korrektheitsbeweis scheitert.

Theorem 7.1.1

Wenn Dijkstra terminiert, dann gilt für jeden Knoten . Darüberhinaus ist für genau jede definiert, für die gilt, und für gilt, dass ist und somit auf einem günstigsten Pfad von nach liegt.

Wir können also den günstigsten Pfad von nach finden, indem wir initialisieren und dann iterativ setzen, bis gilt.

Um Theorem 7.1 zu beweisen, müssen wir, wie so oft, eine stärkere Behauptung formulieren; nämlich eine Schleifenvariante finden, die zu jedem Zeiptunkt gilt und aus der dann die Behauptung des Theorems folgt. Zuerst führen wir etwas Terminologie ein: sei ein Pfad von zu einem Knoten und die Menge der Knoten drauf. Wir nennen den Pfad einen -Pfad, wenn gilt; wenn also bis auf möglicherweise den Endpunkt alle Knoten von in liegen.

Lemma 7.1.2 Vor Ausführung von Zeile 7 und nach Ausführung von Zeile 12 gilt:

  1. für alle und auch für .
  2. für alle .
  3. Für alle sind Kosten eines günstigsten -Pfades von nach .

Aus dem Lemma folgt nun, dass in Zeile 13, wenn geworden ist, nun also für alle Knoten gilt. Für den zweiten Teil des Theorems betrachten wir ein . Wenn ist, dann wurden Zeilen 10, 11 nie mit diesem ausgeführt und , nach wie vor. Wenn jedoch ist, dann betrachten wir das letzte Mal, dass Zeilen 10, 11 mit diesem ausgeführt worden sind (der Wert von also zum letzten Mal nach unten korrigiert worden ist). Nach Ausführen dieser Zeilen gilt ; die Werte ändern sich danach nicht mehr ( ändert sich nicht mehr, weil wir ja gerade das letzte Mal betrachten; ändert sich nicht mehr, weil wir in aufnehmen und nach dem Lemma fortan gilt und nicht noch kleiner werden kann). Es gilt daher, nachdem Zeilen 10 und 11 zum letzten Mal für dieses ausgeführt worden sind: und und somit gilt auch die zweite Behauptung des Theorems.

Beweis des Lemmas. Um nun das Lemma zu beweisen, überzeugen wir uns zuerst, dass die Behauptung des Lemmas vor dem allerersten Schleifendurchlauf gilt, also nach Zeile 5. Behauptung 1 gilt trivialerweise, da . Behauptung 2 gilt für alle , weil ist; für gilt sie auch, weil . Behauptung 3 gilt für , weil es gar keine -Pfade von nach gibt; für gilt Behauptung 3, weil der einzige -Pfad von nach ist.

Das Herz des Beweises liegt nun darin, einen konkreten Schleifendurchlauf anzusehen, dabei anzunehmen, dass die Behauptung zu Beginn (also vor Zeile 7) gilt, und zu zeigen, dass sie nach Durchlauf (also nach Zeile 12) weiterhin gilt. Daraus folgt dann per Induktion, dass sie für alle Schleifendurchläufe gilt und auch, wenn die while-Schleife terminiert. Betrachten wir also einen Schleifendurchgang und sei eben jener Knoten, der in Zeile 7 herangenommen wird.

Wir betrachten den Fall . Der andere Fall ist nicht interessant, weil es bedeutet, dass wir mittlerweile alle von aus erreichbaren Knoten auch erreicht haben. Wir wenden Behauptung 3 auf an und betrachten einen günstigsten -Pfad von nach .

Behauptung 7.1.3 Der Pfad ist ein günstigster Pfad von nach .

Beweis. Sei ein günstigster Pfad von nach (vielleicht ist ; vielleicht nicht) und der erste Knoten auf (von aus gezählt) mit (möglicherweise ist , möglicherweise nicht). Wir können also in den Pfad und zerlegen, und es gilt .

  1. Da ein günstigster Pfad von nach ist, gilt .
  2. Der Teilpfad ist ein günstigster Pfad nach , also .
  3. Der Teilpfad ist ein -Pfad, also gilt nach Behauptung 3.
  4. Da es keine negativen Kantenkosten gibt, gilt und somit .
  5. , sonst hätten wir ja in Zeile 7 nicht gewählt.

Aus diesen Punkten folgt nun

Da aber auch , muss in der Tat gelten. Wenn wir also der Menge hinzufügen, gilt Behauptung 1 nach wie vor: wie haben den optimal Pfad nach gefunden.

Zeigen wir nun noch, dass Behauptung 3 nach Ausführen der Zeilen 8 bis 11 immer nach gilt. Für einen Knotne sind nun möglicherweise günstigere -Pfade hinzugekommen, nämlich die mit als vorletztem Knoten (Übung: Zeigen Sie, dass entweder der vorletzte Knoten ist oder gar nicht auf liegt). Wenn es einen solchen günstigeren -Pfad gibt, dann hat er Kosten und wir passen in Zeile 10 entsprechend an. Somit gilt Behauptung 3 auch nach Zeile 12.

Implementierung

Die zentrale Frage ist, wie wir unseren "Kostenvoranschlag" implementieren.

Variante 1: als Array. Die Schleife while wird genau mal durchlaufen, da wir in jedem Schritt einen Knoten hinzufügen. Zeilen 10-11 durchlaufen wir für jede Kante , also insgesamt mal. Hinzukommen noch die Kosten von Zeile 7. Hier berechnen wir ein argmin, müssen also alle Knoten durchgehen und das mit dem kleinste finden. Dies benötigt noch einmal Schritte. Insgesamt erhalten wir eine Laufzeit von

Variante 2: als Suchbaum. Wir speichern in einer Datenstruktur, die uns schnellen Zugriff auf das kleinste Element ermöglicht. Beispielsweise könnten wir irgendeinen balancierten Baum (Rot-Schwarz-Baum, B-Baum, Treap) verwenden mit als Schlüssel und als Wert; wir müssten allerdings sicherstellen, dass die Schlüssel alle verschieden sind, also beispielsweise das Paar selbst als Schlüssel verwenden. Um in Zeile 7 das Argmin zu bestimmen, müssen wir nun einfach den linkesten Knoten im Baum finden. Um in Zeile 10 den Wert von zu reduzieren, können wir einfach den Knoten löschen und wieder einfügen. Alle diese Operationen brauchen Schritte im Worst-Case. Es ergibt sich eine Laufzeit von

Variante 3: als Heap. In der Tat braucht man hierfür gar keinen Baum. Wir können die Heap-Datenstruktur (ein einfaches Array), die wir von Heapsort in Kapitel 2.4 kennen, nehmen. Da steht das kleinste Schlüssel immer auf Position 0. Entfernen und Hinzufügen braucht . Es ergibt sich wieder eine Laufzeit von , allerdings mit einer einfacheren Implementierung und weniger Overhead als wenn wir einen richtigen Suchbaum verwenden.

Varianten 4...: als Fibonacci-Heap. Eine ausgefeilte Datenstruktur, die auf der Idee des Heaps aufbaut, ist der Fibonacci-Heap (Wikipedia). Dieser weist eine sehr gute amortisierte Laufzeit auf, d.h., manche Operationen können teuer sein, wenn man sie aber über die gesamte "Lebenszeit" der Datenstruktur mittelt, sind sie im Schnitt sehr billig. Insbesondere kann man das kleinste Element in amortisierter Zeit entfernen (wie bei einem normalen Heap); die Kosten eines gegebenen Elementes zu reduzieren - was wir ja in Zeile 7 bis zu mal tun, und was bei einem Heap kostet - braucht bei Fibonacci-Heaps amortisiert nur Schritte.