Was ist der kürzeste Pfad von nach 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 nach .
An dieser Stelle benötigen wir ein paar formale Definition.
Wir haben einen gerichteten Graphen
und eine Kantenkostenfunktion .
Die Kosten eines Pfades sind
Für Knoten definieren wir
Insbesondere gilt , da selsbt als Pfad der Länge 0 gilt,
von nach führt und darüberhinaus
Gesamtkosten hat.
Um nun in einem Graphen mit Kantenkosten den billigsten Pfad
von nach 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 aus
eine Armee von Ameisen los, die mit konstanter Geschwindigkeit
den Graphen erkunden und für eine Kante von Kosten genau
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:
.
def Dijkstra (, , )
für alle
für alle
while:
for:
if:
füge der Menge hinzu
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:
für alle und
auch für .
für alle .
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
.
Da ein günstigster Pfad von nach ist,
gilt .
Der Teilpfad ist ein günstigster Pfad nach ,
also .
Der Teilpfad ist ein -Pfad, also gilt
nach Behauptung 3.
Da es keine negativen Kantenkosten gibt,
gilt und somit
.
, 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.