Algorithmen und Datenstrukturen

Werbung
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|²
Herunterladen