Skript - Informatik

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