Entwurf und Analyse von Algorithmen Volker Diekert Sommersemester 2006 Inhaltsverzeichnis 1 Grundlagen 1 1.1 Worum geht es? . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1 1.2 Aufwandsabschätzungen . . . . . . . . . . . . . . . . . . . . . . . 2 1.2.1 Allgemeines . . . . . . . . . . . . . . . . . . . . . . . . . . 2 1.2.2 Komplexität . . . . . . . . . . . . . . . . . . . . . . . . . . 4 1.3 Maschinenmodelle . . . . . . . . . . . . . . . . . . . . . . . . . . 6 1.4 Untere Schranken und Reduktionen . . . . . . . . . . . . . . . . . 9 1.5 Rekursionsgleichungen . . . . . . . . . . . . . . . . . . . . . . . . 9 2 Entwurfstrategien 2.1 2.2 2.3 2.4 2.5 Divide and Conquer 13 . . . . . . . . . . . . . . . . . . . . . . . . . 13 2.1.1 Multiplikation ganzer Zahlen . . . . . . . . . . . . . . . . . 14 2.1.2 Matrixmultiplikation nach Strassen . . . . . . . . . . . . . 14 2.1.3 Transitive Hülle und Matrixmultiplikation . . . . . . . . . 16 Greedy-Algorithmen . . . . . . . . . . . . . . . . . . . . . . . . . 17 2.2.1 Kürzeste Wege in Graphen (Dijkstra-Algorithmus) . . . . 18 2.2.2 Minimale aufspannende Bäume (Prim-Algorithmus) . . . . 23 Dynamische Programmierung . . . . . . . . . . . . . . . . . . . . 28 2.3.1 Transitive Hülle und kürzeste Wege in Graphen . . . . . . 28 2.3.2 Multiplikation einer Matrizenfolge . . . . . . . . . . . . . . 29 2.3.3 Optimale Suchbäume . . . . . . . . . . . . . . . . . . . . . 31 Backtracking . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 37 2.4.1 Das Mautproblem . . . . . . . . . . . . . . . . . . . . . . . 37 Zusammenfassung . . . . . . . . . . . . . . . . . . . . . . . . . . . 39 i 3 Sortieren und Medianberechnung 40 3.1 Quicksort . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 40 3.2 Bottom-Up-Heapsort . . . . . . . . . . . . . . . . . . . . . . . . . 43 3.3 Ultimatives Heapsort . . . . . . . . . . . . . . . . . . . . . . . . . 48 3.4 Medianberechnung in linearer Zeit . . . . . . . . . . . . . . . . . . 52 3.5 Quickselect . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 54 4 Verwaltung von Mengen 56 4.1 Äquivalenz endlicher Automaten . . . . . . . . . . . . . . . . . . . 57 4.2 Minimale aufspannende Bäume (Kruskal-Algorithmus) . . . . . . 60 4.3 Realisierung von Union und Find . . . . . . . . . . . . . . . . . . 62 4.4 Analyse der Union-Find Entwurfstrategie . . . . . . . . . . . . . . 65 4.4.1 68 Gemessene Laufzeitkosten . . . . . . . . . . . . . . . . . . 5 Weitere Themen 69 5.1 Fibonacci Heaps . . . . . . . . . . . . . . . . . . . . . . . . . . . . 69 5.2 Minimale Schnitte . . . . . . . . . . . . . . . . . . . . . . . . . . . 75 ii Vorwort Das vorliegende Skript ersetzt kein Lehrbuch! Vielmehr mag es der Leserin oder dem Leser zeigen, welchen ungefähren Stoffumfang die zweistündige Vorlesung im Sommersemester hatte und welcher Prüfungsstoff in etwa vorbereitet werden sollte. Für das Nacharbeiten der Vorlesung ist die Hinzunahme eines geeigneten Lehrbuches wie z. B. Aho, Hopcroft, Ullman [2], Cormen, Leiserson, Rivest, Stein [4], Sedgewick [12], oder Weiss [15] unerlässlich. Dennoch hoffe ich, dass dieses Skript nützlich ist und ein wenig Freude an diesem schönen und zentralen Gebiet der Informatik vermitteln kann. An dem Skript haben verschiedene Leute aktiv mitgewirkt: Ursprünglich basiert es auf einer Mitschrift aus dem Jahre 1992 von Juan Roldan Güpner. Dann haben Anca Muscholl und Andreas Bergen weitere Abschnitte hinzugefügt. Die vorliegende Fassung wurde von Stefan Kiefer durchgesehen. Herr Kiefer hat sich insbesondere um den Abschnitt über optimale Suchbäume verdient gemacht und die Lücken im Korrektheitsbeweis zum quadratischen Algorithmus von Knuth geschlossen, die sich in früheren Versionen dieses Skripts befanden. In der Vorlesung stehen der Entwurf und die Analyse der Algorithmen im Vordergrund; die notwendigen Datenstrukturen werden im Wesentlichen als bekannt vorausgesetzt. Wenn Datenstrukturen behandelt wurden, so geschah dies möglichst abstrakt und die konkrete Implementation wurde als Übung überlassen. Ich halte eine solche Umsetzung in die Praxis jedoch für bedeutungsvoll und möchte nachdrücklich dazu auffordern, zumindestens einige der behandelten Algorithmen am eigenen Rechner durchzuspielen. Ich denke, dass man auf diese Weise die Algorithmen in einem fast wörtlichen Sinne begreifen und den Wert einer theoretischen Analyse verstehen kann. Ich danke allen oben erwähnten Personen für die Mithilfe an diesem Skript ganz herzlich. Volker Diekert Stuttgart, April 2003 iii Kapitel 1 Grundlagen 1.1 Worum geht es? Gegenstand der Vorlesung sind Probleme, die sich mathematisch etwa folgendermaßen charakterisieren lassen: Ein Problem P ist eine Relation P ⊆ X × Y , wobei X die Eingabemenge und Y die Lösungsmenge ist. Die allgemeine Vorgehensweise, um ein Problem zu lösen, besteht gewöhnlich aus zwei Phasen. Sei x ∈ X dabei die Eingabe : 1. Stelle fest, ob ein y ∈ Y mit (x, y) ∈ P existiert. An dieser Stelle sind schon viele Probleme i.a. unlösbar (man denke z.B. an die Prädikatenlogik 1. Stufe). 2. Berechne ein f (x) mit (x, f (x)) ∈ P . Dabei kann es mehrere Lösungen f (x) geben, wir begnügen uns aber häufig mit einer Lösung. Es gibt verschiedene Problemstellungen: • Entscheidungsproblem: Bei einem Entscheidungsproblem ist die Lösungsmenge Y durch Y = {ja, nein} gegeben. • Optimierungsproblem: Bei einem Optimierungsproblem ist von den möglichen Lösungen f (x) diejenige gesucht, die nach einem vorgegebenen Kriterium ein Optimum darstellt. • Berechnungsproblem: Bei einem Berechnungsproblem ist die Lösung f bereits bekannt, und man möchte qualitative und quantitative Aussagen über die Berechnung von f machen (d.h. f soll möglichst effizient berechnet werden). 1 1.2 Aufwandsabschätzungen 1.2.1 Allgemeines Unser Ziel wird es sein, effiziente Algorithmen zu entwerfen. Dafür werden qualitative Aussagen, z.B. über die Laufzeit der Algorithmen, gemacht. Meistens sind exakte Berechnungen dieser Art zu umfangreich so, dass wir uns mit guten oberen Schranken begnügen müssen. Die Bedeutung von gut“ zielt in diesem Zusam” menhang auch auf die Abweichungen hin, die wir bei einer qualitativen Aussage in Kauf nehmen wollen. In vielen Fällen sind wir hauptsächlich am Wachstumsverhalten einer Funktion bzw. an Vergleichen dieser Art interessiert. Dafür können die Landau-Symbole verwendet werden. Definition 1.2.1 (Landau-Symbole) • O(f) (lies groß Oh“) ” Es gilt g ∈ O(f ) genau dann, wenn ∃c > 0 ∃n0 ∀n ≥ n0 : g(n) ≤ c · f (n). Dies heißt g wächst nicht schneller als f . • o(f) (lies klein Oh“) ” Es gilt g ∈ o(f ) genau dann, wenn ∀c > 0 ∃n0 ∀n ≥ n0 : g(n) ≤ c · f (n). Dies heißt g wächst echt langsamer als f . • Ω(f) (lies groß Omega“) ” g ∈ Ω(f ) ⇔ f ∈ O(g) Dies heißt g wächst mindestens so schnell wie f . • ω(f) (lies klein Omega“) ” g ∈ ω(f ) ⇔ f ∈ o(g) Dies heißt g wächst echt schneller als f . • Θ(f) (lies Theta“) ” g ∈ Θ(f ) ⇔ (f ∈ O(g) ∧ g ∈ O(f )) Dies heißt g und f wachsen asymptotisch gleichschnell. 2 Beispiel 1.2.2 Hier und im folgenden sei log(n) = max{1, log2 (n)}. Sei f (n) = 3n + 7, g(n) = n2 , h(n) = 2n . Es gilt (für alle ǫ > 0): f ∈ O(n) f ∈ o(g) g ∈ Ω(f ) f ∈ O(f ) f + g ∈ O(g) g ∗ g ∈ o(h) O(n) ⊆ o(n log(n)) f ∈ Ω(1) g ∈ ω(f ) f ∈ Ω(f ) f 6∈ ω(f ) f ∗ g ∈ ω(g) n ∗ log(n) ∈ o(n1+ǫ ) 3n ∈ ω(2n ) Bemerkung 1.2.3 • O(f ), o(f ), Ω(f ), ω(f ), Θ(f ) bezeichnen Klassen von Funktionen. • Oft sieht man die Schreibweise f = O(g). Dies ist mathematisch unglücklich, denn links steht eine Funktion und rechts eine Menge von Funktionen. Außerdem erinnert =“ an eine Äquivalenzrelation und somit an eine sym” metrische Relation. Aus f ∈ O(g) folgt im allgemeinen aber nicht g ∈ O(f ). Bei der Aufwandsabschätzung geht es um die Berechnung des Zeit- bzw. Platzbedarfs eines Algorithmus A, der zu einer Eingabe x den Wert f (x) ∈ Y berechnet. • Zeitbedarf: tA (x) ist die Zeit, die der Algorithmus A auf einem zu präzisierenden Maschinenmodell braucht, bis er die Eingabe x verarbeitet hat. • Zeitbedarf in Abhängigkeit von der Eingabegröße: Sei |x| = n die Größe der Eingabe (auch Länge genannt), dann bezeichnet tA (n) die maximale Zeit, die der Algorithmus A braucht, um Eingaben der Länge n zu verarbeiten. • Platzbedarf: Analog ist der Platzbedarf des Algorithmus, sA (x) bzw. sA (n), definiert. Bei unseren Betrachtungen werden wir hauptsächlich den Zeitbedarf untersuchen. Ein praxisrelevanter Grund dafür ist die Tatsache, dass Speicherplatz i.a. billig ist. Zudem ist aus theoretischer Sicht jede Abschätzung für den schlechtesten Fall der Zeit zugleich auch eine Abschätzung für den benötigten Platz, denn es gilt stets tA (n) ≥ sA (n). Dabei interessiert nicht der genaue Wert von tA (n), denn dies wäre von der Umgebungsgeschwindigkeit abhängig (z.B. wäre der tatsächliche Aufwand von der 3 Codierungsart der Eingaben abhängig). Wir wollen aber Aussagen über Algorithmen machen, die generell den Verfahrensaufwand (unabhängig von der zugrunde liegenden Maschine) betreffen. An tA (n) interessiert uns deshalb hauptsächlich das Wachstumsverhalten der Funktion: linear, exponentiell, n log n usw.. Wir wollen häufig nur wissen, zu welcher Funktionsklasse tA (n) gehört und verwenden deshalb die O-Notation. Die Bedeutung unserer Abschätzungen, trotz der Abstrahierung von konkreten Maschinen und trotz der groben Einteilung in Funktionenklassen mittels der Landau-Symbole, lässt sich am besten anhand einer Tabelle1 verdeutlichen. Dabei wird angenommen, dass ein linearer Algorithmus eine Eingabe der Länge n in n Mikrosekunden verarbeitet: Algorithmus n n log(n) n2 n3 2n 1 Minute 1 Stunde Rechenzeit Rechenzeit 6, 0 · 107 3, 6 · 109 2, 8 · 106 1, 3 · 108 7.746 60.000 391 1.533 26 32 Wachstum bei 10-facher Beschleunigung ∗10 ∗10 √ (für große Werte) ∗ √10 (ca. 3,16) ∗ 3 10 (ca. 2,15) + log2 (10) (ca. 3,32) Die letzte Spalte gibt an, inwieweit die maximal mögliche Verarbeitungslänge zunehmen wird, wenn man die zugrunde liegende Maschine um das 10-fache beschleunigt. Die Tabelle kann man mit folgenden Worten zusammenfassen: je schneller der Rechner, desto mehr lohnen sich effiziente Algorithmen! 1.2.2 Komplexität Zur Bestimmung der Komplexität eines Problems betrachte folgenden Ansatz: tP (n) = min{tA (n) | A löst P } Bemerkung 1.2.4 • Zunächst interessiert uns nicht eine minimale Zahl tP (n) für jedes n ∈ N, sondern in einem zu präzisierenden Sinne eine minimale Funktion λn.tP (n) = min{λn.tA (n) | A löst P }. 1 leicht abgewandelt aus [2] entnommen 4 • Ohne die Festlegung eines Maschinenmodells kann tA (n) nicht genau“ be” rechnet werden. Was sollte als Komplexität gewählt werden? Eine mittlere, untere oder obere Abschätzung des Aufwands oder gar irgendeine Mittelung der drei Abschätzungen? Es werden meistens zwei Fälle untersucht: • Komplexität im ungünstigsten Fall (worst case). • Komplexität im Mittel (average case). Sei eine Wahrscheinlichkeitsverteilung der Eingabe gegeben, dann wird der mittlere Zeitbedarf durch tA,Mittel (n) = E(tA (x), |x| = n) definiert, wobei E(tA (x), |x| = n) der bedingte Erwartungswert von tA (x) (unter der Bedingung |x| = n) ist. Beispiel 1.2.5 Beim Quicksortalgorithmus ist die Anzahl der Vergleiche im ungünstigsten Fall tQ (n) ∈ Θ(n2 ); in Abschnitt 3.1 werden wir jedoch sehen, dass die Average Case-Analyse tA,Mittel (n) = 1.38n log n ergibt. Die Durchschnittskomplexitätsanalyse ist oft extrem schwierig. Meistens nimmt man eine Gleichverteilung der Eingaben gleicher Länge an, d.h. tA,Mittel (n) = 1 X tA (x) |Xn | x∈X n mit Xn := {x ∈ X | |x| = n} Dies ist aber oft nicht sinnvoll, wie man im folgenden Beispiel sehen kann. 2n Beispiel 1.2.6 Gleichverteilung bei binären Suchbäumen: Es gibt n (= (n+1)-te Catalansche Zahl) verschiedene Binärbäume mit n Knoten (bzw. mit n inneren Knoten, wobei jeder innere Knoten genau zwei Nachfolger hat). Setzt man hier die obengenannte Gleichverteilung an, so konnte gezeigt werden, dass √ die mittlere Baumhöhe ungefähr n ist (vgl. [5]). Betrachtet man hingegen eine Verteilung auf der Menge der binären Suchbäume, die sich durch eine Gleichverteilung von Permutationen ergibt, so erhalten wir eine mittlere Baumhöhe von 2n 1 Θ(log(n)). (Beachte, dass n! wesentlich schneller als n+1 wächst.) n 1 n+1 5 Gehen wir zurück zur Problemkomplexität, so ist ein Problem P ⊆ X × Y und ein realistisches Maschinenmodell gegeben, und gesucht ist eine Funktion tP so, dass : • tA ∈ Θ(tP ) für einen Algorithmus A, der P löst. • tA ∈ Ω(tP ) für alle Algorithmen A, die P lösen. In Worten heißt es, dass die Funktion tP eine größte untere Schranke von tA für alle Algorithmen A ist, die P lösen. Natürlich sucht man auch einen Algorithmus A mit tA ∈ Θ(tP ). Nur für wenige (allerdings einige wichtige!) Probleme ist tP bekannt. Für Sortieren gilt beispielsweise tsort ∈ Θ(n log n). 1.3 Maschinenmodelle Die Turingmaschine (TM) ist ein mathematisch leicht exakt beschreibbares Berechnungsmodell. Allerdings ist sie nicht für alle Anwendungen realistisch, wie etwa zur Aufwandsbeschreibung von Sortier- bzw. Suchalgorithmen etc., denn der zeitraubende Speicherzugriff (Bandzugriff) ist in der Realität nicht gegeben. Hier verwenden wir bevorzugt Maschinenmodelle, die einen beliebigen Speicherzugriff in konstanter Zeit erlauben (obwohl dies in der Realität tatsächlich nur für einen gewissen Anwendungsbereich gültig ist). RAM (Random Access Maschine): die Maschine mit beliebigen Speicherzugriff“ ” hat folgende Gestalt: x1 x2 x3 x4 x5 x6 x7 x8 x9 . . . Eingabe READ ONLY Speicher 0 = Akku 1 = 1.Reg 2 = 2.Reg 3 = 3.Reg 4 = 4.Reg . IC - Programm . . RAM y1 y2 y3 y4 y5 y6 y7 y8 y9 . . . Ausgabe WRITE ONLY 6 • Die RAM hat Register, die durchnumeriert sind und auf die in konstanter Zeit zugegriffen werden kann. Dabei ist das Register mit der Nummer 0 als Akkumulator ausgezeichnet. • Jedes Register kann eine beliebige ganze Zahl speichern. • Das Programm ist in eine Folge von Befehlen eingeteilt, wobei die Befehle durchnumeriert sind. • Es gibt eine externe Eingabesequenz, die sequentiell eingelesen werden kann. • Es gibt ein externes Ausgabeband, auf das sequentiell ausgegeben werden kann. • Die RAM besitzt folgenden Befehlssatz: load: store: add: sub: mult: div: read: write: Laden des Akkumulators. Speichern des Akkumulators in ein Register. Zum Akkumulator dazuaddieren. Vom Akkumulator abziehen. Akkumulator multiplizieren. Akkumulator dividieren. Liest nächstes Eingabezeichen in ein Register. Schreibt den Wert eines Registers in die Ausgabesequenz. goto: Sprung zu einem Befehl. HALT: Haltebefehl. if vgl. then goto: Bedingter Sprung; verglichen wird der Akkumulator mit Konstanten. Dabei gelten folgende Adressierungsarten, wobei a ∈ N: – = a“ entspricht dem direkten Wert a, ” – a“ entspricht dem Inhalt des Registers mit der Nummer a (symbolisch ” c(a)), – ∗a“ entspricht der indirekten Adressierung durch a, d.h. der Wert ” des Registers, dessen Nummer im Register mit der Nummer a steht (symbolisch c(c(a))). 7 Beispiel 1.3.1 Befehl load 3 load =3 load *3 add 3 div =3 read *4 store 3 store =3 goto 12 goto *12 if Vergleich then goto 1 Bedeutung lädt in den Akkumulator den Wert des Registers 3 lädt in den Akkumulator den Wert 3 lädt in den Akkumulator den Wert des Registers, dessen Nummer in Register 3 steht addiert zum Akkumulator den Inhalt des Registers 3 hinzu (Ergebnis im Akkumulator) dividiert den Akkumulator durch den Wert 3 lädt in das Register dessen Nummer in Register 4 steht, das nächste Eingabesymbol speichert den Inhalt des Akkumulators im Register mit Nummer 3 ab nicht erlaubt hier ist die einzige Ausnahme bei der Adressierung, hier wird direkt mit dem Befehl 12 des Programms weitergemacht nicht erlaubt. ist der Vergleich TRUE dann springe zum Befehl 1 Bemerkung 1.3.2 Wir gehen hier von konstanten Kosten für jeden Befehl aus. Denkbar (und für große Zahlen realistischer) wäre eine logarithmische Kostenfunktion für die arithmetischen Funktionen bzw. für deren Adressierung. Weiterhin gehen wir davon aus, dass die Programmlänge nicht die Programmkosten beeinflusst. Bei der bedingten Verzweigung if Vergleich then goto“ lassen wir Vergleiche ” zwischen dem Akkumulator und festen Zahlen zu, da diese die Übersichtlichkeit der Programme verbessern, und da jede andere Bedingung auf einen Vergleich mit Null zurückgeführt werden kann. Das Programm kann während der Ausführung nicht verändert werden. These 1.3.3 (von Church) Jedes (intuitiv) berechenbare Problem lässt sich durch eine Turingmaschine (und somit auch durch eine RAM) berechnen. These 1.3.4 (erweiterte These von Church) Jedes (intuitiv) effizient berechenbare Problem lässt sich durch eine Turingmaschine (und somit auch durch eine RAM) effizient berechnen. 8 1.4 Untere Schranken und Reduktionen Einer der schwierigsten Aspekte der Algorithmentheorie ist die Suche nach unteren Schranken. Wir wollen an dieser Stelle lediglich einige einfache Ideen vorstellen. Satz 1.4.1 (Sortieralgorithmen) Jeder Sortieralgorithmus, der ausschließlich auf Schlüsselvergleichen basiert, benötigt Ω(n log n) Vergleiche. Beweis: Wir fassen Sortieralgorithmen als Entscheidungsbäume auf, d.h. als Binärbäume, deren inneren Knoten mit Vergleichen der Art gilt x[i] < x[j]?“ ” beschriftet sind. Es gibt n! Permutationen von n Elementen. Fasst man jede Permutation als einen Pfad durch einen Entscheidungsbaum auf, so muss dieser mindestens n! Blätter haben. Jeder binäre Baum mit n! Blättern hat eine Mindesthöhe von log(n!) ∈ Ω(n log(n)). Die Höhe des Entscheidungsbaums entspricht aber dem Zeitaufwand des Algorithmus. 2 Ein weiteres Problem ist: Sei ein Feld a[1...n] gegeben. Frage: existiert i 6= j mit a[i] = a[j]? Satz 1.4.2 (Zwei gleiche Zahlen in einem Feld) Jeder vergleichsbasierte Algorithmus, der die Existenz von zwei gleichen Zahlen in einem Feld überprüft, benötigt Ω(n log(n)) Vergleiche. Der Beweis benutzt Methoden der reell-algebraischen Geometrie (siehe [11]). Das Problem, zwei gleiche Zahlen in einem Feld zu finden, lässt sich in linearer Zeit auf das Sortieren des Feldes zurückführen. Ist tsort (n) der Aufwand für das Sortieren und t2Z (n) der Aufwand für die Suche nach zwei gleichen Zahlen so gilt: t2Z (n) ∈ O(tsort (n) + n) = O(tsort (n)) 1.5 Rekursionsgleichungen Beispiel 1.5.1 Mergesort ist ein Sortierverfahren, das vornehmlich auf externe Speichermedien angewendet wird, um große Datenmengen zu sortieren. Die Prozedur mische(l, m, r) im Algorithmus 1.5.1 wird nicht weiter ausgeführt. Der Aufwand von mische(l, m, r) beträgt: tmerge (l, m, r) ∈ O(r − l). Damit ergibt sich für die Laufzeit von Mergesort folgende Rekursionsgleichung (wobei n = r − l): tmergesort (n) =: t(n) = 2 · t(n/2) + Θ(n) = Θ(n log(n)) 9 Algorithmus 1.5.1 Mergesort procedure mergesort(l, r) var m : integer; begin if (l < r) then m := (r + l) div 2; mergesort(l, m); mergesort(m + 1, r); mische(l, m, r); endif endprocedure Der folgende Satz stellt Lösungen für Rekursionsgleichungen dieser (einfachen) Art bereit. Satz 1.5.2 Seien a, b ∈ N und b > 1, g : N −→ N und es gelte die Rekursionsgleichung: t(1) = g(1) t(n) = a · t(n/b) + g(n) (1.1) (1.2) Dann gilt für n = bk (d.h. für k = logb (n)): t(n) = k X i=0 i a ·g n bi . (1.3) Beweis: 1. k = 0: es gilt t(1) = g(1). 2. k > 0: Es gelte die Gleichung (1.3) für n = bk−1 . Damit erhalten wir für n = bk : t n b = k−1 X i=0 ai · g 10 n . bi+1 (1.4) Setzt man dies in (1.2) ein, so ergibt sich n + g(n) t(n) = a · t b ! k−1 n X i = a a · g i+1 + g(n) b i=0 k X = i=1 k X = i=0 ai · g n ai · g n bi bi + a0 g n b0 . 2 Korollar 1.5.3 (Mastertheorem I) Im Fall g(n) = Θ(nc ) erhalten wir: c ; falls a < bc Θ(n ) Θ(nc log n) ; falls a = bc t(n) ∈ log a Θ(n log b ) ; falls a > bc Beweis: Wir behandeln nur den Fall g(n) = nc . Damit ist t(n) = nc · Wir unterscheiden drei Fälle: 1. a < bc : c t(n) ≤ n · ∞ X a i i=0 c bc = nc · 1 1− a bc Pk a i i=0 ( bc ) . ∈ O(nc ). Außerdem gilt t(n) ∈ Ω(n ). Hieraus folgt t(n) ∈ Θ(nc ). 2. a = bc : t(n) = (k + 1) · nc ∈ Θ(nc log n). 3. a > bc : c t(n) = n · ∈ = = = k X a i bc i=0 = nc · ( bac )k+1 − 1 a −1 bc a logb (n) c Θ n · c b c log (n) n ·a b Θ bc logb (n) Θ alogb (n) = Θ blogb (a)·logb (n) log a logb (a) Θ n = Θ n log b 11 2 P Satz 1.5.4 (Mastertheorem II) Sei r > 0, ri=0 αi < 1 und ! r X t(n) ≤ t(⌈αi n⌉) + O(n). i=0 Dann gilt t(n) ∈ O(n). Beweis: Wähle zunächst ein γ0 > 0 so, dass t(n) ≤ r X t(⌈αi n⌉) i=0 ! + γ0 n. Wähle ε und ein n0 > 0 so, dass αi n0 ≤ n0 − 1 für alle i, 1 ≤ i ≤ r und Pein r dass i=0 ⌈αi n⌉ ≤ (1 − ε)n für alle n ≥ n0 . Wähle schließlich ein γ so groß, dass γ0 < γε und t(n) < γn für alle n < n0 . Wir zeigen jetzt per Induktion t(n) < γn für alle n ∈ N. Der Induktionsanfang (n < n0 ) ist nach Wahl von γ korrekt. Für den Induktionsschritt (n ≥ n0 ) gilt: ! r X t(n) ≤ t(⌈αi n⌉) + γ0 n ≤ i=0 r X i=0 ! γ⌈αi n⌉ + γ0 n (mit Induktion) ≤ (γ(1 − ε) + γ0 )n ≤ γn 2 12 Kapitel 2 Entwurfstrategien 2.1 Divide and Conquer Die erste Entwurfsstrategie, die wir betrachten, ist die Divide and ConquerMethode. Dieses Verfahren gliedert sich in drei Teile auf: 1. Zerlege das Problem in zwei möglichst gleichgroße Teilprobleme. 2. Löse die Teilprobleme einzeln. 3. Erzeuge aus den Teillösungen die Gesamtlösung. Bemerkung 2.1.1 • Theoretisch reicht bei der Zerlegung des Problems oft ein Größenverhältnis p zu (1 − p), mit festem 0 < p < 1. • Beim Lösen der Teilprobleme ist der Ansatz einer Rekursion häufig auch der Ansatz zur Parallelverarbeitung. Beispiel 2.1.2 Ein typisches Beispiel für das Divide and Conquer–Verfahren ist Mergesort. Dabei wird die Eingabesequenz in zwei Teilsequenzen aufgeteilt, die getrennt sortiert und dann wieder zu einer Sequenz gemischt werden. Ein vergleichbarer Ansatz wird bei Quicksort angewendet: ein Feld wird durch ein Pivotelement in zwei Teilfelder aufgeteilt, die dann getrennt sortiert werden. Das Problem bei Quicksort ist die Wahl des Pivotelements, um möglichst gleichgroße Teilfelder zu erhalten. 13 2.1.1 Multiplikation ganzer Zahlen Hat man zwei Binärzahlen der Länge n, so braucht man bei der grundschulmäßigen Multiplikation der beiden Zahlen i.a. O(n2 ) Operationen. Seien nun die beiden Zahlen r, s wie folgt zusammengesetzt: r= A B s= C D Dabei sind A die ersten k Bits von r und B die letzten k Bits von r. Analoges gilt für C, D und s. Wir können also r und s wie folgt schreiben : r = A 2k + B; s = C 2k + D Daraus folgt: r s = A C 22k + (A D + B C) 2k + B D Statt diesen Ansatz zu verfolgen, berechnen wir rekursiv die drei Zahlen AC, (A − B)(D − C) und BD. Damit können wir rs nur mit drei Multiplikationen von Zahlen mit höchstens k Bits berechnen: rs = A C 22k + (A − B) (D − C) 2k + (B D + A C) 2k + B D Setzt man unser Ergebnis in die Rekursionsgleichnung (1.2) ein und beachtet, dass sich in linearer Zeit addieren lässt, so erhält man als Aufwand: log 3 tmult (n) = 3 · tmult (n/2) + O(n) = O(n log 2 ) = O(n1.58496... ). In dieser Formel steht n für die Bitlänge der Zahlen und der Ausdruck O(n) für den Aufwand der Addition. Wir haben also durch den Teile-und-Beherrsche Ansatz den Exponenten des naiven Ansatzes von 2 auf 1.58496... heruntergesetzt. Ein weiteres Beispiel für den Divide-and-Conquer Ansatz ist das Verfahren von Strassen (1968) für schnelle Matrixmultiplikation. 2.1.2 Matrixmultiplikation nach Strassen P Die übliche Multiplikation zweier n × n Matrizen (ai,j ) (bi,j ) = ( nk=1 ai,k bk,j ) erfordert O(n3 ) skalare Multiplikationen. Wir versuchen, die Anzahl dieser Multiplikationen mit einem Divide-and-Conquer Ansatz zu reduzieren. Dabei werden 14 die zwei Matrizen A, B jeweils in 4 etwa gleichgroßen Untermatrizen unterteilt, wobei sich das Produkt AB = C wie folgt darstellen lässt: C11 C12 B12 B11 A11 A12 = C21 C22 B21 B22 A21 A22 Dabei ergeben sich folgende Beziehungen: C11 C12 C21 C22 = = = = A11 B11 + A12 B21 A11 B12 + A12 B22 A21 B11 + A22 B21 A21 B12 + A22 B22 Aus der Rekursionsgleichung der Laufzeit dieses Ansatzes t(n) = 8 · t(n/2) + Θ(n2 ) ∈ Θ(n3 ) sieht man, dass wir damit keine Verbesserung erreicht haben. Das Verfahren von Strassen (1968) verwendet jedoch die Tatsache, dass man das Produkt zweier 2 × 2 Matrizen geschickter mit nur 7 Multiplikationen berechnen kann: M1 M2 M3 M4 M5 M6 M7 := := := := := := := (A12 − A22 )(B21 + B22 ) (A11 + A22 )(B11 + B22 ) (A11 − A21 )(B11 + B12 ) (A11 + A12 )B22 A11 (B12 − B22 ) A22 (B21 − B11 ) (A21 + A22 )B11 C11 C12 C21 C22 := := := := M1 + M2 − M4 + M6 M4 + M5 M6 + M7 M2 − M3 + M5 − M7 Wir erhalten mit der Methode von Strassen folgende Rekursionsgleichung für die Laufzeit: t(n) = 7t(n/2) + Θ(n2 ) Mit a = 7, b = 2 und g(n) = n2 (c = 2) erhalten wir aus dem ersten Mastertheorem (Korollar 1.5.3) t(n) = Θ(nlog2 7 ) = Θ(n2,81... ) Damit ergibt sich ein Gewinn im Vergleich zum direkten Θ(n3 )-Ansatz. (Coppersmith und Winograd konnten die obere Schranke 1987 auf O(n2,376 ) verbessern.) 15 2.1.3 Transitive Hülle und Matrixmultiplikation Sei A = (aij ) die Adjazenzmatrix eines gerichteten Graphen mit n Knoten. Der Warshall-Algorithmus berechnet den reflexiven transitiven Abschluss A∗ in O(n3 ) Schritten (siehe Abschnitt 2.3.1). Hierbei ist X Ak mit A0 = In und ∨ als Addition boolescher Matrizen A∗ = k≥0 Mit Induktion ergibt sich leicht, dass Ak (i, j) = 1 genau dann wenn es von Pn−1 gilt, Ak . i nach j einen Weg der Länge k gibt. Klar ist auch A∗ = k=0 Setze B = In + A. Dann gilt A∗ = B m für alle m ≥ n − 1. Also reicht es, eine Matrix ⌈log2 (n − 1)⌉-mal zu quadrieren, um A∗ zu berechnen. Sei M(n) der Aufwand, zwei boolesche n × n-Matrizen zu multiplizieren, und sei T (n) der Aufwand, die reflexive transitive Hülle zu berechnen. Dann gilt also: T (n) ∈ O(M(n) · log n). Hieraus folgt für alle ε > 0 nach Strassen T (n) ∈ O(nlog2 (7)+ε ). Die Beziehung M(n) ∈ O(T (n)) ist offensichtlich (unter der plausiblen Annahme M(3n) ∈ O(M(n))). Denn seien A und B beliebige Matrizen, dann gilt: ∗ 0 A 0 In A AB 0 0 B = 0 In B . 0 0 0 0 0 In Unter den (ebenfalls plausiblen) Annahmen M(n) ∈ Ω(n2 ) und M(2n) ≥ (2 + ε) M(n) zeigen wir T (n) ∈ O(M(n)). Dies bedeutet: die Berechnung der transitiven Hülle ist bis auf konstante Faktoren genauso aufwendig wie die Matrixmultiplikation. Berechnung der transitiven Hülle: Eingabe: E ∈ Bool(n × n) 1. Teile E in vier Teilmatrizen A, B, C, D so, dass A und D quadratisch sind und jede Matrix ungefähr die Größe n/2 × n/2 hat: A B . E= C D 16 2. Berechne rekursiv D ∗ : Aufwand T (n/2). 3. Berechne F = A + BD ∗ C: Aufwand O(M(n/2)), da M(n) ∈ Ω(n2 ). 4. Berechne rekursiv F ∗ : Aufwand T (n/2). 5. Setze ∗ E = F∗ F ∗ BD ∗ ∗ ∗ ∗ D CF D + D ∗ CF ∗ BD ∗ . Damit erhalten wir die Rekursionsgleichung T (n) ≤ 2T (n/2) + c · M(n) für ein c > 0. Dies ergibt i i 2 · M(n/2 ) i≥0 P 2 i ≤ c · i≥0 2+ε · M(n) ∈ O(M(n)). T (n) ≤ c · 2.2 P (nach Satz 1.5.2) (da M(n/2i ) ≤ 1 i 2+ε M(n)) Greedy-Algorithmen Greedy ( gierig“) bezeichnet Lösungsstrategien, die auf der schrittweisen Berech” nung von Teillösungen (lokalen Optima) basieren. Anders ausgedrückt nähert man sich dem Ziel dadurch, dass bei jedem Schritt mit Hilfe eines Optimum– Kriteriums der nächste Schritt berechnet wird. Dieses Verfahren eignet sich für Probleme, bei denen jede Teilfolge einer optimalen Folge auch optimal ist (Optimalitätsprinzip). Beispiel 2.2.1 Ist in einem Graphen u = u0, u1 , u2 , . . . , un−1, un = v ein kürzester Weg von Knoten u nach Knoten v, so ist ui , ui+1, ui+2 , . . . , uj mit 0 ≤ i < j ≤ n ein kürzester Weg von ui nach uj . Als klassisches Beispiel für die Greedy-Strategie betrachten wir im folgenden das Problem der Bestimmung kürzester Wege in einem kantengewichteten Graphen (von einer Quelle aus). 17 2.2.1 Kürzeste Wege in Graphen (Dijkstra-Algorithmus) Sei ein Graph G = (V, E, γ) gegeben, wobei V die Knotenmenge und E ⊆ V × V die gewichteten Kanten sind. Dabei ist γ : E → N0 die Gewichtsfunktion der Kanten. Das Gewicht eines Pfades ist gleich der Summe der Kantengewichte. Sei nun d(u, v) für u, v ∈ V das Minimum der Gewichte aller Pfade von u nach v (mit der üblichen Konvention, dass d(u, v) = ∞ gilt, falls kein Pfad von u nach v existiert). Die Aufgabe ist nun, bei gegebenen Graphen G und Knoten u ∈ V für jedes vP ∈ V einen Pfad u = u0 , u1 , u2 , . . . , un−1, un = v mit minimalem Gewicht n i=1 γ(ui−1 , ui ) = d(u, v) zu bestimmen. Der Algorithmus von Dijkstra berechnet hierfür schrittweise Knotenmengen Bi ⊆ V mit folgenden Eigenschaften: 1. B0 = {u}. 2. Nach Schritt i ist Bi berechnet so, dass für jedes w ∈ Bi , d(u, w) und ein zugehöriger kürzester Pfad von u nach w bekannt sind. Weiterhin gilt für alle w ′ ∈ Bi und alle z ∈ V \ Bi : d(u, w ′) ≤ d(u, z). In Schritt i sucht man eine Kante (x, y) ∈ E mit den Eigenschaften • (x ∈ Bi−1 , y 6∈ Bi−1 ), und • ∀(x′ , y ′) ∈ E, x′ ∈ Bi−1 ∧ y ′ 6∈ Bi−1 : d(u, x) + γ(x, y) ≤ d(u, x′) + γ(x′ , y ′). Anschließend wird Bi := Bi−1 ∪ {y} und d(u, y) = d(u, x) + γ(x, y) gesetzt. Wir wollen den Dijkstra-Algorithmus konkret formulieren, wobei wir die genaue Datenstruktur offen lassen (Algorithmus 2.2.1). Die Idee des Algorithmus besteht darin, die Knotenmenge des Graphen in drei disjunkten Mengen zu verwalten: die Baummenge“ B bezeichnet diejenigen Knoten, für die ein kürzester Pfad (von u ” aus) bekannt ist; der Rand“ R enthält die unmittelbaren Nachbarn der Knoten ” aus B, die selbst nicht zu B gehören. Für die Randknoten ist eine Abschätzung des Abstandes zu u berechnet. Schließlich ist U die Menge der unbekannten“ ” Knoten. Wir gehen davon aus, dass eine Datenstruktur v(y) definiert ist, die für jeden Knoten y ∈ (B ∪ R) den zugehörigen Vorgänger enthält. Weiterhin sei eine Datenstruktur D(y) vorausgesetzt so, dass für jeden Knoten y ∈ B, D(y) = d(u, y) und für jeden Randknoten x ∈ R, D(x) = min{D(z) + γ(z, x) | z ∈ B} gilt. 18 Algorithmus 2.2.1 Dijkstra-Algorithmus (∗ ger., kantengew. Graph G ∗) (∗ u = Startknoten ∗) Eingabe : (G = (V, E, γ), u) var x, y : Knoten; α : integer; D : array[1 . . . |V |] of integer; v : array[1 . . . |V |] of Knoten; B : Knotenmenge; (∗ Baumknoten ∗) R : Knotenmenge; (∗ Randknoten ∗) U : Knotenmenge; (∗ unbekannte Knoten ∗) (∗ Für v ∈ B ist d(u, v) bekannt und es gilt: D(v) = d(u, v). Die Knoten in R sind von ∗) (∗ B aus direkt erreichbar, während U = V \(B ∪ R) die unbekannten Knoten umfasst. ∗) begin B := {u}; R := ∅; v(u) := nil; D(u) := 0; (∗ Initialisierung von B, R, U ∗) forall y ∈ V \{u} : (u, y) ∈ E do D(y) := γ(u, y); v(y) := u; R := R ∪ {y}; endfor U := V \(R ∪ {u}); while R 6= ∅ do x := nil; α := ∞; (∗ suche x ∈ R mit min. Abstand ∗) forall y ∈ R do if D(y) < α then x := y; α := D(y) endif endfor B := B ∪ {x}; (∗ verschiebe x von R nach B ∗) R := R\{x} forall (x, y) ∈ E do if y ∈ U then (∗ Rand aktualisieren ∗) D(y) := D(x) + γ(x, y); v(y) := x; U := U\{y}; R := R ∪ {y} elsif y ∈ R and D(x) + γ(x, y) < D(y) then D(y) := D(x) + γ(x, y); (∗ kürzerer Weg über x ∗) v(y) := x endif endfor endwhile end Terminierung Nach dem i-ten Durchlauf ist B = Bi . Die Termination ist trivialerweise gewährleistet, denn jeder Schritt vergrößert Bi und enthält nur die von u aus erreichbaren Knoten. 19 Korrektheit Wir werden die folgende Invariante beweisen: Für jedes w ∈ Bi sind d(u, w) und ein zugehöriger kürzester Pfad von u nach w bekannt. Weiterhin gilt für alle w ∈ Bi und alle z ∈ / Bi : d(u, w) ≤ d(u, z). Für i = 0 und B0 = {u} ist die Invariante erfüllt. Sei i > 0. Wir nehmen an, dass Bi−1 die Invariante erfüllt. Weiterhin sei y ∈ R der im i-ten Durchlauf ausgewählte Randknoten und x ∈ Bi−1 sein Vorgänger im Baum, d.h. es gilt x ∈ Bi−1 , y 6∈ Bi−1 und nach dem Algorithmus ∀ (x′ , y ′) ∈ E, x′ ∈ Bi−1 , y ′ 6∈ Bi−1 : d(u, x) + γ(x, y) ≤ d(u, x′ ) + γ(x′ , y ′). Es reicht zu zeigen, dass 1. der Pfad von u nach x und dann direkt nach y ein kürzester Pfad von u nach y ist und 2. für alle y ′ ∈ V \ Bi die Ungleichung d(u, y) ≤ d(u, y ′) erfüllt ist. Sei y ′ ∈ V \Bi−1 ein Knoten mit minimalem d(u, y ′) und P ′ ein kürzester Pfad von u nach y ′, der unter allen kürzesten Pfaden von u nach y ′ die minimale Anzahl Zwischenknoten besitzt. Beachte, dass y ′ = y sein könnte. Sei x′ der Vorgänger von y ′ auf P ′ . Dann ist x′ ∈ Bi−1 (sonst wäre der Pfad von u nach x′ entweder ein kürzerer Pfad oder ein Pfad mit weniger Zwischenknoten). Damit gilt nach Wahl von y: d(u, y) ≤ d(u, x) + γ(x, y) ≤ d(u, x′ ) + γ(x′ , y ′ ) = d(u, y ′). Nach Wahl von y ′ folgt daraus d(u, y) = d(u, y ′) = d(u, x) + γ(x, y) und damit der erste Teil der Invariante. Außerdem haben wir den zweiten Teil der Invariante gezeigt: d(u, y) ≤ d(u, y ′) für alle y ′ ∈ V \ Bi . Beispiel 2.2.2 Wir wollen uns anhand eines Beispiel-Graphen anschauen, wie der Dijkstra-Algorithmus die kürzesten Pfade bestimmt. Die Graphen zeigen, welche Pfade nacheinander markiert“ werden. In jeder Sequenz ist Bi durch ” schwarze, Ri durch graue und Ui durch weiße Kreise symbolisiert, die besuchten Kanten sind fett, und die kleinen Zahlen neben den Knoten sind die D-Werte. 20 Startknoten Startknoten 0 1 1 3 8 2 13 1 3 13 13 7 8 6 Startknoten 0 7 13 7 3 8 7 3 12 1 6 3 7 3 8 2 4 9 6 13 1 3 2 4 9 3 1 6 13 1 3 4 7 3 Startknoten 3 8 11 0 1 2 3 6 14 0 13 7 6 2 7 3 Startknoten 6 1 3 4 11 6 14 1 8 2 ∞ 3 7 6 1 3 4 ∞ 0 1 3 7 6 Startknoten 0 13 4 9 7 3 6 6 12 Satz 2.2.3 Der Dijkstra–Algorithmus berechnet alle kürzesten Pfade von einer Quelle aus im schlechtesten Fall in der Zeit O(n2 ). Beweis: In der äußeren while–Schleife wird jeder Knoten x ∈ V höchstens einmal betrachtet. Wir erhalten damit die folgende Formel für den Zeitaufwand, wobei e = |E| und n = |V |: X X |R| + O(1) ⊆ O(n|R| + e) ⊆ O(n2 + e) = O(n2 ). tDijkstra(n) ∈ O x∈V (x,y)∈E 2 Die bisher durchgeführte Beschreibung und Analyse des Dijkstra–Algorithmus verwaltet den Rand R als Feld. Dies ist für sehr dichte Graphen optimal; in der Praxis sind jedoch viele Graphen dünn, z.B. ist die Zahl der Kanten e in planaren Graphen linear in der Knotenzahl n (genauer gilt die Eulerformel: e ≤ 3n − 6 für n ≥ 3). Betrachtet man den Rand R als abstrakten Datentyp, so werden die folgenden Operationen benötigt: 21 insert decrease-key delete-min Füge ein neues Element in R ein. Verringere den Schlüsselwert eines Elements von R (und erhalte die Eigenschaften des Datentyps R). Suche ein Element mit minimalem Schlüsselwert und entferne dieses aus R (und erhalte die Eigenschaften des Datentyps R). In einer leicht abgewandelten Form lässt sich der Dijkstra–Algorithmus dann im Kern wie folgt beschreiben: Algorithmus 2.2.2 Dijkstra–Algorithmus mit abstraktem Datentyp procedure dijkstra begin B := {u}; R := ∅; v(u) := nil; D(u) := 0; forall y ∈ V \{u} : (u, y) ∈ E do D(y) := γ(u, y); v(y) := u; insert(R, y, D(y)); (∗ Nachbar y von u zum Rand hinzufügen ∗) endfor U := V \(R ∪ {u}); while (R 6= ∅) do x := delete-min(R); B := B ∪ {x}; forall (x, y) ∈ E do if y ∈ U then U := U \ {y}; v(y) := x; D(y) = D(x) + γ(x, y); insert(R, y, D(y)); (∗ y zum Rand hinzufügen ∗) elsif D(x) + γ(x, y) < D(y) then (∗ die Bedingung impliziert y ∈ R ∗) v(y) := x; D(y) := D(x) + γ(x, y); decrease-key(R, y, D(y)); (∗ Abstand von y verringern ∗) endif endfor endwhile endprocedure Eine einfache Analyse zeigt, dass die Operationen insert und delete-min höchstens n mal durchgeführt werden und decrease-key höchstens e mal aufgerufen wird. Bemerkung 2.2.4 1. Der Dijkstra-Algorithmus liefert nicht notwendigerweise ein korrektes Ergebnis, falls für die Kantengewichte auch negative Zahlen erlaubt sind. 22 2. Für dünne Graphen (O(e) ⊆ o(n2 / log n)) ist es günstiger, den Rand R in einer Prioritätswarteschlange (Heap) zu verwalten. Der Aufwand des Algorithmus wird damit nach der obigen Herleitung O(e log n + n log n) ⊆ O(e log n). 3. Bei Verwendung der Datenstruktur der Fibonacci-Heaps ergibt sich ein Aufwand von O(e + n log n). Hierzu wird auf das Kapitel 5.1 verwiesen. Im zweiten Teil dieses Abschnittes wollen wir ein weiteres Graphenproblem vorstellen, nämlich die Bestimmung von minimal aufspannenden Bäumen. 2.2.2 Minimale aufspannende Bäume (Prim-Algorithmus) Definition 2.2.5 Ein Graph G = (V, E) heißt zusammenhängend, wenn je zwei Knoten durch einen Pfad verbunden sind. Definition 2.2.6 Ein Baum ist ein zusammenhängender, kreisfreier, ungerichteter Graph. Bemerkung 2.2.7 Jeder Baum mit n Knoten besitzt genau n − 1 Kanten. Definition 2.2.8 Ein minimaler aufspannender Baum (minimal spanning tree, MSB) zu einem gewichteten Graphen G = (V,P E, γ) ist ein Baum B = (V, F, γ|F ) mit F ⊆ E mit minimalem Gewicht γ(B) := e∈F γ(e). Sei nun ein zusammenhängender gewichteter Graph G = (V, E, γ) gegeben, wobei γ : E → N die Kantengewichtsfunktion ist. Die Lösungsidee zur Bestimmung des MSB besteht darin, dass ein aufspannender Teilgraph G′ = (V, E ′ , γ|E ′ ) mit minimalem Gewicht γ(G′ ) ein gesuchter MSB ist, d.h. wir müssen G′ nicht auf die Eigenschaft Baum“ testen. ” Es gibt im wesentlichen zwei Algorithmen für die Bestimmung des MSB. Der Kruskal-Algorithmus wird später im Zusammenhang mit Union-Find“ behan” delt (Abschnitt 4.2). Hier werden wir als Beispiel den Prim-Algorithmus (2.2.3 bzw. 2.2.4) besprechen. Algorithmus 2.2.3 Naiver Prim-Algorithmus für MSB’s. 23 function Prim ( G = (V, E, γ) || G zusammenhängend, |V | ≥ 1): MSB; (∗ G ist ein ungerichteter, kantengewichteter Graph. ∗) var B : Knotenmenge; (∗ bekannte Knoten ∗) T : Kantenmenge; (∗ Kantenmenge bezüglich B ∗) x, u, v : V ; begin wähle x0 ∈ V beliebig; B := {x0 }; T := ∅; (∗ Initialisierung ∗) while B 6= V do wähle u, v mit uv ∈ E, u ∈ B und v 6∈ B so, dass γ(uv) minimal ist; B := B ∪ {v}; T := T ∪ {uv}; endwhile return T ; endfunction Der Zeitbedarf ist bei einer naiven Implementation O(|E| · |V |) = O(n3 ) bei n Knoten, denn wir müssen für jeden Schleifendurchlauf die gesamte Kantenmenge E durchgehen. Unter Beibehaltung der Grundidee können wir aber mit einem Dijkstra-ähnlichen Ansatz einen O(n2 ) Algorithmus angeben. Algorithmus 2.2.4 Prim-Algorithmus für minimal aufspannende Bäume function Prim( G = (V, E, γ) || G zusammenhängend, |V | ≥ 1): Kantenmenge; var B, R, U : Knotenmenge; (∗ bekannte/Rand-/unbek. Knoten ∗) T : Kantenmenge; (∗ Kantenmenge bezüglich B ∗) x, y : V ; γ : integer; v : array [1..n] of V ; (∗ v(x) liefert Vorgänger von x ∗) begin wähle x0 ∈ V beliebig; B := {x0 }; R := ∅, U := V \ {x0 }; T := ∅; forall (x0 , y) ∈ E do U := U \ {y}; v(y) := x0 ; D(y) := γ(x, y); insert(R, y, D(y)) endfor while R 6= ∅ do x := delete-min(R); B := B ∪ {x}; T := T ∪ {v(x)x}; forall (x, y) ∈ E, y 6∈ B do if y ∈ U then U := U \ {y}; v(y) := x; D(y) := γ(x, y); insert(R, y, D(y)); 24 elsif γ(x, y) < D(y) then v(y) := x; D(y) := γ(x, y); decrease-key(R, y, D(y)); endif endfor endwhile return T ; endfunction Terminierung Die Termination ist gesichert, denn in jedem Durchlauf wird B vergrößert. Nach Beendigung der Schleife gilt B = V . Korrektheit Seien Bi , Ti , Ri die Mengen B, T, R nach dem i-ten Schleifendurchlauf. Dann lassen sich folgende Invarianten formulieren: 1. Ti lässt sich zu einem MSB von G ergänzen. 2. Ri enthält alle Knoten, die nicht zu Bi gehören, aber eine direkte Verbindung nach Bi haben. 3. Für alle y ∈ Ri gilt: γ(v(y)y) ≤ γ(y ′y), für alle y ′ ∈ Bi mit y ′ y ∈ E. Nach Voraussetzung ist G = (V, E) zusammenhängend, daher genügt es, die Invarianten zu beweisen (denn aus (1) folgt, dass Tn ein MSB von G ist). Wir zeigen (1), (2), (3) mit Induktion. Für i = 0 gilt B0 = {x0 }, T0 = ∅, R0 = {y ∈ V | x0 y ∈ E} und die Invarianten sind trivialerweise erfüllt. Sei i > 0 und seien die Invarianten für i − 1 erfüllt. Sei weiterhin Bi = Bi−1 ∪ {x} und Ti = Ti−1 ∪{v(x)x}. Sei T = (V, F ) ein MSB, der Ti−1 ergänzt und die Kante v(x)x nicht enthält (ansonsten ist (1) bereits erfüllt). Der Graph T ′ = (V, F ∪ {v(x)x}) enthält nun einen Kreis und es existieren Knoten y, y ′ mit y ∈ Ri−1 , y ′ ∈ Bi−1 so, dass die Kante yy ′ ∈ F zu diesem Kreis gehört: 25 Bi−1 y′ Ri−1 ∈T y v(x) 6∈ T x Weiterhin gilt nach Wahl von x bzw. wegen der letzten Bedingung der Invariante für i − 1: γ(v(x)x) ≤ γ(v(y)y) ≤ γ(y ′y). Tausche nun die Kanten y ′y und v(x)x in T aus. Da dies nicht das Gesamtgewicht von T erhöht, erhalten wir erneut einen MSB für G und damit ist Bedingung (1) für i erfüllt. Die Bedingungen (2) und (3) ergeben sich direkt aus der Konstruktion. Zur Zeitanalyse des Algorithmus 2.2.4 betrachten wir zunächst die erste forallSchleife innerhalb der while-Schleife. Während des ganzen Algorithmus kann hier jede Kante xy maximal zweimal betrachtet werden, nämlich jeweils als ausgehende Kante ihrer Ecken. Dies ergibt einen Gesamtaufwand von O(e) ⊆ O(n2 ), wobei e = |E| und n = |V |. Der Aufwand der zweiten forall-Schleife ist bei jedem Durchlauf durch O(n) begrenzt. Es gibt n Durchläufe durch die while-Schleife und somit erhalten wir einen Zeitbedarf von O(e + n2 ) ⊆ O(n2 ). Beispiel 2.2.9 Zur Veranschaulichung des Prim-Algorithmus verwenden wir erneut den Graph aus Beispiel 2.2.2. Die zu Ti gehörenden Kanten sind fett, die an Ti angrenzenden Kanten (zwischen Bi und Ri ) gestrichelt dargestellt. Wie im Beispiel zum Dijkstra-Algorithmus sind die Knoten aus Bi schwarz, die aus Ri grau und die aus Ui weiß gefärbt. 26 Startknoten 1 Startknoten 1 3 6 Startknoten 1 3 6 8 2 6 8 2 4 13 1 3 3 6 Startknoten 1 3 6 1 3 13 8 2 4 13 3 3 6 8 2 4 6 Startknoten 6 8 2 4 13 6 Startknoten 8 2 4 13 3 3 4 13 3 6 6 3 6 Bemerkung 2.2.10 1. Der Prim Algorithmus läuft auch mit negativen Gewichten korrekt. Dies folgt aus der Tatsache, dass Gewichte um eine additive Konstante verschoben werden können, ohne dabei den MSB zu verändern: Sei G = (V, E, γ) mit γ : E → R ein gewichteter Graph und c ∈ R eine Konstante. Betrachte die neue Gewichtsfunktion γ̃ : E → R, γ̃(e) := γ(e) + c. Nun gilt: Ist T ein MSB für G, so auch T für G̃ = (V, E, γ̃). Diese Behauptung folgt leicht mit der Eigenschaft, dass jeder MSB für einen Graphen mit n Knoten n − 1 Kanten besitzt. 2. Für dünne Graphen (O(e) ⊆ o(n2 / log n)) ist es günstiger, die Menge {v(y)y) | y ∈ R} in einer Prioritätswarteschlange (Heap) zu verwalten. Dies führt auf einen O(e log n) Algorithmus. 3. Bei Verwendung der Datenstruktur der Fibonacci-Heaps ergibt sich ein Aufwand von O(e + n log n). Hierzu wird auf das Kapitel 5.1 verwiesen. 27 2.3 Dynamische Programmierung Beim Verfahren der dynamischen Programmierung werden tabellarisch alle Teillösungen eines Problems bestimmt, bis schließlich die Gesamtlösung erreicht ist. Die Teillösungen werden dabei mit Hilfe der bereits existierenden Einträge berechnet. 2.3.1 Transitive Hülle und kürzeste Wege in Graphen Als Beispiel betrachten wir den Warshall-Algorithmus zur Bestimmung der transitiven Hülle und den Floyd-Algorithmus zur Bestimmung der kürzesten Wege. Algorithmus 2.3.1 Hülle Warshall-Algorithmus zur Berechnung der transitiven Eingabe : Graph als Adjazenzmatrix (A[i, j]) ∈ Booln×n procedure Warshall (var A : Adjazenzmatrix) begin for k := 1 to n do for i := 1 to n do for j := 1 to n do if (A[i, k] = 1) and (A[k, j] = 1) then A[i, j] := 1 endif endfor endfor endfor end Die Korrektheit des Warshall-Algorithmus folgt aus der Invariante: 1. Nach dem k-ten Durchlauf der ersten for-Schleife gilt A[i, j] = 1, falls ein Pfad von i nach j über Knoten mit Nummer ≤ k existiert (vgl. mit der Bestimmung rationaler Ausdrücke aus einem endlichen Automaten). 2. Gilt A[i, j] = 1, so existiert ein Pfad von i nach j. Trägt man in die Adjazenz-Matrix Gewichte statt Boolesche Werte ein, so entsteht der Floyd-Algorithmus zur Berechnung kürzester Wege: 28 Algorithmus 2.3.2 Floyd-Algorithmus zur Bestimmung aller kürzesten Wege eines Graphen Eingabe : Gewichteter Graph als Adjazenzmatrix A[i, j] ∈ (N ∪ ∞)n×n , wobei A[i, j] = ∞ bedeutet, dass es keine Kante von i nach j gibt. procedure Floyd (var A : Adjazenzmatrix) begin for k := 1 to n do for i := 1 to n do for j := 1 to n do A[i, j] := min{A[i, j], A[i, k] + A[k, j]}; endfor endfor endfor endprocedure Bemerkung 2.3.1 Der Floyd-Algorithmus liefert ein korrektes Ergebnis auch wenn die Gewichte negativ sind, unter der Bedingung, dass keine negative Schleifen vorhanden sind. Beide Algorithmen 2.3.1, 2.3.2 haben einen Zeitaufwand von Θ(n3 ). Eine Ver” besserung“ kann dadurch erzielt werden, dass vor der j-Schleife zuerst getestet wird, ob A[i, k] = 1 (bzw. ob A[i, k] < ∞) gilt. Damit erreicht man den Aufwand O(n3 ). 2.3.2 Multiplikation einer Matrizenfolge Ein weiteres Beispiel für dynamische Programmierung ist die optimale Klammerung bei der Multiplikation einer Matrizenfolge (mit verschiedenen Dimensionen). Beispiel 2.3.2 Sei A·B·C zu berechnen, wobei A eine 4×100, B eine 100×8 und C eine 8×2 Matrix ist. In welcher Reihenfolge multipliziert man diese Matrizen so, dass man möglichst wenige skalare Multiplikationen hat? Berechnet man zuerst X := A·B und dann Y := X ·C so braucht man 4·100·8 = 3200 Multiplikationen um X zu berechnen und, um Y zu berechnen, 4 · 8 · 2 = 64 Multiplikationen. Berechnet man aber zuerst X ′ := B · C und dann Y ′ := A · X ′ , dann braucht man insgesamt nur 100 · 8 · 2 + 4 · 100 · 2 = 2400 Multiplikationen. Bemerkung 2.3.3 A(n,m) ist die Bezeichnung für eine Matrix A mit n Zeilen und m Spalten. Für A(n,m) := B(n,q) · C(q,m) benötigt man n · q · m skalare Multiplikationen. 29 1 2 3 N Sei die Matrizenfolge M(n , M(n , M(n , . . . , M(n gegeben. Weiter0 ,n1 ) 1 ,n2 ) 2 ,n3 ) N−1 ,nN ) 1 N hin definieren wir die Funktion cost(M , . . Q . , M ) als minimale Anzahl der skalai ren Multiplikationen, die für das Produkt N i=1 M benötigt werden. Der Ansatz der dynamischen Programmierung ergibt sich nun mit der folgenden Beziehung: cost(M i , . . . , M j ) = mink {cost(M i , . . . , M k ) + cost(M k+1 , . . . , M j ) + ni−1 · nk · nj } Für den Algorithmus 2.3.3 wird in einer Tabelle cost[i, j] berechnet, wobei j − i die Werte 2, 3, 4, . . . , n − 1 annimmt. Die Hilfstabelle best[i, j] wird jeweils einen Index i ≤ k < j beinhalten, der eine bzgl. der Anzahl der Multiplikationen optimale Aufteilung des Produktes M i · . . . · M j ergibt. Mit dem Algorithmus 2.3.3 haben wir einen Aufwand für den Platz von Θ(N 2 ) und für die Zeit von Θ(N 3 ). Algorithmus 2.3.3 Effiziente Multiplikation einer Matrizenfolge procedure mult-matrizenfolge() begin for i := 1 to N do (∗ Initialisierung der Kostentabelle ∗) cost[i, i] := 0; for j := i + 1 to N do cost[i, j] := ∞; endfor endfor (∗ Kostentabelle und best-Tabelle erzeugen ∗) for d := 1 to N − 1 do (∗ d ist die Differenz j − i ∗) for i := 1 to N − d do j := i + d; for k := i to j − 1 do t := cost[i, k]+ cost[k + 1, j] + n[i − 1] · n[k] · n[j]; if t < cost[i, j] then cost[i, j] := t; best[i, j] := k; endif endfor endfor endfor return best; endprocedure 30 Eine wichtige Anwendung dieses Schemas findet man ebenfalls bei dem Algorithmus von Cocke-Younger-Kasami (CYK-Algorithmus) zur Erkennung kontextfreier Sprachen. 2.3.3 Optimale Suchbäume Als letztes Beispiel für dynamische Programmierung betrachten wir einen Algorithmus zur Erzeugung von optimalen Suchbäumen. Die direkte Methode zur Erzeugung eines optimalen Suchbaums hat einen Aufwand von O(n3 ). Der verbesserte Algorithmus von Knuth [7] hat einen Aufwand von O(n2 ). Interessant ist hier, wie man durch eine genaue Analyse des Problems den kubischen Algorithmus in einen quadratischen verwandeln kann. Sei ein linear geordnetes Feld gegeben mit v1 < v2 < · · · < vn . Dabei seien die Zugriffshäufigkeiten auf jeden Knoten v durch γ(v) gegeben. γ(v) kann sowohl die relativen als auch die absoluten Häufigkeiten bezeichnen. Zur Erinnerung: ein binärer Suchbaum ist ein knotenbeschrifteter Baum so, dass für jeden Knoten v mit linkem (bzw. rechtem) Unterbaum L (bzw. R) gilt: u < v für alle u ∈ L (bzw. v < w, für alle w ∈ R). Jedem Knoten v sei nun ein Level l(v) zugeordnet: l(v) := Abstand des Knotens v zur Wurzel +1. Das Auffinden eines Knotens auf Level l erfordert nämlich l Vergleiche. Das Problem besteht nun darin, einen optimalen binären Suchbaum zu bestimmen, der die gewichtete innere Pfadlänge X P (B) := l(v) · γ(v) v∈V minimiert. Die innere Pfadlänge kann dabei als durchschnittliche Kosten einer Sequenz von Find-Operationen auf die dargestellte Menge angesehen werden. Der Ansatz zur dynamischen Programmierung besteht nun darin, dass die Unterbäume eines optimalen Baums auch optimal sein müssen (für die dargestellten Mengenbereiche). Bezeichnungen: • Um unnötige Doppelindizierungen zu vermeiden, schreiben wir für die Knoten {v1 , . . . , vn } nur noch den jeweiligen Index, d.h. die Zahl i entspricht dem Knoten vi . Weiterhin wird li (bzw. γi) eine andere Schreibweise für l(i) (bzw. γ(i)) sein. 31 • Pi,j ist die gewichtete innere Pfadlänge eines optimalen Suchbaumes der Knoten {i, . . . , j}. • Ri,j ist die Wurzel eines optimalen Suchbaumes für die Knoten {i, . . . , j}. P • Γi,j := jk=i γ(k) ist das Gewicht der Knotenmenge {i, . . . , j}. Im dynamischen Ansatz sind nun Werte Ri,j gesucht, die einen optimalen Suchbaum B mit Kosten P1,n realisieren. Man geht nun direkt vor und berechnet P (B) rekursiv (dabei bezeichnet BL bzw. BR den linken, bzw. rechten Unterbaum der Wurzel von B): P (B) := P (BL ) + P (BR ) + Γ(B) Wir realisieren diesen Ansatz in einem kubischen Algorithmus. Dabei geben wir nur die Grundidee an, da wir später einen quadratischen Algorithmus im Detail vorstellen. Algorithmus 2.3.4 Berechnung eines optimalen Suchbaums Pi,j = Γi,j + mink∈i...j {Pi,k−1 + Pk+1,j } Ri,j = k, für das Pi,k−1 + Pk+1,j das Minimum annimmt. Für die entscheidende Verbesserung leiten wir zunächst das folgende Lemma her: Lemma 2.3.4 (Monotonie der Funktion P (B)) Sei B ′ ein optimaler Suchbaum für {1, . . . , n} und v ∈ {1, . . . , n}. Sei B ein optimaler Suchbaum für {1, . . . , n} \ {v}. Dann gilt P (B) ≤ P (B ′ ). Beweis: Sei L′ bzw. R′ der linke bzw. rechte Unterbaum von v. Ist R′ = ∅, so betrachten wir nun den Baum B̂ := B ′ \{v} (d.h., L′ rutscht ein Level höher). B̂ ist wieder ein Suchbaum und es gilt offensichtlich P (B̂) ≤ P (B). Ist R′ 6= ∅, so sei v ′ der kleinste Knoten in R′ . Sei R ein optimaler Baum der Knoten aus R′ \{v ′ }. Wir definieren einen Baum B̂ durch Ersetzen von v durch v ′ in B ′ und von R′ durch R. Dabei ist B̂ wieder ein Suchbaum und es gilt: P (B ′) − P (B̂) ≥ γ(v) · l′ (v) − γ(v ′ ) · l′ (v) + γ(v ′ ) · l′ (v ′ ), wobei der letzte Summand eine untere Schranke ist für die Differenz P (R′)−P (R) (bezogen auf den gesamten Baum B ′ bzw. B̂). Daraus ergibt sich P (B̂) ≤ P (B ′ ) und damit P (B) ≤ P (B ′ ) für einen optimalen Baum, der die Knotenmenge ohne v realisiert. 2 32 Mit diesem Lemma lässt sich folgender Satz von Knuth beweisen: Satz 2.3.5 (Monotonie der Wurzel) Sei r[i, j] (bzw. R[i, j]) die kleinste (bzw. größte) Wurzel eines optimalen Suchbaumes für die Knoten {i, . . . , j}. Dann gilt für n ≥ 2: r[1, n − 1] ≤ r[1, n], R[1, n − 1] ≤ R[1, n]. Beweis: Wir beweisen den Satz durch Induktion über die Zahl der Knoten, d. h. wir können ihn für kleineres n bereits als bewiesen annehmen. Dann gilt folgendes Lemma: Lemma 2.3.6 Sei Bj ein optimaler Suchbaum für {1, . . . , n} mit minimalem Level j von Knoten n. Sei j1 die Wurzel von Bj . Sei Bi ein optimaler Suchbaum für {1, . . . , n} mit Wurzel i1 ≥ j1 . Dann existiert ein optimaler Suchbaum B ′ für {1, . . . , n} mit Wurzel i1 und Knoten n auf Level j. Wir werden sehen, wie diese Verbindung der Eigenschaften minimales Level für n und große Wurzel für den Beweis von Satz 2.3.5 von Nutzen ist. Beweis des Lemmas: Wir betrachten die rechten Äste der Bäume Bi und Bj , wobei die Knoten von Bi mit ik und die Knoten von Bj mit jk bezeichnet sind (siehe folgende Abbildung). 1 Bj : j1 Bi : i1 j2 i2 .. 2 .. . ij . n n j i Bei festem i1 maximieren wir i2 , dann maximieren wir i3 usw. Der neue Baum wird weiterhin mit Bi bezeichnet. Angenommen, in Bi und Bj liegt der Knoten 33 n auf gleichem Level j. Dann sind wir fertig. Andernfalls können wir (zur Vereinheitlichung der Bezeichnung) annehmen, dass der Knoten n in Bi auf Level i liegt und i > j gilt, weil j minimal gewählt wurde. Sei k maximal mit ik > jk . Dann gilt 1 ≤ k < j. Setze m = k + 1. Es gilt {ik + 1, . . . , n} ( {jk + 1, . . . , n}, und mit Induktion und Links-Rechts-Symmetrie des Satzes 2.3.5 gilt jm ≤ R[jk + 1, n] ≤ R[ik + 1, n] = im . Also folgt jm = im , da k maximal gewählt wurde. Sei nun Ri der rechte Unterbaum in Bi , der im als Wurzel hat, und Rj sei der rechte Unterbaum in Bj , der jm als Wurzel hat. Wegen im = jm haben Ri und Rj dieselbe Knotenmenge und sind optimale Suchbäume. Wir bilden einen Baum B ′ durch Ersetzen von Ri in Bi durch Rj . Da P (Ri ) = P (Rj ) gilt, ergibt sich auch P (B ′ ) = P (Bi) = P (Bj ), d. h. B ′ ist optimal für {1, . . . , n}, hat i1 als Wurzel und den Knoten n auf Level j. 2 Symmetrisch zu Lemma 2.3.6 lässt sich folgendes Lemma beweisen: Lemma 2.3.7 Sei Bi ein optimaler Suchbaum für {1, . . . , n} mit maximalem Level i von Knoten n. Sei i1 die Wurzel von Bi . Sei Bj ein optimaler Suchbaum für {1, . . . , n} mit Wurzel j1 ≤ i1 . Dann existiert ein optimaler Suchbaum B ′ für {1, . . . , n} mit Wurzel j1 und Knoten n auf Level i. Forsetzung des Beweises zu Satz 2.3.5: Im folgenden bezeichnen wir mit α das Gewicht des größten Knotens n, d.h. α := γn . Der Wert α variiert zwischen 0 und ∞. Sei also zunächst α = 0 und seien B ′ bzw. B ′′ optimale Suchbäume für die Knoten {1, . . . n − 1} mit Wurzel r[1, n − 1] bzw. R[1, n − 1]. Nimmt man jeweils den Knoten n als rechtesten Knoten hinzu, so erhält man Suchbäume für {1, . . . n}, ohne die gewichtete innere Pfadlänge zu erhöhen. Nach Lemma 2.3.4 sind diese Suchbäume optimal. Es folgt r[1, n − 1] ≤ r[1, n] bzw. R[1, n − 1] ≤ R[1, n]. Mit rα (bzw. Rα ) bezeichnen wir im folgenden die kleinste (bzw. größte) Wurzel eines optimalen Suchbaums für die Knoten {1, . . . , n} unter der Bedingung α = γn . Wegen r[1, n − 1] ≤ r0 und R[1, n − 1] ≤ R0 reicht es zu zeigen, dass rα und Rα monoton steigen. Für i ∈ {1, . . . , n} sei Bi ein optimaler Suchbaum unter der Nebenbedingung, dass der Knoten n auf dem Level i liegt. Dann gilt für die gewichtete innere Pfadlänge die Beziehung Pα (Bi ) = α · i + c(i) für eine gewisse Konstante c(i), d.h. der Graph Pα (Bi ) ist eine Gerade mit Steigung i. Aufgrund der Linearität erhalten wir das folgende (vertikal gestauchte) Bild, bei der jede Steigung i = 1, . . . , n genau einmal vorkommt. 34 P 6α Pα (Bn ) Pα (Bi ) Pα (Bℓ ) Pα (Bk ) Pα (Bj ) - α0 α Wenn wir α vergrößern, sind wir am Punkt α0 gezwungen, das Level des Knotens n auf einen kleineren Wert zu ändern. Wähle bei α0 einen optimalen Suchbaum Bi mit Rα0 als Wurzel und einen ebenfalls bei α0 optimalen Suchbaum Bj mit minimalem Level j. Wegen Lemma 2.3.6 gibt es einen optimalen Suchbaum bei α0 mit Rα0 als Wurzel und Knoten n auf Level j. Dieser Suchbaum ist optimal bis einschließlich zum nächsten Knick“. ” Erst dort könnte Rα echt größer werden. Folglich steigt Rα monoton. Umgekehrt gilt: Wenn wir α verkleinern, sind wir am Punkt α0 gezwungen, das Level des Knotens n auf einen größeren Wert zu ändern. Wähle bei α0 einen optimalen Suchbaum Bj mit rα0 als Wurzel und einen ebenfalls bei α0 optimalen Suchbaum Bi mit maximalem Level i. Wegen Lemma 2.3.7 gibt es einen optimalen Suchbaum bei α0 mit rα0 als Wurzel und Knoten n auf Level i. Dieser Suchbaum ist optimal bis einschließlich zum nächsten Knick“. Erst dort ” könnte rα echt kleiner werden. Folglich steigt auch rα monoton. 2 Korollar 2.3.8 Es gilt: r[i, j − 1] ≤ r[i, j] ≤ r[i + 1, j]. Der folgende Algorithmus berechnet für alle Paare i ≤ j die jeweils kleinste Wurzel eines optimalen Suchbaums für die Knoten {i, . . . , j}. Algorithmus 2.3.5 Berechnung eines optimalen Suchbaums (verbessert nach Knuth) begin cost[n, n + 1] := 0; for i := 1 to n do cost[i, i − 1] := 0; 35 cost[i, i] := γ(i); Γ[i, i] := γ(i); r[i, i] := i; endfor for d := 1 to n − 1 do for i := 1 to n − d do j := i + d; lef t := r[i, j − 1]; right := r[i + 1, j]; root := lef t; t := cost[i, lef t − 1] + cost[lef t + 1, j]; for k := lef t + 1 to right do if cost[i, k − 1] + cost[k + 1, j] < t then t := cost[i, k − 1] + cost[k + 1, j]; root := k; endif endfor Γ[i, j] := Γ[i, j − 1] + γ(j); cost[i, j] := t + Γ[i, j]; r[i, j] := root; endfor endfor end Für die Laufzeit gilt: n X n−d X d=1 i=1 (1 + r[i + 1, i + d] − r[i, i + d − 1]) = n X d=1 (n − d + r[n − d + 1, n] − r[1, d]) ∈ Θ(n2 ). Bemerkung 2.3.9 Es wurde ein linear geordnetes Feld v1 , . . . , vn von Knoten vorausgesetzt. Ein solches Feld erhält man aus einem ungeordneten Feld in O(n log n) Schritten, dies fällt aber bei O(n2 ) nicht ins Gewicht. Damit gilt: aus einem beliebigen Feld mit n Elementen kann ein optimaler Suchbaum in O(n2 ) Schritten erzeugt werden. Bemerkung 2.3.10 Der Algorithmus von Knuth wird in vielen Lehrbüchern nur ohne Beweis erwähnt. (In [8] ergibt sich der Beweis z. B. erst nach der Lösung verschiedener Übungsaufgaben. Die Beweise in [9, 10] benutzen eine andere Methode. 36 2.4 Backtracking Sucht man in einem Baum, so ist zum Beispiel Tiefensuche ein effizientes Verfahren, wenn sich frühzeitig entscheidet, ob in einem Teilbaum eine Lösung zu finden ist oder nicht. Backtracking (dt. Zurückgehen“) beschreibt ein solches Verfah” ren, bei dem man im Berechnungsgraphen eines Problems solange vorwärts geht, bis ein Knoten erreicht wird, der eine Lösung darstellt oder bei dem sicher davon ausgegangen werden kann, dass von diesem Knoten aus keine Lösung mehr zu finden ist. Ist dies der Fall, dann geht man den Pfad einen Schritt zurück und entscheidet sich für einen anderen Folgepfad u.s.w.. Backtracking findet oft Anwendung bei Problemen, für die nur exponentielle Algorithmen bekannt sind. Wir betrachten hier das sogenannte Mautstraßenproblem (engl. Turnpike-Problem). 2.4.1 Das Mautproblem Beispiel 2.4.1 Fährt man in Frankreich über die Autobahn nach Paris, so muss Maut gezahlt werden. Man bekommt hierzu eine Karte, die eine Tabelle mit den Entfernungen und Gebühren zwischen den Ein- und Ausfahrten enthält. Die Frage ist nun: lässt sich die Lage der Ausfahrten aus den Entfernungen rekonstruieren, wenn nur eine geordnete Liste der Entfernungen mit ihren Vielfachheiten vorliegt? Wir suchen also eine Lösung für folgendes Problem: Es seien 0 = x0 < x1 < · · · < xn positive Zahlenwerte und A eine n × n-Matrix mit Ai,j = |xi − xj |. Weiterhin sei D ein Feld, das die Werte Ai,j , 1 ≤ i < j ≤ n, in sortierter Reihenfolge enthält. Gesucht sind die Werte xk für 1 ≤ k ≤ n, wenn D gegeben ist. Bemerkung 2.4.2 1. Sind die Werte xk bekannt, dann kann A (bzw. D) in O(n2 ) (bzw. in O(n2 log n)) Schritten berechnet werden. 2. Sei D bekannt. Ist die Folge x0 , . . . , xn eindeutig bestimmt, falls sie existiert? Wie komplex ist dann die Rekonstruktion? Die erste Frage kann mit Nein beantwortet werden. Selbst dann existiert keine eindeutige Lösung, wenn in D alle Einträge verschieden sind (bis jetzt wurde aber kein Beispiel für n > 6 gefunden). 37 Algorithmus 2.4.1 Mautproblem Die Operationen member, delete und insert werden auf der Multimenge D ausgeführt. Zur Verdeutlichung verwenden wir die Zeichen + für insert, − für delete und ∈ bzw. ⊆ für member. type XFeld = array [0..n] of real; DFeld = geordnete Liste [0..n(n + 1) div 2] of real; procedure maut(var X: XFeld; var D: DFeld; n: integer; var found : boolean); (∗ Ist eine Plazierung möglich, so steht das Ergebnis in X und found ist true. ∗) begin (∗ Initialisierung, linker und rechter Rand wird festgelegt ∗) (∗ max(D) sei vordefiniert als Maximumsfunktion ∗) found := false; X0 := 0; Xn := max(D); D := D − max(D); (∗ D aktualisieren ∗) (∗ X1 kann wegen Symmetrie so gewählt werden ∗) X1 := Xn − max(D); if (X1 − X0 ) ∈ D then D := D − {|X1 − X0 |, |Xn − X1 |}; place(X, D, n, 1, n, found) endif endprocedure procedure place(var X: XFeld; var D: DFeld; n, l, r: integer; var found : boolean); (∗ X0 , . . . , Xl und Xr , . . . , Xn sind beim Aufruf von Place schon versuchsweise festgelegt ∗) var d: real; D ′ : DFeld; begin if D = ∅ then found := true else d := max(D); (∗ probiere Xl+1 := Xn − d ∗) D ′ := {|Xj − (Xn − d)| | j ∈ {0, . . . , l} ∪ {r, . . . , n}}; if D ′ ⊆ D then Xl+1 := Xn − d; D := D − D ′ ; (∗ D aktualisieren ∗) place(X, D, n, l + 1, r, found ); (∗ rekursiv Lösung suchen ∗) 38 (∗ Falls Misserfolg: Backtracking ∗) (∗ D wiederherstellen ∗) if found = false then D := D + D ′ endif endif (∗ probiere Xr−1 := d ∗) D ′ := {|Xj − d| | j ∈ {0, . . . , l} ∪ {r, . . . , n}}; if (found = false) and D ′ ⊆ D then Xr−1 := d; D := D − D ′ ; place(X, D, n, l, r − 1, found ); if found = false then D := D + D ′ endif endif endif endprocedure (∗ (∗ (∗ (∗ D aktualisieren ∗) rekursiv Lösung suchen ∗) Falls Misserfolg: Backtracking D wiederherstellen ∗) Zeitanalyse zum maut-Algorithmus: Die Datenstruktur für D sollte die Operationen max(D), member, delete, insert effizient erlauben d.h. in O(log(n)) (= O(log(n2 ))) Schritten. Dies kann z.B. durch balancierte Suchbäume erreicht werden (AVL-, 2-3-Bäume, etc.). Der worst-case ist exponentiell und tritt dann ein, wenn sehr viel zurückgesetzt werden muss. Beispiele hierfür sind bekannt ([16]), es zeigt sich aber (empirisch), dass dieser Fall selten auftritt. 2.5 Zusammenfassung Als erstes haben wir die Divide and Conquer -Methode betrachtet. Typische Vertreter dieser Algorithmengruppe sind Mergesort und Quicksort. Anschließend haben wir den Dijkstra- und den Prim-Algorithmus als Beispiele für die GreedyStrategie kennengelernt. Die Methode der dynamischen Programmierung wurde anhand des Warshall-Algorithmus, des Algorithmus zur Matrizenmultiplikation und des Algorithmus zur Erzeugung von optimalen Suchbäumen eingeführt. Schließlich haben wir noch Backtracking betrachtet, und als Beispiel das Mautproblem algorithmisch gelöst. Nicht betrachtet haben wir die Gruppe der randomisierten Algorithmen. 39 ∗) Kapitel 3 Sortieren und Medianberechnung Wir befassen uns jetzt mit der Analyse von Sortieralgorithmen. Dabei werden die grundsätzlichen Sortierverfahren als bekannt vorausgesetzt. Wir werden hier nur einige Teilaspekte, wie die Herleitung des Average“-Aufwandes von Quicksort ” und eine neuere Entwicklung, das Bottom-Up-Heapsort, behandeln. 3.1 Quicksort Der Quicksort-Algorithmus wurde 1962 von Hoare vorgestellt. Das grundlegende Prinzip besteht darin, ein Pivotelement zu bestimmen und das Feld in zwei Teilfelder zu zerlegen, wobei das Pivotelement als Trennungsmarke der beiden Felder dient. Die Elemente der Felder werden dann so vertauscht, dass im ersten Feld nur die Elemente enthalten sind, die kleiner (oder gleich) als das Pivotelement sind, während das zweite Feld die übrigen enthält. Schließlich wendet man dann auf beide Teilfelder rekursiv den gleichen Algorithmus an. Für die Aufwandsabschätzung benötigt man zusätzliche Informationen, wobei der wichtigste Aspekt die Wahl des Pivots ist. Die beste Laufzeit ergibt sich natürlich, falls das Pivot gleich dem mittleren Element des Feldes (Median) ist. In der Praxis hat sich eine einfachere Methode bewährt, die Median-aus-Drei -Methode. Diese benutzt den Median des ersten, letzten und mittleren Elementes als Pivot. Diese Version ist im folgenden dargestellt. Zunächst geben wir die Prozedur zum Partitionieren eines Feldes A (im Bereich ℓ bis r) bzgl. eines Pivot-Elements P = A[p] an, wobei ℓ < r und p ∈ {ℓ, . . . , r} gelte. Ergebnis dieser Prozedur ist ein Index m ∈ {ℓ, . . . , r − 1}, der folgende Eigenschaften erfüllt: A[i] ≤ P für alle ℓ ≤ i ≤ m und 40 A[i] ≥ P für alle m + 1 ≤ i ≤ r. Man beachte, dass das rechte Teilfeld nach dem Partitionieren nicht leer sein kann. Algorithmus 3.1.1 Partitionieren bzgl. eines Pivot-Elements function partitioniere(A[ℓ . . . r] : array of integer, p : integer) : integer (∗ Partitioniere A[ℓ . . . r] bzgl. A[p]; Rückgabewert = Index m ∗) begin P := A[p]; (∗ Pivot-Element merken ∗) swap(A[ℓ], A[p]); (∗ Pivot an erste Pos. stellen ∗) x := ℓ − 1; y := r + 1; while x < y do repeat x := x + 1 until A[x] ≥ P ; repeat y := y − 1 until A[y] ≤ P ; if x < y then swap(A[x], A[y]); endif endwhile return y endfunction Man vergewissere sich, dass die geforderten Eigenschaften von dieser Implementierung wirklich erfüllt werden, und dass es außerdem nicht vorkommen kann, dass der linke bzw. rechte Zeiger“ (x bzw. y) über die Feldgrenze ℓ bzw. r hin” auswandert. Die vorgestellte Implementierung (entnommen aus [4]) führt in der Regel n + 2 Vergleiche auf einem Feld mit n Elementen durch. Bei der Durchschnittsanalyse von Quicksort (und von Quickselect in Abschnitt 3.5) werden wir jedoch von n−1 Vergleichen ausgehen — dies lässt sich durch eine trickreichere Implementierung erreichen (jedes Element außer A[p] muss genau einmal mit P verglichen werden). Mit Hilfe der Partitionier-Funktion lässt sich Quicksort leicht aufschreiben: Algorithmus 3.1.2 Quicksort procedure quicksort(A[ℓ . . . r] : array of integer) begin if ℓ < r then p := Index des Median von A[ℓ], A[(ℓ + r) div 2], A[r]; m := partitioniere(A[ℓ . . . r], p); (∗ Feld bzgl. A[p] partitionieren ∗) 41 (∗ linkes Teilfeld sortieren ∗) (∗ rechtes Teilfeld sortieren ∗) quicksort(A[ℓ . . . m]); quicksort(A[m + 1 . . . r]); endif endprocedure Der ungünstigste Fall des Quicksort-Algorithmus ist quadratisch und tritt dann ein, wenn in jedem Schritt eines der beiden Teilfelder genau ein Element enthält. Wir wollen hier den average-case für den Quicksort-Algorithmus untersuchen. Dabei bezieht sich unsere Durchschnittsanalyse auf eine zufällige Auswahl des Pivotelements. Sei Q(n)P die mittlere Anzahl der benötigten Vergleiche bei n Elementen und H(n) := nk=1 k1 die n−te harmonische Zahl. Satz 3.1.1 Die mittlere Anzahl der benötigten Vergleiche von Quicksort ist: Q(n) = 2(n + 1)H(n) − 4n. Beweis: Für n = 1 gilt offensichtlich Q(1) = 0 = 2 · 2 · 1 − 4 · 1. Für n ≥ 2 gilt: n Q(n) = (n − 1) + 1X [Q(i − 1) + Q(n − i)] n i=1 n 2X = (n − 1) + Q(i − 1) n i=1 Dabei ist (n − 1) die Zahl der Vergleiche beim Pivotieren und [Q(i − 1) + Q(n − i)] die mittlere Zahl der Vergleiche für das rekursive Sortieren der beiden Teilhälften; dabei sind alle Positionen für das Pivotelement gleich wahrscheinlich (deswegen der Faktor 1/n). Es gilt: nQ(n) − (n − 1)Q(n − 1) = n(n − 1) + 2 n X i=1 Q(i − 1) −(n − 1)(n − 2) − 2 n−1 X i=1 Q(i − 1) = n(n − 1) − (n − 1)(n − 2) + 2Q(n − 1) = 2(n − 1) + 2Q(n − 1) und damit nQ(n) = 2(n − 1) + 2Q(n − 1) + (n − 1)Q(n − 1) = 2(n − 1) + (n + 1)Q(n − 1) 42 Weiter gilt: Q(n) 2(n − 1) Q(n − 1) 2(n − 1) 2(n − 2) Q(n − 2) = + = + + n+1 n(n + 1) n n(n + 1) (n − 1)n n−1 n X 2(k − 1) = k(k + 1) k=2 n X 2 1 − = 2 k+1 k k=2 1 1 = 2 2 H(n) + − (H(n) − 1) −1− n+1 2 4 = 2H(n) + − 4. n+1 Schließlich erhält man für Q(n): Q(n) = 2(n + 1)H(n) + 4 − 4(n + 1) = 2(n + 1)H(n) − 4n. 2 Für große n ist H(n) − ln n ≈ 0.57 . . .. Damit gilt: Q(n) ≈ 2(n + 1)(0.57 + ln n) − 4n ≈ 2n ln n − 2.8n ≈ 1.38n log n − 2.8n. Man beachte dabei, dass die theoretische Grenze bei log(n!) = n log n−1.44n Vergleichen liegt und somit der Quicksort-Algorithmus im Mittel um 38% schlechter ist. Die Durchschnittsanalyse der Median-aus-Drei Methode ist aufwändig und wird hier nicht durchgeführt (siehe [14]). Der Durchschnittswert liegt dann jedoch bei 1.18n log n − 2.2n. Damit ist dann der Spielraum für Verbesserungen sehr eng geworden. 3.2 Bottom-Up-Heapsort Definition 3.2.1 Ein (Min-)Heap ist ein Feld a[1 . . . n] mit der Eigenschaft a[i] ≤ min{a[2i], a[2i + 1]}. Der übliche Heapsort-Algorithmus besteht aus zwei Teilen: 43 1. Der Heapaufbau, der in O(n) Schritten erfolgen kann. Der Heap wird dabei von rechts nach links aufgebaut, wodurch viele kleine und nur wenige große Heaps betrachtet werden. 2. a[1] ist das kleinste (bzw. größte) Element. Vertausche nun a[1] und a[n]. Die Heapbedingung in a[1 . . . n − 1] ist jetzt in a[1] eventuell verletzt. Lasse das zu schwere Element einsinken: x 2 Vergleiche, um min{x, y, z} zu bestimmen y z Ist y (bzw. z) das Minimum, dann vertausche x und y (bzw. x und z). Ist x das Minimum, dann stoppt der Einsinkprozess. Der Kern des Algorithmus ist folgende Prozedur reheap: Algorithmus 3.2.1 Reheap procedure reheap(i, n: integer) (∗ i ≤ n ist die Wurzel des betrachteten Teilbaums ∗) var m: integer; begin if i ≤ n/2 then m := min{a[i], a[2i], a[2i + 1]}; if (m 6= a[i]) ∧ (m = a[2i]) then swap(i, 2i); reheap(2i, n) elsif (m 6= a[i]) ∧ (m = a[2i + 1]) then swap(i, 2i + 1); reheap(2i + 1, n) endif endif endprocedure (∗ vertausche x, y ∗) (∗ vertausche x, z ∗) Der Heap-Aufbau erfolgt dann mit for i := n 2 downto 1 do reheap(i, n) endfor 44 Die Invariante hierfür ist: a[i . . . n] erfüllt bereits die obige Heap-Bedingung (d.h. vor dem Aufruf reheap(i − 1, n)). Für i = n2 + 1 ist dies richtig. Setze i um 1 herab, dann ist die Heapbedingung in a[i] eventuell verletzt. Einsinken kostet im schlechtesten Fall 2 · (Höhe des Teilbaums unter a[i]) Vergleiche. Wir führen die Analyse für n = 2k − 1 durch, d.h. die maximale Höhe des Heaps ist k − 1. Allgemein gibt es • 20 Bäume der Höhe k − 1, • 2i Bäume der Höhe k − 1 − i, • 2k−1 Bäume der Höhe 0. Daher sind zum Heapaufbau maximal 2· Vergleiche nötig. Sei V (k) = dass V (k) = 2k − k − 1 gilt. k−1 X i=0 Pk−1 i=0 2i (k − 1 − i) 2i (k − 1 − i) = Pk i=1 2i−1 (k − i). Wir zeigen, Für k = 1 ist V (1) = 0 korrekt. Für k > 1 gilt: V (k) = = = induktiv k X 2i−1 (k − i) = i=1 k−1 k−1 X 2i−1 + i=1 k−1 X i=1 2i−1 ((k − 1) − i) 2 − 1 + V (k − 1) k−1 2 − 1 + 2k−1 − (k − 1) − 1 = 2k − k − 1. Dies ergibt den folgenden Satz: Satz 3.2.2 Heapaufbau ist in linearer Zeit möglich. Beweis: Für n = 2k − 1 gilt: 2V (k) = 2(n − log(n + 1)) ∈ O(n). Das eigentliche Sortieren findet dann statt mit: Algorithmus 3.2.2 Heap-Sort procedure heapsort(n: integer) begin 45 2 for i := n downto 2 do swap(1, i); reheap(1, i − 1) endfor endprocedure Satz 3.2.3 Standard Heapsort erfordert höchstens 2n log n + O(n) Vergleiche. Beweis: Der Aufbau des Heaps erfordert O(n) und der Abbau durch Einsinken 2n log n. 2 Bemerkung 3.2.4 Eine genaue Analyse der Durchschnittskomplexität von Heapsort ist unter Verwendung von Methoden aus der sogenannten KolmogorovKomplexität möglich. Es ergibt sich ein mittlerer Aufwand von 2n log n Vergleichen. Damit ist Standard-Heapsort zu Quicksort nicht konkurrenzfähig. Bottom-Up-Heapsort ist eine Variante, mit der die Konstante vor dem n log(n) zu 1 verbessert werden soll. Dabei geht man folgendermaßen vor: nach dem Entfernen der Wurzel wird zuerst der potentielle Einsinkpfad des Elementes bestimmt (siehe Abbildung unten), das die Wurzel ersetzen soll. Dies geschieht, indem man von der Wurzel aus den Weg verfolgt, der immer zum kleineren der beiden Nachfolger führt (Kosten insgesamt: n log n Vergleiche). Da erwartungsgemäß dieses Element tief einsinken wird (es war vorher ein Blatt), bietet sich anschließend eine bottom-up Bestimmung der tatsächlichen Position auf dem Einsinkpfad an (in der Hoffnung, insgesamt mit O(n) Vergleichen auskommen zu können). x0 Es gilt xi ≤ yi für 1 ≤ i ≤ Höhe. - y1 x1 Vergleich Das Einsinken geschieht längs dieses Pfades, der mit log n Vergleichen bestimmt werden kann. y2 - y3 x2 - x4 x3 - y4 46 Ein tiefes Einsinken des Blattes, dessen potentieller Einsinkpfad bestimmt wurde, ist auch aufgrund folgender Eigenschaft des Heaps plausibel: im Heap ist die Hälfte der Knoten Blätter, 34 haben höchstens Tiefe 1, 87 höchstens Tiefe 2, usw.. Die erwartete Höhe eines Knotens in einem Binärbaum mit zufälliger Knotenverteilung ist daher: ∞ 1 1 1X n 1 =1 0 · + 1 · + 2 · + ... = 2 4 8 2 n=0 2n Hierbei entspricht der Höhe 0 die Ebene der Blätter. Im Heap sind die Knoten nicht zufällig verteilt, die mittlere Einsinkhöhe sollte jedoch nicht größer sein, da ja a[n] schwer ist. Wir bestimmen jetzt vom Blatt aus (also bottom-up) die tatsächliche Position auf dem Einsinkpfad. Sei x0 , x1 , . . . , xk der berechnete Pfad. Die Prozedur i := k + 1; repeat i := i − 1 until x0 ≥ xi berechnet die Position xi , auf die x0 einsinkt. Ist der Index i gefunden, so werden die Elemente x0 , . . . , xi zyklisch vertauscht (x0 geht an die Stelle von xi , und x1 , . . . , xi rutschen hoch). Es kann gezeigt werden, dass im schlechtesten Fall höchstens 1.5n log n+o(n log n) Vergleiche benötigt werden (siehe [14]). Wenn man nach Carlsson [3] auf dem Pfad eine binäre Suche anwendet, so kommt man auf einen Aufwand von höchstens n log n + O(n log log n). Eine binäre Suche ist aber in der Praxis zu aufwändig und außerdem steigt man in den Pfad i.a. zu hoch ein. Durch eine leichte Abwandlung der reinen bottom-up Positionsbestimmung können wir die Zahl der wesentlichen Vergleiche auf n log n + o(n log n) bringen. Ebenfalls möglich ist eine Schrittweitenverdoppelung bei der bottom-up Suche: man geht beim Aufsteigen im Pfad solange in Schrittweiten 1, 2, 4, 8, . . . hoch, bis ein Knoten erreicht ist, der zu hoch liegt. Man geht dann zu dem vorher betrachteten Knoten zurück und fängt dann wieder mit Schrittweite 1 zu suchen an. Die Zahl der wesentlichen Vergleiche ist hier n log n + O(n log2 (log n)). 47 Beispiel 3.2.5 In der folgenden Tabelle ist die Schrittanzahl in Abhängigkeit vom Level für einen Heap mit 15 Levels dargestellt (dabei gehören die Blätter dem Level 0 an). Levelnummer Schritte zum Ziel Zeile 2 - Zeile 1 3.3 1 1 0 2 3 1 3 2 -1 4 4 0 5 6 1 6 5 -1 7 3 -4 8 5 -3 9 7 -2 10 6 -4 11 8 -3 12 10 -2 13 9 -4 14 7 -7 15 4 -11 Ultimatives Heapsort (nach Jyrki Katajainen 1996) Sei a[1 . . . n] ein zu sortierendes Feld und (L, S) eine Aufteilung von {1, . . . , n} in sogenannte leichte (oder weiße) und schwere (oder schwarze) Elemente, L ∪ S = {1, . . . , n}, L ∩ S = ∅. Eine Grundidee des ultimativen Heap-Sorts ist es, ein (potentiell schweres) Element von der Wurzel bis zu einem Blatt absinken zu lassen, ohne es danach durch einen Aufstieg an die korrekte Position zu bringen. Wir nehmen also in Kauf, die Heap-Bedingung an einem Blatt zu verletzen. Das Absinken lässt sich am einfachsten durch die folgende rekursive Prozedur beschreiben, die ein Element von der Position i bis maximal zur Position j absinken lässt: Algorithmus 3.3.1 Absinken eines Elements procedure down(i, j): if 2i > j then skip elsif 2i = j then swap(i, j) elsif a[2i] < a[2i + 1] then swap(i, 2i); down(2i, j) else swap(i, 2i + 1); down(2i + 1, j) endif endprocedure Definition 3.3.1 Ein Feld a[1 . . . n] erfüllt die Zweischichten-Heap-Bedingung bzgl. (L, S), falls 48 • ∀i ∈ L ∀j ∈ S : a[i] ≤ a[j] • ∀i ∈ L : a[i] ≤ min{a[2i], a[2i + 1]} (d.h., (a[i] ≤ a[2i] oder 2i > n) und (a[i] ≤ a[2i + 1] oder 2i + 1 > n)) • ∀j ∈ S : 2j und 2j + 1 sind schwarz, (d.h., (2j ∈ S oder 2j > n) und (2j + 1 ∈ S oder 2j + 1 > n)) Wir sagen auch, a[1 . . . n] ist ein Zweischichten-Heap bzgl. (L, S). Bemerkung 3.3.2 i) Jedes Feld erfüllt die obige Bedingung bzgl. (∅, {1, . . . n}). ii) Jeder Heap erfüllt die obige Bedingung bzgl. ({1, . . . n}, ∅). iii) Gilt in einem Zweischichten-Heap L 6= ∅, so ist a[1] = min{a[1], . . . , a[n]} und 1 ∈ L. Beweis: i) und ii) sind trivial. iii) folgt mit Induktion über den Heapaufbau: Wurzel H1 H2 und einer Fallunterscheidung, ob H1 oder H2 weiße Elemente enthält. Lemma 3.3.3 Sei a[1 . . . n] ein Zweischichten-Heap bzgl. (L, S) mit L 6= ∅ und n ∈ S. Ersetzt man die Wurzel durch das schwarze Element a[n] und lässt dieses Element bis zu einem Blatt einsinken, so ist a[1 . . . n − 1] ein Zweischichten-Heap bzgl. (L′ , S ′ ) mit |L′ | ≥ |L| − 1(bzw. |L′ | = |L| − 1, falls alle Schlüsselwerte verschieden sind). Beweis: Betrachte die folgende Situation: 49 x y ≤ z Hierbei sei die Heap-Bedingung bei x gestört, x sei schwarz und es gelte (nach einem Vergleich) y ≤ z. Nach der Regel sinkt x bei y ein, d.h. wir erhalten die folgende Situation: y ≤ ? x z Es folgt eine Fallunterscheidung: 1. Ist y weiß, dann gilt y ≤ x, da x schwarz ist. 2. Ist y schwarz und z schwarz, dann sind die beiden Kinder von y schwarz. 3. Ist y schwarz und z weiß, dann folgt y = z (dies ist bei paarweise verschiedenen Schlüsseln nicht möglich und dieser Fall tritt dann nicht auf). Aufgrund von y = z kann y weiß gefärbt werden und die Heapbedingung y ≤ min{x, z} ist erfüllt, da y = z, z weiß und x schwarz ist. 2 Lemma 3.3.4 Sei a[1 . . . n] ein Zweischichten-Heap bzgl. (L, S) mit |L| ≥ c und {n − c + 1, . . . , n} ⊆ S, 1 ≤ c ≤ n2 . Dann kann der Heap um c Elemente abgebaut werden, ohne die Zweischichten-Heap-Bedingung zu verletzen. Dabei werden nur weiße Elemente in sortierter Reihenfolge entnommen. Beweis: Induktion nach c. Ist c = 1, so folgt dies direkt aus dem Lemma von oben. Sei jetzt c > 1. Entnehme die Wurzel und lasse a[n] als neue Wurzel mittels der Prozedur down(1, n − 1) einsinken. Beachte, dass a[n] schwarz ist. Wird also ein schwarzes Element verdrängt, so ist das neue Element schwarz. Das neue Feld a[1 . . . n − 1]ist ein Zweischichten-Heap bzgl. (L′ , S ′) mit |L′ | ≥ |L| − 1 ≥ c − 1. Ferner gilt {n − c + 1, . . . , n − 1} = {(n − 1) − (c − 1) + 1, . . . , n − 1} ⊆ S ′ . Die weiteren c − 1 Elemente lassen sich nach der Induktionsannahme abbauen, ohne die Zweischichten-Heap-Bedingung zu verletzen. 2 50 Definition 3.3.5 Sei a[1 . . . n] ein Feld. Ein Element a[i] heißt Median, falls lnm lnm |{a[k] | a[k] ≤ a[i]}| ≥ und |{a[k] | a[k] ≥ a[i]}| ≥ . 2 2 Im nächsten Abschnitt zeigen wir, wie man einen Median in linearer Zeit berechnen kann. Es ist auch bekannt, dass sich ein Median intern (also nur mit einem konstanten Mehraufwand im Speicherbedarf) finden lässt. Algorithmus 3.3.2 Ultimatives Heapsort procedure ult-heap(a[1 . . . n]) begin if n = 1 (* bzw. n klein“ *) then ” skip (* bzw. sortiere direkt *) else Finde den Median von a[1 . . . n]; (∗ Θ(n) Vergleiche ∗) Benutze den Median als Pivot-Element und teile das Feld so auf, dass gilt: n • L = 1, . . . , 2 , • max a[1], . . . , a[ n2 ] ≤ Median, • S = n2 + 1, . . . , n , (∗ Θ(n) Vergleiche ∗) • min a[ n2 + 1], . . . , a[n] ≥ Median; n (∗ Stelle Heap-Eigenschaft für a[1 . . . 2 ] her (Θ(n) Vergleiche) ∗) 1 do for i = ⌈ n4 ⌉ downto reheap(i, n2 ]) endfor Baue den Heap um n2 Elemente ab: for j = n downto ⌊ n2 ⌋ + 1 do swap(1, j); down(1, j − 1) endfor (∗ Das Restfeld a[1 . . . ⌊ n2 ⌋] ist unsortiert ∗) ult-heap(a[1 . . . ⌊ n2 ⌋]) (∗ ult-heap rekursiv aufrufen ∗) endif (∗ Das Feld a[1 . . . n] ist in umgekehrter Reihenfolge sortiert ∗) endprocedure Wir zählen die Vergleiche. Sei A(n) die Zahl der Vergleiche, die für die Medianberechnung und den Heapaufbau (reheap) benötigt wird, und E(n) die Zahl der Vergleiche, die sich durch das Einsinken ergibt. Dann gilt für A(n) die Rekursionsgleichung: n A(n) = A + Θ(n). 2 51 Also folgt: A(n) ∈ Θ(n). Die Zahl E(n) kann für n = 2k − 1 gut abgeschätzt werden: E(n) ≤ (k − 1) · 2(k−1) + (k − 2) · 2(k−2) + · · · + 1 · 21 k X ≤ (k − 1) 2k−i i=1 = (k − 1)(2k − 1) = (k − 1)n ≤ n log2 n Damit erreicht ultimatives Heapsort die optimale“ Vergleichszahl ” n · log2 n + Θ(n) Ultimatives Heap-Sort ist ein internes Sortierverfahren, das nur auf Schlüsselvergleichen beruht und bis auf evtl. Verbesserungen im linearen Term optimal ist. Asymptotisch sind keine weiteren Verbesserungen möglich. 3.4 Medianberechnung in linearer Zeit Gegeben sei ein Feld a[1 . . . n] von Zahlen. Gesucht ist für ein 1 ≤ k ≤ n das k-kleinste Element m, d.h. die Zahl m ∈ {a[i] | 1 ≤ i ≤ n} so, dass |{i | a[i] < m}| < k und |{i | a[i] > m}| ≤ n − k Die folgende Prozedur berechnet rekursiv den Wert m in linearer Zeit. Sei n ≥ 54. 1. Bestimme ein Pivotelement als Median der Mediane aus 5: Wir teilen das Feld in Fünferblöcken auf. In jedem Block wird der Median bestimmt (mit 6 Vergleichen möglich). Wir bestimmen rekursiv den Median p dieses Feldes (mit dem gesamten Algorithmus). Der Wert p wird als Pivotelement im folgenden verwendet. Kosten: T ( n5 ). 2. Quicksortschritt: Das gesamte Feld wird nun mit dem Pivot p zerlegt so, dass für gewisse m1 < m2 gilt: für 1 ≤ i ≤ m1 für m1 < i ≤ m2 für m2 < i ≤ n a[i] < p a[i] = p a[i] > p Kosten: maximal n Schritte. 52 3. Fallunterscheidung: (a) k ≤ m1 : Suche das k-te Element rekursiv in a[1], . . . , a[m1 ]. (b) m1 < k ≤ m2 : Das Ergebnis ist p. (c) k > m2 : Suche das (k − m2 )-te Element in a[m2 + 1], . . . , a[n]. Wir werden zeigen, dass die Wahl des Pivots als Median-aus-Fünf folgende Ungleichungen für m1 , m2 ergibt: 3 n ≤ m2 10 m1 ≤ und 7 n 10 ). Damit ergeben sich die Kosten für den Rekursionsschritt als T ( 7n 10 Wir zeigen nur den ersten Teil der obigen Behauptung, d.h. 3 n 10 ≤ m2 : Die Hälfte der Fünferblöcke hat einen Median kleiner oder gleich p. In jedem Block sind 3 Elemente kleiner oder gleich dem Blockmedian. Dies ergibt insgesamt 1 n · · 3 = 3n Elemente, die kleiner oder gleich p sind. Mit dieser Bemerkung sieht 2 5 10 man auch, dass für den 2. Schritt des Algorithmus (Zerlegungsschritt) das Pivot p lediglich mit 2n Elemente noch verglichen werden muss, um m1 , m2 bestimmen 5 zu können. Die Aussage für m1 wird analog bewiesen. Zeitanalyse: Sei T (n) die Gesamtzahl der Vergleiche. Wir erhalten folgende Rekursionsgleichung für T (n): n 7n +T + O(n) T (n) = T 5 10 Aus dem Mastertheorem II folgt damit T (n) ∈ O(n). Eine genauere Analyse ergibt: T (n) = T n 5 +T 7n 10 + 6n 2n + 5 5 wobei 6n der Aufwand für die Bestimmung der Blockmediane und 2n der Aufwand 5 5 für den Zerlegungsschritt ist. Damit kann nun gezeigt werden, dass damit T (n) ≤ 7 9 = 10 erhalten wir T (n) ≤ T ( 9n ) + 8n und damit T (n) ≤ 16n gilt: mit 51 + 10 10 5 8n 10 · 5 = 16n. 53 3.5 Quickselect Die im vorigen Abschnitt beschriebene Medianberechnung in linearer Zeit ist in der Praxis nicht immer sinnvoll, da die Ablaufstruktur in der Rekursion sehr kompliziert und die Konstante 16 groß ist. Der hohe Aufwand zur Bestimmung des Pivot-Elements lässt sich durch eine zufällige Auswahl dieses Elements vermeiden. Wie wir in diesem Abschnitt analysieren werden, erhalten wir damit einen Algorithmus, der Quicksort sehr ähnlich ist und der in erwarteter Linearzeit abläuft. Der folgende Algorithmus verwendet die Funktion random(a, b), die eine (ganzzahlige) Zufallszahl aus dem Intervall [a, . . . , b] liefert. Die Prozedur partitioniere ist die selbe wie für Quicksort (Algorithmus 3.1.1). Algorithmus 3.5.1 Quickselect function quickselect(A[ℓ . . . r] : array of integer, k : integer) : integer (∗ Bestimme das k-kleinste Element in A[ℓ . . . r] ∗) begin if ℓ = r then return A[ℓ] else p := random(ℓ, r); (∗ Index des Pivot-El. zufällig best. ∗) m := partitioniere(A[ℓ . . . r], p); (∗ Feld bzgl. A[p] partitionieren ∗) k ′ := (m − ℓ + 1); (∗ # Elemente in linkem Teilfeld ∗) ′ if k ≤ k then return quickselect(A[ℓ . . . m], k) else return quickselect(A[m + 1 . . . r], k − k ′ ) endif endif endfunction Analyse von Quickselect. Ähnlich wie bei Quicksort können wir folgende Rekursionsgleichung aufstellen: n 1X max{Q(i − 1), Q(n − i)} Q(n) ≤ (n − 1) + n i=1 Hierbei ist (n − 1) wiederum die Anzahl der Vergleiche für das Pivotieren und max{Q(i − 1), Q(n − i)} mindestens so gro wie die mittlere Anzahl der Vergleiche für den rekursiven Aufruf auf einem der beiden Teilfelder. 54 Wir machen die Annahme, dass wir stets im größeren Teilfeld suchen. Es gilt: n Q(n) ≤ 1X (n − 1) + max{Q(i − 1), Q(n − i)} n i=1 n ≤ Fallunterscheidung ≤ 1X Q(max{i − 1, n − i}) (n − 1) + n i=1 n−1 2 X (n − 1) + Q(i) n n i=⌊ 2 ⌋ Behauptung: Q(n) ≤ 4 · n: Beweis: Die Behauptung gilt für n = 0, 1. Mit Induktion sei Q(i) ≤ 4 · i für alle i < n. n−1 2 X Q(i) Q(n) ≤ (n − 1) + n n i=⌊ 2 ⌋ ≤ (n − 1) + n−1 8 X i n i=⌊ n 2⌋ ! (n − 1)n ( n2 − 1) n2 − 2 2 n−1 4 n−3 ≤ (n − 1) + 4(n − 1) − n 2 2 < 4n − 1 < 4 · n. 8 = (n − 1) + n 2 55 Kapitel 4 Verwaltung von Mengen Mit den Union-Find –Algorithmen sind wir bei der Klasse der Algorithmen angelangt, deren Kern die Verwaltung von Mengen ist. Definition 4.0.1 Eine Partition P einer Menge M ist eine Zerlegung von M in disjunkte, nicht-leere Teilmengen: m M = [ ˙ Pi i=1 P = {Pi | Pi ∩ Pj = ∅, Pi 6= ∅, 1 ≤ i < j ≤ m} Beispiel 4.0.2 Sei M = {1, 2, . . . , n} die Grundmenge. Dann ist P = {Q, R, S} mit Q = {i ∈ M | i ungerade}, R = {i ∈ M | i ≡ 0 mod 4} und S = {i ∈ M | i ≡ 2 mod 4} eine Partition von M. Wir betrachten zwei grundlegende Operationen auf Partitionen, Find und Union: • Find ist eine Funktion von M nach P, die jedem i ∈ M die Menge Pj zuordnet, für die i ∈ Pj gilt. • Union verändert die Partition wie folgt: Union(A, B, C) bedeutet, dass die Partitionselemente A, B ∈ P zu einer neuen Menge mit dem Namen C vereinigt werden. Nach Union(A, B, C) ist die neue Partition (P \ {A, B}) ∪ {A ∪ B}, wobei das Element A ∪ B nun C heißt. Wir beginnen mit einer einfachen Datenstruktur für das Union-Find–Problem, nämlich mit einem Feld von Paaren. 56 Beispiel 4.0.3 1 Q 2 S 3 Q 4 R 5 6 Q S 7 Q 8 R 9 Q 10 S 11 12 Q R Hier gilt z.B. Find(5) = Q und Union(Q, R, T ) ergibt: 1 T 2 S 3 T 4 T 5 T 6 S 7 T 8 T 9 T 10 11 12 S T T Mit dieser Realisierung kostet eine Find–Operation O(1) Zeit, während Union in O(n) Zeit ausgeführt werden kann. In den Anwendungen werden häufig m Union-Find–Operationen hintereinander durchgeführt, mit m ∈ Θ(n). Damit gilt für die Zeitabschätzung: Zeit ∈ O(n2 ). 4.1 Äquivalenz endlicher Automaten Wir betrachten hier als Anwendung für Union–Find das Problem der Äquivalenz zweier endlicher, deterministischer Automaten. Definition 4.1.1 Ein endlicher, deterministischer Automat über dem Alphabet Σ ist ein 4-Tupel A = (Q, Σ, δ, q0 , F ) mit: • Q ist eine endliche Menge von Zuständen, • q0 ∈ Q ist der Startzustand, • δ : Q × Σ −→ Q ist die Übergangsfunktion und • F ⊆ Q ist die Menge der Endzustände. Wir schreiben statt δ(q, w) für q ∈ Q und w ∈ Σ∗ , vereinfacht qw. Die von A akzeptierte Sprache ist definiert als L(A) := {w ∈ Σ∗ | q0 w ∈ F } Weiterhin bezeichnen wir für q ∈ Q mit L(q) die Leistung eines Zustandes, d.h. L(q) := {w ∈ Σ∗ | qw ∈ F }. 57 Gegeben seien zwei deterministische endliche Automaten A = (Q, Σ, δ, q0 , F ) und A′ = (Q′ , Σ, δ ′ , q0′ , F ′ ), deren Äquivalenz wir testen wollen. D.h. wir wollen testen, ob L(A) = L(A′ ) gilt. Sei Q̃ die disjunkte Vereinigung von Q und Q′ und sei R ⊆ Q̃ × Q̃ die kleinste Äquivalenzrelation mit den Eigenschaften: 1. (q0 , q0′ ) ∈ R, 2. (q, q ′) ∈ R, a ∈ Σ, q ∈ Q, q ′ ∈ Q′ =⇒ (qa, q ′ a) ∈ R Lemma 4.1.2 Es gilt: L(A) = L(A′ ) gdw. R ∩ [(F × (Q′ \ F ′ )) ∪ ((Q \ F ) × F ′ )] = ∅. Beweis: Sei R ∩ [(F × (Q′ \ F ′ )) ∪ ((Q \ F ) × F ′ )] = ∅. Wir zeigen L(q0 ) ⊆ L(q0′ ). Wähle ein v ∈ L(q0 ). Dann gilt q0 v ∈ F und (q0 v, q0′ v) ∈ R. Nach Voraussetzung ist q0′ v ∈ / Q′ \ F ′ , d.h. es ist q0′ v ∈ F ′ und damit v ∈ L(q0′ ). Sei jetzt L(A) = L(A′ ). Wir zeigen: R ⊆ {(q, q ′ ) ∈ Q̃ × Q̃ | L(q) = L(q ′ )} =: R̃ Die rechte Seite R̃ ist eine Äquivalenzrelation. Es gilt (q0 , q0′ ) ∈ R̃, da L(q0 ) = L(q0′ ). Sei jetzt (q, q ′ ) ∈ R̃ mit q ∈ Q und q ′ ∈ Q′ , und a ∈ Σ. Angenommen es wäre L(qa) 6= L(q ′ a). Dann existiert Œ ein v ∈ Σ∗ mit qav ∈ F und q ′ av ∈ / F ′. Dann gilt av ∈ L(q)\L(q ′). Dies ist ein Widerspruch und damit gilt (qa, q ′ a) ∈ R̃. Da R aufgrund der Definition die von 1. und 2. erzeugte Äquivalenzrelation ist, folgt R ⊆ R̃. Wegen R̃ ∩ [(F × (Q′ \ F ′ )) ∪ ((Q \ F ) × F ′ )] = ∅, folgt die Behauptung. Algorithmus 4.1.1 Äquivalenztest für endliche Automaten function Äquivalenztest-DFA(A, A′ : DFA) : boolean begin L := {(q0 , q0′ )}; while L = 6 ∅ do wähle ein (q, q ′) ∈ L; L := L \ {(q, q ′)} if Find(q) 6= Find(q ′ ) then if (q, q ′) ∈ [(F × (Q′ \ F ′ )) ∪ ((Q \ F ) × F ′ )] then return false (∗ L(q0 ) 6= L(q0′ ) ∗) else 58 2 Union(q, q ′); forall a ∈ Σ do L := L ∪ {(qa, q ′ a)} endfor endif endif endwhile return true endprocedure (∗ L(q0 ) = L(q0′ ) ∗) Terminierung Es sind maximal |Q|+|Q′ | =: n = |Q̃| Union-Operationen möglich. Damit werden maximal |Σ| · n Elemente zu L hinzugefügt. In jedem Schleifendurchlauf wird ein Element aus L entfernt. Daher ergibt sich die Termination nach maximal |Σ| · n Schleifendurchläufen. Korrektheit Sei Ri die Relation, die nach dem i-ten Schleifendurchlauf wie folgt definiert ist: Ri = {(q1 , q2 ) ∈ Q̃ × Q̃ | Find(q1 ) = Find(q2 ) oder (q1 , q2 ) ∈ L} Dann gilt: (q0 , q0′ ) ∈ R0 ⊆ R1 ⊆ . . . ⊆ R|Σ|·n ⊆ R, insbesondere (q0 , q0′ ) ∈ Ri ⊆ R für 0 ≤ i ≤ |Σ| · n. Ist L nach dem i-ten Durchlauf leer, so ist Ri ferner eine Äquivalenzrelation. Da Ri ⊆ R für alle i gilt, ist der Abbruch mit L(q0 ) 6= L(q0′ )“ korrekt. Brechen ” wir den Algorithmus nicht in dieser Form ab, so gilt für alle (q1 , q2 ) ∈ Ri , 0 ≤ i ≤ |Σ| · n mit Find(q1 ) = Find(q2 ), die Behauptung: q1 ist ein Endzustand gdw. q2 ist ein Endzustand. Dies ergibt sich leicht mit Induktion nach i und der Eigenschaft von Union. Aus diesen Vorüberlegungen folgt, dass wir nur die folgende Behauptung zu zeigen haben: Behauptung 4.1.3 Sei q ∈ Q, q ′ ∈ Q′ , a ∈ Σ und Find(q) = Find(q ′ ) nach dem i-ten Schleifendurchlauf. Dann gilt schließlich Find(qa) = Find(q ′ a) oder wir brechen mit L(q0 ) 6= L(q0′ )“ ab. ” Es reicht, Behauptung 4.1.3 zu zeigen: wird nicht mit L(q0 ) 6= L(q0′ )“ abgebro” chen, so ist die Liste L schließlich leer. Die Relation R|Σ|·n = {(q1 , q2 ) ∈ Q̃ × Q̃ | Find (q1 ) = Find (q2 )} ist also eine Äquivalenzrelation und erfüllt 1. und 2. Daher gilt R ⊆ R|Σ|·n (damit auch R = R|Σ|·n ), woraus R ∩ ((F × (Q′ \ F ′ )) ∪ ((Q \ F ) × F ′ )) = ∅ folgt. Beweis der Behauptung: Angenommen, die Behauptung wäre falsch für i und richtig für alle j < i. Sei (q, q ′) das Paar, für das die Behauptung falsch wird. 59 Dann hat sich Find(q) = Find(q ′ ) erst im i-ten Schleifendurchlauf ergeben und zwar durch eine Operation Union(r, s). Daher galt schon nach dem (i − 1)-ten Durchlauf (Find(r) = Find(q) und Find(s) = Find(q ′ )) (Find(r) = Find(q ′ ) und Find(s) = Find(q)) oder O.B.d.A. gelte ersteres nach dem (i − 1)-ten Durchlauf. Dann folgt aufgrund der Minimalität für i schließlich: Find(ra) = Find(qa) und Find(sa) = Find(q ′ a) Das Paar (ra, sa) steht nach dem i-ten Durchlauf auf der Liste. Irgendwann wird es von L entfernt und dann gilt Find(ra) = Find(sa). Damit gilt dann auch Find(qa) = Find(q ′ a). Dies ist ein Widerspruch und damit ist die Behauptung gezeigt. 2 Der Algorithmus ist also korrekt und führt höchstens O(|Σ| · (|Q| + |Q′ |)) UnionFind-Operationen durch. 4.2 Minimale aufspannende Bäume (Kruskal-Algorithmus) Eine weitere Anwendung für den Union-Find–Algorithmus ist die Berechnung eines minimalen Spannbaumes nach Kruskal. In Abschnitt 2.1 haben wir bereits einen Greedy– Ansatz für die Berechnung des minimalen Spannbaumes (MSB) gesehen, nämlich den Prim-Algorithmus. Hier wollen wir eine algorithmische Lösung des Problems mit der Union-Find–Methode betrachten. Sei G = (V, E, γ) ein ungerichteter, zusammenhängender Graph mit Kantengewichtsfunktion γ : E → N. Mit m = |E| und n = |V | sind die Kardinalitäten der Mengen E und V bezeichnet. Wir nehmen an, dass n ≤ m + 1 gilt (sonst wäre der Graph nicht zusammenhängend). Als Preprocessing wird die Kantenmenge E nach Gewichten aufsteigend sortiert, was in m log(m) Schritten erfolgen kann. Sei jetzt E = {e1 , e2 , . . . , em } mit γ(e1 ) ≤ γ(e2 ) ≤ . . . ≤ γ(em ). Nun zur Idee von Kruskal: zuerst wird die Knotenmenge ohne Kanten betrachtet; anders ausgedrückt wird mit einem Wald (eine disjunkte Vereinigung von Bäumen), bestehend aus n Bäumen, begonnen. Die zu verwaltenden Mengen sind (disjunkte) Bäume, die schrittweise durch möglichst billige, verbindende Kanten vereinigt werden. Der Algorithmus endet, wenn ein einziger Baum übrigbleibt (siehe Algorithmen 4.2.1 und 4.2.2). 60 Algorithmus 4.2.1 Kruskal–Algorithmus zur Bestimmung des MSB function kruskal(G = (V, E, γ): kantengew. Graph): Kantenmenge; (∗ G sei zusammenhängend ∗) (∗ Die Kantenmenge E = {e1 , e2 , . . . , em } sei nach Gewichten aufsteigend sortiert. ∗) var m, i: integer; x, y: Knoten; T : Kantenmenge; (∗ des MSB ∗) begin m := |E|; T := ∅; forall v ∈ V do [v] := {v} (∗ Initialisierung des Waldes ∗) endfor; for i := 1 to m do xy := ei ; if Find(x) 6= Find(y) then (∗ x,y gehören zu verschiedenen Zusammenhangskomponenten in T ∗) Union([x] , [y]); T := T ∪ {xy} endif endfor return T endfunction Für die Korrektheit genügt es zu zeigen, dass für die Kantenmenge Ti , die nach dem i-ten Schleifendurchlauf entstanden ist, gilt: Ti kann zu einem MSB ergänzt werden. Für i = 0 gilt T0 = ∅. Sei nun i > 0 und ei = xi yi ∈ E die Kante, die in der i-ten Schleife zu T hinzugefügt wird. Sei weiterhin B = (V, E ′ ) ein MSB, der Ti−1 ergänzt, d.h. Ti−1 ⊆ E ′ . Angenommen, ei ∈ / E ′ . Dann enthält der Graph (V, E ′ ∪{ei }) einen Zyklus, in dem mindestens eine Kante e′ ∈ E ′ \Ti−1 vorkommt (da Find(xi ) 6= Find(yi ) vor dem i-ten Durchlauf galt). Mit e′ ∈ / Ti−1 ergibt sich γ(ei ) ≤ γ(e′ ). Der Graph B ′ = (V, (E ′ ∪ {ei }) \ {e′ }) ist erneut ein aufspannender Baum und es gilt γ(B ′ ) ≤ γ(B), d.h. B ′ ist ein MSB, der die Kante ei enthält. Für die Zeitabschätzung ergibt sich Θ(m log(m)) + m · Union-Find-Operationen. Hierzu sei angemerkt, dass wir die Kantenmenge E als Prioritätswarteschlange (Heap) organisieren könnten (statt einer geordneten Liste). Es ergibt sich damit folgender Algorithmus, der eine Laufzeit von O(m log(m)) + m · Union-Find hat. 61 Algorithmus 4.2.2 Kruskal–Algorithmus mit Heap-Datenstruktur function kruskal(G = (V, E, γ): kantengew. Graph): Kantenmenge; (∗ G sei zusammenhängend ∗) var m, i: integer; x, y: Knoten; T : Kantenmenge des MSB; H: Heap; begin m := |E|; H := makeheap(E); (∗ O(m) Zeit ∗) T := ∅; forall v ∈ V do [v] := {v} (∗ Initialisierung des Waldes ∗) endfor; while H 6= ∅ and |T | < n − 1 do xy := deletemin(H); (∗ O(log(m)) Zeit ∗) if Find(x) 6= Find(y) then Union([x] , [y]); T := T ∪ {xy} endif endfor return T endfunction 4.3 Realisierung von Union und Find Wir verwenden nun als Datenstruktur für die Mengen knotenbeschriftete Bäume, wobei die Wurzel den Namen der Menge trägt. Datenstrukturen: Für die n Elemente der Grundmenge verwenden wir die Zahlen 1, . . . , n. Mengen werden durch ihre Repräsentanten dargestellt, d.h. die Mengennamen, die vorkommen können, sind wiederum die Zahlen 1, . . . , n. Seien die Knoten der Bäume mit v1 , . . . , vn bezeichnet, so definieren wir folgende Funktionen bzw. Zugriffsmöglichkeiten: 62 element: {1, . . . , n} → Knoten pred: Knoten → Knoten count: Wurzelknoten → Zahl name: Wurzelknoten → Menge root: Menge → Wurzelknoten zeigt auf den zugehörigen Knoten des Elements im Baum liefert den Vorgänger eines Knotens im Baum liefert die Anzahl der Elemente im Baum mit dieser Wurzel liefert den Namen der entsprechenden Menge liefert die Wurzel des Baumes, der die Menge reprsentiert Die Initialisierung ist dann: name(vi ) root(i) pred(vi ) count(vi ) := := := := i vi nil 1 Die Union-Operation Union(i, j, k) wird dann so realisiert, dass der kleinere der beiden Bäume i, j an die Wurzel des größeren angehängt wird: i j k Union(i,j,k) Algorithmus 4.3.1 Union–Implementierung type Menge = 1, . . . , n; Element = 1, . . . , n; Knoten = v1 , . . . , vn ; procedure Union(i, j, k: Menge); var large, small : Knoten; begin if count(root (i)) > count(root(j)) then Union(j,i,k) else large := root(j); 63 small := root(i); pred(small ) := large; count(large) := count(large)+count(small ); name(large) := k; root(k) := large endif endprocedure Wir werden sehen, dass bei einer n-elementigen Grundmenge die Höhe eines jeden Baums durch log(n) beschränkt ist. Somit kosten in dieser Realisierung FindOperationen O(log(n)) Zeit, während Union in konstanter Zeit durchgeführt werden kann. Eine weitere Idee zur Verkürzung der Suchpfade besteht darin, bei einem Aufruf von Find alle Knoten auf dem Pfad, den man zur Wurzel hin durchläuft, direkt unter die Wurzel anzuhängen: p p s q q r r Baum nach Find(s) s Baum vor Find(s) Algorithmus 4.3.2 Find–Implementierung mit Pfadverkürzung type function Find(i: Element): Element; var u, v, root: Knoten; begin u := element[i]; while pred(u) 6= nil do u := pred(u); endwhile root := u; u := element[i]; while pred(u) 6= nil do v := pred(u); (∗ Suchen nach der Wurzel ∗) (∗ besuchte Knoten werden direkte ∗) 64 (∗ Nachfolger der Wurzel ∗) pred(u) := root; u := v endwhile return name(root); endfunction 4.4 Analyse der Union-Find Entwurfstrategie Wir wollen nun die Datenstruktur und die Algorithmen des vorigen Abschnitts auf ihren schlechtesten Fall untersuchen. Sei n die Anzahl der Knoten (Elemente) und σ eine Sequenz von m Union/Find-Operationen. Dieser Abschnitt beschreibt eine Analyse nach Robert E. Tarjan (1983) mit Hilfe der Ackermannfunktion. Wir verwenden dabei die folgende Variante der Ackermannfunktion: A0 (r) = r + 1, Ak+1 (r) = Ark (r). (Hierbei bezeichnet Ark (r) die r-fache Anwendung von Ak auf r) Wie man leicht nachprüft, ist Ak (1) = 2; daher sind eigentlich nur Argumente größer als zwei interessant. Dies motiviert folgende Definition: α(n) = min{k | Ak (2) > n}. Es gilt A2 (x) = x2x , A3 (2) = 211 = 2048, A4 (2) = A23 (2) = A3 (2048) = A2048 (2048). Diese Zahl ist größer als ein Turm von 11 + 2048 Potenzen der 2 2. Daher ist α(n) ≤ 4 für alle praktisch denkbaren Werte. Definition 4.4.1 Der Rang eines Knotens v, Rang(v), ist die Höhe des Knotens in dem Wald, der durch σ ohne Pfadverkürzung erzeugt wurde. Lemma 4.4.2 Sei v ein Knoten mit Rang(v) = r. Dann hat der Teilbaum mit der Wurzel v mindestens 2r Elemente. Beweis: Wir führen Induktion nach r: Für r = 0 ist die Behauptung klar, da v selbst zum Teilbaum gehört. Sei r ≥ 1 und betrachte einen Knoten v mit Rang(v) = r. Weiterhin betrachte die erste Union-Operation in σ, etwa Union(B ′ , B ′′ , B), nach der Rang(v) = r gilt. Unmittelbar vor der Union Operation sei v ′ (bzw. v ′′ ) die Wurzel des Baumes 65 B ′ (bzw. B ′′ ), und B ′ (bzw. B ′′ ) enthalte c′ (bzw. c′′ ) Elemente. Weiter gelte Rang(v ′) = r ′ (bzw. Rang(v ′′ ) = r ′′ ) zu diesem Zeitpunkt. Œ sei c′ ≤ c′′ und damit v = v ′′ (Union nach Größe), d.h. B ′ wird an B ′′ angehängt. Damit gilt r ′ ≥ r ′′ (ansonsten wäre bereits vor Union(B ′ , B ′′ , B) der Rang von v gleich r gewesen). Damit folgt mit Rang(v) = r = r ′ + 1 und Induktion (angewendet auf ′ ′ B ′ , r ′ ): B enthält c′ + c′′ ≥ 2c′ ≥ 2 · 2r = 2r +1 = 2r Elemente. 2 Zwei Knoten mit gleichem Rang sind nicht Vorgänger voneinander. Damit folgt: Korollar 4.4.3 Es gibt höchstens n/2r Knoten mit Rang r. Wir betrachten für die Analyse nun eine Sequenz von m Union-Find-Operationen auf n Knoten. Wir definieren dazu folgende zeitabhängige Funktionen (sei 1 ≤ t ≤ m der Zeitpunkt vor der t-ten Operation): Definition 4.4.4 Sei x ein Knoten mit Rang r. Dann bezeichnet part (x) seinen Vorgänger (englisch: parent) zum Zeitpunkt t. Definition 4.4.5 Ein Knoten x mit Rang r ≥ 2 gehört im Zeitabschnitt t zur Klasse k, falls k maximal ist mit r(part (x)) ≥ Ak (r(x)). Bemerkung 4.4.6 • Wurzeln oder Knoten vom Rang 0 oder 1 gehören in keine Klasse. Ein innerer Knoten mit Rang 2 gehört mindestens in die Klasse 0. • Es gibt höchstens α(n) verschiedene Klassen (Beweis ?). • Eine Klasse kann als Maß für den Abstand eines Knotens zu seinem Vaterknoten angesehen werden. Damit ist auch klar: Die Klassennummern, die man auf dem Pfad zur Wurzel antrifft, müssen nicht wie die Ränge monoton steigen. Erinnerung: Teuer sind nur die Find-Operationen. Um die Gesamtkosten der Find-Operationen zu bestimmen, führen wir Buchhaltung auf den jeweils durchlaufenen Pfaden zur Wurzel und verteilen die Kosten auf die Knoten der Pfade: Bei einem Find(i) verteilen wir auf dem Pfad Gold- und Eisenstücke auf die Knoten. Diesmal unterscheiden wir aber α(n) Klassen von Eisenstücken, so dass es insgesamt 1 + α(n) verschiedene Währungen gibt. Goldverteilung: Wenn x auf dem Pfad zur Wurzel besucht wird, gibt es ein Goldstück, falls 66 • x keiner Klasse angehört (d.h. x ist Wurzel oder vom Rang 0 oder 1) • oder x letzter seiner Klasse ist. Korollar 4.4.7 Insgesamt werden nicht mehr als (3 + α(n))m Goldstücke verteilt. Eisenverteilung: Wenn x zur Zeit t auf dem Pfad zur Wurzel besucht wird, erhält x ein Eisenstück der Klasse k, falls x zu dieser Zeit zur Klasse k gehört und nicht letzter seiner Klasse ist. Beobachtung: Erhält x zur Zeit t ein Eisenstück der Klasse k, so gibt es auf dem Pfad zur Wurzel Knoten x, part (x), y, part (y) mit: Ak (r(x)) ≤ r(part (x)) ≤ r(y), Ak (r((y)) ≤ r(part (y)). Satz 4.4.8 Sei i ≥ 1 mit r(part (x)) ≥ Aik (r(x)). Dann gilt zur Zeit t + 1 die Beziehung: r(part+1 (x)) ≥ Ai+1 k (r(x)). Beweis: Wähle auf dem Pfad x, part (x), y, part (y) mit: Aik (r(x)) ≤ r(part (x)) ≤ r(y), Ak (r(y)) ≤ r(part (y)). Dann folgt: r(part+1 (x)) ≥ ≥ ≥ ≥ = r(part (y)) Ak (r(y)) Ak (r(part (x))) Ak (Aik (r(x))) Ai+1 k (r(x)). 2 Korollar 4.4.9 Ein Knoten vom Rang r erhält maximal r Eisenstücke der Klasse k. Beweis: Nach r Find-Operationen in der Klasse k gilt: r(pars (x)) ≥ Ark (r) = Ak+1 (r) und der Knoten steigt in die Klasse k + 1 auf. 67 2 Korollar 4.4.10 Es werden insgesamt maximal k verteilt. 3n 2 ≤ 2n Eisenstücke der Klasse Beweis: Es gibt höchstens n/2r Knoten mit Rang r und jeder erhält höchstens r Eisenstücke der Klasse k. Damit ergibt sich die Gesamtzahl: X rn 3n = 2r 2 r≥2 2 Korollar 4.4.11 Es werden insgesamt maximal 2α(n)n Eisenstücke verteilt. Beweis: Es gibt höchstens α(n) Klassen, pro Klasse werden maximal 2n Eisenstücke verteilt. 2 Damit erhalten wir schlielich die Abschtzung fr die Gesamtkosten der Sequenz von Operationen: Korollar 4.4.12 Alle Finds zusammen kosten O(α(n)(2n + m) + 3m) Zeit. Beweis: (3 + α(n))m Gold + 2α(n)n Eisen. 2 Übung: Für α(n) = 4 dominiert die Konstante 7m im O-Term. Wie erreicht man 5m – ohne bei Kozen oder Tarjan nachzulesen? 4.4.1 Gemessene Laufzeitkosten Die folgenden Diagramme zeigen experimentell gemessene Werte für die Laufzeitkosten, die die Union-Find-Implementierung beim Kruskal-Algorithmus verursacht. Gemessen wurde jeweils, wie viele Knoten alle Find-Operationen zusammen besuchen mussten. Der zugrunde gelegte Graph wurde stets zufällig erstellt und hatte n Knoten sowie m Kanten, wie es in den Diagrammen jeweils zu sehen ist. Die Diagramme zeigen die Ergebnisse jeweils für drei verschiedene Strategien der Implementierung: Normal Die normale Strategie, bei einem Union stets den kleineren Baum unter den größeren zu hängen Invers Die umgekehrte Strategie, bei einem Union stets den größeren Baum unter den kleineren zu hängen Zufall Bei jedem Union wird gewürfelt, ob nun der kleinere Baum unter den größeren gehängt werden soll oder umgekehrt 68 Kapitel 5 Weitere Themen 5.1 Fibonacci Heaps Ein Fibonacci-Heap H ist eine Liste von Bäumen (also ein Wald), an die im folgenden weitere Bedingungen gestellt werden. Die Bäume sind knotenbeschriftet und alle Knoten, bis auf die Wurzel, können zusätzlich eine Marke tragen. Sei V die Knotenmenge, key : V → N die Beschriftung, root die Wurzel und markiert ⊆ V \ {root} die Menge der markierten Knoten. Alle Bäume müssen der Heap-Bedingung genügen: ∀x ∈ V : ist y ein Kind von x, so gilt: key (x) ≤ key (y) Die Eltern-Kind-Beziehung wird durch Zeiger realisiert. Die Datenstruktur lässt sich nicht über einem Feld realisieren, da die Bäume unbalanciert sein werden. Im Gegensatz zu einem Standard -Heap müssen Indexrechnungen also durch (aufwändigere) Zeigeroperationen ersetzt werden. Ein Fibonacci-Heap soll folgende Operationen unterstützen: 1. merge, 2. insert, 3. delete min, 4. decrease key. 1. merge: Konkatenation zweier Listen — dies ist in konstanter Zeit möglich. 2. insert: Anhängen eines Elements (als Einpunktbaum). Damit ist insert ein Spezialfall von merge und erfordert ebenfalls nur konstante Zeit. Mit Hilfe von merge und insert können (eventuell sehr lange) Listen einelementiger Bäume entstehen. Jede solche Liste ist ein Fibonacci-Heap. 69 3. delete min: Sei H ein Fibonacci-Heap aus T Bäumen und n Elementen. Für jeden Knoten x sei rank (x) der Rang von x; dieser ist definiert als die Anzahl der Kinder von x. Für einen Baum B sei rank (B) der Rang der Wurzel von B. Sei rmax (n) der maximale Rang, der in einem Fibonacci-Heap mit n Elementen auftreten kann. Trivialerweise gilt rmax (n) ≤ n. Später, und dies wird entscheidend sein, zeigen wir rmax (n) ∈ O(log n). procedure delete min 1. Suche den minimalen Schlüssel. Dieser findet sich in der Wurzel eines Baumes. Hierfür wird O(T ) Zeit benötigt. Sei r der Rang dieses Baumes. Man beachte, dass r ≤ rmax (n) gilt. 2. Trenne die Wurzel ab und ersetze den Baum in der Liste durch die r Teilbäume. Dabei werden eventuell vorhandene Marken an den neuen Wurzeln entfernt. Die neue Liste enthält T −1+r Bäume. Dies erfordert O(T + r) Zeit. 3. Lege ein Feld L[0, . . . , rmax (n)] an, wobei jedes L[i] die Liste der Bäume von Rang i darstellt. Hierzu wird O(T + rmax (n)) Zeit benötigt. 4. for i := 0 to rmax (n) − 1 do while |L[i]| ≥ 2 do Entnehme zwei Bäume aus L[i]. Hänge den Baum mit dem größeren Schlüsselwert an der Wurzel direkt unter die Wurzel des anderen Baumes und füge diesen neuen Baum in L[i + 1] an. endwhile endfor Dies erfordert O(T + rmax (n)) Zeit. endprocedure Bemerkung: Am Ende der Prozedur delete min gibt es für jeden möglichen Rang höchstens einen Baum. Insbesondere ist die Zahl der Bäume danach durch rmax (n) beschränkt. 4. decrease key: Mit dieser Operation soll der Schlüssel eines Knotens verkleinert werden. Dies werden wir durch kaskadenartige Schritte realisieren. Hierbei werden Marken eingeführt bzw. wieder entfernt. procedure decrease key Sei x der Knoten, dessen Schlüssel verkleinert werden soll. 70 1. Ist x die Wurzel, so kann der Schlüsselwert verkleinert werden, ohne die Heap-Bedingung zu zerstören (in konstanter Zeit). Sei also x keine Wurzel und x = y0 , y1, . . . , yk , . . . , root der Pfad von x zur Wurzel. Für ein k ≥ 1 sei yk der erste (von x verschiedene) Knoten, der keine Marke trägt. Da root keine Marke trägt und x 6= root gilt, existiert ein solches k. 2. Für 0 ≤ i < k trenne jetzt yi vom Elternknoten yi+1 ab und entferne dabei die Marke von yi. (Für y0 = x muss nicht unbedingt eine Marke vorhanden gewesen sein.) Damit wird yi für 0 ≤ i < k zu einer unmarkierten Wurzel eines eigenen Baumes. Die so erhaltenen Bäume werden an die Liste der Bäume angehängt. 3. Falls yk keine Wurzel ist, markiere yk . endprocedure Es wird O(k + 1) Zeit benötigt. Beachte: Die Zahl der Marken hat sich mindestens um k − 2 verringert, k ≥ 1. Die Anzahl der Bäume ist nach der Prozedur delete min durch T + k begrenzt. Definition 5.1.1 Ein Fibonacci-Heap ist eine Liste von Bäumen wie eingangs beschrieben, die aus der leeren Liste unter Anwendung der Operationen merge, insert, delete min und decrease key entstanden ist. Lemma 5.1.2 (Fibonacci-Heaps) 1. Sei x ein Knoten in einem FibonacciHeap, und ci das i-t älteste Kind von x (d.h., c1 ist der erste Knoten, der Kind von x geworden ist). Dann hat ci mindestens den Rang i − 2. 2. Seien y1 , y2 , . . . , yk Kinder von x, k ≥ 0, so enthalten die Unterbäume mit Wurzel yi (1 ≤ i ≤ k) zusammen mit x mindestens Fk+2 Knoten. Hierbei ist Fk+2 die (k + 2)-te Fibonacci-Zahl (F0 = 0, F1 = 1, Fk+1 = Fk + Fk−1 für k ≥ 1). Beweis: 1. Sei c1 , c2 , . . . , ck die dem Alter nach absteigend geordnete Folge der Kinder von x, und 1 ≤ i ≤ k. Dann ist c1 älter als c2 , c2 älter als c3 , u.s.w. Zu dem Zeitpunkt, als ci unter den Knoten x (der zu diesem Zeitpunkt eine Wurzel war) gehängt wurde, existierten also bereits die Kinder c1 , . . . , ci−1 . Der Rang von x war also mindestens i − 1. Da nur Bäume vom gleichen Rang zu einem Baum vereinigt werden, hatte ci zu diesem Zeitpunkt mindestens den Rang i − 1. Der Knoten ci kann inzwischen maximal ein Kind verloren haben. (In diesem Fall 71 wäre ci markiert.) Der Verlust eines weiteren Kindes hätte die Abtrennung ci von x zur Folge gehabt. Es folgt die erste Behauptung rank (ci ) ≥ i − 2 . 2. Wir zeigen die Behauptung durch Induktion nach k. Falls k = 0 oder k = 1 gilt die Aussage offenbar. Sei nun k ≥ 2 und seien y1 , . . . , yi−1 , yk jeweils Kinder von x. Dann besitzt nach (1.) o.B.d.A. yk mindestens Rang k − 2. Nach Induktionsvoraussetzung enthält der Unterbaum mit Wurzel yk mindestens Fk Knoten. Die Unterbäume mit Wurzel yi (1 ≤ i ≤ k − 1) zusammen mit x enthalten nach Induktionsvoraussetzung mindestens Fk+1 Knoten. Die Unterbäume mit Wurzel yi (1 ≤ i ≤ k) zusammen mit x enthalten also mindestens Fk + Fk+1 = Fk+2 Knoten. 2 Die Fibonacci-Zahlen lassen sich übrigens auch in geschlossener Form darstellen: √ !k √ !k 1+ 5 1− 5 Fk+1 = a +b , k≥0 2 2 Wegen F0 = 0 und F1 = 1 muss für a, b gelten: a+b = 1, √ √ 1+ 5 a + b 1−2 5 = 1 . 2 Es folgt a = 21 (1 + √15 ), b = 12 (1 − √15 ). Der Beweis der geschlossenen Darstellung folgt unmittelbar aus dem Ansatz √ xk+1 = xk + xk−1 ⇐⇒ xk−1 (x2 − x − 1) = 0 Die Zahlen 1+2 5 , x2 − x − 1 = 0. √ 1− 5 2 sind die beiden Lösungen der quadratischen Gleichung Die Fibonacci-Zahlen wachsen exponentiell. Daraus folgt, dass der maximale Rang rmax (n), der in einem Fibonacci-Heap mit n Elementen auftreten kann, logarithmisch beschränkt ist: rmax (n) ∈ O(log n). Zusammenfassung der Zeitabschätzungen: 1,2. merge, insert: in konstanter Zeit 3. delete min: O(T + log n), wobei T die Zahl der Bäume ist. Der Summand log n ergibt sich aus rmax (n) ∈ O(log n). 4. decrease key: O(1) + O(k), wobei k ≥ 0 die Zahl der Marken sei, um die sich der Fibonacci-Heap verringert hat. 72 Für die weitere Analyse einer Sequenz von Operationen betrachten wir die gegen eine Potentialfunktion amortisierte Zeit. Definition 5.1.3 Für einen Fibonacci-Heap H sei das Potential pot (H) gegeben durch pot (H) := T + 2M , wobei T die Zahl der Bäume und M die Zahl der Marken seien. Für eine Operation op sei ∆(pot ) die Differenz des Potentials vor und nach der Ausführung: ∆(pot ) = pot (Heap nach op ) − pot (Heap vor op ) . Die amortisierte Zeit einer Operation op sei tamort (op ) = t(op ) + ∆(pot ) . Hierbei sei t(op ) die Zahl der tatsächlichen Zeitschritte, die zur Ausführung von op benötigt werden, und ∆(pot ) steht für den Verlust oder Gewinn des in der Struktur angesammelten Potentials. Die Potentialfunktion erfüllt die drei Eigenschaften • pot (H) ≥ 0, • pot (H) ∈ O(|H|) und • pot (nil) = 0. Sei jetzt op 1 , op 2 , op 3 , . . . , op m eine Sequenz von m Operationen auf einem zunächst leeren Fibonacci-Heap. Dann gilt: m X i=1 t(op i ) ≤ m X tamort (op i ) . i=1 Bemerkung: Die Differenz ist gerade das Potential des erzeugten Heaps. Es genügt also eine Schranke für tamort (op ) zu bestimmen. Dies ergibt eine obere Schranke für die tatsächliche Zeit. Für die weitere Rechnung ist es bequemer, sich bei der tatsächlich verbrauchten Zeit zunächst von der O-Notation zu befreien. Durch die Multiplikation mit einer geeigneten Konstanten können wir annehmen, dass sich merge und insert in einem Schritt realisieren lassen. Die Operation delete min benötigt höchstens T + log n und die Operation decrease key höchstens k + 1 Zeitschritte. Die Wahl der Basis für den Logarithmus ist unerheblich. 73 1. tamort (merge) = t(merge) = 1. Denn das Potential der konkatenierten Liste ist die Summe der Potentiale der Einzellisten. 2. tamort (insert) = t(insert)+∆(pot ) = 1 + 1 = 2. 3. Für delete min gilt t(delete min) ≤ T + log n, wobei T die Zahl der Bäume zuvor und rmax (n) bis auf einen konstanten Faktor die maximale Zahl der Bäume danach ist. Die Zahl der Marken kann nur kleiner werden. Aus ∆(pot ) ≤ −T + rmax (n) folgt daher tamort (delete min) ≤ T + log n − T + rmax (n) ∈ O(log n) . 4. Für decrease key gilt nach der obigen Bemerkung t(decrease key) ≤ k + 1. Dabei verliert der Heap mindestens k − 2 Marken, k ≥ 1, und erhält höchstens k neue Bäume. ∆(pot ) = ∆(T ) + 2∆(M) ≤ k + 2 · (2 − k) = 4−k Also gilt tamort (decrease key) ≤ k + 1 + 4 − k = 5 ∈ O(1). Aus der obigen Herleitung erhalten wir: Satz 5.1.4 Für einen Fibonacci-Heap gelten die folgenden amortisierten Zeiten: tamort (merge) ∈ O(1) tamort (insert) ∈ O(1) tamort (delete min) ∈ O(log n) tamort (decrease key) ∈ O(1) Anwendung auf den Dijkstra- oder Prim-Algorithmus: Für den Dijkstra-Algorithmus sei V die Randmenge und key die ermittelte Distanz der Randknoten zum Quellknoten u (entsprechend für Prim). Sei n die Zahl der Knoten und e die Zahl der Kanten. Maximal werden n insert–, e decrease key– und n delete min–Operationen durchgeführt. tDijkstra ≤ n · tamort (insert) + e · tamort (decrease key) + n · tamort (delete min) ∈ O(n + e + n log n) = O(e + n log n) 74 Man beachte, dass sich für die Analyse des schlechtesten Falles keine Verbesserung ergibt. Asymptotisch ist O(e+n log n) jedoch mindestens genauso gut wie min(e· n2 log n, n2 ). In vielen Fällen (n log n ≤ e ≤ log ) ist O(e + n log n) um den Faktor n log n besser. 5.2 Minimale Schnitte Sei G = (V, E, γ) ein ungerichteter Graph mit Knotenmenge V und Kantenmenge E, n = |V |, e = |E|, sowie mit einer Gewichtsfunktion γ : E → N, die durch Null fortgesetzt wird so, dass γ(uv) für alle u, v ∈ V definiert ist. Ein Schnitt Ṡ C = (C1 , C2 ) ist eine Partition von V in zwei nichtleere Teilmengen, V = C1 C2 . Das Gewicht g(C) des Schnittes C ist die Summe der Gewichte der Kanten, die P die beiden Teilmengen trennen, d.h. g(C) = v∈C1 ,w∈C2 γ(vw). Ein s-t-Schnitt für s, t ∈ V , s 6= t, ist ein Schnitt P (C1 , C2 ) mit s ∈ C1 und t ∈ C2 . Für A ⊆ V, v ∈ V definieren wir noch g(A, v) = a∈A γ(av). Das Ziel ist die Berechnung eines Schnittes C mit minimalem Gewicht. Der nachfolgende Algorithmus zur Berechnung eines minimalen Schnittes stammt von M. Stoer und F. Wagner (1994) [13]. Der Algorithmus arbeitet in Phasen. Dies wird im folgenden erklärt, das zugehörige Programm findet sich weiter unten. Zunächst wird in der ersten Phase für zwei Punkte s, t ∈ V, s 6= t ein minimaler s-t-Schnitt Cphase berechnet. Die Punkte s und t liegen vorher nicht fest und ergeben sich erst gegen Ende der Phase! Dann werden die Punkte s, t zu einem neuen Punkt {s, t} verschmolzen. In dem neuen Graphen wird also s = t gesetzt, die Kanten zwischen s und t werden gelöscht und für Knoten x ∈ / {s, t} setzt man γ({s, t}x) = γ(sx)+γ(tx). Der neue Graph heiße G/(s = t). Sei C ′ der rekursiv berechnete Schnitt in G/(s = t). Dann ist C ′ ein minimaler Schnitt von G unter der Nebenbedingung, dass C ′ die Knoten s und t nicht trennt. Der minimale Schnitt für G ergibt sich dann durch einen direkten Vergleich von g(Cphase ) und g(C ′). Das kleinere Gewicht liefert die Lösung. Es kommt also im wesentlichen darauf an, den minimalen s-t-Schnitt für gewisse Knoten s, t ∈ V , s 6= t zu berechnen. Diese Phase verläuft analog zum Prim-Algorithmus und ist als die unten angegebene Funktion Phase realisiert. Zu Beginn der Phase gelte B = {v1 } und R = V \ {v1 }. Die Knoten aus B werden Baumknoten und die Knoten aus R Randknoten genannt. Für einen RandknoP ten v ∈ R sei g(v) = x∈B γ(xv). (Gibt es keine Kante zwischen B und v, so hat diese Summe den Wert Null. Damit ist ein Wert in der Zeit O(e) für alle Randknoten initialisiert.) In jeder Schleife wird B vergrößert und R verkleinert. Zu Beginn jeder Schleife wird die Operation delete max ausgeführt. Diese Operation bestimmt einen 75 Knoten v ∈ R so, dass g(v) ≥ g(w) für alle w ∈ R gilt und entfernt v aus R. Anschließend wird B um den Knoten v ergänzt (v wird also ein Baumknoten) und die Schlüsselwerte werden für den Rand mit einer increase key Operation vergrößert: Für alle Kanten vw ∈ E mit w ∈ R \ {v} erhält g(w) den neuen Wert g(w) := g(w) + γ(vw). Eine Invariante ist dann g(w) = g(B, w), für alle w ∈ R. Die Schleife wird solangePdurchlaufen, bis der Rand nur noch einen Knoten t enthält. Dann gilt g(t) = vt∈E γ(vt), d.h. g(t) ist die Summe der Gewichte aller von t ausgehenden Kanten. Sei s der Knoten, der unmittelbar vor t zum Baumknoten wurde. Dann definiert die Zerlegung (V \ {t}, {t}) einen s-t-Schnitt Cphase mit dem Gewicht g(t). Die Korrektheit des weiter unten angegebenen Algorithmus Min Cut basiert nun auf dem folgenden Lemma. Lemma 5.2.1 Der Schnitt Cphase ist ein minimaler s-t-Schnitt im Eingabegraphen der Phase. Beweis: Sei v1 , v2 , . . . , vn eine Anordnung der Knoten in der Reihenfolge, in der sie zu Baumknoten wurden. Es gilt vn−1 = s, vn = t. Sei C = (C1 , C2 ) ein beliebiger s-t-Schnitt mit s ∈ C1 , t ∈ C2 . Wir zeigen g(Cphase ) ≤ g(C). In der Folge v1 , . . . , vn−1 , vn wird ein Knoten vi aktiv genannt, falls C ein vi−1 -vi -Schnitt ist. Da C ein s-t-Schnitt ist, ist insbesondere vn = t aktiv. Sei vi aktiv; setze B(i) = {v1 , . . . , vi−1 } und betrachte den durch B(i) ∪ {vi } induzierten Untergraphen. Durch Einschränkung definiert C auf diesem Untergraphen einen Schnitt. Diesen bezeichnen wir mit C(i) und das Gewicht mit g(C(i)). Da t = vn aktiv ist und g(Cphase ) = g(B(n), t) sowie C(n) = C gelten, genügt es, die Behauptung g(B(i), vi) ≤ g(C(i)) für alle aktiven Knoten vi zu zeigen. Dies geschieht mit Induktion nach i. Für den bzgl. der obigen Folge ersten aktiven Knoten vi gilt g(B(i), vi) = g(C(i)), denn die durch C induzierte Partition ist ({v1 , . . . , vi−1 }, {vi }). Dies ist die Induktionsverankerung. Sei jetzt vi ein aktiver Knoten für den die Behauptung richtig ist und vj der nächste auf vi folgende aktive Knoten. Dann gilt 1 < i < j ≤ n. Zu zeigen ist: g(B(j), vj ) ≤ g(C(j)). Es gilt zunächst: g(B(j), vj ) = g(B(i), vj ) + g({vi, . . . , vj−1 }, vj ). Da der Knoten vi vor vj gewählt wurde, gilt g(B(i), vj ) ≤ g(B(i), vi) und nach Induktion gilt g(B(i), vi ) ≤ g(C(i)). Alle Kanten zwischen {vi , . . . , vj−1 } und vj sind Schnittkanten und tragen somit zum Gewicht von C(j) bei. Daher haben wir g(C(i)) + g({vi , . . . , vj−1}, vj ) ≤ g(C(j)), und insgesamt die Behauptung: g(B(j), vj ) ≤ g(C(j)). 76 2 In jeder Phase wird also ein minimaler s-t-Schnitt berechnet. Die Laufzeitanalyse einer Phase ist identisch zum Prim-Algorithmus: Wird der Rand R als ein Feld oder als eine Liste verwaltet, so ergibt sich die Zeit n · O(n) + e · O(1) = O(n2 ). Bei Verwendung eines Heaps erhalten wir n · O(log n) + e · O(log n) = O(e log n). Mit Fibonacci-Heaps ergibt sich schließlich aus den amortisierten Zeiten die beste Abschätzung: n · O(log n) + e · O(1) = O(e + n log n). Nach jeder Phase wird der Graph durch Verschmelzen der beiden Knoten s und t verkleinert. Dies ist eine O(n)-Operation. Das Gewicht des jeweils aktuellen minimalen Schnittes ergibt sich in O(1) durch einen direkten Gewichtsvergleich. Die Aktualisierung der Partition (C1 , C2 ) kostet dann O(n) Schritte. Der Algorithmus terminiert, wenn der Graph nur noch einen Punkt enthält, d. h., es werden (n − 1) Phasen durchlaufen. Dies ergibt die Gesamtzeit O(n3 ) für Felder oder Listen, O(ne log n) für Heaps und O(ne + n2 log n) für Fibonacci-Heaps. Algorithmus 5.2.1 Phase function Phase (G = (V, E, γ) || |V | ≥ 2; v1 ∈ V ); (* Die Eingabe ist ein ungerichteter Graph G mit Kantengewichten γ : E → N, der mindestens zwei Knoten besitzt, und ein Startknoten v1 ∈ V . Die Ausgabe besteht aus zwei Knoten s, t ∈ V , s 6= t und das Gewicht gphase eines minimalen s-t-Schnittes. *) var B : Knotenmenge; R : Knotenmenge; s, t, v, w : V ; begin B := {v1 }; R := V \ {v1 }; forall v ∈ R do g(v) := γ(v1 v); endfor s := v1 ; while |R| > 1 do s := Knoten v ∈ R: g(v) ≥ g(w), ∀w ∈ R; R := R \ {s}; B := B ∪ {s}; forall sw ∈ E, w ∈ R do g(w) := g(w) + γ(sw) endfor 77 (∗ Baumknoten ∗) (∗ Randknoten ∗) (∗ Initialisierungen ∗) (∗ begin delete max ∗) (∗ end delete max ∗) (∗ increase key ∗) endwhile t := der letzte Knoten in R return (s, t, g(t)) endfunction Algorithmus 5.2.2 Berechnung eines minimalen Schnittes function Min Cut( G = (V, E, γ) : kantengewichteter Graph || |V | ≥ 2) : (Partition C1 , C2 von V mit minimalem Schnitt g); var C1 , C2 : Knotenmenge; (∗ Partition von V ∗) s, t : V ; g, gphase : integer; begin wähle ein v1 ∈ V ; (s, t, gphase ) := Phase(G, v1 ); if |V | = 2 then C1 := {s}; C2 := {t}; g := gphase ; else verschmelze s und t zu einem Knoten {s, t}; der neue Graph sei G/(s = t); (C1 , C2 , g) := Min Cut (G/(s = t)); endif (∗ Die Partition (C1 , C2 ) der Knotenmenge von G/(s = t) kann direkt als Schnitt von G, ∗) (∗ der s und t nicht trennt, angesehen werden. Dies ist entsprechend zu realisieren. ∗) if gphase ≤ g then C1 := V \ {t}; C2 := {t}; g := gphase ; endif return (C1 , C2 , g) endfunction Übung: Wie verhält sich der Algorithmus bei negativen Kantengewichten, bleibt er korrekt? 78 Literaturverzeichnis [1] A. V. Aho, J. E. Hopcroft, and J. D. Ullman. The Design and Analysis of Computer Algorithms. Addison-Wesley Verlag, 1974. (englisch). [2] A. V. Aho, J. E. Hopcroft, and J. D. Ullman. Data Structures and Algorithms. Addison-Wesley Verlag, 1987. (englisch). [3] S. Carlsson. A variant of heapsort with almost optimal number of comparisons. IPL, 24:247–250, 1987. [4] Thomas H. Cormen, Charles E. Leiserson, Ronald L. Rivest, and Clifford Stein. Introduction to Algorithms. The MIT Electrical Engineering and Computer Science Series. MIT Press, Cambridge, Mass. [u.a.], 2nd edition edition, 2001. 1184 S. (englisch). [5] P. Flajolet and A. Odlyzko. The average height of binary trees and other simple trees. JCSS, 25:171–213, 1982. [6] E. Horowitz and S. Sahni. Algorithmen: Entwurf und Analyse. Springer Verlag, 1981. (deutsch). [7] D. E. Knuth. Optimum binary search trees. Acta Informatica, 1:14–25, 1971. [8] D. E. Knuth. The art of computer programming Vol. 3: Sorting and searching. Addison-Wesley, Reading, Mass., 1998. 780 S. [9] K. Mehlhorn. Effiziente Algorithmen. Teubner Verlag, 1977. (deutsch). [10] K. Mehlhorn. Data structures and algorithms i, ii, iii. EATCS monographs on theoretical computer science, 1984. (englisch). [11] F. P. Preparata and M. I. Shamos. Computational Geometry. Springer Verlag, 1985. (englisch). [12] R. Sedgewick. Algorithmen. Addison-Wesley Verlag, 1992. (deutsch). 79 [13] M. Stoer and F. Wagner. A simple Min Cut algorithm. In J. van Leeuwen, editor, Proc. of the 2nd European Symposium on Algorithms, volume 855 of LNCS, pages 141–147, 1994. [14] I. Wegener. Bekannte Sortierverfahren und eine Heapsort-Variante, die Quicksort schlägt. Informatik-Spektrum, 13:321–330, 1990. [15] M. A. Weiss. Data Structures and Algorithm Analysis. Benjamin/Cummings, 1992. (englisch). [16] Zheng Zhang. An exponential example for a partial digest mapping algorithm. Journal of computational biology, 1(3):235–239, 1994. 80