Algorithmen und Datenstrukturen Skript zur Vorlesung

Dieter Hofbauer und Friedrich Otto FB Elektrotechnik/Informatik und FB Mathematik/Informatik Universit¨at 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¨ ur eine Reihe grundlegender Aufgaben und die dabei verwendeten Datenstrukturen vorgestellt und analysiert. F¨ ur 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 Ubungsaufgaben, 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 u ¨ber Listen implementieren . . . . . . . . . . . . . 35

1.3.5

Queues u ¨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¨ aume und ihre Implementierung

45

2.1

Die Datenstruktur Baum

2.2

Implementierung bin¨ arer B¨aume . . . . . . . . . . . . . . . . . . 49

2.3

Erste Anwendungen bin¨arer B¨aume . . . . . . . . . . . . . . . . . 70

2.4

. . . . . . . . . . . . . . . . . . . . . . 46

2.3.1

TREE SORT . . . . . . . . . . . . . . . . . . . . . . . . . 70

2.3.2

HEAP SORT . . . . . . . . . . . . . . . . . . . . . . . . . 72

Darstellungen allgemeiner B¨aume . . . . . . . . . . . . . . . . . . 79 5

6

INHALTSVERZEICHNIS

3 Datentypen zur Darstellung von Mengen

85

3.1

Mengen mit Vereinigung, Schnitt und Differenz . . . . . . . . . . 85

3.2

Suchb¨ aume . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 91

3.3

Gewichtsbalancierte B¨aume . . . . . . . . . . . . . . . . . . . . . 98

3.4

H¨ ohenbalancierte B¨aume . . . . . . . . . . . . . . . . . . . . . . . 114 3.4.1

AVL-B¨ aume . . . . . . . . . . . . . . . . . . . . . . . . . . 115

3.4.2

(2,4)-B¨ aume . . . . . . . . . . . . . . . . . . . . . . . . . . 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¨ urzeste Wege . . . . . . . . . . . . . . . . . . . . . . . . 165

4.1.3

Starke Komponenten . . . . . . . . . . . . . . . . . . . . . 172

Ungerichtete Graphen . . . . . . . . . . . . . . . . . . . . . . . . 178 4.2.1

