Algorithmen und Datenstrukturen
Sven O. Krumke Entwurf vom 7. Februar 2003
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 16 20 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
6
2
1
4
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
3
5
4
9
(c) Der Knoten 3 wird als Minimum aus der Prioritätsschlange entfernt.
7
1
2
6
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
1
2
7
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
5 8
9 11
20
5 13
12
8 12
9 11
4
2
2 9
5
12
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
4 5 12
9
4
20
11
13
8
5
9 13
12 (a) Ausgangsheap.
(b) Die Wurzel wird durch das letzte Element ersetzt.
4
4
8 5
20
11
9 11
20
5 13
12
8
9 11
20
12 (c) Vertauschen mit den kleineren Sohn.
(d) Endposition.
Abbildung 2.5: Extrahieren des Minimums in einem binären Heap.
13
2.2 Binäre Heaps
15
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. }
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
16
Haufenweise Haufen: Heaps, d-Heaps, Intervall-Heaps, Binomial-Heaps und Leftist-Heaps 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 Zeitkomplexität von B UILD -H EAP ist daher: blog nc l blog nc m X X n h O(h) = O n 2h+1 2h h=0 h=0 ! ∞ X h ≤n·O 2h 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 .
2.3 Erweiterungen von binären Heaps
17
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. Eine mögliche Lösung wäre der Aufbau von zwei Heaps für die zu verwaltenden Elemente, einer minimum-geordnet und einer maximum-geordnet. Jedes Element wird dabei in jedem Heap einmal, also insgesamt zweimal, abgelegt. Während jetzt M INIMUM und M A XIMUM in O(1) Zeit ablaufen und I NSERT in O(log n) Zeit implementierbar ist, erfordern E XTRACT-M IN und E XTRACT-M AX wiederum Ω(n) Zeit. Als Ausweg bieten sich die sogenannten Intervall-Heaps an. Wir beschreiben zunächst den Intervall-Heap über seine Repräsentation als binärer Baum. Genauso wie beim binären Heap ist jedoch eine Implementierung als Array ohne Zeiger problemlos möglich, wie wir gleich zeigen werden. In einem Intervall-Heap gehört zu jedem Heap-Knoten v ein Intervall I(v) = [a, b] mit der möglichen Ausnahme des letzten Knotens, der entweder ein Intervall oder auch nur einen reellen Wert enthält. Wir schreiben hier zur Vereinfachung der Notation auch x ⊆ [a, b], wenn x ∈ [a, b]. Ein Intervall-Heap ist gemäß der Halbordnung ⊆ max-Heap-geordnet: Ist p Vater von v im Heap, so gilt I(v) ⊆ I(p).
Wir kommen jetzt zur Implementierung des Intervall-Heaps als Array. Abbildung 2.6 veranschaulicht einen Intervall-Heap und die im folgenden vorgestellte Abbildung in ein Array. Ein Array, welches einen Intervall-Heap repräsentiert, hat wie beim binären Heap die Attribute length[A] und size[A]. Die Array-Einträge für den iten Knoten im Heap (1 ≤ i ≤ bsize[A]/2c + 1) stehen in A[2i − 1] und A[2i]. Ist 2i − 1 der Startindex des iten Knotens im Heap, so ist 2bi/2c − 1 der Startindex des Vaterknotens. [1,15] [3,10] [4,8]
[2,7] [5,9]
2
1
4
3
15
1 10
9
4
3
6
5
10
2
8
7
7
4
9
8
5
11
4
Abbildung 2.6: Ein Intervall-Heap als binärer Baum und seine Abbildung in ein lineares Array. Wegen der Heap-Ordnung stehen in der Wurzel des Intervall-Heaps das minimale und das maximale Element. Somit sind M INIMUM und M AXIMUM sehr einfach in O(1) Zeit implementierbar. Das Erstellen eines leeren Intervall-Heaps funktioniert ebenfalls in O(1) Zeit. Wir beschreiben jetzt in Algorithmus 2.9 noch, wie das Einfügen in einem Interval-Heap in O(log n) Zeit funktioniert. Die Implementierung ist eine mehr oder weniger einfache Verallgemeinerung des Einfügens in einen binären Heap (siehe Algorithmus 2.4). Wir haben in Schritt 21 von Algorithmus 2.9 nicht alle ermüdenden Details aufgeführt. Das Prinzip sollte jedoch klar sein: Ist [L, R] kein Teilintervall des im Vaterknoten gespeicherten Intervalls [a, b], so gilt L < a oder R > b. In diesem Fall erhält der Vaterknoten das neue Intervall [min{a, L}, max{b, R}] und der Sohnknoten das Intervall [max{a, L}, min{b, R}. Das Verfahren läuft dann beim Vaterknoten weiter oben im Heap iterativ fort. Da ein Intervall-Heap nur logarithmische Tiefe besitzt und wir pro Ebene nur konstante Zeit benötigen, läuft I NTERVAL -I NSERT in einem Intervall-Heap mit n Knoten in O(log n) Zeit. In Abbildung 2.7 ist ein Beispiel zu sehen.
Die Operationen E XTRACT-M IN, E XTRACT-M AX, D ECREASE -K EY und I NCREASE K EY sind ebenfalls in O(log n) Zeit implementierbar. Da die Pseudocodes für die Operationen sehr unübersichtlich werden, wie man anhand von I NTERVAL -I NSERT sehen kann,
18
Haufenweise Haufen: Heaps, d-Heaps, Intervall-Heaps, Binomial-Heaps und Leftist-Heaps
Algorithmus 2.9 Einfügen in einen Intervall-Heap. I NTERVAL -I NSERT(H, x) 1 if size[A] = length[A] then 2 return „Der Heap ist voll“ 3 end if 4 size[A] = size[A] + 1 5 j ← size[A] 6 if j ist ungerade, j = 2i − 1 then { Ein neuer Knoten wird angefügt. } 7 i ← dj/2e 8 A[2i − 1] ← x 9 L←x 10 R←x 11 else { j ist gerade, x wird als zweiter Wert in den letzten Knoten eingefügt. } 12 i ← dj/2 13 L ← min{x, A[2i − 1]} 14 R ← max{x, A[2i − 1]} 15 A[2i − 1] ← L 16 A[2i] ← R 17 end if 18 j ← bi/2c − 1 { Der Vaterknoten des Knotens mit Daten in A[2i − 1] und A[2i] hat seine Daten in A[2j − 1] und A[2j]. } 19 { Heap-Eigenschaft wiederherstellen. } 20 while 2i − 1 > 1 und [L, R] 6⊆ [A[2j − 1], A[2j]] do 21 Stelle die Heap-Eigenschaft lokal durch geeignetes Vertauschen der Intervallgrenzen wieder her. 22 Speichere in L und R die neuen Intervallgrenzen des Vaterknotens. 23 i←j 24 j ← bi/2c − 1 25 end while
2.3 Erweiterungen von binären Heaps
19
[2,30] [3,25] [8,16] [8,16]
[4,25] [4,20]
[9,15]
[10,15]
[5,12]
5
(a) Der Ausgangsheap, in den 2 eingefügt werden soll.
[2,30] [3,25]
[4,25]
[8,16] [8,16]
[4,20] [9,15]
[10,15]
[5,12]
[5,2]
(b) Das neue Element wird in den letzten Knoten geschrieben.
[2,30] [3,25]
[4,25]
[8,16] [8,16]
[4,20] [9,15]
[10,15]
[5,12]
[2,5]
(c) Zunächst wird das Blatt so geordnet, daß die linke Intervallgrenze nicht größer als die rechte ist.
[2,30] [3,25]
[4,25]
[8,16] [8,16]
[2,20] [9,15]
[10,15]
[5,12]
[4,5]
(d) Durch Vertauschen der Intervallgrenzen mit denen des Vaterknotens (sofern nötigt), wird die Heap-Ordnung zum Vater wiederhergestellt.
[2,30] [2,25] [8,16] [8,16]
[4,25] [3,20]
[9,15]
[10,15]
[5,12]
[4,5]
(e) Das Verfahren terminiert, wenn die Heap-Ordnung mit den aktuellen Vater erfüllt ist oder wir in der Wurzel angelangt sind.
Abbildung 2.7: Einfügen I NTERVAL -I NSERT(Q, 2) in einen Intervall-Heap.
20
Haufenweise Haufen: Heaps, d-Heaps, Intervall-Heaps, Binomial-Heaps und Leftist-Heaps beschreiben wir die Implementierungen als Text. Die Umsetzung in Pseudocode ist einfach, aber ermüdend. E XTRACT-M IN Wenn der Heap nur ein einziges Element speichert, so ist die Umsetzung trivial. Wir nehmen also an, daß die Wurzel des Heaps das Interval [a, b] speichert und folglich bei E XTRACT-M IN das Element a gelöscht wird. Wir ersetzen nun a durch den kleineren der beiden linken Intervallgrenzen der Söhne und führen diese Prozedur dann im entsprechenden Sohn fort. Das Verfahren terminiert in einem Knoten v, dessen linke Intervallgrenze gerade in den Vaterknoten hochgeschoben wurde. Falls v der letzte Knoten des Heaps ist, so müssen wir lediglich die möglicherweise existierende rechte Intervallgrenze in v auf die linke verschieben. Ansonsten entfernen wir einen Wert im letzten Knoten des Heaps, fügen ihn in v ein und führen dann das beim Einfügen beschriebene Verfahren hoch, in dem wir wieder den Heap hochsteigen. Pro Ebene im Heap benötigt das Verfahren nur O(1) Zeit, so daß wegen der logarithmischen Höhe des Heaps insgesamt nur O(log n) Zeit benötigt wird. Abbildungen 2.8 und 2.9 zeigen ein Beispiel für E XTRACT-M IN. E XTRACT-M AX wird analog zu E XTRACT-M IN implementiert. D ECREASE -K EY Falls eine linke Intervallgrenze erniedrigt wird, so bleibt die HeapEigenschaft »nach unten hin«, d.h. zu den Söhnen hin, erhalten. Wir müssen nun nur noch die Heap-Eigenschaft nach oben garantieren. Dies funktioniert analog zu I NTERVAL -I NSERT. Falls eine rechte Intervallgrenze erniedrigt wird, so ist das Verfahren etwas komplizierter. Sei v der aktuelle Knoten, in dem die rechte Grenze erniedrigt wird. Wir stellen zunächst in v die Eigenschaft her, daß die linke Grenze nicht größer als die rechte Grenze ist. Dabei kann die linke Grenze natürlich nur kleiner werden, so daß wir mit der linken Grenze nach unten hin keine Probleme haben (möglicherweise nach oben, aber darum kümmern wir uns noch gleich). Die rechte Seite in v ist möglicherweise kleiner geworden, so daß wir die Heap-Eigenschaft nach unten möglicherweise wiederherstellen müssen. Dies geschieht einfach, indem wir die rechte Seite in v durch die größte rechte Seite in einem Sohn ersetzen. Dann setzen wir das Verfahren in diesem Sohn fort. Nachdem wir in einem Blatt angelangt sind, ist die Heap-Eigenschaft ab v nach unten wiederhergestellt. Jetzt sichern wir die Heap-Eigenschaft nach oben wieder durch das bekannte Hochschieben. Man sieht leicht, daß das eben beschriebenen Verfahren nur O(log n) Zeit benötigt. I NCREASE -K EY wird analog zu D ECREASE -K EY implementiert.
2.3.3 Eine Anwendung: Das eindimensionale komplementäre Bereichsproblem Wir schließen den Abschnitt über die Intervall-Heaps mit einer nicht ganz offensichtlichen Anwendung. Gegeben sei eine endliche Punktmenge X ⊂ R, in die dynamisch Punkte eingefügt und entfernt werden. Nun erhalten wir ein Intervall [a, b] (über seine Grenzen) und suchen alle Punkte aus X, die nicht in [a, b] liegen. Zur Illustration stelle man sich etwa ein Computerspiel vor, in dem X gewisse Standorte speichert und dann eine »Bombe«alle Standorte im Bereich [a, b] trifft. Wir nennen das obige Problem das eindimensionale komplementäre Bereichsproblem. Indem man X mit Hilfe eines balancierten Baums implementiert, kann man das 1Bereichsproblem in O(log |X| + k) Zeit lösen, wobei k = dimensionale komplementäre X ∩ (R \ [a, b]) die Größe der Ausgabe, also die Anzahl der auszugebenden Punkte, ist. Mit Hilfe von Intervall-Heaps kann man diese Zeitkomplexität auf O(k) verbessern.
2.3 Erweiterungen von binären Heaps
21
[1,30] [2,25]
[4,25]
[8,16] [8,16]
[3,20] [9,15]
[4,5]
[10,15]
[5,12]
10
(a) Der Ausgangs-Heap.
[2,30] [2,25]
[4,25]
[8,16] [8,16]
[3,20] [9,15]
[4,5]
[10,15]
[5,12]
10
(b) Im Wurzelknoten entsteht eine »Lücke« durch Löschen des Minimums.
[2,30] [2,25]
[4,25]
[8,16] [8,16]
[3,20] [9,15]
[4,5]
[10,15]
[5,12]
10
(c) Das kleinste linke Intervallende der Sohnknoten wird in die Wurzel hochgezogen. Das Verfahren läuft bei diesem Sohnknoten weiter.
[2,30] [3,25]
[4,25]
[8,16] [8,16]
[4,20] [9,15]
[2,5]
[10,15]
[5,12]
10
(d) Das Verfahren endet zunächst in einem Blatt, das nun kein linkes Intervallende mehr hat.
Abbildung 2.8: E XTRACT-M IN in einem Intervall-Heap.
22
Haufenweise Haufen: Heaps, d-Heaps, Intervall-Heaps, Binomial-Heaps und Leftist-Heaps
[2,30] [3,25] [8,16] [8,16]
[4,25] [4,20]
[9,15]
[10,15]
[5,12]
[10,5]
(a) Ein Wert aus dem letzten Knoten im Heap (hier hat der letzte Knoten nur einen Wert) wird benutzt, um die »Lücke« aufzufüllen.
[2,30] [3,25] [8,16] [8,16]
[4,25] [4,20]
[9,15]
[10,15]
[5,12]
[5,10]
(b) Ab nun läuft das Verfahren wie beim Einfügen. Das Blatt wird so geordnet, daß die linke Intervallgrenze nicht größer als die rechte ist. Durch rekursives Vertauschen mit Werten aus dem Vaterknoten wird die Heap-Ordnung wiederhergestellt. Im Beispiel ist nichts mehr zu tun, das Verfahren terminiert also sofort.
Abbildung 2.9: Fortsetzung: E XTRACT-M IN in einem Intervall-Heap.
2.4 Minimale aufspannende Bäume: Der Algorithmus von Boruvka (in Variation) Wir organisieren die Punkte in X in einem Intervall-Heap. Zunächst bemerken wir, daß wir das Löschen eines Punkts x ∈ X dadurch lösen können, daß wir D ECREASE -K EY(x, −∞) und dann E XTRACT-M IN ausführen. Somit ist auch das Löschen in logarithmischer Zeit ausführbar. Sei nun [a, b] das Anfrageintervall. Wir starten in der Wurzel des Heaps und durchlaufen den Heap rekursiv wie folgt: sei v der aktuelle Heap-Knoten und I(v) das in v gespeicherte Intervall. (i) Falls I(v) ⊆ [a, b], so müssen wir keinen der Punkte, die in v gespeichert sind, ausgeben. Weiterhin liegen auch alle Punkte im Teilbaum, der in v wurzelt, in [a, b], so daß wir die Rekursion hier beenden können. (ii) Falls I(v) ∩ [a, b] ( I(v), so existiert mindestens ein Endpunkt von I(v), der nicht in [a, b] liegt. Wir geben alle (maximal zwei) Endpunkte von I(v) aus, die nicht in [a, b] liegen. Sofern v noch Söhne hat, setzen wir die Suche rekursiv in den entsprechenden Teilbäumen fort. Die Laufzeit unseres Algorithmus ist offenbar linear in der Anzahl der besuchten Knoten im Heap. Wenn der Falls (ii) auftritt, so können wir die Kosten für das Besuchen des Knotens v den gefundenen Endpunkten von I(v), die nicht in [a, b] liegen, zuordnen. Falls Fall (i) im Knoten v eintritt, so lag Fall (ii) im Vater von v vor. Wir ordnen die Kosten für das Besuchen von v den im Vaterknoten ausgegebenen Punkten zu. In unserem Kostenzuordnungsschema erhalten somit nur die auszugebenden Punkte in X ∩ (R \ [a, b]) Kosten zugeordnet. Außerdem erhält jeder dieser Punkte nur O(1) Kosten zugeordnet (da jeder Knoten nur zwei Söhne hat). Somit läuft unsere Algorithmus in O(k) Zeit. Dies ist übrigens eine optimale Zeitschranke, da wir gefordert hatten, daß jeder der k Punkte in X ∩ (R \ [a, b]) ausgegeben wird.
2.4 Minimale aufspannende Bäume: Der Algorithmus von Boruvka (in Variation) Für manche Anwendungen ist es wichtig, daß die verwendete Prioritätsschlagen auch die folgende Operation unterstützt: M ELD (Q1 , Q2 ) erzeugt eine neue Datenstruktur, die alle Elemente von Q1 und Q2 enthält. Die Strukturen Q1 und Q2 werden dabei zerstört. Als Motivation für die M ELD-Operation stellen wir einen einfachen Algorithmus zur Bestimmung eines minimalen aufspannenden Baumes vor. Wir wiederholen kurz die Definition eines aufspannenden Baumes, Details finden sich etwa in [3, Kapitel 23] oder [1]. Definition 2.7 (Aufspannender Baum, minimaler aufspannender Baum) Sei G = (V, E) ein ungerichteter Graph. Ein aufspannender Baum von G ist ein Teilgraph T = (V, ET ) von G mit gleicher Eckenmenge, der zusammenhängend und kreisfrei ist. Ist c : E → R eine Kantenbewertung, so heißt ein aufspanndender Baum T von G ein minimaler aufspannender Baum (MST), wenn c(T ) = min{ c(T 0 ) : T 0 ist aufspannender Baum von G }. Falls der Graph G nicht zusammenhängend ist, so kann kein aufspannender Baum von G existieren. Wir setzen daher im Weiteren voraus, daß die Eingabegraphen zusammenhängend sind.
23
24
Haufenweise Haufen: Heaps, d-Heaps, Intervall-Heaps, Binomial-Heaps und Leftist-Heaps Es läßt sich zeigen, daß ein aufspannender Baum eines Graphen mit n Ecken genau n − 1 Kanten besitzt, siehe [3, Kapitel 23] oder [1]. Zum Beweis der Korrektheit der MSTAlgorithmen in diesem Skript benötigen wir ein paar Definitionen und Hilfsaussagen. Definition 2.8 (Sichere Kante) Sei E 0 ⊆ E eine Teilmenge der Kanten von G mit folgender Eigenschaft:
Es gibt einen MST T ∗ = (V, ET ∗ ) von G mit E 0 ⊆ ET ∗ .
(2.5)
Eine Kante e ∈ E heißt sicher für E 0 , wenn E 0 ∪ {e} ebenfalls die Eigenschaft (2.5) besitzt. Definition 2.9 (Schnitt in einem ungerichteten Graphen) Sei G = (V, E) ein ungerichteter Graph und A ∪ B = V eine Partition von V . Dann nennen wir [A, B] := { (a, b) ∈ E : a ∈ A und b ∈ B }
den von A und B erzeugten Schnitt. Abbildung 2.10 zeigt ein Beispiel für einen Schnitt in einem (ungerichteten) Graphen. A
A
B
B
A
A
A
B
B
Abbildung 2.10: Ein Schnitt [A, B] in einem Graphen. Die Kanten in [A, B] sind gestrichelt hervorgehoben. Satz 2.10 Sei G = (V, E) ein zusammenhängender ungerichteter Graph und c : E → R eine Gewichtsfunktion. Sei E 0 ⊆ E mit der Eigenschaft (2.5). Sei [A, B] ein Schnitt von V mit der Eigenschaft: [A, B] ∩ E 0 = ∅. Sei nun e eine billigste Kante aus [A, B]. Dann ist e sicher für E 0 . Beweis: Sei T ∗ ein MST mit E 0 ⊆ T ∗ . Ist e ∈ T ∗ , so ist nichts zu zeigen. Ansonsten erzeugt die Hinzunahme von e zu T ∗ einen einfachen Kreis w = (e1 , . . . , ek ) mit o.B.d.A. e1 = e. Der Kreis w muß eine Kante ei ∈ [A, B] mit ei 6= e enthalten. Nach Voraussetzung gilt c(ei ) ≥ c(e). Dann erfüllt T := T ∗ \ {ei } ∪ {e}: c(T ) ≤ c(T ∗ ). Ferner ist T zusammenhängend und besitzt |V | − 1 Kanten, ist also ein Baum. Somit ist T ein MST, der alle Kanten aus E 0 ∪ {e} enthält. 2 Wir kommen nun zum versprochenen einfachen MST-Algorithmus, der auf Boruvka zurückgeht und uns in späteren Kapiteln nochmals begegnen wird. Die Grundidee ist dabei die folgende: wir starten mit leerer Kantenmenge T = ∅, so daß anfangs jeder Knoten vi ∈ V eine eigene Zusammenhangskomponente bildet. Sei nun T zu einem Zeitpunkt
2.4 Minimale aufspannende Bäume: Der Algorithmus von Boruvka (in Variation) unsere aktuelle Kantenmenge, die Teilmenge eines MST ist, und V 1 , . . . , Vk die Zusammenhangskomponenten von (V, T ). Sei (u, v) eine billigste Kante mit der Eigenschaft, daß u ∈ V1 und v ∈ Vj mit j 6= 1. Nach Satz 2.10 ist (u, v) sicher für T , und wir können (u, v) zu T hinzufügen. Dabei werden die Zusammenhangskomponenten V 1 und Vj zu einer neuen Komponente verschmolzen. Algorithmus 2.10 Algorithmus von Boruvka zur Bestimmung eines MST. MST-B ORUVKA(G, c) Input: Ein zusammenhängender ungerichteter Graph G = (V, E) mit n := |V | und m := |E| in Adjazenzlistendarstellung, eine Kantenbewertungsfunktion c : E → R. Output: Ein minimaler aufspannender Baum T von G. 1 T ←∅ { Kanten im zu erstellenden MST. } 2 L←∅ { Eine doppelt verkettete Liste. } 3 for all vi ∈ V do 4 Vi ← {vi } 5 Füge Vi hinten an L an. 6 end for 7 while L hat mehr als ein Element do 8 Vi ← erstes Element von L { Vi werde dabei aus L gelöscht. } 9 Finde die billigste Kante (u, v) mit u ∈ Vi und v ∈ / Vi 10 Sei v ∈ Vj ∈ L. Entferne Vj aus L. 11 Füge Vi ∪ Vj hinten an L an. 12 T ← T ∪ {(u, v)} 13 end while Algorithmus 2.10 zeigt den Algorithmus von Boruvka im Pseudocode. In den Abbildungen 2.11 bis 2.14 ist die Ausführung des Algorithmus auf einem Beispielgraphen gezeigt. Wir haben oben bereits argumentiert, daß der Algorithmus korrekt ist: jede hinzugefügte Kante ist eine sichere Kante. Wie kann man den Algorithmus von Boruvka effizient implementieren?
25
26
Haufenweise Haufen: Heaps, d-Heaps, Intervall-Heaps, Binomial-Heaps und Leftist-Heaps
1
1
4
2
3
3 1
2 4
2
5
2
4 7
5
6
5
3
2
8
L:
9
2
1
2
3
4
5
7
6
8
9
0
(a) Am Anfang ist jeder Knoten in seiner eigenen Zusammenhangskomponente. Die lineare Liste L, in der der Algorithmus die Komponenten verwaltet, ist rechts neben dem Graphen zu sehen. Das schwarze Quadrat in der Liste ist nicht tatsächlich in der Liste enthalten. Es dient hier nur zum Kennzeichnen des Endes einer Phase, die wir in der später folgenden Implementierung mit Hilfe von Leftist-Heaps zur Analyse benötigen.
1
1
4
2
3
3 1
2 4
2 2
4 7
5
5
6
5
3
2
8
L:
9
2
2
3
4
5
7
6
8
9
0
(b) Die erste Komponente wird der Liste entnommen und die billigste herausführende Kante (gestrichelt gezeichnet) bestimmt.
1 1-2
1-2
4
3 1
2
3 4
2 2
4 7
3
5
5
6
5 8
2 2
9
L:
3
4
5
6
7
8
9
0
1-2
(c) Die eben bestimmte Kante wird zum Baum hinzugefügt. Dabei wird die betroffene Komponente 2 aus L entfernt und die Komponenten 1 und 2 zu einer Komponente 1-2 verschmolzen, die hinten an die Liste angefügt wird.
Abbildung 2.11: Berechnung eines minimalen aufspannenden Baumes mit dem Algorithmus von Boruvka. Die dick gezeichneten Kanten sind jeweils in der aktuellen Lösung, die zum Schluß einen minimalen aufspannenden Baum bildet, enthalten.
2.4 Minimale aufspannende Bäume: Der Algorithmus von Boruvka (in Variation) Für eine Komponente Vi verwalten wir die Kanten Ei , die mindestens einen Endpunkt in Vi besitzen, in einem Binomial-Heap. Dann läßt sich das Minimum (u, v) von E i in O(log m) = O(log n) Zeit3 bestimmen. Sei u ∈ Vi . Falls v ∈ Vi , so extrahieren wir erneut das Minimum aus (dem Heap für) Ei . Beim Vereinigen von Vi und Vj in Schritt 11 des Algorithmus vereinigen wir (die Heaps für) Ei und Ej . Hier benötigen wir die Tatsache, daß sich Binomial-Heaps effizient vereinigen lassen. Algorithmus 2.11 zeigt die Details der Implementierung. Algorithmus 2.11 Implementierung des Boruvka-Algorithmus mit Binomial-Heaps. B INOM -MST-B ORUVKA(G, c) Input: Ein zusammenhängender ungerichteter Graph G = (V, E) mit n := |V | und m := |E| in Adjazenzlistendarstellung, eine Kantenbewertungsfunktion c : E → R. Output: Ein minimaler aufspannender Baum T von G. 1 T ←∅ { Kanten im zu erstellenden MST. } 2 L←∅ { Eine doppelt verkettete Liste. } 3 for all vi ∈ V do 4 M AKE -S ET(vi ) { Vi ← {vi } } 5 Ei ← { (vi , v) ∈ E } 6 Qi ← B UILD -H EAP(Ei ) { Ordne die von vi ausgehenden Kanten in einem Min-Heap. } 7 Füge Qi hinten an L an. 8 end for 9 while L hat mehr als ein Element do 10 Qi ← erstes Element in L { Qi werde dabei aus L entfernt. } 11 found ← false 12 while found = false do 13 (u, v) ← E XTRACT-M IN(Q) 14 Vi ← F IND -S ET(u) { Finde die Menge Vi mit u ∈ Vi . } 15 Vj ← F IND -S ET(v) { analog für v. } 16 if i 6= j then 17 T ← T ∪ {(u, v)} 18 U NION (Vi , Vj ) { Ersetze Vi und Vj durch Vi ∪ Vj . } 19 Entferne die zu Vi und Vj gehörenden Heaps Qi und Qj aus L. Einer der beiden Heaps o.B.d.A. Qi ist bereits aus L entfernt. Er ist derjenige Heap, den wir zu Anfang der Iteration der while-Schleife als erstes Element von L entfernt haben. Der zweite Heap Qj ist in L in konstanter Zeit auffindbar, wenn jeder Menge Vk einen Zei ger auf den zugehörigen Heap Qk in L speichert. Das Entfernen eines Elements aus einer doppelt verketteten Liste ist in konstanter Zeit durchführbar. 20 Q ← M ELD(Qi , Qj ) 21 Füge Q hinten an L an. 22 end if 23 end while 24 end while In der Implementierung 2.11 benutzen wir die Operationen F IND -S ET und U NION einer Datenstruktur für disjunkte Mengen, die wir in Kapitel 5 noch kennenlernen werden. M AKE -S ET(vi ) erstellt eine Menge, deren einziges Element vi ist, U NION(Vi , Vj ) ersetzt die Mengen Vi und Vj durch ihre Vereinigung, und F IND -S ET(v) liefert die Menge in der 3 Wir können annehmen, daß keine parallelen Kanten vorhanden sind. Dann gilt m ∈ O(n 2 ) und log m = O(log n2 ) = O(2 log n) = O(log n).
27
28
Haufenweise Haufen: Heaps, d-Heaps, Intervall-Heaps, Binomial-Heaps und Leftist-Heaps
1 1-2
4
1-2
3
3
2 2
4
7
5
5
2
4
1 6 2
5 8
3
L:
9
2
4
5
7
6
8
9
0
1-2
(a) Als nächstes wird die Komponente 3 aus der Liste entfernt. Durch die billigste ausgehende Kante wird sie mit Komponente 6 verschmolzen.
1 1-2
4
1-2
3
3-6
2 2
4
7
5
5
2
4
1 3-6
5
2
8
3
L:
9
2
7
5
8
9
0
3-6
1-2
(b) Im nächsten Schritt wird die Komponente 4 mit Komponente 5 verschmolzen.
1 1-2
1-2
3
4
3-6
2 2
4-5
4-5 2
4 7
1 5
3-6
5 3
8
2 2
9
L:
8
9
0
1-2
3-6
4-5
(c) Jetzt wird Komponente 7 mit der bereits in dieser Phase erstellten Komponente 4-5 verschmolzen.
Abbildung 2.12: Fortsetzung: Berechnung eines minimalen aufspannenden Baumes mit dem Algorithmus von Boruvka. Die dick gezeichneten Kanten sind jeweils in der aktuellen Lösung, die zum Schluß einen minimalen aufspannenden Baum bildet, enthalten.
2.4 Minimale aufspannende Bäume: Der Algorithmus von Boruvka (in Variation)
1 1-2
1-2
3
4
3-6
2 2
4-5-7
4-5-7 2
4 4-5-7
1 5
3-6
5 8
3
2 9
2
L:
0
9
3-6
1-2
4-5-7
1
(a) Verschmelzen der Komponenten 8 und 9 beendet die Phase 0.
1 1-2-4-5-7
1-2-4-5-7
3
4
3-6
2 2
1-2-4-5-7
1-2-4-5-7 2
4
1-2-4-5-7
3
1 5
3-6
5
2
8-9
8-9
L:
3-6
8-9
1
1-2-4-5-7
2
(b) Als erstes werden in Phase 1 die Komponenten 1-2 und 4-5-7 verschmolzen.
Abbildung 2.13: Fortsetzung: Berechnung eines minimalen aufspannenden Baumes mit dem Algorithmus von Boruvka. Die dick gezeichneten Kanten sind jeweils in der aktuellen Lösung, die zum Schluß einen minimalen aufspannenden Baum bildet, enthalten.
29
30
Haufenweise Haufen: Heaps, d-Heaps, Intervall-Heaps, Binomial-Heaps und Leftist-Heaps
1 1-2-4-5-7
1-2-4-5-7
3
4
2 2
1-2-4-5-7
1-2-4-5-7 2
4
1-2-4-5-7
1 5
5
3
3-6-8-9
3-6-8-9
2
3-6-8-9
3-6-8-9
L:
1
1-2-4-5-7
3-6-8-9
2
2 (a) Mit dem Verschmelzen von 3-6 und 8-9 endet dann Phase 1.
1 3
4 2
2 4
2
1 5
5 3
2 2
(b) Die beiden verbliebenen Komponenten werden in der letzen Phase, Phase 2, verschmolzen. Das Endergebnis ist hier der Platzersparnis ohne 1-2-3-4-5-6-7-8-9 in den Knoten gezeichnet.
Abbildung 2.14: Fortsetzung: Berechnung eines minimalen aufspannenden Baumes mit dem Algorithmus von Boruvka. Die dick gezeichneten Kanten sind jeweils in der aktuellen Lösung, die zum Schluß einen minimalen aufspannenden Baum bildet, enthalten.
2.4 Minimale aufspannende Bäume: Der Algorithmus von Boruvka (in Variation) Partition, welche v enthält. Details für die Datenstruktur zur Verwaltung der Partition von V finden sich in Kapitel 5. Wir analysieren die Laufzeit der Implementierung von Algorithmus 2.11. Dabei vernachlässigen wir zunächst einmal den Zeitaufwand für die Verwaltung der Mengen V i in der Partition. Der Zeitaufwand für P das Erstellen des initialen Heaps für Ei ist O(|Ei |). Somit benötigt die Initialisierung O( vi ∈V |Ei |) = O(2m) = O(m) Zeit, da jede Kante in genau zwei initialen Mengen Ei auftaucht. Es verbleibt, die Zeit für alle Iterationen der while-Schleife von Schritt 9 bis 24 abzuschätzen. Zunächst bemerken wir, daß die Schleife genau n − 1 mal durchlaufen wird: wir starten mit n Komponenten, und in jedem Durchlauf verringert sich durch Vereinigen von zwei Komponenten ihre Anzahl um eins. Man sieht leicht (vgl. auch die Kommentare im Programmcode), daß alle Operationen eines Durchlaufs bis auf die Heap-Operationen und die F IND -S ET/U NION-Operationen (die wir erst einmal ausgeklammert haben) in konstanter Zeit durchführbar sind. Jeder Durchlauf der while-Schleife benötigt eine M ELD-Operation für unsere Heaps. Die M ELD-Operation ist in binären und d-nären Heaps nur recht ineffizient implementierbar: wir müssen die Elemente in ein neues Array umkopieren und die Elemente eines Heaps in den anderen Heap einfügen. Dies benötigt Ω(n) Zeit, so daß wir auch gleich einen neuen Heap aufbauen können. Die Binomial-Heaps unterstützen M ELD hingegen effizient: ein M ELD zweier Heaps mit m1 und m2 Elementen benötigt O(log m1 + log m2 ) Zeit (vgl. auch Tabelle 2.2). Daher ist der Zeitaufwand für alle n − 1 M ELDs bei Benutzung von Binomial-Heaps O(n log m) = O(n log n). Jede Kante (u, v) wird maximal zweimal als Minimum in Schritt 13 extrahiert: maximal einmal für jeden der beiden Endknoten. Somit finden insgesamt ≤ 2m E XTRACTM IN-Operationen auf Heaps der Größe ≤ m statt. Da Binomial-Heaps E XTRACT-M IN in logarithmischer Zeit unterstützen (siehe Tabelle 2.2), ist der Zeitaufwand für alle O(m) E XTRACT-M INs O(m log m). Wir haben somit gezeigt, daß sich der Algorithmus von Boruvka mit Hilfe von BinomialHeaps in O((n+m) log n) implementieren läßt, wenn man den Zeitaufwand für die M AKE S ET/F IND -S ET/U NION-Operationen vernachlässigt. Im Verlauf von Algorithmus 2.11 finden n M AKE -S ET, ≤ 2m F IND -S ET und n − 1 U NION-Operationen statt. In Abschnitt 5.2 werden wir eine einfache Datenstruktur kennenlernen, die es ermöglicht, diese O(n+m) Operationen in O(m+n log n) Zeit auszuführen. Damit erhöht sich die Laufzeit von Algorithmus 2.11 auch durch die Berücksichtigung der Operationen für die disjunkten Mengen nicht. Beobachtung 2.11 Mit Hilfe eines von Binomial-Heaps und der Datenstruktur für disjunkte Mengen aus Kapitel 5 benötigt der Algorithmus von Boruvka zur Bestimmung eines MST O(m log n) Zeit auf einem Graphen mit n Ecken und m Kanten. Bemerkung 2.12 1. Der Algorithmus von Boruvka läßt sich auch ohne Benutzung von Binomial-Heaps so implementieren, daß er in O(m log n) Zeit läuft. Die hier vorgestellte Implementierung ist aber etwas einfacher und illustriert den Nutzen einer effizient durchführbaren M ELD-Operation in Prioritätsschlangen. 2. Mit ein paar Tricks läßt sich sowohl die hier vorgestellte Implementierung als auch die alternative Implementierung ohne Binomial-Heaps noch so erweitern, daß die Laufzeit sogar nur O(m log log n) beträgt. Wir werden später MST-Algorithmen kennenlernen, deren Laufzeit sogar noch besser ist.
31
32
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 M ELD B UILD
binärer Heap O(1) O(log n) O(1) O(log n) O(log n) Θ(n) O(n)
d-närer Heap O(1) O(logd n) O(1) O(d · logd n) O(logd n) Θ(n) O(n)
Binomial-Heap O(1) O(log n) O(1) O(log n) O(log n) O(log n) O(n)
Tabelle 2.2: Zeitkomplexität der Prioritätsschlangen-Operationen bei Implementierung durch einen binären Heap, durch einen d-nären Heap und durch einen Binomial-Heap der Größe n.
2.5 Binomial-Heaps Wir stellen nun die Binomial-Heaps vor und beschreiben, wie man die einzelnen Prioritätsschlangen-Operationen in diesen Heaps implementiert. Unsere Implementierung unterscheidet sich von der in [3, Kapitel 19] in folgenden Punkten: 1. Wir halten zusätzlich einen Zeiger min[H] auf das Minimum des Heaps. Das hat zur Folge, daß M INIMUM in O(1) Zeit statt in O(log n) Zeit durchführbar ist. 2. Das Vereinigen von zwei Binomial-Heaps ist etwas effizienter gelöst. Das hat zur Folge, daß das Aufbauen eines Binomial-Heaps aus n Elementen durch sukzessives Einfügen in O(n) Zeit anstelle von O(n log n) Zeit implementiert werden kann. Im Folgenden nennen wir einen (nicht notwendigerweise binären) Baum T Heap-geordnet, wenn für jeden Knoten v ∈ T und seinen Vater parent(v) ∈ T gilt: key[parent(v)] ≤ key[v].
2.5.1 Binomialbäume und Binomial-Heaps Der Baustein für Binomial-Heaps sind die sogenannten Binomialbäume, die rekursiv wie folgt definiert sind: Definition 2.13 (Geordneter Binomialbaum) Ein geordneter Binomialbaum Bk der Ordnung k ∈ N ist wie folgt definiert:
1. Der Binomialbaum B0 besteht aus einem einzelnen Knoten. 2. Ein Binomialbaum Bk der Ordnung k ≥ 1 besteht aus zwei Binomialbäumen der Ordnung k−1, wobei die Wurzel des einen Baumes ein (beliebiger) Sohn des anderen Baumes ist. Abbildung 2.15 veranschaulicht die Binomialbäume. Durch Induktion nach k lassen sich leicht die folgenden Eigenschaften beweisen. Lemma 2.14 Für einen geordneten Binomialbaum Bk gilt:
(i) Bk enthält genau 2k Knoten. (ii) Die Höhe von Bk ist k .
2.5 Binomial-Heaps
33
Bk−1 (a) B0
Bk−1 (b) Bk
(c) B0
(e) B2
(d) B1
(f) B3
··· Bk−1
Bk−2
Bk−3
B2
(g) Bk
Abbildung 2.15: Rekursive Definition von Binomialbäumen.
B1
B0
34
Haufenweise Haufen: Heaps, d-Heaps, Intervall-Heaps, Binomial-Heaps und Leftist-Heaps
(iii) Es gibt genau
k i
Knoten mit Höhe i für i = 0, 1, . . . , k .
(iv) Der Wurzelknoten hat Grad k . Jeder andere Knoten hat strikt kleineren Grad. Die k Söhne der Wurzel, geordnet von links nach rechts, sind Wurzelknoten von Binomialbäumen B0 , . . . , Bk−1 (d.h. zu jedem 0 ≤ j ≤ k − 1 existiert genau ein Sohn, der Wurzelknoten eines Binomialbaums der Ordnung j ist). Beweis: Der Induktionsanfang k = 0 ist trivial. Wir nehmen an, daß die Aussagen für k −1 bereits bewiesen sind. (i) Bk besteht aus zwei Bk−1 , die nach Induktionsvoraussetzung jeweils 2k−1 Knoten enthalten. Also hat Bk genau 2 · 2k−1 = 2k Knoten. (ii) Die Höhe von Bk ist nach Konstruktion genau um eins größer als die von Bk−1 , also (k − 1) + 1 = k. Knoten des rechten Bk−1 und die k−1 (iii) Die Knoten in Bk der Höhe i sind die k−1 i−1 i Knoten des linken Bk−1 . Also ist die Anzahl der Knoten in Bk mit Höhe i: k−1 k−1 k + = . i i−1 i (iv) Der einzige Knoten in Bk , der größeren Grad besitzt als in Bk−1 ist die Wurzel. Diese besitzt einen zusätzlichen Sohn, hat also Grad (k − 1) + 1 = k. Nach Induktionsvoraussetzung sind die Söhne von Bk−1 die Wurzeln von Binomialbäumen Bk−2 , . . . , B0 in dieser Reihenfolge. Wenn nun aus zwei Bk−1 ein Bk entsteht, so werden die Söhne des rechten Bk−1 , die Wurzeln von Bk−2 , . . . , B0 sind, zu Söhnen der Wurzel von Bk . Die Söhne der Wurzel von Bk sind also Wurzeln von Bk−1 (der linke der beiden Bk−1 , aus denen Bk konstruiert wird) und die Wurzeln von Bk−2 , . . . , B0 . 2 Die Binomialbäume tragen ihren Namen von Eigenschaft (iii) in Lemma 2.14: es besteht ein Zusammenhang zwischen den Binomialkoeffizienten ki und den Knoten der Höhe i in Bk .
Ist Bk nun ein Binomialbaum, der n Knoten enthält, so gilt wegen Lemma 2.14 (i) dann 2k = n, also k = log2 n. Mit Eigenschaft (iv) sehen wir dann, daß der maximale Grad in diesem Binomialbaum genau log2 n beträgt: Korollar 2.15 Der maximale Grad in einem Binomialbaum mit n Knoten ist log 2 n.
2
Mit Hilfe der geordneten Binomialbäume können wir nun den Binomial-Heap definieren: Definition 2.16 (Binomial-Heap) Ein Binomial-Heap H besteht aus einer Kollektion von Binomialbäumen, die alle Heapgeordnet sind. Zusätzlich exitiert zu jeder Zahl k ∈ N höchstens einen Binomialbaum in H , dessen Wurzel Grad k besitzt. Lemma 2.17 Sei H ein Binomial-Heap mit n Elementen, n ∈ N+ . Dann gilt: P (i) Sei n = ki=0 bi 2i die Binärdarstellung von n mit k = blog2 nc. H besitzt genau dann einen Binomialbaum der Ordnung i in der Wurzelliste, wenn b i = 1 gilt.
(ii) Der größte Binomialbaum in der Wurzelliste von H ist ein B k mit k = blog2 nc.
2.5 Binomial-Heaps
35
(iii) Die Wurzelliste von H besitzt v(n) Binomialbäume, wobei v(n) die Anzahl der Einsen in der Binärdarstellung von n bezeichnet. (iv) Die Anzahl der Kanten in allen Binomialbäumen von H beträgt n − v(n). Beweis: Seien Bt0 , . . . , Bts die Binomialbäume in der Wurzelliste von H mit t0 ≤ · · · ≤ ts . Nach Voraussetzung gilt ti 6= tj für i 6= j. Da Bti genau 2ti Knoten besitzt, folgt bereits 2ts ≤ n, also ts ≤ k. Pk Mit di := 1, falls i ∈ {t0 , . . . , ts }, und di := 0 sonst, haben wir also n = i=1 di 2i = Pk i 4 i=0 bi 2 . Da die Zahldarstellung im Binärsystem eindeutig ist , folgt di = bi und somit (i). Die Behauptungen (ii) und (iii) folgen sofort aus (i). Zum Beweis von (iv) bemerken wir, daß ein Binomialbaum Bi genau 2i − 1 Kanten besitzt. Die Anzahl der Kanten in den Bäumen von H ist somit X X 2i − v(n) = n − v(n). (2i − 1) = i:bi 6=0
i:bi 6=0
Dies beendet den Beweis.
2
2.5.2 Implementierung von Binomial-Heaps Jeder Knoten in einem Binomial-Heap enthält die folgenden Informationen: • p[x] ist ein Zeiger auf den Vaterknoten von x (NULL, falls x der Wurzelknoten ist) • child[x] ist ein Zeiger auf den linkesten Sohn von x. Die Söhne von x sind in einer einfach verketteten Liste organisiert. • Für einen Sohn y ist right[y] ein Zeiger auf den Nachfolger in der verketteten Liste der Kinder. • In degree[x] ist der Grad von x, d.h. die Anzahl seiner Kinder, gespeichert. Weiterhin bezeichnen wir wieder mit key[x] den Schlüsselwert des Knotens x. Ein Binomial-Heap H ist über den Zeiger head[H] zugreifbar, der auf das erste Element in der Wurzelliste zeigt. Die Wurzelliste ist dabei nach aufsteigendem Grad der Wurzeln sortiert. Falls H leer ist, so ist head[H] = NULL. Aus Effizienzgründen halten wir uns noch zusätzlich einen Zeiger min[H], der auf das minimiale Element im Heap, also eine Wurzel in der Wurzelliste, zeigt. Abbildung 2.16 veranschaulicht die Organisation eines Binomial-Heaps im Computer.
2.5.3 Implementierung der einfachsten Heap-Operationen Zunächst betrachten wir das Erstellen eines neuen leeren Binomial-Heaps (siehe Algorithmus 2.12). Dazu müssen wir einfach head = NULL setzen. Damit ist die Laufzeit für B INOM -M AKE in Θ(1). Das Finden des minimalen Elements B INOM -M INIMUM ist ebenfalls trivial (siehe Algorithmus 2.13), da wir uns einen Zeiger auf das minimale Element in der Wurzelliste gemerkt hatten. k i k für unseren Fall: Wir haben i=0 (di − bi )2 = 0. Da di − bi ∈ {−1, 1} und 2 > − 1, folgt dk − bk = 0. Fortsetzung liefert nun di = bi für i = k, k − 1, . . . , 0.
4 Schnellbeweis k−1 i=0
2i
=
2k
36
Haufenweise Haufen: Heaps, d-Heaps, Intervall-Heaps, Binomial-Heaps und Leftist-Heaps
min[H] head[H] 10
5
7
13 15
11
9
18
10
12
20 (a) Ein Binomial-Heap mit 11 Knoten. Die Binärdarstellung von 11 ist 1011.
min[H] 10 0
5 1
7 3
head[H]
13 0
15 1
11 2
9 1
18 0
10 0
20 0
(b) Zeiger-Repräsentation im Computer.
p[x] key[x] degree[x] child[x] right[x] (c) Bedeutung der einzelnen Einträge in den Knoten.
Abbildung 2.16: Organisation eines Binomial-Heaps.
12 0
2.5 Binomial-Heaps Algorithmus 2.12 Erstellen eines leeren Binomial-Heaps. B INOM -M AKE() 1 head[H] ← NULL 2 min[H] ← NULL Algorithmus 2.13 Finden des Minimums in einem Binomial-Heap. B INOM -M INIMUM(H) 1 if head[H] = NULL then 2 return „Fehler: Der Heap ist leer!“ 3 end if 4 return key[min[H]]
Die Operation B INOM -D ECREASE -K EY ist ähnlich wie bei den binären Heaps implementiert: wir vertauschen den Knoten bei Bedarf rekursiv mit seinem Vaterknoten, bis daß die Heap-Ordnung wieder hergestellt ist, siehe Algorithmus 2.14. Ein Beispiel ist in Abbildung 2.17 zu sehen. Algorithmus 2.14 Verringern eines Schlüsselwerts in einem Binomial-Heap. B INOM -D ECREASE -K EY(H, x, k) ( Das Verfahren entspricht im wesentlichen dem »Bubble-Up« im ) Binär-Heap plus einer eventuellen Aktualisierung des Minimum1 Zeigers. 2 key[x] ← k 3 y←x 4 z ← p[x] 5 while z 6= NULL und key[y] < key[z] do 6 Vertausche key[y] und key[z]. 7 y←z 8 z ← p[z] 9 end while 10 if z = NULL then { Das Element wurde bis in die Wurzel eines Binomialbaums hochgeschoben. } 11 if key[y] < key[min[H]] then 12 min[H] ← y 13 end if 14 end if Der Zeitaufwand läßt sich erneut recht einfach abschätzen: wenn sich der Knoten x, dessen Schlüsselwert erniedrigt wird, auf Höhe h in einem Binomialbaum im Heap befindet, so benötigen wir O(h) Operationen. Nach Lemma 2.14 (ii) hat jeder Binomialbaum in einem Binomial-Heap mit n Knoten Höhe O(log n). Somit kann B INOM -D ECREASE -K EY in O(log n) Zeit implementiert werden.
2.5.4 Rückführen von I NSERT und E XTRACT-M IN auf M ELD Die Operationen B INOM -I NSERT und B INOM -E XTRACT-M IN lassen sich beide auf B INOM -M ELD zurückführen. Wir zeigen dies zunächst für das Einfügen (siehe Algorithmus 2.15). Wir erzeugen für das neue Element x einfach einen einelementigen Binomial-Heap H 0 in O(1) Zeit. Dann vereinigen wir H 0 mit dem bestehenden Heap H mittels B INOM -
37
38
Haufenweise Haufen: Heaps, d-Heaps, Intervall-Heaps, Binomial-Heaps und Leftist-Heaps
min[H]
min[H]
head[H] 10
5
head[H] 10
7
13 15
11
9
18
10
12
5
7
13 2
20
9
18
10
12
20
(a) Der Ausgangsheap.
(b) Der Schlüsselwert wurde auf 2 verringert.
min[H] head[H] 10
11
min[H] 5
7
13 11
2
9
18
10
head[H] 10
5
2
13
12
7
20
11
9
18
10
12
20
(c)
(d)
min[H] head[H] 10
5
2
13 7
11
9
18
10
12
20 (e) Zum Schuß wird noch der Zeiger auf das Minimum aktualisiert.
Abbildung 2.17: Ausführen der Operation D ECREASE -K EY(15, 2) in einem BinomialHeap.
2.5 Binomial-Heaps
39
Algorithmus 2.15 Einfügen eines Elements in einen Binomial-Heap. B INOM -I NSERT(H, x) 1 H 0 ← B INOM -M AKE () 2 p[x] ← NULL 3 child[x] ← NULL 4 right[x] ← NULL 5 degree[x] ← 0 6 head[H 0 ] ← x 7 min[H 0 ] ← x 8 H ← B INOM -M ELD (H, H 0 ) M ELD (siehe Abbildung 2.18). Da B INOM -M ELD in O(log n) Zeit läuft, wie wir in Abschnitt 2.5.5 zeigen werden, ist das Einfügen ebenfalls in O(log n) Zeit möglich. x
B INOM -M ELD(H, H 0 )
Abbildung 2.18: In Binomial-Heaps können wir das Einfügen von Elementen in einen Heap H auf das Verschmelzen zurückführen. Für das neue Element x wird ein einelementiger Binomial-Heap H 0 erzeugt, der dann mittels B INOM -M ELD mit H verschmolzen wird. Für B INOM -E XTRACT-M IN müssen wir etwas trickreicher arbeiten (siehe Algorithmus 2.16). Wir suchen die Wurzel x in der Wurzelliste mit minimalem Schlüsselwert (genauso wie in B INOM -M INIMUM). Dies ist in O(1) Zeit möglich, wie wir bereits gesehen haben. Dann entfernen wir x mitsamt seines an ihm wurzelnden Binomialbaum T aus der Wurzelliste. Dies benötigt ebenfalls nur O(log n) Zeit, da nur O(log n) Wurzeln in der Wurzelliste sind. Wir konstruieren nun einen neuen Binomial-Heap H 0 aus T (jedoch ohne x). Ist x Wurzel eines Bk , so sind die Söhne von links nach rechts Wurzeln von Bk−1 , . . . , B0 . Im Prinzip haben wir damit schon Bäume für einen Binomial-Heap, dessen Wurzelliste aus eben diesen Bäumen besteht. Allerdings haben wir bisher immer gefordert, daß die Wurzeln in der Wurzelliste eines Binomial-Heaps nach aufsteigendem Grad geordnet sind. Die Söhne haben jedoch von links nach rechts absteigenden Grad. Daher kehren wir die Reihenfolge der Sohnliste in O(log n) Zeit um und machen sie dann zur Wurzelliste eines neuen BinomialHeaps H 0 . Jetzt müssen wir nur noch H und H 0 mittels B INOM -M ELD vereinigen. Alle Operationen sind in O(log n) Zeit durchführbar. Da auch B INOM -M ELD in O(log n) Zeit läuft (siehe nächster Abschnitt), benötigt B INOM -E XTRACT-M IN nur O(log n) Zeit. Ein Beispiel für B INOM -E XTRACT-M IN ist in Abbildung 2.19 zu sehen.
2.5.5 Vereinigen zweier Binomial-Heaps Wir beschäftigen uns nun mit dem Vereinigen zweier Binomial-Heaps. Wir haben gesehen, daß diese Operation als Basis für B INOM -I NSERT und B INOM -E XTRACT-M IN genutzt werden kann.
40
Haufenweise Haufen: Heaps, d-Heaps, Intervall-Heaps, Binomial-Heaps und Leftist-Heaps
Algorithmus 2.16 Löschen des Minimums in einen Binomial-Heap. B INOM - EXTRACT- MIN(H) 1 x ← min[H] 2 Entferne x aus der Wurzelliste. { Dabei wird der gesamte Binomialbaum, der an x hängt, aus dem Heap entfernt. } 3 Aktualisiere min[H] auf das neue Minimum in der aktuellen Wurzelliste. { Dies erfolgt durch einmaliges Durchlaufen der Wurzelliste. } 4 H 0 ← B INOM -M AKE () 5 L ← Liste der Söhne von x in umgekehrter Reihenfolge. ( Die Grade der Söhne von x sind absteigend: Wenn x Wur- ) zel eines Bk ist, dann sind die Söhne von links nach rechts Wurzeln von Bk−1 , . . . , B0 , siehe Lemma 2.14 (iv). 6 head[H 0 ] ← L 7 H ← B INOM -M ELD (H, H 0 ) 8 return x
min[H]
min[H]
head[H] 10
5
2
13 15
11
9
18
10
head[H] 10
12
5
2
13 15
20
11
9
18
10
12
20
(a) Der Ausgangsheap.
(b) Das Minimum wurde samt seines daran wurzelnden Baumes entfernt. Der Zeiger auf das (temporäre) Minimum wurde aktualisiert.
min[H] min[H 0 ]
min[H] head[H] 10
5 13
head[H 0 ] 12
head[H] 10 9 10
11 15
18
20 (c) Es wird eine Liste der Söhne des alten Minimums in umgekehrter Reihenfolge erstellt und damit ein neuer Binomialheap H 0 generiert.
5
12 15
11
9
18
10
13
20 (d) B INOM -M ELD(H, H 0 ) dann das obige Endresultat.
Abbildung 2.19: Extrahieren des Minimums aus einem Binomial-Heap.
ergibt
2.5 Binomial-Heaps Im Prinzip funktioniert das Vereinigen zweier Binomial-Heaps H1 und H2 wie das Addieren von zwei Binärzahlen mittels der »Schulmethode«. Zur Erinnerung: die Größen der Binomialbäume in den Wurzellisten von Hi sind Zweierpotenzen, und wir können zwei Binomialbäume gleicher Größe zu einem neuen Binomialbaum doppelter Größe verschmelzen, indem wir den einen Baum an die Wurzel des zweiten anhängen (genau wie in der rekursiven Definition der Bk ). Wir betrachten die Binomialbäume von H1 und H2 der Reihe nach in aufsteigender Größe. Wir nehmen an, daß Hi genau ni Knoten enthält. Wie bei der Addition von Binärzahlen betrachten wir in jedem Schritt zwei Binomialbäume gleicher Größe und eventuell einen als »Übertrag« erhaltenen Binomialbaum. Anfangs hat man keinen Übertrag. Im iten Schritt hat man alsP Operanden einen Binomialbaum Bi von H1 , wenn in der Binärdarstellung i von n1 = i bi 2 das Bit bi = 1 ist. Analog haben wir einen Binomialbaum aus der Wurzelliste von H2 , falls das entsprechende Bit in der Binärdarstellung von n2 gesetzt ist. Als dritten Operanden haben wir einen möglichen Übertrag. Tritt kein Operand auf, so ist im Resultat ebenfalls kein Baum Bi vorhanden. Tritt ein Operand Bi auf, so enthält das Resultat genau diesen Baum Bi . Bei zwei Operanden werden diese zu einem Bi+1 zusammengefasst und als Übertrag in die nächste Stelle weitergereicht, der Eintrag an Stelle i bleibt leer. Im letzten Fall treten alle drei Operanden auf, so wird einer zur iten Stelle des Ergebnisses und zwei werden zu einem B i+1 , der wiederum in den Übertrag geht. Algorithmus 2.18 zeigt das eben beschriebene Vereinigen B INOM -M ELD zweier BinomialHeaps im Pseudocode. Abbildungen 2.21 und 2.22 zeigen ein Beispiel. Unsere Implementierung von B INOM -M ELD benötigt das elementare Unterprogramm B INOM -L INK. Weiterhin setzen wir zur Vereinfachung der Notation voraus, daß degree[NULL] := +∞ und key[NULL] := +∞ gesetzt sind. B INOM -L INK (y, z) (siehe Algorithmus 2.17 und Abbildung 2.20 für eine Illustration) macht y zum ersten Sohn von z. Dabei wird angenommen, daß bei Aufruf der Prozedur y und z Wurzeln zweier verschiedener Bk sind. Als Resultat ist dann z Wurzel eines Bk+1 . Wie man aus der Implementierung unmittelbar ablesen kann, läuft B INOM -L INK in O(1) Zeit. Algorithmus 2.17 Anhängen eines Binomialbaums mit Wurzel y an einen Binomialbaum mit Wurzel z. B INOM -L INK(H, y, z) 1 if y = min[H] then 2 Vertausche yund z. Der obige Test ist höchstens dann erfolgreich, wenn y und z den gleichen Schlüsselwert besitzen. In diesem Fall wollen wir auf jeden Fall den Zeiger min[H] gültig halten, d.h. er soll weiterhin auf eine Wurzel zeigen. 3 end if 4 p[y] ← z 5 right[y] ← child[z] 6 child[z] ← y 7 degree[z] ← degree[z] + 1 Die Laufzeit von B INOM -M ELD läßt sich wie folgt abschätzen. Hat Hi genau ni Knoten, so besitzt Hi nach Lemma 2.17 (i) nur O(log ni ) Binomialbäume in der Wurzelliste. Da B INOM -L INK nur O(1) Zeit benötigt und wir pro Binärstelle in der Binomialdarstellung von n1 und n2 neben konstant vielen B INOM -L INK auch sonst nur O(1) Zeit investieren, läuft B INOM -M ELD in O(log n1 + log n2 ) = O(log n) Zeit.
41
42
Haufenweise Haufen: Heaps, d-Heaps, Intervall-Heaps, Binomial-Heaps und Leftist-Heaps Algorithmus 2.18 Vereinigen zweier Binomial-Heaps. B INOM -M ELD(H1 , H2 ) 1 { Zur Erinnerung: key[NULL] = +∞ und degree[NULL] = +∞. } 2 Setze min[H] auf die Wurzel min[H1 ] oder min[H2 ] mit geringerem Schlüsselwert. 3 H ← B INOM -M AKE () 4 x1 ← head[H1 ] { Zeiger auf den aktuellen Baum in der Liste von H1 . } 5 x2 ← head[H2 ] { analog für H2 . } 6 carrybit_tree ← NULL { Zeiger auf den Übertragsbaum. } 7 while x1 6= NULL oder x2 6= NULL oder carrybit_tree 6= NULL do 8 if Einer der beiden Zeiger xi ist gleich NULL und carrybit_tree = NULL. then { Test aus Effizienzgründen, siehe Text. } 9 Sei o.B.d.A. x1 = NULL. Hänge den Rest der Wurzelliste von H2 , auf die x2 zeigt, an die Wurzelliste von H an. Das Anhängen der kompletten Restliste benötigt nur konstante Zeit, da es durch das Umhängen eines Zeigers erfolgt. 10 return H 11 end if 12 Sei k der minimale Grad der Bäume, auf die x1 , x2 und carrybit_tree zeigen. 13 14 15 16 17 18 19
20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47
{ 1. Fall: Genau ein Baum mit Grad k ist vorhanden. } if genau einer der Bäume x1 , x2 , carrybit_tree hat Grad k then Sei x der Baum. { Genauer: ein Zeiger auf den Baum. } Füge x hinten an die Wurzelliste von H an. if x = x1 oder x = x2 then Entferne x = xi aus der entsprechenden Wurzelliste und setze xi ← right[xi ]. Beim Entfernen ist xi das vorderste Element der Liste von Hi , so daß das Entfernen in konstanter Zeit möglich ist. end if carrybit_tree ← NULL end if { 2. Fall: Genau zwei Bäume mit Grad k sind vorhanden. } if genau zwei Bäume haben Grad k then Seien dies y und z. if key[z] < key[y] then carrybit_tree ← B INOM -L INK (H, y, z) else carrybit_tree ← B INOM -L INK (H, z, y) end if Entferne analog zu oben bei Bedarf die Bäume aus den Wurzellisten von H 1 und H2 . end if { 3. Fall: Drei Bäume mit Grad k sind vorhanden. if alle drei Bäume haben Grad k then Füge carrybit_tree hinten an die Wurzelliste von H an. Entferne x1 und x2 aus den Wurzellisten von H1 und H2 . if key[x2 ] < key[x1 ] then carrybit_tree ← B INOM -L INK (H, x1 , x2 ) else carrybit_tree ← B INOM -L INK (H, x2 , x1 ) end if x1 ← right[x1 ] x2 ← right[x2 ] end if end while
}
2.5 Binomial-Heaps
43
y
z
···
···
(a) Die beiden Bionomialbäume
z y
···
···
(b) Das Resultat von B INOM -L INK(H, y, z)
Abbildung 2.20: Illustration von B INOM -L INK(H, y, z). Der Binomialbaum mit Wurzel y wird an den Binomialbaum mit Wurzel z angehängt. Besondere Erwähnung verdient der Test in Zeile 9: falls eine der beiden Wurzellisten leer wird und kein Übertrag mehr besteht, so hängen wir den Rest der zweiten Wurzelliste in einem Rutsch an die Ergebnis-Wurzelliste an. Dies hat Effizienzvorteile, wie wir in Abschnitt 2.5.6 noch genauer sehen werden.
2.5.6 Konstruieren eines Binomial-Heaps Ein Binomial-Heap zu einer vorgegebenen Menge von n Elementen wird durch iteratives Einfügen der Elemente mittels B INOM -I NSERT erzeugt. Aus unseren bisherigen Überlegungen erhalten wir sofort die Zeitkomplexität O(n log n) für diese Operation, die wir im folgenden mit B INOM -B UILD bezeichnen. Eine etwas genauere Analyse liefert uns jedoch eine bessere Zeitkomplexität. Dazu erinnern wir uns an Lemma 2.17 (i): Ist H ein Binomal-Heap mit k Elementen und k = Pblog2 kc i bi 2 die Binärdarstellung von k, so besitzt H genau dann einen Binomialbaum i=0 der Ordnung i in der Wurzelliste, wenn bi = 1 gilt. Was bedeutet das für das Einfügen von Elementen? Betrachten wir das Einfügen des kten Elements x. Das Einfügen hatten wir mittels der B INOM -M ELD Prozedur implementiert, indem wir zunächst einen einelementigen Binomialheap H 0 für x erzeugen und dann H 0 mit dem aktuell bestehenden Heap H für die ersten k − 1 Elemente vereinigen. Beim Vereinigen werden die Binomialbäume in den Wurzellisten von H und H 0 analog zur Addition von Binärzahlen »addiert«. Ist nun k − 1 gerade, so hat H keinen Binomialbaum der Ordnung 0. Da die Wurzelliste von H 0 sowieso nur aus dem einen Baum der Ordnung 0 besteht, benötigt das Einfügen für gerades k − 1, also ungerades k, nur O(1) Zeit: das »Addieren« der Wurzellisten bricht nach dem ersten Schritt ab. Die obige Argumentation zeigt, daß für ungerades k, also etwa n/2 Elemente, nur
44
Haufenweise Haufen: Heaps, d-Heaps, Intervall-Heaps, Binomial-Heaps und Leftist-Heaps
min[H1 ]
min[H2 ]
head[H1 ] 10
5
min[H]
head[H2 ] 12
13
9
11
10
15
head[H] NULL
18
20 (a) Die zwei Ausgangsheaps. Die schwarzen Knoten kennzeichnen die Zeiger x 1 und x2 . Der Zeiger für das Minimum des Resultatheaps H wurde bereits gesetzt. Der Überlaufzeiger carrybit_tree ist gleich NULL, zwei Bäume haben Grad k = 0.
min[H] head[H1 ]
5
head[H2 ]
13
9 10
11 15
head[H]
18
20
carrybit_tree 10 12 (b) Die zwei Bäume von Grad 0 wurden zusammengefügt. Nun haben drei Bäume den Grad k = 1.
min[H] head[H1 ]
NULL
head[H2 ] 15
11
head[H] 10
18
12
20
5
carrybit_tree 9
13
10 (c) Der carrybit_tree wurde in die Wurzelliste von H eingefügt, die zwei anderen Bäume von Grad 1 wurden zusammengefügt. Wir haben zwei Bäume mit Grad k = 2.
Abbildung 2.21: Beispiel für B INOM -M ELD(H1 , H2 ).
2.5 Binomial-Heaps
45
min[H] head[H1 ]
NULL
head[H2 ]
head[H] 10
NULL
12
5
carrybit_tree
15
11
9
18
10
13
20 (a) Wir haben einen Baum vom Grad k = 3. Nach dem nächsten Schritt terminiert das Verfahren.
min[H] head[H1 ]
NULL
head[H2 ]
NULL
head[H] 10
5
12 15
11
9
18
10
13
20
carrybit_tree
NULL
(b) Im letzten Schritt wurde der Übertragsbaum an die Wurzelliste von H angehängt. Dies ergibt das obige Endresultat.
Abbildung 2.22: Fortsetzung des Beispiels für B INOM -M ELD(H1 , H2 ).
46
Haufenweise Haufen: Heaps, d-Heaps, Intervall-Heaps, Binomial-Heaps und Leftist-Heaps O(1) Zeit benötigt wird. Somit ist ein geschätzter Aufwand von O(log n) für jede der n iterativen Einfügeoperationen viel zu grob. Wir leiten jetzt eine schärfere Schranke in etwas allgemeinerer Form her. Sei H ein Binomial-Heap mit m Elementen, in den iterativ n Elemente eingefügt werden (B INOM -B UILD ist der Spezialfall mit m = 0). Der Gesamtaufwand ist offenbar linear in der Anzahl der gesamten Additionen von Binomialbäumen während des Einfügens. Man beachte, daß jede Addition von zwei Binomialbäumen eine neue Kante erzeugt. Also finden beim Einfügen insgesamt (m + n − v(n + m)) {z } |
Kanten nachher, siehe Lemma 2.17 (iv)
− (m − v(m)) = n + v(m) − v(n + m) ∈ O(n + log m) | {z } Kanten vorher
Additionen statt. Folglich ist der Gesamtaufwand ebenfalls O(n + log m). Für B UILD H EAP erhalten wir daher einen linearen Zeitaufwand von O(n).
2.6 Leftist-Heaps In diesem Abschnitt stellen wir eine weitere Datenstruktur zur Verfügung, die im wesentlichen die gleichen Komplexitäten für die Prioritätsschlangenoperationen garantiert wie die Binomial-Heaps. Die sogenannten linkslastigen Heaps (engl. Leftist-Heaps) ermöglichen es uns aber durch Einführen von sogenanntem verzögertem Meld (»lazy meld«), den Algorithmus von Boruvka noch schneller zu implementieren. Bemerkung 2.18 In diesem Abschnitt wird nicht beschrieben, wie man D ECREASE -K EY in Leftist-Heaps implementiert. In der Tat sind Leftist-Heaps nicht sehr gut geeignet, um D ECREASE -K EY-Operationen auszuführen (vgl. hierzu auch Abbildung 2.24). Sei T ein binärer Baum, d.h. ein Baum, in dem jeder Knoten maximal zwei Söhne besitzt. Unser binärer Baum sei mit Hilfe der Zeiger left und right organisiert. Dabei sind left[v] und right[v] Zeiger auf den linken bzw. rechten Sohn von v im Baum. Ein Zeiger ist NULL, falls kein entsprechender Sohn existiert. Wir definieren für einen Knoten v ∈ T den Pfad-Rang rank[v] als eins plus die Länge des kürzesten Weges von v zu einem Knoten mit höchstens einem Sohn in seinem Teilbaum. Formal setzen wir für einen Knoten v mit maximal einem Sohn rank[v] := 1 und für alle anderen Knoten w dann rank[w] = 1 + min{rank[left[v]], rank[right[v]]}. Um zahlreiche Fallunterscheidungen zu vermeiden, definieren wir rank[NULL] := 0 und key[NULL] := +∞. Abbildung 2.23 zeigt einen binären Baum und die Ränge der Knoten. 2 3 1
1 4
6 1
1 14 1
2 10
12
1
1 16
11
20
Abbildung 2.23: Ein heap-geordneter Baum. Die Zahlen außerhalb der Knoten bezeichnen die Pfad-Ränge der Knoten. Wir nennen einen heap-geordneten Baum einen linksgerichteten Heap oder einen LeftistHeap, falls für jeden Knoten v gilt: rank[left[v]] ≥ rank[right[v]]. Intuitiv ist ein
2.6 Leftist-Heaps Leftist-Heap »linkslastig», da in jedem Teilbaum das meiste Gewicht links liegt. Der Baum aus Abbildung 2.23 ist ein Leftist-Heap. In einem Leftist-Heap führt das iterative Verfolgen der rechten Söhne zu einem kürzesten Weg zu einen Nachfolger mit höchstens einem Sohn. Wir nennen den kürzesten Weg von der Wurzel zu einem Nachfolger mit höchstens einem Sohn daher auch den rechten Weg des Leftist-Heaps. Man zeigt leicht durch Induktion nach k, daß ein Leftist-Heap, dessen Wurzel Pfad-Rang k besitzt, mindestens 2k − 1 Knoten enthält. Folglich ist der rechte Weg in einem Leftist-Heap mit n Knoten nur O(log n) lang.
Achtung, Leftist-Heaps sind nicht notwendigerweise balanciert. Für einen Knoten in einem Leftist-Heap mit n Elementen kann sein Pfad zur Wurzel des Heaps Länge Ω(n) besitzen (siehe auch Abbildung 2.24). Daher ist das »übliche Einfügen«, bei dem ein Element als Blatt eingefügt und dann hochgeschoben wird, bei den Leftist-Heaps keine gute Lösung (dies ist auch ein Grund, warum wir D ECREASE -K EY in Leftist-Heaps nicht als gut unterstützt bezeichnen). 1 4 1 8 1 9 1 11 1 18 1 42
Abbildung 2.24: Ein Leftist-Heap muß nicht notwendigerweise balanciert sein. Für einen Knoten kann sein Pfad zur Wurzel des Heaps Länge Ω(n) besitzen. In unserer Implementierung des Leftist-Heaps speichert jeder Knoten neben den Zeigern auf die Söhne und dem Schlüsselwert noch seinen Rang. Wir halten uns noch einen Zeiger root[H] auf die Wurzel des Heaps. Das Erstellen eines leeren Leftist-Heaps L EFTISTM AKE ist daher extrem einfach, siehe Algorithmus 2.19, und benötigt nur konstante Zeit. Algorithmus 2.19 Erstellen eines leeren Leftist-Heaps. L EFTIST-M AKE() 1 root[H] ← NULL Wir führen wieder die Operationen I NSERT und E XTRACT-M IN auf die Operation M ELD zurück. Wir können ein Element x in einen Leftist-Heap H einfügen, indem wir zunächst einen Leftist-Heap H 0 mit einem Element erzeugen. Dann verschmelzen wir H und H 0 mittels L EFTIST-M ELD(H, H 0 ). Der Zeitaufwand für I NSERT ist dann von der gleichen Größenordnung wie für L EFTIST-M ELD(H, H 0 ). Das Einfügen ist im Pseudocode in Algorithmus 2.20 zu sehen. Zum Extrahieren des Minimums E XTRACT-M IN (H) entfernen wir einfach die Wurzel und verschmelzen den linken und rechten Teilbaum der Wurzel (siehe Algorithmus 2.21). Wiederum ist der Zeitaufwand von der gleichen Größenordnung wie für L EFTIST-M ELD. Wir kommen jetzt zur Implementierung von L EFTIST-M ELD (siehe Algorithmus 2.22). Zum Verschmelzen von zwei Leftist-Heaps H1 und H2 mit n1 bzw. n2 Elementen bestim-
47
48
Haufenweise Haufen: Heaps, d-Heaps, Intervall-Heaps, Binomial-Heaps und Leftist-Heaps Algorithmus 2.20 Einfügen in einen Leftist-Heap. L EFTIST-I NSERT(H, x) 1 left[x] ← NULL 2 right[x] ← NULL 3 rank[x] ← 1 4 root[H 0 ] ← x 5 L EFTIST-M ELD(H, H 0 ) Algorithmus 2.21 Extrahieren des Minimums eines Leftist-Heaps. L EFTIST-E XTRACT-M IN(H, x) 1 r ← root[H] 2 root[H1 ] ← left[r] 3 root[H2 ] ← right[r] 4 L EFTIST-M ELD(H1 , H2 )
men wir die rechten Pfade der Heaps und verschmelzen diese so, daß die Elemente auf dem Resultatpfad absteigend geordnet sind. Dies ist in O(log n1 + log n2 ) Zeit möglich, da jeder der beiden rechten Pfade bereits absteigend sortiert ist (die Leftist-Heaps sind heapgeordnet) und beide rechten Pfade logarithmische Länge haben. Als nächstes berechnen wir die Ränge der Knoten auf dem Resultatpfad neu und stellen die Leftist-Eigenschaft durch Vertauschen von linken und rechten Söhnen wieder her. Auch dafür benötigen wir nur O(log n1 + log n2 ) Zeit. Die Abbildungen 2.25 und 2.26 zeigen ein Beispiel für das Verschmelzen von zwei Leftist-Heaps. Zum Erstellen eines Leftist-Heaps aus n Elementen könnten wir die Elemente nacheinander in den anfangs leeren Heap einfügen. Das liefert uns aber wiederum nur eine Komplexität von O(n log n) für L EFTIST-B UILD. Man kann diese Zeitschranke wieder auf O(n) verbessern. Dazu benutzen wir die Funktion H EAPIFY(L), die aus einer Liste L von LeftistHeaps einen gemeinsamen Leftist-Heap durch geschicktes Verschmelzen baut, siehe Algorithmus 2.23. Wir analysieren zunächst die Zeitkomplexität von L EFTIST-H EAPIFY(L). Sei k die Anzahl der Heaps in der Liste L und n die Gesamtanzahl der Elemente. Wir betrachten den ersten Durchlauf durch die Liste L. Nach dk/2e Aufrufen von L EFTIST-M ELD ist jeder der k Ausgangsheaps mit einem anderen Heap verschmolzen worden. Uns verbleiben noch bk/2c Heaps. Sei ni , i = 1, . . . , bk/2c die Anzahl der Elemente im iten dieser Heaps. Pbk/2c Dann ist n = i=1 ni . Wir wissen, daß ein L EFTIST-M ELD , das zu einem Heap mit ni Elementen führt, in O(log ni ) Zeit ausgeführt werden kann. Daher ist die Gesamtzeit für die dk/2e Aufrufe von L EFTIST-M ELD:
bk/2c
O
X i=1
max{1, log ni } .
(2.6)
Pbk/2c Aus i=1 ni = n und 0 ≤ ni ≤ n folgt dann, daß der Term in (2.6) von der Größenordnung O(k max{1, log nk }) ist. Wir haben somit gezeigt, daß der erste Lauf durch die Liste O(k max{1, log nk }) Zeit benötigt und die Anzahl der Heaps in der Liste für den nächsten Durchlauf auf bk/2c mindestens halbiert. Allgemein sind nach dem iten Durchlauf noch bk/2 i c Heaps übrig. Daher sind nach spätestens blog2 kc Durchläufen noch zwei Heaps übrig und der Algorithmus
2.6 Leftist-Heaps
Algorithmus 2.22 Verschmelzen zweier Leftist-Heaps. L EFTIST-M ELD(H1 , H2 ) ( Der Algorithmus L EFTIST-M ELD dient eigentlich nur der Abfrage der Spezialfäl- ) le, daß einer der beiden Heaps leer ist. Die eigentliche Arbeit wird in L EFTIST1 M ESH erledigt. 2 if root[H1 ] = NULL then 3 root[H] ← root[H2 ] 4 else 5 if root[H2 ] = NULL then 6 root[H] ← root[H1 ] 7 end if 8 else 9 L EFTIST-M ESH(H1 , H2 ) { L EFTIST-M ESH zerstört H1 und H2 und legt das Ergebnis in H1 ab. } 10 root[H] ← root[H1 ] 11 end if L EFTIST-M ESH(H1 , H2 ) 1 r1 ← root[H1 ] { Ein Zeiger auf die Wurzel von H1 . } 2 r2 ← root[H1 ] { Ein Zeiger auf die Wurzel von H2 . } 3 if key[r1 ] > key[r2 ] then { r1 ist danach die Wurzel mit kleinerem Schlüsselwert. } 4 Vertausche r1 und r2 . 5 end if 6 { Zur Erinnerung: r1 ist die Wurzel mit kleinerem Schlüsselwert, siehe oben. } 7 if right[r1 ] = NULL then { Falls r1 keinen rechten Sohn hatte, dann reduziert sich das Verschmelzen der rechten Pfade auf das Anhängen des rechten Pfades von H2 rechts an r1 . } 8 right[r1 ] ← r2 9 else 10 right[r1 ] ← L EFTIST-M ESH(right[r1 ], r2 ) 11 end if 12 if rank[left[r1 ]] < rank[right[r1 ]] then 13 Vertausche left[r1 ] und right[r1 ]. 14 end if 15 rank[r1 ] ← rank[right[r1 ]] + 1 16 return r1
49
50
Haufenweise Haufen: Heaps, d-Heaps, Intervall-Heaps, Binomial-Heaps und Leftist-Heaps 2
2 2
5 1
2 4 1
1 14
1
6 2
1 10
12
30 1
1
1
8 1
15
16
11
20
(a) Die beiden Ausgangsheaps. Die rechten Pfad in den Heaps sind gestrichelt und durch weiße Knoten hervorgehoben.
2 3 2
2 4
5 1
1 14
1
1
12
15
1
6
1
1
2
16
30
10
8 1
1 20
11
(b) Die beiden rechten Pfade der Heaps wurden so verschmolzen, daß auf dem Ergebnispfad die Schlüsselwerte absteigend sortiert sind.
2 3 2
2 4
5 1
1 14
2
1
12
15
1
6
1 16
1
2 30
10
8 1
1 20
11
(c) Wir starten mit dem letzten Knoten auf dem Resultatpfad und berechnen seinen Pfad-Rang neu (was in unserem Beispiel kein neues Ergebnis liefert).
2 3 2
2 4
5 1
1 14 1
2
1
12
15
6
1 16
1
2 30
10
8 1
1 11
20
(d) Wir steigen nun den Resultatpfad zur Wurzel hinauf. Man geht für den aktuellen Knoten davon aus, daß die Ränge seiner Söhne bereits korrekt berechnet sind. Dabei ist zu beachten, daß sich ja nur der Rang eines Sohnes geändert hat. Falls der Rang des rechten Sohnes größer als der des linken Sohnes ist, werden die beiden Söhne vertauscht. Diese Vertauschung ist im aktuellen Knoten nicht nötig. Danach wird der Rang des Knotens neu berechnet.
Abbildung 2.25: Verschmelzen von zwei Leftist-Heaps.
2.6 Leftist-Heaps
51
2 3 2
2 4
5 1
1 14 1
1
2
12
6
15 1
2 16
10
1
8
30
1
1 11
20
(a) Im Knoten 5 müssen zum ersten Mal die beiden Söhne vertauscht werden. Gezeigt ist hier schon das Ergebnis.
3 3 2
2 4
5 1
1 14 1
1
2 6
12
15 1
2 16
10
8
1 30
1
1 11
20
(b) Das Verfahren terminiert in der Wurzel, in der ebenfalls der Pfad-Rang neu berechnet wird.
Abbildung 2.26: Fortsetzung: Verschmelzen von zwei Leftist-Heaps. Algorithmus 2.23 Konstruieren eines Leftist-Heaps aus einer Menge S von Elementen. Die wichtigste Funktion ist L EFTIST-H EAPIFY. L EFTIST-B UILD -H EAP(S) 1 L←∅ { Eine leere Liste. } 2 for all s ∈ S do 3 Mache aus s einen einelementigen Leftist-Heap und hänge ihn hinten an L an. 4 end for 5 L EFTIST-H EAPIFY(L) L EFTIST-H EAPIFY(L) 1 if |L| = 0 then 2 return NULL { Der leere Leftist-Heap. } 3 end if 4 while |L| ≥ 2 do { Solange noch mehr als ein Heap in der Liste ist. . . } 5 Entferne die ersten beiden Heaps H1 und H2 aus L. { Dies ist in konstanter Zeit möglich, da wir die Elemente von vorne aus der Liste entfernen. } 6 L EFTIST-M ELD(H1 , H2 ) 7 Hänge das Ergebnis hinten wieder an L an. 8 end while 9 return den einzelnen verbleibenden Heap in L.
52
Haufenweise Haufen: Heaps, d-Heaps, Intervall-Heaps, Binomial-Heaps und Leftist-Heaps Operation M AKE I NSERT M INIMUM E XTRACT-M IN M ELD B UILD
Leftist-Heap O(1) O(log n) O(1) O(log n) Θ(n) O(n)
Leftist-Heap mit verzögertem Verschmelzen O(1) O(1) O k · max 1, log nk O k · max 1, log nk O(1) O(n)
Tabelle 2.3: Zeitkomplexität der Prioritätsschlangen-Operationen bei Implementierung durch einen Leftist-Heap der Größe n und einen Leftist-Heap der Größe n mit verzögertem Verschmelzen. Der Parameter k bei M INIMUM/E XTRACT-M IN bezeichnet die Anzahl der Knoten, die bei M INIMUM/E XTRACT-M IN aus dem Heap entfernt werden. terminiert. Der Aufwand ist dann blog2 kc o n X k n = O k · max 1, log n O . · max 1, log 2i k/2i k i=1 Da wir nun die Zeitkomplexität für L EFTIST-H EAPIFY kennen, ist die Bestimmung des Aufwandes für L EFTIST-B UILD nicht mehr schwierig. Für das Erstellen von n einelementigen Heaps benötigen wir O(n) Zeit. Danach lassen wir L EFTIST-H EAPIFY mit k = n Heaps in der Liste und insgesamt n Elementen laufen, so daß wir eine Gesamtlaufzeit von O(n · max{1, log nn }) = O(n) erhalten. Die Laufzeiten für die einzelnen Prioritätsschlangen-Orperationen in Leftist-Heaps sind in Tabelle 2.3 aufgeführt. Dabei sind auch die Werte für eine Abwandlung des Leftist-Heaps, nämlich des Leftist-Heaps mit verzögertem Verschmelzen, angegeben, mit dem wir uns im nächsten Abschnitt beschäftigen.
2.6.1 Verzögertes Verschmelzen Beim verzögerten Verschmelzen von zwei Leftist-Heaps H1 und H2 werden die beiden Heaps nicht sofort anhand ihrer rechten Wege zusammengeordnet. Wir schieben die wirkliche Arbeit erst einmal auf und führen einen »Dummy-Knoten«als neue Wurzel ein, der die beiden Heaps H1 und H2 als Söhne hat (siehe Abbildung 2.27)). Dabei müssen wir lediglich darauf achten, daß der Heap mit dem größeren Grad in der Wurzel zum linken Sohn wird. Algorithmus 2.24 zeigt das Verfahren im Pseudocode. Offenbar läuft L EFTISTL AZY-M ELD in O(1) Zeit. Das Verschmelzen zweier Leftist-Heaps ist mit dem sehr bequemen L EFTIST-L AZY-M ELD sehr schnell geworden. Allerdings müssen wir für diese Bequemlichkeit einen Preis bezahlen: nach ein paar Verschmelzungen enthält unser Heap zahlreiche Dummy-Knoten. Insbesondere ist nun nicht mehr garantiert, daß in der Wurzel unseres Heaps das Element mit minimalem Schlüsselwert steht. Bei einer M INIMUM-Anfrage müssen wir den Heap erst einmal nach dem Minimum durchforsten. Haben wir uns etwa mehr Schwierigkeiten eingehandelt als wir Nutzen bekommen haben? Zunächst zeigen wir, wie wir L EFTIST-L AZY-M INIMUM, d.h. das Finden des Minimums in einem Leftist-Heap, mit verzögertem Verschmelzen effizient implementieren können. Wir erstellen eine Liste L von allen Nicht-Dummy-Knoten mit der Eigenschaft, daß alle ihre Vorfahren im Heap Dummy-Knoten sind, und löschen zugleich alle Dummy-Knoten, die nur Dummy-Knoten als Vorfahren haben. Dann verschmelzen wir die (disjunkten!) Teilbäume mit Wurzeln in L mittels L EFTIST-H EAPIFY.
2.6 Leftist-Heaps
53
2
1 4
3
1
1
2 8
6
4 1
1
1 9
14
2
12
10 1
1
1 16
11
20
(a) Die beiden Ausgangsheaps.
2 2
1 3
4
2
2
4 1
1 14 1
8
2
12
1 9
10 1
1 16
1
6
11
20
(b) Ein speziell markierter Dummy-Knoten wird als neue Wurzel des Resultatheaps eingeführt. Die Wurzel der Ausgangsheaps mit den größeren Pfad-Rang wird der linke Sohn der neuen Wurzel, die andere Wurzel wird zum rechten Sohn. Somit ist im entstehenden Heap die Leftist-Eigenschaft gesichert.
Abbildung 2.27: Verzögertes Verschmelzen von Leftist-Heaps.
54
Haufenweise Haufen: Heaps, d-Heaps, Intervall-Heaps, Binomial-Heaps und Leftist-Heaps Algorithmus 2.24 Verzögertes Verschmelzen von Leftist-Heaps. L EFTIST-L AZY-M ELD(H1, H2 ) Aus Effizienzgründen fragen wir die Sonderfälle, in denen einer der beiden Heaps 1 leer ist, gesondert ab. 2 if root[H1 ] = NULL then 3 root[H] ← root[H2 ] 4 else 5 if root[H2 ] = NULL then 6 root[H] ← root[H1 ] 7 end if 8 else 9 r ← neuer Knoten mit Dummy-Knoten-Kennzeichnung ( Die Dummy-Knoten-Kennzeichnung kann entweder durch ein ) zusätzliches Feld mark[v] = true in jedem Knoten v oder durch Setzen von key[v] := −∞ erfolgen. 10 r1 ← root[H1 ] { Ein Zeiger auf die Wurzel von H1 . } 11 r2 ← root[H1 ] { Ein Zeiger auf die Wurzel von H2 . } 12 if rank[r1 ] < key[r2 ] then { r1 ist danach die Wurzel mit größerem Rang. } 13 Vertausche r1 und r2 . 14 end if 15 { Zur Erinnerung: r1 ist die Wurzel mit größerem Pfad-Rang, siehe oben. } 16 left[r] ← r1 17 right[r] ← r2 18 rank[r] ← rank[r2 ] + 1 19 end if
Zum Erstellen der Liste L (Unterprogramm L EFTIST-P URGE) starten wir in der Wurzel des Heaps und durchlaufen den Heap dann in Prä-Order: rekursiv wird ein Knoten v, dann sein linker Teilbaum und danach sein rechter Teilbaum durchlaufen. Wir brechen die Rekursion in einem Teilbaum ab, sobald wir einen Nicht-Dummy-Knoten gefunden haben. Wenn durch den Aufruf von L EFTIST-P URGE k Knoten aus dem Heap gelöscht werden, so enthält die Liste L maximal 2k Knoten: jeder Knoten in L hat als Vater einen der gelöschten Knoten, und jeder Knoten im Heap hat maximal zwei Söhne. Damit benötigt dann L EFTIST-H EAPIFY O k · max 1, log nk Zeit. Folglich ist der gesamte Zeitaufwand für L EFTIST-L AZY-M INIMUM O k · max 1, log nk . Der Pseudocode für L EFTIST-L AZY-M INIMUM ist in Algorithmus 2.25 dargestellt, Abbildung 2.28 zeigt ein Beispiel. Im Code von Algorithmus 2.25 wird in Zeile 4 auf ein Unterprogramm D ELETED zurückgegriffen. Im Moment benötigen wir nur das triviale Unterprogramm, das immer false zurückgibt. Wir werden im Abschnitt 2.6.2 ein hilfreiches Unterprogramm für eine spezielle Anwendung benutzen. Die Algorithmen 2.26 und 2.27 zeigen die Implementierungen der noch fehlenden Operationen zum Extrahieren des Minimums und zum Einfügen in einen Leftist-Heap mit verzögertem Verschmelzen. Die Zeitkomplexitäten sind unmittelbar ersichtlich und in Tabelle 2.3 mit den anderen Operationen zusammengefasst.
2.6.2 Nochmals der Algorithmus von Boruvka Wir benutzen nun die Leftist-Heaps mit verzögertem Verschmelzen, um eine noch schnellere Implementierung des Algorithmus von Boruvka (Algorithmmus 2.10 bzw. 2.11) zu erhalten. Unsere neue Implementierung ist in Algorithmus 2.28 angegeben. Im wesentli-
2.6 Leftist-Heaps
55
Algorithmus 2.25 Finden des Minimums in einem Leftist-Heap mit verzögertem Verschmelzen. L EFTIST-L AZY-M INIMUM(H) 1 r ← root[H] 2 L ← L EFTIST-P URGE(r) 3 L EFTIST-H EAPIFY(L) L EFTIST-P URGE(v) 1 if v = NULL then 2 return ∅ { Eine leere Liste. } 3 end if 4 if v ist kein Dummy-Knoten and not D ELETED(v) then 5 return Liste mit einzigem Element v 6 else 7 L1 ← L EFTIST-P URGE(left[v]) 8 L2 ← L EFTIST-P URGE(right[v]) 9 Lösche v. { v ist ein Dummy-Knoten. Hier wird der Speicherplatz für v wieder freigegeben. } 10 return L1 , L2 { Die Liste, die aus Aneinanderhängen von L1 und L2 entsteht. } 11 end if D ELETED(v) 1 return false
2
2 1
2 2
2 1 1 16
1
6 1
1
2
12
1
1
6
4
2 4 10 1
16
1 20
2
12
10
(b) Zwei Knoten, deren sämtliche Vorfahren Dummy-Knoten sind, werden gefunden. Die beiden in ihnen wurzelnden Teibäume werden L EFTISTH EAPIFY übergeben. Alle nicht mehr aufgeführten Dummy-Knoten werden durch L EFTIST-P URGE gelöscht.
1
1
20
(a) Der Ausgangsheap
2 4 2
1 6 1
2 10 1
1
12
16
1 20 (c) Endergebnis H EAPIFY
nach
L EFIST-
Abbildung 2.28: Finden des Minimums in einem Leftist-Heap mit verzögertem Verschmelzen.
56
Haufenweise Haufen: Heaps, d-Heaps, Intervall-Heaps, Binomial-Heaps und Leftist-Heaps Algorithmus 2.26 Extrahieren des Minimums in einem Leftist-Heap mit verzögertem Verschmelzen. L EFTIST-L AZY-E XTRACT-M IN(H) 1 L EFTIST-L AZY-M INIMUM(H) { Als Resultat steht nun im Wurzelknoten kein Dummy-Knoten und damit das Minimum im Heap. } 2 root[H1 ] ← left[r] 3 root[H2 ] ← right[r] 4 L EFTIST-L AZY-M ELD(H1 , H2 ) Algorithmus 2.27 Einfügen in einen Leftist-Heap mit verzögertem Verschmelzen. L EFTIST-L AZY-I NSERT(H, x) 1 left[x] ← NULL 2 right[x] ← NULL 3 rank[x] ← 1 4 root[H 0 ] ← x 5 L EFTIST-L AZY-M ELD(H, H 0 )
chen ist dies die gleiche Implementierung wie Algorithmus 2.11 (die Implementierung mit Binomial-Heaps) mit folgenden Unterschieden: • Wir benutzen Leftist-Heaps mit verzögertem Verschmelzen anstelle von BinomialHeaps. • In Algorithmus 2.11 mußten wir in Schritt 13 das Entfernen des Minimums aus einem Heap solange wiederholen, bis wir eine Kante gefunden hatten, deren Endpunkte in verschiedenen Komponenten Vi und Vj lagen. Bei unserer Implementierung mit Hilfe von Leftist-Heaps benutzen wir ein »implizites Löschen« solcher Kanten. Hier kommt das Unterprogramm D ELETED in L EFTIST-L AZY-M INIMUM ins Spiel: wir durchlaufen den Heap und löschen Dummy-Knoten. Gleichzeitig behandeln wir Kanten (u, v) mit D ELETED(u, v) = true wie Dummy-Knoten. Dabei ist unser einfaches Unterprogramm D ELETED in Algorithmus 2.28 dargestellt: eine Kante wird als Dummy-Knoten behandelt, wenn beide Endknoten in der gleichen Zusammenhangskomponente liegen. Mit dieser Änderung wird unser Code für den Algorithmus von Boruvka sogar noch ein wenig einfacher, da wir jedes Mal beim Extrahieren des Minimums wissen, daß die Kante zu unserem Teil des MSTs hinzugefügt werden kann. Wir analysieren nun die Laufzeit von Algorithmus 2.28. Sei Qi (i = 1, . . . , n) der LeftistHeap, der in Schritt 10 als ites vom Anfang der Liste entfernt wird und m i seine Größe. Sei außerdem Vi die zu Qi gehörende Eckenmenge und letztendlich ki die Anzahl der DummyKnoten und implizit als gelöscht markierten Kanten, die aus Qi entfernt werden, wenn in Schritt 11 das Minimum aus Qi entfernt wird. Wir unterteilen die Ausführung des Algorithmus in Phasen. Phase 0 besteht aus dem Bearbeiten aller Heaps, die zu Anfang in der Liste L stehen. Für j > 0 besteht Phase j aus dem Bearbeiten aller Heaps, die in Phase j − 1 zu L hinzugefügt wurden (vgl. hierzu auch die Markierungen im Beispiel in den Abbildungen 2.11 bis 2.14). Lemma 2.19 Ist Qk ein Heap aus Phase j und Vk die zugehörige Eckenmenge, so hat Vk mindestens 2j Knoten. Folglich stoppt Algorithmus 2.28 nach maximal blog 2 nc Phasen mit einem MST.
2.6 Leftist-Heaps
Algorithmus 2.28 Implementierung des Boruvka-Algorithmus mittels Leftist-Heaps mit verzögertem Verschmelzen. L EFTIST-MST-B ORUVKA(G, c) Input: Ein zusammenhängender ungerichteter Graph G = (V, E) mit n := |V | und m := |E| in Adjazenzlistendarstellung, eine Kantenbewertungsfunktion c: E → R Output: Ein minimaler aufspannender Baum T von G. 1 T ←∅ { Kanten im zu erstellenden MST. } 2 L←∅ { Eine doppelt verkettete Liste. } 3 for all vi ∈ V do 4 M AKE -S ET(vi ) { Vi ← {vi } } 5 Ei ← { (vi , v) ∈ E } 6 Qi ← L EFTIST-B UILD -H EAP(Ei ) { Ordne die von vi ausgehenden Kanten in einem Min-Heap. } 7 Füge Qi hinten an L an. 8 end for 9 while L hat mehr als ein Element do 10 Qi ← erstes Element in L { Qi werde dabei aus L entfernt. } 11 (u, v) ← L EFTIST-L AZY-E XTRACT-M IN(Q) Durch das »implizite Löschen«von Kanten unter Zuhilfenahme des Unterprogramms D ELETED (s.u.) innerhalb von L EFTIST-L AZY M INIMUM sind hier (im Gegensatz zur Implementierung mit Binomial- Heaps) die beiden Endpunkte u und v in verschiedenen Mengen. 12 T ← T ∪ {(u, v)} 13 Vi ← F IND -S ET(v) { Finde die Menge Vi mit v ∈ Vi . } 14 Vj ← F IND -S ET(v) { analog für v. } 15 Entferne die zu Vi und Vj gehörenden Heaps Qi und Qj aus L. 16 U NION (Vi , Vj ) { Ersetze Vi und Vj durch Vi ∪ Vj . } Einer der beiden Heaps, o.B.d.A. Qi , ist bereits aus L entfernt. Er ist derjenige Heap, den wir zu Anfang der Iteration der while-Schleife als erstes Element von L entfernt haben. Der zweite Heap Qj ist in L in konstanter Zeit auffindbar, wenn jede Menge Vk einen Zeiger auf den zugehörigen Heap Qk in L speichert. Das Entfernen eines Elements aus einer doppelt verketteten Liste ist in konstanter Zeit durchführbar. 17 Q ← L EFTIST-L AZY-M ELD(Qi , Qj ) 18 Füge Q hinten an L an. 19 end while D ELETED(u, v) Dieses Unterprogramm wird für L EFTIST-L AZY-M INIMUM benötigt. Wir löschen Kanten »implizit«: eine Kante, die in einem Heap gespeichert wird, wird wie ein 1 Dummy-Knoten (alternativ: gelöscht) behandelt, wenn beide Endpunkte in der gleichen Zusammenhangskomponente liegen. 2 if F IND -S ET (u) = F IND -S ET (v) then 3 return true 4 else 5 return false 6 end if
57
58
Haufenweise Haufen: Heaps, d-Heaps, Intervall-Heaps, Binomial-Heaps und Leftist-Heaps Beweis: Der Beweis folgt sofort durch Induktion nach j, da für j > 1 eine Menge in Phase j aus zwei Mengen der Phase j − 1 entstanden ist. 2 Wir beschränken nun die Gesamtgröße aller Heaps Qi , die jemals vom Algorithmus angefasst werden. Lemma 2.20 Es gilt: n−1 X i=1
mi ≤ (2m + 2n − 2) · blog2 nc
Beweis: Die zu zwei Heaps einer Phase gehörenden Eckenmengen sind disjunkt. Jede Kante ist in maximal zwei Heaps einer Phase gespeichert, maximal einmal für jeden ihrer beiden Endpunkte. Somit ist die Gesamtanzahl der nicht implizit gelöschten Kanten (die auch keine Dummy-Knoten sind) in den Heaps einer Phase höchstens 2m. Bei jedem verzögerten Verschmelzen mittels L EFTIST-L AZY-M ELD und jedem L EFTIST-L AZYE XTRACT-M IN wird maximal ein Dummy-Knoten in einen Heap eingefügt. Da wir nur jeweils n − 1 Verschmelzungen und Entfernen von Minima haben, haben wir somit in einer Phase insgesamt maximal n − 1 Dummy-Knoten, mit den normalen Kanten also maximal 2m + 2(n − 1) = 2m + 2n − 2 Elemente in den Heaps. Da wir nach Lemma 2.19 nur blog2 nc Phasen haben, folgt die Behauptung. 2 Wir haben nun alle wichtigen Hilfsmittel zur Laufzeitanalyse in der Hand. Wie bei der Implementierung mit Hilfe der Binomial-Heaps benötigen wir zum Erstellen der initialen Heaps O(m) Zeit (Leftist-Heaps können in linearer Zeit erstellt werden). Insgesamt finden n − 1 verzögerte Verschmelzungen statt, von denen uns jede O(1) Zeit kostet. Bis auf die L EFTIST-L AZY-E XTRACT-M IN-Operationen benötigen wir also O(n + m) Zeit.
Nach unseren Zeitschranken für L EFTIST-L AZY-E XTRACT-M IN benötigt das ite Entfernen des Minimums O(ki · max{log mi /ki } · α(n)) Zeit, da wir O(ki · max{log mi /ki }) Operationen und somit dabei auch ebenso oft D ELETED aufrufen (das jeweils zwei F IND S ET benötigt). Hierbei ist α(n) die Zeitkomplexität für ein F IND -S ET. Wir werden in Abschnitt 5.3 eine entsprechende Datenstruktur kennenlernen. Die Funktion α(n) wächst dabei extrem langsam (deutlich langsamer als log log log log . . . log n).
mi Wir nennen das ite L EFTIST-L AZY-E XTRACT-M IN schnell, wenn ki ≤ (log n)2 , ansonsten heißt es langsam. Der Zeitaufwand für die schnellen L EFTIST-L AZY-E XTRACT-M INs ist höchstens ! ! n−1 n−1 X X mi mi O(ki · max log O · max {1, log mi } · α(n) · α(n)) = O ki (log n)2 i=1 i=1 ! n−1 X mi =O · α(n) (da mi ∈ O(n2 )) log n i=1
= O(m · α(n))
(nach Lemma 2.20).
Für die langsamen L EFTIST-L AZY-E XTRACT-M IN-Operationen ist nach Definition k i > mi (log n)2 und daher der gesamte Zeitaufwand beschränkt durch: ! ! n−1 n−1 X X mi mi O(ki · max 1, log O ki log · α(n)) = O · α(n) ki mi /(log n)2 i=1 i=1 ! n−1 X =O ki log log n · α(n) i=1
= O (m log log n · α(n)) .
2.6 Leftist-Heaps Für die letzte Abschätzung haben wir benutzt, daß jede der 2m in einem Heap gespeicherten Kanten (jede Kante ist maximal zweimal vorhanden) einmal und auch jeder der 2(n−1) Pn−1 Dummy-Knoten maximal einmal gelöscht werden kann, d.h. i=1 ki ≤ 2m + 2n − 2.
Beobachtung 2.21 Mit Hilfe von Leftist-Heaps mit verzögertem Verschmelzen und der Datenstruktur für disjunkte Mengen aus Abschnitt 5.3 benötigt der Algorithmus von Boruvka zur Bestimmung eines MST O(m log log n · α(n)) Zeit auf einem Graphen mit n Ecken und m Kanten.
59
60
Amortisierte Analyse Bei der amortisierten Analyse berechnet man die durchschnittlichen Worst-Case-Kosten einer Operation über eine ganze Folge von Operationen. Ziel ist es, daß »im Durchschnitt« die Kosten einer Operation niedrig liegen. Die Formulierung »im Durchschnitt« bedeutet hier das Mittel über eine Folge von Operationen im Worst-Case. Es findet hier keine probabilistische Analyse statt. In diesem Kapitel wenden wir die amortisierte Analyse auf zwei einfache Beispiele an. Unsere Analysetechnik benutzt dabei eine Potentialfunktion, die als »Bankkonto« benutzt wird, um teure gegen billige Operationen zu verrechnen.
3.1 Stack-Operationen Unser erstes (sehr einfaches) Beispiel ist ein Stack. Ein Stack ist ein Last-in-First-OutSpeicher S, auf dem die folgenden Operationen definiert sind: • P USH(S, x) legt das Objekt x oben auf den Stack. • P OP(S) liefert das oberste Objekt auf dem Stack und entfernt es vom Stack (wenn der Stack leer ist, dann bricht die Operation mit Fehler ab). Beide Operationen kosten O(1) Zeit. Wir erlauben jetzt noch eine weitere Operation M ULTIPOP(S, k), welche die obersten k Objekte des Stacks entfernt. Diese Operation benötigt O(k) Zeit. Wie groß ist die Laufzeit für eine Folge von n Operationen aus P USH-, P OP, M ULTIPOP auf einem anfangs leeren Stack? Offenbar benötigt jede M ULTIPOP-Operation höchstens O(n)-Zeit, da der Stack zu jedem Zeitpunkt höchstens n Elemente enthält. Da die P USH- und P OP-Operationen jeweils nur O(1)-Zeit benötigen, können wir den Gesamtaufwand mit n · O(n) = O(n 2 ) abschätzen. Obwohl diese Abschätzung korrekt ist, liefert sie kein scharfes Resultat. Tatsächlich ist der Gesamtaufwand für n Operationen nur O(n), also deutlich weniger. Der Schlüssel ist hier die M ULTIPOP-Operation. Obwohl ein einzelnes M ULTIPOP sehr teuer sein kann, verringert es dabei doch die Stackgröße. Insgesamt kann jedes der maximal n Elemente nur einmal durch eine M ULTIPOP- oder P OP-Operation vom Stack entfernt werden, so daß der Gesamtaufwand für alle Pops in der Folge nur so groß wie die Anzahl der P USHOperationen sein kann. Somit erhält man die Gesamtlaufzeit von O(n). Wir haben eben gezeigt, daß eine Folge von n Operationen O(n) Zeit benötigt. Im amortisierten Sinne, d.h. im Durchschnitt, kostet damit jede der n Operationen O(n)/n = O(1) Zeit.
62
Amortisierte Analyse Im folgenden benutzen wir eine Potentialfunktion, um das gleiche Ergebnis noch einmal herzuleiten. Für das einfache Beispiel mag die Analyse nach einem zu großen Geschoß aussehen, allerdings werden hier bereits die technischen Hilfsmittel sichtbar. Eine Potentialfunktion Φ ordnet einer Datenstruktur D einen reellen Wert Φ(D), das Potential, zu, welches mißt, »wie gut« oder »wie schlecht« die aktuelle Konfiguration ist. Man kann sich Φ(D) gewissermaßen als Bankkonto vorstellen. Wir verteuern künstlich eine billige Operation, indem wir zusätzlich zu den realen Kosten noch etwas auf das Konto einzahlen. Bei real teuren Operationen entnehmen wir Geld von Konto, um die Operation »amortisiert« ebenfalls günstig zu machen. Wir starten mit einer Ausgangsdatenstruktur D0 , auf die n Operationen wirken. Wir bezeichnen mit ci die (realen) Kosten der iten Operation, welche auf der Datenstruktur D i−1 arbeitet und als Ergebnis Di liefert. Die amortisierten Kosten ai bei der iten Operation sind ai =
ci |{z}
reale Kosten für die ite Operation
+ Φ(Di ) − Φ(Di−1 ) . | {z } Potentialänderung
Wenn die Differenz Φ(Di ) − Φ(Di−1 ) negativ ist, dann unterschätzt ai die tatsächlichen Kosten ci . Die Differenz wird durch das Entnehmen des Potentialverlustes aus dem Konto abgedeckt. Es gilt nun: n X
ai =
i=1
n X i=1
Also haben wir:
(ci + Φ(Di ) − Φ(Di−1 )) = n X i=1
ci =
n X i=1
n X i=1
ci + Φ(Dn ) − Φ(D0 ).
ai + Φ(D0 ) − Φ(Dn ).
(3.1)
(3.2)
Wenn wir ein Potential definieren können, so daß Φ(Dn ) ≥ Φ(D0 ) gilt, dann überschätzen die amortisierten Kosten die realen Kosten, und eine obere Schranke für die amortisierten Kosten ist dann auch eine Schranke für die realen Kosten. Dieses Ergebnis ist derart wichtig, daß wir es (in Variationen) in einem Satz festhalten. Satz 3.1 Sei D0 eine Datenstruktur, auf die n Operationen wirken, wobei die ite Operation die Datenstruktur Di−1 ∈ D in die Datenstruktur Di ∈ D überführt. Sei Φ : D → R ein Potential.
(i) Gilt Φ(Dn ) ≥ Φ(D0 ), so sind die gesamten realen Kosten für die n Operationen nach oben durch die amortisierten Kosten für die Folge beschränkt. (ii) Gilt Φ(Di ) ≥ 0 für i = 0, . . . , n, so sind die realen Kosten durch die amortisierten Kosten plus das Anfangspotential Φ(D0 ) nach oben beschränkt. Beweis: Siehe oben.
2
Der einfachste Weg, um Φ(Dn ) ≥ Φ(D0 ) zu erhalten, ist es, ein Potential mit Φ(Di ) ≥ 0 und Φ(D0 ) = 0 zu finden. Wir führen dies an unserem Stack-Beispiel vor. Wir definieren Φ(S) := |S|. Offenbar erfüllt dieses Potential unsere Anforderungen. Wie groß sind nun die amortisierten Kosten? Wir können annehmen, daß die P USH- und P OP-Operation jeweils reale Kosten 1 und die M ULTIPOP-Operation reale Kosten k besitzt. Wir betrachten die ite Operation. Ist diese ein P USH, so gilt ai = 1 + |Si | − |Si−1 | = 1 + (|Si−1 | + 1 − |Si−1 |) = 2.
3.2 Konstruieren eines Binomial-Heaps Im Falle eines M ULTIPOP (das als Spezialfall das P OP mit k = 1 enthält) gilt: ai = k + |Si | − |Si−1 | = k + (|Si−1 | − k − |Si−1 |) = 0. P Eine Folge von n Operationen besitzt somit die amortisierten Kosten ni=1 ai ≤ 2n ∈ O(n). Mit Hilfe von Satz 3.1 erhalten wir dann auch die Schranke O(n) für die realen Kosten der Folge.
3.2 Konstruieren eines Binomial-Heaps In Abschnitt 2.5.6 haben wir eigentlich bereits eine amortisierte Analyse mit Hilfe einer Potentialfunktion durchgeführt. Hier ging es darum, den Gesamtaufwand für das Einfügen von n Elementen in einen Binomial-Heap mit anfänglich m Elementen abzuschätzen. Sei das Potential eines Binomial-Heaps mit k Elementen definiert als die Anzahl der Einsen in der Binärdarstellung von k, d.h. Φ := v(k) Das Einfügen des iten Elements, i = 1, . . . , n, benötigt unterschiedlich viele Operationen, die davon abhängen, wie die Binärdarstellung von m + (i − 1), der bereits im Heap vorhandenen Anzahl von Elementen, ist. Die Anzahl der Operationen ist jedoch höchstens eins größer als die Anzahl der sich auf Null ändernden Bits. Wir nehmen an, daß das ite Einfügen ti Bits auf Null setzt. Die amortisierten Kosten sind dann: ai ≤ 1 + ti + v(m + i) − v(m + i − 1). Man beachte, daß v(m+i) ≤ v(m+i−1)−ti +1 gilt. Folglich Pn ist ai ≤ 1+ti −ti +1 = 2. Es Pn folgt i=1 ai ∈ O(n). Die realen Kosten sind maximal i=1 ai + Φ0 = O(n) + v(m) = O(n + log m). Genau dies ist das Ergebnis, welches wir bereits in Abschnitt 3.2 erhalten hatten.
3.3 Dynamische Verwaltung einer Tabelle Zur Speicherung einer dynamisch wachsenden Tabelle soll Speicherplatz alloziiert werden. Speicherplatz steht in Form von Blöcken zur Verfügung. Die Tabelle muß in aufeinanderfolgenden Speicheradressen untergebracht werden. So lange die Tabelle noch nicht voll belegt ist, können wir weitere Elemente einfügen. Sobald die Tabelle voll ist, müssen wir eine neue Tabelle erzeugen, welche mehr Einträge als die alte besitzt. Da die Tabelle immer in kontinuierlich im Speicher liegen soll, müssen wir dann neuen Speicherplatz anfordern und die gesamte alte Tabelle in die neue kopieren. Wir stellen nun einen Algorithmus zur dynamischen Verwaltung der Tabelle vor und analysieren seine Laufzeit. Dabei bezeichnen wir mit T das Tabellenobjekt und mit table[T ] einen Zeiger auf den Speicherblock, ab dem die Tabelle im Speicher steht. Mit num[T ] bezeichnen wir die Anzahl der gespeicherten Einträge und mit size[T ] die Größe der Tabelle. In unserem Beispiel ist die Tabelle anfänglich leer, num[T ] = size[T ] = 0. Letztendlich sei INSERT die elementare Funktion, welche ein neues Element in die Tabelle einfügt. In unserer Analyse nehmen wir an, daß die Laufzeit von TABLE -I NSERT linear in der Anzahl der elementaren INSERT-Operationen ist. Wir analysieren die Laufzeit daher in der Anzahl der INSERT-Operationen. Wie groß ist der Aufwand für n TABLE -I NSERT-Operationen wenn man mit einer leeren Tabelle startet? Analog zum Stack-Beispiel in Abschnitt 3.1 kann man schnell eine grobe obere Schranke angeben. Falls noch Platz in der Tabelle ist, so kann die ite EinfügeOperation in O(1) Zeit durchgeführt werden. Bei voller Tabelle müssen hingegen i INSERTOperationen beim Kopieren der Tabelle ausgeführt werden, so daß insgesamt ein Aufwand
63
64
Amortisierte Analyse Algorithmus 3.1 Algorithmus zur dynamischen Tabellenvewaltung TABLE -I NSERT(T, x) 1 if size[T ] = 0 then 2 Alloziiere eine neue Tabelle table[T ] der Größe 1. 3 size[T ] ← 1 4 end if 5 if num[T ] = size[T ] then 6 Alloziiere eine neue Tabelle newtable der Größe 2 · size[T ]. 7 Füge alle Einträge aus table[T ] mittels INSERT in newtable ein. 8 Gebe den Speicherplatz table[T ] frei. 9 table[T ] = newtable 10 size[T ] = 2 · size[T ] 11 end if 12 Füge x in table[T ] mittels INSERT ein. 13 num[T ] ← num[T ] + 1 von Θ(i) anfällt. Bei insgesamt n Operationen kommen wir bei einer Worst-Case-Zeit pro Operation von Θ(n) auf eine Gesamtzeit von O(n2 ). Wir zeigen nun, wie man wieder mit Hilfe der amortisierten Analyse eine scharfe obere Schranke herleiten kann. Wir werden mit Hilfe einer geeigneten Potentialfunktion zeigen, daß die amortisierten Kosten jeder einzelnen Operation nur O(1) sind. Insgesamt erhalten wir dann einen Aufwand von O(n). Die Potentialfunktion benutzt die Idee des Bankkontos. Unmittelbar nach einer Expansion der Tabelle ist das Potential gleich 0. Bis zur nächsten Expansion steigt das Potential an, so daß wir damit für die teure nächste Expansion mit Hilfe der Potentialdifferenz bezahlen können. Wir benutzen folgende Potentialfunktion Φ(T ) := 2 · num[T ] − size[T ].
(3.3)
Nach einer Expansion ist size[T ] = 2 · num[T ], also Φ(T ) = 0. Insbesondere ist das Potential am Anfang ebenfalls gleich 0. Im Verlaufe der Einfüge-Operationen ist die Tabelle immer mindestens halb gefüllt, so daß wir auch Φ(T ) ≥ 0 haben. Aus Satz 3.1 ersehen wir, daß die Summe der amortisierten Kosten eine obere Schranke für die realen Kosten ist. Wir betrachten nun die ite TABLE -I NSERT-Operation. Falls keine Expansion notwendig ist, so gilt size[Ti ] = size[Ti−1 ] und daher: ai = 1 + Φ(Ti ) − Φ(Ti−1 ) = 1 + (2 · num[Ti ] − size[Ti ]) − (2 · num[Ti−1 ] − size[Ti−1 ])
= 1 + (2 · num[Ti ] − size[Ti−1 ]) − (2 · (num[Ti ] − 1) − size[Ti−1 ])
= 1 + 2 = 3.
Falls expandiert wird, haben wir size[Ti ] = 2 · size[Ti−1 ], und es folgt: ai = num[Ti ] + (2 · num[Ti ] − size[Ti ]) − (2 · num[Ti−1 ] − size[Ti−1 ])
= num[Ti ] + (2 · num[Ti ] − 2size[Ti−1 ]) − (2 · (num[Ti ] − 1) − size[Ti−1 ]) = num[Ti ] + 2 − size[Ti−1 ]
= num[Ti−1 ] + 1 − size[Ti−1 ] + 2 = num[Ti−1 ] + 1 − num[Ti−1 ] + 2 = 3.
3.3 Dynamische Verwaltung einer Tabelle Hierbei haben wir benutzt, daß num[Ti−1 ] = size[Ti−1 ] gilt, da sonst nicht expandiert werden muß. Insgesamt erhalten wir also den behaupteten Aufwand von O(n) für eine Folge von n Einfüge-Operationen auf einer anfänglich leeren Tabelle. Das ist eine gute Nachricht: im Durchschnitt kostet jede der n Operationen nur konstante Zeit!
65
66
Fibonacci-Heaps Fibonacci-Heaps sind eine weitere Datenstruktur, um effizient Prioritätsschlangen zu verwalten. Prioritätsschlangen spielen bei vielen Algorithmen eine wichtige Rolle. Wir haben bereits in Kapitel 2 den Algorithmus von Dijkstra als konkretes Anwendungsfeld von Prioritätsschlangen kennengelernt. Bei Verwendung eines binären Heaps erhalten wir eine Gesamtlaufzeit von O((n + m) log n). Diese Laufzeit ist zwar schon recht brauchbar, allerdings kann in dichten Graphen m = Ω(n2 ) gelten, so daß wir in diesem Fall eine (mehr als) quadratische Laufzeit erhalten. Dies ist insbesondere für große Graphen, wie sie etwa bei der Routenplanung auftreten, nicht akzeptabel. Mit Hilfe der d-nären Heaps konnten wir für m = Ω(nε ) lineare Laufzeit erhalten. Allerdings bleibt für m ∈ Ω(n log n) ∩ o(nε ) ein weiter Bereich, wo wir noch Verbesserungspotential haben. Mit Hilfe der Fibonacci-Heaps, welche wir in diesem Kapitel vorstellen und analysieren, kann man die Laufzeit des Dijkstra-Algorithmus auf O(m + n log n) verringern, was zu einer deutlichen Beschleunigung bei dichten Graphen führt. Der Schlüssel zur Verringerung der Zeitkomplexität liegt hier bei einer effizienten Unterstützung der D ECREASE -K EYOperation. Die Laufzeit von O(m + n log n) ist durchgängig besser als für binäre und d-näre Heaps. Tabelle 4.1 zeigt die Laufzeiten, die wir in diesem Kapitel beweisen werden, im Vergleich zum binären Heap und zum Binomial-Heap. Die Laufzeit von O(m + n log n) für den Dijkstra-Algorithmus erhält man mit Hilfe der angegebenen Laufzeiten sofort aus Satz 2.3.
4.1 Der Algorithmus von Prim Als weitere Motivation für die Fibonacci-Heaps betrachten wir die Berechnung eines aufspannenden Baumes mit minimalem Gewicht. Im folgenden sei wieder G = (V, E) ein Operation M AKE I NSERT M INIMUM E XTRACT-M IN D ECRASE -K EY M ELD
Binärer Heap (worst-case) O(1) O(log n) O(1) O(log n) O(log n) Θ(n)
Binomial-Heap (worst-case) O(1) O(log n) O(1) O(log n) O(log n) O(log n)
Fibonacci-Heap (amortisiert) O(1) O(1) O(1) O(log n) O(1) O(1)
Tabelle 4.1: Zeitkomplexität der Prioritätsschlangen-Operationen bei Implementierung durch einen binären Heap, durch einen Binomial-Heap und durch einen Fibonacci-Heap der Größe n.
68
Fibonacci-Heaps ungerichteter Graph in Adjazenzlistendarstellung. Der Algorithmus von Prim startet mit einer leeren Kantenmenge T . Der Algorithmus besitzt die Eigenschaft, daß die Kantenmenge T zu jedem Zeitpunkt zusammenhängend ist. In jedem Schritt fügt er die jeweils billigste Kante hinzu, die T mit dem Restgraphen verbindet. Der Algorithmus von Prim (dargestellt in Algorithmus 4.1) ist dem Algorithmus von Dijkstra (Algorithmus 2.1 auf Seite 8) sehr ähnlich. Ein Beispiel für die Ausführung des Algorithmus von Prim ist in den Abbildungen 4.1 bis 4.2 zu sehen. Die Korrektheit des Algorithmus von Prim folgt mit Hilfe Satz 2.10: Satz 4.1 Der Algorithmus von Prim findet einen MST. Er benötigt in einem Graphen mit n Knoten und m Kanten O(n) I NSERT-, O(n) E XTRACT-M IN und O(m) D ECREASE K EY-Operationen auf einer Prioritätsschlange mit maximal n Elementen. 2 Beweis: Wir bezeichnen mit Si die Menge S der bereits aus der Prioritätsschlange extrahierten Knoten nach i Durchläufen der while-Schleife. Wir zeigen durch Induktion nach |Si | = 1, daß (S, T ) ein Baum ist und im iten Durchlauf eine leichteste Kante aus dem Schnitt [Si , V \ Si ] hinzugefügt wird. Nach Satz 2.10 ist die hinzugenommene Kante sicher und dies impliziert dann die Korrektheit. Der Induktionsanfang, i = 1 ist trivial: |S1 | = |{s}| = 1 und T = ∅; der Baum, der aus nur einem Knoten und keiner Kante besteht, ist ein Baum. Im Induktionsschritt nehmen wir an, daß die Aussage für i richtig ist, und betrachten den (i + 1)ten Durchlauf der while-Schleife. Sei wie im Algorithmus der Knoten u der Knoten, der aus der Schlange Q entfernt wird. Nach Induktionsvoraussetzung enthält T zu Beginn des aktuellen Durchlaufs noch keine Kante aus dem Schnitt [Si , V \ Si ] (sonst hätte T Kanten, deren Endpunkte nicht in Si liegen). Da d[u] < +∞ (sonst wäre u nie in Q aufgenommen worden), ist e[u] eine Kante e[u] = (v, u) ∈ [Si , V \ Si ], die in dieser Iteration hinzugefügt wird. Wir müssen nur noch zeigen, daß (v, u) eine billigste Kante in [Si , V \ Si ] ist.
Wäre (x, y) ∈ [Si , V \ Si ] mit c(x, y) < c(v, u), so ist genau einer der beiden Knoten x und y in Si . Sei dies o.B.d.A. der Knoten x. Dann wurde x in einer früheren Iteration aus der Schlange entfernt. Dabei wurde d[y] auf einen Wert von höchstens c(x, y) gesetzt. Weiterhin ist danach y ∈ Q und wegen y ∈ / §i wurde y auch noch nicht aus Q entfernt. Da d[y] ≤ c(x, y) < c(v, u) = d[u] widerspricht dies aber der Annahme, daß u in der aktuellen Iteration als Element mit minimalem Schlüsselwert aus der Schlange entfernt wurde. Die Laufzeit des Algorithmus ist leicht zu analysieren: Jeder Knoten wird genau einmal in die Prioritätsschlange eingefügt und daraus entfernt. Für jede Kante im Graphen finden maximal zwei D ECREASE -K EY-Operationen statt, eine für jeden Endpunkt. 2
4.1 Der Algorithmus von Prim
Algorithmus 4.1 Algorithmus von Prim. MST-P RIM(G, c, s) Input: Ein zusammenhängender ungerichteter Graph G = (V, E) mit n := |V | und m := |E|, eine Kantenbewertungsfunktion c : E → R und eine Startecke s∈V. Output: Ein minimaler aufspannender Baum T von G. 1 T ←∅ { Die Kantenmenge, die zum Schluß den MST bildet. } 2 S ←∅ { Die Menge der Ecken, die von T aufgespannt wird. } 3 for all v ∈ V do 4 d[v] ← +∞ 5 e[v] ← NULL / S, so ist Kante e[v] ist die billigste Kante von v zu S. Falls v ∈ Falls v ∈ S, so ist e[v] die Kante in T , durch deren Hinzunahme zu T der Knoten v zu S gelangte. 6 end for 7 Q←∅ { Erzeuge eine leere Prioritätsschlange Q. } 8 d[s] ← 0 9 I NSERT (Q, s) { Füge s mit Schlüsselwert d[s] = 0 in die Schlange ein. } 10 while Q 6= ∅ do 11 u ← E XTRACT-M IN (Q) 12 S ← S ∪ {u} 13 d[u] ← −∞ 14 if u 6= s then 15 T ← T ∪ {e[u]} 16 end if 17 for all v ∈ Adj[u] do 18 if d[v] ← +∞ then { Es gab noch keine Kante (w, v) mit w ∈ S. } 19 d[v] ← c(u, v) 20 e[v] ← (u, v) 21 I NSERT(Q, v) { Füge v mit Schlüsselwert d[v] in Q ein. } 22 else 23 if c(u, v) < d[v] then { Es gab bereits eine Kante (w, v) mit w ∈ S, aber diese hatte Kosten größer als c(u, v). } 24 d[v] ← c(u, v) 25 D ECREASE -K EY(Q, v, c(u, v)) { Verringere den Schlüsselwert von v auf c(u, v). } 26 e[v] ← (u, v) 27 end if 28 end if 29 end for 30 end while 31 return T
69
70
Fibonacci-Heaps
0 1
1
+∞ +∞ 4 2 3 2 +∞
3 +∞ 4
2
5
2
4
5
1 6 +∞
5
2
0 1
1
3 2
3 4
+∞ 3
2 +∞
1
5
2
4
4
1 2
6 +∞
5 5
2
7 8 9 3 2 +∞ +∞ +∞
7 8 9 3 2 +∞ +∞ +∞
(a) Am Anfang sind alle Distanzmarken +∞ bis auf die des Startknotens, hier der Knoten 1.
(b) Der Knoten 1 wird aus der Schlange entfernt und alle adjazenten Knoten betrachtet.
0 1
1
1 2
3 3 4
2
4 3
5
0 1 1
2 2
2
4
4
5
6 +∞ 2
5
1 2
3 2 2 4
5 2
4 7 2
3
4 2 2
7 2
3
5
0 1
6 5 2 9 +∞
(e) Der Knoten 4 wird aus der Schlange entfernt. Dabei wird diesmal keine einzige Distanzmarke verändert.
6 5
5
2
5 8 5
1
1 2
3 2
2
1
2
9 +∞
(d) Der Knoten 5 wird aus der Schlange entfernt. Dabei wird unter anderem der Schlüsselwert des Knotens 4 von 3 auf 2 erniedrigt.
1 5
4 3
4 2 2
2
4
4 3
5 8 5
2
2 4
(c) Der Knoten 2 wird aus der Schlange entfernt.
1
1 2
3
7 9 8 3 2 +∞ +∞ +∞
0 1
1
2 4
5 2
4 7 2
4 2 2
4 3 1
5
6 5
5 3
8 3
2 2
9 +∞
(f) Der Knoten 7 wird aus der Schlange entfernt.
Abbildung 4.1: Berechnung eines minimalen aufspannenden Baumes mit dem Algorithmus von Prim. 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. Die dick gezeichneten Kanten sind jeweils in der aktuellen Lösung, die zum Schluß einen minimalen aufspannenden Baum bildet, enthalten.
4.2 Der Aufbau von Fibonacci-Heaps 0 1
1
1 2
3 2 2 4
5 2
4 7 2
4 2 2
1 6 5
2 4
1
2 2
1 2
3 2 2 4
5 2
4 7 2
4
9 2
4
7 2
2 2
8 3
8 3
1
1 2
3 2
6 2 2
2
6 2
5 5
3
0 1 1
5
1
2 9 2
2
(b) Der Knoten 9 wird aus der Schlange entfernt.
1 3
5 3
2 2
5 2
4 3
4
3
(a) Der Knoten 8 wird aus der Schlange entfernt.
0 1
1 2
1
2 5
8 3
0 1
4 3
5 3
71
9 2
(c) Der Knoten 6 wird aus der Schlange entfernt.
2 4
5 2
4 7 2
4 2 2
1 3 1
5
6 2
5 3
8 3
2 2
9 2
(d) Nach dem Entfernen des Knotens 3 terminiert der Algorithmus.
Abbildung 4.2: Fortsetzung: Berechnung eines minimalen aufspannenden Baumes mit dem Algorithmus von Prim. 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. Die dick gezeichneten Kanten sind jeweils in der aktuellen Lösung, die zum Schluß einen minimalen aufspannenden Baum bildet, enthalten. Falls man die Prioritätsschlange Q als binären Heap verwaltet, so benötigen I NSERT, E XTRACT-M IN und D ECREASE -K EY jeweils O(log |Q|) Zeit. Somit ist die Gesamtlaufzeit des Algorithmus von Prim O(m log n). Wieder kann man dies mit Hilfe der FibonacciHeaps auf O(m + n log n) beschleunigen. Beobachtung 4.2 Mit Hilfe von binären Heaps benötigt der Algorithmus von Prim zur Bestimmung eines MST O(m log n) Zeit auf einem Graphen mit n Ecken und m Kanten.
4.2 Der Aufbau von Fibonacci-Heaps Ein Fibonacci-Heap besteht aus einer Kollektion von heap-geordneten Bäumen. Im Gegensatz zu den Binomial-Heaps aus Abschnitt 2.5 sind die einzelnen Bäume jedoch nicht notwendigerweise Binomialbäume. Jeder Knoten in einem Fibonacci-Heap enthält die folgenden Informationen: • p[x] ist ein Zeiger auf den Vaterknoten von x (NULL, falls x der Wurzelknoten ist)
72
Fibonacci-Heaps • child[x] ist ein Zeiger auf einen Sohn von x. Die Söhne von x sind in einer doppelt verketteten zyklischen Liste organisert. • Für einen Sohn y sind left[y] und right[y] Zeiger auf den Vorgänger bzw. Nachfolger in der verketteten Liste der Kinder. • In degree[x] ist der Grad von x, d.h., die Anzahl seiner Kinder, gespeichert. • mark[x] ist eine Markierung, die später für das noch genauer erklärt werden wird. Abbildung 4.3 veranschaulicht die Organisation eines Fibonacci-Heaps. Ein FibonacciHeap H besitzt als ganzes noch folgende Attribute: • min[H] ist ein Zeiger auf das Minimum im Heap. • size[H] bezeichnet die Anzahl der Knoten im Heap. In den folgenden Abschnitten werden wir zeigen, wie man die Operationen für Prioritätsschlangen mit Hilfe von Fibonacci-Heaps effizient implementieren kann. Die Analyse der Laufzeit erfolgt mittels amortisierter Analyse (siehe Kapitel 3). Dabei verwenden wir die folgende Potentialfunktion: Φ(H) := t(H) + 2m · m(H), wobei t(H) die Anzahl der Bäume in der Wurzelliste und m(H) die Anzahl der markierten Knoten im Heap bezeichnet.
4.3 Implementierung der Basis-Operationen Im folgenden sei D(n) eine obere Schranke für den maximalen Grad eines Knotens in einem Fibonacci-Heap mit n Knoten. In der Laufzeitanalyse der Operationen FIB EXTRACT- MIN und FIB - DECREASE - KEY wird D(n) auftauchen. Wir zeigen später, daß D(n) ∈ O(log n) gilt. Der Grund, warum wir den Beweis dieser logaritmischen oberen Schranke auf später verschieben ist, daß wir dazu die Implementierung aller Operationen kennen müssen. Das Erstellen eines neuen Fibonacci-Heaps ist ganz einfach (siehe Algorithmus 4.2). Wir setzen size[H] := 0, min[H] := NULL. Der reale Zeit-Aufwand dafür ist in O(1), ebenso die amortisierten Kosten, da das Potential eines leeren Fibonacci-Heaps H gleich Φ(H) = 0 ist. Algorithmus 4.2 Erstellen eines leeren Fibonacci-Heaps F IB -M AKE() 1 min[H] ← NULL 2 size[H] ← 0 Als nächstes betrachten wir das Einfügen von neuen Elementen (siehe Algorithmus 4.3). Im Folgenden gehen wir davon aus, daß jeder einzufügende Knoten x einen Schlüsselwert key[x] besitzt, der bereits richtig gesetzt ist. FIB - INSERT(H, x) fügt das neue Element x einfach unmarkiert in die zyklisch verkettete Wurzelliste ein: x wird quasi als Binomialbaum der Ordnung 0 behandelt und zur Liste hinzugefügt. Der Zeiger MIN[H] wird bei Bedarf aktualisiert. Alle diese Operationen sind in konstanter Zeit durchführbar. Das Potential steigt um 1 (die Anzahl der Bäume in der Wurzelliste wird um eins größer), so daß das Einfügen in amortisierter Zeit O(1) implementiert werden kann.
4.3 Implementierung der Basis-Operationen
min[H]
10
73
5
7
13
9
12
15
20
16
(a) Ein Fibonacci-Heap
min[H]
10 0
5 1
13 0
7 3
9 0
12 1
15 1
20 0
16 0
(b) Zeiger-Repräsentation im Computer.
Abbildung 4.3: Aufbau eines Fibonacci-Heaps
Algorithmus 4.3 Einfügen eines neuen Elements in einen Fibonacci-Heap F IB - INSERT(H, x) 1 p[x] ← NULL 2 child[x] = NULL 3 degree[x] ← 0 4 mark[x] ← false 5 left[x] ← right[x] ← x 6 Verkette die Wurzelliste mit der einelementigen Wurzellisteliste, welche x enthält. 7 if min[H] = NULL oder key[min[H]] > key[x] then 8 min[H] ← x 9 end if 10 size[H] ← size[H] + 1
74
Fibonacci-Heaps Das Vereinigen von zwei Fibonacci-Heaps H1 und H2 zu H geschieht ähnlich wie das Einfügen eines neuen Elements (siehe Algorithmus 4.4). Die Wurzellisten von H 1 und H2 werden zu einer einzigen Liste in O(1) Zeit verkettet, der Zeiger auf das Minimum wird entsprechend aktualisiert. Die Potentialänderung ist: Φ(H) − Φ(H1 ) − Φ(H1 )
=t(H1 ) + t(H2 ) + 2m(H1 ) + 2m(H2 ) − (t(H1 ) + 2m(H1 )) − (t(H2 ) + 2m(H2 )) =0. Somit ist die amortisierte Zeit wiederum in O(1). Algorithmus 4.4 Vereinigen zweier Fibonacci-Heaps F IB -M ELD(H1 , H2 ) 1 min[H] = min[H1 ] 2 if min[H] = NULL oder key[min[H]] > key[min[H 2 ]] then 3 min[H] ← min[H2 ] 4 end if 5 Verkette die Wurzellisten von H1 und H2 zu einer neuen Liste, der Wurzelliste von H. 6
size[H] ← size[H1 ] + size[H2 ]
Die Implementierung der MINIMUM-Operation ist trivial. Das Extrahieren des Minimums in einem Fibonacci-Heap ist trickreicher als die »einfachen Operationen« oben. Grob funktioniert es folgendermaßen: Sei z = min[H]. Wir entfernen z aus der Wurzelliste und hängen alle Söhne von z in die Wurzelliste ein (siehe Algorithmus 4.5). Dies ist in O(D(n)) Zeit möglich. Danach folgt ein »Aufräumen«, in dem wir die folgenden Schritte so lange wie möglich ausführen: 1. Wir finden zwei Wurzeln x und y in der Wurzelliste mit degree[x] = degree[y]. Sei key[x] < key[y]. 2. Wir entfernen y aus der Wurzelliste und machen y zu einem Sohn von x. Dabei wird degree[x] erhöht und die Markierung mark[y] gelöscht. Ein Durchlauf der beiden Schritte oben kann in O(1) Zeit implementiert werden. Wir zeigen gleich, wie man das gesamte Aufräumen effizient organisieren kann. Vorher soll aber noch etwas zu den Markierungen gesagt werden, die hier zum ersten mal »richtig« auftauchen. Die Markierungen werden nachher noch beim Verringern von Schlüsselwerten (Abschnitt 4.4) eine entscheidende Rolle spielen. Hintergrund ist der folgende: Wir wollen verhindern, daß ein heap-geordneter Baum »zu dünn« wird (und damit eine zu große Höhe besitzt). Die Markierung an einem Knoten x zeigt im Prinzip an, daß x bereits einen Sohn durch Löschen (bei FIB - DECREASE - KEY) verloren hat. In Algorithmus 4.6 wird im Pseudo-Code angegeben, wie das Aufräumen nach dem Entfernen des Minimums und des Einhängens der Söhne effizient umgesetzt werden kann. Wir verwenden ein Hilfsarray A mit folgender Bedeutung: Wenn A[i] = x 6= NULL, dann ist i ein Knoten in der Wurzelliste mit degree[x] = i. Zum Beginn des while-Loops in Zeile 7 gilt d = degree[x]. Die Wenn in Zeile 8 A[d] = y gilt, so ist von allen bereits bearbeiteten Wurzeln der Knoten y der einzige mit degree[y] = d. Nachdem y zum Sohn von x gemacht worden ist, kann man daher A[d] = NULL setzen. Man sieht leicht, daß als Invariante im Loop gilt: d = degree[x]. Der while-Loop endet, wenn es keine (bereits bearbeitete) Wurzel mit Grad d mehr gibt.
4.3 Implementierung der Basis-Operationen
Algorithmus 4.5 Extrahieren des Mininums eines Fibonacci-Heaps F IB -E XTRACT-M IN(H) 1 z ← min[H] 2 if z 6= NULL then 3 for all Söhne x von z do 4 Füge x zur Wurzelliste von H hinzu. 5 p[x] ← NULL 6 end for 7 Entferne z aus der Wurzelliste von H. 8 if z = right[z] then { z war einziges Element im Heap } 9 min[H] ← NULL { temporäres Setzen des Minimums, wird durch FIB - CLEANUP korrigiert } 10 else 11 min[H] ← right[z] 12 FIB - CLEANUP (H) 13 end if 14 size[H] ← size[H] − 1 15 end if
Algorithmus 4.6 Aufräumen nach Extrahieren des Minimums im Fibonacci-Heap F IB -C LEANUP(H) 1 for i ← 0, . . . , D(size[H]) do 2 A[i] ← NULL 3 end for 4 for all Knoten v in der Wurzelliste von H do 5 x←v 6 d ← degree[v] 7 while A[d] 6= NULL do 8 y ← A[d] { y ist ein Knoten in der Wurzelliste mit degree[y] = d } 9 if key[y] < key[x] then 10 vertausche x und y 11 end if 12 Entferne y aus der Wurzelliste. 13 Mache y zu einem Sohn von x, erhöhe degree[x]. 14 mark[y] ← false 15 A[d] ← NULL 16 d← d+1 { Der Grad von x hat sich erhöht } 17 end while 18 A[d] ← x 19 end for 20 min[H] = NULL 21 for i = 0, . . . , D(size[H]) do 22 if A[i] 6= NULL then 23 Füge A[i] zur Wurzelliste von H hinzu. 24 if min[H] = NULL oder key[A[i]] < key[min[H]] then 25 min[H] = A[i] 26 end if 27 end if 28 end for
75
76
Fibonacci-Heaps Wie groß ist der Zeitaufwand für FIB - CLEANUP? Das Initialisieren des A-Arrays benötigt O(D(n)) Zeit. Die Wurzelliste enthält zu Beginn von FIB - CLEANUP maximal t(H) − 1 + D(n) Elemente (t(H) Wurzeln waren vor dem Extrahieren von z in der Liste, z wird entfernt und maximal D(n) Söhne hinzugefügt). In jedem Durchlauf der while-Schleife in FIB - CLEANUP werden zwei Bäume in der Liste zu einem in O(1) Zeit vereinigt. Somit ist der Gesamtaufwand für den while-Loop O(t(H) + D(n)). Das Potential ändert sich wie folgt: Vor dem Extrahieren ist das Potential t(H) + 2m(H). Nach FIB - CLEANUP beträgt das Potential maximal D(n) + 1 + 2m(H), da wir für jeden Grad zwischen 0 und D(n) maximal ein Element in der Wurzelliste haben. Die amortisierten Kosten sind also maximal: O(t(H) + D(n)) + (D(n) + 1 + 2m(H)) − (t(H) + 1 + 2m(H)) | {z } | {z } Potential am Ende
Potential zu Beginn
=O(t(H) + D(n)) + D(n) − t(H) + 1
=O(D(n)) + O(t(n)) − t(H) =O(D(n)).
Für die letzte Gleichung haben wir benutzt, daß wir das Potential dergestalt skalieren könnten, daß die »versteckte Konstante« im O(t(n)) kompensiert wird.
4.4 Das Verringern von Schlüsselwerten Das Verringern des Schlüsselwerts eines Knotens x im Fibonacci-Heap ist sehr trickreich gelöst (siehe Algorithmus 4.7). Dabei kommen auch die Markierungen mark zum Einsatz. Zunächst wird x der neue Schlüsselwert zugewiesen (der kleiner als der alte Schlüsselwert sein muß). Falls der neue Schlüsselwert von x noch immer nicht kleiner ist als der des Vaters y = p[x] von x (oder x keinen Vater besitzt), so sind wir bereits fertig. Andernfalls müssen wir die Heap-Eigenschaft wieder herstellen. Wir entfernen x aus der Sohnliste von y und fügen x zur Wurzelliste hinzu des Heaps hinzu. Dies erfolgt mit Hilfe der Prozedur C UT. Nachdem x von seinem Vater y abgetrennt worden ist, erfolgt ein rekursiver Aufruf von CASCADING - CUT für den Vater y von x. Wenn y bereits markiert ist, dann wird auch y von seinem Vater abgetrennt und in die Wurzelliste aufgenommen. Die Prozedur geht dann beim Vater von y weiter. Was bedeutet nun genau die Markierung? Eine Markierung mark[x] ist für einen Knoten x genau dann auf true gesetzt, wenn folgende Aktionen hintereinander passiert sind: 1. x war eine Wurzel in der Wurzelliste. 2. x wird zum Sohn eines anderen Knotens gemacht. 3. x verliert einen Sohn. Sobald nun x den zweiten Sohn verliert, wird x von seinem Vater abgeschnitten und wieder in die Wurzelliste eingehängt. Wir analysieren jetzt die Zeitkomplexität von FIB - DECREASE - KEY. Es ist einfach zu sehen, daß jeder Aufruf von CUT und jeder rekursive Aufruf von CASCADING - CUT nur O(1) Zeit benötigt. Wir nehmen an, daß insgesamt C rekursive Aufrufe von CASCADING - CUT erfolgen. Dann ist der reale Zeitaufwand für FIB - DECREASE - KEY in O(C).
4.4 Das Verringern von Schlüsselwerten
Algorithmus 4.7 Verringern eines Schlüsselwertes im Fibonacci-Heap F IB -D ECREASE - KEY(H, x, k) 1 key[x] ← k 2 y ← p[x] 3 if y 6= NULL und key[x] < key[y] then 4 CUT (H, x, y) 5 CASCADING - CUT (H, y) 6 end if 7 if key[x] < key[min[H]] then 8 min[H] ← x 9 end if C UT(H, x, y) 1 Entferne x aus der Sohnliste von y. 2 Füge x zur Wurzelliste von H hinzu. 3 p[x] ← NULL 4 mark[x] ← false
C ASCADING -C UT(H, y) 1 z ← p[y] 2 if z 6= NULL then 3 if mark[y] = true then 4 CUT (H, y, z) 5 CASCADING - CUT (H, z) 6 else 7 mark[y] ← true 8 end if 9 end if
77
78
Fibonacci-Heaps Für die amortisierten Kosten müssen wir wiederum die Potentialänderung betrachten. Sei H der Fibonacci-Heap vor dem FIB - DECREASE - KEY und H 0 der Aufwand nach der Ausführung von FIB - DECREASE - KEY. Alle rekursiven Aufrufe von CASCADING - CUT bis auf den letzten erhöhen die Anzahl der Wurzeln in der Wurzelliste um eins und löschen eine Markierung (der letzte Aufruf markiert möglicherweise einen Knoten und bricht dann ab). Somit ist die Gesamtanzahl von Wurzeln in der Wurzelliste von H 0 maximal t(H) + C (wir haben t(H) Bäume in der Wurzelliste von H, ein neuer Knoten ist x, dann kommen noch C − 1 neue Knoten aus den rekursiven Aufrufen hinzu). Es werden C − 1 Markierungen gelöscht und maximal eine neue gesetzt, so daß m(H 0 ) ≤ m(H) − (C − 1) + 1 = m(H) − C + 2 gilt. Daher gilt:
Φ(H 0 ) − Φ(H) = t(H 0 ) + 2m(H 0 ) − (t(H) + 2m(H)) ≤ t(H) + C + 2(m(H) − C + 2)) − t(H) − 2m(H) = −C + 4
Damit sind die armortisierten Kosten für FIB - DECREASE - KEY in O(C) − C + 4 = O(1), wobei wir wieder benutzen, daß wir das Potential derart skalieren können, daß die Konstante in der O-Notation kompensiert wird.
4.5 Beschränkung des Grades in Fibonacci-Heaps Wir haben gezeigt, daß alle Operationen bis auf FIB - EXTRACT- MIN im Fibonacci-Heap in O(1) amortisierter Zeit ausgeführt werden können. Für FIB - EXTRACT- MIN haben wir eine amortisierte Schranke von O(D(n)) bewiesen, wobei D(n) der maximale Grad in einem Fibonacci-Heap mit n Knoten ist. In diesem Abschnitt werden wir nun D(n) ∈ O(log n) zeigen, so daß dann alle in Tabelle 4.1 aufgeführten Zeitkomplexitäten hergeleitet sind. Lemma 4.3 Sei x ein beliebiger Knoten in einem Fibonacci-Heap H . Ordnet man die Söhne von x in der Reihenfolge, wie sie an x angehängt wurden, so erfüllt für i ≥ 2 der ite Sohn yi : degree[yi ] ≥ i − 2. Beweis: Angenommen, x habe k Söhne y1 , . . . , yk (es ist übrigens möglich, daß x schon mehr Söhne hatte, diese aber bereits wieder verloren hat). Als der ite Sohn y i an x angehängt wurde, so hatte zu diesem Zeitpunkt x den gleichen Grad wie y i . Da dann y1 , . . . , yi−1 (und möglicherweise noch mehr Knoten) Söhne von x waren, galt in diesem Moment degree[x] ≥ i − 1, also auch degree[yi ] ≥ i − 1. Im weiteren Verlauf kann yi maximal einen Sohn verloren haben, da sonst yi von x abgeschnitten worden wäre, also folgt degree[yi ] ≥ i − 2. 2 Mit Hilfe des Grad-Lemmas können wir nun eine untere Schranke für die Größe von Teilbäumen in einem Fibonacci-Heap herleiten. Wir erinnern uns zunächst daran, wie die Fibonacci-Zahlen Fk definiert waren: F0 := 0 F1 := 1 Fk = Fk−2 + Fk−1
für k ≥ 2.
Die Fibonacci-Zahlen besitzen folgende Eigenschaften, die leicht durch Induktion zu beweisen sind: Lemma 4.4
(i) Fk+2 = 1 +
Pk
i=1
Fi
4.6 Ein Minimalbaum-Algorithmus mit nahezu linearer Laufzeit
(ii) Fk ≥ φk , wobei φ =
√ 1+ 5 2
79
der Goldene Schnitt ist.
Die Fibonacci-Heaps tragen ihren Namen nicht zu Unrecht, wie das folgende Lemma zeigt. Lemma 4.5 Sei x ein Knoten in einem Fibonacci-Heap H mit degree[x] = k und |T x | die Anzahl der Knoten im Teilbaum, der an x hängt. Dann gilt |Tx | ≥ Fk+2 . Beweis: Sei Sk die minimale Anzahl von Knoten in einem Teilbaum, dessen Wurzel Grad k besitzt. Wir zeigen durch Induktion, daß Sk ≥ Fk+2 gilt. Wegen |Tx | ≥ Sk folgt dann die Behauptung. Offenbar gilt S0 = 1 und S1 = 2. Sei nun degree[x] = k und y1 , . . . , yk die Söhne von x in der Reihenfolge, wie sie an x angehängt wurden. Wegen Lemma 4.3 gilt degree[y i ] ≥ i − 2 für i = 2, . . . , k. Da Sk monoton in k wächst folgt somit:
Sk ≥ 2 + ≥2+ ≥2+ =1+
k X
Sdegree[yi ]
(„+2 für x selbst und y1 )
Si−2
(nach Lemma 4.3)
Fi
(nach Induktionsvoraussetzung)
Fi
(da F1 = 1)
i=2
k X i=2
k X i=2
k X i=1
= Fk+2
(nach Lemma 4.4). 2
Damit können wir nun die gewünschte Schranke für D(n) zeigen. Ist x ein beliebiger Knoten in einem Fibonacci-Heap H mit n Knoten und degree[x] = k, so gilt nach Lemma 4.5 n = size[H] ≥ |Tx | ≥ φk .
(4.1)
Logarithmieren in (4.1) liefert dann k ≤ logφ n ∈ O(log n). Beobachtung 4.6 Mit Hilfe eines binären Heaps als Datenstruktur für die Prioritätsschlange benötigt der Dijkstra-Algorithmus O(m + n log n) Zeit auf einem Graphen mit n Ecken und m Kanten. Beobachtung 4.7 Mit Hilfe eines binären Heaps als Datenstruktur für die Prioritätsschlange benötigt der Algorithmus von Prim zur Bestimmung eines MST O((n + m) log n) Zeit auf einem Graphen mit n Ecken und m Kanten.
4.6 Ein Minimalbaum-Algorithmus mit nahezu linearer Laufzeit Als Abschluß des Kapitels geben wir als Anwendung der Fibonacci-Heaps einen schnellen MST-Algorithmus, den Algorithmus von Fredman und Tarjan, an und analysieren seine
80
Fibonacci-Heaps Laufzeit. Der Algorithmus baut auf dem Algorithmus von Prim (siehe Algorithmus 4.1) auf und läuft in Zeit O(n + mβ(m, n)), wobei β(m, n) := min{ i : log(i) n ≤ m/n }.
(4.2)
Die Funktion β wächst extrem langsam! Es gilt β(m, n) ≤ log ∗ n, wobei log∗ n := min{ i : log(i) n ≤ 1 }.
(4.3)
Man beachte, daß log∗ 16 = 3, log∗ 65536 = 4, log∗ 265536 = 5. Zur Erinnerung: Die geschätzte Zahl der Atome im Universum ist etwa 1080 < 2320 . Die Idee des Algorithmus von Fredman und Tarjan ist es, die Prioritätsschlange Q in ihrer Größe geschickt zu beschränken (denn E XTRACT-M IN benötigt O(log |Q|) amortisierte Zeit). Die Grundidee des Algorithmus ist dabei die folgende: 1. Lasse einen einzelnen Baum T wie im Algorithmus von Prim wachsen, bis die Schlange Q, welche die „Nachbarecken“ zu T enthält, eine gewisse Größe überschreitet. 2. Starte dann von einer neuen Ecke und stoppe wieder, falls Q zu groß wird. 3. Die ersten Schritte werden ausgeführt, bis jede Ecke in einem Baum enthalten ist. Dann wird jeder Baum zu einer „Superecke“ kontrahiert, und der Algorithmus fährt mit dem geschrumpften Graphen fort. 4. Nach einer genügenden Anzahl von Phasen bleibt nur noch eine Superecke übrig. Expandieren liefert dann den MST. Die Implementierung führt das Kontrahieren implizit aus. Für jeden Knoten v ∈ V halten wir uns einen Eintrag tree[v], der angibt, in welchem Baum sich v befindet. In jeder Phase beginnt man mit einem Wald von bisher gewachsenen alten Bäumen. Man verbindet man dann die Bäume zu neuen Bäumen, die dann die alten Bäume für den nächste Phase werden.
Start einer Phase 1. Numeriere die alten Bäume und gib jeder Ecke die Nummer seines Baumes. Damit kann man für jede Ecke v den Baum, dem sie zugehört, direkt aus tree[v] ablesen. Der Aufwand für diesen Schritt ist O(n + m). 2. Aufräumen: Lösche aus dem Graphen alle Kanten, die zwei Ecken im gleichen Baum verbinden. Behalte auch nur die jeweils billigsten Kanten zwischen verschiedenen Bäumen. Das Aufräumen kann in O(n+m) Zeit erfolgen: Sortiere die Kanten lexikographisch nach den Nummern ihrer Endpunkte mittels zweier Durchgänge von Counting-Sort (siehe z.B. [2, Kapitel 9]). Danach laufe die sortierte Liste einmal von vorne nach hinten durch. 3. Nach dem Aufräumen erstellt man für jeden alten Baum T eine Liste mit den Kanten, die einen Endpunkt in T haben. 4. Jeder alte Baum T erhält den Schlüssel d[T ] := +∞. Seine Markierung wird gelöscht.
4.6 Ein Minimalbaum-Algorithmus mit nahezu linearer Laufzeit Wachsen eines neuen Baumes 1. Wähle irgendeinen unmarkierten alten Baum T0 und füge ihn in die Prioritätsschlange Q mit Schlüssel d[T0 ] = −∞ ein. 2. Wiederhole die folgenden Schritte, bis Q leer ist oder |Q| > 22m/t , wobei t die Anzahl der alten Bäume zu Beginn der Phase ist. (a) Lösche einen alten Baum T mit minimalem Schlüssel aus Q und setze d[T ] := −∞.
(b) Wenn T 6= T0 , dann füge e[T ] zum Wald hinzu (e[T ] verbindet den alten Baum T mit dem aktuellen Baum, der T0 enthält). (c) Wenn T markiert ist, dann stoppe und beende den Wachstumsschritt wie unten geschildert. (d) Sonst, markiere T . Für jede Kante (u, v) mit u ∈ T und c(u, v) < d[tree[v]] setze e[tree[v]] := (u, v). Wenn d[tree[v]] = +∞, dann füge tree[v] in Q mit Schlüssel c(u, v) ein. Ansonsten erniedrige den Schlüsselwert von T in Q auf c(u, v). 3. Zum Beenden des Wachstumsschrittes leere Q und setze d[T ] := +∞ für jeden alte Baum T mit endlichem Schlüsselwert (diese sind die Bäume, die während der Phase in Q eingefügt wurden). Wir analysieren jetzt die Laufzeit des MST-Algorithmus. Die Zeit für Aufräumen und Initialisieren ist O(m). Sei t die Anzahl der alten Bäume, dann ist die Zeit für den Wachstumsschritt O(t log 22m/t + m) = O(m), (4.4) denn wir benötigen höchstens t E XTRACT-M IN Operationen auf einem Heap der Größe höchstens 22m/t und O(m) andere Heap-Operationen, von denen jede nur O(1) amortisierte Zeit benötigt. Insgesamt sehen wir, daß eine Phase O(m) Zeit benötigt.
Es bleibt die Frage, wie viele Phasen notwendig sind. Seien zu Beginn eine Phase t alte Bäume und m0 ≤ m Kanten vorhanden (einige Kanten sind möglicherweise gelöscht worden). Nach der Phase besitzt jeder Baum T , der übrigbleibt, mehr als 2 2m/t Kanten, die mindestens einen Endpunkt in T haben (Wenn T0 der erste Baum war, aus dem T entstanden ist, dann wuchs T0 , bis daß der Heap die Größe 22m/t überschritt. Zu diesem Zeitpunkt besaß der aktuelle Baum T 0 mehr als 22m/t inzidente Kanten. Nachher sind möglicherweise noch weitere Bäume mit T 0 verbunden worden, was zur Folge hatte, daß jetzt von diesen inzidenten Kanten einige beide Endpunkte im Endbaum T besitzen). Da jede der m0 Kanten nur zwei Endpunkte besitzt, erfüllt die Anzahl t0 der Bäume nach Ende der Phase 2m0 t0 ≤ 2m/t . 2 Die Schranke für die Heap-Größe in der nächsten Phase ist dann 0
22m/t ≥ 22
2m/t
.
Da die Startschranke für die Heap-Größe 2m/n ist und eine Heap-Größe von n nur in der letzten Phase möglich ist, haben wir höchstens min{ i : log(i) n ≤ m/n } + 1 = β(m, n) + O(1) Durchläufe. Wir hatten bereits oben festgestellt hatten, daß pro Phase nur O(m) Zeit benötigt wird. Daher ist die Gesamtkomplexität des Algorithmus in O(mβ(m, n)).
81
82
Datenstrukturen für disjunkte Mengen Eine Datenstruktur für disjunkte Mengen verwaltet eine Kollektion {S 1 , . . . , Sk } von disjunkten Mengen, welche sich dynamisch ändern. Jede Menge wird mit einem seiner Elemente, dem Repräsentanten der Menge, identifiziert. Folgende Operationen werden unterstüzt: M AKE -S ET (x) Erstellt eine neue Menge, deren einziges Element und damit Repräsentant x ist. U NION(x, y) Vereinigt die beiden Mengen, welche x und y enthalten, und erstellt eine neue Menge, deren Repräsentant irgend ein Element aus der Vereinigungsmenge ist. Es wird vorausgesetzt, daß die beiden Mengen disjunkt sind. Die Ausgangsmengen werden bei dieser Operation zerstört. F IND -S ET (x) Liefert (einen Zeiger auf) den Repräsentanten der Menge, welche x enthält. Datenstrukturen für disjunkte Mengen kommen immer dann ins Spiel, wenn man effizient dynamische Partitionen verwalten möchte. Wir haben bereits in Abschnitt 2.4 eine Anwendung, die Implementierung des MST-Algorithmus von Boruvka, kennengelernt. In diesem Kapitel betrachten wir einen weiteren MST-Algorithmus, in dem eine Datenstruktur für disjunkte Mengen extrem hilfreich ist.
5.1 Der Algorithmus von Kruskal Wir haben in Abschnitt 4.1 bereits den Algorithmus von Prim kennengelernt, der mit Hilfe der Fibonacci-Heaps aus Kapitel 4 in O(m + n log n) Zeit läuft. Abschnitt 4.6 konstruierte einen Algorithmus mit Laufzeit O(n + mβ(m, n)). Ein klassischer MST-Algorithmus ist der Algorithmus von Kruskal (Algorithmus 5.1), der folgende einfache Strategie benutzt. Man startet mit einer leeren Kantenmenge T . Dann fügt man iterativ die leichteste Kante zu T hinzu, die keinen Kreis induziert. Abbildung 5.1 zeigt ein Beispiel für die Ausführung des Algorithmus. Die Korrektheit des Algorithmus von Kruskal läßt sich wie folgt mit Hilfe von Satz 2.10 beweisen. Satz 5.1 Der Algorithmus von Kruskal findet einen minimalen aufspannenden Baum.
84
Datenstrukturen für disjunkte Mengen Algorithmus 5.1 Algorithmus von Kruskal zur Konstruktion eines MST. MST-K RUSKAL(G, c) Input: Ein zusammenhängender ungerichteter Graph G = (V, E) mit n := |V | und m := |E|, eine Kantenbewertungsfunktion c : E → R. Output: Ein minimaler aufspannender Baum T . 1 Sortiere die Kanten nach ihrem Gewicht: c(e1 ) ≤ · · · ≤ c(em ). 2 T ←∅ 3 for i ← 1, . . . , m do 4 if (V, T ∪ {ei }) ist zyklenfrei then 5 T ← T ∪ {ei } 6 end if 7 end for 8 return T Beweis: Wir zeigen durch Induktion nach der Kardinalität der Kantenmenge T des Kruskal-Algorithmus, daß T Teilmenge eines minimalen aufspannenden Baumes ist. Dies ist für T = ∅ trivial.
Wird in Schritt 4 eine Kante ei = (u, v) zu T hinzugefügt, so ist nach Konstruktion T ∪{ei } zyklenfrei. Sei A die Zusammenhangskomponente von (V, T ), in der u liegt und B := V \ A. Dann enthält T keine Kante aus [A, B]: eine solche Kante (a, b) mit a ∈ A, b ∈ B würde implizieren, daß auch b in der Komponente A liegt. Wir behaupten nun, daß e i eine leichteste Kante aus [A, B] ist. Mit Hilfe von Satz 2.10 folgt dann, daß auch T ∪ {e i } Teilmenge eines minimalen aufspannenden Baumes ist. Um die Behauptung zu zeigen, genügt es zu beweisen, daß die Kanten e 1 , . . . , ei−1 nicht im Schnitt [A, B] liegen. Wäre eine solche Kante ej = (a, b) im Schnitt, so folgt wegen ej ∈ / T , daß beim Testen von ej in Schritt 4 ej einen Zykel induziert hat. Dann gab es aber vor der Hinzunahme von ej bereits eine Kante in T , die in [A, B] liegt. Dies widerspricht der weiter oben gezeigten Tatsache, daß T keine Kante aus [A, B] enthält, wenn später e i getestet wird. Wir haben somit gezeigt, daß die Kantenmenge T , die der Kruskal-Algorithmus bei Abbruch liefert, Teilmenge eines minimalen aufspannenden Baumes ist. Die Menge (V, T ) ist zyklenfrei und offenbar maximal mit dieser Eigenschaft (sonst hätten wir noch eine Kante hinzufügen können). Daher ist (V, T ) ein auch ein aufspannender Baum. 2 Wir zeigen nun, wie man den Algorithmus von Kruskal in mit Hilfe einer Datenstruktur für disjunkte Mengen implementieren kann. Die Idee ist die folgende: Eine Kante ei = (u, v) induziert genau dann einen Kreis im (kreisfreien) Graphen (V, E), wenn u und v in der gleichen Zusammenhangskomponente vn (V, E) liegen. Da die Zusammenhangskomponenten eine Partition von V bilden, können wir diese mit einer Datenstruktur für disjunkte Mengen verwalten. Dann reduziert sich der Zyklentest darauf zu prüfen, ob F IND -S ET(u) = F IND -S ET(v) gilt. Wenn ei = (u, v) keinen Kreis induziert und zu E hinzugefügt wird, so verschmelzen die beiden disjunkten Zusammenhangskomponenten von u und v zu einer gemeinsamen Komponente. Dies können wir mit Hilfe von U NION(x, y) behandeln. Algorithmus 5.2 zeigt die Implementierung des Algorithmus von Kruskal mit Hilfe einer Datenstruktur für disjunkte Mengen. Im folgenden Satz fassen wir noch einmal die Korrektheit und die Laufzeit des Algorithmus von Kruskal zusammen. Satz 5.2 Der Algorithmus von Kruskal findet einen MST. Er benötigt neben dem Sortieren der Kanten gemäß ihrer Gewichte in einem Graphen mit n Knoten und m Kanten O(n) M AKE -S ET-, O(m) F IND -S ET und O(n) U NION-Operationen.
5.1 Der Algorithmus von Kruskal
1
1
4
2
3
1 3
2 2
4
7
3
1 1
3
5
5
6 2
5 8
2
1
4
2
3
4
9
5 2
7
5
5
9 2
4
2
3
6
3 1
2 4
2
5 3
2
8
1 1 1
6
2
2 7
5
(b) In den nächsten Schritten werden die Kanten (3, 6), (2, 5), (4, 5), (5, 7), (6, 9) und (8, 9) in dieser Reihenfolge zum Baum hinzugefügt, da keine von ihnen einen Zykel (durch graue Kantenhinterlegung hervorgehoben) induziert. Danach wird die Kante (1, 4) geprüft. Diese induziert aber einen Zykel, weshalb die Kante (1, 4) verworfen wird.
3
2
4
1
5 3
2 4
3
2 4
(a) Zunächst wird die Kante (1, 2) untersucht und zum Baum hinzugefügt.
1
4
2 2
2
4
85
9
8 2
(c) Die Kante (7, 8) kann wiederum zum Baum hinzugefügt werden, ohne einen Zykel zu verursachen.
5 2
4
5
6 2
5
7
9
8 3
2
(d) Nach dem Hinzufügen der Kante (7, 8) induzieren alle weiteren Kanten Zykel, so daß diese verworfen werden.
Abbildung 5.1: Berechnung eines minimalen aufspannenden Baumes mit dem KruskalAlgorithmus. Die dick gezeichneten Kanten gehören zur Menge T , die bei Ende des Algorithmus einen minimialen aufspannenden Baum bildet. Die gepunkteten Kanten sind Kanten, die durch Zyklentests verworfen wurden. Die gestrichelte Kante ist die aktuell untersuchte Kante.
86
Datenstrukturen für disjunkte Mengen Algorithmus 5.2 Implementierung des Algorithmus von Kruskal mit Hilfe einer Datenstruktur für disjunkte Mengen. Ein zusammenhängender ungerichteter Graph G = (V, E) mit n := |V | und m := |E|, eine Kantenbewertungsfunktion c : E → R. Output: Ein minimaler aufspannender Baum T . 1 Sortiere die Kanten nach ihrem Gewicht: c(e1 ) ≤ · · · ≤ c(em ). 2 T ←∅ 3 for all v ∈ V do 4 M AKE -S ET(v) 5 end for 6 for i ← 1, . . . , m do 7 if F IND -S ET(u) 6= F IND -S ET(v) then 8 T ← T ∪ {(u, v)} 9 U NION (u, v) 10 end if 11 end for 12 return T Input:
Beweis: Die Korrektheit haben wir bereits weiter oben in Satz 5.1 bewiesen. Offenbar gibt es genau n M AKE -S ET-Operationen für die n Knoten des Graphen. Für jede Kante werden zwei (vier, wenn jede Kante zweimal abgespeichert ist) F IND -S ET-Operationen benötigt. Jede U NION-Operation verringert die Anzahl der Komponenten um eins, so daß bei anfänglich n Komponenten nur n − 1 U NION-Operationen möglich sind. 2 Der Schlüssel für eine schnelle Laufzeit des Kruskal-Algorithmus ist eine effiziente Datenstruktur für disjunkte Mengen. Die Operationen für die Datenstruktur sind insbesondere dann der Flaschenhals, wenn die Kanten bereits sortiert vorliegen bzw. ein Sortieren in O(m) Zeit möglich ist, etwa, wenn alle Kantengewichte ganze Zahlen aus dem Bereich 1, . . . , m sind.
5.2 Eine einfache Datenstruktur Eine einfache Möglichkeit, die Mengen in einer Partition zu verwalten, ist es, lineare Listen zu verwenden: Wir halten jede Menge als lineare Liste, bei der der Kopf der Liste den Repräsentanten darstellt. Jedes Element in der Liste besitzt einen Zeiger auf den Kopf der Liste. Zusätzlich halten wir uns noch Zeiger auf den Kopf und das Ende jeder Liste. Abbildung 5.2 veranschaulicht die Listen-Implementierung.
head c
a
b
tail
Abbildung 5.2: Datenstruktur für disjunkte Mengen auf Basis von linearen Listen. Im Bild wird die Menge S = {a, b, c} dargestellt, ihr Repräsentant ist das Element c. Das Erstellen einer neuen Menge mittels M AKE -S ET kann in der Listenrepräsentation in O(1) Zeit implementiert werden: wir müssen lediglich ein Listenelement und Speicher für head und tail alloziieren und vier Zeiger initialisieren. F IND -S ET ist ebenfalls in
5.2 Eine einfache Datenstruktur
87
konstanter Zeit möglich: für ein Element x haben wir einen Zeiger auf seinen Listenkopf, so daß wir nur diesem Zeiger folgen müssen. Etwas aufwendiger ist das Vereinigen zweier Mengen U NION (x, y). Sei L x die Liste für die Menge, welche x enthält und Ly die entsprechende Liste für y (siehe Abbildung 5.3 (a) und (b)). Wir müssen eine Liste an die andere Liste anhängen. Angenommen, wir hängen Ly and Ly an. Da wir einen Zeiger auf das Ende von Lx halten, können wir die Listen zunächst einmal in konstanter Zeit verketten, wobei wir gleich den Zeiger auf das Ende der neuen Liste setzen (siehe Abbildung 5.3 (c)).
head b
x
a
tail (a) Die Menge Sx vor der Vereinigung
head y
c
tail (b) Die Menge Sy vor der Vereinigung
head b
x
a
y
c
tail (c) Anhängen der Liste von Sy an Sx
head b
x
a
y
c
tail (d) Endgültiges Ergebnis der Vereinigung
Abbildung 5.3: Vereinigen der beiden Mengen Sx = {x, a, b} und Sy = {y, c} Bisher haben wir nur O(1) Zeit benötigt. Jetzt müssen wir aber noch für alle Elemente in der Liste Ly den Zeiger an den Kopf der Liste neu setzen, so daß wir das Ergebnis aus Abbildung 5.3 (d) erhalten. Das kostet uns Θ(ny ) Zeit, wobei ny die Anzahl der Elemente in der Liste Ly bezeichnet. Wir betrachten jetzt eine beliebige Folge von m Operationen M AKE -S ET, F IND -S ET und U NION, von denen n M AKE -S ET-Operationen sind. Nach den obigen Überlegungen liefert
88
Datenstrukturen für disjunkte Mengen die Implementierung mit Hilfe von linearen Listen eine Laufzeit von n · O(1) + (m − n) · Θ(n) | {z } | {z }
für n M AKE -S ET
∈ O(mn),
n − m andere Operationen
wobei wir die Kosten für jedes U NION mit Θ(n) abgeschätzt haben, da keine Menge mehr als n Elemente enthalten kann. Wenn wir etwas genauer sind, können wir diese Schranke verbessern. Wenn wir die Liste Ly an die Liste Lx anhängen, entstehen und Kosten ny . Wahlweise könnten wir auch umgekehrt Lx an Ly anhängen. Auf jeden Fall ist es günstiger, die kleinere Liste hinten anzuhängen. Dazu müssen wir für jede Menge (Liste) noch zusätzlich ihre Größe abspeichern. Dann können wir bei U NION in O(1) Zeit die kleinere Liste erkennen und diese hinten an die größere anhängen. Die Größe der Vereinigung ist natürlich in konstanter Zeit aktualisierbar. Wir nennen diese Zusatzregel für die Vereinigung die Größenregel. Der folgende Satz zeigt, daß die Größenregel die Laufzeit deutlich verringert, nämlich von O(mn) auf O(m + n log n). Satz 5.3 Eine Folge von m Operationen M AKE -S ET, F IND -S ET und U NION, von denen n Operationen M AKE -S ET sind, benötigt in der Listenimplementierung mit der Größenregel benötigt O(m + n log n) Zeit. Bevor wir den Satz beweisen, zeigen wir kurz, was er für den Kruskal-Algorithmus aus Abschnitt 5.1 bedeutet. Zusammen mit Satz 5.2 ergibt sich eine Laufzeit von O((m + n) + n log n) = O(m + n log n) plus O(m log m) für das Sortieren der Kanten. Falls die Kanten bereits sortiert sind oder wir sie in O(m) Zeit sortieren können, so ist damit der sehr einfache Algorithmus von Kruskal genauso schnell wie der Algorithmus von Prim mit Fibonacci-Heaps. Wir werden dieses Ergebnis nachher noch für dünne Graphen mit Hilfe einer neuen Datenstruktur für disjunkte Mengen verbessern. Beobachtung 5.4 Wird der Kruskal-Algorithmus unter Zuhilfenahme der Listenimplementierung mit Größenregel implementiert, so benötigt er O(m + n log n) Zeit plus die Zeit zum Sortieren der Kantengewichte. Beweis: (Satz 5.3) Offenbar sind die U NION-Operationen bei der Listenimplementierung der Punkt, auf den wir bei der Analyse besonders achten müssen, da m F IND-Operationen sowieso nur insgesamt O(m) Zeit benötigen. Den Gesamtaufwand für die U NION-Operationen können wir abschätzen, indem wir folgende Beobachtung benutzen: der Aufwand für alle U NION-Operationen beträgt (bis auf einen konstanten Faktor) der Anzahl der Veränderungen für die Zeiger auf die Repräsentanten in den Listenelementen. Wenn wir somit zeigen können, daß sich für jedes Element der Zeiger auf den Listenkopf (d.h. auf den Repräsentanten) im Verlauf der Operationenfolge nur O(log n) mal ändert, so ergibt dies die passende obere Schranke von O(n log n) für die Kosten aller U NION-Operationen. Sei dazu x ein Element. Wir zeigen durch Induktion, daß nach i Zeigeränderungen auf den Listenkopf im Listenelement von x die Menge, in der x enthalten ist mindestens 2 i−1 Elemente enthält. Da keine Menge größer als n werden kann, ergibt dies die gewünschte Anzahl von O(log n) Zeigeränderungen. Man beachte, daß sich die Größe einer Menge im Verlauf der Operationenfolge niemals verkleinern kann. Die Behauptung ist offenbar richtig für i = 1: die erste Zeigeränderung erfolgt durch ein M AKE -S ET(x) (sonst wäre x gar nicht an unserer Operationenfolge beteiligt) und die x enthaltende Menge hat 1 = 20 = 21−1 Elemente.
5.3 Baumrepräsentation mit Pfadkompression und Vereinigung nach Rang
89
Wir betrachten nun die ite Zeigeränderung, die durch ein U NION erfolgen muß. Sei S x die Menge, welche x vor der Vereinigung enthält und Sy die andere Menge. Nach Induktionsvoraussetzung gilt |Sx | ≥ 2i−2 . Da sich der Zeiger von x ändert, muß Sx an Sy angehängt werden. Nach der der Größenregel enthält dann Sy mindestens so viele Elemente wie Sx . Da Sx und Sy disjunkt sind, gilt daher dann für die Vereinigungsmenge |Sx ∪ Sy | = |Sx | + |Sy | ≥ |Sx | + |Sx | = 2 · |Sx | ≥ 2 · 2i−2 = 2i−1 . Dies beendet den Beweis.
2
5.3 Baumrepräsentation mit Pfadkompression und Vereinigung nach Rang In diesem Abschnitt stellen wir eine deutlich trickreichere Datenstruktur für disjunkte Mengen vor, die für fast alle Fälle eine deutlich bessere Laufzeit als die Listenimplementierung ermöglicht. Wir repräsentieren jede Menge in unserer Partition als Wurzelbaum, wobei die Wurzel des Baums das Element enthält, welches die Menge repräsentiert. Jeder Knoten im Baum hat einen Zeiger auf seinen Vater im Baum. Abbildung 5.4 veranschaulicht die neue Datenstruktur. a
b
a
b
b
d
c (a) Darstellung der Menge {a, b, c, d} mit a als Repräsentanten
d (b) Alternative ge {a, b, c, d}
Darstellung
der
Men-
Abbildung 5.4: Datenstruktur für disjunkte Mengen auf Basis von Bäumen. Eine M AKE -S ET-Operation erstellt in O(1) Zeit einen Baum mit einem Knoten (siehe Algorithmus 5.3). Für einen Knoten x ist p[x] ein Zeiger auf den Vaterknoten, wobei wir wie bei der Listenimplementierung zweckmäßigerweise für die Wurzel p[x] = x setzen. Im Pseudocode für M AKE -S ET taucht auch die zusätzliche Information rank[x] auf. Diese wird nachher noch von entscheidender Bedeutung sein. Für einen Knoten x ist rank[x] eine obere Schranke für den längsten Weg von x zu einem Blatt im Teilbaum mit Wurzel x. Bei F IND -S ET(x) können wir ausgehend von x entlang der Vaterzeiger bis zur Wurzel des Baums gehen, der x enthält. Ist x in diesem Baum auf Tiefe h, so benötigen wir hierfür Θ(h) Zeit. Daher wird es unser Anliegen sein, die Bäume möglichst »flach« zu halten: Ein Baum wie in Abbildung 5.4 (a) ist sicherlich dem Baum in Abbildung 5.4 (b) vorzuziehen. Eine Möglichkeit, die Höhe der Bäume zu verkürzen, ist die sogenannte Pfadkompression: nachdem wir von x ausgehend den Weg zur Wurzel hinaufgestiegen sind, hängen wir jeden
90
Datenstrukturen für disjunkte Mengen Algorithmus 5.3 Implementierung der M AKE -S ET-Operation in der Datenstruktur auf Basis von Bäumen. M AKE -S ET(x) 1 p[x] ← x 2 rank[x] ← 0 Algorithmus 5.4 Implementierung der F IND -S ET-Operation mit Pfadkompression. F IND -S ET(x) 1 if p[x] 6= x then 2 p[x] ← F IND -S ET(p[x]) 3 end if 4 return p[x]
Knoten auf diesem Weg mitsamt seinem Teilbaum anschließend direkt als Sohn an die Wurzel. Wie in Algorithmus 5.4 zu sehen, können wir diese Operation ausführen, ohne die Laufzeit für ein F IND -S ET asymptotisch zu erhöhen: die Laufzeit erhöht sich nur um einen konstanten Faktor. Abbildung 5.5 veranschaulicht das Vorgehen.
a
b c
d a e x
e
d
c
x
(a) Ausgangsbaum bei F IND -S ET(x)
(b) Resultat nach F IND -S ET(x)
Abbildung 5.5: Pfadkompression bei der F IND -S ET-Operation. Zum Vereinigen zweier Mengen/Bäume S1 und S2 können wir die Wurzel von S1 zu einem Sohn von S2 machen. Damit benötigen wir für U NION(x, y) außer der Zeit für die zwei F IND -S ET(x) und F IND -S ET(y) nur O(1) Zeit. Man beachte, daß es uns prinzipiell frei steht, welchen der beiden Wurzelknoten von S1 und S2 wir zur Wurzel der Vereinigungsmenge machen. Motiviert von der (erfolgreichen) Größenregel in der Listenimplementierung möchten wir gerne den kleineren Baum, bzw. den flacheren Baum, an den größeren anhängen (siehe Abbildung 5.6).
b
5.3 Baumrepräsentation mit Pfadkompression und Vereinigung nach Rang
rank[a] rank[a]
a rank[a] rank[a]
a
rank[b] + 1
b
b
rank[b]
b
b a a
(a) rank(a) < rank(b)
(b) rank(a) = rank(b)
Abbildung 5.6: Vereinigung nach Rang. Hier kommen jetzt die Ränge rank[x] der Knoten ins Spiel. Wie bereits erwähnt, liefert rank[x] eine untere Schranke für die Höhe des Baumes mit Wurzel x. Beim Vereinigen wird der Wurzelknoten mit dem größeren Rang zur Wurzel der Vereinigungsmenge. Gleichzeitig aktualisieren wir die Ränge nach folgender Regel: werden zwei Bäume mit gleichem Rang vereinigt, so erhöht sich der Rang der neuen Wurzel um eins (siehe Abbildung 5.6). Algorithmus 5.5 zeigt den Pseudocode für die Vereinigung nach Rang. Algorithmus 5.5 Implementierung der U NION-Operation bei der Vereinigung nach Rang. U NION(x, y) 1 rx ← F IND -S ET (x) 2 ry ← F IND -S ET (y) 3 L INK (rx , ry ) L INK(rx , ry ) 1 if rank[rx ] < rank[ry ] then 2 p[rx ] ← ry 3 else 4 p[ry ] ← rx 5 if rank[rx ] = rank[ry ] then 6 rank[rx ] ← rank[rx ] + 1 7 end if 8 end if Bevor wir mit Hilfe der amortisierten Analyse aus Kapitel 3 eine fast lineare Schranke für m Operationen auf Basis der neuen Datenstruktur herleiten, beweisen wir eine schwächere Schranke. Dabei zeigen wir auch viele Eigenschaften, die uns nachher bei der komplizierten Analyse hilfreich sein werden. Wir betrachten im Folgenden immer Operationenfolgen, in denen n Operationen M AKE -S ET sind. Die Gesamtzahl der Operationen in der Folge bezeichnen wir wie bisher mit m ≥ n. Lemma 5.5 Die Ränge, welche die Datenstruktur verwaltet, besitzen im Verlauf einer Operationenfolge folgende Eigenschaften:
(i) Der Rang eines Knotens x ist anfangs null und wächst schwach monoton über die Zeit. Sobald im Verlauf der Operationenfolge einmal p[x] 6= x gilt, bleibt der Rang
91
92
Datenstrukturen für disjunkte Mengen
von x konstant. (ii) Für alle Knoten x gilt rank[x] ≤ rank[p[x]], mit strikter Ungleichheit, falls p[x] 6= x. (iii) Wenn man den Pfad von einem beliebigen Knoten zu seinem Wurzelknoten hinaufsteigt, so wachsen auf diesem Pfad die Ränge strikt monoton an. Beweis: Wir beweisen die Aussagen durch Induktion nach der Anzahl k der Operationen. Offenbar sind alle Aussagen für k = 0 oder k = 1 richtig. Wir betrachten nun die k te Operation: (i) Falls p[x] 6= x gilt, so kann x niemals mehr Wurzel eines Baums werden. Da sich höchsten die Ränge von Wurzeln erhöhen, folgt die Aussage. (ii) Wenn vor der kten Operation bereits p[x] 6= x galt, so folgt die Aussage aus (i), da sich der Rang von x nicht mehr erhöhen kann. Falls nach der kten Operation immer noch x = p[x] gilt, so ist ebenfalls nichts mehr zu zeigen. Es bleibt der Fall, daß vor der kten Operation p[x] = x und nachher p[x] 6= x. Dann wird aber in der kten Operation x Sohn eines Knotens, der entweder bereits vorher zumindest aber nachher größeren Rang besitzt. (iii) Da sich Ränge nur in den Wurzelknoten erhöhen können, folgt die Behauptung sofort aus (i) und (ii). 2 Lemma 5.6 Ist rank[x] = k , so besitzt der (Teil-) Baum mit Wurzel x mindestens 2 k Knoten. Beweis: Wieder erfolgt der Beweis durch Induktion nach der Anzahl der Operationen. Die Aussage ist trivial, wenn nur maximal eine Operation erfolgt. Wenn sich in der kten Operation der Rang von x auf r erhöht (nur dann ist noch etwas zu zeigen), so wurde x an den Baum mit Wurzel x ein anderer Baum mit Wurzel y und rank[y] = rank[x] = r − 1 angehängt. Nach Induktionsvoraussetzung hat der Baum mit Wurzel y mindestens 2r−1 Knoten. Ebenfalls nach Induktionsvoraussetzung hat der alte an x wurzelnde Baum mindestens 2r−1 Knoten, so daß nun im neuen Baum mit Wurzel x mindestens 2 r−1 + 2r−1 = 2r Knoten sind. 2 Korollar 5.7 Der Rang jedes Knoten ist maximal blog2 nc. Beweis: Ist rank[x] = k, so hat der Teilbaum mit Wurzel x nach Lemma 5.6 mindestens 2k Knoten. Da es aber nur n Elemente überhaupt gibt, folgt 2k ≤ n, also k ≤ blog2 nc. 2 Korollar 5.8 In einer beliebigen Operationenfolge mit m Operationen, von denen n M AKE -S ET-Operationen sind, hat jeder auftretende Baum maximal Höhe blog 2 nc + 1. Beweis: Nach Korollar 5.7 hat jeder Knoten maximal Rang blog 2 nc. Da nach Lemma 5.5 (iii) die Ränge auf dem Weg von einem Knoten zum Vaterknoten strikt monoton fallen und kein Rang negativ ist, kann ein Baum daher maximal Höhe blog 2 nc + 1 haben. 2 Korollar 5.8 impliziert, daß jede F IND -S ET-Operation O(log n) Zeit benötigt. Somit benötigt auch unsere Implementierung von U NION in Algorithmus 5.5 nur O(log n) Zeit.
Wenn wir also wiederum den gesamten Zeitaufwand für eine Folge von m Operationen, von denen n M AKE -S ET sind, betrachten, so haben wir damit bereits gezeigt, daß in der baumbasierten Implementierung dafür nur Kosten O(n + m log n) anfallen. Dieses Ergebnis benutzt übrigens nicht, daß wir Pfadkompression benutzen. Es beruht allein auf der Tatsache, daß die Vereinigung nach Rang erfolgt.
5.4 Analyse von Pfadkompression und Vereinigung nach Rang
93
5.4 Analyse von Pfadkompression und Vereinigung nach Rang In diesem Abschnitt beweisen wir eine verbesserte (scharfe) obere Schranke für den Zeitaufwand, der bei der neuen Datenstruktur mit Pfadkompression und Vereingung nach Rang anfällt. Dazu benutzen wir die amortisierte Analyse aus Kapitel 3 mit einem geschickten Potentialfunktionsargument.
5.4.1 Eine explosiv wachsende Funktion Zunächst führen wir aber die Funktion A und ihre funktionale Umkehrfunktion α ein, mit deren Hilfe wir die Laufzeit ausdrücken werden. Für ganze Zahlen k ≥ 0 und j ≥ 1 definieren wir die Funktion Ak (j) durch: A0 (j) := j + 1 (j+1)
für k ≥ 1.
Ak (j) := Ak−1 (j) (j+1)
Hierbei bezeichnet Ak−1 die (j + 1)-fache Iteration der Funktion Ak−1 . Die ite Iteration einer Funktion f ist dabei definiert als: f (0) (x) := x f (i) (x) := f (f (i−1) (x))
für i ≥ 1.
Mit anderen Worten: f (i) (x) = f (f (. . . f (x) . . . ). | {z } i mal
Wir nennen den Index k in Ak die Stufe der Funktion A. Zu A definieren wir die inverse Funktion α wie folgt: α(n) := min{ k : Ak (1) ≥ n }, (5.1) d.h., α(n) ist die kleinste Stufe k, in der Ak (1) mindestens n ist.
Wir zeigen zunächst einmal, wie explosiv die Funktion A wirklich wächst (im Umkehrschluß heißt das, daß α extrem langsam wächst). Dazu betrachten wir die ersten Stufen von A. Wir wissen bereits aus der Definition, daß A0 (j) = j + 1
(5.2)
ist, also A in der nullten Stufe linear wächst. Für die erste Stufe gilt dann (j+1)
A1 (j) = A0
(j) = 2j + 1.
(5.3)
Das sieht immer noch recht »gemütlich« aus, die Explosion geht in der nächsten Stufe aber (j+1) los. In der zweiten Stufe haben wir A2 (j) = A1 (j). Wir müssen die (j + 1)fache Interation der ersten Stufe aus (5.3) berechnen. Eine einfache Induktion nach i zeigt, daß (i) A1 (j) = 2i+1 (j + 1) − 1. Das ergibt: A2 (j) = 2j+1 (j + 1) − 1.
(j+1)
Die dritte Stufe ist schon beeindruckend: Da A3 (j) = A2 ··
(5.4) (j), folgt aus (5.4), daß
·2
2 A3 (j) > 2 | {z },
(5.5)
j+1
wobei hier ein »Turm« aus j+1 Zweien gebaut wird. Bevor wir uns der Inversen Funktion α widmen, beobachten wir zunächst, daß Ak (j) sowohl im Argument j als auch in der Stufe k streng monoton wachsend ist.
94
Datenstrukturen für disjunkte Mengen Lemma 5.9 Der Ausdruck Ak (j) ist streng monoton wachsend in j und k . Beweis: Die Behauptung folgt durch Induktion nach k: Für k = 0 ist die Aussage aus der Definition A0 (j) = j + 1 klar. Ansonsten haben wir, falls die Aussage für 0, . . . , k − 1 bereits bewiesen ist: IV
(j+1)
(j+1)
Ak (j) = Ak−1 (j) ≥ Ak−2 (j) = Ak−1 (j) und (j+1)
IV
(j+1)
Ak (j) = Ak−1 (j) ≥ Ak−1 (j − 1) = Ak (j − 1). Dies zeigt die gewünschte Monotonie.
2
Aus (5.2), (5.3), (5.4) und (5.5) folgt: A0 (1) = 2 A1 (1) = 3 A2 (1) = 7 (2)
A3 (1) = A2 (1) = A2 (A2 (1)) = A2 (7) = 27+1 (7 + 1) − 1 = 28 · 8 − 1 = 21 1 − 1 = 2047 (2)
Wir rechnen noch A4 (1) aus. Da nach Definition A4 (1) = A3 (1) = A3 (A3 (1)) = A3 (2047) gilt, entspricht der Wert A4 (1) mindestens dem Wert eines Turms aus 2048 Zweien (vgl. (5.5)), einem riesigen Wert. Wir schätzen diesen Wert extrem grob ab: A4 (1) = A3 (2047) A2 (2047) =2
2048
=2
2054
2
2053
(da A monoton in jedem Argument ist, siehe Lemma 5.9)
· 2048 − 1 −1
= 10log10 (2)·2053 > 10684 . Man schätzt, daß die Zahl der Atome im Universum etwa 1080 beträgt. Damit ist A4 (1) bereits mit unserer Abschätzung (wir haben A3 (2047), d.h., den Turm aus 2048 Zweien extrem grob nach unten durch A2 (2047) abgeschätzt) unvorstellbar groß. Für die inverse Funktion α ergeben unsere Betrachtungen: 0 1 2 α(n) = 3 4 5 .. .
für 0 ≤ n ≤ 2 für n = 3 für 4 ≤ n ≤ 7 für 8 ≤ n ≤ 2047 für 2048 ≤ n ≤ A4 (1) ( 10684 ) für A4 (1) + 1 ≤ n ≤ A5 (1) .. .
Bereits A4 (1) ist derart groß, daß man für »alle praktischen Fälle« α(n) ≤ 4 annehmen kann.
5.4 Analyse von Pfadkompression und Vereinigung nach Rang
95
5.4.2 Amortisierte Analyse mit Potentialfunktionsargument Für die amortisierte Analyse ordnen wir jedem Knoten x in unserer Datenstruktur D i nach der iten Operation ein Potential φi (x) zu. Das Potential der kompletten Datenstruktur ist P Φ(Di ) = x φi (x). Am Anfang setzen wir Φ(D0 ) := 0. Das Potential wird die Eigenschaft haben, daß Φ(Di ) ≥ 0 für alle i gilt, so daß mit unseren Überlegungen aus Kapitel 3 folgt, daß wir die realen Kosten für eine Folge von Operatione nach oben durch die amortisierten Kosten abschätzen können. Zur Definition von φi (x) treffen wir eine Fallunterscheidung. Ist x eine Wurzel oder ist rank[x] = 0, so sei φi (x) := α(n) · rank[x]. Für den anderen Fall benötigen wir zwei weitere Hilfsgrößen. Die Stufe von x ist die größte Stufe k, so daß A k (rank[x]) höchstens dem Rang von p[x] entspricht: `(x) := max{ k ∈ N : Ak (rank[x]) ≤ rank[p[x]] }.
(5.6)
Wir haben A0 (rank[x]) = rank[x] + 1 ≤ rank[p[x]], wobei die letzte Ungleichung aus Lemma 5.5 (ii) folgt. Außerdem gilt (falls rank[x] ≥ 1): Aα(n) (rank[x]) ≥ Aα(n) (1) ≥n
(nach Lemma 5.9) (nach Definition von α(n))
> blog2 nc ≥ rank[x]
(nach Korollar 5.7)
Daher ist `(x) wohldefiniert und es gilt die folgende Eigenschaft: Eigenschaft 5.10 Für die Stufe `(x) eines Knotens x gilt: 0 ≤ `(x) < α(n).
Im Verlauf einer Operationenfolge steigt `(x) schwach monoton. Die Monotonie von `(x) folgt daraus, daß sich der Rang von x nicht mehr ändert, wenn x kein Wurzelknoten ist (siehe Lemma 5.5), und aus der Monotonie des Rangs des Vaterknotens. Als zweite Hilfsgröße definieren wir den Index eines Knotens x durch (i)
index(x) := max{ i ∈ N : A`(x) (rank[x]) ≤ rank[p[x]] }. Der Wert index(x) ist die größte Anzahl von Iteration für A`(x) , angewendet auf rank[x], so daß als Ergebnis höchstens der Rang des Vaterknotens entsteht. Es gilt nach Definition von `(x): (1)
A`(x) (rank[x]) = A`(x) (rank[x]) ≤ rank[p[x]], also ist index(x) ≥ 1. Andererseits haben wir (rank[x]+1)
A`(x)
(rank[x]) = A`(x)+1 (rank[x]) > rank[p[x]]
(nach Definition von A) (nach Definition von `(x) und Lemma 5.9).
Daher ist auch index(x) wohldefiniert und es gilt: Eigenschaft 5.11 Für den Index index(x) eines Knotens x gilt: 1 ≤ index(x) ≤ rank[x].
96
Datenstrukturen für disjunkte Mengen Damit können wir nun die benötigte Potentialfunktion definieren: Definition 5.12 Das Potential φi (x) eines Knotens x nach i Operationen ist definiert als: ( α(n) · rank[x] , falls x Wurzelknoten ist oder rank[x] = 0 φi (x) := (α(n) − `(x)) · rank[x] − index(x) , falls x kein Wurzelknoten ist und rank[x] ≥ 1
Das Potential Φi := Φ(Di ) unserer P baumartigen Datenstruktur nach der iten Operation ist definiert als Φ0 := 0 und Φi := x φi (x) für i ≥ 1. Lemma 5.13 Für jeden Knoten x und für alle i ≥ 0 gilt:
0 ≤ φi (x) ≤ α(n) · rank[x].
Insbesondere ist Φi ≥ 0 für alle i ≥ 0. Beweis: Die Aussage ist trivial, wenn x ein Wurzelknoten ist oder rank[x] = 0 gilt. Sei daher rank[x] > 0 und x kein Wurzelknoten. Dann gilt: (α(n) − `(x)) · rank[x] − index(x) ≥ rank[x] − index(x) ≥ rank[x] − rank[x] = 0.
(da `(x) < α(n)) (nach Eigenschaft 5.11)
Andererseits haben wir auch (α(n) − `(x)) · rank[x] − index(x) ≤ (α(n) − `(x)) · rank[x] (nach Eigenschaft 5.11) ≤ α(n) · rank[x]
(nach Eigenschaft 5.10). 2
Wir beginnen nun mit der Analyse der amortisierten Kosten der einzelnen Operationen: Lemma 5.14 Die amortisierten Kosten jeder M AKE -S ET-Operation sind O(1). Beweis: Die realen Kosten der M AKE -S ET-Operation sind O(1) (siehe Algorithmus 5.3). Die Operation generiert einen neuen Wurzelknoten x, der nach Definition unseres Potentials Potentialwert φ(x) = 0 hat. Alle anderen Potentiale bleiben unverändert. Somit ergibt sich auch insgesamt keine Potentialänderung und die amortisierten Kosten entsprechen den (konstanten) realen Kosten. 2 Um die Kosten für eine U NION-Operation abzuschätzen, zerlegen wir U NION in die F IND S ET- und die L INK-Operation. Es genügt offenbar zu zeigen, daß jede F IND -S ET- und jede L INK-Operation amortisierte Kosten O(α(n)) hat, um eine Schranke von O(α(n)) für die Kosten von U NION zu erhalten. Lemma 5.15 Die amortisierten Kosten für jede L INK-Operation sind O(α(n)). Beweis: Sei die ite Operation L INK(rx , ry ), bei der ry zur Wurzel des gemeinsamen Baums gemacht wird. Die realen Kosten hierfür sind in O(1). Es ändern sich höchstens die folgenden Potentiale: 1. φ(rx ), φ(ry );
5.4 Analyse von Pfadkompression und Vereinigung nach Rang
97
2. die Potentiale der Söhne z von ry , da zwar rank[z] unverändert bleibt, sich aber möglicherweise rank[p[z]] = rank[ry ] um eins erhöht und sich damit wiederum `(z) und index(z) ändern. Für die Potentialänderung in der iten Operation ergibt sich damit: X Φi − Φi−1 = φi (rx ) − φi−1 (rx ) + φi (ry ) − φi−1 (ry ) +
z: z Sohn von ry
(φi (z) − φi−1 (z)).
Wir werden zunächst zeigen, daß im Fall 2 das Potential eines solchen Knotens z nicht erhöht. Wir erinnern uns daran (Eigenschaft 5.10), daß ` monoton steigend war. Wenn `(z) nun in der iten Operation unverändert bleibt, so kann index(z) nicht kleiner werden, sondern höchstens wachsen. Damit folgt aus der Definition des Potentials φ i (z) ≤ φi−1 (z). Sollte `(z) in der iten Operation ansteigen, so steigt `(z) um mindestens eins. Dann fällt aber der vordere Teil des Potentials, also (α(n) − `(z)) · rank[z] um mindestens rank[z]. Da nach Eigenschaft 5.11 der Index index(z) höchstens gleich rank[z] ist, kann er auch höchstens um rank[z] − 1 steigen/fallen. Als Resultat folgt, daß auch hier φ i (z) ≤ φ(z) gilt. Unser Zwischenresultat zeigt nun, daß für die Potentialänderung gilt: X Φi − Φi−1 = φi (rx ) − φi−1 (rx ) + φi (ry ) − φi−1 (ry ) + (φi (z) − φi−1 (z)) {z } | z: z Sohn von ry
≤0
≤ φi (rx ) − φi−1 (rx ) + φi (ry ) − φi−1 (ry )
Es verbleibt, die Potentialänderung für rx und ry abzuschätzen. Da rx vor der iten Operation eine Wurzel war, gilt φi−1 (rx ) = α(n) · rank[rx ]. Andererseits gilt: φi (rx ) = (α(n) − `(rx )) · rank[rx ] − index(rx ) < α(n) · rank[rx ] = φi−1 (rx ).
(da `(x) ≥ 0 und index(x) ≥ 1)
Also fällt das Potential von rx um mindestens eins. Der verbleibende Knoten ry ist sowohl vor als auch nach der L INK-Operation eine Wurzel, d.h., φi−1 (ry ) = α(n) · rank[ry ] und φi (ry ) = α(n) · rank0 [ry ], wobei rank0 [ry ] den neuen Rang von ry nach dem L INK bezeichnet. Wir haben also φi (ry ) − φi−1 (ry ) = α(n) · (rank0 [y] − rank[y]) ≤ α(n) · (rank[y] + 1 − rank[y]) = α(n).
Hierbei haben wir ausgenutzt, daß sich bei einem L INK der Rang eines Wurzelknotens maximal um eins erhöhen kann. Wir haben gezeigt, daß sich das Potential bei einem L INK maximal um α(n) erhöht. Da die realen Kosten konstant sind, betragen die amortisierten Kosten maximal O(1) + α(n) = O(α(n)). 2 Nun analysieren wir noch die F IND -S ET-Operation. Lemma 5.16 Die amortisierten Kosten für jede F IND -S ET-Operation sind O(α(n)).
98
Datenstrukturen für disjunkte Mengen Beweis: Sei die ite Operation F IND -S ET(x). Wenn sich x auf Höhe h befindet, d.h., wenn der Pfad von x zur Wurzel rx seines Baumes genau h Elemente besitzt, dann sind die realen Kosten für F IND -S ET(x) in O(h). Somit ergibt sich für die amortisierten Kosten: ai = O(h) + Φi − Φi−1 .
(5.7)
Unser Ziel ist es, die Potentialdifferenz Φi − Φi−1 so abzuschätzen, daß wir ai ∈ O(α(n)) erhalten. Zunächst zeigen wir, daß sich für keinen Knoten sein Potential bei der iten Operation erhöht. Dazu müssen wir folgende Knoten betrachten: 1. Die Wurzel rx des Baums, der x enthält. Es gilt φi (rx ) = α(n) · rank[rx ] = φi−1 (rx ), da ein F IND -S ET die Ränge unverändert läßt. 2. Alle Knoten y im Baum von x mit y 6= rx .
In diesem Fall haben wir φj (y) = (α(n) − `(y)) · rank[y] − index(y) für j = i − 1, i. Da y kein Wurzelknoten verändert die F IND -S ET-Operation rank[y] nicht.
Der Beweis, daß φi (y) ≤ φi−1 (y) verläuft analog zu Lemma 5.15. Wenn `(y) in der iten Operation unverändert bleibt, so kann index(y) nicht kleiner werden, sondern höchstens wachsen. Damit folgt aus der Definition des Potentials φ i (y) ≤ φi−1 (y). Sollte `(y) in der iten Operation ansteigen, so steigt `(y) um mindestens eins. Dann fällt aber der vordere Teil des Potentials, also (α(n) − `(y)) · rank[y] um mindestens rank[y]. Da nach Eigenschaft 5.11 der Index index(y) höchstens gleich rank[y] ist, kann er auch höchstens um rank[y] − 1 steigen/fallen. Als Resultat folgt, daß auch hier φi (y) ≤ φi−1 (y) gilt. Im Hinblick auf (5.7) haben wir mit der obigen Argumentation bereits gezeigt, daß a i = O(h) gilt. Das ist aber noch nicht genug. Wir werden jetzt zeigen, daß für mindestens max{0, h − (α(n) + 2)} Knoten auf dem Weg von x zur Wurzel das Potential um jeweils mindestens eins fällt. Sei a ein Knoten auf dem Suchweg vom gesuchten Element x zur Wurzel r x mit folgenden Eigenschaften: (i) rank[a] > 0 (ii) Es gibt einen Knoten b ∈ / {a, rx } auf dem Weg von a zur Wurzel rx mit `(a) = `(b). Abbildung 5.7 illustriert die Situation. Zunächst argumentieren wir, daß es mindestens max{0, h − (α(n) + 2)} solche Knoten a gibt: Nach Eigenschaft 5.10 kommen überhaupt nur Werte zwischen 0 und α(n) − 1 als Stufen `(z) von Knoten z vor. Für jede Stufe k, k = 0, . . . , α(n) − 1 erfüllt höchstens der letzte Knoten der Stufe (von unten auf dem Suchpfad zur Wurzel hin gesehen) die Bedingung (ii) mit der abgeschwächten Bedinung b 6= a statt b ∈ / {a, r x } nicht. Mit der Wurzel rx verletzen höchstens α(n) + 1 Knoten die Bedingung (ii). Möglicherweise erfüllt der erste Knoten, der Knoten x auf dem Suchpfad die Bedingung (i) nicht (er könnte rank[x] = 0 haben). Es bleiben uns also noch mindestens h − α(n) − 2 = h − (α(n) + 2) Knoten übrig, welche (i) und (ii) erfüllen. Wir zeigen nun, daß für jeden der mindestens h − (α(h) + 2) Knoten das Potential um mindestens eins fällt. Sei a ein solcher Knoten. Wir zeigen, daß das Potential von a bei der iten Operation echt fällt: φi (a) ≤ φi−1 (a) − 1. Sei b ein Knoten auf dem Weg von a zur Wurzel rx mit `(a) = `(b) = k (so ein Knoten existiert nach (ii)). Wir erinnern
5.4 Analyse von Pfadkompression und Vereinigung nach Rang
99
rx
b
rx a x
a
b
x
(a) Ausgangsbaum bei F IND -S ET(x). Es gilt `(a) = `(b).
(b) Resultat nach F IND -S ET(x)
Abbildung 5.7: Analyse der F IND -S ET-Operation. uns, daß die Ränge auf dem Weg zur Wurzel monoton steigen (Lemma 5.5 (ii)) und somit rank[b] ≥ rank[p[a]] > rank[a] gilt. Damit ergibt sich: rank[p[b]] ≥ Ak (rank[b]) ≥ Ak (rank[p[a]]) (index(a)
≥ Ak (Ak =
(nach Definition von k = `(b)) (da Ak monoton steigend)
(rank[a]))
(nach Definition von index(a))
(index(a)+1) Ak (rank[a]).
(5.8)
Sei s := index(a) der Wert von index(x) vor F IND -S ET (x) und der damit verbundenen Pfadkompression. Nach (5.8) haben wir vor der iten Operation rank[p[b]] ≥ (s+1) Ak (rank[a]). Nach F IND -S ET(x) und der damit verbundenen Pfadkompression gilt p[a] = p[b] (vgl. Bild 5.7). Da rank[p[b]] bei der Pfadkompression auf keinen Fall kleiner wird, haben wir nach der iten Operation damit (s+1)
rank[p[a]] ≥ Ak
(rank[a]).
(5.9)
Entweder steigt nun durch die Pfadkompression `(a) um mindestens eins, oder wegen (5.9) erhöht sich index(a) mindestens um eins auf i + 1. Wenn `(a) unverändert bleibt und index(a) auf i + 1 ansteigt, so haben wir φi (a) = (α(n) − `(a)) · rank[a] − (i + 1) = φi−1 (a) − 1. Wenn `(a) ansteigt, so haben wir φi (a) = φi−1 (a) − rank[a] − index(a) ≤ φi−1 (a) − 1,
100
Datenstrukturen für disjunkte Mengen da nach Eigenschaft 5.11 1 ≤ index(a) ≤ rank[a] und sich somit index(a) um maximal rank[a] − 1 ändern kann.
Wir haben somit gezeigt, daß sich in der iten Operation für keinen Knoten das Potential erhöht, aber für mindestens max{0, h−(α(n)+2)} das Potential um mindestens eins fällt. Daraus folgt nun für die amortisierten Kosten der iten Operation: ai = Φi − Φi−1 + O(h) ≤ −(h − (α(n) + 2)) + O(h) = O(α(n)). Hierbei haben wir wieder ausgenutzt, daß wir das Potential derart skalieren könnten, daß die Konstante im O(h) Term ausgeglichen wird. 2 Wir fasssen die Ergebnisse unserer Analyse nochmals in einem Satz zusammen: Satz 5.17 Eine Folge von m M AKE -S ET, U NION und F IND -S ET Operationen, von denen n Operationen M AKE -S ET sind, kann man mit Hilfe der Baumrepräsentation mit Pfadkompression und Vereinigung nach Rang in Worst-Case Zeit O(mα(n)) implementieren. 2 Im Hinblick auf Satz 5.2 über die Laufzeit des Algorithmus von Kruskal erhalten wir folgendes Resulat: Beobachtung 5.18 Mit Hilfe der Datenstruktur für disjunkte Mengen mit Pfadkompression und Vereinigung nach Rang benötigt der Algorithmus von Kruskal zur Bestimmung eines MST O(mα(n)) Zeit plus die Zeit zum Sortieren der m Kanten auf einem Graphen mit n Ecken und m Kanten. Die letzte Beobachtung ist in folgendem Zusammenhang wichtig: Falls die Kanten des Graphen bereits sortiert sind, so läuft der Kruskal-Algorithmus in O(mα(n)) Zeit! Der Algorithmus und alle benutzten Datenstrukturen sind recht einfach. Dennoch erhalten wir für diesen Fall einen Algorithmus mit fast linearer Laufzeit. Die Laufzeit von O(mα(n)) erhalten wir auch, falls wir die Kanten des Graphen gemäß ihres Gewichts in linearer Zeit sortieren können. Dies ist etwa dann der Fall, wenn die Kantengewichte ganze Zahlen aus {0, . . . , K} mit konstantem K oder aus {0, . . . , m} sind, siehe [2, Kapitel 9].
Suchbäume und Selbstorganisierende Datenstrukturen Eine Heap-Ordnung ist nicht die einzige Möglichkeit, um Elemente in einem Baum anzuordnen. Manchmal benötigt man eine stärkere Ordnung (»Sortierung«). Wir betrachten hier die sogenannte symmetrische Ordnung oder In-Order. Sei T ein binärer Baum. Jeder Knoten in T besitzt neben der Schlüsselwertinformation key noch Zeiger left, right und p, die auf den linken und rechten Sohn und auf den Vater im Baum zeigen (siehe Abbildung 6.1). key left right p
3 2 4 5
key left right p
9 NULL 11 7
5 7
3 2
4
9 11
Abbildung 6.1: Ein binärer Baum und seine Implementierung mit Zeigern. Wir haben hier der Einfachheit halber die Elemente mit den Schlüsselwerten identifiziert. Definition 6.1 (Suchbaumeigenschaft (bzgl. der symmetrischen Ordnung)) Der binäre Baum T erfüllt die Suchbaumeigenschaft bezüglich der symmetrischen Ordnung, wenn für jeden Knoten x ∈ T folgendes gilt: Ist y ein Knoten im linken Teilbaum von x, so gilt key[y] < key[x]. Ist z ein Knoten im rechten Teilbaum von x, so gilt key[z] > key[x]. Wir haben in unserer Definition striktes „<“ und „>“ gefordert. Wir setzen in diesem Kapitel voraus, daß jedes Element einen eindeutigen Schlüssel besitzt, d.h., das jeder Schlüsselwert nur einmal vorkommt. Alle Ergebnisse lassen sich problemlos auch auf den Fall von mehrfach vorkommenden Schlüsseln übertragen. Im Folgenden identifizieren wir meist der Einfachheit halber die Elementen mit Ihren Schlüsselwerten. Dies erspart es uns key[x] für den Schlüssel von x zu schreiben: wir können einfach x schreiben.
102
Suchbäume und Selbstorganisierende Datenstrukturen Wie der Name bereits andeutet, können wir in einem Suchbaum T (effizient) suchen. Um ein Element mit Schlüsselwert x in T zu suchen, starten wir in der Wurzel r von T . Wenn key[r] = x, so sind wir fertig. Falls x < key[r], so suchen wir im linken Teilbaum von r weiter, ansonsten suchen wir im rechten Teilbaum weiter. Falls ein Element mit Schlüssel x in T enthalten ist, so finden wir dieses Element korrekt. Ansonsten terminiert die Suche in einem leeren Teilbaum (Implementation: mit einem NULL-Zeiger). Hier können wir korrekt feststellen, daß kein Element im Baum Schlüssel x hat. Algorithmus 6.1 zeigt den PseudoCode für die Suche. Die Suche nach x benötigt O(h) Zeit, falls x Tiefe h im Baum hat. Algorithmus 6.1 Algorithmus zum Suchen eines Elements mit Schlüssel x in einem Suchbaum. S EARCH -T REE -S EARCH(T, x) 1 v ← root[T ] 2 while v 6= NULL and key[v] 6= x do 3 if x < key[v] then 4 v ← left[v] 5 else 6 v ← right[v] 7 end if 8 if v 6= NULL then 9 return x wurde im Knoten v gefunden. 10 else 11 return x ist nicht im Baum enthalten. 12 end if 13 end while Die Suchbaumeigenschaft ermöglicht es uns außerdem, die Schlüsselwerte in einem Baum T sehr einfach sortiert auszugeben. Man muß dazu lediglich Algorithmus 6.2 mit der Wurzel root[T ] aufrufen. Man sieht leicht, daß die Laufzeit von Algorithmus 6.2 für einen Baum mit n Knoten Θ(n) beträgt. Algorithmus 6.2 Rekursiver Algorithmus zur Ausgabe der Schlüsselwerte in einem Suchbaum in geordneter Reihenfolge. I NORDER -T REE -T RAVERSAL(x) 1 if x 6= NULL then 2 I NORDER -T REE -T RAVERSAL(left[x]) 3 print key[x] 4 I NORDER -T REE -T RAVERSAL(right[x]) 5 end if Definition 6.2 (Direkter Vorgänger und Nachfolger in einem Suchbaum) Sei T ein Suchbaum, der eine Menge {x1 , . . . , xn } mit x1 < x2 < · · · < xn repräsentiert. Wir setzen x0 := −∞ und xn := +∞. Ist x ∈ / {x1 , . . . , xn } mit xi < x < xi+1 , so heißen x− := xi der direkte Vorgänger von x und x+ := xi+1 der direkte Nachfolger von x in T . Für die Standard-Operationen auf (balancierten) Suchbäumen verweisen wir auf [3, Kapitel 12]. Wir beschäftigen uns in diesem Kapitel mit binären Suchbäumen, die »sich selbst organisieren«. Was dies genau heißt, wird nachher noch genauer klar werden. Wir motivieren die Selbstorganisation durch eine spezielle Anwendung für einen optimalen (statischen) Suchbaum. Bevor wir diese Anwendung im nächsten Abschnitt genauer vorstellen, notieren wir noch die dynamischen Mengenoperationen, von denen wir fordern, daß sie ein Suchbaum effizient unterstützt:
6.1 Optimale statische Suchbäume
103
S EARCH (S, k) Sucht und liefert das Element mit Schlüsselwert k in der sortierten dynamischen Menge S. Falls x nicht im Baum vorhanden ist, soll NULL ausgegeben werden. I NSERT (S, x) Fügt das Element x in die dynamische sortierte Menge S ein. D ELETE (S, x) Löscht das Element x in der Menge S. Neben diesen »Standard-Operationen« fordern wir von unserer Datenstruktur noch, daß auch folgende Operationen effizient unterstützt werden: J OIN(S1 , S2 ) Liefert die sortierte Menge, die aus dem Elementen von S1 ∪ S2 besteht. Diese Operation zerstört S1 und S2 und setzt voraus, daß für alle Schlüsselwerte k1 ∈ S1 und k2 ∈ S2 gilt: k1 ≤ k2 . S PLIT (S, x) Teilt die Menge S, die x enthalten muß, in zwei Mengen: S1 enthält alle Elemente aus S mit Schlüsselwerten kleiner oder gliech key[x] und S 2 enthält alle Elemente aus S mit Schlüsselwerten größer als key[x]. Es gibt zahlreiche Klassen von balancierten Bäumen (etwa AVL-Bäume, Rot-SchwarzBäume, 2-3-Bäume, B-Bäume), die alle oben genannten Operationen in O(log n) Zeit unterstützen, wobei n die aktuelle Anzahl der Elemente in der Menge S sind, siehe [9, 3]. Wie schon erwähnt, ist unser Schwerpunkt in diesem Kapitel anders.
6.1 Optimale statische Suchbäume Unsere Motation für optimale statische Suchbäume kommt aus dem Bereich der Codierungstheorie. Angenommen, wir haben eine Datei D mit 100.000 Zeichen, wobei jedes Zeichen aus dem acht-elementigen Zeichenvorrat Σ = {a, b, c, d, e, f, g, h} stammt. Wenn wir die Zeichen binär mit fester Länge codieren, so benötigen wir drei Bits pro Zeichen: a = 000, b = 001, . . . , f = 101, g = 110, h = 111. Dies führt zu einem Platzbedarf von 100.000 Bits, um D zu speichern. Geht dies besser? In unserem ersten Ansatz haben wir einen sogenannten Code mit fester Länge benutzt. Ein Code mit variabler Länge kann eine deutliche Verbesserung der Speicherplatzausnutzung ergeben. Beim Zählen, wie oft jedes Zeichen aus Σ in der Datei D auftaucht, ergibt sich die Verteilung in Tabelle 6.1.
Häufigkeit (in 1000) Codewort fester Länge Codewort variabler Länge
a 40
b 13
c 12
d 5
e 18
f 6
g 3
h 3
000
001
010
011
100
101
110
111
1
010
011
00001
001
0001
000000
000001
Tabelle 6.1: Häufigkeiten der einzelnen Zeichen in der Beispieldatei und Codierung mit fester bzw. variabler Länge. Wenn wir die Zeichen gemäß des Codes in der dritten Zeile von Tabelle 6.1 codieren, so benötigen wir folgende Anzahl von Bits: 1000 · (40 · 1 + 13 · 2 + 12 · 2 + 5 · 5 + 18 · 3 + 6 · 3 + 3 · 3 + 3 · 3) = 205.000. Dies ist eine beträchtliche Ersparnis gegenüber dem Code mit fester Länge. Wie berechnet man einen Code mit variabler Länge und was hat dies mit Suchbäumen zu tun?
104
Suchbäume und Selbstorganisierende Datenstrukturen Der Code aus Tabelle 6.1 ist ein sogenannter Präfix-Code, d.h., kein Codewort ist ein Präfix eines anderen Codeworts. Wir können den Code als binären Baum darstellen, der gleichzeitig als effizienter Decodier-Mechanismus gilt. Abbildung 6.2 zeigt den Code aus Tabelle 6.1 als binären Baum. Dabei ist das Codewort für ein ein Zeichen aus Σ im Pfad von der Wurzel des Baums bis zum Blatt, welches das Zeichen enthält, »gespeichert«: eine 0 steht für »linker Sohn« eine 1 für »rechter Sohn«. Man sieht leicht, daß jedem Präfix-Code ein Code-Baum entspricht und umgekehrt jeder Baum, dessen Blätter die Elemente aus Σ bijektiv zugeordnet sind, einen Präfix-Code impliziert. 100 0
1
60 0
a: 40 1
35 0 17
b: 13
1 c: 12
f: 6
11 1
0 6
g: 3
e: 18
0
1
0
0
25 1
d: 5 1 h: 3
Abbildung 6.2: Code-Baum für den Beispielcode mit variabler Länge. Jedes Blatt ist mit einem Zeichen aus dem Alphabet Σ und seiner relativen Häufigkeit (in Prozent) markiert. Jeder innere Knoten enthält die Summe der relativen Häufigkeiten aller Blätter in seinem Teilbaum. Mit Hilfe eines Code-Baums kann man übrigens effizient decodieren: Man startet in der Wurzel und folgt gemäß den gelesenen Bits einem Weg bis zu einem Blatt. Sobald man in einem Blatt angelangt ist, hat man das entsprechende Zeichen aus Σ gefunden. Man startet dann wieder in der Wurzel für das nächste Zeichen. Ist der Code-Baum bekannt, so kann man einen Datenstrom in linearer Zeit decodieren. Den Code-Baum zu einem Präfix-Code γ kann man auch als Suchbaum für die Elemente in Σ betrachten. Ist für z ∈ Σ die Höhe des entsprechenden Blattes im Baum d T (z) und p(z) ∈ [0, 1] die (relative) Häufigkeit von z in der zu codierenden Datei D, so benötigt die Codierung von D mittels γ genau |D| · c(T ) Bits, wobei |D| die Anzahl der Zeichen in D ist und X c(T ) := dT (z)p(z) (6.1) z∈Σ
die gewichtete durchschnittliche Blatthöhe von T ist. Wir können die relative Häufigkeit p(z) von z auch als Wahrscheinlichkeit ansehen, daß z angefragt wird. Dann entspricht c(T ) dem Erwartungswert der Blatthöhe bei einer Suchanfrage. Einen optimalen PräfixCode erhalten wir, indem wir einen Suchbaum T ∗ konstruieren, der eine kleinstmögliche erwartete Blatthöhe c(T ) besitzt.
Wenn die Verteilung p bekannt ist (im Fall unseres Codierungsbeispiels können wir p durch einmaliges Durchlaufen der zu codierenden Datei D errechnen), so kann ein optimaler
6.2 Der Algorithmus von Huffman Baum mit Hilfe des Algorithmus von Huffman (siehe nächster Abschnitt) bestimmt werden. Allerdings hätten wir auch gerne für den Fall, daß die Verteilung p nicht bekannt ist, etwa wenn Daten »online» über einen Datenkanal gesendet werden sollen, einen optimalen oder zumindest »guten « Baum/Code, mit dem wir »online» codieren können. Wir werden in Abschnitt 6.3 eine Datenstruktur, die Schüttelbäume, kennenlernen, die dieses Problem lösen.
6.2 Der Algorithmus von Huffman Wir führen auf dem Weg zu den selbstorganisierenden Datenstrukturen unseren kurzen Ausflug in die Codierungstheorie fort. Wir zeigen, wie man einen optimalen statischen Suchbaum effizient konstruieren kann. Zum einen rundet dies unseren Ausflug ab, zum anderen werden wir sehen, wie man auch hier mit Hilfe von geeigneten Datenstrukturen eine effiziente Laufzeit erhalten kann. Im folgenden sei p : Σ → [0, 1] eine Wahrscheinlichkeitsverteilung auf Σ. Unsere Aufgabe ist es, einen Baum T ∗ zu konstruieren, der optimale Kosten c(T ∗ ) (siehe Gleichung (6.1)) besitzt. Der Algorithmus von Huffman arbeitet wie folgt: er startet mit w[z] := p(z) für alle z ∈ Σ. Dann fasst er iterativ die beiden »Zeichen«x und y (warum hier Anführungszeichen stehen, wird gleich klar) mit den kleinsten Werten w[x], w[y] zusammen, indem er sie zu Söhnen einer gemeinsamen Wurzel z macht, die Gewicht w[z] := w[x] + w[y] erhält. Die Zeichen x und y werden entfernt und durch z ersetzt. Das Verfahren ist in Algorithmus 6.3 genauer im Pseudo-Code beschrieben. Ein Beispiel, wie der Huffman-Algorithmus einen Code erzeugt, ist in den Abbildungen 6.3 und 6.4 zu sehen. Algorithmus 6.3 Der Algorithmus von Huffman. H UFFMAN -C ODE 1 for all z ∈ Σ do 2 w[z] ← p(z) 3 Alloziiere einen neuen Baumknoten z mit left[z] = right[z] = p[z] = NULL 4 end for 5 Q ← B UILD -H EAP (w) Konstruiere einen Minimum-Heap für die Elemente aus Σ, wobei das Element z ∈ Σ Schlüsselwert w[z] besitzt. 6 while |Q| > 1 do 7 x ← E XTRACT-M IN(Q) 8 y ← E XTRACT-M IN (Q) 9 Alloziiere einen neuen Baumknoten z. 10 left[z] ← x, p[x] ← z 11 p[y] ← z, p[x] ← z 12 right[z] ← y 13 p[z] ← NULL 14 w[z] ← w[x] + w[y] 15 I NSERT(Q, z) { Füge z in den Heap Q ein. } 16 end while 17 z ← E XTRACT-M IN (Q) { Q besteht jetzt nur noch aus einem Element. } 18 return z Bevor wir die Korrektheit des Huffman-Algorithmus beweisen, analysieren wir seine Laufzeit. Sei n := |Σ| die Größe des Alphabets, das wir codieren wollen. Das Alloziieren der n Knoten für die n Zeichen aus Σ benötigt dann O(n) Zeit. Ebenso ist für das Erstellen des Heaps Q nur O(n) Zeit nötig. Die while-Schleife in den Zeilen 6 bis 16 wird insgesamt
105
106
Suchbäume und Selbstorganisierende Datenstrukturen
a: 40
b: 13
c: 12
d: 5
f: 6
e: 18
g: 3
h: 3
(a) Start: Die Knoten sind alle einzelne Zeichen aus Σ
6 a: 40
b: 13
c: 12
d: 5
f: 6
e: 18
g: 3
h: 3
(b) Situation nach Zusammenfassen der Knoten g und h mit kleinstem Gewicht
12 a: 40
b: 13
c: 12
e: 18
f: 6
6 g: 3
d: 5 h: 3
(c)
17 a: 40
b: 13
c: 12
f: 6
11
e: 18 6 g: 3
d: 5 h: 3
(d)
Abbildung 6.3: Erzeugung eines Beispielcodes durch den Huffman-Algorithmus.
6.2 Der Algorithmus von Huffman
107
25 a: 40
e: 18
b: 13
17 f: 6
11
c: 12 6
d: 5
g: 3
h: 3
(a)
25 a: 40
b: 13
35 17
c: 12
f: 6
11 6 g: 3
e: 18
d: 5 h: 3
(b)
60 35
a: 40 17
e: 18
b: 13
c: 12
f: 6
11 6 g: 3
25
d: 5 h: 3 (c)
100 60
a: 40
35 17
g: 3
e: 18
b: 13
c: 12
f: 6
11 6
25
d: 5 h: 3 (d) Fertiger Code-Baum
Abbildung 6.4: Fortsetzung: Erzeugung eines Beispielcodes durch den HuffmanAlgorithmus.
108
Suchbäume und Selbstorganisierende Datenstrukturen n − 1 mal durchlaufen, da wir mit n Knoten starten und in jedem Schritt zwei Knoten zu einem verschmelzen (die Anzahl der Knoten also um eins reduzieren). Bis auf die E XTRACTM IN-Operationen läuft H UFMANN -C ODE also in linearer Zeit. Jede der 2n − 2 E XTRACTM IN-Aufrufe benötigt (bei Implementierung mit einem binären Heap) O(log n) Zeit, so daß wir insgesamt eine Laufzeit von O(n log n) erhalten. Satz 6.3 Der Huffman-Algorithmus findet einen Baum T∗ mit minimalen Kosten c(T ), bzw. einen optimalen Präfix-Code mit variabler Länge. Der Algorithmus kann so implementiert werden, daß er in O(n log n) Zeit läuft. Beweis: Die Laufzeit haben wir bereits bewiesen. Wir zeigen die Behauptung des Satzes in zwei Schritten: Behauptung 6.4 Seien x ∈ Σ und y ∈ Σ die zwei Zeichen mit geringster Häufigkeit p(x), p(y). Es existiert ein optimaler Baum, in dem x und y Blätter größter Höhe sind, die außerdem einen gemeinsamen Vater besitzen. Behauptung 6.5 Seien x ∈ Σ und y ∈ Σ die zwei Zeichen mit geringster Häufigkeit p(x), p(y). Sei Σ0 := Σ\{x, y}∪{z}, wobei z ∈ / Σ und p(z) := p(x)+p(y). Ist T 0 ein optimaler 0 Code-Baum für Σ , so ist der Baum T , der aus T 0 entsteht, indem man das Blatt für z durch den Teilbaum x y ersetzt, ein optimaler Code-Baum für Σ. Aus den Behauptungen 6.4 und 6.5 folgt dann sofort die Aussage des Satzes über die Korrektheit des Huffman-Algorithmus. Beweis: (Behauptung 6.4) Sei T ein optimaler Code-Baum für Σ. Zunächst bemerken wir, daß wir o.B.d.A. annehmen können, daß ein Blatt a maximaler Höhe in T immer einen Bruder hat. Falls a keinen Bruder besitzt, so ist a alleiniger Sohn seines Vaters p[a] (von p[a] kann kein weiterer Teilbaum abzweigen, da in diesem sonst ein Blatt mit größerer Höhe als a vorliegen würde). Daher können wir p[a] durch a ersetzen und den alten Knoten von a entfernen (siehe Abbildung 6.5). Die Kosten von T können höchstens geringer werden, da die Höhe von a um eins sinkt, alle anderen Blätter ihre Höhe aber behalten. Fortsetzung dieses Verfahrens liefert einen Baum, in dem das Blatt maximaler Höhe einen Bruder besitzt. Ersetzen von p[a] durch a
p[a]
a
a Abbildung 6.5: Ein Blatt maximaler Höhe a besitzt o.B.d.A. in einem optimalen Codebaum einen Bruder. Ansonsten kann man den Baum ohne Kostenerhöhung modifizieren. Seien nun a und b Blätter mit maximaler Höhe in T , die einen gemeinsamen Vater besitzen. Wir nehmen o.B.d.A. an, daß p(a) ≤ p(b) ist. Nach Wahl von x und y gilt dann p(x) ≤ p(a) und o(y) ≤ p(b). Wir vertauschen a mit x und dann in einem zweiten Schritt b mit y, so daß aus T zunächst T 0 und dann T 00 entsteht (siehe Abbildung 6.6).
6.2 Der Algorithmus von Huffman
109
Vertauschen von x und a
y
y
x
a
a
b
x
b
Vertauschen von y und b
b
a
x
y
Abbildung 6.6: Illustration des Beweises von Behauptung 6.4. Durch Vertauschen der Positionen von x und y mit derer von zwei Blättern maximaler Höhe mit gemeinsamem Vater ensteht ein neuer Baum, ohne die Kosten zu erhöhen.
110
Suchbäume und Selbstorganisierende Datenstrukturen Die Kosten von T 0 unterscheiden sich von denen von T durch die Terme für a und x (alle anderen Blätter behalten ihre Höhen). Wir haben dann: c(T 0 ) = c(T ) − (dT (a)p(a) + dT (x)p(x)) + dT 0 (a)p(a) + dT 0 (x)p(x) = c(T ) − (dT (a)p(a) + dT (x)p(x)) + dT (x)p(a) + dT (a)p(x) = c(T ) + (dT (x) − dT (a)) · (p(a) − p(x)) {z } | {z } | ≤0
≥0
≤ c(T ).
Dabei haben wir benutzt, daß dT (x) ≤ dT (a), da a ein Blatt größter Höhe ist, und p(a) ≤ p(x), da x kleinste Häufigkeit besitzt. Vollkommen analog zeigt man nun c(T 00 ) ≤ c(T 0 ). Daher ist T 00 ebenfalls ein optimaler Baum, bei dem x und y an der gewünschten Position liegen. Dies beendet den Beweis von Behauptung 6.4. 2 Beweis: (Behauptung 6.5) Wir müssen zeigen, daß der Baum T 0 aus der Behauptung ein optimaler Code-Baum für Σ0 ist. Zunächst setzen wir die Kosten von T und T 0 in Beziehung. Der Baum T entspricht T 0 mit der Modifikation, daß z durch den dreiknotigen Teilbaum mit Blättern x und y ersetzt wird. Wir haben also dT (x) = dT (y) = dT 0 (z) + 1. Die Kosten von T errechnen sich daher wie folgt: X c(T ) = dT (s)p(s) s∈Σ
=
X
dT (s)p(s) + dT (y)p(y) + dT (x)p(x)
s∈Σ\{x,y}
=
X
dT 0 (s)p(s) + dT (y)p(y) + dT (x)p(x)
s∈Σ\{x,y}
=
X
s∈Σ\{x,y}
=
X
dT (s)p(s) + (dT 0 (z) + 1) (p(y) + p(x)) | {z } =p(z)
dT 0 (s)p(s) + (p(y) + p(x))
s∈Σ\{x,y}∪{z}
= c(T 0 ) + (p(y) + p(x)) Angenommen, T˜ wäre ein Code-Baum für Σ0 mit c(T˜) < c(T 0 ). Wir ersetzen in T˜ das Blatt z durch den dreiknotigen Teilbaum mit Blättern x und y (siehe Abbildung 6.7). Sei Tˆ der entsprechende Code-Baum für Σ. Analog zum Kostenvergleich von T und T 0 errechnet man c(Tˆ) = c(T˜) + p(y) + p(x) < c(T 0 ) + p(y) + p(x) = c(T ). Dies widerspricht der Annahme, daß T ein optimaler Code-Baum für Σ ist. 2 Wie bereits oben erwähnt, implizieren die Behauptungen 6.4 und 6.5 unmittelbar die Korrektheit des Hufmann-Algorithmus. 2
6.3 Schüttelbäume Ein Schüttelbaum (engl. Splay-Tree) ist ein binärer Suchbaum, bei dem alle Suchbaumoperationen auf die folgende Operation S PLAY (»schüttele«) zurückgeführt werden:
6.3 Schüttelbäume
111
Ersetzen von z durch y
x
z
x
y
Abbildung 6.7: Konstruktion eines neuen Code-Baums für Σ aus dem optimalen CodeBaum für Σ0 = Σ \ {x, y} ∪ {z}. S PLAY(T, x) gibt einen Baum aus, der die selbe Menge von Elementen wie T darstellt. Wenn x im Baum enthalten ist, so wird x zur Wurzel des Resultatbaums gemacht. Wenn x nicht im Baum enthalten ist, so wird entweder der unmittelbare Vorgänger x− oder der umittelbare Nachfolger x+ von x im Baum T zur Wurzel.
6.3.1 Rückführen der Suchbaumoperationen auf S PLAY Bevor wir die genaue Implementierung der S PLAY-Operation beschreiben (schon einmal zur Vorwarnung: es ist wichtig, daß die S PLAY-Operation genau wie hier beschrieben ausgeführt wird, für andere Varianten gelten die gezeigten Ergebnisse nicht), zeigen wir, wie die anderen Operationen auf S PLAY zurückgeführt werden können. S EARCH Für S EARCH (T, x) führen wir S PLAY(T, x) aus und inspizieren die Wurzel. Nach Definition der S PLAY-Operation befindet sich x nach S PLAY (T, x) genau in der Wurzel, wenn x im Baum enthalten ist. J OIN Um J OIN 2 (T1 , T2 ) zu implementieren, führen wir zunächst S PLAY(T1 , +∞) aus. Als Resultat steht dann das größte Element in der Wurzel des geänderten Baums T 10 . Diese Wurzel hat keinen rechten Sohn (da es kein größeres Element als +∞ gibt). Wir können nun T2 zum rechten Teilbaum der Wurzel von T10 machen. Abbildung 6.8 veranschaulicht die Operationenfolge. z
S PLAY(T1 , +∞)
T1
T2
z T2
A
A
T2
Abbildung 6.8: Rückführen von J OIN (T1 , T2 ) auf S PLAY. S PLIT Für S PLIT(T, x) führen wir S PLAY(T, x) aus und brechen dann eine der Verbindungen von der Wurzel zu den Teilbäumen auf, je nachdem, ob die Wurzel nach dem S PLAY ein Element kleiner oder größer als x enthält, siehe Abbildung 6.9.
112
Suchbäume und Selbstorganisierende Datenstrukturen z ∈ {x, x− , x+ } z
S PLAY(T, x)
x−
Aufbrechen z ∈ {x, x− }
A
B
A
B
Abbildung 6.9: Rückführen von S PLIT(T, x) auf S PLAY. Hier ist der Fall z ∈ {x, x − } gezeigt. Der Fall z = x+ verläuft symmetrisch dazu. I NSERT Um I NSERT(T, x) auszuführen, führen wir zunächst S PLIT(T, x) durch. Als Resultat erhalten wir zwei Bäume T − und T + , wobei T − alle Elemente kleiner als x und T + alle Elemente größer als x enthält. Wir konstruieren einen neuen Baum mit Wurzel x und T − als linkem und T + als rechtem Teilbaum. Das Vorgehen ist in Abbildung 6.10 illustriert. x
S PLIT(T, x)
T−
T+ T−
T+
Abbildung 6.10: Implementierung von I NSERT(T, x) in Schüttelbäumen. D ELETE Zum Ausführen von D ELETE(T, x) führen wir S PLAY(T, x) aus, zerstören die Wurzel, wodurch wir zwei Teilbäume T1 und T2 erhalten. Diese beiden Bäume werden dann wieder durch J OIN 2 (T1 , T2 ) zu einem neuen Baum verbunden, siehe Abbildung 6.11. x
S PLAY(T, x)
J OIN(T1 , T2 )
T1
T1
T2
T2
Abbildung 6.11: Implementierung von D ELETE(T, x) in SchüttelbäumeSchüttelbäumen.
6.3.2 Implementierung der S PLAY-Operation In diesem Abschnitt beschreiben wir, wie die zentrale Operation S PLAY(T, x) in einem Schüttelbaum ausgeführt wird. Wie bereits erwähnt, gelten die in den folgenden Abschnitten gezeigten Ergebnisse nur, wenn die S PLAY-Operation wie hier beschrieben ausgeführt wird. Insbesondere ist es dabei wichtig, daß die Operationen in der angegebenen Reihenfolge ausgeführt werden.
6.3 Schüttelbäume Bei der Operation S PLAY(T, x) führen wir eine Anzahl von Rotationen im Baum T aus, durch die x zur Wurzel gemacht wird. Wir starten dabei in x und unterscheiden verschiedene Fälle (im wesentlichen drei Fälle, von denen jeder zwei symmetrische Unterfälle hat), je nachdem wie die Position von x zu seinem Vater p[x] und seinem Großvater p[p[x]] ist. Sei u der Knoten, der x enthält. Diesen Knoten können wir durch die in Algorithmus 6.1 vorgestellte Suche in einem Suchbaum lokalisieren. Falls u einen Vater, aber keinen Großvater besitzt, so führen wir eine Rotation am Vater v = p[u] durch, wodurch u zur Wurzel wird. Mit diesem Schritt terminiert das Verfahren. Der eben beschriebene Fall ist in Abbildung 6.12(a) gezeichnet. Die Zeichnung in Abbildung 6.12(a) entspricht dem Fall, daß u linker Sohn seines Vaters ist. Falls u rechter Sohn ist, so funktioniert die Rotation entsprechend symmetrisch. Es sollte klar sein, daß eine Rotation in konstanter Zeit ausgeführt werden kann, da wir nur Zeiger auf die Teilbäume umhängen müssen. Details zu Rotationen in Suchbäumen, etwa zum Balancieren von Bäumen, findet man in [3]. Falls u einen Großvater besitzt, so unterscheiden wir zwei Fälle, je nach der Stellung von u zu seinem Vater und vom Vater p[u] zum Großvater w = p[v] = p[p[u]]. Je nachdem, welcher Fall vorliegt, wird u durch eine geeignete Rotation weiter nach oben im Baum befördert. Algorithmus 6.4 zeigt die Details der S PLAY-Operation. In Abbildung 6.12 sind die drei Fälle und die entsprechenden Rotationen gezeigt. Abbildung 6.13 zeigt ein Beispiel. Algorithmus 6.4 Implementierung der S PLAY-Operation. S PLAY(T, x) 1 u ← S EARCH -T REE -S EARCH (T, x) { Finde x mit Hilfe von Algorithmus 6.1. } 2 while p[u] 6= NULL do { Solange u noch nicht die Wurzel des Baums ist } 3 if u hat einen Vater v = p[u], aber keinen Großvater then { »Zick«-Fall, siehe Abbildung 6.12(a) } 4 Führe eine einfache Rotation an v = p[u] durch. 5 return { Beende das Verfahren. } 6 end if 7 if u hat einen Vater v = p[u] und einen Großvater w = p[v] = p[p[v]] and sowohl v als auch u sind linke (rechte) Söhne ihres Vaters then { »Zick-Zick«-Fall, siehe Abbildung 6.12(b) } 8 Führe eine einfache Rotation an w gefolgt von einer einfachen Rotation an v aus. 9 else 10 { »Zick-Zack«-Fall, siehe Abbildung 6.12(c). Der Knoten u hat einen Vater v = p[u] und einen Großvater w = p[v] = p[p[v]] und v ist linker (rechter) Sohn seines Vaters, u aber rechter (linker) Sohn seines Vaters } 11 Führe eine Doppelrotation an w aus. 12 end if 13 end while Noch einmal soll darauf hingewiesen werden, daß die Reihenfolge der Rotationen von entscheidender Bedeutung ist. So führen die Rotationen im Zick-Zack-Fall dazu, daß mit u auch seine Teilbäume B und C näher an die Wurzel gelangen.
6.3.3 Analyse der S PLAY-Operation In den nächsten beiden Abschnitten gehen wir der Frage nach, wie effizient Schüttelbäume sind. Dazu betrachten wir in diesem Abschnitt zunächst einmal die zentrale S PLAYOperation. Als Hilfsmittel dient uns die amortisierte Analyse aus Kapitel 3.
113
114
Suchbäume und Selbstorganisierende Datenstrukturen
Rotation an v
v
u
u
v C
A
A
B
B
C
(a) Zick: Der Knoten u wird durch Rotation zur Wurzel.
Rotation an w
w
v
v
u
w
D u C A
A
B
Rotation an v
B
C
D
u v A w B C
D
(b) Zick-Zick: Es erfolgt eine einfache Rotationen an w, gefolgt von einer einfachen Rotationen an v
Doppelrotation an w
w
u
v
w
v
A u D B
A
B
C
D
C (c) Zick-Zack: Es erfolgt eine Doppelrotation an w.
Abbildung 6.12: Die drei Situationen beim Splay am Knoten u. Jeder Fall hat noch eine symmetrische Variante, die hier nicht gezeichnet ist.
6.3 Schüttelbäume
115
8
8 9
6 7
2
7
2
5
1
9
6
3
1
4
4
3
5
(a) Im Ausgangsbaum wurde 3 gesucht. Es wird jetzt am Knoten 13 geschüttelt. Es liegt der Zick-Zick-Fall vor (angedeutet durch die gestrichelten Kanten), bei dem zwei einfache Rotation erfolgen.
(b) Nun liegt ein Zick-Zack-Fall vor. Es erfolgt eine Doppelrotation an 6.
8
3
3
9 6
2 1
6
1 7
4
8
2
5 (c) Nun liegt noch einmal der Zick-Fall vor, bei dem 3 durch eine einfache Rotation zur Wurzel wird und nach dem das Verfahren terminiert.
9 7
4 5
(d) Im Endergebnis steht 13 in der Wurzel.
Abbildung 6.13: Beispiel für eine S PLAY-Operation. Hier wird S PLAY(T, 1) ausgeführt.
116
Suchbäume und Selbstorganisierende Datenstrukturen Sei U eine Menge von Elementen (»Universum«), die wir in den Suchbaum einfügen, im Baum suchen und aus dem Baum löschen können. Die Menge U repräsentiert die möglichen Schlüsselwerte in unseren Bäumen. Sei g : U → R≥0 eine Gewichtsfunktion auf U . Wir analysieren alle Operationen in Abhängigkeit der Gewichte der involvierten Elemente. Nachher werden wir g geeignet wählen, so daß wir eine ganze Reihe von hilfreichen Ergebnissen erhalten. Sei v ein Knoten im Baum T . Mit Tv bezeichnen wir den Teilbaum mit Wurzel v inklusive v und mit G(v) das Gewicht aller Knoten in Tv , d.h., X g(w). G(v) := w∈Tv
Zur kürzeren Notation setzen wir außerdem: G(T ) := G(root[T ]), wobei root[T ] wie bisher die Wurzel von T ist. Nun definieren wir noch den GewichtsRang (oder einfach Rang) von v durch ! X r(v) := log2 G(v) = log2 g(w) . (6.2) w∈Tv
Letztendlich sei das Potential Φ(T ) eines Schüttelbaums definiert durch X Φ(T ) := r(v). v∈T
Wir werden nun die amortisierten Kosten der S PLAY-Operation nach oben abschätzen. Wir erinnern daran, daß die amortisierten Kosten einer Operation wie folgt definiert sind (siehe Kapitel 3): a := c + Φ(T 0 ) − Φ(T ),
wobei c der reale Zeitaufwand (reale Kosten) ist und Φ(T ) bzw. Φ(T 0 ) das Potential des Baums vor bzw. nach der Operation bezeichnen. Um die amortisierten Kosten der S PLAY-Operation abzuschätzen, zerlegen wir eine solche Operation in einzelne Splay-Schritte, in denen jeweils einer der drei Fälle aus Abbildung 6.12 vorliegt. Zunächst zeigen wir eine triviale, aber auch hilfreiche Eigenschaft der Ränge. Lemma 6.6 Für alle Knoten x mit p[x] 6= NULL gilt die Ungleichung r(p[x]) ≥ r(x). Beweis: Die Ungleichung folgt sofort aus G(p[x]) = g(p[x]) + G(x) ≥ G(x).
2
Wir notieren noch ein hilfreiches Lemma: Lemma 6.7 Die Logarithmusfunktion log 2 : R>0 → R ist konkav, erfüllt also insbesondere log2 a + log2 b a+b log2 ≤ 2 2
für alle a, b > 0.
Beweis: Mit elementaren Mitteln der Analysis.
2
Lemma 6.8 Die amortisierten Kosten eines einzelnen Splay-Schrittes am Knoten u, bei dem der Zick-Fall vorliegt, betragen höchstens 1 + 3(r 0 (u) − r(u)).
6.3 Schüttelbäume
117
Beweis: Wir bezeichnen mit r 0 die Ränge der einzelnen Knoten nach dem Splay-Schritt und mit T 0 den Ergebnisbaum. Durch den Splay-Schritt ändern sich nur die Ränge von u und seinem Vater v im Baum T , so daß für die Potentialdifferenz gilt: Φ(T 0 ) − Φ(T ) = r0 (u) + r0 (v) − r(u) − r(v)
(6.3)
Weiterhin ist r0 (u) = r(v), da beide Größen dem Logarithmus der Summe der Gewichte aller Elemente im Baum entsprechen. Die realen Kosten für den Splay-Schritt sind gleich 1. Somit erhalten wir aus (6.3) für die amortisierten Kosten die obere Schranke: 1 + Φ(T 0 ) − Φ(T ) = 1 + r 0 (v) − r(u)
≤ 1 + r0 (u) − r(u) ≤ 1 + 3(r0 (u) − r(u))
(nach Lemma 6.6) (da r 0 (u) = r(v) ≥ r(u) nach Lemma 6.6)
Somit folgt das Lemma.
2
Lemma 6.9 Die amortisierten Kosten eines einzelnen Splay-Schrittes am Knoten u, bei dem der Zick-Zick-Fall oder ein Zick-Zack-Fall vorliegt, betragen höchstens 3(r 0 (u) − r(u)). Beweis: Wir betrachten als erstes den Zick-Zick-Fall. Die realen Kosten sind gleich 2 (für zwei Rotationen) Die Ränge aller Knoten außer u, v und w bleiben unverändert. Daher sind die amortisierten Kosten für den Splay-Schritt: 2 + Φ(T 0 ) − Φ(T ) = r0 (u) + r0 (v) + r0 (w) − r(u) − r(v) − r(w) = 2 + r0 (v) + r0 (w) − r(u) − r(v) ≤ 2 + r0 (v) + r0 (w) − 2r(u) ≤ 2 + r0 (u) + r0 (w) − 2r(u)
(da r 0 (u) = r(w)) (da r(u) ≤ r(v))
(da r 0 (v) ≤ r0 (u)) (6.4)
Weiterhin gilt G(u) + G0 (w) ≤ G0 (u) (vgl. Abbildung 6.12(b)), also haben wir r(u) + r0 (w) = log2 G(u) + log2 G0 (w) G(u) + G0 (w) 2 = 2r0 (u) − 2. ≤ 2 log2
(nach Lemma 6.7)
≤ 2 log2
G0 (u) 2
Aus dieser Ungleichungskette erhalten wir r 0 (w) ≤ 2r0 (u) − 2 − r(u). Setzt man diese Ungleichung in (6.4) ein, so erhalten wir 2 + Φ(T 0 ) − Φ(T ) ≤ 2 + r 0 (u) + (2r0 (u) − 2 − r(u)) − 2r(u) = 3(r 0 (u) − r(u)). Damit haben wir die Behauptung des Lemmas für den Zick-Zick-Fall bewiesen. Wir betrachten nun den Zick-Zack-Fall (siehe Abbildung 6.12(c)). Wie beim Zick-ZickFall ändern sich höchstens die Ränge von u, v und w. Daher sind die amortisierten Kosten gegeben durch: 2 + Φ(T 0 ) − Φ(T ) = 2 + r 0 (u) + r0 (v) + r0 (w) − r(u) − r(v) − r(w) = 2 + r0 (v) + r0 (w) − r(u) − r(v) ≤ 2 + r0 (v) + r0 (w) − 2r(u)
(da r 0 (u) = r(w)) (da r(v) ≥ r(u)) (6.5) (6.6)
118
Suchbäume und Selbstorganisierende Datenstrukturen Es gilt nun G0 (v) + G0 (w) ≤ G0 (u) (siehe Abbildung 6.12(c)). Damit folgt analog zum Zick-Zick-Fall, daß r 0 (v) + r0 (w) ≤ 2r0 (u) − 2. Benutzt man diese Ungleichung in (6.5), so erhält man: 2 + Φ(T 0 ) − Φ(T ) ≤ 2 + (2r 0 (u) − 2)) − 2r(u) = 2(r0 (u) − r(u)) ≤ 3(r0 (u) − r(u))
Dies beendet den Beweis des Lemmas.
(da r 0 (u) ≥ r(u)) 2
Korollar 6.10 Sei T ein Schüttelbaum mit Wurzel root[T ] und x ein Knoten in T . Die amortisierten Kosten für die Operation S PLAY (T, x) betragen höchstens G(T ) , (6.7) 1 + 3 · (r(root[T ]) − r(x)) = O log G(x)
wobei root[T ] der Wurzelknoten von T ist. Beweis: Die Abschätung durch den linken Term in (6.7) folgt sofort aus Lemma 6.8 und 6.9 durch Summieren der amortisierten Kosten für die einzelnen Splay-Schritte. Der rechte Term ergibt sich aus r(v) = log2 G(v) und den Rechenregeln für den Logarithmus. 2 Aus unserer Implementierung des Suchens mittels der S PLAY-Operation ergibt sich die gleiche (amortisierte) Zeitkomplexität wie für S PLAY(T, x) auch für S EARCH (T, x). Wir werden im nächsten Abschnitt ähnliche Schranken wie in Korollar 6.10 für die anderen Suchbaumoperationen beweisen. Bevor wir dies tun, soll hier schon auf die Mächtigkeit der Aussage in Korollar 6.10 hingewiesen werden. Das Korollar gilt unabhängig von der Gewichtsfunktion g : U → R ≥0 . Es steht uns frei, g geeignet zu wählen. Zunächst erinnern wir nochmal daran, wie wir aus oberen Schranken für die amortisierten Kosten einer Operationenfolge auch obere Schranken für die realen Kosten dieser Folge herleiten können. Die realen Kosten entsprechen den amortisierten Kosten plus der Potentialdifferenz Φ(T ) − Φ(T 0 ), wobei T der Startbaum und T 0 der Endbaum nach der Operationenfolge ist (vgl.˜(3.2)). Für die erwähnte Potentialdifferenz gilt: X Φ(T ) − Φ(T 0 ) = r(u) − r0 (u) u∈T
=
X
log
G(u) G0 (u)
log
G(T ) g 0 (u)
u∈T
≤
X
u∈T
(6.8)
Wir sind nun bereit, das erste wichtige Ergebnis zu zeigen. Satz 6.11 Die realen Kosten für eine Folge von m Suchzugriffen auf einen Schüttelbaum mit n Elementen sind in O((m + n) log n). Beweis: Wir setzen g(u) := 1/n für alle u ∈ T . Es gilt dann G(T ) = 1 und somit sind nach Korollar 6.10 die amortisierten Kosten für einen Suchzugriff dann O(log n). Damit ergibt sich unmittelbar die obere Schranke von O(m log n) für die amortisierten Kosten einer Folge von m Suchoperationen. Die realen Kosten sind nach (6.8) in O(m log n) + O(n · n) = O((n + m) log n). 2
6.3 Schüttelbäume
119
Das Ergebnis aus Satz 6.11 läßt sich informell wie folgt formulieren: auf einer genügend langen Folge von Suchzugriffen sind Schüttelbäume mindestens so effizient wie ein beliebiger statischer Suchbaum, der »gleichmäßig balanciert« ist, d.h. in dem jedes Element logarithmische Tiefe besitzt. Wir verschärfen dieses Ergebnis jetzt, indem wir zeigen, daß Schüttelbäume mindestens so effizient sind wie ein beliebiger statischer Suchbaum. Satz 6.12 (Statische Optimialität von Schüttelbäumen) Die realen Kosten für eine Folge von m Suchzugriffen auf einen Schüttelbaum mit n Elementen, wobei Element u genau q(u) ≥ 1 mal gesucht wird, sind in ! X m O m+ . q(u) log q(u) u∈T
Beweis: Für u ∈ T setzen wir G(u) := q(u)/m. Dann ist G(T ) = 1 und nach Korollar 6.10 sind die amortisierten Kosten für einen Suchzugriff auf u in O(log(m/q(u))). Daher sind die amortisierten Kosten für die q(u) Zugriffe auf u höchstens O(q(u) log(m/q(u))).P Nach (6.8) ist mit den gerade definierten Gewichten der Po2 tentialverlust über die Folge u∈T log(m/q(u)). Warum liefert uns Satz 6.12 eine »statische Optimalität« der Schüttelbäume? Wir benutzen ein Resultat aus der Informationstheorie, welches die minimalen Suchkosten nach unten abschätzt.
Satz 6.13 Die Kosten für eine Folge von m Suchoperationen auf einem statischen Suchbaum T ∗ mit n Elementen, von denen Elememt u genau q(u) ≥ 1 mal gesucht wird, sind in ! X m q(u) log Ω m+ . q(u) ∗ u∈T
2 Aus den Sätzen 6.12 und 6.13 folgt, daß die Schüttelbäume maximal um einen konstanten Faktor schlechter sind als ein optimaler statischer Suchbaum. Die Schüttelbäume benötigen dabei aber keinerlei Kenntnis über die Verteilung der Suchzugriffe!
6.3.4 Analyse der Suchbaumoperationen Nachdem wir in Korollar 6.10 eine obere Schranke für die amortisierten Kosten der SplayOperation (und somit auch der Such-Operation) hergeleitet haben, beschäftigen wir uns nun mit den anderen Suchbaumoperationen. Satz 6.14 Für die amortisierten Kosten der Suchbaumoperationen in einem Schüttelbaum gelten folgende Aussagen:
1. Die amortisierten Kosten für S PLAY(T, x) und S EARCH (T, x) sind in O log G(T ) falls x ∈ T G(x) G(T ) O log falls x ∈ / T. min{G(x− ),G(x+ )}
120
Suchbäume und Selbstorganisierende Datenstrukturen
2. Die amortisierten Kosten für J OIN(T1 , T2 ) betragen G(T1 ) + G(T2 ) , O log G(z) wobei z das größte Element im Baum T1 ist. 3. Die amortisierten Kosten für S PLIT(T, x) sind in O log G(T ) falls x ∈ T G(x) G(T ) O log falls x ∈ / T. min{G(x− ),G(x+ )}
4. Die amortisierten Kosten für I NSERT(T, x) sind G(T ) + g(x) , O log min{G(x− ), G(x+ ), g(x)}
wobei x− und x+ der direkte Vorgänger bzw. Nachfolger von x in T sind. 5. Die amortisierten Kosten für D ELETE(T, x) sind G(T ) − g(x) G(T ) + O log , O log G(x) G(x− ) wobei x− der direkte Vorgänger von x im Baum T ist. Beweis: 1. Die Schranke für S PLAY(T, x) haben wir bereits in Korollar 6.10 gezeigt. Wir haben auch bereits argumentiert, daß die Kosten von S EARCH(T, x) mit denen von S PLAY(T, x) identisch sind, falls x ∈ T . Falls x ∈ / T , so wird nach Definition der S PLAY-Operation entweder der Vorgänger x− oder der Nachfolger x+ in die Wurzel befördert. 2. Die amortisierten Kosten für J OIN 2 (T1 , T2 ) kann man wie folgt nach oben abschätzen (vgl. Abbildung 6.8): G(T1 ) O log (für S PLAY(T1 , +∞)) G(r) +1
(für die realen Kosten,) (um T2 an r anzuhängen)
+ O (log(G(T1 ) + G(T1 )) − log G(T1 ))
(Rangänderung von r) (beim Anhängen von T2 )
G(T1 ) G(T1 ) + G(T2 ) = O log + O log G(r) G(T1 ) G(T1 ) + G(T2 ) = O log G(r)
(da G(T1 ) ≥ G(r))
3. Bei S PLIT(T, x) wird zunächst ein S PLAY(T, x) ausgeführt (vgl. Abbildung 6.9). Für diesen Schritt sind die amortisierten Kosten identisch mit der S PLAY-Operation. Danach werden noch zwei Links von der Wurzel aufgebrochen, was konstante reale Kosten erfordert. In diesem zweiten Schritt ändert sich maximal der Rang der Wurzel x. Da der Rang aber höchstens fällt, sind die amortisierten Kosten für den zweiten Schritt auch konstant.
6.3 Schüttelbäume
121
4. Die Kosten für I NSERT(T, x) entsprechen bis auf die Potentialänderung durch das Einfügen der neuen Wurzel denen von S PLIT(T, x). Durch das Einfügen der Wurzel x (siehe Abbildung 6.10) erhöht sich das Potential höchstens um G(T ) + g(x) . log g(x) so daß die behauptete Schranke folgt. 5. Die Abschätzung für D ELETE(T, x) folgt aus den Abschätzungen für S PLAY(T, x) und J OIN(T1 , T2 ), wobei T1 und T2 wie in Abbildung 6.11 sind. Man benutzt dabei, daß G(T1 ) + G(T2 ) = G(T ) − g(x). 2 Setzt man g(u) := 1 für alle u ∈ U , so zeigt Satz 6.14, daß alle Suchbaumoperationen in einem Schüttelbaum mit n Knoten in O(log n) amortisierter Zeit ausgeführt werden können. Somit haben wir folgendes Ergebnis: Satz 6.15 Eine Folgt von m Suchbaumoperationen auf einer Menge von anfangs leeren P Schüttelbäume benötigt O(m + m i=1 log ni ) Zeit, wobei ni die Anzahl der Knoten in demjenigen Baum ist, auf den die ite Operation wirkt. 2 Satz 6.15 zeigt, daß die Schüttelbäume nicht nur bei der Suche, sondern bei allen Suchbaumoperationen asymptotisch so gut sind wie die gebräuchlichen Klassen von balancierten Bäumen.
122
Schnelle Algorithmen für Maximale Netz-Flüsse Netzflüsse sind wichtige Werkzeuge zur Modellierung vieler Optimierungsprobleme. Informell besteht das »Maximalfluss-Problem« darum, in einem Netz mit Kapazitäten auf den Bögen so viel Fluß wie möglich von einer ausgezeichneten Quelle s zu einer ausgezeichneten Quelle t zu schicken. Dabei dürfen die Kapazitäten auf den Kanten nicht überschritten werden. In diesem Kapitel stellen wir Algorithmen zur Bestimmung maximaler Flüsse vor. Wir starten mit einer kurzen Wiederholung der Grundbegriffe und der einfachsten Algorithmen, die auf sogenannten augmentierenden Pfaden basieren. Der Schwerpunkt liegt dann aber auf fortgeschritteneren Techniken. Dies sind zum einen Präfluß-Schub-Algorithmen (engl. Preflow-Push) , die während des Laufs unzulässige Lösungen halten, letztendlich aber mit einem gültigen Fluß terminieren. Außerdem führen wir die dynamischen Bäume, eine Erweiterung der Schüttelbäume aus Abschnitt 6.3, ein, und zeigen, wie man mit dieser ausgefeilten Datenstruktur auch Fluß-Algorithmen weiterhin beschleunigen kann.
7.1 Notation und grundlegende Definitionen In diesem Kapitel bezeichnet G = (V, A) einen gerichteten Graphen mit Kapazitäten c : A → R≥0 für die Kanten. Wir nehmen an, daß G keine parallelen Bögen besitzt. Diese Annahme ist rein notationstechnisch: für die Bestimmung von maximalen Flüssen können parallele Kanten zu einer Kante mit der Summe der Kapazitäten zusammengefasst werden. Unsere zweite notationsvereinfachende Voraussetzung ist, daß G zu jedem Bogen (u, v) ∈ A auch den inversen Bogen (v, u) enthält. Diese Voraussetzung kann man immer dadurch erzwingen, daß wir für einen nicht vorhandenen inversen Bogen einen Bogen mit Kapazität 0 einfügen. Sei f : A → R≥0 eine Funktion. Wir stellen uns dabei f (u, v) als »Flußwert« auf dem Bogen (u, v) vor. Für den Knoten v ∈ V bezeichnen wir mit ef (v) :=
X
(u,v)∈A
f (u, v) −
X
f (v, w)
(7.1)
(v,w)∈A
den Überschuß von v unter f . Der erste Term in (7.1) entspricht dabei dem Zufluß in v über eingehende Bögen, der zweite Term ist der Abfluß von v. Definition 7.1 (Fluß in einem Netz) Seien s, t ∈ V Knoten im Netz G mit Kapazitäten c : A → R≥0 . Ein (zulässiger) (s, t)Fluß ist eine Funktion f : A → R, die folgende Bedingungen erfüllt:
124
Schnelle Algorithmen für Maximale Netz-Flüsse
(i) f erfüllt die Kapazitätsbedingungen, d.h. für für jeden Bogen a ∈ A gilt: 0 ≤ f (a) ≤ c(a). (ii) f gewährleistet Flußerhaltung in allen Knoten bis auf s und t, d.h. für jeden Knoten v ∈ V \ {s, t} gilt: ef (v) = 0. Der Knoten s heißt Quelle, der Knoten t Senke des Flusses f . Ist f ein Fluß in G, so gilt:
ef (s) + ef (t) =
X
(da ef (v) = 0 für alle v ∈ V \ {s, t})
ef (v)
v∈V
=
X
v∈V
= 0.
X
(u,v)∈A
f (u, v) −
X
(v,w)∈A
f (v, w)
Dabei haben wir für die letzte Gleichheit ausgenutzt, daß in der Summe jeder Flußwert f (x, y) einmal positiv in der ersten Summe für v = x und einmal negativ für v = y auftaucht. Der Netto-Zufluß in die Senke t ist also gleich dem Netto-Abfluß aus der Quelle. Den Wert val(f ) := e(t) = −e(s) nennt man den Flußwert des Flusses f . Ein Fluß heißt maximaler (s, t)-Fluß, wenn er maximalen Flußwert unter allen zulässigen (s, t)-Flüssen besitzt. Grundsätzlich kann man sich bei der Betrachtung maximaler Flüsse auf den Fall beschränken, daß immer mindestens einer der Werte f (u, v) oder f (v, u) gleich null ist. Ist f (u, v) > 0 und f (v, u) > 0, und setzen wir = min{f (u, v), f (u, v)}, so besitzt der durch f (u, v) − , falls (x, y) = (u, v) 0 f (x, y) := f (v, u) − , falls (x, y) = (v, u) f (u, v) , sonst
definierte Fluß den gleichen Flußwert wie f . Zusätzlich gilt f 0 (u, v) = 0 oder f 0 (v, u) = 0. Fortsetzung liefert einen Fluß, der auf maximal einem Bogen aus einem Paar inverser Bögen positiven Fluß aufweist. Wir werden daher im Folgenden immer annehmen, daß ein Fluß nur auf maximal einem Bogen aus einem Paar von inversen Bögen positiven Fluß besitzt.
7.2 Residualnetze und flußvergrößernde Wege Das Residualnetz Gf zu einem Fluß f spezifiziert, wie viel Fluß man maximal längs der Bögen noch zusätzlich schicken kann. Definition 7.2 (Residualnetz) Sei f ein Fluß in G. Das Residualnetz Gf besitzt die gleiche Eckenmenge wie G und enthält für jeden Bogen (u, v) ∈ A bis zu zwei Bögen: • Falls f (u, v) < c(u, v), so enthält Gf einen Bogen (u, v)+ mit Residualkapazität r((u, v)+ ) := c(u, v) − f (u, v). • Falls f (u, v) > 0, so enthält Gf einen Bogen (v, u)− mit Residualkapazität r((v, u)− ) := f (u, v).
7.3 Maximale Flüsse und Minimale Schnitte
125
Abbildung 7.1 zeigt ein Beispiel für ein Residualnetz. Prinzipiell kann G f durchaus parallele Bögen besitzen, obwohl G keine Parallelen hat. Gilt nämlich f (u, v) < c(u, v) und f (v, u) > 0, so enthält Gf einmal den Bogen (u, v)+ wegen f (u, v) < c(u, v) und noch einmal den Bogen (u, v)− wegen f (v, u) > 0. Um hier das Notationsproblem zu umschiffen, haben wir die »Vorzeichen« für die Bögen in Gf eingeführt. Ohne die Vorzeichen wäre unklar, welchen der potentiell zwei Bögen (u, v) in Gf wir meinen. Im Folgenden benutzen wir δ als Platzhalter für ein Vorzeichen, d.h. jeder Bogen in Gf hat die Form (u, v)δ .
2
2
(3, 4) s
1
(5, 5)
(2, 3)
4
(2, 2)
1 (5, 9)
5
t
s
1
1
4
2
(0, 1) 3
4 5
5
t
1 3
(f (u, v), c(u, v))
u
5 3 2
r(u, v)
v
u
(a) Das Ausgangsnetzwerk G mit einem Fluß f . Inverse Bögen mit Fluß 0 sind nicht eingezeichnet.
v (b) Das zugehörige Residualnetz Gf
Abbildung 7.1: Illustration für ein Residualnetz. Residualnetze haben eine besondere Bedeutung für maximale Flüsse. Sei p ein gerichteter Weg von s nach t im Residualnetz Gf und ∆(p) := min(u,v)δ ∈p r((u, v)δ ) die minimale Residualkapazität auf den Bögen von p (vgl. Abbildung 7.2). Wir können nun den Fluß f »längs des Weges p« erhöhen: Falls der Bogen (u, v) + von Gf auf dem Weg p liegt, so ist f (u, v) < c(u, v) und wir setzen f 0 (u, v) := f (u, v) + ∆(p). Liegt (u, v)− , so ist f (v, u) > 0 und wir setzen f 0 (v, u) := f (v, u)−∆(p). Für alle Bögen (u, v)δ , die nicht auf p liegen, sei f 0 (u, v) := f (u, v) (vgl. Abbildung 7.2(c)). Offenbar ist f 0 wieder ein Fluß in G und val(f 0 ) = val(f ) + ∆(p) > val(f ). Definition 7.3 (Flußvergrößernder Weg) Ein Weg im Residualnetz Gf heißt flußvergrößernder Weg für den Fluß f . Die Residualkapazität des Weges ist die minimale Residualkapazität auf seinen Bögen. Aus unseren Überlegungen ergibt sich nun sofort das folgende Lemma: Lemma 7.4 Existiert ein flußvergrößernder Weg für f , so ist f kein maximaler Fluß.
2
7.3 Maximale Flüsse und Minimale Schnitte Definition 7.5 (Schnitt in einem gerichteten Graphen, Vorwärtsteil und Rückwärtsteil) Sei G = (V, A) ein gerichteter Graph und S ∪ T = V eine Partition von V . Dann nennen wir [S, T ] := { (u, v) ∈ A : u ∈ S und v ∈ T } ∪ { (u, v) ∈ A : u ∈ T und v ∈ S }
126
Schnelle Algorithmen für Maximale Netz-Flüsse
2
2
(3, 5) s
1
(5, 5)
(2, 3)
2 (5, 9)
4
(2, 2)
s
t
5
5 3 2
1
5
t
r(u, v)
v
u
(a) Das Ausgangsnetzwerk mit einem Fluß f mit Wert val(f ) = 5.
v
(b) Ein flußvergrößernder Weg p (gestrichelt hervorgehoben) im Residualnetz Gf mit ∆(p) = 1.
2
2
(4, 5)
(5, 5)
(1, 3)
1 (6, 9)
4
(2, 2)
s
t
5
5 4 1
1
2
4
2
(1, 1) 3
3 6
5
1 3
(f (u, v), c(u, v))
u
5
3
(f (u, v), c(u, v))
1
4
1
3
s
4
2
(0, 1)
u
1
r(u, v)
v
u
v
(d) Das resultierende Residualnetz Gf 0 besitzt keinen Weg von s nach t mehr. Die noch von der Quelle s in Residualnetz erreichbaren Knoten S sind schwarz hervorgehoben.
(c) Flußerhöhung längs des Weges ergibt einen neuen Fluß f 0 mit val(f 0 ) = val(f ) + ∆(p) = val(f ) + 1.
2 (4, 5) s
1
(5, 5)
(1, 3)
4
(2, 2)
(6, 9)
5
t
(1, 1) 3
(f (u, v), c(u, v))
u
v
(e) Die im Residualnetz Gf 0 von s aus erreichbaren Knoten S induzieren einen Schnitt [S, T ] mit c[S, T ] = val(f ). Die Bögen im Vorwärtsteil (S, T ) sind gestrichelt hervorgehoben.
Abbildung 7.2: Ein gerichteter (s, t)-Weg in einem Residualnetz kann zum Erhöhen des Flußwertes benutzt werden.
t
7.3 Maximale Flüsse und Minimale Schnitte
127
den von S und T erzeugten Schnitt. Weiterhin nennen wir (S, T ) := { (u, v) ∈ A : u ∈ S und v ∈ T } (T, S) := { (u, v) ∈ A : u ∈ T und v ∈ S }
den Vorwärtsteil und (T, S) den Rückwärtsteil des Schnittes. Es gilt [S, T ] = (S, T ) ∪ (T, S). Der Schnitt [S, T ] heißt ein (s, t)-Schnitt, falls s ∈ S und t ∈ T . Abbildung 7.3 zeigt ein Beispiel für einen Schnitt in einem gerichteten Graphen und seinen Vorwärts- bzw. Rückwärtsteil. 2
s
3
2
1
4
5
s
t
1
4
6
5
(a) Ein (s, t)-Schnitt [S, T ] in einem gerichteten Graphen. Die Bögen in [S, T ] sind gestrichelt hervorgehoben.
6
(b) Der Vorwärtsteil (S, T ) des (s, t)-Schnittes: Die Bögen in (S, T ) sind gestrichelt hervorgehoben.
2
s
3
3
1
4
5
t
6
(c) Der Rückwärtsteil (T, S) des (s, t)-Schnittes: Die Bögen in (T, S) sind gestrichelt hervorgehoben.
Abbildung 7.3: Ein Schnitt [S, T ] in einem gerichtenen Graphen sowie sein Vorwärtsteil (S, T ) und sein Rückwärtsteil (T, S). Definition 7.6 (Kapazität eines Schnittes) Ist c : A → R≥0 eine Kapazitätsfunktion auf den Bögen, so definieren wir die Kapazität des Schnittes [S, T ] als die Summe der Kapazitäten der Bögen im Vorwärtsteil des Schnittes: X c[S, T ] := c(u, v). (u,v)∈(S,T )
t
128
Schnelle Algorithmen für Maximale Netz-Flüsse Seien f ein (s, t)-Fluß und [S, T ] ein (s, t)-Schnitt im Netz G. Dann gilt: X val(f ) = −e(s) = − e(v) v∈S
=
X
v∈S
X
(v,w)∈A
f (v, w) −
X
(u,v)∈A
f (u, v) .
(7.2)
Wenn für einen Bogen (x, y) sowohl x als auch y in S liegen, dann tritt der Term f (x, y) in der Summe in (7.2) wieder einmal positiv und einmal negativ auf. Die Summe in (7.2) reduziert sich also auf: X X f (u, v). (7.3) f (v, w) − val(f ) = (u,v)∈(T,S)
(v,w)∈(S,T )
Benutzen wir, daß f zulässig ist, also 0 ≤ f (x, y) ≤ c(x, y) für alle (x, y) ∈ A gilt, so erhalten wir aus (7.3): X X X c(v, w) = c[S, T ]. f (u, v) ≤ f (v, w) − (v,w)∈(S,T )
(u,v)∈(T,S)
(v,w)∈(S,T )
Die obige Rechnung zeigt, daß die Kapazität eines beliebigen (s, t)-Schnittes eine obere Schranke für den maximalen Flußwert eines (s, t)-Flusses ist. Wir notieren dieses wichtige Ergebnis in einem Lemma: Lemma 7.7 Ist f ein (s, t)-Fluß und [S, T ] ein (s, t)-Schnitt, so gilt:
val(f ) ≤ c[S, T ]. Da f und [S, T ] beliebig wählbar sind, folgt: max
f ist (s, t)-Fluß in G
val(f ) ≤
min
[S, T ] ist (s, t)-Schnitt in G
c[S, T ].
(7.4) 2
Wir zeigen jetzt, daß in (7.4) tatsächlich Gleichheit gilt. Sei f ∗ ein maximaler (s, t)-Fluß (die Existenz eines solchen Flusses folgt aus Stetigkeitsgründen). Nach Lemma 7.4 existiert kein flußvergrößernder Weg für f ∗ . Folglich ist t von s aus in Gf ∗ nicht erreichbar und die beiden Mengen S := { v ∈ V : v ist in Gf + von s erreichbar }
T := { v ∈ V : v ist in Gf + von s nicht erreichbar } sind beide nichtleer (wir haben s ∈ S und t ∈ T ) und definieren einen Schnitt [S, T ].
Sei (v, w) ∈ (S, T ) ein Bogen im Vorwärtsteil des Schnittes. Dann gilt f (v, w) = c(v, w), denn sonst wäre (u, v)+ ein Bogen in Gf ∗ und w von s in Gf ∗ erreichbar im Widerspruch zu w ∈ T (wir haben v ∈ S und somit ist nach Definition von S der Knoten v von s aus in Gf ∗ erreichbar). Dies zeigt: X X f (v, w) = c(v, w) = c[S, T ]. (7.5) (v,w)∈(S,T )
(u,v)∈(S,T )
Analog muß für jeden Bogen (u, v) ∈ (T, S) gelten, daß f (u, v) = 0, da sonst (v, u) − Bogen in Gf ∗ wäre und mit v auch u von s aus erreichbar wäre. Also ist: X f (u, v) = 0. (7.6) (u,v)∈(T,S)
7.4 Grundlegende Algorithmen
129
Aus (7.5) und (7.6) folgt: X f (v, w) − c[S, T ] = (v,w)∈(S,T )
X
f (u, v)
(u,v)∈(T,S)
= val(f )
(nach Gleichung (7.3)).
Wegen Lemma 7.7 muß f ∗ ein maximaler Fluß und gleichzeitig [S, T ] ein minimaler Schnitt, d.h. ein Schnitt mit minimaler Kapazität, sein. Unser Ergebnis, daß der maximale Flußwert gleich der Kapazität eines minimalen Schnittes ist, ist als das berühmte MaxFlow-Min-Cut-Theorem bekannt: Satz 7.8 (Max-Flow-Min-Cut-Theorem) In einem Netz ist der Wert eines maximalen (s, t)-Flusses gleich der minimalen Kapazität eines (s, t)-Schnittes: max
f ist (s, t)-Fluß in G
val(f ) ≤
min
[S, T ] ist (s, t)-Schnitt in G
Beweis: Siehe oben.
c[S, T ]. 2
Das Max-Flow-Min-Cut-Theorem hat eine große Anzahl wichtiger und interessanter kombinatorischer Anwendungen, auf die wie aber hier wegen der Ausrichtung dieses Skripts nicht eingehen. Details finden sich unter anderem in [1, 3, 6, 7]. Unser Beweis des Max-Flow-Min-Cut-Theorems oben hat noch ein zweites Nebenprodukt. Die Argumentation (angewendet auf einen beliebigen Fluß f anstelle des maximalen Flusses f ∗ ) zeigt, daß, falls es keinen flußvergrößernden Weg in Gf ist, der Fluß f ein maximaler Fluß ist. Die Umkehrung dieses Sachverhalts hatten wir bereits in Lemma 7.4 gezeigt. Dieses Ergebnis wird uns noch beim Beweis der Korrektheit von Flußalgorithmen nützlich sein. Daher notieren wir es in einem Satz: Satz 7.9 Ein Fluß f ist genau dann ein (s, t)-Fluß mit maximalem Flußwert, wenn es keinen flußvergrößernden Weg, d.h. keinen Weg in G f von s nach t, gibt. 2
7.4 Grundlegende Algorithmen Satz 7.9 aus dem letzten Kapitel motiviert sofort die Idee zu einem einfachen Algorithmus zur Bestimmung eines maximalen Flusses (siehe Algorithmus 7.1): Wir starten mit dem Nullfluß f ≡ 0. Solange Gf einen Weg von s nach t besitzt, erhöhen wir den Fluß längs dieses Weges. Danach aktualisieren wir Gf . Dieser Algorithmus geht auf Ford und Fulkerson zurück. Sind die Kapazitäten c im Netzwerk ganzzahlig, ist also c : A → N0 , so wird durch Algorithmus 7.1 der Fluß in jedem Erhöhungsschritt um einen ganzzahligen Betrag erhöht (ist der aktuelle Fluß f ganzzahlig, so sind alle Residualkapazitäten als Differenzen von ganzen Zahlen wieder ganzzahlig). Somit ist jeder Fluß, der zwischenzeitlich entsteht ganzzahlig. Außerdem erhöht sich der Fluß in jedem Schritt um mindestens 1. Sei C := max{ c(a) : a ∈ A } die größte auftretende Kapazität. Dann hat der (s, t)Schnitt [S, T ] mit S := {s} und T := V \ S höchstens Kapazität (n − 1)C (in s starten höchstens n − 1 Bögen, zu jedem der n − 1 anderen Knoten jeweils einer). Somit muß der generische Algorithmus 7.1 nach maximal (n − 1)C Erhöhungen terminieren, weil er keinen vergrößernden Weg mehr findet. Nach Satz 7.9 ist bei Abbruch gefundene Fluß (der nach unseren Überlegungen oben ganzzahlig ist) dann auch maximal. Damit haben wir folgende Ergebnisse bewiesen:
130
Schnelle Algorithmen für Maximale Netz-Flüsse Algorithmus 7.1 Generischer Algorithmus auf Basis flußvergrößernder Wege. AUGMENTING -PATH(G, c, s, t) Input: Ein gerichteter Graph G = (V, A) in Adjazenzlistendarstellung; eine nichtnegative Kapazitätsfunktion c : E → R≥0 , zwei Knoten s, t ∈ V . 1 2 3 4 5 6 7 8 9
for all (u, v) ∈ A do f (u, v) ← 0 end for while in Gf existiert ein Weg von s nach t do Wähle einen solchen Weg p. ∆ ← min{ r((u, v)δ ) : (u, v)δ ∈ p } Erhöhe f längs p um ∆ Einheiten. Aktualisiere Gf . end while
{ Starte mit dem Nullfluß f ≡ 0. }
{ Residualkapazität des Weges p. }
Satz 7.10 Sind alle Kapazitäten ganzzahlig, so bricht der generische Algorithmus auf Basis flußvergrößernder Wege, Algorithmus 7.1, nach O(nC) Vergrößerungsschritten mit einem maximalen Fluß ab, der ganzzahlig ist. 2 Korollar 7.11 Sind alle Kapazitäten ganzzahlig, so existiert immer ein maximaler Fluß, der ganzzahlig ist. 2 Das Ergebnis von Korollar 7.11 ist äußerst wichtig und Grundbaustein für viele kombinatorische Folgerungen aus dem Max-Flow-Min-Cut-Theorem. Achtung, das Korollar zeigt nicht, daß jeder maximale Fluß ganzzahlig ist! Es zeigt nur, daß mindestens ein maximaler Fluß existiert, der zusätzlich ganzzahlig ist. Beispielsweise zeigt Abbildung 7.4 einen maximalen Fluß der nicht ganzzahlig ist, obwohl alle Kapazitäten ganzzahlig sind. 2 ( 12 , 1) s
1
(1, 1)
( 12 , 1)
4
t
( 12 , 1) 3
(f (u, v), c(u, v))
u
v
Abbildung 7.4: Auch bei ganzzahligen Kapazitäten kann ein maximaler Fluß existieren, der nicht ganzzahlig ist. Der gestrichelte Bogen ist der Vorwärtsteil eines minimalen Schnittes. Nach Korollar 7.11 existiert aber mindestens ein ganzzahliger maximaler Fluß. In Algorithmus 7.1 bleibt zunächst offen, wie wir in Schritt 5 einen flußvergrößernden Weg wählen. Zur Flußerhöhung (und zum Beweis von Satz 7.10 und Korollar 7.11) genügt es, irgendeinen Flußvergrößernden Weg zu finden. Dazu müssen wir im Residualgraphen G f einen Weg von s nach t finden, bzw. feststellen, daß es keinen solchen Weg gibt. Mit Hilfe der Breitensuche (siehe Abschnitt C.2 auf Seite 166) können wir diese Aufgabe in O(n + m) Zeit lösen. Da die Breitensuche immer einen kürzesten Weg von s nach t in G f liefert, finden alle Flußvergrößerungen auf kürzesten Wegen statt. Diese Auswahl der flußvergrößernden
7.4 Grundlegende Algorithmen
131
2
2
(0, C)
(0, C)
C 1
s
1
(0, 1)
4
s
t
C −1
1
1
t
4
1 (0, C)
(0, C) 3
(f (u, v), c(u, v))
u
C
C −1
3
r(u, v)
v
u
v
(b) Nach der Flußerhöhung dreht sich im Residualnetzwerk die Richtung des Bogens (3, 2) um. Der flußvergrößernde Weg (1, 2, 3, 4) hat wieder Residualkapazität 1.
(a) Das Ausgangsnetzwerk entspricht dem Residualnetzwerk Gf für den Nullfluß f ≡ 0. Der flußvergrößernde Weg (1, 3, 2, 4) hat Residualkapazität 1.
2 C −1 s
1
1 1
1
C −1 4
1 C −1
2 C −1 t
s
C −1
C −2
C −2 4
1
r(u, v)
u
1 1
1
1 3
1
t
1 C −1
3
r(u, v)
v
(c) Wählt man nun wieder den flußvergrößernden Weg (1, 3, 2, 4) so hat dieser wieder Residualkapazität 1.
u
v
(d) Auch im nächsten Schritt hat dann der Weg (1, 2, 3, 4) wieder Residualkapazität 1, so daß sich der Flußwert auch wieder um 1 erhöht.
Abbildung 7.5: Bei ungeschickter Wahl des flußvergrößernden Wegs kann der generische Algorithmus 7.1 Ω(nC) Iterationen benötigen. Im oben gezeigten Beispiel werden abwechselnd die flußvergrößernden Wege (1, 3, 2, 4) und (1, 2, 3, 4) gewählt. In jeder Iteration erhöht sich der Flußwert um 1, der maximale Flußwert ist 2C, so daß insgesamt 2C = n/2·C Iterationen benötigt werden.
132
Schnelle Algorithmen für Maximale Netz-Flüsse Wege im generischen Algorithmus 7.1 liefert den Algorithmus von Edmonds und Karp. Aus unseren bisherigen Überlegungen folgt, daß der Algorithmus von Edmonds und Karp bei ganzzahligen Kapazitäten in O((n + m)nC) Zeit einen maximalen Fluß liefert. Wir werden weiter unten (Satz 7.13 auf der folgenden Seite) noch eine polynomiale Schranke für die Zeitkomplexität des Algorithmus von Edmonds und Karp herleiten. Es sollte bemerkt werden, daß der Algorithmus 7.1 bei ungeschickter Wegeauswahl extrem lange benötigen kann. Es gibt Beispiele, bei denen dann wirklich Ω(nC) Flußerhöhungen vorgenommen werden. Ein solches Beispiel ist in Abbildung 7.5 dargestellt. Falls C = 2 n , so besitzt Algorithmus 7.1 exponentielle Laufzeit. Ein weiterer theoretischer Nachteil des generischen Algorithmus 7.1 ist, daß er bei nicht ganzzahligen Kapazitäten möglicherweise nicht terminiert (die Residualkapazitäten der gefundenen Wege konvergieren hier gegen 0) und daß selbst im Grenzprozeß der gefundene Fluß dann möglicherweise nicht maximal ist. Für Beispiele verweisen wir wieder auf [1]. Lemma 7.12 Für jeden Knoten v ∈ V ist während des Algorithmus von Edmonds und Karp der Abstand δ(s, v) von s nach v in Gf monoton wachsend. Beweis: Wir zeigen die Behauptung durch Induktion nach der Anzahl der Erhöhungsschritte über flußvergrößernde Wege. Falls keine Erhöhung stattfindet, so ist die Aussage trivial. Angenommen, die Aussage gelte bis nach der iten Erhöhung. Sei f der Fluß nach der iten Erhöhung und f 0 der Fluß nach der (i + 1)ten Erhöhung. Wir bezeichnen mit δ(s, v) den Abstand von s zu v in Gf und mit δ 0 (s, v) den entsprechenden Abstand in Gf 0 . Wir müssen zeigen, daß δ 0 (s, v) ≥ δ(s, v) für alle v ∈ V gilt. δ 0 (s, u) s
u
v δ 0 (s, v) = δ 0 (s, u) + 1
Abbildung 7.6: Beweis von Lemma 7.12: Ist u Vorgänger von v auf dem kürzesten Weg von s nach v in Gf 0 , so gilt δ 0 (s, v) = δ 0 (s, u) + 1. Angenommen δ 0 (s, v) < δ(s, v) für ein v ∈ V . Dann gilt v 6= s, da der Abstand von s zu sich selbst immer gleich Null ist. Sei v bereits so gewählt, daß δ 0 (s, v) minimal unter allen Knoten ist, welche die Behauptung des Lemmas verletzen. Sei weiterhin u der Vorgänger von v auf dem kürzesten Weg von s nach v in Gf 0 (vgl. Abbildung 7.6). Dann gilt δ 0 (s, v) = δ 0 (s, u) + 1,
(7.7)
da der Teilweg von s nach u ein kürzester Weg von s nach u sein muß. Nach der Wahl von v folgt δ 0 (s, u) ≥ δ(s, u). (7.8) Wir wissen, daß der Bogen (u, v)δ in Gf 0 ist. Falls (u, v)δ ebenfalls in Gf vorhanden war, so gilt: δ(s, v) ≤ δ(s, u) + 1
≤ δ 0 (s, u) + 1 0
= δ (s, v)
Dies widerspricht der Annahme, daß δ 0 (s, v) < δ(s, v).
(nach (7.8)) (nach (7.7)).
7.5 Präfluß-Schub-Algorithmen
133
Also ist (u, v)δ nicht in Gf . Da aber (u, v)δ in Gf vorhanden ist, muß der im (i + 1)ten Schritt gefundene flußvergrößernde Weg den Bogen (v, u)−δ benutzt haben. Da der Algorithmus längs kürzester Wege erhöht, gilt δ(s, u) = δ(s, v) + 1, also δ(s, v) = δ(s, u) − 1 ≤ δ 0 (s, u) − 1
(nach (7.8))
0
= δ (s, v) − 2 < δ 0 (s, v).
(nach (7.7))
Erneut erhalten wir einen Widerspruch.
2
Damit können wir nun die Komplexität des Algorithmus von Edmonds und Karp abschätzen. Satz 7.13 Sei G ein Netzwerk mit ganzzahligen, rationalen oder reellen 1 Kapazitäten. Der Algorithmus von Edmonds und Karp terminiert nach O(nm) Iterationen mit einem maximalen Fluß. Die Gesamtkomplexität des Algorithmus ist O(nm 2 ). Beweis: Wir nennen einen Bogen (u, v)δ auf einen flußvergrößernden Weg p einen Flaschenhals-Bogen, wenn die Residualkapazität von (u, v)δ der Residualkapazität von p entspricht. In jeder Iteration des Algorithmus von Edmonds und Karp ist mindestens ein Bogen ein Flaschenhals-Bogen. Durch die Flußerhöhung verschwinden alle FlaschenhalsBögen aus dem Residualnetzwerk. Wir zeigen, daß jeder Bogen maximal O(n) mal Flaschenhals sein kann. Damit folgt dann die behauptete Iterationszahl von O(nm). Die Komplexität für eine Iteration hatten wir bereits mit O(n + m) bestimmt.
Sei (u, v)δ ein Flaschenhalsbogen in der aktuellen Iteration, und sei f der Fluß zu Beginn dieser Iteration. Da der Algorithmus längs kürzester Wege erhöht, gilt für die Abstände in Gf : δ(s, v) = δ(s, u) + 1. (7.9) Wie bereits bemerkt, verschwindet durch die Erhöhung der Bogen (u, v) δ aus dem Residualnetzwerk. Insbesondere kann (u, v)δ so lange nicht wieder ein Flaschenhals werden, bis in einem Erhöhungsschritt der neue flußvergrößernde Weg den Bogen (v, u) −δ benutzt. Sei f 0 der Fluß, bei dem dies passiert. Dann gilt für die Abstände δ 0 in Gf 0 : δ 0 (s, u) = δ 0 (s, v) + 1 ≥ δ(s, v) + 1 = δ(s, u) + 2.(nach (7.9))
(da längs kürzester Wege erhöht wird) (nach Lemma 7.12)
Somit hat sich der Abstand von s zu u um mindestens 2 erhöht. Der Abstand von s zu u kann jedoch niemals größer als n − 1 werden (solange der Abstand endlich ist, was hier der Fall ist, da längs eines Weges von s nach u erhöht wird). Damit folgt, daß der Bogen (u, v)δ höchstens (n − 1)/2 = O(n) mal Flaschenhals sein kann. 2
7.5 Präfluß-Schub-Algorithmen In diesem Abschnitt stellen wir die sogenannten Präfluß-Schub-Algorithmen zur Bestimmung maximaler Flüsse vor. Diese Algorithmen bieten sowohl theoretisch als auch praktisch effiziente Laufzeiten. 1 Im Fall von reellen Kapazitäten nehmen wir an, daß wir arithmetische Operationen auf reellen Zahlen ebenfalls in konstanter Zeit ausführen können
134
Schnelle Algorithmen für Maximale Netz-Flüsse Ein Nachteil der (meisten) Algorithmen auf Basis flußvergrößernder Wege ist, daß sie in jedem Schritt einen (potentiell langen) flußvergrößernden Weg suchen und in der nächsten Iteration erneut mit der Suche nach einem solchen Weg starten, ohne Informationen aus der letzten Suche zu benutzen. Abbildung 7.7 verdeutlicht diese Situation. Die Präfluß-SchubAlgorithmen versuchen, effizienter zu arbeiten, indem sie nicht längs ganzer Wege, sondern nur längs einzelner Kanten Fluß »schieben«.
1
s
1
1
1
.. .
1
1 1 1 1 1 1
1 1 1 1 1 1
t
.. .
Abbildung 7.7: Bei Algorithmen auf Basis flußvergrößernder Wege wird der lange erste Teil des Graphen in jedem Weg benutzt. Wir nennen eine Knotenbewertung d : V → N0 eine Distanzmarkierung bezüglich Gf , wenn sie folgende Eigenschaften besitzt: d(t) = 0 d(u) ≤ d(v) + 1
(7.10) δ
für jeden Bogen (u, v) ∈ Gf .
(7.11)
Die Bedingungen (7.10) und (7.11) nennen wir die Gültigkeitsbedingungen und bezeichnen d(v) als die Distanzmarke des Knotens v. Ist p = (v = v0 , v1 , . . . , vk = t) ein Weg von v nach t in Gf , so folgt aus den Gültigkeitsbedingungen, daß d(v) ≤ d(v1 ) + 1 ≤ d(v2 ) + 2 ≤ · · · ≤ d(t) + k = k. Also ist die Distanzmarke d(v) höchstens so groß wie die Länge des Weges p (in Bögen). Da p beliebig war, folgt, daß d(v) eine untere Schranke für die Länge des kürzesten Weges von v nach t in Gf ist. Aus dieser Eigenschaft und Satz 7.9 ergibt sich das folgende Lemma: Tatsache: Lemma 7.14 Ist d eine Distanzmarkierung bezüglich Gf und d(s) ≥ n, so ist f ein maximaler Fluß. Beweis: Gibt es einen Weg von s nach t in Gf , so existiert auch ein kreisfreier solcher Weg. Nach den Gültigkeitsbedingungen müßte ein Weg von s nach t in G f mindestens Länge n besitzen. Daraus folgt aber, daß dieser Weg einen Kreis aufweisen muß. Also existiert kein flußvergrößernder Weg und f ist nach Satz 7.9. 2
7.5 Präfluß-Schub-Algorithmen Wir benötigen vor der Beschreibung des ersten Präfluß-Schub-Algorithmus noch die Definition eines Präflusses. Für einem Präfluß lockern wir die Flußerhaltungsbedingungen dahingehend, daß wir für alle Knoten v ∈ V \ {s, t} fordern, daß e f (v) ≥ 0 ist. In jeden Knoten außer der Quelle und der Senke läuft also mindestens soviel Fluß, wie aus dem Knoten abläuft. Definition 7.15 (Präfluß, aktiver Knoten) Seien s, t ∈ V Knoten im Netz G mit Kapazitäten c : A → R≥0 . Ein (zulässiger) (s, t)Präfluß (engl. Preflow) ist eine Funktion f : A → R, welche die Kapazitätsbedingungen (siehe Definition 7.1) einhält, und die ef (v) ≥ 0 für alle v ∈ V \ {s, t} erfüllt.
Ein Knoten v ∈ V \ {s, t} mit ef (v) > 0 heißt aktiver Knoten.
Die Präfluß-Schub-Algorithmen basieren auf folgender Idee: Wir starten mit einem Präfluß f , der bewirkt, daß s von t in Gf nicht mehr erreichbar ist. Die Eigenschaft, daß es in Gf keinen Weg von s nach t gibt, wird im Verlauf des Algorithmus invariant gesichert. Erreichen wir, daß für alle Knoten v ∈ V \ {s, t} gilt: ef (v) = 0, so ist f ein Fluß, der maximal sein muß, da kein flußvergrößernder Weg vorhanden ist. Falls noch ein aktiver Knoten u, also ein Knoten u ∈ V \ {s, t} mit ef (u) > 0 existiert, so versuchen wir Fluß von ihm zu einem Knoten v mit (u, v)δ ∈ Gf »wegzuschieben«.
Beim Schieben von Fluß längs der Bögen im Residualnetzwerk halten wir uns an folgende Strategie: da wir letztendlich möglichst viel Fluß zur Senke schieben wollen, ist es unser Ziel (Über-) Fluß von Knoten, die weiter von t entfernt sind, zu Knoten zu schieben, die näher an t liegen. Hier kommen die Distanzmarkierungen ins Spiel. Definition 7.16 (Zulässiger Bogen) Sei Gf das Residualnetzwerk eines Präflusses f und d eine Distanzmarkierung bezüglich Gf . Ein Bogen (u, v)δ in Gf heißt zulässig, wenn d(u) = d(v) + 1. Im Präfluß-Schub-Algorithmus schieben wir nur Fluß über zulässige Bögen im Residualnetzwerk. Die Arbeitsweise hat eine einprägsame und bildliche Interpretation. Wir stellen uns den Fluß als Wasser in einem Röhrensystem vor. Die Distanzmarken der Knoten betrachten wir als »Höhen«. Das Wasser fließt immer »bergab«. Anfangs heben wir die Quelle ganz hoch, so daß genügend Wasser ins Röhrensystem läuft. Irgendwann sind wir möglicherweise in der Situation, daß ein aktiver Knoten u keinen Abfluß besitzt, da alle benachbarten Knoten höher liegen. In diesem Fall heben wir u »genügend« an, so daß überflüssiges Wasser nach und nach wieder zur Quelle zurückströmt.
Algorithmus 7.2 zeigt den Pseudocode eines generischen Präfluß-Schub-Algorithmus. Er started mit dem Nullfluß f ≡ 0, der ein gültiger Präfluß ist. Die exakten Distanzen zur Senke t werden in Schritt 4 berechnet. Anschließend werden in den Zeilen 5 bis 7 alle von der Quelle ausgehenden Bögen (s, v) gesättigt. Die Quelle s wird in Schritt 8 »hochgehoben«. Der Hauptteil des Algorithmus besteht aus dem wiederholten Aufruf des Unterprogramms P USH -R ELABEL(u) für einen aktiven Knoten u. In P USH -R ELABEL(u) wird entweder Fluß von u »bergab« über einen zulässigen Bogen zu einem Nachbarn in G f geschoben (wir nennen dies einen Flußschub), oder u wird durch Erhöhen seiner Markierung »angehoben«(wir nennen dies eine Markenerhöhung). Der Algorithmus terminiert, sobald keine aktiven Knoten mehr vorhanden sind. Abbildungen 7.8 bis 7.10 zeigen die Arbeitsweise des Algorithmus an einem Beispiel. Wir beschäftigen uns zunächst mit der Korrektheit von Algorithmus 7.2. Dazu betrachten wir zuerst die Knoten-Markierungen d. Lemma 7.17 Die Knotenbewertungen d[v] (v ∈ V ), die Algorithmus 7.2 hält, sind eine gültige Distanzmarkierung.
135
136
Schnelle Algorithmen für Maximale Netz-Flüsse
Algorithmus 7.2 Generischer Präfluß-Schub-Algorithmus zur Bestimmung zur Bestimmung eines maximalen Flusses. G ENERIC -P REFLOW-P USH(G, c, s, t) Input: Ein gerichteter Graph G = (V, A) in Adjazenzlistendarstellung; eine nichtnegative Kapazitätsfunktion c : E → R≥0 , zwei Knoten s, t ∈ V . Output: Ein maximaler (s, t)-Fluß f . 1 for all (u, v) ∈ A do 2 f (u, v) ← 0 { Starte mit dem Preflow f ≡ 0. } 3 end for 4 Berechne die Abstände δ(v, t) in Gf und setze d[v] ← δ(v, t) für alle v ∈ V . Diese Abstandsberechnung kann mittels einer »umgedrehten Breitensuche« von t aus in O(n + m) Zeit erfolgen. Bei der »umgedrehten Breitensuche« kehren wir die Richtung der Bögen um, so daß wir anstelle der Abstände von t die Ab- stände zu t erhalten. 5 for all (s, v) ∈ A do 6 f (s, v) ← c(s, v) 7 end for 8 d[s] ← n 9 while es existiert ein aktiver Knoten do { aktiver Knoten v: ef (v) > 0. } 10 Wähle einen aktiven Knoten u. 11 P USH -R ELABEL(u) 12 end while P USH -R ELABEL(u) 1 if es gibt einen zulässigen Bogen (u, v)δ in Gf then { zulässiger Bogen: d[u] = d[v] + 1 } 2 δ ← min{ef (u), r((u, v)δ )} 3 Falls δ = +, setze f (u, v) ← f (u, v) + δ. Falls δ = −, setze f (v, u) ← f (v, u) − δ. { »Schiebe δ Flußeinheiten von u nach v« (engl. Push). } 4 else 5 d[u] ← 1 + min{ d[v] : (u, v)δ ∈ Gf } { »Erhöhe die Marke von u« (engl. Relabel). } 6 end if
7.5 Präfluß-Schub-Algorithmen
137
(1, 0) 2
4 s
1
s 4
3
2
4
(2, 0)
4
4 (0, 0)
1 3
t 2
2
1
(1, 0)
3
s
(1, 4) 4
2
1
1
3
(b) Das Residualnetzwerk zum Präfluß f ≡ 0 mit den exakten Distanzmarken und den Überschüssen der Knoten. Die Höhen der Knoten illustrieren die Distanz. Die Bogenbewertungen zeigen die Residualkapazitäten.
(a) Das Ausgangsnetzwerk.
(4, −4)
s
4
(1, 4)
(4, −4)
4
2
1
4
(0, 0) 3
4
(0, 1) t
2 (1, 2)
3
t
4
1
(c) Zuerst werden alle von s ausgehenden Bögen gesättigt. Zusätzlich wird die Distanzmarke von s auf d[s] = n gesetzt. Nicht zulässige Bögen sind gestrichelt gezeichnet. Sie führen von einem Knoten zu einem Knoten mit mindestens »gleicher Höhe«. In der aktuellen Iteration wird der aktive Knoten 3 ausgewählt. Der Bogen (3, 4) ist der einzige ausgehende zulässige Bogen.
3
4
2 (2, 1)
3
t
1
(d) Es werde erneut der Knoten 3 ausgewählt. Es existieren keine zulässigen ausgehenden Bögen. Daher wird die Marke von 3 auf 1 + min{1, 4} = 2 erhöht. Dadurch wird der Bogen (3, 2) zulässig.
Abbildung 7.8: Bestimmung eines maximalen Flusses durch den Präfluß-SchubAlgorithmus.
138
Schnelle Algorithmen für Maximale Netz-Flüsse
s
(1, 4)
(4, −4)
4
2
1
s
4
(1, 0)
(4, −4)
4
2
1
(0, 4) 3
4
2 (2, 1)
t
(2, 1)
(a) In dieser Iteration werde Knoten 2 gewählt. Der einzige zulässige ausgehende Bogen ist (2, 4), über den 4 Einheiten Fluß geschoben werden.
s
s
1
2
1 1
3
(4, −4)
t
1
(c) Der einzige aktive Knoten ist Knoten 2. Er besitzt keinen zulässigen ausgehenden Bogen. Seine Marke wird auf 1 + min{4, 2} = 3 erhöht. Dadurch wird der Bogen (2, 3) zulässig. Es wird dann eine Einheit Fluß über den Bogen (2, 3) geschoben.
2
4
1
1
3
(0, 5) 4
2 (2, 0)
1
t
4
(3, 0)
2
4
3
(0, 5)
(b) Es werde Knoten 3 gewählt. Der einzige zulässige ausgehende Bogen ist (3, 2), über den 1 Einheit Fluß geschoben werden.
(3, 1) (4, −4)
4
2
1
3
3
2 (2, 1)
(0, 5) 4
3
t
1
(d) Als nächstes wird die Marke des einzigen aktiven Knoten 3 auf 1 + min{3, 4} = 4 erhöht. Bogen (3, 2) wird zulässig und es wird wieder eine Einheit Fluß über (3, 2) geschoben.
Abbildung 7.9: Fortsetzung: Bestimmung eines maximalen Flusses durch den PräflußSchub-Algorithmus.
7.5 Präfluß-Schub-Algorithmen
139
(5, 1) 2
4 s
(5, 0)
(4, −4)
1
1
2 2 (4, 0)
1
s
(4, −4) 1
1
1 2
(0, 5) 4
3
2
3
t
1
(a) Der Knoten 2 wird gewählt. Seine Marke wird auf 1 + min{4, 4} = 5 erhöht. Es wird dann eine Einheit Fluß über den Bogen (2, 1) geschoben.
2 (4, 0)
1
(0, 5) 4
3
t
1
(b) Es existiert kein aktiver Knoten mehr. Der Algorithmus terminiert mit einem Fluß, der wegen d[s] = n maximal sein muß.
Abbildung 7.10: Fortsetzung: Bestimmung eines maximalen Flusses durch den PräflußSchub-Algorithmus. Beweis: Wir zeigen die Behauptung durch Induktion nach der Anzahl der Aufrufe des P USH -R ELABEL-Unterprogramms. Vor dem ersten Aufruf sind alle Bedingungen (7.10) und (7.11) offenbar erfüllt. Durch eine Markenerhöhung des Knotens u auf 1 + min{ d[v] : (u, v) δ ∈ Gf } in Schritt 5 bleiben alle Bedingungen (7.11) offenbar erfüllt. Bei einem Schub von Fluß über den Bogen (u, v)δ in Schritt 3 kann der Bogen (v, u)−δ zum Residualnetzwerk hinzukommen. Für diesen müssen wir die Bedingung d[v] ≤ d[u] + 1 verifizieren. Da der Algorithmus aber nur gültige Bögen zum Schieben benutzt, beim Schub über (u, v) δ also nach Konstruktion d[u] = d[v] + 1 gilt, ist dies aber gesichert. 2 Mit Hilfe des letzen Lemmas läßt sich nun die (partielle) Korrektheit des Algorithmus folgern. Lemma 7.18 Falls Algorithmus 7.2 abbricht, so ist f ein maximaler Fluß. Beweis: Bei Abbruch existiert kein aktiver Knoten mehr, also ist f ein Fluß. Nach Lemma 7.17 ist d[s] ≥ n eine untere Schranke für den Abstand von t zur Quelle s. Nach Lemma 7.14 muß f ein maximaler Fluß sein. 2 Unser nächster Schritt ist es zu zeigen, daß Algorithmus 7.2 immer nach endlich vielen Schritten abbricht (also nach Lemma 7.18 immer einen maximalen Fluß liefert), und die Komplexität bis zum Abbruch abzuschätzen. Es genügt, die Anzahl der Markenerhöhungen und der Flußschübe zu beschränken.
7.5.1 Anzahl der Markenerhöhungen im Algorithmus Lemma 7.19 Sei f der aktuelle Präfluß während der Ausführung von Algorithmus 7.2 und v ∈ V ein aktiver Knoten. Dann existiert ein Weg von v nach s in G f .
140
Schnelle Algorithmen für Maximale Netz-Flüsse Beweis: Sei S ⊆ V die Menge aller Knoten in Gf , von denen aus s erreichbar ist (d.h. für die es einen Weg zu s gibt), und sei T := V \ S. Wir müssen zeigen, daß T keinen aktiven Knoten enthält. Es existiert kein Bogen (u, v)δ in Gf mit u ∈ T , v ∈ S (sonst wäre s auch von v aus erreichbar). Also ist: X X X c(v, w). (7.12) f (v, w) = − f (u, v) − (v,w)∈(T,S)
(v,w)∈(T,S)
(u,v)∈(S,T )
Damit ergibt sich X ef (v) 0≤
(da f Präfluß ist und s ∈ S)
v∈T
=
X
v∈T
=
X
(u,v)∈A
X
(u,v)∈(S,T )
=−
X
f (u, v) −
f (u, v) − c(v, w)
X
(v,w)∈A
X
f (v, w) f (v, w)
(v,w)∈(T,S)
(nach (7.12))
(v,w)∈(T,S)
≤0 (da c ≥ 0). P Es folgt v∈T ef (v) = 0 und wegen ef (v) ≥ 0 dann auch ef (v) = 0 für alle v ∈ T .
2
Als Korollar aus dem letzten Lemma erhalten wir, daß der Algorithmus in Schritt 5 nie über die leere Menge minimiert: Da es vom aktiven Knoten u einen Weg in G f zu s gibt, startet insbesondere mindestens ein Bogen aus Gf in u. Lemma 7.20 Während Algorithmus 7.2 gilt invariant d[v] ≤ 2n − 1 für alle v ∈ V . Die Distanzmarke jedes Knotens wird höchstens 2n − 1 mal erhöht. Insgesamt finden O(n2 ) Markenerhöhungen statt. Beweis: Algorithmus 7.2 erhöht nur die Distanzmarken von aktiven Knoten. Es genügt daher zu zeigen, daß nie die Marke eines aktiven Knoten auf mehr als 2n − 1 erhöht wird. Sei u ein aktiver Knoten. Nach Lemma 7.19 existiert dann ein Weg von u nach s. Dieser Weg besteht ohne Einschränkung aus maximal n−1 Bögen (da er sonst einen Kreis besitzt). Aus der Gültigkeit der Distanzmarken (siehe Lemma 7.17) folgt, daß d[u] ≤ (n − 1) + d[s] = (n − 1) + n = 2n − 1 gilt. 2
7.5.2 Anzahl der Flußschübe im Algorithmus Die Anzahl der Markenänderungen haben wir im letzten Abschnitt abgeschätzt. Wir wenden uns nun den Flußschüben zu. Dabei zeigt es sich als sinnvoll, die Flußschübe in zwei Klassen einzuteilen. Definition 7.21 (Sättigender und nicht-sättigender Flußschub) Ein Flußschub in Schritt 3 von Algorithmus 7.2 heißt sättigend, wenn δ = r((u, v) δ ). Andernfalls nennen wir den Flußschub nicht-sättigend. Bei einem sättigenden Flußschub über (u, v)δ verschwindet (u, v)δ aus dem Residualnetzwerk Gf und der entsprechende inverse Bogen (v, u)−δ erscheint. Lemma 7.22 Die Anzahl der sättigenden Flußschübe von Algorithmus 7.2 ist O(nm).
7.5 Präfluß-Schub-Algorithmen
141
Beweis: Sei (u, v)δ ein (potentieller) Bogen im Residualnetzwerk. Wir zeigen, daß nur O(n) sättigende Flußschübe über (u, v)δ erfolgen. Daraus folgt dann, daß die Gesamtanzahl der sättigenden Flußschübe höchstens 2m · O(n) = O(nm) ist.
Bei einem sättigenden Flußschub über (u, v)δ gilt d[u] = d[v] + 1, da Algorithmus 7.2 nur über zulässige Bögen Fluß befördert. Nach dem sättigenden Flußschub verschwindet (u, v)δ aus dem Residualnetzwerk und kann erst dann wieder erscheinen, wenn über den zugehörigen inversen Bogen (v, u)−δ Fluß geschoben wird. Zu diesem Zeitpunkt muß dann aber d0 [v] = d0 [u] + 1 gelten. Da Marken nie erniedrigt werden, haben wir d0 [v] ≥ d[u] + 1 = d[v] + 2. Somit muß sich die Marke von v zwischen zwei sättigenden Flußschüben über (u, v) δ um mindestens 2 erhöhen. Nach Lemma 7.20 kann dies aber maximal (2n − 1)/2 = O(n) mal passieren, da d[v] ≤ 2n − 1. 2 Die Abschätzung der nicht-sättigenden Flußschübe ist etwas trickreicher.
Lemma 7.23 Die Anzahl der nicht-sättigenden Flußschübe von Algorithmus 7.2 ist O(n2 m). Beweis: Wir benutzen ein Potentialfunktionsargument ähnlich wie bei der amortisierten Analyse in Kapitel 3. Sei I ⊆ V \ {s, t} die Menge aller aktiven Knoten und das Potential Φ definiert als X Φ := d[v]. v∈I
Dann ist Φ nichtnegativ und vor dem Hauptteil des Algorithmus gilt Φ ≤ (n−1)(2n−1) < 2n2 = O(n2 ), da jeder der n − 1 Nachfolger von s nach Lemma 7.20 eine Marke von höchstens 2n − 1 besitzt.
Wenn im Hauptteil des Algorithmus irgendwann Φ auf 0 fällt, so muß wegen der Nichtnegativität der Marken I = ∅ gelten. Der Algorithmus terminiert dann, da kein aktiver Knoten mehr vorhanden ist. Ein nicht-sättigender Flußschub über einen Bogen (u, v)δ verringert den Überschuß des aktiven Knotens u auf 0. Möglicherweise wird v dabei aktiv. Das Potential fällt damit um d[u] − d[v] = 1, da der Bogen (u, v)δ zulässig war und d[u] = d[v] + 1 gilt. Alle Potentialerhöhungen werden also durch Erhöhungen von Marken oder sättigende Schübe verursacht. Eine Erhöhung der Marke d[u] eines aktiven Knotens u (nur solche Marken werden erhöht) erhöht auch das Potential. Da für jeden der n − 2 potentiell aktiven Knoten die Marke nur auf maximal 2n − 1 ansteigen, so daß die Summe aller Potentialanstiege durch Markenerhöhungen nach oben durch (n − 2)(2n − 1) = O(n2 ) beschränkt ist.
Ein sättigender Schub über einen Bogen (u, v)δ kann das Potential höchstens um d[v] ≤ 2n − 1 erhöhen. Nach Lemma 7.22 finden nur O(nm) sättigende Schübe statt, so daß der Potentialanstieg durch sättigende Schübe nach oben durch (2n − 1) · O(nm) = O(n 2 m) abgeschätzt werden kann. Wir haben gezeigt, daß über den gesamten Algorithmus die Summe aller Potentialanstiege in O(n2 m) liegt. Das Ausgangspotential ist O(n2 ). Jeder nicht-sättigende Schub führt zu einem Potentialverlust von 1, somit können insgesamt nur O(n 2 m) nicht-sättigende Flußschübe stattfinden. 2
7.5.3 Zeitkomplexität des generischen Algorithmus In diesem Abschnitt zeigen wir, daß der Gesamtaufwand für den generischen Algorithmus 7.2 in O(n2 m) liegt. Später werden wir noch zeigen wie diese Komplexität durch geschickte Wahl des aktiven Knotens in Schritt 10 verbessert werden kann.
142
Schnelle Algorithmen für Maximale Netz-Flüsse Alle Operationen des Algorithmus in der Initialisierung bis einschließlich Zeile 7 sind in O(n+m) Zeit durchführbar. Wir haben außerdem bereits Schranken von O(n 2 ) für die Anzahl der Markenerhöhungen und von O(n2 m) für die Anzahl der Flußschübe hergeleitet. Das Unterprogramm P USH -R ELABEL wird daher also nur O(n2 + n2 m) = O(n2 m) mal aufgerufen. Allerdings ist nicht klar, daß wir für jeden Aufruf nur konstante Zeit benötigen (zumindest im Durchschnitt benötigen wir diese Schranke, um bei O(n2 m) Aufrufen eine Gesamtkomplexität von O(n2 m) zu erreichen). Beispielsweise müssen wir für eine Markenänderung bei u potentiell alle Marken für die Nachfolger von u in Gf betrachten. Außerdem müssen wir in Schritt 1 für einen aktiven Knoten entscheiden, ob ein zulässiger ausgehender Bogen in Gf existiert. Letztendlich stellt sich auch noch die Frage, wie wir in Zeile 10 in konstanter Zeit einen aktiven Knoten finden, bzw. feststellen, daß kein solcher Knoten vorhanden ist. Alle diese Probleme lösen wir, indem wir das Residualnetzwerk Gf geeignet (implizit) speichern. Dazu speichern wir für jeden Knoten u ∈ V ähnlich wie bei der Adjazenzlistenspeicherung (siehe Abschnitt 2.1) eine Liste aller möglichen Bögen von G f , die in u starten. Die Liste L[u] enthält also alle Bögen der Menge { (u, v)+ : (u, v) ∈ A } ∪ { (u, v)− : (v, u) ∈ A }. Für jeden Bogen (u, v)δ wird im Listeneintrag zusätzlich seine Residualkapazität r((u, v)δ ) und ein Zeiger auf den Listeneintrag des zugehörigen inversen Bogen (v, u) −δ in der Liste L[v] abgelegt. Die Reihenfolge der Bögen in L[u] ist beliebig, wird aber am Anfang einmal festgelegt und dann nicht mehr geändert. Die Liste L[u] enthält − + − deg+ G (u) + degG (u) Bögen, wobei deg G (u) und degG (u) die Anzahl der in u startenden P bzw. endenden Bögen in G ist. Es gilt u∈V |L[u]| = 2m. Für jede Liste L[u] halten wir noch einen Zeiger current[u] auf den »aktuellen«Listeneintrag. Zu Beginn zeigt current[u] auf den ersten Eintrag in der Liste L[u]. Für jeden Knoten u ∈ V speichern wir außer seiner Distanzmarke d[u] auch noch seinen Überschuß e[u]. Es sollte klar sein, daß wir unsere Strukturen zum Speichern des Residualnetzwerks und der Knotendaten in O(n + m) Zeit aus dem Originalnetzwerk aufbauen können.
Die Menge der aktiven Knoten verwalten wir in einer doppelt verketteten Liste L active . Wir erinnern daran, daß man Elemente in einer doppelt verketteten Liste in konstanter Zeit löschen und einfügen kann (siehe beispielsweise [3]). Ebenso kann in konstanter Zeit getestet werden, ob Lactive leer ist. Wird P USH -R ELABEL(u) aufgerufen, so müssen wir in Schritt 1 zunächst feststellen, ob in u ein zulässiger Bogen startet. Dazu untersuchen wir ausgehend vom Eintrag current[u] alle Listenelemente in L[u], bis daß wir entweder einen zulässigen Bogen finden, oder erfolglos am Ende der Liste ankommen. Falls wir einen zulässigen Bogen (u, v)δ gefunden haben, so setzen wir current[u] auf den entsprechenden Listeneintrag. Da wir die Residualkapazität im Listeneintrag gespeichert hatten und den Überschuß e[u] direkt aus dem Array ablesen können, kann dann der Wert δ in Schritt 2 in konstanter Zeit bestimmt werden. Über den Zeiger von (u, v) δ auf den zugehörigen inversen Bogen (v, u)−δ in L[v] können wir die Residualkapazitäten beider Bögen ebenfalls in konstanter Zeit aktualisieren. Falls duch den Flußschub der Endknoten v aktiv werden sollte, so fügen wir v zu Lactive hinzu. Somit ist ein Flußschub in konstanter Zeit ausführbar. Nach Lemma 7.22 und 7.23 ist damit der gesamte Zeitaufwand für die Flußschübe in O(n2 m).
Wenn wir erfolglos am Ende der Liste L[u] ankommen, führen wir eine Markenerhöhung von u durch und setzen current[u] auf den ersten Eintrag in L[u] zurück (wir werden im nächsten Absatz argumentieren, daß in diesem Fall tatsächlich kein zulässiger Bogen in u
7.5 Präfluß-Schub-Algorithmen Algorithmus 7.3 Implementierung des Unterprogramms P USH -R ELABEL mit Hilfe der Zeiger auf die aktuellen Bögen. P USH -R ELABEL(u) Input: Ein aktiver Knoten u. 1 (u, v)δ ← current[u] 2 if d[u] = d[v] + 1 und r((u, v)δ ) > 0 then { Ist (u, v)δ zulässig? } 3 Falls δ = +, setze f (u, v) ← f (u, v) + δ. Falls δ = −, setze f (v, u) ← f (v, u) − δ. { »Schiebe δ Flußeinheiten von u nach v« (engl. Push). } 4 else { d[u] ≤ d[v] oder r((u, v)δ ) = 0 } δ 5 if (u, v) ist nicht der letzte Bogen in der Liste von u then 6 current[u] ← nächster Bogen in der Liste 7 else { (u, v)δ ist der letzte Bogen in der Liste von u } 8 current[u] ← erster Bogen in der Liste 9 d[u] ← 1 + min{ d[v] : (u, v)δ ∈ Gf } { »Erhöhe die Marke von u« (engl. Relabel). } 10 end if 11 end if
startet und wir korrekterweise eine Markenerhöhung durchführen können). Die Bestimmung des neuen Markenwerts kann durch einen vollständigen Durchlauf der Liste L[u] von Anfang an erfolgen. Dies benötigt O(|L[u]|) Zeit. Da die Marke von u nur 2n − 1 mal erhöht wird (siehe Lemma 7.20), fällt dieser Aufwand insgesamt nur höchstens 2n − 1 mal an. Damit ist der gesamte Aufwand für alle Markenerhöhungen im Algorithmus nach oben abschätzbar durch: X |L[u]| = (2n − 1)2m = O(nm). (2n − 1) u∈U
Wir müssen noch zeigen, daß wir beim erfolglosen Durchlauf der Liste L[u] ausgehend vom Eintrag current[u] in u keine zulässigen Bögen mehr starten. Es ist natürlich trivial, daß ab dem Eintrag current[u] keine zulässigen Bögen existieren (diese haben wir ja alle untersucht). Was ist aber mit den Bögen vor current[u]? Sei (u, x)δ ein Bogen, der vor current[u] in L[u] steht. Da wir current[u] irgendwann einmal am Eintrag von (u, x)δ vorbeibewegt haben, war entweder zu diesem Zeitpunkt r((u, x)δ ) = 0 und d[u] = d[x] + 1 oder r((u, x)δ ) > 0, aber (u, x)δ nicht zulässig. Seit dem letzten Zeitpunkt, wo wir current[u] am Eintrag vorbeibewegt haben, hat keine Markenerhöhung stattgefunden, da bei dieser current[u] wieder an den Anfang gesetzt wird. Im ersten Fall kann jetzt nur r((u, x)δ ) > 0 gelten, wenn zwischendurch ein Flußschub über den inversen Bogen (x, u)−δ erfolgte. Dann gilt aber zu diesem Zeitpunkt d0 [x] = d0 [u] + 1 ≥ d[u] + 1. Damit (u, x)δ wieder zulässig wird, müßte eine Markenerhöhung von u stattfinden. Im zweiten Fall haben wir r((u, x)δ ) > 0, aber (u, x)δ war nicht zulässig, also galt dann d[u] ≤ d[x]. Damit (u, x)δ wieder zulässig wird, ist eine Markenerhöhung von u notwendig. Algorithmus 7.3 zeigt die Implementierung des Unterprogramms P USH -R ELABEL mit Hilfe der Zeiger current[u] (u ∈ V ). Wir können nun den Zeitaufwand abschätzen, der in Algorithmus 7.2 anfällt, um nach zulässigen Bögen zu suchen. Durch unseren Trick mit dem Zeiger current[u] bedingt ein
143
144
Schnelle Algorithmen für Maximale Netz-Flüsse kompletter Durchlauf der Liste L[u] (eventuell aufgeteilt in mehrere Suchen nach zulässigen Bögen) eine Markenerhöhung von u. Nach Lemma 7.20 wird die Marke d[u] höchstens 2n−1 mal erhöht. Daher ist der Gesamtaufwand für die Suche nach zulässigen Bögen wieder abschätzbar durch X |L[u]| = (2n − 1)2m = O(nm). (2n − 1) u∈U
Damit haben wir nun folgenden Satz bewiesen: Satz 7.24 Der generische Präfluß-Schub-Algorithmus kann so implementiert werden, daß er in O(n2 m) Zeit einen maximalen Fluß findet. 2
7.5.4 Der FIFO-Präfluß-Schub-Algorithmus Der Flaschenhals bei der Analyse der Laufzeit des generischen Präfluß-SchubAlgorithmus 7.2 sind die O(n2 m) nicht-sättigenden Flußschübe. Der Zeitaufwand für alle anderen Operationen ist in O(nm), also deutlich besser. In diesem Abschnitt zeigen wir, wie man durch etwas bessere Verwaltung der aktiven Knoten den die Flußschübe auf O(n3 ) reduzieren können. Wenn der generische Algorithmus 7.2 P USH -R ELABEL für einen aktiven Knoten u aufruft und einen sättigenden Flußschub ausführt, ist u möglicherweise immer noch aktiv. In nächsten Schritt wird P USH -R ELABEL möglicherweise für einen anderen aktiven Knoten aufgerufen. Beim FIFO-Präfluß-Schub-Algorithmus halten wir die Menge der aktiven Knoten in einer First-in-First-Out-Schlange (FIFO-Schlange): wir entfernen aktive Knoten vom Kopf der Schlange und fügen neue aktive Knoten hinten an die Schlange an. Wenn ein aktiver Knoten u vom Kopf entfernt wird, dann rufen wir P USH -R ELABEL solange für u auf, bis entweder u inaktiv oder die Marke von u erhöht wird. Im letzteren Fall wird u hinten an die FIFO-Schlange angefügt. Satz 7.25 Der FIFO-Präfluß-Schub-Algorithmus findet in O(n 3 ) Zeit einen maximalen Fluß. Beweis: Nach unseren bisherigen Ergebnissen genügt es zu zeigen, daß der Algorithmus nur O(n3 ) nicht-sättigende Flußschübe ausführt. Wir partitionieren die Ausführung des Algorithmus in Phasen. Phase 1 besteht aus der Bearbeitung aller Knoten, die nach der Initialisierung in der FIFO-Schlange stehen. Phase i + 1 besteht aus der Bearbeitung aller aktiver Knoten, die in Phase i zur FIFO-Schlange hinzugfügt werden. Zunächst beobachten wir, daß in jeder Phase höchstens n nicht-sättigende Schübe stattfinden: bei einem nicht-sättigendem Schub in der Phase wird der aktive Knoten inaktiv. Wird er wieder durch andere Flußschübe aktiv, so wird er durch die FIFO-Schlange in der aktuellen Phase nicht mehr betrachtet. Für jeden Knoten gibt es pro Phase also höchstens einen nicht-sättigenden Schub. Wir benutzen wieder ein Potentialfunktionsargument, um die Anzahl der Phasen abzuschätzen. Sei wieder I die Menge aller aktiven Knoten, dann ist unser Potential definiert durch Φ := max{ d[u] : u ∈ I }. Wir nennen eine Phase eine Aufstiegsphase, wenn Φ vom Start der Phase bis zum Ende der Phase ansteigt. Ansonsten heißt die Phase eine Abstiegsphase.
7.6 Dynamische Bäume und ihr Nutzen in Flußalgorithmen Findet in einer Phase keine Markenerhöhung statt, so wird der komplette Überschuß jedes Knoten, der zu Beginn der Phase aktiv war, zu Knoten geschoben, die niedrigere Marken haben. Es handelt sich also um eine Abstiegsphase. Also kann eine Aufstiegsphase nur dann vorliegen, wenn in der Phase mindestens eine Marke erhöht wird. Da nach Lemma 7.20 nur O(n2 ) Markenerhöhungen stattfinden, gibt es also nur O(n2 ) Aufstiegsphasen.
Wenn wir die Potentialanstiege vom Start bis zum Ende der Phase über alle Aufstiegsphasen zusammenzählen, so erhalten wir eine obere Schranke für die Anzahl der Abstiegsphasen. Wir betrachten eine Anstiegsphase. Sei u ein Knoten mit größter Marke d 0 [u] am Ende der Phase, also ein Knoten, der den Wert des Potentials bestimmt, und sei d[u] sein Markenwert am Anfang der Phase. Der Potentialanstieg in der Phase ist maximal d 0 [u] − d[u].
Die Summe der Potentialanstiege über alle Anstiegsphasen ist daher höchstens der Summe P der Markenanstiege aller Knoten, nach Lemma 7.20 also höchstens u∈V (2n − 1) = n(2n − 1) = O(n2 ). Daher existieren auch höchstens O(n2 ) Abstiegsphasen.
Wir haben gezeigt, daß insgesamt O(n2 ) Phasen vorliegen. Wie bereits am Anfang des Beweises bemerkt, enthält jede Phase maximal n nicht-sättigende Flußschübe. Dies beendet den Beweis. 2
7.6 Dynamische Bäume und ihr Nutzen in Flußalgorithmen In diesem Abschnitt benutzen wir die Datenstruktur der dynamischen Bäume, um den FIFO-Präfluß-Schub-Algorithmus weiter zu beschleunigen. Zunächst benutzen wir die dynamischen Bäume als »Blackbox«: wir verwenden die von ihnen zur Verfügung gestellten Operationen und die Zeitschranken ohne Beweis. In Abschnitt 7.6.3 zeigen wir dann, wie die dynamischen Bäume durch eine Erweiterung der Schüttelbäume aus Kapitel 6 implementiert werden können.
7.6.1 Operationen auf dynamischen Bäumen Die Datenstruktur der dynamischen Bäume verwaltet eine Kollektion von knotendisjunkten Bäumen. Jeder Knoten v hat ein Gewicht g(v) ∈ R≥0 ∪ {−∞, +∞}. Die Bäume werden als von unten nach oben gerichtet angesehen: für v ist p[v] der Vater von v im Baum, der v enthält, wobei v = NULL, falls v eine Wurzel ist. Folgende Operationen werden unterstützt: F IND -ROOT(v) liefert die Wurzel des Baums, der den Knoten v enthält. F IND -S IZE (v) liefert die Anzahl der Knoten im Baum, der v enthält. F IND -VALUE (v) liefert das Gewicht g(v). F IND -M IN(v) liefert den Knoten w auf dem eindeutigen Weg von v zur Wurzel mit minimalem Gewicht g(w). Falls es mehrere solche Knoten gibt, dann liefere den Knoten, der am dichtesten an der Wurzel ist. C HANGE -VALUE (v, x) addiert den Wert x ∈ R zum Gewicht g(w) jedes Vorfahren von v hinzu. Wir definieren (−∞) + (+∞) := 0. L INK(v, w) kombiniert die Bäume, welche die Knoten v und w enthalten, indem w zum Vaterknoten von v gemacht wird (vgl. Abbildung 7.11). Die Operation führt keine Aktionen aus, falls v und w im bereits gleichen Baum sind oder v kein Wurzelknoten ist.
145
146
Schnelle Algorithmen für Maximale Netz-Flüsse C UT (v) zerschneidet den Baum, der v enthält, durch Entfernen des Bogens von v zu seinem Vaterknoten in zwei Bäume (vgl. Abbildung 7.12). Die Operation führt keine Aktion aus, falls v ein Wurzelknoten ist. a
c
b g
e
a d
f
i
c
b
h L INK(x, d) e
d
g
f
j
i
x
y
x
h y
z
j z Abbildung 7.11: Illustration der Operation L INK. a
c
b e
f
g
a
d
h
b
C UT(e)
c
g
e
d
f
h
i
i
j
j
Abbildung 7.12: Illustration der Operation C UT .
Satz 7.26 Startet man mit einer Kollektion von einelementigen Bäumen, so benötigt eine Folge von ` Operationen O(` log k) Zeit, wobei k eine obere Schranke für die Größe der während der Folge auftretenden Bäume ist. Beweis: Siehe Abschnitt 7.6.3.
2
7.6.2 Einsatz im Präfluß-Schub-Algorithmus Wir benutzen dynamische Bäume, um eine Teilmenge der »aktuellen Bögen« current[u] (u ∈ V ) des Residualnetzwerkes zu speichern. Wir werden dabei nur zulässige Bögen
7.6 Dynamische Bäume und ihr Nutzen in Flußalgorithmen
147
speichern, d.h. Bögen (u, v)δ mit d[u] = d[v] + 1 und r((u, v)δ ) > 0. Allerdings werden nicht alle zulässigen Bögen in den dynamischen Bäumen gespeichert. Der Wert g(v) eines Knotens ist r((v, p[v])δ ), falls p[v] 6= NULL und +∞ sonst. Daher sagen wir auch, daß die dynamischen Bäume den Bogen (v, p[v]) δ des Residualnetzwerkes speichern. Zu Beginn des Algorithmus ist jeder Knoten v ∈ V in einem einelementigen dynamischen Baum gespeichert. Bevor wir die Details ausführen, soll hier bereits die Idee für den Einsatz der dynamischen Bäume angesprochen werden. Sei v ein aktiver Knoten. Dann besteht der Weg v = v0 , . . . , vk von v zu F IND -ROOT (v) nur aus zulässigen Bögen. Die minimale Residualkapazität auf diesem Weg ist ε = F IND -M IN(v). Wir können also hintereinander Flußschübe mit dem Betrag δ = min{ef (v), ε} auf allen Bögen (vi , vi+1 )δ ausführen. Dies können wir mit Hilfe der dynamischen Bäume ausführen, ohne jeden dieser Bögen explizit »anzufassen«: Es genügt nämlich, C HANGE -VALUE(v, −δ) auszuführen, um die Residualkapazitäten der betroffenen Bögen zu aktualisieren. Durch die Flußschübe verringert sich die Residualkapazität mindestens eines Bogens (möglicherweise mehrerer Bögen) auf 0. Diese Bögen können wir durch w = F IND -M IN (v) lokalisieren und mit C UT(w) dann aus dem dynamischen Baum von v entfernen. Nach Satz 7.26 können wir (im wesentlichen) jede Operation in logarithmischer Zeit ausführen. Das ist insbesondere dann von Vorteil, wenn der Weg aus k = Ω(n) Bögen besteht. Der Algorithmus T REE -P REFLOW-P USH Algorithmus mit folgenden Modifikationen:
entspricht
dem
FIFO-Präfluß-Schub-
• Anstelle des Unterprogramms P USH -R ELABEL wird die auf dynamischen Bäumen aufbauende Variante T REE -P USH -R ELABEL (Algorithmus 7.4) eingesetzt. • Der Algorithmus T REE -P REFLOW-P USH speichert den Präfluß f auf zwei verschiedene Weisen: – Ist für den Bogen (u, v) weder (u, v)+ noch (v, u)− in einem dynamischen Baum gespeichert, so ist f (u, v) direkt mit (u, v) in der (erweiterten) Adjazenzliste gespeichert. – Ist (u, v)δ in einem dynamischen Baum gespeichert, so beträgt der Flußwert f (u, v) dann g(u), wobei g(u) der in u »implizit« gespeicherte Wert ist. Falls der Bogen (u, v)δ durch C UT (u) entfernt wird, so wird g(u) über F IND -VALUE(u) aufgerufen und der Wert f (u, v) für den Bogen explizit in der Adjazenzliste abgespeichert. Das Unterprogramm T REE -P USH -R ELABEL wird nur für einen aktiven Knoten aufgerufen, der außerdem Wurzelknoten u eines dynamischen Baums ist. Wir werden in Lemma 7.28 auf Seite 149 zeigen, daß im Verlauf des Algorithmus überhaupt nur Wurzelknoten aktiv sein können. Beim Aufruf von T REE -P USH -R ELABEL unterscheidet die Prozedur zwei Hauptfälle: 1. Der Bogen current[u] = (u, v)δ ist zulässig. Falls die beiden Bäume, die u und v enthalten zusammen höchstens k Knoten enthalten, dann verbindet T REE -P USH -R ELABEL diese beiden Bäume, indem v zum Vaterknoten von u wird. Man beachte, daß u und v tatsächlich in verschiedenen Bäumen sind: es werden in den Bäumen nur zulässige Bögen gespeichert (siehe Lemma 7.27 auf Seite 149). Wäre v bereits im Baum von u, so müßte daher d[v] > d[u] gelten. Andererseits ist (u, v)δ zulässig und d[u] = d[v] + 1. Es erfolgt nun ein Schieben von Fluß längs des Pfades von u zur Wurzel des neuen Baumes mit Hilfe von S END(u) (Zeilen 4 bis 7). Wir nennen das Schieben mittels S END auch »Verschicken«.
148
Schnelle Algorithmen für Maximale Netz-Flüsse
Algorithmus 7.4 Neues Unterprogramm zum Schieben/Markenerhöhen für den PräflußSchub-Algorithmus mit dynamischen Bäumen. T REE -P USH -R ELABEL(u) Input: Ein aktiver Knoten u, der zugleich Wurzel eines dynamischen Baums ist. 1 (u, v)δ ← current[u] 2 if d[u] = d[v] + 1 und r((u, v)δ ) > 0 then { Ist (u, v)δ zulässig? } 3 if F IND -S IZE(u) + F IND -S IZE(v) ≤ k then { Fall 1(a): Die Bäume sind klein } 4 C HANGE -VALUE(u, −∞) 5 C HANGE -VALUE(u, r((u, v)δ ) 6 L INK(u, v) { u wird zum Sohn von v gemacht. } 7 S END(u) 8 else { Fall 1(b): F IND -S IZE(u) + F IND -S IZE(v) > k } 9 Falls δ = +, setze f (u, v) ← f (u, v) + δ. Falls δ = −, setze f (v, u) ← f (v, u) − δ. { »Schiebe δ Flußeinheiten von u nach v« (engl. Push). } 10 S END(v) 11 end if 12 else { d[u] ≤ d[v] oder r((u, v)δ ) = 0 } δ 13 if (u, v) ist nicht der letzte Bogen in der Liste von u then { Fall 2(a) } 14 current[u] ← nächster Bogen in der Liste 15 else { Fall 2(b): (u, v)δ ist der letzte Bogen in der Liste von u } 16 current[u] ← erster Bogen in der Liste 17 d[u] ← 1 + min{ d[v] : (u, v)δ ∈ Gf } { »Erhöhe die Marke von u« (engl. Relabel). } 18 Führe C UT(w) und C HANGE -VALUE(w, +∞) für jeden Sohn von u aus. 19 end if 20 end if
Algorithmus 7.5 Unterprogramm zum »Versenden« von Fluß für den Präfluß-SchubAlgorithmus mit dynamischen Bäumen. S END(u) Input: 1 2 3 4 5 6 7 8 9
Ein aktiver Knoten u.
while F IND -ROOT (u) 6= u und ef (u) > 0 do δ ← min{ef (u), F IND -VALUE(F IND -M IN (u))} C HANGE -VALUE(u, −δ) { Schiebe δ Flußeinheiten auf dem gesamten Pfad von v zur Wurzel. } while F IND -VALUE(F IND -M IN (u)) = 0 do w = F IND -M IN(u) C UT(w) { Entferne den Bogen (w, p[w])δ , da dieser jetzt keine Residualkapazität mehr besitzt. } C HANGE -VALUE(w, +∞) end while end while
7.6 Dynamische Bäume und ihr Nutzen in Flußalgorithmen
149
Falls die beiden Bäume mehr als k Knoten besitzen, so erfolgt in Zeile 9 ein »herkömmlicher« Flußschub über den Bogen (u, v)δ gefolgt von einem Verschicken ab dem Knoten v. 2. Der Bogen current[u] = (u, v)δ ist nicht zulässig. In diesem Fall wird durch den Algorithmus current[u] weitergesetzt und, falls notwendig, die Marke von u erhöht. Dies entspricht bis hierhin genau der StandardImplementierung von P USH -R ELABEL (vgl. Algorithmus 7.3). Wenn die Marke von u erhöht wird, so werden alle in v mündenden Bögen im dynamischen Baum in Schritt 18 mittels C UT entfernt. Dadurch bleibt die Invariante bestehen, daß alle Bögen im dynamischen Baum auch zulässige Bögen im Residualnetzwerk sind. Lemma 7.27 Jeder Bogen (u, v)δ , der in einem dynamischen Baum gespeichert ist, ist ein zulässiger Bogen des Residualnetzwerkes. Beweis: Die Behauptung folgt leicht durch Induktion nach der Anzahl der Aufrufe von T REE -P USH -R ELABEL. Zu Beginn ist kein Bogen gespeichert. Ein Bogen (u, v) δ wird nur dann in Schritt 6 hinzugefügt, wenn d[u] = d[v] + 1. Falls die Marke eines Knotens erhöht wird, so werden in Schritt 18 alle Bögen, die unzulässig werden könnten, entfernt. Da außerdem noch alle Bögen mit Residualkapazität 0 aus dem Bäumen entfernt werden, folgt die Behauptung. 2 Lemma 7.28 Ein Knoten v , der kein Wurzelknoten eines dynamischen Baumes ist, kann nur kurzzeitig, genauer gesagt, zwischen Schritt 3 und Schritt 11 von T REE -P USH R ELABEL strikt positiven Überschuß ef (v) > 0 besitzen. Beweis: Die Behauptung folgt wiederum durch Induktion nach der Anzahl der Aufrufe von T REE -P USH -R ELABEL. Vor dem ersten Aufruf sind alle Knoten Wurzelknoten, die Behauptung also trivial. Die beiden einzigen Stellen in T REE -P USH -R ELABEL, an denen Überschuß an einem Nicht-Wurzelknoten erzeugt werden können sind Schritt 7 sowie die Schritte 9 und 7.5. In Schritt 7 wird S END(u) aufgerufen, bei dem möglicherweise ein Überschuß bei Knoten auf dem Weg von u zur Wurzel z des Baums von u verbleiben könnte (u ist keine Wurzel mehr und ist ein solcher Kandidat). Nach Konstruktion von S END wird aber in Schritt 6 für alle Knoten w auf diesem Weg, die Ihren Überschuß nicht zum Vaterknoten p[w] weiterreichen können, der Bogen (w, p[w])δ mittels C UT(w) entfernt, so daß w ein Wurzelknoten wird. In Schritt 9 bleibt u Wurzelknoten, so daß die Situation bei u in Ordnung ist. Bei S END(v) in Schritt 10 werden analog zu oben Knoten mit Überschuß zu Wurzelknoten. 2 Lemma 7.28 zeigt, daß es genügt, T REE -P USH -R ELABEL nur für Wurzelknoten aufzurufen. Nur diese können aktiv sein. Die FIFO-Schlange des Algorithmus zur Verwaltung aktiver Knoten enthält also lediglich Wurzelknoten. Da im Algorithmus nur über zulässige Bögen Fluß geschoben wird (vgl. hierzu auch Lemma 7.27 für die S END-Operationen), folgt die Korrektheit des Algorithmus (mit unseren Standard-Argumenten für die PräflußSchub-Algorithmen): Satz 7.29 Der Algorithmus T REE -P REFLOW-P USH findet einen maximalen Fluß.
2
Wir beschäftigen uns nun mit der Zeitkomplexität des Algorithmus. Lemma 7.30 In Verlauf von Algorithmus T REE -P REFLOW-P USH werden maximal O(nm) C UT-Operationen und O(nm) L INK-Operationen ausgeführt.
150
Schnelle Algorithmen für Maximale Netz-Flüsse Beweis: Wir haben bereits gesehen, daß L INK nur Knoten in verschiedenen dynamischen Bäumen verbindet. Daher kann die Anzahl der L INK-Operationen die Anzahl der C UTOperationen um maximal n − 1 übersteigen. Somit genügt es, die C UT-Operationen zu beschränken. C UT-Operationen werden an zwei Stellen ausgeführt: innerhalb von S END und in Zeile 18. Eine C UT-Operation innerhalb von S END wird durch einen sättigenden Schub verursacht. Da es nach Lemma 7.22 O(nm) sättigende Flußschübe gibt, werden innerhalb von S END nur O(nm) C UT-Operationen ausgeführt. Ein C UT in Zeile 18 entspricht einer Markenerhöhung von u. Da wir nach Lemma 7.20 die Anzahl der Markenerhöhungen durch O(n2 ) = O(nm) beschränken können, folgt die Behauptung des Lemmas. 2 Lemma 7.31 Sei h die Anzahl der Hinzufügungen von Knoten an das Ende der FIFOSchlange zur Verwaltung der aktiven Knoten. Der Algorithmus T REE -P REFLOW-P USH benötigt O((nm + h) log k) Zeit. Beweis: Zuerst betrachten wir die Anzahl der Aufrufe von T REE -P REFLOW-P USH. Beim Aufruf von T REE -P REFLOW-P USH erfolgt ein L INK (Fall 1(a)), ein normaler Flußschub (Fall 1(b)), ein Zeigerweiterrücken (Fall 2(a)) oder eine Markenerhöhung (Fall 2(b)). Nach Lemma 7.30 tritt Fall 1(a) O(nm) mal auf. Da nach Lemma 7.20 jede Marke nur O(n) mal erhöht wird (und dhaer jede Adjazenzliste nur O(n) mal durchlaufen wird), können wir die Anzahl der Fälle 2(a) und 2(b) mit O(nm) und O(n2 ) = O(nm) abschätzen. Weiterhin kann in Fall 1(b) nach Lemma 7.22 nur O(nm) mal ein sättigender Flußschub auftreten. Bei einem nichtsättigendenden Flußschub wird der komplette Überschuß des aktiven Knotens u zu v geschoben, so daß für jeden Knoten in der FIFO-Schlange höchstens einmal diese Situation eintreten kann. Daher wird T REE -P REFLOW-P USH höchstens O(mn) + h mal aufgerufen. Den Zeitaufwand für einen Aufruf von T REE -P REFLOW-P USH teilen wir wie folgt auf: 1. Operationen innerhalb von S END, 2. Operationen in Fall 2(b). 3. sonstige Operationen auf dynamischen Bäumen, 4. Zeit für Markenerhöhungen, 5. sonstige elementare Operationen. Durch Schritt 3 des Algorithmus wird sichergestellt, daß nur dann zwei dynamische Bäume vereinigt werden, wenn der entstehende Baum höchstens k Knoten besitzt. Da im Algorithmus nur durch L INK Bäume wachsen können, folgt damit, daß jeder dynamische Baum im Algorithmus Größe höchstens k besitzt. Das bedeutet, daß jede Operation auf dynamischen Bäumen nur O(log k) (amortisierte) Zeit benötigt.
Jeder Aufruf von T REE -P REFLOW-P USH benötigt nur O(1) Operationen des Typs 3 und des Typs 5. Das bedeutet insbesondere, daß über den gesamten Algorithmus nur O(nm)+h Operationen auf dynamischen Bäumen vom Typ 3 ausgeführt werden. Wie wir in Abschnitt 7.5.3 bereits gezeigt haben, ist der gesamte Aufwand für alle Markenerhöhungen in O(nm), somit können wir den Zeitaufwand für alle Typ 4-Operationen durch O(nm) abschätzen. Wir kommen nun zu den Operationen vom Typ 1. Wir können die Operationen innerhalb von S END den dort ausgeführten C UT-Operationen zuordnen: pro C UT-Operation werden O(1) Operationen auf dynamischen Bäumen und O(1) elementare Operationen ausgeführt. Mit Lemma 7.30 erhalten wir eine Schranke von O(nm) für die Anzahl der Operationen vom Typ 1.
7.6 Dynamische Bäume und ihr Nutzen in Flußalgorithmen Die Operationen vom Typ 2 lassen sich auf ähnliche Weise den C UT-Operationen in Zeile 18 zuordnen, so daß jedem C UT nur O(1) Operationen zufallen. Lemma 7.30 liefert auch hier die gewünschte obere Schranke von O(nm) für die Operationen vom Typ 2.
Wir haben gezeigt, daß insgesamt O(nm)+h elementare Operationen und O(nm)+h Operationen auf dynamischen Bäumen ausgeführt werden. Da jede Baumoperation O(log k) amortisierte Zeit kostet, folgt die Behauptung des Lemmas. 2 Wir müssen jetzt noch die Anzahl h der Hinzufügungen von Knoten zur FIFO-Schlange abschätzen. Wie im Beweis von Satz 7.25 unterteilen wir die Ausführung von Algorithmus T REE -P REFLOW-P USH in Phasen. Phase 1 besteht aus der Bearbeitung aller Knoten, die nach der Initialisierung in der FIFO-Schlange stehen. Phase i + 1 besteht aus der Bearbeitung aller aktiver Knoten, die in Phase i zur FIFO-Schlange hinzugfügt werden. Bemerkung 7.32 Wie im Beweis von Satz 7.25 folgt, daß es nur O(n 2 ) Phasen gibt. Der für Satz reftheorem:fifo-prefolow-push vorgeführte Beweis bleibt ohne Änderung auch für T REE -P REFLOW-P USH gültig.
Lemma 7.33 Im Verlauf des Algorithmus wird O(nm + n3 /k) mal ein Knoten zur FIFOSchlange hinzugefügt. Beweis: Es gibt genau zwei Situtationen, die bewirken können, daß ein Knoten w zur FIFOSchlange hinzugefügt wird: (i) Die Marke des Knotens w wird erhöht. In diesem Fall ist w = u ein aktiver Knoten, für den T REE -P USH -R ELABEL aufgerufen wurde und dessen Überschuß nicht komplett weggeschoben werden konnte. Es wird genau ein Knoten, nämlich w = u an die FIFO-Schlange angefügt. (ii) Der Überschuß des Knotens erhöht sich von 0 auf einen positiven Wert. Diese Situation kann nur in Fall 1(a) und 1(b) von T REE -P USH -R ELABEL auftreten. Es wird maximal ein Knoten mehr zur FIFO-Schlange hinzugefügt, wie C UTOperationen in S END erfolgen. Nach Lemma 7.20 tritt der Fall (i) nur O(n2 ) = O(nm) mal auf. Somit genügt es zu zeigen, daß beim Fall (ii) insgesamt höchstens O(nm + n3 /k) Knoten zur FIFO-Schlange hinzugefügt werden. Bei Fall 1(a) erfolgt ein L INK-Aufruf. Nach Lemma 7.30 können wir die Anzahl der L INK-Aufrufe nach oben durch O(nm) beschränken, so daß Fall 1(a) insgesamt O(nm) + O(nm) = O(nm) Knoten in die Schlange stellt.
Die Knoten, die in Fall 1(b) an die FIFO-Schlange angehängt werden, ist etwas trickreicher zu analysieren. Bei O(nm) Eintreten von Fall 1(b) wird ein C UT ausgeführt (da nach Lemma 7.30 nur O(nm) C UT-Operationen erfolgen). Außerdem kann nur bei O(nm) Eintreten von Fall 1(b) ein sättigender Schub erfolgen (vgl. Lemma 7.22). Es verbleiben somit noch die Situationen, in denen weder ein C UT noch ein sättigender Flußschub erfolgt. Wir nennen ein solches Auftreten von Fall 1(b) einen nicht-sättigenden b-Fall. Wir sind mit dem Beweis fertig, wenn wir die Anzahl der nicht sättigenden b-Fälle geeignet beschränken können. Unsere Strategie dafür ist die folgende: wir ordnen jeden nicht-sättigenden b-Fall einer L INK einer C UT-Operation oder einem bestimmten »großen« Baum zu (was »groß« genau heißt, definieren wir weiter unten exakt). Dabei stellen wir sicher, daß jedes Ziel nur O(1) nicht-sättigende b-Fälle erhält. Die Anzahl der L INK/C UT-Operationen ist nach Lemma 7.30 O(nm). Ebenso werden wir die Anzahl der großen Bäume geeignet beschränken.
151
152
Schnelle Algorithmen für Maximale Netz-Flüsse Mit Tu bezeichnen wir den dynamischen Baum, der den Knoten u enthält. Die Anzahl seiner Knoten referenzieren wir wie üblich mit |Tu |. Der Baum Tu heißt groß, wenn |Tu | > k/2. Ansonsten nennen wir Tu klein. Bei einem nicht-sättigenden b-Fall ist mindestens einer der beiden Bäume groß. Lemma 7.28 liefert uns, daß jeweils nur Wurzelknoten in der Schlange stehen, da nur solche positiven Überschuß haben. Die Bäume in der FIFO-Schlange sind knotendisjunkt. Folglich enthält die FIFO-Schlange zu jedem Zeitpunkt maximal 2n/k große Bäume. Wir betrachten einen nicht-sättigenden b-Fall beim Aufruf T REE -P USH -R ELABEL(u). Wie wir bereits gesehen haben ist mindestens einer der beiden Bäume T u oder Tv groß. Nach Konstruktion ist u Wurzel von Tu . Nach Definition des nicht-sättigenden b-Falls wird beim Flußschub von u nach v in Zeile 9 der komplette Überschuß von u zu v geschoben. Folglich kann ein nicht-sättigender b-Fall für jeden Knoten maximal einmal pro Phase auftreten. 1. Fall: Tu ist groß. Falls Tu seit Beginn der Phase durch eine L INK- oder C UT-Operation verändert wurde, so ordnen wir den nicht-sättigenden b-Fall der letzten dieser L INK -/C UT-Operationen vor dem nicht-sättigenden b-Fall zu. Da jedes L INK einen neuen Baum und jedes C UT zwei neue Bäume erzeugt, bekommt jedes L INK maximal einen und jedes C UT maximal zwei nicht-sättigende b-Fälle zugeordnet. Die Anzahl der derart zugeordneten nicht-sättigenden b-Fälle ist O(nm) für die gesamte Laufzeit. Falls sich Tu nicht geändert hat, so ordnen wir den nicht-sättigenden b-Fall dem Baum T u zu. Da der komplette Überschuß von u jetzt verschwindet, bekommt jeder solche Baum T u pro Phasenur einen nicht-sättigenden b-Fall zugeordnet. Da T u groß ist, gibt ist pro Phase maximal 2n/k große Bäume, insgesamt also 2n/k · O(n2 ) = O(n3 /k). Daher haben wir auch O(n3 /k) nicht-sättigende b-Fälle zugeordnet.
2. Fall: Tv ist groß. Sei r die Wurzel von Tv . Durch S END(v) wird der vollständige Überfluß von v zur Wurzel r geschoben, da sonst ein C UT erfolgen müßte. Also wird r zur FIFO-Schlange hinzugefügt (falls r bereits in der Schlange ist, so können wir den aktuellen Fall ignorieren). Wir ordnen nun analog zum ersten Fall zu. Falls sich Tv seit dem Beginn der Phase geändert hat, so wird der nicht-sättigende b-Fall dem L INK/C UT zugeordnet, das Tv zuletzt änderte. Andernfalls ordnen wir den Fall dem großen Baum Tv zu. Man beachte, daß r nur einmal pro Phase zur FIFO-Schlange hinzugefügt werden kann. Mit der gleichen Argumentation wie im ersten Fall können wir die nicht-sättigenden b-Fälle durch O(nm + n3 /k) beschränken. Dies beendet den Beweis.
2
Satz 7.34 Der Algorithmus T REE -P REFLOW-P USH bestimmt in O(nm log k)Zeit einen 2 2 maximalen Fluß. Für k = n /m erhält man die Zeitkomplexität O nm log nm . Beweis: Unmittelbar aus Lemma 7.31 und 7.33.
2
7.6.3 Implementierung der dynamischen Bäume In diesem Abschnitt zeigen wir, wie wir dynamische Bäume durch eine Erweiterung der Schüttelbäume aus Kapitel 6 implementieren können, so daß jede Operation auf einem dynamischen Baum der Größe n nur O(log n) amortisierte Zeit benötigt.
Wir repräsentieren jeden dynamischen Baum T durch einen virtuellen Baum VT mit gleicher Knotenmenge, aber anderer Struktur. VT besteht aus einer hierarchischen Kollektion von binären Bäumen in folgender Weise: Zusätzlich zu linkem und rechten Sohn (jeder möglicherweise gleich NULL, also nichtexistent) hat jeder Knoten noch null oder mehr mittlere Kinder. Wir stellen uns Kanten zwischen mittleren Kindern und den Vätern als
7.6 Dynamische Bäume und ihr Nutzen in Flußalgorithmen »gestrichelt« vor. Der virtuelle Baum zerfällt also an den gestrichelten Kanten in eine Kollektion von durchgezogenen Bäumen. (vgl. Abbildung 7.13). Der Zusammenhang zwischen T und VT ist wie folgt: Der Vater von v in T ist der LWRNachfolger von v im durchgezogenen Baum von VT, der v enthält. Für den gemäß LWROrdnung letzten Knoten in einem durchgezogenen Baum ist sein Vater der Vater der Wurzel seines durchgezogenen Baums. Wir werden jeden durchgezogenen Baum mit Hilfe eines Schüttelbaums umsetzen. Um die Topologie des gesamten virtuellen Baumes VT zu speichern, genügt es wie bisher für jeden Knoten u einen Zeiger p[u] auf den Vater von x und Zeiger left[u] und right[u] auf den linken und rechten Sohn von u zu speichern. Wir können dann in konstanter Zeit feststellen, ob u ein linker, rechter oder mittlerer Sohn seines Vaters p[u] ist: dazu vergleichen wir wir einfach x mit left[p[u]] und right[p[u]]. Im Folgenden werden wir zur Restrukturierung eines virtuellen Baumes S PLAYOperationen in den durchgezogenen Teilbäumen ausführen. Dabei werden die mittleren Kinder »einfach mitgenommen«. Genauer, rotieren wir so, als ob mittlere Kinder nicht vorhanden wären in den durchgezogenen Bäumen. Da wir keine Zeiger auf mittlere Kinder, sondern nur von den mittleren Kindern zu den Vätern halten, wandern die Kinder mit dem Vater. Abbildung 7.14 zeigt eine Rotation unter Beibehaltunger der mittleren Kinder. Die zweite Restrukturierungstechnik, die wir anwenden werden, ist das Vertauschen von Söhnen. Genauer, wird dabei ein mittlerer Sohn v zum linken Sohn seines Vaters w gemacht, und der bisherige linke Sohn u wird zu einem mittleren Sohn. Abbildung 7.15 veranschaulicht diese Operation. Die Operation kann einfach durch Setzen von left[w] = v ausgeführt werden und benötigt konstante Zeit. Wir zeigen nun, wie wir die Gewichte g(u) für die Knoten im Baum speichern. Dazu werden wir g(u) nicht direkt bei u sondern »implizit« abspeichern. Die implizite Speicherung, wie wir sie gleich vorstellen, hat den Vorteil, daß Aktualisierungen des Baumes, vor allem die C HANGE -VALUE-Operation schneller vorgenommen werden können. Für einen Knoten u bezeichnen wir mit g(u) sein Gewicht und mit m(u) das minimale Gewicht eines Nachfolgers von u im durchgezogenen Teilbaum von u. Wir speichern nun für jeden Knoten u die folgenden zwei Werte: ( g(u) falls u Wurzel eines durchgezogenen Baums ist ∆g(u) := g(u) − g(p[u]) sonst ∆m(u) := g(u) − m(u).
Bei der vorgestellten Speicherung können wir für jeden Knoten u die Werte g(u) und m(u) in O(1) Zeit bestimmen. Außerdem können wir die Werte nach einer Rotation oder einer Sohn-Vertauschung in O(1) Zeit wieder auf die korrekten Werte aktualisieren.
Wir zeigen dies für eine einfache Rotation, die beim Zick-Fall auftritt (vgl. Abbildung 6.12(a)). Die anderen Rotationen lassen sich analog behandeln. Sei u der linke Sohn von v = p[u] und seien die Knoten a, b, c wie in Abbildung 7.14 gezeichnet. Nach der Rotation an v müssen die Daten für die Knoten u, v und b aktualisiert werden. Alle anderen Werte werden durch die Rotation nicht berührt. Die neuen Daten ergeben sich durch: ∆g 0 (u) = ∆g(u) + ∆g(v) ∆g 0 (v) = −∆g(u) ∆g 0 (b) = ∆g(u) + ∆g(b) ∆m0 (v) = max{0, ∆m(b) − ∆g 0 (b), ∆m(c) − ∆g(c) } ∆m0 (u) = max{0, ∆m(a) − ∆g(a), ∆m0 (v) − ∆g 0 (v) } ∆m0 (b) = ∆m(b).
153
154
Schnelle Algorithmen für Maximale Netz-Flüsse
10 a 3 b 2 c e 5
d 13 g 6
8 f 4 i
h 15
j 1
6 l
k 7 9 o p 1
n 10
m 15 r 4
12 q
2 s 12 t 8 u 3 v 3 w
(a) Der Baum T . Die Zahlen in den Knoten bezeichen die Kosten g(v).
f 8,6
l -2,2 q 6,0
-5,1 b
p 1,0
c -1,0
i -2,0
7,0 a
j 1,0
15,10 h 5,0 g
r 4,0
11,0 m
k -8,0
7,0 d o 2,0]
-10,0 e
10,0 n
v 3,1
w 0,0
9,10 t
u -4,0
-10,0 s
(b) Der virtuelle Baum VT. Die Zahlen in den Knoten bezeichnen ∆g und ∆m.
Abbildung 7.13: Ein dynamischer Baum T und ein virtueller Baum VT, der T repräsentiert.
7.6 Dynamische Bäume und ihr Nutzen in Flußalgorithmen
155 Rotation an v
v u
c Z
a
b X
Y u a
v X
Y c
b Z
Abbildung 7.14: Rotation in einem Schüttelbaum unter Beibehaltung der mittleren Kinder.
w u
v
w x
v
u
x
Abbildung 7.15: Vertauschen von einem mittleren Sohn mit den linken Sohn. Bei einer Sohn-Vertauschung wie in Abbildung 7.15 sind lediglich Werte bei den betroffenen Söhnen u und v zu aktualisieren. Die Formeln hierfür lauten: ∆g 0 (v) = ∆g(v) − ∆g(w) ∆g 0 (u) = ∆g(u) + ∆g(w) ∆m0 (w) = max{0, ∆m(v) − ∆g 0 (v), ∆m(right[w]) − ∆g(right[w])}. Die Expose-Operation Im virtuellen Baum VT werden wir die Operationen auf dynamischen Bäumen auf eine abgewandelte Schüttel-Operation zurückführen. Um Mißverständnisse zu vermeiden, bezeichnen wir das Schütteln in einem durchgezogenen Baum, bei dem die mittleren Kinder mitgenommen werden, als S PLAY und die abgewandelte Schüttel-Operation, die wir gleich beschreiben als E XPOSE-Operation. Wir beschreiben E XPOSE im virtuellen Baum VT als einen dreiphasigen Bottom-UpProzeß (die drei Phasen lassen sich auch zu einem kombinieren, die Darstellung ist mit getrennten Phasen aber klarer). Sei x der Knoten, der exponiert werden soll. In der ersten Phase folgen wir dem Pfad von x zur Wurzel von VT. Dabei schütteln wir innerhalb jedes durchgezogenen Baums wie folgt: zunächst wird x zur Wurzel seines durchgezogenen Baum geschüttelt. Sei y der resultierende Vater von x im virtuellen Baum, der mit x durch eine gestrichelte Kante verbunden ist. Wir schütteln nun y zur Wurzel seines durchgezogenen Baums. Dieses Verfahren setzen wir fort, bis wir an der Wurzel angelangt sind. Nach der ersten Phase besteht der Pfad von x zur Wurzel des virtuellen Baums nur aus gestrichelten Kanten.
156
Schnelle Algorithmen für Maximale Netz-Flüsse In der zweiten Phase folgen wir wieder den (aktuellen) Pfad von x zur Wurzel von VT, wobei wir den aktuellen Knoten (der mittlerer Sohn seines Vaters ist) mit den linken Knoten des Vaters vertauschen. Dabei wird der alte linke Sohn zu einem mittleren Sohn. Nach der zweiten Phase befinden sich x und die Wurzel des virtuellen Baums im gleichen durchgezogenen Baum. In der dritten Phase folgen wir ein letztes Mal dem Pfad von x zur Wurzel und schütteln in der üblichen Weise x zur Wurzel. Nach der dritten Phase ist x dann Wurzel des virtuellen Baums. Zeitaufwand für E XPOSE Wir analysieren nun die amortisierte Zeit für E XPOSE(v). Dabei benutzen ein Potential analog zu Abschnitt 6.3.3. Jeder Knoten v hat Gewicht g(v) = 1, G(v) bezeichnet das Gewicht aller Knoten im Teilbaum mit Wurzel v (wobei wir hier sowohl durch gestrichelte als auch durch durchgezogene Kanten erreichbare P Knoten zählen) und r(v) = log 2 G(v). Das Potential Φ(T ) eines Baums T ist dann 2 v∈T r(v). Es wird gleich klarwerden, warum wir hier die doppelten Ränge benutzen.
Als reale Zeit zählen wir die Anzahl der ausgeführen Rotationen, die gleich der Ausgangstiefe des Knotens v ist. Analog zu Korollar 6.10 auf Seite 118 zeigt man nun das folgende Ergebnis:
Lemma 7.35 Sei T ein durchgezogener Baum mit Wurzel root[T ] in einem virtuellen Baum und x ein Knoten in T . Die amortisierten Kosten für die erweiterte Splay-Operation S PLAY(T, x) betragen höchstens 1 + 6 · (r(root[T ]) − r(x))
(7.13)
wobei root[T ] der Wurzelknoten von T ist. Falls wir jede Rotation bei den realen Kosten doppelt zählen, sind die amortisierten Kosten immer noch höchstens 2 + 6 · (r(root[T ]) − r(x)). Beweis: Der Beweis von Korollar 6.10 über Lemmas 6.8 und 6.9 bleibt gültig, auch wenn mittlere Kinder vorhanden sind. Wir führen die leicht geänderten Lemmas mit den nahezu trivialen Änderungen an den Beweisen der Vollständigkeit halber nochmals auf. Die Änderungen sind dabei durch Einkastelungen hervorgehoben. Lemma 7.36 Die amortisierten Kosten eines einzelnen Splay-Schrittes am Knoten u, bei dem der Zick-Fall vorliegt, betragen höchstens 1 + 6(r 0 (u) − r(u)). Falls wir jede Rotation doppelt bei den realen Kosten zählen, sind die amortisierten Kosten höchstens 2+6(r 0 (u)− r(u)). Beweis: Wir bezeichnen mit r 0 die Ränge der einzelnen Knoten nach dem Splay-Schritt und mit T 0 den Ergebnisbaum. Durch den Splay-Schritt ändern sich nur die Ränge von u und seinem Vater v im Baum T , so daß für die Potentialdifferenz gilt: Φ(T 0 ) − Φ(T ) = 2 (r 0 (u) + r0 (v) − r(u) − r(v))
(7.14)
Weiterhin ist r0 (u) = r(v), da beide Größen dem Logarithmus der Summe der Gewichte aller Elemente im Baum entsprechen. Die realen Kosten für den Splay-Schritt sind gleich 1. Somit erhalten wir aus (7.14) für die amortisierten Kosten die obere Schranke: 1 + Φ(T 0 ) − Φ(T ) = 1 + 2 (r 0 (v) − r(u))
≤ 1 + 2 (r0 (u) − r(u)) 0
≤ 1 + 6 (r (u) − r(u))
(nach Lemma 6.6) (da r 0 (u) = r(v) ≥ r(u) nach Lemma 6.6)
7.6 Dynamische Bäume und ihr Nutzen in Flußalgorithmen
157
Somit folgt der erste Teil des Lemmas. Falls wir jede Rotation doppelt in den realen Kosten zählen, so ergeben sich offensichtlich Kosten höchstens 2 + 6(r 0 (u) − r(u)). 2 Lemma 7.37 Die amortisierten Kosten eines einzelnen Splay-Schrittes am Knoten u, bei dem der Zick-Zick-Fall oder ein Zick-Zack-Fall vorliegt, betragen höchstens 3(r 0 (u) − r(u)). Falls wir jede Rotation doppelt bei den realen Kosten zählen, sind die amortisierten Kosten höchstens 6(r 0 (u) − r(u)). Beweis: Wir betrachten als erstes den Zick-Zick-Fall. Die realen Kosten sind gleich 2 (für zwei Rotationen) Die Ränge aller Knoten außer u, v und w bleiben unverändert. Daher sind die amortisierten Kosten für den Splay-Schritt: 2 + Φ(T 0 ) − Φ(T ) = 2 + 2 (r 0 (u) + r0 (v) + r0 (w) − r(u) − r(v) − r(w)) = 2 + 2 (r0 (v) + r0 (w) − r(u) − r(v)) ≤ 2 + 2 (r0 (v) + r0 (w) − 2r(u))
(da r 0 (u) = r(w)) (da r(u) ≤ r(v))
≤ 2 + 2 (r0 (u) + r0 (w) − 2r(u))
(da r 0 (v) ≤ r0 (u)) (7.15)
Weiterhin gilt G(u) + G0 (w) ≤ G0 (u), also haben wir r(u) + r0 (w) = log2 G(u) + log2 G0 (w) G(u) + G0 (w) 2 = 2r0 (u) − 2. ≤ 2 log2
(nach Lemma 6.7)
≤ 2 log2
G0 (u) 2
Aus dieser Ungleichungskette erhalten wir r 0 (w) ≤ 2r0 (u) − 2 − r(u). Setzt man diese Ungleichung in (7.15) ein, so erhalten wir 2 + Φ(T 0 ) − Φ(T ) ≤ 2 + 2 (r 0 (u) + (2r0 (u) − 2 − r(u)) − 2r(u)) = 6 (r 0 (u) − r(u)) -2 . Damit haben wir die erste Behauptung des Lemmas für den Zick-Zick-Fall bewiesen. Falls wir die Rotationen doppelt bewerten, so werden die zusätzlichen Kosten von 2 bei den realen Kosten durch die −2 in der letzten Ungleichung kompensiert.
Wir betrachten nun den Zick-Zack-Fall. Wie beim Zick-Zick-Fall ändern sich höchstens die Ränge von u, v und w. Daher sind die amortisierten Kosten gegeben durch: 2 + Φ(T 0 ) − Φ(T ) = 2 + 2 (r 0 (u) + r0 (v) + r0 (w) − r(u) − r(v) − r(w)) = 2 + 2 (r0 (v) + r0 (w) − r(u) − r(v)) ≤ 2 + 2 (r0 (v) + r0 (w) − 2r(u))
(da r 0 (u) = r(w)) (da r(v) ≥ r(u)) (7.16) (7.17)
Es gilt nun G0 (v) + G0 (w) ≤ G0 (u). Damit folgt analog zum Zick-Zick-Fall, daß r 0 (v) + r0 (w) ≤ 2r0 (u) − 2. Benutzt man diese Ungleichung in (7.16), so erhält man: 2 + Φ(T 0 ) − Φ(T ) ≤ 2 + 2 ((2r 0 (u) − 2) − 2r(u)) = 4 (r0 (u) − r(u)) -2
≤ 6 (r0 (u) − r(u)) -2
(da r 0 (u) ≥ r(u))
Wieder kompensiert das −2 die zusätzlichen Kosten von 2 beim doppelten Zählen der Rotationen. 2
158
Schnelle Algorithmen für Maximale Netz-Flüsse Lemma 7.35 folgt nun unmittelbar aus Lemma 7.36 und Lemma 7.37.
2
Mit Lemma 7.35 folgt, daß die Kosten für die erste Phase von E XPOSE(v) höchstens 6 log n + k ist, wobei k die Tiefe von v nach der ersten Phase bezeichnet. Man erhält diese Schranke durch Summieren über die beteiligten durchgezogenen Bäume, in denen geschüttelt wird. Für das Schütteln im ersten Baum T1 (von unten gesehen), entstehen Kosten 1 + 6 · (r(root[T1 ]) − r(v)), für das Schütteln im zweiten Baum T2 dann 1 + 6 · (r(root[T2 ]) − r(y)), wobei y der Vater von v nach dem Schütteln in T1 ist. Da r(y) ≥ r(root[T1 ]) ist, sind die Kosten für das Schütteln in T2 also höchstens 1+6·(r(root[T1 ]) − r(root[T2 ])). Die gesamte Summe ergibt dann eine Telekopsumme, die sich auf k + 6 · (r(root[VT]) − r(v)) reduziert, wobei VT die Wurzel des virtuellen Baums ist, die Rang höchstens n besitzt. Die zweite Phase verändert das Potential des virtuellen Baums nicht und führt auch keine Rotationen aus, so daß sie amortisierte Kosten 0 hat. Man beachte, daß in der dritten Phase genau k Rotationen stattfinden. Wir schlagen k Rotationen aus der ersten Phase der dritten Phase zu, indem wir jede Rotation in der dritten Phase doppelt zählen. Damit verringern sich die gerechneten amortisierten Kosten für die erste Phase auf 6 log n. Aus Lemma 7.35 folgt, daß die amortisierten Kosten für die dritte Phase dann immer noch höchstens 6 log n + 2 betragen. Insgesamt haben wir somit für E XPOSE(v) amortisierte Kosten höchstens 6 log n+6 log n+2 = 12 log n+2 = O(log n). Lemma 7.38 Die amortisierten Kosten für E XPOSE(v) in einem virtuellen Baum mit n Knoten sind O(log n). 2 Implementierung der Operationen mit Hilfe von Expose Die einzelnen Operationen auf dynamischen Bäumen können wir folgt mit Hilfe der E X POSE -Operation implementiert werden: F IND -ROOT(v) Wir führen E XPOSE(v) durch. Dann folgen wir den Zeigern für die rechten Söhne solange, bis wir beim LWR-letzten Knoten w im durchgezogenen Baum angelangt sind. Wir führen E XPOSE(w) durch und liefern w zurück. F IND -S IZE (v) wird dadurch implementiert, daß wir uns für jeden virtuellen Baum seine Kardinalität merken. F IND -VALUE (v) Wir führen E XPOSE(v) durch. Danach wird g(v) explizit geführt und wir können den Wert g(v) zurückliefern. F IND -M IN(v) Wir führen E XPOSE(v) durch und folgen dann den ∆g und ∆m Feldern um zum letzten Nachfolger w von v im durchgezogenen Baum mit minimalen Kosten zu gelangen. Wir führen E XPOSE(w) durch und liefern w zurück. C HANGE -VALUE (v, x) Wir führen E XPOSE(v) aus, addieren x zu ∆g hinzu und subtrahieren x von ∆g(left[x]), falls left[x] 6= NULL. L INK(v, w) Wir führen E XPOSE(v) und E XPOSE(w) durch. Danach machen wir v zu einem mittleren Sohn von w, indem wir p[v] = w setzen. C UT (v) Nach E XPOSE(v) addieren wir ∆g(v) zu ∆g(right[v]) und entfernen den Link von v zu right[v], indem wir p[right[v] = NULL und right[v] = NULL setzen. Man sieht leicht, daß alle Operationen oben O(log n) amortisierte Kosten haben, da E XPOSE nur O(log n) amortisierte Zeit kostet.
Abkürzungen und Symbole Abkürzungen O. B. d. A.
Ohne Beschränkung der Allgemeinheit
Symbole ∀ ∃ [a, b] [a, b) (a, b) |A| ∅ N Ω(g) O(g) R Θ(g)
der Allquantor der Existenzquantor das geschlossene Intervall { x ∈ R a ≤ x ≤ b } das halboffene Intervall { x ∈ R a ≤ x < b }, analog auch (a, b] das offene Intervall { x ∈ R a < x < b }
die Kardinalität der Menge A die leere Menge die Menge der natürlichen Zahlen, N := {0, 1, 2, . . .} Ω(g) := { f ∈ M | ∃c, n0 ∈ N : ∀n ≥ n0 : f (n) ≥ c · g(n) } O(g) := { f ∈ M | ∃c, n0 ∈ N : ∀n ≥ n0 : f (n) ≤ c · g(n) } die Menge der reellen Zahlen Θ(g) := O(g) ∩ Ω(g)
160
Komplexität von Algorithmen B.1 Größenordnung von Funktionen Sei M die Menge aller reellwertigen Funktionen f : N → R auf den natürlichen Zahlen. Jede Funktion g ∈ M legt dann drei Klassen von Funktionen wie folgt fest: • O(g) := { f ∈ M | ∃c, n0 ∈ N : ∀n ≥ n0 : f (n) ≤ c · g(n) } • Ω(g) := { f ∈ M | ∃c, n0 ∈ N : ∀n ≥ n0 : f (n) ≥ c · g(n) } • Θ(g) := O(g) ∩ Ω(g) Man nennt eine Funktion f von polynomieller Größenordnung oder einfach polynomiell, wenn es ein Polynom g gibt, so daß f ∈ O(g) gilt.
B.2 Berechnungsmodell Das bei der Laufzeit-Analyse verwendete Maschinenmodell ist das der Unit-Cost RAM (Random Access Machine). Diese Maschine besitzt abzählbar viele Register, die jeweils eine ganze Zahl beliebiger Größe aufnehmen können. Folgende Operationen sind jeweils in einem Takt der Maschine durchführbar: Ein- oder Ausgabe eines Registers, Übertragen eines Wertes zwischen Register und Hauptspeicher (evtl. mit indirekter Adressierung), Vergleich zweier Register und bedingte Verzweigung, sowie die arithmetischen Operationen Addition, Subtraktion, Multiplikation und Division [8]. Dieses Modell erscheint für die Analyse der Laufzeit von Algorithmen besser geeignet als das Modell der Turing-Maschine, denn es kommt der Arbeitsweise realer Rechner näher. Allerdings ist zu beachten, daß die Unit-Cost RAM in einem Takt Zahlen beliebiger Größe verarbeiten kann. Durch geeignete Codierungen können damit ausgedehnte Berechnungen in einem einzigen Takt versteckt werden, ferner sind beliebig lange Daten in einem Takt zu bewegen. Damit ist das Modell echt mächtiger als das der Turing-Maschine. Es gibt keine Simulation einer Unit-Cost RAM auf einer (deterministischen) Turing-Maschine, die mit einem polynomiell beschränkten Mehraufwand auskommt. Um diesem Problem der zu großen Zahlen vorzubeugen, kann man auf das Modell der Log-Cost RAM [8] zurückgreifen. Bei einer solchen Maschine wird für jede Operation ein Zeitbedarf angesetzt, der proportional zum Logarithmus der Operanden, also proportional zur Codierungslänge ist. Eine andere Möglichkeit, das Problem auszuschließen, besteht darin, sicherzustellen, daß die auftretenden Zahlen nicht zu groß werden, also daß ihre Codierungslänge polynomiell beschränkt bleibt. Diese Voraussetzung ist bei den hier vorgestellten Algorithmen stets erfüllt. Der einfacheren Analyse wegen wird daher das Modell der Unit-Cost RAM zugrundegelegt.
162
Komplexität von Algorithmen
B.3 Komplexitätsklassen Die Komplexität eines Algorithmus ist ein Maß dafür, welchen Aufwand an Ressourcen ein Algorithmus bei seiner Ausführung braucht. Man unterscheidet die Zeitkomplexität, die die benötigte Laufzeit beschreibt, und die Raumkomplexität, die Aussagen über die Größe des benutzten Speichers macht. Raumkomplexitäten werden in diesem Skript nicht untersucht. Die Komplexität wird in der Regel als Funktion über der Länge der Eingabe angegeben. Man nennt einen Algorithmus von der (worst-case-) Komplexität T , wenn die Laufzeit für alle Eingaben der Länge n durch die Funktion T (n) nach oben beschränkt ist. Die Komplexität von Algorithmen wird in dieser Arbeit als Funktion der Eckenzahl n und Kantenzahl m des eingegebenen Graphen angegeben. Diese Angabe ist detaillierter als die Abhängigkeit der Komplexität von der Eingabelänge: bei ecken- und kantenbewerteten Graphen ist deren Codierungslänge mindestens von der Größenordnung Ω(n + m). Besonders wichtig sind in diesem Zusammenhang die Klassen P und NP. Die Klasse P ist die Menge aller Entscheidungsprobleme, die auf einer deterministischen Turing-Maschine in polynomieller Zeit gelöst werden können. Entsprechend ist die Klasse NP definiert als die Menge aller Probleme, deren Lösung auf einer nichtdeterministischen Turing-Maschine in Polynomialzeit möglich ist. Man vergleiche dazu etwa [5]. Eine Transformation zwischen NP-Problemen heißt polynomielle Reduktion, wenn sie in polynomieller Zeit Instanzen zweier Probleme aus NP so ineinander überführt, daß die Antwort des Ausgangsproblems auf die Ausgangsinstanz dieselbe ist wie die Antwort des zweiten Problems auf die transformierte Instanz. Ein Problem heißt NP-vollständig, wenn jedes andere Problem aus NP polynomiell darauf reduziert werden kann. Der Reduktionsbegriff wird durch Einführen der Turing-Reduktion so erweitert, daß Reduktionen zwischen Optimierungsproblemen und Entscheidungsproblemen in NP erfaßt werden. Ein NP-Optimierungsproblem heißt dann NP-hart, wenn es von einem NP-vollständigen Entscheidungsproblem turing-reduziert werden kann. Ein wesentliches Resultat der Komplexitätstheorie besagt, daß NP-harte Optimierungsprobleme nicht in polynomieller Zeit gelöst werden können, es sei denn, es gilt P = NP. Dies ist der Grund, warum bei der Untersuchung von NP-harten Optimierungsproblemen auf exakte Lösungen verzichtet wird und stattdessen Näherungen in Betracht gezogen werden.
Bemerkungen zum Dijkstra-Algorithmus Mit den im Skript vorgestellten Datenstrukturen für Prioritätsschlangen erhalten wir die folgenden Laufzeiten für den Dijkstra-Algorithmus 2.1 auf Seite 8: Datenstruktur Array binärer Heap Binomial-Heap Fibonacci-Heap
Laufzeit O(m + n2 ) O((n + m) log n) O((n + m) log n) O(m + n log n)
Hierbei ist wie üblich n die Anzahl der Ecken und m die Anzahl der Kanten/Bögen im Graphen. In diesem Anhang soll kurz darauf eingegangen werden, wie man im Falle von ganzzahligen Kantenbewertungen alternative Zeitschranken erhält, die, je nach Anwendung, unter anderem besser sind. In diesem Kapitel des Anhangs sei daher immer c : E → N0 bzw. c : A → N0 eine ganzzahlige nichtnegative Kanten-/Bogenbewertung. Mit C bezeichnen wir die größte auftretende Länge. Da jeder kürzeste Weg maximal n Kanten/Bögen besitzt (sonst würden Knoten wiederholt und der Weg besäße einen Kreis), hat jeder Knoten mit endlichem Abstand von s höchstens Abstand nC.
C.1
Ganzzahlige Längen
Der ersten alternativen Implementierung einer Prioritätsschlange liegt folgende Idee zugrunde: Wir benutzen für die Schlange ein Array, wobei jeder Arrayeintrag aus einer doppelt verketteten Liste besteht, die wir Korb nennen. Der Korb L[x] im Eintrag x speichert alle Knoten v mit Schlüsselwert d[v] = x. Das Einfügen eines neuen Knotens u in die Schlange kann dann in konstanter Zeit erfolgen: wir müssen u lediglich an den Korb L[d[u]] anhängen. Eine Verringerung des Schlüsselwertes kann ebenfalls in konstanter Zeit erfolgen: Wir müssen den betreffenden Konten aus seinem aktuellen Korb entfernen und in den Korb einfügen, die seinem neuen Schlüsselwert entspricht. Zum Extrahieren des Minimums könnten wir das Array der Körbe von vorne nach hinten (d.h. startend vom Index 0) durchgehen und den ersten nichtleeren Korb finden. Dies erfordert O(nC) Zeit pro E XTRACT-M IN. Dies geht allerdings viel geschickter.
Man beachte, daß die Folge der Minima der Prioritätsschlange im Dijkstra-Algorithmus monoton wachsend ist (dies folgt unmittelbar aus der Nichtnegativität von c). Lag unser
164
Bemerkungen zum Dijkstra-Algorithmus aktuelles Minimum, das wir gerade aus der Schlange entfernt haben, in Korb L[x], so müssen die Körbe L[0], . . . , L[x−1] leer sein und auch weiterhin leer bleiben. Zum Extrahieren des Minimums genügt es also, sich eine Markierung k auf den Korb L[k] zu halten, der das letzte Minimum enthielt. Beim nächsten E XTRACT-M IN starten wir bei der Suche nach dem ersten nichtleeren Korb mit Korb L[k]. Damit reduziert sich der gesamte Aufwand für alle O(n) E XTRACT-M IN-Operationen zusammen des Dijkstra-Algorithmus auaf O(nC). Zusammen mit Satz 2.3 erhalten wir folgendes Ergebnis:
Beobachtung C.1 Mit Hilfe der oben beschriebenen Array-Listen-Implementierung für die Prioritätsschlange läuft der Dijkstra-Algoirthmus in O(m + nC) Zeit und benötigt Θ(nC) Speicherplatz. Für kleine Werte von C ist diese Implementierung unseren Heap-Implementierungen überlegen. Falls C eine (globale) Konstante ist, die nicht von der Eingabe abhängt, erhalten wir sogar lineare Laufzeit. Allerdings sind die Speicherplatzanforderungen von Θ(nC) für die Prioritätsschlange sehr groß (man denke etwa an den Fall C = 2n ). Wir verfeinern jetzt die obige Konstruktion und verringern den Speicherplatzbedarf auf O(C). Dazu benötigen wir eine weitere Beobachtung. Im Algorithmus von Dijkstra steht am Anfang nur die Startecke s in der Prioritätsschlange Q. Diese hat den Schlüsselwert d[s] = 0. Danach werden alle Nachfolger von s eingefügt. Jeder dieser Nachfolger v besitzt einen Schlüsselwert von maximal d[s] + c(s, v) ≤ C. Somit unterscheiden sich die Schlüsselwerte in der Schlange nach Extrahieren von s um maximal C. Man sieht leicht, daß dies allgemeiner gilt: Ist k = d[v] der Schlüsselwert des im zu Beginn des aktuellen Durchlaufs der while-Schleife entfernten Minimums, so sind am Ende des Durchlaufs alle Schlüsselwerte in der Schlange aus dem Bereich k, k + 1, · · · , k + C. Dies bedeutet, daß aus unserem Array von nC Körben gleichzeitig immer nur C + 1 Körbe aus einem zusammenhängenden Bereich benötigt werden. Wir organisieren die Prioritätsschlange daher als eine zyklisch verkettete Liste aus aus C + 1 Körben L[0], . . . , L[C]. Der Korb L[k], k = 0, . . . , C, speichert die Knoten v mit Markierung k modulo C + 1: L[k] = { v : v ist in der Schlange und d[v]
mod (C + 1) = k }.
(C.1)
Abbildung C.1 veranschaulicht die zyklische Anordnung der Körbe. Im Prinzip könnten sich in Korb L[k] somit Einträge mit Schlüsselwerten k, k + (C + 1), k + 2(C + 1), . . . befinden. Da sich im Verlauf des Dijkstra-Algorithmus zwei Schlüsselwerte in der Schlange aber um maximal C unterscheiden können, wie wir oben erkannt haben, befinden sich in L[k] nur Elemente mit gleichem Schlüsselwert. Das Einfügen in die neue Datenstruktur sowie das Verringern von Schlüsselwerten erfolgt ähnlich wie bei der Array-Listen-Implementierung mit dem Zusatz, daß wir modulo C + 1 rechnen müssen (vgl. (C.1)). Zum Extrahieren des Minimums durchlaufen wir die zyklische Liste, bis daß wir den ersten nichtleeren Korb L[k] gefunden haben. Beim nächsten E XTRACT-M IN starten wir wieder bei L[k] und suchen weiter (ringsherum) nach dem nächsten nichtleeren Korb. Da wir nach maximal einer »Umdrehung« das Minimum gefunden haben und wir höchstens n − 1 mal das Minimum suchen, benötigen alle E XTRACT-M IN-Operationen wieder nur O(nC) Zeit. Diese Implementierung ist auch als Dials Implementierung des Dijkstra-Algorithmus bekannt. Beobachtung C.2 Mit Hilfe von Dials Implementierung läuft der Dijkstra-Algoirthmus in O(m + nC) Zeit und benötigt Θ(n + C) Speicherplatz. Wir können den Speicherplatzbedarf noch weiter reduzieren und dabei sogar noch die Laufzeit verringern. Wir benutzen B < C + 1 normale Körbe und einen Überlaufkorb. Wir
C.1 Ganzzahlige Längen
165
2 3
1
4
0
5
C 6
···
Abbildung C.1: Organisation der Körbe in der Implementierung von Dial. unterteilen die Ausführung des Algorithmus in Phasen. In der iten Phase enthalten die normalen Körbe alle Knoten mit Schlüsselwerten im Bereich [Bi , Bi + B − 1], so daß jeder Korb nur Knoten mit dem gleichen Schlüsselwert speichert. Die Knoten mit größeren (endlichen) Schlüsselwerten sind im Überlaufkorb gespeichert. Einfügen und Verringern von Schlüsselwerten ist wieder in konstanter Zeit möglich, da wir über den Index auf den entsprechenden Korb zugreifen können. Am Anfang sind wir in Phase 0. Wir setzen B0 := 0 und der Zeiger M für den ersten nichtleeren Korb ist auf Korb 0 gesetzt. Wir extrahieren das Minimum, indem wir den ersten nichtleeren Korb suchen. Wie bei der Array-Listen-Implementierung und bei Dial’s Implementierung starten wir dabei beim Korb, in dem wir das letzte Mal das Minimum gefunden hatten. Sobald M nun den Wert Bi + B erreicht (d.h., sobald wir unsere normalen Körbe durchlaufen haben), beenden wir die aktuelle Phase und setzen B i+1 auf den minimalen Schlüsselwert im Überlaufkorb (dazu durchlaufen wir den Überlaufkorb einmal). Danach verteilen wir die Elemente im Überlaufkorb wieder auf die normalen Körbe, die jetzt den Bereich [Bi+1 , Bi + B − 1] speichern, und den Überlaufkorb. Satz C.3 Die Implementierung von Dijkstras Algorithmus mit Hilfe der ÜberlaufkorbDatenstruktur benötigt O(m + n(C/B + B)) Zeit und Θ(n + B) Speicherplatz. Beweis: Der gesamte Aufwand für alle I NSERT- und D ECREASE -K EY-Operationen ist in O(n + m), da jede dieser Operationen in konstanter Zeit ausgeführt wird. Es genügt somit, den Aufwand für alle E XTRACT-M IN-Operationen und das Reorganisieren der Körbe abzuschätzen. In jeder Phase wird mindestens ein Knoten als Minimum aus dem Heap entfernt. Somit existieren maximal n Phasen. In einer Phase werden die Körbe einmal durchlaufen, so daß bis auf das Umverteilen des Überlaufkorbs, in jeder Phase O(B) Aufwand für das Minimum-Suchen und Extrahieren anfällt. Dies ergibt O(nB) für alle Phasen zusammen. Wenn ein Knoten das erste Mal im Überlaufkorb landet, so ist sein Schlüsselwert mindestens Bi + B (wobei i die Nummer der entsprechenden Phase ist). Da sich B i in der nächsten Phase erhöht, kann der Knoten maximal O(C/B) mal im Überlaufkorb landen. Daher ist der Gesamtaufwand für das Durchlaufen des Überlaufkorbs und das Umverteilen seines Inhalts in allen Phasen zusammen in O(nC/B). 2 √ Wenn wir in Satz C.3 B = d Ce wählen, so erhalten wir folgendes Korollar:
166
Bemerkungen zum Dijkstra-Algorithmus Korollar C.4 Die Implementierung von Dijkstras Algorithmus mit √ √ Hilfe der Überlaufkorb-Datenstruktur benötigt O(m + n C) Zeit und Θ(n + C) Speicherplatz. 2
C.2
Einheitslängen: Breitensuche
Oft tritt der Fall auf, daß wir in einem ungewichteten Graphen einen (kürzesten) Weg von einem Knoten s zu einem Knoten t (oder zu allen Knoten) finden müssen. Dieses Problem stellt sich etwa bei einigen der Flußalgorithmen aus Kapitel 7. Falls alle Kantenlängen (Bogenlängen) gleich 1 sind, so liefert Beobachtung C.1 bereits einen Algorithmus mit O(n + m) Zeit und O(n + m) Speicherplatz. Es lohnt sich aber trotzdem, noch einmal kurz über die Situation nachzudenken, da wir im vorliegenden Spezialfall eine einfachere Datenstruktur für die Prioritätsschlange (sogar einen einfacheren Algorithmus) einsetzen können, ohne Effizienz einzubüßen. Wir erinnern uns daran, daß beim Dijkstra-Algorithmus die Schlüsselwerte in der Prioritätsschlange aus dem Bereich k, k +1, . . . , k +C sind, wobei k der minimale Schlüsselwert in der Schlange und C die größte Kanten-/Bogenlänge ist. Im Fall C = 1 bedeutet dies, daß wir nur maximal die Schlüsselwerte k und k + 1 in der Schlange haben. Sei u der aktuell als Minimum aus der Schlange entfernte Knoten und k = d[u]. Jeder Knoten v, der jetzt als Nachfolger von u neu in die Schlange eingefügt wird (weil vorher d[v] = +∞ galt), wird mit Schlüsselwert d[v] + 1 = k + 1 eingefügt, also mit einem Schlüsselwert, der mindestens so groß ist wie alle aktuellen Schlüsselwerte in der Schlange. Jeder Knoten v, der beim Entfernen von u bereits in der Schlange war, hat bereits einen Schlüsselwert von maximal k + 1. Also wird niemals ein Schlüsselwert erniedrigt! Die Beobachtungen im letzten Absatz zeigen, daß wir die Prioritätsschlange als lineare Liste verwalten können. Das Minimum ist immer das erste Listenelement. Neue Elemente können wir einfach hinten an die Liste anfügen (dies zerstört die Ordnung nicht, da die neuen Elementen Schlüsselwerte haben, die größer sind als alle Schlüssel in der Liste). Da keine D ECREASE -K EY-Operationen ausgeführt werden, bleibt unsere Liste somit immer nach Schlüsselwerten sortiert. Den entsprechenden Algorithmus nennt man auch Breitensuche (engl. Breadth-FirstSearch). Algorithmus C.1 zeigt den Algorithmus im Pseudocode. Wir haben bereits argumentiert, daß Algorithmus C.1 korrekt arbeitet. Da jedes »E XTRACT-M IN« (Entfernen des ersten Listenelements) und jedes »I NSERT« (Anhängen an das Ende der Liste) konstante Zeit benötigen und keine E XTRACT-M IN-Operationen benötigt werden, ist die Laufzeit der Breitensuche linear. Satz C.5 Der Algorithmus C.1 bestimmt korrekt die Abstände von s in einem ungewichteten Graphen. Seine Laufzeit ist O(n + m). 2
C.2 Einheitslängen: Breitensuche
Algorithmus C.1 Breitensuche (Breadth-First-Search) B FS(G) Input:
Ein (un-) gerichteter Graph G = (V, A) in Adjazenzlistendarstellung; ein Knoten s ∈ V . Output: Für jeden Knoten v ∈ V der Abstand d[v] von s zu 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 L ← {s} { Eine Liste, die nur s enthält. } 7 while L 6= ∅ do 8 Entferne das erste Element u aus L. 9 for all v ∈ Adj[u] do 10 if d[v] > d[u] + 1 then 11 d[v] ← d[u] + 1 12 p[v] ← u 13 Füge v an das Ende von L an. 14 end if 15 end for 16 end while
167
168
Literaturverzeichnis [1] R. K. Ahuja, T. L. Magnanti, and J. B. Orlin, Networks flows, Prentice Hall, Englewood Cliffs, New Jersey, 1993. [2] T. H. Cormen, C. E. Leiserson, and R. L. Rivest, Introduction to algorithms, MIT Press, 1990. [3] T. H. Cormen, C. E. Leiserson, R. L. Rivest, and C. Stein, Introduction to algorithms, 2 ed., MIT Press, 2001. [4] A. Fiat and G. J. Woeginger (eds.), Online algorithms: The state of the art, Lecture Notes in Computer Science, vol. 1442, Springer, 1998. [5] M. R. Garey and D. S. Johnson, Computers and intractability (a guide to the theory of NP-completeness), W.H. Freeman and Company, New York, 1979. [6] D. Jungnickel, Graphen, Netzwerke und Algorithmen, 2 ed., BI-Wissenschaftsverlag, 1990. [7] H. Noltemeier, Graphentheorie: mit Algorithmen und Anwendungen, de Gruyter Lehrbuch, 1975. [8] C. M. Papadimitriou, Computational complexity, Addison-Wesley Publishing Company, Inc., Reading, Massachusetts, 1994. [9] R. E. Tarjan, Data structures and networks algorithms, CBMS-NSF Regional Conference Series in Applied Mathematics, vol. 44, Society for Industial and Applied Mathematics, 1983.