Skript - oth-regensburg.de

Werbung
Algorithmen und Datenstrukturen
Prof. Jürgen Sauer
Algorithmen und Datenstrukturen
Skriptum zur Vorlesung im SS 2008
1
Algorithmen und Datenstrukturen
2
Algorithmen und Datenstrukturen
Inhaltsverzeichnis
Literaturverzeichnis.............................................................................................................................................. 7
1. GRUNLEGENDE KONZEPTE............................................................................................................ 9
1.1 Die zentralen Begriffe ..................................................................................................................................... 9
1.2 Algorithmische Grundkonzepte................................................................................................................... 10
1.2.1 Algorithmenbegriffe ................................................................................................................................ 10
1.2.2 Terminierung und Determinismus ........................................................................................................... 10
1.2.3 Algorithmenbausteine .............................................................................................................................. 11
1.2.4 Paradigmen der Algorithmenbeschreibung.............................................................................................. 13
1.2.4.1 Applikative Algorithmen .................................................................................................................. 14
1.2.4.2 Imperative Algorithmen .................................................................................................................... 16
1.2.4.3 Objektorientierte Algorithmen .......................................................................................................... 16
1.2.4.4 Paradigmen und Programmiersprachen ............................................................................................ 19
1.2.5 Beschreibung von Algorithmen ............................................................................................................... 19
1.2.6 Formale Eigenschaften von Algorithmen ................................................................................................ 22
1.2.6.1 Korrektheit und Terminierung .......................................................................................................... 22
1.2.6.2 Effizienz............................................................................................................................................ 23
1.2.7 Komplexität.............................................................................................................................................. 25
1.2.7.1 Laufzeitberechnungen („Big-O“-) .................................................................................................... 28
1.2.7.2 O(logN)-Algorithmen ....................................................................................................................... 32
1.2.7.3 Berechnungsgrundlagen für rechnerische Komplexität .................................................................... 32
1.3 Daten und Datenstrukturen ......................................................................................................................... 35
1.3.1 Datentyp................................................................................................................................................... 35
1.3.2 Datenstruktur............................................................................................................................................ 37
1.3.3 Relationen und Ordnungen ...................................................................................................................... 42
1.3.4 Klassifikation von Datenstrukturen ......................................................................................................... 47
1.3.4.1 Lineare Ordnungsgruppen ................................................................................................................ 47
1.3.4.2 Nichtlineare Kollektion..................................................................................................................... 50
1.3.4.2.1 Hierarchische angeordnete Sammlung (Bäume)........................................................................ 50
1.3.4.2.2 Gruppenkollektionen.................................................................................................................. 55
1.3.4.3 Dateien und Datenbanken ................................................................................................................. 56
1.3.5 Definitionsmethoden für Datenstrukturen................................................................................................ 59
1.3.5.1 Der abstrakte Datentyp...................................................................................................................... 59
1.3.5.2 Die axiomatische Methode................................................................................................................ 60
1.3.5.3 Die konstruktive Methode................................................................................................................. 62
1.3.5.4 Die objektorientierte Modellierung abstrakter Datentypen............................................................... 63
1.3.5.6 Die Implementierung abstrakter Datentypen in Java ........................................................................ 71
1.3.5.6.1 Klassen und Schnittstellen (Referenzdatentypen)...................................................................... 71
1.3.5.6.2 Generische Typen ...................................................................................................................... 76
1.3.5.6.3 Wildcard-Typen ......................................................................................................................... 83
2. DATENSTRUKTUREN UND ALGORITHMEN IN JAVA ................................................................. 88
2.1 Kollektionen (Collections) ............................................................................................................................ 89
2.1.1 Durchwandern von Daten mit Iteratoren ................................................................................................. 89
2.1.2 Die Klasse Vector .................................................................................................................................... 89
2.1.3 Die Klasse Stack ...................................................................................................................................... 92
2.1.4 Die Klasse Bitset für Bitmengen.............................................................................................................. 94
2.1.5 Die Klasse Hashtable und assoziative Speicher....................................................................................... 95
2.1.6 Die abstrakte Klasse Dictionary............................................................................................................... 98
2.1.7 Die Klasse Properties ............................................................................................................................... 98
3
Algorithmen und Datenstrukturen
2.2 Das Collection Framework........................................................................................................................... 99
2.2.1 Die Schnittstellen Collection, Iterator, Comparator................................................................................. 99
2.2.2 Die Behälterklassen und Schnittstellen des Typs List ........................................................................... 102
2.2.3 Queues ................................................................................................................................................... 107
2.2.4 Behälterklassen des Typs Set................................................................................................................. 109
2.2.5 Behälterklassen des Typs Map............................................................................................................... 111
2.2.6 Algorithmen ........................................................................................................................................... 114
2.2.5.1 Datenmanipulation .......................................................................................................................... 114
2.2.5.2 Größter und kleinster Wert einer Collection................................................................................... 115
2.2.5.3 Sortieren.......................................................................................................................................... 115
2.2.5.4 Suchen von Elementen.................................................................................................................... 117
2.2.6 Generics ................................................................................................................................................. 118
2.2.6.1 Sammlungsklassen .......................................................................................................................... 118
2.2.6.2 Implementierung von Java Generics ............................................................................................... 120
2.2.6.2.1 Übersetzung von Generics ....................................................................................................... 120
2.2.6.2.2 Repräsentation von Generics im Laufzeitsystem ..................................................................... 120
2.2.7 Spracherweiterung in Java zur Iteration................................................................................................. 121
2.2.7.1 Erweiterte for-Schleife.................................................................................................................... 121
2.2.7.2 Das Interface Iterable ...................................................................................................................... 122
2.2.8 Implementierung von Graphen-Algorithmen zur Berechnung kürzester Wege mit Behälterklassen .... 124
2.2.8.1 Die Datenstrukturen Graph, Vertex, Edge zur Implementierung von Graphenalgorithmen für die
Berechnung kürzester Wege ....................................................................................................................... 125
2.2.8.2 Kürzeste Pfade in gerichteten, ungewichteten Graphen.................................................................. 127
2.2.8.3 Berechnung der kürzesten Pfadlängen in gewichteten Graphen (Algorithmus von Dijkstra) ........ 131
2.2.8.4 Berechnung der kürzesten Pfadlängen in gewichteten Graphen mit negativen Kosten.................. 136
2.2.8.5 Berechnung der kürzesten Pfadlängen in gewichteten, azyklischen Graphen ................................ 137
3. ALGORITHMEN.............................................................................................................................. 138
3.1 Ausgesuchte algorithmische Probleme...................................................................................................... 138
3.1.1 Spezielle Sortieralgorithmen.................................................................................................................. 138
3.1.1.1 Interne Sortierverfahren .................................................................................................................. 138
3.1.1.1.1 Quicksort.................................................................................................................................. 138
3.1.1.1.2 Heap-Sort ................................................................................................................................. 140
3.1.1.1.3 Sortieren durch Mischen .......................................................................................................... 142
3.1.1.2 Externe Sortierverfahren ................................................................................................................. 145
3.1.1.2.1 Direktes Mischsortieren ........................................................................................................... 145
3.1.1.2.2 Natürliches Mischen ................................................................................................................ 149
3.1.2 Suche in Texten...................................................................................................................................... 152
3.1.2.1 String Pattern-Matching.................................................................................................................. 152
3.1.2.1.1 Ein einfacher Algorithmus zum Suchen in Zeichenfolgen ...................................................... 152
3.1.2.1.2 Der Algorithmus von Knuth-Morris-Pratt ............................................................................... 153
3.1.2.1.3 Boyer / Moore - Suche ............................................................................................................. 157
3.1.2.2 Pattern-Matching mit regulären Ausdrücken .................................................................................. 164
3.1.2.2.1 Reguläre Ausdrücke..................................................................................................................... 164
3.1.2.2.2 Überprüfung regulärer Ausdrücke mit endlichen Automaten.................................................. 167
3.1.2.2.3 Java 1.4 "regex" ....................................................................................................................... 171
3.2 Entwurfstechniken für Algorithmen (Einsatz von Algorithmen-Mustern)........................................... 174
3.2.1 Greedy Algorithmen .............................................................................................................................. 174
3.2.1.1 Greedy-Algorithmen für minimale Spannbäume ............................................................................ 175
3.2.1.2 Huffman Codes ............................................................................................................................... 178
3.2.2 Divide and Conquer ............................................................................................................................... 184
3.2.3 Induktiver Algorithmenentwurf und Dynamisches Programmieren ...................................................... 184
3.3 Rekursion..................................................................................................................................................... 186
3.3.1 Linear rekursive Funktionen .................................................................................................................. 186
3.3.2 Nichtlineare rekursive Funktionen......................................................................................................... 187
3.3.3 Primitive Rekursion ............................................................................................................................... 187
3.3.4 Nicht primitive Rekursion...................................................................................................................... 187
4
Algorithmen und Datenstrukturen
3.3.5 Rekursive Kurven .................................................................................................................................. 188
3.4 Backtracking-Algorithmen ........................................................................................................................ 193
3.5 Zufallsgesteuerte Algorithmen................................................................................................................... 197
4. BÄUME ........................................................................................................................................... 198
4.1 Grundlagen.................................................................................................................................................. 198
4.1.1 Grundbegriffe und Definitionen ............................................................................................................ 198
4.1.2 Darstellung von Bäumen........................................................................................................................ 199
4.1.3 Berechnungsgrundlagen......................................................................................................................... 200
4.1.4 Klassifizierung von Bäumen.................................................................................................................. 201
4.2 Freie Binäre Intervallbäume...................................................................................................................... 204
4.2.1 Ordnungsrelation und Darstellung ......................................................................................................... 204
4.2.2 Operationen............................................................................................................................................ 208
4.2.3 Ordnungen und Durchlaufprinzipien ..................................................................................................... 222
4.3 Balancierte Bäume ...................................................................................................................................... 228
4.3.1 Statisch optimierte Bäume ..................................................................................................................... 231
4.3.2 AVL-Baum ............................................................................................................................................ 232
4.3.3 Splay-Bäume.......................................................................................................................................... 243
4.4 Bayer-Bäume ............................................................................................................................................... 251
4.4.1 Grundlagen und Definitionen ................................................................................................................ 251
4.4.1.1 Ausgeglichene T-äre Suchbäume (Bayer-Bäume) .......................................................................... 251
4.4.1.2 (a,b)-Bäume .................................................................................................................................... 253
4.4.2 Darstellung von Bayer-Bäumen............................................................................................................. 254
4.4.3 Suchen eines Schlüssels ......................................................................................................................... 256
4.4.4 Einfügen................................................................................................................................................. 258
4.4.5 Löschen .................................................................................................................................................. 261
4.4.6 Demonstration eines 3ären bzw. 5ären Bayer-Baums ........................................................................... 268
4.4.6 Auf Platte/ Diskette gespeicherte Datensätze......................................................................................... 270
4.4.7 B*-Bäume .............................................................................................................................................. 272
4.5 Digitale Suchbäume .................................................................................................................................... 275
4.5.1 Grundlagen und Definitionen ................................................................................................................ 275
4.5.2 Tries ....................................................................................................................................................... 276
4.5.3 Binäre Tries............................................................................................................................................ 279
4.5.4 Patricia Bäume (Compressed Tries)....................................................................................................... 279
4.5.5 Suffix Tries ............................................................................................................................................ 281
4.5.6 Dateikompression mit dem Huffman-Algorithmus................................................................................ 281
5. GRAPHEN UND GRAPHENALGORITHMEN................................................................................ 282
5.1 Einführung .................................................................................................................................................. 282
5.1.1 Grundlagen............................................................................................................................................. 282
5.1.2 Definitionen ........................................................................................................................................... 286
5.1.3 Darstellung in Rechnerprogrammen ...................................................................................................... 291
5.2 Durchlaufen von Graphen ......................................................................................................................... 295
5.2.1 Tiefensuche (depth-first search)............................................................................................................. 295
5.2.2 Breitensuche (breadth-first search) ........................................................................................................ 299
5.2.3 Implementierung .................................................................................................................................... 302
5.3 Topologischer Sort ...................................................................................................................................... 306
5.4 Transitive Hülle........................................................................................................................................... 309
5
Algorithmen und Datenstrukturen
5.5 Kürzeste Wege............................................................................................................................................. 310
5.5.1 Der Algorithmus von Dijkstra................................................................................................................ 310
5.5.2 Der Algorithmus von Floyd ................................................................................................................... 312
5.6 Minimale Spannbäume............................................................................................................................... 313
5.6.1 Der Algorithmus von Prim..................................................................................................................... 313
5.6.2 Der Algorithmus von Kruskal................................................................................................................ 316
6
Algorithmen und Datenstrukturen
Literaturverzeichnis
Sauer, Jürgen: Programmieren in Java, Skriptum zur Vorlesung im WS 2005/2007
http://fbim.fh-regensburg.de/~saj39122/pgj/index.html
Sauer, Jürgen: Programmieren in C++, Skriptum zur Vorlesung im SS 2006
http://fbim.fh-regensburg.de/~saj39122/pgc/index.html
Sauer, Jürgen: Datenbanken, Skriptum zur Vorlesung im SS 2007
http://fbim.fh-regensburg.de/~saj39122/dbnew/index.html
Sedgewick, Robert: Algorithmen in Java, 3.überarbeitete Auflage, Pearson Studium,
München …. , 2003
Wirth, Nicklaus: Algorithmen und Datenstrukturen, 2. duchgesehene Auflage,
Teubner, Stuttgart 1979
Ottmann, Thomas und Widmayer, Peter: Algorithmen und Datenstrukturen, BI
Wissenschaftsverlag, Mannheim /Wien /Zürich 1990
Weiss, Marc Allen: Data Structures and Algorithm Analysis in Java, Pearson, Boston
…., 2007
Nowak, Johannes: Fortgeschrittene Programmierung mit Java 5, dpunkt.verlag,
Heidelberg 2005
Saake, Gunter und Sattler, Kai Uwe: Algorithmen und Datenstrukturen,
dpunkt.verlag, 2. überarbeitete Auflage, Heidelberg, 2004
Maurer, H.: Datenstrukturen und Programmierverfahren, Teubner,Stuttgart 1974
Krüger, Guido und Stark, Thomas: Handbuch der Java-Programmierung, 5. Auflage,
HTML-Ausgabe 5.0.1, Addison-Wesley, 2007
Ullenboom, Christian: Java ist auch eine Insel, 7. aktualisierte Auflage, HTMLVersion
7
Algorithmen und Datenstrukturen
8
Algorithmen und Datenstrukturen
1. Grunlegende Konzepte
1.1 Die zentralen Begriffe
In den 50er Jahren bedeutete „Rechnen“ auf einem Computer weitgehend
„numerisches Lösen“ wissenschaftlich-technischer Probleme. Kontroll- und
Datenstrukturen waren sehr einfach und brauchten daher nicht weiter untersucht
werden. Ein bedeutender Anstoß kam hier aus der kommerziellen Datenverarbeitung (DV). So führte hier bspw. die Frage des Zugriffs auf ein Element einer
endlichen Menge zu einer großen Sammlung von Algorithmen 1, die grundlegende
Aufgaben der DV lösen. Dabei ergab sich: Die Leistungsfähigkeit dieser Lösungen
(Programme) ist wesentlich bestimmt durch geeignete Organisationsformen für die
zu bearbeitenden Daten.
Der Datentyp oder die Datenstruktur und die zugehörigen Algorithmen sind
demnach ein entscheidender
Bestandteil eines leistungsfähigen Programms.
Datenstrukturen und Programmierverfahren bilden eine Einheit. Bei der Formulierung
des Lösungswegs ist man auf eine bestimmte Darstellung der Daten festgelegt. Rein
gefühlsmäßig könnte man sagen: Daten gehen den Algorithmen voraus.
Programmieren führt direkt zum Denken in Datenstrukturen, um Datenelemente, die
zueinander in Beziehung stehen, zusammen zu fassen. Mit Hilfe solcher
Datenstrukturen ist es möglich, sich auf die relevanten Eigenschaften der Umwelt zu
konzentrieren und eigene Modelle zu bilden. Die Leistung des Rechners wird dabei
vom reinen Zahlenrechnen auf das weitaus höhere Niveau der „Verarbeitung von
Daten“ angehoben
Datenstrukturen und Algorithmen bilden die wesentlichen Bestandteile der
Programmierung. Ein erster Versuch soll diese zentralen Begriffe so festlegen (bzw.
abgrenzen):
Datenstruktur
Ein auf Daten anwendbares Ordnungsschema (z.B. ein Datensatz oder Array). Mit
der Hilfe von Datenstrukturen lassen sich die Daten interpretieren und spezifische
Operationen auf ihnen ausführen
Algorithmus
Verarbeitungsvorschrift, die angibt, wie Eingabe(daten) schrittweise mit Hilfe von
Anweisungen auf Rechnern in Ausgabe(daten) umgewandelt werden. Für die Lösung
eines Problems existieren meist mehrere Algorithmen, die sich in der Länge sowie
der für die Ausführung benötigte Zeit unterscheiden.
Programm und Programmiersprache
Ein Programm ist die Formulierung eines Algorithmus und seiner Datenbereiche in
einer Programmiersprache.
Eine Programmiersprache erlaubt, Algorithmen präzise zu beschreiben.
Insbesondere legen sie fest:
- die elementaren Operationen
- die Möglichkeiten zu ihrer Kombination
1
D. E. Knuth hat einen großen Teil dieses Wissens in "The Art of Computer Programming" zusammengefaßt
9
Algorithmen und Datenstrukturen
- die zulässigen Datenbereiche
1.2 Algorithmische Grundkonzepte
1.2.1 Algorithmenbegriffe
Algorithmen im Alltag
Gegeben ist ein Problem. Eine Handlungsvorschrift, deren mechanisches Befolgen
- ohne Verständnis des Problems
- mit sinnvollen Eingabedaten
- zur Lösung des Problems
führt, wird Algorithmus genannt. Ein Problem, für dessen Lösung ein Algorithmus
existiert, heißt berechenbar.
Bsp.:
- Zerlegung handwerklicher Arbeiten in einzelne Schritte
- Kochrezepte
- Verfahren zur schriftlichen Multiplikation
- Algorithmen zur Bestimmung des größten gemeinsamen Teiles zweier natürlichen Zahlen
- Bestimmung eines Schaltjahres
- Spielregeln
Der intuitive Algorithmenbegriff
Ein Algorithmus ist eine präzise (d.h. in einer festgelegten Sprache abgefasste)
endliche Beschreibung eines allgemeinen Verfahrens unter Angabe ausführbarer
(Verarbeitungs-) Schritte.
Ein Algorithmus (in der EDV) ist
- ein Lösungsschritt für eine Problemklasse (konkretes Problem wird durch
Eingabeparameter identifiziert)
- geeignet für die Implementierung als Rechnerpogramm
- endliche Folge von elementaren, ausführbaren Instruktionen Verarbeitungsschritten
1.2.2 Terminierung und Determinismus
Abgeleitet vom intuitiven Algorithmenbegriff spielen bei der Konzeption von
Algorithmen die Begriffe Terminierung, Determinismus und Vollständigkeit eine
Rolle:
Terminierung
Ein Algorithmus heißt terminierend, wenn er (bei jeder erlaubten Eingabe von
Parametern) nach endlich vielen Schritten abbricht.
Determinismus
10
Algorithmen und Datenstrukturen
Ein Algorithmus hat einen deterministischen Ablauf, wenn er eine eindeutige
Schrittfolge besitzt. Der Algorithmus läuft bei jedem Ablauf mit den gleichen
Eingaben durch dieselbe Berechnung. Ein Algorithmus liefert ein determiniertes
Ergebnis, wenn bei vorgegebener Eingabe (auch bei mehrfacher Durchführung) stes
ein eindeutiges Ergebnis erreicht wird. Nicht deterministische Algorithmen mit
determiniertem Ergebnis heißen determinierter Algorithmus.Nicht deterministische
Algorithmen können zu einem determiniertem Ergebnis führen, z.B.:
1. Nimm eine Zahl x ungleich Null
2. Entweder: Addiere das Dreifache von x zu x und teile das Ergebnis durch den Anfangswert von x
Oder: Subtrahiere 4 von x und subtrahiere das Ergebnis von x
3. Schreibe das Ergebnis auf
Vollständigkeit
Alle Fälle, die bei korrekten Eingabedaten auftreten können, werden berücksichtigt.
Bsp.:
Nichtvollständige Algorithmen
(1) Wähle zufällig eine Zahl x
(2) Wähle zufällig eine Zahl y
(3) Das Ergebnis ist x/y
Was ist, wenn y == 0 sein sollte
Nicht terminierender Algorithmus
(1) Wähle zufällig eine Zahl x
(2) Ist die Zahl gerade, wiederhole ab (1)
(3) Ist die Zahl ungerade, wiederhole ab (1)
Nicht determinierter Algorithmus
60
64
(1) Wähle zufällig eine natürliche Zahl zwischen 2 und 2
(2) Prüfe, ob die Zahl eine Primzahl ist.
(3) Falls nicht, wiederhole ab 1.
Das Ergenis ist immer eine Primzahl, aber nicht die gleiche, daher ist der Algorithmus nicht
determiniert.
Deterministische,
terminierende
Algorithmen
definieren
jeweils
eine
Ein/Ausgabefunktion: f : Eingabewerte -> Ausgabewerte
Algorithmen geben eine konstruktiv ausführbare Beschreibung dieser Funktion, die
Funktion heißt Bedeutung (Semantik) des Algorithmus. Es kann mehrere
verschiedene Algorithmen mit der gleichen Bedeutung geben.
1.2.3 Algorithmenbausteine
Gängige Bausteine zur Beschreibung bzw. Ausführung von Algorithmen sind:
- elementare Operationen
- sequentielle Ausführung (ein Prozessor)
Der Sequenzoperator ist „;“. Sequenzen ohne Sequenzoperator sind häufig
durchnummeriert und können schrittweise verfeinert werden, z.B:
(1) Koche Wasser
(2) Gib Kaffepulver in Tasse
(3) Fülle Wasser in Tasse
11
Algorithmen und Datenstrukturen
(2) kann verfeinert werden zu:
Öffne Kaffeedose;
Entnehme Löffel von Kaffee;
Kippe Löffel in Tasse;
Schließe Kaffeedose;
- parallele Ausführung
- bedingte Ausführung
Die Auswahl / Selektion kann allgemein so formuliert werden:
falls Bedingung, dann Schritt
bzw.
falls Bedingung
dann Schritt a
sonst Schritt b
„falls ... dann ... sonst ...“ entspricht in Programmiersprachen den Konstrukten:
if Bedingung then ... else … fi
if Bedingung then … else …endif
if (Bedingung) … else …
- Schleife (Iteration)
Dafür schreibt man allgemein
wiederhole Schritte
bis Abbruchkriterium
Häufig findet man auch die Variante
solange Bedingung
führe aus Schritte
bzw. die Iteration über festen Bereich
wiederhole für Bereichsangabe
Schleifenrumpf
Diese Schleifenkonstrukte
Konstrukten:
wiederhole ... bis ...
solange … führe aus
wiederhole für
entsprechen
repeat ... until …
do …
while ...
while … do ...
while ( ... ) ...
for each ... do …
for ... do …
for ( ... ) ...
- Unterprogramm (Teilalgoritmus)
- Rekursion
12
jeweils
den
Programmiersprachen-
Algorithmen und Datenstrukturen
Eine Funktion (mit oder ohne Rückgabewert, mit oder ohne Parameter) darf in der
Deklaration ihres Rumpfes den eigenen Namen verwenden. Hierdurch kommt es zu
einem rekursiven Aufruf. Typischerweise werden die aktuellen Parameter so
modifiziert, daß die Problemgröße schrumpft, damit nach mehrmaligem Wiederholen
dieses Prinzips keine weiterer Aufruf erforderlich ist und die Rekursion abbrechen
kann.
1.2.4 Paradigmen der Algorithmenbeschreibung
Ein Algorithmenparadigma legt Denkmuster fest, die einer Beschreibung eines
Algorithmus zugrunde liegen. Faßt man einen Algorithmus als Beschreibung eines
allgemeinen Verfahrens unter Verwendung ausführbarer elementarer Schritte auf,
dann gibt es 2 grundlegende Arten, Schritte von Algorithmen zu notieren:
- Applikative Algorithmen sind eine Verallgemeinerung der Funtionsauswertung
mathematisch notierter Funktionen. In ihnen spielt die Rekursion 2 eine wesentliche
Rolle.
- Imperative Algorithmen basieren auf einem einfachen Maschinenmodell mit
gespeicherten und änderbaren Werten. Hier werden primär Schleifen und
Alternativen als Kontrollbausteine eingesetzt.
In der Informatik sind darüber hinaus noch folgende Paradigmen wichtig:
- Objektorientiete Algorithmen. In einem objektorientierten Algorithmus werden
Datenstrukturen und Methoden zu einer Klasse zusammengefasst. Von jeder
Klasse können Objekte gemäß der Datenstruktur erstellt und über die Methode
manipuliert werden.
Das objektorientierte Paradigma ist kein Algorithmenparadigma im engeren Sinne,
da es sich um ein Paradigma zur Strukturierung von Algorithmen handelt, das
sowohl mit applikativen, imperativen und logischen Konzepten zusammen
eingesetzt werden kann.
- logische (deduktive) Algorithmen. Ein logischer Algorithmus führt Berechnungen
durch, indem er aus Fakten unjd Regeln durch Ableitungen in einem logischen
Kalkül weitere Fakten ausweist.
2
vgl. 3.3
13
Algorithmen und Datenstrukturen
1.2.4.1 Applikative Algorithmen
Idee: Defintion zusammengesetzter Funktionen durch Ausdrücke / Terme, z.B.
f ( x) = 5 x + 1 .
Definitionen
Ein applikativer Algorithmus ist eine Liste von Funktionsdefinitionen
f1 (v1,1 ,..., v1,n1 ) = t1 (v1,1 ,..., v1,n1 )
.
.
f m (v m1,1 ,..., v m1,nm ) = t m (v m1,1 ,..., v m ,nm )
v1 ,..., v n : Unbestimmte vom Typ τ 1 ,...,τ n , formale Parameter.
t (v1 ,..., v n ) : ein Term (/Ausdruck), heißt Funktionsausdruck
Die erste Funktion wird ausgewertet und bestimmt die Bedeutung (Semantik) des
Algorithmus.
Bsp.: Auswertungen 3
1. f ( x, y ) = if g ( x, y ) then h( x + y ) else h( x − y ) fi
g ( x, y ) = ( x = y ) or odd ( y )
h( x) = j ( x + 1) ∗ j ( x − 1)
j ( x) = 2 x − 3
4 f (1,2) a if g (1,2) then h(1 + 2) else h(1 − 2) fi
a if 1 = 2 or odd (2) then h(1 + 2) else h(1 − 2) fi
a if 1 = 2 or false then h(1 + 2) else h(1 − 2) fi
a if false or false then h(1 + 2) else h(1 − 2) fi
a if false then h(1 + 2) else h(1 − 2) fi
a h(1 − 2)
a h(−1)
a j (−1 + 1) ∗ j (−1 − 1)
a j (0) ∗ j (−1 − 1)
a j (0) ∗ j (−1 − 1)
a j (0) ∗ j (−2)
a j (2 ∗ 0 − 3) ∗ j (−2)
a (−3) ∗ (−7)
a 21
2. f ( x, y ) = if x = 0 then y else (
if x > 0 then f ( x − 1, y ) + 1else − f (− x,− y ) fi ) fi
f (0, y ) a y für alle y
f (1, y ) a f (0, y ) + 1a y + 1
f (2, y ) a f (1, y ) + 1a y + 1 + 1 a y + 2
3
x, y : ganze Zahlen
4 a : konsekutive Ausführung mehrerer elementarer Termauswertungsgebiete
14
Algorithmen und Datenstrukturen
….
f (n, y ) a y + n
f (−1, y ) a − f (1,− y ) a −(1 − y ) a y − 1
…
f ( x, y ) = x + y
Eine Funktionsdefinition definiert eine Funktion f : τ 1 × τ 2 × ...τ n → τ n
Sind a1 ,..., a n Werte vom Typ τ 1 ,...,τ n , so ersetzt man bei der Auswertung von
f (a1 ,..., a n ) im definierten Vorkommen v1 durch a1 und wertet t (a1 ,..., a n ) aus.
a1 ,..., a n : aktuelle Parameter
f (a1 ,..., a n ) : Funktionsaufruf
Aufrufe definierter Funktionen dürfen als Terme verwendet werden.
Bsp. für applikative Algorithmen
1. Fakultätsberechnung
x!= x ∗ ( x − 1) ∗ ( x − 2) ∗ ... ∗ 2 ∗ 1
für x > 0
mathematische Funktion
fak ( x) = if x ≤ 0 then1else x ∗ fak ( x − 1)
applikativer Algorithmus
5
2. Größter gemeinsamer Teiler (ggT)
ggT ( x, x) = x
mathematische
ggT ( x, y ) = ggT ( y, x)
Gesetzmäßigkeiten
ggT ( x, y ) = ggT ( x, y − x)
für x < y
applikativer Algorithmus
ggT ( x, y ) = if ( x ≤ 0) or ( y ≤ 0) then ggT ( x, y )
else if x = y then x
else if x > y then ggT ( y, x)
else ggT ( x, y − x)
fi fi fi;
ggT ist korrekt für positive Eingaben, bei negativen Eingaben ergeben sich nicht
abbrechbare Berechnungen (undefinierte Funktionen) 6.
ggT (39,15) a ggT (15,39) + ggT (15,24) a ggT (15,9) a ggT (9,15) a ggT (9,6) a ggT (6,9)
ggT (6,3) a ggT (3,6) + ggT (3,3) a 3
3. Fibonacci-Zahlen: f 0 = f 1 = 1, f i = f i −1 + f i − 2 für i > 0
fib( x) = if ( x = 0) or ( x = 1) then1 else fib( x − 2) + fib( x − 1) fi
⎧ x _ te Fibonacci − Zahl falls x ≥ 0
Bedeutung: fib( x) = ⎨
sonst
⎩ 1
5
6
vgl. 1.2.5
Das Berechnungsschema stützt sich auf eine Formularisierung des Originalverfahrens von Euklid ab.
15
Algorithmen und Datenstrukturen
1.2.4.2 Imperative Algorithmen
In einem imperativen Algorithmus gibt es Variable, die verschiedene Werte
annehmen können. Die Menge aller Variablen und ihrer Werte (sowie der
Programmzähler) beschreiben den Zustand zu einem bestimmten Zeitpunkt. Ein
Algorithmus bewirkt eine Zustandstransformation.
Imperative Konzepte
- Anweisungen
-- primitive Anweisungen: Zuweisung, Block, Prozeduraufruf
-- zusammengesetzte Anweisungen: Sequenz, Auswahl, Iteration
- Ausdrücke
-- primitive Ausdrücke: Konstante, Variable, Funktionsaufruf
-- zusammengesetzte Ausdrücke: Operanden / Operatoren
- Datentypen
-- primitive Datentypen: Wahrheitswerte, Zeichen, Zahlen, Aufzählung
-- zusammengesetzte Datentypen: Felder, Verbund, Vereinigung, Zeiger
- Abstraktion
-- Anweisung
-- Ausdruck: Funktionsdeklaration
-- Datentyp: Typdeklaration
- Weitere Konzepte
-- Ein- und Ausgabe
-- Ausnahmenbehandlung
-- Bibliotheken
-- Parallele und verteilte Berechnungen
Wertzuweisungen sind die einzigen elementaren Anweisungen imperativer
Algorithmen. Aus ihnen werden zusammengesetzte Anweisungen gebildet, aus
denen imperative Algorithmen bestehen.
Elementare Anweisungen können auf unterschiedliche Art zu komplexen
Anweisungen zusammengestzt werden:
(1) sequentielle Ausführung
(2) bedingte Ausführung
(3) wiederholte Ausführung
(4) Ausführung als Unterprogramm
(5) rekursive Ausführung eines Unterprogramms
Diese Möglichkeiten werden als Kontrollstrukturen bezeichnet.
1.2.4.3 Objektorientierte Algorithmen
Das objektorientierte Paradigma der Algorithmenentwicklung hat verschiedene
Wurzeln:
-
Realisierung abstrakter Datentypen 7
Rechnergeeignete Modellierung der realen Welt (objektorientierte Analyse)
Problemnaher Entwurf von Softwaresystemen (objektorientiertes Design)
Problemnahe Implementierung (objektorientierte Programmierung
Ein Objekt ist die Repräsentation eines Gegenstands und Sachverhalts der realen
Welt oder eines gedanklichen Konzepts.
7
Vgl. 1.3.5.1
16
Algorithmen und Datenstrukturen
Es ist gekennzeichnet durch
-
-
eine eindeutige Identität, durch die es sich von anderen Objekten unterscheidet
Wertbasierte Objektmodelle: In diesem Modell besitzen Objekte keine eigene Identität im
eigentlichen Sinn. Zwei Objekte werden schon als identisch angesehen, wenn ihr Zustand
gleich ist.
Identitätsbasierte Objektmodelle: Jedem Objekt innerhalb des Systems wird eine vom Wert
unabhängige Identität zugeordnet,
statische Eigenschaften zur Darstellung des Zustands des Objekts in Form von Attributen
dynamische Eigenschaften in Form von Methoden, die das Verhalten des Objekts
beschreiben
Der Zustand eines Objekts zu einem Zeitpunkt entspricht der Belegung der Attribute
des Objekts zu diesem Zeitpunkt.
Der Zustand kann mit Hilfe von Methoden erfragt und geändert werden.
Methoden sind in der programmiesprachlichen Umsetzung Prozeduren und
Funktionen, denen Parameter übergeben werden können. Der Zustand eines eine
Methode ausführenden Objekts (und nur dieses Objekts) ist der Methode im Sinne
einer Menge globaler Variablen direkt zugänglich. Es kann daher sowohl gelesen als
auch geändert werden.
Objekte verwenden das Geheimnisprinzip und das Prinzip der Kapselung. Sie
verbergen ihre Interna:
-
Zustand (Belegung der Attribute)
Implementierung ihres Zustands
Implementierung ihres Verhaltens
Objekte sind nur über ihre Schnittstelle, also über die Menge der vom Objekt der
Außenwelt zur Verfügung gestellten Methoden zugänglich. Man spricht von den
Diensten des Objekts.
Objekte interagieren über Nachrichten:
-
Ein Objekt x sendet eine Nachricht an Objekt y. y empfängt die Nachricht von x
Innerhalb der Programmiersprache wird dieser Vorgang meistens durch einen
Methodenaufruf implementiert
Nachrichten (Methodenaufrufe) können den Zustand eines Objekts verändern
Ein Objekt kann sich selbst Nachrichten schicken.
Objekte können in Beziehung zueinander stehen.
-
-
-
Die Beteiligten an eine Beziehung nehmen Rollen ein, z.B.:
Rolle des Arztes: „behandelnder Arzt“,
Rolle des Patienten: „Patient“
Ein Objekt kann mit mehreren Objekten in Beziehung stehen
Rolle vom Arzt: „behandelnder Arzt“
Rolle von Patient 1: „Patient“, Rolle von Patient 2: „Patient“
Nachrichen können nur ausgetauscht werden, wenn eine Beziehung besteht
Beziehungen können sich während der lebenszeit eines Objekts verändern
Es gibt in der Regel Objekte, die sich bezüglich der Attribute, Methoden und
Beziehungen ähnlich sind. Daher bieten es sich an, diese Objekt zu einer Klasse
zusammenzufassen. Die Klasse beinhaltet dann auch Angaben darüber, wie Objekte
dieser Klasse verwaltet (z.B. erzeugt oder gelöscht) werden können.
-
Klassendefinitionen sind eng verwandt mit abstrakten Datentypen. Sie legen Attribute und
Methoden der zugehörigen Objekte fest
Objekte dieser Klasse nennt man auch Instanzen dieser Klasse
17
Algorithmen und Datenstrukturen
-
Beziehungen (Assoziationen) zwischen Objekten werden auf Klassenebene beschrieben
Ein Konstruktor ist eine Methode zur Erzeugung von Objekten 8.
-
Es gibt Attribute von Klassen, die nicht an konkrete Instanzen gebunden sind. Diese heißen
Klassenvariable oder statische Vartiable.
Klassenvariable existieren für die gesamte Lebensdauer einer Klasse genau einmal –
unabhängig davon, wie viele Objekte erzeugt wurden
Neben Klassenvariablen gibt es auch Klassenmethoden, d.h. Methoden, deren Existenz
nicht an konkrete Objekte gebunden ist. Klassenmethoden werden auch statische Methoden
genannt.
Zu ähnlichen Klassen versucht man eine gemeinsame Oberklasse (Basisklasse) zu
finden, die die Ähnlichkeiten aufnimmt. Unterklassen (Subklassen) werden nur um
individuelle Eigenschaften ergänzt, denn eine Unterklasse erbt die Attribute und
Methoden der Oberklasse.
Eine Veraible vom Typ einer Basisklasse kann während ihrer Lebensdauer sowohl
Objekte ihres eigenen Typs als auch soche von abgeleiteten Klassen aufnehmen.
Dieses wird als Polymorphismus 9 bezeichner.
-
-
-
Eine Unterklasse erbt von ihrere Oberklasse alle Attribute und Methoden und kann diese um
weitere Methoden ergänzen
Erben heißt: Die Attribute und Methoden können in der Unterklasse verwendet werden, als
wären sie in der Klasse selbst definiert.
Vererbungen können mehrstufig sein. Es entstehen Vererbungshierarchien.
Eine Unterklasse kann eine Variable deklarieren, die denselben Namen trägt, wie eine der
Oberklasse. Hierdurch wird eine weiter oben liegende Variable verdeckt. Dies wird häufig
dazu benutzt, um den Typ einert Variablen der Oberklasse zu überschreiben. In manchen
Programmiersprachen gibt es Konstrukte, die den Zugriff auf verdeckte Variable ermöglichen
Metoden, die aus der Basisklasse geerbt wurden, dürfen in der abgeleiteten Klasse
überlagert, d.h. neu definiert werden.
Da eine Variable einer Basisklasse Werte von verschiedenen Typen annehmen kann,
entscheidet sichj bei überlagerten Mathoden erst zur Laufzeit, welche Methode zu verwenden
ist: Dynamische Methodensuche
Wird eine Methode in einer abgeleiteten Klasse überlagert, wird die ursprüngliche Methode
verdeckt. Aufrufe der Methode beziehen sich auf die überlagerte Variante
In amnchen Programmiersprachen gibt esw Konstrukte, die den Zugriff auf überlagerte
Methoden ermöglichen 10.
Mit Hilfe von Modifikatoren 11 können Sichtbarkeit und Eigenschaften von Klassen,
Variablen und Methoden beeinflusst werden.
-
Die Sichbarkeit bestimmt, ob eine Klasse, Variable oder Methode in anderen Klassen genutzt
werden kann.
Eigenschaften, die über Modifikatoren gesteuert werden können, sind z.B. die Lebensdauer
und die Veränderbarkeit
Abstrakte Methoden: Eine Methode heißt abstrakt, wenn ihre Deklaration nur die
Schnittstelle, nicht aber die Implementierung enthält. Im Gegensatz dazu stehen die
konkreten Methoden, deren Deklaration auch Implementierungen besitzen 12.
Abstrakte Methoden können nicht aufgerufen werden, sie definieren nur eine
8
Vgl. Skriptum zur Vorlesung im WS 2005 / 2006: Programmieren in Java, 1.4.1.1.3
Vgl. Skriptum zur Vorlesung im WS 2005 / 2006: Programmieren in Java, 1.4.1.8
10 in Java: Verwendung des Präfixes: super
11 Bsp. für Modifikatoren in Java sind: public, private, static, final, …
12 Java: Die Deklaration einer abstrakten Methode erfolgt durch den Modifikator abstract.
9
18
Algorithmen und Datenstrukturen
Schnittstelle. Erst durch Überlagerung in einer abgeleiteten Klasse und durch
Angabe der fehlenden Implementierung wird eine abstrakte Klasse konkret.
Abstraklte Klassen: Eine Klasse, die nicht instanziiert werden kann, heißt abstrakte
Klasse. Klassen, von denen Objekte erzeugt werden können, sind konkrete Klassen.
Jede Klasse, die mindestens eine abstrakte Methode besitzt, ist abstrakt 13.
Schnittstellen: Eine Schnittstelle (interface) ist in Java eine Klasse, die
ausschließlich Konstanten und abstrakte Methoden enthält. Zur Definition einer
Schnittstelle wird das Schlüsselwort class durch das Schlüsselwort interface
ersetzt.
Generizität: Unter Generizität versteht man die Parametrisierung von Klassen,
Datentypen, Prozeduren, Moduln, Funktionen, etc. Als Parameter werden in der
Regeln Datentypen (manchmal auch Algorithmen in Form von Prozeduren)
verwendet.
1.2.4.4 Paradigmen und Programmiersprachen
Zu den Paradigmen korrespondieren jeweils Programmiersprachen, die diesen
Ansatz realisieren. Moderne Programmiersprachen vereinen oft Ansätze mehrerer
Paradigmen. So ist bspw. Java objektorientiert 14, umfasst aber auch imperative und
applikative Elemente.
1.2.5 Beschreibung von Algorithmen
Verbreitetes Grundschema von Algorithmen
Name des Algorithmus und Parameterliste
Spezifikation des Ein- und Ausgabeverhaltes
1. Schritt
Einführung von Hilfsgrößen
Vorbereitung
Initialisierungen
2. Schritt
Prüfe, ob ein einfacher Fall vorliegt
Trivialfall
Falls ja: Ergebnis ausgeben und enden
3. Schritt
Reduziere Problemstellung A auf einfachere Form B
Problemreduktion, (z.B. Aufteilen in Teilprobleme)
Ergebnisaufbau
4. Schritt
entweder Rekursion:
oder Iteration:
Rekursion bzw.
Rufe Algorithmus mit
Fahre mit B anstelle a bei
Iteration
reduziertem B auf
Schritt 2 fort
Verbale Umschreibung von Algorithmen
Eine derartige Handlungsanweisung könnte bspw. die „Berechnung des größten
gemeinsamen Teilers von a und b“ in folgender Weise sein:
1. Weise x den Wert von a zu
2. Weise y den Wert von b zu
3. Falls x gleich y ist: gehe zu 9
13
Java: Es ist erforderlich, abstrakte Klassen abzuleiten und in der abgeleiteten Klasse eine oder mehrere
abstrakte Methoden zu implementieren. Die Konkretisierung kann über mehrere Stufen erfolgen.
14 Vgl. Skriptum zur Vorlesung im WS 2005 / 2006: Programmieren in Java, 1.
19
Algorithmen und Datenstrukturen
4. Falls x kleiner als y ist: gehe zu 7
5. Weise x den Wert von (x-y) zu
6. Gehe zu 3
7. Weise y den Wert von (y-x) zu
8. gehe zu 3
9. Weise ggTden Wert von x zu
Pseudo-Code
- Abstrakte Beschreibung eines Algorithmus
- Strukturierter als Beschreibung mit normalen Sprachvokabular
- weniger detailliert als ein Programm
- Bevorzugete Notation zur Beschreibung eines Algorithmus
- versteckt Programmimplementierungsprobleme
Bsp.: Finden des größten Elements in einem Array
Algorithmus arrayMax(a,n)
Input array a mit n Ganzzahlen
Output größtes Element von a
currentMax = a[0]
for i= 1 to n-1 do
if (a[i] > curentMax then currentMax = a[i]
return currentMax
Pseudocode-Details:
- Kontrollfluss
-- if … then … [else …]
-- while … do …
-- repeat … until …
-- for … do
- Einrücken ersetzt Klammern
- Deklaration von Methoden
Algorithmus methode(arg [,arg …])
Input …
Output …
- Rückgabewert
return Ausdruck
- Ausdrücke
=
Zuweisung
==
Gleiheitstest
2
Subscripts und andere mathematische Formulierungen sind erlaubt
n
Pseudo-Code Elemente:
Sequenz
Verzeigung
{
Anweisung_1
Anweisung_2
…
Anweisung_n
}
if Bedingung
{
Anweisung_1
Anweisung_2
…
Anweissung_n
}
else
{
Anweisung_m
…
Anweisung_k
}
20
Algorithmen und Datenstrukturen
Iteration
While Bedingung
{
Anweisung_1
Anweisung_2
…
Anweisung_n
}
Graphische Darstellung von Flußdiagrammen
Normierte Methode (DIN 66001) zur Darstellung von Programmen
Kontrollstrukturen und Struktogramme
Strukturblock
Anweisung_1
Anweisung_2
….
Java-Struktur 15
Kommentar
Block in geschweiften
Klammern
{
Anweisung_1;
Anweisung_2;
…..
Eine Folge von Anweisungen,
die alle der Reihe nach
abgearbeitet werden,
bezeichnet man als Sequenz.
Anweisung_n;
}
Anweisung_n
Sequenz
if-Anweisung
if (Bedingung)
{
anweisung1;
}
else {
Anweisung2;
}
Fallunterscheidung
(bedingte Anweisung)
1
2
3
A1
A2
A3
Fall
….
Mehrfachauswahl
sonst
An
Mit einer Anweisung der Form
Wenn Bedingung erfüllt
dann führe Anweisung1 aus
sonst führe Anweisung 2 aus
führt man eine
Fallunterscheidung durch
switch-Anweisung
Mehrfachauswahl
switch (Ausdruck) {
case Wert1 :
Anweisung1;
break;
case Wert2 :
Anweisung2;
break;
default:
Anweisungn
}
Der Ausdruck muß ganzzahlig
sein. Das Programm wird an
der case-Anweisung
fortgesetzt., deren Wert dem
Ausdruck entspricht. Falls
Ausdruck keinem der Werte
entspricht, geht es mit der
default-Anweisung weiter
for-Schleife
Eine Anweisung der Form
for (int i=1;i <=n; i++)
{
Für Zähler = Anfang bis Ende
Anweisung
für i=1 bis n
Anweisung
15
Vgl. Skriptum zur Vorlesung im WS 2005 / 2006: Programmieren in Java, 2.4
21
Algorithmen und Datenstrukturen
Gezählte Schleife
for-Schleife
Anweisung;
}
heißt gezählte Schleife.
Gezählte Schleifen werden
dann benutzt, wenn man weiß,
wie oft eine Schleife
durchlaufen werden muß
while-Schleife
Eine Anweisung der Form
while (Bedingung)
{
Anweisung;
}
Solange bedingung erfüllt
führe Anweisung aus
do-while-(repeat)Schleife
Eine Anweisung der Form
solange Bedingung
Anweisung
while-Schleife
Anweisung
bis Bedingung
repeat-Schleife
do
{
Anweisung;
} while (Bedingung);
Prozeduraufruf
Prozedurname(Arg1,
Arg2, … , Argn);
heißt Schleife mit Eingangsbedingung. Trifft die
Bedingung anfangs nicht zu,
so wird die Wiederholungsanweisung nicht ausgeführt
Wiedrhole Anweisung
Solange Bedingung erfüllt
heißt Schleife mit Ausgangsbedingung. Im Unterschied
zur while-Schleife wird die zu
wiederholende Anweisung
mindestens einmal ausgeführt.
Prozeduren werden über ihren
Namen aufgerufen. In
Klammern kann man
Argumente übergeben.
Prozeduraufruf
1.2.6 Formale Eigenschaften von Algorithmen
1.2.6.1 Korrektheit und Terminierung
Die wichtigste formale Eigenschaft eines Algorithmus ist die Korrektheit. Dazu muß
gezeigt werden, daß der Algorithmus die jeweils gestellte Aufgabe richtig löst. Man
kann die Korrektheit eines Algorithmus im Allg. nicht durch Testen an ausgewählten
Beispielen nachweisen 16:
Durch Testen kann lediglich nachgewiesen werden, dass sich ein Programm für endlich viele
Eingaben korrekt verhält.
Durch eine Verifikation kann nachgewiesen werden, dass sich das Programm für alle Eingaben
korrekt verhält.
Bei der Zusicherungsmethode sind zwischen den Statements sogenannte Zusicherungen eingesetzt,
die eine Aussage darstellen über die momentane Beziehung zwischen den Variablen. Typischerweise
gibt man Zusicherungen als Kommentare vor.
/* P */
16
E. Dijkstra formulierte das so: Man kann durch Testen die Anwesenheit von Fehlern, aber nicht die
Abwesenheit von Fehlern nachweisen.
22
Algorithmen und Datenstrukturen
while (b)
{
/* P && b */
…
/* P */
}
/* P && !b */
Zusicherungen enthalten boolsche Ausdrücke, von denen der Programmierer annimmt, dass sie an
entsprechender Stelle gelten.
Beginnend mit der ersten, offensichtlich richtigen Zusicherung lässt sich als letzte
Zusicherung eine Aussage über das berechnete Ergebnis durch Anwendung der
Korrektheitsformel 17 ableiten:
{ P } A { Q }
-
P und Q sind Zusicherungen
P ist die pre-condition (Vorbedingung), beschreibt die Bedingungen (constraints).
Q ist die post-condition (Nachbedingung), beschreibt den Zustand nach Ausführung der
Methode
Die Korrektheitsformel bedeutet: Jede Ausführung von A, bei der zu Beginn P erfüllt
ist, terminiert in einem Zustand, in dem Q erfüllt ist.
Die Korrektheitsformel bestimmt partielle Korrektheit : "Wenn P beim Start von A
erfüllt ist, und A terminiert, dann wird am Ende Q gelten".
Für die Terminierung gilt folgende Formel: { P} A. Sie bedeutet: "Wenn P beim
Start von A erfüllt ist, wird A terminieren.
Partielle Korrektheit und Terminierung führen zur totale Korrektheit. Totale
Korrektheit ist eine stärkere Anforderung an das Programm.
Da das Halteproblem bekanntlich unentscheidbar ist, kann im allgemeinen die totale
Korrektheit nicht entschieden werden.
1.2.6.2 Effizienz
Die zweite wichtige Eigenschaft eines Algorithmus ist seine Effizienz. Die wichtigsten
Maße für die Effizienz sind der zur Ausführung des Algorithmus benötigte
Speicherplatz und die benötigte Rechenzeit (Laufzeit):
1. Man kann die Laufzeit durch Implementierung des Algorithmus in einer
Programmiersprache (z.B. C++) auf einem konkreten Rechner für eine Menge
repräsentativer Eingaben messen.
Bsp.: Implementierung eines einfachen Sortieralgorithmus in C++ mit Messen der
CPU-Zeit 18.
#include <time.h>
// …
clock_t start, finish;
start = clock();
sort(…);
17
Robert Floyd hatte 1967 die Idee den Kanten von Flussdiagrammen Prädikate zuzuordnen, um
Korrektheitsbeweise zu führen. C.A.R. Hoare entwickelte die Idee weiter, indem er Programme mit
"Zusicherungen" anreicherte. Er entwickelte das nach ihm benannte "Hoare Tripel"
18 In Java steht zur Zeitmessung die Methode Methode currentTimeMillis() aus System zur Verfügung.
currentTimeMillis bestimmt die Anzahl der Millisekunden, die seit Mitternacht des 1.1.1970 vergangen sind.
23
Algorithmen und Datenstrukturen
finish = clock();
cout << "sort hat " << double (finish – start) / CLOCKS_PER_SEC
<< " Sek. benoetigt\n";
// …
Solche experimentell ermittelten Meßergebnisse lassen sich nicht oder nur schwer
auf andere Implementierungen und andere Rechner übertragen.
2. Aus dieser Schwierigkeit bieten sich 2 Auswege an:
1. Man benutzt einen idealiserenden Modellrechner als Referenzmaschine und mißt die auf diesem
Rechner zur Ausführung des Algorithmus benötigte Zeit und benötigten Speicherplatz. Ein in der
Literatur 19 zu diesem Zweck häufig benutztes Maschinenmodell ist das der RAM (Random-AccessMaschine). Eine solche Maschine verfügt über einige Register und eine (abzählbar unendliche)
Menge einzeln addressierbarer Speicherzellen. Register und Speicherzellen können je eine (im
Prinzip) unbeschränkt große (ganze oder reelle) Zahl aufnehmen. Das Befehlsrepertoire für eine RAM
ähnelt einer einfachen, herkömmlichen Assemblersprache. Die Kostenmaße Speicherplatz und
Laufzeit enthalten dann folgende Bedeutung: Der von einem Algorithmus benötigte Speicherplatz ist
die Anzahl der zur Ausführung benötigten RAM-Speicherzellen. Die benötigte Zeit ist die Zahl der
ausgeführten RAM-Befehle.
2. Bestimmung einiger für die Effizienz des Algorithmus besonders charakteristischer Parameter 20.
Laufzeit und Speicherbedarf eines Algorithmus hängen in der Regel von der Größe der Eingabe ab 21.
Man unterscheidet zwischen dem Verhalten im besten Fall, dem Verhalten im Mittel (average case)
und dem Verhalten im schlechtesten Fall (worst case). In den meisten Fällen führt man eine worstcase Analyse für die Ausführung eines Algorithmus der Problengröße N durch. Dabei kommt es auf
den Speicherplatz nicht an, lediglich die Größenordnung der Laufzeit- und Speicherplatzfunktionen in
Abhängigkeit von der Größe der Eingabe N wird bestimmt. Zum Ausdruch dieser Größenordnung hat
sich eine besondere Notation eingebürgert: die O-Notation bzw. Big-O-Notation.
Laufzeit T(N): Die Laufzeit gibt exakt an, wieviel Schritte ein Algorithmus bei einer
Eingabelänge N benötigt. T(N) kann man im Rahmen sog. assymptotischer
Kostenmaße abschätzen. Für diese Abschätzung existieren die sog. Big-O-Notation
(bzw. Ω - und Θ -Notation):
Big-O-Notation: Ein Funktion f (N ) heißt von der Ordnung O ( g ( N )) , wenn 2 Konstante c0 und n0
existieren, so dass f ( N ) ≤ c o ⋅ g ( N ) für alle N > n0 .
Die Big-O-Notation liefert eine Obergrenze für die Wachstumsrate von Funktionen: f ∈ O (g ) , wenn f
höchstens so schnell wie g wächst. Man sagt dann: die Laufzeit eines Algorithmus "T(N) ist O(N)"
oder "T(N) ist ein O(N)".
Big- Ω -Notation: Ein Funktion f (N ) heißt von der Ordnung Ω( g ( N )) , wenn 2 Konstante c0 und n0
existieren, so dass f ( N ) ≥ c o ⋅ g ( N ) für alle N > n0 .
Die Big- Ω -Notation liefert eine Untergrenze für die Wachstumsrate von Funktionen: f ∈ Ω(g ) ,
wenn f mindestens so schnell wie g wächst.
θ -Notation: Das Laufzeitverhalten eines Algorithmus ist θ (N ) , falls O( N ) = Ω( N ) . Über θ (N )
kann das Laufzeitverhalten exakt beschrieben werden.
19
Vgl. Aho, Hopcroft, Ullman: The Design and Analysis of Computer Algorithms, Addison-Wesley Publishing
Company
20 So ist es bspw. üblich, die Laufzeit eines Verfahrens zum Sortieren einer Folge von Schlüsseln durch die
Anzahl der dabei ausgeführten Vergleichsoperationen zwischen Schlüsseln und die Anzahl der ausgeführten
Bewegungen von den jeweiligen betroffenen Datensätzen zu messen.
21 die im Einheitskostenmaß oder im logarithmischen Kostenmaß gemessen wird
24
Algorithmen und Datenstrukturen
T (N ) 22 aus dem
Bereich der positiven reellen Zahlen: Ein Algorithmus hat die Komplexität O (g ) , wenn T ( N ) ∈ O ( g )
Damit lässt sich der Zeitbedarf eines Algorithmus darstellen als eine Zeitfunktion
gilt.
Meistens erfolgt die Abschätzung hinsichtlich der oberen Schranken (Worst Case):
Groß-O-Notation.
T (N )
c1 g (n)
f ∈ Θ(g )
c 2 g ( n)
n0
N
Abb. 1.2-71: Assymptotische Kostenmaße
Zeitbedarf eines Algorithmus: Ist N die Problemgröße, A ein Algorithmus, dann hat
ein Algorithmus die Komplexität O(g ) , wenn für den Zeitbedarf von A T A (n) ∈ O( g )
gilt. Wenn nicht explizit anders beschrieben, ist T A (n) maximale Laufzeit für die
gegebene Faustregel in der O-Notation
Rechenregeln zur O-Notation.
⎧O( f ), falls g ∈ O( f )
Addition: f + g ∈ O(max( f , g )) = ⎨
⎩O( g ), falls f ∈ O( g )
Die Additionsregel dient zur Bestimmung der Komplexität bei Hintereinanderausführung der Programme
Multiplikation: f ⋅ g ∈ O( f ⋅ g )
Die Multiplikationsregel dient zur Bestimmung der Komplexität von ineinandergeschachtelten Schleifen
Linearität: f (n) = a ⋅ g (n) + b ∧ Ω(1) ⇒ f ∈ O( g )
1.2.7 Komplexität
Für die algorithmische Lösung eines gegebenen Problems ist es unerläßlich, daß der
gefundene Algorithmus das Problem korrekt löst. Darüber hinaus ist es natürlich
wünschenswert, daß er dies mit möglichst geringem Aufwand tut. Die Theorie der
22
falls nicht explizit anders beschrieben, ist T (N ) die maximale Laufzeit für die gegebene Problemgröße
N
25
Algorithmen und Datenstrukturen
Komplexität von Algorithmen beschäftigt sich damit, gegebene Algorithmen
hinsichtlich ihres Aufwands abzuschätzen und – darüber hinaus – für gegebene
Problemklassen anzugeben, mit welchem Mindestaufwand Probleme dieser Klasse
gelöst werden können.
Meistens geht es bei der Ananlyse der Komplexität von Algorithmen (bzw.
Problemklassen) darum, als Maß für den Aufwand eine Funktion anzugeben, wobei
f ( Ν) = a bedeutet: „ Bei einem Problem der Größe N ist der Aufwand a“. Die
Problemgröße „N“ bezeichnet dabei in der Regel ein grobes Maß für den Umfang
einer Eingabe, z.B. die Anzahl der Elemente in der Eingabeliste oder die Größe eines
bestimmten Eingabewertes. Der Aufwand „a“ ist in der Regel ein grobes Maß für die
Rechenzeit. Die Rechenzeit wird häufig dadurch abgeschätzt, daß man zählt, wie
häufig eine bestimmte Operation ausgeführt wird, z.B. Speicherzugriffe,
Multiplikationen, Additionen, Vergleiche, etc.
Bsp.: Wie oft wird die Wertzuweisung „x = x + 1“ in folgenden Anweisungen
ausgeführt?
1. x = x + 1; ............
..1-mal
2. for (i=1; i <= n; i++) x = x + 1;..
..n-mal
3. for (i=1; i <= n; i++)
for (j = 1; j <= n; j++)
x = x + 1;................................... ......... n2-mal
Die Aufwandfunktion läßt sich in den wenigsten Fällen exakt bestimmen.
Vorherrschende Analysemethoden sind:
- Abschätzungen des Aufwands im schlechtesten Fall
- Abschätzungen des Aufwands im Mittel
Selbst hierfür lassen sich im Allg. keine exakten Angaben machen. Man beschränkt
sich dann auf „ungefähres Rechnen in Größenordnungen“.
Bsp.: Gegeben: n ≥ 0 a1 , a 2 , a3 ,..., a n ∈ Z
Gesucht: Der Index i der (ersten) größten Zahl unter den ai (i=1,...,n)
Lösung:
max = 1;
for (i=2;i<=n;i++)
if (amax < ai) max = i
Wie oft wird die Anweisung „max = i“ im Mittel ausgeführt (abhängig von n)?
Die gesuchte mittlere Anzahl sei Tn. Offenbar gilt: 1 ≤ Tn ≤ n . „max = i“ wird genau dann
ausgeführt, wenn ai das größte der Elemente a1 , a 2 , a3 ,..., ai ist.
Angenommen wird Gleichverteilung: Für jedes i = 1, ... , n hat jedes der Elemente a1 , a 2 , a3 ,..., a n die
gleiche Chance das größte zu sein, d.h.: Bei N Durchläufen wird N/n-mal die Anweisung „max = i“
ausgeführt.
Daraus folgt für N ⋅ Tn (Aufwendungen bei N Durchläufen vom „max = i“):
N ⋅ Tn = N +
N N
N
1 1
1
+ + ... + = N (1 + + + ... + )
2 3
n
2 3
n
Dies ist Hn, die n-te harmonische Zahl. Für Hn ist keine geschlossene Formel bekannt, jedoch eine
ungefähre Abschätzung: Tn = H n ≈ ln n + γ 23. Interessant ist nur, daß Tn logarithmisch von n
abhängt. Man schreibt Tn ist „von der Ordnung logn“, die multiplikative und additive Konstante sowie
die Basis des Logarithmus bleiben unspezifiziert.
23
Eulersche Konstante
γ = 0.57721566
26
Algorithmen und Datenstrukturen
Diese sog. (Landau'sche) Big-O-Notation läßt sich mathematisch exakt definieren:
f ( n)
f (n) = O( g (n)) :⇔ ∃c, n0 ∀n ≥ n0 : f (n) ≤ c ⋅ g (n) , d.h.
ist für genügend große n
g ( n)
durch eine Konstante c beschränkt. „f“ wächst nicht stärker als „g“.
Diese Begriffsbildung wendet man bei der Analyse von Algorithmen an, um
Aufwandsfunktionen
durch
Eingabe
einer
einfachen
Vergleichsfunktion
abzuschätzen, so daß f (n) = O( g (n)) gilt, also das Wachstum von f durch das von g
beschränkt ist.
Gebräuchliche Vergleichsfunktionen sind:
O-Notation
O(1)
Aufwand
Konstanter Aufwand
O(log n)
Logarithmischer Aufwand
O(n)
Linearer Aufwand
Problemklasse
Einige Suchverfahren für Tabellen
(„Hashing“)
Allgemeine Suchverfahren für Tabellen
(Binäre Suche)
Sequentielle Suche, Suche in Texten,
syntaktische Analyse in Programmen
„schlaues Sortieren“, z.B. Quicksort
O(n ⋅ log n)
O(n 2 )
Quadratischer Aufwand
Einige dynamische Optimierungsverfahren,
z.B. optimale Suchbäume); „dummes
Sortieren“, z.B. Bubble-Sort
Multiplikationen Matrix mal Vektor
Exponentieller Aufwand
Viele Optimierungsprobleme, automatisches
Beweisen (im Prädikatenkalkül 1. Stufe)
Alle Permutationen
O(n k ) für k ≥ 0
O(2 n )
O(n!)
Zur Veranschaulichung des Wachstums konnen die folgende Tabellen betrachtet
werden:
f(N)
ldN
N
N ⋅ ldN
N2
N3
2N
N=2
1
2
2
4
8
4
24=16
4
16
64
256
4096
65536
25=256
8
256
1808
65536
16777200
≈ 1077
210
10
1024
10240
1048576
≈ 109
≈ 10308
220
20
1048576
20971520
≈ 1012
≈ 1018
≈ 10315653
Unter der Annahme „1 Schritt dauert 1 μs = 10 −6 s folgt für
N=
N
N2
N3
2N
3N
N!
10
10 μs
100 μs
1 ms
1 ms
59 ms
3,62 s
20
20 μs
400 μs
8 ms
1s
58 min
771 Jahre
30
30 μs
900 μs
27 ms
18 min
6.5 Jahre
1016 Jahre
Abb. 1.2-2: Polynomial- und Exponentialzeit
27
40
40 μs
1.6 ms
64 ms
13 Tage
3855 Jahre
1032 Jahre
50
50 μs
2.5 ms
125 ms
36 Jahre
108 Jahre
1049 Jahre
60
60 μs
3.6 ms
216 ms
366 Jahre
1013 Jahre
1066 Jahre
Algorithmen und Datenstrukturen
1.2.7.1 Laufzeitberechnungen („Big-O“-)
N
Ein einfaches Beispiel: Gegeben ist die folgende Funktion zur Berechnung von
∑i
3
i =1
public static int sum(int n)
{
/* 1 */ int teilSumme;
/* 2 */ teilSumme = 0;
/* 3 */ for (int i = 1; i <= n; i++)
/* 4 */
teilSumme += i * i * i;
/* 5 */ returm teilSumme;
}
Analyse zur Effizienz:
Zeile 1 und Zeile 2 zählen je einmal.
Zeile 4 zählt viermal (2 Multiplikationen, Zuweisung und 1. Additition) und
wird N-mal ausgeführt. Das ergibt 4N.
Zeile 3 zeigt die Deklaration und Initialisierung von i (zählt zweimal), den
Test i <= N und das Inkrementieren i++ (zählt jeweils N-mal).
Insgesamt führt das zu 2N + 2. Ignoriert man Aufruf und
Rückkehranweisung der Funktion erhält man 6N + 4. Man sagt dazu: Die
Funktion besitzt ein Leistungsverhalten von O(N).
Einiges kann bei der Abschätzung offensichtlich beschleunigt werden. In Zeile 3 steht
bspw. eine O(1)-Anweisung. Es ist egal (für die Abschätzung der
Laufzeitberechnung), ob bei der Ausführung diese Anweisung 2fach oder 3fach
gezählt wird. Auch bzgl. der Schleife ist der Faktor 2 und die Addition von 2
unerheblich.
Das führt zu folgenden Regeln zur Abschätzung des Leistungsverhaltens nach der
„Big-O“-Notation:
1. Regel (für Schleifen): Die Laufzeit einer Schleife ist im wesentlichen bestimmt
durch die Anzahl der Anweisungen innerhalb des Schleifenkörpers multipliziert mit
der Anzahl der Iterationen.
2. Regel (für verschachtelte Schleifen): Die Laufzeit einer Anweisung innerhalb einer
Gruppe verschachtelter Schleifen ist bestimmt durch die Laufzeit der Anweisung
multipliziert mit dem Produkt aller Schleifengrößen.
Bsp.:
for (i=1; i <= n; i++)
for (j = 1; j <= n; j++)
k++;
ist einzuordnen unter O(N2).
3. Regel (aufeinanderfolgende Anweisungen): Der größte Wert zählt für das
Leistungsverhalten.
Bsp.:
for (int i=1; i <= n; i++)
a[i] = 0;
// O(N)
for (int i=1; i <= n; i++)
for (int j=1; j <= n; j++)
a[i] += i + j;
// O(N2)
Insgesamt ergibt sich das Leistungsverhalten O(N2).
4. Regel: Die Laufzeit einer „if“-Anweisung ist niemals größer als die Laufzeit des
Tests plus der größeren Laufzeit vom „ja“- bzw. „nein“-Zweig.
28
Algorithmen und Datenstrukturen
Rekursionen können häufig auf einfache Schleifen mit dem Leistungsverhalten O(N)
zurückgeführt werden, z.B.:
public static long fakultaet(int n)
{
if (n <= 1) return 1;
else return n * fakultaet(n-1);
Liegen in einer Funktion mehrere rekursive Aufrufe vor, dann ist die Umsetzung in
eine einfache Schleifenstruktur nicht so einfach.
Bsp.:
public static long fib(int n)
{
/* 1 */ if (n <= 1)
/* 2 */ return 1;
else
/* 3 */ returm fib(n-1) + fib(n-2);
}
Die Analyse ergibt unter der Annahme, daß T(N) die Laufzeit nach einem Aufruf von
fib(n) ist: Für N = 0, N = 1 ist T(0) = T(1) = 1 (irgendein konstanter Wert. In
Zeile 3 wird fib(N-1) aufgerufen, was eine Laufzeit von T(N-1) bewirkt.
Anschließend wird fib(N-2) aufgerufen, was eine Laufzeit von T(N-2) bewirkt.
Zusammengezählt ergibt das: T(N) = T(N-1) + T(N-2) + 2. Da fib(N) =
fib(N-1) + fib(N-2) ist, kann leicht gezeigt werden, daß T(N) >= fib(N) ist.
5
Man kann zeigen: fib( N ) < ( ) N . Das bedeutet: Die Laufzeit dieses Programms
3
wächst exponentiell (schlechter geht es nicht mehr).
Man kann häufig dasselbe Problem mit verschieden Algorithmen lösen. Das Ziel ist
natürlich, den für das Problem besten Algorithmus zu finden bzw. zu implementieren:
Bsp.: Das Maximum-Subarray-Problem
Gegeben ist eine Folge X von N ganzen Zahlen in einem Array. Gesucht ist die maximale Summe
aller zusammenhängenden Teilfolgen. Sie wird als maximale Teilsumme bezeichnet.
So ist für die Eingabefolge
X[0]
31
X[1]
-41
X[2]
59
X[3]
26
X[4]
-53
X[5]
58
X[6]
97
X[7]
-93
X[8]
-23
X[9]
84
die Summe der Teilfolgen X[2] + X[3] + X[4] + X[5] + X[6] mit dem Wert (59 + 26 – 53 + 58 + 97) = 187
die Lösung des Problems. Lösungen zu diesem Problem können auf verschiedene Weise erreicht
werden:
1. Lösung
public
{
/* 1
/* 2
/* 3
/*
/*
/*
/*
/*
4
5
6
7
8
/* 9
static int
maxSubsum1(int a[])
*/ int maxSumme = 0;
*/ for (int i = 0; i < a.length;i++)
*/ for (int j = i; j < a.length; j++)
{
*/
int summe = 0;
*/
for (int k = i; k <= j; k++)
*/
summe += a[k];
*/
if (summe > maxSumme)
*/
maxSumme = summe;
}
*/ return maxSumme;
29
Algorithmen und Datenstrukturen
}
N
Die Analyse des Leistungsverhaltens wird bestimmt durch
N
j
∑∑∑1 = O( N
3
) . Diese Summe
i =1 j =i k =i
berechnet, wieviele Male Zeile 6 ausgeführt wird.
2. Lösung
public static int maxSubsum2(int a[])
{
/* 1 */ int maxSumme = 0;
/* 2 */ for (int i = 0; i < a.length;i++)
{
/* 3 */ int summe = 0;
/* 4 */ for (int j = i; j < a.length; j++)
{
/* 5 */
summe += a[j];
/* 6 */
if (summe > maxSumme)
/* 7 */
maxSumme = summe;
}
}
/* 8 */ return maxSumme;
}
2
In dieser Lösung ist das Leistungsverhalten auf O(N ) reduziert.
3. Lösung
Diese Lösung folgt der „Divide-and-Conquer“-Strategie, die ein sehr allgemeines und mächtiges
Prinzip zur algorithmischen Lösung von Problemen darstellt. Das zugehörige Problemlösungsschema
kann allg. so formuliert werden:
1. Divide: Teile das Problem der Größe N in (wenigstens) 2 annähernd gleich große Teilprobleme,
wenn N > 1 ist, sonst löse das Problem der Größe 1 direkt.
2. Conquer: Löse die Teilprobleme auf dieselbe Art.
3. Merge: Füge die Teillösungen zur Gesamtlösung zusammen.
Abb.: Divide and Conquer-Verfahren zur Lösung eines Problems der Größe N
Bei der Anwendung dieses Algorithmus auf das vorliegende Problem bewirkt das Teilen der Folge in
Teilfolgen evtl. das Trennen der Teilfolge mit der größten Teilsumme, z.B.: Bei der Vorgabe
a[0]
4
a[1]
-3
a[2]
5
1. Häfte
a[3]
-2
a[4]
-1
a[5]
2
a[6]
6
2. Hälfte
a[7]
-2
ist die größte Teilsumme in der ersten Teilhälfte 6 (a[0] + a[1] + a[2]), die größte Teilsumme in der
zweiten Teilhälfte ist 8 (a[5] + a[6]). Die maximale Summe in der 1. Hälfte, die das letzte Element in
der 1. Hälfte mit einschließt (a[0] + a[1] + a[2] + a[3]) ist 4. Die maximale Summe in der 2. Hälfte, die
das erste Element in der 2. Hälfte einschließt ist 7. Die maximale Summe, die beide Hälften
überspannt ist 4 + 7 = 11. Der Algorithmus muß demnach Teilsummenbildungen über die jeweilige
Teilhälften berücksichtigen.
private static int maxSubsum(int
{
/* 1 */
if (links == rechts)
/* 2 */
if (a[links] > 0)
/* 3 */
return a[links];
/* 4 */
else return 0;
/* 5 */
int mitte = (links +
/* 6 */
int maxLinkeSumme =
/* 7 */
int maxRechteSumme =
a[], int links, int rechts)
// Falls dieses Element positiv ist
// dann ist es die max.Teilsumme
rechts) / 2;
maxSubsum(a, links, mitte);
maxSubsum(a, mitte + 1, rechts);
30
Algorithmen und Datenstrukturen
/* 8 */
int maxLinkeGrenzSumme
/* 9 */
for (int i = mitte; i >= links; i--)
{
linkeGrenzsumme += a[i];
if (linkeGrenzsumme > maxLinkeGrenzSumme)
maxLinkeGrenzSumme = linkeGrenzsumme;
}
int maxRechteGrenzSumme = 0, rechteGrenzsumme = 0;
for (int i = mitte + 1; i <= rechts; i++)
{
rechteGrenzsumme += a[i];
if (rechteGrenzsumme > maxRechteGrenzSumme)
maxRechteGrenzSumme = rechteGrenzsumme;
}
if (maxLinkeSumme > maxRechteSumme)
if (maxLinkeSumme > (maxRechteGrenzSumme + maxLinkeGrenzSumme))
return maxLinkeSumme;
else return (maxRechteGrenzSumme + maxLinkeGrenzSumme);
else if (maxRechteSumme > (maxRechteGrenzSumme +
maxLinkeGrenzSumme) )
return maxRechteSumme;
else return (maxRechteGrenzSumme + maxLinkeGrenzSumme);
/*10 */
/*11 */
/*12 */
/*13 */
/*14 */
/*15 */
/*16 */
/*17 */
/*18 */
= 0, linkeGrenzsumme
= 0;
}
public static int maxSubsum3(int a[])
{
return maxSubsum(a, 0, a.length - 1);
}
Die Anwendung des Lösungsverfahrens auf das
Implementierung mit dem Leistungsverhalten O(NlogN).
Max-Subarray-Problem
führt
zu
einer
4. Lösung: Implementierung mit dem Leistungsverhalten O(N)
Die Positionen 0,..,N-1 der Eingabefolge bilden eine aufsteigend sortierte, lineare Folge von
Inspektionsstellen (oder: Ereignispunkten). Man durchläuft die Eingabe in der durch die
Inspektionsstelleb vorgegebenen Reihenfolge und führt zugleich eine vom jeweiligen Problem
abhängige, dynamisch veränderliche, d.h. an jeder Informationsstelle gegebenenfalls zu korrigierende
Information mit. Im vorliegenden Fall ist das die maximale Summe einer Teilfolge (maxSumme) im
gesamten bisher inspizierten Anfangsteil und das an der Inspektionstelle endende rechte
Randmaximum (summe) des bisher inspizierten Anfangsstücks.
public static int maxSubsum4(int a[])
{
/* 1 */ int maxSumme = 0, summe = 0;
/* 2 */ for (int j = 0; j < a.length; j++)
{
/* 3 */
summe += a[j];
/* 4 */
if (summe > maxSumme)
/* 5 */
maxSumme = summe;
/* 6 */
else if (summe < 0)
/* 7 */
summe = 0;
}
/* 8 */ return maxSumme;
}
Das ist ein Algorithmus, der in linearer Zeit ausführbar ist. Zur Bestimmung der maximalen Teilfolge
müssen alle Folgeelemente wenigstens einmal betrachtet werden. Das sind insgesamt N Schritte.
31
Algorithmen und Datenstrukturen
1.2.7.2 O(logN)-Algorithmen
Gelingt es die Problemgröße in konstanter Zeit (O(1)) zu halbieren, dann zeigt der
zugehörige Algorithmus das Leistungsverhalten O(logN)). Nur spezielle Probleme
können dieses Leistungsverhalten erreichen.
Binäre Suche
Aufgabe: Gegeben ist eine Zahl X und eine sortiert vorliegenden Folge von
Ganzzahlen A0, A1, A2, ... , AN-1 im Arbeitsspeicher. Finde die Position i so, daß Ai=X
bzw. gib i=-1 zurück, wenn X nicht gefunden wurde.
Implementierung
public static int binaereSuche(Comparable a[], Comparable x)
{
/* 1 */ int links = 0, rechts = a.length - 1;
/* 2 */ while (links < rechts)
{
/* 3 */ int mitte = (links + rechts) / 2;
/* 4 */ if (a[mitte].compareTo(x) < 0)
/* 5 */
links = mitte + 1;
/* 6 */ else if (a[mitte].compareTo(x) > 0)
/* 7 */
rechts = mitte - 1;
else
/* 8 */
return mitte;
// Gefunden
}
/* 9 */ return -1;
// Nicht gefunden
}
Leistungsanalyse: Entscheidend für das Leistungsverhalten ist die Schleife (/* 2 */.
Sie beginnt mit (rechts – links) = N-1 und endet mit (rechts – links) =
-1. Bei jedem Schleifendurchgang muß (rechts – links) halbiert werden. Ist
bspw. (rechts – links) = 128, dann sind die maximalen Werte nach jeder
Iteration: 64, 32, 16, 8, 4, 2, 1, 0, -1. Die Laufzeit läßt sich demnach in der
Größenordnung O(logN) sehen.
Die binäre Suche ist eine Implementierung eines Algorithmus für eine Datenstruktur
(sequentiell gespeicherte Liste, Array). Zum Aufsuchen von Datenelementen wird
eine Zeit von O(logN) verbraucht. Alle anderen Operationen (z.B. Einfügen) nehmen
ein Leistungsverhalten von O(N) in Anspruch.
1.2.7.3 Berechnungsgrundlagen für rechnerische Komplexität
System-Effizienz und rechnerische Effizienz
Effiziente Algorithmen zeichnen sich aus durch
- schnelle Bearbeitungsfolgen (Systemeffizienz) auf unterschiedliche Rechnersystemen. Hier wird die
Laufzeit der diversen Suchalgorithmen auf dem Rechner (bzw. verschiedene Rechnersysteme)
ermittelt und miteinander verglichen. Die zeitliche Beanspruchung wird über die interne Systemuhr
gemessen und ist abhängig vom Rechnertyp
- Inanspruchnahme von möglichst wenig (Arbeits-) Speicher
- Optimierung wichtiger Leistungsmerkmale, z.B. die Anzahl der Vergleichsbedingungen, die Anzahl
der Iterationen, die Anzahl der Anweisungen (, die der Algorithmus benutzt). Die
32
Algorithmen und Datenstrukturen
Berechnungskriterien bestimmen die sog. rechnerische Komplexität in einer Datensammlung. Man
spricht auch von der rechnerischen Effizienz.
P- bzw. NP-Probleme: Von besonderem Interesse für die Praxis ist der Unterschied
zwischen Problemen mit polynomialer Laufzeit (d.h. T ( N ) = O ( p ( N )) , p = Polynom in
N) und solchen mit nicht polynomialer Laufzeit. Probleme mit polynomialer Laufzeit
nennt man leicht, alle übrigen Probleme heißen hart (oder unzugänglich). Harte
Probleme sind praktisch nicht mehr (wohl aber theoretisch) algorithmisch lösbar,
denn selbst für kleine Eingaben benötigt ein derartiger Algorithmus Rechenzeit, die
nicht mehr zumutbar ist und leicht ein Menschenalter überschreitet 24.
Viele wichtige Problemlösungsverfahren liegen in dem Bereich zwischen leichten und
harten Problemen.. Man kann nicht zeigen, dass diese Probleme leicht sind, denn es
gibt für sie keinen Polynomialzeit-Algorithmus. Umgekehrt kann man auch nicht
sagen, dass es sich um harte Probleme handelt. Der Fakt, dass kein PolynomialzeitAlgorithmus gefunden wurde, schließt die Existenz eines solchem Algorithmus nicht
aus. Möglicherweise hat man sich bei der Suche danach bisher noch nicht klug
genug angestellt. Es wird dann nach seit Jahrzenten erfogloser Forschung
angenommen, dass es für diese Probleme keine polynomiellen Algorithmen gibt.
Man spricht in diesem Fall von der Klasse der sog. NP-vollständigen Probleme. 25
Es ist heute allgemeine Überzeugung, daß höchstens solche Algorithmen praktikabel
sind, deren Laufzeit durch ein Polynom in der Problemgröße beschränkt bleibt.
Algorithmen, die exponentielle Schrittzahl erfordern, sind schon für relativ kleine
Problemgrößen nicht mehr ausführbar.
Berechnungsgrundlagen für rechnerische Komplexität: Generell kann man für
Algorithmen folgende Grenzfälle bzgl. der Rechenbarkeit beobachten:
- kombinatorische Explosion
Es gibt eine Reihe von klassischen Problemen, die immer wieder in der Mathematik oder der DVLiteratur auftauchen, weil sie knapp darzustellen und im Prinzip einfach zu verstehen sind. Manche
von ihnen sind nur von theoretischen Interesse, wie etwa die Türme von Hanoi.
Ein anderes klassisches Problem ist dagegen das Problem des Handlungsreisenden 26 (Travelling
Salesman Problem, TSP). Es besteht darin, daß ein Handlungsreisender eine Rundreise zwischen
einer Reihe von Städten machen soll, wobei er am Ende wieder am Abfahrtort ankommt. Dabei will er
den Aufwand (gefahrene Kilometer, gesamte Reisezeit, Eisenbahn- oder Flugkosten, je nach dem
jeweiligen Optimierungswunsch) minimieren. So zeigt bspw. die folgende Entfernungstabelle die zu
besuchenden Städte und die Kilometer zwischen ihnen:
München
Frankfurt
Heidelberg
Karlsruhe
Mannheim
Frankfurt
395
-
Heidelberg
333
95
-
Karlsruhe
287
143
54
-
Mannheim
347
88
21
68
-
Wiesbaden
427
32
103
150
92
Grundsätzlich (und bei wenigen Städten, wie in diesem Bsp., auch tatsächlich) ist die exakte Lösung
dieser Optimierungsaufgabe mit einem trivialen Suchalgorithmus zu erledigen. Man rechnet sich
einfach die Route der Gesamtstrecke aus und wählt die kürzeste.
Der benötigte Rechenaufwand steigt mit der Zahl N der zu besuchenden Städte sprunghaft an. Erhöht
man bspw. N von 5 auf 10, so verlängert sich die Rechenzeit etwa auf das „dreißigtausendfache“.
Dies nennt man kombinatorische Explosion, weil der Suchprozeß jede mögliche Kombination der
24
vgl. Abb. 1.2-2
nichtdeterministisch polynomial
26 Vorbild für viele Optimierungsaufgaben, wie sie vor allem im Operations Research immer wieder
vorkommen.
25
33
Algorithmen und Datenstrukturen
für das Problem relevanten Objekte einzeln durchprobieren muß. Der Aufwand steigt proportional zur
Fakultät (N!).
- exponentielle Explosion
Wie kann man die vollständige Prüfung aller möglichen Kombinationen und damit die kombinatorische
Explosion umgehen? Naheliegend für das TSP ist, nicht alle möglichen Routen zu berechnen und erst
dann die optimale zu suchen, sondern sich immer die bis jetzt beste zu merken und das Ausprobieren
einer neuen Wegkombination sofort abzubrechen, wenn bereits eine Teilstrecke zu größeren
Kilometerzahlen führt als das bisherige Optimum, z.B.:
Route 1
München
Karlsruhe
Heidelberg
Mannheim
Wiesbaden
Frankfurt
München
Streckensumme
0
287
341
362
454
486
881
Route 2
München
Wiesbaden
Karlsruhe
Frankfurt
Heidelberg
Streckensumme
0
429
722
865
960
Route 2 kann abgebrochen werden, weil die Teilstrecke der Route 2 (960) bereits länger ist als die
Gesamtstrecke der Route 1. Diese Verbesserung vermeidet die kombinatorische Explosion, ersetzt
sie aber leider nur durch die etwas schwächere exponentielle Explosion. Die Rechenzeit nimmt
exponetiell, d.h. mit aN für irgendeinen problemspezifischen Wert zu. Im vorliegenden Fall ist a etwa
1.26.
- polynomiales Zeitverhalten
In der Regel ist polynomiales Zeitverhalten das beste, auf das man hoffen kann. Hiervon redet man,
wenn man die benötigte Rechenzeit durch ein Polynom T = a n N + ... + a 2 N + a1 N + a 0
n
2
ausgedrückt werden. „N“ ist bestimmt durch die zu suchenden problemspezifischen Werte, n
beschreibt den Exponenten. Da gegen das erste Glied mit der höchsten Potenz bei größeren
Objektzahlen alle anderen Terme des Ausdrucks vernachlässigt werden können, klassifiziert man das
polynomiale Zeitverhalten nach dieser höchsten Potenz. Man sagt, ein Verfahren zeigt polynomiales
Zeitverhalten O(Nn), falls die benötigte Rechenzeit mit der nten Potenz der Zahl der zu bearbeitenden
Objekte anwächst.
Die einzigen bekannten Lösungen des TSP, die in polynomialer Zeit ablaufen, verzichten darauf, unter
allen Umständen die beste Lösung zu finden, sondern geben sich mit einer recht guten Lösung
zufrieden. In der Fachsprache wird das so ausgedrückt, daß das TSP NP-vollständig sei. Das
bedeutet: In polynomialer Zeit kann nur eine nichtdeterministische Lösung berechnet werden, also
eine, die nicht immer deterministisch ein und dasselbe (optimale) Ergebnis findet.
Ein Verfahren, für das nicht garantiert werden kann, daß es in allen Fällen ein exaktes Resultat liefert,
wird heuristisch genannt. Eine naheliegende heuristische Lösung für das TSP ist der „Nächste
Nachbarn-Algorithmus“. Er beginnt die Route mit der Stadt, die am nächsten zum Ausgangsort liegt
und setzt sie immer mit derjenigen noch nicht besuchten Stadt fort, die wiederum die nächste zum
jeweiligen Aufenthaltsort ist. Da in jeder der N Städte alle (d.h. im Durchschnitt (N-1)/2) noch nicht
besuchte Orte nach dem nächsten benachbarten durchsucht werden müssen, ist der Teitaufwand für
2
das Durchsuchen proportional N ⋅ ( N − 1) / 2 , d.h. O(N ), und damit polynomial „in quadratischer
Zeit“
Die meisten Algorithmen für Datenstrukturen bewegen sich in einem schmalen Band
rechnerischer Komplexität. So ist ein Algorithmus von der Ordnung O(1) unabhängig
von der Anzahl der Datenelemente in der Datensammlung. Der Algorithmus läuft
unter konstanter Zeit ab, z.B.: Das Suchen des zuletzt in eine Schlange eingefügten
Elements bzw. die Suche des Topelements in einem Stapel.
34
Algorithmen und Datenstrukturen
Ein Algorithmus mit dem Verhalten O(N) ist linear. Zeitlich verhält er sich proportional
zur Größe der Liste.
Bsp.: Das Bestimmen des größten Elements in einer Liste. Es sind N Elemente zu
überprüfen, bevor das Ende der Liste erkannt wird.
Andere Algorithmen zeigen „logarithmisches Verhalten“. Ein solches Verhalten läßt
sich beobachten, falls Teildaten die Größe einer Liste auf die Hälfte, ein Viertel, ein
Achtel... reduzieren. Die Lösungssuche ist dann auf die Teilliste beschränkt.
Derartiges Verhalten findet sich bei der Behandlung binärer Bäume bzw. tritt auf
beim „binären Suchen“. Der Algorithmus für binäre Suche zeigt das Verhalten
O(log 2 N ) , Sortieralgorithmen wie der Quicksort und Heapsort besitzen eine
rechnerische Komplexität von O( N log 2 N ) . Einfache Sortierverfahren (z.B. „BubbleSort“) bestehen aus Algorithmen mit einer Komplexität von O( N 2 ) . Sie sind deshalb
auch nur für kleine Datenmengen brauchbar. Algorithmen mit kubischem Verhalten
O( N 3 ) sind bereits äußerst langsam (z.B. der Algorithmus von Warshall zur
Bearbeitung von Graphen). Ein Algorithmus mit einer Komplexität von O( 2 N ) zeigt
exponentielle Komplexität. Ein derartiger Algorithmus ist nur für kleine N auf einem
Rechner lauffähig.
1.3 Daten und Datenstrukturen
1.3.1 Datentyp
Ein Algorithmus verarbeitet Daten. Ein Datentyp soll gleichartige
zusammenfassen und die nötigen Basisoperationen zur Verfügung stellen.
Ein Datentyp ist durch 2 Angaben festgelegt:
Daten
1. Eine Menge von Daten (Werte)
2. Eine Menge von Operationen auf diesen Daten
Ein Datentyp 27ist demnach eine Zusammenfassung von Wertebereichen und
Operationen zu einer Einheit.
Eine passende Abstraktion für Datentypen sind Algebren. Eine Algebra ist eine
Wertemenge plus Operation auf diesen werten. Ein typisches Beispiel für dieses
Konzept sind die natürlichen Zahlen mit den Operationen +, -, * ,%, etc.
Wertemengen eines Datentyps werden in der Informatik als Sorten bezeichnet. Die
Operationen eines Datentyps entsprechen Funktionen und werden durch
Algorithmen realisiert, In der Regel liegt eine mehrwertige Algebra vor, also eine
Algebra mit mehreren Sorten als Wertebereiche.
Bsp.:Natürliche Zahlen plus Wahrheitswerte mit den Operationen +, -, *, % auf
zahlen, ¬, ∧, ∨ ,... auf Wahrheitswerten und =, <, > , ≤,... als Verbindung zwischen den
Sorten.
Signatur von Datentypen. Darunter versteht man eine Formularisierung der
Schnittstellenbeschreibung eines Datentyps. Sie besteht aus Angaben der Namen
der Sorten und der Operationen. Die Operationen werden neben dem Bezeichner der
Operation auch die Stelligkeit der Operanden und der Sorten der einzelnen
27
Vgl. Skriptum Programmieren in Java WS 2005 / 2006: 1.3.4, 1.4.1.3, 2.3
35
Algorithmen und Datenstrukturen
Parameter angegeben. Die Konstanten werden als O-stellige Operationen realisert,
z.B 28.:
typ nat
sorts nat, boll
functions
0 -> nat
succ : nat -> nat
+ : nat x nat -> nat
<= : nat x nat -> bool
…..
Datentypen in Java: Datentypen spielen eine wichtige Rolle, da hier eine strenge
Typisierung realisiert ist.
Datentyp
Einfacher
(elementarer, primitiver)
Typ
Wahrheitswerte
Zeichentyp
- boolean
-char
Referenztyp
numerischer Typ
Klassentyp
Array-Typ
- Integer-Typ -Gleitpunkt-Typ
-- byte
-- float
-- short
-- double
-- int
-- long
Abb. 1.3-1: Datentypen in Java
28
Das Beispiel ist angelehnt an die algebraische Spezifikation von Datenstrukturen
36
Schnittstellentyp
Algorithmen und Datenstrukturen
1.3.2 Datenstruktur
Komplexe Datentypen, sog. Datenstrukturen, werden durch Kombination primitiver
Datentypen gebildet. Sie besitzen selbst spezifische Operationen.
Eine Datenstruktur ist ein Datentyp und dient zur Organisation von Daten zur
effizienten Unterstützung bestimmter Operationen.
Betrachtet wird ein Ausschnitt aus der realen Welt, z.B. die Hörer dieser Vorlesung
an einem bestimmten Tag:
Juergen
Josef
Liesel
Maria
........
Regensburg
.........
.........
.........
.........
Bad Hersfeld
.........
.........
.........
.........
13.11.70
........
........
........
........
Friedrich-. Ebertstr. 14
..........
..........
..........
..........
Diese Daten können sich zeitlich ändern, z.B. eine Woche später kann eine
veränderte Zusammensetzung der Zuhörerschaft vorliegen. Es ist aber deutlich
erkennbar: Die Modelldaten entsprechen einem zeitinvarianten Schema:
NAME
WOHNORT
GEBURTSORT
GEB.-DATUM
STRASSE
Diese Feststellung entspricht einem Abstraktionsprozeß und führt zur Datenstruktur.
Sie bestimmt den Rahmen (Schema) für die Beschreibung eines Datenbestandes.
Der Datenbestand ist dann eine Ansammlung von Datenelementen (Knoten), der
Knotentyp ist durch das Schema festgelegt.
Der Wert eines Knoten k ∈ K wird mit wk bezeichnet und ist ein n ≥ 0 -Tupel von
Zeichenfolgen; w i k bezeichnet die i-te Komponente des Knoten. Es gilt
wk = ( w1k , w2 k ,...., wn k )
Die Knotenwerte des vorstehenden Beispiels sind:
wk1 = (Jürgen____,Regensburg,Bad Hersfeld,....__,Ulmenweg__)
wk2 = (Josef_____,Straubing_,......______,....__,........__)
wk3 = (Liesel____,....._____,......______,....__,........__)
..........
wkn = (__________,__________,____________,______,__________)
Welche Operationen sind mit dieser Datenstruktur möglich?
Bei der vorliegenden Tabelle sind z.B. Zugriffsfunktionen zum Einfügen, Löschen
und Ändern eines Tabelleneintrages mögliche Operationen. Generell bestimmen
Datenstrukturen auch die Operationen, die mit diesen Strukturen ausgeführt werden
dürfen.
Zusammenhänge zwischen den Knoten eines Datenbestandes lassen sich mit Hilfe
von Relationen bequem darstellen. Den vorliegenden Datenbestand wird man aus
Verarbeitungsgründen bspw. nach einem bestimmten Merkmal anordnen (Ordnungsrelation). Dafür steht hier (im vorliegenden Beispiel) der Name der Studenten:
37
Algorithmen und Datenstrukturen
Josef
Juergen
Liesel
Abb. 1.3-2: Einfacher Zusammenhang zwischen Knoten eines Datenbestandes
Datenstrukturen bestehen also aus Knoten(den einzelnen Datenobjekten) und
Relationen (Verbindungen). Die Verbindungen bestimmen die Struktur des
Datenbestandes.
Bsp.:
1. An Bayerischen Fachhochschulen sind im Hauptstudium mindestens 2
allgemeinwissenschaftliche Wahlfächer zu absolvieren. Zwischen den einzelnen
Fächern, den Dozenten, die diese Fächer betreuen, und den Studenten bestehen
Verbindungen. Die Objektmengen der Studenten und die der Dozenten ist nach den
Namen sortiert (geordnet). Die Datenstruktur, aus der hervorgeht, welche
Vorlesungen die Studenten bei welchen Dozenten hören, ist:
38
Algorithmen und Datenstrukturen
STUDENT
FACH
DATEN
DOZENT
DATEN
DATEN
DATEN
DATEN
DATEN
DATEN
DATEN
DATEN
DATEN
geordnet
(z.B. nach Matrikelnummern)
geordnet
(z.B. nach Titel
im Vorlesungsverzeichnis)
geordnet
(z.B. nach Namen)
Abb. 1.3-3: Komplexer Zusammenhang zwischen den Knoten eines Datenbestands
2. Ein Gerät soll sich in folgender Form aus verschiedenen Teilen zusammensetzen:
Anfangszeiger Analyse
Anfangszeiger Vorrat
G1, 5
B2, 4
B1, 3
B3, 2
B4, 1
Abb. 1.3-4: Darstellung der Zusammensetzung eines Geräts
39
Algorithmen und Datenstrukturen
2 Relationen können hier unterschieden werden:
1) Beziehungsverhältnisse eines Knoten zu seinen unmittelbaren Nachfolgeknoten. Die Relation
Analyse beschreibt den Aufbau eines Gerätes
2) Die Relation Vorrat gibt die Knoten mit w2k <= 3 an.
Die Beschreibung eines Geräts erfordert in der Praxis eine weit komplexere
Datenstruktur (größere Knotenzahl, zusätzliche Relationen).
3. Eine Bibliotheksverwaltung soll angeben, welches Buch welcher Student entliehen
hat. Es ist ausreichend, Bücher mit dem Namen des Verfassers (z.B. „Stroustrup“)
und die Entleiher mit ihrem Vornamen (z.B. „Juergen“, „Josef“) anzugeben. Damit
kann die Bibliotheksverwaltung Aussagen, z.B. „Josef hat Stroustrup ausgeliehen“
oder „Juergen hat Goldberg zurückgegeben“ bzw. Fragen, z.B. „welche Bücher hat
Juergen ausgeliehen?“, realisieren. In die Bibliothek sind Objekte aufzunehmen, die
Bücher repäsentieren, z.B.:
Buch
„Stroustrup“
Weiterhin muß es Objekte geben, die Personen repräsentieren, z.B.:
Person
„Juergen“
Falls „Juergen“ Stroustrup“ ausleiht, ergibt sich folgende Darstellung:
Person
„Juergen“
Buch
„Stroustrup“
Abb. 1.3-5: Objekte und ihre Beziehung in der Bibliotheksverwaltung
Der Pfeil von „Stroustrup“ nach „Juergen“ zeigt: „Juergen“ ist der Entleiher von „Stroustrup“, der Pfeil
von „Juergen“ nach „Stroustrup“ besagt: „Stroustrup“ ist eines der von „Juergen“ entliehenen Bücher.
Für mehrere Personen kann sich folgende Darstellung ergeben:
40
Algorithmen und Datenstrukturen
Person
Person
„Juergen“
Person
„Josef“
Buch
Buch
„Stroustrup“
„Goldberg“
Buch
„Lippman“
Abb. 1.3-6: Objektverknüpfungen in der Bibliotheksverwaltung
Zur Verbindung der Klasse „Person“ bzw. „Buch“ wird eine Verbindungsstruktur benötigt:
Person
buecher =
Verbindungsstruktur
„Juergen“
Buch
„Stroustrup“
Abb. 1.3-7: Verbindungsstruktur zwischen den Objekttypen „Person“ und „Buch“
Ein bestimmtes Problem kann auf vielfätige Art in Rechnersystemen abgebildet
werden. So kann das vorliegende Problem über verkettete Listen im Arbeitsspeicher
oder auf Externspeicher (Dateien) realisiert werden.
Die vorliegenden Beispiele können folgendermaßen zusammengefaßt werden:
Die Verkörperung einer Datenstruktur wird durch das Paar D = (K,R) definiert.
K ist die Knotenmenge (Objektmenge) und R ist eine endliche Menge von binären
Relationen über K.
41
Algorithmen und Datenstrukturen
1.3.3 Relationen und Ordnungen
Relationen
Zusammenhänge zwischen den Knoten eines Datenbestandes lassen sich mit Hilfe
von Relationen bequem darstellen.
Eine Relation ist bspw. in folgender Form gegeben:
R = {(1,2),(1,3),(2,4),(2,5),(2,6),(3,5),(3,7),(5,7),(6,7)}
Diese Relation bezeichnet eine Menge geordneter Paare oder eine Produktmenge
M × N . Sind M und N also Mengen, dann nennt man jede Teilmenge M × N eine
zweistellige oder binäre Relation über M × N (oder nur über M , wenn M = N ist).
Jede binäre Relation auf einer Produktmenge kann durch einen Graphen dargestellt
werden, z.B.:
1
3
2
5
6
4
7
Abb. 1.3-8: Ein Graph zur Darstellung einer binären Relation
Bsp.: Gegeben ist S (eine Menge der Studenten) und V (eine Menge von
Vorlesungen). Die Beziehung ist: x ∈ S hört y ∈V . Diese Beziehung kann man durch
die Angabe aller Paare ( x , y ) beschreiben, für die gilt: Student x hört Vorlesung y .
Jedes dieser Paare ist Element des kartesischen Produkts S × V der Mengen S und
V.
Für Relationen sind aus der Mathematik folgende Erklärungen bekannt:
1. Vorgänger und Nachfolger
R ist eine Relation über der Menge M.
Gilt ( a , b ) ∈ R , dann sagt man: „a ist Vorgänger von b, b ist Nachfolger von a“.
Zweckmäßigerweise unterscheidet man in diesem Zusammenhang auch den
Definitions- und Bildbereich
Def(R) = { x | ( x , y ) ∈ R }
Bild(R) = { y | ( x , y ) ∈ R }
2. Inverse Relation (Umkehrrelation)
42
Algorithmen und Datenstrukturen
Relationen sind umkehrbar. Die Beziehungen zwischen 2 Grössen x und y können auch als
Beziehung zwischen y und x dargestellt werden, z.B.: Aus „x ist Vater von y“ wird durch Umkehrung „y
ist Sohn von x“.
Allgemein gilt:
R-1 = { (y,x) | ( x , y ) ∈ R }
3. Reflexive Relation
∀ ( x , x ) ∈ R (Für alle Elemente x aus M gilt, x steht in Relation zu x)
x ∈M
Beschreibt man bspw. die Relation "... ist Teiler von ..." für die Menge M = {2,4,6,12} in einem Grafen,
so erhält man:
12
4
6
2
Abb. 1.3-9: Die binäre Relation „... ist Teiler von ... “
Alle Pfeile, die von einer bestimmten Zahl ausgehen und wieder auf diese Zahl verweisen, sind
Kennzeichen einer reflexiven Relation ( in der Darstellung sind das Schleifen).
Eine Relation, die nicht reflexiv ist, ist antireflexiv oder irreflexiv.
4. Symmetrische Relation
Aus (( ( x , y ) ∈ R ) folgt auch (( ( y , x ) ∈ R ).
Das läßt sich auch so schreiben: Wenn ein geordnetes Paar (x,y) der Relation R angehört, dann
gehört auch das umgekehrte Paar (y,x) ebenfalls dieser Relation an.
Bsp.:
a) g ist parallel zu h
h ist parallel zu g
b) g ist senkrecht zu h
h ist senkrecht zu g
5. Asymmetrische Relation
Solche Relationen sind auch aus dem täglichen Leben bekannt. Es gilt bspw. „x ist Vater von y“ aber
nicht gleichzeitig „y ist Vater von x“.
Eine binäre Relation ist unter folgenden Bedingungen streng asymetrisch:
∀ ( x , y ) ∈ R → (( y , x ) ∉ R )
( x , y )∈R
43
Algorithmen und Datenstrukturen
Das läßt sich auch so ausdrücken: Gehört das geordnete Paar (x,y) zur Relation, so gehört das
entgegengesetzte Paar (y,x) nicht zur Relation.
Gilt für x <> y die vorstehende Relation und für x = y ∀( x , x ) ∈ R , so wird diese binäre Relation
"unstreng asymmetrisch" oder "antisymmetrisch" genannt.
6. Transitive Relation
Eine binäre Relation ist transitiv, wenn (( ( x , y ) ∈ R ) und (( ( y , z ) ∈ R ) ist, dann ist auch
(( ( x , z ) ∈ R ). x hat also y zur Folge und y hat z zur Folge. Somit hat x auch z zur Folge.
7. Äquivalenzrelation
Eine binäre Relation ist eine Äquivalenzrelation, wenn sie folgenden Eigenschaften entspricht:
- Reflexivität
- Transitivität
- Symmetrie
Bsp.: Die Beziehung "... ist ebenso gross wie ..." ist eine Äquivalenzrelation.
1. Wenn x1 ebenso groß ist wie x2, dann ist x2 ebenso groß wie x1. Die Relation ist symmetrisch.
2. x1 ist ebenso groß wie x1. Die Relation ist reflexiv.
3. Wenn x1 ebenso groß wie x2 und x2 ebenso gross ist wie x3, dann ist x1 ebenso groß wie x3. Die
Relation ist transitiv.
Klasseneinteilung
- Ist eine Äquivalenzrelation R in einer Menge M erklärt, so ist M in Klassen eingeteilt
- Jede Klasse enthält Elemente aus M, die untereinander äquivalent sind
- Die Einteilung in Klassen beruht auf Mengen M1, M2, ... , Mx, ... , My
Für die Teilmengen gilt:
Mx ∩ M y = 0
(2) M1 ∪ M 2 ∪....∪ M y = M
(1)
(3) Mx <> 0 (keine Teilmenge ist die leere Menge)
Bsp.: Klasseneinteilungen können sein:
- Die Menge der Studenten der FH Regensburg: Äquivalenzrelation "... ist im gleichen Semester
wie ..."
- Die Menge aller Einwohner einer Stadt in die Klassen der Einwohner, die in der-selben
Straße wohnen: Äquivalenzrelation ".. wohnt in der gleichen Strasse wie .."
Aufgabe
1. Welche der folgenden Relationen sind transitiv bzw. nicht transitiv?
1)
2)
3)
4)
5)
...
...
...
...
...
ist
ist
ist
ist
ist
der Teiler von ....
der Kamerad von ...
Bruder von ...
deckungsgleich mit ...
senkrecht zu ...
(transitiv)
(transitiv)
(transitiv)
(transitiv)
(nicht transitiv)
2. Welche der folgenden Relationen sind Aequivalenzrelationen?
1)
2)
3)
4)
...
...
...
...
gehört dem gleichen Sportverein an ...
hat denselben Geburtsort wie ...
wohnt in derselben Stadt wie ...
hat diesselbe Anzahl von Söhnen
44
Algorithmen und Datenstrukturen
Ordnungen
1. Halbordnung
Eine binäre Relation ist eine "Halbordnung", wenn sie folgende Eigenschaften
besitzt: "Reflexivität, Transitivität"
2. Strenge Ordnungsrelation
Eine binäre Relation ist eine "strenge Ordnungsrelation", wenn sie folgende Eigenschaft besitzt: "Transitivität, Asymmetrie"
3. Unstrenge Ordnungsrelation
Eine binäre Relation ist eine "unstrenge Ordnungsrelation", wenn sie folgende
Eigenschaften besitzt: Transitivität, unstrenge Asymmetrie
4. Totale Ordnungsrelation und partielle Ordnungsrelation
Tritt in der Ordnungsrelation x vor y auf, so verwendet man das Symbol < (x < y).
Vergleicht man die Abb. 1.2-9, so kann man für (1) schreiben: e < a < b < d und c < d
Das Element c kann man weder mit den Elementen e, a noch mit b in eine
gemeinsame Ordnung bringen. Daher bezeichnet man diese Ordnungsrelation als
partielle Ordnung (teilweise Ordnung).
Eine totale Ordnungsrelation enthält im Gegensatz dazu Abb. 1.2-9 in (2): e < a <
b<c<d
Kann also jedes Element hinsichtlich aller anderen Elemente geordnet werden, so ist
die Ordnungsrelation eine totale, andernfalls heißt sie partiell.
(1)
(2)
a
a
b
e
e
b
d
c
d
Abb.1.3-10: Totale und partielle Ordnungsrelationen
45
c
Algorithmen und Datenstrukturen
5. „Natürliche Ordnungsbeziehungen“ in Java
Das Comparable Interface aus dem Paket java.lang dient zum Herstellen
„natürlicher Ordnungsbeziehungen“:
/**************** Comparable.java *****************************
/** Das Interface deklariert eine Methode anhand der sich das
* das aufgerufene Objekt mit dem uebergebenen vergleicht.
* Fuer jeden vom Objekt abgeleiteten Datentyp muss eine solche
* Vergleichsklasse implementiert werden.
* Die Methode erzeugt eine Fehlermeldung, wenn "a" ein Objekt
* einer anderen Klasse als dieses Objekt ist.
*
* int compareTo(Comparable a)
*
liefert
0, wenn this == 0
*
liefert < 0, wenn this < a
*
liefert > 0, wenn this > a
*
*/
public interface Comparable
{
public int compareTo(Comparable a);
}
Seit dem JDK 1.2 wird das Comparable Interface bereits von vielen eingebauten
Klassen implementiert, etwa von String, Character, Double, usw. Die natürliche
Ordnung ergibt sich, indem man alle Elemente paarweise miteinander vergleicht und
dabei jeweils das kleinere vor der größere Element stellt.
Besitzt eine Klasse das Interface Comparable nicht, dann kann auch eine
Implementierung des Interface Comparator vorgesehen werden.
/**************** Comparator.java *****************************
/** Das Interface deklariert zwei Methoden zur Durchfuehrung von
* Vergleichen.
* Die Methode equals() führt auf den Rückgabewert true bzw. false,
* je nachdem ob this == o ist.
* compare() hat folgende Rückgabewerte:
* Falls das erste Element vor dem zweiten Element kommt,
* ist der Rückgabewert negativ.
* Falls das erste Element nach dem zweiten Element kommt,
* ist der Rückgabewert positiv
* Der Rückgabewert 0 signalisiert, dass die beiden Elemente an
* der gleichen Ordnungsposition eingeordnet werden.
*/
public interface Comparator
{
public int compare(Object element1, Object element2);
public boolean equals(Object o);
}
46
Algorithmen und Datenstrukturen
1.3.4 Klassifikation von Datenstrukturen
Eine Datenstruktur ist ein Datentyp mit folgenden Eigenschaften
1. Sie besteht aus mehreren Datenelementen. Diese können
-
atomare Datentypen oder
selbst Datenstrukturen sein
2. Sie setzt die Elemente durch eine Menge von Regeln (eine Struktur) in eine Beziehung (Relation).
Elementare Strukturrelationen
Menge
lineare Struktur (gerichtete 1:1-Relation)
Baum (hierarchisch)
(gerichtete 1 : n – Relation)
Graph (Netzwerk)
( n : m Relation)
Abb. 1.3.-11: Elementare Datenstrukturen
Eine Datenstruktur ist durch Anzahl und Eigenschaften der Relationen bestimmt.
Obwohl sehr viele Relationstypen denkbar sind, gibt es nur 4 fundamentale
Datenstrukturen 29, die immer wieder verwendet werden und auf die andere
Datenstrukturen zurückgeführt werden können. Den 4 Datenstrukturen ist
gemeinsam, daß sie nur binäre Relationen verwenden.
1.3.4.1 Lineare Ordnungsgruppen
Sie sind über eine (oder mehrere) totale Ordnung(en) definiert. Die bekanntesten
Verkörperungen der linearen Ordnung sind:
- (ein- oder mehrdimensionale) Felder (lineare Felder)
- Stapel
- Schlangen
- lineare Listen
Lineare Ordnungsgruppen können sequentiell (seqentiell gespeichert) bzw. verkettet
(verkettet gespeichert) angeordnet werden.
29
nach: Rembold, Ulrich (Hrsg.): "Einführung in die Informatik", München/Wien, 1987
47
Algorithmen und Datenstrukturen
1. Sammlungen mit direktem Zugriff
Ein „array“ (Reihung) ist eine Sammlung von Komponenten desselben Typs, auf den
direkt zugegriffen werden kann.
„array“-Kollektion
Daten
Eine Kollektion von Objekten desselben (einheitlichen) Typs
Operationen
Die Daten an jeder Stelle des „array“ können über einen
ganzzahligen Index erreicht werden.
Ein statisches Feld („array“) enthält eine feste Anzahl von Elementen und ist zur
Übersetzungszeit festgelegt. Ein dynamisches Feld benutzt Techniken zur
Speicherbeschaffung und kann während der Laufzeit an die Gegebenheiten
angepaßt werden. Ein „array“ kann zur Speicherung einer Liste herangezogen
werden. Allerdings können Elemente der Liste nur effizient am Ende des „array“
eingefügt
werden.
Anderenfalls
sind
für
spezielle
Einfügeoperationen
Verschiebungen der bereits vorliegenden Elemente (ab Einfügeposition) nötig.
Eine „array“-Klasse sollte Bereichsgrenzenüberwachung für Indexe und dynamische
Erweiterungsmöglichkeiten
erhalten.
Implementierungen
aktueller
Programmiersprachen
umfassen
Array-Klassen
mit
nützlichen
Bearbeitungsmethoden bzw. mit dynamischer Anpassung der Bereichsgrenzen zur
Laufzeit.
Eine Zeichenkette („character string“) ist ein spezialisierter „array“, dessen
Elemente aus Zeichen bestehen:
„character string“-Kollektion
Daten
Eine Zusammenstellung von Zeichen in bekannter Länge
Operationen
Sie umfassen Bestimmen der Länge der Zeichenkette, Kopieren
bzw. Verketten einer Zeichenkette auf eine bzw. mit einer
anderen Zeichenkette, Vergleich zweier Zeichenketten (für die
Musterverarbeitung), Ein-, Ausgabe von Zeichenketten
In einem „array“ kann ein Element über einen Index direkt angesprochen werden. In
vielen Anwendungen ist ein spezifisches Datenelement, der Schlüssel (key) für den
Zugriff auf einen Datensatz vorgesehen. Behälter, die Schlüssel und übrige
Datenelemente zusammen aufnehmen, sind Tabellen.
Ein Dictionary ist eine Menge von Elementen, die über einen Schlüssel identifiziert
werden. Das Paar aus Schlüsseln und zugeordnetem Wert heißt Assoziation, man
spricht auch von „assoziativen Arrays“. Derartige Tabellen ermöglichen den
Direktzugriff über Schlüssel so, wie in einem Array der Direktzugriff über den Index
erreicht wird, z.B.: Die Klasse Hashtable in Java
Der Verbund (record) ist in der Regel eine Zusammenfassung von Datenbehältern
unterschiedlichen Typs:
„record“-Kollektion
Daten
48
Algorithmen und Datenstrukturen
Ein Element mit einer Sammlung von Datenfeldern mit
möglicherweise unterschiedlichen Typen.
Operationen
Der Operator . ist für den Direktzugriff auf den Datenbehälter vorgesehen.
Eine Datei (file) ist eine extern eingerichtete Sammlung, die mit einer Datenstruktur
(„stream“) genannt verknüpft wird.
„file“-Kollektion
Daten
Eine Folge von Bytes, die auf einem externen Gerät abgelegt
ist. Die Daten fließen wie ein Strom von und zum Gerät.
Operationen
Öffnen (open) der Datei, einlesen der Daten aus der Datei,
schreiben der Daten in die Datei, aufsuchen (seek) eines
bestimmten Punkts in der Datei (Direktzugriff) und schließen
(close) der Datei.
Bsp.: Die RandomAccessFile-Klasse in Java 30 dient zum Zugriff auf RandomAccess-Dateien.
2. Sammlungen mit sequentiellem Zugriff
Darunter versteht man lineare Listen (linear list), die Daten in sequentieller
Anordnung aufnehmen:
„list“-Kollektion
Daten
Ein meist größere Objektsammlung von Daten gleichen Typs.
Operationen
Das Durchlaufen der Liste mit Zugriff auf die einzelnen
Elemente beginnt an einem Anfangspunkt, schreitet danach von
Element zu Element fort bis der gewünschte Ort erreicht ist.
Operationen zum Einfügen und Löschen verändern die Größe der
Liste.
Stapel (stack) und Schlangen (queue) 31 sind spezielle Versionen linearer Listen,
deren Zugriffsmöglichkeiten eingeschränkt sind.
„Stapel“-Kollektion
Daten
Eine Liste mit Elementen, auf die man nur über die Spitze
(„top“) zugreifen kann.
Operationen
Unterstützt werden „push“ und „pop“. „push“ fügt ein neues
Element an der Spitze der Liste hinzu, „pop“ entfernt ein
Element von der Spitze („top“) der Liste.
„Schlange“-Kollektion
30
Implementiert das Interface DataInput und DataOutput mit eigenen Methoden.
http://www.galileocomputing.de/openbook/javainsel7/javainsel_12_007.htm
Stand: März 2008
31
49
Algorithmen und Datenstrukturen
Daten
Eine Sammlung von Elementen mit Zugriff am Anfang und Ende
der Liste.
Operationen
Ein Element wird am Ende der Liste hinzugefügt und am Ende
der Liste entfernt.
Eine Schlange ist besonders geeignet zur Verwaltung von „Wartelisten“ und kann zur
Simulation von Wartesystemen eingesetzt werden. Eine Schlange kann ihre
Elemente nach Prioritäten bzgl. der Verarbeitung geordnet haben (priority
queue) 32. Entfernt wird dann zuerst das Element, das die höchste Priorität besitzt.
„prioritätsgesteuerte Schlange“-Kollektion
Daten
Eine Sammlung von Elementen, von denen jedes Element eine
Priorität besitzt.
Operationen
Hinzufügen von Elementen zur Liste. Entfernt wird immer das
Element, das die höchste (oder niedrigste) Priorität besitzt.
1.3.4.2 Nichtlineare Kollektion
1.3.4.2.1 Hierarchische angeordnete Sammlung (Bäume)
Bäume sind im wesentlichen durch die Äquivalenzrelation bestimmt.
Bsp.: Gliederung zur Vorlesung Algorithmen und Datenstrukturen
Algorithmen und Datenstrukturen
Kapitel 1: Datenverarbeitung und
Datenorganisation
Abschnitt 1:
Ein einführendes Beispiel
Kapitel 2:
Suchverfahren
Abschnitt 2:
Begriffe
Abb.: Gliederung zur Vorlesung Datenorganisation
Die Verkörperung dieser Vorlesung ist das vorliegende Skriptum. Diese Skriptum {Seite 1, Seite 2, .....
, Seite n} teilt sich in einzelne Kapitel, diese wiederum gliedern sich in Abschnitte. Die folgenden
Äquivalenzrelationen definieren diesen Baum:
1. Seite i gehört zum gleichen Kapitel wie Seite j
2. Seite i gehört zum gleichen Abschnitt von Kapitel 1 wie Seite j
3. ........
Die Definitionen eines Baums mit Hilfe von Äquivalenzrelationen regelt ausschließlich "Vorgänger/Nachfolger" - Beziehungen (in vertikaler Richtung) zwischen
den Knoten eines Baums. Ein Baum ist auch hinsichtlich der Baumknoten in der
32 http://java.sun.com/j2se/1.5.0/docs/api/java/util/PriorityQueue.html
Stand März 2008
50
Algorithmen und Datenstrukturen
horizontalen Ebene geordnet, wenn zur Definition des Baums neben der
Äquivalenzrelation auch eine partielle Ordnung (Knoten Ki kommt vor Knoten Kj, z.B.
Kapitel1 kommt vor Kapitel 2) eingeführt wird.
Eine hierarchisch angeordnete Sammlung von Datenbehältern ist gewöhnlich ein
Baum mit einem Ursprungs- bzw. Ausgangsknoten, der „Wurzel“ genannt wird. Von
besonderer Bedeutung ist eine Baumstruktur, in der jeder Baumknoten zwei Zeiger
auf nachfolgende Knoten aufnehmen kann. Diese Binärbaum-Struktur kann mit Hilfe
einer speziellen angeordneten Folge der Baumknoten zu einem binären Suchbaum
erweitert werden. Binäre Suchbäume bilden die Ausgangsbasis für das Speichern
großer Datenmengen.
„Baum“-Kollektion
Daten
Eine hierarchisch angeordnete Ansammlung von Knotenelementen,
die von einem Wurzelknoten abgeleitet sind. Jeder Knoten hält
Zeiger
zu
Nachfolgeknoten,
die
wiederum
Wurzeln
von
Teilbäumen sein können.
Operationen
Die Baumstruktur erlaubt das Hinzufügen und Löschen von
Knoten. Obwohl die Baumstruktur nichtlinear ist, ermöglichen
Algorithmen zum Ansteuern der Baumknoten den Zugriff auf die
in den Knoten gespeicherten Informationen.
Ein „heap“ ist eine spezielle Version, in der das kleinste bzw. größte Element den
Wurzelknoten besetzt. Operationen zum Löschen entfernen den Wurzelknoten, dabei
wird, wie auch beim Einfügen, der Baum reorganisiert.
Basis der Heap-Darstellung ist ein „array“ (Feldbaum), dessen Komponenten eine
Binärbaumstruktur überlagert ist. In der folgenden Darstellung ist eine derartige
Interpretation durch Markierung der Knotenelemente eines Binärbaums mit
Indexpositionen sichtbar:
[1]
wk1
[2]
[3]
wk2
wk3
[4]
[5]
wk4
[8]
[0]
wk5
[9]
wk8
[6]
[10]
wk9
wk6
[11]
wk10
[7]
[12]
wk11
wk7
[13]
wk12
wk13
[14]
wk14
[15]
wk15
wk1
wk2
wk3
wk4
wk5
wk6
wk7
wk8
wk9
wk10 wk11 wk12 wk13 wk14 wk15
[1]
[2]
[3]
[4]
[5]
[6]
[7]
[8]
[9]
[10] [11] [12] [13] [14] [15]
Abb. 1.3-10: Darstellung eines Feldbaums
Liegen die Knoten auf den hier in Klammern angegebenen Positionen, so kann man
von jeder Position I mit
51
Algorithmen und Datenstrukturen
Pl = 2 * I
Pr = 2 * I + 1
Pv = I div 2
auf die Position des linken (Pl) und des rechten (Pr) Nachfolgers und des Vorgängers
(Pv) gelangen. Ein binärer Baum kann somit in einem eindimensionalen Feld (array)
gespeichert werden, falls folgende Relationen eingehalten werden:
X[I] <= X[2*I]
X[I] <= X[2*I+1]
X[1] = min(X[I] .. X[N])
Anstatt auf das kleinste kann auch auf das größte Element Bezug genommen
werden
Aufbau des Heap: Ausgangspunkt ist ein „array“ mit bspw. folgenden Daten:
X[1]
40
X[2]
10
X[3]
30
X[4]
......
, der folgendermaßen interpretiert wird:
X[1]
40
X[2]
X[3]
10
30
Abb. 1.3-11: Interpretation von Feldinhalten im Rahmen eines binären Baums
Durch eine neue Anordnung der Daten in den Feldkomponenten entsteht ein „heap“:
X[1]
10
X[2]
X[3]
40
30
Abb. 1.3-12:
52
Algorithmen und Datenstrukturen
Falls ein neues Element eingefügt wird, dann wird nach dem Ordnen gemäß der
„heap“-Bedingung erreicht:
X[1]
10
X[2]
X[3]
40
30
X[4]
15
X[1]
10
X[2]
X[3]
15
30
X[4]
40
Abb.1.3-13: Das Einbringen eines Elements in einen Heap
Beim Löschen wird das Wurzelelement an der 1. Position entfernt. Das letzte
Element im „heap“ wird dazu benutzt, das Vakuum zu füllen. Anschließend wird
reorganisiert:
X[1]
10
X[2]
X[3]
15
30
X[4]
40
53
Algorithmen und Datenstrukturen
X[1]
40
X[2]
X[3]
15
30
X[1]
15
X[2]
X[3]
40
30
Abb. 1.3-14: Das Löschen eines Elements im Heap
Implementierung des Heap in Java 33.
// Erzeugung mit optionaler Angabe zur Kapazitaet (Defaultwert: 100)
//
// ******************PUBLIC OPERATIONEN**********************************
// void insert( x )
--> Einfuegen x
// Comparable loescheMin( )--> Rueckgabe und entfernen des kleinsten
//
Elements
// Comparable findMin( )
--> Rueckgabe des kleinsten Elements
// boolean isEmpty( )
--> Rueckgabe: true, falls leer; anderenfalls
//
false
// boolean isFull( )
--> Rueckgabe true, falls voll; anderenfalls
// false
// void makeEmpty( )
--> Entfernen aller Elemente
Anwendung
Der Binary Heap kann zum Sortieren herangezogen werden.
Ein Heap kann aber auch in Simulationsprogrammen und vor allem zur
Implementierung von Priority Queues verwendet werden. Hier wird vielfach der
einfache Heap durch komplexere Datenstrukturen ( Binomial Heap, Fibonacci Heap)
ersetzt.
33
Vgl. pr13228
54
Algorithmen und Datenstrukturen
1.3.4.2.2 Gruppenkollektionen
Menge (Set)
Eine Gruppe umfaßt nichtlineare Kollektionen ohne jegliche Ordnungsbeziehung.
Eine Menge (set) einheitlicher Elemente ist. bspw. eine Gruppe. Operationen auf die
Kollektion „Set“ umfassen Vereinigung, Differenz und Schnittmengenbildung.
„Set“-Kollektion
Daten
Eine ungeordnete Ansammlung von Objekten ohne Ordnung
Operationen
Die binäre Operationen über Mitgliedschaft, Vereinigung,
Schnittmenge und Differenz bearbeiten die Strukturart „Set“.
Weiter Operationen testen auf Teilmengenbeziehungen.
Graph
Ein Graph (graph) ist eine Datenstruktur, die durch eine Menge Knoten und eine
Menge Kanten, die die Knoten verbinden, definiert ist.
In seiner einfachsten Form besteht eine Verkörperung dieser Datenstruktur aus einer
Knotenmenge K (Objektmenge) und einer festen aber beliebigen Relation R über
dieser Menge 34. Die folgende Darstellung zeigt einen Netzplan zur Ermittlung des
kritischen Wegs:
Die einzelnen Knoten des Graphen sind Anfangs- und Endereignispunkte der Tätigkeiten, die an den
Kanten angegeben sind. Die Kanten (Pfeile) beschreiben die Vorgangsdauer und sind Abbildungen
binärer Relationen. Zwischen den Knoten liegt eine partielle Ordnungsrelation.
Bestelle A
50 Tage
Baue B
1
Teste B
4
20 Tage
Korrigiere Fehler
2
25 Tage
3
15 Tage
Handbucherstellung
60 Tage
Abb. 1.3-11: Ein Graph der Netzplantechnik
„graph“-Kollektion
Daten
Eine Menge von Knoten und eine Menge verbindender Kanten.
Operationen
Der Graph kann Knoten hinzufügen bzw. löschen. Bestimmte
Algorithmen starten an einem gegebenen Knoten und finden alle
von diesem Knoten aus erreichbaren Knoten. Andere Algorithmen
erreichen jeden Knoten im Graphen über „Tiefen“ bzw.
„Breiten“ - Suche.
34
vgl. 1.2.2.2, Abb. 1.2-7
55
Algorithmen und Datenstrukturen
Ein Netzwerk ist spezielle Form eines Graphen, in dem jede Kante ein bestimmtes
Gewicht trägt. Den Gewichten können Kosten, Entfernungen etc. zugeordnet sein.
1.3.4.3 Dateien und Datenbanken
Datei
Damit ist eine Datenstruktur bestimmt, bei der Verbindungen zwischen den
Datenobjekten durch beliebige, binäre Relationen beschrieben werden. Die Anzahl
der Relationen ist somit im Gegensatz zur Datenstruktur Graph nicht auf eine
beschränkt. Verkörperungen solcher assoziativer Datenstrukturen sind vor allem
Dateien. In der Praxis wird statt mehrere binärer Relationen eine n-stellige Relation
(Datensatz) gespeichert. Eine Datei ist dann eine Sammlung von Datensätzen
gleichen Typs.
Bsp.: Studenten-Datei
Sie faßt die relevanten Daten der Studenten 35 nach einem ganz bestimmten Schema zusammen. Ein
solches Schema beschreibt einen Datensatz. Alle Datensätze, die nach diesem Schema aufgestellt
werden, ergeben die Studenten-Datei. Es sind binäre Relationen (Student - Wohnort, Student Geburtsort, ...), die aus Speicheraufwandsgründen zu einer n-stelligen Relation (bezogen auf eine
Datei) zusammengefaßt werden.
Datenbanken
Eine Datenbank ist die Sammlung von verschiedenen Datensatz-Typen.
Fachbereich
1
betreut
M
Student
Abb. 1.3-14: „ER“-Diagramm zur Darstellung der Beziehung „Fachbereich-Student“
Auch hier zeigt sich: Knoten bzw. Knotentypen und ihre Beziehungen bzw.
Beziehungstypen stehen im Mittelpunkt der Datenbank-Beschreibung. Statt Knoten
spricht man hier von Entitäten (bzw. Entitätstypen) und Beziehungen werden
Relationen genannt. Dies ist dann Basis für den Entity-Relationship (ER) -Ansatz
von P.S. Chen. Zur Beschreibung der Entitätstypen und ihrer Beziehungen benutzt
der ER-Ansatz in einem ER-Diagramm rechteckige Kanten bzw. Rauten:
Die als „1“ und „M“ an den Kanten aufgeschriebenen Zahlen zeigen: Ein Fachbereich betreut mehrere (viele) Studenten. Solche Beziehungen können vom Typ
1:M, 1:1, M:N sein. Es ist auch die Bezugnahme auf gleiche Entitätstypen möglich,
z.B.:
35
vgl. 1.2.2.1
56
Algorithmen und Datenstrukturen
Person
1
1
Heirat
Abb.: 1.3-15: Bezugnahme auf den gleiche Entitätstyp „Person“
Zur Verwaltung großer Datenbestände nutzen Datenbanken
-
die Speicherung von Daten in Tabellenform
mit effizienten Zugriffsalgorithmen nach wahlfreien Kriterien
und SQL als Datenzugriffssprache
Datenbanken zeigen den praktischen Einsatz vieler vorgestellten Methoden,
Algorithmen und Datenstrukturen, z.B. die Speicherung in Tabellen hinsichtlich
-
-
der Dateiorganisationsform
o sortiert nach Schlüssel
o B-Baum nach Schlüssel
o Hash-Tabelle nach Schlüssel
Zugriffsunterstützung nach Index
o Primärindexe verweisen auf Hauptdatei
o B –Bäume für Nichtschlüsselattribute zur Bescheunigung des Zugriffs
(Sekundärindex)
Die folgende Darstellung einer Datenbank in einem ER-Diagramm
Abt_ID
Bezeichnung
Job_ID
Titel
Abteilung
Gehalt
Job
Abt-Ang
Job-Ang
Qualifikation
Angestellte
Ang_ID
Name
GebJahr
Abb.: ER-Diagramm zur Datenbank Personalverwaltung
führt zum folgenden Schemaentwurf einer relationalen Datenbank
- Abteilung(Abt_ID,Bezeichnung)
- Angestellte(Ang_ID,Name,Gebjahr,Abt_ID,Job_ID)
- Job(Job_ID,Titel,Gehalt)
- Qualifikation(Ang_ID,Job_ID)
und resultiert in folgender relationalen Datenbank für das Personalwesen:
57
Algorithmen und Datenstrukturen
ABTEILUNG
ABT_ID
KO
OD
PA
RZ
VT
BEZEICHNUNG
Konstruktion
Organisation und Datenverarbeitung
Personalabteilung
Rechenzentrum
Vertrieb
ANG_ID
A1
A2
A3
A4
A5
A6
A7
A8
A9
A10
A11
A12
A13
A14
NAME
Fritz
Tom
Werner
Gerd
Emil
Uwe
Eva
Rita
Ute
Willi
Erna
Anton
Josef
Maria
ANGESTELLTE
GEBJAHR
2.1.1950
2.3.1951
23.4.1948
3.11.1950
2.3.1960
3.4.1952
17.11.1955
02.12.1957
08.09.1962
7.7.1956
13.10.1966
5.7.1948
2.8.1952
17.09.1964
ABT_ID
OD
KO
OD
VT
PA
RZ
KO
KO
OD
KO
OD
OD
KO
PA
JOB_ID
SY
IN
PR
KA
PR
OP
TA
TA
SY
IN
KA
SY
SY
KA
JOB
JOB_ID
KA
TA
SY
PR
OP
TITEL
Kaufm. Angestellter
Techn. Angestellter
Systemplaner
Programmierer
Operateur
GEHALT
3000,00 DM
3000,00 DM
6000,00 DM
5000,00 DM
3500,00 DM
QUALIFIKATION
ANG_ID
A1
A1
A1
A2
A2
A3
A4
A5
A6
A7
A8
A9
A10
A11
A12
A13
A14
JOB_ID
SY
PR
OP
IN
SY
PR
KA
PR
OP
TA
IN
SY
IN
KA
SY
IN
KA
Abb. 1.3-17: Tabellen zur relationalen Datenbank
58
Algorithmen und Datenstrukturen
Die relationale Datenbank besteht, wie die vorliegende Darstellung zeigt aus einer
Datenstruktur, die Dateien (Tabellen) vernetzt. Die Verbindung besorgen Referenzen
(Fremdschlüssel).
1.3.5 Definitionsmethoden für Datenstrukturen
1.3.5.1 Der abstrakte Datentyp
Anforderungen an die Definition eines Datentyps 36
-
Die Spezifikation eines Datentyps sollte unabhängig von seiner Implementierung sein. Daurch
kann die Spezifikation für unterschiedliche Implementierungen verwendet werden.
Reduzierung der von außen sichtbaren (zugänglichen) Aspekte auf der Schnittstelle des
Datentyps. Dadurch kann die Implementierung später verwendet werden, ohne dass
Programmteile, die den Datentyp benutzen, angepasst werden.
Aus diesen Anforderungen ergeben sich zwei Prinzipien:
-
Kapselung (ecucapsultaion): Alle Zugriffe gehen immer nur über die Schnittstelle des
Datentyps
Geheimnisprinzip (programming by contract). Die interne Realisierung des Datentyps bleibt
dem Benutzer verborgen
Ein Datentyp, dem nur Spezifikation und Eigenschaften (bspw. in Form von Regeln
oder Gesetzmäßigkeiten bekannt sind, heißt abstrakt. Man abstrahiert hier von der
konkreten Implementierung.
Ein abstrakter Datentyp wird als ADT bezeichnet.
Die Definition einer Datenstruktur ist bestimmt durch Daten (Datenfelder, Aufbau,
Wertebereiche) und die für die Daten gültigen Rechenvorschriften (Algorithmen,
Operationen). Datenfelder und Algorithmen bilden einen Typ, den abstrakten
Datentyp (ADT).
Eine Spezifikation eines ADT besteht aus:
-
Angabe der Signatur. Sie legt die Namen der Typen sowie die Funktionstypen fest und bildet
die Schnittstelle eines ADT.
Mengen und Funktionen, die zur Signatur passen, heißen Algebren
Gleichungen dienen als Axiome zur Einschränkung möglicher Algebren als Modell.
Zusätzlich erfolgt evtl. ein Import anderer Spezifikationen.
Daten und Algorithmen als Einheit zu sehen, war bereits schon vor 1980 bekannt.
Da damals noch keine allgemein einsetzbare Implementierung vorlag, hat man
Methoden zur Deklaration von ADT 37 bereitgestellt. Damit sollte dem Programmierer
wenigstens durch die Spezifikation die Einheit von Daten und zugehörigen
Operationen vermittelt werden.
36
37
vgl. 1.3.1
vgl. Guttag, John: "Abstract Data Types and the Development of Data Structures", CACM, June 1977
59
Algorithmen und Datenstrukturen
1.3.5.2 Die axiomatische Methode
Die axiomatische Methode beschreibt abstrakte (Daten-)Typen über die Angabe
einer Menge von Operationen und deren Eigenschaften, die in der Form von
Axiomen präzisiert werden. Problematisch ist jedoch: Die Axiomenmenge ist so
anzugeben, daß Widerspruchsfreiheit, Vollständigkeit und möglichst Eindeutigkeit
erzielt wird.
Algebraische Spezifikation
Eine spezielle axiomatische Methode ist die algebraische Spezifikation von Datenstrukturen. Sie soll hier stellvertretend für axiomatische Definitionsmethoden an
einem Beispiel vorgestellt werden.
1. Bsp.: Die algebraische Spezifikation des (ADT) Schlange
Konventionell würde die Datenstruktur Schlange so definiert werden: Eine Schlange
ist ein lineares Feld, bei dem nur am Anfang Knoten entfernt und nur am Ende
Knoten hinzugefügt werden können. Die Definition ist ungenau. Operationen sollten
mathematisch exakt als Funktionen und Beziehungen der Operationen als
Gleichungen angegeben sein. Erst dann ist die Prüfung auf Konsistenz und der
Nachweis der korrekten Implementierung möglich. Die algebraische Spezifikation
bestimmt den ADT Schlange deshalb folgendermaßen:
ADT Schlange
Typen
Schlange<T>, boolean
Funktionen (Protokoll)
NeueSchlange
FuegeHinzu :
Vorn
:
Entferne
:
Leer
:
→ Schlange<T>
T,Schlange<T> → Schlange<T>
→ T
Schlange<T>
Schlange<T> → Schlange<T>
→ boolean
Schlange<T>
Axiome
Für alle t : T bzw. s : Schlange<T> gilt:
1.
2.
3.
4.
5.
6.
Leer(NeueSchlange) = wahr
Leer(FuegeHinzu(t,s)) = falsch
Vorn(NeueSchlange) = Fehler
Vorn(FuegeHinzu(t,s)) = Wenn Leer(s), dann t; andernfalls Vorn(s)
Entferne(NeueSchlange) = Fehler
Entferne(FuegeHinzu(t,s)) = Wenn Leer(s), dann NeueSchlange; andernfalls
FuegeHinzu(t,Entferne(s))
Der Abschnitt Typen zeigt die Datentypen der Spezifikation. Der 1. Typ ist der
spezifizierte ADT. Von anderen Typen wird angenommen, daß sie an anderer Stelle
definiert sind. Der Typ „Schlange<T>“ wird als generischer ADT bezeichnet, da er
"übliche Schlangeneigenschaften" bereitstellt. Eigentliche Schlangentypen erhält
man durch die Vereinbarung eines Exemplars des ADT, z.B.: Schlange<integer>
Der Abschnitt Funktionen zeigt die auf Exemplare des Typs anwendbaren
Funktionen: f : D1 , D2 ,...., Dn → D . Einer der Datentypen D1 , D2 ,...., Dn oder D muß
der spezifizierte ADT sein.
60
Algorithmen und Datenstrukturen
Die Funktionen können eingeteilt werden in:
- Konstruktoren (constructor functions)
(Der ADT erscheint nur auf der rechten Seite des Pfeils.) Sie liefern neue Elemente (Instanzen) des
ADT.
- Zugriffsfunktionen (accessor functions)
(Der ADT erscheint nur auf der linken Seite des Pfeils.) Sie liefern Eigenschaften von existierenden
Elementen des Typs (vgl. Die Funktion: Leer)
- Umsetzungsfunktionen (transformer functions)
(Der ADT erscheint links und rechts vom Pfeil.) Sie bilden neue Elemente des ADT aus
bestehenden Elementen und (möglicherweise) anderen Argumenten (vgl. FuegeHinzu,
Entferne).
Der Abschnitt Axiome beschreibt die dynamischen Eigenschaften des ADT.
2. Bsp.: Die „algebraische Spezifikation“ des ADT Stapel
ADT Stapel<T>, integer, boolean
1. Funktionen (Operationen, Protokoll)
NeuerStapel
PUSH
POP
Top
Stapeltiefe
Leer
:
:
:
:
:
T,Stapel<T>
Stapel<T>
Stapel<T>
Stapel<T>
Stapel<T>
→
→
→
→
→
→
Stapel<T>
Stapel<T>
Stapel<T>
T
integer
boolean
2. Axiome
Für alle t:T und s:Stapel<T> gilt:
(POP(PUSH(t,s)) = s
Top(PUSH(t,s)) = t
Stapeltiefe(NeuerStapel) = 0
Stapeltiefe(PUSH(i,s)) = Stapeltiefe + 1
Leer(NeuerStapel) = wahr
¬ Leer(PUSH(t,s) = wahr
3. Restriktionen (conditional axioms)
Wenn Stapeltiefe(s) = 0, dann führt POP(s) auf einen Fehler
Wenn Stapeltiefe(s) = 0, dann ist Top(s) undefiniert
Wenn Leer(s) = wahr, dann ist Stapeltiefe(s) Null.
Wenn Stapeltiefe(s) = 0, dann ist Leer(s) wahr.
Für viele Programmierer ist eine solche Spezifikationsmethode zu abstrakt. Die
Angabe von Axiomen, die widerspruchsfrei und vollständig ist, ist nicht möglich bzw.
nicht nachvollziehbar.
3. Bsp.: Die algebraische Spezifikation für einen binären Baum
ADT Binaerbaum<T>, boolean
1. Funktionen (Operationen, Protokoll)
NeuerBinaerbaum
-> Binaerbaum<T>
bin
: Binaerbaum<T>, T, Binaerbaum<T> → Binaerbaum<T>
→ Binaerbaum<T>
links
: Binaerbaum<T>
→ Binaerbaum<T>
rechts : Binaerbaum<T>
61
Algorithmen und Datenstrukturen
wert
: Binaerbaum<T>
istLeer : Binaerbaum<T>
-> T
→ boolea
2. Axiome
Für alle t:T und x:Binaerbaum<T>, y:Binaerbaum<T> gilt:
links(bin(x,t,y)) = x
rechts(bin(x,t,y)) = y
wert(bin(x,t,y)) = t
istLeer(NeuerBinaerbaum) = true
istLeer(bin(x,t,y)) = false
Der direkte Weg zur Deklaration von ADT im Rahmen der objektorientierten
Programmierung ist noch nicht weit verbreitet. Der konventionelle Programmierstil,
Daten und Algorithmen getrennt zu behandeln, bevorzugt den konstruktiven Aufbau
der Daten aus elementaren Datentypen.
Realisierung von abstrakten Datentypen
Die folgenden Elemente müssen im Programm abgebildet werden:
-
Name des ADT wird üblicherweise der Klassenname
Importierte ADTs werden sowohl zur Definition mit dem entsprechenden importierten Typ, als
auch zu Importanweisungen innerhalb des Programms
Objekterzeugende Operatoren: sog. Konstruktoren werden in den (meist speziellen)
Klassenmethoden abgebildet, die ein neues Objekt des gewünschten Typ zurückliefern
Lesende Opeartoren: sog. Selektoren werden zu Methoden, die auf die Attribute nur lesend
zugreifen.
Scheibende Operatoren: sog. Manipulatoten werden zu Methoden, die den Zustand des
Objekts verändern
Axiome müssen sichergestellt sein.
1.3.5.3 Die konstruktive Methode
Die Basis bilden hier die Datentypen. Jedem Objekt ist eine Typvereinbarung in der
folgenden Form zugeordnet: X : T;
X ... Bezeichner (Identifizierer) für ein Objekt
T ... Bezeichner (Identifizierer) für einen Datentyp
Einem Datentyp sind folgende Eigenschaften zugeordnet:
1. Ein Datentyp bestimmt die Wertmenge, zu der eine Konstante gehört oder die durch eine Variable
oder durch einen Ausdruck angenommen werden kann oder die durch einen Operator oder durch
eine Funktion berechnet werden kann.
2. Jeder Operator oder jede Funktion erwartet Argumente eines bestimmten Typs und liefert Resultate
eines bestimmten Typs.
Bei der konstruktiven Methode erfolgt die Definition von Datenstrukturen mit Hilfe
bereits eingeführter Datentypen. Die niedrigste Stufe bilden die einfachen
Datentypen. Basis-Datentypen werden in den meisten Programmiersprachen zur
Verfügung gestellt und sind eng mit dem physikalischen Wertevorrat einer DVAnlage verknüpft (Standard-Typen). Sie sind die „Atome“ auf der niedrigsten
Betrachtungsebene. Neue "höherwertige" Datentypen werden aus bereits definierten
„niederstufigen“ Datentypen definiert.
62
Algorithmen und Datenstrukturen
1.3.5.4 Die objektorientierte Modellierung abstrakter Datentypen
Die Spezifikation abstrakter Datentypen
Im Mittelpunkt dieser Methode steht die Definition von Struktur und Wertebereich der
Daten bzw. eine Sammlung von Operationen mit Zugriff auf die Daten. Jede Aufgabe
aus der Datenverarbeitung läßt sich auf ein solches Schema (Datenabstraktion)
zurückführen.
Zur Beschreibung des ADT dient das folgende Format:
ADT Name
Daten
Beschreibung der Datenstruktur
Operationen
Konstruktor
Intialisierungswerte: Daten zur Initialisierung des
Objekts
Verarbeitung: Initialisierung des Objekts
Operation1
Eingabe: Daten der Anwendung dieser Methode
Vorbedingung: Notwendiger Zustand des Systems vor
Ausführung einer Operation
Verarbeitung: Aktionen, die an den Daten ausgeführt
werden
Ausgabe: Daten (Rückgabewerte) an die Anwendung dieser
Methode
Nachbedingung: Zustand des Systems nach Ausführung der
Operation
Operation2
.........
Operationn
.........
Bsp.: Anwendung dieser Vorlage zur Beschreibung des ADT Stapel
ADT Stapel
Daten
Eine Liste von Datenelementen mit einer Position „top“, sie auf den
Anfang des Stapels verweist.
Operationen
Konstruktor:
Initialisierungswerte: keine
Verarbeitung: Initialisiere „top“.
Push
Eingabe: Ein Datenelement zur Aufnahme in den Stapel
Vorbedingung: keine
Verarbeitung: Speichere das Datenelement am Anfang
(„top“) des Stapel
Ausgabe: keine
Nachbedingung: Der Stapel hat ein neues Datenelement
an der Spitze („top“).
Pop
Eingabe: keine
Vorbedingung: Der Stapel ist nicht leer
Verarbeitung: Das Element an der Spitze („top“) wird entfernt.
Ausgabe: keine
Peek bzw. Top
Eingabe: keine
Vorbedingung: Stapel ist nicht leer
Verarbeitung: Bestimme den Wert des Datenelements an der Spitze („top“
des Stapel.
Ausgabe: Rückgabe des Datenwerts, der an der Spitze
(„top“) des Stapel steht.
63
Algorithmen und Datenstrukturen
Nachbedingung: Der Stapel bleibt unverändert.
Leer
Eingabe: keine
Vorbedingung: keine
Verarbeitung: Prüfe, ob der Stapel leer ist.
Ausgabe: Gib TRUE zurueck, falls der Stapel leer ist; andernfalls FALSE.
Nachbedingung: keine
bereinigeStapel
Eingabe: keine
Vorbedingung: keine
Verarbeitung: Löscht alle Elemente im Stapel und setzt die Spitze
(„top“) des Stapels zurück.
Ausgabe: keine
Klassendiagramme der Unified Modelling Language
Visualisierung und Spezifizierung objektorientierter Softwaresysteme erfolgt mit der
Unified Modelling Language (UML). Zur Beschreibung abstrakter Dytentypen dient
das wichtigste Diagramm der UML: Das Klassendiagramm.
Das Klassendiagramm beschreibt die statische Struktur der Objekte in einem System
sowie ihre Beziehungen untereinander. Die Klasse ist das zentrale Element. Klassen
werden durch Rechtecke dargestellt, die entweder den Namen der Klasse tragen
oder zusätzlich auch Attribute und Operationen. Klassenname, Attribute, Operationen
(Methoden) sind jeweils durch eine horizontale Linie getrennt. Klassennamen
beginnen mit Großbuchstaben und sind Substantive im Singular.
Ein strenge visuelle Unterscheidung zwischen Klassen und Objekten entfällt in der
UML. Objekte werden von den Klassen dadurch unterschieden, daß ihre
Bezeichnung unterstrichen ist. Haufig wird auch dem Bezeichner eines Objekts ein
Doppelpunkt vorangestellt.. Auch können Klassen und Objekte zusammen im
Klassendiagramm auftreten.
Klasse
Objekt
Wenn man die Objekt-Klassen-Beziehung (Exemplarbeziehung, Instanzbeziehung)
darstellen möchte, wird zwischen einem Objekt und seiner Klasse ein gestrichelter
Pfeil in Richtung Klasse gezeichnet:
Klasse
Objekt
Die Definition einer Klasse umfaßt die „bedeutsamen“ Eigenschaften. Das sind:
- Attribute
d.h.: die Struktur der Objekte: ihre Bestandteile und die in ihnen enthaltenen Informationen und
Daten.. Abhängig von der Detaillierung im Diagramm kann die Notation für ein Attribut den
Attributnamen, den Typ und den voreingestellten Wert zeigen:
Sichtbarkeit Name: Typ = voreingestellter Wert
- Operationen
d.h.: das Verhalten der Objekte. Manchmal wird auch von Services oder Methoden gesprochen. Das
Verhalten eines Objekts wird beschrieben durch die möglichen Nachrichten, die es verstehen kann.
Zu jeder Nachricht benötigt das Objekt entsprechende Operationen. Die UML-Syntax für
Operationen ist:
Sichtbarkeit Name (Parameterliste) : Rückgabetypausdruck (Eigenschaften)
64
Algorithmen und Datenstrukturen
Sichtbarkeit ist + (öffentlich), # (geschützt) oder – (privat)
Name ist eine Zeichenkette
Parameterliste enthält optional Argumente, deren Syntax dieselbe wie für Attribute ist
Rückgabetypausdruck ist eine optionale, sprachabhängige Spezifikation
Eigenschaften zeigt Eigenschaftswerte (über String) an, die für die Operation Anwendung finden
- Zusicherungen
Die Bedingungen, Voraussetzungen und Regeln, die die Objekte erfüllen müssen, werden
Zusicherungen genannt. UML definiert keine strikte Syntax für die Beschreibung von Bedingungen.
Sie müssen nur in geschweifte Klammern ({}) gesetzt werden.
Idealerweise sollten Regeln als Zusicherungen (engl. assertions) in der Programmiersprache
implementiert werden können.
Attribute werden mindestens mit ihrem Namen aufgeführt und können zusätzliche
Angaben zu ihrem Typ (d.h. ihrer Klasse), einen Initialwert und evtl.
Eigenschaftswerte und Zusicherungen enthalten. Attribute bilden den Datenbestand
einer Klasse.
Operationen (Methoden) werden mindestens mit ihrem Namen, zusätzlich durch ihre
möglichen Parameter, deren Klasse und Initialwerte sowie evtl. Eigenschaftswerte
und Zusicherungen notiert. Methoden sind die aus anderen Sprachen bekannten
Funktionen.
Klassenname
attribut:Typ=initialerWert
operation(argumentenliste):rückgabetyp
Abb.:
Bsp.: Die Klasse Object aus dem Paket java.lang
Object
+equals(obj :Object)
#finalize()
+toString()
+getClass()
#clone()
+wait()
+notify()
........
Sämtliche Java-Klassen bilden eine Hierarchie mit java.lang.Object als
gemeinsame Basisklasse.
Assoziationen repräsentieren Beziehungen zwischen Instanzen von Klassen.
Mögliche Assoziationen sind:
- einfache (benannte) Assoziationen
65
Algorithmen und Datenstrukturen
- Assoziation mit angefügten Attributen oder Klassen
- Qualifzierte Assoziationen
- Aggregationen
- Assoziationen zwischen drei oder mehr Elementen
- Navigationsassoziationen
- Vererbung
Attribute werden von Assoziationen unterschieden:
Assoziation: Beschreibt Beziehungen, bei denen beteiligte Klassen und Objekte von anderen Klassen
und Objekten benutzt werden können.
Attribut: Beschreibt einen privaten Bestandteil einer Klasse oder eines Objekts, welcher von außen
nicht sichtbar bzw. modifizierbar ist.
Grafisch wird eine Assoziation als durchgezogene Line wiedergegeben, die gerichtet
sein kann, manchmal eine Beschriftung besitzt und oft noch weitere Details wie z.B.
Muliplizität (Kardinalität) oder Rollenanmen enthält, z.B.:
Arbeitet für
0..1
Arbeitgeber
Arbeitnehmer
Eine Assoziation kann einen Namen zur Beschreibung der Natur der Beziehung
(„Arbeitet für“) besitzen. Damit die Bedeutung unzweideutig ist, kann man dem
Namen eine Richtung zuweisen: Ein Dreieck zeigt in die Richtung, in der der Name
gelesen werden soll.
Rollen („Arbeitgeber, Arbeitnehmer) sind Namen für Klassen in einer Relation. Eine
Rolle ist die Seite, die die Klasse an einem Ende der Assoziation der Klasse am
anderen Ende der Assoziation zukehrt. Die Navigierbarkeit kann durch einen Pfeil in
Richtung einer Rolle angezeigt werden.
Rolle1
Rolle2
K1
K2
1
0..*
Abb.: Binäre Relation R = C1 x C2
Rolle1
K1
K2
Rollen
...
Kn
Abb.: n-äre Relation K1 x K2 x ... x Kn
In vielen Situationen ist es wichtig anzugeben, wie viele Objekte in der Instanz einer
Assoziation miteinander zusammenhänen können. Die Frage „Wie viele?“ bezeichnet
man als Multiplizität der Rolle einer Assoziation. Gibt man an einem Ende der
Assoziation eine Multiplizität an, dann spezifiziert man dadurch: Für jedes Objekt am
entgegengesetzten Ende der Assoziation muß die angegebene Anzahl von Objekten
vorhanden sein.
66
Algorithmen und Datenstrukturen
Ein A ist immer mit Ein A ist immer mit Ein A ist mit
einem B assoziiert einem oder mehre- keinem
oder
ren B assoziiert
einem B assoziiert
Ein A ist mit keinem, einem oder
mehreren B assoziiert
Unified
A
1 B
A
1..*
B
A
0..1 B
A
*
B
1:1
1..*
1:1..n
0..*
2..6
0..*
*
17
4
n
m
0..n:2..6
0..n:0..n
17:4
?
Abb.: Kardinalitäten für Beziehungen
Pfeile in Klassendiagrammen zeigen Navigierbarkeit an. Wenn die Navigierbarkeit
nur in einer Richtung existiert, nennt man die Assoziation eine gerichtete Assoziation
(uni-directional association). Eine ungerichtete (bidirektionale) Assoziation enthält
Navigierbarkeiten in beiden Richtungen. In UML bedeuten Assoziationen ohne Pfeile,
daß die Navigierbarbeit unbekannt oder die Assoziation ungerichtet ist.
Ungerichtete Assoziationen enthalten eine zusätzliche Bedingung: Die zu dieser
Assoziation zugehörigen zwei Rollen sind zueinander invers.
Abhängigkeit (dependency): Manchmal nutzt eine Klasse eine andere. Die UMLNotation ist dafür oft eine gestrichelte Linie mit einem Pfeil, z.B.:
Applet
WillkommenApplet
paint()
Graphics
Abb. : WillkommenApplet nutzt die Klasse Graphics über paint()
Reflexive Assoziation: Manchmal ist auch eine Klasse mit sich selbst assoziiert. Das
kann bspw. der fall sein, wenn eine Klasse Objekte hat, die mehrere Rollen spielen,
z.B.:
67
Algorithmen und Datenstrukturen
Fahrzeuginsasse
1
fahrer
fährt
0..4
beifahrer
Ein Fahrzeuginsasse kann entweder ein Fahrer oder ein Beifahrer sein. In der Rolle des Fahrers fährt
ein Fahrzeuginsasse null oder mehr Fahrzeuginsassen, die die Rolle von Beifahrern spielen.
Abb.:
Bei einer reflexiven Assoziation zieht man eine Linie von der Klasse aus zu dieser
zurück. Man kann die Rollen sowie die Namen, die Richtung und die Multiplizität der
Assoziation angeben.
Eine Aggregation ist eine Sonderform der Assoziation. Sie repräsentiert eine
(strukturelle) Ganzes/Teil-Beziehung. Zusätzlich zu einfacher Aggregation bietet UML
eine stärkere Art der Aggregation, die Komposition genannt wird. Bei der
Komposition darf ein Teil-Objekt nur zu genau einem Ganzen gehören.
Teil
Ganzes
Existenzabhängiges Teil
Abb.: Aggregation und Komposition
Eine Aggregation wird durch eine Raute dargestellt. Die Komposition wird durch eine
ausgefüllte Raute dargestellt und beschreibt ein „physikalisches Enthaltensein“.
Die Vererbung (Spezialisierung bzw. Generalisierung) stellt eine Verallgemeinerung
von Eigenschaften dar. Eine Generalisierung (generalization) ist eine Beziehung
zwischen dem Allgemeinen und dem Speziellen, in der Objekte des speziellen Typs
(der Subklasse) durch Elemente des allgemeinen Typs (der Oberklassse) ersetzt
werden können. Grafisch wird eine Generalisierung als durchgezogene Linle mit
einer unausgefüllten, auf die Oberklasse zeigenden Pfeilspitze wiedergegeben, z.B.:
Supertyp
Subtyp 1
Subtyp 2
68
Algorithmen und Datenstrukturen
Bsp.: Vererbungshierarchie und wichtige Methoden der Klasse Applet
Panel
Applet
+init()
+start()
+paint(g:Graphics) {geerbt}
+update(g:Graphics) {geerbt}
+repaint()
+stop()
+destroy()
+getParameter(name:String);
+getParameterInfo()
+getAppletInfo()
Abb.:
Schnittstellen und abstrakte Klassen: Eine Schnittstelle (Interface) ist eine
Ansammlung von Operationen, die eine Klasse ausführt. Programmiersprachen (z..
B. Java) benutzen ein einzelnes Konstrukt, die Klasse, die sowohl Schnittstelle als
auch deren Implementierung enthält. Bei der Bildung einer Unterklasse wird beides
vererbt. Eine reine Schnittstelle (wie bspw. in Java) ist eine Klasse ohne
Implementierung und besitzt daher nur Operationsdeklarationen. Schnittstellen
werden oft mit Hilfe abstrakter Klassen deklariert.
Bei abstrakten Klassen oder Methoden wird der Name des abstrakten Gegenstands
in der UML kursiv geschrieben. Ebenso kann man die Bedingung {abstract}
benutzen.
<<interface>>
InputStream
DataInput
OrderReader
{abstract}
Abhängigkeit
Generalisierung
Verfeinerung
DataInputStream
Irgendeine Klasse, z.B. „OrderReader“ benötigt die DataInput-Funktionalität. Die Klasse
DataInputStream implementiert DataInput und InputStream. Die Verbindung zwischen
DataInputStream und DataInput ist eine „Verfeinerung (refinement)“. Eine Verfeinerung ist in UML ein
allgemeiner Begriff zur Anzeige eines höheren Detaillierungsniveaus. Die Objektbeziehung zwischen
OrderReader und DataInput ist eine Abhängigkeit. Sie zeigt, daß „OrderReader“ die Schnittstelle
„DataInput für einige Zwecke benutzt.
Abb.: Schnittstellen und abstrakte Klassen: Ein Beispiel aus Java
Abstrakte Klassen und Schnittstellen ermöglichen die Definition einer Schnittstelle
und das Verschieben ihrer Implementierung auf später. Jedoch kann die abstrakte
69
Algorithmen und Datenstrukturen
Klasse schon die Implementierung einiger Methoden enthalten, während die
Schnittstelle die Verschiebung der Definition aller Methoden erzwingt.
Eine Schnittstelle modelliert man in Form einer gestrichelten Linie mit einem großen,
unausgefüllten Dreieck, das neben der Schnittstelle steht und auf diese zeigt. Eine
andere verkürzte Darstellung einer Klasse und einer Schnittstelle besteht aus einem
(kleinen Kreis), der mit der Klasse durch eine Linie verbunden ist, z.B.:
Object
Component
ImageObserver
Container
Panel
Applet
Abb.: Vererbungshierarchie von Applet
70
Algorithmen und Datenstrukturen
1.3.5.6 Die Implementierung abstrakter Datentypen in Java
1.3.5.6.1 Klassen und Schnittstellen (Referenzdatentypen)
1. Klassen
Top-level Klassen: Die Verwendung von Klassen ist in Java, im Gegensatz zu C++,
zwingend. Jede Klasse bildet einen abgeschlossenen Sichtbarkeitsbereich für die
darin definierten Attribute und Methoden.
Eine Java-Klasse besteht aus der in der folgenden Abbildung dargestellten
Bestandteilen:
public abstract final class KlassenName
extends KlassenName
implements InterfaceListe
{
// Attribute
type attributName;
// Konstruktor
public KlassenName()
{ // ... }
// Methoden
public xyz( ... )
{ ... }
......
}
Abb.: Bestandteile von Java-Klassen
Am Anfang steht die Klassendeklaration. Sie benennt die Klasse eindeutig, und legt
die allgemeinen Merkmale der Klasse fest:
public
abstract
final
class KlassenName
extends KlassenName
implements InterfaceListe
{
// Klassenkörper
}
Die Klasse ist allgemein sichtbar.
Vorgabegemäß kann eine Klasse nur von Klassen desselben
Packages genutzt werden. Durch das Schlüsselwort public wird sie
auch außerhalb des umgebenden Packages sichtbar und zugreifbar.
Die namensgebende Klasse einer Java-Quellcode-Datei, due auch
die main()-Methode enthalten kann, muß zwingend als public erklärt
sein.
Abstrakte Klassen dienen zur Strukturierung des Entwurfs, um
gemeinsame
Merkmale
verschiedener
Klassen
zentralisiert
ausdrücken zu können. Von einer abstrakten Klasse kann nicht geerbt
werden.
So können Vererbungsgäste ausgeschlossen werden.An dieser Stelle
kann keine Weiterentwicklung erfolgen. Das Schlüsselwort verhindert
einen entsprechende Versuch zum Übersetzungszeitpunkt.
Name der Klasse
Die Klasse erbt von der Klasse KlassenName. In Java ist nur einfache
Vererbung zugelassen, daher kann an dieser Stelle auch maximal nur
eine Superklasse spezifiziert werden.
Die Klasse implementiert die in InterfaceLIste aufgeführten
Schnittstellen. Die Schnittstellennamen werden in der Liste durch
Kommata getrennt.
Ausprogrammierter Klassenrumpf
71
Algorithmen und Datenstrukturen
Objekterzeugung: Die Erzeugung konkreter Ausprägungen (auch Instanzen oder
Objekte) einer Klasse geschieht üblicherweise durch den new-Operator:
Type variable = new Type(parameter)
Innere Klassen
Lokale Klassen: Klassen können innerhalb von Klassen definiert werden, z.B.
class Aussen
{
...
class Innen
{
...
}
...
}
-
Objekte der inneren Klassen können auf private Daten des erstellenden Objekts zugreifen
Innere Klassen unterliegen der Sichtbarkeit der umgebenden Klasse, d.h. sie sind vor
Zugriffen anderer Klassen geschützt.
Für innere Klassen wird eine eigenständige class-Datei mit der Namenskonvention
UmgebendeKlasse$innereKlasse erzeugt.
Anonyme Klassen: Die eingebettete Klasse kann anonym definiert sein, z.B.
new Basisklasse(parameter)
{
// Datenelemente und Methoden der inneren Klasse
};
Java setzt für anonyme Klassen den Namen aus dem Namen der umgebenden
Klasse, dem Zeichen $ und einer fortlaufenden Nummer zusammen.
Wrapper-Klassen
Korrespondierend zu jedem primitiven Datentyp in Java gibt es einen Wrapper-Typ.
Sie kapselt den zugrundeliegenden Primitivtyp in eine eigene Klasse und stellt einige
Servicemethoden bereit.
Primitivtyp
boolean
byte
short
int
long
float
double
void 38
Wrapper-Typ
Boolean
Byte
Short
Integer
Long
Float
Double
Void 39
Alle Wrapper-Typen bieten bestimmte Service-Methoden an.
-
38
39
Alle Wrapper-Typen sind final deklariert und können nicht weiter spezialisiert werden.
kann nur als Rückgabetyp von Operationen spezifiziert werden
kann nicht instanziiert werden
72
Algorithmen und Datenstrukturen
-
-
-
-
...Value(), z.B. booleanValue(), byteValue(), shortValue(), intValue(),
longValue(), floatValue(), doubleValue(). All diese Methoden geben den intern
gekapselten Wert unverändert zurück. Darüber hinaus existieren Methoden zur Ausgabe
eines numerischen Wrapper-Typs in einen beliebigen numerischen Primitivtyp 40.
Konstruktoren erlauben die Erzeugung eines Wrappertyps aus einem Ausdruck vom Typ des
gekapselten Typs oder aus einem String.
compareTo() vergleicht den gekapselten zweier Wrappertyp-Objekte.
parse...() Methoden, z.B. parseByte(String s), parseByte(String s, int
radix), parseInt(String s), parseInt(String s, int radix), erzeugen aus
Zeichenketten den jeweiligen Primitivtyp.
valueOf(). Für alle Wrapper-Typen integraler Primitivtypen (byte, char, long, int, short) ist
mit der valueOf()-Methode eine (Factory-) Methode zur Erzeugung eines neuen WrapperObjekts aus einer Zeichenkette definiert.
die üblichen arithmetischen Operationen stehen zur Verknüpfung von Wrapperobjekten nicht
zur Verfügung (kein Operator overloading).
Boxing / Unboxing: Mit Java 1.5 eingeführte dynamische Umwandlung zwischen
primitiven Werten und den zugehörigen Wrapper-Klassen.
Boxing: automatische Wrapper-Erzeugung
Unboxing: Wandlung eines objektgekapselten Werts in einen Primitivwert.
Die automatische Typumwandlung wird mit Java 1.5 ausschließlich für
Übergabeparameter angeboten.
Bsp. 41:
public class BUBTest1
{
public static void main(String[] args)
{
BUBTest1 obj = new BUBTest1();
obj.boxIt( new Integer(42) );
obj.boxIt( 42 ); //dynamic boxing
obj.unBoxIt( 42 ); //nothing new
obj.unBoxIt( new Integer(42) ); //dynamic unboxing
} //main()
public void boxIt(Integer i)
{
System.out.println("value="+i);
} //boxIt
public void unBoxIt(int i)
{
System.out.println("value="+i);
} //unBoxIt
} //class BUBTest1
Der Boxingvorgang kann außer für Wrapper-Typen auch für Objekte der Klasse
Object durchgeführt werden. Da diese als Superklasse aller Wrapper-Typen angelegt
ist, ist diese Typkonversion typkonform.
Bsp. 42:
public class BUBTest2
{
public static void main(String[] args)
{
BUBTest2 obj = new BUBTest2();
obj.boxIt( new Integer(42) );
obj.boxIt( 42 ); //dynamic boxing
40
ersetzen die explizite Typumwandlung
pr12361
42 pr12361
41
73
Algorithmen und Datenstrukturen
} //main()
public void boxIt(Object i)
{
System.out.println("value=" + i);
} //boxIt
} //class BUBTest2
Die umgekehrte Richtung, d.h. die Verwendung einer Ausprägung von Object an
einer Stelle an der eine konkrete Zahl (z.B. int-Zahl) erwartet wird ist – nach
Maßgabe
der
nicht
typsicher
möglichen
Konversion
entgegen
der
Vererbungsrichtung - nicht möglich.
Aufzählungstypen
Im einfachsten Anwendungsfall besteht die Definition von Aufzählungstypen aus der
durch Kommata voneinander getrennten, vollständigen Aufzählung aller Werte.
Bsp. 43: Aufzählungstyp jahreszeiten, der durch die zulässigen Werte winter,
fruehling, sommer, herbst festgelegt ist.
public class EnumTest1
{
enum jahreszeiten { winter, fruehling, sommer, herbst }
public static void main(String args[])
{
jahreszeiten jz1 = jahreszeiten.winter;
if (jz1 == jahreszeiten.winter)
System.out.println("Es ist " + jz1);
}
}
Die Verwendung von Aufzählungstypen entspricht der von Primitivtypen. Aus diesem
Grund ist keine Anforderung von Speicherplatz durch das new-Schlüsselwort 44
notwendig.
Aufzählungstypen lassen sich auch "klassenartig" ausbauen. Diese Erweiterung
erlaubt es die Elemente des Aufzählungstypes wahlfrei an selbstdefinierte
Eigenschaften zu binden. Konzeptionell werden diese Eigenschaften dabei als
Attribute eines Aufzählungstyps aufgefasst, die durch einen vom Programmierer
bereitzustellenden Konstruktor zugewiesen werden. Der Konstruktoraufruf erfolgt
dabei automatisch durch das Laufzeitsystem zum Definitionszeitpunkt eines
Aufzählungstyps für alle konstituierenden Werte.
Bsp. 45:
public class EnumTest2
{
public enum Muenzen
{
einCent(1), zweiCent(2), fuenfzent(5),
zehnCent(10), zwanzigCent(20), fuenfzigCent(50);
private int wert;
Muenzen(int wert)
{
this.wert = wert;
}
43
pr12361
Der Übersetzer verhindert sogar aktiv die Instanziierung eines Aufzählungstyps über new und bricht mit einer
Fehlermeldung ab
45 pr12361
44
74
Algorithmen und Datenstrukturen
public int wert() { return wert; }
}
public static void main(String args[])
{
Muenzen m = Muenzen.fuenfzigCent;
System.out.println(m.wert());
}
}
2. Schnittstellen (Interfaces)
Java erlaubt es nicht, dass eine Klasse mehr als eine Superklasse hat.
Mehrfachvererbung ist daher in Java nicht ohne Weiters möglich. Eine Klasse kann
aber beliebig viele Interfaces implementieren.
Java-Schnittstellen umfassen im wesentlichen Operationen, d.h. Methodensignaturen
ohne eigene Implementierung. Ein Interface ist ein ReferenzTyp (, ebenso wie eine
Klasse) und gibt Signaturen – Namen und Typen von Methoden (und Konstanten)
vor.
Interface sind reine Spezifikationen:
-
-
ein Interface wird mit dem Schlüsselwort interface deklariert.
ein Interface enthält keinerlei Methoden-Implementation. Alle Methoden sind implizit abstrakt,
( auch wenn ohne diesen Modifikator deklariert).
Ein Interface kann nur Instanz-Methoden enthalten.
Ein
Interface
ist
ohne
Sichbarkeitsmodifikator
paketsicher.
Als
einziger
Sichtbarkeitsmodifikator darf public angegeben werden. Alle Methoden sind implizit
public, auch wenn der Modifikator nicht explizit angegeben ist. Es ist ein Fehler protected
oder private in einem Interface zu deklarieren.
Ein Interface kann keine Instanz-Felder deklarieren, aber als static oder final deklarierte
Konstanten.
Da ein Interface nicht instanziiert werden kann, definiert es keinen Konstruktor.
Benutzung: Wenn eine Klasse ein Interface implementiert, können Objekte dieser
Klasse Variable vom Typ des Interfaces zugewiesen werden.
75
Algorithmen und Datenstrukturen
1.3.5.6.2 Generische Typen
Java kannte neben primitiven Datentypen (int, double, etc.) nur Klassen und
Interfaces. Ab Version 1.5 unterscheidet Java folgende Typen:
-
Klassen bzw. Interfaces
Typ-Variablen
Parametrisierte Typen
Generische Array-Typen
Die Virtuelle Maschine kennt zur Laufzeit nur Klassen und Interfaces. Die NichtKlassen-Typen sind nur zur Kompilierungszeit bekannt.
Typvariable
Mit Java 1.5 wird das Typsystem auf generische Typen erweitert. Die Idee für
generische Typen ist es eine Klasse zu schreiben, die für verschiedene Typen als
Inhalt zu benutzen ist. Die Speicherung beliebiger Objekte kann Java bis zur Version
1.4 nur in Feldern vom Typ Object vollziehen. Allerdings geht damit die typische
statische Typinformation verloren. Dynamische Typzusicherung ist in diesem Fall für
weitere sinnvolle Nutzung der Objekte unerläßich. Dynamische Typzusicherung kann
aber zu Laufzeitfehlern führen:
Der folgende generische Binärbaumknoten 46 besitzt einen entscheidenden Nachteil,
den eine Übersetzung mit Java 1.5 aufdeckt:
// Elementarer Knoten eines binaeren Baums, der nicht ausgeglichen ist
class BinaerBaumknoten
{
// Instanzvariable
protected BinaerBaumknoten links;
protected BinaerBaumknoten rechts;
public Comparable daten;
// linker Teilbaum
// rechter Teilbaum
// Dateninhalt der Knoten
// Konstruktor
public BinaerBaumknoten(Comparable datenElement)
{
this(datenElement, null, null );
}
public BinaerBaumknoten(Comparable datenElement,
BinaerBaumknoten l,
BinaerBaumknoten r)
{
daten
= datenElement;
links
= l;
rechts
= r;
}
public void insert (Comparable x)
{
if (x.compareTo(daten) > 0)
// dann rechts
{
if (rechts == null) rechts = new BinaerBaumknoten(x);
else rechts.insert(x);
}
else // sonst links
{
if (links == null) links = new BinaerBaumknoten(x);
else links.insert(x);
46
pr12362
76
Algorithmen und Datenstrukturen
}
}
public BinaerBaumknoten getLinks()
{
return links;
}
public BinaerBaumknoten getRechts()
{
return rechts;
}
}
Beim Übersetzen mit Java 1.5 erhält man folgende Hinweise:
Note: BinaerBaumknoten.java uses unchecked or unsafe operations
Note: Recompile with –Xlint:unchecked for details
Führt man den letzten Hinweis aus, dann zeigt der Compiler das Problem an:
Mit Java 1.5 wird das Typsystem auf generische Typen erweitert. Eine generische
Klasse, die statische Typsicherheit garantiert, muß jedes Auftreten des Typs Object
(im vorliegenden Fall des Referenzdatentyps Comparable) durch einen
Variablennamen ersetzen. Die Variable ist eine Typvariable, die für einen beliebigen
Typ steht. Dem Klassennamen wird zusätzlich im Rahmen der Klassendefinition - in
spitzen Klammern eingeschlossen - hinzugefügt, dass diese Klasse eine
Typvariable benutzt:
class C<T>
{
T t;
T m(T t)
{
T t1 = t;
this.t = t1;
return this.t;
}
}
Eine generische Klasse ist eine Klasse, die Typvariable benutzt. "T" ist der Name der
Typ-Variablen (oder: der formale Typ-Parameter). Sie steht für "irdendeinen" nicht
näher spezifizierten Typ. Dieser Typ muß kompatibel mit Object sein. Es kann sich
um alles handeln, außer um int, double, etc. Der Compiler generiert aufgrund der
vorstehenden Defintion folgende gewöhnliche Klasse C:
class C
{
Oblect t;
Object m(Object t)
{
77
Algorithmen und Datenstrukturen
Object t1 = t;
this.t = t1;
return this.t;
}
}
Diese vom Compiler definierte Klasse wird als Raw-Type bezeichnet. Zur Laufzeit ( für die VM -) ist C ein generischer Typ, der mit einer Typ-Variablen mit dem Namen T
parametrisiert ist. Eine Typ-Variable kann innerhalb der Klasse, für die sie definiert
ist, benutzt werden, u.a. zur Definition von Referenzen (Instanzvariablen, lokalen
Variablen, Methoden-Parameter) und zur Spezifikation von Rückgabewerten.
Typvariablen können auch als parametriserter Typ benutzt werden, z.B.:
class A { } ist ein gewöhnlicher Klassentyp. Dann kann die Klasse C folgendermaßen verwendet
werden:
C<A> c = new C<A> ();
A a = c.m (new A ());
C<A> ist ein parametrisierter Typ. A ist der aktuelle Parameter des parametrisierten Typs. C<A> ist
eine konkrete Instanz der generischen Klasse C.
Für diesen parametrisierten Typ wird vom Compiler aber keine Klasse generiert. Alle
Typen C<A>, C>B>, etc. teilen sich also diesselbe Klasse C. Nur dies ist zur Laufzeit
bekannt:
-
Ein C<A> ist niemals kompatibel zu einem C<B>, auch dann nicht, wenn B von A abgeleitet
ist
Ein C<A> oder ein C<B> sind auch nicht kompatibel zu Object.
C<A> und C<B> sind aber kompatibel zum Raw-Type
Eine Typ-Variable T ist immer kompatibel zu Object, aber Object ist nicht kompatibel
zu T. In der Klasse C können daher über eine T-Referenz alle Methoden aufgerufen
werden, die in der Klasse Object definiert sind.
Einschränkung der Typvariablen (Typebounds): Bei der Definition der Schablone
kann eingeschränkt werden, dass eine Typvariable nur für bestimmte Typen ersetzt
werden darf. Es kann vorgeschrieben werden, dass der Typ eine bestimmte konkrete
Schnittstelle implementieren muß.
Das folgende Programm ersetzt in der angegebenen Lösung zum BinaerBaumknoten
den Typ Comparable durch eine Typvariable mit der Angabe, dass diese Variable für
alle Typen steht, die Subtypen der Schnittstelle Comparable sind.
In der folgenden Lösung 47 wird der Typ Comparable durch eine Typvariable ersetzt.
Diese Variable steht für alle Typen, die SubTypen der Schnittstelle Comparable
sind.
// Elementarer Knoten eines binaeren Baums, der nicht ausgeglichen ist
class BinaerBaumknoten<T extends Comparable>
{
// Instanzvariable
protected BinaerBaumknoten<T> links;
protected BinaerBaumknoten<T> rechts;
public
T daten;
// Konstruktor
public BinaerBaumknoten(T datenElement)
47
vgl. GenericBinaerBaumknoten.java in pr12362
78
// linker Teilbaum
// rechter Teilbaum
// Dateninhalt der Knoten
Algorithmen und Datenstrukturen
{
this(datenElement, null, null );
}
public BinaerBaumknoten(T datenElement,
BinaerBaumknoten<T> l,
BinaerBaumknoten<T> r)
{
daten
= datenElement;
links
= l;
rechts
= r;
}
public void insert (T x)
{
if (x.compareTo(daten) > 0)
// dann rechts
{
if (rechts == null) rechts = new BinaerBaumknoten<T>(x);
else rechts.insert(x);
}
else // sonst links
{
if (links == null) links = new BinaerBaumknoten<T> (x);
else links.insert(x);
}
}
public BinaerBaumknoten<T> getLinks()
{
return links;
}
public BinaerBaumknoten<T> getRechts()
{
return rechts;
}
}
Die Lösung ist noch nicht perfekt. Eine abgeleitete Klasse, deren Basisklasse
Comparable implementiert, wird nicht akzeptiert, weil die Defintion <T extends
Comparable<T>> verlangt, dass das Typargument selbst Comparable ist 48.
Vererbung: Eine generische Klasse kann von einer anderen Klasse abgeleitet sein:
class B<T> { }
class C<T> extends B<T> { } // 2-fache Nutzung: Typvariable in C<T>, aktueller Typ-Parameter in
B<T>
Wäre die Typ-Variable von B eingeschränkt:
class A { }
class B<T extends A> { }
dann müßte auch die Typ-Variable von C eingeschränkt sein:
class C<T extends A> extends B<T> { }
Man kann eine nicht generische Klasse von einem parametrisierten Typ ableiten.
Eine Ableitungsbeziehung zwischen Typargumenten wird nicht auf generische Typen
übertragen. Obwohl bspw. Integer von Number abgeleitet ist, ist
BinaerBaumknoten<Integer> nicht kompatibel zu BinaerBaumknoten<Number>. Die
folgende Wertzuweisung ist deshalb fehlerhaft:
BinaerBaumKnoten<Number> b = new BinaerBaumknoten<Integer>(13)
48
Mit contravarianten Widcardtype kann auch dieses Problem gelöst werden.
79
Algorithmen und Datenstrukturen
BinaerBaumknoten<Integer>
und
BinaerBaumknoten<Number>
sind
getrennte Typen.
Die Eigenschaft wird als Invarianz bezeichnet: Eine Variation der Typargumente in
Bezug auf die Vererbungsbeziehungen wirkt sich nicht auf die generischen Typen
aus.
Generische Array-Typen: Gegeben ist
class C<T>
{
T[] list;
}
T[] ist ein generischer Typ, der in diesem Fall zur Definition einer Instanzvariablen
verwendet wird. Er kann natürlich auch als Typ von lokalen Variablen, MethodenParametern und –Rückgaben dienen. Die Typ-Variable T bezeichnet hier den Typ
eines Array, auf welchen über die Variable list zugegriffen werden kann. Der
Ausdruck list[i] ist daher vom Typ T.
Ein generischer Array-Typ kann auch einen parametrisierten Typ benutzen:
C<A>[] list0;
Das i-te Element einer solchen Liste wäre dann vom Typ C<A>.
Generische Methoden
Generische Typen sind nicht an einen objektorientierten Kontext gebunden. In Java
verläßt man den objektorientierten Kontext in statischen Methoden. Alle Arten vom
Methoden können parametrisiert werden: statische und nicht statische Methoden.
Hierzu ist vor die Methodensignatur in spitzen Klammern eine Liste der in den
Methoden benutzten Typvariablen anzugeben.
Bsp. 49: Generische Methoden der Klasse TestGenericBinaerBaumKnoten
public class TestGenericBinaerBaumKnoten
{
public static void main (String args[])
{
BinaerBaumknoten<Integer> baum = null;
/*
for (int i = 0; i < 20; i++) // 20 Zusfallsstrings speichern
{
String s = "Zufallszahl " + (int)(Math.random() * 100);
if (baum == null) baum = new BinaerBaumknoten(s);
}
print(baum); // Sortiert wieder ausdrucken
*/
for (int i = 0; i < 10; i++)
{
// Erzeuge eine Zahl zwischen 0 und 100
Integer r = new Integer((int)(Math.random()*100));
if (baum == null) baum = new BinaerBaumknoten<Integer>(r);
else baum.insert(r);
}
System.out.println("Inorder-Durchlauf");
print(baum);
49
vgl. pr12362
80
Algorithmen und Datenstrukturen
System.out.println();
System.out.println("Baumdarstellung um 90 Grad versetzt");
ausgBinaerBaum(baum,0);
// Aufruf parametrisierter Methoden
System.out.print("Kleinster Wert: ");
System.out.print(findeMin(baum));
System.out.println();
System.out.print("Groesster Wert: ");
System.out.print(findeMax(baum));
System.out.println();
}
// Generische Methoden
public static <T extends Comparable>
void print (BinaerBaumknoten<? extends T> baum)
// Rekursive Druckfunktion
{
if (baum == null) return;
print(baum.getLinks());
System.out.print(baum.daten + " ");
print(baum.getRechts());
}
public static <T extends Comparable>
void ausgBinaerBaum(BinaerBaumknoten<T> b, int stufe)
{
if (b != null)
{
ausgBinaerBaum(b.getRechts(), stufe + 1);
for (int i = 0; i < stufe; i++)
{
System.out.print("
");
}
System.out.println(b.daten);
ausgBinaerBaum(b.getLinks(), stufe + 1);
}
}
public static <T extends Comparable> T findeMin(BinaerBaumknoten<T> b)
{
return datenZugriff( findMin(b) );
}
public static <T extends Comparable> T findeMax(BinaerBaumknoten<T> b)
{
return datenZugriff( findMax(b) );
}
public static <T extends Comparable> T datenZugriff(BinaerBaumknoten<T> b)
{
return b == null ? null : b.daten;
}
public static <T extends Comparable>
BinaerBaumknoten<T> findMin(BinaerBaumknoten<T> b)
{
if (b == null) return null;
else if (b.getLinks() == null) return b;
return findMin(b.getLinks());
}
public static <T extends Comparable>
BinaerBaumknoten<T> findMax(BinaerBaumknoten<T> b)
{
if (b != null)
while (b.getRechts() != null)
b = b.getRechts();
return b;
}
}
81
Algorithmen und Datenstrukturen
Parametrisierte Methoden werden wie ganz normale Methoden aufgerufen, wie man
in der main()-Methode sehen kann. Der konkrete Typ, der den Typparameter
ersetzt, muß nicht explizit angegeben werden. Er wird automatisch vom Compiler aus
dem Typ des Arguments abgeleitet, das beim Aufruf an die Parametrisierte Methode
übergeben wird. Im vorliegenden Bsp. wird BinaerBaumknoten<T> als Argument
an die findMin()- bzw. findMax()-Methode übergeben.
Generische Schnittstellen
Generische Typen erlauben es, den Typ Object in Typsignaturen zu eliminieren.
Der Typ Object ist als schlecht anzusehen, denn er ist gleichbedeutend damit, dass
keine Information über einen konkreten Typ während der Übersetzungszeit zur
Verfügung steht. In herkömmlichen Java ist in APIs von Bibliotheken der Typ Object
allgegenwärtig. Sogar in der Klasse Object selbst findet man diesen Typ in
Signaturen, z.B. in der Methode equals(). Prinzipiell kann deshalb ein Objekt mit
Objekten jedes beliebigen Typs verglichen werden. Häufig will man aber nur gleiche
Typen miteinander vergleichen. Generische Typen erlauben es, allgemein eine
Gleichheitsmethode zu definieren, in der nur Objekte gleichen Typs miteinander
verglichen werden können.
Generische Typen erweitern sich ohne Umstände auf Schnittstellen. Die
Syntaxregeln sind diesselben wie bei generischen Klassen.
Implementierung generischer Interfaces: Gegeben sind: Zwei Klassen A und B und
ein generisches Interface I
class A {}
class B{}
interface I<T0 extends A, T1 extends B>
{
void m0(T0 t);
void m1(T1 t);
}
Das Interface kann dann folgendermaßen implementiert werden:
class C implements I<A,B>
{
public void m0(A a) { }
public void m1(B b) { }
}
82
Algorithmen und Datenstrukturen
1.3.5.6.3 Wildcard-Typen
Grundlagen
Wildcards bei Typangaben: Generische Typen können ein unbestimmtes
Typargument benennen. Syntaktisch wird dann als Typargument das
Wildcardzeichen '?' angegeben, z.B. BinaerBaumknoten<?> b; . Ein derartiger
generischer Typ heißt Wildcard-Typ. Alle generischen Typen der gleichen
generischen Klasse sind kompatibel zu diesem Wildcard-Typ. Ein Wildcard
bezeichnet keinen konkreten Typ, sondern eine Familie von Typen.
Folgende Klassen sind bspw. definiert:
class A { }
class B { }
class C<T> { }
Folgende Referenzen sind definiert bzw. initialisiert:
C<A> ca = new C<A> ();
C<B> cb = new C<B> ();
Es ist dann nicht möglich, die ca- oder die cb-Variable an eine Variable des Typs
C<Object> zuzuweisen, obwohl A und B natürlich von Object abgeleitet sind:
C<Object> c = ca; // illegal: incompatible Typs
Es ist natürlich möglich, ca und cb an eine Referenz vom raw-type C zuzuweisen:
C c = ca;
Aber auch folgende Zeilen sind legal
C<?> c0 = ca;
C<?> c1 = cb;
C<?> ist ein parametrisierter Typ, dessen Argument ein Wildcard-Typ ist. Das ?
steht hier für irgendeinen Typ.
Es gibt drei Arten von Wildcards: ?extends Type, ? super Type und ?.
Wildcard-Instanziierungen sehen dann z.B. so aus: List<? extends Number?,
List<? super Long> oder List<?>.
Upper-Typebound: Das Wildcard ? extends Number bezeichnet bspw. die Menge
aller Typen, die von Number direkt oder indirekt abgeleitet sind, d.h. die Familie aller
Subtypen von Number (also Long, Integer, usw., inklusive Number). Die WildcardInstanziierung List<extends Number> bezeichnet logischerweise die Familie aller
Instanziierungen des parametrisierten Typs List, bei dem ein Typ aus der Familie der
Subtypen von Number für den Typparameter eingesetzt wurde (, also List<Long>,
List<Integer>, usw., inkl. List<Number>). Zum eingeschränkten Wildcard-Typ
BinaerBaumKnoten <? extends Number> sind nur die generischen
BinaerBaumknoten-Typen kompatibel, deren Typargument Number ist, z.B.:
BinaerBaumknoten<? extends Number> nb;
nb = new BinaerBaumknoten<Integer> (23);
nb = new BinaerBaumknoten<Object> (new Object());
83
// OK
// Fehler
Algorithmen und Datenstrukturen
Ein derartiger Typebound wird auch als Upper-Typebound bezeichnet, weil er aus
Sicht der Vererbungshierarchie eine Obergrenze für die konkreten Typargumente
festlegt.
Lower Typebound: Ein Wildcard wie ? super Long bezeichnet die Menge aller
Supertypen von Long und das Wildcard ? steht für die Menge aller Typen. Es liegt
hier die Umkehrung der Einschränkung vor, wie sie beim Upper-Typebound gegeben
ist. Ein solcher Lower-Typebound ist bspw. durch BinaerBaumknoten<? super
Number> nb gegeben. Die Typargumente müssen hier Basistypen des LowerTypebound sein.
Bsp.: BinaerBaumknoten<? super Number> nb;
nb = new BinaerBaumknoten<Object>(new Object()); // OK
nb = new BinaerBaumknoten<Integer>(23);
// Fehler
Der generische Typ BinaerBaumknoten<Object> ist kompatibel zu
BinaerBaumknoten<? super Number> , weil Object ein Basistyp von Number
ist.
Wildcard-Instanziierung eines parametrisierten Types: Eine Wildcard-Instanziierung
eines parametrisierten Types kann (verglichen mit einer konkreten Instanziierung)
nicht zur Verwendung von Objekten verwendet werden. Man kann zwar eine Variable
vom Typ List<? extends Number> deklarieren, aber man kann kein Objekt vom
Typ List<? extends Number> erzeugen. Eine Variable vom Typ <List?
extends Number> kann aber auf Objekte von kompatiblen Typen (z.B.
List<Long> oder List<Integer>) verweisen. Die kompatiblen Typen sind genau
die Typen aus der Familie von Typen, die die Wildcard-Instanziierung bezeichnet.
Eine Variable vom Typ List<? super Long> kann auf Objekt vom Typ
List<Long> oder List<Number> oder List<Comparable> verweisen. Eine
Variable vom Typ List<?> kann auf beliebige Instanziierungen von List
verweisen.
Der Zugriff auf ein Objekt, das über eine Referenzvariable vom Typ WildcardInstanziierung referenziert wird, ist eingeschränkt. Über eine Variable vom Typ
List<? extends Number> bspw. dürfen keine Methoden des Typs List
aufgerufen werden, die ein Argument von dem Typ nehmen, für das das Wildcard
steht:
List<? extends Number> list = new LinkedList<Integer>();
list.add(new Integer(25));
Bei einem Wildcard mit super ist der Aufruf von Methoden unmöglich, deren
Returntyp von dem Typ ist, für den das Wildcard steht. Für das Wildcard ? gelten
beide Einschränkungen.
Covarianz generischer Typen.
Ein mit extends eingeschränkter Wildcard-Typ wird als covarianter Wildcard-Typ
bezeichnet. Die Kompatiblität zwischen einem covarianten Wildcard-Typ und den
anderen generischen Typen folgt der gleichen Richtung wie die Verebungsbeziehung
zwischen Upper-Typebound und konketem Typargument. Allgemein gilt: C<A> ist
kompatibel zu C<? extends B>, wenn A ist kompatibel zu B
Contravarianz generischer Typen
Die in dem folgenden Beispiel dargestellte Situation
84
Algorithmen und Datenstrukturen
BinaerBaumknoten<? super Number> nb;
nb = new BinaerBaumknoten<Object>(new Object()); // OK
nb = new BinaerBaumknoten<Integer>(23);
// Fehler
wird als Contravarianz bezeichnet, die entsprechenden Wildcardtypen als
contravariante Typen.
Contravarianter Typebound. Mit contravarianten Typebounds kann das Problem
der generischen Knotenklasse BinaerBaumknoten<? extends Number> gelöst
werden, deren Typebound zu streng ist und nur Typargumente zulässt, die selbst das
Interface Comparable implementieren. Akzeptiert werden sollen auch
Typargumente, bei denen irgendeine Basisklasse das Interface Comparable
implementiert.
class BinaerBaumknoten<T extends Comparable<?
{
// Instanzvariable
protected BinaerBaumknoten<T> links;
protected BinaerBaumknoten<T> rechts;
public
T daten;
super T>>
// linker Teilbaum
// rechter Teilbaum
// Dateninhalt der Knoten
// Konstruktor
public BinaerBaumknoten(T datenElement)
{
this(datenElement, null, null );
}
public BinaerBaumknoten(T datenElement,
BinaerBaumknoten<T> l,
BinaerBaumknoten<T> r)
{
daten
= datenElement;
links
= l;
rechts
= r;
}
public void insert (T x)
{
if (x.compareTo(daten) > 0)
// dann rechts
{
if (rechts == null) rechts = new BinaerBaumknoten<T>(x);
else rechts.insert(x);
}
else // sonst links
{
if (links == null) links = new BinaerBaumknoten<T> (x);
else links.insert(x);
}
}
public BinaerBaumknoten<T> getLinks()
{ return links; }
public BinaerBaumknoten<T> getRechts()
{ return rechts; }
}
Diese Art von Typebound befindet sich bspw. auch im Java-API der Klasse
Collections.
Upper- bzw. Lower-Typebounds gibt es auch in generischen Methoden, z.B.:
public class TestGenericBinaerBaumKnotenLB
{
public static void main (String args[])
{
BinaerBaumknoten<Integer> baum = null;
85
Algorithmen und Datenstrukturen
/*
for (int i = 0; i < 20; i++) // 20 Zusfallsstrings speichern
{
String s = "Zufallszahl " + (int)(Math.random() * 100);
if (baum == null) baum = new BinaerBaumknoten(s);
}
print(baum); // Sortiert wieder ausdrucken
*/
for (int i = 0; i < 10; i++)
{
// Erzeuge eine Zahl zwischen 0 und 100
Integer r = new Integer((int)(Math.random()*100));
if (baum == null) baum = new BinaerBaumknoten<Integer>(r);
else baum.insert(r);
}
System.out.println("Inorder-Durchlauf");
print(baum);
System.out.println();
System.out.println("Baumdarstellung um 90 Grad versetzt");
ausgBinaerBaum(baum,0);
System.out.print("Kleinster Wert: ");
System.out.print(findeMin(baum));
System.out.println();
System.out.print("Groesster Wert: ");
System.out.print(findeMax(baum));
System.out.println();
}
public static <T extends Comparable<? super T>>
void print (BinaerBaumknoten<? extends T> baum)
// Rekursive Druckfunktion
{
if (baum == null) return;
print(baum.getLinks());
System.out.print(baum.daten + " ");
print(baum.getRechts());
}
public static <T extends Comparable<? super T>>
void ausgBinaerBaum(BinaerBaumknoten<T> b, int stufe)
{
if (b != null)
{
ausgBinaerBaum(b.getRechts(), stufe + 1);
for (int i = 0; i < stufe; i++)
{ System.out.print("
"); }
System.out.println(b.daten);
ausgBinaerBaum(b.getLinks(), stufe + 1);
}
}
public static <T extends Comparable<? super T>>
T findeMin(BinaerBaumknoten<T> b)
{
return datenZugriff( findMin(b) );
}
public static <T extends Comparable<? super T>>
T findeMax(BinaerBaumknoten<T> b)
{ return datenZugriff( findMax(b) ); }
public static <T extends Comparable<? super T>>
T datenZugriff(BinaerBaumknoten<T> b)
{ return b == null ? null : b.daten; }
public static <T extends Comparable<? super T>>
BinaerBaumknoten<T> findMin(BinaerBaumknoten<T> b)
{
if (b == null) return null;
else if (b.getLinks() == null) return b;
return findMin(b.getLinks());
}
86
Algorithmen und Datenstrukturen
public static <T extends Comparable<? super T>>
BinaerBaumknoten<T> findMax(BinaerBaumknoten<T> b)
{
if (b != null)
while (b.getRechts() != null) b = b.getRechts();
return b;
}
}
87
Algorithmen und Datenstrukturen
2. Datenstrukturen und Algorithmen in Java
Datenstrukturen und Algorithmen sind eng miteinander verbunden. Die Wahl der
richtigen Datenstruktur entscheidet über effiziente Laufzeiten. Beide erfüllen nur
alleine ihren Zweck. Leider ist die Wahl der richtigen Datenstruktur nicht so einfach.
Eine Reihe von schwierigen Problenem in der Informatik wurde deshalb noch nicht
gelöst, da eine passende Datenorganisation bis heute noch nicht gefunden wurde.
Wichtige Datenstrukturen (Behälterklassen, Collection, Container) werden in Java
bereitgestellt. Auch Algorithmen befinden sich in diesen „Sammlungen“.
Sammlungen (Kollektionen) sind geeignete Datenstrukturen (Behälter) zum
Aufbewahren von Daten. Durch ihre vielfältigen Ausprägungen können sie zur
Lösung unterschiedlicher Aufgaben herangezogen werden. Ein Lösungsweg umfaßt
dann das Erzeugen eines solchen Behälters, das Einfügen, Modifizieren und
Löschen der Datenelemente. Natürlich steht dabei im Mittelpunkt der Zugriff auf die
Datenelemente, das Lesen der Dateninformationen und die aus diesen Informationen
resultierenden Schlußfolgerungen. Verallgemeinert bedeutet dies: Das Suchen nach
bestimmten Datenwerten, die in den Datenelementen der Datensammlung
(Kollektion) gespeichert sind.
Suchmethoden bestehen aus einer Reihe bestimmter Operationen. Im wesentlichen
sind dies
- Das Initialisieren der Kollektion ( die Bildung einer Instanz mit der Datenstruktur, auf der die Suche
erfolgen soll
- das Suchen eines Datenelements (z.B. eines Datensatzes oder mehrerer Datensätze)in der
Datensammlung mit einem gegebenen Kriterium (z.B. einem identifizierenden Schlüssel)
- das Einfügen eines neuen Datenelements. Bevor eingefügt werden kann, muß festgestellt werden,
ob das einzufügende Element in der Kollektion schon vorliegt.
- das Löschen eines Datenelements. Ein Datenelement kann nur dann gelöscht werden, falls das
Element in der Kollektion vorliegt.
Häufig werden Suchvorgänge in bestimmten Kollektionen (Tabellen) benötigt, die
Daten über identifizierende Kriterien (Schlüssel) verwalten. Solche Tabellen können
als Wörterbücher (dictionary) oder Symboltabellen implementiert sein. In einem
Wörterbuch sind bspw. die „Schlüssel“ Wörter der deutschen Sprache und die
Datensätze, die zu den Wörtern gehörenden Erläuterungen über Definition,
Aussprache usw. Eine Symboltabelle beschreibt die in einem Programm
verwendeten Wörter (symbolische Namen). Die Datensätze sind die Deklarationen
bzw. Anweisungen des Programms. Für solche Anwendungen sind nur zwei weitere
zusätzliche Operationen interessant:
- Verbinden (Zusammenfügen) von Kollektionen, z.B. von zwei Wörterbüchern zu einem großen
Wörterbuch
- Sortieren von Sammlungen, z.B. des Wörterbuchs nach dem Schlüssel
Die Wahl einer geeigneten Datenstruktur (Behälterklasse) ist der erste Schritt. Im
zweiten Schritt müssen die Algorithmen implementiert werden. Die StandardBibliotheken der Programmiersprache Java bietet Standardalgorithmen an.
88
Algorithmen und Datenstrukturen
2.1 Kollektionen (Collections)
2.1.1 Durchwandern von Daten mit Iteratoren
Bei Datenstrukturen gibt es eine Möglichkeit, gespeicherte Daten unabhängig von
der Implementierung immer mit der gleichen Technik abzufragen. Bei
Datenstrukturen handelt es sich meistens um Daten in Listen, Bäumen oder
ähnlichem und oft wird nur die Frage nach der Zugehörigkeit eines Worts zum
Datenbestand gestellt (z.B. „Gehört das Wort dazu?“). Auch die Möglichkeit Daten in
irgendeiner Form aufzuzählen, ist eine häufig gestellte Aufgabe. Hierfür bieten sich
Iteratoren an. In Java umfaßt das Interface Enumeration die beiden Funktionen
hasMoreElements() und nextElement(), mit denen durch eine Datenstruktur
iteriert werden kann.
public interface Enumeration
{
public boolean hasMoreElements();
// Test, ob noch ein weiteres Element aufgezählt werden kann
public Object nextElement() throws NoSuchElementException;
/* setzt den internen Zeiger auf das nächste Element, d. h. liefert das
das nächste Element der Enumertion zurück. Diese Funktion kann eine
NoSuchException auslösen, wenn nextElement() aufgerufen wird, obwohl
hasMoreElements() unwahr ist
*/
}
Die Aufzählung erfolgt meistens über
for (Enumeration e = ds.elements(); e.hasMoreElements(); )
System.out.println(e.nextElements());
Die Datenstruktur ds besitzt eine Methode elements(), die ein Enumeration-Objekt
zurückgibt, das die Aufzählung erlaubt.
2.1.2 Die Klasse Vector
class java.util.Vector extends AbstractList
implements List, Cloneable, Serializable
Die Klasse Vector beschreibt ein Array mit variabler Länge. Objekte der Klasse
Vector sind Repräsentationen einer linearen Liste. Die Liste kann Elemente
beliebigen Typs enthalten, ihre Länge ist zur Laufzeit veränderbar (Array mit variabler
Länge). Vector erlaubt das Einfügen von Elementen an beliebiger Stelle, bietet
sequentiellen und wahlfreien Zugriff auf die Elemente. Das JDK realisiert Vector als
Array von Elementen des Typs Object. Der Zugriff auf Elemente erfolgt über
Indizes. Es wird dazu aber kein Operator [], sondern es werden Methoden benutzt,
die einen Index als Parameter annehmen.
Anlegen eines neuen Vektors (Konstruktor): public Vector()
public Vector(int initialCapacity,int capacityIncrement)
// Ein Vector vergrößert sich automatisch, falls mehr Elemente aufgenommen werden, als
// ursprünglich vorgesehen (Resizing). Dabei sollen initialCapacity und capacityIncrement
// passend gewählt werden.
Einfügen von Elementen: public void addElement(Object obj)
// Anhängen an des Ende der bisher vorliegenden Liste von Elementen
89
Algorithmen und Datenstrukturen
Eigenschaften: public final boolean isEmpty()
// Prüfen, ob der Vektor leer ist
public final int size()
// bestimmt die Anzahl der Elemente
public final int capacity()
// bestimmt die interne Größe des Arrays. Sie kann mit ensureCapacity() geändert
// werden
Einfügen an beliebiger Stelle innerhalb der Liste:
public void insertElementAt(Object obj, int index) throws
ArrayIndexOutOfBoundsException
// fügt obj an die Position index in den „Vector“ ein.
Zugriff auf Elemente: Für den sequentiellen Zugriff steht ein Iterator zur Verfügung.
Wahlfreier Zugriff erfolgt über:
public Object firstElement() throws ArrayIndexOutOfBoundException;
public Object lastElement() throws ArrayIndexOutOfBoundException;
public Object elementAt(int index) throws ArrayIndexOutOfBoundException;
firstElement() liefert das erste, lastElement() das letzte Element- Mit
elementAt() wird auf das Element an der Position index zugegriffen. Alle 3
Methoden verursachen eine Ausnahme, wenn das gewünschte Element nicht
vorhanden ist.
Arbeitsweise des internen Arrays. Der Vector vergrößert sich automatisch, falls mehr
Elemente aufgenommen werden (resizing).
Die Größe des Felds. Mit capacity() erhält man die interne Größe des Arrays. Sie
kann mit ensureCapacity() geändert werden. ensureCapacity(int
minimumCapacity) bewirkt bei einem Vector, daß er mindestens minCapacity
Elemente aufnehmen soll.
Der Vektor verkleinert nicht die aktuelle Kapazität, falls sie schon höher als
minCapacity ist. Zur Veränderung dieser Größe, dient die Methode trimToSize(). Sie
reduziert die Kapazität des Vectors auf die Anzahl der Elemente, die gerade im
Vector sind.
Die Anzahl der Elemente kann über die Methode size() erfragt werden. Sie kann über
setSize(int newSize) geändert werden. Ist die neue Größe kleiner als die alte,
so werden die Elemente am Ende des Vectors abgeschnitten. Ist newSize größer
als die alte Größe, werden die neu angelegten Elemente mit null initialisiert.
Bereitstellen des Interface Enumerartion. In der Klasse Vector liefert die Methode
public Enumeration elements() einen Enumerator (Iterator) für alle
Elemente, die sich in Vector befinden.
90
Algorithmen und Datenstrukturen
Vector
<< Konstruktoren >>
public Vector()
// Ein Vector in der Anfangsgröße von 10 Elementen wird angelegt
public Vector(int startKapazitaet)
// Ein Vector enthält Platz für startKapazitaet Elemente
public Vector(int startKapazitaet, int kapazitaetsSchrittweite)
<< Methoden >>
public final synchronized Object elementAt(int index)
// Das an der Stelle index befindliche Objekt wird zurückgegeben
public final int size()
public final synchronized Object firstElement()
public final synchronized Object lastElement();
public final synchronized void insertElementAt(Object obj, int index)
// fügt Object obj an index ein und verschiebt die anderen Elemente
public final synchronized void setElementAt(Object obj, int index)
public final synchronized copyInto(Object einArray[])
// kopiert die Elemente des Vektors in das Array einArray
// Falls das bereitgestellte Objektfeld nicht so groß ist wie der Vektor,
// dann tritt eine ArrayIndexOutOfBounds Exception
public final boolean contains(Object obj)
// sucht das Element, liefert true zurück wenn o im Vector vorkommt
public final int indexOf(Object obj)
// sucht im Vector nach dem Objekt obj. Falls obj nicht in der Liste ist, wird
// -1 übergeben
public final int lastIndexOf(Object obj)
public final synchronized boolean removeElement(Object obj)
// entfernt obj aus der Liste. Konnte es entfernt werden, wird true
// zurückgeliefert
public final synchronized void removeElementAt(int index)
// entfernt das Element an Stelle index
public final synchronized void removeAllElements()
// löscht alle Elemente
public final int capacity()
// gibt an, wieviel Elemente im Vektor Patz haben
// (ohne automatische Größenanpassung)
public synchronized Object clone()
// Implementierung der clone()-Methode von Object
// das kopierte Feld wird zurückgegeben. Die Kopie ist flach.
public final synchronized String toString()
Abb.: Die Klasse Vector
91
Algorithmen und Datenstrukturen
2.1.3 Die Klasse Stack
class java.util.Stack extends Vector
Ein Stack ist eine nach dem LIFO-Prinzip arbeitende Datenstruktur. Elemente werden vorn (am
vorderen Ende der Liste) eingefügt und von dort auch wieder entnommen. In Java ist ein Stack eine
Ableitung von Vector mit neuen Zugriffsfunktionen für die Implementierung des typischen Verhaltens
von einem Stack.
Konstruktor: public Stack();
Hinzufügen neuer Elemente: public Object push(Object item);
Zugriff auf das oberste Element:
public Object pop();
// Zugriff und Entfernen des obersten Element
public Object peek()
// Zugriff auf das oberste Element
Suche im Stack: public int search(Object o)
// Suche nach beliebigem Element,
// Rueckgabewert: Distanz zwischen gefundenem und
//
obersten Stack-Element bzw. –1,
//
falls das Element nicht da ist.
Test: public boolean empty()
// bestimmt, ob der Stack leer ist
Vector
Stack
public Stack()
public Object push(Object obj)
public synchronized Object pop()
public synchronized Object peek()
public synchronized int search(Object obj)
public boolean empty()
Abb.: Die Klasse Stack
Anwendungen:
1. Umrechnen von Dezimalzahlen in andere Basisdarstellungen
Aufgabenstellung: Defaultmäßig werden Zahlen dezimal ausgegeben. Ein Stapel, der Ganzzahlen
aufnimmt, kann dazu verwendet werden, Zahlen bezogen auf eine andere Basis als 10 darzustellen.
Die Funktionsweise der Umrechnung von Dezimalzahlen in eine Basis eines anderen Zahlensystem
zeigen die folgenden Beispiele:
92
Algorithmen und Datenstrukturen
2810 = 3 ⋅ 8 + 4 = 34 8
72 10 = 1 ⋅ 64 + 0 ⋅ 16 + 2 ⋅ 4 + 0 = 1020 4
5310 = 1 ⋅ 32 + 1 ⋅ 16 + 0 ⋅ 8 + 1 ⋅ 4 + 0 ⋅ 2 + 1 = 1101012
Mit einem Stapel läßt sich die Umrechnung folgendermaßen unterstützen:
6
leerer Stapel
n = 355310
7
7
4
4
4
1
1
1
1
n%8=1
n/8=444
n%8=4
n/8=55
n = 444 10
n%8=7
n/8=6
n = 5510
n = 610
n%8=6
n/6=0
n = 010
Abb.: Umrechnung von 355310 in 67418 mit Hilfe eines Stapel
Algorithmus zur Lösung der Aufgabe:
1) Die am weitesten rechts stehende Ziffer von n ist n%b. Sie ist auf dem Stapel abzulegen.
2) Die restlichen Ziffern von n sind bestimmt durch n/b. Die Zahl n wird ersetzt durch n/b.
3) Wiederhole die Arbeitsschritte 1) und 2) bis keine signifikanten Ziffern mehr übrig bleiben.
4) Die Darstellung der Zahl in der neuen Basis ist aus dem Stapel abzulesen. Der Stapel ist zu diesem
Zweck zu entleeren.
Implementierung: Das folgende kleine Testprogramm 50 realisiert den Algorithmus und benutzt dazu
eine Instanz von Stack.
import java.util.*;
public class PR61210
{
public static void main(String[] args)
{
int zahl = 3553;
// Dezimalzahl
int b
= 8;
// Basis
Stack s = new Stack();
// Stapel
do
{
s.push(new Integer(zahl % b));
zahl /= b;
} while (zahl != 0);
while (!s.empty())
{
System.out.print(s.pop());
}
System.out.println();
}
}
50
pr61210
93
Algorithmen und Datenstrukturen
Ein Stack ist ein Vector. Die Vector-Klasse wird von der Klasse Stack erweitert. Das
ist sicherlich nicht immer besonders sinnvoll. Funktionen, die im Gegensatz zur
Leistungsfähigkeit eines Stapels stehen sind add(), addAll(), addElement(),
capacity(), clear(), clone(), contains(), copyInto(), elementsAt(),
.... .
2.1.4 Die Klasse Bitset für Bitmengen
class java.util.BitSet implements Cloneable, Serializable
Die Klasse Bitset bietet komfortable Möglichkeiten zur bitweisen Manipulation von
Daten.
Bitset anlegen und füllen. Mit zwei Methoden lassen sich die Bits des Bitsets leicht
ändern: set(bitNummer) und clear(bitNummer).
Mengenorintierte Operationen. Das Bitset erlaubt mengenorientierte Operationen mit
einer weiteren Menge.
BitSet
public void and(BitSet bs)
public void or(BitSet bs)
public void xor(BitSet bs)
public void andNot(Bitset set)
// löscht alle Bits im Bitset, dessen Bit in set gesetzt sind
public void clear(int index)
// Löscht ein Bit. Ist der Index negativ, kommt es
// zur Auslösung von IndexOutOfBoundsException
public void set(int index)
// Setzt ein Bit. Ist der Index negativ, kommt es
// zur Auslösung von IndexOutOfBoundsException
public boolean get(int index)
// liefert den Wert des Felds am übergebenen Index,
// kann IndexOutOfBoundsException auslösen.
public int size()
public boolean equals(Object o)
// Vergeicht sich mit einem anderen Bitset-Objekt o.
Abb.: Die Klasse BitSet
94
Algorithmen und Datenstrukturen
2.1.5 Die Klasse Hashtable und assoziative Speicher
Eine Hashtabelle (Hashtable) ist ein assoziativer Speicher, der Schlüssel (keys) mit
Werten verknüpft. Die Datenstruktur ist mit einem Wörterbuch vergleichbar. Die
Hashtabelle arbeitet mit Schlüssel/Werte Paaren. Aus dem Schlüssel wird nach einer
Funktion – der sog. Hashfunktion – ein Hashcode berechnet. Dieser dient als Index
für ein internes Array. Dieses Array hat zu Anfang ein feste Grösse. Leider hat dieses
Technik einen entscheidenden Nachteil. Besitzen zwei Wörter denselben Hashcode,
dann kommt es zu einer Kollision. Auf ihn muß die Datenstruktur vorbereitet sein.
Hier gibt es verschiedene Lösungsansätze. Die unter Java implementierte Variante
benutzt eine verkettete Liste (separate Chaining). Falls eine Kollision auftritt, so wird
der Hashcode beibehalten und der Schlüssel bzw. Wert in einem Listenelement an
den vorhandenen Eintrag angehängt. Wenn allerdings irgendwann einmal eine Liste
durchsucht werden muß, dann wird die Datenstruktur langsam. Ein Maß für den
Füllgrad ist der Füllfaktor (Load Factor). Dieser liegt zwischen 0 und 100 %. 0
bedeutet: kein Listenelement wird verwendet. 100 % bedeutet: Es ist kein Platz mehr
im Array und es werden nur noch Listen für alle zukommenden Werte erweitert. Der
Füllfaktor sollte für effiziente Anwendungen nicht höher als 75% sein. Ist ein
Füllfaktor nicht explizit angegeben, dann wird die Hashtabelle „rehashed“, wenn mehr
als 75% aller Plätze besetzt sind.
class java.util.Hashtable extends Dictionary implements Map,
Cloneable, Serializable
Erzeugen von einem Objekt der Klasse HashTable:
public Hashtable()
/* Die Hashtabelle enthält eine Kapazität von 11 Einträgen und einen Füllfaktor von 75 % */
public HashTable(int initialCapacity)
/* erzeugt eine Hashtabelle mit einer vorgebenen Kapazität und dem Füllfaktor 0.75 */
public HashTable(int initialCapacity, float loadFactor)
/* erzeugt eine Hashtabelle mit einer vorgebenen Kapazität und dem angegebenen Füllfaktor */
Daten einfügen: public Object put(Object key, Object value)
/* speichert den Schlüssel und den Wert in der Hashtabelle. Falls sich zu dem
Schlüssel schon ein Eintrag in der Hashtabelle befand, so wird dieser
zurückgegeben. Anderenfalls ist der Rückgabewert null. Die Methode ist
vorgegeben vom Interface Map. Es überschreibt die Methode von der Superklasse
Dictionary. */
Daten holen: public Object get(Object key)
Schlüssel entfernen. public Object remove(Object key)
Löschen der Werte. public void clear()
Test. public boolean containsKey(Object key)
// Test auf einen bestimmten Schlüssel
public boolean containsValue(Object value)
// Test auf einen bestimmten Wert
Aufzählen der Elemente. Mit keys() und elements() bietet die Hashtabelle zwei
Methoden an, die eine Aufzählung zurückgeben:
public Enumeration keys()
// liefert eine Aufzählung aller Schlüssel, überschreibt keys() in Dictionary.
public Enumeration elements()
// liefert eine Aufzählung der Werte, überschreibt elements() in Dictionary
95
Algorithmen und Datenstrukturen
Wie üblich liefern beide Iteratoren ein Objekt, welches das Interface Enumeration
implementiert.
Der
Zugriff
erfolgt
daher
mit
Hilfe
der
Methoden
hasMoreElements() und nextElement().
Dictionary
{abstract}
public abstract Object put (Object key, Object value)
publicc abstract Object get(Object key)
public abstract Enumeration elements()
public abstract Enumeration keys()
public abstract int size()
public absztract boolean isEmpty()
public abstract Object remove(Object key)
Hashtable
Map
<< Konstruktor >>
public Hashtable(int initialKapazitaet)
public Hashtable(int initialKapazitaet, float Ladefaktor)
public Hashtable()
<< Methoden >>
public synchronized boolean contains(Object wert)
public synchronized boolean containsKey(Object key)
public synchronized void clear()
public synchronized Object clone()
protected void rehash()
public synchronized String toString()
Properties
<< Konstruktor >>
public Properties()
// legt einen leeren Container an
public Properties(Properties defaults)
// füllt eine Property-Liste mit den angegebenen Default-Werten
<< Methoden >>
public String getProperty(String key)
public String getProperty(String key, String defaultKey)
public synchronized void load(InputStream in) throws IOException
// Hier muß ein InputStream übergeben werden, der die daten der
// Property-Liste zur Verfügung stellt.
public synchronizred void save(OutputStream out, String header)
public void list(PrintStream out)
public void list(PrintWriter out)
public Enumeration propertyNames()
// beschafft ein Enumerations-Objekt mit denen Eigenschaften
// der Property-Liste aufgezählt werden können
Abb.: Die Klassen Hashtable und Properties
96
Algorithmen und Datenstrukturen
Die Klasse Hashtable ist eine Konkretisierung der abstrakten Klasse Dictionary.
Diese Klasse beschreibt einen assoziativen Speicher, der Schlüssel auf Werte
abbildet und über den Schlüsselbegriff einen effizienten Zugriff auf den Wert
ermöglicht. Einfügen und der Zugriff auf Schlüssel erfolgt nicht auf der Basis des
Operators „==“, sondern mit Hilfe der Methode „equals“. Schlüssel müssen daher
lediglich inhaltlich gleich sein, um als identisch angesehen zu werden.
Bsp. 51: Hashtabelle zum Test der Zufallszahlen der Methode Math.random().
import java.util.*;
class Zaehler
{
int i = 1;
public String toString()
{
return Integer.toString(i);
}
}
public class Statistik
{
public static void main(String args[])
{
Hashtable h = new Hashtable();
for (int i = 0; i < 10000; i++)
{
// Erzeuge eine Zahl zwischen 0 und 20
Integer r = new Integer((int)(Math.random() * 20));
if (h.containsKey(r))
((Zaehler) h.get(r)).i++;
else h.put(r,new Zaehler());
}
System.out.println(h);
}
}
Die Klasse Hashtable benutzt das Verfahren der Schlüsseltransformation (HashFunktion) zur Abbildung von Schlüsseln auf Indexpostionen eines Arrays. Die
Kapazität der Hash-Tabelle gibt die Anzahl der Elemente an, die insgesamt
untergebracht werden können. Der Ladefaktor zeigt an, bei welchem Füllungsgrad
die Hash-Tabelle vergrößert werden muß. Das Vergrößern erfolgt automatisch, falls
die Anzahl der Elemente innerhalb der Tabelle größer ist als das Produkt aus
Kapazität und Ladefaktor. Seit dem JDK 1.2 darf der Ladefaktor auch größer als 1
sein. In diesem Fall wird die Hash-Tabelle erst dann vergrößert, wenn der
Füllungsgrad größer als 100% ist und bereits ein Teil der Elemente in den
Überlaufbereichen untergebracht wurde.
Die Klasse Hashtable ist eine besondere Klasse für Wörterbücher. Ein Wörterbuch
ist eine Datenstruktur, die Elemente miteinander assoziiert. Das Wörterbuchproblem
ist das Problem, wie aus dem Schlüssel möglichst schnell der zugehörige Wert
konstruiert wird. Die Lösung des Problems ist: Der Schlüssel wird als Zahl kodiert
(Hashcode) und dient in einem Array als Index. An einem Index hängen dann noch
die Werte mit gleichem Hashcode als Liste an.
51
vgl. pr13215
97
Algorithmen und Datenstrukturen
2.1.6 Die abstrakte Klasse Dictionary
Die Klasse Dictionary ist eine abstrakte Klasse, die Methoden anbietet, wie
Objekte (also Schlüssel und Wert) miteinander assoziiert werden:
public abstract Object put(Object key,Object value)
// fügt den Schlüssel key mit dem verbundenen Wert value in das Wörterbuch
// ein
public abstract Object get(Object key)
//
//
//
//
liefert das zu key gehörende Objekt zurück. Falls kein Wert mit dem
Schlüssel verbunden ist, so liefert get() eine null. Eine null als
Schlüssel oder Wert kann nicht eingesetz werden. In put() würde das zu
einer NullPointerException führen.
public abstract Object remove(Object key)
// entfernt ein Schlüssel/Wertepaar aus dem Wörterbuch. Zurückgegeben wird
// der assoziierte Wert.
public abstract boolean isEmpty()
// true, falls keine Werte im Wörterbuch
public int size()
gibt zurück, wie viele Elemente aktuell im Wörterbuch sind.
public abstract Enumeration keys()
// liefert eine Enumeration für alle Schlüssel
public abstract Enumeration elements()
// liefert eine Enumeration über alle Werte.
2.1.7 Die Klasse Properties
Die Properties Klasse ist eine Erweiterung von Hashtable. Ein Properties Objekt
erweitert die Hashtable um die Möglichkeit, sich unter einem wohldefinierten Format
über einen Strom zu laden und zu speichern.
Erzeugen. public Properties()
// erzeugt ein leeres Propertes Objekt ohne Worte.
public Properties(Properties p)
// erzeugt ein leeres Properties Objekt mit Standard-werten aus den
// übergebenen Properties
Die Methode getProperty(). public String getProperty(String s)
// sucht in den Properties nach der Zeichenkette
public String getProperty(String key, String default)
// sucht in den Properties nach der Zeichenkette key. Ist dieser nicht
// vorhanden, wird der String default zurückgegeben
Eigenschaften ausgeben. Die Methode list() wandert durch die Daten und gibt sie
auf einem PrintWriter aus:
public void list(PrintWriter pw)
// listet die Properties auf dem PrintWriter aus.
98
Algorithmen und Datenstrukturen
2.2 Das Collection Framework
Die Java 2 Plattform hat Java erweitert um das Collection API. Anstatt Collection
kann man auch Container (Behälter) sagen. Ein Container ist ein Objekt, das
wiederum Objekte aufnimmt und die Verantwortung für die Elemente übernimmt. Im
„util“-Paket befinden sich sechs Schnittstellen, die grundlegende Eigenschaften
der Containerklassen definieren.
Das in Java 1.2 enthaltene Collections Framework beinhaltet im Wesentlichen drei
Grundformen: Set, List und Map 52. Jede dieser Grundformen ist als Interface
implementiert. Die Interfaces List und Set sind direkt aus Collection 53 abgeleitet. Es
gibt auch noch eine abstrakte Implementierung des Interface, mit dessen Hilfe das
Erstellen eigener Collections erleichtert wird. Bei allen Collections, die das Interface
Collection implementieren, kann ein Iterator zum Durchlaufen der Elemente mit der
Methode „iterator()“ beschafft werden.
Zusätzlich fordert die JDK 1.2-Spezifikation für jede Collection-Klasse zwei
Konstruktoren:
- Einen parameterlosen Konstruktor zum Anlegen einer neuen Collection.
- Ein mit einem einzigen Collection-Argument ausgestatteter Konstruktor, der eine neue Collection
anlegt und mit den Elementen der als Argument übergebenen Collection auffüllt.
Collections konnen mit foreach-Schleifen durchlaufen werden.
2.2.1 Die Schnittstellen Collection, Iterator, Comparator
Das Interface Collection bildet die Basis der Collection-Klasse und –Interfaces des JDK
1.2. Alle Behälterklassen implementieren das Collection Interface und geben den Klassen
damit einen äußeren Rahmen.
52
53
Maps speichern Element-Paare. Alle Klassen aus dieser Gruppe implementieren das Interface Map.
Collections speichern Einzelelemente
99
Algorithmen und Datenstrukturen
Das Interface Collection
<< interface >>
Collection
public void clear();
// Optional: Löscht alle Elemente in dem Container. Eird dies vom Container nicht unterstützt,
// kommt es zur UnSupportedOperationException
public boolean add(Object o);
// Optional: Fügt ein Objekt dem Container hinzu und gibt true zurück, falls sich das Element
// einfügen läßt. Gibt false zurück, falls schon ein Objektwert vorhanden ist und doppelte Werte
// nicht erlaubt sind.
public boolean add(Tt);
// fügt das Element t ein. Das Ergebnis zeigt an, ob tatsächlich ein Element eingefägt wurde.
public boolean addAll(Collection c);
// fügt alle Elemente der Collection c dem Container hinzu
public boolean remove(Object o);
// Entfernen einer einzelnen Instanz. Rückgabewert ist true, wenn das Element gefunden und
// entfernt werden konnte
public boolean remove(T t);
// löscht das Element t. Das Ergebnis zeigt an, ob tatsächlich ein Element gelöscht wurde
public boolean removeAll(Collection c);
// Oprtional: Entfernt alle Objekte der Collection c aus dem Container
public boolean contains(Object o);
// liefert true, falls der Container das Element enthält
public boolean contains(T t);
// gibt Auskunft, ob das Element t in dieser Collection enthalten ist
// Rückgabewert ist true, falls das vorgegebene Element gefunden werden konnte
public boolean containsAll(Collection c);
// liefert true, falls der Container alle Elemente der Collection c enthält.
public boolean equals(Object o);
// vergleicht das angegebene Objekt mit dem Container, ob die gleichen Elemente vorkommen.
public boolean isEmpty();
// liefert true, falls der Container keine Elemente enthält
public int size();
// gibt die Größe des Containers zurück (Anzahl der Elemente)
public boolean retainAll(Collection c);
public Iterator iterator(); // liefert Iterator über die Kollektion
publicObject [] toArray();
// gibt ein Array mit Elementen des Containers zurück
public Object [] toArray(Object [] a);
public int hashCode();
// liefert den Hashwert des Containers
public String toString()
// Rückgabewert ist die Zeichenketten-Repräsentation der Kollektion.
Abb.: Das Interface Collection
Die abstrakte Basisklasse AbstractCollection implementiert die Methoden des
Interface Collection (ohne iterator() und size()). AbstractCollection ist
die Basisklasse von AbstractList und AbstractSet.
100
Algorithmen und Datenstrukturen
Das Interface Iterator
Ein Iterator durchläuft von Beginn an elementweise durch eine Kollektion. Sprünge
sind nicht möglich. Ein Iterator ist verbraucht, wenn er am Ende angekommen ist. Er
kann nicht wiederverwendet werden. Statt dessen erzeugt man für einen nächsten
Durchlauf einen neuen Iterator. Innerhalb einer Kollektion können gleichzeitig
mehrere Iteratoren unterwegs sein. Sie sind unabhängig voneinander und können
einzeln bewegt werden.
<< interface >>
Iterator
public boolean hasNext();
// gibt true zurück, wenn der Iterator mindestens ein weiteres Element enthält.
public Object next();
// liefert das nächste Element bzw. löst eine Ausnahme des Typs NoSuchElementException
// aus, wenn es keine weiteren Elemente gibt
public void remove();
// entfernt das Element, das der Iterator bei next() geliefert hat.
Abb.:
Bei allen Collections, die das Interface Collection implementieren, kann ein Iterator
zum Durchlaufen der Elemente mit der Methode „iterator()“ beschafft werden.
Das Interface Comparator
Vergleiche zwischen Objekten werden mit speziellen Objekten vorgenommen, den
Comparatoren. Ein konkreter Comparator implementiert die folgende Schnittstelle.
<< interface >>
java.util.Comparator
public int compare(Object o1, Object o2)
// vergleicht 2 Argumente auf ihre Ordnung
public boolean equals(Object arg)
// testet, ob zwei Objekte bzgl. des Comparator-Objekts gleich sind
Abb.
101
Algorithmen und Datenstrukturen
2.2.2 Die Behälterklassen und Schnittstellen des Typs List
Behälterklassen des Typs List fassen eine Menge von Elementen zusammen, auf die
sequentiell oder über Index (-positionen) zugegriffen werden kann. Wie Vektoren der
Klasse Vector 54 hat das erste Element den Index 0 und das letzte den Index „size()
– 1“. Es ist möglich an einer beliebigen Stelle ein Element einzufügen oder zu
löschen. Die weiter hinten stehenden Elemente werden dann entsprechend weiter
nach rechts bzw. nach links verschoben.
Das Interface List 55
<< interface >>
List
public void add(int index, Object element);
// Einfügen eines Elements an der durch Index spezifizierten Position
public boolean add(Object o);
// Anhängen eines Elements ans Ende der Liste
// Rückgabewert ist true, falls die Liste durch den Aufruf von add verändert wurde. Er ist false,
// wenn die Liste nicht verändert wurde. Das kann bspw. der Fall sein, wenn die Liste keine
// Duplikate erlaubt und ein bereits vorhandenes Element noch einmal eingefügt werden soll.
// Konnte das Element aus einem anderen Grund nicht eingefügt werden, wird eine Ausnahme
// des Typs UnsupportedOperationException, CallsCastException oder IllegalArgumentException
// ausgelöst
public boolean addAll(Collection c);
// Einfügen einer vollständigen Collection in die Liste. Der Rückgabewert ist true, falls die Liste
// durch den Ausfruf von add veränder wurde
public boolean addAll(int index, Collection c)
public void clear();
public boolean equals(Object object);
public boolean contains(Object element);
public boolean containsAll(Collection collection);
public int indexOf(T t);
// ersetzt das Element an Indexposition i durch t. Als Ergebnis wird das alte Element zurückgeliefert.
public T lastIndexOf(T t)
// wie indexOf(), sucht aber das letzte Vorkommen von t
public Object remove(int index)
public boolean remove(Object element);
public boolean removeAll(Collection c);
// Alle Elemente werden gelöscht, die auch in der als Argument angebenen
// Collection enthalten sind.
public boolean retainAll(Collection c);
// löscht alle Elemente außer den in der Argument-Collection enthaltenen
public Object get();
public T get(int I);
// liefert das Element an Indexposition i. Indexfehler lösen eine Exception vom Typ
// IndexOutOfBoundsExcetion aus
public int hashCode();
public Iterator iterator();
public ListIterator listIterator():
public ListIterator listIterator(int startIndex);
public Object set(int index, Obeject element);
public T set(int I,T t)
54
seit Java 1.2 implementiert die Klasse Vector die Schnittstelle List
Da ebenfalls das AWT-Paket eine Klasse mit gleichen namen verwendet, muß der voll qualifizierte Name in
Anwendungen benutzt werden.
55
102
Algorithmen und Datenstrukturen
// ersetzt das Element an Indexposition i durch t. Als Ergebnis wird das alte Element zurückgeliefert.
public List subList(int fromIndex, int toIndex);
public Object [] toArray();
public Object [] toArray(Object [] a);
Abb.:
Auf die Elemente einer Liste läßt sich mit einem Index zugreifen und nach Elementen
läßt sich mit linearem Aufwand suchen. Doppelte Elemente sind erlaubt. Die
Schnittstelle List, die in ArrayList und LinkedList eine Implementierung findet, erlaubt
sequentiellen Zugriff auf die gespeicherten gespeicherten Elemente. Das Interface
List wird im JDK von verschiedenen Klassen implementiert:
AbstractList
ist eine abstrakte Basisklasse (für eigene List-Implementierungen), bei der alle
Methoden die Ausnahme UnsupportedException auslösen und diverse Methoden
abstract deklariert sind. Die direkten Subklassen sind AbstractSequentialList,
ArrayList und java.util.Vector. AbstractList implementiert bereits viele Methoden für
die beiden Listen-Klassen 56:
abstract class AbstractList extends AbstractCollection implements List
<< Methoden >>
public void add(int index, Object element)
// Optional: Fügt ein Objekt an der spezifizierten stelle ein
public boolean add(Object o)
// Optional: Fügt das Element am Ende an
public boolean addAll(int index, Collection c)
// Optional: Fügt alle Elemente der Collection ein
public void clear()
// Optional: Löscht alle Elemente
public boolean equals(Object o)
// vergleicht die Liste mit dem Objekt
public abstract Object get(int index)
// liefert das Element an dieser Stelle
int hashCode()
// liefert HashCode der Liste
int indexOf(Object o)
// liefert Position des ersten Vorkommens für o oder –1,
// wenn das Element nicht existiert.
Iterator iterator()
// liefert den Iterator. Überschreibt die Methode AbstractCollection,
// obwohl es auch listIterator() für die spezielle Liste gibt. Die Methode
// ruft aber listIterator() auf und gibt ein ListIterator-Objekt zurück
Object remove(int index)
// löscht ein Element an Position index.
protected void removeRange(int fromIndex, int toIndex)
// löscht Teil der Liste von fromIndex bis toIndex. fromIndex wird mitgelöscht,
// toIndex nicht.
public Object set(int index, Object element)
// Optional. Ersetzt das Element an der Stelle index mit element.
public List subList(int fromIndex, int toIndex)
// liefert Teil einer Liste fromIndex (einschließlich) bis toIndex (nicht mehr dabei)
56
Beim Aufruf einer optionalen Methode, die von der Subklasse nicht implementiert wird, führt zur
UnsupportedOperationException.
103
Algorithmen und Datenstrukturen
Abb.:
AbstractSequentialList
bereitet die Klasse LinkedList darauf vor, die Elemente in einer Liste zu verwalten
und nicht wie ArrayList in einem internen Array.
LinkedList
realisiert die doppelt verkettete, lineare Liste und implementiert List.
ArrayList
implementiert die Liste als Feld von Elementen und implementiert List. Da Arraylist
ein Feld ist, ist der Zugriff auf ein spezielles Element sehr schnell. Eine LinkedList
muß aufwendiger durchsucht werden. Die verkettete Liste ist aber deutlich im Vorteil,
wenn Elemente gelöscht oder eingefügt werden.
<< interface >>
Collection
<< interface >>
List
LinkedList
ArrayList
<< Konstruktor >>
public LinkedList();
public LinkedList(Collection collection);
<< Konstruktor
public ArrayList();
public ArrayList(Collection collection);
public ArrayList(int anfangsKapazitaet);
<< Methoden >>
public void addFirst(Object object);
public void addLast(Object object);
public Object getFirst();
public Object getLast();
public Object removeFirst();
public Object removeLast();
<< Methoden >>
protected void removeRange
(int fromIndex, int toIndex)
// löscht Teil der Liste von
// fromIndex bis toIndex. fromIndex wird
// mitgelöscht, toIndex nicht.
Abb.
104
Vector
Algorithmen und Datenstrukturen
Das Interface ListIterator
<< interface >>
ListIterator
public boolean hasPrevious();
// bestimmt, ob es vor der aktuellen Position ein weiteres Element gibt, der Zugriff ist mit
// previous möglich
public boolean hasNext();
public Object next();
public Object previous();
public int nextIndex();
puplic int previousIndex();
public void add(Object o);
// Einfügen eines neuen Elements an der Stelle der Liste, die unmittelbar vor dem nächsten
// Element des Iterators liegt
public void set(Object o);
// erlaubt, das durch den letzten Aufruf von next() bzw. previous() beschaffene Element zu
// ersetzen
public void remove();
Abb.:
ListIterator ist eine Erweiterung von Iterator. Die Schnittstelle fügt noch
Methoden hinzu, damit an aktueller Stelle auch Elemente eingefügt werden können.
Mit einem ListIterator läßt sich rückwärts laufen und auf das vorgehende
Element zugreifen.
Bsp.:
1. Simulation einer Schlange 57
public class Queue
{
private java.util.LinkedList list = new java.util.LinkedList();
public Queue() { }
public void clear() { list.clear(); }
public boolean isEmpty() { return list.isEmpty(); }
public Object firstEl() { return list.getFirst(); }
public Object dequeue() { return list.removeFirst(); }
public void enqueue(Object el) { list.add(el); }
public String toString() { return list.toString(); }
}
Eine verkettete Liste hat neben den normalen Funktionen aus AbstractList noch weitere
Hilfsmethoden zur Implementierung von einem Stack oder einer Schlange. Es handelt sich dabei um
die Methoden addFirst(), addLast(), getFirst(), getLast() und removeFirst().
import java.util.*;
public class ListBsp
{
public static void main(String [] args)
{
LinkedList schlange = new LinkedList();
schlange.add("Thomas");
schlange.add("Andreas");
57
Vgl. pr22220
105
Algorithmen und Datenstrukturen
schlange.add("Josef");
System.out.println(schlange);
schlange.removeFirst();
schlange.removeFirst();
System.out.println(schlange);
}
}
2. Entfernen von Duplikaten 58
import java.util.*;
public class EntfDupl
{
public static void main(String args[])
{
int [] a = { 1, 7, 7, 1, 5, 1, 2, 7, 2, 1, 6, 6, 3, 6, 7 };
LinkedList l = new LinkedList();
for (int i = 0; i < a.length; i++)
{ l.add(new Integer(a[i])); }
System.out.println(l);
int i = 0;
do
{
int aktWert = ((Integer) l.get(i)).intValue();
i++;
if (i == l.size()) break;
int j = i;
do
{
int wert = ((Integer) l.get(j)).intValue();
if (wert == aktWert) l.remove(j);
j++;
} while (j < l.size());
System.out.println(l);
} while (i < l.size());
}
}
58
vgl. pr22220
106
Algorithmen und Datenstrukturen
2.2.3 Queues
Eine Queue arbeitet nach dem FIFO-Prinzip: Zuerst eingeführte Elemente werden
zuerst wieder ausgegeben.
<<interface>>
java.lang.Iterabe<T>
<<interface>>
java.util.Collection<E>
<<interface>>
java.util.Queue<E>
<<interfave>>
java.util.Deque<E>
<<interface>>
java.util.List<E>
java.util.LinkedList
Abb.:
Die Schnittstelle Queue für (Warte-) Schlangen 59
Die Schnittstelle Queue erweitert Collection und ist somit auch vom Typ
Iterable.
public interface Queue<E> extends Collection<E>
{
E element()
boolean offer(E o)
E peek()
E poll()
E remove()
}
Die Schnittstelle Deque
59
http://www.galileocomputing.de/openbook/javainsel7/javainsel_12_007.htm
Stand März 2008
107
Algorithmen und Datenstrukturen
Die Datenstruktur Deque 60 (double ended queue) hängt an beiden Enden Daten.
Blockierende Queues und Prioritätswarteklassen
Die Schnittstelle java.util.concurrent.BlockingQueue erweitert die Schnittstelle
java.util.Queue. Klassen, die BlockingQueue implementieren, blockieren, falls eine
Operation wegen fehlender Daten nicht durchgeführt werden kann.
ConcurrentQueue
DelayQueue
ArrayBlockingQueue
PriorityQueue. Hält in einen Heap-Speicher Elemente sortiert 61 und liefert bei
Anfragen das jeweils kleinste Element. Wie beim TreeSet 62 müssen die Elemente
entweder Comparable implementieren oder es muß ein Comperator angegeben
werden.
PriorityBlockingQueue. Wie PriorityQueue, nur blockierend.
SynchronousQueue
Die Priority-Klassen implementieren im Gegensatz zu den übrigen Klassen kein
FIFO-Verhalten.
60
Deque wird als „Deck“ ausgesprochen
http://java.sun.com/j2se/1.5.0/docs/api/java/util/PriorityQueue.html
Stand: März 2008
62 vgl. 2.2.4
61
108
Algorithmen und Datenstrukturen
2.2.4 Behälterklassen des Typs Set
Ein Set ist eine Menge, in der keine doppelten Einträge vorkommen können. Set hat
die gleichen Methoden wie Collection. Standard-Implementierung für Set sind das
unsortierte HashSet (Array mit veränderlicher Größe) und das sortierte TreeSet
(Binärbaum).
Die Schnittstelle Set
Ist eine im mathematischen Sinne definierte Menge von Objekten. Die Reihenfolge
wird durch das Einfügen festgelegt. Wie von mathematischen Mengen bekannt, darf
ein Set keine doppelten Elemente enthalten. Besondere Beachtung muß Objekten
geschenkt werden, die ihren Wert nachträglich ändern. Die kann ein Set nicht
kontrollieren. Eine Menge kann sich nicht selbst als Element enthalten.
Zwei Klassen ergeben sich aus Set: die abstrakte Klasse AbstractSet und die
konkrete Klasse HashSet.
Die Schnittstelle SortedSet
erweitert Set so, daß Elemente sortiert ausgelesen werden können. Sortierkriterium
kann sein
- Comparable. Elemente müssen Comparable Interface implementieren, Vergleich der Elemente
erfolgt mit compareTo().
- Comperator. SortedSet wird ein eigenes Comperator-Objekt übergeben, mit denen die Elemente
bearbeitet werden können
Bsp. 63:
import java.util.*;
public class SetBeispiel
{
public static void main(String args [])
{
Set set = new HashSet();
set.add("Gerhard");
set.add("Thomas");
set.add("Michael");
set.add("Peter");
set.add("Christian");
set.add("Valentina");
System.out.println(set);
Set sortedSet = new TreeSet(set);
System.out.println(sortedSet);
}
}
63
Vgl. pr13230
109
Algorithmen und Datenstrukturen
<< interface >>
Collection
<< interface >>
Set
public boolean add(Object element);
public boolean addAll(Collection collection);
public void clear();
public boolean equals(Object object);
public boolean contains(Object element);
public boolean containsAll(Collection collection);
public int hashCode();
public Iterator iterator();
public boolean remove(Object element);
public boolean removeAll(Collection collection);
public boolean retainAll(Collection collection);
public int size();
public Object[] toArray();
public Object[] toArray(Object[] a);
HashSet
<< interface >>
SortedSet
public HashSet();
public HashSet(Collection collection);
public HashSet(int anfangskaazitaet);
public HashSet(int anfangskapazitaet,
int ladeFaktor);
public Object first();
public Object last();
public SortedSet headSet(Object toElement);
public SortedSet subSet(Object fromElement,
Object toElement);
public SortedSet tailSet(Object fromElement);
public Comparator comparator();
public Object fisrt();
public Object last();
TreeSet 64
public TreeSet()
public TreeSet(Collection collection);
public TreeSet(Comparator vergleich);
public TreeSet(SortedSet collection);
64
implementiert die sortierte Menge mit Hilfe der Klasse TreeMap, verwendet einen Red-Black-Tree als
Datenstruktur
110
Algorithmen und Datenstrukturen
2.2.5 Behälterklassen des Typs Map
Ein Map ist eine Menge von Elementen, auf die über Schlüssel zugegriffen wird.
Jedem Schlüssel (key) ist genau ein Wert (value) zugeordnet. StandardImplementierungem sind HashMap, HashTable und TreeMap.
Interface Map, SortedMap und implemetierende Klassen
<< interface >>
Collection
<< interface >>
Map
public void clear();
public boolean containskey(Object key);
public boolean containsValue(Object value);
public Set entrySet();
public Object get(Object key);
public boolean isEmpty();
public Set keySet();
public Object remove(Object key);
public int size();
public Collection values();
HashMap
public HashMap();
public Hashmap(Collection collection);
public HashMap(int anfangskapazitaet);
public HashMap(int anfangskapazitaet,
int ladeFaktor);
<< interface >>
SortedMap
public Comparator comparator();
public Object firstKey();
public Object lastKey();
public SortedMap headmap(Object toKey);
public SortedMap subMap(Object fromKey,
Object toKey);
public SortedMap tailMap(Object fromKey);
Hashtable
TreeMap
public TreeMap();
public TreeMap(Map collection);
public TreeMap(Comparator vergleich);
public TreeMap(SortedMap collection);
Abb.:
Die Schnittstelle Map
Eine Klasse, die Map implementiert, behandelt einen assoziativen Speicher. Dieser
verbindet einen Schlüssel mit einem Wert. Die Klasse Hashtable erbt von Map.
Map ist für die implementierenden Klassen AbstractMap, HashMap, Hashtable,
111
Algorithmen und Datenstrukturen
RenderingHints, WeakHashMap und Attributes das, was die abstrakte Klasse
Dictionary für die Klasse Hashtable ist.
Die Schnittstelle SortedMap
Eine Map kann mit Hilfe eines Kriteriums sortiert werden und nennt sich dann
SortedMap. SortedMap erweitert direkt Map. Das Sortierkriterium wird mit einem
speziellen Objekt, das sich Comparator nennt, gesetzt. Damit besitzt auch der
assoziative Speicher über einen Iterator eine Reihenfolge. Nur die konkrete Klasse
TreeMap implementiert bisher eine SortedMap.
Die abstrakte Klasse AbstractMap implementiert die Schnittstelle Map.
Die konkrete Klasse HashMap implementiert einen assoziativen Speicher, erweitert
die Klasse AbstractMap und implementiert die Schnittstelle Map.
Die konkrete Klasse TreeMap erweitert AbstractMap und implementiert
SortedMap. Ein Objekt von TreeMap hält Elemente in einem Baum sortiert.
Bsp. 65: Aufbau und Anwendung einer Hash-Tabelle
import java.io.*;
import java.util.*;
public class HashTabTest
{
public static void main(String [ ] args)
{
Map map = new HashMap();
String eingabeZeile
= null;
BufferedReader eingabe = null;
try {
eingabe = new BufferedReader(
new FileReader("eing.txt"));
}
catch (FileNotFoundException io)
{
System.out.println("Fehler beim Einlesen!");
}
try {
while ( (eingabeZeile = eingabe.readLine() ) != null)
{
StringTokenizer str = new StringTokenizer(eingabeZeile);
if (eingabeZeile.equals("")) break;
String key
= str.nextToken();
String daten = str.nextToken();
System.out.println(key);
map.put(key,daten);
}
}
catch (IOException ioe)
{
System.out.println("Eingefangen in main()");
}
try {
eingabe.close();
}
catch(IOException e)
{
System.out.println(e);
}
System.out.println("Uebersicht zur Hash-Tabelle");
System.out.println(map);
65
vgl. pr23300
112
Algorithmen und Datenstrukturen
//h.printHashTabelle();
System.out.println("Abfragen bzw. Modifikationen");
// Wiederauffinden
String eingabeKey = null;
BufferedReader ein = new BufferedReader(
new InputStreamReader(System.in));
System.out.println("Wiederauffinden von Elementen");
while (true)
{
try {
System.out.print("Bitte Schluessel eingeben, ! bedeutet Ende: ");
eingabeKey = ein.readLine();
// System.out.println(eingabeKey);
if (eingabeKey.equals("!")) break;
String eintr = (String) map.get(eingabeKey);
if (eintr == null)
System.out.println("Kein Eintrag!");
else
{
System.out.println(eintr);
System.out.println("Soll dieser Eintrag geloescht werden? ");
String antwort = ein.readLine();
// System.out.println(antwort);
if ((antwort.equals("j")) || (antwort.equals("J")))
{
// System.out.println("Eintrag wird entfernt!");
map.remove(eingabeKey);
}
}
}
catch(IOException ioe)
{
System.out.println(eingabeKey +
" konnte nicht korrekt eingelesen werden!");
}
}
System.out.println(map);
System.out.println("Sortierte Tabelle");
Map sortedMap = new TreeMap(map);
System.out.println(sortedMap);
}
}
113
Algorithmen und Datenstrukturen
2.2.6 Algorithmen
Die Wahl einer geeigneten Datenstruktur ist der erste Schritt. Im zweiten Schritt
müssen die Algorithmen implementiert werden. Die Java Bibliothek hilft mit einigen
Standardalgorithmen weiter. Dazu zählen Funktionen zum Sortieren und Suchen in
Containern und das Füllen von Containern. Zum flexiblen Einsatz dieser Funktionen
haben die Java-Entwickler die Klasse Collections 66 bereitgestellt. Collections bietet
Algorithmen statischer Funktionen an, die als Parameter ein Collection Objekt
erwarten. Objekte der Klasse Collections sind nicht sinnvoll, der einzige Konstruktor
dieser Klasse ist deshalb private. Leider sind viele Algorithmen nur auf ListObjekte definiert 67, z.B.
public static void shuffle(List<T> liste)
// mischt die Elöemente von liste zufällig
public static void shuffle(List liste)
// würfelt die Werte einer Liste durcheinander
Bsp. 68:
import java.util.*;
public class VectorShuffle
{
public static void main(String args[])
{
Vector v = new Vector();
for (int i = 0; i < 10; i++)
v.add(new Integer(i));
Collections.shuffle(v);
System.out.println(v);
}
}
public static void shuffle(List list, random rnd)
// würfelt die Werte der Liste durcheinander und benutzt dabei den Random Generator rnd.
Nur die Methoden min() und max() arbeiten auf allgemeinen Collection-Objekten.
2.2.5.1 Datenmanipulation
Daten umdrehen. Die Methode reverse() dreht die Werte einer Liste um. Die
Laufzeit ist linear zu der Anzahl der Elemente.
public static void reverse(List l)
// dreht die Elemente in der Liste um
Listen füllen. Mit der fill()-Methode läßt sich eine Liste in linearer Zeit belegen.
Nützlich ist dies, wenn eine Liste mit Werten initialisiert werden muß.
public static void fill (List l, Object o)
// füllt eine Liste mit dem Element o
Daten zwischen Listen kopieren. Die Methode copy(List quelle, List ziel)
kopiert alle Elemente von quelle in die Liste ziel und überschreibt dabei
Elemente, die evtl. an dieser Stelle liegen.
66
Die Klasse Collections spielt eine ähnliche Rolle wie die Klasse Arrays
Nutzt die Collection Klasse keine List Objekte, arbeitet sie mit Iterator Objekten, um allgemein zu bleiben
68 vgl. pr22122
67
114
Algorithmen und Datenstrukturen
public static void copy(List quelle, List ziel)
// kopiert Elemente von quelle nach ziel. Ist ziel zu klein, gibt es eine IndexOutOfBoundsException
2.2.5.2 Größter und kleinster Wert einer Collection
Die Methoden min() und max() suchen das größte und kleinste Element einer
Collection. Die Laufzeit ist linear zur Größe der Collection. Die Methoden machen
keinen Unterschied, ob die Liste schon sortiert ist oder nicht.
public static Object min(Collection c)
//
public static Object max(Collection c)
/* Falls min() bzw. max() auf ein Collection-Objekt angewendet wird, erfolgt die Bestimmung des
Minimums bzw. Maximums nach der Methode compareTo() der Comparable Schnittstelle. Byte,
Character, Double, File, Float, Long, Short, String, Integer, BigInteger, ObjectStreamField, Date und
Calendar haben diese Schnittstelle implementiert. Lassen sich die Daten nicht vergleichen, dann gibt
es eine ClassCastException.
*/
public static Object min(Collection c, Comparator vergl)
//
public static Object max(Collection c, Comparator vergl)
2.2.5.3 Sortieren
Die Collection Klasse bietet zwei sort() Methoden an, die die Elemente einer Liste
stabil 69 sortieren. Die Methode sort() sortiert die Elemente in ihrer natürlichen
Ordnung 70, z.B.:
- Zahlen nach der Größe (13 < 40)
- Zeichenketten alphanumerisch (Juergen < Robert < Ulli)
Eine zweite überladene Form von sort() arbeitet mit einem speziellen Comparator
Objekt, das zwei Objekte mit der Methode compare() vergleicht.
public static void sort(List liste)
// sortiert die Liste
public static void sort(List liste,Comparator c)
// sortiert die Liste mit dem Comparator c
public static void sort(List<T> liste)
// sortiert die Elemente der Liste mit Elementtyp T nach aufsteigender Größe
Viele der Methoden der Klasse Collections , u.a.a sort() sind mit einem Comparator
überladen. Die Methoden erfüllen den gleichen Zweck wie die Fassungen ohne
Comparator. Zum Element-Vergleich benutzen sie aber das als Argument
übergebene Comparator-Objekt anstelle der compareTo-Methode des Elementtyps.
Comparator ist ein generisches Interface mit einer einzigen Methode compare. Diese
Methode akzeptiert zwei Objekte des Elementtyps und liefert ein int-Ergebnis (wie
compareTo()).
69
Stabile Sortieralgorithmen beachten die Reihenfolge von gleichen Elementen, z.B. beim Sortieren von
Nachrichten in einem Email-Programm, zuerst nach dem Datum und anschließend nach dem Sender, soll die
Liste innerhalb des Datum sortiert bleiben.
70 Zu sortierende Elemente müssen das Interface Comparable implemetieren bzw. implementiert haben. Die
Klassen String, Integer, Double, Float ... sind bereits Comparable, ebenso Date, File und einige andere.
115
Algorithmen und Datenstrukturen
Die Sortierfunktion arbeitet nur mit List-Objekten. „sort()“ gibt es aber auch in der
Klasse Arrays.
Bsp.: Das folgende Programm sortiert eine Reihe von Zeichenketten in aufsteigender
Folge. Es nutzt die Methode Arrays.asList() zur Konstruktion einer Liste aus
einem Array 71.
import java.util.*;
public class CollectionsSortDemo
{
public static void main(String args[])
{
String feld[] =
{ "Regina","Angela","Michaela","Maria","Josepha",
"Amalia","Vera","Valentina","Daniela","Saida",
"Linda","Elisa"
};
List l = Arrays.asList(feld);
Collections.sort(l);
System.out.println(l);
}
}
Die Java Bibliothek bietet nicht viel zur Umwandlung von Feldern („Array“) in
dynamische Datenstrukturen. Eine Ausnahme bildet die Hilfsklasse Arrays, die die
Methode asList() anbietet. Die Behälterklassen ArrayList und LinkedList
werden über asList() nicht unterstützt, d.h. Über asList() wird zwar eine interne
Klasse ArrayList benutzt, die eine Erweiterung von AbstractList ist, aber nur
das notwendigste implementiert.
Sortieralgorithmus. Es handelt sich um einen optimierten „Merge-Sort“. Seine
Laufzeit beträgt N ⋅ log( N ) .
Die sort() Methode arbeitet mit der toArray() Funktion der Klasse List. Damit
werden die Elemente der Liste in einem Feld (Array) abgelegt. Schließlich wird die
sort() Methode der Klasse Arrays genutzt und mit einem ListIterator wieder
in die Liste eingefügt.
Daten in umgekehrter Reihenfolge sortieren. Das wird über ein spezielles
Comparator-Objekt geregelt, das von Collections über die Methode
reverseOrder() angefordert werden kann.
Bsp. 72:
import java.util.*;
public class CollectionsReverseSortDemo
{
public static void main(String args[])
{
Vector v = new Vector();
for (int i = 0; i < 10; i++)
{ v.add(new Double(Math.random())); }
Comparator comparator = Collections.reverseOrder();
Collections.sort(v,comparator);
System.out.println(v);
}
}
Eine andere Möglichkeit für umgekehrt sortierte Listen besteht darin, erst die Liste
mit sort() zu sortieren und anschließend mit reverse() umzudrehen.
71
72
Leider gibt es keinen Konstruktor für ArrayList, der einen Array mit Zeichenketten zuläßt.
Vgl. pr22122
116
Algorithmen und Datenstrukturen
2.2.5.4 Suchen von Elementen
Die Behälterklassen enthalten die Methode contains(), mit der sich Elemente
suchen lassen. Für sortierte Listen gibt es eine wesentlich schnellere Suchmethode:
binarySearch():
public static int binarySearch(List liste, Object key)
// sucht ein Element in der Liste. Gibt die Position zurück oder ein Wert kleiner 0,
// falls key nicht in der Liste ist.
public static int binarySearch(List liste, Object key, Comparator c)
// Sucht ein Element mit Hilfe des Comparator Objekts in der Liste. Gibt die Position zurück oder
// einen Wert kleiner als 0, falls der key nicht in der Liste ist.
public static int binarySearch(List<T> liste, T key)
// sucht das Element key in der sortierten Liste t und liefert seinen Index zurück.
// Bei mehreren Vorkommen von key wird der Index irgendeines t geliefert.
Bsp. 73: Das folgende Programm sortiert (zufällig ermittelte) Daten und bestimmt
Daten in Listen mit Hilfe der binären Suche.
import java.util.*;
public class ListSort
{
public static void main(String [] args)
{
final int GR = 20;
// Verwenden einer natuerliche Ordnung
List a = new ArrayList();
for (int i = 0; i < GR; i++)
a.add(new VglClass((int)(Math.random() * 100)));
Collections.sort(a);
Object finde = a.get(GR / 2);
int ort = Collections.binarySearch(a,finde);
System.out.println("Ort von " + finde + " = " + ort);
// Verwenden eines Comparator
List b = new ArrayList();
// Bestimmt zufaellig Zeichenketten der Laenge 4
for (int i = 0; i < GR; i++) b.add(Felder.randString(4));
// Instanz fuer den Comparator
AlphaVgl av = new AlphaVgl();
// Sortieren
Collections.sort(b,av);
// Binaere Suche
finde = b.get(GR / 2);
ort = Collections.binarySearch(b,finde,av);
System.out.println(b);
System.out.println("Ort von " + finde + " = " + ort);
}
}
73
Vgl. pr22122
117
Algorithmen und Datenstrukturen
2.2.6 Generics
Einen Algorithmus, der von einem Datentyp unabhängig programmiert werden kann,
nennt man generisch und die Möglichkeit in Java mit generischen Typen zu arbeiten
Generics.
2.2.6.1 Sammlungsklassen
Generische Typen werden zur Implementierung von Datenstrukturen wie Collections
und Containern benötigt. Bei der Implementierung einer Collection stellt man fest,
dass die Implementierung weitgehend unabhängig vom Typ der Elemente ist, die in
der Collection abgelegt werden. Man möchte deshalb nur eine Implementierung der
Collection zur Verfügung stellen, die dann für das Ablegen beliebiger Elementtypen
verwendet werden kann.
Traditionell (im nicht generischen Java) erreicht man das folgendermaßen: Die
Collection Klassen (z.B. die Klassen aus dem J2SE 74 Collection Framework)
vewalten Referenzen vom Typ Object. Da Object die Superklasse aller JavaKlassen ist, kann eine Collection Elemente beliebigen Typs enthalten.
Ausgenommen sind lediglich die primitiven Typen, die nicht von Object abgeleitet
sind. Das ist aber kein großes Problem, da es zu jedem primitiven Typ einen
korrespondierenden Referenztyp gibt. Die Umwandlung eines primitiven Typs in
einen korrespondierenden Referenztyp bezeichnet man als Boxing 75.
Eine so implementierte Collection kann eine Mischung von Objekten verschiedenen
Typs verwalten. Beim Herausholen unterschiedlicher Elemente aus einer Collection,
weiß man allerdings niemals genau, von welchem Typ das betreffende Element ist.
Deshalb muß man einen Laufzeit-Cast 76 machen, ehe man das Element verwenden
kann.
Java-Generics erlauben eine alternative Implementierungstechnik, bei der die
Collections mit einem Element-Typ parametrisiert 77 werden und dann homogen sind,
also Element desselben Typs enthalten. Das Einfügen eines "fremden Typs" wird zur
Compile-Zeit bereits abgewiesen. Deshalb muß beim Herausholen aus der Collection
nicht mehr überprüft werden, ob das gefundene Element vom gewünschten Typ ist.
Bei einer parametrisierten Collection werden Typprüfungen bereits zur Compilezeit
beim Einfügen der Element gemacht. Traditionelle Collections sind ein Beispiel für
Weak Typing mit Typprüfungen zur Laufzeit. Parametrisierte Collections sind ein
Beispiel für Strong Typing, bei dem Typeprüfungen bereits zur Compile-Zeit gemacht
werden.
Die Collections des traditionellen Collection Frameworks werden in der J2SE 1.5
durch parametrisierte Collections ersetzt. So kann eine LinkedList in J2SE 1.5 als
LinkedList<String> oder LinkedList<Integer> verwendet werden. Die
herkömmliche Verwendung als LinkedList ist aber weiterhin möglich.
74
Java 2 Standard Edition
Ab Java 1.5 ist Autoboxing implementiert (automatische Konvertierung)
76 Ist dieser Laufzeit-Cast vom Typ Object-Referenz auf den Typ Integer-Referenz, dann kommt es, falls das
Element kein Integer ist ui einer ClassCastException
77 vgl. 1.2.3.6.2
75
118
Algorithmen und Datenstrukturen
Iterabe<E>
Iterator<E>
Collection<E>
List<E>
ArrayList<E>
Queue<E>
LinkedList<E>
Map<K,V>
Set<E>
SortedSet<E>
SortedMap
<K,V>
HashSet<E>
ConcurrentMap
<K,V>
TreeMap<K,V> ConcurrentHash
Map<K,V>
TreeSet<E>
Abb.: Klassenhierachie: Interfaces und konkrete Klassen im Paket java.util
119
HashMap
<K,V>
Algorithmen und Datenstrukturen
2.2.6.2 Implementierung von Java Generics
Wie üblich, übersetzt der Java Compiler parametrisierte Methoden in Java Bytecode.
2.2.6.2.1 Übersetzung von Generics
Ein Compiler hat prinzipiell zwei Möglichkeiten zur
parametrisierten Typs bzw. einer parametrisierten Methode:
-
-
Übersetzung
eines
Code-Spezialisierung. Dabei erzeugt der Compiler eine jeweils separate Repräsentation für
jede einzelne Instanziierung eines parametrisierten Typs. Bspw. würde der Compiler mit
dieser Technik Code für eine Liste von Strings und einer Liste von Integers erzeugen.
Code-Sharing. Dabei erzeugt der Compiler nur eine einzige Repräsentation je
parametisiertem Typ/Methode. Die verschiednen Instanziierungen des parametrisierten Typs
werden auf diese eine Repräsentation abgebildet, wobei Typprüfungen und
Typkonvertierungen eingefügt werden, wo immer das nötig ist.
Code-Spezialisierung ist die in C++ verwendete Übersetzungstechnik. Ein C++Compiler generiert aus einem Template für jede Instanziierung unterschiedlichen
Binärcode. Aus einem Listen-Template bspw. entsteht der Binärcode für mehrere
Listen-Klassen.
Code-Sharing ist die in Java verwendete Übersetzungstechnik. Das hat den Vortiel,
dass tendenziell weniger Binärcode erzeugt wird. Es verhindert aber die Verwendung
von primitiven Typen als Typargumente einer Instanziierung.
In Java Generics erfolgt die Abbildung von verschiedenen Instanziierungen auf eine
gemeinsamen Repräsentation der parametrisierten Typen/Methoden über eine
Erasure. Die Übersetzung mit Hilfe des Typs Erasure kann man sich vorstellen wie
eine Übersetzung von generischen Java in reguläres Java: von der Instanziierung
eines parametrisierten Types bzw. einer parametrisierten Methode werden sämtliche
Typparameter entfernt und in der Definition eines parametrisierten Typs bzw. einer
parametrisierten Methode wird der Typparameter durch sein erstes Bound oder den
Typ Object ersetzt, falls keine Bounds angegeben wurden.
2.2.6.2.2 Repräsentation von Generics im Laufzeitsystem
Die Übersetzung per Type Erasure wurde bewusst von den Entwicklern der Java
Generics als Übersetzungstechnik gewählt. Ziel des Designs der Java Generics ist
die hundertprozentige Kompatibilität des aus Generics generierten Bytecode mit
herkömmlichen Byte-Code. Es soll problemlos möglich sein, generischen mit nicht
generischeM Code zu mischen.
Es gibt deshalb nur wenige Spuren, die Generics in einer .class Datei dennoch
hinterlassen: es werden sogenannte Signature-Attribute im Bytecode abgelegt. Sie
enthalten statische Informationen über die Typparameter einer Klasse und
Methoden. Diese Signature-Attribute werden von der virtuelle Maschine als
Kommentar behandelt und nicht ausgewertet. Für die virtuelle Maschine gibt es
keinerlei Unterschiede zwischen einem parametrisierten Typ bzw. einer
perametrisierten Methode und einem regulären Tpy bzw. einer regulären Methode.
120
Algorithmen und Datenstrukturen
2.2.7 Spracherweiterung in Java zur Iteration
Typischerweise wird in einem Programm über die Elemente eines Sammlungstyps
iteriert oder über alle Elemente einer Reihung. Hierzu kennt Java eine Reihe von
Schleifenkonstrukten.
2.2.7.1 Erweiterte for-Schleife
Typischerweise wird in Programmen über die Elemente einer Klasse iteriert oder
über alle Elemente eines Array (einer Reihung). Hierzu kennt Java verschiedene
Schleifenkonstrukte.
Bsp. 78: Iterieren über alle Elemente einer Reihe und Iterieren mit Hilfe eines
Iteratorobjekts über alle Elemente eines Sammlungsobjekts.
import java.util.List;
import java.util.ArrayList;
import java.util.Iterator;
class AlteIteration
{
public static void main(String args [])
{
String [] ar = {"Brecht", "Goethe", "Shakespeare", "Schiller"};
List xs = new ArrayList();
for (int i = 0; i < ar.length; i++)
{
String s = ar[i];
xs.add(s);
}
for (Iterator it = xs.iterator(); it.hasNext();)
{
String s = (String) it.next();
System.out.println(s.toUpperCase());
}
}
}
Mit Java 1.5 gibt es zusätzlich ein eigendes Schleifenkonstrukt, das bequem die
Iteration über die Elemente einer Sammlung ausdrücken kann:
for (Type identifier : expr) { // body }
Zu lesen ist dieses Konstrukt als: "Für jedes identifier des Typs Type in expr
führe body aus".
Bsp. 79:
import java.util.ArrayList;
import java.util.Iterator;
public class ForLoopTest
{
public static void main(String[] args)
{
double[] array = {2.5, 5.2, 7.9, 4.3, 2.0, 4.1, 7.3, 0.1, 2.6};
// Einfache Iteration vorwaerts durch die Schleife
78
79
pr22102
pr22102
121
Algorithmen und Datenstrukturen
for(double d: array)
{ System.out.println(d); }
System.out.println("---------------------");
// Das folgende arbeitet mit allem, das das Interface Iterable
// implementiert, z.B mit Collections wie ArrayList
ArrayList<Integer> list = new ArrayList<Integer>();
list.add(7); list.add(15); list.add(-67);
for(Integer number : list)
{ System.out.println(number); }
System.out.println("---------------------");
// Es unterstuetzt auch Autoboxing
for(int item: list)
{ System.out.println(item); }
System.out.println("---------------------");
}
}
Test:
Abb.:
2.2.7.2 Das Interface Iterable
Das erweiterte for möchte links vom Doppelpunkt ein Feld oder ein Objekt, das vom
Typ Iterable ist. Diese Schnittstelle schreibt nur die Existenz einer Funktion
iterator() vor, die einen java.lang.SimpleIterator liefert. Der konkrete
SimpleIterator muß nur die Methoden hasNext() und next() implementieren,
um das nächste Element in der Aufzählung zu geben und das Ende anzuzeigen. Ein
SimpleIterator ist ein Iterator 80 ohne remove().
Bsp. 81:
import java.util.Iterator;
import java.util.StringTokenizer;
class WordIterable implements Iterable, Iterator
{
private StringTokenizer st;
public WordIterable( String s )
{
80
81
vgl. 2.2.8.1
pr22102
122
Algorithmen und Datenstrukturen
st = new StringTokenizer( s );
}
// Methode vom Iterable
public Iterator iterator()
{ return this; }
// Methoden vom Iterator
public boolean hasNext()
{ return st.hasMoreTokens(); }
public Object next()
{ return st.nextToken(); }
public void remove() { }
}
public class WordIterableDemo
{
public static void main( String args[] )
{
String s =
"Am Anfang war das Wort - am Ende die Phrase. (Stanislaw Jerzy Lec)";
for ( Object word : new WordIterable(s) )
System.out.println( word );
}
}
Praktisch ist, dass java.util.Iterator seit Java 1.5 den SimpleIterator
implementiert. Das folgende bekannte Programmierer-Idiom
for (Iterator i = c.iterator(); i.hasNext(); )
{
Typ o = (Typ) i.next();
}
kann durch das erweiterte for erfolgreich abgekürzt werden:
for (i : c)
{ Typ o = (Typ) I; }
123
Algorithmen und Datenstrukturen
2.2.8 Implementierung von Graphen-Algorithmen zur Berechnung
kürzester Wege mit Behälterklassen
Die folgende Abbildung zeigt einen gerichteten Graphen ohne Gewichte:
k2
k1
k4
k3
k5
k7
k6
Abb.:
Ein derartiger Graph wird im Speicher eines Rechners entweder als Adjazensliste
oder Adjazensmatrix abgebildet. Die Darstellung des vorliegenden Graphen in einer
Adjazenzliste zeigt die folgende Abbildung:
k1
k2
k4
k2
k4
k5
k3
k1
k6
k4
k3
k5
k5
k6
k7
k7
k6
k7
k6
Abb.:
Zur Implementierung derartiger Adjazenzlisten wird benötigt:
-
eine Liste mit den Knoten. Da auf die Knoten direkt zugegriffen werden soll, ist eine
Implementierung einer Knotenliste über eine Hashmap erwünscht.
Zu jedem Knoten der Knotenliste gehört einer Liste mit den Nachbarn des betrachteten
Knoten. Jeder Eintrag in die Knotenliste wird durch die Datenstruktur Vertex beschrieben.
Vertex enthält einen Anker zur Aufnahme einer verketteten Liste mit den benachbarten
Knoten. Die Implementierung erfolgt über die Collection-Klasse LinkedList.
124
Algorithmen und Datenstrukturen
2.2.8.1 Die Datenstrukturen Graph, Vertex, Edge zur Implementierung von
Graphenalgorithmen für die Berechnung kürzester Wege
Repräsentation von Knoten 82
class Vertex
{
public String
name;
// Bezeichner des Knoten
public List<Edge> adj;
// Benachbarte Knoten
public double
dist;
// Kosten
public Vertex
prev;
// Vorgaengerknoten auf dem kuerzesten Pfad
public int
scratch; // Spez. Variable, die in den Alg. genutzt wird
// Konstruktor
public Vertex( String nm )
{ name = nm; adj = new LinkedList<Edge>( ); reset( ); }
// Methoden
public void reset( )
{ dist = Graph.INFINITY; prev = null; /* pos = null; */ scratch = 0; }
}
Über die Instanzvariable adj wird die Liste der benachbarten Knoten geführt, dist
enthält die Kosten, path den Vorgängerknoten vom kürzsten Pfad. Identifiziert wird
der Knoten durch einen Namen (Typ: String).
Repräsentation von Kanten
Die Kanten eines Graphen können Distanzen, Entfernungen, Gewichte, Kosten
aufnehmen. Jede Kante eines Graphen wird beschrieben über den Zielknoten und
das der Kante zugeordnete Gewicht.
class Edge
{
public Vertex
dest;
// Zielknoten
public double
cost;
// Gewicht
// Konstruktor
public Edge( Vertex d, double c )
{
dest = d;
cost = c;
}
}
Die Klasse Graph zur Aufnahme von Algorithmen zur Berechnung kürzester Pfade
public class Graph
{
public static final double INFINITY = Double.MAX_VALUE;
protected Map<String,Vertex> vertexMap = new HashMap<String,Vertex>( );
/*
* Hinzufuegen einer neuen Kante.
*/
public void addEdge( String sourceName, String destName, double cost )
{
Vertex v = getVertex( sourceName ); Vertex w = getVertex( destName );
v.adj.add( new Edge( w, cost ) );
}
/*
* Routine zur Bearbeitung von nicht Erreichbarem bzw. Ausgabe der Kosten.
* Aufruf einer rekursiven Methode zur Ausgabe kuerzester Pfade zum
82
vgl. pr22859
125
Algorithmen und Datenstrukturen
* Zielknotem, nachdem ein kuezester_Pfad_Algorithmus gelaufen ist.
*/
public void printPath( String destName )
{
Vertex w = (Vertex) vertexMap.get( destName );
if( w == null )
throw new NoSuchElementException( "Zielknoten wurde nicht gefunden" );
else if( w.dist == INFINITY )
System.out.println( destName + " ist unerreichbar" );
else
{
System.out.print( "(Kosten: " + w.dist + ") " );
printPath( w ); System.out.println( );
}
}
/*
* Falls es den Knotennamen noch nicht gibt,
* addiere ihn zur Map der Knoten.
* Gib immer den Knoten zurueck.
*/
private Vertex getVertex( String vertexName )
{
Vertex v = (Vertex) vertexMap.get( vertexName );
if( v == null )
{
v = new Vertex( vertexName );
vertexMap.put( vertexName, v );
}
return v;
}
/*
* Rekursive Routine zur Ausgabe des kuerzesten Pfads zum Zielknoten,
* nachdem der Algorithmus zum kuerzesten Pfad gelaufen ist.
* Es ist bekannt, dass der Pfad existiert.
*/
private void printPath( Vertex dest )
{
if( dest.prev != null )
{
printPath( dest.prev ); System.out.print( " to " );
}
System.out.print( dest.name );
}
/*
* Setzt den Graph auf die Ausgangsposition zur Bearbeitung
* der Algorthmen fuer kuerzeste Pfade zurueck.
*/
private void clearAll( )
{
for (Vertex v : vertexMap.values()) v.reset();
}
/*
* Graphenalgorithmen zur Berechnung kuerzester Pfade
*/
}
126
Algorithmen und Datenstrukturen
2.2.8.2 Kürzeste Pfade in gerichteten, ungewichteten Graphen.
Lösungsbeschreibung. Die
ungewichteten Graphen G:
folgende
Abbildung
zeigt
einen
gerichteten,
k2
k1
k4
k3
k5
k7
k6
Abb.:
Ausgangspunkt ist ein Startknoten s (Eingabeparameter). Von diesem Knoten aus
soll der kürzeste Pfad zu allen anderen Knoten gefunden werden. Es interessiert nur
die Anzahl der Kanten, die in dem Pfad enthalten sind.
Falls für s der Knoten k3 gewählt wurde, kann zunächst am Knoten k3 der Wert 0
eingetragen werden. Die „0“ wird am Knoten k3 vermerkt.
k2
k1
k4
k3
k5
0
k7
k6
Abb.: Der Graph nach Markierung des Startknoten als erreichbar
Danach werden alle Knoten aufgesucht, die „eine Einheit“ von s entfernt sind. Im
vorliegenden Fall sind das k1 und k6. Dann werden die Knoten aufgesucht, die von s
zwei Einheiten entfernt sind. Das geschieht über alle Nachfolger von k1 und k6. Im
vorliegenden Fall sind es die Knoten k2 und k4. Aus den benachbarten Knoten von k2
und k4 erkennt man, dass k5 und k7 die kürzesten Pfadlängen von drei Knoten
besitzen. Da alle Knoten nun bewertet sind ergibt sich folgenden Bild:
k2
k1
1
2
k4
k3
0
k5
2
1
k6
k7
Abb.: Graph nach Ermitteln aller Knoten mit der kürzeszen Pfadlänge 2
127
Algorithmen und Datenstrukturen
Die hier verwendete Strategie ist unter dem Namen „breadth-first search“ 83 bekannt.
Die „Breitensuche zuerst“ berücksichtigt zunächst alle Knoten vom Startknoten aus,
die am weitesten entfernt liegenden Knoten werden zuerst ausgerechnet.
Übertragen der Lösungsbeschreibung in Quellcode. Zu Beginn sollte eine Tabelle mit
folgenden Einträgen vorliegen:
k
k1
k2
k3
k4
k5
k6
k7
bekannt
false
false
false
false
false
false
false
dk
∞
∞
0
∞
∞
∞
∞
pk
0
0
0
0
0
0
0
Die Tabelle überwacht den Fortschritt beim Ablauf des Algorithmus und führt Buch
über gewonnene Pfade. Für jeden Knoten werden 3 Angaben in der Tabelle
verwaltet:
- die Distanz dk des jeweiligen Knoten zu dem Startknoten s. Zu Beginn sind alle Knoten von s aus
unerreichbar ( ∞ ). Ausgenommen ist natürlich s, dessen Pfadlänge ist 0 (k3).
- Der Eintrag pk ist eine Variable für die Buchführung (und gibt den Vorgänger im Pfad an).
- Der Eintrag unter „bekannt“ wird auf „true“ gesetzt, nachdem der zugehörige Knoten erreicht wurde.
Zu Beginn wurden noch keine Knoten erreicht.
Das führt zu der folgenden Knotenbeschreibung:
class Vertex
{
public String
public LinkedList
public boolean
public int
public Vertex
.....
}
name;
adj;
bekannt;
dist;
path;
// Name des Knoten
// Benachbarte Knoten
// Kosten
// Vorheriger Knoten auf dem kuerzesten Pfad
Die Grundlage des Algorithmus kann folgendermaßen (in Pseudocode) beschrieben
werden:
/*
/*
/*
/*
1
2
3
4
*/
*/
*/
*/
/* 5 */
/* 6 */
/* 7 */
/* 8 */
/* 9 */
83
void ungewichtet(Vertex s)
{
Vertex v, w;
s.dist = 0;
for (int aktDist = 0; aktDist < ANZAHL_KNOTEN; aktDist++)
for each v
if (!v.bekannt && v.dist == aktDist)
{
v.bekannt = true;
for each w benachbart_zu v
if (w.dist == INFINITY)
{
w.dist = aktDist + 1;
w.path = v;
}
}
}
vgl. 5.2.2
128
Algorithmen und Datenstrukturen
Der Algorithmus deklariert schrittweise je nach Distanz (d = 0, d = 1, d= 2 ) die
Knoten als bekannt und setzt alle benachbarten Knoten von d w = ∞ auf die Distanz
d w = d + 1.
2
Die Laufzeit des Algorithmus liegt bei O( V ) . 84 Die Ineffizienz kann beseitigt werden:
d v ≠ ∞ . Einigen Knoten wurde dv = aktDist
Es gibt nur zwei unbekannte Knotentypen mit
zugeordnet, der Rest zeigt dv = aktDist + 1. Man braucht daher nicht die ganze Tabelle, wie es
in Zeile 3 und Zeile 4 beschrieben ist, nach geeigneten Knoten zu durchsuchen. Am einfachsten ist
es, die Knoten in zwei Schachteln einzuordnen. In die erste Schachtel kommen Knoten, für die gilt: dv
= aktDist. In die zweite Schachtel kommen Knoten, für die gilt: dv = aktDist + 1. In Zeile 3 und
Zeile 4 kann nun irgendein Knoten aus der ersten Schachtel herausgegriffen werden. In Zeile 9 kann
w der zweiten Schachtel hinzugefügt werden. Wenn die äußere for-Schleife terminiert ist die erste
Schachtel leer, und die zweite Schachtel kann nach der ersten Schachtel für den nächsten Durchgang
übertragen werden.
Durch Anwendung einer Schlange (Queue) kann das Verfahren verbessert werden.
Am Anfang enthält diese Schlange nur Knoten mit Distanz aktDist. Benachbarte
Knoten haben die Distanz aktDist + 1 und werden „hinten“ an die Schlange
angefügt. Damit wird garantiert, daß zuerst alle Knoten mit Distanz aktDist
bearbeitet werden. Der verbesserte Algorithmus kann in Pseudocode so formuliert
werden:
/* 1 */
/* 2 */
/* 3 */
/*
/*
/*
/*
4
5
6
7
*/
*/
*/
*/
/* 8 */
/* 9 */
/*10 */
void ungewichtet(Vertex s)
{
Queue q; Vertex v, w;
q = new Queue();
q.enqueue(s); s.dist = 0;
while (!q.isEmpty())
{
v = q.dequeue();
v.bekannt = true; // Wird eigentlich nicht mehr benoetigt
for each w benachbart_zu v
if (w.dist == INFINITY)
{
w.dist = v.dist + 1;
w.path = v;
q.enqueue(w);
}
}
}
Die folgende Tabelle zeigt, wie sich die Daten der Tabelle während der Ausführung
des Algorithmus ändern:
Anfangszustand
k
bekannt dk
k1
false
∞
k2
false
∞
k3
false
0
k4
false
∞
k5
false
∞
k6
false
∞
k7
false
∞
Q: k3
84
pk
0
0
0
0
0
0
0
k3 aus der Schlange
bekannt dk
pk
false
1
k3
false
0
∞
true
0
0
false
0
∞
false
0
∞
false
1
k3
false
0
∞
Q: k1, k6
k1 aus der Schlange
bekannt dk
pk
true
1
k3
false
2
k1
true
0
0
false
2
k1
false
0
∞
false
1
k3
false
0
∞
Q: k6, k2, k4
wegen der beiden verschachtelten for-Schleifen
129
k6 aus der Schlange
bekannt dk
pk
true
1
k3
false
2
k1
true
0
0
false
2
k1
false
0
∞
true
1
k3
false
0
∞
Q: k2, k4
Algorithmen und Datenstrukturen
k2 aus der Schlange
k
bekannt dk
k1
true
1
k2
true
2
k3
true
0
k4
false
2
k5
false
3
k6
true
1
k7
false
∞
Q: k4, k5
pk
k3
k1
0
k1
k2
k3
0
k4 aus der Schlange
bekannt dk
pk
true
1
k3
true
2
k1
true
0
0
true
2
k1
false
3
k2
true
1
k3
false
3
k4
Q: k5, k7
k5 aus der Schlange
bekannt dk
pk
true
1
k3
true
2
k1
true
0
0
true
2
k1
true
3
k2
true
1
k3
false
3
k4
Q: k7
K7 aus der Schlange
bekannt dk
pk
true
1
k3
true
2
k1
true
0
0
true
2
k1
true
3
k2
true
1
k3
true
3
k4
Q: leer
Abb.:Veränderung der Daten während der Ausführung des Algorithmus zum kürzesten Pfad
Implementierung 85. Die Klasse Graph implementiert die Methode unweighted().
Die Schlange in dieser Liste wird über eine LinkedList mit den Methoden
removeFirst() und addLast() simuliert.
/*
* Single-source unweighted shortest-path algorithm.
*/
public void unweighted( String startName )
{
clearAll( );
Vertex start = (Vertex) vertexMap.get( startName );
if( start == null )
throw new NoSuchElementException( "Startknoten nicht gefunden" );
LinkedList q = new LinkedList( );
q.addLast( start ); start.dist = 0;
while( !q.isEmpty( ) )
{
Vertex v = (Vertex) q.removeFirst( );
for (Edge e : v.adj)
{
Vertex w = e.dest;
if( w.dist == INFINITY )
{
w.dist = v.dist + 1;
w.prev = v;
q.addLast( w );
}
}
}
}
85
vgl.: pr22859
130
Algorithmen und Datenstrukturen
2.2.8.3 Berechnung der kürzesten Pfadlängen in gewichteten Graphen (Algorithmus von
Dijkstra)
Gegeben ist ein gerichteter Graph G mit Knotenmenge V und Kantenmenge E. Jede
Kante e kat eine nichtnegative Länge, Außerdem ist ein Knoten s (Standort)
gegeben.
Gesucht ist der kürzeste Weg von s nach v für jeden Knoten v ∈ V des Graphen.
Vorausgesetzt ist, dass jeder Knoten v ∈ V durch wenigstens einen Weg von s aus
erreichbar ist. Für den kürzesten Weg soll die Länge ermittelt werden.
Lösungsbeschreibung. Die Lösung stützt sich auf die Berechnung der kürzesten
Pfadlängen in ungewichteten Graphen 86 ab. Im Algorithmus von Dijkstra werden
auch die Daten über „bekannt“, dv (kürzeste Pfadlänge) und pv (letzter Knoten, der
eine Veränderung von dv verursacht hat) verwaltet.
Es wird eine Menge S von Knoten betrachtet und schrittweise vergrößert, für die der
kürzeste Weg von s aus bereits bekannt ist. Jedem Knoten v ∈ V wird ein Distanz
d(v) zugeordnet. Anfangs ist d(s) = 0 und für alle von s verschiedenen Knoten v ∈ V
ist d v = ∞ , und S ist leer. Dann wird S nach dem Prinzip "Knoten mit kürzester
Distanz von s zuerst" schrittweise folgendermaßen vergrößert, bis S alle Knoten V
des Graphen enthält:
1. Wähle Knoten v ∈ V S mit minimaler Distanz
2. Nimm v zu S hinzu
3. Für jede Kante vw von einem Knoten v zu einem Knoten w ∉ S , ersetze d(w) durch
min({d (v), d (v) + c(v, w)})
Der folgende Graph
2
k2
k1
4
1
3
10
2
2
k4
k3
5
8
k5
4
6
k7
k6
1
Abb.: Graph nach Ermitteln aller Knoten mit der kürzeszen Pfadlänge 2
mit der Knotenbeschreibung
class Vertex
{
....
public LinkedList
public boolean
public DistType 87
public Vertex
...
}
86
87
adj;
// Benachbarte Knoten
bekannt; //
dist;
// Kosten
path;
// Vorheriger Knoten auf dem kuerzesten Pfad
vgl. 1.
DistType ist wahrscheinlich int
131
Algorithmen und Datenstrukturen
führt zu der folgende Initialisierung:
k
k1
k2
k3
k4
k5
k6
k7
bekannt
false
false
false
false
false
false
false
dk
0
∞
∞
∞
∞
∞
∞
pk
null
null
null
null
null
null
null
Abb.: Anfangszustand der Tabelle mit den Daten für den Algorithmus von Dijkstra
Der erste Knoten (Start) ist der Knoten k1 mit Pfadlänge 0. Nachdem k1 bekannt ist,
ergibt sich folgendes Bild:
k
k1
k2
k3
k4
k5
k6
k7
bekannt
true
false
false
false
false
false
false
dk
0
2
∞
1
∞
∞
∞
pk
null
k1
null
k1
null
null
null
Abb.: Zustand der Tabelle nach „k1 ist bekannt“
„k1“ besitzt die Nachbarknoten: k2 und k4. „k4“ wird gewählt und als bekannt markiert.
Die Knoten k3, k5, k6 und k7 sind jetzt die benachbarten Knoten.
k
k1
k2
k3
k4
k5
k6
k7
bekannt
true
false
false
true
false
false
false
dk
0
2
3
1
3
9
5
pk
null
k1
k4
k1
k4
k4
k4
Abb.: Zustand der Tabelle nach „k4 ist bekannt“
„k2“ wird gewählt. „k4“ ist benachbart, aber schon bekannt. „k5“ ist ebenfalls
benachbart, wir aber nicht ausgerichtet, da die Kosten von „k2“ aus 2 +10 = 12 sind
und ein Pfad der Länge 3 schon bekannt ist
k
k1
k2
k3
k4
k5
k6
k7
bekannt
true
true
false
true
false
false
false
dk
0
2
3
1
3
9
5
pk
null
k1
k4
k1
k4
k4
k4
Abb.: Zustand der Tabelle nach „k2 ist bekannt“
132
Algorithmen und Datenstrukturen
Der nächste ausgewählte Knoten ist „k5“ (ohne Ausrichtungen), danach wird k3
gewählt. Die Wahl von „k3“ bewirkt die Ausrichtung von „k6“
k
k1
k2
k3
k4
k5
k6
k7
bekannt
true
true
true
true
true
false
false
dk
0
2
3
1
3
8
5
pk
null
k1
k4
k1
k4
k3
k4
Abb.: Zustand der Tabelle „k5 ist bekannt“ und (anschließend) „k3 ist bekannt“.
„k7“ wird gewählt. Daraus resultiert folgende Tabelle:
k
k1
k2
k3
k4
k5
k6
k7
bekannt
true
true
true
true
true
false
true
dk
0
2
3
1
3
6
5
pk
null
k1
k4
k1
k4
k7
k4
Abb.: Zustand der Tabelle „k7 ist bekannt“.
Schließlich bleibt nur noch k6 übrig. Das ergibt dann die folgende Abschlußtabelle:
k
k1
k2
k3
k4
k5
k6
k7
bekannt
true
true
true
true
true
true
true
dk
0
2
3
1
3
6
5
pk
null
k1
k4
k1
k4
k7
k4
Abb.: Zustand der Tabelle nach „k6 ist bekannt“.
Der Algorithmus, der diese Tabellen
folgendermaßen beschrieben werden:
berechnet,
kann
void dijkstra(Vertex s)
{
Vertex v, w;
/* 1 */
s.dist = 0;
/* 2 */
for(; ;)
{
/* 3 */
v = kleinster_unbekannter_Distanzknoten;
/* 4 */
if (v == null)
/* 5 */
break;
/* 6 */
v.bekannt = true;
/* 7 */
for each w benachbart_zu v
/* 8 */
if (!w.bekannt)
/* 9 */
if (v.dist + cvw < w.dist)
{
/* 10 */
w.dist = v.dist + cvw;
/* 11 */
w.pfad = v;
}
133
(in
Pseudocode)
Algorithmen und Datenstrukturen
}
}
Die Laufzeit des Algorithmus resultiert aus dem Aufsuchen aller Knoten (in den
beiden for-Schleifen) und im Aufsuchen der Kanten (c(vw)) (in der inneren forSchleife): O(|E| + |V|2) = O(|V|2).
Ein Problem des vorstehenden Agorithmus ist das Durchsuchen der Knotenmenge
nach der kleinsten Distanz 88. Man kann das wiederholte Bestimmen der kleinsten
Distanz
einer
prioritätsgesteuerten
Warteschlange
übertragen.
Der
Leistungsaufwand beträgt dann O(|E| log(|V)+|V| log(|V|)). Der Algorithmus (in
Pseudocode) könnte so aussehen:
/* 1 */ for_all v ∈ V do
d (v ) = ∞
/* 2 */ d ( s ) = 0 ; S = 0
/* 3 */ pq = new PriorityQueue(); // Vorrangwarteschlange für Knoten in V
/* 4 */ while pq ≠ 0 do /* pq = V S */
pq.delete _ min()
/* 6 */ S = S ∪ {v}
/* 7 */ for _ all (v, w) ∈ E do
/* 8 */ if d (v) + c(v, w) < d ( w) pq.decrease _ key( w, d (v) + c(v, w))
/* 9 */ Entferne (v, w) aus E
/* 5 */
/*10*/ end while
prioritätsgesteuerte Warteschlange: Heaps sind eine mögliche Implementation für
Priority Queues. Ein Heap mit N Schlüsseln erlaubt das Einfügen eines neuen
Elements und das Entfernen des Minimums in O(log N ) Schritten. Da das Minimum
stets am Anfang des Heap steht, kann der Zugriff auf das kleinste Element stets in
konstanter Zeit ausgeführt werden. In Java gibt es seit der Version 1.5 die Klasse
PriorityQueue, die sich auf einen Binary Heap abstützt.
public class PriorityQueue<E 89> extends AbstractQueue implements Serializable
// Konstruktoren
PriorityQueue()
PriorityQueue(Collection<? extends E> c)
PriorirityQueue(int intCapacity)
// Methoden
public boolean add(E o)
// fügt ein spezifiziertes Element in die Schlange ein
public void clear()
// entfernt alle Elemente aus der Schlange
public Comparator<? super E> comparator()
public Iterator<E> iterator()
public boolean offer(E o)
public boolean peek()
public E poll()
// entfernt und gibt das Element zurück, das am Kopf der Schlange sich befindet
public boolean remove(Object o)
// entfernt ein Element aus der Schlange
public int size()
88
89
v = kleinster_unbekannter_Distanzknoten
Typ des Elements, das in die Queue aufgenommen wurde
134
Algorithmen und Datenstrukturen
Geerbt wird u.a. die Methode isEmpty() der Klasse Collection.
In der Priority-Queue werden für den Algorithmus von Dijkstra Datensätze von
folgendem Typ abgelegt:
class Path implements Comparable
{
public Vertex
dest;
// w
public double
cost;
// d(w)
// Konstruktor
public Path( Vertex d, double c )
{
dest = d;
cost = c;
}
// Methode des Comparable-Interface
public int compareTo( Object rhs )
{
double otherCost = ((Path)rhs).cost;
return cost < otherCost ? -1 : cost > otherCost ? 1 : 0;
}
}
Implementierung. 90
/*
* Kuerzster Pfad Algorihmus mit Gewichten.
*/
public void dijkstra( String startName )
{
PriorityQueue pq = new PriorityQueue();
Vertex start = (Vertex) vertexMap.get( startName );
if( start == null )
throw new NoSuchElementException( "Start vertex not found" );
clearAll( );
pq.add( new Path( start, 0 ) ); start.dist = 0;
int nodesSeen = 0;
while( !pq.isEmpty( ) && nodesSeen < vertexMap.size( ) )
{
Path vrec = (Path) pq.poll( );
Vertex v = vrec.dest;
if( v.scratch != 0 ) // schon bearbeited v
continue;
v.scratch = 1; nodesSeen++;
for( Iterator itr = v.adj.iterator( ); itr.hasNext( ); )
{
Edge e = (Edge) itr.next( );
Vertex w = e.dest;
double cvw = e.cost;
if( cvw < 0 )
throw new GraphException( "Graph hat negativ gewichtete Kanten" );
if( w.dist > v.dist + cvw )
{
w.dist = v.dist +cvw; w.prev = v;
pq.add( new Path( w, w.dist ) );
}
}
}
}
90
vgl. pr22859
135
Algorithmen und Datenstrukturen
2.2.8.4 Berechnung der kürzesten Pfadlängen in gewichteten Graphen mit negativen
Kosten
Falls ein Graph Kanten mit negativen Kosten enthält, arbeitet der DijkstraAlgorithmus nicht korrekt. Das Problem ist, falls ein Knoten u als bekannt dekariert
ist, die Möglichkeit besteht, dass es einen Weg zurück nach u von einem Knoten v
mit negativem Resultat gibt.
Eine mögliche, aber umständliche Lösung ist: Addition einer Konstanten Δ zu jedem
Kantengewicht. Die Konstante wird so groß gewählt, dass keine negativen Kanten
nach der Addition vorliegen.
Besser ist der folgende Algorithmus (in Pseudocode):
void negativ_gewichtet(Vertex s)
{
Queue q;
Vertex v, w;
/* 1*/ q = new Queue();
//
/* 2*/ q.enqueue(s);
// Einreihen des Startknoten s
/* 3*/ while (!q.isEmpty())
{
/* 4 */ v = dequeue();
/* 5 */ for each w adjazent to v
/* 6 */
if (v.dist + cvw < w.dist)
{
// Update w
/* 7 */
w.dist = v.dist + cvw;
/* 8 */
w.path = v;
/* 9 */
if (w ist nicht in q)
/* 10*/
q.enqueue(w);
}
}
}
Komplexität. Jeder Knoten kann etwa |V| mal aus der Warteschlange entnommen
werden, die Laufzeit ist somit O( | E | ⋅ | V | ) (Anstieg gegenüber dem Djikstra
Algorithmus), falls Adjazenslisten benutzt werden. Falls negative "Kosten-Zyklen"
vorliegen, dann gelangt der Algorithmus in eine Endlosschleife.
Implementierung 91
public void negative( String startName )
{
clearAll( );
Vertex start = (Vertex) vertexMap.get( startName );
if( start == null )
throw new NoSuchElementException( "Startknoten wurde nicht gefundem");
LinkedList<Vertex> q = new LinkedList<Vertex>( );
q.addLast( start ); start.dist = 0; start.scratch++;
while( !q.isEmpty( ) )
{
Vertex v = (Vertex) q.removeFirst( );
if( v.scratch++ > 2 * vertexMap.size( ) )
throw new GraphException( "Negative Zyklen entdeckt" );
for (Edge e : v.adj)
{
Vertex w = e.dest;
double cvw = e.cost;
if( w.dist > v.dist + cvw )
91
vgl. pr22859
136
Algorithmen und Datenstrukturen
{
w.dist = v.dist + cvw; w.prev = v;
// Einreihen nur, wenn noch nicht in der Schlange
if( w.scratch++ % 2 == 0 ) q.addLast( w );
else w.scratch--; // undo the enqueue increment
}
}
}
}
2.2.8.5 Berechnung der kürzesten Pfadlängen in gewichteten, azyklischen Graphen
Falls bekannt ist, dass der Graph azyklisch ist, kann der Dijkstra-Algorithmus
verbessert werden: Die Knoten des Graphen werden in topologischer Reihenfolge
(partielle Ordnung 92) ausgewählt. Die Auswahl der Knoten in topologischer Folge
garantiert: die Distanz dv kann nicht weiter erniedrigt werden.
/* Kuezester Pfad fuer azyklische Graphen mit negativ Gewichten. */
public void acyclic( String startName )
{
Vertex start = (Vertex) vertexMap.get( startName );
if( start == null )
throw new NoSuchElementException( "Startknoten wurde nicht gefunden" );
clearAll( );
LinkedList<Vertex> q = new LinkedList<Vertex>( );
start.dist = 0;
// Berechne indegrees
Collection vertexSet = vertexMap.values( );
for( Iterator vsitr = vertexSet.iterator( ); vsitr.hasNext( ); )
{
Vertex v = (Vertex) vsitr.next( );
for( Iterator witr = v.adj.iterator( ); witr.hasNext( ); )
( (Edge) witr.next( ) ).dest.scratch++;
}
// Einordnen der Knoten mit indegree zero in die Schlange
for( Iterator vsitr = vertexSet.iterator( ); vsitr.hasNext( ); )
{
Vertex v = (Vertex) vsitr.next( );
if( v.scratch == 0 ) q.addLast( v );
}
int iterations;
for( iterations = 0; !q.isEmpty( ); iterations++ )
{
Vertex v = (Vertex) q.removeFirst( );
for (Edge e : v.adj)
{
Vertex w = e.dest; double cvw = e.cost;
if( --w.scratch == 0 ) q.addLast( w );
if( v.dist == INFINITY ) continue;
if( w.dist > v.dist + cvw )
{ w.dist = v.dist + cvw; w.prev = v; }
}
}
if( iterations != vertexMap.size( ) )
throw new GraphException( "Graph has a cycle!" );
}
}
92
vgl. 1.2.2.2
137
Algorithmen und Datenstrukturen
3. Algorithmen
3.1 Ausgesuchte algorithmische Probleme
3.1.1 Spezielle Sortieralgorithmen
Sortieren bedeutet: Anordnen einer gegebenen Menge von Datenelementen in einer
nach diesen Elementen
bestimmten Ordnung 93. Danach sind Suchvorgänge
wesentlich vereinfacht. Da es nur wenige Programmierprobleme gibt, die ohne
Sortieren auskommen, ist die Vielfalt der dafür vorhandenen Algorithmen fast
unüberschaubar. Alle verfolgen den gleichen Zweck, viele sind in gewisser
Hinsicht optimal, und die meisten Algorithmen haben unter gewissen Bedingungen
auch Vorteile gegenüber anderen. Eine Leistungsanalyse der Algorithmen kann
diese Vorteile herausstellen.
Selbstverständlich hängt auch beim Sortieren die Wahl des Algorithmus von der
Struktur der zu bearbeitenden Daten ab. Die Sortiermethoden teilen sich hier
grundsätzlich bereits in zwei Gruppen:
- Sortieren von Feldern (internes Sortieren)
Felder befinden sich auf direkt zugreifbaren, internen Speicherbereichen.
- Sortieren von (sequentiellen) Dateien (externes Sortieren).
Dateien sind auf externen Speichern (Bänder, Platten) untergebracht. Daten liegen hier im Format
eines sequentiellen File vor. Dadurch ist zu jeder Zeit nur eine Komponente im direkten Zugriff.
Diese Einschränkungen gegenüber Feldstrukturen bedeutet, daß andere Techniken zum Sortieren
herangezogen werden müssen.
3.1.1.1 Interne Sortierverfahren
3.1.1.1.1 Quicksort
Beschreibung. Beim Quicksort-Verfahren wird in jedem Schritt ein Element x der zu
sortierenden Folge als Pivot-Element ausgewählt. Dann wird die zu sortierende Folge
so umgeordnet, dass eine Teilfolge links von x entsteht, in die alle Werte der
Elemente kommen, die nicht größer als x sind. Rechts von x entsteht eine Teilfolge,
in der alle Werte der Elemente kommen, die größer sind als das Pivot-Element x.
Diese Teilfolgen werden dann selbst wieder nach dem gleichen Verfahren rekursiv
zerlegt und umsortiert. Dies geschieht so lange, bis die Teilfolgen die Länge 1
besitzen und damit bereits sortiert sind, so dass man am Ende eine vollständig
sortierte Folge enthält.
93
1.2.2.2
138
Algorithmen und Datenstrukturen
Abb.:
Implementierung.
void quicksort(char array[], int min, int max)
{
int left = min, right = max;
// reads the value of the cell in the middle
char middle = array[(left + right) / 2];
do
{
// find first element >= element in the middle
while (array[left] < middle)
left++;
// find last element <= element in the middle
while (array[right] > middle)
right--;
// swap these elements
if (left < right)
swap(array, left, right);
if (left <= right)
}
left++;
// go to the
right--;
// next elements
}
// continue as long as we meet in the middle
} while (left <= right);
if (min < right)
// separate the array
quicksort(array, min, right);
// into two parts and continue sorting
if (left < max)
// as long there is a part which
quicksort(array, left, max);
// has more than one cell
}
139
Algorithmen und Datenstrukturen
⎛N⎞
Aufwand. Maximal werden zum Sotieren des Felds (Array) der Länge N ⎜⎜ ⎟⎟
⎝2⎠
Vergleiche benötigt. Besonders ungünstig ist eine bereits sortierte Liste. Wird der
Quicksort auf eine solche Liste angesetzt und ist die Wahl des Pivot-Elements auf
das erste bzw. letze Element gefallen, dann läuft in diesem Fall das „Divide and
Conquer“-Verfahren komplett ins Leere 94. In diesem Fall benötigt der Quicksort
N
⎛N⎞
n − i = ⎜⎜ ⎟⎟ Vergleiche.
∑
i =1
⎝2⎠
Durchschnittlich benötigt der Quicksort zum Sortieren eines Felds der Länge N
2 ⋅ ln(2) N ⋅ log( N ) + O( N ) Vergleiche.
Entscheidend für die Laufzeit vom Quicksort ist hierbei die gute Wahl des
Pivotelements:
- Fällt die Wahl auf das letzte Element, dann ist das schlecht bei vorsortierten Arrays.
- Bei einer zufälligen Wahl liegt besseres Verhalten vor bei vorsortierten Arrays. Nachteilig ist der
zusätzliche Aufwand für die Randomisierung.
- Meistens entscheidet man sich für die Wahl des Median: Das mittlere Element des ersten, mittleren
und letzten Elements des Array.
3.1.1.1.2 Heap-Sort
Beschreibung. Der Algorithmus zum Heap-Sort untergliedert sich in zwei Phasen:
- In der ersten Phase wird aus der unsortierten Folge von N Elementen ein Heap aufgebaut.
- In der zweiten Phase wird der Heap ausgegeben, d.h. ihm wird jeweils das größte Element
entnommen (das ja an der Wurzel steht). Dieses Element wird in die zu sortierende Folge
aufgenommen und die Heap-Eigenschaften werden anschließend wieder hergestellt.
Implementierung 95.
// import java.io.*;
public class HeapSort
{
private static void durchdringeRunter(Comparable [] a, int i, int n)
{
int kind ;
Comparable tmp;
for (tmp = a[i]; (2* i + 1) < n; i = kind)
{
kind = 2 * i + 1;
if (kind != n - 1 && a[kind].compareTo(a[kind+1]) < 0)
kind++;
System.out.println(i + ", " + kind + ", " + a[kind]);
if (tmp.compareTo(a[kind]) < 0)
a[i] = a[kind];
else
break;
}
a[i] = tmp;
}
public static void heapsort(Comparable [] a)
{
for (int i = a.length / 2; i >= 0; i--)
94
95
Eine der entstehenden Teilfoge ist leer, die andere enthält alle restlichen Elemente.
pr13228
140
Algorithmen und Datenstrukturen
durchdringeRunter(a, i, a.length);
for (int i = a.length - 1; i > 0; i--)
{
Comparable tmp = a[0];
a[0] = a[i];
a[i] = tmp;
durchdringeRunter(a,0,i);
}
}
public static void main(String[ ] args)
{
// InputStreamReader isr = new InputStreamReader(System.in);
// BufferedReader
ein = new BufferedReader(isr);
int [] x = { 150, 80, 40, 30, 10, 70, 110, 100, 20, 90,
60, 50, 120, 140, 130 };
Comparable [] a = new Comparable[x.length];
for (int i = 0; i < x.length; i++)
{
a[i] = new Integer(x[i]);
}
HeapSort.heapsort(a);
System.out.println("Sortierte Ausgabe: ");
for (int i = 0; i < a.length; i++)
System.out.print(a[i].toString() + ", " );
System.out.println();
}
}
Leistungsaufwand. Mit dem Heap-Sort kann ein Feld der Länge N mit höchstens
2 ⋅ N ⋅ log( N ) + O( N ) vielen Vergleichen sortiert werden.
Ein Heap mit l Stufen (Level) verfügt höchstens über 2l-1 Knoten.
Beim Heap-Sort ist die Anzahl der Vergleiche kleiner als die Anzahl der Vergleiche
zum Erzeugen eines Heap für N beliebige Elemente addiert mit der Summe der
Vergleiche bei allen „Löschungen des Größtwerts“.
Anzahl der Vergleiche zum Erzeugen eines Heap für N beliebige Elemente:
l −1
≤ 2 l −1 ⋅ 0 + 2 l − 2 ⋅ 2 ⋅ 1 + ... + 2 ⋅ 2 ⋅ (l − 2) + 1 ⋅ 2 ⋅ (l − 1) = ∑ 2 i ⋅ 2 ⋅ (l − 1 − i ) = 2 ⋅ 2 l − 2 ⋅ l + 2
i =0
Anzahl der Vergleiche zum Löschen des Maximums: Spätestens nach dem Löschen
von 2l-1 Elementen nimmt die Anzahl der Levels des Heap um 1 ab, nach weiteren 2l-2
Elementen wieder um 1, usw. Damit gilt für die Anzahl der Vergleiche
l −1
≤ 2 l −1 ⋅ 2 ⋅ (l − 1) + 2 l − 2 ⋅ 2 ⋅ (l − 2) + ... + 2 ⋅ (2 ⋅ 1) = 2 ⋅ ∑ i ⋅ 2 i = 2 ⋅ ((l − 2) ⋅ 2 l + 2) .
i =1
Für die Anzahl der Vergleiche beim Heap-Sort ergibt sich damit:
≤ 2 ⋅ 2 l − 2 ⋅ l + 2 + 2 ⋅ ((l − 2) ⋅ 2 l + 2) = 2 ⋅ l ⋅ 2 l − 2 ⋅ l + 6
Da der Heap-Sort auf einem N Elemente umfassenden Array ausgeführt wird, ergibt
sich die Höhe l zu ⎡log 2 ( N + 1) ⎤ . die Anzahl der Vergleiche ist dann beim Heap-Sort
bestimmt durch: 2 ⋅ N ⋅ log 2 ( N ) + O( N )
141
Algorithmen und Datenstrukturen
3.1.1.1.3 Sortieren durch Mischen
1. Einführung
Aus 2 (2-Weg-Mischen) oder mehr (n-Weg-Mischen) bereits sortiert vorliegenden
Teillisten ist durch geeignetes Zusammenfügen eine einzige sortierte Teilliste zu
erzeugen. Auf diese Weise sollen aus kleinen Teillisten (zu Beginn: Länge = 1)
immer größere produziert werden, bis schließlich nur noch eine einzige sortierte Liste
übrig bleibt.
2. Verschmelzen von Feldern
Kern dieses Mischverfahrens ist das wiederholte Verschmelzen sortierter Teillisten.
Bsp.:
17
11
Vergleiche: 17 - 11
23
37
68
45
78
67
17 - 37
23 - 37
37 - 68
45 - 68
67 - 68
Abb.:
Der soeben beschriebene Mischungsvorgang findet häufig auch bei Dateien
Anwendung 96.
3. 2-Wege-Mischsortieren
Eine Folge von Schlüsseln wird sortiert, indem bereits sortiert vorliegende Teilfolgen
zu immer längeren Teilfolgen verschmolzen werden. Zu Beginn ist jeder Schlüssel
eine sortierte Teilfolge. In einem Durchgang werden jeweils zwei benachbarte
Teilfolgen zu einer Folge verschmolzen. Ist die Anzahl der Schlüssel eine Potenz von
2, dann ist das paarweise Zusammenmischen, ohne daß eine Teilfolge übrig bleibt,
immer gewährleistet, z.B.:
27
18
33
55
68
12
16
08
87
95
63
37
45
52
11
96
18
27
33
55
12
68
08
16
87
95
37
63
45
52
11
18
27
33
55
08
12
16
68
37
63
87
95
11
16
45
08
12
16
18
27
33
55
68
11
19
37
45
52
63
87
08
11
12
16
18
19
27
33
37
45
52
55
63
68
87
vgl. Sequential Update Problem
142
Algorithmen und Datenstrukturen
19
19
52
95
95
Bei jedem Durchgang verdoppelt sich die Länge der Teilfolgen. Falls die Anzahl der
Schlüssel keine Zweierpotenz ist, bleibt am Ende eines Durchgangs eine Teilfolge
übrig, z.B.:
27
18
33
55
68
12
16
8
87
95
63
18
27
33
55
12
68
8
16
87
95
63
18
27
33
55
8
12
16
68
63
87
95
8
12
16
18
27
33
55
68
63
87
95
8
12
16
18
27
33
55
63
68
87
95
Vollständig ist die Schlüsselfolge sortiert, falls in einem Durchgang nur noch zwei
Teilfolgen verschmelzen.
Leistungsanalyse. Durch das Umspeichern geht jeder Schlüssel in jedem Durchlauf
in eine Elementaroperation ein. Neben dem Transport findet auch ein Vergleich statt
(mit Ausnahme der Restliste). Da es bei N = 2n Schlüsseln n = ldN Durchläufe gibt,
ist der Gesamtaufwand: Z = NldN
Der Speicheraufwand ist: S = 2N
4. Rekursives Mischsortieren
Das Prinzip des 2-Wege-Mischsortierverfahrens beruht in der Aufteilung. Eine
Teilfolge ist einfacher zu sortieren als die vollständige Folge. Diese Folge wird
deshalb zunächst einmal geteilt, da die beiden Hälften einfacher durch das
Mischsortieren zu behandeln sind. Die sortierte Folge ergibt sich dann durch
Verschmelzen der beiden sortierten Teilfolgen. Nutzt man dieses Prinzip vollständig
aus, dann ist das Teilen schließlich so weit durchzuführen bis Teilfolgen vorliegen,
die bereits sortiert sind. Eine Folge, die nur aus einem Schlüssel besteht ist immer
sortiert und besimmt damit eindeutig das Ende des Teilungsprozesses. Das
Mischsortieren ist damit eindeutig durch ein rekursives Verfahren lösbar.
// Rekursives Mischsortieren in C++
template <class T>
void mische(const T* a, int na, const T* b, int nb, T* c)
{
int ia = 0, ib = 0, ic = 0;
while (ia < na && ib < nb)
c[ic++] = (a[ia] < b[ib] ? a[ia++] : b[ib++]);
while(ia < na) c[ic++] = a[ia++];
while(ib < nb) c[ic++] = b[ib++];
}
// Die vorliegende Funktion dient als Basis fuer ein einfaches und
// schnelles Sortierverfahren. Nachteilig: Ein zusaetzliches "Array"
// ist noetig
template <class T>
void mischSort(T* a, int n)
{
if (n < 2) return;
int nLinks = n / 2, nRechts = n - nLinks;
mischSort(a,nLinks); mischSort(a+nLinks,nRechts);
T* z = new T[n];
143
Algorithmen und Datenstrukturen
mische(a,nLinks,a + nLinks,nRechts,z);
for (int i = 0; i < n; i++) a[i] = z[i];
delete [] z;
}
5. Natürliches 2-Wege-Mischen
Das Verschmelzen von nur aus einem Element bestehenden Teilfolgen kann häufig
durch längere, bereits sortiert vorliegende Teilfolgen verbessert werden. Man
versucht, eine natürliche, in der gegebenen Folge bereits enthaltene Vorsortierung
auszunutzen. So zeigt bspw. das folgende Feld
27
18
37
55
68
12
16
8
87
95
63
sechs bereits sortiert vorliegende Teilfolgen:
27
18
37
12
16
8
87
55
68
95
63
Abb.:
Die Teilfolgen können ermittelt und anschließend zusammengemischt werden:
18
27
37
55
68
8
12
16
87
95
63
95
63
Der Vorgang kann wiederholt werden. Das führt zur Folge
8
12
16
18
27
37
55
68
87
, die schließlich zu einer vollständig sortierten Folge umgestellt werden kann:
8
12
16
18
27
37
55
144
63
68
87
95
Algorithmen und Datenstrukturen
3.1.1.2 Externe Sortierverfahren
Generell ist hier die zu sortierende Datenmenge so groß, daß sie nicht mehr
vollständig im Arbeitsspeicher Platz findet. Die Daten sind in einem peripheren und
sequentiellen Speichermedium (Band, Platte) enthalten. Die Daten sind grundsätzlich sequentielle Dateien (Files) mit der Eigenschaft, daß zu jeder Zeit genau eine
Komponente zugreifbar ist. Diese Einschränkung verlangt die Verwendung anderer
Techniken zum Sortieren. Am bedeutendsten ist hier: Sortieren durch Mischen 97.
3.1.1.2.1 Direktes Mischsortieren
2-Wege-Mischsortierverfahren
Grundlagen. Das direkte Mischsortieren kann auf sequentielle „Files“
folgendermaßen angewandt werden:
1. Zerlegung einer gegebenen Sequenz (z.B. A) in 2 Hälften (z.B. B und C).
2. Mischen von B und C durch Kombination einzelner Elemente zu geordneten Paaren
3. Die gemischte Sequenz ist A.
Wiederholung der Schritte 1 und 2, wobei die geordneten Paare nun zu Quadrupeln
zusammenzufassen sind.
4. Wiederholung der vorhandenen Schritte, in der jedes Mal die Länge der gemischten Sequenzen
verdoppelt werden, bis die ganze Sequenz geordnet ist.
Bsp.: Gegeben ist die Sequenz A: 44
1. Schritt
B:
C:
44
94
55
18
12
06
42
67
A:
44
94
18
55
55
12
06
12
42
94
18
06
67
2. Schritt
42
67
3. Schritt
B:
C:
44
06
94
12
18
42
55
67
A:
06
12
44
94
4. Schritt
18
42
55
67
5. Schritt
B:
C:
06
18
12
42
44
55
94
67
A:
06
12
18
42
6. Schritt
44
55
67
94
Begriffe:
- Phase: Jede Operation, die die ganze Menge der Daten einmal behandelt.
- Durchlauf, Arbeitsgang: Der kleinste Teilprozess, dessen Wiederholung den Sortierprozess ergibt.
Im Bsp. umfaßt das Sortieren 3 Durchläufe. Jeder Durchlauf besteht aus einer
Zerlegungs- und Mischphase. Zum Sortieren werden 3 Bänder (sequentielle Files)
benötigt, der Prozeß heißt 3-Band-Mischen.
Das direkte Mischsortierverfahren verwendet Teillisten fester Länge. Das Verfahren
besteht aus einer Reihe von (Durch-) Läufen, die mit nur ein Datenelement
97
vgl. 3.1.1.1, Sortieren durch Mischen
145
Algorithmen und Datenstrukturen
umfassenden Teillisten beginnen. Jeder Lauf verdoppelt die Größe der Teillisten.
Sortiert ist dann, wenn nur eine Teilliste mit allen Datenelementen in sortierter Folge
vorliegt.
Verfahrensaufwand. Sortiert ist dann, wenn nur eine Teilliste mit allen
Datenelementen in sortierter Folge vorliegt. Erforderlich sind bei N Datenelementen
ldN verschiedene Läufe, wobei alle N Datenelemente auf temporären Dateien und
anschließend wieder zurück auf das Original kopiert werden. Das führt zu 2 ⋅ N ⋅ ldN
Zugriffe.
Algorithmus. Umfaßt das sequentielle „File“ N zu sortierende Datensätze, dann teilt
man diese Datensätze in N/I Teilfolgen. Die Teilfolgen enthalten demnach
höchstens I Datensätze. I ist die Anzahl der Datensätze, die (höchstens) in den
Hauptspeicher passen. Man liest eine derartige Teilfolge (ein Intervall mit I
Datensätzen) in den Internspeicher ein, sortiert sie mit einem der bekannten
Arbeitsspeicher-Sortierverfahren und schreibt die sortierte Teilfolge zurück auf den
Externspeicher, d.h. auf diverse sequentielle „Files“. Zu Beginn muß man diverse
Datensätze auf dem „Eingabe-File“ auf mehrere „Files“ aufteilen (z.B. 2), dann
verschmilzt man die inzwischen sortierten Teilfolgen. Die so entstandene Folge muß
wieder aufgeteilt werden, bis man schließlich eine vollständig sortierte Folge erzeugt
hat. Der Wechsel für Verteilungs- und Mischphase ist charakteristisch.
Ausgeglichenes direktes Mischsortieren 98.
Der bisher beschriebene und implementierte Verfahrensablauf wurde mit Hilfe von
drei sequentiellen „Files“ realisiert. Nimmt man noch ein viertes File hinzu, dann kann
die Verteilungs- und Mischphase zusammengefaßt werden (ausgeglichenes direktes
Mischsortieren).
Verfahrensbeschreibung: 4 Dateien d1, d2, d3, d4 sind gegeben. Eingabedatei ist d1.
Es werden wiederholt eine bestimmte Anzahl (A) Datensätze eingelesen, intern
sortiert und abwechselnd solange auf d3 und d4 geschrieben bis d1 erschöpft ist.
Sortierte Teilfolgen (sog. Läufe, Runs) der Länge I stehen dann auf d3 und d4. Diese
Läufe werden anschließend verschmolzen. Dabei entstehen Läufe mit 2*I langen
Teilfolgen, die abwechselnd auf d1 und d2 verteilt werden. Nach jeder Aufteilungsund Verschmelzungsphase hat sich die Run-Länge verdoppelt und die Anzahl der
Läufe etwa halbiert. Das Verfahren besteht also aus
- dem Verschmelzen der Läufe von zwei Dateien und abwechselndem Verteilen aus den beiden
anderen Dateien
- dem (logischen) Vertauschen der Dateien
bis schließlich nur ein Lauf auf einer der Dateien übrig bleibt.
Bsp.: Der Hauptspeicher des Rechner faßt 3 Datensätze (I = 3). Die Dateien
enthalten folgende Schlüssel:
d1: 12, 2, 5, 15, 13, 6, 14, 1, 4, 9, 10, 3, 11, 7, 8
d2:
A Datensätze werden jeweils von d1 gelesen, intern sortiert und auf d3 und d4 aufgeteilt:
d3: 2, 5, 12, 1, 4, 14, 7, 8, 11
d4: 6, 13, 15, 3, 9, 10
d3 und d4 werden gelesen, d1 und d2 beschrieben:
98
vgl. PR33116.CPP
146
Algorithmen und Datenstrukturen
d1: 2, 5, 6, 12, 13, 15, 7, 8, 11
d2: 1, 3, 4, 9, 10, 14
Es folgt:
d3: 1, 2, 3, 4, 5, 6, 9, 10, 12, 13, 14, 15
d4: 7, 8, 11
und schließlich
d1: 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15
d1 enthält zufälligerweise die sortierte Folge. Generell kann sie auf d1 oder d3 entstehen, wenn von
den beiden Dateien d1 und d2 (bzw. d3 und d4) entstehende Läufe zuerst auf d1 und danach auf d2
(bzw. zuerst auf d3 und danach auf d4) geschrieben werden 99.
Verfahrensaufwand: Nach jeder Verschmelzungs- und Verteilungsphase hat sich die
Anzahl der Läufe etwa halbiert. Zu Beginn wurde aus N Datensätzen über ein
internes Arbeitsspeichersortierverfahren N/I Läufe hergestellt. Damit ergibt sich nach
log(N/I) Durchgängen ein einziger sortierter Lauf.
Mehrwege-Mischsortierverfahren
Verfahrensbeschreibung: Ausgangspunkt sind 2k sequentielle Files (Bänder): d1,
d2,...,d2k. Es werden wiederholt I Datensätze von d1 gelesen und abwechselnd aud
dk+1,dk+2,...,d2k geschrieben bis d1 erschöpft ist. Dann stehen N/(k-I) Läufe der Länge I
auf di (k + 1 <= i <= 2k) Die k Files (Bänder) dk+1,...,d2k sind jetzt Eingabebänder für
ein k-Wege-Mischen, die k Files d1,...,dk sind die Ausgabebänder. Die ersten Läufe
der Eingabebänder werden zu einem Lauf der Länge k - l verschmolzen und auf das
Ausgabeband d1 geschrieben. Danach werden die nächsten k Läufe der
Eingabebänder verschmolzen und nach d2 geschrieben. So werden der Reihe nach
Läufe der Länge k – l auf die Ausgabedateien geschrieben, bis die Eingabedateien
erschöpft sind. Nach dieser Verschmelzungs- und Aufteilungsphase tauschen die
Eingabe- und Ausgabedateien ihre Rollen. Das k-Wege-Verschmelzen und k-WegeVerteilen wird solange fortgesetzt, bis die gesamte Folge der Datensätze als ein Lauf
auf einer der Dateien steht.
Verfahrensaufwand 100: Zu Beginn werden mindestens ein Lauf, höchstens (N/I)
Läufe und im Mittel (N/(2*I)) Läufe hergestellt. Nach jeder Verschmelzungs- und
Verteilungsphase hat sich die Anzahl der Läufe um das 1/k-Fache vermindert.
Implementierung 101in C++:
Mehrphasen-Mischsortieren
Beim ausgeglichenen Mehrwege-Mischsortieren werden alle Eingabedateien benutzt,
aber nur auf eine der Ausgabedateien geschrieben. Die anderen Ausgabedateien
sind temporär nutzlos. Da normalerweise die Zahl der Dateien viel kleiner ist als die
Anzahl der Elemente (I) in einem Lauf der Anfangsverteilung wäre es
wünschenswert, diese Dateien zu Eingabedateidateien heranzuziehen. Man will aber
nicht alle Läufe auf ein einzige Ausgabedatei speichern, weil man sonst vor der
99
vgl. Ottman, T. / Wittmayer, P.: Algorithmen und Datenstrukturen, Mannheim ... 1990, S. 142
vgl. Ottman, T. / Wittmayer, P.: Algorithmen und Datenstrukturen, Mannheim ... 1990, S. 147
101 vgl. PR33118.CPP
100
147
Algorithmen und Datenstrukturen
nachfolgenden Verschmelzungsphase noch eine zusätzliche Verteilungsphase
einschieben müßte.
Beim Mehrphasen-Mischsortieren 102 (polyphase mergesort) 103 arbeitet man mit (k+1)
Dateien, von denen zu jedem Zeitpunkt k Eingabedateien sind und eine
Ausgabedatei ist. Man schreibt solange alle entstehenden Läufe auf die
Ausgabedatei bis eine der Dateien erschöpft ist 104. Dann wird die leere Eingabedatei
zur Ausgabedatei, die bisher vorliegende Ausgabedatei dient als Eingabedatei. Damit
besitzt man wieder k Eingabedateien und eine (andere) Ausgabedatei. Der
Sortiervorgang ist beendet, falls die Eingabedateien erschöpft sind. Das Verfahren
funktioniert nur dann, wenn zu jedem Zeitpunkt (außer am Schluß) nur ein
Eingabeband leer wird.
Bsp.: In einer Anfangsverteilung sind 13 Läufe l1 l2 l3 l4 l5 l6 l7 l8 l9 l10 l11 l12 l13 auf die
Beiden dateien d1 und d2 folgendermaßen verteilt (d3 ist leer):
d1
l1 l2 l3 l4 l5 l6 l7 l8
d2
l9 l10 l11 l12 l13
d3
leer
Danach werden jeweils die nächsten Läufe von d1 und d2 verschmolzen und auf das
Ausgabeband d3 geschrieben, bis d2 erschöpft ist.
d1
l6 l7 l8
d2
leer
d3
l1,9 l2,10 l3,11 l4,12 l5,13
Die erste Phase ist damit abgeschlossen. Die jeweils nächsten Läufe von d1 und d3
werden verschmolzen, bis d1 leer ist
d1
d2
l6,1,9 l7,2,10 l8,3,11
d3
l4,12 l5,13
In den folgenden Phasen resultieren folgende Verteilungen:
d1
l6,1,9,4,12 l7,2,10,5,13
l7,2,10,5,13
leer
d2
l8,3,11
leer
l7,2,10,5,13,6,1,9,4,12,8,3,11
d3
leer
l6,1,9,4,12,8,3,11
leer
Am Ende muß genau ein Lauf auf einer Datei stehen, der aus 2 Läufen resultiert, die
jeweils auf zwei Dateien stehen.
102
vgl. Ottman, T. / Wittmayer, P.: Algorithmen und Datenstrukturen, Mannheim ... 1990, S. 148
vgl. Wirth, N.: Algorithmen und Datenstrukturen, Stuttgart 1979, S. 153
104 Das ist eine Phase.
103
148
Algorithmen und Datenstrukturen
3.1.1.2.2 Natürliches Mischen
Zwei-Wege Mischsortieren
Einführung. Beim direkten Mischen wird aus einer anfangs vorhandenen, teilweisen
Ordnung kein Nutzen gezogen. Tatsächlich können 2 geordnete Teilsequenzen mit
den Längen M und N direkt in eine Sequenz mit M + N Elementen eingemischt
werden. Ein Mischsortieren, das immer die längsten möglichen Teilfolgen mischt, ist
das natürliche Mischen.
X[J] heißt (maximaler) Lauf, wenn
Vereinbarung: Eine Teilsequenz X[I] ..
folgende Bedingungen erfüllt sind:
1. X[K] <= X[K + 1]
2. X[I - 1] > X[I]
3. X[J] > X[J + 1]
(für K = I .. J - 1)
Die natürliche Mischsortierung mischt (maximale) Läufe statt fester Sequenzen mit
vorbestimmter Länge. Jede Folge von natürlichen Zahlen zerfällt in eine solche
Folge, so z.B.:
5
3
2
7 10
4
1 7
3
6
8
2 geordnete Sequenzen sind dann jeweils zu einer einzigen, geordneten Sequenz zu
vereinigen.
Verfahrensaufwand. Er wird danach gemessen, wie oft Läufe in die Betrachtung
eingehen. Läufe haben die Eigenschaft, daß beim Mischen von 2 Sequenzen mit L
Läufen eine einzige Sequenz mit genau L Läufen entsteht.
So ergibt sich für die folgenden beiden Sequenzen
8
7
6
5
4
3
2
1
mit jeweils 4 Läufen die Sequenz
7
8
5
6
3
4
1
2
, die genau 4 Läufe besitzt. Die Zahl der Läufe wird in jedem Durchlauf halbiert. Im
schlimmsten Fall ergibt sich dann die Anzahl der Bewegungen zu: L ld(L).
Der Algorithmus.
Ablauf: Die zu sortierenden Daten liegen im File F vor und sollen am Schluß in
sortierter Form unter demselben Namen zurückgegeben werden. Die beiden
Hilfsfiles sind G1 und G2. Jeder Durchlauf umfaßt eine Verteilungsphase
(distribution), die Läufe gleichmäßig von F auf G1 und G2 verteilt, und eine
Mischphase, die Läufe von G1 und G2 auf F mischt.
149
Algorithmen und Datenstrukturen
G1
F
G1
G1
F
F
F
F
.......
G2
G2
G2
Mischphase
Verteilungsphase
Abb.:
Das Sortieren ist beendet sobald F nur noch ein Lauf ist.
Für die Definition des momentanen Zustands eines Files stellt man sich am besten
einen Positionszeiger vor. Er wird beim Schreiben um je eine Einheit vorwärts
geschoben.
Beschreibung: Sie erfolgt nach der Methode „stepwise refinement“.
Grobstruktur des Prozesses:
Wiederhole
Setze die Zeiger aller 3 Files auf den Anfang;
Verteile;
Setze die Zeiger aller 3 Files auf den Anfang;
Mische;
bis L = 1;
(* L ist die Anzahl der Läufe auf dem File F *)
Verfeinerungsschritt:
Verteile
Wiederhole
Kopiere ein Lauf F auf G1
Falls
noch nicht eof(F),
kopiere einen Lauf von F auf G2;
bis Ende von F erreicht;
Mische
150
Algorithmen und Datenstrukturen
Setze L = 0;
Solange weder eof(G1) noch eof(G2) fuehre aus:
Mische je einen Lauf von G1 und G2 auf F;
Erhoehe L um 1;
Solange eof(G1) noch nicht erreicht, fuehre aus:
Kopiere einen Lauf von G1 auf F;
Erhöhe L um 1;
Solange eof(G2) noch nicht erreicht, fuehre aus:
Kopiere einen Lauf von G2 auf F;
Erhöhe L um 1;
151
Algorithmen und Datenstrukturen
3.1.2 Suche in Texten
3.1.2.1 String Pattern-Matching
3.1.2.1.1 Ein einfacher Algorithmus zum Suchen in Zeichenfolgen
1. Problem
Finden eines Teilstrings (Muster / Pattern) in einem anderen String.
Suche erstes Vorkommen der Muster-Zeichenfolge pattern[0..m-1] im TextZeichenfolgen text[0...n-1]
2. Straightforward Lösung
- Algorithmische Idee
Starte beim ersten Zeichen im Text 105 und prüfe, ob die Zeichenfolge des Patterns
mit der anfänglichen Zeichenfolge im Text übereinstimmt.
Falls ja: Erfolg
anderenfalls: Starte neu beim zweiten Zeichen im Text
etc.
Nicht gerade effizient: Anzahl von Zeichen-Vergleichen ist O(n*m) (m: Länge des
Pattern, n: Länge des Texts)
- Struktogramm
Ansatz: Ab jeder Position i prüfen, ob der Test text[i..i + (m – 1)] mit dem
Muster pattern übereinstimmt.
letztes-bearbeitungswürdiges-Zeichen = textLaenge - musterLaenge
Schleife über alle bearbeitungswürdigen Zeichenpositionen i im Text
String substr = Substring im Text von Postion i bis i + Musterlänge
substr stimmt mit Muster überein
return: i
return: -1
Implementierung mit charAt() bzw. substr()
public static int search(String text, String pattern)
{
final int last = text.length() - pattern.length();
for (int i = 0; i <= last; i++)
105
Texte sind nicht weiter strukturierte Folgen beliebiger Zeichenketten aus einem Alphabet
152
Algorithmen und Datenstrukturen
{
int j;
for (j = 0; j < pattern.length(); j++)
{
if (pattern.charAt(j) != text.charAt(i++)) break;
}
if (j == pattern.length()) return i;
}
return -1;
}
public static int search(String text, String pattern)
{
final int last = text.length() - pattern.length();
for (int i = 0; i <= last; i++)
{
String substr = text.substring(i,i+pattern.length());
if (substr.equals(pattern)) return i;
}
return -1;
}
3. String-Matching mit endlichen Automaten
Idee: Repräsentation eines Pattern als endlicher Automat. (Text als Input in den
Automaten, Terminierung in Endzustand, wenn Pattern entdeckt.)
Leider ist das Erstellen eines endlichen Automaten (die entsprechende
Übergangstabelle) sehr aufwendig. Der Knuth-Morris-Pratt-Algorithmus arbeitet mit
einer einfacheren Variante zum endlichen Automaten- einer sog. Verschiebetabelle,
die in linearer Zeit in der Pattern-Länge konstruiert werden kann.
3.1.2.1.2 Der Algorithmus von Knuth-Morris-Pratt
Idee: Bisherige Teilsuchergebnisse nutzen zur Verschiebung der Muster um mehr als
eine Stelle. Tritt ein Missmatch an Position i des Patterns auf, so kann man
a) das Pattern u.U. um mehr als eine Position weiterschieben
b) braucht man die Position vor dem Missmatch nicht nochmals zu vergleichen.
text
b a c d b a b b a a a a b f a a a a
pattern
b a b b a f e a
b a b b a f e a
Definitionen:
- pattern[0..k] (k <= m-1) heißt Präfix vom pattern[0..m-1]
- pattern[l...m-1] (l >= 0) heißt Suffix von pattern [0...m-1]
- Man spricht von echtem Praefix / Suffix, wenn k < m-1 bzw. l > 0
- Eigentlicher Rand von pattern ist der längste Teilstring von pattern, der sowohl echtes Präfix als
auch echtes Suffix von pattern ist.
153
Algorithmen und Datenstrukturen
Bsp.: UNGLEICHUNG hat eigentlichen Rand UNG
Ränder können sich überlappen.
Bsp.: Der eigentliche Rand von aabaabaa ist aabaa. aa ist ein Rand aber nicht der
eigentliche Rand.
Vorarbeit zur KMP-Suche: Zunächst für alle Teilmuster pattern[0..j-1]: Länge
des eigentlichen Rands in der Shift-Tabelle next[j] speichern.
Bsp.: text
...UNGLEICHUNGSTEIL...
i-j
pattern
i
UNGLEICHUNGEN
next[j]
j
UNGLEICHUNGEN
next[j]
- 1. Mismatch (von links) bei text[i] und pattern[j] => text[i-j...i-1] == pattern[0...j-1] und text[i] !=
pattern[j}
- Zurücksetzen von j auf j' = next[j]
- Trick: Vergleichen beginnt zwischen text[i] und pattern[j]
Definition der Shift-Tabelle
j=0
⎧− 1
next[ j ] = ⎨
⎩∂ ( s 0 ...s j −1 ) j ≥ 1
∂ ( s ) bezeichnet den eigentlichen Rand vom pattern. Das Teilwort s stimmt ab der
Position i mit dem zugehörigen Teilwort von text[i] bzw. s0 mit text[i+j-1] bzw. s j −1
überein, d.h. text[i+j] <> s j . Der zum Teilwort s 0 ...s j −1 gehörende eigentliche Rand
hat laut Definition die Länge next[j]. Verschiebt man s um j – next[j] nach rechts, so
kommt der rechte Rand des Teilworts s 0 ...s j −1 von s auf dem linken Rand zu liegen,
d.h. man schiebt Gleiches auf Gleiches. Da es keinen längeren Rand von s 0 ...s j −1 als
diesen gibt, der ungleich s 0 ...s j −1 ist dieser Shift sicher.
154
Algorithmen und Datenstrukturen
text
[0]
[i]
pattern
[i+j]
[0]
[j]
a b a
a b a
a b a
a b a
j-next[j]
next[j]
Eigentlicher Rand von
s 0 ...s j −1
a b a
Abb.: Shift um j-next[j]
Implementierung der Shift-Tabelle
In der Tabelle next[] wird für jedes Präfix s 0 ...s j −1 die Länge 0...m des Suchstrings
pattern der Länge m gespeichert, wie groß dessen eigentlicher Rand ist.
Initialisierung: next[0] = -1, next[1] = 0
Annahme: next[0]...next[j-1] seien bereits berechnet.
Ziel: Berechnung von next[j]=Länge des eigentlichen Rands eines Suffixes der Länge
j
Ist s next[ j −1] = s j −1 , dann ist next[j]=next[j-1]+1. Anderenfalls müsste man ein kürzeres
Präfix von s 0 ...s j − 2 finden, das auch ein Suffix von s 0 ...s j − 2 ist. Der nächsthöhere
Rand ist iffensichtlich der eigentliche Rand des zuletzt betrachteten Rands des
Worts: Nach der Konstruktion der Tabelle next ist das nächstkürzere Präfix mit dieser
Eigenschaft das der Länge next[next[j-1]]. Es folgt der Test, ob sich dieser Rand von
s 0 ...s j − 2 zu einem eigentlichen Rand von s 0 ...s j −1 erweitern lässt. Das wird solange
wiederholt, bis ein Rand gefunden ist, der sich zu einem Rand von s 0 ...s j −1 erweitern
lässt. Falls sich kein Rand von s 0 ...s j − 2 zu einem Rand von s 0 ...s j −1 erweitern lässt,
dann ist der eigentliche Rand von s 0 ...s j −1 das leere Wort und man setzt next[j] = 0.
Der Inhalt der Tabelle next[] kann durch Vergleichen der Teilmuster aus pattern mit
sich selbst, um 1 verschoben, ermittelt werden.
public static int[] initNext(String pattern)
{
final int m = pattern.length();
int next[] = new int[m];
int i = 0;
int j = -1;
next[0] = -1;
while (i < (m - 1))
{
while(j >= 0 && (pattern.charAt(i) != pattern.charAt(j)))
j = next[j];
++i;
++j;
next[i] = j;
}
return next;
}
155
Algorithmen und Datenstrukturen
Bsp. und Test:
next[j]
j=1
A
B
C
A
A
B
2
A
B
A
C
B
A
C
A
A
B
A
A
B
C
A
A
B
A
C
B
A
A
B
C
A
A
A
B
B
C
A
A
B
A
A
A
B
A
B
C
B
A
C
A
A
B
A
3
A
4
A
5
B
A
B
C
= 0
0
0
1
B
1
Abb.: Die Tabelle next[] für das Pattern ABCAAB
Implementierung KMP-Suche
Mit der aus dem letzten Beispiel bestimmten Shift-Tabelle
j
next[j]
0
-1
1
0
2
0
3
0
4
1
5
1
ergeben sich für den folgenden Text folgende Vergleiche:
text
[0]
A
Vergleichsrichtung
B
C
A
B
A
pattern
[0]
[1]
B
[2]
[3]
[4]
[5]
A
B
C
A
A
B
1
2
3
4
5
j = 4, next[4] = 1
A
B
6
C
A
B
A
A
B
B
C
A
B
A
A
A
B
B
7
j = 2, next[j] = 0
A
B
C
8
9
10
j = 2, next[j] = 0
A
A
B
C
A
11
j = 0, next[j] = -1 => i++, j = 0
A
B
C
A
A
B
12
13
14
15
16
17
Das führt zu folgenden Quellcodeanweisungen:
// Knuth-Morris-Pratt-Methode
156
B
Algorithmen und Datenstrukturen
public static int search(String text, String pattern)
{
int next[] = initNext(pattern);
final int n = text.length();
final int m = pattern.length();
int j = 0; // Position in pattern
int i = 0; // Position in text
while (i < n)
{
while (j >= 0 && (pattern.charAt(j) !=text.charAt(i)))
{
j = next[j];
}
++i; // im Text eine Position weitergehen
++j;
if (j == m)
{
return (i - m); // Muster ganz gefunden
}
}
return -1;
}
Komplexität von KMP-Textsuche
- Such-Phase: In jedem Schritt
„ entweder im Text um ein Zeichen weitergehen (++i)
„ oder Muster um mindestens 1 Zeichen weiter rechts anlegen j = next[j]
Æ Aufwand O(n).
- Initialisieren der next-Tabelle (läuft genau wie Suche ab): Aufwand O(m)
- KMP-Algorithmen insgesamt: O(m+n)
- Zusätzlicher Platzbedarf O(m)
3.1.2.1.3 Boyer / Moore - Suche
Neuer Ansatzpunkt zum Suchen in Texten: Zu suchendes Wort in einem Textstück
nicht mehr von links nach rechts sondern von rechts nach links vergleichen. Ein
Vorteil dieser Vorgehensweise ist, dass man in Alphabeten mit vielen
unterschiedlichen Symbolen bei einem Mismatch an der Position i + j im Textstück , i
auf i + j + 1 setzen kann, falls das im Textstück gesuchte Zeichen an der Position i + j
nicht im gesuchten Wort vorkommt.
1. Simple BM 106
Grundüberlegung:
Muster an Position i am text "anlegen" und von rechts nach links mit Text vergleichen.
Falls:
- 1.Mismatch zwischen pattern[j] und text[i+j] (tij)
- und tij kommt irgendwo im Muster vor
Muster um j+i Zeichen verschieben
=> im günstigsten Fall (Alphabet groß genug, nur wenig verschiedene Zeichen im Muster) kann
oft um gesamte Musterlänge verschoben werden
-
bad-character Heuristik
106
In der Literatur gibt es verschiedene Varianten unter dem Namen "Boyer/Moore"-Algorithmus. Die hier
beschriebene Variante entspricht dem sog. "bad character"-Teil des Algorithmus.
157
Algorithmen und Datenstrukturen
Fall a): Zeichen im text, an dem ein Mismatch auftritt, kommt im Muster nicht vor
=> Muster so weit verschieben, dass es hinter Mismatch-Position anfängt.
A
C
B
B
D
A
A
B
C
A
B
A
A
A
B
C
A
B
B
C
A
B
A
A
..
Vergleichsrichtung
Fall b.1) Zeichen im text, an dem Mismatch auftritt, kommt im Muster vor, und zwar
nur vor aktueller Stelle im Muster => Muster so weit verschieben, dass diese
Musterposition an der Mismatch-Position im text anliegt.
A
C
B
B
D
A
A
B
D
A
B
A
A
B
D
A
A
B
B
A
C
A
B
A
..
Vergleichsrichtung
Fall b.2) Zeichen im text, an dem Mismatch auftritt, kommt im Muster vor, und zwar
hinter der aktuellen Stelle im Muster => Muster um eine Position weiterschieben
A
C
B
C
B
A
B
A
D
C
B
A
B
A
D
C
B
A
B
C
A
B
A
..
A
Vergleichsrichtung
Bsp.:
A
A
A B B
C B A
A C B
A C
A
B
A
B
A
C
C
B
A
C
C
B
C
B
B
B
A
B
C
A
A C D A C
A
B A
B C B A
A
B
A
B
C
B
A
C B A B C B A
"last"-Tabelle
enthält zu jedem Zeichen des Zeichensatzes die Position des letzten Vorkommen im
Muster (oder "-1", falls es nicht vorkommt). Implementierung z.B. als Array indiziert
mit (Unicode)-Zeichensatz: 'A' auf Index 65, 'B' auf 66 usw., 'a' auf 97, 'b' auf 98 usw.
158
Algorithmen und Datenstrukturen
Bsp.: "last"-Tabelle des Muster "bananas"
pattern
Index im Muster
Unicode-Index
b
0
98
a
1
97
n
2
110
a
3
97
"last"-Wert
0
5
4
5
...
0-96
-1
a
97
5
b
98
0
...
99-109
-1
n
4
11
0
4
n
110
4
a s
5 6
97 115
5
6
...
111-114
-1
s
115
6
…
116-127
-1
Implementierung "last"-Tabelle
private static int[] initLast(String pattern)
{
// Platz fuer ASCII-Zeichen (Unicode 0 - 255)
int [] last = new int[256];
int i;
for (i = 0; i < 255; i++) last[i] = -1;
// nach vorbelegen mit -1 fuellen
for (i = 0; i < pattern.length(); i++)
last[pattern.charAt(i)] = i;
return last;
}
Implementierung Simple BM
Die Anwendung der vorliegenden "last"-Tabelle auf folgenden text
o
r
a
n
g
e
s
b
a
n
a
n
a
s
b
,
a
a
n
n
a
n
a
s
b
a
n
a
n
b
a
a
n
n
a
a
s
a
n
a
n
d
b
a
n
a
n
a
s
s
a
s
b
a
n
a
n
a
s
b
a
n
a
n
a
s
b
a
n
a
n
Verschiebedistanz = max(1,,j – last[text[i+j]])
Vergleichsrichtung
führen zu dem Simple-BM-Algorithmus
public static int search(String text, String pattern)
{
int last[] = initLast(pattern);
final int lastidx = text.length() - pattern.length();
int i = 0;
while (i <= lastidx)
{
// pattern vergleich ab [i]
// suche nach mismatch von rechts nach links
int j = pattern.length() - 1;
while (j >= 0 && pattern.charAt(j) == text.charAt(i+j))
--j; // match, dann naechstes Zeichen
159
a
s
Algorithmen und Datenstrukturen
if (j < 0) // Muster ganz gefunden
return i;
else // sonst: pattern verschieben
i += Math.max(1,j - last[text.charAt(i+j)]);
}
return -1; // Muster nicht gefunden
}
Laufzeitverhalten (unter Annnahme n>>m).
-
-
O(n*m) im schlimmsten Fall. Zutreffend für ungünstige Text/Pattern
Kombination, z.B. Text: "a*", Muster: "baaaaaa"
Im Normalfall (Text und Muster sind sich sehr unähnlich) geht es aber viel
schneller. Wenn Alphabet des Textes groß im Vergleich zu m: nur etwa
(O(n/m) Vergleiche.
Nur sinnvoll, wenn das Alphabet groß ist (z.B. ASCII/Unicode). Für Bitstrings
ist dieses Verfahren weniger gut geeignet.
2. BM
Der von R.S. Boyer und J.S. Moore vorgeschlagene Algorithmus unterscheidet sich
von den vorstehenden naiven Ansätzen in der Ausführung größerer Shifts, die in
einer Shift-Tabelle gespeichert sind.
Bestimmen der shift-Tabelle.
Zu Beginn wird die Shift-Tabelle an allen Stellen mit der Länge des Suchstrings
initialisiert. Im wesentlichen entsprechen mögliche Shifts der Bestimmung von
Rändern von Teilwörtern des gesuchten Worts. Im Unterschied zum KMPAlgorithmus wird zusätzlich zu den Rändern von Präfixen des Suchworts auch nach
Rändern von Suffixen des Suchworts gesucht.
Mögliche Shifts beim Boyer-Moore-Algorithmus.
text
i
i+j
[0]
j
n-1
m-1
pattern
σ
j- σ
j
m-1
σ
m- σ
σ
m- σ
Abb.: Zulässige Shifts bei Boyer-Moore
Prinzipiell gibt es 2 mögliche Arten eines "vernünftigen" Shifts. Im oberen Teil ist ein
"kurzer" Shift angegeben, bei dem im grünen Bereich die Zeichen nach dem Shift
weiter übereinstimmen. Das rote Zeichen im text, das den Mismatch ausgelöst hat,
wird nach dem Shift auf ein anderes Zeichen im pattern treffen, damit überhaupt
160
Algorithmen und Datenstrukturen
die Chance auf Übereinstimmung besteht. Im unteren Teil ist ein "langer" Shift
angegeben, bei dem die Zeichenreihe von pattern so weit verschoben wird, so
dass an der Position des Mismatch im text gar kein Vergleich mehr entsteht.
Allerdings soll auch hier im schraffierten Bereich wieder Übereinstimmung mit den
bereits verglichenen Zeichen im text herrschen.
Die Kombination der beiden Regeln nennt man die Good-Suffix-Rule, da man darauf
achtet, die Zeichenreihe so zu verschieben, dass im letzten übereinstimmenden
Bereich wieder Übereinstimmung herrscht.
Ermitteln der in der Shift-Tabelle zu speichernden Werte: Der erste Mismatch soll im
zu durchsuchenden Text an der Stelle i+j auftreten. Da der Boyer-MooreAlgorithmus das Suchwort von hinten nach vorn vergleicht, ergibt sich folgende
Voraussetzung:
s j +1 ...s m −1 = t i + j +1 ...t i + m −1
∧ s j ≠ ti+ j
Um einen nicht nutzlosen Shift um σ Positionen zu erhalten, muß gelten:
s j +1−σ ...s m −1−σ = t i + j +1 ...t i + m −1 = s j +1 ...s m −1
∧ s j ≠ s j −σ
Diese Bedingung ist nur für "kleine" Shift mit σ ≤ j sinnvoll 107. Für größere Shift
σ > j muß gelten, dass das Suffix des übereinstimmenden Bereichs mit dem Präfix
des Suchworts übereinstimmt, d.h.
s 0 ...s m −1−σ ...s m −1−σ = t i +σ ...t i + m −1 = s j +1 ...s m −1
Bsp.: Shift-Tabelle für den Suchstring "ababbababa"
Teil 1: σ ≤ j
[0]
[1]
a
b
[2]
a
[3]
[4]
[5]
[6]
[7]
[8]
[9]
b
b
a
b
a
b
a
b
a
≠
a
b
shift[9] = 1
a
=
b
a
b
a
a
b
a
a
b
a
=
a
b
=
b
a
b
=
107
Ein solcher Shift ist im obigen Bild als erster Shift zu finden.
161
Algorithmen und Datenstrukturen
b
b
a
b
a
b
a
a
b
a
b
a
≠
b
b
≠
b
b
shift[5] = 2
a
b
a
b
a
≠
b
b
shift[7] = 4
a
b
a
b
a
≠
b
a
b
shift[9] = 6 108
b
a
b
a
b
a
b
b
a
b
a
b
a
b
b
a
b
a
b
a
=
a
b
a
=
a
b
a
Teil 2: σ > j
a b a b b a b
σ =7
bei Mismatch an Position
a b
j ∈ [0 : 6]
shift[j] = 7
a b b a b a b a
a b a b b a b a b a
σ =9
bei Mismatch an Position
a b
a b a
a b a b b a b a b a
j ∈ [7 : 8]
shift[j] = 9
a b b a b a b a
a b a b b a b a b a
σ = 10
bei Mismatch an Position
j ∈ [9]
shift[j] = 10
Zusammenfassung:
shift
[0]
7
[1]
7
[2]
7
[3]
7
[4]
7
[5]
2
Implementierung der Shift-Tabelle 109
private static int[] initShift(String pattern)
{
int m = pattern.length();
108
109
Dieser Wert nicht gespeichert, da s[9] schon mit 1 belegt ist.
pr31200
162
[6]
7
[7]
4
[8]
9
[9]
1
Algorithmen und Datenstrukturen
int[] shift = new int[m+1];
int[] suffix = new int[m+1];
int i, j, h = 0;
suffix[m-1] = m;
j = m-1;
for(i = m-2; i >= 0; --i)
{
if (i > j && suffix[i+m-1-h] < i - j)
suffix[i] = suffix[i+m-1-h];
else {
if ( i < j ) j = i;
h = i;
while( j >= 0 && pattern.charAt(j) == pattern.charAt(j+m-1-h) )
--j;
suffix[i] = h - j;
}
}
for (i = 0;i < m; ++i)
shift[i] = m;
j=0;
for(i = m-1; i >= -1; --i)
if(i==-1 || suffix[i]==i+1)
while( j < m-1-i)
{
if(shift[j] == m)
shift[j] = m-1-i;
++j;
}
for(i = 0;i < m-1; ++i)
shift[m-1-suffix[i]] = m-i-1;
return shift;
} // Ende initShift()
Implementierung Boyer-Moore (Suchfunktion)
public static int search(String text, String pattern)
{
int i = 0;
int last[] = initLast(pattern);
// last-Tabelle
int shift[] = initShift(pattern); // shift-Tabelle
while(i<=text.length()-pattern.length())
{
// pattern-Vergleich ab [i]
int j = pattern.length() - 1;
// suche nach mismatch von rechts nach links
while(j >= 0 && pattern.charAt(j) == text.charAt(i+j))
--j;
if(j < 0) return i;
else
i += Math.max(shift[j], j-last[text.charAt(i+j)]);
}
return -1;
}
Komplexität: In der Vorbereitungsphase beträgt der Aufwand für die Erstellung der
Shift-Tabelle O(m) , für die Erstellung der last-Tabelle O(m + Σ ) . Unter der
Voraussetzung, dass das Muster nicht oder wenige Male im Text vorkommt, werden
im schlechtesten Fall O(n) Vergleiche benötigt. Bei einem im Vergleich zur Länge
des Musters großen Alphabet beträgt die Anzahl der Vergleiche sogar nur O(n / m) .
In den eher seltenen Fällen, wo ein Suffix des Musters sehr häufig im Text vorkommt
(z.B. beim Suchen des Musters AB m −1 im Text B n ist der Aufwand O(nm) .
163
Algorithmen und Datenstrukturen
3.1.2.2 Pattern-Matching mit regulären Ausdrücken
Bisher wurde eine konkrete Zeichenfolge gesucht. Jetzt soll die Suche nach einem
allgemeinen Muster erfolgen. Dazu wird benötigt:
-
Notation zur Beschreibung allgemeiner Ausdrücke => reguläre Ausdrücke
Mechanismus zur Erkennung solcher Muster => endliche Automaten
Mit Automaten definiert man die Worte einer Programmiersprache.
Reguläre Ausdrücke können aus regulären Sprachen abgeleitet werden. Eine
Sprache L wird über dem Alphabet Σ ( d.h. eine endlichen Menge von Zeichen)
gebildet: L ⊆ Σ * .
Σ * ist die Menge aller Wörter über
Σ* = {ε , a, b, aa, ab, ba, aaa,...}
- Ein Wort über dem Alphabet Σ ist
„ eine endliche Menge von Zeichen aus Σ
„ Spezialfall: leeres Wort ε
-
Σ . Falls bspw.
Σ = {a, b} ist, dann ist
Ein Sonderfall bilden reguläre Sprachen. Für sie gilt folgende rekursive Definition:
{ε } ist eine reguläre Sprache über Σ
2. ∀σ ∈ Σ . Die Menge {σ } ist eine reguläre Sprache über Σ
1. Die Menge
3. Falls L, L1 , L2 ⊆ Σ * regulär sind, dann sind es auch folgende Sprachen
„
„
„
Vereinigungsmenge L1 ∪ L2 = {w | w ∈ L1 ∨ w ∈ L2 }
Konkatenation L1 o L2 = {w1 w2 | w1 ∈ L1 ∧ w2 ∈ L2 }
Wiederholung L* = {ε } ∪ L ∪ L o L ∪ L ∪ L o L o L ∪ ...
3.1.2.2.1 Reguläre Ausdrücke
Reguläre Ausdrücke können aus regulären Sprachen abgeleitet werden:
1. die leere Zeichenkette
ε
ist ein regulärer Ausdruck mit L(ε ) = {ε }
2. ∀σ ∈ Σ ist ein regulärer Ausdruck mit L(σ ) = {σ }
3.
Reguläre
Ausdrücke
R,
S
mit
L( R ) = LR ,
L ( S ) = LS
können
mit
den
folgenden
Verknüpfungsoperationen komplexe Ausdrücke bilden:
„ Klammerung T = ( R) mit L(T ) = LR
L(T ) = LR o LS
„ Oder T = R | S mit L(T ) = LR ∨ LS
„ Hüllenbildung T = R * mit L (T ) = LR *
„
Verkettung T = RS mit
Für Pattern-Matching mit regulären Ausdrücken existieren Pattern-Matcher. Diese
Matcher basieren typischerweise auf endlichen Automaten.
Das grep-Kommando implementiert einen Matcher für reguläre Ausdrücke. Die
Programmiersprache Perl ist im wesentlichen ein Matcher für reguläre Ausdrücke.
Natürlich können Matcher für reguläre Ausdrücke auch String-Pattern-Matching,
konstante Strings sind spezielle reguläre Ausdrücke.
164
Algorithmen und Datenstrukturen
Neben den standardmäßig zur Beschreibung regulärer Ausdrücke verwendbaren
Konstrukte werden einige Zusatzkonstrukte erlaubt, die es einfacher machen
reguläre Ausdrücke aufzuschreiben. Die folgende Tabelle zeigt dafür einen
Überblick:
Zeichen
x
Zeichen x
\\
Backslash
\t
Tab
\n
Newline
\cx
Control-Zeichen x
...
Zeichenklassen
[abc]
einfache Klasse
[ˆabc]
Negation
[a-zA-Z]
inklusiver Bereich
[a-z-[bc]]
Subtraktion
[a-z-[m-p]]
Subtraktion mit inkl. Bereich
[a-z-[ˆdef]]
Vordefinierte Zeichenklassen
Beliebiges Zeichen
.
eine Ziffer
\d
keine Ziffer
\D
Trenner (whitespace)
\s
kein Trenner
\S
Textzeichen
\w
kein Textzeichen
\W
Begrenzer
Zeilenanfang
ˆ
Zeilenende
$
Wortgrenze
\b
keine Wortgrenze
\B
...
Greedy Quantoren
0 bis n des vorangestellten Zeichens
*
Kleene-Stern
1 bis n des vorangestellten Zeichens
+
das vorangestellte Zeichen 0- oder
?
1-mal
...
Weitere Quantoren
...
Quotation
markiert das folgende Zeichen als
\
“wörtlich zu nehmen”
Logische Operation
XY
Sequenz (X gefolgt von Y)
X|Y
Oder
(a, b oder c)
(ein beliebiges Zeichen ausser a, b, oder c)
(alle Groß- und Kleinbuchstaben)
(a bis Z ausser b und c)
(a bis z ausser m bis p)
(d, e, oder f)
[0-9]
[ˆ0-9]
[ \t\n\x0B\f\r]
[a-zA-Z_0-9]
weitere Verwendung: Negation
Abb.: Konstruktion regulärer Ausdrücke
Bsp.: Der reguläre Ausdruck "[a-z]+" deckt eine Folge von einem oder mehreren
Kleinbuchstaben ab.
[a-z] bedeutet: irgendein Zeichen von a bis z, zusätzlich bedeutet + "ein" oder
mehrere. Angenommen wird, dass dieses Muster auf den String "Now in the time"
angewendet wird.
Es gibt 3 Wege, wie dieses Muster angewendet werden kann:
165
Algorithmen und Datenstrukturen
-
auf den vollständigen String: Es führt zur Fehlanzeige, weil der String auch andere
Buchstaben benutzt als Kleinbuchstaben.
auf den Anfang der Zeichenkette: Es führt zur Fehlanzeige, weil der String nicht mit einem
Kleinbuchstaben anfängt.
auf Suche im String: Es ist erfolgreich und passt auf ow. Falls wiederholt angewendet, findet
man is, the, time. Danach gibt es eine Fehlanzeige.
Kann das in diesem Bsp. enthaltene Problem in Java gelöst werden? Vor Java 1.4
konnte Pattern-Matching nur umständlich mit Hilfe der StringTokenizer- und
charAt()-Methoden realisiert werden. Mit Java 1.4 gibt es folgende
Lösungsmöglichkeit:
- Zuerst muß das pattern kompiliert werden
import java.util.regex.*;
Pattern p
= Pattern.compile("[a-z]+");
- Dann muß ein Matcher für ein bestimmtes Stück Text erzeugt werden, in dem eine Nachricht an das
Muster (pattern) gesendet wird:
Matcher m
= p.matcher("Now is the time");
Vollständiges Bsp. 110:
import java.util.regex.*;
public class RegexTest
{
public static void main(String args[])
{
String pattern = "[a-z]+";
String text
= "Now is the time";
Pattern p
= Pattern.compile(pattern);
Matcher m
= p.matcher(text);
while (m.find())
{
System.out.print(text.substring(m.start(),m.end())+"*");
}
}
}
// Ausgabe: ow*is*the*time*
Allgemein können reguläre Ausdrücke durch Strings über Variablen ersetzt werden.
Hierzu werden in regulären Ausdrücken Gruppen gebildet, z.B.:
- ([a-zA-Z]*)([0-9]*) führt zur Übereinstimmung mit einer Anzahl Buchstaben, gefolgt von einer Anzahl
Ziffern
- \[([a-z])\.([A-Z])\] kann durch (\1,\2) ersetzt werden.
Gruppen werden durch Klammern eingeschlossen. Capturing groups werden
nummeriert durch Zählen der öffnenden Klammern von links nach rechts, z.B.:
((A)(B(C)))
12
3 4
\0=\1=((A)(B(C))), \2=(A), \3=(B(C)), \4=(C)
110
vgl. pr31250
166
Algorithmen und Datenstrukturen
3.1.2.2.2 Überprüfung regulärer Ausdrücke mit endlichen Automaten
Definition eines nichtdeterministischen, endlichen Automaten (NEA)
Ein nichtdeterministischer endlicher Automat (NEA) ist definiert durch ein Quintupel
mit
1. einer endlichen Zustandsmenge S
2. einer Menge
∑
(oder auch Alphabet) von Eingabesymbolen
3. einem Zustand s0, der als Start- oder Endzustand bezeichnet wird
4. eine Menge F von Zuständen, die als akzeptierte oder Endzustände bezeichnet werden
5. einer Übergangsfunktion δ : S × (Σ ∪ {ε }) .
Ein endlicher Automat lässt sich als markierter, endlicher Graph darstellen, wobei
-
Knoten die Zustände
markierte Kanten die Übergangsfunktion
repräsentieren.
Ein endlicher Automat akzeptiert eine Zeichenfolge genau dann, wenn es einen Pfad
vom Startzustand zu einem Endzustand gibt und die Markierungen der Kanten des
Pfads genau diese Zeichenfolge bilden 111.
Da die Übergangsfunktion eine Menge von Zuständen (Potenzmenge, Folgezustand
nicht eindeutig) liefert, handelt es sich um einen nichtdeterministischen endlichen
Automaten (NEA). Dies ist für die Erkennung regulärer Ausdrücke sinnvoll, weil durch
die Oder- oder Hüllenoperationen mehrere (d.h. zwei) Übergänge aus einem Zustand
möglich sind.
Bsp.:
A
A
0
B
1
A
2
3
B
Abb.: Beispiel für einen nichtdeterministischen endlichen Automaten für die Erkennung des Ausdrucks
A(A+B)*BA, Zustandsmenge S={0,1,2,3}, Alphabet
∑
= {A,B}
Satz von Kleene
- Zu jedem regulären Ausdruck r gibt es einen NEA M, der die von r beschriebene Sprache L(r)
akzeptiert
- Zu jedem NEA M gibt es einen regulären Ausdruck, der die von M akzeptierte Sprache L(M)
beschreibt
In beiden Fällen ist L(r) = L(M). Es kann also folgendermaßen überprüft werden, ob
eine Zeichenfolge w zu einem regulären Ausdruck passt:
1. Konstruktion eines NEA M zu r
111
Beim KMP-Algorithmus wird die Kodierung eines endlichen Automaten als "next"-Tabelle benutzt.
167
Algorithmen und Datenstrukturen
2. Prüfen, ob M die Zeichenfolge w akzeptiert, d.h. Simulation von M (Breitensuche
im Zustandsdiagramm)
Konstruktion eines NEA zu einem regulären Ausdruck
- Eingabe des regulären Ausdrucks r über
∑
- Ausgabe: NEA M, der L(r) akzeptiert
- Algorithmus:
-- Zerlege r in Bestandteile
-- Erzeuge für jeden Term in r einen regulären Ausdruck und konstruiere entsprechend der rekursiven
Definition von regulären Ausdrücken aus diesen NEAs einen NEA, der L(r ) akzeptiert
a) zu jedem atomaren Symbol aus Σ ∪ {ε }
b) zu jeder Verkettung
c) zu jeder Oder-Verknüpfung
d) zu jeder Hüllenbildung
für Klammerung (s) nehme Ms
a) Zu jedem atomaren Symbol σ ∈ ∑ oder ε (dem leeren String) wird ein eigener
Automat erstellt, der aus einem Startzustand, einen Endzustand sowie einer Kante
besteht, die beide Zustände verbindet und mit dem jeweiligen Symbol markiert ist.
σ
ε
Start
Start
Abb.: NEA zu jedem Buchstaben
σ
(der im regulären Ausdruck vorkommt) bzw.
ε
Die einzelnen Automaten werden anschließend entsprechend der definierten
Operationen verknüpft:
Ms
Start
Mt
Abb.: NEA für Oder-Verknüpfung s + t bzw. s | t
Start
Ms
Mt
Abb.: NEA für Verketttung st
168
Algorithmen und Datenstrukturen
ε
ε
ε
Start
Ms
ε
Abb.: NEA für Hüllenbildung s*
ε
ε
Start
0
ε
A
1
B
2
A
3
4
5
ε
Abb.: Aus A*BA konstruierter NEA
Die Repräsentation der Zustände und Übergänge erfolgt durch eine
Tabellendarstellung, wobei für jeden Zustand (state) die mit einem gelesenen Symbol
(symbol) durchzuführenden Übergänge (next) zu den Folgezuständen angegeben
sind. Da es für die speziell nach den vorstehenden Regeln konstruierten Regeln bis
zu 2 Folgezustände geben kann, werden für die Übergänge entsprechend 2
Folgezustände next1 und next2 benötigt.
state
symbol
next1
next2
0
ε
1
3
1
A
2
2
2
3
B
4
4
ε
3
1
4
A
5
5
5
ε
0
0
Abb.: Repräsentation eines NEA, der aus A*BA konstruiert ist
Simulation eines NEA
Alle Zustände, die während des Verarbeitens eines bestimmten Symbols
eingenommen werden können, sind zu sichern und später der Reihe nach
abzuarbeiten. Die Implementierung nutzt die Datenstruktur Deque (Kombination von
Warteschlange und Kellerspeicher). Die Warteschlange wird benötigt, weil erst alle
Zustände des aktuellen Zeichen untersucht werden müssen, bevor mit dem nächsten
Zeichen fortgefahren wird. Der neue Zustand wird am Ende der Warteschlange
eingeordnet. Ein Kellerspeicher ist zur Verarbeitung von Nullzuständen mit ε
Übergängen notwendig, da diese zur sofortigen Untersuchung als erste Elemente
eingeordnet werden sollen.
import java.util.LinkedList;
public class NEA
{
public static final int SCAN = -1;
// Klasse zur Repräsentation der Zustände
static class State
169
Algorithmen und Datenstrukturen
{
public State(char s, int n1, int n2) {
symbol = s; next1 = n1; next2 = n2;
}
char symbol; // zu akzeptierendes Symbol
int next1, next2; // Nachfolgezustaende
}
// "Programm" des NEA
State[] states =
{
new State(' ', 1, 3), new State('A', 2, 2),
new State(' ', 3, 1), new State('B', 4, 4),
new State('A', 5, 5), new State(' ', 0, 0)
};
public NEA() {}
public boolean match(String s)
{ /* Passt die uebergegebene Zeichenkette s zum Muster des
Automaten */
LinkedList deque = new LinkedList();
// Initialisierung
int j = 0, state = states[0].next1;
deque.addLast(new Integer(SCAN));
// Ablauftabelle - Ueberschrift
while (state != 0)
{
if (state == SCAN)
{
j++;
deque.addLast(new Integer(SCAN));
}
else if (states[state].symbol == ' ')
{
// "leeres" Zeichen -> Nullzustand
int n1 = states[state].next1;
int n2 = states[state].next2;
deque.addFirst(new Integer(n1));
if (n1 != n2)
deque.addFirst(new Integer(n2));
}
else if (states[state].symbol == s.charAt(j))
// Zeichen akzeptiert
deque.addLast(new Integer(states[state].next1));
if (deque.isEmpty() || j > s.length())
// kein Endzustand erreicht -> Fehler
return false;
// (!) neuen Zustand einnehmen
state = ((Integer) deque.removeFirst()).intValue();
}
// Endzustand: Eingabe akzeptieren
System.out.println();
return true;
}
public static void main(String[] args)
{
NEA nea = new NEA();
System.out.println("accept = " + nea.match("AABA"));
}
}
170
Algorithmen und Datenstrukturen
Abb.: Ablauf bei der Erkennung von AABA
3.1.2.2.3 Java 1.4 "regex"
Pattern und Matcher sind im Paket java.util.regex. Weder Pattern noch
Matcher besitzen Konstruktoren, Instanzen werden über Methoden der Klasse
Pattern gebildet. Der Matcher umfasst Informationen, wie das pattern anzuwenden
ist und den text, auf den es angewendet wird.
Die Pattern-Klasse
Die Pattern-Klasse repräsentiert einen regulären Ausdruck, der als String spezifiziert
wurde.
Mit der Klassenmethode Pattern.compile(string) wird das Pattern in eine
effiziente interne Repräsentation umgewandelt. So erzeugt Pattern p =
Pattern.compile("[,\\s]+"); ein Pattern für Trennung durch Komma oder
Whitespace
Weitere Methoden:
- static Pattern compile(String regex, int flags): Übersetzt den regulären Ausdruck
in ein Pattern Objekt mit Flags. Als Flags sind erlaubt: CASE_INSENSITIVE, MULTILINE, DOTALL,
UNICODE_CASE und CANON_EQ.
- int flags() liefert die Flags, nach denen geprüft wird.
- String split(Charsequene input): Zerlegt die Zeichenfolge in Teilzeichenketten, wie es das
aktuelle Pattern-Objekt befiehlt.
- String split(Charsequene input, int limit): wie split(CharSequence), nur durch
limit begrenzt viele Zeichenkettem.
Mit split() aus Pattern (oder String) kann eine Trennfolge definiert werden,
die eine Zeichenfolge in Teilzeichenketten zerlegt, ähnlich wie es der
StringTokenizer macht. Der StringTokenizer ist jedoch beschränkt auf
einzelne Zeichen als Trennsymbole, während die Methode split() einen regulären
Ausdruck zur Beschreibung verwendet.
- Matcher matcher(CharSequence input): erzeugt einen Matcher, der gegebenen Input gegen
das Pattern vergleicht, z.B.: Matcher m = p.matcher("onetwothree four fivesix”);
171
Algorithmen und Datenstrukturen
- static boolean matches(String regex, CharSequence input) liefert true, wenn der
reguläre Ausdruck regex auf die Eingabe paßt.
- String pattern() liefert den regulären Ausdruck, den das Pattern repräsentiert.
Im Prinzip besteht das Erkennen eines Musters immer aus dem Aufbau eines
Pattern-Objekts mit regulärem Ausdruck und Prüfung:
Pattern p = Pattern.compile("a*b");
Matcher m = p.matcher("aaaaab");
boolean b = m.matches();
Die drei letzten Zeilen lassen sich zusammenfassen zu:
boolean b = Pattern.matches("a*b","aaaaab");
Die Matcher-Klasse
Die Eingabe an einen Matcher muss dem Interface CharSequence genügen. Im
obigen Beispiel wurde ein String-Objekt übergeben. Beispielsweise implementieren
String, StringBuffer und CharBuffer dieses Interface.
Wichtige Methoden:
– boolean matches(): Vergleicht den gesamten Input gegen das Pattern und liefert true, bei ¨
Übereinstimmung, false sonst.
– boolean lookingAt() gibt true zurück, wenn das Muster auf den Anfang des Textstrings passt,
anderenfalls false.
– boolean find(): Scanned die eingegebene Zeichenfolge und sucht die nächste Teilfolge, die mit
dem Pattern übereinstimmt.
– boolean find(int start): Setzt den Matcher und versucht die nächste Teilzeichenfolge der
Eingabesequenz zu finden, die zum Muster passt. Start ist der spezifizierte Index.
– int start() gibt nach einem erfolgreichen Match den Index des ersten Zeichens zurück, das
gematcht wurde
– int start(group) gibt den Startindex der Teilzeichenfolge zurück, die durch die gegebene
Gruppe während der vorhergehenden Operation eingefangen wurde
– int end() gibt nach einem erfolgreichen Match den Index des letzten Zeichens zurück, das
gematcht wurde. Falls kein Match versucht wurde bzw. kein Match erfolgreich war, werfen start()
und end() eine IllegalException aus. Dies ist eine RunTimeException, die nicht
aufgefangen werden kann.
– int end(int group) gibt das Offset nach dem letzten Zeichen der Teilzeichenfolge zurück, die
von der gegebenen Gruppe während der vorhergehenden Operation eingefangen wurde.
– Matcher appendReplacement(StringBuffer sb,String replacement): implementiert
einen nicht begrenzten "anhängen-und-ersetzen"-Schritt.
– String replaceAll(replacement): ersetzt jede Teilzeichenfolge der Eingabe, die das Pattern
durch das gegebene replacement ersetzt
– replaceFirst(replacement): ersetzt die erste Teilzeichenfolge der Eingabe, die mit dem
Muster übereinstimmt, durch pattern.
Bsp. 112:
import java.util.regex.*;
public class Replacement
{
public static void main(String[] args)
throws Exception
{
// Create a pattern to match cat
Pattern p = Pattern.compile("cat");
112
pr31250
172
Algorithmen und Datenstrukturen
// Create a matcher with an input string
Matcher m = p.matcher("one cat," + " two cats in the yard");
StringBuffer sb = new StringBuffer();
boolean result = m.find();
// Loop through and create a new String
// with the replacements
while(result)
{
m.appendReplacement(sb, "dog");
result = m.find();
}
// Add the last segment of input to
// the new String
m.appendTail(sb);
System.out.println(sb.toString());
}
}
– String group(): liefert die Eingabefolge (String), die von dem vorhergehenden Match bestimmt
wurde.
– String group(int group): liefert die Eingabefolge (String) zurück, die von der angebenen
Gruppe während der vorhergehenden Operation eingefangen wurde.
173
Algorithmen und Datenstrukturen
3.2 Entwurfstechniken
Algorithmen-Mustern)
für
Algorithmen
(Einsatz
von
Wie findet man für ein gegebenes Problem einen Algorithmus bzw. einen guten
Algorithmus? Für dieses Problem gibt es leider keinen allgemeingültigen
Algorithmus.
Manche Algorithmen sind aber von ihrer Grundkonzeption her ähnlich. Man
betrachtet eine Auswahl solcher häufig wiederkehrender Techniken für den
Algorithmenentwurf und versucht folgende Idee zu realisieren: Anpassung von
generischen Algorithmenmustern für bestimmte Problemklassen an eine konkrete
Aufgabe.
3.2.1 Greedy Algorithmen
Ein greedy algorithm ist ein Algorithmus, der sich in jedem Schritt ein lokales
Optimum aussucht, was dann im Endeffekt zum globalen Optimum führt (und damit
zum Erfolg des Algorithmus).
Ein einführendes Beispiel. Auf Geldbeträge unter 1 DM soll Wechselgeld
herausgegeben werden. Zur Verfügung stehen ausreichend Münzen mit den Werten
50, 10, 5, 2, 1 Pfennig. Das Wechselgeld soll aus so wenig Münzen wie möglich
bestehen.
Also: 50 + 2 * 10 + 5 + 2 + 1. Der Greedy-Algorithmus bestimmt: Nimm
jeweils die größte Münze unter Zielwert, und ziehe sie von diesem ab. Verfahre
derart bis Zielwert gleich Null.
Greedy-Algorithmen berechnen lokales Optimum, z.B.:
Münzen 11, 5 und 1; Zielwert 15.
Greedy: 11 + 1 + 1 + 1 + 1
Optimum: 5 + 5 + 5
Aber in vielen Fällen entsprechen lokale Optima den globalen bzw. reicht ein lokales
Optimum aus.
Gierige Algorithmen. Das (Optimierungs-) Problem wird in einzelne Schritte zerlegt.
In jedem Schritt wird "gierig" die kurzfristig (lokal) optimale Lösung gewählt. Aus
diesen Einzelschritten ergibt sich die Gesamtlösung 113.
Bsp. für gierige Algorithmen: Algorithmus von Dijkstra 114, Minimaler spannender
Baum (Algorithmus von Prim) 115.
Eigenschaften von Greedy-Algorithmen:
1. Gegebene Menge Werte von Eingabewerten
2. Menge von Lösungen, die aus Eingabewerten aufgebaut sind
113
Nicht jedes Problem lässt sich mit einem gierigen Algorithmus lösen. Lösungen, bei denen zunächst ein
(kleiner) Nachteil in Kauf genommen werden muß, um später einen (größeren) Vorteil zu erlangen, findet man
nicht mit einem gierigen Algorithmus
114 In jedem Schritt wird gierig aus den noch nicht besuchten Knoten jener augewählt, der die geringste
Entfernung zum Startknoten besitzt.
115 In jedem Schritt wird gierig die kürzeste Kante vom Baum zu einem Knoten gewählt, der sich noch nicht im
Baum befindet.
174
Algorithmen und Datenstrukturen
3. Lösungen lassen sich schrittweise aus partiellen Lösungen, beginnend bei der leeren Lösung,
durch Hinzunahme von Eingabewerten aufbauen
4. Bewertungsfunktion für partielle und vollständige Lösungen
5. Gesucht wird die / eine optimale Lösung
3.2.1.1 Greedy-Algorithmen für minimale Spannbäume
Gegeben: Ungerichteter, zusammenhängender kantenbewerteter Graph G = (V , E )
Gesucht minimaler Spannbaum (minimum spanning tree) des ungerichteten Graphen
mit
- T ⊂ E (der minimale Spannbaum ist Teilgraph von G
- (V , T ) ist ungerichteter Baum (zusammenhängend, kreisfrei)
- Summe der Kantenlängen von
(V , T ) hat den kleinstmöglichen Wert.
Demonstrationsgraph:
1
2
1
4
2
5
3
4
4
5
6
5
6
3
8
7
3
4
7
1. Algorithmus von Prim
Zugrundeliegende (algorithmische) Idee:
Minimaler Spannbaum wird beginnend mit willkürlich gewählter Wurzel zusammenhängend
aufgebaut.
Prim-Algorithmus in Struktogrammdarstellung
175
Algorithmen und Datenstrukturen
Eingabe Knoten, Kanten, Gewichte
Initialisiere Menge B mit einem beliebig gewählten Element von V
(B: Wurzelknoten des aufzubauenden minimalen Spannbaums)
T=0
(u,v): kürzeste noch verfügbare Kante mit u ∈ V\B und v ∈ B (greedy!)
T = T ∪ {(u,v)}
B = B ∪ {u}
Lösche (u,v)
B == V
Ausgabe: T
Abarbeitungsprotokoll des Prim-Algorithmus:
Greedy-Schritt
0
1
2
3
4
5
6
V\B
{2,3,4,5,6,7}
{3,4,5,6,7}
{4,5,6,7}
{5,6,7}
{6,7}
{6}
0
B 116
{1}
{1,2}
{1,2,3}
{1,2,3,4}
{1,2,3,4,5}
{1,2,3,4,5,7}
{1,2,3,4,5,6,7}
(u,v)
(2,1)
(3,2)
(4,1)
(5,4)
(7,4)
(6,7)
T
0
{(2,1)}
{(2,1),(3,2)}
{(2,1),(3,2),(4,1)}
{(2,1),(3,2),(4,1),(5,4)}
{(2,1),(3,2),(4,1)},(5,4),(7,4)}
{(2,1),(3,2),(4,1),(5,4),(7,4),(6,7)
}
2. Algorithmus von Kruskal
Zugrundeliegende (algorithmische) Idee:
-
-
116
Alle ungerichteten Kanten des gegebenen Graphen werden nach aufsteigender Reihenfolge
geordnet.
Der minimale Spannbaum wird sequentiell aus Teilbäumen aufgebaut.
Begonnen wird mit Startbäumen, die jeweils aus einem Knoten bestehen.
Zwei Startbäume, die mit dem Endknoten einer kürzesten Kante (greedy!) übereinstimmen,
werden mit den Kanten zu einem Teilbaum (minmaler Länge) über zwei Knoten verbunden.
In den nachfolgenden Verbindungsschritten werden jeweils zwei der vorhandenen Teilbäume
durch eine noch nicht eingebaute kürzeste Kante (greedy) zu einem Teilbaum (minimaler
Länge) mit größerer Knotenanzahl verbunden, wenn der Endknoten dieser Kante in
verschiedenen Teilbäumen liegen. Ist das nicht der Fall, scheidet diese Kante endgültig aus
dem Verfahren aus (Kreisfreiheit!).
Die Vorgehensweise wird erschöpfend angewandt.
Das Verfahren hält an, wenn so alle Knoten zu einem Baum verbunden sind. Dieser ist dann
ein minimaler Spannbaum des gegebenen Graphen.
Menge der besuchten Knoten
176
Algorithmen und Datenstrukturen
Kruskal-Algorithmus in Struktogrammdarstellung
Eingabe: Knoten und Kanten einschl. Gewichte
Ordne alle Kanten nach aufsteigender Länge
Initialisiere n Mengen (Knotenmenge er Teilbäume) mit je einem Element von V.
T=0
(u,v): kürzeste noch verfügbare Kante
ucomp: Menge, die u enthält
vcomp: Menge, die v enthält
nein
vcomp == ucomp
ja
mische(ucomp,vcomp)
{ vereinige die disjunkten Mengen ucomp und vcomp
lösche danach ucomp und vcomp }
T = T {(u,v)}
Lösche (u,v)
card(T) = n 117 - 1
Ausgabe: T
Abarbeitungsprotokoll des Kruskal-Algorithmus 118:
Greedy- Mengen 119
Schritt
0 (Init.) {1},{2},{3},{4},{5},{6},{7
}
1
{1,2},{3},{4},{5},{6},{7}
2
{1,2,3},{4},{5},{6},{7}
3
{1,2,3},{4},{5},{6},{7}
4
{1,2,3},{4,5},{6,7}
5
{1,2,3,4,5},{6,7}
6
{1,2,3,4,5},{6,7}
7
{1,2,3,4,5,6,7}
(u,v) ucomp
vcomp
T
0
(1.2)
(2,3)
(4,5)
(6,7)
(1,4)
(2,5)
(4,7)
{1}
{1,2}
{4}
{6}
{1,2,3}
{1,2,3,4,5} 120
{1,2,3,4,5}
Implementierung: vgl. Kruskal.java 121
117
Anzahl der Knoten im Graphen
vgl. 5.6.2
119 Knotenmenge der Teilbäume
120 nicht disjunkt
121 pr56200
118
177
{2}
{3}
{5}
{7}
{4,5}
{1,2,3,4,5}
{6,7}
{(1,2)}
{(1,2),(2,3)}
{(1,2),(2,3),(4,5)}
{(1,2),(2,3),(4,5),(6,7)}
{(1,2),(2,3),(4,5),(6,7),(1,4)}
{(1,2),(2,3),(4,5),(6,7),(1,4)}
{(1,2),(2,3),(4,5),(6,7),(1,4),(4,7)
}
Algorithmen und Datenstrukturen
3.2.1.2 Huffman Codes
Definition. Der Huffman-Algorithmus erzeugt einen Binärcode für eine gegebene
Zeichenmenge gemäß der Häufigkeit jedes einzelnen Zeichens in einem Text. Je
öfter ein Zeichen auftritt, desto kürzer ist die ihm entsprechende Bitfolge. In diesem
Sinne liefert der Algorithmus einen optimalen Code.
Bsp.: Codeworte fester Länge bei der Binärcodierung von Zeichen.
In einer Textdatei befinden sich bspw. die Zeichen „a“, „e“, „i“, „s“, „t“ und die Zeichen
„space“ bzw. „newline“. Die folgende Tabelle zeigt, in welcher Häufigkeit diese
Zeichen in der Textdatei vorkommen:
Zeichen (Character)
a
e
i
s
t
space
newline
Häufigkeit
10
15
12
3
4
13
1
Würde man diese Zeichen mit einem Binärcode fester Länge verschlüsseln, dann
könnte dies auf folgende Weise bspw. geschehen:
Zeichen (Character)
a
e
i
s
t
space
newline
Summe
Code
000
001
010
011
100
101
110
Häufigkeit
10
15
12
3
4
13
Anzahl Bits
30
45
36
9
12
39
3
174
Abb. Standard zur Binärcodierung von Zeichen
Bei größeren Dateien führt diese Art der Binärcodierung zur erheblichem Platzbedarf.
Besser wäre eine Codierung, die angepasst an die Häufigkeit von Vorkommen der
Zeichen, für die einzelnen Zeichen Codeworte variabler Länge vorsieht. Ein
besonders häufig vorkommendes Zeichen, erhält ein kurzes Codewort. Ein weniger
häufig vorkommendes Zeichen erhält ein längeres Codewort zugeordnet.
Der Algorithmus von Huffman. Zu Beginn liegen die einzelnen Zeichen mit ihren
Häufigkeiten in der folgenden Form vor:
10
a
15
e
12
i
3
s
4
t
13
sp
1
nl
Die beiden Knoten(Bäume) mit den niedigsten Gewichtswerten (Häufigkeiten)
werden zusammengefasst:
178
Algorithmen und Datenstrukturen
10
a
15
12
e
i
4
13
t
4
sp
T1
s
nl
Abb. Huffman Algorithmus nach dem ersten Mischen
Die beiden Bäume mit dem kleinsten Gewicht wurden zusammengemischt. Das
Gewicht des neuen Baums entspricht der Summe der Gewichte der alten Bäume.
10
a
15
12
e
i
13
8
sp
T2
t
T1
s
nl
Abb. Huffman Algorithmus nach dem zweiten Mischen
15
e
12
i
13
18
sp
T3
a
T2
t
T1
s
nl
Abb. Huffman Algorithmus nach dem dritten Mischen
Es gibt jetzt vier Bäume, aus denen die beiden Bäume mit dem kleinsten Gewicht
gewählt werden.
179
Algorithmen und Datenstrukturen
15
25
e
18
T4
i
T3
sp
a
T2
t
T1
s
nl
Abb. Huffman Algorithmus nach dem vierten Mischen
Im fünften Schritt werden die Bäume mit den Wurzeln „e“ und „T3“ gemischt, da sie
jetzt die kleinsten Gewichte haben.
25
33
T4
i
T5
sp
e
T3
a
T2
t
T1
s
Abb. Huffman Algorithmus nach dem fünften Mischen
180
nl
Algorithmen und Datenstrukturen
58
T6
T4
T5
e
T3
sp
a
T2
t
T1
s
i
nl
Abb. Huffman Algorithmus nach Erreichen des optimalen Baums
Für die Codierung der Zeichen steht der folgende optimale Präfix-Code bereit:
0
0
1
1
0
e
0
i
1
sp
1
a
0
1
t
0
s
1
nl
Abb. Optimaler Präfix-Code
Die Tabelle für die Codierung der Zeichen im vorliegenden Beispiel hat folgende
Gestalt:
181
Algorithmen und Datenstrukturen
Zeichen
A
E
I
S
T
Space
Newline
Summe
Code
001
01
10
00000
0001
11
00001
Häufigkeit
10
15
12
3
4
13
1
Anzahl Bits
30
30
24
15
16
26
5
146
Abb. Optimaler Präfix-Code für das vorliegende Beispiel
Die Codierung benutzt nur 146 Bits.
Der Huffman-Algorithmus ist ein Greedy-Algorithmus. Auf jeder Ebene wird ein
Mischvorgang ausgeführt ohne Rücksicht auf globale Betrachtungen. Gemischt
werden lokal die Bäume mit den kleinsten Gewichtswerten. Das Gewicht eines
Baums ist gleich der Summe der Häufigkeiten von seinen Blättern bzw. Teilbäumen.
Damit kann der Algorithmus folgendermaßen formuliert werden:
- Wähle die beiden Bäume T1 und T2 mit dem kleinsten Gewicht aus und bilde daraus einen neuen
Baum mit den Teilbäumen T1 und T2.
- Zu Beginn gibt es nur Bäume, die aus einzelnen Knoten bestehen. Jeder Knoten besteht steht für
ein einzelnes Zeichen.
- Am Ende gibt es nur einen einzigen Baum und das ist der optimale Baum mit dem Huffman-Code.
Aufwand. Werden die Bäume in einer Priority-Queue verwaltet, dann beträgt die
Laufzeit O(C 122logC). Wird die Priority-Queue einfach in einer verketteten Liste
implementiert, dann beträgt die Laufzeit O(C2)
Zu dieser Aufwandsgröße muß noch der Aufwand zur Ermittlung der Häufigkeit der
Zeichen addiert werden.
Java-Implementierung. Der Huffman-Algorithmus kann zur Verdichtung bzw.
Kompression von zu speichernden Daten verwendet werden. Für diese Aufgaben
existieren in Java die Klassen
- Deflater: zur Kompression eines Datenstroms nach einem wählbaren Verfahren
- Inflater: zur Dekompression eines Datenstroms der zuvor mit Deflater komprimiert wurde unter
Verwendung desselben Ver-/Entschlüsselungsverfahrens.
Die Deflater-Klasse hat folgende Methoden:
deflate()
end()
finalize()
finish()
finished()
needsInput()
reset()
getAdler()
getTotalIn()
122
liefert den komprimierten Datenstrom zurück
gibt alle intern benötigten Ressourcen frei
schließt die Deflater-Zustände ab und wird durch
die JVM aufgerufen
zeigt das Ende dernoch nicht komprimierten
daten an
bestimmt, ob noch nicht komprimierte Daten
verfügbar sind
prüft, ob der Eingabepuffer leer ist
stellt den Deflater wieder auf den Anfangszustand
gibt die Checksumme der nicht komprimierten
Daten an
gibt die Anzahl (noch) nicht komprimierter Daten
in Bytes an
C ist die Anzahl der Zeichen.
182
Algorithmen und Datenstrukturen
getTotalOut()
setInput()
setLevel()
setStrategy()
setDictionary
gibt die Anzahl komprimierter Daten in Bytes an
stellt die zu komprimierenden Daten bereit
legt das Komprimierungsniveau fest
setzt die Komprimierungsstrategie fest
definiert ein Dictionary-feld zur Unterstütung der
Kompression
Darüber hinau besitzt die Deflater-Klasse "Field-Größen" zur Festlegung der
Komprimierungen:
BEST_COMPRESSION,
BEST_SPEED,
FILTERED, HUFFMAN_ONLY
DEFAULT_COMPRESSION,
NO_COMPRESSION,
Bsp. 123: Kompremierung einer Datei mit Anzeige des Komprimierungsergebnisses
(Byteanzahl, Checksumme)
123
vgl.pr33120
183
Algorithmen und Datenstrukturen
3.2.2 Divide and Conquer
Typisches Beispiel. Quicksort
Prinzip. Rekursive Rückführung auf identisches Problem mit kleiner Eingabemenge.
Divide-and-Conquer-Algorithmen arbeiten grundsätzlich so:
Teile das gegebene Problem in mehrere getrennte Teilprobleme auf, löse diese einzeln und setze die
Lösungen des ursprünglichen Problems aus den Teillösungen zusammen.
Wende dieselbe Technik auf jedes der Teilprobleme an, dann auf deren Teilprobleme usw. bis die
Teilprobleme klein genug sind, dass man eine Lösung explizit angeben kann.
Trachte danach, dass jedes Teilproblem derselben Art ist wie das ursprüngliche Problem, so dass es
mit demselben Algorithmus gelöst werden kann.
Weitere Beispiele für Divide and Conquer.
1. Merge-Sort
2. Türme von Hanoi
3.2.3 Induktiver Algorithmenentwurf und Dynamisches Programmieren
Induktiver Algorithmenentwurf
-
Für kleinere Problemgrößen erfolgt eine direkte Problemlösung
Zur Lösung eines Problem der Größe n, geht man davon aus: Die Lösung des Problems der
Größe n – 1 ist bekannt.
Alternative: Zur Lösung eines Problems der Größe n, geht man davon aus, dass Lösungen
von Problemen der Größe 1, 2, 3, ... , n-1 bekannt sind.
Bsp.: Berechnung von Fibonacci-Zahlen (Ineffizienter rekursiver Algorithmus 124).
public static int fibrek(int n)
{
if (n <= 1) return 1;
else return fibrek(n - 1) + fibrek(n -2);
}
Diese so elegant aussehende Lösung ist furchtbar schlecht. Sie benötigt eine
Laufzeit T(N) >= T(N-1) + T(N-2). Da T(N) die gleiche rekurive Beziehung
besitzt wie Fibonacci-Zahlen, wächst T(N) genauso wie Fibonacci-Zahlen wachsen,
d.h. exponentiell. Eine Protokollierung der rekursiven Aufrufe für fibrek(6) zeigt
dies:
124
pr32301
184
Algorithmen und Datenstrukturen
fibrek(6)
fibrek(5)
fibrek(4)
fibrek(4)
fibrek(3)
fibrek(3)
fibrek(2)
fibrek(3)
fibrek(2) fibrek(1) fibrek(2)
fibrek(2)
fibrek(1)
fibrek(1) fibrek(0)
fibrek(2) fibrek(1) fibrek(1) fibrek(0) fibrek(1) fibrek(0) fibrek(1) fibrek(0)
fibrek(1) fibrek(0)
Dynamisches Programmieren
Schrittweise werden alle Problemgrößen von der kleinsten bis zur gesuchten gelöst.
Die Lösungen der kleineren Pobleme werden zwischengespeichert, falls man die
Lösung eines kleineren Problems zur Lösung eines größeren benötigt, wird die
bereits ermittelte Lösung einfach nachgeschlagen.
Bsp.: Man beobachtet bei der Berechnung der Fibonacci-Zahlen:
FN-1 wird nur einmal aufgerufen, aber FN-2 wird zweimal, FN-3 wird dreimal, FN-4 wird viermal, FN-5 wird
fünfmal berechnet, usw.
Zur Berechnung von FN wird aber lediglich die Berechnung von FN-1 und FN-2 benötigt. Man braucht
sich dabei nur auf die aktuell errechneten Werte zu beziehen. Das führt zu dem folgenden
Algorithmus 125 mit dem Aufwand O(N).
public static int fibit(int n)
{
if (n <= 1) return 1;
int letzterWert = 1;
int vorletzterWert = 1;
int resultat = 1;
for (int i = 2;i <= n; i++)
{
resultat = letzterWert + vorletzterWert;
vorletzterWert = letzterWert;
letzterWert
= resultat;
}
return resultat;
}
Generell bietet sich dynamisches Programmieren an, wenn man zur Lösung eines
Problems das Problem in Teilprobleme zerlegt, um die Gesamtlösung aus den
Teillösungen zusammenzusetzen, und die Teilprobleme nicht wie bei divide-andconquer unabhängig sind. Bei der dynamischen Programmierung speichert man
bereits berechnete Lösungen in einer Tabelle ab, um Mehrfachberechnungen zu
vermeiden. Das erhöht den Speicheraufwand.
125
pr32301
185
Algorithmen und Datenstrukturen
3.3 Rekursion
Viele Probleme beschreiben eine rekursive Struktur. Für sie können häufig rekursive
Lösungen elegant formuliert werden:
-
Berechnung rekursiv defilnierter mathematischer Funktionen
Überprüfung der Syntax von Ausdrücken
Operationen auf rekursiv definierten Datenstrukturen
Rekursive Formulierungen sparen explizit Laufvariablen und Kontrollstruikturen.
Rekursive Aufrufe verbrauchen Speicher auf dem Stack, es sei denn, es handelt sich
um Endrekursion, die von modernen Compilern optimiert wird.
3.3.1 Linear rekursive Funktionen
Eine Funktion heißt linear rekursiv, wenn die Ausführung der Funktion zu höchstens
einem rekursiven Aufruf der Funktion führt.
Linear rekursive Funktionen können endrekursiv sein.
Endrekursive Funktionen: Eine rekursive Funktion heißt endrekursiv, wenn sie linear
ist und jede Ausführung der Funktion entweder nicht zu einem rekursiven Aufruf führt
oder das Ergebnis des rekursiven Aufrufs gleich dem Ergebnis der Funktion ist.
Eine endrekursive Funktion heißt schlicht, wenn der rekursive Aufruf direkt (ohne
nachfolgende Operation) den Funktionswert liefert.
Eine rekursive Funktion ist endrekursiv, wenn alle rekursiven Aufrufe schlicht sind.
Bsp.: Die Fakultätsfunktion ist endrekursiv.
Schema zur Entrekusivierung endrekursiver Funktionen.
Prinzipiell gilt: Alle Aufgaben, die mit einer Iteration lösbar sind, sind auch ohne
Schleifen durch Rekursion lösbar. Rekursionen kann man meistens mit Hilfe von
Iterationen simulieren. Oft sind rekursive Lösungen einfacher zu verstehen,
verbrauchen aber für die Abarbeitung mehr Speicherplatz.
endrekuriv
iterativ
einTyp pRekursiv(... x ...)
{
....
if (condition(x))
{
/* S1; */ // Anweisungen
return pRekursiv(f(x));
}
else {
/* S2; */ // Anweisungen
return g(x);
}
}
einTyp pIterativ(… x …)
{
...
while(condition(x))
{
/* S1; */
x = f(x);
}
/* S2; */ // Anweisungen
return g(x);
}
186
Algorithmen und Datenstrukturen
3.3.2 Nichtlineare rekursive Funktionen
Eine rekursive Funktion heißt nichtlinear rekursiv, wenn die Ausführung der Funktion
zu mehr als einem rekursiven Aufruf führt.
Bsp.: Die Fibonacci-Funktion 126 ist nichtlinear rekursiv.
3.3.3 Primitive Rekursion
Eine rekursive Funktion ist primitiv rekursiv, wenn der rekursive Aufruf nicht
geschachtelt ist
3.3.4 Nicht primitive Rekursion
Eine rekursive Funktion ist nicht primitiv rekursiv, wenn der rekursive Aufruf
geschachtelt ist.
Bsp.: Die Ackermann-Funktion
Die Rekursion eignet sich für die Definition von Funktionen, die sehr schnell wachsen. So ist die
Ackermann-Funktion 127 ein Beispiel für eine Rekursion, die nicht (oder zumindest nicht direkt) durch
Iteration ersetzt werden kann.
Die Werte der Ackermann-Funktion (mit zwei Argumenten n und m) sind durch die folgenden Formeln
definiert:
a m0 = m + 1
a 0n = a1n −1
a mn = a ann−1
m −1
Die Ackermann-Funktion wächst stärker als jede primitiv rekursive Funktion.
/*
* Acker.java
*/
public class Acker
{
static int a(int x, int
{
if (x == 0) return y +
if (y == 0) return a(x
return a(x - 1,a(x,y }
y)
1;
- 1,1);
1));
public static void main(String args[])
{
int x = Integer.parseInt(args[0]);
int y = Integer.parseInt(args[1]);
System.out.println(a(x,y));
}
}
126
127
vgl. 1.2.1.5.1 bzw. 3.2.3
Benannt nach dem Mathematiker F.W. Ackermann, 1896 - 1962
187
Algorithmen und Datenstrukturen
3.3.5 Rekursive Kurven
Das Wesen der Rekursion – insbesondere der Selbstähnlichkeit – kann mit Hilfe von
Monsterkurven demonstriert werden. Sie sind eindimensionale geometrische Gebilde
unendlicher Länge, die eine Fläche abdecken. Monsterkurven werden nach einem
regelmäßigen Muster schrittweise verfeinert. Sie errinnern an Formen, die mit Hilfe
von Fraktalen erstellt werden können. Die Rekursion ist geeignet, die unendlich lange
Monsterkurve mit endlich langen Kurven anzunähern. Solche Annäherungen einer
Monsterkurve kann man als rekursive Kurve bezeichnen.
Anweisungen für eine Monsterkurve können folgendermaßen formuliert werden:
1) Man nehme einen Initiator. Das kann eine einzelne Strecke sein, kann aber auch aus mehreren
Liniensegmenten bestehen.
2) Man nehme einen Generator, der den Wachstumsprozeß des Monsters festlegt. Der Generator
besteht aus mehreren Teilstrecken sowie einem Start- und einem Endpunkt.
Im ersten Konstruktionsschritt (1. Rekursionsstufe) werden die Initiator-Segmente
durch den Generator ersetzt. Der Generator wird dabei so gedreht und skaliert, daß
seine Begrenzungspunkte mit der Initiator-Strecke zusammenfallen.
Gibt es mehrere Initiator-Strecken, dann entstehen auch mehrere Generatoren. In
der zweiten Rekursionsstufe wird jedes einzelne Segment wiederum durch den
Generator ersetzt. Diesen Prozeß wiederhölt man so oft, bis das Fraktal (die
Monsterkurve) die gewünschte Einheit zeigt.
Zum Zeichnen von rekursiven Kurven wird die Technik der Turtle-Grafik 128
herangezogen. Die Turtle (Grafik-Cursor) bewegt sich folgendermaßen über die
Ebene:
a) geradeaus in einem Schritt.
Nach dem Arbeitsgang werden die neu berechneten Kordinaten der Turtle bereitgestellt.
b) Wende über eine Drehung um einen bestimmten Winkel. Der Drehwinkel wird als Parameter im
Aufruf übergeben.
Beispiele.
1. Die Schneeflockenkurve (Kochsche Kurve)
Ausgangspunkt ist eine Gerade (Initiator).
128
Am MIT wurde die Turtle-Grafik erfunden
188
Algorithmen und Datenstrukturen
Abb.: Schneeflockenkurve auf der Stufe 0
Diese Strecke wird in drei gleiche Teile unterteilt, und das Mittelstück durch 2 Seiten
eines gleichseitigen Dreiecks ersetzt.
Abb.: Schneeflockenkurve auf der Stufe 1
Im nächsten Schritt wird jede der vier Strecken des Generators, allerdings auf ein
Drittel seiner Größe reduziert, ersetzt.
189
Algorithmen und Datenstrukturen
Abb.: Schneeflockenkurve auf der Stufe 2
Die Prozedur für den Initiator umfaßt hie nur das Zeichnen der geraden Strecke.
Entscheidend ist der Generator. Hier wandert der Grafik-Cursor (die Turtle) zunächst
geradeaus (ein Drittel der vorhergehenden Stufe), wendet sich dann um 60 Grad
nach links, dann 120 Grad nach rechts, usw. (vgl. Abb.). Das wird auf den weiteren
Stufen fortgesetzt und führt zu einem allgemeinen Bildungsgesetz einer rekursiven
Prozedur mit zwei Parametern im rekursiven Aufruf: „stufe – 1“, „laenge/3“.
public void schneeflocke(int stufe,int laenge)
{
double xAlt, yAlt;
if (stufe == 0)
{
xAlt = turtle.holeTurtleX(); // System.out.println(xAlt);
yAlt = turtle.holeTurtleY(); // System.out.println(yAlt);
turtle.setzeTurtleR(laenge);
turtle.schritt();
grafKontext.drawLine((int) xAlt, (int) yAlt,
(int) turtle.holeTurtleX(),
(int) turtle.holeTurtleY());
}
else {
schneeflocke(stufe-1,laenge/3);
turtle.wende(-60);
schneeflocke(stufe-1,laenge/3);
turtle.wende(120);
schneeflocke(stufe-1,laenge/3);
turtle.wende(-60);
schneeflocke(stufe-1,laenge/3);
}
}
2. Die Drachenkurve
Faltet man einen Papierstreifen doppelt in der Mitte und öffnet ihn dann so, daß sich
rechte Winkel bilden, dann erhält man – von der Seite gesehen – die zweite Stufe der
Drachenkurve.
190
Algorithmen und Datenstrukturen
Abb.: Stufe 2 der Drachenkurve
Der Initiator ist wieder die Einheitsstrecke, der Generator besteht aus zwei rechten
Winkeln.
Abb.: Stufe 3 der Drachenkurve
Es ist zwischen Links- und Rechtsorientierung zu unterscheiden (Parameter
Orientierung). Im weiteren Verlauf (Stufe 15) führt das zu folgendem Bild:
191
Algorithmen und Datenstrukturen
Abb.: Stufe 15 der Drachenkurve
Die folgende rekursive Prozedur 129 ermöglicht das Zeichnen der vorliegenden
Drachenkurven:
public void drache(int stufe,double laenge,boolean richtung)
{
double wurzelZwei = Math.sqrt(2);
double xAlt, yAlt;
if (stufe == 0)
{
xAlt = turtle.holeTurtleX(); // System.out.println(xAlt);
yAlt = turtle.holeTurtleY(); // System.out.println(yAlt);
turtle.setzeTurtleR(laenge);
turtle.schritt();
grafKontext.drawLine((int) xAlt, (int) yAlt,
(int) turtle.holeTurtleX(),
(int) turtle.holeTurtleY());
}
else {
final int gradLinks = 45 * (richtung ? 1 : -1);
final int gradRechts = 90 * (richtung ? -1 : 1);
turtle.wende(gradLinks);
drache(stufe-1,laenge/wurzelZwei,true);
turtle.wende(gradRechts);
drache(stufe-1,laenge/wurzelZwei,false);
turtle.wende(gradLinks);
}
}
129
pr33201
192
Algorithmen und Datenstrukturen
3.4 Backtracking-Algorithmen
Bei manchen Problemen kann nicht mit Sicherheit bestimmt werden, welcher der
möglichen nächsten Schritte zum (optimalen) Ziel führt. Prinzipiell muß man alle
möglichen Schritte der Reihe nach ausprobieren, um festzustellen, ob sie zu einer
Lösung (zur optimalen Lösung) führen. Falls erkannt wird, dass ein Schritt nicht
zielführend ist, wird er rückgängig gemacht.
Backtracking ist eine systematische Art der Suche (Tiefensuche) in einem
vorgebenen Suchraum. Wenn eine Teillösung in eine Sackgasse führt, dann wird
jeweils der letzte Schritt rückgängig gemacht. Das Rückgängigmachen eines Schritts
nennt man Back-tracking, daher der Name Backtracking.
Allgemeiner Backtracking Algorithmus
boolean findeLoesung(int index, Lsg loesung, ...)
{
// index ist die aktuelle Schrittzahl
// Teilloesungen werden als Referenz uebergeben
1. Solange es noch neue Teil-Loesungsschritte gibt:
a) Waehle einen neuen Teil-Lösungsschritt
b) Falls schritt gueltig ist
I) Erweitere loesung um schritt
II) Falls loesung vollstaendig ist, return true, sonst:
if (findeLoesung(index+1,loesung)
{
return true; // Loesung gefunden
}
else
{
// Sackgasse
Mache schritt rueckgaengig; // Backtracking
}
2. Gibt es keinen neuen Teil-Loesungsschritt mehr, so: return false;
}
In Java-Pseudocode lässt sich der allgemeine Backtracking Algorithmus so
formulieren:
boolean findeLoesung(int index, Lsg loesung, ...)
{
// index ... Schrittzahl
// loesung . Referenz auf Teilloesung
while (es_gibt_noch_neue_Teil-Loesungsschritte)
{
waehle_einen_neuen_Teilloesungsschritt;
if (schritt_ist_gueltig)
{
erweitere_loesung_um_schritt
if (loesung_noch_nicht_vollstaendig)
{
// rekursiver Aufruf von findeLoesung
if (findeLoesung(index + 1,loesung,...)
return true; // Loesung gefunden
else { // Sackgasse
mache_Schritt_rueckgaengig; // Backtracking
}
} else return true; // Loesung gefunden -> fertig
}
}
return false;
} // falls true Rueckgabewert steht die Loesung in loesung
193
Algorithmen und Datenstrukturen
Backtracking ist Tiefensuche, z.B. in einem Labyrinth. Der Lösungsprozeß wird in
einzelne Schritte aufgeteilt. In jedem Schritt öffnet sich eine endliche Zahl von
Alternativen. Einige führen in eine Sackgasse, andere werden nach demselben
Verfahren überprüft. Ergebnisse können Erfolg und Misserfolg zeigen (Trial and
Error). Die Lösungsstrategie führt zwar theoretisch zum Ziel, allerdings kann die
Anzahl der zu untersuchenden Alternativen derart groß werden, dass die Lösungen
nicht meht in zumutbarer Zeit ermittelt werden können.
Die folgende Darstellung zeigt ein Labyrinth. In diesem Labyrinth soll ein Weg vom
Start- zum Zielpunkt gefunden werden. Die Lösung dieses Problems ist eine
Anwendung der vorstehenden Prozedur in Java-Pseudocode
boolean findeLoesung(int index,Lsg loesung, int aktX, int aktY)
mit [aktX][aktY] zur Angabe der aktuellen Feldposition. „findeLoesung() 130“ findet
in einem Labyrinth mit K x L Feldern eine Weg vom Start zum Ziel und benutzt
dabei die folgende Lösungsstrategie:
- Systematische Suche vom aktuellen Feld im Labyrinth 1. oben, 2. rechts, 3. unten, 4. links
- Markierung besuchter Felder
- Zurücknahme der Züge in Sackgassen (Backtracking)
Das Resultat soll in einem zweidimensionalen Array loesung.feld[][] stehen.
Den Feldwert loesung.feld[x][y] an der Position [x][y] definiert man als
-1, wenn das Feld als Sackgasse erkannt wurde.
0, wenn das Feld besucht wurde
> 0, wenn das Feld zum Lösungsweg gehört.
130
vgl. pr32410, Labyrinth
194
Algorithmen und Datenstrukturen
Abb.
Die Feldwerte des Lösungsweges geben die Besuchsreihenfolge wieder.
195
Algorithmen und Datenstrukturen
[6][2]
[2][5]
[6][3]
[5][1]
[5][3]
[6][4]
[6][1]
[5][4]
[6][5]
[6][0]
[5][5]
[6][6]
[5][0]
[5][6]
[4][0]
[4][6]
[4][1]
[3][6]
[2][6]
[1][6]
[0][6]
[1][5]
[0][5]
[2][5]
[0][4]
[2][4]
[0][3]
[1][4]
[0][2]
[1][3]
[0][1]
[1][2]
[2][2]
[2][1]
[1][1]
[0][1]
[0][0]
Abb. Wege im Labyrinth (Durchlauf durch das Labyrinth)
Laufzeit. Bei der Tiefensuche werden bei
-max k möglichen Verzweigungen von jeder Teillösung aus und
- einem Lösungsbaum mit maximaler Tiefe von n
im schlechtesten Fall O(kn) Knoten im Lösungsbaum erwartet.
Tiefensuche und somit auch Backtracking haben im schlechtesten Fall mit O(kn) eine
exponentielle Laufzeit. Aus der Komplexitätstheorie ist bekannt: Algorithmen mit nicht
polynomialer Laufzeit sind zu langsam. Bei Problemen mit großer Suchtiefe wird
Backtracking deshalb zu lange brauchen.
196
Algorithmen und Datenstrukturen
Backtracking
- ist eine systematische Suchstrategie und findet deshalb immer eine optimale Lösung, sofern
vorhanden, und sucht höchstens einmal in der gleichen Sackgasse.
- ist einfach zu implementieren mit Rekursion.
- macht Tiefensuche im Lösungsraum.
- hat im schlechtesten Fall eine exponentielle Laufzeit O(kn) und ist deswegen primär nur für kleine
Probleme geeignet.
- erlaubt Wissen über ein Problem in Form einer Heuristik 131 zu nutzen, um den Suchraum
einzuschränken und die Suche dadurch zu beschleunigen.
Typische Einsatzfelder des Backtracking:
- Spielprogramme (Schach, Dame)
- Erfüllbarkeit von logischen Aussagen (logische Programmierspachen)
- Planungsprobleme, Konfigurationen.
3.5 Zufallsgesteuerte Algorithmen
Zufallsgesteuerte Algorithmen verwenden zufällige Daten, um das Laufzeitverhalten
zu verringern. Zufallsgesteuerte Algorithmen sind nicht deterministisch.
131 Damit nicht alle Lösungswege ausprobiert werden müssen, werden Heuristiken verwendet. Das sind
Strategien, die mit hoher Wahrscheinlichkeit (jedoch ohne Garantie) das auffinden einer Lösung beschleunigen
sollen.
197
Algorithmen und Datenstrukturen
4. Bäume
4.1 Grundlagen
4.1.1 Grundbegriffe und Definitionen
Bäume sind eine Struktur zur Speicherung von (meist ganzahligen) Schlüsseln. Die
Schlüssel werden so gespeichert, daß sie sich in einem einfachen und effizienten
Verfahren wiederfinden lassen. Neben dem Suchen sind üblicherweise das Einfügen
eines neuen Knoten (mit gegebenem Schlüssel), das Entfernen eines Knoten (mit
gegebenem Schlüssel), das Durchlaufen aller Knoten eines Baums in bestimmter
Reihenfolge erklärte Operationen. Weitere wichtige Verfahren sind:
- Das Konstruieren eines Baums mit bestimmten Eigenschaften
- Das Aufspalten eines Baums in mehrere Teilbäume
- Das Zusammenfügen mehrere Bäume zu einem Baum
Definitionen
Eine Datenstruktur heißt "t-ärer" Baum, wenn zu jedem Element höchstens t
Nachfolger (t = 2,3,.... ) festgelegt sind. "t" bestimmt die Ordnung des Baumes (z.B. "t
= 2": Binärbaum, "t = 3": Ternärbaum).
Die Elemente eines Baumes sind die Knoten (K), die Verbindungen zwischen den
Knoten sind die Kanten (R). Sie geben die Beziehung (Relation) zwischen den
Knotenelementen an.
Eine Datenstruktur D = (K,R) ist ein Baum, wenn R aus einer Beziehung besteht, die
die folgenden 3 Bedingungen erfült:
1. Es gibt genau einen Ausgangsknoten (, das ist die Wurzel des Baums).
2. Jeder Knoten (mit Ausnahme der Wurzel) hat genau einen Vorgänger
3. Der Weg von der Wurzel zu jedem Knoten ist ein Pfad, d.h.: Für jeden von der Wurzel
verschiedenen Knoten gibt es genau eine Folge von Knoten k1, k2, ... , kn (n >= 2), bei der ki der
Nachfolger von
ki-1 ist. Die größte vorkommende Pfadlänge ist die Höhe eines Baums.
Knoten, die keinen Nachfolger haben, sind die Blätter.
Knoten mit weniger als t Nachfolger sind die Randknoten. Blätter gehören deshalb
mit zum Rand.
Haben alle Blattknoten eines vollständigen Baums die gleiche Pfadlänge, so heißt
der Baum voll.
Quasivoller Baum: Die Pfadlängen der Randknoten unterscheiden sich höchstens
um 1. Bei ihm ist nur die unterste Schicht nicht voll besetzt.
Linksvoller Baum: Blätter auf der untersten Schicht sind linksbündig dicht.
Geordneter Baum: Ein Baum der Ordnung t ist geordnet, wenn für jeden Nachfolger
k' von k festgelegt ist, ob k' der 1., 2., ... , t. Nachfolger von k ist. Dabei handelt es
sich um eine Teilordnung, die jeweils die Söhne eines Knoten vollständig ordnet.
Speicherung von Bäumen
Im allg. wird die der Baumstruktur zugrundeliegende Relation gekettet gespeichert,
d.h.: Jeder Knoten zeigt über einen sog. Relationenteil (mit t Komponenten) auf seine
198
Algorithmen und Datenstrukturen
Nachfolger. Die Verwendung eines Anfangszeigers (, der auf die Wurzel des Baums
zeigt,) ist zweckmäßig.
4.1.2 Darstellung von Bäumen
In der Regel erfolgt eine grafische Darstellung: Ein Nachfolgerknoten k' von k wird
unterhalb des Knoten k gezeichnet. Bei der Verbindung der Knotenelemente reicht
deshalb eine ungerichte Linie (Kante).
Abb. 4.1-1:
Bei geordneten Bäumen werden die Nachfolger eines Knoten k in der Reihenfolge "1.
Nachfolger", "2. Nachfolger", .... von links nach rechts angeordnet.
Ein Baum ist demnach ein gerichteter Graf mit der speziellen Eigenschaft: Jeder
Knoten (Sohnknoten) hat bis auf einen (Wurzelknoten) genau einen Vorgänger
(Vaterknoten).
Ebene 1
linker Teilbaum
von k
Ebene 2
k
Ebene 3
Weg, Pfad
Ebene 4
Randknoten oder Blätter
Abb. 4.1-2:
199
Algorithmen und Datenstrukturen
4.1.3 Berechnungsgrundlagen
Die Zahl der Knoten in einem Baum ist N. Ein voller Baum der Höhe h enthält:
h
(1) N = ∑ t i −1 =
i =1
th −1
t −1
Bspw. enthält ein Binärbaum N = 2 h − 1 Knoten. Das ergibt für den Binärbaum der
Höhe 3 sieben Knoten.
Unter der Pfadsumme Z versteht man die Anzahl der Knoten, die auf den
unterschiedlichen Pfaden im vollen t-ären Baum auftreten können:
h
(2) Z = ∑ i ⋅ t i −1
i =1
Die Summe kann durch folgende Formel ersetzt werden:
h⋅ th
th −1
Z=
−
t − 1 ( t − 1) 2
t und h können aus (1) bestimmt werden:
t h = N ⋅ ( t − 1) + 1
h = log t ( N ⋅ ( t − 1) + 1)
Mit Gleichung (1) ergibt sich somit für Z
Z=
log t ( N ⋅ ( t − 1) + 1) ⋅ ( N ⋅ ( t − 1) + 1) N ⋅ ( t − 1) ( N ⋅ ( t − 1) + 1) ⋅ log t ( N ⋅ ( t − 1) + 1) − N
−
=
t −1
t −1
( t − 1) 2
Für t = 2 ergibt sich damit:
Z = h ⋅ 2 h − (2 h − 1) bzw. Z = ( N + 1) ⋅ ld ( N + 1) − N
Die mittlere Pfadlänge ist dann:
(3)
Z mit =
Z ( N ⋅ ( t − 1) + 1) ⋅ log t ( N ⋅ ( t − 1) + 1) − N ( N ⋅ ( t − 1) + 1) ⋅ log t ( N ⋅ ( t − 1) + 1)
1
−
=
=
N
N ⋅ ( t − 1)
N ⋅ ( t − 1)
t −1
Für t = 2 ist
200
Algorithmen und Datenstrukturen
(4) Z mit =
Z N +1
=
⋅ ld ( N + 1) − 1
N
N
Die Formeln unter (3) und (4) ergeben den mittleren Suchaufwand bei
gleichhäufigem Aufsuchen der Elemente.
Ist dies nicht der Fall, so ordnet man den Elementen die relativen Gewichte gi (i = 1,
2, 3, ... , N) bzw. die Aufsuchwahrscheinlichkeiten zu:
N
gi
, G = ∑ gi
pi =
G
i =1
N
Man kann eine gewichtete Pfadsumme
Zg = ∑ g i ⋅ hi
bzw. einen mitlleren
i =1
Suchaufwand ( Z g ) mit =
Zg
G
N
= ∑ p i ⋅ h i berechnen.
i =1
4.1.4 Klassifizierung von Bäumen
Wegen der großen Bedeutung, die binäre Bäume besitzen, ist es zweckmäßig in
Binär- und t-äre Bäume zu unterteilen. Bäume werden oft verwendet, um eine Menge
von Daten festzulegen, deren Elemente nach einem Schlüssel wiederzufinden sind
(Suchbäume). Die Art, nach der beim Suchen in den Baumknoten eine Auswahl
unter den Nachfolgern getroffen wird, ergibt ein weiteres Unterscheidungsmerkmal
für Bäume.
Intervallbäume
In den Knoten eines Baumes befinden sich Daten, mit denen immer feinere
Teilintervalle ausgesondert werden.
Bsp.: Binärer Suchbaum
Die Schlüssel sind nach folgendem System angeordnet. Neu ankommende Elemente
werden nach der Regel "falls kleiner" nach links bzw. "falls größer" nach rechts
abgelegt.
201
Algorithmen und Datenstrukturen
40
30
50
20
11
39
37
24
60
44
40
41
45
62
65
Es kann dann leicht festgestellt werden, in welchem Teilbereich ein Schlüsselwort
vorkommt.
Selektorbäume (Entscheidungsbäume)
Der Suchweg ist hier durch eine Reihe von Eigenschaften bestimmt. Beim
Binärbaum ist das bspw. eine Folge von 2 Entscheidungsmöglichkeiten. Solche
Entscheidungsmöglichkeiten können folgendermaßen codiert sein:
- 0 : Entscheidung für den linken Nachfolger
- 1 : Entscheidung für den rechten Nachfolger
Die Folge von Entscheidungen gibt dann ein binäres Codewort an. Dieses Codewort
kann mit einem Schlüssel bzw. mit einem Schlüsselteil übereinstimmen.
Bsp.: "Knotenorientierter binärer Selektorbaum"
Folgende Schlüsselfolge wird zum Erstellen des Baums herangezogen:
1710
3810
6310
1910
3210
2910
4410
2610
5310
=
=
=
=
=
=
=
=
=
0
1
1
0
1
0
1
0
1
1
0
1
1
0
1
0
1
1
0
0
1
0
0
1
1
1
0
0
1
1
0
0
1
1
0
1
0
1
1
1
0
0
0
1
1
12
02
12
12
02
12
02
02
12
Der zugehörige Binärbaum besitzt dann folgende Gestalt:
202
Algorithmen und Datenstrukturen
17
19
0_
38
39
01_
32
1_
63
10_
11_
101_
40 011_
44
53
110_
In den Knoten dient demnach der Wertebereich einer Teileigenschaft zur Auswahl
der nächsten Untergruppe.
Knotenorientierte und blattorientierte Bäume
Zur Unterscheidung von Bäumen kann auf die Aufbewahrungsstelle der Daten
zurückgegriffen werden:
1. Knotenorientierte Bäume
Daten befinden sich hier in allen Baumknoten
2. Blattorientierte Bäume
Daten befinden sich nur in den Blattknoten
Optimierte Bäume
Man unterscheidet statisch und dynamisch optimierte Bäume. In beiden Fällen sollen
entartete Bäume (schiefe Bäume, Äste werden zu linearen Listen) vermieden
werden.
Statische Optimierung bedeutet: Der Baum wird neu (oder wieder neu) aufgebaut.
Optimalität ist auf die Suchoperation bezogen. Es interessiert dabei das Aussehen
des Baums, wenn dieser vor Gebrauch optimiert werden kann.
Bei der dynamischen Optimierung wird der Baum während des Betriebs (bei jedem
Ein- und Ausfügen) optimiert. Ziel ist also hier: Eine günstige Speicherstruktur zu
erhalten. Diese Aufgabe kann im allg. nicht vollständig gelöst werden, eine
Teiloptimierung (lokale Optimierung) reicht häufig aus.
Werden die Operationen "Einfügen", "Löschen" und "Suchen" ohne besondere
Einschränkungen oder Zusätze angewendet, so spricht man von freien Bäumen.
Strukturbäume
Sie dienen zur Darstellung und Speicherung hierarchischer Zusammenhänge.
Bsp.: "Darstellung eines arithmetischen Ausdrucks"
Operationen in einem arithmetischen Ausdruck sind zweiwertig (, die einwertige Operation "Minus"
kann als Vorzeichen dem Operanden direkt zugeordnet werden). Zu jeder Operation gehören
demnach 2 Operanden. Somit bietet sich die Verwendung eines binären Baumes an. Für den
arithmetischen Ausdruck (A + B / C)∗ ( D − E∗ F) ergibt sich dann folgende Baumdarstellung:
203
Algorithmen und Datenstrukturen
*
+
-
A
/
B
D
*
E
C
Abb. 4.1-3: Baumdarstellung des arithmetischen Ausdrucks
F
(A + B / C)∗ ( D − E∗ F)
4.2 Freie Binäre Intervallbäume
4.2.1 Ordnungsrelation und Darstellung
Freie Bäume sind durch folgende Ordnungsrelation bestimmt:
In jedem Knoten eines knotenorientierten, geordneten Binärbaums gilt: Alle Schlüssel im rechten
(linken) Unterbaum sind größer (kleiner) als der Schlüssel im Knoten selbst.
Mit Hilfe dieser Ordnungsrelation erstellte Bäume dienen zum Zugriff auf Datenbestände (Aufsuchen
eines Datenelements). Die Daten sind die Knoten (Datensätze, -segmente, -elemente). Die Kanten
des Zugriffsbaums sind Zeiger auf weitere Datenknoten (Nachfolger).
Dateninformation
Schluessel
Datenteil
Knotenzeiger
LINKS
RECHTS
Zeiger zum linken Sohn
Zeiger zum rechten Sohn
Abb. 4.2-1:
Das Aufsuchen eines Elements im Zugriffsbaum geht vom Wurzelknoten über einen Kantenzug (d.i. eine Reihe
von Zwischenknoten) zum gesuchten Datenelement. Bei jedem Zwischenknoten auf diesem Kantenzug findet
ein Entscheidungsprozeß über die folgenden Vergleiche statt:
1. Die beiden Schlüssel sind gleich: Das Element ist damit gefunden
2. Der gesuchte Schlüssel ist kleiner: Das gesuchte Element kann sich dann nur im linken Unterbaum
befinden
3. Der gesuchte Schlüssel ist größer: Das gesuchte Element kann sich nur im rechten Unterbaum
befinden.
204
Algorithmen und Datenstrukturen
Das Verfahren wird solange wiederholt, bis das gesuchte (Schlüssel-) Element
gefunden ist bzw. feststeht, daß es in dem vorliegenden Datenbestand nicht
vorhanden ist.
Struktur und Wachstum binärer Bäume sind durch die Ordnungsrelation bestimmt:
Aufgabe: Betrachte die 3 Schlüssel 1, 2, 3. Diese 3 Schlüssel können durch verschieden angeordnete
Folgen bei der Eingabe unterschiedliche binäre Bäume erzeugen.
Stellen Sie alle Bäume, die aus unterschiedlichen Eingaben der 3 Schlüssel resultieren, dar!
1, 2, 3
1, 3, 2
2, 1, 3
1
1
2
3
2
3
1
2
2, 3, 1
3, 1, 2
3, 2, 1
2
3
3
1
3
2
1
1
Es gibt also: Sechs unterschiedliche Eingabefolgen und somit 6 unterschiedliche Bäume.
Allgemein können n Elemente zu n! verschiedenen Anordnungen zusammengestellt
werden.
Suchaufwand
N +1
⋅ ld ( N + 1) − 1
N
Zur Bestimmung des Suchaufwands stellt man sich vor, daß ein Baum aus dem
leeren Baum durch sukzessives Einfuegen der N Schlüssel entstanden ist. Daraus
resultieren N! Permutationen der N Schlüssel. Über all diese Baumstrukturen ist der
Mittelwert zu bilden, um schließlich den Suchaufwand festlegen zu können.
Aus den Schlüsselwerten 1, 2, ... , N interessiert zunächst das Element k, das die
Wurzel bildet.
Der mittlere Suchaufwand für einen vollen Baum beträgt Z mit =
205
Algorithmen und Datenstrukturen
k
Hier gibt es:
(k-1)! Unterbäume
Schlüsseln 1..N
Hier gibt es:
(N-k)! Unterbäume mit den
Schlüsseln k+1, ... , N
Der mittlere Suchaufwand im gesamten Baum ist:
ZN =
1
( N + Z k −1 ⋅ ( k − 1) + Z N − k ⋅ ( N − k ))
N
Zk-1: mittlerer Suchaufwand im linken Unterbaum
ZN-k: mittlerer Suchaufwand im rechten Unterbaum
Zusätzlich zu diesen Aufwendungen entsteht ein Aufwand für das Einfügen der
beiden Teilbäume an die Wurzel. Das geschieht (N-1)-mal. Zusammen mit dem dem
Suchschritt selbst ergibt das N-mal.
Der angegebene Suchaufwand gilt nur für die Wurzel mit dem Schlüssel k. Da alle
Werte für k gleichwahrscheinlich sind, gilt allgemein:
ZN
(k)
=
2 N
1 N
⋅
(
N
+
Z
⋅
(
k
−
)
+
Z
⋅
(
N
−
k
))
Z
=
1
+
⋅ ∑ (Z k −1 ⋅ ( k − 1)
1
bzw.
∑
N
k −1
N−k
N 2 k =1
N 2 k =1
N −1
2
⋅
∑ (Z k −1 ⋅ ( k − 1)
( N − 1) 2 k =1
N −1
2 N
2
= 2 ⋅ ∑ Z k −1 ⋅ ( k − 1) −
⋅
∑ Z k −1 ⋅ ( k − 1)
N k =1
( N − 1) 2 k −1
bzw. für N - 1: Z N −1 = 1 +
Z N − Z N −1
Es läßt sich daraus ableiten:
Mit
der
YN = YN −1
Ersatzfunktion
2⋅N −1
N
N −1
⋅ Z N +1 +
⋅ ZN =
N +1
N
N ⋅ ( N + 1)
YN =
N
⋅ ZN
N +1
132
die
Rekursionsformel:
N
N
N
2⋅N −1
2⋅i −1
1
132
+
bzw. nach Auflösung YN = ∑
= 2 ⋅ ∑ − 3⋅
N ⋅ ( N + 1)
N +1
i =1 i ⋅ ( i + 1)
i =1 i
Einsetzen ergibt:
ZN = 2 ⋅
folgt
N +1
N +1
⋅ HS N − 3 = 2 ⋅
⋅ ( HS N +1 − 1) − 1
N
N
vgl. Wettstein, H.: Systemprogrammierung, 2. Auflage, S.291
206
Algorithmen und Datenstrukturen
N
"HS" ist die harmonische Summe: HS N = ∑
i =1
1
N
Sie läßt sich näherungsweise mit ln( N ) + 0.577 ⋅ (ln( N )) = 0.693 ⋅ ld ( N ) . Damit ergibt
. ⋅ ld ( N + 1) − 2
sich schließlich: Z mit = 14
Darstellung
Jeder geordnete binäre Baum ist eindeutig durch folgende Angaben bestimmt:
1. Angabe der Wurzel
2. Für jede Kante Angabe des linken Teilbaums ( falls vorhanden) sowie des rechten Teilbaums (falls
vorhanden)
Die Angabe für die Verzweigungen befinden sich in den Baumknoten, die die
zentrale Konstruktionseinheit für den Aufbau binärerer Bäume sind.
207
Algorithmen und Datenstrukturen
4.2.2 Operationen
1. Generieren eines Suchbaums
Bsp.: Gestalt eines binären Suchbaums nach der Eingabe der Schlüsselfolge (12, 7,
15, 5, 8, 13, 2, 6, 14).
Schlüssel
12
LINKS
RECHTS
7
5
2
15
8
13
6
14
Abb.:
Baumknotendarstellung und Test eines binären Suchbaumknotens in Java 133
// Elementarer Knoten eines binaeren Baums, der nicht ausgeglichen ist
// Der Zugriff auf diese Klasse ist nur innerhalb eines Verzeichnisses
// bzw. Pakets moeglich
class BinaerBaumknoten<T extends Comparable>
{
// Instanzvariable
protected BinaerBaumknoten<T> links;
protected BinaerBaumknoten<T> rechts;
public
T daten;
// Konstruktor
public BinaerBaumknoten(T datenElement)
{ this(datenElement, null, null ); }
public BinaerBaumknoten(T datenElement,
BinaerBaumknoten<T> l,
BinaerBaumknoten<T> r)
{
daten
= datenElement;
links
= l;
133
pr12362
208
// linker Teilbaum
// rechter Teilbaum
// Dateninhalt der Knoten
Algorithmen und Datenstrukturen
rechts
= r;
}
public void insert (T x)
{
if (x.compareTo(daten) > 0)
// dann rechts
{
if (rechts == null) rechts = new BinaerBaumknoten<T>(x);
else rechts.insert(x);
}
else // sonst links
{
if (links == null) links = new BinaerBaumknoten<T> (x);
else links.insert(x);
}
}
public BinaerBaumknoten<T> getLinks()
{ return links; }
public BinaerBaumknoten<T> getRechts()
{ return rechts; }
}
public class TestGenericBinaerBaumKnoten
{
public static void main (String args[])
{
BinaerBaumknoten<Integer> baum = null;
/*
for (int i = 0; i < 20; i++) // 20 Zusfallsstrings speichern
{ String s = "Zufallszahl " + (int)(Math.random() * 100);
if (baum == null) baum = new BinaerBaumknoten(s); }
print(baum); // Sortiert wieder ausdrucken
*/
for (int i = 0; i < 10; i++)
{
// Erzeuge eine Zahl zwischen 0 und 100
Integer r = new Integer((int)(Math.random()*100));
if (baum == null) baum = new BinaerBaumknoten<Integer>(r);
else baum.insert(r);
}
System.out.println("Inorder-Durchlauf");
print(baum); System.out.println();
System.out.println("Baumdarstellung um 90 Grad versetzt");
ausgBinaerBaum(baum,0);
System.out.print("Kleinster Wert: ");
// System.out.print(((Integer)(findeMin(baum))).intValue());
System.out.print(findeMin(baum)); System.out.println();
System.out.print("Groesster Wert: ");
// System.out.print(((Integer)(findeMax(baum))).intValue());
System.out.print(findeMax(baum)); System.out.println();
}
public static <T extends Comparable>
void print (BinaerBaumknoten<? extends T> baum)
// Rekursive Druckfunktion
{
if (baum == null) return;
print(baum.getLinks());
System.out.print(baum.daten + " ");
print(baum.getRechts());
}
public static <T extends Comparable>
void ausgBinaerBaum(BinaerBaumknoten<T> b, int stufe)
{
if (b != null)
{
ausgBinaerBaum(b.getRechts(), stufe + 1);
for (int i = 0; i < stufe; i++)
209
Algorithmen und Datenstrukturen
{ System.out.print("
"); }
System.out.println(b.daten);
ausgBinaerBaum(b.getLinks(), stufe + 1);
}
}
public static <T extends Comparable> T findeMin(BinaerBaumknoten<T> b)
{ return datenZugriff( findMin(b) ); }
public static <T extends Comparable> T findeMax(BinaerBaumknoten<T> b)
{ return datenZugriff( findMax(b) ); }
public static <T extends Comparable> T datenZugriff(BinaerBaumknoten<T> b)
{ return b == null ? null : b.daten; }
public static <T extends Comparable>
BinaerBaumknoten<T> findMin(BinaerBaumknoten<T> b)
{
if (b == null) return null;
else if (b.getLinks() == null) return b;
return findMin(b.getLinks());
}
public static <T extends Comparable>
BinaerBaumknoten<T> findMax(BinaerBaumknoten<T> b)
{
if (b != null)
while (b.getRechts() != null)
b = b.getRechts();
return b;
}
}
Abb.: Ergebnisdarstellung
Der Inorder-Durchlauf des binären Suchbaums (LWR-Ordnung 134) durchläuft die
Schlüsselwerte monoton aufsteigend.
134
vgl. 4.2.3
210
Algorithmen und Datenstrukturen
2. Suchen und Einfügen
Vorstellung zur Lösung
1. Suche nach dem Schlüsselwert
2. Falls vorhanden kein Einfügen
3. Bei erfolgloser Suche Einfügen als Sohn des erreichten Blatts
Eine generische Klasse für den binären Suchbaum in Java 135
Der binäre Suchbaum setzt voraus, dass alle Datenelemente in eine
Ordnungsbeziehung 136 gebracht werden können. Eine generische Klasse für einen
binären Suchbaum erfordert daher ein Interface, das Ordnungsbeziehungen
zwischen Daten eines Datenbestands festlegt. Diese Eigenschaft besitzt das
Interface Comparable:
public interface Comparable
{
int compareTo(Comparable rs)
}
Das Interface 137 zeigt, dass zwei Datenelemente über die Methode „compareTo“
verglichen werden können. Über die Defintion eines Interface wird auch der
zugehörige Referenztyp erzeugt, der wie andere Datentypen eingesetzt werden
kann.
// Freier binaerer Intervallbaum
// Generische Klasse fuer einen unausgeglichenen binaeren Suchbaum
//
// Konstruktor: Initialisierung der Wurzel mit dem Wert null
//
// **********oeffentlich zugaengliche
Methoden*************************************
// void insert( x )
-> Fuege x ein
// void remove( x )
-> Entferne x
// Comparable find( x ) -> Gib das Element zurueck, das zu x passt
// Comparable findMin( ) -> Rueckgabe des kleinsten Elements
// Comparable findMax( ) -> Rueckgabe des groessten Elements
// boolean isEmpty( )
-> Return true if empty; else false
// void makeEmpty( )
-> Entferne alles
// void printTree( )
-> Ausgabe der Binaerbaum-Elemente in sort. Folge
// void ausgBinaerBaum() -> Ausgabe: Binaerbaum-Elemente um 90 Grad vers.
/*
* Implementiert einen unausgeglichenen binaeren Suchbaum.
* Das Einordnen in den Suchbaum basiert auf der Methode compareTo
*/
public class BinaererSuchbaum<T extends Comparable>
{
// Private Datenelemente
/* Die Wurzel des Baums */
135
vgl. pr42110
vgl. Kapitel 1, 1.2.2.2
137 Java-Interfaces werden in der Regel als eine Form abstrakter Klassen beschrieben, durch die einzelne
Klassen der Hierarchie zusätzliche Funktionalität erhalten können. Sollen die Klassen diese Funktionalität durch
Vererbung erhalten, müssten sie in einer gemeinsamen Superklasse angesiedelt werden. Weil ein Interface keine
Implementierung enthält, kann auch keine Instanz davon erzeugt werden. Eine Klasse, die nicht alle Methoden
eines Interface implementiert, wird zu einer abstrakten Klasse.
136
211
Algorithmen und Datenstrukturen
private BinaerBaumknoten<T> root;
// Oeffentlich zugaengliche Methoden
/*
* Konstruktor.
*/
public BinaererSuchbaum()
{ root = null; }
/*
* Einfuegen eines Elements in den binaeren Suchbaum
* Duplikate werden ignoriert
*/
public void insert( T x )
{ root = insert(x, root); }
/*
* Entfernen aus dem Baum. Falls x nicht da ist, geschieht nichts
*/
public void remove( T x )
{ root = remove(x, root); }
/*
* finde das kleinste Element im Baum
*/
public T findMin()
{ return elementAt(findMin(root)); }
/*
* finde das groesste Element im Baum
*/
public T findMax()
{ return elementAt(findMax(root)); }
/*
* finde ein Datenelement im Baum
*/
public T find(T x)
{ return elementAt(find(x, root)); }
/*
* Mache den Baum leer
*/
public void makeEmpty()
{ root = null; }
/*
* Test, ob der Baum leer ist
*/
public boolean isEmpty()
{ return root == null; }
/*
* Ausgabe der Datenelemente in sortierter Reihenfolge
*/
public void printTree()
{
if( isEmpty( ) )
System.out.println( "Baum ist leer" );
else printTree( root );
}
/*
* Ausgabe der Elemente des binaeren Baums um 90 Grad versetzt
*/
public void ausgBinaerBaum()
{
if( isEmpty() )
System.out.println( "Leerer baum" );
else
ausgBinaerBaum( root,0 );
}
// Private Methoden
/*
* Methode fuer den Zugriff auf ein Datenelement
*/
212
Algorithmen und Datenstrukturen
private T elementAt( BinaerBaumknoten<T> b )
{
return b == null ? null : b.daten;
}
/*
* Interne Methode fuer das Einfuegen in einen Teilbaum
*/
private BinaerBaumknoten<T> insert(T x, BinaerBaumknoten<T> b)
{
/* 1*/
if( b == null )
/* 2*/
b = new BinaerBaumknoten<T>( x, null, null );
/* 3*/
else if( x.compareTo( b.daten ) < 0 )
/* 4*/
b.links = insert( x, b.links );
/* 5*/
else if( x.compareTo( b.daten ) > 0 )
/* 6*/
b.rechts = insert( x, b.rechts );
/* 7*/
else
/* 8*/
; // Duplikat; tue nichts
/* 9*/
return b;
}
/*
* Interne Methode fuer das Entfernen eines Knoten in einem Teilbaum
*/
private BinaerBaumknoten<T> remove(T x, BinaerBaumknoten<T> b)
{
if( b == null )
return b;
// nichts gefunden; tue nichts
if( x.compareTo(b.daten) < 0 )
b.links = remove(x, b.links );
else if( x.compareTo(b.daten) > 0 )
b.rechts = remove( x, b.rechts );
else if( b.links != null && b.rechts != null ) // Zwei Kinder
{
b.daten = findMin(b.rechts).daten;
b.rechts = remove(b.daten, b.rechts);
}
else
b = ( b.links != null ) ? b.links : b.rechts;
return b;
}
/*
* Interne Methode zum Bestimmen des kleinsten Datenelements im Teilbaum
*/
private BinaerBaumknoten<T> findMin(BinaerBaumknoten<T> b)
{
if (b == null)
return null;
else if( b.links == null)
return b;
return findMin(b.links );
}
/*
* Interne Methode zum Bestimmen des groessten Datenelements im Teilbaum
*/
private BinaerBaumknoten<T> findMax( BinaerBaumknoten<T> b)
{
if( b != null )
while( b.rechts != null )
b = b.rechts;
return b;
}
/*
* Interne Methode zum Bestimmen eines Datenelements im Teilbaum.
*/
private BinaerBaumknoten<T> find(T x, BinaerBaumknoten<T> b)
{
if(b == null)
213
Algorithmen und Datenstrukturen
return null;
if( x.compareTo(b.daten ) < 0)
return find(x, b.links);
else if( x.compareTo(b.daten) > 0)
return find(x, b.rechts);
else
return b;
// Gefunden!
}
/*
* Interne Methode zur Ausgabe eines Teilbaums in sortierter Reihenfolge
*/
private void printTree(BinaerBaumknoten<T> b)
{
if(b != null)
{
printTree(b.links);
System.out.print(b.daten);
System.out.print(' ');
printTree( b.rechts );
}
}
/*
* Ausgabe des Binaerbaums um 90 Grad versetzt
*/
private void ausgBinaerBaum(BinaerBaumknoten<T> b, int stufe)
{
if (b != null)
{
ausgBinaerBaum(b.links, stufe + 1);
for (int i = 0; i < stufe; i++)
{ System.out.print(' '); }
System.out.println(b.daten);
ausgBinaerBaum(b.rechts, stufe + 1);
}
}
214
Algorithmen und Datenstrukturen
Löschen eines Knoten
Es soll ein Knoten mit einem bestimmten Schlüsselwert entfernt werden.
Fälle
A) Der zu löschende Knoten ist ein Blatt
Bsp.:
vorher
nachher
Abb.:
Das Entfernen kann leicht durchgeführt werden
B) Der zu löschende Knoten hat genau einen Sohn
nachher
vorher
Abb.:
C) Der zu löschende Knoten hat zwei Söhne
215
Algorithmen und Datenstrukturen
nachher
vorher
Abb.:
Der Knoten k wird durch den linken Sohn ersetzt.
Der rechte Sohn von k wird rechter Sohn der rechtesten Ecke des linken Teilbaums.
Der resultierende Teilbaum T' ist ein Suchbaum, häufig allerdings mit erheblich
vergrößerter Höhe.
Aufgaben:
1. Gegeben ist ein binärer Baum folgender Gestalt:
k
k1
k2
k3
Die Wurzel wird gelöscht. Welche Gestalt nimmt der Baum dann an:
k1
k3
k2
Abb.:
Es ergibt sich eine Höhendifferenz ∂H , die durch folgende Beziehung eingegrenzt
ist: −1 ≤ ∂H ≤ H (TL )
216
Algorithmen und Datenstrukturen
H (TL ) ist die Höhe des linken Teilbaums.
2. Gegeben ist die folgende Gestalt eines binären Baums
12
7
15
5
13
2
6
14
Welche Gestalt nimmt dieser Baum nach dem Entfernen der Schlüssel mit den unter
a) bis f) angegebenen Werten an?
a) 2 b) 6
12
7
15
5
13
14
c) 13
12
7
15
5
14
d) 15
12
7
14
5
e) 5
12
7
14
217
Algorithmen und Datenstrukturen
f) 12
7
14
Schlüsseltransfer
Der angegebene Algorithmus zum Löschen von Knoten kann zu einer beträchtlichen
Vergrößerung der Baumhöhe führen. Das bedeutet auch eine beträchtliche
Steigerung des mittleren Suchaufwands. Man ersetzt häufig die angegebene
Verfahrensweise durch ein anderes Verfahren, das unter dem Namen
Schlüsseltransfer bekannt ist.
Der zu löschende Schlüssel (Knoten) wird ersetzt durch den kleinsten Schlüssel des
rechten oder den größten Schlüssel des linken Teilbaums. Dieser ist dann nach Fall
A) bzw. B) aus dem Baum herauszunehmen.
Bsp.:
Abb.:
Test der Verfahrensweise "Schlüsseltransfer":
1) Der zu löschende Baumknoten besteht nur aus einem Wurzelknoten, z.B.:
Schlüssel
LINKS
12
RECHTS
Ergebnis: Der Wurzelknoten wird gelöscht.
2) Vorgegeben ist
218
Algorithmen und Datenstrukturen
Schlüssel
LINKS
12
RECHTS
7
5
8
Abb.:
Der Wurzelknoten wird gelöscht.
Ergebnis:
7
5
8
Abb.:
3) Vorgegeben ist
219
Algorithmen und Datenstrukturen
12
Schlüssel
LINKS
RECHTS
7
5
15
8
13
14
Abb.:
Der Wurzelknoten wird gelöscht.
Ergebnis:
Schlüssel
LINKS
13
RECHTS
7
5
15
8
14
Abb.:
Implementierung der Verfahrenweise Schlüsseltransfer
Baumknoten in einem binären Suchbaum in Java 138
zum Löschen von
/*
* Interne Methode fuer das Entfernen eines Knoten in einem Teilbaum
138
pr42000
220
Algorithmen und Datenstrukturen
*/
private BinaerBaumknoten<T> remove(T x, BinaerBaumknoten<T> b)
{
if( b == null )
return b;
// nichts gefunden; tue nichts
if( x.compareTo(b.daten) < 0 )
b.links = remove(x, b.links );
else if( x.compareTo(b.daten) > 0 )
b.rechts = remove( x, b.rechts );
else if( b.links != null && b.rechts != null ) // Zwei Kinder
{
b.daten = findMin(b.rechts).daten;
b.rechts = remove(b.daten, b.rechts);
}
else
b = ( b.links != null ) ? b.links : b.rechts;
return b;
}
221
Algorithmen und Datenstrukturen
4.2.3 Ordnungen und Durchlaufprinzipien
Das Prinzip, wie ein geordneter Baum durchlaufen wird, legt eine Ordnung auf der
Menge der Knoten fest. Es gibt 3 Möglichkeiten (Prinzipien), die Knoten eines
binären Baums zu durchlaufen:
1. Inordnungen
LWR-Ordnung
(1) Durchlaufen (Aufsuchen) des linken Teilbaums in INORDER
(2) Aufsuchen der BAUMWURZEL
(3) Durchlaufen (Aufsuchen) des rechten Teilbaums in INORDER
RWL-Ordnung
(1) Durchlaufen (Aufsuchen) des rechten Teilbaums in INORDER
(2) Aufsuchen der BAUMWURZEL
(3) Durchlaufen (Aufsuchen) des Teilbaums in INORDER
Der LWR-Ordnung und die RWL-Ordnung sind zueinander invers. Die LWR Ordnung heißt auch symmetrische Ordnung.
2. Präordnungen
WLR-Ordnung
(1) Aufsuchen der BAUMWURZEL
(2) Durchlaufen (Aufsuchen) des linken Teilbaums in PREORDER
(3) Durchlaufen (Aufsuchen) des rechten Teilbaums in PREORDER
WRL-Ordnung
(1) Aufsuchen der BAUMWURZEL
(2) Durchlaufen (Aufsuchen) des rechten Teilbaums in PREORDER
(3) Durchlaufen (Aufsuchen) des linken Teilbaums in PREORDER
Es wird hier grundsätzlich die Wurzel vor den (beiden) Teilbäumen durchlaufen.
3. Postordnungen
LRW-Ordnung
(1) Durchlaufen (Aufsuchen) des linken Teilbaums in POSTORDER
(2) Durchlaufen (Aufsuchen) des rechten Teilbaums in POSTORDER
(3) Aufsuchen der BAUMWURZEL
Zunächst werden die beiden Teilbäume und dann die Wurzel durchlaufen.
RLW-Ordnung
(1) Durchlaufen (Aufsuchen) des rechten Teilbaums in POSTORDER
(2) Durchlaufen (Aufsuchen) des linken Teilbaums in POSTORDER
(3) Aufsuchen der BAUMWURZEL
Zunächst werden die beiden Teilbäume und dann die Wurzel durchlaufen.
222
Algorithmen und Datenstrukturen
Traversierungen eines eines binären (Struktur-) Baums mit Operanden in den
Blättern und Operatoren in den inneren Knoten
import java.util.*;
class Baumknoten<T>
{
private T daten;
private Baumknoten<T> links, rechts;
public Baumknoten(T daten)
{
this(daten,null,null);
}
public Baumknoten(T daten, Baumknoten<T> links, Baumknoten<T> rechts)
{
this.links = links;
this.rechts = rechts;
this.daten = daten;
}
public T getDaten()
{
return daten;
}
public Baumknoten<T> getLinks()
{
return links;
}
public Baumknoten<T> getRechts()
{
return rechts;
}
}
public class GenBaumknoten
{
private static Baumknoten wurzel;
private static void praeorderAusg(Baumknoten b)
{
// Rekursiver Durchlauf
if (b == null) return;
{
System.out.print(b.getDaten() + " ");
praeorderAusg(b.getLinks());
praeorderAusg(b.getRechts());
}
}
private static void postorderAusg(Baumknoten b)
{
// Rekursiver Durchlauf
if (b == null) return;
{
postorderAusg(b.getLinks());
postorderAusg(b.getRechts());
System.out.print(b.getDaten() + " ");
}
}
private static void inorderAusg(Baumknoten b)
{
// Rekursiver Durchlauf
if (b == null) return;
{
inorderAusg(b.getLinks());
System.out.print(b.getDaten() + " ");
223
Algorithmen und Datenstrukturen
inorderAusg(b.getRechts());
}
}
public static void main(String
{
Baumknoten<Character> h = new
Baumknoten<Character> i = new
Baumknoten<Character> d = new
Baumknoten<Character> e = new
Baumknoten<Character> b = new
Baumknoten<Character> f = new
Baumknoten<Character> g = new
Baumknoten<Character> c = new
Baumknoten<Character> a = new
wurzel = a;
// Rekursive Durchlaeufe
praeorderAusg(wurzel);
System.out.println();
inorderAusg(wurzel);
System.out.println();
postorderAusg(wurzel);
System.out.println();
}
}
args[])
Baumknoten<Character>('C');
Baumknoten<Character>('D');
Baumknoten<Character>('A');
Baumknoten<Character>('B');
Baumknoten<Character>('*',d,e);
Baumknoten<Character>('*',h,i);
Baumknoten<Character>('E');
Baumknoten<Character>('+',f,g);
Baumknoten<Character>('+',b,c);
Es wird ein binärer Baum folgender Gestalt erzeugt:
224
Algorithmen und Datenstrukturen
’+’
‘*’
’A’
‘+’
’B’
’*’
’C’
Abb.: Gestalt des Strukturbaums
Der vorliegende Baum enthält folgende Traversierungen:
Abb.: Traversierungen
225
’E’
’E’
Algorithmen und Datenstrukturen
Aufgaben: Gegeben sind eine Reihe binärer Bäume. Welche Folgen entstehen beim
Durchlaufen der Knoten nach den Prinzipien "Inorder (LWR)", "Praeorder WLR" und
"Postorder (LRW)".
1.
A
B
C
E
D
F
I
G
H
J
K
L
"Praeorder": A B C E I F J D G H K L
"Inorder":
EICFJBGDKHLA
"Postorder": I E J F C G K L H D B A
2.
+
*
A
+
B
*
C
E
D
"Praeorder": + * A B + * C D E
"Inorder":
A*B+C*D+E
"Postorder": A B * C D * E + +
Diese Aufgabe zeigt einen Strukturbaum (Darstellung der hierarchischen Struktur eines
arithmetischen Ausdrucks). Diese Baumdarstellung ist besonders günstig für die Übersetzung eines
Ausdrucks in Maschinensprache. Aus der vorliegenden Struktur lassen sich leicht die
unterschiedlichen Schreibweisen eines arithmetischen Ausdrucks herleiten. So liefert das
Durchwandern des Baums in "Postorder" die Postfixnotation, in "Praeorder" die Praefixnotation".
3.
+
A
*
B
C
"Praeorder": + A * B C
"Inorder":
A+B*C
"Postorder": A B C * +
226
Algorithmen und Datenstrukturen
4.
*
+
A
C
B
"Praeorder": * + A B C
"Inorder":
A+B*C
"Postorder": A B + C *
Anwendungen der Durchlaufprinzipien
Mit Hilfe der angegebenen Ordnungen bzw. Durchlaufprinzipien lassen sich weitere
Operationen auf geordneten Wurzelbäumen bestimmen, z.B. „Grafische Darstellung
eines binaeren Suchbaums 139“
private void zeichneBaum(BinaerBaumknoten b, int x, int y,
int m, int s)
{
Graphics g = meinCanvas.getGraphics();
if (b != null)
{
if (b.links != null)
{
g.drawLine(x,y,x - m / 2,y + s);
zeichneBaum(b.links,x - m / 2,y + s,m / 2,s);
}
if (b.rechts != null)
{
g.drawLine(x,y,x + m / 2,y+s);
zeichneBaum(b.rechts,x + m / 2,y + s,m / 2,s);
}
}
}
}
139
vgl. pr42110
227
Algorithmen und Datenstrukturen
4.3 Balancierte Bäume
Hier geht es darum, entartete Bäume (schiefe Bäume, Äste werden zu linearen
Listen, etc.) zu vermeiden. Statische Optimierung heißt: Der ganze Baum wird neu
(bzw. wieder neu) aufgebaut. Bei der dynamischen Optimierung wird der Baum
während des Betriebs (bei jedem Ein- und Ausfügen) optimiert.
Perfekt ausgeglichener, binärer Suchbaum
Ein binärer Suchbaum sollte immer ausgeglichen sein. Der folgende Baum
1
2
3
4
5
ist zu einer linearen Liste degeneriert und läßt sich daher auch nicht viel schneller als
eine lineare Liste durchsuchen. Ein derartiger binärer Suchbaum entsteht
zwangsläufig, wenn die bei der Eingabe angegebene Schlüsselfolge in aufsteigend
sortierter Reihenfolge vorliegt. Der vorliegende binäre Suchbaum ist
selbstverständlich nicht ausgeglichen. Es gibt allerdings auch Unterschiede bei der
Beurteilung der Ausgeglichenheit, z.B.:
Die vorliegenden Bäume sind beide ausgeglichen. Der linke Baum ist perfekt
ausbalanciert. Jeder Binärbaum ist perfekt ausbalanciert, falls jeder Knoten über
einen linken und rechten Teilbaum verfügt, dessen Knotenzahl sich höchstens um
den Wert 1 unterscheidet.
Der rechte Teilbaum ist ein in der Höhe ausgeglichener (AVL 140-)Baum. Die Höhe
der Knoten zusammengehöriger linker und rechter Teilbäume unterscheiden sich
höchstens um den Wert 1. Jeder perfekt ausgeglichene Baum ist gleichzeitig auch
ein in der Höhe ausgeglichener Binärbaum. Der umgekehrte Fall trifft allerdings nicht
zu.
Es gibt einen einfachen Algorithmus zum Erstellen eines pefekt ausgeglichenen
Binärbaums 141, falls
140
141
nach den Anfangsbuchstaben der Namen seiner Entdecker: Adelson, Velskii u. Landes
pr43205
228
Algorithmen und Datenstrukturen
(1) die einzulesenden Schlüsselwerte sortiert in aufsteigender Reihenfolge angegeben werden
(2) bekannt ist, wieviel Objekte (Schlüssel) werden müssen.
import java.io.*;
class PBBknoten
{
// Instanzvariable
protected PBBknoten links;
// linker Teilbaum
protected PBBknoten rechts;
// rechter Teilbaum
public int daten;
// Dateninhalt der Knoten
// Konstruktoren
public PBBknoten()
{
this(0,null,null);
}
public PBBknoten(int datenElement)
{
this(datenElement, null, null );
}
public PBBknoten(int datenElement,
PBBknoten l,
PBBknoten r)
{
daten
= datenElement;
links
= l;
rechts
= r;
}
public PBBknoten getLinks()
{
return links;
}
public PBBknoten getRechts()
{
return rechts;
}
}
public class PBB
{
static BufferedReader ein = new BufferedReader(new InputStreamReader(
System.in));
// Instanzvariable
PBBknoten wurzel;
// Konstruktor
public PBB(int n) throws IOException
{
if (n == 0) wurzel = null;
else
{
int nLinks = (n - 1) / 2;
int nRechts = n - nLinks - 1;
wurzel = new PBBknoten();
wurzel.links = new PBB(nLinks).wurzel;
wurzel.daten = Integer.parseInt(ein.readLine());
wurzel.rechts = new PBB(nRechts).wurzel;
}
}
public void ausgPBB()
{
ausg(wurzel,0);
}
private void ausg(PBBknoten b, int nSpace)
229
Algorithmen und Datenstrukturen
{
if (b != null)
{
ausg(b.rechts,nSpace += 6);
for (int i = 0; i < nSpace; i++)
System.out.print(" ");
System.out.println(b.daten);
ausg(b.links, nSpace);
}
}
// Test
public static void main(String args[]) throws IOException
{
int n;
System.out.print("Gib eine ganze Zahl n an, ");
System.out.print("gefolgt von n ganzen Zahlen in ");
System.out.println("aufsteigender Folge");
n = Integer.parseInt(ein.readLine());
PBB b = new PBB(n);
System.out.print(
"Hier ist das Resultat: ein perfekt balancierter Baum, ");
System.out.println("die Darstellung erfogt um 90 Grad versetzt");
b.ausgPBB();
}
}
Schreibtischtest: Wird mit n = 10 aufgerufen, dann wird anschließend die Anzahl der Knoten
berechnet, die sowohl in den linken als auch in den rechten Teilbaum eingefügt werden. Da der
Wurzelknoten keinem Teilbaum zugeordnet werden kann, ergeben sich für die beiden Teilbäume (10
– 1) Knoten. Das ergibt nLinks = 4, nRechts = 5. Anschließend wird der Wurzelknoten erzeugt.
Es folgt der rekursive Aufruf wurzel.links = new PBB(nLinks).wurzel; mit nLinks = 4.
Die Folge davon ist: Einlesen von 4 Zahlen und Ablage dieser Zahlen im linken Teilbaum. Die danach
folgende Zahl wird im Wurzelknoten abgelegt. Der rekursive Aufruf wurzel.rechts = new
PBB(nRechts).wurzel; mit nRechts = 5 verarbeitet die restlichen 5 Zahlen und erstellt damit
den rechten Teilbaum.
Durch jeden rekursiven Aufruf wird ein Baum mit zwei ungefähr gleich großen Teilbäumen erzeugt. Da
die im Wurzelknoten enthaltene Zahl direkt nach dem erstellen des linken Teilbaum gelesen wird,
ergibt sich bei aufsteigender Reihenfolge der Eingabedaten ein binärer Suchbaum, der außerdem
noch perfekt balanciert ist.
230
Algorithmen und Datenstrukturen
4.3.1 Statisch optimierte Bäume
Der Algorithmus zum Erstellen eines perfekt ausgeglichenen Baums kann zur
statischen Optimierung binärer Suchbäume verwendet werden. Das Erstellen des
binären Suchbaums erfolgt dabei nach der bekannten Verfahrensweise. Wird ein
solcher Baum in Inorder-Folge durchlaufen, so werden die Informationen in den
Baumknoten aufsteigend sortiert. Diese sortierte Folge ist Eingangsgröße für die
statische Optimierung. Es wird mit der sortiert vorliegende Folge der Schlüssel ein
perfekt ausgeglichener Baum erstellt.
Bsp.: Ein Java-Applet zur Demonstration der statischen Optimierung. 142
Zufallszahlen werden generiert und in einen freien binären Intervallbaum aufgenommen, der im
oberen Bereich des Fensters gezeigt werden. Über Sortieren erfolgt ein Inorder-Durchlauf des
binären Suchbaums, über „Perfekter BinaerBaum“ Erzeugen und Darstellen des perfekt
ausgeglichen binären Suchbaums.
Abb.:
142
vgl. pr43205, ZPBBApplet.java und ZPBBApplet.html
231
Algorithmen und Datenstrukturen
4.3.2 AVL-Baum
Der wohl bekannteste dynamisch ausgeglichene Binärbaum ist der AVL-Baum,
genannt nach dem Anfangsbuchstaben seiner Entdecker (Adelson, Velskii und
Landis). Ein Baum hat die AVL-Eigenschaft, wenn in jedem Knoten sich die Höhen
der beiden Unterbäume höchstens um 1 (|HR - HL| <= 1) unterscheiden.
Die Last ("Balance") muß in einem Knoten mitgespeichert sein. Es genügt aber als
Maß für die Unsymmetrie die Höhendifferenz ∂H festzuhalten, die nur die Werte -1
(linkslastig), 0 (gleichlastig) und +1 (rechtslastig) annehmen kann.
1. Einfügen
Beim Einfügen eines Knoten können sich die Lastverhältnisse nur auf dem Wege,
den der Suchvorgang in Anspruch nimmt, ändern. Der tatsächliche Ort der Änderung
ist aber zunächst unbekannt. Der Knoten ist deshalb einzufügen und auf notwendige
Berichtigungen zu überprüfen.
Bsp.: Gegeben ist der folgende binäre Baum
8
4
2
10
6
Abb.:
1) In diesen Baum sind die Knoten mit den Schlüsseln 9 und 11 einzufügen. Die Gestalt des Baums
ist danach:
8
4
2
10
6
9
Abb.:
Die Schlüsel 9 und 11 können ohne zusätzliches Ausgleichen eingefügt werden.
232
11
Algorithmen und Datenstrukturen
2) In den gegebenen Binärbaum sind die Knoten mit den Schlüsseln 1, 3, 5 und 7 einzufügen. Wie ist
die daraus resultierende Gestalt des Baums beschaffen?
8
4
2
-1
-2
10
6
1
Abb.:
Schon nach dem Einfügen des Schlüsselwerts „1“ ist anschließendes Ausgleichen unbedingt
erforderlich.
3) Wie könnte das Ausgleichen vollzogen werden?
Eine Lösungsmöglichkeit ist hier bspw. eine einfache bzw. eine doppelte Rotation.
4
2
8
1
6
10
Abb.: Gestalt des Baums nach „Rotation“
b) Beschreibe den Ausgleichsvorgang, nachdem die Schlüssel 3, 5 und 7 eingefügt wurden!
4
2
1
8
3
6
5
10
7
Abb.: Das Einfügen der Schlüssel mit den Werten „3“, „5“ und „7“ verletzt die AVL-Eigenschaft nicht
Nachdem ein Knoten eingefügt ist, ist der Pfad, den der Suchvorgang für das
Einfügen durchlaufen hat, aufwärts auf notwendige Berichtigungen zu überprüfen.
Bei dieser Prüfung wird die Höhendifferenz des linken und rechten Teilbaums
bestimmt. Es können generell folgende Fälle eintreten:
233
Algorithmen und Datenstrukturen
(1) ∂H = +1 bzw. -1
Eine Verlängerung des Baums auf der Unterlastseite gleicht die Last aus, die
Verlängerung wirkt sich nicht weiter nach oben aus. Die Prüfung kann abgebrochen
werden.
(2) ∂H = 0
Das bedeutet: Verlängerung eines Teilbaums
Hier ist der Knoten dann ungleichlastig ( ∂H = +1 bzw. -1), die AVL-Eigenschaft bleibt jedoch
insgesamt erhalten. Der Baum wurde länger.
(3) ∂H = +1 bzw. -1
Das bedeutet: Verlängerung des Baums auf der Überlastseite.
Die AVL-Eigenschaft ist verletzt, wenn ∂H = +2 bzw. -2. Sie wird durch Rotationen
berichtigt.
Die Information über die Ausgeglichenheit steht im AVL-Baumknoten, z.B.:
struct knoten { int num,
// Schluessel
bal;
// Ausgleichsfaktor
struct knoten *zLinks, *zRechts;
};
Abb.: AVL-Baumknoten mit Ausgleichfaktor in C++
In der Regel gibt es folgende Faktoren für die "Ausgeglichenheit" je Knoten im AVLBaum:
"-1": Höhe des linken Teilbaums ist um eine Einheit (ein Knoten) größer als die Höhe im rechten
Teilbaum.
"0": Die Höhen des linken und rechten Teilbaums sind gleich.
"1": Die Höhe des linken Teilbaums ist um eine Einheit (ein Knoten) kleiner als die Höhe des rechten
Teilbaums.
Bsp.:
Die folgende Darstellung zeigt den Binärbaum unmittelbar nach dem Einfügen eines Baumknoten.
Daher kann hier der Faktor für Ausgeglichenheit -2 bis 2 betragen.
12
+1
7
17
+1
+2
5
0
9
14
-1
0
8
24
+2
25
0
+1
30
0
234
Algorithmen und Datenstrukturen
Nach dem Algorithmus für das Einfügen ergibt sich folgender AVL-Baum:
12
0
7
17
+1
+1
5
9
14
25
-1
0
8
24
0
30
0
0
Es gibt 4 Möglichkeiten die Ausgeglichenheit, falls sie durch Einfügen eines
Baumknoten gestört wurde, wieder herzustellen.
A
A
B
b
a
A
a
B
c
c
1a
B
b
a
b
1b
A
a
B
c
2a
c
b
2b
Abb.: Die vier Ausgangssituationen bei der Wiederherstellung der AVL-Eigenschaft
Von den 4 Fällen sind jeweils die Fälle 1a, 1b und die Fälle 2a, 2b zueinander
symmetrisch.
Für den Fall 1a kann durch einfache Rotation eine Ausgeglichenheit erreicht werden.
B
0
b
A
0
c
a
Im Fall 1b muß die Rotation nach links erfolgen.
235
Algorithmen und Datenstrukturen
Für die Behandlung von Fall 2a der Abb. 1 wird der Teilbaum c aufgeschlüsselt in
dessen Teilbäume c1 und c2:
A
-2
B
a
2
b
C
-1
c1
c2
Abb.:
Durch zwei einfache Rotationen kann der Baum ausgeglichen werden:
1. Rotation
2. Rotation
A
C
-2
C
0
a
B
-2
B
A
+1
c2
b
+1
c1
c2
a
+1
b
c1
Abb.:
Die generische Klasse „AvlBaum“ in Java 143
Grundlagen: Die AVL-Eigenschaft ist verletzt, wenn diese Höhendifferenz +2 bzw. –2
ist. Der Knoten, der diesen Wert erhalten hat, ist der Knoten „alpha“, dessen
Unausgeglichenheit auf einen der folgenden 4 Fälle zurückzuführen ist:
1. Einfügen in den linken Teilbaum, der vom linken Nachkommen des Knoten „alpha“ bestimmt ist.
2. Einfügen in den rechten Teilbaum, der vom linken Nachkommen des Knoten „alpha“ bestimmt ist.
3. Einfügen in den linken Teilbaum, der vom rechten Nachkommen des Knoten „alpha“ bestimmt ist.
4. Einfügen in den rechten Teilbaum, der vom rechten Nachkommen des Knoten „alpha“ bestimmt ist
143
pr43210
236
Algorithmen und Datenstrukturen
Fall 1 und Fall 4 bzw. Fall 2 und Fall 3 sind Spiegelbilder, zeigen also das gleiche
Verhalten.
Fall 1 kann durch einfache Rotation behandelt werden und ist leicht zu bestimmen,
daß das Einfügen „außerhalb“ (links – links bzw. rechts – rechts im Fall 4 stattfindet.
Fall 2 kann durch doppelte Rotation behandelt werden und ist ebenfalls leicht zu
bestimmen, da das Einfügen „innerhalb“ (links –rechts bzw. rechts – links) erfolgt.
Die einfache Rotation: Die folgende Darstellung beschreibt den Fall 1 vor und nach
der Rotation:
k2
k1
k1
k2
Z
Y
X
Y
Z
X
Abb.:
Die folgende Darstellung beschreibt Fall 4 vor und nach der Rotation:
k1
k2
k2
k1
X
Y
X
Y
Z
Z
Abb.:
Doppelrotation: Die einfache Rotation führt in den Fällen 2 und 3 nicht zum Erfolg.
Fall 2 muß durch eine Doppelrotation (links – rechts) behandelt werden.
k3
k2
k1
k1
k3
D
k2
B
A
A
B
C
Abb.:
237
C
D
Algorithmen und Datenstrukturen
Auch Fall 3 muß durch Doppelrotation behandelt werden
k1
k3
k2
A
k1
k2
k3
D
B
C
A
B
D
C
Abb.:
Implementierung: Zum Einfügen eines Knoten mit dem Datenwert „x“ in einen AVLBaum, wird „x“ rekursiv in den betoffenen Teilbaum eingesetzt. Falls die Höhe dieses
Teilbaums sich nicht verändert, ist das Einfügen beendet. Liegt Unausgeglichenheit
vor, dann ist einfache oder doppelte Rotation (abhängig von „x“ und den Daten des
betroffenen Teilbaums) nötig.
Avl-Baumknoten 144
Er enthält für jeden Knoten eine Angabe zur Höhe(ndifferenz) seiner Teilbäume.
// Baumknoten fuer AVL-Baeume
class GenAvlKnoten<T extends Comparable>
{
// Instanzvariable
protected GenAvlKnoten<T> links;
// Linkes Kind
protected GenAvlKnoten<T> rechts;
// Rechtes Kind
protected int
hoehe;
// Hoehe
public T daten;
// Das Datenelement
// Konstruktoren
public GenAvlKnoten(T datenElement)
{ this(datenElement, null, null ) }
public GenAvlKnoten( T datenElement, GenAvlKnoten<T> lb,
GenAvlKnoten<T> rb )
{
daten = datenElement; links = lb; rechts = rb; hoehe = 0;
}
}
Der Avl-Baum 145
Bei jedem Schritt ist festzustellen, ob die Höhe des Teilbaums, in dem ein Element
eingefügt wurde, zugenommen hat.
private static <T extends Comparable> int hoehe(GenAvlKnoten<T> b)
{ return b == null ? -1 : b.hoehe; }
Die Methode „insert“ führt das Einfügen eines Baumknoten in den Avl-Baum aus:
/*
144
145
vgl. pr43210
vgl. pr43210
238
Algorithmen und Datenstrukturen
* Interne Methode zum Einfuegen eines Baumknoten in einen Teilbaum.
* x ist das einzufuegende Datenelement.
* b ist der jeweilige Wurzelknoten.
* Rueckgabe der neuen Wurzel des jeweiligen Teilbaums.
*/
private GenAvlKnoten<T> insert(T x, GenAvlKnoten<T> b)
{
if( b == null )
b = new GenAvlKnoten<T>(x, null, null);
else if (x.compareTo( b.daten) < 0 )
{
b.links = insert(x, b.links );
if (hoehe( b.links ) - hoehe( b.rechts ) == 2 )
if (x.compareTo( b.links.daten ) < 0 )
b = rotationMitLinksNachf(b);
else b = doppelrotationMitLinksNachf(b);
}
else if (x.compareTo( b.daten ) > 0 )
{
b.rechts = insert(x, b.rechts);
if( hoehe(b.rechts) - hoehe(b.links) == 2)
if( x.compareTo(b.rechts.daten) > 0 )
b = rotationMitRechtsNachf(b);
else
b = doppelrotationMitRechtsNachf( b );
}
else
; // Duplikat; tue nichts
b.hoehe = max( hoehe( b.links ), hoehe( b.rechts ) ) + 1;
return b;
}
Rotationen
/*
* Rotation Binaerbaumknoten mit linkem Nachfolger.
* Fuer AVL-Baeume ist dies eine einfache Rotation (Fall 1).
* Aktualisiert Angaben zur Hoehe, dann Rueckgabe der neuen Wurzel.
*/
private static <T extends Comparable> GenAvlKnoten<T>
rotationMitLinksNachf(GenAvlKnoten<T> k2)
{
GenAvlKnoten<T> k1 = k2.links;
k2.links = k1.rechts; k1.rechts = k2;
k2.hoehe = max( hoehe( k2.links ), hoehe( k2.rechts ) ) + 1;
k1.hoehe = max( hoehe( k1.links ), k2.hoehe ) + 1;
return k1;
}
/*
* Rotation Binaerbaumknoten mit rechtem Nachfolger.
* Fuer AVL-Baeume ist dies eine einfache Rotation (Fall 4).
* Aktualisiert Angaben zur Hoehe,, danach Rueckgabe der neuen Wurzel.
*/
private static <T extends Comparable> GenAvlKnoten<T>
rotationMitRechtsNachf(GenAvlKnoten<T> k1)
{
GenAvlKnoten<T> k2 = k1.rechts;
k1.rechts = k2.links; k2.links = k1;
k1.hoehe = max( hoehe( k1.links ), hoehe( k1.rechts ) ) + 1;
k2.hoehe = max( hoehe( k2.rechts ), k1.hoehe ) + 1;
return k2;
}
/*
* Doppelrotation der Binaerbaumknoten: : erster linker Nachfolgeknoten
* mit seinem rechten Nachfolger; danach Knoten k3 mit neuem linken
239
Algorithmen und Datenstrukturen
* Nachfolgerknoten.
* Fuer AVL-Baeume ist dies eine doppelte Rotation (Fall 2)
* Aktualisiert Angaben zur Hoehe,, danach Rueckgabe der neuen Wurzel.
*/
private static <T extends Comparable> GenAvlKnoten<T>
doppelrotationMitLinksNachf(GenAvlKnoten<T> k3)
{
k3.links = rotationMitRechtsNachf( k3.links );
return rotationMitLinksNachf( k3 );
}
/*
* Doppelrotation der Binaerbaumknoten: erster rechter Nachfolgeknoten
* mit seinem linken Nachfolger;danach Knoten k1 mit neuem rechten
* Nachfolgerknoten
* Fuer AVL-Baeume ist dies eine doppelte Rotation (Fall 3)
* Aktualisiert Angaben zur Hoehe,, danach Rueckgabe der neuen Wurzel.
*/
private static <T extends Comparable> GenAvlKnoten<T>
doppelrotationMitRechtsNachf(GenAvlKnoten<T> k1)
{
k1.rechts = rotationMitLinksNachf(k1.rechts);
return rotationMitRechtsNachf(k1);
}
2. Löschen
Man kann folgende Fälle unterscheiden:
(1) ∂H = +1 bzw. -1
(Verkürzung des Teilbaums auf der Überlastseite)
(2) ∂H = 0
(Verkürzung eines Unterbaums)
Der Knoten ist jetzt ungleichlastig ( ∂H = +1 bzw. -1), bleibt jedoch im Rahmen der AVL-Eigenschaft.
Der Baum hat seine Höhe nicht verändert, die Berichtigung kann abgebrochen werden.
(3) ∂H = +1 bzw. -1
(Verkürzung eines Baums auf der Unterlastseite)
Die AVL-Eigenschaft ist verletzt, falls ∂H = +2 bzw. -2. Sie wird durch eine Einfachbzw. Doppelrotation wieder hergestellt. Dadurch kann sich der Baum verkürzen, so
daß Lastreduzierungen an den Vorgänger weiterzugeben sind. Es können aber auch
Lastsituationen mit dem Lastfaktor 0 auftreten.
Bsp.: Spezialfälle zum Lastausgleich in einem AVL-Baum
k
k'
H+2
H+1
k'
a
k
c
H+1
H+1
H
H+1
b
c
a
b
240
Algorithmen und Datenstrukturen
k
k''
H+1
H+2
k'
a
k
k''
H
H
a
b
d
H
H
Abb.:
Löschen kann in der Regel nicht mit einer einzigen Rotation abgeschlossen werden.
Im schlimmsten Fall erfordern alle Knoten im Pfad der Löschstelle eine
Rekonfiguration. Experimente zeigen allerdings, daß beim Einfügen je Operation
mehr Rotationen durchzuführen sind als beim Löschen. Offenbar existieren beim
Löschen durch den inneren Bereich des Baums mehr Knoten, die ohne weiteres
eliminiert werden können.
Aufgabe
1. Gegeben ist die Schlüsselfolge 7, 6, 8, 5, 9, 4. Ermittle, wie sich mit dieser Schlüsselfolge einen
AVL-Baum aufbaut.
Schlüssel
7
BALANCE
0
LINKS, RECHTS
4
0
5
8
0
-1
6
0
9
0
Abb.:
2. Aus dem nun vorliegenden AVL-Baum sind die Knoten mit den Schlüsselwerten 9 und 8 zu
löschen. Gib an, welche Gestalt der AVL-Baum jeweils annimmt.
241
Algorithmen und Datenstrukturen
Schlüssel
5
BALANCE
1
LINKS, RECHTS
7
4
0
-1
6
0
Abb.:
3. Animation zur Demonstration der Funktionsweise von AVL-Bäumen 146
146
http://fbim.fh-regensburg.de/~saj39122/bruhi/index.html
242
Algorithmen und Datenstrukturen
4.3.3 Splay-Bäume
Zugrundeliegende Idee
Nachdem auf einen Baumknoten zugegriffen wurde, wird dieser Knoten über eine
Reihe von AVL-Rotationen zur Wurzel. Bis zu einem gewissen Grade führt das zur
Ausbalancierung.
Bsp.:
1.
y
x
x
C
A
A
y
B
B
2.
e
C
e
d
F
e
d
F
d
F
c
E
c
E
b
A
a
E
a
a
D
b
A
b
c
C
D
B
B
C
C
D
A
e
a
a
F
c
c
e
d
d
A
B
F
b
A
B
b
B
E
E
C
C
D
243
D
Algorithmen und Datenstrukturen
Splaying-Operationen
Der Knoten „x“ im Splay-Baum bewegt sich über einfache und doppelte Rotationen
zur Wurzel. Man unerscheidet folgende Fälle:
1. (zig): x ist ein Kind der Wurzel von einem Splay-Baum, einfache Rotation
2. (zig-zig): x hat den Großvater g(x) und den Vater p(x), x und p(x) sind jeweils linke (bzw. rechte)
Kinder ihres Vaters.
g(x)=p(y)
g(x)
bzw.
y = p(x)
p(x)
D
A
x
x
C
A
B
B
C
D
x
y
A
z
B
C
D
3. (zig-zag): x hat Großvater g(x) und Vater p(x), x ist linkes (rechtes) Kind von p(x), p(x) ist rechtes
(linkes) Kind von g(x)
z = g(x)
x
y=p(x)
y
z
D
x
A
B
A
B
C
244
C
D
Algorithmen und Datenstrukturen
Implementierung 147
BinaerBaumKnoten
// Elementarer Knoten eines binaeren Baums, der nicht ausgeglichen ist
// Der Zugriff auf diese Klasse ist nur innerhalb eines Verzeichnisses
// bzw. Pakets moeglich
class BinaerBaumknoten
{
// Instanzvariable
protected BinaerBaumknoten links;
// linker Teilbaum
protected BinaerBaumknoten rechts;
// rechter Teilbaum
public Comparable daten;
// Dateninhalt der Knoten
// Konstruktor
public BinaerBaumknoten(Comparable datenElement)
{
this(datenElement, null, null );
}
public BinaerBaumknoten(Comparable datenElement,
BinaerBaumknoten l,
BinaerBaumknoten r)
{
daten
= datenElement;
links
= l;
rechts
= r;
}
public void insert (Comparable x)
{
if (x.compareTo(daten) > 0)
// dann rechts
{
if (rechts == null) rechts = new BinaerBaumknoten(x);
else rechts.insert(x);
}
else // sonst links
{
if (links == null) links = new BinaerBaumknoten(x);
else links.insert(x);
}
}
public BinaerBaumknoten getLinks()
{
return links;
}
public BinaerBaumknoten getRechts()
{
return rechts;
}
}
SplayBaum
//
//
//
//
//
//
//
//
//
//
147
SplayBaum class
***************** PUBLIC OPERATIONen ********************
void insert( x )
--> Insert x
void remove( x )
--> Remove x
Comparable find( x )
--> Gib das Merkmal zurück, das x zugeordnet ist
Comparable findMin( ) --> Rueckgabe des kleinsten elements
Comparable findMax( ) --> Rueckgabe des groessten Elements
boolean isEmpty( )
--> Rueckgabe true, falls leer; sonst false
void makeEmpty( )
--> Entferne alle Elemente
pr43215
245
Algorithmen und Datenstrukturen
// void printTree( )
--> Gib den Baum sortiert aus
/*
* Implementiere einen top-down Splay Baum.
* Vergleiche beziehen sich auf die Methode compareTo.
*/
public class SplayBaum
{
private BinaerBaumknoten root;
private static BinaerBaumknoten nullNode;
static
// Static initializer for nullNode
{
nullNode = new BinaerBaumknoten( null );
nullNode.links = nullNode.rechts = nullNode;
}
private static BinaerBaumknoten newNode = null;
// wird in diversen Einfuegevorgaengen benutzt
private static BinaerBaumknoten header = new BinaerBaumknoten(null);
/*
* Konstruktor.
*/
public SplayBaum( )
{
root = nullNode;
}
/*
* Zugriff auf die Wurzel
*/
public BinaerBaumknoten holeWurzel()
{
return root;
}
/*
* Insert.
* Parameter x ist das einzufuegende Element.
*/
public void insert( Comparable x )
{
if( newNode == null )
newNode = new BinaerBaumknoten( null );
newNode.daten = x;
if( root == nullNode )
{
newNode.links = newNode.rechts = nullNode;
root = newNode;
}
else
{
root = splay( x, root );
if( x.compareTo( root.daten ) < 0 )
{
newNode.links = root.links;
newNode.rechts = root;
root.links = nullNode;
root = newNode;
}
else if( x.compareTo( root.daten ) > 0 )
{
newNode.rechts = root.rechts;
newNode.links = root;
root.rechts = nullNode;
root = newNode;
}
else return;
}
newNode = null;
246
Algorithmen und Datenstrukturen
}
/*
* Remove.
* Parameter x ist das zu entfernende Element.
*/
public void remove( Comparable x )
{
BinaerBaumknoten neuerBaum;
// Falls x gefunden wird, liegt x in der Wurzel
root = splay( x, root );
if( root.daten.compareTo( x ) != 0 )
return;
// Element nicht gefunden; tue nichts
if( root.links == nullNode )
neuerBaum = root.rechts;
else
{
// Finde das Maximum im linken Teilbaum
// Splay es zur Wurzel; dann haenge das rechte Kind dran
neuerBaum = root.links;
neuerBaum = splay( x, neuerBaum );
neuerBaum.rechts = root.rechts;
}
root = neuerBaum;
}
/*
* Bestimme das kleinste Daten-Element im Baum.
* Rueckgabe: kleinstes Datenelement bzw. null, falls leer.
*/
public Comparable findMin( )
{
if( isEmpty( ) ) return null;
BinaerBaumknoten ptr = root;
while( ptr.links != nullNode ) ptr = ptr.links;
root = splay( ptr.daten, root );
return ptr.daten;
}
/*
* Bestimme das groesste Datenelement im Baum.
* Rueckgabe: das groesste Datenelement bzw. null, falls leer
*/
public Comparable findMax( )
{
if (isEmpty( )) return null;
BinaerBaumknoten ptr = root;
while( ptr.rechts != nullNode ) ptr = ptr.rechts;
root = splay( ptr.daten, root );
return ptr.daten;
}
/*
* Bestimme ein Datenelement im Baum.
* Parameter x entfält das zu suchende Element.
* Rueckgabe: Das passende Datenelement oder null, falls leer
*/
public Comparable find( Comparable x )
{
root = splay( x, root );
if (root.daten.compareTo( x ) != 0) return null;
return root.daten;
}
/*
* Mache den Baum logisch leer.
*/
public void makeEmpty( )
{
root = nullNode;
}
247
Algorithmen und Datenstrukturen
/*
* Ueberpruefe, ob der Baum logisch leer ist
* Rueckgabe true, falls leer, anderenfalls false.
*/
public boolean isEmpty( )
{
return root == nullNode;
}
/*
* Gib den Inhalt des baums in sortierter Folge aus.
*/
public void printTree( )
{
if (isEmpty( ))
System.out.println( "Empty tree" );
else
printTree( root );
}
/*
* Ausgabe des Binaerbaums um 90 Grad versetzt
*/
public void ausgBinaerbaum(BinaerBaumknoten b, int stufe)
{
if (b != b.links)
{
ausgBinaerbaum(b.links,stufe + 3);
for (int i = 0; i < stufe; i++)
{
System.out.print(' ');
}
System.out.println(b.daten.toString());
ausgBinaerbaum(b.rechts,stufe + 3);
}
}
/*
* Interne Methode zur Ausfuehrung eines "top down" splay.
* Der zuletzt im Zugriff befindliche Knoten
* wird die neue Wurzel.
* Parameter x Ist das Zielelement, die Umgebung fuer das Splaying.
* Parameter b ist die Wurzel des Teilbaums,
* um den das Splaying stattfindet.
* Rueckgabe des Teilbaums.
*/
private BinaerBaumknoten splay( Comparable x, BinaerBaumknoten t )
{
BinaerBaumknoten leftTreeMax, rightTreeMin;
header.links = header.rechts = nullNode;
leftTreeMax = rightTreeMin = header;
nullNode.daten = x;
// Guarantee a match
for( ; ; )
if( x.compareTo( t.daten ) < 0 )
{
if( x.compareTo( t.links.daten ) < 0 )
t = rotateWithLeftChild( t );
if( t.links == nullNode ) break;
// Kette Rechts
rightTreeMin.links = t;
rightTreeMin = t;
t = t.links;
}
else if( x.compareTo( t.daten ) > 0 )
{
if( x.compareTo( t.rechts.daten ) > 0 )
t = rotateWithRightChild( t );
if( t.rechts == nullNode ) break;
// Kette Links
248
Algorithmen und Datenstrukturen
leftTreeMax.rechts = t;
leftTreeMax = t;
t = t.rechts;
}
else break;
leftTreeMax.rechts = t.links;
rightTreeMin.links = t.rechts;
t.links = header.rechts;
t.rechts = header.links;
return t;
}
/*
* Rotation BinaerBaumknoten mit linkem Nachfolger.
*/
static BinaerBaumknoten rotateWithLeftChild(BinaerBaumknoten k2)
{
BinaerBaumknoten k1 = k2.links;
k2.links = k1.rechts;
k1.rechts = k2;
return k1;
}
/*
* Rotation BinaerBaumknoten mit rechtem Nachfolger.
*/
static BinaerBaumknoten rotateWithRightChild(BinaerBaumknoten k1)
{
BinaerBaumknoten k2 = k1.rechts;
k1.rechts = k2.links;
k2.links = k1;
return k2;
}
/*
* Interne Methode zur Ausgabe eines Teilbaums in sortierter Folge.
* Parameter b ist der jweilige Wurzelknoten.
*/
private void printTree( BinaerBaumknoten b )
{
if( b != b.links )
{
printTree(b.links);
System.out.println(b.daten.toString( ));
printTree(b.rechts);
}
}
}
SplaybaumTest
import java.io.*;
public class SplayBaumTest
{
public static void main( String [ ] args )
{
SplayBaum b = new SplayBaum();
String eingabeZeile
= null;
System.out.println("Einfuegen");
BufferedReader eingabe = null;
eingabe = new BufferedReader(
new InputStreamReader(System.in));
try {
int zahl;
do
{
System.out.println("Zahl? ");
249
Algorithmen und Datenstrukturen
eingabeZeile = eingabe.readLine();
try {
zahl = Integer.parseInt(eingabeZeile);
b.insert(new Integer(zahl));
b.ausgBinaerbaum(b.holeWurzel(),2);
}
catch (NumberFormatException ne)
{ break; }
} while (eingabeZeile != "");
}
catch (IOException ioe)
{
System.out.println("Eingefangen in main()");
}
System.out.println("Loeschen");
try {
int zahl;
do
{
System.out.println("Zahl? ");
eingabeZeile = eingabe.readLine();
try {
zahl = Integer.parseInt(eingabeZeile);
b.remove(new Integer(zahl));
b.ausgBinaerbaum(b.holeWurzel(),2);
}
catch (NumberFormatException ne)
{ break; }
} while (eingabeZeile != "");
}
catch (IOException ioe)
{
System.out.println("Eingefangen in main()");
}
System.out.println("Zugriff auf das kleinste Element");
b.findMin();
b.ausgBinaerbaum(b.holeWurzel(),2);
System.out.println("Zugriff auf das groesste Element");
b.findMax();
b.ausgBinaerbaum(b.holeWurzel(),2);
}
}
250
Algorithmen und Datenstrukturen
4.4 Bayer-Bäume
4.4.1 Grundlagen und Definitionen
4.4.1.1 Ausgeglichene T-äre Suchbäume (Bayer-Bäume)
Bayer-Bäume sind für die Verwaltung der Schlüssel zu Datensätzen in
umfangreichen Dateien vorgesehen. Der binäre Baum ist für die Verwaltung solcher
Schlüssel nicht geeignet, da er nur jeweils einen Knoten mit einem einzigen
Datensatz adressiert. Die Daten (Datensätze) stehen blockweise zusammengefaßt
auf Massenspeichern, der Binärbaum müßte Knoten für Knoten auf einen solchen
Block abgebildet werden. Jeder Zugriff auf den Knoten des Baums würde ein Zugriff
auf den Massenspeicher bewirken. Da ein Plattenzugriff relativ zeitaufwendig ist,
hätte man die Vorteile der Suchbäume wieder verloren. In einen Knoten ist daher
nicht nur ein Datum aufzunehmen, sondern maximal (T - 1) Daten. Ein solcher
Knoten hat T Nachfolger. Die Eigenschaften der knotenorientierten "T-ären"
Intervallbäume sind :
- Jeder Knoten enthält max. (T - 1) Daten
- Die Daten in einem Knoten sind aufsteigend sortiert
- Ein Knoten enthält maximal T Teilbäume
- Die Daten (Schlüssel) der linken Teilbäume sind kleiner als das Datum der Wurzel.
- Die Daten der rechten Teilbäume sind größer als das Datum der Wurzel.
- Alle Teilbäume sind T-äre Suchbäume.
Durch Zusammenfassen mehrerer Knoten kommt man so vom binären zum "T-ären"
Suchbaum, z.B.:
8
4
12
2
1
3
5
2
3
11
9
7
4
1
14
10
6
5
6
8
15
12
9
7
13
10
11
13
14
15
Abb. 4.4-1:
T-äre Bäume haben aber noch einen schwerwiegenden Nachteil. Sie können leicht
zu entarteten Bäumen degenerieren.
251
Algorithmen und Datenstrukturen
Bsp.: Ein entarteter 5-ärer Baum enthält durch Eingabe (Einfügen) der Elemente 1
bis 16 in aufsteigender Folge folgende Gestalt:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Abb. 4.4-2:
Durch Einfügen und Löschen können sich beliebig unsymetrische Strukturen
herausbilden. Algorithmen zum Ausgleichen, vegleichbar mit den AVL-Rotationen
sind jedoch nicht bekannt.
Zunehmend an Bedeutung gewinnt ein höhengleicher Baum (B-Baum), der von R.
Bayer eingeführt wurde. Höhengleichheit kann erreicht werden, wenn folgende
Eigenschaften eingehalten werden:
- Ein Knoten enthält höchstens 2 ⋅ M Schlüssel ( 2 ⋅ M + 1 -ärer Baum). Jeder Bayer-Baum (B-Baum)
besitzt eine Klasse, im vorliegenden Fall die Klasse M. M heißt Ordnung des Baums 148.
- Jeder Knoten (Ausname: Wurzel) enthält mindestens M Schlüssel, höchstens 2 ⋅ M Schlüssel.
- Jeder Nichtblattknoten hat daher zwischen ( M + 1 ) und( 2 ⋅ M + 1 ) Nachfolger.
- Alle Blattknoten liegen auf der gleichen Stufe.
- B-Bäume sind von (a,b)-Bäumen abgeleitet.
148
In einigen Büchern wird als Ordnung des Baums der Verzweigungsgrad bezeichnet, hier also 2M+1
252
Algorithmen und Datenstrukturen
4.4.1.2 (a,b)-Bäume
Ein (a,b)-Baum ist ein (externer) Suchbaum, für den gilt:
- Alle Blätter haben die gleiche Tiefe
- Schlüssel sind nur in den Blättern gespeichert 149.
- Für alle Knoten k (außer Wurzeln und Blättern) gilt
a ≤ Anzahl _ der _ Kinder (k ) ≤ b
- b ≥ 2 ⋅ a −1
- Für alle inneren Knoten gilt: Hat k l Kinder, so sind in k l-1 Werte k1, ..., ki-1 gespeichert und es gilt:
k i −1 ≤ key( w) ≤ k i für alle Knoten w im i-ten Unterraum von k .
- Falls B ein (a.b)-Baum mit n Blättern ist, dann gilt log b ( n) ≤
n
Höhe( B) ≤ 1 + log a ( ) . Der rechte
2
Teile der Ungleichung resultiert daraus, daß bei Bäumen mit Tiefe größer als 1 die Wurzel
wenigstens zwei Kinder hat, eines der Kinder hat maximal n/2 Blätter und minimalen
Verzweigungsgrad a.
- B-Bäume sind Spezialfälle von (a.b)-Bäumen mit b = 2 ⋅ a − 1
Bsp.:
21, 39
7,15
32
1,4
1
71
9
4
7
9
17
15
17
52, 62
24
21
24
35
32
35
39
43,47
43
47
53,56
52
53
Abb. 4.4-3: (2,3) - Baum
2
1
1
3.4.5
2
3
4
5
Abb. 4.4-4: (2,4) - Baum
149
In dieser Sicht unterscheiden sich (a,b)-Bäume von den hier angesprochenen B-Bäumen.
253
56
67
62
67
Algorithmen und Datenstrukturen
4.4.2 Darstellung von Bayer-Bäumen
+-----------------------------------+
|
|
| Z0S1Z1S2Z2S3 ........... ZN-1SNZN |
|
|
+-----------------------------------+
Zl ... Zeiger
Sl ... Schluessel
Alle Schlüssel in einem Teilbaum, auf den durch Zl-1 verwiesen wird, sind kleiner als
Sl. Alle Schlüssel in einem Unterbaum, auf den durch Zl verwiesen wird, sind größer
als Sl.
In einem B-Baum der Höhe H befinden sich daher zwischen N min = 2 ⋅ ( M + 1) H −1 − 1
und N max = 2 ⋅ ( M + 1) H − 1 Schlüssel. Neue Schlüssel werden stets in den Blättern
zugefügt.
Aufgabe: Gegeben ist die folgende Schlüsselfolge: „1, 7, 6, 2, 11, 4, 8, 13, 10, 5, 19,
9, 18, 24, 3, 12, 14, 20, 21, 17“.
Bestimme die zugehörigen Strukturen eines 5-ären Baumes.
1) 1, 7, 6, 2
1
6
2
7
2) 11
6
1
2
7
11
7
8
3) 4, 8, 13
6
1
2
4
11
13
11
13
4) 10
6
1
2
4
10
7
8
5) 5, 19, 9, 18
254
Algorithmen und Datenstrukturen
6
1
2
4
5
10
7
8
9
18
19
11
13
11
13
19
24
13
14
19
20
6) 24
6
1
2
4
5
10
7
8
18
9
7) 3, 12, 14, 20, 21
1
2
4
5
3
6
7
8
10
9
18
11
12
21
24
Abb. 4.4-6:
Java-Darstellung
Zeiger ZI und Schlüssel SI eines jeden Knoten sind folgendermaßen angeordnet:
+----------------------------- ------+
|
|
| Z0S0Z1S1Z2S2 ........... ZN-1SN-1ZN |
|
|
+-------------------------------------+
Das führt zu der folgenden Beschreibung des B-Baums der Ordnung 2 (mit 5
Kettengliedern je Knoten).
Die Klasse CBNode zeigt die Implementierung eines Bayer-Baumknotens 150. Der
Konstruktor dieser Klasse leistet die Arbeit und bekommt dazu einen
Übergabeparameter (M) geliefert, der die Ordnung des (M-ären) Bayer-Baums
beschreibt.
import java.util.*;
/*
* The CBNode class represents on single Node of the Bayer Tree.
*/
public class CBNode
{
// Instanzvariable
protected Vector key, nodeptr, initkey, initvec;
150
Vgl. pr44200, CBNode.java
255
Algorithmen und Datenstrukturen
int count;
// Konstruktor
/*
* constructs a single Bayer Tree node with M references to subnodes.
*/
CBNode(int M)
{
// System.out.println("CBNode(): constructor invoked!");
nodeptr = new Vector();
initvec = new Vector();
initvec = null;
key = new Vector();
initkey = new Vector();
initkey = null;
for(int i = 0; i <= M; i++)
{
nodeptr.addElement((Object)initvec);
System.out.println(i +
"ter Nodepointer erzeugt. Wert: " + nodeptr.elementAt(i));
}
for(int j = 0; j <= M - 1; j++)
{
key.addElement((Object)initkey);
// System.out.println(j + "ter Key erzeugt. Wert: " + key.elementAt(j));
}
count = 0;
// System.out.println("count Wert: " + count);
}
}
4.4.3 Suchen eines Schlüssels
Gegeben ist der folgende Ausschnitt eines B-Baums mit N Schlüsseln:
+----------------------------- ------+
|
|
| Z0S0Z1S1Z2S2 ........... ZN-1SN-1ZN |
|
|
+-------------------------------------+
S0<S1<S2<.....<SN-1
Handelt es sich beim Knoten um ein Blatt, dann ist der Wert eines jeden Zeigers ZI
im Knoten NULL. Falls der Knoten kein Blatt ist, verweisen einige der (N+1) Zeiger
auf andere Knoten (Kinder des aktuellen Knoten).
I > 0: Alle Schlüssel im betreffenden Kind-Knoten, auf den ZI zeigt, sind größer als
SI-1
I < N: Alle Schlüssel im betreffenden Kind, auf das ZI zeigt, sind kleiner als SN.
Hat einer der angegebenen Zeiger den Wert „null“, dann existiert in dem
vorliegenden Baum kein Teilbaum, der diesen Schlüssel enthält (d.h. die Suche ist
beendet).
Java-Implementierung
Die Klasse CBayerTree umfaßt den Algorithmus 151 zum Einfügen und Löschen von
Bayer-Baumknoten
151
Vgl. pr44200, CBayerTree.java
256
Algorithmen und Datenstrukturen
class CBayerTree
{
// protected Instanzvariablen
/*
* Die Variable MAERER definiert die Ordnung des Bayer-Baums,
* d.h. der Nachfolgerknoten bzw. Teilbäume eines Knoten
* (max. MAERER). Jeder Knoten besteht aus MAERER Referenzen
* auf Subknoten und MAERER - 1 Schlüsseln.
*/
protected int MAERER = 5;
/*
* contains the reference to the CVisualizeTree class. It will be set in
* the constructor.
*/
protected CVisualizeTree mpCVisualizeTree;
/*
* contains the reference to the CExtendedCanvas class. It will be set in
* the constructor.
*/
protected CExtendedCanvas mpCExtendedCanvas;
/*
* Referenz zum Wurzelknoten während der Lebensdauer des Baums.
*/
protected CBNode root;
/*
* speichert den neuen Schluessel, der in den baum eingefügt werden soll.
*/
protected Integer newValue;
/*
* Indikator fuer den Wurzelknoten.
*/
private int rootflag = 0;
// Public Operationen
// Der Konstruktor umfasst zwei Paramerte zur Visualisierung
/*
public CBayerTree(CExtendedCanvas pCExtendedCanvas,
CVisualizeTree pCVisualizeTree);
Einfuegen
public int Insert(Integer value);
;
Rueckgabe: -1, falls Fehlversuch; anderenfalls 0
Parameter: einzufuegender Schluessel
Loeschen
public int Delete(Integer delValue);
;
Rueckgabe: -1, falls Fehlversuch; anderenfalls 0
Parameter: einzufuegender Schluessel
public boolean searchkey(int key);
Rueckgabe: true, falls der Schlüssel gefunden wurde; anderenfalls
false
Geschuetzte Methoden
protected CBNode insrekurs(CBNode tempnode, Integer insValue);
Private Methoden
private int delrekurs(CBNode delNode, Integer value);
.....
*/
}
/* Rueckgabe ist true, falls der Schlüssel gefunden wurde;
anderenfalls false. */
public boolean searchkey(int key)
{ boolean found = false;
int i = 0, n;
CBNode node = root;
while(node != null)
257
Algorithmen und Datenstrukturen
{ i = 0; n = node.count; // search key in actual node
while(i < n && key > ((Integer)node.key.elementAt(i)).intValue())
{ i++; }
// end while
if(i < n && key == ((Integer)node.key.elementAt(i)).intValue())
{ found = true; }
if(node.nodeptr.elementAt(i) != null)
{ mpCVisualizeTree.MoveLevelDown(i);}
node = (CBNode) node.nodeptr.elementAt(i);
} // end while
(node != null)
return found; }
// end searchkey Methode
4.4.4 Einfügen
Bsp.: Der Einfügevorgang in einem Bayer-Baum der Klasse 2
1) Aufnahme der Schlüssel 1, 2, 3, 4 in den Wurzelknoten
1
3
2
4
2) Zusätzlich wird der Schlüssel mit dem Wert 5 eingefügt
1
3
2
4
5
Normalerweise würde jetzt rechts von "4" ein neuer Knotem erzeugt. Das würde aber zu einem
Knotenüberlauf führen. Nach dieser Erweiterung enthält der Knoten eine ungerade Zahl von
Elementen ( 2 ⋅ M + 1 ). Dieser große Knoten kann in 2 Söhne zerlegt werden, nur das mittlere
Element verbleibt im Vaterknoten. Die neuen Knoten genügen wieder den B-Baum-Eigenschaften und
könnem weitere Daten aufnehmen.
3
1
2
4
5
Abb.:
Beschreibung des Algorithmus für das Einfügen
Ein neues Element wird grundsätzlich in einen Blattknoten eingefügt. Ist der Knoten
mit 2 ⋅ M Schlüsseln voll, so läuft bei der Aufnahme eines weiteren Schlüssels der
Knoten über.
258
Algorithmen und Datenstrukturen
+---------------------------------------------+
|
|
| ...........
SX-1ZX-1SXZX
.....
|
|
|
+---------------------------------------------+
+----------------------------------------------+
|
|
| Z0S1Z1 .... ZM-1SMZMSM+1 ....... Z2MS2M+1
|
|
Überlauf
|
+----------------------------------------------+
Abb.:
Der Knoten wird geteilt:
Die vorderen Schlüssel verbleiben im alten Knoten, der Schlüssel mit der Nummer
M+1 gelangt als Trennschlüssel in den Vorgängerknoten. Die M Schlüssel mit den
Nummern M+2 bis 2 ⋅ M + 1 kommen in den neuen Knoten.
+-----------------------------+
| ....SX-1ZX-1SM+1ZYSXZX .... |
+-----------------------------+
+------------------+
|Z0S1 .... ZM-1SMZM|
+------------------+
+-----------------------------+
|ZM+1SM+2ZM+2 ..... S2M+1Z2M+1|
+-----------------------------+
Abb.:
Die geteilten Knoten enthalten genau M Elemente. Das Einfügen eines Elements in
der vorangehenden Seite kann diese ebenfalls zum Überlaufen bringen und somit die
Aufteilung fortsetzen. Der B-Baum wächst demnach von den Blättern bis zur Wurzel.
Methoden zum Einfügen in Java
/*
*Einfügen eines neuen Schlüssels. Rückgabe ist –1, falls
* es misslingt, anderenfalls 0
* Parameter: value
einzufuegender Wert
*/
public int Insert(Integer value)
{
if(root == null)
{
root = new CBNode(MAERER);
root.key.setElementAt((Object)value, 0);
root.count = 1;
} // end if
else
{
if(searchkey(((Integer)value).intValue()) == true)
{
//System.out.println("double key found and will be ignored!");
return -1;
} // end if
CBNode result;
result = insrekurs(root, value);
if(result != null)
{
CBNode node = new CBNode(MAERER);
node.key.setElementAt(newValue, 0);
259
Algorithmen und Datenstrukturen
node.nodeptr.setElementAt(root, 0);
node.nodeptr.setElementAt(result, 1);
node.count = 1;
root = node;
}
// end if(result)
}
// end else
mpCVisualizeTree.DeleteRootKnot();
mpCExtendedCanvas.repaint();
rootflag = 0;
this.drawTree(root);
mpCExtendedCanvas.repaint();
return 0;
}
// end Insert() Methode
Die Methode „Insert“ ruft „insrekurs()“ auf. Diese rekursive Methode leistet die
eigentliche Arbeit
/*
* der neue Wert wird rekursiv in den Baum eingebracht
*/
protected CBNode insrekurs(CBNode tempnode, Integer insValue)
{
CBNode result;
result = null;
newValue = insValue;
if(tempnode.nodeptr.elementAt(0) != null) // kein Blatt -> Rekursion
{
int pos = 0;
while(pos < tempnode.count && newValue.intValue() >
((Integer)tempnode.key.elementAt(pos)).intValue())
{
pos++;
} // end while
result = insrekurs((CBNode)tempnode.nodeptr.elementAt(pos), newValue);
if(result == null)
{
return null; // if result = null: nothing has to be inserted into
// node-> finished!
} // end if(resul == null)
} // end if(tempnode.nodeptr.elementAt(0) != null)
// insert a element
CBNode node = null;
int flag = 0;
int s = tempnode.count;
if(s >= MAERER - 1)
// split the knot
{
tempnode.count = (MAERER - 1) / 2;
node = new CBNode(MAERER);
node.count = (MAERER - 1) / 2;
for(int d = ((MAERER - 1) / 2); d > 0;)
{
if(flag != 0 || ((Integer)tempnode.key.elementAt(s - 1)).intValue() >
newValue.intValue())
{
node.nodeptr.setElementAt(tempnode.nodeptr.elementAt(s), d);
node.key.setElementAt(tempnode.key.elementAt(--s), --d);
} // end if(flag != 0 ...
else
{
node.nodeptr.setElementAt(result, d);
node.key.setElementAt(newValue, --d);
flag = 1;
} // end else
}
// end if(s >= MAERER - 1)
if(flag != 0 || ((Integer)tempnode.key.elementAt(s - 1)).intValue() >
260
Algorithmen und Datenstrukturen
newValue.intValue())
{
node.nodeptr.setElementAt(tempnode.nodeptr.elementAt(s), 0);
} // end if
else
{
node.nodeptr.setElementAt(result, 0);
} // end else
} // end if(s >= MAERER - 1)
else
{
tempnode.count++;
} // end else
// shift
for(; s > 0 && ((Integer)tempnode.key.elementAt(s - 1)).intValue() >
newValue.intValue(); s--)
{
tempnode.nodeptr.setElementAt(tempnode.nodeptr.elementAt(s), s + 1);
tempnode.key.setElementAt(tempnode.key.elementAt(s - 1), s);
} // end for
tempnode.key.setElementAt(newValue, s);
tempnode.nodeptr.setElementAt(result, s + 1);
newValue = (Integer) tempnode.key.elementAt((MAERER - 1) / 2);
return node;
} // end insrekurs() methode
4.4.5 Löschen
Grundsätzlich ist zu unterscheiden:
1. Das zu löschende Element ist in einem Blattknoten
2. Das Element ist nicht in einem Blattknoten enthalten.
In diesem Fall ist es durch eines der benachbarten Elemente zu ersetzen. Entlang des rechts
stehenden Zeigers Z ist hier zum Blattknoten hinabzusteigen und das zu löschende Element durch
das äußere linke Element von Z zu ersetzen.
Auf jeden Fall darf die Anzahl der Schlüssel im Knoten nicht kleiner als M werden.
Ausgleichen
Die Unterlauf-Gegebenheit (Anzahl der Schlüssel ist kleiner als M) ist durch
Ausleihen oder "Angliedern" eines Elements von einem der benachbarten Knoten
abzustellen.
Zusammenlegen
Ist kein Element zum Angliedern übrig (, der benachbarte Knoten hat bereits die
minimale Größe erreicht), dann enthalten die beiden Knoten je 2 ⋅ M − 1 Elemente.
Beide Knoten können daher zusammengelegt werden. Das mittlere Element ist dazu
aus den dem Knoten vorausgehenden Knoten zu entnehmen und der NachbarKnoten ist ganz zu entfernen. Das Herausnehmen des mittleren Schlüssels in der
vorausgehenden Seite kann nochmals die Größe unter die erlaubte Grenze fallen
lassen und gegebenenfalls auf der nächsten Stufe eine weitere Aktion hervorrufen.
Bsp.: Gegeben ist ein 5-ärer B-Baum (der Ordnung 2) in folgender Gestalt:
261
Algorithmen und Datenstrukturen
50
30
10
20
35
40
60
42
38
44
46
56
58
65
80
70
90
95
Abb.:
1) Löschen der Schlüssel 44, 80
50
30
10
20
35
40
38
42
56
46
58
65
Abb.:
2) Einfügen des Schlüssels 99, Löschen des Schlüssels 70 mit Ausgleichen
50
30 40
10 20
35 38
60
42 46
90
60
56 58
Abb.:
262
95
65 90
96
99
70
95
96
96
Algorithmen und Datenstrukturen
3) Löschen des Schlüssels 35 mit Zusammenlegen
50
40
10 20 30
60
38
42 46
56 58
95
65 90
96
99
65 90
96
99
Abb.:
40
10 20 30
38
42 46
50
60
95
56 58
Abb.:
Löschen eines Schlüssels im Blattknoten
1. Fall: Im Blattknoten befinden sich mehr als die kleinste zulässige Anzahl von Schlüsselelementen.
Der Schlüssel kann einfach entfernt werden, die rechts davon befindlichen Elemente werden
einfach eine Position nach links verschoben.
2. Fall: Das Blatt enthält genau nur noch die kleinste zulässige Anzahl von Schlüsselelementen,
Nachbachknoten auf der Ebene der Blattknoten enthalten mehr als die kleinste zulässige Anzahl
von Schlüsselelementen. Der Schlüssel wird gelöscht, im Blatt liegt dann ein „Unterlauf“ vor. Man
versucht aus den linken oder rechten Nachbarknoten ein Element zu besorgen, z.B.: Es liegt im
Anschluß an einen (rekursiven) Aufruf, der in einem Blattknoten einen Schlüssel entfernt hat,
folgende Situation vor:
20
10
12
30
15
40
25
33
34
36
46
48
Abb.:
Die Entnahme geeigneter Schlüssel kann hier aus dem linken bzw. aus dem rechten Nachbarn vom
betroffenen Knoten erfolgen:
20
10
12
15
33
40
25
30
34 36
Abb.:
263
46
48
Algorithmen und Datenstrukturen
Falls vorhanden, soll immer der rechte Nachbarknoten gewählt werden. Im vorliegenden Beispiel ist
das nicht möglich beim Löschen der Schlüsselwerte „46“ bzw. „48“. In solchen Fällen wird dem Linken
Knoten ein Element entnommen.
3. Fall: Das Blatt enthält genau die kleinste mögliche Anzahl an Elementen, Nachbarknoten auf der
Ebene der Blattknoten enthalten auch nur genau die kleinste mögliche Anzahl an Elementen.
In diesem Fall müssen die betroffenen Knoten miteinander verbunden werden, z.B. liegt im Anschluß
an einen Aufruf, der in einem Blattknoten einen Schlüssel entfernt hat, folgende Situation vor:
20
10
30
12 15
40
25
34
36
46
48
Die Verbindung zu einem Knoten mit zulässiger Anzahl von Schlüsselelementen kann so vollzogen
werden:
20
10
12
40
15
25
30
34
36
46
48
Abb.:
Löschen eines Schlüssels in einem inneren Knoten
Solches Löschen kann aus einem Löschvorgang in einem Blattknoten resultieren.
Bsp.:
15
3
1 2
4
6
5
20
10 12
18 19
60
22 30
70
80
22
70 80
Abb.:
Das Löschen vom Schlüssel mit dem Wert 1 ergibt:
6
2
3
4
5
10
15
12
20
60
18 19
30
Abb.:
Im übergeordneten Knoten kann es erneut zu einer Unterlauf-Gegebenheit kommen. Es ist wieder ein
Verbinden bzw. Borgen mit / von Nachbarknoten erforderlich, bis man schließlich an der Wurzel
264
Algorithmen und Datenstrukturen
angelangt ist. Im Wurzelknoten kann nur ein Element sein. Wird dieses Element in den
Verknüpfungsvorgang der beiden unmittelbaren Nachfolger einbezogen, dann wird der Wurzelknoten
gelöscht, die Höhe des Baums nimmt ab.
Eine Löschoperation kann auch direkt in einem internen Knoten beginnen, z.B. wird im folgenden
Bayer-Baum im Wurzelknoten der Schlüssel mit dem Wert „15“ gelöscht.
15
3
1 2
4
6
5
20
10 12
18 19
60
22 30
70
80
Abb.:
Zuerst wird zum linken Nachfolger gewechselt, anschließend wird der Baum bis zum Blatt nach rechts
durchlaufen. In diesem Blatt wird dann das am weitesten rechts stehenden Datum aufgesucht und mit
dem zu löschenden Element im Ausgangsknoten getauscht.
12
3
1 2
4
6
5
20
10 15
18 19
60
22 30
70
80
Abb.:
Der Schlüssel mit dem Wert „15“ kann jetzt nach einer bereits beschriebenen Methode gelöscht
werden.
Implementierung in Java
Grundsätzlich ist zu unterscheiden:
1. Das zu löschende Element ist in einem Blattknoten
2. Das Element ist nicht in einem Blattknoten enthalten.
In diesem Fall ist es durch eines der benachbarten Elemente zu ersetzen. Entlang des rechts
stehenden Zeigers Z ist hier zum Blattknoten hinabzusteigen und das zu löschende Element
durch das äußere linke Element von Z zu ersetzen.
Auf jeden Fall darf die Anzahl der Schlüssel im Knoten nicht kleiner als M werden.
/*
* loescht einen Schlüssel. Rückgabe ist
* anderenfalls 0.
* Parameter: zu löschender Wert
*/
public int Delete(Integer delValue)
265
-1, falls es misslingt;
Algorithmen und Datenstrukturen
{
mpCVisualizeTree.MoveToRoot();
if(searchkey(((Integer)delValue).intValue()) == false)
{ return -1; } // end if
if(delrekurs(root, delValue) == 0)
{
CBNode temp = root;
root = (CBNode)root.nodeptr.elementAt(0);
temp = null;
} // end if
mpCVisualizeTree.DeleteRootKnot();
mpCExtendedCanvas.repaint();
rootflag = 0;
this.drawTree(root);
mpCExtendedCanvas.repaint();
return 0;
} // end Delete() Methode
Die eigentliche Arbeit beim Löschen übernimmt die rekursive Methode „delrekurs()“
/* deletes the value rekursively from the tree. *
private int delrekurs(CBNode delNode, Integer value)
{
int result = 0, pos, found = 0;
Vector nullVec = new Vector();
nullVec = null;
if(delNode != null)
{
CBNode node;
for(pos = 0; pos < delNode.count; pos++)
{
if(value.intValue() <=
((Integer)delNode.key.elementAt(pos)).intValue())
{
if(value.intValue() <
((Integer)delNode.key.elementAt(pos)).intValue())
{
result = delrekurs((CBNode)delNode.nodeptr.elementAt(pos),
(Integer)value);
} // end if
else
{
if(delNode.nodeptr.elementAt(0) == null)
// leaf node!
{
for(; (pos + 1) < delNode.count; pos++)
{
delNode.key.setElementAt(delNode.key.elementAt(pos + 1), pos);
} // end for
//System.out.println("Ende weil Blatt!");
return --delNode.count;
} // end if
else
{
node = (CBNode)delNode.nodeptr.elementAt(pos + 1);
while(node.nodeptr.elementAt(0) != null)
{
node = (CBNode)node.nodeptr.elementAt(0);
} // end while
delNode.key.setElementAt(node.key.elementAt(0), pos);
//System.out.println("Tausch, loesche rechts");
pos++;
result = delrekurs((CBNode)delNode.nodeptr.elementAt(pos),
(Integer)delNode.key.elementAt(pos - 1));
} // end else
} // end else
266
Algorithmen und Datenstrukturen
found = 1;
break;
} // end if
} // end for
if(found == 0)
{
result = delrekurs((CBNode)delNode.nodeptr.elementAt(pos),
(Integer)value);
} // end if
if(result < ((MAERER - 1) / 2))
{
CBNode temp;
int l = 0, r = 0;
if(pos > 0)
{ l = ((CBNode)delNode.nodeptr.elementAt(pos - 1)).count; } // end if
if(pos < delNode.count)
{
r = ((CBNode)delNode.nodeptr.elementAt(pos + 1)).count;
} // end if
node = (CBNode)delNode.nodeptr.elementAt(pos);
if(l > (MAERER - 1) / 2)
// steal a key from left
{
//System.out.println("klaue einen Schluessel von links");
temp = (CBNode)delNode.nodeptr.elementAt(pos - 1);
for(int i = node.count; i > 0; i--)
{
node.key.setElementAt(node.key.elementAt(i - 1), i);
node.nodeptr.setElementAt(node.nodeptr.elementAt(i), i + 1);
} // end for
node.nodeptr.setElementAt(node.nodeptr.elementAt(0), 1);
node.count++;
node.key.setElementAt(delNode.key.elementAt(pos - 1), 0);
node.nodeptr.setElementAt(temp.nodeptr.elementAt(temp.count), 0);
delNode.key.setElementAt(temp.key.elementAt(--temp.count), pos - 1);
} // end if
else
{
if(r > (MAERER - 1) / 2)
// steal a key from right
{
// System.out.println("klaue Schluessel von rechts");
temp = (CBNode)delNode.nodeptr.elementAt(pos + 1);
node.key.setElementAt(delNode.key.elementAt(pos), node.count);
node.count++;
node.nodeptr.setElementAt(temp.nodeptr.elementAt(0), node.count);
delNode.key.setElementAt(temp.key.elementAt(0), pos);
for(int i = 1; i < temp.count; i++)
{
temp.key.setElementAt(temp.key.elementAt(i), i - 1);
temp.nodeptr.setElementAt(temp.nodeptr.elementAt(i), i - 1);
} // end for
temp.nodeptr.setElementAt(temp.nodeptr.elementAt(temp.count),
temp.count - 1);
temp.count--;
} // end if
else
{
//System.out.println("Knoten verschmelzen !");
if(r == 0) { pos--; } // end if
node = (CBNode)delNode.nodeptr.elementAt(pos);
temp = (CBNode)delNode.nodeptr.elementAt(pos + 1);
node.key.setElementAt(delNode.key.elementAt(pos),
node.count++);
node.nodeptr.setElementAt(temp.nodeptr.elementAt(0), node.count);
for(int i = 0; i < temp.count; i++)
{
267
Algorithmen und Datenstrukturen
node.key.setElementAt(temp.key.elementAt(i), node.count++);
node.nodeptr.setElementAt(temp.nodeptr.elementAt(i + 1),
node.count);
} // end for
// System.out.println(
//
"Jetzt kann der Knoten rechts daneben entfernt werden!");
delNode.count--;
// delNode.nodeptr.elementAt(pos + 1) = null; // geht das so?
delNode.nodeptr.setElementAt((Object)nullVec, pos + 1);
while(pos < delNode.count)
{
delNode.key.setElementAt(delNode.key.elementAt(pos + 1), pos);
pos++;
delNode.nodeptr.setElementAt(delNode.nodeptr.elementAt(pos + 1),
pos);
} // end while
} // end else
} // end else
} // end if
return delNode.count;
} // end if
else
{ return 0; } // end else
} // end delrekurs() Methode
4.4.6 Demonstration eines 3ären bzw. 5ären Bayer-Baums
Mit dem Aufruf der folgenden HTML-Seite 152 kann ein Java-Applet gestartet werden,
das 3äre bzw. 5äre Bäume darstellt.
152
http://fbim.fh-regensburg.de/~saj39122/AD/index.html
268
Algorithmen und Datenstrukturen
269
Algorithmen und Datenstrukturen
4.4.6 Auf Platte/ Diskette gespeicherte Datensätze
Der Ausgangspunkt zu B-Bäumen war die Verwaltung der Schlüssel zu Datensätzen
in umfangreichen Dateien. In der Regel will man ja nicht nur einfache Zahlen (d.h.
einzelne Daten), sondern ganze Datensätze speichern. Eine größere Anzahl von
Datensätzen einer solchen Datei ist aber im Arbeitsspeicher (, der ja noch Teile des
Betriebssystems, das Programm etc. enthalten muß,) nicht unterzubringen.
Notwendig ist die Auslagerung von einem beträchtlichen Teil der Datensätze auf
einen externen Speicher. Dort sind die Datensätze in einer Datei gespeichert und in
"Seiten" zusammengefaßt. Eine Seite umfaßt die im Arbeitsspeicher adressierbare
Menge von Datensätzen (Umfang entspricht einem Bayer-Baumknoten). Aus
Vergleichsgründen soll hier die Anzahl der aufgenommenen bzw. aufzunehmenden
Datensätze die Zahl M = 2 nicht überschreiten. Es werden also mindestens 2, im
Höchstfall 4 Datensätze in eine Seite aufgenommen. Allgemein gilt: Je größer M
gewählt wird, umso mehr Arbeitsspeicherplatz wird benötigt, um so größer ist aber
auch die Verarbeitungsleistung des Programms infolge der geringeren Anzahl der
(relativ langsamen) Zugriffsoperationen auf externe Speicher.
Die 1. Seite der Datei (Datenbank) enthält Informationen für die Verwaltung.
Implementierung in C++
Die Verwaltung eines auf einer Datei hinterlegten Bayer-Baums übernimmt die
folgende Klasse BBaum:
#define MAERER 5
// Anzahl Verkettungen im Bayer-Baum Knoten
enum status {unvollstaendigesEinfuegen, erfolgreich, doppelterSchluessel,
Unterlauf, nichtGefunden};
typedef int dtype;
// Knoten eines auf einer Datei hinterlegten Bayer-Baums.
struct knoten {
int n;
// Anzahl der Elemente, die in einem Knoten
// Knoten gespeichert sind (n < MAERER)
dtype s[MAERER-1]; // Datenelemente (aktuell sind n)
long z[MAERER];
// 'Zeiger' auf andere Knoten (aktuell sind n+1)
};
// Logische Ordnung:
//
z[0], s[0], z[1], s[1], ..., z[n-1], s[n-1], z[n]
// Die Klasse zum auf einer Datei hinterlegten Bayer-Baum
class BBaum {
private:
enum {NIL=-1};
long wurzel, freieListe;
knoten wurzelKnoten;
fstream datei;
status einf(long w, dtype x, dtype &y, long &u);
void ausg(long w, int nLeer);
int knotenSuche(dtype x, const dtype *a, int n)const;
status loe(long w, dtype x);
void leseKnoten(long w, knoten &Knoten);
void schreibeKnoten(long w, const knoten &Knoten);
270
Algorithmen und Datenstrukturen
void leseStart();
long holeKnoten();
void freierKnoten(long w);
public:
BBaum(const char *BaumDateiname);
~BBaum();
void einfuegen(dtype x);
void einfuegen(const char *eingabeDateiname);
void gibAus(){cout << "Dateninhalt:\n"; ausg(wurzel, 0);}
void loeschen(dtype x);
void zeigeSuche(dtype x);
};
Zur Verwaltung des Bayer-Baums in einer Datei ist besonders wichtig:
- Die Wurzel wurzel (d. h. die Position des Wurzelknotens
- eine Liste mit Informationen über den freien Speicherplatz in der Datei (adressiert über
freieListe).
Zu solchen freien Speicherplätzen kann es beim Löschen von Schlüsselelementen
kommen. Zweckmäßigerweise wird dann dieser freie Speicherbereich nicht
aufgefüllt, sondern in einer Liste freier Speicherbereiche eingekettet. freieListe
zeigt auf das erste Element in dieser Liste. Solange das Programm läuft sind
„wurzel“ und „freieListe“ Datenelemente der Klasse BBaum (in einer Datei
abgelegter Bayer-Baum). Für die Belegung dieser Dateielemente wird am
Dateianfang (1.Seite) Speicherplatz reserviert. Am Ende der Programmausführung
werden die Werte zu „wurzel“ bzw. „freieListe“ in der Datei abgespeichert.
Bsp.: Der folgende Bayer-Baum
20
10
15
60
80
10
wurzel
15
60
80
20
freieListe
Abb.:
Zeiger haben hier ganzzahlige Werte des Typs long (mit -1L als NIL), die für die
Positionen und Bytenummern stehen.
271
Algorithmen und Datenstrukturen
4.4.7 B*-Bäume
Der B*-Baum entspricht einer geketteten sequentiellen Datei von Blättern, die einen
Indexteil besitzt, der selbst ein B-Baum ist. Im Indexteil werden insbesondere beim
Split-Vorgang die Operationen des B-Baums eingesetzt.
Hauptunterschied zu B-Bäumen: im inneren Knoten wird nur die Wegweiser-Funktion
ausgenutzt:
- innere Knoten führen nur (Si,Zi) als Einträge.
- Information (Si,Di) wird in den Blattknoten abgelegt. Dabei werden alle Schlüssel mit ihren
zugehörigen Daten in Sortierreihenfolge in den Blättern abgelegt.
- Für einige Si ergibt sich redundante Speicherung. Die inneren Knoten bilden einen Index, der einen
schnellen direkten Zugriff zu den Schlüsseln ermöglicht.
- Durch Verkettung aller Blattknoten lässt sich eine effiziente sequentielle Verarbeitung erreichen
. 12 .
.2.5.9.
1 2
3 4 5
6 7 8 9
Abb.: B*-Baum der Klasse
. 15 . 18 . 20 .
10 11 12
13 14 15
16 17 18
19 20
21 22 23
τ (2,2,3)
Definition. k, k* >0 und h* >= 0 sind ganze Zahlen. Ein B*-Baum der Klasse
τ (k , k *, h*) ist entweder ein leerer Baum oder ein geordneter Baum, für den gilt:
1. Jeder Pfad von der Wurzel zu einem Blatt besitzt die gleiche Länge h* - 1.
2. Jeder Knoten außer der Wurzel und den Blättern hat mindestens k + 1 Söhne, die Wurzel hat
mindestens 2 Söhne, außer wenn sie ein Blatt ist.
3. Jeder innere Knoten hat höchstens 2k + 1 Söhne.
4. Jeder Blattknoten mit Ausnahme der Wurzel als Blatt hat mindestens k* und höchstens 2k* Einträge
Unterscheidung zwischen zwei Knotenformaten:
l
M . S1 .
innere Knoten
k <=b <=2k
Z0
Blattknoten
k*<=m<=2k*
.........
Z1
Zb
M . . S1D1 S2D2
Zp
Sb . freier Platz
....
SmDm
freier Platz
Zn
M: enthält Kennung des Seitentyps sowie Zahl der aktuellen Einträge
Abb. Knotenformate im B*-Baum
Bestimmen von k bzw. k*
272
Algorithmen und Datenstrukturen
l = l M + l z + 2 ⋅ k ⋅ (l z + l S ) , k =
l − lM − lz
2 ⋅ (l S +l z )
l − lM − 2 ⋅ l z
2 ⋅ (l S +l D )
l = l M + 2 ⋅ l z + 2 ⋅ k * ⋅(l S + l D ) ; k * =
Höhe des B*-Baums: 1 + log 2 k +1 (
n
n
) ≤ h* ≤ 2 + log k +1 (
) für h* >= 2
2k *
2k *
Minimale bzw. maximale Anzahl von Knoten.
nmin = 2 ⋅ k * ⋅(k + 1) h*−2
nmax = 2 ⋅ k * ⋅(2k + 1) h*−1
Operationen. B*-Baum entspricht einer geketteten sequentiellen Datei von Blättern,
die einen Indexteil besitzt, der selbst ein B-Baum ist. Im Indexteil werden
insbesondere beim Split-Vorgang, die Operationen des B-Baums eingesetzt.
Grundoperationen beim B*-Baum.
(1) Direkte Suche: Da alle Schlüssel in den Blättern sind, kostet jede direkte Such h*
Zugriffe. h* ist jedoch im Mittel kleiner als h in B-Bäumen.
(2) Sequentielle Suche: Sie erfolgt nach Aufsuchen des Linksaußen der Struktur
unter Ausnutzung der Verkettung der Blattseiten. Es sind zwar gegebenenfalls mehr
Blätter als beim B-Baum zu verarbeiten, doch da nur h*-1 innere Knoten aufzusuchen
sind, wird die sequentielle Suche effizienter ablaufen.
(3) Einfügen: Von Durchführung und Leistungsverhalten dem Einfügen von BBäumen sehr ähnlich. Bei inneren Knoten wird die Spaltung analog zum B-Baum
durchgeführt. Beim Split-Vorgang einer Basis-Seite muß gewährleistet sein, dass
jeweils der höchste Schlüssel einer Seite als Wegweiser in den Vaterknoten kopiert
werden.
S2k*
S1D1 .... Sk*Dk* Sk*+1Dk*+1 ... S2k*D2k* SD
Sk* S2k*
S1D1 … Sk*Dk*
Sk*+1Dk*+1 … SD … S2k*D2k*
Bsp.: In den folgenden B*-Baum soll der Schlüssel 45 (einschl. Datenteil) eingefügt werden.
273
Algorithmen und Datenstrukturen
12 28 46 67
1 5 9 12
15 19 28
33 37 41 46
53 59 67
71 83 99
41
12 28
1 5 9 12
15 19
46 67
28
33
37
Abb.: Einfügen in einen B*-Baum der Klasse
41
45 46
53 59 67
71
83 99
τ (2,2,3)
(4) Löschen: Datenelemente werden immer von einem Blatt entfernt (keine komplexe
Fallunterscheidung wie beim B-Baum). Weiterhin muß beim Löschen eines
Schlüssels aus einem Blatt dieser Schlüssel nicht aus dem Indexteil entfernt werden,
er behält seine Funktion als Wegweiser.
Bsp.: Löschen der Schüssel 28, 41, 46 (einschl. der zugehörigen Datenteile) im zuletzt angegebenen
B*-Baum der Klasse τ ( 2,2,3)
41
12 28
1 5 9 12
15 19
53 67
28
33
37
Abb.: Löschen in einen B*-Baum der Klasse
45 53
τ (2,2,3)
274
59 67
71
83 99
Algorithmen und Datenstrukturen
4.5 Digitale Suchbäume
Bei B-Bäumen führen variabel lange Zeichenketten zu Problemen. Falls der im Baum
zu verteilende Inhalt eine insgesamt relativ statische Struktur hat, dann kann diese
Struktur selbst die Schlüsselwerte (z.B. als Präfixfolge) bilden.
Die dabei entstehende Baumstruktur ist wiederun ein Mehrwegbaum, der aufgrund
einer möglichen Schlüsselwertunterteilungsform digital genannt wird.
4.5.1 Grundlagen und Definitionen
Ein digitaler Suchbaum (Digital Search Tree) ist eine Baumstruktur für die
Datenspeicherung und –suche, bei der die Schlüsselwerte die Anfangswertteile der
Daten selbst darstellen. Diese Datenteile werden im allg. als Kantenbewegungen
visualisiert und implementiert. Konkrete Formen digitaler Bäume sind der Trie und
der Patricia-Baum.
Der Trie leitet seine Bezeichnung von Information Retrieval ab 153. Diese
Baumstruktur eignet sich insbesondere für eine effiziente Suche in Zeichenketten, bei
dem die ersten Zeichen den jeweiligen Suchbegriff bzw. Schlüsselwert darstellen.
Die Konkatenation der jeweiligen Schlüsselwerte ergibt dann den Präfix der
gesuchten Zeichenkette.
Der Patricia-Baum hat seine Bezeichnung von dem Akronym "Practical Algorithm To
Retrieve Information Coded in Alphanumeric". Sein Prinzip ist die Möglichkeit des
"Überspringens" von Teilworten im Suchbaum. Das wird dadurch erreicht, dass
Präfixinhalte in den Suchknoten selbst gespeichert werden.
Das Prinzip digitaler Suchbäume ist
- Zerlegung des Schlüssels – bestehend aus Zeichen eines Alphabets – in Teile
- Aufbau des Baums nach Schlüsselteilen
- Suche im Baum durch Vergleich von Schlüsselteilen
- Jede unterschiedliche Folge von Teilschlüsseln ergibt eigenen Suchweg im Baum
- Alle Schlüssel mit dem gleichen Präfix haben in der Länge des Präfix den gleichen Suchweg.
- vorteilhaft u.a. bei variabel langen Schlüsseln, z.B. Strings
Schlüssselteile können gebildet werden durch Elemente (Bits, Zeichen, Ziffern) eines
Alphabets oder durch Zusammenfassungen dieser Grundelemente (z.B. Silben der
Länge k).
l
+ 1 , wenn l die maximale
Die Höhe des Baums (z.B. Silben der Länge k) ist
k
Schlüssellänge und k die Schlüsselteillänge ist.
153
wird aber wie "try" gesprochen
275
Algorithmen und Datenstrukturen
4.5.2 Tries
Ein Trie ist eine auf Bäumen basierte Datenstruktur, um Worte (strings) zu speichern.
Auf Tries lässt sich schnelles pattern matching anwenden. Darau ergibt sich die
Hauptanwendung von Tries, das Wiedererlangen (retrieval) von Informationen.
Tries sind spezielle m-Wege-Bäume, wobei Kardinalität und Länge k der
Schlüsselteile den Grad m festlegen.
Standard-Tries
Definition: Falls S sine Menge von k Strings ist im Alphabet Σ ist, dann ist eine
Standard Trie ein geordneter Baum T mit folgenden Eigenschaften:
1. Jeder Knoten von T – mit Ausnahne der Wurzel – ist mit einem Zeichen von Σ versehen.
2. Kinder eines internen Knotens sind kanonisch angeordnet.
3. Kein String in S ist Präfix eines anderen Strings.
4. T besitzt k externe Knoten, die jeweils einen String von S repräsentieren. Die Aneinanerreihung der
Knotenbezeichnungen auf dem Weg von der Wurzel zu einem externen Knoten v von T ergibt den
String von S, den v repräsentiert.
Bsp.: Standard-Trie für die Strings {ANGEL, ART, AUTO, BUS, BUSCH} (Alphabet
Σ = Großbuchstaben A ... Z.
A
B
R
N
U
U
T
G
T
S
O
C
E
S
L
T
H
Zwar scheinen im obigen Bsp. die Punkte 3 (da {BUS , BUSCH } ∈ S ) und 4 (da k = 6, aber T nur 5
externen Knoten besitzt) aus der Definition verletzt zu sein, jedoch kann der Trie auch dargestellt
werden, indem jedem String aus S ein zusätzliches, nicht in Σ enthaltendes Zeichen - z.B. $ hinzugefügt wird, das das Ende des Strings repräsentiert. Im obigen Bsp. ist ein Knoten, an dem ein
String endet, als eckiger Knoten dargestellt 154.
Einfügen (insert()) für einen String s[1..n] in einen Trie:
public void insert(Trie t, String s)
// der aktuelle Knoten Ist die Wurzel
{
for (int n = 0; n < s.length(); n++)
{
int index = s.charAt(n) – 'a';
if (t.next[index] == null)
154
von t)
// Ueberpruefe fuer alle s[i] ...
// .. ob der aktuelle Knoten einen
// Zeiger auf Knoten besitzt, der
entspricht in der Implementierung dem Setzen eines Flags isWord
276
Algorithmen und Datenstrukturen
{
t.next[index] = new Trie();
}
t = t.next[index];
}
t.isWord = true;
}
//
//
//
//
//
//
s[i] repraesentiert. Falls nicht
fuege diesen Knoten ein
Setze den aktuellen Knoten auf
eben dieses Kind des aktuellen Knotens
Setze beim Knoten, der s[n] repraesentiert, das Flag isWord.
insert() besitzt eine Laufzeit O(n), wobei n die Länge des einzufügenden String ist.
Suchen (search()) nach dem Vorhandensein eines Strings s[1..n]:
public boolean search(Trie t, String s)
// der aktuelle Knoten Ist die Wurzel von t)
{
for (int n = 0; n < s.length();n++) // Ueberpruefe fuer alle s[j] ..
{
int index = s.charAt(n) – 'a'; // .. ob der aktuelle Knoten einen
if (t.next[index] == null)
// Zeiger auf Knoten besitzt, der
{
// s[i] repraesentiert. Falls nicht,
return false;
// gib false zurueck
}
// Setze den aktuellen Knoten auf
t = t.next[index];
// eben dieses Kind des aktuellen Knotens
}
// Gib beim Knoten, der s[n] repraesent.isWord = true;
// tiert, das Flag isWord zurueck.
}
Das Suchen eines Strings s der Länge n in Standard Tries lässt sich in O(n) realisieren
Präfix-Suche (isPrefix()) zur Überprüfung auf das Vorhandensein eines Präfix: Die
Präfix-Suche entspricht der Implementierung des search()-Algorithmus mit dem
einzigen Unterschied, dass die Rückgabe von isWord durch return true ersetzt wird.
Überprüfen, ob ein Knoten t eines Standard Tries extern 155 ist, durch isEmpty():
public boolean isEmpty(Trie t)
{
for (int d = 0; d < ALPH – 1; d++)
// Ueberpruefe fuer alle ALPH − Σ Zeiger, ob diese auf null ges. sind.
if (t.next[d] != null) return false;
// Falls nicht, gebe false …
return true;
// sonst true zurueck
}
Falls d die Länge des zugrundeliegenden Alphabets
Σ ist, besitzt isEmpty() eine Laufzeit von O(d).
Löschen (delete()) eines Strings s aus einem Standard Trie t:
public void delete(Trie t,String s)
{
if (!search(s)) // Ueberpruefe zunaechst, ob der zu loeschende String
{
// im Trie vorhanden ist
return;
// falls nicht, rufe doDelete auf
}
doDelete(s,0,t,t.next[charAt(0) – 'a']);
}
void doDelete(String s, int n, Trie prev, Trie current)
{
if (n < s.length() – 1)) // gehe zuerst rekursiv zum Knoten, der s[n]
{
// repraesentiert
155
d.h. alle Zeiger dieses Knotens sind auf Null gesetzt.
277
Algorithmen und Datenstrukturen
doDelete(s,(n+1),current,current.next[s.charAt(n+1)-'a']);
}
if (n == s.length() – 1) current.isWord = false;
// Loesche an diesem Knoten das Flag
if (current.isEmpty() && (current.isWord == false)) // Ist dieser Knoten
prev.next[s.charAt(k)-'a'] = null; // extern und repraesentiert keinen
} // keinen String aus s,loesche ihn und verfahre dann analog mit den
// darueberliegenden Knoten.
delete() besitzt eine Laufzeit von O(d – n), wobei n die Länge von s und d die Länge von
Σ ist.
m-äre Tries
Definition: Ein m-ärer Trie ist ein spezieller m-Wege-Baum, wobei Kardinalität des
Alphabets und Länge k der Schlüsselteile den Grad festlegen
- bei Ziffern: m = 10
- bei Alpha-Zeichen: m = 26; bie alphanumerischen Zeichen: m = 36
- bei Schlüsselteilen der Länge k potenziert sich der Grad, d.h. als Grad ergibt sich mk.
Darstellung
- Jeder Knoten eines Tries vom Grade m ist im Prinzip ein eindimensionale Vektor mit m Zeigern
- Jedes Element im Vektor ist einem Zeichen (bzw. Zeichenkombination) zugeordnet. Auf diese Weise
wird ein Schlüsselteil (Kante) implizit durch die Vektorposition ausgedrückt
P0 P1 P2 P3 P4 P5 P6 P7 P8 P9
m=10
k=1
Abb.: Knoten eines 10-ären Tries mit Ziffern als Schlüsselteilen
- implizite Zuordnung von Ziffer / Zeichen zu Zeiger (Referenz)
Pi gehört zur Ziffer i. Tritt Ziffer i in der betreffenden Position auf, so verweist Pi auf den
Nachfolgerknoten. Kommt i in der betreffenden Position nicht vor, so ist Pi mit NULL belegt.
- Falls der Knoten auf der j-ten Stufe eines 10-ären Tries liegt, dann zeigt Pi auf einen Unterbaum, der
nur Schlüssel enthält, die in der j-ten Position die Ziffer i besitzen.
Bsp.: Trie für Schlüssel aus einem auf A – E beschränkten Alphabet
$ 156 A B C D E
m=6
k=1
* *
* *
*
*
* * * * *
*
*
*
*
* * * * *
* * * *
* * *
*
* * * * *
Abb. Trie für Schlüssel aus einem auf A … E beschränkten Alphabet.
156
Trennzeichen: kennzeichnet Schlüsselende
278
*
*
* * * * *
* * * * *
Algorithmen und Datenstrukturen
Grundoperationen.
Direkte Suche: In der Wurzel wird nach dem 1. Zeichen des Suchschlüssels verglichen. Bei Gleichheit
wird der zugehörige Zeiger verfolgt. Im gefundenen Knoten wird nach dem 2. Zeichen verglichen usw.
Aufwand bei erfolgreicher Suche:
li
.
k
Löschen: Nach dem Aufsuchen des richtigen Knoten wird ein *-Zeiger auf NULL gesetzt. Besitzt
daraufhin der Knoten nur NULL-Zeiger, wird er aus dem Baum entfernt
4.5.3 Binäre Tries
Eine spezielle Form von Tries sind binäre Tries, die sich auf ein binäres Alphabet mit
den Zeichen {0,1} abstützen. Die Daten werden als Bitfolgen interpretiert, d.h. die
Verzweigung im Baum erfolgt in Abhängigkeit vom Wert der betrachteten Bitposition
4.5.4 Patricia Bäume (Compressed Tries)
Grundidee
Teile der Zeichenketten, die für den Vergleich bzw. das Verzweigen irrelevant sind,
werden übersprungen. Dies wird erreicht, in dem jeder Knoten die Anzahl der zu
überspringenden Bits bzw. Zeichen enthält. So lässt sich die Position in der
Zeichenkette bestimmen, die für die Entscheidung über den weiter zu verfolgenden
Pfad zu testen ist. Im ursprünglichen Verfahren wurde ein binärer Baum mit Bitfolgen
verwendet, es lässt sich jedoch auch ein Alphabet nutzen.
Merkmale.
- Speicherung aller Schlüssel in den Blättern
- innere Knoten speichern, wie viel Zeichen (Bits) beim Test zur Wegeauswahl zu überspringen sind
- Vermeidung von Einwegverzweigungen, in dem nur bei einem verbleibenden Schlüssel direkt auf
entsprechendes Blatt verwiesen wird.
3
e
a
Database
e
u
5
m
Datum
s
Datenbanken
Datenbankmodell
Datenbanksystem
Bewertung
- speichereffizient. Gegenüber den einfachen Tries ergibt sich eine deutlich komprimierte Darstellung.
Auch der Suchaufwand kann bei sehr langen und wenigen häufigen Worten reduziert werden.
- sehr gut geeignet für variable lange Schlüssel und (sehr lange) Binärdarstellungen von
Schlüsselwerten
279
Algorithmen und Datenstrukturen
- bei jedem Teilschlüssel muß die Testfolge von der Wurzel beginnend ganz ausgeführt werden, bevor
über Erfolg oder Misserfolg der Suche entschieden werden kann.
- Erfolgreiche und erfolglose Suche endet in einem Blattknoten, z.B.
-- Erfolgreiche Suche nach dem Schlüssel Heinz X'10010001000101100110011101011010'
-- Erfolglose Suche nach dem Schlüssel Abel
X'1000001100001010001011001100'
9
0
25
0
11
H
A
R
A
L
D
H
O
L
G
E
R
6
H
A
R
T
M
U
T
9
H
E
I
N
6
H
U
B
E
R
T
H
E
L
M
U
T
2
H
E
I
N
R
I
C
H
H
U
B
E
R
H
E
I
N
Z
n
H
U
B
E
R
T
U
S
Anzahl zu überspringenden Bits
Schlüssel
Abb.:
Präfix- bzw. Radix-Baum: Häufig benutzt man folgende Variante des Patricia-Baums:
- Speicherung variable langer Schlüsselteile in den inneren Knoten, sobald sie sich als Präfixe für die
Schlüssel des zugehörigen Unterbaums abspalten lassen
- erfolglose Suche lässt sich schon oft in einem inneren Knoten abbrechen.
Dat
3
a
e
base
u
nbank
5
e
m
n
odell
m
s
ystem
Abb.: Praefix-Baum
280
Algorithmen und Datenstrukturen
4.5.5 Suffix Tries
Ein Suffix Trie ist ein Compressed Trie, der aus allen Suffixes eines String s gebildet
4.5.6 Dateikompression mit dem Huffman-Algorithmus
Eine spezielle Form eines Trie ist der optimale Präfix-Baum, der mit Hilfe des
Huffman-Algorithmus 157 bestimmt wird.
Bsp.:
0
0
1
1
0
e
0
i
1
sp
1
a
0
1
t
0
s
1
nl
Abb. Optimaler Präfix-Code
Allgemeine Formulierung des Huffman-Algorithmus: Die Anzahl der Zeichen betrage
C. Daraus werden C Einzelbäume erstellt, deren Gewicht jeweils die Summe der
Häufigkeiten der Blätter darstellt (zu Beginn sind dies nur Einzelknoten). Dann
werden (C – 1)-mal jeweils zwei Bäume mit den geringsten Gewichten zu einem
neuen Baum zusammen, bis der optimale Code vorliegt.
Dateikompression. Der Huffman-Algorithmus kann zur Verdichtung bzw.
Kompression von zu speichernden Daten verwendet werden.
157
vgl. 3.2.1.2
281
Algorithmen und Datenstrukturen
5. Graphen und Graphenalgorithmen
5.1 Einführung
5.1.1 Grundlagen
Viele Objekte und Vorgänge in verschiedenen Bereichen besitzen den Charakter
eines Systems, d.h.: Sie setzen sich aus einer Anzahl von Bestandteilen, Elementen
zusammen, die in gewisser Weise in Beziehung stehen. Sollen an einem solchen
System Untersuchungen durchgeführt werden, dann ist es oft zweckmäßig, den
Gegenstand der Betrachtungen durch ein graphisches Schema (Modell) zu
veranschaulichen. Dabei stehen grundsätzlich immer 2 Elemente untereinander in
Beziehung, d.h.: Die Theorie des graphischen Modells ist ein Teil der Mengenlehre,
die binäre Relationen einer abzählbaren Menge mit sich selbst behandelt.
Bsp.: Es ist K = {A, B, C, D} eine endliche Menge. Es ist leicht die Menge aller
geordneten Paare von K zu bilden:
K × K = {(A,A),(A,B),(A,C),(A,D),(B,A),(B,B),(B,C),(B,D),(C,A),(C,B),(C,C),(C,D),(D,A),(D,B),D,C),(D,D)}
Gegenüber der Mengenlehre ist die Graphentheorie nicht autonom.
Die Graphentheorie besitzt ein eigenes, sehr weites und spezifisches Vokabular. Sie
umfaßt viele Anwendungsungsmöglichkeiten in der Physik, aus dem
Fernmeldewesen und dem Operations Research. Im OR sind es vor allem
Organisations- bzw. Verkehrs- und Transportprobleme, die mit Hilfe von
Graphenalgorithmen untersucht und gelöst werden.
Generell dienen Graphenalgorithmen in der Praxis zum Lösen von kombinatorischen
Problemen. Dabei geht man folgendermaßen vor:
1. Modelliere das Problem als Graph
2. Formuliere die Zielfunktion als Eigenschaft des Graphen
3. Löse das Problem mit Hilfe eines Graphenalgorithmus
Bsp.: Es ist K = {A,B,C,D} ein endliche Menge. Es ist leicht die Menge aller
geordneten Paare von K zu bilden:
K × K = {(A,A),(A,B),(A,C),(A,D),(B,A),(B,B),(B,C),(B,D),(C,A),(C,B),(C,C),(C,D),(D,A),(D,B),D,C),(D,D)}
Die Menge dieser Paare kann auf verschiedene Arten dargestellt werden:
282
Algorithmen und Datenstrukturen
1. Koordinatendarstellung
A
B
C
D
A
B
C
D
Abb.:
2. Darstellung durch Punkte (Kreise) und Kanten (ungerichteter Graph)
A
B
C
D
Abb.:
Eine Kante (A,A) nennt man Schlinge
3. Darstellung durch Punkte (Kreise) und Pfeile (gerichteter Graph)
A
B
D
C
Abb.:
Einen Pfeil (A,A) nennt man eine Schlinge. Zwei Pfeile mit identischem Anfangsund Endknoten nennt man parallel. Analog lassen sich parallele Kanten definieren.
Ein Graphen ohne parallele Kanten bzw. Pfeile und ohne Schlingen bezeichnet man
als schlichte Graphen.
283
Algorithmen und Datenstrukturen
4. Darstellung durch paarweise geordnete Paare
A
A
B
B
C
C
D
D
Abb
Ein bipartiter Graph ist ein Graph, dessen Knoten so in zwei Mengen zerteilt werden
können, dass jede Kante je einen Knoten aus beiden Mengen verbindet.
Probleme:
1. Herausfinden, ob ein Graph bipartit ist
2. Welches sind die Partitionen
Bipartites Matching: Bipartite Graphen dienen häufig zur Lösung
Zuordnungsproblemen, z.B. für Männer und Frauen in einem Tanzkurs.
Heini
von
Eva
Martin
Klaus
Maria
Pia
gematcht
Lilo
Uwe
Abb.: Jeder Teilnehmer im Tanzkurs ist ein Knoten im Graphen zugeordnet, jede Kante beschreibt
mögliche Tanzpartner. Drei Paare sind gefunden, aber nicht jeder Knoten hat einen Partner, und es
sind keine weiteren Paarungen möglich.
5. Darstellung mit Hilfe einer Matrix
A
B
C
D
A
1
1
1
1
B
1
1
1
1
C
1
1
1
1
D
1
1
1
1
284
Algorithmen und Datenstrukturen
Einige der geordneten Paare aus der Produktmenge K × K sollen eine bestimmte
Eigenschaft haben, während die anderen sie nicht besitzen.
Eine solche Untermenge von K × K ist :
G = {(A,B),(A,D),(B,B),(B,C),(B,D),(C,C),(D,A),(D,B),(D,C),(D,D)}
Üblicherweise wird diese Untermenge (Teilgraph) so dargestellt:
A
B
D
C
Abb.:
Betrachtet man hier die Paare z.B. (A,B) bzw. (A,D), so kann man feststellen: Von A
erreicht man, den Pfeilen folgend, direkt B oder D. B und D heißt auch die
"Inzidenzabbildung" von A und {B,D} das volle Bild von A.
Verwendet man das Symbol Γ zur Darstellung des vollen Bilds, dann kann man das
vorliegende Beispiel (vgl. Abb.:) so beschreiben:
Γ ( A) = ( B, D )
Γ ( B ) = ( B, C, D )
Γ( C ) = C
Γ ( D ) = ( A, B, C, D )
Zwei Kanten (Pfeile) werden benachbart oder adjazent genannt, wenn es einen
Knoten gibt, der Endknoten einen Kante und Anfangsknoten der anderen Kante ist.
Zwei Knoten heißen benachbart oder adjazent, wenn sie durch einen Kante (Pfeil)
unmittelbar verbunden sind.
Kanten (Pfeile), die denselben Anfangs- und Endknoten haben, heißen parallel.
285
Algorithmen und Datenstrukturen
5.1.2 Definitionen
Gegeben ist eine endliche (nicht leere) Menge K 158. Ist G eine Untermenge der
Produktmenge K × K , so nennt man ein Element der Menge K einen Knoten von G.
Die Elemente der Knotenmenge K können auf dem Papier durch Punkte (Kreise)
markiert werden.
Ein Element von G selbst ist eine (gerichtete) Kante. Im vorstehenden Bsp 159. sind
(A,B), (A,D), (B,B), (B,C), (B,D), (C,C), (D,A), (D,B), (D,C), (D,D) (gerichtete) Kanten.
Ein Graph wird durch die Menge seiner Knoten K und die seiner Inzidenzabbildungen
beschrieben: G=(K, Γ )
Ein Graph kann aber auch folgendermaßen beschrieben werden: G = (K,E). E ist die
Menge der Kanten (gerichtet, ungerichtet, gewichtet). In gewichteten Graphen
werden jeder Kante ganze Zahlen (Gewichte, z.B. zur Darstellung von Entfernungen
oder Kosten) zugewiesen. Gewichtete gerichtete Graphen werden auch Netzwerke
genannt.
Falls die Anzahl der Knoten in einem Graphen "n" ist, dann liegt die Anzahl der
n ⋅ ( n − 1)
im ungerichten Graphen. Ein gerichteter Graph
Kanten zwischen 0 und
2
kann bis zu n ⋅ ( n − 1) Pfeile besitzen.
Eingangsgrad: Zahl der ankommenden Kanten.
1
3
2
1
2
1
1
2
0
Abb.: Eingangsgrad
Ausgangsgrad: Zahl der abgehenden Kanten
2
1
0
1
2
0
1
2
3
Abb.: Ausgangsgrad
Bei ungerichteten Graphen ist der Ausgangsgrad gleich dem Eingangsgrad. Man
spricht dann nur von Grad.
Ein Pfad vom Knoten K1 zum Knoten Kk ist eine Folge von Knoten K1, K2, ... , Kk,
wobei (K1,K2), ... ,(Kk-1,Kk) Kanten sind. Die Länge des Pfads ist die Anzahl der
Kanten im Pfad. Auch Pfade können gerichtet oder ungerichtet sein.
158
159
Anstatt K schreibt man häufig auch V (vom englischen Wort Vertex abgeleitet)
vgl. 5.1.1
286
Algorithmen und Datenstrukturen
Kk
K1
Abb.:
Ein Graph ist zusammenhängend, wenn von jedem Knoten zu jedem anderen
Knoten im Graph ein Weg (Pfad) existiert.
X1
X5
X2
X4
X3
Abb.:
Dieser Graph ist streng zusammenhängend. Man kann sehen, daß es zwischen je 2
Knoten mindestens einen Weg gibt. Dies trifft auf den folgenden Grafen nicht zu:
X1
X6
X2
X3
X5
X4
Abb.:
Hier gibt es bspw. keinen Weg von X4 nach X1.
Ein Graph, der nicht zusammenhängend ist, setzt sich aus zusammenhängenden
Komponenten zusammen.
Ein Knoten in einem zusammenhängenden Netzwerk heißt Artikulationspunkt,
wenn durch sein Entfernen der Graph zerfällt, z.B.
287
Algorithmen und Datenstrukturen
Artikulationspunkte sind dunkel eingefärbt.
Abb.:
Erreichbarkeit: Der Knoten B ist in dem folgenden Graphen erreichbar vom Knoten
G, wenn es einen Pfad von G nach B gibt.
C
E
B
D
F
I
G
A
H
Abb.: Knoten B ist erreichbar von Knoten G
Ein Graph heißt Zyklus, wenn sein erster und letzter Knoten derselbe ist.
Abb.: Zyklus (manchmal auch geschlossener Pfad genannt)
Ein Zyklus ist ein einfacher Zyklus, wenn jeder Knoten (außer dem ersten und dem
letzten) nur einmal vorkommt.
Abb.: Einfacher Zyklus (manchmal auch geschlossener Pfad genannt)
288
Algorithmen und Datenstrukturen
Ein gerichteter Graph heißt azyklisch, wenn er keine Zyklen enthält. Ein azyklischer
Graph kann in Schichten eingeteilt werden (Stratifikation).
Bäume sind Graphen, die keine Zyklen enthalten. Graphen, die keine Zyklen
enthalten heißen Wald. Zusammenhängender Graphen, die keine Zyklen enthalten,
heißen Bäume. Wenn ein gerichteter Graph ein Baum ist und genau einen Knoten
mit Eingangsgrad 0 hat, heißt der Baum Wald.
Ein spannender Baum (Spannbaum) eines ungerichteten Graphen ist ein Teilgraph
des Graphen, und ist ein Baum der alle seine Knoten enthält.
Abb.
Einen spannenden Baum mit minimaler Summe der Kantenbewegungen bezeichnet
man als minimalen spannenden Baum. Zu dem folgenden Graphen
3
4
2
5
4
3
4
4
5
6
gehört der folgende minimale spannende Baum
Abb.: Minimaler spannender Baum
Eulersche Pfade bzw. Eulerscher Kreis: Ausgangspunkt dieses Problems ist das
sog. Königsberger Brückenproblem, das Leonard Euler 1736 gelöst hat. Euler
interpretierte dabei die Brücken über den Fluß Pregel in Königsberg als Kanten und
Ufer bzw. Inseln als Knoten.
289
Algorithmen und Datenstrukturen
neuer Pregel
Pregel
alter Pregel
Abb.: Königsberger Brückenproblem mit Darstellung als Graph
Königsberger Brückenproblem: Existiert ein Eulerscher Pfad?
Lösung: Da man, wenn man in einen Knoten hineinkommt, auf anderem Weg wieder
herauskommen muß, gilt als Bedingung: Der Grad jedes Knoten muß durch 2 teilbar
sein.
Neuformulierung des Problems: Gibt es einen Zyklus im Graphen, der alle Kanten
genau einmal enthält (Eulerkreis).
Bedingung für die Existenz eines Eulerkreises: Der Grad jedes Knoten muß durch 2
teilbar sein. Das Königsberger Brückenproblem stellt offenbar keinen Eulerkreis dar.
Bekanntes Bsp.: Kann das Häuschen der folgenden Abbildung in einem Strich
gezeichnet werden?
Abb. Haus des Nikolaus
Hamiltonsche Pfade: Gegeben ist eine Landkarte mit Orten und Verbindungen.
Gesucht ist ein Rundgang einmal durch jeden Ort.
290
Algorithmen und Datenstrukturen
Abb.: Hamiltonscher Kreis
Verschärfung: jede Verbindung ist mit Kosten gewichtet. Gesucht ist der billigste
Rundgang.
Ein Hamiltonscher Pfad ist ein einfacher Zyklus, der jeden Knoten eines Graphen
enthält. Ein Algorithmus für das Finden eines Hamiltonschen Graphen ist relativ
einfach (modifizierte Tiefensuche) aber sehr aufwendig. Bis heute ist kein
Algorithmus bekann, der eine Lösung in polynomialer Zeit findet.
5.1.3 Darstellung in Rechnerprogrammen
1. Der abstrakte Datentyp (ADT) für gewichtete Graphen
Ein gewichteter Graph besteht aus Knoten und gewichteten Kanten. Der ADT
beschreibt die Operationen, die einem solchen gewichteten Graphen Datenwerte
hinzufügen oder löschen. Für jeden Knoten Ki definiert der ADT alle benachbarten
Knoten, die mit Ki durch eine Kante E(Ki,Kj) verbunden sind.
ADT Graph
Daten
Sie umfassen eine Menge von Knoten {Ki} und Kanten {Ei}. Eine Kante ist ein
Paar (Ki, Kj), das anzeigt: Es gibt eine Verbindung vom Knoten Ki zum Knoten
Kj. Verbunden ist mit jeder Kante die Angabe eines Gewichts. Es bestimmt
den Aufwand, um entlang der Kante vom Knoten Ki nach dem Knoten Kj zu
kommen.
Operationen
Konstruktor
Eingabe: keine
Verarbeitung: Erzeugt den Graphen als Menge von Knoten und Kanten
Einfuegen_Knoten
Eingabe: Ein neuer Knoten
Vorbedingung: keine
Verarbeitung: Füge den Knoten in die Menge der Knoten ein
Ausgabe: keine
Nachbedingung: Die Knotenliste nimmt zu
Einfügen_Kante
Eingabe: Ein Knotenpaar Ki und Kj und ein Gewicht
Vorbedingung: Ki und Kj sind Teil der Knotenmenge
Verarbeitung: Füge die Kante (Ki,Kj) mit dem gewicht in die Menge der
Kanten ein.
Ausgabe: keine
Nachbedingung: Die Kantenliste nimmt zu
Loesche_Knoten
Eingabe: Eine Referenz für den Knoten Kl
Vorbedingung: Der Eingabewert muß in der Knotenmenge vorliegen
291
Algorithmen und Datenstrukturen
Verarbeitung: Lösche den Knoten aus der Knotenliste und lösche alle
Kanten der Form (K,Kl) bzw. (Kl,K), die eine Verbindung mit
Knoten Kl besitzen
Loesche_Kante
Eingabe: Ein Knotenpaar Ki und Kj
Vorbedingung: Der Eingabewert muß in der Kantenliste vorliegen
Verarbeitung: Falls (Ki,Kj) existiert, loesche diese Kante aus der
Kantenliste
Ausgabe: keine
Nachbedingung: Die Kantenmenge wird modifiziert
Hole_Nachbarn:
Eingabe: Ein Knoten K
Vorbedingung: keine
Verarbeitung: Bestimme alle Knoten Kn, so daß (K,Kn) eine Kante ist
Ausgabe: Liste mit solchen Kanten
Nachbedingung: keine
Hole_Gewichte
Eingabe: Ein Knotenpaar Ki und Kj
Vorbedingung: Der Eingabe wert muß zur Knotenmenge gehören
Verarbeitung: Beschaffe das Gewicht der Kante (Ki, Kj), falls es
existiert
Ausgabe: Gib das Gewicht dieser Kante aus (bzw. Null, falls die Kante
nicht existiert
Nachbedingung: keine
2. Abbildung der Graphen
Es gibt zahlreiche Möglichkeiten zur Abbildung von Knoten und Graphen in einem
Rechnerprogramm. Eine einfache Abbildung speichert die Knoten in einer
sequentiellen Liste. Die Kanten werden in einer Matrix beschrieben
(Adjazenzmatrix), in der Zeile i bzw. Spalte j den Knoten Ki und Kj zugeordnet sind.
Jeder Eintrag in der Matrix gibt das Gewicht der Kante Eij = (Ki,Kj) oder den Wert 0
an, falls die Kante nicht existiert. In ungewichteten, gerichteten Graphen hat der
Eintrag der (booleschen) Wert 0 oder 1, je nachdem, ob die Kante zwischen den
Knoten existiert oder nicht, z.B.:
2
A
B
3
5
1
4
E
C
7
D
0 2 1 0 0
0 0 5 0 0
0 4 0 0 0
0 0 7 0 0
0 3 0 0 0
292
Algorithmen und Datenstrukturen
B
A
C
D
E
0 1 1 1 0
1 0 1 0 0
1 0 0 0 0
0 0 0 0 1
0 0 1 0 0
Abb.:
In der Darstellungsform „Adjazenzstruktur“ werden für jeden Knoten alle mit ihm
verbundenen Knoten in eine Adjazenzliste für diese Knoten aufgelistet. Das läßt sich
leicht über verkettete Listen realisieren. In einem gewichteten Graph kann zu jedem
Listenelement ein Feld für das Gewicht hinzugefügt werden, z.B.:
2
A
B
3
5
1
4
E
C
7
Knoten:
D
Liste der Nachbarn:
A
B
2
B
C
5
C
B
4
D
C
7
E
B
3
C
1
293
Algorithmen und Datenstrukturen
B
A
C
D
E
A
B
C
B
A
C
C
A
D
E
E
C
D
Abb.:
3. Lösungsstrategien
Für die Lösung der Graphenprobleme stattet man die Algorithmen mit verschiedenen
Strategien aus:
- Greedy (sukzessive bestimmung der Lösungsvariablen)
- Divide and Conquer (Aufteilen, Lösen, Lösungen vereinigen)
- Dynamic Programming (Berechne Folgen von Teillösungen)
- Enumeration (Erzeuge alle Permutationen und überprüfe sie)
- Backtracking (Teillösungen werden systematisch erweitert)
- Branch and Bound (Erweitere Teillösungen an der vielversprechenden Stelle)
294
Algorithmen und Datenstrukturen
5.2 Durchlaufen von Graphen
Für manche Probleme ist es wichtig, alle Knoten in einem Graphen zu betrachten. So
kann man etwa einer in einem Labyrinth eingeschlossenen Person nachfühlen, dass
sie sämtliche Kreuzungen von Gängen in Augenschein nehmen will. Die Gänge des
Labyrinths sind hier die Kanten des Graphen, und Kreuzungen sind die Knoten.
5.2.1 Tiefensuche (depth-first search)
Bei der Tiefensuche bewegt man sich möglichst weit vom Startknoten weg, bevor
man die restlichen Knoten besucht. Trifft man auf einen Knoten, der keine
unbesuchten Nachbarn hat, so erfolgt "backtracking", d.h. die Suche wird beim
Vorgänger fortgesetzt. Dadurch werden alle vom Startknoten erreichbaren Knoten
gefunden.
Algorithmus: Als Eingabe benötigt der Algorithmus einen Graphen und einen
Startknoten.
- color[v]: repräsentiert den aktuellen Bearbeitungsstatus
weiß = unbesucht/unbearbeitet
schwarz = abgearbeitet (v und alle Nachbarn von v wurden besucht).
grau = in Bearbeitung (v wurde besucht, kann aber noch unbesuchte Nachbarn haben)
- p[v]: Vorgänger (predecessor) von v
- b[v]: Beginn der Suche (Einfügen des Knotens in den Stack bzw. Zeitpunkt des rekursiven Aufrufs)
- f[v]: Ende der Suche (Löschen des Knotens aus dem Stack bzw. Ende des rekursiven Aufrufs)
Die Knoten, die in Bearbeitung sind, werden in einem Stack K (LIFO) verwaltet.
for each vertex u ∈ V [G ] − {s}
do color[u ] ← WHITE
b[u ] ← ∞
f [u ] ← ∞
p[u ] ← NIL
time ← 1
color[ s ] ← GRAY
PUSH ( K , s )
b[ s ] ← time
p[ s] ← NIL
while K ≠ 0
do u ← TOP( K )
if ∃v ∈ Adj[u ] : color[v] = WHITE
then color[v] ← GRAY
PUSH ( K , v)
b[v] ← time ← time + 1
else POP( K )
color[u ] ← BLACK
f [u ] ← time ← time + 1
Komplexität: Das Initialisieren des Graphen dauert O( | V | ) Zugriffe auf den Stack und
die "Arrays" brauchen konstante Zeit (insgesamt O( | V | )), die Adjazenzliste wird
295
Algorithmen und Datenstrukturen
genau einmal durchlaufen (O( | E | ). Damit ergibt sich eine Gesamtlaufzeit von
O( | V | + | E | ).
Bsp.: Tiefensuche in ungerichteten Graphen
Anfangsschritt: für alle v ∈ V :
color[v] ← WHITE , b[v] ← ∞ , f [v] ← ∞ , p[v] ← NIL
Stack
u
v
w
x
y
z
1.Schritt: b[u ] = 1
Stack
u
v
w
x
y
z
u
2. Schritt: b[v ] = 2
Stack
u
v
w
x
y
z
v
u
3. Schritt: b[ w] = 3
Stack
u
v
w
w
x
y
v
u
z
296
Algorithmen und Datenstrukturen
4. Schritt:
b[ y ] = 4
Stack
u
v
w
y
w
x
y
v
u
z
5. Schritt: b[ x] = 5
Stack
x
u
v
w
y
w
x
y
v
u
z
6. Schritt: f [ x] = 6 , back edge zu u und v
Stack
u
v
w
y
w
x
y
v
u
z
7. Schritt: backtracking zu y, f [ y ] = 7
Stack
u
v
w
w
x
8. Schritt: backtracking zu w,
y
v
u
z
f [ z] = 8
Stack
u
v
w
z
w
x
y
v
u
z
297
Algorithmen und Datenstrukturen
9. Schritt:
f [ z] = 9
Stack
u
v
w
w
x
y
v
u
z
10. Schritt: backtracking zu w, f [ w] = 10
Stack
u
v
w
x
y
z
v
u
11. Schritt: backtracking zu v, f [ w] = 10
Stack
u
v
w
x
y
z
u
12. Schritt: backtracking zu w, f [u ] = 10
Stack
u
v
w
x
y
z
298
Algorithmen und Datenstrukturen
5.2.2 Breitensuche (breadth-first search)
Die Suche beginnt beim Startknoten, danach werden die Nachbarn der Startknoten
besucht, danach die Nachbarn der Nachbarn usw. Dadurch kann die Knotenmenge –
entsprechend ihrer minimalen Anzahl von Kanten zum Startknoten – in Level
unterteilt werden. In Level 0 befindet sich nur der Startknoten, Level 1 besteht aus
allen Nachbarn des Startknoten, usw.
Algorithmus. Eingaben sind ein Graph G=(V,E) und ein Startknoten s. Zu jedem
Knoten speichert man einige Daten:
color[v]: repräsentiert den aktuellen Bearbeitungsstatus
weiß = unbearbeitet / unbesucht
schwarz = abgearbeitet (v und alle Nachbarn von v wurden besucht)
grau = in Bearbeitung (v wurde besucht, kann aber noch unbesuchte Nachbarn haben
p[v]: Vorgänger (predecessor) von v
d[v]: Distanz zum Startknoten bzgl. der minimalen Kantenzahl
Zum Speichern der Knoten, die in Bearbeitung sind, wird eine Warteschlange Q (FIFO) verwendet.
u ∈ V [G ] − {s}
do color[u ] ← WHITE
d [u ] ← ∞
p[u ] ← NIL
color[ s ] ← GRAY
d [ s] ← 0
p[ s ] ← NIL
Q←s
while Q ≠ 0
// u ist erstes Element in Q
do u ← first[Q ]
// Nachbarn von u
for v ∈ Adj[u ]
do if color[v] ← WHITE
then color[v ] ← GRAY
d [v] ← d [u ] + 1
p[v] ← u
for each vertex
ENQUEUE(Q,v)
DEQUE(Q)
color[v] ← BLACK
// füge in Q ein
// lösche erstes Element aus Q
Komplexität. Das Initialisieren der Arrays dauert insgesamt O( | V | ). Die Operationen
auf der Liste (Einfügen und Löschen) und den Arrays brauchen konstante Zeit,
insgesamt O( | V | ). Das Durchsuchen der Adjazensliste dauert O( | E | ). Damit ergibt
sich eine Gesamtlaufzeit von O( | V | + | E | ).
Die
Breitensuche
konstruiert
einen
Baum,
der
die
BFS-Baum.
Zusammenhangskomponente des Startknotens aufspannt. Der Weg im Baum vom
Startknoten (Wurzel) zu den Nachfolgern entspricht dem kürzesten Weg bzgl. der
Kantenzahl im Graphen. Die Levelnummer des Knotens entspricht der Höhe im
Baum.
299
Algorithmen und Datenstrukturen
Bsp.: Breitensuche im ungerichteten Graphen
Anfangsschritt: für alle v ∈ V : color[v] ← WHITE , d [v ] = ∞ , p[v] ← NIL
r
s
t
u
v
w
x
y
1. Schritt: Startknoten wird grau markiert, Q = (s), d[s] = 0
r
s
t
u
v
w
x
y
2. Schritt: Q = (w,r), Level 0 abgearbeitet
r
s
t
u
v
w
x
y
r
s
t
u
v
w
x
y
t
u
3. Schritt: Q = (r,t,x)
4. Schritt: Q = (t,x,v), Level 1 abgearbeitet
r
s
v
w
x
y
300
Algorithmen und Datenstrukturen
5. Schritt: Q = (x,v,u)
r
s
v
w
t
u
x
y
6. Schritt: Q = (v,u,y)
r
s
v
w
t
u
x
y
7. Schritt: Q = (u,y)
r
s
v
w
r
s
v
w
t
u
x
y
8. Schritt: Q = (y)
t
u
x
y
9. Schritt: Q = (), Level 3 abgearbeitet
r
s
t
u
v
w
1
0
2
3
r
s
t
u
v
w
x
y
10. Schritt:
y
x
301
Algorithmen und Datenstrukturen
2
1
2
3
5.2.3 Implementierung
In der Klasse Graph sind Tiefensuche (Methode traverseDFS) und Breitensuche
(traverseBFS) implementiert 160.
import java.util.*;
/** Graphrepräsentation. */
/** Repräsentiert einen Knoten im Graphen. */
class Vertex
{
Object key = null; // Knotenbezeichner
LinkedList edges = null; // Liste ausgehender Kanten
/** Konstruktor */
public Vertex(Object key)
{ this.key = key; edges = new LinkedList(); }
/** Ueberschreibe Object.equals-Methode */
public boolean equals(Object obj)
{
if (obj == null) return false;
if (obj instanceof Vertex) return key.equals(((Vertex) obj).key);
else return key.equals(obj);
}
/** Ueberschreibe Object.hashCode-Methode */
public int hashCode()
{ return key.hashCode(); }
}
/** Repraesentiert eine Kante im Graphen. */
class Edge
{
Vertex dest = null; // Kantenzielknoten
int weight = 0; // Kantengewicht
/** Konstruktor */
public Edge(Vertex dest, int weight)
{
this.dest = dest; this.weight=weight;
}
}
class GraphException extends RuntimeException
{
public GraphException( String name )
{
super( name );
}
}
public class Graph
{
protected Hashtable vertices = null; // enthaelt alle Knoten des Graphen
/** Konstruktor */
public Graph() { vertices = new Hashtable(); }
/** Fuegt einen Knoten in den Graphen ein. */
public void addVertex(Object key)
{
if (! vertices.containsKey(key))
// throw new GraphException("Knoten existiert bereits!");
vertices.put(key, new Vertex(key));
}
160
vgl. pr52220
302
Algorithmen und Datenstrukturen
/** Fuegt eine Kante in den Graphen ein. */
public void addEdge(Object src, Object dest, int weight)
{
Vertex vsrc = (Vertex) vertices.get(src);
Vertex vdest = (Vertex) vertices.get(dest);
if (vsrc == null)
throw new GraphException("Ausgangsknoten existiert nicht!");
if (vdest == null)
throw new GraphException("Zielknoten existiert nicht!");
vsrc.edges.add(new Edge(vdest, weight));
}
/** Liefert einen Iterator ueber alle Knoten. */
public Iterator getVertices()
{ return vertices.values().iterator(); }
/** Liefert den zum Knotenbezeichner gehoerigen Knoten. */
public Vertex getVertex(Object key)
{
return (Vertex) vertices.get(key);
}
/** Liefert die Liste aller erreichbaren Knoten in Breitendurchlauf. */
public List traverseBFS(Object root)
{
LinkedList list = new LinkedList();
Hashtable d
= new Hashtable();
Hashtable pred = new Hashtable();
Hashtable color = new Hashtable();
Integer gray = new Integer(1);
Integer black = new Integer(2);
Queue q = new Queue();
Vertex v, u = null;
Iterator eIter = null;
//v = (Vertex)vertices.get(root);
color.put(root, gray);
d.put(root, new Integer(0));
q.enqueue(root);
while (! q.isEmpty())
{
v = (Vertex) vertices.get(((Vertex)q.firstEl()).key);
eIter = v.edges.iterator();
while(eIter.hasNext())
{
u = ((Edge)eIter.next()).dest;
// System.out.println(u.key.toString());
if (color.get(u) == null)
{
color.put(u, gray);
d.put(u, new Integer(((Integer)d.get(v)).intValue() + 1));
pred.put(u, v);
q.enqueue(u);
}
}
q.dequeue();
list.add(v);
color.put(v, black);
}
return list;
}
/** Liefert die Liste aller erreichbaren Knoten im Tiefendurchlauf. */
public List traverseDFS(Object root)
{
// Loesungsvorschlag: H. Auer
LinkedList list = new LinkedList();
// Hashtable d
= new Hashtable();
// Hashtable pred = new Hashtable();
Hashtable color = new Hashtable();
Integer gray = new Integer(1);
303
Algorithmen und Datenstrukturen
Integer black = new Integer(2);
Stack s = new Stack();
Vertex v, u = null;
Iterator eIter = null;
//v = (Vertex)vertices.get(root);
color.put(root, gray);
// d.put(root, new Integer(0));
s.push(root);
while (! s.empty())
{
v = (Vertex) vertices.get(((Vertex)s.peek()).key);
eIter = v.edges.iterator(); u = null; Vertex w;
while(eIter.hasNext())
{
w = ((Edge)eIter.next()).dest;
// System.out.println(u.key.toString());
if (color.get(w) == null) { u = w; break; }
}
if (u != null) { color.put(u, gray); s.push(u); }
else {
v = (Vertex) s.pop();
list.add(v);
color.put(v, black);
}
}
return list;
}
}
Anstatt einen Stapel explizit in die Tiefensuche einzubeziehen, kann man
Tiefensuche rekursiv so formulieren:
LinkedList liste = new LinkedList();
Hashtable color = new Hashtable();
Integer gray = new Integer(1);
Integer black = new Integer(2);
// Iterator eIter = null;
public List traverseDFSrek(Object root)
{
// LinkedList list = new LinkedList();
// Hashtable d
= new Hashtable();
// Hashtable pred = new Hashtable();
// Hashtable color = new Hashtable();
// Integer gray = new Integer(1);
// Integer black = new Integer(2);
// Stack s = new Stack();
Vertex v = (Vertex) root; Vertex u = null;
Iterator eIter = null;
//v = (Vertex)vertices.get(root);
color.put(root, gray);
// d.put(root, new Integer(0));
// s.push(root);
// while (! s.empty())
// {
// v = (Vertex) vertices.get(((Vertex)s.pop()).key);
// liste.add(v); // color.put(v,black);
eIter = v.edges.iterator();
while(eIter.hasNext())
{
u = ((Edge)eIter.next()).dest;
// System.out.println(u.key.toString());
if (color.get(u) == null)
{
color.put(u, gray);
traverseDFSrek(u);
304
Algorithmen und Datenstrukturen
}
}
liste.add(v);
// s.pop();
// list.add(v);
color.put(v, black);
//}
return liste;
}
Abb.: Durchläufe zur Breiten- bzw. Tiefensuche
305
Algorithmen und Datenstrukturen
5.3 Topologischer Sort
Sortieren bedeutet Herstellung einer totalen (vollständigen) Ordnung. Es gibt auch
Prozesse zur Herstellung von teilweisen Ordnungen 161, d.h.: Es gibt eine Ordnung
für einige Paare dieser Elemente, aber nicht für alle. In Graphen für die
Netzplantechnik ist die Feststellung partieller Ordnungen zur Berechnung der
kürzesten (und längsten) Wege erforderlich.
Bsp.: Die folgende Darstellung zeigt einen Netzplan zur Ermittlung des kritischen
Wegs. Die einzelnen Knoten des Graphen sind Anfangs- und Endereignispunkte der
Tätigkeiten, die an den Kanten angegeben sind. Die Kanten (Pfeile) beschreiben die
Vorgangsdauer und sind Abbildungen binärer Relationen. Zwischen den Knoten liegt
eine partielle Ordnungsrelation.
Bestelle A
50 Tage
Baue B
Teste B
4
1
20 Tage
Korrigiere Fehler
2
25 Tage
15 Tage
3
Handbucherstellung
60 Tage
Abb. : Ein Graph der Netzplantechnik
Zur Berechnung des kürzesten Wegs sind folgende Teilfolgen, die partiell geordnet
sind, nötig:
1 -> 3:
50 Tage
1->4->2->3: 60 Tage
1->4->3:
80 Tage (kürzester Weg)
Eindeutig ist das Bestimmen der topologischen Folgen nicht. Zu dem folgenden
Graphen
2
1
4
3
kann es mehrere topologische Folgen geben.Zwei dieser topologischen Folgen sind
161
vgl. 1.2.2.2
306
Algorithmen und Datenstrukturen
1
2
1
3
3
4
2
4
Abb.:
Bezugspunkt zur Ableitung eines Algorithmus für den topologischen Sort ist ein
gerichteter, azyklischer Graph, z.B.
0
1
1
2
2
3
1
3
4
5
3
2
6
7
Über der Knotenidentifikationen ist zusätzlich die Anzahl der Vorgänger vermerkt.
Dieser Zähler wird in die Knotenbeschreibung aufgenommen. Der Zähler soll
festhalten, wie viele unmittelbare Vorgänger der Knoten hat. Hat ein Knoten keine
Vorgänger, dann wird der Zähler auf 0 gesetzt.
Damit kann der Algorithmus durch folgende Pseudocode-Darstellung beschrieben
werden.
void topsort()
{
Queue q;
int zaehler = 0;
Vertex v, w;
q = new Queue();
for each vertex v
if (v.indegree 162 == 0)
q = new Queue();
while (!q.isEmpty())
{
v = q.dequeue();
zaehler++;
for each w adjacent to v
if (--w.indegree == 0)
q.enqueue(w);
}
if (zaehler != anzahlKnoten)
System.out.println(“Fehler: Zyklus gefunden”);
}
162
indegree ist der Zähler für die jeweilige Anzahl von Vorgängerknoten
307
Algorithmen und Datenstrukturen
Zur Bestimmung der gewünschten topologischen Folge wird mit den
Knotenpunktnummern begonnen, deren Zähler den Wert 0 enthalten. Sie verfügen
über keinen Vorgänger und erscheinen in der topologischen Folge an erster Stelle.
Schreibtischtest. Die folgende Tabelle soll anhand des folgenden Graphen
1
2
3
4
5
6
7
die Veränderung des Zählers für unmittelbare Vorgänger zeigen und über die
Knotenidentifikationen das Ein- bzw. Ausgliedern aus der Schlange (Queue) q.
Vertex
1
2
3
4
5
6
7
Enqueue
Dequeue
1
0
1
2
3
1
3
2
1
1
2
0
0
1
2
1
3
2
2
2
3
0
0
1
1
0
3
2
5
5
4
0
0
1
0
0
3
1
4
4
5
0
0
0
0
0
2
0
3,7
3
Komplexität.
308
6
0
0
0
0
0
1
0
7
7
0
0
0
0
0
0
0
6
6
Algorithmen und Datenstrukturen
5.4 Transitive Hülle
Welche Knoten sind von einem gegeben Knoten aus erreichbar?
Gibt es Knoten, von denen aus alle anderen Knoten erreicht werden können?
Die Bestimmung der transitiven Hülle ermöglicht die Beantwortung solcher Fragen.
S. Warshall hat 1962 einen Algorithmus entwickelt, der die Berechnung der
transitiven Hülle über seine Adjazenzmatrix ermöglicht und nach folgenden Regeln
arbeitet:
Falls ein Weg existiert, um von einem Knoten x nach einem Knoten y zu gelangen, und ein Weg, um vom
Knoten y nach z zu gelangen, dann existiert auch ein Weg, um vom Knoten x nach dem Knoten z zu gelangen.
Bsp.: Der fogende Graph enthält gestrichelte Kanten, die die Erreichbarkeit
markieren
A
B
C
D
E
Die zu diesem Graphen errechnete transitive Hülle beschreibt die folgende
Erreichbarkeitsmatrix:
1
0
0
1
0
1
1
0
1
0
1
1
1
1
1
0
0
0
1
0
1
1
0
1
1
309
Algorithmen und Datenstrukturen
5.5 Kürzeste Wege
5.5.1 Der Algorithmus von Dijkstra
Der Algorithmus von Dijkstra 163 (aus dem Jahre 1959) löst folgendes Problem:
Ausgehend von einem Startknoten (z.B. a) werden zunächst für alle Knoten die direkten Knoten eingetragen.
Nun wird der billigste noch nicht besuchte Knoten gewählt und getestet, ob von diesem aus andere Knoten
günstiger erreichbar sind. Diese Änderungen werden gespeichert und der Knoten als besucht markiert. Das
Vorgehen wird solange wiederholt, bis alle Knoten besucht wurden.
Die Komplexität des Verfahrens von Dijkstra beträgt O( | V 2 | ).
Implementierung 164:
public static final int INFINITY = Integer.MAX_VALUE;
public int [] dijkstra(int [][] a, int start)
{
if (a == null || a.length == 0) return null; // Matrix ist leer
boolean visited[] = new boolean[a.length];
// Knoten besucht
int [] costs = new int[a.length];
// Kosten start -> i
int i, w = 0, j, billigsteKosten, n = a.length;
for (i = 0; i < n; i++)
visited[i] = false;
visited[start] = true;
// Kosten setzen im Rahmen der Initialisierung
for (i = 0; i < n; i++)
costs[i] = (a[start][i] > 0) ? a[start][i] : INFINITY;
costs[start] = 0;
// Start kostenlos
for (i = 0; i < n; i++)
{
// Suche nicht besuchte Knoten w mit costs[w] minimal
billigsteKosten = INFINITY; // maximale Kosten
for (j = 0;j < n; j++)
{
if (!visited[j] && costs[j] < billigsteKosten)
{
w = j;
billigsteKosten = costs[w];
}
}
visited[w] = true;
// markiere w als besucht
for (j = 0; j < n; j++)
{
if (a[w][j] == 0) continue;
costs[j] = java.lang.Math.min(costs[j],costs[w] + a[w][j]);
}
}
return costs;
}
163
164
vgl. 2.2.11.3
pr52221
310
Algorithmen und Datenstrukturen
Testbeispiel und Test:
[0]
[1]
3
a
b
2
[2]
6
c
[3]
4
1
5
d
[4]
5
2
1
e
[5]
f
4
Abb.: Graph für den Test des Dijkstra-Algorithmus
Der Test führt zu folgendem Programmablauf:
Abb.: Lösungsschritte beim Test
Nachteile. Der Algorithmus von Dijkstra hat zwei Nachteile:
Es wurden nur die kürzesten Verbindungen von einem ausgezeichneten Startknoten zu einem
anderen Knoten bestimmt.
Die Gewichte aller Kanten müssen positiv sein
311
Algorithmen und Datenstrukturen
5.5.2 Der Algorithmus von Floyd
Der Algorithmus von Floyd berechnet die kürzesten Verbindungen von allen Knoten
zu allen Knoten
Zugrundeliegende Idee: Es werden alle direkten Verbindungen zweier Knoten als die
"billigste" Veränderung der Beiden Knoten verwendet. Die billigste Verbindung ist
entweder die direkte Verbindung oder aber zwei Wege über einen Mittelknoten.
Die Komplexität des Verfahrens von Floyd beträgt O( | V 3 | ).
Implementierung 165:
public static final int NO_EDGE = 0;
public int [][] floyd(int [][] a, int start)
{
if (a == null || a.length == 0) return null; // Matrix ist leer
int i, j, x, n = a.length;
// i = Start, j = Ende, x = Zwischenknoten
// Anlegen einer neuen Adjazenzmatrix
int [][] c = new int[n][n];
// Kopiere alle Werte aus der Matrix: Am Anfang ist die
// direkte Verbindung die einzige und daher auch die
// billigste
for (i = 0; i < n; i++)
for (j = 0; j < n; j++)
{
c[i][j] = a[i][j]; // direkte Kanten kopieren
}
// Suche fuer Knoten x nach Wegen ueber x, d.h. i -> x, x -> j
for (x = 0; x < n; x++)
for (i = 0; i < n; i++)
if (c[i][x] != NO_EDGE) // gibt es einen Weg i -> x
for (j = 0; j < n; j++)
if (c[x][j] != NO_EDGE)
if (c[i][j] == NO_EDGE // noch kein Weg i -> j
|| (c[i][x] + c[x][j] < c[i][j])) // i->x->j billiger
{
if (i == j) continue;
c[i][j] = c[i][x] + c[x][j];
}
return c;
}
Der Test dieses Algorithmus führt zu folgendem Resultat:
165
pr52221
312
Algorithmen und Datenstrukturen
5.6 Minimale Spannbäume
Anwendung. Minimale spannende Bäume sind z.B. für folgende Fragestellung
interessant: "Finde die billigste Möglichkeit alle Punkte zu verbinden". Diese Frage
stellt sich bspw. für elektrische Schaltungen, Flugrouten und Autostrecken.
Problemstellung. Zu einem zusammenhängenden Graphen soll ein Spannbaum
(aufspannender Baum) mit minimalem Kantengewicht (minimale Gesamtlänge)
bestimmt werden.
Der minimale Spannbaum muß nicht eindeutig sein, zu jedem gewichteten Graphen
gibt es aber mindestens einen minimalen spannenden Baum.
5.6.1 Der Algorithmus von Prim
Das einfachste Verfahren zur Erzeugung eines minimalen spannenden Baums
stammt von Prim aus dem Jahre 1952. In diesem Verfahren wird zu dem bereits
vorhandenen Teilgraph immer die billigste Kante hinzugefügt, die den Teilgraph mit
einem bisher noch nicht besuchten Knoten verbindet.
Aufgabe. Berechne einen spannenden Baum mit minimalen Kosten (minimum
spanning tree).
Lösungsbeschreibung. Der folgende Graph
2
k2
k1
4
1
3
10
2
7
k4
k3
5
8
k5
4
6
k7
k6
1
313
Algorithmen und Datenstrukturen
besitzt folgenden minimale Spannbaum:
2
k2
k1
1
2
k4
k3
k5
4
6
k7
k6
1
Abb.:
Die Anzahl der Kanten in einem minimal spannenden Baum ist |V| - 1 (Anzahl der
Knoten – 1). Der minimal spannende Baum ist
- ein Baum, der keine Zyklen besitzt.
- spannend, da er jeden Knoten abdeckt.
- ein Minimum.
Der Algorithmus von Prim arbeitet stufenweise. Auf jeder Stufe wird ein Knoten
ausgewählt. Die Kanten auf seine nachfolgenden Knoten werden dann untersucht.
Die Untersuchung folgt nach den Vorschriften des Dijkstra-Algorithmus. Es gibt nur
eine Ausnahme hinsichtlich der Ermittlung der Distanz: d w = min(d v , c vw )
Die Ausgangssituation zeigt folgende Tabelle:
k
k1
k2
k3
k4
k5
k6
k7
bekannt
false
false
false
false
false
false
false
dv
0
∞
∞
∞
∞
∞
∞
pv
null
null
null
null
null
null
null
Abb.: Ausgangssituation
„k1“ wird ausgewählt, „k2, k3, k4 sind zu k1 benachbart“. Das führt zur folgenden
Tabelle:
k
k1
k2
k3
k4
k5
k6
k7
bekannt
true
false
false
false
false
false
false
dv
0
2
4
1
∞
∞
∞
pv
null
k1
k1
k1
null
null
null
Abb.: Die Tabelle im Zustand „k1 ist bekannt“
Der nächste Knoten, der ausgewählt wird ist k4. Jeder Knoten ist zu k4 benachbart.
Ausgenommen ist k1, da dieser Knoten „bekannt“ ist. k2 bleibt unverändert, denn die
314
Algorithmen und Datenstrukturen
„Kosten“ von k4 nach k2 sind 3, bei k2 ist 2 eingetragen. Der Rest wird, wie die
folgende Tabelle zeigt, verändert:
k
k1
k2
k3
k4
k5
k6
k7
bekannt
true
false
false
true
false
false
false
dv
0
2
2
1
7
8
4
pv
null
k1
k4
k1
k4
k4
k4
Abb.: Die Tabelle im Zustand „k4 ist bekannt“
Der nächste Knoten, der ausgewählt wird, ist k2. Das zeigt keine Auswirkungen.
Dann wird k3 gewählt. Das bewirkt eine Veränderung der Distanz zu k6.
k
k1
k2
k3
k4
k5
k6
k7
bekannt
true
true
true
true
false
false
false
dv
0
2
2
1
7
5
4
pv
null
k1
k4
k1
k4
k3
k4
Abb.: Tabelle mit Zustand „k2 ist bekannt“ und (anschließend) mit dem Zustand „k3 ist bekannt“
Es folgt die Wahl des Knoten k7, was die Ausrichtung von k6 und k5 bewirkt:
k
k1
k2
k3
k4
k5
k6
k7
Bekannt
true
true
true
true
false
false
true
dv
0
2
2
1
6
1
4
pv
null
k1
k4
k1
k7
k7
k4
Abb.: Tabelle mit Zustand „k7 ist bekannt“
Jetzt werden noch k6 und dann k5 bestimmt. Die Tabelle nimmt danach folgende
Gestalt an:
k
k1
k2
k3
k4
k5
k6
k7
Bekannt
true
true
true
true
true
true
true
dv
0
2
2
1
6
1
4
pv
null
k1
k4
k1
k7
k7
k4
Abb.: Tabelle mit Zustand „k6 ist bekannt“ und (anschließend) „k5 ist bekannt“
Die Tabelle zeigt, daß folgende Kanten den minimal spannenden Baum bilden:
(k2,k1),k3,k4)(k4,k1),(k5,k7),(k6,k7),(k7,k4)
315
Algorithmen und Datenstrukturen
Der Algorithmus von Prim zeigt weitgehende Übereinstimmung mit dem Algorithmus
von Dijkstra 166.
Komplexität: Die Laufzeit ist O(|V|2)
Implementierung. MinimalSpanningTree.java 167
Abb.
5.6.2 Der Algorithmus von Kruskal
Beschreibung des Algorithmus.
1. Markiere alle Knoten als nicht besucht.
2. Erstelle eine neue Adjazenzmatrix, in der die tatsächlich verwendeten Kanten eingetragen werden.
Zu Beginn sind alle Elemente 0.
3. Bestimme die billigste Kante von einem Knote i zu einem Knoten j, die entweder zwei bisher nicht
erreichte Knoten verbindet, einen nicht nicht erreichten mit einem erreichten oder zwei bisher
unverknüpfte Teilgraphen verbindet.
Falls beide Knoten bereits erreicht wurden, kann diese Kante ignoriert werden, da durch sie ein Zyklus
entstehen würde.
4. Markiere i und j als erreicht und setze minimalTree[i][j]= g, wobei g das Gewicht der Kante
(i,j) ist.
5. Fahre mit Schritt 3 fort, bis alle Knoten erreicht sind
166
167
vgl. 2.2.11.2
vgl. pr53330
316
Algorithmen und Datenstrukturen
Bsp.: Gegeben ist
2
k2
k1
4
1
3
10
2
7
k4
k3
5
8
k5
4
6
k7
k6
1
Bestimme den minimale spannenden Baum nach dem Algorithmus von Kruskal:
1. Schritt
k2
k1
1
k4
k3
k5
k7
k6
2. Schritt
k2
k1
1
k4
k3
k5
k7
k6
1
3. Schritt
2
k2
k1
1
k4
k3
k5
k7
k6
1
317
Algorithmen und Datenstrukturen
4. Schritt
2
k2
k1
1
2
k4
k3
k5
k7
k6
1
5. Schritt
2
k2
k1
1
2
k4
k3
k5
4
k7
k6
1
6. Schritt
2
k2
k1
1
2
k4
k3
k5
4
6
k7
k6
1
Abb. Lösungsschritte zum Demonstrationsbeispiel
Prinzip. Auswahl der Kanten in der Reihenfolge kleinster Gewichte mit Aufnahme
einer Kante, falls sie nicht einen Zyklus verursacht.
Implementierung. MinimalSpanningTree.java
318
Herunterladen