Minimale aufspannende W¨alder . . . . . . . . . . . . . . . 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¨ upft. 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¨asst sich graphisch wie folgt veranschaulichen [G¨ uting, 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 u ¨berwiegend mit der mittleren Ebene dieses Diagramms befassen. Die folgenden Beispiele sollen das obige Diagramm ein wenig erl¨autern, 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¨ ugt oder entfernt werden k¨onnen und der Test auf Enthaltensein durchgef¨ uhrt werden kann! Angabe der Signatur: Drei Datenmengen spielen hier eine Rolle, n¨amlich die ganzen Zahlen, die endlichen Teilmengen der ganzen Zahlen und die Wahrheitswerte (Boolesche1 Werte). F¨ ur jede dieser Menge w¨ahlen wir ein Sortensymbol, hier integer,

intset,

boolean.

Zur Angabe der Signatur geh¨ ort nun noch, Symbole f¨ ur 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¨ ussen konkrete Datenmengen und Operationen angegeben werden. Jedes Symbol der zugeh¨origen Signatur wird interpretiert, im Beispiel etwa wie folgt. Wir w¨ahlen 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¨ ullen die Operationen die Vorgaben der obigen Signatur bez¨ uglich der Argument- und Zielmengen. Algorithmische Realisierung: Man w¨ahlt eine Realisierung f¨ ur die Datenmengen und gibt dann entsprechende Algorithmen an, die die obigen Operationen realisieren (vgl. Beispiel 1.1.1). Hier eine m¨ogliche Implementierung in Java f¨ ur 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¨onnen nur die hier eingef¨ uhrten 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¨opfen und den Typdeklarationen der Felder. F¨ ur die Verwendung einer Datenstruktur m¨ ussen Anwender neben der Syntax nat¨ urlich auch die Semantik dieser Datenstruktur kennen. Diese wird durch die Implementierung festgelegt, was nat¨ urlich 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¨ ugung sowie einige logische Operationen wie Negation und Konjunktion. Eine algebraische Spezifikation dieser Struktur k¨onnte so aussehen: ¨ Uber 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 u ¨berfl¨ ussig, 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¨ amlich ein neues Objekt; ein solches Vorgehen ist meist nicht angemessen, da hierbei viel Speicherplatz verschwendet wird. In der Regel wird man wie im n¨ achsten Beispiel vorgehen und alternativ die bereits vorhandenen Objekte manipulieren. Beispiel 1.1.5. Die Datenstruktur Nat enth¨alt einige einfache Operationen u urlichen Zahlen. Wollen wir Nat durch eine algebraische Spezifika¨ber den nat¨ tion definieren, so gehen wir wie folgt vor. Wir erweitern die Spezifikation aus Beispiel 1.1.3 um das Sortensymbol nat, erg¨anzen 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¨ andig ist. Diese Forderungen sind oft nur schwer zu erf¨ ullen (und noch schwerer zu verifizieren). Beispiel 1.1.7. Eine andere Spezifikation f¨ ur 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¨ ortern (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¨ oß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¨ ur die Speicherabbildung von ganzen Zahlen, hier am Beispiel des Typs int, wird der Wert im Bin¨arcode in folgendem Format dargestellt: z≥0:

2er-Komplement:

0

bin(z) bin(232 − |z|)

z=< 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 atter 12 , /.-, 6 bis 7654 10 und 7654 13 haben keine Kinder. Sie sind die Bl¨ des Baums, w¨ ahrend 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¨ aumen u ¨ber einer Knotenmenge V unterscheiden wir zwei Arten, die ungeordneten und die geordneten. Bei der ersten Struktur kommt es auf die Reihenfolge der Unterb¨ aume 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¨aume, einen geordneten Baum stattdessen als (geordnete) Folge seiner Wurzel gefolgt von der Folge seiner direkten Unterb¨ aume. Beide Definitionen sind also rekursiv. Definition 2.1.1 ((Ungeordneter) Baum). Sei V eine Menge von Knoten. • F¨ ur v ∈ V ist (v, {}) ein Baum; er hat nur den Knoten v, seine Wurzel. • Sind B1 , . . . , Bm (m ≥ 1) B¨aume 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¨aumen (oder: Teilb¨aumen) 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¨ ur v ∈ V ist (v) ein geordneter Baum; er hat nur den Knoten v, seine Wurzel. • Sind B1 , . . . , Bm (m ≥ 1) geordnete B¨aume 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¨ ur 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¨ ohe eines Baumes B ist definiert als H¨ ohe(B) = max{Tiefe(v) | v ist Knoten von B}. Ein wichtiger Sonderfall der geordneten B¨aume sind die bin¨aren B¨aume. In Abweichung zu obiger Definition l¨asst man auch leere bin¨are B¨aume zu, d.h. bin¨are B¨ aume ohne Knoten. Definition 2.1.3. Ein bin¨ arer Baum ist entweder leer, oder er ist ein geordneter Baum mit Knotenmenge V 6= ∅, und es gilt d(v) ≤ 2 f¨ ur alle v ∈ V . Wir unterscheiden bei einem Knoten eines bin¨aren 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¨ aume sind B1 und B2 identisch, nicht aber als bin¨are B¨aume. Nachdem wir die Objekte Bin¨are B¨aume“ definiert haben, k¨onnen wir nun ” die Datenstruktur Bin¨ are B¨ aume u uhren. Hier eine ¨ber einer Menge Item“ einf¨ ” m¨ogliche algebraische Spezifikation dieses Datentyps:

¨ KAPITEL 2. BAUME UND IHRE IMPLEMENTIERUNG

48

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¨arer Baum der H¨ohe h. (a) F¨ ur 0 ≤ i ≤ h gilt, dass B h¨ochstens 2i Knoten der Tiefe i enth¨alt. (b) B enth¨ alt mindestens h + 1 und h¨ochstens 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¨arer Baum. Ist n0 die Anzahl der Bl¨ atter und n2 die Anzahl der Knoten vom Grad 2 in B, so gilt n0 = n2 + 1. Beweis: Durch Induktion u ¨ber den Aufbau von B.

¨ ¨ 2.2. IMPLEMENTIERUNG BINARER BAUME

49

Definition 2.1.7. Ein nicht-leerer bin¨arer Baum der H¨ohe h ist voll, wenn er 2h+1 − 1 Knoten enth¨ alt. Definition 2.1.8. Sei B ein bin¨arer Baum der H¨ohe h mit n > 0 Knoten und sei B 0 ein voller bin¨ arer Baum der H¨ohe h. In B 0 seien die Knoten stufenweise von links nach rechts durchnummeriert (vgl. obiges Beispiel). B heißt vollst¨ andig, wenn er dem bin¨ aren 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¨andig nennen. Beispiel 2.1.9. Die Abbildung zeigt einen vollen bin¨aren Baum der H¨ohe 2. Werden die Knoten mit den Nummern 6 und 7 gestrichen, entsteht ein vollst¨andiger bin¨ arer Baum mit f¨ unf Knoten.  1   HH    H 2 3   @ @     @ @ 4 5 6 7   

Beachte: F¨ ur vollst¨ andige bin¨ are B¨aume mit n > 0 Knoten und H¨ohe h gilt 2h ≤ n < 2h+1 .

2.2

Implementierung bin¨ arer B¨ aume

F¨ ur vollst¨ andige bin¨ are B¨ aume (Definition 2.1.8) erhalten wir eine sehr einfache und effiziente Implementierung mittels eindimensionaler Felder. Sei B ein vollst¨ andiger bin¨ arer Baum mit n Knoten, wobei in den Knoten Elemente der Menge Item gespeichert seien3 . Wir implementieren B durch ein Feld der L¨ ange 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¨ ur vollst¨ andige bin¨are B¨aume 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¨ ussel des Knoten.

50

¨ KAPITEL 2. BAUME UND IHRE IMPLEMENTIERUNG

Beweis: (a) folgt aus (b) und (c). (b) Durch Induktion nach i: Der Fall i = 1 ist klar. F¨ ur 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¨andiger bin¨arer B¨aume: 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 (1 − 2α)/(1 − α) durch Doppelrotation nach links, (b) f¨ ur ρ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¨ ugt 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 aume. Nach Voraussetzung sind die Teilb¨aume  aBB  dBB  eBB  B BB[α]-B¨

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¨onnen die entsprechenden Aussagen f¨ ur ρ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 BAUME

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¨amlich 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¨ ur den jeder echte Teilbaum in BB[α] ist, er selbst aber nicht wegen ρx > 1 − α. Dann entsteht daraus ein BB[α]-Baum (a) f¨ ur ρy > α/(1 − α) durch Rotation nach rechts, (b) f¨ ur ρ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¨ ugen eines Knotens mit Schl¨ ussel 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¨ ugen 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¨ ugen in BB[α]-B¨aume: 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¨ ugenden Knotens im Baum. (2.) F¨ uge einen neuen Knoten mit Schl¨ ussel key ein. (3.) Gehe den Weg von diesem neuen Knoten zur¨ uck zur Wurzel; f¨ ur 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¨ ohe von T ) = O(log |T |) (nach Satz 3.3.4). Lemma 3.3.12. Sei T ein BB[α]-Suchbaum und sei key ein Schl¨ ussel, der in T nicht vorkommt. Dann erzeugt insert( key ) aus T einen BB[α]-Suchbaum, der alle Schl¨ ussel aus T und den Schl¨ ussel key enth¨alt. Beweis: Sei v0 , v1 , . . . , vk der Weg in T von der Wurzel zu dem Knoten vk , als dessen Kind der Knoten mit Schl¨ ussel key in Schritt (2.) eingef¨ ugt wird, und sei S der entstehende Baum. Dann ist S ein Suchbaum, der alle Schl¨ ussel aus T und den Schl¨ ussel key enth¨alt. 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¨aume von S sind Teilb¨aume von T und damit BB[α]-B¨ aume. Gilt nun stets α ≤ ρ(Si ) ≤ 1 − α, so sind alle Teilb¨aume von S BB[α]-B¨ aume, also auch S. Andernfalls sei j maximal mit ρ(Sj ) ∈ / [α, 1 − α].

insert

¨ 3.3. GEWICHTSBALANCIERTE BAUME

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

(`)


= 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 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 BAUME

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¨ ohenbalancierte B¨ aume

Es gibt viele Arten h¨ ohenbalancierter B¨aume, etwa (a, b)-B¨aume, B-B¨aume, rot-schwarze B¨ aume und AVL-B¨aume. Wir wollen uns hier zun¨achst mit den letzteren befassen. Im Anschluss schauen wir uns noch die (2,4)-B¨aume an.

main

¨ ¨ 3.4. HOHENBALANCIERTE BAUME

3.4.1

115

AVL-B¨ aume

Die H¨ ohe 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¨atzlich die H¨ ohe des leeren Baums mit −1 fest. Definition 3.4.1. Die H¨ ohenbalance eines bin¨aren Baums mit direkten linken bzw. rechten Teilb¨ aumen T` und Tr ist der Wert h(T` ) − h(Tr ). Ein Baum heißt h¨ ohenbalanciert, falls jeder seiner Teilb¨aume eine H¨ohenbalance aus {−1, 0, 1} hat. H¨ ohenbalancierte Suchb¨aume werden AVL-B¨ aume genannt nach ihren Erfindern Adel´son-Vel´skiˇı und Landis (1962). Beispiel 3.4.2. Die H¨ ohenbalance der Teilb¨aume steht hier an ihrer Wurzel: ggg Juli 0 XXXXXXXXX ggggg M¨ arz 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¨ arz −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¨ ur AVL-B¨ aume mit n Knoten und H¨ohe 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¨ ur bin¨ are B¨ aume gilt n ≤ 2h+1 −1 nach Lemma 2.1.5(b). Wir m¨ ussen also nur noch die untere Schranke f¨ ur n nachweisen. F¨ ur m > 0 sei dazu Fm ein AVL-Baum mit H¨ ohe 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¨ aume werden auch Fibonacci-B¨ aume 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¨ ur 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¨ ur AVL-B¨ aume mit n Knoten und H¨ohe h gilt log(n + 1) − 1 ≤ h < 1, 441 · log(n + 2). AVL-B¨ aume sind also h¨ ochstens um circa 44% h¨oher als minimal m¨oglich. Insbesondere sind Suchen, Einf¨ ugen und L¨oschen in Zeit O(log n) realisierbar. Leider kann sowohl beim Einf¨ ugen als auch beim L¨oschen die H¨ohenbalanciertheit zerst¨ ort werden. Wie bei den BB[α]-B¨aumen m¨ ussen wir in einem solchen Fall durch Rebalancierungsoperationen versuchen, den entstandenen Baum in einen ¨ aquivalenten umzuformen, der wieder h¨ohenbalanciert ist. Einf¨ ugen in AVL-B¨ aume 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¨ ugen 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. HOHENBALANCIERTE BAUME

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¨ ugen 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¨ ugen 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¨ ugen 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¨ ugen 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¨ugen  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¨ aumen stets wieder Suchb¨ aume. (2) Wird in einen AVL-Baum T ein Knoten eingef¨ ugt, so ist der resultierende Baum T 0 entweder wieder ein AVL-Baum, oder der kleinste Teilbaum Tˆ von T 0 , der den neu eingef¨ ugten Knoten enth¨alt und der selbst kein AVL-Baum mehr ist, hat eine der Formen, wie sie in obiger Definition jeweils nach einer Einf¨ uge-Operation auftreten. Insbesondere ist die Wurzel von Tˆ der unterste Knoten auf dem Weg von der Wurzel von T 0 zu dem neu eingef¨ ugten Knoten, der H¨ ohenbalance ±2 hat.

¨ ¨ 3.4. HOHENBALANCIERTE BAUME

119

(3) Wird ein Teilbaum Tˆ rebalanciert, so entsteht ein Teilbaum Tˆ0 , der dieselbe H¨ohe hat wie der Teilbaum Tˆ vor dem Einf¨ ugen des neuen Knotens: T0 : T 00 : S  S S ; ;  T S  S Rebalancieren  TS Einf¨ u 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¨ ussel

Baum nach Einf¨ ugen

(1.) Mai

Baum nach Rebalancieren

Mai 0



Mai −1R R

(2.) M¨arz

— M¨ arz 0

Mai −2 SS S M¨ arz −1R R

(3.) November

RR: 0 Mai

0 M¨ arzQ 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¨ arzQ Q mm

2 M¨ arzQ Q mm

— Nov 0

Nov 0

LL:

1 M¨ arzPP P nn 0 Aug P Nov 0 PP pp 0 Apr Mai 0

(6.) Januar

2 M¨ arzPP 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¨ arzQ 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¨ arz −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¨ arz −1Q QQ Jan 0 RR Nov 0 R mmm 0 Dez Juli 0

mm −1 AugP PP nn





2 Mai RR R M¨ arz −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¨ arz −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¨ arz −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¨ arz −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¨ arz −2R −1 JuliQ Febr 0 RR QQ nn 0 April Nov −1QQ Juni 0 Q

(11.) Oktober

Okt 0

¨ ¨ 3.4. HOHENBALANCIERTE BAUME

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 arz Juni 0 0 M¨ 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 arz Juni 0 0 M¨ P Sept 0

Ger¨at ein AVL-Baum T durch das L¨ oschen eines Knotens k aus der Balance, so m¨ ussen durch eventuell mehrere Rotationen die Teilb¨aume, 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¨ uck 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¨ oschen 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¨ ohenbalance des dort wurzelnden Teilbaums (statt dessen H¨ohe). Die Rebalancierungsoperationen geben direkt an, wie sich diese Werte ¨andern. Satz 3.4.9. Werden Suchb¨ aume durch AVL-B¨aume implementiert, so ist Einf¨ ugen, Suchen und L¨ oschen in Zeit O(log n) realisierbar.

3.4.2

(2,4)-B¨ aume

Als zweite Variante der h¨ ohenbalancierten B¨aume betrachten wir noch die (2,4)B¨ aume. Die folgenden Begriffe k¨onnen ganz allgemein f¨ ur Zahlenpaare (a, b) mit a ≥ 2 und b ≥ 2a − 1 formuliert werden, was dann die (a, b)-B¨aume ergibt. Aus ¨ Gr¨ unden der Ubersichtlichkeit beschr¨anken wir uns hier aber auf den Spezialfall (2,4). Bei diesen B¨ aumen wird alle zu speichernde Information in den Bl¨attern untergebracht, d.h. f¨ ur n Schl¨ ussel brauchen wir einen Baum mit n Bl¨attern und einigen inneren Knoten, die der Verwaltung der Information dienen.

¨ ¨ 3.4. HOHENBALANCIERTE BAUME

123

Definition 3.4.10. Ein nicht-leerer geordneter Baum ist ein (2,4)-Baum, wenn alle Bl¨ atter dieselbe Tiefe haben und wenn f¨ ur alle inneren Knoten k gilt 2 ≤ d(k) ≤ 4. Wie wird nun eine Schl¨ usselmenge S = {x1 , . . . , xn } mit x1 < · · · < xn in einem (2,4)-Baum gespeichert? Dazu brauchen wir einen (2,4)-Baum mit n Bl¨attern, 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¨ ur alle Bl¨atter im i-ten direkten Teilbaum unterhalb von k Inhalt des Blattes ≤ y1

f¨ ur i = 1,

yi−1 < Inhalt des Blattes ≤ yi

f¨ ur 1 < i < r,

yr−1 < Inhalt des Blattes

f¨ ur i = r.

Beispiel 3.4.11. Ein (2,4)-Baum f¨ ur 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¨ohe von T ) Schritte. Lemma 3.4.12. F¨ ur einen (2,4)-Baum mit n Bl¨attern und H¨ohe 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¨ ullt, hat h h ein (2, 4)-Baum der H¨ ohe h h¨ ochstens 4 und mindestens 2 Bl¨atter, da ja alle Bl¨atter auf derselben Stufe h liegen. In einem (2,4)-Baum mit n Bl¨attern kostet die Operation Suchen also nur O(log n) Schritte. Wie kann man das Einf¨ ugen in (2,4)-B¨aumen realisieren? Durch die Operation Suchen wird ein innerer Knoten gefunden, der als Kind ein Blatt mit dem einzuf¨ ugenden Schl¨ ussel 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¨ ugt. Im Knoten selbst wird auch ein neuer Wert eingetragen, der die Blattinhalte korrekt voneinander trennt. Falls der Knoten durch dieses Anf¨ ugen den Grad d(k) = 5 erh¨ alt, wird er in zwei Knoten k 0 und k 00 aufgespalten. Dadurch erh¨alt der Elternknoten von k nun ein zus¨atzliches 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¨ ugt 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¨ oschen 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¨oschen, dann kann k 0 von k 00 das Kind 8 u ¨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¨oschen, 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¨ urlich kann sich die Wiederherstellung des (2,4)-Baums nach einer L¨oschOperation auch wieder bis zur Wurzel fortpflanzen. Satz 3.4.15. Werden Suchb¨ aume durch (2,4)-B¨aume implementiert, so ist Einf¨ ugen, Suchen und L¨ oschen in Zeit O(log n) realisierbar.

3.5

Hashing (Streuspeicherung)

In diesem Abschnitt lernen wir eine weitere Realisierung f¨ ur den Datentyp Dic” tionary“ kennen: die Hash-Tabelle. Die Grundidee der Hashing-Verfahren besteht darin, aus dem zu speichernden Schl¨ usselwert die Adresse im Speicher zu berechnen, an der dieses Element untergebracht wird. Sei etwa U ( Univer” sum“) der Bereich, aus dem die Schl¨ usselwerte stammen, und sei S ⊆ U eine zu speichernde Menge mit |S| = n. Zur Speicherung der Elemente von S stellen wir eine Menge von Beh¨ altern B0 , B1 , . . . , Bm−1 zur Verf¨ ugung. Nat¨ urlich gilt im allgemeinen |U |  m (d.h. |U | ist sehr viel gr¨oßer als m). Definition 3.5.1. Eine Hash-Funktion ist eine (totale) Funktion h : U → {0, . . . , m − 1}. F¨ ur a ∈ U gibt h(a) den Beh¨alter an, in dem der Schl¨ ussel a untergebracht werden soll. Der Wert n/|U | ist die Schl¨ usseldichte, 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¨alter sollten erfasst werden. • Sie sollte die zu speichernden Schl¨ ussel m¨oglichst gleichm¨aßig u ¨ber alle Beh¨ alter verteilen, d.h. jeder Beh¨alter sollte mit gleicher Wahrscheinlichkeit getroffen werden. • Sie sollte m¨ oglichst einfach zu berechnen sein. Beispiel 3.5.2. Wir wollen die Monatsnamen auf 13 Beh¨alter B0 , . . . , B12 verteilen. Als einfaches Beispiel einer Hash-Funktion verwenden wir dabei die folgende: Ist w = a1 a2 a3 . . . ak ein Wort der L¨ange ≥ 3 u ¨ber dem Alphabet {a, . . . , z}, so w¨ ahlen 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¨ alter da sind, die nichts enthalten, werden mehrfach Schl¨ ussel auf denselben Beh¨ alter 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¨alter beliebig viele Elemente aufnehmen, w¨ahrend beim geschlossenen Hashing jeder Beh¨ alter nur eine kleine konstante Anzahl b von Elementen aufnehmen kann. Falls mehr als b Elemente auftreten, die alle auf denselben Beh¨alter abgebildet ¨ werden, so entsteht ein Uberlauf. 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¨ usselwerte gleichm¨ aßig auf alle m Beh¨alter verteilt, d.h. f¨ ur 0 ≤ i < m ist  1 P r h(s) = i = . m Wir wollen die Wahrscheinlichkeit daf¨ ur bestimmen, dass bei einer zuf¨alligen Folge von n Schl¨ usseln (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¨ ur ist, dass der i-te Schl¨ ussel auf einen freien Platz kommt, wenn die Schl¨ ussel 1, . . . , i − 1 auch alle auf freie Pl¨atze 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¨ ur 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¨ ur das Hashing bedeutet die obige Analyse, dass Kollisionen praktisch unvermeidbar sind.

3.5. HASHING (STREUSPEICHERUNG)

127

Definition 3.5.4. Hashing mit Verkettung: F¨ ur jeden Beh¨alter wird eine verkettete Liste angelegt, in die alle Schl¨ ussel eingef¨ ugt werden, die auf diesen Beh¨alter abgebildet werden. F¨ ur 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¨ ur die Operationen Suchen, Einf¨ ugen und L¨oschen: 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¨ ur die Operationen Suchen, Einf¨ ugen und L¨ oschen gebraucht? F¨ ur 0 ≤ i < m sei HT [i] die Liste der Schl¨ ussel xj , f¨ ur die h(xj ) = i gilt. Mit |HT [i]| bezeichnen wir die L¨ange der i-ten Liste. Dann kostet jede Operation im schlechtesten Fall O(|HT [h(x)]|) viele Schritte. Ist n¨amlich immer h(xj ) = h(x1 ) f¨ ur 1 ≤ j ≤ n, dann enth¨alt 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¨ uhrung einer Operation Suchen, Einf¨ ugen oder L¨ oschen im schlechtesten Fall Rechenzeit O(n). Das Verhalten im Mittel ist aber wesentlich besser. Wir betrachten eine beliebige Folge von n Operationen Suchen, Einf¨ ugen und L¨oschen, wobei wir mit der leeren Menge beginnen. Anf¨ anglich 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¨ ussel aus U gleichm¨ aßig u ur i, j ∈ I gilt ¨ber das Intervall I: F¨ |h−1 (i)| = |h−1 (j)| =

|U | . m

• Alle Schl¨ ussel treten mit derselben Wahrscheinlichkeit als Argument einer Operation auf: F¨ ur s ∈ U und 1 ≤ i ≤ n gilt P r(das Argument der i-ten Operation ist der Schl¨ ussel s) =

1 . |U |

130

KAPITEL 3. DATENTYPEN ZUR DARSTELLUNG VON MENGEN

Aus diesen beiden Annahmen folgt P r(h liefert f¨ ur das Argument der i-ten Operation den Wert j) =

1 , m

d.h. h(x1 ), . . . , h(xn ) kann als eine Folge von n unabh¨angigen Ausf¨ uhrungen eines Zufallsexperiments mit Gleichverteilung u ¨ber {0, . . . , m − 1} aufgefasst werden. Satz 3.5.7. Unter obigen Annahmen hat eine Folge von n Operationen Suchen, Einf¨ ugen und L¨ oschen 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¨ uhrt! Beweis: Zuerst bestimmen wir die mittlere L¨ange der Liste HT [i] (0 ≤ i < m) nach Ausf¨ uhrung von k ≥ 1 Operationen. Es ist P r(w¨ ahrend 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¨ochstens die L¨ ange j. Sei nun `i (k) die mittlere L¨ange 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¨ ur alle i gilt, bedeutet dies, dass die (k + 1)-ste Operation im Mittel in Zeit O(1 + k/m) ausgef¨ uhrt werden kann. Damit erhalten wir f¨ ur die mittlere Rechenzeit einer Folge von n Operationen die folgende Absch¨atzung: 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¨alter nur eine konstante Anzahl b ≥ 1 von Schl¨ usseln aufnehmen kann. Wir betrachten dabei im folgenden den Spezialfall b = 1 und behandeln Hash-Tabellen, die Schl¨ ussel/Wert-Paare mit Schl¨ usseln vom Typ String und Werten vom Typ Object speichern. Diese Paarmengen sollen partielle Funktionen sein, d.h. jedem Schl¨ ussel ist jeweils h¨ ochstens 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 u ¨ber dem Typ Content. Den Typ Content versehen wir noch mit einem weiteren Feld vom Typ boolean, um zwischen den beiden folgenden F¨allen unterscheiden zu k¨onnen: • Ein Beh¨ alter ist leer (empty), also noch nie gef¨ ullt gewesen. • Ein Beh¨ alter ist zwar schon gebraucht worden, aber wegen einer L¨oschoperation 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¨ ur einen Schl¨ ussel x werden dann die Zellen h0 (x), h1 (x), . . . , hm−1 (x) der Reihe nach angeschaut. Sobald eine freie oder als gel¨oscht markierte Zelle mit Schl¨ ussel x gefunden wird, kann ein Paar mit Schl¨ ussel x eingef¨ ugt werden. Andererseits besagt das erste Auftreten einer freien Zelle, dass x nicht in der Hash-Tabelle enthalten ist. Hier sehen wir auch, warum gel¨oschte Paare markiert werden m¨ ussen; diese Technik bezeichnet man als lazy deletion“. ” Die Folge der Funktionen h0 , . . . , hm−1 sollte so gew¨ahlt werden, dass f¨ ur jeden Schl¨ usselwert s¨ amtliche Beh¨alter HT [i] (0 ≤ i < m) erreicht werden. Die einfachste Strategie hierf¨ ur 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¨osung 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¨arz

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¨alter abgebildet wird, wird durch lineares Sondieren bis zum n¨achsten unbelegten Beh¨alter verschoben“. Sind also k Beh¨alter hintereinander belegt, so ist die ” Wahrscheinlichkeit, dass im n¨ achsten Schritt der erste freie Beh¨alter nach diesen k Beh¨ altern belegt wird, mit (k + 1)/m wesentlich gr¨oßer als die Wahrscheinlichkeit, dass ein Beh¨ alter im n¨achsten Schritt belegt wird, dessen Vorg¨anger 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¨ oße m, die mit n Elementen gef¨ ullt ist. Beim Hashing mit linearem Sondieren entstehen f¨ ur eine Operation durchschnittlich folgende Kosten:  (1.) 1 + 1/(1 − α) /2 beim erfolgreichen Suchen und  (2.) 1 + 1/(1 − α)2 /2 beim erfolglosen Suchen. F¨ ur 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¨alter 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¨ ahlt man bei der zweiten Variante m = 4j + 3 als Primzahl, so wird jeder Beh¨ alter getroffen. (c) Doppel-Hashing: Seien h, h0 : U → {0, . . . , m − 1} zwei Hash-Funktionen. Dabei seien h und h0 so gew¨ahlt, dass f¨ ur 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¨ angig, wenn eine Doppelkollision 2 nur mit Wahrscheinlichkeit 1/m 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¨angig 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¨oglichen Schl¨ ussel eine nat¨ urliche 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¨orter u ¨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¨ alt 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¨angen, 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¨arzahl bin(w) aufgefasst. Diese Bin¨ arzahl wird in Teile der L¨ange k zerlegt, wobei das letzte Teil eventuell k¨ urzer sein kann. Diese Teile werden nun als Bin¨arzahlen 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¨ ur 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¨ ugen oder L¨ oschen beim Hashing im Mittel O(1) Schritte. Andererseits kann eine solche Operation im schlechtesten Fall aber auch O(n) Schritte kosten. F¨ ur jede Hash-Funktion h : U → {0, . . . , m − 1} sind es gewisse Schl¨ usselmengen S ⊆ U , die dieses schlechte Verhalten bewirken. W¨ urde man S kennen, k¨onnte man eine Hash-Funktion ausw¨ ahlen, die f¨ ur diese Schl¨ usselmenge m¨oglichst 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¨ahlt man zur Laufzeit eine Funktion durch ein Zufallsexperiment aus. Ist die Klasse H geeignet gew¨ahlt, so kann man zeigen, dass f¨ ur jede Schl¨ usselmenge 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¨ ur 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¨ ur, dass eine aus H zuf¨allig ausgew¨ahlte Funktion x und y auf denselben Beh¨alter abbildet, ist mit 1/m genauso groß wie die

3.5. HASHING (STREUSPEICHERUNG)

139

Wahrscheinlichkeit daf¨ ur, dass eine Kollision auftritt, wenn man die Werte h(x) und h(y) zuf¨ allig und unabh¨ angig voneinander aus {0, . . . , m − 1} ausw¨ahlt. Satz 3.5.15. Sei S ⊆ U eine beliebige Schl¨ usselmenge mit n Elementen, und sei H eine universelle Menge von Hash-Funktionen von U nach {0, . . . , m − 1}. Sei h ∈ H zuf¨ allig ausgew¨ ahlt. F¨ ur jeden Schl¨ ussel x ∈ S ist dann die mittlere Anzahl der Kollisionen |{y ∈ S \ {x} | h(y) = h(x)}| h¨ochstens n/m. Beweis: F¨ ur 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¨ ur 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¨ochstens 1. Wir beschließen diesen Abschnitt mit einem Beispiel f¨ ur eine universelle Menge von Hash-Funktionen. Definition 3.5.16. Alle Schl¨ ussel x ∈ U seien durch Bitstrings der L¨ange ` gegeben. Wir zerlegen jeden dieser Bitstrings in Bl¨ocke derselben L¨ange 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¨ ussel aus U mit xi , yj ∈ {0, . . . , m−1}. Da x 6= y ist, gibt es einen Index s mit xs 6= ys . Ohne Beschr¨ ankung 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 a¨quivalent zu −1

a0 = (x0 − y0 )

·

r X

ai (yi − xi ) mod m.

i=1

F¨ ur jede Wahl von a1 , . . . , ar ∈ {0, . . . , m − 1} gibt es also genau einen Wert a0 ∈ {0, . . . , m − 1}, so dass f¨ ur a = (a0 , a1 , . . . , ar ) die Gleichheit ha (x) = r+1 ha (y) gilt. Von den m vielen Funktionen aus H erf¨ ullen also genau mr viele Funktionen diese Gleichheit, d.h. f¨ ur 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¨ ussel a = (a0 , . . . , ar ) bilden, zuf¨ allig und unabh¨ angig ausw¨ahlt. Daher ist die Aufgabe, (Pseudo-) Zufallszahlen effektiv zu bestimmen, von großer Wichtigkeit. Leider k¨onnen 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 ¨ Aquivalenz-Anweisungen“ der Form ” a1 ≡ b1 , a2 ≡ b2 , . . . , am ≡ bm , ¨ wobei die ai , bj ∈ S sind. Die Anweisung ai ≡ bi besagt, dass die Aquivalenzklassen von ai und bi zu einer Klasse verschmolzen werden sollen. Außerdem ¨ wollen wir die Aquivalenzklasse eines gegebenen Elements aus S bestimmen k¨onnen, etwa indem wir einen Repr¨asentanten dieser Klasse angeben. Beispiel 3.6.1. Sei S = {1, 2, 3, 4, 5, 6}. Anf¨anglich sei jedes Element f¨ ur sich eine Klasse der betrachteten Partition P , d.h. P = {{1}, {2}, {3}, {4}, {5}, {6}}. Die Anweisungen unten bewirken nun die folgenden Ver¨anderungen 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¨ uhren: • 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¨aume: 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¨aume soll von jedem Knoten, der nicht Wurzel ist, ein Verweis auf seinen Elternknoten zeigen. Beispiel 3.6.3. F¨ ur 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¨origen 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¨ aume identifizieren, d.h. diese Elemente dienen als Repr¨ asentanten 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¨ ahlen wir f¨ ur 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¨ ur 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¨asentaten der Menge), die i enth¨alt. */ 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¨ uhrt 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¨ unstiges Laufzeitverhalten. Beispiel 3.6.5. F¨ ur die Partition {S1 , . . . , Sn } mit Si = {i} f¨ uhren 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¨ ur die Ausf¨ uhrung 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¨ uhren 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¨ ussen wir mit jedem Baum die Anzahl seiner Knoten speichern. Dies k¨onnen wir in der Wurzel tun, da die Wurzel ja keinen Verweis auf einen Elternknoten hat. Um die F¨alle, 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¨onnen, 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] = 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¨ ur eine Folge von q Vereinigungs-Operationen und p ≥ q Finde-Operationen abzusch¨ atzen, 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¨ achst extrem schnell. So gilt z.B. A(3, 4) = 22 , 65536-mal geschachtelt. Und f¨ ur 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¨achst 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¨otigt wird, eine Folge von q Vereinigungs-Operationen und p ≥ q Finde-Operationen mit den Algorithmen union und find auszuf¨ uhren, 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] = 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¨ ur solche Mengen von Objekten und Relationen sind die folgenden: • Objekte: Personen, Relation: A kennt B. • Objekte: Flugh¨ afen, Relation: es gibt einen Non-Stop-Flug von A nach B. • Objekte: Methoden eines Java-Programms, Relation: A ruft B auf. ¨ Ublicherweise 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¨ ur 0 ≤ i < r. Die L¨ ange 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¨ ur gibt es mehrere M¨oglichkeiten. Definition 4.1.8. Implementierung von Graphen durch Adjazenzmatrizen: Sei G = (V, E) mit V = {v0 , . . . , vn−1 } ein gerichteter Graph. Die Adjazenzmatrix f¨ ur G ist die boolesche (n × n)-Matrix (Ai,j )0≤i,j= N ) throw new IllegalArgumentException( "Knoten nicht vorhanden." ); matrix[v][w] = true; } /** Entfernt die Kante zwischen Knoten v und w, falls vorhanden. * Falls nicht 0 = 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¨ ur Graphen lassen sich leicht l¨osen, wenn die betrachteten Graphen durch Adjazenzmatrizen dargestellt sind. Allerdings ben¨otigt man f¨ ur das Lesen der Eingabe dann schon O(n2 ) Schritte, unabh¨angig davon, wieviele Kanten der jeweilige Graph enth¨ alt. F¨ ur 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¨ ur G durch verkettete Listen dar. F¨ ur 0 ≤ i < n gibt es eine Liste, die f¨ ur jede von vi ausgehende Kante einen Knoten enth¨alt, welcher

toString

154

KAPITEL 4. GRAPHEN UND GRAPH-ALGORITHMEN

das Ziel der jeweiligen Kante angibt. Außerdem gibt es f¨ ur 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¨ ur 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¨ ur e  n2 spart man mit der Darstellung durch Adjazenzlisten also Platz. • F¨ ur 0 ≤ i < n ist d− (vi ) die L¨ange der vi zugeordneten Liste. Die Bestimmung von d+ (vi ) ist wesentlich aufw¨andiger! • Um zu pr¨ ufen, ob die Kante (vi , vj ) im Graphen G enthalten ist, muss man die vi zugeordnete Liste durchsuchen: Zeitbedarf O(d− (vi )). • Die Repr¨ asentation 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 u ¨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 = 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 = 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¨ uhrt, so speichert man f¨ ur jeden Knoten eine zus¨atzliche 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¨ ur G nehmen wir nun auch noch verkettete Listen zur Darstellung der Spalten der Adjazenzmatrix von G. F¨ ur 0 ≤ i < n gibt es eine Liste, die f¨ ur jede in vi endende Kante einen Knoten enth¨alt, welcher den Start der jeweiligen Kante angibt. Außerdem gibt es f¨ ur jede dieser Listen einen Kopfknoten, welche wieder sequentiell angeordnet werden. Beispiel 4.1.16. (Forts. Beispiel 4.1.2) Die inversen Adjazenzlisten f¨ ur G1 : 0

b

1

1

ab

0

2

b

1

" ""   " ""

Stellt man einen gerichteten Graphen durch Adjazenzlisten und inverse Adjazenzlisten dar, so werden f¨ ur 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¨andig 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¨ ur jeden Knoten vi implementieren wir die Adjazenzliste und die inverse Adjazenzliste wie folgt. Wieder gibt es f¨ ur jede dieser Listen einen Kopfknoten. F¨ ur 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¨ uhren wir einen Knotentyp mit vier Feldern ein, je ein Feld f¨ ur Start- und Zielknoten und je ein Feld f¨ ur den Verweis auf den in der jeweiligen Liste n¨achsten Knoten:

start

ziel

slink

zlink

Beispiel 4.1.18. (Forts. von Beispiel 4.1.2) Die Adjazenzmultilisten f¨ ur G1 :

0

b

1

ab

0

1

2

b

b

0

b % % % % " 1 "" 

1

0 """

! !! 2 ! !!

1

2  

Tragen die Knoten eines Graphen zus¨atzlich Information, so kann man diese entweder in den Kopfknoten der Adjazenzlisten abspeichern, oder man f¨ uhrt in den Kopfknoten noch zus¨ atzliche Verweise auf die gespeicherte Information ein. Tragen die Kanten eines Graphen zus¨atzliche Information (z.B. Gewichte), so kann man diese analog entweder in den Listenknoten der Adjazenzlisten speichern, oder man h¨ alt 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¨ ussen wir den Graph G systematisch durchlaufen, um alle erreichbaren Knoten zu bestimmen. F¨ ur diese Aufgabe werden wir zwei L¨ osungen 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¨oglich in den Graphen einzudringen. Besuchte Knoten werden markiert. Hat ein Knoten u noch unmarkierte Nachbarn, so besuchen wir als n¨achstes einen dieser Nachbarn; hat u keine unmarkierten Nachbarn mehr, so gehen wir den bisher zur¨ uckgelegten Weg soweit zur¨ uck, bis wir zu einem Knoten gelangen, der noch unmarkierte Nachbarn hat.

4.1. GERICHTETE GRAPHEN

159

Zur Vereinfachung w¨ ahlen 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 u ¨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¨ uckgibt. 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¨angt 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¨ ochstens n Aufrufe von dfs m¨oglich. 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¨oglich, 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¨ achlich 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¨ ur jeden besuchten Knoten als n¨ achstes 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¨achstes 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¨ uck. 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¨angt wieder von der Darstellung des Graphen ab. Jeder Knoten wird h¨ochstens einmal in die Schlange aufgenommen. Wird ein Knoten entfernt, so werden alle seine Nachbarn u uft und dann gegebenenfalls in die Schlange aufgenommen. Die Men¨berpr¨ ge 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 = 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 = 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 = 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 = 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

Ku ¨ rzeste Wege

Hier wollen wir uns dem Problem zuwenden, k¨ urzeste 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¨ ur 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¨ urzester Weg von v0 nach v. Wir suchen nun einen Algorithmus, der f¨ ur alle Knoten v den Wert dist(v) bestimmt, und der f¨ ur Knoten v mit dist(v) < ∞ einen k¨ urzesten Weg von v0 nach v liefert. Bemerkung 4.1.28. Ist c(e) = 1 f¨ ur alle e ∈ E, so ist dist(v) gerade die Anzahl der Kanten in einem k¨ urzesten 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¨onnen wir in diesem Fall eine Variante des genannten Algorithmus zur Bestimmung der Abst¨ande 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¨ande 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¨ ahlt nun einen Knoten w ∈ T so aus, dass folgende Bedingung f¨ ur alle Knoten z ∈ T erf¨ ullt 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¨ urzesten Weg von v0 nach v, der nur Knoten in S durchl¨ auft. 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¨ urzester Weg von v0 nach v mit v1 , . . . , vm ∈ S. Wir behaupten, dass (v0 , v1 , . . . , vm , v, w) ein k¨ urzester 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¨achlich ein k¨ urzester 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¨ ur den folgenden Algorithmus. In jedem Schritt wird ein Knoten w wie beschrieben gew¨ahlt. Zur Bestimmung eines derartigen Knotens mit minimaler Distanz speichern wir Knoten zusammen mit ihrem vorl¨ aufigen Distanzwert in einem Heap. Solche Paare werden bez¨ uglich der Distanz geordnet; daf¨ ur eignet sich gerade der Typ Edge aus Programm 4.1.14. Zus¨ atzlich wird f¨ ur jeden Knoten w der aktuell bestimmte Wert dist(w) gespeichert, sowie der Knoten v = vorg¨anger(w) als Vorg¨anger von w auf dem aktuell gefundenen k¨ urzesten Weg von v0 nach w. Zu Beginn setzen wir vorg¨anger(v) = −1 und dist(v) = ∞ f¨ ur alle Knoten v. Die Menge T ist nur indirekt dargestellt: w ∈ V \ S geh¨ ort 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¨ ugt. 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¨ ur einen Greedy“-Algorithmus ” (greedy: engl. gierig, gefr¨ aßig). Solche Algorithmen werden meist zur L¨osung von Optimierungsproblemen eingesetzt, also zum Finden einer bez¨ uglich einer Zielfunktion optimalen (minimalen, maximalen, . . . ) L¨osung des gegebenen Problems. Ein Greedy-Algorithmus n¨ahert sich schrittweise einer L¨osung, wobei in jedem einzelnen Schritt die Zielfunktion optimiert wird; ein Zur¨ ucknehmen fr¨ uherer Schritte (backtracking) ist nicht vorgesehen. Leider lassen sich mit dieser L¨ osungsstrategie nur wenige Optimierungsprobleme l¨osen; in anderen F¨allen kommt man nicht umhin, viele m¨ogliche L¨osungen mehr oder weniger geschickt durchzuprobieren.

4.1. GERICHTETE GRAPHEN

167

Algorithmus 4.1.30. Dijkstras Algorithmus zur Bestimmung k¨ urzester Wege: 1 2 3 4 5 6 7 8

shortestPaths( Knoten start ) { for all( v ∈ V ) { vorg¨anger(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¨anger(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¨ urzeste Wege zwischen St¨adten 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¨angt 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¨ ur d¨ unne“ Graphen mit wenigen Kanten (|E|  |V |2 ) kann der Zeitbe” darf noch verringert werden. Daf¨ ur 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¨oße |E| haben, und es werden maximal |E| Einf¨ uge- bzw. L¨oschoperationen durchgef¨ uhrt. 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¨ochstens um einen konstanten Faktor schlechter als bei Verwendung eines Heaps, der lediglich Knoten speichert, also maximal die Gr¨oße |V | hat.) Programm 4.1.32. Die folgende Implementierung von Dijkstras Algorithmus erweitert Programm 4.1.14, das gewichtete Graphen u ¨ber Adjazenzlisten darstellt. Der Wert ∞ wird dabei durch double.MAX VALUE repr¨asentiert. Die Funktionen vorg¨ anger 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 u ¨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¨osen, 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¨ uhre 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¨ uhre Tiefensuche auf G−1 durch. Dabei wird jeweils mit dem Knoten begonnen, der unter allen noch nicht besuchten Knoten die h¨ ochste 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 u ¨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¨ unfte 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¨alt. 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¨ urlich reicht es, den Beweis f¨ ur den Knoten v zu f¨ uhren, 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¨ ur die zeitliche Abfolge der Aufrufe und ihrer Beendigungen von dfs in (1.) gibt es damit drei M¨oglichkeiten: (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 u ¨ber einen Zwischenknoten z f¨ uhrt, 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



x

x ¯

Damit ist µ(z) > µ(x), und in (2.) wird dfs(z) vor dfs(x) aufgerufen. Dieser Aufruf w¨ urde aber bereits den Knoten v erreichen, so dass v nicht erst in der Erreichbarkeitskomponente von x erreicht w¨ urde. 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¨ ur 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¨auft den Graphen G und notiert die neue Knotennummerierung, die zweite durchl¨auft den Graphen G−1 , wobei die Adjazenzmatrix von G entsprechend interpretiert wird, und ordnet jedem Knoten seine Komponentennummer zu. Als Aufwand f¨ ur 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 u ¨bertragen. Ein Zykel ist ein geschlossener einfacher Weg, der mindestens drei verschiedene Knoten durchl¨auft. F¨ ur 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¨ alt. 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¨alt man aus einem freien Baum einen Baum gem¨aß 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¨ ur 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¨ aume bzw. W¨ alder haben die folgenden wichtigen Eigenschaften. Lemma 4.2.2. Ein freier Baum mit n Knoten hat n − 1 Kanten; f¨ ugt man eine Kante hinzu, so entsteht genau ein Zykel. Allgemeiner gilt: Ein Wald mit n Knoten und k Komponenten hat n − k Kanten; f¨ ugt 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¨ ur ungerichtete Graphen auch die Zusammenhangskomponenten.

4.2.1

Minimale aufspannende W¨ alder

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¨ ur 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¨ ur G ist ein aufspannender Wald f¨ ur 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¨osen, 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¨ ur 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¨ ur 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¨ ur G. Beweis: F¨ ur (u, v) ∈ F 0 gilt die Aussage. Andernfalls enth¨alt (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¨alt dieser Weg auch eine Kante (¯ u, v¯), die verschiedene Komponenten von H verbindet. Damit ist der Graph H 00 = (V, F 0 \ {(¯ u, v¯)} ∪ {(u, v)})

180

KAPITEL 4. GRAPHEN UND GRAPH-ALGORITHMEN

ein aufspannender Wald f¨ ur G, und es gilt c(H 00 ) = c(H 0 ) − c((¯ u, v¯)) + c((u, v)) ≤ c(H 0 ) wegen c((u, v)) ≤ c((¯ u, v¯)). Also ist auch H 00 ein minimaler aufspannender Wald f¨ ur G, der nun (V, F ∪ {(u, v)}) als Teilgraphen enth¨alt. Auf diesem Lemma basiert der folgende Algorithmus von Kruskal zur Bestimmung eines minimalen aufspannenden Waldes f¨ ur G. Er arbeitet wie folgt: Zu Beginn wird der zu berechnende Wald als H = (V, ∅) gew¨ahlt. Nun werden die Kanten von G der Reihe nach angeschaut, wobei die Kanten nach aufsteigendem Gewicht sortiert sind. Hierf¨ ur eignet sich also eine Priorit¨atsschlange, 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¨ ur G = (V, E). Ein Heap speichert Kanten (v, w) als Tripel (v, w, c(v, w)); minimales Gewicht hat maximale Priorit¨at. 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¨ ur 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¨atsschlange 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¨ uhrt. Nach Satz 3.6.11 reichen hierf¨ ur 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¨ uglich 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 = 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 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 = nodeSize( ) | | destination >= nodeSize( ) ) throw new IllegalArgumentException( "Knoten nicht vorhanden." ); if( 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¨ uck, das wir in Abschnitt 2.3 schon angesprochen hatten. Dort hatten wir zwei Algorithmen f¨ ur das Sortieren kennengelernt, n¨ amlich TREE SORT und HEAP SORT, die beide bin¨are B¨ aume als unterliegende Datenstruktur f¨ ur das Sortieren verwenden. Hier werden wir einige weitere Sortieralgorithmen kennenlernen, analysieren und miteinander vergleichen.

5.1

Elementare Sortieralgorithmen

Zun¨ achst wollen wir die Problemstellung fixieren. Definition 5.1.1. Das Sortierproblem f¨ ur die durch die Ordnung ≤ linear geordnete Menge Item ist das Problem, folgende Aufgabe zu l¨osen: 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¨ost, 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¨ahrend HEAP SORT (aus Abschnitt 2.3.2) inh¨arent instabil ist. Hier betrachten wir zun¨ achst zwei elementare Sortierverfahren. Immer nehmen wir an, dass die Eingabefolge (a0 , . . . , an−1 ) in einem Array a der Gr¨oße n u ¨ber dem Typ Comparable steht. Am Ende der Berechnung enth¨alt das Array die sortierte Ausgabefolge. 187

188

KAPITEL 5. SORTIERALGORITHMEN

Algorithmus 5.1.3. SELECTION SORT (Sortieren durch Ausw¨ahlen): 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¨ usselvergleiche und O(n) viele Vertauschungen durchgef¨ uhrt. Beweis: Die ¨ auß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¨ ahlen 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¨ ugen, wird die Anfangsfolge der L¨ange 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¨ ugt wird. Algorithmus 5.1.5. INSERTION SORT (Sortieren durch Einf¨ ugen): 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¨ ur das Element a[i] gemacht, indem alle gr¨ oßeren Elemente zwischen a[i − 1] und a[0] um jeweils einen Platz nach rechts geschoben werden. DieP¨ auß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¨ usselvergleiche und Umspeicherungen vorgenommen. Statt das Anfangsst¨ uck von a[i−1] bis a[0] linear zu durchlaufen, um die richtige Position f¨ ur das Element a[i] zu bestimmen, kann man auf diesem Breich auch Bin¨arsuche f¨ ur diese Aufgabe einsetzen. Dann wird die Position f¨ ur 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¨ usselvergleichen aus, braucht aber dennoch Rechenzeit O(n2 ). Algorithmus 5.1.7. INSERTION SORT mit Bin¨arsuche : 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 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¨ achsten 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¨ ugt werden. Beim ersten Verfahren ist das Aufteilen trivial, und die gesamte Arbeit wird beim Zusammenf¨ ugen der sortierten Teilfolgen geleistet. Beim zweiten Verfahren erfordert das Aufteilen die haupts¨achliche Arbeit, und das Zusammenf¨ ugen 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