Informatik III Prof. Dr. W. Vogler 1 INHALTSVERZEICHNIS INHALTSVERZEICHNIS Inhaltsverzeichnis 1 Grundlagen 1.1 Literatur . . . . . . . . . . . . . . 1.2 Prinzipien des Korrektheitsbeweis 1.3 Effizienz und Größenordnungen . 1.4 Bäume . . . . . . . . . . . . . . . 1.5 Schichtnumerierung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1 1 1 4 8 11 2 Listen, Keller und Warteschlangen 13 3 Sortieren 3.1 Einfache Sortierverfahren . . . . . . . . . . . . . 3.2 Quicksort . . . . . . . . . . . . . . . . . . . . . . 3.3 Untere Schranke für vergleichsbasierte Verfahren 3.4 Heapsort . . . . . . . . . . . . . . . . . . . . . . . 3.4.1 Halden . . . . . . . . . . . . . . . . . . . . 3.4.2 Heapsort . . . . . . . . . . . . . . . . . . . 3.4.3 Vorrangswarteschlangen . . . . . . . . . . 3.5 Bucket Sort, Radix Sort . . . . . . . . . . . . . . 3.6 Externes Sortieren . . . . . . . . . . . . . . . . . 3.6.1 Direktes Mischen . . . . . . . . . . . . . . 3.6.2 Verbesserungen . . . . . . . . . . . . . . . 3.6.3 Natürliches Mischen . . . . . . . . . . . . 3.6.4 Verbesserungen des natürlichen Mischens 3.6.5 Mehrphasenmischen . . . . . . . . . . . . . . . . . . . . . . . . . . 17 17 17 20 21 21 24 25 26 26 27 29 29 30 30 . . . . . . . 32 32 33 34 39 39 40 43 5 Verwaltung disjunkter Mengen 5.1 Schnelles Find . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5.2 Schnelles Union . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5.3 Pfadverkürzung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 45 45 47 48 6 Graphenalgorithmen 6.1 Darstellung von Graphen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 6.2 Zusammenhangskomponenten, Tiefen- und Breitensuche . . . . . . . . . . . . 6.3 Gerichtete Graphen: starke Zusammenhangskomponenten . . . . . . . . . . . 52 53 54 59 4 Mengenverwaltung 4.1 Baumdurchläufe . . . . . . . . . 4.2 Binäre Suchbäume . . . . . . . . 4.3 2-3-Bäume . . . . . . . . . . . . . 4.4 Hashing (Streuspeicherverfahren) 4.4.1 Hash-Funktionen . . . . . 4.4.2 Offenes Hashing . . . . . 4.4.3 Geschlossenes Hashing . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . i . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . INHALTSVERZEICHNIS 6.4 INHALTSVERZEICHNIS Kürzeste Wege . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 63 7 Turingmaschinen (Wiederholung) 67 8 NP-vollständige Probleme 8.1 P . . . . . . . . . . . . . 8.2 NP . . . . . . . . . . . . 8.3 SAT . . . . . . . . . . . 8.4 VC . . . . . . . . . . . . 8.5 Tripartites Matching . . . . . . . 71 71 72 73 74 76 . . . . . . . . . . . . . . 79 79 79 82 82 85 89 90 91 92 94 94 95 97 99 . . . . . 100 100 100 100 101 101 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 9 Entwurfstechniken für Algorithmen 9.1 Greedy Algorithmen . . . . . . . . . . . . 9.1.1 Minimale Spannbäume . . . . . . . 9.1.2 Ein einfaches Scheduling-Problem . 9.1.3 Huffman Codes . . . . . . . . . . . 9.1.4 Bin-Packing . . . . . . . . . . . . . 9.2 Divide and Conquer . . . . . . . . . . . . 9.2.1 Multiplikation ganzer Zahlen . . . 9.2.2 Matrixmultiplikation nach Strassen 9.2.3 Selektion in linearer Zeit . . . . . . 9.3 Dynamische Programmierung . . . . . . . 9.3.1 Alle kürzesten Wege . . . . . . . . 9.3.2 Multiplikation mehrerer Matrizen . 9.4 Backtracking . . . . . . . . . . . . . . . . 9.5 Probabilistische Algorithmen . . . . . . . 10 Fibonacci-Heaps und amortisierte 10.1 Motivation . . . . . . . . . . . . . 10.2 Amortisierte Zeit und Potentiale 10.3 Fibonacci-Heap . . . . . . . . . . 10.3.1 Operationen . . . . . . . . 10.3.2 Zeitanalyse . . . . . . . . Zeit . . . . . . . . . . . . . . . . . . . . ii . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . (1968) . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1 Grundlagen Abstrakte Datentypen fassen Typen (die für Mengen von Werten stehen) und getypte Operationen mit “vorgeschriebenen Effekten” zusammen. Z.B. haben Sie in Informatik I zur Darstellung von Mengen natürlicher Zahlen den Typ set mit den Operationen set emptyset(), bool iselem (nat, set), set insert (nat, set) etc. kennengelernt. Wir erwarten z.B. iselem (n, insert( n, M)) = true für jede Menge M. Eine Datenstruktur für einen solchen Datentyp legt genau fest, wie die Werte und die Operationen realisiert werden. Dabei kann z.B. set in unterschiedlicher Weise implementiert werden, z.B. als sortierte wiederholungsfreie Listen oder als Bitvektoren. Wird die Implementierung ausgetauscht, bleibt die Korrektheit von Programmen, welche den Datentyp verwenden, unberührt (Modularität, OOP); die Laufzeit oder der (Speicher-) Platzbedarf hingegen können sich ändern. In dieser Vorlesung sollen Sie effiziente Datenstrukturen kennenlernen, in denen die Daten möglichst geschickt und platzsparend organisiert werden, so dass die Operationen durch intelligente Algorithmen realisiert werden können, die in erster Linie möglichst wenig Zeit, in zweiter Linie aber auch möglichst wenig Platz benötigen. Dabei ist ein Algorithmus ein präzis formuliertes Verfahren, das in endlicher Zeit sein Ergebnis abliefert. Die Algorithmen in dieser Vorlesung sind oft Grundverfahren; sie bearbeiten Probleme, die stark von der Realität abstrahieren und demgemäß stark vereinfacht sind. Mit den Kenntnissen aus dieser Vorlesung sollten Sie aber in der Lage sein, auch realistischere Probleme effizient zu lösen. 1.1 Literatur M. A. Weiss: Data Structures and Algorithm Analysis in Java, z.B. 3. Aufl. 2012 Aho, Hopcroft, Ullman: Data Structures and Algorithms A. Gibbons: Algorithmic Graph Theory, 1985 T. Ottmann, R. Widmayer: Algorithmen und Datenstrukturen, 3. Auflage, 1996 R. Sedgewick, Wayne: Algorithmen. 2014 1.2 Prinzipien des Korrektheitsbeweis Wird ein Verfahren zur Lösung eines Problems vorgestellt, muss man das Verfahren als erstes verstehen und seine Korrektheit einsehen; für beide Fragen sind Schleifeninvarianten wichtig. Ein Verfahren, Programm oder Programmstück ist partiell korrekt, wenn gilt: Falls das Verfahren (ohne Fehlerabbruch) terminiert, liefert es das richtige Ergebnis. Es ist total korrekt, wenn es partiell korrekt ist und terminiert. Korrektheit ist vor allem ein Problem, wenn das Programm eine Schleife enthält oder rekursiv ist. Betrachten wir eine While-Schleife while (B) {S} – für For-Schleifen gilt entsprechendes. Um Terminierung zu zeigen, definiert man eine Terminierungsfunktion t, die die Werte der oder einiger Programmvariablen in der Regel nach N0 abbildet; dabei muss der Funktionswert zu Beginn eines Schleifendurchlaufs immer kleiner sein als zu Beginn des vorhergenden Schleifendurchlaufs – und es muss sichergestellt sein, dass S terminiert. Da die Werte nicht immer kleiner werden können, wird es irgendwann keinen nächsten Schleifendurchlauf geben. 1.2 Prinzipien des Korrektheitsbeweis 1 GRUNDLAGEN Statt N0 mit dem üblichen < kann man auch andere fundierte (well-founded) bzw. Noethersche Mengen nehmen, d.h. in denen es keine unendlichen absteigenden Ketten gibt; z.B. N0 × N0 mit der lexikographischen Ordnung oder mit komponentenweisem <. Um partielle Korrektheit zu zeigen, verwendet man Schleifeninvarianten – Aussagen, die bei jedem Beginn eines Schleifendurchlaufs wahr sind. Um sich überzeugen zu können, dass die Aussage P dies erfüllt, verlangt man im Prinzip: P wird durch S erhalten („invariant“), d.h. gilt P und wir führen S aus, so gilt P danach wieder. Gilt P zu Beginn, so gilt es nach Induktion zu Beginn jedes Schleifendurchlaufs, und insbesondere nach Beendigung der Schleife. Es fällt natürlich leichter zu beweisen, dass P am Ende gilt, wenn wir mehr voraussetzen dürfen; und tatsächlich wird der Schleifenrumpf S ja nur ausgeführt, wenn die Schleifenbedingung B gilt. Eine gleichermaßen ausreichende Anforderung ist demnach: Sind B und P wahr und wir führen S aus, so gilt P am Ende wieder. Auch aus dieser Anforderung folgt: Gilt P zu Beginn, so gilt es nach Induktion zu Beginn jedes Schleifendurchlaufs, und insbesondere nach Beendigung der Schleife. Weil die Anforderung schwächer ist („leichter zu beweisen“), wird sie von mehr Aussagen P erfüllt, und das ist praktisch wichtig. Ist die Schleife Teil eines Programms, so erreichen wir unser Ziel natürlich nur, wenn P auch tatsächlich beim Erreichen der Schleife wahr ist. Dies müssen wir nachweisen durch Betrachtung des vorhergehenden Programmtextes – und evtl. unter Verwendung von Annahmen an die Eingabewerte des Programms. Zusammengefasst: Ist die Schleife Teil eines Programms, so ist eine Aussage P eine Schleifeninvariante, wenn P • wahr ist, wenn die Schleife erreicht wird, • und nach Ausführung von S wahr sein, vorausgesetzt daß zuvor B und P wahr sind. Ob eine gegebene Aussage P eine Schleifeninvariante ist, läst sich oft vergleichsweise leicht prüfen. Konvention: Wenn man über den Wert von i beim Erreichen und dem am Ende der Schleife reden will, schreibt man für letzteren oft i′ .Die wirklich schwierige und kreative Aufgabe ist es, P zu finden. Hier liegt auch das wesentliche Problem für automatische Verifikationsverfahren. Ein wichtige Aufgabe in der Software-Entwicklung ist die Anpassung vorhandener Programme; dazu muss man zunächst ein solches Programm verstehen. Was das Programm tun soll, ist oft bekannt; das sagt einem aber nicht, was eine Schleife in dem Programm genau tut. Letzteres wird gerade durch eine Schleifeninvariante beschrieben. Ihre Angabe ist also eine unerlässliche Dokumentation!! Eine noch so schöne Schleifeninvariante bringt natürlich nichts, wenn sie nicht den Nachweis der partiellen Korrektheit unterstützt. Aufgrund der Definition gilt P nach Abschluss der Schleife (wenn sie denn je abgeschlossen wird); daraus müssen wir folgern, dass das Endergebnis des Programms richtig ist. Tatsächlich wissen wir wieder mehr als nur P : Am Schleifenende gilt zudem • Eine Schleifeninvariante P ist nützlich, wenn folgendes zutrifft: Gelten P und ¬B und wir führen das Restprogramm nach der Schleife aus, so ist das Ergebnis korrekt. 2 1.2 Prinzipien des Korrektheitsbeweis 1 GRUNDLAGEN Wenn wir eine nützliche Schleifeninvariante P finden (d.h. die obigen drei Punkte überprüft haben), so folgt die partielle Korrektheit. Beispiel: Ein Algorithmus ist z.B. ein Sortierverfahren wie Sortieren durch Einfügen (Insertion Sort), das Sie vermutlich schon kennen. Beim Sortierproblem soll eine Folge von Objekten/Datensätzen „aufsteigend “ sortiert werden. Oft ist diese Folge in Form eines Felds a gegeben, und im Allgemeinen sind die Komponenten von a Verbunde mit einer Komponente key, nach der sortiert werden soll. Für den Typ von key muss also < und > definiert, damit wir wissen, wie wir die Objekte sortieren sollen. Vereinfachend nehmen wir an, dass key eine ganze Zahl ist, und dass in a nur key gespeichert ist; gegeben ist uns also int a[n]. (Die Notation in diesem Skript orientiert sich an Java.) (1) (2) (3) (4) (5) int x,j; for (int i=1; i<n; i++) { x = a[i]; j = i; while ((j>0) && (a[j-1]>x)) { a[j] = a[j-1]; j--; } a[j] = x; } Zum Verständnis macht man sich klar, was ein typischer Schritt von Insertion Sort ist: Anfangsstück (zunächst a[0]) ist sortiert, das nächste Element a[i] wird durch Vergleichen (und ggf. Verschieben) von rechts in diesen Anfang einsortiert. Formal: Für die For-Schleife – d.h. immer wenn i < n in (1) überprüft wird – gilt die Schleifeninvariante „a[0] ≤ . . . ≤ a[i-1]“. Begründung: Dies gilt trivialerweise für i = 1 beim ersten Erreichen der Schleife; gilt es zu Beginn eines Schleifendurchlaufs, so wird a[i] einsortiert, am Ende gilt also a[0] ≤ . . . ≤ a[i], und da i′ = i + 1 (s. Konvention oben), gilt die Aussage am Ende der Schleife für i′ wieder – d.h. beim nächsten Erreichen von i < n in (1). (Man muss daran denken, dass die neue Zuweisung an i noch vor dem Erreichen von i < n in (1) und damit innerhalb der Schleife geschieht; die Situation wird evtl. klarer, wenn man die For-Schleife in eine While-Schleife übersetzt.) Weiter muss man die Aussage i ≤ n in die Schleifeninvariante aufnehmen; dies gilt zu Beginn und wegen der Abfrage i < n auch am Ende jeder Schleife (i′ ≤ n). Damit gilt am Ende i ≤ n und ¬(i < n) (d.h. B ist falsch); mit i = n ergibt die Schleifeninvariante, dass das Feld am Ende sortiert ist. Genaugenommen will man natürlich auch, dass es immer noch dieselben Elemente wie am Anfang enthält (es ist eine Permutation der Eingabe); formal müsste man dazu die Schleifeninvariante abändern, was wir uns hier aber ersparen. Eine formalere Behandlung von Korrektheitsbeweisen macht man im Hoare-Kalkül, s. Logik für Informatiker. Terminierungsfunktion für (3): Die While-Schleife terminiert also und damit der Rumpf der For-Schleife. Terminierungsfunktion für (1): Bemerkung: Verschiedene Verbunde können den gleichen key haben; erhält ein Sortierverfahren immer die Reihenfolge der “gleichwertigen” Komponenten, so heißt es stabil. (Sind die 3 1.3 Effizienz und Größenordnungen 1 GRUNDLAGEN Feldelemente wirklich nur ganze Zahlen, ist das natürlich egal.) Wie man sieht, ist Insertion Sort stabil. Hier noch ein kurzes Beispiel für einen Korrektheitsbeweis bei Rekursion: int f(int n,m) /* n,m > 0 */ { if (n == m) return n; else { if (n < m) return f(n,m-n); else return f(n-m,m); } } Diesmal bildet eine Terminierungsfunktion t die Parameterwerte z.B. nach N0 ab, wobei der t-Wert eines rekursiven Aufrufs immer kleiner ist als der t-Wert des Ursprungsaufrufs. Existenz einer Terminierungsfunktion garantiert offenbar die Terminierung. Im Beispiel: Rekursive Funktionen sind Fallunterscheidungen (s.o.): Enthält ein Zweig keinen rekursiven Aufruf (Terminierungszweig, Gesamtzahl von Aufrufen = 1), zeigt man partielle Korrektheit des Ergebnisses direkt; wird die Funktion insgesamt nur einmal aufgerufen, gilt also partielle Korrektheit. Weiter zeigt man, daß die anderen Zweige partiell korrekt sind, wenn man partielle Korrektheit der rekursiven Aufrufe annimmt (Gesamtzahl ist kleiner). Damit gilt nach Induktion über k: Wird die Funktion insgesamt k-mal aufgerufen, gilt Korrektheit. (Führt ein Aufruf zu rekursiven Aufrufen, so ist die Gesamtzahl der vom ersten Aufruf ausglösten Aufrufe gerade die Summe der Gesamtzahlen der rekursiven Aufrufe plus 1.) Dies zeigt gerade die partielle Korrektheit. Wir wollen mit diesem Prinzip zeigen, dass f unter der Annahme n, m > 0 den ggT berechnet. Im Terminierungszweig ist dies offenbar zutreffend. Betrachten wir nun den zweiten Zweig (der dritte ist analog): Da n < m, gilt n, m − n > 0 und wir können annehmen, dass f (n, m − n) = ggT (n, m − n). Ein Teiler von n und m − n teilt aber auch n + (m − n) = m, ein Teiler von n und m teilt auch m − n. (n, m) und (n, m − n) haben also dieselben gemeinsamen Teiler, damit auch denselben ggT und das Ergebnis ist korrekt. Dies zeigt partielle Korrektheit: Wenn die Rekursion bei Eingabe positiver natürlicher Zahlen terminiert, berechnet sie den ggT. Zusammen mit t haben wir totale Korrektheit. 1.3 Effizienz und Größenordnungen Wenn man ein Verfahren verstanden, d.h. insbesondere seine Korrektheit eingesehen hat, muss man sich Gedanken über die Effizienz machen, also über den benötigten Speicherplatz und die Laufzeit. Den Speicherplatz für die Eingabe, beim Sortieren also für das Feld a, zählt man oft nicht mit. Beim Sortieren soll oft darüberhinaus nur konstant viel weiterer Speicherplatz verwendet werden, er soll also vor allem unabhängig von der Länge von a sein. (Sortieren “am Platz”, “in situ”; Mergesort z. B. sortiert nicht am Platz.) Dabei nimmt man an, dass man jede ganze Zahl mit konstant Speicherplatz speichern kann; das ist natürlich nicht allgemein der Fall – aber zumindest für die Zahlen, die wir verwenden, soll es gelten. 4 1.3 Effizienz und Größenordnungen 1 GRUNDLAGEN Bei Insertion Sort ist der zusätzliche Speicherplatz in der Tat konstant: Wir brauchen nur drei int-Variablen. Wieviel Zeit dieses Programm benötigt, hängt natürlich einerseits von n und der WerteVerteilung in a ab, andererseits aber auch stark vom verwendeten Rechner; um die Effizienz zu bestimmen, nehmen wir daher einfach nur an, dass eine Addition, Subtraktion, Multiplikation und Division, aber auch ein Vergleich, eine Zuweisung und ein Zugriff auf ein Feldelement jeweils eine konstante (rechnerabhängige) Zeit brauchen. Demnach wird in jeder der numerierten Zeilen jeweils eine gewisse feste Zeit verbraucht; die Werte seien c1 , . . . , c5 . Zeile (1) wird n-mal, Zeilen (2) und (5) (n − 1)-mal durchgeführt, was zusammen nc1 + (n − 1)(c2 + c5 ) = n(c1 + c2 + c5 ) − (c2 + c5 ) dauert. Da wir die genauen Konstanten sowieso nicht kennen, kommt es uns bei diesem Wert nur auf das Wachstum mit wachsendem n an; wir interessieren uns für die asymptotische Laufzeit. Bei großem n spielt die Subtraktion von c2 + c5 sicher keine Rolle, der Aufwand für diese Zeilen ist also im wesentlichen cn für ein c, er ist also linear in n. Wie oft die Zeilen (3) und (4) ausgeführt werden, hängt nun aber auch von der WerteVerteilung ab: Ist a aufsteigend sortiert, bricht die Schleife jedesmal sofort ab (insgesamt Zeit (n − 1)c3 ); ist a absteigend sortiert, wird die Schleife 1 + 2 + . . . + (n − 1) = n(n − 1)/2 Mal ausgeführt (insgesamt Zeit (n − 1)( n2 (c3 + c4 ) + c3 )). Dies sind der beste und der schlechteste Fall. Man kann sich nun für den besten Fall (best case) interessieren; dann ist die Laufzeit linear. Das erscheint übertrieben optimistisch. Allerdings könnten in einer bestimmten Anwendung die Felder im Prinzip sortiert und nur leicht durcheinander gekommen sein; für solche weitgehend sortierten Felder ist Insertion Sort ein sehr schnelles Verfahren. Man könnte auch für jedes n die Laufzeiten für alle möglichen Werte-Verteilungen betrachten und den Durchschnitt berechnen. Wie kann das bei unendlich vielen int-Werten gehen?? Die konkreten a[i] sind hier nicht wichtig, nur ihre relativen Größen! Es gibt also . . . . . . viele Werte-Verteilungen, wenn alle Werte verschieden sind. Diese mittlere Laufzeit (average case) ist praktisch sehr relevant. Allerdings gibt es auch sicherheitskritische Anwendungen, bei denen man die Laufzeit in jedem Fall begrenzen will. Zudem ist die Bestimmung der mittleren Laufzeit oft sehr schwierig. Wir werden daher im wesentlichen die Laufzeit im schlechtesten Fall (worst case) betrachten. Diese sieht aber auch sehr unübersichtlich aus; grob hat sie aber die Form cn2 + c′ n + c′′ für Konstanten c, c′ , c′′ . Ähnlich wie oben können wir nun sagen, dass bei größeren n nur wichtig ist, dass sie quadratisch ist; wir sagen, dass sie in Θ(n2 ) („Theta von n-Quadrat“) ist, s.u. Mit dieser Sichtweise erhält man das Ergebnis leichter wie folgt: Die for-Schleife wird (n−1)mal durchlaufen. Neben der while-Schleife fallen die sonstigen konstanten Aufwendungen (1), (2) und (5) nicht ins Gewicht; diese wird beim i-ten Mal im schlechtesten Fall i-mal durchlaufen mit jeweils konstantem Aufwand. Wegen 1 + 2 + . . . + (n − 1) = n(n − 1)/2 ist der Aufwand also quadratisch. Noch leichter wird die Sache, wenn wir nur eine obere Schranke angeben wollen: Bei jedem der ≤ n Durchläufe der for-Schleife wird die while-Schleife ≤ n Mal durchlaufen mit jeweils konstantem Aufwand. Also ist der Aufwand höchstens quadratisch, O(n2 ) (Groß-Oh). 5 1.3 Effizienz und Größenordnungen 1 GRUNDLAGEN Es sei noch angemerkt: Für wirklich große Zahlen ist Multiplikation aufwendiger als Addition; tatsächlich werden wir uns noch mit der Multiplikation langer Zahlen befassen. Unsere Annahme, dass jede Multiplikation konstante Zeit kostet, gilt also nur, wenn wir darauf achten, dass die Zahlen nicht zu groß werden. Wir wollen noch kurz als zweites Beispiel binäre Suche betrachten. Man kann eine Menge auch verwalten, indem man ihre Elemente in ein Feld einträgt. Um zu entscheiden, ob ein Wert x enthalten ist, muss man dann wie bei einer Liste die (besetzten) Zellen des Felds der Reihe nach durchsuchen, der Aufwand ist also O(. . . . . .), wobei Um dies zu verbessern, kann man das Feld sortiert halten. Um nun nach x zu suchen, teilt man das (benutzte) Feld in der Mitte, stellt durch Vergleich mit einem Element an der Grenze fest, in welcher Hälfte x liegen würde, und wiederholt dies. Da der Aufwand bei jeder Teilung konstant ist, dauert das Suchen jetzt O(. . . . . .). Wie fügen wir ein neues Element ein? Wir wollen nun die Schreibweisen Θ(n2 ) und O(n2 ) genauer einführen. Grundsätzlich gilt: je größer die Eingabe (z.B. je mehr Zahlen wir sortieren sollen), desto größer die Laufzeit, die wir jetzt in Schritten (Addition,. . . , Zuweisung . . . ) messen wollen. Üblicherweise wird die Größe der Eingabe mit n bezeichnet. Die Effizienz eines Algorithmus A ist eine Funktion f : N0 → N, n → maximale Laufzeit von A bei Eingabegröße n. Dies ist die Effizienz im schlechtesten Fall (worst case complexity). Statt f : n → n2 schreiben wir n2 , statt f : n → n log n schreiben wir n log n, was genauer f : n → n ⌈log n⌉ bedeutet, wobei immer ⌈log n⌉ ≥ 1 sein soll, u.ä. Damit vermeiden wir, dass eine Laufzeit n log n für n = 1 0 ist – was sie natürlich nicht ist – und n log log n für n = 1 sogar undefiniert. Definition 1.1. O(g) = {f : N0 → N | ∃c, d ∈ N ∀n : f (n) ≤ cg(n) + d} f ∈ O(g) bedeutet, daß f größenordnungsmäßig – d.h. asymptotisch (vgl. 1.3 i)) bis auf einen konstanten Faktor/Summanden (vgl. 1.3 iii)) – ≤ g. Beispiel 1.2. i) Für l ≤ k ist nl ∈ O(nk ), denn nl ≤ nk . ii) O(1) = {f : N0 → N | ∃c, d ∀n : f (n) ≤ c · 1 + d} ist die Menge aller konstant nach oben beschränkten Funktionen. iii) ∀a, b > 1 O(loga n) = O(logb n), denn loga n = (loga b) logb n. Wir schreiben einfach log n und meinen im Zweifel log2 n. iv) log n ∈ O(n) Man schreibt oft f = O(g) statt f ∈ O(g); n = O(n) und n = O(n2 ) (s. Bsp. i)) suggerieren O(n) = O(n2 ) — falsch! Proposition 1.3. i) Ist f ∈ O(g) und sind f und f ′ nur an endlich vielen Stellen verschieden, so ist f ′ ∈ O(g). ii) Sei a > 0. f ∈ O(g) ⇒ af + b ∈ O(g). iii) Sei a > 0. f ∈ O(g) ⇔ af + b ∈ O(g) ⇔ f ∈ O(ag + b). 6 1.3 Effizienz und Größenordnungen 1 GRUNDLAGEN Beweis. i) Sei f ≤ cg + d und d′ der maximale f ′ -Wert der “Stellen”, so ist f ′ ≤ cg + d + d′ . ii) f ≤ cg + d ⇒ af ≤ acg + ad ⇒ af + b ≤ acg + ad + b. iii) ähnlich ✷ Proposition 1.4. i) f ∈ O(f ) ii) f ∈ O(g), g ∈ O(h) ⇒ f ∈ O(h) – partielle Ordnung iii) f ∈ O(g) ⇔ O(f ) ⊆ O(g) iv) f ∈ O(g), f ′ ∈ O(g ′ ) ⇒ f + f ′ ∈ O(g + g ′ ) v) f, f ′ ∈ O(g) ⇒ f + f ′ ∈ O(g) vi) f + g ∈ O(max(f, g)) Beweis. Übung ✷ Korollar 1.5. Sei p ein Polynom vom Grad k; dann ist p ∈ O(nk ) P Beweis. Sei p(n) = ki=0 ai ni . Dann ist ai ni ∈ O(nk ) nach Bsp. 1.2 i) und Proposition 1.3 ii); Behauptung folgt aus 1.4 v). ✷ Insertion Sort hat Laufzeit f (n) ≤ cn2 + c′ n + c′′ , also O(n2 ). Wir werden sehen: Auch Quicksort sortiert in O(n2 ), d.h. n Daten werden auf jeden Fall größenordnungsmäßig in ≤ n2 Schritten sortiert. Sortiert A in O(n log n), so ist A “besser” als Q.; aber: • evtl. nur für große n (z.B. n ≥ 21000 ) • average case Effizienz von Quicksort ist O(n log n) • konstanten Faktor kann man zwar durch schnelleren Rechner ausgleichen, ist aber doch relevant Beispiel: Man habe die Wahl zwischen einem O(2n )-, einem O n2 - und einem O (n log n)Verfahren. Annahme: 106 Operationen sind zeitlich akzeptabel, und die Verfahren brauchen 2n , n2 bzw. 100n log n Operationen: Max. Eingabe 2n n2 100n log n alt neu(4.8) neu(48) ≈ 20 1000 ≈ 1000 ≈ 22 ≈ 2200 ≈ 4000 ≈ 25 ≈ 7000 ≈ 32000 Wird der Rechner schneller, z. B. 4.8 oder 48mal so schnell, dann wachsen auch die Anforderungen. Die Tabelle zeigt: je schneller der Rechner, um so mehr lohnt sich ein asymptotisch besseres Verfahren. f ∈ O(g) ⇐⇒: g ∈ Ω(f ) (untere Schranke) f ∈ O(g) ∧ g ∈ O(f ) ⇐⇒: f ∈ Θ(g) (gleiche Größenordnung) f ∈ O(g) ∧ g ∈ / O(f ) ⇐⇒: f ∈ o(g) (echt kleinere Größenordnung) Beispiel: logk n ∈ o(n) 7 1.4 Bäume 1.4 1 GRUNDLAGEN Bäume Ein Baum T besteht aus einem Knoten, der Wurzel, und einer Folge von Bäumen, den unmittelbaren Teilbäumen oder Ästen. Wurzel w w 1 w w 2 n Kinder Ast Bemerkung rekursive Definition; Terminierungsfall? Die Wurzeln der Äste heißen Söhne oder Kinder der Wurzel, diese ist ihr Vater (parent); sie sind Brüder voneinander; für i < j ist wi jünger als wj . Kinder und Kindeskinder etc. heißen Nachkommen. Ein Baum T ′ ist Teilbaum von T , wenn T = T ′ oder T ′ Teilbaum eines Astes von T ist. • Grad eines Knotens: Anzahl der Kinder • Blatt = Knoten vom Grad 0; sonst: innerer Knoten • Grad von t: maximaler Knotengrad in t • Höhe eines Baumes: Länge des längsten Weges (Anzahl der Kanten) von der Wurzel zu einem Blatt • Höhe eines Knotens v: Höhe des Teilbaumes mit Wurzel v 3 ✔❆ ✔✔ ❆❆ 1 2 ✁▲ ✁✁ ▲▲ ✆ ✆ ✆ 0 1 0 ❇ ❇ ❇ 0 • Tiefe eines Knotens v: Weglänge von der Wurzel zu v 0 ✔❆ ✔✔ ❆❆ 1 ✁▲ ✁✁ ▲▲ 2 1 ✆ ✆✆ 2 2 ❇ ❇ ❇ 3 8 1.4 Bäume 1 GRUNDLAGEN • Tiefe eines Baumes: Die maximale Tiefe eines Baumes entspricht der maximalen Tiefe eines Blattes, also der Höhe. • Durchschnittliche Tiefe eines Baumes: Die durchschnittliche Tiefe eines Baumes T ist als die durchschnittliche Tiefe seiner = 2, 3 Blätter definiert, im Beispiel: 2+2+3 3 • Binärbaum: Baum vom Grad ≤ 2; oft unterscheidet man linken und rechten Sohn, so dass ein Knoten mit einem Sohn entweder einen linken oder einen rechten Sohn hat • voller Binärbaum: Jeder Knoten hat genau zwei Söhne (“innerer Knoten”) oder keinen Sohn (“Blatt”). • vollständiger Binärbaum: voller Binärbaum, bei dem alle Blätter dieselbe Tiefe haben • vollständig ausgeglichener Binärbaum: entsteht aus einem vollständigen Binärbaum, indem man Blätter von rechts entfernt Zu jeder Knotenzahl n gibt es genau einen vollständig ausgeglichenen Binärbaum. 9 1.4 Bäume 1 GRUNDLAGEN Lemma 1.6. Ein Baum vom Grad ≤ c mit c > 1 und der Tiefe ≤ d hat höchstens cd Blätter und (cd+1 − 1)/(c − 1) Knoten. Beweis: Induktion über d: d = 0: Der Baum besteht nur aus der Wurzel und hat somit genau ein (= c0 ) Blatt und einen (= c−1 c−1 ) Knoten. d > 0: Der Baum besteht aus der Wurzel und bis zu c Teilbäumen, jeweils mit Tiefe ≤ d − 1 und Grad ≤ c, d. h. mit jeweils ≤ cd−1 Blättern und ≤ (cd − 1)/(c − 1) Knoten. Zusammen sind dies ≤ ccd−1 = cd Blätter und ≤ 1 + c(cd − 1)/(c − 1) = (cd+1 − c + c − 1)/(c − 1) = (cd+1 − 1)/(c − 1) Knoten. ✷ Lemma 1.7. Ein binärer Baum mit l Blättern hat mindestens Tiefe log(l). Beweis: Folgt direkt aus Lemma 1.6. Lemma 1.8. Knoten. ✷ i) Ein vollständiger Binärbaum der Tiefe d hat 2d Blätter und 2d+1 − 1 ii) Für einen vollständig ausgeglichenen Binärbaum T der Tiefe d mit n Knoten und b Blättern gilt: d = ⌈log(n + 1)⌉ − 1 und b = (n + 1) div 2, d.h. n − b = n div 2. Beweis: i) wie 1.6. ii) d = 0 ok. Sei d > 0. T besteht aus einem vollständigen Binärbaum T1 der Tiefe d− 1 und b′ zusätzlichen Blättern. Nach i) ist 2d − 1 < n ≤ 2d+1 − 1, d.h. d + 1 = ⌈log(n + 1)⌉. Ferner ist n = 2d − 1 + b′ , und damit (n + 1) div 2 = 2d−1 + b′ div 2. Von den 2d−1 Blättern von T1 werden b′ div 2 in T ‘verdoppelt’, d.h. b = 2d−1 + b′ div 2. Damit ist (s.o.) (n + 1) div 2 = 2d−1 + b′ div 2 = b. ✷ d-1 T1 b' 10 1.5 Schichtnumerierung 1 GRUNDLAGEN Lemma 1.9. Ein binärer Baum mit l Blättern hat mindestens die durchschnittliche Tiefe log(l). 1.5 Schichtnumerierung 1 2 3 4 8 6 5 9 10 11 7 12 Bei vollständig ausgeglichenem Binärbaum hat der Knoten mit der Nr. i (ggf.) Kinder mit Nr. 2i und 2i + 1. Denn: Knoten i habe Tiefe d ≥ 1 und mind. ein Kind. Oberhalb von i ist ein vollständiger Binärbaum der Tiefe d − 1 mit 2d − 1 Knoten. In derselben Tiefe wie i = 2d − 1 + r + 1 liegen 2d Knoten. Also ist s = 2d+1 − 1 + 2r + 1 = 2i. d-1 d 2 -1 r 2r i s s' 11 1.5 Schichtnumerierung 1 GRUNDLAGEN Markierte / beschriftete Bäume (z.B. Suchbäume) 1 9 2 20 5 3 6 10 4 5 6 3 Linearisierung: Abspeichern in einem Feld Index: Nr. der Schichtnumerierung Inhalt: Beschriftung 0 9 5 20 3 6 10 1 2 5 3 4 6 Bei vollständig ausgeglichenen Binärbäumen läßt sich der markierte Baum rekonstruieren: Wurzel hat Index 1, Knoten mit Index i hat Kinder mit Index 2i und 2i + 1 soweit vorhanden. 12 2 LISTEN, KELLER UND WARTESCHLANGEN 2 Listen, Keller und Warteschlangen Zu Beginn der Vorlesung wurde schon kurz der abstrakte Datentyp set (mit emptyset, isempty, iselem etc.) erwähnt, den man als Bitvektor (bei endlicher, nicht zu großer Grundmenge) oder als aufsteigend sortierte Liste implementieren kann. Andere einfache Datentypen sind Keller (bekannt aus Info II und TI) und Warteschlangen. Keller (stack): - Stack(): erzeugt einen leeren Keller - push(x): legt x „oben auf“ den Keller - pop(): liefert das „oberste“ Kellerelement und entfernt dies - isempty() Abb. 1 zeigt, wie ein Keller sich bei der Folge push(i), push(t), pop (liefert t), push(n), push(u) entwickelt. Wiederholtes pop liefert jetzt den Kellerinhalt in umgekehrter Eingabefolge – ein Keller folgt dem LIFO-Prinzip (last in, first out). u t i i i n n i i Abbildung 1 Wie Sie schon in Informatik II gehört haben, kann man einen Keller bei bekannter maximaler Elementzahl mit einem Feld realisieren; next gibt jeweils den Index der nächsten freien Zelle von A an. public class Stack { private int[] A ; private int next ; public Stack (int n) { A = new int[n] ; next = 0 ; } public void push (int x) /* not isfull */ { A[next++] = x ; } public int pop () /* not isempty */ { return A[--next] ; } public boolean isempty () 13 2 LISTEN, KELLER UND WARTESCHLANGEN { return (next == 0) ; } } Offenbar kann jede der Operationen in O(1), d.h. in konstanter Zeit unabhängig von der Anzahl der gespeicherten Elemente ausgeführt werden. Kennt man keine obere Schranke für die Anzahl der zu speichernden Elemente, benötigt man eine dynamische Datenstruktur. Wir beschäftigen uns daher zunächst mit Listen und kommen später auf den Keller zurück. Es gibt verschiedene Varianten von Listen; hier werden wir doppelt verkettete Listen betrachten. public class L2Elem { public int value ; public L2Elem left, right ; } 14 42 17 101 Abbildung 2 Die Liste selbst ist ein Kopfverbund (header) mit zwei Zeigern auf das erste und das letzte Element, ggf. beide null (Konstruktor List2() ). 14 2 LISTEN, KELLER UND WARTESCHLANGEN public class List2 { private L2Elem fst, lst ; public boolean isempty () { return (fst == null) ; } public void prefix (int v) { L2Elem l = new L2Elem() ; l.value = v ; l.right = fst ; if (isempty()) fst = lst = l ; else { fst.left = l ; fst = l ; } } public void postfix (int v) { L2Elem l = new L2Elem() ; l.value = v ; l.left = lst ; if (isempty()) fst = lst = l ; else { } public int pop() /* not isempty */ { int x = fst.value ; fst = fst.right ; if (isempty()) lst = null ; else { fst.left = null ; } return x ; } public boolean iselem(int x) { L2Elem l = fst ; while (l != null) if ( l.value = x) return true ; else l = l.right ; return false ; } } 15 2 LISTEN, KELLER UND WARTESCHLANGEN Der Aufwand der ersten vier Operationen ist O(. . .), der von iselem ist O(. . .) Mit List2 können wir einen Keller implementieren, wobei push durch prefix realisiert wird. Warteschlange (queue): - Queue(): erzeugt eine leere Warteschlange - enq(x): hängt x hinten an die Warteschlange an (enqueue) - deq(): liefert das erste Element der Warteschlange und entfernt dies (dequeue) - isempty() Eine Warteschlange folgt dem FIFO-Prinzip (first in, first out). Sie kann mit List2 implementiert werden: enq wird durch postfix, deq durch pop realisiert. Dynamische Datenstrukturen erfordern im Vergleich zu einer Implementierung mit einem Feld immer einen gewissen Extra-Aufwand: Speicherplatz organisieren und wieder freigeben, Speichern und korrekte Verwaltung der Verweise. (Allerdings muss man beim Feld immer den maximal benötigten Speicherplatz bereithalten.) Warteschlange als Feld: Bei enq wird eine Zelle nach der anderen gefüllt, bei deq wird unabhängig davon eine Zelle nach der anderen geleert. Man braucht also einen Zeiger, der auf das erste Element zeigt, und eine Variable length, aus denen man die nächste freie Zelle berechnen kann; an length kann man ablesen, ob die Warteschlange leer oder ganz voll ist. Die Position der Warteschlange wandert also im Feld langsam nach rechts. Wenn man am Ende ankommt, 16 3 SORTIEREN 3 Sortieren 3.1 Einfache Sortierverfahren Neben dem Sortieren durch Einfügen (Insertion Sort), s.o., beschreiben wir kurz: Sortieren durch Minimumssuche (Selection Sort) • typischer Schritt: Anfangsstück (zunächst leer) ist sortiert und enthält die kleinsten Elemente. (Dies ist wieder eine (Schleifen-)Invariante.) Suche minimales Element im Rest und füge es an das Anfangsstück an – um die Zahl der Vertauschungen klein zu halten, mit 1 Vertauschung. • immer (n − 1) + (n − 2) + . . . + 1 Vergleiche, also ist selbst der beste Fall Θ n2 • stabil? 3.2 Quicksort 1. wähle sog. Pivot(-Element) x in a 2. partitioniere a in zwei Teile mit Elementen ≤ x im linken und ≥ x im rechten Teil 3. sortiere die Teile genauso Bemerkung: Beispiel für das Prinzip „divide and conquer“ (teile und herrsche, divide et impera), nach dem man eine Aufgabe aufteilt, die Teile rekursiv löst und aus diesen Lösungen die Gesamtlösung ermittelt. (Der letzte Schritt ist bei Quicksort trivial.) Zu 2.: Zwei Zähler (Zeiger) i und j durchlaufen das Feld von links bzw. von rechts (bis sie sich treffen); jedesmal wenn dabei a[i] > x und a[j] < x, werden a[i] und a[j] vertauscht. Der Aufwand ist also linear: c · n Ist x immer das kleinste Element, spaltet man jedes Mal einfach nur 1 Element ab; der Gesamtaufwand ist also c · n + c · (n − 1) + . . . + c · 2, d. h. O n2 . Dieser Fall tritt auch ein, wenn alle Elemente gleich sind – “i hält alle Elemente für klein”. Besser: auch tauschen, wenn a[i] = x bzw. a[j] = x, um gleichmäßig zu teilen. Zu 1.: essentiell! Zufällige Wahl von x ist aufwendig, evtl. auch nicht wirklich zufällig. x := a[0] ist praktisch ungünstig, da die Eingabe oft ein wenig vorsortiert ist; dann ist die Aufteilung jedesmal sehr ungleichmäßig. Optimal wäre der Median (der Größe nach n2 -tes Element), dessen Bestimmung aber aufwendig ist. Median-of-3 sortiert das erste, letzte und mittlere Element in a; der mittlere Wert dient als x. Dabei soll swap(a,l,r) die Elemente a[l] und a[r] vertauschen. 17 3.2 Quicksort 3 SORTIEREN int median3(int[] a, int l, int r) { int m = (l+r)/2; if (a[l] > a[m]) swap(a,l,m); if (a[l] > a[r]) swap(a,l,r); /* kleinstes links */ if (a[m] > a[r]) swap(a,m,r); swap(a,m,r-1); /* Pivot verstecken */ return a[r-1]; } Damit steht links ein kleines und rechts ein großes Element als Wächter (sentinel); damit laufen j und i nicht über den Rand des Feldes, ohne dass wir dies jedesmal durch einen Vergleich mit l bzw. r prüfen müssen (s.u. Bsp.). Als Ausgleich für den Aufwand in Medianof-3 beginnen die Vergleiche mit x bei a[l + 1] bzw. a[r − 2]; das Pivot kommt zum Schluss in die Mitte und muss in keiner Hälfte berücksichtigt werden. Für kleine Felder (die gegen “Ende” der Rekursion zahlreich auftreten), ist Quicksort zu aufwendig ( – außerdem: was ist Median-of-3 bei zwei Elementen?); besser: Sortieren durch Einfügen. Als cutoff wird ungefähr 10 empfohlen. Organisatorisch gut: kleine Teilfelder unsortiert lassen und zum Schluß einmal Sortieren durch Einfügen. void quicksort (int[] a, int n) { qsort (a,0,n-1); insertsort (a,n); } void qsort (int[] a, int l, int r) { int i,j; int x; /* Element-Typ */ if (l+cutoff <= r) { x= median3(a,l,r); i=l; j=r-1; for ( ; ; ) { while (a[++i] < x) {} while (a[--j] > x) {} if (i<j) swap(a,i,j); else break; } 18 3.2 Quicksort 3 SORTIEREN swap(a,i,r-1); /* restore pivot */ qsort (a,l,i-1); qsort (a,i+1,r); } } Tip: Code von swap explizit einfügen. Bsp. 8 7 4 9 0 3 5 2 1 6 ↑ ↑ ↑ median-of-3 → 6 0749135268 ↑ ↑ i j ↑ ↑ i j // Wächter 0249135768 ↑ ↑ i j 0245139768 ↑↑ j i restore pivot 9 8} 0| 2 4{z5 1 3} 6 7| {z beachte: Jetzt lässt Insertion Sort automatisch die gefundene Teilung unberührt; es führt z.B. auf den Zwischenzustand 0 1 2 3 4 5 | 6 7 9 8 Aufwand: Im schlechtesten Fall werden jeweils mit linearem Aufwand zwei Elemente abgespalten. (median-of-3: z.B. das kleinste und das zweitkleinste) Also: O(n2 ) Warum linearer Aufwand? Die Zuweisungen an x, i und j, das letzte swap und die rekursiven Aufrufe (ohne Durchführung) erfordern nur konstante Zeit. In der for-Schleife durchlaufen i und j maximal den Bereich von l bis r, wobei für jeden i- bzw. j-Wert höchstens einmal inkrementiert/dekrementiert, eine while- und eine if-Bedingung geprüft sowie einmal geswappt wird; dieser Aufwand ist proportional zur Bereichslänge, d.h. linear. Der beste Fall Das Feld wird immer in der Mitte geteilt (ohne Beweis), für die Laufzeit BT gilt im Prinzip: BT (n) = 2 · BT n2 + c · n 19 3.3 Untere Schranke für vergleichsbasierte Verfahren 3 SORTIEREN Also: (für n = 2k ) BT (n) n BT n2 n 2 BT (2) 2 = = BT n 2 BT n 4 .. . = n 2 n 4 +c +c BT (1) +c 1 Insgesamt gibt es log n Gleichungen dieser Art. Einsetzen ergibt: BTn(n) = BT1(1) + c · log n, d.h. BT (n) ∈ O (n log n). Für allgemeines n kann man die Laufzeit wegen Monotonie durch die Laufzeit der nächsten 2er-Potenz abschätzen: Ist n ≤ 2k < 2n, so ist BT (n) ≤ BT (2k ) ∈ O(2k log 2k ) ⊆ O(2n log(2n). Wegen 2n log(2n) = 2n log n + 2n ∈ Θ(n log n) erfordert der beste Fall also für jedes n O (n log(n)); dies gilt auch im Durchschnittsfall mit recht kleinen Konstanten. Trotz Aufwands O(n2 ) im schlechtesten Fall ist Quicksort das praktisch beste Sortierverfahren! Quickselect Zur Bestimmung des k-größten Elementes von a verwendet man Quicksort, sortiert aber jeweils nur den passenden Teil soweit nötig. worst case: O n2 (es werden mit linearem Aufwand 2 Elemente abgespalten) im Schnitt: O(n), auch bei der Medianbestimmung 3.3 Untere Schranke für vergleichsbasierte Verfahren Es soll jetzt eine untere Zeitschranke für Sortierverfahren angegeben werden, welche die zu sortierenden Elemente nur untereinander vergleichen und vertauschen. Man nimmt an, daß alle Elemente verschieden sind. Dazu definiert man den Entscheidungsbaum eines Verfahrens (für ein n), einen Binärbaum, dessen • Knoten jeweils einen Zwischenzustand durch die Menge aller Anordnungen beschreiben, die mit den bisherigen Vergleichen konsistent sind • Kanten aus Knoten v mit den Ergebnissen des nächsten Vergleiches nach v beschriftet sind Ein Ablauf des jeweiligen Verfahrens entspricht einem Pfad von der Wurzel zu einem Blatt; das Blatt darf nur eine Anordnung enthalten, denn keine zwei Anordnungen lassen sich durch dieselben Vertauschungen sortieren. Der Entscheidungsbaum hat also ≥ n! Blätter. Beispiel: median3 sortiert 3 Elemente folgendermaßen: { if (a[1] > a[2]) swap(a,1,2); if (a[1] > a[3]) swap(a,1,3); /* kleinstes links */ 20 3.4 Heapsort 3 SORTIEREN if (a[2] > a[3]) swap(a,2,3); } Ausschnitt des zugehörigen Entscheidungsbaums in Abb. 3: b1 b2 b3 b1<b2<b3, b1<b3<b2, ..., b3<b2<b1 b1< b2 b1 > b2 b2 b1 b3 b2<b1<b3, b2<b3<b1, b3<b2<b1 b2< b3 b2 b1 b3 b3< b2 b2<b1<b3, b2<b3<b1 b3<b2<b1 b3 b1 b2 Abbildung 3 Satz 3.1. Ein Sortierverfahren, das Elemente nur vergleicht und vertauscht, benötigt i) ≥ log(n!) Vergleiche im schlimmsten Fall und im Durchschnitt ii) Ω(n log n) Zeit im schlimmsten Fall und im Durchschnitt Beweis: i) folgt aus Lemmata 1.7 und 1.9 ii) folgt aus i), denn: n n P P log(n!) = log i ≥ n2 log n2 = log i ≥ i=1 i=n/2 n 2 log n − Bem.: n P • log(i) ≤ n log(n) – die Abschätzung ist optimal n 2 ∈ Ω(n log n) ✷ i=1 • Quicksort hat unter vergleichsbasierten Sortierverfahren optimale Durchschnittseffizienz. 3.4 Heapsort 3.4.1 Halden bilden eine nicht nur für Heapsort, sondern oft nützliche Datenstruktur. Eine (Max-)Halde (heap) ist ein markierter vollständig ausgeglichener Binärbaum, so daß: die Markierung eines Knotens ist immer ≥ den Markierungen seiner Kinder (ggf.), wobei ≥ eine totale Ordnung auf der Menge der Markierungen Min-Halden sind analog definiert. Für Max-Halden gilt: – jeder Knoten trägt das Maximum seines Teilbaums; 21 3.4 Heapsort 3 SORTIEREN – jeder Teilbaum ist auch eine Halde; – jeder Baum mit nur einem Knoten ist eine Halde. Ein Feld heißt Halde, wenn es Linearisierung einer Halde ist (vgl. Schichtnumerierung); äquivalent: int a[n+1] ist Halde, gdw. grbed(a, i, n) für i = 1, . . . , n gilt, wobei grbed(a, i, j) ≡ (2i + 1 > j || a[i] ≥ a[2i + 1]) && (2i > j || a[i] ≥ a[2i]) Intuitiv: Die Haldenbedingung gilt für den Knoten i, wenn die Halde in a[1] bis a[j] abgelegt ist; wäre ein oder beide Kinder > j, so hat i nur ein oder kein Kind, das die Haldenbedingung verletzen könnte. Man beachte, daß a[0] ungenutzt bleibt und insbesondere || das sequentielle Oder ist. 1.Ziel: Feld a durch Vertauschungen in Halde umwandeln; dazu: Einbettung (in ein allgemeineres Problem): Haldenbedingung für Teilbereiche definieren und solche Teilbereiche ausweiten Definition: heap(a, i, j) bedeutet grbed (a, k, j) für k = i, . . . , j. Teilbäume sind Halden w i j Abbildung 4 Also ist a Halde, gdw. heap(a, 1, n). Zunächst: Alle Blätter sind Halden; es gilt also heap(a, ⌊n/2⌋ + 1, n), vgl. Lemma 1.8; die hintere Feld-Hälfte ist also ok. (Wie sieht man das anhand der Definition?) Gesucht: „Vertauschung“ sift(a, i, j), die aus heap(a, i, j) heap(a, i − 1, j) macht; dann ist buildmaxheap: for(i= n/2 + 1; i>1; i--) /* heap(a,i,n) */ sift(a,i,n); // heap(a,1,n) Bem.: Zu Beginn und nach jedem Schleifendurchlauf gilt die Schleifeninvariante heap(a, i, n); am Ende gilt daher heap(a, 1, n). sift(a, i, j) muß Teilbaum mit Wurzel w = i−1 (Abb. 4) in Halde umwandeln. Dazu muß bei w das Maximum des Teilbaums stehen; die Kinder tragen schon die Maxima ihrer Teilbäume; also: w hat größeren Wert als 2w und 2w + 1 oder w tauscht mit maximalem Kind, d.h. der Wert von w rieselt nacht unten. Es gilt die Invariante P: heap(a, i − 1, j) außer dass die Haldenbedingung beim jeweiligen Wert von w verletzt sein kann. Damit ist sift(a, i, j): 22 3.4 Heapsort 3 SORTIEREN /* heap(a,i,j) && i>1*/ for (w= i-1, s= 2*w /* P */; s<=j; w=s, s= 2*w /* P */) { if (s<j && a[s] < a[s+1]) s++; // s ist größtes Kind if (a[w] < a[s]) swap(a,w,s); else break; /* heap(a,i-1,j) */ } /* heap(a,i-1,j) oder (P && s>j), also heap(a,i-1,j) */; Beispiel: 2 5/2 +1 = 3 2 4 8 7 1 4 8 7 sift(a,3,5) 2 7 8 4 1 1 8 7 sift(a,2,5) 8 7 2 4 1 4 2 1 Aufwand für sift(a,i,j) je Schicht zwischen w = i − 1 und j in Abb. 4 ein „Schritt“ i − 1 ist in Schicht ⌈log i⌉ − 1 nach Lemma 1.8 j ist in Schicht ⌈log(j + 1)⌉ − 1 also: ungefähr log j − log i + 1 = log(j/i) + 1 Aufwand für buildmaxheap Z.B. braucht sift(a, 2, n) O(log n) Zeit, also zusammen O((n/2) log n); besser: P n P∞ i i Zusammen: log i=1 (n/2 )i ≤ n i=1 (i/2 ) = 2n; demnach arbeitet buildmaxheap in O(n). 23 3.4 Heapsort 3 SORTIEREN Maximale Schrittzahl bei sift 1 Element mit log n 2 Elemente mit log (n/2) n/8 Elemente mit 3 n/4 Elemente mit 2 n/2 Elemente mit 1 3.4.2 Heapsort Wir gehen hier von einem Feld int a[n+1] aus, bei dem a[0] irrelevant ist und a[1] bis a[n] sortiert werden sollen. buildmaxheap; for (j= n; j>1; ) deletemax; Dabei ist deletemax: /* heap(a,1,j) && j>1*/ swap(a,1,j]); // heap(a,2,j-1) j--; // heap(a,2,j) sift(a,2,j); // heap(a,1,j) maximale Elemente sortiert Halde 1 j Aufwand: deletemax: O(log j) (für sift) P P Heapsort: O(n + nj=1 log j) ⊆ O(n + nj=1 log n) = O(n log n) Heapsort sortiert also optimal. Bemerkung: Quicksort und Heapsort brauchen im Schnitt O(n log n); dabei hat Quicksort einen besseren konstanten Faktor als Heapsort; es gibt aber auch Verbesserungen von Heapsort. Heapsort ist nicht stabil und macht sich bei weitgehend sortierten Feldern sehr viel Arbeit (da es große Elemente zunächst nach vorn bringt). 24 3.4 Heapsort 3.4.3 3 SORTIEREN Vorrangswarteschlangen (priority queue) Speicherung von beschränkt vielen Elementen, die je nach Priorität zu bedienen sind; Operationen: i) makeemptyprio, isemptyprio ii) Finden und Entfernen des Maximums iii) Einfügen eines neuen Elements Implementierung durch Halden (wobei evtl. auch buildmaxheap zum Aufbau herangezogen werden kann): i) ok (langes Feld und ? ) ii) wie deletemax iii) Idee: analog zu sift, neues Element hinten anfügen und aufsteigen lassen; Übung! Priorityqueues mit bis zu n Elementen unterstützen: i) makeemptyprio, isemptyprio in O(1) ii) deletemax und insert in O(log n) iii) Aufbau aus einem Feld in O(n) Anwendung z.B. bei Halten von Bestenlisten bei fortlaufend anfallenden Ergebnissen; s.a.u. 25 3.5 Bucket Sort, Radix Sort 3.5 3 SORTIEREN Bucket Sort, Radix Sort Satz 3.1 ist nicht immer anwendbar. Will man z.B. kleine natürliche Zahlen ai ∈ {0, . . . , m} sortieren, so verwendet man Bucket Sort, das für int bucket[m+1] • bucket mit Nullen initialisieren O(m) • ai ’s durchlaufen und jeweils bucket[ai ] um 1 erhöhen O(n) • bucket durchlaufen und jeweils bucket[i]-mal i ausgeben O(m + n) Satz 3.2. Bucket Sort sortiert in O(m + n); bei n ∈ Ω(m) also in linearer Zeit. Sind die ai Verbunde, so Erweiterung: ai ∈ {0, . . . , m}2 , (ai1 , ai2 ) < (aj1 , aj2 ) ⇐⇒ ai1 < aj1 ∨(ai1 = aj1 ∧ai2 < aj2 ) (lexikographische Ordnung) • Bucket Sort zunächst nach ... Komponente sortieren lassen, • dann nach ... Komponente Beispiel: m = 2 a: (0,2) (2,2) (2,1) (1,2) Zeit: O(2(m + n)) Allgemein können ai ∈ {0, . . . , m}k in O(k(m+n)) sortiert werden. Im Rechner darstellbare natürliche Zahlen können (oft) als Elemente von {0, 1}31 oder von {0, . . . , 15}8 gesehen werden; das allgemeine Bucket Sort heißt in diesem Fall Radix Sort. 3.6 Externes Sortieren Bisher lagen bei Sortierverfahren wie z.B. Quicksort und Heapsort die Daten in einem array vor; größere Datenmengen benötigen u.U. viel mehr Speicherplatz; also externe Speichermedien mit sequentiellem und vergleichsweise langsamem Zugriff (ursprüngich Magnetbänder, externe Festplatten); somit andere Sortierverfahren. Grundidee dabei ist das Mischsortieren ( Mergesort), das wie Quicksort dem Paradigma divide and conquer folgt. Hier teilt man (ohne Aufwand) die Eingabe in zwei Hälften; diese sortiert man nach demselben Verfahren und mischt (mit Aufwand) die Ergebnisse zusammen. Bsp.: 71954281❀7195|4281❀71|95|42|81❀7|1|9|5|4|2|8|1 ❀ 1 7 | 5 9 | 2 4 | 1 8 ❀ 1 5 7 9| 1 2 4 8 ❀ 1 1 2 4 5 7 8 9 Beim Mischen durchläuft man die beiden Ergebnisse (z.B. die letzten beiden Teilergebnisse) gleichzeitig und übernimmt das jeweils kleinere Element ins Endergebnis. (Z.B. die beiden 1en, dann 2 (<5) und 4 (<5), 5 (<8) etc.) Um dies wirklich mit sequentiellem Zugriff zu realisieren, müssen die Teilergebnisse in verschiedenen Dateien liegen, man braucht also zusätzlichen linearen Platz. 26 3.6 Externes Sortieren 3.6.1 3 SORTIEREN Direktes Mischen Idee: Wiederholtes Aufteilen der Eingabedaten auf Hilfsdateien und Mischen dieser Dateien: 3-Band-Mischen 7948 17 24 1579 71954281❀ ❀ 17 59 24 18 ❀ ❀ 1579 1248 ❀ ❀ 11245789 1521 59 18 1248 Ein Lauf (run) ist ein aufgrund des Verfahrens sortiertes Teilstück von Daten in einer der Dateien. (Statt Datei sagt man oft Band.) Bei jedem Aufteilen und Mischen verwendet man auf jede Zahl konstante Zeit, also zusammen jeweils O(n) Zeit. Da sich jedesmal die Länge des Laufs verdoppelt, bis n erreicht ist, wird ⌈log n⌉-mal aufgeteilt und gemischt; es ergibt sich also O(n log n) Zeit. In Java muss man auf die Dateien abwechselnd als Input- und Output-Streams zugreifen; um von den entsprechenden Java-Details zu abstrahieren, erfinden wir Mstreams: Für Mstream a liefert a.read jeweils die nächste Zahl von a, bzw. a.write(x) hängt die Zahl x an a an; zwischen Lese- und Schreibphasen muss man a.reset ausführen (Geschriebenes von vorn lesen bzw. löschen und neu beschreiben); ob beim Lesen das Ende der relevanten Daten erreicht ist, prüfen wir mit a.eof etc. (end of file). Wir nehmen an, dass uns Mstreams a (für die Eingabe), b und c gegeben sind. 27 3.6 Externes Sortieren 3 SORTIEREN Top-down-Entwurf des Verfahrens: void directmergesort() /* Sortiert a mit Hilfe von b und c */ { int k; /* Größe der Runs */ k=1; while (k<n) { distribute(k); a.reset; b.reset; c.reset; directmerge(k); a.reset; b.reset; c.reset; k*=2; } } void distribute(int k) /* Verteilt Teilstücke der Länge k von a abwechselnd auf b und c */ { while (!a.eof) { copyrun(k,a,b); /* zuerst auf b schreiben */ copyrun(k,a,c); /* dann auf c */ } } void copyrun(int k, Mstream a, Mstream b) /* Kopiert k Elemente von a nach b (soweit vorhanden) */ { while (k >0 && !a.eof) b.write(a.read); k--; } void directmerge(int k) /* Mische Sequenzen der Länge k > 0 aus b und c mit |b|, |c| ≥ 1 nach a */ { while(!b.eof && !c.eof) { mergerun(k); } copyrest(b,a); copyrest(c,a); } void mergerun (int k) /* Mische je k > 0 Elemente von b und c mit |b|, |c| ≥ 1 nach a */ { int x,y; int i,j; i=j=k; /* Wieviele Elemente aus b bzw. c sind noch zu übertragen? */ x = b.read; y = c.read; 28 3.6 Externes Sortieren 3 SORTIEREN do { if (x<=y) { a.write(x); i--; if(i>0 && !b.eof) x = b.read; else i=0; /* Schleifenterminierung */ } else . . . } while (i>0 && j>0); if(i==0) { a.write(y); copyrun(j-1,c,a); /* Rest kopieren*/ } if(j==0) . . . } 3.6.2 Verbesserungen • Man halbiert die Anzahl der Durchgänge durch die Daten (langsamer Zugriff auf externes Medium!), indem man die unproduktive Verteilungsphase (a nach b und c) einspart; stattdessen werden die zusammengemischten Läufe gleich wieder auf zwei andere Dateien verteilt: Direktes 4-Band-Mischen (benötigt 4 Dateien) • Sind 2m Dateien vorhanden, kann man auf jeweils m Dateien verteilen. Beim direkten m-Weg-Mischen verlängern sich die Läufe auf das jeweils m-fache. Die Anzahl der log2 n Durchläufe verringert sich dadurch auf logm (n) = log m. 2 • Idee: Verlängerung der 1-Läufe zu Beginn des Verfahrens auf m-Läufe. Dazu liest man beim ersten Verteilen m Elemente in den Hauptspeicher (m geeignet) und sortiert sie mit Quicksort/Heapsort o. ä. 3.6.3 Natürliches Mischen Ausnutzen bereits vorsortierter Teilfolgen; hier ist ein Lauf eine maximale vorsortierte Teilfolge. Beispiel: Aufteilen: Mischen: a b c a 2 2 3 2 8 | 3 23 | 10 12 27 | 16 | 5 9 8 | 10 12 27 | 5 9 23 | 16 3 8 10 12 23 27 | 5 9 16 u. s. w. Statt viermal Teilen/Mischen ist nur noch zweimal Teilen/Mischen notwendig. Dieses Verfahren heißt natürliches 3-Band-Mischen. Beim natürlichen Mischen kann sich die Zahl der Durchgänge vermindern, besonders, wenn die Daten schon etwas vorsortiert sind. Dafür fällt ein erhöhter Verwaltungsaufwand zur Erkennung der Läufe an, der aber im Vergleich zum Dateizugriff eher eine geringe Rolle spielt. 29 3.6 Externes Sortieren 3.6.4 3 SORTIEREN Verbesserungen des natürlichen Mischens Wie in 3.6.2 kann man • sich die Verteilungsphasen sparen, indem man die gemischten Läufe sofort verteilt: natürliches 4-Band-Mischen • die Anzahl der Durchgänge durch Verwendung von zusätzlichen Bändern vermindern: natürliches m-Weg-Mischen Die Verlängerung der anfänglichen Läufe läßt sich wie folgt verfeinern: Einlesen von m Elementen in ein Feld; buildminheap; do { deletemin liefert nächstes Element e für den augenblicklichen Lauf; Einlesen des nächsten Elements e′ ; if (e′ ≥ e) insert(e′ ); else e′ im freiwerdenden Teil des Feldes ablegen; */ vgl. Heapsort */ } while (Halde nicht-leer) Jetzt stehen im Feld m Elemente; zur Erstellung des nächsten Laufes beginnt man also direkt mit buildheap. 12 3 24 6 5 38 20 33 ... Beispiel: m=3 Elemente einlesen und buildheap: 3.6.5 Mehrphasenmischen Jetzt will man den Vorteil des 4-Band-Mischens mit nur 3 Bändern erzielen. Dazu mischt man die Läufe zweier Bänder auf das dritte Band; ist eines der beiden Bänder erschöpft, mischt man die restlichen Läufe des anderen Bandes mit denen des dritten. Die ursprünglichen Läufe müssen also ungleichmäßig verteilt werden: Beispiel: 21 Läufe; gleichmäßige Teilung ergibt (jeweils Anzahl Läufe mit Länge indiziert): D1 D2 D3 0 101 111 102 0 11 92 13 0 82 0 15 72 17 0 ... Jedesmal wird ein immer länger werdender Lauf durchgearbeitet, aber wenig gewonnen. Teilung 41 zu 43 : D1 D2 D3 0 5 16 5 0 11 0 5 6 1:3 1:2 1:1 5 0 1 4 1 0 ... 30 3.6 Externes Sortieren 3 SORTIEREN Damit das Verhältnis stabil bleibt und die ursprünglichen Läufe spätestens in der zweiten 2 Phase gemischt werden, sollte dd23 = d3d−d sein (❀ goldener Schnitt; Fibonacci-Zahlen). 2 (f ib(1) = f ib(2) = 1; f ib(n) = f ib(n − 1) + f ib(n − 2) für n > 2) D1 D2 D3 0 81 131 82 0 51 32 53 0 0 23 35 28 0 15 18 113 0 0 0 121 21 = f ib(8) Ist die Anzahl der Läufe keine Fibonacci-Zahl, so füllt man bis zur nächsten FibonacciZahl mit Dummy-Läufen auf. Bei mehr als drei Bändern kann man Fibonacci-Zahlen höherer Ordnung zur Aufteilung verwenden: f ib2 = f ib; f ib3 (n) = f ib3 (n − 1) + f ib3 (n − 2) + f ib3 (n − 3); . . . 31 4 MENGENVERWALTUNG 4 Mengenverwaltung Wir wollen mittels der Operationen insert, delete und iselem eine Menge von Daten mit Schlüsseln verwalten; vgl. Datenbanken. In Kapitel 1 haben wir schon kurz über binäre Suche gesprochen; ähnlich wollen wir im ersten Teil dieses Kapitels die Menge in „die Kleineren“ und „die Größeren“ aufteilen. Abweichend suchen wir nach einer dynamischen Datenstruktur, und auch insert soll schneller gehen. Binäre Bäume liefern (immer wieder) eine Aufteilung in den linken und den rechten Ast. 4.1 Baumdurchläufe Man kann die Knoten eines Baums in verschiedener Weise systematisch durchlaufen (und dabei bearbeiten). In der Präfixordnung läuft man zuerst die Wurzel an und durchläuft danach die Äste von links nach rechts in gleicher Weise; Postfixordnung analog, Wurzel zuletzt. Infixordnung bei Binärbäumen: linker Ast, Wurzel, rechter Ast. Die 3 Durchläufe für die beiden linken Bäume in Abb. 5 sind: • 9 17 3 20 10 6, 3 17 10 6 20 9, 3 17 9 10 20 6 • 9 5 3 6 20 10, Ein Baum ist (aufsteigend) sortiert wenn seine Infixordnung aufsteigend sortiert ist, d.h. wenn für jeden Knoten im linken Ast kein größerer und rechten Ast kein kleinerer Wert steht. 17 3 * 9 9 10 20 5 20 6 3 6 10 - + 3 6 10 Abbildung 5 Der rechte Baum ist der Strukturbaum eines Terms, der aufgrund der Baumstruktur ein Produkt einer Summe und einer negativen Zahl ist. Infixordnung 3 + 6 ∗ - 10 ist fast der Term, aber Aus der Präfixordnung ∗ + 3 6 - 10 (polnische Notation) kann man den Term . . . . . . . . . rekonstruieren, zumindest wenn man die Stelligkeit der Operationen kennt (Problem mit -): Der Term ist ein Produkt, der erste Operand ist eine Summe, d.h. bei - beginnt der zweite Operand. Bem.: Bei (-10) ∗ (3+6) erhält man ∗ - 10 + 3 6 und könnte denken, man soll 10 - (3+6) berechnen. Postfixordnung: 3 6 + 10 - ∗ (umgekehrt polnische Notation); so arbeitet der Rechner: beschaffe Dir zwei Operanden (z.B. Werte aus dem Speicher), berechne Summe (die man für spätere Zwecke aufbewahrt), beschaffe Dir Operanden und wechsle das Vorzeichen, wende * auf die beiden Ergebnisse an. 32 4.2 Binäre Suchbäume 4.2 4 MENGENVERWALTUNG Binäre Suchbäume Ein (beschrifteter) binärer Baum ist ein Suchbaum, wenn für jeden Knoten im linken Ast nur kleinere und im rechten Ast keine kleineren Werte stehen; vgl. Baum oben mitte. Für wiederholungsfreie Binärbäume (d.h. injektive Beschriftung) ist dies dasselbe wie sortiert; wir betrachten in diesem Abschnitt 4.2 nur solche. public class BinNode { public int value ; public BinNode leftson, rightson ; public BinNode (int v, BinNode l, BinNode r) { value = v ; leftson = l ; rightson = r ; } } public class SBinTree { private BinNode root ; public boolean isempty () { return (root == null) ; } public boolean iselem(int x) { BinNode b = root ; while ( b != null) { if ( b.value == x ) return true ; if ( x < b.value ) b = b.leftson ; else b = b.rightson ; } return false ; } private void insertsub(BinNode b, int x) { if ( x < b.value ) { if (b.leftson == null ) b.leftson = new BinNode (x, null, null) ; else insertsub(b.leftson,x) ; } else { if ( x > b.value ) ... insertsub(b.rightson,x) ; } } public void insert(int x) { if ( root == null) root = new BinNode (x, null, null) ; else insertsub(root,x) ; } } 33 4.3 2-3-Bäume 4 MENGENVERWALTUNG Was geschieht in insertsub, wenn x = b.value? Partielle Korrektheit von insertsub verlangt, dass im Terminierungsfall x in den Suchbaum b eingefügt wird, falls es neu ist. Je nach If-Abfrage wird es links oder rechts eingefügt. Im ersten Fall ist der Then-Zweig ein Terminierungszweig; hier wird die Spezifikation erfüllt ( wegen x < b.value). Im Else-Zweig können wir annehmen, dass insertsub(b.leftson,x) das gewünschte leistet; da die alten Werte im Baum b.leftson und wegen der Abfrage auch das neue x kleiner als b.value sind, ist der umgebaute Baum b wieder ein Suchbaum, der zusätzlich x enthält. Warum ist dieses Vorgehen beim Korrektheitsbeweis selbst korrekt? Wiederholung: Bei nur einem Aufruf, landen wir im Terminierungszweig, und der ist korrekt. Angenommen, bei ≤ n Aufrufen ist das Ergebnis korrekt. Erfordert der Aufruf insertsub(b,x) n + 1 Aufrufe, so führt dieser erste Aufruf o.E. auf den Aufruf insertsub(b.leftson,x). Da dieser also n Aufrufe erfordert, ist das Ergebnis nach Ind.ann. korrekt; wir haben oben gezeigt, dass dann auch der Aufruf insertsub(b,x) das Gewünschte leistet. Der obige Suchbaum (Abb. 5 mitte) entsteht z.B. aus SBinTree() insert(9) insert(5) insert(3) insert(20) insert(6) insert(10) . Dauert iselem nun immer O(log n)? Betrachten Sie SBinTree().insert(20).insert(10).insert(9).insert(5).insert(3) . Tatsächlich ist der schlechteste Fall Ist der Baum aber (einigermaßen) ausgeglichen, so hat er Tiefe O(log n), vgl. Abschnitt 1.5; b steigt jeweils mit konstantem Aufwand zur nächsttieferen Schicht ab. Zudem gibt es Methoden, einen Suchbaum durch Umbauten „balanciert“ zu halten (s. AVL-Bäume). Eine andere Möglichkeit besteht darin, den Baum sehr ausgeglichen zu halten, aber dabei auch mehr als 2 Kinder zuzulassen: 4.3 2-3-Bäume In einem 2-3-Baum hat jeder innere Knoten 2 oder 3 Kinder, alle Bätter sind in derselben Tiefe. Die inneren Knoten enthalten nur Schlüsselwerte als Trenninformation, in einem Blatt liegt dann ein ganzer Datensatz; da bei einer Operation nur auf ein Blatt zugegriffen wird, kann man den Datensatz auch auf einen externen Speicher legen – das Blatt hält dann dessen Adresse. Bem.: Es gibt auch allgemeinere B-Bäume, bei denen die inneren Knoten ⌈M/2⌉ bis M Kinder (die Wurzel 2 bis M Kinder) haben. Die Trenninformation besteht jeweils aus 1 oder 2 Werten, dem kleinsten Wert im 2. Ast und ggf. dem kleinsten Wert im 3. Ast. Suche nach 18 im 2-3-Baum in Abb. 6: 18 ≥ 7, also nicht im 1. Ast; 18 ≥ 16, also nicht im 2. Ast – gehe in den 3.; 18 ≥ 18, also nicht im 1. Ast; 18 < 19, also im 2. Ast. Sei n die Mächtigkeit der Menge, also die Anzahl der Blätter; sei ferner h die Höhe des Baums. Dann hat der Baum zwischen 2h und 3h Blätter, also h ≤ log n ≤ h log 3. Demnach hat der Baum immer logarithmische Höhe und wir werden auf eine Suchzeit von O(log n) kommen. 34 4.3 2-3-Bäume 4 MENGENVERWALTUNG 7 16 5 2 18 19 8 12 5 7 8 12 16 18 19 Abbildung 6 public class Node23 { public int value ; public boolean isleaf ; public Node23 son1, son2, son3 ; public int lowof2, lowof3 ; public Node23 (int v) { value = v ; isleaf = true ; } public Node23 (Node23 s1, Node23 s2, int lo2) { isleaf = false ; son1 = s1 ; son2 = s2 ; lowof2 = lo2 ; } public boolean twosons () /* innerer Knoten */ { return (son3 == null) ; } } Besser: Node23 hat zwei Unterklassen für Blätter (nur mit value) und innere Knoten (ohne value), um Speicherplatz zu sparen ; isleaf kann ersetzt werden durch public class Tree23 { private Node23 root ; public boolean isempty () { return (root == null) ; } public boolean iselem(int x) { Node23 b = root ; if ( b == null) return false ; while ( ! b.isleaf ) { if ( x < b.lowof2 ) b = b.son1 ; else if ( b.twosons() || x < b.lowof3 ) /* sequentielles oder */ b = b.son2 ; 35 4.3 2-3-Bäume 4 MENGENVERWALTUNG else b = b.son3 ; } return ( b.value == x ) ; } } Je Schicht konstanter Aufwand, also insgesamt O(log n) wie gewünscht. Ähnlich kann man für nicht-leere 2-3-Bäume findmin() und findmax() programmieren. Vor-Bem.: Ist x nicht in b und liegt z.B. „zwischen“ son2 und son3, so suchen wir in son2, also links. Daher ist x nie kleiner als der kleinste Wert des Teilbaums, in dem wir suchen – es sei denn, x ist kleiner als jeder Wert in b. Analog geht man beim Einfügen vor: x wird in einem Teilbaum eingefügt, so dass sich dessen kleinster Wert (Trenninfo!) nicht ändert – es sei denn, x wird neuer kleinster Wert und liegt immer in son1; dessen kleinster Wert wird nicht als Trenninfo benötigt. (Außer: neuer Baum, s.u.) Einfügen: Suche nach dem neuen Element und füge ungefähr bei dem entsprechenden Blatt ein. kleinster Wert zu neu 8 8 12 12 7 7 12 8 12 neu Abbildung 7 Hatte der neue Vater schon 3 Kinder: aufteilen! Der Großvater braucht den kleinsten Wert des neuen Teilbaums. 10 kleinster Wert zu 7 10 7 neuer Teilbaum 5 2 5 8 12 5 7 8 10 12 2 8 5 7 12 8 10 12 4 Kinder - Vater spalten Abbildung 8 Evtl. wiederholtes Spalten; im Extremfall muss man eine neue Wurzel einfügen, s. Abb. 9. Im Prinzip durchlaufen wir einen Suchpfad von der Wurzel zum neuen Blatt und (teilweise) wieder zurück; wieder logarithmische Laufzeit. Die Grundidee des Einfügens ist relativ einfach – das Programm erfordert viele Fallunterscheidungen. 36 4.3 2-3-Bäume 4 MENGENVERWALTUNG 10 7 16 5 2 7 16 18 19 8 12 5 7 8 16 12 8 5 18 19 5 2 7 18 19 12 8 10 12 16 18 19 neuer Teilbaum kleinster Wert zu 10 neuer Teilbaum 7 16 neuer Vater 5 2 8 5 7 18 19 12 8 10 12 16 18 19 Abbildung 9 Die Funktion insert23 behandelt den Fall des leeren Suchbaums; ansonsten stützt sie sich auf insert23sub und fügt allenfalls eine neue Wurzel ein. Die Funktion insert23sub fügt x ein und erzeugt dabei evtl. einen zusätzlichen Teilbaum; ggf. liefert sie dessen kleinsten Wert als Ergebnis (oder 0). Skizze: public int insert23sub(Node23 b /* nichtleer */, int x, Tree23 newtree /* newtree.root == null */) /* fuege x in b ein; erzeuge ggf. neuen Teilbaum newtree, der rechts von b liegen und dieselbe Hoehe haben soll; Ausgabe ggf. kleinster Wert von newtree nur wenn x kleiner als alle Werte in b ist, bekommt b neuen kleinsten Wert -- kann man ignorieren, s. letzte Bem. */ { Tree23 newt = new Tree23() ; if( b.isleaf()) { if ( b.value == x ) return 0; // keine Duplikate if ( x < b.value ) { newtree.root = new Node23(b.value) ; b.value = x ; return newtree.root.value ; } else { newtree.root = new Node23(x) ; return x ; } } 37 4.3 2-3-Bäume 4 MENGENVERWALTUNG else // not b.isleaf { waehle passenden Teilbaum w fuer x (Nr. in son merken) ; int nlow = insert23sub(w, x, newt) ; /* nach Spez. von insert23sub haben w und newt (rechts von w) ggf. dieselbe Hoehe; w hat neuen kleinsten Wert nur wenn son=1 */ if (! newt.isempty) // sonst ist nichts zu tun { newt "als Sohn von b rechts von w einfuegen"; //das ist die Idee if (b hat jetzt 3 Soehne) { lowof3 = nlow oder lowof3 = lowof2; lowof2 = nlow return 0 ; } else // b spalten { newtree.root = new Node23( 3. Sohn, 4. Sohn, lowof3/nlow) ; int newlow = kleinster Wert von newtree, also des 3. Sohns ; ggf. b.son2 = newt.root // lowof2/lowof3/nlow b.son3 = null; lowof2 ggf. auf nlow setzen ; return newlow ; } } else return 0 ; } } Für jede Schicht des Baums werden höchstens die Anweisungen des Rumpfes (außer Auswertung der rekursiven Aufrufe) einmal ausgeführt; dieser Aufwand ist also tatsächlich konstant. Entfernen von x: Wenn der Vater von Blatt x 3 Kinder hat, im Prinzip einfach. Ist x aber der 1. Sohn, so muss der neue kleinste Wert zum Großvater und evtl. noch weiter nach oben weitergereicht werden; vgl. newlow in insert23sub. Hat der Vater nur 2 Söhne – vgl. Abb. 8 rechts für x = 10 – so können wir den Vater mit einem Bruder verschmelzen, der nur 2 Söhne hat – lesen Sie Abb. 8 von rechts nach links. Dabei verliert der Großvater einen Sohn; evtl. muss auch der verschmolzen werden, etc. 10 10 7 12 7 16 18 19 12 10 12 18 16 18 16 12 19 19 16 18 Abbildung 10 Hat der Bruder aber 3 Söhne, leiht man sich einen, s. Abb. 10 für Baum aus Abb. 9. 38 19 4.4 Hashing (Streuspeicherverfahren) 4 MENGENVERWALTUNG Zusammengefasst: empty, isempty, insert, delete, iselem, findmin, findmax laufen mit 2-3Bäumen in O(log n) im schlechtesten Fall. 4.4 Hashing (Streuspeicherverfahren) Jetzt wollen wir eine relativ kleine Teilmenge einer großen Menge von möglichen Schlüsseln verwalten (die nicht geordnet sein müssen). Hash-Tabellen unterstützen insert, delete und iselem in durchschnittlich konstanter Zeit! Anwendung: Compiler legt Symboltabelle an, die Bezeichner (kleine Auswahl aus einer sehr großen Menge) mit zugehörigen Speicheradressen enthält. Grundidee: Objekte, z. B. Strings, in einem Feld ht[size] (Hash-Tabelle) speichern; dazu verwendet man eine Funktion hash: Objekte 7−→{0, . . ., size-1} und speichert o in ht[hash(o)], wo man o auch wiederfindet (vgl. Bucket Sort). Es gibt wesentlich mehr als size mögliche Objekte, also sollte hash möglichst gleichverteilte Werte liefern, so daß man möglichst nicht o1 und o2 mit hash(o1 ) = hash(o2 ) speichern muß (Kollision!). Probleme: • Welche Hash-Funktion? • Was tun bei Kollision? • Wie groß wählt man size? 4.4.1 Hash-Funktionen Sind die Objekte ganze Zahlen x, so bietet sich “x mod size” an. Beispiel: size=100; unglücklicherweise Es gibt eine Reihe von Verfahren, die in (unterschiedlichen) speziellen Situationen besser sind, insgesamt ergibt die Divisions-Rest-Methode aber das beste Gesamtverhalten. Sind die Objekte Strings, kann man die Zeichen in ASCII übersetzen und aufaddieren. Beispiel: Alle Strings haben höchstens 8 Zeichen (> 2 · 1011 mögliche Objekte, selbst wenn nur Buchstaben auftreten); size =10007. Es wird empfohlen, die Zeichen-Werte zu wichten, wie z. B.: hash(h0 , . . . , hn−1 ) = (27n−1 h0 + 27n−2 h1 + . . . + 270 hn− 1 ) % size, wobei 27 die Anzahl der Buchstaben im Alphabet einschließlich space ist. bzw.: hash=0; for (j= 0; j < n ; j++ ) hash=(32*hash+hj ) % size; 32∗...: bit-shift 39 4.4 Hashing (Streuspeicherverfahren) 4 MENGENVERWALTUNG jedesmal “%”: gegen Überlauf, aber teuer (Bei anderen Faktoren als 32 würde durch “%” aber die Multiplikation billiger.) Oft berücksichtigt man nur einige Zeichen; nimmt man z. B. nur die ersten drei, ist zu beachten, daß • die natürliche Sprache nicht alle Möglichkeiten ausschöpft (z. B. xta...), • Bezeichner z. B. adr1, adr2, adr3,... heißen könnten. (Also besser ersten, mittleren und letzten Buchstaben verwenden.) 4.4.2 Offenes Hashing Kollidierende Objekte in einer Liste halten (vgl. Bucket Sort) Beispiel: size=10; Objekte: 49, 58, 89, 18, 11 ergibt 0 1 2 3 4 5 6 7 ❄ 11 8 9 ❄ ❄ 18 ❄ 58 89 ❄ 49 Grobe Aufwandsabschätzung Insert kostet konstante Zeit (Hash-Funktion berechnen, vorn in Liste einfügen), für iselem und delete muss die jeweilige Liste durchsucht werden, also O(Listenlänge). Suchen eines vorhandenen Objekts im Mittel schneller, da die entsprechende Liste evtl. nicht ganz durchsucht wird. Bei einer guten Hash-Funktion wird jede Liste mit gleicher Wahrscheinlichkeit durchsucht; die Suchzeit für ein nicht vorhandenes Objekt entspricht also der durchschnittlichen Listenlänge, d. h. dem Füllungsgrad=(Anzahl der gespeicherten Objekte)/(size). Hält man den Füllungsgrad ≤ c (c = 1 wird empfohlen), so ist die Suchzeit im Mittel konstant. Man kann die Listen sortiert halten, so daß Nichtenthaltensein im Mittel schneller festgestellt wird. Selbstanordnende Listen bringen häufig gesuchte Elemente an den Listenanfang. Hierzu drei Techniken: • MF (move to front): Wird auf ein Element zugegriffen, so verschiebe es an den Listenanfang. • T (transpose): Wird auf ein Element zugegriffen, so verschiebe es um einen Platz nach vorn. 40 4.4 Hashing (Streuspeicherverfahren) 4 MENGENVERWALTUNG • FC (frequency count): Häufigkeiten zählen und danach sortieren. MF liefert praktisch sehr gute Ergebnisse; FC ist zu speicher- und zeitaufwendig. Rehashing Erreicht der Füllungsgrad c, so legt man eine neue Hash-Tabelle der Größe 2 · size an. Alle Objekte der alten Tabelle werden in die neue eingefügt; dies dauert O(c · size). Da seit dem Objekte eingefügt wurden, entfällt auf jedes dieser alten letzten Rehashing mindestens c·size 2 inserts im Schnitt ein Aufwand von 2 weiteren inserts, d.h. der Durchschnittsaufwand (für die gesamte Sequenz von Operationen) bleibt konstant. Statt den schlechtesten Fall für eine Operation betrachtet man den schlechtesten Fall für eine Folge von Operationen von einem Startzustand aus; der entsprechende Aufwand heißt amortisierte Zeit. Bsp.: c = 1, size = 2. Nach Einfügen von 2 Elementen rehashen; size = 4. Jetzt hat die Folge von Operationen ins ins (rehash) (nun size=8) ins ins ins ins (rehash) den Aufwand 1 1 4 1 1 1 1 8 entprechend 3 3 3 3 3 3 Amortisierte Zeit Für die amortisierte Zeit betrachtet man (eine Abschätzung nach oben für) den schlechtesten Fall für eine Folge von Operationen von einem Startzustand aus und teilt diese Zeit auf die Operationen auf. Eine späte Operation kann also wesentlich teurer sein als die amortisierte Zeit, wenn die Operationen zuvor entsprechend billiger waren. Wenn ein Benutzer (z.B. ein Programm) die ganze Folge ausführt, ist eine gute amortisierte Zeit gleichwertig zu einer worst-case Betrachtung für einzelne Operationen. Führt ein Benutzer aber nur die späte teure Operation aus, hat er Pech gehabt; die evtl. gute amortisierte Zeit nützt ihm nichts. Daher gilt: worst-case O(1): Jede Operation dauert O(1) – ist besser als amortisiert O(1): k Operationen dauern O(k) – ist besser als durchschnittlich O(1): Der Erwartungswert für die Dauer einer Operation ist O(1), die tatsächliche Dauer kann aber auch beim ersten Mal und sehr oft hintereinander länger sein. Eine Technik zum formalen Nachweis für amortisierte Effizienz bilden Potentiale: Ein Potential Φ für eine Datenstruktur ordnet dem Zustand einen Wert zu, so daß initial Φ = 0 und immer Φ ≥ 0; Φ ist eine Art Sparbuch. Für eine Operation op sei ∆Φ = (Φ nach op) − (Φ vor op) (die Einzahlung oder Abhebung); ist z.B. ∆Φ > 0, so zahlt op auf das Sparbuch ein – rein rechnerisch sehen wir die Zeit von op als teurer an als die tatsächlich benötigte Zeit t(op). Die amortisierte (“rein rechnerische”) Zeit bzgl. Φ ist definiert als ta (op) = t(op) + ∆Φ. k P Führen die Operationen op1 , . . . , opk das Potential von Φ = 0 auf Φe , so gilt: ta (opi ) = i=1 k P i=1 t(opi ) + Φe ≥ k P t(opi ). Die Summe der amortisierten Zeiten bzgl. Φ ist also immer minde- i=1 stens die von der entsprechenden Folge tatsächlich benötigte Zeit; die amortisierte Zeit bzgl. 41 4.4 Hashing (Streuspeicherverfahren) 4 MENGENVERWALTUNG Φ ist also tatsächlich eine amortisierte Zeit gemäß Definition. Statt die durchschnittliche Zeit der Operationen der Folge op1 , . . . , opk abzuschätzen, genügt es also, eine Schranke (z. B. O(1)) für ta (op) nachzuweisen. Man beachte: Bei mehreren Operationstypen kann man den Aufwand auch rechnerisch verschieden verteilen; z.B. könnte bei einer Datenstruktur insert eine amortisierte Zeit von O(1), deletemin aber eine von O(log n) haben. Mit verschiedenen Potentialen lassen sich verschiedene amortisierte Zeiten nachweisen; bei derselben Datenstruktur könnte bzgl. einem anderen Φ also auch deletemin eine amortisierte Zeit von O(1), aber insert eine von O(log n) haben. Rehashing (Forts.) Wir betrachten zunächst offenes Hashing mit Rehashing und den Operationen insert und find. Der Erwartungswert für die Zeit von find ist konstant; daher konzentrieren wir uns auf insert und beginnen mit einer informellen Diskussion. Ein insert ohne Rehashing kostet konstante Zeit, die wir als Einheit wählen; mit dieser Einheit kostet ein insert also tatsächlich Zeit 1. Nach obigen Überlegungen wollen wir aber jedem insert Zeit 3 zurechnen, jedes insert erhöht das Potential also um 2; diese 2 misst gewissermaßen die Arbeit, die uns dieses Einfügen später beim nächsten Rehashing machen wird. Vor dem letzten Rehashing war die c·size Tabellengröße c·size 2 , die Zahl der Elemente 2 . Beim aktuellen Rehashing ist die Zahl der c·size Elemente c·size, beim Einfügen der 2 neuen Elemente wurde Φ also auf mindestens c·size erhöht. Nach dem letzten insert müssen alle Elemente erneut eingefügt werden; die Zeit dafür ist gerade c · size, und statt diese dem insert zuzurechnen vermindern wir Φ entsprechend. In anderen Worten: Real dauert fast jedes insert 1, es wird ihm aber Zeit 3 zugerechnet. Ein insert, das zum Rehashing führt, dauert real 1 + c · size, es wird ihm aber auch nur 3 zugerechnet, was sich aufgrund der Potential-Argumentation genau ausgleicht. Um die Zeit einer langen Folge von inserts zu bestimmen, können wir also jedes insert mit 3 anrechnen – die amortisierte Zeit ist also konstant. Damit ist – aus unterschiedlichen Gründen – die Zeit für find und insert im Schnitt konstant. Formal muss Φ jedem Zustand einen Wert zuordnen; dazu sei x jeweils die Anzahl der gespeicherten Elemente. Im Prinzip wollen wir Φ als 2(x− c·size 2 ) definieren, also als die doppelte Anzahl der „neuen“ Elemente; dies wäre aber am Anfang (x = 0) negativ. Gewissermaßen aus technischen Gründen definieren wir zusätzlich x0 = c · size0 , wobei size0 die ursprüngliche Tabellengröße ist. Jetzt ist die Bestimmung der amortisierten Zeit recht kurz: Definiere Φ = 2(x − c·size 2 ) + x0 , was initial 0 ist. Ein find verändert Φ nicht. Ein insert inkrementiert x, erhöht Φ also in der Regel um 2 (t(insert) = 1, ta (insert) = 3). Führt ein insert aber zum Rehashing (x + 1 = c · size), so verdoppelt sich zudem size: ∆Φ = 2(c · size − c·2·size ) + x0 − (2(c · size − 1 − 2 c·size ) + x ) = x − (2c · size − 2 − c · size + x ) = −c · size + 2, t(insert + rehash) = 1 + c · size, 0 0 0 2 ta (insert + rehash) = 3. Unabhängig von unseren Vorüberlegungen zeigt dieser Absatz, dass die amortisierte Zeit von insert konstant ist. Betrachten wir nun zusätzlich delete: Der Erwartungswert für delete ist konstant; werden auch deletes ausgeführt, muss man seltener rehashen, der Aufwand je insert wird also nur geringer. 42 4.4 Hashing (Streuspeicherverfahren) 4.4.3 4 MENGENVERWALTUNG Geschlossenes Hashing Alternative: ohne Listen; bei Kollision neues Objekt in der nächsten freien Zelle der Tabelle speichern. Allgemein: Man versucht Speicherung in den Zellen ht[(hash(o) + f (i)) % size], i=0,1, . . ., wobei f mit f(0)=0 die Konfliktlösungs-Strategie ist. Im einfachsten Fall ist f linear (d.h. f (i) = ki), z. B. f (i) = i. Beispiel: size=10; Objekte: 89, 18, 49, 58, 11 ergibt 0 1 2 49 58 11 ❏❏ ❏❏ 3 4 5 6 7 8 9 18 89 ❏❏ Probleme: • Haufenbildung mit aufwendigen Zugriffen • delete darf Deshalb: Zusatzinformation in der Tabelle speichern; ein Tabelleneintrag besteht also aus dem gespeicherten Objekt elem und der Information info ∈ {empty, ok, deleted}. Für f (i) = i: bool find(Object o, hashtable ht) { int pos = hash(o); while (ht[pos].info != empty && ht[pos].elem != o) pos = (pos+1) % size; return (ht[pos].info == ok); } evtl. Totschleife, wenn kein Eintrag empty ist Bei insert sollte man o Das Problem der Haufenbildung kann man durch Beschränkung des Füllungsgrades auf unter 0,75 oder 0,5 in den Griff bekommen; er muß < 1 sein, wobei Zellen mit Bsp.: 10, 30, 80, 21 0 1 2 3 4 80 10 30 43 4.4 Hashing (Streuspeicherverfahren) 4 MENGENVERWALTUNG Zudem kann man statt linearer eine quadratische Konfliktlösungs-Strategie verwenden, z. B. f (i) = i2 . Dies bringt neue Probleme; ist z. B. size = 16, dann liegen die einzigen Alternativ-Zellen im Abstand 1, 4, 9; bereits beim 5. Objekt läßt sich also evtl. keine freie Zelle mehr finden – trotz 75% leerer Zellen. Satz 4.1. Ist size prim und ht mindestens zur Hälfte leer, so wird mit f (i) = i2 immer eine freie Zelle gefunden. Beweis: s. M. A. Weiss ✷ 44 5 VERWALTUNG DISJUNKTER MENGEN 5 Verwaltung disjunkter Mengen Angenommen, die Rechner einer Firma sind teilweise vernetzt; man möchte • abfragen, ob von Rechner 1 zu Rechner 2 eine Verbindung (direkt oder indirekt über andere Rechner) besteht • neue Verbindungen hinzufügen Man benötigt also eine Datenstruktur zur Verwaltung von Partitionen einer Grundmenge X bzw. von Äquivalenzrelationen auf X mit Operationen: • initDisjointSet(X): ({x})x∈X • find(x): liefert die Äquivalenzklasse von x; die Bezeichnung der Klasse ist unwichtig, es muß lediglich gelten: find(x)=find(y)⇐⇒ x,y liegen in derselben Klasse. Daher wählt man als Bezeichnung ein (für die jeweilige Implementierung geeignetes) “kanonisches” Element der Klasse (“Wurzel”). • union(x,y): vereinigt die Klassen von x und y, wobei x und y Wurzeln seien; manchmal ist union(x, y) auch für beliebige Elemente x und y definiert. Die Werte der Elemente x sind irrelevant; man nimmt daher X = {1, . . . , n}. Ggf. können die wirklichen Elemente den Zahlen zugeordnet und 5.1 Schnelles Find Die Namen der Klassen werden in einem Feld int find[n+1] gespeichert; find benötigt union: Ist a = f ind[x], b = f ind[y], a 6= b, so Es sind höchstens n − 1 “richtige” unions (mit a 6= b) möglich (warum?); bei Ω n2 finds heißt das, daß der durchschnittliche Aufwand für jede Operation konstant ist. Allgemein ist der Gesamtaufwand bei m finds und n − 1 unions O m + n2 . Bsp.: Abb. 11 Abbildung 11 Verbesserung: Beispiel: union(2,1); union(3,2); union (4,3); ... erfordert 1 + 2 + 3 + . . . Änderungen, also immer noch Θ n2 . Verbesserung: Aufwand: find in O(1), union proportional zur kleineren size; Gesamtaufwand? Ändert sich die Bezeichnung der Klasse von x, so wird sie mindestens doppelt so groß; dies geschieht höchstens log(n)-mal. Der Eintrag von x wird also nur O(log(n))-mal geändert. Also: m finds und bis zu n − 1 unions benötigen O(m + n log(n)) 45 5.1 Schnelles Find 5 VERWALTUNG DISJUNKTER MENGEN allgemeiner “Trick”: Aufwand einer Operation ist proportional zur Anzahl der zugegriffenen Elemente; statt zeilenweise zu zählen und aufzuaddieren, zählen wir spaltenweise und addieren auf. Zugriff op1 op2 opm 1 × × 2 ... × × Bem.: Sind die opi nun finds, unions, beides? 46 n × × 5.2 Schnelles Union 5.2 5 VERWALTUNG DISJUNKTER MENGEN Schnelles Union Ziel: Aufwand von O(f ), wobei f „kaum schneller“ als m+n wächst. Dabei wird union schnell, aber find aufwendig sein. Grundidee: Partition als Wald darstellen Jeder Knoten verweist auf den Vater oder auf 0; speichern in einem Feld int p[n+1]. Die Namen der Klassen sind die Wurzeln: 2, 6, 10 union(int x,y) /∗ x, y seien Wurzeln ∗/ : y unter x hängen, d. h. p[y]=x int find(int x) { while (p[x]>0) x=p[x]; return x; } Aufwand für find ist evtl. Θ(n); dies tritt z.B. nach der Folge initDisjointSet({1, . . . , n}), union(i + 1,i) für i = 1, . . . , n − 1 ein bei find(1). Der Gesamtaufwand (s.o.) ist also O(mn). Die Höhe der Bäume muß also klein gehalten werden. Wie in 5.1 hängt man den kleineren Baum an den größeren. Wenn die Tiefe eines Knotens um 1 steigt, verdoppelt sich die Größe der Klasse zumindest; die maximale Tiefe eines Knotens ist also log(n). Wir halten formal fest: Lemma 5.1. Ein Union/Find-Baum mit Höhe r hat ≥ 2r Knoten. Beweis: Induktion über r: r = 0: klar. Wird die Höhe von v zu r > 0, so deshalb, weil v eine Wurzel v ′ mit Höhe r − 1 als Kind erhält. Der Baum von v ′ hat nach Induktion ≥ 2r−1 Knoten, der von v nach Definition von union mindestens genauso viele. ✷ Die Größe g eines Baumes kann man anstelle von 0 als −g speichern. void union(int root1, root2) { if (p[root1] <= p[root2]) /* { p[root1] += p[root2]; p[root2]=root1; } else ... } root1 ist der groessere Baum */ Bsp.: Jetzt ist der Gesamtaufwand O(m log(n) + n) für m finds und höchstens n unions. Mitteilung: Für m finds und unions mit m ∈ Ω(n) ist der durchschnittliche Gesamtaufwand O(m). Analog kann man die Vereinigung statt durch die Größe der Bäume auch durch ihre Höhe steuern – wobei die Eintragung der Größe log n Bits, die der Höhe nur log log n Bits benötigt. 47 5.3 Pfadverkürzung 5.3 5 VERWALTUNG DISJUNKTER MENGEN Pfadverkürzung Der Durchschnittsaufwand ist zwar gut, es soll aber auch der worst case behandelt werden: Idee: Bei einem find durchläuft man den Pfad zur Wurzel nochmals und hängt alle Knoten direkt an die Wurzel (Nebeneffekt eines Funktionsaufrufs!); dadurch bleibt der Aufwand bei diesem find im wesentlichen gleich (d.h. bis auf einen konstanten Faktor), verringert sich aber für weitere finds. int find(int x) { if (p[x]<0) return x; else { p[x] = find(p[x]); return p[x]; } } Bsp.: pseudo: real: oder: Rang 4 Höhe 2 oder 1 Zur Aufwandsabschätzung sei der Rang eines Knotens v die Höhe seines Teilbaumes (also zuerst 0), wenn dieselbe Folge von Operationen ohne Pfadverkürzung durchgeführt würde. Die Knoten dieses (gedachten) Teilbaumes sind die Pseudonachkommen von v. Es gilt stets: (1) Die Pseudonachkommen einer Wurzel sind genau die Nachkommen. Beweis: Die Pfadverkürzung ordnet lediglich die Nachkommen der Wurzel um – (1) bleibt bei find wahr. Bei union gewinnt die Wurzel eine bisherige Wurzel mit all ihren Nachkommen = Pseudonachkommen hinzu. ✷ Unser Programm ordnet also tatsächlich die kleinere der größeren Klasse unter, da die Größenbestimmung für Wurzeln wichtig ist und in der „realen“ und der „Pseudo“-Welt dasselbe Ergebnis hat. (2) Der Rang von v ist mindestens seine Höhe. Beweis: Durch Pfadverkürzung werden die Pfade zur Wurzel kürzer, andere Knoten verlieren Nachkommen; die tatsächliche Höhe wird also nur geringer. Auch bei union steigt die Höhe höchstens auf den Wert des Rangs. ✷ 48 5.3 Pfadverkürzung 5 VERWALTUNG DISJUNKTER MENGEN (3) Auf jedem Weg zur Wurzel nehmen die Ränge echt zu. Beweis: Durch Pfadverkürzung wird der Weg auf einen Ausschnitt reduziert. ✷ (4) Ein Knoten mit Rang r hat ≥ 2r Pseudonachkommen. Beweis: Aussage über die „Pseudo“-Welt; vgl. Lemma 5.1. ✷ (5) Es gibt höchstens 2nr Knoten vom Rang r. Beweis: Die echten Pseudonachkommen eines Knotens mit Rang r haben Rang < r; zwei Knoten mit Rang r sind also nicht Pseudonachkommen voneinander. Damit sind (wie in jedem Wald) die Mengen ihrer Pseudonachkommen disjunkt. Fertig mit (4). ✷ Für das Resultat benötigt man eine sehr langsam wachsende Funktion. Definiere: 2... F (0) = 0 und F (i) = 2F (i−1) für i ≥ 1. (also 22 ) G(i) = min{k | F (k) ≥ i} G ist ungefähr das Inverse der sehr schnell wachsenden Funktion F . Es gilt für i > 0: G(i) = log∗ (i), wobei log∗ (i) = min{k | log(log(. . . log(i) . . .)) ≤ 0} {z } | k−mal i 0 1 2 3, 4 5-16 17-216 65537-265536 G(i) 0 1 2 3 4 5 6 F (G(i)) 0 1 2 4 16 16 2 = 65536 265536 G(i) ≤ 6 für alle relevanten i. Die Ränge werden zu Ranggruppen zusammengefaßt: v gehört zur Ranggruppe g = G(Rang(v)). (6) Die Ränge in Ranggruppe g liegen zwischen F (g − 1) + 1 und F (g). Beweis: vgl. Tabelle. ✷ (7) Es existieren Knoten zu höchstens G(n) + 1 verschiedenen Ranggruppen. Beweis: 0 ≤ Rang(v) ≤ n − 1, d.h. Ranggruppen 0, . . . , G(n). (8) Sei V (g) die Anzahl der Knoten in Ranggruppe g > 0. Dann gilt: V (g) ≤ 49 ✷ n F (g) . 5.3 Pfadverkürzung 5 VERWALTUNG DISJUNKTER MENGEN Beweis: F (g) V (g) ≤ = ≤ = X r=F (g−1)+1 n 2F (g−1) n (mit (5) und (6)) 2r F (g)−F (g−1) · X r=1 ∞ X n 1 · F (g) r=1 2r n F (g) 1 2r ✷ Man betrachtet jetzt eine Folge von m Operationen, m ∈ Ω(n). union kostet jeweils O(1), zusammen: O(m). Teuer sind die finds, jeweils proportional zur Länge des Weges zur Wurzel. Vorgehen wie am Ende von 5.1, aber es gibt rote und grüne Kreuze in der Tabelle; rote Kreuze werden zeilenweise (per Op.), grüne Kreuze werden spaltenweise gezählt. Oder auch: Zur Buchhaltung verteilt man Münzen, wobei jeder Knoten auf dem Weg eine Münze erhält. Der Gesamtaufwand ist also die Summe dieser Münzen. Ein Knoten v bekommt ein Goldstück (rotes Kreuz), falls • v die Wurzel ist, • der Vater von v die Wurzel ist, oder • der Vater von v zu einer anderen Ranggruppe als v gehört. Sonst bekommt v ein Silberstück (grünes Kreuz, per Knoten zählen). (9) Bei jedem find werden höchstens G(n) + 2 Goldstücke verteilt, zusammen also O(m · G(n)). Beweis: 2 Goldstücke für die ersten beiden Fälle; je Ranggruppe eins für den dritten Fall; vgl. (7). ✷ Jetzt zählt man für jeden Knoten v seine gesamten Silberstücke. Knoten in Ranggruppe 0 bekommen wegen des 3. Falls nur Goldstücke (bzw. 1. Falls für Einzelknoten). v bekommt Silberstücke nur, • wenn es nicht die Wurzel ist, wenn sein Rang g > 0 also endgültig feststeht; (*) • wenn der Vater nicht die Wurzel ist, d. h. v einen neuen Vater bekommt; dabei ist der Rang des neuen Vaters jedesmal größer als der alte (3). Da es in Ranggruppe g nur F (g) − F (g − 1) Ränge gibt (6), kann dies höchstens F (g)-mal geschehen; danach erhält v nur noch Goldstücke nach dem 3. Fall. Zusammen mit (7) und (8) ist also die Gesamtzahl der Silberstücke: (wegen (*) ist V (g) im folgenden der Wert des Endzustands) 50 5.3 Pfadverkürzung G(n) P g=1 V (g)F (g) ≤ G(n) P g=1 5 VERWALTUNG DISJUNKTER MENGEN n F (g) · F (g) = n(G(n)) ∈ O(n · log∗ (n)) Insgesamt werden also O(m · log∗ (n)) + O(n · log∗ (n)) Münzen (9) verteilt. Satz 5.2. m union/find-Operationen mit m ∈ Ω(n) benötigen bei Pfadverkürzung zusammen O(m · log∗ (n)) Zeit. Wie beim Hashing betrachtet man nicht den schlechtesten Fall für eine Operation sonder den schlechtesten Fall für eine Folge von Operationen von einem Startzustand aus; hier ist diese amortisierte Zeit gut, falls die Folge lang genug ist. 51 6 Graphenalgorithmen Ein Graph beschreibt eine Verbindungsstruktur wie z. B. elektrische Schaltungen, Straßennetze von Städten/Ländern, Telefonnetze, Flußdiagramme. Beispiel: planar? (= es gibt krezungsfreie Einbettung in die Ebene) ✉ ❅ ❅ ❅✉ ✉ ❅ ❅ ❅ ❅ ❅ ✉ ❅✉ Häuser ✉ ✉ ✟✉ ❍ ❅❍❍ ❅ ✟ ✟ ❅ ❍ ❅✟ ✟ ❅ ❍❍ ✟❍ ❅ ✟ ❅ ❍❍❅ ✟✟ ❅ ✟ ❍❅ ✟ ❍ ✉ ❡ ❅✉ ❅ ✟ ❍✉ Brunnen Ein Graph G = (V, E) besteht aus • einer (hier endlichen) Menge V von Ecken (vertex, vertices) • einer Menge E von Kanten (edges): a) E ⊆ V 2 , G heißt gerichtet (→) b) E ⊆ {{v, w} | v, w ∈ V }, G heißt ungerichtet; V (2) = {{v, w} | v, w ∈ V, v 6= w} In jedem Fall schreibt man Kanten als vw; dann ist w ein a) Außen-Nachbar bzw. b) Nachbar von v. Der (Außen-)Grad von v ist die Anzahl seiner (Außen-)Nachbarn. Das stimmt mit dem Grad bei Bäumen (s.o.) überein, wenn man die Bäume als gerichtet sieht (von der Wurzel weg). Analog definiert man den Innengrad. T = (VT , ET ) ist ein Teilgraph von G, T ⊆ G, wenn VT ⊆ V und ET ⊆ E. (Bem.: T ist ein von VT induzierter Teilgraph, wenn ET = E ∩ VT2 bzw. = E ∩ {{v, w} | v, w ∈ VT }.) Wenn man bei einem gerichteten Graphen die Richtungen der Kanten ignoriert, erhält man den unterliegenden ungerichteten Graphen. Ein Weg (von v1 nach vn , ein v1 ,vn -Weg) in G ist eine Sequenz v1 v2 . . . vn , n ≥ 1, so daß vi vi+1 ∈ E für i = 1, . . . , n − 1, wobei in ungerichteten Graphen vi 6= vi+2 für i = 1, . . . , n − 2. Seine Länge ist n − 1 (entspricht also der Anzahl der Kanten). Sind alle vi verschieden, so heißt er einfach; sind alle vi verschieden mit der Ausnahme v1 = vn und a) n ≥ 3 bzw. b) n ≥ 4, so heißt er Kreis. Offenbar gilt: ∃ Weg von v nach w (d.h. für für den gerichteten Fall: (v, w) ∈ E ∗ für die Relation E) ⇐⇒ ∃ einfacher Weg von v nach w. Eine Kante vv heißt Schlinge. Im folgenden werden nur schlingenlose und, soweit nicht anders angegeben, ungerichtete Graphen betrachtet, d.h. E ⊆ V (2) . Die Relation „∃ Weg von v nach w“ ist eine Äquivalenzrelation; jede Äquivalenzklasse (zusammen mit den Kanten von G zwischen den entsprechenden Ecken) heißt Zusammenhangskomponente von G. G ist zusammenhängend (zh.), wenn es von jedem v zu jedem w einen Weg gibt. Die Zusammenhangskomponenten sind also die maximalen zusammenhängenden Teilgraphen von G. 6.1 Darstellung von Graphen 6 GRAPHENALGORITHMEN Ein Baum ist ein kreisloser zusammenhängender Graph (andere Sichtweise: keine Wurzel); ein Blatt ist eine Ecke vom Grad 1. Ein Wald ist ein kreisloser Graph (dessen Zusammenhangskomponenten Bäume sind). Bei gerichteten Graphen ist ein Baum ein Graph mit einer Wurzel r ∈ V , so dass es zu jedem v genau einen r, v-Weg gibt. Ein Blatt hat Außengrad 0. (Der unterliegende ungerichtete Graph ist ein ungerichteter Baum.) Ein Wald ist eine disjunkte Vereinigung von Bäumen. Oft haben die Kanten (oder Ecken) Gewichte, gegeben durch g : E 7→ R+ 0 o. ä. Im weiteren ist immer |V | = n und |E| = m. 2 . (n(n − 1) bei gerichtetem G), d. h. m ∈ O n Proposition 6.1. Es ist 0 ≤ m ≤ n(n−1) 2 Ist m ∈ Θ n2 , heißt G dicht, sonst dünn (sparse, spärlich); z.B. haben planare Graphen höchstens 3n − 6 Kanten, d. h. m ∈ O(n); speziell Bäume haben n − 1 Kanten. 6.1 Darstellung von Graphen Man nimmt an, daß V = {1, . . . , n}. Sind die Ecken z. B. Städtenamen, speichert man die Zuordnung Name–Nummer 1) Adjazenzmatrix ( 1 : wenn vw ∈ E . 0 : wenn vw ∈ /E (Man speichert also statt der ungerichteten Kante vw = wv die gerichteten Kanten vw und wv.) A ist eine n × n-Matrix mit A(v, w) = Ist E gewichtet, so kann man statt 1 auch g(vw) speichern; sind kleine Gewichte gut (z. B. bei Straßenentfernungen), sollte man A(v, v) = 0 und sonst A(v, w) = ∞ für vw ∈ / E setzen. • schnelle Abfrage “vw ∈ E?” in O(1) • “Hat v einen Nachbarn?” bzw. “Finde einen/alle Nachbarn von v !” braucht aber O(n) • Bei dünnen Graphen verschwendet A Platz; s.o. Wegen der Probleme verwendet man meist 2) Adjazenzlisten Beispiel: ✗✔ ✖✕ ✱ ✱ ✗✔ ✱ 2 ✖✕ ❧ ❧ ❧✗✔ ✗✔ ✖✕ 4 ✲ 1 ✖✕ 3 ✗✔ ✖✕ 5 53 1 ✲ 2 ✲ 3 2 ✲ 1 ✲ 4 3 ✲ / ✲ 3 / 6.2 Zusammenhangskomponenten, Tiefen- und Breitensuche 6 GRAPHENALGORITHMEN Kante vw erscheint in den Listen von v und w; evtl. g(vw) oder Verweis auf die “Gegenkante” zusätzlich eintragen. (Man speichert also wieder statt der ungerichteten Kante vw = wv die gerichteten Kanten vw und wv.) • “Finde einen Nachbarn von v !” in O(1) • “Finde alle Nachbarn von v !”, “vw ∈ E?” in O(Anzahl der Nachbarn von v) • Durchmustern aller Ecken und Kanten in linearer Zeit, d. h. O(n + m) • platzsparend bei dünnen Graphen 6.2 Zusammenhangskomponenten, Tiefen- und Breitensuche DFS: depth first search BFS: breadth first search DFS ist eine oft nützliche Methode, einen Graphen zu durchmustern; sie erzeugt einen Spannbaum T von G (falls G zusammenhängend ist), d. h. • T ist ein Baum • T ist Teilgraph von G • T ist aufspannend, d.h. VT = V Allgemeines Verfahren zur Erzeugung von Spannbäumen: Algorithmus 6.2. Eingabe: G und r ∈ V ( r = ˆ root ) Ausgabe: Spannbaum T der Zusammenhangskomponente von r Man sieht T oft als gerichteten Baum mit Wurzel r. (1) alle Ecken und Kanten seien unmarkiert; (2) markiere r; (3) while (ein markiertes v hat einen unmarkierten Nachbarn w) (4) markiere w und vw; T =(markierte Ecken, markierte Kanten) Prinzipien des Korrektheitsbeweis (Wiederholung): partiell korrekt: Nachweis mit Schleifeninvariante total korrekt: zusätzlich Terminierung; Nachweis mit einer stets abnehmenden Terminierungsfunktion Korrektheitsbeweis von 6.2: a) Terminierung: Bei jedem Schleifendurchgang wird 54 6.2 Zusammenhangskomponenten, Tiefen- und Breitensuche 6 GRAPHENALGORITHMEN b) Invariante bei (3): T ist ein Baum mit r ∈ T ⊆ G Beweis: Nach (2) erfüllt T = ({r}, ∅) die Invariante. Nach (4) sei T ′ = (VT ∪ {w}, ET ∪ {vw}) (vgl. Konvention beim Nachweis von Schleifeninvarianten). Nach Induktion, d.h. weil die Invariante vor (4) galt, ist r ∈ T ′ ⊆ G. Für alle u, u′ ∈ VT gibt es einen Weg von u nach u′ in T ⊆ T ′ , damit in T ′ einen Weg von u über v nach w; T ′ ist also zusammenhängend. T ist kreislos, und kein Kreis in T ′ läuft durch w, da w nur einen Nachbarn (das markierte v) in T ′ hat; also ist T ′ kreislos, d. h. ein Baum. c) Gäbe es am Ende ein u ∈ V − VT und einen Weg von r nach u in G, so enthielte dieser Weg eine Kante vw mit v ∈ VT und w ∈ / VT ; Bedingung (3) würde also gelten. ✷ Implementierung: • G durch Adjazenzlisten • Eckenmarkierung: Feld mit 0,1-Einträgen; Markieren und Abfrage “v ∈ VT ” in O(1) • Kantenmarkierung: Zusatzeintrag in den Listen von G oder Adjazenzlisten von T aufbauen Damit: (1) in O(n + m); (2) in O(1); (4) jeweils in O(1), zusammen: O(n). • Für (3) verwaltet man die markierten Ecken, die (evtl.) unmarkierte Nachbarn haben, in einer Liste als Keller oder Queue (⇒ DFS oder BFS) Wir verwenden hier Listen mit postfix für push bzw. enq; last und first liefern das letzte (im Keller oberste) bzw. erste (in der Queue erste) Element, ohne es zu entfernen; init bzw. rest entfernen es dann. Mit ft wird jeder Ecke eine laufende Nummer zugeordnet; ft 6= 0 ist äquivalent zu markiert. Wir werden erst weiter unten daruaf eingehen. Zusammen mit lt ordnen ft und lt jeder Ecke die „Zeit“ des ersten und letzten Besuchs zu. 55 6.2 Zusammenhangskomponenten, Tiefen- und Breitensuche 6 GRAPHENALGORITHMEN Algorithmus 6.3. (1) (2) (3) (4) (5) (6) (7) (8) (9) (10) (11) (12) (13) Ecken, Kanten unmarkiert; V -Felder ft (und evtl. lt) auf 0 initialisieren; markiere r; int time=1; list=postfix(emptylist,r); ft[r]=time++; for all (v ∈ V ) A(v) = {w | vw ∈ E}; while (list != empty) { v=last(list) bzw. v=first(list); if (A(v) 6= ∅) { choose w ∈ A(v); if (w unmarkiert) { markiere w und vw; list=postfix(list,w); ft[w]=time++; } A(v) = A(v) − {w}; } else {list=init(list) bzw. list=rest(list); // lt[v]=time++; }} Beispiel: 1 2 9 5 10 7 3 6 8 4 Kellervariante DFS 1 2 10 5 7 3 6 4 Wir beginnen bei 1 und gehen zunächst vorwärts zu 10; der Keller wächst. Bei 10 finden wir keinen unmarkierten Nachbarn und – Zeile (13) – setzen zurück zum Vater 3 (Backtracking). Keller: 1 2 4 3 10, dann 1 5 7 6 Bemerkung: DFS durchläuft den Spannbaum sozusagen in Prä-In-Postfix-Ordnung; z.B. wird 1 dreimal besucht. Die fehlende Kante 2 10 wird zuerst von 10 aus entdeckt, wobei 2 ein Vorfahr ist. Das ist bei allen fehlenden Kanten so; sie heißen daher Rückwärtskanten. 56 6.2 Zusammenhangskomponenten, Tiefen- und Breitensuche 6 GRAPHENALGORITHMEN Queue-Variante BFS 1 2 10 5 7 3 6 4 Queue: 1 2 3 5} 4 | 10{z6 7} |{z} | {z Tiefe 0 Tiefe 1 Tiefe 2 Bemerkung: Graphen sind oft nur implizit gegeben. Z.B. kann ein interaktives Programm von einem Zustand (im Prinzip = Variablenbelegung) im nächsten Schritt zu verschiedenen Zuständen übergehen; alle diese Übergänge bilden einen (gerichteten) Graphen, ein sog. Transitionssystem. Dieses kann man mit DFS/BFS systematisch durchlaufen, wobei man es während des Durchlaufs erzeugt. DFS/BFS bei unendlichen Graphen, z.B. bei unendlichem Binärbaum; beim unendliche Binärbaum (und allgemeiner bei lokal-endlichen Graphen) erreicht Korrektheit und Laufzeit von 6.3: (10) entspricht (4) aus 6.2. (3)-(13) ohne (10) realisieren (3) aus 6.2, denn: list enthält nur markierte Ecken, A(v) nur Nachbarn von v; ist (9) also wahr, so auch (3) in 6.2. (12) streicht nur markierte Ecken aus A(v), d. h. A(v) enthält alle unmarkierten Nachbarn von v. Ist demnach A(v) = ∅ und v wird aus list gestrichen (13), so hat v keine unmarkierten Nachbarn; list enthält also alle markierten v mit unmarkierten Nachbarn. Terminiert (5), so auch (3) in 6.2. Es bleibt noch, die Termination zu zeigen, denn ... ; s. u. A(v) realisiert man durch einen Zeiger, der die Adjazenzlisten von v durchläuft; daher: (4) in O(n), (7), (8), (12) und ggf. (10) jeweils in O(1). Damit: (1)-(4) in O(n + m) (5)-(13) jeweils in O(1) Bei jedem Schleifendurchlauf wird entweder ein |A(v)| vermindert, zusammen ...-mal, oder ein v (für immer) aus list gestrichen, zusammen ≤ n-mal. Die Schleife wird also O(n + m)-mal durchlaufen (Terminierung!), was auch die Gesamtlaufzeit ist. Satz 6.4. DFS bzw. BFS ermittelt einen Spannbaum der jeweiligen Zusammenhangskomponente in O(n + m). Scan durch das Markierungsfeld zeigt, ob alle Ecken markiert sind, was genau dann gilt, wenn G zusammenhängend ist. Korollar 6.5. “Ist G zusammenhängend?” läßt sich in O(n + m) entscheiden. Weitere Variation: 57 6.2 Zusammenhangskomponenten, Tiefen- und Breitensuche 6 GRAPHENALGORITHMEN c=1; do { (1) wähle unmarkiertes r ∈ V ; (2) DF S(G, r) mit Eckenmarkierung c; c=c+1; } (3) while (es gibt unmarkierte Ecke) Dies ergibt (1) und (3) erfordern insgesamt einen Durchlauf durch das Markierungsfeld, also O(n). Für (2) müssen (1) und (4) in 6.3 nur einmal ausgeführt werden; (2) benötigt insgesamt O(n + m). Korollar 6.6. Zusammenhangskomponenten lassen sich in O(n + m) bestimmen. 58 6.3 Gerichtete Graphen: starke Zusammenhangskomponenten 6.3 6 GRAPHENALGORITHMEN Gerichtete Graphen: Topologisches Sortieren und starke Zusammenhangskomponenten In diesem Abschnitt betrachten wir gerichtete Graphen. Beim topologisches Sortieren eines gerichteten Graphen numeriert man die Ecken so durch, dass jede Kante von einer kleineren zu einer größeren Ecke läuft. Offenbar existiert so eine Sortierung nicht, wenn ein Kreis existiert – die Ecke des Kreises mit der kleinsten Nummer hat eine eingehende Kante von einer anderen Kreisecke. Beachten Sie, dass in einem kreislosen gerichteten Graphen, einem DAG (directed acyclic graph), Wege von einer Ecke aus wieder zusammenlaufen können; wenn man die Richtungen ignoriert, erhält man den unterliegenden ungerichteten Graphen, der Kreise haben kann. Für eine topologische Sortierung eines DAG gibt man wiederholt einer Ecke v mit Innengrad 0 die nächste Nummer und entfernt sie. Eine solche Ecke existiert, da man von einem bel. w rückwärts laufend zu einem solchen v kommt. Die Kanten von v erfüllen die Bedingung einer topologischen Sortierung. Nach Entfernen von v hat man wieder einen DAG, der letztlich eine korrekte Numerierung erhält (Induktion über n). 2 3 4 1 Abbildung 12 6 5 topologische Sortierung Zur effizienten Implementierung durchläuft man die Adjanzenzlisten und bestimmt in O(n+ m) für jedes v den Innengrad indeg(v) und setzt die v mit indeg(v) = 0 auf eine Liste. Dann entfernt man wiederholt ein v aus der Liste, vergibt die nächste Nummer, dekrementiert alle indeg(w) für w auf der Adjanzenzliste von v, und fügt w der Liste hinzu, falls indeg(w) 0 wird. Dies dauert zusammen O(n + m). So ergibt sich nach obigem eine topologische Sortierung. Ist der Graph kein DAG, so wird die Liste leer, obwohl noch Ecken ohne Nummer existieren. Man kann also auch in O(n + m) entscheiden, ob ein Graph ein DAG ist. Satz 6.7. “Ist G azyklisch?” läßt sich in O(n + m) entscheiden. Gegebenenfalls kann eine topologische Sortierung in O(n + m) konstruiert werde. Die Relation „∃ Weg von v nach w und umgekehrt“ ist eine Äquivalenzrelation; jede Äquivalenzklasse (bzw. der induzierte Graph) heißt starke Zusammenhangskomponente (SCC) von G. G ist stark zusammenhängend, wenn es von jedem v zu jedem w einen Weg gibt. Die SCC sind also die maximalen stark zh Teilgraphen von G. SCC spielen z.B. eine Rolle in der temporalen Logik für das CTL-Model-Checking. Im gerichteten Fall sind die Komponenten evtl. miteinander verbunden. Der SCC-Graph von G hat eine Ecke für jede SCC und eine Kante, wenn es in G eine Kante von einer SCC zu einer anderen gibt. G in Abb. 13 hat die SCC 123, 456789 und 10, der SCC-Graph 3 Kanten. Offenbar ist ein SCC-Graph immer ein DAG. Hat C Grad 0 in einem SCC-Graphen, nennen wir C eine Blatt-SCC. Offenbar gibt es keine Kante von C zum restlichen Graphen. In Abb. 13 ist 456789 die einzige Blatt-SCC. 59 6.3 Gerichtete Graphen: starke Zusammenhangskomponenten 6 GRAPHENALGORITHMEN DFS wie in Algorithmus 6.3 funktioniert gleichermaßen für gerichtete Graphen und erzeugt einen (gerichteten) DFS-Baum mit Wurzel r, den wir im weiteren T nennen wollen. Abb. 13 zeigt einen Graphen G; die dicken Kanten sind die Baumkanten, also die Kanten von T . Die Ecken wurden mit ihren f t-Werten identifiziert, d.h. sie wurden in der Reihenfolge der Nummern zu T hinzugefügt; r = 1 und ∀v ∈ T : r ≤ v. Im Bsp.: 31, 86 und 94 sind wieder Rückwärtskanten. 10 5 und 98 sind Querkanten, d.h. dass jeweils keine der beiden Ecken Nachkomme der anderen ist. Zudem ist 59 eine Vorwärtskante: 9 ist Nachkomme von 5. Im ungerichteten Fall wäre diese Kante schon von 9 aus entdeckt worden und würde als Rückwärtskante betrachtet. In T ist jedes v Wurzel eines Teilbaums Tv , wobei ∀w ∈ Tv : w ≥ v (w wurde später hinzugefügt). Wenn v verlassen wird, Zeile (13), ist Tv vollständig und (*) für alle jetzt markierten w 6∈ Tv ist w < v. Zu diesen w zählen alle Außennachbarn von Ecken in Tv (soweit sie nicht in Tv liegen). Um die SCCs zu bestimmen, will man bei DFS ihre Wurzeln bestimmen: s ist Wurzel einer SCC C, wenn es die erste besuchte, d.h. kleinste Ecke von C ist. Verlässt man s, liest man C aus einem zusätzlichen Keller szk (mit push und pop) aus. Zur Bestimmung berechnet man für alle v einen Wert low(v). Grob gesagt gibt low(v) den kleinsten Vorfahr an, den man von v erreichen kann; v und low(v) gehören also zur selben SCC. Man fügt die folgenden Zeilen an den passenden Stellen in Algorithmus 6.3 ein: Algorithmus 6.8. Ergänzung zu Algorithmus 6.3: (1.1) (3.1) szk= emptystack; V -Feld stacked auf false initialisieren; low[r]=1; szk.push(r); stacked[r]=true; (11.1) (11.2) low[w]=ft[w]; szk.push(w); stacked[w]=true; else if (ft[w]<ft[v] und stacked[w]) { low[v] = min(low[v],ft[w]); } (13.1) if (low[v]==ft[v]) { pop und Ausgabe aller w von szk inkl. v; jeweils stacked auf false; } else if (list != empty) { low[last(list)] = min(low[last(list)],low[v]); } (13.2) Im Bsp. in Abb. 13 sind die Zustände von szk vor und nach einer Ausgabe (top ist rechts): 123456789, 123, 123 10, 123, leer Die if-Bedingung in (11.2) ist zum ersten Mal wahr für v = 3, w = 1 und neuem low(3) = 1, geschrieben: 3:1. Es folgt 8:6, low(7) = 6 beim Backtracking wegen (13.2) und 9:8. Hier ist 8 kein Vorfahr von 9, 98 ist eine Querkante. Dann 9:4 sowie low(6) = 4 und low(5) = 4 wegen (13.2). Bei der Vorwärtskante 59 gilt in (11.2) nicht f t(w) < f t(v). Dann wird 4 als Wurzel 60 6.3 Gerichtete Graphen: starke Zusammenhangskomponenten 6 GRAPHENALGORITHMEN 5 10 7 1 4 6 8 2 3 Abbildung 13 9 Bestimmung der SCC erkannt, (13.1), und 456789 ausgegeben. Beachten Sie, dass nicht alle Ecken von C jetzt 4 als low-Wert haben; die obige grobe Idee zu low ist also nicht exakt. Im weiteren wird 10 5 betrachtet; dabei gilt in (11.2) nicht stacked(w), so dass low(10) nicht fälschlicherweise auf 5 gesetzt wird. (1.1) benötigt O(n) Zeit; die anderen Ergänzungen fügen im wesentlichen alten Zeilen konstanten Aufwand hinzu. Ausnahme ist die Ausgabe aus szk, die insgesamt O(n) Zeit braucht. Der Gesamtaufwand ist also immer noch O(n + m). Beachten Sie: 456789 ist eine (hier: die) Blatt-SCC. Sei G′ der Graph, der aus G durch Entfernen dieser SCC entsteht; DFS läuft auf G′ im Prinzip genauso ab wie auf den Ecken in V ′ bei Eingabe von G. Es existieren nur zwei Unterschiede, die aber irrelevant sind: Es gäbe für G′ keinen Sprung in der Numerierung (hier: 123 10); aber nur die Ordnung der ft-Werte und nicht die exakten ft-Werte sind wichtig. Auch gäbe es keine Kanten wie 10 5; 10 5 ist aber ohne Einfluss, da die Bedingung in (11.2) falsch ist – wie gerade bemerkt. Zur Korrektheit müssen wir also nur zeigen: Ist C eine Blatt-SCC mit Wurzel s, dann i) hat Ts genau die Ecken von C, ii) wird s als Wurzel erkannt und iii) daraufhin C ausgegeben. i) Ts enthält nur Ecken von C, da alle Ecken von Ts von s aus erreicht werden können und C eine Blatt-SCC ist Würde eine Ecke von C fehlen, gäbe es auf einem Weg von s zu dieser Ecke eine Kante vw mit v ∈ Ts und w 6∈ Ts . In Zeile (9) war w also schon markiert und wegen (*) wäre s > w gar nicht die Wurzel von C. ii) Alle v ∈ C haben stets low(v) ≥ s: Dies ist initial in (11.1) der Fall und kann weder in (11.2) falsch werden – auch w gehört zur Blatt-SCC C – noch in (13.2) – wenn last(list) in der Blatt-SCC C, so auch v. Also bleibt low(s) = s. iii) Wir müssen nur zeigen, dass kein v ∈ C mit v 6= s als Wurzel „erkannt“ wird. Dann liegt bei (13.1) für s Ts oberhalb von s auf szk und C wird ausgegeben. Angenommen für v ∈ C \ {s} gilt bei (13.1) zum ersten Mal low(v) = f t(v). Betrachte einen v, s-Weg. Dieser enthält wegen s 6∈ Tv eine Kante v ′ w, so dass v ′ in Tv , w aber nicht. Wegen (*) ist w < v. Da C eine Blatt-SCC, ist w ∈ C; beim Erreichen von 61 6.3 Gerichtete Graphen: starke Zusammenhangskomponenten 6 GRAPHENALGORITHMEN (11.2) für v ′ w wurde noch nichts von C ausgegeben. Also ist die Bedingung erfüllt, low(v ′ ) und damit low(v) werden ≤ w. Damit ist die Bedingung in (13.1) verletzt. Satz 6.9. Die SCCs eines gerichteten Graphen lassen sich in O(n + m) bestimmen. Graphen finden sich auch als Abhängigkeitsgraphen in der SWT. Entspricht die Abhängigkeit der Vererbung zwischen Klassen, so macht ein Kreis das geplante Vorgehen unmöglich. Entspricht sie der Verwendung einer Klasse in einer anderen, so spricht ein Kreis für schlechte SW-Qualität: Separates Testen ist nicht möglich, Wiederverwendung wird erschwert. Aufgrund von Satz 6.7 lässt sich effizient entscheiden, ob ein Kreis existiert, aufgrund von Satz 6.9 lassen sich alle zyklischen Abhängigkeiten effizient bestimmen durch Angabe der SCCs mit mehr als einer Ecke. 62 6.4 Kürzeste Wege 6.4 6 GRAPHENALGORITHMEN Kürzeste Wege Gewichteter Graph: g(e) ≥ 0 für alle Kanten. Die Distanzen in G sind definiert durch: k P d(v, w) = min{ g(vi vi+1 ) | v1 . . . vk ist ein Kantenzug von v nach w} i=1 Sei Gc der vollständige Graph auf V (der also alle möglichen Kanten vw enthält) mit der erweiterten Gewichtsfunktion, für die g(vw) := ∞ für vw ∈ / E. (Implementierung: ∞ ↔ MAXINT) Dann gilt: d(v, w) 6= ∞ in Gc ⇐⇒ ∃ Kantenzug von v nach w in G. In diesem Fall ist d(v, w) in beiden Graphen gleich, während sonst d(v, w) in G undefiniert ist. Wir arbeiten daher z.T. mit Gc , um G zu behandeln. Bemerkung: Wäre g(e) < 0 zulässig, könnte es Probleme geben: • Hinzufügen einer Kante zu einem Weg macht ihn evtl. kürzer; wegen g(e) ≥ 0 ist bei uns immer ein Weg ein kürzester Kantenzug. • Was ist d(v, w) in v 1 ✁❆ -1 ✁ ❆ -1 ✁ ❆ 1 ? 1 w Problem 1: Gegeben G und s, t ∈ V . Bestimme d(s, t) und einen kürzesten Weg von s nach t (source, target). Die bekannten Verfahren lösen immer gleich: Problem 2: Gegeben G und s ∈ V . Bestimme d(s, v) und einen kürzesten s, v-Weg für alle v ∈V. Im folgenden Verfahren ist OK eine Menge von Ecken, für die das Problem gelöst ist; hier ist d(v) = d(s, v) und pre(v) ein Vorgänger von v auf einem kürzesten s, v-Weg. Ansonsten gilt: d(v) ≥ d(s, v) Dijkstras Kürzester-Wege-Algorithmus (1) (2) (3) int d[n+1], pre[n+1]; for all (v 6= s ) { d[v]=g(sv); pre[v]=s; } d[s]=0; OK={s}; while (OK6=V) { finde w ∈OK / mit minimalem d[w]; OK=OK∪{w}; for all (v∈OK / ) { if (d[v]>d[w]+g(wv)) 63 6.4 Kürzeste Wege 6 GRAPHENALGORITHMEN { d[v]=d[w]+g(wv); pre[v]=w; } } } s 2 8 18 4 10 6 12 1 Bsp.: Abbildung 14 Korrektheitsbeweis: Termination: while-Schleife wird (n − 1)-mal durchlaufen. Bei (2) gilt stets: i) ∀v ∈ OK : d(v) = d(s, v) ii) ∀v ∈ / OK : d(v) ist die Länge eines kürzesten s, v-Weges, der außer v nur Ecken aus OK verwendet iii) ∀v ∈ V − {s}: mittels pre ergibt sich ein s, v-Weg der Länge d(v); pre(v) ∈ OK i)-iii) gilt am Anfang (OK = {s}). Da am Ende OK = V , folgt die Korrektheit aus i) und iii). Nachweis, dass i)-iii) bei einem Schleifendurchlauf erhalten bleibt: Gelte i)-iii) für OK, und w sei die nächste Ecke (gibt es wg. OK 6= V ). i) und iii) bleiben wahr für v ∈ OK. (Für iii) beachte, daß pre(v) ∈ OK und daß für alle v ′ ∈ OK pre(v ′ ) unverändert bleibt.) Angenommen, es gibt einen s, w-Weg P , der kürzer als d(w) ist. Wegen ii) verläuft P nicht nur in OK ∪ {w}; sei also w′ die erste Ecke auf P mit w′ ∈ / OK. Nach Wahl von w gilt ′ ′ d(w) ≤ d(w ); P ist Verlängerung des s, w -Wegstücks, das nach ii) eine Länge ≥ d(w′ ) hat, d.h. P ist nicht kürzer als d(w) =⇒ WIDERSPRUCH! Also gilt i) und auch iii) für w; damit gelten i) und iii) für alle Ecken in der neuen Menge OK ′ = OK ∪ {w}, und wir müssen ii) und iii) für alle Ecken außerhalb von OK ′ zeigen. Sei nun v ∈ / OK ′ . Wenn jeder kürzeste s, v-Weg in OK ∪ {v, w} w benutzt, besteht ein solcher Weg aus einem kürzesten s, w-Weg der Länge d(w) und der Kante wv. Der Weg kann 64 6.4 Kürzeste Wege 6 GRAPHENALGORITHMEN s s OK OK d(w) w' P w w v w' nicht von w nach OK zurücklaufen, z. B. zu w′ , denn nach i) und iii) beschreiben die pre-Werte einen kürzesten s, w′ -Weg, der ganz in OK verläuft, so daß man w auch auslassen könnte. Ein solcher Weg hat nun die Länge d(w) + g(wv) < d(v). (Einen Weg der Länge d(v), der w nicht benutzt, haben wir ja schon.) d(v) und pre(v) werden also korrekt gemäß ii) und iii) verändert. Ansonsten ist ein kürzester s, v-Weg in OK ∪ {v, w} auch in OK ∪ {v}, so daß d(v) und pre(v) zu Recht unverändert bleiben. ii) und iii) gelten also am Schleifenende für v ∈ / OK. ✷ Laufzeit (1) in O(n), (2) jeweils O(n), zusammen O(n2 ), (3) braucht nur für die (wirklichen!) Nachbarn von w durchgeführt zu werden, zusammen also O(m). Insgesamt ergibt sich O(n2 ), was für dichte Graphen linear, also optimal ist. Alternativ kann man V − OK in einer priority queue (Min-Halde) halten, geordnet nach dWerten – also als Paare (d(v), v). (1) braucht immer noch O(n) (buildheap); (2) ist deletemin, zusammen O(n log(n)). (3) macht Probleme. 1. Lösung: Hinzufügen einer Prozedur decreasekey(v,newvalue), die den Wert d(v) auf newvalue vermindert und die Min-Halde updatet; v muß dazu – wie beim Einfügen – in der Halde aufsteigen, was O(log(n)) benötigt. Allerdings kann man v in der Halde eigentlich nicht finden; man muß also in einem Feld speichern, wo v in der Halde liegt, und diese Information ständig erneuern. Dies ist größenordnungsmäßig ohne zusätzlichen Aufwand möglich. (3) erfordert also insgesamt O(m log(n)). Laufzeit ist also O(n + n log(n) + m log(n)) = n2 O(m log(n)) unter der Annahme m ∈ Ω(n); für m ∈ o log(n) ist dies eine Verbesserung. decreasekey stört aber die Wiederverwendung eines Moduls für Halden. 65 6.4 Kürzeste Wege 6 GRAPHENALGORITHMEN 2. Lösung: Für jeden neuen Wert d(v) wird (d(v), v) in die Halde eingefügt, v wird also mehrfach gespeichert. Glücklicherweise wird das richtige Paar (mit kleinstem d(v)-Wert) als erstes gefunden; man muß aber für w = deletemin prüfen, ob bereits w ∈ OK. Die Halde muß jetzt bis zu n + m Werte speichern; der Platzbedarf steigt also. (Zunächst n Werte; dann für jede Kante bei Aufnahme der 1. Ecke in OK evtl. ein neues Paar für die 2. Ecke.) Auch sind mehr deletemins nötig; praktisch wird diese Lösung wohl langsamer als die 1. Lösung sein. Sie ist aber leichter zu programmieren (Wiederverwendung), und die Laufzeit ist: (1) in O(n), (2) in O(m log(m)), (3) erfordert ≤ m Einfügungen, also O(m log(m)). Zusammen: O(m log(m)) = Sonderfall: Die Gewichte aller vorhandenen Kanten sind 1, d. h. G ist ungewichtet. Hier findet (2) zunächst die Nachbarn von s, dann die Nachbarn der Nachbarn etc. Dijkstra entspricht BF S, was in O(n + m) läuft. Satz 6.10. In einem gewichteten Graphen G lassen sich alle kürzesten Wege von s ∈ V in O(n2 ) bzw. O(m log(n)) finden; bei ungewichtetem G in O(n + m). 66 7 TURINGMASCHINEN (WIEDERHOLUNG) 7 Turingmaschinen (Wiederholung) Turingmaschinen (Alan Turing 1936) sind mathematische Modelle der real existierenden Rechner, d.h. sie können trotz ihrer Einfachheit alles, was ein wirklicher Computer kann. Eine Turingmaschine hat eine endliche Steuerung und ferner ein Band mit unendlich vielen Zellen, das zu Beginn die Eingabe aufnimmt und zugleich als Arbeitsspeicher dient. _ _ e i n g a a _ _ Arbeitsband Lese-/Schreibkopf endliche Steuerung p Abbildung 15 Definition 7.1. Eine (nicht–deterministische) Turingmaschine (TM) ist ein Tupel M = (Q, Σ, Γ, δ, q0 , F, _) mit: Q — endliche Menge von Zuständen Σ — Eingabealphabet Γ — Bandalphabet mit Σ ⊆ Γ und Q ∩ Γ = ∅. δ — (endliche) Überführungsrelation ⊆ (Q − F ) × Γ × Q × Γ × {L, R, M } (Links, Rechts, Mitte) q0 — Startzustand F ⊆ Q — Endzustände _ ∈ Γ − Σ — Blank–Symbol ✷ Arbeitsweise: M sei im Zustand q, x ∈ Γ werde gelesen und (q, x, q ′ , x′ , D) ∈ δ. M kann x mit x′ überschreiben, Kopf ein Feld nach links bzw. rechts (D = L bzw. R) oder nicht bewegen und in den Zustand q ′ übergehen. Konfiguration: uqv mit u, v ∈ Γ∗ , q ∈ Q. (Beachte: . . . ) Dabei ist uv die Bandinschrift, M im Zustand q, Lese/Schreibkopf auf dem ersten Zeichen von v (links und rechts von uv nur _). Da wir die unendlich vielen Blanks auf dem Band nicht mitschreiben wollen, normieren wir Konfigurationen in der folgenden Definition. Da wir die Blanks aber doch evtl. nach und nach brauchen, benutzen wir bei der Definition eines Rechenschritts einen Trick. Definition 7.2. Gegeben sei eine Turingmaschine M ; Wörter v, w ∈ Γ∗ QΓ∗ heißen _– äquivalent (≡b ), wenn sie durch Anfügen/Entfernen von _’s am linken bzw. rechten Wortende ineinander überführt werden können. 67 7 TURINGMASCHINEN (WIEDERHOLUNG) v heißt _–reduziert, wenn _weder erstes noch letztes Zeichen ist (z.B. q_ _ a). Offenbar enthält jede _-Äquivalenzklasse genau ein _–reduziertes Wort. Eine Konfiguration ist ein _–reduziertes Element von Γ∗ QΓ∗ . Die Startkonfiguration bei Eingabe w ∈ Σ∗ ist q0 w. Übergang ⊢M zwischen Konfigurationen K1 , K2 : Wir schreiben K1 ⊢M K2 und nennen K2 eine Folgekonfiguration von K1 , falls: • K1 ≡b _K1 _ = uyqxv und K2 ≡b w mit u, v ∈ Γ∗ , x, y ∈ Γ, q ∈ Q; beachte: x, y existieren – warum?. • ∃(q, x, q ′ , x′ , D) ∈ δ mit: i) falls D = R: w = uyx′ q ′ v; ii) falls D = M : w = uyq ′ x′ v; iii) falls D = L: w = uq ′ yx′ v. Die von M akzeptierte Sprache ist n L(M ) = w ∈ Σ∗ | ∗ q0 w ⊢M uqv mit q ∈ F o . Von einer Turingmaschine akzeptierte Sprachen heißen rekursiv aufzählbar, kurz r.e. (recursively enumerable). ✷ Bemerkung: • aufzählbar – abzählbar? • Die Eingabe muß nicht ganz gelesen werden. • Hält die TM in der Konfiguration uqv können wir uv auch als Ausgabe ansehen; die TM hat also zur Eingabe eine Ausgabe berechnet. • Interessiert man sich für Rechenzeiten, so kann man die Anzahl der Schritte betrachten, bis ein Wort akzeptiert oder eine Ausgabe berechnet wird. Definition 7.3. Eine Turingmaschine heißt deterministisch (DTM), falls δ : (Q − F ) × Γ 99K Q × Γ × {L, M, R} eine partielle Funktion ist. ✷ Deterministische Turingmaschinen haben je Konfiguration höchstens eine Folgekonfiguration. Zu jeder TM kann man eine DTM konstruieren, die dieselben Wörter akzeptiert; dazu benötigt sie aber evtl. (viel) mehr Schritte. 68 7 TURINGMASCHINEN (WIEDERHOLUNG) 0,0,R q0 0,_,R q7 _,0,M 1,1,R q2 q1 1,1,R _,_,L _,_,R q5 1,1,L q4 q3 1,_,L 0,0,L 1,1,L _,_,R q6 Abbildung 16 {0n 1n | n ≥ 1}; Beispielrechnung: q0 0011 ⊢M ⊢M ⊢M ⊢M ⊢M Bei Eingabe 0n 1m berechnet M terlassen. q1 011 011q2 q5 01 q1 1 q4 ⊢M ⊢M ⊢M ⊢M ⊢M 0q1 11 01q3 1 q5 _01 1q2 q6 ⊢M ⊢M ⊢M ⊢M 01q2 1 0q4 1 q0 01 q3 1 ; als Ausgabe wird auf dem Band bzw. hin- Es gibt auch eine Variante der Turingmaschinen. deren Band nur einseitig unendlich ist; links von der Startposition des Kopfes „fällt dieser vom Band“, d.h. die Maschine blockiert. Diese Variante leistet dasselbe wie die hier definierte. L ⊆ Σ∗ heißt entscheidbar, wenn es eine deterministische Turingmaschine M gibt, so daß L = L(M ) und M auf jeder Eingabe w ∈ Σ∗ hält (eventuell nicht akzeptierend, falls keine Folgekonfiguration definiert ist.) In anderen Worten: Es gibt ein stets terminierendes Programm, dass bei Eingabe eines Wortes berechnet, ob das Wort zu L gehört oder nicht. Die Wörter in L können gerade Fragestellungen definieren (z.B. Graphen), für die die Antwort (Z.B. auf die Frage „zusammenhängend?“) ja lautet. Das entsprechende Programm kann also entscheiden, ob ein Graph zusammenhängend ist. Eine Eigenschaft P aufzählbarer Sprachen heißt trivial, wenn P = ∅ oder P = {L ⊆ Σ∗ | L ist rekursiv aufzählbar }. P ist entscheidbar, wenn ein Programm bei Eingabe einer TM M berechnen kann, ob L(M ) die Eigenschaft P hat. Ein solches Programm gibt es praktisch nie: Satz 7.4 (von Rice). Jede nichttriviale Eigenschaft P aufzählbarer Sprachen ist unentscheidbar. 69 7 TURINGMASCHINEN (WIEDERHOLUNG) Also: Es ist unentscheidbar, ob für eine Turingmaschine M gilt: • λ ∈ L(M ) (λ: leeres Wort) bzw. allgemeiner für w ∈ Σ∗ : w ∈ L(M ) • L(M ) = ∅, L(M ) ist endlich oder regulär etc. M= b Programm, z.B. Compiler: Es gibt also kein Programm, daß für jeden Compiler prüft, ob er begin end akzeptabel findet. Bem.: „Hat M genau 5 Zustände?“ ist entscheidbar. 70 8 NP-VOLLSTÄNDIGE PROBLEME 8 NP-vollständige Probleme z.B.: C. Papdimitriou: Computational Complexity. Addison-Wesley 8.1 P Beispiel: HC: Gegeben ein Graph G; hat G einen Hamiltonschen Kreis, d. h. einen Kreis, der jede Ecke (genau) einmal durchläuft? Dieses Problem ist verwandt mit dem Problem des Handlungsreisenden (TSP: travelling sales person): Finde einen Hamiltonschen Kreis mit minimalem Gewicht! In diesem Kapitel: nur Entscheidungsverfahren wie HC; T SP läßt sich durch eine Reihe von Ja/Nein-Fragen der Art “Gibt es einen Hamiltonschen Kreis mit Gewicht ≤ g?” lösen; analog andere Optimierungsprobleme. Ein Entscheidungsproblem D besteht aus • einer Menge inst(D) von Fällen (instances), z. B. Menge der Graphen • der Eingabegröße s : inst(D) → N0 , z. B. s(G) = n + m • der Antwort D : inst(D) → {T, F }, z. B. HC(G) = T ⇐⇒ G hat einen Hamiltonschen Kreis Ein Entscheidungsverfahren für D ist eine deterministische Maschine M (z. B. ein CProgramm, eine deterministische Turing-Maschine, eine Random-Access-Maschine), die bei jeder Eingabe aus inst(D) mit einer Antwort aus {T,F} hält, d. h. eine Funktion M : inst(D) 7→ {T, F } berechnet, wobei ∀I ∈ inst(D) : M (I) = D(I). Der Aufwand für M ist, für gegebenes n ∈ N0 , die maximale Rechenzeit, d. h. die maximale Anzahl von Schritten zur Bearbeitung eines I mit s(I) = n. T M , RAM , C sind gleichmächtig, d. h. für alle D gibt es genau dann eine T M , die D entscheidet, wenn es ein C-Programm bzw. eine RAM gibt, die D entscheidet. Der Aufwand allerdings ist vom Maschinenmodell abhängig: eine T M wird länger als ein C-Programm brauchen; der T M -Aufwand ist aber höchstens polynomiell (∼ 4. Grad) größer als der C-Aufwand. Vgl. Wagner: Theoretische Informatik. Für alle unseren bisherigen Verfahren war der C-Aufwand O n3 ; für viele praktische Probleme wie T SP , auch z. B. für HC, sind aber nur exponentielle Verfahren bekannt. Daher die grobe Klassifizierung: Ein Verfahren ist effizient, wenn es in polynomieller Zeit arbeitet, d. h. in O(p(s(I))) für ein festes Polynom p. P ist die Menge der effizient lösbaren Probleme, d. h. für die ein effizientes Entscheidungsverfahren existiert. P ist sehr robust, d. h. unabhängig vom zugrundegelegten Maschinenmodell, s.o. Man kann ein effizientes Verfahren zur Lösung von D2 evtl. wiederverwenden, indem man ein anderes Problem D1 effizient auf D2 zurückführt. Eine (polynomielle many-one-) Reduktion von D1 auf D2 ist eine deterministische Maschine M : inst(D1 ) → inst(D2 ) mit: • ∀I ∈ inst(D1 ) : D1 (I) = D2 (M (I)): statt D1 für I zu beantworten, übersetzen wir I in polynomieller Zeit in M (I) und übernehmen die Antwort von D2 für M (I). 71 8.2 NP 8 NP-VOLLSTÄNDIGE PROBLEME • M arbeitet in polynomieller Zeit in s(I) p D1 7→ D2 heißt: Eine solche Reduktion existiert. M ist gewissermaßen ein Programm, dass die Funktion D2 verwendet, um das Problem D1 zu lösen; die Situation ist speziell, da unser Programm mit dem Aufruf von D2 endet, dessen Ausgabe wir übernehmen. Wenn man D2 beliebig und insbesondere auch evtl. mehrfach zur Lösung von D1 verwendet, spricht man von einer Turing-Reduktion. Wenn wir unser Programm schreiben, müssen wir D2 gar nicht kennen – modulare Programmierung! Im Extremfall existiert vielleicht gar kein geeignetes Programm zur Berechnung von D2 , vgl. Unentscheidbarkeit. p i) D1 7→ D2 ∧ D2 ∈ P =⇒ D1 ∈ P Satz 8.1. p p p ii) D1 7→ D2 ∧ D2 7→ D3 =⇒ D1 7→ D3 Beweis: i) Eine Reduktion M1 von D1 auf D2 ii) analog i). 8.2 ✷ NP Auch wenn kein effizientes Verfahren für HC bekannt ist, so läßt sich doch in linearer C-Zeit (O(n + m)) prüfen, ob eine Folge von Ecken v1 , . . . , vn einen Hamiltonschen Kreis beschreibt. Man muß also “nur” richtig raten. Ein nicht-deterministisches Programm erlaubt Anweisungen ALT Statement {✷ Statement }∗ ENDALT, wobei genau eine der Anweisungen ausgeführt wird. Ein Problem D ist in NP, wenn es eine nichtdeterministische Maschine M gibt (z. B. eine N DT M , ein nicht-det. C-Programm), so daß ∀I ∈ inst(D) gilt: • alle Berechnungen von M auf I benötigen polynomielle Zeit. • D(I) = T ⇐⇒ eine Berechnung von M auf I liefert true, d. h. akzeptiert. Man kann sich ein NP-Verfahren auch so vorstellen: 1. Phase: Rate einen “Lösungsvorschlag”, z. B. eine Eckensequenz. 2. Phase: Verifiziere in polynomieller Zeit; bei Erfolg ist das Ergebnis T . Gibt es keinen “Lösungsvorschlag”, der zum Erfolg führt, ist das Ergebnis F. i) P ⊆ NP Satz 8.2. p ii) D1 7→ D2 ∧ D2 ∈ NP =⇒ D1 ∈ NP Beweis: 72 8.3 SAT 8 NP-VOLLSTÄNDIGE PROBLEME i) klar ii) wie oben ✷ Viele wichtige Probleme sind in NP! Offene Frage: P=NP? Statt zu zeigen: “D ∈ NP ist nicht polynomiell lösbar”, d. h. D ∈ NP \ P, zeigt man: “D ist eines der schwersten Probleme in NP”. D ist NP-vollständig (D ∈ NPC), wenn • D ∈ NP p • D ist NP-hart, d.h. ∀D ′ ∈ NP : D ′ 7→ D Dann ist D eines der schwersten Probleme in NP, denn Satz 8.3. Sei D ∈ NPC. Dann D ∈ P ⇐⇒ P = NP. Beweis: . 8.3 ✷ SAT Frage: NPC6= ∅? Sei U = {x1 , . . . , xn } eine Menge von Variablen; dann heißen xi , xi , i = 1, . . . , n, Literale. Eine Klausel C ist eine Menge von Literalen. Idee: {x1 , x3 , x4 } ↔ x1 ∨x3 ∨¬x4 . Eine Belegung B für U ist eine Funktion U 7→ {T, F }; läßt sich auf aussagenlogische Formeln über U erweitern: B(xi ) = T ⇔ B(xi ) = F B(C) = T ⇔ ∃l ∈ C: B(l) = T SAT (satisfiability) (Erfüllbarkeit): • ein Fall ist eine Menge A von Klauseln über einer Menge U . Idee: {{x1 , x3 }, {x2 , x5 }} ↔ (x1 ∨ x3 ) ∧ (¬x2 ∨ x5 ), d. h. A ist eine Aussage in konjunktiver Normalform. P |C| – Anzahl Literale • s(A) = C∈A • SAT (A) = T ⇐⇒ es gibt eine erfüllende Belegung, d. h. ∃B: U 7→ {T, F } mit ∀C ∈ A: B(C) = T Satz 8.4. (Satz von Cook) SAT ∈ NPC. Beweisidee: i) SAT ∈ NP: rate erfüllende Bedingung und verifiziere in linearer Zeit. ii) Sei D ∈ NP und M eine entsprechende N DT M . Es ist zu zeigen: ∃ eine polynomielle T M M ′ , die zu jedem I ∈ inst(D) einen entsprechenden Fall von SAT konstruiert, der also erfüllbar ist gdw. D(I) = T . Dieser Fall A beschreibt eine erfolgreiche Rechnung von M : U enthält Variablen 73 8.4 VC 8 NP-VOLLSTÄNDIGE PROBLEME • Q(i, k): M ist nach i Schritten in Zustand qk . • H(i, j): M liest nach i Schritten die Zelle j. • C(i, j, l): Zelle j enthält nach i Schritten das l-te Zeichen tl von Γ. Dabei sind i und damit j polynomiell, k und l sind konstant beschränkt. Wird I z.B. durch die Eingabe t2 t5 t2 t1 beschrieben, so gibt es Klauseln {C(0, 0, 2)}, {C(1, 0, 5)}, {C(2, 0, 2)} und {C(3, 0, 1)}. Ist z. B. δ(qk , tl ) = {(qk1 , ., .) , (qk2 , ., .)}, so enthält A u. a. für alle i, j die Klausel n o Q(i, k), H(i, j), C(i, j, l), Q(i + 1, k1 ), Q(i + 1, k2 ) , d. h. (Q(i, k) ∧ H(i, j) ∧ C(i, j, l)) =⇒ Q(i + 1, k1 ) ∨ Q(i + 1, k2 ) Ist imax der maximale i-Wert und qf der (o.E. einzige) Zustand in F , so ist auch {Q(imax , f )} eine Klausel. Gibt es eine akzeptierende Rechnung (dann mit höchstens imax Schritten), so kann man die Variablen gemäß obiger Erläuterung belegen; diese Belegung ist erfüllend. Hat man umgekehrt eine erfüllende Belegung, so ergibt sich eine akzeptierende Rechnung gemäß obiger Erläuterung. Z.B. sind wegen {C(0, 0, 2)}, {C(1, 0, 5)}, {C(2, 0, 2)} und {C(3, 0, 1)} initial die ersten vier Felder des Bands mit t2 t5 t2 t1 beschriftet; wegen {Q(imax , f )} akzeptiert M tatsächlich. ✷ Hat man erst einmal ein Problem in NPC gefunden, findet man weitere leichter: p Satz 8.5. D1 ∈ NPC ∧ D2 ∈ NP ∧ D1 7→ D2 =⇒ D2 ∈ NPC Beweis: ∀D ′ ∈ NP: ✷ Damit zeigt man z. B., daß 3SAT ∈ NPC: 3SAT umfaßt die Fälle von SAT , in denen jede Klausel genau drei Literale enthält. Da 3SAT ∈ NP und SAT ∈ NPC, muss man lediglich p zeigen, dass SAT 7→ 3SAT . Für weitere Probleme kann man jetzt wahlweise die Reduktion von SAT oder von 3SAT zeigen; auf diese Weise wurde schon für hunderte von Problemen die NP-Vollständigkeit gezeigt. 8.4 VC Als Anwendung von Satz 8.5 behandeln wir V C: Eine Eckenüberdeckung (vertex cover) eines Graphen G ist eine Menge V ′ ⊆ V , so daß ∀vw ∈ E: v ∈ V ′ oder w ∈ V ′ . V C: inst: G und k mit 1 ≤ k ≤ n Frage: Hat G eine Eckenüberdeckung V ′ mit |V ′ | ≤ k? Satz 8.6. V C ∈ NPC Beweis: i) V C ∈ NP: rate V ′ und prüfe in linearer Zeit. 74 8.4 VC 8 NP-VOLLSTÄNDIGE PROBLEME p ii) Um 8.5 anzuwenden, wählt man D1 = 3SAT , es ist also zu zeigen: 3SAT 7→ V C. Sei also A = {C1 , . . . , Cs } über U = {x1 , . . . , xr } ein Fall von 3SAT . Man muß in polynomieller Zeit einen passenden Graphen G und k konstruieren. Beispiel: A = {{x1 , x2 , x3 } , {x1 , x3 , x4 } , {x2 , x3 , x4 }} x2 x3 _ x4 Selektor Tester Abbildung 17 o n = {xi , xi | i = 1, . . . , r} ∪ v1j , v2j , v3j | j = 1, . . . , s n o E = {xi xi | i = 1, . . . , r} ∪ v1j v2j , v2j v3j , v3j v1j | j = 1, . . . , s n o ∪ v1j l1 , v2j l2 , v3j l3 | j = 1, . . . , s und Cj = {l1 , l2 , l3 } V k = r + 2s Zu zeigen: V C(G, k) ⇐⇒ 3SAT (A) “=⇒”: Sei V ′ ein V C mit |V ′ | ≤ k. V ′ muß für jedes i xi oder xi und aus jedem Dreieck mindestens zwei Ecken enthalten. Also ist |V ′ | = k und V ′ enthält (a) für jedes i genau eine Ecke aus {xi , xi } und n o (b) für jedes j genau zwei Ecken aus v1j , v2j , v3j . Setze B(xi ) = T ⇐⇒ xi ∈ V ′ ; wegen (a) ist B eine Belegung mit B(xi ) = T ⇐⇒ xi ∈ V ′ . Sei nun Cj = {l1 , l2 , l3 } und ohne Einschränkung v1j ∈ / V ′ . Um v1j l1 abzudecken, ist also l1 ∈ V ′ , d. h. B(l1 ) = T ; also B(Cj ) = T . Demnach 3SAT (A) = T “⇐=”: Sei B eine erfüllende Belegung; ohne Einschränkung sei für 3} n alle Cj = {l1 , l2 , lo j j ′ B(l1 ) = T . Setze V = {xi | B(xi ) = T } ∪ {xi | B(xi ) = T } ∪ v2 , v3 | j = 1, . . . , s . Dann ist V ′ ein V C mit k = r + 2s Ecken. p Durch Nachweis von V C 7→ HC zeigt man: Satz 8.7. HC ∈ NPC. V Ck : inst: G mit 1 ≤ k ≤ n Frage: Hat G eine Eckenüberdeckung V ′ mit |V ′ | ≤ k? Ist V Ck NP-vollständig? 75 ✷ 8.5 Tripartites Matching 8.5 8 NP-VOLLSTÄNDIGE PROBLEME Tripartites Matching Wir wollen noch einen weiteren NP-Vollständigkeitsnachweis führen. Dazu führen wir zunächst ein: Ein Graph ist bipartit, wenn seine Eckenmenge in zwei disjunkte Teilmengen zerfällt, so dass jede Kante eine Ecke der einen mit einer Ecke der anderen Teilmenge verbindet. Relativ bekannt ist das Problem des bipartiten Matching bzw. des Heiratsproblems: Gegeben ist ein bipartiter Graph, dessen disjunkte Teilmengen (boys und girls) die gleiche Mächtigkeit n haben; Kanten entsprechen „akzeptablen Partnerschaften“. Die Frage ist, ob es ein perfektes Matching gibt, d.h. eine Menge von n Kanten, so dass jeder Junge mit genau einem akzeptablen Mädchen verbunden ist und umgekehrt. Für das tripartite Matching betrachtet man eine Graphen-ähnliche Struktur, bei der die Verbindungen aber mehr als 2 Ecken betreffen können – sogenannte Hypergraphen. Beim tripartiten Matching hat man eine Eckenmenge, die in drei Teilmengen B, G und H (boys, girls, homes) mit je n Elementen zerfällt, und Hyperkanten aus B × G × H. TriM: Gibt es n Hyperkanten, die zusammen B ∪ G ∪ H enthalten? Findet also jeder Junge sein Mädchen und sein Heim, so dass alle glücklich sind? Satz 8.8. T riM ∈ NPC. Beweis: i) T riM ∈ NP: rate die Hyperkanten und prüfe in linearer Zeit. p ii) Um 8.5 anzuwenden, wählen wir wieder D1 = 3SAT , es ist also zu zeigen: 3SAT 7→ T riM . Sei also A = {C1 , . . . , Cs } über U = {x1 , . . . , xr } ein Fall von 3SAT . Man muß in polynomieller Zeit einen passenden Hypergraphen G konstruieren. Während bei VC der Selektor für jedes Literal über U eine Ecke hatte, brauchen wir diesmal für jedes Auftreten eines Literals in einer Klausel eine Ecke. Für jede Variable x ∈ U bestimmen wir daher, wie oft x und wie oft x in A auftritt; sei k das Maximum dieser Zahlen. Dann führen wir für x einen Selektor mit k Jungen, k Mädchen und 2k Heimen ein wie in Abb. 18 für k = 4 gezeigt – wobei jedes Dreieck eine Hyperkante darstellt. Jedes hi repräsentiert ein Auftreten von x, jedes hi ein Auftreten von x; dabei kann es überschüssige hi oder hi geben. Da die bi und gi an keinen anderen Hyperkanten beteiligt sind, gibt es für ein Matching auf diesem Teilgraph nur 2 Möglichkeiten: entweder bleiben alle hi frei (= b x = T ) oder alle hi (= b x = F ). Als Tester führt man für jede Klausel ein b und ein g ein; mit jedem der drei hi bzw. hi , die die Literale der Klausel repräsentieren, bilden b und g eine Hyperkante. Wir können dies b und g also genau dann matchen, wenn im Selektor ein Matching vorgenommen wird, das ein Literal wahr macht. Zusammengefasst: (*) Man kann alle (bisherigen) Jungen und Mädchen genau dann matchen, wenn es eine erfüllende Belegung für A gibt. Allerdings sind nach der bisherigen Konstruktion immer mehr Heime vorhanden als Jungen bzw. Mädchen; die Anzahl dieses Wohnungsüberschusses lässt sich leicht bestimmen. Abschließend fügt man noch entsprechend viele Paare b und g hinzu, die 76 8.5 Tripartites Matching 8 NP-VOLLSTÄNDIGE PROBLEME h1 __ h4 g __ h1 b1 4 g 1 b4 h4 g 3 __ h3 h2 b2 b3 g 2 __ h2 h3 Abbildung 18 jeweils mit jedem Heim eine Hyperkante bilden. Das ändert an (*) nichts, und jedes Matching der bisherigen Jungen und Mädchen läst sich zu einem perfekten tripartiten Matching ergänzen. ✷ T riM ist ein kombinatorisches Problem; es ist deshalb so interessant, weil man mit seiner Hilfe zeigen kann, dass ein eher numerisches Problem auch in NPC ist – Bin-Packing: Bin=Behälter, großer Kasten Problem: Gegeben n Objekte der Größe s1 , . . . , sn , 0 < si ≤ 1, und beliebig viele Kästen der Größe 1. Ziel: Objekte in möglichst wenige Kästen verpacken. Beispiel: .3, .3, .7, .2, .3, .8, .4 1. Möglichkeit: .3 .2 .3 .7 .8 .4 .3 77 8.5 Tripartites Matching 8 NP-VOLLSTÄNDIGE PROBLEME optimal: BP: Gegeben n Objekte wie oben. Kann man diese in höchstens m Kästen packen? p Durch Nachweis von T riM 7→ BP zeigt man (s. Papadimitriou, Abschnitt 9.4): Satz 8.9. BP ∈ NPC. 78 9 ENTWURFSTECHNIKEN FÜR ALGORITHMEN 9 Entwurfstechniken für Algorithmen In diesem Kapitel werden einige Prinzipien/Paradigmen zur Aufgabenlösung vorgestellt, die in unterschiedlichen Ausprägungen vielen Algorithmen zugrunde liegen. 9.1 Greedy Algorithmen Greedy (gierige) Algorithmen arbeiten schrittweise; bei jedem Schritt wird lokal optimiert. Bei manchen Problemen erreicht man so ein globales Optimum. Ein (nicht so gutes) Beispiel: Dijkstras Algorithmus. Gegenbeispiel: Wer in den Bergen nur aufwärts geht, erreicht nicht unbedingt den höchsten Gipfel. Ein besseres Beispiel: 9.1.1 Minimale Spannbäume MST: minimum spanning tree Lemma 9.1. Sei T = (V, E) ein Baum. i) ∀v, w ∈ V : Es gibt genau einen Weg von v nach w. ii) ∀v, w ∈ V : Ist vw ∈ / E, so hat T ′ = (V, E ∪ {vw}) genau einen Kreis; dieser Kreis enthält vw. Beweis: i) Da T zusammenhängend ist, gibt es einen Weg. Gibt es 2 Wege P1 und P2 , so sei die erste Kante auf P2 , die nicht zu P1 gehört. Sei u′ die erste Ecke von P2 nach v ′ , die auf P1 liegt. Dann bilden die v ′ , u′ -Wegstücke von P1 und P2 einen Kreis =⇒ WIDERSPRUCH! ii) Der v, w-Weg nach i) bildet mit vw einen Kreis. Da T kreislos ist, muß jeder Kreis von ′ T vw enthalten, also aus dem eindeutigen v, w-Weg in T und vw bestehen. ✷ v ′ w′ Satz 9.2. Für einen Graphen G mit n ≥ 1 sind äquivalent: i) G ist ein Baum ii) G ist zusammenhängend mit m = n − 1 iii) G ist kreislos mit m = n − 1 Beweis: i) ⇒ ii), i) ⇒ iii) Induktion über n: n = 1: Behauptung offensichtlich wahr Sei also n > 1: Man betrachtet einen maximalen Weg in G, der in einer Ecke v endet. Wegen der Maximalität hat v nur Nachbarn auf dem Weg; wegen der Kreislosigkeit hat v also genau einen Nachbarn. Ein Weg von u 6= v zu w 6= v führt also nicht über v; also ist G − v zusammenhängend und natürlich auch kreislos. Nach Induktion hat G − v also n − 2 Kanten, G hat demnach n − 1. ii) ⇒ i): Hätte G Kreise, könnte man Kanten aus diesen Kreisen entfernen, ohne den Zusammenhang zu zerstören. Es ergäbe sich ein Baum mit m < n − 1 =⇒ WIDERSPRUCH! iii) ⇒ i): G ist ein Wald; jede Zusammenhangskomponente Gi ist ein Baum mit mi = ni −1. Aufsummieren würde zeigen: m < n − 1 =⇒ WIDERSPRUCH! ✷ Problem: Gegeben ein kantengewichteter zusammenhängender Graph. Gesucht ist ein Spannbaum mit minimalem Gesamtgewicht. 79 9.1 Greedy Algorithmen 9 ENTWURFSTECHNIKEN FÜR ALGORITHMEN Anwendungsbeispiel: Es soll ein kostengünstiges Kommunikationsnetzwerk zwischen Rechnern in einer Firma oder zwischen Städten errichtet werden, wobei die Kosten für Punkt-zuPunkt-Verbindung bekannt sind. Da das Netzwerk offenbar ein Baum sein wird, ist also ein MST gesucht. [Vgl. auch: Steiner-Baum, wo neue Knotenstellen eingeführt werden] 1. Lösung: Prims Algorithmus Beginne mit trivialem zh. Graphen (eine Ecke) und laß diesen Teilbaum „gierig“ wachsen! Also: Wähle s ∈ V und setze T0 = ({s}, ∅). Ergänze Tk zu Tk+1 um eine billigste Kante vw mit v ∈ V (Tk ) und w ∈ / V (Tk ) (greedy!). Tn−1 ist die Lösung. Beispiel: Gesamtgewicht: 4 2 s 6 5 8 7 2 1 3 Korrektheit: Die Tk sind Bäume mit k Kanten und k + 1 Ecken. (Tn−1 ist also ein Spannbaum.) Weiter gilt für alle k die Schleifeninvariante: ∃ M ST M : Tk ⊆ M (, woraus die Korrektheit folgt). Beweis der Schleifeninvariante: entspricht Induktion über k: k = 0 entspricht Erreichen der Schleife und ist klar. k + 1 > 0: Sei Tk ⊆ M , M ein M ST , Tk+1 = Tk + e. Ist e in M , so ist man fertig. Sonst hat M + e genau einen Kreis K, K enthält e, s. 9.1 ii). e verbindet eine Ecke in Tk mit einer außerhalb Tk ; also gibt es auf K eine weitere Kante v ′ w′ mit dieser Eigenschaft. Setze M ′ = M + e − v ′ w′ ; also Tk+1 ⊆ M ′ . Der einzige Kreis in M + e ist in M ′ zerstört, aber v ′ und w′ bleiben in M ′ verbunden; also ist M ′ ein Spannbaum. Nach Wahl von e ist g(e) ≤ g(v ′ w′ ), d. h. M ′ ist ein M ST mit Tk+1 ⊆ M ′ . ✷ Implementierung: Wie bei Dijkstra nimmt man immer die billigste Ecke hinzu, nur daß hier der Wert d(v) das Gewicht der billigsten Kante von OK nach v ist. Ändere Dijkstra ab: (3) for all (v ∈ / OK) if ( d(v)>g(wv) ) { d(v)=g(wv); pre(v)=w; } Damit läßt sich Prim wie Dijkstra implementieren mit Laufzeit O n2 oder O(m log(n)). 2. Lösung: Kruskals Algorithmus 80 9.1 Greedy Algorithmen 9 ENTWURFSTECHNIKEN FÜR ALGORITHMEN Setze T0 = (V, ∅). Ergänze Tk zu Tk+1 um eine billigste Kante, die keinen Kreis erzeugt (greedy!). Tn−1 ist die Lösung. Korrektheit: Implementierung: Um die Kanten nach Gewicht geordnet durchzumustern, kann man sie in O(m log(m)) sortieren. Alternativ kann man sie in einer Min-Halde halten: Aufbau in O(m); ≤ m Abrufe erfordern auch O(m log(m)) Zeit, aber in der Praxis muß man oft viele teure Kanten nicht ansehen. Daher ist dieses Verfahren praktisch schnell, denn: Um zu prüfen, ob eine Kante vw einen Kreis erzeugt, Satz 9.3. Das MST-Problem läßt sich in O n2 oder O(m log(n)) lösen. 81 9.1 Greedy Algorithmen 9.1.2 9 ENTWURFSTECHNIKEN FÜR ALGORITHMEN Ein einfaches Scheduling-Problem Schedule = Zeitplan Gegeben sind n Aufgaben mit Laufzeiten t1 , . . . , tn . In welcher Reihenfolge sollen die Aufgaben bearbeitet werden, damit die durchschnittliche Wartezeit minimal ist? Beispiel: t1 , . . . , t4 : 15, 8, 3, 10 Für 1, 2, 3, 4 ergibt sich: T = 1/4 ∗ (15 + 23 + 26 + 36) = 25 Lösung: Im Beispiel: Für 3, 2, 4, 1 ergibt sich: T = 1/4 ∗ (3 + 11 + 21 + 36) = 17, 75 Beweis: Die Summe der Wartezeiten bei Reihenfolge i1 , . . . , in ist n n n P P P S = ti1 + (ti1 + ti2 ) + (ti1 + ti2 + ti3 ) + . . . = (n − j + 1)tij = (n + 1) t ij − jtij j=1 j=1 j=1 Die 1. Summe ist unabhängig von der Anordnung. Gibt es j < k mit tij > tik , so ist jtij + ktik = j(tij + tik ) + (k − j)tik < j(tij + tik ) + (k − j)tij = jtik + ktij Vertauschen von tij und tik würde also die 2. Summe vergrößern und S verkleinern. 9.1.3 ✷ Huffman Codes Codierung eines Zeichenvorrats A durch 0,1-Folgen, c : A 7→ {0, 1}∗ , erfüllt die Fano-Bedingung, wenn für alle a, b ∈ A, a 6= b, c(a) kein Präfix von c(b) ist; eine entsprechende Bedingung gilt für Telefonnummern – warum? Ein solcher Code läßt sich als Codebaum darstellen: ✟❍❍ ❍❍1 ❍❍ ❅ 0 ❅1 ❅ 0✟✟✟ ✟✟ ❅ ❅1 ❅ 0 ✁❆ 0✁ ❆ 1 ✁ a ❆ b d f k ✻ c(f ) = 10 Wegen der Fano-Bedingung stehen die Zeichen an den Blättern des Baumes. Erweitert man c zu c : A∗ 7→ {0, 1}∗ , a1 . . . an 7→ c(a1 ) . . . c(an ), so ist c injektiv (was heißt das?), also als Codierung geeignet. Beispiel: c−1 (001011011) = c−1 (001|01|10|11) = bdf k 0,1-Folgen im Baum verfolgen, bis Blatt=1. Zeichen erreicht ist; wieder bei Wurzel beginnen. Beim ASCII-Code sind alle Zeichen gleich lang ⇒ Fano. Es kann aber günstiger sein, häufig auftretende Zeichen durch kürzere 0,1-Folgen zu codieren. Beispiel: Ein Text enthält nur a, e, n, s, t, Leerzeichen ␣ und Zeilenvorschub ←֓; bei gleichmäßiger Codelänge hat man also 3 bits, z. B. 82 9.1 Greedy Algorithmen a Zeichen a e n s t ␣ ←֓ 9 ENTWURFSTECHNIKEN FÜR ALGORITHMEN ✏PP PP ✏✏ ✏ PP ✏✏ PP ✏ P ✏✏ ✟ ❍ ✟ ❍ ✟✟❍❍ ✟ ❍ ❍❍ ✟ ✟ ❍ ✟ ✟ ❍ ❍❍ ✟ ✟ ❍ ✟ ❆ ✁❆ ✁ ✁❆ ❆ ✁ ❆ ✁ ✁ ❆ ❆ ✁ ❆ ✁ ✁ ❆ e Häufigkeit 10 15 12 3 4 13 1 n Code 1 30 45 36 9 12 39 3 174 Code 2 30 45 36 9 12 39 2 173 s t ␣ ←֓ Code 3 30 30 24 15 16 26 5 146 Problem: Man sucht einen Code mit Fano-Bedingung, für den ein gegebener Text (also mit gegebenen Zeichenhäufigkeiten) eine Codierung minimaler Länge hat. Anwendung: Datei-Kompression; dabei muß aber zusätzlich der optimale Code gespeichert werden, d. h. das Verfahren lohnt sich nur bei großen Dateien. Bemerkung: Andere Methoden codieren nicht Einzelzeichen, sondern kurze Zeichenfolgen, z. B. ist qu häufiger als qx. Beobachtung: Im Beispiel spart man Generell: Optimal: Code 3: ✁❆ ✁ ❆ ✁ ❆ ✁❆ e ✁ ❆ ✁ ❆ ✁❆ a ✁ ❆ ✁ ❆ ✁❆ t ✁ ❆ ✁ ❆ s ❅ ❅ ❅ ✁❆ ✁ ❆ ❆ ✁ n ␣ ←֓ ... und damit für den Text gleich; es gibt also mehrere optimale Codes. 83 9.1 Greedy Algorithmen 9 ENTWURFSTECHNIKEN FÜR ALGORITHMEN Entscheidende Beobachtung: Die seltensten Zeichen, s und ←֓, haben die größte Tiefe, d. h. die größte Codelänge. Beim Aufbau des Codebaumes faßt man s und ←֓ zu einem Baum zusammen; dieser Baum wird als ein Zeichen mit Häufigkeit 3+1=4 gleichberechtigt mit den anderen Zeichen behandelt. Bekommen jetzt alle Zeichen z. B. dieselbe Codelänge, so sind die Codes von s und ←֓ um 1 länger ❀ gieriger Ansatz! Beispiel: (Fortsetzung) Konstruktion von Code 3: Die beiden seltensten Zeichen, s und ←֓, werden zu einem Baum zusammengefaßt und als ein Zeichen mit Häufigkeit 4 gezählt: ❅ ❅ ❅ s ←֓ Jetzt sind t und der eben entstandene Baum die seltensten Zeichen und werden auf gleiche Weise zusammengefaßt. Es entsteht ✁❆ ✁ ❆ ✁ ❆ s ❅ ❅ ❅ t ←֓ mit Häufigkeit 4+4=8. Analog erhält man mit Häufigkeit 18: ❅ ❅ ❅ ✁❆ ✁ ❆ ✁ ❆ s ❅ ❅ ❅ a t ←֓ Die beiden seltensten Zeichen sind nunmehr n und ␣. Also werden sie zu einem Zeichen mit Häufigkeit 25 zusammengefaßt. Insgesamt hat man jetzt 3 Zeichen: e, den n-␣-Baum und den obigen Baum. Fahre nach Schema fort, bis ein einziger Baum entstanden ist. Huffmans Algorithmus Fasse die verschiedenen Zeichen a1 , . . . , an des Textes als Bäume T1 , . . . , Tn (jeweils ein Knoten mit Beschriftung ai ) auf mit f (Ti ) = Häufigkeit von ai ; für k=1 bis n-1 tue füge die Bäume Ti und Tj mit minimalen f -Werten zu einem neuen Binärbaum Tn−k mit (neuer Wurzel und) f (Tn−k ) = f (Ti ) + f (Tj ) zusammen, wobei die alten Ti und Tj gestrichen und alle Tl gegebenenfalls umnumeriert werden; Korrektheitsbeweis: Termination: klar Schleifeninvariante (*): Es gibt einen optimalen Codebaum, der sich ergibt aus einem Baum T , dessen Blätter mit T1 , . . . , Tn−k beschriftet sind, indem man jedes Blatt Ti mit der Wurzel des Baumes Ti verschmilzt. 84 9.1 Greedy Algorithmen 9 ENTWURFSTECHNIKEN FÜR ALGORITHMEN Gilt (*), so ist am Ende T1 der optimale Codebaum aus (*). (*) ist vor der Schleife (k = 0) korrekt. Sei also (*) erfüllt, und Ti und Tj haben minimale f -Werte. i) T ist als Teil eines optimalen Codebaumes ein voller Binärbaum, s. o. Also hat T mindestens 2 verbrüderte Blätter mit maximaler Tiefe. ii) Ohne Einschränkung sind Ti und Tj Blätter maximaler Tiefe in T . Andernfalls gibt es ein Blatt Tl mit maximaler Tiefe dl , während z. B. Ti Tiefe di < dl hat (wegen i)). Vertauschen von Ti und Tl macht den Code der f (Ti )-mal auftretenden Zeichen in Ti jeweils um dl − di Bits länger, während die Codes der Zeichen in Tl kürzer werden. Der Code des Textes verändert sich also um f (Ti )(dl − di ) + f (Tl )(di − dl ) = (f (Ti ) − f (Tl ))(dl − di ) ≤ 0. Der Baum nach der Vertauschung ergibt also auch einen optimalen Code gemäß (*). iii) Ohne Einschränkung sind die Blätter Ti und Tj Brüder nach i), ii) und weil Vertauschen von Blättern gleicher Tiefe (auch in T !) die Codelänge der Einzelzeichen nicht ändert. Streicht man in T die Blätter Ti und Tj , beschriftet ihren Vater mit Tn−k und numeriert ggf. die anderen Blattbeschriftungen um, so erhält man einen Baum T ′ , der nach dem Schleifendurchgang denselben optimalen Codebaum gemäß (*) beschreibt; (*) gilt also weiterhin. ✷ Implementierung: Man kann die Bäume in einer Min-Halde der Größe ≤ n halten. Erforderlich sind buildheap, n − 2 inserts und 2n − 2 deletemins, also Aufwand O(n log(n)). Eine Implementierung der Vorrangswarteschlange als einfache Liste mit Aufwand O n2 tut es i. d. R. auch, da ... den wesentlichen Aufwand erfordern. 9.1.4 Bin-Packing s. Abschnitt 8.5: Bin=Behälter, großer Kasten Problem: Gegeben n Objekte der Größe s1 , . . . , sn , 0 < si ≤ 1, und beliebig viele Kästen der Größe 1. Ziel: Objekte in möglichst wenige Kästen verpacken. Beispiel: .3, .3, .7, .2, .3, .8, .4 1. Möglichkeit: 85 9.1 Greedy Algorithmen .3 9 ENTWURFSTECHNIKEN FÜR ALGORITHMEN .2 .3 .7 .8 .4 .3 optimal: Für dieses Problem ist kein effizientes Lösungsverfahren bekannt, es ist NP-vollständig. Mit dem Greedy-Ansatz kann man aber relativ gute Lösungen in vertretbarer Zeit finden. Es gibt 2 Varianten: • off-line: Die Objekte sind von Anfang an bekannt. • on-line: Die Objekte werden nach und nach gegeben; jedes muß sofort verpackt werden. On-line-Verfahren Sei m gerade, 0 < ε < 0.08. I1 sei eine Folge von m Objekten der Größe 21 − ε, I2 sei I1 , gefolgt von m-mal 21 + ε. Für I2 sind m Kästen optimal (je ein großes und je ein kleines Objekt); dann muß das Verfahren für I1 bereits m Kästen benutzen, was nicht optimal ist. Ein on-line-Verfahren kann also nicht immer optimal sein. Genauer: Satz 9.4. Für jedes on-line-Verfahren A gibt es Eingaben, die zu mindestens 34 des Optimums führen. (Genauer: es gibt eine unendliche Familie solcher Eingaben mit unterschiedlichen n und Optima, also mit beliebig großer Kastenzahl. ) Beweis: Angenommen, A garantiert < 43 · Optimum, und A benötigt für I1 k Kästen. Dann 2 ist k < 43 · m 2 = 3 m. Für I2 füllt A diese k Kästen mit höchstens 2k Objekten, während alle weiteren Kästen nur 1 Objekt enthalten können. Dies sind also mindestens 2m − 2k weitere Kästen/Objekte, d. h. A benötigt für I2 mindestens 2m − k Kästen. Nach Annahme ist also 2m − k < 34 m, d. ✷ h. 23 m < k =⇒ WIDERSPRUCH! Einfach zu programmieren mit linearer Laufzeit, aber nicht sehr schlau ist Next-Fit: Nächstes Objekt in denselben Kasten, wenn möglich; sonst: neuer Kasten. Satz 9.5. Next-Fit braucht ≤ 2 · Optimum Kästen, „manchmal“ 2 · Optimum−2. 86 9.1 Greedy Algorithmen 9 ENTWURFSTECHNIKEN FÜR ALGORITHMEN Beweis: Da der Inhalt von Kasten j + 1 nicht in Kasten j paßt, haben beide zusammen einen Inhalt > 1. Braucht Next-Fit k Kästen, ist der Gesamtinhalt und damit das Optimum > k2 (bzw. > k−1 2 für ungerades k). Sei n durch 4 teilbar; I besteht aus n Objekten, abwechselnd 0.5 und n2 . Optimal ist n4 ✷ Kästen mit 2 mal 0.5 und 1 Kasten mit n2 mal n2 , also n4 + 1. Next-Fit braucht n2 . Besser – und praktisch gut – ist First-Fit: Für jedes Objekt durchsuche die Kästen der Reihe nach und packe es ggf. in den ersten mit genügend Platz; andernfalls: neuer Kasten. Leicht zu programmieren mit Laufzeit O n2 . Es läßt sich eine exakte Grenze von 1.7 · Optimum zeigen. Best-Fit: Für jedes Objekt durchsuche die Kästen und packe es ggf. in den ersten, bei dem der Restplatz ausreichend und minimal ist; andernfalls: neuer Kasten. Nicht so leicht zu programmieren. Die Qualität der Packung hat denselben worst-case wie First-Fit, ist aber im Schnitt besser. 87 9.1 Greedy Algorithmen 9 ENTWURFSTECHNIKEN FÜR ALGORITHMEN Off-line-Verfahren Das Problem bei on-line-Verfahren sind große Objekte, die spät kommen. Daher sortiert man die Objekte, große zuerst, in O(n log(n)); dann wendet man First-Fit an. Diese Methode heißt First-Fit-Decreasing (FFD). Ziel: Abschätzung für das Ergebnis. Sei also s1 ≥ s2 ≥ . . . ≥ sn , m die optimale Kastenzahl, und jeder weitere Kasten sei ein Extrakasten. Lemma 9.6. Bei FFD sind alle Objekte in Extrakästen ≤ 1 3 groß. Beweis: Angenommen, das i-te Objekt ist das erste in einem Extrakasten und si > 31 . Also sind s1 , . . . , si−1 > 13 , d. h. die Kästen k1 , . . . , km enthalten höchstens je 2 Objekte in diesem Augenblick. Es gilt: m j (*): Für ein j enthalten k1 , . . . , kj je ein Objekt und kj+1 , . . . , km je 2. Beweis von (*): Angenommen, für x < y ≤ m enthält kx 2 Objekte der Größe x1 und x2 , ky nur 1 Objekt der Größe y1 . x1 wurde vor y1 , x2 vor si plaziert ; also ist x1 + x2 ≥ y1 + si , ✷ d. h. si paßt in ky =⇒ WIDERSPRUCH! x 2 x 1 yl kx ky s i Bei jeder Packung sind s1 , . . . , sj in verschiedenen Kästen; andernfalls hätte FFD zwei zusammengepackt. Bei keiner Packung sind die 2(m − j) + 1 Objekte sj+1 , . . . , si in einem dieser j Kästen; sonst hätte FFD anders gepackt. Bei einer optimalen Packung sind diese 2(m−j)+1 Objekte also in m−j Kästen, d. h. 3 Objekte > 13 in einem =⇒ WIDERSPRUCH! ✷ Lemma 9.7. FFD packt ≤ m − 1 Objekte in Extrakästen. Beweis: Angenommen, die Objekte mit Größen x1 , . . . , xm sind in Extrakästen, und ki ist n m m m P P P P bis wi vollgepackt. Dann gilt: si ≥ wi + xi = (wi + xi ). Da xi nicht in ki paßt i=1 i=1 i=1 i=1 (sonst hätte FFD xi nicht in einen Extrakasten gepackt) ist wi + xi > 1. Also m ≥ m P 1 = m =⇒ WIDERSPRUCH! i=1 88 n P si > i=1 ✷ 9.2 Divide and Conquer 9 ENTWURFSTECHNIKEN FÜR ALGORITHMEN Satz 9.8. FFD braucht höchstens 34 m + 1 3 Kästen. Beweis: Die Extrakästen enthalten höchstens m − 1 Objekte (9.7) der Größe ≤ 31 (9.6). m+1 Da FFD mindestens je 3 in einen Kasten packt, gibt es höchstens ⌈ m−1 3 ⌉ ≤ 3 Extrakästen. ✷ 11 Eine genauere Analyse ergibt, daß FFD mit höchstens 9 m + 4 Kästen auskommt. Es gibt aber Eingaben, für die FFD 11 9 m Kästen benötigt. Eine solche Eingabe besteht aus je 6k 1 1 Objekten der Größen 2 + ε, 4 + 2ε und 14 + ε sowie 12k Objekten der Größe 41 − 2ε. Übung: Bestimmen Sie eine optimale Packung sowie die Packung, die FFD erzeugt! In der Praxis erzielt FFD ein gutes Ergebnis: Sind die Objektgrößen gleichmäßig im Intervall √ ]0, 1] verteilt, liegt der Erwartungswert für die Anzahl der Extrakästen in Θ( m). 9.2 Divide and Conquer (Teile und herrsche, divide et impera) Bei dieser Technik wird das Problem • in kleinere, “ungefähr disjunkte”, gleichartige Teilprobleme aufgeteilt und • die Lösung aus den rekursiv gewonnenen Teillösungen zusammengesetzt Bemerkung: Die Teilprobleme können evtl. parallel bearbeitet werden. Beispiele: • mergesort: – sortiert die beiden Hälften der Eingabe (Zerlegen einfach) – mischt die Ergebnisse zur Lösung zusammen (Zusammensetzen aufwendig) • quicksort: leistet hingegen die Arbeit beim Zerlegen; Sortieren der Teile liefert dann direkt das Ergebnis. (im besten Fall in O(n log n) ) Fibonacci-Zahlen: f ib(1) = f ib(2) = 1; f ib(n + 2) = f ib(n + 1) + f ib(n) f ib führt das Problem auf Teilprobleme zurück, die alles andere als disjunkt sind: Problem “f ib(n)” ist ganz in “f ib(n + 1)” enthalten. Also kein Beispiel. Typischerweise wird ein Problem der Größe n in a Teilprobleme der Größe nb aufgeteilt, wobei für Aufteilung und Kombinieren der Teillösungen ein Aufwand von g(n) anfällt. Zur Aufwandsbestimmung von Divide-and-Conquer-Verfahren dient Satz 9.9. Seien a, b ∈ N, b > 1 und g : N 7→ N. Es gelte t(1) = g(1) und für n = bm > 1 gelte m P t(n) = at nb + g(n). Dann gilt: t(n) = ai g bni für n = bm , d. h. m = logb (n). i=0 Beweis: Induktion über m: m = 0: t(1) = g(1): klar 89 9.2 Divide and Conquer 9 ENTWURFSTECHNIKEN FÜR ALGORITHMEN m > 0: t(n) = at n b + g(n) Ind. m−1 X = a = m X ai g i=0 i=1 m X = ai g i ag i=0 n + g(n) bi+1 n bi n + a0 g n b0 bi ✷ Die Einschränkung auf n = bm ist hier bei monotoner Laufzeit irrelevant, denn n wächst höchstens um den konstanten Faktor b, wenn man auf die nächste b-Potenz aufrundet, um mit dem aufgerundeten Wert die Laufzeit abzuschätzen. Korollar 9.10. Sei in 9.9 g ∈ O nk und t monoton. Dann ist i) falls a < bk : t ∈ O nk ii) falls a = bk : t ∈ O nk log(n) iii) falls a > bk : t ∈ O nlogb a Beweis: Aus 9.9 erhält man: t(n) ≤ m P k ai c bnik i=0 Für a = bk ist dies (m + 1)cnk , d. h. O nk log(n) . Für a 6= bk ist t(n) ≤ cnk a bk m+1 a bk −1 −1 . abschätzen, d. h. t(n) ∈ O nk . m+1 −1 ≤ Für a > bk sind bak und bak − 1 positive Konstanten, d. h. t(n) ≤ c1 nk bak logb n m+1 logb (n) c1 nk bak = c2 nlogb (a) . ✷ ≤ c2 nk bak = c2 alogb (n) = c2 blogb a Für a < bk kann man die geometrische Reihe durch −1 −1 a bk Ferner gilt für eine Problemaufteilung in verschieden große gleichartige Probleme, die zusammen aber kleiner als das Ausgangsproblem sind: Satz 9.11. Ist k P i=1 αi < 1 und t(n) ∈ k P i=1 t(αi n) + O(n), so ist t(n) ∈ O(n). Im folgenden werden drei interessante, aber (zur Zeit?) nur begrenzt praktikable, schnelle Verfahren betrachtet: 9.2.1 Multiplikation ganzer Zahlen Üblicherweise unterstellt man dafür konstante Zeit, was aber bei sehr langen Zahlen sicher nicht zutrifft. Haben die Zahlen r und s (binär) jeweils die Länge n, so braucht die übliche Methode O n2 . Man zerlegt nun: 90 9.2 Divide and Conquer 9 ENTWURFSTECHNIKEN FÜR ALGORITHMEN a b r = a · 2k + b, k = n 2 c d s = c · 2k + d, k = n 2 Dann gilt: rs = ac · 2n + (ad + bc) · 2k + bd Da Addition und Multiplikation mit 2n bzw. 2k lineare Zeit erfordert, hat man das Problem mit linearem Aufwand auf 4 Teilprobleme der Größe n2 zurückgeführt; nach 9.10 iii) (b = 2, k = 1) gibt das ein O n2 -Verfahren. Man muß also eine Multiplikation einsparen: rs = ac · 2n + (a − b)(d − c) · 2k + (ac + bd) · 2k + bd Mit diesem Ansatz ist t(n) ∈ 3t( n2 ) + O(n), nach Korollar 9.10 iii) also Satz 9.12. Lange Zahlen können in O nlog(3) , d. h. in O n1.585 multipliziert werden. 9.2.2 Matrixmultiplikation nach Strassen (1968) Übliche Multiplikation zweier n × n-Matrizen erfordert n3 Einzelmultiplikationen. Die Matrizen zerlegt man in je vier n2 × n2 -Matrizen: A11 A12 A21 A22 ! B11 B12 B21 B22 ! = C11 C12 C21 C22 ! Es gilt: C11 C12 C21 C22 = A11 B11 + A12 B21 = A11 B12 + A12 B22 = A21 B11 + A22 B21 = A21 B12 + A22 B22 Dies sind acht Multiplikationen, wobei die Additionen O n2 erfordern; da 8 > 22 und log(8) = 3, ergibt dies wieder keine Verbesserung. Strassens Idee: 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 Dann gilt: 91 9.2 Divide and Conquer C11 C12 C21 C22 9 ENTWURFSTECHNIKEN FÜR ALGORITHMEN = M1 + M2 − M4 + M6 = M4 + M5 = M6 + M7 = M2 − M3 + M5 − M7 Jetzt haben wir nur noch 7 Multiplikationen, also: t(n) ∈ 7t( n2 ) + O n2 ; nach 9.10 iii): Satz 9.13. Strassens Matrixmultiplikation benötigt O nlog(7) , d. h. O n2.81 . Dieses Verfahren lohnt sich erst für sehr große n und hilft wenig bei dünn besiedelten Matrizen. Es bedeutete aber einen theoretischen Durchbruch. Bestes heute bekannte Verfahren: Coppersmith & Winograd: O n2.376 (1987). 9.2.3 Selektion in linearer Zeit Zur Bestimmung des k-größten Elements in einem Feld a der Länge n, speziell für den Median, wählt quickselect (vgl. Abschnitt 3.2) ein Pivot-Element x, partitioniert a in Elemente ≤ x und Elemente ≥ x und sucht im passenden Teil weiter. Dies erfordert im Schnitt O(n) und ist praktisch zu empfehlen. Theoretisch: worst case ist Θ n2 ; mit Sortieren ergibt sich die Lösung in O(n log(n)). Geht’s in O(n)? Problem von quickselect: Es wird nicht unbedingt ein fester Anteil von a abgespalten; Teilung in der Mitte wäre optimal, aber dann müßte x gerade der Median sein =⇒ TEUFELSKREIS! Lösung: Man wählt x als Median einer großen Stichprobe (vgl. median-of-3). Vereinfachend nimmt man an, daß alle Elemente verschieden sind.(Bei Elementen = x kann man schwer steuern, wo sie bei der Partitionierung landen; ein Teil kann also zu groß werden.) Ferner sei n = 10l + 5 – wir füllen, auch bei den rekursiven Aufrufen, mit −∞ auf. Median-of-median-of 5: 1) Bilde 5er-Blöcke und bestimme jeweils den Median in konstanter Zeit; zusammen ... 2) Bestimme den Median x der Mediane rekursiv 3) Teile a mit Pivot-Element x und suche im entsprechenden Teil weiter Entscheidend ist, wie groß der Teil in 3) sein kann. Nenne dazu die Mediane > x groß, die Mediane < x klein; nenne die beiden größeren Elemente in den 5er-Blöcken von x und von den großen Medianen riesig; analog winzig. 92 9.2 Divide and Conquer 9 ENTWURFSTECHNIKEN FÜR ALGORITHMEN ✲ riesig k k k x k ✛ g g g g winzig Es gibt 2l + 1 Mediane, also ... große und ... kleine; also gibt es ... riesige und ... winzige Elemente. Da die großen und die riesigen Elemente in einem Teil landen, die kleinen und winzigen im anderen, hat jeder Teil mindestens 3l + 2 und damit höchstens 7l + 3 < 0.7n Elemente. Benötigt dieses Verfahren t(n) Zeit, so gilt: 1) braucht O(n), 2) braucht t n5 , 3) braucht O(n) und für die Rekursion weniger als t(0.7n). Also ist t(n) ∈ t(0.2n) + t(0.7n) + O(n), nach Satz 9.14. Das k-größte Element, speziell der Median, läßt sich in O(n) bestimmen. 93 9.3 Dynamische Programmierung 9.3 9 ENTWURFSTECHNIKEN FÜR ALGORITHMEN Dynamische Programmierung Manchmal läßt sich ein Problem in Teilprobleme aufteilen, die sich aber überlappen. Dadurch müssen rekursiv insgesamt zu viele Teilprobleme gelöst werden – ... Dynamische Programmierung bedeutet, alle kleineren Teilprobleme (evtl. auch nicht benötigte) der Größe nach zu lösen und die Lösungen zu tabellieren. Beispiel: fib(n) = if (n=1 ∨ n=2) 1; else fib(n-1)+fib(n-2); f ib(n − 1) und f ib(n − 2) sind überlappende Teilprobleme; tatsächlich ist die Anzahl der Aufrufe für f ib(n) selbst f ib(n), d. h. es werden exponentiell viele Teilprobleme gelöst, obwohl es nur linear viele gibt, nämlich f ib(1), . . . , f ib(n). Trägt man diese Zahlen in ein Feld ein, ist die Rechnung linear. 9.3.1 Alle kürzesten Wege Gegeben ein gewichteter Graph G als Adjazenzmatrix A mit n Ecken (also A[i, j] = g(ij); A[i, i] = 0; A[i, j] = ∞, falls ij ∈ / E); gesucht sind alle Distanzen d(v, w), vgl. 6.4. Dies läßt 3 sich mit n-mal Dijkstra in O n lösen; der folgende Algorithmus hat aber einen geringeren overhead. Dijkstras Algorithmus ist gierig, weil er die augenblicklich minimale Distanz der nicht erledigten Ecken für gültig erklärt. Dabei tabelliert er aber auch Zwischenergebnisse (für Problem 1 aus 6.4), die auf Wegen beruhen, die im Inneren nur Ecken aus OK benutzen. Diese Idee steckt hinter dem Algorithmus von Floyd. Idee: Tabelliere in Dk [i, j] die kürzeste Länge eines Weges von i nach j, der im Inneren – also außer i und j – nur Ecken aus 1, . . . , k benutzt. Es ist D0 = A. Benutzt ein kürzester Weg für Dk+1 [i, j] • k + 1, so setzt er sich aus Wegen von i nach k + 1 und von k + 1 nach j zusammen, das heißt Dk+1 [i, j] = Dk [i, k + 1] + Dk [k + 1, j]. • nicht k + 1, so Dk+1 [i, j] = Dk [i, j]. Dies ergibt: D0 =A; for (k=1; k <= n; k++) for (i=1; i <= n; i++) for (j=1; j <= n; j++) Dk [i,j] = min (Dk−1 [i,j], Dk−1 [i,k]+Dk−1 [k,j]); Dk benötigt nur Werte aus Dk−1 , d. h. zwei D-Matrizen genügen. Genauer: Dk [i, k] = Dk−1 [i, k] und Dk [k, j] = Dk−1 [k, j], da k als Zwischenecke nutzlos ist: Dk [i, j] benötigt also den sicherlich noch nicht erneuerten Wert Dk−1 [i, j] und zwei Werte, bei denen es egal ist, ob sie schon erneuert wurden. Man braucht also nur eine Matrix D. Die Matrix path dient zur Ermittlung der kürzesten Wege. Floyds Algorithmus: 94 9.3 Dynamische Programmierung (1) (2) (3) (4) (5) 9 ENTWURFSTECHNIKEN FÜR ALGORITHMEN for (i=1; i <= n; i++) for (j=1; j <= n; j++) { D[i,j]=A[i,j]; path[i,j]=0; } for (k=1; k <= n; k++) for (i=1; i <= n; i++) for (j=1; j <= n; j++) if (D[i,k]+D[k,j]<D[i,j]) { D[i,j]=D[i,k]+D[k,j]; path[i,j]=k; } Bemerkungen: i) Floyds Algorithmus arbeitet auch für gerichtete Graphen; dort auch für negatives Kantengewicht g, wenn keine Kreise mit negativem Gewicht existieren. Existiert ein solcher Kreis, so ist D[i, i] für die entsprechenden i kleiner als Null; dies dient als Fehlermeldung. ii) Alle Zuweisungen in (1) und (2) können parallel für alle Paare (i, j) durchgeführt werden; ebenso für jeweils festes k die Folgen (3) bis (5) parallel für alle Paare (i, j). Dieselben Überlegungen lassen sich für ungewichtete Graphen in folgender Form anwenden, wenn man die Zusammenhangskomponenten bestimmen will; Ziel: D[i, j] = 1, falls ∃i, j-Weg, und = 0 sonst. Initial sei D[i, j] = 1 für ij ∈ E oder i = j, und = 0 sonst; dann ergibt sich D[i, j] = max(D[i, j], min(D[i, k], D[k, j])). Dieses Verfahren heißt Warshalls Algorithmus (vor allem bei gerichteten Graphen zur Bestimmung der reflexiv-transitiven Hülle von E) : Gegeben ist die Adjazenzmatrix mit 0,1-Einträgen; in Floyds Algorithmus (streiche man (2) und (5), und) ersetze (3) und (4) durch if (D[i,k] && D[k,j]) D[i,j]=1; 9.3.2 Multiplikation mehrerer Matrizen • Ist A eine p × q-, B eine q × r-Matrix, so benötigt AB pqr Einzelmultiplikationen. • Will man ABC berechnen, hat man die Wahl zwischen (AB)C und A(BC) Beispiel: Sei A eine 6 × 10-, B eine 10 × 9- und C eine 9 × 2-Matrix. • AB kostet 6 · 10 · 9 = 540 Einzelmultiplikationen und ergibt eine 6 × 9-Matrix; (AB)C kostet 6 · 9 · 2 = 108, zusammen also 648 Einzelmultiplikationen. • BC kostet 10 · 9 · 2 = 180 Einzelmultiplikationen und ergibt eine 10 × 2-Matrix; A(BC) kostet 6 · 10 · 2 = 120, zusammen also 300 Einzelmultiplikationen. Merke: Etwas Überlegung lohnt also! Jede mögliche Klammerung von A1 A2 . . . An hat die grobe Form (A1 . . . Ai )(Ai+1 . . . An ) mit 1 ≤ i < n; ist die Klammerung optimal, so müssen auch die Klammerungen von A1 . . . Ai 95 9.3 Dynamische Programmierung 9 ENTWURFSTECHNIKEN FÜR ALGORITHMEN und Ai+1 . . . An optimal sein. Ist k(n) die Anzahl der möglichen Klammerungen, so ergibt n−1 P sich k(1) = 1 und für n > 1 k(n) = k(i)k(n − i); gemäß der folgenden Bemerkung wächst i=1 k(n) exponentiell, und man sollte nicht alle k(n) Möglichkeiten untersuchen. Bei rekursivem Vorgehen gilt für die Anzahl a(n) der Aufrufe: a(1) = a(2) = 1 und für n−1 n−1 P P n > 2 a(n) = 1 + (a(i) + a(n − i)) = 1 + 2 a(i) = 5 · 3n−3 . Andererseits sind die i=1 i=1 Teilprobleme von der Form “Klammere Ai . . . Aj ”, d. h. es gibt nur ungefähr n2 2 viele. Bemerkung: k(n) ist die (n − 1)-te Catalansche Zahl ct(n − 1). ct(n) ist die Anzahl der n−1 P Klammergebirge mit n Klammerpaaren; es ist ct(0) = 1 und für n ≥ 1 ct(n) = ct(i)ct(n − 1 − i); nach Formelsammlung ist ct(n) = i=0 2n 1 n+1 n , und damit k(n) = 1 2(n−1) n (n−1) . Problem: Seien Matrizen A1 , . . . , An gegeben mit Spaltenzahlen c1 , . . . , cn . Ai hat ci−1 Zeilen, i = 2, . . . , n; sei c0 die Zeilenzahl von A1 . Gesucht ist M , die minimale Anzahl von Einzelmultiplikationen zur Berechnung von A1 · . . . · An . (Dabei sollen Verbesserungen der Matrixmultiplikationen wie bei Strassen nicht berücksichtigt werden.) Lösung: Sei Mi,j die minimale Anzahl für Ai · . . . · Aj , d. h. M = M1,n . Es gilt Mi,j = min Mi,k +Mk+1,j +ci−1 ck cj . Für die benötigten M -Werte gilt . . . . Man kann also die Θ n2 i≤k<j Werte tabellieren, geordnet nach . . . und beginnend mit den Werten . . . . Der Aufwand für jedes Mi,j ist O(n), d. h. der Gesamtaufwand ist O n3 . Es gibt auch eine O(n log(n))-Lösung; allerdings wird . . . . Bemerkung: Weitere Beispiele für sinnvolle Anwendungen der Tabellierungstechnik sind das Pascal’sche Dreieck zur Berechnung der Binomialkoeffizienten , sowie der Cocke-KasamiYounger-Algorithmus zur Syntaxanalyse bei kontextfreien Grammatiken. 96 9.4 Backtracking 9.4 9 ENTWURFSTECHNIKEN FÜR ALGORITHMEN Backtracking Manchmal sieht man keinen anderen Weg, als alle Möglichkeiten nach einer Lösung bzw. alle möglichen Lösungen nach einer optimalen Lösung zu durchsuchen (exhaustive search; erschöpfende Suche). Backtracking ist eine systematische Methode zum Durchsuchen, wenn sich die Möglichkeiten als Baum darstellen lassen: • Jeder Knoten repräsentiert eine Menge von Möglichkeiten, seine Kinder eine Partition dieser Menge. Jedes Blatt entspricht einer Möglichkeit. oder speziell: • Jeder Knoten entspricht einer Teilbeschreibung von Möglichkeiten; beim Übergang auf die Kinder wird diese auf alle möglichen Weisen um einen Schritt vervollständigt; jedes Blatt entspricht einer vollständigen Beschreibung einer Möglichkeit. Beispiel: 8-Damen-Problem: Stelle 8 Damen auf ein Schachbrett, so daß sich keine zwei Damen schlagen können. Eine Möglichkeit ist eine Stellung von 8 Damen, eine Teilbeschreibung eine Stellung von i < 8 Damen. Skizze des Baumes: ✩ ✬ Alle Stellungen ✬ ✫ ✱ ✱ ✱ ✱ ✩ ... 1. Dame auf A1 ✫ ✪ ❅ ❅ ❅ ✬ ✪ ✬ .. . ✩ 1. Dame auf H8 ✫ ✪ ✩ 8. Dame auf ?? ✫ ✪ Beim Backtracking wird der Baum gemäß Tiefensuche durchlaufen, also: Von der Wurzel zu einem Blatt laufen; wenn in einem Teilbaum keine (optimale) Lösung existiert, dann zum Vater der Wurzel des Teilbaumes zurücksetzen (backtracking) und beim nächsten Kind weitermachen. Entscheidend: Oft kann man sofort (d. h. ohne Durchlaufen) erkennen, daß in einem Teilbaum keine Lösung existieren kann bzw. keine Lösung mit besserem Wert als den, den 97 9.4 Backtracking 9 ENTWURFSTECHNIKEN FÜR ALGORITHMEN man bereits gefunden hat (branch-and-bound). In der Regel, wenn auch nicht im schlechtesten Fall, ergibt sich dadurch eine erhebliche Beschleunigung. Beispiel: (Fortsetzung) Eine Position, auf der die 2. Dame von der ersten bedroht ist, braucht man nicht weiter zu verfolgen. Dadurch ergibt sich folgendes Schema für eine Funktion, die die weiteren Schritte untersucht: (1) (2) (3) (4) (5) (6) (7) (8) (9) (10) void try(); { initialisiere die Wahl der Kandidaten für den nächsten Schritt; do { wähle nächsten Kandidaten; /* branch */ if (annehmbar) /* and bound */ { zeichne ihn auf; if (Lösung unvollständig) { try(); /* untersucht weitere Schritte */ if (not erfogreich) lösche Aufzeichnung; } else erfolgreich=1; } } while (not erfolgreich && ∃ weiterer Kandidat); } Beispiel: (Fortsetzung) Offenbar kann auf jeder Linie höchstens eine Dame stehen, also betrachtet man nur solche Stellungen, in denen die i-te Dame in der i-ten Linie steht. Wir wollen hier Felder verwenden, bei denen wir Ober- und Untergrenze spezifizieren; also Aufzeichnung der Stellung in int x[1..8]. In C kann man so etwas leicht realisieren. x[i] = j heißt: Auf der i-ten Linie steht eine Dame in der j-ten Reihe. Um schnell prüfen zu können, ob eine Reihe frei ist, führt man ein: boolean reihe[1..8], wobei reihe[i] ⇐⇒ die i-te Reihe ist frei. Entsprechend behandelt man die anderen Schlagmöglichkeiten auf den Diagonalen: Auf der Aufwärtsdiagonalen ist für die Felder (i, j) ..., auf den Abwärtsdiagonalen ist ... Um zu prüfen, ob eine Diagonale frei ist, führt man analog ein: ... Außerdem braucht man die globale Variable boolean f ound. Damit ergibt sich als Rumpf der Hauptfunktion: for (i=1; i <= 8; i++) reihe[i]=true; for (i=-7; i <= 7; i++) auf[i]=true; for (i=2; i <= 16; i++) ab[i]=true; found=false; try(1); if (found) ausgabe(x); 98 9.5 Probabilistische Algorithmen 9 ENTWURFSTECHNIKEN FÜR ALGORITHMEN Obiges Schema spezialisiert sich zu: (1) (2) (3) (4) (5) (6) (7) (8) (9) (10) 9.5 void try (int i) { int j=0; do { j=j+1; if (reihe[j] && auf[j-i] && ab[i+j]) { x[i]=j; reihe[j]=auf[j-i]=ab[i+j]=false; if (i<8) { try(i+1); if (!found) { reihe[j]=auf[j-i]=ab[i+j]=true; } } else found=true; } } while (!found && j<8); } Probabilistische Algorithmen Ihr Ablauf wird von Zufallszahl(en) gesteuert; es gibt 2 Möglichkeiten: • Probabilistischer Algorithmus liefert nur mit einer gewissen Wahrscheinlichkeit ein korrektes Ergebnis (Monte-Carlo-Verfahren). Lohnt sich evtl., wenn dafür die Laufzeit sehr gut ist. Durch wiederholte Läufe (=⇒ Laufzeit wird um einen konstanten Faktor schlechter) läßt sich die Fehlerwahrscheinlichkeit beliebig klein machen, wenn man einseitige Fehler annehmen kann. Beispiel: Weiss gibt einen probabilistischen Primzahl-Test an. Bei Antwort “nein” ist die Eingabe keine Primzahl; bei “ja” ist die Eingabe mit hoher Wahrscheinlichkeit (die nicht von der Eingabe, sondern von den Zufallszahlen abhängt) prim. • Probabilistischer Algorithmus liefert immer ein korrektes Ergebnis, wobei der Erwartungswert für die Laufzeit günstig ist (Las-Vegas-Verfahren). Beispiel: Quicksort mit 1. Element als Pivot liefert bei fast sortierter Eingabe auf jeden Fall eine schlechte Aufteilung und im weiteren eine Laufzeit von O n2 ; jeder rekursive Aufruf liefert nämlich auch eine schlechte Aufteilung. Mit Zufallselement ist die schlechte Aufteilung unwahrscheinlich; daß sie fast jedes Mal auftritt, ist unwahrscheinlicher; für jede Eingabe ist die erwartete Laufzeit O(n log(n)). (Der schlechteste Fall ist natürlich immer noch O n2 .) 99 10 Fibonacci-Heaps und amortisierte Zeit 10.1 Motivation Dijkstras Algorithmus kann leicht mit Laufzeit O(n2 ) implementiert werden; mittels Halde wurde in 6.4 O(m log(n)) erreicht, wobei die 1. Lösung durch decreasekey dominiert wird; zur Erinnerung: Wird eine neue Ecke in OK aufgenommen, muss evtl. für jede ihrer Kanten decreasekey ausgeführt werden. Könnte man letzteres in O(1), liefe Dijkstra (buildheap +n·deletemin +m·decreasekey) in O(n + n log n + m) = O(m + n log n) ⊆ O(m log n) ∩ O n2 . n2 Für n log n ≤ m ≤ log n ist O(m + n log n) sogar um den Faktor log(n) besser als O(m log n) 2 und O n . 10.2 Amortisierte Zeit und Potentiale decreasekey in O(1) ist nicht immer zu erreichen. Es genügt aber natürlich, aus einer Startsituation heraus k Operationen in O(k) auszuführen, d. h. eine amortisierte Laufzeit von O(1). Wiederholung: Dauert eine Operation länger, wurde die Zeit vorher eingespart. worst-case O(1): jede Operation dauert O(1) – ist besser als amortisiert O(1): k Operationen dauern O(k) – ist besser als durchschnittlich O(1): Erwartungswert O(1), kann aber auch beim ersten Mal und sehr oft hintereinander länger dauern. Potentiale sind eine Technik, amortisierte Zeit nachzuweisen. Ein Potential Φ für eine Datenstruktur ist eine Art Sparbuch; Φ ordnet dem Zustand einen Wert zu, so daß initial Φ = 0 und immer Φ ≥ 0. Für eine Operation op sei ∆Φ = (Φ nach op) − (Φ vor op) (die Einzahlung oder Abhebung), die tatsächlich benötigte Zeit sei t(op); dann ist die amortisierte (“rein rechnerische”) Zeit ta (op) = t(op) + ∆Φ. Führen die Operationen op1 , . . . , opk das Potential von k k k P P P Φ = 0 auf Φe , so gilt: ta (opi ) = t(opi ) + Φe ≥ t(opi ); es genügt also, eine Schranke i=1 i=1 (z. B. O(1)) für ta (op) nachzuweisen. 10.3 i=1 Fibonacci-Heap • unterstützt insert, deletemin, decreasekey und merge (Zusammenfügen zweier FibonacciHeaps). • ist eine Liste von Bäumen (evtl. nicht-binär), die der Min-Halden-Bedingung genügen und die sich mittels obiger Operationen aus der leeren Liste empty ergeben kann; dabei können Ecken, die nicht Wurzeln ihres Baumes sind, markiert sein. Bemerkung: Fibonacci-Heaps können nicht mit Feldern realisiert werden; jeder Knoten wird durch pointer auf den Vater und Liste der Kinder realisiert. Das Potential Φ einer Menge von Fibonacci-Heaps ist T + 2M , wobei T die Anzahl der Bäume und M die Anzahl der Marken ist; offenbar ist für empty Φ = 0 und immer Φ ≥ 0. Der Rang einer Ecke sei die Anzahl der Kinder; rmax(n) sei der maximale Rang, der in einem Fibonacci-Heap mit n Ecken auftreten kann. Der Rang eines Baumes ist der Rang der Wurzel. 10.3 Fibonacci-Heap 10.3.1 10 FIBONACCI-HEAPS UND AMORTISIERTE ZEIT Operationen • insert: Erzeugen eines neuen Baumes mit einer Ecke und anfügen an die Liste in O(1). • merge: Verkettung zweier Listen in O(1) (lazy merge, Verschmelzen der Bäume wird aufgeschoben). • deletemin: Das minimale Element ist eine Wurzel, also: i) Suche minimale Wurzel in O(T ); der entsprechende Baum habe Rang r; also r ≤ rmax(n). ii) Trenne die Wurzel ab und hänge die r Teilbäume an die Liste; ggf. Marken der Wurzeln entfernen; Zeit: O(r). iii) Lege Feld L[rmax(n)+1] an, wobei L[i] die Liste der Bäume vom Rang i ist (bucket sort). Es gibt T − 1 + r Bäume, also Zeit O(T + rmax(n)). iv) for (i=0; i<= rmax(n)-1; i++) while (|L[i]| >= 2) { nimm 2 Bäume aus L[i]; hänge die Wurzel mit größerem Wert als jüngstes Kind an die andere Wurzel; füge das Resultat in L[i+1] ein; } Jede while-Schleife vermindert die Anzahl der Bäume, d. h. die Gesamtzeit für deletemin beträgt O(T + rmax(n)). Bemerkung: iii) und iv) lassen sich auch ohne Kenntnis von rmax(n)) zumindest ineffizient implementieren; hierdurch wird rmax(n) überhaupt erst definiert! Da man rmax(n) aber bestimmen kann (s. u.), läßt sich deletemin tatsächlich wie beschrieben implementieren. • decreasekey(x): Wert von x vermindern. Ist x Wurzel, so fertig. Sonst: Trenne x vom Vater ab und entferne ggf. die Marke von der neuen Wurzel x. Ist der Vater unmarkiert, so markiere ihn, falls er keine Wurzel ist. Ist er markiert, trenne ihn rekursiv ab etc. Ist c die Anzahl der Trennungen, so benötigt man O(c + 1). 10.3.2 Zeitanalyse Zunächst wird rmax(n) bestimmt. Lemma 10.1. i) Sei x eine Ecke, ci ihr i-t-ältestes Kind. Dann hat ci Rang ≥ i − 2. ii) Hat x Rang r, so hat der Teilbaum von x mindestens f ib(r + 2) Ecken. Beweis: i) Wird ci als j-tes Kind x untergeordnet bei deletemin (j ≥ i), so hatte x Rang = j − 1, ci also auch. Verliert ci ein Kind, wird ci markiert. Beim 2. Verlust würde ci zu einer Wurzel; ci hat also höchstens ein Kind verloren. Da j ≥ i (x könnte Kinder verloren haben), fertig. 101 10.3 Fibonacci-Heap 10 FIBONACCI-HEAPS UND AMORTISIERTE ZEIT ii) Sei B(r) die Mindestanzahl von Ecken in einem Teilbaum vom Rang ≥ r. Es ist B(0) = 1 = f ib(2), B(1) ≥ 2 = f ib(3). Ein x vom Rang r + 1, r ≥ 1, hat nach i) Kinder vom Rang mindestens r − 1, r − 2, . . . , 0 und ein weiteres. Man erhält nach Induktion: B(r + 1) ≥ 2 + ≥ 2+ = 1+ r−1 X 2= ˆ x und das weitere Kind B(i) i=0 r+1 X i=2 r+1 X f ib(i) f ib(i) i=1 = f ib(r + 3) Letzteres lässt sich leicht durch Ind. zeigen. ✷ Lemma 10.2. rmax(n) ∈ O(log(n)). Beweis: Betrachte einen Fibonacci-Heap mit n Ecken und Rang rmax(n). Dann ist n ≥ f ib(rmax(n) + 2) nach 10.1 ii), und da f ib exponentiell wächst, fertig. ✷ Bemerkung: rmax(n) läßt sich auch durch eine konkrete Zahl nach oben abschätzen √ 1+ 5 (logφ (n) mit φ = 2 ), um deletemin zu implementieren. Jetzt wird eine Zeiteinheit so gewählt, daß die Angaben O(1), O(T ) etc. in 10.3.1 bedeuten: ≤ 1 bzw. ≤ T Zeiteinheiten. Nun zeigt man: Satz 10.3. Fibonacci-Heaps unterstützen insert, merge und decreasekey in O(1) und deletemin in O(log(n)) amortisierter Zeit. (Also läßt sich buildheap – z.B. bei Dijkstra – durch n inserts in O(n) durchführen.) Beweis: • merge kostet 1; ∆Φ = 0: 1 + 0 ∈ O(1). • insert kostet 1; erzeugt einen neuen Baum; d. h. ∆Φ = 1: 1 + 1 ∈ O(1). • deletemin kostet ≤ T + rmax(n); die Anzahl der Bäume verändert sich von T auf ≤ rmax(n), die Anzahl der Marken vermindert sich um bis zu r, d. h. ∆Φ ≤ rmax(n) − T : ta (deletemin) ≤ T + log(n) + rmax(n) − T ∈ O(log(n)) nach 10.2. • decreasekey kostet c+ 1. T erhöht sich um c; bei jeder Trennung außer der ersten verliert man eine Marke (evtl. auch bei der ersten); bei der letzten gewinnt man evtl. eine Marke; der Verlust beträgt also mindestens c − 2 Marken. Also ∆Φ ≤ c − 2(c − 2) = 4 − c; insgesamt: ta (decreasekey) ≤ c + 1 + 4 − c ∈ O(1). Bemerkung: Hier sieht man den Grund für die Wahl von Φ: decreasekey hat hohe Kosten und erzeugt zusätzliche Bäume; etwas anderes muß abnehmen und zwar stärker als die Zahl der Bäume zunimmt. Daher wird 2M in Φ verwendet. ✷ 102