Algorithmen und Datenstrukturen - Fachgebiet Theoretische Informatik

Werbung
Algorithmen und Datenstrukturen
Skript zur Vorlesung
Dieter Hofbauer und Friedrich Otto
FB Elektrotechnik/Informatik und FB Mathematik/Informatik
Universität Kassel
Vorwort
Effiziente Algorithmen und Datenstrukturen sind ein zentrales Thema der Informatik. Man macht sich leicht klar, dass ein enger Zusammenhang besteht
zwischen der Organisation von Daten (ihrer Strukturierung) und dem Entwurf
von Algorithmen, die diese Daten bearbeiten.
In dieser Vorlesung werden Algorithmen für eine Reihe grundlegender Aufgaben
und die dabei verwendeten Datenstrukturen vorgestellt und analysiert. Für die
meisten der Algorithmen wird zudem eine konkrete Implementierung in der
Programmiersprache Java angegeben.
Wichtige Informationen zur Lehrveranstaltung werden unter
http://www.theory.informatik.uni-kassel.de/~dieter/algo/
bereitgestellt, unter anderem die Übungsaufgaben, die Programme und das
Skript selbst.
Kassel, April 2002
3
4
Inhaltsverzeichnis
1 Einleitung
7
1.1
Datenstrukturen und ihre Spezifikation . . . . . . . . . . . . . . .
7
1.2
Einige elementare Datenstrukturen . . . . . . . . . . . . . . . . . 17
1.3
Einige einfache strukturierte Datentypen . . . . . . . . . . . . . . 18
1.3.1
Keller (Stacks) . . . . . . . . . . . . . . . . . . . . . . . . 18
1.3.2
Schlangen (Queues) . . . . . . . . . . . . . . . . . . . . . 24
1.3.3
Einfach verkettete Listen . . . . . . . . . . . . . . . . . . 29
1.3.4
Stacks über Listen implementieren . . . . . . . . . . . . . 35
1.3.5
Queues über Listen implementieren . . . . . . . . . . . . . 37
1.3.6
Doppelt verkettete Listen . . . . . . . . . . . . . . . . . . 38
1.4
Rechenzeit- und Speicherplatzbedarf von Algorithmen . . . . . . 39
1.5
Asymptotisches Wachstumsverhalten . . . . . . . . . . . . . . . . 40
1.6
Die Java-Klassenbibliothek . . . . . . . . . . . . . . . . . . . . . 42
2 Bäume und ihre Implementierung
45
2.1
Die Datenstruktur Baum
2.2
Implementierung binärer Bäume . . . . . . . . . . . . . . . . . . 49
2.3
Erste Anwendungen binärer Bäume . . . . . . . . . . . . . . . . . 70
2.4
. . . . . . . . . . . . . . . . . . . . . . 46
2.3.1
TREE SORT . . . . . . . . . . . . . . . . . . . . . . . . . 70
2.3.2
HEAP SORT . . . . . . . . . . . . . . . . . . . . . . . . . 72
Darstellungen allgemeiner Bäume . . . . . . . . . . . . . . . . . . 79
5
6
INHALTSVERZEICHNIS
3 Datentypen zur Darstellung von Mengen
85
3.1
Mengen mit Vereinigung, Schnitt und Differenz . . . . . . . . . . 85
3.2
Suchbäume . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 91
3.3
Gewichtsbalancierte Bäume . . . . . . . . . . . . . . . . . . . . . 98
3.4
Höhenbalancierte Bäume . . . . . . . . . . . . . . . . . . . . . . . 114
3.4.1
AVL-Bäume . . . . . . . . . . . . . . . . . . . . . . . . . . 115
3.4.2
(2,4)-Bäume . . . . . . . . . . . . . . . . . . . . . . . . . . 122
3.5
Hashing (Streuspeicherung) . . . . . . . . . . . . . . . . . . . . . 125
3.6
Partitionen von Mengen mit UNION und FIND . . . . . . . . . . 140
4 Graphen und Graph-Algorithmen
4.1
4.2
149
Gerichtete Graphen . . . . . . . . . . . . . . . . . . . . . . . . . . 150
4.1.1
Traversieren von Graphen . . . . . . . . . . . . . . . . . . 158
4.1.2
Kürzeste Wege . . . . . . . . . . . . . . . . . . . . . . . . 165
4.1.3
Starke Komponenten . . . . . . . . . . . . . . . . . . . . . 172
Ungerichtete Graphen . . . . . . . . . . . . . . . . . . . . . . . . 178
4.2.1
Minimale aufspannende Wälder . . . . . . . . . . . . . . . 178
5 Sortieralgorithmen
187
5.1
Elementare Sortieralgorithmen . . . . . . . . . . . . . . . . . . . 187
5.2
Sortierverfahren mit Divide-and-Conquer . . . . . . . . . . . . . 189
5.3
Sortieren durch Fachverteilen . . . . . . . . . . . . . . . . . . . . 196
5.4
Sortierverfahren im Vergleich . . . . . . . . . . . . . . . . . . . . 197
Kapitel 1
Einleitung
1.1
Datenstrukturen und ihre Spezifikation
Algorithmen und Datenstrukturen sind untrennbar miteinander verknüpft. Ein
Algorithmus realisiert eine Funktion, und er wird selbst wiederum durch ein
Programm realisiert. Auf der Seite der Daten entsprechen diesen Begriffen die
Algebra (d.h. ein konkreter Datentyp), die Datenstruktur, die eine Implementierung einer Algebra ist, und die programmiertechnischen Konzepte der Klasse,
des Moduls oder des Typs. Der Zusammenhang zwischen diesen Begriffen lässt
sich graphisch wie folgt veranschaulichen [Güting, Abb. 1.1]:
Abstrakter Datentyp
Mathematik
Spezifikation
Implementierung
Algorithmik
Spezifikation
Implementierung
Programmierung
Funktion
Algorithmus
Programm, Prozedur
Funktion
Algebra (Datentyp)
Datenstruktur
Typ, Modul, Klasse
Dabei werden wir uns überwiegend mit der mittleren Ebene dieses Diagramms
befassen. Die folgenden Beispiele sollen das obige Diagramm ein wenig erläutern,
wobei sich Beispiel 1.1.1 auf die linke und Beispiel 1.1.2 auf die rechte Spalte
in obigem Diagramm bezieht.
7
8
KAPITEL 1. EINLEITUNG
Beispiel 1.1.1. Aufgabenstellung: Sei S eine endliche Menge von ganzen Zahlen. Stelle fest, ob eine gegebene Zahl in S enthalten ist!
Diese informelle“ Beschreibung kann auf verschiedene Weisen formalisiert wer”
den.
Was soll gemacht werden? Mathematische Formulierung, Spezifikation:
Die Funktion contains : V(Z) × Z → {true, false} mit
(
true falls c ∈ S,
contains(S, c) =
false sonst,
soll berechnet werden (V(M ) bezeichnet die Menge aller endlichen (engl. finite)
Teilmengen einer Menge M ).
Wie kann dies geschehen? Formulierung eines Algorithmus; mehr oder weniger formal, hier meist als (Pseudo-)Java-Programm:
1
2
3
4
5
6
7
8
9
10
/** Test, ob die ganze Zahl c in der Menge S enthalten ist.
* Dabei ist S ein Array ueber dem Typ int.
*/
contains( int[ ] S, int c ) {
for( int i = 0; i < S.length; i++ ) {
if( S[i] == c )
return true;
}
return false;
}
contains
Praktische Realisierung, hier als Java-Programm:
1
2
/** Die Klasse Menge1 implementiert Mengen ganzer Zahlen. */
public class Menge1 {
3
4
5
/** Die Menge als Array ueber dem Typ int. */
private int[ ] array;
6
7
8
9
10
/** Konstruiert eine Menge aus einem Array. */
public Menge1( int[ ] array ) {
this.array = array;
}
Menge1
11
12
13
14
15
16
17
18
19
/** Test, ob die Zahl c in der Menge enthalten ist. */
public boolean contains( int c ) {
for( int i = 0; i < array.length; i++ ) {
if( array[i] == c )
return true;
}
return false;
}
contains
1.1. DATENSTRUKTUREN UND IHRE SPEZIFIKATION
9
20
21
public static void main( String[ ] args ) {
main
22
Menge1 S = new Menge1( new int[ ] { 5, 8, 42 } );
23
System.out.println( S.contains( 8 ) ); // true
24
System.out.println( S.contains( 9 ) ); // false
25
26
}
} // class Menge1
Beispiel 1.1.2. Aufgabenstellung: Verwalte eine Menge von Objekten (ganze
Zahlen), so dass Objekte eingefügt oder entfernt werden können und der Test
auf Enthaltensein durchgeführt werden kann!
Angabe der Signatur: Drei Datenmengen spielen hier eine Rolle, nämlich die
ganzen Zahlen, die endlichen Teilmengen der ganzen Zahlen und die Wahrheitswerte (Boolesche1 Werte). Für jede dieser Menge wählen wir ein Sortensymbol,
hier
integer,
intset,
boolean.
Zur Angabe der Signatur gehört nun noch, Symbole für jede der Operationen (synonym: Funktionen) festzulegen, die sog. Operationssymbole, sowie die
jeweiligen Argumentsorten und die Zielsorte anzugeben:
EMPTY : → intset
INSERT : intset × int → intset
DELETE : intset × int → intset
CONTAINS : intset × int → boolean
ISEMPTY : intset → boolean
Die Signatur gibt somit die Syntax unseres Datentyps an. Die Semantik kann
nun abstrakt (durch eine Menge von Axiomen, vgl. die Beispiele 1.1.3 und 1.1.5)
oder konkret durch eine spezielle Algebra angegeben werden.
Angabe einer Algebra: Dazu müssen konkrete Datenmengen und Operationen angegeben werden. Jedes Symbol der zugehörigen Signatur wird interpretiert, im Beispiel etwa wie folgt. Wir wählen die Menge Z als Interpretation des
Sortensymbols int, die Menge V(Z) als Interpretation des Symbols intset und
die Menge {true, false} als Interpretation von boolean. Die Operationssymbole
EMPTY, INSERT, DELETE, CONTAINS bzw. ISEMPTY werden durch die
1
Nach George Boole (1815-1864)
10
KAPITEL 1. EINLEITUNG
folgenden Operationen interpretiert (M ∈ V(Z), c ∈ Z):
empty = ∅,
insert(M, c) = M ∪ {c},
delete(M, c) = M \ {c},
(
true falls c ∈ M ,
contains(M, c) =
false sonst,
(
true falls M = ∅,
isEmpty(M ) =
false sonst.
Wie man unschwer verifiziert, erfüllen die Operationen die Vorgaben der obigen
Signatur bezüglich der Argument- und Zielmengen.
Algorithmische Realisierung: Man wählt eine Realisierung für die Datenmengen und gibt dann entsprechende Algorithmen an, die die obigen Operationen realisieren (vgl. Beispiel 1.1.1). Hier eine mögliche Implementierung in
Java für unser Beispiel:
1
2
/** Die Klasse Menge2 implementiert Mengen ganzer Zahlen. */
public class Menge2 {
3
4
5
/** Die maximale Groesse der Menge. */
private static final int MAX CARDINALITY = 10;
6
7
8
/** Die Menge als Array ueber dem Typ int. */
private int[ ] array = new int[MAX CARDINALITY];
9
10
11
/** Die Groesse der Menge. */
private int size;
12
13
14
15
16
/** Konstruiert eine leere Menge. */
// Die Angabe des parameterlosen Konstruktors ist hier redundant.
public Menge2( ) {
}
Menge2
17
18
19
20
21
/** Liefert eine neue leere Menge zurueck. */
public static Menge2 empty( ) {
return new Menge2( );
}
empty
22
23
24
25
26
27
28
29
30
31
32
/** Fuegt die Zahl c in die Menge ein. */
public void insert( int c ) {
if( contains( c ) ) // Element bereits vorhanden
return;
if( size == MAX CARDINALITY ) // Menge ist bereits voll
throw new IllegalStateException( "Menge ist bereits voll." );
array[size] = c;
size++;
}
insert
1.1. DATENSTRUKTUREN UND IHRE SPEZIFIKATION
33
34
35
36
37
38
39
40
41
42
43
/** Entfernt die Zahl c aus der Menge, falls vorhanden. */
public void delete( int c ) {
int i = 0;
while( array[i] != c && i < size ) // suche c
i++;
if( i < size ) { // falls c in der Menge enthalten ist:
for( int j = i; j < size; j++ )
array[j] = array[j+1]; // fuelle die neue Luecke
size−−;
}
}
11
delete
44
45
46
47
48
49
50
51
52
/** Test, ob die Zahl c in der Menge enthalten ist. */
public boolean contains( int c ) {
for( int i = 0; i < MAX CARDINALITY; i++ ) {
if( array[i] == c )
return true;
}
return false;
}
contains
53
54
55
56
57
/** Test, ob die Menge leer ist. */
public boolean isEmpty( ) {
return size == 0;
}
isEmpty
58
59
60
61
62
63
64
65
/** Gibt eine String-Repraesentation der Menge zurueck. */
public String toString( ) {
StringBuffer ausgabe = new StringBuffer( );
for( int i = 0; i < size ; i++ )
ausgabe.append( array[i] + " " );
return ausgabe.toString( );
}
toString
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
public static void main( String[ ] args ) {
Menge2 S = empty( );
System.out.println( S.isEmpty( ) ); // true
S.insert( 5 );
System.out.println( S.isEmpty( ) ); // false
S.insert( 8 );
S.insert( 42 );
System.out.println( S ); // 5 8 42
S.delete( 9 );
System.out.println( S ); // 5 8 42
S.delete( 8 );
System.out.println( S ); // 5 42
S.delete( 5 );
System.out.println( S ); // 42
S.delete( 42 );
System.out.println( S.isEmpty( ) ); // true
}
} // class Menge2
main
12
KAPITEL 1. EINLEITUNG
Wir sehen an diesen Beispielen, wie sich Spezifikation und Implementierung von
Algorithmen und Datenstrukturen gegenseitig beeinflussen.
Bei der Implementierung einer Datenstruktur werden wir stets zwei Stufen unterscheiden:
• Definitionsmodul“ oder Interface“: Dies ist die Schnittstelle zu den An”
”
wendungsprogrammen. Hierin wird die Signatur (Syntax) der Datenstruktur festgelegt. Alle Anwendungen können nur die hier eingeführten Operationen verwenden, um Objekte der Struktur zu bearbeiten.
• Implementierungsmodul“: Hierin wird eine Datenstruktur realisiert. Die
”
Details sind nicht nach außen sichtbar.
In Java spiegelt sich die Signatur in den Methodenköpfen und den Typdeklarationen der Felder.
Für die Verwendung einer Datenstruktur müssen Anwender neben der Syntax
natürlich auch die Semantik dieser Datenstruktur kennen. Diese wird durch
die Implementierung festgelegt, was natürlich unbefriedigend ist. Daher wird
im Allgemeinen die angestrebte Semantik“ formal (durch Axiome) oder halb
”
formal beschrieben ( spezifiziert“), und man verlangt, dass die Implementierung
”
dieser Spezifikation entspricht ( Korrektheit der Implementierung“). Wir geben
”
hierzu einige einfache Beispiele an.
Beispiel 1.1.3. Die Datenstruktur Boole stellt die Booleschen Werte true und
false zur Verfügung sowie einige logische Operationen wie Negation und Konjunktion. Eine algebraische Spezifikation dieser Struktur könnte so aussehen:
Über der Signatur
TRUE : → boolean
FALSE : → boolean
NOT : boolean → boolean
AND : boolean × boolean → boolean
OR : boolean × boolean → boolean
mit dem Sortensymbol boolean schreiben wir die folgenden Gleichungen (x und
y sind Variablen zur Sorte boolean):
NOT(TRUE) = FALSE
NOT(FALSE) = TRUE
AND(x, TRUE) = x
AND(x, FALSE) = FALSE
OR(x, y) = NOT(AND(NOT(x), NOT(y)))
Mit diesen Gleichungen kann man rechnen, indem man sie als Ersetzungsregeln
1.1. DATENSTRUKTUREN UND IHRE SPEZIFIKATION
13
verwendet:
OR(x, TRUE) = NOT(AND(NOT(x), NOT(TRUE)))
= NOT(AND(NOT(x), FALSE))
= NOT(FALSE)
= TRUE
Auf diese Weise gelangt man zu neuen Gleichungen, hier OR(x, TRUE) =
TRUE, die in allen Modellen der Spezifikation gelten. Solche Gleichungen sind
logische Folgerungen aus den Axiomen. Andere Gleichungen, im Beispiel etwa
NOT(NOT(x)) = x, sind keine logischen Konsequenzen der Axiome; sie gelten
aber im sog. initialen Modell der Gleichungmenge, einer Art Standardmodell“.
”
Dieses Thema wollen wir hier aber nicht weiter vertiefen.
Programm 1.1.4. Eine einfache Implementierung der Datenstruktur Boole:
1
2
/** Die Klasse Boole implementiert boolesche Werte. */
public class Boole {
3
4
5
6
7
8
/** Der boolesche Wert, realisiert als eine nicht-negative Zahl
* vom Typ int. Dabei entspricht der Wert 0 dem booleschen Wert
* false und jeder Wert groesser 0 dem booleschen Wert true.
*/
private int wert;
9
10
11
12
13
/** Konstruiert ein Objekt vom Typ Boole mit int-Wert i. */
public Boole( int i ) {
wert = Math.abs( i );
}
Boole
14
15
16
17
/** Statische Felder fuer die Konstanten TRUE und FALSE. */
public static final Boole TRUE = new Boole( 1 );
public static final Boole FALSE = new Boole( 0 );
18
19
20
21
22
23
24
/** Gibt ein neues Objekt zurueck, dessen Wert die Negation
* des Werts dieses Objekts ist.
*/
public Boole not( ) {
return new Boole( wert == 0 ? 1 : 0 );
}
not
25
26
27
28
29
30
31
/** Gibt ein neues Objekt zurueck, dessen Wert die Konjunktion
* des Werts dieses Objekts und des Werts von b ist.
*/
public Boole and( Boole b ) {
return new Boole( wert * b.wert );
}
and
32
33
34
35
36
37
38
/** Gibt ein neues Objekt zurueck, dessen Wert die Disjunktion
* des Werts dieses Objekts und des Werts von b ist.
*/
public Boole or( Boole b ) {
return new Boole( wert + b.wert );
}
or
14
KAPITEL 1. EINLEITUNG
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
/** Gibt eine String-Darstellung des Booleschen Werts zurueck. */
public String toString( ) {
return wert > 0 ? "true" : "false";
}
public static void main( String[ ] args ) {
System.out.println( TRUE ); // true
System.out.println( FALSE ); // false
System.out.println( TRUE.not( ) ); // false
System.out.println( FALSE.not( ) ); // true
System.out.println( TRUE.and( TRUE ) ); // true
System.out.println( FALSE.and( TRUE ) ); // false
System.out.println( FALSE.or( FALSE ) ); // false
System.out.println( FALSE.or( TRUE ) ); // true
}
} // class Boole
Eine solche Implementierung der Datenstruktur Boole ist in der Praxis überflüssig, da fast jede Programmiersprache bereits eine analoge Datenstruktur
mitbringt (in Java ist dies der Grundtyp boolean).
Das Beispiel ist noch in einer anderen Hinsicht untypisch. Jede der Booleschen
Operationen erzeugt hier nämlich ein neues Objekt; ein solches Vorgehen ist
meist nicht angemessen, da hierbei viel Speicherplatz verschwendet wird. In der
Regel wird man wie im nächsten Beispiel vorgehen und alternativ die bereits
vorhandenen Objekte manipulieren.
Beispiel 1.1.5. Die Datenstruktur Nat enthält einige einfache Operationen
über den natürlichen Zahlen. Wollen wir Nat durch eine algebraische Spezifikation definieren, so gehen wir wie folgt vor. Wir erweitern die Spezifikation aus
Beispiel 1.1.3 um das Sortensymbol nat, ergänzen die Signatur um
NULL : → nat
SUCC : nat → nat
ISTNULL : nat → boolean
ADD : nat × nat → nat
EQ : nat × nat → boolean
und die Axiome um
ISTNULL(NULL) = TRUE
ISTNULL(SUCC(x)) = FALSE
ADD(NULL, y) = y
ADD(SUCC(x), y) = SUCC(ADD(x, y))
EQ(x, NULL) = ISTNULL(x)
EQ(NULL, SUCC(y)) = FALSE
EQ(SUCC(x), SUCC(y)) = EQ(x, y)
toString
main
1.1. DATENSTRUKTUREN UND IHRE SPEZIFIKATION
15
Eine Beispielrechung:
EQ(ADD(SUCC(NULL), SUCC(NULL)), SUCC(NULL))
= EQ(SUCC(ADD(NULL, SUCC(NULL))), SUCC(NULL))
= EQ(ADD(NULL, SUCC(NULL)), NULL)
= ISTNULL(ADD(NULL, SUCC(NULL)))
= ISTNULL(SUCC(NULL))
= FALSE
Programm 1.1.6. Eine einfache Implementierung der Datenstruktur Nat:
1
2
/** Die Klasse Nat implementiert natuerliche Zahlen. */
public class Nat {
3
4
5
/** Die natuerliche Zahl als Zahl vom Typ int, die nie negativ ist. */
private int wert;
6
7
8
9
10
/** Konstruiert die Zahl Null. */
private Nat( ) {
wert = 0;
}
Nat
11
12
13
14
15
/** Statische Methode zur Konstruktion der Zahl Null. */
public static Nat Null( ) {
return new Nat( );
}
Null
16
17
18
19
20
21
/** Der Wert dieses Objekts wird um eins inkrementiert. */
public Nat succ( ) {
wert++;
return this;
}
succ
22
23
24
25
26
/** Test, ob der Wert null (0) ist. */
public Boole istNull( ) {
return new Boole( wert ).not( );
}
istNull
27
28
29
30
31
32
/** Zum Wert dieses Objekts wird n addiert. */
public Nat add( Nat n ) {
wert += n.wert;
return this;
}
add
33
34
35
36
37
/** Test, ob der Wert gleich dem Wert von n ist. */
public Boole eq( Nat n ) {
return new Boole( wert − n.wert ).not( );
}
eq
38
39
40
41
42
/** Gibt die Zahl als String zurueck. */
public String toString( ) {
return String.valueOf( wert );
}
toString
16
KAPITEL 1. EINLEITUNG
43
44
45
46
47
48
49
50
51
52
53
54
55
public static void main( String[ ] args ) {
System.out.println( Null( ) ); // 0
System.out.println( Null( ).istNull( ) ); // true
Nat zwei = Null( ).succ( ).succ( );
System.out.println( zwei ); // 2
System.out.println( zwei.istNull( ) ); // false
Nat vier = Null( ).succ( ).succ( ).succ( ).succ( );
System.out.println( zwei.add( vier ) ); // 6
System.out.println( zwei.eq( vier ) ); // false
System.out.println( vier.eq( zwei ) ); // false
System.out.println( zwei.eq( zwei ) ); // true
}
56
57
} // class Nat
Bei der Beschreibung der Semantik muss man aufpassen, dass diese widerspruchsfrei und vollständig ist. Diese Forderungen sind oft nur schwer zu erfüllen
(und noch schwerer zu verifizieren).
Beispiel 1.1.7. Eine andere Spezifikation für eine Datenstruktur Boole erhalten wir, wenn wir die Signatur aus Beispiel 1.1.3 beibehalten, die Axiomenmenge
aber wie folgt modifizieren:
NOT(TRUE) = FALSE
NOT(FALSE) = TRUE
TRUE 6= FALSE
NOT(NOT(x)) = x
AND(x, FALSE) = FALSE
AND(x, y) = NOT(OR(NOT(x), NOT(y)))
OR(x, y) = OR(y, x)
OR(x, TRUE) = x
Dann gilt beispielsweise
FALSE = AND(TRUE, FALSE)
= NOT(OR(NOT(TRUE), NOT(FALSE)))
= NOT(OR(NOT(TRUE), TRUE))
= NOT(NOT(TRUE))
= TRUE,
was der Ungleichung TRUE 6= FALSE widerspricht. Also ist die obige Spezifikation nicht widerspruchsfrei.
main
1.2. EINIGE ELEMENTARE DATENSTRUKTUREN
1.2
17
Einige elementare Datenstrukturen
Grundtypen wie Boolesche Werte (boolean), Buchstaben (char) oder Zahlen
(int, double etc.) werden im Speicher im Allgemeinen durch eine bestimmte
Anzahl von Wörtern (Bytes) zu je acht Bits dargestellt. In Java ist diese Darstellung (im Gegensatz zu den meisten anderen Programmiersprachen) festgelegt:
Typ
boolean
char
byte
short
int
long
float
double
Größe in Bits
8
16
8
16
32
64
32
64
Wertebereich
true, false
’\u0000’ bis ’uFFFF’ (0 bis 65535)
−27 bis 27 − 1
−215 bis 215 − 1
−231 bis 231 − 1
−263 bis 263 − 1
1.40239846e−45 bis 3.40282347e+38 (positiv)
4.94065645841246544e−324 bis
1.79769313486231570e+308 (positiv)
Der Typ char entspricht ISO Unicode, die Fließkommatypen float und double entsprechen IEEE 754. Für die Speicherabbildung von ganzen Zahlen, hier
am Beispiel des Typs int, wird der Wert im Binärcode in folgendem Format
dargestellt:
z≥0:
2er-Komplement:
0
bin(z)
bin(232 − |z|)
z<0:
z = −1 :
zum Beispiel:
1
1
1
...
1
1
da bin(232 − 1) = 11 . . . 11 (32-mal) ist.
Hier noch zwei weitere geläufige Speicherabbildungen (man beachte, dass hier
die Null jeweils zwei Darstellungen hat):
Vorzeichen und Betrag: z :
z≥0:
1er-Komplement:
v
bin(|z|)
0
bin(z)
bin(232 − 1 − |z|)
z<0:
z.B.: z = −1 :
1
1
1
...
Die Speicherabbildung von Fließkommazahlen:
v
Exponent e
Mantisse m
1
0
18
KAPITEL 1. EINLEITUNG
stellt die Zahl
z = (−1)v · 2e · (1 + m)
dar, wobei 0 ≤ m < 1 gilt und e eine ganze Zahl mit Vorzeichen ist. Bei der
Kodierung mit 32 Bits (float) benötigt das Vorzeichen 1 Bit, der Exponent 8
Bits und die Mantisse 23 Bits; mit 64 Bits (double) benötigt das Vorzeichen 1
Bit, der Exponent 11 Bits und die Mantisse 52 Bits.
1.3
Einige einfache strukturierte Datentypen
Hier wollen wir folgende Datentypen vorstellen:
• Keller (engl. Stacks),
• Schlangen (engl. Queues),
• verkettete Listen.
1.3.1
Keller (Stacks)
Ein Stack besteht aus einer Folge a1 , . . . , am von Elementen einer Datenmenge
Item, wobei nur an einem Ende dieser Folge ( oben“) Elemente gelesen, gelöscht
”
oder eingefügt werden können. Stacks sind daher auch unter dem Namen LIFOListen (Last-I n-F irst-Out) bekannt.
am
..
.
← oben (top)
a2
a1
Einige Anwendungen:
• Auswertung von Ausdrücken in Postfix-Notation (siehe Beispiel 1.3.2)
• Umwandlung von Ausdrücken in Infix-Notation in Postfix-Notation
• Realisierung des Bindungskellers von Namen bei der syntaktischen Analyse von Programmen
• Umwandlung von rekursiven Programmen in nicht-rekursive Programme
• Backtracking-Algorithmen
• Laufzeitstack bei der Speicherplatzverwaltung von Programmen
1.3. EINIGE EINFACHE STRUKTURIERTE DATENTYPEN
19
Eine algebraische Spezifikation des Datentyps Stack kann wie folgt aussehen.
Die Signatur mit den Sortensymbolen stack, boolean und item:
EMPTY : → stack
PUSH : stack × item → stack
ISEMPTY : stack → boolean
MAKEEMPTY : stack → stack
TOP : stack → item
POP : stack → stack
ERROR1 : → item
ERROR2 : → stack
TRUE, FALSE : → boolean
Und die Axiome:
ISEMPTY(EMPTY) = TRUE
ISEMPTY(PUSH(s, i)) = FALSE
MAKEEMPTY(s) = EMPTY
TOP(EMPTY) = ERROR1
TOP(PUSH(s, i)) = i
POP(EMPTY) = ERROR2
POP(PUSH(s, i)) = s
Diese Spezifikation ist mit dem Sortensymbol item parametrisiert, das durch
eine beliebige Menge interpretiert werden kann.
Programm 1.3.1. Eine Implementierung der Datenstruktur Stack durch die
Datenstruktur Array:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import java.util.NoSuchElementException; // zur Fehlerbehandlung
import java.io.*; // Testroutine main benutzt I/O-Klassen
/** Die Klasse Stack implementiert Stacks beschraenkter Groesse mittels Arrays. */
public class Stack {
/** Das Array. */
private Object[ ] array;
/** Die Position des Top-Elements des Stacks im Array. */
private int topPosition;
/** Die maximale Laenge des Arrays. */
private static final int MAX LAENGE = 10;
20
16
17
18
19
20
KAPITEL 1. EINLEITUNG
/** Konstruiert den leeren Stack mit maximaler Groesse MAX LAENGE. */
public Stack( ) {
array = new Object[MAX LAENGE];
topPosition = −1;
}
Stack
21
22
23
24
25
26
/** Konstruiert den leeren Stack mit maximaler Groesse n. */
public Stack( int n ) {
array = new Object[n];
topPosition = −1;
}
Stack
27
28
29
30
31
/** Test, ob der Stack leer ist. */
public boolean isEmpty( ) {
return topPosition == −1;
}
isEmpty
32
33
34
35
36
/** Macht den Stack leer. */
public void makeEmpty( ) {
topPosition = −1;
}
makeEmpty
37
38
39
40
41
/** Test, ob der Stack voll ist. */
public boolean isFull( ) {
return topPosition == array.length−1;
}
isFull
42
43
44
45
46
47
48
49
50
51
/** Fuegt ein neues Element oben in den Stack ein.
* Ist der Stack voll, so wird eine Ausnahme ausgeloest.
*/
public void push( Object inhalt ) {
if( isFull( ) )
throw new IllegalStateException( "Stack ist voll." );
topPosition++;
array[ topPosition ] = inhalt;
}
push
52
53
54
55
56
57
58
59
60
/** Gibt das oberste Element des Stacks zurueck, wenn vorhanden.
* Ist der Stack leer, so wird eine Ausnahme ausgeloest.
*/
public Object top( ) {
if( isEmpty( ) )
throw new NoSuchElementException( "Stack ist leer." );
return array[ topPosition ];
}
top
61
62
63
64
65
66
67
68
69
/** Entfernt das oberste Element des Stacks, wenn vorhanden.
* Ist der Stack leer, so wird eine Ausnahme ausgeloest.
*/
public void pop( ) {
if( isEmpty( ) )
throw new NoSuchElementException( "Stack ist bereits leer." );
topPosition−−;
}
pop
1.3. EINIGE EINFACHE STRUKTURIERTE DATENTYPEN
21
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
/** Gibt das oberste Element des Stacks zurueck und entfernt es,
* wenn vorhanden. Ist der Stack leer, so wird eine Ausnahme ausgeloest.
*/
public Object topAndPop( ) {
if( isEmpty( ) )
throw new NoSuchElementException( "Stack ist bereits leer." );
Object topElement = array[ topPosition ];
topPosition−−;
return topElement;
}
/** Gibt den String zurueck, der aus der Folge der String-Darstellungen
* der Stack-Elemente besteht, jeweils durch eine Leerzeile getrennt.
* Die Reihenfolge ist von oben nach unten.
*/
public String toString( ) {
StringBuffer ausgabe = new StringBuffer( );
for( int i = topPosition; i >= 0 ; i−− )
ausgabe.append( array[i] + "\n" );
return ausgabe.toString( );
}
/** Eine kleine Testroutine, die ueber die Kommandozeile benutzt wird. */
public static void main( String[ ] args ) throws IOException {
System.out.println( "Ein Stack mit Eintraegen vom Typ String\n" +
"kann ueber die Kommandozeile modifiziert werden.\n" +
"\tVerfuegbare Kommandos:\n" +
"\t\"e\" -- Stack leer machen (makeEmpty)\n" +
"\t\"u\" -- Element oben einfuegen (push)\n" +
"\t\"t\" -- Oberstes Element ausgeben (top)\n" +
"\t\"o\" -- Oberstes Element loeschen (pop)\n" +
"\t\"p\" -- Stack von oben nach unten ausgeben (print)\n" +
"\t\"q\" -- beendet das Programm (quit)" );
Stack stack = new Stack( );
BufferedReader in = new BufferedReader( new InputStreamReader( System.in ) );
char command = ’ ’;
while( command != ’q’ ) {
switch( command ) {
case ’ ’ : { // Tue nichts
break;
}
case ’e’ : { // Stack leer machen
stack.makeEmpty( );
break;
}
case ’u’ : { // String oben einfuegen
System.out.println( "\tEinen String eingeben:" );
stack.push( in.readLine( ) );
break;
}
topAndPop
toString
main
22
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
KAPITEL 1. EINLEITUNG
case ’t’ : { // Oberstes Element ausgeben
try {
System.out.println( "Oberstes Element: " + stack.top( ) );
}
catch( NoSuchElementException e ) { System.out.println( e ); }
break;
}
case ’o’ : { // Oberstes Element loeschen
try {
stack.pop( );
}
catch( NoSuchElementException e ) { System.out.println( e ); }
break;
}
case ’p’ : { // Stack von oben nach unten ausgeben
System.out.println( "Stack:\n" + stack );
break;
}
default :
System.out.println( "Kommando " + command + " existiert nicht." );
}
System.out.println( "Bitte Kommando eingeben:" );
try {
// Kommando einlesen, Leerzeichen entfernen, erstes Zeichen auswaehlen:
command = in.readLine( ).trim( ).charAt( 0 );
}
catch( IndexOutOfBoundsException e ) {
System.out.println( "Keine leeren Kommandos eingeben!" );
command = ’ ’;
continue;
}
}
} // main
} // class Stack
Problematisch ist bei dieser Implementierung, dass durch die Größe des benutzten Feldes eine maximale Größe für die realisierten Stacks vorgegeben wird. Dadurch weicht diese Implementierung von der Spezifikation des Datentyps Stack
ab. Abhilfe kann hier eine dynamisch expandierende Struktur schaffen, etwa die
in Abschnitt 1.3.3 vorgestellten verketteten Listen.
Beispiel 1.3.2. Als beispielhafte Anwendung von Stacks wird hier zuletzt ein
Rechner“ implementiert, der arithmetische Ausdrücke in Postfix-Notation ak”
zeptiert und auswertet.
1
2
import java.io.*; // main benutzt I/O-Klassen
/** Die Klasse PostfixRechner implementiert einen Stack-basierten Rechner fuer
* ganze Zahlen, der Ausdruecke in Postfix-Notation akzeptiert und auswertet.
5
*/
6 public class PostfixRechner {
3
4
1.3. EINIGE EINFACHE STRUKTURIERTE DATENTYPEN
23
7
8
public static void main( String[ ] args ) throws IOException {
9
10
11
12
13
14
15
16
17
18
System.out.println( "Der Rechner fuer ganze Zahlen ist bereit!\n" +
"\tVerfuegbare Operationen:\n" +
"\t+ -- Addition\n" +
"\t- -- Subtraktion\n" +
"\t* -- Multiplikation\n" +
"\t/ -- Division\n" +
"\t= -- Endergebnis ausgeben\n" +
"Einen arithmetischen Ausdruck in Postfix-Notation eingeben,\n" +
"dabei jede Zahl und jeden Operator mit Enter bestaetigen:\n" );
19
20
21
22
23
24
Stack stack = new Stack( ); // ein leerer Stack
BufferedReader in = new BufferedReader( new InputStreamReader( System.in ) );
String eingabeString;
int eingabeZahl, erstesArgument, zweitesArgument, zwischenErgebnis;
char operation;
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
while( true ) { // potentielle Endlosschleife
eingabeString = in.readLine( );
try { // Ist die Eingabe eine Zahl oder eine Operation?
eingabeZahl = Integer.parseInt( eingabeString );
// Die Eingabe war eine Zahl, also Zahl in den Stack einfuegen:
stack.push( new Integer( eingabeZahl ) );
continue;
}
catch( NumberFormatException e ) { // Die Eingabe war keine Zahl:
if( eingabeString.length( ) != 0 ) { // Eingabe-String war nicht leer.
// Leerzeichen entfernen, erstes Zeichen als Operationssymbol auswaehlen:
operation = eingabeString.trim( ).charAt( 0 );
}
else continue;
}
if( operation == ’=’ ) {
// Endergebnis ausgeben (0 bei leerem Stack) und terminieren:
System.out.println( "Endergebnis: " +
( stack.isEmpty( ) ? "0" : stack.top( ) ) );
return;
}
// Andernfalls die Operation auswerten:
if( stack.isEmpty( ) ) { // Der Stack kann leer sein.
System.out.println( "Bitte zuerst ein Argument eingeben!" );
continue;
}
// Das zweite Argument aus dem Stack holen:
zweitesArgument = ( (Integer)stack.topAndPop( ) ).intValue( );
if( stack.isEmpty( ) ) { // Der Stack kann leer sein.
System.out.println( "Bitte zuerst ein Argument eingeben!" );
// Das zweite Argument wieder auf den Stack legen:
stack.push( new Integer( zweitesArgument ) );
continue;
}
main
24
KAPITEL 1. EINLEITUNG
// Das erste Argument aus dem Stack holen:
erstesArgument = ( (Integer)stack.topAndPop( ) ).intValue( );
switch( operation ) {
case ’+’ : { // Addition ausfuehren
zwischenErgebnis = erstesArgument + zweitesArgument;
break;
}
case ’-’ : { // Subtraktion ausfuehren
zwischenErgebnis = erstesArgument − zweitesArgument;
break;
}
case ’*’ : { // Multiplikation ausfuehren
zwischenErgebnis = erstesArgument * zweitesArgument;
break;
}
case ’/’ : { // Division ausfuehren
if( zweitesArgument == 0 ) // Division durch Null loest Ausnahme aus:
throw new ArithmeticException( "Division durch Null verboten!" );
zwischenErgebnis = erstesArgument / zweitesArgument;
break;
}
default :
System.out.println( "Operation " + operation + " existiert nicht." );
continue;
}
stack.push( new Integer( zwischenErgebnis ) ); // Zwischenergebnis auf Stack
System.out.println( "Zwischenergebnis: " + zwischenErgebnis );
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
}
} // main
} // class PostfixRechner
1.3.2
Schlangen (Queues)
Auch eine Queue besteht aus einer Folge a1 , . . . , am von Elementen einer Datenmenge Item. Wieder wird nur an einem Ende dieser Folge ( vorne“) gelesen und
”
gelöscht, allerdings nun am anderen Ende ( hinten“) eingefügt. Queues werden
”
daher auch FIFO-Listen (F irst-I n-F irst-Out) genannt.
a1
a2
↑
ANFANG
...
am−1 am
↑
ENDE
Einige Anwendungen:
• Betriebsmittelverwaltung (Warteschlangen für Prozessoren, Drucker, usw.)
• Branch-and-Bound-Algorithmen für Suchprobleme in Bäumen und Graphen
1.3. EINIGE EINFACHE STRUKTURIERTE DATENTYPEN
25
Auch hierfür wollen wir eine algebraische Spezifikation angeben. Die Signatur
mit den Sortensymbolen queue, boolean und item:
EMPTY : → queue
ENQUEUE : queue × item → queue
ISEMPTY : queue → boolean
MAKEEMPTY : queue → queue
FRONT : queue → item
DEQUEUE : queue → queue
ERROR1 : → item
ERROR2 : → queue
TRUE, FALSE : → boolean
Die Axiome:
ISEMPTY(EMPTY) = TRUE
ISEMPTY(ENQUEUE(q, i)) = FALSE
MAKEEMPTY(q) = EMPTY
FRONT(EMPTY) = ERROR1
FRONT(ENQUEUE(EMPTY, i)) = i
FRONT(ENQUEUE(ENQUEUE(q, i), j)) = FRONT(ENQUEUE(q, i))
DEQUEUE(EMPTY) = ERROR2
DEQUEUE(ENQUEUE(EMPTY, i)) = EMPTY
DEQUEUE(ENQUEUE(ENQUEUE(q, i), j)) =
ENQUEUE(DEQUEUE(ENQUEUE(q, i)), j)
Programm 1.3.3. Eine Implementierung der Datenstruktur Queue durch die
Datenstruktur Array:
0
1
2
3
a1 a2
↑
↑
head ANFANG
1
2
3
4
5
6
7
8
9
n−3 n−2 n−1
...
am
↑
ENDE (rear)
import java.util.NoSuchElementException; // zur Fehlerbehandlung
import java.io.*; // Testroutine main benutzt I/O-Klassen
/** Die Klasse Queue implementiert Queues beschraenkter Groesse mittels Arrays. */
public class Queue {
/** Das Array. */
private Object[ ] array;
26
10
11
12
13
KAPITEL 1. EINLEITUNG
/** Die Position vor dem vordersten Elements der Queue im Array.
* Bei leerer Queue ist der Wert gleich dem Wert von rear.
*/
private int head;
14
15
16
17
18
/** Die Position des hintersten Elements der Queue im Array.
* Bei leerer Queue ist der Wert gleich dem Wert von head.
*/
private int rear;
19
20
21
/** Die maximale Laenge des Arrays. */
private static final int MAX LAENGE = 10;
22
23
24
25
26
27
/** Konstruiert die leere Queue mit maximaler Groesse MAX LAENGE. */
public Queue( ) {
array = new Object[MAX LAENGE];
head = rear = −1;
}
Queue
28
29
30
31
32
33
/** Konstruiert die leere Queue mit maximaler Groesse n. */
public Queue( int n ) {
array = new Object[n];
head = rear = −1;
}
Queue
34
35
36
37
38
/** Test, ob die Queue leer ist. */
public boolean isEmpty( ) {
return head == rear;
}
isEmpty
39
40
41
42
43
/** Macht die Queue leer. */
public void makeEmpty( ) {
head = rear = −1;
}
makeEmpty
44
45
46
47
48
/** Test, ob die Queue voll ist. */
public boolean isFull( ) {
return head == −1 && rear == array.length−1;
}
isFull
49
50
51
52
53
54
55
56
57
58
59
60
61
62
/** Fuegt ein neues Element hinten in die Queue ein. */
public void enqueue( Object inhalt ) {
if( isFull( ) )
throw new IllegalStateException( "Queue ist voll." );
int f = head+1; // Position des Front-Elements (wenn vorhanden)
if( rear == array.length−1 ) { // rear hat bereits maximalen Wert
// Die Queue so weit wie moeglich, also um f Positionen nach vorn schieben:
for( int i = f; i <= rear; i++ )
array[i−f] = array[i];
head = −1;
rear = rear−f+1;
array[rear] = inhalt; // Element einfuegen
}
enqueue
1.3. EINIGE EINFACHE STRUKTURIERTE DATENTYPEN
else { // rear hat noch nicht maximalen Wert
rear++;
array[rear] = inhalt; // Element einfuegen
}
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
27
}
/** Gibt das vorderste Element der Queue zurueck, wenn vorhanden.
* Ist die Queue leer, so wird eine Ausnahme ausgeloest.
*/
public Object front( ) {
if( isEmpty( ) )
throw new NoSuchElementException( "Queue ist leer." );
return array[head+1];
}
/** Entfernt das vorderste Element der Queue, wenn vorhanden.
* Ist die Queue leer, so wird eine Ausnahme ausgeloest.
*/
public void dequeue( ) {
if( isEmpty( ) )
throw new NoSuchElementException( "Queue ist bereits leer." );
head++;
}
/** Gibt den String zurueck, der aus der Folge der String-Darstellungen
* der Queue-Elemente besteht, jeweils durch eine Leerzeile getrennt.
* Die Reihenfolge ist von vorne nach hinten.
*/
public String toString( ) {
StringBuffer ausgabe = new StringBuffer( );
for( int i = head+1; i <= rear ; i++ )
ausgabe.append( array[i] + "\n" );
return ausgabe.toString( );
}
/** Eine kleine Testroutine, die ueber die Kommandozeile benutzt wird. */
public static void main( String[ ] args ) throws IOException {
System.out.println( "Eine Queue mit Eintraegen vom Typ String\n" +
"kann ueber die Kommandozeile modifiziert werden.\n" +
"\tVerfuegbare Kommandos:\n" +
"\t\"e\" -- Queue leer machen (makeEmpty)\n" +
"\t\"n\" -- Element hinten einfuegen (enqueue)\n" +
"\t\"f\" -- Vorderstes Element ausgeben (front)\n" +
"\t\"d\" -- Vorderstes Element loeschen (dequeue)\n" +
"\t\"p\" -- Queue von vorn nach hinten ausgeben (print)\n" +
"\t\"q\" -- beendet das Programm (quit)" );
Queue queue = new Queue( );
BufferedReader in = new BufferedReader( new InputStreamReader( System.in ) );
char command = ’ ’;
while( command != ’q’ ) {
front
dequeue
toString
main
28
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
KAPITEL 1. EINLEITUNG
switch( command ) {
case ’ ’ : { // Tue nichts
break;
}
case ’e’ : { // Queue leer machen
queue.makeEmpty( );
break;
}
case ’n’ : { // String hinten einfuegen
System.out.println( "\tEinen String eingeben:" );
queue.enqueue( in.readLine( ) );
break;
}
case ’f’ : { // Vorderstes Element ausgeben
try {
System.out.println( "Vorderstes Element: " + queue.front( ) );
}
catch( NoSuchElementException e ) { System.out.println( e ); }
break;
}
case ’d’ : { // Vorderstes Element loeschen
try {
queue.dequeue( );
}
catch( NoSuchElementException e ) { System.out.println( e ); }
break;
}
case ’p’ : { // Queue von vorn nach hinten ausgeben
System.out.println( "Queue:\n" + queue );
break;
}
default :
System.out.println( "Kommando " + command + " existiert nicht." );
}
System.out.println( "Bitte Kommando eingeben:" );
try {
// Kommando einlesen, Leerzeichen entfernen, erstes Zeichen auswaehlen:
command = in.readLine( ).trim( ).charAt( 0 );
}
catch( IndexOutOfBoundsException e ) {
System.out.println( "Keine leeren Kommandos eingeben!" );
command = ’ ’;
continue;
}
}
} // main
} // class Queue
Bemerkung 1.3.4. Eine effizientere Implementierung von Queues erhält man,
wenn die Elemente des Arrays als kreisförmig angeordnet betrachtet werden,
d.h. wenn modulo n gerechnet wird:
1.3. EINIGE EINFACHE STRUKTURIERTE DATENTYPEN
29
Dazu müssen natürlich die Operationen entsprechend implementiert werden.
Ein Problem dabei ist, die beiden Fälle Schlange ist leer“ und Schlange ist
”
”
voll“ zu unterscheiden, da beide Mal ANFANG = ENDE wäre. Dafür brauchen
wir entweder eine zusätzliche boolesche Variable (was zusätzlichen Aufwand
bei jeder Operation mit sich bringt), oder aber wir erlauben nur, dass maximal
n − 1 Elemente abgespeichert werden, d.h. die Schlange wird als voll angesehen,
wenn ENDE + 1 = ANFANG (mod n) gilt.
1.3.3
Einfach verkettete Listen
Eine einfach verkettete Liste (engl. singly linked list) besteht aus einer Folge
von Knoten, wobei jeder Knoten neben einem Element ai einer Datenmenge
Item noch einen Verweis auf den Nachfolgerknoten in der Folge speichert.
a1
c
a2
c
a3
c
···
am
Diese Datenstruktur kann wie folgt spezifiziert werden. Die Signatur mit den
Sortensymbolen list, boolean und item:
EMPTY : → list
INS FIRST : list × item → list
INS LAST : list × item → list
ISEMPTY : list → boolean
MAKEEMPTY : list → list
HEAD : list → item
TAIL : list → list
CONCAT : list × list → list
ERROR1 : → item
ERROR2 : → list
TRUE, FALSE : → boolean
30
KAPITEL 1. EINLEITUNG
Die Axiome:
ISEMPTY(EMPTY) = TRUE
ISEMPTY(INS FIRST(`, i)) = FALSE
MAKEEMPTY(`) = EMPTY
INS LAST(EMPTY, i) = INS FIRST(EMPTY, i)
INS LAST(INS FIRST(`, i), j) = INS FIRST(INS LAST(`, j), i)
HEAD(EMPTY) = ERROR1
HEAD(INS FIRST(`, i)) = i
TAIL(EMPTY) = ERROR2
TAIL(INS FIRST(`, i)) = `
CONCAT(EMPTY, `) = `
CONCAT(INS FIRST(`1 , i), `2 ) = INS FIRST(CONCAT(`1 , `2 ), i)
Weitere mögliche Operationen (Spezifikation und Implementierung als Übung):
EQUAL : list × list → boolean, LENGTH : list → int, REVERT : list → list.
Natürlich kann man die Datenstruktur einfach verkettete Liste“ auch in der
”
Datenstruktur Array implementieren. Dann muss man wieder eine maximale
Größe vorgeben, die die Länge der implementierten Listen beschränkt. Stattdessen betrachten wir hier eine Implementierung durch Referenzen.
Programm 1.3.5. Eine Implementierung einfach verketteter Listen mittels
Referenzen:
1
2
import java.util.NoSuchElementException; // zur Fehlerbehandlung
import java.io.*; // Testroutine main benutzt I/O-Klassen
3
4
/** Die Klasse EinfachVerketteterKnoten implementiert
* die Knoten einer einfach verketteten Listen.
*/
7 class EinfachVerketteterKnoten {
5
6
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/** Das Feld inhalt referiert auf ein beliebiges Objekt. */
private Object inhalt;
/** Das Feld next referiert auf den Nachfolgerknoten in einer linearen Liste.
* Gibt es keinen Nachfolger, so ist der Wert null.
*/
private EinfachVerketteterKnoten next;
/** Konstruiert einen Knoten mit Inhalt inhalt.
* Das Feld next wird mit null belegt.
*/
public EinfachVerketteterKnoten( Object inhalt ) {
this.inhalt = inhalt;
}
EinfachVerketteterKnoten
1.3. EINIGE EINFACHE STRUKTURIERTE DATENTYPEN
24
25
26
27
28
/** Konstruiert einen Knoten mit Inhalt inhalt und Nachfolgerknoten next. */
public EinfachVerketteterKnoten( Object inhalt, EinfachVerketteterKnoten next ) {
this.inhalt = inhalt;
this.next = next;
}
31
EinfachVerketteterKnoten
29
30
31
32
33
/** Gibt den Inhalt des Knotens zurueck. */
public Object inhalt( ) {
return inhalt;
}
inhalt
34
35
36
37
38
/** Gibt den Nachfolgerknoten zurueck, wenn vorhanden, sonst null. */
public EinfachVerketteterKnoten next( ) {
return next;
}
next
39
40
41
42
43
/** Aendert den Nachfolgerknoten auf next. */
public void changeNext( EinfachVerketteterKnoten next ) {
this.next = next;
}
changeNext
44
45
} // class EinfachVerketteterKnoten
46
47
/** Die Klasse EinfachVerketteteListe implementiert einfach verkettete
* Listen mit Referenzen auf den ersten und den letzten Knoten.
50
*/
51 public class EinfachVerketteteListe {
48
49
52
53
54
/** Das Feld first referiert auf den ersten Knoten der Liste. */
private EinfachVerketteterKnoten first;
55
56
57
/** Das Feld last referiert auf den letzten Knoten der Liste. */
private EinfachVerketteterKnoten last;
58
59
60
// Die Klasse hat den parameterlosen Standardkonstruktor
// public EinfachVerketteteListe( ) { } zur Erzeugung der leeren Liste.
61
62
63
64
65
/** Test, ob die Liste leer ist. */
public boolean isEmpty( ) {
return first == null;
}
isEmpty
66
67
68
69
70
/** Macht die Liste leer. */
public void makeEmpty( ) {
first = last = null;
}
makeEmpty
71
72
73
74
75
76
/** Fuegt einen neuen Knoten mit Inhalt inhalt vorne in die Liste ein. */
public void insertFirst( Object inhalt ) {
EinfachVerketteterKnoten k = new EinfachVerketteterKnoten( inhalt );
if( isEmpty( ) )
first = last = k;
insertFirst
32
else {
k.changeNext( first );
first = k;
}
77
78
79
80
81
KAPITEL 1. EINLEITUNG
}
82
83
84
85
86
87
88
89
90
91
92
/** Fuegt einen neuen Knoten mit Inhalt inhalt hinten in die Liste ein. */
public void insertLast( Object inhalt ) {
EinfachVerketteterKnoten k = new EinfachVerketteterKnoten( inhalt );
if( isEmpty( ) )
first = last = k;
else {
last.changeNext( k );
last = k;
}
}
insertLast
93
94
95
96
97
98
99
100
101
/** Gibt den Inhalt des ersten Knotens der Liste zurueck, wenn vorhanden.
* Ist die Liste leer, so wird eine Ausnahme ausgeloest.
*/
public Object head( ) {
if( isEmpty( ) )
throw new NoSuchElementException( "Liste ist leer." );
return first.inhalt( );
}
head
102
103
104
105
106
107
108
109
110
111
112
/** Entfernt den ersten Knoten der Liste, wenn vorhanden.
* Ist die Liste leer, so wird eine Ausnahme ausgeloest.
*/
public void tail( ) {
if( isEmpty( ) )
throw new NoSuchElementException( "Liste ist bereits leer." );
first = first.next( );
if( first == null ) // wenn die Liste leer geworden ist
last = null;
}
tail
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
/** Haengt die Liste list hinten an. Die Argumentliste wird danach
* leer sein. Wir fordern, dass die beiden Listen verschieden sind,
* ausser sie sind leer; andernfalls wird eine Ausnahme ausgeloest.
*/
public void concat( EinfachVerketteteListe list ) {
if( !isEmpty( ) && first == list.first )
throw new IllegalArgumentException( "Identische Listen verknuepft." );
if( !list.isEmpty( ) ) {
if( !isEmpty( ) )
last.changeNext( list.first );
else {
first = list.first;
}
last = list.last;
list.makeEmpty( );
}
}
concat
1.3. EINIGE EINFACHE STRUKTURIERTE DATENTYPEN
132
133
134
135
136
137
138
139
140
141
/** Gibt den String zurueck, der aus der Folge der String-Darstellungen
* der Knoteninhalte besteht, jeweils durch eine Leerzeile getrennt.
* Die Reihenfolge ist von first nach last.
*/
public String toString( ) {
StringBuffer ausgabe = new StringBuffer( );
for( EinfachVerketteterKnoten k = first; k != null; k = k.next( ) )
ausgabe.append( k.inhalt( ) + "\n" );
return ausgabe.toString( );
}
33
toString
142
143
144
/** Eine kleine Testroutine, die ueber die Kommandozeile benutzt wird. */
public static void main( String[ ] args ) throws IOException {
main
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
System.out.println( "Liste1 und Liste2 koennen ueber die Kommandozeile" +
" modifiziert\nwerden. Beide enthalten Eintraege vom Typ String.\n" +
"\tVerfuegbare Kommandos:\n" +
"\t\"e\" -- Liste1 leer machen (makeEmpty)\n" +
"\t\"E\" -- Liste2 leer machen (makeEmpty)\n" +
"\t\"i\" -- String vorn in Liste1 einfuegen (insertFirst)\n" +
"\t\"I\" -- String vorn in Liste2 einfuegen (insertFirst)\n" +
"\t\"h\" -- Erstes Element von Liste1 ausgeben (head)\n" +
"\t\"H\" -- Erstes Element von Liste2 ausgeben (head)\n" +
"\t\"t\" -- Erstes Element aus Liste1 loeschen (tail)\n" +
"\t\"T\" -- Erstes Element aus Liste2 loeschen (tail)\n" +
"\t\"c\" -- Liste2 hinten an Liste1 anhaengen (concat)\n" +
"\t\"p\" -- Liste1 von first nach last ausgeben (print)\n" +
"\t\"P\" -- Liste2 von first nach last ausgeben (print)\n" +
"\t\"q\" -- beendet das Programm (quit)" );
EinfachVerketteteListe list1 = new EinfachVerketteteListe( );
EinfachVerketteteListe list2 = new EinfachVerketteteListe( );
BufferedReader in = new BufferedReader( new InputStreamReader( System.in ) );
char command = ’ ’;
while( command != ’q’ ) {
switch( command ) {
case ’ ’ : { // Tue nichts
break;
}
case ’e’ : { // Liste1 leer machen
list1.makeEmpty( );
break;
}
case ’E’ : { // Liste2 leer machen
list2.makeEmpty( );
break;
}
case ’i’ : { // String in Liste1 einfuegen
System.out.println( "\tEinen String eingeben:" );
list1.insertFirst( in.readLine( ) );
break;
}
case ’I’ : { // String in Liste2 einfuegen
System.out.println( "\tEinen String eingeben:" );
34
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
KAPITEL 1. EINLEITUNG
list2.insertFirst( in.readLine( ) );
break;
}
case ’h’ : { // Erstes Element von Liste1 ausgeben
try {
System.out.println( "Erster Eintrag von Liste1:" + list1.head( ) );
}
catch( NoSuchElementException e ) { System.out.println( e ); }
break;
}
case ’H’ : { // Erstes Element von Liste2 ausgeben
try {
System.out.println( "Erster Eintrag von Liste2:" + list2.head( ) );
}
catch( NoSuchElementException e ) { System.out.println( e ); }
break;
}
case ’t’ : { // Erstes Element aus Liste1 loeschen
try {
list1.tail( );
}
catch( NoSuchElementException e ) { System.out.println( e ); }
break;
}
case ’T’ : { // Erstes Element aus Liste2 loeschen
try {
list2.tail( );
}
catch( NoSuchElementException e ) { System.out.println( e ); }
break;
}
case ’c’ : { // Liste2 hinten an Liste1 anhaengen
list1.concat( list2 );
break;
}
case ’p’ : { // Liste1 von first nach last ausgeben
System.out.println( "Liste1:\n" + list1 );
break;
}
case ’P’ : { // Liste2 von first nach last ausgeben
System.out.println( "Liste2:\n" + list2 );
break;
}
default :
System.out.println( "Kommando " + command + " existiert nicht." );
}
System.out.println( "Bitte Kommando eingeben:" );
try {
// Kommando einlesen, Leerzeichen entfernen, erstes Zeichen auswaehlen:
command = in.readLine( ).trim( ).charAt( 0 );
}
1.3. EINIGE EINFACHE STRUKTURIERTE DATENTYPEN
236
237
238
239
240
241
242
243
244
35
catch( IndexOutOfBoundsException e ) {
System.out.println( "Keine leeren Kommandos eingeben!" );
command = ’ ’;
continue;
}
}
} // main
} // class EinfachVerketteteListe
Die Datenstrukturen Stack und Queue können leicht durch einfach verkettete Listen realisiert werden, wodurch dann die bei der Implementierung durch
Arrays erforderlichen Begrenzungen der Größe wegfallen.
1.3.4
Stacks über Listen implementieren
Ein Stack der Form
am
← top
am−1
..
.
a1
kann als Liste so realisiert werden:
first
am
c
am−1 c
···
a1
Programm 1.3.6. Alle Stack-Operationen lassen sich nun einfach realisieren:
1
2
import java.io.*; // Testroutine main benutzt I/O-Klassen
3
4
/** Die Klasse Stack implementiert Stacks mittels einfach verketteter Listen. */
public class Stack {
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/** Die einfach verkettete Liste. */
private EinfachVerketteteListe list;
/** Konstruiert den leeren Stack. */
public Stack( ) {
list = new EinfachVerketteteListe( );
}
/** Test, ob der Stack leer ist. */
public boolean isEmpty( ) {
return list.isEmpty( );
}
Stack
isEmpty
36
19
20
21
22
KAPITEL 1. EINLEITUNG
/** Macht den Stack leer. */
public void makeEmpty( ) {
list.makeEmpty( );
}
makeEmpty
23
24
25
26
27
/** Fuegt ein neues Element oben in den Stack ein. */
public void push( Object inhalt ) {
list.insertFirst( inhalt );
}
push
28
29
30
31
32
33
34
35
36
/** Gibt das oberste Element des Stacks zurueck, wenn vorhanden.
* Ist der Stack leer, so wird eine Ausnahme ausgeloest.
*/
public Object top( ) {
if( isEmpty( ) )
throw new IllegalStateException( "Stack ist leer." );
return list.head( );
}
top
37
38
39
40
41
42
43
44
45
/** Entfernt das oberste Element des Stacks, wenn vorhanden.
* Ist der Stack leer, so wird eine Ausnahme ausgeloest.
*/
public void pop( ) {
if( isEmpty( ) )
throw new IllegalStateException( "Stack ist bereits leer." );
list.tail( );
}
pop
46
47
48
49
50
51
52
53
54
55
56
/** Gibt das oberste Element des Stacks zurueck und entfernt es,
* wenn vorhanden. Ist der Stack leer, so wird eine Ausnahme ausgeloest.
*/
public Object topAndPop( ) {
if( isEmpty( ) )
throw new IllegalStateException( "Stack ist bereits leer." );
Object topElement = list.head( );
list.tail( );
return topElement;
}
topAndPop
57
58
59
60
61
62
63
64
/** Gibt den String zurueck, der aus der Folge der String-Darstellungen
* der Stack-Elemente besteht, jeweils durch eine Leerzeile getrennt.
* Die Reihenfolge ist von oben nach unten.
*/
public String toString( ) {
return list.toString( );
}
toString
65
66
67
/** Eine kleine Testroutine, die ueber die Kommandozeile benutzt wird. */
public static void main( String[ ] args ) throws IOException {
wie in Programm 1.3.1
127
} // main
128
129
} // class Stack
main
1.3. EINIGE EINFACHE STRUKTURIERTE DATENTYPEN
1.3.5
37
Queues über Listen implementieren
Eine Queue der Form
a1
a2
↑
ANFANG
...
am−1 am
↑
ENDE
kann als Liste so realisiert werden:
a1
c
a2
c
···
am−1 c
first
am
last
Programm 1.3.7. Auch alle Queue-Operationen lassen sich damit leicht realisieren:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import java.util.NoSuchElementException; // zur Fehlerbehandlung
import java.io.*; // Testroutine main benutzt I/O-Klassen
/** Die Klasse Queue implementiert Queues mittels einfach verketteter Listen. */
public class Queue {
/** Die einfach verkettete Liste. */
private EinfachVerketteteListe list;
/** Konstuiert die leere Queue. */
public Queue( ) {
list = new EinfachVerketteteListe( );
}
/** Test, ob die Queue leer ist. */
public boolean isEmpty( ) {
return list.isEmpty( );
}
/** Macht die Queue leer. */
public void makeEmpty( ) {
list.makeEmpty( );
}
/** Fuegt ein neues Element hinten in die Queue ein. */
public void enqueue( Object inhalt ) {
list.insertLast( inhalt );
}
/** Gibt das vorderste Element der Queue zurueck, wenn vorhanden.
* Ist die Queue leer, so wird eine Ausnahme ausgeloest.
*/
Queue
isEmpty
makeEmpty
enqueue
38
33
34
35
36
37
KAPITEL 1. EINLEITUNG
public Object front( ) {
if( isEmpty( ) )
throw new NoSuchElementException( "Queue ist leer." );
return list.head( );
}
front
38
39
40
41
42
43
44
45
46
/** Entfernt das vorderste Element der Queue, wenn vorhanden.
* Ist die Queue leer, so wird eine Ausnahme ausgeloest.
*/
public void dequeue( ) {
if( isEmpty( ) )
throw new NoSuchElementException( "Queue ist bereits leer." );
list.tail( );
}
dequeue
47
48
49
50
51
52
53
54
/** Gibt den String zurueck, der aus der Folge der String-Darstellungen
* der Queue-Elemente besteht, jeweils durch eine Leerzeile getrennt.
* Die Reihenfolge ist von vorne nach hinten.
*/
public String toString( ) {
return list.toString( );
}
toString
55
56
57
/** Eine kleine Testroutine, die ueber die Kommandozeile benutzt wird. */
public static void main( String[ ] args ) throws IOException {
wie in Programm 1.3.3
117
} // main
118
119
} // class Queue
1.3.6
Doppelt verkettete Listen
Bei doppelt verketteten Listen (engl. doubly linked lists) speichert jeder Knoten neben dem Verweis auf seinen Nachfolger auch einen Verweis auf seinen
Vorgänger:
%
%
%
a1
first
c
c
a2
c
···
···
c am−1
c
c
am
last
Die zusätzlichen Verweise verbrauchen zwar zusätzlich Speicherplatz, dafür
ermöglichen sie es aber, gewisse Operationen effizienter zu implementieren.
main
1.4. RECHENZEIT- UND SPEICHERPLATZBEDARF VON ALGORITHMEN39
1.4
Rechenzeit- und Speicherplatzbedarf von Algorithmen
Es gibt viele Kriterien zur Beurteilung von Algorithmen (Programmen), z.B.:
• Korrektheit: Arbeitet der Algorithmus korrekt bzgl. der gegebenen Problemspezifikation? (Korrektheitsbeweis: Programmverifikation)
• Dokumentation: Sind sowohl die Arbeitsweise als auch das Ein-/Ausgabeverhalten hinreichend gut beschrieben?
• Modularität: Werden in sich abgeschlossene Teilaufgaben als Methoden
bereitgestellt?
• Lesbarkeit: Ist der Code lesbar (Formatierung, Kommentare)?
• Implementierbarkeit: Ist der Algorithmus leicht zu implementieren?
Außer diesen statischen Eigenschaften gibt es natürlich auch solche, die sich auf
das Laufzeitverhalten beziehen.
Definition 1.4.1. Sei A ein Algorithmus (Programm) zur Berechnung einer
Funktion f : Σ∗ → Σ∗ , und für alle n ∈ N sei Pn : Σn → [0, 1] eine Wahrscheinlichkeitsverteilung auf der Menge Σn der Eingaben der Länge n.
a) Für w ∈ Σ∗ sei tA (w) die Anzahl der Einzelschritte, die A bei Eingabe
w macht. Die Zeitkomplexität (Rechenzeitbedarf im schlechtesten Fall )
TA : N → N von A ist definiert als
TA (n) = max{tA (w) | w ∈ Σn }.
(P )
Die mittlere Zeitkomplexität (Rechenzeitbedarf im Mittel ) TA
von A ist definiert als
:N→N
(P )
TA (n) = E(tA (w) | w ∈ Σn ),
d.h. als der Erwartungswert für den Rechenzeitbedarf für Eingaben der
P
(P )
Länge n; damit gilt TA (n) =
tA (w) · Pn (w).
w∈Σn
(b) Für w ∈ Σ∗ sei sA (w) die Anzahl der Speicherplätze für Daten, die A bei
Eingabe w benutzt. Die Platzkomplexität (Speicherplatzbedarf im schlechtesten Fall ) SA : N → N von A ist definiert als
SA (n) = max{sA (w) | w ∈ Σn }.
(P )
Die mittlere Platzkomplexität (Speicherplatzbedarf im Mittel ) SA
N von A ist definiert als
(P )
SA (n) = E(sA (w) | w ∈ Σn ).
:N→
40
KAPITEL 1. EINLEITUNG
Damit die Zeit- und die Platzkomplexität eines Algorithmus bestimmt werden kann, muss genau festlegen werden, wie die Einzelschritte und Speicherplätze gezählt werden sollen. Dazu wird meist ein mehr oder weniger abstraktes
Maschinenmodell festgelegt, das Schrittzahl und Platzverbrauch klar zu definieren gestattet. In der Komplexitätstheorie sind beispielsweise Turing-Maschinen2
oder RAMs (Random access machines) gängige Modelle. Zur weiteren Vertiefung verweisen wir auf die Lehrveranstaltung Einführung in die Theoretische
”
Informatik“.
1.5
Asymptotisches Wachstumsverhalten
Den Speicherplatz- und Rechenzeitbedarf eines Algorithmus kann man oft nicht
exakt angeben, weil der genaue Zusammenhang zwischen der Eingabegröße und
diesen Werten viel zu kompliziert ist. Wir werden uns daher meistens damit
begnügen, das asymptotische Wachstumsverhalten dieser Größen zu bestimmen. Dazu verwenden wir die folgenden Notationen.
Definition 1.5.1. Sei f : N → N eine Funktion.
• O(f ) = {g : N → N | ∃c ∈ R+ ∃n0 ∈ N ∀n ≥ n0 : g(n) ≤ c · f (n)}
g ∈ O(f ) : g wächst asymptotisch nicht schneller als f .
• Ω(f ) = {g : N → N | ∃c ∈ R+ ∃n0 ∈ N ∀n ≥ n0 : g(n) ≥ c · f (n)}
g ∈ Ω(f ) : g wächst asymptotisch nicht langsamer als f .
• Θ(f ) = O(f ) ∩ Ω(f ), d.h. g ∈ Θ(f ) gdw. g ∈ O(f ) und g ∈ Ω(f ).
g ∈ Θ(f ) : g wächst asymptotisch genauso schnell wie f .
Lemma 1.5.2. Für Funktionen f, g : N → N gilt g ∈ Ω(f ) gdw. f ∈ O(g).
Beweis:
g ∈ Ω(f )
gdw. ∃c ∈ R+ ∃n0 ∈ N ∀n ≥ n0 : g(n) ≥ c · f (n)
1
gdw. ∃c ∈ R+ ∃n0 ∈ N ∀n ≥ n0 : · g(n) ≥ f (n)
c
gdw. ∃d ∈ R+ ∃n0 ∈ N ∀n ≥ n0 : f (n) ≤ d · g(n)
gdw. f ∈ O(g).
Lemma 1.5.3. Für Funktionen f, g : N → N gilt g ∈ Θ(f ) genau dann, wenn
∃c, d ∈ R+ ∃n0 ∈ N ∀n ≥ n0 : c · f (n) ≤ g(n) ≤ d · f (n).
Beweis: Als Übung.
2
Nach Alan Turing (1912-1954)
1.5. ASYMPTOTISCHES WACHSTUMSVERHALTEN
Beispiel 1.5.4. Für f (n) = n2 , g(n) =
1
2
41
· n · (n + 1) und h(n) = n3 gelten:
g ∈ O(h),
g ∈ O(f ) und g ∈ Ω(f ), d.h. g ∈ Θ(f ),
g 6∈ Ω(h).
Satz 1.5.5. Sei f (n) = am ·nm +am−1 ·nm−1 +· · ·+a1 ·n+a0 mit a0 , . . . , am ∈ Z
ein Polynom mit am > 0. Dann gilt f ∈ Θ(nm ).
Beweis: Für alle n ≥ 1 gilt: f (n) =
m
P
i=0
ai ·
ni
≤
m
P
i=0
|ai | ·
ni
≤
m
P
|ai | · nm .
i=0
Also ist f ∈ O(nm ). Und wegen am > 0 gibt es ein n0 ∈ N mit der Eigenschaft
∀n ≥ n0 : am · nm + 2am−1 · nm−1 + · · · + 2a1 · n + 2a0 ≥ 0.
Daraus folgt: ∀n ≥ n0 : nm ≤ am · nm ≤ 2f (n), d.h. nm ∈ O(f ). Mit Lemma
1.5.2 folgt: f ∈ Θ(nm ).
Zum Schluss dieses Abschnitts wollen wir uns noch anschauen, wie sich die
verschiedenen Zeitkomplexitäten auswirken. Die erste Tabelle zeigt, wie sich für
die verschiedenen Zeitschranken die Rechenzeit mit der Eingabegröße verändert.
Zeitschranke
f (n)
f (n) = n
f (n) = nblog nc
f (n) = n2
3
f (n) = n√
f (n) = 2 n
f (n) = 2n
f (n) = n!
Rechenzeit für Eingabegröße n bei 109
n = 10
n = 20
n = 30
10−8 s
2 · 10−8 s
3 · 10−8 s
3 · 10−8 s
8 · 10−8 s 1, 5 · 10−7 s
−7
1 · 10 s
4 · 10−7 s
9 · 10−7 s
1 · 10−6 s
8 · 10−6 s 2, 7 · 10−5 s
8 · 10−9 s 2, 2 · 10−8 s 4, 5 · 10−8 s
1 · 10−6 s
1 · 10−3 s
1 sec
−3
3, 6 · 10 s
77 Jahre
···
Operationen pro Sekunde
n = 40
n = 50
4 · 10−8 s
5 · 10−8 s
2, 2 · 10−7 s
3 · 10−7 s
−6
1, 6 · 10 s
2, 5 · 10−6 s
6, 4 · 10−5 s
1, 2 · 10−4 s
−8
7 · 10 s
1, 3 · 10−7 s
1000 sec 11 Tg. 13 Std.
···
···
Die zweite Tabelle gibt für die verschiedenen Zeitschranken an, welche Eingabegrößen noch innerhalb einer gegebenen Zeit verarbeitet werden können.
Zeitkomplexität
f (n)
f (n) = n
f (n) = n · blog2 nc
f (n) = n2
3
f (n) = n√
f (n) = 2 n
f (n) = 2n
f (n) = n!
Maximale Eingabegröße für Zeit t
t = 1 sec. t = 1 min. t = 1 Std.
109
6 · 1010
3, 6 · 1012
7
9
6 · 10
2, 8 · 10
1, 3 · 1011
3 · 104
2, 5 · 105
2 · 106
1000
4000
15000
900
1300
1400
30
36
37
12
14
15
Wir sehen also:
• Die Steigerung der Rechengeschwindigkeit wirkt sich am deutlichsten bei
schnellen Algorithmen aus.
42
KAPITEL 1. EINLEITUNG
• Der Übergang zu einem schnelleren Algorithmus ist oft wirksamer als der
zu einer schnelleren Maschine.
• Für die Lösung von Problemen, die häufig auftreten, etwa als Teilprobleme anderer Probleme, lohnt sich der Aufwand, nach einem optimalen
”
Algorithmus“ zu suchen.
1.6
Die Java-Klassenbibliothek
Viele objektorientierte Programmiersprachen bieten einen großen Vorrat an vordefinierten Datenstrukturen, entweder bereits als Teil der Kernsprache oder
als mehr oder weniger standardisierte Erweiterungen (für C++ etwa durch die
Standard Template Library). Auch Java bringt viele für diese Lehrveranstaltung
relevanten Strukturen und Algorithmen mit.
In diesem Abschnitt soll beispielhaft gezeigt werden, wie Queues über der JavaKlasse LinkedList implementiert werden können. Der Datentyp LinkedList entspricht weitgehend den in Abschnitt 1.3.3 vorgestellten verketteten Listen. Hier
einige Ausschnitte aus dem Java-API (Application Programming Interface) (siehe http://java.sun.com/j2se/1.3/docs/api/):
Class LinkedList
java.lang.Object
java.util.AbstractCollection
java.util.AbstractList
java.util.AbstractSequentialList
java.util.LinkedList
All Implemented Interfaces: Cloneable, Collection, List, Serializable
Linked list implementation of the List interface. Implements all optional list
operations, and permits all elements (including null). In addition to implementing the List interface, the LinkedList class provides uniformly named methods
to get, remove and insert an element at the beginning and end of the list. These operations allow linked lists to be used as a stack, queue, or double-ended
queue (deque). [ . . . ] All of the operations perform as could be expected for a
doubly-linked list. [ . . . ]
• public LinkedList() Constructs an empty list.
• public Object getFirst() Returns the first element in this list.
1.6. DIE JAVA-KLASSENBIBLIOTHEK
43
• public Object getLast() Returns the last element in this list. Throws
NoSuchElementException if this list is empty.
• public Object removeFirst() Removes and returns the first element from
this list. Throws NoSuchElementException if this list is empty.
• public Object removeLast() Removes and returns the last element from
this list. Throws NoSuchElementException if this list is empty.
• public void addFirst(Object o) Inserts the given element at the beginning of this list.
• public void addLast(Object o) Appends the given element to the end of
this list.
• public boolean add(Object o) Appends the specified element to the end
of this list. Returns true (as per the general contract of Collection.add3 ).
• public void clear() Removes all of the elements from this list.
• public String toString() Returns a string representation of this collection. The string representation consists of a list of the collection’s elements
in the order they are returned by its iterator, enclosed in square brackets
("[]"). Adjacent elements are separated by the characters ", " (comma
and space). Elements are converted to strings as by String.valueOf(Object).
Programm 1.6.1. Eine Implementierung von Queue über LinkedList:
import java.util.LinkedList; // zur internen Darstellung der Queue
import java.util.NoSuchElementException; // zur Fehlerbehandlung
3 import java.io.*; // Testroutine main benutzt I/O-Klassen
1
2
4
5
6
/** Die Klasse Queue implementiert Queues ueber der Java-Klasse LinkedList. */
public class Queue {
7
8
9
/** Die verkettete Liste. */
private LinkedList list;
10
11
12
13
14
/** Konstuiert die leere Queue. */
public Queue( ) {
list = new LinkedList( );
}
Queue
15
16
17
18
19
/** Test, ob die Queue leer ist. */
public boolean isEmpty( ) {
return list.isEmpty( );
}
20
3
In der Dokumentation des Interface Collection steht nämlich: Returns true if this collection
changed as a result of the call.
isEmpty
44
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
/** Macht die Queue leer. */
public void makeEmpty( ) {
list.clear( );
}
/** Fuegt ein neues Element hinten in die Queue ein. */
public void enqueue( Object inhalt ) {
list.addLast( inhalt );
}
/** Gibt das vorderste Element der Queue zurueck, wenn vorhanden.
* Ist die Queue leer, so wird eine Ausnahme ausgeloest.
*/
public Object front( ) {
if( isEmpty( ) )
throw new NoSuchElementException( "Queue ist leer." );
return list.getFirst( );
}
/** Entfernt das vorderste Element der Queue, wenn vorhanden.
* Ist die Queue leer, so wird eine Ausnahme ausgeloest.
*/
public void dequeue( ) {
if( isEmpty( ) )
throw new NoSuchElementException( "Queue ist bereits leer." );
list.removeFirst( );
}
119
120
makeEmpty
enqueue
front
dequeue
/** Gibt den String zurueck, der aus der Folge der String-Darstellungen
* der Queue-Elemente besteht, in eckigen Klammern und jeweils durch Komma getrennt.
* Die Reihenfolge ist von vorne nach hinten.
*/
public String toString( ) {
toString
return list.toString( );
}
/** Eine kleine Testroutine, die ueber die Kommandozeile benutzt wird. */
public static void main( String[ ] args ) throws IOException {
wie in Programm 1.3.3
118
KAPITEL 1. EINLEITUNG
} // main
} // class Queue
main
Kapitel 2
Bäume und ihre
Implementierung
Die Datenstruktur Baum“ ist von großer Bedeutung, da man damit sehr ein”
fach hierarchische Beziehungen zwischen Objekten darstellen kann. Solche Hierarchien treten in natürlicher Weise in vielen Anwendungen auf:
• Gliederung eines Unternehmens in Bereiche, Abteilungen, Gruppen und
Mitarbeiter
• Gliederung eines Buches in Kapitel, Abschnitte und Unterabschnitte
• Gliederung eines Programms in Methoden, Blöcke und Einzelanweisungen
Ein Baum besteht aus einer (endlichen) Menge von Knoten1 V und einer (endlichen) Menge von gerichteten Kanten2 E zwischen Knoten aus V . Führt eine
Kante von u ∈ V zu v ∈ V , dann heißt u der Elternknoten von v und v ein Kind
von u. Ist v in einem oder mehreren Schritten (d.h. durch eine Folge von Kanten) von u aus erreichbar, dann ist u ein Vorgänger von v und v ein Nachfolger
von u. Ein Baum hat einen ausgezeichneten Knoten, die Wurzel, der Vorgänger
jedes anderen Knotens ist. Außerdem ist jeder Knoten auf nur genau eine Weise
von der Wurzel aus erreichbar.
1
2
engl. vertices
engl. edges
45
46
KAPITEL 2. BÄUME UND IHRE IMPLEMENTIERUNG
Im Allgemeinen stellen wir einen Baum graphisch wie folgt dar:
89:;
?>=<
1 TTTTT
jjjj
TTTT
j
j
j
j
TTTT
j
j
j
j
TTT
jjjj
89:;
?>=<
89:;
?>=<
89:;
?>=<
2?
3?
4?
??
?
?



?
?



??
?
?



?
?



89:;
?>=<
89:;
?>=<
89:;
?>=<
89:;
?>=<
89:;
?>=<
@ABC
GFED
@ABC
5
6
7
8
9
10 GFED
11
@ABC
GFED
12
@ABC
GFED
13
()*+
Dieser Baum hat 13 Knoten und 12 Kanten. Knoten /.-,
1 ist die Wurzel. Die
()*+
/.-,
()*+
/.-,
()*+
/.-,
Knoten 2 , 3 und 4 sind die Kinder der Wurzel, sie sind also Geschwister.
0123
()*+
0123
0123
Die Knoten 7654
12 , /.-,
6 bis 7654
10 und 7654
13 haben keine Kinder. Sie sind die Blätter
des Baums, während die anderen Knoten die inneren Knoten des Baums sind.
Der Grad d(v) eines Knotens v ist die Anzahl seiner Kinder.
2.1
Die Datenstruktur Baum
Bei Bäumen über einer Knotenmenge V unterscheiden wir zwei Arten, die ungeordneten und die geordneten. Bei der ersten Struktur kommt es auf die Reihenfolge der Unterbäume nicht an, bei der zweiten aber sehr wohl. Wir formalisieren einen ungeordneten Baum (oder einfach: Baum) als Paar aus seiner Wurzel
und der Menge seiner direkten Unterbäume, einen geordneten Baum stattdessen als (geordnete) Folge seiner Wurzel gefolgt von der Folge seiner direkten
Unterbäume. Beide Definitionen sind also rekursiv.
Definition 2.1.1 ((Ungeordneter) Baum). Sei V eine Menge von Knoten.
• Für v ∈ V ist (v, {}) ein Baum; er hat nur den Knoten v, seine Wurzel.
• Sind B1 , . . . , Bm (m ≥ 1) Bäume mit paarweise disjunkten Knotenmengen, und ist v ∈ V ein weiterer Knoten, dann ist
B = (v, {B1 , . . . , Bm })
ein Baum mit Wurzel v und direkten Unterbäumen (oder: Teilbäumen)
B1 , . . . , Bm . Graphisch stellen wir B wie folgt dar, wobei {Bi1 , . . . , Bim } =
{B1 , . . . , Bm } sei:
v
Q
Q
Q
Q
A
A
A
A
...
A
A
Bi 1 A
B im A
A
A
2.1. DIE DATENSTRUKTUR BAUM
47
Definition 2.1.2 (Geordneter Baum). Sei V wie oben.
• Für v ∈ V ist (v) ein geordneter Baum; er hat nur den Knoten v, seine
Wurzel.
• Sind B1 , . . . , Bm (m ≥ 1) geordnete Bäume mit paarweise disjunkten
Knotenmengen, und ist v ∈ V ein weiterer Knoten, dann ist
B = (v, B1 , . . . , Bm )
ein geordneter Baum mit Wurzel v, und für 1 ≤ i ≤ m ist Bi der i-te
direkte Unterbaum (oder: Teilbaum) von B.
Notationen:
Die Tiefe (oder Stufe) eines Knotens v in einem Baum wird rekursiv definiert:
Tiefe(v) = 0
wenn v die Wurzel ist,
0
Tiefe(v) = 1 + Tiefe(v )
wenn v 0 Elternknoten von v ist.
Die Höhe eines Baumes B ist definiert als
Höhe(B) = max{Tiefe(v) | v ist Knoten von B}.
Ein wichtiger Sonderfall der geordneten Bäume sind die binären Bäume. In
Abweichung zu obiger Definition lässt man auch leere binäre Bäume zu, d.h.
binäre Bäume ohne Knoten.
Definition 2.1.3. Ein binärer Baum ist entweder leer, oder er ist ein geordneter Baum mit Knotenmenge V 6= ∅, und es gilt d(v) ≤ 2 für alle v ∈ V .
Wir unterscheiden bei einem Knoten eines binären Baumes zwischen dem linken
und dem rechten Teilbaum.
Beispiel 2.1.4. B1 :
89:;
?>=<
1
vv
v
v
v
v
89:;
?>=<
2
B2 :
?>=<
89:;
1 HH
HHH
H
?>=<
89:;
2
Als Bäume sind B1 und B2 identisch, nicht aber als binäre Bäume.
Nachdem wir die Objekte Binäre Bäume“ definiert haben, können wir nun
”
die Datenstruktur Binäre Bäume über einer Menge Item“ einführen. Hier eine
”
mögliche algebraische Spezifikation dieses Datentyps:
48
KAPITEL 2. BÄUME UND IHRE IMPLEMENTIERUNG
Die Signatur mit den Sortensymbolen btree, boolean und item:
EMPTY : → btree
TREE : btree × item × btree → btree
ISEMPTY : btree → boolean
LCHILD : btree → btree
ITEM : btree → item
RCHILD : btree → btree
ERROR1 : → item
ERROR2 : → btree
TRUE, FALSE : → boolean
Die Axiome:
ISEMPTY(EMPTY) = TRUE
ISEMPTY(TREE(b1 , i, b2 )) = FALSE
LCHILD(EMPTY) = ERROR2
LCHILD(TREE(b1 , i, b2 )) = b1
ITEM(EMPTY) = ERROR1
ITEM(TREE(b1 , i, b2 )) = i
RCHILD(EMPTY) = ERROR2
RCHILD(TREE(b1 , i, b2 )) = b2
Lemma 2.1.5. Sei B ein nicht-leerer binärer Baum der Höhe h.
(a) Für 0 ≤ i ≤ h gilt, dass B höchstens 2i Knoten der Tiefe i enthält.
(b) B enthält mindestens h + 1 und höchstens 2h+1 − 1 Knoten.
(c) Sei n die Anzahl der Knoten in B. Dann gilt
dlog(n + 1)e − 1 ≤ h ≤ n − 1.
Beweis: (a) Beweis durch Induktion nach i. (b) Offensichtlich gilt n ≥ h + 1.
Andererseits gilt:
n=
h
X
Anzahl der Knoten der Tiefe i in B ≤
i=0
h
X
(a) i=0
2i = 2h+1 − 1.
(c) Nach (b) gilt h ≤ n − 1. Andererseits gilt nach (b) auch n ≤ 2h+1 − 1, d.h.
log(n + 1) ≤ h + 1. Da h ganzzahlig ist, bedeutet dies dlog(n + 1)e − 1 ≤ h.
Lemma 2.1.6. Sei B ein nicht-leerer binärer Baum. Ist n0 die Anzahl der
Blätter und n2 die Anzahl der Knoten vom Grad 2 in B, so gilt n0 = n2 + 1.
Beweis: Durch Induktion über den Aufbau von B.
2.2. IMPLEMENTIERUNG BINÄRER BÄUME
49
Definition 2.1.7. Ein nicht-leerer binärer Baum der Höhe h ist voll, wenn er
2h+1 − 1 Knoten enthält.
Definition 2.1.8. Sei B ein binärer Baum der Höhe h mit n > 0 Knoten
und sei B 0 ein voller binärer Baum der Höhe h. In B 0 seien die Knoten stufenweise von links nach rechts durchnummeriert (vgl. obiges Beispiel). B heißt
vollständig, wenn er dem binären Baum B 00 entspricht, der aus B 0 entsteht, indem die Knoten mit den Nummern n + 1, n + 2, . . . , 2h+1 − 1 gestrichen werden.
Auch den leeren Baum wollen wir sowohl voll als auch vollständig nennen.
Beispiel 2.1.9. Die Abbildung zeigt einen vollen binären Baum der Höhe 2.
Werden die Knoten mit den Nummern 6 und 7 gestrichen, entsteht ein vollständiger binärer Baum mit fünf Knoten.
1
HH
H
2
3
@
@
@
@
4
5
6
7
Beachte: Für vollständige binäre Bäume mit n > 0 Knoten und Höhe h gilt
2h ≤ n < 2h+1 .
2.2
Implementierung binärer Bäume
Für vollständige binäre Bäume (Definition 2.1.8) erhalten wir eine sehr einfache
und effiziente Implementierung mittels eindimensionaler Felder.
Sei B ein vollständiger binärer Baum mit n Knoten, wobei in den Knoten
Elemente der Menge Item gespeichert seien3 . Wir implementieren B durch ein
Feld der Länge n; der Knoten mit Index i (1 ≤ i ≤ n) wird an der Position
i − 1 gespeichert. (Statt vom Knoten mit Index i sprechen wir auch kurz vom
Knoten i.) Dabei ist uns die folgende Beobachtung von Nutzen.
Lemma 2.2.1. Für vollständige binäre Bäume mit n Knoten gilt (1 ≤ i ≤ n):
(a) Ist i > 1, so ist der Knoten bi/2c der Elternknoten des Knotens i.
(b) Ist 2i ≤ n, so ist der Knoten 2i das linke Kind des Knotens i.
(c) Ist 2i + 1 ≤ n, so ist der Knoten 2i + 1 das rechte Kind des Knotens i.
3
Das in einem Knoten gespeicherte Datenelement nennen wir auch Schlüssel des Knoten.
50
KAPITEL 2. BÄUME UND IHRE IMPLEMENTIERUNG
Beweis: (a) folgt aus (b) und (c).
(b) Durch Induktion nach i: Der Fall i = 1 ist klar. Für Knoten j (1 ≤ j ≤ i−1)
ist nach Induktionsannahme das linke Kind der Knoten 2j. Auf das linke Kind
von i − 1 folgt das rechte Kind von i − 1 und dann das linke Kind von i; das
linke Kind des Knoten i ist also der Knoten 2(i − 1) + 2 = 2i, falls 2i ≤ n ist.
(c) Analog zu (b).
Programm 2.2.2. Eine Implementierung vollständiger binärer Bäume:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
/**
* Die Klasse VollstaendigerBinaererBaum implementiert
* vollstaendige binaere Baeume mittels Arrays.
*
* Die Knoten des Baums haben einen Index i mit
* 1 <= i <= Anzahl der Knoten. Der Knoten mit Index i
* wird im Array an der Position i-1 gespeichert.
* Jeder Knoten speichert ein beliebiges Objekt vom Typ Object.
*/
public class VollstaendigerBinaererBaum {
/** Das Array, in dem die Knoteninhalte gespeichert werden. */
private Object[ ] array;
/** Die Anzahl der Knoten. */
private int knotenZahl;
/** Der Konstruktor legt einen flachen Klon des Argument-Arrays an. */
public VollstaendigerBinaererBaum( Object[ ] array ) {
this.array = (Object[ ]) array.clone( );
knotenZahl = array.length;
}
/** Der Konstruktor legt einen leeren Baum
* in einem Array der Groesse maxKnotenZahl an.
*/
public VollstaendigerBinaererBaum( int maxKnotenZahl ) {
this( new Object[maxKnotenZahl] );
knotenZahl = 0;
}
/** Test, ob der Baum leer ist. */
public boolean istLeer( ) {
return knotenZahl == 0;
}
/** Gibt die Anzahl der Knoten zurueck. */
public int groesse( ) {
return knotenZahl;
}
VollstaendigerBinaererBaum
VollstaendigerBinaererBaum
istLeer
groesse
2.2. IMPLEMENTIERUNG BINÄRER BÄUME
42
43
44
45
46
47
48
49
50
51
/** Gibt das im Knoten mit Index i gespeicherte Objekt zurueck.
* Fuer die Zahl i muss 1 <= i <= knotenZahl gelten.
*/
public Object inhalt( int i ) {
if( i < 1 | | i > knotenZahl )
throw new
IndexOutOfBoundsException( "Zugriff auf nicht vorhandenen Knoten" );
return array[i−1];
}
inhalt
51
52
53
54
55
56
57
58
59
60
/** Der Inhalt des Knotens mit Index i wird das Objekt obj.
* Fuer die Zahl i muss 1 <= i <= knotenZahl gelten.
*/
public void inhaltAendern( int i, Object obj ) {
if( i < 1 | | i > knotenZahl )
throw new
IndexOutOfBoundsException( "Zugriff auf nicht vorhandenen Knoten" );
array[i−1] = obj;
}
inhaltAendern
61
62
63
64
65
66
67
68
69
70
/** Gibt den Index des ersten Knotens zurueck, der das Objekt obj speichert,
* falls einer vorhanden ist, sonst 0. Die Objekte werden mit equals verglichen.
*/
public int findeErstesVorkommen( Object obj ) {
for( int i = 1; i <= knotenZahl; i++ )
if( array[i−1].equals( obj ) )
return i;
return 0;
}
findeErstesVorkommen
71
72
73
74
75
76
77
78
79
80
81
82
/** Gibt den Index des linken Kindes des Knotens mit Index i zurueck,
* falls eines vorhanden ist, sonst 0.
* Fuer die Zahl i muss 1 <= i <= knotenZahl gelten.
*/
public int linkesKind( int i ) {
if( i < 1 | | i > knotenZahl )
throw new
IndexOutOfBoundsException( "Zugriff auf nicht vorhandenen Knoten" );
int j = 2*i;
return j <= knotenZahl ? j : 0;
}
linkesKind
83
84
85
86
87
88
89
90
91
92
93
94
95
/** Gibt den Index des rechten Kindes des Knotens mit Index i zurueck,
* falls eines vorhanden ist, sonst 0.
* Fuer die Zahl i muss 1 <= i <= knotenZahl gelten.
*/
public int rechtesKind( int i ) {
if( i < 1 | | i > knotenZahl )
throw new
IndexOutOfBoundsException( "Zugriff auf nicht vorhandenen Knoten" );
int j = 2*i+1;
return j <= knotenZahl ? j : 0;
}
rechtesKind
52
96
97
98
99
100
101
102
103
104
105
KAPITEL 2. BÄUME UND IHRE IMPLEMENTIERUNG
/** Gibt den Index des Elternknotens des Knotens mit Index i zurueck,
* falls einer vorhanden ist, sonst 0.
* Fuer die Zahl i muss 1 <= i <= knotenZahl gelten.
*/
public int elternknoten( int i ) {
if( i < 1 | | i > knotenZahl )
throw new
IndexOutOfBoundsException( "Zugriff auf nicht vorhandenen Knoten" );
return i/2; // wird implizit abgerundet
}
elternknoten
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
/** Gibt den Baum stufenweise als String zurueck. */
public String toString( ) {
if( istLeer( ) )
return "Der Baum ist leer.";
StringBuffer ausgabe = new StringBuffer( );
int stufe = 0;
int k = 2; // k ist immer 2^(stufe+1)
int i = 1; // i ist der Index des naechsten auszugebenden Knotens
while( i <= knotenZahl ) {
ausgabe.append( "Stufe " + stufe + ":" );
for( ; i < k; i++ ) {
if( i <= knotenZahl )
ausgabe.append( " " + array[i−1] );
}
ausgabe.append( ’\n’ );
stufe++;
k *= 2;
}
return ausgabe.toString( );
}
toString
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
public static void main( String[ ] args ) {
Character[ ] c = {
new Character( ’a’ ), new Character( ’b’ ), new Character( ’c’ ),
new Character( ’d’ ), new Character( ’e’ ), new Character( ’f’ ),
new Character( ’g’ ), new Character( ’h’ )
};
VollstaendigerBinaererBaum b1 = new VollstaendigerBinaererBaum( c );
System.out.println( b1 );
for( int i = 1; i <= b1.groesse( ); i++ ) {
System.out.println(
"Der Knoten " + i + " hat den Inhalt " + b1.inhalt( i ) + "," );
int l = b1.linkesKind( i );
System.out.print(
l != 0 ? " das linke Kind " + l : " kein linkes Kind" );
int r = b1.rechtesKind( i );
System.out.print(
r != 0 ? ", das rechte Kind " + r : ", kein rechtes Kind" );
int e = b1.elternknoten( i );
System.out.println(
e != 0 ? " und den Elternknoten " + e : " und keinen Elternknoten" );
}
main
2.2. IMPLEMENTIERUNG BINÄRER BÄUME
149
System.out.println( );
150
151
152
153
154
155
156
157
158
159
160
int finda = b1.findeErstesVorkommen( new Character( ’a’ ) );
System.out.println( "Das Zeichen ’a’ steht in " +
( finda != 0 ? "Knoten " + finda : "keinem Knoten" ) );
int findh = b1.findeErstesVorkommen( new Character( ’h’ ) );
System.out.println( "Das Zeichen ’h’ steht in " +
( findh != 0 ? "Knoten " + findh : "keinem Knoten" ) );
int findi = b1.findeErstesVorkommen( new Character( ’i’ ) );
System.out.println( "Das Zeichen ’i’ steht in " +
( findi != 0 ? "Knoten " + findi : "keinem Knoten" ) );
System.out.println( );
161
162
163
164
b1.inhaltAendern( 6, new Character( ’z’ ) );
System.out.print( b1 );
} // main
165
166
} // class VollstaendigerBinaererBaum
Ein Testlauf:
Stufe
Stufe
Stufe
Stufe
0:
1:
2:
3:
a
b c
d e f g
h
Der Knoten 1 hat den Inhalt a,
das linke Kind 2, das rechte Kind 3
Der Knoten 2 hat den Inhalt b,
das linke Kind 4, das rechte Kind 5
Der Knoten 3 hat den Inhalt c,
das linke Kind 6, das rechte Kind 7
Der Knoten 4 hat den Inhalt d,
das linke Kind 8, kein rechtes Kind
Der Knoten 5 hat den Inhalt e,
kein linkes Kind, kein rechtes Kind
Der Knoten 6 hat den Inhalt f,
kein linkes Kind, kein rechtes Kind
Der Knoten 7 hat den Inhalt g,
kein linkes Kind, kein rechtes Kind
Der Knoten 8 hat den Inhalt h,
kein linkes Kind, kein rechtes Kind
und keinen Elternknoten
und den Elternknoten 1
und den Elternknoten 1
und den Elternknoten 2
und den Elternknoten 2
und den Elternknoten 3
und den Elternknoten 3
und den Elternknoten 4
Das Zeichen ’a’ steht in Knoten 1
Das Zeichen ’h’ steht in Knoten 8
Das Zeichen ’i’ steht in keinem Knoten
Stufe
Stufe
Stufe
Stufe
0:
1:
2:
3:
a
b c
d e z g
h
53
54
KAPITEL 2. BÄUME UND IHRE IMPLEMENTIERUNG
Natürlich könnte man diese Implementierung für alle binären Bäume verwenden, nicht nur für die vollständigen, wenn man im Feld an den Stellen, die
keinem Knoten entsprechen, einen speziellen Wert Knoten nicht vorhanden“
”
speichert. Dies führt allerdings im allgemeinen zu einer erheblichen Platzverschwendung. Man betrachte etwa den binären Baum
?>=<
89:;
1 EE
E
89:;
?>=<
2
89:;
?>=<
n
der ein Feld der Größe 2n − 1 belegt. Dieser Nachteil kann durch eine dynamische Implementierung vermieden werden. Im Folgenden geben wir zwei solche
Implementierungen der Datenstruktur Binärer Baum“ an.
”
Definition 2.2.3. (a) Man verwendet drei Felder der Länge max zur Speicherung binärer Bäume: inhalt, linkesKind und rechtesKind. Ein binärer Baum B
wird nun in diesen Feldern gespeichert, indem jedem Knoten die drei Plätze
inhalt[j], linkesKind[j] und rechtesKind[j] zur Verfügung gestellt werden für
ein j mit 0 ≤ j ≤ max − 1:
inhalt[j] = Inhalt des Knotens,
linkesKind[j] = Position des linken Kindes,
rechtesKind[j] = Position des rechten Kindes.
Ferner brauchen wir eine Variable b, die die Position der Wurzel angibt.
Beispiel: Für max = 12 wird ein Baum mit acht Knoten gespeichert (b = 4):
j
0
1
2
3
4
5
6
7
8
9
10
11
@ABC
GFED
w1 F
FF
x
x
F
xx
@ABC
GFED
@ABC
GFED
w3 F
w2 F
FF
FF
x
x
F
F
xx
@ABC
GFED
@ABC
GFED
@ABC
GFED
w4
w5 F
w6
FF
x
x
F
xx
@ABC
GFED
@ABC
GFED
w7
w8
inhalt
linkesKind
rechtesKind
w4
w2
w5
w1
w6
w3
0
1
8
2
0
0
0
3
10
6
0
5
w7
0
0
w8
0
0
2.2. IMPLEMENTIERUNG BINÄRER BÄUME
55
(b) Man verwendet Knoten, die aus drei Komponenten bestehen:
• dem gespeicherten Inhalt,
• dem Verweis auf das linke Kind,
• dem Verweis auf das rechte Kind.
Ein Baum wird dann als eine Referenz auf eine durch Referenzen verbundene
Struktur von Knoten realisiert.
Beispiel:
w1 !
!
!
!
!
!
!!
w2
w4
-
-
w7
B
Q
Q
Q
Q
Q
w3 -
l
l
l
l
w5 -
l
l
l
l
w6 l
l
l
l
w8 -
-
-
Programm 2.2.4. Die entsprechende Implementierung binärer Bäume:
/** Die Klasse Binaerknoten implementiert Knoten eines binaeren Baums.
* Jeder Knoten speichert ein beliebiges Objekt vom Typ Object,
3
* zusaetzlich je eine Referenz auf das linke und das rechte Kind.
4
*/
5 class Binaerknoten {
1
2
6
7
8
9
10
11
12
13
14
15
16
17
18
/** Der Knoteninhalt. */
private Object inhalt;
/** Das linke und das rechte Kind. */
private Binaerknoten linkesKind;
private Binaerknoten rechtesKind;
/** Konstruiert einen Blattknoten mit Inhalt inhalt. */
public Binaerknoten( Object inhalt ) {
this.inhalt = inhalt;
}
Binaerknoten
56
19
20
21
22
23
24
25
26
KAPITEL 2. BÄUME UND IHRE IMPLEMENTIERUNG
/** Konstruiert einen Knoten mit Inhalt inhalt,
* linkem Kind knoten1 und rechtem Kind knoten2.
*/
public Binaerknoten( Object inhalt, Binaerknoten knoten1, Binaerknoten knoten2 ) {
this.inhalt = inhalt;
linkesKind = knoten1;
rechtesKind = knoten2;
}
Binaerknoten
27
28
29
30
31
/** Gibt den Inhalt des Knotens zurueck. */
public Object inhalt( ) {
return inhalt;
}
inhalt
32
33
34
35
36
/** Gibt das linke Kind zurueck. */
public Binaerknoten linkesKind( ) {
return linkesKind;
}
linkesKind
37
38
39
40
41
/** Gibt das rechte Kind zurueck. */
public Binaerknoten rechtesKind( ) {
return rechtesKind;
}
rechtesKind
42
43
44
45
46
/** Der Inhalt wird zu inhalt. */
public void inhaltAendern( Object inhalt ) {
this.inhalt = inhalt;
}
inhaltAendern
47
48
49
50
51
/** Das linke Kind wird zu knoten. */
public void linkesKindAendern( Binaerknoten knoten ) {
linkesKind = knoten;
}
linkesKindAendern
52
53
54
55
56
/** Das rechte Kind wird zu knoten. */
public void rechtesKindAendern( Binaerknoten knoten ) {
rechtesKind = knoten;
}
rechtesKindAendern
57
58
59
60
61
62
63
64
65
66
/** Durchsucht den hier wurzelnden Baum in Vorordnung.
* Gibt true zurueck, wenn es darin einen Knoten mit Inhalt inhalt gibt,
* sonst false. Die Objekte werden mit equals verglichen.
*/
public boolean suche( Object inhalt ) {
return inhalt.equals( inhalt ) | |
( linkesKind == null ? false : linkesKind.suche( inhalt ) ) | |
( rechtesKind == null ? false : rechtesKind.suche( inhalt ) );
}
suche
67
68
69
70
71
/** Gibt eine String-Darstellung des Inhalts zurueck. */
public String toString( ) {
return inhalt.toString( );
}
toString
2.2. IMPLEMENTIERUNG BINÄRER BÄUME
57
72
73
74
75
76
77
78
79
80
/** Gibt den hier wurzelnden Baum in Vorordnung aus. */
public void druckeVorordnung( ) {
System.out.print( this + " " );
if( linkesKind != null )
linkesKind.druckeVorordnung( );
if( rechtesKind != null )
rechtesKind.druckeVorordnung( );
}
druckeVorordnung
81
82
83
84
85
86
87
88
89
/** Gibt den hier wurzelnden Baum in Zwischenordnung aus. */
public void druckeZwischenordnung( ) {
if( linkesKind != null )
linkesKind.druckeZwischenordnung( );
System.out.print( this + " " );
if( rechtesKind != null )
rechtesKind.druckeZwischenordnung( );
}
druckeZwischenordnung
90
91
92
93
94
95
96
97
98
/** Gibt den hier wurzelnden Baum in Nachordnung aus. */
public void druckeNachordnung( ) {
if( linkesKind != null )
linkesKind.druckeNachordnung( );
if( rechtesKind != null )
rechtesKind.druckeNachordnung( );
System.out.print( this + " " );
}
druckeNachordnung
99
100
} // class Binaerknoten
101
102
/** Die Klasse Binaerbaum implementiert binaere Baeume mittels Referenzen.
* Ein Binaerbaum besteht einfach aus seinem Wurzelknoten vom Typ Binaerknoten.
105
*/
106 public class Binaerbaum {
103
104
107
108
109
/** Der Wurzelknoten. */
private Binaerknoten wurzel;
110
111
112
113
114
115
116
/** Konstruiert einen Baum, der nur einen Wurzelknoten
* mit Inhalt inhalt besitzt.
*/
public Binaerbaum( Object inhalt ) {
wurzel = new Binaerknoten( inhalt );
}
Binaerbaum
117
118
119
120
/** Konstruiert den leeren Baum. */
public Binaerbaum( ) { }
Binaerbaum
58
121
122
123
124
KAPITEL 2. BÄUME UND IHRE IMPLEMENTIERUNG
/** Gibt den Wurzelknoten zurueck. */
public Binaerknoten wurzel( ) {
return wurzel;
}
wurzel
125
126
127
128
129
/** Test, ob der Baum leer ist. */
boolean istLeer( ) {
return wurzel == null;
}
istLeer
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
/** Erzeugt einen Baum mit dem aktuellen Baum als linkem Teilbaum,
* mit neuem Wurzelknoten mit Inhalt inhalt und mit rechtem Teilbaum baum.
* Der Argumentbaum baum wird danach leer sein.
* Um zu verhindern, dass die Teilbaeume uebereinstimmen, fordern wir, dass
* die beiden Wurzelreferenzen verschieden sind, ausser beide Baeume sind leer;
* andernfalls wird eine Ausnahme ausgeloest.
*/
Binaerbaum anhaengen( Object inhalt, Binaerbaum baum ) {
if( !istLeer( ) && wurzel == baum.wurzel )
throw new IllegalArgumentException( "Identische Baeume verknuepft." );
wurzel = new Binaerknoten( inhalt, wurzel, baum.wurzel );
baum.wurzel = null;
return this;
}
anhaengen
145
146
147
148
149
150
151
152
/** Durchsucht den Baum in Vorordnung.
* Gibt true zurueck, wenn es darin einen Knoten mit Inhalt inhalt gibt,
* sonst false. Die Objekte werden mit equals verglichen.
*/
public boolean suche( Object inhalt ) {
return istLeer( ) ? false : wurzel.suche( inhalt );
}
suche
153
154
155
156
157
158
159
160
161
162
/** Gibt den Baum in Vorordnung aus. */
public void druckeVorordnung( ) {
if( istLeer( ) )
System.out.println( "Der Baum ist leer." );
else {
wurzel.druckeVorordnung( );
System.out.println( );
}
}
druckeVorordnung
163
164
165
166
167
168
169
170
171
172
173
/** Gibt den Baum in Zwischenordnung aus. */
public void druckeZwischenordnung( ) {
if( istLeer( ) )
System.out.println( "Der Baum ist leer." );
else {
wurzel.druckeZwischenordnung( );
System.out.println( );
}
}
druckeZwischenordnung
2.2. IMPLEMENTIERUNG BINÄRER BÄUME
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
59
/** Gibt den Baum in Nachordnung aus. */
public void druckeNachordnung( ) {
if( istLeer( ) )
System.out.println( "Der Baum ist leer." );
else {
wurzel.druckeNachordnung( );
System.out.println( );
}
}
public static void main( String[ ] args ) {
Binaerbaum b1 =
new Binaerbaum( "A" ).anhaengen( "*", new Binaerbaum( "B" ) );
b1.druckeVorordnung( );
b1.druckeZwischenordnung( );
b1.druckeNachordnung( );
System.out.println( );
Binaerbaum b2 =
new Binaerbaum( "A" ).
anhaengen( "/", new Binaerbaum( "B" ).
anhaengen( "*", new Binaerbaum( "C" ) ) ).
anhaengen( "*", new Binaerbaum( "D" ) ).
anhaengen( "+", new Binaerbaum( "E" ) );
b2.druckeVorordnung( );
b2.druckeZwischenordnung( );
b2.druckeNachordnung( );
} // main
} // class Binaerbaum
Ein Testlauf (vgl. Beispiel 2.2.6):
* A B
A * B
A B *
+ * / A * B C D E
A / B * C * D + E
A B C * / D * E +
Eine der Operationen, die man oftmals auf Bäumen durchführen muss, besteht
darin, jeden Knoten des Baumes genau einmal zu besuchen. Dazu müssen wir
den Baum systematisch durchlaufen (oder: traversieren). Ein solches systematisches Durchlaufen liefert uns eine lineare Anordnung der Knoten und damit der
im Baum gespeicherten Information. Drei wichtige Strategien hierfür ergeben
sich aus der rekursiven Form der Definition der binären Bäume.
druckeNachordnung
main
60
KAPITEL 2. BÄUME UND IHRE IMPLEMENTIERUNG
Definition 2.2.5. Sei B ein binärer Baum.
Die Vorordnungsfolge der Knoten von B ist rekursiv wie folgt definiert:
(i) Als erstes kommt die Wurzel von B,
(ii) dann die Vorordnungsfolge des linken direkten Teilbaums,
(iii) und schließlich die Vorordnungsfolge des rechten direkten Teilbaums.
Die Zwischenordnungsfolge der Knoten von B ist rekursiv definiert durch:
(i) Zuerst kommt die Zwischenordnungsfolge des linken direkten Teilbaums,
(ii) dann die Wurzel von B,
(iii) und schließlich die Zwischenordnungsfolge des rechten direkten Teilbaums.
Die Nachordnungsfolge der Knoten von B ist rekursiv definiert durch:
(i) Zuerst kommt die Nachordnungsfolge des linken direkten Teilbaums,
(ii) dann die Nachordnungsfolge des rechten direkten Teilbaums,
(iii) und schließlich die Wurzel von B.
Beispiel 2.2.6. Mittels binärer Bäume lassen sich leicht arithmetische Ausdrücke darstellen. Der arithmetische Ausdruck
((A/(B ∗ C)) ∗ D) + E
mit den Variablen A, . . . , E kann durch den folgenden binären Baum eindeutig
beschrieben werden:
89:;
?>=<
+
 AAAA



89:;
?>=<
GFED
@ABC
∗=
E
=
=
=
@ABC
GFED
@ABC
GFED
/=
D
==
=
89:;
?>=<
89:;
?>=<
∗
A
 ???

?

@ABC
GFED
GFED
@ABC
B
C
Seine Vorordnungsfolge (=
ˆ Präfixnotation des Ausdrucks):
+∗/A∗BCDE
Seine Zwischenordnungsfolge (=
ˆ Infixnotation des Ausdrucks; ist ohne Klammerung nicht eindeutig):
A/B∗C∗D+E
2.2. IMPLEMENTIERUNG BINÄRER BÄUME
61
Seine Nachordnungsfolge (=
ˆ Postfixnotation des Ausdrucks):
ABC∗/D∗E+
Offensichtlich lassen sich diese Folgen leicht rekursiv bestimmen (wie in der
Definition angegeben), wobei wir die Implementierung mittels Referenzen aus
Definition 2.2.3(b) zu Grunde legen. In Programm 2.2.4 ist dies in den Methoden druckeVorordnung, druckeZwischenordnung bzw. druckeNachordnung
realisiert.
Wir wollen nun die Rechenzeit bestimmen, die diese Algorithmen für einen
binären Baum der Höhe h mit n = 2h+1 − 1 Knoten brauchen. Dazu sei z(h)
die Zeit, die das Drucken in Zwischenordnung im schlechtesten Fall für einen
binären Baum der Höhe h braucht. Dann gelten für z(h) die folgenden Gleichungen:
z(0) = c1
für eine Konstante c1 > 0,
z(h + 1) = z(h) + c2 + z(h)
für eine Konstante c2 > 0.
Zur Vereinfachung ersetzen wir c1 und c2 durch den Wert c = max{c1 , c2 }. Dies
ist gerechtfertigt, da wir uns nur für das asymptotische Verhalten der Funktion z(h) für h → ∞ interessieren. Wir erhalten nun aus obigen Gleichungen
folgendes:
z(h) = 2z(h − 1) + c = 4z(h − 2) + 2c + c = · · ·
h
= 2 z(0) + c ·
h
h−1
X
2i
i=0
h
= 2 · c + c · (2 − 1) = c · (2h+1 − 1) = c · n ∈ O(n).
Da n Knoteninhalte ausgegeben werden, erhalten wir sogar z(h) ∈ Θ(n). Das
Durchlaufen eines binären Baums in Vor- oder Nachordnung wird ganz analog
realisiert, daher ergibt sich dafür derselbe Rechenzeitbedarf.
Anwendungen für dieses Durchlaufen eines binären Baums sind neben der Ausgabe beispielsweise die folgenden Aufgaben:
• das Erstellen der Kopie eines binären Baums,
• das Durchsuchen nach einem speziellen Knoten (vgl. die Methode suche
der Klasse Binaerbaum bzw. -knoten in Programm 2.2.4),
• die Auswertung der durch einen binären Baum dargestellten Information
(z.B. einen Ausdruck auswerten).
Wenn wir uns die Darstellung von binären Bäumen mittels Referenzen genauer
anschauen, dann sehen wir, dass die meisten Felder linkesKind und rechtesKind
den Wert null enthalten.
62
KAPITEL 2. BÄUME UND IHRE IMPLEMENTIERUNG
Sei etwa B ein binärer Baum mit n0 Blättern, n1 Knoten mit nur einem Kind
und n2 Knoten mit 2 Kindern. Bei den Blättern sind jeweils beide Felder linkesKind und rechtesKind leer, bei den Knoten mit einem Kind ist jeweils genau eines dieser Felder leer, und nur bei den Knoten mit 2 Kindern sind alle
diese Felder nicht leer, d.h. 2n0 + n1 Felder sind leer, aber nur 2n2 + n1 =
2(n0 − 1) + n1 < 2n0 + n1 Felder sind nicht leer (Lemma 2.1.6). Diese leeren
Felder kann man nun benutzen, um zusätzliche Verweise zu speichern, wodurch
das Durchlaufen des Baums vereinfacht wird.
Definition 2.2.7. Implementierung der Datenstruktur Binärer Baum“ mit”
tels Referenzen und zusätzlichen Verweisen (threaded binary trees):
Die Darstellung erfolgt wie in Definition 2.2.3(b). Zusätzlich werden folgende
Verweise (threads) gespeichert: Ist für einen Knoten K im Baum B das Feld
K.rechtesKind gleich null, so wird in K.rechtesKind ein Verweis auf den Knoten gespeichert, der beim Durchlaufen von B in Zwischenordnung unmittelbar
hinter K ausgegeben wird. Ist für K das Feld K.linkesKind gleich null, so wird
entsprechend in K.linkesKind ein Verweis auf den Knoten gespeichert, der beim
Durchlaufen von B in Zwischenordnung unmittelbar vor K ausgegeben wird.
Beispiel:
@ABC o
GFED
l w1 M
lll O W 5 MMMMM
l
l
&
lu ll
M ] GFED
@ABC
GFED
@ABC
w2 F
w3 F
F
S+ FF
x K O FFF
x
F"
x
x|
"
F [ GFED
@ABC
GFED
@ABC
GFED
@ABC
w
w
w6
4
5
xxx K S+ FFFF
? j p
x|
"
F [ GFED
@ABC
GFED
@ABC
w7
B
W
w8
/ : linkes bzw. rechtes Kind, _ _/ : linker Thread,
/ : rechter Thread
Zwischenordnungsfolge: w4 , w2 , w7 , w5 , w8 , w1 , w3 , w6
Damit wir bei der Implementierung zwischen den normalen Referenzen, die auf
die Kinder eines Knotens zeigen, und den zusätzlichen Verweisen unterscheiden
können, ergänzen wir jeden Knoten (bzw. seine Darstellung) durch zwei Boolesche Werte linksThread und rechtsThread, die anzeigen, ob es sich bei der
entsprechenden Referenz um einen zusätzlichen Verweis handelt:
K.linksThread = true gdw K.linkerVerweis ist ein zusätzlicher Verweis,
K.rechtsThread = true gdw K.rechterVerweis ist ein zusätzlicher Verweis.
Für das obige Beispiel erhalten wir damit die folgende Realisierung (im Diagramm sind jeweils von links nach rechts die fünf Felder linksThread, linkerVerweis, inhalt, rechterVerweis, rechtsThread dargestellt):
2.2. IMPLEMENTIERUNG BINÄRER BÄUME
false
w1
"
"
"
"
false
w2
"
"
"
"
true -
true
false
w3
false
w5
true
false
w6
-
true
b
b
b
b
true
w7
false
b
b
b
b
"
"
"
"
true
TB
false
b
b
b
b
b
b
b
b
true
w4
63
true
true
w8
Zur Vereinfachung mancher Algorithmen kann man bei dieser Implementierung
für jeden binären Baum noch einen speziellen Kopfknoten einführen:
Für den leeren Baum:
true
false
TB
Für den nicht-leeren Baum:
false
false
TB
zum Wurzelknoten
Damit wird ein nicht-leerer Baum über die linke Referenz (linkesKind) des
Kopfknotens erreicht. Die beiden Verweise im Baum, die noch null sind, werden
nun als Verweise auf den Kopfknoten verwendet. Obiges Beispiel sieht dann so
aus:
Kopfknoten
false
false
TB
%
%
%
false
w1
"
"
"
"
false
w2
"
"
"
"
true
w4
false
true
w3
b
b
b
b
true
false
w7
true
false
b
b
b
b
w5
"
"
"
"
true
false
b
b
b
b
false
true
w6
b
b
b
b
true
w8
true
true
64
KAPITEL 2. BÄUME UND IHRE IMPLEMENTIERUNG
Die zusätzlichen Verweise kann man nun dazu verwenden, für einen Knoten den
Nachfolger bezüglich der Zwischenordnung zu bestimmen. In folgendem Programm tut dies die Methode nachfolger der Klasse BinaerknotenMitThreads:
1
2
3
4
5
6
7
8
9
10
public BinaerknotenMitThreads erster( ) {
BinaerknotenMitThreads k = this;
while( !k.linksThread ) // solange ein linkes Kind vorhanden ist
k = k.linkerVerweis;
return k;
}
public BinaerknotenMitThreads nachfolger( ) {
return rechtsThread ? rechterVerweis : rechterVerweis.erster( );
}
Man überlegt sich leicht, dass diese Methode die folgenden Eigenschaften hat.
Lemma 2.2.8. Sei T B eine Referenz auf einen Knoten eines binären Baums B,
der wie oben beschrieben implementiert ist. Wird dafür die Methode nachfolger
aufgerufen, dann liefert dieser Aufruf eine Referenz auf den Knoten K in B,
der in der Zwischenordnungsfolge unmittelbar auf den Knoten T B folgt. Ferner
gelten folgende Aussagen:
• K = T B genau dann, wenn T B der Kopfknoten des leeren Baums ist.
• K ist der Kopfknoten des nicht-leeren Baums B genau dann, wenn T B
der letzte Knoten von B bezüglich der Zwischenordnung ist.
Unter Verwendung der Methode nachfolger kann man einen binären Baum in
Zwischenordnung durchlaufen, ohne dabei Rekursion bzw. einen zusätzlichen
Keller zu verwenden. Dies nutzen die Methoden suche und druckeZwischenordnung in folgendem Beispielprogramm aus.
Programm 2.2.9. Eine Implementierung binärer Bäume mit zusätzlichen Verweisen und mit Kopfknoten:
1
2
3
4
5
6
7
8
9
10
11
/** Die Klasse BinaerknotenMitThreads implementiert Knoten eines binaeren
* Baums. Jeder Knoten speichert ein beliebiges Objekt vom Typ Object,
* zusaetzlich je eine Referenz auf das linke und das rechte Kind oder,
* falls das jeweilige Kind nicht vorhanden ist, eine Referenz auf den
* Vorgaenger bzw. Nachfolger bzgl. der Zwischenordnung.
*/
class BinaerknotenMitThreads {
/** Der Knoteninhalt. */
private Object inhalt;
erster
nachfolger
2.2. IMPLEMENTIERUNG BINÄRER BÄUME
12
13
14
65
/** Das linke und das rechte Kind bzw. der jeweilige zusaetzliche Verweis. */
private BinaerknotenMitThreads linkerVerweis;
private BinaerknotenMitThreads rechterVerweis;
15
16
17
18
19
20
/** Boolesche Werte geben an, ob linker bzw. rechter Verweis auf ein Kind
* referiert (false) oder ein zusaetzlicher Verweis ist (true).
*/
private boolean linksThread;
private boolean rechtsThread;
21
22
23
24
25
26
27
28
/** Konstruiert einen Blattknoten mit Inhalt inhalt.
* Die Threads sind noch auf null gesetzt.
*/
public BinaerknotenMitThreads( Object inhalt ) {
this.inhalt = inhalt;
linksThread = rechtsThread = true;
}
BinaerknotenMitThreads
29
30
31
32
33
/** Gibt den Inhalt des Knotens zurueck. */
public Object inhalt( ) {
return inhalt;
}
inhalt
34
35
36
37
38
/** Gibt den linken Verweis zurueck. */
public BinaerknotenMitThreads linkerVerweis( ) {
return linkerVerweis;
}
linkerVerweis
39
40
41
42
43
/** Gibt den rechten Verweis zurueck. */
public BinaerknotenMitThreads rechterVerweis( ) {
return rechterVerweis;
}
rechterVerweis
44
45
46
47
48
/** Gibt den Wert von linksThread zurueck. */
public boolean linksThread( ) {
return linksThread;
}
linksThread
49
50
51
52
53
/** Gibt den Wert von rechtsThread zurueck. */
public boolean rechtsThread( ) {
return rechtsThread;
}
rechtsThread
54
55
56
57
58
/** Der Inhalt wird zu inhalt. */
public void inhaltAendern( Object inhalt ) {
this.inhalt = inhalt;
}
inhaltAendern
59
60
61
62
63
64
/** Der linke Verweis wird zu knoten. */
public void linkenVerweisAendern( BinaerknotenMitThreads knoten ) {
linkerVerweis = knoten;
}
linkenVerweisAendern
66
65
66
67
68
KAPITEL 2. BÄUME UND IHRE IMPLEMENTIERUNG
/** Der rechter Verweis wird zu knoten. */
public void rechtenVerweisAendern( BinaerknotenMitThreads knoten ) {
rechterVerweis = knoten;
}
rechtenVerweisAendern
69
70
71
72
73
/** Der Wert von linksThread wird zu b. */
public void linksThreadAendern( boolean b ) {
linksThread = b;
}
linksThreadAendern
74
75
76
77
78
/** Der Wert von rechtsThread wird zu b. */
public void rechtsThreadAendern( boolean b ) {
rechtsThread = b;
}
rechtsThreadAendern
79
80
81
82
83
/** Test, ob der Knoten ein Kopfknoten ist. */
boolean istKopfknoten( ) {
return this.rechterVerweis == this;
}
istKopfknoten
84
85
86
87
88
89
90
91
92
93
94
/** Gibt den ersten Knoten des hier wurzelnden (bzw. fuer Kopfknoten hier
* haengenden) Baums bzgl. Zwischenordnung zurueck. Ist der aktuelle Knoten
* der Kopfknoten des leeren Baums, wird er selbst zurueckgegeben.
*/
public BinaerknotenMitThreads erster( ) {
BinaerknotenMitThreads k = this;
while( !k.linksThread ) // solange ein linkes Kind vorhanden ist
k = k.linkerVerweis;
return k;
}
erster
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
/** Gibt den letzten Knoten des hier wurzelnden (bzw. fuer Kopfknoten hier
* haengenden) Baums bzgl. Zwischenordnung zurueck. Ist der aktuelle Knoten
* der Kopfknoten des leeren Baums, wird er selbst zurueckgegeben.
*/
public BinaerknotenMitThreads letzter( ) {
BinaerknotenMitThreads k = this;
if( k.istKopfknoten( ) ) { // falls der Knoten ein Kopfknoten ist
if( k.linksThread ) // falls es der Kopfknoten des leeren Baums ist
return k;
else // der Baum ist nicht leer
k = k.linkerVerweis;
}
while( !k.rechtsThread ) // solange ein rechtes Kind vorhanden ist
k = k.rechterVerweis;
return k;
}
letzter
112
113
114
115
116
117
/** Gibt den Nachfolgerknoten bzgl. Zwischenordnung zurueck. */
public BinaerknotenMitThreads nachfolger( ) {
return rechtsThread ? rechterVerweis : rechterVerweis.erster( );
}
nachfolger
2.2. IMPLEMENTIERUNG BINÄRER BÄUME
118
119
120
121
122
123
124
125
126
127
128
129
130
67
/** Durchsucht den hier wurzelnden Baum in Zwischenordnung. Gibt true zurueck,
* wenn es darin einen Knoten mit Inhalt inhalt gibt, sonst false. Die Objekte
* werden mit equals verglichen. Fuer Kopfknoten wird false zurueckgegeben.
*/
public boolean suche( Object inhalt ) {
suche
BinaerknotenMitThreads k = this;
while( !k.istKopfknoten( ) ) {
if( k.inhalt.equals( inhalt ) )
return true;
k = k.nachfolger( );
}
return false;
}
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
/** Gibt eine String-Darstellung des Inhalts zurueck. */
public String toString( ) {
return inhalt.toString( );
}
/** Gibt den hier wurzelnden Baum in Zwischenordnung aus.
* Fuer Kopfknoten wird nichts ausgegeben.
*/
public void druckeZwischenordnung( ) {
BinaerknotenMitThreads k = this;
while( !k.istKopfknoten( ) ) {
System.out.print( k + " " );
k = k.nachfolger( );
}
}
toString
druckeZwischenordnung
} // class BinaerknotenMitThreads
150
151
/** Die Klasse BinaerbaumMitThreads implementiert binaere Baeume mit Threads.
* Ein solcher Binaerbaum besteht aus seinem Kopfknoten (siehe Skripttext).
*/
154 public class BinaerbaumMitThreads {
152
153
155
156
157
158
159
160
161
162
163
164
165
166
167
168
/** Der Kopfknoten. */
private BinaerknotenMitThreads kopfknoten;
/** Konstruiert den leeren Baum, der also nur den Kopfknoten besitzt.
* Der Kopfknoten speichert (zu Testzwecken) den String “Kopfknoten”.
*/
public BinaerbaumMitThreads( ) {
kopfknoten = new BinaerknotenMitThreads( "Kopfknoten" );
kopfknoten.linkenVerweisAendern( kopfknoten );
kopfknoten.rechtenVerweisAendern( kopfknoten );
kopfknoten.rechtsThreadAendern( false );
}
BinaerbaumMitThreads
68
169
170
171
172
173
174
175
176
177
178
179
KAPITEL 2. BÄUME UND IHRE IMPLEMENTIERUNG
/** Konstruiert einen Baum, der nur einen Wurzelknoten mit Inhalt
* inhalt besitzt.
*/
public BinaerbaumMitThreads( Object inhalt ) {
this( ); // Konstruktor fuer den leeren Baum aufrufen
BinaerknotenMitThreads wurzel = new BinaerknotenMitThreads( inhalt );
kopfknoten.linksThreadAendern( false );
kopfknoten.linkenVerweisAendern( wurzel );
wurzel.linkenVerweisAendern( kopfknoten );
wurzel.rechtenVerweisAendern( kopfknoten );
}
BinaerbaumMitThreads
180
181
182
183
184
/** Gibt den Kopfknoten zurueck. */
public BinaerknotenMitThreads kopfknoten( ) {
return kopfknoten;
}
kopfknoten
185
186
187
188
189
/** Test, ob der Baum leer ist. */
boolean istLeer( ) {
return kopfknoten.linksThread( );
}
istLeer
190
191
192
193
194
195
196
/** Gibt den Wurzelknoten zurueck, falls der Baum nicht leer ist,
* sonst den Kopfknoten.
*/
public BinaerknotenMitThreads wurzel( ) {
return istLeer( ) ? kopfknoten : kopfknoten.linkerVerweis( );
}
wurzel
197
198
199
200
201
202
203
/** Gibt den ersten Knoten des Baums bzgl. Zwischenordnung zurueck.
* Ist der Baum leer, wird der Kopfknoten zurueckgegeben.
*/
public BinaerknotenMitThreads erster( ) {
return kopfknoten.erster( );
}
erster
204
205
206
207
208
209
210
/** Gibt den letzten Knoten des Baums bzgl. Zwischenordnung zurueck.
* Ist der Baum leer, wird der Kopfknoten zurueckgegeben.
*/
public BinaerknotenMitThreads letzter( ) {
return kopfknoten.letzter( );
}
letzter
211
212
213
214
215
216
217
218
219
220
221
222
/** Erzeugt einen Baum mit dem aktuellen Baum als linkem Teilbaum,
* mit neuem Wurzelknoten mit Inhalt inhalt und mit rechtem Teilbaum baum.
* Der Argumentbaum baum wird danach leer sein.
* Um zu verhindern, dass die Teilbaeume uebereinstimmen, fordern wir, dass
* die beiden Wurzelreferenzen verschieden sind, ausser beide Baeume sind leer;
* andernfalls wird eine Ausnahme ausgeloest.
*/
BinaerbaumMitThreads anhaengen( Object inhalt, BinaerbaumMitThreads baum ) {
BinaerbaumMitThreads neuerBaum = new BinaerbaumMitThreads( inhalt );
if( !istLeer( ) && wurzel( ) == baum.wurzel( ) )
throw new IllegalArgumentException( "Identische Baeume verknuepft." );
anhaengen
2.2. IMPLEMENTIERUNG BINÄRER BÄUME
if( !istLeer( ) ) { // wenn der linke Teilbaum nicht leer ist
neuerBaum.wurzel( ).linksThreadAendern( false );
neuerBaum.wurzel( ).linkenVerweisAendern( wurzel( ) );
erster( ).linkenVerweisAendern( neuerBaum.kopfknoten );
letzter( ).rechtenVerweisAendern( neuerBaum.wurzel( ) );
}
if( !baum.istLeer( ) ) { // wenn der rechte Teilbaum nicht leer ist
neuerBaum.wurzel( ).rechtsThreadAendern( false );
neuerBaum.wurzel( ).rechtenVerweisAendern( baum.wurzel( ) );
baum.erster( ).linkenVerweisAendern( neuerBaum.wurzel( ) );
baum.letzter( ).rechtenVerweisAendern( neuerBaum.kopfknoten );
}
// Das Argument baum leer machen:
baum.kopfknoten.linksThreadAendern( true );
baum.kopfknoten.linkenVerweisAendern( baum.kopfknoten );
return neuerBaum;
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
69
}
240
241
242
243
244
245
246
247
/** Durchsucht den Baum in Vorordnung. Gibt true zurueck, wenn es darin
* einen Knoten mit Inhalt inhalt gibt, sonst false. Die Objekte werden
* mit equals verglichen.
*/
public boolean suche( Object inhalt ) {
return istLeer( ) ? false : erster( ).suche( inhalt );
}
suche
248
249
250
251
252
253
254
255
256
257
/** Gibt den Baum in Zwischenordnung aus. */
public void druckeZwischenordnung( ) {
if( istLeer( ) )
System.out.println( "Der Baum ist leer." );
else {
erster( ).druckeZwischenordnung( );
System.out.println( );
}
}
druckeZwischenordnung
258
259
260
261
262
263
public static void main( String[ ] args ) {
BinaerbaumMitThreads b1 =
new BinaerbaumMitThreads( "A" ).
anhaengen( "*", new BinaerbaumMitThreads( "B" ) );
b1.druckeZwischenordnung( );
264
265
266
267
268
269
270
271
272
BinaerbaumMitThreads b2 =
new BinaerbaumMitThreads( "A" ).
anhaengen( "/", new BinaerbaumMitThreads( "B" ).
anhaengen( "*", new BinaerbaumMitThreads( "C" ) ) ).
anhaengen( "*", new BinaerbaumMitThreads( "D" ) ).
anhaengen( "+", new BinaerbaumMitThreads( "E" ) );
b2.druckeZwischenordnung( );
} // main
273
274
} // class BinaerbaumMitThreads
main
70
KAPITEL 2. BÄUME UND IHRE IMPLEMENTIERUNG
2.3
Erste Anwendungen binärer Bäume
Als erste Anwendung der binären Bäume lernen wir hier zwei Sortierverfahren
kennen. Sei dabei Item eine Menge von Objekten und ≤ eine lineare Ordnung
auf Item, d.h. ≤ erfülle die Axiome
• a≤a
• aus a ≤ b und b ≤ c folgt a ≤ c
• aus a ≤ b und b ≤ a folgt a = b
• a ≤ b oder b ≤ a
für alle a, b, c ∈ Item. Sei nun (a1 , . . . , an ) eine endliche Folge von Elementen
aus Item. Wir wollen diese Folge so zu einer Folge (b1 , . . . , bn ) umordnen, dass
b1 ≤ b2 ≤ · · · ≤ bn gilt.
2.3.1
TREE SORT
Unser erster Algorithmus löst diese Aufgabe unter Verwendung binärer Bäume
wie folgt:
Eingabe: n = 2k Elemente a1 , . . . , an einer linear geordneten Menge (Item, ≤).
Ausgabe: Die Elemente a1 , . . . , an in aufsteigend sortierter Reihenfolge, d.h.
(b1 , . . . , bn ) mit b1 ≤ b2 ≤ · · · ≤ bn , wobei bi = aπ(i) für eine Permutation (d.h.
eine bijektive Abbildung) π : {1, . . . , n} → {1, . . . , n} ist.
Bemerkung: Ist 2k < n < 2k+1 , so wähle an+1 , . . . , a2k+1 als den symbolischen
Wert ∞ und führe den Algorithmus für a1 , . . . , an , an+1 , . . . , a2k+1 durch.
Algorithmus 2.3.1. Sortieren mit binären Bäumen (TREE SORT):
void treeSort( ) {
(1.) Trage ein KO-Turnier zwischen den n Elementen a1 , . . . , an aus.
3
Dabei soll ein binärer Baum aufgebaut werden, wobei jeder Elternknoten stets
4
als Knoteninhalt den kleinsten Wert der Knoteninhalte seiner Kinder erhält.
1
2
5
for( int i = 1; i <= n; i++ ) {
(2.1.) Gib das Element an der Wurzel des Baums aus.
(2.2.) Steige den Pfad herunter, dessen sämtliche Knoten als Schlüssel
das ausgegebene Element tragen, und lösche diese Schlüssel.
(2.3.) Beim Zurückkehren an die Spitze werden alle Vergleiche entlang
dieses Weges neu ausgetragen.
}
6
7
8
9
10
11
12
13
}
treeSort
2.3. ERSTE ANWENDUNGEN BINÄRER BÄUME
71
Beispiel 2.3.2. n = 4 und (b1 , . . . , b4 ) = (17, 2, 12, 13).
(1.)
17
2 QQQQ
ppp
QQQ
ppp
2
12 D
<
D
}}} <
zz
2
12
13
(2.) i = 1: Ausgabe 2
17
− QQQ
QQQ
nnn
Q
nnn
−A
12 D
A
D
{{
zz
−
12
,
17
13
12 RRRR
mmm
RRR
mmm
17 C
12 D
C
D
zz
zz
−
12
13
i = 2: Ausgabe 12
17
− PPP
nnn
PPP
nnn
−C
17 C
C
C
}}
zz
−
−
,
17
13
13 QQQ
QQQ
mmm
mmm
13 D
17 C
D
C
zz
{{
−
−
13
i = 3: Ausgabe 13
17
− PPP
nnn
PPP
nnn
−A
17 C
A
C
}}
zz
−
−
,
−
17
17 PPP
mmm
PPP
mmm
−A
17 C
A
C
}}
zz
−
−
−
i = 4: Ausgabe 17.
Lemma 2.3.3. Für n = 2k gilt:
(a) TREE SORT hat bei Eingabe a1 , . . . , an den Platzbedarf 2n − 1.
(b) TREE SORT hat bei Eingabe a1 , . . . , an den Zeitbedarf O(n log n).
Beweis: (a) Abgesehen von einigen Referenzen muss TREE SORT den aufgebauten binären Baum mit n Blättern und n − 1 inneren Knoten speichern.

(b) Schritt (1.):
2k−1 Vergleiche auf Stufe
k 

 Damit werden insge2k−2 Vergleiche auf Stufe k − 1  samt 2k − 1 = n − 1
..
..
Vergleiche durchge.
···
. 


 f ührt.
1
Vergleiche auf Stufe
1
Schritt (2.): Für 1 ≤ i ≤ n: Absteigen und Löschen: k Stufen; Aufsteigen und
neu vergleichen: k Stufen.
Damit ergibt sich ein Zeitbedarf von c · ((n − 1) + n · k) ∈ O(n log n).
Der Zeitbedarf von TREE SORT ist gut, da man zeigen kann, dass für das
Sortieren von n Elementen im schlechtesten Fall stets O(n log n) Schlüsselvergleiche notwendig sind. Der Platzbedarf von TREE SORT ist mit 2n − 1 aber
entschieden zu hoch, wenn man sehr lange Folgen a1 , . . . , an sortieren will.
72
2.3.2
KAPITEL 2. BÄUME UND IHRE IMPLEMENTIERUNG
HEAP SORT
Hier wollen wir noch ein Sortierverfahren betrachten, das ebenfalls mit binären
Bäumen arbeitet, das zum Sortieren von n Elementen aber nur Platzbedarf
n + c hat. Dazu brauchen wir den folgenden Begriff.
Definition 2.3.4. Sei Item eine linear geordnete Menge. Ein Heap (über Item)
ist ein vollständiger binärer Baum über Item, in dem jeder Knoten einen Schlüssel hat, der höchstens so groß ist wie die Schlüssel seiner Kinder.
Beispiel 2.3.5. Zwei Heaps:
10
2N
ppp NNNNN
ppp
4
7<
}}} <
8
6
50
zz
20
10 NNN
pp
NN
ppp
83
Bemerkung 2.3.6. Sei H ein Heap über Item mit n Knoten. Für 1 ≤ i ≤ n
bezeichne Ki den i-ten Knoten bei der stufenweisen Durchnummerierung von
H von links nach rechts.
(a) Wird H in einem Feld der Länge n so gespeichert, dass Ki an Position i
steht, so gilt nach Lemma 2.2.1 für 1 ≤ i ≤ n folgendes:
(i) Ist i > 1, so steht der Elternknoten von Ki an Position bi/2c.
(ii) Ist 2i ≤ n, so steht das linke Kind von Ki an Position 2i; andernfalls
hat Ki kein linkes Kind.
(iii) Ist 2i + 1 ≤ n, so steht das rechte Kind von Ki an Position 2i + 1;
andernfalls hat Ki kein rechtes Kind.
(b) Ist bi ∈ Item der Schlüssel von Ki , so gilt b1 = min{bi | 1 ≤ i ≤ n}. Dies
folgt unmittelbar aus der Definition des Heaps.
Daher eignen sich Heaps insbesondere zur Speicherung von Prioritätsschlangen.
Als nächstes wollen wir uns einige wichtige Operationen auf Heaps anschauen.
Wir beginnen mit der Operation Einfügen“.
”
, b ∈ Item.
Beispiel 2.3.7. H :
ll 20 NN
40
NNN
ll
lll
35 D
D
zz
45
50
Nach dem Einfügen muss der entstandene Heap folgende Form haben:
*
@
@
@
*
*
*
A
A
A
*
*
2.3. ERSTE ANWENDUNGEN BINÄRER BÄUME
(a) b = 55:
40
35
A
A
A
50
20
@
@
@
73
45
55
(b) b = 25:
35
A
A
A
40
50
20
@
@
@
,
45
35
A
A
A
40
50
* ⇐ 25 ?
,
20
@
@
@
35
A
A
A
40
50
20
@
@
@
* ⇐ 25 ?
45
25n
45
(c) b = 10:
40
35
A
A
A
50
20
@
@
@
45
* ⇐ 10 ?
,
40
35
A
A
A
50
,
n
10
@
@
@
* ⇐ 10 ?
35
20
A
A
A
45
40
50
45
20
@
@
@
Algorithmus 2.3.8. Einfügen eines Elements in einen Heap (wir setzen voraus, dass ein Feld array zur Speicherung des Heaps zur Verfügung steht):
1
2
3
4
5
6
7
8
9
10
11
void einfuegenInHeap( Comparable c ) {
if( knotenZahl == array.length ) // Einfuegen ist unmoeglich
throw new IllegalStateException( "Heap ist bereits voll." );
knotenZahl++;
int i = knotenZahl;
for( ; i > 1 && c.compareTo( inhalt( i/2 ) ) < 0; i = i/2 ) {
// wenn i nicht 1 ist und c kleiner als der Inhalt des Knotens i/2 ist:
inhaltAendern( i, inhalt( i/2 ) );
}
inhaltAendern( i, c ); // das neue Objekt in den Knoten i einfuegen
}
Zeitbedarf von insertHeap: O(log n).
Sei H ein nicht-leerer Heap über Item mit n Knoten. Wir wollen nun die Wurzel
von H entfernen und dann die verbleibenden Knoten wieder in Form eines Heaps
organisieren.
einfuegenInHeap
74
KAPITEL 2. BÄUME UND IHRE IMPLEMENTIERUNG
Beispiel 2.3.9.
H:
40
l 10 RRRRR
lll
RR
lll
35 D
20
D
zz
zz
50
45
Zuerst wird die 10 entfernt. Danach muss der Heap die folgenden Form haben:
*
@
@
@
*
*
35
A
A
A
40
50
* ⇐ 45 ?
@
@
@
20
*
A
A
A
*
; min{20, 35} = 20 < 45 :
40
35
A
A
A
50
20
@
@
@
45
Aus dem resultierenden Heap wird nun noch die 20 entfernt:
35
* ⇐ 50 ?
@
@
@
45
; min{35, 45} = 35 < 50 :
? 50 ⇒ *
40
40
40 < 50 :
40
35
@
@
@
35
@
@
@
45
;
45
50
Zur Realisierung dieser Operation entwickeln wir zunächst eine Hilfsmethode
lasseEinsinken, die folgendes leistet: Eine Folge von Elementen aus Item sei im
Feld array im Bereich i bis n gespeichert. Falls die Teilbäume mit Wurzelknoten
2i und 2i+1 bereits die Heap-Eigenschaft erfüllen, so sorgt diese Methode dafür,
dass auch der Teilbaum mit Wurzelknoten i diese Eigenschaft erfüllt.
2.3. ERSTE ANWENDUNGEN BINÄRER BÄUME
75
Algorithmus 2.3.10. Hilfsmethode lasseEinsinken:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void lasseEinsinken( int i, int n ) {
int j = 2*i;
Comparable c = (Comparable)inhalt( i );
while( j <= n ) {
// j wird dasjenige Kind von j/2, das minimalen Knoteninhalt hat:
if( j < n && ((Comparable)inhalt( j )).compareTo( inhalt( j+1 ) ) > 0 )
j++;
if( c.compareTo( inhalt( j ) ) > 0 ) {
inhaltAendern( j/2, inhalt( j ) );
j = 2*j;
}
else
break;
}
inhaltAendern( j/2, c );
}
lasseEinsinken
Zeitbedarf von lasseEinsinken: O(Höhe des Teilbaums mit Wurzelknoten i).
Der Algorithmus loeschenAusHeap kann nun wie folgt formuliert werden.
Algorithmus 2.3.11. Entfernen des obersten Elements aus einem Heap:
1
2
3
4
5
6
7
8
9
10
Object loeschenAusHeap( ) {
if( istLeer( ) ) // Loeschen ist unmoeglich
throw new IllegalStateException( "Heap ist leer." );
Comparable c = (Comparable)inhalt( 1 );
inhaltAendern( 1, inhalt( knotenZahl ) );
knotenZahl−−;
if( !istLeer( ) )
lasseEinsinken( 1, knotenZahl );
return c;
}
Zeitbedarf von loeschenAusHeap: O(log n).
Da bei einem Heap das kleinste Element immer oben steht, liefert uns die
Operation loeschenAusHeap
(1.) das kleinste der n Elemente im Heap und
(2.) einen Heap für die restlichen n − 1 Elemente.
Also können wir durch das Aufbauen eines Heaps für n Elemente und anschließendes Entfernen diese Elemente sortieren. Dies ist die Idee des folgenden
Sortieralgorithmus.
loeschenAusHeap
76
KAPITEL 2. BÄUME UND IHRE IMPLEMENTIERUNG
Algorithmus 2.3.12. Sortieren mit Heaps (HEAP SORT):
void erzeugeHeap( ) {
for( int i = knotenZahl/2; i > 0; i−− )
lasseEinsinken( i, knotenZahl );
4 }
1
erzeugeHeap
2
3
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/** Die im Baum gespeicherten Knoteninhalte werden absteigend sortiert
* bezueglich der durch compareTo gegebenen Ordnung.
* Alle Knoteninhalte muessen daher paarweise mit compareTo vergleichbar sein.
*/
void heapsort( ) {
erzeugeHeap( );
for( int i = knotenZahl−1; i > 0; i−− ) {
Object c = inhalt( i+1 );
inhaltAendern( i+1, inhalt( 1 ) );
inhaltAendern( 1, c );
lasseEinsinken( 1, i );
}
}
Lemma 2.3.13. HEAP SORT hat einen Zeitbedarf von O(n log n) und einen
Platzbedarf von n + c.
Beweis: (1.) Heap erstellen: Sei 2h ≤ n < 2h+1 , d.h. der zu erstellende Heap
hat die Höhe h. Damit finden statt:
auf Stufe h − 1: höchstens 2h−1 Aufrufe von lasseEinsinken,
d.h. Zeitbedarf ≤ c · 2h−1 · 1
auf Stufe h − 2: höchstens 2h−2 Aufrufe von lasseEinsinken
d.h. Zeitbedarf ≤ c · 2h−2 · 2.
Und so weiter. Damit erhalten wir:
Zeitbedarf ≤
h−1
X
c · 2i · (h − i) = c · 2h ·
i=1
= c · 2h ·
h−1
X
(h − i) · 2i−h
i=1
h−1
X
i · 2−i ≤ 2c · n ∈ O(n).
|i=1 {z
<2
}
(2.) Umordnen und Heaps wiederherstellen (n − 1 Aufrufe von lasseEinsinken):
Zeitbedarf ≤ c · (n − 1) · h ∈ O(n log n).
Der Vergleich zwischen TREE SORT und HEAP SORT zeigt, dass beide einen
bezüglich der Anzahl der Schlüsselvergleiche optimalen Zeitbedarf haben, sich
aber im Platzbedarf um den Faktor zwei unterscheiden:
Zeitbedarf
Platzbedarf
TREE SORT
O(n log n)
2n − 1 (+c)
HEAP SORT
O(n log n)
n (+c)
heapsort
2.3. ERSTE ANWENDUNGEN BINÄRER BÄUME
77
Programm 2.3.14. Eine einfache Implementierung von HEAP SORT:
wie in Programm 2.2.2
* Werden Heaps mit vollstaendigen binaeren Baeumen realisiert,
* dann muessen die gespeicherten Objekte vom Typ Comparable sein.
13
*/
14 public class VollstaendigerBinaererBaum {
11
12
wie in Programm 2.2.2
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
/** Das Objekt c vom Typ Comparable wird in den Heap eingefuegt.
* Wir setzen voraus, dass der Baum davor die Heap-Eigenschaft hat
* und garantieren, dass diese Eigenschaft erhalten bleibt.
* Ist der Heap bereits voll, so wird eine Ausnahme ausgeloest.
*/
public void einfuegenInHeap( Comparable c ) {
if( knotenZahl == array.length ) // Einfuegen ist unmoeglich
throw new IllegalStateException( "Heap ist bereits voll." );
knotenZahl++;
int i = knotenZahl;
for( ; i > 1 && c.compareTo( inhalt( i/2 ) ) < 0; i = i/2 ) {
// wenn i nicht 1 ist und c kleiner als der Inhalt des Knotens i/2 ist:
inhaltAendern( i, inhalt( i/2 ) );
}
inhaltAendern( i, c ); // das neue Objekt in den Knoten i einfuegen
}
einfuegenInHeap
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
/** Hier werden nur die Knoten zwischen i und n (i <= n) beruecksichtigt.
* Wir setzen voraus, dass die Teilbaeume des Knotens i die Heap-Eigenschaft
* haben. Diese Eigenschaft wird nun fuer den Teilbaum mit Wurzel i hergestellt.
* Alle Knoteninhalte muessen paarweise mit compareTo vergleichbar sein.
*/
private void lasseEinsinken( int i, int n ) {
int j = 2*i;
Comparable c = (Comparable)inhalt( i );
while( j <= n ) {
// j wird dasjenige Kind von j/2, das minimalen Knoteninhalt hat:
if( j < n && ((Comparable)inhalt( j )).compareTo( inhalt( j+1 ) ) > 0 )
j++;
if( c.compareTo( inhalt( j ) ) > 0 ) {
inhaltAendern( j/2, inhalt( j ) );
j = 2*j;
}
else
break;
}
inhaltAendern( j/2, c );
}
170
171
172
173
174
175
/** Gibt das im Wurzelknoten des Baums gespeicherte Objekt zurueck
* und entfernt dann den Wurzelknoten.
* Ist der Baum leer, so wird eine Ausnahme ausgeloest.
* Wir setzen voraus, dass der Baum davor die Heap-Eigenschaft hat
* und garantieren, dass diese Eigenschaft erhalten bleibt.
lasseEinsinken
78
176
177
178
179
180
181
182
183
184
185
186
187
KAPITEL 2. BÄUME UND IHRE IMPLEMENTIERUNG
* Alle Knoteninhalte muessen paarweise mit compareTo vergleichbar sein.
*/
public Object loeschenAusHeap( ) {
if( istLeer( ) ) // Loeschen ist unmoeglich
throw new IllegalStateException( "Heap ist leer." );
Comparable c = (Comparable)inhalt( 1 );
inhaltAendern( 1, inhalt( knotenZahl ) );
knotenZahl−−;
if( !istLeer( ) )
lasseEinsinken( 1, knotenZahl );
return c;
}
loeschenAusHeap
188
189
190
191
192
193
194
195
/** Stellt die Heap-Eigenschaft her.
* Alle Knoteninhalte muessen paarweise mit compareTo vergleichbar sein.
*/
public void erzeugeHeap( ) {
for( int i = knotenZahl/2; i > 0; i−− )
lasseEinsinken( i, knotenZahl );
}
erzeugeHeap
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
/** Die im Baum gespeicherten Knoteninhalte werden absteigend sortiert
* bezueglich der durch compareTo gegebenen Ordnung.
* Alle Knoteninhalte muessen daher paarweise mit compareTo vergleichbar sein.
* Gibt das sortierte Array zurueck.
*/
public Comparable[ ] heapSort( ) {
erzeugeHeap( );
for( int i = knotenZahl−1; i > 0; i−− ) {
Object c = inhalt( i+1 );
inhaltAendern( i+1, inhalt( 1 ) );
inhaltAendern( 1, c );
lasseEinsinken( 1, i );
}
return (Comparable[ ])array;
}
heapSort
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
public static void main( String[ ] args ) {
int groesse = 30;
int max = 100;
// Erzeuge einen Baum der Groesse groesse, dessen Knoten Zufallszahlen
// zwischen 0 (inklusive) und max (exklusive) speichern:
Integer[ ] array = new Integer[groesse];
Random zufall = new Random( );
for( int i = 0; i < groesse; i++ )
array[i] = new Integer( zufall.nextInt( max ) );
VollstaendigerBinaererBaum baum = new VollstaendigerBinaererBaum( array );
System.out.println( baum ); // unsortiert
baum.heapSort( );
System.out.print( baum ); // sortiert
} // main
227
228
} // class VollstaendigerBinaererBaum
main
2.4. DARSTELLUNGEN ALLGEMEINER BÄUME
79
Ein Testlauf:
Stufe
Stufe
Stufe
Stufe
Stufe
0:
1:
2:
3:
4:
40
6 83
43 26 45 19
43 47 32 1 39 18 64 46
30 2 37 87 99 72 63 5 86 71 45 2 94 44 92
Stufe
Stufe
Stufe
Stufe
Stufe
0:
1:
2:
3:
4:
99
94
87
71
43
2.4
92
86 83 72
64 63 47 46 45 45 44
43 40 39 37 32 30 26 19 18 6 5 2 2 1
Darstellungen allgemeiner Bäume
Nach Definition 2.1.2 wird ein geordneter Baum B = (v, B1 , . . . , Bm ) aus einem
Knoten v (der Wurzel) und m (≥ 0) direkten Teilbäumen B1 , . . . , Bm gebildet.
Zur Implementierung von Bäumen gibt es nun eine Reihe von Möglichkeiten.
Beschränkt man sich auf Bäume mit maximalem Knotengrad k, so ist eine Implementierung ganz analog zu Programm 2.2.4 möglich. Jeder Knoten speichert
dann statt 2 genau k Referenzen kind1, . . . ,kindk für seine maximal k Kinder.
Diese Implementierung hat aber einige gravierende Nachteile:
(1.) Die Anzahl der Kinder eines Knotens ist beschränkt.
(2.) Haben nur wenige Knoten tatsächlich k Kinder, so wird viel Speicherplatz
verschwendet.
(3.) Selbst wenn alle Knoten, die nicht Blätter sind, k Kinder haben, wird
noch viel Speicherplatz verschwendet. Es gilt nämlich folgende Aussage.
Lemma 2.4.1. Sei B ein Baum vom Grad k mit n Knoten. Wird B wie oben
beschrieben implementiert, so sind n · (k − 1) + 1 der n · k Referenzen kind1,
. . . , kindk gleich null.
Beweis: Es gibt n · k Referenzen für die jeweiligen Kinder. Nur n − 1 dieser
Referenzen sind tatsächlich in Gebrauch, d.h. n · k − (n − 1) = n · (k − 1) + 1
davon sind null.
Für k = 3 sind also (2n + 1)/(3n) ≈ 2/3 aller Referenzen nicht benutzt. Man
wird daher diese Implementierung i.Allg. nicht verwenden.
In Kapitel 4 werden wir Graphen betrachten. Da Bäume eine spezielle Art
von Graphen sind, kann man natürlich die Methoden zur Implementierung von
Graphen verwenden.
80
KAPITEL 2. BÄUME UND IHRE IMPLEMENTIERUNG
Schließlich kann man (allgemeine) Bäume aber auch mittels binärer Bäume
implementieren, wie im Folgenden noch ausgeführt wird.
Definition 2.4.2. Eine Implementierung (allgemeiner) geordneter Bäume durch
binäre Bäume:
Sei B = (v, B1 , . . . , Bm ) ein geordneter Baum. Nun definieren wir einen binären
Baum C, der genauso viele Knoten hat wie der Baum B. Die Wurzel von C
entspricht der Wurzel v von B. Als linkes Kind erhält jeder Knoten in C einen
Verweis auf den Knoten, der dem ersten Kind des entsprechenden Knotens in
B entspricht. Als rechtes Kind erhält jeder Knoten in C einen Verweis auf den
Knoten, der dem nächsten Geschwisterknoten des entsprechenden Knotens in
B entspricht.
Beispiel 2.4.3. Allgemeiner Baum B:
89:;
?>=<
A PPP
PPP
nnn
n
n
PPP
n
n
n
PP
n
nn
@ABC
GFED
@ABC
GFED
@ABC
GFED
B@
C
D@
@
@@
}
~
@@
}
~
@@
}
~
@@
}
~
@
}
~
}
~
@ABC
GFED
@ABC
GFED
@ABC
GFED
@ABC
GFED
89:;
?>=<
89:;
?>=<
E
F
G
H
I
J
Binärer Baum C:
89:;
?>=<
n A
n
n
n
nnn
nnn
n
w
@ABC
GFED
@ABC
@ABC
B _ _ _ _ _/GFED
C _ _ _ _ _/ GFED
D
}
~
}
~
}
~
}
~
~}}
~~~
@ABC
GFED
@ABC
GFED
@ABC
GFED
@ABC
GFED
89:;
89:;
_
_
_
_
_
/
_
E
F
G
H _/ ?>=<
I _ _/ ?>=<
J
Als über Referenzen verbundene Struktur erhalten wir folgende Darstellung:
A
-
"
"
"
"
B
-
E
-
F
C
bb
bb
b
-
-
G
-
-
"
"
"
"
H
-
D
-
-
J
I
-
2.4. DARSTELLUNGEN ALLGEMEINER BÄUME
81
Programm 2.4.4. Eine Implementierung allgemeiner geordneter Bäume:
/** Die Klasse Binaerknoten implementiert Knoten eines binaeren Baums.
* Jeder Knoten speichert ein beliebiges Objekt vom Typ Object,
3
* zusaetzlich je eine Referenz auf das linke und das rechte Kind.
4
*/
5 class Binaerknoten {
1
2
wie in Programm 2.2.4
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
/** Gibt den hier wurzelnden Baum in Vorordnung mit Klammern
* und Kommata aus.
*/
public void druckeVorordnung( ) {
System.out.print( this );
if( linkesKind != null ) {
System.out.print( "(" );
linkesKind.druckeVorordnung( );
Binaerknoten k = linkesKind.rechtesKind;
while( k != null ) {
System.out.print( "," );
k.druckeVorordnung( );
k = k.rechtesKind;
}
System.out.print( ")" );
}
}
druckeVorordnung
80
81
} // class Binaerknoten
82
83
/** Die Klasse AllgemeinerBaum implementiert allgemeine Baeume
* ueber Binaerbaeume. Ein AllgemeinerBaum besteht
86
* einfach aus seinem Wurzelknoten vom Typ Binaerknoten.
87
*/
88 public class AllgemeinerBaum {
84
85
89
90
91
/** Der Wurzelknoten. */
private Binaerknoten wurzel;
92
93
94
95
96
97
98
/** Konstruiert einen Baum, der nur einen Wurzelknoten
* mit Inhalt inhalt besitzt.
*/
public AllgemeinerBaum( Object inhalt ) {
wurzel = new Binaerknoten( inhalt );
}
AllgemeinerBaum
99
100
101
102
/** Konstruiert den leeren Baum. */
public AllgemeinerBaum( ) { }
AllgemeinerBaum
82
103
104
105
106
KAPITEL 2. BÄUME UND IHRE IMPLEMENTIERUNG
/** Gibt den Wurzelknoten zurueck. */
public Binaerknoten wurzel( ) {
return wurzel;
}
wurzel
107
108
109
110
111
/** Test, ob der Baum leer ist. */
boolean istLeer( ) {
return wurzel == null;
}
istLeer
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
/** Haengt den Baum baum als neuen ersten direkten Teilbaum an.
* Der bisherige i-te Teilbaum (wenn vorhanden) wird zum (i+1)-ten Teilbaum.
* Ist der aktuelle Baum leer, so wird der Baum zu baum.
* Gibt den modifizierten Baum zurueck.
* Der Argumentbaum baum wird danach leer sein.
*
* Um zu verhindern, dass die Teilbaeume uebereinstimmen, fordern wir, dass
* die beiden Wurzelreferenzen verschieden sind, ausser beide Baeume sind leer;
* andernfalls wird eine Ausnahme ausgeloest.
*/
AllgemeinerBaum alsErstenTeilbaumAnhaengen( AllgemeinerBaum baum ) {
if( !istLeer( ) && wurzel == baum.wurzel )
throw new IllegalArgumentException( "Identische Baeume verknuepft." );
if( istLeer( ) )
wurzel = baum.wurzel;
else {
baum.wurzel.rechtesKindAendern( wurzel.linkesKind( ) );
wurzel.linkesKindAendern( baum.wurzel );
}
baum.wurzel = null;
return this;
}
alsErstenTeilbaumAnhaengen
135
136
137
138
139
140
141
142
143
144
/** Gibt den Baum in Vorordnung mit Klammern und Kommata aus. */
public void druckeVorordnung( ) {
if( istLeer( ) )
System.out.println( "Der Baum ist leer." );
else {
wurzel.druckeVorordnung( );
System.out.println( );
}
}
druckeVorordnung
145
146
147
148
149
150
151
152
public static void main( String[ ] args ) {
AllgemeinerBaum b1 =
new AllgemeinerBaum( "*" ).
alsErstenTeilbaumAnhaengen( new AllgemeinerBaum( "B" ) ).
alsErstenTeilbaumAnhaengen( new AllgemeinerBaum( "A" ) );
b1.druckeVorordnung( );
main
2.4. DARSTELLUNGEN ALLGEMEINER BÄUME
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
AllgemeinerBaum b2 =
new AllgemeinerBaum( "A" ).
alsErstenTeilbaumAnhaengen(
new AllgemeinerBaum( "D" ).
alsErstenTeilbaumAnhaengen( new
alsErstenTeilbaumAnhaengen( new
alsErstenTeilbaumAnhaengen( new
alsErstenTeilbaumAnhaengen(
new AllgemeinerBaum( "C" ).
alsErstenTeilbaumAnhaengen( new
alsErstenTeilbaumAnhaengen(
new AllgemeinerBaum( "B" ).
alsErstenTeilbaumAnhaengen( new
alsErstenTeilbaumAnhaengen( new
b2.druckeVorordnung( );
} // main
83
AllgemeinerBaum( "J" ) ).
AllgemeinerBaum( "I" ) ).
AllgemeinerBaum( "H" ) ) ).
AllgemeinerBaum( "G" ) ) ).
AllgemeinerBaum( "F" ) ).
AllgemeinerBaum( "E" ) ) );
} // class AllgemeinerBaum
Der Testlauf druckt zuletzt den Baum aus Beispiel 2.4.3 in Vorordnung mit
Klammern und Kommata:
*(A,B)
A(B(E,F),C(G),D(H,I,J))
84
KAPITEL 2. BÄUME UND IHRE IMPLEMENTIERUNG
Kapitel 3
Datentypen zur Darstellung
von Mengen
Die Darstellung von Mengen ist offensichtlich eine grundlegende Aufgabe. Einige Hilfsmittel hierzu, nämlich Listen und Bäume, haben wir bereits kennengelernt. Hier werden wir nun einige weitere Datentypen zur Implementierung von
Mengen betrachten, die sich durch ihre Operationen unterscheiden. Es geht dabei darum, die Grundbausteine so auszuwählen und zu verfeinern, dass spezielle
Operationen effizient realisiert werden können.
Wir werden im wesentlichen drei verschiedene Gruppen von Operationen auf
Mengen betrachten. Zunächst schauen wir uns Mengen an, für die die klassischen mathematischen Operationen Vereinigung, Durchschnitt und Differenz
realisiert werden sollen. Danach wenden wir uns Mengen zu, in denen Elemente gesucht, eingefügt und entfernt werden sollen. Dieser Datentyp ist auch als
dictionary“ bekannt. Wegen seiner grundlegenden Bedeutung werden wir eine
”
ganze Reihe verschiedener Realisierungen untersuchen. Wir beschließen dieses
Kapitel mit der Betrachtung eines Datentyps für die Verwaltung von Partitionen
endlicher Mengen, wobei das Vereinigen zweier Teilmengen und das Auffinden
der Teilmenge, die ein gegebenes Element enthält, im Vordergrund stehen.
3.1
Mengen mit Vereinigung, Schnitt und Differenz
Hier betrachten wir Mengen mit den klassischen mathematischen Operationen
Vereinigung, Durchschnitt und Differenz:
M1 ∪ M2 = {x | x ∈ M1 oder x ∈ M2 },
M1 ∩ M2 = {x | x ∈ M1 und x ∈ M2 },
M1 \ M2 = {x | x ∈ M1 und x 6∈ M2 }.
Als weitere Operationen nehmen wir die Konstante ∅ (die leere Menge), das
Einfügen eines Elements, das Auflisten aller Elemente einer Menge und den
85
86
KAPITEL 3. DATENTYPEN ZUR DARSTELLUNG VON MENGEN
Test auf Leerheit hinzu. Wir erhalten die folgende Signatur der Datenstruktur
Set (set, boolean, item und list sind Sortensymbole):
EMPTY : → set
ISEMPTY : set → boolean
INSERT : set × item → set
UNION : set × set → set
INTERSECTION : set × set → set
DIFFERENCE : set × set → set
ENUMERATE : set → list
Hier sollen drei Implementierungen der Datenstruktur Set vorgestellt werden:
(a) Ist der Wertebereich Item hinreichend klein (etwa |Item| = 32), dann
kann man jede Teilmenge S von Item = {a1 , . . . , a32 } durch einen Bitvektor
(b1 , . . . , b32 ) darstellen, der gerade in ein Maschinenwort passt:
(
1 falls ai ∈ S
bi =
0 falls ai ∈
/S
Programm 3.1.1. Alle Mengenoperationen lassen sich sehr effizient umsetzen:
1
2
import java.util.LinkedList; // fuer die Methode enumerate
3
4
/** Die Klasse Set1 implementiert Teilmengen einer
* 32-elementigen Menge {a 1,. . .,a 32}.
* Intern wird eine Teilmenge als Zahl vom Typ int repraesentiert,
* die wir als Bitvektor auffassen.
*/
public class Set1 {
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/** Der Bitvektor als Zahl vom Typ int. */
private int vector;
/** Konstruiert die leere Menge. */
public Set1( ) {
}
/** Konstruiert eine (tiefe) Kopie der Menge otherSet. */
public Set1( Set1 otherSet ) {
vector = otherSet.vector;
}
/** Liefert eine neue leere Teilmenge zurueck. */
public static Set1 empty( ) {
return new Set1( );
}
Set1
Set1
empty
3.1. MENGEN MIT VEREINIGUNG, SCHNITT UND DIFFERENZ
27
28
29
30
/** Test, ob die Teilmenge leer ist. */
public boolean isEmpty( ) {
return vector == 0;
}
87
isEmpty
31
32
33
34
35
36
37
/** Fuegt das i-te Element a i in die Teilmenge ein. */
public void insert( int i ) {
if( i < 1 | | i > 32 )
throw new IllegalArgumentException( "Element nicht im Universum." );
vector |= 1 << i−1; // i-tes Bit von rechts setzen
}
insert
38
39
40
41
42
/** Vereinigt diese Teilmenge mit der Argumentmenge. */
public void union( Set1 otherSet ) {
vector |= otherSet.vector;
}
union
43
44
45
46
47
/** Schneidet diese Teilmenge mit der Argumentmenge. */
public void intersection( Set1 otherSet ) {
vector &= otherSet.vector;
}
intersection
48
49
50
51
52
/** Bildet die Differenz dieser Teilmenge mit der Argumentmenge. */
public void difference( Set1 otherSet ) {
vector &= ˜otherSet.vector;
}
difference
53
54
55
56
57
58
59
60
61
62
63
64
65
/** Gibt eine Liste der Elemente der Teilmenge zurueck. */
public LinkedList enumerate( ) {
int mask = 1; // nur das Bit ganz rechts ist gesetzt
LinkedList list = new LinkedList( ); // die leere Liste
int v = vector; // Kopie anlegen
for( int i = 1; i <= 32; i++ ) {
if( ( v & mask ) == 1 ) // ist das Bit ganz rechts in v gesetzt?
list.add( new Integer( i ) ); // i in die Liste einfuegen
v >>>= 1; // schiebe alle Bits in v um eine Stelle nach rechts
}
return list;
}
enumerate
66
67
68
69
70
71
72
/** Gibt eine String-Repraesentation der Teilmenge zurueck:
* Die Liste [i 1,. . .,i n] repraesentiert die Teilmenge {a (i 1),. . .,a (i n)}.
*/
public String toString( ) {
return enumerate( ).toString( );
}
toString
73
74
75
76
77
78
79
80
public static void main( String[ ] args ) {
Set1 s = empty( );
s.insert( 8 );
s.insert( 1 );
s.insert( 32 );
s.insert( 5 );
s.insert( 8 );
main
88
81
82
83
84
KAPITEL 3. DATENTYPEN ZUR DARSTELLUNG VON MENGEN
System.out.println( "s = " + s ); // s = [1, 5, 8, 32]
Set1 s0 = new Set1( s );
Set1 s1 = new Set1( s );
Set1 s2 = new Set1( s );
85
86
87
88
89
90
Set1 t = empty( );
t.insert( 7 );
t.insert( 8 );
t.insert( 1 );
System.out.println( "t = " + t ); // t = [1, 7, 8]
91
92
93
94
95
96
97
98
s0.union( t );
System.out.println( "Vereinigung:\t" + s0 ); // Vereinigung: [1, 5, 7, 8, 32]
s1.intersection( t );
System.out.println( "Durchschnitt:\t" + s1 ); // Durchschnitt: [1, 8]
s2.difference( t );
System.out.println( "Differenz:\t" + s2 ); // Differenz: [5, 32]
} // main
99
100
} // class Set1
Bemerkung: Mengen größerer Kardinalität können auf ähnliche Weise implementiert werden, wenn längere Bitvektoren zur Verfügung stehen; allerdings
muss dann in der Regel ein Effizienzverlust in Kauf genommen werden. In Java
eignet sich für diesen Zweck die Klasse java.util.BitSet.
(b) Natürlich kann man eine Menge durch eine ungeordnete Liste implementieren, indem man jedem Element der Menge einen Listenknoten zuordnet, und
diese Knoten in einer beliebigen Reihenfolge verkettet. Für eine Menge M1 mit
m Elementen und eine Menge M2 mit n Elementen kosten die Operationen
Vereinigung, Schnitt und Differenz dann Rechenzeit O(m · n). Das Einfügen in
M1 kostet O(m) Schritte, ebenso wie das Aufzählen vom M1 .
(c) Ist auf der Grundmenge Item eine lineare Ordnung definiert (siehe Abschnitt 2.3), so kann man die Elemente einer Teilmenge von Item so in einer
linearen Liste speichern, dass sie in aufsteigender Reihenfolge sortiert sind. Für
die einzelnen Operationen ergeben sich dann folgende Abschätzungen für den
Rechenzeitbedarf:
empty
isEmpty
insert
union
intersection
difference
enumerate
Liste initialisieren
Test auf leere Liste
richtige Position suchen, dort einfügen
Listen parallel durchlaufen
Liste durchlaufen
O(1)
O(1)
O(m)
O(m + n)
O(m)
3.1. MENGEN MIT VEREINIGUNG, SCHNITT UND DIFFERENZ
89
Programm 3.1.2. Eine Implementierung könnte wie folgt aussehen:
1
2
import java.util.LinkedList;
import java.util.ListIterator;
3
/** Die Klasse Set2 implementiert Mengen ueber dem
* Typ Comparable als aufsteigend sortierte Listen.
6
*/
7 public class Set2 {
4
5
8
9
10
/** Die aufsteigend sortierte Liste. */
private LinkedList list;
11
12
13
14
15
/** Konstruiert die leere Menge. */
public Set2( ) {
list = new LinkedList( );
}
Set2
16
17
18
19
20
/** Konstruiert eine flache Kopie der Menge otherSet. */
public Set2( Set2 otherSet ) {
list = (LinkedList)otherSet.list.clone( );
}
Set2
21
22
23
24
25
/** Liefert eine neue leere Menge zurueck. */
public static Set2 empty( ) {
return new Set2( );
}
empty
26
27
28
29
30
/** Test, ob die Menge leer ist. */
public boolean isEmpty( ) {
return list.size( ) == 0;
}
isEmpty
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
/** Fuegt das Objekt c vom Typ Comparable in die Menge ein.
* Objekte, die bezueglich der durch compareTo definierten
* Ordnung aequivalent sind, werden hoechstens einmal in die
* Menge aufgenommen. Gibt true zurueck, wenn das Einfuegen
* die Menge veraendert, sonst false.
*/
public boolean insert( Comparable c ) {
ListIterator it = list.listIterator( );
while( it.hasNext( ) ) { // Position suchen
if( c.compareTo( it.next( ) ) <= 0 ) {
if( c.compareTo( it.previous( ) ) < 0 ) {
it.add( c ); // neues Element dort einfuegen
return true;
}
return false; // c ist aequivalent zu vorhandenem Element
}
}
it.add( c ); // sonst am Ende der Liste einfuegen
return true;
}
insert
90
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
KAPITEL 3. DATENTYPEN ZUR DARSTELLUNG VON MENGEN
/** Vereinigt diese Menge mit der Argumentmenge. */
public void union( Set2 otherSet ) {
ListIterator it = list.listIterator( );
ListIterator otherIt = otherSet.list.listIterator( );
while( it.hasNext( ) && otherIt.hasNext( ) ) {
Comparable c = (Comparable)otherIt.next( );
int comparison = c.compareTo( it.next( ) );
if( comparison < 0 ) {
it.previous( );
it.add( c );
continue;
}
if( comparison > 0 )
otherIt.previous( );
// if( comparison == 0 ): nichts zu tun
}
while( otherIt.hasNext( ) ) // it ist am Ende
it.add( otherIt.next( ) );
}
union
72
73
74
75
76
/** Schneidet diese Menge mit der Argumentmenge. */
public void intersection( Set2 otherSet ) {
// als Uebung
}
intersection
77
78
79
80
81
/** Bildet die Differenz dieser Menge mit der Argumentmenge. */
public void difference( Set2 otherSet ) {
// als Uebung
}
difference
82
83
84
85
86
/** Gibt eine Liste der Elemente der Menge zurueck. */
public LinkedList enumerate( ) {
return list;
}
enumerate
87
88
89
90
91
/** Gibt eine String-Repraesentation der Menge zurueck. */
public String toString( ) {
return list.toString( );
}
toString
92
93
94
95
96
97
98
99
100
public static void main( String[ ] args ) {
Set2 s = empty( );
s.insert( new Integer( 8 ) );
s.insert( new Integer( 1 ) );
s.insert( new Integer( 32 ) );
s.insert( new Integer( 5 ) );
s.insert( new Integer( 8 ) );
System.out.println( "s = " + s ); // s = [1, 5, 8, 32]
101
102
103
104
105
Set2 t = empty( );
t.insert( new Integer( 7 ) );
t.insert( new Integer( 8 ) );
t.insert( new Integer( 1 ) );
main
3.2. SUCHBÄUME
106
107
108
109
110
111
112
91
System.out.println( "t = " + t ); // t = [1, 7, 8]
s.union( t );
System.out.println( "Vereinigung:\t" + s ); // Vereinigung: [1, 5, 7, 8, 32]
} // main
} // class Set2
3.2
Suchbäume
Die am häufigsten auftretenden Anwendungen der Datenstruktur Menge“ be”
nötigen aber nicht die mengentheoretischen Operationen Durchschnitt, Vereinigung und Differenz. Vielmehr stehen dabei die Operation Einfügen (insert), Löschen (delete) und Test auf Enthaltensein (isMember) im Vordergrund.
Ein Datentyp, der diese Operationen zur Verfügung stellt, heißt Dictionary
(Wörterbuch). In den folgenden Abschnitten werden wir verschiedene Realisierungen dieses Datentyps kennenlernen. Hier betrachten wir eine Realisierung
mittels binärer Bäume.
Definition 3.2.1. Eine Knotenmarkierung für einen Baum mit Knotenmenge
V über einer Menge Item ist eine Abbildung µ : V → Item.
Definition 3.2.2. Sei Item eine durch ≤ linear geordnete Menge. Ein Suchbaum für die Schlüssel a1 , . . . , an ∈ Item ist ein binärer Baum mit Knotenmenge
V und einer Knotenmarkierung µ : V → Item, so dass folgende Bedingungen
erfüllt sind:
• µ ist eine Bijektion von V auf {a1 , . . . , an } (also ist |V | = n).
• Für Knoten k, k 0 ∈ V gilt
µ(k 0 ) < µ(k) wenn k 0 im linken direkten Teilbaum unterhalb von k ist,
µ(k) < µ(k 0 ) wenn k 0 im rechten direkten Teilbaum unterhalb von k ist.
Beispiel 3.2.3. Bezüglich der lexikographischen Ordnung der Knotenmarkierungen sind die beiden folgenden Bäume Suchbäume:
JAN
Q
""
Q ""
Q
FEB
MAR
APR
T
AUG
@
@
MAI
SEP
JUN
OKT
T
DEZ
JUL
NOV
92
KAPITEL 3. DATENTYPEN ZUR DARSTELLUNG VON MENGEN
JUL
!
aa
!! aa
!!
a
DEZ
NOV
@
@
@
@
MAI
OKT
AUG
FEB
@
@
\\
@
@
APR
JAN
JUN
MAR
SEP
Auf Suchbäumen wollen wir nun die folgenden Operationen realisieren: Die
Konstante empty für den leeren Suchbaum, die Boolesche Operation isEmpty
(Test auf Leerheit), die Operationen insert und delete (Einfügen bzw. Entfernen
eines Elements aus Item), und zuletzt noch die Boolesche Operation isMember
(Test auf Enthaltensein). Tatsächlich werden wir von der Operation isMember
etwas mehr verlangen; ist nämlich im Suchbaum ein Knoten vorhanden, der mit
dem gesuchten Element aus Item markiert ist, so soll isMember einen Verweis
auf diesen Knoten liefern, ansonsten die Referenz null.
Algorithmus 3.2.4. Wir verwenden die Implementierung binärer Bäume aus
Definition 2.2.3(b), bei der jeder Knoten aus drei Komponenten besteht:
• dem gespeicherten Inhalt (hier: die Knotenmarkierung)
• dem Verweis auf das linke Kind,
• dem Verweis auf das rechte Kind.
Die Operationen empty und isEmpty sind dann trivial. Das Suchen nach einem
Knoten (Operation isMember) kann durch einfaches Absteigen im Baum realisiert werden, wobei das gesuchte Element jeweils mit dem Inhalt des aktuellen
Knotens verglichen wird.
Die Operation insert arbeitet wie das Suchen. Entweder ist schon ein Knoten
mit dem entsprechenden Inhalt vorhanden, so dass der Baum nicht verändert
wird, oder aber es wird die Stelle gefunden, an die der entsprechende Knoten
als Blatt eingefügt werden muss.
Etwas komplizierter ist die Realisierung der Operation delete. Ist kein Knoten
mit dem gesuchten Schlüssel vorhanden, dann geschieht nichts. Ist der gesuchte
Knoten ein Blatt, so kann er einfach entfernt werden. Ist er aber ein innerer
Knoten, so müssen wir die Struktur des Suchbaums nach dem Löschen wiederherstellen.
1. Fall:
delete(b)
...
x
xxx
89:;
?>=<
b



89:;
?>=<
a GG
w
GG
www
...
...
3.2. SUCHBÄUME
93
/.-,
()*+
In diesem Fall wird einfach der Knoten ()*+
b entfernt. Der Verweis auf /.-,
b wird
'&%$
!"#
()*+
/.-,
umgesetzt auf a . Falls b nur ein rechtes Kind hat, verfährt man entsprechend.
2. Fall:
delete(b)
...
%
b
"
QQ
""
Q
a
d
,
@
% e
%
e
,
@
...
B
B . . .
B
B
T2 B
T1 B
B B
()*+
Um die Struktur eines Suchbaums auch nach dem Löschen von /.-,
b zu erhalten,
müssen wir in den Knoten, der jetzt b enthält, einen geeigneten Wert schreiben.
()*+
Hierzu bietet sich der Knoten aus dem linken direkten Teilbaum von /.-,
b an, der
den größten Schlüsselwert enthält (d.h. der dort bzgl. Zwischenordnung letzte
Knoten).
Fall 2.(a):
"
QQ
""
Q
a
d
,
% e
@
%
e
,
@
m
.
.
.
...
B
B
T1 B . . .
B
m
...
c
Fall 2.(b):
"
QQ
""
Q
a
d
,
% e
@
%
e
,
@
m
...
...
B
B
T1 B . . .
B
m
...
c
B
B
T3 B
B
c
"
QQ
Q
""
a
d
,
% e
@
%
e
,
@
m
.
.
.
...
B
B
T1 B . . .
B
m
...
c
"
QQ
Q
""
a
d
,
% e
@
%
e
,
@
m
...
...
B
B
T1 B . . .
B
m
@
@
...
B
B
T3 B
B
94
KAPITEL 3. DATENTYPEN ZUR DARSTELLUNG VON MENGEN
Man kann leicht zeigen, dass jeder so entstandene Baum ein Suchbaum für die
verbliebenen Schlüssel ist.
Wir sehen also, dass jede der Operationen isMember, insert und delete im
schlechtesten Fall so viele Schritte braucht, wie der aktuelle Suchbaum hoch
ist. Enthält der Baum n Knoten, dann kann er die Höhe n − 1 haben, d.h. im
schlechtesten Fall kostet jede der obigen Operationen O(n) Schritte. Fügt man
in den anfänglich leeren Baum n Knoten ein, so kostet dies im schlechtesten
Fall
c·
n
X
i ∈ Θ(n2 )
i=1
Schritte. Wenn man annimmt, dass alle Reihenfolgen für das Einfügen der n
Elemente gleich wahrscheinlich sind, kann man aber zeigen, dass im Mittel
O(log n) Schritte pro Operation ausreichen.
Programm 3.2.5. Eine Implementierung von Suchbäumen:
wie in Programm 2.2.4
5
class Binaerknoten {
wie in Programm 2.2.4
63
64
65
66
67
68
69
70
71
72
73
74
75
76
/** Zu Testzwecken: Gibt den hier wurzelnden Baum in Vorordnung
* mit Klammern und Kommata aus.
*/
public void druckeVorordnung( ) {
System.out.print( this + "(" );
if( linkesKind != null )
linkesKind.druckeVorordnung( );
System.out.print( "," );
if( rechtesKind != null )
rechtesKind.druckeVorordnung( );
System.out.print( ")" );
}
druckeVorordnung
} // class Binaerknoten
77
78
79
80
81
82
83
84
85
86
87
88
89
90
/** Die Klasse Suchbaum implementiert binaere Suchbaeume mittels Referenzen. */
public class Suchbaum {
/** Der Wurzelknoten. */
private Binaerknoten wurzel;
/** Konstruiert einen Baum, der nur einen Wurzelknoten
* mit Schluessel key besitzt.
*/
public Suchbaum( Object key ) {
wurzel = new Binaerknoten( key );
}
Suchbaum
3.2. SUCHBÄUME
95
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
/** Konstruiert den leeren Baum. */
public Suchbaum( ) {
}
/** Gibt den Wurzelknoten zurueck. */
public Binaerknoten wurzel( ) {
return wurzel;
}
/** Test, ob der Baum leer ist. */
boolean istLeer( ) {
return wurzel == null;
}
/** Gibt den Elternknoten des Knotens mit maximalem Schluessel
* im linken direkten Teilbaum von k zurueck. Wir setzen voraus,
* dass dieser linke Teilbaum nicht leer ist.
*/
private Binaerknoten predMaximum( Binaerknoten k ) {
Binaerknoten pred = k;
Binaerknoten node = k.linkesKind( ); // existiert nach Voraussetzung
while( node.rechtesKind( ) != null ) {
pred = node;
node = node.rechtesKind( );
}
return pred;
}
/** Diese Klasse dient als Rueckgabetyp der Methode searchKey. */
private class SearchKeyResult {
Binaerknoten pred, node;
int direction;
SearchKeyResult( Binaerknoten p, Binaerknoten n, int d ) {
pred = p;
node = n;
direction = d;
}
}
/** Gibt ein Tripel (pred, node, direction) vom Typ SearchKeyResult zurueck.
* Wenn pred und node beide null sind, dann ist der Baum leer.
* Wenn node nicht null ist:
*
key ist (aequivalent zum) Schluessel in Knoten node,
*
pred ist dessen Elternknoten, falls vorhanden, sonst null.
* Wenn node null ist, pred nicht null ist und direction < 0:
*
key kommt nicht als Schluessel (bzgl. Aequivalenz) vor;
*
die korrekte Position fuer das Blatt mit Schluessel key
*
ist das (noch nicht vorhandene) linke Kind von pred.
* Wenn node null ist, pred nicht null ist und direction > 0:
*
analog mit rechtem Kind von pred.
*/
Suchbaum
wurzel
istLeer
predMaximum
SearchKeyResult
96
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
KAPITEL 3. DATENTYPEN ZUR DARSTELLUNG VON MENGEN
private SearchKeyResult searchKey( Comparable key ) {
if( istLeer( ) )
return new SearchKeyResult( null, null, 0 );
Binaerknoten pred = null;
Binaerknoten node = wurzel;
int direction = 0;
do {
int vergleich = key.compareTo( node.inhalt( ) );
if( vergleich == 0 )
return new SearchKeyResult( pred, node, direction );
pred = node;
if( vergleich < 0 ) {
node = node.linkesKind( );
direction = −1;
}
else { // vergleich > 0
node = node.rechtesKind( );
direction = +1;
}
}
while( node != null );
return new SearchKeyResult( pred, node, direction );
}
searchKey
166
167
168
169
170
171
172
/** Gibt einen Knoten mit einem zu key aequivalenten Schluessel
* zurueck, falls ein solcher Knoten vorhanden ist, sonst null.
*/
public Binaerknoten isMember( Comparable key ) {
return searchKey( key ).node;
}
isMember
173
174
175
176
177
178
179
180
181
182
183
/** Fuegt einen Knoten mit Schluessel key in den Suchbaum ein. */
public void insert( Comparable key ) {
SearchKeyResult r = searchKey( key );
if( r.pred == null && r.node == null ) // Baum ist leer
wurzel = new Binaerknoten( key );
else if( r.direction < 0 )
r.pred.linkesKindAendern( new Binaerknoten( key ) );
else if( r.direction > 0 )
r.pred.rechtesKindAendern( new Binaerknoten( key ) );
}
insert
184
185
186
187
188
189
190
191
192
193
194
195
/** Entfernt den Knoten mit zu key aequivalentem Schluessel, falls vorhanden.
*/
public void delete( Comparable key ) {
SearchKeyResult r = searchKey( key );
Binaerknoten k = r.node;
if( k != null ) { // k hat Schluessel key
if( k.linkesKind( ) == null | | k.rechtesKind( ) == null ) { // Fall 1
Binaerknoten einzigesKind =
k.linkesKind( ) == null ? k.rechtesKind( ) : k.linkesKind( );
if( r.pred == null ) // k ist Wurzel
wurzel = einzigesKind;
delete
3.2. SUCHBÄUME
else // r.pred ist Elternknoten von k
if( r.direction < 0 )
r.pred.linkesKindAendern( einzigesKind );
else // r.direction > 0
r.pred.rechtesKindAendern( einzigesKind );
196
197
198
199
200
}
else { // Fall 2
Binaerknoten predMax = predMaximum( k );
Binaerknoten maximum =
predMax == k ? k.linkesKind( ) : predMax.rechtesKind( );
k.inhaltAendern( maximum.inhalt( ) );
if( predMax == k )
k.linkesKindAendern( maximum.linkesKind( ) );
else
predMax.rechtesKindAendern( maximum.linkesKind( ) );
}
201
202
203
204
205
206
207
208
209
210
211
}
212
213
97
}
214
215
216
217
218
219
220
221
222
223
224
225
/** Gibt die Schluessel des Suchbaums in Zwischenordnung aus,
* also als aufsteigend geordnete Folge.
*/
public void druckeZwischenordnung( ) {
if( istLeer( ) )
System.out.println( "Der Baum ist leer." );
else {
wurzel.druckeZwischenordnung( );
System.out.println( );
}
}
druckeZwischenordnung
226
227
228
229
230
231
232
233
234
235
236
237
/** Gibt die Schluessel des Suchbaums in Vorordnung mit Klammern
* und Kommata aus.
*/
public void druckeVorordnung( ) {
if( istLeer( ) )
System.out.println( "Der Baum ist leer." );
else {
wurzel.druckeVorordnung( );
System.out.println( );
}
}
druckeVorordnung
238
239
240
241
242
243
244
245
246
247
248
public static void main( String[ ] args ) {
Suchbaum b = new Suchbaum( );
b.insert( "JAN" ); b.insert( "FEB" ); b.insert( "APR"
b.insert( "AUG" ); b.insert( "DEZ" ); b.insert( "MAR"
b.insert( "MAI" ); b.insert( "JUN" ); b.insert( "JUL"
b.insert( "SEP" ); b.insert( "OKT" ); b.insert( "NOV"
b.druckeZwischenordnung( ); b.druckeVorordnung( );
b.delete( "FEB" );
b.druckeZwischenordnung( ); b.druckeVorordnung( );
b.delete( "JAN" );
main
);
);
);
);
98
b.druckeZwischenordnung( ); b.druckeVorordnung( );
b.delete( "MAR" );
b.druckeZwischenordnung( ); b.druckeVorordnung( );
249
250
251
252
253
254
KAPITEL 3. DATENTYPEN ZUR DARSTELLUNG VON MENGEN
}
} // class Suchbaum
3.3
Gewichtsbalancierte Bäume
Wie wir gesehen haben, sind Suchbäume im schlechtesten Fall genauso ineffizient wie lineare Listen. Andererseits kann man n Schlüssel in einem binären Suchbaum der Höhe dlog(n + 1)e − 1 unterbringen (Lemma 2.1.6.). Für einen solchen
Suchbaum kosten die Operationen isMember, insert und delete nur O(log n)
Schritte. Damit stellt sich die Frage, ob man nicht mit solchen Suchbäumen auskommen kann, die für n Schlüssel nur Höhe ≤ c · log n für eine feste
Konstante c haben. Solche Suchbäume wollen wir ausgeglichen oder balanciert
nennen. Es gibt verschiedene Möglichkeiten, Suchbäume zu balancieren. In diesem Abschnitt wollen wir uns die sogenannten BB[α]-Bäume anschauen, die
gewichtsbalanciert sind.
Für einen binären Baum T bezeichne |T | die Anzahl seiner Knoten, T` seinen
linken und Tr seinen rechten direkten Teilbaum.
T :
Q
Q
Q
Q
A
A
A
A
A
A
A
A
A
A
T`
Tr
Definition 3.3.1. Die Wurzelbalance eines binären Baums T ist der Wert
|T` | + 1
|Tr | + 1
ρ(T ) =
=1−
.
|T | + 1
|T | + 1
Ein Baum ist von beschränkter Balance α, falls für jeden seiner Teilbäume T 0
α ≤ ρ(T 0 ) ≤ 1 − α
gilt. BB[α] ist die Menge aller binären Bäume von beschränkter Balance α; wir
nennen diese Bäume auch BB[α]-Bäume.
In diesem Abschnitt bezeichne α eine festgewählte reelle Zahl mit
1
1
< α ≤ 1 − √ ≈ 0, 29.
4
2
3.3. GEWICHTSBALANCIERTE BÄUME
99
Bemerkung 3.3.2. Wenn wir in jedem Knoten k zusätzlich zum Inhalt und zu
den Verweisen auf die beiden Kinder noch die Anzahl der Knoten im Teilbaum
mit Wurzel k speichern, so können wir ρ in Zeit O(1) ausrechnen.
Beispiel 3.3.3.
T :
5
HH
H
2
7
@
@
@
@
1
4
8
6
3
Sei Ti der Teilbaum mit Wurzel i. Dann gilt:
i
ρ(Ti )
1
1/2
2
2/5
3
1/2
4
2/3
5
5/9
6
1/2
7
1/2
8
1/2
Für alle i gilt also 1/3 ≤ ρ(Ti ) ≤ 1 − 1/3, daher ist T ein BB[α]-Baum.
Satz 3.3.4. Sei T ein BB[α]-Baum mit n Knoten und Höhe h. Dann gilt
h≤
log(n + 1) − 1
,
− log(1 − α)
d.h. BB[α]-Bäume haben nur logarithmische Höhe.
Beweis: Sei v ein Knoten von T der Tiefe h und sei v0 , v1 , . . . , vh = v der Weg
von der Wurzel v0 zum Knoten v. Für 0 ≤ i ≤ h sei Ti der Teilbaum mit Wurzel
vi , und es sei wi = |Ti |.
Behauptung: wi+1 + 1 ≤ (1 − α)(wi + 1) für 1 ≤ i < h.
• 1. Fall: vi+1 ist das linke Kind von vi , d.h. Ti+1 ist der linke direkte
Teilbaum von Ti . Da T ∈ BB[α] ist, folgt
|Ti+1 | + 1
wi+1 + 1
=
= ρ(Ti ) ≤ 1 − α.
|Ti | + 1
wi + 1
• 2. Fall: vi+1 ist das rechte Kind von vi , d.h. Ti+1 ist der rechte direkte
Teilbaum von Ti . Dann folgt
α ≤ ρ(Ti ) = 1 −
|Ti+1 | + 1
wi+1 + 1
=1−
.
|Ti | + 1
wi + 1
100
KAPITEL 3. DATENTYPEN ZUR DARSTELLUNG VON MENGEN
In beiden Fällen gilt also die Behauptung. Damit erhalten wir
2 ≤ wh + 1 ≤ (1 − α)(wh−1 + 1) ≤ · · · ≤ (1 − α)h (w0 + 1) = (1 − α)h (n + 1),
also 1 ≤ h log(1 − α) + log(n + 1), d.h. h ≤ (log(n + 1) − 1)/ − log(1 − α).
√
Für α = 1 − 1/ 2 ergibt Satz 3.3.4 folgende Abschätzung für die Höhe h:
h≤
log(n + 1) − 1
√
= 2(log(n + 1) − 1).
log 2
Da immer h ≥ dlog(n + 1)e − 1 gilt (Lemma 2.1.5), ist ein BB[α]-Baum also
immer höchstens doppelt so hoch wie überhaupt minimal möglich.
Durch das Einfügen oder das Löschen von Knoten kann ein BB[α]-Baum aus
der Balance geraten. Wir werden daher im folgenden Operationen betrachten,
die einen aus der Balance geratenen Baum wieder in einen BB[α]-Baum transformieren. Dies sind die folgenden vier Operationen:
Rotation nach links:
T :
x
ρx
QQ
Q
B
y
ρy
B
aB
% e
B
%
e
B
B
B
B
bB
cB
B B
y
ρ0y
QQ
Q
B
0
ρx
x
B
cB
% e
%
e
B
B
B
B
B
aB
bB
B B
B
B = Teilbaum mit a Knoten
aB
B
ρx
= Wurzelbalance im Knoten x in T
ρ0x
= Wurzelbalance im Knoten x in T 0
T0 :
Rotation nach rechts:
y
ρy
Q
QQ
B
ρx
x
B
cB
SS
B
B
B
B
B
bB
aB
B
B T :
T0 :
x
b
"
b ""
b
B
y
ρ0y
B
aB
% e
%
e
B
B
B
B
B
bB
cB
B B
ρ0x
3.3. GEWICHTSBALANCIERTE BÄUME
101
Doppelrotation nach links:
T :
x
ρx
QQ
Q
B
y
ρy
B
aB
e
B
e
B
ρz z
B
d B
JJ
B
B
B
B
B
bB
cB
B B
z
ρ0z
b
b b
0
ρ0y
ρx
x
y
% e
SS
%
e
B
B
B
B
B
B
B
B
cB
aB
bB
dB
B B
B B
T0 :
Doppelrotation nach rechts:
y
QQ
Q
B
ρx
x
B
dB
,
l
l
,
B
B
z
ρz
B
aB
J
B
J
B
B
B
B
bB
cB
B B
T :
z
ρ0z
b
b b
0
ρ0y
ρx
x
y
% e
SS
%
e
B
B
B
B
B
B
B
B
aB
bB
cB
dB
B B
B B
T0 :
ρy
Bemerkung 3.3.5. (1) Ist T ein Suchbaum für gewisse Schlüssel, so ist auch
T 0 wieder ein Suchbaum für diese Schlüssel.
(2) Eine Doppelrotation nach links ist keine doppelte Rotation nach links! Eine
solche Operation im Knoten x entspricht stattdessen einer Rotation nach rechts
im rechten Kind von x gefolgt von einer Rotation nach links in x.
Lemma 3.3.6. (a) Entsteht T 0 aus T durch Rotation nach links, so gilt
ρ0x =
ρx
ρx + (1 − ρx )ρy
und ρ0y = ρx + (1 − ρx )ρy .
(b) Entsteht T 0 aus T durch Doppelrotation nach links, so gilt
ρ0x =
ρx
,
ρx + (1 − ρx )ρy ρz
ρ0y =
ρy (1 − ρz )
1 − ρy ρz
und ρ0z = ρx + (1 − ρx )ρy ρz .
102
KAPITEL 3. DATENTYPEN ZUR DARSTELLUNG VON MENGEN
Beweis: (a)
T :
x
ρx
QQ
Q
B
y
ρy
B
aB
% e
B
%
e
B
B
B
B
bB
cB
B B
y
ρ0y
QQ
Q
B
ρ0x
x
B
cB
% e
%
e
B
B
B
B
B
bB
aB
B B
T0 :
Mit ρx = (a + 1)/(a + b + c + 3) und ρy = (b + 1)/(b + c + 2) erhalten wir
ρx + (1 − ρx )ρy =
b+c+2
b+1
a+b+2
a+1
+
·
=
= ρ0y ,
a+b+c+3 a+b+c+3 b+c+2
a+b+c+3
also
ρx
a+1
a+b+c+3
a+1
=
·
=
= ρ0x .
ρ0y
a+b+c+3
a+b+2
a+b+2
(b)
T :
x
ρx
Q
QQ
B
y
ρy
B
aB
e
B
e
B
ρz z
B
d B
JJ
B
B
B
B
B
bB
cB
B B
z
ρ0z
b
b b
0
ρ0y
ρx
x
y
% e
SS
%
e
B
B
B
B
B
B
B
B
bB
cB
dB
aB
B B
B B
T0 :
Mit ρx = (a + 1)/(a + b + c + d + 4), ρy = (b + c + 2)/(b + c + d + 3) und
ρz = (b + 1)/(b + c + 2) erhalten wir
ρy ρz =
b+1
b+c+d+3
und (1 − ρx )ρy ρz =
b+1
a+b+c+d+4
also
a+b+2
= ρ0z ,
a+b+c+d+4
ρy (1 − ρz )
c+1
b+c+d+3
c+1
=
·
=
= ρ0y ,
1 − ρy ρz
b+c+d+3
c+d+2
c+d+2
ρx
a+1
a+b+c+d+4
a+1
=
·
=
= ρ0x .
0
ρz
a+b+c+d+4
a+b+2
a+b+2
ρx + (1 − ρx )ρy ρz =
3.3. GEWICHTSBALANCIERTE BÄUME
103
Lemma 3.3.7. (a) Entsteht T 0 aus T durch Rotation nach rechts, so gilt
ρ0x = ρx ρy
und ρ0y =
(1 − ρx )ρy
.
1 − ρx ρy
(b) Entsteht T 0 aus T durch Doppelrotation nach rechts, so gilt
ρ0x =
ρx
,
ρx + (1 − ρx )ρz
ρ0y =
(1 − ρx )ρy (1 − ρz )
,
(1 − ρy ) + (1 − ρx )ρy (1 − ρz )
ρ0z = ρy ρz + ρx ρy (1 − ρz ).
Beweis: als Übung.
Lemma 3.3.8. Sei
T :
x
ρx
Q
QQ
B
y
ρy
B
aB
% e
B
%
e
B
B
B
B
bB
cB
B B
ein Baum, für den jeder echte Teilbaum in BB[α] ist, er selbst aber nicht wegen
α · (1 − α) ≤ ρx < α.
(∗)
Dann entsteht daraus ein BB[α]-Baum
(a) für ρy > (1 − 2α)/(1 − α) durch Doppelrotation nach links,
(b) für ρy ≤ (1 − 2α)/(1 − α) durch Rotation nach links.
Bemerkung: Bedingung (∗) besagt, dass die Wurzelbalance zu klein geworden
ist, entweder dadurch, dass im linken direkten Teilbaum ein Knoten entfernt
wurde, oder dadurch, dass in den rechten direkten Teilbaum ein Knoten eingefügt wurde. Lemma 3.3.8 besagt, dass durch eine Rotation oder Doppelrotation nach links wieder ein BB[α]-Baum entsteht.
Beweis: (a) Es ist ρy = (b + 1)/(b + c + 2) > (1 − 2α)/(1 − α). Die Funktion
√
f (t) = (1−2t)/(1−t) ist im Intervall [0, 1/2] monoton fallend. Mit α ≤ 1−1/ 2
folgt
√
√
√
1 − 2α
1−2+ 2
1
√ =2− 2> ,
= f (α) ≥ f (1 − 1/ 2) =
1−α
2
1 − 1 + 1/ 2
B
also b + 1 > (b + c + 2)/2, was b > c ≥ 0 liefert. Daher ist der Teilbaum bBB
n
z
nicht leer, hat also die Gestalt B
B .
B B
dB eB
104
KAPITEL 3. DATENTYPEN ZUR DARSTELLUNG VON MENGEN
T0 :
x
ρx
z
ρ0z
b
QQ
b Q
b
B
0
ρ0y
y
ρ
x
ρ
y
y
x
B
Doppelrotation
aB
nach links
% e
SS
%
e
B
e
e
B
B
B
B
B
ρz z
B
B
B
B
B
c B
aB
dB
cB
eB
JJ
B
B B
B B
B
B
B
B
dB
eB
B B
T :
,
,
und cB
Nach Voraussetzung sind die Teilbäume aBB dBB eBB
B BB[α]-Bäume.
B
B
B
B
Damit bleibt α ≤ ρ0x , ρ0y , ρ0z ≤ 1 − α zu zeigen. Einerseits gilt
ρx
(nach Lemma 3.3.6(b))
ρx + (1 − ρx )ρy ρz
1 − ρx
= 1/(1 +
· ρy · ρz )
ρx
1 − α 1 − 2α
< 1/(1 +
·
· α) = 1/(2(1 − α)) ≤ 1 − α.
α
1−α
ρ0x =
Die erste Ungleichung gilt, weil (1 − t)/t monoton fallend auf [0, 1] ist und mit
ρx < α, wegen ρy > (1√− 2α)/(1 − α) und wegen ρz ≥ α; die zweite Ungleichung
gilt wegen α ≤ 1 − 1/ 2. Andererseits gilt
1 − ρx
· ρy · ρz )
ρx
1 − α(1 − α)
· (1 − α)2 )
≥ 1/(1 +
α · (1 − α)
α
α
=
=
≥ α.
2
α + (1 − α) − α(1 − α)
1 − α(1 − α)2
ρ0x = 1/(1 +
Hier gilt die Ungleichung, weil (1 − t)/t monoton fallend auf [0, 1] ist und mit
ρx ≥ α(1 − α), wegen ρy ≤ 1 − α und wegen ρz ≤ 1 − α. Analog können die
entsprechenden Aussagen für ρ0y und ρ0z gezeigt werden.
(b)
T :
x
ρx
QQ
Q
B
y
ρy
B
Rotation
nach links
aB
%
e
B
%
e
B
B
B
B
bB
cB
B B
y
ρ0y
QQ
Q
B
0
x ρx
B
cB
% e
%
e
B
B
B
B
B
aB
bB
B B
T0 :
Analog muss α ≤ ρ0x , ρ0y ≤ 1 − α nachgewiesen werden.
3.3. GEWICHTSBALANCIERTE BÄUME
105
Lemma 3.3.8 behandelt den Fall, dass die Balance in einem Knoten zu klein
geworden ist. Im folgenden Lemma betrachten wir den anderen Fall, nämlich
dass die Balance in einem Knoten zu groß geworden ist.
Lemma 3.3.9. Sei
x
ρx
HH
H
B
y
ρy
B
cB
% e
%
e
B
B
B
B
B
aB
bB
B B
T :
ein Baum, für den jeder echte Teilbaum in BB[α] ist, er selbst aber nicht wegen
ρx > 1 − α.
Dann entsteht daraus ein BB[α]-Baum
(a) für ρy > α/(1 − α) durch Rotation nach rechts,
(b) für ρy ≤ α/(1 − α) durch Doppelrotation nach rechts.
Beweis: Analog zum Beweis von Lemma 3.3.8.
Beispiel 3.3.10. Sei α = 0, 29 und damit 1 − α = 0, 71. Aus dem Suchbaum
T0 :
89:;
?>=<
2=
==
=
89:;
?>=<
89:;
?>=<
1
5
====
89:;
?>=<
89:;
?>=<
4
6
entstehe durch Einfügen eines Knotens mit Schlüssel 7 der Suchbaum
T1 :
89:;
?>=<
2
====
89:;
?>=<
89:;
?>=<
1
5=
==
=
89:;
?>=<
89:;
?>=<
4
6=
==
=
89:;
?>=<
7
Der Suchbaum war zuerst in BB[α], durch das Einfügen ist er aber aus der
Balance geraten wegen 2/7 < α:
T0 :
T1 :
i
ρi
ρi
1
1/2
1/2
2
1/3
2/7
4
1/2
1/2
5
1/2
2/5
6
1/2
1/3
7
—
1/2
106
KAPITEL 3. DATENTYPEN ZUR DARSTELLUNG VON MENGEN
Es ist α(1 − α) < 2/7 und ρ5 = 2/5 ≤ (1 − 2α)/(1 − α) (= 0, 42/0, 71 ≈ 0, 59).
Wir wenden nach Lemma 3.3.8(b) auf T1 eine Rotation nach links an und
erhalten einen BB[α]-Baum:
89:;
?>=<
5
====
89:;
?>=<
89:;
?>=<
6=
2=
==
=
=
=
=
89:;
?>=<
89:;
?>=<
89:;
?>=<
1
4
7
i
ρi
1
1/2
2
1/2
4
1/2
5
4/7
6
1/3
7
1/2
Algorithmus 3.3.11. Einfügen in BB[α]-Bäume:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void insert( Comparable key ) {
(1.) Bestimme die Position des einzufügenden Knotens im Baum.
(2.) Füge einen neuen Knoten mit Schlüssel key ein.
(3.) Gehe den Weg von diesem neuen Knoten zurück zur Wurzel;
für jeden Knoten k auf dem Weg:
k.groesseAendern( +1 );
if( ρ(k) < α )
if( ρ(k.rechtesKind( )) > (1 − 2α)/(1 − α) )
Doppelrotation nach links in k;
else
Rotation nach links in k;
if( ρ(k) > 1 − α )
if( ρ(k.linkesKind( )) > α/(1 − α) )
Rotation nach rechts in k;
else
Doppelrotation nach rechts in k;
}
Zeitbedarf von insert: O(Höhe von T ) = O(log |T |) (nach Satz 3.3.4).
Lemma 3.3.12. Sei T ein BB[α]-Suchbaum und sei key ein Schlüssel, der in
T nicht vorkommt. Dann erzeugt insert( key ) aus T einen BB[α]-Suchbaum,
der alle Schlüssel aus T und den Schlüssel key enthält.
Beweis: Sei v0 , v1 , . . . , vk der Weg in T von der Wurzel zu dem Knoten vk , als
dessen Kind der Knoten mit Schlüssel key in Schritt (2.) eingefügt wird, und
sei S der entstehende Baum. Dann ist S ein Suchbaum, der alle Schlüssel aus
T und den Schlüssel key enthält.
Sei Ti der Teilbaum von T mit Wurzel vi , sei Si der Teilbaum von S mit Wurzel
(`)
(r)
vi , und seien Si und Si der linke bzw. rechte direkte Teilbaum von Si . Alle
von S0 , . . . , Sk verschiedenen Teilbäume von S sind Teilbäume von T und damit
BB[α]-Bäume. Gilt nun stets α ≤ ρ(Si ) ≤ 1 − α, so sind alle Teilbäume von S
BB[α]-Bäume, also auch S. Andernfalls sei j maximal mit ρ(Sj ) ∈
/ [α, 1 − α].
insert
3.3. GEWICHTSBALANCIERTE BÄUME
107
Dann ist der neue Knoten offensichtlich in diesem Teilbaum Sj . Angenommen,
(r)
(`)
der neue Knoten ist in Sj . Dann ist Sj ein Teilbaum von T und es gilt
(`)
ρ(Sj ) =
|Sj | + 1
|Sj | + 1
(`)
<
|Sj | + 1
|Sj | − 1 + 1
= ρ(Tj ),
damit
|Sj | + 1
|Sj |
1
1
= (`)
= (`)
+ (`)
ρ(Sj )
|Sj | + 1
|Sj | + 1 |Sj | + 1
1
1
1
1
1
+
=
.
=
≤ +
ρ(Tj ) |S (`) | + 1
α 1−α
α(1 − α)
j
Also ist α(1 − α) ≤ ρ(Sj ) < ρ(Tj ) ≤ 1 − α und wegen ρ(Sj ) ∈
/ [α, 1 − α] gilt
α(1 − α) ≤ ρ(Sj ) < α.
Nach Lemma 3.3.8 liefert eine Rotation oder Doppelrotation nach links einen zu
Sj äquivalenten BB[α]-Suchbaum. Analog zeigt man, dass eine Rotation oder
Doppelrotation nach rechts einen zu Tj äquivalenten BB[α]-Suchbaum liefert,
(`)
wenn der neue Knoten im Teilbaum Sj ist.
Indem man in Schritt (3.) alle Knoten vk , vk−1 , . . . , v0 besucht und die entsprechenden Teilbäume gegebenenfalls rebalanciert, erhält man also einen BB[α]Baum.
Das Löschen eines Knotens aus einem BB[α]-Baum (die Operation delete) geschieht in ähnlicher Weise wie das Einfügen:
(1.) Knoten wird gesucht.
(2.) Knoten wird entfernt.
(3.) Der Weg zurück zur Wurzel wird durchlaufen, wobei die Teilbäume, deren
Wurzeln auf diesem Weg liegen, gegebenenfalls rebalanciert werden.
√
Satz 3.3.13. Werden Suchbäume als BB[α]-Bäume mit 1/4 ≤ α ≤ 1 − 1/ 2
implementiert, so sind die Operationen insert und delete in Zeit O(log n) realisierbar.
Bemerkung 3.3.14. Wird ein kleines solches α gewählt, dann ist das Intervall
[α, 1 − α] relativ groß, und somit sind weniger Rebalancierungen nötig. Dafür
ist nach Satz 3.3.4 die Höhe der BB[α]-Bäume aber auch größer. Also ist eine
solche Wahl günstig, wenn viele Änderungen und relativ wenige Suchoperationen auszuführen sind. Andernfalls ist es günstiger, α möglichst groß zu wählen,
wodurch die Höhe der BB[α]-Bäume stärker beschränkt wird.
108
KAPITEL 3. DATENTYPEN ZUR DARSTELLUNG VON MENGEN
Programm 3.3.15. Eine Implementierung der BB[α]-Bäume:
Hier verwenden wir die Klassen Stack und Queue, um den Weg, der beim Suchen nach einem Knoten zurückgelegt wird, für das Rebalancieren zu speichern.
Beim Einfügen besteht dieser Weg aus nur einem Stück, das in einem Stack gespeichert wird. Beim Löschen kommt noch ein zweites Stück hinzu, nämlich
das vom zu löschenden Knoten zum Knoten mit maximalem Inhalt im linken
direkten Teilbaum; hierfür verwenden wir eine Queue.
In jedem Baumknoten wird zusätzlich zum Inhalt noch die aktuelle Größe des
in diesem Knoten wurzelnden Teilbaums gespeichert, siehe Bemerkung 3.3.2.
1
2
3
4
5
6
/** Die Klasse BBAlphaKnoten implementiert Knoten eines binaeren Baums.
* Jeder Knoten speichert ein beliebiges Objekt vom Typ Object,
* je eine Referenz auf das linke und das rechte Kind
* und die Groesse des hier wurzelnden Teilbaums.
*/
class BBAlphaKnoten {
7
8
9
10
11
/** Die Konstanten aus Definition 3.3.1, Lemma 3.3.8 und Lemma 3.3.9. */
public static final double ALPHA = 0.275;
public static final double ALPHA LEFT = (1−2*ALPHA)/(1−ALPHA);
public static final double ALPHA RIGHT = ALPHA/(1−ALPHA);
12
13
14
/** Der Knoteninhalt. */
private Object inhalt;
15
16
17
18
/** Das linke und das rechte Kind. */
private BBAlphaKnoten linkesKind;
private BBAlphaKnoten rechtesKind;
19
20
21
/** Die Groesse des hier wurzelnden Baums. */
private int groesse;
22
23
24
25
26
27
/** Konstruiert einen Blattknoten mit Inhalt inhalt. */
public BBAlphaKnoten( Object inhalt ) {
this.inhalt = inhalt;
groesse = 1;
}
BBAlphaKnoten
die Methoden inhalt( ), linkesKind( ) und rechtesKind( ) analog zu Programm 2.2.4
44
45
46
47
/** Gibt die Groesse des hier wurzelnden Baums zurueck. */
public double groesse( ) {
return groesse;
}
groesse
die Methoden inhaltAendern( ), linkesKindAendern( ) und rechtesKindAendern( ) analog
zu Programm 2.2.4
64
65
66
67
68
/** Die Groesse wird um i inkrementiert. Gibt den neuen Wert zurueck. */
public int groesseAendern( int i ) {
return groesse += i;
}
groesseAendern
3.3. GEWICHTSBALANCIERTE BÄUME
69
70
71
72
73
/** Gibt die Hoehenbalance des hier wurzelnden Teilbaums zurueck. */
public double balance( ) {
return ( ( linkesKind == null ? 0 : linkesKind.groesse ) + 1 ) /
(double)( groesse + 1 );
}
109
balance
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
/** Rotation des hier wurzelnden Baums nach links.
* Wir setzen voraus, dass die Rotation moeglich ist.
* Gibt die neue Wurzel zurueck.
*/
public BBAlphaKnoten rotationLinks( ) {
BBAlphaKnoten x = this;
BBAlphaKnoten y = x.rechtesKind; // existiert nach Annahme
int a = x.linkesKind == null ? 0 : x.linkesKind.groesse;
int b = y.linkesKind == null ? 0 : y.linkesKind.groesse;
int c = y.rechtesKind == null ? 0 : y.rechtesKind.groesse;
x.groesseAendern( −c−1 );
y.groesseAendern( a+1 );
x.rechtesKindAendern( y.linkesKind );
y.linkesKindAendern( x );
return y;
}
rotationLinks
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
/** Rotation des hier wurzelnden Baums nach rechts.
* Wir setzen voraus, dass die Rotation moeglich ist.
* Gibt die neue Wurzel zurueck.
*/
public BBAlphaKnoten rotationRechts( ) {
BBAlphaKnoten y = this;
BBAlphaKnoten x = y.linkesKind; // existiert nach Annahme
int a = x.linkesKind == null ? 0 : x.linkesKind.groesse;
int b = x.rechtesKind == null ? 0 : x.rechtesKind.groesse;
int c = y.rechtesKind == null ? 0 : y.rechtesKind.groesse;
x.groesseAendern( c+1 );
y.groesseAendern( −a−1 );
y.linkesKindAendern( x.rechtesKind );
x.rechtesKindAendern( y );
return x;
}
rotationRechts
108
109
110
111
112
113
114
115
116
117
118
119
120
121
/** Doppelrotation des hier wurzelnden Baums nach links.
* Wir setzen voraus, dass die Rotation moeglich ist.
* Gibt die neue Wurzel zurueck.
*/
public BBAlphaKnoten doppelRotationLinks( ) {
BBAlphaKnoten x = this;
BBAlphaKnoten y = x.rechtesKind; // existiert nach Annahme
BBAlphaKnoten z = y.linkesKind; // existiert nach Annahme
int a = x.linkesKind == null ? 0 : x.linkesKind.groesse;
int b = z.linkesKind == null ? 0 : z.linkesKind.groesse;
int c = z.rechtesKind == null ? 0 : z.rechtesKind.groesse;
int d = y.rechtesKind == null ? 0 : y.rechtesKind.groesse;
x.groesseAendern( −c−d−2 );
doppelRotationLinks
110
y.groesseAendern( −b−1 );
z.groesseAendern( a+d+2 );
x.rechtesKindAendern( z.linkesKind );
y.linkesKindAendern( z.rechtesKind );
z.linkesKindAendern( x );
z.rechtesKindAendern( y );
return z;
122
123
124
125
126
127
128
129
KAPITEL 3. DATENTYPEN ZUR DARSTELLUNG VON MENGEN
}
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
/** Doppelrotation des hier wurzelnden Baums nach rechts.
* Wir setzen voraus, dass die Rotation moeglich ist.
* Gibt die neue Wurzel zurueck.
*/
public BBAlphaKnoten doppelRotationRechts( ) {
BBAlphaKnoten y = this;
BBAlphaKnoten x = y.linkesKind; // existiert nach Annahme
BBAlphaKnoten z = x.rechtesKind; // existiert nach Annahme
int a = x.linkesKind == null ? 0 : x.linkesKind.groesse;
int b = z.linkesKind == null ? 0 : z.linkesKind.groesse;
int c = z.rechtesKind == null ? 0 : z.rechtesKind.groesse;
int d = y.rechtesKind == null ? 0 : y.rechtesKind.groesse;
x.groesseAendern( −c−1 );
y.groesseAendern( −a−b−2 );
z.groesseAendern( a+d+2 );
x.rechtesKindAendern( z.linkesKind );
y.linkesKindAendern( z.rechtesKind );
z.linkesKindAendern( x );
z.rechtesKindAendern( y );
return z;
}
doppelRotationRechts
die Methoden toString( ) und druckeZwischenordnung( ) wie in Programm 2.2.4, die Methode druckeVorordnung( ) wie in Programm 3.2.5
180
} // class BBAlphaKnoten
181
182
183
184
/** Die Klasse BBAlphaBaum implementiert BB[alpha]-Baeume. */
public class BBAlphaBaum {
185
186
187
/** Der Wurzelknoten. */
private BBAlphaKnoten wurzel;
188
189
190
191
192
193
194
/** Konstruiert einen Baum, der nur einen Wurzelknoten
* mit Schluessel key besitzt.
*/
public BBAlphaBaum( Object key ) {
wurzel = new BBAlphaKnoten( key );
}
BBAlphaBaum
195
196
197
198
/** Konstruiert den leeren Baum. */
public BBAlphaBaum( ) { }
BBAlphaBaum
3.3. GEWICHTSBALANCIERTE BÄUME
199
200
201
202
/** Gibt den Wurzelknoten zurueck. */
public BBAlphaKnoten wurzel( ) {
return wurzel;
}
111
wurzel
203
204
205
206
207
/** Test, ob der Baum leer ist. */
boolean istLeer( ) {
return wurzel == null;
}
istLeer
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
/** Gibt den Pfad vom linken direkten Kind von k zum Knoten mit
* maximalem Schluessel im linken direkten Teilbaum von k zurueck.
* Wir setzen voraus, dass dieser linke Teilbaum nicht leer ist.
* Dabei sei der Weg als Queue analog zu der fuer die Methode
* searchKey beschriebenen Weise repraesentiert.
*/
private Queue pathToMaximum( BBAlphaKnoten k ) {
Queue path = new Queue( );
BBAlphaKnoten node = k.linkesKind( ); // existiert nach Voraussetzung
path.enqueue( node );
path.enqueue( new Integer( +1 ) );
while( node.rechtesKind( ) != null ) {
node = node.rechtesKind( );
path.enqueue( node );
path.enqueue( new Integer( +1 ) );
}
return path;
}
pathToMaximum
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
/** Gibt einen Stack path mit 2n Elementen (n >= 0) zurueck,
* der einen Pfad im Baum auf folgende Weise repraesentiert.
* (Das Stack-Element an Position i, bezeichnet mit path[i], sei dasjenige,
* das durch i-maliges pop( ) gefolgt von einem top( ) erreichbar ist.)
* Der leere Stack repraesentiert den leeren Pfad. Ist der Stack nicht leer, so gilt:
* (1) path[2n-1] ist die Wurzel des Baums.
* (2) Fuer 0 <= i < n: path[2i] hat Typ Integer, path[2i+1] Typ BBAlphaKnoten.
* (3) Fuer 0 < i < n gilt path[2i] != 0, und wenn path[2i] < 0 gilt, dann ist
*
path[2i-1] linkes Kind von path[2i+1], sonst rechtes Kind von path[2i+1].
* (4) path[0] == 0: key ist (aequivalent zum) Schluessel in Knoten path[1].
* (5) path[0] < 0: key kommt nicht als Schluessel (bzgl. Aequivalenz) vor;
*
die korrekte Position fuer das Blatt mit Schluessel key
*
ist das (noch nicht vorhandene) linke Kind von path[1].
* (6) path[0] > 0: analog mit rechtem Kind von path[1].
*/
private Stack searchKey( Comparable key ) {
searchKey
Stack path = new Stack( );
if( istLeer( ) )
return path;
BBAlphaKnoten node = wurzel;
do {
int vergleich = key.compareTo( node.inhalt( ) );
if( vergleich == 0 ) {
path.push( node );
112
path.push( new Integer( 0 ) );
return path;
252
253
}
if( vergleich < 0 ) {
path.push( node );
path.push( new Integer( −1 ) );
node = node.linkesKind( );
}
else { // vergleich > 0
path.push( node );
path.push( new Integer( +1 ) );
node = node.rechtesKind( );
}
254
255
256
257
258
259
260
261
262
263
264
}
while( node != null );
return path;
265
266
267
268
KAPITEL 3. DATENTYPEN ZUR DARSTELLUNG VON MENGEN
}
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
/** Entlang des im Stack gespeicherten Weges wird der Baum rebalanciert.
* Dabei sei der Weg im Stack auf die bei der Methode searchKey beschriebene
* Weise repraesentiert.
* Die Groesse der Knoten aendert sich um i. (Beim Rebalancieren nach
* insert ist daher i == +1, beim Rebalancieren nach delete ist i == -1.)
*/
private void rebalance( Stack path, int i ) {
rebalance
if( !path.isEmpty( ) )
path.pop( ); // diese Zahl ist fuer das Rebalancieren nicht relevant
while( !path.isEmpty( ) ) { // path enthaelt noch 2i+1 > 0 Eintraege
BBAlphaKnoten k = (BBAlphaKnoten)path.topAndPop( );
BBAlphaKnoten wurzelDesTeilbaums = k;
k.groesseAendern( i );
if( k.balance( ) < BBAlphaKnoten.ALPHA ) // Balance in k ist zu klein
if( k.rechtesKind( ).balance( ) > BBAlphaKnoten.ALPHA LEFT )
wurzelDesTeilbaums = k.doppelRotationLinks( );
else
wurzelDesTeilbaums = k.rotationLinks( );
else if( k.balance( ) > 1−BBAlphaKnoten.ALPHA ) // Balance in k ist zu gross
if( k.linkesKind( ).balance( ) > BBAlphaKnoten.ALPHA RIGHT )
wurzelDesTeilbaums = k.rotationRechts( );
else
wurzelDesTeilbaums = k.doppelRotationRechts( );
if( !path.isEmpty( ) ) {
int direction = ( (Integer)path.topAndPop( ) ).intValue( );
BBAlphaKnoten parent = (BBAlphaKnoten)path.top( );
if( direction < 0 )
parent.linkesKindAendern( wurzelDesTeilbaums );
else // direction > 0
parent.rechtesKindAendern( wurzelDesTeilbaums );
}
else
wurzel = wurzelDesTeilbaums;
}
}
3.3. GEWICHTSBALANCIERTE BÄUME
306
307
308
309
310
311
312
313
314
315
316
317
113
/** Gibt einen Knoten mit einem zu key aequivalenten Schluessel
* zurueck, falls ein solcher Knoten vorhanden ist, sonst null.
*/
public BBAlphaKnoten isMember( Comparable key ) {
Stack path = searchKey( key );
if( path.isEmpty( ) | | ( (Integer)path.top( ) ).intValue( ) != 0 )
return null;
else { // im Stack steht oben 0, also ist darunter der gesuchte Knoten
path.pop( );
return (BBAlphaKnoten)path.top( );
}
}
isMember
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
/** Fuegt einen Knoten mit Schluessel key in den BB[alpha]-Baum ein. */
public void insert( Comparable key ) {
Stack path = searchKey( key );
if( path.isEmpty( ) ) { // Baum ist leer
wurzel = new BBAlphaKnoten( key );
return;
}
int vergleich = ( (Integer)path.topAndPop( ) ).intValue( );
if( vergleich == 0 ) // nicht zu tun
return;
if( vergleich < 0 )
( (BBAlphaKnoten)path.top( ) ).
linkesKindAendern( new BBAlphaKnoten( key ) );
else // vergleich > 0
( (BBAlphaKnoten)path.top( ) ).
rechtesKindAendern( new BBAlphaKnoten( key ) );
( (BBAlphaKnoten)path.topAndPop( ) ).groesseAendern( +1 );
rebalance( path, +1 );
}
insert
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
/** Entfernt den Knoten mit zu key aequivalentem Schluessel, falls vorhanden.
*/
public void delete( Comparable key ) {
Stack path = searchKey( key );
if( !path.isEmpty( ) && ( (Integer)path.topAndPop( ) ).intValue( ) == 0 ) {
BBAlphaKnoten k = (BBAlphaKnoten)path.topAndPop( );
// k hat Schluessel key
if( k.linkesKind( ) == null | | k.rechtesKind( ) == null ) { // Fall 1
BBAlphaKnoten einzigesKind =
k.linkesKind( ) == null ? k.rechtesKind( ) : k.linkesKind( );
if( path.isEmpty( ) ) // k ist Wurzel
wurzel = einzigesKind;
else {
if( ( (Integer)path.topAndPop( ) ).intValue( ) < 0 )
( (BBAlphaKnoten)path.top( ) ).linkesKindAendern( einzigesKind );
else
( (BBAlphaKnoten)path.top( ) ).rechtesKindAendern( einzigesKind );
path.push( new Integer( 0 ) ); // dieser Wert ist nicht relevant
rebalance( path, −1 );
}
}
delete
114
KAPITEL 3. DATENTYPEN ZUR DARSTELLUNG VON MENGEN
else { // Fall 2
Queue pathToMaximum = pathToMaximum( k );
// den Pfad pathToMaximum an den Pfad path haengen:
path.push( k );
path.push( new Integer( −1 ) );
while( !pathToMaximum.isEmpty( ) ) {
// zuerst push fuer Typ BBAlphaKnoten, dann fuer Typ Integer
path.push( pathToMaximum.frontAndDequeue( ) );
path.push( pathToMaximum.frontAndDequeue( ) );
}
path.pop( ); // nicht relevante Zahl entfernen
BBAlphaKnoten maximum = (BBAlphaKnoten)path.topAndPop( );
// maximum ist der Knoten mit maximalem Schluessel
// im linken direkten Teilbaum von k
path.pop( ); // nicht relevante Zahl entfernen
BBAlphaKnoten predMax = (BBAlphaKnoten)path.top( );
// predMax ist der Elternknoten von maximum
k.inhaltAendern( maximum.inhalt( ) );
if( predMax == k )
k.linkesKindAendern( maximum.linkesKind( ) );
else
predMax.rechtesKindAendern( maximum.linkesKind( ) );
path.push( new Integer( 0 ) ); // dieser Wert ist nicht relevant
rebalance( path, −1 );
}
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
}
385
386
}
die Methoden druckeZwischenordnung( ) und druckeVorordnung( ) wie in Programm 3.2.5
412
413
414
415
416
417
418
419
420
421
422
423
public static void main( String[ ] args ) {
BBAlphaBaum b = new BBAlphaBaum( );
String[ ] monat = { "JAN", "FEB", "APR", "AUG", "DEZ", "MAR",
"MAI", "JUN", "JUL", "SEP", "OKT", "NOV" };
for( int i = 0; i < 12; i++ ) {
b.insert( monat[i] );
b.druckeVorordnung( );
}
b.delete( "FEB" ); b.druckeVorordnung( );
b.delete( "JAN" ); b.druckeVorordnung( );
b.delete( "MAR" ); b.druckeVorordnung( );
}
424
425
} // class BBAlphaBaum
3.4
Höhenbalancierte Bäume
Es gibt viele Arten höhenbalancierter Bäume, etwa (a, b)-Bäume, B-Bäume,
rot-schwarze Bäume und AVL-Bäume. Wir wollen uns hier zunächst mit den
letzteren befassen. Im Anschluss schauen wir uns noch die (2,4)-Bäume an.
main
3.4. HÖHENBALANCIERTE BÄUME
3.4.1
115
AVL-Bäume
Die Höhe eines nicht-leeren Baums T , ab jetzt mit h(T ) bezeichnet, wurde in
Abschnitt 2.1 definiert. Zur Vereinfachung der folgenden Definition legen wir
zusätzlich die Höhe des leeren Baums mit −1 fest.
Definition 3.4.1. Die Höhenbalance eines binären Baums mit direkten linken
bzw. rechten Teilbäumen T` und Tr ist der Wert
h(T` ) − h(Tr ).
Ein Baum heißt höhenbalanciert, falls jeder seiner Teilbäume eine Höhenbalance
aus {−1, 0, 1} hat. Höhenbalancierte Suchbäume werden AVL-Bäume genannt
nach ihren Erfindern Adelśon-Velśkiı̌ und Landis (1962).
Beispiel 3.4.2. Die Höhenbalance der Teilbäume steht hier an ihrer Wurzel:
ggg Juli 0 XXXXXXXXX
ggggg
März 0PP
Febr 1OO
O
P
nn
nnn
Aug 0 O
Jan 0
Mai 1
Okt 0O
OO
O
pp
ooo
ooo
Apr 0
Sept 0
Dez 0
Juni 0
Nov 0
ein AVL-Baum
Apr 0
pp
Juli 1 WWWW
WWWW
hhhh
hhhh
März −1P
Jan 2
PP
NN
oo
nnnn
Mai 0
Febr 1 QQ
Juli 0
Okt 0 PP
Q
nn
lll
Aug 1
Sept 0
Dez 0
Nov 0
kein AVL-Baum
Lemma 3.4.3. Für AVL-Bäume mit n Knoten und Höhe h gilt
F (h + 3) − 1 ≤ n ≤ 2h+1 − 1.
Dabei ist F (k) die k-te Fibonacci1 -Zahl, definiert durch F (0) = 0, F (1) = 1
und F (k + 2) = F (k + 1) + F (k).
Beweis: Für binäre Bäume gilt n ≤ 2h+1 −1 nach Lemma 2.1.5(b). Wir müssen
also nur noch die untere Schranke für n nachweisen. Für m > 0 sei dazu Fm ein
AVL-Baum mit Höhe m, der die minimal notwendige Anzahl von Knoten hat2 .
Dann ist h(F` ) = m−1 und h(Fr ) = m−2 oder h(F` ) = m−2 und h(Fr ) = m−1.
1
2
Leonardo Pisano Fibonacci (1170–1250)
Solche Bäume werden auch Fibonacci-Bäume genannt.
116
KAPITEL 3. DATENTYPEN ZUR DARSTELLUNG VON MENGEN
Also gilt |Fm | = 1 + |Fm−1 | + |Fm−2 |, und wir haben |F0 | = 1 und |F1 | = 2. Also
|F0 | + 1 = 2, |F1 | + 1 = 3 und |Fm | + 1 = (|Fm−1 | + 1) + (|Fm−2 | + 1), damit
|Fm | + 1 = F (m + 3).
Wir erhalten n ≥ |Fh | = F (h + 3) − 1.
Für Fibonacci-Zahlen weiß man
1 F (k) = √ φk − φbk
5
√
√
mit φ = (1 + 5)/2√≈ 1, 618 (der goldene Schnitt)
und φb = (1 − 5)/2 ≈
√
−0, 618. Wegen |φbk / 5| < 1/2 gilt F (k) > φk / 5 − 1, also mit obigem Lemma
√
n + 1 ≥ F (h + 3) > φh+3 / 5 − 1.
√
Das liefert log(n + 2) > (h + 3) log φ − log 5, d.h.
√
log(n + 2) + log 5
1
h<
−3<
log(n + 2) ≈ 1, 44 · log(n + 2).
log φ
log φ
Satz 3.4.4. Für AVL-Bäume mit n Knoten und Höhe h gilt
log(n + 1) − 1 ≤ h < 1, 441 · log(n + 2).
AVL-Bäume sind also höchstens um circa 44% höher als minimal möglich. Insbesondere sind Suchen, Einfügen und Löschen in Zeit O(log n) realisierbar.
Leider kann sowohl beim Einfügen als auch beim Löschen die Höhenbalanciertheit zerstört werden. Wie bei den BB[α]-Bäumen müssen wir in einem solchen
Fall durch Rebalancierungsoperationen versuchen, den entstandenen Baum in
einen äquivalenten umzuformen, der wieder höhenbalanciert ist.
Einfügen in AVL-Bäume mit anschließender Rebalancierung:
Operation LL:
1
x
h(x) = h + 2
"
Q
Q
"
B h3 = h
h(y) = h + 1 y 0
B3B
% S
S
B
%
B
B
B2B
B B
1 B B h1 = h2 = h
Einfügen in B1
2
x
h0 (x) = h + 3
0
y
h00 (y) = h + 2
bb
"
Q
"
Q
Rotation LL b
00
B
B
x h (x) = h + 1
0
h0 (y) = h + 2 y 1
h
=
h
3
B B
0B
S
% e
%
S
3B
e
h01 = h + 1 B1 B
B
B h3 = h
B h = h
h01 = h + 1 B
h
=
h
2
B 2
B B
B B
0B
B
2 B 3B
B1 B 2 B
3.4. HÖHENBALANCIERTE BÄUME
117
Operation RR:
−1
h(x) = h + 2
x
bb
"
""
b
h1 = h B
0 y h(y) = h + 1
B
% e
%
e
B1 B
B h2 = h3 = h
B
B B
B B
2 B 3B
Einfügen in B3
−2
h0 (x) = h + 3
x
0
h00 (y) = h + 2
y
bb
"
"
Q
Rotation RR
""
""
Q
b
00
0
h1 = h B
B h03 = h + 1
h (x)
x 0
−1 y h (y)
B 0B
B B
= h + 1 =h+2
S
S
S
S
3B
1B
B
B
B
B
h2 = h B B
0B h03 = h + 1 B B
B B
2
B
2
B 3B
1B B
h1 = h
Operation LR(i):
1
x
h(x) = 1
"
""
Einfügen
0
y
von z
h(y) = 0
2
0
x
h (x) = 2
0
z
h00 (z) = 1
"
"
Q
""
""
Q
Rotation LR(i)
-1
y
h0 (y) = 1
Q
Q
0
Operation LR(ii):
h2 = h
z
0
y
00
h (y) = 0
x
0
00
h (x) = 0
h0 (z) = 0
1
x
h(x) = h + 2
"
Q
""
Q
h(y)
B h4 = h
y 0
= h + 1 B B
, l
l
,
4B
B
0 z h(z) = h
B1B
B J
J
h1 = h B
B
B B
B B
2B 3B
h2 = h3 = h − 1
Einfügen in B2
2
0
x
h (x) = h + 3
00
0
z
h (z) = h + 2
"
Q
Rotation LR(ii) bbb
""
Q
h00 (x)
h0 (y)
B h4 = h
h00 (y)
y 0
-1 x = h + 1
y -1
= h + 2 B B
= h + 1 , l
S
% e
l
%
,
4B
S
e
B
B
B
B
B
1 z h0 (z) = h + 1
B1B
B1B
B B
B4B
0B
3
B J
J
B B2 B
B B
h3
h4 = h
h1 = h B
h1 = h 0
B
h2 = h
=h−1
B B
B 0 B
2B 3B
h02 = h
h3 = h − 1
118
KAPITEL 3. DATENTYPEN ZUR DARSTELLUNG VON MENGEN
Operation LR(iii):
1
x
h(x) = h + 2
"
Q
""
Q
h(y)
B h4 = h
y 0
= h + 1 B B
, l
l
,
4B
B
0 z h(z) = h
B1B
B J
J
h1 = h B
B
B B
B B
2B 3B
h2 = h3 = h − 1
Einfügen in B3
2
x
00
h0 (x) = h + 3
0
h (z) = h + 2
z
"
Q
Rotation LR(iii) bbb
""
Q
h00 (x)
h0 (y)
B h4 = h
h00 (y)
y 1
0 x =h+1
y -1
= h + 2 B B
= h + 1 , l
S
% e
l
%
,
4B
S
e
B
B
B
B
B
0
-1 z
h (z) = h + 1
B B
B 0 B
B1B
B
B B
2
B
3B 4B
B J
J
1B B
h4 = h
h1 = h B
h1 = h h2
B
0
= h − 1 h3 = h
B B
B 0 B
2B 3B
h2
0
= h − 1 h3 = h
Operation RL(i):
-1
0
x h(x) = 1
bb
b Einfügen
0
y
von z
-2
x
h0 (z) = 0
0
z
h (z) = 1
bb Rotation RL(i)""
Q
b
"
Q
h0 (y) = 1
h(y) = 0
00
h (x) = 2
y
1
0
x
h00 (x) = 0
z
y
0
h00 (y) = 0
0
RL(ii) und RL(iii) analog zu LR(ii) und LR(iii).
Bemerkung 3.4.5. (1) Durch solche Rebalancierungen entstehen aus Suchbäumen stets wieder Suchbäume.
(2) Wird in einen AVL-Baum T ein Knoten eingefügt, so ist der resultierende
Baum T 0 entweder wieder ein AVL-Baum, oder der kleinste Teilbaum T̂ von
T 0 , der den neu eingefügten Knoten enthält und der selbst kein AVL-Baum
mehr ist, hat eine der Formen, wie sie in obiger Definition jeweils nach einer
Einfüge-Operation auftreten. Insbesondere ist die Wurzel von T̂ der unterste
Knoten auf dem Weg von der Wurzel von T 0 zu dem neu eingefügten Knoten,
der Höhenbalance ±2 hat.
3.4. HÖHENBALANCIERTE BÄUME
119
(3) Wird ein Teilbaum T̂ rebalanciert, so entsteht ein Teilbaum T̂ 0 , der dieselbe
Höhe hat wie der Teilbaum T̂ vor dem Einfügen des neuen Knotens:
T0 :
T 00 :
S
S
S
;
;
T S
S Rebalancieren TS
Einfügen
T S
S
TS
T̂ T S
T S
T̂ 0 T S
0 T S
T̂ TT S
T S
T
T :
Also ist T 00 ein AVL-Baum, d.h. zum Rebalancieren reicht stets eine einzige
Rotation des entsprechenden Typs aus.
Beispiel 3.4.6.
Neuer Schlüssel
Baum nach Einfügen
(1.) Mai
Baum nach Rebalancieren
Mai 0
—
Mai −1R
R
(2.) März
—
März 0
Mai −2 SS
S
März −1R
R
(3.) November
RR:
0 Mai
0 MärzQ
Q
mm
Nov 0
Nov 0
(4.) August
0 Aug
1 Mai
oo
(5.) April
0 Apr
pp
1 Aug
2 Mai
oo
1 MärzQ
Q
mm
2 MärzQ
Q
mm
—
Nov 0
Nov 0
LL:
1 MärzPP
P
nn
0 Aug P
Nov 0
PP
pp
0 Apr
Mai 0
(6.) Januar
2 MärzPP
P
ll
−1 AugQ
Nov 0
QQ
nn
0 Apr
Mai 1
lll
0 Jan
LR:
0 Mai RR
R
oo
0 Aug N
−1 MärzQ
QQ
NN
pp
0 Apr
Jan 0
Nov 0
120
KAPITEL 3. DATENTYPEN ZUR DARSTELLUNG VON MENGEN
(7.) Dezember
1 Mai RR
R
mm
−1 AugP
März −1Q
QQ
PP
nn
0 Apr
Jan 1
Nov 0
mmm
0 Dez
(8.) Juli
0 Apr
(9.) Februar
0 Apr
1 Mai RR
R
März −1Q
QQ
Jan 0 RR
Nov 0
R
mmm
0 Dez
Juli 0
mm
−1 AugP
PP
nn
—
—
2 Mai RR
R
März −1Q
QQ
Jan
1
Nov 0
RRR
mmm
−1 DezR
Juli 0
R
Febr 0
mm
−2 AugQ
QQ
nn
RL:
1 Mai RR
nn
März −1Q
0 Dez OO
QQ
O
oo
1 Aug
Jan 0 QQ
Nov 0
Q
nn
ooo
0 April
0 Febr
Juli 0
(10.) Juni
2 Mai SSS
ll
−1 DezQQ
März −1R
RR
Q
nn
1 Aug
Jan −1 R
Nov 0
RR
nn
mmm
0 April
Juli −1 RR
0 Febr
R
Juni 0
ffff 0 Jan XXXXXXXXXX
fffff
1 Dez PP
Mai 0 RR
R
P
oo
mmm
1 Aug
März −1Q
−1 JuliP
Febr 0
PP
QQ
nn
0 April
Juni 0
Nov 0
LR:
−1 Jan XXXXX
ffff
XXXXX
fffff
Mai −1 R
1 Dez OO
RR
O
oo
lll
1 Aug
März −2R
−1 JuliQ
Febr 0
RR
QQ
nn
0 April
Nov −1QQ
Juni 0
Q
(11.) Oktober
Okt 0
3.4. HÖHENBALANCIERTE BÄUME
121
ffff 0 Jan XXXXXXXXXX
ffffff
1 Dez PP
ffff Mai 0 PPP
P
oo
fffff
1 Aug
Nov 0NN
Febr 0 −1 JuliPP
N
P
nn
ooo
0 April
Juni 0 0 März
Okt 0
RR:
(12.) September
−1 Jan XXXX
XXXXX
ffff
fffff
Mai −1RR
1 Dez OO
ffff
R
O
oo
fffff
1 Aug
Nov −1Q
Febr 0 −1 JuliPP
QQ
P
nn
mmm
0 April
Okt −1P
Juni 0 0 März
P
Sept 0
Gerät ein AVL-Baum T durch das Löschen eines Knotens k aus der Balance,
so müssen durch eventuell mehrere Rotationen die Teilbäume, deren Wurzeln
auf dem Weg von der Wurzel von T zum Knoten k liegen, rebalanciert werden.
Dabei geht man ausgehend vom Elternknoten von k zurück zur Wurzel von T .
Beispiel 3.4.7. Im Baum
30 ```
``` `
1
1
20
40 b
!
!
bb
!!
1 10
45 1
25 1
1 35
"
Q
"
"
22 -1
26 0
32 1 0 38
42 0
"
1 15
1 5
S
S
31 0
0 12
S
0 23
7 0
-1 1
J
2 0
T :
7654
ergibt das Löschen von 0123
42 den Baum
1
30 ```
``` `
1
2
20
40 a
!
!
aa
""
!!
1 10
25 1
45 0
1 35
"
Q
"
"
22 -1
26 0
32 1 0 38
1 15
"
1 5
S
S
# 0 12
31 0
#
#
0 23
7 0
-1 1
J
2 0
1
122
KAPITEL 3. DATENTYPEN ZUR DARSTELLUNG VON MENGEN
0123
Rotation nach rechts im Knoten 7654
40 ergibt:
30 ```
``` `
1
0
20
35 H
!
!
"
HH
"
!!
1 10
25 1
40 0
1 32
"
Q
Q
"
"
22 -1
26 0
1 15
0 31
0 38
0 45
"
1 5
S
S
, 0 12
,
0 23
,
7
0
1
-1
J
2 0
2
7654
Erst eine Rotation nach rechts im Knoten 0123
30 ergibt einen AVL-Baum:
0 20
aaa
!!
0
!!
30 `
```
1 10
```
,
"
0
"
25
35 H
1
"
1 15 "
Q
HH
1 5
0
22
26
40 0
-1
1 32
0 12
Q
L
S
S
0 38
0 45
7 0
0 31
-1 1
0 23
J
2 0
Bemerkung 3.4.8. Bei der Implementierung speichern wir in jedem Knoten
die Höhenbalance des dort wurzelnden Teilbaums (statt dessen Höhe). Die
Rebalancierungsoperationen geben direkt an, wie sich diese Werte ändern.
Satz 3.4.9. Werden Suchbäume durch AVL-Bäume implementiert, so ist Einfügen, Suchen und Löschen in Zeit O(log n) realisierbar.
3.4.2
(2,4)-Bäume
Als zweite Variante der höhenbalancierten Bäume betrachten wir noch die (2,4)Bäume. Die folgenden Begriffe können ganz allgemein für Zahlenpaare (a, b) mit
a ≥ 2 und b ≥ 2a − 1 formuliert werden, was dann die (a, b)-Bäume ergibt. Aus
Gründen der Übersichtlichkeit beschränken wir uns hier aber auf den Spezialfall
(2,4). Bei diesen Bäumen wird alle zu speichernde Information in den Blättern
untergebracht, d.h. für n Schlüssel brauchen wir einen Baum mit n Blättern
und einigen inneren Knoten, die der Verwaltung der Information dienen.
3.4. HÖHENBALANCIERTE BÄUME
123
Definition 3.4.10. Ein nicht-leerer geordneter Baum ist ein (2,4)-Baum, wenn
alle Blätter dieselbe Tiefe haben und wenn für alle inneren Knoten k gilt
2 ≤ d(k) ≤ 4.
Wie wird nun eine Schlüsselmenge S = {x1 , . . . , xn } mit x1 < · · · < xn in einem
(2,4)-Baum gespeichert? Dazu brauchen wir einen (2,4)-Baum mit n Blättern,
in denen von links nach rechts die Werte x1 , . . . , xn gespeichert werden. Ist nun
k ein innerer Knoten dieses (2,4)-Baums mit d(k) = r ∈ {2, 3, 4}, so wird in
k eine Folge von r − 1 Elementen aus dem Universum, aus dem S stammt,
gespeichert. Ist diese Folge gerade y1 < · · · < yr−1 , so gilt für alle Blätter im
i-ten direkten Teilbaum unterhalb von k
Inhalt des Blattes ≤ y1
für i = 1,
yi−1 < Inhalt des Blattes ≤ yi
für 1 < i < r,
yr−1 < Inhalt des Blattes
für i = r.
Beispiel 3.4.11. Ein (2,4)-Baum für S = {1, 3, 6, 8, 9, 10} ⊆ N:
4 X
XXX
XX
X
2
7, 8, 9
b
"
bb
@
T
""
@
1
3
6
8
9
10
Das Suchen in einem (2,4)-Baum ist recht einfach. Anhand der in einem inneren
Knoten k gespeicherten Werte y1 < · · · < yd(k)−1 kann man feststellen, in
welchem direkten Teilbaum unterhalb von k die Suche fortgesetzt werden muss.
Wegen d(k) ≤ 4 kostet das Suchen nur O(Höhe von T ) Schritte.
Lemma 3.4.12. Für einen (2,4)-Baum mit n Blättern und Höhe h gilt
2h ≤ n ≤ 4h ,
also
(log n)/2 ≤ h ≤ log n.
Beweis: Da jeder innere Knoten k die Ungleichung 2 ≤ d(k) ≤ 4 erfüllt, hat
ein (2, 4)-Baum der Höhe h höchstens 4h und mindestens 2h Blätter, da ja alle
Blätter auf derselben Stufe h liegen.
In einem (2,4)-Baum mit n Blättern kostet die Operation Suchen also nur
O(log n) Schritte.
Wie kann man das Einfügen in (2,4)-Bäumen realisieren? Durch die Operation
Suchen wird ein innerer Knoten gefunden, der als Kind ein Blatt mit dem
einzufügenden Schlüssel hat, wenn er schon im Baum vorhanden ist. Ist dies
der Fall, so wird kein Knoten neu erzeugt. Andernfalls wird ein neues Blatt
124
KAPITEL 3. DATENTYPEN ZUR DARSTELLUNG VON MENGEN
erzeugt und als neues Kind an der richtigen Stelle an diesen Knoten angefügt.
Im Knoten selbst wird auch ein neuer Wert eingetragen, der die Blattinhalte
korrekt voneinander trennt. Falls der Knoten durch dieses Anfügen den Grad
d(k) = 5 erhält, wird er in zwei Knoten k 0 und k 00 aufgespalten. Dadurch erhält
der Elternknoten von k nun ein zusätzliches Kind, d.h. das Aufspalten kann
sich entlang des Suchwegs bis zur Wurzel fortsetzen.
Beispiel 3.4.13. In den Baum aus Beispiel 3.4.11 soll ein Knoten mit Inhalt
7 eingefügt werden.
4
XXX
"
XXX
"
XX
"
6,
7,
8,
9
k
2
!
a
!
a
!
%
e
cc aaa
!!
%
e
1
3
6
7
8
9
10
Knoten k hat nun den Grad d(k) = 5, muss also geteilt werden.
T1 :
4, 7
X
XX
XXX
0
k
8, 9
2
6
T
TT
T
1
3
6
7
8
k 00
@
@
9
10
Beim Löschen kann es passieren, dass ein innerer Knoten k nur noch ein Blatt
als Kind hat. Dann muss k entweder ein weiteres Kind von einem seiner direkten Nachbarn erhalten, oder k muss mit einem seiner direkten Nachbarn
verschmolzen werden.
Beispiel 3.4.14. Wenn wir im Baum T1 aus Beispiel 3.4.13 den Knoten 7
löschen, dann kann k 0 von k 00 das Kind 8 übernehmen:
T2 :
4, 8
P
P
PPPP
2
6
9
T
T
%
e
T
e
T
%
1
3
6
8
9
10
Wenn wir im Baum T1 den Knoten 3 löschen, dann wird dessen Elternknoten
mit seinem Nachbarn verschmolzen:
3.5. HASHING (STREUSPEICHERUNG)
125
T3 :
! 7 aa
!! aa
!
! a
2, 6
1
6
@
@
7
8, 9
%
%
8
Z
Z
9
10
Natürlich kann sich die Wiederherstellung des (2,4)-Baums nach einer LöschOperation auch wieder bis zur Wurzel fortpflanzen.
Satz 3.4.15. Werden Suchbäume durch (2,4)-Bäume implementiert, so ist Einfügen, Suchen und Löschen in Zeit O(log n) realisierbar.
3.5
Hashing (Streuspeicherung)
In diesem Abschnitt lernen wir eine weitere Realisierung für den Datentyp Dic”
tionary“ kennen: die Hash-Tabelle. Die Grundidee der Hashing-Verfahren besteht darin, aus dem zu speichernden Schlüsselwert die Adresse im Speicher
zu berechnen, an der dieses Element untergebracht wird. Sei etwa U ( Univer”
sum“) der Bereich, aus dem die Schlüsselwerte stammen, und sei S ⊆ U eine
zu speichernde Menge mit |S| = n. Zur Speicherung der Elemente von S stellen
wir eine Menge von Behältern B0 , B1 , . . . , Bm−1 zur Verfügung. Natürlich gilt
im allgemeinen |U | m (d.h. |U | ist sehr viel größer als m).
Definition 3.5.1. Eine Hash-Funktion ist eine (totale) Funktion h : U →
{0, . . . , m − 1}. Für a ∈ U gibt h(a) den Behälter an, in dem der Schlüssel
a untergebracht werden soll. Der Wert n/|U | ist die Schlüsseldichte, und der
Wert n/m ist der Belegungsfaktor der Hash-Tabelle B0 , . . . , Bm−1 .
Eine Hash-Funktion sollte die folgenden Eigenschaften haben:
• Sie sollte surjektiv sein, d.h. alle Behälter sollten erfasst werden.
• Sie sollte die zu speichernden Schlüssel möglichst gleichmäßig über alle
Behälter verteilen, d.h. jeder Behälter sollte mit gleicher Wahrscheinlichkeit getroffen werden.
• Sie sollte möglichst einfach zu berechnen sein.
Beispiel 3.5.2. Wir wollen die Monatsnamen auf 13 Behälter B0 , . . . , B12
verteilen. Als einfaches Beispiel einer Hash-Funktion verwenden wir dabei die
folgende: Ist w = a1 a2 a3 . . . ak ein Wort der Länge ≥ 3 über dem Alphabet
{a, . . . , z}, so wählen wir
h(w) = d(a1 ) + d(a2 ) + d(a3 ) mod 13
126
KAPITEL 3. DATENTYPEN ZUR DARSTELLUNG VON MENGEN
mit d(a) = 1, d(b) = 2, . . . , d(z) = 26. Wir erhalten folgende Zuordnung:
februar 7→ 0
oktober 7→ 7
september 7→ 1
7→ 8
7→ 2
april, dezember 7→ 9
august 7→ 3
mai 7→ 10
juli 7→ 4
7→ 11
7→ 5
januar, november 7→ 12
maerz, juni 7→ 6
Obwohl noch Behälter da sind, die nichts enthalten, werden mehrfach Schlüssel
auf denselben Behälter abgebildet, d.h. sogenannte Kollisionen treten auf.
Die verschiedenen Hashverfahren unterscheiden sich nun dadurch, wie sie mit
auftretenden Kollisionen umgehen. Beim offenen Hashing kann ein Behälter
beliebig viele Elemente aufnehmen, während beim geschlossenen Hashing jeder
Behälter nur eine kleine konstante Anzahl b von Elementen aufnehmen kann.
Falls mehr als b Elemente auftreten, die alle auf denselben Behälter abgebildet
werden, so entsteht ein Überlauf.
Bemerkung 3.5.3. Wie groß ist die Wahrscheinlichkeit, dass Kollisionen auftreten? Sei h : U → {0, . . . , m − 1} eine ideale Hash-Funktion, die die Schlüsselwerte gleichmäßig auf alle m Behälter verteilt, d.h. für 0 ≤ i < m ist
1
P r h(s) = i = .
m
Wir wollen die Wahrscheinlichkeit dafür bestimmen, dass bei einer zufälligen
Folge von n Schlüsseln (n < m) eine Kollision auftritt. Es gilt
PKollision = 1 − Pkeine Kollision ,
Pkeine Kollision = P (1) · P (2) · · · P (n),
wobei P (i) die Wahrscheinlichkeit dafür ist, dass der i-te Schlüssel auf einen
freien Platz kommt, wenn die Schlüssel 1, . . . , i − 1 auch alle auf freie Plätze
gekommen sind. Nun ist P (1) = 1, P (2) = (m − 1)/m, und allgemein P (i) =
(m − i + 1)/m, also
PKollision = 1 −
m(m − 1) · · · (m − n + 1)
.
mn
Für m = 365 ergeben sich die folgenden Kollisionswahrscheinlichkeiten:
n = 22 : PKollision ≈ 0, 475
n = 23 : PKollision ≈ 0, 507
n = 50 : PKollision ≈ 0, 970
Dies ist das sogenannte Geburtstagsparadoxon“: Sind mehr als 23 Personen
”
zusammen, so haben mit mehr als 50% Wahrscheinlichkeit mindestens zwei von
ihnen am selben Tag Geburtstag. Für das Hashing bedeutet die obige Analyse,
dass Kollisionen praktisch unvermeidbar sind.
3.5. HASHING (STREUSPEICHERUNG)
127
Definition 3.5.4. Hashing mit Verkettung: Für jeden Behälter wird eine verkettete Liste angelegt, in die alle Schlüssel eingefügt werden, die auf diesen
Behälter abgebildet werden. Für die Tabelle aus Beispiel 3.5.2 erhalten wir
damit die folgende Darstellung:
0
b
1
ab
februar
%
%
september %%
2
3
4
b
august
%
%
b
juli
%
%
b
maerz
b
oktober
b
april
b
mai
b
januar
5
6
7
b
juni
%
%
%
%
8
9
10
b
dezember %%
%
%
11
12
b
november %%
Programm 3.5.5. Unter Verwendung der Hash-Funktion aus Beispiel 3.5.2
und dem Java-Typ LinkedList erhalten wir folgende Implementierung für die
Operationen Suchen, Einfügen und Löschen:
1
import java.util.LinkedList;
2
3
/** Die Klasse OpenHashTable implementiert offenes Hashing.
* Die Hash-Tabelle enthaelt Strings.
*/
6 class OpenHashTable {
4
5
7
8
9
10
11
12
13
14
15
16
17
18
19
/** Die Anzahl der Behaelter. */
private static final int ANZAHL BEHAELTER = 13;
/** Ein Array von ANZAHL BEHAELTER vielen Listen. */
private LinkedList[ ] hashTable = new LinkedList[ANZAHL BEHAELTER];
/** Konstruiert eine leere Hash-Tabelle. */
public OpenHashTable( ) {
for( int i = 0; i < ANZAHL BEHAELTER; i++ )
hashTable[i] = new LinkedList( );
}
OpenHashTable
128
20
21
22
23
KAPITEL 3. DATENTYPEN ZUR DARSTELLUNG VON MENGEN
/** Implementiert die Abbildung d(’a’) = 1, . . ., d(’z’) = 26. */
private static int d( char c ) {
return c − ’a’ + 1;
}
d
24
25
26
27
28
29
30
31
32
33
34
35
/** Ordnet einem Wort w = a 1 a 2 a 3 v mit a i aus {’a’,. . .,’z’} den Wert
* (d(a 1) + d(a 2) + d(a 3)) mod ANZAHL BEHAELTER zu. Dafuer
* muss w mindestens die Laenge 3 haben, sonst wird eine Ausnahme ausgeloest.
*/
private static int hash( String w ) {
if( w.length( ) < 3 )
throw new IllegalArgumentException( "String zu kurz." );
return
( d( w.charAt( 0 ) ) + d( w.charAt( 1 ) ) + d( w.charAt( 2 ) ) )
% ANZAHL BEHAELTER;
}
hash
36
37
38
39
40
41
/** Test, ob der String key in der Hash-Tabelle enthalten ist. */
public boolean isMember( String key ) {
// Test, ob key in der Liste mit Index hash( key ) enthalten ist:
return hashTable[ hash( key ) ].contains( key );
}
isMember
42
43
44
45
46
47
48
/** Fuegt den String key in die Hash-Tabelle ein. */
public void insert( String key ) {
int h = hash( key );
if( !hashTable[ h ].contains( key ) )
hashTable[ h ].add( key );
}
insert
49
50
51
52
53
/** Entfernt den String key aus der Hash-Tabelle. */
public void delete( String key ) {
hashTable[ hash( key ) ].remove( key );
}
delete
54
55
56
57
58
59
60
61
/** Gibt eine String-Darstellung der Hash-Tabelle zurueck. */
public String toString( ) {
StringBuffer ausgabe = new StringBuffer( );
for( int i = 0; i < ANZAHL BEHAELTER; i++ )
ausgabe.append( "Behaelter " + i + ":\t" + hashTable[ i ] + "\n" );
return ausgabe.toString( );
}
toString
62
63
64
65
66
67
68
69
70
public static void main( String[ ] args ) {
OpenHashTable ht = new OpenHashTable( );
String[ ] monat = { "januar", "februar", "maerz", "april", "mai", "juni",
"juli", "august", "september", "oktober", "november", "dezember" };
for( int i = 0; i < monat.length; i++ )
ht.insert( monat[ i ] );
System.out.println( ht );
}
71
72
} // class OpenHashTable
main
3.5. HASHING (STREUSPEICHERUNG)
129
Ein Testlauf:
Behaelter
Behaelter
Behaelter
Behaelter
Behaelter
Behaelter
Behaelter
Behaelter
Behaelter
Behaelter
Behaelter
Behaelter
Behaelter
0:
1:
2:
3:
4:
5:
6:
7:
8:
9:
10:
11:
12:
[februar]
[september]
[]
[august]
[juli]
[]
[maerz, juni]
[oktober]
[]
[april, dezember]
[mai]
[]
[januar, november]
Sei S = {x1 , . . . , xn } ⊆ U eine zu speichernde Menge, und sei HT eine offene
Hash-Tabelle mit Hash-Funktion h. Wir wollen annehmen, dass h in konstanter
Zeit ausgewertet werden kann. Wieviel Rechenzeit wird dann für die Operationen Suchen, Einfügen und Löschen gebraucht?
Für 0 ≤ i < m sei HT [i] die Liste der Schlüssel xj , für die h(xj ) = i gilt.
Mit |HT [i]| bezeichnen wir die Länge der i-ten Liste. Dann kostet jede Operation im schlechtesten Fall O(|HT [h(x)]|) viele Schritte. Ist nämlich immer
h(xj ) = h(x1 ) für 1 ≤ j ≤ n, dann enthält HT [h(x1 )] alle n Elemente. Damit
erhalten wir die folgende Aussage.
Satz 3.5.6. Ist eine Menge S mit n Elementen in einer offenen Hash-Tabelle
mit Verkettung gespeichert, so braucht die Ausführung einer Operation Suchen,
Einfügen oder Löschen im schlechtesten Fall Rechenzeit O(n).
Das Verhalten im Mittel ist aber wesentlich besser. Wir betrachten eine beliebige Folge von n Operationen Suchen, Einfügen und Löschen, wobei wir mit der
leeren Menge beginnen. Anfänglich sind also alle Listen HT [0], . . . , HT [m − 1]
leer. Wir wollen dabei die folgenden Annahmen machen:
• Die Hash-Funktion h : U → I = {0, . . . , m − 1} streut die Schlüssel aus U
gleichmäßig über das Intervall I: Für i, j ∈ I gilt
|h−1 (i)| = |h−1 (j)| =
|U |
.
m
• Alle Schlüssel treten mit derselben Wahrscheinlichkeit als Argument einer
Operation auf: Für s ∈ U und 1 ≤ i ≤ n gilt
P r(das Argument der i-ten Operation ist der Schlüssel s) =
1
.
|U |
130
KAPITEL 3. DATENTYPEN ZUR DARSTELLUNG VON MENGEN
Aus diesen beiden Annahmen folgt
P r(h liefert für das Argument der i-ten Operation den Wert j) =
1
,
m
d.h. h(x1 ), . . . , h(xn ) kann als eine Folge von n unabhängigen Ausführungen
eines Zufallsexperiments mit Gleichverteilung über {0, . . . , m − 1} aufgefasst
werden.
Satz 3.5.7. Unter obigen Annahmen hat eine Folge von n Operationen Suchen,
Einfügen und Löschen im Mittel den Zeitbedarf
n
O (1 +
)·n .
2m
Ist der Belegungsfaktor n/m klein, so wird also in einer Folge von n solchen
Operationen im Mittel jede Operation in konstanter Zeit durchgeführt!
Beweis: Zuerst bestimmen wir die mittlere Länge der Liste HT [i] (0 ≤ i < m)
nach Ausführung von k ≥ 1 Operationen. Es ist
P r(während der ersten k Operationen wird j-mal auf HT [i] zugegriffen)
j 1 k−j
k
1
1−
.
=
m
m
j
(∗)
Nachdem j-mal auf die Liste HT [i] zugegriffen worden ist, hat sie höchstens
die Länge j. Sei nun `i (k) die mittlere Länge der Liste HT [i] nach den ersten
k Operationen. Dann gilt:
`i (k) ≤
k X
k
1 j
j
j=0
m
1
1−
m
k−j
·j
j−1 k k X k−1
1
1 k−j
=
·
1−
m
j−1
m
m
j=1
k
=
·
m
k−1 X
j=0
|
k
k k−1
denn
=
j
j j−1
j k−1
1
1 (k−1)−j
k
1−
= .
j
m
m
m
{z
}
=1 mit (∗)
Da dies für alle i gilt, bedeutet dies, dass die (k + 1)-ste Operation im Mittel in
Zeit O(1 + k/m) ausgeführt werden kann. Damit erhalten wir für die mittlere
Rechenzeit einer Folge von n Operationen die folgende Abschätzung:
T
(P )
(n) ≤ c ·
n−1
X
k=0
k
1+
m
n(n − 1)
=c· n+
2m
n
∈ O (1 +
)·n .
2m
3.5. HASHING (STREUSPEICHERUNG)
131
Wir wenden uns nun dem geschlossenen Hashing zu, bei dem jeder Behälter nur
eine konstante Anzahl b ≥ 1 von Schlüsseln aufnehmen kann. Wir betrachten
dabei im folgenden den Spezialfall b = 1 und behandeln Hash-Tabellen, die
Schlüssel/Wert-Paare mit Schlüsseln vom Typ String und Werten vom Typ
Object speichern. Diese Paarmengen sollen partielle Funktionen sein, d.h. jedem
Schlüssel ist jeweils höchstens ein Wert zugeordnet. Die Paare werden durch den
Typ Content realisiert, der zwei Felder vorsieht, key vom Typ String und value
vom Typ Object. Die Hash-Tabelle besteht dann aus einem Array über dem
Typ Content.
Den Typ Content versehen wir noch mit einem weiteren Feld vom Typ boolean,
um zwischen den beiden folgenden Fällen unterscheiden zu können:
• Ein Behälter ist leer (empty), also noch nie gefüllt gewesen.
• Ein Behälter ist zwar schon gebraucht worden, aber wegen einer Löschoperation zur Zeit nicht aktiv (deleted).
Beim geschlossenen Hashing ist die Behandlung auftretender Kollisionen von
großer Bedeutung. Die grundlegende Idee des Rehashing besteht darin, neben
der Funktion h = h0 auch noch weitere Hash-Funktionen h1 , . . . , hm−1 zu benutzen. Für einen Schlüssel x werden dann die Zellen h0 (x), h1 (x), . . . , hm−1 (x)
der Reihe nach angeschaut. Sobald eine freie oder als gelöscht markierte Zelle
mit Schlüssel x gefunden wird, kann ein Paar mit Schlüssel x eingefügt werden. Andererseits besagt das erste Auftreten einer freien Zelle, dass x nicht in
der Hash-Tabelle enthalten ist. Hier sehen wir auch, warum gelöschte Paare
markiert werden müssen; diese Technik bezeichnet man als lazy deletion“.
”
Die Folge der Funktionen h0 , . . . , hm−1 sollte so gewählt werden, dass für jeden Schlüsselwert sämtliche Behälter HT [i] (0 ≤ i < m) erreicht werden. Die
einfachste Strategie hierfür ist das lineare Sondieren (engl. linear probing):
hi (x) = (h(x) + i) mod m.
Beispiel 3.5.8. (Fortsetzung von Beispiel 3.5.2) Auflösung von Kollisionen
mittels linearem Sondieren:
0 : februar
7 : juni
1 : september
8 : oktober
2 : november
9 : april
3 : august
10 : mai
4 : juli
11 : dezember
5:
12 : januar
6 : märz
132
KAPITEL 3. DATENTYPEN ZUR DARSTELLUNG VON MENGEN
Programm 3.5.9. Geschlossenes Hashing mit linearem Sondieren:
1
import java.util.LinkedList;
2
3
4
5
6
7
8
/** Die Klasse ClosedHashTableLinearProbing implementiert geschlossenes
* Hashing mit linearem Sondieren.
* Die Hash-Tabelle enthaelt Schluessel/Wert-Paare mit Schluesseln vom Typ
* String und Werten vom Typ Object. Diese Paarmenge ist eine partielle Funktion.
*/
class ClosedHashTableLinearProbing {
9
10
11
/** Die Anzahl der Behaelter. */
private static final int ANZAHL BEHAELTER = 13;
12
13
14
15
16
17
18
19
/** Die Klasse Content implementiert Schluessel/Wert-Paare mit einem
* zusaetzlichen Feld zur Markierung geloeschter Eintraege in der
* Hash-Tabelle (‘lazy deletion’). Ist fuer eine im Array gespeicherte
* Instanz deleted == false, dann sagen wir, dass der Schluessel in der
* Hash-Table vorhanden ist.
*/
private class Content {
20
21
22
/** Der Schluessel. */
String key;
23
24
25
/** Der Wert zum Schluessel. */
Object value;
26
27
28
/** Gibt an, ob der Eintrag geloescht ist. */
boolean deleted;
29
30
31
32
33
34
35
36
/** Konstruiert das Schluessel/Wert-Paar (key, value).
* Das Feld deleted bekommt den Wert false.
*/
Content( String key, Object value ) {
this.key = key;
this.value = value;
}
Content
37
38
} // class Content
39
40
41
42
43
44
45
/** Ein Array ueber dem Typ Content.
* Positionen, die noch nicht gefuellt worden sind, enthalten null.
* Positionen, die bereits gefuellt waren, aber aktuell leer sind, enthalten
* ein Objekt content mit content.deleted == true (‘lazy deletion’).
*/
private Content[ ] hashTable = new Content[ANZAHL BEHAELTER];
46
47
/** Der parameterlose Konstruktor liefert eine leere Hash-Tabelle. */
48
49
50
51
52
/** Implementiert die Abbildung d(’a’) = 1, . . ., d(’z’) = 26. */
private static int d( char c ) {
return c − ’a’ + 1;
}
d
3.5. HASHING (STREUSPEICHERUNG)
133
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
/** Ordnet einem Wort w = a 1 a 2 a 3 v mit a i aus {’a’,. . .,’z’} den Wert
* (d(a 1) + d(a 2) + d(a 3)) mod ANZAHL BEHAELTER zu. Dafuer
* muss w mindestens die Laenge 3 haben, sonst wird eine Ausnahme ausgeloest.
*/
private static int hash( String w ) {
if( w.length( ) < 3 )
throw new IllegalArgumentException( "String zu kurz." );
return
( d( w.charAt( 0 ) ) + d( w.charAt( 1 ) ) + d( w.charAt( 2 ) ) )
% ANZAHL BEHAELTER;
}
/** Gibt den naechsten Behaelter nach dem i-ten Behaelter an. */
private static int rehash( int i ) {
return ( i+1 ) % ANZAHL BEHAELTER;
}
/** Test, ob der i-te Behaelter leer ist, d.h. noch nie gefuellt war. */
private boolean isEmpty( int i ) {
return hashTable[ i ] == null;
}
/** Test, ob der Inhalt des i-ten Behaelters geloescht ist. */
private boolean isDeleted( int i ) {
return hashTable[ i ].deleted;
}
/** Test, ob i >= 0 gilt und der i-te Behaelter weder leer noch geloescht ist. */
private boolean isActive( int i ) {
return i != −1 && !isEmpty( i ) && !isDeleted( i );
}
/** Gibt die Position des im Array gespeicherten Eintrags mit Schluessel key
* zurueck, falls er existiert; dabei wird nicht zwischen geloeschten und nicht
* geloeschten Eintraegen unterschieden. Andernfalls wird die Position des ersten
* gefundenen freien Behaelters zurueckgegeben, so vorhanden, sonst -1.
*/
private int findPosition( String key ) {
int h = hash( key );
int i = 0;
while( !isEmpty( h ) && i < ANZAHL BEHAELTER ) {
if( hashTable[ h ].key.equals( key ) )
return h;
i++;
h = rehash( h );
}
if( i < ANZAHL BEHAELTER )
return h;
return −1;
}
hash
rehash
isEmpty
isDeleted
isActive
findPosition
134
105
106
107
108
KAPITEL 3. DATENTYPEN ZUR DARSTELLUNG VON MENGEN
/** Test, ob der Schluessel key in der Hash-Tabelle vorhanden ist. */
public boolean isMember( String key ) {
return isActive( findPosition( key ) );
}
isMember
109
110
111
112
113
114
115
116
/** Gibt den zum Schluessel key in der Hash-Tabelle gespeicherten Wert zurueck,
* falls der Schluessel vorhanden ist, sonst null.
*/
public Object value( String key ) {
int pos = findPosition( key );
return isActive( pos ) ? hashTable[ pos ].value : null;
}
value
117
118
119
120
121
122
123
124
125
/** Der zum Schluessel key in der Hash-Tabelle gespeicherte Wert wird zu v,
* falls der Schluessel vorhanden ist.
*/
public void changeValue( String key, Object v ) {
int pos = findPosition( key );
if( isActive( pos ) )
hashTable[ pos ].value = v;
}
changeValue
126
127
128
129
130
131
132
133
134
135
136
137
138
139
/** Fuegt das Schluessel/Wert-Paar (key, value) in die Hash-Tabelle ein,
* falls der Schluessel key nicht in der Hash-Tabelle vorhanden ist.
* Gibt false zurueck, wenn das Paar eingefuegt werden muesste, aber kein
* freier Behaelter mehr vorhanden ist, sonst true.
*/
public boolean insert( String key, Object value ) {
int pos = findPosition( key );
if( pos == −1 ) // kein freier Behaelter vorhanden
return false;
if( !isActive( pos ) )
hashTable[ pos ] = new Content( key, value ); // Paar einfuegen
return true;
}
insert
140
141
142
143
144
145
146
147
148
/** Entfernt das Paar mit Schluessel key aus der Hash-Tabelle. */
public void delete( String key ) {
int pos = findPosition( key );
if( isActive( pos ) ) {
hashTable[ pos ].deleted = true;
hashTable[ pos ].value = null; // fuer Garbage-Collection
}
}
delete
149
150
151
152
153
154
155
156
157
158
159
/** Gibt eine String-Darstellung der Hash-Tabelle zurueck. */
public String toString( ) {
StringBuffer ausgabe = new StringBuffer( );
for( int i = 0; i < ANZAHL BEHAELTER; i++ )
ausgabe.append( "Behaelter " + i + ":\t" +
( hashTable[ i ] == null | | hashTable[ i ].deleted ? "--\n" :
"(" + hashTable[ i ].key + ", " + hashTable[ i ].value + ")\n" ) );
return ausgabe.toString( );
}
toString
3.5. HASHING (STREUSPEICHERUNG)
160
161
162
163
164
165
166
167
168
169
135
public static void main( String[ ] args ) {
ClosedHashTableLinearProbing ht = new ClosedHashTableLinearProbing( );
String[ ] monat = { "januar", "februar", "maerz", "april", "mai", "juni",
"juli", "august", "september", "oktober", "november", "dezember" };
String[ ] tage = { "31", "28", "31", "30", "31", "30", "31",
"31", "30", "31", "30", "31" };
for( int i = 0; i < monat.length; i++ )
ht.insert( monat[ i ], tage[ i ] );
System.out.println( ht );
}
170
171
} // class ClosedHashTableLinearProbing
Ein Testlauf:
Behaelter
Behaelter
Behaelter
Behaelter
Behaelter
Behaelter
Behaelter
Behaelter
Behaelter
Behaelter
Behaelter
Behaelter
Behaelter
0:
1:
2:
3:
4:
5:
6:
7:
8:
9:
10:
11:
12:
(februar, 28)
(september, 30)
(november, 30)
(august, 31)
(juli, 31)
-(maerz, 31)
(juni, 30)
(oktober, 31)
(april, 30)
(mai, 31)
(dezember, 31)
(januar, 31)
Jedes Element, das mit der Hash-Funktion auf einen bereits belegten Behälter
abgebildet wird, wird durch lineares Sondieren bis zum nächsten unbelegten
Behälter verschoben“. Sind also k Behälter hintereinander belegt, so ist die
”
Wahrscheinlichkeit, dass im nächsten Schritt der erste freie Behälter nach diesen
k Behältern belegt wird, mit (k + 1)/m wesentlich größer als die Wahrscheinlichkeit, dass ein Behälter im nächsten Schritt belegt wird, dessen Vorgänger
noch frei ist. Dadurch entstehen beim linearen Sondieren regelrechte Ketten.
Satz 3.5.10. [Knuth 1973] Sei α = n/m der Belegungsfaktor einer HashTabelle der Größe m, die mit n Elementen gefüllt ist. Beim Hashing mit linearem
Sondieren entstehen für eine Operation durchschnittlich folgende Kosten:
(1.) 1 + 1/(1 − α) /2 beim erfolgreichen Suchen und
(2.) 1 + 1/(1 − α)2 /2 beim erfolglosen Suchen.
Für verschiedene Belegungsfaktoren α erhalten wir die folgenden Werte:
main
136
KAPITEL 3. DATENTYPEN ZUR DARSTELLUNG VON MENGEN
α
20%
50%
70%
80%
90%
95%
(1.)
(2.)
1,125
1,5
2,17
3
5,5
10,5
1,28
2,5
6,06
13
50,5
200,5
Bei dicht besetzten Tabellen wird Hashing mit linearem Sondieren also sehr
ineffizient.
Definition 3.5.11. Weitere Kollisionsstrategien
(a) Verallgemeinertes lineares Sondieren (1 ≤ i < m):
hi (x) = (h(x) + c · i) mod m.
Dabei sollten c und m teilerfremd sein, um alle Behälter zu erreichen.
(b) Quadratisches Sondieren (1 ≤ i < m):
hi (x) = (h(x) + i2 ) mod m,
oder (1 ≤ i ≤ (m − 1)/2)
h2i−1 (x) = (h(x) + i2 ) mod m,
h2i (x) = (h(x) − i2 ) mod m.
Wählt man bei der zweiten Variante m = 4j + 3 als Primzahl, so wird
jeder Behälter getroffen.
(c) Doppel-Hashing: Seien h, h0 : U → {0, . . . , m − 1} zwei Hash-Funktionen.
Dabei seien h und h0 so gewählt, dass für beide eine Kollision nur mit
Wahrscheinlichkeit 1/m auftritt, d.h.
1
P r h(x) = h(y) = P r h0 (x) = h0 (y) = .
m
Die Funktionen h und h0 heißen unabhängig, wenn eine Doppelkollision
nur mit Wahrscheinlichkeit 1/m2 auftritt, d.h.
1
P r h(x) = h(y) und h0 (x) = h0 (y) = 2 .
m
Wir erhalten nun eine Folge von Hash-Funktionen wie folgt (i ≥ 1):
hi (x) = (h(x) + h0 (x) · i2 ) mod m.
Dies ist eine gute Methode, bei der die Schwierigkeit aber darin liegt,
Paare von Funktionen zu finden, die wirklich unabhängig sind.
3.5. HASHING (STREUSPEICHERUNG)
137
Programm 3.5.12. Eine Variante von Programm 3.5.9 mit quadratischem
Sondieren:
69
70
71
72
73
74
75
76
77
78
private static int rehash( int h, int i ) {
int j = ( i+1 )/2;
if( i%2 == 0 )
j = ( h−j*j ) % ANZAHL BEHAELTER;
else
j = ( h+j*j ) % ANZAHL BEHAELTER;
if( j < 0 )
j += ANZAHL BEHAELTER;
return j;
}
rehash
79
100
101
102
103
104
105
106
107
108
109
110
111
112
113
...
private int findPosition( String key ) {
int hInitial = hash( key );
int h = hInitial;
int i = 0;
while( !isEmpty( h ) && i < ANZAHL BEHAELTER ) {
if( hashTable[ h ].key.equals( key ) )
return h;
i++;
h = rehash( hInitial, i );
}
if( i < ANZAHL BEHAELTER )
return h;
return −1;
}
Definition 3.5.13. Einige Hash-Funktionen:
Sei nat : U → N eine Funktion, die jedem möglichen Schlüssel eine natürliche Zahl zuordnet. Durch h(x) = nat(x) mod m erhalten wir dann eine HashFunktion. Wir schauen uns im folgenden verschiedene Funktionen U → N an.
(a) Sei U = Σ∗ die Menge der Wörter über dem Alphabet Σ = {a, b, . . . , z}.
Dann kann ein Wort w = a1 a2 . . . an (ai ∈ Σ) als eine Zahl
nat(w) = a1 · 26n−1 + a2 · 26n−2 + · · · + an−1 · 26 + an
aufgefasst werden, wenn man a mit 0, b mit 1, . . . , z mit 25 identifiziert.
P
(b) Die Mittel-Quadrat-Methode: Sei U ⊆ N, und sei k = `i=0 zi · 10i , d.h.
k wird durch die Ziffernfolge z` z`−1 . . . z0 beschrieben. Den Wert h(k)
erhält man nun dadurch, dass man aus der Mitte der Ziffernfolge von
k 2 einen hinreichend großen Block nimmt. Da die mittleren Ziffern von
k 2 von allen Ziffern von k abhängen, ergibt dies eine gute Streuung von
aufeinanderfolgenden Werten.
findPosition
138
KAPITEL 3. DATENTYPEN ZUR DARSTELLUNG VON MENGEN
Beispiel: Sei m = 100.
k
k mod 100
k2
h(k)
127
128
129
27
28
29
16129
16384
16641
12
38
64
(c) Shift-Folding: Jedes Wort w wird als eine Binärzahl bin(w) aufgefasst.
Diese Binärzahl wird in Teile der Länge k zerlegt, wobei das letzte Teil
eventuell kürzer sein kann. Diese Teile werden nun als Binärzahlen aufgefasst und addiert. Dies ergibt eine Zahl nat(w). Beispiel w = emin:
w 7→ (5, 13, 9, 14) 7→ 0000 0101 0000 1101 0000 1001 0000 1110.
Für k = 5 erhalten wir die folgenden Teile mit Summe nat(w) = 65:
00000
10100
00110
10000
10010
00011
10
1000001
Wie wir in Satz 3.5.7 gesehen haben, kostet eine Operation Suchen, Einfügen
oder Löschen beim Hashing im Mittel O(1) Schritte. Andererseits kann eine solche Operation im schlechtesten Fall aber auch O(n) Schritte kosten. Für jede
Hash-Funktion h : U → {0, . . . , m − 1} sind es gewisse Schlüsselmengen S ⊆ U ,
die dieses schlechte Verhalten bewirken. Würde man S kennen, könnte man
eine Hash-Funktion auswählen, die für diese Schlüsselmenge möglichst wenige
Kollisionen und damit ein gutes Laufzeitverhalten ergibt. Leider kennt man die
Menge S in den meisten Anwendungen aber nicht. Die Idee des universellen
Hashing ist nun die folgende. Man hat eine Klasse H von Hash-Funktionen von
U nach {0, . . . , m − 1}. Aus dieser Klasse wählt man zur Laufzeit eine Funktion durch ein Zufallsexperiment aus. Ist die Klasse H geeignet gewählt, so kann
man zeigen, dass für jede Schlüsselmenge S ⊆ U im Mittel ein gutes Laufzeitverhalten erreicht wird. Wir haben es hier also mit einem probabilistischen
Algorithmus zu tun.
Definition 3.5.14. Sei H eine endliche Menge von Hash-Funktionen, die die
Menge U auf {0, . . . , m − 1} abbilden. Die Menge H heißt universell, falls für
alle Elemente x, y ∈ U mit x 6= y folgendes gilt:
1
P r h ∈ H | h(x) = h(y) = ,
m
d.h. die Wahrscheinlichkeit dafür, dass eine aus H zufällig ausgewählte Funktion
x und y auf denselben Behälter abbildet, ist mit 1/m genauso groß wie die
3.5. HASHING (STREUSPEICHERUNG)
139
Wahrscheinlichkeit dafür, dass eine Kollision auftritt, wenn man die Werte h(x)
und h(y) zufällig und unabhängig voneinander aus {0, . . . , m − 1} auswählt.
Satz 3.5.15. Sei S ⊆ U eine beliebige Schlüsselmenge mit n Elementen, und
sei H eine universelle Menge von Hash-Funktionen von U nach {0, . . . , m − 1}.
Sei h ∈ H zufällig ausgewählt. Für jeden Schlüssel x ∈ S ist dann die mittlere
Anzahl der Kollisionen |{y ∈ S \ {x} | h(y) = h(x)}| höchstens n/m.
Beweis: Für x, y ∈ S mit x 6= y bezeichne cxy die folgende Zufallsvariable:
1, falls h(x) = h(y),
cxy =
0, falls h(x) 6= h(y).
Dann gilt
E(cxy ) =
X
h∈H
1
cxy · P r(h) = P r h ∈ H | h(x) = h(y) = .
m
Für die Zufallsvariable Cx = |{y ∈ S \ {x} | h(y) = h(x)}| gilt damit
X
X
X 1
n
≤ .
E(Cx ) = E
cxy ≤
E(cxy ) =
m
m
y∈S\{x}
y∈S\{x}
y∈S\{x}
Ist also n ≤ m, so ist die mittlere Anzahl der zu erwartenden Kollisionen
höchstens 1. Wir beschließen diesen Abschnitt mit einem Beispiel für eine universelle Menge von Hash-Funktionen.
Definition 3.5.16. Alle Schlüssel x ∈ U seien durch Bitstrings der Länge `
gegeben. Wir zerlegen jeden dieser Bitstrings in Blöcke derselben Länge k, so
dass 2k ≤ m gilt. Dann kann jeder Block als eine Adresse aus {0, . . . , m − 1}
aufgefasst werden. Sei nun x ∈ U , und sei (x0 , . . . , xr ) die Blockzerlegung von
x. Die Klasse H besteht aus allen Hash-Funktionen h = ha , die durch ein Tupel
a = (a0 , . . . , ar ) mit ai ∈ {0, . . . , m − 1} spezifiziert werden, wobei ha durch
ha (x) =
r
X
ai · xi mod m
i=0
definiert ist. Es gibt also mr+1 viele verschiedene Funktionen in H.
Satz 3.5.17. Ist m eine Primzahl, so ist H eine universelle Klasse von HashFunktionen.
Beweis: Seien x = (x0 , . . . , xr ) und y = (y0 , . . . , yr ) zwei verschiedene Schlüssel
aus U mit xi , yj ∈ {0, . . . , m−1}. Da x 6= y ist, gibt es einen Index s mit xs 6= ys .
Ohne Beschränkung der Allgemeinheit sei s = 0, d.h. x0 6= y0 . Dann ist
ha (x) =
r
X
i=0
ai xi mod m,
ha (y) =
r
X
i=0
ai yi mod m,
140
KAPITEL 3. DATENTYPEN ZUR DARSTELLUNG VON MENGEN
also gilt
r
X
ai xi ≡
r
X
ai yi mod m
gdw a0 (x0 − y0 ) ≡
r
X
ai (yi − xi ) mod m.
ha (x) = ha (y) gdw
i=0
i=0
i=1
Da m eine Primzahl ist, gibt es eine Zahl (x0 − y0 )−1 ∈ {1, . . . , m − 1} mit
(x0 − y0 ) · (x0 − y0 )−1 ≡ 1 mod m, d.h. die obige Gleichheit ist äquivalent zu
−1
a0 = (x0 − y0 )
·
r
X
ai (yi − xi ) mod m.
i=1
Für jede Wahl von a1 , . . . , ar ∈ {0, . . . , m − 1} gibt es also genau einen Wert
a0 ∈ {0, . . . , m − 1}, so dass für a = (a0 , a1 , . . . , ar ) die Gleichheit ha (x) =
ha (y) gilt. Von den mr+1 vielen Funktionen aus H erfüllen also genau mr viele
Funktionen diese Gleichheit, d.h. für alle x, y ∈ U mit x 6= y ist
1
.
m
Also ist H eine universelle Klasse von Hash-Funktionen.
P r(h ∈ H | h(x) = h(y)) =
Um diese universelle Klasse zu benutzen, braucht man einen Prozess, der die
r + 1 Zahlen a0 , . . . , ar ∈ {0, . . . , m − 1}, die den Schlüssel a = (a0 , . . . , ar )
bilden, zufällig und unabhängig auswählt. Daher ist die Aufgabe, (Pseudo-)
Zufallszahlen effektiv zu bestimmen, von großer Wichtigkeit. Leider können wir
hier nicht darauf eingehen.
3.6
Partitionen von Mengen mit UNION und FIND
Unsere Betrachtungen zur Darstellung von Mengen und Operationen auf Mengen wollen wir mit einem Datentyp abschließen, der es erlaubt, Partitionen von
endlichen Mengen zu beschreiben. Sei S eine endliche Menge. Wir betrachten
Äquivalenz-Anweisungen“ der Form
”
a1 ≡ b1 , a2 ≡ b2 , . . . , am ≡ bm ,
wobei die ai , bj ∈ S sind. Die Anweisung ai ≡ bi besagt, dass die Äquivalenzklassen von ai und bi zu einer Klasse verschmolzen werden sollen. Außerdem
wollen wir die Äquivalenzklasse eines gegebenen Elements aus S bestimmen
können, etwa indem wir einen Repräsentanten dieser Klasse angeben.
Beispiel 3.6.1. Sei S = {1, 2, 3, 4, 5, 6}. Anfänglich sei jedes Element für sich
eine Klasse der betrachteten Partition P , d.h. P = {{1}, {2}, {3}, {4}, {5}, {6}}.
Die Anweisungen unten bewirken nun die folgenden Veränderungen von P :
1 ≡ 4 ; P = {{1, 4}, {2}, {3}, {5}, {6}}
2 ≡ 5 ; P = {{1, 4}, {2, 5}, {3}, {6}}
2 ≡ 4 ; P = {{1, 2, 4, 5}, {3}, {6}}
3.6. PARTITIONEN VON MENGEN MIT UNION UND FIND
141
Auf einer Partition P wollen wir also folgende Operationen ausführen:
• Vereinigen (UNION): Ersetze S1 ∈ P und S2 ∈ P durch S1 ∪ S2 .
• Finden (FIND): Bestimme zu i ∈ S die Teilmenge Sj ∈ P mit i ∈ Sj .
Definition 3.6.2. Eine Implementierung von Partitionen als Bäume:
Sei {S1 , . . . , Sm } eine Partition von {1, . . . , n}. Ist |Si | = `i , so stellen wir diese
Menge durch einen Baum mit `i Knoten dar, wobei jeder Knoten ein Element
von Si als Inhalt hat. Bei der Implementierung dieser Bäume soll von jedem
Knoten, der nicht Wurzel ist, ein Verweis auf seinen Elternknoten zeigen.
Beispiel 3.6.3. Für S = {1, . . . , 10} und P = {S1 , S2 , S3 } mit S1 = {1, 7, 8, 9},
S2 = {2, 5, 10} und S3 = {3, 4, 6} ergibt sich folgende Baummenge:
89:;
?>=<
@ 1O ^==
==
=
89:;
?>=<
89:;
?>=<
89:;
?>=<
7
8
9
S1
89:;
?>=<
@ 5 `@@
@@
@@
89:;
?>=<
@ABC
GFED
2
10
S2
89:;
?>=<
@ 3 ^==
==
=
89:;
?>=<
89:;
?>=<
4
6
S3
Haben wir die Mengennamen S1 , . . . , Sm (z.B. in einer Liste) gespeichert, so
kann mit jedem Namen ein Verweis auf die Wurzel des zugehörigen Baums gespeichert werden, und umgekehrt kann in der Wurzel des Baums ein Verweis auf
den Mengennamen gespeichert sein. Zur Vereinfachung wollen wir im folgenden
daher die Mengen S1 , . . . , Sm mit den Elementen in den Wurzeln der darstellenden Bäume identifizieren, d.h. diese Elemente dienen als Repräsentanten der
Mengen S1 , . . . , Sm .
Um zwei disjunkte Mengen Si und Sj zu vereinigen, kann man nun einfach
die Wurzel des einen Baums zu einem Kind der Wurzel des anderen Baums
machen. Wählen wir für die Darstellung ein Array ganzer Zahlen parent, wobei
wir in parent[i] einfach das Element angeben, das als Elternknoten von Element
i auftritt, so erhalten wir folgende einfachen Algorithmen für die Realisierung
der obigen beiden Operationen.
/** Ersetze die Mengen mit Wurzeln i und j durch ihre Vereinigung. */
void union’( int i, int j) {
if( i != j )
4
parent[i] = j;
5 }
1
2
3
6
7
8
9
10
11
12
13
/** Liefert die Menge (d.h. den Repräsentaten der Menge), die i enthält. */
int find’( int i ) {
int h = i;
while( parent[h] > 0 )
h = parent[h];
return h;
}
union’
find’
142
KAPITEL 3. DATENTYPEN ZUR DARSTELLUNG VON MENGEN
Beispiel 3.6.4. (Fortsetzung von Beispiel 3.6.3) Führt man die Operation
union’(1, 5) aus, dann entsteht der untenstehende Baum. Die Operation find’(8)
liefert darauf das Resultat 5.
89:;
1 ?>=<
= 5 Eb
{{ O EEE
{
EE
{
EE
{{
{{
89:;
?>=<
@ABC
GFED
89:;
?>=<
2
10
= 1 C
a
{{ J O CCC
{
C
{
CC
{{
C
{{
89:;
?>=<
89:;
?>=<
89:;
?>=<
7
8
9
Diese Algorithmen sind zwar einfach, aber leider haben sie im Allgemeinen ein
sehr ungünstiges Laufzeitverhalten.
Beispiel 3.6.5. Für die Partition {S1 , . . . , Sn } mit Si = {i} führen wir folgende Operationen in der angegebenen Reihenfolge durch (p ≤ n):
union’(1, 2), find’(1), union’(2, 3), find’(1), . . . , union’(p − 1, p), find’(1).
89:;
?>=<
p
O
..
.O
?>=<
89:;
2
O
89:;
?>=<
1
76
54 . . . ?>=<
89:;
01p + 123
n
Dadurch entsteht die nebenstehende Partition.
Die p − 1 union’-Operationen kosten Zeit O(p).
Jede find’-Operation kostet soviel Zeit, wie der
zu durchlaufende Weg lang ist, der i-te Aufruf
kostet also Zeit O(i). Als Gesamtzeit erhalten
wir damit O(p2 ).
Die Gesamtzeit für die Ausführung einer Folge von Vereinigungs- und FindeOperationen kann erheblich verbessert werden, wenn wir bei der Vereinigung
die folgende Gewichtungsregel benutzen: Ist die Anzahl der Knoten im Baum
i kleiner als die Anzahl der Knoten im Baum j, so wird i zum Kind von j,
andernfalls wird j zum Kind von i.
Beispiel 3.6.6. (Fortsetzung von Beispiel 3.6.5) Diesmal führen wir die obige
Folge von Operationen aus, indem wir bei der Vereinigung die Gewichtungsregel
benutzen. Wir erhalten dann folgende Partition in Gesamtzeit O(p):
54 . . . ?>=<
76
89:;
89:;
?>=<
01p + 123
n
1 Mf
B O MMMMM
MMM
89:;
?>=<
89:;
?>=<
?>=<
89:;
.
.
.
p
2
3
Um die Vereinigung auf diese Weise zu realisieren, müssen wir mit jedem Baum
die Anzahl seiner Knoten speichern. Dies können wir in der Wurzel tun, da
die Wurzel ja keinen Verweis auf einen Elternknoten hat. Um die Fälle, ob in
parent[i] nun ein Verweis oder die Anzahl n der Knoten steht, d.h. ob i Wurzel
3.6. PARTITIONEN VON MENGEN MIT UNION UND FIND
143
ist oder nicht, unterscheiden zu können, speichern wir in der Wurzel die Zahl
−n statt n. Damit erhalten wir folgenden Algorithmus.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/** Ersetze die Mengen mit Wurzeln i und j durch ihre
* Vereinigung, wobei die Gewichtungsregel benutzt wird.
* Wir setzen parent[i] = -(Groesse des Baums mit Wurzel i) und
* parent[j] = -(Groesse des Baums mit Wurzel j) voraus.
*/
void union( int i, int j ) {
if( i != j ) {
if( parent[i] > parent[j] ) { // Baum i ist kleiner als Baum j
parent[j] += parent[i];
parent[i] = j;
}
else { // parent[i] <= parent[j]: Baum i ist mindestens so gross wie Baum j
parent[i] += parent[j];
parent[j] = i;
}
}
}
Zeitbedarf von union: O(1).
Was können wir nun über die Gesamtzeit für eine Folge von Vereinigungsund Finde-Operationen sagen? Wir werden sehen, dass diese Gesamtzeit nun
O(p log p) ist, wenn p die Anzahl der auszuführenden Operationen ist. Dazu
brauchen wir folgendes Lemma.
Lemma 3.6.7. Sei T ein Baum mit m Knoten und Höhe h, der mittels des Algorithmus union aus der Partition {{1}, {2}, . . . , {n}} aufgebaut wurde. Dann
gilt h ≤ blog mc.
Beweis: Durch Induktion nach m. Der Fall m = 1 ist klar. Angenommen, die
Aussage sei bereits für alle Zahlen kleiner als m bewiesen worden, und sei nun
union(`, r) die letzte Operation, die den Baum T liefert:
T :
A
A
A
T` A
A
A
A
A
Tr A
A
union(`, r)
!
!! A
!
A
!!
A
A
A
T
r
A
A
A
T` A
A
O.B.d.A. nehmen wir |T` | = j mit 1 ≤ j ≤ m/2 an, also |Tr | = m − j mit
m/2 ≤ m − j < m. Nach Induktionsvoraussetzung haben wir
h(T` ) ≤ blog jc ≤ blog(m/2)c = blog mc − 1,
h(Tr ) ≤ blog(m − j)c ≤ blog mc.
Das ergibt h(T ) = max{h(T` ) + 1, h(Tr )} ≤ blog mc.
union
144
KAPITEL 3. DATENTYPEN ZUR DARSTELLUNG VON MENGEN
Für eine Finde-Operation brauchen wir also höchstens O(log m) Schritte, wenn
sie auf einem Baum mit m Knoten ausgeführt wird, der durch den Algorithmus
union aufgebaut worden ist.
Folgerung 3.6.8. Eine beliebige Folge von p Vereinigungs-Operationen und q
Finde-Operationen kann in Zeit O(p + q · log p) ausgeführt werden.
Beweis: Ein Baum mit m Knoten kann nur mit m − 1 Anwendungen der
Vereinigungs-Operation aufgebaut werden, da wir immer mit der Partition
{{1}, {2}, . . . , {n}} beginnen.
Die Schranke aus Lemma 3.6.7 ist scharf, denn mit m − 1 Vereinigungen kann
tatsächlich ein Baum mit m Knoten und Höhe blog mc erzeugt werden.
Eine weitere Verbesserung können wir erzielen, wenn bei der Finde-Operation
die folgende Kompressionsregel benutzen wird: Mache alle Knoten auf dem Weg
vom Knoten i zur Wurzel (einschließlich Knoten i) zu Kindern der Wurzel.
Unter Benutzung dieser Regel erhalten wir folgenden Algorithmus.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/** Gibt die Wurzel des Baums zurueck, der i enthaelt,
* wobei die Kompressionsregel benutzt wird.
*/
int find( int i ) {
int w = i;
while( parent[w] >= 0 ) // solange w nicht die Wurzel ist
w = parent[w];
// w ist nun Repraesentant der Teilmenge, die i enthaelt.
// Realisiere noch die Kompressionsregel:
int tmp;
while( i != w ) {
tmp = parent[i];
parent[i] = w;
i = tmp;
}
return w;
}
Zeitbedarf von find: O(Tiefe des Knotens i).
Beispiel 3.6.9. Aus der Partition {{1}, . . . , {8}} entsteht durch die Operationenfolge union(1,2), union(3,4), union(5,6), union(7,8), union(1,3), union(5,7),
union(1,5) der Baum
89:;
?>=<
1
@ O ^===
==
89:;
?>=<
89:;
?>=<
89:;
?>=<
2
3
5 ^=
O
O ==
==
89:;
?>=<
89:;
?>=<
89:;
?>=<
4
6
7
O
89:;
?>=<
8
find
3.6. PARTITIONEN VON MENGEN MIT UNION UND FIND
145
Durch Operation find(8) (mit Ausgabe 1) entsteht daraus der neue Baum
89:;
?>=<
g Ti NTNTTT
@ 1O ^=N
== NN TTT
== NNN TTTTT
NN TTT
89:;
?>=<
89:;
?>=<
89:;
?>=<
89:;
?>=<
89:;
?>=<
2
3
5
7
8
O
O
?>=<
89:;
4
89:;
?>=<
6
Um die Gesamtzeit für eine Folge von q Vereinigungs-Operationen und p ≥ q
Finde-Operationen abzuschätzen, brauchen wir die Ackermannsche Funktion3
A : N × N → N, die so definiert ist (p, q ≥ 0):
A(0, q) = 2q,
A(p + 1, 0) = 1,
A(p + 1, q + 1) = A(p, A(p + 1, q)).
Die folgende Funktion ist vom Wachstum her gerade umgekehrt:
α(m, n) = min{z ≥ 1 | A (z, 4 · dm/ne) > log n}.
Lemma 3.6.10. A ist im ersten Argument monoton wachsend und im zweiten
streng monoton wachsend: A(p + 1, q) ≥ A(p, q) und A(p, q + 1) > A(p, q).
..
2
Die Funktion A wächst extrem schnell. So gilt z.B. A(3, 4) = 22 , 65536-mal
geschachtelt. Und für m > 0 und 1 ≤ n < 2A(3,4) , also log n < A(3, 4), ist
A(3, 4 · dm/ne) ≥ A(3, 4) > log n, damit α(m, n) ≤ 3. Also wächst die Funktion
α extrem langsam; in der Praxis ist sie von einer konstanten Funktion nicht zu
unterscheiden.
Satz 3.6.11. Die Gesamtzeit, die maximal benötigt wird, eine Folge von q
Vereinigungs-Operationen und p ≥ q Finde-Operationen mit den Algorithmen
union und find auszuführen, ist in Θ(p · α(p, q)).
Beweis: Siehe R. Tarjan, Efficiency of a good but not linear set union algorithm,
Journal of the ACM 22(2) (1975), S. 215–225, oder auch K. Mehlhorn, Data
structures and algorithms, Vol. 1, Springer 1984, S. 300–304.
Programm 3.6.12. Eine Implementierung der UNION-FIND-Algorithmen:
1
2
3
4
5
/** Die Klasse Partition implementiert Partitionen endlicher Mengen. */
public class Partition {
/** Die Groesse des Universums {0,. . .,cardinality-1}. */
private final int cardinality;
6
3
Wilhelm Ackermann (1896–1962)
146
7
8
9
10
11
12
KAPITEL 3. DATENTYPEN ZUR DARSTELLUNG VON MENGEN
/** Das Array zur Speicherung von Verweisen auf Elternknoten
* bzw. von Baumgroessen. Die Knoten sind 0 bis cardinality-1.
* Alle Werte >= 0 sind Verweise auf Elternknoten,
* alle Werte < 0 sind negierte Baumgroessen.
*/
private int[ ] parent;
13
14
15
16
17
18
19
20
21
22
/** Konstruiert die feinste Partition des Universums mit n Elementen:
* jede Menge enthaelt genau ein Element.
*/
public Partition( int n ) {
cardinality = n;
parent = new int[cardinality];
for( int i = 0; i < cardinality; i++ )
parent[i] = −1;
}
Partition
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
/** Ersetze die Mengen mit Wurzeln i und j durch ihre
* Vereinigung, wobei die Gewichtungsregel benutzt wird.
* Wir setzen parent[i] = -(Groesse des Baums mit Wurzel i) und
* parent[j] = -(Groesse des Baums mit Wurzel j) voraus.
* Wir setzen ausserdem i,j < cardinality voraus.
*/
public void union( int i, int j ) {
if( parent[i] >= 0 | | parent[j] >= 0 )
throw new
IllegalArgumentException( "Union erwartet zwei Repraesentanten.");
if( i >= cardinality | | j >= cardinality )
throw new
IllegalArgumentException( "Union erwartet Elemente des Universums.");
if( i != j ) {
if( parent[i] > parent[j] ) { // Baum i ist kleiner als Baum j
parent[j] += parent[i];
parent[i] = j;
}
else { // parent[i] <= parent[j]: Baum i ist mindestens so gross wie Baum j
parent[i] += parent[j];
parent[j] = i;
}
}
}
union
48
49
50
51
52
53
54
55
56
57
58
59
/** Gibt die Wurzel des Baums zurueck, der i enthaelt,
* wobei die Kompressionsregel benutzt wird.
* Wir setzen 0 <= i < cardinality voraus.
*/
public int find( int i ) {
if( i < 0 | | i >= cardinality )
throw new
IllegalArgumentException( "Find erwartet Elemente des Universums.");
int w = i;
while( parent[w] >= 0 ) // solange w nicht die Wurzel ist
w = parent[w];
find
3.6. PARTITIONEN VON MENGEN MIT UNION UND FIND
// w ist nun Repraesentant der Teilmenge, die i enthaelt.
// Realisiere noch die Kompressionsregel:
int tmp;
while( i != w ) {
tmp = parent[i];
parent[i] = w;
i = tmp;
}
return w;
60
61
62
63
64
65
66
67
68
69
70
}
71
72
/** Gibt eine String-Repraesentation der Partition zurueck. */
public String toString( ) {
StringBuffer result = new StringBuffer( );
for( int i = 0; i < cardinality; i++ )
if( parent[i] < 0 ) // falls i Repraesentant ist
result.append( i + " ist Repraesentant.\n" );
else // i hat einen Elternknoten
result.append( i + " hat Elternknoten " + parent[i] + "\n" );
return result.toString( );
}
73
74
75
76
77
78
79
80
81
public static void main( String[ ] args ) {
Partition p = new Partition( 9 );
p.union( 1, 2 );
p.union( 3, 4 );
p.union( 5, 6 );
p.union( 7, 8 );
p.union( 1, 3 );
p.union( 5, 7 );
p.union( 1, 5 );
System.out.println( "Vor find(8):" );
System.out.println( p );
System.out.println( "find(8) liefert " + p.find( 8 ) );
System.out.println( "Nach find(8):" );
System.out.println( p );
}
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
147
}
toString
main
148
KAPITEL 3. DATENTYPEN ZUR DARSTELLUNG VON MENGEN
Kapitel 4
Graphen und
Graph-Algorithmen
Graphen sind eine mathematische Struktur, die eine Menge von Objekten zusammen mit einer Relation auf diesen Objekten darstellt. Beispiele für solche
Mengen von Objekten und Relationen sind die folgenden:
• Objekte: Personen, Relation: A kennt B.
• Objekte: Flughäfen, Relation: es gibt einen Non-Stop-Flug von A nach B.
• Objekte: Methoden eines Java-Programms, Relation: A ruft B auf.
Üblicherweise wird ein Graph durch ein Diagramm beschrieben: Die Objekte
werden als Knoten dargestellt, und die Relation zwischen zwei Objekten wird
durch eine Kante zwischen den entsprechenden Knoten beschrieben. Im allgemeinen sind Kanten als Pfeile dargestellt, d.h. wir haben gerichtete Kanten. In
diesem Fall sprechen wir auch von einem gerichteten Graphen. Ist die betrachtete Relation E aber symmetrisch (d.h. wenn aus (a, b) ∈ E stets (b, a) ∈ E folgt)
dann ersetzen wir die beiden gerichteten Kanten a → b und b → a durch eine
ungerichtete Kante a − b. In diesem Fall sprechen wir von einem ungerichteten
Graphen.
89:;
?>=<
B 0
89:;
?>=<
1
?>=<
89:;
2
89:;
?>=<
0
===
==
89:;
?>=<
89:;
?>=<
1=
2
==
== 89:;
?>=<
3
ein gerichteter Graph
ein ungerichteter Graph
149
150
4.1
KAPITEL 4. GRAPHEN UND GRAPH-ALGORITHMEN
Gerichtete Graphen
Definition 4.1.1. Ein gerichteter Graph G = (V, E) besteht aus einer endlichen Menge V von Knoten und einer Menge E ⊆ V × V von Kanten.
Beispiel 4.1.2. Der Graph G1 = ({0, 1, 2}, {(0, 1), (1, 0), (1, 2)}) ist gerade der
durch das obige Diagramm beschriebene gerichtete Graph.
Eine Kante (u, v) ∈ E hat den Startknoten u und den Zielknoten v. Die Kante
(u, v) ist mit den Knoten u und v inzident, und die Knoten u und v sind adjazent
oder benachbart.
Definition 4.1.3. Sei G = (V, E) ein gerichteter Graph.
• Ein Weg in G ist eine Folge (v0 , v1 , . . . , vr ) von Knoten mit (vi , vi+1 ) ∈ E
für 0 ≤ i < r. Die Länge dieses Wegs ist die Anzahl r der durchlaufenen
Kanten. Ein Weg heißt einfach, wenn alle auftretenden Knoten paarweise
verschieden sind, wobei die Ausnahme v0 = vr zugelassen ist.
• Ein Teilgraph von G ist ein Graph G0 = (V 0 , E 0 ) mit V 0 ⊆ V und E 0 ⊆ E.
• Zwei Knoten u, v ∈ V heißen stark verbunden, wenn es einen Weg von u
nach v und einen Weg von v nach u gibt. Eine stark verbundene Komponente (eine starke Komponente) ist ein Teilgraph mit maximaler Knotenzahl, in dem alle Knoten paarweise stark verbunden sind. Hat G nur eine
starke Komponente, so heißt G stark verbunden.
Beispiel 4.1.4. (Fortsetzung von Beispiel 4.1.2) Der Graph G1 hat die beiden
folgenden starken Komponenten, ist also nicht stark verbunden:
89:;
?>=<
0
z
89:;
?>=<
: 1
und
89:;
?>=<
2
Definition 4.1.5. Der Grad d(v) eines Knotens v ∈ V ist die Anzahl der
Kanten, mit denen v inzident ist. Der Eingangsgrad d+ (v) ist die Anzahl der
Kanten, die v als Zielknoten haben, und der Ausgangsgrad d− (v) ist die Anzahl
der Kanten, die v als Startknoten haben. Offensichtlich gilt d(v) = d+ (v) +
d− (v).
Lemma 4.1.6. Sei G = (V, E) ein gerichteter Graph mit V = {v0 , . . . , vn−1 }.
n−1
n−1
n−1
P
P
P
Dann gilt
d+ (vi ) =
d− (vi ) = |E| und insbesondere
d(vi ) = 2|E|.
i=0
i=0
i=0
In vielen Anwendungen werden Graphen betrachtet, deren Kanten gewisse Ko”
sten“ zugeordnet sind.
Definition 4.1.7. Eine Gewichtungsfunktion c : E → R+ ordnet jeder Kante
e eines Graphen G = (V, E) ein Gewicht c(e) (die Kosten der Kante e) zu.
4.1. GERICHTETE GRAPHEN
151
Als erstes wollen wir uns nun der Implementierung von gerichteten Graphen
(mit oder ohne Gewichtungsfunktion) zuwenden. Hierfür gibt es mehrere Möglichkeiten.
Definition 4.1.8. Implementierung von Graphen durch Adjazenzmatrizen: Sei
G = (V, E) mit V = {v0 , . . . , vn−1 } ein gerichteter Graph. Die Adjazenzmatrix
für G ist die boolesche (n × n)-Matrix (Ai,j )0≤i,j<n mit
true falls (vi , vj ) ∈ E,
Ai,j =
false sonst.
Die markierte Adjazenzmatrix für G bezüglich einer Gewichtungsfunktion c :
E → R+ ist die (n × n)-Matrix (Mi,j )0≤i,j<n über R+ ∪ {0} mit
c(vi , vj ) falls (vi , vj ) ∈ E,
Mi,j =
0
sonst.
Der Wert 0 besagt hierbei also, dass eine Kante in G nicht enthalten ist.
Für die Adjazenzmatrix braucht man Speicherplatz O(n2 ), unabhängig von der
Anzahl der vorhandenen Kanten. Ihr Vorteil besteht darin, dass man in Zeit
O(1) feststellen kann, ob eine Kante von vi nach vj führt. Ferner gelten folgende
Gleichungen für alle Knoten vi , wenn man true mit 1 und false mit 0 identifiziert:
d− (vi ) =
n−1
X
Ai,j
und d+ (vi ) =
j=0
n−1
X
Aj,i .
j=0
Beispiel 4.1.9. (Fortsetzung von Beispiel 4.1.2) Die Adjazenzmatrix für G1 :


0 1 0
1 0 1
0 0 0
Programm 4.1.10. Eine Implementierung gerichteter Graphen über Adjazenzmatrizen:
1
import java.util.Random;
2
/** Die Klasse DirectedGraph implementiert gerichtete Graphen.
* Die Knoten sind Zahlen >= 0 vom Typ int.
5
*/
6 public class DirectedGraph {
3
4
7
8
9
10
11
/** Die Anzahl der Knoten des Graphen.
* Knoten sind Zahlen v mit 0 <= v < N.
*/
private final int N;
12
13
14
15
/** Die Adjazenzmatrix des Graphen. */
private boolean[ ][ ] matrix;
152
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
KAPITEL 4. GRAPHEN UND GRAPH-ALGORITHMEN
/** Konstruiert einen Graphen mit n Knoten und keiner Kante. */
public DirectedGraph( int n ) {
N = n;
matrix = new boolean[N][N];
}
/** Konstruiert einen Zufallsgraphen mit n Knoten.
* Jede Kante ist mit Wahrscheinlichkeit p vorhanden.
*/
public DirectedGraph( int n, double p ) {
this( n );
for( int v = 0; v < N; v++ )
for( int w = 0; w < N; w++ )
if( Math.random( ) < p )
insertEdge( v, w );
}
/** Gibt die Knotenzahl zurueck. */
public int nodeSize( ) {
return N;
}
/** Gibt die Kantenzahl zurueck. */
public int edgeSize( ) {
int e = 0;
for( int v = 0; v < N; v++ )
for( int w = 0; w < N; w++ )
e += isEdge( v, w ) ? 1 : 0;
return e;
}
/** Gibt true zurueck, wenn eine Kante zwischen Knoten v und w vorhanden ist,
* sonst false. Falls nicht 0 <= v, w < N gilt, wird eine Ausnahme ausgeloest.
*/
public boolean isEdge( int v, int w ) {
if( v < 0 | | w < 0 | | v >= N | | w >= N )
throw new IllegalArgumentException( "Knoten nicht vorhanden." );
return matrix[v][w];
}
/** Fuegt eine Kante zwischen Knoten v und w ein.
* Falls nicht 0 <= v, w < N gilt, wird eine Ausnahme ausgeloest.
*/
public void insertEdge( int v, int w ) {
if( v < 0 | | w < 0 | | v >= N | | w >= N )
throw new IllegalArgumentException( "Knoten nicht vorhanden." );
matrix[v][w] = true;
}
/** Entfernt die Kante zwischen Knoten v und w, falls vorhanden.
* Falls nicht 0 <= v, w < N gilt, wird eine Ausnahme ausgeloest.
*/
DirectedGraph
DirectedGraph
nodeSize
edgeSize
isEdge
insertEdge
4.1. GERICHTETE GRAPHEN
68
69
70
71
72
153
public void deleteEdge( int v, int w ) {
if( v < 0 | | w < 0 | | v >= N | | w >= N )
throw new IllegalArgumentException( "Knoten nicht vorhanden." );
matrix[v][w] = false;
}
deleteEdge
73
74
75
76
77
78
79
80
/** Gibt den String s n-fach konkateniert zurueck. */
private String conc( String s, int n ) {
StringBuffer out = new StringBuffer( );
for( int i = 0; i < n; i++ )
out.append( s );
return out.toString( );
}
conc
81
82
83
84
85
/** Gibt die Zahl i als String der Laenge n rechtsbuendig zurueck. */
private String print( int i, int n ) {
return conc( " ", n − Integer.toString( i ).length( ) ) + i;
}
print
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
/** Gibt eine String-Darstellung des Graphen zurueck. */
public String toString( ) {
StringBuffer out = new StringBuffer( );
int n = Integer.toString( N ).length( ); // die Laenge der Darstellung von N
// Die Spaltenueberschrift:
out.append( conc( " ", n+2 ) + "|" );
for( int v = 0; v < N; v++ )
out.append( print( v, n+1 ) );
out.append( "\n" + conc( "_", n+2 ) + "|" + conc( "_", N*(n+1) ) + "\n" );
// Die Matrix:
for( int v = 0; v < N; v++ ) {
out.append( print( v, n+1 ) + " |" );
for( int w = 0; w < N; w++ )
out.append( print( ( isEdge( v, w ) ? 1 : 0 ), n+1 ) );
out.append( "\n" );
}
return out.toString( );
}
105
106
} // class DirectedGraph
Viele Probleme für Graphen lassen sich leicht lösen, wenn die betrachteten
Graphen durch Adjazenzmatrizen dargestellt sind. Allerdings benötigt man für
das Lesen der Eingabe dann schon O(n2 ) Schritte, unabhängig davon, wieviele
Kanten der jeweilige Graph enthält. Für Graphen mit wenigen Kanten empfiehlt
sich daher eine andere Art der Speicherung.
Definition 4.1.11. Implementierung von Graphen durch Adjazenzlisten: Sei
G = (V, E) mit V = {v0 , . . . , vn−1 } ein gerichteter Graph. Wir stellen nun die
Zeilen der Adjazenzmatrix für G durch verkettete Listen dar. Für 0 ≤ i < n gibt
es eine Liste, die für jede von vi ausgehende Kante einen Knoten enthält, welcher
toString
154
KAPITEL 4. GRAPHEN UND GRAPH-ALGORITHMEN
das Ziel der jeweiligen Kante angibt. Außerdem gibt es für jede dieser Listen
einen Kopfknoten der auf diese Liste verweist. Die Kopfknoten sind sequentiell
angeordnet.
Beispiel 4.1.12. (Fortsetzung von Beispiel 4.1.2) Die Adjazenzlisten für G1 :
0
b
1
1
ab
0
2
"
""
2
"
""
!!
!!
!
!
Bemerkung 4.1.13.
• Bei Graphen mit n Knoten und e Kanten entstehen
n + e Listenknoten, der Platzbedarf ist daher O((n + e) · log n) Bits. Für
e n2 spart man mit der Darstellung durch Adjazenzlisten also Platz.
• Für 0 ≤ i < n ist d− (vi ) die Länge der vi zugeordneten Liste. Die Bestimmung von d+ (vi ) ist wesentlich aufwändiger!
• Um zu prüfen, ob die Kante (vi , vj ) im Graphen G enthalten ist, muss
man die vi zugeordnete Liste durchsuchen: Zeitbedarf O(d− (vi )).
• Die Repräsentation von Graphen durch Adjazenzlisten erlaubt auch Graphen mit Mehrfachkanten, ebenso allgemeinere Gewichtungsfunktionen
c : E → R.
Programm 4.1.14. Eine Implementierung gerichteter Graphen mit Gewichtsfunktion über Adjazenzlisten:
import java.util.LinkedList;
import java.util.Iterator;
3 import java.util.Random;
1
2
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/** Die Klasse WeightedDirectedGraph implementiert gerichtete Graphen mit
* Gewichtungsfunktion ueber Adjazenzlisten. Knoten sind Zahlen >= 0 vom
* Typ int, Gewichte sind Zahlen >= 0 vom Typ double.
* Mehrfachkanten sind zugelassen (auch mit demselben Gewicht).
*/
public class WeightedDirectedGraph {
/** Die Anzahl der Knoten des Graphen.
* Knoten sind Zahlen v mit 0 <= v < N.
*/
private final int N;
/** Repraesentiert unendliches Gewicht. */
private static final double INFINITE = Double.MAX VALUE;
/** Die Klasse Edge implementiert Kanten des gerichteten Graphen.
* Kanten sind Paare aus Zielknoten und Gewicht. Der Startknoten der Kante
* ist implizit durch die Zugehoerigkeit zu einer Adjazenzliste bestimmt.
*/
4.1. GERICHTETE GRAPHEN
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
public class Edge implements Comparable {
/** Der Zielknoten der Kante. */
private int destination;
/** Das Gewicht der Kante. */
private double weight;
/** Konstruiert eine Kante mit Zielknoten destination und Gewicht weight.
* Falls nicht 0 <= destination < N und weight >= 0 gilt, wird eine
* Ausnahme ausgeloest.
*/
public Edge( int destination, double weight ) {
if( destination < 0 | | destination >= nodeSize( ) )
throw new IllegalArgumentException( "Knoten nicht vorhanden." );
if( weight < 0 )
throw new IllegalArgumentException( "Gewicht ist negativ." );
this.destination = destination;
this.weight = weight;
}
/** Kanten werden bezueglich ihres Gewichts verglichen. */
public int compareTo( Object otherEdge ) {
double otherWeight = ( (Edge)otherEdge ).weight;
return weight < otherWeight ? −1 : weight > otherWeight ? +1 : 0;
}
/** Gibt eine String-Darstellung der Kante zurueck. */
public String toString( ) {
return "(" + destination + ", " + weight + ")";
}
56
57
} // class Edge
58
59
/** Die Adjazenzlisten des Graphen. Sie enthalten Objekte vom Typ Edge. */
private LinkedList[ ] adjacencyList;
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
155
/** Konstruiert einen Graphen mit n Knoten und keiner Kante. */
public WeightedDirectedGraph( int n ) {
N = n;
adjacencyList = new LinkedList[N];
for( int v = 0; v < N; v++ )
adjacencyList[v] = new LinkedList( );
}
/** Konstruiert einen Zufallsgraphen mit n Knoten ohne Mehrfachkanten.
* Jede Kante ist mit Wahrscheinlichkeit p vorhanden. Das Gewicht einer
* Kante ist als Zahl vom Typ int gleichverteilt aus [0, maxWeight) gewaehlt.
* (Beachte, dass die entstehenden Adjazenzlisten bzgl. Knotennummern
* aufsteigend sortiert sind.)
*/
Edge
compareTo
toString
WeightedDirectedGraph
156
75
76
77
78
79
80
81
KAPITEL 4. GRAPHEN UND GRAPH-ALGORITHMEN
public WeightedDirectedGraph( int n, double p, double maxWeight ) {
this( n );
for( int v = 0; v < N; v++ )
for( int w = 0; w < N; w++ )
if( Math.random( ) < p )
insertEdge( v, w, (int)( Math.random( ) * maxWeight ) );
}
WeightedDirectedGraph
82
83
84
85
86
/** Gibt die Knotenzahl zurueck. */
public int nodeSize( ) {
return N;
}
nodeSize
87
88
89
90
91
92
93
94
/** Gibt die Kantenzahl zurueck. */
public int edgeSize( ) {
int e = 0;
for( int v = 0; v < N; v++ )
e += adjacencyList[v].size( );
return e;
}
edgeSize
95
96
97
98
99
100
101
102
103
/** Fuegt eine Kante zwischen Knoten v und w mit Gewicht weight ein. Falls
* nicht 0 <= v,w < N und weight >= 0 gilt, wird eine Ausnahme ausgeloest.
*/
public void insertEdge( int v, int w, double weight ) {
if( v < 0 | | v >= N )
throw new IllegalArgumentException( "Knoten nicht vorhanden." );
adjacencyList[v].add( new Edge( w, weight ) );
}
insertEdge
die Methoden conc und print wie in Programm 4.1.10
118
119
120
121
122
123
124
125
126
127
128
129
130
/** Gibt eine String-Darstellung des Graphen zurueck. */
public String toString( ) {
StringBuffer out = new StringBuffer( );
int n = Integer.toString( N ).length( ); // die Laenge der Darstellung von N
for( int v = 0; v < N; v++ ) {
out.append( print( v, n+1 ) + " : " );
Iterator it = adjacencyList[v].iterator( );
while( it.hasNext( ) )
out.append( it.next( ) + " -> " );
out.append( "null\n" );
}
return out.toString( );
}
toString
131
132
133
134
135
public static void main( String[ ] args ) {
WeightedDirectedGraph g = new WeightedDirectedGraph( 12, 0.2, 100 );
System.out.println( "\nEin Zufallsgraph:\n\n" + g );
}
136
137
} // class WeightedDirectedGraph
main
4.1. GERICHTETE GRAPHEN
157
Ein Testlauf:
Ein Zufallsgraph:
0
1
2
3
4
5
6
7
8
9
10
11
:
:
:
:
:
:
:
:
:
:
:
:
(1, 71.0) -> (7, 98.0) -> (10, 78.0) -> null
(6, 55.0) -> (7, 33.0) -> null
(0, 17.0) -> (10, 86.0) -> (11, 27.0) -> null
(3, 66.0) -> (7, 75.0) -> (10, 33.0) -> null
null
(1, 81.0) -> null
(2, 61.0) -> (8, 86.0) -> null
(2, 88.0) -> (9, 40.0) -> null
(1, 71.0) -> (2, 62.0) -> (3, 60.0) -> null
(2, 4.0) -> null
(6, 53.0) -> null
(1, 24.0) -> (5, 26.0) -> (8, 13.0) -> (9, 0.0) -> null
Muss man oft auf alle die Knoten zugreifen, von denen aus eine Kante zu einem
festen Knoten führt, so speichert man für jeden Knoten eine zusätzliche Liste.
Definition 4.1.15. Implementierung von Graphen durch Adjazenzlisten und
inverse Adjazenzlisten: Sei G = (V, E) mit V = {v0 , . . . , vn−1 } ein gerichteter
Graph. Außer den Adjazenzlisten für G nehmen wir nun auch noch verkettete
Listen zur Darstellung der Spalten der Adjazenzmatrix von G. Für 0 ≤ i < n
gibt es eine Liste, die für jede in vi endende Kante einen Knoten enthält, welcher
den Start der jeweiligen Kante angibt. Außerdem gibt es für jede dieser Listen
einen Kopfknoten, welche wieder sequentiell angeordnet werden.
Beispiel 4.1.16. (Forts. Beispiel 4.1.2) Die inversen Adjazenzlisten für G1 :
0
b
1
1
ab
0
2
b
1
"
""
"
""
Stellt man einen gerichteten Graphen durch Adjazenzlisten und inverse Adjazenzlisten dar, so werden für jede auftretende Kante zwei Listenknoten angelegt.
Oftmals will man beim Durchsuchen eines Graphen alle bereits benutzten Kanten markieren, was bei dieser Art der Darstellung aufwändig ist. Um diesem
Problem abzuhelfen, kann man beide Listenstrukturen mit Hilfe gemeinsamer
Knoten als Multilisten speichern.
Definition 4.1.17. Implementierung von Graphen durch Adjazenzmultilisten:
Sei G = (V, E) mit V = {v0 , . . . , vn−1 } ein gerichteter Graph. Für jeden Knoten
vi implementieren wir die Adjazenzliste und die inverse Adjazenzliste wie folgt.
Wieder gibt es für jede dieser Listen einen Kopfknoten. Für jede Kante (vi , vj )
wird nun ein Knoten angelegt, der dann sowohl in der Adjazenzliste von vi
158
KAPITEL 4. GRAPHEN UND GRAPH-ALGORITHMEN
als auch in der inversen Adjazenzliste von vj auftritt. Dazu führen wir einen
Knotentyp mit vier Feldern ein, je ein Feld für Start- und Zielknoten und je ein
Feld für den Verweis auf den in der jeweiligen Liste nächsten Knoten:
start
ziel
slink
zlink
Beispiel 4.1.18. (Forts. von Beispiel 4.1.2) Die Adjazenzmultilisten für G1 :
0
b
1
ab
0
1
2
b
b
0
b
%
%
%
%
"
1 "" 1
0 """
!
!!
2
!
!!
1
2 Tragen die Knoten eines Graphen zusätzlich Information, so kann man diese
entweder in den Kopfknoten der Adjazenzlisten abspeichern, oder man führt in
den Kopfknoten noch zusätzliche Verweise auf die gespeicherte Information ein.
Tragen die Kanten eines Graphen zusätzliche Information (z.B. Gewichte), so
kann man diese analog entweder in den Listenknoten der Adjazenzlisten speichern, oder man hält in den Knoten Verweise auf die gespeicherte Information.
4.1.1
Traversieren von Graphen
Als erstes befassen wir uns mit der folgenden Aufgabe: Sei G ein gerichteter
Graph, und sei v ein Knoten. Bestimme die Menge aller Knoten, die von v aus
erreichbar sind!
Ausgehend vom Knoten v müssen wir den Graph G systematisch durchlaufen,
um alle erreichbaren Knoten zu bestimmen. Für diese Aufgabe werden wir zwei
Lösungen betrachten, Tiefensuche (engl. depth-first search) und Breitensuche
(engl. breadth-first search).
Algorithmus 4.1.19. Traversieren eines Graphen mittels Tiefensuche:
Die Strategie ist, ausgehend vom Startknoten so tief wie möglich in den Graphen einzudringen. Besuchte Knoten werden markiert. Hat ein Knoten u noch
unmarkierte Nachbarn, so besuchen wir als nächstes einen dieser Nachbarn; hat
u keine unmarkierten Nachbarn mehr, so gehen wir den bisher zurückgelegten
Weg soweit zurück, bis wir zu einem Knoten gelangen, der noch unmarkierte
Nachbarn hat.
4.1. GERICHTETE GRAPHEN
159
Zur Vereinfachung wählen wir V = {0, . . . , n−1} und erweitern die Darstellung
gerichteter Graphen aus Programm 4.1.10. Zur Markierung besuchter Knoten
verwenden wir das Array besucht über boolean, wobei besucht[i] = true bedeutet, dass der i-te Knoten schon besucht worden ist.
Zuerst stellen wir eine rekursive Methode vor, die die Liste der von Knoten v
aus erreichbaren Knoten in der durch eine Tiefensuche erzeugten Reihenfolge
zurückgibt.
1
2
3
4
5
6
7
8
9
10
11
12
LinkedList depthFirstSearchRec( int v ) {
return dfs( v, new boolean[N], new LinkedList( ) );
}
private LinkedList dfs( int v, boolean[ ] besucht, LinkedList knotenListe ) {
besucht[ v ] = true;
knotenListe.add( new Integer( v ) );
for( int w = 0; w < N; w++ )
if( isEdge( v, w ) && !besucht[ w ] )
dfs( w, besucht, knotenListe );
return knotenListe;
}
Beispiel 4.1.20. Der folgende Graph hat die Knotenmenge {0, . . . , 7}.
89:;
?>=<
0 NNN
NNN
ppp
p
p
NNN
p
p
p
N'
wpp
89:;
?>=<
89:;
?>=<
1=
2 ^=
=
==
=
==
==
89:;
?>=<
89:;
?>=<
89:;
?>=<
89:;
?>=<
3 Ti TTT
4=
5
jjj 6
TTTT
j
==
@
j
j
TTTT =
jj
TTTT= jjjjjjj
j
u
89:;
?>=<
7
Diese Knoten werden in der Reihenfolge 0, 1, 3, 4, 7, 5, 2 besucht und markiert;
ferner sehen wir, dass Knoten 6 von Knoten 0 aus nicht erreichbar ist.
89:;
?>=<
@0 ==
==
=
89:;
?>=<
89:;
?>=<
2
@1= ^===
=
= ==
==
?>=<
89:;
?>=<
89:;
3
4
O
89:;
?>=<
7
O
89:;
?>=<
5
depthFirstSearchRec
dfs
160
KAPITEL 4. GRAPHEN UND GRAPH-ALGORITHMEN
Bemerkung 4.1.21. Der Aufwand der Tiefensuche hängt von der Darstellung
des Graphen ab.
• Darstellung durch eine Adjazenzmatrix: Zur Bestimmung der Nachbarn
von v muss die v-te Zeile der Matrix durchlaufen werden; insgesamt sind
höchstens n Aufrufe von dfs möglich. Der Algorithmus bestimmt also in
Zeit O(n2 ) die Menge der vom Startknoten aus erreichbaren Knoten.
• Darstellung durch Adjazenzlisten: Zur Bestimmung der Nachbarn von v
muss die Adjazenzliste zu v durchlaufen werden, d.h. es werden alle e
Kanten angeschaut. Insgesamt sind min(n, e) Aufrufe von dfs möglich, der
Algorithmus bestimmt also in Zeit O(e) die Menge der vom Startknoten
aus erreichbaren Knoten.
Die folgende Variante der Tiefensuche ist nicht rekursiv und verwendet einen
Keller zur Organisation der Suche. Man beachte, dass das Ergebnis wegen der
unterschiedliche Markierungsstrategien vom Ergebnis der Methode depthFirstSearchRec abweichen kann, vgl. den Testlauf zu Programm 4.1.26.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
LinkedList depthFirstSearch( int v ) {
boolean[ ] besucht = new boolean[N];
LinkedList knotenListe = new LinkedList( );
Stack knotenKeller = new Stack( );
knotenKeller.push( new Integer( v ) );
besucht[ v ] = true;
while( !knotenKeller.isEmpty( ) ) {
int u = ( (Integer)knotenKeller.topAndPop( ) ).intValue( );
knotenListe.add( new Integer( u ) );
for( int w = N−1; w >= 0; w−− )
if( isEdge( u, w ) && !besucht[ w ] ) {
knotenKeller.push( new Integer( w ) );
besucht[ w ] = true;
}
}
return knotenListe;
}
Beispiel 4.1.22. (Fortsetzung von Beispiel 4.1.20) Die Knoten werden in diesem Beispiel tatsächlich in derselben Reihenfolge wie zuvor besucht:
aktueller Knoten u
–
0
1
3
4
7
5
2
als besucht markiert
{0}
{0, 1, 2}
{0, 1, 2, 3, 4}
{0, 1, 2, 3, 4}
{0, 1, 2, 3, 4, 7}
{0, 1, 2, 3, 4, 7, 5}
{0, 1, 2, 3, 4, 7, 5}
{0, 1, 2, 3, 4, 7, 5}
der Keller
(0)
(1, 2)
(3, 4, 2)
(4, 2)
(7, 2)
(5, 2)
(2)
()
depthFirstSearch
4.1. GERICHTETE GRAPHEN
161
Algorithmus 4.1.23. Traversieren eines Graphen mittels Breitensuche:
Hier ist die Strategie, ausgehend vom Startknoten für jeden besuchten Knoten
als nächstes alle seine Nachbarn zu besuchen. Besuchte Knoten werden wieder
markiert. Ist u der aktuelle Knoten, so werden alle Nachbarn von u, die noch
nicht besucht worden sind, in eine Schlange aufgenommen. Als nächstes wird
dann der Knoten besucht, der an der Spitze der Schlange steht; dabei wird
dieser aus der Schlange entfernt.
Die Methode breadthFirstSearch ist nicht rekursiv und verwendet eine Schlange
zur Organisation der Suche. Sie gibt die Liste der von Knoten v aus erreichbaren
Knoten in der durch eine Breitensuche erzeugten Reihenfolge zurück.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
LinkedList breadthFirstSearch( int v ) {
boolean[ ] besucht = new boolean[N];
LinkedList knotenListe = new LinkedList( );
Queue knotenSchlange = new Queue( );
knotenSchlange.enqueue( new Integer( v ) );
besucht[ v ] = true;
while( !knotenSchlange.isEmpty( ) ) {
int u = ( (Integer)knotenSchlange.frontAndDequeue( ) ).intValue( );
knotenListe.add( new Integer( u ) );
for( int w = 0; w < N; w++ )
if( isEdge( u, w ) && !besucht[ w ] ) {
knotenSchlange.enqueue( new Integer( w ) );
besucht[ w ] = true;
}
}
return knotenListe;
}
Bemerkung 4.1.24. Der Aufwand der Breitensuche hängt wieder von der
Darstellung des Graphen ab. Jeder Knoten wird höchstens einmal in die Schlange aufgenommen. Wird ein Knoten entfernt, so werden alle seine Nachbarn
überprüft und dann gegebenenfalls in die Schlange aufgenommen. Die Menge der erreichbaren Knoten kann also wie zuvor in Zeit O(n2 ) bzw. Zeit O(e)
bestimmt werden, je nachdem, ob Adjazenzmatrizen oder Adjazenzlisten verwendet werden.
Beispiel 4.1.25. (Fortsetzung von Beispiel 4.1.20) Im Beispiel werden die Knoten in der Reihenfolge 0, 1, 2, 3, 4, 5, 7 besucht:
aktueller Knoten u
–
0
1
2
3
4
5
7
als besucht markiert
{0}
{0, 1, 2}
{0, 1, 2, 3, 4}
{0, 1, 2, 3, 4, 5}
{0, 1, 2, 3, 4, 5}
{0, 1, 2, 3, 4, 5, 7}
{0, 1, 2, 3, 4, 5, 7}
{0, 1, 2, 3, 4, 5, 7}
die Schlange
(0)
(1, 2)
(2, 3, 4)
(3, 4, 5)
(4, 5)
(5, 7)
(7)
()
breadthFirstSearch
162
KAPITEL 4. GRAPHEN UND GRAPH-ALGORITHMEN
Programm 4.1.26. Eine Implementierung obiger Traversierungsstrategien:
wie in Programm 4.1.10
75
76
77
78
79
80
81
82
83
/** Gibt die Liste der von Knoten v aus erreichbaren Knoten
* in der durch eine Tiefensuche erzeugten Reihenfolge zurueck.
* Falls nicht 0 <= v < N gilt, wird eine Ausnahme ausgeloest.
*/
public LinkedList depthFirstSearchRec( int v ) {
if( v < 0 | | v >= N )
throw new IllegalArgumentException( "Knoten nicht vorhanden." );
return dfs( v, new boolean[N], new LinkedList( ) );
}
depthFirstSearchRec
84
85
86
87
88
89
90
91
92
93
/** Eine rekursive Hilfsmethode fuer die Methode depthFirstSearchRec. */
private LinkedList dfs( int v, boolean[ ] besucht, LinkedList knotenListe ) {
besucht[ v ] = true;
knotenListe.add( new Integer( v ) );
for( int w = 0; w < N; w++ )
if( isEdge( v, w ) && !besucht[ w ] )
dfs( w, besucht, knotenListe );
return knotenListe;
}
dfs
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
/** Gibt die Liste der von Knoten v aus erreichbaren Knoten mit
* aufsteigenden Knotennummern zurueck.
* Falls nicht 0 <= v < N gilt, wird eine Ausnahme ausgeloest.
*/
public LinkedList accessibleNodes( int v ) {
if( v < 0 | | v >= N )
throw new IllegalArgumentException( "Knoten nicht vorhanden." );
boolean[ ] besucht = dfs( v, new boolean[N] );
LinkedList knotenListe = new LinkedList( );
for( int i = 0; i < N; i++ )
if( besucht[i] )
knotenListe.add( new Integer( i ) );
return knotenListe;
}
accessibleNodes
109
110
111
112
113
114
115
116
117
/** Eine rekursive Hilfsmethode fuer die Methode accessibleNodes. */
private boolean[ ] dfs( int v, boolean[ ] besucht ) {
besucht[ v ] = true;
for( int w = 0; w < N; w++ )
if( isEdge( v, w ) && !besucht[ w ] )
dfs( w, besucht );
return besucht;
}
118
119
120
121
122
123
124
125
126
/** Gibt die Liste der von Knoten v aus erreichbaren Knoten
* in der durch eine Tiefensuche erzeugten Reihenfolge zurueck.
* Falls nicht 0 <= v < N gilt, wird eine Ausnahme ausgeloest.
* Die Methode ist nicht rekursiv und verwendet einen Keller zur
* Organisation der Suche. Beachte, dass das Ergebnis wegen der
* unterschiedliche Markierungsstrategien vom Ergebnis der Methode
* depthFirstSearchRec abweichen kann.
*/
dfs
4.1. GERICHTETE GRAPHEN
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
public LinkedList depthFirstSearch( int v ) {
if( v < 0 | | v >= N )
throw new IllegalArgumentException( "Knoten nicht vorhanden." );
boolean[ ] besucht = new boolean[N];
LinkedList knotenListe = new LinkedList( );
Stack knotenKeller = new Stack( );
knotenKeller.push( new Integer( v ) );
besucht[ v ] = true;
while( !knotenKeller.isEmpty( ) ) {
int u = ( (Integer)knotenKeller.topAndPop( ) ).intValue( );
knotenListe.add( new Integer( u ) );
for( int w = N−1; w >= 0; w−− )
if( isEdge( u, w ) && !besucht[ w ] ) {
knotenKeller.push( new Integer( w ) );
besucht[ w ] = true;
}
}
return knotenListe;
}
/** Gibt die Liste der von Knoten v aus erreichbaren Knoten
* in der durch eine Breitensuche erzeugten Reihenfolge zurueck.
* Falls nicht 0 <= v < N gilt, wird eine Ausnahme ausgeloest.
* Die Methode ist nicht rekursiv und verwendet eine Schlange zur
* Organisation der Suche.
*/
public LinkedList breadthFirstSearch( int v ) {
if( v < 0 | | v >= N )
throw new IllegalArgumentException( "Knoten nicht vorhanden." );
boolean[ ] besucht = new boolean[N];
LinkedList knotenListe = new LinkedList( );
Queue knotenSchlange = new Queue( );
knotenSchlange.enqueue( new Integer( v ) );
besucht[ v ] = true;
while( !knotenSchlange.isEmpty( ) ) {
int u = ( (Integer)knotenSchlange.frontAndDequeue( ) ).intValue( );
knotenListe.add( new Integer( u ) );
for( int w = 0; w < N; w++ )
if( isEdge( u, w ) && !besucht[ w ] ) {
knotenSchlange.enqueue( new Integer( w ) );
besucht[ w ] = true;
}
}
return knotenListe;
}
163
depthFirstSearch
breadthFirstSearch
die Methoden conc, print und toString wie in Programm 4.1.10
205
206
207
208
209
210
public static void main( String[ ] args ) {
DirectedGraph b = new DirectedGraph( 8 );
b.insertEdge( 0, 1 ); b.insertEdge( 0, 2 ); b.insertEdge( 1, 3 );
b.insertEdge( 1, 4 ); b.insertEdge( 2, 5 ); b.insertEdge( 4, 7 );
b.insertEdge( 6, 2 ); b.insertEdge( 6, 7 ); b.insertEdge( 7, 3 );
b.insertEdge( 7, 5 );
main
164
KAPITEL 4. GRAPHEN UND GRAPH-ALGORITHMEN
System.out.println( "Der Graph aus Beispiel 4.1.20:\n\n" + b +
"\nVon Knoten 0 aus erreichbare Knoten:" +
"\nTiefensuche1: " + b.depthFirstSearchRec( 0 ) +
"\nTiefensuche2: " + b.depthFirstSearch( 0 ) +
"\nBreitensuche: " + b.breadthFirstSearch( 0 ) +
"\nKnotenliste: " + b.accessibleNodes( 0 ) );
211
212
213
214
215
216
217
DirectedGraph g = new DirectedGraph( 5, 0.2 );
System.out.println( "\nEin Zufallsgraph:\n\n" + g +
"\nVon Knoten 0 aus erreichbare Knoten:" +
"\nTiefensuche1: " + g.depthFirstSearchRec( 0 ) +
"\nTiefensuche2: " + g.depthFirstSearch( 0 ) +
"\nBreitensuche: " + g.breadthFirstSearch( 0 ) +
"\nKnotenliste: " + g.accessibleNodes( 0 ) );
218
219
220
221
222
223
224
225
}
226
227
} // class DirectedGraph
Ein Testlauf:
Der Graph aus Beispiel 4.1.20:
| 0 1 2 3 4 5 6 7
___|________________
0 | 0 1 1 0 0 0 0 0
1 | 0 0 0 1 1 0 0 0
2 | 0 0 0 0 0 1 0 0
3 | 0 0 0 0 0 0 0 0
4 | 0 0 0 0 0 0 0 1
5 | 0 0 0 0 0 0 0 0
6 | 0 0 1 0 0 0 0 1
7 | 0 0 0 1 0 1 0 0
Von Knoten 0 aus erreichbare Knoten:
Tiefensuche1: [0, 1, 3, 4, 7, 5, 2]
Tiefensuche2: [0, 1, 3, 4, 7, 5, 2]
Breitensuche: [0, 1, 2, 3, 4, 5, 7]
Knotenliste: [0, 1, 2, 3, 4, 5, 7]
Ein Zufallsgraph:
| 0 1 2 3 4
___|__________
0 | 0 1 0 1 0
1 | 0 0 1 0 1
2 | 0 0 0 1 0
3 | 0 0 0 0 0
4 | 0 0 0 0 0
Von Knoten 0 aus erreichbare Knoten:
Tiefensuche1: [0, 1, 2, 3, 4]
Tiefensuche2: [0, 1, 2, 4, 3]
Breitensuche: [0, 1, 3, 2, 4]
Knotenliste: [0, 1, 2, 3, 4]
4.1. GERICHTETE GRAPHEN
4.1.2
165
Kürzeste Wege
Hier wollen wir uns dem Problem zuwenden, kürzeste Wege“ in einem gewich”
teten Graphen zu bestimmen.
Definition 4.1.27. Sei G = (V, E) ein gerichteter Graph, und sei c : E → R+
eine Gewichtungsfunktion. Wir erweitern
Gewichtungsfunktion von Kanten
Pdie
r−1
auf Wege p = (u0 , . . . , ur ) durch c(p) = i=0 c((ui , ui+1 )). Ferner zeichnen wir
einen Knoten v0 ∈ V als Startknoten aus. Für v ∈ V sei dann die Distanz zu
v0 der Wert
dist(v) = min{c(p) | p ist ein Weg von v0 nach v},
falls es einen Weg von v0 nach v gibt; andernfalls setzen wir dist(v) = ∞. Ein
Weg p von v0 nach v mit c(p) = dist(v) ist ein kürzester Weg von v0 nach v.
Wir suchen nun einen Algorithmus, der für alle Knoten v den Wert dist(v)
bestimmt, und der für Knoten v mit dist(v) < ∞ einen kürzesten Weg von v0
nach v liefert.
Bemerkung 4.1.28. Ist c(e) = 1 für alle e ∈ E, so ist dist(v) gerade die Anzahl der Kanten in einem kürzesten Weg von v0 nach v. Da der Algorithmus
breadthFirstSearch ausgehend von v0 die Knoten von G gerade in der Reihenfolge ihres Abstands von v0 besucht, können wir in diesem Fall eine Variante
des genannten Algorithmus zur Bestimmung der Abstände benutzen.
Sei nun G = (V, E), c : E → R+ und v0 ∈ V wie oben. Wir definieren Teilmengen S, T ⊆ V , die in unserem Algorithmus zur Bestimmung der Abstände
verwendet werden:
S = {v ∈ V | dist(v) ist bereits bestimmt},
T = {w ∈ V \ S | ∃v ∈ S : (v, w) ∈ E}.
Am Anfang wird S = {v0 } gesetzt, und T besteht gerade aus den Zielknoten
aller Kanten mit Start v0 . Seien nun zu einem Zeitpunkt S und T wie oben.
Man wählt nun einen Knoten w ∈ T so aus, dass folgende Bedingung für alle
Knoten z ∈ T erfüllt ist:
min{dist(v) + c((v, w))} ≤ min{dist(v) + c((v, z))}.
v∈S
v∈S
Dann kann w in S aufgenommen und aus T entfernt werden, und alle Zielknoten
von Kanten mit Start w, die nicht in S liegen, werden in T aufgenommen. Die
Korrektheit dieser Wahl ist der Gegenstand des folgenden Lemmas.
Lemma 4.1.29. Mit den obigen Bezeichnungen gilt
dist(w) = min{dist(v) + c((v, w))}.
v∈S
166
KAPITEL 4. GRAPHEN UND GRAPH-ALGORITHMEN
Beweis: Ist v ∈ S, so gibt es einen kürzesten Weg von v0 nach v, der nur
Knoten in S durchläuft. Sei nun v ∈ S mit
dist(v) + c((v, w)) = min{dist(x) + c((x, w))},
x∈S
und sei (v0 , v1 , . . . , vm , v) ein kürzester Weg von v0 nach v mit v1 , . . . , vm ∈ S.
Wir behaupten, dass (v0 , v1 , . . . , vm , v, w) ein kürzester Weg von v0 nach w ist.
Sei etwa (v0 , x1 , . . . , xk , w) ein weiterer Weg von v0 nach w. Da v0 ∈ S und
w 6∈ S sind, gibt es einen kleinsten Index i, 1 ≤ i ≤ k + 1, mit xi−1 ∈ S und
xi 6∈ S, wobei wir x0 = v0 und xk+1 = w setzen. Dann ist xi ∈ T , und es gilt
k
X
c((xj , xj+1 )) = dist(xi−1 ) + c((xi−1 , xi )) +
j=0
k
X
c((xj , xj+1 ))
j=i
≥ dist(xi−1 ) + c((xi−1 , xi ))
≥ dist(v) + c((v, w)) (nach Wahl von w).
Also ist (v0 , v1 , . . . , vm , v, w) tatsächlich ein kürzester Weg von v0 nach w, d.h.
es gilt dist(w) = min{dist(v) + c((v, w))}.
v∈S
Dieses Ergebnis liefert die Strategie für den folgenden Algorithmus. In jedem
Schritt wird ein Knoten w wie beschrieben gewählt. Zur Bestimmung eines derartigen Knotens mit minimaler Distanz speichern wir Knoten zusammen mit ihrem vorläufigen Distanzwert in einem Heap. Solche Paare werden bezüglich der
Distanz geordnet; dafür eignet sich gerade der Typ Edge aus Programm 4.1.14.
Zusätzlich wird für jeden Knoten w der aktuell bestimmte Wert dist(w) gespeichert, sowie der Knoten v = vorgänger(w) als Vorgänger von w auf dem aktuell
gefundenen kürzesten Weg von v0 nach w. Zu Beginn setzen wir vorgänger(v) =
−1 und dist(v) = ∞ für alle Knoten v. Die Menge T ist nur indirekt dargestellt:
w ∈ V \ S gehört genau dann zu T , wenn dist(w) < ∞ ist.
Immer wenn sich der Wert von dist(w) verringert, wird das Paar (w, dist(w)) in
den Heap eingefügt. Das hat zur Folge, dass der Heap mehrere Paare mit derselben Knotenkomponente enthalten kann. Daher sind nicht immer alle Knotenkomponenten im Heap auch Elemente von T . (Alternativ kann man Heaps so
modifizieren, dass nur Knoten statt derartige Paare abgelegt werden. Da aber
nach Distanzen geordnet wird, muss dann jedesmal bei Verringerung eines Distanzwerts der entsprechende Knoten wieder an die richtige Stelle im Heap
gebracht werden.)
Dijkstras Algorithmus ist ein typisches Beispiel für einen Greedy“-Algorithmus
”
(greedy: engl. gierig, gefräßig). Solche Algorithmen werden meist zur Lösung
von Optimierungsproblemen eingesetzt, also zum Finden einer bezüglich einer
Zielfunktion optimalen (minimalen, maximalen, . . . ) Lösung des gegebenen
Problems. Ein Greedy-Algorithmus nähert sich schrittweise einer Lösung, wobei
in jedem einzelnen Schritt die Zielfunktion optimiert wird; ein Zurücknehmen
früherer Schritte (backtracking) ist nicht vorgesehen. Leider lassen sich mit
dieser Lösungsstrategie nur wenige Optimierungsprobleme lösen; in anderen
Fällen kommt man nicht umhin, viele mögliche Lösungen mehr oder weniger
geschickt durchzuprobieren.
4.1. GERICHTETE GRAPHEN
167
Algorithmus 4.1.30. Dijkstras Algorithmus zur Bestimmung kürzester Wege:
1
2
3
4
5
6
7
8
shortestPaths( Knoten start ) {
for all( v ∈ V ) {
vorgänger(v) = −1;
dist(v) = ∞;
}
dist(start) = 0;
Heap heap = new Heap( |E| );
heap.einfuegenInHeap( (start, 0) );
9
10
while( !heap.istLeer( ) ) {
Knoten v = heap.loeschenAusHeap( ).zielknoten;
if( v 6∈ S ) {
S = S ∪ {v};
for all( (v, w) ∈ E ) {
if( dist(w) > dist(v) + c((v, w)) ) {
dist(w) = dist(v) + c((v, w));
vorgänger(w) = v;
heap.einfuegenInHeap( (w, dist(w)) );
}
}
}
}
11
12
13
14
15
16
17
18
19
20
21
22
23
shortestPaths
}
Beispiel 4.1.31. Gesucht sind kürzeste Wege zwischen Städten der USA:
Chicago
S.F.
Denver
1200
1500
3
Boston
4
aa
aa 1000
1
2
aa
aa
250
"
aa "
a
"
300
"
5 N.Y.
" 1000
!!
"
!
"
!
"
!!
0 XXX
1400!!
XXX 1700
!
XXX
!!
900
L.A.
!
XXX
!
XXX
!!
7 hh
hhh1000
hhhh h 6
New Orleans
800
Miami
ausgew.
Knoten
4
5
6
3
7
2
1
0
S
{}
{4}
{4, 5}
{4, 5, 6}
{4, 5, 6, 3}
{4, 5, 6, 3, 7}
{4, 5, 6, 3, 7, 2}
{4, 5, 6, 3, 7, 2, 1}
{4, 5, 6, 3, 7, 2, 1, 0}
dist(0) dist(1) dist(2) dist(3) dist(4) dist(5) dist(6) dist(7)
∞
∞
∞
∞
0
∞
∞
∞
∞
∞
∞
1500
0
250
∞
∞
∞
∞
∞
1250
0
250
1150
1650
∞
∞
∞
1250
0
250
1150
1650
∞
∞
2450
1250
0
250
1150
1650
3350
∞
2450
1250
0
250
1150
1650
3350
3250
2450
1250
0
250
1150
1650
3350
3250
2450
1250
0
250
1150
1650
3350
3250
2450
1250
0
250
1150
1650
168
KAPITEL 4. GRAPHEN UND GRAPH-ALGORITHMEN
Der Aufwand von Dijkstras Algorithmus hängt wieder von der Darstellung der
Graphen ab.
• Sind Graphen durch Adjazenzmatrizen implementiert, dann liefert eine
einfache Implementierung ohne Verwendung der Datenstrukur Heap eine
Laufzeit von O(n2 ).
• Für dünne“ Graphen mit wenigen Kanten (|E| |V |2 ) kann der Zeitbe”
darf noch verringert werden. Dafür verwenden wir die Graphdarstellung
durch Adjazenzlisten. Außerdem speichern wir die Paare (w, dist(w)) wie
oben beschrieben in einem Heap. Der Heap kann maximal die Größe |E|
haben, und es werden maximal |E| Einfüge- bzw. Löschoperationen durchgeführt. Daher ist die Laufzeit in O(|E| log |E|); wegen |E| ≤ |V |2 gilt
aber log |E| ≤ log |V |2 = 2 log |V |, also erhalten wir eine Laufzeit in
O(|E| log |V |). (Bemerkung: Damit ist die Laufzeit höchstens um einen
konstanten Faktor schlechter als bei Verwendung eines Heaps, der lediglich Knoten speichert, also maximal die Größe |V | hat.)
Programm 4.1.32. Die folgende Implementierung von Dijkstras Algorithmus
erweitert Programm 4.1.14, das gewichtete Graphen über Adjazenzlisten darstellt. Der Wert ∞ wird dabei durch double.MAX VALUE repräsentiert. Die
Funktionen vorgänger und dist werden in den Arrays vorgaenger und distanz
gespeichert. Dijkstras Algorithmus findet sich im Konstruktor der inneren Klasse ShortestPaths; dort ist die Knotenmenge S durch ein Boolesches Array inS
realisiert. Heaps schließlich sind aus Programm 2.3.14 übernommen.
wie in Programm 4.1.14
105
106
107
108
/** Die Klasse implementiert die kuerzesten Wege im Graphen
* von einem Startknoten aus.
*/
public class ShortestPaths {
109
110
111
/** Der Startknoten. */
private int start;
112
113
114
115
116
117
/** Nach der Konstruktion ist vorgaenger[v] der Knoten, der auf den
* gefundenen kuerzesten Wegen vor Knoten v liegt. Fuer den Startknoten
* und fuer unerreichbare Knoten ist der Wert -1.
*/
private int[ ] vorgaenger = new int[ nodeSize( ) ];
118
119
120
121
122
/** Nach der Konstruktion ist distanz[v] der Abstand von Knoten v
* zum Startknoten.
*/
private double[ ] distanz = new double[ nodeSize( ) ];
123
124
125
126
/** Dijkstras Algorithmus bestimmt die kuerzesten Wege
* vom Startknoten start aus.
*/
nodeSize
4.1. GERICHTETE GRAPHEN
127
128
129
130
131
132
133
134
135
136
137
169
public ShortestPaths( int start ) {
if( start < 0 | | start >= nodeSize( ) )
throw new IllegalArgumentException( "Startknoten nicht vorhanden." );
this.start = start;
for( int v = 0; v < nodeSize( ); v++ ) {
vorgaenger[v] = −1;
distanz[v] = INFINITE;
}
// inS[v] ist true genau dann, wenn v in S ist; zu Beginn ist S leer.
boolean[ ] inS = new boolean[ nodeSize( ) ];
distanz[ start ] = 0;
138
VollstaendigerBinaererBaum heap =
new VollstaendigerBinaererBaum( edgeSize( ) );
heap.einfuegenInHeap( new Edge( start, 0 ) );
139
140
141
142
while( !heap.istLeer( ) ) {
int v = ( (Edge)heap.loeschenAusHeap( ) ).destination;
if( !inS[v] ) { // falls v nicht in S ist:
inS[v] = true; // v wird neues Element in S
Iterator it = adjacencyList[v].iterator( );
while( it.hasNext( ) ) {
Edge e = (Edge)it.next( );
int w = e.destination;
double c = e.weight;
if( distanz[w] > distanz[v] + c ) {
distanz[w] = distanz[v] + c;
vorgaenger[w] = v;
heap.einfuegenInHeap( new Edge( w, distanz[w] ) );
}
}
}
}
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
}
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
/** Gibt eine String-Darstellung der kuerzesten Wege
* mit Startknoten start zurueck.
*/
public String toString( ) {
StringBuffer out = new StringBuffer( );
out.append( "Distanzen zum Startknoten " + start + ":\n\n" );
for( int v = 0; v < nodeSize( ); v++ )
if( distanz[v] == INFINITE )
out.append( " " + v + " ist nicht erreichbar\n" );
else
out.append( " dist(" + v + ") = " + distanz[v] + "\n" );
out.append( "\nKuerzeste Wege mit Startknoten " + start + ":\n\n" );
for( int v = 0; v < nodeSize( ); v++ ) {
Stack path = new Stack( );
int w = v;
do {
path.push( new Integer( w ) );
w = vorgaenger[w];
}
while( w != −1 );
toString
170
KAPITEL 4. GRAPHEN UND GRAPH-ALGORITHMEN
if( distanz[v] == INFINITE )
out.append( " Ziel " + v + ": ist nicht erreichbar\n" );
else
out.append( " Ziel " + v + ": " + path + "\n" );
182
183
184
185
}
return out.toString( );
186
187
}
188
189
190
} // class ShortestPaths
die Methoden conc, print und toString wie in Programm 4.1.14
220
221
222
223
224
225
226
227
228
229
public static void main( String[ ] args ) {
WeightedDirectedGraph usa = new WeightedDirectedGraph( 8 );
usa.insertEdge( 1, 0, 300 ); usa.insertEdge( 2, 1, 800 );
usa.insertEdge( 2, 0, 1000 ); usa.insertEdge( 3, 2, 1200 );
usa.insertEdge( 4, 3, 1500 ); usa.insertEdge( 4, 5, 250 );
usa.insertEdge( 5, 3, 1000 ); usa.insertEdge( 5, 6, 900 );
usa.insertEdge( 5, 7, 1400 ); usa.insertEdge( 6, 7, 1000 );
usa.insertEdge( 7, 0, 1700 );
System.out.println( "Der Graph aus Beispiel 4.1.31:\n\n" +
usa + "\n" + usa.new ShortestPaths( 4 ) );
230
WeightedDirectedGraph g = new WeightedDirectedGraph( 10, 0.2, 100 );
System.out.println( "\nEin Zufallsgraph:\n\n" +
g + "\n" + g.new ShortestPaths( 0 ) );
231
232
233
234
}
235
236
} // class WeightedDirectedGraph
Ein Testlauf:
Der Graph aus Beispiel 4.1.31:
0
1
2
3
4
5
6
7
:
:
:
:
:
:
:
:
null
(0, 300.0) -> null
(1, 800.0) -> (0, 1000.0) -> null
(2, 1200.0) -> null
(3, 1500.0) -> (5, 250.0) -> null
(3, 1000.0) -> (6, 900.0) -> (7, 1400.0) -> null
(7, 1000.0) -> null
(0, 1700.0) -> null
Distanzen zum Startknoten 4:
dist(0)
dist(1)
dist(2)
dist(3)
dist(4)
dist(5)
dist(6)
dist(7)
=
=
=
=
=
=
=
=
3350.0
3250.0
2450.0
1250.0
0.0
250.0
1150.0
1650.0
main
4.1. GERICHTETE GRAPHEN
Kuerzeste Wege mit Startknoten 4:
Ziel
Ziel
Ziel
Ziel
Ziel
Ziel
Ziel
Ziel
0:
1:
2:
3:
4:
5:
6:
7:
[4,
[4,
[4,
[4,
[4]
[4,
[4,
[4,
5,
5,
5,
5,
7, 0]
3, 2, 1]
3, 2]
3]
5]
5, 6]
5, 7]
Ein Zufallsgraph:
0
1
2
3
4
5
6
7
8
9
:
:
:
:
:
:
:
:
:
:
(2,
(0,
(8,
(7,
(3,
(2,
(1,
(9,
(9,
(3,
6.0) -> (3, 31.0) -> (4, 15.0) -> null
23.0) -> (5, 71.0) -> (7, 23.0) -> null
87.0) -> null
38.0) -> null
73.0) -> (4, 31.0) -> (8, 5.0) -> null
56.0) -> (7, 81.0) -> null
20.0) -> null
55.0) -> null
91.0) -> null
74.0) -> null
Distanzen zum Startknoten 0:
dist(0) = 0.0
1 ist nicht erreichbar
dist(2) = 6.0
dist(3) = 31.0
dist(4) = 15.0
5 ist nicht erreichbar
6 ist nicht erreichbar
dist(7) = 69.0
dist(8) = 20.0
dist(9) = 111.0
Kuerzeste Wege mit Startknoten 0:
Ziel
Ziel
Ziel
Ziel
Ziel
Ziel
Ziel
Ziel
Ziel
Ziel
0:
1:
2:
3:
4:
5:
6:
7:
8:
9:
[0]
ist
[0,
[0,
[0,
ist
ist
[0,
[0,
[0,
nicht
2]
3]
4]
nicht
nicht
3, 7]
4, 8]
4, 8,
erreichbar
erreichbar
erreichbar
9]
171
172
4.1.3
KAPITEL 4. GRAPHEN UND GRAPH-ALGORITHMEN
Starke Komponenten
Schließlich wollen wir noch das Problem lösen, die starken Komponenten eines
gerichteten Graphen zu bestimmen. Zur Erinnerung: Eine starke Komponente
eines gerichteten Graphen ist ein Teilgraph mit maximaler Knotenzahl, in dem
alle Knoten paarweise stark verbunden sind, d.h. sind u und v zwei beliebige
Knoten aus diesem Teilgraphen, dann gibt es einen Weg von u nach v und einen
Weg von v nach u.
Algorithmus 4.1.33. Ein Algorithmus zur Bestimmung der starken Komponenten eines gerichteten Graphen G:
(1.) Führe Tiefensuche auf G durch. Dabei werden die Knoten in der Reihenfolge durchnummeriert, in der die rekursiven Aufrufe von dfs beendet
werden. Werden nicht alle Knoten erreicht, so erfolgen weitere Aufrufe so
lange, bis jeder Knoten erreicht und damit nummeriert worden ist.
(2.) Der Graph G−1 entstehe aus G, indem jede Kante (u, v) durch die Kante
(v, u) ersetzt wird. Führe Tiefensuche auf G−1 durch. Dabei wird jeweils
mit dem Knoten begonnen, der unter allen noch nicht besuchten Knoten
die höchste Nummer aus (1.) hat. Ist eine Erreichbarkeitskomponente in
G−1 gefunden, dann werden ihre Knoten aus G−1 entfernt. Diese Knoten
bilden gerade eine starke Komponente von G.
Die Tiefensuche aus Algorithmus 4.1.19 ist also das entscheidende Hilfsmittel,
um die starken Komponenten von G zu bestimmen. Ehe wir uns überlegen, dass
obiger Algorithmus korrekt ist, schauen wir uns ein einfaches Beispiel an.
Beispiel 4.1.34. Sei G der folgende Graph:
@ABC
@ABC
?>=<
89:;
/ GFED
4 E
C `@
A 0W
iGFED
@@ iiiiiii O
00
i
@
i
0
iii@@@
00 iiiiii
i
0
i
@ABC
GFED
@ABC
GFED
@ABC
GFED
B @ 000
D Ao
F
O
AA
@@ 0
0
AA
@@ 0
AA
@@0 @ABC
GFED
@ABC
GFED
G
H
89:;
/ ?>=<
I
89:;
/ ?>=<
J j
+ GFED
@ABC
K
(1.) Tiefensuche auf G ergibt die folgenden Teilgraphen und die folgende Knotennummerierung:
A : 5 UUUU
UUUU
UUUU
*
B : 3KK
ss
ysss
E:1
KKK
%
G:2
D : 11
C:4
H : 10MM
q
F :6
q
xqqq
MMM
&
I:9
J :8
K:7
4.1. GERICHTETE GRAPHEN
173
(2.) Der inverse Graph G−1 :
@ABC
GFED
@ABC
?>=<
89:;
C@
E
A0 o
iGFED
O 0
O @@
iiii
i
i
i
i
00
@
i
i
00 iiiiiii @@@
i0i
t
i
@ABC
GFED
@ABC
GFED
@ABC
/GFED
B @` 000
D A`
F
AA
@@ 0
AA
@@ 00
AA @@0
@ABC
GFED
@ABC
GFED
H o
G
Tiefensuche auf G−1 :
• Startknoten D :
89:;
?>=<
I o
t
?>=<
89:;
J
@ABC
GFED
3 K
D
F
H
d.h. die erste starke Komponente von G ist {D, F, H}.
• Startknoten I :
I
d.h. die zweite starke Komponente von G ist {I}.
• Startknoten J :
J
K
d.h. die dritte starke Komponente von G ist {J, K}.
• Startknoten A :
A
~~
~~~
G@
@@
@
B
C
d.h. die vierte starke Komponente von G ist {A, B, C, G}.
• Startknoten E :
E
d.h. die fünfte starke Komponente von G ist {E}.
Damit hat G die folgenden starken Komponenten:
?>=<
89:;
@ABC
/ GFED
C
A 0W
00
0
00
0
@ABC
GFED
B @ 000
@@ 0
@@ 00
@@0 @ABC
GFED
G
@ABC
GFED
E
GFED
@ABC
@ABC
GFED
D Ao
F
O
AA
AA
AA
@ABC
GFED
H
89:;
?>=<
I
89:;
?>=<
J j
+ GFED
@ABC
K
Lemma 4.1.35. Sei G = (V, E) ein gerichteter Graph mit |V | = n, und sei µ :
V → {1, . . . , n} die in Algorithmus 4.1.33 (1.) erzeugte Knotennummerierung.
Dann gilt: Zwei Knoten liegen in derselben starken Komponente von G genau
dann, wenn sie in derselben Erreichbarkeitskomponente von G−1 liegen.
174
KAPITEL 4. GRAPHEN UND GRAPH-ALGORITHMEN
Beweis: Seien v, w ∈ V , v 6= w.
⇒“: Liegen v und w in derselben starken Komponente von G, so gibt es
”
Wege von v nach w und von w nach v in G, und damit auch in G−1 . Bei der
Tiefensuche auf G−1 werde der Knoten v (o.B.d.A.) vor dem Knoten w besucht.
Da es in G−1 einen Weg von v nach w gibt, wird w beim Aufruf dfs(v) in G−1
erreicht, d.h. v und w liegen in derselben Erreichbarkeitskomponente von G−1 .
⇐“: Sei x der Knoten, der als Startknoten diente, als beim Durchlaufen von
” −1
G die Komponente erzeugt wurde, die v und w enthält. Wir behaupten, dass
es in G Wege von x nach v und von v nach x sowie Wege von x nach w und von w
nach x gibt, woraus dann folgt, dass v und w in derselben starken Komponente
von G liegen. Natürlich reicht es, den Beweis für den Knoten v zu führen, wobei
wir v 6= x annehmen.
Weil v in der Erreichbarkeitskomponente von x in G−1 liegt, gibt es in G−1
einen Weg von x nach v, somit gibt es in G einen Weg von v nach x. Weiterhin
gilt µ(x) > µ(v), denn v war beim Aufruf von dfs(x) in (2.) noch nicht besucht.
Also terminiert dfs(v) in Schritt (1.) vor dfs(x). Für die zeitliche Abfolge der
Aufrufe und ihrer Beendigungen von dfs in (1.) gibt es damit drei Möglichkeiten:
(a) dfs(v) ist beendet, ehe dfs(x) aufgerufen wird:
v
↑
v̄
↑
x
x̄
Terminierung von dfs(v)
Aufruf von dfs(v)
Weil es in G einen Weg von v nach x gibt, wird beim Aufruf von dfs(v)
in (1.) also dfs(x) nur dann nicht aufgerufen, wenn dieser Weg über einen
Zwischenknoten z führt, der vor v besucht wurde, d.h. wir haben die
folgende Situation:
?>=<
89:;
v
?>=<
89:;
z
H
89:;
?>=<
x
Dann gilt aber, dass dfs(z) sowohl dfs(v) als auch dfs(x) umfasst:
z
z̄
v
v̄
x
x̄
Damit ist µ(z) > µ(x), und in (2.) wird dfs(z) vor dfs(x) aufgerufen. Dieser
Aufruf würde aber bereits den Knoten v erreichen, so dass v nicht erst in
der Erreichbarkeitskomponente von x erreicht würde. Damit scheidet der
Fall (a) aus.
(b) dfs(x) wird unterhalb von dfs(v) aufgerufen:
v
v̄
x
x̄
Dies widerspricht der Struktur der Tiefensuche.
4.1. GERICHTETE GRAPHEN
175
(c) dfs(v) liegt innerhalb von dfs(x):
v
x
v̄
x̄
Dann gibt es in G einen Weg von x nach v.
Für die folgende Implementierung erweitern wir Programm 4.1.10, das gerichtete Graphen durch ihre Adjazenzmatrix darstellt. Es werden zwei Varianten
der rekursiven Realisierung der Tiefensuche benutzt. Die erste durchläuft den
Graphen G und notiert die neue Knotennummerierung, die zweite durchläuft
den Graphen G−1 , wobei die Adjazenzmatrix von G entsprechend interpretiert
wird, und ordnet jedem Knoten seine Komponentennummer zu. Als Aufwand
für diese Implementierung erhalten wir den Rechenzeitbedarf O(n2 ).
Programm 4.1.36. Eine Implementierung von Algorithmus 4.1.33:
wie in Programm 4.1.10
74
public class StrongComponents {
75
76
77
78
79
/** Nach der Konstruktion ist komponente[v] == k genau dann, wenn
* Knoten v zur Komponente mit Nummer k gehoert.
*/
private int[ ] komponente = new int[ nodeSize( ) ];
80
81
82
/** Die Nummer der aktuell zu bestimmenden Komponente, eine Zahl >= 1. */
private int laufendeKomponente = 1;
83
84
85
86
87
88
/** Nach der Konstruktion ist nummer[v] == n genau dann, wenn
* Knoten v die Nummer n hat. Waehrend der Depth-First-Nummerierung
* ist nummer[v] == 0 genau dann, wenn v noch nicht besucht wurde.
*/
private int[ ] nummer = new int[ nodeSize( ) ];
89
90
91
/** Die aktuell zu vergebende Knotennummer, eine Zahl >= 1. */
private int laufendeNummer = 1;
92
93
94
95
96
97
98
99
100
101
102
103
104
105
/** Bestimmt die starken Komponenten des Graphen. */
public StrongComponents( ) {
// Depth-First-Nummerierung des Graphen:
int nichtBesuchterKnoten;
while( ( nichtBesuchterKnoten = nichtBesuchterKnoten( ) ) >= 0 )
depthFirstNumbering( nichtBesuchterKnoten );
// Depth-First-Traversierung des inversen Graphen:
int maximalKnoten;
while( ( maximalKnoten = maximalKnoten( ) ) >= 0 ) {
inverseDepthFirstTraversal( maximalKnoten );
laufendeKomponente++;
}
}
nodeSize
176
KAPITEL 4. GRAPHEN UND GRAPH-ALGORITHMEN
106
107
108
109
110
111
112
113
114
115
/** Depth-First-Nummerierung der von Knoten v aus erreichbaren Knoten. */
private void depthFirstNumbering( int v ) {
nummer[ v ] = 1; // als besucht markiert; die Nummer wird spaeter bestimmt
for( int w = 0; w < nodeSize( ); w++ )
if( isEdge( v, w ) && nummer[ w ] == 0 )
depthFirstNumbering( w );
nummer[v] = laufendeNummer; // die tatsaechliche Nummer wird bestimmt
laufendeNummer++;
}
depthFirstNumbering
116
117
118
119
120
121
122
123
124
125
/** Gibt den ersten waehrend der Depth-First-Nummerierung noch nicht
* besuchten Knoten zurueck, oder -1, falls kein solcher Knoten existiert.
*/
private int nichtBesuchterKnoten( ) {
for( int v = 0; v < nodeSize( ); v++ )
if( nummer[v] == 0 ) // v ist noch nicht besucht
return v;
return −1;
}
nichtBesuchterKnoten
126
127
128
129
130
131
132
133
134
135
136
137
/** Depth-First-Traversierung des inversen Graphen beginnend bei Knoten v.
* Die erreichbaren Knoten bilden die Komponente mit Nummer
* laufendeKomponente.
*/
private void inverseDepthFirstTraversal( int v ) {
komponente[ v ] = laufendeKomponente;
nummer[ v ] = 0; // erreichten Knoten als besucht markieren
for( int w = 0; w < nodeSize( ); w++ )
if( isEdge( w, v ) && nummer[ w ] != 0 )
inverseDepthFirstTraversal( w );
}
inverseDepthFirstTraversal
138
139
140
141
142
143
144
145
146
147
148
149
150
/** Gibt den Knoten mit maximalem Wert im Array nummer zurueck,
* oder -1, falls kein Knoten dort einen Wert > 0 hat.
*/
private int maximalKnoten( ) {
int maximalWert = 0, maximalKnoten = −1;
for( int i = 0 ; i < nummer.length; i++ )
if( maximalWert < nummer[i] ) {
maximalWert = nummer[i];
maximalKnoten = i;
}
return maximalKnoten;
}
maximalKnoten
151
152
153
154
155
156
157
158
159
/** Gibt eine String-Darstellung der starken Komponenten zurueck. */
public String toString( ) {
StringBuffer out = new StringBuffer( );
out.append( "Die starken Komponenten des Graphen:\n\n" );
// Die Anzahl der Komponenten ist laufendeKomponente - 1.
for( int k = 1; k < laufendeKomponente; k++ ) {
out.append( " Komponente " + k + ": { " );
boolean komma = false;
toString
4.1. GERICHTETE GRAPHEN
for( int v = 0; v < nodeSize( ); v++ )
if( komponente[v] == k ) {
out.append( ( komma ? ", " : "" ) + String.valueOf( v ) );
komma = true;
}
out.append( " }\n" );
160
161
162
163
164
165
}
return out.toString( );
166
167
168
177
}
169
170
} // class StrongComponents
die Methoden conc, print und toString wie in Programm 4.1.10
207
208
209
210
211
212
213
214
215
216
public static void main( String[ ] args ) {
DirectedGraph b = new DirectedGraph( 11 );
b.insertEdge( 0, 1 ); b.insertEdge( 0, 2 ); b.insertEdge( 1, 6 );
b.insertEdge( 1, 4 ); b.insertEdge( 2, 6 ); b.insertEdge( 3, 2 );
b.insertEdge( 3, 7 ); b.insertEdge( 5, 3 ); b.insertEdge( 5, 4 );
b.insertEdge( 6, 0 ); b.insertEdge( 7, 5 ); b.insertEdge( 7, 8 );
b.insertEdge( 8, 9 ); b.insertEdge( 9, 10 ); b.insertEdge( 10, 9 );
System.out.println( "Der Graph aus Beispiel 4.1.34:\n\n" +
b + "\n" + b.new StrongComponents( ) );
}
217
218
} // class DirectedGraph
Ein Testlauf:
Der Graph aus Beispiel 4.1.34:
| 0 1 2 3 4 5 6 7 8 9 10
____|_________________________________
0 | 0 1 1 0 0 0 0 0 0 0 0
1 | 0 0 0 0 1 0 1 0 0 0 0
2 | 0 0 0 0 0 0 1 0 0 0 0
3 | 0 0 1 0 0 0 0 1 0 0 0
4 | 0 0 0 0 0 0 0 0 0 0 0
5 | 0 0 0 1 1 0 0 0 0 0 0
6 | 1 0 0 0 0 0 0 0 0 0 0
7 | 0 0 0 0 0 1 0 0 1 0 0
8 | 0 0 0 0 0 0 0 0 0 1 0
9 | 0 0 0 0 0 0 0 0 0 0 1
10 | 0 0 0 0 0 0 0 0 0 1 0
Die starken Komponenten des Graphen:
Komponente
Komponente
Komponente
Komponente
Komponente
1:
2:
3:
4:
5:
{
{
{
{
{
3, 5, 7 }
8 }
9, 10 }
0, 1, 2, 6 }
4 }
main
178
4.2
KAPITEL 4. GRAPHEN UND GRAPH-ALGORITHMEN
Ungerichtete Graphen
Definition 4.2.1. Ein ungerichteter Graph ist ein gerichteter Graph, bei dem
die Kantenrelation E symmetrisch ist: (u, v) ∈ E genau dann, wenn (v, u) ∈ E.
Zur Vereinfachung fassen wir die Kantenmenge {(u, v), (v, u)} als eine ungerichtete Kante zwischen u und v auf, die wir graphisch wie folgt darstellen:
89:;
?>=<
u
89:;
?>=<
v
Die Begriffe Weg und Teilgraph lassen sich unmittelbar auf ungerichtete Graphen übertragen. Ein Zykel ist ein geschlossener einfacher Weg, der mindestens drei verschiedene Knoten durchläuft. Für einen Knoten v ist der Teilgraph, der von der Menge aller mit v verbundenen Knoten induziert wird, die
(Zusammenhangs-)Komponente von v. Ein ungerichteter Graph heißt verbunden, wenn er aus nur einer Komponente besteht. Er heißt azyklisch, wenn er
keine Zykeln enthält.
Ein Wald ist ein azyklischer, ungerichteter Graph, und ein freier Baum ist ein
verbundener Wald. Indem man einen Knoten als Wurzel auszeichnet und alle
Kanten so orientiert, dass sie von der Wurzel weg zeigen, erhält man aus einem
freien Baum einen Baum gemäß Definition 2.1.1.
Ein gewichteter ungerichteter Graph ist ein ungerichteter Graph G = (V, E) zusammen mit einer Gewichtungsfunktion c : E → R+ , die symmetrisch ist, d.h.
für alle u, v ∈ VPist c((u, v)) = c((v, u)). Die Kosten des Graphen G ergeben
sich als c(G) = e∈E c(e).
Freie Bäume bzw. Wälder haben die folgenden wichtigen Eigenschaften.
Lemma 4.2.2. Ein freier Baum mit n Knoten hat n − 1 Kanten; fügt man
eine Kante hinzu, so entsteht genau ein Zykel. Allgemeiner gilt: Ein Wald mit
n Knoten und k Komponenten hat n − k Kanten; fügt man innerhalb einer
Komponente eine Kante hinzu, so entsteht genau ein Zykel.
Zur Implementierung ungerichteter Graphen kann man wieder (dann symmetrische) Adjazenzmatrizen (Def. 4.1.8) oder Adjazenzlisten (Def. 4.1.11) verwenden. Auch Tiefensuche (Algorithmus 4.1.19) und Breitensuche (Algorithmus 4.1.23) sind hier anwendbar. Diese Algorithmen liefern für ungerichtete
Graphen auch die Zusammenhangskomponenten.
4.2.1
Minimale aufspannende Wälder
Wir beschließen dieses Kapitel mit der Untersuchung eines weiteren GraphenAlgorithmus.
Definition 4.2.3. Sei G ein gewichteter ungerichteter Graph. Ein aufspannender Wald für G ist ein Wald, der Teilgraph von G ist und dieselbe Knotenmenge
4.2. UNGERICHTETE GRAPHEN
179
sowie dieselbe Anzahl von Komponenten hat wie G. Ein minimaler aufspannender Wald für G ist ein aufspannender Wald für G mit minimalen Kosten. Ein
(minimaler) aufspannender Baum ist ein verbundener (minimaler) aufspannender Wald, existiert also nur, wenn G verbunden ist.
Wir wollen im Folgenden die Aufgabe lösen, zu einem gewichteten ungerichteten
Graphen einen minimalen aufspannenden Wald zu bestimmen.
Beispiel 4.2.4. Sei G der folgende ungerichtete Graph, wobei die Kosten einer
Kante als Markierung an die Kante geschrieben sind:
13
GFED
@ABC
@ABC
GFED
A0
B
oo
o
o
00
18ooo
009 ooo
4
12
o00oo 7
o
@ABC
GFED
@ABC
GFED
E @ 000
C
@@ 0
~
~
0
@@ 0
~~
10 @@0 ~~~ 5
@ABC
GFED
D
Der folgende Baum ist ein aufspannender Baum für G:
13
GFED
@ABC
A0
00
009
00
7
@ABC
GFED
E 000
00
00
@ABC
GFED
D
@ABC
GFED
B
@ABC
GFED
C
12
Seine Kosten betragen 41. Ist er minimal?
Lemma 4.2.5. Sei G = (V, E) ein ungerichteter Graph mit symmetrischer
Gewichtungsfunktion c. Sei H = (V, F ) ein Wald, der Teilgraph eines minimalen aufspannenden Waldes H 0 = (V, F 0 ) für G ist. Sei ferner (u, v) ∈ E eine
Kante, die verschiedene Komponenten von H verbindet und minimales Gewicht
unter allen solchen Kanten hat. Dann ist auch (V, F ∪ {(u, v)}) Teilgraph eines
minimalen aufspannenden Waldes für G.
Beweis: Für (u, v) ∈ F 0 gilt die Aussage. Andernfalls enthält (V, F 0 ∪ {(u, v)})
nach Lemma 4.2.2 einen Zykel, also gibt es in H 0 einen Weg von v nach u. Da
v und u in verschiedenen Komponenten von H liegen, enthält dieser Weg auch
eine Kante (ū, v̄), die verschiedene Komponenten von H verbindet. Damit ist
der Graph
H 00 = (V, F 0 \ {(ū, v̄)} ∪ {(u, v)})
180
KAPITEL 4. GRAPHEN UND GRAPH-ALGORITHMEN
ein aufspannender Wald für G, und es gilt
c(H 00 ) = c(H 0 ) − c((ū, v̄)) + c((u, v)) ≤ c(H 0 )
wegen c((u, v)) ≤ c((ū, v̄)). Also ist auch H 00 ein minimaler aufspannender Wald
für G, der nun (V, F ∪ {(u, v)}) als Teilgraphen enthält.
Auf diesem Lemma basiert der folgende Algorithmus von Kruskal zur Bestimmung eines minimalen aufspannenden Waldes für G. Er arbeitet wie folgt: Zu
Beginn wird der zu berechnende Wald als H = (V, ∅) gewählt. Nun werden
die Kanten von G der Reihe nach angeschaut, wobei die Kanten nach aufsteigendem Gewicht sortiert sind. Hierfür eignet sich also eine Prioritätsschlange,
wie sie etwa durch einen Heap realisiert wird. Verbindet die betrachtete Kante
zwei Komponenten von H, so wird sie zu H hinzugenommen, andernfalls wird
die Kante ignoriert. Am Ende ist H dann der gesuchte minimale aufspannende
Wald. Zur Verwaltung der Komponenten verwenden wir die Algorithmen aus
Abschnitt 3.6.
Algorithmus 4.2.6. Kruskals Algorithmus zur Bestimmung eines minimalen
aufspannenden Waldes für G = (V, E). Ein Heap speichert Kanten (v, w) als
Tripel (v, w, c(v, w)); minimales Gewicht hat maximale Priorität.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
WeightedUndirectedGraph minimalSpanningForest( ) {
Partition komponenten = new Partition( |V | );
WeightedUndirectedGraph minimalSpanningForest =
new WeightedUndirectedGraph( |V | );
Heap heap = new Heap( |E| );
for all( (v, w) ∈ E )
heap.einfuegenInHeap( (v, w, c((v, w))) );
while( !heap.istLeer( ) ) {
Edge minimalEdge = heap.loeschenAusHeap( );
int a = komponenten.find( minimalEdge.start );
int b = komponenten.find( minimalEdge.destination );
if( a != b ) { // minimalEdge verbindet zwei Komponenten
komponenten.union( a, b );
minimalSpanningForest.insertEdge( minimalEdge );
}
}
return minimalSpanningForest;
}
Beispiel 4.2.7. (Forts. von Beispiel 4.2.4) Für den Graphen G liefert Algorithmus 4.2.6 den folgenden minimalen aufspannenden Baum mit Kosten 28:
GFED
@ABC
A
@ABC
GFED
E
4
@ABC
GFED
B
@ABC
GFED
C
~
~
5 ~
~~
~~
@ABC
GFED
D
12
7
minimalSpanningForest
4.2. UNGERICHTETE GRAPHEN
181
Lemma 4.2.8. Kruskals Algorithmus hat einen Zeitbedarf von O(|E| log |E|).
Beweis: Die Partition {{0}, . . . , {|V | − 1}} wird in Zeit O(|V |) initialisiert.
Nach der Analyse von HEAPSORT (Beweis von Lemma 2.3.13) kann die Prioritätsschlange in Zeit O(|E|) initialisiert werden, wenn sie durch einen Heap
implementiert wird. Die while-Schleife wird |E|-mal durchlaufen. Insgesamt werden dabei |E| Elemente aus dem Heap entfernt, was Zeit O(|E| log |E|) kostet,
und es werden 2|E| Finde- und maximal |V | − 1 Vereinigungs-Operationen ausgeführt. Nach Satz 3.6.11 reichen hierfür O(|E|α(2|E|, |V | − 1)) Schritte aus.
Insgesamt haben wir also einen Rechenzeitbedarf von O(|E| log |E|).
Programm 4.2.9. Die folgende Implementierung von Kruskals Algorithmus
benutzt als Heaps Instanzen der Klasse VollstaendigerBinaererBaum aus Programm 2.3.14; im Heap werden Kanten als Tripel aus Startknoten, Zielknoten und Gewicht gespeichert (Typ Edge), die bezüglich ihres Gewichts geordnet sind. Die Klasse Partition aus Programm 3.6.12 dient zur Verwaltung der
Zusammenhangskomponenten.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import java.util.LinkedList;
import java.util.Random;
/** Die Klasse WeightedUndirectedGraph implementiert ungerichtete Graphen
* mit Gewichtungsfunktion ueber Adjazenzmatrizen. Knoten sind Zahlen >= 0
* vom Typ int, Gewichte sind Zahlen > 0 vom Typ double.
* Mehrfachkanten sind nicht zugelassen.
*/
public class WeightedUndirectedGraph {
/** Die Anzahl der Knoten des Graphen.
* Knoten sind Zahlen v mit 0 <= v < N.
*/
private final int N;
/** Das maximale Kantengewicht im Graphen
* (0 wenn keine Kante vorhanden ist).
*/
private double maximalWeight = 0;
/** Die Summe aller Kantengewichte im Graphen
* (0 wenn keine Kante vorhanden ist).
*/
private double sumOfWeights = 0;
/** Die symmetrische Adjazenzmatrix des Graphen. */
private double[ ][ ] matrix;
/** Konstruiert einen Graphen mit n Knoten und keiner Kante. */
public WeightedUndirectedGraph( int n ) {
N = n;
matrix = new double[N][N];
}
WeightedUndirectedGraph
182
KAPITEL 4. GRAPHEN UND GRAPH-ALGORITHMEN
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
/** Konstruiert einen Zufallsgraphen mit n Knoten. Jede Kante ist mit
* Wahrscheinlichkeit p vorhanden. Das Gewicht einer Kante ist als Zahl
* vom Typ int gleichverteilt aus (0, maxWeight] gewaehlt.
*/
public WeightedUndirectedGraph( int n, double p, double maxWeight ) {
this( n );
for( int v = 0; v < N; v++ )
for( int w = 0; w < v; w++ )
if( Math.random( ) < p )
insertEdge( v, w, (int)( Math.random( ) * maxWeight ) + 1 );
}
/** Gibt die Knotenzahl zurueck. */
public int nodeSize( ) {
return N;
}
/** Gibt die Kantenzahl zurueck. */
public int edgeSize( ) {
int e = 0;
for( int v = 0; v < N; v++ )
for( int w = 0; w < v; w++ )
e += isEdge( v, w ) ? 1 : 0;
return e;
}
/** Gibt die Summe aller Kantengewichte zurueck. */
public double sumOfWeights( ) {
return sumOfWeights;
}
/** Gibt true zurueck, wenn eine Kante zwischen Knoten v und w vorhanden ist,
* sonst false. Falls nicht 0 <= v, w < N gilt, wird eine Ausnahme ausgeloest.
*/
public boolean isEdge( int v, int w ) {
if( v < 0 | | w < 0 | | v >= N | | w >= N )
throw new IllegalArgumentException( "Knoten nicht vorhanden." );
return matrix[v][w] > 0;
}
/** Fuegt eine Kante mit Gewicht weight zwischen Knoten v und w ein,
* falls zwischen v und w keine Kante vorhanden ist. Falls nicht
* 0 <= v, w < N und weight > 0 gilt, wird eine Ausnahme ausgeloest.
*/
public void insertEdge( int v, int w, double weight ) {
if( v < 0 | | w < 0 | | v >= N | | w >= N )
throw new IllegalArgumentException( "Knoten nicht vorhanden." );
if( weight <= 0 )
throw new IllegalArgumentException( "Gewicht ist nicht positiv." );
WeightedUndirectedGraph
nodeSize
edgeSize
sumOfWeights
isEdge
insertEdge
4.2. UNGERICHTETE GRAPHEN
if( !isEdge( v, w ) ) {
if( maximalWeight < weight )
maximalWeight = weight;
sumOfWeights += weight;
matrix[v][w] = matrix[w][v] = weight; // die Matrix ist symmetrisch
}
84
85
86
87
88
89
90
183
}
91
92
93
94
95
96
97
98
/** Fuegt die Kante e ein, falls zwischen e.start und e.destination keine
* Kante vorhanden ist. Falls nicht 0 <= e.start, e.destination < N und
* e.weight > 0 gilt, wird eine Ausnahme ausgeloest.
*/
public void insertEdge( Edge e ) {
insertEdge( e.start, e.destination, e.weight );
}
insertEdge
99
100
101
102
103
/** Die Klasse Edge implementiert Kanten des ungerichteten Graphen.
* Kanten sind Tripel aus Startknoten, Zielknoten und Gewicht.
*/
public class Edge implements Comparable {
104
105
106
/** Der Startknoten der Kante. */
private int start;
107
108
109
/** Der Zielknoten der Kante. */
private int destination;
110
111
112
/** Das Gewicht der Kante. */
private double weight;
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
/** Konstruiert eine Kante mit Startknoten start, Zielknoten destination
* und Gewicht weight. Falls nicht 0 <= start, destination < N und weight > 0
* gilt, wird eine Ausnahme ausgeloest.
*/
public Edge( int start, int destination, double weight ) {
if( start < 0 | | destination < 0 | |
start >= nodeSize( ) | | destination >= nodeSize( ) )
throw new IllegalArgumentException( "Knoten nicht vorhanden." );
if( weight <= 0 )
throw new IllegalArgumentException( "Gewicht ist nicht positiv." );
this.start = start;
this.destination = destination;
this.weight = weight;
}
Edge
128
129
130
131
132
133
/** Kanten werden bezueglich ihres Gewichts verglichen. */
public int compareTo( Object otherEdge ) {
double otherWeight = ( (Edge)otherEdge ).weight;
return weight < otherWeight ? −1 : weight > otherWeight ? +1 : 0;
}
134
135
136
} // class Edge
compareTo
184
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
KAPITEL 4. GRAPHEN UND GRAPH-ALGORITHMEN
/** Kruskals Algorithmus bestimmt einen minimalen aufspannenden Wald. */
public WeightedUndirectedGraph minimalSpanningForest( ) {
Partition komponenten = new Partition( N );
WeightedUndirectedGraph minimalSpanningForest =
new WeightedUndirectedGraph( N );
// Ein Heap, der alle Kanten enthaelt;
// minimales Gewicht hat maximale Prioritaet:
VollstaendigerBinaererBaum heap =
new VollstaendigerBinaererBaum( edgeSize( ) );
for( int v = 0; v < N; v++ )
for( int w = 0; w < v; w++ )
if( isEdge( v, w ) )
heap.einfuegenInHeap( new Edge( v, w, matrix[v][w] ) );
while( !heap.istLeer( ) ) {
Edge minimalEdge = (Edge)heap.loeschenAusHeap( );
int a = komponenten.find( minimalEdge.start );
int b = komponenten.find( minimalEdge.destination );
if( a != b ) { // minimalEdge verbindet zwei Komponenten
komponenten.union( a, b );
minimalSpanningForest.insertEdge( minimalEdge );
}
}
return minimalSpanningForest;
}
minimalSpanningForest
die Methoden conc und print wie in Programm 4.1.10
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
/** Gibt eine String-Darstellung des Graphen zurueck.
* Gewichte werden zu ganzen Zahlen abgerundet.
*/
public String toString( ) {
StringBuffer out = new StringBuffer( );
// Das Maximum der Laengen der Darstellungen von N und maximalWeight:
int n = Math.max( Integer.toString( N ).length( ),
Integer.toString( (int)maximalWeight ).length( ) );
// Die Spaltenueberschrift:
out.append( conc( " ", n+2 ) + "|" );
for( int v = 0; v < N; v++ )
out.append( print( v, n+1 ) );
out.append( "\n" + conc( "_", n+2 ) + "|" + conc( "_", N*(n+1) ) + "\n" );
// Die Matrix:
for( int v = 0; v < N; v++ ) {
out.append( print( v, n+1 ) + " |" );
for( int w = 0; w < v; w++ )
out.append( conc( " ", n+1 ) );
for( int w = v; w < N; w++ )
out.append( print( (int)matrix[v][w], n+1 ) );
out.append( "\n" );
}
return out.toString( );
}
toString
4.2. UNGERICHTETE GRAPHEN
200
201
202
203
204
205
206
207
208
209
210
211
public static void main( String[ ] args ) {
WeightedUndirectedGraph usa = new WeightedUndirectedGraph( 8 );
usa.insertEdge( 1, 0, 300 ); usa.insertEdge( 2, 1, 800 );
usa.insertEdge( 2, 0, 1000 ); usa.insertEdge( 3, 2, 1200 );
usa.insertEdge( 4, 3, 1500 ); usa.insertEdge( 4, 5, 250 );
usa.insertEdge( 5, 3, 1000 ); usa.insertEdge( 5, 6, 900 );
usa.insertEdge( 5, 7, 1400 ); usa.insertEdge( 6, 7, 1000 );
usa.insertEdge( 7, 0, 1700 );
System.out.println( "Der Graph aus Beispiel 4.1.31:\n\n" + usa );
WeightedUndirectedGraph usaForest = usa.minimalSpanningForest( );
System.out.println( "Ein minimaler aufspannender Wald mit Gewicht " +
usaForest.sumOfWeights( ) + ":\n\n" + usaForest );
212
WeightedUndirectedGraph u = new WeightedUndirectedGraph( 5 );
u.insertEdge( 0, 1, 13 ); u.insertEdge( 0, 3, 9 );
u.insertEdge( 0, 4, 4 ); u.insertEdge( 1, 2, 12 );
u.insertEdge( 1, 4, 18 ); u.insertEdge( 2, 3, 5 );
u.insertEdge( 2, 4, 7 ); u.insertEdge( 3, 4, 10 );
System.out.println( "\nDer Graph aus Beispiel 4.2.4:\n\n" + u );
WeightedUndirectedGraph uForest = u.minimalSpanningForest( );
System.out.println( "Ein minimaler aufspannender Wald mit Gewicht " +
uForest.sumOfWeights( ) + ":\n\n" + uForest );
213
214
215
216
217
218
219
220
221
222
}
223
224
} // class WeightedUndirectedGraph
Ein Testlauf:
Der Graph aus Beispiel 4.1.31:
|
0
1
2
3
4
5
6
7
______|________________________________________
0 |
0 300 1000
0
0
0
0 1700
1 |
0 800
0
0
0
0
0
2 |
0 1200
0
0
0
0
3 |
0 1500 1000
0
0
4 |
0 250
0
0
5 |
0 900 1400
6 |
0 1000
7 |
0
Ein minimaler aufspannender Wald mit Gewicht 5450.0:
|
0
1
2
3
4
5
6
7
______|________________________________________
0 |
0 300
0
0
0
0
0
0
1 |
0 800
0
0
0
0
0
2 |
0 1200
0
0
0
0
3 |
0
0 1000
0
0
4 |
0 250
0
0
5 |
0 900
0
6 |
0 1000
7 |
0
185
main
186
KAPITEL 4. GRAPHEN UND GRAPH-ALGORITHMEN
Der Graph aus Beispiel 4.2.4:
| 0 1 2 3 4
____|_______________
0 | 0 13 0 9 4
1 |
0 12 0 18
2 |
0 5 7
3 |
0 10
4 |
0
Ein minimaler aufspannender Wald mit Gewicht 28.0:
| 0 1 2 3 4
____|_______________
0 | 0 0 0 0 4
1 |
0 12 0 0
2 |
0 5 7
3 |
0 0
4 |
0
Kapitel 5
Sortieralgorithmen
In diesem Kapitel kommen wir zum Problem des Sortierens zurück, das wir in
Abschnitt 2.3 schon angesprochen hatten. Dort hatten wir zwei Algorithmen für
das Sortieren kennengelernt, nämlich TREE SORT und HEAP SORT, die beide
binäre Bäume als unterliegende Datenstruktur für das Sortieren verwenden.
Hier werden wir einige weitere Sortieralgorithmen kennenlernen, analysieren
und miteinander vergleichen.
5.1
Elementare Sortieralgorithmen
Zunächst wollen wir die Problemstellung fixieren.
Definition 5.1.1. Das Sortierproblem für die durch die Ordnung ≤ linear geordnete Menge Item ist das Problem, folgende Aufgabe zu lösen:
Eingabe:
Eine endliche Folge (a0 , a1 , . . . , an−1 ) von Elementen aus Item.
Aufgabe:
Bestimme eine Permutation π : {0, . . . , n − 1} → {0, . . . , n − 1},
so dass aπ(0) ≤ aπ(1) ≤ · · · ≤ aπ(n−1) gilt, d.h. so dass die Folge
(aπ(0) , aπ(1) , . . . , aπ(n−1) ) aufsteigend sortiert ist.
Ein Algorithmus, der diese Aufgabe löst, ist ein Sortieralgorithmus. Falls aus
i < j und ai = aj stets π −1 (i) < π −1 (j) folgt (d.h. falls auch in der sortierten
Folge ai vor aj kommt), so heißt der Sortieralgorithmus stabil.
Bemerkung 5.1.2. Man macht sich leicht klar, dass TREE SORT (aus Abschnitt 2.3.1) so realisiert werden kann, dass es ein stabiles Verfahren ist,
während HEAP SORT (aus Abschnitt 2.3.2) inhärent instabil ist.
Hier betrachten wir zunächst zwei elementare Sortierverfahren. Immer nehmen
wir an, dass die Eingabefolge (a0 , . . . , an−1 ) in einem Array a der Größe n über
dem Typ Comparable steht. Am Ende der Berechnung enthält das Array die
sortierte Ausgabefolge.
187
188
KAPITEL 5. SORTIERALGORITHMEN
Algorithmus 5.1.3. SELECTION SORT (Sortieren durch Auswählen):
1
2
3
4
5
6
7
8
9
10
11
selectionSort( Comparable[ ] a ) {
for( int i = 0; i < n−1; i++ ) {
// Erstes minimales Element in (a[i], . . . , a[n − 1]) finden . . .
int min = i; // Position des aktuellen minimalen Elements
for( int j = i+1; j < n; j++ )
if( a[ j ].compareTo( a[ min ] ) < 0 )
min = j;
// . . . und mit a[i] vertauschen:
swap( a, min, i );
}
}
selectionSort
12
13
/** Vertauscht die Array-Elemente a[i] und a[j] (0 ≤ i, j < n). */
swap( Comparable[ ] a, int i, int j ) {
Comparable tmp = a[ i ];
16
a[ i ] = a[ j ];
17
a[ j ] = tmp;
18 }
14
15
swap
Satz 5.1.4. Algorithmus SELECTION SORT sortiert ein Array mit n Elementen in Zeit O(n2 ). Dabei werden O(n2 ) viele Schlüsselvergleiche und O(n)
viele Vertauschungen durchgeführt.
Beweis: Die äußere Schleife wird (n − 1)-mal, die
− i)-mal
Pinnere jeweils (n − 1P
n−1
durchlaufen. Dies ergibt einen Zeitbedarf von c · n−2
(n
−
1
−
i)
=
c
·
i=0
i=1 i ∈
2
O(n ).
Beim Sortieren durch Auswählen wird im i-ten Schritt das i-te Element der
sortierten Folge bestimmt und an seinen Platz gebracht. Beim folgenden Verfahren, dem Sortieren durch Einfügen, wird die Anfangsfolge der Länge i + 1
sortiert, indem das (i + 1)-te Element der unsortierten Folge in die bereits sortierte Teilfolge der ersten i Elemente an der richtigen Stelle eingefügt wird.
Algorithmus 5.1.5. INSERTION SORT (Sortieren durch Einfügen):
1
2
3
4
5
6
7
8
9
insertionSort( Comparable[ ] a ) {
for( int i = 1; i < n; i++ ) {
Comparable tmp = a[ i ];
int j = i;
for( ; j > 0 && tmp.compareTo( a[ j−1 ] ) < 0; j−− )
a[ j ] = a[ j−1 ];
a[ j ] = tmp;
}
}
In der inneren Schleife wird Platz für das Element a[i] gemacht, indem alle
größeren Elemente zwischen a[i − 1] und a[0] um jeweils einen Platz nach rechts
geschoben werden. DiePäußere Schleife wird (n − 1)-mal durchlaufen, die innere
2
im schlechtesten Fall n−1
i=1 i ∈ O(n ) mal. Also gilt:
insertionSort
5.2. SORTIERVERFAHREN MIT DIVIDE-AND-CONQUER
189
Satz 5.1.6. Algorithmus INSERTION SORT sortiert ein Array mit n Elementen in Zeit O(n2 ). Dabei werden O(n2 ) Schlüsselvergleiche und Umspeicherungen vorgenommen.
Statt das Anfangsstück von a[i−1] bis a[0] linear zu durchlaufen, um die richtige
Position für das Element a[i] zu bestimmen, kann man auf diesem Breich auch
Binärsuche für diese Aufgabe einsetzen. Dann wird die Position für a[i] in Zeit
O(log i) gefunden. Allerdings brauchen wir im schlechtesten Fall noch immer i
Umspeicherungen, um a[i] an der gefundenen Position unterzubringen, d.h. diese Variante von INSERTION SORT kommt mit O(n log n) Schlüsselvergleichen
aus, braucht aber dennoch Rechenzeit O(n2 ).
Algorithmus 5.1.7. INSERTION SORT mit Binärsuche :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
insertionSortBinarySearch( Comparable[ ] a ) {
for( int i = 1; i < n; i++ ) {
Comparable tmp = a[ i ];
// Position fuer a[i] im Bereich a[0] bis a[i − 1] binaer suchen:
int left = 0;
int right = i−1;
while( left <= right ) {
int mid = ( left + right ) / 2;
if( a[ i ].compareTo( a[ mid ] ) < 0 )
right = mid − 1;
else if( a[ i ].compareTo( a[ mid ] ) > 0 )
left = mid + 1;
else {
left = mid;
break;
}
}
// Dann a[i] an Position left bringen:
for( int j = i; j > left; j−− )
a[ j ] = a[ j−1 ];
a[ left ] = tmp;
}
}
5.2
Sortierverfahren mit Divide-and-Conquer
Die nächsten beiden Sortierverfahren beruhen auf der Strategie Divide and
”
Conquer“ (Teile und Herrsche): Die Folge (a0 , . . . , an−1 ) wird in zwei Teilfolgen
aufgeteilt, etwa (a0 , . . . , am ) und (am+1 , . . . , an−1 ), die dann getrennt voneinander nach demselben Verfahren sortiert und anschließend zu einer sortierten
Gesamtfolge zusammengefügt werden. Beim ersten Verfahren ist das Aufteilen
trivial, und die gesamte Arbeit wird beim Zusammenfügen der sortierten Teilfolgen geleistet. Beim zweiten Verfahren erfordert das Aufteilen die hauptsächliche
Arbeit, und das Zusammenfügen ist trivial.
insertionSortBinarySearch
190
KAPITEL 5. SORTIERALGORITHMEN
Als wichtigen Bestandteil des ersten Verfahrens brauchen wir also einen Algorithmus, der zwei sortierte Teilfolgen zu einer sortierten Folge zusammenmischt.
Algorithmus 5.2.1. MERGE: Mischen zweier sortierter Teil-Arrays a[left] bis
a[mid] und a[mid + 1] bis a[right] zu einem sortierten Array a[left] bis a[right].
Im Array b wird das Ergebnis zwischengespeichert.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
merge( Comparable[ ] a, Comparable[ ] b, int left, int mid, int right ) {
int i1 = left; // laeuft hoch bis mid
int i2 = mid + 1; // laeuft hoch bis right
int bPos = left; // kopiere nach b ab bPos
while( i1 <= mid && i2 <= right ) {
if( a[ i1 ].compareTo( a[ i2 ] ) <= 0 )
b[ bPos++ ] = a[ i1++ ];
else
b[ bPos++ ] = a[ i2++ ];
}
while( i1 <= mid ) // ersten Bereich vollends kopieren
b[ bPos++ ] = a[ i1++ ];
while( i2 <= right ) // zweiten Bereich vollends kopieren
b[ bPos++ ] = a[ i2++ ];
// Zuletzt von b nach a zurueckkopieren:
for( bPos = left; bPos <= right; bPos++ )
a[ bPos ] = b[ bPos ];
}
merge
Hat ein Teil-Array m und das andere Teil-Array n Elemente, so gilt für die
Komplexität des Algorithmus MERGE offensichtlich folgendes:
Anzahl der Schlüsselvergleiche: CMERGE (m, n) ≤ m + n − 1
Zeitbedarf:
TMERGE (m, n) ∈ O(m + n)
Platzbedarf:
SMERGE (m, n) = 2(m + n) + c
Algorithmus 5.2.2. MERGE SORT (Sortieren durch Mischen): Die rekursive
Hilfemethode sortiert den Bereich a[left] bis a[right]. Beachte: Gilt left = right,
so ist dieser Bereich bereits sortiert. Gilt left < right und ist mid = b(left +
right)/2c, so gelten left ≤ mid und mid + 1 ≤ right.
mergeSort( Comparable[ ] a ) {
mergeSort( a, new Comparable[ n ], 0, n−1 );
3 }
1
2
mergeSort
4
5
6
7
8
9
10
11
12
mergeSort( Comparable[ ] a, Comparable[ ] b, int left, int right ) {
if( left < right ) {
int mid = ( left + right ) / 2;
mergeSort( a, b, left, mid );
mergeSort( a, b, mid+1, right );
merge( a, b, left, mid, right );
}
}
mergeSort
5.2. SORTIERVERFAHREN MIT DIVIDE-AND-CONQUER
191
Zum Aufwand: Die Anzahl der Schlüsselvergleiche ist CMERGE SORT (1) = 0 und
CMERGE SORT (n) = 2 · CMERGE SORT (n/2) + (n − 1) für n > 1, also gilt:
Anzahl der Schlüsselvergleiche: CMERGE SORT (n) ∈ O(n log n)
Zeitbedarf:
TMERGE SORT (n) ∈ O(n log n)
Platzbedarf:
SMERGE SORT (n) = 2n + c
Hieraus und aus der Art und Weise, wie in MERGE die beiden Teilfolgen zusammengemischt werden, ergibt sich die folgende Aussage.
Satz 5.2.3. Algorithmus MERGE SORT ist ein stabiles Sortierverfahren, das
ein Array mit n Elementen in Zeit O(n log n) sortiert.
MERGE SORT arbeitet ausgehend von dem Gesamtbereich gewissermaßen TopDown. Für die Abarbeitung der rekursiven Aufrufe wird also ein Keller gebraucht. Durch ein entsprechendes Bottom-Up-Verfahren lässt sich dieser Keller
einsparen.
Algorithmus 5.2.4. MERGE SORT BOTTOM UP:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
mergeSortBottomUp( Comparable[ ] a ) {
Comparable[ ] b = new Comparable[ n ];
for( int step = 1; step < n; step *= 2 ) {
// Je zwei Bereiche der Groesse step werden zusammengemischt:
for( int left = 0; left < n; left += 2*step ) {
if( left + step < n ) { // sind noch zwei Bereich uebrig?
int mid = left + step − 1;
// Der letzte Bereich muss an Position n − 1 enden.
if( mid + step >= n )
merge( a, b, left, mid, n − 1 );
else
merge( a, b, left, mid, mid + step );
}
}
}
}
Bei MERGE SORT wird die zu sortierende Folge in der Mitte geteilt, die entstehenden Teilfolgen werden unabhängig voneinander sortiert, und schließlich
werden die sortierten Teilfolgen zu einer sortierten Folge zusammengemischt.
Es wäre natürlich vorteilhaft, wenn man die zu sortierende Folge stets so teilen
könnte, dass die Teilfolgen nach dem Sortieren nicht mehr zusammengemischt
werden müssten. Dies kann man erreichen, wenn man die Folge vor dem Teilen
so umordnet, dass für eine Stelle m (0 ≤ m < n) folgendes gilt:
0≤i<m<j<n
impliziert ai ≤ am ≤ aj .
mergeSortBottomUp
192
KAPITEL 5. SORTIERALGORITHMEN
Dieses Umordnen wird als Partitionieren bezeichnet, wobei das Element am
der Schnittpunkt (oder: das Pivotelement) ist. Alle Elemente, die in der umgeordneten Folge vor dem Schnittpunkt auftreten, sind also ≤ am , die dahinter ≥ am . Die noch zu sortierenden Teilfolgen sind dann (a0 , . . . , am−1 ) und
(am+1 , . . . , an−1 ). Für die Wahl des Schnittpunkts gibt es mehrere Strategien:
• Der Schnittpunkt ist das erste Element a0 der Eingabefolge.
• Der Schnittpunkt ist das mittlere Element ab(n−1)/2c der Eingabefolge.
• Der Schnittpunkt ist ein zufällig gewähltes Element der Eingabefolge.
• Betrachte das erste, das mittlere und das letzte Element der Eingabefolge,
d.h. die Menge {a0 , ab(n−1)/2c , an−1 }. Der Schnittpunkt ist das zweitgrößte
Element dieser Menge ( Median-von-drei“).
”
Bei zufällig verteilten Eingaben ist jede dieser Strategien akzeptabel, bei vorsortierten oder umgekehrt sortierten Eingabefolgen schneidet aber beispielsweise
die erste Strategie sehr schlecht ab, da die Partitionierung sehr ungleich große
Teilfolgen erzeugt.
Algorithmus 5.2.5. PARTITION: Aufteilen des Bereichs a[left] bis a[right]
mit der Strategie Median-von-drei“ zur Wahl des Schnittpunkts:
”
1
2
3
4
5
6
7
8
9
10
11
12
int partition( Comparable[ ] a, int left, int right ) {
// “Median von drei”:
int mid = ( left + right) / 2;
if( a[ mid ].compareTo( a[ left ] ) < 0 )
swap( a, mid, left );
if( a[ right ].compareTo( a[ left ] ) < 0 )
swap( a, right, left );
if( a[ right ].compareTo( a[ mid ] ) < 0 )
swap( a, right, mid );
Comparable cut = a[ mid ]; // der Schnittpunkt der Partitionierung
swap( a, mid, right−1 ); // der Schnittpunkt kommt an Position right-1
// Das eigentliche Partitionieren:
int i1 = left; // laeuft hinauf
int i2 = right−1; // laeuft hinunter
while( i1 < i2 ) {
do i1++;
while( a[ i1 ].compareTo( cut ) < 0 ); // nun ist a[i1] ≥ cut
do i2−−;
while( a[ i2 ].compareTo( cut ) > 0 ); // nun ist a[i2] ≤ cut
if( i1 < i2 )
swap( a, i1, i2 );
}
swap( a, i1, right − 1 ); // den Schnittpunkt wieder an seinen Platz bringen
return i1;
13
14
15
16
17
18
19
20
21
22
23
24
25
26
}
partition
5.2. SORTIERVERFAHREN MIT DIVIDE-AND-CONQUER
193
Beispiel 5.2.6. Die unsortierte Folge (93, 82, 30, 75, 13, 28, 33, 50, 11, 39) steht
bei (i). Bei (ii) wird die Situation nach Programmzeile 10 gezeigt. Der Schnittpunkt ist 39 als der Median der Menge {93, 13, 39}. Nach dem Vertauschen in
Zeile 11 entsteht das Array (iii); die initialen Zeigerpositionen (Zeile 14 und 15)
sind markiert. Das Vertauschen in Zeile 22 findet zweimal statt, bei (vi) und
bei (v); am Ende ist i1 = 5 und i2 = 4. Bei (vi) muss noch das Vertauschen in
Zeile 24 stattfinden. Das Resultat steht bei (vii).
a[0]
a[1]
a[2]
a[3]
a[4]
a[5]
a[6]
a[7]
a[8]
a[9]
82
82
82
30
30
30
75
75
75
13
39
11
28
28
28
33
33
33
50
50
50
30
75
11
28
13
30
11
39
93
13
13
33
33
30
30
28
↑
75
39
50
(vi)
(vii)
75
↑
28
28
33
↑
82
50
(v)
82
↑
33
11
11
39
↑
39
39
93
93
(iv)
93
13
13
↑
13
82
82
50
50
39
75
93
93
(i)
(ii)
(iii)
11
11
93
Zum Aufwand (sei n = right − left + 1):
Anzahl der Schlüsselvergleiche: CPARTITION (n) ≤ n + 2
Zeitbedarf:
TPARTITION (n) ∈ O(n)
Algorithmus 5.2.7. QUICK SORT (Sortieren durch Partitionieren): Zur Optimierung werden Arrays der Größe CUTOFF oder kleiner hierbei mit INSERTION SORT sortiert. CUTOFF muss dabei größer als 1 sein; typische Werte
sind zwischen 5 und 20.
void quickSort( Comparable[ ] a ) {
quickSort( a, 0, n − 1 );
3 }
1
2
quickSort
4
5
6
7
8
9
10
11
12
13
void quickSort( Comparable[ ] a, int left, int right ) {
if( left + CUTOFF > right ) // fuer kleine Bereiche
insertionSort( a, left, right );
else { // fuer grosse Bereiche
int m = partition( a, left, right );
quickSort( a, left, m − 1 ); // kleine Elemente sortieren
quickSort( a, m + 1, right ); // grosse Elemente sortieren
}
}
quickSort
194
KAPITEL 5. SORTIERALGORITHMEN
Satz 5.2.8. Algorithmus QUICK SORT sortiert ein Array mit n Elementen
im schlechtesten Fall in Zeit O(n2 ) mit O(n2 ) Schlüsselvergleichen.
Beweis: Wir haben gesehen, dass beim Partitionieren höchstens n+2 Schlüsselvergleiche und Zeit O(n) gebraucht werden. Im schlechtesten Fall wird beim
rekursiven Partitionieren der Schnittpunkt an den Rand gelegt, d.h. die Folge
wird nicht wirklich geteilt. Dann sind n geschachtelte Aufrufe möglich, wobei
der
der Größe n − i + 1 bearbeitet. Damit erhalten wir
Pn i-te Aufruf eine Teilfolge
2
2
i=1 (n − i + 3) ∈ O(n ) Schlüsselvergleiche und Rechenzeit O(n ).
Damit ist QUICK SORT im schlechtesten Fall sehr ineffizient. Für die mittlere
Rechenzeit sieht es aber wesentlich besser aus.
Satz 5.2.9. Für das Verhalten im Mittel gilt für QUICK SORT folgendes: Sind
alle Schlüssel verschieden und wird der Schnittpunkt zufällig gewählt, dann ist
die durchschnittliche Anzahl der Schlüsselvergleiche C(n) ∈ O(n log n).
Beweis: Sei (a0 , . . . , an−1 ) eine Folge, in der alle Schlüssel verschieden sind. Die
Wahl des Schnittpunktes beim Partitionieren soll nun zufällig geschehen1 . Wir
nehmen an, dass beim Aufruf von partition(a, `, r) jedes Element der Teilfolge
(a` , . . . , ar ) mit Wahrscheinlichkeit 1/(r − ` + 1) als Schnittpunkt gewählt wird.
Für jedes m (` ≤ m ≤ r) entstehen also mit Wahrscheinlichkeit 1/(r − ` + 1)
die beiden Teilfolgen (a` , . . . , am−1 ) und (am+1 , . . . , ar ). Damit erhalten wir
C(n) = (n + 1) +
n−1
1 X
·
C(k) + C(n − k − 1)
n
k=0
und C(0) = C(1) = 0. Also gilt
n · C(n) = n(n + 1) + 2 ·
n−1
X
C(k)
n−2
X
C(k).
k=0
und insbesondere für n > 1
(n − 1) · C(n − 1) = (n − 1)n + 2 ·
k=0
Subtrahiert man die letzte von der vorletzten Gleichung, so ergibt sich
n · C(n) − (n − 1) · C(n − 1) = 2n + 2 · C(n − 1),
also ist
C(n)
2
C(n − 1)
=
+
,
n+1
n+1
n
1
Man überzeuge sich, dass dies mit n + 1 Schlüsselvergleichen implementiert werden kann.
5.2. SORTIERVERFAHREN MIT DIVIDE-AND-CONQUER
195
woraus
n+1
X1
C(n)
2
2
2
2 C(1)
=
+ +
+ ··· + +
=2·
n+1
n+1 n n−1
3
2
k
k=3
folgt. Nun ist aber Hn =
Pn
k=1 1/k
die n-te Harmonische Zahl, für die
ln n ≤ Hn ≤ 1 + ln n
gilt. Damit erhalten wir die Abschätzung
n+1
X1
C(n)
=2·
= 2(Hn+1 − 3/2) < 2 ln(n + 1),
n+1
k
k=3
die wiederum C(n) < 2(n + 1) ln(n + 1) ∈ O(n log n) liefert.
Bemerkung 5.2.10. An zusätzlichem Speicherplatz braucht QUICK SORT
einen Keller zum Abarbeiten der Rekursion, der im schlechtesten Fall Platz
O(n) braucht. Ändert man QUICK SORT aber so ab, dass nach dem Teilen
einer Folge stets zuerst die kleinere Teilfolge weiterbearbeitet wird, so braucht
dieser Keller nur Platz O(log n). Diese Verbesserung ist im folgenden Algorithmus realisiert.
void quickSortTuned( Comparable[ ] a ) {
2
quickSortTuned( a, 0, n − 1 );
3 }
1
quickSortTuned
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void quickSortTuned( Comparable[ ] a, int left, int right ) {
if( left + CUTOFF > right ) // fuer kleine Bereiche
insertionSort( a, left, right );
else { // fuer grosse Bereiche
int m = partition( a, left, right );
// Rekursiven Aufruf zuerst fuer den kleineren Bereich:
if( m − left < right − m ) {
quickSortTuned( a, left, m − 1 ); // die kleinen Elemente sortieren
quickSortTuned( a, m + 1, right ); // die grossen Elemente sortieren
}
else {
quickSortTuned( a, m + 1, right ); // die grossen Elemente sortieren
quickSortTuned( a, left, m − 1 ); // die kleinen Elemente sortieren
}
}
}
Bemerkung 5.2.11. Das Sortierverfahren QUICK SORT ist nicht stabil.
quickSortTuned
196
5.3
KAPITEL 5. SORTIERALGORITHMEN
Sortieren durch Fachverteilen
Die bisher betrachteten Sortierverfahren benutzen von den verwendeten Schlüsselwerten nur die Eigenschaft, dass sie aus einer linear geordneten Menge stammen. In diesen Verfahren werden Entscheidungen in Abhängigkeit davon getroffen, wie gewisse Schlüsselvergleiche ausgehen. Für das Sortieren von n Elementen braucht man bei dieser Vorgehensweise im schlechtesten Fall Θ(n log n)
Vergleiche. Hier wollen wir nun noch ein Verfahren vorstellen, das Informationen über den Aufbau der Schlüsselelemente ausnutzt.
Sei dazu Item ⊆ {0, 1, . . . , mk − 1} für ein m ≥ 2 und ein k ≥ 1. Dann lässt sich
jedes Element p ∈ Item eindeutig darstellen als
p=
k−1
X
d i · mi
i=0
mit 0 ≤ di < m. Damit ist dk−1 dk−2 . . . d1 d0 die Darstellung der Zahl p zur
Basis m. Wir nehmen hier also an, dass der Schlüsselbereich Item endlich ist.
Das Sortierverfahren RADIX SORT sortiert eine Folge nun dadurch, dass die
einzelnen Ziffern der Darstellung der Schlüssel zur Basis m von rechts nach links
durchgegangen werden. Für jede Stelle wird die Folge einmal durchlaufen und
anhand der Werte dieser Stelle umgeordnet. Nach k Durchläufen ist die Folge
schließlich sortiert, der Aufwand beträgt also nur O(k · n).
Algorithmus 5.3.1. RADIX SORT sortiert hier der Einfachheit halber ein
Array über dem Typ Integer. Wir nehmen Item ⊆ {0, . . . , mk − 1} an für feste
Werte m ≥ 2 und k ≥ 1.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void radixSort( Integer[ ] a ) {
Erzeuge m leere Listen list[0] bis list[m − 1].
Erzeuge eine Hilfsliste tmpList, die zu Beginn die Elemente von a enthaelt.
for( int i = 0; i < k; i++ ) { // Stelle i betrachten
while( !tmpList.isEmpty( ) ) {
int p = tmpList.removeFirst( );
Sei p = dn−1 , . . . , d1 d0 als Darstellung zur Basis m.
list[ di ].addLast( p ); // p hinten in list[di ] einfuegen
}
for( int j = 0; j < m; j++ ) {
list[j] an tmpList anhaengen und list[j] leer machen.
}
}
// tmpList enthaelt jetzt alle Elemente von a in sortierter Folge.
tmpList.toArray( a ); // Schreibe diese Liste zurueck ins Array a
}
Beispiel 5.3.2. Seien k = 2 und m = 10, also Item ⊆ {0, . . . , 99}. Wir sortieren die Liste (3, 18, 14, 6, 47, 7, 56, 92, 98, 60, 75, 12). Für i = 0 werden die
Elemente wie folgt auf die Listen list[0] bis list[9] verteilt:
radixSort
5.4. SORTIERVERFAHREN IM VERGLEICH
list[0]
60
list[1]
list[2]
92
12
list[3]
3
list[4]
14
list[5]
75
197
list[6]
6
56
list[7]
47
7
list[8]
18
98
list[9]
Daraus entsteht die Liste (60, 92, 12, 3, 14, 75, 6, 56, 47, 7, 18, 98). Für i = 1 liefert die Verteilung der neuen Liste folgendes:
list[0]
3
6
7
list[1]
12
14
18
list[2]
list[3]
list[4]
47
list[5]
56
list[6]
60
list[7]
75
list[8]
list[9]
92
98
Wir erhalten also die sortierte Liste (3, 6, 7, 12, 14, 18, 47, 56, 60, 75, 92, 98).
Man kann sich leicht klarmachen, dass dieses Verfahren auch auf Schlüsselmengen übertragen werden kann, die aus verschiedenartigen Komponenten bestehen, wie beispielsweise das Datum. Dann wird erst nach dem Tag, dann nach
dem Monat und schließlich nach dem Jahr sortiert, wobei diese letzte Runde
selbst wieder aus mehreren Runden bestehen kann.
5.4
Sortierverfahren im Vergleich
Die folgende Tabelle fasst die wichtigsten Eigenschaften der betrachteten Sortierverfahren zusammen.
Verfahren
TREE SORT
HEAP SORT
SELECTION SORT
INSERTION SORT
mit Binärsuche
MERGE SORT
QUICK SORT
im Mittel
RADIX SORT
Vergleiche
O(n log n)
O(n log n)
O(n2 )
O(n2 )
O(n log n)
O(n log n)
O(n2 )
O(n log n)
O(k · n)
Zeitbedarf
O(n log n)
O(n log n)
O(n2 )
O(n2 )
O(n2 )
O(n log n)
O(n2 )
O(n log n)
O(k · n)
Platzbedarf
2n + c
n+c
n+c
n+c
n+c
2n + c
n + c log n
2n + c
stabil
+
−
−
+
−
+
−
+
Die folgenden beiden Tabellen geben die Ergebnisse einiger Experimente zum
Vergleich des Laufzeitverhaltens von Sortierverfahren wieder. Immer wurden
Arrays der Größe n zufällig mit ganzzahligen Werten aus dem Intervall [0, n)
belegt. Die erste Tabelle gibt die benötigte Rechenzeit pro Array der jeweiligen
Größe in Millisekunden an, die zweite Tabelle in Sekunden. Die Zahlen ergeben
sich als Durchschnittswerte für große Stichproben.
Laufzeitmessungen dieser Art sind in der Regel zurückhalten zu interpretieren,
da sie von vielen Faktoren (Plattform, Compiler, etc.) abhängen. Es zeigt sich
198
KAPITEL 5. SORTIERALGORITHMEN
immerhin deutlich, dass QUICK SORT unter allen betrachteten Verfahren das
mit Abstand schnellste ist. Und dies gilt, obwohl QUICK SORT das schlechteste
Verhalten im schlechtesten Fall unter den sechs schnellsten Sortierverfahren ist.
n
SELECTION SORT
INSERTION SORT
INSERTION S. BINARY SEARCH
MERGE SORT
MERGE S. BOTTOM UP
HEAP SORT
QUICK SORT
QUICK SORT TUNED
RADIX SORT
10
0,009
0,006
0,009
0,017
0,015
0,028
0,007
0,006
0,030
50
0,21
0,11
0,08
0,11
0,10
0,23
0,05
0,05
0,16
n
SELECTION SORT
INSERTION SORT
INSERTION S. BINARY SEARCH
MERGE SORT
MERGE S. BOTTOM UP
HEAP SORT
QUICK SORT
QUICK SORT TUNED
RADIX SORT
2000
0,411
0,192
0,026
0,008
0,008
0,019
0,005
0,005
0,012
10000
10,64
5,16
0,57
0,05
0,05
0,12
0,03
0,03
0,10
100
0,8
0,4
0,2
0,3
0,2
0,5
0,1
0,1
0,3
200
3,3
1,6
0,6
0,6
0,5
1,3
0,3
0,3
0,8
50000
270,33
147,26
14,50
0,29
0,35
0,79
0,20
0,21
1,63
500
21,1
10,5
2,3
1,6
1,5
3,8
0,9
0,9
2,1
100000
—
—
58,42
0,65
0,73
1,82
0,44
0,43
3,08
1000
94,9
45,5
7,6
3,5
3,5
8,5
2,0
2,0
4,4
200000
—
—
—
1,4
1,6
3,9
1,0
1,0
12,8
Programm 5.4.1. Zuletzt geben wir hier noch eine Implementierung aller diskutierten Sortierverfahren an, die auch den obigen Tests zugrunde lag:
1
2
3
import java.util.Arrays;
import java.util.List;
import java.util.LinkedList;
4
5
/** Die Klasse Sort implementiert verschiedene Sortieralgorithmen.
* Alle Algorithmen sortieren ein Array ueber dem Typ Comparable
* bezueglich der durch compareTo definierten Ordnung.
8
*/
9 public class Sort {
6
7
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/** Implementiert den Algorithmus SELECTION SORT. */
public static void selectionSort( Comparable[ ] a ) {
for( int i = 0; i < a.length−1; i++ ) {
// Erstes minimales Element in (a[i],. . .,a[n-1]) finden . . .
int min = i; // Position des aktuellen minimalen Elements
for( int j = i+1; j < a.length; j++ )
if( a[ j ].compareTo( a[ min ] ) < 0 )
min = j;
// . . . und mit a[i] vertauschen:
swap( a, min, i );
}
}
selectionSort
5.4. SORTIERVERFAHREN IM VERGLEICH
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
/** Hilfsmethode zur Vertauschung der Array-Elemente a[i] und a[j].
* Wir setzen 0 <= i, j < a.length voraus.
*/
private final static void swap( Comparable[ ] a, int i, int j ) {
Comparable tmp = a[ i ];
a[ i ] = a[ j ];
a[ j ] = tmp;
}
/** Implementiert den Algorithmus INSERTION SORT. */
public static void insertionSort( Comparable[ ] a ) {
for( int i = 1; i < a.length; i++ ) {
Comparable tmp = a[ i ];
int j = i;
for( ; j > 0 && tmp.compareTo( a[ j−1 ] ) < 0; j−− )
a[ j ] = a[ j−1 ];
a[ j ] = tmp;
}
}
/** Implementiert den Algorithmus INSERTION SORT als Hilfmethode fuer
* QUICK SORT auf dem Teil-Array a[left] bis a[right].
*/
public static void insertionSort( Comparable[ ] a, int left, int right ) {
for( int i = left+1; i <= right; i++ ) {
Comparable tmp = a[ i ];
int j = i;
for( ; j > left && tmp.compareTo( a[ j−1 ] ) < 0; j−− )
a[ j ] = a[ j−1 ];
a[ j ] = tmp;
}
}
/** Implementiert den Algorithmus INSERTION SORT mit Binaersuche. */
public static void insertionSortBinarySearch( Comparable[ ] a ) {
for( int i = 1; i < a.length; i++ ) {
Comparable tmp = a[ i ];
// Position fuer a[i] im Bereich a[0] bis a[i-1] binaer suchen:
int left = 0;
int right = i−1;
while( left <= right ) {
int mid = ( left + right ) / 2;
if( a[ i ].compareTo( a[ mid ] ) < 0 )
right = mid − 1;
else if( a[ i ].compareTo( a[ mid ] ) > 0 )
left = mid + 1;
else {
left = mid;
break;
}
}
199
swap
insertionSort
insertionSort
insertionSortBinarySearch
200
KAPITEL 5. SORTIERALGORITHMEN
// Dann a[i] an Position left bringen:
for( int j = i; j > left; j−− )
a[ j ] = a[ j−1 ];
a[ left ] = tmp;
75
76
77
78
}
79
80
}
81
82
83
84
85
/** Implementiert den Algorithmus MERGE SORT. */
public static void mergeSort( Comparable[ ] a ) {
mergeSort( a, new Comparable[ a.length ], 0, a.length−1 );
}
mergeSort
86
87
88
89
90
91
92
93
94
95
96
97
98
/** Eine rekursive Hilfsmethode fuer die Methode mergeSort
* sortiert den Bereich a[left] bis a[right].
*/
private static void mergeSort( Comparable[ ] a, Comparable[ ] b,
int left, int right ) {
if( left < right ) {
int mid = ( left + right ) / 2;
mergeSort( a, b, left, mid );
mergeSort( a, b, mid+1, right );
merge( a, b, left, mid, right );
}
}
mergeSort
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
/** Implementiert den Algorithmus MERGE SORT ohne Rekursion. */
public static void mergeSortBottomUp( Comparable[ ] a ) {
Comparable[ ] b = new Comparable[ a.length ];
for( int step = 1; step < a.length; step *= 2 ) {
// Je zwei Bereiche der Groesse step werden zusammengemischt:
for( int left = 0; left < a.length; left += 2*step ) {
if( left + step < a.length ) { // sind noch zwei Bereich uebrig?
int mid = left + step − 1;
// Der letzte Bereich muss an Position a.length-1 enden.
if( mid + step >= a.length )
merge( a, b, left, mid, a.length − 1 );
else
merge( a, b, left, mid, mid + step );
}
}
}
}
mergeSortBottomUp
117
118
119
120
121
122
123
124
125
126
127
/** Eine Hilfsmethode fuer die Methode mergeSort.
* Die sortierten Bereiche a[left] bis a[mid] und a[mid+1] bis a[right]
* werden zu einem sortierten Bereich a[left] bis a[right] gemischt.
* Im Array b wird das Ergebnis zwischengespeichert.
*/
private static void merge( Comparable[ ] a, Comparable[ ] b,
int left, int mid, int right ) {
int i1 = left; // laeuft hoch bis mid
int i2 = mid + 1; // laeuft hoch bis right
int bPos = left; // kopiere nach b ab bPos
merge
5.4. SORTIERVERFAHREN IM VERGLEICH
while( i1 <= mid && i2 <= right ) {
if( a[ i1 ].compareTo( a[ i2 ] ) <= 0 )
b[ bPos++ ] = a[ i1++ ];
else
b[ bPos++ ] = a[ i2++ ];
}
while( i1 <= mid ) // ersten Bereich vollends kopieren
b[ bPos++ ] = a[ i1++ ];
while( i2 <= right ) // zweiten Bereich vollends kopieren
b[ bPos++ ] = a[ i2++ ];
// Zuletzt von b nach a zurueckkopieren:
for( bPos = left; bPos <= right; bPos++ )
a[ bPos ] = b[ bPos ];
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
201
}
/** Implementiert den Algorithmus QUICK SORT. */
public static void quickSort( Comparable[ ] a ) {
quickSort( a, 0, a.length−1 );
}
quickSort
/** Arrays der Groesse CUTOFF oder kleiner werden bei QUICK SORT
* mit INSERTION SORT sortiert. CUTOFF muss > 1 sein.
*/
private final static int CUTOFF = 8;
/** Eine rekursive Hilfsmethode fuer die Methode quickSort. */
private static void quickSort( Comparable[ ] a, int left, int right ) {
if( left + CUTOFF > right ) // fuer kleine Bereiche
insertionSort( a, left, right );
else { // fuer grosse Bereiche
int m = partition( a, left, right );
quickSort( a, left, m − 1 ); // kleine Elemente sortieren
quickSort( a, m + 1, right ); // grosse Elemente sortieren
}
}
/** Implementiert den Algorithmus QUICK SORT mit kleiner Verfeinerung. */
public static void quickSortTuned( Comparable[ ] a ) {
quickSortTuned( a, 0, a.length−1 );
}
/** Eine rekursive Hilfsmethode fuer die Methode quickSortTuned. */
private static void quickSortTuned( Comparable[ ] a, int left, int right ) {
if( left + CUTOFF > right ) // fuer kleine Bereiche
insertionSort( a, left, right );
else { // fuer grosse Bereiche
int m = partition( a, left, right );
// Rekursiven Aufruf zuerst fuer den kleineren Bereich:
if( m − left < right − m ) {
quickSortTuned( a, left, m − 1 ); // die kleinen Elemente sortieren
quickSortTuned( a, m + 1, right ); // die grossen Elemente sortieren
}
quickSort
quickSortTuned
quickSortTuned
202
KAPITEL 5. SORTIERALGORITHMEN
else {
quickSortTuned( a, m + 1, right ); // die grossen Elemente sortieren
quickSortTuned( a, left, m − 1 ); // die kleinen Elemente sortieren
}
180
181
182
183
}
184
185
}
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
/** Eine Hilfsmethode fuer die Methode quickSort.
* Gibt die Position des Schnittpunkts zurueck.
*/
private static int partition( Comparable[ ] a, int left, int right ) {
// “Median von drei”:
int mid = ( left + right) / 2;
if( a[ mid ].compareTo( a[ left ] ) < 0 )
swap( a, mid, left );
if( a[ right ].compareTo( a[ left ] ) < 0 )
swap( a, right, left );
if( a[ right ].compareTo( a[ mid ] ) < 0 )
swap( a, right, mid );
Comparable cut = a[ mid ]; // der Schnittpunkt der Partitionierung
swap( a, mid, right−1 ); // der Schnittpunkt kommt an Position right-1
partition
201
// Das eigentliche Partitionieren:
int i1 = left;
// laeuft hinauf
int i2 = right−1; // laeuft hinunter
while( i1 < i2 ) {
do i1++;
while( a[ i1 ].compareTo( cut ) < 0 ); // nun ist a[i1] groesser gleich cut
do i2−−;
while( a[ i2 ].compareTo( cut ) > 0 ); // nun ist a[i2] kleiner gleich cut
if( i1 < i2 )
swap( a, i1, i2 );
}
swap( a, i1, right − 1 ); // den Schnittpunkt wieder an seinen Platz bringen
return i1;
202
203
204
205
206
207
208
209
210
211
212
213
214
215
}
216
217
218
219
220
221
222
223
224
225
226
/** Implementiert den Algorithmus RADIX SORT.
* Jede Zahl im Array a muss < 10^k sein.
*/
public static void radixSort( Integer[ ] a, int k ) {
LinkedList[ ] list = new LinkedList[ 10 ]; // die Listen list[0] bis list[9]
for( int i = 0; i < 10; i++ )
list[ i ] = new LinkedList( );
LinkedList tmpList = new LinkedList( ); // die Hilfsliste
for( int i = 0; i < a.length; i++ )
tmpList.addLast( a[ i ] );
227
228
229
230
231
232
for( int i = 0, mult = 1; i < k; i++ ) {
while( !tmpList.isEmpty( ) ) {
Integer p = (Integer)tmpList.removeFirst( );
list[ ( p.intValue( ) / mult ) % 10 ].addLast( p );
}
radixSort
5.4. SORTIERVERFAHREN IM VERGLEICH
for( int j = 0; j < 10; j++ ) {
tmpList.addAll( list[ j ] ); // list[j] an tmpList anhaengen
list[ j ].clear( ); // list[j] leer machen
}
mult *= 10;
233
234
235
236
237
}
// tmpList enthaelt jetzt alle Elemente von a in sortierter Folge
tmpList.toArray( a );
238
239
240
241
203
}
242
243
244
245
246
247
248
249
250
251
252
253
/** Testet, ob zwei Arrays identisch sind, d.h. gleich lang sind und an
* gleichen Positionen Elemente enthalten, die true beim Vergleich mit == liefern.
*/
public static boolean identical( Object[ ] a, Object[ ] b ) {
identical
if( a.length != b.length )
return false;
for( int i = 0; i < a.length; i++ )
if( a[ i ] != b[ i ] )
return false;
return true;
}
254
255
public static void main( String[ ] args ) {
main
256
257
258
259
260
261
262
263
264
265
final int n = 20;
final int max = 100;
// Erzeuge ein Array der Laenge n, das zufaellige ganzzahlige Werte
// gleichverteilt aus dem Bereich [0, max) enthaelt:
Integer[ ] a = new Integer[ n ];
for( int i = 0; i < n; i++ )
a[ i ] = new Integer( (int)( Math.random( ) * max ) );
System.out.println( "Das unsortierte Array:\n" + Arrays.asList( a ) );
System.out.println( );
266
267
268
269
270
271
272
273
274
275
276
// Teste
Integer[ ]
Integer[ ]
Integer[ ]
Integer[ ]
Integer[ ]
Integer[ ]
Integer[ ]
Integer[ ]
Integer[ ]
die verschiedenen Sortieralgorithmen:
a1 = (Integer[ ])a.clone( );
a2 = (Integer[ ])a.clone( );
a3 = (Integer[ ])a.clone( );
a4 = (Integer[ ])a.clone( );
a5 = (Integer[ ])a.clone( );
a6 = (Integer[ ])a.clone( );
a7 = (Integer[ ])a.clone( );
a8 = (Integer[ ])a.clone( );
a9 = (Integer[ ])a.clone( );
277
278
279
280
281
282
283
284
285
long time0 = System.currentTimeMillis(
selectionSort( a1 );
long time1 = System.currentTimeMillis(
insertionSort( a2 );
long time2 = System.currentTimeMillis(
insertionSortBinarySearch( a3 );
long time3 = System.currentTimeMillis(
mergeSort( a4 );
);
);
);
);
204
286
287
288
289
290
291
292
293
294
295
296
KAPITEL 5. SORTIERALGORITHMEN
long time4 = System.currentTimeMillis( );
mergeSortBottomUp( a5 );
long time5 = System.currentTimeMillis( );
a6 = (Integer[ ]) new VollstaendigerBinaererBaum( a ).heapSort( );
long time6 = System.currentTimeMillis( );
quickSort( a7 );
long time7 = System.currentTimeMillis( );
quickSortTuned( a8 );
long time8 = System.currentTimeMillis( );
radixSort( a9, Integer.toString( max − 1 ).length( ) );
long time9 = System.currentTimeMillis( );
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
System.out.println( "Das mit selectionSort sortierte Array:\n" +
Arrays.asList( a1 ) );
System.out.println( "Das mit insertionSort sortierte Array:\n" +
Arrays.asList( a2 ) );
System.out.println( "Das mit insertionSortBinarySearch sortierte Array:\n" +
Arrays.asList( a3 ) );
System.out.println( "Das mit mergeSort sortierte Array:\n" +
Arrays.asList( a4 ) );
System.out.println( "Das mit mergeSortBottomUp sortierte Array:\n" +
Arrays.asList( a5 ) );
System.out.println( "Das mit heapSort sortierte Array:\n" +
Arrays.asList( a6 ) );
System.out.println( "Das mit quickSort sortierte Array:\n" +
Arrays.asList( a7 ) );
System.out.println( "Das mit quickSortTuned sortierte Array:\n" +
Arrays.asList( a8 ) );
System.out.println( "Das mit radixSort sortierte Array:\n" +
Arrays.asList( a9 ) );
System.out.println( );
317
318
// Laufzeiten:
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
System.out.println( "Laufzeiten in Millisekunden:" );
System.out.println( "selectionSort:\t\t\t" +
(time1 − time0) + " ms" );
System.out.println( "insertionSort:\t\t\t" +
(time2 − time1) + " ms" );
System.out.println( "insertionSortBinarySearch:\t" +
(time3 − time2) + " ms" );
System.out.println( "mergeSort:\t\t\t" +
(time4 − time3) + " ms" );
System.out.println( "mergeSortBottomUp:\t\t" +
(time5 − time4) + " ms" );
System.out.println( "heapSort:\t\t\t" +
(time6 − time5) + " ms" );
System.out.println( "quickSort:\t\t\t" +
(time7 − time6) + " ms" );
System.out.println( "quickSortTuned:\t\t\t" +
(time8 − time7) + " ms" );
System.out.println( "radixSort:\t\t\t" +
(time9 − time8) + " ms" );
System.out.println( );
5.4. SORTIERVERFAHREN IM VERGLEICH
// Test, ob alle Sortierverfahren identische Resultate geliefert haben:
System.out.println( "Alle Algorithmen liefern sortierte Folgen: " +
( Arrays.equals( a1, a2 ) && Arrays.equals( a1, a3 ) &&
Arrays.equals( a1, a4 ) && Arrays.equals( a1, a5 ) &&
Arrays.equals( a1, a6 ) && Arrays.equals( a1, a7 ) &&
Arrays.equals( a1, a8 ) && Arrays.equals( a1, a9 ) ) );
System.out.println( );
341
342
343
344
345
346
347
348
349
// Test, ob Stabilitaet verletzt ist:
System.out.println( "Test auf Stabilitaet am Beispiel:" );
// Wir wissen, dass insertionSort stabil ist, also dient a2 als Referenzarray:
System.out.println( "selectionSort:\t\t\t" +
( identical( a2, a1 ) ? "+" : "-" ) );
System.out.println( "insertionSort:\t\t\t" + "+ (immer +)" );
System.out.println( "insertionSortBinarySearch:\t" +
( identical( a2, a3 ) ? "+" : "-" ) );
System.out.println( "mergeSort:\t\t\t" +
( identical( a2, a4 ) ? "+" : "-" ) + " (immer +)" );
System.out.println( "mergeSortBottomUp:\t\t" +
( identical( a2, a5 ) ? "+" : "-" ) + " (immer +)" );
System.out.println( "heapSort:\t\t\t" +
( identical( a2, a6 ) ? "+" : "-" ) );
System.out.println( "quickSort:\t\t\t" +
( identical( a2, a7 ) ? "+" : "-" ) );
System.out.println( "quickSortTuned:\t\t\t" +
( identical( a2, a8 ) ? "+" : "-" ) );
System.out.println( "radixSort:\t\t\t" +
( identical( a2, a9 ) ? "+" : "-" ) + " (immer +)" );
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
}
} // class Sort
205
206
KAPITEL 5. SORTIERALGORITHMEN
Literatur
A. Aho, J. Hopcroft, J. Ullman, The Design and Analysis of Computer
Algorithms. Addison-Wesley, 1974
K. Arnold, J. Gosling, D. Holmes, Die Programmiersprache Java.
Addison-Wesley, 2001
R.H. Güting, Datenstrukturen und Algorithmen. Teubner, 1992
E. Horowitz, S. Sahni, Fundamentals of Data Structures in PASCAL.
3rd Edition, Computer Science Press, 1990
K. Mehlhorn, Data Structures and Algorithms. Band 1 und 2, Springer, 1984
Datenstrukturen und Algorithmen. Band 1 und 2, Teubner, 1986
T. Ottmann, P. Widmayer, Algorithmen und Datenstrukturen.
BI-Wissenschaftsverlag, 1990
U. Schöning, Algorithmen – kurz gefaßt.
Spektrum Akademischer Verlag, 1997
R. Sedgewick, Algorithmen. 2. Auflage, Addison-Wesley, 2002
R. Sedgewick, Algorithmen in C++, Teil 1-4.
3. Auflage, Addison-Wesley, 2002
M.A. Weiss, Data Structures & Algorithm Analysis in Java.
Addison-Wesley, 1999
M.A. Weiss, Data Structures & Problem Solving using Java.
2nd Edition, Addison-Wesley, 2002
207
Herunterladen