Algorithmen und Datenstrukturen Sven O. Krumke Entwurf vom 14. April 2004 Technische Universität Berlin ii Dieses Skript basiert auf der Vorlesung »Fortgeschrittene Datenstrukturen und Algorithmen« (Wintersemester 2002/2003) an der Technischen Universität Berlin. Über Kritik, Verbesserungsvorschläge oder gefundene Tippfehler würden ich mich sehr freuen! Sven O. Krumke [email protected] Inhaltsverzeichnis 1 Einleitung 1.1 Zielgruppe und Voraussetzungen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1.2 Danksagung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1.3 Literatur . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2 Haufenweise Haufen: Heaps, d-Heaps, Intervall-Heaps, Binomial-Heaps und Leftist-Heaps 2.1 Der Algorithmus von Dijkstra . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2.2 Binäre Heaps . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2.3 Erweiterungen von binären Heaps . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2.3.1 d-näre Heaps . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2.3.2 Intervall-Heaps . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2.3.3 Eine Anwendung: Das eindimensionale komplementäre Bereichsproblem . . . . . 2.4 Minimale aufspannende Bäume: Der Algorithmus von Boruvka (in Variation) . . . . . . . 2.5 Binomial-Heaps . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2.5.1 Binomialbäume und Binomial-Heaps . . . . . . . . . . . . . . . . . . . . . . . . 2.5.2 Implementierung von Binomial-Heaps . . . . . . . . . . . . . . . . . . . . . . . . 2.5.3 Implementierung der einfachsten Heap-Operationen . . . . . . . . . . . . . . . . 2.5.4 Rückführen von I NSERT und E XTRACT-M IN auf M ELD . . . . . . . . . . . . . . 2.5.5 Vereinigen zweier Binomial-Heaps . . . . . . . . . . . . . . . . . . . . . . . . . 2.5.6 Konstruieren eines Binomial-Heaps . . . . . . . . . . . . . . . . . . . . . . . . . 2.6 Leftist-Heaps . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2.6.1 Verzögertes Verschmelzen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2.6.2 Nochmals der Algorithmus von Boruvka . . . . . . . . . . . . . . . . . . . . . . 1 2 2 2 . . . . . . . . . . . . . . . . . 5 6 11 16 16 17 19 23 32 32 35 35 37 39 43 46 52 54 3 Amortisierte Analyse 3.1 Stack-Operationen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3.2 Konstruieren eines Binomial-Heaps . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3.3 Dynamische Verwaltung einer Tabelle . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 61 61 63 63 4 Fibonacci-Heaps 4.1 Der Algorithmus von Prim . . . . . . . . . . . . . . . . . . 4.2 Der Aufbau von Fibonacci-Heaps . . . . . . . . . . . . . . 4.3 Implementierung der Basis-Operationen . . . . . . . . . . . 4.4 Das Verringern von Schlüsselwerten . . . . . . . . . . . . . 4.5 Beschränkung des Grades in Fibonacci-Heaps . . . . . . . . 4.6 Ein Minimalbaum-Algorithmus mit nahezu linearer Laufzeit 67 67 71 72 76 78 79 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . iv 5 Datenstrukturen für disjunkte Mengen 5.1 Der Algorithmus von Kruskal . . . . . . . . . . . . . . . . . . . . . 5.2 Eine einfache Datenstruktur . . . . . . . . . . . . . . . . . . . . . . 5.3 Baumrepräsentation mit Pfadkompression und Vereinigung nach Rang 5.4 Analyse von Pfadkompression und Vereinigung nach Rang . . . . . . 5.4.1 Eine explosiv wachsende Funktion . . . . . . . . . . . . . . . 5.4.2 Amortisierte Analyse mit Potentialfunktionsargument . . . . 6 Suchbäume und Selbstorganisierende Datenstrukturen 6.1 Optimale statische Suchbäume . . . . . . . . . . . . . . . 6.2 Der Algorithmus von Huffman . . . . . . . . . . . . . . . 6.3 Schüttelbäume . . . . . . . . . . . . . . . . . . . . . . . . 6.3.1 Rückführen der Suchbaumoperationen auf S PLAY 6.3.2 Implementierung der S PLAY-Operation . . . . . . 6.3.3 Analyse der S PLAY-Operation . . . . . . . . . . . 6.3.4 Analyse der Suchbaumoperationen . . . . . . . . . 7 Schnelle Algorithmen für Maximale Netz-Flüsse 7.1 Notation und grundlegende Definitionen . . . . . . . . 7.2 Residualnetze und flußvergrößernde Wege . . . . . . . 7.3 Maximale Flüsse und Minimale Schnitte . . . . . . . . 7.4 Grundlegende Algorithmen . . . . . . . . . . . . . . . 7.5 Präfluß-Schub-Algorithmen . . . . . . . . . . . . . . . 7.5.1 Anzahl der Markenerhöhungen im Algorithmus 7.5.2 Anzahl der Flußschübe im Algorithmus . . . . 7.5.3 Zeitkomplexität des generischen Algorithmus . 7.5.4 Der FIFO-Präfluß-Schub-Algorithmus . . . . . 7.6 Dynamische Bäume und ihr Nutzen in Flußalgorithmen 7.6.1 Operationen auf dynamischen Bäumen . . . . 7.6.2 Einsatz im Präfluß-Schub-Algorithmus . . . . 7.6.3 Implementierung der dynamischen Bäume . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 83 83 86 89 93 93 95 . . . . . . . 101 103 105 110 111 112 113 119 . . . . . . . . . . . . . 123 123 124 125 129 133 139 140 141 144 145 145 146 152 A Abkürzungen und Symbole 159 B Komplexität von Algorithmen B.1 Größenordnung von Funktionen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . B.2 Berechnungsmodell . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . B.3 Komplexitätsklassen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 161 161 161 162 C Bemerkungen zum Dijkstra-Algorithmus 163 C.1 Ganzzahlige Längen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 163 C.2 Einheitslängen: Breitensuche . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 166 Literaturverzeichnis 169 Einleitung In der Kombinatorischen Optimierung treten beim Entwurf von Algorithmen viele „elementare Probleme“ auf. Mit Hilfe von geeigneten Algorithmen und Datenstrukturen für diese „elementaren Probleme“ läßt sich der gesamte Algorithmus oft (theoretisch und auch in der Praxis) deutlich beschleunigen. Dieses Skript bietet anhand von ausgewählten Themen einen Einblick in moderne Datenstrukturen und Algorithmen sowie ihre Analyse. Dabei werden die einzelnen Datenstrukturen nicht isoliert behandelt, sondern stets im Zusammenhang mit konkreten Fragestellungen aus der Kombinatorischen Optimierung vorgestellt. Kapitel 2 startet mit einer Einführung und kurzen Wiederholung. Ausgangsbasis ist der binäre Heaps, mit dem man bereits Prioritätsschlangen effizient implementieren kann und die keine Zeiger benötigen. Wir gehen dann auf Erweiterungen von binären Heaps ein, zeigen etwa, wie man mit Intervall-Heaps zweiendige Prioritätsschlangen verwalten kann. Die Tatsache, daß binäre Heaps das Vereinigen von zwei Schlangen nicht effizient ermöglichen, führt uns zu den Binomial-Heaps und den Leftist-Heaps, zwei ausgeklügelten Datenstrukturen. In Kapitel 3 stellen wir die amortisierte Analyse von Algorithmen vor. Diese ist eines der grundlegenden Hilfsmittel für die Analyse der Algorithmen und Datenstrukturen in diesem Skript. Informell gesprochen wird bei der amortisierten Analyse wird die Laufzeit eines Algorithmus für eine Folge von Operationen nicht separat für jede einzelne Operation, sondern für die gesamte Folge analysiert. Mit Hilfe der amortisierten Analyse und Potentialfunktionen sind oft verbesserte und aussagekräftigere Laufzeitabschätzungen für Algorithmen möglich. Kapitel 4 beschäftigt sich mit den sogenannten Fibonacci-Heaps. Diese Datenstruktur ermöglicht eine effiziente Verwaltung von Prioritätsschlangen, wie sie etwa bei kürzesteWege-Algorithmen (Dijkstra) und Algorithmen für Minimale Aufspannende Bäume (Prim) benötigt werden. Mit Hilfe von Fibonacci-Heaps lassen sich die Algorithmen von Dijkstra und Prim so implementieren, daß sie in O(m + n log n) Zeit auf Graphen mit n Ecken und m Kanten laufen. Als weitere Anwendung von Fibonacci-Heaps stellen wir einen trickreichen Minimalbaum-Algorithmus vor, der eine Laufzeit von O(n + mβ(m, n)) besitzt, wobei β(m, n) = min{ i : log(i) n ≤ m/n } eine extrem langsam wachsende Funktion ist. Datenstrukturen für disjunkte Mengen oder Union-Find-Strukturen, wie sie Kapitel 5 besprochen werden, kommen dann ins Spiel, wenn man effizient Partitionen einer Menge verwalten möchte, beispielsweise die Zusammenhangskomponenten eines Graphen im Algorithmus von Kruskal. Wir werden als Anwendung zeigen, daß geeignete Union-FindStrukturen es ermöglichen, Kruskals Algorithmus in Zeit O(m log m + mα(n)) laufen zu lassen. Hier bezeichnet α(n) (im wesentlichen) die inverse Ackermann-Funktion, die für alle Zahlen, die kleiner als die Anzahl der Atome im Universum sind, nach oben durch fünf beschränkt ist. 2 Einleitung In Kapitel 6 beschäftigen wir uns mit Suchbäumen und selbstorganisierenden Datenstrukturen. Zunächst wiederholen wir kurz einige Fakten über optimale statische Suchbäume. Wir zeigen, wo solche Suchbäume unter anderem bei der Datenkompression eingesetzt werden. Wir stellen dann die Datenstruktur der Schüttelbäume (engl. Splay-Trees) vor, die asymptotisch genauso gut sind wie optimale statische Suchbäume, dies allerdings ohne Informationen über die Verteilungen von Suchanfragen zu besitzen. Netzwerkflußprobleme sind wichtige Bausteine in der Kombinatorischen Optimierung. In Kapitel 7 stellen wir einige fortgeschrittene Techniken und Datenstrukturen vor, um Netzwerkflußprobleme effizient zu lösen. Nach einer kurzen Wiederholung der Grundbegriffe (Max-Flow-Min-Cut-Theorem, klassische Algorithmen) stellen wir Algorithmen für das Maximum-Flow-Problem vor, die schneller sind als die klassischen Algorithmen, die mit flußvergrößernden Pfaden arbeiten. Wir zeigen dann, wie man mit Hilfe von geeigneten Datenstrukturen deutliche Beschleunigungen erreichen kann. Anhang C beschäftigt sich noch einmal kurz mit dem Algorithmus von Dijkstra zur Bestimmung kürzester Wege. Wir zeigen hier kurz, daß für ganzzahlige Längen auf den Kanten unter bestimmten Voraussetzungen noch Laufzeitverbesserungen gegenüber den in vorhergehenden Kapiteln vorgestellten Implementierungen möglich sind. Ein Spezialfall ergibt sich im Fall von ungewichteten Graphen. Die Breitensuche (engl. Breadth-First-Search) ist ein nützliches Hilfsmittel, um in ungewichten Graphen in linearer Zeit kürzeste Wege zu berechnen. Sie wird vor allem in Kapitel 7 benötigt und ist daher in Abschnitt C.2 der Vollständigkeit halber aufgeführt und analysiert. 1.1 Zielgruppe und Voraussetzungen Das Skript und die zugrundeliegende Vorlesung richten sich an Studenten der Mathematik und Informatik im Grund- und Hauptstudium. Grundkenntnisse der Kombinatorischen Optimierung (Graphen, Netzwerke) sowie über elementare Datenstrukturen und Algorithmen (Sortieren, Suchen) sind hilfreich, aber nicht zwingend erforderlich. Das Material ist als Ergänzung zur Standardvorlesung »Algorithmen und Datenstrukturen« oder »Algorithmische Diskrete Mathematik« gedacht. Daher werden einige Themen, nur kurz oder gar nicht angesprochen. Im Anhang dieses Skripts sind ein paar Grundlagen (O-Notation, Berechnungsmodell, etc.) erklärt. Eine hervorrangende Einführung bietet hier das Buch [3]. 1.2 Danksagung Ich möchte mich bei allen Teilnehmern der Vorlesung für Ihre Kommentare und Fragen zum Stoff bedanken. Besonderer Dank gilt Diana Poensgen und Adrian Zymolka für zahlreiche konstruktive Verbesserungsvorschläge und das nimmermüde Finden von Tippfehlern im Skript. 1.3 Literatur Bücher zum Thema sind: 1.3 Literatur Ref. Nr. 3 Buch Preis [3] T. Cormen, C. Leiserson, R. L. Rivest, C. Stein. Introduction to Algorithms. 76,– EUR [1] R. K. Ahuja, T. L. Magnanti, J. B. Orlin. Network Flows. 78,– EUR [4] A. Fiat and G. J. Woeginger (eds.). Online Algorithms: The State of the Art. 35,– EUR 4 Haufenweise Haufen: Heaps, d-Heaps, Intervall-Heaps, Binomial-Heaps und Leftist-Heaps Heaps (deutsch: Haufen) sind Datenstrukturen, um effizient sogenannte Prioritätsschlangen zu verwalten. Prioritätsschlangen stellen folgende Operationen zur Verfügung: M AKE () erstellt eine leere Prioritätsschlange. I NSERT (Q, x) fügt das Element x ein, dessen Schlüssel key[x] bereits korrekt gesetzt ist. M INIMUM(Q) liefert einen Zeiger auf das Element in der Schlange, das minimalem Schlüsselwert besitzt. E XTRACT-M IN (Q) löscht das Element mit minimalem Schlüsselwert aus der Schlange und liefert einen Zeiger auf das gelöschte Element. D ECREASE -K EY (Q, x, k) weist dem Element x in der Schlange den neuen Schlüsselwert k zu. Dabei wird vorausgesetzt, daß k nicht größer als der aktuelle Schlüsselwert von x ist. Prioritätsschlangen spielen bei vielen Algorithmen eine wichtige Rolle, etwa beim Dijkstra-Algorithmus zur Berechnung kürzester Wege in einem gewichteten Graphen. Die Abschnitte 2.1 und 2.2 sind vorwiegend als Einführung und Wiederholung gedacht. Anhand des Algorithmus von Dijkstra zeigen wir in Abschnitt 2.1 auf, wo Prioritätsschlangen effektiv eingesetzt werden. Wir stellen in Abschnitt 2.2 die einfachste HeapDatenstruktur, den binären Heap, vor. Dieser Heap kommt ohne Zeiger aus und ist für viele Anwendungen bereits hervorragend geeignet. Allerdings besitzt der binäre Heap ein paar Defizite. In Abschnitt 2.3 stellen wir Erweiterungen des binären Heaps vor. Abschnitt 2.5 führt dann die erste kompliziertere Heap-Datenstruktur vor, den Binomial-Heap. Der Binomial-Heap bietet alle Operationen des binären Heaps mit der gleichen Zeitkomplexität, stellt aber zusätzlich noch das Vereinigen von Heaps in logarithmischer Zeit zur Verfügung. In Abschnitt 2.6 beschäftigen wir uns mit den sogenannten Leftist-Heaps. Diese Heaps sind sehr einfach implementierbar und ermöglichen alle Operationen mit der gleichen Zeitkomplexität wie die Binomial-Heaps bis auf D ECREASE -K EY. 6 Haufenweise Haufen: Heaps, d-Heaps, Intervall-Heaps, Binomial-Heaps und Leftist-Heaps 2.1 Der Algorithmus von Dijkstra Sei G = (V, E) ein endlicher ungerichteter Graph ohne Parallelen1 und c : E → R≥0 eine Gewichtsfunktion. Wir bezeichnen wie üblich mit n := |V | die Anzahl der Ecken und mit m := |E| die Anzahl der Kanten von G. Wir nehmen dabei an, daß G = (V, E) in Adjazenzlistendarstellung gegeben ist. Zur Erinnerung: Die Adjazenzlistendarstellung von G besteht aus den Zahlen n und m, sowie einem Array Adj von n Listen, für jede Ecke eine. Die Liste Adj[u] enthält (Pointer auf) alle Ecken v mit (u, v) ∈ E und zusätzlich das Gewicht der entsprechenden Kante (u, v). Da G ungerichtet ist, erscheint jede ungerichtete Kante (u, v) zweimal, einmal via v ∈ Adj[u] und einmal via u ∈ Adj[v]). Abbildung 2.1 zeigt ein Beispiel für die Adjazenzlistenspeicherung eines Graphen. Sei δc (u, v) die Länge eines kürzesten Weges von u nach v in G bezüglich der Kantengewichtsfunktion c. Oft schreiben wir auch nur δ(u, v), wenn die Gewichtsfunktion c klar ist. Der Algorithmus von Dijkstra ist ein bekannter Algorithmus zur Bestimmung von kürzesten Wegen. Er ist in Algorithmus 2.1 im Pseudocode angegeben. Abbildung 2.2 zeigt ein Beispiel für die Ausführung des Dijkstra-Algorithmus. Wir zeigen nun die Korrektheit des Dijkstra-Algorithmus. Lemma 2.1 Für alle v ∈ V gilt nach der Initialisierung bis zum Abbruch des Algorithmus d[v] ≥ δc (s, v). Beweis: Der Beweis folgt durch einfache Induktion nach der Anzahl der Relaxierungen R ELAX (nur durch eine Relaxierung kann d[v] überhaupt sinken). Nach null Relaxierungen ist die Aussage trivial. Angenommen, die Behauptung gelte bis nach der iten Relaxierung. In der (i + 1)ten Relaxierung R ELAX(u, v) wird höchstens d[v] verändert. Wenn d[v] unverändert bleibt, so ist nichts zu zeigen, ansonsten sinkt d[v] auf d[u] + c(u, v). Nach Induktionsvoraussetzung gilt d[u] + c(u, v) ≥ δc (s, u) + c(u, v) ≥ δc (s, v). Dies zeigt den Induktionsschritt. 2 Satz 2.2 Beim Abbruch des Algorithmus von Dijkstra gilt d[v] = δ c (v) für alle v ∈ V . Weitherin ist für jedes v ∈ V mit d[v] < +∞ der Knoten p[v] Vorgänger von v auf einem kürzesten Weg von s nach v . Beweis: Wir nennen im Folgenden einen Weg w = (v1 , . . . , vp+1 ) einen Grenzweg, falls v1 , . . . , vp ∈ S und vp+1 ∈ V \S sind. Sei Si die Menge S nach der iten Iteration der while Schleife, es gilt also |Si | = i. Wir zeigen durch Induktion nach i, daß folgende Invarianten gelten: (i) Für alle u ∈ Si gilt d[u] = δ(s, u), und es existiert ein Weg von s nach u der Länge d[u], der nur Knoten aus Si durchläuft. (ii) Für alle u ∈ V \ Si gilt d[u] = min{ c(w) : w ist Grenzweg mit Startknoten s und Zielknoten u } bzw. d[u] = ∞, falls kein solcher Grenzweg existiert. (Induktionsanfang): i = 1 1 Parallelen spielen bei der Berechnung kürzester Wege keine Rolle. Wir können jeweils die kürzeste der Parallelen im Graphen behalten und alle anderen vorab eliminieren. 2.1 Der Algorithmus von Dijkstra 7 1 2 4 v Adj[v] 1 2 7 3 4 2 1 7 4 1 3 1 4 5 5 4 2 1 5 2 5 2 1 3 5 6 NULL 5 1 7 1 2 1 4 6 5 3 4 2 5 (a) Bei der Speicherung eines ungerichteten Graphen taucht jede Kante (u, v) zweimal auf, je einmal in der Adjazenzliste der beiden Endknoten. v Adj[v] 1 2 7 3 4 2 4 1 5 1 3 5 5 4 5 2 5 NULL 6 NULL 1 2 4 7 1 2 1 4 5 3 6 5 (b) Bei der Speicherung eines gerichteten Graphen wird jede Kante genau einmal abgespeichert. Abbildung 2.1: Adjazenzlistenspeicherung von Graphen. Die grau hinterlegten Einträge in den Listenelementen sind die Knotennummern, die anderen Einträge bezeichnen die Kantengewichte. 8 Haufenweise Haufen: Heaps, d-Heaps, Intervall-Heaps, Binomial-Heaps und Leftist-Heaps Algorithmus 2.1 Algorithmus von Dijkstra D IJKSTRA -S HORTEST-PATH(G, c, s) Input: Ein ungerichteter Graph G = (V, E) in Adjazenzlistendarstellung; eine nichtnegative Gewichtsfunktion c : E → R≥0 und ein Knoten s ∈ V . Output: Für jeden Knoten v ∈ V die Länge d[v] eines kürzesten Weges von s nach v; zusätzlich noch einen Zeiger p[v] auf den Vorgänger von v im kürzesten Weg von s nach v. 1 for all v ∈ V do 2 d[v] ← +∞ { Bisher wurde noch kein Weg gefunden. } 3 p[v] ← NULL 4 end for 5 d[s] ← 0 6 Q ← M AKE () { Erzeuge eine leere Prioritätschlange Q. } 7 I NSERT (Q, s) { Füge s mit Schlüssel d[s] = 0 in die Prioritätsschlange Q ein. } 8 S ←∅ { S enthält die Knoten u mit d[u] = δ(s, u). } 9 while Q 6= ∅ do 10 u ← E XTRACT-M IN (Q) 11 S ← S ∪ {u} 12 for all v ∈ Adj[u] do 13 R ELAX(u, v) R ELAX(u, v) prüft, ob über den Knoten u und die Kan- te (u, v) ein kürzerer Weg von s nach v gefunden werden kann als der bereits bekannte Weg von s nach v (sofern ein solcher existiert). 14 end for 15 end while R ELAX(u, v) 1 if d[v] = +∞ then { Es war noch kein Weg von s nach v bekannt. } 2 d[v] ← d[u] + c(u, v) 3 p[v] ← u 4 I NSERT(Q, v) 5 else 6 if d[v] > d[u] + c(u, v) then { Der bekannte Weg war länger als d[u] + c(u, v). } 7 d[v] ← d[u] + c(u, v) 8 D ECREASE -K EY(Q, v, d[u] + c(u, v)) { Vermindere den Schlüssel d[v] von v in Q auf d[u] + c(u, v). } 9 p[v] ← u 10 end if 11 end if 2.1 Der Algorithmus von Dijkstra +∞ 1 2 9 +∞ 7 4 1 +∞ 2 7 4 7 0 1 2 1 4 +∞ 0 6 1 +∞ 5 5 +∞ +∞ 2 3 5 4 +∞ (b) Der Knoten 1 wird als Minimum aus der Prioritätsschlange entfernt. Für alle Nachfolger werden die Distanzmarken d mittels R ELAX korrigiert. (a) Initialisierung, der Startknoten ist der Knoten 1. 1 6 5 3 7 2 1 4 +∞ 7 4 8 1 2 7 4 7 0 1 2 1 4 +∞ 0 6 1 +∞ 5 5 4 9 (c) Der Knoten 3 wird als Minimum aus der Prioritätsschlange entfernt. 1 2 6 5 3 7 2 1 4 3 5 4 8 (d) Der Knoten 2 wird als Minimum aus der Prioritätsschlange entfernt. Dabei wird unter anderem die Distanzmarke von Knoten 5 von 9 auf 8 verringert. 8 7 4 2 7 1 8 4 7 0 1 2 1 4 +∞ 0 6 1 +∞ 2 1 4 5 6 5 3 5 4 8 (e) Der Knoten 4 wird als Minimum aus der Prioritätsschlange entfernt. 3 5 4 8 (f) Der Knoten 5 wird als Minimum aus der Prioritätsschlange entfernt. Danach terminiert der Algorithmus, da die Prioritätsschlange leer ist. Abbildung 2.2: Arbeitsweise des Dijkstra-Algorithmus auf einem ungerichteten Graphen. Die Zahlen an den Knoten bezeichnen die Distanzmarken d, die vom Algorithmus vergeben werden. Die schwarz gefärbten Knoten sind diejenigen Knoten, die in die Menge S aufgenommen wurden. Der weiße Knoten ist der Knoten, der gerade als Minimum aus der Prioritätsschlange entfernt wurde. 10 Haufenweise Haufen: Heaps, d-Heaps, Intervall-Heaps, Binomial-Heaps und Leftist-Heaps Mit d[s] = 0 folgt S1 = {s} und (i) gilt offensichtlich. Nach der for-Schleife ab Zeile 12 gilt für alle u ∈ V \ S1 , daß d[u] = min{ c(s, u) : (s, u) ∈ E } bzw. d[u] = ∞, falls es kein (s, u) ∈ Adj[s]. Da Adj[s] alle Grenzwege enthält, folgt Aussage (ii). (Induktionsvoraussetzung): Es gelten die Invarianten (i) und (ii) für ein i. (Induktionsschritt): i → i + 1 Sei Si+1 = Si ∪ {v}. Nach Konstruktion des Algorithmus gilt dann d[v] < +∞. Nach Lemma 2.1 gilt d[v] ≥ δc (s, v). Wäre d[v] > δc (s, v), so existiert ein Weg w von s nach v mit c(w) < d[v]. Nach der Induktionsvoraussetzung (ii) hat für Si der minimale Grenzweg von s nach v die Länge d[v]. Somit ist w kein Grenzweg für Si . Sei u der erste Knoten von w, der nicht in Si liegt, und sei w 0 der Teilweg von w mit w 0 = (s, . . . , u). Dann ist w 0 ein Grenzweg von s nach v, und mit Induktionsvoraussetzung (ii) folgt c(w 0 ) ≥ d[u]. Da im (i+1)ten Schritt v das minimale Heap-Element war, gilt d[u] ≥ d[v]. Da c eine nichtnegative Gewichtsfunktion ist, folgt c(w) ≥ c(w 0 ) ≥ d[v] im Widerspruch zur Annahme, daß c(w) < d[v]. Somit folgt d[v] = δc (s, v). Nach Induktionsvoraussetzung, Teil (ii) existiert zudem ein Grenzweg von s nach v der Länge d[v], der damit gleichzeitig der kürzeste Weg von s nach v ist. Dies zeigt (i). Es verbleibt zu zeigen, daß (ii) gilt. Sei dazu u ∈ V \ Si+1 . Wir bezeichnen mit d[u] und d[v] die Werte d bei Entfernen von v aus dem Heap, aber vor den R ELAX-Aufrufen. Für die Länge c(w) eines kürzesten Si+1 -Grenzweges w von s nach u gilt: c(w) = min min{ c(w0 ) : w0 ist Si -Grenzweg von s nach u }, δ(s, v) + min{ c(v, u) : (v, u) ∈ E } (2.1) (2.2) Nach Induktionsvoraussetzung ist der Term in (2.1) gleich d[u] und der Term in (2.2) entspricht d[v] + min{ c(v, u) : (v, u) ∈ E } Somit gilt: c(w) = min d[u], d[v] + min{ c(v, u) : (v, u) ∈ E } (2.3) Nach den D ECREASE -K EY-Operationen, die auf das Entfernen von v aus Q folgen, wird der neue Wert d[u] aber genau auf den Wert aus (2.3) gesetzt. Dies zeigt (ii). 2 Wir analysieren nun die Laufzeit des Dijkstra-Algorithmus. Diese kann wie folgt abgeschätzt werden: Jeder Knoten wird maximal einmal in die Prioritätsschlange Q eingefügt, jeder Knoten wird maximal einmal aus Q entfernt. Weiterhin gibt es maximal 2m := 2|E| Operationen, welche Schlüsselwerte verringern. Wir erhalten somit: Satz 2.3 Die Laufzeit des Dijkstra-Algorithmus liegt in O(n + n · TINSERT (n) + n · TEXTRACT-M IN (n) + m · TDECREASE -K EY (n)). Hierbei bezeichnen TINSERT (n), TEXTRACT-MIN (n) und TDECREASE -K EY (n) die Zeitkomplexitäten zum Einfügen, Entfernen des Minimums und zum Verringern des Schlüssels in einer 2 Prioritätsschlange mit n Elementen. 2.2 Binäre Heaps 11 Um den Dijkstra-Algorithmus möglichst schnell zu machen, müssen wir die Prioritätsschlange möglichst effizient implementieren. Eine Möglichkeit, die Prioritätsschlange Q zu verwalten, ist, einfach das unsortierte Array d zu benutzen (dies wurde übrigens in der ursprünglichen Arbeit von Dijkstra so vorgeschlagen). Der Eintrag d[v] ist dann einfach an der Stelle v im Array gespeichert. I NSERT(Q, v) und D ECREASE -K EY(Q, v, k) sind dann trivial zu implementieren: wir ändern einfach den entsprechenden Eintrag im Array. Dies ist in O(1) Zeit möglich. Bei E XTRACT-M IN müssen wir das ganze Array durchlaufen, um das Minimum zu bestimmen. Das kostet uns Θ(n) Zeit. Wenn man diese Zeiten für die Prioritätsschlangen-Operationen einsetzt, erhält man folgendes Ergebnis: Beobachtung 2.4 Mit Hilfe eines Arrays als Datenstruktur für die Prioritätsschlange benötigt der Dijkstra-Algorithmus O(m + n2 ) Zeit auf einem Graphen mit n Ecken und m Kanten. Mit Hilfe von ausgeklügelten Datenstrukturen werden wir die oben angegebene Zeitschranke im Folgenden deutlich verbessern. 2.2 Binäre Heaps Ein binärer Heap ist ein Array A, welches man als „fast vollständigen“ binären Baum mit besonderen Eigenschaften auffassen kann. Ein Array, welches einen binären Heap repräsentiert, hat folgende Attribute: • length[A] bezeichnet die Größe des Arrays; • size[A] speichert die Anzahl der im Heap abgelegten Elemente. Für einen Heap-Knoten 1 ≤ i ≤ size[A] ist parent(i) := bi/2c der Vater von i im Heap. Umgekehrt sind für einen Knoten j dann left(i) := 2i und right(i) := 2i + 1 der linke und der rechte Sohn2 im Heap (sofern diese existieren). Abbildung 2.3 zeigt einen Heap und seine Visualisierung als Baum. 2 2 1 2 3 5 4 9 6 5 8 11 5 20 8 9 11 20 Abbildung 2.3: Ein Heap als Array und seine Visualisierung als Baum. Die entscheidende Heap-Eigenschaft ist, daß für alle 1 ≤ i ≤ size[A] gilt: A[i] ≥ A[parent(i)]. (2.4) Folglich steht in der Wurzel des Baumes bzw. in A[1] das kleinste Element. Einen Heap mit der Eigenschaft (2.4) nennt man auch minimum-geordnet. Analog dazu kann man natürlich auch maximum-geordnete Heaps betrachten, bei denen das Ungleichheitszeichen in (2.4) umgekehrt ist. Hier steht dann das größte Element in der Wurzel. Man sieht leicht, daß der Baum, den ein binärer Heap mit size[A] = n repräsentiert, eine Höhe von blog2 nc = O(log n) besitzt: auf Höhe h, h = 0, 1, . . . befinden sich maximal 2 In diesem Skript verwenden wir aus historischen Gründen die Begriffe »Sohn« und »Vater« für Knoten in Bäumen. Natürlich könnten wir genausogut »Tochter« und »Mutter« verwenden. 12 Haufenweise Haufen: Heaps, d-Heaps, Intervall-Heaps, Binomial-Heaps und Leftist-Heaps Operation M AKE I NSERT M INIMUM E XTRACT-M IN D ECREASE -K EY B UILD binärer Heap O(1) O(log n) O(1) O(log n) O(log n) O(n) d-närer Heap O(1) O(logd n) O(1) O(d · logd n) O(logd n) O(n) Tabelle 2.1: Zeitkomplexität der Prioritätsschlangen-Operationen bei Implementierung durch einen binären Heap der Größe n und einen d-nären Heap der Größe n. 2h Knoten, und, bevor ein Knoten auf Höhe h existiert, müssen alle Höhen h 0 < h bereits voll sein. Die Prioritätsschlangen-Operationen lassen sich sehr einfach im binären Heap implementieren. Das Erstellen eines leeren binären Heaps (siehe Algorithmus 2.2) und das Liefern des Minimums (siehe Algorithmus 2.3) sind nahezu trivial und benötigen nur konstante Zeit. Das Einfügen eines neuen Elements x in den Heap funktioniert wie folgt. Angenommen, der aktuelle Heap habe n Elemente. Wir fügen das Element an die Position n + 1 an. Im Baum bedeutet dies, daß x Sohn des Knotens b(n + 1)/2c wird. Jetzt lassen wir x durch sukzessives Vertauschen mit seinem Vaterknoten soweit im Baum »hochsteigen«, bis die Heap-Eigenschaft wiederhergestellt ist. Der Code für das Einfügen ist in Algorithmus 2.4 beschrieben, Abbildung 2.4 zeigt ein Beispiel. Da ein binärer Heap für n Elemente die Höhe O(log n) besitzt, benötigen wir zum Einfügen O(log n) Zeit. Beim Extrahieren des Minimums (siehe Algorithmus 2.5) ersetzen wir A[1] durch das letzte Element y des Heaps. Nun lassen wir y im Heap durch Vertauschen mit dem kleineren seiner Söhne soweit im Heap »absinken«, bis die Heap-Eigenschaft wieder erfüllt ist. Abbildung 2.5 zeigt ein Beispiel. Das Extrahieren des Minimums benötigt ebenfalls nur logarithmische Zeit, da wir pro Ebene des Baumes nur konstanten Aufwand investieren und der Baum logarithmische Höhe besitzt. Das Verringern des Schlüsselwerts eines Elements an Position j läuft analog zum Einfügen ab und ist in Algorithmus 2.6 dargestellt. Nach Verringern des Schlüsselwerts lassen wir das Element im Heap durch sukzessives Vertauschen mit dem Vaterknoten aufsteigen, bis die Heap-Ordnung wieder hergestellt ist. Auch hier erhält man eine logarithmische Zeitkomplexität. Tabelle 2.1 fasst die Zeitkomplexitäten für die Operationen im binären Heap zusammen. Algorithmus 2.2 Erstellen eines leeren binären Heaps M AKE() 1 size[A] ← 0 Algorithmus 2.3 Minimum eines binären Heaps M INIMUM(A) 1 return A[1] Bei Implementierung des Dijkstra-Algorithmus mit Hilfe eines binären Heaps ergibt sich aus Satz 2.3 und den Komplexitäten in Tabelle 2.1 eine Laufzeit von O(n + n · log n + m · log n) = O((n + m) log n): 2.2 Binäre Heaps 13 Algorithmus 2.4 Einfügen eines neuen Elements in einen binären Heap I NSERT(A, x) 1 if size[A] = length[A] then 2 return „Der Heap ist voll“ 3 else 4 size[A] = size[A] + 1 5 i ← size[A] 6 A[i] ← x 7 B UBBLE -U P(A, i) 8 end if B UBBLE -U P(A, i) 1 while i > 1 und A[i] < A[parent(i)] do 2 Vertausche A[i] und A[parent(i)]. 3 i ← parent(i) 4 end while 2 2 9 5 8 11 20 9 5 13 12 8 12 11 4 2 2 5 12 9 11 13 (b) Einfügen des neuen Elements 4. (a) Ausgangsheap. 4 20 20 9 4 13 8 (c) Nach einer Vertauschung mit den Vaterknoten. 5 12 11 20 13 8 (d) Endposition, die Heap-Ordnung ist wiederhergestellt. Abbildung 2.4: Einfügen des neuen Elements 4 in einen binären Heap. Das neue Element wird unten in den Heap eingefügt und steigt dann durch Vertauschen mit den Vaterknoten solange auf, bis die Heap-Ordnung wiederhergestellt ist. 14 Haufenweise Haufen: Heaps, d-Heaps, Intervall-Heaps, Binomial-Heaps und Leftist-Heaps Algorithmus 2.5 Extrahieren des Minimums in einem binären Heap E XTRACT-M IN(A) 1 r := A[1] { Das Minimum, welches zurückgeliefert wird. } 2 A[1] := A[size(A)] { Das alte Minimum wird überschrieben. } 3 size[A] = size[A] − 1 4 i←1 { Das neue Element in A[1] muß nun im Heap absinken, bis die Heap-Eigenschaft wieder hergestellt ist. } 5 while i < size[A] do 6 j ← left(i) 7 if right(i) ≤ size[A] und A[right(i)] < A[left(i)] then 8 j ← right(i) 9 end if 10 if A[i] > A[j] then { A[j] ist der Sohn mit den kleinsten Schlüsselwert. } 11 Vertausche A[i] und A[j]. 12 i←j 13 else 14 return r 15 end if 16 end while 17 return r 2 8 9 4 5 20 11 13 8 12 9 4 5 13 12 (a) Ausgangsheap. (b) Die Wurzel wird durch das letzte Element ersetzt. 4 4 9 8 5 20 11 11 20 9 5 13 12 8 11 20 13 12 (c) Vertauschen mit den kleineren Sohn. (d) Endposition. Abbildung 2.5: Extrahieren des Minimums in einem binären Heap. Algorithmus 2.6 Verringern des Schlüsselwerts des Elements an Position j in einem binären Heap D ECREASE -K EY(A, j, k) 1 i←j 2 A[i] ← k 3 B UBBLE -U P (A, i) { B UBBLE -U P steht in Algorithmus 2.4 auf Seite 13. } 2.2 Binäre Heaps Beobachtung 2.5 Mit Hilfe eines binären Heaps als Datenstruktur für die Prioritätsschlange benötigt der Dijkstra-Algorithmus O((n + m) log n) Zeit auf einem Graphen mit n Ecken und m Kanten. Abschließend soll noch erwähnt werden, wie man einen binären Heap für n Elemente in linearer Zeit O(n) aufbaut (die »offensichtliche Lösung« mit n-fachem Einfügen eines Elements führt auf die Zeitkomplexität O(n log n)). Algorithmus 2.7 Algorithmus zum Herstellen der Heap-Eigenschaft im Array A[i], . . . , A[size[A]], wobei angenommen wird, daß die Teilheaps mit Wurzeln left(i) und right(i) bereits korrekt geordnet sind. H EAPIFY(A, i) 1 l ← left(i) 2 r ← right(i) 3 if l < size[A] und A[i] < A[l] then 4 s←l 5 else 6 s←i 7 end if 8 if r < size[A] und A[r] < A[s] then 9 s←r 10 end if 11 if s 6= i then 12 Vertausche A[i] und A[s] 13 H EAPIFY(A, s) 14 end if Algorithmus 2.8 Algorithmus zum Erstellen eines Heaps aus Elementen in einem Array in linearer Zeit B UILD -H EAP(A) 1 size[A] ← length[A] 2 for i ← blength[A]/2c, . . . , 1 do 3 H EAPIFY(A, i) 4 end for Ein wichtiges Unterprogramm für das Erstellen eines Heaps ist die Prozedur H EAPIFY aus Algorithmus 2.7. Beim Aufruf H EAPIFY(A, i) wird vorausgesetzt, daß die binären Bäume mit Wurzeln left(i) und right(i) bereits Heap-geordnet sind. H EAPIFY läßt nun A[i] so lange im Heap weiter nach unten sinken, indem es A[i] rekursiv immer mit dem kleinsten Sohn vertauscht, bis die Heap-Eigenschaft auch im Array A[i], . . . , A[size[A] gilt. Man sieht leicht, daß die Laufzeit von H EAPIFY auf einem Heap der Höhe h in O(h) ist. Zum Erstellen eines Heaps (B UILD -H EAP in Algorithmus 2.8) nutzen wir H EAPIFY wie folgt. Die Elemente A[bn/2c + 1], . . . , A[n] sind Blätter im binären Baum. Man kann sie also als Wurzeln von einelementigen Heaps betrachten. Zu Beginn von H EAPIFY ist induktiv jeder Knoten i + 1, . . . , n Wurzel eines Heaps. Durch den Durchlauf wird dann i mit Hilfe von H EAPIFY korrekt zur Wurzel eines Heaps. Da in einem n elementigen Heap maximal dn/2h+1e Knoten der Höhe h existieren, wird H EAPIFY höchstens dn/2h+1 e mal für einen Heap der Höhe h aufgerufen. Die gesamte 15 16 Haufenweise Haufen: Heaps, d-Heaps, Intervall-Heaps, Binomial-Heaps und Leftist-Heaps Zeitkomplexität von B UILD -H EAP ist daher: blog nc l X h=0 n m 2h+1 X h O(h) = O n 2h h=0 ! ∞ X h ≤n·O 2h blog nc h=0 = n · O (1) = O(n). 2.3 Erweiterungen von binären Heaps 2.3.1 d-näre Heaps Als naheliegende Verallgemeinerung von binären Heaps bieten sich die d-nären Heaps an, in denen jeder Knoten nicht zwei sondern bis zu d Söhne hat. Für einen Heap-Knoten 1 ≤ i ≤ size[A] ist dann parent(i) := bi/dc der Vater von i im Heap. Umgekehrt sind für einen Knoten j dann d · (j − 1) + 2, . . . , min{(d · (j − 1) + d + 1, size[A]} die Söhne von j. Die Implementierung der Prioritätsschlangen-Operationen in d-nären Heaps ist eine einfache Erweiterung der Implementierung für binäre Heaps. Man erhält die Zeitkomplexitäten, die in Tabelle 2.1 aufgeführt sind. Für den Algorithmus von Dijkstra bedeutet dies eine Laufzeit von O(m logd n + nd logd n). Was haben wir im Vergleich zum binären Heap gewonnen? Wir können den Parameter d so wählen, daß die bestmögliche Laufzeit daraus resultiert. Dazu wählen wir d dergestalt, daß beide Terme in der O-Notation identisch werden: d = max{2, dm/ne}. Dies ergibt eine Laufzeit von O(m log d n) = O(m logmax{2,dm/ne} n). Beobachtung 2.6 Mit Hilfe eines d-nären Heaps (d = max{2, dm/ne}) als Datenstruktur für die Prioritätsschlange benötigt der Dijkstra-Algorithmus O(m log max{2,dm/ne} n) Zeit auf einem Graphen mit n Ecken und m Kanten. Für dünne Graphen, d.h. m = O(n), ist die Laufzeit dann O(n log n). Für dichtere Graphen mit m = Ω(n1+ε ) für ein ε > 0 erhalten wir O(m logd n) = O(m log n/ log d) = O(m log n/ log nε ) = O(m/ε) = O(m), wobei die letzte Gleichheit folgt, da ε > 0 konstant ist. Für diesen Fall erhalten wir also eine lineare Laufzeit. Dies ist sicherlich optimal, da jeder korrekte Algorithmus für kürzestes Wege zumindest jede der m Kanten einmal betrachten muß. 2.3.2 Intervall-Heaps Eine zweiseitige Prioritätsschlange unterstützt zusätzlich zu den Operationen M AKE, I N SERT , M INIMUM , E XTRACT-M IN und D ECREASE -K EY auch die Operationen M AXI MUM , E XTRACT-M AX und I NCREASE -K EY . Eine zweiseitige Prioritätsschlange ist nicht effizient mit Hilfe eines einfachen binären Heaps implementierbar: Wenn der Heap minimum-geordnet ist (wie wir das bisher immer angenommen haben), so erfordert etwa M AXIMUM das komplette Durchsuchen des Heaps, was Ω(n) Zeit bei n Elementen im Heap erfordert.