Algorithmen und Datenstrukturen Zusammenfassung zur Vorlesung im Sommersemester 2009 Anmerkung: Der Autor übernimmt keinerlei Gewährleistung dafür, dass diese Zusammenfassung inhaltlich vollständig oder korrekt ist. Dies bezieht sich insbesondere auf die als „informell“ betitelten Erläuterungen, die hauptsächlich als vereinfachte Veranschaulichung zu verstehen sind. Die Benutzung erfolgt also zu eigenem Vergnügen – und auf eigene Gefahr. ;) Zur Quelle: Der folgende Text versteht sich mit Blick auf die Klausur als Zusammenfassung zur gleichnamigen Vorlesung und fußt dazu natürlich auf die von Prof. Hofmann erstellten Inhalte. An einigen Stellen wurde auch noch zusätzlich wikipedia zu Rate gezogen (dort gekennzeichnet). Daniel Buschek 2 Inhalt I. Grundlegende Konzepte Master-Methode Sortieren durch Einfügen Sortieren durch Mischen Heaps Quicksort Maximumbestimmung Selektion 3 5 6 7 12 14 17 II. Bäume Rotationen AVL-Bäume Rot-Schwarz-Bäume B-Bäume 19 19 19 22 III. Hashtabellen Direkte Adressierung Hashing Kollisionsauflösung durch Verkettung Hashfunktionen Offene Adressierung Hashfunktionen für Offene Adressierung 24 24 25 26 26 27 IV. Allg. Entwurfs- und Optimierungsmethoden Greedy-Algorithmen Dynamische Programmierung Amortisierte Analyse Union Find Backtracking 28 28 28 29 31 V. Algorithmen auf Graphen Grundlagen Breitensuche Tiefensuche Kantentypen Starke Zusammenhangskomponenten (Minimale) Spannbäume Kürzeste Wege Flüsse in Netzwerken 32 32 33 34 35 36 39 42 3 I. Grundlegende Konzepte Master-Methode Mit der sogenannten Master-Methode lässt sich die Laufzeit von rekursiven Algorithmen berechnen. Die Methode lässt sich in drei Unterfälle gliedern. Diese sind jedoch nicht „vollständig“, d. h. es kann auch vorkommen, dass keiner der Fälle zutrifft und die MasterMethode somit gar nicht anwendbar ist. Teilt ein Algorithmus ein Problem der Größe n in a Teilpobleme der Größe n/b auf, und benötigt das Aufteilen und Zusammenfassen der Teilergebnisse Aufwand f(n), so erhält man die Formel: Diese lässt sich nun nach folgendem Schema mit der Master-Methode bearbeiten. Berechne Es gibt nun drei Fälle: , für ein 0 Falls f mind. konstant wächst: f Ω1 , für ein 0 Falls: # $ für ein $ % 1 und genügend großes n. Die drei Fälle vergleichen also im Prinzip die Geschwindigkeit des Wachstums von f und . 4 Eine Anmerkung: In Vorlesung/Übung/Probeklausur trat bisher der Fall „Master-Theorem nicht anwendbar“ immer dann auf, wenn f(n) von der Form log war. Man sieht sofort, dass ein solches f(n) schneller wächst als (schließlich wird in f das n* noch mit log(n) multipliziert), was eigentlich für den dritten Fall sprechen würde. Leider gilt allerdings nicht, dass f(n) schneller wächst als , denn + und n hoch ein noch so kleines wächst trotzdem schneller als log(n) (s. u. 1. Satz). Formal: Zwar gilt ,-. / , aber nicht ,-. Ω0 . Zwei nützliche „Sätze“ zur Asymptotik: 1. log -0 2. log! 2 ,-. 5 Sortieren durch Einfügen INSERTION-SORT(A) for j ← 2 to length[A]do key ← A[j] i ← j - 1 while i > 0 und A[i] > key do A[i + 1] ← A[i] i ← i - 1 A[i + 1] ← key In Worten: In der for-Schleife durchlaufen wir jedes Element von A (ab dem zweiten). In der while-Schleife wird das aktuell betrachtete Element dann in die bereits sortierte Folge der Elemente davor (A[1.. j-1]) eingefügt. Laufzeit: O(n²) , da zwei verschachtelte Schleifen mit Abhängigkeit von n. 6 Sortieren durch Mischen MERGE-SORT(A, p, r) if p < r then q ← (p + r)/2 MERGE-SORT(A, p, q) MERGE-SORT(A, q + 1, r) MERGE(A, p, q, r) MERGE(A, p, q, r) i ← p j ← q + 1 for k ← 1 to r if j > r or B[k] ← else B[k] ← for k ← 1 to r - p +1 do (i <= q A[i]; i A[j]; j p +1 do and ← i ← j A[p A[i] <= A[j]) then + 1 + 1 + k - 1] ← B[k] In Worten: MERGE-SORT arbeitet nach dem divide-and-conquer-Prinzip: Die Sortierung von A wird in zwei „Halbprobleme“ aufgeteilt, deren rekursiv erarbeitete Lösungen dann mit der Methode MERGE zusammengefügt werden. MERGE sortiert A[p..r] mit der Annahme, dass A[p..q] und A[q+1..r] jeweils in sich bereits sortiert sind. Das sind sie auch, da auf „unterster Rekursionsebene“ in beiden Teilbereichen nur ein Element enthalten ist (und damit sind diese Teilbereiche ja automatische sortiert). Idee der Methode MERGE: Man stelle sich i und j als Zeigefinger vor. i deutet zu Beginn als linker Zeigefinger auf die linke Grenze von A, j als rechter Zeigefinger auf die Mitte von A. Zeigt der linke Finger nun auf etwas Kleineres als der rechte, so kopieren wir dieses kleinere Element in einen Zwischenspeicher B und rücken unseren linken Zeigefinger eins weiter nach rechts. Liegt das kleinere Element aber am rechten Zeigefinger, so kopieren wir dieses in den Zwischenspeicher und rücken den rechten Finger eins weiter nach rechts. Die if-Abfrage prüft allerdings nicht nur, wo das kleinere Element liegt (A[i] <= A[j]), sondern zugleich auch, ob die Finger noch in ihren gültigen Bereichen liegen (der Linke links der Mitte: i <= q. Und der Rechte noch innerhalb von A: j > r). Zum Schluss wird das Ergebnis im Zwischenspeicher B noch auf A übertragen. Laufzeit: O(n log(n)) – mit der Master-Methode 7 Heaps Eigenschaften: 1. Knoten und Blätter sind mit Objekten beschriftet. 2. Alle Schichten sind gefüllt, bis auf den rechten Teil der Untersten. Das bedeutet: Alle Pfade von der Wurzel zu einem Blatt haben die Länge d oder d - 1. Hat ein Pfad die Länge d, so auch alle Pfade zu weiter links liegenden Blättern. Daraus ergibt sich zudem folgende Beobachtung: In einem Heap der Größe n liegt der letzte Knoten, der Kinder hat, an der Stelle n/2. 3. Die Beschriftungen der Nachfolger eines Knoten sind kleiner oder gleich der Beschriftung des Knotens. Repräsentation: • Ein heap wird als Array A gespeichert, zusammen mit seiner Größe heap-size[A] als Zahl. • Der erste Eintrag bildet die Wurzel des heaps. • Der Elternknoten eines Eintrags i ist der Eintrag floor(i/2). • Der linke Nachfolger eines Eintrags i ist der Eintrag 2i, der rechte Nachfolger ist 2i + 1. → Die Eigenschaften 1 und 2 sind für ein Array von Objekten automatisch gegeben. Die dritte Eigenschaft bedeutet, dass für alle Einträge im heap, also alle Einträge i <= heapSize[A] gilt: A[floor(i/2)] >= A[i] Beispiel: i = 4. Der Eintrag an der Stelle A[2] ist Elternknoten von A[4] und muss damit auch größer sein als dieser, um die dritte Eigenschaft zu erfüllen. 8 Methoden auf Heaps Heapify HEAPIFY(A, i) l ← 2i r ← 2i + 1 if l <= heap-size[A] und A[l] > A[i] then largest ← l else largest ← i if r <= heap-size[A] und A[r] > A[largest] then largest ← r if largest != i then exchange A[i] ↔ A[largest] HEAPIFY(A, largest) Vorbedingung: Die Teilbäume des Knotens i erfüllen bereits die Heap-Eigenschaft. In Worten: Die ersten beiden if-Abfragen dienen allein dem Auffinden des größten Elements unter den drei Elementen: Wurzel i, linkes Kind, rechtes Kind. Wenn nun die Wurzel i nicht ohnehin schon das größte Element dieser drei ist (if largest != i), so wird das Größte nach oben an die Wurzelposition i getauscht. Anschließend muss HEAPIFY erneut aufgerufen werden, diesmal an der Stelle, wo das größte Element vor dem „hochtauschen“ stand (largest). Denn das Element von der Stelle i, das nach dem Tausch nun dort steht, muss ja nicht zwangsläufig das größte Element in diesem Teilbaum sein. Laufzeit: O(log(heap-size[A])) 9 Build-Heap BUILD-HEAP(A) heap-size[A] ← length[A] for i ← heap-size[A]/2 downto 1 do HEAPIFY(A, i) In Worten: Die for-Schleife zählt von heap-size[A]/2 an abwärts, da in der Methode HEAPIFY als linkes bzw. rechts Kind ja die Einträge 2i bzw. 2i + 1 genommen werden. Für i > heap-size[A]/2 weiß man aber sofort, dass diese gar nicht mehr im Array liegen. Man „heapifiziert“ das Array also von hinten nach vorn, und erfüllt so die Bedingung, dass für einen Aufruf von HEAPIFY(A, i) für beide Teilbäume von i bereits die heap-Eigenschaft gilt. Laufzeit: O(length[A]) 10 Heap-Sort HEAP-SORT(A) BUILD-HEAP(A) for i ← length[A] downto 2 do exchange A[1] ↔ A[i] heap-size[A] ← heap-size[A] - 1 HEAPIFY(A, 1) In Worten: In der for-Schleife passiert folgendes: Die Wurzel des Heaps wird nach hinten vertauscht (exchange A[1] ↔ A[i]). Da die Wurzel das größte Element eines Heaps ist, steht nach dem ersten Durchlauf der for-Schleife das größte Element im Array an der letzten Stelle. Durch Verkleinern der heap-size wird dieses bereits sortierte Element im folgenden Aufruf von HEAPIFY nicht mehr beachtet, da es nicht mehr als Teil des Heaps gilt. Durch eben dieses HEAPIFY gelangt das nun größte Element an die Wurzelposition (A[1]). Von dort wird es im nächsten Durchlauf der Schleife an die zweitletzte Position vertauscht, usw. … bis man am Ende eine aufsteigende Sortierung erreicht hat. Laufzeit: O(n log(n)) 11 Heap-Insert HEAP-INSERT(A, key) heap-size[A] ← heap-size[A] + 1 i ← heap-size[A] while i > 1 and A[i/2] < key do A[i] ← A[i/2] i ← i/2 A[i] ← key In Worten: Die heap-size wird erhöht, da wir ja ein Element einfügen. Der Zähler i beginnt an dieser neuen, letzten und noch leeren Position (i ← heap-size[A]). In der while Schleife wird nun geprüft ob der Elternknoten des aktuellen i kleiner ist als das neue Element (A[i/2] < key). Ist dies der Fall, besteht weiter Handlungsbedarf, denn ein Elternknoten soll ja nicht kleiner sein, als sein Kind: Hier können wir also noch nicht das neue Element einfügen. Stattdessen kopieren wir den Elternknoten an die (im ersten Durchlauf der Schleife leere) Stelle i (A[i] ← A[i/2]), und rücken mit unserem „Einfügeversuch“ an dessen Stelle i/2 vor (i ← i/2). Das „um i/2 nach hinten kopieren“ macht einen kleineren Knoten, der vormals an einer Elternposition für das neue Element stand, nun zu einem Kind des neuen Elements bzw. des Elements, das im nächsten Durchlauf dorthin kopiert wird. Dieser Vorgang wird solange wiederholt bis entweder die Wurzel erreicht (dann war das neue Element größer, als alles Bisherige im Heap), oder ein Elternknoten gefunden wurde (also einer der größer ist als das neue Element). Zum Schluss ist die Position i für das Einfügen schließlich gefunden und das neue Element wird dort abgelegt (A[i] ← key). 12 Ein weiterer Suchalgorithmus: Quicksort QUICKSORT(A, p, r) if p < r then q ← PARTITION(A, p, r) QUICKSORT(A, p, q – 1) QUICKSORT(A, q + 1, r) PARTITION(A, p, r) x ← A[r] i ← p – 1 for j ← p to r – 1 do if A[j] <= x then i ← i + 1 exchange A[i] ↔ A[j] exchange A[i + 1] ↔ A[r] return i + 1 In Worten: PARTITION gruppiert die Elemente von A[p..r] um, und bestimmt einen Index q, so dass danach gilt: A[p..q-1] <= A[q] <= A[q+1..r]. D. h. alle Elemente mit Index kleiner q sind kleiner, alle mit Index größer q größer als das Element mit Index q. Die beiden Teilgruppen „kleiner“ und „größer“ müssen aber nicht in sich sortiert sein! Zunächst wird ein Pivot-Element x gewählt. Die Variable i zeigt auf die rechte Grenze des unteren Bereichs (also der Bereich, in dem am Ende die kleineren Elemente stehen sollen). Die Schleifen-Variable j zeigt auf die rechte Grenze des oberen Bereichs (mit den Elementen größer dem Pivot-Element x). Die for-Schleife durchläuft A von p bis r – 1 (an Position r steht ja das Pivot-Element). Treffen wir auf ein Element, das kleiner ist als das Pivot-Element (if A[j] <= x), so vergrößern wir den unteren Bereich (i ← i + 1) und tauschen das Element in diesen Bereich hinein (exchange A[i] ↔ A[j]). Ist das Element hingegen größer als das Pivot-Element, muss nichts weiter getan werden: Die Schleifenvariable j rückt einfach weiter nach rechts und schließt somit das Element in den „größer als x“-Bereich ein. Zum Schluss muss man nur noch das Pivot-Element x zwischen die beiden Bereiche tauschen (exchange A[i + 1] ↔ A[r]) und dessen neuen Index zurückgeben (return i + 1). Laufzeit: O(n log(n)) , im schlechtesten Fall allerdings 23 . 13 Randomisiertes Quicksort Der für die Laufzeit ungünstigste Fall beim Sortieren mit Quicksort tritt dann auf, wenn das Array bereits mehr oder weniger sortiert ist, da dann beim Aufteilen in die beiden Teilprobleme eines von beiden fast alle Elemente enthält. Dem begegnet man, in dem beim Aufteilen als Pivot-Element ein zufälliges Element ausgewählt wird, anstatt immer das ganz rechts zu nehmen. RANDOMIZED-QUICKSORT(A, p, r) if p < r then q ← RANDOMIZED-PARTITION(A, p, r) RANDOMIZED-QUICKSORT(A, p, q – 1) RANDOMIZED-QUICKSORT(A, q + 1, r) RANDOMIZED-PARTITION(A, p, r) i ← RANDOM(p, r) exchange A[r] ↔ A[i] return PARTITION(A, p, r) In Worten: Im Prinzip bleibt alles so, wie beim „normalen“ Quicksort ohne Zufallselement. Der Unterschied: RANDOMIZED-PARTITION vertauscht das letzte Element im Bereich (also A[r]) mit einem zufällig ausgewählten Element aus A[p..r]. Anschließend wird die bekannte PARTITION-Methode aufgerufen. Diese wird als PivotElement nun wieder A[r] wählen, dort steht Dank des Vertauschens aber auch bei bereits fast sortiertem Array nicht zwangsläufig das größte Element des Bereichs A[p..r]. Somit wird erreicht, dass die Länge der beiden Bereiche „kleiner als x“/„größer als x“ gleichmäßiger verteilt ist. Laufzeit: Durch die Zufallswahl ergibt sich nun ein Erwartungswert für die Laufzeit von O(n log(n)). Von „Erwartungswert“ muss aufgrund des Zufalls gesprochen werden. Der worstcase kann jetzt praktisch allerdings nur noch durch eine Verkettung von sehr ungünstigen Zufallsauswahlen entstehen. 14 Maximumbestimmung MAXIMUM(A, n) kandidat ← A[1] for i ← 2 to n do if A[i] > kandidat then kandidat ← A[i] return kandidat In Worten: Diese Methode findet das Maximum im Bereich A[1..n]. Erster „Kandidat“ für die Lösung wird das erste Element (kandidat ← A[1]). Mittels forSchleife wird dieser Kandidat nun mit allen anderen Elementen verglichen. Verliert er einen Vergleich (if A[i] > kandidat) so wird der Sieger zum neuen Kandidaten für das Maximum. Wer zuletzt ungeschlagen vom Platz geht, ist somit das Maximum. Laufzeit: O(n), da einfache Schleife. 15 Maximum durch KO-System MAXIMUM-KO(A, p, r) if p = r then return A[p] else q ← (p + r)/2 l ← MAXIMUM-KO(A, p, q) r ← MAXIMUM-KO(A, q +1, r) return max(l, r) In Worten: Die divide-and-conquer-Variante der Maximumbestimmung. Ist das Teilproblem der Länge 1, wird trivialerweise dieses eine Element als Maximum gefunden (if p = r then return A[p]). Ist das Problem größer, wird weiter zerlegt und das Maximum beider Teilprobleme zurückgegeben (return max(l, r)). Laufzeit: O(n) 16 Kombinierte Maximum- und Minimumbestimmung MAXIMUM-MINIMUM(A, n) for i ← 1 to floor(n/2) do if A[2i - 1] < A[2i] then B[i] ← A[2i – 1]; C[i] ← A[2i] else C[i] ← A[2i – 1]; B[i] ← A[2i] if n ungerade then B[floor(n/2) + 1] ← A[n] C[floor(n/2) + 1] ← A[n] return (MINIMUM(B, ceil(n/2)), MAXIMUM(C, ceil(n/2)) In Worten: In der for-Schleife wird das Array durchlaufen, und zwar nur bis zur Hälfte, da in der folgenden if-Abfrage immer zwei Elemente verglichen werden (if A[2i - 1] < A[2i]). →Der Sieger des Vergleichs landet in C, der Verlierer in B. Bei ungerader Anzahl von Elementen wird das übriggebliebene Element ohne Vergleichspartner einfach sowohl zu den Siegern als auch den Verlierern gesteckt. Minimum ist dann das Minimum der Verlierer, Maximum das Maximum der Sieger. Dieses Tupel wird hier zurückgegeben. Laufzeit: O(n), allerdings mit optimaler Vergleichskomplexität V(n) = ceil(3n/2) – 2 . Die Bestimmung von Maximum und Minimum nacheinander hat hingegen eine Vergleichskomplexität von V(n) = 2(n-1). 17 Selektion Selektion bedeutet, von n verschiedenen Elementen das i-kleinste zu finden. Somit ist das 1kleinste Element das Minimum und das n-kleinste Element das Maximum. Die Vergleichskomplexität ist V(n) = 2. Mit weniger als n-1 Vergleichen lässt sich kein Element als das i-kleinste bestätigen. Randomized-Select RANDOMIZED-SELECT(A, p, r, i) if p = r then return p q ← RANDOMIZED-PARTITION(A, p, r) k ← q – p + 1 if i <= k then return RANDOMIZED-SELECT(A, p, q, i) else return RANDOMIZED-SELECT(A, q+1, r, i-k) In Worten: Befindet sich im aktuell betrachteten Teilbereich des Arrays nur noch ein Element (if p = r), so geben wir dieses eine zurück (Terminierungsfall). Ansonsten rufen wir Partition auf und merken uns in einer Variablen k die Anzahl der Elemente zwischen (Grenzen eingeschlossen) p und q (k ← q – p + 1). Zur Erinnerung: Nach Partition ist q die Grenze zwischen Elementen kleiner und größer A[q]. Das bedeutet auch, dass A[q] das q-kleinste Element ist. Die abschließende if-Abfrage realisiert folgende Überlegung: Sei z. B. i = 5. Wir suchen also das 5-kleinste Element. Wenn sich für k nun ein Wert von z. B. 7 ergeben hat, heißt das, dass 6 Elemente kleiner waren als das Element A[q]. Wir suchen das 5-kleinste; das muss also unter diesen 6 Elementen sein. Denn A[q] ist in diesem Fall das 7-kleinste Element und alle Elemente danach sind ja noch größer. Daher muss links von q weitergemacht werden. Hätte sich für k ein Wert von 4 ergeben, würde das umgekehrt bedeuten: 3 Elemente waren kleiner als A[q]. A[q] ist also das 4-kleinste Element. Daher muss rechts weitergesucht werden. Dabei muss man k von i abziehen, da das 5-kleinste Element ohne die vier Kleinsten ja dann das 1-kleinste Element ist. 18 Select Randomized-Select hat im worst-case eine Laufzeit von 2². Verbessern lässt sich dies mit der folgenden Idee zu O(n). SELECT(A, p, r, i) if p = r then return p - Teile die A[i] in 5-er-Gruppen auf. - Bestimme den Median jeder Gruppe. - Bestimme den Median dieser Mediane durch rek. Aufruf von SELECT. - Vertausche in A diesen Median mit A[r] q ← PARTITION(A, p, r) k ← q – p + 1 if i <= k then return SELECT(A, p, q, i) else return SELECT(A, q+1, r, i-k) In Worten: Diese Methode gleicht der vorigen, allerdings wird nicht mehr mit einem zufälligen Pivot-Element partitioniert, sondern mit einem „Median der Mediane“ von 5erGruppen aus A. Laufzeit: Dadurch erhält man für den worst-case lineare Laufzeit O(n). Details zur Rechnung/Herleitung siehe Skript (in der Probeklausur reichte es schlicht zu wissen, das man in O(n) selektieren kann). 19 II. Bäume Bäume allgemein Mit Rotationsoperationen kann verhindert werden, dass ein Baum „entartet“, also listenähnlich wird. Ein balancierter Baum mit n Knoten hat eine optimale Höhe von O(log(n)). Es gibt Rechts- und Linksrotationen, sowie die Doppelrotation, die sich aus den beiden gerichteten zusammensetzt. AVL-Bäume AVL-Bäume unterscheiden sich von binären Suchbäumen durch ein zusätzliches Attribut der Knoten, in dem ein sog. Balancefaktor gespeichert wird. Dieser gibt die Höhendifferenz zwischen linkem und rechtem Teilbaum des Knotens an. Diese Differenz darf folgende Werte annehmen: -1, wenn der linke Teilbaum um eins höher ist als der rechte +1 im umgekehrten Fall 0 bei gleicher Höhe der Teilbäume Alle anderen Werte (z.B. 2) sind unzulässig und müssen mittels Rotationen behoben werden. Somit wird für AVL-Bäume sichergestellt, dass diese balanciert sind. Rot-Schwarz-Bäume Rot-Schwarz-Bäume unterscheiden sich von binären Suchbäumen durch ein zusätzliches Attribut der Knoten, in dem deren Farbe (Rot oder Schwarz) gespeichert wird. Definition von Rot-Schwarz-Bäumen • Jeder Knoten x hat eine Farbe color[x], mit den Werten RED oder BLACK. • Alle Knoten werden als innere Knoten betrachtet. Falls ein Knoten keine Kinder hat, denkt man sich zwei schwarze (nil)-Kinder (als Blätter). • Die Wurzel (und alle Blätter) sind schwarz. • Beide Kinder eines roten Knotens sind schwarz; d.h. es folgen keine zwei roten Knoten aufeinander (zwei schwarze Knoten dürfen dies hingegen schon). • Jeder Pfad von einem Knoten x zu einem Blatt enthält gleich viele schwarze Knoten. 20 Einfügen in Rot-Schwarz-Bäume Der Vater des neuen Knotens ist… rot schwarz Der Onkel des neuen Knotens ist… rot Einfügen. Fertig. schwarz/ kein Onkel 1. Vater und Onkel schwarz färben. 2. Großvater rot färben. Der neue Knoten und sein Vater sind beide linke (rechte) Kinder. (Evtl. Großvater wie einen neu eingefügten Knoten rekursiv behandeln.) Fertig. Ja Nein 1. Rotation um den Großvater. Rotation um den Vater Dadurch wird der ursprüngliche Vater nun sowohl Vater vom neuen Knoten als auch von dessen (nun ehemaligem) Großvater. durchführen, sodass neuer Knoten und Vater beide linke (rechte) Kinder sind. 2. Farben vom ehemaligen Großvater und Vater vertauschen. Man beachte für den nächsten Schritt, dass dabei neuer Knoten und Vater „die Rollen tauschen“! Fertig. Die Grafik berücksichtigt nicht den trivialen Fall, dass der neue Knoten keinen Vater hat, also in einen leeren Baum eingefügt wird (dann wird der neue Knoten schwarz und zur Wurzel). Vorgehen auch nach der Beschreibung auf wikipedia (http://de.wikipedia.org/wiki/Rot-Schwarz-Baum). 21 Löschen aus Rot-Schwarz-Bäumen Der zu löschende Knoten hat maximal ein (echtes) Kind. Ja Nein Der zu löschende Knoten ist... Wert des Nachfolgers in den zu löschenden Knoten schreiben (Farben nicht ersetzen!). rot Den Nachfolger löschen. schwarz Den zu löschenden Knoten durch sein Kind ersetzen. Fertig. Das Kind des zu löschenden Knoten ist... rot schwarz 1. Kind schwarz färben. 2. Den zu löschenden Knoten durch sein Kind ersetzen. Fertig. Nein Der Bruder von K ist… rot 1. Den zu löschenden Knoten durch sein Kind ersetzen. 2. Den Knoten löschen. Nun gibt es ein Schwarz weniger. Dieses Problem wird durch Bearbeiten des Kindes (K) gelöst. K ist die Wurzel. Ja Fertig. schwarz 1. Farben des Vaters und Bruders invertieren. 2. Rotation um den Vater. (K’s neuer Bruder ist nun in jedem Fall schwarz!) K ist linkes (rechtes) Kind. Die Kinder des Bruders von K sind… rechtes (linkes) Kind rot linkes (rechtes) Kind rot, rechtes (linkes) Kind schwarz beide schwarz Der Vater von K ist schwarz 1. Bruder von K rot färben. 2. Vater rekursiv behandeln. Fertig. rot 1. Bruder rot färben. 2. Das rote Kind des Bruders schwarz färben. 3. Rotation um den Bruder (dadurch ändert sich der Bruder von K!). 1. Farben von Bruder und Vater vertauschen. 2. Rechtes (linkes) Kind des Bruders schwarz färben. 3. Rotation um den Vater. Fertig. Farben von Vater und Bruder tauschen. Fertig. Anmerkung: In der Probeklausur musste man nur die beiden einfacheren Fälle „Knoten rot“ und „Knoten schwarz, aber Kind rot“ anwenden. Vorgehen wiederum auch nach der Beschreibung auf wikipedia (http://de.wikipedia.org/wiki/Rot-Schwarz-Baum). 22 B-Bäume B-Bäume unterscheiden sich von binären Suchbäumen dahingehend, dass ein Knoten mehrere Schlüssel beinhaltet und dementsprechend auch mehrere Kinder hat. Somit kommt man beim Suchen an weniger Knoten vorbei, allerdings ist dafür der einzelne Besuch jeweils aufwendiger. Definition von B-Bäumen Ein B-Baum mit einem Verzweigungsgrad 5 6 7 hat folgende Eigenschaften: • Jeder Knoten x speichert eine Anzahl n[x] von Schlüsseln, wobei gilt: 5 8 1 # 9:; # 25 8 1 (für die Wurzel gilt die untere Grenze nicht) • Die Schlüssel in einem Knoten sind aufsteigend sortiert. • Jeder Knoten (der kein Blatt ist) hat somit n[x]+1 Kinder. • Für die Kinder gilt jeweils die Suchbaumeigenschaft; d. h. die Schlüssel im i-ten Kind eines Knotens sind kleiner als die im i+1-ten Kind; dazwischen liegt im Elternknoten der Schlüssel, der diese beiden Kinder trennt. Einfügen in B-Bäume Richtiges Blatt finden und einfügen. Das Blatt, in das eingefügt wurde, hat nun 2t Schlüssel. Ja 1. Das Blatt in zwei Blätter der Größen t und t-1 aufteilen. 2. Den übrigen Schlüssel als Trenner dem Elternknoten hinzufügen (diesen Elternknoten evtl. rekursiv behandeln). Fertig. Nein Fertig. 23 Löschen aus B-Bäumen Der zu löschende Schlüssel ist in einem Blatt. Ja Nein Zu löschenden Schlüssel mit seinem Nachfolger ersetzen. Nun muss der Nachfolger gelöscht werden (somit kann man immer aus einem Blatt löschen). Den Schlüssel löschen. Das Blatt (B), aus dem gelöscht wurde, hat nun weniger als t-1 Schlüssel (Unterlauf). Nein Fertig. Ja Der rechte (linke) Nachbar (B‘) von B hat mehr als t-1 Schlüssel. Nein Verschmelze B und B‘ mitsamt deren Trenner aus dem Elternknoten zu einem neuen Knoten (dieser hat nun 2t-2 Schlüssel). Ja 1. Nehme den Schlüssel, der B und B‘ trennt aus dem Elternknoten und füge ihn B hinzu. 2. Nehme den kleinsten (größten) Schlüssel aus B‘ und füge ihn dem Elternknoten als neuen Trenner hinzu. Fertig. Evtl. erzeugt dies einen Unterlauf im Elternknoten, der rekursiv behandelt werden muss. Fertig. 24 III. Hashtabellen Eine Hashtabelle speichert Elemente anhand ihres Schlüssels in einem Array. Diese Datenstruktur ermöglicht quasi direkten Zugriff auf jedes Element (im Gegensatz zu Bäumen, in denen ein Element immer erst gesucht werden muss), hat aber den Nachteil eines höheren Speicherplatzbedarfs. Direkte Adressierung • Die Schlüssel sind ganze Zahlen in einem Bereich 0…N-1. Dann kann man ein Array A der Größe N verwenden. • Der Eintrag A[k] ist x, falls es ein Element mit Schlüssel k gibt; oder Nil, falls die Hashtabelle kein solches Element enthält. • Dann haben Suche, Einfügen und Löschen konstante Laufzeit, also 21. • Nachteile der direkten Adressierung: Enorm hoher Speicherplatzbedarf (ein ArrayFeld für jedes potentiell mögliche Element); bzw. ganz unmöglich, falls es gar keine Obergrenze an Schlüsseln gibt. Hashing Um die Nachteile der direkten Adressierung zu umgehen, bedient man sich einer sog. Hashfunktion h. • Diese Funktion h bildet eine Menge von Schlüsseln U auf eine Menge natürlicher Zahlen ab, nämlich den Bereich 0…m-1. Dann kann man ein Array A der Größe m verwenden. Formal: =: ? @ A0, 1, 2, 3, … , E 8 1F Ein Beispiel für eine Hashfunktion: h(k) = k mod m. Dank Modulo werden keine Werte kleiner 0 bzw. größer m-1 geliefert. Es kommt also nicht zu Problemen mit den Arraygrenzen. • Ein Element mit Schlüssel k wird dann an der Stelle A[h(k)] gespeichert. • Problem: Es kann passieren, dass die Hashfunktion für zwei verschiedene Schlüssel dieselbe Zahl liefert. Solche Kollisionen sind häufiger als man denkt und müssen umgangen werden. 25 Kollisionsauflösung durch Verkettung Man kann das Problem der Kollisionen (s. o.) recht einfach lösen, indem man mehr als ein Element in jedem Array-Feld erlaubt. Umgesetzt wird dies durch eine verkettete Liste in jedem Array-Feld. Erzeugt die Hashfunktion dann nämlich zweimal denselben Schlüssel, kann das zweite Element einfach an die Liste angehängt werden. Kollisionsauflösung durch Verkettung „verkompliziert“ leider die Operationen Suche, Einfügen und Löschen: • Suche nach einem Element mit Schlüssel k: Hier kann man nun nicht mehr einfach das Element an der Stelle A[h(k)] zurückgeben, sondern muss erst noch in der Liste an dieser Stelle danach suchen. • Einfügen: Es ist nun nicht mehr möglich, das Element schlicht in A[h(k)] zu schreiben. Man muss es an die dortige Liste anhängen. • Löschen: Entsprechend ist es beim Löschen nicht möglich, A[h(k)] auf Nil zu setzen. Man muss das Element aus der dortigen Liste löschen. Die Laufzeiten sind daher nun nicht mehr 21. Lastfaktor Eine Hashtabelle mit m Plätzen und n Einträgen (d. h. belegten Plätzen) hat einen Lastfaktor von G H /J Man beachte, dass K 1möglich ist (da mit Verkettung in jedem Feld ja mehrere Elemente gespeichert sein können). Seien die Hashwerte uniform verteilt (alle gleichwahrscheinlich). Dann haben die Listen im Durchschnitt eine Länge von G . Somit haben Suche, Einfügen und Löschen eine mittlere Laufzeit von: # L G „1“ für das Finden des Array-Feldes A[h(k)]. Und dann noch G für das Durchlaufen der Liste. c ist eine geeignete Konstante größer 0. 26 Hashfunktionen Hashfunktionen sollten möglichst uniform verteilte Werte liefern, da ansonsten einige Felder leer bleiben, während andere überfüllt werden. Nachfolgend werden zwei mögliche Konzepte für Hashfunktionen besprochen. Divisionsmethode Hashfunktion: MN N JOP J Dazu folgende Überlegungen: - m sollte keine Zweierpotenz sein, da sonst h(k) nicht von allen Bits von k abhängt. - Eine gute Wahl für m ist eine Primzahl, die nicht nahe bei einer Zweierpotenz liegt (ohne Begründung). Multiplikationsmethode Hashfunktion: QR STRU TV LW für ein X 6 ;0, 19 Dazu folgende Überlegungen: - x mod 1 liefert den Nachkommawert (z.B. 3,12345 mod 1 = 0,12345). - Rationale Zahlen A mit kleinem Nenner führen zu Ungleichverteilungen, daher empfiehlt sich für A der „Goldene Schnitt“: X √5 8 1/2 - Vorteil dieser Methode: Arithmetische Progressionen von Schlüsseln werden ebenmäßig verstreut. Offene Adressierung Möchte man auf verkettete Listen verzichten, kann man die sog. Offene Adressierung verwenden. Kommt es bei dieser Methode zu einer Kollision, wird für das einzufügende Element stattdessen eine neue Array-Position benutzt. Dazu braucht man eine zweistellige Hashfunktion: M: [ \ A], … , J 8 LF @ A], … , J 8 LF Einfaches Beispiel für eine solche Hashfunktion: =^, _ ^ E-` E _ E-` E mit _ 6 0, … E 8 1 Anwendung: zunächst wird h(k, 0) aufgerufen. Ist das so gefundene Array-Feld schon belegt, wird h(k, 1) versucht, usw. … Nachteile: Einfügen und Suche werden komplizierter (Algorithmen s. Skript, Folie 134/35). Löschen ist sogar nur beschränkt umsetzbar, da bei schlichtem Überschreiben des Feldes mit Nil nachfolgende Suchen durcheinander kommen werden. Man könnte Elemente stattdessen als gelöscht markieren, und dann dem Einfüge-Algorithmus erlauben, so markierte Elemente zu überschreiben. 27 Hashfunktionen für offene Adressierung Lineares Sondieren Hashfunktion: MN, a Mb N a JOP E (entspricht dem Beispiel oben) Problem: Es entstehen lange zusammenhängende Blöcke besetzter Plätze, was die Sondierdauer erhöht. Quadratisches Sondieren Hashfunktion: MN, a Mb N L ac ac JOP J Quadratisches Sondieren ist zwar besser als die lineare Variante, hat aber immer noch folgenden Nachteil: Kollidierende Schlüssel (gleiche Werte für h‘(k)) haben dieselbe Sondierungsfolge. Double Hashing Hashfunktion: MN, a dML N aMc Ne JOP J Dadurch wird jede Sondierungsfolge zu einer arithmetischen Progression, bei der Startwert und Schrittweite durch Hashfunktionen bestimmt sind. Man kann sich nun noch überlegen, dass der größte gemeinsame Teiler von Mc N und m gleich 1 sein muss, damit alle Positionen sondiert werden. Z.B. m Zweierpotenz und Mc N immer ungerade. Es gibt dann 2E3 Sondierungsfolgen. Analyse der offenen Adressierung – Zwei Sätze In einer offen adressierten Hashtabelle mit Lastfaktor von G /J % 1 ist die zu erwartende Dauer… 1. … einer erfolglosen Suche (und der Einfüge-Operation) beschränkt durch L/L 8 G. 2. … einer erfolgreichen Suche beschränkt durch L 8 fL 8 G/G. Dies ist deshalb von Interesse, da man zunächst nicht annehmen würde, dass die erwarteten Laufzeiten hier gar nicht von der tatsächlichen Größe der Hashtabelle abhängig sind, sondern allein vom Lastfaktor. 28 IV. Allgemeine Entwurfs- und Optimierungsmethoden Greedy-Algorithmen Greedy-Algorithmen lösen ein Problem, indem die Lösung Schritt für Schritt ausgebaut wird, wobei immer „gierig“ die im momentanen Schritt beste Verbesserung gewählt wird. Das bedeutet andererseits: Ist die optimale Lösung nur über einen „Umweg“ zu erreichen, wird ein Greedy-Algorithmus versagen. Greedy-Algorithmen sind speziell für Optimierungsprobleme geeignet. Sie sind grundsätzlich anwendbar, wenn die Lösung eines Problems aus Einzelentscheidungen besteht. Dynamische Programmierung Auch die dynamische Programmierung eignet sich speziell für Optimierungsprobleme. Dieses Prinzip errechnet die optimale Lösung als Zusammensetzung aus bereits (rekursiv) berechneten Teilproblemen. Um den Zeitaufwand bei der Rekursion zu verbessern, werden diese Teillösungen in einer Tabelle gespeichert, von wo sie bei Bedarf nachgeschlagen werden können (und nicht neu berechnet werden müssen). Dieses „Merken“ macht dann Sinn, wenn die Teilprobleme ihrerseits aus gemeinsamen Teilproblemen bestehen (denn ein divide-and-conquer-Verfahren würde hier die gleichen Teilprobleme mehrfach lösen müssen). Fazit: • • Ausgangspunkt einer Umsetzung mit dynamischer Programmierung ist immer eine rekursive Lösung des gegebenen Problems. Das Prinzip bietet sich vor allem dann an, wenn bei „normaler“ Umsetzung die Zahl der rekursiven Aufrufe größer wäre, als die Zahl der überhaupt möglichen voneinander verschiedenen rekursiven Aufrufe. Amortisierte Analyse Diese Vorgehensweise zur Berechnung der Laufzeit erlaubt es, den Aufwand für eine ganze Folge von Operationen besser abzuschätzen, als durch einfaches Aufsummieren. Sinn macht dies dann, wenn es zwar gelegentlich teure Operationen gibt, die dann aber dazu führen, dass nachfolgende Operationen „es wieder leichter haben“ und schneller ablaufen können. Es gibt dazu drei verschiedene Ansätze (s. Skript, Folien 168ff). 29 Union Find Union Find ist eine Datenstruktur für eine Familie von disjunkten Mengen (d.h. ein Element ist immer nur Teil einer Menge). Jede einzelne der Mengen wird durch einen Baum dargestellt. Ein Knoten x hat hierbei einen Zeiger auf seinen Vater p[x]. Die Wurzel hat ja keinen Vater und zeigt daher auf sich selbst. Man beachte, dass die Knoten dieser Bäume anders als sonst üblich mehr als zwei Kinder haben dürfen. Es stehen folgende drei Operationen zu dieser Datenstruktur zur Verfügung: • Make-Set(x) Fügt die Menge {x} zur Familie hinzu, also eine neue Menge, die nur x enthält. • Find(x) Liefert das kanonische Element der Menge, die x enthält. Das kanonische Element ist die Wurzel des entsprechenden Baums; es dient sozusagen als Name der Menge. • Union(x, y) Vereinigt die Menge, die x enthält, mit jener, die y enthält. Union bedient sich außerdem einer Methode Link(x, y). Diese hängt y unter x, wobei x und y Wurzeln sein müssen. Somit entspricht ein Aufruf von Union(a, b) dem verschachtelten Aufruf Link(Find(a), Find(b)). Es gibt nun zwei Ansätze, um eine solche Datenstruktur effizienter zu gestalten. Optimierung 1: Union by Rank Hierbei erhält jedes Element x ein Rang-Feld rank[x], welches die Höhe des (Teil-)Baumes angibt, dessen Wurzel x ist. Neue Elemente erhalten Rang 0. Die Idee dahinter: Die Link-Methode wird so verändert, dass nicht immer (willkürlich festgelegt) y unter x gehängt wird, sondern das Element mit dem niedrigeren Rang unter das andere; d. h. der kleinere Baum wird an den größeren gekettet. So wird vermieden, dass der entstehende Baum noch länger wird und auf die Dauer evtl. zu einer Liste entartet. Bei gleichem Rang von x und y wird y unter x gehängt und dessen Rang wächst um 1. Durch diese Optimierung hat jede Folge von m Make-Set, Union- und Find-Operationen, von denen n Make-Set sind (d. h. am Ende sind n Elemente in der Datenstruktur), eine Laufzeit von O(m log n). Die einzelnen Operationen haben also amortisierte Laufzeit von O(log n). 30 Optimierung 2: Pfadkompression Die zweite Idee zur Verbesserung ist folgende: Man hängt jeden Knoten, der in einem Aufruf von Find auf dem Weg zur Wurzel besucht wird direkt unter diese Wurzel. Dadurch werden spätere Suchvorgänge effizienter, weil der Pfad vom Knoten mit dem Element x zur Wurzel dann nur noch die Länge 1 hat. Zur Umsetzung muss natürlich die Find-Methode entsprechend angepasst werden (s. Skript, Folie 187). Durch beide Optimierungen hat jetzt jede Folge von m Make-Set, Union- und FindOperationen, von denen n Make-Set sind, eine Laufzeit von O(m log* n). Die einzelnen Operationen haben damit eine amortisierte Laufzeit von O(log* n). Iterierter Logarithmus log* n = „Mindestanzahl der nötigen log-Verschachtelungen (d.h.: log log … log n), um einen Wert kleiner gleich 1 zu erhalten.“ 31 Backtracking Hierbei handelt es sich um eine spezielle Lösungsstrategie für Suchprobleme der Art: • Mögliche Lösungen können als Blätter eines (immensen) Baumes gedacht werden. • Teillösungen entsprechen dann den inneren Knoten. Nun wird der Baum mit Tiefensuche (s. auch Promo-Skript) durchgearbeitet. Sieht man einer Teillösung bereits an, dass sie nicht zu einer Lösung führen kann, so kann man den ganzen entsprechenden Teilbaum verwerfen. Es folgen zwei Verbesserungskonzepte zum Backtracking. Iterative Deepening Oft gibt es Probleme, deren Lösung mit steigender Tiefe im Lösungsbaum immer unwahrscheinlicher wird. Dann würde es sich anbieten, die Tiefensuche durch eine Breitensuche zu ersetzen, was leider aber zu aufwändig ist (großer Speicherplatzbedarf). Stattdessen tut man folgendes, um sich nicht in einem nicht-enden-wollenden Zweig zu verlieren: Man erkundet im ersten Durchlauf alle Knoten, zu denen ein Pfad der Länge 1 führt. Findet man keine Lösung, erkundet man in einem zweiten Durchlauf dann alle Knoten, zu denen ein Pfad der Länge 2 führt, usw. … Da man immer von der Wurzel aus neu beginnt, muss nun jedesmal der Baum wieder neu aufgebaut werden (Pfade der Länge 2 enthalten ja die Pfade der Länge 1, die man eigentlich schon berechnet hatte). Allerdings spart man so enorm Speicherplatz und erzielt eine höhere Effizienz als die Breitensuche. Branch and bound Oft sind Lösungen im Lösungsbaum nach ihrer Güte angeordnet. Sucht man nun die beste Lösung bzw. eine Lösung mit bestimmter „Mindestqualität“, so kann man folgende Überlegung anstellen: Wenn man anhand einer Teillösung schon sagen kann, dass nichts Nachfolgendes (in diesem Teilbaum) besser sein kann, als eine bereits gefundene andere Teillösung, so braucht man diesen Pfad nicht mehr weiter verfolgen. Anmerkung zur Terminologie (kam nicht in der Vorlesung): „Branch“ bezeichnet den ersten Schritt, in dem das Problem aufgeteilt („verästelt“) wird. „Bound“ benennt den nun folgenden zweiten Schritt, der versucht den Baum zu „beschränken“, indem alle zu schlechten Äste abgeschnitten werden. Quelle: wikipedia. 32 V. Algorithmen auf Graphen Grundlagen Repräsentation von Graphen Ein Graph G besteht aus einer Menge V von Knoten und einer Menge E von „Verbindungen“ (wie z.B. (a, b) für den „Pfeil“ von a nach b) zwischen jeweils zwei Knoten (Bsp. s. Folie 205). Adjazenzliste/-matrix Eine solche Liste (bzw. Matrix) speichert für jeden Knoten x dessen Nachbarn; d. h. jene Knoten, die von x aus über die vorhandenen Verbindungen direkt zu erreichen sind. Breitensuche Die Breitensuche geht von einem Startknoten g 6 h aus und sucht für jeden Knoten i 6 h den kürzesten Pfad (über die Verbindungen) zu s. Außerdem soll jeweils noch die Distanz d[v] (in Verbindungen) ermittelt werden. Dazu speichert man für jedes i 6 hden Vorgänger j9i; auf dem kürzesten Pfad zu s. Zu Beginn werden für jeden Knoten i 6 h gesetzt: j9i;= nil und d[v] = ∞ Außerdem wird d[s] = 0 (der Startknoten ist von sich selbst 0 entfernt) und s in einer FIFOqueue Q gespeichert. Somit kann der folgende Algorithmus eine Breitensuche implementieren: put(Q,s) while Q l m do v ← get(Q) for each u 6 Adjazenzliste[v] with d[u] = ∞ do d[u] ← d[v] + 1 j9n; ← v put(Q,u) In Worten: Zu Beginn wird der Startknoten s in die leere Warteschlange Q gelegt. Dann durchlaufen wir Q, solange es nicht leer ist. Wir holen uns das älteste Element in Q und merken es uns in v. Nun wird die Adjazenzliste von v nach Nachbarn u durchsucht, deren Distanz-Wert noch auf „Unendlich“ steht. Jedes solche u erhält nun einen Distanzwert von v + 1, da es als 33 Nachbar von v ja 1 weiter von s entfernt sein muss (sonst wäre dieses u vorher schon als Nachbar an der Reihe gewesen). v wird natürlich außerdem zum Vorgänger von u auf dem Pfad zu s. Anschließend wird u selbst in Q gelegt. Nach allen Durchläufen der for-Schleife befinden sich in Q nun (neben evtl. noch vorhandenen alten Knoten) nun auch alle Nachbarn von v, die zuvor bearbeitet werden mussten. In den nächsten Durchläufen der while-Schleife werden diese nun betrachtet, um deren Nachbarn wiederum zu bearbeiten; usw. … Der Algorithmus terminiert, wenn Q leer ist, was dann passiert, wenn alle Knoten erkundet wurden, da dann kein Distanz-Wert mehr „Unendlich“ ist und somit nichts Neues mehr in Q hineingelegt wird. Tiefensuche (DFS) Neben der Breitensuche wurden zwei Varianten der Tiefensuche betrachtet. Der Unterschied zwischen „Breite“ und „Tiefe“: Breitensuche breitet sich sozusagen ringförmig um einen Startknoten aus, während bei der Tiefensuche immer erst ein Pfad ganz bis zum Ende verfolgt wird. Tiefensuche mit Farben Die erste Variante speichert für jeden Knoten einen von drei möglichen Farbwerten, nämlich Weiß, Grau oder Schwarz. Noch nicht besuchte Knoten sind weiß. Besuchte Knoten sind grau, wenn sie noch nicht abgefertigt wurden, bzw. schwarz, wenn dies der Fall ist. „Abgefertigt“ bedeutet, dass der gesamte Teil durchsucht wurde, der von diesem Knoten aus zu erreichen ist. Der Algorithmus (s. Folie 209) durchläuft alle Knoten und startet „Besuche“ für Knoten v die noch weiß sind. Besucht werden dann alle Nachbarn u in der Adjazenzliste von v, wobei die Nachbarn eines u wiederum (rekursiv) besucht werden, bevor der nächste Nachbar u von v an der Reihe ist. Durch diese Rekursivität der Visit-Methode entsteht die „Tiefenwanderung“. Tiefensuche mit „Zeiten“ In der zweiten Variante werden neben den Farben (dies beinhaltet also obige Idee) für jeden Knoten v noch zwei Felder angelegt: Die Entdeckungszeit d[v] und die Abfertigungszeit f[v]. Außerdem gibt es eine globale „Uhr“, die vor jeder Zeitzuweisung um eins erhöht wird. Der Algorithmus (s. Folie 212) arbeitet genauso wie jener oben, hinzu kommen nur die entsprechenden Zeitzuweisungen: d[v] wird beim ersten Besuch eines Knotens v gesetzt, f[v] nachdem alle bei diesem Besuch gestarteten Rekursionen abgearbeitet wurden. Wenn fortan DFS benutzt wird, gehen wir von einer Implementierung mit (Farben und) Zeiten aus. 34 Klassifikation der Kanten Durch die Tiefensuche kann man die Kanten („Verbindungen“ der Knoten) eines Graphen in die folgenden vier Arten einteilen: • Baumkanten: Dies sind die Kanten des DFS-Waldes, d. h. (u, v) mit j9i;= u (u ist Vorgänger von v). Kennzeichen: Beim ersten Durchlaufen ist v weiß. In Worten: Beim Besuch von u erreichen wir von diesem u aus das noch nicht besuchte v. • Rückwärtskanten: Jene (u, v) , bei denen v Vorfahre von u ist. Kennzeichen: Beim ersten Durchlaufen ist v grau. In Worten: Beim Besuch von v wird rekursiv u besucht. Innerhalb dieses rekursiven Aufrufs wird zu v „zurückgeblickt“. • Vorwärtskanten: Jene (u, v), bei denen v Nachkomme von u ist. Kennzeichen: Beim ersten Durchlaufen ist v schwarz und wurde nach u entdeckt (d[u] < d[v]). In Worten: v wurde durch einen rekursiven Aufruf beim Besuch eines anderen Nachbarn von u bereits besucht, bevor es direkt als Nachbar von u (d. h. aus u’s Adjazenzliste) besucht wird. • Querkanten: Alle anderen (u, v). Kennzeichen: Beim ersten Durchlaufen ist v schwarz und wurde vor u entdeckt (d[u] > d[v]). In Worten: Ähnlich wie bei den Vorwärtskanten, nur wurde v von einem anderen Knoten aus besucht, der bereits vor u entdeckt und besucht wurde. 35 Starke Zusammenhangskomponenten Zusammenhang • • Zwei Knoten u und v heißen zusammenhängend, wenn es einen Weg von u nach v gibt. Sie heißen stark zusammenhängend, wenn es auch einen Weg von v nach u gibt. Ein Graph lässt sich nun in sog. Starke Zusammenhangskomponenten (SCC) gliedern. Alle Knoten einer SCC erreichen sich gegenseitig. Jeder Knoten liegt dabei in genau einer SCC. Algorithmus zur Zerlegung eines Graphen in SCC: 1. DFS(G) – also G mit Tiefensuche erkunden. 2. Die Knoten nach absteigender finishing time sortieren (das ist die Topologische Ordnung). 3. Berechne den transponierten Graphen o p von G (informell: „alle Pfeile umdrehen“). 4. DFS(o p ), wobei die Knoten nach der in 2. erstellten Reihenfolge behandelt werden. 5. SCCs von G sind nun die Bäume des in 4. berechneten DFS-Waldes (Informell: „Ein neuer Baum beginnt, wenn bei der Tiefensuche an einem neuen Knoten ‚neu angesetzt‘ werden muss, weil alle rekursiven Aufrufe abgehandelt worden sind.“). 36 (Minimale) Spannbäume Definitionen Sei G = (V, E) ein zusammenhängender, ungerichteter Graph. Ein Spannbaum von G ist eine Teilmenge q rder Kanten, sodass (V, T) azyklisch ist und je zwei Knoten durch einen Pfad verbunden sind. Ein minimaler Spannbaum (MST) liegt dann vor, wenn die Summe der Gewichte seiner Kanten so klein wie möglich ist. Nach welchen Kriterien den Kanten ihr Gewicht zugeordnet wird, muss natürlich vorher festgelegt werden. Grundalgorithmus Man interessiert sich nun natürlich dafür, wie man systematisch einen minimalen Spannbaum für einen gegeben Graphen ermitteln kann. Das Grundprinzip dafür ist ein Greedy-Algorithmus, der nach und nach sog. „sichere“ Kanten zur Kanten-Auswahl für den minimalen Spannbaum hinzufügt. Eine Kante ist „sicher“, wenn sie ausgewählt werden kann, ohne die minimale Lösung zu „versauen“ (formell s. Folie 226). Satz: „kreuzen“ Für s q h und t 6 r sagen wir „e kreuzt S“, falls t An, iF mit n 6 s und i 6 h u s. In Worten: S ist eine Auswahl von Knoten. Eine Kante e „kreuzt“ S, wenn ein Knoten von e in S liegt und der andere nicht. Eine Kante e ist damit sicher für eine Teilmenge A eines minimalen Spannbaumes, wenn für s q h gilt: Keine Kante in A kreuzt S und e ist eine Kante mit minimalem Gewicht, die S kreuzt. Informell: „e ist der günstigste Weg für die bisherige Auswahl A eine Brücke über die Grenze von S zu schlagen.“ 37 Der Algorithmus von Prim Dieser Algorithmus ist eine Umsetzung des oben dargelegten Prinzips, um einen minimalen Spannbaum zu bestimmen. Er benutzt eine Priority-Queue Q. • Eingabe: G mit Gewichtsfunktion w und Anfangsknoten r 6 V. • Initialisiere Q mit allen v 6 V, mit key[v] = ∞. • Setze key[r] ← 0 und j[r] ← NIL. • Solange Q nicht leer ist, setze v ← EXTRACT-MIN(Q): Für alle Nachbarn u 6 Adj[v], die noch in Q sind, und für die w({u,v}) < key[u] ist, setze j[u] ← v und key[v] ← w({u,v}) In Worten: Die ersten drei Punkte sind nur „Vorbereitung“. Anschließend entnimmt man immer das kleinste Element v aus Q (Vergleichskriterium ist der key-Wert) und schaut sich dessen Nachbarn an: Ist ein Nachbar u nicht bereits abgearbeitet (also noch in Q) und ist dessen key-Wert größer, als das Gewicht jener Kante, die ihn mit dem gerade zu bearbeitenden Knoten v verbindet, so wird diese bessere Kante „gewählt“ (j[u] ← v) und der key-Wert entsprechend aktualisiert. Anschließend wird das nächstkleinste Element aus Q entnommen, dessen Nachbarn betrachtet, usw. … Ein graphisches Beispiel findet sich im Skript auf den Folien 230/31. Zum Schluss hat man einen minimalen Spannbaum gefunden; er enthält die Kanten (j[v], v). 38 Der Algorithmus von Kruskal Auch dieser Algorithmus dient dem Finden eines minimalen Spannbaumes. Kruskals Ansatz verwendet die Union/Find-Datenstruktur. A ist Teilmenge, bzw. am Schluss Lösung eines minimalen Spannbaumes. • Setze X v m. • Rufe MAKE-SET(v) für jeden Knoten v 6 V auf. • Sortiere die Kanten aufsteigend nach Gewicht. • Prüfe für jede Kante e = {u, v} in der sortierten Reihenfolge, ob FIND(u) l FIND(v). • Falls ja, füge e zu A hinzu und rufe UNION(u, v) auf, sonst einfach weiter zur nächsten Kante. In Worten: Die ersten drei Punkte sind wieder nur „Vorbereitung“. Somit liegen zu Beginn |V| disjunkte Mengen vor, die eben jeweils einen Knoten enthalten. Anschließend nimmt man sich die günstigste Kante und prüft ob die Knoten, die sie verbindet, noch nicht anderweitig verbunden sind (FIND(u) l FIND(v)). Ist dies der Fall, dürfen wir sie nun mit e verbinden - d. h. e wird zur Auswahl A der Kanten für den minimalen Spannbaum hinzugefügt; und außerdem merken wir uns in der Union/Find-Struktur, dass diese beiden Knoten nun verbunden sind, indem wir sie in eine Menge packen (UNION(u, v)). Dann nehmen wir die nächstgünstigste Kante, usw. … Ein graphisches Beispiel findet sich im Skript auf Folie 235. Zum Schluss hat man einen minimalen Spannbaum gefunden; er enthält die Kanten in A. 39 Kürzeste Wege Im Folgenden sollen Möglichkeiten betrachtet werden, wie man den kürzesten Weg von einem Knoten zu einem anderen findet. Als „kürzester Weg“ gilt dabei natürlich jener Pfad zum Ziel, dessen Kantengewichte in der Summe am niedrigsten sind. Sind zwei Knoten gar nicht verbunden, ist die Distanz unendlich. Man muss nun beachten, dass es auch negative Kantengewichte geben kann; und insbesondere negative Zyklen. Dann könnte man nämlich durch „Umrundungen“ eine beliebig kleine Distanz erreichen. Initialisierung Will man kürzeste Wege bestimmen, muss man zunächst für jeden Knoten die Distanz auf Unendlich setzen. Nur die des Startknotens wird auf 0 gesetzt, da er von sich selbst ja 0 entfernt ist. Außerdem hat jeder Knoten wieder ein Vorgänger-Attribut j[v]. Dieses ist zu Beginn nil. Relaxierung RELAX(u, v) if d[v] > d[u] + w((u, v)) then d[v] ← d[u] + w((u, v)) j[v] ← u In Worten: Diese Methode prüft, ob der bisher kürzeste Pfad zu v durch die Kante (u, v) verbessert werden kann. Falls, ja wird diese Kante für den neuen kürzesten Pfad verwendet (graphisches Beispiel, s. Folie 241). 40 Der Algorithmus von Dijkstra Dieser Algorithmus berechnet die kürzesten Wege von einem Startknoten s zu allen anderen Knoten. Dabei dürfen im Graphen keine Kanten mit negativem Gewicht vorkommen (sonst funktioniert der Algorithmus nicht). Es wird eine Priority-Queue Q benutzt. DIJKSTRA(G, s) • Rufe INITIALIZE(G, s) auf und setze Q ← V (lade alle Knoten in Q). • Solange Q nicht leer ist, setzte u ← EXTRACT-MIN(Q); d.h. nehme das kleinste Element aus Q. Vergleichskriterium ist die Distanz d[v]. Für alle v 6 Adj[u], also Nachbarn von u, führe RELAX(u, v) aus. Für die Absenkung eines d-Wertes ist DECREASE-KEY zu verwenden. In Worten: Der erste Punkt dient der „Vorbereitung“. Danach nimmt man jenen Knoten u unter den verbliebenen, der am wenigsten weit vom Startknoten s entfernt ist (also im ersten Durchgang s selbst) und betrachtet dessen Nachbarknoten. Es wird für jeden Nachbarn v von u RELAX aufgerufen, um den Pfad „von s über u zu v“ zu verbessern, falls möglich. Ein graphisches Beispiel findet sich im Skript auf Folie 244. Am Ende kann der kürzeste Weg jeweils über die Vorgänger-Zeiger j[v] verfolgt werden. Laufzeit: Q als Liste: Q als Heap: Q als Fibonacci-Heap: O(|V|²) O(|E| * log|V|) O(|V| * log|V| + |E|) 41 Der Algorithmus von Bellman-Ford Auch dieser Algorithmus berechnet die kürzesten Wege von einem Startknoten s zu allen anderen Knoten. Allerdings dürfen hier auch Kanten mit negativem Gewicht vorkommen; negative Zyklen verhindern zwar ein erfolgreiches Abschließen, werden vom Algorithmus allerdings erkannt und gemeldet. BELLMAN-FORD(G, s) • • Rufe INITIALIZE(G, s) auf. Wiederhole |V| - 1 mal: Für jede Kante (u, v) 6 E rufe RELAX(u, v) auf. • • Teste für jede Kante (u, v) 6 E, ob d[v] > d[u] + w(u, v) ist. Falls ja für eine Kante: Melde negativen Zyklus. Sonst erfolgreich. In Worten: Der erste Punkt dient der „Vorbereitung“. Anschließend werden „Anzahl der Knoten – 1“-Mal alle Kanten relaxiert. Danach hat dieser Algorithmus für Graphen mit rein positiven Kantengewichten dasselbe geleistet, wie jener von Dijkstra - nur mit einigen Relaxierungs-Aufrufen mehr. Allerdings liegt dadurch nun auch für Graphen mit negativen Kanten ein korrektes Ergebnis vor, insofern kein negativer Zyklus existiert. Anschließend prüft man alle Kanten daraufhin ab, ob eine weitere Relaxierung „noch etwas bringen würde“. Dies kann aber jetzt nur noch der Fall sein, wenn ein negativer Zyklus vorliegt, den man somit entdeckt. Am Ende kann wieder der kürzeste Weg jeweils über die Vorgänger-Zeiger j[v] verfolgt werden. Laufzeit: O(|V| * |E|) 42 Flüsse in Netzwerken Ein Fluss durch einen Graphen läuft anschaulich von einem Quell-Knoten (auf den keine Pfeile zeigen) über Kanten bis zu einem Ziel-Knoten (von dem keine Pfeile wegzeigen). Intuitiv ist klar, dass aus jedem Knoten, der dabei gekreuzt wird, nicht weniger oder mehr wegfließen kann, als dort hineingeflossen ist. Zahlen an den Kanten gelten im Zusammenhang mit Flüssen nicht als Kantengewichte, sondern werden vielmehr als „maximaler Durchfluss“ (Kapazität) dieser Kante interpretiert. Das Hauptaugenmerk liegt nun darauf, wie man durch den Graphen möglichst viel „Wasser“ von Quelle zum Ziel (auch Senke genannt) schicken kann, d. h. der Wert des Flusses |f| soll maximiert werden. Natürlich dürfen dabei nirgends Kapazitäten überschritten werden. Flusseigenschaften Zur Notation: f(v, X) bedeutet: Summe der Flüsse von/zu v zu/von den Knoten der Menge X. Statt v kann auch noch eine Menge von Knoten stehen (s. u.). Für w, x, y q z mit Y { Z = m gilt: • • • • w, w ] Um von X nach X zu fließen (d.h. innerhalb einer Menge), braucht natürlich gar nichts zu fließen. w, x 8 x, w Ein Fluss in die eine Richtung ist auch ein negativer Fluss in die Gegenrichtung. w, x | y w, x w, y Um von X nach Y und Z zu fließen, fließt soviel wie von X nach Y fließt, plus der Fluss von X nach Z. x | y, w x, w y, w Der umgekehrt Fall zu oben: Von Y und Z fließt zu X soviel, wie von Y zu X fließt, plus das, was von Z zu X fließt. 43 Restnetzwerke und Erweiterungspfade Gibt es in einem Netzwerk bereits einen Fluss, so will man vielleicht die Restkapazität des Netzwerks/Graphen bestimmen. Dazu muss nur für jede Kante betrachtet werden, wie viel „Wasser“ hier noch hindurch passen würde. Soll man ein Restnetzwerk in einen gegebenen Graphen mit Fluss einzeichnen, muss man also zu den bestehenden Kanten einen in gleicher Richtung verlaufenden Pfeil mit der Restmenge eintragen. Außerdem darf man die Rückwärtskanten nicht vergessen: Geflossenes darf wieder zurücklaufen, d. h. man muss einen Pfeil in umgekehrter Richtung antragen, wobei beide „neuen“ Pfeile in der Summe der Kapazität der Kante entsprechen. Die Grafiken im Skript stellen dies gut dar (z.B. Folie 258). Erweiterungspfad Ein Weg p: s } t im Restnetzwerk Gf eines Flusses f ist ein Erweiterungspfad des Flusses. Seine Restkapazität cf entspricht der Kapazität der Kante in p mit der geringsten Kapazität (gewissermaßen: bestimmend ist „das schwächste Glied der Kette“). Addiert man einen solchen Pfad zum bestehenden Fluss, so hat man diesen sozusagen „erweitert“, daher der Name. Zwei Flüsse f und g werden im Übrigen einfach addiert, in dem man ihre Werte |f| und |g| addiert. Das Max-Flow-Min-Cut Theorem Dieses Theorem besteht aus den folgenden drei Aussagen und besagt, dass diese äquivalent sind: • f ist ein maximaler Fluss in G. • Im Restnetzwerk Gf gibt es keinen Erweiterungspfad. Sozusagen: „f lässt sich nicht mehr verbessern, da kein neuer Fluss mehr Start und Ziel verbinden kann.“ • Es gibt einen Schnitt (S, T) mit |f| = c(S, T). Ein Schnitt (S, T) teilt die Knoten des Netzwerks in zwei disjunkte Mengen S und T. Man kann die Knoten des Netzwerks also so in zwei Gruppen teilen, dass die Summe der Kapazitäten von „grenzüberschreitenden“ Pfeilen gleich dem Wert des Flusses ist. Denn ein Fluss muss ja von einer Gruppe zur anderen kommen, um ans Ziel zu gelangen. Wenn es nicht mehr Wege dazu gibt, als schon voll durchflossen werden, geht es natürlich nicht mehr besser. Man beachte, dass dies nicht für jeden möglichen Schnitt gelten muss. Hat man aber einen gefunden, für den es gilt, so ist f ein maximaler Fluss. In der Vorlesung wurde nun noch bewiesen wie sich aus einem Punkt der nächste schlussfolgern lässt. 44 Die Ford-Fulkerson-Methode Mit diesem Algorithmus lässt sich ein maximaler Fluss finden. Die Idee: Finde solange Erweiterungspfade (sprich: „neue Flüsse“) und addiere sie zu den bisherigen, bis es keine Möglichkeit für einen neuen Fluss mehr gibt. FORD-FULKERSON(G, s, t, c) • • Initialisiere f(u, v) = 0 für alle u, v 6 V. Solange es einen Erweiterungspfad p in Gf gibt: Setze für jede Kante (u, v) in p f(u, v) ← f(u, v) + cf(p) f(v, u) ← - f(u, v) In Worten: Der erste Punkt dient wieder einmal der Vorbereitung: Am Anfang fließt durch keine Kante etwas. Anschließend findet man einen Erweiterungspfad p im Restnetzwerk des Graphen und betrachtet dessen Kanten: Der „Durchfluss“ für diese Kanten wird aktualisiert; zum alten Wert kommt der Wert des neuen Flusses (Erweiterungspfades) hinzu (f(u, v) ← f(u, v) + cf(p)). Man könnte sagen: „p fließt hier jetzt auch noch.“ Zudem wird der entsprechende „Rückfluss“ gesetzt (f(v, u) ← - f(u, v)). Man ist fertig, wenn kein Erweiterungspfad mehr möglich ist. Der Algorithmus von Edmonds-Karp Hierbei handelt es sich um eine Verbesserung der Ford-Fulkerson-Methode, die dazu führt, dass für den nächsten Erweiterungspfad eine bessere Wahl getroffen werden kann, als einfach irgendeinen zu nehmen. Algorithmus von Edmonds-Karp • Wähle bei der Ford-Fulkerson-Methode immer den kürzestmöglichen Erweiterungspfad. In Worten: Es soll immer der Erweiterungspfad gewählt werden, der aus den wenigsten Kanten besteht, d. h. der von Quelle zu Senke am wenigsten Kanten „durchfließt“. Es wurde noch angemerkt: Die Zahl der benötigten Kanten ließe sich z.B. mit einer Breitensuche im Netzwerk berechnen. Laufzeit: ~|h| · |r|²