Skript - BFH

Werbung
Bern University of Applied Sciences
Engineering and Information Technology
Algorithmen & Datenstrukturen
P. Fierz
Keywords: Datenstrukturen, Abstrakter Daten Typ (ADT), Algorithmen, Berechenbarkeit,
Komplexität, Sortieren, Suchen, Graphen
[File had.tex, Date 29.08.2011, Version V 3.1]
© P. Fierz
Zusammenfassung
In diesem Skript werden wichtige Datenstrukturen wie der Stack, die Queue, der Baum,
Graphen usw. als abstrakte Datentypen ADT vorgestellt. Dazu werden viele Algorithmen
vorgestellt, die diese Datenstrukturen nutzen. Eine Einführung in die Berechenbarkeitstheorie
und in die Komplexitätstheorie sind ebenfalls vorhanden.
Kapitel 1
Daten
1.1
Einleitung
Der Digital-Computer wurde entwickelt, um komplexe und aufwendige Berechnungen durchzuführen. Dabei werden reelle, ganze und natürliche Zahlen verwendet. Diese Objekte können
im Computer auch sehr gut dargestellt und manipuliert werden (die Operationen auf Zahlen
stehen auch als Maschineninstruktionen zur Verfügung).
Bei den meisten Applikationen steht aber eine andere Eigenschaft des Computers im Vordergrund: seine Fähigkeit, grosse Datenmengen zu speichern. In diesen Fällen stellen die gespeicherten Daten eine Abstraktion eines Teils der realen Welt dar. Mit Abstraktion meinen wir
eine Abbildung der realen Welt in ein gegebenes Datenmodell. Wie bei den meisten Modellen
können dabei nicht alle Aspekte der Realität berücksichtigt werden, da das Modell dadurch
viel zu komplex würde. Daher werden nur diejenigen Aspekte berücksichtigt, die für das zu
lösende Problem als wichtig erachtet werden.
Beispiel 1.1 [Personaldaten] Als Beispiel vergleichen wir die Personaldaten in einer Arbeitgeberkartei und in einer Arztkartei. Möglichkeiten für diese verschiedenen Abstraktionen
sind in der Tabelle 1-1 dargestellt.
Aufbau der Arbeitgeberkartei
Name
Adresse
Funktion
Gehalt
Lohnentwicklung
usw.
Aufbau der Arztkartei
Name
Adresse
Grösse
Gewicht
Krankengeschichte
usw.
Abbildung 1-1: Abstraktion von Personen
Die Tabelle zeigt auch, dass Daten, die für den Arbeitgeber unwichtig sind, in der Arbeitgeberkartei einfach weggelassen werden. Dasselbe gilt auch für den Arzt und die Arztkartei. Somit
kann also jede Abstraktion auch als Vereinfachung der realen Tatsachen gesehen werden.
Bei der Abbildung der realen Welt in ein Datenmodell sind zwei wichtige Aspekte zu berücksichtigen.
1-1
Abstraktion Diesen Aspekt haben wir oben schon erwähnt. Es geht darum, von einem realen
Objekt die für die Applikation wichtigen Daten zu identifizieren und unwichtige Daten
wegzulassen.
Darstellung Für die gewählten Daten der Abstraktion muss im Computer nun auch eine
Darstellung gewählt werden. Diese Aufgabe ist in vielen Fällen schwierig. Die Wahl
der Darstellung der Daten im Computer muss immer in Bezug auf die mit den Daten
durchzuführenden Operationen (Algorithmen oder Programme) gesehen werden.
Beispiel 1.2 [Darstellung von Zahlen] Wir wollen die Darstellung von Zahlen im kommerziellen und im wissenschaftlich-technischen Bereich betrachten.
technische Darstellung
Im technisch-wissenschaftlichen Bereich werden von Zahlen folgende Eigenschaften verlangt:
• Das Rechnen ist die Hauptoperation und muss möglichst effizient sein.
• Es werden sehr grosse und auch sehr kleine Zahlen benötigt. (Bereich ca. 10−40 bis
1040 )
• Im allgemeinen genügt eine Genauigkeit von 10 bis 16 signifikante Stellen zur Berechnung und Ausgabe der Resultate.
• Die Druckoperation ist viel seltener als die Rechenoperationen.
Diese Überlegungen legt die floating point-Darstellung nahe, die nachstehend erklärt ist.
V orzeichen
Exponent
M antisse
Das V orzeichen besteht aus einem Bit, dabei bedeutet 0 eine positive und 1 eine negative Zahl.
Der Exponent (zur Basis 2) besteht aus 8 Bits und stellt eine binäre Zahl zwischen
0 und 255 dar. Um den effektiven Exponenten der Zahl zu bekommen, muss von der
gespeicherten Zahl 127 abgezogen werden. Anders ausgedrückt: die Zahlen 1 bis 126
stellen die negativen, die Zahlen 127 bis 255 die positiven Exponenten dar. Falls der
Exponent 0 ist, so ist die Zahl 0.
Die M antisse besteht aus 23 Bits und stellt einen normalisierten Dualbruch dar. Normalisiert heisst, dass die erste signifikante Stelle immer unmittelbar vor dem Komma
steht. Da die Stelle vor dem Komma immer 1 ist, wird diese nicht gespeichert.
In einer Formel zusammengefasst gilt:
zahl = V orzeichen · (2(Exponent−127) · 1.M antisse)
kommerzielle Darstellung
Im kommerziellen Bereich werden Zahlen vor allem zur Darstellung von Geldbeträgen
und Mengenangaben verwendet. Für diese Objekte werden die folgenden Eigenschaften
verlangt:
• Mit den Zahlen wird wenig gerechnet (höchstens Additionen). Dafür werden die
Zahlen sehr oft gedruckt.
1-2
• Der benötigte Bereich ist viel kleiner als im technischen Bereich. Vor allem sind
keine sehr kleinen Zahlen notwendig, so dass die Anzahl Stellen nach dem Komma
fix vereinbart werden kann (z.B. 4 Stellen).
• Die Zahlen müssen sowohl beim Rechnen wie auch in der Ausgabe genau sein. Es
dürfen also keine signifikanten Stellen verlorengehen.
Diese Überlegungen legt die fix point-Darstellung nahe, die nachfolgend erklärt ist.
n − m Vorkommastellen
m Nachkommastellen
Vorzeichen
Dabei sind n und m vereinbarte Konstanten. Der Dezimalpunkt steht somit immer an
derselben Stelle.
Die Zahlen werden nicht in binärer, sondern in dezimaler Form gespeichert. Jede Dezimalstelle und das Vorzeichen werden durch je 4 Bits dargestellt. Mit diesen vier Bits
können nun die Ziffern von 0 bis 9 dargestellt werden. Als Beispiel stellen wir die Zahl
235.7310 dar.
0000
| {z } 0000
| {z } . . . 0010
| {z } 0011
| {z } 0101
| {z }
0
0
2
3
5
0111
| {z } 0011
| {z } 0001
| {z }
7
3
1
0000
| {z } 0000
| {z }
0
+
Wir wollen am Schluss dieser Einführung versuchen, den Begriff “Computer-Daten” genauer
zu definieren.
Definition 1.1 [Computer-Daten] Computer-Daten sind Objekte mit den drei folgenden
Eigenschaften
1. Bezeichnung
Die Bezeichnung trägt den semantischen Teil (Bedeutung) des Objektes (siehe auch Datentypen).
2. Wertemenge
Die Wertemenge legt alle möglichen Werte fest, die ein Objekt dieses Types annehmen
kann. Die Wertemenge bestimmt somit die Syntax (Form) eines Objektes.
3. Speicherplatz
Der Speicherplatz lokalisiert das Objekt im Speicher und identifiziert dieses eindeutig.
Beispiel 1.3 [Computer-Daten]
Datenobjekt: "Müller"
Bezeichnung: Familienname einer Person. Damit ist die Bedeutung des angegebenen Objekts festgelegt.
Wertemenge: Alle Folgen von maximal n alphabetischen Zeichen. Das erste Zeichen muss
ein grosser Buchstabe sein.
Speicherplatz: Eine Adresse im Speicher, wo die gegebene Zeichenfolge beginnt.
Bemerkung 1.1 [Semantik] In der Informatik wird die Semantik durch den Datentyp
eines Objektes gegeben. Die Wertemenge ist im Prinzip durch die Anzahl reservierter Bits im
Speicher gegeben.
1-3
1.2
Datentypen
Wie in der Mathematik ist es in der Informatik üblich, Objekte nach bestimmten wichtigen Eigenschaften zu ordnen. In der Mathematik werden genaue Unterscheidungen zwischen
einzelnen Werten, Mengen von Werten, Funktionen, Mengen von Funktionen u.s.w. gemacht.
In der Informatik werden verschiedene Objekte wie Zahlen, Funktionen, Personaldaten, Wetterdaten, Rechnungen u.s.w. bearbeitet. Bei einer solchen Vielfalt ist es vielleicht noch wichtiger als in der Mathematik, diese Objekte nach ihren Eigenschaften zu ordnen. Dazu führen
wir den Begriff des Datentyps ein.
Definition 1.2 [Datentyp] Der Datentyp bestimmt eine Menge von Werten, welche die
Objekte dieses Typs annehmen können. Diese Menge bezeichnet man als Wertemenge des Datentyps. Die Elemente des Wertebereichs bezeichnet man auch als Konstanten des Datentyps.
Der Datentyp bestimmt auch die Operatoren, welche auf dem Wertemenge definiert sind und
somit auf Objekte dieses Typs angewandt werden können.
Beispiel 1.4 [Datentyp] Wir können die Wertemenge eines Datentyps vollständig beschreiben, indem wir alle Elemente der Menge aufzählen. In der Sprache C++ wird ein solcher Typ
folgendermassen definiert:
enum Farben {rot, blau, grün, braun};
Als Operator ist nur der Vergleich zweier Werte auf Gleichheit definiert. Weiter können in
C++ weitere Operatoren wie ++, <, . . . definiert werden.
Bemerkung 1.2 [Syntax und Semantik] Die Wertemenge des Datentyps legt die Syntax
fest (Platzbedarf und Codierung). Die Operationen bestimmen den semantischen Teil des
Objektes.
1.3
Atomare Typen
Als atomare Typen bezeichnen wir Datentypen, die in einer Sprache schon definiert sind. Aus
diesen atomaren Typen können mit Hilfe von Typkonstruktoren (siehe 1.4) weitere Typen
abgeleitet werden. Die atomaren Typen sind also die Bausteine des ganzen Typsystems einer
Programmiersprache.
Welche atomaren Typen zur Verfügung stehen, hängt von der gewählten Programmiersprache
ab. Im folgenden stellen wir vier wichtige Typen vor, die in den meisten höheren Programmiersprachen existieren.
1.3.1
Ganze Zahlen (INTEGER)
Dieser Typ umfasst ein um die Zahl Null symmetrisches Intervall der ganzen Zahlen. Innerhalb
dieses Intervalls können die Zahlen genau dargestellt werden. Als Operationen stehen die
Addition, die Subtraktion, die Multiplikation und die ganzzahlige Division zur Verfügung.
Die Operationen sind bezüglich der Menge INTEGER nicht abgeschlossen. Das Resultat einer
Addition, einer Subtraktion oder einer Multiplikation kann grösser (bzw. kleiner) als die für
INTEGER gegebenen Intervallgrenzen sein. Liegt das Resultat einer Operation ausserhalb
des gegebenen Intervalls, so spricht man von Overflow.
1-4
1.3.2
Reelle Zahlen (REAL)
Dieser Datentyp bezeichnet eine Teilmenge der reellen Zahlen. Besonders einschneidende Auswirkungen hat hier die Endlichkeit von mit dem Computer darstellbaren Wertemengen. Während bei den ganzen Zahlen noch gefordert werden konnte, dass jede arithmetische Operation
bis auf Overflow-Situationen zu einem korrekten Resultat führt, gilt dies beim Rechnen mit
Realzahlen nicht mehr. Der Grund liegt darin, dass jedes noch so kleine Intervall der reellen
Zahlenachse unendlich viele Werte enthält. Der Datentyp REAL stellt nun eine endliche Menge
von Repräsentanten von Intervallen der Zahlenachse dar. Dadurch wird die reelle Zahlenachse
diskretisiert. Aus der Tatsache, dass jede Zahl nur durch einen Repräsentanten dargestellt
werden kann, folgt, dass bei Berechnungen nur angenähert richtige Resultate erwartet werden
können. Die Abschätzung der entstehenden Fehler ist das Anliegen der numerischen Mathematik. In der Abbildung 1-2 sind die REAL auf der reellen Zahlenachse Dargestellt.
0
−max
+max
Overflowbereich
Overflowbereich
Underflowbereich
Abbildung 1-2: Die REALs auf der reellen Zahlenachse
Bemerkung 1.3 [Under- und Overflow] Das Intervall der reellen Zahlen, das durch die
REAL-Zahl 0 repräsentiert wird, heisst Underflow-Bereich. Fällt das Resultat einer Operation
in diesen Bereich, spricht man von Underflow. Underflow ist für numerische Berechnungen
gleich fatal wie Overflow.
1.3.2.1
Endliche Arithmetik
Im Beispiel 1.2 haben wir gesehen, dass die reellen Zahlen als floating point-Zahlen dargestellt
werden.
Zahl = V orzeichen · (2(Exponent−127) · 1.M antisse)
Diese Darstellung der reellen Zahlen nennt man eine endliche Arithmetik. Nachfolgend werden
wir die reellen Zahlen mit R und die Menge der Zahlen einer endlichen Arithmetik mit M
(M steht für Maschinenzahlen bezeichnen.
Nachfolgend sind einige Eigenschaften einer endlichen Arithmetik aufgeführt.
1. Die Wertemenge M des Datentyps REAL ist eine Untermenge der reellen Zahlenmenge
R.
M⊂R
2. Jeder Zahl x ∈ R ist eindeutig ein x̃ ∈ M zugeordnet, welches als Repräsentant von x
bezeichnet wird.
1-5
3. Jedes x̃ ∈ M repräsentiert alle Zahlen in einem Intervall um x̃. Die Grösse des Intervalls ist von der Anzahl signifikanter Stellen (Mantisse) und der Grösse der Zahl selbst
abhängig. Aus der Darstellung von floating point-Zahlen ergibt sich, dass die Anzahl Repräsentanten von reellen Zahlen in M mit wachsendem x exponentiell abnimmt (siehe
Abbildung 1-2).
4. Die reellen Zahlen 0 und 1 können in M exakt dargestellt werden. Es gilt:
0 = 0̃ und 1 = 1̃
5. Das Intervall der reellen Zahlen, das durch die REAL-Zahl 0 repräsentiert wird heisst
Underflow-Bereich
6. Sind x, y ∈ M, dann ist das Resultat der Addition im Allgemeinen nicht mehr in M.
Dies gilt auch für die anderen arithmetischen Operatoren (−, ·, /). Damit das Resultat
wieder in M zu liegen kommt, muss nach jeder Operation das Resultat auf die gegebene Anzahl Stellen der Mantisse gerundet werden. Aus diesem Grund gelten in einer
endlichen Arithmetik nicht alle bekannten Gesetze der gewöhnlichen Arithmetik.
• Das Assoziativgesetz der Addition gilt in einer endlichen Arithmetik im allgemeinen
nicht:
(x + y) + z 6= x + (y + z)
Ein Zahlenbeispiel, welches das Gesetz verletzt, sei an einer vierstelligen (dezimalen) Arithmetik aufgezeigt. Intern wird mit 5 Stellen gerechnet und nach jeder
Operation das Resultat auf 4 Stellen gerundet.
x = 9.100 y = 3.0 · 10−4
z = 4.0 · 10−4
(x + y) + z = (9.100 + 0.0003 +0.0004 = 9.100
|
{z
gerundet=9.100
|
{z
}
}
gerundet=9.100
x + (y + z) = 9.100 + (0.0003 + 0.0004) = 9.101
|
|
{z
gerundet=0.0007
{z
}
}
gerundet=9.101
• Das Distributivgesetz gilt in einer endlichen Arithmetik im allgemeinen nicht:
x · (y + z) 6= (x · y) + (x · z)
Wir illustrieren dies wieder an einem Zahlenbeispiel.
x = 2.000 y = 3.000 z = 3.000 · 10−4
x · (y + z) = 2.000 · (3.000 + 0.0003) = 6.000
|
|
{z
gerundet=3.000
{z
gerundet=6.000
1-6
}
}
(x · y) + (x · z) = (2.000 · 3.000) + (2.000 · 0.0003) = 6.001
|
{z
}
gerundet=6.000
|
1.3.2.2
|
{z
}
gerundet=0.0006
{z
gerundet=6.001
}
Absoluter und relativer Fehler
Wir haben schon gesehen, dass nach jeder Operation in M das Resultat auf die Anzahl Stellen
der Mantisse gerundet werden muss. Es entsteht also ein Rundungsfehler. Unsere Resultate
sind immer nur Approximationen des exakten Resultats.
Definition 1.3 [Fehler] Sei x̃ eine Approximation der Zahl x so definieren wir den absoluten
Fehler als
|x − x̃|
und den relativen Fehler als
|x − x̃|
|x|
Beim Rechnen interessiert uns vor allem der relative Fehler.
1.3.2.3
Numerische Auslöschung
Die numerische Auslöschung ist ein spezieller Rundungsfehler, der auftritt, wenn zwei nahezu gleichgrosse Zahlen voneinander subtrahiert werden. Wir betrachten dazu die folgende
Operation.
1.2345 · 100 − 1.2344 · 100 = 1.0000 · 10−4
Die Nullen nach dem Dezimalpunkt im Resultat sind nur richtig, wenn die beiden Operanden
exakte Zahlen waren. Waren die Operanden schon durch einen Rundungsfehler verfälscht, so
ist das exakte Resultat der Operation 1.xxxx · 10−4 wobei die mit xxxx bezeichneten Stellen
nicht bekannt sind. Das heisst, der absolute Fehler liegt im Bereich 10−5 und der relative
Fehler ist im Bereich 10−5 /10−4 = 10−1 . Der relative Fehler der Ausgangsdaten liegt aber
im Bereich 10−5 /1.2 · 100 ≈ 10−5 . Mit anderen Worten der relative Fehler wird durch die
Subtraktion von zwei fast gleich grossen Zahlen massiv vergrössert.
1.3.2.4
Numerische Stabilität
Ein stabiler Algorithmus ist ein Algorithmus, der auch in endlicher Arthmetik “richtig” läuft.
Unter “richtig” meint man dabei, dass die Rundungsfehler den Ablauf nicht zu sehr stören.
Die folgende Definition von Stabilität stammt von Wilkinson:
Definition 1.4 [Stabilität] Bei stabilen Algorithmen ist das vom Computer berechnete (durch
Rundungsfehler verfälschte) Resultat, das exakte Resultat von leicht geänderten Anfangsdaten.
1-7
Beispiel 1.5 [Stabilität] Wenn man zur Auflösung von quadratischen Gleichungen der Form
x2 + px + q = 0
die übliche Formel
x1,2
−p
±
=
2
s 2
p
2
−q
verwendet und |p| ≫ |q| ist, wird die kleinere der beiden Lösungen ungenau berechnet, weil
sie numerischer Auslöschung unterworfen ist. Es gilt dann
s 2
p
2
−q ≈
p
2
und daher werden in der Formel zwei fast gleich grosse Zahlen voneinander subtrahiert. Wir
betrachten das folgende Zahlenbeispiel.
x2 + 108 x + 1 = 0
Mit einer 7-Stelligen Arithmetik erhalten wir nach obiger Formel das Resultat:
x1 = −108 und x2 = 0
Dies ist aber das Resultat der Gleichung
x2 + 108 x = 0
Der relative Fehler von x1 ist gering (Grössenordnung 10−9 ) das Resultat x2 ist hingegen
wegen numerischer Auslöschung Falsch. Dieser Algorithmus ist also im allgemeinen numerisch
nicht stabil.
Wir können eine bessere Lösung erhalten, wenn wir die folgende Formel zur Berechnung der
betragsmässig kleineren Lösung verwenden. Falls eine Lösung der Gleichung bekannt ist, kann
die zweite Lösung mit der Formel
x2 =
q
x1
berechnet werden. Mit dieser Formel erhalten wir für x2 nicht mehr 0 sondern
x2 =
1
1
= − 8 = −10−8
x1
10
Wird diese neue Formel angewendet, so ist die Berechnung in jedem Fall numerisch stabil.
1-8
1.3.2.5
Kondition
Stabile Algorithmen nützen nichts, wenn ein Problem schlecht konditioniert ist.
Definition 1.5 [Schlecht konditioniert] Ein Problem ist schlecht konditioniert (ill posed
problem), wenn die Lösung sich stark ändert, wenn man die Anfangsdaten wenig stört.
In den Naturwissenschaften sind die Ausgangsdaten von Berechnungen meistens Messdaten.
Messdaten sind aber immer ungenau. Ist das zu lösende Problem nun schlecht konditioniert,
so wird das Resultat sehr häufig falsch sein.
Beispiel 1.6 [Schlecht konditioniert] Das Polynom
P (x) = (x − 1)3 = x3 − 3x2 + 3x − 1
hat die dreifache Nullstelle x1 = x2 = x3 = 1. Das P benachbarte Polynom
Q(x) = x3 − 3.000001x2 + 3x − 0.999999
hat die Nullstellen
x1 = 1, x2 ≈ 1.001414 und x3 ≈ 0.998586
Die Koeffizienten von P wurden um 10−6 gestört, was die Nullstellen um 10−3 veränderte.
Die Störung wurde damit 1000 mal verstärkt. Das Polynom P ist schlecht konditioniert.
1.3.3
Logische Werte (BOOLEAN)
Ein Objekt dieses Typs kann nur die Werte TRUE (wahr) und FALSE (falsch) annehmen.
Als Operationen sind die Konjunktion (AND), die Disjunktion (OR) und die Negation (NOT)
zugelassen.
Man beachte, dass die Vergleichsoperatoren in vielen Sprachen ein Resultat vom Typ BOOLEAN liefern. Somit kann das Resultat eines Vergleichs einer Variablen des Typs BOOLEAN
zugewiesen werden. In der Sprache Java etwa ist die folgende Zuweisung völlig korrekt.
boolean Bool = (10 >= 8);
1.3.4
Text (CHAR)
)Dieser Typ bezeichnet eine Menge von Schriftzeichen (zum Beispiel die ASCII-Zeichen. Von
dieser Menge werden folgende Eigenschaften verlangt:
• Die Menge muss mindestens die grossen und kleinen lateinischen Buchstaben, die 10
arabischen Ziffern, ein Leerzeichen sowie alle Satzzeichen enthalten.
• Auf der Menge existiert eine totale Ordnung. Bezüglich dieser Ordnung müssen die Teilmengen der Buchstaben und der Ziffern je zusammenhängend und wie üblich geordnet
sein. Das heisst, es gilt:
A < B < C ··· < Z
a < b < c··· < z
0 < 1 < 2··· < 9
1-9
• Die beiden Funktionen ord und char müssen zur Verfügung stehen. Mit N bezeichnen
wir die Anzahl Elemente im gegebenen Zeichensatz.
ord : CHAR 7→ {0, 1, . . . , N − 1}
char : {0, 1, . . . , N − 1} 7→ CHAR
Für die beiden Funktionen ord und char gelten die folgenden Regeln:
∀x, y ∈ CHAR; ∀i ∈ {0, 1, . . . , N }
– x 6= y ⇔ ord(x) 6= ord(y)
– x < y ⇔ ord(x) < ord(y)
– ord(char(i)) = i
– char(ord(x)) = x
1.4
Abgeleitete Typen
Da ein Datentyp aus einer Wertemenge und dazugehörenden Operationen besteht, können aus
bestehenden Datentypen mit Hilfe von Mengenoperationen neue Datentypen generiert werden.
Die wichtigsten Mengenoperationen sind die Teilmengenbildung, das Kartesische Produkt und
die Bildung der Potenzmenge. Bei der Teilmengenbildung spricht man von Unterbereichstypen oder von Subtypen, beim Kartesischen Produkt und der Potenzmenge von strukturierten
Typen.
In einigen sprachen kann man auch Funktionsräume als Datentypen definieren. Die Konstanten eines solchen Typs sind dann Funktionen mit einer gegebenen Signatur.
1.4.1
Unterbereichstypen
In vielen Fällen nimmt ein Objekt Werte eines bestimmten Typs nur innerhalb eines gewissen
Intervalls an. Für solche Objekte kann ein Unterbereichstyp mit der Angabe der Intervallgrenzen angegeben werden. Dies bedingt natürlich, dass auf dem Obertyp eine totale Ordnung
definiert ist. In der Sprache Pascal etwa kann ein Unterbereichstyp folgendermassen definiert
werden:
TYPE T = min..max
Dabei sind min und max Konstanten vom Typ INTEGER oder CHAR.
Beispiel 1.7 [Unterbereichstypen] TYPE Grossbuchstabe = A..Z
TYPE Ziffer = 0..9
TYPE Kleinezahl = -10..10
Die Wertemenge eines Unterbereichstypsist nach Definition eine Teilmenge der Wertemenge
des Obertyps. Daher sind alle Operatoren, die auf dem Obertyp anwendbar sind auch auf dem
Unterbereichstyp anwendbar. Es gilt dabei zu beachten, dass die Resultate der Operationen
bezüglich des Unterbereichstyps im allgemeinen nicht abgeschlossen sind.
1-10
1.4.2
Subtypen
Wir wollen das Konzept des Unterbereichstyps verallgemeinern. Bis jetzt haben wir nur Intervalle eines total geordneten Obertyps zugelassen. Wir können aber auch beliebige Untermengen der Wertemenge eines Obertyps als Datentyp zulassen.
Beispiel 1.8 [Subtyp] Wir betrachten als Beispiel den Obertyp CHadr. Die Wertemenge
des Typs sind alle existierenden Adressen in der Schweiz. Der Operator P LZ sei auf CHadr
definiert und liefert als Resultat die Postleitzahl des Ortes. Wir können nun den neuen Subtyp
BEadr folgendermassen definieren:
BEadr = {x ∈ CHadr | P LZ(x) ≥ 3000 ∧ P LZ(x) ≤ 3999}
Es ist sofort ersichtlich, dass alle Objekte des Subtyps auch Objekte des Obertyps sind, aber
natürlich nicht umgekehrt.
Objekte eines so definierten Subtyps sind mit Objekten ihres Obertyps zuweisungskompatibel.
Die Operationen, die auf dem Obertyp definiert sind, können auf die Wertemenge des Untertyps eingeschränkt und somit auf diesen wieder angewandt werden. (Achtung: Das Resultat
einer Operation angewendet auf zwei Elemente des Subtyps kann aus dem eingeschränkten
Wertebreich herausführen).
1.4.3
Die Struktur Array
Der strukturierte Typ Array wird aus zwei gegebenen Datentypen, dem Grundtyp (GT ) und
dem Indextyp (ID) konstruiert. Der Grundtyp ist ein beliebiger atomarer oder abgeleiteter
Datentyp. Der Indextyp ist ein Unterbereichstyp (Intervall) des Typs INTEGER. Die Wertemenge eines Arrays des Grundtyps GT und des Indextyps ID kann nun folgendermassen
definiert werden. Dabei bezeichnen wir mit |ID| die Anzahl Elemente des Indextyps.
Arraygtid = GT |ID|
Die Wertemenge des definierten Datentyps besteht aus allen (geordneten) |ID|-Tupeln von
Elementen des Grundtyps. Bei der Definition des Datentyps Array muss sowohl der Grundtyp wie auch der Indextyp angegeben werden. In der Sprache C wird der Datentyp Array
folgendermassen definiert:
typedef GT MyArray[max];
max ist eine INTEGER Konstante und bestimmt die Anzahl Elemente des Arrays. Der Indextyp ist das Intervall 0..(max-1) der Integerzahlen.
Beispiel 1.9 [Array] Definition eines Arrays des Grundtyp REAL und des Indextyps 0..9.
typedef double Myarray[10]
Elemente der Wertemenge des definierten Datentyps MyArray sind 10-Tupel von REALZahlen. Das Tupel
(1.0, 2.37, 0.5, 22.1, 300.0, 55.2, 18.0, 31.33, 2.5, 1.0)
1-11
ist ein möglicher Wert für Objekte des Datentyps MyArray.
Auf Arrays ist die Operation Selektor definiert, die es erlaubt, ein einzelnes Element des
Arrays zu lesen oder zu schreiben. Das i-te Element des Arrays A wird mit A[i] bezeichnet
und repräsentiert ein Objekt des Grundtyps des Arrays, das gelesen oder geschrieben werden
kann.
x = A[7] + 10; /* Lesen der 7. Komponente des Arrays A */
A[3] = 27;
/* Schreiben der 3. Komponente
des Arrays A */
Bemerkung 1.4 [Gleichheit von Arrays] Zwei Arrays A und B vom gleichen Grundtyp
und vom gleichen Indextyp werden nur dann als gleich bezeichnet, wenn alle Komponenten
den gleichen Wert besitzen:
A = B ⇔ A[i] = B[i] ∀i ∈ ID
Wird der Wert einer Komponente eines Arrays A verändert, so gilt der ganze Array A als
verändert.
Was den Array besonders auszeichnet, ist die Verwendung von berechenbaren Grössen als
Index. Der Zugriff auf eine Komponente kann zur Laufzeit berechnet werden. Dies birgt natürlich auch die Gefahr, dass ein Index ausserhalb des Indexbereichs zu liegen kommt, was
einen Programmabsturz zur Folge haben kann. Als Beispiel für das Rechnen mit dem Index
wollen wir das binäre Suchen betrachten.
Algorithmus 1.1 [Binäres Suchen] Das binäre Suchen kann angewendet werden, um in
einem sortierten Array (A[0] ≤ A[1] ≤ · · · ≤ A[n]) ein Element schnell zu finden. Die Idee ist
einfach. Es wird getestet, ob der Wert des mittleren Elements gleich dem gesuchten Wert ist.
Sind die Werte gleich, kann die Suche abgebrochen werden. Ist der gesuchte Wert kleiner als
der Wert des mittleren Elements, wird im linken Teil des Arrays nach der gleichen Methode
weitergesucht, sonst im rechten Teil des Arrays.
static int binSearch(int[] a, int such) {
int left = 0, right = a.length - 1, mid = 0;
boolean found = false;
while (right >= left && found == false) {
//Testen des mittleren Elementes
mid = (left + right) / 2;
if (a[mid] == such)
found = true;
else if (a[mid] < such)
//Das Element muss rechts der Mitte liegen.
left = mid + 1;
else
// Das Element muss links der Mitte liegen.
right = mid - 1;
}
return (found) ? mid : -1;
}
1-12
Die maximale Anzahl Vergleiche, die nötig sind, um ein Element im Array zu finden, ist
log2 (max). Dies ist klar, da bei jedem Vergleich die Hälfte der restlichen Elemente ausgeschlossen werden.
1.4.4
Allgemeine Struktur
Die allgemeinste Methode, einen strukturierten Datentyp zu definieren, besteht darin, Elemente von beliebigen Typen zu einer Einheit zusammenzufassen. Ein solcher Typ ist formal
gesprochen das kartesische Produkt von beliebigen Datentypen.
Structur = DT1 × DT2 × DT3 × · · · × DTn
Die Datentypen DT1 . . . DTn können selbst wieder strukturierte Typen sein. In der Sprache
Pascal heissen solche Strukturen Record, in der Sprache C werden sie als struct bezeichnet. In
der Spache C kann ein solcher Typ folgendermassen definiert werden:
typedef struct
{
DT1 Name_1;
DT2 Name_2;
DT3 Name_3;
.
.
DTn Name_n;
} MyStruct;
Beispiel 1.10 [Datum] Definition eines Datentyps für das Datum. Der Typ könnte auch als
Array of INTEGER mit drei Elementen deklariert werden. Die Form der Struktur ist aber
sprechender. Der Ausdruck Tagesdatum.Tag für den Zugriff auf den Tag ist viel klarer als der
entsprechende Zugriff Tagesdatum[0] in einem Array.
typedef struct
{
int Tag;
int Monat;
int Jahr;
} Datum;
Das nächste Beispiel zeigt, dass auch strukturierte Datentypen (Arrays oder Strukturen), als
Datentyp der Komponenten einer Struktur zugelassen sind.
1-13
Beispiel 1.11 [Person] Wir wollen einen Datentyp Person definieren. Bei dieser Definition
benutzen wir den Datentyp Datum aus dem vorhergehenden Beispiel.
typedef struct
{
char
Name[30];
char
Vorname[20];
Datum
Geburtsdatum;
char
Beruf[30];
double Gehalt;
} Person;
Eine Konstante des Datentyps Person ist ein geordnetes Tupel von Werten der einzelnen
Komponenten der Struktur Person.
("Grubauer", "Peter", (1,7,1958), "Koch", 5600.50)
Beim Beispiel steht "Grubauer" als Abkürzung für das Tupel
(’G’,’r’,’u’,’b’,’a’,’u’,’e’,’r’, , ,. . . ).
Beim Datentyp struct wird nicht mehr über einen Index auf die einzelnen Komponenten
zugegriffen, sondern über festgelegte Namen. Will man auf das Gehalt vom Objekt Hans des
Typs Person zugreifen, so steht der Selektor ’.’ zur Verfügung.
Hans.Gehalt
Selektoren können natürlich auch kombiniert werden, wie die zwei nächsten Beispiele zeigen:
Hans.Geburtsdatum.Tag
Hans.Name[0]
1.5
Zugriff auf den Tag des Geburtsdatums.
Zugriff auf den ersten Buchstaben des Namens.
Weitere Datenstrukturen
Die bisher betrachteten Datenstrukturen sind Bestandteil der meisten Programmiersprachen.
Die folgenden Datenstrukturen werden im allgemeinen nicht angeboten und müssen zum Beispiel als Java-Klassen implementiert werden.
1.5.1
Die Struktur lineare Liste
Wie der Array besteht eine lineare Liste aus lauter Elementen vom gleichen Datentyp, dem
Grundtyp GT . Anders als beim Array ist die Anzahl Elemente der linearen Liste nicht konstant. Es existieren die Operationen Einfügen eines neuen Elements und Löschen eines Elements. Die möglichen Werte einer linearen Liste sind also alle i-Tupel von Elementen vom
Grundtyp mit i = 0, 1, 2, . . . Formal kann die Wertemenge der Struktur Liste folgendermassen
definiert werden:
ListeGT =
∞
[
GT i
i=0
Aus dieser Definition können wir einige Eigenschaften von linearen Listen ableiten.
1-14
• Da wir i = 0 zulassen, ist eine leere Struktur auch ein legaler Wert für Listen.
• Die Werte einer Liste sind als geordnete Tupel definiert. Daher können wir vom ersten
und letzten Element der Liste sprechen und allgemein vom n-ten Element. Wir können
auch vom Vorgänger und vom Nachfolger eines Elements sprechen.
Die wichtigsten Operationen auf Listen sind:
• Schreiben/Lesen des Wertes eines beliebigen Elements der Liste.
• Einfügen eines Elements an einer beliebigen Stelle in der Liste.
• Löschen eines beliebigen Elements der Liste.
• Bestimmen der Länge der Liste.
1.5.1.1
Repräsentation von Listen
In den meisten klassischen Programmiersprachen wird die Struktur lineare Liste nicht angeboten und muss implementiert werden. Dazu bieten sich zwei Möglichkeiten:
1. Sequentielle Repräsentation:
Alle Elemente der Liste werden in aufeinanderfolgenden Speicherplätzen abgelegt.
Vorteil: Der Zugriff auf ein Element der Liste ist sehr schnell, da dessen Adresse berechnet werden kann.
Nachteil Das Einfügen oder Löschen eines Elements der Liste ist langsam, da alle
hinteren Elemente geschoben werden müssen.
2. Verkettete Repräsentation:
Zu jedem Element der Liste wird zusätzlich die (physische) Adresse seines Nachfolgers
gespeichert.
Vorteil: Das Löschen und Einfügen eines Elements der Liste ist schnell, da nur der
Zeiger des Vorgängers verändert werden muss.
Nachteil: Der Zugriff auf ein beliebiges Element ist langsam, da alle Vorgängerelemente
der Liste gelesen werden müssen.
1.5.2
Spezielle Listen: Stack und Queue
Bei der Definition von Subtypen wird die Wertemenge eines Datentyps auf eine Untermenge eingeschränkt. Bei der Definition der beiden nächsten Typen wird nicht die Wertemenge
eingeschränkt, sondern die Operationen auf dieser Menge. Beide Typen gehen vom Obertyp
lineare Liste aus.
1-15
1.5.2.1
Der Stack
Der Stack ist eine lineare Liste, bei der die Operationen Einfügen, Löschen und Lesen eines Elements nur auf das erste Element der Liste erlaubt sind (siehe Abbildung 1-3). Beim
Stack haben sich die Namen push und pop für das Einfügen bzw. Löschen eines Elements
eingebürgert. Der Stack wird häufig als LiFo-Struktur bezeichnet (LiFo = Last-in-First-out).
Mit anderen Worten, das letzte Element, das eingefügt wird, wird als erstes wieder entfernt.
Dank dieser Eigenschaft eignet sich der Stack sehr gut für die Bearbeitung von verschachtelten
Strukturen (Prozeduraufrufe, arithmetische Ausdrücke usw.).
Sami
push(Sami)
Sami
pop()
Paul
Paul
Paul
Peter
Peter
Peter
Hans
Hans
Hans
Fritz
Fritz
Fritz
Abbildung 1-3: Die Struktur Stack
1.5.2.2
Die Queue
Die Queue ist eine lineare Liste, bei der die Operationen Lesen und Löschen eines Elementes
nur auf das erste Element der Liste erlaubt sind. Das Einfügen eines neuen Elementes darf
nur nach dem letzten Element der Liste erfolgen (Siehe Abbildung 1-4).
write(Sami)
Sami
read()
Paul
Paul
Sami
Peter
Peter
Paul
Hans
Hans
Peter
Fritz
Fritz
Hans
Fritz
Abbildung 1-4: Die Struktur Queue
Die Queue wird häufig als FiFo-Struktur bezeichnet (FiFo = First-in-First-out). Mit anderen
Worten, das Element, das zuerst eingetragen wird, wird auch als erstes wieder gelöscht. Eine
Anwendung für die Queue sind Druckerwarteschlangen.
1.5.3
Der Baum
Eine weitere wichtige Datenstruktur, die wir in diesem Abschnitt behandeln wollen, ist der
Baum. Im Gegensatz zu eindimensionalen Strukturen wie der Liste oder dem Array ist der
Baum dem Wesen nach eine zweidimensionale, verkettete Struktur.
1-16
Definition 1.6 [Baum] Ein Baum ist entweder
1. die leere Menge (leerer Baum) oder
2. eine endliche, nichtleere Menge von Elementen, wovon ein Element Wurzel (engl. root)
genannt wird und die restlichen Elemente in m ≥ 0 disjunkte Teilmengen eingeteilt sind,
wobei jede wieder ein Baum ist (Unterbäume).
Im allgemeinen werden Bäume als Graphen dargestellt. Dabei wird die Wurzel durch m Kanten
(engl. edges) mit den Wurzeln der m Unterbäume verbunden (siehe Abbildung 1-5).
A
Wurzel
Geschwister
Unterbaum
B
C
D
Hoehe
E
G
F
H
I
Innere Knoten
J
Blaetter
Abbildung 1-5: Allgemeine Baumstruktur als Graph
Nachfolgend noch einige wichtige Begriffe:
• Die Elemente eines Baumes heissen Knoten (engl. vertices oder nodes).
• Die Wurzel eines Baumes (oder Unterbaumes) wird oft als Vater (engl. father, parent),
ein direkt nachfolgender Knoten als Sohn (engl. son, child) bezeichnet. Oft werden auch
die Begriffe Vorgänger (engl. predecessor) und Nachfolger (engl. successor) verwendet.
• Knoten mit Nachfolger heissen innere Knoten (engl. internal nodes).
• Knoten ohne Nachfolger heissen Blätter (engl. leaves), äussere Knoten (engl. external
node) oder Endknoten (engl. terminal nodes).
• Die direkten Nachfolger eines Knotens heissen Brüder (engl. brothers) oder Geschwister
(engl. siblings).
• Das Niveau (engl. level) eines Knotens ist folgendermassen definiert:
level(root) := 0
level(x) := level(f ather(x)) + 1
1-17
• Die Tiefe (engl. depth) oder Höhe (engl. height) eines Baumes ist definiert als:
depth(b) := maxx∈b (level(x))
• Der Grad (engl. degree) eines Knotens ist gleich der Anzahl Nachfolger dieses Knotens.
Der Grad des Baumes ist definiert als:
degree(b) := maxx∈b (degree(x))
• Eine Menge von Bäumen heisst Wald (engl. forest).
• Ein geordneter Baum (engl. ordered tree) ist ein Baum, bei dem die Reihenfolge der
direkten Nachfolger eines Knotens angegeben ist.
1.5.3.1
Binäre Bäume
Ein wichtiger Speziallfall von Bäumen sind die sogenannten binären Bäume.
Definition 1.7 [Binäre Bäume]
1. Ein binärer Baum (engl. binary tree) ist ein geordneter Baum vom Grad ≤ 2.
2. Ein streng binärer Baum (engl. strictly binary tree) ist ein binärer Baum und alle inneren Knoten haben den Grad 2.
3. Ein vollständiger binärer Baum (engl. complete binary tree) ist ein streng binärer Baum
und alle Blätter haben das gleiche Niveau.
4. Ein fast vollständiger binärer Baum (engl. allmost complete binary tree) ist ein vollständiger binärer Baum wobei ganz rechts auf der untersten Stufe des Baumes Knoten
fehlen dürfen.
Die verschiedenen Begriffe aus der Definition 1.7 sind in der Abbildung 1-6 dargestellt.
1 Binaerer Baum
2 Streng binaerer Baum
3 Vollstaendiger binaerer Baum
4 Fast vollstaendiger binaerer Baum
Abbildung 1-6: Die verschiedenen binären Bäume
Bemerkung 1.5 [Binärbaum ist geordnet] Da wir den binären Baum als geordneten
Baum definiert haben, können wir bei jedem Knoten vom linken und vom rechten Unterbaum
des Knotens sprechen.
1-18
1.5.3.2
Darstellung von Bäumen
Ganz allgemein ist ein Baum durch die Struktur der Knoten definiert. Jeder Knoten enthält
beliebige Nutzdaten und eine Menge (bei geordneten Bäumen eine Liste) von Zeigern auf die
betreffenden Unterbäume. Wie bei doppelt verketteten Listen kann auch noch ein Zeiger auf
den Vorgänger geführt werden, damit kann der Baum auch von Unten nach Oben durchlaufen
werden. Nachfolgend ist eine mögliche Definition für den allgemeinen Baum in der Sprache
Java.
public class TreeNode<Element> {
Element info;
TreeNode<Element> father;
List<TreeNode<Element>> sons;
}
Beim Kreieren eines Knotens muss natürlich die Liste sons für die Nachfolger auch kreiert
werden.
Bemerkung 1.6 [Keine Kreise] Beachten Sie, dass nach Definition des Baumes (siehe
Definition 1.6) kein Knoten auf einen direkten oder indirekten Vorgänger zeigen darf. Diese
Eigenschaft muss durch die insert Operation des Datentyps “Baum” garantiert werden.
1.5.3.3
Darstellung von binären Bäumen
Bei binären Bäumen kennt man die maximale Anzahl Nachfolger eines Knotens zum Vornherein. Das heisst, ein Knoten enthält beliebige Nutzdaten, sowie einen linken und rechten
Zeiger auf die Unterbäume.
public class BinTreeNode<Element> {
Element info;
TreeNode<Element> father;
TreeNode<Element> left;
TreeNode<Element> rigth;
}
Die angegebene Struktur kann nicht nur für binäre Bäume verwendet werden, sondern auch
zur Darstellung von allgemeinen Bäumen. Dabei muss nur die Interpretation der beiden Zeiger
left und right geändert werden. Im Zeiger left wird der erste Nachfolger des entsprechenden
Knotens gespeichert und im Zeiger right das nächste Geschwister des Knotens. Die Situation
ist in der Abbildung 1-7 dargestellt.
1-19
A
A
B
E
C
F
B
D
G
H
E
I
C
F
D
G
H
I
J
J
Abbildung 1-7: Darstellung eines allgemeinen Baumes als binärer Baum
1.5.4
Rekursion und traversieren von Bäumen
Wir haben bis jetzt nicht beschrieben, wie Bäume traversiert werden (traversieren = besuchen
aller Knoten des Baumes). In diesem Abschnitt wollen wir nun dies nachholen. Zu diesem
Zweck führen wir zuerst die Rekursion ein.
Eine Prozedur (oder Funktion) heisst rekursiv, falls sie sich direkt oder indirekt selbst aufruft.
Viele Probleme lassen sich rekursiv einfacher und klarer lösen als ohne Rekursion. Rekursion
ist eine häufige Design-Methode für Algorithmen.
Achtung
Bei einer rekursiven Prozedur sind die folgenden Punkte besonders zu beachten.
• Die Rekursion darf nicht unendlich sein. Das heisst, es muss in der Prozedur ein Instruktionszweig existieren, der keinen Aufruf der Prozedur enthält. Diesen Teil der Prozedur
nennt man den Rekursionsgrund. Bei indirekter Rekursion (Prozedur A ruft Prozedur
B auf und B ruft wieder A auf) ist besondere Vorsicht geboten.
• Es muss sichergestellt sein, dass die Anzahl der hintereinander ausgeführten rekursiven
Aufrufen (Rekursionstiefe) “vernünftig” bleibt, da sonst zu viel Speicher verwendet wird.
Wenn beim Sortieren von n Elementen n rekursive Aufrufe der Prozedur nötig sind, ist
dies sicher nicht mehr vernünftig. Beim Sortieren wäre eine Anzahl log2 (n) vernünftig.
• Rekursion soll nur dann angewandt werden, wenn die Formulierung der Lösung dadurch
klarer und kürzer wird. Auch darf der Aufwand der rekursiven Lösung in der Ordnung
nicht grösser werden als der Aufwand der iterativen Lösung. Insbesondere kann die
Rekursion sehr leicht eliminiert werden, wenn die Prozedur nur einen rekursiven Aufruf
enthält und dieser Aufruf die letzte Instruktion der Prozedur ist.
Als erstes wollen wir ein schlechtes Beispiel für die Rekursion angeben.
1.5.4.1
Berechnen der Fakultät einer Zahl
Die Fakultät einer Zahl n! wird in der Mathematik rekursiv definiert. Daher wird in der
Informatik häufig dieses Beispiel für die Rekursion gewählt. n! ist für n ∈ N folgendermassen
definiert:
1-20
0! = 1
n! = n · (n − 1)! f ür n > 0
Diese Definition kann direkt in dieser Form implementiert werden. Wir erhalten die folgende
rekursive Prozedur:
Algorithmus 1.2 [Rekursive Berechnung der Fakultät]
static long int fac(long int n) {
if (n == 0)
return 1;
else
return (n * fac(n - 1));
}
Dies ist ein schlechtes Beispiel für die Rekursion. Die Funktion wird nur einmal rekursiv
aufgerufen und der Aufruf ist die letzte Instruktion der Routine. Wir können die Rekursion
sehr einfach durch eine Schleife ersetzen. Diesen Vorgang nennt man End-Recursion Removal.
Nachstehend die iterative Lösung zur Berechnung der Fakultät.
Algorithmus 1.3 [Iterative Berechnung der Fakultät]
static long int fac(long int n) {
long result = 1;
for (long i = 1; i <= n; i++)
result *= i;
return result;
}
1.5.4.2
Traversieren eines binären Baumes
Da die Baumstruktur rekursiv definiert ist, liegt es natürlich nahe, die Traversierung eines
Baumes rekursiv zu implementieren. Um diese Aufgabe zu lösen betrachten wir die folgenden
drei Ansätze:
Preorder Traversierung
1. Behandle die Wurzel des Baumes
2. Traversiere den linken Unterbaum in preorder
3. Traversiere den rechten Unterbaum in preorder
Postorder Traversierung
1. Traversiere den linken Unterbaum in postorder
2. Traversiere den rechten Unterbaum in postorder
3. Behandle die Wurzel des Baumes
1-21
Inorder Traversierung
1. Traversiere den linken Unterbaum in inorder
2. Behandle die Wurzel des Baumes
3. Traversiere den rechten Unterbaum in inorder
Algorithmus 1.4 [Inorder Traversierung eines binären Baumes]
Der folgende Algorithmus traversiert einen binären Baum in inorder.
void inorderTraverse(BinTreeNode bt) {
// Rekursionsgrund falls der angegebene Knoten null ist
if (bt != null) {
// Rekursiver Aufruf fuer den linken Unterbaum
InorderTraverse(bt.left);
// ausgeben der Wurzel
System.out.println(bt.info);
// Rekursiver Aufruf fuer den rechten Unterbaum
InorderTraverse(bt.right);
}
}
Bemerkung 1.7 [Sinnvolle Rekursion] Im Gegensatz zur Berechnung der Fakultät einer
Zahl ist der Einsatz der Rekursion bei der Traversierung von Bäumen sinnvoll. Für die iterative
Implementation ist ein Stack notwendig. Die iterative Implementation ist auch weniger gut
verständlich als die rekursive (siehe ??).
Nachfolgend noch ein Beispiel zur Baumtraversierung:
Beispiel 1.12 [Baumtraversierung]
−
7
/
c
+
a
d
e
b
Abbildung 1-8: Syntaxbaum für den Ausdruck ((a + b) · c − d/e) · 7
Zu jedem arithmetischen Ausdruck gehört ein sogenannter Syntaxbaum. Der Baum wird so
aufgebaut, dass in den inneren Knoten des Baumes immer ein Operator (+, −, ·, /) steht.
1-22
Im linken Unterbaum steht der 1. Operand im rechten Unterbaum der linke Operand des
Operators. In der Abbildung 1-8 ist der Syntaxbaum für den Ausdruck
((a + b) · c − d/e) · 7
angegeben.
Traversieren wir den Baum in Preorder, so erhalten wir die folgende Reihenfolge für den
Besuch der einzelnen Knoten:
· − · + a b c / d e 7 (Polnische Notation)
Traversieren wir den Baum in Postorder, so erhalten wir die folgende Reihenfolge für den
Besuch der einzelnen Knoten:
a b + c · d e / − 7 · (Umgekehrte polnische Notation)
Traversieren wir den Baum in Inorder, so erhalten wir die folgende Reihenfolge für den Besuch
der einzelnen Knoten:
a + b · c − d / e · 7 (Infix Notation ohne Klammern)
1.6
1.6.1
Der Abstrakte Datentyp
Definition
Der abstrakte Datentyp (abgekürzt ADT) ist eines der wichtigsten Konzepte in der modernen Informatik. Die Philosophie der objektorientierten Sprachen basiert auf diesem Konzept.
Der ADT dient dazu, Datentypen (Datenstrukturen) unabhängig von deren Implementation
zu definieren. Beispiele solcher Datentypen sind Listen, Stacks und Queues. Neben diesen
eher programmiertechnischen Strukturen können aber auch konkrete Objekte der Welt wie
Motoren, Schrauben, Maschinen, Personaldaten u.s.w. mit Hilfe von abstrakten Datentypen
modeliert werden. Die Idee des ADTs beruht auf zwei wichtigen Prinzipien: Das Geheimnisprinzip und das Prinzip der Wiederverwendbarkeit.
1.6.1.1
Das Geheimnisprinzip
Das Geheimnisprinzip kann folgendermassen formuliert werden:
Definition 1.8 [Geheimnisprinzip] Dem Benutzer eines Datentyps (Moduls) werden nur
die auf diesem Datentyp erlaubten Operationen bekanntgegeben. Die Implementation des Datentyps bleibt für den Benutzer verborgen (abstrakt).
Die Anwendung dieses Prinzips bringt folgende Vorteile:
• Der Anwender kann den Datentyp nur im Sinne der Definition verwenden. Er hat keine
Möglichkeit, Eigenschaften einer speziellen Implementation auszunutzen.
• Die Implementation eines Datentyps kann jederzeit verändert werden, ohne dass die
Benutzer des Datentyps davon betroffen sind.
• Die Verantwortung zwischen dem Anwender und dem Implementator des Datentyps
sind durch die Interface-Definitionen klar geregelt. Die Suche nach Fehlern wird dadurch
erheblich vereinfacht.
1-23
1.6.1.2
Wiederverwendbarkeit
Dieses Prinzip kann wie folgt formuliert werden:
Definition 1.9 [Wiederverwendbarkeit] Ein Datentyp (Modul) soll in verschiedenen Applikationen wiederverwendbar sein, wenn ähnliche Probleme gelöst werden müssen.
Die Idee hinter diesem Prinzip ist klar. Es geht darum, die Entwicklungszeit von Systemen
zu reduzieren. Das Ziel ist, Softwaresysteme wie Hardwaresysteme zu bauen. Das heisst, die
einzelnen Komponenten eines Systems werden eingekauft, eventuell parametrisiert und zum
Gesamtsystem verbunden.
1.6.2
Formale Spezifikation von ADTs
Die Spezifikation eines Datentyps muss vollständig, präzise und eindeutig sein. Weiter wollen
wir keine Beschreibung, die auf der konkreten Implemenation des Datentyps basiert, obwohl
diese die geforderten Kriterien erfüllt. Eine Beschreibung, die auf der Implementation basiert,
führt zu einer Überspezifikation des Datentyps.
Beispiel 1.13 [Der Stack als ADT] Wir können den Datentyp Stack als Array mit einem
zusätzlichen Zeiger auf das aktuelle oberste Element des Stacks implementieren. Nachstehend
ist die Implementation der Struktur und der Prozedur push in der Sprache C angegeben.
struct stack
{
int
stack_pointer;
DT
stack_element[MAX];
/* DT steht fuer irgend
einen Datentyp */
};
void push(stack st, DT x)
{
++st.stack_pointer;
st.stack_element[st.stack_pointer] = x;
}
Wird nun der Datentyp Stack mit Hilfe dieser internen Struktur beschrieben, so ist der Datentyp überspezifiziert. Der Benutzer weiss, dass der Stack aus einem Array besteht und kann
diese Tatsache ausnutzen, um auf beliebige Elemente des Arrays zuzugreifen. Ein solcher
Zugriff widerspricht aber der Idee des Stacks.
Der abstrakte Datentyp spezifiziert eine Datenstruktur nicht mit Hilfe der Implementation,
sondern nur als eine Liste von Dienstleistungen, die der Datentyp dem Anwender zur Verfügung stellt. Die Eigenschaften der Dienstleistungen werden formal beschrieben. Die Dienstleistungen nennt man auch häufig Operationen, Funktionen oder Methoden.
Beispiel 1.14 [Stack Spezifikation] Der abstrakte Datentyp Stack wird also als eine Menge
von Funktionen wie Eintragen eines Elements (push), Entfernen eines Elements (pop), Lesen
des obersten Elements (top) u.s.w. betrachtet. Eine solche Beschreibung berücksichtigt nicht,
was ein Stack ist, sondern nur was ein Stack dem Anwender zu bieten hat.
Für die formale Spezifikation des Datentyps übernehmen wir die Schreibweise von Bertrand
Meyer meyer.88. Nach dieser Schreibweise besteht die Spezifikation aus vier Teilen. Nachstehend ist die Spezifikation eines Integerstacks als Beispiel angegeben.
1-24
TYPES
StackOf Int
FUNCTIONS
new :
empty :
push :
pop :
top :
7→ StackOf Int
StackOf Int 7→ BOOLEAN
IN T EGER × StackOf Int 7→ StackOf Int
StackOf Int ֒→ StackOf Int
StackOf Int ֒→ IN T EGER
PRECONDITIONS
∀s ∈ StackOf Int :
pre pop(s) = ¬(empty(s))
pre top(s) = ¬(empty(s))
AXIOMS
∀i ∈ IN T EGER; ∀s ∈ StackOf Int :
1. empty(new) = T RU E
2. empty(push(i, s)) = F ALSE
3. top(push(i, s)) = i
4. pop(push(i, s)) = s
Wir wollen nun die vier Teile der obigen Spezifikation des abstrakten Datentyps näher erläutern.
1.6.2.1
TYPES
Der Abschnitt TYPES legt die Namen der Datentypen fest, die spezifiziert werden. In unserem
Beispiel ist es nur einer, StackOf Int.
1.6.2.2
FUNCTIONS
Der Abschnitt FUNCTIONS legt die Dienstleistungen fest, die auf den Objekten des Datentyps angeboten werden. Die Dienstleistungen werden als mathematische Konstanten und
Funktionen spezifiziert. In unserem Beispiel sind dies new, empty, push, pop und top.
Jede Zeile in diesem Abschnitt definiert die Signatur (d.h. Anzahl und Typ der Argumente
und des Resultats). Eine Funtion f mit der Signatur
f : A1 × A2 × · · · × Am 7→ B1 × B2 × · · · × Bn
1-25
erwartet m Argumente mit den Typen a1 ∈ A1 , . . . , am ∈ Am und liefert n Resultate mit
den Typen b1 ∈ B1 , . . . , bn ∈ Bn . Mindestens eine der Mengen Ai oder Bj muss gleich dem
spezifizierten Typ sein, in unserem Beispiel also StackOf Int. In der Praxis haben die meisten
Funktionen nur ein Resultat (das heisst, n = 1).
Falls der Definitionsbereich leer ist, handelt es sich um den Namen einer Konstanten. In
unserem Beispiel ist new die einzige Konstante.
Funktionen mit einem einfachen Pfeil (֒→) sind partielle Funktionen (normale Funktionen
werden mit 7→ angegeben). D.h., sie sind nicht auf dem ganzen Definitionsbereich definiert.
In unserem Beispiel sind die Funktionen pop und top solche partielle Funktionen (siehe PRECONDITIONS).
Wir können die Funktionen eines abstrakten Datentyps T in zwei Kategorien einteilen:
Accessoren sind Funktionen bei denen T nur im Definitionsbereich vorkommt. Solche Funktionen liefern Informationen über den aktuellen Wert eines Objekts des abstrakten Datentyps. In unserem Beispiel sind empty und top Accessor-Funktionen.
Transformer sind Funktionen bei denen T sowohl im Definitionsbereich wie auch in der
Wertemenge der Funktion vorkommt. Solche Funktionen bilden also einen Wert von T
auf einen anderen Wert von T ab. In unserem Beispiel sind pop und push TransformerFunktionen.
1.6.2.3
PRECONDITIONS
Eine Operation kann nicht immer auf alle Werte des Definitionsbereich angewendet werden.
Das berühmteste Beispiel ist die Division durch Null. Partielle Funktionen führen in der
Programmierung oft zu Fehlern. Es ist daher wichtig, die Vorbedingungen für die Anwendung
einer partiellen Funktion klar zu definieren. Dies ist das Ziel des Abschnitts Preconditions
(Vorbedingungen). In unserem Beispiel steht die Precondition:
pre pop(s) = ¬(empty(s))
Auf der linken Seite steht der Name der partiellen Funktion, auf der rechten Seite die Vorbedingungen, die erfüllt sein müssen, damit die Funktion angewendet werden kann. Das obige
Beispiel bedeutet, dass die Funktion pop auf einen leeren Stack nicht angewendet werden darf.
Jede partielle Funktion aus dem Abschnitt Functions muss im Abschnitt Preconditions aufgeführt werden. In unserem Beispiel sind die Vorbedingungen für die Funktionen pop und top
dieselben.
1.6.2.4
AXIOMS
Bis jetzt passt die Beschreibung in unserem Beispiel nicht nur auf die Datenstruktur Stack.
Jede Datenstruktur, die ähnliche Dienstleistungen wie der Stack anbietet, würde auch auf
diese Beschreibung passen. Zum Beispiel eine Queue, eine lineare Liste u.s.w. Um wirklich
die Spezifikation auf den Typ Stack einzuschränken, müssen wir die Semantik der Funktionen
beschreiben. Das ist die Rolle der Axiome. Jedes der Axiome (oder eine Gruppe von Axiomen)
beschreibt die Eigenschaften der vom Datentyp angebotenen Dienstleistungen. In unserem
Beispiel beschreiben die Axiome
• empty(new) = T RU E
1-26
• empty(push(i, s)) = F ALSE
die Eigenschaften der Funktion empty. Diese zwei Axiome sagen aus, dass die Konstante new
der leere Stack ist und dass der Wert der Funktion push nie der leere Stack sein kann. Die
Axiome
• top(push(i, s)) = i
• pop(push(i, s)) = s
beschreiben die typische Last-in-First-out (LiFo) Eigenschaft des Stacks.
Bemerkung 1.8 [Transformer Funktionen] In der Definition des ADTs sind in unserem
Beispiel nur reine transformer Funktionen definiert. Das heisst, die Wertemenge ist gleich dem
definierten ADT. Die Funktion pop könnte auch so definiert werden:
pop : StackOf Int 7→ IN T EGER × StackOf Int
Die Funktion pop ist dann eine Mischung aus transformer- und accessor- Funktion. Sie verändert den Stack und gibt gleichzeitig das oberste Element zurück. Dies ist der klassische Fall
einer Funktion mit Nebeneffekten. Solche Funktionen sind aber sehr häufig Fehler anfällig.
Bertrand Meyer verlangt, dass die folgenden Prinzipien eingehalten werden:
• Eine transformer-Funktion verändert den Zustand eines Objektes. Sie gibt keine weiteren
Werte zurück.
• Eine accessor-Funktion verändert den Zustand eines Objektes nie. Sie gibt nur Informationen über den Zustand des Objektes zurück. Wird eine accessor-Funktion mehrmals
hintereinander ausgeführt, so gibt sie immer denselben Wert zurück.
1.6.3
Spezifikation des ADTs in Java
Die formale Spezifikation eines ADT ist eine sehr schwierige Aufgabe, die viel Wissen und
Erfahrung erfordert. In vielen Fällen genügt aber eine weniger formale Beschreibung des ADT.
Diese Beschreibung kann zum Beispiel in Form eines Java-Interfaces geschehen. Auch dort
können wir mit Preconditions und Postconditions vorgehen. Es ist aber auch erlaubt, Teile
der Spezifikationen als Kommentare in natürlicher Sprache zu schreiben. Dabei ist darauf zu
achten, dass die Aussagen möglichst eindeutig sind.
Beispiel 1.15 [Stack Spezifikation in java] Als Beispiel wollen wir auch wieder den Stack
von Integers ansehen. Eine Java Interfacedefinition könnte folgendermassen aussehen:
/**
* Dieses Interface definiert die Schnittstelle zu einem Stack
* von Integers. Die Implementation steht noch offen.
*/
public interface StackOfInt {
/** Preconditions: none
* Postconditions: none
1-27
* @return true falls keine Elemente im Stack sind.
*/
public boolean empty();
/** Preconditions: none
* Postconditions: none
* @return true falls kein Element eingefuegt werden kann.
*/
public boolean full();
/** Preconditions: old.full() == false
* Postconditions: -- Ein Element (item) mehr auf dem Stack
*
new.empty() == false
*
new.top() == item
* @param item: neues Element zuoberst auf dem Stack
*/
public void push(int item);
/** Preconditions: old.empty() != true
* Postconditions: -- Ein Element weniger auf dem Stack
*
new.push(old.top()) == old
* @return Oberstes Element des Stacks
*/
public int pop();
/** Preconditions: old.empty() != true
* Postconditions: none
* @return old.top() (Oberstes Element des Stacks)
*/
public int top();
}
Hier noch zwei Kommentare zu dieser Definition:
1. Die Variable old bezeichnet das Objekt vor dem Aufruf der Funktion und new bezeichnet
das Objekt nach dem Aufruf der Funktion.
2. Die Beschreibung der Funktionen ist nicht streng formal.
Bemerkung 1.9 [Stack Implementation] Das Verhalten des Stacks ist davon abhängig,
ob dieser mit einem Array oder als gelinkte Liste implementiert ist. Vor allem muss der
Benutzer des Stacks wissen, wieviele Elemente eingefügt werden können. Diese Information
gehört nicht im Interface sondern in der Schnittstellenbeschreibung der Klasse, welche das
Interface implementiert.
Nachstehend ist die Klasse StaticStackOfInt, welche den Stack mit Hilfe eines Arrays implementiert. In diesem Fall ist das Resultat der Methode full von der Grösse des Stacks
(size) abhängig.
/**
1-28
* Diese Klasse implementiert einen Stack von Integers. Die
* Implementation ist statisch. D.h. beim kreieren des Stacks
* muss der Anwender angeben, wieviele Elemente maximal
* eingefuegt werden koennen.
*/
public class StaticStackOfInt implements StackOfInt {
private int size;
private int sp;
private int stack[];
/** Preconditions: none
* Postconditions: new.empty() == true
*
-- Im Stack koennen maximal _size Elemente
*
eingefuegt werden.
* @param _size Die maximale Anzahl Elemente im Stack
*/
public StaticStackOfInt(int size){
stack = new int[size];
this.size = size;
sp = -1;
}
/** Preconditions: none
* Postconditions: none
* @return true falls size Elemente eingefuegt wurden.
*/
public boolean full() {
return (sp + 1 < size);
}
usw.
}
Die Klasse DynamicStackOfInt implementiert den Stack mit Hilfe der Java-Klasse Vector.
In diesem Fall ist das Resultat der Methode full immer wahr.
/**
*
*
*
*
*/
Diese Klasse implementiert einen Stack
Implementation ist dynamisch. D.h. Die
Elemente die eingefuegt werden koennen
die groesse der Resourcen des Rechners
von Integers. Die
maximale Anzahl
ist nur durch
beschraenkt.
import java.util.vector;
public class DynamicStackOfInt implements StackOfInt {
private int sp;
private Vector stack;
/** Preconditions: none
* Postconditions: new.empty() == true
1-29
*/
public DynamicStackOfInt(){
stack = new Vector();
sp = -1;
}
/** Preconditions: none
* Postconditions: none
* @return immer false (Stack ist nie voll)
*/
public boolean full() {
return false;
}
usw.
}
1.6.4
Generische Parameter
Der bis jetzt eingeführte Begriff des abstrakten Datentyps genügt dem Geheimnisprinzip. Das
Prinzip der Wiederverwendbarkeit ist aber nur sehr ungenügend realisiert. Wir haben einen
Stack definiert, der INTEGERs aufnehmen kann. Brauchen wir nun einen Stack, der REALs
oder CHARs aufnehmen kann, so müssen wir dafür neue ADTs definieren und anschliessend
implementieren.
Mit Hilfe von generischen Parametern kann der Stack noch abstrakter definiert werden, indem
der Datentyp der Stackelemente erst beim Kreieren eines Stacks angegeben werden muss. In
der nachstehenden Spezifikation ist der ADT Stack mit einem generischen Parameter definiert.
Der generische Parameter Element ersetzt den Typ INTEGER und steht für einen beliebigen
Datentyp.
TYPES
Stack[Element]
FUNCTIONS
new :
empty :
push :
pop :
top :
7→ Stack[Element]
Stack[Element] 7→ BOOLEAN
Element × Stack[Element] 7→ Stack[Element]
Stack[Element] ֒→ Stack[Element]
Stack[Element] ֒→ Element
PRECONDITIONS
∀s ∈ Stack[Element] :
pre pop(s) = ¬(empty(s))
pre top(s) = ¬(empty(s))
AXIOMS
∀x ∈ Element; ∀s ∈ Stack[Element] :
1-30
1. empty(new) = T RU E
2. empty(push(x, s)) = F ALSE
3. top(push(x, s)) = x
4. pop(push(x, s)) = s
1.6.5
Implementation in java
Für diese neue Spezifikation sieht nun das Interface für den Stack folgendermassen aus:
public interface Stack<Element> {
/**
* Dieses Interface definiert die Schnittstelle zu
* einem Stack von beliebigen Elementen des Typs
* Element Die Implementation steht noch offen.
*/
/** Preconditions: none
* Postconditions: none
* @return true falls keine Elemente im Stack sind.
*/
public boolean empty();
/** Preconditions: none
* Postconditions: none
* @return true falls kein Element eingefuegt werden kann.
*/
public boolean full();
/** Preconditions: old.full() == false
* Postconditions: -- Ein Element (item) mehr auf dem Stack
*
new.empty() == false
*
new.top() == item
* @param item: neues Element zuoberst auf dem Stack
*/
public void push(Element item);
/** Preconditions: old.empty() != true
* Postconditions: -- Ein Element weniger auf dem Stack
*
new.push(old.top()) == old
* @return Oberstes Element des Stacks
*/
public Element pop();
/** Preconditions: old.empty() != true
* Postconditions: none
* @return old.top() (Oberstes Element des Stacks)
*/
public Element top();
1-31
}
Eine Implementation könnte in etwa so aussehen:
import java.util.Vector;
public class MyStack<Element> implements Stack<Element> {
private Vector<Element> st;
public MyStack() {
st = new Vector<Element>();
}
public boolean empty() {
return st.size() == 0;
}
public boolean full() {
// Immer false
return false;
}
public Element pop() {
return st.remove(0);
}
public void push(Element item) {
st.add(item);
}
public Element top() {
return st.get(0);
}
}
Und nachstehend noch wie diese verwendet werden kann.
...
MyStack<Integer> st = new MyStack<Integer>();
st.push(2);
...
1-32
Kapitel 2
Algorithmen
2.1
Einführung
In dieser Einführung wollen wir den Begriff des Algoritmus nur intuitiv beschreiben. Die exakte
Definition eines Algorithmus folgt im Kapitel über die Berechenbarkeit (2.2).
Ein Algorithmus beschreibt die Methode, mit der eine Aufgabe gelöst wird. Er besteht aus
einer Folge von Schritten (Anweisungen), deren korrekte Bearbeitung die gestellte Aufgabe
löst. Die Bebarbeitung der Schritte bezeichnet man als Prozess.
Der Algorithmusgedanke ist keine Besonderheit der Informatik. In fast allen Naturwissenschaften aber auch im Alltag werden Arbeitsvorgänge mit Hilfe von Algorithmen beschrieben.
Der Prozess kann von einer Person, einem Computer, einem mechanischen Gerät usw. ausgeführt werden. Die ausführende Instanz heisst Prozessor. In der Tabelle 2-1 sind Beispiele für
diverse Algorithmen angegeben.
Prozess
Pullover stricken
Algorithmus
Strickmuster
Modellflugzeug bauen
Kuchen backen
GGT berechnen
Montageanleitung
Kochrezept
Euklid
Typische Schritte
Stricke Rechtsmasche
Stricke Linksmasche
Leime Teil A an Flügel B.
Nimm 3 Eier, schaumig schlagen.
Abbildung 2-1: Diverse Algorithmen
Für deterministische Algorithmen werden die drei folgenden Eigenschaften gefordert:
Vollständigkeit
Der Algorithmus besteht aus einer endlichen Anzahl Schritte, deren Beschreibung nur
endlich viele Zeichen benötigt.
Eindeutigkeit
Die Wirkung jedes einzelnen Schrittes ist eindeutig festgelegt. Nach der Ausführung eines
Schrittes steht eindeutig fest, welcher Schritt als nächstes auszuführen ist.
2-1
Effektivität
Die Effektivität verlangt, dass die Ausführung eines einzelnen Schrittes nur endlich
viel Zeit in Anspruch nimmt. Damit ist nur sichergestellt, dass nach einem Schritt der
nachfolgende Schritt auch begonnen werden kann, aber nicht, dass der Algorithmus als
Ganzes in endlicher Zeit terminiert.
Bemerkung 2.1 [Parallele Algorithmen] Die Forderung nach Eindeutigkeit wird etwa in
parallelen Algorithmen zum Teil fallengelassen. Nach dem Abschluss eines einzelnen Schrittes
ist der nächste Schritt nicht eindeutig bestimmt, sondern es existiert eine endliche Menge von
möglichen nächsten Schritten. Die Auswahl des nächsten Schrittes aus der gegebenen Menge
ist nichtdeterministisch.
2.2
Berechenbarkeit
Die Idee, einen Algorithmus oder ein Rezept zur Lösung irgendeiner Aufgabe zu finden, existiert schon seit tausenden von Jahren. Die grosse Frage ist, kann jedes formal definierte Problem mit Hilfe eines Algorithmus gelöst werden?
Der Mathematiker David Hilbert (1862-1943) etwa suchte nach einem mathematischen System, in dem alle Probleme präzise als Aussagen formulierbar wären. Gleichzeitig suchte er
nach einem Algorithmus, der zu einer gegebenen Aussage in seinem System entscheiden könnte, ob die Aussage wahr oder falsch ist. Dieses Problem ist unter dem Namen Hilbert’sches
Entscheidungsproblem bekannt.
Kurt Gödel zeigte 1931, dass ein solcher Algorithmus nicht existieren kann. Das heute berühmte Unvollständigkeitstheorem besagt unter anderem, dass es keinen Algorihmus gibt, der als
Eingabe irgendeine Aussage über die natürlichen Zahlen erhält und feststellt, ob die Aussage
wahr oder falsch ist. Mit anderen Worten, das Entscheidungsproblem ist im allgemeinen nicht
berechenbar.
2.2.1
Definition Algorithmus
Um präzise Aussagen über Algorithmen machen zu können, muss der Begriff natürlich auch
präzise definiert werden. Eine Möglichkeit den Begriff zu definieren ist die Turingmaschine,
die im Folgenden erklärt wird.
2.2.1.1
Die Turingmaschine
Die Turingmaschine ist ein vom britischen Mathematiker Alan Turing 1936 entwickeltes mathematisches Modell, um eine Klasse von berechenbaren Funktionen zu bilden.
Informelle Beschreibung
Die Turingmaschine besteht aus:
• Einem unendlich langen Speicherband, dass in unendlich vielen Speicherzellen unterteilt
ist. In jeder dieser Zelle kann genau ein Zeichen eines gegebenen Alphabets gespeichert
sein.
• Einem Lese- und Schreibkopf, der sich auf dem Band zellenweise bewegen kann und die
Zellen lesen oder schreiben kann.
2-2
• Einem Programm, das die Aktionen des Lese/Schreibkopf mit Hilfe von Maschinenzuständen steuert.
Eine Turingmaschine modifiziert die Eingabe auf dem Band nach dem gegebenen Programm.
Ist die Berechnung beendet, so befindet sich das Ergebnis auf dem Band. Es wird somit
jedem Eingabewert ein Ausgabewert zugeordnet. Eine Turingmaschine muss aber nicht für
alle Eingaben stoppen. In diesem Fall ist die Funktion für die Eingabe undefiniert.
Die Turingmaschine ist in der Abbildung 2-2 dargestellt.
Band mit Zellen
Lese−/Schreibkopf
Programm
Abbildung 2-2: Die Turingmaschine
Bei jedem Schritt kann die Turingmaschine das Zeichen beim Lese-/Schreibkopf lesen dann
ein neues Zeichen schreiben und schliesslich den Kopf nach rechts oder nach links bewegen.
Welche Aktionen durchgeführt werden ist vom aktuellen Zustand der Maschine und vom
aktuell gelesenen Zeichen abhängig.
Formale Beschreibung
Formal kann eien Turingmaschine als 7-Tupel
M = {Q, Σ, Γ, δ, q0 , 2, F }
definiert werden. Dabei sind:
• Q die Zustandsmenge
• Σ ist das Eingabealphabet
• Γ ⊃ Σ ist das Bandalphabet
• δ : Q × Γ 7−→ Q × Γ × {L, 0, R} ist die Überführungsfunktion
• q0 ist der Anfangszustand
• 2 ∈ (Γ \ Σ) ist das Leerzeichen
• F ⊆ Q ist die Menge der End- bzw. akzeptirenden Zustände.
Die Turingmaschine führt eine Berechnung aus, indem sie schrittweise eine Eingabe in eine
Ausgabe umwandelt. Ein-, Ausgabe und Zwischenergebnisse werden auf dem unendlich langen
Band gespeichert.
Zu Beginn steht ein Wort als Eingabe auf dem Band (pro Bandfeld ein Zeichen des Eingabewortes), der Rest des Bandes ist mit dem leeren Feld formatiert. Der Schreib-/Lesekopf
steht auf dem ersten Zeichen auf der linken Seite der Eingabe und die TM befindet sich im
(Start-)Zustand q0 .
2-3
Die Überführungsfunktion hat als Argumente den aktuellen Zustand und das Zeichen, das sich
im aktuellen Schritt unter dem Schreib-/Lesekopf befindet. Als Ergebnis liefert sie dann genau
einen Nachfolgezustand, ein Zeichen (mit diesem Zeichen wird dann der Inhalt des Feldes, auf
das der Schreib-/Lesekopf weist, überschrieben) und entweder das Symbol L (in diesem Fall
bewegt sich der Schreib-/Lesekopf um ein Feld nach links), ein R (in diesem Fall bewegt er
sich ein Feld nach rechts) oder eine 0 (dann verharrt er auf dem selben Feld). Damit hat die
Turingmaschine einen Schritt ihres Arbeitszyklus’ durchlaufen und steht für einen weiteren
bereit.
Erreicht die Turingmaschine einen Endzustand, also einen Zustand der Menge F, ist die Berechnung beendet. Die Ausgabe ist dann der Inhalt des Bandes (wobei die Felder, die mit
Symbolen aus Γ \ Σ gefüllt sind, insbesondere dem Symbol 2, nicht berücksichtigt werden).
Beispiel 2.1 [Palindrome] In diesem Beispiel soll Σ = {0, 1} und Γ = {0, 1, 2} sein. Wir
wollen nun ein Programm entwickeln, das von einem gegebenen Eingabestring entscheidet,
ob es ein Palindrom ist oder nicht. Die Ausgabe ist entweder eine 1 für “ja” oder eine 0 für
“nein”. wir verwenden die folgende Maschine:
T M = ({0, 1, 2, 3, 4, 5, 6, 7}, {0, 1}, {0, 1, 2}, δ, 0, 2, {7})
Die Überführungsfunktion δ ist in der folgenden Tabelle angegeben.
Zustand
0
1
2
3
4
5
6
7
1,
1,
5,
3,
6,
5,
6,
1
2,
1,
2,
1,
2,
1,
2,
R
R
L
R
L
L
L
3,
1,
6,
3,
5,
5,
6,
0
2,
0,
2,
0,
2,
0,
2,
halt
R
R
L
R
L
L
L
7,
2,
7,
4,
7,
0,
7,
2
1,
2,
1,
2,
1,
2,
0,
0
L
0
L
0
R
0
Wir sind nun in der Lage eine erste Definition des Begriffs Algorithmus zu geben.
Definition 2.1 [Algorithmus (1.Version)] Ein Algorithmus zur Lösung eines Problems
ist eine Folge von Anweisungen für die Turingmaschine, so dass die Turingmaschine für jede
Eingabe in endlicher Zeit entweder die Lösung liefert oder meldet, dass keine Lösung existiert.
2.2.2
Weitere Definitionen von Algorithmus
In der ersten Hälfte des 20. Jahrhunderts wurden (ausser der Turingmaschine) eine ganze
Reihe von Ansätzen entwickelt, um zu einer genauen Definition des Begriffs Algorithmus zu
kommen.
2.2.2.1
Rekursive Funktionen
Bei diesem Ansatz definiert man einen kleinen Satz von Grundfunktionen (Konstante Funktion, Projektion und Nachfolgerfunktion) und Regeln (Komposition, primitive Rekursion und
µ-Operator). Die Regeln geben an, wie aus einer Funktion weitere Funktionen gebildet werden
können. Ein Algorithmus ist dann eine Folge von Regeln, die aus den Grundfunktionen eine
kompliziertere Funktion konstruieren.
2-4
2.2.2.2
Lambda-Kalkül
Der Lambda-Kalkül (λ-Kalkül) wurde von Alonzo Church und Stephen Kleene in den 1930er
Jahren eingeführt.
Im Lambda-Kalkül steht jeder Ausdruck für eine Funktion mit nur einem Argument. Sowohl
die Argumente als auch die Resultate solcher Funktionen sind wiederum Funktionen. Eine
Funktion kann anonym durch eine so genannte Lambda-Abstraktion definiert werden, die
die Zuordnung des Arguments zum Resultat beschreibt. Zum Beispiel wird die erhöhe-um-2
Funktion f(x) = x + 2 im Lambda-Kalkül durch die Lambda-Abstraktion λx.x+2 beschrieben;
f(3) (die so genannte Funktionsanwendung) kann daher als (λx.x + 2)3 geschrieben werden.
Ein Algorithmus ist dann ein λ-Ausdruck, der aus Abstraktionen und Funktionsanwendungen
gebildet wird.
2.2.2.3
Computersprachen
Zur Definition eines Algorithmus können wir auch Computersprachen heranziehen dies führt
zu der folgenden Definition von Algorithmus:
Definition 2.2 [Algorithmus (2.Version)] Ein Algorithmus zur Lösung eines Problems
ist ein Computerprogramm, das aus Sequenzen, Selektionen und Iterationen gebildet ist, die
Grundoperationen für die ganzen Zahlen N zulässt und für jede Eingabe in endlicher Zeit
entweder die Lösung liefert oder meldet, dass keine Lösung existiert.
2.2.3
Die Church-Turing-These
Es ist mathematisch bewiesen, dass alle obigen Definitionen gleichwertig sind. Das heisst,
wenn ein Problem mit Hilfe von µ-rekursiven Funktionen lösbar ist, so auch mit einer TuringMaschine, dem λ-Kalkül oder einem Computer und umgekehrt.
Bis heute ist keine Definition bekannt, die nicht äquivalent zu den obigen wäre. Dies hat zur
Formulierung der Church-Turing-These geführt.
1. Alle vernünftigen Definitionen von “Algorithmus”, soweit sie bekannt sind, sind gleichwertig und gleichbedeutend.
2. Jede vernünftige Definition von “Algorithmus” (auch zukünftige), ist gleichwertig und
gleichbedeutend zu den heute bekannten Definitionen.
Dies ist natürlich nur eine These und nicht beweisbar. Die These sagt aus, dass wir glauben,
die beste Definition für den Begriff “Algorithmus” gefunden zu haben.
2.2.4
Das Halteproblem
In diesem Abschnitt wird ein bekanntes, nicht berechenbares Problem vorgestellt. Das heisst,
ein Problem für das kein allgemeiner Algorithmus gefunden werden kann.
Gegeben:
Gesucht:
Ein beliebiges Comuterprogramm, das in einer beliebigen Programmiersprache geschrieben ist. Die Programmiersprache sowie die Hardware auf
der das Programm läuft, spielen für die folgenden Betrachtungen keine
Rolle.
Gesucht ist ein Algorithmus, der zu einem beliebigen Programm und zu
beliebigen Input-Daten entscheidet, ob das Programm terminiert oder
nicht.
2-5
Dieses Problem ist unter dem Namen Halteproblem bekannt.
Das Halteproblem ist nicht berechenbar.
Wir wollen nun zeigen, dass diese Aussage stimmt. Wir gehen dabei indirekt vor. Das heisst,
wir nehmen an, dass ein solches Programm existiert und werden zeigen, dass diese Annahme
zu einem Widerspruch führt.
Annahme:
Die Funktion stopp_tester, existiert und entscheidet, ob ein beliebiges
Programm P bei beliebigen Input-Daten I terminiert. Die Funktionsweise von stopp_tester ist in der Abbildung 2-3 schematisch dargestellt.
Programm P
Input I
terminiert P bei Input I?
Ja
Nein
OK
NOK
Abbildung 2-3: Ablauf der Funktion stopp_tester
Wir benutzen nun die Funktion stopp_tester um eine eingeschränkte Funtion stopp_tester_neu zu definieren. Die neue Funktion testet, ob ein beliebiges Programm P terminiert,
wenn die Input-Daten aus P selbst bestehen. Die neue Funktion sieht also folgendermassen
aus:
stopp_tester_neu(P)
{
return stopp_tester(P,P);
}
Die Funktionsweise dieser neuen Funktion ist in der Abbildung 2-4 schematisch dargestellt.
Programm P
terminiert P bei Input P?
Ja
Nein
OK
NOK
Abbildung 2-4: Ablauf der Funktion stopp_tester_neu
2-6
Aus stopp_tester_neu können wir nun die folgende Funktion unsinn konstruieren.
unsinn(P)
{
if (stopp_tester_neu(P) == NOK)
exit;
else
while (1 == 1);
}
Die Funktion unsinn ist selbst wieder ein Programm. Also können wir unsinn als InputDaten für die Funktion unsinn verwenden. In der Abbildung 2-5 ist schematisch dargestellt,
was dann passiert.
Hier ist aber ein Widerspruch. Es wird behauptet, dass die Funktion unsinn bei Input unsinn terminiert, wenn sie nicht terminiert und nicht terminiert, wenn sie terminiert. Dieser
Widerspruch kann nur aufgelöst werden, wenn man einräumt, dass die Funktion unsinn nicht
existieren kann. Die einzige Voraussetzung zur Herleitung von unsinn ist aber die Existenz
der Funktion stopp_tester. Also kann auch die Funktion stopp_tester nicht existieren.
unsinn(unsinn)
terminiert unsinn bei Input unsinn?
Ja
Nein
Endlos Loop
stopp
Abbildung 2-5: Ablauf von unsinn(unsinn)
2.3
Komplexität von Algorithmen
Im Abschnitt 2.2 haben wir nur betrachtet, ob ein Problem algorithmisch lösbar ist oder nicht.
Für lösbare Probleme ist es interessant zu wissen, wieviele Betriebsmittel eines Computers
für ihre Lösung erforderlich sind. Nur solche Algorithmen, die eine vertretbare Menge an
Betriebsmitteln nutzen, sind von praktischem Interesse. Die Komplexitätstheorie stellt die
Frage nach dem Gebrauch von Betriebsmitteln und versucht diese zu beantworten. Meistens
werden die Betriebsmittel Zeit und Speicherbedarf eines Algorithmus untersucht.
Zeit
Die vom Start bis zum Ende der Ausführung eines Algorithmus vergangene Zeit.
Speicherbedarf
Die vom Algorihmus benötigte Speichermenge (Memory). Dabei wird der Speicherplatz
gemessen, der benötigt wird, um Zwischenergebnisse zu speichern.
2-7
Wir bezeichnen Algorithmen, die eine vertretbare Menge an Betriebsmitteln verwenden als
durchführbar (engl. feasible). In den folgenden Betrachtungen wird untersucht, wann ein Algorithmus durchführbar ist und wann nicht. Die Abbildung 2-6 zeigt den Zusammenhang
zwischen der Menge aller endlichen Probleme, der Menge der berechenbaren Probleme und
der Menge der durchführbaren Probleme.
Alle Probleme
Berechenbare Probleme
Durchfuehrbare Probleme
Abbildung 2-6: Durchführbare Algorithmen
2.3.1
Zeitaufwand von Algorithmen
Algorithmen nehmen gewöhnlich Eingabedaten entgegen und führen mit diesen eine Verarbeitung durch. Der Bedarf an Zeit wird daher von der Menge der Eingabedaten abhängig sein.
Als Beispiel betrachten wir den Multiplikationsalgorithmus in der Abildung 2-7.
1
9
1
8
4
6
6
3
5
0
7
3
×
2
3
4
1
1
6
6
7
1
3
8
7
8
0
7
1
5
4
3
2
5
9
2
Abbildung 2-7: Standardalgorithmus zur Multiplikation
Falls der Algorithmus mit zwei n-stelligen Zahlen durchgeführt wird, entstehen n Zahlen mit n
(oder n + 1) Ziffern. Jede dieser Zahlen kann in n Schritte berechnet werden. Die Addition der
n Zahlen erfordert n × n Schritte. Die Ausführungszeit des Algorithmus ist daher proportional
zu n2 . Es existieren aber auch Algorithmen (z.B. Karatsuba oder Schönhage-Strassen), die
zwei Zahlen mit weniger Operationen multiplizieren können. Wir halten also fest:
• Ein Problem kann durch verschiedene Algorithmen mit verschiedener Komplexität gelöst
werden. In vielen Fällen ist es von Interesse, den Algorithmus zu finden, der am wenigsten
Betriebsmittel erfordert.
• Die Komplexität eines Algorithmus hängt von der Menge der Eingabedaten ab. Je mehr
Ziffern die Zahlen bei der Multplikation enthalten, um so länger wird die Ausführung
des Algorithmus dauern.
Im allgemeinen können wir die Komplexität eines Algorihmus als Funktion der Länge
der Eingabedaten angeben. Die Funktion geht also von N nach R.
2-8
In vielen Fällen betrachten wir nicht die Länge der Eingabe in Bytes, sondern in grösseren, für
das Problem natürliche Einheiten. Man spricht dann von der natürlichen Länge des Problems.
Will man nur eine Grössenordnung für die Komplexität eines Algorithmus angeben, so zählt
man auch nicht alle Operationen, sondern nur die, für die Lösung des Problems wichtigsten
Operationen. In der Tabelle 2-8 sind Probleme mit ihrer natürlichen Länge und wichtigsten
Operationen angegeben.
Problem
Primzahlenalgorithmen
Sortalgorithmen
Matrix Algorithmen
natürliche Einheit
Anzahl Ziffern
Anzahl Elemente
Dimension der Matrix
Operationen
Operationen in N
Vergleiche und Vertauschungen
Operationen in R
Abbildung 2-8: “Natürliche” Länge von Problemen
Eine weitere Vereinfachung ergibt sich, wenn man nur das assymtotische Verhalten der Komplexität eines Algorithmus betrachtet und nicht eine genaue Zahl. Es gibt viele Funktionen,
bei denen für wachsendes n ein Ausdruck in der Funktion dominiert.
Beispiel 2.2 [Dominierender Ausdruck] Wir betrachten die Funktion
f (n) = 3 · n2 + 5 · n
In dieser Funktion fällt für wachsendes n der Ausdruck 5 · n gegenüber dem Ausdruck 3 · n2
immer weniger ins Gewicht. Der dominierende Ausdruck ist in diesem Fall 3 · n2 . Man schreibt
f (n) = O(n2 )
Wir wollen das assymptotishe Verhalten einer Funktion noch genau definieren.
Definition 2.3 [Ordnung] f und g seien zwei Funktionen von N in R. Man sagt die Ordnung
von f sei kleiner oder gleich der Ordnung von g, falls zwei positive Konstanten n0 und c
existieren, sodass
f (n) ≤ c · g(n) ∀n ≥ n0
Man schreibt dafür auch:
Beispiel 2.3 [Ordnung von Funktionen] Nachfolgend einige Beispiele für die Ordnung
von Funktionen.
5
2
· n = O(n2 )
5 · n4 + 2 · n3 − 7 = O(n4 )
n4 6= O(n3 )
n10 = O(2n )
2-9
Definition 2.4 [assymptotisches Verhalten] Θ(f ) ist die Menge aller Funktionen g für
die gilt:
g = O(f ) ∧ f = O(g)
Obschon Θ() als Menge definiert ist schreiben wir kurz:
g = Θ(f )
Man sagt g hat dasselbe assymptotische Verhalten wie f .
Nachfolgend sind einige wichtige Regeln (ohne Beweis) angegeben:
• Die Ordnung des Logarithmus ist kleiner als die Ordnung einer linearen Funktion.
log(n) = O(n) ∧ log(n) 6= Θ(n)
• Die Ordnung eines Polynoms ist gleich der Ordnung des Terms mit der höchsten Potenz.
ak nk + ak−1 nk−1 + · · · + a1 n + a0 = Θ(nk ) ak 6= 0
• Ein Polynom vom Grad k hat eine höhere Ordnung als ein beliebiges Polynom mit Grad
< k.
k0 , k1 ∈ N ∧ k0 < k1 ⇒ nk0 = O(nk1 ) ∧ nk0 6= Θ(nk1 )
• Die Ordnung der Exponentialfunktion ist grösser als die Ordnung eines beliebigen Polynoms.
∀c > 1, d > 1 : nd = O(cn ) ∧ nd 6= Θ(cn )
Bemerkung 2.2 [Worst Case und Averrage] Wenn wir vom Aufwand eines Algorithmus
sprechen sind wir häufig an zwei Werten interessiert:
1. Aufwand im schlechtesten Fall (worst case).
Diese Zahl interessiert uns vor allem in realtime Systemen, wo die Antwortzeit des
Systems nach gewissen Ereignissen spezifiziert ist. Diese Zeit darf auch nicht im schlechtesten Fall überschritten werden.
2. Aufwand im Durchschnitt (average).
Diese Zahl ist dann interessant, wenn in einem System ein Algorithmus häufig ausgeführt
wird (zum Beispiel sortieren in einer kommerziellen Applikation). In einem solchen Fall
ist nur wichtig, dass der Algorithmus im Durchschnitt ein gutes Verhalten zeigt. Man
hofft, dass der schlechteste Fall eben nur selten eintrifft.
Wir wollen uns nun der Frage zuwenden, wann ein Agorithmus durchführbar ist und wann
nicht. Dafür betrachten wir die verschiedenen Zeitfunktionen in der Tabelle 2-9.
Wir nehmen nun an, dass für alle in der Tabelle 2-9 angegebenen Algorithmen eine Operation in einer Nanosekunde ausgeführt werden kann. Die daraus resultierenden Zeiten für
verschiedene n’s sind in der Tabelle 2-10 angegeben.
2-10
Zeitverhalten
Θ(log2 (n))
Θ(n)
Θ(n2 )
Θ(2n )
Algorihmus
Binary Search mit n Elementen
Sequentielles Suchen in einem Array der Länge n
Addition von zwei n × n Matritzen
Durchlaufen eines binären Baumes der Tiefe n
Abbildung 2-9: Zeitverhalten von Algorithmen
n
10
100
1000
10000
100000
Θ(log2 (n))
3 · 10−9
7 · 10−9
1 · 10−8
1.3 · 10−8
1.7 · 10−8
s
s
s
s
s
Θ(n)
1 · 10−8
1 · 10−7
1 · 10−6
1 · 10−5
1 · 10−4
s
s
s
s
s
Θ(n2 )
1 · 10−7
1 · 10−5
1 · 10−3
1 · 10−1
10
s
s
s
s
s
Θ(2n )
1 · 10−6
1013
10284
103002
1030095
s
Jahre
Jahre
Jahre
Jahre
Abbildung 2-10: Ausführungszeiten von Algorithmen
Aus der Tabelle 2-10 geht hervor, dass der Algorithmus mit einer Komplexität von 2n schon bei
kleinsten n undurchführbar wird. Algorithmen mit einer Komplexität der Form nc (c konstant)
sind dagegen durchführbar.
Definition 2.5 [Polynomiale- und Exponentielle-Algorithmen] Algorithmen, deren
asymtotisches Verhalten nc für eine Konstante c > 1 ist, heissen polynomiale Algorithmen.
Auch Algorithmen mit kleinerer Komplexität wollen wir polynomial nennen.
Algorithmen, deren asymtotisches Verhalten cn für eine Konstante c > 1 ist, heissen exponentielle Algorithmen.
Ein polynomialer Algorithmus ist leider in der Praxis auch nicht immer durchführbar. Nehmen
wir an, dass die Komplexität eines Algorithmus n100 sei. In diesem Fall ist zwar der Algorithmus polynomial, wie wir aber aus der Tabelle 2-10 entnehmen können schon für n = 2
undurchführbar.
Die Grenze zwischen durchführbaren und undurchführbaren Algorithmen wird trotzdem zwischen polynomialen und exponentiellen Algorithmen gezogen, da bis heute kein besseres Abgrenzungskriterium bekannt ist.
Definition 2.6 [Durchführbarkeit] Ein Algorithmus ist durchführbar, wenn er polynomial
ist, sonst ist er undurchführbar.
Da alle heutigen sequentiellen Computer einander simulieren können und der Zeitaufwand für
die Simulation höchstens polynomial ist, ist diese Definition maschinenunabhängig.
2.3.2
Amortisierte Kosten
Amortisation ist ein wichtiges analyse Werkzeug für Algorithmen bei denen die Kosten der
verschiedenen Operationen stark varieren.
Eine typische Datenstruktur stellt meistens viele verschiedene Operationen zur Verfügung
um auf die einzelnen Elemente der Struktur zuzugreifen oder um diese zu verändern. Wir
können diese Operationen auf ihr worst-case verhalten analysieren. Amortisation nimmt einen
anderen Standpunkt ein. Statt jede Operation für sich zu betrachten werden Sequenzen von
Operationen auf ihr Laufzeitverhalten untersucht.
2-11
2.3.2.1
Löschbare Tabelle
Als Beispiel wollen wir eine sehr einfache Datenstruktur betrachten: die löschbare Tabelle.
Diese Struktur speichert Elemente in einer Tabelle. Auf die Elemente kann mit ihrem Index
in der Tabelle zugegriffen werden. Zusätzlich werden die beiden folgenden Operationen zur
Verfügung gestellt:
add(e):
clear():
Das Element e wird in die nächste freie Zelle der Tabelle eingefügt
Alle vorhandenen Elemente werden aus der Tabelle entfernt.
Wir nehmen nun an, dass S eine löschbare Tabelle mit n Elementen ist. S sei als Array
implementiert mit einer zusätzlichen Variablen N , die die Länge der Tabelle angibt. Beim
löschen der Tabelle müssen alle Elemente auf null gesetzt werden um die Tabelle wirklich zu
löschen. Das heisst, die Löschoperation braucht Θ(n) zeit. Nun betrachten wir eine Sequenz
von n add und/oder clear Operationen. Falls wir eine worst-case Analyse durchführen ist der
Aufwand für diese Sequenz O(n2 ), da der schlechteste Fall für eine Operation (clear) O(n) ist.
Diese Analyse ist an sich korrekt aber übertrieben. Falls wir auch die Beziehungen zwischen
den einzelnen Operationen in der Sequenz in betracht ziehen, können wir zeigen, dass der
Zeitaufwand nur O(n) beträgt.
Sei M0 , M1 . . . Mn−1 eine Sequenz von n Operationen, die mit einer leeren lösch Tabelle beginnt.
Seien Mi0 , Mi1 . . . Mik−1 mit 0 ≤ i0 < · · · < ik−1 ≤ n − 1 die k clear Operationen.
Die Laufzeit für die Operation Mij ist O(ij − ij−1 ). Daraus erhalten wir die Laufzei für alle
clear Operationen. Dabei setzen wir den Wert von i−1 = −1

k−1
X
Laufzeit clear Operationen = O 
j=0

(ij − ij−1 )
Dies ist eine sogenannte teleskopische Summe dabei heben sich fast alle Summanden auf
i0 − i−1 + i1 − i0 + i2 − i1 · · · + ik−1 − ik−2
Es bleit also nur (ik−1 − i−1 ) übrig und das ist O(n). Da die add Operationen alle O(1) sind,
erhalten wir für die ganze Sequenz M0 , M1 . . . Mn−1 eine Laufzeit von O(n).
2.3.2.2
Amortisationstechnik: Die Buchhaltungsmethode
Es gibt verschiedene Techniken zum berechnen der amortisierten Kosten. Wir wollen nur die
sogenannte Buchhaltungstechnik betrachten. Die Idee ist, dass jeder Zeitabschnitt, der zur
Bewältigung einer Aufgabe benötigt wird, einen Cyberdollar kostet. Wir müssen dazu schauen,
dass immer genügend Cyberdollars zur Verfügung stehen um eine Operation auszuführen.
Trick: Gewisse Operationen werden mit höheren Kosten als die effektiven Kosten belastet.
Das angehäufte Geld kann dazu verwendet werden um “teure” Operationen zu bezahlen. Wir
versuchen nun, die Operationen so zu belasten, dass wir im Durchschnitt für jede Operation
gleich viel bezahlt wird, ohne dass uns die Cyberdollars jemals ausgehen. Wir nennen dies ein
amortisations Schema.
Wir betrachten nun als Beispiel wieder die löschbare Tabelle. Wir nehmen an, dass die effektiven Kosten für eine add Operation 1 Cyberdollar betragen. In unserem amortisations Schema
bezahlen wir nun für jede Operation 2 Cyberdollars. Bei der add Operation können wir also
2-12
einen Cyberdollar sparen. Wir denken uns, dass in jedem Element ein Cyberdollar gespeichert
ist. Wird nun die clear Operation audgeführt, so haben wir genügend Cyberdollars um jedes
Element zu löschen (Kosten pro Element 1 Cyberdollar). Das heisst, wenn wir bei jeder Operation 2 Cyberdollars bezahlen, so haben wir immer genug Geld um die clear Operationen zu
bezahlen. Das heisst, der Auffand für eine Sequenz von n Operationen (beginnend mit einer
leeren Tabelle) ist 2n also O(n). Die amortisierten Kosten für eine Operation sind in diesem
Fall O(1).
2.3.2.3
Erweiterbarer Array
Eine Schwäche der löschbaren Tabelle ist die vorgegebene Grösse N des Arrays. Falls nur
wenige Elemente gespeichert sind, so geht viel Platz verloren. Falls die Anzahl Elemente n
grösser als N wird, so wird das System abstürzen.
Wir wollen eine Datenstruktur entwickeln, die den Array vergrössert, wenn dies nötig ist.
In konventionellen Sprachen wie C oder Java kann die grösse des Arrays A nicht einfach
verändert werden. Falls ein Overflow passiert wenden wir indessen die folgende Prozedur an:
1. Alloziere einen neuen Array B mit der Grösse 2N
2. Kopiere A[i] nach B[i] für i = 0, . . . N − 1
3. Setze A = B und dealloziere A
Diese Strategie bezeichnet man als erweiterbarer Array (engl. extendable array). Das
Wachstum des Arrays ist in der Abbildung 2-11 angegeben.
Abbildung 2-11: (a) kreieren des Arrays B (b) kopieren der Elemente von A nach B (c) setzen
von A=B
Diese Strategie scheint nicht sehr effizient zu sein, da bei einer neuen Allokation des Arrays
n Elemente kopiert werden müssen (dies bedeutet eine Komplexität von O(n)). Man beachte
jedoch, dass nach der Allokation n Elemente eingetragen werden können, bevor der Array
wieder erweitert werden muss. Dank dieser Tatsache können wir zeigen, dass eine Sequenz
von Operationen, die mit einer leeren Tabelle beginnt sehr effizient ist. Wir benutzen dazu
die amortisations Technik.
Satz 2.1 [Erweiterbarer Array] Sei S eine Tabelle, die mittels eines erweiterbaren Arrays
A implementiert ist. Die Laufzeit einer Sequenz von n add Operationen, die mit einer leeren
Tabelle S beginnt wobei A die Grösse N = 1 hat, ist O(n).
Beweis: Wir benutzen dazu die Buhhaltungsmethode: Wir nehmen an, dass eine add Operation (ohne overflow) effektiv einen Cyberdollar kostet. Wir nehmen
ferner an, dass die Kosten um den Array von der Grösse k auf 2k zu expandieren
k Cyberdollars kostet (einen Cyberdollar für das Kopieren eines Elements). Wir
bezahlen nun drei Cyberdollar pro add Operation. Wir stellen uns vor, dass die
2-13
verbleibenden zwei Cyberdollars in dem entsprechenden Element gespeichert sind.
Ein Overflow passiert, wenn in der Tabelle 2i Elemente für ein gegebnes i ≥ 0
gespeichert sind. Daher wird die Vergrösserung des Arrays 2i Cyberdollar kosten.
Nun können dies 2i Cyberdollars in den Elementen 2i−1 bis 2i − 1 des Arrays
gefunden werden (siehe Abbildung 2-12).
Abbildung 2-12: (a) Array mit 8 Elementen. Die Elemente 4 bis 7 besitzen je 2 Cyberdollar (b)
neuer Array die Cyberdollar der Elemente 4 bis 7 sind aufgebraucht. Das Element 8 speichert
nun 2 Cyberdollars
Man beachte, dass der letzte overflow beim einfügen des Elements 2i−1 stattgefunden hat und seit dem sind keine in den Elementen gespeicherten Cyberdollars
ausgegeben worden. Das heisst, wenn wir pro add Operation 3 Cyberdollar bezahlen sind die Kosten für die ganze Sequenz 3n also O(n).
Zum Schluss wollen wir noch betrachten welche Kosten entstehen, wenn der Array immer um
eine fixe Anzahl Elemente c erweitert wird.
Satz 2.2 [Fixer Inkrement] Falls wir eine Tabelle mit einem Array implementieren und
der Array bei Bedarf um einen fixen Wert erweitert wird, so hat eine Sequenz von n add
Operationen, die mit der leeren Tabelle beginnt eine Laufzeit von O(n2 )
Beweis: Sei c > 0 der Wert des Inkrements und c0 > 0 die Anfangskapazität
des Arrays. Ein Overflow wird passieren, wenn die Anzahl der Elemente in der
Tabelle die form c0 + ic für i = 0 . . . m − 1 hat mit m = ⌊(n − c0 )/c⌋. Das heisst,
die gesmate Laufzeit für die Sequenz ist proportional zu
m−1
X
i=0
(c0 − ic) = c0 m + c
m−1
X
i=0
i = c0 m + c
m(m − 1)
2
dies ist aber O(n2 ).
In der Abbildung 2-13 ist der Vergleich der Laufzeit der beiden Methoden (verdoppeln der
Grösse und erweitern um einen fixen Wert) dargestellt.
2-14
Abbildung 2-13: (a) Grösse wird verdoppelt (b) Array wird immer um 3 Elemente vergrössert
2.3.3
Die Klassen P und N P
In diesem Abschnitt wollen wir eines der berühmtesten Probleme der theoretischen Informatik informell besprechen. Wie im Abschnitt 2.3.1 schon besprochen, sind exponentielle
Algorithmen schon für kleine Eingabedaten undurchführbar. Leider existieren viele prakische
Probleme, für die nur exponentielle Algorithmen bekannt sind. Auch ist die Grenze zwischen
leichten und schwierigen Problemen oft sehr schmal. Dies wird durch das folgende Beispiel
illustriert.
Beispiel 2.4 [Polynomiale und Exponentielle Probleme]
polynomial
Existiert in einem gewichteten Graphen ein Weg von x nach y mit einem Gewicht G <
M?
exponentiell
Existiert in einem gewichteten Graphen ein Weg von x nach y mit einem Gewicht G >
M?
Bis heute ist es nicht gelungen zu zeigen, dass das zweite Problem nur in exponentieller Zeit
zu lösen ist. Es ist aber auch nicht gelungen, einen polynomialen Algorithmus zu finden.
Wir wollen nun die zwei wichtigen Klassen P und N P definieren und Beispiele dazu angeben.
Bemerkung 2.3 [Darstellung des Inputs] Für diese Betrachtungen wird die Komplexität
eines Algorithmus als Funktion der Anzahl Bits des Inputs gegeben. Solange die Darstellung
des Inputs “vernünftig” ist, spielt diese für die Komplexität keine grosse Rolle. Eine unvernünftige Darstellung für ganze Zahlen wäre die unäre Darstellung. 1 = 1 Bit, 2 = 2 Bits, 3
= 3 Bits u.s.w. Wir erwarten, dass die Anzahl benötigten Bits zur Darstellung einer ganzen
Zahl M proportional zu log(M ) ist.
2.3.3.1
Entscheidungsprobleme
Als erstes möchten wir eine Vereinfachung vornehmen und nur sogenannte Entscheidungsprobleme betrachten. Entscheidungsprobleme sind Probleme, für die zu einer gegebenen Eingabe
als Lösung nur zwei Antworten (ja oder nein) vorgesehen sind.
2-15
Typische Entscheidungsprobleme
1. Gegeben sei eine Formel der Aussagenlogik in konjuktiver Normalform. Gibt es eine
Belegung der Variablen (mit 0 und 1) so, dass die Formel wahr wird?
2. Gegeben seien zwei Strings P und T. Ist P als Substring in T enthalten?
3. Gegeben seien Zahlentrippel (a,b,c) mit a, b, c ∈ Z. Gilt die Aussage a · b = c ?
4. Gegeben sei ein gewichteter Graph G und eine Zahl k. Gibt es für G einen minimalen
aufspannenden Baum mit einem Gewicht kleiner als k?
An den Beispielen sieht man, dass die meisten relevanten Probleme auch als Entscheidungsprobleme formuliert werden können. Insbesondere lassen sich Optimierungsprobleme (Punkt
4) als Entscheidungsprobleme formulieren.
Entscheidbare Sprachen Ein Entscheidungsproblem kann auch als formale Sprache L
verstanden werden.
Dazu definieren wir eine formale Sprache L (für language) als eine Menge von Strings über
ein gegebnes endliches Alphabet Σ. Ein Entscheidungsproblem kann nun folgendermassen
formuliert werden:
Gegeben sei ein endliches Alphabet Σ eine formale Sprache L über Σ und ein Wort (String)
über das Alphabet Σ. Die Frage ist nun, ob das Wort zur Sprache L gehört oder nicht.
Eine formale Sprache L ist entscheidbar (rekursiv entscheidbar), wenn eine Turingmaschine
existiert, die bei Eingabe eines Wortes der Sprache als Ausgabe entweder 1 (ja) oder 0 (nein)
ausgibt.
Bemerkung 2.4 [Entscheidbare Sprachen versus Entscheidungsproblem] Aus den
obigen Betrachtungen geht hervor, dass es keine Rolle spielt ob wir von entscheidbaren Sprachen oder von Entscheidungsproblemen sprechen.
2.3.3.2
Die Klasse P
Definition 2.7 [Klasse P] Die Klasse P ist die Menge aller Entscheidungsprobleme (oder
Sprachen), die von einer deterministischen Turingmaschine in polynomialer Zeit entschieden
werden können.
Deterministisch heisst, dass nach jedem Schritt der Turingmaschine der nächste Zustand eindeutig definiert ist. Sehr viele praktische Probleme gehören zur Klasse P.
• Gibt es in einem Graphen einen Weg von a nach b der kürzer ist als k?
• Hat ein lineares Gleichungssystem mit n Unbekannten eine Lösung?
• u.s.w.
2-16
2.3.3.3
Die Klasse N P
Nichtdeterministische Turingmaschine Bei einer nichtdeterministischen Turingmaschine ist die Überführungsfunktion δ folgendermassen definiert:
δ : Q × Γ 7−→ P(Q × Γ × {L, 0, R})
Hierbei steht P wie üblich für die Potenzmenge. Das heisst, dass das Ergebnis der Überführungsfunktion eine Menge von Überführungstripeln ist, und nicht mehr genau ein Überführungstripel.
Durch diese Überführungsrelation ist der Folgezustand, der sich aus dem aktuellen Bandzeichen und dem aktuellen Zustand ergibt, nicht mehr eindeutig bestimmt. Die Turingmaschine
hat also im Allgemeinen zu jedem Berechnungszeitpunkt eine Auswahl an Folgezuständen,
wodurch verschiedene nicht eindeutig vorherbestimmte Rechenwege möglich sind. Alle diese Rechenwege können von der Maschine parallel ausgeführt werden. Die Eingabe wird von
der nichtdeterministischen Turingmaschine akzeptiert, wenn es einen Rechenweg gibt, dessen
Ausgabe “ja” ist.
Wir können nun die Klasse N P (Nondeterministic Polynomial-Time Algorithms) definieren.
Definition 2.8 [Klasse N P] Die Klasse N P enthält alle Entscheidungsprobleme (oder Sprachen), die von einer nichtdeterministischen Turingmaschine in polynomialer Zeit entschieden
werden können.
Aus dieser Definition können wir einige Schlüsse ziehen.
• Jedes Problem in P ist natürlich auch in N P. Eine deterministische Turingmaschine
kann als Spezialfall einer nichtdeterministischen Turingmaschine angesehen werden. Das
heisst,
P ⊆ NP
• Auf den ersten Blick scheint es klar zu sein, dass nichtdeterministische Turingmaschinen mehr Probleme in polynomialer Zeit lösen können als deterministische. In unserer
Sprache würde das heissen, dass P eine echte Teilmenge von N P ist
• Bis heute ist es aber niemandem gelungen, diese Tatsache zu beweisen oder zu widerlegen. Das heisst,
Es ist heute nicht bekannt, ob N P eine echte Obermenge von P ist oder nicht.
Dieser Tatbestand ist ziemlich frustrierend, da wir heute für viele praktische Probleme in N P nur exponentielle Algorithmen kennen. Wir wissen aber nicht, ob doch ein
polynomialer Algorithmus für das Problem existiert oder nicht.
Man weiss zwar nicht, ob die Klassen P und N P gleich sind oder nicht, aber man kennt
wenigsten die schwierigsten Probleme in der Klasse N P. Schwierig heisst in diesem Zusammenhang, dass wenn man von einem dieser Probleme beweisen kann, dass es in polynomialer
Zeit lösbar ist, so sind alle Probleme in N P auch in polynomialer Zeit lösbar.
2-17
2.3.3.4
N P-Vollständigkeit
Definition 2.9 [Polynomial reduzierbar] Eine Sprache L über das Alphabet Σ, die ein
Entscheidungsproblem darstellt ist polynomial reduzierbar auf die Sprache M über das Alphabet Σ′ , falls eine Funktion f : Σ∗ 7→ Σ′∗ existiert, die in polynomialer Zeit berechenbar ist
und für die gilt:
x ∈ L ⇔ f (x) ∈ M
Wir schreiben dafür L ≤p M
Beispiel 2.5 [Polynomiale Reduktion] So lässt sich die Sprache aller Quadratzahlen
LQ = {(a, b) ∈ Z2 : b = a2 }
in polynomialer Zeit auf die Sprache der Multiplikationen mit zwei Faktoren
LM = {(a1 , a2 , b) ∈ Z3 : b = a1 · a2 }
über die Abbildung f : Z2 7→ Z3 , (a, b) 7→ (a, a, b) reduzieren.
Mit dieser Reduktion ist gezeigt, dass ein Algorithmus, welcher zwei natürliche Zahlen miteinander multipliziert, auch eine natürliche Zahl quadrieren kann, das Quadrieren kann also
auf das Multiplizieren reduziert werden.
Definition 2.10 [N P-vollständigkeit] Eine Sprache M heisst N P-schwer falls ∀L ∈ N P
gilt L ≤p M . Falls zusätzlich M ∈ N P gilt, so heisst die Sprache M N P-vollständig.
Um zu zeigen, dass ein Problem N P-vollständig ist, muss man nur zeigen, dass es auf ein
anderes N P-vollständiges Problem polynomial reduzierbar ist. Dazu muss aber mindestens
ein N P-vollständiges Problem bekannt sein.
2.3.3.5
Cook’s Theorem
Gegeben sei eine Formel der Aussagenlogik in konjunktiver Normalform. Das heisst, die Formel
besteht aus beliebiegen Konjunktionen von Disjunktionstermen. Ein Disjunktionsterm ist eine
Disjunktion von nichtnegierten oder negierten Variablen
Beispiel 2.6 [Konjuktive Normalform] Die folgende Formel ist in konjunktiver Normalform.
(x1 ∨ x2 ) ∧ (x3 ∨ x4 ∨ x5 )
Gesucht ist, ob eine Formel in konjunktiver Normalform erfüllbar ist. Das heisst, kann der
Wert der Variablen so gewählt werden, dass die Formel wahr wird. Die zwei erlaubten Werte
für die einzelnen Variablen sind wahr und falsch.
Satz 2.3 [(Cook 1971)] Das Erfülbarkeitsproblem für Formeln in konjuktiver Normalform
ist N P-vollständig.
2-18
Der Beweis dieses Theorems zeigt, dass zu jeder nichtdeterministischen Turingmaschine TM
und zu jedem Input I der von TM in polynomialer Zeit entschieden wird, eine entsprechende
logische Formel konstruiert werden kann, die genau dann erfüllbar ist, wenn TM I akzeptiert.
Beispiel 2.7 [N P-vollständige Probleme] Wir wollen zuletzt noch einige Beispiele N Pvollständiger Probleme angeben. Für all diese Probleme sind also heute nur exponentielle
Algorithmen bekannt.
• Traveling Salesman:
Gegeben ist eine Menge von Städten und die Distanzen zwischen all diesen Städten.
Gesucht ist eine Tour durch alle Städte, so dass der gesammte Weg < M ist.
• Hamilton Kreise:
Gegeben sei ein Graph. Gesucht ist ein einfacher Kreis, der alle Ecken des Graphen
durchläuft.
• Partition:
Gegeben sei eine Menge von ganzen Zahlen. Gesucht sind zwei disjunkte Teilmengen, so
dass die Vereinigung der Teilmengen gleich der Menge ist und die Summe der Elemente
in beiden Teilmengen dieselbe ist.
2.3.4
Lower Bound (LB)
Die Lower-Bound-Theory hat zum Ziel, den minimalen Aufwand zu ermitteln, der zur Lösung
eines Problems notwendig ist. Der minimale Aufwand gibt einen Hinweis auf das mögliche
Verbesserungspotential von bekannten Algorithmen, die das Problem lösen.
Den Lower-Bound für ein Problem zu finden ist im allgemeinen eine sehr schwierige Aufgabe.
Zum Beispiel ist für alle N P-vollständigen Probleme der Lower-Bound nicht bekannt. Aber
auch für viele andere Probleme kann der minimale Aufwand nur geschätzt werden (Angabe
einer unteren Schranke).
Für die folgenden Probleme ist der Lower-Bound (LB) bekannt. Dieser gilt nur für sequentielle
Computer. Für parallele Maschinen kann der LB tiefer sein.
• Suchen des grössten Elements in einem unsortierten Array der Länge n.
LB = Θ(n)
• Suchen eines Elementes x in einem sortierten Array der Länge n.
LB = Θ(log2 (n))
• Sortieren der Elemente in einem Array der Länge n.
LB = Θ(n · log2 (n))
Für die folgenden Probleme ist der Lower-Bound nicht bekannt.
• Für alle N P-vollständigen Probleme ist LB nicht bekannt.
2-19
• Für die Multiplikation von zwei (n × n) Matritzen ist der LB auch nicht bekannt. Hier
können wir aber die untere Schranke Θ(n2 ) angeben.
• Für die Faktorisierung einer Natürlichen Zahl mit n Dezimalstellen.
Resultat.
2-20
Kapitel 3
Sortieralgorithmen
3.1
Einführung
Nachstehend sind die wichtigsten Begriffe, die im Zusammenhang mit dem Sortieren auftauchen zusammengefasst:
• Ein File der Länge n ist eine Sequenz von n Records.
• Ein Schlüssel oder Key besteht aus bestimmten Felder des Records (z.B. Der Ort und
der Name einer Person im Telefonbuch bilden zusammen einen Schlüssel).
• Wenn der Key im Record enthalten ist spricht man von internal oder embedded key.
Wird der Key zusammen mit einem Zeiger auf die Daten in einer externen Tabelle
gehalten, so spricht man von einem external key.
• Ein Sortieralgorithmus ist ein Verfahren, das die Records in einem File der Länge n so
anordnet das gilt:
(∀i, j ∈ [0, n − 1])((i < j) ⇒ key(r(i)) ≤ key(r(j)))
dabei bedeutet key(r(i)) der Schlüssel des i-ten Records. ≤ ist eine totale Ordnung auf
die Menge der Schlüssel.
• Hat das ganze File im Hauptspeicher Platz, so spricht man von einem internen Sortierverfahren. Wird ein File auf einer Disk oder auf einem Magnetband sortiert, so spricht
man von einem externen Sortierverfahren.
• Ein Inplace-Sortierverfahren sortiert einen Array an Ort und Stelle. Das heisst es gibt
kein Umkopieren des Arrays in einem Hilfsarray.
• Ein Sortieralgorithmus heisst stabil, wenn die ursprüngliche Reihenfolge zweier Records
mit gleichem Key erhalten bleibt.
3.2
Elementare Sortierverfahren
Elementare Sortierverfahren Selection-, Insertion- und Bubble-Sort eignen sich zum Sortieren
von kleinen Arrays. Ihr Vorteil ist ihre Einfachheit. Für grosse Arrays sind diese Sortierverfahren nicht geeignet, da Sie eine Komplexität von O(n2 ) aufweisen. Bei allen drei Verfahren
wollen wir die Elemente eines gegebenen Arrays in aufsteigender Reihenfolge inplace sortieren.
3-1
3.2.1
Sortieren durch Auswählen (selection sort)
Dies ist wahrscheinlich das intuitivste Verfahren und funktioniert folgendermassen:
• Suche im Array[1..n] das kleinste Element und vertausche dieses mit dem 1. Element.
• Suche im Array[2..n] das kleinste Element und vertausche dieses mit dem 2. Element.
• Suche im Array[3..n] das kleinste Element und vertausche dieses mit dem 3. Element.
• usw.
Der Algorithmus ist nachfolgend in Java angegeben
Algorithmus 3.1 [Sortieren durch Auswählen]
public class SelectionSort {
public static void selectionSort(int buffer[]) {
int left;
int minpos;
int size = buffer.length;
for (left = 0; left < size - 1; left++) {
minpos = left;
// Suchen des Minimums
for (int i = left; i < size; i++) {
if (buffer[i] < buffer[minpos]) {
minpos = i;
}
}
// Elemente vertauschen
int tmp = buffer[left];
buffer[left] = buffer[minpos];
buffer[minpos] = tmp;
}
}
}
3.2.2
Sortieren durch Einfügen (insertion sort)
Bei diesem Algorithmus werden die Elemente der Reihe nach im bereits sortierten Anfang des
Arrays eingetragen. Dazu wird im bereits sortierten Teil des Arrays die richtige Stelle zum
Einfügen von rechts her gesucht und gleichzeitig die bereits sortierten Elemente um eins nach
rechts verschoben. Das neue Element wird dann an der auf diese Weise freigewordene Position
eingefügt. Am Anfang besteht die sortierte Folge von Elementen nur aus dem Ersten Element
im Array und das zweite Element wird eingefügt. Anschliessend wird das dritte Element in
die sortierte Folge der zwei ersten Elemente eingefügt usw.
3-2
Der Algorithmus ist nachfolgend in Java angegeben
Algorithmus 3.2 [Sortieren durch Einfügen]
public class InsertionSort {
public static void insertionSort(int buffer[]) {
int left;
int actual;
int size = buffer.length;
for (left = 1; left < size; left++) {
actual = buffer[left];
// Verschieben der Elemente bis der Platz gefunden ist.
int pos = left - 1;
while (pos > 0 && actual <= buffer[pos]) {
buffer[pos + 1] = buffer[pos];
--pos;
}
// Element an den richtigen Platz verschieben.
buffer[pos] = actual;
}
}
}
3.2.3
Sortieren durch Vertauschen (bubble sort)
Dieser Algorithmus beruht auf folgender Idee:
Der Array wird von Anfang bis zum Ende durchlaufen. Die Elemente werden dabei paarweise
verglichen. Wenn beide in der richtigen Reihenfolge stehen, dann wird mit dem nächsten Paar
fortgefahren. Stehen sie nicht in richtiger Reihenfolge, werden sie vertauscht, und anschliessend
mit dem nächten Paar weitergefahren. Wenn der Array durchlaufen ist, so steht das grösste
Element am Ende des Arrays.
Der Prozess wird dann ohne das letzte Element wiederholt. Im nächsten Durchgang werden
die letzten beiden Elemente ausgelassen usw. bis fertig sortiert ist.
Der Algorithmus ist nachfolgend in Java angegeben
Algorithmus 3.3 [Sortieren durch Vertauschen]
public class BubbleSort {
public static void bubbleSort(int buffer[]) {
int size = buffer.length;
int right = size - 1;
while (right > 0) {
int lastchange = 0;
for (int i = 0; i < right; i++) {
if (buffer[i] > buffer[i + 1]) {
// Elemente vertauschen
3-3
int tmp = buffer[i];
buffer[i] = buffer[i + 1];
buffer[i + 1] = tmp;
lastchange = i + 1;
}
}
right = lastchange;
}
}
}
3.2.4
Vergleich der elementaren Sortierverfahren
Hier noch einige Bemerkungen zu den drei Elementarverfahren.
• Die komplexität der drei elementaren Algorithmen ist (O(n2 )) (im Prinzip ein zweifach
verschachtelter Loop über n Elemente). Die Aussagen können aber noch folgendermassen
Präzisiert werden:
– Selection-Sort benötigt ca.
n2
2
Vergleiche und n Austauschoperationen.
2
2
– Insertion-Sort benötigt im Durchschnitt ca. n4 Vergleiche und n8 Austauschoperationen. Im schlechtesten Fall sind es doppelt so viele. In diesem Algorithmus
werden Austauschoperationen nur halb gezählt, weil eine Austauschoperation nur
aus dem schieben einer Zahl besteht (bei richtigen Austauschoperationen sind 3
Zuweisungen notwendig).
– Bubbel-Sort benötigt ca.
n2
2
Vergleiche und
n2
2
Austauschoperationen.
• Werden grosse Records Vertauscht, so ist Selection-Sort am besten, da dort am wenigsten
Vertauschoperationen durchgeführt werden müssen.
• Ist der Vergleich der Keys aufwendig (grosser Key), so ist Insertion-Sort am besten, da
im durchschnitt am wenigsten Vergleiche notwendig sind.
• Im besten Fall sind Insertion-Sort und Bubble-Sort linear.
• In der Praxis ist der Bubble-Sort das schlechteste elementare Verfahren. Dies liegt daran,
dass der innerste Loop des Algorithmus bezüglich den anderen Verfahren sehr aufwendig
ist (siehe auch Übungen).
3.3
Heapsort
Der Heapsort wird in der Praxis selten verwendet. Das Verfahren benötigt aber eine interessante Datenstruktur, die zur Implementation einer Priorityqueue verwendet werden kann.
3.3.1
Priorityqueue
Eine Priorityqueue PQ ist eine Warteschlange, aus der die Elemente nicht in erster Linie nach
Wartezeit, sondern nach Priorität ausgelesen werden.
Wichtigste Anwendungen von PQ’s:
3-4
• Simulation
• Job Scheduling in Computersystemen
• Datenkompression
• Graph-Algorithmen
• usw.
Die PQ kann natürlich wieder als ADT definiert werden. Die wichtigsten Operationen sind
dabei:
• Einfügen eines Elements.
• Löschen des Elements mit grösster Priorität
• Aufbauen einer PQ aus n gegebenen Elementen.
Nachfolgend ist die Spezifikation des ADTs als Java-Interfacedefinition gegeben
/**
* Dieses Interface definiert die Schnittstelle zu einer
* Prioritaetsschlange. Die Implementation steht noch offen
*/
public interface PrioQueue {
public static class FullException extends Exception{}
public static class EmptyException extends Exception{}
/** Preconditions: full() != true<BR>
* Postconditions: -- Ein Element mehr (mit Prioritaet Prio)
*
in der PQ<BR>
*
new.pqlength() = old.pqlength() + 1<BR>
*
new.readprio() = Max(old.readprio(), prio)
*
<BR>
*
* @param prio Prioritaet des neuen Elementes
* @param obj Nutzdaten
*/
public void insert(Comparable prio, Object obj)
throws PrioQueue.FullException;
/** Preconditions: pqlength() != 0<BR>
* Postconditions: -- Das Element mit groesster Prioritaet
*
wird entfernt. Sind mehrere
*
Elemente mit groesster Prioritaet
*
vorhanden, so ist die Wahl unter diesen
*
Elementen nicht deterministisch.<BR>
*
new.pqlength() = old.pqlength() - 1<BR>
*
new.readprio() <= old.readprio()<BR>
*/
public void remove()
3-5
throws PrioQueue.EmptyException;
/** Preconditions: none<BR>
* Postconditions: none<BR>
*
* @return: false falls Element eingefuegt werden kann
*/
public boolean full();
/** Preconditions: none<BR>
* Postconditions: none<BR>
*
* @return: Anzahl Elemente in PQ
*/
public int pqlength();
/** Preconditions: pqlength() != 0<BR>
* Postconditions: none<BR>
*
* @return: Daten des Elements mit der groessten Prioritaet.
*
Sind mehrere Elemente mit groesster Prioritaet
*
vorhanden, so ist die Wahl unter diesen Elementen
*
nicht deterministisch.
*/
public Object readData();
/** Preconditions: pqlength() != 0<BR>
* Postconditions: none<BR>
*
* @return: Groesste Prioritaet in der PQ.
*/
public Comparable readPrio();
}
3.3.2
Implementation der PQ als Heap
Eine PQ kann natürlich auf viele verschiedene Arten implementiert werden (ungeordnete
Liste, geordnete Liste usw.). Im folgenden wird ein sogenannter Heap verwendet.
Definition 3.1 [Heap] Ein Heap ist ein fast vollständiger binärer Baum mit den folgenden
Zusatzeigenschaften.
1. Jeder Knoten im Baum besitzt eine Priorität und eventuell noch weitere Daten.
2. Die Priorität eines Knotens ist immer grösser oder gleich als die Priorität der Nachkommen. Diese Bedingung heisst Heapbedingung.
Aus der definition kann sofort abgelesen werden, dass die Wurzel des Baumes die höchste
Priorität besitzt.
Weil der Heap ein fast vollständiger binärer Baum ist, lässt er sich einfach als Array implementieren. Wir numerieren die Knoten des Baumes von links nach rechts und von oben nach
3-6
unten. Die so erhaltene Nummerierung ergibt für jeden Knoten seinen Index im Array (siehe
Abbildung 3-1).
1
2
4
8
30
3
60
5
50
9
65
49
1
10
37
2
3
6
48
12
11
4
5
6
12
7
8
45
47
7
18
8
9
10 11 12
65 60 47 50 48 45 18 30 49 37 12 8
Abbildung 3-1: Der Heap als Array
Werden die Knoten wie in Abbildung 3-1 in den Array abgelegt, so gelten für alle Knoten i
mit 1 ≤ i ≤ N die folgenden Regeln:
1. Der linke Nachfolger des Knotens i ist (falls vorhanden) der Knoten 2 · i.
2. Der rechte Nachfolger des Knotens i ist (falls vorhanden) der Knoten 2 · i + 1.
3. Der direkte Vorfahre eines Knotens i ist (falls vorhanden) der Knoten ⌊ 2i ⌋.
Die drei wichtigsten Operationen insert, remove und build können nun folgendermassen
implementiert werden:
3.3.2.1
insert
Das Element wird als letztes Element im Array hinzugefügt. Der Array repräsentiert immer
noch einen fast vollständigen binären Baum. Das neue Element verletzt aber eventuell die
Heapbedingung. Um wieder einen Heap zu erhalten, vertauschen wir das neue Element solange
mit seinen direkten Vorgängern, bis die Heapbedingung wieder erfüllt ist (siehe Abbildung
3-2). Dieses Verfahren wird upheap genannt.
Die Methode verfolgt einen direkten Weg von einem Blatt zur Wurzel. Da der binäre Baum
fast vollständig ist, hat ein solcher Weg höchstens die Länge log2 (n). Mit anderen Worten,
wir brauchen höchstens log2 (n) Vertauschoperationen, um ein Element im Heap einzufügen.
3.3.2.2
remove
Das erste Element im Array entspricht der Wurzel des Baumes. Da die Priorität der Wurzel
maximal ist, entfernen wir dieses Element. Wir kopieren nun das letzte Element des Heaps an
3-7
65
55
60
50
30
47
48
49
37
12
8
18
45
55
Abbildung 3-2: Einfügen im Heap
die Stelle der Wurzel im Array. Wieder erhalten wir einen fast vollständigen binären Baum.
Die Wurzel verletzt aber eventuell die Heapbedingung. Die Wurzel wird nun mit dem grösseren
ihrer beiden direkten Nachfolger vertauscht. Wir fahren so fort, bis die Heapbedingung nicht
mehr verletzt ist (siehe Abbildung 3-3). Dieses Verfahren wird downheap genannt.
Wie beim Einfügen, brauchen wir höchstens log2 (n)) Vertauschoperationen, um ein Element
aus dem Heap zu löschen.
60
65
55
50
49
30
47
48
45
37
12
8
18
45
Abbildung 3-3: Löschen im Heap
3.3.2.3
build
Als erstes wollen wir sehen, wie wir aus einem beliebigen Array von Elementen einen Heap
konstruieren können. Der Array kann als fast vollständiger binärer Baum aufgefasst werden.
Es bleibt also nur die Aufgabe die Heapbedingung für alle Knoten zu erzwingen.
Man beachte, dass jedes Element des Arrays als Wurzel eines Heaps betrachtet werden kann.
Wir beginnen nun zuhinterst im Array mit dem Aufbau der kleinsten Heaps und gehen so
rückwärts bis zur Wurzel des ganzen Heaps. Die Heapbedingung kann dann für jede Wurzel
mit Hilfe des Downheap-Verfahrens erzwungen werden. Man beachte ferner, dass alle Elemente
des Arrays mit Index grösser als n2 Heaps mit nur einem Element darstellen und daher die
Heapbedingung für diese trivialerweise erfüllt ist. Aus diesem Grund kann der Aufbau der
Heaps beim Index n2 begonnen werden. Die Konstruktion des Heaps ist in der Abbildung 3-4
dargestellt.
3-8
1
2
3
4
5
12 50 37 65 8
8
4
8
60
9
50
8
9
48
11
9
49
10
8
6
11
30
12
12
8
10
30
11
12
6
48
8
1
2
3
11
4
5
6
18
7
18
37
3
10
7
65
5
49
18
47
45
60
50
9
7
47
45
6
48
49
45
3
5
9
12
65
60
2
8
30
48
1
4
10 11 12
5
2
8
7
10
65
60
4
49
6
47 18 60 49 48 30 45
30
7
12
8
9
45
47
37
10 11 12
65 60 47 50 48 45 18 12 49 8
30 37
Abbildung 3-4: Konstruktion des Heaps
Der Aufbau des Heaps mit dieser Prozedur erfolgt in linearer Zeit. Um dies zu sehen, betrachten wir die Anzahl Vertauschungen (Vergleiche sind dann doppelt so viele) die notwendig sind um den Heap aufzubauen. Bei 127 Elementen wird das Downheap-Verfahren
für 32 Heaps der Tiefe 1, 16 Heaps der Tiefe 2, 8 Heaps der Tiefe 3, 4 Heaps der Tiefe
4, 2 Heaps der Tiefe 5 und einem Heap der Tiefe 6 angewendet. Dies ergibt also maximal
32 · 1 + 16 · 2 + 8 · 3 + 4 · 4 + 2 · 5 + 1 · 6 = 120 Vertauschungen. Für n = 2k Elemente kann
eine obere Schranke gefunden werden:
V ertauschungen =
k
X
(i − 1) · 2k−i = 2k − k − 1 < n
i=1
Wir haben also gezeigt, dass das Verfahren eine lineare Komplexität besitzt.
3-9
3.3.3
Das Sortierverfahren (Heapsort)
Wir sind nun in der Lage für einen Array den Heapsort zu formulieren.
1. Heap aufbauen:
Mit Hilfe der vorher erklärten Prozedur wird der Array in einen Heap umgewandelt.
2. Array sortieren:
Wir wissen nun, dass das grösste Element immer im ersten Element des Arrays gespeichert ist. Dieses Element vertauschen wir mit dem letzten Element des Arrays und
wenden das Downheap-Verfahren auf die Elemente 1 . . . n − 1 des Arrays an. Nun ist
das zweitgrösste Element an erster Stelle im Array und wir können mit dem Heap der
Grösse n − 1 gleich weiterfahren.
Da wir hier das Downheap-Verfahren für jedes Element anwenden ist die Komplexität
dieser Prozedur O(n · log2 (n)).
Da der Aufbau des Heaps in linearer Zeit möglich ist und das anschliessende sortieren eine
Komplexität von O(n · log2 (n)) hat können wir sagen:
T (Heapsort) = O(n + n · log2 (n)) = O(n · log2 (n)
Dies gilt sowohl im Durchschnitt wie auch im schlechtesten Fall.
3.4
Quicksort
Quicksort wurde 1960 von C.A.R. Hoare entwickelt und gilt heute als der Standard für internes
Sortieren. Quicksort ist ein sogenannter Divide-And-Conquer Algorithmus. Was das heisst, ist
im nächsten Abschnitt beschieben.
3.4.1
Divide-And-Conquer Methode
Die Divide-And-Conquer Methode (kurz: DAC) zerlegt das zu lösende Problem in kleinere
Teilprobleme, (divide) bis die Lösung der einzelnen Teilprobleme trivial ist. Anschliessend
werden die Teillösungen zur Gesamtlösung vereinigt (conquer).
Da das Problem immer wieder in kleinere Teilprobleme zerlegt wird und diese ihrerseits auch
wieder zerlegt werden, ergibt sich sehr oft ein Lösungsansatz mit Rekursion.
Ein DAC-Algorithmus hat also folgende allgemeine Form:
void DAC(problem P)
{
if (Lösung von P trivial)
return Lösung
else {
divide(P, Teil1 , . . . , Teiln );
return conquer(DAC(Teil1 ), . . . , DAC(Teiln ));
}
}
3-10
DAC-Algorithmen können in eine der beiden folgenden Kategorien eingereiht werden.
• Das Aufteilen in Teilprobleme (divide) ist einfach, dafür ist das Zusammensetzen der
Teillösungen (conquer) schwierig.
• das Aufteilen in Teilprobleme (divide) ist schwierig, dafür ist das Zusammensetzen der
Teillösungen (conquer) einfach.
wenn sowohl das Aufteilen in Teilprobleme wie das Zusammensetzen der Teillösungen schwierig sind, so lohnt sich die DAC-Methode nicht.
3.4.2
Das Sortierverfahren
Gegeben sei ein Array von Elementen a[left],. . . ,a[right] und eine totale Ordnungsfunktion ≤ auf diesen Elementen. left und right sind die Grenzen des zu sortierenden Unterarray.
Der Quicksort Algorithmus hat den folgenden allgemeinen Aufbau:
• Umordnen der Elemente a[left],. . . ,a[right], so dass die folgenden Bedingungen erfüllt sind.
∃pivot: left≤pivot≤right, so dass
a[j]≤a[pivot] ∀j: j<pivot und
a[j]≥a[pivot] ∀j: j>pivot
• Das Element a[pivot] ist offensichtlich am richtigen Platz im Array.
• Der Vorgang kann nun rekursiv auf die beiden Teilarrays a[left. . . pivot - 1] und
a[pivot + 1. . . right] angewendet werden.
Der wesentliche Teil des Algorithmus ist also die Prozedur Partition, die nachfolgend in der
Sprache Java angegeben ist.
Algorithmus 3.4 [Partitionierung]
public static int partitionArray(int a[], int left, int right) {
int pivot;
int lindex;
int rindex;
int tmp;
// Wir wollen das Element a[right] an die richtige
// Stelle bringen. Diese Wahl ist willkuerlich. Wir
// koennten ebensogut jedes andere Element waehlen.
pivot = a[right];
lindex = left;
rindex = right - 1;
while(lindex <= rindex) {
// Suchen ab dem Element a[left] aufwaerts, bis wir
// ein Element a[lindex] >= pivot finden.
while(lindex <= rindex && a[lindex] <= pivot) {
3-11
++lindex;
}
// Suchen ab dem Element a[right - 1] abwaerts, bis wir
// ein Element a[rindex] <= pivot finden.
while(rindex >= lindex && a[rindex] > pivot) {
--rindex;
}
// Die Elemente a[lindex] und a[rindex] sind offensichtlich
// nicht am richtigen Platz, also werden sie vertauscht.
if (lindex < rindex) {
temp = a[lindex];
a[lindex] = a[rindex];
a[rindex] = temp;
}
}
// Nun wird der pivot an die richtige Stelle gebracht.
if (lindex < right) {
temp = a[lindex];
a[lindex] = a[right];
a[right] = temp;
}
// Index der Partitionierung
return lindex;
}
In der Abbildung 3-5 wird die Prozedur partition an einem Zahlenbeispiel verdeutlicht.
55
55
22
22
22
22
22
22
22
22
22
20
17
33
77
20
20
20
20
20
20
20
20
17
17
17
17
17
17
17
17
33
33
33
33
33
33
33
33
77
7
7
7
7
7
7
7
22
22
22
22
22
22
22
88
88
2
2
2
2
2
12
12
12
12
12
99
33
99
33
33
33
33
99
99
43
2
7
44
2
88
88
88
88
88
7
77
77
77
77
77
77
77
44
44
44
44
44
44
44
44
22
22
55
55
55
55
55
55
55
55
55
43
43
43
43
43
43
43
43
43
43
99
a[right]=43
suchen i,j
55 ↔ 22
suchen i,j
77 ↔ 7
suchen i,j
88 ↔ 2
suchen i,j
99 ↔ 33
i≥j
99 ↔ 43
Abbildung 3-5: Partitionierungsprozess von Quicksort
Mit Hilfe der Prozedur partition kann nun Quicksort folgendermassen rekursiv implementiert werden.
Algorithmus 3.5 [Quicksort rekursiv]
public static int quickRekursiv(int a[], int left, int right) {
int partitionElement;
// Falls der Teilarray nur aus einem Element besteht gibt
3-12
// es nichts zu tun.
if (left < right) {
// Partitionieren
partitionElement = partitionArray(a, left, right);
// Linker Teil behandeln
quickRekursiv(a, left, partitionElement - 1);
// Rechter Teil behandeln
quickRekursiv(a, partitionElement + 1, right);
}
}
Die Prozedur quicksort in dieser Form hat einen gewaltigen Nachteil. Sind die Elemente im
Array a schon sortiert, so wird der Array durch die Prozedur partition in einem Array der
Länge right - left und einem Array der Länge 0 unterteilt. Dies hat zwei Konsequenzen:
1. Für einen Array der Länge n kann die Prozedur quicksort bis zu n mal aufgerufen
werden. Die Stacktiefe kann also im schlechtesten Fall gleich n werden, was bei einer
grossen Anzahl von Elementen nicht akzeptabel ist.
2. Der Zeitaufwand für das Sortieren eines Arrays der Länge n kann im schlechtesten Fall
≈ n · n2 = O(n2 ) betragen.
Um Quicksort zu verbessern, werden wir noch folgende Änderungen anbringen.
Eliminieren der Rekursion
Wir eliminieren in der Prozedur quicksort die Rekursion mit Hilfe eines Stacks. Dabei
wird nach der Partitionierung immer der kleinere Teilarray weiterverarbeitet und die
Grenzen des grösseren Teilarrays auf den Stack abgelegt. So wird garantiert, dass die
Stacktiefe höchsten 2 · log2 (n) werden kann.
Bessere Wahl des Pivots
Der Zeitaufwand von Quicksort ist am schlechtesten, wenn die Partitionierung immer
einen Teilarray der Länge n − 1 und einen Teilarray der Länge 0 liefert.
Wird als Pivot immer das rechte Element des Arrays gewählt, so tritt der schlechteste
Fall (O(n2 )) auf, wenn die Elemente im Array schon sortiert sind. Dies ist unglücklich, da in der Praxis die Elemente sehr oft schon sortiert (oder fast sortiert) im Array
abgelegt sind. Um diese Problem zu entschärfen kann man als Pivot das mittlere Element des Arrays annehmen. Falls die Elemente schon sortiert sind, so ist das mittlere
Element sogar das beste. Auch mit dieser Wahl des Pivots lassen sich Verteilungen der
Elemente finden, so dass Quicksort die Laufzeit O(n2 ) hat (siehe Übung ??). Im nächsten Abschnitt werden wir noch den randomized Quicksort kennen lernen, deren Laufzeit
unabhängig von der Verteilung der Elemente im Array ist.
Für den moment ändern wir die Prozedur partition nun folgendermassen.
Am Anfang der Prozedur wird das mittlere Element des Arrays mit dem rechten Element
vertauscht.
mid = (left + right) / 2;
temp = a[mid]; a[mid] = a[right]; a[right] = temp
Mit dieser Änderung in der Prozedur partition ist die Wahrscheinlichkeit, dass der
schlechteste Fall auftritt schon viel kleiner.
3-13
3.4.2.1
definitive Version von Quicksort
Nachstehend ist die definitive Version der Prozedur quicksort angegeben. Die verwendete
Prozedur einfuegen sortiert den gewünschten Teil des Arrays durch Einfügen (siehe 3.2).
Algorithmus 3.6 [Quicksort iterativ]
public static void quickSort(int a[]) {
int stack[] = new int[200];
int sp = -1;
int mid;
int le;
int ri;
stack[++sp] = 0;
stack[++sp] = a.length - 1;
while (sp >= 0) {
ri = stack[sp--];
le = stack[sp--];
while ((ri - le) > 0) {
mid = partitionArray(a, le, ri);
// sortieren des kleineren Teils
if ((mid - le) > (ri - mid)) {
// Grenzen des groesseren Teils auf Stack
stack[++sp] = le;
stack[++sp] = mid - 1;
// Weiterfahren mit kleinerem Teil
le = mid + 1;
} else {
// Grenzen des groesseren Teils auf Stack
stack[++sp] = mid + 1;
stack[++sp] = ri;
// Weiterfahren mit kleinerem Teil
ri = mid - 1;
}
}
}
}
3.4.2.2
Komplexität von Quicksort
Wir wollen nun die Anzahl Vergleiche berechnen, die Quicksort im besten Fall benötigt, um
die Elemente eines Arrays der Länge n zu sortieren. Der beste Fall liegt vor, wenn der Array
durch die Prozedur partition in zwei gleich grosse Arrays geteilt wird. Der Einfachheit
halber nehmen wir an, dass n die Form n = 2k − 1 hat. Wir nehmen ferner an, dass die
Partitionierung den Array immer in zwei gleich grosse Teilarrays unterteilt. In diesem Fall
ergibt sich die folgende Rekursionsformel für die Anzahl Vergleiche:
V (k) = 0 für k = 0
V (k) = 2k − 2 + 2 · V (k − 1) für k > 0
3-14
Die Prozedur partition benötigt n − 1 Vergleiche. Zum Sortieren eines Teilarrays werden
V (k − 1) Vergleiche verwendet.
Die obige Rekursionsformel kann nun folgendermassen aufgelöst werden:
V (k) = 2k − 2 + 2 · V (k − 1)
= 2k − 2 + 2 · (2k−1 − 2) + 4 · V (k − 2)
= 2k − 2 + 2k − 4 + 2k − 8 + 8 · V (k − 3)
P
k−1 k
i
V (k) =
i=1 (2 − 2 )P
k−1 i
= (k − 1) · 2k − i=1
2
k
k
= (k − 1) · 2 − 2 − 2
= ⌊log2 (n)⌋(n + 1) − n + 1
Im besten Fall ist also die Komplexität von Quicksort O(n · log2 (n)). Im nächsten Abschnitt
werden wir zeigen, dass die durchschnittliche Komplexität O(n log(n)) beträgt (log(n) ist hier
nicht der Zweierlogarithmus).
3.4.2.3
Randomized Quicksort
Die sicherste Methode um den Pivot auszuwählen ist mit einer Randomfunktion. In diesem Fall
ist die Wahrscheinlichkeit, dass der schlechteste Fall eintrifft bei einer beliebigen Verteilung
der Elemente im Array sehr klein. Wir müssen die Prozedur Partition dann folgendermassen
ändern:
ran = new Random();
.
.
randele = ran.nextInt(rigth - left + 1) + left;
temp = a[randele]; a[randele] = a[right]; a[right] = temp
Wir wollen nun die Komplexität des Algorithmus bestimmen, wenn wir den Pivot zufällig
auswählen.
Satz 3.1 [Laufzeit Quicksort] Die erwartete Laufzeit des randomized Quicksort einer Sequenz der Länge n ist O(n log(n)), falls zwei Elemente von S in der Zeit O(1) verglichen
werden können.
Beweis:
Wir verwenden zum Beweis eine einfache Tatsache der Wahrscheinlichkeitsrechnung: Satz 3.2
[Erwartungswert] Der Erwartungswert der Anzahl Würfe um mit einer Münze k mal Kopf
zu erreichen ist 2k
Wir sagen im folgenden, dass die Partitionierung einer Sequenz von m Elementen bei Quicksort
“gut” ist, wenn die zwei entstehenden Subsequenzen nicht kleiner al m
4 und nicht grösser als
3m
m
sind.
Es
existieren
in
S
Pivots,
die
eine
“gute”
Partition
erzeugen.
Das heisst, die
4
2
1
Wahrscheinlichkeit, dass eine Partitionierung “gut” ist beträgt 2 .
In der Abbildung 3-6 ist der Baum, der durch wiederholte Partitionierung bei Quicksort
entsteht abgebildet. Falls ein Knoten r (mit Länge s(r)) im Baum “gut” partitioniert wird, so
haben die Kinder von r höchstens die Länge s(r)
4 . Falls wir nun irgend einen Weg T von der
3
Wurzel zu einem Blatt des Baumes betrachten, dann ist die Länge von T höchstens so lang
3-15
bis wir log4/3 (n) “gute” Partitionierungen erreicht haben. Nach Lemma 3.2 also höchstens
2 log4/3 (n). Das heisst, dass die Länge von jedem Pfad T von der Wurzel zu einem Blatt
höchstens O(log(n)) ist. Für jede Stufe des Baumes ist der Aufwand für die Partitionierung
aber O(n).
Die erwartete Laufzeit von Quicksort ist also O(n log(n)).
Man kann ferner zeigen, dass Quicksort mit hoher Wahrscheinlichkeit in der Zeit O(n log(n))
abläuft. Diese Wahrscheinlichkeit beträgt 1 − n12 .
Zeit pro Stufe
Erwartete Höhe
s(r)
O(n)
s(a)
O(log(n))
s(c)
s(b)
s(d)
s(e)
O(n)
s(f)
Total erwartete Zeit:
O(n)
O(nlog(n))
Abbildung 3-6: Komplexitätsanalyse von randomized Quicksort
3.5
Externes Sortieren
Unter externem Sortieren versteht man das Sortieren von grossen Dateien auf einem Sekundärspeicher. Im Gegensatz zum internen Sortieren kann nur noch sequentiell auf die Elemente
zugegriffen werden.
3.5.1
Grundoperation Mischen
Unter Mischen versteht man das Zusammenfügen von 2 bis n sortierten Files zu einem sortierten File. Das folgende Verfahren bezeichnet man als “n-Weg-Mischen”:
1. Lese das erste Element der Files 1 bis n in den Elementen 1 bis n eines Arrays im
Hauptspeicher ein und markiere diese Elemente als “besetzt”.
2. Solange noch Elemente mit “besetzt” markiert sind
3. Bestimme den Index m des kleinsten besetzten Elements im Array und schreibe dieses
Element auf das Resultatfile.
4. Falls noch ein Element im File m vorhanden ist, so wird dieses Element eingelesen und
im Element m des Arrays gespeichert, sonst wird das Element m des Arrays mit “nicht
besetzt” markiert.
5. Fahre bei Punkt 2 fort.
Beispiel 3.1 [Mischen] Wir wollen als Beispiel ein 2-Weg Mischen betrachten:
3-16
Ausgangslage
File 1:
78
File 2:
129
Resultatfile: 3
1.Schritt
File 1:
78
File 2:
29
Resultatfile: 1
3.Schritt
File 1:
8
File 2:
9
Resultatfile: 1 2 7
4.Schritt
File 1:
3
File 2:
9
Resultatfile: 1 2 7 8
3.5.2
2.Schritt
File 1:
78
File 2:
9
Resultatfile: 1 2
5.Schritt
File 1:
3
File 2:
3
Resultatfile: 1 2 7 8 9
Ausgeglichenes Mehrweg-Mischen
Mit diesem Algorithmus wird eine beliebige Datei mit Hilfe der Mischoperation sortiert. Der
Algorithmus kann folgendermassen beschrieben werden:
1. Zerlege die Datei in Blöcke, die ungefähr die Grössse des Hauptspeichers haben und
sortiere diese Blöcke. Die Sortierten Blöcke werden auf p Files gleichmässig verteilt
(Sortier-Durchlauf).
2. Mische die ersten Blöcke der p Files zu einem grösseren (sortierten) Block. Wiederhohle
diese Operation für alle Blöcke in den p Files. Die doppelt so grossen Blöcke werden
wieder auf p Files gleichmässig verteilt (Misch-Durchgang).
3. Wiederhole Punkt 2, bis nur noch ein Block vorhanden ist.
Beispiel 3.2 [Ausgeglichenes Mischen] Gegeben sei ein File mit den folgenden Elementen:
11 34 12 55 17 12 53 19 95 13 17 23 25 11 20 30 73 44 61 26 84 87 19 52 15
Wir nehmen an, dass gleichzeitig 3 Elemente sortiert werden können und dass 6 Files zur
Verfügung stehen.
Sortierdurchlauf
File 1: 11 12 34 3 13 17 23 3 26 61 84 3
File 2: 12 17 55 3 11 20 25 3 19 52 87 3
File 3: 19 53 95 3 30 44 73 3 15 3
File 4: 3
File 5: 3
File 6: 3
1. Mischdurchlauf
File 1: 3
File 2: 3
File 3: 3
File 4: 11 12 12 17 19 34 53 55 953
File 5: 11 13 17 20 23 25 30 44 733
File 6: 15 19 26 52 61 84 873
2. Mischdurchlauf
File 1: 11 11 12 12 13 15 17 17 19 19 20 23 25 26 30 34 44 52 53 55 61 73 84 87 95 3
File 2: 3
File 3: 3
File 4: 3
File 5: 3
File 6: 3
3-17
Wir wollen noch die Komplexität des Algorithmus betrachten. Da die Operationen auf einem
Sekundärspeicher (externe Operationen) viel aufwendiger sind als Operationen im Hauptspeicher, betrachten wir nur die externen Operationen. Wir stellen fest, dass bei jedem Durchlauf
(Sortierdurchlauf und Mischdurchläufe) alle Elemente des Ursprünglichen Files einmal gelesen
und einmal geschrieben werden. Die wichtige Grösse für die Komplexität ist also die Anzahl
der benötigten Durchläufe.
Wir nehmen an, dass wir N Elemente haben und im Sortierdurchlauf Blöcke der Länge M
N
Blöcke. Wenn wir nun 2 · p Files zur
entstehen. Nach dem Sortierdurchlauf haben wir also M
N
Verfügung haben, so beträgt die Anzahl der Mischdurchläufe logp ( M
) (bei jedem Durchlauf
wird die Anzahl der Blöcke um den Faktor p verringert).
AnzahlDurchäuf e = 1 + logp (
3.5.3
N
)
M
Replacement Selection
Dieses Verfahren ist sehr ähnlich wie das ausgeglichene Mehrweg-Mischen. Der Unterschied
liegt im Sortier-Durchlauf. Wir verwenden dazu einen Heap (siehe Abschnitt 3.3). Die Prozeduren werden so angepasst, dass zuerst immer das kleinste Element ausgegeben wird. Ferner
wird noch eine Methode replace eingeführt, die das kleinste Element des Heaps mit einem
neuen Element austauscht (ausser wenn das neue Element kleiner ist)
Wir verwenden nun einen Heap der Grösse n (n soll möglichst gross sein) um die sortierten
Blöcke im ersten Durchlauf zu erzeugen. Mit der nachfolgend erklärten Methode können so
Blöcke erzeugt werden die grösser sind als n.
1. Lese n Elemente aus dem File. Die Elemente werden als zum Block 0 gehörend markiert
und im Heap eingefügt. Die Heap-Routinen sind so modifiziert, dass die Priorität der
Elemente sich aus Blocknummer und Wert des Elements zusammensetzt.
2. Lesen des nächsten Elements. Falls das Element kleiner als das zuletzt geschriebene
Element ist, so wird es mit der Nummer des nächsten Blocks markiert, sonst mit der
Nummer des aktuellen Blocks.
3. Mit der Methode replace wird nun das neue Element mit dem kleinsten Element des
Heaps vertauscht.
4. Ist das kleinste Element mit einer grösseren Blocknummer markiert als die aktuelle
Blocknummer, so wird der aktuelle Block abgeschlossen und ein Block mit der neuen
Blocknummer begonnen.
5. Schreibe das Element auf den aktuellen Outputfile.
6. Wiederhole die Punkte 2 bis 5 bis keine Elemente mehr im Input-File vorhanden sind.
Beispiel 3.3 [Replacement Selection] Um das ganze zu illustrieren wollen wir mit einem
Heap der Grösse 3 und zwei Outputfiles arbeiten.
Input: 7 3 9 2 10 8 1 11 4 7
Der Vorgang ist in der Abbildung 3-7 dargestellt.
3-18
Input−Sequenz: 7 3 9 2 10 8 1 11 4 7
0,3
0,3
0,7
0,9
0,7
0,9
0,10
1,1
0: 2 3 7 8
0,7
0,9
0,10
0,9
0: 2
0: 2 3
0,10
0,11
0,11
1,4
1,1
1,7
1: 1
1: 1 4
0,10
0,9
0: 2 3 7
1,1
1,1
0: 2 3 7 8 9 10
0: 2 3 7 8 9
1,4
0,8
1,4
1,7
0: 2 3 7 8 9 10 11
1,7
1: 1 4 7
Abbildung 3-7: Funktionsweise von Replacement Selection
In diesem Beispiel erzeugen wir mit einem Heap der Grösse 3 einen Block der Grösse 7 und
einen Block der Grösse 3. Wir sehen also, dass die Blöcke grösser als der Heap werden können.
Der Vorteil dieser Methode ist, dass die sortierten Blöcke grösser werden als die Grösse des
verwendeten Heaps damit wird die Zahl der Mischdurchgänge verringert.
Für zufällige Schlüssel besitzen die Blöcke, die durch Replacement Selection erzeugt werden,
ungefähr die doppelte Grösse des verwendeten Heaps.
Aus dieser Tatsache können wir die nötige Anzahl Durchläufe berechnen. Falls der File N
Schlüssel enthält, der Hauptspeicher M Schlüssel aufnehmen kann und 2·p Files zur Verfügung
stehen gilt:
N
AnzahlDurchäuf e = 1 + logp (
)
2·M
3.5.4
Mehrphasen-Mischen
Ein Problem beim ausgeglichnenen Mehrweg-Mischen ist die hohe Anzahl verwendeter Files
(dies ist ein Problem, wenn Bandstationen verwendet werden). Aus diesem Grund wurde das
Mehrphasen-Mischen entwickelt.
1. Beim Sortier-Durchlauf werden die sortierten Blöcke ungleichmässig auf die Bänder
verteilt, wobei ein Band leer bleibt.
2. Die Bänder werden nun auf das leere Band gemischt, bis ein anderes Band am Ende
angelangt ist. Dieses Band wid zurückgespult und wird zum neuen Output-Band.
3. Das Band auf dem gemischt wurde wird zurückgespult und wird zu einem Input-Band.
4. Die Punkte 2 und 3 werden wiederholt bis nur noch ein Block existiert.
3-19
Das Problem bei diesem Algorithmus ist, die richtige Anfangsverteilung der Blöcke zu finden.
Das folgende Beispiel soll dies illustrieren.
Beispiel 3.4 [Mehrphasen-Mischen]
Nach dem Sortierdurchgang entstehen 193 Blöcke und wir verwenden 4 Bänder. Die nachfolgende Tabelle zeigt, wie diese Blöcke am Anfang verteilt sein müssen, damit das Ganze
aufgeht.
Band
Band
Band
Band
1:
2:
3:
4:
81
68
44
0
37
24
0
44
13
0
24
20
0
13
11
7
7
6
4
0
3
2
0
4
1
0
2
2
0
1
1
1
1
0
0
0
Die Frage ist nun, wie die Anfangsverteilung gewählt werden muss. Es ist einfach die Werte
der obigen Tabelle zu erhalten, wenn man rückwärts vorgeht. Wenn eine Spalte gegeben ist,
kann die vorhergehende Spalte folgendermassen berechnet werden:
Man wähle die grösste Zahl in der Spalte, mache sie zu Null und addiere sie zu
allen anderen Elementen in der Spalte.
Diese Methode ist für eine beliebige Anzahl Bänder anwendbar. Die Schwierigkeit besteht
darin, dass am Anfang nicht unbedingt bekannt ist, wieviele Blöcke entstehen. Ferner wird
diese Anzahl wahrscheinlich nicht genau aufgehen. Daher müssen “Pseudoblöcke” hinzugefügt
werden, damit die Zahl der Anfangsblöcke genau den gemäss der Tabelle benötigten Wert hat.
Die durch die Tabelle erzeugten Zahlen sind “verallgemeinerte Fibonacci-Zahlen” (Bei drei
Bändern entstehen die “normalen Fibonacci-Zahlen”).
einen Array so, dass das Laufzeitverhalten dieses Algorithmus Θ(n2 ) wird?
3-20
Kapitel 4
Suchalgorithmen
Suchen ist in der Informatik eine sehr häufige Aufgabe. Es geht darum, in einer gegebenen Menge von Elementen, Elemente nach einem bestimmten Merkmal (Schlüssel) zu finden.
Neben dem Schlüssel sind in jedem Element noch weitere Informationen (oder Zeiger auf
Informationen) gespeichert.
4.1
Suchstruktur
In diesem Abschnitt untersuchen wir Suchalgorithmen, welche nach vollständigen Schlüssel
suchen. Es ist dabei nicht möglich die Elemente in sortierter Reihenfolge zu lesen.
Beispiel 4.1 [Anwendungen] Suchstrukturen können in den folgenden Anwendungen verwendet werden.
Compiler
Ein C-Compiler muss alle deklarierten Variablen und Funktionen kennen, damit getestet
werden kann, ob Operationen auf diesen Objekten erlaubt sind oder nicht. Der Compiler
baut eine Liste von Namen auf und speichert dazu Zusatzinformationen. Jedesmal wenn
eine Variable oder Funktion im Code verwendet wird, muss diese über ihren Namen
schnell in der Tabelle gefunden werden können.
Datenbanken
Das bekannteste Beispiel einer Suchstruktur sind sicher moderne Datenbanken. Meistens
können dort Objekte nicht nur über einen Schlüssel gesucht werden, sondern über alle
Merkmale des Objekts (Bsp. SQL).
Die Suchstruktur kann als abstrakter Datentyp definiert werden. Der ADT muss mindestens
die folgenden Dienstleistungen zur Verfügung stellen.
• Einfügen eines neuen Elements mit den Daten ’data’ und dem Schlüssel ’key’. Ein Schlüssel darf nur einmal eingetragen werden.
• Finden eines Elements, das den Schlüssel ’key’ besitzt und Lesen der dazugehörenden
Daten.
• Löschen eines mit ’key’ gefundenen Elements aus der Struktur.
4-1
4.1.1
Spezifikation der Suchstruktur in Java
/**
* Dieses Interface definiert die Schnittstelle zu einer
* allgemeinen Suchstruktur ohne Ordnung der Elemente
* (assoziative Menge).
*/
interface SuchStruct<Key, Info> {
/**
* Testet ob die Suchstruktur voll ist.
*
* <pre>
*
Preconditions: none
*
Postconditions: none
* </pre>
*
* @return true falls kein platz mehr in der Struktur
*/
public boolean full();
/**
* Gibt die Anzahl Elemente in der Suchstruktur
* zurück.
*
* <pre>
*
Preconditions: none
*
Postconditions: none
* </pre>
*
* @return Anzahl der Elemente in der Struktur
*/
public int cardinality();
/**
* Sucht einen Schlüssel in der SuchStruktur
*
* <pre>
*
Preconditions: none
*
Postconditions: none
* </pre>
*
* @param key
*
dieser Schluessel wird gesucht
* @return true falls der Schluessel gefunden wurde
*
*/
public boolean search(Key key);
/**
4-2
* Sucht einen Schlüssel in der SuchStruktur
* und gibt die assozierten Daten zurück.
*
* <pre>
*
Preconditions: old.search() = true;
*
Postconditions: none
* </pre>
*
* @param key
*
dieser Schluessel wird gesucht
* @return die zu diesem Schlüssel
*
gespeicherte Information.
*
* @throws HTE.NotInTableException
*
Falls der Schlüssel nicht existiert
*/
public Info getInfo(Key key)
throws HTE.NotInTableException;
/**
* Löscht einen Schlüssel aus der
* Suchstruktur.
*
* <pre>
*
Preconditions: old.search(key) == true
*
Postconditions: new.search(key) == false
* </pre>
*
* @throws HTE.NotInTableException
*
Falls der Schlüssel nicht existiert
*/
public void remove(Key key)
throws HTE.NotInTableException;
/**
* Einfügen eines neuen Schlüssels.
*
* <pre>
*
Preconditions: old.search(key) == false
*
old.full == false
*
Postconditions: new.search(key) == true
* </pre>
*
* @param key
*
dieser Schluessel wird eingefuegt
* @param data
*
Information zum Schluessel
* @throws HTE.InTableException
*
Falls der Schlüssel schon vorhanden
* @throws HTE.TableFullException
4-3
*
Falls die Struktur voll ist
*/
public void insert(Key key, Info data)
throws HTE.InTableException,
HTE.TableFullException;
}
4.1.2
Implementation als Hashtabelle
Die Idee vom Hashing ist in der Abbildung 4-1 dargestellt. Man versucht, durch eine arithmetische Transformation des Schlüssels direkt einen Index in einer Tabelle zu berechnen, in
der die gesuchten Elemente gespeichert sind. Wir wollen nun das Problem genauer definieren
und die wichtigsten Begriffe einführen.
Hashtabelle
Key
Pointer auf
Daten
0
1
Hashfunktion
Keymenge
2
3
N−2
N−1
Hashadressen
Abbildung 4-1: Hashing (Schlüsseltransformation)
Menge der möglichen Schlüssel KeyM enge
Die Art und Anzahl der möglichen Schlüssel ist von der Applikation abhängig. Die
Anzahl Elemente in dieser Menge kann sehr gross werden. Nehmen wir als Beispiel die
Menge aller Schlüssel, die genau aus 10 grossen Buchstaben bestehen, so sind das 2610
Schlüssel.
Die Hashtabelle HashT able
Dies ist eine Tabelle (Array), die eine vordefinierte Anzahl Schlüssel aufnehmen kann.
Im Gegensatz zu Bäumen kann die Hashtabelle nicht dynamisch vergrössert werden.
Die Hashfunktion
Die Hashfunktion ist eine Abbildung:
Hash : KeyM enge 7→ {0, 1, 2 . . . T abellenplätze − 1}
4-4
Die Funktion Hash bildet also einen Schlüssel k ∈ KeyM enge auf einen Index der
Hashtabelle ab. Der Wert der Funktion Hash(k) heisst die Hashadresse des Schlüssels
k.
Kollisionen
Wir haben schon gesehen, dass die Schlüsselmenge im allgemeinen sehr gross ist. Aus
diesem Grund ist die Anzahl der Tabellenplätze im allgemeinen viel kleiner als die
Anzahl der möglichen Schlüssel. Daraus folgt, dass die Hashfunktion nicht eineindeutig
ist. D.h., verschiedene Schlüssel können auf dieselbe Hashadresse abgebildet werden.
Formal ausgedrückt:
∃k1 , k2 ∈ KeyM enge : k1 6= k2 ∧ Hash(k1 ) = Hash(k2 )
Eine solche Situation wird als Kollision bezeichnet. Die Schlüssel k1 und k2 heissen Synonyme bezüglich der Funktion Hash. Beim Auftreten einer Kollision müssen trotzdem
beide Schlüssel in die Tabelle eingetragen werden. Die Art und Weise wie dieses Problem
gelöst wird, hat einen grossen Einfluss auf die Effizienz (und auf die Implementation)
des Suchprozesses.
4.1.2.1
Die Wahl der Hashfunktion
Die Hashfunktion muss leicht zu berechnen sein, die Zahl der Kollisionen minimieren und
die Schlüssel möglichst gut auf die ganze Tabelle verteilen. Damit diese Forderungen erfüllt
werden können, muss die Funktion den zwei folgenden Kriterien genügen:
1. Der Funktionswert muss von allen Zeichen des Schlüssels abhängig sein. Eine Funktion, die etwa nur vom ersten Zeichen des Schlüssels abhängt, wird viele Kollisionen
verursachen.
2. Die Hashfunktion muss die Schlüssel gleichmässig über den gegebenen Indexbereich der
Tabelle verteilen. D.h., wird k ∈ KeyM enge zufällig ausgewählt, so ist die Wahrscheinlichkeit, dass Hash(k) = i ist, gleich 1/T abellenplätze.
Die Erfahrung hat gezeigt, dass die Wahl der Hashfunktion für die Effizienz der Suche viel
weniger wichtig ist, als die Wahl der Strategie zur Behandlung von Kollisionen. Es genügt
daher, eine Funktion zu wählen, die den oben genannten Kriterien genügt.
Wir können die Evaluation der Hashfunktion in zwei Operationen aufteilen. Die erste Operation besteht darin, den Schlüssel auf eine Integerzahl abzubilden, den sogenannten Hashcode.
Die zweite Operation besteht darin, den Hashcode auf einen Index abzubilden, der im Range
unserer Hashtabelle liegt. Diese Operation heisst Kompression. Dieser Sachverhalt ist in der
Abbildung 4-2 dargestellt.
Als erstes müssen wir also eine Funktion finden, die die Schlüssel auf eine ganze Zahl abbildet
(kann auch negativ sein). Wir möchten aber erreichen, dass bei der Berechnung des Hashcodes
möglichst wenige Kollisionen auftreten.
Summieren der Componenten
Wir können schliesslich jeden Key als k-Tuple (x0 , x1 , . . . , xk−1 ) von Integers (oder von Bytes) auffassen. Nun können wir alle diese Zahlen Zusammenzählen und erhalten als Hashcode
4-5
Beliebige Schlüssel
Hashcode
−2 −1 0 1 2
Kompression
0
N−1
Abbildung 4-2: Die beiden Operationen der Hashfunktion
k−1
X
xi
i=0
Diese Methode ist für Strings nicht sehr gut, da die Reihenfolge der Zeichen nicht berücksichtigt wird. Das heisst, die Strings temp01 und temp10 erhalten den gleichen Hashcode. In
der Englischen sprache kollidieren alle folgenden Wörter stop, tops, pots und spot.
Polynomialer Hashcode
Ein besserer Hashcode erhält man, wenn wir die Reihenfolge der der xi ’s im k-Tuple (x0 , . . . , xk−1 )
berücksichtigen. Dies kann man erreichen, indem man eine Konstante a ≥ 1 wählt und den
Hashcode folgendermassen berechnet:
hc = x0 ak−1 + x1 ak−2 + · · · + xk−2 a + xk−1
Nach Horn’s Regel kann dies folgendermassen umgeschrieben werden:
hc = xk−1 + a(xk−2 + a(xk−3 + · · · + a(x2 + a(x1 + ax0 ))...))
Mathematisch gesehen ist das die Auswertung eines Polynoms mit den Koeffizienten xk−1 . . . x0
an der Stelle a. Daher heisst dieser Code auch polynomialer Hashcode.
Beispiel 4.2 [Berechnen der Hashfunktion]
key = "HANS"
a = 33
Wenn wir den ASCII-Code verwenden gilt nun (x0 , x1 , x2 , x3 ) = (72, 65, 78, 83).
Mit diesen Angaben können wir nun den Hashcode für den Schlüssel "HANS"
besrechnen:
hc = 83 + 33(78 + 33(65 + 33 · 72)) = 2660906
Bemerkung 4.1 [Wahl der Basis] Die Frage ist nun, welchen Wert wir für a wählen sollen.
Die Java-Methode hashCode() der Klasse String setzt a = 31. Im Buch von Goodrich und
Tamassia [GT04] wird angegeben, dass sowohl 33, 37, 39 wie 41 eine sehr gute Wahl für a
darstellen. In einem Experiment wurde der Hashcode von 50’000 verschiedenen Englischwörter
berechnet. Mit der Wahl von a als 33, 37, 39 oder 41 gab es in allen Fällen weniger als 7
Kollisionen.
4-6
4.1.2.2
Die Kompressionsfunktion
Wir müssen nun noch betrachten, wie der Hashcode auf die Menge der Indizes der Hashtabelle
abgebildet wird. Im allgemeinen werden hier auch Kollisionen auftreten, da die Tabelle kleiner
ist als das ganze Intervall des Hashcodes.
Die Divisionsmethode
Für die Kompression des Hashcodes (hc) können wir die folgende einfache Funktion anwenden
h(hc) = |hc| mod N
dies ist als Divisionsmethode bekannt.
Bei der Divisionsmethode ist die Wahl der Tabellenlänge N kritisch. Eine wirklich gute Verteilung der Haschcodes auf das Intervall [0 . . . N − 1] erhält man nur, wenn N eine Primzahl
ist. Falls N nicht prim ist, entsteht eine erhöte Wahrscheinlichkeit, dass sich Muster in der
Verteilung der Zahlen ergeben und das führt zu einer erhöten Anzahl von Kollisionen.
Beispiel 4.3 [Kollisionen] Die Tabellenlänge sei N = 100 und die Menge von Hashcodes sei
{200, 205, 215, 220, . . . , 600}. In diesem Fall wird jeder Hashcode mit drei anderen Hashcodes
kollidieren.
Wählen wir hingegen N = 101 (eine Primzahl), so entsteht überhaupt keine Kollision.
Auch wenn N eine Primzahl ist haben wir nicht die Garantie, dass die Verteilung immer gut
ist. Falls wir beim Hashcode viele Zahlen der Form i · N + j für viele i’s haben, so gibt es
immer noch viele Kollisionen.
Die MAD-Methode
MAD steht für multiply add and divide und ist eine etwas bessere Methode den Hashcode zu
komprimieren. Mit dieser Methode werden sich wiederhohlende Muster in einer Menge von
ganzen Zahlen besser behandelt. Für die Kompression verwenden wir die Funktion.
h(hc) = |a · hc + b| mod N
Dabei ist N eine Primzahl und a und b sind positive ganze Zahlen, die bei der Bestimmung
der Funktion zufällig ausgewählt werden. Natürlich muss (a mod N 6= 0) gelten, da sonst der
Wert der Funktion immer (b mod N ) ist. Diese Funktion verteilt die Werte einer Menge von
Hashcodes fast gleichmässig auf das Intervall [0, N − 1].
4.1.2.3
Behandlung von Kollisionen
Beim Einfügen eines neuen Elements in die Hashtabelle kann es vorkommen, dass an der
berechneten Hashadresse schon ein anderer Schlüssel gespeichert ist (Kollision). In diesem Fall
muss der Schlüssel an einer anderen Stelle in der Tabelle eingetragen werden. Die Strategie zur
Behandlung der Kollisionen bestimmt die Algorithmen für das Suchen, Einfügen und Löschen
in der Tabelle. Nachfolgend sind zwei verschiedene Methoden beschrieben:
4-7
Separat chaining (direkte Verkettung)
Bei dieser Methode wird in jedem Element der Hashtabelle neben dem Schlüssel ein Anker
für eine verkettete Liste gespeichert. In der verketteten Liste werden alle Synonyme, des
in der Tabelle gespeicherten Schlüssels abgelegt. Separat chaining ist in der Abbildung 4-3
dargestellt.
Hash(Fritz) = Hash(Paul) = Hash(Hans) = Hash(Peter)
Fritz
Paul
Gerda
Sami
Hans
Peter
Hash(Gerda) = Hash(Sami)
Hash(Carmen) = Hash(Gina) = Hash(Herbert)
Carmen
Gina
Herbert
Abbildung 4-3: Hashing mit separat chaining
Einfügen des Schlüssels k: Die Hashadresse Hash(k) wird berechnet. Ist der Tabellenplatz noch leer, so wird der Schlüssel dort eingetragen. Sonst wird der neue Schlüssel
am Ende der verketteten Liste angehängt.
Suchen des Schlüssels k: Die Hashadresse Hash(k) wird berechnet. Ist der Schlüssel k in
diesem Tabellenplatz gespeichert, so ist die Suche erfolgreich beendet. Sonst wird k mit
den Schlüsseln in der entsprechenden Liste verglichen, bis k gefunden wird oder das
Ende der Liste erreicht ist.
Löschen des Schlüssels k: Beim Löschen eines Schlüssel sind zwei Fälle möglich:
1. k ist in der verketetten Liste gespeichert. In diesem Fall wird dieses Element aus
der Liste gelöscht.
2. k ist in der Hashtabelle gespeichert. In diesm Fall wird, falls vorhanden, das erste
Element der verketteten Liste in die Tabelle kopiert und aus der verketteten Liste
entfernt.
double hashing
Bei double hashing verwenden wir zwei verschiedene Hashfunktionen Hash1 und Hash2 .
Hash1 dient dazu, die Hashadresse eines Schlüssels in der Tabelle zu Suchen. Hash2 dient
dazu, bei einer Kollision, in der Tabelle den nächsten freien Platz zu suchen. Double hashing
ist in der Abbildung 4-4 veranschaulicht.
Einfügen des Schlüssels k: Die Hashadresse Hash1 (k) wird berechnet. Ist dieser Tabellenplatz noch leer, so wird der Schlüssel dort eingetragen. Ist der Tabellenplatz schon
besetzt, so wird nacheinander bei den Adressen
4-8
Hash(Fritz) = Hash(Paul) = A0
A0
Fritz
(A0 + 1 * Hash2(Paul) mod N
Gerda
(A0 + 2 * Hash2(Paul) mod N
Paul
Carmen
Abbildung 4-4: Double hashing
(Hash1 (k) + Hash2 (k)) mod N, (Hash1 (k) + 2 · Hash2 (k)) mod N, . . .
gesucht, bis ein freier Platz gefunden wird (N = Länge der Tabelle). An dieser Stelle
wird nun der Schlüssel eingetragen. Die Suche nach einem freien Platz wird abgebrochen,
wenn (Hash1 (k)+n·Hash2 (k)) mod N = Hasch1 (k) gilt. In diesem Fall ist die Tabelle
voll, und der Schlüssel kann nicht mehr eingetragen werden. Damit bei der Suche nach
einem freien Platz alle Elemente der Tabelle durchsucht werden, muss Hash2 (k) für alle
k relativ prim zu N sein (d.h. Hash2 (k) und N haben keine gemeinsamen Primfaktoren).
Suchen des Schlüssels k: Die Hashadresse Hash1 (k) wird berechnet. Ist der Schlüssel k an
dieser Adresse gespeichert, ist die Suche erfolgreich. Falls nicht, wird der Wert Hash2 (k)
berechnet und der Schlüssel in den Tabellenplätzen
(Hash1 (k) + Hash2 (k)) mod N, (Hash1 (k) + 2 · Hash2 (k)) mod N, . . .
gesucht. Die Suche wird durch eine der drei folgenden Bedingungen abgebrochen:
• Der Schlüssel wird gefunden. Die Suche kann erfolgreich abgeschlossen werden.
• Ein leerer Tabellenplatz wird gefunden. Die Suche ist erfolglos.
• Es gilt:
(Hash1 (k) + n · Hash2 (k)) mod N = Hash1 (k))
Die Suche ist erfolglos.
4-9
Hash(Fritz) = Hash(Paul) = A0
A0
Fritz
(A0 + 1 * Hash2(Paul) mod N
Gerda
(A0 + 2 * Hash2(Paul) mod N
Gelöscht
Weitersuchen
Carmen
(A0 + 3 * Hash2(Paul) mod N
leer
Platz leer
Paul nicht gefunden
Abbildung 4-5: Suchen mit double hashing
Damit ein in der Tabelle eingetragener Schlüssel in jedem Fall gefunden wird, müssen wir
folgendes beachten: Seien k1 6= k2 zwei Schlüssel in der Hashtabelle. k1 sei an der Adresse
Hash1 (k2 ) und k2 an der Adresse (Hash1 (k2 )+Hash2 (k2 )) mod N ) eingetragen. Wird
nun der Schlüssel k1 aus der Hashtabelle gelöscht, so wird k2 nicht mehr gefunden, weil
der Tabellenplatz Hash1 (k2 ) jetzt leer ist. Um diese Situation zu vermeiden, muss jeder
Tabelleneintrag einen Flag besitzen, der angibt, ob ein Eintrag leer, benutzt oder gelöscht
ist. Die oben beschriebene Suche darf dann nur bei einem leeren Element abgebrochen
werden. Diese Situation ist in der Abbildung 4-5 dargestellt.
Löschen des Schlüssels k: Beim Löschen eines Schlüssels aus der Tabelle muss nur der
Tabellenplatz mit gelöscht markiert werden.
Zum Schluss wollen wir noch mögliche Funktionen Hash1 und Hash2 angeben. Die Funktion Hash1 wird wieder mit dem polynomialen Haschcode und der Divisionsmethode zur
Kompression berechnet.
Für die Funktion Hash2 wollen wir zwei Möglichkeiten betrachten:
1. Hash2 (k) = 1 ∀k ∈ KeyM enge
In diesem Fall spricht man nicht mehr von double hashing sondern von linear probing
(lineares Sondieren). Bei dieser Wahl von Hash2 tendieren die Schlüssel dazu, in der
Tabelle in Bündeln aufzutreten. Die Verteilung der Schlüssel in der Tabelle ist nicht
gleichmässig. Zu grosse Bündel erhöhen die Suchzeit eines Schlüssels wesentlich.
2. Hash2 (k) = (N − 2) − (|poly(k)| mod (N − 2))
Dabei bedeutet poly(k) die Berechnung des polynomialen Hashcodes von k. Diese Funktion hat sich in der Praxis bewährt. Falls die Tabellenlänge N eine Primzahl ist, sind
4-10
Hash2 (k) und N für alle k relativ prim zueinander (Hash2 (k) < N ).
Die Idee ist die folgende: Falls k1 und k2 bezüglich Hash1 Synonyme sind, so gilt im
allgemeinen Hash2 (k1 ) 6= Hash2 (k2 ) d.h., bezüglich Hash2 sind die Schlüssel keine
Synonyme. Dies verhindert die Bildung von Bündeln wie sie beim linearen Sondieren
auftreten.
Vergleich der Methoden
Am Ende wollen wir die drei Methoden separat chaining, linear probing und double hashing
bezüglich ihrer Effizienz vergleichen. Die Anzahl Vergleiche, die nötig sind um einen Schlüssel
zu suchen, sind vom Loadfactor α der Tabelle abhängig. Ist N die Grösse der Tabelle und M
die Anzahl gespeicherter Schlüssel, so definieren wir:
α = M/N
Bei linear probing und double hashing gilt immer α ≤ 1 für separat chaining kann α auch
grösser als 1 werden. In der nächsten Tabelle sind die durchnittlichen Anzahl Vergleiche für
die drei Methoden angegeben. Wir unterscheiden noch zwischen erfolgreichem und erfolglosem
Suchen eines Elements.
Methode
Separat chaining
Linear probing
Erfolglose Suche
1+α
1
1
2 (1 + (1−α)2 ) f alls α 6= 1
Erfolgreiche Suche
1 + α2
1
1
2 (1 + (1−α) ) f alls α 6= 1
Double hashing
1
1−α
f alls α 6= 1
− ln(1−α)
α
f alls α 6= 1
Beispiel 4.4 [Aufwand beim Suchen] Eine Hashtabelle sei zu 90% gefüllt d.h. α = 0.9.
Berechnen wir die nötigen Anzahl Vergleiche für eine erfolglose Suche nach obigen Formeln
erhalten wir:
Separat chaining
Linear probing
Double hashing
1.9
50.5
10.0
Ganz klar werden linear probing und double hashing für α ≈ 1 immer schlechter. In solchen
Fällen muss die ganze Hashtabelle vergrössert und dann neu aufgebaut werden.
4.1.3
Universelles Hashing
In diesem Abschnitt wollen wir noch Familien von universellen Hashfunktionen betrachten.
Im folgenden setzten wir immer voraus, dass die Kollisionsbehndlung mittels separat chaining
geschieht.
Für ein gutes Hashing sind die folgenden Eigenschaften wichtig:
1. Die Schlüssel sollen gut (regelmässig) in der Hashtabelle verteilt sein, damit möglichst
wenig Kollisionen entstehen.
2. Falls in einer Tabelle n Schlüssel gespeichert werden, sollte die Tabellengrösse N = O(n)
sein, damit nicht zu viel Platz verlorengeht.
3. Die Hashfunktion ist in Zeit O(1) berechenbar.
4-11
Mit den obigen Voraussetzungen ist die Komplexität der Operationen (Suchen, Einfügen,
Löschen) eines Element x gleich O(Länge der Liste beih(x)). Wir wollen also analysieren, wie
gross die Listen werden.
Intuition: Eine gute Verteilung der Schlüssel in der Tabelle wird mit Hilfe eines Randomgenerators erreicht. Dieser Ansatz ist aber nicht möglich, da wir dann die Schlüssel nie mehr
finden könnten. Also muss man versuchen die Hashfunktion h so zu wählen, dass diese “pseudorandom” ist.
Sei U die Menge der möglichen Schlüssel, M die Anzahl Schlüssel in der Tabelle und N die
Grösse der Tabelle, dann gilt der folgende Satz:
Satz 4.1 [Kollisionen] Für eine beliebige Hashfunktion h gilt:
Falls |U | ≥ (M − 1)N + 1 ist, so existiert eine Menge S ⊂ U mit |S| = M , dessen Elemente
alle dieselbe Hashadresse besitzen.
Aus diesem Grund erscheint Hashing oft misteriös. Wie kann man behaupten Hashing sei gut,
wenn jede Hashfunktion durch eine geschikte Wahl der Schlüssel “überlistet” werden kann?
Um dieses Problem zu lösen und zu garantieren, dass das Hashing für alle Schlüsselmengen
S gut funktioniert benutzen wir die Wahrscheinlichkeit in analogie zu randomized quicksort
(siehe 3.4.2.3). Wir werden Randomnumber in der Konstruktion der Hashfunktion h verwenden (h wird dabei natürlich eine deterministische Funktion sein). Wir werden zeigen, dass für
jede Menge S von Schlüsseln und einer mit Randomnumbers konstruierten Funktion h der
Erwartungswert des Resultats gut ist. Dies ist die Idee vom universellen Hashing.
4.1.3.1
Universelle Familie von Hashfunktionen
Nun das ganze etwas formaler. Als erstes nehmen wir ohne Einschränkung der Allgemeinheit, dass die Schlüssel ganze Zahlen im Bereich U = [0, K − 1] sind. Die entsprechenden
Hashfunktionen sind dann Funktionen der Form h : [0, K − 1] → [0, N − 1].
Definition 4.1 [Universelle Hashfunktion] Eine Menge H von Hashfunktionen der Form
h : [0, K − 1] → [0, N − 1] heisst universell (oder universelle Familie von Funktionen), falls
für alle j, k ∈ [0, K − 1] mit j 6= k und für eine zufällig aus H gewählte Funktion gilt:
1
P r(h(j) = h(k)) ≤
N
P r steht für “Probability”.
Satz 4.2 [Erwartungswert von Kollisionen] Falls H eine universelle Familie von Hashfunktionen ist, so gilt für jede Teilmenge S ⊆ U mit |S| = M , jedes Element x ∈ U und einer
zufällig aus H ausgewählten Funktion h, dass der Erwartungswert einer Kollision von x mit
anderen Elementen aus S höchstens M
N.
Beweis:
• Sei Cxy = 1 falls x und y kollidieren sonst 0.
P
• Cx sei die Summe aller Kollisionen von x also gilt Cx = y∈S,x6=y Cxy .
• Weil H universell ist gilt, dass E[Cxy ] = P r(x und y kollidieren) ≤ N1
P
• Die Linearität des Erwartungswertes ergibt: E[Cx ] = y∈S,x6=y E[Cxy ] ≤
M
N
Aus Satz 4.2 folgt sofort, dass das Suchen eines Schlüssels O(1 + M
N ) Zeit braucht. Dies weil
der Aufwand für das Suchen eines Schlüssels proportional zur Anzahl Kollisionen ist plus die
konstante Zeit für die Berechnung von h. Das Suchen eines Schlüssels ist also proportional
zum Loadfactor α = M
N und somit also konstant für M = N .
4-12
4.1.3.2
Konstruktion einer Familie von Hashfunktionen
Wir müssen noch zeigen, wir eine universelle Familie von Hashfunktionen konstruieren können.
Wir verwenden dazu das Matrixverfahren
Wir nehmen an, dass die Länge der Keys u−Bits ist und dass die Länge N der Hashtabelle
eine Potenz von zwei ist es gilt also N = 2b . Wir wählen die Funktion h als eine zufällige
u × b Matrix von 0 und 1. Dann ist h(x) = h~x. Dabei werden die Additionen modulo zwei
durchgeführt. Die Menge H wird von allen u × b (0/1) Matritzen gebildet.
Beispiel 4.5 [Hashfunktion]



1 0 0 0 


 0 1 1 1 

1 1 1 0
1
0
1
0



1


 
= 1 

0
Satz 4.3 [Universell] Für h ∈ H und x, y ∈ U mit x 6= y gilt:
1
1
= b
N
2
Anders ausgedrückt: H ist eine universelle Familie von Hashfunktionen.
P r[h(x) = h(y)] =
Beweis: Die Multiplikation h~x kann auch berechnet werden indem alle Kolonnen
von h, für die die entsprechende Komponente in ~x 1 ist modulo 2 addiert werden.
Im Beispiel 4.5 werden also die Kolonnen 1 un 3 addiert.
Wir betrachten nun x, y ∈ U mit x 6= y. i sei die erste Komponente wo x un y nicht
übereinstimmen. Wir nehmen o.B.d.A. an, dass xi = 0 und yi = 1. Nun wählen
wir alle Kolonnen von h ausser dert i-ten aus. Bei der Wahl der i − ten Kolonne
ändert aber h(x) nicht da xi = 0. Aber jede von den 2b Möglichkeiten die Werte
der i-ten Kolonne zu setzen liefert einen anderen Wert für h(y) (jedes mal wenn
ein Bit in der i-ten Kolonne gedreht wird, wird das entsprechende Bit im Resultat
auch gedreht). Daher ist die Wahrscheinlichkeit, dass h(x) = h(y) gleich 21b = N1 .
Bemerkung 4.2 [Andere universelle Familie von Hashfunktionen] Die folgende Menge
H von Hashfunktionen ist auch universell. p sei eine Primzahl mit |U | ≤ p < 2|U | wir definieren
nun die folgende Familie von Funktionen.
ha,b (x) = ((ax + b) mod p)mod N
Die Menge H schliesslich ist folgendermassen definiert und ist universell.
H = {ha,b |0 < a < p ∧ 0 ≤ b < p}
4.2
Sortiertes Suchen
In diesem Abschnitt wird das Suchen um eine weitere Funktion erweitert. Wir möchten in
der Lage sein, alle Elemente der Suchstruktur nach Schlüssel sortiert zu lesen. Es soll auch
möglich sein, das sortierte Lesen ab einem gewünschten Schlüssel zu beginnen. Der Unterschied
zur Struktur aus dem Abschnitt 4.1 sind die beiden Funktionen reset und nextkey. Dabei
positioniert die Funktion reset das Lesen auf den kleinsten Schlüssel. Die Funktion nextkey
positioniert auf den nächst grösseren Schlüssel.
4-13
4.2.1
Java Spezifikation der sortierten Suchstruktur
public interface SortSuch<Key extends Comparable<Key>, Info> {
/**
* Suchen eines Elements mit Hilfe eines Keys.
*
* <pre>
* Precond: none
* Postcond: Falls das Element in der Suchstrucktur
*
new.found() == true
*
new.readKey() == sk
*
sonst
*
new.found() == false
* </pre>
*
* @param sk der gesuchte Key.
* @return true falls das Element in der Suchstruktur ist.
*/
public boolean search(Key sk);
/**
* Nächstes Element lesen.
*
* <pre>
* Precond: old.found() == true
* Postcond: Falls new.found() == true
*
new.readKey() > old.readKey()
*
kein key zwischen new und old
*
sonst
*
old.readKey() ist groesster Key
* </pre>
*
* @return true falls noch weitere Elemente vorhanden
* @throws BTE.NotFoundException
*
Falls keine Position existiert.
*/
public boolean nextKey() throws BTE.NotFoundException;
/**
* Positionieren auf das kleinste Element
*
* <pre>
* Precond: none
* Postcond: Falls new.found() == true
*
new.readKey() ist kleinster key
*
sonst
*
Keine Elemente in der Suchstruktur
* </pre>
*
4-14
* @return false falls der Baum leer ist.
*/
public boolean reset();
/**
* Gibt die Anzahl Elemente zurück.
*
* <pre>
* Precond: none
* Postcond: none
* </pre>
*
* @return Anzahl Schluessel in der Struktur
*/
public int getKeyCount();
/**
* Testet ob ein Element positioniert ist.
*
* <pre>
* Precond: none
* Postcond: none
* </pre>
*
* @return true falls ein key mit search(), nextKey()
*
oder reset() gefunden wurde.
*/
public boolean found();
/**
* Einfügen eines neuen Elements mit
* gegebenem Key.
*
* <pre>
* Precond: old.search(key) == false
* Postcond: new.search(key) == true
*
new.found() == false
* </pre>
*
* @param key
der Key des neuen Elements
* @param data Nutzdaten des neuen Elements
* @throws BTE.InTreeException
*
Falls der Key schon im Baum ist.
*/
public void insert(Key key, Info data)
throws BTE.InTreeException;
/**
* Löschen eines Elementes mit gegebenem Key.
*
4-15
* <pre>
* Precond: old.search(key) == true
* Postcond: new.search(key) == false
*
new.found() == false
* </pre>
*
* @param key Der Key, der gelöscht werden soll.
* @throws BTE.NotInTreeException
*
Falls der Key nicht im Baum ist.
*/
public void remove(Key key)
throws BTE.NotInTreeException;
/**
* Lesen der Information zum aktuell positionierten
* Element.
*
* <pre>
* Precond: old.found() == true
* Postcond: none
* </pre>
*
* @return Object: Informationen zum aktuellen Element
* @throws BTE.NotFoundException
Falls keine Position existiert.
*/
public Info readInfo() throws BTE.NotFoundException;
/**
* Lesen des Keys zum aktuell positionierten Element.
*
* <pre>
*
Precond: old.found() == true
*
Postcond: none
* </pre> * *
*
* @return Aktueller Key
* @throws BTE.NotFoundException
*
Falls keine Position existiert.
*/
public Key readKey() throws BTE.NotFoundException;
}
4.2.2
Implementation als sortierter Array
Falls das sortierte Lesen der Schlüssel gefordert wird, ist die Hashtabelle sicher keine geeignete
Implementation. Die Sortierung in der Hashtabelle hat nichts mit der üblichen Sortierung zu
tun. Für das sortierte Suchen müssen wir eine andere Implementation wählen.
Die Elemente werden nach Schlüssel sortiert in einem Array gespeichert. Für die Implemen4-16
tation braucht es neben dem Array noch die Variable anzahl, die die Anzahl Elemente im
Array zählt, und die Variable position, die beim sortierten Lesen verwendet wird. Den Array
bezeichnen wir mit such.
4.2.2.1
Suchen eines Schlüssels k
Da die Elemente im Array nach dem Schlüssel sortiert sind, kann k mit binärem Suchen
gefunden werden (siehe Algorithmus 1.1). Der Pointer position für das sortierte Lesen wird
auf den Index des gefundenen Elementes gesetzt. Falls das Element nicht im Array ist, setzen
wir position gleich anzahl.
Das Suchen eines Elements benötigt also log2 (anzahl) Vergleiche.
4.2.2.2
Sortiertes Lesen
Die nextkey Operation inkrementiert den Pointer position um eins.
Die reset Operation setzt den Pointer position auf 0.
4.2.2.3
Einfügen eines Elements
Diese Operation ist in diesem Fall aufwendig. Wir müssen ja das Element an die richtige
Position im Array bringen. Der folgende Java-Code (eine Anpassung von sortieren durch
Einfügen) löst diese Aufgabe. Die Variable neu enthaltet das neue Element.
Algorithmus 4.1 [Einfügen im sortierten Array] Der folgende Code zeigt das Einfügen
eines Elements im Array.
{
int i = anzahl;
while (i > 0 && such[i-1].key > neu.key) {
// Verschieben aller Elemente mit groesserem
// Schluessel als neu um eine Position nach oben.
such[i] = such[i-1];
--i;
}
//Das neue Element an der richtigen Stelle Einfuegen.
such[i] = neu;
++anzahl;
}
Für diese Operation sind im Durchschnitt anzahl/2 Vergleiche und Verschiebungen notwendig.
4.2.2.4
Löschen eines Elements
Das Element mit dem Index position wird gelöscht. Alle Elemente mit einem Index grösser
als position müssen um eine Stelle nach links verschoben werden.
4-17
Algorithmus 4.2 [Löschen im sortierten Array] Der folgende Pseudocode zeigt das Löschen eines Elements im Array.
{
int i = position;
while (i < anzahl) {
such[i] = such[i+1];
++i;
}
--anzahl;
}
Auch diese Operation braucht im Durchschnitt anzahl/2 Verschiebungen.
Die Implementation als Array eignet sich sicher nur für eine kleine Anzahl von Elementen,
die alle im Hauptspeicher Platz finden. Für grosse Datenmengen ist das Einfügen und das
Löschen viel zu aufwendig.
4.2.3
Implementation als binären Suchbaum
Wir können das sortierte Suchen auch als binären Suchbaum implementieren. Wie wir aber aus
den Übungen wissen, kann ein binärer Suchbaum im schlechtesten Fall zu einer linearen Liste
entarten. In diesem Fall ist die Komplexität einer Suchoperation im Baum O(n). In diesem
Abschnitt wollen wir zeigen, dass binäre Suchbäume so aufgebaut werden können, dass sie
ausgeglichen bleiben. Das heisst, alle Wege von der Wurzel zu einem Blatt sind ungefähr
gleich lang.
4.2.3.1
2-3-4-Bäume
Wir nehmen vorerst an, dass die Knoten unseres Baumes mehr als einen Schlüssel aufnehmen
können. Wir lassen insbesondere 2-Knoten, 3-Knoten und 4-Knoten zu, die einen bzw. zwei
bzw. 3 Schlüssel aufnehmen können.
Für innere Knoten des Baumes gilt:
• Ein 2-Knoten hat genau zwei Unterbäume. Links sind alle kleineren rechts alle grösseren
Schlüssel.
• Ein 3-Knoten hat genau drei Unterbäume. Links sind alle kleineren rechts alle grösseren
Schlüssel und in der Mitte alle Schlüssel, die zwischen den zwei Schlüsseln des Knotens
liegen.
• Ein 4-Knoten hat genau vier Unterbäume. Einen für jedes Intervall, dass durch die 3
Schlüssel definiert werden.
Ein 2-3-4-Baum hat die folgenden Eigenschaften:
1. Grössen Eigenschaft: Jeder Interne Knoten hat mindestens zwei und höchstens vier
Nachfolger.
2. Tiefen Eigenschaft: Alle Blätter des Baumes haben dieselbe Tiefe.
4-18
Aus diesen Eigenschaften können wir ableiten, dass die Tiefe eines 2-3-4-Baum der n Schlüssel
enthält O(log2 (n)) ist. Die grösste Tiefe erhält man, wenn nur 2-Knoten vorhanden sind. In
diesem Fall entspricht der 2-3-4-Baum einem vollständigen Binärbaum und hat die Tiefe
log2 ( n+1
2 ). Die kleinste Tiefe erhält man, wenn nur 4-Knoten vorhanden sind in diesem Fall
ist die Tiefe 12 log2 ( n−3
3 − 1) . Das heisst, die Tiefe ist O(log2 (n)).
In der Figur 4-6 ist ein 2-3-4-Baum dargestellt.
50 70
10 30 33
55 58 62
100
Abbildung 4-6: Ein 2-3-4-Baum
Das Suchen in einem 2-3-4-Baum funktioniert gleich wie das Suchen in einem binären Suchbaum. Ebenso ist das Traversieren des Baumes kein Problem. Wir wollen uns also der Hauptaufgabe widmen, das Einfügen.
Einfügen eines Schlüssels in einem 2-3-4-Baum
Das Einfügen eines Schlüssels in einem 2-3-4-Baum ist in der Abbildung 4-7 dargesetllt.
50 70
10 30 33
90
55 58 62
56
50 70
100
10 30 33
90 100
55 58 62
a)
b)
58
50 58 70
35
70
30 50
10 30 33
55 56
62
90 100
10
33 35
c)
55 56
62
90 100
d)
Abbildung 4-7: Einfügen von Schlüsseln im 2-3-4-Baum
Das Einfügen kann folgendermassen umschrieben werden:
1. Suche das Blatt, wo der Knoten eingefügt werden muss.
2. Ist das Blatt ein 2-Knoten oder 3-Knoten, so kann der Schlüssel einfach eingefügt werden.
Ein 2-Knoten wird dabei zu einem 3-Knoten und ein 3-Knoten zu einem 4-Knoten (Fall
a) zu b) in der Abbildung 4-7).
3. Ist das Blatt ein 4-Knoten, so wird dieser in 2 2-Knoten aufgeteilt (split Operation).
Der mittlere Schlüssel wird in den Vorgängerknoten eingetragen und der neue Schlüssel
in einem der beiden 2-Knoten (Fall b) zu c) in der Abbildung 4-7).
4-19
4. Falls der Vorgängerknoten auch schon ein 4-Knoten ist, so wird er in zwei 2-Knoten
aufgeteilt und der mittlere Schlüssel wird im Vorgänger dieses Knotens eingetragen.
Dieser Prozess kann sich bis zur Wurzel fortsetzen. (Fall c) zu d) in der Abbildung 4-7).
Alternative Methode zum Einfügen eines Schlüssels
Um zu verhindern, dass beim Einfügen alle Knoten bis zur Wurzel aufgespalten werden müssen, kann der Algorithmus so abgeändert werden, dass auf dem Weg im Baum nach unten
alle angetroffenen 4-Knoten aufgespalten werden. Dies garantiert uns, dass kein durchlaufener
Knoten mehr einen 4-Knoten als Vorgänger hat. Dieser Vorgang ist in der Abbildung 4-8
dargestellt.
47
58
30 45 50
10
33 35
45 58
30
70
46 48 49
55 56
62
90 100
10
50
33 35
46 48 49
a)
70
55 56
62
90 100
b)
45 58
48 50
30
10
33 35
46 47
49
70
55 56
62
90 100
c)
Abbildung 4-8: Aufspalten der 4-Knoten beim Einfügen
Löschen eines Schlüssels in einem 2-3-4-Baum
Beim Löschen eines Schlüssels müssen wir nur den Fall betrachten, wo der Schlüssel in einem
Blatt des Baumes liegt. Der Fall bei dem der Schlüssel in einem internen Knoten liegt, kann
auf den ersten Fall zurückgeführt werden.
Wir nehmen an, dass der Schlüssel k in einem internen Knoten v an der Position i liegt. In
diesem Fall werden die folgenden Schritte durchgeführt
1. Wir suchen im Unterbaum i des Knotens v das Blatt w, das am weitesten rechts liegt.
(im Unterbaum immer dem Pointer ganz rechts verfolgen).
2. Wir Tauschen nun den Grössten Schlüssel im Knoten w mit dem Schlüssel k im Knoten
v.
3. Nun kann der Schlüssel k im Blatt w gelöscht werden.
Beim Löschen eines Schlüssels in einem Blatt gibt es nun 3 Fälle:
1. Das Blatt besitzt mehr als einen Schlüssel.
In diesem Fall kann der Schlüssel einfach aus dem Blatt entfernt werden. Dieser Fall ist
in der Abbildung 4-9 (löschen von 100) im Fall d) dargestellt.
4-20
58
58
70
30 50
70
33 50
10
33 35
90 100
62
55
30
35
90 100
62
55
a)
b)
58
58
70
33 50
70
33
55
30
90 100
62
35
30
90 100
62
35 50
c)
d)
58
33 58
70
33
90
30
30
35 50
62
35 50
62 70
f)
e)
Abbildung 4-9: Löschen im 234-Baum
2. Das Blatt enthält nur einen Schlüssel. In einem der Nachbarknoten sind aber mindestens
2 Schlüssel.
In diesem Fall kann ein Schlüssel vom Nachbarknotenknoten ausgeliehen werden. Dieser
Fall ist in der Abbildung 4-9 Fall a) dargestellt.
3. Das Blatt enthält nur einen Schlüssel. Beide Nachbarknoten besitzen auch nur einen
Schlüssel.
In diesem Fall wird der Knoten mit einem der Nachbarknoten zusammengelegt zusammen mit dem entsprechenden Schlüssel vom Vorgängerknoten. Dieser Fall ist in der
Abbildung 4-9 Fall c) dargestellt.
4. Beim entnehmen des Schlüssels in den Vorgängerknoten kann es passieren, dass dieser
nachher keinen Schlüssel mehr enthält.
In diesem Fall wird dieselbe Prozedur auf der höheren Stufe angewendet. Dieser Vorgang kann sich bis zur Wurzel fortsetzen. Dieser Fall ist in der Abbildung 4-9 Fall e)
dargestellt.
4.2.3.2
Rot-Schwarz-Bäume
2-3-4-Bäume können als gewöhnliche binäre Bäume dargestellt werden. Pro Knoten brauchen
wir dabei nur ein zusätliches Bit das angibt, ob der Knoten “rot” oder “schwarz” ist. Ein
4-Knoten wird im Baum mit drei Knoten dargestellt, der Vater ist dabei schwarz und die
beiden Kinder sind rot. Ein 3-Knoten mit Hilfe von zwei Knoten wobei das Kind wieder rot
ist. Ein 2-Knoten ist ein schwarzer Knoten mit 2 schwarzen Kinder. In der Abbildung 4-10
ist die Darstellung von 4- und 3-Knoten als Rot-Schwarz-Baum angegeben. Rote Knoten und
deren Verbindung zum Vorgängerknoten sind mit einem dicken Strich gezeichnet.
Ein Rot-Schwarz-Baum hat die folgenden Eigenschaften:
4-21
70
50
20 50 100
40
40 70
20
oder
40
100
70
Abbildung 4-10: Darstellung von 4- und 3-Knoten
1. Wurzel Eigenschaft: Die Wurzel des Baumes ist immer schwarz.
2. Interne Eigenschaft: Die Kinder eines roten Knotens sind immer schwarz.
3. Tiefen Eigenschaft: Alle Blätter haben dieselbe schwarze Tiefe. Das heisst, auf jedem
Weg von der Wurzel zu einem Blatt hat es gleich viele schwarze Knoten.
In der Abbildung 4-11 ist der 2-3-4-Baum aus der Abbildung 4-6 als Rot-Schwarz-Baum dargestellt. Man sieht sofort, dass die Algorithmen zum Suchen eines Schlüssels oder zum sortierten
Lesen der Elemente im Rot-Schwarz-Baum genau die gleichen sind wie für den binären Suchbaum. Im Folgenden müssen wir also zeigen, wie ein Knoten eingefügt und gelöscht werden
kann.
50 70
70
50
10 30 33
55 58 62
100
100
30
10
58
33
55
62
Abbildung 4-11: Ein Rot-Schwarz-Baum
Bemerkung 4.3 [Höhe des Rot-Schwarz-Baumes] Aus der Definition von Rot-SchwarzBäumen geht hervor, dass auf einem Weg von der Wurzel zu einem Blatt niemals zwei rote
Knoten unmittelbar nacheinander auftreten können, und alle solche Wege haben gleich viele
schwarze Knoten. Das heisst, ein Weg, bei dem rot und schwarz immer abwechselt kann
maximal doppelt so lang werden, wie ein Weg, das nur schwarze Knoten enthält. Die Tiefe
des entsprechenden 2-3-4-Baumes entspricht aber genau der schwarzen Tiefe des Rot-SchwarzBaumes. Aus diesen Tatsachen kann man schliessen, dass die Höhe des Rot-Schwarz-Baumes
O(log2 (N )) ist.
Einfügen in einem Rot-Schwarz-Baum
Das Einfügen funktioniert vorerst wie in einem binären Suchbaum. Wir suchen zuerst den
Schlüssel k bis wir einen leeren Pointer antreffen. Dort wird ein neuer Knoten kreiert und
der Schlüssel k dort eingefügt. Ist der neue Knoten die Wurzel, so wird der neue Knoten
schwarz gefärbt sonst rot. Durch diese Operation bleiben im Baum die Wurzel- und die TiefenEigenschaft erhalten, aber die Interne Eigenschaft kann verletzt sein. Falls der neue Knoten
k nicht die Wurzel des Baumes ist und sein Vorgänger v ist selber auch schon rot, dann ist
das Kind eines roten Knotens auch rot. Wir nennen diesen Fall “doppel rot”.
Wir stellen fest, dass im Fall “doppel rot” v nicht die Wurzel des Baumes sein kann (Wurzel
Eigenschaft), und dass der Vorgänger von v schwarz sein muss (Interne Eigenschaft). Wir
können nun zwei Fälle unterscheiden:
4-22
Der Bruder des Knotens v ist schwarz. In diesem Fall bedeutet “doppel rot”, dass wir
im entsprechenden 2-3-4-Baum einen neuen 4-Knoten kreiert haben, der aber im RotSchwarz-Baum falsch orientiert ist. Die möglichen Anordnungen der Knoten im RotSchwarz-Baum sind in der Abbildung 4-12 im Fall a) angegeben. In diesem Fall ist
eine Restrukturierung des Baumes notwendig. Diese Operation kann folgendermassen
beschrieben werden:
• Nehme die Knoten k seinen Vater v und seinen Grossvater u und bennene diese als
a, b und c so, dass diese der Grösse nach sortiert sind.
• nun ersetze den Grossvaterknoten u mit Knoten b und mache a und c zu den
Kindern von b.
Nach dieser Restrukturierung wird b als schwarz, a und c als rot markiert. Dieser Vorgang
ist in der Abbildung 4-12 angegeben.
u 90
v 70
k 20
u 90
20
v 20
u
20
70
v
k 70
u
90
90
k
70
v
k
a)
b
70
a
c
20
90
b)
Abbildung 4-12: Restrukturierung des Rot-Schwarz-Baumes
Der Bruder des Knotens v ist rot. In diesem Fall bedeutet “doppel rot”, dass im entsprechenden 2-3-4-Baum ein überlauf passiert ist (einfügen in einem 4-Knoten). Wir
müssen also eine Splitoperation durchführen. Dies geschieht mit einer Umfärbung (siehe
Abbildung 4-13). Die Knoten v und w werden schwarz gefärbt und der Knoten u wird
rot gefärbt. Falls der Vorgänger von u schwarz ist, sind wir fertig. Sonst haben wir das
“doppel rot” Problem auf einer höheren Stufe des Baumes. Das heisst, eine Umfärbung
löst das “doppel rot” Problem beim Knoten k oder es gibt das Problem an den Knoten u
weiter. Falls bei u das “doppel rot” Problem wieder auftaucht, so müssen wir dort entweder wieder Umfärben oder eine Restrukturierung vornehmen. Dieser Vorgang setzt sich
fort, bis das “doppel rot” Problem durch eine Umfärbung oder eine Restrukturierung
gelöst wird. Der Vorgang kann sich bis zur Wurzel des Baumes fortsetzen.
Wir können noch überlegen, wieviele Operationen maximal nötig sind um ein neues Element
in einem Rot-Schwarz-Baum einzufügen. Da eine Restrukturierung des Baumes das “doppel
rot” Problem löst, brauchen wir höchstens eine Restrukturierungsoperation. Umfärbungen
gibt es höchstens so viele wie die Hälfte der Höhe des Baumes und das ist log2 (n). Das heisst,
das Einfügen eines neuen Elementes ist O(log2 (n)).
4-23
40
u
63
55 63 90
v 55
90 w
v 55
k 40
63 ...
u
63
90 w
40 55
90
k 40
Abbildung 4-13: Spliten eines 4-Knotens in Rot-Schwarz-Bäume
Löschen in einem Rot-Schwarz-Baum
Beim Löschen eines Elementes in einem Rot-Schwarz-Baum können wir vorerst gleich vorgehen
wie beim binären Suchbaum. Wir suchen den Knoten k der gelöscht werden muss. Wir können
dies immer so einrichten, dass k höchstens einen Nachfolger hat. Nun gibt es mehrere Fälle:
Der Knoten k ist rot. In diesem Fall hat der Knoten keine Nachfolger und er kann einfach
gelöscht werden.
Der Knoten k ist schwarz und hat einen Nachfolger z. In diesem Fall muss der Nachfolger rot sein. Hätte der Knoten einen schwarzen Nachfolger, so würde dies bedeuten,
dass im entsprechenden 2-3-4-Baum ein 2-Knoten mit nur einem Nachfolger existiert
und das ist ja nicht erlaubt. In diesem Fall kann der Knoten k gelöscht werden und z
wird am Vater u von k angehängt. Anschliessend wird z schwarz gefärbt.
u
... 30 40
30
u
30
... 30
r
25
r
40
50
z
40
z
50 k
25
25 40
25
Fall a)
... 30
30
u
30
u
... 30
r
r
40
40
z
25
50
25
40
50
k
z
25
25 40
Fall b)
Abbildung 4-14: Verschmelzen zweier Knoten im Rot-Schwarz-Baum
Der Knoten k ist schwarz und hat keinen Nachfolger. Dies entspricht im 2-3-4-Baum
dem Fall, dass der Knoten leer wird. Wir müssen nun die folgenden Fälle unterscheiden:
Fall 1: Der Bruder z des Knotens k ist schwarz und hat schwarze Kinder. Dies
entspricht im 2-3-4-Baum dem Fall wo wir die Knoten z und k verschmelzen. Dies
wird mittels einer Umfärbung der Knoten realisiert. Das heisst der Bruder z von k
4-24
wird rot. Falls der Vater r rot ist, wird er neu schwarz und die Löschoperation ist
abgeschlossen. Dieser Fall ist in der Abbildung 4-14 im Fall a) dargestellt.
Falls der Vater r schwarz ist, so wird die Tiefen Eigenschaft des Rot-SchwarzBaumes verletzt. Der schwarze Weg zum Knoten z ist um 1 kürzer. Wir färben den
Knoten r doppel schwarz um diese Tatsache darzustellen. Im 2-3-4-Baum entspricht
dies dem Fall, wo der Vaterknoten leer wird und wir müssen das “doppel schwarz”
Problem auf der höheren Ebene des Baumes lösen. Dieser Fall ist in der Abbildung
4-14 im Fall b) dargestellt.
Das “doppel schwarz” Problem kann sich bis zur Wurzel des Baumes fortpflanzen.
Dieser Prozess ist in der Abbildung 4-15 dargestellt.
70
70
50
120
30
10
58
33
55
50
90
62
80
150
100
125
120
30
200
10
58
33
55
90
62
70
10
120
55
62
125
50
90
58
33
80
200
70
50
30
150
80
150
125
120
30
200
10
58
33
55
90
62
80
150
125
200
Abbildung 4-15: Fortpflanzung des “doppel schwarz” Problems im Rot-Schwarz-Bäume
Fall 2: Der Bruder v von r ist schwarz und hat mindestens ein rotes Kind z.
Die Lösung dieses Falles entspricht im 2-3-4-Baum dem Ausleihen eines Schlüssels
beim Nachbarknoten. In diesem Fall führen wir dieselbe Restrukturierung vor wie
beim Einfügen. Wir nehmen den Knoten z seinen Vater v und seinen Grossvater u
und machen eine Umbennenung auf a, b und c so, dass die Schlüssel in aufsteigender
Reihenfolge sortiert sind. Anschliessend machen wir b zum Vater von a und c a und
c werden schwarz, b hat dieselbe Farbe wie u sie hatte. Nach der Restrukturierung
existiert das “doppel schwarz” Problem nicht mehr. Das heisst, pro Löschvorgang
wird höchstens eine Restrukturierung gemacht. Der Vorgang ist in der Abbildung
4-16 dargestellt.
4-25
... 20
... 30
30
u
20 b
30
10
10 20
v
a
40 r
20
30 c
10
40
40
z
40 r
10
Fall a)
... 20
... 30
30
u
20 b
... 10
... 10 20
v
a
40 r
10
30
30 c
10
40
40
z
40 r
20
Fall b)
Abbildung 4-16: Ausleihen eines Schlüssels beim Nachbarn
Fall 3: Der Bruder v von r ist rot. In diesem Fall führen wir eine Anpassung durch.
Wenn z das linke Kind von v ist, so führen wir eine Restrukturierung mit den Knoten z, v und u durch. v wird schwarz und u wird rot. Wir haben nichts anderes
getan, als den 3-Knoten (20,30) im 2-3-4-Baum im Rot-Schwar-baum anders darzustellen. Nun ist der Bruder x von r schwarz und wir haben entweder den Fall
1 oder den Fall 2. Man beachte, dass der Vater u von r rot ist. Das heisst, wenn
der Fall 1 vorliegt, so kann es keine Fortpflanzung des “doppel schwarz” Problems
geben. Das heisst, pro Löschvorgang gibt es höchstens eine Anpassungsoperation.
Der Fall ist in der Abbildung 4-17 dargestellt.
20 30
u
v
30
... 10 ...
20
... 27 ...
v
40 r
20
z
30 u
10
40
z
10
27 x
27 x
Abbildung 4-17: Anpassungsoperation im Rot-Schwarz-Baum
4-26
40
r
Wir können noch überlegen wieviele Operationen nötig sind, um ein Schlüssel aus dem RotSchwarzbaum zu löschen. Es braucht dabei höchstens eine Anpassung und höchstens eine
Restrukturierung. Eventuell müssen wir log2 (n) Umfärbeoperationen durchführen. Das heisst,
das Löschen im Rot-Schwarz-Baum hat eine Komplexität von O(log2 (n)).
4.2.4
Implementation als Splay Baum
In diesem Abschnitt wollen wir noch den Splay-Baum betrachten. Diese Datenstruktur unterscheidet sich wesentlich von den bisher besprochenen ausgelichenen Bäumen (2-3-4-Bäume,
rot-schwarz-Bäume). Ein Splay-Baum hat keine Regeln, um die Ausgeglichenheit des Baumes
zu garantieren. Um den Baum ausgeglichen zu halten (in einem amortisierten Sinne) wird
nach jedem Zugriff eine sogenannte Splay-Operation ausgeführt. Die Splay-Operation wird
auf dem tiefsten Knoten ausgeführt, der während einer Operation erreicht wird (Suchen, Einfügen oder Löschen). Die Struktur des Splay-Baumes ist ein gewöhnlicher binärer Suchbaum.
Die amortisierten Kosten für das Suchen, Einfügen und Löschen in einem Splay-Baum sind
logarithmisch.
4.2.4.1
Die Splay-Operation
Eine Splay-Operation bewegt einen Knoten x bis zur Wurzel des Baumes mit Hilfe einer Sequenz von restrukturierungs Operationen. Die spezifischen Operationen, die ausgeführt werden hängen von der Position vom Knoten x, seinem Vorgänger y und (wenn er existiert) von
seinem Vorvorgänger z ab. Nachfolgend sind die drei möglichen restrukturierungs Operationen
angegeben.
zig-zig Operation: Der Knoten x und sein Vorgänger sind beide linke oder beide rechte
Nachfolger. Wir ersetzen z durch x, y wird nachfolger von x und z nachfolger von y.
Die zig-zig Operation ist in der Abbildung 4-18 dargestellt.
Abbildung 4-18: Die zig-zig Operation: (a) vor der Opeation (b) nach der Operation. Es gibt
einen zweiten symmetrischen Fall, wenn x und y linke Nachfolger ist.
zig-zag Operation: y ist ein rechter Nachfolger und x ein linker Nachfolger oder umgekehrt.
Wir ersetzen z durch x. y und z werden zu den Nachfolgern von x. Die zig-zag Operation
ist in der Abbildung 4-19 dargestellt.
4-27
Abbildung 4-19: Die zig-zag Operation: (a) vor der Operation (b) nach der Operation. Es gibt
einen zweiten symmetrischen Fall, wenn y ein linker und x ein rechter Nachfolger ist.
die zig Operation: Falls der Vorgänger y von x die Wurzel des Baumes ist, so wird x zur
neuen Wurzel des Baumes. Die zig Operation ist in der Abbildung 4-20 dargestellt.
Abbildung 4-20: Die zig Operation
In den Abbildungen 4-21 und 4-22 ist das Suchen in einem splay Baum dargestellt.
4-28
Abbildung 4-21: Splay-Operation (1): (a) Splay von Knoten 14 beginnt mit einer zig-zag
Operation. (b) nach zig-zag (c) der nächste Schritt ist eine zig-zig Operation
4-29
Abbildung 4-22: Splay-Operation (2): (d) nach zig-zig (e) nächster Schritt ist wieder zig-zig
(f) die Splay-Operation ist beendet
4-30
Eine Splay-Operation auf einem Knoten x im Baum ist eine Folge von zig-zig, zig-zag und zig
Operationen so, dass am Schluss der Knoten x zur neuen Wurzel des Baumes wird. In den
Abbildungen 4-21 und 4-22 ist eine vollständige Splay-Operation dargestellt. Man beachte,
dass bei jeder zig-zig oder zig-zag Operation die Tiefe des Knotens x um 2 abnimmt und bei
einer zig Operation die Tiefe von x um 1 abnimmt. Falls die Tiefe des Knotens x gleich d
ist, so braucht eine Splay-Operation gesammthaft ⌊ d2 ⌋ zig-zig und/oder zig-zag Operationen
plus eine zig Operation falls d ungerade ist. Da zig-zig, zig-zag und zig Operationen eine
Komplexität von O(1) haben, so hat eine Splay-Operation auf einen Knoten x mit Tiefe d
eine Komlexität von O(d). Das heisst, die Komplexität der Splay-Operation ist gleich wie die
Komplexität von Suchen, Einfügen oder Löschen eines Knotens.
4.2.4.2
Anwendung der Splay-Operation
Suchen Falls der Schlüssel k im Knoten x gefunden wird, so wird die Splay-Operation auf
den Knoten x ausgeführt. Falls die Suche erfolglos ist, so wird die Operation auf den
Knoten ausgeführt, bei dem die Suche endet. Die Splay-Operation in der Abbildung 4-21
wird ausgeführt, wenn der Schlüssel 14 gefunden wird oder bei einer erfolglosen Suche
nach dem Schlüssel 14.5.
Einfügen Wenn der Schlüssel k eingefügt wird, so wird die Splay-Operation auf den neu
kreierten Knoten x ausgeführt. In der Abbildung 4-23 Fall (f) und (g) wird die Operation
auf dem neu kreierten Knoten mit Schlüssel 4 ausgeführt.
Löschen Beim Löschen des Schlüssels k wir die Splay-Operation entweder auf den Vorgänger
des Knotens x der k enthält oder den Vorgänger eines Nachfolgers von x (der Knoten
mit grösstem Schlüssel im linken Unterbaum von x) ausgeführt. In der Abbildung 4-24
ist dieser Vorgang abgebildet.
Im schlesteten Fall brauchen die Operationen Suchen, Einfügen und Löschen in einem Baum
der Tiefe d mit n Elementen O(d) Operationen. Wie man in der Abbildung 4-23 sieht, kann
die Tiefe aber n sein. Für den schlechtesten Fall ist der Splay-Baum also keine attraktive
Datenstruktur.
In der Abbildungen 4-23 ist das Einfügen in einem splay Baum dargestellt.
In der Abbildungen 4-24 ist das Löschen in einem splay Baum dargestellt.
4.2.4.3
Amortisierte Kosten
Wir haben schon festgestellt, dass der Splay-Baum sich im schlechtesten Fall nicht gut verhält (O(n)). Wir wollen aber in diesem Kapitel zeigen, dass die amortisierten Kosten sich
logarithmisch verhalten. Da die Kosten für das Suchen, Einfügen und Löschen proportional
zu der entsprechenden Splay-Operation sind, werden wir nur die Kosten der Splay-Operation
untersuchen.
T sei ein Splay-Baum mit n Knoten und x sei ein beliebiger Knoten. Wir definieren:
size : s(x) = Anzahl Knoten im Unterbaum der durch x gegeben ist.
rang : r(x) = log2 (s(x))
Wir bezahlen nun für eine zig-zig oder zig-zag Operation zwei Cyberdollars und für eine zig
Operation einen Cyberdollar. Mit diesem Schema kostet die Splay-Operation für einen Knoten
4-31
Abbildung 4-23: Einfügen im Splay Baum: (a) Anfangsbaum (b) einfügen von 2 (c) nach der
Splay-Operation (d) einfügen von 3 (e) nach der Splay-Operation (f) einfügen von 4 (g) nach
der Splay-Operation
x mit Tiefe d genau d Cyberdollars. Bei jedem Splay bezahlen wir eine gewisse Summe sum
(der Betrag werden wir später festlegen). Damit ergeben sich drei Fälle.
• Falls die Operationskosten genau sum sind, so benutzen wir den ganzen Betrag um die
Operation zu bezahlen.
• Falls sum grösser ist als die Kosten, so verteilen wir den Rest auf diverse Knoten im
Baum
• Falls sum kleiner ist als die effektiven Kosten, so entnehmen wir den Restbetrag von
diversen Knoten im Baum um die Differenz zu begleichen.
Wir wollen im System die folgende Invariante erzwingen:
Vor und nach einer Splay-Operation besitzt jeder Knoten x ∈ T r(x)
Cyberdollar
Man beachte, dass diese Invariante gilt, wenn der Baum leer ist. Das heisst am Anfang sind keine Cyberdollars im Baum gespeichert. Wir zeigen nun, dass wir für jede Operation O(log2 (n))
Cyberdollars bezahlen müssen um das System am leben zu erhalten. Wir definieren
r(T ) =
X
r(x)
x∈T
um die Invariante nach einer Splay-Operation zu erhalten müssen wir die Kosten der Operation
plus die Änderung von r(T ) bezahlen.
4-32
Abbildung 4-24: Löschen im Splay Baum: (a) löschen des Schlüssels 8. Dabei wird der Knoten
mit dem Schlüssel 7 entfernt. (b) zig-zig Operation (c) nach zig-zig (d) zig Operation (e) nach
zig
Wir bezeichnen zig-zig, zig-zag und zig Operationen als Schritte der Splay-Operation. Der
Rang eines Knotens vor und nach einem Schritt bezeichnen wir mit r(x) beziehungsweise
r′ (x). Wir beweisen nun den folgenden Satz über r(x) und r′ (x).
Satz 4.4 [Änderung von r(T )] Die Änderung δ von r(T ), die durch einen Schritt der
Splay-Operation eines Knotens x ensteht, kann folgendermassen begrenzt werden:
δ ≤ 3(r′ (x) − r(x)) − 2
δ ≤ 3(r′ (x) − r(x))
für zig-zig und zig-zag Schritte
für einen zig Schritt
Beweis: Wir beweisen die Behauptung für jeden möglichen Schritt einzeln. Dabei
werden wir die folgende Tatsache (ohne Beweis) verwenden: Seien a > 0, b > 0
und c > a + b dann gilt:
log(a) + log(b) ≤ log(c) − 2
(4.1)
zig-zig (Abbildung 4-18): Aus der Figur wird sofort klar, dass nur der Rang
für die Knoten r(x), r(y) und r(z) ändert. Für alle anderen Knoten bleibt
er gleich. Ferner sieht man, dass
r′ (x) = r(z)
r′ (y) ≤ r′ (x)
r(x) ≤ r(y)
4-33
Nun können wir δ abschätzen:
δ = r′ (x) + r′ (y) + r′ (z) − r(x) − r(y) − r(z)
= r′ (y) + r′ (z) − r(x) − r(y)
≤ r′ (x) + r′ (z) − 2r(x)
(4.2)
Beachten Sie, dass n(x) + n′ (z) ≤ n′ (x) aus 4.1 folgt:
r(x) + r′ (z) ≤ 2r′ (x) − 2
r′ (z)
≤ 2r′ (x) − r(x) − 2
(4.3)
aus 4.2 und 4.3 folgt nun die Behauptung
δ ≤ r′ (x) + (2r′ (x) − r(x) − 2) − 2r(x)
= 3(r′ (x) − r(x)) − 2
(4.4)
zig-zag (Abbildung 4-19): Auch in diesem Fall ändert nur der Rang der 3 Knoten x, y und z. Man sieht sofort, dass
r′ (x) = r(z)
r(x) ≤ r(y)
Wir können also schreiben
δ = r′ (x) + r′ (y) + r′ (z) − r(x) − r(y) − r(z)
= r′ (y) + r′ (z) − r(x) − r(y)
≤ r′ (y) + r′ (z) − 2r(x)
(4.5)
Beachten Sie, dass n′ (y) + n′ (z) ≤ n′ (x) aus 4.1 folgt:
r′ (y) + r′ (z) ≤ 2r′ (x) − 2
(4.6)
Setzen wir 4.6 in 4.5 ein, so erhalten wir die Behauptung
δ ≤ 2r′ (x) − 2 − 2r(x)
≤ 3(r′ (x) − r(x)) − 2
(4.7)
zig (Abbildung 4-20): In diesem Fall kann nur der Rang von x und y ändern.
Ferner gilt:
r′ (y) ≤ r(y)
r′ (x) ≥ r(x)
Daraus folgt die Behauptung denn:
δ = r′ (x) + r′ (y) − r(x) − r(y)
≤ r′ (x) − r(x)
≤ 3(r′ (x) − r(x))
(4.8)
Satz 4.5 [Kosten einer Splay-Operation] T sei ein Splay-Baum mit Wurzel t und ∆ die
Änderung von r(T ) bei einer Splay-Operation des Knotens x mit Tiefe d. Dann gilt:
∆ ≤ 3(r(t) − r(x)) − d + 2
4-34
Beweis: Die Splay-Operation beteht aus p = ⌈ d2 ⌉ Schritte, wobei jeder Schritt
eine zig-zig oder zig-zag Operation ist. Falls d ungerade is kommt noch eine zig
Operation dazu. Sei r0 (x) = r(x) und für i = 1..p sei ri der Rang von x und δi
die Änderung von r(T ) nach dem i-ten Schritt der Splay-Operation. Nach dem
Theorem 4.4 gilt nun:
p
δ
∆ =
Ppi=1 i
≤
(3(r
i (x) − ri−1 (x)) − 2) + 2
i=1
= 3(rp (x) − r0 (x)) − 2p + 2
≤ 3(r(t) − r(x)) − d + 2
P
Falls wir bei jeder Splay-Operation 3(r(t) − r(x)) + 2 Cyberdollar bezahlen so genügt dies
nach Theorem 4.5 um die Splay-Operation zu bezahlen und gleichzetig zu garantieren, dass in
jedem Knoten immer noch r(x) Cyberdollars gespeichert sind. Für die Wurzel t des Baumes
T mit n Knoten gilt r(t) = log(n). Da r(x) < r(t) ist die Zahlung O(log(n)). Wir halten also
fest:
Die amortisierten Kosten einer Splay-Operation betragen O(log(n))
Bemerkung 4.4 [Einfügen und Löschen] Da die Kosten für eine Such, Einfügen oder
Löschoperation proportional zu den Kosten einer Splay-Operation sind, kann man sagen,
dass die amortisierten Kosten für diese Operationen im Splay-Baum O(log(n)) betragen.
Für ein Sequenz von Operationen auf einen leeren Splay-Baum können wir also festhalten:
Satz 4.6 [Amortisierte Kosten im Splay-Baum] Für eine Sequenz von Operationen (suchen, einfügen oder löschen) der Länge m in einem leeren Splay-Baum ist der totale Aufwand
gleich
O (m +
Pm
i=1 log(ni ))
Dabei bezeichnet ni die Anzahl Knoten nach der i-ten Operation. Dies ist gleich O(m log(n))
wobei n = max1≤i≤m (ni )
Der Vorteil des Splay-Baumes ist, dass er adaptiv ist. Das heisst, Elemente auf die häufig
zugegriffen wird sind nahe bei der Wurzel und daher ist der Zugriff auf solche Elemente
schneller. Das nächste Theorem gibt darüber auskunft.
Satz 4.7 [Splay-Baum ist adaptiv] Wir betrachten wieder eine Sequenz von m Operationen,
die mit einem leeren Splay-Baum beginnt. f (i) bezeichne die Anzahl Zugriffe auf das Element
i und n sei die totale Anzahl der Elemente. Falls auf jedes Element im Baum zugegriffen wird,
so ist der totale Aufwand gleich
O (m +
Pn
i=1 f (i) log(m/f (i)))
Das heisst, die amortisierte Zeit für einen Zugriff auf ein Element i ist O(log(m/f (i))).
Als Beispiel nehmen wir an, dass für das Element i gilt f (i) = m/4. In diesem Fall gilt
O(log(m/(m/4))) = O(log(4)) = O(1).
4-35
4.2.5
Implementation als B-Tree
B-Trees (balanced trees oder ausgeglichene Bäume) sind für das Suchen in grossen Datenmengen geeignet. Die Knoten im Baum enthalten meistens nur die Schlüssel und einen Zeiger
auf die entsprechenden Daten. Bei grossen Datenmengen hat der ganze Baum keinen Platz
im Speicher und wird deshalb auf einer Festplatte gespeichert. Der Zugriff auf einen Knoten
des Baumes bedingt also einen Diskzugriff. Daher ist es sinnvoll, in jedem Knoten möglichst
viele Schlüssel zu speichern (verringert auch die Höhe des Baumes). Vor allem muss darauf
geachtet werden, dass die Höhe des Baumes ausgeglichen wächst (alle Wege von der Wurzel
zu einem Blatt müssen gleich lang sein).
Beispiel 4.6 [Tiefe von Bäumen] Nehmen wir an, dass in einem Baum 100 Schlüssel pro
Knoten gespeichert werden können. Im ganzen Baum seien 106 Schlüssel gespeichert. Ist der
Baum ausgeglichen, so wird man zum Finden eines Schlüssels
log100 (106 ) = 3
Diskzugriffe benötigen.
Ist der Baum nicht ausgeglichen, so können im schlimmsten Fall
106
= 104
100
Zugriffe nötig sein, um einen Schlüssel zu finden, was sicher nicht akzeptabel ist.
Die folgenden Kriterien für einen ausgeglichenen Baum wurden 1970 von R. Bayer und E.
McCreigth aufgestellt.
Definition 4.2 [B-Tree] Ein B-Tree der Ordnung n (n ist eine Konstante des B-Trees) ist
ein Baum mit folgenden Eigenschaften:
1. Jeder Knoten des Baumes enthält höchstens 2 · n Elemente.
2. Jeder Knoten ausser der Wurzel enthält mindestens n Elemente. Dieses Kriterium garantiert, dass der Baum mindestens zu 50% gefüllt ist.
3. Jeder Knoten ist entweder ein Blatt, d.h. hat keine Nachfolger, oder er hat m + 1 Nachfolger. m ist die Anzahl Schlüssel, die in diesem Knoten gespeichert ist.
4. Alle Blätter liegen auf der gleichen Stufe des Baumes.
In der Abbildung 4-25 ist ein B-Tree der Ordnung 2 dargestellt.
Der interne Aufbau eines Knotens ist in der Abbildung 4-26 aufgezeichnet. Die C-Definition
des Knotens lautet:
struct Knoten {
int
m;
Knoten
*Vorgaenger;
Knoten
*p[(2*n) + 1];
Keys
key[2*n];
};
//
//
//
//
Anzahl Schluessel im Knoten
Vorgaengerknoten
Alle Nachfolger = m+1
Schluessel sortiert
Die Schlüssel im Array key sind sortiert abgelegt und es gilt:
4-36
52
10, 20
2, 7, 8, 9
13, 16
70, 85
27, 33, 45
54, 63, 68
75, 78, 81
88, 91, 97, 99
Abbildung 4-25: B-Tree der Ordnung 2 mit 3 Stufen
1. Alle Schlüssel k im Baum mit k <key[0] sind im Unterbaum, der durch den Knoten p[0]
definiert ist, gespeichert.
2. Alle Schlüssel k im Baum mit k >key[m-1] sind im Unterbaum, der durch den Knoten
p[m] definiert ist, gespeichert.
3. Alle Schlüssel k im Baum mit key[i] < k < key[i+1] i = 0 . . . m-2 sind im Unterbaum,
der durch den Knoten p[i+1] definiert ist, gespeichert.
p[0],key[0],p[1],key[1], p[2], key[2], . . . ,p[m−1], key[m−1],p[m]
Alle keys < key[0]
Alle keys k mit key[0] < k < key[1]
Abbildung 4-26: Knoten eines B-Trees
Mit diesen Definitionen können nun die Operationen für den B-Tree angegeben werden. Wir
nehmen vorerst an, dass alle Schlüssel im Baum verschieden sein müssen.
4.2.5.1
Suchen eines Schlüssels k
1. Einlesen der Wurzel und suchen von k in der Wurzel. Da die Elemente innerhalb eines Knotens sortiert abgelegt werden, kann dies mit binärem Suchen geschehen (siehe
Algorithmus 1.1).
2. Falls k im Knoten ist, kann die Suche erfolgreich abgebrochen werden. Ist k nicht in
diesem Knoten, sind 3 Fälle möglich:
(a) k < key[0]: Die Suche wird im Knoten p[0] fortgesetzt.
(b) k > key[m-1]: Die Suche wird im Knoten p[m] fortgesetzt.
(c) key[i] < k < key[i+1]i = 0 . . . m-2: Die Suche wird im Knoten p[i+1] fortgesetzt.
3. Ist der gefundene Zeiger NULL, so ist der aktuelle Knoten ein Blatt. D.h. der Schlüssel
k ist nicht im Baum. Die Suche wird abgebrochen.
4-37
4.2.5.2
Einfügen eines Schlüssels k
Zuerst wird der Schlüssel k im Baum nach obiger Methode gesucht. Ist der Schlüssel noch
nicht im Baum gespeichert, so endet die Suche in einem Blatt. Nun sind zwei Fälle möglich:
14
52
A
10, 20
70, 85
B
2, 7, 8, 9
C
13, 14, 16
D
E
27, 33, 45
54, 63, 68
F
96
G
75, 78, 81
88, 91, 97, 99
H
I
52
A
10, 20
70, 85, 96
B
2, 7, 8, 9
D
C
13, 14, 16
E
27, 33, 45
F
54, 63, 68
G
75, 78, 81
88, 91
97, 99
I
J
H
Abbildung 4-27: Einfügen im B-Tree
1. Im Blatt sind weniger als 2·n Elemente gespeichert. In diesem Fall kann k an die richtige
Stelle im Array key eingefügt werden.
2. Im Blatt sind 2 · n Elemente gespeichert. In diesem Fall muss ein neuer Knoten kreiert
werden. Dabei muss darauf geachtet werden, dass die Eigenschaften des B-Trees erhalten
bleiben. Zum Verständnis dieses Vorgangs betrachte man das Einfügen des Schlüssels
96 in der Abbildung 4-27.
(a) Der Schlüssel 96 muss im Knoten I eingetragen werden. Dieser Knoten enthält
schon 2 · n Elemente.
(b) Ein neuer Knoten J wird kreiert.
(c) Die 2 · n + 1 vorhandenen Schlüssel werden gleichmässig auf die Knoten I und J
verteilt, wobei der mittlere Schlüssel in den Vorgängerknoten C gebracht wird. Alle Eigenschaften des B-Trees bleiben damit erhalten. Insbesondere enthalten die
Knoten I und J je n Schlüssel. Der Vorgängerknoten C enthält jetzt einen Schlüssel
mehr und demzufolge hat er auch einen Nachfolger mehr. Das Einfügen des mittleren Elementes in den Vorgängerknoten kann diesen wieder zum Überlaufen bringen.
In diesem Fall wird derselbe Prozess eine Stufe höher wiederholt. Im Extremfall
muss der Prozess bis zur Wurzel fortgesetzt werden (Abbildung 4-28).
4.2.5.3
Löschen des Schlüssels k
Zuerst wird der Schlüssel k im im Baum gesucht. Wird der Schlüssel gefunden, so gibt es zwei
Fälle:
4-38
99
30, 60
A
15
41
B
C
3, 13
20, 27
E
F
35
G
66,80
D
54, 57
62,65
70,77
84,92
H
I
J
K
60
O
30
80
A
N
15
41
66
B
C
D
3, 13
20, 27
E
F
35
G
92
M
54, 57
62,65
70,77
H
I
J
84
K
99
L
Abbildung 4-28: Wachstum eines B-Tree der Ordnung 1
1. Der Schlüssel k befindet sich in einem Blatt. In diesem Fall kann das Element gelöscht
werden und m um eins reduziert werden.
2. Der Schlüssel k befindet sich in key[i] und der Knoten ist kein Blatt. In diesem Fall
ersetzen wir den Schüssel key[i] durch den grössten Schlüssel im Unterbaum, der durch
p[i] gegeben ist. Dieser Schlüssel befindet sich im rechtesten Blatt des Unterbaumes. Um
dieses Blatt zu finden, steigen wir entlang den Zeigern p[m] den Baum hinab, bis wir auf
das gewünschte Blatt stossen. Der zu löschende Schlüssel wird nun mit dem Schlüssel
key[m-1] des Blattes überschrieben. Im Blatt wird m um eins verringert.
Das Löschen eines Schlüssels passiert also nur auf Blätter des Baumes. Nach dem Löschen
muss in jedem Fall geprüft werden, ob mindestens noch n Schlüssel im Knoten vorhanden
sind. Ist dies nicht der Fall, so muss der Baum wieder ausgeglichen werden. Auch hier gibt es
zwei Fälle:
Entleihen von Schlüsseln beim Nachbarknoten
Zuerst können wir beim Nachbarknoten nachsehen, ob dieser mehr als n Schlüssel besitzt. Ist
dies der Fall, so können die Schlüssel gleichmässig auf beide Knoten verteilt werden. Diesen
Vorgang nennt man Ausgleichen (Abbildung 4-29).
Zusammenlegen von zwei Knoten
Hat keiner der zwei Nachbarn mehr als n Schlüssel, müssen wir den Knoten mit einem seiner
Nachbarn verschmelzen. In diesen beiden Knoten sind 2 · n − 1 Schlüssel gespeichert. Wir
nehmen dazu den mittleren Schlüssel aus dem Vorgängerknoten und speichern diese 2 · n
Schlüssel in einem Knoten. Dies ist genau der zur Aufteilung eines Knotens inverse Vorgang
(Abbildung 4-30). Nun muss auch nachgeprüft werden, ob der Vorgängerknoten immer noch
mehr als n Schlüssel besitzt. Ist dies nicht der Fall, so wird derselbe Prozess auf der höheren
Stufe fortgesetzt. Im Extremfall kann sich das Zusammenlegen von Knoten bis zur Wurzel
fortsetzen (Abbildung 4-30).
Bemerkung 4.5 [Interne Operationen im Knoten] Für die internen Operationen auf
einem Knoten (Suchen, Einfügen und Löschen eines Schlüssels innerhalb des Knotens), können
die im Abschnitt 4.2.2 beschriebenen Algorithmen verwendet werden.
4-39
97
52
10, 20
2, 7, 8, 9
13, 16
70, 85
27, 33, 45
54, 63, 68
75, 78, 81
97, 99
52
10, 20
2, 7, 8, 9
13, 16
70, 81
27, 33, 45
54, 63, 68
75, 78
85, 99
Abbildung 4-29: Ausgleich im B-Tree
52
85
10, 20
2, 7, 8, 9
13, 16
70, 81
27, 33, 45
54, 63, 68
75, 78
52
Zu wenig Elemente
10, 20
2, 7, 8, 9
13, 16
85, 99
70
27, 33, 45
54, 63, 68
75, 78, 81, 99
10, 20, 52, 70
2, 7, 8, 9
13, 16
27, 33, 45
54, 63, 68
75, 78, 81, 99
Abbildung 4-30: Zusammenlegen zweier Knoten im B-Tree
4-40
4.2.5.4
Mehrfachschlüssel
Bis jetzt kann ein Schlüssel höchstens einmal im B-Tree gespeichert werden. In der Praxis
kommen aber Mehrfachschlüssel sehr oft vor. Zum Beispiel bei Namen. Der B-Tree muss also
noch erweitert werden, damit Mehrfachschlüssel eingefügt werden können. Dazu sind zwei
Methoden denkbar:
Separat chaining
Diese Methode funktioniert wie beim Hashing. Der erste Schlüssel wird normal im Baum gespeichert. Alle anderen Schlüssel, die gleich sind, werden in einer verketteten Liste gespeichert.
Einfügen im Baum
Wir ergänzen die Bedingung an die Knoten folgendermassen:
Ist der Schlüssel k in einem Knoten in key[i]i = 0 . . . m-1 gespeichert, so kann
k auch in den durch p[i] und p[i+1] definierten Unterbäumen vorkommen. Wir
können nicht verlangen, dass k nur in einem der Unterbäume vorkommt, wie die
Abbildung 4-31 zeigt.
Wählen wir diese Methode, so muss die Suchoperation angepasst werden:
Wird der gewünschte Schlüssel in einem Knoten im Element key[i] gefunden, so
kann die Suche nicht abgebrochen werden. Die Suche muss im Unterbaum der
durch p[i] gegeben ist fortgeführt werden. Nur so kann garantiert werden, dass bei
einem anschliessenden sortierten Lesen der Schlüssel im Baum alle Schlüssel mit
Wert k auch gelesen werden.
33
33, 33, 33, 33
33
33, 33
33, 33
Abbildung 4-31: B-Tree mit lauter gleichen Schlüssel
4.2.5.5
Die Wahl der Ordnung eines B-Trees
Die Wahl der Anzahl Schlüssel pro Knoten hängt von den Eigenschaften des zur Verfügung
stehenden Rechnersystems ab. Als Faustregel gilt, dass das Lesen eines Knotens höchstens
4-41
einen Diskzugriff bedeutet. Wählt man als Grösse des Knotens die optimale Grösse einer
Diskseite, kann man mit Hilfe dieser Grösse und der Schlüssellänge die Anzahl Schlüssel pro
Knoten berechnen.
4.2.6
Implementation als Skipliste
Die Skipliste ist eine interessante Implementation des sortierten Suchens. Die Skip-Liste benutzt zufällige Entscheide um die Schlüssel so zu organisieren, dass das Suchen, das Einfügen
und das Löschen im Durchschnitt in logarithmischer Zeit durchgeführ werden können.
4.2.6.1
definition der Skipliste
Eine Skipliste für eine Menge von Schlüsseln K besteht aus einer Menge von linearen Listen
S = {S0 , S1 , . . . , Sh }. Jede Liste Si enthält eine Teilmenge der Schlüssel aus K in aufsteigender
Reihenfolge plus zwei spezielle Schlüssel −∞ und +∞. −∞ ist kleiner als jeder Schlüssel in
K und +∞ ist grösser als jeder Schlüssel in K. Die Listen in S genügen den folgenden
Eigenschaften.
• Die Liste S0 enthält alle Schlüssel aus K (plus die Elemente −∞ und +∞).
• Für i = 1, 2, . . . , h−1 enthält die Liste Si eine zufällig generierte Teilmenge der Schlüssel
aus der Liste Si−1 (plus die Elemente −∞ und +∞).
• Die Liste Sh enthält nur die Elemente −∞ und +∞.
In der Abbildung 4-32 ist eine Skipliste dargestellt. Es ist üblich die Liste S0 zuunterst und
die Listen S1 , . . . , Sh darüber zu zeichnen. Auch bezeichnen wir h als die Höhe der Liste.
S5
−
S4
−
8
17
S3
−
8
17
25
S2
−
17
25
31
S1
−
8
12
17
25
31
38
S0
−
12
17
25
31
38
8
8
+
8
+
55
+
55
+
8
50
+
8
44
55
8
8
39
+
8
8
20
44
55
Abbildung 4-32: Beispiel einer Skipliste
Wie wir beim Einfügen sehen werden, werden die Schlüssel in der Liste Si+1 zufällig aus der
Liste Si so ausgewählt, dass jeder Schlüssel in Si mit einer Wahrscheinlichkeit von 12 auch
in Si+1 ist. So erwarten wir, dass S1 etwa n2 , S2 etwa n4 und im allgemeinen Si etwa 2ni
Schlüssel enthält. Mit anderen Worten erwarten wir, dass die Höhe h etwa log2 (n) sein wird.
Die Halbierung der Elemente von einer Liste zur anderen wird aber nicht als Eigenschaft der
Skipliste gefordert. Es wird nur mit Hilfe von Randomisierung in etwa erzielt.
Wir können die Skipliste als zweidimensionale Struktur von Positionen ansehen die Horizontal
in Ebenen und vertikal in Türme angeordnet sind. Durch diese Positionen können wir nun
mit den vier folgenden Operationen navigieren:
4-42
after(p)
before(p)
below(p)
above(p)
Gibt
Gibt
Gibt
Gibt
Position
Position
Position
Position
nach p in derselben Ebene zurück.
vor p in derselben Ebene zurück.
unterhalb p im selben Turm zurück.
oberhalb p im selben Turm zurück.
Falls die gesuchte Position nicht existiert geben die vier Funktionen null zurück. Mit diesen
Hilfsoperationen können wir nun das Suchen, das Einfügen und das Löschen eines Schlüssels
beschreiben.
4.2.6.2
Suchen eines Schlüssels in der Skipliste
Der folgende Algorithmus findet den grössten Schlüssel der kleiner oder gleich k ist.
public SkipPosition skipSearch(k) {
SkipPosition p;
// p ist erstes Element der obersten Liste
p = topLeft;
while (below(p) != null) {
p = below(p);
while(after(p).key() <= k {
p = after(p);
}
}
}
Das Suchen ist in der Abbildung 4-33 dargestellt. Wir suchen das Element 50.
S5
−
S4
−
8
17
S3
−
8
17
25
S2
−
17
25
31
S1
−
8
12
17
25
31
38
S0
−
12
17
25
31
38
8
8
+
8
+
55
+
55
+
8
50
+
8
44
55
8
8
39
+
8
8
20
44
55
Abbildung 4-33: Suchen des Schlüssels 50 in der Skipliste
4.2.6.3
Einfügen eines Schlüssels in der Skipliste
Beim Einfügen eines neuen Schlüssels k suchen wir zuerst den Schlüssel und bekommen die
position des grössten Schlüssels der kleiner oder gleich k ist. In der untersten Liste wird k
gerade nach dieser Position eingefügt. Anschliessend benutzen wir die Random-Funtion um
zu entscheiden in welchen Listen der neue Schlüssel eingefügt werden soll. Solange der Wert
von Random kleiner als 21 ist, wird der Schlüssel auch in der nächsthöheren Liste eingetragen.
Der Algorithmus ist nachfolgend angegeben.
4-43
public void skipInsert(k) {
SkipPosition p;
p = skipSearch(k);
insertAfter(p, k);
while (random() < 1/2) {
while (above(p) == null) {
p = before(p);
}
p = above(p);
insertAfter(p, k);
}
}
Das Einfügen eines Schlüssels ist in der Abbildung 4-34 dargestellt. Der Schlüssel 42 wird
eingefügt.
S5
−
S4
−
8
17
S3
−
8
17
25
S2
−
17
25
31
S1
−
8
12
17
25
31
38
S0
−
12
17
25
31
38
8
8
+
8
+
+
42
55
+
55
+
55
+
42
44
50
8
44
8
42
8
8
39
55
8
8
20
42
Abbildung 4-34: Einfügen des Schlüssels 42 in der Skipliste
4.2.6.4
Löschen eines Schlüssels in der Skipliste
Auch das Löschen eines Elementes in der Skipliste ist relativ einfach. Zuerst suchen wir den
Schlüssel. Falls dieser nicht vorhanden ist, so gibt es eine Exception. Sonst finden wir eine
Position p mit Schlüssel k auf der untersten Ebene. Wir entfernen den Schlüssel aus der Liste
und anschliessend auch in den oberen Ebenen, falls der Schlüssel dort vorhanden ist. Der
folgende Algorithmus realisiert das Löschen.
public void skipRemove(k) {
SkipPosition p;
p = skipSearch(k);
if (p.key() != k) {
throw new KeyNotFoundException();
}
while (p != null) {
removeAfter(before(p));
p = above(p);
}
}
4-44
Das Löschen eines Schlüssels ist in der Abbildung 4-35 dargestellt. Dabei wird der Schlüssel
25 entfernt.
S5
−
S4
−
8
17
S3
−
8
17
25
S2
−
17
25
31
S1
−
8
12
17
25
31
38
S0
−
12
17
25
31
38
8
8
+
8
+
55
+
55
+
8
50
+
8
44
55
8
8
39
+
8
8
20
44
55
Abbildung 4-35: Löschen des Schlüssels 25 aus der Skipliste
Bemerkung 4.6 [Operationen before und above] Die Operationen before und above
sind nicht unbedingt notwendig, da wir uns beim Suchen des Elements Pointers auf das letzte
Element jeder Ebene merken können. Somit ist es nicht nötig, die Elemente der Liste doppelt
zu verlinken.
4.2.6.5
Pointer auf das linke oberste Element
Jede Skipliste muss einen Pointer topLeft auf das oberste linke Element besitzen, da die
Suche in der Struktur immer dort beginnt. Ferner muss definiert werden was passieren soll,
wenn beim Einfügen eines neuen Elementes der entstehende Turm höher wird als die aktuelle
Höhe der Skipliste. Dafür gibt es zwei Strategien:
• Wir beschränken die maximale Höhe der Liste auf einen fixen Wert, der davon abhängig
ist, wieviele Elemente in der Liste sind. Wir werden bei der Analyse sehen, dass der
Wert h = max(10, 3 · ⌈log2 (n)⌉) eine vernünftige Wahl ist.
• Die andere Möglichkeit ist, wie im gegebenen Algorithmus, die Höhe wachsen zu lassen,
solange random < 1/2 erfüllt ist. Wir werden in der Analyse des Algorithmus sehen,
dass die Wahrscheinlichkeit, dass die Höhe grösser wird als O(log2 (n)) sehr klein ist.
Daher ist diese Implementation auch möglich.
4.2.6.6
Analyse der Komplexität
Abschätzen der maximalen Höhe einer Skipliste
Als erstes wollen wir den Erwartungswert für die Höhe h der Liste S bestimmen. Wir nehmen
an, dass beim Einfügen eines Elementes in der Skipliste die Höhe nicht beschränkt wird. Die
Wahrscheinlichkeit, dass ein gegebener Schlüssel in der Ebene i gespeichert ist, ist 21i . Daher
ist die Wahrscheinlichkeit Pi , dass überhaupt ein Schlüssel auf der Ebene i gespeichert ist
höchstens:
Pi ≤
n
2i
Dies gilt, weil die Wahrscheinlichkeit, dass irgend ein Ereigniss von n verschiedenen Ereignissen
eintrifft höchstens die Summe der Wahrscheinlichkeit der einzelen Ereignisse ist.
4-45
Die Wahrscheinlichkeit, dass die Höhe h von S grösser als i ist, ist gleich der Wahrscheinlichkeit, dass die Ebene i der Skipliste mindestens ein Element enthält und das ist nicht mehr als
Pi . Das heisst, h ist höher als 3 log2 (n) mit einer Wahrscheinlichkeit von höchstens
n
1
n
= 3 = 2
n
n
23 log2 (n)
P3 log2 (n) ≤
Wir können diese Aussage veralgemeinern:
Pc log2 (n) =
1
nc−1
f ür
c>1
Das heisst, die Wahrscheinlichkeit, dass h kleiner oder gleich c log2 (n) ist, ist mindestens
1
. Also ist die Höhe h einer Skipliste mit hoher Wahrscheinlichkeit O(log2 (n)).
1 − nc−1
Anzahl Operationen beim Suchen
Beim Suchen eines Schlüssels k in der Skipliste haben wir zwei ineinandergeschachtelte whileSchlaufen. Die innere Schlaufe sucht vorwärts auf einer Ebene der Skipliste nach einem Element das grösser oder gleich ist als k. Die äussere Schlaufe läuft von der Höhe h der Skipliste
bis auf die Ebene 0. Da die Höhe h mit hoher Wahrscheinlichkeit O(log2 (n)) ist, ist die Anzahl
Iterationen in der äusseren Schlaufe mit hoher Wahrscheinlichkeit O(log2 (n)).
Nun müssen wir noch die Anzahl Vorwärtsschritte abschätzen, die wir bei jeder Ebene machen. Sei ni die Anzahl Schlüssel, die auf der Ebene i mit k verglichen werden. Man beachte,
dass spätestens beim ersten Schlüssel, der auch zur Ebene i + 1 gehört, die Suche abgebrochen wird. Dies ist so, weil dieser Schlüssel bei der Suche schon auf einer höheren Ebene
verglichen wurde und grösser als k ist. Die Wahrscheinlichkeit, dass ein Schlüssel der in der
Ebene i gespeichert ist auch in der Ebene i + 1 gespeichert ist, ist ja 1/2. Das heisst, der
Erwartungswert der Anzahl Vorwärtsschritte auf der Ebene i ist 2. Der Erwartungswert für
die Anzahl vorwärtsschritte auf jeder Ebene der Skipliste ist O(1). Da die Skipliste mit hoher
Wahrscheinlichkeit eine Höhe von O(log2 (n)) hat, so gilt:
Komplexität der Suche in einer Skipliste = O(log2 (n))
Ähnlich kann man zeigen, dass der Erwartungswert für die Anzahl Operationen beim Einfügen
und beim Löschen eines Schlüssels auch O(log2 (n)) ist.
Platzbedarf für die Skipliste
Wir wollen am Schluss noch zeigen, dass der Platzbedarf für die Skipliste nicht zu gross ist. Wie
wir oben schon festgestellt haben, ist der Erwartungswert der Anzahl Schlüssel auf der Ebene
i gleich 2ni . Daraus können wir den Erwartungswert für die gesammte Anzahl gespeicherter
Schlüssel in S berechnen und die ist:
h
X
n
i=0
2i
=n
h
X
1
i=0
2i
< 2n
Das heisst, der erwartete Platzbedarf für die Skipliste S ist O(n)
4-46
Kapitel 5
Graphen
5.1
Einleitung
Sehr viele Probleme können auf natürliche Weise mit Hilfe von Objekten und Verbindungen zwischen diesen Objekten dargestellt werden. Im folgenden wollen wir als erstes solche
Beispiele betrachten.
5.1.1
Klassische Probleme
• Königsberger Brückenproblem (Euler 1736):
Gibt es einen Spaziergang so, dass jede Brücke in der Abbildung 5-1 genau einmal
begangen wird und dass man sich am Schluss wieder am Ausgangspunkt befindet?
B
Fluss
A
D
C
Abbildung 5-1: Königsberger Brückenproblem
• Hamilton Problem (Hamilton 1859):
Kann ein Graph (Netz) derart durchlaufen werden, dass jeder Knoten genau einmal
besucht wird?
5.1.2
Angewandte Probleme
• Finden eines kürzesten und eines billigsten Weges von einer Stadt zu einer anderen.
5-1
• Erstellen eines Netzplanes für die Projektplanung, so dass die Abfolge von Tätigkeiten
logisch richtig ist. Finde in diesem Netzplan einen Lösungsweg so, dass das Projekt in
minimaler Zeit fertiggestellt werden kann.
• Darstellung von Petrinetzen.
• Färben der Länder in einer Landkarte mit einer minimalen Anzahl Farben.
• Darstellung der Verknüpfung der Atome in einem Molekül.
• Finden der optimalen Verkabelung für einen Neubau.
• Erstellen von Verkehrsleitsystemen, die Autos auf dem schnellstmöglichen Weg zum Ziel
führen.
• usw.
5.2
5.2.1
Begriffe
Gerichtete Graphen
In der Graphentheorie gibt es sehr viele Begriffe, die wir nachstehend definieren wollen.
Definition 5.1 [Gerichteter Graph] Ein gerichteter Graph G = (V, E) besteht aus:
1. einer Menge V von Knoten (engl: vertices) und
2. einer binären Relation E auf V . Die Elemente (u, v) von E werden gerichtete Kanten (engl: edges) des gerichteten Graphen genannt. Eine gerichtete Kante ist also ein
geordnetes Paar von Knoten.
Falls (u, v) eine Kante des Graphen ist so sagt man, dass die Knoten u und v adjazent sind.
Eine Kante e heisst inzident zum Knoten u, falls u einer der Endpunkte der Kante ist.
Gerichtete Graphen können graphisch wie in der Abbildung 5-2 dargestellt werden. Jeder
Knoten wird durch einen Kreis und einen Namen dargestellt und jede Kante durch einen
Pfeil.
N = {0, 1, 2, 3, 4}
A = {(0,0), (0,2), (1,0), (1,4), (2,1), (2,3), (3,1), (4,2) (4,3)}
0
1
4
2
3
Abbildung 5-2: Beispiel eines gerichteten Graphen
5-2
In Texten ist es üblich, eine gerichtete Kante (u, v) als u → v darzustellen.
Definition 5.2 [Vorgänger und Nachfolger] Wenn u → v eine gerichtete Kante ist, so
ist u ein Vorgänger (engl: predecessor) von v und entsprechend ist v ein Nachfolger (engl:
successor) von u.
Beispiel 5.1 [Vorgänger und Nachfolger] Im Beispiel in der Abbildung 5-2 bedeutet die
gerichtete Kante 1 → 0, dass 0 ein Nachfolger von 1 ist. Die gerichtete Kante 0 → 0 bedeutet,
dass 0 sowohl sein eigener Vorgänger wie auch sein eigener Nachfolger ist.
Definition 5.3 [Pfad in gerichteten Graphen] Ein Pfad (engl: path) in einem gerichteten
Graphen ist eine Liste von Knoten (v1 , v2 , . . . , vk ) so, dass von jedem Knoten zum nächsten
Knoten in der Liste eine gerichtete Kante existiert. Das heisst, vi → vi+1 i = 1, 2, . . . , k − 1
Die Länge des Pfades ist die Anzahl der gerichteten Kanten im Pfad also k − 1.
Der triviale Fall k = 1 ist auch erlaubt. Das heisst, jeder Knoten v ist für sich ein Pfad der
Länge 0 von v nach v.
Beispiel 5.2 [Pfad] Im Graphen der Abbildung 5-2 bilden die Knoten (0, 2, 3) einen Pfad
der Länge 2.
Definition 5.4 [Zyklus] Ein Zyklus (engl: cycle) in einem gerichteten Graphen ist ein Pfad
der Länge ≥ 1, mit gleichem Begin- und Endknoten.
Ein Zyklus (v1 , v2 , . . . , vk , v1 ) heisst einfach (engl: simple), wenn die Knoten v1 , v2 , . . . , vk alle
verschieden sind.
Ein Zyklus der Länge 1 heisst auch Schlinge (engl: loop).
Ein Graph heisst zyklisch (engl: cyclic), wenn er ein oder mehrere Zyklen enthält sonst azyklisch (engl: acyclic).
Beispiel 5.3 [Zyklen] Der Graph in der Abbildung 5-2 enthält mehrere Zyklen. (1, 0, 2, 3, 1)
ist ein einfacher Zyklus. (0, 0, 0) oder (1, 0, 0, 2, 3, 1) sind nicht einfache Zyklen.
Man beachte, dass ein Pfad der Länge 0 kein Zyklus bildet.
Satz 5.1 [Knoten in Zyklen] Jeder Knoten, der in einem Zyklus enthalten ist, ist auch in
einem einfachen Zyklus enthalten.
5.2.2
Ungerichtete Graphen
In vielen Fällen macht es Sinn, Knoten über ungerichtete Kanten zu verbinden. Formal ist
eine ungerichtete Kante eine Menge die zwei Knoten enthält.
Definition 5.5 [Ungerichteter Graph] Ein ungerichteter Graph (engl: undirected graph)
G = (V, E) besteht aus einer Menge von Knoten V und einer Menge von ungerichteten Kanten
(engl: edges) E. Eine Ungerichtete Kante ist eine Menge, die genau zwei Knoten enthält.
Falls {u, v} eine ungerichtete Kante ist sagt man, dass die Knoten u und v adjazent sind.
Eine Kante e heisst inzident zum Knoten u, falls u einer der Endpunkte der Kante ist.
Beispiel 5.4 [Ungerichteter Graph] In der Abbildung 5-3 sind einige Bahnverbindungen
zwischen einigen Städten als Graphen dargestellt. In diesem Fall ist es Sinnvoll mit einem
ungerichteten Graphen zu arbeiten, da die Züge immer in beide Richtungen fahren können.
Definition 5.6 [Pfad in ungerichteten Graphen] Ein Pfad (engl: path) in einem ungerichteten Graphen ist eine Liste von Knoten (v1 , v2 , . . . , vk ) so, dass von jedem Knoten zum
nächten Knoten in der Liste eine ungerichtete Kante existiert. Das heisst, {vi , vi+1 } ∈ E i =
1, 2, . . . , k − 1
5-3
K = {Bern, Lausanne, Genf, Thun, Brig, Sion}
E = {{Lausanne,Genf}, {Lausanne,Sion},{Lausanne,Bern},{Bern,Thun},{Thun,Brig},{Brig,Sion}}
Bern
Thun
Lausanne
Brig
Genf
Sion
Abbildung 5-3: Beispiel eines ungerichteten Graphen
Die Länge des Pfades ist die Anzahl der ungerichteten Kanten im Pfad also k − 1.
Der triviale Fall k = 1 ist auch erlaubt. Das heisst, jeder Knoten v ist für sich ein Pfad der
Länge 0 von v nach v.
Die Definition von Zyklen in ungerichteten Graphen ist jedoch etwas schwieriger als in gerichteten Graphen. In der Abbildung 5-3 wollen wir den Pfad ({Sion,Brig,Sion}) sicher nicht als
Zyklus betrachten. Wir müssen diesen Fall also ausschliessen. Die einfachste Variante ist im
folgenden angegeben:
Definition 5.7 [Einfacher Zyklus im ungerichteten Graphen] Ein einfacher Zyklus in
einem ungerichteten Graphen ist ein Pfad (v1 , v2 , . . . , vk , v1 ) der Länge ≥ 3 der im gleichen
Knoten beginnt und endet, wobei die Knoten v1 , v2 , . . . , vk alle verschieden sind.
Bemerkung 5.1 [Zyklen in ungerichteten Graphen] In ungerichteten Graphen ist das
Konzept eines nicht einfachen Zyklus im allgemeinen nicht sehr nützlich und wir werden es
hier auch nicht weiter verfolgen.
5.3
Der Graph als ADT
Der Graph ist eine komplexere Datenstruktur als die Datenstrukturen, die wir bis jetzt behandelt haben. Diese Komplexität kommt daher, dass Graphen zwei Arten von Objekten
enthalten die Knoten und die Kanten. Daraus ergeben sich viele Methoden, die vom ADT
angeboten werden müssen. Ferner gibt es ja ungerichtete und gerichtete Graphen was die Beschreibung nicht unbedingt erleichtert. Dieses Problem kann mit Hilfe von Vererbung gelöst
werden dabei definieren wir eine abstrakte Klasse Graph mit zwei Subklassen DirectedGraph
und UndirectedGraph.
Da sowohl für Knoten wie auch für Kanten Zusatzinformationen existieren können, speichern
wir zu jedem Knoten und zu jeder Kante ein Object o.
5.3.1
Methoden des Graphen
Wir beginnen mit den Methoden, die zu jedem Graphen gehören und auch in dieser Klasse
implementiert werden können.
5-4
verticeCount():
edgeCount():
vertices():
edges():
endVertices(e):
areAdjacent(v,w):
getEdge(v,w):
Gibt
Gibt
Gibt
Gibt
Gibt
Gibt
Gibt
die Anzahl Knoten des Graphen zurück
die Anzahl Kanten zurück
einen Iterator über die Knoten zurück
einen Iterator über die Kanten zurück
die beiden Endknoten einer Kante zurück
zurück, ob die Knoten v un w adjazent sind.
die Kante zwischen v und w zurück.
Die folgenden Methoden dienen dazu den Graphen zu verändern.
abstract insertEdge(v,w,o):
insertVertex(o):
removeEdge(e):
removeVertex(v):
5.3.2
Einfügen einer Kante.
Einfügen eines Knotens
Löscht die Kante e.
Löscht den Knoten v und alle inzidenten Kanten.
Methoden des gerichteten Graphen
Diese Methoden sind nur für einen gerichteten Graphen sinnvoll. Man beachte, dass die Methode insertEdge() hier speziell für gerichtete Graphen implementiert wird.
insertEdge(v,w,o):
origine(e):
destination(e):
inIncident(v):
outIncident(v):
5.3.3
Einfügen einer gerichteten Kante von v nach w.
Gibt den Anfangspunkt der Kante zurück.
Gibt den Endpunkt der Kante zurück.
Gibt einen Iterator über alle eingehenden Kanten von v zurück.
Gibt einen Iterator über alle ausgehenden Kanten von v zurück.
Methoden des ungerichteten Graphen
Die folgenden Methoden werden nur für ungerichtete Graphen angeboten. Man beachte, dass
auch hier die Methode insertEdge() speziell für ungerichtete Graphen implementiert wird.
insertEdge(v,w,o):
incident(v):
5.4
Einfügen einer ungerichteten Kante zwischen v und w.
Gibt einen Iterator über alle zu v inzidenten Kanten zurück.
Implementation von Graphen
Es gibt zwei verschiedene “standards” um Graphen im Computer darzustellen. Die eine ist
die Adjazenzliste, die ganz allgemein zur Darstellung von binären Relationen verwendet wird
und die Adjazenzmatrix. Welche Darstellung Sinnvoll ist hängt vom betrachteten Graphen
und von den gewünschten Operationen ab.
5.4.1
Implementation als Adjazenzliste
Bei dieser Darstellung wird zu jedem Knoten eine verkettete Liste aller Nachfolger geführt.
In Java kann die Definition folgendermassen lauten:
5-5
import java.util.LinkedList;
public class Knoten {
int
nodeNr;
LinkedList<Knoten> next;
}
Der Graph kann nun als Array von solchen Listen definiert werden also:
Knoten successor[]
Der Eintrag successor[u] enthält also eine Liste aller Nachfolger des Knotens u. Die Adjazenzliste für den Graph der Abbildung 5-2 ist in der Figur 5-4 angegeben.
0
0
2
1
0
4
2
1
3
3
1
4
2
3
Abbildung 5-4: Adjazenzliste
Falls der Graph ungerichtet ist, werden die Knoten einfach symetrisch in den Listen aufgenommen. Das heisst, falls die ungerichtete Kante {u, v} existiert, so wird in der Liste des
Knotens u der Knoten v eingetragen und in der Liste des Knotens v der Knoten u. In der
Abbildung 5-5 ist die Adjazensliste für den Graphen in der Abbildung 5-3 angegeben.
Bern
Lausanne
Thun
Lausanne
Bern
Genf
Genf
Lausanne
Sion
Brig
Lausanne
Thun
Bern
Brig
Brig
Sion
Thun
Sion
Abbildung 5-5: Adjazenzliste für ungerichteten Graphen
5.4.2
Implementation als Adjazenzmatrix
In dieser Implementation wird der Graph einfach als Boolsche-Matrix dargestellt.
boolean arcs[][] = new boolean [KMAX][KMAX]
Der eintrag arcs[u,v] ist wahr, wenn eine gerichtete Kante u → v im Graphen existiert. In
der Tabelle 5-6 ist der Graph aus der Abbildung 5-2 als Adjazenzmatrix dargestellt:
5-6
0
1
2
3
4
0
1
1
0
0
0
1
0
0
1
1
0
2
1
0
0
0
1
3
0
0
1
0
1
4
0
1
0
0
0
Abbildung 5-6: Adjazenzmatrix
Entsprechend können natürlich auch ungerichtete Graphen dargestellt werden. In diesem Fall
ist die Adjazenzmatrix immer symmetrisch. Der Graph der Abbildung 5-3 ist in der Tabelle
5-7 als Adjazenzmatrix dargestellt.
Bern
Lausanne
Genf
Sion
Thun
Brig
Bern
0
1
0
0
1
0
Lausanne
1
0
1
1
0
0
Genf
0
1
0
0
0
0
Sion
0
1
0
0
0
1
Thun
1
0
0
0
0
1
Brig
0
0
0
1
1
0
Abbildung 5-7: Adjazenzmatrix für ungerichteten Graphen
5.4.3
Vergleich der Methoden
Im allgemeinen wird die Adjazenzmatrix dann bevorzugt, wenn der Graph dicht (engl: dense)
ist. Das heisst, die Anzahl Kanten des Graphen ist fast maximal (das Maximum für einen
gerichteten Graphen mit n Knoten ist n2 ).
Falls der Graph licht (engl: sparse) ist (nur wenige der möglichen Kanten sind vorhanden), so
kann es manchmal von Vorteil sein, den Graphen als Adjazensliste darzustellen. Der Vorteil
ist, dass bei grossen lichten Graphen weniger Platz verwendet wird.
Wir nehmen an, dass integers und Pointers je 32 Bits verwenden und dass Boolsche Variablen
mit einem Bit dargestellt werden. n sei die Anzahl Knoten und a die Anzahl Kanten im
Graphen. Für die Darstellung als Adjazenzliste benötigen wir also 32 · n + 64 · a Bits zur
darstellung des Graphen. Für die Darstellung als Adjazenzmatrix benötigen wir n2 Bits. Wir
bestimmen nun, für welches a die Darstellung als Liste kleiner wird. Aus
32 · n + 64 · a < n2
erhalten wir
a<
Falls n gross ist, können wir
n
2
n2 n
−
64
2
vernachlässigen und erhalten
a<
n2
64
5-7
1
der möglichen Kanten vorhanden sind, so lohnt
Mit anderen Worten heisst das, wenn nur 64
es sich den Graphen als Liste zu implementieren (natürlich nur für grosse n).
5.5
Algorithmen auf Graphen
5.5.1
Tiefensuche (Depth-First Search)
Die Tiefensuche bei Graphen kann mit der rekursiven Traversierung von Bäumen verglichen
werden. Wie dort beginnen wir bei einem Knoten und besuchen alle seine Nachfolger (Nachbarn). Das einzige Problem ist, dass diese Methode für zyklische Graphen unendlich ist. Wir
müssen daher alle Knoten die besucht werden speziell markieren.
Allgemeines Vorgehen
1. Alle Knoten als “unbesucht” markieren
2. Für jeden Knoten k der als “unbesucht” markiert ist besuche(k)
Die Prozedur besuche(k) kann sowohl rekursiv wie auch iterativ implementiert werden. Sie
besucht alle Knoten, die vom Knoten k aus über einen Pfad erreichbar sind und noch nie
besucht wurden.
Rekursive Implementation von besuche
1. markiere k als “besucht”
2. Für alle Nachfolger kn von k die als “unbesucht” markiert sind besuche(kn)
Implementation von besuche mit einem Stack
1. Knoten k als besucht markieren und auf den Stack legen.
2. Solange der Stack nicht leer ist:
(a) k = pop
(b) Alle unmarkierten Nachfolger kn von k als besucht markieren und auf den Stack
ablegen.
Die folgende Prozedur implementiert die Tiefensuche (Java-artig) rekursiv.
Algorithmus 5.1 [Tiefensuche]
boolean arc[][] = new boolean[KMAX][KMAX];
boolean visited[] = new boolean[KMAX];
public void search() {
for (int i = 0; i < KMAX; i++)
visited[i] = false;
for (int i = 0; i < KMAX; i++)
if (visited[i] == false)
5-8
visit(i);
}
private void visit(int k) {
visited[k] = true;
// Knoten behandeln
for (int i = 0; i < KMAX; i++)
if (arc[k][i] == true)
if (visited[i] == false)
visit(i);
}
5.5.2
Breitensuche (Breath-First Search)
Die Breitensuche entsteht dadurch, dass wir in der nicht rekursiven Implementation der Tiefensuche den Stack durch eine Warteschlange ersetzen. Nun werden zuerst alle ersten “Nachkommen” eines Knotens besucht und dann alle “Nachkommen” der zweiten Generation usw.
Daher nennt man diese Art der Traversierung Breitensuche.
Implementation von besuche mit einer Warteschlange
Algorithmus 5.2 [Breitensuche]
1. Knoten k als besucht markieren und in die Queue eintragen.
2. Solange die Queue nicht leer ist:
(a) k = dequeue
(b) Alle unmarkierten Nachfolger kn von k als besucht markieren und in die Queue
ablegen.
Bemerkung 5.2 [Gerichtet und Ungerichtet] Die oben angegebenen Algorithmen funktionieren sowohl für gerichtete wie auch für ungerichtete Graphen. In beiden Fällen wird jeder
Knoten des Graphen genau einmal besucht.
5.5.3
Tiefensuche Wald
Wir können verfolgen, in welcher Reihenfolge die Knoten eines gerichteten Graphen bei der
Tiefensuche durchlaufen werden. Dabei kann für jeden Aufruf der Prozedur visit in search
ein Tiefensuchbaum folgendermassen definiert werden:
Wird die Prozedur visit für den Knoten v vom Knoten u aus aufgerufen, so ist u ein direkter
Vorgänger vom Knoten v im Tiefensuchbaum. Die direkten Nachfolger von u werden von links
nach rechts im Baum eingetragen.
Die Menge der so erhaltenen Tiefensuchbäume wird Tiefensuchwald genannt.
Beispiel 5.5 [Tiefensuche Wald] In der Abbildung 5-8 ist ein gerichteter Graph angegeben.
In der Abbildung 5-9 ist der entsprechende Tiefensuchwald abgebildet.
In der Abbildung 5-9 sind alle Kanten des Graphen, die nicht zum Tiefensuchwald gehören
gestrichelt eingetragen.
Wir können nun die Kanten eines Graphen G bezüglich seines Tiefensuchwaldes in vier Kategorien aufteilen:
5-9
1
3
6
5
8
9
10
11
7
4
2
12
13
Abbildung 5-8: Ein gerichteter Graph
1
10
8
2
3
6
11
12
9
4
5
13
7
Abbildung 5-9: Tiefenwald zum obigen Graphen
1. Baumkanten
Kanten u → v für die gilt, dass visit(v) von visit(u) aufgerufen wird.
2. Vorwärtskanten
Kanten u → v für die gilt, dass u ein Vorgänger von v im Tiefensuchbaum ist, aber kein
direkter Vorgänger. In der Abbildung 5-9 ist die Kante 10 → 13 eine Vorwärtskante.
3. Rückwärtskanten
Kanten u → v für die gilt, dass u ein Nachfahre von v im Baum ist. Der Fall u = v
(Schlinge) gilt auch als Rückwärtskante. In der Abbildung 5-9 sind die Kanten 5 → 2,
7 → 1 und 8 → 8 Rückwärtskanten.
4. Querkanten
Kanten u → v für die gilt, dass keines der beiden Knoten im Tiefenwald ein Vorfahre
des anderen ist. In der Abbildung 5-9 sind die Kanten 6 → 3 und 7 → 5 Querkanten.
Bemerkung 5.3 [Orientierung der Querkanten] Wir sehen, dass alle Querkanten in der
Abbildung 5-9 von rechts nach links verlaufen. Dies ist kein Zufall und kommt daher, dass die
Nachfahren eines Knotens u von links nach rechts im Tiefenbaum eingetragen werden.
5.5.4
Postorder Nummerierung
Wenn wir einmal ein Tiefensuchbaum (Tiefensuchwald) des Graphen besitzen, können wir diesen in Postorder traversieren und die besuchten Knoten in dieser Reihenfolge nummerieren.
Die Nummerierung der Knoten in postorder kann aber auch direkt während der Tiefensuche
5-10
geschehen. Der Algorithmus für die Tiefensuche muss in diesem Fall folgendermassen abgeändert werden.
Algorithmus 5.3 [Postorder Nummerierung]
boolean arc[][] = new boolean[KMAX][KMAX];
boolean visited[] = new boolean[KMAX];
int postnr[] = new int[KMAX];
int k1;
public void search() {
for (int i = 0; i < KMAX; i++)
visited[i] = false;
k1 = 0;
for (int i = 0; i < KMAX; i++)
if (visited[i] == false)
visit(i);
}
private void visit(int k) {
visited[k] = true;
// Knoten behandeln
for (int i = 0; i < KMAX; i++)
if (arc[k][i] == true)
if (visited[i] == false)
visit(i);
// Postorder Nummer verteilen
++k1;
postnr[k] = k1;
}
Zwischen der Postordernummerierung der Knoten eines Graphens und den vier Arten von
gerichteten Kanten besteht der folgende Zusammenhang.
1. Falls u → v eine Baumkante oder Vorwärtskante ist, so ist v ein Nachfahre von u im
Tiefenbaum und somit ist die Postordernummer von v kleiner als die von u.
2. Falls u → v eine Querkante ist, so wissen wir, dass v links von u im Baum liegt. Auch
hier ist also die Postordernummer von v kleiner als die von u.
3. Falls u → v eine Rückwärtskante ist und u 6= v, dann ist u ein Nachkomme von v
im Baum und hat daher eine kleinere Postordernummer als v. Im Fall u = v ist die
Postordernummer gleich. Wir können also sagen, eine Kante u → v ist dann und nur
dann eine Rückwärtskante, wenn die Postordernummer von u kleiner oder gleich der
Postordernummer von v ist.
5.5.5
5.5.5.1
Anwendungen der Tiefensuche
Finden von Zyklen in einem gerichteten Graphen
Ein gerichteter Graph, der eine Rückwätskante besitzt ist zyklisch. Diese Aussage ist nach
Definition der Rückwärtskante banal (siehe auch die Abbildung 5-10).
5-11
v
u
Abbildung 5-10: Jede Rückwärtskante bildet mit Baumkanten einen Zykel
Die Umkehrung der Aussage gilt aber auch. Das heisst, in einem zyklischen Graphen existiert
mindestens eine Rückwärtskante. Um dies zu sehen betrachten wir einen Zyklus (v1 , v2 , . . . , vk , v1 ).
Wir bezeichnen mit pi die Postordernummer von vi für i = 1, . . . , k. Falls keine der Kanten
v1 → v2 bis vk−1 → vk eine Rückwärtskante ist, so gilt p1 > p2 > · · · > pk . Insbesondere gilt
dann pk < p1 und somit ist die Kante vk → v1 eine Rückwärtskante.
Wir können also einen Algorithmus schreiben, der entscheidet ob ein gerichteter Graph zyklisch ist oder nicht.
1. Führe die Tiefensuche auf den Graphen durch und nummeriere die Knoten in postorder.
2. Für alle gerichteten Kanten u → v
Falls pu ≤ pv return zyklisch
3. return azyklisch
5.5.5.2
Topologisches Sortieren
Gegeben sei ein gerichteter und azyklischer Graph G. Wir können mit der Tiefensuche jedem
Knoten im Graphen eine Postordernummer zuweisen. Nun bilden wir eine Liste (v1 , v2 , . . . , vn )
aller Knoten des Graphen mit der Eigenschaft, dass pi > pj f alls i < j.
Diese Ordnung der Knoten hat die Eigenschaft, dass für alle gerichteten Kanten des Graphen
vi → vj gilt: i < j. Das heisst, alle Kanten gehen in dieser Ordnung vorwärts.
Eine solche Ordnung heisst topologische Ordnung des Graphen.
Bemerkung 5.4 [Topologische Ordnung nicht eindeutig] Die topologische Ordnung ist
im allgemeinen nicht eindeutig wie das Beispiel in der Abbildung 5-11 zeigt. Dort ist sowohl
die Liste (1, 5, 2, 3, 4, 6, 7, 8) wie auch die Liste (1, 5, 2, 6, 3, 7, 4, 8) eine topologische Ordnung.
1
4
3
2
7
6
5
8
Abbildung 5-11: Ein gerichteter azyklischer Graph
Mit Hilfe der Tiefensuche kann eine topologische Ordnung einfach gefunden werden. Jeder
Knoten wird nach der Zuweisung seiner Postordernummer auf einen Stack abgelegt. Am Ende
der Tiefensuche können die Knoten in topologischer Ordnung vom Stack gelesen werden.
5-12
5.5.5.3
Zusammenhang in ungerichteten Graphen
Gegeben sei ein ungerichteter Graph G und eine Relation P auf der Menge K der Knoten des
Graphen, die folgendermassen definiert ist:
uP v ⇔ es existiert ein Pfad von u nach v
Die definierte Relation P auf der Menge der Knoten ist eine Äquivalenzrelation, das heisst:
1. Sie ist reflexsiv. Das heisst uP u ∀u ∈ K
2. Sie ist symmetrisch. Das heisst uP v ⇔ vP u
3. Sie ist transitiv. Das heisst (uP v ∧ vP w) ⇒ uP w
Definition 5.8 [Zusammenhangskomponenten] Die durch die Relation P definierten
Äquivalenzklassen eines ungerichteten Graphen G heissen Zusammenhangskomponenten von
G.
Ein ungerichteter Graph G heisst zusammenhängend, wenn er nur aus einer Zusammenhangskomponente besteht.
Mit Hilfe der Tiefensuche (und des Tiefenwaldes) können die Zusammenhangskomponenten
eines ungerichteten Graphes sehr einfach gefunden werden.
Wir betrachten für diesen Fall einen ungerichteten Graphen als gerichteten Graphen, wobei
jede ungerichtete Kante durch zwei entsprechende gerichtete Kanten in beiden Richtungen
ersetzt werden. Nun können wir den Tiefensuchwald für diesen Graph konstruieren. Die Menge
der Bäume im Wald entspricht dann 1 zu 1 den Zusammenhangskomponenten des Graphen.
In der Abbildung 5-12 ist ein Graph mit seinem entsprechenden Tiefensuchwald angegeben.
Der Graph hat 3 Zusammenhangskomponenten (bzw. drei Bäume im Tiefensuchwald)
1
7
8
9
10
11
6
3
5
4
2
12
13
a) Ein ungerichteter Graph
1
2
4
7
3
5
10
8
9
11
6
12
13
b) Der entsprechende Tiefensuchwald
Abbildung 5-12: Zusammenhangskomponenten eines Graphen
5.5.5.4
Zusammenhang in gerichteten Graphen
In gerichteten Graphen unterscheidet man zwischen schwach zusammenhängenden (weakly
connected) und stark zusammenhängenden Graphen (strongly connected).
Definition 5.9 [schwach zusammenhängender gerichteter Graph] Ein gerichteter
Graph G heisst schwach zusammenhängend, falls aus G durch das Ersetzen aller gerichteten
Kanten durch ungerichtete Kanten ein zusammenhängender ungerichteter Graph G′ entsteht.
5-13
Bei einem stark zusammenhängenden Graphen muss jeder Knoten von jedem Knoten aus
erreichbar sein. Definition 5.10 [stark zusammenhängender gerichteter Graph] Ein
gerichteter Graph G heisst stark zusammenhängend, falls für jedes Paar von Knoten u, v ∈ V
sowohl ein gerichteter Pfad von u nach v wie auch von v nach u existiert.
Wie bei ungerichteten Graphen kann man dann von schwachen und starken Zusammenhangskomponenten sprechen. Dies sind maximale zusammenhängende Subgraphen.
5.6
Gewichtete Graphen
Bei vielen Problemen, die mit Hilfe von Graphen gelöst werden, spielt die Länge (oder die
Kosten) der Kanten eine wichtige Rolle.
Beispiel 5.6 [Gewichteter Graph] Ein Transportunternehmen wird versuchen, die Länge
der Wege zwischen Startort und Zielort zu minimieren. Wenn die Landkarte als Graphen
definiert wird mit den Orten als Knoten und den Strassen als Kanten, so kann das Problem
nun gelöst werden, indem jeder Kante als Gewicht die Distanz zwischen den beiden Ortschaften
zugewiesen wird.
Definition 5.11 [Gewichteter Graph] Ein gewichteter Graph G = (V, E, w) ist ein gerichteter oder ungerichteter Graph mit einer zusätzlichen Funktion w : E 7→ R von der Menge
der Kanten in die reellen Zahlen. Die Zahl w(e) bezeichnet man als Gewicht der Kante e.
5.6.1
Implementation von gewichteten Graphen
Bei der Implementation als Adjazenzliste enthält jedes Listenelement das Gewicht der Kante
als weitere Komponente.
Wenn der Graph als Adjazenzmatrix implementiert wird, so können die Matrixelemente gerade
das Gewicht der Kante enthalten. Dabei muss die Matrix natürlich als Integer- oder FloatMatrix definiert werden.
5.6.2
Minimal aufspanneder Baum
Gegeben sei ein ungerichteter, gewichteter und zusammenhängender Graph G = (V, E, w).
Gesucht wird ein Untergraph G′ = (V, E ′ , w/E ′ ) von G mit folgenden Eigenschaften.
• G′ ist kreisfrei und zusammenhängend. Das heisst, G′ ist ein Baum, der alle Knoten von
G verbindet.
• G′ hat minimale Kosten. Das heisst, die Summe der Gewichte der Kanten in G′ ist
minimal.
Ein solcher Baum heisst minimaler aufspannender Baum von G (minimal spanning tree).
Der folgende Algorithmus stammt von Kruskal. Bei jedem Schritt wird die kürzeste Kante
aus den noch nicht betrachteten Kanten gewählt. Falls diese Kante zur Lösung hinzugefügt
werden kann, ohne das dadurch ein Kreis entsteht, wird dies getan. Sonst wird diese Kante
nie mehr betrachtet. Es werden also keine Züge zurückgenommen. In der Abbildung 5-13 ist
die Methode bildlich dargestellt.
Nachfolgend ist der Algorithmus in Pseudocode angegeben.
5-14
2
1
3
5
8
3
3
4
4
3
1
7
5
6
7
6
3
8
3
3
4
8
3
5
6
5
10
6
7
7
6
7
9
4)
6
6
7
9
9
5)
6)
2
1
8
5
1
7
7
10
3
3
4
1
5
7
2
3
5
4
3
3
2
1
2
4
10
2
1
7
2
3
3
4
2
1
3
5
4
2
3
3
4
5
10
1
4
5
7
7
9
2
4
1
7
6
6
7
3)
1
2
4
7
7
2)
5
5
10
6
2
8
1
7
9
1)
1
3
3
4
4
7
9
6
2
3
5
5
10
6
7
7
8
3
4
10
6
5
2
1
2
3
4
1
7
2
1
2
8
6
7
6
9
6
7
7)
8)
Abbildung 5-13: Algorithmus von Kruskal
E ′ = {}
while (|E ′ | < |K| − 1)
{
suche in E eine Kante k mit w(k) minimal.
E = E \ {k}
if (G′ = (K, E ′ ∪ {k}) hat keinen Kreis)
E ′ = E ′ ∪ {k}
}
Bemerkung 5.5 [Kruskal für unzusammenhängende Graphen] Der Algorithmus von
Kruskal funktioniert auch, wenn der Graph nicht Zusammenhängend ist. In diesem Fall müssen einfach alle Kanten des Graphen untersucht werden. Als Resultat erhält man für jede
Zusammenhangskomponente des Graphen einen minimalen aufspannenden Baum.
Der heickle Punkt im Algorithmus ist festzustellen, ob die Hinzunahme einer Kante zum Resultat einen Kreis bildet oder nicht. Die Hinzunahme einer Kante (u, v) zum Resultat führt
genau dann zu einem Kreis, wenn im bisher konstruierten aufspannenden Baum G′ ein Pfad
von u nach v schon existiert. Das heisst, wenn u und v in der gleichen Zusammenhangskomponente des Graphen G′ enthalten sind. Wir müssen also während der Konstruktion des
aufspannenden Baumes uns merken, welche Knoten in der gleichen Zusammenhangskomponente liegen. Dazu konstruieren wir einen “Wald” (forest) von Bäumen über die Knoten im
ursprünglichen Graphen G. Zwei Knoten von G′ sind dann und nur dann im gleichen Baum,
wenn sie in G′ in derselben Zusammenhangskomponente liegen. Zum Testen, ob die Hinzunahme einer Kante (u, v) einen Kreis bildet, muss man nun testen, ob die beiden Knoten u
5-15
und v schon im gleichen Baum liegen.
Die Konstruktion des Waldes für das Beispiel aus der Abbildung 5-13 ist in der Abbildung
5-14 angegeben.
1
5
2
3
6
3
3
4
1
6
7
2
6
5
1)
3)
1
1
6
4
3
2
6
7
2
2)
1
4
7
5
3
1
4
2
4
7
3
2
4
7
6
7
5
5
5
4)
5)
6)
1
6
3
2
4
7
5
7)
Abbildung 5-14: Bestimmen der Zusammenhangskomponenten
5.6.2.1
Erklärungen
1. Solange wir noch keine Kante aus G ausgewählt haben, ist jeder Knoten aus dem ursprünglichen Graphen G in einer eigenen Zusammenhangskomponente von G′ . Daher
bildet jeder Knoten einen Baum in unserem Wald.
2. Die Kante (3, 5) wird zum Resultat hinzugefügt. Nun sind die Knoten 3 und 5 in der
gleichen Zusammenhangskomponente in G′ und müssen daher im gleichen Baum zu
liegen kommen. Der Knoten 3 wird zur Wurzel dieses Baumes.
3. Die Hinzunahme der Kante (1, 2) bildet keinen Kreis, da beide Knoten in Verschiedenen
Bäume liegen. Nun sind die Knoten 1 und 2 in der gleichen Zusammenhangskomponente
in G′ und müssen daher im gleichen Baum zu liegen kommen. Der Knoten 1 wird zur
Wurzel dieses Baumes.
4. Die Hinzunahme der Kante (2, 5) bildet keinen Kreis, da der Knoten 2 im Baum mit
Wurzel 1 und der Knoten 5 im Baum mit Wurzel 3 liegt. Nach Hinzunahme der Kante
(2, 5) liegen die Knoten beider Bäume in derselben Zusammenhangskomponente. Daher
wird der Baum mit der Wurzel 3 zu einem Unterbaum des Baumes mit der Wurzel 1.
5. Die Kante (3, 2) kann nicht hinzugefügt werden, da die Knoten 3 und 2 im gleichen
Baum, also in der gleichen Zusammenhangskomponente von G′ liegen.
Die Kante (3, 4) kann hinzugefügt werden.
5-16
6. Die Kante (1, 3) kann nicht hinzugefügt werden, da die Knoten 1 und 3 im gleichen
Baum, also in der gleichen Zusammenhangskomponente von G′ liegen.
Die Kante (4, 7) kann hinzugefügt werden.
7. Die Kante (5, 6) kann hinzugefügt werden.
5.6.2.2
Implementation
Um die Kanten nach Gewicht zu sortieren kann ein Heap verwendet werden.
Um den Wald zu konstruieren wird einfach ein Array verwendet, der die Knoten des Graphen G
als Integerzahlen 1, . . . , n repräsentiert. Jeder Knoten enthält die Nummer seines Vorgängers
im Baum. Knoten mit Wert ≤ 0 sind die Wurzeln der Bäume. Um zu entscheiden, ob zwei
Knoten in der gleichen Zusammenhangskomponente liegen, verfolgt man die Vorfahren beider
Knoten bis zur Wurzel des entsprechenden Baumes. Sind die Wurzeln für beide Knoten gleich,
so heisst das, dass beide Knoten in der gleichen Zusammenhangskomponente liegen.
Damit der Baum möglichst flach bleibt, können noch zwei weitere Massnahmen getroffen
werden.
1. In jeder Wurzel wird (negativ) die Anzahl Knoten des entsprechenden Baumes gespeichert. Wenn zwei Bäume zusammengefügt werden, so wird die eine Wurzel zur neuen
Wurzel des zusammengsetzten Baumes. Die Knoten des anderen Baumes rutschen somit
eine Stufe Tiefer im Baum. Damit möglichst viele Knoten nahe bei der Wurzel bleiben,
macht es Sinn, als neue Wurzel die Wurzel des Baumes mit mehr Knoten zu wählen.
2. Wenn wir die Wurzel eines Knotens gefunden haben, können wir den Pfad vom Ausgangsknoten zur Wurzel noch einmal durchlaufen und alle Knoten auf diesem Pfad direkt
an der (jetzt bekannten) Wurzel anhängen. Dadurch bleibt der Baum immer sehr flach.
Wir wollen noch die Komplexität des Algorithmus im schlechtesten Fall betrachten. Nachfolgend bezeichnen wir die Anzahl Kanten im Graphen mit |E|.
Man kann zeigen, dass die Komplexität für den Aufbau des Waldes (falls die obigen Verbesserungen gemacht werden) im schlechtesten Fall O(|E|α(|E|)) beträgt, wobei die Funktion α
so langsam wächst, dass man diese für alle praktischen Fälle als Konstante annehmen kann.
Es gilt:
α(|E|) < 4 bis |E| so gross ist, dass log2 (log2 (. . . 16mal . . . log2 (|E|) . . . )) > 1 ist.
Wir müssen noch die Zeit betrachten, die der Algorithmus für die Heapmanipulationen verwendet. Wir brauchen dort im schlechtesten Fall (2|E|) Heapoperationen. Im ganzen bekommen
wir also O(2|E|log2 (|E|) + |E|α(|E|)) Operationen auf Kanten. Wenn α(E) als Konstante
betrachtet wird gilt:
Komplexität(Kruskal) = O(|E| log2 (|E|))
5.6.3
Kürzeste Pfade in Graphen (Dijkstra)
In gewichteten Graphen taucht sehr oft die Frage nach dem kürzesten Pfad zwischen zwei
Knoten u und v auf. Der Algorithmus von Dijkstra ist eine effiziente Methode um die kürzesten
Pfade von einem gegebenen Knoten (der Source) zu allen anderen Knoten im Graphen zu
5-17
finden. Für die folgenden Betrachtungen nehmen wir an, dass G = (K, E, w) ein ungerichteter
oder gerichteter Graph mit den Knoten K den Kanten E und der Gewichtsfunktion w sei.
Der Algorithmus von Dijkstra unterteilt die Knoten des Graphen G in zwei Mengen S und
K \ S. In S sind alle Knoten, für die die minimale Distanz zur Source s schon bekannt ist
(gesetzte Knoten) und in K \ S, diejenigen für die das noch nicht der Fall ist.
Für alle Knoten u ∈
/ S berechnen wir die Länge des kürzesten sogenannten speziellen Pfades
von s nach u. Ein spezieller Pfad von s nach u ist ein Pfad der Form (s, v1 , . . . , vn , u) mit
vi ∈ S i = 1, . . . , n. Das heisst, nur die letzte Kante des Pfades führt aus S heraus. Falls für
u noch kein solcher Pfad gefunden wurde, setzen wir für die Länge des speziellen Pfades eine
definierte Konstante ∞ (für unendlich) ein.
Zu jedem Knoten u im Graphen wird nun der Wert der Funktion dist(u) folgendermassen
berechnet.
1. Falls u ∈ S, dann ist dist(u) die Länge des kürzesten Pfades von s nach u wobei alle
Knoten des Pfades in S liegen.
2. Falls u ∈
/ S, dann ist dist(u) der Wert des kürzesten speziellen Pfades von s nach u
Die Zusammenhänge sind in der Abbildung 5-15 dargestellt.
G
S
s
v
u
spezieller Pfad
Abbildung 5-15: Gesetzte Knoten und spezielle Pfade
Am Anfang des Algorithmus ist nur die Source s in der Menge S enthalten und dist(s) = 0.
Für alle andern Knoten u ist dist(u) = w((s, u)) falls die Kante s → u existiert, sonst ist
dist(u) = ∞.
Nachfolgend ist angegeben, wie der nächste Knoten nun “gesetzt” wird (d.h. in S aufgenommen wird).
1. Wähle den Knoten v ∈ (K \ S) mit dist(v) ist minimal. Der Knoten v wird in S
aufgenommen und die Länge des kürzesten Pfades von s nach v ist dist(v).
2. Für jeden verbleibenden Knoten u ∈ (K \ S) wird dist(u) nach folgender Formel neu
berechnet:
distneu (u) = min(distalt (u), dist(v) + w((v, u)))
Bemerkung 5.6 [Eigenschaften von Dijkstra]
5-18
• Damit nicht nur die Länge des kürzesten Pfades bekannt ist, sondern auch der kürzeste
Pfad selbst, muss im Punkt zwei bei jedem Knoten auch der Vorgänger gespeichert
werden, der zu diesem speziellen Pfad geführt hat. Der kürzeste Pfad kann anschliessend
rückwärts verfolgt werden.
• In die Menge S werden die Knoten in der Reihenfolge ihrer Distanz zur Source s aufgenommen (nahe Knoten zuerst, weite Knoten am schluss).
• Wenn nur der kürzeste Pfad zwischen zwei Knoten u und v ermittelt werden muss, so
kann der Algorithmus von Dijkstra mit u als Source angewendet werden. Das Prozedere
kann aber abgebrochen werden, sobald der Knoten v gesetzt wird.
Beispiel 5.7 [Dijkstra] Wir wollen den Algorithmus an Hand des Graphen in der Abbildung
5-16 verfolgen.
1
100
10
5
30
60
2
10
4
50
20
3
Abbildung 5-16: Ein gewichteter Graph
gesetzt
1
2
4
3
5
Menge S
1
1,2
1,2,4
1,2,3,4
1,2,3,4,5
dist(2)
10
10
10
10
10
dist(3)
∞
60
50
50
50
dist(4)
30
30
30
30
30
dist(5)
100
100
90
60
60
Wir wollen noch begründen, wieso der Algorithmus überhaupt funktioniert.
Wir müssen folgendes zeigen:
a) Für alle Knoten u in S ist dist(u) die Länge des kürzesten Pfades von s nach u und alle
Knoten des Pfades sind in S.
b) Für jeden Knoten u ∈ (K \ S) ist dist(u) die Länge eines kürzesten speziellen Pfades
von s nach u (∞ falls kein spezieller Pfad existiert).
Induktion über die Anzahl Elemente von S:
• Für k = 1 gehört nur s zu S. Wir setzen dist(s) = 0 und damit ist a) erfüllt. Für alle
andern Knoten u initialisieren wir dist(u) auf w(s → u) falls die Kante existiert, sonst
auf ∞. Damit ist b) auch erfüllt.
5-19
• Wir nehmen an, a) und b) seien für k Elemente erfüllt. v sei der (k+1) Knoten der
gesetzt wird. Wir zeigen, dass dist(v) die Länge des kürzesten Pfades von s nach v ist.
Wir nehmen an, es gäbe einen kürzeren Pfad. Nach Induktionsannahme (Punkt b)) ist
distv(v) die Länge des kürzesten speziellen Pfades von s nach v. Wenn ein kürzerer Pfad
von s nach v existiert, so kann es also kein spezieller Pfad sein. Wie in der Abbildung
5-17 dargestellt, muss dieser Pfad bei einem bestimmten Knoten w (kann auch s sein)
die Menge S verlassen und zu einem Knoten u ∈
/ S ∧ u 6= v führen. Von u aus muss dann
ein Pfad zu v existieren, der sowohl innerhalb wie ausserhalb von S verlaufen kann. Der
Pfad von s über w nach u ist aber ein spezieller Pfad und daher gilt dist(v) ≤ dist(u)
und damit ist der Pfad von s über w und u nach v mindestens so lang wie dist(v). Damit
ist also a) auch für k+1 Elemente wahr.
Man beachte, dass diese Argumentation nur gilt, wenn alle Gewichte positiv sind.
G
S
v
s
w
u
Abbildung 5-17: kürzester Pfad nach v
Wir müssen noch zeigen, dass b) nach der Aufnahme von v in S und der entsprechenden
Korrekturen von dist(u) immer noch gilt. Wir betrachten irgend einen Knoten u ∈
(K \ S). Ein kürzester spezieller Pfad von s nach u enthält immer einen vorletzten
Knoten. Dieser Knoten ist entweder v oder ein anderer Knoten w ∈ S. Diese Tatsache
ist in der Abbildung 5-18 dargestellt. Nach Induktionsannahme (Punkt a)) enthält der
Pfad s nach w nur Knoten, die vor v in S aufgenommen wurden und damit den Knoten
v nicht. Die Aufnahme von v in S verändert die Länge des speziellen Pfades s über
w nach u also nicht. Falls v der vorletzte Knoten im speziellen Pfad ist, so ist nach
Konstruktion der Pfad über v kürzer als der früherere spezielle Pfad und daher auch
wieder minimal. Damit ist auch b) bewiesen.
G
v
S
u
s
w
Abbildung 5-18: Vorletzter Knoten des kürzesten speziellen Pfades
5-20
5.6.4
Kürzeste Pfade in Graphen (Floyd)
Mit dem Algorithmus von Dijkstra können wir alle kürzesten Pfade zwischen einem Knoten
und allen anderen Knoten im Graphen bestimmen. Lassen wir den Algorithmus über alle Knoten des Graphen laufen, so finden wir alle kürzesten Pfade zwischen zwei beliebigen Knoten
im Graphen. Diese Aufgabe kann aber auch mit dem Algorithmus von Floyd gelöst werden.
Wir nehmen wieder an, dass die Knoten des Graphen von 0 . . . n − 1 nummeriert sind. Die
Idee des Algorithmus von Floyd ist, jeden Knoten u nacheinander als Charnier (Pivot) zu
verwenden. Wenn u das Pivot ist, versuchen wir den Weg zwischen beliebigen Knoten v und
w zu verkürzen, indem wir einen Weg über u wählen. In der Abbildung 5-19 ist dieser Vorgang
skizziert.
1
2
3
u
d
n−1
e
v
f
w
Abbildung 5-19: Verwenden des Knotens u um den Weg zwischen v und w zu verkürzen
Die Distanz f zwischen v und w wird durch d + e ersetzt, wenn diese Summe kleiner ist als f .
Nachfolgend ist der Algorithmus von Floyd in Java angegeben.
Algorithmus 5.4 [Floyd]
{
for (v = 0; v < MAX; v++) {
for (w = 0; w < MAX; w++) {
if (v == w) {
dist[v][w] = 0;
} else {
dist[v][w] = arc[v][w];
}
}
}
for (u = 0; u < MAX; u++) {
for (v = 0; v < MAX; v++) {
for (w = 0; w < MAX; w++) {
if (dist[v,u] + dist[u,w] < dist[v,w]) {
dist[v,w] = dist[v,u] + dist[u,w];
}
}
5-21
}
}
}
In den Zeilen 2 bis 10 wird die Adjazenzmatrix arc in die Distanzmatrix dist kopiert. Dabei
ist wichtig, dass in der Diagonale immer 0 eingetragen wird, da ein kürzester Weg nie eine
Schlinge enthält. In den Zeilen 12 bis 20 werden nacheinander alle Knoten als Pivot verwendet
und wenn nötig wird die Distanz zwischen zwei Knoten wie oben beschrieben angepasst.
Beispiel 5.8 [Floyd]
Wir wollen den Algorithmus an Hand des Graphen in der Abbildung 5-20 verfolgen.
0
90
10
4
30
65
1
10
3
50
20
2
Abbildung 5-20: Ein gewichteter Graph
Die Distanzmatrix sieht nach den Zeilen 2 bis 4 des Algorithmus folgendermassen aus.
0
1
2
3
4
0
0
∞
∞
∞
∞
1
10
0
∞
∞
∞
2
∞
50
0
20
∞
3
30
∞
∞
0
∞
4
90
∞
10
65
0
Als erstes stellen wir fest, dass keine Kante zum Knoten 0 führt. Das heisst, über den Knoten 0 kann kein Pfad zu einem anderen Knoten führen. Wir müssen also diesen Pivot nicht
berücksichtigen. Weiter stellen wir fest, dass keine Kante aus dem Knoten 4 herausführt und
daher können wir auch diesen Knoten als Pivot ausschliessen. Nachstehend verfolgen wir den
Algorithmus für die Knoten 1 bis 3.
Pivot = 1
0
1
2
3
4
0
0
∞
∞
∞
∞
1
10
0
∞
∞
∞
Pivot = 2
2
60
50
0
20
∞
3
30
∞
∞
0
∞
4
90
∞
10
65
0
0
1
2
3
4
0
0
∞
∞
∞
∞
Pivot = 3
1
10
0
∞
∞
∞
2
60
50
0
20
∞
5-22
3
30
∞
∞
0
∞
4
70
60
10
30
0
0
1
2
3
4
0
0
∞
∞
∞
∞
1
10
0
∞
∞
∞
2
50
50
0
20
∞
3
30
∞
∞
0
∞
4
60
60
10
30
0
5.6.4.1
Wieso funktioniert Floyds Algorithmus
Wir wollen noch begründen, wieso der Algorithmus von Floyd überhaupt funktioniert.
Wir nehmen wieder an, dass die Knoten im Graphen von 0 bis n − 1 nummeriert sind.
Ein k-Pfad von v nach w ist ein Pfad von v nach w, der keinen Zwischenknoten enthält, der
grösser als k ist. v und w hingegen sind beliebieg.
Ein (−1)-Pfad enthält nach Definition keine Zwischenknoten und kann daher nur ein Pfad
der Länge 0 oder 1 (das heisst eine Kante) sein.
Ein (n − 1)-Pfad kann beliebiege Knoten enthalten ist also ein beliebiger Pfad des Graphen.
Wir beweisen nun durch vollständige Induktion die folgende Aussage A(k):
Bevor wir in den Zeilen 12 bis 20 in Floyds Algorithmus die Variable u auf k + 1
setzen, so steht in dist[v, w] die Länge des kürzesten k-Pfades von v nach w
oder ∞ falls kein solcher Pfad existiert (v, w = 0 . . . n − 1).
BASIS: In diesem Fall nehmen wir k = −1 als Basis. Gerade bevor wir u auf 0 setzen, haben
wir die Matrix arc nach dist koppiert (Zeilen 2 bis 10). Da die Kanten und Pfade der Länge
0 die einzigen (−1)-Pfade sind gilt die behauptung für k = −1.
INDUKTION. Wir nehmen nun an, A(k) sei wahr und betrachten was passiert, wenn die
Variable u im Algorithmus den Wert k + 1 hat. Wir nehmen nun an, P sei ein kürzester
(k + 1)-Pfad von v nach w. Wir betrachten nun zwei Fälle.
1. Falls P ein k-Pfad ist so führt P nicht durch den Knoten k + 1. Das heisst, nach
Induktionsannahme enthält dist[v,w] schon die Länge von P .
2. Ist P ein (k + 1)-Pfad, so können wir annehmen, dass der Knoten k + 1 nur einmal
vorkommt (Zyklen verlängern den Weg). Das heisst, P setzt sich aus einem k-Pfad Q
von v nach k + 1 und einem k-Pfad R von k + 1 nach w zusammen (siehe Abbildung
5-21).
v
k−Pfad Q
k+1
k−Pfad R
w
Abbildung 5-21: Ein (k + 1)-Pfad ist aus zwei k-Pfade R und Q zusammengesetzt.
Nach Induktionsannahme enthalten dist[v,k+1] und dist[k+1,w] die Längen von Q
respektive R nach der k-ten Iteration im Algorithmus. Es ist leicht zu sehen, dass diese
Längen während der k+1-ten Iteration nicht ändern können.
Der Test auf der Zeile 15 des Algorithmus vergleicht für beliebige Knoten v und w den
kürzesten k-Pfad mit der Summe der kürzesten k-Pfade von v nach k + 1 und von k + 1
nach w. Falls P durch den Knoten k + 1 führt, so muss diese Summe kleiner sein und
daher wird sie auch in dist[v,w] eingetragen.
Wir haben gezeigt, dass in beiden Fällen in der k+1-ten Iteration dist[v,w] auf die Länge
des kürzesten (k + 1)-Pfad von v nach w gesetzt wird. Das heisst, die Aussage A(k + 1) ist
wahr.
Um den Beweis abzuschliessen setzen wir k = n − 1. Das heisst, nach n Iterationen enthält
dist[v,w] den kürzesten (n − 1)-Pfad von v nach w. Da jeder Pfad im Graph ein (n − 1)-Pfad
ist, haben wir gezeigt, dass dist[v,w] die Länge des kürzesten Pfades im Graphen enthält.
5-23
5.7
Flussprobleme
Ein klassisches Problem bei gewichteten Graphen ist das Flussproblem. Bei diesem Problem
repräsentiert jede Kante im gerichteten Graphen eine “Röhre”, die ein gegebenes Gebrauchsgut transportieren kann. Die Gewichte geben dabei die Kapazität der Röhre an, das heisst,
die Menge des Gebrauchsgut, das pro Zeiteinheit transportiert werden kann. Das Problem
liegt nun darin, die maximale Menge, die von einem gegebenen Knoten q (Quelle) zu einem
anderen Knoten s (Senke) transportiert werden kann, zu bestimmen.
Beispiel 5.9 [Computer Netzwerk] Wir können ein Computernetzwerk als gerichteten
Graphen G darstellen. Jeder Knoten repräsentiert einen Computer. Eine gerichtete Kante
(u, v) repräsentiert einen unidirektionalen Kommunikationskanal zwischen dem Computer u
und dem Computer v. Das Gewicht w der Kante (u, v) bezeichnet die Bandbreite des Kanals,
das heisst, die maximale anzahl Bytes, die pro Sekunde von u nach v gesendet werden kann.
Falls wir nun eine Meldung vom Computer s zum Computer t senden wollen, so ist die beste
Methode die Meldung in Packete zu zerteilen und diese durch G nach dem Algorithmus für
den maximalen Fluss zu schicken.
Weitere Flussprobleme sind Verkehrsprobleme oder di maximale Abwassermenge, die durch
das Kanalisationssystem fliessen kann.
5.7.1
Flussgraphen
Definition 5.12 [Flussgraph] Ein Flussgraph N besteht aus den folgenden Komponenten:
• Ein zusammenhängender gerichteter Graph G = (V, E, c) mit nichtnegativen ganzen
Gewichten. Das Gewicht einer Kante e heisst die Kapazität der Kante c(e).
• Zwei verschiedene Knoten q ∈ V und s ∈ V . q heisst die Quelle und hat keine eingehende
Kanten. s heisst die Senke und hat keine ausgehende Kanten.
Um den maximalen Fluss durch einen Flussgraphen zu finden müssen wir zuerst den Begriff
“Fluss” genau definieren.
Definition 5.13 [Fluss] Ein Fluss f in einem Flussgraphen G ist eine Funktion f : E 7→ N
mit den folgenden zwei Eigenschaften:
1. Kapazitätsbeschränkung:
∀e ∈ E : 0 ≤ f (e) ≤ c(e)
2. Flusserhaltung:
∀v ∈ V \ {q, s} :
X
e∈E − (v)
f (e) =
X
f (e)
e∈E + (v)
Dabei bedeuten E − (v) die Menge der eingehenden und E + (v) die Menge der ausgehenden Kanten des Knotens v.
5-24
Die Menge f (e) heisst Fluss der Kante e. Der Wert eines Flusses f in einem Flussgraphen
N , das wir mit |f | bezeichnen, ist die Summe der Flüsse, der ausgehenden Kanten der Quelle
q.
|f | =
X
f (e)
e∈E + (q)
Das heisst, ein Fluss darf die Kapazität der einzelnen Kanten nicht übersteigen. Ferner muss
für alle Knoten ausser für q und s gelten, dass die Summe der eingehenden Flüsse gleich der
Summe der ausgehenden Flüsse ist. In der Abbildung 5-22 ist ein Flussgraph dargestellt mit
einem Fluss, der allen Regeln der Definition genügt.
v1
v4
2/2
3/7
1/1
q
2/5
v3
2/5
5/6
4/6
s
2/3
1/3
4/8
v2
v5
4/9
Abbildung 5-22: Ein Flussgraph mit einem gültigen Fluss f (mit Wert |f | = 10)
Wir werden sehen, dass die Summe der Flüsse der eingehenden Kanten der Senke s in einem
Flussgraphen N gleich dem Wert des Flusses des Graphen N ist.
|f | =
X
f (e)
e∈E − (s)
Das heisst, ein Fluss spezifiziert wie ein Gebrauchsgut von der Quelle q durch den Flussgraphen
N in die Senke s fliesst.
Definition 5.14 [Maximaler Fluss] Ein maximaler Fluss für einen Flussgraphen N ist
ein Fluss f mit maximalem Wert über alle möglichen Flüsse von N .
in der Figur 5-23 ist ein maximaler Fluss für den Graphen dargestellt.
Im folgenden werden wir einen Algorithmus entwickeln, um maximale Flüsse in Flussgraphen
zu finden.
5.7.2
Schnitte
Das Konzept des Flusses ist mit dem Konzept des Schnitts eng verwandt. Ein Schnitt ist nichts
anderes als eine Aufteilung der Knoten eines Flussgraphen in zwei disjunkte Teilmengen, mit
5-25
v1
3/7
1/1
q
v4
2/2
4/5
v3
5/5
6/6
6/6
s
2/3
6/8
0/3
v2
v5
6/9
Abbildung 5-23: Ein maximaler Fluss f ∗ (mit Wert |f ∗ | = 14) für den Flussgraphen N
der Quelle q in der einen Menge und der Senke s in der anderen.
Definition 5.15 [Schnitt] ein Schnitt eines Flussgraphen N ist eine Partition X = (Vq , Vs )
der Knoten von N mit q ∈ Vq und s ∈ Vs .
Eine Kante (u, v) mit u ∈ Vq und v ∈ Vs heisst Vorwärtskante des Schnittes X . Eine Kante
(u, v) mit u ∈ Vs und v ∈ Vq heisst Rückwärtskante des Schnittes X .
Die Kapazität des Schnittes X ist die Summe der Kapazitäten der Vorwärtskanten von X
(die Rückwärtskanten werden nicht berücksichtigt). Die Kapazität des Schnittes X bezeichnen
wir mit c(X ).
Unter einem minimalen Schnitt von N versteht man einen Schnitt mit minimaler Kapazität
unter allen Schnitten von N .
Der Fluss durch den Schnitt X , ist gleich der Summe der Flüsse der Vorwärtskanten von X
minus die Summe der Flüsse der Rückwärtskanten von X . Der Fluss durch den Schnitt X
bezeichnen wir mit f (X ).
In der Abbildung 5-24 sind drei Schnitte des Flussgraphen N dargestellt. X1 und X2 haben
nur Vorwärtskanten. X3 hat auch eine Rückwärtskante. Die Kapazitäten sind c(X1 ) = 14,
c(X2 ) = 18 und c(X3 ) = 22. Der Schnitt X1 ist ein minimaler Schnitt für N
Der folgende Satz sagt aus, dass unabhängig vom Schnitt, der q und s trennt, der Fluss durch
den Schnitt gleich dem Fluss des gesammten Netzwerkes ist.
Satz 5.2 [Fluss durch einen Schnitt] Sei N ein Flussgraph und f ein Fluss von N . Für
jeden Schnitt X von N ist der Wert von f gleich dem Fluss durch X . Das heisst, |f | = f (X ).
Beweis: Wir betrachten die folgende Summe
F =
X
v∈Vq


X
f (e) −
e∈E + (v)
X
e∈E − (v)
P

f (e)
P
Nach der Flusserhaltungsregel gilt ∀v ∈ Vq \ {q}, dass e∈E + (v) f (e) − e∈E − (v) f (e) = 0.
P
Daher gilt, F = e∈E + (q) f (e) und damit F = |f |.
Auf der anderen Seite gilt für jede Kante e, die weder Vorwärts- noch Rückwärtskante von X
ist, dass F sowohl den Term f (e) wie den Term −f (e) enthält, die einander aufheben. Für
5-26
v1
7
1
q
X1
v4
2
5
6
v3
5
6
s
3
3
8
v2
v5
9
X3
X2
Abbildung 5-24: Ein Flussgraph mit drei Schnitte X1 , X2 und X3
Vorwärtskanten von X is nur der Term f (e) in der Summe F und für Rückwärtskanten nur
der Term −f (e). Das heisst F = f (X ).
Der nächste Satz zeigt, dass die Kapazität eines Schnittes X eine obere Schranke für jeden
Fluss durch X darstellt.
Satz 5.3 [Fluss kleiner gleich als Kapazität] Sei N ein Flussgraph und X ein Schnitt
von N . Für jeden Fluss f von N gilt, dass der Fluss durch X kleiner oder gleich der Kapazität
von X ist. Das heisst, f (X ) ≤ c(X ).
Beweis: Wir bezeichnen mit E + (X ) die Vorwärtskanten von X und mit E − (X ) die Rückwärtskanten von X . Nach der Definition von f (X ) haben wir
f (X ) =
X
f (e) −
e∈E + (X )
X
f (e)
e∈E − (X )
Falls wir alle negativen Terme aus der obigen Summe entfernen erhalten wir
X
f (X ) ≤
f (e)
e∈E + (X )
Nach der Kapazitätsregel für jede Kante e, f (e) ≤ c(e) erhalten wir
f (X ) ≤
X
c(e) = c(X )
e∈E + (X )
Wenn wir die beiden Sätze kombinieren erhalten wir das wichtige Resultat:
Satz 5.4 [Fluss und Schnitte] Gegeben sei ein Flussgraph N . Für jeden beliebiegen Fluss
f von N und für jeden beliebiegen Schnitt X von N gilt, dass der Wert von f die Kapazität
von X nicht übersteigt. Das heisst |f | ≤ c(X ).
Mit anderen Worten ist die Kapazität eines minimalen Schnitts von N eine obere Schranke
für den Wert von jedem Fluss f von N .
5-27
5.7.3
Maximaler Fluss
Nach dem Satz 5.4 können wir sagen, dass ein maximaler Fluss nicht grösser sein kann als die
Kapazität eines minimalen Schnittes. Wir werden zeigen, dass beide Grössen gleich sind.
5.7.3.1
Restkapazität und nutzbare Pfade
Gegeben sei ein Flussgraph N und ein Fluss f von N . Ferner sei e eine gerichtete Kante (u, v)
von u nach v. Die Restkapazität von u nach v für den Fluss f ist
∆f (u, v) = c(e) − f (e)
für die Richtung v nach u wird die Restkapazität definiert als
∆f (v, u) = f (e)
Sei π ein Pfad von q nach s bei dem die Kanten in beiden Richtungen durchlaufen werden
können. Das heisst eine Kante e = (u, v) kann sowohl von u nach v wie auch von v nach u
durchlaufen werden. Eine Kante e = (u, v) die auf dem Pfad π von q nach s von u nach v
passiert wird heisst Vorwärtskante von π sonst Rückwärtskante. Wir wollen die Definition der
Restkapazität auf die Kanten e von π erweitern, so dass ∆f (e) = ∆f (u, v). Das heisst,
∆f (e) =
(
c(e) − f (e) falls e eine Vorwärtskante ist
f (e)
falls e eine Rückwärtskante ist.
Wir sind nun in der Lage die Restkapazität eines Pfades π und die nutzbaren Pfade zu
definieren.
Definition 5.16 [nutzbarer Pfad] Die Restkapazität eines Pfades π ist die minimale
Restkapazität über die Kanten des Pfades
∆f (π) = min ∆f (e)
e∈π
Ein nutzbarer Pfad für den Fluss f im Flussgraph N ist ein Pfad π von der Quelle q zur
Senke s mit ∆f (π) > 0.
Für die Kanten e eines nutzbaren Pfades π gilt:
• f (e) < c(e) falls e eine Vorwärtskante ist
• f (e) > 0 falls e eine Rückwärtskante ist.
In der Abbildung 5-25 ist ein Beispiel für einen nutzbaren Pfad dargestellt.
Der folgende Satz zeigt, dass wenn zu einem Fluss die Restkapazität eines nutzbaren Pfades
addiert wird, ein neuer gültiger Fluss f ′ entsteht.
Satz 5.5 [Fluss und nutzbare Pfade] Sei π ein nutzbarer Pfad für den Fluss f im Flussgraphen N . Dann existiert ein Fluss f ′ von N mit dem Wert |f ′ | = |f | + ∆f (π).
Beweis: Wir berechnen den neuen Fluss f ′ indem wir den Fluss aller Kanten im Pfad π
folgendermassen verändern.
5-28
v1
5/8
2/5
3/6
q
v4
2/4
v3
2/3
3/7
4/9
s
3/3
3/8
0/3
v2
v5
3/3
Abbildung 5-25: Ein nutzbarer Pfad π mit ∆f (π) = 2 für den Fluss f im Flussgraphen N
′
f (e) =
(
f (e) + ∆f (π) falls e eine Vorwärtskante ist
f (e) − ∆f (π) falls e eine Rückwärtskante ist.
Da ∆f (π) ≥ 0 die minimale Restkapazität über alle Kanten in π ist, wird für eine Vorwärtskante keine Kapazitätsbeschränkung verletzt, falls wir ∆f (π) zum Fluss addieren. Aus dem
gleichen Grund kann der Fluss auf einer Rückwärtskante nicht kleiner als 0 werden. Also ist
f ′ ein gültiger Fluss für N und der Wert von f ′ ist |f | + ∆f (π), da die erste Kante von π eine
ausgehende Kante von q ist.
In der Abbildung 5-26 ist der Flussgraph dargestellt, der nach dem Addieren des Restflusses
∆f (π) = 2 aus der Abbildung 5-25 entsteht.
v1
5/8
2/5
1/6
q
v3
2/3
5/7
v4
4/4
6/9
s
3/3
3/8
2/3
v2
3/3
v5
Abbildung 5-26: Der Fluss f ′ der aus dem Fluss f entsteht
Nach Satz 5.5 impliziert die Existenz eines nutzbaren Pfades π für einen Fluss f , dass f nicht
maximal ist. Was ist nun, wenn kein nutzbarer Pfad π für den Fluss f im Flussgraphen N
5-29
existiert? In diesem Fall ist f maximal wie der folgende Satz zeigt.
Satz 5.6 [Maximaler Fluss] Falls ein Flussgraph N keinen nutzbaren Pfad für den Fluss
f besitzt, dann ist f ein maximaler Fluss. In diesem Fall existiert ein Schnitt X von N mit
|f | = c(X ).
Beweis: Sei f ein Fluss für N und es existiert keinen nutzbaren Pfad für f . Wir konstruieren
einen Schnitt X = (Vq , Vs ) indem wir alle Knoten v in Vq aufnehmen, für die ein Pfad von
q nach v existiert mit der Bedingung, dass die Restkapazität aller Kanten des Pfades nicht
null ist. In diesem Fall gehört aber s zu Vs , da kein nutzbarer Pfad für f existiert. Das
heisst, X = (Vq , Vs ) ist ein Schnitt. Nach der Definition von X haben alle Vorwärts- und
Rückwärtskanten des Schnittes X eine Restkapazität von 0. Das heisst
f (e) =
(
c(e) falls e eine Vorwärtskante von X ist
0
falls e eine Rückwärtskante von X ist.
Das heisst, f (X ) = c(X ) und nach Satz 5.2 gilt:
|f | = c(X )
Nach Satz 5.4 folgt, dass f maximal ist.
Als Konsequenz aus den Sätzen 5.4 und 5.6 ergibt sich der fundamentale Satz
Satz 5.7 [Max-Flow, Min-Cut Theorem] Der Wert eines maximalen Flusses ist gleich
der Kapazität eines minimalen Schnittes.
5.7.3.2
Der Algorithmus von Ford-Fulkerson
Wir wenden uns nun dem Problem zu, einen maximalen Fluss in einem Flussgraphen zu
finden. Die Idee des Algorithmus von Ford-Fulkerson ist, nutzbare Pfade zu finden und die
Restkapazität zum Fluss zu addieren. Am Anfang ist der Fluss aller Kanten gleich null.
Solange wir noch einen nutzbaren Pfad finden, wird die Restkapazität dieses Pfades zum
Fluss addiert (wie im Beweis vom Satz 5.5). Der Algorithmus terminiert, wenn kein nutzbarer
Pfad mehr gefunden werden kann. Der Vorgang ist in der Abbildung 5-27 abgebildet.
Als erstes wollen wir überlegen, wie wir in einem Flussgraphen N mit fluss f einen Nutzbaren Pfad finden können. Wir benutzen dazu den Algorithmus zum traversieren von Graphen
mit folgenden Modifikationen. Statt vom aktuellen Knoten v aus alle inzidenten Kanten zu
betrachten, berücksichtigen wir nur die folgenden Kanten:
• Aus v ausgehende Kanten e, falls f (e) < c(e)
• In v eingehende Kanten e, falls f (e) > 0
Wir beginnen das Traversieren nun bei der Quelle q und halten an, wenn wir den Knoten s
erreichen oder der Graph vollständig traversiert ist. In letzterem Fall gibt es keinen nutzbaren
Pfad mehr.
5-30
v1
0/8
0/6
q
v4
0/4
v3
0/3
0/7
0/5
0/9
v1
4/8
0/8
q
v2
4/4
v1
0/7
4/9
4/8
s
3/3
0/3
s
3/3
0/8
0/3
v5
4/4
v4
0/6
q
0/8
0/9
0/3
v5
v3
3/3
0/7
v4
0/5
v3
3/3
0/3
0/6
0/5
0/6
q
v4
0/4
0/8
s
0/3
0/3
v2
v1
v3
3/3
3/7
0/5
4/9
s
3/3
0/3
3/8
v2
0/3
v5
v2
3/3
v5
v1
4/4
v4
v1
4/4
v4
8/8
4/5
4/6
q
v3
3/3
3/7
8/9
s
3/3
0/3
v2
8/8
q
3/8
3/3
4/6
v5
v3
3/3
4/7
5/5
9/9
s
3/3
1/3
v2
3/8
3/3
v5
Minimaler Schnitt
Abbildung 5-27: Der Algorithmus von Ford-Fulkerson
Nachfolgend ist der Algorithmus in Pseudocode angegeben.
Algorithmus 5.5 [Ford-Fulkerson]
foreach (e ∈ N )
f (e) := 0
done := false
while (!done) {
Suche einen nutzbaren Pfad π in N für f
if (Pfad π existiert) {
∆ := ∞
foreach (e ∈ π)
if (∆f (e) < ∆)
∆ := ∆f (e)
foreach (e ∈ π)
if (e ist eine Vorwärtskante)
f (e) := f (e) + ∆
else
f (e) := f (e) − ∆
} else
done := true
}
Komplexität von Ford-Fulkerson
Die Analyse des Algorithmus von Ford-Fulkerson is nicht so einfach. Das ist weil der Algorithmus nicht genau spezifiziert wie nutzbare Pfade gefunden werden sollen. Diese Wahl hat
5-31
aber einen entscheidenden Einfluss auf die Komplexität.
Sei n = |V | die Anzahl Knoten und m = |E| die Anzahl Kanten des Flussgraphen und sei f ∗
ein maximaler Fluss. Da der zugrundeliegende Graph zusammenhängend ist gilt, n ≤ m + 1.
Man beachte, dass jedesmal wenn wir einen nutzbaren Pfad finden der Fluss mindestens um
1 erhöht wird. Dies ist der Fall, weil für jede Kante sowohl die Kapazität wie auch der Fluss
ganze nichtnegative Zahlen sind. Daher ist |f ∗ | eine obere Schranke für die Anzahl mal wo
der Algorithmus einen nutzbaren Pfad sucht. Wir können mit der Graphtraversierung einen
nutzbaren Pfad in O(m) Zeit finden. Wir können also sagen, dass die Komplexität des Algorithmus
O(|f ∗ |m)
ist. In der Abbildung 5-28 ist ein Beispiel wo dieser Wert tatsächlich erreicht wird, wenn die
nutzbaren Pfade ungünstig gewählt werden.
Der Algorithmus von Ford-Fulkerson ist ein sogenannter pseudo polynomialer Algorithmus.
Dies ist, weil seine Komplexität nicht nur von der grösse des Inputs (n + m) sondern auch vom
Wert eines numerischen Parameters |f ∗ | ist. Daher kann die Ausführung von Ford-Fulkerson
sehr langsam sein, wenn |f ∗ | sehr gross ist und die nutzbaren Pfade schlecht gewählt werden.
v1
0/1,000,000
0/1,000,000
0/1
q
0/1,000,000
s
0/1,000,000
q
0/1,000,000
1/1,000,000
1/1
s
1/1,000,000
0/1,000,000
v2
v2
v1
v1
0/1,000,000
1/1,000,000
1/1
q
v1
1/1,000,000
1/1,000,000
s
q
0/1,000,000
1/1,000,000
0/1
1/1,000,000
v2
s
1/1,000,000
v2
Abbildung 5-28: Falls die nutzbaren Pfade zwischen (q, v1 , v2 , s) und (q, v2 , v1 , s) alternieren
so braucht es 2,000,000 Iterationen, obschon 2 Iterationen genügen würden.
5.7.3.3
Der Algorithmus von Edmonds-Karp
Der Algorithmus von Edmonds-Karp ist eine Variation von Ford-Fulkersons Algorithmus. Der
Unterschied liegt in der Wahl der nutzbaren Pfade. Bei jeder Iteration wird ein nutzbarer Pfad
mit einer minimalen Anzahl Kanten gewählt. Dies kann mit einer modifizierten Breitensuche
in O(m) Zeit gelöst werden. Wir werden zeigen, dass die obere Schranke für die Anzahl Iterationen bei nm liegt. Das heisst der Edmonds-Karp Algorithmus hat dann eine Komplexität
von
5-32
O(nm2 )
Gegeben sei ein Flussgraph N und ein Fluss f für N . Für jeden Knoten v definieren wir df (v)
als die Länge eines minimalen (bezüglich der Anzahl Kanten) nutzbaren Weges von q nach v.
Als erstes hält der folgende Satz fest, dass bei jeder Iteration des Algorithmus df (v) monoton
steigend ist.
Satz 5.8 [Distanz und Fluss] Sei g ein Fluss der aus dem Fluss f durch addieren der Restkapazität eines nutzbaren Pfades π entstanden ist. Dann gilt für alle Knoten im Flussgraphen
df (v) ≤ dg (v)
Diesen Satz wollen wir nicht beweisen aber den folgenden Satz, der uns das gewünschte Resultat liefert.
Satz 5.9 [Anzahl Iterationen Edmond-Karp] Wenn der Algorithmus von Edmonds-Karp
auf einem Flussgraph mit n Knoten und m Kanten ausgeführt wird, so ist die Anzahl Iterationen nicht grösser als nm.
Beweis: Sei fi der Fluss im Flussgraphen vor der i-ten Iteration und πi der bei dieser Iteration
benutzte nutzbare Pfad. Man sagt dass die Kante e ∈ πi kritisch ist für πi falls ∆f (e) = ∆f (πi ).
Es ist klar, dass jeder nutzbare Pfad mindestens eine kritische Kante besitzt.
Wir betrachten nun zwei Knoten u und v die durch eine Kante e verbunden sind. Ferner
nehmen wir an, dass e kritisch ist für πi und πk mit i < k und von u nach v geht (e = (u, v)).
Aus diesen Voraussetzungen können wir folgendes ableiten:
• ∆fi (u, v) > 0 weil (u, v) zum nutzbaren Pfad πi gehört.
• ∆fi+1 (u, v) = 0 wei die Kante kritisch ist
• ∆fk (u, v) > 0 weil (u, v) zum nutzbaren Pfad πk gehört.
Das heisst, es muss ein j existieren mit i < j < k dessen nutzbarer Pfad πj die Kante e von
v nach u passiert. Daraus ergibt sich:
dfj (u) = dfj (v) + 1 weil πj ein kürzester Pfad ist
≥ dfi (v) + 1 nach Satz 5.8
≥ dfi (u) + 2 weil πi ein kürzester Pfad ist
Da ein nutzbarer Pfad immer kürzer ist als die Anzahl Knoten n kann jede Kante e während
der Ausführung des Algorithmus höchstens n mal kritisch sein (n/2 mal für jede Richtung in
die sie traversiert werden kann). Das heisst die Anzahl Iterationen kann nicht grösser sein als
nm.
Da die Suche eines nutzbaren Pfades mit einer Breitensuche in O(m) Zeit realisiert werden
kann erhalten wir das gewünschte Resultat.
Satz 5.10 [Komplexität von Edmonds-Karp] Gegeben sei ein Flussgraph N mit n Knoten
und m Kanten, so wird der maximale Fluss vom Algorithmus von Edmonds-Karp in
O(nm2 )
Zeit berechnet.
5-33
Kapitel 6
Verarbeitung von Strings
Unter einem String verstehen wir eine beliebige Sequenz von Zeichen aus einem gegebenen
Alphabet. Für die Wahl der Algorithmen sind zwei Faktoren von Bedeutung:
1. Länge des Strings (einige Zeichen oder ein ganzes Buch). Fast alle Algorithmen in diesem
Kapitel sind nur sinnvoll, wenn sie auf sehr langen Strings angewendet werden.
2. Das Alphabet aus denen die Zeichen stammen (alphabetisch, alphanumerisch, binär
usw).
6.1
Suchen in Strings
Beim Suchen in Strings geht es darum in einem String einen Teilstring zu finden, welcher mit
einem gegebenen String (Muster) übereinstimmt. Betrachtet man das Muster als Schlüssel,
besteht eine Analogie zu den bisher behandelten Suchproblemen. Im folgenden wollen wir nun
einige Algorithmen zur Lösung dieses Problems betrachten.
6.1.1
Brute Force Algorithmus
Bei diesem Algorithmus wird das Muster Zeichenweise mit dem String verglichen. Sobald zwei
Zeichen unterschiedlich sind wird das Muster um eine Stelle nach rechts verschoben und der
Vergleich beginnt von neuem, bis eine Stelle im String gefunden wird, wo alle Zeichen des
Musters mit den Zeichen des Strings übereinstimmen.
Dieser Algorithmus funktioniert im Allgemeinen für normale Texte im Vergleich zu komplizierten Verfahren erstaunlich gut. Andererseits kann das Verfahren sehr langsam werden. Vor
allem, wenn das Alphabet binär ist. Falls der String die Länge N hat und das Muster die
Länge M , so braucht der Brute-Force Algorithmus im schlechtesten Fall O(N · M ) Vergleiche
um das Muster zu finden wie das folgende Beispiel zeigt.
Beispiel 6.1 [Brute Force]
String:
Muster:
111111111111111111110 N = 20
1110 M = 4
Das Muster wird N − M (im Beispiel also 16) mal verschoben. Wir müssen dabei jedesmal
bis zum Ende des Musters Vergleichen um zu merken, dass das Muster nicht mit dem String
übereinstimmt. Das heisst, wir brauchen 16 · 4 Vergleiche um das Muster zu finden.
6-1
Das Beispiel 6.1 zeigt also, dass der schlechteste Fall beim Brute Force Algorithmus (N −M )·M
Vergleiche benötigt. Falls N viel grösser ist als M ist dies gleich O(N · M ).
6.1.2
Knuth-Morris-Pratt Algorithmus
Im folgenden werden wir immer die beiden Zeiger i und j verwenden, die angeben, welches
Zeichen im String bzw. im Muster gerade miteinander verglichen werden.
Eine charakteristik des Brute Force Algorithmus ist, dass der Zeiger i bei nicht Übereinstimmung der Zeichen auf (i-(j-1)) gesetzt werden muss (das heisst, falls j>1 ist, muss
i zurückgesetzt werden). Der Algorithmus von Knuth Morris und Pratt hingegen setzt den
Zeiger i nie zurück. Der String wird also streng von links nach rechts gelesen.
6.1.2.1
Idee des Algorithmus
Falls die ersten k Zeichen des Muster mit den Zeichen des Strings übereinstimmen, so kann
diese Information verwendet werden, um zu bestimmen wie weit das Muster weitergeschoben
werden darf.
Beispiel 6.2 [Information über das Muster] Wir wollen die Idee des Algorithmus an
einem einfachen Beispiel illustrieren.
String:
Muster:
beliebig
10000
Wenn wir nun beim j-ten Zeichen des Musters keine Übereinstimmung haben, so wissen wir,
das die j-1 vorhergehenden Zeichen im String keine 1 enthalten. Da das Muster aber mit einer
1 beginnt, so können wir das Muster bis zur i-ten Position im String durchschieben und der
Vergleich bei der Position i+1 weiterfahren. Dieser Algorithmus kann aber nur verwendet
werden, wenn das erste Zeichen im Muster nicht noch einmal im Muster erscheint.
Das im Beispiel 6.2 angegebene Verfahren ist nicht besonders nützlich, da solche spezielle
Muster nicht häufig vorkommen. Der Algorithmus von Knuth Morris und Pratt ist eine Verallgemeinerung dieses Verfahrens. Erstaunlicherweise ist es tatsächlich möglich den Algorithmus
so zu formulieren, dass der Zeiger i nie dekrementiert werden muss.
Wir können im allgemeinen nicht wie im Beispiel 6.2 bei der Feststellung einer Nichtübereinstimmung das ganze Muster überspringen. Es wäre ja möglich, dass das Muster an der Stelle
der Nichtübereinstimmung mit sich selbst in Übereinstimmung gebracht werden könnte.
Beispiel 6.3 [Muster wird verpasst]
String:
Muster:
11101001
1101
in diesem Fall haben wir bei i=2 und j=2 die erste Nichtübereinstimmung. Würden wir jetzt
wie im vorigen Beispiel das Muster durchschieben, erhalten wir die folgende Situation:
String:
Muster:
11101001
1101
6-2
In diesem Fall verpassen wir gerade die richtige Stelle, die sich bei i=1 befindet.
Das Problem im Beispiel 6.3 ist, dass wir die Wiederhohlungen des Anfangs des Musters im
Muster selbst nicht berücksichtigen. Um dies zu tun führen wir eine Hilfstabelle next ein, die
für jedes Zeichen im Muster mu den folgenden Wert bestimmt:
next[i] = −1 falls i = 0
next[i] = max({0} ∪ {j|j > 0 ∧ j < i ∧ (mu[i − j + k] = mu[k] k = 0, . . . j − 1)}) sonst
Das heisst, in der Tabelle next wird gespeichert, wieviele Zeichen links vom i-ten Zeichen mit
dem Anfang des Musters übereinstimmen.
Beispiel 6.4 [Tabelle zum Muster]
Muster: 1001011100111
Die Tabelle next zu diesem String sieht nun folgendermassen aus:
i
next[i]
0
-1
1
0
2
0
3
0
4
1
5
2
6
1
7
1
8
1
9
2
10
3
11
4
12
1
Wenn nun die Zeiger i im String und j im Muster auf nicht übereinstimmende Zeichen
zeigen, so beginnt die nächste mögliche Position für eine Übereinstimmung mit dem Muster
bei der Position i - next[j] im String. Doch laut Definition der Tabelle next stimmen die ersten
next[j] Zeichen in dieser Position mit den ersten next[j] Zeichen des Musters überein, so
dass der Zeiger i unverändert bleibt und wir den Zeiger j einfach auf next[j] setzen können.
Das heisst, der Zeiger i muss nie zurückgesetzt werden. Der String kann von links nach rechts
gelesen werden.
Beispiel 6.5 [Beispiel Knuth-Morris-Pratt]
String:
Muster:
100101110001100000 . . .
1001011100111
↑ Vershieden bei i=j=10
Die next Tabelle für dieses Muster ist im Beispiel 6.4 angegeben. Wir sehen, dass der String
und das Muster bei i = 10 und j = 10 nicht übereinstimmen. Das heisst, wir müssen j auf
next[j] setzen und weiter mit i=10 weiterfahren.
String:
Muster:
100101110001100000 . . .
1001011100111
↑ Ab hier muss weiter verglichen werden.
Die folgende Java Klasse KnuthMorrisPratt implementiert den Algorithmus.
Algorithmus 6.1 [Knuth-Morris-Pratt]
public class KnuthMorrisPratt {
public static int searchPattern(String str, String muster) {
int next[] = initnext(muster);
int n = str.length();
int m = muster.length();
int j = 0, i = 0;
6-3
while (i < n) {
if (str.charAt(i) == muster.charAt(j)) {
if (j == m - 1) {
return i - m + 1;
}
++i;
++j;
}
else if (j > 0) {
j = next[j];
} else {
++i;
}
}
return -1;
}
private static int[] initnext(String mu) {
int m = mu.length();
int i = 0, j = -1;
int next[] = new int[m];
while (i < m) {
next[i] = j;
while (j >= 0 && mu.charAt(i) != mu.charAt(j)) {
j = next[j];
}
++i;
++j;
}
return next;
}
}
Interessant ist vor allem die Funktion initnext. Im Grunde genommen ist es genau derselbe
Algorithmus wie in der Hauptfunktion. Der Unterschied ist, dass wir das Muster auf Übereinstimmung mit sich selbst überprüfen.
Unmittelbar nachdem i und j inkrementiert worden sind steht fest, dass die ersten j Zeichen
des Musters mit den Zeichen mu[i-j-1],. . . mu[i-1] übereinstimmen. Das heisst, j ist gerade
der richtige Wert von next[i].
Die Komplexität des Algorithmus ist gleich O(N + M ).
6.1.3
Boyer-Moore Algorithmus
Der Algorithmus von Knuth Morris und Pratt ist nur dann effizient, wenn das Alphabet klein
ist, und die Wahrscheinlichkeit, dass sich gewisse Zeichenkombinationen sowohl im Muster
wie auch im String wiederhohlen, relativ gross ist. Ist hingegen das Alpahbet gross (z.B. die
druckbaren ASCII-Zeichen), so kann eine weit effizientere Methode entwickelt werden.
6-4
Die Idee besteht darin, das Muster von rechts nach links zu untersuchen und dabei das Verschieben sowohl auf der Basis des nicht übereinstimmenden Zeichens im String als auch auf
der Basis des Musters zu berechnen. Wir betrachten dazu wieder ein Beispiel.
Beispiel 6.6 [Boyer-Moore] Die Situation ist in der folgenden Abbildung dargestellt:
A STRING SEARCHING EXAMPLE CONSISTING OF ...
v
|
|
|
|
|
|
STING
v
|
|
|
|
|
STING
v
|
|
|
|
STING
v
|
|
|
STING
v
|
|
STING
V
|
STING
V
STING
STING
Die Methode funktioniert nun folgendermassen.
Da wir bei der Überprüfung im Text von rechts nach links vorgehen, vergleichen wir als erstes
ein R mit einem G. Wir stellen nun fest, dass die Zeichen nicht übereinstimmen. Ferner sehen
wir, dass das zeichen R im Muster überhaupt nicht vorkommt. Daher können wir das Muster
ganz am Buchstaben R vorbeischieben.
Der nächste Vergleich geschieht mit dem S im String und dem G im Muster. Wieder sind die
Zeichen verschieden diesmal jedoch kommt das S auch im Muster vor, daher können wir das
Muster nur soweit schieben, bis das S im Muster mit dem S im Text übereinstimmt.
Beim Vorletzten Suchen stimmen das T im String und das G im Muster nicht überein. Wir
können soviel schieben, bis das T vom Muster mit dem T im String übereinstimmt.
Um den obigen Algorithmus zu implementieren brauchen wir eine Tabelle skip, die uns für
jedes Zeichen des Alphabets angibt, wie weit wir im String springen können, falls dieses
Zeichen mit dem Muster nicht übereinstimmt. Die Einträge in der Tabelle skip können für
ein beliebiges Zeichen x folgendermassen berechnet werden:
• skip[index(x)] = M falls x nicht im Muster vorkommt.
• skip[index(x)] = M - j - 1 falls x im Muster an der Stelle j vorkommt.
Das heisst, wir merken uns die Distanz vom Zeichen zum Ende des Musters. Um soviele
Positionen können wir dann den Index i erhoehen, damit das im Muster übereinstimmende Zeichen an die richtige Stelle kommt.
Falls das Zeichen x im Muster mehrmals vorkommt, so wird diese Distanz für das lezte
vorkommende x berechnet.
Die folgende Java Klasse BoyerMoore implementiert den Algorithmus. Dabei wird angenommen, dass das Alphabet den ASCII-Zeichensatz umfasst.
Algorithmus 6.2 [Boyer-Moore]
public class BoyerMoore {
private static int skip[] = new int[127];
public static int searchPattern(String str, String muster) {
int n = str.length();
6-5
int m = muster.length();
int j, i, sk;
initskip(muster);
i = m - 1;
j = m - 1;
while (i < n && j >= 0) {
while (i < n && str.charAt(i) != muster.charAt(j)) {
sk = skip[(int)str.charAt(i)];
i += (m-j > sk) ? m-j : sk;
j = m - 1;
}
--i;
--j;
}
return (i < n) ? i+1 : -1;
}
private static void initskip(String muster) {
int m = muster.length();
for (int j = 0; j < muster.length(); j++)
skip[j] = m;
for (int j = 0; j < m; j++)
skip[(int)muster.charAt(j)] = m - j - 1;
}
}
Im schlechtesten Fall braucht diese Methode O(N · M ) Schritte um das Problem zu loesen.
Beispiel 6.7 [Worst Case Boyer-Moore]
N
String:
Muster:
}|
z
{
aaaaaaaaaaaaa . . . a
b aaaa
| {z. . . a}
M −1
In diesem Fall braucht es immer M vergleiche um festzustellen, dass das Muster nicht passt.
Anschliessend kann das Muster nur um eine Position nach rechts verschoben werden. Daher
braucht es in diesem Fall (N − M ) · M Vergleiche also O(N · M ).
Ist aber das Alphabet relativ gross und das Muster kurz, so ist die Wahrscheinlichkeit, dass
das Muster einfach durchgeschoben werden kann gross und wir erhalten im Durchschnitt eine
Komplexität von O(N/M ) Vergleiche.
6.2
6.2.1
Datenkomprimierung
Lauflängenkodierung
Das einfachste Vorkommen von Redundanz in einem File sind lange Folgen sich wiederholender
Zeichen, die wir Läufe (runs) nennen wollen. Diese Redundanz kann durch die Angabe des
6-6
sich wiederholenden Zeichens und der Anzahl Wiederholungen eliminiert werden. Um Läufe
zu kodieren werden 3 Bytes verwendet. Ein Escape-Zeichen, das angibt, dass eine solche
Kodierung folgt, ein Byte für die Anzahl und ein Byte für die Angabe des Zeichens. Da die
Kodierung 3 Bytes benötigt, werden nur Läufe kodiert, die länger sind als 3.
Beispiel 6.8 [Lauflängenkodierung]
String:
Escape-Zeichen:
Kodierung:
aaaaaabcbcccccccsssssccdddeaaaaaaaaaaaaaa
\
\<6>abcb\<7>c\<5>sccddde\<14>a
In diesem Fall hat der ursprüngliche String die Länge 41 der kodierte String nur noch die
Länge 21. Wir sehen auch, dass der Lauf ddd nicht kodiert wird, da er nur die Länge 3 hat.
Ein Problem ist noch das Escape-Zeichen. Was machen wir, wenn dieses Zeichen im String
vorkommt? Dieses Problem wird gelöst, indem die Anzahl nach dem Escape-Zeichen auf 0
gesetzt wird. Das Escape-Zeichen wird also als \<0> kodiert.
Beispiel 6.9 [Escape Zeichen]
String:
Escape-Zeichen:
Kodierung:
\a\b\c\d
\
\<0>a\<0>b\<0>c\<0>d
Es ist interessant, dass in diesem Fall der kodierte String länger als der ursprüngliche String
ist. Insbesondere wird ein schon kodierter String durch eine zweite Kodierung verlängert.
Für Textdateien ist dieses Verfahren nicht effizient, da sich in einem normalen Text keine
Zeichen mehr als 3 mal Wiederholen.
6.2.2
Kodierung mit variabler Länge
Die Kodierung mit variabler Länge kann in Textdateien (und auch anderen Dateien) eine
beträchtliche Platzeinsparung bedeuten. Die Idee ist eigentlich ganz einfach. Statt für jedes
Zeichen 7 oder 8 Bit zu verwenden, werden Zeichen, die häufig vorkommen, nur mit wenigen
Bits dargestellt und Zeichen die selten vorkommen mit mehr Bits.
Wir nehmen an, dass der Text nur aus grossen Buchstaben und Blanks besteht. Für die
Kodierung werden in diesem Fall nur 5 Bits benötigt, wobei Blank mit 0 Kodiert wird. Der
i-te Buchstabe im Alphabet wird als Binäre Zahl i dargestellt.
Beispiel 6.10 [Variable Länge]
String:
Kodierung:
EABAABABCCD
00101’00001’00010’00001’00001’00010’00001’00010
00011’00011’00100
Das Zeichen ’ gehört nicht zur Kodierung, sondern dient nur der besseren lesbarkeit. Diese
Kodierung verwendet 45 Bits.
Wir können nun versuchen den String zu komprimieren, indem wir zuerst feststellen, wie oft
die einzelnen Zeichen vorkommen und anschliessend dem häufigsten Charakter die kürzeste
Sequenz zuordnen.
6-7
Beispiel 6.11 [Variable Länge 2] Im String des Beispiels 6.10 kommt das ’A’ 4 mal, das
’B’ 3 mal, das C 2 mal das D und das E je 1 mal vor. Wir könnten also die Kodierung
folgendermassen vornehmen:
A = 0, B = 1, C = 01, D = 10 E = 11
Wir erhalten mit dieser Kodierung das folgende Resultat.
String:
Kodierung:
EABAABABCCD
11 0 1 0 0 1 0 1 01 01 10
Das Resultat im Beispiel 6.11 is viel kürzer als das ursprüngliche. Im Prinzip brauchen wir
jetzt nur 15 Bits um den String zu kodieren.
Es sind aber noch nicht alle Probleme gelöst. Wollen wir den String dekodieren, so braucht
es einen Begrenzer, um die Zeichen auseinanderhalten zu können. Ferner kann der kodierte
String nur dekodiert werden, wenn der gewählte Code bekannt ist. Das heisst, der gewählte
Code muss im kodierten String mitgespeichert werden.
Um die Probleme zu lösen stellen wir fest, dass wir keine Begrenzer brauchen, wenn wir eine
Kodierung wählen, bei der alle Zeichen anders beginnen.
Beispiel 6.12 [Prefix verschieden] Wir wählen den Code nun folgendermassen aus:
A = 11, B = 00, C = 10, D = 010 und E = 011
Mit dieser Wahl erhalten wir die folgende Kodierung:
String:
Kodierung:
EABAABABCCD
011’11’00’11’11’00’11’00’10’10’010
Auch hier ist das Zeichen ’ nur zur besseren Lesbarkeit eingefügt worden. Wir brauchen mit
dieser Kodierung nur 24 statt 45 Bits und wenn der Code bekannt ist, so ist die Dekodierung
eindeutig.
Das Problem ist nun, zu einem gegebenen Text den optimalen Code zu finden, der eindeutig
dekodierbar ist. Zu diesem Zweck verwenden wir einen Trie. Der Trie ist ein spezieller binärer
Baum, der Nachfolgend definiert ist.
Definition 6.1 [Trie] Ein Trie (oder digitaler Suchbaum) ist ein binärer Baum mit den
folgenden Eigenschaften:
• Innere Knoten haben immer zwei Nachfolger.
• Äussere Knoten enthalten ein Zeichen des Alphabets oder NULL.
• Wenn ein Zeichen des Alphabets im Baum enthalten ist, so kann es folgendermassen
gefunden werden:
1. Wir starten bei der Wurzel des Baumes und lesen das erste Bit des Zeichens. Ist
dieses Bit 0 so gehen wir zum linken Nachfolger sonst zum rechten.
2. Nun wird das nächste Bit gelesen. Ist der Wert 0 gehen wir nach links sonst nach
rechts.
6-8
3. Wenn man zu einem äusseren Knoten (Blatt) gelangt, so enthält dieser das gewünschte zum gegebenen Code gehörige Zeichen (oder NULL, wenn der Code keinem Zeichen des Alphabets entspricht).
Beispiel 6.13 [Trie] In der Abbildung 6-1 ist der zum Code
A = 11, B = 00, C = 10, D = 010 und E = 011
entsprechende Trie dargestellt.
0
0
1
1
0
B
1
C
0
A
1
D
E
Abbildung 6-1: Trie zum Code A = 11, B = 00, C = 10, D = 010 und E = 011
Die Dekodierung eines gegebenen Strings ist nun sehr einfach, wenn der Trie bekannt ist.
In diesem Fall müssen wir im kodierten String die Bits von links nach rechts lesen und den
entsprechenden Weg im Trie verfolgen. Wenn wir auf ein Blatt stossen, haben wir das nächste
Zeichen gefunden und fahren mit dem nächsten Bit bei der Wurzel des Tries weiter. Dieser
Vorgang ist für den String 011’11’00’11’11’010 (EABAAD) in der Abbildung 6-2 angegeben.
011 11 00 11 11 010
011 11 00 11 11 010
0
0
0
1
0
B
0
1
C
0
A
A
B
C
A
E
A
E
011 11 00 11 11 010
B
0
C
D
C
1
0
0
1
1
D
1
1
B
E
0
0
0
0
011 11 00 11 11 010
1
1
1
1
1
0
0
0
1
C
D
1
D
B
E
0
0
0
0
0
011 11 00 11 11 010
1
1
1
D
011 11 00 11 11 010
1
0
1
A
0
B
C
0
1
E
1
1
D
1
A
1
E
Abbildung 6-2: Dekodieren mit Hilfe eines Tries
Es bleibt noch die Aufgabe, zu einem gegebenen String den optimalen Trie aufzubauen.
6.2.3
Erzeugung des Huffman-Codes
Um einen guten Code zu bekommen, müssen wir dafür sorgen, dass Zeichen die häufig vorkommen möglichst nahe bei der Wurzel zu liegen kommen (kurzer Code) und Zeichen die fast
6-9
nie vorkommen am weitesten von der Wurzel entfernt sind (langer Code). Eine Möglichkeit
ist der sogenannte Huffman-Code, der im folgenden beschrieben ist.
Der erste Schritt besteht darin, die Häufigkeit jedes Zeichens innerhalb des zu kodierenden
Strings zu ermitteln und zu speichern.
Der nächste Schritt ist der Aufbau des Tries gemäss den Häufigkeiten. Dabei gehen wir fogendermassen vor:
1. Wir betrachten alle Zeichen, zusammen mit ihrer Häufigkeit im String, als Wurzeln eines
binären Waldes (Menge von binären Bäumen). Zeichen des Alphabets mit Häufigkeit
null werden dabei nicht berücksichtigt.
2. Die zwei Bäume im Wald, die in der Wurzel die geringste Häufigkeit besitzen, werden
jetzt ausgewählt (falls mehr als zwei Bäume mit dieser Eigenschaft existieren, so ist es
gleichgültig, welche zwei benutzt werden).
3. Nun kreieren wir eine neue Wurzel (und damit auch einen neuen Baum), der die beiden
ausgewählten Bäume als Nachfolger hat. Die Häufigkeit in der neuen Wurzel ist die
Summe der Häufigkeiten der beiden Nachfolgerknoten. Der neue Baum wird jetzt auch
im Wald aufgenommen.
4. Der Punkt 3 wird wiederholt, bis nur noch ein Baum im Wald vorhanden ist. Dies wird
irgenwann der Fall sein, da wir bei jedem Durchlauf aus zwei Bäumen einen machen
und somit die Anzahl Bäume im Wald um eins verringern.
Beispiel 6.14 [Huffman Code] Wir wollen nun den Huffman-Code für den folgenden String
bestimmen.
String: KODIERTE ZEILEN SIND KURZ
Wir erhalten die folgenden Häufigkeiten:
bl
O
I
R
Z
N
U
=
=
=
=
=
=
=
3
1
3
2
2
2
1
K
D
E
T
L
S
=
=
=
=
=
=
2
2
4
1
1
1
Die Konstruktion des vollständigen Baumes ist in der Abbildung 6-3 dargestellt.
Aus diesem Baum kann der vollständige Huffman-Code nun abgeleitet werden. Man muss nur
den Baum entsprechend traversieren und sich den binären String dabei merken. Der Code ist
nachstehend angegeben.
I
D
U
T
=
001
= 0000
= 1011
= 01110
E
Z
K
L
=
010
= 0001
= 1110
= 01111
N =
R =
O =
100
0110
1111
bl =
S =
6-10
110
1010
bl
3
K
2
O
1
D
2
I
3
E
4
R
2
T
1
Z
2
L
1
N
2
S
1
U
1
bl
3
K
2
O
1
D
2
I
3
E
4
R
2
T
1
bl
3
3
K
2
I
3
4
O
1
D
2
E
4
4
4
R
2
Z
2
N
2
2
T
1
6
bl
3
2
S
1
L
1
U
1
7
3
K
2
O
1
D
2
E
4
25
0
4
0
D
2
1
Z
2
0
I
3
E
4
4
1
0
R
2
1
2
0
1
T
1
L
1
S
1
2
U
1
L
1
6
1
N
2
4
0
2
10 1
0
8
1
N
2
4
1
15 1
7
0
U
1
4
R
2
Z
2
2
S
1
L
1
T
1
0
N
2
8
I
3
4
Z
2
2
0
2
0
1
S
1
U
1
bl
3
1
3
0
1
K
2
O
1
Abbildung 6-3: Erzeugung des Huffman-Codes
6.2.3.1
Länge des kodierten Strings
Im Baum aus der Abbildung 6-3 kann noch die Länge des kodierten Strings (in Bit) abgelesen
werden. In jedem Blatt steht ja, wie häufig das entsprechende Zeichen im Text vorkommt. Die
Anzahl Bits, die es braucht um das entsprechende Zeichen zu kodieren ist gerade gleich dem
Niveau (level siehe Abschnitt 1.5.3) des Knotens. Das heisst, die folgende Summe ist gleich
der Länge des Kodierten Strings (H(x) ist dabei die Häufigkeit des entsprechenden Blattes):
X
level(x) · H(x)
x=Blätter
Man kann sogar zeigen, dass der so aufgebaute Baum optimal ist.
6.2.3.2
Implementation
Beim Aufbau des Huffman-Baumes müssen wir immer die beiden Bäume mit minimaler Häufigkeit in der Wurzel bestimmen. Diese Aufgabe kann mit Hilfe einer Priorityqueue gelöst
werden (siehe Abschnitt 3.3).
Ein Problem haben wir noch nicht behandelt. Um den kodierten String wieder zu dekodieren,
müssen wir den Huffman-Code zusammen mit dem kodierten String speichern. Da wir den
6-11
Huffman-Code mit Hilfe der Häufigkeiten und dem oben angegebenen Algorithmus konstruieren können, ist es nur notwendig, die Häufigkeiten als Integerarray am Anfang des kodierten
Strings zu speichern. Vor dem Dekodieren wird dann mit Hilfe des vorigen Algorithmus zuerst
der Huffman-Baum (und somit auch der Code) generiert.
Da die Häufigkeit der einzelnen Zeichen auch im kodierten String gespeichert werden muss,
macht diese Art von Kodierung nur Sinn, wenn der String gross ist.
6-12
Kapitel 7
Typen von Algorithmen
In diesem Kapitel wollen wir einige Algorithmen-Typen (Strategien) kennenlernen. Unter dem
Begriff “Algorithmen-Typ” verstehen wir die Wahl einer Strategie, mit der versucht wird, ein
Problem zu lösen. Natürlich ist nicht jede Strategie für jedes Problem geeignet. Darum werden
im folgenden verschiedene Design-Methoden vorgestellt und auch Beispiele dazu.
7.1
Rekursion
Der im Abschnitt 1.5.4 beschriebene Verfahren der Rekursion ist eine mögliche Strategie
zum Lösen von algorithmischen Problemen. Da wir dieses Verfahren aber schon genügend
beschrieben haben, wird es in diesem Abschnitt nicht mehr im Detail behandelt.
Bemerkung 7.1 [Rekursion] Andere Verfahren wie Backtracking und Divide and Conquer
führen sehr oft auch zu rekursiven Algorithmen.
7.2
Backtracking
Prinzip:
Im Prinzip ist das ein “trial-and-error” Verfahren. Man dringt im Suchraum so weit vor, bis
man in eine Sackgasse gerät. Anschliessend werden soviele Schritte zurückgenommen, bis ein
weiterer möglicher Weg verfolgt werden kann.
Beispiele:
Hamilton-Zyklen: Gibt es in einem ungerichteten Graphen einen Zyklus, der durch alle
Knoten geht?
Traveling Salesman: Finde in einem gewichteten Graphen den kürzesten (einfachen) Zyklus, der durch alle Knoten führt.
Färbungsproblem: Gegeben ist ein Graph: gesucht ist eine Färbung der Knoten mit einer
minimalen Anzahl Farben so, dass benachbarte Knoten unterschiedlich gefärbt sind.
Springer-Problem (Euler 1759): Gesucht ist die Bahn eines Springers auf einem Schachbrett so, dass jedes Feld genau einmal besucht wird.
7-1
7.2.1
Hamilton-Zyklus
Wir wollen feststellen, ob in einem ungerichteten Graphen ein Zyklus der Länge n existiert,
der durch alle n Knoten des Graphen geht. Ein solcher Zyklus heisst Hamilton-Zyklus.
Bemerkung 7.2 [Einfacher Zyklus] Falls der Graph gerichtet ist, können wir die Frage
stellen ob ein einfacher Zyklus existiert, der durch alle Knoten des Graphen geht.
Um das Problem zu lösen verwenden wir nun Backtracking. In diesem Fall heisst dies, wir
bestimmen alle möglichen Pfade im Graphen und testen für jeden Pfad, ob er ein HamiltonZyklus bildet.
Um dieses Problem zu lösen, verändern wir den Algorithmus der Tiefensuche wie im nachfolgenden Programm angegeben.
Algorithmus 7.1 [Hamilton Zyklus]
public class Hamilton {
private static boolean found = false;
private static final int BEGIN = 0;
private static int visited[];
private static int nodecount = 0;
public static int cycle[];
public static boolean search(boolean arc[][])
visited = new int[arc.length];
cycle = new int[arc.length];
for (int i = 0; i < arc.length ; i++) {
visited[i] = 0;
}
// Falls der Graph nicht zusammenhaengend
// so existiert kein Hamilton-Zyklus. Wir
// die Prozedur visit also nur fuer einen
// aufrufen (z.B. Knoten BEGIN = 0).
visit(arc, BEGIN);
return found;
}
{
ist,
muessen
Knoten
private static void visit(boolean arc[][], int k) {
// In der Variablen "nodecount" ist die Laenge des
// aktuellen Astes im Suchbaum gespeichert.
//In "cycle" die Knoten des aktuellen Astes.
cycle[nodecount] = k;
visited[k] = ++nodecount;
// Falls alle Knoten auf dem aktuellen Weg liegen
// und eine Kante zum Anfangsknoten fuehrt, so haben
// wir einen Hamilton-Zyklus gefunden.
if (nodecount == arc.length && arc[k][BEGIN] == true) {
found = true;
} else {
// Wir verfolgen so lange weitere Wege, bis ein
// Hamiltonzyklus gefunden ist.
7-2
int i = 0;
while (i < arc.length && !found) {
if (arc[k][i] && visited[i] == 0)
visit(arc,i);
++i;
}
}
// Backtracking Ansatz. Wenn wir die Prozedur
// verlassen, machen wir alles rueckgaengig was
// die Prozedur bisher gemacht hat.
--nodecount;
visited[k] = 0;
}
}
Die Prozedur ist so ausgerichtet, dass abgebrochen wird, sobald der erste Hamilton-Zyklus
gefunden wird. Will man alle solchen Zyklen, so muss einfach weitergefahren werden. Dabei
ist zu beachten, dass jeder Zyklus dann zwei mal vorkommt (in beide Richtungen).
Beispiel 7.1 [Hamilton] Wir wollen nun an einem Beispiel verfolgen, in welcher Reihenfolge
die gegebene Prozedur visit aufgerufen wird. In der Abbildung 7-1 ist ein ungerichteter
(ungewichteter) Graph abgebildet.
1
0
2
3
5
6
4
7
8
9
Abbildung 7-1: Ein ungerichteter Graph
In der Abbildung 7-2 ist nun der Suchbaum für den Backtracking Prozess bis zum Finden des
ersten Zyklus angegeben.
Bemerkung 7.3 [Aufwand Backtracking] Der Aufwand eines solchen Backtrackings
kann sehr gross werden. Ist die Anzahl Knoten im Graphen n und ist jeder Knoten mit jedem
anderen Knoten durch eine Kante verbunden, so existieren n! Hamilton-Zyklen. Das heisst,
falls wir alle Zyklen suchen, so ist die Komplexität des Algorithmus exponential. Bis jetzt sind
keine Algorithmen bekannt, die nicht exponential wären (das Problem ist N P-vollständig).
7.2.2
Traveling Salesman
Dies ist ein ähnliches Problem wie das von Hamilton-Zyklen. Zu einer gegebnen Menge von
n Städten ist die kürzeste Route zu finden, die alle Städte verbindet, ohne dass eine Stadt
zweimal besucht wird.
Dieses Problem können wir mit Hilfe eines gewichteten Graphen lösen. Wir können alle
7-3
0
3
1
3
1
6
9
2
7
5
4
8
2
5
6
8
5
7
7
6
9
8
4
9
4
6
7
5
5
8
7
6
9
8
4
9
found = true
4
Abbildung 7-2: Suchbaum für den Graphen aus Abb. 7-1
Hamilton-Zyklen bestimmen und anschliessend den Zyklus wählen, der die minimale Länge (bezüglich Gewicht) aufweist.
7.3
Branch and Bound
Prinzip:
Jeder Ast (engl. branch) des Entscheidungsbaumes (oder Suchbaumes) entspricht einer Teillösung des Problems. Auf das Durchsuchen eines Astes kann verzichtet werden, wenn eine
bestimmte Schranke (engl. bound) überschritten worden ist.
Beispiel:
Traveling Salesman: Die Suche kann verkürzt werden, wenn man als obere Schranke das
Minimum aller bisher gefundenen Touren wählt.
Springer-Problem: Ein Suchpfad wird abgebrochen, wenn ein nicht besuchtes Feld nicht
mehr erreichbar ist. Man muss für jedes Feld zählen, von wievielen nichtbesuchten Feldern aus es noch erreichbar ist.
7.3.1
Traveling Salesman
Um die Suche zu verkürzen, können wir unsere Prozedur visit folgendermassen ändern.
Algorithmus 7.2 [Traveling Salesman]
public class TravelingSalesman {
private static final int BEGIN = 0;
private static int visited[];
private static int cycle[];
private static int shortestcycle[];
7-4
private
private
private
private
static
static
static
static
int
int
int
int
nodecount
beginnode
minlength
actlength
=
=
=
=
0;
0;
0;
0;
public static int[] search(int arc[][]){
visited = new int[arc.length];
cycle = new int[arc.length];
shortestcycle = new int[arc.length];
for (int i = 0; i < arc.length; i++) {
visited[i] = 0;
}
// Falls der Graph nicht zusammenhaengend ist, so
// existiert sicher keine Route. Wir muessen die Prozedur
// visit also nur fuer einen Anfangsknoten aufrufen.
visit(arc, BEGIN, 0);
if (minlength > 0) {
return shortestcycle;
} else {
return null;
}
}
private static void visit(int arc[][], int k, int length) {
// In "actlength" ist das Gewicht des aktuellen Astes
// gespeichert. Falls dies Groesser ist als das aktuelle
// Minimum koennen wir diesen Ast abbrechen.
// Bound and Branch Ansatz.
if (minlength > 0 && (actlength + length) >= minlength) {
return;
}
actlength += length;
// In "cycle" sind die Knoten des aktuellen Astes.
// In "nodecount" ist die Laenge in Kanten des
// aktuellen Astes.
cycle[nodecount] = k;
visited[k] = ++nodecount;
// Falls alle Knoten auf dem aktuellen Weg liegen
// und eine Kante zum Anfangsknoten fuehrt, so
// haben wir einen Hamilton-Zyklus gefunden.
// Testen ob er bisher der kuerzeste ist.
if (nodecount == arc.length && arc[k][beginnode] >= 0 &&
(minlength == 0 ||
minlength > actlength + arc[k][beginnode])) {
minlength = actlength + arc[k][beginnode];
// Kopieren der Loesung.
for (int i = 0; i < arc.length ; i++) {
shortestcycle[i] = cycle[i];
}
7-5
} else {
// Wir verfolgen alle Wege.
for (int i = 0; i < arc.length; i++)
if (arc[k][i] >= 0 && visited[i] == 0)
visit(arc, i, arc[k][i]);
}
// Backtracking Ansatz. Wenn wir die Prozedur
// verlassen, machen wir alles rueckgaengig was
// die Prozedur bisher gemacht hat.
actlength -= length;
--nodecount;
visited[k] = 0;
}
}
Bemerkung 7.4 [Komplexität von Branch and Bound] Durch die Anwendung von
Branch And Bound wird die Komplexität des Algorithmus nicht verringert. Das Traveling
Salesman Problem ist und bleibt wie das Problem der Hamilton-Zyklen N P-Vollständig
7.4
Divide-And-Conquer
Diese Methode haben wir schon im Abschnitt 3.4 kennengelernt hier sei nur noch einmal das
Pinzip wiederholt und ein weiteres Beispiel angeführt.
Prinzip:
Die Divide-And-Conquer Methode (kurz: DAC) zerlegt das zu lösende Problem in kleinere
Teilprobleme, (divide) bis die Lösung der einzelnen Teilprobleme trivial ist. Anschliessend
werden die Teillösungen zur Gesamtlösung vereinigt (conquer).
Beispiele:
Quicksort: Im Abschnitt 3.4 schon behandelt.
Matrixmultiplikation: Die Komplexität der Matrixmultiplikation kann mit Hilfe von DAC
verringert werden (Strassen)
Multiplikationen von Integer: Die Anzahl Bit-Operationen bei der Multiplikation von
zwei Integerzahlen kann mit DAC verringert werden.
7.4.1
Matrix-Multiplikation (Strassen)
Der klassische Algorithmus für die Multiplikation von zwei (n × n) Matrizen benötigt
• n3 Multiplikationen und
• n3 − n2 Additionen
7-6
Strassen hat 1969 gezeigt, dass der Aufwand der Matrixmultiplikation mit Hilfe eines DACAgorithmus auf etwa n2.81 reduzierbar ist. Diesen Algorithmus wollen wir in diesem Abschnitt
vorstellen.
Bemerkung 7.5 [Strassen ist DAC] Strassen’s Algorithmus ist ein Beispiel für einen
DAC-Algorithmus, wo das Aufteilen in Teilprobleme einfach ist, das Zusammensetzen zur
Gesammtlösung aber schwierig.
Wir betrachten zuerst die Multiplikation von zwei (2 × 2) Matrizen:
A11 A12
A21 A22
!
×
B11 B12
B21 B22
!
=
C11 C12
C21 C22
!
Im klassischen Algorithmus wird das Resultat folgendermassen berechnet:
C11
C12
C21
C22
= A11 · B11 + A12 · B21
= A11 · B12 + A12 · B22
= A21 · B11 + A22 · B21
= A21 · B12 + A22 · B22
Wir sehen, dass dazu 8 Multiplikationen und 4 Additionen benötigt werden.
Strassen hat nun gezeigt, dass nur 7 Multiplikationen notwendig sind um die zwei Matrizen
zu multiplizieren. Als erstes werden die Hilfszahlen m1 , . . . , m7 wie folgt berechnet:
m1
m3
m5
m7
= (A12 − A22 ) · (B21 + B22 )
= (A11 − A21 ) · (B11 + B12 )
= A11 · (B12 − B22 )
= (A21 + A22 ) · B11
m2 = (A11 + A22 ) · (B11 + B22 )
m4 = (A11 + A12 ) · B22
m6 = A22 · (B21 − B11 )
Das Produkt der Matrizen A und B kann nun aus diesen Werten folgendermassen berechnet
werden:
C11
C12
C21
C22
=
=
=
=
m1 + m2 − m4 + m6
m5 + m4
m6 + m7
m2 − m3 + m5 − m7
Wir wollen am Beispiel von C12 zeigen, dass diese Berechnungen stimmen.
C12 =
=
=
=
m4 + m5
A11 · (B12 − B22 ) + (A11 + A12 ) · B22
A11 · B12 − A11 · B22 + A11 · B22 + A12 · B22
A11 · B12 + A12 · B22
Für (2 × 2) Matrizen können wir nun festhalten:
• Für die Multiplikation der Matrizen werden 7 Multiplikationen und 18 Additionen verwendet.
• Bei der obigen Berechnungen von C11 . . . C22 mit Hilfe von m1 . . . m7 wird die Kommutativität der Multiplikation nirgends verwendet. Das heisst, die obige Berechnung kann
auch durchgeführt werden, wenn A11 . . . A22 , B11 . . . B22 und C11 . . . C22 selbst wieder
Matrizen sind.
7-7
Wir nehmen nun an, dass die Dimension der Matrizen die Form n = 2k hat. Die (n × n)
Matrizen können also in vier ( n2 × n2 ) Matrizen unterteilt werden (divide). Das Resultat kann
nun nach der Methode von Strassen berechnet werden (conquer). Da n = 2k ist, kann dieser
Vorgang rekursiv fortgesetzt werden, bis die Dimension der Matrizen 1 ist. In diesem Fall ist
die Matrixmultiplkation trivial (Multiplikation von zwei Zahlen).
Wir haben nun einen DAC-Algorithmus für die Matrixmultiplikation gefunden. Nun müssen wir noch berechnen, wieviele Multiplikationen und Additionen dieser DAC-Algorithmus
benötigt.
n = 2k
M (k)
A(k)
sei die Dimension der Matrizen wobei k ∈ N
sei die Anzahl Multiplikationen von reellen Zahlen, die Strassens Methode verwendet um zwei (2k × 2k ) Matrizen zu multiplizieren
sei die Anzahl Additionen von reellen Zahlen, die Strassens Methode
verwendet um zwei (2k × 2k ) Matrizen zu multiplizieren
Für die Multiplikationen erhalten wir folgende Rekursionsformel:
M (0) = 1
M (k) = 7 · M (k − 1) f ür k > 0
Diese Rekursionsformel ist sehr einfach zu lösen und es gilt:
M(k) = 7k = 7log2 (n) = nlog2 (7) ≈ n2.81
Die Berechnung der Anzahl Additionen von reellen Zahlen ist etwas komplizierter. Wir brauchen nach der Methode von Strassen 18 Additionen (bzw. subtraktion) von ( n2 × n2 ) Matrizen.
Diese benötigen 18(2k−1 )2 Additionen von reellen Zahlen. Zusätzlich sind 7 Multiplikationen
von ( n2 × n2 ) Matrizen nötig, die 7 · (A(k − 1)) Additionen benötigen. Die Rekursionsformel für
die Additionen hat also folgende Form:
A(0) = 0
A(k) = 18(2k−1 )2 + 7 · A(k − 1)
Wir können A(k − 1) mit derselben Formel ersetzen und erhalten
A(k) = 18(2k−1 )2 + 7 · 18(2k−2 )2 + 72 · A(k − 2)
= 18(2k−1 )2 + 7 · 18(2k−2 )2 + 72 · 18(2k−3 )2 + 73 · A(k − 3)
Wir können nun daraus die allgemeine Formel ableiten und erhalten:
A(k) =
k−1
X
7i · 18 · (2k−i−1 )2
i=0
k 2
Aus dieser Formel klammern wir 18 (24) aus und erhalten
A(k) =
9
2
· 4k ·
Pk−1 7 i
i=0
k
4
( 47 ) −1
= 29 · 4k ·
7
(4 )−1 k
7
−1
= 6 · 4k
4
= 6 · 7k − 6 · 4k
= 6 · n2.81 − 6 · n2
7-8
Bemerkung 7.6 [Allgemeine Dimensionen] Bis jetzt haben wir angenommen, dass die
Dimension der Matrizen die Form n = 2k besitzt. Wenn dies nicht der Fall ist, kann die
Unterteilung der Matrizen in vier ( n2 × n2 ) bei ungeradem n ja nicht vorgenommen werden.
In diesem Fall wird den Matrizen eine Zeile und ein Kolonne mit lauter Nullen angehängt.
Nun ist die Dimension wieder gerade und die Unterteilung kann fortgesetzt werden. Auch für
diesen abgeänderten Algorithmus kann gezeigt werden:
Komplexität = O(n2.81 )
Zum Schluss noch ein paar Bemerkungen:
• Das Hauptinteresse von Strassens Algorithmus ist theoretischer Natur. Erstmals wurde
die Komplexität von O(n3 ) für die Matrixmuliplikation durchbrochen und damit auch
für viele andere Matrizenoperationen wie Matrixinversion, Determinantenberechnung
und Lösen von linearen Gleichungssystemen. Alle diese Operationen können auf die
Matrixmultiplikation zurückgeführt werden und haben daher dieselbe Komplexität.
• Der praktische Nutzen von Strassens Algorithmus ist gering, da der Zeitgewinn durch
den grossen Verwaltungsaufwand wieder aufgehoben wird.
• Die besten heute bekannten Algorithmen für die Matrixmultiplikation haben eine Komplexität von O(n2.4 ). Der lower Bound ist bis heute aber nicht bekannt.
7.5
Dynamic Programming (DP)
Prinzip:
Bei der DAC Methode werden Probleme in kleinere Probleme zerlegt, die dann für sich gelöst
werden können. Dynamic Programming führt dieses Prinzip noch weiter: wissen wir nicht,
welche kleineren Probleme wir lösen müssen, so werden alle gelöst. Diese Teillösungen werden
gespeichert und später bei der Lösung der grösseren Probleme wieder verwendet.
Beispiele:
Floyd’s Algorithmus: Dieser Algorithmus ist im Abschnitt 5.6.4 beschrieben.
Multiplikation von p × q Matrizen: Wenn mehrere Matrizen hintereinander multipliziert
werden, so ist die Anzahl Multiplikationen von der Reihenfolge der Auswertung abhängig. Der Algorithmus soll die optimale Reihenfolge finden.
Rucksak-Problem (Knapsack): Verpacken von Objekten in einem Rucksack mit beschränkter Kapazität so, dass der Wert der gewählten Objekte maximal wird.
Mit der DP Technik sind zwei prinzipielle Schwierigkeiten verbunden:
1. Es ist nicht immer möglich, die Lösungen von zwei kleineren Problemen zur Lösung des
gesammten Problems zu kombinieren.
2. Die Anzahl der kleineren Probleme kann so gross sein, dass das Speichern der Teillösungen nicht mehr möglich ist.
7-9
Wie schon bei den anderen Design-Methoden können nicht alle Probleme mit Hilfe von Dynamic Programming gelöst werden. Es gibt jedoch eine ganze Klasse von Problemen, die gut mit
DP gelöst werden können. Vor allem Probleme, bei denen unter vielen verschiedenen Möglichkeiten etwas zu tun die beste gesucht wird. DP führt meistens zu iterativen Algorihmen.
7.5.1
Multiplikation von pxq Matrizen
Wir möchten das Resultat des Produkts von n Matrizen
M1 × M2 × · · · × Mn
berechnen. Jede Matrix Mi hat ri−1 Zeilen und ri Kolonnen. Die Reihenfolge in der die
Matrizen multipliziert werden beeinflusst die Anzahl Floatoperationen wesentlich wie das
folgende Beispiel zeigt.
Beispiel 7.2 [Reihenfolge der Multiplikation] Als Beispiel betrachten wir die Multiplikation der folgenden 4 Matrizen (die Dimension der Matrizen ist in [] angegeben).
M = M1 × M2 × M3 × M4
|{z}
[10×20]
es gilt also für die Zeilen un Kolonnen:
|{z}
[20×50]
|{z}
[50×1]
|{z}
[1×100]
r0 = 10, r1 = 20, r2 = 50, r3 = 1, r4 = 100
falls in M in der Reihenfolge
M = M1 × (M2 × (M3 × M4 ))
berechnet wird, so sind 125000 Multiplikationen notwendig. Wird M hingegen in der Reihenfolge
M = (M1 × (M2 × M3 )) × M4
berechnet, so sind nur 2200 Multiplikationen notwendig.
Die Reihenfolge in der die Matrizen multipliziert werden spielt also für die Anzahl Operationen eine wesentliche Rolle. Falls wir aber alle möglichen Reihenfolgen betrachten um n
Matrizen zu multiplizieren, so wird der Algorithmus exponentiell und daher für grosse n undurchführbar. Der DP-Ansatz hingegen führt zu einem Algorithmus der Ordnung O(n3 ). mi,j
sei die minimale Anzahl Operationen um dir Matrizen Mi × Mi+1 × . . . × Mj 1 ≤ i ≤ j ≤ n.
Es ist klar, dass für mi,j folgendes gilt.
mi,j =
(
0
: f alls i = j
mini≤k<j (mi,k + mk+1,j + ri−1 rk rj) : f alls j > i
(7.1)
Dabei ist mi,k die minimale Anzahl Operationen für die Berechnung von M ′ = Mi × . . . × Mk
und mk+1,j die minimale Anzahl Operationen für die Berechnung von M ′′ = Mk+1 × . . . ×
Mj . Der Term ri−1 rk rj ist die Anzahl Operationen die gebraucht werden, um M ′ × M ′′ zu
berechnen.
7-10
Der DP-Ansatz berechnet nun alle mi,j und speichert diese in einem 2-dimensionalen Array.
Die Reihenfolge der Berechnung ist folgendermassen.
1. Berechnen mi,i für 1 ≤ i ≤ n
2. Berechnen mi,i+1 für 1 ≤ i ≤ n − 1
3. Berechnen mi,i+2 für 1 ≤ i ≤ n − 2
4. usw
Bei der Berechnung von mi,j stehen uns alle benötigten mi,k und mk+1,j aus der Formel (7.1)
schon zur Verfügung.
Da wir nicht nur an der minimalen Anzahl Operationen interessiert sind sondern auch an der
Reihenfolge der Berechnung, müssen wir uns auch merken, welches k in der Formel (7.1) zum
Minimum führt.
Beispiel 7.3 [Multiplikation von Matritzen] Wir betrachten die Matrizen aus dem Beispiel 7.2. Wir erinnern uns, dass für die Zeilen und Kolonnen folgende Werte gelten:
r0 = 10, r1 = 20, r2 = 50, r3 = 1, r4 = 100
Der DP-Algorithmus liefert nun die folgende Tabelle.
m11 = 0
index = 0
m12 = 10000
index = 1
m13 = 1200
index = 1
m14 = 2200
index = 3
m22 = 0
index = 0
m23 = 1000
index = 2
m24 = 3000
index = 3
m33 = 0
index = 0
m34 = 5000
index = 3
m44 = 0
index = 0
Wir wollen etwa verfolgen, wie die Einträge der zwei letzten Zeilen zustande kommen wenn
wir die Formel 7.1 anwenden (für die zwei ersten Zeilen ist die Berechnung trivial):
Berechnen von m13
k=1:
k=2:
m11 + m23 + r0 r1 r3
m12 + m22 + r0 r2 r3
=
=
0 + 1000 + 10 · 20 · 1
10000 + 0 + 10 · 50 · 1
=
=
1200
10500
index = 1
Berechnen von m24
k=2:
k=3:
m22 + m34 + r1 r2 r4
m23 + m44 + r1 r3 r4
=
=
0 + 5000 + 20 · 50 · 100
1000 + 0 + 20 · 1 · 100
index = 3
Berechnen von m14
7-11
=
= 3000
15000
k=1:
k=2:
k=3:
m11 + m24 + r0 r1 r4
m12 + m34 + r0 r2 r4
m13 + m44 + r0 r3 r4
=
=
=
0 + 3000 + 10 · 20 · 100
10000 + 5000 + 10 · 50 · 100
1200 + 0 + 10 · 1 · 100
=
=
=
23000
65000
2200
index = 3
Aus dem Index können wir nun zurückverfolen, dass zuerst die Matrizen M1 · M3 multipliziert
werden müssen. Das Resultat wird mit M4 multipliziert.
Um die Matrizen M1 · M3 zu multiplizieren wird zuerst M2 mit M3 multipliziert. Nun wird
noch M1 mit dem Resultat multipliziert.
2
Die Komplexität des Algorithmus is wie gesagt O(n3 ). Wir berechnen n2 Minimas mi,j . Zur
Berechnung dieser Minima müssen wir im Durchschnitt n2 Berechnungen in der Formel 7.1
2
vornehmen. Also haben wir ca. n2 · n2 Operationen und das ergibt
Komplexität = O(n3 )
7.5.2
Rucksack-Problem (Knapsack)
Wir nehmen an, dass ein Räuber einen Panzerschrank geknackt hat und darin verschiedene
Objekte von verschiedener Grösse und Wert vorfindet. Der Räuber hat einen Rucksack bei sich,
mit einem Volumeninhalt von M , in dem er seine Beute verpacken kann. Wir nehmen ferner
an, dass von jeder Objektart im Schrank genug Objekte vorhanden sind, um den Rucksack zu
füllen.
Die Frage ist nun, welche Objekte der Räuber im Rucksack einpacken muss, damit der Wert
seiner Beute maximiert wird.
Wir können natürlich das Problem lösen, indem wir alle möglichen Kombinationen einfach
versuchen und die beste auswählen. Die Komplexität eines solchen Algoritmus ist aber exponentiell.
Bei einem DP Ansatz berechnen wir die besten Kombinationen für alle Kapazitäten des Rucksacks bis zur Endkapazität M . Wenn diese Berechnungen in einer geschickten Reihenfolge
ausgeführt werden, so entsteht ein effizienter Algorithmus, der nachstehend angegeben ist.
Algorithmus 7.3 [Knapsack]
for (i = 1; i <= N; i++) {
for (j = 1; j <= M; j++) {
if (j - size[i] >= 0) {
if (cost[j] < cost[j - size[i]] + value[i]) {
cost[j] = cost[j - size[i]] + value[i];
best[j] = i;
}
}
}
}
7.5.2.1
//(1)
//(2)
//(3)
Erklärungen
In den Arrays value und size sind der Wert und die Grösse der verschiedenen Objekte
gespeichert. Im Array cost ist für jede Kapazität der maximal erreichbare Wert gespeichert
7-12
und im Array best wird das letzte hinzugefügte Objekt, das zu diesem maximalen Wert
führt, gespeichert. best dient dazu, am Schluss zu bestimmen, welche Objekte gewählt werden
müssen.
(1) Loop über alle Objektarten.
(2) Berechnen des optimalen Wertes für alle Kapazitäten indem nur die Objektarten 1 bis
i verwendet werden.
(3) An dieser Stelle testen wir, ob wir im Rucksack der Kapazität j ein Objekt der Art
i hinzufügen sollen oder nicht. Der maximale Wert den wir erreichen, falls wir das
Objekt hinzunehmen, ist der Wert des Objektes value[i] plus der maximale Wert eines
Rucksacks der Kapazität j - size[i] und das ist gleich cost[j - size[i]], das ja
schon berechnet ist. Ist nun dieser Wert grösser als cost[i], so wird das neue Objekt
dazugenommen und die Array-Elemente cost[j] und best[j] entsprechend verändert.
Beispiel 7.4 [Knapsack] Wir wollen den Inhalt der Arrays cost[] und best[] verfolgen,
wenn die Kapazität des Rucksacks 17 ist, und die folgenden Objekte zur Verfügung stehen:
Objekt
Grösse
Wert
1
3
4
2
4
5
3
7
10
4
8
11
5
9
13
Die Abbildung 7-3 zeigt wie die Arrays cost[] und best[] durch den DP Algorithmus aufgebaut werden.
Kapazität
0
1
2
3
4
cost
best
0
0
0
4
1
4
1
cost
best
0
0
0
4
1
5
2
cost
best
0
0
0
4
1
5
2
cost
best
0
0
0
4
1
5
2
cost
best
0
0
0
4
1
5
2
5
6
7
8
9 10 11
Objektart 1
4 8
8
8 12 12 12
1 1
1
1
1
1
1
Objektarten 1 und 2
5 8
9 10 12 13 14
2 1
2
2
1
2
2
Objektarten 1, 2 und 3
5 8 10 10 12 14 15
2 1
3
2
1
3
3
Objektarten 1, 2, 3 und 4
5 8 10 11 12 14 15
2 1
3
4
1
3
3
Objektarten 1, 2, 3 , 4 und 5
5 8 10 11 13 14 15
2 1
3
4
5
3
3
12
13
14
15
16
17
16
1
16
1
16
1
20
1
20
1
20
1
16
1
17
2
18
2
20
1
21
2
22
2
16
1
18
3
20
3
20
1
22
3
24
3
16
1
18
3
20
3
21
4
22
3
24
3
17
5
18
3
20
3
21
4
23
5
24
3
Abbildung 7-3: Ablauf des DP Algorithmus für knapsack
Der maximale Wert, der erreicht werden kann, ist also 24. Welche Objekte mitgenommen
werden müssen, können wir im Array best ablesen.
Wir haben nicht nur das Resultat für die Kapazität M = 17 berechnet. Wir können in unserem
Array den maximalen Wert für jede Kapazität von 1 bis 17 ablesen.
Wir wollen nun mit Hilfe des Arrays best ermitteln, welche Objekte zum maximalen Wert
führen. Nach Definition ist best[M] in der Lösung eingeschlossen. Die restlichen Objekte
7-13
sind dieselben wie diejenigen der otimalen Lösung für einen Rucksack der Kapazität M size[best[M]], also gehört best[M - size[best[M]] zur Lösung und so weiter. Für die
Kapazität 17 bekommen wir also als Lösung:
best[17] = 3
best[17 - size[3]] = best[10] = 3
best[10 - size[3]] = best[3] = 1
Es ist leicht zu sehen, dass die Komplexität des Algorithmus N ·M beträgt. Dies ist akzeptabel,
solange M nicht zu gross wird (pseudo polynomial).
Der Speicherbedarf muss bei DP Algorithmen immer in Betracht gezogen werden. Solche Algorithmen sind nur dann durchführbar, wenn die Anzahl der zu speichernden Teillösungen
’vernünftig’ bleibt. In unserem Fall sind dies die beiden Integer-Arrays cost und best. Das
heisst, der Speicherbedarf ist proportional zur Kapazität M . Dies ist auch nur dann akzeptabel, wenn M nicht zu gross wird.
Bemerkung 7.7 [Anzahl Objekte] Diese DP Methode funktioniert nur, falls von jeder
Objektart beliebig viele Objekte vorhanden sind. Ist dies nicht der Fall, so wird das Problem
N P-vollständig.
7.6
Greedy (die gierige Methode)
Greedy ist eine Methode, die dann versucht werden kann, wenn von n möglichen Lösungen
eines Problems, die bezüglich einer Bewertungsfunktion f optimale Lösung gesucht wird.
Prinzip:
Die Greedy-Methode arbeitet in Schritten, ohne voraus- oder zurückzublicken. Bei jedem
Schritt wird aus einer Menge von möglichen Wegen derjenige ausgesucht, der den Bedingungen
des Problems genügt und lokal optimal ist.
Beispiele:
Minimaler aufspannender Baum (Kruskal): Dieser Algorithmus wurde im Abschnitt
5.6.2 behandelt.
Färbungsproblem: Färbe eine maximale Anzahl Knoten mit einer ersten Farbe, eine maximale Anzahl der verbleibenden Knoten mit einer zweiten Farbe, etc.
Traveling Salesman Problem: Wähle in jedem Knoten jeweils die billigste Kante, welche
zu einem noch unbesuchten Knoten führt (“best first search”).
7.6.1
Hill Climbing
Wir wollen die Arbeitsweise dieser Methode an einem anschaulichen Beispiel illustrieren. Wir
nehmen an, dass jemand sich irgendwo auf einem Berg befindet und so schnell wie möglich zum
Gipfel kommen möchte. Eine einfache Greedy-Strategie für dieses Problem ist die folgende.
Man bewege sich immer entlang der grössten Steigung nach oben bis dies nicht
mehr möglich ist. Das heisst, bis in allen Richtungen die Wege nur noch nach unten
führen.
7-14
Abbildung 7-4: Hill climbing
Dieser Ansatz ist in der Abbildung 7-4 dargestellt. Es ist ein typischer Greedy Ansatz. Man
schaut nicht zurück, und nach vorne wird die lokal optimale Strategie gewählt.
In der Abbildung 7-5 sieht man aber, dass diese Strategie nicht unbedingt zum Ziel führt. Hat
der Berg mehrere Nebengipfel, so kann man auf einem solchen Nebengipfel stehen bleiben.
Wir haben also nur ein lokales Optimum erreicht.
Maximum
Lokales Maximum
Abbildung 7-5: Erreichen eines lokalen Maximums mit Greedy
Bei vielen Problemen derselben Art liefert nur ein exponentieller Algorithmus eine optimale
Lösung, während ein heuristischer Ansatz mit Greedy nur “fast” optimale Lösungen liefert,
dies aber in polynomialer Zeit.
Es existieren aber auch Probleme, bei denen der Greedy-Ansatz zum optimalen Ergebnis
führt, wie zum Beispiel der Algorithmus von Kruskal oder für das Task Scheduling Problem.
7.6.2
Task Scheduling
In diesem Beispiel wollen wir ein Optimierungsproblem betrachten. Wir nehmen an, dass wir
eine gegebene Menge T von n Tasks (Arbeiten) haben, so dass jeder Task i eine Startzeit si
und eine Endzeit ei mit si < ei besitzt. Für jeden Task wird eine Maschine benutzt und jede
Maschine kann nur einen Task gleichzeitig ausführen. Zwei Taks i und j sind konfliktfrei, falls
ei ≤ sj oder ej ≤ si . Zwei Tasks können nur dann auf derselben Maschine ausgeführt werden,
wenn sie konfliktfrei sind.
Das Task Scheduling Problem besteht nun darin, alle Tasks in T auf eine minimale Anzahl
Maschinen zu verteilen. Wir können zum Beispiel annehmen, dass die Tasks Sitzungen sind
und dass wir diese Sitzungen auf eine minimale Anzahl Sitzungszimmer verteilen möchten. In
der Abbildung 7-6 ist ein Beispiel für eine solche Verteilung.
7-15
Maschine 3
Maschine 2
Maschine 1
1
2
3
4
5
6
7
8
9
Abbildung 7-6: Verteilung der Tasks {(1, 3), (1, 4), (2, 5), (3, 7), (4, 7), (6, 9), (7, 8)}
Der Greedyansatz ist nun, die Tasks nach Ihrer Startzeit zu sortieren und auf die erste Maschine zu verteilen, wo kein Konflikt entsteht. Es wird nie ein Zug zurückgenommen. Nachfolgend
ist der Algorithmus in Pseudocode angegeben.
Algorithmus 7.4 [TaskSchedule(T)]
Input: Eine Menge T von Tasks mit Startzeit si und Endzeit ei .
Output: Eine Verteilung der Tasks auf eine minimale Anzahl von Maschinen, so dass kein
Konflikt entsteht.
m = 0 // Anzahl Maschinen
while T 6= ∅
Entferne aus T der Task i mit der kleinsten Startzeit si
if es existiert eine Maschine j mit allen Task sind mit Task i konfliktfrei then
Task i wird auf Maschine j ausgeführt
else
m=m+1
Task i wird auf Maschine m ausgeführt.
endif
endwhile
Wir wollen noch zeigen, dass der Algorithmus wirklich eine oprimale Lösung findet. Das heisst,
die Anzahl Maschinen, die vom Algorithmus ermittelt wird ist tatsächlich minimal.
Dazu nehmen wir das Gegenteil an. Das heisst, der Algorithmus findet eine Verteilung mit k
Maschinen aber es gäbe eine konfliktfreie Verteilung, die nur k − 1 Maschinen benötigt. Wir
nehmen an, dass k die letzte vom Algorithmus allozierte Maschine ist und dass i der Task
ist, der zur Allokation geführt hat. Nach Aufbau des Algorithmus wird die Maschine k nur
dann alloziert, wenn auf jede Maschine 1 . . . k − 1 einen Task existiert, der einen konflikt mit
dem Task i besitzt. Da wir die Task in der Reihenfolge ihrer Startzeit betrachten, haben alle
betrachteten Tasks eine Startzeit die kleiner oder gleich ist wie si und haben eine Endzeit,
die grösser als si ist. Das heisst aber, dass alle betrachteten Tasks auch untereinander einen
Konflikt aufweisen (siehe Abbildung 7-7). Wir haben also k Tasks die alle untereinander einen
Konflikt aufweisen. Diese können aber unmöglich auf nur k − 1 Maschinen verteilt werden.
Daher ist k die minimale Anzahl Maschinen um das Problem zu lösen.
7-16
si
Task i
Alle Tasks die mit i einen Konflikt haben sind
untereinander nicht konfliktfrei.
Abbildung 7-7: Tasks mit Konflikten zum Task i
7.7
Parallele Algorithmen
Prinzip:
Um die Performance von Algorithmen zu verbessern, wird die zu lösende Aufgabe auf mehreren
Prozessoren verteilt.
Beispiele:
Matrix Multiplikation: Strassen’s Algorithmus kann gut parallelisiert werden.
Vektorströme: Multiplikation eines Vektorstroms mit einer Matrix.
Lineare Algebra: Fast alle Algorithmen der linearen Algebra lassen sich gut parallelisieren.
In diesem Abschnitt sollen kurz die wichtigsten parallelen Architekturen und zwei parallele
Algorithmen vorgestellt werden. Diese kurze Einführung erhebt keinen Anspruch auf Vollständigkeit.
7.7.1
Pipelining
Pipelining ist ein altes Konzept (zum Beispiel ist es schon im Z-80 Prozessor realisiert). Die
Idee ist folgende:
Die auszuführenden Operationen werden in Teiloperationen aufgeteilt und für jede
Teiloperation existiert im Prozessor eine unabhängige Einheit, die diese Teiloperation ausführen kann. Nun kann die erste Teiloperation für die aktuellen Operanden
ausgeführt werden. Sobald diese beendet ist, beginnt die zweite Teiloperation für
die ersten Operanden und gleichzeitig die erste Operation für die nächsten Operanden.
Beispiel 7.5 [Pipelining] In einem Mikroprozessor kann die Ausführung einer Instruktion
in die folgenden Teiloperationen aufgeteilt werden:
• Holen der Instruktion und der Operanden vom Memory.
• Ausführen der Operation.
7-17
Existieren nun im Prozessor für diese Operationen zwei Einheiten, die unabhängig voneinander
arbeiten können, so kann die nächste Instruktion vom Memory gelesen werden, währenddem
die aktuelle Instruktion ausgeführt wird.
Beispiel 7.6 [Addition von Vektoren] Wir wollen zwei n-dimensionale Vektoren addieren.
(x1 , . . . , xn ) + (y1 , . . . , yn ) = (x1 + y1 , . . . , xn + yn )
Die Addition von Zahlen wird nun in kleinere Teiloperationen Op1 , . . . , Opm unterteilt. Für
jede Teiloperation existiert im Prozessor eine Rechnungseinheit, die unabhängig von den anderen Rechnungseinheiten arbeiten kann. Sobald nun die Teiloperation Op1 für die ersten
Komponenten der Vektoren beendet ist, kann mit der Teiloperation Op2 begonnen werden
und gleichzeitig kann die Teiloperation Op1 für die zweite Komponente der Vektoren in Angriff genommen werden. Die Abbildung 7-8 veranschaulicht dieses Prinzip.
x1 + y1
x2 + y2
x3 + y3
x4 + y4
x4 + y4
x3 + y3
x2 + y2
x1 + y1
t
Zeitdifferenz
Abbildung 7-8: Pipelining
7.7.2
Vektor Rechner
Normalerweise besitzt ein Prozessor eine gewisse Anzahl Register in denen je ein Prozessorwort (binäre Zahl) gespeichert werden kann. Beim Vektorrechner besteht ein Register nicht
aus einem skalaren Wert, sondern aus einem Vektor. Eine Addition von zwei Registern ist
dann entsprechend eine Vektoraddition der beiden Vektoren. Die Addition der einzelnen Vektorkomponenten werden alle parallel ausgeführt. Solche Prozessoren sind natürlich vor allem
zum Rechnen geeignet und werden in wissenschaftlichen Applikationen verwendet.
Standardbeispiele für Vektorrechner sind die CRAY Supercomputer. Für diese Maschinen
existieren FORTRAN Compiler, welche die Vektorarchitektur ausnutzen. Das Prinzip dabei
ist, die innersten Loops eines Programms zu finden und diese wenn immer möglich durch
Vektoroperationen zu ersetzen.
Beispiel 7.7 [Vektoroperationen] Der Loop
FOR i = 1 TO N
C[i] := A[i] + B[i]
kann durch die entsprechende Vektoraddition
~ := A
~+B
~
C
7-18
ersetzt werden.
Der Vorteil bei diesem Vorgehen ist, dass bestehende Programme nicht speziell für die neue
Architektur umprogrammiert werden müssen.
7.7.3
Multiprozessor Systeme
Bei dieser Architektur wird die Parallelität durch den Einsatz von mehreren Prozessoren,
die miteinander kommunizieren, erreicht. Dabei können zwei verschiedene Philosophien angewandt werden.
7.7.3.1
Eng gekoppelte Systeme
Diese Systeme benutzen zur Kommunikation und Synchronisation ein gemeinsames Memory.
Die Prozessoren sind über einen Bus verbunden. Jeder Prozessor kann zusätzlich über ein
privates Cachememory verfügen, wo ein Teil der Informationen des Hauptspeichers dupliziert
sind. Ein eng gekoppeltes System ist in der Abbildung 7-9 dargestellt.
P1
P2
P3
Pn
C
C
C
C
Memory
Abbildung 7-9: Eng gekoppeltes System
Bei eng gekoppelten Systemen kommt es sehr oft zur Überlastung des Buses. Werden zu viele
Prozessoren an den Bus geschaltet, so verbringen diese die meiste Zeit mit Warten, weil der
Bus nicht frei ist. Durch das zusätzliche Cachememory in jedem Prozessor wird diese Situation
zwar entschärft, es entsteht aber das Problem der Cache Konsistenz.
7.7.3.2
Lose gekoppelte Systeme
Diese Systeme basieren auf dem Prinzip des Nachrichtenaustausches über ein Netzwerk. Jeder
Prozessor hat ein eigenes privates Memory und eine oder mehrere Punkt zu Punkt Verbindungen zu anderen Prozessoren im System. In einem solchen System existiert kein gemeinsames
Memory. Alle Informationen zwischen Prozessoren werden ausschliesslich über Kommunikationskanäle ausgetauscht. Ein lose gekoppeltes System ist in der Abbildung 7-10 dargestellt.
Typische Repräsentanten für solche Systeme sind Transputernetzwerke.
7-19
M
P1
M
P4
M
M
P3
P2
Abbildung 7-10: Lose gekoppeltes System
7.7.4
Klassifikation von Flynn
Flynn hat für die Klassifikation von Computerarchitekturen folgendes Schema vorgeschlagen.
SISD MIMD
MISD SIMD
S
M
Dabei bedeuten:
I
D
Single
Multiple
Instruction stream
Data stream
Diese Klassifikation können wir folgendrmassen interpretieren:
SISD: Single Instruction Single Data
Das ist die klassische von Neumann Architektur.
SIMD: Single Instruction Multiple Data
Das sind die Vetorprozessoren, bei denen dieselbe Instruktion auf mehreren Datenströmen (Komponenten der Vektoren) gleichzeitig ausgeführt wird.
MIMD: Multiple Instruction Multiple Data
Das sind die Prozessornetzwerke. Auf verschiedenen Datenströmen können unabhängig
voneinander auch verschiedene Instruktionen ausgeführt werden.
MISD Multiple Instruction Single Data
Bei dieser Klasse gibt es unter den Experten einen Streit. Die einen behaupten, dass eine
solche Architektur nicht existiert. Andere interpretieren das Pipelining als MISD, was sicher auch vertretbar ist. Beim Pipelining werden auf demselben Datenstrom gleichzeitig
mehrere Instruktionen ausgeführt.
7-20
7.7.5
Parallele Algorithmen
In diesem Abschnitt werden zwei parallele Algorithmen vorgestellt. Der erste dieser Algorithmen illustriert, wie die Parallelität als Designmethode (oder auch als Denkweise) verwendet
werden kann.
Der zweite Algorithmus illustriert, wie die Lösung einer Aufgabe mit Hilfe der Parallelität
verschnellert werden kann.
7.7.5.1
Lochkartenproblem
Bei diesem Algorithmus wird die Denkweise beim parallelen Programmieren an Hand eines
typischen Beispiel von Jackson illustriert.
Aufgabe:
• Es sollen Lochkarten a je 80 Zeichen gelesen werden.
• Nach jeder Karte muss im Zeichenstrom ein Blank eingefügt werden.
• Die Zeichen sollen in Zeilen a je 132 Zeichen auf einem Drucker ausgegeben werden.
Nach 132 Zeichen muss also ein Newline im Datenstrom eingefügt werden.
• Die letzte Zeile muss, falls nötig, mit Blank aufgefüllt werden.
Dieses Problem ist klassisch nicht einfach zu lösen, da nicht klar ist, ob der äussere Loop
über die Karten oder über die Druckerzeilen gehen soll. In einem parallelen System stellen
wir folgende Überlegungen an:
Die Teilaufgabe, einen Zeichenstrom zu lesen, und nach 80 Zeichen ein Blank
einzufügen, ist logisch unabhängig von der Aufgabe einen Zeichenstrom zu lesen,
und nach 132 Zeichen ein Newline einzufügen.
Diese Überlegung führt dazu, zwei durch einen Kanal verbundene Prozesse einzuführen, die
je eine der Teilaufgaben übernehmen. Die Anordnung der Prozesse und der Kanäle ist in der
Abbildung 7-11 dargestellt.
C1
C2
P1
C3
P2
Abbildung 7-11: Prozessgraph für das Druckproblem
Wir wollen in Worten beschreiben, was die Prozesse P1 und P2 tun:
Prozess P1
while noch Charakter von c1
lies 80 Charakter von c1 und schreibe diese auf c2.
schreibe Blank auf c2.
7-21
Prozess P2
while noch Charakter von c2
lies 132 Charakter von c2 und schreibe diese auf c3.
schreibe Newline auf c3.
if noch Charakters vorhanden
schreibe entsprechend viele Blanks und ein Newline auf c3
7.7.5.2
Multiplikation eines Vektorstroms mit einer Matrix
Wir wollen ein Problem aus der linearen Algebra behandeln. Es geht darum, einen Strom von
3-dimensionalen Vektoren mit einer (3 × 3) Matrix zu multiplizieren. (Lineare Abbildung im
Raum).
Die für die Lösung dieser Aufgabe benötigten Prozesse und Kanäle sind in der Abbildung 7-12
dargestellt.
0
0
0
x1
x1
P[1,1]
sum + a[1,1]*x1
x1
P[1,2]
sum + a[1,2]*x1
sum + a[2,1]*x2
x2
P[2,2]
sum + a[2,2]*x2
P[3,1]
sum + a[3,1]*x3
S2
sum + a[2,3]*x2
x3
P[3,2]
sum + a[3,2]*x3
y1
x2
P[2,3]
x3
x3
S1
sum + a[1,3]*x1
x2
P[2,1]
x2
x1
P[1,3]
x3
P[3,3]
S3
sum + a[3,3]*x3
y2
y3
Abbildung 7-12: Multiplikation von Vektoren mit einer Matrix
Wir beschreiben nun den Anfangszustand und was die einzelnen Prozesse in diesem System
tun.
Beschreibung des Gesamtsystems
gespeichert.
• Im Prozess P[i,j] ist das Element a[i,j] der Matrix
• Von den drei Kanälen im Westen können die einzelnen Komponenten des Vekorstreams gelesen werden.
7-22
• Die drei Kanäle im Norden produzieren je einen Datenstream, die aus lauter
Nullen bestehen.
• Das Resultat kann an den drei Kanälen im Süden abgelesen werden.
Beschreibung der Prozesse S1 . . . S3
Diese drei Prozesse werden nur als Senken verwendet. Sie lesen den Input aus Westen
und sonst nichts.
Beschreibung der Prozesse P[i,j ]
Alle neun Prozesse P[i,j] tun genau das Gleiche.
1. Lesen des Inputs von Westen auf die Variable x.
2. Lesen des Inputs von Norden auf die Variable sum.
3. Schreiben des Wertes von x nach Osten.
4. Schreiben des Wertes sum + a[i,j] * x nach Süden.
Es ist einfach zu sehen, dass im Süden des Systems das gewünschte Resultat gelesen werden
kann (siehe auch die Abbildung 7-12).
Die Prozesse des obigen Systems können echt parallel arbeiten.
7-23
Abbildungsverzeichnis
1-1 Abstraktion von Personen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1-1
1-2 Die REALs auf der reellen Zahlenachse . . . . . . . . . . . . . . . . . . . . . . . 1-5
1-3 Die Struktur Stack . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1-16
1-4 Die Struktur Queue . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1-16
1-5 Allgemeine Baumstruktur als Graph . . . . . . . . . . . . . . . . . . . . . . . . 1-17
1-6 Die verschiedenen binären Bäume . . . . . . . . . . . . . . . . . . . . . . . . . . 1-18
1-7 Darstellung eines allgemeinen Baumes als binärer Baum . . . . . . . . . . . . . 1-20
1-8 Syntaxbaum für den Ausdruck ((a + b) · c − d/e) · 7 . . . . . . . . . . . . . . . . 1-22
2-1 Diverse Algorithmen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2-1
2-2 Die Turingmaschine . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2-3
2-3 Ablauf der Funktion stopp_tester . . . . . . . . . . . . . . . . . . . . . . . . 2-6
2-4 Ablauf der Funktion stopp_tester_neu . . . . . . . . . . . . . . . . . . . . . 2-6
2-5 Ablauf von unsinn(unsinn) . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2-7
2-6 Durchführbare Algorithmen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2-8
2-7 Standardalgorithmus zur Multiplikation . . . . . . . . . . . . . . . . . . . . . . 2-8
2-8 “Natürliche” Länge von Problemen . . . . . . . . . . . . . . . . . . . . . . . . . 2-9
2-9 Zeitverhalten von Algorithmen . . . . . . . . . . . . . . . . . . . . . . . . . . . 2-11
2-10 Ausführungszeiten von Algorithmen . . . . . . . . . . . . . . . . . . . . . . . . 2-11
2-11 (a) kreieren des Arrays B (b) kopieren der Elemente von A nach B (c) setzen von A=B2-13
2-12 (a) Array mit 8 Elementen. Die Elemente 4 bis 7 besitzen je 2 Cyberdollar (b) neuer Array die C
2-13 (a) Grösse wird verdoppelt (b) Array wird immer um 3 Elemente vergrössert . 2-15
3-1 Der Heap als Array . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3-7
3-2 Einfügen im Heap . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3-8
3-3 Löschen im Heap . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3-8
3-4 Konstruktion des Heaps . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3-9
3-5 Partitionierungsprozess von Quicksort . . . . . . . . . . . . . . . . . . . . . . . 3-12
3-6 Komplexitätsanalyse von randomized Quicksort . . . . . . . . . . . . . . . . . . 3-16
3-7 Funktionsweise von Replacement Selection . . . . . . . . . . . . . . . . . . . . . 3-19
24
4-1 Hashing (Schlüsseltransformation) . . . . . . . . . . . . . . . . . . . . . . . . . 4-4
4-2 Die beiden Operationen der Hashfunktion . . . . . . . . . . . . . . . . . . . . . 4-6
4-3 Hashing mit separat chaining . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4-8
4-4 Double hashing . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4-9
4-5 Suchen mit double hashing . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4-10
4-6 Ein 2-3-4-Baum . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4-19
4-7 Einfügen von Schlüsseln im 2-3-4-Baum . . . . . . . . . . . . . . . . . . . . . . 4-19
4-8 Aufspalten der 4-Knoten beim Einfügen . . . . . . . . . . . . . . . . . . . . . . 4-20
4-9 Löschen im 234-Baum . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4-21
4-10 Darstellung von 4- und 3-Knoten . . . . . . . . . . . . . . . . . . . . . . . . . . 4-22
4-11 Ein Rot-Schwarz-Baum . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4-22
4-12 Restrukturierung des Rot-Schwarz-Baumes . . . . . . . . . . . . . . . . . . . . 4-23
4-13 Spliten eines 4-Knotens in Rot-Schwarz-Bäume . . . . . . . . . . . . . . . . . . 4-24
4-14 Verschmelzen zweier Knoten im Rot-Schwarz-Baum
. . . . . . . . . . . . . . . 4-24
4-15 Fortpflanzung des “doppel schwarz” Problems im Rot-Schwarz-Bäume . . . . . 4-25
4-16 Ausleihen eines Schlüssels beim Nachbarn . . . . . . . . . . . . . . . . . . . . . 4-26
4-17 Anpassungsoperation im Rot-Schwarz-Baum . . . . . . . . . . . . . . . . . . . . 4-26
4-18 Die zig-zig Operation: (a) vor der Opeation (b) nach der Operation. Es gibt einen zweiten symme
4-19 Die zig-zag Operation: (a) vor der Operation (b) nach der Operation. Es gibt einen zweiten symm
4-20 Die zig Operation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4-28
4-21 Splay-Operation (1): (a) Splay von Knoten 14 beginnt mit einer zig-zag Operation. (b) nach zig-z
4-22 Splay-Operation (2): (d) nach zig-zig (e) nächster Schritt ist wieder zig-zig (f) die Splay-Operatio
4-23 Einfügen im Splay Baum: (a) Anfangsbaum (b) einfügen von 2 (c) nach der Splay-Operation (d)
4-24 Löschen im Splay Baum: (a) löschen des Schlüssels 8. Dabei wird der Knoten mit dem Schlüssel 7
4-25 B-Tree der Ordnung 2 mit 3 Stufen . . . . . . . . . . . . . . . . . . . . . . . . . 4-37
4-26 Knoten eines B-Trees . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4-37
4-27 Einfügen im B-Tree . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4-38
4-28 Wachstum eines B-Tree der Ordnung 1 . . . . . . . . . . . . . . . . . . . . . . . 4-39
4-29 Ausgleich im B-Tree . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4-40
4-30 Zusammenlegen zweier Knoten im B-Tree . . . . . . . . . . . . . . . . . . . . . 4-40
4-31 B-Tree mit lauter gleichen Schlüssel . . . . . . . . . . . . . . . . . . . . . . . . 4-41
4-32 Beispiel einer Skipliste . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4-42
4-33 Suchen des Schlüssels 50 in der Skipliste . . . . . . . . . . . . . . . . . . . . . . 4-43
4-34 Einfügen des Schlüssels 42 in der Skipliste . . . . . . . . . . . . . . . . . . . . . 4-44
4-35 Löschen des Schlüssels 25 aus der Skipliste . . . . . . . . . . . . . . . . . . . . . 4-45
5-1 Königsberger Brückenproblem . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5-1
25
5-2 Beispiel eines gerichteten Graphen . . . . . . . . . . . . . . . . . . . . . . . . . 5-2
5-3 Beispiel eines ungerichteten Graphen . . . . . . . . . . . . . . . . . . . . . . . . 5-4
5-4 Adjazenzliste . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5-6
5-5 Adjazenzliste für ungerichteten Graphen . . . . . . . . . . . . . . . . . . . . . . 5-6
5-6 Adjazenzmatrix . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5-7
5-7 Adjazenzmatrix für ungerichteten Graphen . . . . . . . . . . . . . . . . . . . . 5-7
5-8 Ein gerichteter Graph . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5-10
5-9 Tiefenwald zum obigen Graphen . . . . . . . . . . . . . . . . . . . . . . . . . . 5-10
5-10 Jede Rückwärtskante bildet mit Baumkanten einen Zykel . . . . . . . . . . . . 5-12
5-11 Ein gerichteter azyklischer Graph . . . . . . . . . . . . . . . . . . . . . . . . . . 5-12
5-12 Zusammenhangskomponenten eines Graphen . . . . . . . . . . . . . . . . . . . 5-13
5-13 Algorithmus von Kruskal . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5-15
5-14 Bestimmen der Zusammenhangskomponenten . . . . . . . . . . . . . . . . . . . 5-16
5-15 Gesetzte Knoten und spezielle Pfade . . . . . . . . . . . . . . . . . . . . . . . . 5-18
5-16 Ein gewichteter Graph . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5-19
5-17 kürzester Pfad nach v . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5-20
5-18 Vorletzter Knoten des kürzesten speziellen Pfades . . . . . . . . . . . . . . . . . 5-20
5-19 Verwenden des Knotens u um den Weg zwischen v und w zu verkürzen . . . . . 5-21
5-20 Ein gewichteter Graph . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5-22
5-21 Ein (k + 1)-Pfad ist aus zwei k-Pfade R und Q zusammengesetzt. . . . . . . . . 5-23
5-22 Ein Flussgraph mit einem gültigen Fluss f (mit Wert |f | = 10) . . . . . . . . . 5-25
5-23 Ein maximaler Fluss f ∗ (mit Wert |f ∗ | = 14) für den Flussgraphen N . . . . . 5-26
5-24 Ein Flussgraph mit drei Schnitte X1 , X2 und X3
. . . . . . . . . . . . . . . . . 5-27
5-25 Ein nutzbarer Pfad π mit ∆f (π) = 2 für den Fluss f im Flussgraphen N . . . . 5-29
5-26 Der Fluss f ′ der aus dem Fluss f entsteht . . . . . . . . . . . . . . . . . . . . . 5-29
5-27 Der Algorithmus von Ford-Fulkerson . . . . . . . . . . . . . . . . . . . . . . . . 5-31
5-28 Falls die nutzbaren Pfade zwischen (q, v1 , v2 , s) und (q, v2 , v1 , s) alternieren so braucht es 2,000,00
6-1 Trie zum Code A = 11, B = 00, C = 10, D = 010 und E = 011 . . . . . . . . . 6-9
6-2 Dekodieren mit Hilfe eines Tries . . . . . . . . . . . . . . . . . . . . . . . . . . . 6-9
6-3 Erzeugung des Huffman-Codes . . . . . . . . . . . . . . . . . . . . . . . . . . . 6-11
7-1 Ein ungerichteter Graph . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7-3
7-2 Suchbaum für den Graphen aus Abb. 7-1 . . . . . . . . . . . . . . . . . . . . . 7-4
7-3 Ablauf des DP Algorithmus für knapsack . . . . . . . . . . . . . . . . . . . . . 7-13
7-4 Hill climbing . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7-15
7-5 Erreichen eines lokalen Maximums mit Greedy . . . . . . . . . . . . . . . . . . 7-15
7-6 Verteilung der Tasks {(1, 3), (1, 4), (2, 5), (3, 7), (4, 7), (6, 9), (7, 8)} . . . . . . . . 7-16
26
7-7 Tasks mit Konflikten zum Task i . . . . . . . . . . . . . . . . . . . . . . . . . . 7-17
7-8 Pipelining . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7-18
7-9 Eng gekoppeltes System . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7-19
7-10 Lose gekoppeltes System . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7-20
7-11 Prozessgraph für das Druckproblem
. . . . . . . . . . . . . . . . . . . . . . . . 7-21
7-12 Multiplikation von Vektoren mit einer Matrix . . . . . . . . . . . . . . . . . . . 7-22
27
Literaturverzeichnis
[AHU74] Aho, Hopcroft, and Ullman. The Design and Analysis of Computer Algorithms.
Addison Wesley, Reading, Massachusetts, 1974. ISBN 0-201-00029-6.
[AU95] A.V. Aho and J.D. Ullman. Foundations Of Computer Science C Edition. Computer
Science Press An Imprint of W.H. Freeman and Company, New York, 1995. ISBN
0-7167-8284-7.
[GT02] M.T. Goodrich and R. Tamassia. Algorithm Design Foundations, Analysis and
Internet Examples. John Wiley & Sons,Inc., New York, 2002. ISBN 0-471-38365-1.
[GT04] M.T. Goodrich and R. Tamassia. Data Structures and Algorithms in Java. John
Wiley & Sons,Inc., New York, 3rd edition, 2004. ISBN 0-471-46983-1.
[Knu73a] D.E. Knuth. The Art of Computer Programing, volume 1 Fundamental Algorithms.
Addison Wesley, Reading, MA, 1973. ISBN 0-201-03809-9.
[Knu73b] D.E. Knuth. The Art of Computer Programing, volume 3 Sorting and Searching.
Addison Wesley, Reading, MA, 1973. ISBN 0-201-03803-X.
[Knu81] D.E. Knuth. The Art of Computer Programing, volume 2 Seminumerical algorithms.
Addison Wesley, Reading, MA, 1981. ISBN 0-201-03822-6.
[Sed02] Robert Sedgewick. Algorithms in Java. Addison Wesley, 2002. ISBN 978-0-20136120-9.
28
Index
2-3-4-Baum, 4-18
Averrage, 2-10
Abgeleitete Typen
Funktionsräume, 1-10
Strukturierter Typ, 1-10
Subtyp, 1-10
Unterbereichstyp, 1-10
Abstraktion, 1-1
Accessoren, 1-26
adjazent, 5-2, 5-3
Adjazenzliste, 5-6
Adjazenzmatrix, 5-6
ADT, 1-23, 4-1
Accessoren, 1-26
Formale Spezifikation, 1-24
Geheimnisprinzip, 1-23
generische Parameter, 1-30
Transformer, 1-26
Wiederverwendbarkeit, 1-24
Algorithmus, 2-4, 2-5
Brute Force, 6-1
durchführbar, 2-8, 2-11
polynomial, 2-11
exponentiell, 2-11
Alphabet, 6-1
Amortisation, 2-11
amortisations Schema, 2-12
Array
Grundtyp, 1-11
Indextyp, 1-11
Selektor, 1-12
assymptotische Verhalten, 2-9
assymptotisches Verhalten, 2-10
Atomare Typen, 1-4
BOOLEAN, 1-9
CHAR, 1-9
INTEGER, 1-4
REAL, 1-5
Ausgeglichener Baum
2-3-4-Baum, 4-18
B-Tree, 4-36
Rot-Schwarz-Baum, 4-21
B-Tree, 4-36
Definition, 4-36
Ordnung, 4-36, 4-41
Backtracking, 7-1
Aufwand, 7-3
Baum
Blatt, 1-17
geordnet, 1-18
Grad, 1-18
innerer Knoten, 1-17
Kante, 1-17
Knoten, 1-17
Tiefe, 1-18
Vorgänger, 1-17
Wurzel, 1-17
Betriebsmittel, 2-7
Binärer Baum
fast vollständiger, 1-18
strenger, 1-18
vollständiger, 1-18
Binärer Suchbaum, 4-18
Binäres Suchen, 1-12
Blatt, 1-17
BOOLEAN, 1-9
Boyer-Moore Algorithmus, 6-4
Branch and Bound, 7-4
Aufwand, 7-6
Breitensuche, 5-9
Brute Force, 6-1
CHAR, 1-9
Computer-Daten, 1-3
Semantik, 1-3
Speicher, 1-3
Syntax, 1-3
Wertemenge, 1-3
Cooks Theorem, 2-18
DAC-Algorithmus, 3-10
Daten
Abstraktion, 1-1
29
Darstellung, 1-2
Fixpoint, 1-3
Floatingpoint, 1-2
Datenmodell, 1-1
Datentyp, 1-4
atomar, 1-4
Konstante, 1-4
Operator, 1-4
Wertemenge, 1-4
deterministisch, 2-1, 2-16
Algorithmus
Effektivität, 2-2
Eindeutigkeit, 2-1
Vollständigkeit, 2-1
digitaler Suchbaum, 6-8
Divide-And-Conquer, 7-6
Divisionsmethode, 4-7
double hashing, 4-8
downheap Verfahren, 3-8
durchführbar, 2-8, 2-11
Dynamic Programming, 7-9
Edmonds-Karp Algorithmus, 5-32
embedded key, 3-1
Endliche Arithmetik, 1-5–1-9
eng gekoppelt, 7-19
entscheidbar, 2-16
Exponent, 1-2
exponentiell, 2-11
externer Sort, 3-1
Färbungsproblem, 7-1
Fehler, 1-7
absolut, 1-7
relativ, 1-7
Fixpoint, 1-3
Floatingpoint, 1-2
Exponent, 1-2
Mantisse, 1-2, 1-5
normalisiert, 1-2
Floyd Algorithmus, 7-9
Fluss, 5-24
maximaler, 5-28
Flussgraph, 5-24
nutzbarer Pfad, 5-28
Quelle, 5-24
Schnitt, 5-26
Senke, 5-24
Flussprobleme, 5-24
Ford-fulkerson Algorithmus, 5-31
Funktion
assymptotisches Verhalten, 2-10
dominierender Ausdruck, 2-9
Ordnung, 2-9
Funktionsräume, 1-10
Geheimnisprinzip, 1-23
geichtete Kante, 5-2
generische Parameter, 1-30
Gerichteter Graph, 5-2
gerichteter Graph
schwach zusammenhängend, 5-13
stark zusammenhängend, 5-14
gewichteter Graph, 5-14
Grad, 1-18
Graph
Fluss, 5-24
maximaler, 5-25
gerichtet, 5-2
gewichtet, 5-14
Postorder Nummerierung, 5-11
Traversierung
Breitensuche, 5-9
Tiefensuche, 5-8
ungerichtet, 5-3
zusammenhängend, 5-13
zyklisch, 5-3
Graph als ADT, 5-4–5-5
greedy Methode, 7-14
Grundtyp, 1-11, 1-14
Hamilton Kreis, 2-19
Hamilton Problem, 5-1
Hamilton Zyklen, 7-1, 7-2
Hashcode, 4-5
Hashfunktion, 4-4
Hashing, 4-4
Divisionsmethode, 4-7
Kollisionen, 4-5
double hashing, 4-8
linear probing, 4-10
Separat Chaining, 4-8
Kompression, 4-5
Loadfactor, 4-11
MAD methode, 4-7
Matrixverfahren, 4-13
polynomialer Hashcode, 4-6
pseudorandom, 4-12
Synonyme, 4-5
Hashtabelle, 4-4
Heap, 3-6
Heap Aufbau, 3-8
30
Heapbedingung, 3-6
Hill climbing, 7-14
Huffman Kodierung, 6-9
Indextyp, 1-11
innerer Knoten, 1-17
Inorder, 1-22
inplace Sort, 3-1
INTEGER, 1-4
internal key, 3-1
interner Sort, 3-1
inzident, 5-2, 5-3
Kante, 1-17
adjazent, 5-2, 5-3
Gewicht, 5-14
inzident, 5-2, 5-3
Kapazität, 5-24
Restkapazität, 5-28
Kapazität, 5-24
Kapazität, 5-26
Klassifikation von Flynn, 7-20
Knapsack, 7-12
Knoten, 1-17, 5-2
Knuth-Morris-Pratt Algorithmus, 6-2
Kodierung
Läufe, 6-6
runs, 6-6
Kodierung mit variabler Länge, 6-7
Königsberger Brückenproblem, 5-1
Kollisionen, 4-5
Komplexität
Averrage, 2-10
Speicherbedarf, 2-7
Worst Case, 2-10
Zeit, 2-7
Kompression, 4-5
Konstante, 1-4
Kruskal Algorithmus, 5-14
kürzeste Pfade, 5-18
Dijkstra, 5-18–5-20, 5-23
Floyd, 5-21
Läufe, 6-6
Lauflängenkodierung, 6-6
linear probing, 4-10
Liste
Grundty, 1-14
Sequentielle Repräsentation, 1-15
Verkettete Repräsentation, 1-15
Loadfactor, 4-11
lose gekoppelt, 7-19
MAD methode, 4-7
Mantisse, 1-2, 1-5
Maschinenzahlen, 1-5
Repräsentant, 1-5
Matrixmultiplikation, 7-6
maximaler Fluss, 5-25, 5-28
MIMD, 7-20
minimal spanning tree, 5-14
minimaler aufspannender Baum, 5-14
Mischen
ausgeglichen, 3-17
Mehrphasen, 3-19
n-Weg, 3-16
Replacement Selection, 3-18
MISD, 7-20
Modell, 1-1
Muster, 6-1
natürlichen Länge, 2-9
Netzplan, 5-2
nichtdeterministisch, 2-17
nichtdeterministischen, 2-17
NP-schwer, 2-18
NP-vollständig, 2-18
numerische Auslöschung, 1-7
numerische Stabilität, 1-7
nutzbarer Pfad, 5-28
Operator, 1-4
Ordnung, 2-9
Overflow, 1-5
Palindrom, 2-4
parallele Algorithmen, 7-17
paralleler Algorithmus, 2-2
Partitionierung, 3-11
Petrinetze, 5-2
Pfad, 5-3
Pipelining, 7-17
pivot, 3-11, 3-13
polynomial, 2-11
polynomial reduzierbar, 2-18
polynomialer Hashcode, 4-6
Postorder, 1-21
Preorder, 1-21
pringer-Problem, 7-1
Priority Queue, 3-5
Prozess, 2-1
Prozessor, 2-1
31
pseudo polynomial, 7-14
Quicksort, 7-6
iterativ, 3-14
Komplexität, 3-14
randomized, 3-15
rekursiv, 3-12
Randomized Quicksort, 3-15
REAL, 1-5
Redundanz, 6-6
Rekursion, 1-20–1-23, 7-1
Rekursionsgrund, 1-20
Rekursionstiefe, 1-20
Restkapazität, 5-28
Rot-Schwarz-Baum, 4-21
Interne Eigenschaft, 4-22
Tiefen Eigenschaft, 4-22
Wurzel Eigenschaft, 4-22
Rucksack Problem, 7-12
runs, 6-6
schlecht konditioniert, 1-9
Schlinge, 5-3
Schlüssel, 3-1
Schnitt, 5-26
Kapazität, 5-26
minimaler, 5-26
Rückwärtskante, 5-26
Vorwärtskante, 5-26
schwierige Probleme, 2-17
Selektor, 1-12, 1-14
Semantik, 1-3, 1-4
Separat Chaining, 4-8
SIMD, 7-20
SISD, 7-20
Skipliste
Definition, 4-42
Höhe, 4-45
Sortierverfahren
Auswahl, 3-2
Einfügen, 3-3
extern, 3-1
inplace, 3-1
intern, 3-1
stabil, 3-1
Vertauschen, 3-3
Splay-Baum, 4-27
amortisierte Kosten, 4-31
Rang, 4-31
Size, 4-31
zig Operation, 4-28
zig-zag Operation, 4-27
zig-zig Operation, 4-27
Splay-Operation, 4-27
Sprache, 2-16
entscheidbar, 2-16
stabiler Sort, 3-1
Strassen Matrix-Multiplikation, 7-6
Strategien, 7-1
String, 6-1
Muster, 6-1
Strukturierter Typ, 1-10
Subtyp, 1-10
Suchstruktur, 4-1
Synonyme, 4-5
Syntax, 1-3, 1-4
Task scheduling, 7-15
teleskopische Summe, 2-12
Tiefe, 1-18
Tiefensuchbaum, 5-9
Tiefensuche, 5-8
Tiefensuchwald, 5-9
Baumkante, 5-10
Querkante, 5-10
Rückwärtskante, 5-10
Vorwärtskante, 5-10
topologische Ordnung, 5-12
Transformer, 1-26
Traveling Salesman, 7-1, 7-3
Traveling salesman, 2-19
Traversierung
Inorder, 1-22
Postorder, 1-21
Preorder, 1-21
trial-and-error, 7-1
Trie, 6-8, 6-10
Typkonstruktor, 1-4
Underflow, 1-5, 1-6
ungerichteter Graph, 5-3
universelles Hashing, 4-12
Unterbäume, 1-17
Unterbereichstyp, 1-10
upheap Verfahren, 3-7
Vektor Rechner, 7-18
Vorgaenger
Vorgänger, 5-3
Vorgänger, 1-17
32
Wertemenge, 1-3, 1-4
Wiederverwendbarkeit, 1-24
Worst Case, 2-10
Wurzel, 1-17
Zusammenhangskomponente, 5-13
Zyklus, 5-3, 5-4, 5-11
einfach, 5-3, 5-4
schlinge, 5-3
33
Inhaltsverzeichnis
1 Daten
1-1
1.1
Einleitung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1-1
1.2
Datentypen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1-4
1.3
Atomare Typen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1-4
1.4
1.5
1.6
1.3.1
Ganze Zahlen (INTEGER) . . . . . . . . . . . . . . . . . . . . . . . . . 1-4
1.3.2
Reelle Zahlen (REAL) . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1-5
1.3.3
Logische Werte (BOOLEAN) . . . . . . . . . . . . . . . . . . . . . . . . 1-9
1.3.4
Text (CHAR) . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1-9
Abgeleitete Typen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1-10
1.4.1
Unterbereichstypen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1-10
1.4.2
Subtypen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1-11
1.4.3
Die Struktur Array . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1-11
1.4.4
Allgemeine Struktur . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1-13
Weitere Datenstrukturen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1-14
1.5.1
Die Struktur lineare Liste . . . . . . . . . . . . . . . . . . . . . . . . . . 1-14
1.5.2
Spezielle Listen: Stack und Queue . . . . . . . . . . . . . . . . . . . . . 1-15
1.5.3
Der Baum . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1-16
1.5.4
Rekursion und traversieren von Bäumen . . . . . . . . . . . . . . . . . . 1-20
Der Abstrakte Datentyp . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1-23
1.6.1
Definition . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1-23
1.6.2
Formale Spezifikation von ADTs . . . . . . . . . . . . . . . . . . . . . . 1-24
1.6.3
Spezifikation des ADTs in Java . . . . . . . . . . . . . . . . . . . . . . . 1-27
1.6.4
Generische Parameter . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1-30
1.6.5
Implementation in java
. . . . . . . . . . . . . . . . . . . . . . . . . . . 1-31
2 Algorithmen
2-1
2.1
Einführung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2-1
2.2
Berechenbarkeit . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2-2
2.2.1
Definition Algorithmus . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2-2
34
2.3
2.2.2
Weitere Definitionen von Algorithmus . . . . . . . . . . . . . . . . . . . 2-4
2.2.3
Die Church-Turing-These . . . . . . . . . . . . . . . . . . . . . . . . . . 2-5
2.2.4
Das Halteproblem . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2-5
Komplexität von Algorithmen . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2-7
2.3.1
Zeitaufwand von Algorithmen . . . . . . . . . . . . . . . . . . . . . . . . 2-8
2.3.2
Amortisierte Kosten . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2-11
2.3.3
Die Klassen P und N P . . . . . . . . . . . . . . . . . . . . . . . . . . . 2-15
2.3.4
Lower Bound (LB) . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2-19
3 Sortieralgorithmen
3-1
3.1
Einführung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3-1
3.2
Elementare Sortierverfahren . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3-1
3.3
3.4
3.5
3.2.1
Sortieren durch Auswählen (selection sort) . . . . . . . . . . . . . . . . . 3-2
3.2.2
Sortieren durch Einfügen (insertion sort) . . . . . . . . . . . . . . . . . . 3-2
3.2.3
Sortieren durch Vertauschen (bubble sort) . . . . . . . . . . . . . . . . . 3-3
3.2.4
Vergleich der elementaren Sortierverfahren . . . . . . . . . . . . . . . . . 3-4
Heapsort . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3-4
3.3.1
Priorityqueue . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3-4
3.3.2
Implementation der PQ als Heap . . . . . . . . . . . . . . . . . . . . . . 3-6
3.3.3
Das Sortierverfahren (Heapsort) . . . . . . . . . . . . . . . . . . . . . . 3-10
Quicksort . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3-10
3.4.1
Divide-And-Conquer Methode
3.4.2
Das Sortierverfahren . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3-11
Externes Sortieren . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3-16
3.5.1
Grundoperation Mischen . . . . . . . . . . . . . . . . . . . . . . . . . . . 3-16
3.5.2
Ausgeglichenes Mehrweg-Mischen . . . . . . . . . . . . . . . . . . . . . . 3-17
3.5.3
Replacement Selection . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3-18
3.5.4
Mehrphasen-Mischen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3-19
4 Suchalgorithmen
4.1
4.2
. . . . . . . . . . . . . . . . . . . . . . . 3-10
4-1
Suchstruktur . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4-1
4.1.1
Spezifikation der Suchstruktur in Java . . . . . . . . . . . . . . . . . . . 4-2
4.1.2
Implementation als Hashtabelle . . . . . . . . . . . . . . . . . . . . . . . 4-4
4.1.3
Universelles Hashing . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4-11
Sortiertes Suchen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4-13
4.2.1
Java Spezifikation der sortierten Suchstruktur . . . . . . . . . . . . . . . 4-14
4.2.2
Implementation als sortierter Array . . . . . . . . . . . . . . . . . . . . 4-16
4.2.3
Implementation als binären Suchbaum . . . . . . . . . . . . . . . . . . . 4-18
35
4.2.4
Implementation als Splay Baum . . . . . . . . . . . . . . . . . . . . . . 4-27
4.2.5
Implementation als B-Tree
4.2.6
Implementation als Skipliste . . . . . . . . . . . . . . . . . . . . . . . . . 4-42
5 Graphen
5-1
5.1
5.2
5.3
5.4
5.5
5.6
5.7
. . . . . . . . . . . . . . . . . . . . . . . . . 4-36
Einleitung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5-1
5.1.1
Klassische Probleme . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5-1
5.1.2
Angewandte Probleme . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5-1
Begriffe . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5-2
5.2.1
Gerichtete Graphen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5-2
5.2.2
Ungerichtete Graphen . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5-3
Der Graph als ADT . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5-4
5.3.1
Methoden des Graphen . . . . . . . . . . . . . . . . . . . . . . . . . . . 5-4
5.3.2
Methoden des gerichteten Graphen . . . . . . . . . . . . . . . . . . . . . 5-5
5.3.3
Methoden des ungerichteten Graphen . . . . . . . . . . . . . . . . . . . 5-5
Implementation von Graphen . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5-5
5.4.1
Implementation als Adjazenzliste . . . . . . . . . . . . . . . . . . . . . . 5-5
5.4.2
Implementation als Adjazenzmatrix . . . . . . . . . . . . . . . . . . . . 5-6
5.4.3
Vergleich der Methoden . . . . . . . . . . . . . . . . . . . . . . . . . . . 5-7
Algorithmen auf Graphen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5-8
5.5.1
Tiefensuche (Depth-First Search) . . . . . . . . . . . . . . . . . . . . . . 5-8
5.5.2
Breitensuche (Breath-First Search) . . . . . . . . . . . . . . . . . . . . . 5-9
5.5.3
Tiefensuche Wald . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5-9
5.5.4
Postorder Nummerierung . . . . . . . . . . . . . . . . . . . . . . . . . . 5-10
5.5.5
Anwendungen der Tiefensuche . . . . . . . . . . . . . . . . . . . . . . . 5-11
Gewichtete Graphen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5-14
5.6.1
Implementation von gewichteten Graphen . . . . . . . . . . . . . . . . . 5-14
5.6.2
Minimal aufspanneder Baum . . . . . . . . . . . . . . . . . . . . . . . . 5-14
5.6.3
Kürzeste Pfade in Graphen (Dijkstra) . . . . . . . . . . . . . . . . . . . 5-17
5.6.4
Kürzeste Pfade in Graphen (Floyd) . . . . . . . . . . . . . . . . . . . . . 5-21
Flussprobleme . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5-24
5.7.1
Flussgraphen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5-24
5.7.2
Schnitte . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5-25
5.7.3
Maximaler Fluss . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5-28
36
6 Verarbeitung von Strings
6.1
6.2
6-1
Suchen in Strings . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 6-1
6.1.1
Brute Force Algorithmus
. . . . . . . . . . . . . . . . . . . . . . . . . . 6-1
6.1.2
Knuth-Morris-Pratt Algorithmus . . . . . . . . . . . . . . . . . . . . . . 6-2
6.1.3
Boyer-Moore Algorithmus . . . . . . . . . . . . . . . . . . . . . . . . . . 6-4
Datenkomprimierung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 6-6
6.2.1
Lauflängenkodierung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 6-6
6.2.2
Kodierung mit variabler Länge . . . . . . . . . . . . . . . . . . . . . . . 6-7
6.2.3
Erzeugung des Huffman-Codes . . . . . . . . . . . . . . . . . . . . . . . 6-9
7 Typen von Algorithmen
7-1
7.1
Rekursion . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7-1
7.2
Backtracking . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7-1
7.3
7.2.1
Hamilton-Zyklus . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7-2
7.2.2
Traveling Salesman . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7-3
Branch and Bound . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7-4
7.3.1
7.4
Divide-And-Conquer . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7-6
7.4.1
7.5
7.6
7.7
Traveling Salesman . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7-4
Matrix-Multiplikation (Strassen) . . . . . . . . . . . . . . . . . . . . . . 7-6
Dynamic Programming (DP) . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7-9
7.5.1
Multiplikation von pxq Matrizen . . . . . . . . . . . . . . . . . . . . . . 7-10
7.5.2
Rucksack-Problem (Knapsack) . . . . . . . . . . . . . . . . . . . . . . . 7-12
Greedy (die gierige Methode) . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7-14
7.6.1
Hill Climbing . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7-14
7.6.2
Task Scheduling . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7-15
Parallele Algorithmen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7-17
7.7.1
Pipelining . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7-17
7.7.2
Vektor Rechner . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7-18
7.7.3
Multiprozessor Systeme . . . . . . . . . . . . . . . . . . . . . . . . . . . 7-19
7.7.4
Klassifikation von Flynn . . . . . . . . . . . . . . . . . . . . . . . . . . . 7-20
7.7.5
Parallele Algorithmen . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7-21
Abbildungsverzeichnis
24
Literaturverzeichnis
28
Index
29
Inhaltsverzeichnis
34
37
Herunterladen