Algorithmen und Datenstrukturen Skript zur Vorlesung Dieter Hofbauer und Friedrich Otto FB Elektrotechnik/Informatik und FB Mathematik/Informatik Universität Kassel Vorwort Effiziente Algorithmen und Datenstrukturen sind ein zentrales Thema der Informatik. Man macht sich leicht klar, dass ein enger Zusammenhang besteht zwischen der Organisation von Daten (ihrer Strukturierung) und dem Entwurf von Algorithmen, die diese Daten bearbeiten. In dieser Vorlesung werden Algorithmen für eine Reihe grundlegender Aufgaben und die dabei verwendeten Datenstrukturen vorgestellt und analysiert. Für die meisten der Algorithmen wird zudem eine konkrete Implementierung in der Programmiersprache Java angegeben. Wichtige Informationen zur Lehrveranstaltung werden unter http://www.theory.informatik.uni-kassel.de/~dieter/algo/ bereitgestellt, unter anderem die Übungsaufgaben, die Programme und das Skript selbst. Kassel, April 2002 3 4 Inhaltsverzeichnis 1 Einleitung 7 1.1 Datenstrukturen und ihre Spezifikation . . . . . . . . . . . . . . . 7 1.2 Einige elementare Datenstrukturen . . . . . . . . . . . . . . . . . 17 1.3 Einige einfache strukturierte Datentypen . . . . . . . . . . . . . . 18 1.3.1 Keller (Stacks) . . . . . . . . . . . . . . . . . . . . . . . . 18 1.3.2 Schlangen (Queues) . . . . . . . . . . . . . . . . . . . . . 24 1.3.3 Einfach verkettete Listen . . . . . . . . . . . . . . . . . . 29 1.3.4 Stacks über Listen implementieren . . . . . . . . . . . . . 35 1.3.5 Queues über Listen implementieren . . . . . . . . . . . . . 37 1.3.6 Doppelt verkettete Listen . . . . . . . . . . . . . . . . . . 38 1.4 Rechenzeit- und Speicherplatzbedarf von Algorithmen . . . . . . 39 1.5 Asymptotisches Wachstumsverhalten . . . . . . . . . . . . . . . . 40 1.6 Die Java-Klassenbibliothek . . . . . . . . . . . . . . . . . . . . . 42 2 Bäume und ihre Implementierung 45 2.1 Die Datenstruktur Baum 2.2 Implementierung binärer Bäume . . . . . . . . . . . . . . . . . . 49 2.3 Erste Anwendungen binärer Bäume . . . . . . . . . . . . . . . . . 70 2.4 . . . . . . . . . . . . . . . . . . . . . . 46 2.3.1 TREE SORT . . . . . . . . . . . . . . . . . . . . . . . . . 70 2.3.2 HEAP SORT . . . . . . . . . . . . . . . . . . . . . . . . . 72 Darstellungen allgemeiner Bäume . . . . . . . . . . . . . . . . . . 79 5 6 INHALTSVERZEICHNIS 3 Datentypen zur Darstellung von Mengen 85 3.1 Mengen mit Vereinigung, Schnitt und Differenz . . . . . . . . . . 85 3.2 Suchbäume . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 91 3.3 Gewichtsbalancierte Bäume . . . . . . . . . . . . . . . . . . . . . 98 3.4 Höhenbalancierte Bäume . . . . . . . . . . . . . . . . . . . . . . . 114 3.4.1 AVL-Bäume . . . . . . . . . . . . . . . . . . . . . . . . . . 115 3.4.2 (2,4)-Bäume . . . . . . . . . . . . . . . . . . . . . . . . . . 122 3.5 Hashing (Streuspeicherung) . . . . . . . . . . . . . . . . . . . . . 125 3.6 Partitionen von Mengen mit UNION und FIND . . . . . . . . . . 140 4 Graphen und Graph-Algorithmen 4.1 4.2 149 Gerichtete Graphen . . . . . . . . . . . . . . . . . . . . . . . . . . 150 4.1.1 Traversieren von Graphen . . . . . . . . . . . . . . . . . . 158 4.1.2 Kürzeste Wege . . . . . . . . . . . . . . . . . . . . . . . . 165 4.1.3 Starke Komponenten . . . . . . . . . . . . . . . . . . . . . 172 Ungerichtete Graphen . . . . . . . . . . . . . . . . . . . . . . . . 178 4.2.1 Minimale aufspannende Wälder . . . . . . . . . . . . . . . 178 5 Sortieralgorithmen 187 5.1 Elementare Sortieralgorithmen . . . . . . . . . . . . . . . . . . . 187 5.2 Sortierverfahren mit Divide-and-Conquer . . . . . . . . . . . . . 189 5.3 Sortieren durch Fachverteilen . . . . . . . . . . . . . . . . . . . . 196 5.4 Sortierverfahren im Vergleich . . . . . . . . . . . . . . . . . . . . 197 Kapitel 1 Einleitung 1.1 Datenstrukturen und ihre Spezifikation Algorithmen und Datenstrukturen sind untrennbar miteinander verknüpft. Ein Algorithmus realisiert eine Funktion, und er wird selbst wiederum durch ein Programm realisiert. Auf der Seite der Daten entsprechen diesen Begriffen die Algebra (d.h. ein konkreter Datentyp), die Datenstruktur, die eine Implementierung einer Algebra ist, und die programmiertechnischen Konzepte der Klasse, des Moduls oder des Typs. Der Zusammenhang zwischen diesen Begriffen lässt sich graphisch wie folgt veranschaulichen [Güting, Abb. 1.1]: Abstrakter Datentyp Mathematik Spezifikation Implementierung Algorithmik Spezifikation Implementierung Programmierung Funktion Algorithmus Programm, Prozedur Funktion Algebra (Datentyp) Datenstruktur Typ, Modul, Klasse Dabei werden wir uns überwiegend mit der mittleren Ebene dieses Diagramms befassen. Die folgenden Beispiele sollen das obige Diagramm ein wenig erläutern, wobei sich Beispiel 1.1.1 auf die linke und Beispiel 1.1.2 auf die rechte Spalte in obigem Diagramm bezieht. 7 8 KAPITEL 1. EINLEITUNG Beispiel 1.1.1. Aufgabenstellung: Sei S eine endliche Menge von ganzen Zahlen. Stelle fest, ob eine gegebene Zahl in S enthalten ist! Diese informelle“ Beschreibung kann auf verschiedene Weisen formalisiert wer” den. Was soll gemacht werden? Mathematische Formulierung, Spezifikation: Die Funktion contains : V(Z) × Z → {true, false} mit ( true falls c ∈ S, contains(S, c) = false sonst, soll berechnet werden (V(M ) bezeichnet die Menge aller endlichen (engl. finite) Teilmengen einer Menge M ). Wie kann dies geschehen? Formulierung eines Algorithmus; mehr oder weniger formal, hier meist als (Pseudo-)Java-Programm: 1 2 3 4 5 6 7 8 9 10 /** Test, ob die ganze Zahl c in der Menge S enthalten ist. * Dabei ist S ein Array ueber dem Typ int. */ contains( int[ ] S, int c ) { for( int i = 0; i < S.length; i++ ) { if( S[i] == c ) return true; } return false; } contains Praktische Realisierung, hier als Java-Programm: 1 2 /** Die Klasse Menge1 implementiert Mengen ganzer Zahlen. */ public class Menge1 { 3 4 5 /** Die Menge als Array ueber dem Typ int. */ private int[ ] array; 6 7 8 9 10 /** Konstruiert eine Menge aus einem Array. */ public Menge1( int[ ] array ) { this.array = array; } Menge1 11 12 13 14 15 16 17 18 19 /** Test, ob die Zahl c in der Menge enthalten ist. */ public boolean contains( int c ) { for( int i = 0; i < array.length; i++ ) { if( array[i] == c ) return true; } return false; } contains 1.1. DATENSTRUKTUREN UND IHRE SPEZIFIKATION 9 20 21 public static void main( String[ ] args ) { main 22 Menge1 S = new Menge1( new int[ ] { 5, 8, 42 } ); 23 System.out.println( S.contains( 8 ) ); // true 24 System.out.println( S.contains( 9 ) ); // false 25 26 } } // class Menge1 Beispiel 1.1.2. Aufgabenstellung: Verwalte eine Menge von Objekten (ganze Zahlen), so dass Objekte eingefügt oder entfernt werden können und der Test auf Enthaltensein durchgeführt werden kann! Angabe der Signatur: Drei Datenmengen spielen hier eine Rolle, nämlich die ganzen Zahlen, die endlichen Teilmengen der ganzen Zahlen und die Wahrheitswerte (Boolesche1 Werte). Für jede dieser Menge wählen wir ein Sortensymbol, hier integer, intset, boolean. Zur Angabe der Signatur gehört nun noch, Symbole für jede der Operationen (synonym: Funktionen) festzulegen, die sog. Operationssymbole, sowie die jeweiligen Argumentsorten und die Zielsorte anzugeben: EMPTY : → intset INSERT : intset × int → intset DELETE : intset × int → intset CONTAINS : intset × int → boolean ISEMPTY : intset → boolean Die Signatur gibt somit die Syntax unseres Datentyps an. Die Semantik kann nun abstrakt (durch eine Menge von Axiomen, vgl. die Beispiele 1.1.3 und 1.1.5) oder konkret durch eine spezielle Algebra angegeben werden. Angabe einer Algebra: Dazu müssen konkrete Datenmengen und Operationen angegeben werden. Jedes Symbol der zugehörigen Signatur wird interpretiert, im Beispiel etwa wie folgt. Wir wählen die Menge Z als Interpretation des Sortensymbols int, die Menge V(Z) als Interpretation des Symbols intset und die Menge {true, false} als Interpretation von boolean. Die Operationssymbole EMPTY, INSERT, DELETE, CONTAINS bzw. ISEMPTY werden durch die 1 Nach George Boole (1815-1864) 10 KAPITEL 1. EINLEITUNG folgenden Operationen interpretiert (M ∈ V(Z), c ∈ Z): empty = ∅, insert(M, c) = M ∪ {c}, delete(M, c) = M \ {c}, ( true falls c ∈ M , contains(M, c) = false sonst, ( true falls M = ∅, isEmpty(M ) = false sonst. Wie man unschwer verifiziert, erfüllen die Operationen die Vorgaben der obigen Signatur bezüglich der Argument- und Zielmengen. Algorithmische Realisierung: Man wählt eine Realisierung für die Datenmengen und gibt dann entsprechende Algorithmen an, die die obigen Operationen realisieren (vgl. Beispiel 1.1.1). Hier eine mögliche Implementierung in Java für unser Beispiel: 1 2 /** Die Klasse Menge2 implementiert Mengen ganzer Zahlen. */ public class Menge2 { 3 4 5 /** Die maximale Groesse der Menge. */ private static final int MAX CARDINALITY = 10; 6 7 8 /** Die Menge als Array ueber dem Typ int. */ private int[ ] array = new int[MAX CARDINALITY]; 9 10 11 /** Die Groesse der Menge. */ private int size; 12 13 14 15 16 /** Konstruiert eine leere Menge. */ // Die Angabe des parameterlosen Konstruktors ist hier redundant. public Menge2( ) { } Menge2 17 18 19 20 21 /** Liefert eine neue leere Menge zurueck. */ public static Menge2 empty( ) { return new Menge2( ); } empty 22 23 24 25 26 27 28 29 30 31 32 /** Fuegt die Zahl c in die Menge ein. */ public void insert( int c ) { if( contains( c ) ) // Element bereits vorhanden return; if( size == MAX CARDINALITY ) // Menge ist bereits voll throw new IllegalStateException( "Menge ist bereits voll." ); array[size] = c; size++; } insert 1.1. DATENSTRUKTUREN UND IHRE SPEZIFIKATION 33 34 35 36 37 38 39 40 41 42 43 /** Entfernt die Zahl c aus der Menge, falls vorhanden. */ public void delete( int c ) { int i = 0; while( array[i] != c && i < size ) // suche c i++; if( i < size ) { // falls c in der Menge enthalten ist: for( int j = i; j < size; j++ ) array[j] = array[j+1]; // fuelle die neue Luecke size−−; } } 11 delete 44 45 46 47 48 49 50 51 52 /** Test, ob die Zahl c in der Menge enthalten ist. */ public boolean contains( int c ) { for( int i = 0; i < MAX CARDINALITY; i++ ) { if( array[i] == c ) return true; } return false; } contains 53 54 55 56 57 /** Test, ob die Menge leer ist. */ public boolean isEmpty( ) { return size == 0; } isEmpty 58 59 60 61 62 63 64 65 /** Gibt eine String-Repraesentation der Menge zurueck. */ public String toString( ) { StringBuffer ausgabe = new StringBuffer( ); for( int i = 0; i < size ; i++ ) ausgabe.append( array[i] + " " ); return ausgabe.toString( ); } toString 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 public static void main( String[ ] args ) { Menge2 S = empty( ); System.out.println( S.isEmpty( ) ); // true S.insert( 5 ); System.out.println( S.isEmpty( ) ); // false S.insert( 8 ); S.insert( 42 ); System.out.println( S ); // 5 8 42 S.delete( 9 ); System.out.println( S ); // 5 8 42 S.delete( 8 ); System.out.println( S ); // 5 42 S.delete( 5 ); System.out.println( S ); // 42 S.delete( 42 ); System.out.println( S.isEmpty( ) ); // true } } // class Menge2 main 12 KAPITEL 1. EINLEITUNG Wir sehen an diesen Beispielen, wie sich Spezifikation und Implementierung von Algorithmen und Datenstrukturen gegenseitig beeinflussen. Bei der Implementierung einer Datenstruktur werden wir stets zwei Stufen unterscheiden: • Definitionsmodul“ oder Interface“: Dies ist die Schnittstelle zu den An” ” wendungsprogrammen. Hierin wird die Signatur (Syntax) der Datenstruktur festgelegt. Alle Anwendungen können nur die hier eingeführten Operationen verwenden, um Objekte der Struktur zu bearbeiten. • Implementierungsmodul“: Hierin wird eine Datenstruktur realisiert. Die ” Details sind nicht nach außen sichtbar. In Java spiegelt sich die Signatur in den Methodenköpfen und den Typdeklarationen der Felder. Für die Verwendung einer Datenstruktur müssen Anwender neben der Syntax natürlich auch die Semantik dieser Datenstruktur kennen. Diese wird durch die Implementierung festgelegt, was natürlich unbefriedigend ist. Daher wird im Allgemeinen die angestrebte Semantik“ formal (durch Axiome) oder halb ” formal beschrieben ( spezifiziert“), und man verlangt, dass die Implementierung ” dieser Spezifikation entspricht ( Korrektheit der Implementierung“). Wir geben ” hierzu einige einfache Beispiele an. Beispiel 1.1.3. Die Datenstruktur Boole stellt die Booleschen Werte true und false zur Verfügung sowie einige logische Operationen wie Negation und Konjunktion. Eine algebraische Spezifikation dieser Struktur könnte so aussehen: Über der Signatur TRUE : → boolean FALSE : → boolean NOT : boolean → boolean AND : boolean × boolean → boolean OR : boolean × boolean → boolean mit dem Sortensymbol boolean schreiben wir die folgenden Gleichungen (x und y sind Variablen zur Sorte boolean): NOT(TRUE) = FALSE NOT(FALSE) = TRUE AND(x, TRUE) = x AND(x, FALSE) = FALSE OR(x, y) = NOT(AND(NOT(x), NOT(y))) Mit diesen Gleichungen kann man rechnen, indem man sie als Ersetzungsregeln 1.1. DATENSTRUKTUREN UND IHRE SPEZIFIKATION 13 verwendet: OR(x, TRUE) = NOT(AND(NOT(x), NOT(TRUE))) = NOT(AND(NOT(x), FALSE)) = NOT(FALSE) = TRUE Auf diese Weise gelangt man zu neuen Gleichungen, hier OR(x, TRUE) = TRUE, die in allen Modellen der Spezifikation gelten. Solche Gleichungen sind logische Folgerungen aus den Axiomen. Andere Gleichungen, im Beispiel etwa NOT(NOT(x)) = x, sind keine logischen Konsequenzen der Axiome; sie gelten aber im sog. initialen Modell der Gleichungmenge, einer Art Standardmodell“. ” Dieses Thema wollen wir hier aber nicht weiter vertiefen. Programm 1.1.4. Eine einfache Implementierung der Datenstruktur Boole: 1 2 /** Die Klasse Boole implementiert boolesche Werte. */ public class Boole { 3 4 5 6 7 8 /** Der boolesche Wert, realisiert als eine nicht-negative Zahl * vom Typ int. Dabei entspricht der Wert 0 dem booleschen Wert * false und jeder Wert groesser 0 dem booleschen Wert true. */ private int wert; 9 10 11 12 13 /** Konstruiert ein Objekt vom Typ Boole mit int-Wert i. */ public Boole( int i ) { wert = Math.abs( i ); } Boole 14 15 16 17 /** Statische Felder fuer die Konstanten TRUE und FALSE. */ public static final Boole TRUE = new Boole( 1 ); public static final Boole FALSE = new Boole( 0 ); 18 19 20 21 22 23 24 /** Gibt ein neues Objekt zurueck, dessen Wert die Negation * des Werts dieses Objekts ist. */ public Boole not( ) { return new Boole( wert == 0 ? 1 : 0 ); } not 25 26 27 28 29 30 31 /** Gibt ein neues Objekt zurueck, dessen Wert die Konjunktion * des Werts dieses Objekts und des Werts von b ist. */ public Boole and( Boole b ) { return new Boole( wert * b.wert ); } and 32 33 34 35 36 37 38 /** Gibt ein neues Objekt zurueck, dessen Wert die Disjunktion * des Werts dieses Objekts und des Werts von b ist. */ public Boole or( Boole b ) { return new Boole( wert + b.wert ); } or 14 KAPITEL 1. EINLEITUNG 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 /** Gibt eine String-Darstellung des Booleschen Werts zurueck. */ public String toString( ) { return wert > 0 ? "true" : "false"; } public static void main( String[ ] args ) { System.out.println( TRUE ); // true System.out.println( FALSE ); // false System.out.println( TRUE.not( ) ); // false System.out.println( FALSE.not( ) ); // true System.out.println( TRUE.and( TRUE ) ); // true System.out.println( FALSE.and( TRUE ) ); // false System.out.println( FALSE.or( FALSE ) ); // false System.out.println( FALSE.or( TRUE ) ); // true } } // class Boole Eine solche Implementierung der Datenstruktur Boole ist in der Praxis überflüssig, da fast jede Programmiersprache bereits eine analoge Datenstruktur mitbringt (in Java ist dies der Grundtyp boolean). Das Beispiel ist noch in einer anderen Hinsicht untypisch. Jede der Booleschen Operationen erzeugt hier nämlich ein neues Objekt; ein solches Vorgehen ist meist nicht angemessen, da hierbei viel Speicherplatz verschwendet wird. In der Regel wird man wie im nächsten Beispiel vorgehen und alternativ die bereits vorhandenen Objekte manipulieren. Beispiel 1.1.5. Die Datenstruktur Nat enthält einige einfache Operationen über den natürlichen Zahlen. Wollen wir Nat durch eine algebraische Spezifikation definieren, so gehen wir wie folgt vor. Wir erweitern die Spezifikation aus Beispiel 1.1.3 um das Sortensymbol nat, ergänzen die Signatur um NULL : → nat SUCC : nat → nat ISTNULL : nat → boolean ADD : nat × nat → nat EQ : nat × nat → boolean und die Axiome um ISTNULL(NULL) = TRUE ISTNULL(SUCC(x)) = FALSE ADD(NULL, y) = y ADD(SUCC(x), y) = SUCC(ADD(x, y)) EQ(x, NULL) = ISTNULL(x) EQ(NULL, SUCC(y)) = FALSE EQ(SUCC(x), SUCC(y)) = EQ(x, y) toString main 1.1. DATENSTRUKTUREN UND IHRE SPEZIFIKATION 15 Eine Beispielrechung: EQ(ADD(SUCC(NULL), SUCC(NULL)), SUCC(NULL)) = EQ(SUCC(ADD(NULL, SUCC(NULL))), SUCC(NULL)) = EQ(ADD(NULL, SUCC(NULL)), NULL) = ISTNULL(ADD(NULL, SUCC(NULL))) = ISTNULL(SUCC(NULL)) = FALSE Programm 1.1.6. Eine einfache Implementierung der Datenstruktur Nat: 1 2 /** Die Klasse Nat implementiert natuerliche Zahlen. */ public class Nat { 3 4 5 /** Die natuerliche Zahl als Zahl vom Typ int, die nie negativ ist. */ private int wert; 6 7 8 9 10 /** Konstruiert die Zahl Null. */ private Nat( ) { wert = 0; } Nat 11 12 13 14 15 /** Statische Methode zur Konstruktion der Zahl Null. */ public static Nat Null( ) { return new Nat( ); } Null 16 17 18 19 20 21 /** Der Wert dieses Objekts wird um eins inkrementiert. */ public Nat succ( ) { wert++; return this; } succ 22 23 24 25 26 /** Test, ob der Wert null (0) ist. */ public Boole istNull( ) { return new Boole( wert ).not( ); } istNull 27 28 29 30 31 32 /** Zum Wert dieses Objekts wird n addiert. */ public Nat add( Nat n ) { wert += n.wert; return this; } add 33 34 35 36 37 /** Test, ob der Wert gleich dem Wert von n ist. */ public Boole eq( Nat n ) { return new Boole( wert − n.wert ).not( ); } eq 38 39 40 41 42 /** Gibt die Zahl als String zurueck. */ public String toString( ) { return String.valueOf( wert ); } toString 16 KAPITEL 1. EINLEITUNG 43 44 45 46 47 48 49 50 51 52 53 54 55 public static void main( String[ ] args ) { System.out.println( Null( ) ); // 0 System.out.println( Null( ).istNull( ) ); // true Nat zwei = Null( ).succ( ).succ( ); System.out.println( zwei ); // 2 System.out.println( zwei.istNull( ) ); // false Nat vier = Null( ).succ( ).succ( ).succ( ).succ( ); System.out.println( zwei.add( vier ) ); // 6 System.out.println( zwei.eq( vier ) ); // false System.out.println( vier.eq( zwei ) ); // false System.out.println( zwei.eq( zwei ) ); // true } 56 57 } // class Nat Bei der Beschreibung der Semantik muss man aufpassen, dass diese widerspruchsfrei und vollständig ist. Diese Forderungen sind oft nur schwer zu erfüllen (und noch schwerer zu verifizieren). Beispiel 1.1.7. Eine andere Spezifikation für eine Datenstruktur Boole erhalten wir, wenn wir die Signatur aus Beispiel 1.1.3 beibehalten, die Axiomenmenge aber wie folgt modifizieren: NOT(TRUE) = FALSE NOT(FALSE) = TRUE TRUE 6= FALSE NOT(NOT(x)) = x AND(x, FALSE) = FALSE AND(x, y) = NOT(OR(NOT(x), NOT(y))) OR(x, y) = OR(y, x) OR(x, TRUE) = x Dann gilt beispielsweise FALSE = AND(TRUE, FALSE) = NOT(OR(NOT(TRUE), NOT(FALSE))) = NOT(OR(NOT(TRUE), TRUE)) = NOT(NOT(TRUE)) = TRUE, was der Ungleichung TRUE 6= FALSE widerspricht. Also ist die obige Spezifikation nicht widerspruchsfrei. main 1.2. EINIGE ELEMENTARE DATENSTRUKTUREN 1.2 17 Einige elementare Datenstrukturen Grundtypen wie Boolesche Werte (boolean), Buchstaben (char) oder Zahlen (int, double etc.) werden im Speicher im Allgemeinen durch eine bestimmte Anzahl von Wörtern (Bytes) zu je acht Bits dargestellt. In Java ist diese Darstellung (im Gegensatz zu den meisten anderen Programmiersprachen) festgelegt: Typ boolean char byte short int long float double Größe in Bits 8 16 8 16 32 64 32 64 Wertebereich true, false ’\u0000’ bis ’uFFFF’ (0 bis 65535) −27 bis 27 − 1 −215 bis 215 − 1 −231 bis 231 − 1 −263 bis 263 − 1 1.40239846e−45 bis 3.40282347e+38 (positiv) 4.94065645841246544e−324 bis 1.79769313486231570e+308 (positiv) Der Typ char entspricht ISO Unicode, die Fließkommatypen float und double entsprechen IEEE 754. Für die Speicherabbildung von ganzen Zahlen, hier am Beispiel des Typs int, wird der Wert im Binärcode in folgendem Format dargestellt: z≥0: 2er-Komplement: 0 bin(z) bin(232 − |z|) z<0: z = −1 : zum Beispiel: 1 1 1 ... 1 1 da bin(232 − 1) = 11 . . . 11 (32-mal) ist. Hier noch zwei weitere geläufige Speicherabbildungen (man beachte, dass hier die Null jeweils zwei Darstellungen hat): Vorzeichen und Betrag: z : z≥0: 1er-Komplement: v bin(|z|) 0 bin(z) bin(232 − 1 − |z|) z<0: z.B.: z = −1 : 1 1 1 ... Die Speicherabbildung von Fließkommazahlen: v Exponent e Mantisse m 1 0 18 KAPITEL 1. EINLEITUNG stellt die Zahl z = (−1)v · 2e · (1 + m) dar, wobei 0 ≤ m < 1 gilt und e eine ganze Zahl mit Vorzeichen ist. Bei der Kodierung mit 32 Bits (float) benötigt das Vorzeichen 1 Bit, der Exponent 8 Bits und die Mantisse 23 Bits; mit 64 Bits (double) benötigt das Vorzeichen 1 Bit, der Exponent 11 Bits und die Mantisse 52 Bits. 1.3 Einige einfache strukturierte Datentypen Hier wollen wir folgende Datentypen vorstellen: • Keller (engl. Stacks), • Schlangen (engl. Queues), • verkettete Listen. 1.3.1 Keller (Stacks) Ein Stack besteht aus einer Folge a1 , . . . , am von Elementen einer Datenmenge Item, wobei nur an einem Ende dieser Folge ( oben“) Elemente gelesen, gelöscht ” oder eingefügt werden können. Stacks sind daher auch unter dem Namen LIFOListen (Last-I n-F irst-Out) bekannt. am .. . ← oben (top) a2 a1 Einige Anwendungen: • Auswertung von Ausdrücken in Postfix-Notation (siehe Beispiel 1.3.2) • Umwandlung von Ausdrücken in Infix-Notation in Postfix-Notation • Realisierung des Bindungskellers von Namen bei der syntaktischen Analyse von Programmen • Umwandlung von rekursiven Programmen in nicht-rekursive Programme • Backtracking-Algorithmen • Laufzeitstack bei der Speicherplatzverwaltung von Programmen 1.3. EINIGE EINFACHE STRUKTURIERTE DATENTYPEN 19 Eine algebraische Spezifikation des Datentyps Stack kann wie folgt aussehen. Die Signatur mit den Sortensymbolen stack, boolean und item: EMPTY : → stack PUSH : stack × item → stack ISEMPTY : stack → boolean MAKEEMPTY : stack → stack TOP : stack → item POP : stack → stack ERROR1 : → item ERROR2 : → stack TRUE, FALSE : → boolean Und die Axiome: ISEMPTY(EMPTY) = TRUE ISEMPTY(PUSH(s, i)) = FALSE MAKEEMPTY(s) = EMPTY TOP(EMPTY) = ERROR1 TOP(PUSH(s, i)) = i POP(EMPTY) = ERROR2 POP(PUSH(s, i)) = s Diese Spezifikation ist mit dem Sortensymbol item parametrisiert, das durch eine beliebige Menge interpretiert werden kann. Programm 1.3.1. Eine Implementierung der Datenstruktur Stack durch die Datenstruktur Array: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 import java.util.NoSuchElementException; // zur Fehlerbehandlung import java.io.*; // Testroutine main benutzt I/O-Klassen /** Die Klasse Stack implementiert Stacks beschraenkter Groesse mittels Arrays. */ public class Stack { /** Das Array. */ private Object[ ] array; /** Die Position des Top-Elements des Stacks im Array. */ private int topPosition; /** Die maximale Laenge des Arrays. */ private static final int MAX LAENGE = 10; 20 16 17 18 19 20 KAPITEL 1. EINLEITUNG /** Konstruiert den leeren Stack mit maximaler Groesse MAX LAENGE. */ public Stack( ) { array = new Object[MAX LAENGE]; topPosition = −1; } Stack 21 22 23 24 25 26 /** Konstruiert den leeren Stack mit maximaler Groesse n. */ public Stack( int n ) { array = new Object[n]; topPosition = −1; } Stack 27 28 29 30 31 /** Test, ob der Stack leer ist. */ public boolean isEmpty( ) { return topPosition == −1; } isEmpty 32 33 34 35 36 /** Macht den Stack leer. */ public void makeEmpty( ) { topPosition = −1; } makeEmpty 37 38 39 40 41 /** Test, ob der Stack voll ist. */ public boolean isFull( ) { return topPosition == array.length−1; } isFull 42 43 44 45 46 47 48 49 50 51 /** Fuegt ein neues Element oben in den Stack ein. * Ist der Stack voll, so wird eine Ausnahme ausgeloest. */ public void push( Object inhalt ) { if( isFull( ) ) throw new IllegalStateException( "Stack ist voll." ); topPosition++; array[ topPosition ] = inhalt; } push 52 53 54 55 56 57 58 59 60 /** Gibt das oberste Element des Stacks zurueck, wenn vorhanden. * Ist der Stack leer, so wird eine Ausnahme ausgeloest. */ public Object top( ) { if( isEmpty( ) ) throw new NoSuchElementException( "Stack ist leer." ); return array[ topPosition ]; } top 61 62 63 64 65 66 67 68 69 /** Entfernt das oberste Element des Stacks, wenn vorhanden. * Ist der Stack leer, so wird eine Ausnahme ausgeloest. */ public void pop( ) { if( isEmpty( ) ) throw new NoSuchElementException( "Stack ist bereits leer." ); topPosition−−; } pop 1.3. EINIGE EINFACHE STRUKTURIERTE DATENTYPEN 21 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 /** Gibt das oberste Element des Stacks zurueck und entfernt es, * wenn vorhanden. Ist der Stack leer, so wird eine Ausnahme ausgeloest. */ public Object topAndPop( ) { if( isEmpty( ) ) throw new NoSuchElementException( "Stack ist bereits leer." ); Object topElement = array[ topPosition ]; topPosition−−; return topElement; } /** Gibt den String zurueck, der aus der Folge der String-Darstellungen * der Stack-Elemente besteht, jeweils durch eine Leerzeile getrennt. * Die Reihenfolge ist von oben nach unten. */ public String toString( ) { StringBuffer ausgabe = new StringBuffer( ); for( int i = topPosition; i >= 0 ; i−− ) ausgabe.append( array[i] + "\n" ); return ausgabe.toString( ); } /** Eine kleine Testroutine, die ueber die Kommandozeile benutzt wird. */ public static void main( String[ ] args ) throws IOException { System.out.println( "Ein Stack mit Eintraegen vom Typ String\n" + "kann ueber die Kommandozeile modifiziert werden.\n" + "\tVerfuegbare Kommandos:\n" + "\t\"e\" -- Stack leer machen (makeEmpty)\n" + "\t\"u\" -- Element oben einfuegen (push)\n" + "\t\"t\" -- Oberstes Element ausgeben (top)\n" + "\t\"o\" -- Oberstes Element loeschen (pop)\n" + "\t\"p\" -- Stack von oben nach unten ausgeben (print)\n" + "\t\"q\" -- beendet das Programm (quit)" ); Stack stack = new Stack( ); BufferedReader in = new BufferedReader( new InputStreamReader( System.in ) ); char command = ’ ’; while( command != ’q’ ) { switch( command ) { case ’ ’ : { // Tue nichts break; } case ’e’ : { // Stack leer machen stack.makeEmpty( ); break; } case ’u’ : { // String oben einfuegen System.out.println( "\tEinen String eingeben:" ); stack.push( in.readLine( ) ); break; } topAndPop toString main 22 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 KAPITEL 1. EINLEITUNG case ’t’ : { // Oberstes Element ausgeben try { System.out.println( "Oberstes Element: " + stack.top( ) ); } catch( NoSuchElementException e ) { System.out.println( e ); } break; } case ’o’ : { // Oberstes Element loeschen try { stack.pop( ); } catch( NoSuchElementException e ) { System.out.println( e ); } break; } case ’p’ : { // Stack von oben nach unten ausgeben System.out.println( "Stack:\n" + stack ); break; } default : System.out.println( "Kommando " + command + " existiert nicht." ); } System.out.println( "Bitte Kommando eingeben:" ); try { // Kommando einlesen, Leerzeichen entfernen, erstes Zeichen auswaehlen: command = in.readLine( ).trim( ).charAt( 0 ); } catch( IndexOutOfBoundsException e ) { System.out.println( "Keine leeren Kommandos eingeben!" ); command = ’ ’; continue; } } } // main } // class Stack Problematisch ist bei dieser Implementierung, dass durch die Größe des benutzten Feldes eine maximale Größe für die realisierten Stacks vorgegeben wird. Dadurch weicht diese Implementierung von der Spezifikation des Datentyps Stack ab. Abhilfe kann hier eine dynamisch expandierende Struktur schaffen, etwa die in Abschnitt 1.3.3 vorgestellten verketteten Listen. Beispiel 1.3.2. Als beispielhafte Anwendung von Stacks wird hier zuletzt ein Rechner“ implementiert, der arithmetische Ausdrücke in Postfix-Notation ak” zeptiert und auswertet. 1 2 import java.io.*; // main benutzt I/O-Klassen /** Die Klasse PostfixRechner implementiert einen Stack-basierten Rechner fuer * ganze Zahlen, der Ausdruecke in Postfix-Notation akzeptiert und auswertet. 5 */ 6 public class PostfixRechner { 3 4 1.3. EINIGE EINFACHE STRUKTURIERTE DATENTYPEN 23 7 8 public static void main( String[ ] args ) throws IOException { 9 10 11 12 13 14 15 16 17 18 System.out.println( "Der Rechner fuer ganze Zahlen ist bereit!\n" + "\tVerfuegbare Operationen:\n" + "\t+ -- Addition\n" + "\t- -- Subtraktion\n" + "\t* -- Multiplikation\n" + "\t/ -- Division\n" + "\t= -- Endergebnis ausgeben\n" + "Einen arithmetischen Ausdruck in Postfix-Notation eingeben,\n" + "dabei jede Zahl und jeden Operator mit Enter bestaetigen:\n" ); 19 20 21 22 23 24 Stack stack = new Stack( ); // ein leerer Stack BufferedReader in = new BufferedReader( new InputStreamReader( System.in ) ); String eingabeString; int eingabeZahl, erstesArgument, zweitesArgument, zwischenErgebnis; char operation; 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 while( true ) { // potentielle Endlosschleife eingabeString = in.readLine( ); try { // Ist die Eingabe eine Zahl oder eine Operation? eingabeZahl = Integer.parseInt( eingabeString ); // Die Eingabe war eine Zahl, also Zahl in den Stack einfuegen: stack.push( new Integer( eingabeZahl ) ); continue; } catch( NumberFormatException e ) { // Die Eingabe war keine Zahl: if( eingabeString.length( ) != 0 ) { // Eingabe-String war nicht leer. // Leerzeichen entfernen, erstes Zeichen als Operationssymbol auswaehlen: operation = eingabeString.trim( ).charAt( 0 ); } else continue; } if( operation == ’=’ ) { // Endergebnis ausgeben (0 bei leerem Stack) und terminieren: System.out.println( "Endergebnis: " + ( stack.isEmpty( ) ? "0" : stack.top( ) ) ); return; } // Andernfalls die Operation auswerten: if( stack.isEmpty( ) ) { // Der Stack kann leer sein. System.out.println( "Bitte zuerst ein Argument eingeben!" ); continue; } // Das zweite Argument aus dem Stack holen: zweitesArgument = ( (Integer)stack.topAndPop( ) ).intValue( ); if( stack.isEmpty( ) ) { // Der Stack kann leer sein. System.out.println( "Bitte zuerst ein Argument eingeben!" ); // Das zweite Argument wieder auf den Stack legen: stack.push( new Integer( zweitesArgument ) ); continue; } main 24 KAPITEL 1. EINLEITUNG // Das erste Argument aus dem Stack holen: erstesArgument = ( (Integer)stack.topAndPop( ) ).intValue( ); switch( operation ) { case ’+’ : { // Addition ausfuehren zwischenErgebnis = erstesArgument + zweitesArgument; break; } case ’-’ : { // Subtraktion ausfuehren zwischenErgebnis = erstesArgument − zweitesArgument; break; } case ’*’ : { // Multiplikation ausfuehren zwischenErgebnis = erstesArgument * zweitesArgument; break; } case ’/’ : { // Division ausfuehren if( zweitesArgument == 0 ) // Division durch Null loest Ausnahme aus: throw new ArithmeticException( "Division durch Null verboten!" ); zwischenErgebnis = erstesArgument / zweitesArgument; break; } default : System.out.println( "Operation " + operation + " existiert nicht." ); continue; } stack.push( new Integer( zwischenErgebnis ) ); // Zwischenergebnis auf Stack System.out.println( "Zwischenergebnis: " + zwischenErgebnis ); 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 } } // main } // class PostfixRechner 1.3.2 Schlangen (Queues) Auch eine Queue besteht aus einer Folge a1 , . . . , am von Elementen einer Datenmenge Item. Wieder wird nur an einem Ende dieser Folge ( vorne“) gelesen und ” gelöscht, allerdings nun am anderen Ende ( hinten“) eingefügt. Queues werden ” daher auch FIFO-Listen (F irst-I n-F irst-Out) genannt. a1 a2 ↑ ANFANG ... am−1 am ↑ ENDE Einige Anwendungen: • Betriebsmittelverwaltung (Warteschlangen für Prozessoren, Drucker, usw.) • Branch-and-Bound-Algorithmen für Suchprobleme in Bäumen und Graphen 1.3. EINIGE EINFACHE STRUKTURIERTE DATENTYPEN 25 Auch hierfür wollen wir eine algebraische Spezifikation angeben. Die Signatur mit den Sortensymbolen queue, boolean und item: EMPTY : → queue ENQUEUE : queue × item → queue ISEMPTY : queue → boolean MAKEEMPTY : queue → queue FRONT : queue → item DEQUEUE : queue → queue ERROR1 : → item ERROR2 : → queue TRUE, FALSE : → boolean Die Axiome: ISEMPTY(EMPTY) = TRUE ISEMPTY(ENQUEUE(q, i)) = FALSE MAKEEMPTY(q) = EMPTY FRONT(EMPTY) = ERROR1 FRONT(ENQUEUE(EMPTY, i)) = i FRONT(ENQUEUE(ENQUEUE(q, i), j)) = FRONT(ENQUEUE(q, i)) DEQUEUE(EMPTY) = ERROR2 DEQUEUE(ENQUEUE(EMPTY, i)) = EMPTY DEQUEUE(ENQUEUE(ENQUEUE(q, i), j)) = ENQUEUE(DEQUEUE(ENQUEUE(q, i)), j) Programm 1.3.3. Eine Implementierung der Datenstruktur Queue durch die Datenstruktur Array: 0 1 2 3 a1 a2 ↑ ↑ head ANFANG 1 2 3 4 5 6 7 8 9 n−3 n−2 n−1 ... am ↑ ENDE (rear) import java.util.NoSuchElementException; // zur Fehlerbehandlung import java.io.*; // Testroutine main benutzt I/O-Klassen /** Die Klasse Queue implementiert Queues beschraenkter Groesse mittels Arrays. */ public class Queue { /** Das Array. */ private Object[ ] array; 26 10 11 12 13 KAPITEL 1. EINLEITUNG /** Die Position vor dem vordersten Elements der Queue im Array. * Bei leerer Queue ist der Wert gleich dem Wert von rear. */ private int head; 14 15 16 17 18 /** Die Position des hintersten Elements der Queue im Array. * Bei leerer Queue ist der Wert gleich dem Wert von head. */ private int rear; 19 20 21 /** Die maximale Laenge des Arrays. */ private static final int MAX LAENGE = 10; 22 23 24 25 26 27 /** Konstruiert die leere Queue mit maximaler Groesse MAX LAENGE. */ public Queue( ) { array = new Object[MAX LAENGE]; head = rear = −1; } Queue 28 29 30 31 32 33 /** Konstruiert die leere Queue mit maximaler Groesse n. */ public Queue( int n ) { array = new Object[n]; head = rear = −1; } Queue 34 35 36 37 38 /** Test, ob die Queue leer ist. */ public boolean isEmpty( ) { return head == rear; } isEmpty 39 40 41 42 43 /** Macht die Queue leer. */ public void makeEmpty( ) { head = rear = −1; } makeEmpty 44 45 46 47 48 /** Test, ob die Queue voll ist. */ public boolean isFull( ) { return head == −1 && rear == array.length−1; } isFull 49 50 51 52 53 54 55 56 57 58 59 60 61 62 /** Fuegt ein neues Element hinten in die Queue ein. */ public void enqueue( Object inhalt ) { if( isFull( ) ) throw new IllegalStateException( "Queue ist voll." ); int f = head+1; // Position des Front-Elements (wenn vorhanden) if( rear == array.length−1 ) { // rear hat bereits maximalen Wert // Die Queue so weit wie moeglich, also um f Positionen nach vorn schieben: for( int i = f; i <= rear; i++ ) array[i−f] = array[i]; head = −1; rear = rear−f+1; array[rear] = inhalt; // Element einfuegen } enqueue 1.3. EINIGE EINFACHE STRUKTURIERTE DATENTYPEN else { // rear hat noch nicht maximalen Wert rear++; array[rear] = inhalt; // Element einfuegen } 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 27 } /** Gibt das vorderste Element der Queue zurueck, wenn vorhanden. * Ist die Queue leer, so wird eine Ausnahme ausgeloest. */ public Object front( ) { if( isEmpty( ) ) throw new NoSuchElementException( "Queue ist leer." ); return array[head+1]; } /** Entfernt das vorderste Element der Queue, wenn vorhanden. * Ist die Queue leer, so wird eine Ausnahme ausgeloest. */ public void dequeue( ) { if( isEmpty( ) ) throw new NoSuchElementException( "Queue ist bereits leer." ); head++; } /** Gibt den String zurueck, der aus der Folge der String-Darstellungen * der Queue-Elemente besteht, jeweils durch eine Leerzeile getrennt. * Die Reihenfolge ist von vorne nach hinten. */ public String toString( ) { StringBuffer ausgabe = new StringBuffer( ); for( int i = head+1; i <= rear ; i++ ) ausgabe.append( array[i] + "\n" ); return ausgabe.toString( ); } /** Eine kleine Testroutine, die ueber die Kommandozeile benutzt wird. */ public static void main( String[ ] args ) throws IOException { System.out.println( "Eine Queue mit Eintraegen vom Typ String\n" + "kann ueber die Kommandozeile modifiziert werden.\n" + "\tVerfuegbare Kommandos:\n" + "\t\"e\" -- Queue leer machen (makeEmpty)\n" + "\t\"n\" -- Element hinten einfuegen (enqueue)\n" + "\t\"f\" -- Vorderstes Element ausgeben (front)\n" + "\t\"d\" -- Vorderstes Element loeschen (dequeue)\n" + "\t\"p\" -- Queue von vorn nach hinten ausgeben (print)\n" + "\t\"q\" -- beendet das Programm (quit)" ); Queue queue = new Queue( ); BufferedReader in = new BufferedReader( new InputStreamReader( System.in ) ); char command = ’ ’; while( command != ’q’ ) { front dequeue toString main 28 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 KAPITEL 1. EINLEITUNG switch( command ) { case ’ ’ : { // Tue nichts break; } case ’e’ : { // Queue leer machen queue.makeEmpty( ); break; } case ’n’ : { // String hinten einfuegen System.out.println( "\tEinen String eingeben:" ); queue.enqueue( in.readLine( ) ); break; } case ’f’ : { // Vorderstes Element ausgeben try { System.out.println( "Vorderstes Element: " + queue.front( ) ); } catch( NoSuchElementException e ) { System.out.println( e ); } break; } case ’d’ : { // Vorderstes Element loeschen try { queue.dequeue( ); } catch( NoSuchElementException e ) { System.out.println( e ); } break; } case ’p’ : { // Queue von vorn nach hinten ausgeben System.out.println( "Queue:\n" + queue ); break; } default : System.out.println( "Kommando " + command + " existiert nicht." ); } System.out.println( "Bitte Kommando eingeben:" ); try { // Kommando einlesen, Leerzeichen entfernen, erstes Zeichen auswaehlen: command = in.readLine( ).trim( ).charAt( 0 ); } catch( IndexOutOfBoundsException e ) { System.out.println( "Keine leeren Kommandos eingeben!" ); command = ’ ’; continue; } } } // main } // class Queue Bemerkung 1.3.4. Eine effizientere Implementierung von Queues erhält man, wenn die Elemente des Arrays als kreisförmig angeordnet betrachtet werden, d.h. wenn modulo n gerechnet wird: 1.3. EINIGE EINFACHE STRUKTURIERTE DATENTYPEN 29 Dazu müssen natürlich die Operationen entsprechend implementiert werden. Ein Problem dabei ist, die beiden Fälle Schlange ist leer“ und Schlange ist ” ” voll“ zu unterscheiden, da beide Mal ANFANG = ENDE wäre. Dafür brauchen wir entweder eine zusätzliche boolesche Variable (was zusätzlichen Aufwand bei jeder Operation mit sich bringt), oder aber wir erlauben nur, dass maximal n − 1 Elemente abgespeichert werden, d.h. die Schlange wird als voll angesehen, wenn ENDE + 1 = ANFANG (mod n) gilt. 1.3.3 Einfach verkettete Listen Eine einfach verkettete Liste (engl. singly linked list) besteht aus einer Folge von Knoten, wobei jeder Knoten neben einem Element ai einer Datenmenge Item noch einen Verweis auf den Nachfolgerknoten in der Folge speichert. a1 c a2 c a3 c ··· am Diese Datenstruktur kann wie folgt spezifiziert werden. Die Signatur mit den Sortensymbolen list, boolean und item: EMPTY : → list INS FIRST : list × item → list INS LAST : list × item → list ISEMPTY : list → boolean MAKEEMPTY : list → list HEAD : list → item TAIL : list → list CONCAT : list × list → list ERROR1 : → item ERROR2 : → list TRUE, FALSE : → boolean 30 KAPITEL 1. EINLEITUNG Die Axiome: ISEMPTY(EMPTY) = TRUE ISEMPTY(INS FIRST(`, i)) = FALSE MAKEEMPTY(`) = EMPTY INS LAST(EMPTY, i) = INS FIRST(EMPTY, i) INS LAST(INS FIRST(`, i), j) = INS FIRST(INS LAST(`, j), i) HEAD(EMPTY) = ERROR1 HEAD(INS FIRST(`, i)) = i TAIL(EMPTY) = ERROR2 TAIL(INS FIRST(`, i)) = ` CONCAT(EMPTY, `) = ` CONCAT(INS FIRST(`1 , i), `2 ) = INS FIRST(CONCAT(`1 , `2 ), i) Weitere mögliche Operationen (Spezifikation und Implementierung als Übung): EQUAL : list × list → boolean, LENGTH : list → int, REVERT : list → list. Natürlich kann man die Datenstruktur einfach verkettete Liste“ auch in der ” Datenstruktur Array implementieren. Dann muss man wieder eine maximale Größe vorgeben, die die Länge der implementierten Listen beschränkt. Stattdessen betrachten wir hier eine Implementierung durch Referenzen. Programm 1.3.5. Eine Implementierung einfach verketteter Listen mittels Referenzen: 1 2 import java.util.NoSuchElementException; // zur Fehlerbehandlung import java.io.*; // Testroutine main benutzt I/O-Klassen 3 4 /** Die Klasse EinfachVerketteterKnoten implementiert * die Knoten einer einfach verketteten Listen. */ 7 class EinfachVerketteterKnoten { 5 6 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 /** Das Feld inhalt referiert auf ein beliebiges Objekt. */ private Object inhalt; /** Das Feld next referiert auf den Nachfolgerknoten in einer linearen Liste. * Gibt es keinen Nachfolger, so ist der Wert null. */ private EinfachVerketteterKnoten next; /** Konstruiert einen Knoten mit Inhalt inhalt. * Das Feld next wird mit null belegt. */ public EinfachVerketteterKnoten( Object inhalt ) { this.inhalt = inhalt; } EinfachVerketteterKnoten 1.3. EINIGE EINFACHE STRUKTURIERTE DATENTYPEN 24 25 26 27 28 /** Konstruiert einen Knoten mit Inhalt inhalt und Nachfolgerknoten next. */ public EinfachVerketteterKnoten( Object inhalt, EinfachVerketteterKnoten next ) { this.inhalt = inhalt; this.next = next; } 31 EinfachVerketteterKnoten 29 30 31 32 33 /** Gibt den Inhalt des Knotens zurueck. */ public Object inhalt( ) { return inhalt; } inhalt 34 35 36 37 38 /** Gibt den Nachfolgerknoten zurueck, wenn vorhanden, sonst null. */ public EinfachVerketteterKnoten next( ) { return next; } next 39 40 41 42 43 /** Aendert den Nachfolgerknoten auf next. */ public void changeNext( EinfachVerketteterKnoten next ) { this.next = next; } changeNext 44 45 } // class EinfachVerketteterKnoten 46 47 /** Die Klasse EinfachVerketteteListe implementiert einfach verkettete * Listen mit Referenzen auf den ersten und den letzten Knoten. 50 */ 51 public class EinfachVerketteteListe { 48 49 52 53 54 /** Das Feld first referiert auf den ersten Knoten der Liste. */ private EinfachVerketteterKnoten first; 55 56 57 /** Das Feld last referiert auf den letzten Knoten der Liste. */ private EinfachVerketteterKnoten last; 58 59 60 // Die Klasse hat den parameterlosen Standardkonstruktor // public EinfachVerketteteListe( ) { } zur Erzeugung der leeren Liste. 61 62 63 64 65 /** Test, ob die Liste leer ist. */ public boolean isEmpty( ) { return first == null; } isEmpty 66 67 68 69 70 /** Macht die Liste leer. */ public void makeEmpty( ) { first = last = null; } makeEmpty 71 72 73 74 75 76 /** Fuegt einen neuen Knoten mit Inhalt inhalt vorne in die Liste ein. */ public void insertFirst( Object inhalt ) { EinfachVerketteterKnoten k = new EinfachVerketteterKnoten( inhalt ); if( isEmpty( ) ) first = last = k; insertFirst 32 else { k.changeNext( first ); first = k; } 77 78 79 80 81 KAPITEL 1. EINLEITUNG } 82 83 84 85 86 87 88 89 90 91 92 /** Fuegt einen neuen Knoten mit Inhalt inhalt hinten in die Liste ein. */ public void insertLast( Object inhalt ) { EinfachVerketteterKnoten k = new EinfachVerketteterKnoten( inhalt ); if( isEmpty( ) ) first = last = k; else { last.changeNext( k ); last = k; } } insertLast 93 94 95 96 97 98 99 100 101 /** Gibt den Inhalt des ersten Knotens der Liste zurueck, wenn vorhanden. * Ist die Liste leer, so wird eine Ausnahme ausgeloest. */ public Object head( ) { if( isEmpty( ) ) throw new NoSuchElementException( "Liste ist leer." ); return first.inhalt( ); } head 102 103 104 105 106 107 108 109 110 111 112 /** Entfernt den ersten Knoten der Liste, wenn vorhanden. * Ist die Liste leer, so wird eine Ausnahme ausgeloest. */ public void tail( ) { if( isEmpty( ) ) throw new NoSuchElementException( "Liste ist bereits leer." ); first = first.next( ); if( first == null ) // wenn die Liste leer geworden ist last = null; } tail 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 /** Haengt die Liste list hinten an. Die Argumentliste wird danach * leer sein. Wir fordern, dass die beiden Listen verschieden sind, * ausser sie sind leer; andernfalls wird eine Ausnahme ausgeloest. */ public void concat( EinfachVerketteteListe list ) { if( !isEmpty( ) && first == list.first ) throw new IllegalArgumentException( "Identische Listen verknuepft." ); if( !list.isEmpty( ) ) { if( !isEmpty( ) ) last.changeNext( list.first ); else { first = list.first; } last = list.last; list.makeEmpty( ); } } concat 1.3. EINIGE EINFACHE STRUKTURIERTE DATENTYPEN 132 133 134 135 136 137 138 139 140 141 /** Gibt den String zurueck, der aus der Folge der String-Darstellungen * der Knoteninhalte besteht, jeweils durch eine Leerzeile getrennt. * Die Reihenfolge ist von first nach last. */ public String toString( ) { StringBuffer ausgabe = new StringBuffer( ); for( EinfachVerketteterKnoten k = first; k != null; k = k.next( ) ) ausgabe.append( k.inhalt( ) + "\n" ); return ausgabe.toString( ); } 33 toString 142 143 144 /** Eine kleine Testroutine, die ueber die Kommandozeile benutzt wird. */ public static void main( String[ ] args ) throws IOException { main 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 System.out.println( "Liste1 und Liste2 koennen ueber die Kommandozeile" + " modifiziert\nwerden. Beide enthalten Eintraege vom Typ String.\n" + "\tVerfuegbare Kommandos:\n" + "\t\"e\" -- Liste1 leer machen (makeEmpty)\n" + "\t\"E\" -- Liste2 leer machen (makeEmpty)\n" + "\t\"i\" -- String vorn in Liste1 einfuegen (insertFirst)\n" + "\t\"I\" -- String vorn in Liste2 einfuegen (insertFirst)\n" + "\t\"h\" -- Erstes Element von Liste1 ausgeben (head)\n" + "\t\"H\" -- Erstes Element von Liste2 ausgeben (head)\n" + "\t\"t\" -- Erstes Element aus Liste1 loeschen (tail)\n" + "\t\"T\" -- Erstes Element aus Liste2 loeschen (tail)\n" + "\t\"c\" -- Liste2 hinten an Liste1 anhaengen (concat)\n" + "\t\"p\" -- Liste1 von first nach last ausgeben (print)\n" + "\t\"P\" -- Liste2 von first nach last ausgeben (print)\n" + "\t\"q\" -- beendet das Programm (quit)" ); EinfachVerketteteListe list1 = new EinfachVerketteteListe( ); EinfachVerketteteListe list2 = new EinfachVerketteteListe( ); BufferedReader in = new BufferedReader( new InputStreamReader( System.in ) ); char command = ’ ’; while( command != ’q’ ) { switch( command ) { case ’ ’ : { // Tue nichts break; } case ’e’ : { // Liste1 leer machen list1.makeEmpty( ); break; } case ’E’ : { // Liste2 leer machen list2.makeEmpty( ); break; } case ’i’ : { // String in Liste1 einfuegen System.out.println( "\tEinen String eingeben:" ); list1.insertFirst( in.readLine( ) ); break; } case ’I’ : { // String in Liste2 einfuegen System.out.println( "\tEinen String eingeben:" ); 34 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 KAPITEL 1. EINLEITUNG list2.insertFirst( in.readLine( ) ); break; } case ’h’ : { // Erstes Element von Liste1 ausgeben try { System.out.println( "Erster Eintrag von Liste1:" + list1.head( ) ); } catch( NoSuchElementException e ) { System.out.println( e ); } break; } case ’H’ : { // Erstes Element von Liste2 ausgeben try { System.out.println( "Erster Eintrag von Liste2:" + list2.head( ) ); } catch( NoSuchElementException e ) { System.out.println( e ); } break; } case ’t’ : { // Erstes Element aus Liste1 loeschen try { list1.tail( ); } catch( NoSuchElementException e ) { System.out.println( e ); } break; } case ’T’ : { // Erstes Element aus Liste2 loeschen try { list2.tail( ); } catch( NoSuchElementException e ) { System.out.println( e ); } break; } case ’c’ : { // Liste2 hinten an Liste1 anhaengen list1.concat( list2 ); break; } case ’p’ : { // Liste1 von first nach last ausgeben System.out.println( "Liste1:\n" + list1 ); break; } case ’P’ : { // Liste2 von first nach last ausgeben System.out.println( "Liste2:\n" + list2 ); break; } default : System.out.println( "Kommando " + command + " existiert nicht." ); } System.out.println( "Bitte Kommando eingeben:" ); try { // Kommando einlesen, Leerzeichen entfernen, erstes Zeichen auswaehlen: command = in.readLine( ).trim( ).charAt( 0 ); } 1.3. EINIGE EINFACHE STRUKTURIERTE DATENTYPEN 236 237 238 239 240 241 242 243 244 35 catch( IndexOutOfBoundsException e ) { System.out.println( "Keine leeren Kommandos eingeben!" ); command = ’ ’; continue; } } } // main } // class EinfachVerketteteListe Die Datenstrukturen Stack und Queue können leicht durch einfach verkettete Listen realisiert werden, wodurch dann die bei der Implementierung durch Arrays erforderlichen Begrenzungen der Größe wegfallen. 1.3.4 Stacks über Listen implementieren Ein Stack der Form am ← top am−1 .. . a1 kann als Liste so realisiert werden: first am c am−1 c ··· a1 Programm 1.3.6. Alle Stack-Operationen lassen sich nun einfach realisieren: 1 2 import java.io.*; // Testroutine main benutzt I/O-Klassen 3 4 /** Die Klasse Stack implementiert Stacks mittels einfach verketteter Listen. */ public class Stack { 5 6 7 8 9 10 11 12 13 14 15 16 17 18 /** Die einfach verkettete Liste. */ private EinfachVerketteteListe list; /** Konstruiert den leeren Stack. */ public Stack( ) { list = new EinfachVerketteteListe( ); } /** Test, ob der Stack leer ist. */ public boolean isEmpty( ) { return list.isEmpty( ); } Stack isEmpty 36 19 20 21 22 KAPITEL 1. EINLEITUNG /** Macht den Stack leer. */ public void makeEmpty( ) { list.makeEmpty( ); } makeEmpty 23 24 25 26 27 /** Fuegt ein neues Element oben in den Stack ein. */ public void push( Object inhalt ) { list.insertFirst( inhalt ); } push 28 29 30 31 32 33 34 35 36 /** Gibt das oberste Element des Stacks zurueck, wenn vorhanden. * Ist der Stack leer, so wird eine Ausnahme ausgeloest. */ public Object top( ) { if( isEmpty( ) ) throw new IllegalStateException( "Stack ist leer." ); return list.head( ); } top 37 38 39 40 41 42 43 44 45 /** Entfernt das oberste Element des Stacks, wenn vorhanden. * Ist der Stack leer, so wird eine Ausnahme ausgeloest. */ public void pop( ) { if( isEmpty( ) ) throw new IllegalStateException( "Stack ist bereits leer." ); list.tail( ); } pop 46 47 48 49 50 51 52 53 54 55 56 /** Gibt das oberste Element des Stacks zurueck und entfernt es, * wenn vorhanden. Ist der Stack leer, so wird eine Ausnahme ausgeloest. */ public Object topAndPop( ) { if( isEmpty( ) ) throw new IllegalStateException( "Stack ist bereits leer." ); Object topElement = list.head( ); list.tail( ); return topElement; } topAndPop 57 58 59 60 61 62 63 64 /** Gibt den String zurueck, der aus der Folge der String-Darstellungen * der Stack-Elemente besteht, jeweils durch eine Leerzeile getrennt. * Die Reihenfolge ist von oben nach unten. */ public String toString( ) { return list.toString( ); } toString 65 66 67 /** Eine kleine Testroutine, die ueber die Kommandozeile benutzt wird. */ public static void main( String[ ] args ) throws IOException { wie in Programm 1.3.1 127 } // main 128 129 } // class Stack main 1.3. EINIGE EINFACHE STRUKTURIERTE DATENTYPEN 1.3.5 37 Queues über Listen implementieren Eine Queue der Form a1 a2 ↑ ANFANG ... am−1 am ↑ ENDE kann als Liste so realisiert werden: a1 c a2 c ··· am−1 c first am last Programm 1.3.7. Auch alle Queue-Operationen lassen sich damit leicht realisieren: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 import java.util.NoSuchElementException; // zur Fehlerbehandlung import java.io.*; // Testroutine main benutzt I/O-Klassen /** Die Klasse Queue implementiert Queues mittels einfach verketteter Listen. */ public class Queue { /** Die einfach verkettete Liste. */ private EinfachVerketteteListe list; /** Konstuiert die leere Queue. */ public Queue( ) { list = new EinfachVerketteteListe( ); } /** Test, ob die Queue leer ist. */ public boolean isEmpty( ) { return list.isEmpty( ); } /** Macht die Queue leer. */ public void makeEmpty( ) { list.makeEmpty( ); } /** Fuegt ein neues Element hinten in die Queue ein. */ public void enqueue( Object inhalt ) { list.insertLast( inhalt ); } /** Gibt das vorderste Element der Queue zurueck, wenn vorhanden. * Ist die Queue leer, so wird eine Ausnahme ausgeloest. */ Queue isEmpty makeEmpty enqueue 38 33 34 35 36 37 KAPITEL 1. EINLEITUNG public Object front( ) { if( isEmpty( ) ) throw new NoSuchElementException( "Queue ist leer." ); return list.head( ); } front 38 39 40 41 42 43 44 45 46 /** Entfernt das vorderste Element der Queue, wenn vorhanden. * Ist die Queue leer, so wird eine Ausnahme ausgeloest. */ public void dequeue( ) { if( isEmpty( ) ) throw new NoSuchElementException( "Queue ist bereits leer." ); list.tail( ); } dequeue 47 48 49 50 51 52 53 54 /** Gibt den String zurueck, der aus der Folge der String-Darstellungen * der Queue-Elemente besteht, jeweils durch eine Leerzeile getrennt. * Die Reihenfolge ist von vorne nach hinten. */ public String toString( ) { return list.toString( ); } toString 55 56 57 /** Eine kleine Testroutine, die ueber die Kommandozeile benutzt wird. */ public static void main( String[ ] args ) throws IOException { wie in Programm 1.3.3 117 } // main 118 119 } // class Queue 1.3.6 Doppelt verkettete Listen Bei doppelt verketteten Listen (engl. doubly linked lists) speichert jeder Knoten neben dem Verweis auf seinen Nachfolger auch einen Verweis auf seinen Vorgänger: % % % a1 first c c a2 c ··· ··· c am−1 c c am last Die zusätzlichen Verweise verbrauchen zwar zusätzlich Speicherplatz, dafür ermöglichen sie es aber, gewisse Operationen effizienter zu implementieren. main 1.4. RECHENZEIT- UND SPEICHERPLATZBEDARF VON ALGORITHMEN39 1.4 Rechenzeit- und Speicherplatzbedarf von Algorithmen Es gibt viele Kriterien zur Beurteilung von Algorithmen (Programmen), z.B.: • Korrektheit: Arbeitet der Algorithmus korrekt bzgl. der gegebenen Problemspezifikation? (Korrektheitsbeweis: Programmverifikation) • Dokumentation: Sind sowohl die Arbeitsweise als auch das Ein-/Ausgabeverhalten hinreichend gut beschrieben? • Modularität: Werden in sich abgeschlossene Teilaufgaben als Methoden bereitgestellt? • Lesbarkeit: Ist der Code lesbar (Formatierung, Kommentare)? • Implementierbarkeit: Ist der Algorithmus leicht zu implementieren? Außer diesen statischen Eigenschaften gibt es natürlich auch solche, die sich auf das Laufzeitverhalten beziehen. Definition 1.4.1. Sei A ein Algorithmus (Programm) zur Berechnung einer Funktion f : Σ∗ → Σ∗ , und für alle n ∈ N sei Pn : Σn → [0, 1] eine Wahrscheinlichkeitsverteilung auf der Menge Σn der Eingaben der Länge n. a) Für w ∈ Σ∗ sei tA (w) die Anzahl der Einzelschritte, die A bei Eingabe w macht. Die Zeitkomplexität (Rechenzeitbedarf im schlechtesten Fall ) TA : N → N von A ist definiert als TA (n) = max{tA (w) | w ∈ Σn }. (P ) Die mittlere Zeitkomplexität (Rechenzeitbedarf im Mittel ) TA von A ist definiert als :N→N (P ) TA (n) = E(tA (w) | w ∈ Σn ), d.h. als der Erwartungswert für den Rechenzeitbedarf für Eingaben der P (P ) Länge n; damit gilt TA (n) = tA (w) · Pn (w). w∈Σn (b) Für w ∈ Σ∗ sei sA (w) die Anzahl der Speicherplätze für Daten, die A bei Eingabe w benutzt. Die Platzkomplexität (Speicherplatzbedarf im schlechtesten Fall ) SA : N → N von A ist definiert als SA (n) = max{sA (w) | w ∈ Σn }. (P ) Die mittlere Platzkomplexität (Speicherplatzbedarf im Mittel ) SA N von A ist definiert als (P ) SA (n) = E(sA (w) | w ∈ Σn ). :N→ 40 KAPITEL 1. EINLEITUNG Damit die Zeit- und die Platzkomplexität eines Algorithmus bestimmt werden kann, muss genau festlegen werden, wie die Einzelschritte und Speicherplätze gezählt werden sollen. Dazu wird meist ein mehr oder weniger abstraktes Maschinenmodell festgelegt, das Schrittzahl und Platzverbrauch klar zu definieren gestattet. In der Komplexitätstheorie sind beispielsweise Turing-Maschinen2 oder RAMs (Random access machines) gängige Modelle. Zur weiteren Vertiefung verweisen wir auf die Lehrveranstaltung Einführung in die Theoretische ” Informatik“. 1.5 Asymptotisches Wachstumsverhalten Den Speicherplatz- und Rechenzeitbedarf eines Algorithmus kann man oft nicht exakt angeben, weil der genaue Zusammenhang zwischen der Eingabegröße und diesen Werten viel zu kompliziert ist. Wir werden uns daher meistens damit begnügen, das asymptotische Wachstumsverhalten dieser Größen zu bestimmen. Dazu verwenden wir die folgenden Notationen. Definition 1.5.1. Sei f : N → N eine Funktion. • O(f ) = {g : N → N | ∃c ∈ R+ ∃n0 ∈ N ∀n ≥ n0 : g(n) ≤ c · f (n)} g ∈ O(f ) : g wächst asymptotisch nicht schneller als f . • Ω(f ) = {g : N → N | ∃c ∈ R+ ∃n0 ∈ N ∀n ≥ n0 : g(n) ≥ c · f (n)} g ∈ Ω(f ) : g wächst asymptotisch nicht langsamer als f . • Θ(f ) = O(f ) ∩ Ω(f ), d.h. g ∈ Θ(f ) gdw. g ∈ O(f ) und g ∈ Ω(f ). g ∈ Θ(f ) : g wächst asymptotisch genauso schnell wie f . Lemma 1.5.2. Für Funktionen f, g : N → N gilt g ∈ Ω(f ) gdw. f ∈ O(g). Beweis: g ∈ Ω(f ) gdw. ∃c ∈ R+ ∃n0 ∈ N ∀n ≥ n0 : g(n) ≥ c · f (n) 1 gdw. ∃c ∈ R+ ∃n0 ∈ N ∀n ≥ n0 : · g(n) ≥ f (n) c gdw. ∃d ∈ R+ ∃n0 ∈ N ∀n ≥ n0 : f (n) ≤ d · g(n) gdw. f ∈ O(g). Lemma 1.5.3. Für Funktionen f, g : N → N gilt g ∈ Θ(f ) genau dann, wenn ∃c, d ∈ R+ ∃n0 ∈ N ∀n ≥ n0 : c · f (n) ≤ g(n) ≤ d · f (n). Beweis: Als Übung. 2 Nach Alan Turing (1912-1954) 1.5. ASYMPTOTISCHES WACHSTUMSVERHALTEN Beispiel 1.5.4. Für f (n) = n2 , g(n) = 1 2 41 · n · (n + 1) und h(n) = n3 gelten: g ∈ O(h), g ∈ O(f ) und g ∈ Ω(f ), d.h. g ∈ Θ(f ), g 6∈ Ω(h). Satz 1.5.5. Sei f (n) = am ·nm +am−1 ·nm−1 +· · ·+a1 ·n+a0 mit a0 , . . . , am ∈ Z ein Polynom mit am > 0. Dann gilt f ∈ Θ(nm ). Beweis: Für alle n ≥ 1 gilt: f (n) = m P i=0 ai · ni ≤ m P i=0 |ai | · ni ≤ m P |ai | · nm . i=0 Also ist f ∈ O(nm ). Und wegen am > 0 gibt es ein n0 ∈ N mit der Eigenschaft ∀n ≥ n0 : am · nm + 2am−1 · nm−1 + · · · + 2a1 · n + 2a0 ≥ 0. Daraus folgt: ∀n ≥ n0 : nm ≤ am · nm ≤ 2f (n), d.h. nm ∈ O(f ). Mit Lemma 1.5.2 folgt: f ∈ Θ(nm ). Zum Schluss dieses Abschnitts wollen wir uns noch anschauen, wie sich die verschiedenen Zeitkomplexitäten auswirken. Die erste Tabelle zeigt, wie sich für die verschiedenen Zeitschranken die Rechenzeit mit der Eingabegröße verändert. Zeitschranke f (n) f (n) = n f (n) = nblog nc f (n) = n2 3 f (n) = n√ f (n) = 2 n f (n) = 2n f (n) = n! Rechenzeit für Eingabegröße n bei 109 n = 10 n = 20 n = 30 10−8 s 2 · 10−8 s 3 · 10−8 s 3 · 10−8 s 8 · 10−8 s 1, 5 · 10−7 s −7 1 · 10 s 4 · 10−7 s 9 · 10−7 s 1 · 10−6 s 8 · 10−6 s 2, 7 · 10−5 s 8 · 10−9 s 2, 2 · 10−8 s 4, 5 · 10−8 s 1 · 10−6 s 1 · 10−3 s 1 sec −3 3, 6 · 10 s 77 Jahre ··· Operationen pro Sekunde n = 40 n = 50 4 · 10−8 s 5 · 10−8 s 2, 2 · 10−7 s 3 · 10−7 s −6 1, 6 · 10 s 2, 5 · 10−6 s 6, 4 · 10−5 s 1, 2 · 10−4 s −8 7 · 10 s 1, 3 · 10−7 s 1000 sec 11 Tg. 13 Std. ··· ··· Die zweite Tabelle gibt für die verschiedenen Zeitschranken an, welche Eingabegrößen noch innerhalb einer gegebenen Zeit verarbeitet werden können. Zeitkomplexität f (n) f (n) = n f (n) = n · blog2 nc f (n) = n2 3 f (n) = n√ f (n) = 2 n f (n) = 2n f (n) = n! Maximale Eingabegröße für Zeit t t = 1 sec. t = 1 min. t = 1 Std. 109 6 · 1010 3, 6 · 1012 7 9 6 · 10 2, 8 · 10 1, 3 · 1011 3 · 104 2, 5 · 105 2 · 106 1000 4000 15000 900 1300 1400 30 36 37 12 14 15 Wir sehen also: • Die Steigerung der Rechengeschwindigkeit wirkt sich am deutlichsten bei schnellen Algorithmen aus. 42 KAPITEL 1. EINLEITUNG • Der Übergang zu einem schnelleren Algorithmus ist oft wirksamer als der zu einer schnelleren Maschine. • Für die Lösung von Problemen, die häufig auftreten, etwa als Teilprobleme anderer Probleme, lohnt sich der Aufwand, nach einem optimalen ” Algorithmus“ zu suchen. 1.6 Die Java-Klassenbibliothek Viele objektorientierte Programmiersprachen bieten einen großen Vorrat an vordefinierten Datenstrukturen, entweder bereits als Teil der Kernsprache oder als mehr oder weniger standardisierte Erweiterungen (für C++ etwa durch die Standard Template Library). Auch Java bringt viele für diese Lehrveranstaltung relevanten Strukturen und Algorithmen mit. In diesem Abschnitt soll beispielhaft gezeigt werden, wie Queues über der JavaKlasse LinkedList implementiert werden können. Der Datentyp LinkedList entspricht weitgehend den in Abschnitt 1.3.3 vorgestellten verketteten Listen. Hier einige Ausschnitte aus dem Java-API (Application Programming Interface) (siehe http://java.sun.com/j2se/1.3/docs/api/): Class LinkedList java.lang.Object java.util.AbstractCollection java.util.AbstractList java.util.AbstractSequentialList java.util.LinkedList All Implemented Interfaces: Cloneable, Collection, List, Serializable Linked list implementation of the List interface. Implements all optional list operations, and permits all elements (including null). In addition to implementing the List interface, the LinkedList class provides uniformly named methods to get, remove and insert an element at the beginning and end of the list. These operations allow linked lists to be used as a stack, queue, or double-ended queue (deque). [ . . . ] All of the operations perform as could be expected for a doubly-linked list. [ . . . ] • public LinkedList() Constructs an empty list. • public Object getFirst() Returns the first element in this list. 1.6. DIE JAVA-KLASSENBIBLIOTHEK 43 • public Object getLast() Returns the last element in this list. Throws NoSuchElementException if this list is empty. • public Object removeFirst() Removes and returns the first element from this list. Throws NoSuchElementException if this list is empty. • public Object removeLast() Removes and returns the last element from this list. Throws NoSuchElementException if this list is empty. • public void addFirst(Object o) Inserts the given element at the beginning of this list. • public void addLast(Object o) Appends the given element to the end of this list. • public boolean add(Object o) Appends the specified element to the end of this list. Returns true (as per the general contract of Collection.add3 ). • public void clear() Removes all of the elements from this list. • public String toString() Returns a string representation of this collection. The string representation consists of a list of the collection’s elements in the order they are returned by its iterator, enclosed in square brackets ("[]"). Adjacent elements are separated by the characters ", " (comma and space). Elements are converted to strings as by String.valueOf(Object). Programm 1.6.1. Eine Implementierung von Queue über LinkedList: import java.util.LinkedList; // zur internen Darstellung der Queue import java.util.NoSuchElementException; // zur Fehlerbehandlung 3 import java.io.*; // Testroutine main benutzt I/O-Klassen 1 2 4 5 6 /** Die Klasse Queue implementiert Queues ueber der Java-Klasse LinkedList. */ public class Queue { 7 8 9 /** Die verkettete Liste. */ private LinkedList list; 10 11 12 13 14 /** Konstuiert die leere Queue. */ public Queue( ) { list = new LinkedList( ); } Queue 15 16 17 18 19 /** Test, ob die Queue leer ist. */ public boolean isEmpty( ) { return list.isEmpty( ); } 20 3 In der Dokumentation des Interface Collection steht nämlich: Returns true if this collection changed as a result of the call. isEmpty 44 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 /** Macht die Queue leer. */ public void makeEmpty( ) { list.clear( ); } /** Fuegt ein neues Element hinten in die Queue ein. */ public void enqueue( Object inhalt ) { list.addLast( inhalt ); } /** Gibt das vorderste Element der Queue zurueck, wenn vorhanden. * Ist die Queue leer, so wird eine Ausnahme ausgeloest. */ public Object front( ) { if( isEmpty( ) ) throw new NoSuchElementException( "Queue ist leer." ); return list.getFirst( ); } /** Entfernt das vorderste Element der Queue, wenn vorhanden. * Ist die Queue leer, so wird eine Ausnahme ausgeloest. */ public void dequeue( ) { if( isEmpty( ) ) throw new NoSuchElementException( "Queue ist bereits leer." ); list.removeFirst( ); } 119 120 makeEmpty enqueue front dequeue /** Gibt den String zurueck, der aus der Folge der String-Darstellungen * der Queue-Elemente besteht, in eckigen Klammern und jeweils durch Komma getrennt. * Die Reihenfolge ist von vorne nach hinten. */ public String toString( ) { toString return list.toString( ); } /** Eine kleine Testroutine, die ueber die Kommandozeile benutzt wird. */ public static void main( String[ ] args ) throws IOException { wie in Programm 1.3.3 118 KAPITEL 1. EINLEITUNG } // main } // class Queue main Kapitel 2 Bäume und ihre Implementierung Die Datenstruktur Baum“ ist von großer Bedeutung, da man damit sehr ein” fach hierarchische Beziehungen zwischen Objekten darstellen kann. Solche Hierarchien treten in natürlicher Weise in vielen Anwendungen auf: • Gliederung eines Unternehmens in Bereiche, Abteilungen, Gruppen und Mitarbeiter • Gliederung eines Buches in Kapitel, Abschnitte und Unterabschnitte • Gliederung eines Programms in Methoden, Blöcke und Einzelanweisungen Ein Baum besteht aus einer (endlichen) Menge von Knoten1 V und einer (endlichen) Menge von gerichteten Kanten2 E zwischen Knoten aus V . Führt eine Kante von u ∈ V zu v ∈ V , dann heißt u der Elternknoten von v und v ein Kind von u. Ist v in einem oder mehreren Schritten (d.h. durch eine Folge von Kanten) von u aus erreichbar, dann ist u ein Vorgänger von v und v ein Nachfolger von u. Ein Baum hat einen ausgezeichneten Knoten, die Wurzel, der Vorgänger jedes anderen Knotens ist. Außerdem ist jeder Knoten auf nur genau eine Weise von der Wurzel aus erreichbar. 1 2 engl. vertices engl. edges 45 46 KAPITEL 2. BÄUME UND IHRE IMPLEMENTIERUNG Im Allgemeinen stellen wir einen Baum graphisch wie folgt dar: 89:; ?>=< 1 TTTTT jjjj TTTT j j j j TTTT j j j j TTT jjjj 89:; ?>=< 89:; ?>=< 89:; ?>=< 2? 3? 4? ?? ? ? ? ? ?? ? ? ? ? 89:; ?>=< 89:; ?>=< 89:; ?>=< 89:; ?>=< 89:; ?>=< @ABC GFED @ABC 5 6 7 8 9 10 GFED 11 @ABC GFED 12 @ABC GFED 13 ()*+ Dieser Baum hat 13 Knoten und 12 Kanten. Knoten /.-, 1 ist die Wurzel. Die ()*+ /.-, ()*+ /.-, ()*+ /.-, Knoten 2 , 3 und 4 sind die Kinder der Wurzel, sie sind also Geschwister. 0123 ()*+ 0123 0123 Die Knoten 7654 12 , /.-, 6 bis 7654 10 und 7654 13 haben keine Kinder. Sie sind die Blätter des Baums, während die anderen Knoten die inneren Knoten des Baums sind. Der Grad d(v) eines Knotens v ist die Anzahl seiner Kinder. 2.1 Die Datenstruktur Baum Bei Bäumen über einer Knotenmenge V unterscheiden wir zwei Arten, die ungeordneten und die geordneten. Bei der ersten Struktur kommt es auf die Reihenfolge der Unterbäume nicht an, bei der zweiten aber sehr wohl. Wir formalisieren einen ungeordneten Baum (oder einfach: Baum) als Paar aus seiner Wurzel und der Menge seiner direkten Unterbäume, einen geordneten Baum stattdessen als (geordnete) Folge seiner Wurzel gefolgt von der Folge seiner direkten Unterbäume. Beide Definitionen sind also rekursiv. Definition 2.1.1 ((Ungeordneter) Baum). Sei V eine Menge von Knoten. • Für v ∈ V ist (v, {}) ein Baum; er hat nur den Knoten v, seine Wurzel. • Sind B1 , . . . , Bm (m ≥ 1) Bäume mit paarweise disjunkten Knotenmengen, und ist v ∈ V ein weiterer Knoten, dann ist B = (v, {B1 , . . . , Bm }) ein Baum mit Wurzel v und direkten Unterbäumen (oder: Teilbäumen) B1 , . . . , Bm . Graphisch stellen wir B wie folgt dar, wobei {Bi1 , . . . , Bim } = {B1 , . . . , Bm } sei: v Q Q Q Q A A A A ... A A Bi 1 A B im A A A 2.1. DIE DATENSTRUKTUR BAUM 47 Definition 2.1.2 (Geordneter Baum). Sei V wie oben. • Für v ∈ V ist (v) ein geordneter Baum; er hat nur den Knoten v, seine Wurzel. • Sind B1 , . . . , Bm (m ≥ 1) geordnete Bäume mit paarweise disjunkten Knotenmengen, und ist v ∈ V ein weiterer Knoten, dann ist B = (v, B1 , . . . , Bm ) ein geordneter Baum mit Wurzel v, und für 1 ≤ i ≤ m ist Bi der i-te direkte Unterbaum (oder: Teilbaum) von B. Notationen: Die Tiefe (oder Stufe) eines Knotens v in einem Baum wird rekursiv definiert: Tiefe(v) = 0 wenn v die Wurzel ist, 0 Tiefe(v) = 1 + Tiefe(v ) wenn v 0 Elternknoten von v ist. Die Höhe eines Baumes B ist definiert als Höhe(B) = max{Tiefe(v) | v ist Knoten von B}. Ein wichtiger Sonderfall der geordneten Bäume sind die binären Bäume. In Abweichung zu obiger Definition lässt man auch leere binäre Bäume zu, d.h. binäre Bäume ohne Knoten. Definition 2.1.3. Ein binärer Baum ist entweder leer, oder er ist ein geordneter Baum mit Knotenmenge V 6= ∅, und es gilt d(v) ≤ 2 für alle v ∈ V . Wir unterscheiden bei einem Knoten eines binären Baumes zwischen dem linken und dem rechten Teilbaum. Beispiel 2.1.4. B1 : 89:; ?>=< 1 vv v v v v 89:; ?>=< 2 B2 : ?>=< 89:; 1 HH HHH H ?>=< 89:; 2 Als Bäume sind B1 und B2 identisch, nicht aber als binäre Bäume. Nachdem wir die Objekte Binäre Bäume“ definiert haben, können wir nun ” die Datenstruktur Binäre Bäume über einer Menge Item“ einführen. Hier eine ” mögliche algebraische Spezifikation dieses Datentyps: 48 KAPITEL 2. BÄUME UND IHRE IMPLEMENTIERUNG Die Signatur mit den Sortensymbolen btree, boolean und item: EMPTY : → btree TREE : btree × item × btree → btree ISEMPTY : btree → boolean LCHILD : btree → btree ITEM : btree → item RCHILD : btree → btree ERROR1 : → item ERROR2 : → btree TRUE, FALSE : → boolean Die Axiome: ISEMPTY(EMPTY) = TRUE ISEMPTY(TREE(b1 , i, b2 )) = FALSE LCHILD(EMPTY) = ERROR2 LCHILD(TREE(b1 , i, b2 )) = b1 ITEM(EMPTY) = ERROR1 ITEM(TREE(b1 , i, b2 )) = i RCHILD(EMPTY) = ERROR2 RCHILD(TREE(b1 , i, b2 )) = b2 Lemma 2.1.5. Sei B ein nicht-leerer binärer Baum der Höhe h. (a) Für 0 ≤ i ≤ h gilt, dass B höchstens 2i Knoten der Tiefe i enthält. (b) B enthält mindestens h + 1 und höchstens 2h+1 − 1 Knoten. (c) Sei n die Anzahl der Knoten in B. Dann gilt dlog(n + 1)e − 1 ≤ h ≤ n − 1. Beweis: (a) Beweis durch Induktion nach i. (b) Offensichtlich gilt n ≥ h + 1. Andererseits gilt: n= h X Anzahl der Knoten der Tiefe i in B ≤ i=0 h X (a) i=0 2i = 2h+1 − 1. (c) Nach (b) gilt h ≤ n − 1. Andererseits gilt nach (b) auch n ≤ 2h+1 − 1, d.h. log(n + 1) ≤ h + 1. Da h ganzzahlig ist, bedeutet dies dlog(n + 1)e − 1 ≤ h. Lemma 2.1.6. Sei B ein nicht-leerer binärer Baum. Ist n0 die Anzahl der Blätter und n2 die Anzahl der Knoten vom Grad 2 in B, so gilt n0 = n2 + 1. Beweis: Durch Induktion über den Aufbau von B. 2.2. IMPLEMENTIERUNG BINÄRER BÄUME 49 Definition 2.1.7. Ein nicht-leerer binärer Baum der Höhe h ist voll, wenn er 2h+1 − 1 Knoten enthält. Definition 2.1.8. Sei B ein binärer Baum der Höhe h mit n > 0 Knoten und sei B 0 ein voller binärer Baum der Höhe h. In B 0 seien die Knoten stufenweise von links nach rechts durchnummeriert (vgl. obiges Beispiel). B heißt vollständig, wenn er dem binären Baum B 00 entspricht, der aus B 0 entsteht, indem die Knoten mit den Nummern n + 1, n + 2, . . . , 2h+1 − 1 gestrichen werden. Auch den leeren Baum wollen wir sowohl voll als auch vollständig nennen. Beispiel 2.1.9. Die Abbildung zeigt einen vollen binären Baum der Höhe 2. Werden die Knoten mit den Nummern 6 und 7 gestrichen, entsteht ein vollständiger binärer Baum mit fünf Knoten. 1 HH H 2 3 @ @ @ @ 4 5 6 7 Beachte: Für vollständige binäre Bäume mit n > 0 Knoten und Höhe h gilt 2h ≤ n < 2h+1 . 2.2 Implementierung binärer Bäume Für vollständige binäre Bäume (Definition 2.1.8) erhalten wir eine sehr einfache und effiziente Implementierung mittels eindimensionaler Felder. Sei B ein vollständiger binärer Baum mit n Knoten, wobei in den Knoten Elemente der Menge Item gespeichert seien3 . Wir implementieren B durch ein Feld der Länge n; der Knoten mit Index i (1 ≤ i ≤ n) wird an der Position i − 1 gespeichert. (Statt vom Knoten mit Index i sprechen wir auch kurz vom Knoten i.) Dabei ist uns die folgende Beobachtung von Nutzen. Lemma 2.2.1. Für vollständige binäre Bäume mit n Knoten gilt (1 ≤ i ≤ n): (a) Ist i > 1, so ist der Knoten bi/2c der Elternknoten des Knotens i. (b) Ist 2i ≤ n, so ist der Knoten 2i das linke Kind des Knotens i. (c) Ist 2i + 1 ≤ n, so ist der Knoten 2i + 1 das rechte Kind des Knotens i. 3 Das in einem Knoten gespeicherte Datenelement nennen wir auch Schlüssel des Knoten. 50 KAPITEL 2. BÄUME UND IHRE IMPLEMENTIERUNG Beweis: (a) folgt aus (b) und (c). (b) Durch Induktion nach i: Der Fall i = 1 ist klar. Für Knoten j (1 ≤ j ≤ i−1) ist nach Induktionsannahme das linke Kind der Knoten 2j. Auf das linke Kind von i − 1 folgt das rechte Kind von i − 1 und dann das linke Kind von i; das linke Kind des Knoten i ist also der Knoten 2(i − 1) + 2 = 2i, falls 2i ≤ n ist. (c) Analog zu (b). Programm 2.2.2. Eine Implementierung vollständiger binärer Bäume: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 /** * Die Klasse VollstaendigerBinaererBaum implementiert * vollstaendige binaere Baeume mittels Arrays. * * Die Knoten des Baums haben einen Index i mit * 1 <= i <= Anzahl der Knoten. Der Knoten mit Index i * wird im Array an der Position i-1 gespeichert. * Jeder Knoten speichert ein beliebiges Objekt vom Typ Object. */ public class VollstaendigerBinaererBaum { /** Das Array, in dem die Knoteninhalte gespeichert werden. */ private Object[ ] array; /** Die Anzahl der Knoten. */ private int knotenZahl; /** Der Konstruktor legt einen flachen Klon des Argument-Arrays an. */ public VollstaendigerBinaererBaum( Object[ ] array ) { this.array = (Object[ ]) array.clone( ); knotenZahl = array.length; } /** Der Konstruktor legt einen leeren Baum * in einem Array der Groesse maxKnotenZahl an. */ public VollstaendigerBinaererBaum( int maxKnotenZahl ) { this( new Object[maxKnotenZahl] ); knotenZahl = 0; } /** Test, ob der Baum leer ist. */ public boolean istLeer( ) { return knotenZahl == 0; } /** Gibt die Anzahl der Knoten zurueck. */ public int groesse( ) { return knotenZahl; } VollstaendigerBinaererBaum VollstaendigerBinaererBaum istLeer groesse 2.2. IMPLEMENTIERUNG BINÄRER BÄUME 42 43 44 45 46 47 48 49 50 51 /** Gibt das im Knoten mit Index i gespeicherte Objekt zurueck. * Fuer die Zahl i muss 1 <= i <= knotenZahl gelten. */ public Object inhalt( int i ) { if( i < 1 | | i > knotenZahl ) throw new IndexOutOfBoundsException( "Zugriff auf nicht vorhandenen Knoten" ); return array[i−1]; } inhalt 51 52 53 54 55 56 57 58 59 60 /** Der Inhalt des Knotens mit Index i wird das Objekt obj. * Fuer die Zahl i muss 1 <= i <= knotenZahl gelten. */ public void inhaltAendern( int i, Object obj ) { if( i < 1 | | i > knotenZahl ) throw new IndexOutOfBoundsException( "Zugriff auf nicht vorhandenen Knoten" ); array[i−1] = obj; } inhaltAendern 61 62 63 64 65 66 67 68 69 70 /** Gibt den Index des ersten Knotens zurueck, der das Objekt obj speichert, * falls einer vorhanden ist, sonst 0. Die Objekte werden mit equals verglichen. */ public int findeErstesVorkommen( Object obj ) { for( int i = 1; i <= knotenZahl; i++ ) if( array[i−1].equals( obj ) ) return i; return 0; } findeErstesVorkommen 71 72 73 74 75 76 77 78 79 80 81 82 /** Gibt den Index des linken Kindes des Knotens mit Index i zurueck, * falls eines vorhanden ist, sonst 0. * Fuer die Zahl i muss 1 <= i <= knotenZahl gelten. */ public int linkesKind( int i ) { if( i < 1 | | i > knotenZahl ) throw new IndexOutOfBoundsException( "Zugriff auf nicht vorhandenen Knoten" ); int j = 2*i; return j <= knotenZahl ? j : 0; } linkesKind 83 84 85 86 87 88 89 90 91 92 93 94 95 /** Gibt den Index des rechten Kindes des Knotens mit Index i zurueck, * falls eines vorhanden ist, sonst 0. * Fuer die Zahl i muss 1 <= i <= knotenZahl gelten. */ public int rechtesKind( int i ) { if( i < 1 | | i > knotenZahl ) throw new IndexOutOfBoundsException( "Zugriff auf nicht vorhandenen Knoten" ); int j = 2*i+1; return j <= knotenZahl ? j : 0; } rechtesKind 52 96 97 98 99 100 101 102 103 104 105 KAPITEL 2. BÄUME UND IHRE IMPLEMENTIERUNG /** Gibt den Index des Elternknotens des Knotens mit Index i zurueck, * falls einer vorhanden ist, sonst 0. * Fuer die Zahl i muss 1 <= i <= knotenZahl gelten. */ public int elternknoten( int i ) { if( i < 1 | | i > knotenZahl ) throw new IndexOutOfBoundsException( "Zugriff auf nicht vorhandenen Knoten" ); return i/2; // wird implizit abgerundet } elternknoten 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 /** Gibt den Baum stufenweise als String zurueck. */ public String toString( ) { if( istLeer( ) ) return "Der Baum ist leer."; StringBuffer ausgabe = new StringBuffer( ); int stufe = 0; int k = 2; // k ist immer 2^(stufe+1) int i = 1; // i ist der Index des naechsten auszugebenden Knotens while( i <= knotenZahl ) { ausgabe.append( "Stufe " + stufe + ":" ); for( ; i < k; i++ ) { if( i <= knotenZahl ) ausgabe.append( " " + array[i−1] ); } ausgabe.append( ’\n’ ); stufe++; k *= 2; } return ausgabe.toString( ); } toString 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 public static void main( String[ ] args ) { Character[ ] c = { new Character( ’a’ ), new Character( ’b’ ), new Character( ’c’ ), new Character( ’d’ ), new Character( ’e’ ), new Character( ’f’ ), new Character( ’g’ ), new Character( ’h’ ) }; VollstaendigerBinaererBaum b1 = new VollstaendigerBinaererBaum( c ); System.out.println( b1 ); for( int i = 1; i <= b1.groesse( ); i++ ) { System.out.println( "Der Knoten " + i + " hat den Inhalt " + b1.inhalt( i ) + "," ); int l = b1.linkesKind( i ); System.out.print( l != 0 ? " das linke Kind " + l : " kein linkes Kind" ); int r = b1.rechtesKind( i ); System.out.print( r != 0 ? ", das rechte Kind " + r : ", kein rechtes Kind" ); int e = b1.elternknoten( i ); System.out.println( e != 0 ? " und den Elternknoten " + e : " und keinen Elternknoten" ); } main 2.2. IMPLEMENTIERUNG BINÄRER BÄUME 149 System.out.println( ); 150 151 152 153 154 155 156 157 158 159 160 int finda = b1.findeErstesVorkommen( new Character( ’a’ ) ); System.out.println( "Das Zeichen ’a’ steht in " + ( finda != 0 ? "Knoten " + finda : "keinem Knoten" ) ); int findh = b1.findeErstesVorkommen( new Character( ’h’ ) ); System.out.println( "Das Zeichen ’h’ steht in " + ( findh != 0 ? "Knoten " + findh : "keinem Knoten" ) ); int findi = b1.findeErstesVorkommen( new Character( ’i’ ) ); System.out.println( "Das Zeichen ’i’ steht in " + ( findi != 0 ? "Knoten " + findi : "keinem Knoten" ) ); System.out.println( ); 161 162 163 164 b1.inhaltAendern( 6, new Character( ’z’ ) ); System.out.print( b1 ); } // main 165 166 } // class VollstaendigerBinaererBaum Ein Testlauf: Stufe Stufe Stufe Stufe 0: 1: 2: 3: a b c d e f g h Der Knoten 1 hat den Inhalt a, das linke Kind 2, das rechte Kind 3 Der Knoten 2 hat den Inhalt b, das linke Kind 4, das rechte Kind 5 Der Knoten 3 hat den Inhalt c, das linke Kind 6, das rechte Kind 7 Der Knoten 4 hat den Inhalt d, das linke Kind 8, kein rechtes Kind Der Knoten 5 hat den Inhalt e, kein linkes Kind, kein rechtes Kind Der Knoten 6 hat den Inhalt f, kein linkes Kind, kein rechtes Kind Der Knoten 7 hat den Inhalt g, kein linkes Kind, kein rechtes Kind Der Knoten 8 hat den Inhalt h, kein linkes Kind, kein rechtes Kind und keinen Elternknoten und den Elternknoten 1 und den Elternknoten 1 und den Elternknoten 2 und den Elternknoten 2 und den Elternknoten 3 und den Elternknoten 3 und den Elternknoten 4 Das Zeichen ’a’ steht in Knoten 1 Das Zeichen ’h’ steht in Knoten 8 Das Zeichen ’i’ steht in keinem Knoten Stufe Stufe Stufe Stufe 0: 1: 2: 3: a b c d e z g h 53 54 KAPITEL 2. BÄUME UND IHRE IMPLEMENTIERUNG Natürlich könnte man diese Implementierung für alle binären Bäume verwenden, nicht nur für die vollständigen, wenn man im Feld an den Stellen, die keinem Knoten entsprechen, einen speziellen Wert Knoten nicht vorhanden“ ” speichert. Dies führt allerdings im allgemeinen zu einer erheblichen Platzverschwendung. Man betrachte etwa den binären Baum ?>=< 89:; 1 EE E 89:; ?>=< 2 89:; ?>=< n der ein Feld der Größe 2n − 1 belegt. Dieser Nachteil kann durch eine dynamische Implementierung vermieden werden. Im Folgenden geben wir zwei solche Implementierungen der Datenstruktur Binärer Baum“ an. ” Definition 2.2.3. (a) Man verwendet drei Felder der Länge max zur Speicherung binärer Bäume: inhalt, linkesKind und rechtesKind. Ein binärer Baum B wird nun in diesen Feldern gespeichert, indem jedem Knoten die drei Plätze inhalt[j], linkesKind[j] und rechtesKind[j] zur Verfügung gestellt werden für ein j mit 0 ≤ j ≤ max − 1: inhalt[j] = Inhalt des Knotens, linkesKind[j] = Position des linken Kindes, rechtesKind[j] = Position des rechten Kindes. Ferner brauchen wir eine Variable b, die die Position der Wurzel angibt. Beispiel: Für max = 12 wird ein Baum mit acht Knoten gespeichert (b = 4): j 0 1 2 3 4 5 6 7 8 9 10 11 @ABC GFED w1 F FF x x F xx @ABC GFED @ABC GFED w3 F w2 F FF FF x x F F xx @ABC GFED @ABC GFED @ABC GFED w4 w5 F w6 FF x x F xx @ABC GFED @ABC GFED w7 w8 inhalt linkesKind rechtesKind w4 w2 w5 w1 w6 w3 0 1 8 2 0 0 0 3 10 6 0 5 w7 0 0 w8 0 0 2.2. IMPLEMENTIERUNG BINÄRER BÄUME 55 (b) Man verwendet Knoten, die aus drei Komponenten bestehen: • dem gespeicherten Inhalt, • dem Verweis auf das linke Kind, • dem Verweis auf das rechte Kind. Ein Baum wird dann als eine Referenz auf eine durch Referenzen verbundene Struktur von Knoten realisiert. Beispiel: w1 ! ! ! ! ! ! !! w2 w4 - - w7 B Q Q Q Q Q w3 - l l l l w5 - l l l l w6 l l l l w8 - - - Programm 2.2.4. Die entsprechende Implementierung binärer Bäume: /** Die Klasse Binaerknoten implementiert Knoten eines binaeren Baums. * Jeder Knoten speichert ein beliebiges Objekt vom Typ Object, 3 * zusaetzlich je eine Referenz auf das linke und das rechte Kind. 4 */ 5 class Binaerknoten { 1 2 6 7 8 9 10 11 12 13 14 15 16 17 18 /** Der Knoteninhalt. */ private Object inhalt; /** Das linke und das rechte Kind. */ private Binaerknoten linkesKind; private Binaerknoten rechtesKind; /** Konstruiert einen Blattknoten mit Inhalt inhalt. */ public Binaerknoten( Object inhalt ) { this.inhalt = inhalt; } Binaerknoten 56 19 20 21 22 23 24 25 26 KAPITEL 2. BÄUME UND IHRE IMPLEMENTIERUNG /** Konstruiert einen Knoten mit Inhalt inhalt, * linkem Kind knoten1 und rechtem Kind knoten2. */ public Binaerknoten( Object inhalt, Binaerknoten knoten1, Binaerknoten knoten2 ) { this.inhalt = inhalt; linkesKind = knoten1; rechtesKind = knoten2; } Binaerknoten 27 28 29 30 31 /** Gibt den Inhalt des Knotens zurueck. */ public Object inhalt( ) { return inhalt; } inhalt 32 33 34 35 36 /** Gibt das linke Kind zurueck. */ public Binaerknoten linkesKind( ) { return linkesKind; } linkesKind 37 38 39 40 41 /** Gibt das rechte Kind zurueck. */ public Binaerknoten rechtesKind( ) { return rechtesKind; } rechtesKind 42 43 44 45 46 /** Der Inhalt wird zu inhalt. */ public void inhaltAendern( Object inhalt ) { this.inhalt = inhalt; } inhaltAendern 47 48 49 50 51 /** Das linke Kind wird zu knoten. */ public void linkesKindAendern( Binaerknoten knoten ) { linkesKind = knoten; } linkesKindAendern 52 53 54 55 56 /** Das rechte Kind wird zu knoten. */ public void rechtesKindAendern( Binaerknoten knoten ) { rechtesKind = knoten; } rechtesKindAendern 57 58 59 60 61 62 63 64 65 66 /** Durchsucht den hier wurzelnden Baum in Vorordnung. * Gibt true zurueck, wenn es darin einen Knoten mit Inhalt inhalt gibt, * sonst false. Die Objekte werden mit equals verglichen. */ public boolean suche( Object inhalt ) { return inhalt.equals( inhalt ) | | ( linkesKind == null ? false : linkesKind.suche( inhalt ) ) | | ( rechtesKind == null ? false : rechtesKind.suche( inhalt ) ); } suche 67 68 69 70 71 /** Gibt eine String-Darstellung des Inhalts zurueck. */ public String toString( ) { return inhalt.toString( ); } toString 2.2. IMPLEMENTIERUNG BINÄRER BÄUME 57 72 73 74 75 76 77 78 79 80 /** Gibt den hier wurzelnden Baum in Vorordnung aus. */ public void druckeVorordnung( ) { System.out.print( this + " " ); if( linkesKind != null ) linkesKind.druckeVorordnung( ); if( rechtesKind != null ) rechtesKind.druckeVorordnung( ); } druckeVorordnung 81 82 83 84 85 86 87 88 89 /** Gibt den hier wurzelnden Baum in Zwischenordnung aus. */ public void druckeZwischenordnung( ) { if( linkesKind != null ) linkesKind.druckeZwischenordnung( ); System.out.print( this + " " ); if( rechtesKind != null ) rechtesKind.druckeZwischenordnung( ); } druckeZwischenordnung 90 91 92 93 94 95 96 97 98 /** Gibt den hier wurzelnden Baum in Nachordnung aus. */ public void druckeNachordnung( ) { if( linkesKind != null ) linkesKind.druckeNachordnung( ); if( rechtesKind != null ) rechtesKind.druckeNachordnung( ); System.out.print( this + " " ); } druckeNachordnung 99 100 } // class Binaerknoten 101 102 /** Die Klasse Binaerbaum implementiert binaere Baeume mittels Referenzen. * Ein Binaerbaum besteht einfach aus seinem Wurzelknoten vom Typ Binaerknoten. 105 */ 106 public class Binaerbaum { 103 104 107 108 109 /** Der Wurzelknoten. */ private Binaerknoten wurzel; 110 111 112 113 114 115 116 /** Konstruiert einen Baum, der nur einen Wurzelknoten * mit Inhalt inhalt besitzt. */ public Binaerbaum( Object inhalt ) { wurzel = new Binaerknoten( inhalt ); } Binaerbaum 117 118 119 120 /** Konstruiert den leeren Baum. */ public Binaerbaum( ) { } Binaerbaum 58 121 122 123 124 KAPITEL 2. BÄUME UND IHRE IMPLEMENTIERUNG /** Gibt den Wurzelknoten zurueck. */ public Binaerknoten wurzel( ) { return wurzel; } wurzel 125 126 127 128 129 /** Test, ob der Baum leer ist. */ boolean istLeer( ) { return wurzel == null; } istLeer 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 /** Erzeugt einen Baum mit dem aktuellen Baum als linkem Teilbaum, * mit neuem Wurzelknoten mit Inhalt inhalt und mit rechtem Teilbaum baum. * Der Argumentbaum baum wird danach leer sein. * Um zu verhindern, dass die Teilbaeume uebereinstimmen, fordern wir, dass * die beiden Wurzelreferenzen verschieden sind, ausser beide Baeume sind leer; * andernfalls wird eine Ausnahme ausgeloest. */ Binaerbaum anhaengen( Object inhalt, Binaerbaum baum ) { if( !istLeer( ) && wurzel == baum.wurzel ) throw new IllegalArgumentException( "Identische Baeume verknuepft." ); wurzel = new Binaerknoten( inhalt, wurzel, baum.wurzel ); baum.wurzel = null; return this; } anhaengen 145 146 147 148 149 150 151 152 /** Durchsucht den Baum in Vorordnung. * Gibt true zurueck, wenn es darin einen Knoten mit Inhalt inhalt gibt, * sonst false. Die Objekte werden mit equals verglichen. */ public boolean suche( Object inhalt ) { return istLeer( ) ? false : wurzel.suche( inhalt ); } suche 153 154 155 156 157 158 159 160 161 162 /** Gibt den Baum in Vorordnung aus. */ public void druckeVorordnung( ) { if( istLeer( ) ) System.out.println( "Der Baum ist leer." ); else { wurzel.druckeVorordnung( ); System.out.println( ); } } druckeVorordnung 163 164 165 166 167 168 169 170 171 172 173 /** Gibt den Baum in Zwischenordnung aus. */ public void druckeZwischenordnung( ) { if( istLeer( ) ) System.out.println( "Der Baum ist leer." ); else { wurzel.druckeZwischenordnung( ); System.out.println( ); } } druckeZwischenordnung 2.2. IMPLEMENTIERUNG BINÄRER BÄUME 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 59 /** Gibt den Baum in Nachordnung aus. */ public void druckeNachordnung( ) { if( istLeer( ) ) System.out.println( "Der Baum ist leer." ); else { wurzel.druckeNachordnung( ); System.out.println( ); } } public static void main( String[ ] args ) { Binaerbaum b1 = new Binaerbaum( "A" ).anhaengen( "*", new Binaerbaum( "B" ) ); b1.druckeVorordnung( ); b1.druckeZwischenordnung( ); b1.druckeNachordnung( ); System.out.println( ); Binaerbaum b2 = new Binaerbaum( "A" ). anhaengen( "/", new Binaerbaum( "B" ). anhaengen( "*", new Binaerbaum( "C" ) ) ). anhaengen( "*", new Binaerbaum( "D" ) ). anhaengen( "+", new Binaerbaum( "E" ) ); b2.druckeVorordnung( ); b2.druckeZwischenordnung( ); b2.druckeNachordnung( ); } // main } // class Binaerbaum Ein Testlauf (vgl. Beispiel 2.2.6): * A B A * B A B * + * / A * B C D E A / B * C * D + E A B C * / D * E + Eine der Operationen, die man oftmals auf Bäumen durchführen muss, besteht darin, jeden Knoten des Baumes genau einmal zu besuchen. Dazu müssen wir den Baum systematisch durchlaufen (oder: traversieren). Ein solches systematisches Durchlaufen liefert uns eine lineare Anordnung der Knoten und damit der im Baum gespeicherten Information. Drei wichtige Strategien hierfür ergeben sich aus der rekursiven Form der Definition der binären Bäume. druckeNachordnung main 60 KAPITEL 2. BÄUME UND IHRE IMPLEMENTIERUNG Definition 2.2.5. Sei B ein binärer Baum. Die Vorordnungsfolge der Knoten von B ist rekursiv wie folgt definiert: (i) Als erstes kommt die Wurzel von B, (ii) dann die Vorordnungsfolge des linken direkten Teilbaums, (iii) und schließlich die Vorordnungsfolge des rechten direkten Teilbaums. Die Zwischenordnungsfolge der Knoten von B ist rekursiv definiert durch: (i) Zuerst kommt die Zwischenordnungsfolge des linken direkten Teilbaums, (ii) dann die Wurzel von B, (iii) und schließlich die Zwischenordnungsfolge des rechten direkten Teilbaums. Die Nachordnungsfolge der Knoten von B ist rekursiv definiert durch: (i) Zuerst kommt die Nachordnungsfolge des linken direkten Teilbaums, (ii) dann die Nachordnungsfolge des rechten direkten Teilbaums, (iii) und schließlich die Wurzel von B. Beispiel 2.2.6. Mittels binärer Bäume lassen sich leicht arithmetische Ausdrücke darstellen. Der arithmetische Ausdruck ((A/(B ∗ C)) ∗ D) + E mit den Variablen A, . . . , E kann durch den folgenden binären Baum eindeutig beschrieben werden: 89:; ?>=< + AAAA 89:; ?>=< GFED @ABC ∗= E = = = @ABC GFED @ABC GFED /= D == = 89:; ?>=< 89:; ?>=< ∗ A ??? ? @ABC GFED GFED @ABC B C Seine Vorordnungsfolge (= ˆ Präfixnotation des Ausdrucks): +∗/A∗BCDE Seine Zwischenordnungsfolge (= ˆ Infixnotation des Ausdrucks; ist ohne Klammerung nicht eindeutig): A/B∗C∗D+E 2.2. IMPLEMENTIERUNG BINÄRER BÄUME 61 Seine Nachordnungsfolge (= ˆ Postfixnotation des Ausdrucks): ABC∗/D∗E+ Offensichtlich lassen sich diese Folgen leicht rekursiv bestimmen (wie in der Definition angegeben), wobei wir die Implementierung mittels Referenzen aus Definition 2.2.3(b) zu Grunde legen. In Programm 2.2.4 ist dies in den Methoden druckeVorordnung, druckeZwischenordnung bzw. druckeNachordnung realisiert. Wir wollen nun die Rechenzeit bestimmen, die diese Algorithmen für einen binären Baum der Höhe h mit n = 2h+1 − 1 Knoten brauchen. Dazu sei z(h) die Zeit, die das Drucken in Zwischenordnung im schlechtesten Fall für einen binären Baum der Höhe h braucht. Dann gelten für z(h) die folgenden Gleichungen: z(0) = c1 für eine Konstante c1 > 0, z(h + 1) = z(h) + c2 + z(h) für eine Konstante c2 > 0. Zur Vereinfachung ersetzen wir c1 und c2 durch den Wert c = max{c1 , c2 }. Dies ist gerechtfertigt, da wir uns nur für das asymptotische Verhalten der Funktion z(h) für h → ∞ interessieren. Wir erhalten nun aus obigen Gleichungen folgendes: z(h) = 2z(h − 1) + c = 4z(h − 2) + 2c + c = · · · h = 2 z(0) + c · h h−1 X 2i i=0 h = 2 · c + c · (2 − 1) = c · (2h+1 − 1) = c · n ∈ O(n). Da n Knoteninhalte ausgegeben werden, erhalten wir sogar z(h) ∈ Θ(n). Das Durchlaufen eines binären Baums in Vor- oder Nachordnung wird ganz analog realisiert, daher ergibt sich dafür derselbe Rechenzeitbedarf. Anwendungen für dieses Durchlaufen eines binären Baums sind neben der Ausgabe beispielsweise die folgenden Aufgaben: • das Erstellen der Kopie eines binären Baums, • das Durchsuchen nach einem speziellen Knoten (vgl. die Methode suche der Klasse Binaerbaum bzw. -knoten in Programm 2.2.4), • die Auswertung der durch einen binären Baum dargestellten Information (z.B. einen Ausdruck auswerten). Wenn wir uns die Darstellung von binären Bäumen mittels Referenzen genauer anschauen, dann sehen wir, dass die meisten Felder linkesKind und rechtesKind den Wert null enthalten. 62 KAPITEL 2. BÄUME UND IHRE IMPLEMENTIERUNG Sei etwa B ein binärer Baum mit n0 Blättern, n1 Knoten mit nur einem Kind und n2 Knoten mit 2 Kindern. Bei den Blättern sind jeweils beide Felder linkesKind und rechtesKind leer, bei den Knoten mit einem Kind ist jeweils genau eines dieser Felder leer, und nur bei den Knoten mit 2 Kindern sind alle diese Felder nicht leer, d.h. 2n0 + n1 Felder sind leer, aber nur 2n2 + n1 = 2(n0 − 1) + n1 < 2n0 + n1 Felder sind nicht leer (Lemma 2.1.6). Diese leeren Felder kann man nun benutzen, um zusätzliche Verweise zu speichern, wodurch das Durchlaufen des Baums vereinfacht wird. Definition 2.2.7. Implementierung der Datenstruktur Binärer Baum“ mit” tels Referenzen und zusätzlichen Verweisen (threaded binary trees): Die Darstellung erfolgt wie in Definition 2.2.3(b). Zusätzlich werden folgende Verweise (threads) gespeichert: Ist für einen Knoten K im Baum B das Feld K.rechtesKind gleich null, so wird in K.rechtesKind ein Verweis auf den Knoten gespeichert, der beim Durchlaufen von B in Zwischenordnung unmittelbar hinter K ausgegeben wird. Ist für K das Feld K.linkesKind gleich null, so wird entsprechend in K.linkesKind ein Verweis auf den Knoten gespeichert, der beim Durchlaufen von B in Zwischenordnung unmittelbar vor K ausgegeben wird. Beispiel: @ABC o GFED l w1 M lll O W 5 MMMMM l l & lu ll M ] GFED @ABC GFED @ABC w2 F w3 F F S+ FF x K O FFF x F" x x| " F [ GFED @ABC GFED @ABC GFED @ABC w w w6 4 5 xxx K S+ FFFF ? j p x| " F [ GFED @ABC GFED @ABC w7 B W w8 / : linkes bzw. rechtes Kind, _ _/ : linker Thread, / : rechter Thread Zwischenordnungsfolge: w4 , w2 , w7 , w5 , w8 , w1 , w3 , w6 Damit wir bei der Implementierung zwischen den normalen Referenzen, die auf die Kinder eines Knotens zeigen, und den zusätzlichen Verweisen unterscheiden können, ergänzen wir jeden Knoten (bzw. seine Darstellung) durch zwei Boolesche Werte linksThread und rechtsThread, die anzeigen, ob es sich bei der entsprechenden Referenz um einen zusätzlichen Verweis handelt: K.linksThread = true gdw K.linkerVerweis ist ein zusätzlicher Verweis, K.rechtsThread = true gdw K.rechterVerweis ist ein zusätzlicher Verweis. Für das obige Beispiel erhalten wir damit die folgende Realisierung (im Diagramm sind jeweils von links nach rechts die fünf Felder linksThread, linkerVerweis, inhalt, rechterVerweis, rechtsThread dargestellt): 2.2. IMPLEMENTIERUNG BINÄRER BÄUME false w1 " " " " false w2 " " " " true - true false w3 false w5 true false w6 - true b b b b true w7 false b b b b " " " " true TB false b b b b b b b b true w4 63 true true w8 Zur Vereinfachung mancher Algorithmen kann man bei dieser Implementierung für jeden binären Baum noch einen speziellen Kopfknoten einführen: Für den leeren Baum: true false TB Für den nicht-leeren Baum: false false TB zum Wurzelknoten Damit wird ein nicht-leerer Baum über die linke Referenz (linkesKind) des Kopfknotens erreicht. Die beiden Verweise im Baum, die noch null sind, werden nun als Verweise auf den Kopfknoten verwendet. Obiges Beispiel sieht dann so aus: Kopfknoten false false TB % % % false w1 " " " " false w2 " " " " true w4 false true w3 b b b b true false w7 true false b b b b w5 " " " " true false b b b b false true w6 b b b b true w8 true true 64 KAPITEL 2. BÄUME UND IHRE IMPLEMENTIERUNG Die zusätzlichen Verweise kann man nun dazu verwenden, für einen Knoten den Nachfolger bezüglich der Zwischenordnung zu bestimmen. In folgendem Programm tut dies die Methode nachfolger der Klasse BinaerknotenMitThreads: 1 2 3 4 5 6 7 8 9 10 public BinaerknotenMitThreads erster( ) { BinaerknotenMitThreads k = this; while( !k.linksThread ) // solange ein linkes Kind vorhanden ist k = k.linkerVerweis; return k; } public BinaerknotenMitThreads nachfolger( ) { return rechtsThread ? rechterVerweis : rechterVerweis.erster( ); } Man überlegt sich leicht, dass diese Methode die folgenden Eigenschaften hat. Lemma 2.2.8. Sei T B eine Referenz auf einen Knoten eines binären Baums B, der wie oben beschrieben implementiert ist. Wird dafür die Methode nachfolger aufgerufen, dann liefert dieser Aufruf eine Referenz auf den Knoten K in B, der in der Zwischenordnungsfolge unmittelbar auf den Knoten T B folgt. Ferner gelten folgende Aussagen: • K = T B genau dann, wenn T B der Kopfknoten des leeren Baums ist. • K ist der Kopfknoten des nicht-leeren Baums B genau dann, wenn T B der letzte Knoten von B bezüglich der Zwischenordnung ist. Unter Verwendung der Methode nachfolger kann man einen binären Baum in Zwischenordnung durchlaufen, ohne dabei Rekursion bzw. einen zusätzlichen Keller zu verwenden. Dies nutzen die Methoden suche und druckeZwischenordnung in folgendem Beispielprogramm aus. Programm 2.2.9. Eine Implementierung binärer Bäume mit zusätzlichen Verweisen und mit Kopfknoten: 1 2 3 4 5 6 7 8 9 10 11 /** Die Klasse BinaerknotenMitThreads implementiert Knoten eines binaeren * Baums. Jeder Knoten speichert ein beliebiges Objekt vom Typ Object, * zusaetzlich je eine Referenz auf das linke und das rechte Kind oder, * falls das jeweilige Kind nicht vorhanden ist, eine Referenz auf den * Vorgaenger bzw. Nachfolger bzgl. der Zwischenordnung. */ class BinaerknotenMitThreads { /** Der Knoteninhalt. */ private Object inhalt; erster nachfolger 2.2. IMPLEMENTIERUNG BINÄRER BÄUME 12 13 14 65 /** Das linke und das rechte Kind bzw. der jeweilige zusaetzliche Verweis. */ private BinaerknotenMitThreads linkerVerweis; private BinaerknotenMitThreads rechterVerweis; 15 16 17 18 19 20 /** Boolesche Werte geben an, ob linker bzw. rechter Verweis auf ein Kind * referiert (false) oder ein zusaetzlicher Verweis ist (true). */ private boolean linksThread; private boolean rechtsThread; 21 22 23 24 25 26 27 28 /** Konstruiert einen Blattknoten mit Inhalt inhalt. * Die Threads sind noch auf null gesetzt. */ public BinaerknotenMitThreads( Object inhalt ) { this.inhalt = inhalt; linksThread = rechtsThread = true; } BinaerknotenMitThreads 29 30 31 32 33 /** Gibt den Inhalt des Knotens zurueck. */ public Object inhalt( ) { return inhalt; } inhalt 34 35 36 37 38 /** Gibt den linken Verweis zurueck. */ public BinaerknotenMitThreads linkerVerweis( ) { return linkerVerweis; } linkerVerweis 39 40 41 42 43 /** Gibt den rechten Verweis zurueck. */ public BinaerknotenMitThreads rechterVerweis( ) { return rechterVerweis; } rechterVerweis 44 45 46 47 48 /** Gibt den Wert von linksThread zurueck. */ public boolean linksThread( ) { return linksThread; } linksThread 49 50 51 52 53 /** Gibt den Wert von rechtsThread zurueck. */ public boolean rechtsThread( ) { return rechtsThread; } rechtsThread 54 55 56 57 58 /** Der Inhalt wird zu inhalt. */ public void inhaltAendern( Object inhalt ) { this.inhalt = inhalt; } inhaltAendern 59 60 61 62 63 64 /** Der linke Verweis wird zu knoten. */ public void linkenVerweisAendern( BinaerknotenMitThreads knoten ) { linkerVerweis = knoten; } linkenVerweisAendern 66 65 66 67 68 KAPITEL 2. BÄUME UND IHRE IMPLEMENTIERUNG /** Der rechter Verweis wird zu knoten. */ public void rechtenVerweisAendern( BinaerknotenMitThreads knoten ) { rechterVerweis = knoten; } rechtenVerweisAendern 69 70 71 72 73 /** Der Wert von linksThread wird zu b. */ public void linksThreadAendern( boolean b ) { linksThread = b; } linksThreadAendern 74 75 76 77 78 /** Der Wert von rechtsThread wird zu b. */ public void rechtsThreadAendern( boolean b ) { rechtsThread = b; } rechtsThreadAendern 79 80 81 82 83 /** Test, ob der Knoten ein Kopfknoten ist. */ boolean istKopfknoten( ) { return this.rechterVerweis == this; } istKopfknoten 84 85 86 87 88 89 90 91 92 93 94 /** Gibt den ersten Knoten des hier wurzelnden (bzw. fuer Kopfknoten hier * haengenden) Baums bzgl. Zwischenordnung zurueck. Ist der aktuelle Knoten * der Kopfknoten des leeren Baums, wird er selbst zurueckgegeben. */ public BinaerknotenMitThreads erster( ) { BinaerknotenMitThreads k = this; while( !k.linksThread ) // solange ein linkes Kind vorhanden ist k = k.linkerVerweis; return k; } erster 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 /** Gibt den letzten Knoten des hier wurzelnden (bzw. fuer Kopfknoten hier * haengenden) Baums bzgl. Zwischenordnung zurueck. Ist der aktuelle Knoten * der Kopfknoten des leeren Baums, wird er selbst zurueckgegeben. */ public BinaerknotenMitThreads letzter( ) { BinaerknotenMitThreads k = this; if( k.istKopfknoten( ) ) { // falls der Knoten ein Kopfknoten ist if( k.linksThread ) // falls es der Kopfknoten des leeren Baums ist return k; else // der Baum ist nicht leer k = k.linkerVerweis; } while( !k.rechtsThread ) // solange ein rechtes Kind vorhanden ist k = k.rechterVerweis; return k; } letzter 112 113 114 115 116 117 /** Gibt den Nachfolgerknoten bzgl. Zwischenordnung zurueck. */ public BinaerknotenMitThreads nachfolger( ) { return rechtsThread ? rechterVerweis : rechterVerweis.erster( ); } nachfolger 2.2. IMPLEMENTIERUNG BINÄRER BÄUME 118 119 120 121 122 123 124 125 126 127 128 129 130 67 /** Durchsucht den hier wurzelnden Baum in Zwischenordnung. Gibt true zurueck, * wenn es darin einen Knoten mit Inhalt inhalt gibt, sonst false. Die Objekte * werden mit equals verglichen. Fuer Kopfknoten wird false zurueckgegeben. */ public boolean suche( Object inhalt ) { suche BinaerknotenMitThreads k = this; while( !k.istKopfknoten( ) ) { if( k.inhalt.equals( inhalt ) ) return true; k = k.nachfolger( ); } return false; } 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 /** Gibt eine String-Darstellung des Inhalts zurueck. */ public String toString( ) { return inhalt.toString( ); } /** Gibt den hier wurzelnden Baum in Zwischenordnung aus. * Fuer Kopfknoten wird nichts ausgegeben. */ public void druckeZwischenordnung( ) { BinaerknotenMitThreads k = this; while( !k.istKopfknoten( ) ) { System.out.print( k + " " ); k = k.nachfolger( ); } } toString druckeZwischenordnung } // class BinaerknotenMitThreads 150 151 /** Die Klasse BinaerbaumMitThreads implementiert binaere Baeume mit Threads. * Ein solcher Binaerbaum besteht aus seinem Kopfknoten (siehe Skripttext). */ 154 public class BinaerbaumMitThreads { 152 153 155 156 157 158 159 160 161 162 163 164 165 166 167 168 /** Der Kopfknoten. */ private BinaerknotenMitThreads kopfknoten; /** Konstruiert den leeren Baum, der also nur den Kopfknoten besitzt. * Der Kopfknoten speichert (zu Testzwecken) den String “Kopfknoten”. */ public BinaerbaumMitThreads( ) { kopfknoten = new BinaerknotenMitThreads( "Kopfknoten" ); kopfknoten.linkenVerweisAendern( kopfknoten ); kopfknoten.rechtenVerweisAendern( kopfknoten ); kopfknoten.rechtsThreadAendern( false ); } BinaerbaumMitThreads 68 169 170 171 172 173 174 175 176 177 178 179 KAPITEL 2. BÄUME UND IHRE IMPLEMENTIERUNG /** Konstruiert einen Baum, der nur einen Wurzelknoten mit Inhalt * inhalt besitzt. */ public BinaerbaumMitThreads( Object inhalt ) { this( ); // Konstruktor fuer den leeren Baum aufrufen BinaerknotenMitThreads wurzel = new BinaerknotenMitThreads( inhalt ); kopfknoten.linksThreadAendern( false ); kopfknoten.linkenVerweisAendern( wurzel ); wurzel.linkenVerweisAendern( kopfknoten ); wurzel.rechtenVerweisAendern( kopfknoten ); } BinaerbaumMitThreads 180 181 182 183 184 /** Gibt den Kopfknoten zurueck. */ public BinaerknotenMitThreads kopfknoten( ) { return kopfknoten; } kopfknoten 185 186 187 188 189 /** Test, ob der Baum leer ist. */ boolean istLeer( ) { return kopfknoten.linksThread( ); } istLeer 190 191 192 193 194 195 196 /** Gibt den Wurzelknoten zurueck, falls der Baum nicht leer ist, * sonst den Kopfknoten. */ public BinaerknotenMitThreads wurzel( ) { return istLeer( ) ? kopfknoten : kopfknoten.linkerVerweis( ); } wurzel 197 198 199 200 201 202 203 /** Gibt den ersten Knoten des Baums bzgl. Zwischenordnung zurueck. * Ist der Baum leer, wird der Kopfknoten zurueckgegeben. */ public BinaerknotenMitThreads erster( ) { return kopfknoten.erster( ); } erster 204 205 206 207 208 209 210 /** Gibt den letzten Knoten des Baums bzgl. Zwischenordnung zurueck. * Ist der Baum leer, wird der Kopfknoten zurueckgegeben. */ public BinaerknotenMitThreads letzter( ) { return kopfknoten.letzter( ); } letzter 211 212 213 214 215 216 217 218 219 220 221 222 /** Erzeugt einen Baum mit dem aktuellen Baum als linkem Teilbaum, * mit neuem Wurzelknoten mit Inhalt inhalt und mit rechtem Teilbaum baum. * Der Argumentbaum baum wird danach leer sein. * Um zu verhindern, dass die Teilbaeume uebereinstimmen, fordern wir, dass * die beiden Wurzelreferenzen verschieden sind, ausser beide Baeume sind leer; * andernfalls wird eine Ausnahme ausgeloest. */ BinaerbaumMitThreads anhaengen( Object inhalt, BinaerbaumMitThreads baum ) { BinaerbaumMitThreads neuerBaum = new BinaerbaumMitThreads( inhalt ); if( !istLeer( ) && wurzel( ) == baum.wurzel( ) ) throw new IllegalArgumentException( "Identische Baeume verknuepft." ); anhaengen 2.2. IMPLEMENTIERUNG BINÄRER BÄUME if( !istLeer( ) ) { // wenn der linke Teilbaum nicht leer ist neuerBaum.wurzel( ).linksThreadAendern( false ); neuerBaum.wurzel( ).linkenVerweisAendern( wurzel( ) ); erster( ).linkenVerweisAendern( neuerBaum.kopfknoten ); letzter( ).rechtenVerweisAendern( neuerBaum.wurzel( ) ); } if( !baum.istLeer( ) ) { // wenn der rechte Teilbaum nicht leer ist neuerBaum.wurzel( ).rechtsThreadAendern( false ); neuerBaum.wurzel( ).rechtenVerweisAendern( baum.wurzel( ) ); baum.erster( ).linkenVerweisAendern( neuerBaum.wurzel( ) ); baum.letzter( ).rechtenVerweisAendern( neuerBaum.kopfknoten ); } // Das Argument baum leer machen: baum.kopfknoten.linksThreadAendern( true ); baum.kopfknoten.linkenVerweisAendern( baum.kopfknoten ); return neuerBaum; 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 69 } 240 241 242 243 244 245 246 247 /** Durchsucht den Baum in Vorordnung. Gibt true zurueck, wenn es darin * einen Knoten mit Inhalt inhalt gibt, sonst false. Die Objekte werden * mit equals verglichen. */ public boolean suche( Object inhalt ) { return istLeer( ) ? false : erster( ).suche( inhalt ); } suche 248 249 250 251 252 253 254 255 256 257 /** Gibt den Baum in Zwischenordnung aus. */ public void druckeZwischenordnung( ) { if( istLeer( ) ) System.out.println( "Der Baum ist leer." ); else { erster( ).druckeZwischenordnung( ); System.out.println( ); } } druckeZwischenordnung 258 259 260 261 262 263 public static void main( String[ ] args ) { BinaerbaumMitThreads b1 = new BinaerbaumMitThreads( "A" ). anhaengen( "*", new BinaerbaumMitThreads( "B" ) ); b1.druckeZwischenordnung( ); 264 265 266 267 268 269 270 271 272 BinaerbaumMitThreads b2 = new BinaerbaumMitThreads( "A" ). anhaengen( "/", new BinaerbaumMitThreads( "B" ). anhaengen( "*", new BinaerbaumMitThreads( "C" ) ) ). anhaengen( "*", new BinaerbaumMitThreads( "D" ) ). anhaengen( "+", new BinaerbaumMitThreads( "E" ) ); b2.druckeZwischenordnung( ); } // main 273 274 } // class BinaerbaumMitThreads main 70 KAPITEL 2. BÄUME UND IHRE IMPLEMENTIERUNG 2.3 Erste Anwendungen binärer Bäume Als erste Anwendung der binären Bäume lernen wir hier zwei Sortierverfahren kennen. Sei dabei Item eine Menge von Objekten und ≤ eine lineare Ordnung auf Item, d.h. ≤ erfülle die Axiome • a≤a • aus a ≤ b und b ≤ c folgt a ≤ c • aus a ≤ b und b ≤ a folgt a = b • a ≤ b oder b ≤ a für alle a, b, c ∈ Item. Sei nun (a1 , . . . , an ) eine endliche Folge von Elementen aus Item. Wir wollen diese Folge so zu einer Folge (b1 , . . . , bn ) umordnen, dass b1 ≤ b2 ≤ · · · ≤ bn gilt. 2.3.1 TREE SORT Unser erster Algorithmus löst diese Aufgabe unter Verwendung binärer Bäume wie folgt: Eingabe: n = 2k Elemente a1 , . . . , an einer linear geordneten Menge (Item, ≤). Ausgabe: Die Elemente a1 , . . . , an in aufsteigend sortierter Reihenfolge, d.h. (b1 , . . . , bn ) mit b1 ≤ b2 ≤ · · · ≤ bn , wobei bi = aπ(i) für eine Permutation (d.h. eine bijektive Abbildung) π : {1, . . . , n} → {1, . . . , n} ist. Bemerkung: Ist 2k < n < 2k+1 , so wähle an+1 , . . . , a2k+1 als den symbolischen Wert ∞ und führe den Algorithmus für a1 , . . . , an , an+1 , . . . , a2k+1 durch. Algorithmus 2.3.1. Sortieren mit binären Bäumen (TREE SORT): void treeSort( ) { (1.) Trage ein KO-Turnier zwischen den n Elementen a1 , . . . , an aus. 3 Dabei soll ein binärer Baum aufgebaut werden, wobei jeder Elternknoten stets 4 als Knoteninhalt den kleinsten Wert der Knoteninhalte seiner Kinder erhält. 1 2 5 for( int i = 1; i <= n; i++ ) { (2.1.) Gib das Element an der Wurzel des Baums aus. (2.2.) Steige den Pfad herunter, dessen sämtliche Knoten als Schlüssel das ausgegebene Element tragen, und lösche diese Schlüssel. (2.3.) Beim Zurückkehren an die Spitze werden alle Vergleiche entlang dieses Weges neu ausgetragen. } 6 7 8 9 10 11 12 13 } treeSort 2.3. ERSTE ANWENDUNGEN BINÄRER BÄUME 71 Beispiel 2.3.2. n = 4 und (b1 , . . . , b4 ) = (17, 2, 12, 13). (1.) 17 2 QQQQ ppp QQQ ppp 2 12 D < D }}} < zz 2 12 13 (2.) i = 1: Ausgabe 2 17 − QQQ QQQ nnn Q nnn −A 12 D A D {{ zz − 12 , 17 13 12 RRRR mmm RRR mmm 17 C 12 D C D zz zz − 12 13 i = 2: Ausgabe 12 17 − PPP nnn PPP nnn −C 17 C C C }} zz − − , 17 13 13 QQQ QQQ mmm mmm 13 D 17 C D C zz {{ − − 13 i = 3: Ausgabe 13 17 − PPP nnn PPP nnn −A 17 C A C }} zz − − , − 17 17 PPP mmm PPP mmm −A 17 C A C }} zz − − − i = 4: Ausgabe 17. Lemma 2.3.3. Für n = 2k gilt: (a) TREE SORT hat bei Eingabe a1 , . . . , an den Platzbedarf 2n − 1. (b) TREE SORT hat bei Eingabe a1 , . . . , an den Zeitbedarf O(n log n). Beweis: (a) Abgesehen von einigen Referenzen muss TREE SORT den aufgebauten binären Baum mit n Blättern und n − 1 inneren Knoten speichern. (b) Schritt (1.): 2k−1 Vergleiche auf Stufe k Damit werden insge2k−2 Vergleiche auf Stufe k − 1 samt 2k − 1 = n − 1 .. .. Vergleiche durchge. ··· . f ührt. 1 Vergleiche auf Stufe 1 Schritt (2.): Für 1 ≤ i ≤ n: Absteigen und Löschen: k Stufen; Aufsteigen und neu vergleichen: k Stufen. Damit ergibt sich ein Zeitbedarf von c · ((n − 1) + n · k) ∈ O(n log n). Der Zeitbedarf von TREE SORT ist gut, da man zeigen kann, dass für das Sortieren von n Elementen im schlechtesten Fall stets O(n log n) Schlüsselvergleiche notwendig sind. Der Platzbedarf von TREE SORT ist mit 2n − 1 aber entschieden zu hoch, wenn man sehr lange Folgen a1 , . . . , an sortieren will. 72 2.3.2 KAPITEL 2. BÄUME UND IHRE IMPLEMENTIERUNG HEAP SORT Hier wollen wir noch ein Sortierverfahren betrachten, das ebenfalls mit binären Bäumen arbeitet, das zum Sortieren von n Elementen aber nur Platzbedarf n + c hat. Dazu brauchen wir den folgenden Begriff. Definition 2.3.4. Sei Item eine linear geordnete Menge. Ein Heap (über Item) ist ein vollständiger binärer Baum über Item, in dem jeder Knoten einen Schlüssel hat, der höchstens so groß ist wie die Schlüssel seiner Kinder. Beispiel 2.3.5. Zwei Heaps: 10 2N ppp NNNNN ppp 4 7< }}} < 8 6 50 zz 20 10 NNN pp NN ppp 83 Bemerkung 2.3.6. Sei H ein Heap über Item mit n Knoten. Für 1 ≤ i ≤ n bezeichne Ki den i-ten Knoten bei der stufenweisen Durchnummerierung von H von links nach rechts. (a) Wird H in einem Feld der Länge n so gespeichert, dass Ki an Position i steht, so gilt nach Lemma 2.2.1 für 1 ≤ i ≤ n folgendes: (i) Ist i > 1, so steht der Elternknoten von Ki an Position bi/2c. (ii) Ist 2i ≤ n, so steht das linke Kind von Ki an Position 2i; andernfalls hat Ki kein linkes Kind. (iii) Ist 2i + 1 ≤ n, so steht das rechte Kind von Ki an Position 2i + 1; andernfalls hat Ki kein rechtes Kind. (b) Ist bi ∈ Item der Schlüssel von Ki , so gilt b1 = min{bi | 1 ≤ i ≤ n}. Dies folgt unmittelbar aus der Definition des Heaps. Daher eignen sich Heaps insbesondere zur Speicherung von Prioritätsschlangen. Als nächstes wollen wir uns einige wichtige Operationen auf Heaps anschauen. Wir beginnen mit der Operation Einfügen“. ” , b ∈ Item. Beispiel 2.3.7. H : ll 20 NN 40 NNN ll lll 35 D D zz 45 50 Nach dem Einfügen muss der entstandene Heap folgende Form haben: * @ @ @ * * * A A A * * 2.3. ERSTE ANWENDUNGEN BINÄRER BÄUME (a) b = 55: 40 35 A A A 50 20 @ @ @ 73 45 55 (b) b = 25: 35 A A A 40 50 20 @ @ @ , 45 35 A A A 40 50 * ⇐ 25 ? , 20 @ @ @ 35 A A A 40 50 20 @ @ @ * ⇐ 25 ? 45 25n 45 (c) b = 10: 40 35 A A A 50 20 @ @ @ 45 * ⇐ 10 ? , 40 35 A A A 50 , n 10 @ @ @ * ⇐ 10 ? 35 20 A A A 45 40 50 45 20 @ @ @ Algorithmus 2.3.8. Einfügen eines Elements in einen Heap (wir setzen voraus, dass ein Feld array zur Speicherung des Heaps zur Verfügung steht): 1 2 3 4 5 6 7 8 9 10 11 void einfuegenInHeap( Comparable c ) { if( knotenZahl == array.length ) // Einfuegen ist unmoeglich throw new IllegalStateException( "Heap ist bereits voll." ); knotenZahl++; int i = knotenZahl; for( ; i > 1 && c.compareTo( inhalt( i/2 ) ) < 0; i = i/2 ) { // wenn i nicht 1 ist und c kleiner als der Inhalt des Knotens i/2 ist: inhaltAendern( i, inhalt( i/2 ) ); } inhaltAendern( i, c ); // das neue Objekt in den Knoten i einfuegen } Zeitbedarf von insertHeap: O(log n). Sei H ein nicht-leerer Heap über Item mit n Knoten. Wir wollen nun die Wurzel von H entfernen und dann die verbleibenden Knoten wieder in Form eines Heaps organisieren. einfuegenInHeap 74 KAPITEL 2. BÄUME UND IHRE IMPLEMENTIERUNG Beispiel 2.3.9. H: 40 l 10 RRRRR lll RR lll 35 D 20 D zz zz 50 45 Zuerst wird die 10 entfernt. Danach muss der Heap die folgenden Form haben: * @ @ @ * * 35 A A A 40 50 * ⇐ 45 ? @ @ @ 20 * A A A * ; min{20, 35} = 20 < 45 : 40 35 A A A 50 20 @ @ @ 45 Aus dem resultierenden Heap wird nun noch die 20 entfernt: 35 * ⇐ 50 ? @ @ @ 45 ; min{35, 45} = 35 < 50 : ? 50 ⇒ * 40 40 40 < 50 : 40 35 @ @ @ 35 @ @ @ 45 ; 45 50 Zur Realisierung dieser Operation entwickeln wir zunächst eine Hilfsmethode lasseEinsinken, die folgendes leistet: Eine Folge von Elementen aus Item sei im Feld array im Bereich i bis n gespeichert. Falls die Teilbäume mit Wurzelknoten 2i und 2i+1 bereits die Heap-Eigenschaft erfüllen, so sorgt diese Methode dafür, dass auch der Teilbaum mit Wurzelknoten i diese Eigenschaft erfüllt. 2.3. ERSTE ANWENDUNGEN BINÄRER BÄUME 75 Algorithmus 2.3.10. Hilfsmethode lasseEinsinken: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 void lasseEinsinken( int i, int n ) { int j = 2*i; Comparable c = (Comparable)inhalt( i ); while( j <= n ) { // j wird dasjenige Kind von j/2, das minimalen Knoteninhalt hat: if( j < n && ((Comparable)inhalt( j )).compareTo( inhalt( j+1 ) ) > 0 ) j++; if( c.compareTo( inhalt( j ) ) > 0 ) { inhaltAendern( j/2, inhalt( j ) ); j = 2*j; } else break; } inhaltAendern( j/2, c ); } lasseEinsinken Zeitbedarf von lasseEinsinken: O(Höhe des Teilbaums mit Wurzelknoten i). Der Algorithmus loeschenAusHeap kann nun wie folgt formuliert werden. Algorithmus 2.3.11. Entfernen des obersten Elements aus einem Heap: 1 2 3 4 5 6 7 8 9 10 Object loeschenAusHeap( ) { if( istLeer( ) ) // Loeschen ist unmoeglich throw new IllegalStateException( "Heap ist leer." ); Comparable c = (Comparable)inhalt( 1 ); inhaltAendern( 1, inhalt( knotenZahl ) ); knotenZahl−−; if( !istLeer( ) ) lasseEinsinken( 1, knotenZahl ); return c; } Zeitbedarf von loeschenAusHeap: O(log n). Da bei einem Heap das kleinste Element immer oben steht, liefert uns die Operation loeschenAusHeap (1.) das kleinste der n Elemente im Heap und (2.) einen Heap für die restlichen n − 1 Elemente. Also können wir durch das Aufbauen eines Heaps für n Elemente und anschließendes Entfernen diese Elemente sortieren. Dies ist die Idee des folgenden Sortieralgorithmus. loeschenAusHeap 76 KAPITEL 2. BÄUME UND IHRE IMPLEMENTIERUNG Algorithmus 2.3.12. Sortieren mit Heaps (HEAP SORT): void erzeugeHeap( ) { for( int i = knotenZahl/2; i > 0; i−− ) lasseEinsinken( i, knotenZahl ); 4 } 1 erzeugeHeap 2 3 5 6 7 8 9 10 11 12 13 14 15 16 17 18 /** Die im Baum gespeicherten Knoteninhalte werden absteigend sortiert * bezueglich der durch compareTo gegebenen Ordnung. * Alle Knoteninhalte muessen daher paarweise mit compareTo vergleichbar sein. */ void heapsort( ) { erzeugeHeap( ); for( int i = knotenZahl−1; i > 0; i−− ) { Object c = inhalt( i+1 ); inhaltAendern( i+1, inhalt( 1 ) ); inhaltAendern( 1, c ); lasseEinsinken( 1, i ); } } Lemma 2.3.13. HEAP SORT hat einen Zeitbedarf von O(n log n) und einen Platzbedarf von n + c. Beweis: (1.) Heap erstellen: Sei 2h ≤ n < 2h+1 , d.h. der zu erstellende Heap hat die Höhe h. Damit finden statt: auf Stufe h − 1: höchstens 2h−1 Aufrufe von lasseEinsinken, d.h. Zeitbedarf ≤ c · 2h−1 · 1 auf Stufe h − 2: höchstens 2h−2 Aufrufe von lasseEinsinken d.h. Zeitbedarf ≤ c · 2h−2 · 2. Und so weiter. Damit erhalten wir: Zeitbedarf ≤ h−1 X c · 2i · (h − i) = c · 2h · i=1 = c · 2h · h−1 X (h − i) · 2i−h i=1 h−1 X i · 2−i ≤ 2c · n ∈ O(n). |i=1 {z <2 } (2.) Umordnen und Heaps wiederherstellen (n − 1 Aufrufe von lasseEinsinken): Zeitbedarf ≤ c · (n − 1) · h ∈ O(n log n). Der Vergleich zwischen TREE SORT und HEAP SORT zeigt, dass beide einen bezüglich der Anzahl der Schlüsselvergleiche optimalen Zeitbedarf haben, sich aber im Platzbedarf um den Faktor zwei unterscheiden: Zeitbedarf Platzbedarf TREE SORT O(n log n) 2n − 1 (+c) HEAP SORT O(n log n) n (+c) heapsort 2.3. ERSTE ANWENDUNGEN BINÄRER BÄUME 77 Programm 2.3.14. Eine einfache Implementierung von HEAP SORT: wie in Programm 2.2.2 * Werden Heaps mit vollstaendigen binaeren Baeumen realisiert, * dann muessen die gespeicherten Objekte vom Typ Comparable sein. 13 */ 14 public class VollstaendigerBinaererBaum { 11 12 wie in Programm 2.2.2 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 /** Das Objekt c vom Typ Comparable wird in den Heap eingefuegt. * Wir setzen voraus, dass der Baum davor die Heap-Eigenschaft hat * und garantieren, dass diese Eigenschaft erhalten bleibt. * Ist der Heap bereits voll, so wird eine Ausnahme ausgeloest. */ public void einfuegenInHeap( Comparable c ) { if( knotenZahl == array.length ) // Einfuegen ist unmoeglich throw new IllegalStateException( "Heap ist bereits voll." ); knotenZahl++; int i = knotenZahl; for( ; i > 1 && c.compareTo( inhalt( i/2 ) ) < 0; i = i/2 ) { // wenn i nicht 1 ist und c kleiner als der Inhalt des Knotens i/2 ist: inhaltAendern( i, inhalt( i/2 ) ); } inhaltAendern( i, c ); // das neue Objekt in den Knoten i einfuegen } einfuegenInHeap 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 /** Hier werden nur die Knoten zwischen i und n (i <= n) beruecksichtigt. * Wir setzen voraus, dass die Teilbaeume des Knotens i die Heap-Eigenschaft * haben. Diese Eigenschaft wird nun fuer den Teilbaum mit Wurzel i hergestellt. * Alle Knoteninhalte muessen paarweise mit compareTo vergleichbar sein. */ private void lasseEinsinken( int i, int n ) { int j = 2*i; Comparable c = (Comparable)inhalt( i ); while( j <= n ) { // j wird dasjenige Kind von j/2, das minimalen Knoteninhalt hat: if( j < n && ((Comparable)inhalt( j )).compareTo( inhalt( j+1 ) ) > 0 ) j++; if( c.compareTo( inhalt( j ) ) > 0 ) { inhaltAendern( j/2, inhalt( j ) ); j = 2*j; } else break; } inhaltAendern( j/2, c ); } 170 171 172 173 174 175 /** Gibt das im Wurzelknoten des Baums gespeicherte Objekt zurueck * und entfernt dann den Wurzelknoten. * Ist der Baum leer, so wird eine Ausnahme ausgeloest. * Wir setzen voraus, dass der Baum davor die Heap-Eigenschaft hat * und garantieren, dass diese Eigenschaft erhalten bleibt. lasseEinsinken 78 176 177 178 179 180 181 182 183 184 185 186 187 KAPITEL 2. BÄUME UND IHRE IMPLEMENTIERUNG * Alle Knoteninhalte muessen paarweise mit compareTo vergleichbar sein. */ public Object loeschenAusHeap( ) { if( istLeer( ) ) // Loeschen ist unmoeglich throw new IllegalStateException( "Heap ist leer." ); Comparable c = (Comparable)inhalt( 1 ); inhaltAendern( 1, inhalt( knotenZahl ) ); knotenZahl−−; if( !istLeer( ) ) lasseEinsinken( 1, knotenZahl ); return c; } loeschenAusHeap 188 189 190 191 192 193 194 195 /** Stellt die Heap-Eigenschaft her. * Alle Knoteninhalte muessen paarweise mit compareTo vergleichbar sein. */ public void erzeugeHeap( ) { for( int i = knotenZahl/2; i > 0; i−− ) lasseEinsinken( i, knotenZahl ); } erzeugeHeap 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 /** Die im Baum gespeicherten Knoteninhalte werden absteigend sortiert * bezueglich der durch compareTo gegebenen Ordnung. * Alle Knoteninhalte muessen daher paarweise mit compareTo vergleichbar sein. * Gibt das sortierte Array zurueck. */ public Comparable[ ] heapSort( ) { erzeugeHeap( ); for( int i = knotenZahl−1; i > 0; i−− ) { Object c = inhalt( i+1 ); inhaltAendern( i+1, inhalt( 1 ) ); inhaltAendern( 1, c ); lasseEinsinken( 1, i ); } return (Comparable[ ])array; } heapSort 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 public static void main( String[ ] args ) { int groesse = 30; int max = 100; // Erzeuge einen Baum der Groesse groesse, dessen Knoten Zufallszahlen // zwischen 0 (inklusive) und max (exklusive) speichern: Integer[ ] array = new Integer[groesse]; Random zufall = new Random( ); for( int i = 0; i < groesse; i++ ) array[i] = new Integer( zufall.nextInt( max ) ); VollstaendigerBinaererBaum baum = new VollstaendigerBinaererBaum( array ); System.out.println( baum ); // unsortiert baum.heapSort( ); System.out.print( baum ); // sortiert } // main 227 228 } // class VollstaendigerBinaererBaum main 2.4. DARSTELLUNGEN ALLGEMEINER BÄUME 79 Ein Testlauf: Stufe Stufe Stufe Stufe Stufe 0: 1: 2: 3: 4: 40 6 83 43 26 45 19 43 47 32 1 39 18 64 46 30 2 37 87 99 72 63 5 86 71 45 2 94 44 92 Stufe Stufe Stufe Stufe Stufe 0: 1: 2: 3: 4: 99 94 87 71 43 2.4 92 86 83 72 64 63 47 46 45 45 44 43 40 39 37 32 30 26 19 18 6 5 2 2 1 Darstellungen allgemeiner Bäume Nach Definition 2.1.2 wird ein geordneter Baum B = (v, B1 , . . . , Bm ) aus einem Knoten v (der Wurzel) und m (≥ 0) direkten Teilbäumen B1 , . . . , Bm gebildet. Zur Implementierung von Bäumen gibt es nun eine Reihe von Möglichkeiten. Beschränkt man sich auf Bäume mit maximalem Knotengrad k, so ist eine Implementierung ganz analog zu Programm 2.2.4 möglich. Jeder Knoten speichert dann statt 2 genau k Referenzen kind1, . . . ,kindk für seine maximal k Kinder. Diese Implementierung hat aber einige gravierende Nachteile: (1.) Die Anzahl der Kinder eines Knotens ist beschränkt. (2.) Haben nur wenige Knoten tatsächlich k Kinder, so wird viel Speicherplatz verschwendet. (3.) Selbst wenn alle Knoten, die nicht Blätter sind, k Kinder haben, wird noch viel Speicherplatz verschwendet. Es gilt nämlich folgende Aussage. Lemma 2.4.1. Sei B ein Baum vom Grad k mit n Knoten. Wird B wie oben beschrieben implementiert, so sind n · (k − 1) + 1 der n · k Referenzen kind1, . . . , kindk gleich null. Beweis: Es gibt n · k Referenzen für die jeweiligen Kinder. Nur n − 1 dieser Referenzen sind tatsächlich in Gebrauch, d.h. n · k − (n − 1) = n · (k − 1) + 1 davon sind null. Für k = 3 sind also (2n + 1)/(3n) ≈ 2/3 aller Referenzen nicht benutzt. Man wird daher diese Implementierung i.Allg. nicht verwenden. In Kapitel 4 werden wir Graphen betrachten. Da Bäume eine spezielle Art von Graphen sind, kann man natürlich die Methoden zur Implementierung von Graphen verwenden. 80 KAPITEL 2. BÄUME UND IHRE IMPLEMENTIERUNG Schließlich kann man (allgemeine) Bäume aber auch mittels binärer Bäume implementieren, wie im Folgenden noch ausgeführt wird. Definition 2.4.2. Eine Implementierung (allgemeiner) geordneter Bäume durch binäre Bäume: Sei B = (v, B1 , . . . , Bm ) ein geordneter Baum. Nun definieren wir einen binären Baum C, der genauso viele Knoten hat wie der Baum B. Die Wurzel von C entspricht der Wurzel v von B. Als linkes Kind erhält jeder Knoten in C einen Verweis auf den Knoten, der dem ersten Kind des entsprechenden Knotens in B entspricht. Als rechtes Kind erhält jeder Knoten in C einen Verweis auf den Knoten, der dem nächsten Geschwisterknoten des entsprechenden Knotens in B entspricht. Beispiel 2.4.3. Allgemeiner Baum B: 89:; ?>=< A PPP PPP nnn n n PPP n n n PP n nn @ABC GFED @ABC GFED @ABC GFED B@ C D@ @ @@ } ~ @@ } ~ @@ } ~ @@ } ~ @ } ~ } ~ @ABC GFED @ABC GFED @ABC GFED @ABC GFED 89:; ?>=< 89:; ?>=< E F G H I J Binärer Baum C: 89:; ?>=< n A n n n nnn nnn n w @ABC GFED @ABC @ABC B _ _ _ _ _/GFED C _ _ _ _ _/ GFED D } ~ } ~ } ~ } ~ ~}} ~~~ @ABC GFED @ABC GFED @ABC GFED @ABC GFED 89:; 89:; _ _ _ _ _ / _ E F G H _/ ?>=< I _ _/ ?>=< J Als über Referenzen verbundene Struktur erhalten wir folgende Darstellung: A - " " " " B - E - F C bb bb b - - G - - " " " " H - D - - J I - 2.4. DARSTELLUNGEN ALLGEMEINER BÄUME 81 Programm 2.4.4. Eine Implementierung allgemeiner geordneter Bäume: /** Die Klasse Binaerknoten implementiert Knoten eines binaeren Baums. * Jeder Knoten speichert ein beliebiges Objekt vom Typ Object, 3 * zusaetzlich je eine Referenz auf das linke und das rechte Kind. 4 */ 5 class Binaerknoten { 1 2 wie in Programm 2.2.4 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 /** Gibt den hier wurzelnden Baum in Vorordnung mit Klammern * und Kommata aus. */ public void druckeVorordnung( ) { System.out.print( this ); if( linkesKind != null ) { System.out.print( "(" ); linkesKind.druckeVorordnung( ); Binaerknoten k = linkesKind.rechtesKind; while( k != null ) { System.out.print( "," ); k.druckeVorordnung( ); k = k.rechtesKind; } System.out.print( ")" ); } } druckeVorordnung 80 81 } // class Binaerknoten 82 83 /** Die Klasse AllgemeinerBaum implementiert allgemeine Baeume * ueber Binaerbaeume. Ein AllgemeinerBaum besteht 86 * einfach aus seinem Wurzelknoten vom Typ Binaerknoten. 87 */ 88 public class AllgemeinerBaum { 84 85 89 90 91 /** Der Wurzelknoten. */ private Binaerknoten wurzel; 92 93 94 95 96 97 98 /** Konstruiert einen Baum, der nur einen Wurzelknoten * mit Inhalt inhalt besitzt. */ public AllgemeinerBaum( Object inhalt ) { wurzel = new Binaerknoten( inhalt ); } AllgemeinerBaum 99 100 101 102 /** Konstruiert den leeren Baum. */ public AllgemeinerBaum( ) { } AllgemeinerBaum 82 103 104 105 106 KAPITEL 2. BÄUME UND IHRE IMPLEMENTIERUNG /** Gibt den Wurzelknoten zurueck. */ public Binaerknoten wurzel( ) { return wurzel; } wurzel 107 108 109 110 111 /** Test, ob der Baum leer ist. */ boolean istLeer( ) { return wurzel == null; } istLeer 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 /** Haengt den Baum baum als neuen ersten direkten Teilbaum an. * Der bisherige i-te Teilbaum (wenn vorhanden) wird zum (i+1)-ten Teilbaum. * Ist der aktuelle Baum leer, so wird der Baum zu baum. * Gibt den modifizierten Baum zurueck. * Der Argumentbaum baum wird danach leer sein. * * Um zu verhindern, dass die Teilbaeume uebereinstimmen, fordern wir, dass * die beiden Wurzelreferenzen verschieden sind, ausser beide Baeume sind leer; * andernfalls wird eine Ausnahme ausgeloest. */ AllgemeinerBaum alsErstenTeilbaumAnhaengen( AllgemeinerBaum baum ) { if( !istLeer( ) && wurzel == baum.wurzel ) throw new IllegalArgumentException( "Identische Baeume verknuepft." ); if( istLeer( ) ) wurzel = baum.wurzel; else { baum.wurzel.rechtesKindAendern( wurzel.linkesKind( ) ); wurzel.linkesKindAendern( baum.wurzel ); } baum.wurzel = null; return this; } alsErstenTeilbaumAnhaengen 135 136 137 138 139 140 141 142 143 144 /** Gibt den Baum in Vorordnung mit Klammern und Kommata aus. */ public void druckeVorordnung( ) { if( istLeer( ) ) System.out.println( "Der Baum ist leer." ); else { wurzel.druckeVorordnung( ); System.out.println( ); } } druckeVorordnung 145 146 147 148 149 150 151 152 public static void main( String[ ] args ) { AllgemeinerBaum b1 = new AllgemeinerBaum( "*" ). alsErstenTeilbaumAnhaengen( new AllgemeinerBaum( "B" ) ). alsErstenTeilbaumAnhaengen( new AllgemeinerBaum( "A" ) ); b1.druckeVorordnung( ); main 2.4. DARSTELLUNGEN ALLGEMEINER BÄUME 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 AllgemeinerBaum b2 = new AllgemeinerBaum( "A" ). alsErstenTeilbaumAnhaengen( new AllgemeinerBaum( "D" ). alsErstenTeilbaumAnhaengen( new alsErstenTeilbaumAnhaengen( new alsErstenTeilbaumAnhaengen( new alsErstenTeilbaumAnhaengen( new AllgemeinerBaum( "C" ). alsErstenTeilbaumAnhaengen( new alsErstenTeilbaumAnhaengen( new AllgemeinerBaum( "B" ). alsErstenTeilbaumAnhaengen( new alsErstenTeilbaumAnhaengen( new b2.druckeVorordnung( ); } // main 83 AllgemeinerBaum( "J" ) ). AllgemeinerBaum( "I" ) ). AllgemeinerBaum( "H" ) ) ). AllgemeinerBaum( "G" ) ) ). AllgemeinerBaum( "F" ) ). AllgemeinerBaum( "E" ) ) ); } // class AllgemeinerBaum Der Testlauf druckt zuletzt den Baum aus Beispiel 2.4.3 in Vorordnung mit Klammern und Kommata: *(A,B) A(B(E,F),C(G),D(H,I,J)) 84 KAPITEL 2. BÄUME UND IHRE IMPLEMENTIERUNG Kapitel 3 Datentypen zur Darstellung von Mengen Die Darstellung von Mengen ist offensichtlich eine grundlegende Aufgabe. Einige Hilfsmittel hierzu, nämlich Listen und Bäume, haben wir bereits kennengelernt. Hier werden wir nun einige weitere Datentypen zur Implementierung von Mengen betrachten, die sich durch ihre Operationen unterscheiden. Es geht dabei darum, die Grundbausteine so auszuwählen und zu verfeinern, dass spezielle Operationen effizient realisiert werden können. Wir werden im wesentlichen drei verschiedene Gruppen von Operationen auf Mengen betrachten. Zunächst schauen wir uns Mengen an, für die die klassischen mathematischen Operationen Vereinigung, Durchschnitt und Differenz realisiert werden sollen. Danach wenden wir uns Mengen zu, in denen Elemente gesucht, eingefügt und entfernt werden sollen. Dieser Datentyp ist auch als dictionary“ bekannt. Wegen seiner grundlegenden Bedeutung werden wir eine ” ganze Reihe verschiedener Realisierungen untersuchen. Wir beschließen dieses Kapitel mit der Betrachtung eines Datentyps für die Verwaltung von Partitionen endlicher Mengen, wobei das Vereinigen zweier Teilmengen und das Auffinden der Teilmenge, die ein gegebenes Element enthält, im Vordergrund stehen. 3.1 Mengen mit Vereinigung, Schnitt und Differenz Hier betrachten wir Mengen mit den klassischen mathematischen Operationen Vereinigung, Durchschnitt und Differenz: M1 ∪ M2 = {x | x ∈ M1 oder x ∈ M2 }, M1 ∩ M2 = {x | x ∈ M1 und x ∈ M2 }, M1 \ M2 = {x | x ∈ M1 und x 6∈ M2 }. Als weitere Operationen nehmen wir die Konstante ∅ (die leere Menge), das Einfügen eines Elements, das Auflisten aller Elemente einer Menge und den 85 86 KAPITEL 3. DATENTYPEN ZUR DARSTELLUNG VON MENGEN Test auf Leerheit hinzu. Wir erhalten die folgende Signatur der Datenstruktur Set (set, boolean, item und list sind Sortensymbole): EMPTY : → set ISEMPTY : set → boolean INSERT : set × item → set UNION : set × set → set INTERSECTION : set × set → set DIFFERENCE : set × set → set ENUMERATE : set → list Hier sollen drei Implementierungen der Datenstruktur Set vorgestellt werden: (a) Ist der Wertebereich Item hinreichend klein (etwa |Item| = 32), dann kann man jede Teilmenge S von Item = {a1 , . . . , a32 } durch einen Bitvektor (b1 , . . . , b32 ) darstellen, der gerade in ein Maschinenwort passt: ( 1 falls ai ∈ S bi = 0 falls ai ∈ /S Programm 3.1.1. Alle Mengenoperationen lassen sich sehr effizient umsetzen: 1 2 import java.util.LinkedList; // fuer die Methode enumerate 3 4 /** Die Klasse Set1 implementiert Teilmengen einer * 32-elementigen Menge {a 1,. . .,a 32}. * Intern wird eine Teilmenge als Zahl vom Typ int repraesentiert, * die wir als Bitvektor auffassen. */ public class Set1 { 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 /** Der Bitvektor als Zahl vom Typ int. */ private int vector; /** Konstruiert die leere Menge. */ public Set1( ) { } /** Konstruiert eine (tiefe) Kopie der Menge otherSet. */ public Set1( Set1 otherSet ) { vector = otherSet.vector; } /** Liefert eine neue leere Teilmenge zurueck. */ public static Set1 empty( ) { return new Set1( ); } Set1 Set1 empty 3.1. MENGEN MIT VEREINIGUNG, SCHNITT UND DIFFERENZ 27 28 29 30 /** Test, ob die Teilmenge leer ist. */ public boolean isEmpty( ) { return vector == 0; } 87 isEmpty 31 32 33 34 35 36 37 /** Fuegt das i-te Element a i in die Teilmenge ein. */ public void insert( int i ) { if( i < 1 | | i > 32 ) throw new IllegalArgumentException( "Element nicht im Universum." ); vector |= 1 << i−1; // i-tes Bit von rechts setzen } insert 38 39 40 41 42 /** Vereinigt diese Teilmenge mit der Argumentmenge. */ public void union( Set1 otherSet ) { vector |= otherSet.vector; } union 43 44 45 46 47 /** Schneidet diese Teilmenge mit der Argumentmenge. */ public void intersection( Set1 otherSet ) { vector &= otherSet.vector; } intersection 48 49 50 51 52 /** Bildet die Differenz dieser Teilmenge mit der Argumentmenge. */ public void difference( Set1 otherSet ) { vector &= ˜otherSet.vector; } difference 53 54 55 56 57 58 59 60 61 62 63 64 65 /** Gibt eine Liste der Elemente der Teilmenge zurueck. */ public LinkedList enumerate( ) { int mask = 1; // nur das Bit ganz rechts ist gesetzt LinkedList list = new LinkedList( ); // die leere Liste int v = vector; // Kopie anlegen for( int i = 1; i <= 32; i++ ) { if( ( v & mask ) == 1 ) // ist das Bit ganz rechts in v gesetzt? list.add( new Integer( i ) ); // i in die Liste einfuegen v >>>= 1; // schiebe alle Bits in v um eine Stelle nach rechts } return list; } enumerate 66 67 68 69 70 71 72 /** Gibt eine String-Repraesentation der Teilmenge zurueck: * Die Liste [i 1,. . .,i n] repraesentiert die Teilmenge {a (i 1),. . .,a (i n)}. */ public String toString( ) { return enumerate( ).toString( ); } toString 73 74 75 76 77 78 79 80 public static void main( String[ ] args ) { Set1 s = empty( ); s.insert( 8 ); s.insert( 1 ); s.insert( 32 ); s.insert( 5 ); s.insert( 8 ); main 88 81 82 83 84 KAPITEL 3. DATENTYPEN ZUR DARSTELLUNG VON MENGEN System.out.println( "s = " + s ); // s = [1, 5, 8, 32] Set1 s0 = new Set1( s ); Set1 s1 = new Set1( s ); Set1 s2 = new Set1( s ); 85 86 87 88 89 90 Set1 t = empty( ); t.insert( 7 ); t.insert( 8 ); t.insert( 1 ); System.out.println( "t = " + t ); // t = [1, 7, 8] 91 92 93 94 95 96 97 98 s0.union( t ); System.out.println( "Vereinigung:\t" + s0 ); // Vereinigung: [1, 5, 7, 8, 32] s1.intersection( t ); System.out.println( "Durchschnitt:\t" + s1 ); // Durchschnitt: [1, 8] s2.difference( t ); System.out.println( "Differenz:\t" + s2 ); // Differenz: [5, 32] } // main 99 100 } // class Set1 Bemerkung: Mengen größerer Kardinalität können auf ähnliche Weise implementiert werden, wenn längere Bitvektoren zur Verfügung stehen; allerdings muss dann in der Regel ein Effizienzverlust in Kauf genommen werden. In Java eignet sich für diesen Zweck die Klasse java.util.BitSet. (b) Natürlich kann man eine Menge durch eine ungeordnete Liste implementieren, indem man jedem Element der Menge einen Listenknoten zuordnet, und diese Knoten in einer beliebigen Reihenfolge verkettet. Für eine Menge M1 mit m Elementen und eine Menge M2 mit n Elementen kosten die Operationen Vereinigung, Schnitt und Differenz dann Rechenzeit O(m · n). Das Einfügen in M1 kostet O(m) Schritte, ebenso wie das Aufzählen vom M1 . (c) Ist auf der Grundmenge Item eine lineare Ordnung definiert (siehe Abschnitt 2.3), so kann man die Elemente einer Teilmenge von Item so in einer linearen Liste speichern, dass sie in aufsteigender Reihenfolge sortiert sind. Für die einzelnen Operationen ergeben sich dann folgende Abschätzungen für den Rechenzeitbedarf: empty isEmpty insert union intersection difference enumerate Liste initialisieren Test auf leere Liste richtige Position suchen, dort einfügen Listen parallel durchlaufen Liste durchlaufen O(1) O(1) O(m) O(m + n) O(m) 3.1. MENGEN MIT VEREINIGUNG, SCHNITT UND DIFFERENZ 89 Programm 3.1.2. Eine Implementierung könnte wie folgt aussehen: 1 2 import java.util.LinkedList; import java.util.ListIterator; 3 /** Die Klasse Set2 implementiert Mengen ueber dem * Typ Comparable als aufsteigend sortierte Listen. 6 */ 7 public class Set2 { 4 5 8 9 10 /** Die aufsteigend sortierte Liste. */ private LinkedList list; 11 12 13 14 15 /** Konstruiert die leere Menge. */ public Set2( ) { list = new LinkedList( ); } Set2 16 17 18 19 20 /** Konstruiert eine flache Kopie der Menge otherSet. */ public Set2( Set2 otherSet ) { list = (LinkedList)otherSet.list.clone( ); } Set2 21 22 23 24 25 /** Liefert eine neue leere Menge zurueck. */ public static Set2 empty( ) { return new Set2( ); } empty 26 27 28 29 30 /** Test, ob die Menge leer ist. */ public boolean isEmpty( ) { return list.size( ) == 0; } isEmpty 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 /** Fuegt das Objekt c vom Typ Comparable in die Menge ein. * Objekte, die bezueglich der durch compareTo definierten * Ordnung aequivalent sind, werden hoechstens einmal in die * Menge aufgenommen. Gibt true zurueck, wenn das Einfuegen * die Menge veraendert, sonst false. */ public boolean insert( Comparable c ) { ListIterator it = list.listIterator( ); while( it.hasNext( ) ) { // Position suchen if( c.compareTo( it.next( ) ) <= 0 ) { if( c.compareTo( it.previous( ) ) < 0 ) { it.add( c ); // neues Element dort einfuegen return true; } return false; // c ist aequivalent zu vorhandenem Element } } it.add( c ); // sonst am Ende der Liste einfuegen return true; } insert 90 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 KAPITEL 3. DATENTYPEN ZUR DARSTELLUNG VON MENGEN /** Vereinigt diese Menge mit der Argumentmenge. */ public void union( Set2 otherSet ) { ListIterator it = list.listIterator( ); ListIterator otherIt = otherSet.list.listIterator( ); while( it.hasNext( ) && otherIt.hasNext( ) ) { Comparable c = (Comparable)otherIt.next( ); int comparison = c.compareTo( it.next( ) ); if( comparison < 0 ) { it.previous( ); it.add( c ); continue; } if( comparison > 0 ) otherIt.previous( ); // if( comparison == 0 ): nichts zu tun } while( otherIt.hasNext( ) ) // it ist am Ende it.add( otherIt.next( ) ); } union 72 73 74 75 76 /** Schneidet diese Menge mit der Argumentmenge. */ public void intersection( Set2 otherSet ) { // als Uebung } intersection 77 78 79 80 81 /** Bildet die Differenz dieser Menge mit der Argumentmenge. */ public void difference( Set2 otherSet ) { // als Uebung } difference 82 83 84 85 86 /** Gibt eine Liste der Elemente der Menge zurueck. */ public LinkedList enumerate( ) { return list; } enumerate 87 88 89 90 91 /** Gibt eine String-Repraesentation der Menge zurueck. */ public String toString( ) { return list.toString( ); } toString 92 93 94 95 96 97 98 99 100 public static void main( String[ ] args ) { Set2 s = empty( ); s.insert( new Integer( 8 ) ); s.insert( new Integer( 1 ) ); s.insert( new Integer( 32 ) ); s.insert( new Integer( 5 ) ); s.insert( new Integer( 8 ) ); System.out.println( "s = " + s ); // s = [1, 5, 8, 32] 101 102 103 104 105 Set2 t = empty( ); t.insert( new Integer( 7 ) ); t.insert( new Integer( 8 ) ); t.insert( new Integer( 1 ) ); main 3.2. SUCHBÄUME 106 107 108 109 110 111 112 91 System.out.println( "t = " + t ); // t = [1, 7, 8] s.union( t ); System.out.println( "Vereinigung:\t" + s ); // Vereinigung: [1, 5, 7, 8, 32] } // main } // class Set2 3.2 Suchbäume Die am häufigsten auftretenden Anwendungen der Datenstruktur Menge“ be” nötigen aber nicht die mengentheoretischen Operationen Durchschnitt, Vereinigung und Differenz. Vielmehr stehen dabei die Operation Einfügen (insert), Löschen (delete) und Test auf Enthaltensein (isMember) im Vordergrund. Ein Datentyp, der diese Operationen zur Verfügung stellt, heißt Dictionary (Wörterbuch). In den folgenden Abschnitten werden wir verschiedene Realisierungen dieses Datentyps kennenlernen. Hier betrachten wir eine Realisierung mittels binärer Bäume. Definition 3.2.1. Eine Knotenmarkierung für einen Baum mit Knotenmenge V über einer Menge Item ist eine Abbildung µ : V → Item. Definition 3.2.2. Sei Item eine durch ≤ linear geordnete Menge. Ein Suchbaum für die Schlüssel a1 , . . . , an ∈ Item ist ein binärer Baum mit Knotenmenge V und einer Knotenmarkierung µ : V → Item, so dass folgende Bedingungen erfüllt sind: • µ ist eine Bijektion von V auf {a1 , . . . , an } (also ist |V | = n). • Für Knoten k, k 0 ∈ V gilt µ(k 0 ) < µ(k) wenn k 0 im linken direkten Teilbaum unterhalb von k ist, µ(k) < µ(k 0 ) wenn k 0 im rechten direkten Teilbaum unterhalb von k ist. Beispiel 3.2.3. Bezüglich der lexikographischen Ordnung der Knotenmarkierungen sind die beiden folgenden Bäume Suchbäume: JAN Q "" Q "" Q FEB MAR APR T AUG @ @ MAI SEP JUN OKT T DEZ JUL NOV 92 KAPITEL 3. DATENTYPEN ZUR DARSTELLUNG VON MENGEN JUL ! aa !! aa !! a DEZ NOV @ @ @ @ MAI OKT AUG FEB @ @ \\ @ @ APR JAN JUN MAR SEP Auf Suchbäumen wollen wir nun die folgenden Operationen realisieren: Die Konstante empty für den leeren Suchbaum, die Boolesche Operation isEmpty (Test auf Leerheit), die Operationen insert und delete (Einfügen bzw. Entfernen eines Elements aus Item), und zuletzt noch die Boolesche Operation isMember (Test auf Enthaltensein). Tatsächlich werden wir von der Operation isMember etwas mehr verlangen; ist nämlich im Suchbaum ein Knoten vorhanden, der mit dem gesuchten Element aus Item markiert ist, so soll isMember einen Verweis auf diesen Knoten liefern, ansonsten die Referenz null. Algorithmus 3.2.4. Wir verwenden die Implementierung binärer Bäume aus Definition 2.2.3(b), bei der jeder Knoten aus drei Komponenten besteht: • dem gespeicherten Inhalt (hier: die Knotenmarkierung) • dem Verweis auf das linke Kind, • dem Verweis auf das rechte Kind. Die Operationen empty und isEmpty sind dann trivial. Das Suchen nach einem Knoten (Operation isMember) kann durch einfaches Absteigen im Baum realisiert werden, wobei das gesuchte Element jeweils mit dem Inhalt des aktuellen Knotens verglichen wird. Die Operation insert arbeitet wie das Suchen. Entweder ist schon ein Knoten mit dem entsprechenden Inhalt vorhanden, so dass der Baum nicht verändert wird, oder aber es wird die Stelle gefunden, an die der entsprechende Knoten als Blatt eingefügt werden muss. Etwas komplizierter ist die Realisierung der Operation delete. Ist kein Knoten mit dem gesuchten Schlüssel vorhanden, dann geschieht nichts. Ist der gesuchte Knoten ein Blatt, so kann er einfach entfernt werden. Ist er aber ein innerer Knoten, so müssen wir die Struktur des Suchbaums nach dem Löschen wiederherstellen. 1. Fall: delete(b) ... x xxx 89:; ?>=< b 89:; ?>=< a GG w GG www ... ... 3.2. SUCHBÄUME 93 /.-, ()*+ In diesem Fall wird einfach der Knoten ()*+ b entfernt. Der Verweis auf /.-, b wird '&%$ !"# ()*+ /.-, umgesetzt auf a . Falls b nur ein rechtes Kind hat, verfährt man entsprechend. 2. Fall: delete(b) ... % b " QQ "" Q a d , @ % e % e , @ ... B B . . . B B T2 B T1 B B B ()*+ Um die Struktur eines Suchbaums auch nach dem Löschen von /.-, b zu erhalten, müssen wir in den Knoten, der jetzt b enthält, einen geeigneten Wert schreiben. ()*+ Hierzu bietet sich der Knoten aus dem linken direkten Teilbaum von /.-, b an, der den größten Schlüsselwert enthält (d.h. der dort bzgl. Zwischenordnung letzte Knoten). Fall 2.(a): " QQ "" Q a d , % e @ % e , @ m . . . ... B B T1 B . . . B m ... c Fall 2.(b): " QQ "" Q a d , % e @ % e , @ m ... ... B B T1 B . . . B m ... c B B T3 B B c " QQ Q "" a d , % e @ % e , @ m . . . ... B B T1 B . . . B m ... c " QQ Q "" a d , % e @ % e , @ m ... ... B B T1 B . . . B m @ @ ... B B T3 B B 94 KAPITEL 3. DATENTYPEN ZUR DARSTELLUNG VON MENGEN Man kann leicht zeigen, dass jeder so entstandene Baum ein Suchbaum für die verbliebenen Schlüssel ist. Wir sehen also, dass jede der Operationen isMember, insert und delete im schlechtesten Fall so viele Schritte braucht, wie der aktuelle Suchbaum hoch ist. Enthält der Baum n Knoten, dann kann er die Höhe n − 1 haben, d.h. im schlechtesten Fall kostet jede der obigen Operationen O(n) Schritte. Fügt man in den anfänglich leeren Baum n Knoten ein, so kostet dies im schlechtesten Fall c· n X i ∈ Θ(n2 ) i=1 Schritte. Wenn man annimmt, dass alle Reihenfolgen für das Einfügen der n Elemente gleich wahrscheinlich sind, kann man aber zeigen, dass im Mittel O(log n) Schritte pro Operation ausreichen. Programm 3.2.5. Eine Implementierung von Suchbäumen: wie in Programm 2.2.4 5 class Binaerknoten { wie in Programm 2.2.4 63 64 65 66 67 68 69 70 71 72 73 74 75 76 /** Zu Testzwecken: Gibt den hier wurzelnden Baum in Vorordnung * mit Klammern und Kommata aus. */ public void druckeVorordnung( ) { System.out.print( this + "(" ); if( linkesKind != null ) linkesKind.druckeVorordnung( ); System.out.print( "," ); if( rechtesKind != null ) rechtesKind.druckeVorordnung( ); System.out.print( ")" ); } druckeVorordnung } // class Binaerknoten 77 78 79 80 81 82 83 84 85 86 87 88 89 90 /** Die Klasse Suchbaum implementiert binaere Suchbaeume mittels Referenzen. */ public class Suchbaum { /** Der Wurzelknoten. */ private Binaerknoten wurzel; /** Konstruiert einen Baum, der nur einen Wurzelknoten * mit Schluessel key besitzt. */ public Suchbaum( Object key ) { wurzel = new Binaerknoten( key ); } Suchbaum 3.2. SUCHBÄUME 95 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 /** Konstruiert den leeren Baum. */ public Suchbaum( ) { } /** Gibt den Wurzelknoten zurueck. */ public Binaerknoten wurzel( ) { return wurzel; } /** Test, ob der Baum leer ist. */ boolean istLeer( ) { return wurzel == null; } /** Gibt den Elternknoten des Knotens mit maximalem Schluessel * im linken direkten Teilbaum von k zurueck. Wir setzen voraus, * dass dieser linke Teilbaum nicht leer ist. */ private Binaerknoten predMaximum( Binaerknoten k ) { Binaerknoten pred = k; Binaerknoten node = k.linkesKind( ); // existiert nach Voraussetzung while( node.rechtesKind( ) != null ) { pred = node; node = node.rechtesKind( ); } return pred; } /** Diese Klasse dient als Rueckgabetyp der Methode searchKey. */ private class SearchKeyResult { Binaerknoten pred, node; int direction; SearchKeyResult( Binaerknoten p, Binaerknoten n, int d ) { pred = p; node = n; direction = d; } } /** Gibt ein Tripel (pred, node, direction) vom Typ SearchKeyResult zurueck. * Wenn pred und node beide null sind, dann ist der Baum leer. * Wenn node nicht null ist: * key ist (aequivalent zum) Schluessel in Knoten node, * pred ist dessen Elternknoten, falls vorhanden, sonst null. * Wenn node null ist, pred nicht null ist und direction < 0: * key kommt nicht als Schluessel (bzgl. Aequivalenz) vor; * die korrekte Position fuer das Blatt mit Schluessel key * ist das (noch nicht vorhandene) linke Kind von pred. * Wenn node null ist, pred nicht null ist und direction > 0: * analog mit rechtem Kind von pred. */ Suchbaum wurzel istLeer predMaximum SearchKeyResult 96 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 KAPITEL 3. DATENTYPEN ZUR DARSTELLUNG VON MENGEN private SearchKeyResult searchKey( Comparable key ) { if( istLeer( ) ) return new SearchKeyResult( null, null, 0 ); Binaerknoten pred = null; Binaerknoten node = wurzel; int direction = 0; do { int vergleich = key.compareTo( node.inhalt( ) ); if( vergleich == 0 ) return new SearchKeyResult( pred, node, direction ); pred = node; if( vergleich < 0 ) { node = node.linkesKind( ); direction = −1; } else { // vergleich > 0 node = node.rechtesKind( ); direction = +1; } } while( node != null ); return new SearchKeyResult( pred, node, direction ); } searchKey 166 167 168 169 170 171 172 /** Gibt einen Knoten mit einem zu key aequivalenten Schluessel * zurueck, falls ein solcher Knoten vorhanden ist, sonst null. */ public Binaerknoten isMember( Comparable key ) { return searchKey( key ).node; } isMember 173 174 175 176 177 178 179 180 181 182 183 /** Fuegt einen Knoten mit Schluessel key in den Suchbaum ein. */ public void insert( Comparable key ) { SearchKeyResult r = searchKey( key ); if( r.pred == null && r.node == null ) // Baum ist leer wurzel = new Binaerknoten( key ); else if( r.direction < 0 ) r.pred.linkesKindAendern( new Binaerknoten( key ) ); else if( r.direction > 0 ) r.pred.rechtesKindAendern( new Binaerknoten( key ) ); } insert 184 185 186 187 188 189 190 191 192 193 194 195 /** Entfernt den Knoten mit zu key aequivalentem Schluessel, falls vorhanden. */ public void delete( Comparable key ) { SearchKeyResult r = searchKey( key ); Binaerknoten k = r.node; if( k != null ) { // k hat Schluessel key if( k.linkesKind( ) == null | | k.rechtesKind( ) == null ) { // Fall 1 Binaerknoten einzigesKind = k.linkesKind( ) == null ? k.rechtesKind( ) : k.linkesKind( ); if( r.pred == null ) // k ist Wurzel wurzel = einzigesKind; delete 3.2. SUCHBÄUME else // r.pred ist Elternknoten von k if( r.direction < 0 ) r.pred.linkesKindAendern( einzigesKind ); else // r.direction > 0 r.pred.rechtesKindAendern( einzigesKind ); 196 197 198 199 200 } else { // Fall 2 Binaerknoten predMax = predMaximum( k ); Binaerknoten maximum = predMax == k ? k.linkesKind( ) : predMax.rechtesKind( ); k.inhaltAendern( maximum.inhalt( ) ); if( predMax == k ) k.linkesKindAendern( maximum.linkesKind( ) ); else predMax.rechtesKindAendern( maximum.linkesKind( ) ); } 201 202 203 204 205 206 207 208 209 210 211 } 212 213 97 } 214 215 216 217 218 219 220 221 222 223 224 225 /** Gibt die Schluessel des Suchbaums in Zwischenordnung aus, * also als aufsteigend geordnete Folge. */ public void druckeZwischenordnung( ) { if( istLeer( ) ) System.out.println( "Der Baum ist leer." ); else { wurzel.druckeZwischenordnung( ); System.out.println( ); } } druckeZwischenordnung 226 227 228 229 230 231 232 233 234 235 236 237 /** Gibt die Schluessel des Suchbaums in Vorordnung mit Klammern * und Kommata aus. */ public void druckeVorordnung( ) { if( istLeer( ) ) System.out.println( "Der Baum ist leer." ); else { wurzel.druckeVorordnung( ); System.out.println( ); } } druckeVorordnung 238 239 240 241 242 243 244 245 246 247 248 public static void main( String[ ] args ) { Suchbaum b = new Suchbaum( ); b.insert( "JAN" ); b.insert( "FEB" ); b.insert( "APR" b.insert( "AUG" ); b.insert( "DEZ" ); b.insert( "MAR" b.insert( "MAI" ); b.insert( "JUN" ); b.insert( "JUL" b.insert( "SEP" ); b.insert( "OKT" ); b.insert( "NOV" b.druckeZwischenordnung( ); b.druckeVorordnung( ); b.delete( "FEB" ); b.druckeZwischenordnung( ); b.druckeVorordnung( ); b.delete( "JAN" ); main ); ); ); ); 98 b.druckeZwischenordnung( ); b.druckeVorordnung( ); b.delete( "MAR" ); b.druckeZwischenordnung( ); b.druckeVorordnung( ); 249 250 251 252 253 254 KAPITEL 3. DATENTYPEN ZUR DARSTELLUNG VON MENGEN } } // class Suchbaum 3.3 Gewichtsbalancierte Bäume Wie wir gesehen haben, sind Suchbäume im schlechtesten Fall genauso ineffizient wie lineare Listen. Andererseits kann man n Schlüssel in einem binären Suchbaum der Höhe dlog(n + 1)e − 1 unterbringen (Lemma 2.1.6.). Für einen solchen Suchbaum kosten die Operationen isMember, insert und delete nur O(log n) Schritte. Damit stellt sich die Frage, ob man nicht mit solchen Suchbäumen auskommen kann, die für n Schlüssel nur Höhe ≤ c · log n für eine feste Konstante c haben. Solche Suchbäume wollen wir ausgeglichen oder balanciert nennen. Es gibt verschiedene Möglichkeiten, Suchbäume zu balancieren. In diesem Abschnitt wollen wir uns die sogenannten BB[α]-Bäume anschauen, die gewichtsbalanciert sind. Für einen binären Baum T bezeichne |T | die Anzahl seiner Knoten, T` seinen linken und Tr seinen rechten direkten Teilbaum. T : Q Q Q Q A A A A A A A A A A T` Tr Definition 3.3.1. Die Wurzelbalance eines binären Baums T ist der Wert |T` | + 1 |Tr | + 1 ρ(T ) = =1− . |T | + 1 |T | + 1 Ein Baum ist von beschränkter Balance α, falls für jeden seiner Teilbäume T 0 α ≤ ρ(T 0 ) ≤ 1 − α gilt. BB[α] ist die Menge aller binären Bäume von beschränkter Balance α; wir nennen diese Bäume auch BB[α]-Bäume. In diesem Abschnitt bezeichne α eine festgewählte reelle Zahl mit 1 1 < α ≤ 1 − √ ≈ 0, 29. 4 2 3.3. GEWICHTSBALANCIERTE BÄUME 99 Bemerkung 3.3.2. Wenn wir in jedem Knoten k zusätzlich zum Inhalt und zu den Verweisen auf die beiden Kinder noch die Anzahl der Knoten im Teilbaum mit Wurzel k speichern, so können wir ρ in Zeit O(1) ausrechnen. Beispiel 3.3.3. T : 5 HH H 2 7 @ @ @ @ 1 4 8 6 3 Sei Ti der Teilbaum mit Wurzel i. Dann gilt: i ρ(Ti ) 1 1/2 2 2/5 3 1/2 4 2/3 5 5/9 6 1/2 7 1/2 8 1/2 Für alle i gilt also 1/3 ≤ ρ(Ti ) ≤ 1 − 1/3, daher ist T ein BB[α]-Baum. Satz 3.3.4. Sei T ein BB[α]-Baum mit n Knoten und Höhe h. Dann gilt h≤ log(n + 1) − 1 , − log(1 − α) d.h. BB[α]-Bäume haben nur logarithmische Höhe. Beweis: Sei v ein Knoten von T der Tiefe h und sei v0 , v1 , . . . , vh = v der Weg von der Wurzel v0 zum Knoten v. Für 0 ≤ i ≤ h sei Ti der Teilbaum mit Wurzel vi , und es sei wi = |Ti |. Behauptung: wi+1 + 1 ≤ (1 − α)(wi + 1) für 1 ≤ i < h. • 1. Fall: vi+1 ist das linke Kind von vi , d.h. Ti+1 ist der linke direkte Teilbaum von Ti . Da T ∈ BB[α] ist, folgt |Ti+1 | + 1 wi+1 + 1 = = ρ(Ti ) ≤ 1 − α. |Ti | + 1 wi + 1 • 2. Fall: vi+1 ist das rechte Kind von vi , d.h. Ti+1 ist der rechte direkte Teilbaum von Ti . Dann folgt α ≤ ρ(Ti ) = 1 − |Ti+1 | + 1 wi+1 + 1 =1− . |Ti | + 1 wi + 1 100 KAPITEL 3. DATENTYPEN ZUR DARSTELLUNG VON MENGEN In beiden Fällen gilt also die Behauptung. Damit erhalten wir 2 ≤ wh + 1 ≤ (1 − α)(wh−1 + 1) ≤ · · · ≤ (1 − α)h (w0 + 1) = (1 − α)h (n + 1), also 1 ≤ h log(1 − α) + log(n + 1), d.h. h ≤ (log(n + 1) − 1)/ − log(1 − α). √ Für α = 1 − 1/ 2 ergibt Satz 3.3.4 folgende Abschätzung für die Höhe h: h≤ log(n + 1) − 1 √ = 2(log(n + 1) − 1). log 2 Da immer h ≥ dlog(n + 1)e − 1 gilt (Lemma 2.1.5), ist ein BB[α]-Baum also immer höchstens doppelt so hoch wie überhaupt minimal möglich. Durch das Einfügen oder das Löschen von Knoten kann ein BB[α]-Baum aus der Balance geraten. Wir werden daher im folgenden Operationen betrachten, die einen aus der Balance geratenen Baum wieder in einen BB[α]-Baum transformieren. Dies sind die folgenden vier Operationen: Rotation nach links: T : x ρx QQ Q B y ρy B aB % e B % e B B B B bB cB B B y ρ0y QQ Q B 0 ρx x B cB % e % e B B B B B aB bB B B B B = Teilbaum mit a Knoten aB B ρx = Wurzelbalance im Knoten x in T ρ0x = Wurzelbalance im Knoten x in T 0 T0 : Rotation nach rechts: y ρy Q QQ B ρx x B cB SS B B B B B bB aB B B T : T0 : x b " b "" b B y ρ0y B aB % e % e B B B B B bB cB B B ρ0x 3.3. GEWICHTSBALANCIERTE BÄUME 101 Doppelrotation nach links: T : x ρx QQ Q B y ρy B aB e B e B ρz z B d B JJ B B B B B bB cB B B z ρ0z b b b 0 ρ0y ρx x y % e SS % e B B B B B B B B cB aB bB dB B B B B T0 : Doppelrotation nach rechts: y QQ Q B ρx x B dB , l l , B B z ρz B aB J B J B B B B bB cB B B T : z ρ0z b b b 0 ρ0y ρx x y % e SS % e B B B B B B B B aB bB cB dB B B B B T0 : ρy Bemerkung 3.3.5. (1) Ist T ein Suchbaum für gewisse Schlüssel, so ist auch T 0 wieder ein Suchbaum für diese Schlüssel. (2) Eine Doppelrotation nach links ist keine doppelte Rotation nach links! Eine solche Operation im Knoten x entspricht stattdessen einer Rotation nach rechts im rechten Kind von x gefolgt von einer Rotation nach links in x. Lemma 3.3.6. (a) Entsteht T 0 aus T durch Rotation nach links, so gilt ρ0x = ρx ρx + (1 − ρx )ρy und ρ0y = ρx + (1 − ρx )ρy . (b) Entsteht T 0 aus T durch Doppelrotation nach links, so gilt ρ0x = ρx , ρx + (1 − ρx )ρy ρz ρ0y = ρy (1 − ρz ) 1 − ρy ρz und ρ0z = ρx + (1 − ρx )ρy ρz . 102 KAPITEL 3. DATENTYPEN ZUR DARSTELLUNG VON MENGEN Beweis: (a) T : x ρx QQ Q B y ρy B aB % e B % e B B B B bB cB B B y ρ0y QQ Q B ρ0x x B cB % e % e B B B B B bB aB B B T0 : Mit ρx = (a + 1)/(a + b + c + 3) und ρy = (b + 1)/(b + c + 2) erhalten wir ρx + (1 − ρx )ρy = b+c+2 b+1 a+b+2 a+1 + · = = ρ0y , a+b+c+3 a+b+c+3 b+c+2 a+b+c+3 also ρx a+1 a+b+c+3 a+1 = · = = ρ0x . ρ0y a+b+c+3 a+b+2 a+b+2 (b) T : x ρx Q QQ B y ρy B aB e B e B ρz z B d B JJ B B B B B bB cB B B z ρ0z b b b 0 ρ0y ρx x y % e SS % e B B B B B B B B bB cB dB aB B B B B T0 : Mit ρx = (a + 1)/(a + b + c + d + 4), ρy = (b + c + 2)/(b + c + d + 3) und ρz = (b + 1)/(b + c + 2) erhalten wir ρy ρz = b+1 b+c+d+3 und (1 − ρx )ρy ρz = b+1 a+b+c+d+4 also a+b+2 = ρ0z , a+b+c+d+4 ρy (1 − ρz ) c+1 b+c+d+3 c+1 = · = = ρ0y , 1 − ρy ρz b+c+d+3 c+d+2 c+d+2 ρx a+1 a+b+c+d+4 a+1 = · = = ρ0x . 0 ρz a+b+c+d+4 a+b+2 a+b+2 ρx + (1 − ρx )ρy ρz = 3.3. GEWICHTSBALANCIERTE BÄUME 103 Lemma 3.3.7. (a) Entsteht T 0 aus T durch Rotation nach rechts, so gilt ρ0x = ρx ρy und ρ0y = (1 − ρx )ρy . 1 − ρx ρy (b) Entsteht T 0 aus T durch Doppelrotation nach rechts, so gilt ρ0x = ρx , ρx + (1 − ρx )ρz ρ0y = (1 − ρx )ρy (1 − ρz ) , (1 − ρy ) + (1 − ρx )ρy (1 − ρz ) ρ0z = ρy ρz + ρx ρy (1 − ρz ). Beweis: als Übung. Lemma 3.3.8. Sei T : x ρx Q QQ B y ρy B aB % e B % e B B B B bB cB B B ein Baum, für den jeder echte Teilbaum in BB[α] ist, er selbst aber nicht wegen α · (1 − α) ≤ ρx < α. (∗) Dann entsteht daraus ein BB[α]-Baum (a) für ρy > (1 − 2α)/(1 − α) durch Doppelrotation nach links, (b) für ρy ≤ (1 − 2α)/(1 − α) durch Rotation nach links. Bemerkung: Bedingung (∗) besagt, dass die Wurzelbalance zu klein geworden ist, entweder dadurch, dass im linken direkten Teilbaum ein Knoten entfernt wurde, oder dadurch, dass in den rechten direkten Teilbaum ein Knoten eingefügt wurde. Lemma 3.3.8 besagt, dass durch eine Rotation oder Doppelrotation nach links wieder ein BB[α]-Baum entsteht. Beweis: (a) Es ist ρy = (b + 1)/(b + c + 2) > (1 − 2α)/(1 − α). Die Funktion √ f (t) = (1−2t)/(1−t) ist im Intervall [0, 1/2] monoton fallend. Mit α ≤ 1−1/ 2 folgt √ √ √ 1 − 2α 1−2+ 2 1 √ =2− 2> , = f (α) ≥ f (1 − 1/ 2) = 1−α 2 1 − 1 + 1/ 2 B also b + 1 > (b + c + 2)/2, was b > c ≥ 0 liefert. Daher ist der Teilbaum bBB n z nicht leer, hat also die Gestalt B B . B B dB eB 104 KAPITEL 3. DATENTYPEN ZUR DARSTELLUNG VON MENGEN T0 : x ρx z ρ0z b QQ b Q b B 0 ρ0y y ρ x ρ y y x B Doppelrotation aB nach links % e SS % e B e e B B B B B ρz z B B B B B c B aB dB cB eB JJ B B B B B B B B B dB eB B B T : , , und cB Nach Voraussetzung sind die Teilbäume aBB dBB eBB B BB[α]-Bäume. B B B B Damit bleibt α ≤ ρ0x , ρ0y , ρ0z ≤ 1 − α zu zeigen. Einerseits gilt ρx (nach Lemma 3.3.6(b)) ρx + (1 − ρx )ρy ρz 1 − ρx = 1/(1 + · ρy · ρz ) ρx 1 − α 1 − 2α < 1/(1 + · · α) = 1/(2(1 − α)) ≤ 1 − α. α 1−α ρ0x = Die erste Ungleichung gilt, weil (1 − t)/t monoton fallend auf [0, 1] ist und mit ρx < α, wegen ρy > (1√− 2α)/(1 − α) und wegen ρz ≥ α; die zweite Ungleichung gilt wegen α ≤ 1 − 1/ 2. Andererseits gilt 1 − ρx · ρy · ρz ) ρx 1 − α(1 − α) · (1 − α)2 ) ≥ 1/(1 + α · (1 − α) α α = = ≥ α. 2 α + (1 − α) − α(1 − α) 1 − α(1 − α)2 ρ0x = 1/(1 + Hier gilt die Ungleichung, weil (1 − t)/t monoton fallend auf [0, 1] ist und mit ρx ≥ α(1 − α), wegen ρy ≤ 1 − α und wegen ρz ≤ 1 − α. Analog können die entsprechenden Aussagen für ρ0y und ρ0z gezeigt werden. (b) T : x ρx QQ Q B y ρy B Rotation nach links aB % e B % e B B B B bB cB B B y ρ0y QQ Q B 0 x ρx B cB % e % e B B B B B aB bB B B T0 : Analog muss α ≤ ρ0x , ρ0y ≤ 1 − α nachgewiesen werden. 3.3. GEWICHTSBALANCIERTE BÄUME 105 Lemma 3.3.8 behandelt den Fall, dass die Balance in einem Knoten zu klein geworden ist. Im folgenden Lemma betrachten wir den anderen Fall, nämlich dass die Balance in einem Knoten zu groß geworden ist. Lemma 3.3.9. Sei x ρx HH H B y ρy B cB % e % e B B B B B aB bB B B T : ein Baum, für den jeder echte Teilbaum in BB[α] ist, er selbst aber nicht wegen ρx > 1 − α. Dann entsteht daraus ein BB[α]-Baum (a) für ρy > α/(1 − α) durch Rotation nach rechts, (b) für ρy ≤ α/(1 − α) durch Doppelrotation nach rechts. Beweis: Analog zum Beweis von Lemma 3.3.8. Beispiel 3.3.10. Sei α = 0, 29 und damit 1 − α = 0, 71. Aus dem Suchbaum T0 : 89:; ?>=< 2= == = 89:; ?>=< 89:; ?>=< 1 5 ==== 89:; ?>=< 89:; ?>=< 4 6 entstehe durch Einfügen eines Knotens mit Schlüssel 7 der Suchbaum T1 : 89:; ?>=< 2 ==== 89:; ?>=< 89:; ?>=< 1 5= == = 89:; ?>=< 89:; ?>=< 4 6= == = 89:; ?>=< 7 Der Suchbaum war zuerst in BB[α], durch das Einfügen ist er aber aus der Balance geraten wegen 2/7 < α: T0 : T1 : i ρi ρi 1 1/2 1/2 2 1/3 2/7 4 1/2 1/2 5 1/2 2/5 6 1/2 1/3 7 — 1/2 106 KAPITEL 3. DATENTYPEN ZUR DARSTELLUNG VON MENGEN Es ist α(1 − α) < 2/7 und ρ5 = 2/5 ≤ (1 − 2α)/(1 − α) (= 0, 42/0, 71 ≈ 0, 59). Wir wenden nach Lemma 3.3.8(b) auf T1 eine Rotation nach links an und erhalten einen BB[α]-Baum: 89:; ?>=< 5 ==== 89:; ?>=< 89:; ?>=< 6= 2= == = = = = 89:; ?>=< 89:; ?>=< 89:; ?>=< 1 4 7 i ρi 1 1/2 2 1/2 4 1/2 5 4/7 6 1/3 7 1/2 Algorithmus 3.3.11. Einfügen in BB[α]-Bäume: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 void insert( Comparable key ) { (1.) Bestimme die Position des einzufügenden Knotens im Baum. (2.) Füge einen neuen Knoten mit Schlüssel key ein. (3.) Gehe den Weg von diesem neuen Knoten zurück zur Wurzel; für jeden Knoten k auf dem Weg: k.groesseAendern( +1 ); if( ρ(k) < α ) if( ρ(k.rechtesKind( )) > (1 − 2α)/(1 − α) ) Doppelrotation nach links in k; else Rotation nach links in k; if( ρ(k) > 1 − α ) if( ρ(k.linkesKind( )) > α/(1 − α) ) Rotation nach rechts in k; else Doppelrotation nach rechts in k; } Zeitbedarf von insert: O(Höhe von T ) = O(log |T |) (nach Satz 3.3.4). Lemma 3.3.12. Sei T ein BB[α]-Suchbaum und sei key ein Schlüssel, der in T nicht vorkommt. Dann erzeugt insert( key ) aus T einen BB[α]-Suchbaum, der alle Schlüssel aus T und den Schlüssel key enthält. Beweis: Sei v0 , v1 , . . . , vk der Weg in T von der Wurzel zu dem Knoten vk , als dessen Kind der Knoten mit Schlüssel key in Schritt (2.) eingefügt wird, und sei S der entstehende Baum. Dann ist S ein Suchbaum, der alle Schlüssel aus T und den Schlüssel key enthält. Sei Ti der Teilbaum von T mit Wurzel vi , sei Si der Teilbaum von S mit Wurzel (`) (r) vi , und seien Si und Si der linke bzw. rechte direkte Teilbaum von Si . Alle von S0 , . . . , Sk verschiedenen Teilbäume von S sind Teilbäume von T und damit BB[α]-Bäume. Gilt nun stets α ≤ ρ(Si ) ≤ 1 − α, so sind alle Teilbäume von S BB[α]-Bäume, also auch S. Andernfalls sei j maximal mit ρ(Sj ) ∈ / [α, 1 − α]. insert 3.3. GEWICHTSBALANCIERTE BÄUME 107 Dann ist der neue Knoten offensichtlich in diesem Teilbaum Sj . Angenommen, (r) (`) der neue Knoten ist in Sj . Dann ist Sj ein Teilbaum von T und es gilt (`) ρ(Sj ) = |Sj | + 1 |Sj | + 1 (`) < |Sj | + 1 |Sj | − 1 + 1 = ρ(Tj ), damit |Sj | + 1 |Sj | 1 1 = (`) = (`) + (`) ρ(Sj ) |Sj | + 1 |Sj | + 1 |Sj | + 1 1 1 1 1 1 + = . = ≤ + ρ(Tj ) |S (`) | + 1 α 1−α α(1 − α) j Also ist α(1 − α) ≤ ρ(Sj ) < ρ(Tj ) ≤ 1 − α und wegen ρ(Sj ) ∈ / [α, 1 − α] gilt α(1 − α) ≤ ρ(Sj ) < α. Nach Lemma 3.3.8 liefert eine Rotation oder Doppelrotation nach links einen zu Sj äquivalenten BB[α]-Suchbaum. Analog zeigt man, dass eine Rotation oder Doppelrotation nach rechts einen zu Tj äquivalenten BB[α]-Suchbaum liefert, (`) wenn der neue Knoten im Teilbaum Sj ist. Indem man in Schritt (3.) alle Knoten vk , vk−1 , . . . , v0 besucht und die entsprechenden Teilbäume gegebenenfalls rebalanciert, erhält man also einen BB[α]Baum. Das Löschen eines Knotens aus einem BB[α]-Baum (die Operation delete) geschieht in ähnlicher Weise wie das Einfügen: (1.) Knoten wird gesucht. (2.) Knoten wird entfernt. (3.) Der Weg zurück zur Wurzel wird durchlaufen, wobei die Teilbäume, deren Wurzeln auf diesem Weg liegen, gegebenenfalls rebalanciert werden. √ Satz 3.3.13. Werden Suchbäume als BB[α]-Bäume mit 1/4 ≤ α ≤ 1 − 1/ 2 implementiert, so sind die Operationen insert und delete in Zeit O(log n) realisierbar. Bemerkung 3.3.14. Wird ein kleines solches α gewählt, dann ist das Intervall [α, 1 − α] relativ groß, und somit sind weniger Rebalancierungen nötig. Dafür ist nach Satz 3.3.4 die Höhe der BB[α]-Bäume aber auch größer. Also ist eine solche Wahl günstig, wenn viele Änderungen und relativ wenige Suchoperationen auszuführen sind. Andernfalls ist es günstiger, α möglichst groß zu wählen, wodurch die Höhe der BB[α]-Bäume stärker beschränkt wird. 108 KAPITEL 3. DATENTYPEN ZUR DARSTELLUNG VON MENGEN Programm 3.3.15. Eine Implementierung der BB[α]-Bäume: Hier verwenden wir die Klassen Stack und Queue, um den Weg, der beim Suchen nach einem Knoten zurückgelegt wird, für das Rebalancieren zu speichern. Beim Einfügen besteht dieser Weg aus nur einem Stück, das in einem Stack gespeichert wird. Beim Löschen kommt noch ein zweites Stück hinzu, nämlich das vom zu löschenden Knoten zum Knoten mit maximalem Inhalt im linken direkten Teilbaum; hierfür verwenden wir eine Queue. In jedem Baumknoten wird zusätzlich zum Inhalt noch die aktuelle Größe des in diesem Knoten wurzelnden Teilbaums gespeichert, siehe Bemerkung 3.3.2. 1 2 3 4 5 6 /** Die Klasse BBAlphaKnoten implementiert Knoten eines binaeren Baums. * Jeder Knoten speichert ein beliebiges Objekt vom Typ Object, * je eine Referenz auf das linke und das rechte Kind * und die Groesse des hier wurzelnden Teilbaums. */ class BBAlphaKnoten { 7 8 9 10 11 /** Die Konstanten aus Definition 3.3.1, Lemma 3.3.8 und Lemma 3.3.9. */ public static final double ALPHA = 0.275; public static final double ALPHA LEFT = (1−2*ALPHA)/(1−ALPHA); public static final double ALPHA RIGHT = ALPHA/(1−ALPHA); 12 13 14 /** Der Knoteninhalt. */ private Object inhalt; 15 16 17 18 /** Das linke und das rechte Kind. */ private BBAlphaKnoten linkesKind; private BBAlphaKnoten rechtesKind; 19 20 21 /** Die Groesse des hier wurzelnden Baums. */ private int groesse; 22 23 24 25 26 27 /** Konstruiert einen Blattknoten mit Inhalt inhalt. */ public BBAlphaKnoten( Object inhalt ) { this.inhalt = inhalt; groesse = 1; } BBAlphaKnoten die Methoden inhalt( ), linkesKind( ) und rechtesKind( ) analog zu Programm 2.2.4 44 45 46 47 /** Gibt die Groesse des hier wurzelnden Baums zurueck. */ public double groesse( ) { return groesse; } groesse die Methoden inhaltAendern( ), linkesKindAendern( ) und rechtesKindAendern( ) analog zu Programm 2.2.4 64 65 66 67 68 /** Die Groesse wird um i inkrementiert. Gibt den neuen Wert zurueck. */ public int groesseAendern( int i ) { return groesse += i; } groesseAendern 3.3. GEWICHTSBALANCIERTE BÄUME 69 70 71 72 73 /** Gibt die Hoehenbalance des hier wurzelnden Teilbaums zurueck. */ public double balance( ) { return ( ( linkesKind == null ? 0 : linkesKind.groesse ) + 1 ) / (double)( groesse + 1 ); } 109 balance 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 /** Rotation des hier wurzelnden Baums nach links. * Wir setzen voraus, dass die Rotation moeglich ist. * Gibt die neue Wurzel zurueck. */ public BBAlphaKnoten rotationLinks( ) { BBAlphaKnoten x = this; BBAlphaKnoten y = x.rechtesKind; // existiert nach Annahme int a = x.linkesKind == null ? 0 : x.linkesKind.groesse; int b = y.linkesKind == null ? 0 : y.linkesKind.groesse; int c = y.rechtesKind == null ? 0 : y.rechtesKind.groesse; x.groesseAendern( −c−1 ); y.groesseAendern( a+1 ); x.rechtesKindAendern( y.linkesKind ); y.linkesKindAendern( x ); return y; } rotationLinks 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 /** Rotation des hier wurzelnden Baums nach rechts. * Wir setzen voraus, dass die Rotation moeglich ist. * Gibt die neue Wurzel zurueck. */ public BBAlphaKnoten rotationRechts( ) { BBAlphaKnoten y = this; BBAlphaKnoten x = y.linkesKind; // existiert nach Annahme int a = x.linkesKind == null ? 0 : x.linkesKind.groesse; int b = x.rechtesKind == null ? 0 : x.rechtesKind.groesse; int c = y.rechtesKind == null ? 0 : y.rechtesKind.groesse; x.groesseAendern( c+1 ); y.groesseAendern( −a−1 ); y.linkesKindAendern( x.rechtesKind ); x.rechtesKindAendern( y ); return x; } rotationRechts 108 109 110 111 112 113 114 115 116 117 118 119 120 121 /** Doppelrotation des hier wurzelnden Baums nach links. * Wir setzen voraus, dass die Rotation moeglich ist. * Gibt die neue Wurzel zurueck. */ public BBAlphaKnoten doppelRotationLinks( ) { BBAlphaKnoten x = this; BBAlphaKnoten y = x.rechtesKind; // existiert nach Annahme BBAlphaKnoten z = y.linkesKind; // existiert nach Annahme int a = x.linkesKind == null ? 0 : x.linkesKind.groesse; int b = z.linkesKind == null ? 0 : z.linkesKind.groesse; int c = z.rechtesKind == null ? 0 : z.rechtesKind.groesse; int d = y.rechtesKind == null ? 0 : y.rechtesKind.groesse; x.groesseAendern( −c−d−2 ); doppelRotationLinks 110 y.groesseAendern( −b−1 ); z.groesseAendern( a+d+2 ); x.rechtesKindAendern( z.linkesKind ); y.linkesKindAendern( z.rechtesKind ); z.linkesKindAendern( x ); z.rechtesKindAendern( y ); return z; 122 123 124 125 126 127 128 129 KAPITEL 3. DATENTYPEN ZUR DARSTELLUNG VON MENGEN } 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 /** Doppelrotation des hier wurzelnden Baums nach rechts. * Wir setzen voraus, dass die Rotation moeglich ist. * Gibt die neue Wurzel zurueck. */ public BBAlphaKnoten doppelRotationRechts( ) { BBAlphaKnoten y = this; BBAlphaKnoten x = y.linkesKind; // existiert nach Annahme BBAlphaKnoten z = x.rechtesKind; // existiert nach Annahme int a = x.linkesKind == null ? 0 : x.linkesKind.groesse; int b = z.linkesKind == null ? 0 : z.linkesKind.groesse; int c = z.rechtesKind == null ? 0 : z.rechtesKind.groesse; int d = y.rechtesKind == null ? 0 : y.rechtesKind.groesse; x.groesseAendern( −c−1 ); y.groesseAendern( −a−b−2 ); z.groesseAendern( a+d+2 ); x.rechtesKindAendern( z.linkesKind ); y.linkesKindAendern( z.rechtesKind ); z.linkesKindAendern( x ); z.rechtesKindAendern( y ); return z; } doppelRotationRechts die Methoden toString( ) und druckeZwischenordnung( ) wie in Programm 2.2.4, die Methode druckeVorordnung( ) wie in Programm 3.2.5 180 } // class BBAlphaKnoten 181 182 183 184 /** Die Klasse BBAlphaBaum implementiert BB[alpha]-Baeume. */ public class BBAlphaBaum { 185 186 187 /** Der Wurzelknoten. */ private BBAlphaKnoten wurzel; 188 189 190 191 192 193 194 /** Konstruiert einen Baum, der nur einen Wurzelknoten * mit Schluessel key besitzt. */ public BBAlphaBaum( Object key ) { wurzel = new BBAlphaKnoten( key ); } BBAlphaBaum 195 196 197 198 /** Konstruiert den leeren Baum. */ public BBAlphaBaum( ) { } BBAlphaBaum 3.3. GEWICHTSBALANCIERTE BÄUME 199 200 201 202 /** Gibt den Wurzelknoten zurueck. */ public BBAlphaKnoten wurzel( ) { return wurzel; } 111 wurzel 203 204 205 206 207 /** Test, ob der Baum leer ist. */ boolean istLeer( ) { return wurzel == null; } istLeer 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 /** Gibt den Pfad vom linken direkten Kind von k zum Knoten mit * maximalem Schluessel im linken direkten Teilbaum von k zurueck. * Wir setzen voraus, dass dieser linke Teilbaum nicht leer ist. * Dabei sei der Weg als Queue analog zu der fuer die Methode * searchKey beschriebenen Weise repraesentiert. */ private Queue pathToMaximum( BBAlphaKnoten k ) { Queue path = new Queue( ); BBAlphaKnoten node = k.linkesKind( ); // existiert nach Voraussetzung path.enqueue( node ); path.enqueue( new Integer( +1 ) ); while( node.rechtesKind( ) != null ) { node = node.rechtesKind( ); path.enqueue( node ); path.enqueue( new Integer( +1 ) ); } return path; } pathToMaximum 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 /** Gibt einen Stack path mit 2n Elementen (n >= 0) zurueck, * der einen Pfad im Baum auf folgende Weise repraesentiert. * (Das Stack-Element an Position i, bezeichnet mit path[i], sei dasjenige, * das durch i-maliges pop( ) gefolgt von einem top( ) erreichbar ist.) * Der leere Stack repraesentiert den leeren Pfad. Ist der Stack nicht leer, so gilt: * (1) path[2n-1] ist die Wurzel des Baums. * (2) Fuer 0 <= i < n: path[2i] hat Typ Integer, path[2i+1] Typ BBAlphaKnoten. * (3) Fuer 0 < i < n gilt path[2i] != 0, und wenn path[2i] < 0 gilt, dann ist * path[2i-1] linkes Kind von path[2i+1], sonst rechtes Kind von path[2i+1]. * (4) path[0] == 0: key ist (aequivalent zum) Schluessel in Knoten path[1]. * (5) path[0] < 0: key kommt nicht als Schluessel (bzgl. Aequivalenz) vor; * die korrekte Position fuer das Blatt mit Schluessel key * ist das (noch nicht vorhandene) linke Kind von path[1]. * (6) path[0] > 0: analog mit rechtem Kind von path[1]. */ private Stack searchKey( Comparable key ) { searchKey Stack path = new Stack( ); if( istLeer( ) ) return path; BBAlphaKnoten node = wurzel; do { int vergleich = key.compareTo( node.inhalt( ) ); if( vergleich == 0 ) { path.push( node ); 112 path.push( new Integer( 0 ) ); return path; 252 253 } if( vergleich < 0 ) { path.push( node ); path.push( new Integer( −1 ) ); node = node.linkesKind( ); } else { // vergleich > 0 path.push( node ); path.push( new Integer( +1 ) ); node = node.rechtesKind( ); } 254 255 256 257 258 259 260 261 262 263 264 } while( node != null ); return path; 265 266 267 268 KAPITEL 3. DATENTYPEN ZUR DARSTELLUNG VON MENGEN } 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 /** Entlang des im Stack gespeicherten Weges wird der Baum rebalanciert. * Dabei sei der Weg im Stack auf die bei der Methode searchKey beschriebene * Weise repraesentiert. * Die Groesse der Knoten aendert sich um i. (Beim Rebalancieren nach * insert ist daher i == +1, beim Rebalancieren nach delete ist i == -1.) */ private void rebalance( Stack path, int i ) { rebalance if( !path.isEmpty( ) ) path.pop( ); // diese Zahl ist fuer das Rebalancieren nicht relevant while( !path.isEmpty( ) ) { // path enthaelt noch 2i+1 > 0 Eintraege BBAlphaKnoten k = (BBAlphaKnoten)path.topAndPop( ); BBAlphaKnoten wurzelDesTeilbaums = k; k.groesseAendern( i ); if( k.balance( ) < BBAlphaKnoten.ALPHA ) // Balance in k ist zu klein if( k.rechtesKind( ).balance( ) > BBAlphaKnoten.ALPHA LEFT ) wurzelDesTeilbaums = k.doppelRotationLinks( ); else wurzelDesTeilbaums = k.rotationLinks( ); else if( k.balance( ) > 1−BBAlphaKnoten.ALPHA ) // Balance in k ist zu gross if( k.linkesKind( ).balance( ) > BBAlphaKnoten.ALPHA RIGHT ) wurzelDesTeilbaums = k.rotationRechts( ); else wurzelDesTeilbaums = k.doppelRotationRechts( ); if( !path.isEmpty( ) ) { int direction = ( (Integer)path.topAndPop( ) ).intValue( ); BBAlphaKnoten parent = (BBAlphaKnoten)path.top( ); if( direction < 0 ) parent.linkesKindAendern( wurzelDesTeilbaums ); else // direction > 0 parent.rechtesKindAendern( wurzelDesTeilbaums ); } else wurzel = wurzelDesTeilbaums; } } 3.3. GEWICHTSBALANCIERTE BÄUME 306 307 308 309 310 311 312 313 314 315 316 317 113 /** Gibt einen Knoten mit einem zu key aequivalenten Schluessel * zurueck, falls ein solcher Knoten vorhanden ist, sonst null. */ public BBAlphaKnoten isMember( Comparable key ) { Stack path = searchKey( key ); if( path.isEmpty( ) | | ( (Integer)path.top( ) ).intValue( ) != 0 ) return null; else { // im Stack steht oben 0, also ist darunter der gesuchte Knoten path.pop( ); return (BBAlphaKnoten)path.top( ); } } isMember 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 /** Fuegt einen Knoten mit Schluessel key in den BB[alpha]-Baum ein. */ public void insert( Comparable key ) { Stack path = searchKey( key ); if( path.isEmpty( ) ) { // Baum ist leer wurzel = new BBAlphaKnoten( key ); return; } int vergleich = ( (Integer)path.topAndPop( ) ).intValue( ); if( vergleich == 0 ) // nicht zu tun return; if( vergleich < 0 ) ( (BBAlphaKnoten)path.top( ) ). linkesKindAendern( new BBAlphaKnoten( key ) ); else // vergleich > 0 ( (BBAlphaKnoten)path.top( ) ). rechtesKindAendern( new BBAlphaKnoten( key ) ); ( (BBAlphaKnoten)path.topAndPop( ) ).groesseAendern( +1 ); rebalance( path, +1 ); } insert 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 /** Entfernt den Knoten mit zu key aequivalentem Schluessel, falls vorhanden. */ public void delete( Comparable key ) { Stack path = searchKey( key ); if( !path.isEmpty( ) && ( (Integer)path.topAndPop( ) ).intValue( ) == 0 ) { BBAlphaKnoten k = (BBAlphaKnoten)path.topAndPop( ); // k hat Schluessel key if( k.linkesKind( ) == null | | k.rechtesKind( ) == null ) { // Fall 1 BBAlphaKnoten einzigesKind = k.linkesKind( ) == null ? k.rechtesKind( ) : k.linkesKind( ); if( path.isEmpty( ) ) // k ist Wurzel wurzel = einzigesKind; else { if( ( (Integer)path.topAndPop( ) ).intValue( ) < 0 ) ( (BBAlphaKnoten)path.top( ) ).linkesKindAendern( einzigesKind ); else ( (BBAlphaKnoten)path.top( ) ).rechtesKindAendern( einzigesKind ); path.push( new Integer( 0 ) ); // dieser Wert ist nicht relevant rebalance( path, −1 ); } } delete 114 KAPITEL 3. DATENTYPEN ZUR DARSTELLUNG VON MENGEN else { // Fall 2 Queue pathToMaximum = pathToMaximum( k ); // den Pfad pathToMaximum an den Pfad path haengen: path.push( k ); path.push( new Integer( −1 ) ); while( !pathToMaximum.isEmpty( ) ) { // zuerst push fuer Typ BBAlphaKnoten, dann fuer Typ Integer path.push( pathToMaximum.frontAndDequeue( ) ); path.push( pathToMaximum.frontAndDequeue( ) ); } path.pop( ); // nicht relevante Zahl entfernen BBAlphaKnoten maximum = (BBAlphaKnoten)path.topAndPop( ); // maximum ist der Knoten mit maximalem Schluessel // im linken direkten Teilbaum von k path.pop( ); // nicht relevante Zahl entfernen BBAlphaKnoten predMax = (BBAlphaKnoten)path.top( ); // predMax ist der Elternknoten von maximum k.inhaltAendern( maximum.inhalt( ) ); if( predMax == k ) k.linkesKindAendern( maximum.linkesKind( ) ); else predMax.rechtesKindAendern( maximum.linkesKind( ) ); path.push( new Integer( 0 ) ); // dieser Wert ist nicht relevant rebalance( path, −1 ); } 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 } 385 386 } die Methoden druckeZwischenordnung( ) und druckeVorordnung( ) wie in Programm 3.2.5 412 413 414 415 416 417 418 419 420 421 422 423 public static void main( String[ ] args ) { BBAlphaBaum b = new BBAlphaBaum( ); String[ ] monat = { "JAN", "FEB", "APR", "AUG", "DEZ", "MAR", "MAI", "JUN", "JUL", "SEP", "OKT", "NOV" }; for( int i = 0; i < 12; i++ ) { b.insert( monat[i] ); b.druckeVorordnung( ); } b.delete( "FEB" ); b.druckeVorordnung( ); b.delete( "JAN" ); b.druckeVorordnung( ); b.delete( "MAR" ); b.druckeVorordnung( ); } 424 425 } // class BBAlphaBaum 3.4 Höhenbalancierte Bäume Es gibt viele Arten höhenbalancierter Bäume, etwa (a, b)-Bäume, B-Bäume, rot-schwarze Bäume und AVL-Bäume. Wir wollen uns hier zunächst mit den letzteren befassen. Im Anschluss schauen wir uns noch die (2,4)-Bäume an. main 3.4. HÖHENBALANCIERTE BÄUME 3.4.1 115 AVL-Bäume Die Höhe eines nicht-leeren Baums T , ab jetzt mit h(T ) bezeichnet, wurde in Abschnitt 2.1 definiert. Zur Vereinfachung der folgenden Definition legen wir zusätzlich die Höhe des leeren Baums mit −1 fest. Definition 3.4.1. Die Höhenbalance eines binären Baums mit direkten linken bzw. rechten Teilbäumen T` und Tr ist der Wert h(T` ) − h(Tr ). Ein Baum heißt höhenbalanciert, falls jeder seiner Teilbäume eine Höhenbalance aus {−1, 0, 1} hat. Höhenbalancierte Suchbäume werden AVL-Bäume genannt nach ihren Erfindern Adelśon-Velśkiı̌ und Landis (1962). Beispiel 3.4.2. Die Höhenbalance der Teilbäume steht hier an ihrer Wurzel: ggg Juli 0 XXXXXXXXX ggggg März 0PP Febr 1OO O P nn nnn Aug 0 O Jan 0 Mai 1 Okt 0O OO O pp ooo ooo Apr 0 Sept 0 Dez 0 Juni 0 Nov 0 ein AVL-Baum Apr 0 pp Juli 1 WWWW WWWW hhhh hhhh März −1P Jan 2 PP NN oo nnnn Mai 0 Febr 1 QQ Juli 0 Okt 0 PP Q nn lll Aug 1 Sept 0 Dez 0 Nov 0 kein AVL-Baum Lemma 3.4.3. Für AVL-Bäume mit n Knoten und Höhe h gilt F (h + 3) − 1 ≤ n ≤ 2h+1 − 1. Dabei ist F (k) die k-te Fibonacci1 -Zahl, definiert durch F (0) = 0, F (1) = 1 und F (k + 2) = F (k + 1) + F (k). Beweis: Für binäre Bäume gilt n ≤ 2h+1 −1 nach Lemma 2.1.5(b). Wir müssen also nur noch die untere Schranke für n nachweisen. Für m > 0 sei dazu Fm ein AVL-Baum mit Höhe m, der die minimal notwendige Anzahl von Knoten hat2 . Dann ist h(F` ) = m−1 und h(Fr ) = m−2 oder h(F` ) = m−2 und h(Fr ) = m−1. 1 2 Leonardo Pisano Fibonacci (1170–1250) Solche Bäume werden auch Fibonacci-Bäume genannt. 116 KAPITEL 3. DATENTYPEN ZUR DARSTELLUNG VON MENGEN Also gilt |Fm | = 1 + |Fm−1 | + |Fm−2 |, und wir haben |F0 | = 1 und |F1 | = 2. Also |F0 | + 1 = 2, |F1 | + 1 = 3 und |Fm | + 1 = (|Fm−1 | + 1) + (|Fm−2 | + 1), damit |Fm | + 1 = F (m + 3). Wir erhalten n ≥ |Fh | = F (h + 3) − 1. Für Fibonacci-Zahlen weiß man 1 F (k) = √ φk − φbk 5 √ √ mit φ = (1 + 5)/2√≈ 1, 618 (der goldene Schnitt) und φb = (1 − 5)/2 ≈ √ −0, 618. Wegen |φbk / 5| < 1/2 gilt F (k) > φk / 5 − 1, also mit obigem Lemma √ n + 1 ≥ F (h + 3) > φh+3 / 5 − 1. √ Das liefert log(n + 2) > (h + 3) log φ − log 5, d.h. √ log(n + 2) + log 5 1 h< −3< log(n + 2) ≈ 1, 44 · log(n + 2). log φ log φ Satz 3.4.4. Für AVL-Bäume mit n Knoten und Höhe h gilt log(n + 1) − 1 ≤ h < 1, 441 · log(n + 2). AVL-Bäume sind also höchstens um circa 44% höher als minimal möglich. Insbesondere sind Suchen, Einfügen und Löschen in Zeit O(log n) realisierbar. Leider kann sowohl beim Einfügen als auch beim Löschen die Höhenbalanciertheit zerstört werden. Wie bei den BB[α]-Bäumen müssen wir in einem solchen Fall durch Rebalancierungsoperationen versuchen, den entstandenen Baum in einen äquivalenten umzuformen, der wieder höhenbalanciert ist. Einfügen in AVL-Bäume mit anschließender Rebalancierung: Operation LL: 1 x h(x) = h + 2 " Q Q " B h3 = h h(y) = h + 1 y 0 B3B % S S B % B B B2B B B 1 B B h1 = h2 = h Einfügen in B1 2 x h0 (x) = h + 3 0 y h00 (y) = h + 2 bb " Q " Q Rotation LL b 00 B B x h (x) = h + 1 0 h0 (y) = h + 2 y 1 h = h 3 B B 0B S % e % S 3B e h01 = h + 1 B1 B B B h3 = h B h = h h01 = h + 1 B h = h 2 B 2 B B B B 0B B 2 B 3B B1 B 2 B 3.4. HÖHENBALANCIERTE BÄUME 117 Operation RR: −1 h(x) = h + 2 x bb " "" b h1 = h B 0 y h(y) = h + 1 B % e % e B1 B B h2 = h3 = h B B B B B 2 B 3B Einfügen in B3 −2 h0 (x) = h + 3 x 0 h00 (y) = h + 2 y bb " " Q Rotation RR "" "" Q b 00 0 h1 = h B B h03 = h + 1 h (x) x 0 −1 y h (y) B 0B B B = h + 1 =h+2 S S S S 3B 1B B B B B h2 = h B B 0B h03 = h + 1 B B B B 2 B 2 B 3B 1B B h1 = h Operation LR(i): 1 x h(x) = 1 " "" Einfügen 0 y von z h(y) = 0 2 0 x h (x) = 2 0 z h00 (z) = 1 " " Q "" "" Q Rotation LR(i) -1 y h0 (y) = 1 Q Q 0 Operation LR(ii): h2 = h z 0 y 00 h (y) = 0 x 0 00 h (x) = 0 h0 (z) = 0 1 x h(x) = h + 2 " Q "" Q h(y) B h4 = h y 0 = h + 1 B B , l l , 4B B 0 z h(z) = h B1B B J J h1 = h B B B B B B 2B 3B h2 = h3 = h − 1 Einfügen in B2 2 0 x h (x) = h + 3 00 0 z h (z) = h + 2 " Q Rotation LR(ii) bbb "" Q h00 (x) h0 (y) B h4 = h h00 (y) y 0 -1 x = h + 1 y -1 = h + 2 B B = h + 1 , l S % e l % , 4B S e B B B B B 1 z h0 (z) = h + 1 B1B B1B B B B4B 0B 3 B J J B B2 B B B h3 h4 = h h1 = h B h1 = h 0 B h2 = h =h−1 B B B 0 B 2B 3B h02 = h h3 = h − 1 118 KAPITEL 3. DATENTYPEN ZUR DARSTELLUNG VON MENGEN Operation LR(iii): 1 x h(x) = h + 2 " Q "" Q h(y) B h4 = h y 0 = h + 1 B B , l l , 4B B 0 z h(z) = h B1B B J J h1 = h B B B B B B 2B 3B h2 = h3 = h − 1 Einfügen in B3 2 x 00 h0 (x) = h + 3 0 h (z) = h + 2 z " Q Rotation LR(iii) bbb "" Q h00 (x) h0 (y) B h4 = h h00 (y) y 1 0 x =h+1 y -1 = h + 2 B B = h + 1 , l S % e l % , 4B S e B B B B B 0 -1 z h (z) = h + 1 B B B 0 B B1B B B B 2 B 3B 4B B J J 1B B h4 = h h1 = h B h1 = h h2 B 0 = h − 1 h3 = h B B B 0 B 2B 3B h2 0 = h − 1 h3 = h Operation RL(i): -1 0 x h(x) = 1 bb b Einfügen 0 y von z -2 x h0 (z) = 0 0 z h (z) = 1 bb Rotation RL(i)"" Q b " Q h0 (y) = 1 h(y) = 0 00 h (x) = 2 y 1 0 x h00 (x) = 0 z y 0 h00 (y) = 0 0 RL(ii) und RL(iii) analog zu LR(ii) und LR(iii). Bemerkung 3.4.5. (1) Durch solche Rebalancierungen entstehen aus Suchbäumen stets wieder Suchbäume. (2) Wird in einen AVL-Baum T ein Knoten eingefügt, so ist der resultierende Baum T 0 entweder wieder ein AVL-Baum, oder der kleinste Teilbaum T̂ von T 0 , der den neu eingefügten Knoten enthält und der selbst kein AVL-Baum mehr ist, hat eine der Formen, wie sie in obiger Definition jeweils nach einer Einfüge-Operation auftreten. Insbesondere ist die Wurzel von T̂ der unterste Knoten auf dem Weg von der Wurzel von T 0 zu dem neu eingefügten Knoten, der Höhenbalance ±2 hat. 3.4. HÖHENBALANCIERTE BÄUME 119 (3) Wird ein Teilbaum T̂ rebalanciert, so entsteht ein Teilbaum T̂ 0 , der dieselbe Höhe hat wie der Teilbaum T̂ vor dem Einfügen des neuen Knotens: T0 : T 00 : S S S ; ; T S S Rebalancieren TS Einfügen T S S TS T̂ T S T S T̂ 0 T S 0 T S T̂ TT S T S T T : Also ist T 00 ein AVL-Baum, d.h. zum Rebalancieren reicht stets eine einzige Rotation des entsprechenden Typs aus. Beispiel 3.4.6. Neuer Schlüssel Baum nach Einfügen (1.) Mai Baum nach Rebalancieren Mai 0 — Mai −1R R (2.) März — März 0 Mai −2 SS S März −1R R (3.) November RR: 0 Mai 0 MärzQ Q mm Nov 0 Nov 0 (4.) August 0 Aug 1 Mai oo (5.) April 0 Apr pp 1 Aug 2 Mai oo 1 MärzQ Q mm 2 MärzQ Q mm — Nov 0 Nov 0 LL: 1 MärzPP P nn 0 Aug P Nov 0 PP pp 0 Apr Mai 0 (6.) Januar 2 MärzPP P ll −1 AugQ Nov 0 QQ nn 0 Apr Mai 1 lll 0 Jan LR: 0 Mai RR R oo 0 Aug N −1 MärzQ QQ NN pp 0 Apr Jan 0 Nov 0 120 KAPITEL 3. DATENTYPEN ZUR DARSTELLUNG VON MENGEN (7.) Dezember 1 Mai RR R mm −1 AugP März −1Q QQ PP nn 0 Apr Jan 1 Nov 0 mmm 0 Dez (8.) Juli 0 Apr (9.) Februar 0 Apr 1 Mai RR R März −1Q QQ Jan 0 RR Nov 0 R mmm 0 Dez Juli 0 mm −1 AugP PP nn — — 2 Mai RR R März −1Q QQ Jan 1 Nov 0 RRR mmm −1 DezR Juli 0 R Febr 0 mm −2 AugQ QQ nn RL: 1 Mai RR nn März −1Q 0 Dez OO QQ O oo 1 Aug Jan 0 QQ Nov 0 Q nn ooo 0 April 0 Febr Juli 0 (10.) Juni 2 Mai SSS ll −1 DezQQ März −1R RR Q nn 1 Aug Jan −1 R Nov 0 RR nn mmm 0 April Juli −1 RR 0 Febr R Juni 0 ffff 0 Jan XXXXXXXXXX fffff 1 Dez PP Mai 0 RR R P oo mmm 1 Aug März −1Q −1 JuliP Febr 0 PP QQ nn 0 April Juni 0 Nov 0 LR: −1 Jan XXXXX ffff XXXXX fffff Mai −1 R 1 Dez OO RR O oo lll 1 Aug März −2R −1 JuliQ Febr 0 RR QQ nn 0 April Nov −1QQ Juni 0 Q (11.) Oktober Okt 0 3.4. HÖHENBALANCIERTE BÄUME 121 ffff 0 Jan XXXXXXXXXX ffffff 1 Dez PP ffff Mai 0 PPP P oo fffff 1 Aug Nov 0NN Febr 0 −1 JuliPP N P nn ooo 0 April Juni 0 0 März Okt 0 RR: (12.) September −1 Jan XXXX XXXXX ffff fffff Mai −1RR 1 Dez OO ffff R O oo fffff 1 Aug Nov −1Q Febr 0 −1 JuliPP QQ P nn mmm 0 April Okt −1P Juni 0 0 März P Sept 0 Gerät ein AVL-Baum T durch das Löschen eines Knotens k aus der Balance, so müssen durch eventuell mehrere Rotationen die Teilbäume, deren Wurzeln auf dem Weg von der Wurzel von T zum Knoten k liegen, rebalanciert werden. Dabei geht man ausgehend vom Elternknoten von k zurück zur Wurzel von T . Beispiel 3.4.7. Im Baum 30 ``` ``` ` 1 1 20 40 b ! ! bb !! 1 10 45 1 25 1 1 35 " Q " " 22 -1 26 0 32 1 0 38 42 0 " 1 15 1 5 S S 31 0 0 12 S 0 23 7 0 -1 1 J 2 0 T : 7654 ergibt das Löschen von 0123 42 den Baum 1 30 ``` ``` ` 1 2 20 40 a ! ! aa "" !! 1 10 25 1 45 0 1 35 " Q " " 22 -1 26 0 32 1 0 38 1 15 " 1 5 S S # 0 12 31 0 # # 0 23 7 0 -1 1 J 2 0 1 122 KAPITEL 3. DATENTYPEN ZUR DARSTELLUNG VON MENGEN 0123 Rotation nach rechts im Knoten 7654 40 ergibt: 30 ``` ``` ` 1 0 20 35 H ! ! " HH " !! 1 10 25 1 40 0 1 32 " Q Q " " 22 -1 26 0 1 15 0 31 0 38 0 45 " 1 5 S S , 0 12 , 0 23 , 7 0 1 -1 J 2 0 2 7654 Erst eine Rotation nach rechts im Knoten 0123 30 ergibt einen AVL-Baum: 0 20 aaa !! 0 !! 30 ` ``` 1 10 ``` , " 0 " 25 35 H 1 " 1 15 " Q HH 1 5 0 22 26 40 0 -1 1 32 0 12 Q L S S 0 38 0 45 7 0 0 31 -1 1 0 23 J 2 0 Bemerkung 3.4.8. Bei der Implementierung speichern wir in jedem Knoten die Höhenbalance des dort wurzelnden Teilbaums (statt dessen Höhe). Die Rebalancierungsoperationen geben direkt an, wie sich diese Werte ändern. Satz 3.4.9. Werden Suchbäume durch AVL-Bäume implementiert, so ist Einfügen, Suchen und Löschen in Zeit O(log n) realisierbar. 3.4.2 (2,4)-Bäume Als zweite Variante der höhenbalancierten Bäume betrachten wir noch die (2,4)Bäume. Die folgenden Begriffe können ganz allgemein für Zahlenpaare (a, b) mit a ≥ 2 und b ≥ 2a − 1 formuliert werden, was dann die (a, b)-Bäume ergibt. Aus Gründen der Übersichtlichkeit beschränken wir uns hier aber auf den Spezialfall (2,4). Bei diesen Bäumen wird alle zu speichernde Information in den Blättern untergebracht, d.h. für n Schlüssel brauchen wir einen Baum mit n Blättern und einigen inneren Knoten, die der Verwaltung der Information dienen. 3.4. HÖHENBALANCIERTE BÄUME 123 Definition 3.4.10. Ein nicht-leerer geordneter Baum ist ein (2,4)-Baum, wenn alle Blätter dieselbe Tiefe haben und wenn für alle inneren Knoten k gilt 2 ≤ d(k) ≤ 4. Wie wird nun eine Schlüsselmenge S = {x1 , . . . , xn } mit x1 < · · · < xn in einem (2,4)-Baum gespeichert? Dazu brauchen wir einen (2,4)-Baum mit n Blättern, in denen von links nach rechts die Werte x1 , . . . , xn gespeichert werden. Ist nun k ein innerer Knoten dieses (2,4)-Baums mit d(k) = r ∈ {2, 3, 4}, so wird in k eine Folge von r − 1 Elementen aus dem Universum, aus dem S stammt, gespeichert. Ist diese Folge gerade y1 < · · · < yr−1 , so gilt für alle Blätter im i-ten direkten Teilbaum unterhalb von k Inhalt des Blattes ≤ y1 für i = 1, yi−1 < Inhalt des Blattes ≤ yi für 1 < i < r, yr−1 < Inhalt des Blattes für i = r. Beispiel 3.4.11. Ein (2,4)-Baum für S = {1, 3, 6, 8, 9, 10} ⊆ N: 4 X XXX XX X 2 7, 8, 9 b " bb @ T "" @ 1 3 6 8 9 10 Das Suchen in einem (2,4)-Baum ist recht einfach. Anhand der in einem inneren Knoten k gespeicherten Werte y1 < · · · < yd(k)−1 kann man feststellen, in welchem direkten Teilbaum unterhalb von k die Suche fortgesetzt werden muss. Wegen d(k) ≤ 4 kostet das Suchen nur O(Höhe von T ) Schritte. Lemma 3.4.12. Für einen (2,4)-Baum mit n Blättern und Höhe h gilt 2h ≤ n ≤ 4h , also (log n)/2 ≤ h ≤ log n. Beweis: Da jeder innere Knoten k die Ungleichung 2 ≤ d(k) ≤ 4 erfüllt, hat ein (2, 4)-Baum der Höhe h höchstens 4h und mindestens 2h Blätter, da ja alle Blätter auf derselben Stufe h liegen. In einem (2,4)-Baum mit n Blättern kostet die Operation Suchen also nur O(log n) Schritte. Wie kann man das Einfügen in (2,4)-Bäumen realisieren? Durch die Operation Suchen wird ein innerer Knoten gefunden, der als Kind ein Blatt mit dem einzufügenden Schlüssel hat, wenn er schon im Baum vorhanden ist. Ist dies der Fall, so wird kein Knoten neu erzeugt. Andernfalls wird ein neues Blatt 124 KAPITEL 3. DATENTYPEN ZUR DARSTELLUNG VON MENGEN erzeugt und als neues Kind an der richtigen Stelle an diesen Knoten angefügt. Im Knoten selbst wird auch ein neuer Wert eingetragen, der die Blattinhalte korrekt voneinander trennt. Falls der Knoten durch dieses Anfügen den Grad d(k) = 5 erhält, wird er in zwei Knoten k 0 und k 00 aufgespalten. Dadurch erhält der Elternknoten von k nun ein zusätzliches Kind, d.h. das Aufspalten kann sich entlang des Suchwegs bis zur Wurzel fortsetzen. Beispiel 3.4.13. In den Baum aus Beispiel 3.4.11 soll ein Knoten mit Inhalt 7 eingefügt werden. 4 XXX " XXX " XX " 6, 7, 8, 9 k 2 ! a ! a ! % e cc aaa !! % e 1 3 6 7 8 9 10 Knoten k hat nun den Grad d(k) = 5, muss also geteilt werden. T1 : 4, 7 X XX XXX 0 k 8, 9 2 6 T TT T 1 3 6 7 8 k 00 @ @ 9 10 Beim Löschen kann es passieren, dass ein innerer Knoten k nur noch ein Blatt als Kind hat. Dann muss k entweder ein weiteres Kind von einem seiner direkten Nachbarn erhalten, oder k muss mit einem seiner direkten Nachbarn verschmolzen werden. Beispiel 3.4.14. Wenn wir im Baum T1 aus Beispiel 3.4.13 den Knoten 7 löschen, dann kann k 0 von k 00 das Kind 8 übernehmen: T2 : 4, 8 P P PPPP 2 6 9 T T % e T e T % 1 3 6 8 9 10 Wenn wir im Baum T1 den Knoten 3 löschen, dann wird dessen Elternknoten mit seinem Nachbarn verschmolzen: 3.5. HASHING (STREUSPEICHERUNG) 125 T3 : ! 7 aa !! aa ! ! a 2, 6 1 6 @ @ 7 8, 9 % % 8 Z Z 9 10 Natürlich kann sich die Wiederherstellung des (2,4)-Baums nach einer LöschOperation auch wieder bis zur Wurzel fortpflanzen. Satz 3.4.15. Werden Suchbäume durch (2,4)-Bäume implementiert, so ist Einfügen, Suchen und Löschen in Zeit O(log n) realisierbar. 3.5 Hashing (Streuspeicherung) In diesem Abschnitt lernen wir eine weitere Realisierung für den Datentyp Dic” tionary“ kennen: die Hash-Tabelle. Die Grundidee der Hashing-Verfahren besteht darin, aus dem zu speichernden Schlüsselwert die Adresse im Speicher zu berechnen, an der dieses Element untergebracht wird. Sei etwa U ( Univer” sum“) der Bereich, aus dem die Schlüsselwerte stammen, und sei S ⊆ U eine zu speichernde Menge mit |S| = n. Zur Speicherung der Elemente von S stellen wir eine Menge von Behältern B0 , B1 , . . . , Bm−1 zur Verfügung. Natürlich gilt im allgemeinen |U | m (d.h. |U | ist sehr viel größer als m). Definition 3.5.1. Eine Hash-Funktion ist eine (totale) Funktion h : U → {0, . . . , m − 1}. Für a ∈ U gibt h(a) den Behälter an, in dem der Schlüssel a untergebracht werden soll. Der Wert n/|U | ist die Schlüsseldichte, und der Wert n/m ist der Belegungsfaktor der Hash-Tabelle B0 , . . . , Bm−1 . Eine Hash-Funktion sollte die folgenden Eigenschaften haben: • Sie sollte surjektiv sein, d.h. alle Behälter sollten erfasst werden. • Sie sollte die zu speichernden Schlüssel möglichst gleichmäßig über alle Behälter verteilen, d.h. jeder Behälter sollte mit gleicher Wahrscheinlichkeit getroffen werden. • Sie sollte möglichst einfach zu berechnen sein. Beispiel 3.5.2. Wir wollen die Monatsnamen auf 13 Behälter B0 , . . . , B12 verteilen. Als einfaches Beispiel einer Hash-Funktion verwenden wir dabei die folgende: Ist w = a1 a2 a3 . . . ak ein Wort der Länge ≥ 3 über dem Alphabet {a, . . . , z}, so wählen wir h(w) = d(a1 ) + d(a2 ) + d(a3 ) mod 13 126 KAPITEL 3. DATENTYPEN ZUR DARSTELLUNG VON MENGEN mit d(a) = 1, d(b) = 2, . . . , d(z) = 26. Wir erhalten folgende Zuordnung: februar 7→ 0 oktober 7→ 7 september 7→ 1 7→ 8 7→ 2 april, dezember 7→ 9 august 7→ 3 mai 7→ 10 juli 7→ 4 7→ 11 7→ 5 januar, november 7→ 12 maerz, juni 7→ 6 Obwohl noch Behälter da sind, die nichts enthalten, werden mehrfach Schlüssel auf denselben Behälter abgebildet, d.h. sogenannte Kollisionen treten auf. Die verschiedenen Hashverfahren unterscheiden sich nun dadurch, wie sie mit auftretenden Kollisionen umgehen. Beim offenen Hashing kann ein Behälter beliebig viele Elemente aufnehmen, während beim geschlossenen Hashing jeder Behälter nur eine kleine konstante Anzahl b von Elementen aufnehmen kann. Falls mehr als b Elemente auftreten, die alle auf denselben Behälter abgebildet werden, so entsteht ein Überlauf. Bemerkung 3.5.3. Wie groß ist die Wahrscheinlichkeit, dass Kollisionen auftreten? Sei h : U → {0, . . . , m − 1} eine ideale Hash-Funktion, die die Schlüsselwerte gleichmäßig auf alle m Behälter verteilt, d.h. für 0 ≤ i < m ist 1 P r h(s) = i = . m Wir wollen die Wahrscheinlichkeit dafür bestimmen, dass bei einer zufälligen Folge von n Schlüsseln (n < m) eine Kollision auftritt. Es gilt PKollision = 1 − Pkeine Kollision , Pkeine Kollision = P (1) · P (2) · · · P (n), wobei P (i) die Wahrscheinlichkeit dafür ist, dass der i-te Schlüssel auf einen freien Platz kommt, wenn die Schlüssel 1, . . . , i − 1 auch alle auf freie Plätze gekommen sind. Nun ist P (1) = 1, P (2) = (m − 1)/m, und allgemein P (i) = (m − i + 1)/m, also PKollision = 1 − m(m − 1) · · · (m − n + 1) . mn Für m = 365 ergeben sich die folgenden Kollisionswahrscheinlichkeiten: n = 22 : PKollision ≈ 0, 475 n = 23 : PKollision ≈ 0, 507 n = 50 : PKollision ≈ 0, 970 Dies ist das sogenannte Geburtstagsparadoxon“: Sind mehr als 23 Personen ” zusammen, so haben mit mehr als 50% Wahrscheinlichkeit mindestens zwei von ihnen am selben Tag Geburtstag. Für das Hashing bedeutet die obige Analyse, dass Kollisionen praktisch unvermeidbar sind. 3.5. HASHING (STREUSPEICHERUNG) 127 Definition 3.5.4. Hashing mit Verkettung: Für jeden Behälter wird eine verkettete Liste angelegt, in die alle Schlüssel eingefügt werden, die auf diesen Behälter abgebildet werden. Für die Tabelle aus Beispiel 3.5.2 erhalten wir damit die folgende Darstellung: 0 b 1 ab februar % % september %% 2 3 4 b august % % b juli % % b maerz b oktober b april b mai b januar 5 6 7 b juni % % % % 8 9 10 b dezember %% % % 11 12 b november %% Programm 3.5.5. Unter Verwendung der Hash-Funktion aus Beispiel 3.5.2 und dem Java-Typ LinkedList erhalten wir folgende Implementierung für die Operationen Suchen, Einfügen und Löschen: 1 import java.util.LinkedList; 2 3 /** Die Klasse OpenHashTable implementiert offenes Hashing. * Die Hash-Tabelle enthaelt Strings. */ 6 class OpenHashTable { 4 5 7 8 9 10 11 12 13 14 15 16 17 18 19 /** Die Anzahl der Behaelter. */ private static final int ANZAHL BEHAELTER = 13; /** Ein Array von ANZAHL BEHAELTER vielen Listen. */ private LinkedList[ ] hashTable = new LinkedList[ANZAHL BEHAELTER]; /** Konstruiert eine leere Hash-Tabelle. */ public OpenHashTable( ) { for( int i = 0; i < ANZAHL BEHAELTER; i++ ) hashTable[i] = new LinkedList( ); } OpenHashTable 128 20 21 22 23 KAPITEL 3. DATENTYPEN ZUR DARSTELLUNG VON MENGEN /** Implementiert die Abbildung d(’a’) = 1, . . ., d(’z’) = 26. */ private static int d( char c ) { return c − ’a’ + 1; } d 24 25 26 27 28 29 30 31 32 33 34 35 /** Ordnet einem Wort w = a 1 a 2 a 3 v mit a i aus {’a’,. . .,’z’} den Wert * (d(a 1) + d(a 2) + d(a 3)) mod ANZAHL BEHAELTER zu. Dafuer * muss w mindestens die Laenge 3 haben, sonst wird eine Ausnahme ausgeloest. */ private static int hash( String w ) { if( w.length( ) < 3 ) throw new IllegalArgumentException( "String zu kurz." ); return ( d( w.charAt( 0 ) ) + d( w.charAt( 1 ) ) + d( w.charAt( 2 ) ) ) % ANZAHL BEHAELTER; } hash 36 37 38 39 40 41 /** Test, ob der String key in der Hash-Tabelle enthalten ist. */ public boolean isMember( String key ) { // Test, ob key in der Liste mit Index hash( key ) enthalten ist: return hashTable[ hash( key ) ].contains( key ); } isMember 42 43 44 45 46 47 48 /** Fuegt den String key in die Hash-Tabelle ein. */ public void insert( String key ) { int h = hash( key ); if( !hashTable[ h ].contains( key ) ) hashTable[ h ].add( key ); } insert 49 50 51 52 53 /** Entfernt den String key aus der Hash-Tabelle. */ public void delete( String key ) { hashTable[ hash( key ) ].remove( key ); } delete 54 55 56 57 58 59 60 61 /** Gibt eine String-Darstellung der Hash-Tabelle zurueck. */ public String toString( ) { StringBuffer ausgabe = new StringBuffer( ); for( int i = 0; i < ANZAHL BEHAELTER; i++ ) ausgabe.append( "Behaelter " + i + ":\t" + hashTable[ i ] + "\n" ); return ausgabe.toString( ); } toString 62 63 64 65 66 67 68 69 70 public static void main( String[ ] args ) { OpenHashTable ht = new OpenHashTable( ); String[ ] monat = { "januar", "februar", "maerz", "april", "mai", "juni", "juli", "august", "september", "oktober", "november", "dezember" }; for( int i = 0; i < monat.length; i++ ) ht.insert( monat[ i ] ); System.out.println( ht ); } 71 72 } // class OpenHashTable main 3.5. HASHING (STREUSPEICHERUNG) 129 Ein Testlauf: Behaelter Behaelter Behaelter Behaelter Behaelter Behaelter Behaelter Behaelter Behaelter Behaelter Behaelter Behaelter Behaelter 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: [februar] [september] [] [august] [juli] [] [maerz, juni] [oktober] [] [april, dezember] [mai] [] [januar, november] Sei S = {x1 , . . . , xn } ⊆ U eine zu speichernde Menge, und sei HT eine offene Hash-Tabelle mit Hash-Funktion h. Wir wollen annehmen, dass h in konstanter Zeit ausgewertet werden kann. Wieviel Rechenzeit wird dann für die Operationen Suchen, Einfügen und Löschen gebraucht? Für 0 ≤ i < m sei HT [i] die Liste der Schlüssel xj , für die h(xj ) = i gilt. Mit |HT [i]| bezeichnen wir die Länge der i-ten Liste. Dann kostet jede Operation im schlechtesten Fall O(|HT [h(x)]|) viele Schritte. Ist nämlich immer h(xj ) = h(x1 ) für 1 ≤ j ≤ n, dann enthält HT [h(x1 )] alle n Elemente. Damit erhalten wir die folgende Aussage. Satz 3.5.6. Ist eine Menge S mit n Elementen in einer offenen Hash-Tabelle mit Verkettung gespeichert, so braucht die Ausführung einer Operation Suchen, Einfügen oder Löschen im schlechtesten Fall Rechenzeit O(n). Das Verhalten im Mittel ist aber wesentlich besser. Wir betrachten eine beliebige Folge von n Operationen Suchen, Einfügen und Löschen, wobei wir mit der leeren Menge beginnen. Anfänglich sind also alle Listen HT [0], . . . , HT [m − 1] leer. Wir wollen dabei die folgenden Annahmen machen: • Die Hash-Funktion h : U → I = {0, . . . , m − 1} streut die Schlüssel aus U gleichmäßig über das Intervall I: Für i, j ∈ I gilt |h−1 (i)| = |h−1 (j)| = |U | . m • Alle Schlüssel treten mit derselben Wahrscheinlichkeit als Argument einer Operation auf: Für s ∈ U und 1 ≤ i ≤ n gilt P r(das Argument der i-ten Operation ist der Schlüssel s) = 1 . |U | 130 KAPITEL 3. DATENTYPEN ZUR DARSTELLUNG VON MENGEN Aus diesen beiden Annahmen folgt P r(h liefert für das Argument der i-ten Operation den Wert j) = 1 , m d.h. h(x1 ), . . . , h(xn ) kann als eine Folge von n unabhängigen Ausführungen eines Zufallsexperiments mit Gleichverteilung über {0, . . . , m − 1} aufgefasst werden. Satz 3.5.7. Unter obigen Annahmen hat eine Folge von n Operationen Suchen, Einfügen und Löschen im Mittel den Zeitbedarf n O (1 + )·n . 2m Ist der Belegungsfaktor n/m klein, so wird also in einer Folge von n solchen Operationen im Mittel jede Operation in konstanter Zeit durchgeführt! Beweis: Zuerst bestimmen wir die mittlere Länge der Liste HT [i] (0 ≤ i < m) nach Ausführung von k ≥ 1 Operationen. Es ist P r(während der ersten k Operationen wird j-mal auf HT [i] zugegriffen) j 1 k−j k 1 1− . = m m j (∗) Nachdem j-mal auf die Liste HT [i] zugegriffen worden ist, hat sie höchstens die Länge j. Sei nun `i (k) die mittlere Länge der Liste HT [i] nach den ersten k Operationen. Dann gilt: `i (k) ≤ k X k 1 j j j=0 m 1 1− m k−j ·j j−1 k k X k−1 1 1 k−j = · 1− m j−1 m m j=1 k = · m k−1 X j=0 | k k k−1 denn = j j j−1 j k−1 1 1 (k−1)−j k 1− = . j m m m {z } =1 mit (∗) Da dies für alle i gilt, bedeutet dies, dass die (k + 1)-ste Operation im Mittel in Zeit O(1 + k/m) ausgeführt werden kann. Damit erhalten wir für die mittlere Rechenzeit einer Folge von n Operationen die folgende Abschätzung: T (P ) (n) ≤ c · n−1 X k=0 k 1+ m n(n − 1) =c· n+ 2m n ∈ O (1 + )·n . 2m 3.5. HASHING (STREUSPEICHERUNG) 131 Wir wenden uns nun dem geschlossenen Hashing zu, bei dem jeder Behälter nur eine konstante Anzahl b ≥ 1 von Schlüsseln aufnehmen kann. Wir betrachten dabei im folgenden den Spezialfall b = 1 und behandeln Hash-Tabellen, die Schlüssel/Wert-Paare mit Schlüsseln vom Typ String und Werten vom Typ Object speichern. Diese Paarmengen sollen partielle Funktionen sein, d.h. jedem Schlüssel ist jeweils höchstens ein Wert zugeordnet. Die Paare werden durch den Typ Content realisiert, der zwei Felder vorsieht, key vom Typ String und value vom Typ Object. Die Hash-Tabelle besteht dann aus einem Array über dem Typ Content. Den Typ Content versehen wir noch mit einem weiteren Feld vom Typ boolean, um zwischen den beiden folgenden Fällen unterscheiden zu können: • Ein Behälter ist leer (empty), also noch nie gefüllt gewesen. • Ein Behälter ist zwar schon gebraucht worden, aber wegen einer Löschoperation zur Zeit nicht aktiv (deleted). Beim geschlossenen Hashing ist die Behandlung auftretender Kollisionen von großer Bedeutung. Die grundlegende Idee des Rehashing besteht darin, neben der Funktion h = h0 auch noch weitere Hash-Funktionen h1 , . . . , hm−1 zu benutzen. Für einen Schlüssel x werden dann die Zellen h0 (x), h1 (x), . . . , hm−1 (x) der Reihe nach angeschaut. Sobald eine freie oder als gelöscht markierte Zelle mit Schlüssel x gefunden wird, kann ein Paar mit Schlüssel x eingefügt werden. Andererseits besagt das erste Auftreten einer freien Zelle, dass x nicht in der Hash-Tabelle enthalten ist. Hier sehen wir auch, warum gelöschte Paare markiert werden müssen; diese Technik bezeichnet man als lazy deletion“. ” Die Folge der Funktionen h0 , . . . , hm−1 sollte so gewählt werden, dass für jeden Schlüsselwert sämtliche Behälter HT [i] (0 ≤ i < m) erreicht werden. Die einfachste Strategie hierfür ist das lineare Sondieren (engl. linear probing): hi (x) = (h(x) + i) mod m. Beispiel 3.5.8. (Fortsetzung von Beispiel 3.5.2) Auflösung von Kollisionen mittels linearem Sondieren: 0 : februar 7 : juni 1 : september 8 : oktober 2 : november 9 : april 3 : august 10 : mai 4 : juli 11 : dezember 5: 12 : januar 6 : märz 132 KAPITEL 3. DATENTYPEN ZUR DARSTELLUNG VON MENGEN Programm 3.5.9. Geschlossenes Hashing mit linearem Sondieren: 1 import java.util.LinkedList; 2 3 4 5 6 7 8 /** Die Klasse ClosedHashTableLinearProbing implementiert geschlossenes * Hashing mit linearem Sondieren. * Die Hash-Tabelle enthaelt Schluessel/Wert-Paare mit Schluesseln vom Typ * String und Werten vom Typ Object. Diese Paarmenge ist eine partielle Funktion. */ class ClosedHashTableLinearProbing { 9 10 11 /** Die Anzahl der Behaelter. */ private static final int ANZAHL BEHAELTER = 13; 12 13 14 15 16 17 18 19 /** Die Klasse Content implementiert Schluessel/Wert-Paare mit einem * zusaetzlichen Feld zur Markierung geloeschter Eintraege in der * Hash-Tabelle (‘lazy deletion’). Ist fuer eine im Array gespeicherte * Instanz deleted == false, dann sagen wir, dass der Schluessel in der * Hash-Table vorhanden ist. */ private class Content { 20 21 22 /** Der Schluessel. */ String key; 23 24 25 /** Der Wert zum Schluessel. */ Object value; 26 27 28 /** Gibt an, ob der Eintrag geloescht ist. */ boolean deleted; 29 30 31 32 33 34 35 36 /** Konstruiert das Schluessel/Wert-Paar (key, value). * Das Feld deleted bekommt den Wert false. */ Content( String key, Object value ) { this.key = key; this.value = value; } Content 37 38 } // class Content 39 40 41 42 43 44 45 /** Ein Array ueber dem Typ Content. * Positionen, die noch nicht gefuellt worden sind, enthalten null. * Positionen, die bereits gefuellt waren, aber aktuell leer sind, enthalten * ein Objekt content mit content.deleted == true (‘lazy deletion’). */ private Content[ ] hashTable = new Content[ANZAHL BEHAELTER]; 46 47 /** Der parameterlose Konstruktor liefert eine leere Hash-Tabelle. */ 48 49 50 51 52 /** Implementiert die Abbildung d(’a’) = 1, . . ., d(’z’) = 26. */ private static int d( char c ) { return c − ’a’ + 1; } d 3.5. HASHING (STREUSPEICHERUNG) 133 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 /** Ordnet einem Wort w = a 1 a 2 a 3 v mit a i aus {’a’,. . .,’z’} den Wert * (d(a 1) + d(a 2) + d(a 3)) mod ANZAHL BEHAELTER zu. Dafuer * muss w mindestens die Laenge 3 haben, sonst wird eine Ausnahme ausgeloest. */ private static int hash( String w ) { if( w.length( ) < 3 ) throw new IllegalArgumentException( "String zu kurz." ); return ( d( w.charAt( 0 ) ) + d( w.charAt( 1 ) ) + d( w.charAt( 2 ) ) ) % ANZAHL BEHAELTER; } /** Gibt den naechsten Behaelter nach dem i-ten Behaelter an. */ private static int rehash( int i ) { return ( i+1 ) % ANZAHL BEHAELTER; } /** Test, ob der i-te Behaelter leer ist, d.h. noch nie gefuellt war. */ private boolean isEmpty( int i ) { return hashTable[ i ] == null; } /** Test, ob der Inhalt des i-ten Behaelters geloescht ist. */ private boolean isDeleted( int i ) { return hashTable[ i ].deleted; } /** Test, ob i >= 0 gilt und der i-te Behaelter weder leer noch geloescht ist. */ private boolean isActive( int i ) { return i != −1 && !isEmpty( i ) && !isDeleted( i ); } /** Gibt die Position des im Array gespeicherten Eintrags mit Schluessel key * zurueck, falls er existiert; dabei wird nicht zwischen geloeschten und nicht * geloeschten Eintraegen unterschieden. Andernfalls wird die Position des ersten * gefundenen freien Behaelters zurueckgegeben, so vorhanden, sonst -1. */ private int findPosition( String key ) { int h = hash( key ); int i = 0; while( !isEmpty( h ) && i < ANZAHL BEHAELTER ) { if( hashTable[ h ].key.equals( key ) ) return h; i++; h = rehash( h ); } if( i < ANZAHL BEHAELTER ) return h; return −1; } hash rehash isEmpty isDeleted isActive findPosition 134 105 106 107 108 KAPITEL 3. DATENTYPEN ZUR DARSTELLUNG VON MENGEN /** Test, ob der Schluessel key in der Hash-Tabelle vorhanden ist. */ public boolean isMember( String key ) { return isActive( findPosition( key ) ); } isMember 109 110 111 112 113 114 115 116 /** Gibt den zum Schluessel key in der Hash-Tabelle gespeicherten Wert zurueck, * falls der Schluessel vorhanden ist, sonst null. */ public Object value( String key ) { int pos = findPosition( key ); return isActive( pos ) ? hashTable[ pos ].value : null; } value 117 118 119 120 121 122 123 124 125 /** Der zum Schluessel key in der Hash-Tabelle gespeicherte Wert wird zu v, * falls der Schluessel vorhanden ist. */ public void changeValue( String key, Object v ) { int pos = findPosition( key ); if( isActive( pos ) ) hashTable[ pos ].value = v; } changeValue 126 127 128 129 130 131 132 133 134 135 136 137 138 139 /** Fuegt das Schluessel/Wert-Paar (key, value) in die Hash-Tabelle ein, * falls der Schluessel key nicht in der Hash-Tabelle vorhanden ist. * Gibt false zurueck, wenn das Paar eingefuegt werden muesste, aber kein * freier Behaelter mehr vorhanden ist, sonst true. */ public boolean insert( String key, Object value ) { int pos = findPosition( key ); if( pos == −1 ) // kein freier Behaelter vorhanden return false; if( !isActive( pos ) ) hashTable[ pos ] = new Content( key, value ); // Paar einfuegen return true; } insert 140 141 142 143 144 145 146 147 148 /** Entfernt das Paar mit Schluessel key aus der Hash-Tabelle. */ public void delete( String key ) { int pos = findPosition( key ); if( isActive( pos ) ) { hashTable[ pos ].deleted = true; hashTable[ pos ].value = null; // fuer Garbage-Collection } } delete 149 150 151 152 153 154 155 156 157 158 159 /** Gibt eine String-Darstellung der Hash-Tabelle zurueck. */ public String toString( ) { StringBuffer ausgabe = new StringBuffer( ); for( int i = 0; i < ANZAHL BEHAELTER; i++ ) ausgabe.append( "Behaelter " + i + ":\t" + ( hashTable[ i ] == null | | hashTable[ i ].deleted ? "--\n" : "(" + hashTable[ i ].key + ", " + hashTable[ i ].value + ")\n" ) ); return ausgabe.toString( ); } toString 3.5. HASHING (STREUSPEICHERUNG) 160 161 162 163 164 165 166 167 168 169 135 public static void main( String[ ] args ) { ClosedHashTableLinearProbing ht = new ClosedHashTableLinearProbing( ); String[ ] monat = { "januar", "februar", "maerz", "april", "mai", "juni", "juli", "august", "september", "oktober", "november", "dezember" }; String[ ] tage = { "31", "28", "31", "30", "31", "30", "31", "31", "30", "31", "30", "31" }; for( int i = 0; i < monat.length; i++ ) ht.insert( monat[ i ], tage[ i ] ); System.out.println( ht ); } 170 171 } // class ClosedHashTableLinearProbing Ein Testlauf: Behaelter Behaelter Behaelter Behaelter Behaelter Behaelter Behaelter Behaelter Behaelter Behaelter Behaelter Behaelter Behaelter 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: (februar, 28) (september, 30) (november, 30) (august, 31) (juli, 31) -(maerz, 31) (juni, 30) (oktober, 31) (april, 30) (mai, 31) (dezember, 31) (januar, 31) Jedes Element, das mit der Hash-Funktion auf einen bereits belegten Behälter abgebildet wird, wird durch lineares Sondieren bis zum nächsten unbelegten Behälter verschoben“. Sind also k Behälter hintereinander belegt, so ist die ” Wahrscheinlichkeit, dass im nächsten Schritt der erste freie Behälter nach diesen k Behältern belegt wird, mit (k + 1)/m wesentlich größer als die Wahrscheinlichkeit, dass ein Behälter im nächsten Schritt belegt wird, dessen Vorgänger noch frei ist. Dadurch entstehen beim linearen Sondieren regelrechte Ketten. Satz 3.5.10. [Knuth 1973] Sei α = n/m der Belegungsfaktor einer HashTabelle der Größe m, die mit n Elementen gefüllt ist. Beim Hashing mit linearem Sondieren entstehen für eine Operation durchschnittlich folgende Kosten: (1.) 1 + 1/(1 − α) /2 beim erfolgreichen Suchen und (2.) 1 + 1/(1 − α)2 /2 beim erfolglosen Suchen. Für verschiedene Belegungsfaktoren α erhalten wir die folgenden Werte: main 136 KAPITEL 3. DATENTYPEN ZUR DARSTELLUNG VON MENGEN α 20% 50% 70% 80% 90% 95% (1.) (2.) 1,125 1,5 2,17 3 5,5 10,5 1,28 2,5 6,06 13 50,5 200,5 Bei dicht besetzten Tabellen wird Hashing mit linearem Sondieren also sehr ineffizient. Definition 3.5.11. Weitere Kollisionsstrategien (a) Verallgemeinertes lineares Sondieren (1 ≤ i < m): hi (x) = (h(x) + c · i) mod m. Dabei sollten c und m teilerfremd sein, um alle Behälter zu erreichen. (b) Quadratisches Sondieren (1 ≤ i < m): hi (x) = (h(x) + i2 ) mod m, oder (1 ≤ i ≤ (m − 1)/2) h2i−1 (x) = (h(x) + i2 ) mod m, h2i (x) = (h(x) − i2 ) mod m. Wählt man bei der zweiten Variante m = 4j + 3 als Primzahl, so wird jeder Behälter getroffen. (c) Doppel-Hashing: Seien h, h0 : U → {0, . . . , m − 1} zwei Hash-Funktionen. Dabei seien h und h0 so gewählt, dass für beide eine Kollision nur mit Wahrscheinlichkeit 1/m auftritt, d.h. 1 P r h(x) = h(y) = P r h0 (x) = h0 (y) = . m Die Funktionen h und h0 heißen unabhängig, wenn eine Doppelkollision nur mit Wahrscheinlichkeit 1/m2 auftritt, d.h. 1 P r h(x) = h(y) und h0 (x) = h0 (y) = 2 . m Wir erhalten nun eine Folge von Hash-Funktionen wie folgt (i ≥ 1): hi (x) = (h(x) + h0 (x) · i2 ) mod m. Dies ist eine gute Methode, bei der die Schwierigkeit aber darin liegt, Paare von Funktionen zu finden, die wirklich unabhängig sind. 3.5. HASHING (STREUSPEICHERUNG) 137 Programm 3.5.12. Eine Variante von Programm 3.5.9 mit quadratischem Sondieren: 69 70 71 72 73 74 75 76 77 78 private static int rehash( int h, int i ) { int j = ( i+1 )/2; if( i%2 == 0 ) j = ( h−j*j ) % ANZAHL BEHAELTER; else j = ( h+j*j ) % ANZAHL BEHAELTER; if( j < 0 ) j += ANZAHL BEHAELTER; return j; } rehash 79 100 101 102 103 104 105 106 107 108 109 110 111 112 113 ... private int findPosition( String key ) { int hInitial = hash( key ); int h = hInitial; int i = 0; while( !isEmpty( h ) && i < ANZAHL BEHAELTER ) { if( hashTable[ h ].key.equals( key ) ) return h; i++; h = rehash( hInitial, i ); } if( i < ANZAHL BEHAELTER ) return h; return −1; } Definition 3.5.13. Einige Hash-Funktionen: Sei nat : U → N eine Funktion, die jedem möglichen Schlüssel eine natürliche Zahl zuordnet. Durch h(x) = nat(x) mod m erhalten wir dann eine HashFunktion. Wir schauen uns im folgenden verschiedene Funktionen U → N an. (a) Sei U = Σ∗ die Menge der Wörter über dem Alphabet Σ = {a, b, . . . , z}. Dann kann ein Wort w = a1 a2 . . . an (ai ∈ Σ) als eine Zahl nat(w) = a1 · 26n−1 + a2 · 26n−2 + · · · + an−1 · 26 + an aufgefasst werden, wenn man a mit 0, b mit 1, . . . , z mit 25 identifiziert. P (b) Die Mittel-Quadrat-Methode: Sei U ⊆ N, und sei k = `i=0 zi · 10i , d.h. k wird durch die Ziffernfolge z` z`−1 . . . z0 beschrieben. Den Wert h(k) erhält man nun dadurch, dass man aus der Mitte der Ziffernfolge von k 2 einen hinreichend großen Block nimmt. Da die mittleren Ziffern von k 2 von allen Ziffern von k abhängen, ergibt dies eine gute Streuung von aufeinanderfolgenden Werten. findPosition 138 KAPITEL 3. DATENTYPEN ZUR DARSTELLUNG VON MENGEN Beispiel: Sei m = 100. k k mod 100 k2 h(k) 127 128 129 27 28 29 16129 16384 16641 12 38 64 (c) Shift-Folding: Jedes Wort w wird als eine Binärzahl bin(w) aufgefasst. Diese Binärzahl wird in Teile der Länge k zerlegt, wobei das letzte Teil eventuell kürzer sein kann. Diese Teile werden nun als Binärzahlen aufgefasst und addiert. Dies ergibt eine Zahl nat(w). Beispiel w = emin: w 7→ (5, 13, 9, 14) 7→ 0000 0101 0000 1101 0000 1001 0000 1110. Für k = 5 erhalten wir die folgenden Teile mit Summe nat(w) = 65: 00000 10100 00110 10000 10010 00011 10 1000001 Wie wir in Satz 3.5.7 gesehen haben, kostet eine Operation Suchen, Einfügen oder Löschen beim Hashing im Mittel O(1) Schritte. Andererseits kann eine solche Operation im schlechtesten Fall aber auch O(n) Schritte kosten. Für jede Hash-Funktion h : U → {0, . . . , m − 1} sind es gewisse Schlüsselmengen S ⊆ U , die dieses schlechte Verhalten bewirken. Würde man S kennen, könnte man eine Hash-Funktion auswählen, die für diese Schlüsselmenge möglichst wenige Kollisionen und damit ein gutes Laufzeitverhalten ergibt. Leider kennt man die Menge S in den meisten Anwendungen aber nicht. Die Idee des universellen Hashing ist nun die folgende. Man hat eine Klasse H von Hash-Funktionen von U nach {0, . . . , m − 1}. Aus dieser Klasse wählt man zur Laufzeit eine Funktion durch ein Zufallsexperiment aus. Ist die Klasse H geeignet gewählt, so kann man zeigen, dass für jede Schlüsselmenge S ⊆ U im Mittel ein gutes Laufzeitverhalten erreicht wird. Wir haben es hier also mit einem probabilistischen Algorithmus zu tun. Definition 3.5.14. Sei H eine endliche Menge von Hash-Funktionen, die die Menge U auf {0, . . . , m − 1} abbilden. Die Menge H heißt universell, falls für alle Elemente x, y ∈ U mit x 6= y folgendes gilt: 1 P r h ∈ H | h(x) = h(y) = , m d.h. die Wahrscheinlichkeit dafür, dass eine aus H zufällig ausgewählte Funktion x und y auf denselben Behälter abbildet, ist mit 1/m genauso groß wie die 3.5. HASHING (STREUSPEICHERUNG) 139 Wahrscheinlichkeit dafür, dass eine Kollision auftritt, wenn man die Werte h(x) und h(y) zufällig und unabhängig voneinander aus {0, . . . , m − 1} auswählt. Satz 3.5.15. Sei S ⊆ U eine beliebige Schlüsselmenge mit n Elementen, und sei H eine universelle Menge von Hash-Funktionen von U nach {0, . . . , m − 1}. Sei h ∈ H zufällig ausgewählt. Für jeden Schlüssel x ∈ S ist dann die mittlere Anzahl der Kollisionen |{y ∈ S \ {x} | h(y) = h(x)}| höchstens n/m. Beweis: Für x, y ∈ S mit x 6= y bezeichne cxy die folgende Zufallsvariable: 1, falls h(x) = h(y), cxy = 0, falls h(x) 6= h(y). Dann gilt E(cxy ) = X h∈H 1 cxy · P r(h) = P r h ∈ H | h(x) = h(y) = . m Für die Zufallsvariable Cx = |{y ∈ S \ {x} | h(y) = h(x)}| gilt damit X X X 1 n ≤ . E(Cx ) = E cxy ≤ E(cxy ) = m m y∈S\{x} y∈S\{x} y∈S\{x} Ist also n ≤ m, so ist die mittlere Anzahl der zu erwartenden Kollisionen höchstens 1. Wir beschließen diesen Abschnitt mit einem Beispiel für eine universelle Menge von Hash-Funktionen. Definition 3.5.16. Alle Schlüssel x ∈ U seien durch Bitstrings der Länge ` gegeben. Wir zerlegen jeden dieser Bitstrings in Blöcke derselben Länge k, so dass 2k ≤ m gilt. Dann kann jeder Block als eine Adresse aus {0, . . . , m − 1} aufgefasst werden. Sei nun x ∈ U , und sei (x0 , . . . , xr ) die Blockzerlegung von x. Die Klasse H besteht aus allen Hash-Funktionen h = ha , die durch ein Tupel a = (a0 , . . . , ar ) mit ai ∈ {0, . . . , m − 1} spezifiziert werden, wobei ha durch ha (x) = r X ai · xi mod m i=0 definiert ist. Es gibt also mr+1 viele verschiedene Funktionen in H. Satz 3.5.17. Ist m eine Primzahl, so ist H eine universelle Klasse von HashFunktionen. Beweis: Seien x = (x0 , . . . , xr ) und y = (y0 , . . . , yr ) zwei verschiedene Schlüssel aus U mit xi , yj ∈ {0, . . . , m−1}. Da x 6= y ist, gibt es einen Index s mit xs 6= ys . Ohne Beschränkung der Allgemeinheit sei s = 0, d.h. x0 6= y0 . Dann ist ha (x) = r X i=0 ai xi mod m, ha (y) = r X i=0 ai yi mod m, 140 KAPITEL 3. DATENTYPEN ZUR DARSTELLUNG VON MENGEN also gilt r X ai xi ≡ r X ai yi mod m gdw a0 (x0 − y0 ) ≡ r X ai (yi − xi ) mod m. ha (x) = ha (y) gdw i=0 i=0 i=1 Da m eine Primzahl ist, gibt es eine Zahl (x0 − y0 )−1 ∈ {1, . . . , m − 1} mit (x0 − y0 ) · (x0 − y0 )−1 ≡ 1 mod m, d.h. die obige Gleichheit ist äquivalent zu −1 a0 = (x0 − y0 ) · r X ai (yi − xi ) mod m. i=1 Für jede Wahl von a1 , . . . , ar ∈ {0, . . . , m − 1} gibt es also genau einen Wert a0 ∈ {0, . . . , m − 1}, so dass für a = (a0 , a1 , . . . , ar ) die Gleichheit ha (x) = ha (y) gilt. Von den mr+1 vielen Funktionen aus H erfüllen also genau mr viele Funktionen diese Gleichheit, d.h. für alle x, y ∈ U mit x 6= y ist 1 . m Also ist H eine universelle Klasse von Hash-Funktionen. P r(h ∈ H | h(x) = h(y)) = Um diese universelle Klasse zu benutzen, braucht man einen Prozess, der die r + 1 Zahlen a0 , . . . , ar ∈ {0, . . . , m − 1}, die den Schlüssel a = (a0 , . . . , ar ) bilden, zufällig und unabhängig auswählt. Daher ist die Aufgabe, (Pseudo-) Zufallszahlen effektiv zu bestimmen, von großer Wichtigkeit. Leider können wir hier nicht darauf eingehen. 3.6 Partitionen von Mengen mit UNION und FIND Unsere Betrachtungen zur Darstellung von Mengen und Operationen auf Mengen wollen wir mit einem Datentyp abschließen, der es erlaubt, Partitionen von endlichen Mengen zu beschreiben. Sei S eine endliche Menge. Wir betrachten Äquivalenz-Anweisungen“ der Form ” a1 ≡ b1 , a2 ≡ b2 , . . . , am ≡ bm , wobei die ai , bj ∈ S sind. Die Anweisung ai ≡ bi besagt, dass die Äquivalenzklassen von ai und bi zu einer Klasse verschmolzen werden sollen. Außerdem wollen wir die Äquivalenzklasse eines gegebenen Elements aus S bestimmen können, etwa indem wir einen Repräsentanten dieser Klasse angeben. Beispiel 3.6.1. Sei S = {1, 2, 3, 4, 5, 6}. Anfänglich sei jedes Element für sich eine Klasse der betrachteten Partition P , d.h. P = {{1}, {2}, {3}, {4}, {5}, {6}}. Die Anweisungen unten bewirken nun die folgenden Veränderungen von P : 1 ≡ 4 ; P = {{1, 4}, {2}, {3}, {5}, {6}} 2 ≡ 5 ; P = {{1, 4}, {2, 5}, {3}, {6}} 2 ≡ 4 ; P = {{1, 2, 4, 5}, {3}, {6}} 3.6. PARTITIONEN VON MENGEN MIT UNION UND FIND 141 Auf einer Partition P wollen wir also folgende Operationen ausführen: • Vereinigen (UNION): Ersetze S1 ∈ P und S2 ∈ P durch S1 ∪ S2 . • Finden (FIND): Bestimme zu i ∈ S die Teilmenge Sj ∈ P mit i ∈ Sj . Definition 3.6.2. Eine Implementierung von Partitionen als Bäume: Sei {S1 , . . . , Sm } eine Partition von {1, . . . , n}. Ist |Si | = `i , so stellen wir diese Menge durch einen Baum mit `i Knoten dar, wobei jeder Knoten ein Element von Si als Inhalt hat. Bei der Implementierung dieser Bäume soll von jedem Knoten, der nicht Wurzel ist, ein Verweis auf seinen Elternknoten zeigen. Beispiel 3.6.3. Für S = {1, . . . , 10} und P = {S1 , S2 , S3 } mit S1 = {1, 7, 8, 9}, S2 = {2, 5, 10} und S3 = {3, 4, 6} ergibt sich folgende Baummenge: 89:; ?>=< @ 1O ^== == = 89:; ?>=< 89:; ?>=< 89:; ?>=< 7 8 9 S1 89:; ?>=< @ 5 `@@ @@ @@ 89:; ?>=< @ABC GFED 2 10 S2 89:; ?>=< @ 3 ^== == = 89:; ?>=< 89:; ?>=< 4 6 S3 Haben wir die Mengennamen S1 , . . . , Sm (z.B. in einer Liste) gespeichert, so kann mit jedem Namen ein Verweis auf die Wurzel des zugehörigen Baums gespeichert werden, und umgekehrt kann in der Wurzel des Baums ein Verweis auf den Mengennamen gespeichert sein. Zur Vereinfachung wollen wir im folgenden daher die Mengen S1 , . . . , Sm mit den Elementen in den Wurzeln der darstellenden Bäume identifizieren, d.h. diese Elemente dienen als Repräsentanten der Mengen S1 , . . . , Sm . Um zwei disjunkte Mengen Si und Sj zu vereinigen, kann man nun einfach die Wurzel des einen Baums zu einem Kind der Wurzel des anderen Baums machen. Wählen wir für die Darstellung ein Array ganzer Zahlen parent, wobei wir in parent[i] einfach das Element angeben, das als Elternknoten von Element i auftritt, so erhalten wir folgende einfachen Algorithmen für die Realisierung der obigen beiden Operationen. /** Ersetze die Mengen mit Wurzeln i und j durch ihre Vereinigung. */ void union’( int i, int j) { if( i != j ) 4 parent[i] = j; 5 } 1 2 3 6 7 8 9 10 11 12 13 /** Liefert die Menge (d.h. den Repräsentaten der Menge), die i enthält. */ int find’( int i ) { int h = i; while( parent[h] > 0 ) h = parent[h]; return h; } union’ find’ 142 KAPITEL 3. DATENTYPEN ZUR DARSTELLUNG VON MENGEN Beispiel 3.6.4. (Fortsetzung von Beispiel 3.6.3) Führt man die Operation union’(1, 5) aus, dann entsteht der untenstehende Baum. Die Operation find’(8) liefert darauf das Resultat 5. 89:; 1 ?>=< = 5 Eb {{ O EEE { EE { EE {{ {{ 89:; ?>=< @ABC GFED 89:; ?>=< 2 10 = 1 C a {{ J O CCC { C { CC {{ C {{ 89:; ?>=< 89:; ?>=< 89:; ?>=< 7 8 9 Diese Algorithmen sind zwar einfach, aber leider haben sie im Allgemeinen ein sehr ungünstiges Laufzeitverhalten. Beispiel 3.6.5. Für die Partition {S1 , . . . , Sn } mit Si = {i} führen wir folgende Operationen in der angegebenen Reihenfolge durch (p ≤ n): union’(1, 2), find’(1), union’(2, 3), find’(1), . . . , union’(p − 1, p), find’(1). 89:; ?>=< p O .. .O ?>=< 89:; 2 O 89:; ?>=< 1 76 54 . . . ?>=< 89:; 01p + 123 n Dadurch entsteht die nebenstehende Partition. Die p − 1 union’-Operationen kosten Zeit O(p). Jede find’-Operation kostet soviel Zeit, wie der zu durchlaufende Weg lang ist, der i-te Aufruf kostet also Zeit O(i). Als Gesamtzeit erhalten wir damit O(p2 ). Die Gesamtzeit für die Ausführung einer Folge von Vereinigungs- und FindeOperationen kann erheblich verbessert werden, wenn wir bei der Vereinigung die folgende Gewichtungsregel benutzen: Ist die Anzahl der Knoten im Baum i kleiner als die Anzahl der Knoten im Baum j, so wird i zum Kind von j, andernfalls wird j zum Kind von i. Beispiel 3.6.6. (Fortsetzung von Beispiel 3.6.5) Diesmal führen wir die obige Folge von Operationen aus, indem wir bei der Vereinigung die Gewichtungsregel benutzen. Wir erhalten dann folgende Partition in Gesamtzeit O(p): 54 . . . ?>=< 76 89:; 89:; ?>=< 01p + 123 n 1 Mf B O MMMMM MMM 89:; ?>=< 89:; ?>=< ?>=< 89:; . . . p 2 3 Um die Vereinigung auf diese Weise zu realisieren, müssen wir mit jedem Baum die Anzahl seiner Knoten speichern. Dies können wir in der Wurzel tun, da die Wurzel ja keinen Verweis auf einen Elternknoten hat. Um die Fälle, ob in parent[i] nun ein Verweis oder die Anzahl n der Knoten steht, d.h. ob i Wurzel 3.6. PARTITIONEN VON MENGEN MIT UNION UND FIND 143 ist oder nicht, unterscheiden zu können, speichern wir in der Wurzel die Zahl −n statt n. Damit erhalten wir folgenden Algorithmus. 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 /** Ersetze die Mengen mit Wurzeln i und j durch ihre * Vereinigung, wobei die Gewichtungsregel benutzt wird. * Wir setzen parent[i] = -(Groesse des Baums mit Wurzel i) und * parent[j] = -(Groesse des Baums mit Wurzel j) voraus. */ void union( int i, int j ) { if( i != j ) { if( parent[i] > parent[j] ) { // Baum i ist kleiner als Baum j parent[j] += parent[i]; parent[i] = j; } else { // parent[i] <= parent[j]: Baum i ist mindestens so gross wie Baum j parent[i] += parent[j]; parent[j] = i; } } } Zeitbedarf von union: O(1). Was können wir nun über die Gesamtzeit für eine Folge von Vereinigungsund Finde-Operationen sagen? Wir werden sehen, dass diese Gesamtzeit nun O(p log p) ist, wenn p die Anzahl der auszuführenden Operationen ist. Dazu brauchen wir folgendes Lemma. Lemma 3.6.7. Sei T ein Baum mit m Knoten und Höhe h, der mittels des Algorithmus union aus der Partition {{1}, {2}, . . . , {n}} aufgebaut wurde. Dann gilt h ≤ blog mc. Beweis: Durch Induktion nach m. Der Fall m = 1 ist klar. Angenommen, die Aussage sei bereits für alle Zahlen kleiner als m bewiesen worden, und sei nun union(`, r) die letzte Operation, die den Baum T liefert: T : A A A T` A A A A A Tr A A union(`, r) ! !! A ! A !! A A A T r A A A T` A A O.B.d.A. nehmen wir |T` | = j mit 1 ≤ j ≤ m/2 an, also |Tr | = m − j mit m/2 ≤ m − j < m. Nach Induktionsvoraussetzung haben wir h(T` ) ≤ blog jc ≤ blog(m/2)c = blog mc − 1, h(Tr ) ≤ blog(m − j)c ≤ blog mc. Das ergibt h(T ) = max{h(T` ) + 1, h(Tr )} ≤ blog mc. union 144 KAPITEL 3. DATENTYPEN ZUR DARSTELLUNG VON MENGEN Für eine Finde-Operation brauchen wir also höchstens O(log m) Schritte, wenn sie auf einem Baum mit m Knoten ausgeführt wird, der durch den Algorithmus union aufgebaut worden ist. Folgerung 3.6.8. Eine beliebige Folge von p Vereinigungs-Operationen und q Finde-Operationen kann in Zeit O(p + q · log p) ausgeführt werden. Beweis: Ein Baum mit m Knoten kann nur mit m − 1 Anwendungen der Vereinigungs-Operation aufgebaut werden, da wir immer mit der Partition {{1}, {2}, . . . , {n}} beginnen. Die Schranke aus Lemma 3.6.7 ist scharf, denn mit m − 1 Vereinigungen kann tatsächlich ein Baum mit m Knoten und Höhe blog mc erzeugt werden. Eine weitere Verbesserung können wir erzielen, wenn bei der Finde-Operation die folgende Kompressionsregel benutzen wird: Mache alle Knoten auf dem Weg vom Knoten i zur Wurzel (einschließlich Knoten i) zu Kindern der Wurzel. Unter Benutzung dieser Regel erhalten wir folgenden Algorithmus. 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 /** Gibt die Wurzel des Baums zurueck, der i enthaelt, * wobei die Kompressionsregel benutzt wird. */ int find( int i ) { int w = i; while( parent[w] >= 0 ) // solange w nicht die Wurzel ist w = parent[w]; // w ist nun Repraesentant der Teilmenge, die i enthaelt. // Realisiere noch die Kompressionsregel: int tmp; while( i != w ) { tmp = parent[i]; parent[i] = w; i = tmp; } return w; } Zeitbedarf von find: O(Tiefe des Knotens i). Beispiel 3.6.9. Aus der Partition {{1}, . . . , {8}} entsteht durch die Operationenfolge union(1,2), union(3,4), union(5,6), union(7,8), union(1,3), union(5,7), union(1,5) der Baum 89:; ?>=< 1 @ O ^=== == 89:; ?>=< 89:; ?>=< 89:; ?>=< 2 3 5 ^= O O == == 89:; ?>=< 89:; ?>=< 89:; ?>=< 4 6 7 O 89:; ?>=< 8 find 3.6. PARTITIONEN VON MENGEN MIT UNION UND FIND 145 Durch Operation find(8) (mit Ausgabe 1) entsteht daraus der neue Baum 89:; ?>=< g Ti NTNTTT @ 1O ^=N == NN TTT == NNN TTTTT NN TTT 89:; ?>=< 89:; ?>=< 89:; ?>=< 89:; ?>=< 89:; ?>=< 2 3 5 7 8 O O ?>=< 89:; 4 89:; ?>=< 6 Um die Gesamtzeit für eine Folge von q Vereinigungs-Operationen und p ≥ q Finde-Operationen abzuschätzen, brauchen wir die Ackermannsche Funktion3 A : N × N → N, die so definiert ist (p, q ≥ 0): A(0, q) = 2q, A(p + 1, 0) = 1, A(p + 1, q + 1) = A(p, A(p + 1, q)). Die folgende Funktion ist vom Wachstum her gerade umgekehrt: α(m, n) = min{z ≥ 1 | A (z, 4 · dm/ne) > log n}. Lemma 3.6.10. A ist im ersten Argument monoton wachsend und im zweiten streng monoton wachsend: A(p + 1, q) ≥ A(p, q) und A(p, q + 1) > A(p, q). .. 2 Die Funktion A wächst extrem schnell. So gilt z.B. A(3, 4) = 22 , 65536-mal geschachtelt. Und für m > 0 und 1 ≤ n < 2A(3,4) , also log n < A(3, 4), ist A(3, 4 · dm/ne) ≥ A(3, 4) > log n, damit α(m, n) ≤ 3. Also wächst die Funktion α extrem langsam; in der Praxis ist sie von einer konstanten Funktion nicht zu unterscheiden. Satz 3.6.11. Die Gesamtzeit, die maximal benötigt wird, eine Folge von q Vereinigungs-Operationen und p ≥ q Finde-Operationen mit den Algorithmen union und find auszuführen, ist in Θ(p · α(p, q)). Beweis: Siehe R. Tarjan, Efficiency of a good but not linear set union algorithm, Journal of the ACM 22(2) (1975), S. 215–225, oder auch K. Mehlhorn, Data structures and algorithms, Vol. 1, Springer 1984, S. 300–304. Programm 3.6.12. Eine Implementierung der UNION-FIND-Algorithmen: 1 2 3 4 5 /** Die Klasse Partition implementiert Partitionen endlicher Mengen. */ public class Partition { /** Die Groesse des Universums {0,. . .,cardinality-1}. */ private final int cardinality; 6 3 Wilhelm Ackermann (1896–1962) 146 7 8 9 10 11 12 KAPITEL 3. DATENTYPEN ZUR DARSTELLUNG VON MENGEN /** Das Array zur Speicherung von Verweisen auf Elternknoten * bzw. von Baumgroessen. Die Knoten sind 0 bis cardinality-1. * Alle Werte >= 0 sind Verweise auf Elternknoten, * alle Werte < 0 sind negierte Baumgroessen. */ private int[ ] parent; 13 14 15 16 17 18 19 20 21 22 /** Konstruiert die feinste Partition des Universums mit n Elementen: * jede Menge enthaelt genau ein Element. */ public Partition( int n ) { cardinality = n; parent = new int[cardinality]; for( int i = 0; i < cardinality; i++ ) parent[i] = −1; } Partition 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 /** Ersetze die Mengen mit Wurzeln i und j durch ihre * Vereinigung, wobei die Gewichtungsregel benutzt wird. * Wir setzen parent[i] = -(Groesse des Baums mit Wurzel i) und * parent[j] = -(Groesse des Baums mit Wurzel j) voraus. * Wir setzen ausserdem i,j < cardinality voraus. */ public void union( int i, int j ) { if( parent[i] >= 0 | | parent[j] >= 0 ) throw new IllegalArgumentException( "Union erwartet zwei Repraesentanten."); if( i >= cardinality | | j >= cardinality ) throw new IllegalArgumentException( "Union erwartet Elemente des Universums."); if( i != j ) { if( parent[i] > parent[j] ) { // Baum i ist kleiner als Baum j parent[j] += parent[i]; parent[i] = j; } else { // parent[i] <= parent[j]: Baum i ist mindestens so gross wie Baum j parent[i] += parent[j]; parent[j] = i; } } } union 48 49 50 51 52 53 54 55 56 57 58 59 /** Gibt die Wurzel des Baums zurueck, der i enthaelt, * wobei die Kompressionsregel benutzt wird. * Wir setzen 0 <= i < cardinality voraus. */ public int find( int i ) { if( i < 0 | | i >= cardinality ) throw new IllegalArgumentException( "Find erwartet Elemente des Universums."); int w = i; while( parent[w] >= 0 ) // solange w nicht die Wurzel ist w = parent[w]; find 3.6. PARTITIONEN VON MENGEN MIT UNION UND FIND // w ist nun Repraesentant der Teilmenge, die i enthaelt. // Realisiere noch die Kompressionsregel: int tmp; while( i != w ) { tmp = parent[i]; parent[i] = w; i = tmp; } return w; 60 61 62 63 64 65 66 67 68 69 70 } 71 72 /** Gibt eine String-Repraesentation der Partition zurueck. */ public String toString( ) { StringBuffer result = new StringBuffer( ); for( int i = 0; i < cardinality; i++ ) if( parent[i] < 0 ) // falls i Repraesentant ist result.append( i + " ist Repraesentant.\n" ); else // i hat einen Elternknoten result.append( i + " hat Elternknoten " + parent[i] + "\n" ); return result.toString( ); } 73 74 75 76 77 78 79 80 81 public static void main( String[ ] args ) { Partition p = new Partition( 9 ); p.union( 1, 2 ); p.union( 3, 4 ); p.union( 5, 6 ); p.union( 7, 8 ); p.union( 1, 3 ); p.union( 5, 7 ); p.union( 1, 5 ); System.out.println( "Vor find(8):" ); System.out.println( p ); System.out.println( "find(8) liefert " + p.find( 8 ) ); System.out.println( "Nach find(8):" ); System.out.println( p ); } 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 147 } toString main 148 KAPITEL 3. DATENTYPEN ZUR DARSTELLUNG VON MENGEN Kapitel 4 Graphen und Graph-Algorithmen Graphen sind eine mathematische Struktur, die eine Menge von Objekten zusammen mit einer Relation auf diesen Objekten darstellt. Beispiele für solche Mengen von Objekten und Relationen sind die folgenden: • Objekte: Personen, Relation: A kennt B. • Objekte: Flughäfen, Relation: es gibt einen Non-Stop-Flug von A nach B. • Objekte: Methoden eines Java-Programms, Relation: A ruft B auf. Üblicherweise wird ein Graph durch ein Diagramm beschrieben: Die Objekte werden als Knoten dargestellt, und die Relation zwischen zwei Objekten wird durch eine Kante zwischen den entsprechenden Knoten beschrieben. Im allgemeinen sind Kanten als Pfeile dargestellt, d.h. wir haben gerichtete Kanten. In diesem Fall sprechen wir auch von einem gerichteten Graphen. Ist die betrachtete Relation E aber symmetrisch (d.h. wenn aus (a, b) ∈ E stets (b, a) ∈ E folgt) dann ersetzen wir die beiden gerichteten Kanten a → b und b → a durch eine ungerichtete Kante a − b. In diesem Fall sprechen wir von einem ungerichteten Graphen. 89:; ?>=< B 0 89:; ?>=< 1 ?>=< 89:; 2 89:; ?>=< 0 === == 89:; ?>=< 89:; ?>=< 1= 2 == == 89:; ?>=< 3 ein gerichteter Graph ein ungerichteter Graph 149 150 4.1 KAPITEL 4. GRAPHEN UND GRAPH-ALGORITHMEN Gerichtete Graphen Definition 4.1.1. Ein gerichteter Graph G = (V, E) besteht aus einer endlichen Menge V von Knoten und einer Menge E ⊆ V × V von Kanten. Beispiel 4.1.2. Der Graph G1 = ({0, 1, 2}, {(0, 1), (1, 0), (1, 2)}) ist gerade der durch das obige Diagramm beschriebene gerichtete Graph. Eine Kante (u, v) ∈ E hat den Startknoten u und den Zielknoten v. Die Kante (u, v) ist mit den Knoten u und v inzident, und die Knoten u und v sind adjazent oder benachbart. Definition 4.1.3. Sei G = (V, E) ein gerichteter Graph. • Ein Weg in G ist eine Folge (v0 , v1 , . . . , vr ) von Knoten mit (vi , vi+1 ) ∈ E für 0 ≤ i < r. Die Länge dieses Wegs ist die Anzahl r der durchlaufenen Kanten. Ein Weg heißt einfach, wenn alle auftretenden Knoten paarweise verschieden sind, wobei die Ausnahme v0 = vr zugelassen ist. • Ein Teilgraph von G ist ein Graph G0 = (V 0 , E 0 ) mit V 0 ⊆ V und E 0 ⊆ E. • Zwei Knoten u, v ∈ V heißen stark verbunden, wenn es einen Weg von u nach v und einen Weg von v nach u gibt. Eine stark verbundene Komponente (eine starke Komponente) ist ein Teilgraph mit maximaler Knotenzahl, in dem alle Knoten paarweise stark verbunden sind. Hat G nur eine starke Komponente, so heißt G stark verbunden. Beispiel 4.1.4. (Fortsetzung von Beispiel 4.1.2) Der Graph G1 hat die beiden folgenden starken Komponenten, ist also nicht stark verbunden: 89:; ?>=< 0 z 89:; ?>=< : 1 und 89:; ?>=< 2 Definition 4.1.5. Der Grad d(v) eines Knotens v ∈ V ist die Anzahl der Kanten, mit denen v inzident ist. Der Eingangsgrad d+ (v) ist die Anzahl der Kanten, die v als Zielknoten haben, und der Ausgangsgrad d− (v) ist die Anzahl der Kanten, die v als Startknoten haben. Offensichtlich gilt d(v) = d+ (v) + d− (v). Lemma 4.1.6. Sei G = (V, E) ein gerichteter Graph mit V = {v0 , . . . , vn−1 }. n−1 n−1 n−1 P P P Dann gilt d+ (vi ) = d− (vi ) = |E| und insbesondere d(vi ) = 2|E|. i=0 i=0 i=0 In vielen Anwendungen werden Graphen betrachtet, deren Kanten gewisse Ko” sten“ zugeordnet sind. Definition 4.1.7. Eine Gewichtungsfunktion c : E → R+ ordnet jeder Kante e eines Graphen G = (V, E) ein Gewicht c(e) (die Kosten der Kante e) zu. 4.1. GERICHTETE GRAPHEN 151 Als erstes wollen wir uns nun der Implementierung von gerichteten Graphen (mit oder ohne Gewichtungsfunktion) zuwenden. Hierfür gibt es mehrere Möglichkeiten. Definition 4.1.8. Implementierung von Graphen durch Adjazenzmatrizen: Sei G = (V, E) mit V = {v0 , . . . , vn−1 } ein gerichteter Graph. Die Adjazenzmatrix für G ist die boolesche (n × n)-Matrix (Ai,j )0≤i,j<n mit true falls (vi , vj ) ∈ E, Ai,j = false sonst. Die markierte Adjazenzmatrix für G bezüglich einer Gewichtungsfunktion c : E → R+ ist die (n × n)-Matrix (Mi,j )0≤i,j<n über R+ ∪ {0} mit c(vi , vj ) falls (vi , vj ) ∈ E, Mi,j = 0 sonst. Der Wert 0 besagt hierbei also, dass eine Kante in G nicht enthalten ist. Für die Adjazenzmatrix braucht man Speicherplatz O(n2 ), unabhängig von der Anzahl der vorhandenen Kanten. Ihr Vorteil besteht darin, dass man in Zeit O(1) feststellen kann, ob eine Kante von vi nach vj führt. Ferner gelten folgende Gleichungen für alle Knoten vi , wenn man true mit 1 und false mit 0 identifiziert: d− (vi ) = n−1 X Ai,j und d+ (vi ) = j=0 n−1 X Aj,i . j=0 Beispiel 4.1.9. (Fortsetzung von Beispiel 4.1.2) Die Adjazenzmatrix für G1 : 0 1 0 1 0 1 0 0 0 Programm 4.1.10. Eine Implementierung gerichteter Graphen über Adjazenzmatrizen: 1 import java.util.Random; 2 /** Die Klasse DirectedGraph implementiert gerichtete Graphen. * Die Knoten sind Zahlen >= 0 vom Typ int. 5 */ 6 public class DirectedGraph { 3 4 7 8 9 10 11 /** Die Anzahl der Knoten des Graphen. * Knoten sind Zahlen v mit 0 <= v < N. */ private final int N; 12 13 14 15 /** Die Adjazenzmatrix des Graphen. */ private boolean[ ][ ] matrix; 152 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 KAPITEL 4. GRAPHEN UND GRAPH-ALGORITHMEN /** Konstruiert einen Graphen mit n Knoten und keiner Kante. */ public DirectedGraph( int n ) { N = n; matrix = new boolean[N][N]; } /** Konstruiert einen Zufallsgraphen mit n Knoten. * Jede Kante ist mit Wahrscheinlichkeit p vorhanden. */ public DirectedGraph( int n, double p ) { this( n ); for( int v = 0; v < N; v++ ) for( int w = 0; w < N; w++ ) if( Math.random( ) < p ) insertEdge( v, w ); } /** Gibt die Knotenzahl zurueck. */ public int nodeSize( ) { return N; } /** Gibt die Kantenzahl zurueck. */ public int edgeSize( ) { int e = 0; for( int v = 0; v < N; v++ ) for( int w = 0; w < N; w++ ) e += isEdge( v, w ) ? 1 : 0; return e; } /** Gibt true zurueck, wenn eine Kante zwischen Knoten v und w vorhanden ist, * sonst false. Falls nicht 0 <= v, w < N gilt, wird eine Ausnahme ausgeloest. */ public boolean isEdge( int v, int w ) { if( v < 0 | | w < 0 | | v >= N | | w >= N ) throw new IllegalArgumentException( "Knoten nicht vorhanden." ); return matrix[v][w]; } /** Fuegt eine Kante zwischen Knoten v und w ein. * Falls nicht 0 <= v, w < N gilt, wird eine Ausnahme ausgeloest. */ public void insertEdge( int v, int w ) { if( v < 0 | | w < 0 | | v >= N | | w >= N ) throw new IllegalArgumentException( "Knoten nicht vorhanden." ); matrix[v][w] = true; } /** Entfernt die Kante zwischen Knoten v und w, falls vorhanden. * Falls nicht 0 <= v, w < N gilt, wird eine Ausnahme ausgeloest. */ DirectedGraph DirectedGraph nodeSize edgeSize isEdge insertEdge 4.1. GERICHTETE GRAPHEN 68 69 70 71 72 153 public void deleteEdge( int v, int w ) { if( v < 0 | | w < 0 | | v >= N | | w >= N ) throw new IllegalArgumentException( "Knoten nicht vorhanden." ); matrix[v][w] = false; } deleteEdge 73 74 75 76 77 78 79 80 /** Gibt den String s n-fach konkateniert zurueck. */ private String conc( String s, int n ) { StringBuffer out = new StringBuffer( ); for( int i = 0; i < n; i++ ) out.append( s ); return out.toString( ); } conc 81 82 83 84 85 /** Gibt die Zahl i als String der Laenge n rechtsbuendig zurueck. */ private String print( int i, int n ) { return conc( " ", n − Integer.toString( i ).length( ) ) + i; } print 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 /** Gibt eine String-Darstellung des Graphen zurueck. */ public String toString( ) { StringBuffer out = new StringBuffer( ); int n = Integer.toString( N ).length( ); // die Laenge der Darstellung von N // Die Spaltenueberschrift: out.append( conc( " ", n+2 ) + "|" ); for( int v = 0; v < N; v++ ) out.append( print( v, n+1 ) ); out.append( "\n" + conc( "_", n+2 ) + "|" + conc( "_", N*(n+1) ) + "\n" ); // Die Matrix: for( int v = 0; v < N; v++ ) { out.append( print( v, n+1 ) + " |" ); for( int w = 0; w < N; w++ ) out.append( print( ( isEdge( v, w ) ? 1 : 0 ), n+1 ) ); out.append( "\n" ); } return out.toString( ); } 105 106 } // class DirectedGraph Viele Probleme für Graphen lassen sich leicht lösen, wenn die betrachteten Graphen durch Adjazenzmatrizen dargestellt sind. Allerdings benötigt man für das Lesen der Eingabe dann schon O(n2 ) Schritte, unabhängig davon, wieviele Kanten der jeweilige Graph enthält. Für Graphen mit wenigen Kanten empfiehlt sich daher eine andere Art der Speicherung. Definition 4.1.11. Implementierung von Graphen durch Adjazenzlisten: Sei G = (V, E) mit V = {v0 , . . . , vn−1 } ein gerichteter Graph. Wir stellen nun die Zeilen der Adjazenzmatrix für G durch verkettete Listen dar. Für 0 ≤ i < n gibt es eine Liste, die für jede von vi ausgehende Kante einen Knoten enthält, welcher toString 154 KAPITEL 4. GRAPHEN UND GRAPH-ALGORITHMEN das Ziel der jeweiligen Kante angibt. Außerdem gibt es für jede dieser Listen einen Kopfknoten der auf diese Liste verweist. Die Kopfknoten sind sequentiell angeordnet. Beispiel 4.1.12. (Fortsetzung von Beispiel 4.1.2) Die Adjazenzlisten für G1 : 0 b 1 1 ab 0 2 " "" 2 " "" !! !! ! ! Bemerkung 4.1.13. • Bei Graphen mit n Knoten und e Kanten entstehen n + e Listenknoten, der Platzbedarf ist daher O((n + e) · log n) Bits. Für e n2 spart man mit der Darstellung durch Adjazenzlisten also Platz. • Für 0 ≤ i < n ist d− (vi ) die Länge der vi zugeordneten Liste. Die Bestimmung von d+ (vi ) ist wesentlich aufwändiger! • Um zu prüfen, ob die Kante (vi , vj ) im Graphen G enthalten ist, muss man die vi zugeordnete Liste durchsuchen: Zeitbedarf O(d− (vi )). • Die Repräsentation von Graphen durch Adjazenzlisten erlaubt auch Graphen mit Mehrfachkanten, ebenso allgemeinere Gewichtungsfunktionen c : E → R. Programm 4.1.14. Eine Implementierung gerichteter Graphen mit Gewichtsfunktion über Adjazenzlisten: import java.util.LinkedList; import java.util.Iterator; 3 import java.util.Random; 1 2 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 /** Die Klasse WeightedDirectedGraph implementiert gerichtete Graphen mit * Gewichtungsfunktion ueber Adjazenzlisten. Knoten sind Zahlen >= 0 vom * Typ int, Gewichte sind Zahlen >= 0 vom Typ double. * Mehrfachkanten sind zugelassen (auch mit demselben Gewicht). */ public class WeightedDirectedGraph { /** Die Anzahl der Knoten des Graphen. * Knoten sind Zahlen v mit 0 <= v < N. */ private final int N; /** Repraesentiert unendliches Gewicht. */ private static final double INFINITE = Double.MAX VALUE; /** Die Klasse Edge implementiert Kanten des gerichteten Graphen. * Kanten sind Paare aus Zielknoten und Gewicht. Der Startknoten der Kante * ist implizit durch die Zugehoerigkeit zu einer Adjazenzliste bestimmt. */ 4.1. GERICHTETE GRAPHEN 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 public class Edge implements Comparable { /** Der Zielknoten der Kante. */ private int destination; /** Das Gewicht der Kante. */ private double weight; /** Konstruiert eine Kante mit Zielknoten destination und Gewicht weight. * Falls nicht 0 <= destination < N und weight >= 0 gilt, wird eine * Ausnahme ausgeloest. */ public Edge( int destination, double weight ) { if( destination < 0 | | destination >= nodeSize( ) ) throw new IllegalArgumentException( "Knoten nicht vorhanden." ); if( weight < 0 ) throw new IllegalArgumentException( "Gewicht ist negativ." ); this.destination = destination; this.weight = weight; } /** Kanten werden bezueglich ihres Gewichts verglichen. */ public int compareTo( Object otherEdge ) { double otherWeight = ( (Edge)otherEdge ).weight; return weight < otherWeight ? −1 : weight > otherWeight ? +1 : 0; } /** Gibt eine String-Darstellung der Kante zurueck. */ public String toString( ) { return "(" + destination + ", " + weight + ")"; } 56 57 } // class Edge 58 59 /** Die Adjazenzlisten des Graphen. Sie enthalten Objekte vom Typ Edge. */ private LinkedList[ ] adjacencyList; 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 155 /** Konstruiert einen Graphen mit n Knoten und keiner Kante. */ public WeightedDirectedGraph( int n ) { N = n; adjacencyList = new LinkedList[N]; for( int v = 0; v < N; v++ ) adjacencyList[v] = new LinkedList( ); } /** Konstruiert einen Zufallsgraphen mit n Knoten ohne Mehrfachkanten. * Jede Kante ist mit Wahrscheinlichkeit p vorhanden. Das Gewicht einer * Kante ist als Zahl vom Typ int gleichverteilt aus [0, maxWeight) gewaehlt. * (Beachte, dass die entstehenden Adjazenzlisten bzgl. Knotennummern * aufsteigend sortiert sind.) */ Edge compareTo toString WeightedDirectedGraph 156 75 76 77 78 79 80 81 KAPITEL 4. GRAPHEN UND GRAPH-ALGORITHMEN public WeightedDirectedGraph( int n, double p, double maxWeight ) { this( n ); for( int v = 0; v < N; v++ ) for( int w = 0; w < N; w++ ) if( Math.random( ) < p ) insertEdge( v, w, (int)( Math.random( ) * maxWeight ) ); } WeightedDirectedGraph 82 83 84 85 86 /** Gibt die Knotenzahl zurueck. */ public int nodeSize( ) { return N; } nodeSize 87 88 89 90 91 92 93 94 /** Gibt die Kantenzahl zurueck. */ public int edgeSize( ) { int e = 0; for( int v = 0; v < N; v++ ) e += adjacencyList[v].size( ); return e; } edgeSize 95 96 97 98 99 100 101 102 103 /** Fuegt eine Kante zwischen Knoten v und w mit Gewicht weight ein. Falls * nicht 0 <= v,w < N und weight >= 0 gilt, wird eine Ausnahme ausgeloest. */ public void insertEdge( int v, int w, double weight ) { if( v < 0 | | v >= N ) throw new IllegalArgumentException( "Knoten nicht vorhanden." ); adjacencyList[v].add( new Edge( w, weight ) ); } insertEdge die Methoden conc und print wie in Programm 4.1.10 118 119 120 121 122 123 124 125 126 127 128 129 130 /** Gibt eine String-Darstellung des Graphen zurueck. */ public String toString( ) { StringBuffer out = new StringBuffer( ); int n = Integer.toString( N ).length( ); // die Laenge der Darstellung von N for( int v = 0; v < N; v++ ) { out.append( print( v, n+1 ) + " : " ); Iterator it = adjacencyList[v].iterator( ); while( it.hasNext( ) ) out.append( it.next( ) + " -> " ); out.append( "null\n" ); } return out.toString( ); } toString 131 132 133 134 135 public static void main( String[ ] args ) { WeightedDirectedGraph g = new WeightedDirectedGraph( 12, 0.2, 100 ); System.out.println( "\nEin Zufallsgraph:\n\n" + g ); } 136 137 } // class WeightedDirectedGraph main 4.1. GERICHTETE GRAPHEN 157 Ein Testlauf: Ein Zufallsgraph: 0 1 2 3 4 5 6 7 8 9 10 11 : : : : : : : : : : : : (1, 71.0) -> (7, 98.0) -> (10, 78.0) -> null (6, 55.0) -> (7, 33.0) -> null (0, 17.0) -> (10, 86.0) -> (11, 27.0) -> null (3, 66.0) -> (7, 75.0) -> (10, 33.0) -> null null (1, 81.0) -> null (2, 61.0) -> (8, 86.0) -> null (2, 88.0) -> (9, 40.0) -> null (1, 71.0) -> (2, 62.0) -> (3, 60.0) -> null (2, 4.0) -> null (6, 53.0) -> null (1, 24.0) -> (5, 26.0) -> (8, 13.0) -> (9, 0.0) -> null Muss man oft auf alle die Knoten zugreifen, von denen aus eine Kante zu einem festen Knoten führt, so speichert man für jeden Knoten eine zusätzliche Liste. Definition 4.1.15. Implementierung von Graphen durch Adjazenzlisten und inverse Adjazenzlisten: Sei G = (V, E) mit V = {v0 , . . . , vn−1 } ein gerichteter Graph. Außer den Adjazenzlisten für G nehmen wir nun auch noch verkettete Listen zur Darstellung der Spalten der Adjazenzmatrix von G. Für 0 ≤ i < n gibt es eine Liste, die für jede in vi endende Kante einen Knoten enthält, welcher den Start der jeweiligen Kante angibt. Außerdem gibt es für jede dieser Listen einen Kopfknoten, welche wieder sequentiell angeordnet werden. Beispiel 4.1.16. (Forts. Beispiel 4.1.2) Die inversen Adjazenzlisten für G1 : 0 b 1 1 ab 0 2 b 1 " "" " "" Stellt man einen gerichteten Graphen durch Adjazenzlisten und inverse Adjazenzlisten dar, so werden für jede auftretende Kante zwei Listenknoten angelegt. Oftmals will man beim Durchsuchen eines Graphen alle bereits benutzten Kanten markieren, was bei dieser Art der Darstellung aufwändig ist. Um diesem Problem abzuhelfen, kann man beide Listenstrukturen mit Hilfe gemeinsamer Knoten als Multilisten speichern. Definition 4.1.17. Implementierung von Graphen durch Adjazenzmultilisten: Sei G = (V, E) mit V = {v0 , . . . , vn−1 } ein gerichteter Graph. Für jeden Knoten vi implementieren wir die Adjazenzliste und die inverse Adjazenzliste wie folgt. Wieder gibt es für jede dieser Listen einen Kopfknoten. Für jede Kante (vi , vj ) wird nun ein Knoten angelegt, der dann sowohl in der Adjazenzliste von vi 158 KAPITEL 4. GRAPHEN UND GRAPH-ALGORITHMEN als auch in der inversen Adjazenzliste von vj auftritt. Dazu führen wir einen Knotentyp mit vier Feldern ein, je ein Feld für Start- und Zielknoten und je ein Feld für den Verweis auf den in der jeweiligen Liste nächsten Knoten: start ziel slink zlink Beispiel 4.1.18. (Forts. von Beispiel 4.1.2) Die Adjazenzmultilisten für G1 : 0 b 1 ab 0 1 2 b b 0 b % % % % " 1 "" 1 0 """ ! !! 2 ! !! 1 2 Tragen die Knoten eines Graphen zusätzlich Information, so kann man diese entweder in den Kopfknoten der Adjazenzlisten abspeichern, oder man führt in den Kopfknoten noch zusätzliche Verweise auf die gespeicherte Information ein. Tragen die Kanten eines Graphen zusätzliche Information (z.B. Gewichte), so kann man diese analog entweder in den Listenknoten der Adjazenzlisten speichern, oder man hält in den Knoten Verweise auf die gespeicherte Information. 4.1.1 Traversieren von Graphen Als erstes befassen wir uns mit der folgenden Aufgabe: Sei G ein gerichteter Graph, und sei v ein Knoten. Bestimme die Menge aller Knoten, die von v aus erreichbar sind! Ausgehend vom Knoten v müssen wir den Graph G systematisch durchlaufen, um alle erreichbaren Knoten zu bestimmen. Für diese Aufgabe werden wir zwei Lösungen betrachten, Tiefensuche (engl. depth-first search) und Breitensuche (engl. breadth-first search). Algorithmus 4.1.19. Traversieren eines Graphen mittels Tiefensuche: Die Strategie ist, ausgehend vom Startknoten so tief wie möglich in den Graphen einzudringen. Besuchte Knoten werden markiert. Hat ein Knoten u noch unmarkierte Nachbarn, so besuchen wir als nächstes einen dieser Nachbarn; hat u keine unmarkierten Nachbarn mehr, so gehen wir den bisher zurückgelegten Weg soweit zurück, bis wir zu einem Knoten gelangen, der noch unmarkierte Nachbarn hat. 4.1. GERICHTETE GRAPHEN 159 Zur Vereinfachung wählen wir V = {0, . . . , n−1} und erweitern die Darstellung gerichteter Graphen aus Programm 4.1.10. Zur Markierung besuchter Knoten verwenden wir das Array besucht über boolean, wobei besucht[i] = true bedeutet, dass der i-te Knoten schon besucht worden ist. Zuerst stellen wir eine rekursive Methode vor, die die Liste der von Knoten v aus erreichbaren Knoten in der durch eine Tiefensuche erzeugten Reihenfolge zurückgibt. 1 2 3 4 5 6 7 8 9 10 11 12 LinkedList depthFirstSearchRec( int v ) { return dfs( v, new boolean[N], new LinkedList( ) ); } private LinkedList dfs( int v, boolean[ ] besucht, LinkedList knotenListe ) { besucht[ v ] = true; knotenListe.add( new Integer( v ) ); for( int w = 0; w < N; w++ ) if( isEdge( v, w ) && !besucht[ w ] ) dfs( w, besucht, knotenListe ); return knotenListe; } Beispiel 4.1.20. Der folgende Graph hat die Knotenmenge {0, . . . , 7}. 89:; ?>=< 0 NNN NNN ppp p p NNN p p p N' wpp 89:; ?>=< 89:; ?>=< 1= 2 ^= = == = == == 89:; ?>=< 89:; ?>=< 89:; ?>=< 89:; ?>=< 3 Ti TTT 4= 5 jjj 6 TTTT j == @ j j TTTT = jj TTTT= jjjjjjj j u 89:; ?>=< 7 Diese Knoten werden in der Reihenfolge 0, 1, 3, 4, 7, 5, 2 besucht und markiert; ferner sehen wir, dass Knoten 6 von Knoten 0 aus nicht erreichbar ist. 89:; ?>=< @0 == == = 89:; ?>=< 89:; ?>=< 2 @1= ^=== = = == == ?>=< 89:; ?>=< 89:; 3 4 O 89:; ?>=< 7 O 89:; ?>=< 5 depthFirstSearchRec dfs 160 KAPITEL 4. GRAPHEN UND GRAPH-ALGORITHMEN Bemerkung 4.1.21. Der Aufwand der Tiefensuche hängt von der Darstellung des Graphen ab. • Darstellung durch eine Adjazenzmatrix: Zur Bestimmung der Nachbarn von v muss die v-te Zeile der Matrix durchlaufen werden; insgesamt sind höchstens n Aufrufe von dfs möglich. Der Algorithmus bestimmt also in Zeit O(n2 ) die Menge der vom Startknoten aus erreichbaren Knoten. • Darstellung durch Adjazenzlisten: Zur Bestimmung der Nachbarn von v muss die Adjazenzliste zu v durchlaufen werden, d.h. es werden alle e Kanten angeschaut. Insgesamt sind min(n, e) Aufrufe von dfs möglich, der Algorithmus bestimmt also in Zeit O(e) die Menge der vom Startknoten aus erreichbaren Knoten. Die folgende Variante der Tiefensuche ist nicht rekursiv und verwendet einen Keller zur Organisation der Suche. Man beachte, dass das Ergebnis wegen der unterschiedliche Markierungsstrategien vom Ergebnis der Methode depthFirstSearchRec abweichen kann, vgl. den Testlauf zu Programm 4.1.26. 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 LinkedList depthFirstSearch( int v ) { boolean[ ] besucht = new boolean[N]; LinkedList knotenListe = new LinkedList( ); Stack knotenKeller = new Stack( ); knotenKeller.push( new Integer( v ) ); besucht[ v ] = true; while( !knotenKeller.isEmpty( ) ) { int u = ( (Integer)knotenKeller.topAndPop( ) ).intValue( ); knotenListe.add( new Integer( u ) ); for( int w = N−1; w >= 0; w−− ) if( isEdge( u, w ) && !besucht[ w ] ) { knotenKeller.push( new Integer( w ) ); besucht[ w ] = true; } } return knotenListe; } Beispiel 4.1.22. (Fortsetzung von Beispiel 4.1.20) Die Knoten werden in diesem Beispiel tatsächlich in derselben Reihenfolge wie zuvor besucht: aktueller Knoten u – 0 1 3 4 7 5 2 als besucht markiert {0} {0, 1, 2} {0, 1, 2, 3, 4} {0, 1, 2, 3, 4} {0, 1, 2, 3, 4, 7} {0, 1, 2, 3, 4, 7, 5} {0, 1, 2, 3, 4, 7, 5} {0, 1, 2, 3, 4, 7, 5} der Keller (0) (1, 2) (3, 4, 2) (4, 2) (7, 2) (5, 2) (2) () depthFirstSearch 4.1. GERICHTETE GRAPHEN 161 Algorithmus 4.1.23. Traversieren eines Graphen mittels Breitensuche: Hier ist die Strategie, ausgehend vom Startknoten für jeden besuchten Knoten als nächstes alle seine Nachbarn zu besuchen. Besuchte Knoten werden wieder markiert. Ist u der aktuelle Knoten, so werden alle Nachbarn von u, die noch nicht besucht worden sind, in eine Schlange aufgenommen. Als nächstes wird dann der Knoten besucht, der an der Spitze der Schlange steht; dabei wird dieser aus der Schlange entfernt. Die Methode breadthFirstSearch ist nicht rekursiv und verwendet eine Schlange zur Organisation der Suche. Sie gibt die Liste der von Knoten v aus erreichbaren Knoten in der durch eine Breitensuche erzeugten Reihenfolge zurück. 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 LinkedList breadthFirstSearch( int v ) { boolean[ ] besucht = new boolean[N]; LinkedList knotenListe = new LinkedList( ); Queue knotenSchlange = new Queue( ); knotenSchlange.enqueue( new Integer( v ) ); besucht[ v ] = true; while( !knotenSchlange.isEmpty( ) ) { int u = ( (Integer)knotenSchlange.frontAndDequeue( ) ).intValue( ); knotenListe.add( new Integer( u ) ); for( int w = 0; w < N; w++ ) if( isEdge( u, w ) && !besucht[ w ] ) { knotenSchlange.enqueue( new Integer( w ) ); besucht[ w ] = true; } } return knotenListe; } Bemerkung 4.1.24. Der Aufwand der Breitensuche hängt wieder von der Darstellung des Graphen ab. Jeder Knoten wird höchstens einmal in die Schlange aufgenommen. Wird ein Knoten entfernt, so werden alle seine Nachbarn überprüft und dann gegebenenfalls in die Schlange aufgenommen. Die Menge der erreichbaren Knoten kann also wie zuvor in Zeit O(n2 ) bzw. Zeit O(e) bestimmt werden, je nachdem, ob Adjazenzmatrizen oder Adjazenzlisten verwendet werden. Beispiel 4.1.25. (Fortsetzung von Beispiel 4.1.20) Im Beispiel werden die Knoten in der Reihenfolge 0, 1, 2, 3, 4, 5, 7 besucht: aktueller Knoten u – 0 1 2 3 4 5 7 als besucht markiert {0} {0, 1, 2} {0, 1, 2, 3, 4} {0, 1, 2, 3, 4, 5} {0, 1, 2, 3, 4, 5} {0, 1, 2, 3, 4, 5, 7} {0, 1, 2, 3, 4, 5, 7} {0, 1, 2, 3, 4, 5, 7} die Schlange (0) (1, 2) (2, 3, 4) (3, 4, 5) (4, 5) (5, 7) (7) () breadthFirstSearch 162 KAPITEL 4. GRAPHEN UND GRAPH-ALGORITHMEN Programm 4.1.26. Eine Implementierung obiger Traversierungsstrategien: wie in Programm 4.1.10 75 76 77 78 79 80 81 82 83 /** Gibt die Liste der von Knoten v aus erreichbaren Knoten * in der durch eine Tiefensuche erzeugten Reihenfolge zurueck. * Falls nicht 0 <= v < N gilt, wird eine Ausnahme ausgeloest. */ public LinkedList depthFirstSearchRec( int v ) { if( v < 0 | | v >= N ) throw new IllegalArgumentException( "Knoten nicht vorhanden." ); return dfs( v, new boolean[N], new LinkedList( ) ); } depthFirstSearchRec 84 85 86 87 88 89 90 91 92 93 /** Eine rekursive Hilfsmethode fuer die Methode depthFirstSearchRec. */ private LinkedList dfs( int v, boolean[ ] besucht, LinkedList knotenListe ) { besucht[ v ] = true; knotenListe.add( new Integer( v ) ); for( int w = 0; w < N; w++ ) if( isEdge( v, w ) && !besucht[ w ] ) dfs( w, besucht, knotenListe ); return knotenListe; } dfs 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 /** Gibt die Liste der von Knoten v aus erreichbaren Knoten mit * aufsteigenden Knotennummern zurueck. * Falls nicht 0 <= v < N gilt, wird eine Ausnahme ausgeloest. */ public LinkedList accessibleNodes( int v ) { if( v < 0 | | v >= N ) throw new IllegalArgumentException( "Knoten nicht vorhanden." ); boolean[ ] besucht = dfs( v, new boolean[N] ); LinkedList knotenListe = new LinkedList( ); for( int i = 0; i < N; i++ ) if( besucht[i] ) knotenListe.add( new Integer( i ) ); return knotenListe; } accessibleNodes 109 110 111 112 113 114 115 116 117 /** Eine rekursive Hilfsmethode fuer die Methode accessibleNodes. */ private boolean[ ] dfs( int v, boolean[ ] besucht ) { besucht[ v ] = true; for( int w = 0; w < N; w++ ) if( isEdge( v, w ) && !besucht[ w ] ) dfs( w, besucht ); return besucht; } 118 119 120 121 122 123 124 125 126 /** Gibt die Liste der von Knoten v aus erreichbaren Knoten * in der durch eine Tiefensuche erzeugten Reihenfolge zurueck. * Falls nicht 0 <= v < N gilt, wird eine Ausnahme ausgeloest. * Die Methode ist nicht rekursiv und verwendet einen Keller zur * Organisation der Suche. Beachte, dass das Ergebnis wegen der * unterschiedliche Markierungsstrategien vom Ergebnis der Methode * depthFirstSearchRec abweichen kann. */ dfs 4.1. GERICHTETE GRAPHEN 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 public LinkedList depthFirstSearch( int v ) { if( v < 0 | | v >= N ) throw new IllegalArgumentException( "Knoten nicht vorhanden." ); boolean[ ] besucht = new boolean[N]; LinkedList knotenListe = new LinkedList( ); Stack knotenKeller = new Stack( ); knotenKeller.push( new Integer( v ) ); besucht[ v ] = true; while( !knotenKeller.isEmpty( ) ) { int u = ( (Integer)knotenKeller.topAndPop( ) ).intValue( ); knotenListe.add( new Integer( u ) ); for( int w = N−1; w >= 0; w−− ) if( isEdge( u, w ) && !besucht[ w ] ) { knotenKeller.push( new Integer( w ) ); besucht[ w ] = true; } } return knotenListe; } /** Gibt die Liste der von Knoten v aus erreichbaren Knoten * in der durch eine Breitensuche erzeugten Reihenfolge zurueck. * Falls nicht 0 <= v < N gilt, wird eine Ausnahme ausgeloest. * Die Methode ist nicht rekursiv und verwendet eine Schlange zur * Organisation der Suche. */ public LinkedList breadthFirstSearch( int v ) { if( v < 0 | | v >= N ) throw new IllegalArgumentException( "Knoten nicht vorhanden." ); boolean[ ] besucht = new boolean[N]; LinkedList knotenListe = new LinkedList( ); Queue knotenSchlange = new Queue( ); knotenSchlange.enqueue( new Integer( v ) ); besucht[ v ] = true; while( !knotenSchlange.isEmpty( ) ) { int u = ( (Integer)knotenSchlange.frontAndDequeue( ) ).intValue( ); knotenListe.add( new Integer( u ) ); for( int w = 0; w < N; w++ ) if( isEdge( u, w ) && !besucht[ w ] ) { knotenSchlange.enqueue( new Integer( w ) ); besucht[ w ] = true; } } return knotenListe; } 163 depthFirstSearch breadthFirstSearch die Methoden conc, print und toString wie in Programm 4.1.10 205 206 207 208 209 210 public static void main( String[ ] args ) { DirectedGraph b = new DirectedGraph( 8 ); b.insertEdge( 0, 1 ); b.insertEdge( 0, 2 ); b.insertEdge( 1, 3 ); b.insertEdge( 1, 4 ); b.insertEdge( 2, 5 ); b.insertEdge( 4, 7 ); b.insertEdge( 6, 2 ); b.insertEdge( 6, 7 ); b.insertEdge( 7, 3 ); b.insertEdge( 7, 5 ); main 164 KAPITEL 4. GRAPHEN UND GRAPH-ALGORITHMEN System.out.println( "Der Graph aus Beispiel 4.1.20:\n\n" + b + "\nVon Knoten 0 aus erreichbare Knoten:" + "\nTiefensuche1: " + b.depthFirstSearchRec( 0 ) + "\nTiefensuche2: " + b.depthFirstSearch( 0 ) + "\nBreitensuche: " + b.breadthFirstSearch( 0 ) + "\nKnotenliste: " + b.accessibleNodes( 0 ) ); 211 212 213 214 215 216 217 DirectedGraph g = new DirectedGraph( 5, 0.2 ); System.out.println( "\nEin Zufallsgraph:\n\n" + g + "\nVon Knoten 0 aus erreichbare Knoten:" + "\nTiefensuche1: " + g.depthFirstSearchRec( 0 ) + "\nTiefensuche2: " + g.depthFirstSearch( 0 ) + "\nBreitensuche: " + g.breadthFirstSearch( 0 ) + "\nKnotenliste: " + g.accessibleNodes( 0 ) ); 218 219 220 221 222 223 224 225 } 226 227 } // class DirectedGraph Ein Testlauf: Der Graph aus Beispiel 4.1.20: | 0 1 2 3 4 5 6 7 ___|________________ 0 | 0 1 1 0 0 0 0 0 1 | 0 0 0 1 1 0 0 0 2 | 0 0 0 0 0 1 0 0 3 | 0 0 0 0 0 0 0 0 4 | 0 0 0 0 0 0 0 1 5 | 0 0 0 0 0 0 0 0 6 | 0 0 1 0 0 0 0 1 7 | 0 0 0 1 0 1 0 0 Von Knoten 0 aus erreichbare Knoten: Tiefensuche1: [0, 1, 3, 4, 7, 5, 2] Tiefensuche2: [0, 1, 3, 4, 7, 5, 2] Breitensuche: [0, 1, 2, 3, 4, 5, 7] Knotenliste: [0, 1, 2, 3, 4, 5, 7] Ein Zufallsgraph: | 0 1 2 3 4 ___|__________ 0 | 0 1 0 1 0 1 | 0 0 1 0 1 2 | 0 0 0 1 0 3 | 0 0 0 0 0 4 | 0 0 0 0 0 Von Knoten 0 aus erreichbare Knoten: Tiefensuche1: [0, 1, 2, 3, 4] Tiefensuche2: [0, 1, 2, 4, 3] Breitensuche: [0, 1, 3, 2, 4] Knotenliste: [0, 1, 2, 3, 4] 4.1. GERICHTETE GRAPHEN 4.1.2 165 Kürzeste Wege Hier wollen wir uns dem Problem zuwenden, kürzeste Wege“ in einem gewich” teten Graphen zu bestimmen. Definition 4.1.27. Sei G = (V, E) ein gerichteter Graph, und sei c : E → R+ eine Gewichtungsfunktion. Wir erweitern Gewichtungsfunktion von Kanten Pdie r−1 auf Wege p = (u0 , . . . , ur ) durch c(p) = i=0 c((ui , ui+1 )). Ferner zeichnen wir einen Knoten v0 ∈ V als Startknoten aus. Für v ∈ V sei dann die Distanz zu v0 der Wert dist(v) = min{c(p) | p ist ein Weg von v0 nach v}, falls es einen Weg von v0 nach v gibt; andernfalls setzen wir dist(v) = ∞. Ein Weg p von v0 nach v mit c(p) = dist(v) ist ein kürzester Weg von v0 nach v. Wir suchen nun einen Algorithmus, der für alle Knoten v den Wert dist(v) bestimmt, und der für Knoten v mit dist(v) < ∞ einen kürzesten Weg von v0 nach v liefert. Bemerkung 4.1.28. Ist c(e) = 1 für alle e ∈ E, so ist dist(v) gerade die Anzahl der Kanten in einem kürzesten Weg von v0 nach v. Da der Algorithmus breadthFirstSearch ausgehend von v0 die Knoten von G gerade in der Reihenfolge ihres Abstands von v0 besucht, können wir in diesem Fall eine Variante des genannten Algorithmus zur Bestimmung der Abstände benutzen. Sei nun G = (V, E), c : E → R+ und v0 ∈ V wie oben. Wir definieren Teilmengen S, T ⊆ V , die in unserem Algorithmus zur Bestimmung der Abstände verwendet werden: S = {v ∈ V | dist(v) ist bereits bestimmt}, T = {w ∈ V \ S | ∃v ∈ S : (v, w) ∈ E}. Am Anfang wird S = {v0 } gesetzt, und T besteht gerade aus den Zielknoten aller Kanten mit Start v0 . Seien nun zu einem Zeitpunkt S und T wie oben. Man wählt nun einen Knoten w ∈ T so aus, dass folgende Bedingung für alle Knoten z ∈ T erfüllt ist: min{dist(v) + c((v, w))} ≤ min{dist(v) + c((v, z))}. v∈S v∈S Dann kann w in S aufgenommen und aus T entfernt werden, und alle Zielknoten von Kanten mit Start w, die nicht in S liegen, werden in T aufgenommen. Die Korrektheit dieser Wahl ist der Gegenstand des folgenden Lemmas. Lemma 4.1.29. Mit den obigen Bezeichnungen gilt dist(w) = min{dist(v) + c((v, w))}. v∈S 166 KAPITEL 4. GRAPHEN UND GRAPH-ALGORITHMEN Beweis: Ist v ∈ S, so gibt es einen kürzesten Weg von v0 nach v, der nur Knoten in S durchläuft. Sei nun v ∈ S mit dist(v) + c((v, w)) = min{dist(x) + c((x, w))}, x∈S und sei (v0 , v1 , . . . , vm , v) ein kürzester Weg von v0 nach v mit v1 , . . . , vm ∈ S. Wir behaupten, dass (v0 , v1 , . . . , vm , v, w) ein kürzester Weg von v0 nach w ist. Sei etwa (v0 , x1 , . . . , xk , w) ein weiterer Weg von v0 nach w. Da v0 ∈ S und w 6∈ S sind, gibt es einen kleinsten Index i, 1 ≤ i ≤ k + 1, mit xi−1 ∈ S und xi 6∈ S, wobei wir x0 = v0 und xk+1 = w setzen. Dann ist xi ∈ T , und es gilt k X c((xj , xj+1 )) = dist(xi−1 ) + c((xi−1 , xi )) + j=0 k X c((xj , xj+1 )) j=i ≥ dist(xi−1 ) + c((xi−1 , xi )) ≥ dist(v) + c((v, w)) (nach Wahl von w). Also ist (v0 , v1 , . . . , vm , v, w) tatsächlich ein kürzester Weg von v0 nach w, d.h. es gilt dist(w) = min{dist(v) + c((v, w))}. v∈S Dieses Ergebnis liefert die Strategie für den folgenden Algorithmus. In jedem Schritt wird ein Knoten w wie beschrieben gewählt. Zur Bestimmung eines derartigen Knotens mit minimaler Distanz speichern wir Knoten zusammen mit ihrem vorläufigen Distanzwert in einem Heap. Solche Paare werden bezüglich der Distanz geordnet; dafür eignet sich gerade der Typ Edge aus Programm 4.1.14. Zusätzlich wird für jeden Knoten w der aktuell bestimmte Wert dist(w) gespeichert, sowie der Knoten v = vorgänger(w) als Vorgänger von w auf dem aktuell gefundenen kürzesten Weg von v0 nach w. Zu Beginn setzen wir vorgänger(v) = −1 und dist(v) = ∞ für alle Knoten v. Die Menge T ist nur indirekt dargestellt: w ∈ V \ S gehört genau dann zu T , wenn dist(w) < ∞ ist. Immer wenn sich der Wert von dist(w) verringert, wird das Paar (w, dist(w)) in den Heap eingefügt. Das hat zur Folge, dass der Heap mehrere Paare mit derselben Knotenkomponente enthalten kann. Daher sind nicht immer alle Knotenkomponenten im Heap auch Elemente von T . (Alternativ kann man Heaps so modifizieren, dass nur Knoten statt derartige Paare abgelegt werden. Da aber nach Distanzen geordnet wird, muss dann jedesmal bei Verringerung eines Distanzwerts der entsprechende Knoten wieder an die richtige Stelle im Heap gebracht werden.) Dijkstras Algorithmus ist ein typisches Beispiel für einen Greedy“-Algorithmus ” (greedy: engl. gierig, gefräßig). Solche Algorithmen werden meist zur Lösung von Optimierungsproblemen eingesetzt, also zum Finden einer bezüglich einer Zielfunktion optimalen (minimalen, maximalen, . . . ) Lösung des gegebenen Problems. Ein Greedy-Algorithmus nähert sich schrittweise einer Lösung, wobei in jedem einzelnen Schritt die Zielfunktion optimiert wird; ein Zurücknehmen früherer Schritte (backtracking) ist nicht vorgesehen. Leider lassen sich mit dieser Lösungsstrategie nur wenige Optimierungsprobleme lösen; in anderen Fällen kommt man nicht umhin, viele mögliche Lösungen mehr oder weniger geschickt durchzuprobieren. 4.1. GERICHTETE GRAPHEN 167 Algorithmus 4.1.30. Dijkstras Algorithmus zur Bestimmung kürzester Wege: 1 2 3 4 5 6 7 8 shortestPaths( Knoten start ) { for all( v ∈ V ) { vorgänger(v) = −1; dist(v) = ∞; } dist(start) = 0; Heap heap = new Heap( |E| ); heap.einfuegenInHeap( (start, 0) ); 9 10 while( !heap.istLeer( ) ) { Knoten v = heap.loeschenAusHeap( ).zielknoten; if( v 6∈ S ) { S = S ∪ {v}; for all( (v, w) ∈ E ) { if( dist(w) > dist(v) + c((v, w)) ) { dist(w) = dist(v) + c((v, w)); vorgänger(w) = v; heap.einfuegenInHeap( (w, dist(w)) ); } } } } 11 12 13 14 15 16 17 18 19 20 21 22 23 shortestPaths } Beispiel 4.1.31. Gesucht sind kürzeste Wege zwischen Städten der USA: Chicago S.F. Denver 1200 1500 3 Boston 4 aa aa 1000 1 2 aa aa 250 " aa " a " 300 " 5 N.Y. " 1000 !! " ! " ! " !! 0 XXX 1400!! XXX 1700 ! XXX !! 900 L.A. ! XXX ! XXX !! 7 hh hhh1000 hhhh h 6 New Orleans 800 Miami ausgew. Knoten 4 5 6 3 7 2 1 0 S {} {4} {4, 5} {4, 5, 6} {4, 5, 6, 3} {4, 5, 6, 3, 7} {4, 5, 6, 3, 7, 2} {4, 5, 6, 3, 7, 2, 1} {4, 5, 6, 3, 7, 2, 1, 0} dist(0) dist(1) dist(2) dist(3) dist(4) dist(5) dist(6) dist(7) ∞ ∞ ∞ ∞ 0 ∞ ∞ ∞ ∞ ∞ ∞ 1500 0 250 ∞ ∞ ∞ ∞ ∞ 1250 0 250 1150 1650 ∞ ∞ ∞ 1250 0 250 1150 1650 ∞ ∞ 2450 1250 0 250 1150 1650 3350 ∞ 2450 1250 0 250 1150 1650 3350 3250 2450 1250 0 250 1150 1650 3350 3250 2450 1250 0 250 1150 1650 3350 3250 2450 1250 0 250 1150 1650 168 KAPITEL 4. GRAPHEN UND GRAPH-ALGORITHMEN Der Aufwand von Dijkstras Algorithmus hängt wieder von der Darstellung der Graphen ab. • Sind Graphen durch Adjazenzmatrizen implementiert, dann liefert eine einfache Implementierung ohne Verwendung der Datenstrukur Heap eine Laufzeit von O(n2 ). • Für dünne“ Graphen mit wenigen Kanten (|E| |V |2 ) kann der Zeitbe” darf noch verringert werden. Dafür verwenden wir die Graphdarstellung durch Adjazenzlisten. Außerdem speichern wir die Paare (w, dist(w)) wie oben beschrieben in einem Heap. Der Heap kann maximal die Größe |E| haben, und es werden maximal |E| Einfüge- bzw. Löschoperationen durchgeführt. Daher ist die Laufzeit in O(|E| log |E|); wegen |E| ≤ |V |2 gilt aber log |E| ≤ log |V |2 = 2 log |V |, also erhalten wir eine Laufzeit in O(|E| log |V |). (Bemerkung: Damit ist die Laufzeit höchstens um einen konstanten Faktor schlechter als bei Verwendung eines Heaps, der lediglich Knoten speichert, also maximal die Größe |V | hat.) Programm 4.1.32. Die folgende Implementierung von Dijkstras Algorithmus erweitert Programm 4.1.14, das gewichtete Graphen über Adjazenzlisten darstellt. Der Wert ∞ wird dabei durch double.MAX VALUE repräsentiert. Die Funktionen vorgänger und dist werden in den Arrays vorgaenger und distanz gespeichert. Dijkstras Algorithmus findet sich im Konstruktor der inneren Klasse ShortestPaths; dort ist die Knotenmenge S durch ein Boolesches Array inS realisiert. Heaps schließlich sind aus Programm 2.3.14 übernommen. wie in Programm 4.1.14 105 106 107 108 /** Die Klasse implementiert die kuerzesten Wege im Graphen * von einem Startknoten aus. */ public class ShortestPaths { 109 110 111 /** Der Startknoten. */ private int start; 112 113 114 115 116 117 /** Nach der Konstruktion ist vorgaenger[v] der Knoten, der auf den * gefundenen kuerzesten Wegen vor Knoten v liegt. Fuer den Startknoten * und fuer unerreichbare Knoten ist der Wert -1. */ private int[ ] vorgaenger = new int[ nodeSize( ) ]; 118 119 120 121 122 /** Nach der Konstruktion ist distanz[v] der Abstand von Knoten v * zum Startknoten. */ private double[ ] distanz = new double[ nodeSize( ) ]; 123 124 125 126 /** Dijkstras Algorithmus bestimmt die kuerzesten Wege * vom Startknoten start aus. */ nodeSize 4.1. GERICHTETE GRAPHEN 127 128 129 130 131 132 133 134 135 136 137 169 public ShortestPaths( int start ) { if( start < 0 | | start >= nodeSize( ) ) throw new IllegalArgumentException( "Startknoten nicht vorhanden." ); this.start = start; for( int v = 0; v < nodeSize( ); v++ ) { vorgaenger[v] = −1; distanz[v] = INFINITE; } // inS[v] ist true genau dann, wenn v in S ist; zu Beginn ist S leer. boolean[ ] inS = new boolean[ nodeSize( ) ]; distanz[ start ] = 0; 138 VollstaendigerBinaererBaum heap = new VollstaendigerBinaererBaum( edgeSize( ) ); heap.einfuegenInHeap( new Edge( start, 0 ) ); 139 140 141 142 while( !heap.istLeer( ) ) { int v = ( (Edge)heap.loeschenAusHeap( ) ).destination; if( !inS[v] ) { // falls v nicht in S ist: inS[v] = true; // v wird neues Element in S Iterator it = adjacencyList[v].iterator( ); while( it.hasNext( ) ) { Edge e = (Edge)it.next( ); int w = e.destination; double c = e.weight; if( distanz[w] > distanz[v] + c ) { distanz[w] = distanz[v] + c; vorgaenger[w] = v; heap.einfuegenInHeap( new Edge( w, distanz[w] ) ); } } } } 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 } 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 /** Gibt eine String-Darstellung der kuerzesten Wege * mit Startknoten start zurueck. */ public String toString( ) { StringBuffer out = new StringBuffer( ); out.append( "Distanzen zum Startknoten " + start + ":\n\n" ); for( int v = 0; v < nodeSize( ); v++ ) if( distanz[v] == INFINITE ) out.append( " " + v + " ist nicht erreichbar\n" ); else out.append( " dist(" + v + ") = " + distanz[v] + "\n" ); out.append( "\nKuerzeste Wege mit Startknoten " + start + ":\n\n" ); for( int v = 0; v < nodeSize( ); v++ ) { Stack path = new Stack( ); int w = v; do { path.push( new Integer( w ) ); w = vorgaenger[w]; } while( w != −1 ); toString 170 KAPITEL 4. GRAPHEN UND GRAPH-ALGORITHMEN if( distanz[v] == INFINITE ) out.append( " Ziel " + v + ": ist nicht erreichbar\n" ); else out.append( " Ziel " + v + ": " + path + "\n" ); 182 183 184 185 } return out.toString( ); 186 187 } 188 189 190 } // class ShortestPaths die Methoden conc, print und toString wie in Programm 4.1.14 220 221 222 223 224 225 226 227 228 229 public static void main( String[ ] args ) { WeightedDirectedGraph usa = new WeightedDirectedGraph( 8 ); usa.insertEdge( 1, 0, 300 ); usa.insertEdge( 2, 1, 800 ); usa.insertEdge( 2, 0, 1000 ); usa.insertEdge( 3, 2, 1200 ); usa.insertEdge( 4, 3, 1500 ); usa.insertEdge( 4, 5, 250 ); usa.insertEdge( 5, 3, 1000 ); usa.insertEdge( 5, 6, 900 ); usa.insertEdge( 5, 7, 1400 ); usa.insertEdge( 6, 7, 1000 ); usa.insertEdge( 7, 0, 1700 ); System.out.println( "Der Graph aus Beispiel 4.1.31:\n\n" + usa + "\n" + usa.new ShortestPaths( 4 ) ); 230 WeightedDirectedGraph g = new WeightedDirectedGraph( 10, 0.2, 100 ); System.out.println( "\nEin Zufallsgraph:\n\n" + g + "\n" + g.new ShortestPaths( 0 ) ); 231 232 233 234 } 235 236 } // class WeightedDirectedGraph Ein Testlauf: Der Graph aus Beispiel 4.1.31: 0 1 2 3 4 5 6 7 : : : : : : : : null (0, 300.0) -> null (1, 800.0) -> (0, 1000.0) -> null (2, 1200.0) -> null (3, 1500.0) -> (5, 250.0) -> null (3, 1000.0) -> (6, 900.0) -> (7, 1400.0) -> null (7, 1000.0) -> null (0, 1700.0) -> null Distanzen zum Startknoten 4: dist(0) dist(1) dist(2) dist(3) dist(4) dist(5) dist(6) dist(7) = = = = = = = = 3350.0 3250.0 2450.0 1250.0 0.0 250.0 1150.0 1650.0 main 4.1. GERICHTETE GRAPHEN Kuerzeste Wege mit Startknoten 4: Ziel Ziel Ziel Ziel Ziel Ziel Ziel Ziel 0: 1: 2: 3: 4: 5: 6: 7: [4, [4, [4, [4, [4] [4, [4, [4, 5, 5, 5, 5, 7, 0] 3, 2, 1] 3, 2] 3] 5] 5, 6] 5, 7] Ein Zufallsgraph: 0 1 2 3 4 5 6 7 8 9 : : : : : : : : : : (2, (0, (8, (7, (3, (2, (1, (9, (9, (3, 6.0) -> (3, 31.0) -> (4, 15.0) -> null 23.0) -> (5, 71.0) -> (7, 23.0) -> null 87.0) -> null 38.0) -> null 73.0) -> (4, 31.0) -> (8, 5.0) -> null 56.0) -> (7, 81.0) -> null 20.0) -> null 55.0) -> null 91.0) -> null 74.0) -> null Distanzen zum Startknoten 0: dist(0) = 0.0 1 ist nicht erreichbar dist(2) = 6.0 dist(3) = 31.0 dist(4) = 15.0 5 ist nicht erreichbar 6 ist nicht erreichbar dist(7) = 69.0 dist(8) = 20.0 dist(9) = 111.0 Kuerzeste Wege mit Startknoten 0: Ziel Ziel Ziel Ziel Ziel Ziel Ziel Ziel Ziel Ziel 0: 1: 2: 3: 4: 5: 6: 7: 8: 9: [0] ist [0, [0, [0, ist ist [0, [0, [0, nicht 2] 3] 4] nicht nicht 3, 7] 4, 8] 4, 8, erreichbar erreichbar erreichbar 9] 171 172 4.1.3 KAPITEL 4. GRAPHEN UND GRAPH-ALGORITHMEN Starke Komponenten Schließlich wollen wir noch das Problem lösen, die starken Komponenten eines gerichteten Graphen zu bestimmen. Zur Erinnerung: Eine starke Komponente eines gerichteten Graphen ist ein Teilgraph mit maximaler Knotenzahl, in dem alle Knoten paarweise stark verbunden sind, d.h. sind u und v zwei beliebige Knoten aus diesem Teilgraphen, dann gibt es einen Weg von u nach v und einen Weg von v nach u. Algorithmus 4.1.33. Ein Algorithmus zur Bestimmung der starken Komponenten eines gerichteten Graphen G: (1.) Führe Tiefensuche auf G durch. Dabei werden die Knoten in der Reihenfolge durchnummeriert, in der die rekursiven Aufrufe von dfs beendet werden. Werden nicht alle Knoten erreicht, so erfolgen weitere Aufrufe so lange, bis jeder Knoten erreicht und damit nummeriert worden ist. (2.) Der Graph G−1 entstehe aus G, indem jede Kante (u, v) durch die Kante (v, u) ersetzt wird. Führe Tiefensuche auf G−1 durch. Dabei wird jeweils mit dem Knoten begonnen, der unter allen noch nicht besuchten Knoten die höchste Nummer aus (1.) hat. Ist eine Erreichbarkeitskomponente in G−1 gefunden, dann werden ihre Knoten aus G−1 entfernt. Diese Knoten bilden gerade eine starke Komponente von G. Die Tiefensuche aus Algorithmus 4.1.19 ist also das entscheidende Hilfsmittel, um die starken Komponenten von G zu bestimmen. Ehe wir uns überlegen, dass obiger Algorithmus korrekt ist, schauen wir uns ein einfaches Beispiel an. Beispiel 4.1.34. Sei G der folgende Graph: @ABC @ABC ?>=< 89:; / GFED 4 E C `@ A 0W iGFED @@ iiiiiii O 00 i @ i 0 iii@@@ 00 iiiiii i 0 i @ABC GFED @ABC GFED @ABC GFED B @ 000 D Ao F O AA @@ 0 0 AA @@ 0 AA @@0 @ABC GFED @ABC GFED G H 89:; / ?>=< I 89:; / ?>=< J j + GFED @ABC K (1.) Tiefensuche auf G ergibt die folgenden Teilgraphen und die folgende Knotennummerierung: A : 5 UUUU UUUU UUUU * B : 3KK ss ysss E:1 KKK % G:2 D : 11 C:4 H : 10MM q F :6 q xqqq MMM & I:9 J :8 K:7 4.1. GERICHTETE GRAPHEN 173 (2.) Der inverse Graph G−1 : @ABC GFED @ABC ?>=< 89:; C@ E A0 o iGFED O 0 O @@ iiii i i i i 00 @ i i 00 iiiiiii @@@ i0i t i @ABC GFED @ABC GFED @ABC /GFED B @` 000 D A` F AA @@ 0 AA @@ 00 AA @@0 @ABC GFED @ABC GFED H o G Tiefensuche auf G−1 : • Startknoten D : 89:; ?>=< I o t ?>=< 89:; J @ABC GFED 3 K D F H d.h. die erste starke Komponente von G ist {D, F, H}. • Startknoten I : I d.h. die zweite starke Komponente von G ist {I}. • Startknoten J : J K d.h. die dritte starke Komponente von G ist {J, K}. • Startknoten A : A ~~ ~~~ G@ @@ @ B C d.h. die vierte starke Komponente von G ist {A, B, C, G}. • Startknoten E : E d.h. die fünfte starke Komponente von G ist {E}. Damit hat G die folgenden starken Komponenten: ?>=< 89:; @ABC / GFED C A 0W 00 0 00 0 @ABC GFED B @ 000 @@ 0 @@ 00 @@0 @ABC GFED G @ABC GFED E GFED @ABC @ABC GFED D Ao F O AA AA AA @ABC GFED H 89:; ?>=< I 89:; ?>=< J j + GFED @ABC K Lemma 4.1.35. Sei G = (V, E) ein gerichteter Graph mit |V | = n, und sei µ : V → {1, . . . , n} die in Algorithmus 4.1.33 (1.) erzeugte Knotennummerierung. Dann gilt: Zwei Knoten liegen in derselben starken Komponente von G genau dann, wenn sie in derselben Erreichbarkeitskomponente von G−1 liegen. 174 KAPITEL 4. GRAPHEN UND GRAPH-ALGORITHMEN Beweis: Seien v, w ∈ V , v 6= w. ⇒“: Liegen v und w in derselben starken Komponente von G, so gibt es ” Wege von v nach w und von w nach v in G, und damit auch in G−1 . Bei der Tiefensuche auf G−1 werde der Knoten v (o.B.d.A.) vor dem Knoten w besucht. Da es in G−1 einen Weg von v nach w gibt, wird w beim Aufruf dfs(v) in G−1 erreicht, d.h. v und w liegen in derselben Erreichbarkeitskomponente von G−1 . ⇐“: Sei x der Knoten, der als Startknoten diente, als beim Durchlaufen von ” −1 G die Komponente erzeugt wurde, die v und w enthält. Wir behaupten, dass es in G Wege von x nach v und von v nach x sowie Wege von x nach w und von w nach x gibt, woraus dann folgt, dass v und w in derselben starken Komponente von G liegen. Natürlich reicht es, den Beweis für den Knoten v zu führen, wobei wir v 6= x annehmen. Weil v in der Erreichbarkeitskomponente von x in G−1 liegt, gibt es in G−1 einen Weg von x nach v, somit gibt es in G einen Weg von v nach x. Weiterhin gilt µ(x) > µ(v), denn v war beim Aufruf von dfs(x) in (2.) noch nicht besucht. Also terminiert dfs(v) in Schritt (1.) vor dfs(x). Für die zeitliche Abfolge der Aufrufe und ihrer Beendigungen von dfs in (1.) gibt es damit drei Möglichkeiten: (a) dfs(v) ist beendet, ehe dfs(x) aufgerufen wird: v ↑ v̄ ↑ x x̄ Terminierung von dfs(v) Aufruf von dfs(v) Weil es in G einen Weg von v nach x gibt, wird beim Aufruf von dfs(v) in (1.) also dfs(x) nur dann nicht aufgerufen, wenn dieser Weg über einen Zwischenknoten z führt, der vor v besucht wurde, d.h. wir haben die folgende Situation: ?>=< 89:; v ?>=< 89:; z H 89:; ?>=< x Dann gilt aber, dass dfs(z) sowohl dfs(v) als auch dfs(x) umfasst: z z̄ v v̄ x x̄ Damit ist µ(z) > µ(x), und in (2.) wird dfs(z) vor dfs(x) aufgerufen. Dieser Aufruf würde aber bereits den Knoten v erreichen, so dass v nicht erst in der Erreichbarkeitskomponente von x erreicht würde. Damit scheidet der Fall (a) aus. (b) dfs(x) wird unterhalb von dfs(v) aufgerufen: v v̄ x x̄ Dies widerspricht der Struktur der Tiefensuche. 4.1. GERICHTETE GRAPHEN 175 (c) dfs(v) liegt innerhalb von dfs(x): v x v̄ x̄ Dann gibt es in G einen Weg von x nach v. Für die folgende Implementierung erweitern wir Programm 4.1.10, das gerichtete Graphen durch ihre Adjazenzmatrix darstellt. Es werden zwei Varianten der rekursiven Realisierung der Tiefensuche benutzt. Die erste durchläuft den Graphen G und notiert die neue Knotennummerierung, die zweite durchläuft den Graphen G−1 , wobei die Adjazenzmatrix von G entsprechend interpretiert wird, und ordnet jedem Knoten seine Komponentennummer zu. Als Aufwand für diese Implementierung erhalten wir den Rechenzeitbedarf O(n2 ). Programm 4.1.36. Eine Implementierung von Algorithmus 4.1.33: wie in Programm 4.1.10 74 public class StrongComponents { 75 76 77 78 79 /** Nach der Konstruktion ist komponente[v] == k genau dann, wenn * Knoten v zur Komponente mit Nummer k gehoert. */ private int[ ] komponente = new int[ nodeSize( ) ]; 80 81 82 /** Die Nummer der aktuell zu bestimmenden Komponente, eine Zahl >= 1. */ private int laufendeKomponente = 1; 83 84 85 86 87 88 /** Nach der Konstruktion ist nummer[v] == n genau dann, wenn * Knoten v die Nummer n hat. Waehrend der Depth-First-Nummerierung * ist nummer[v] == 0 genau dann, wenn v noch nicht besucht wurde. */ private int[ ] nummer = new int[ nodeSize( ) ]; 89 90 91 /** Die aktuell zu vergebende Knotennummer, eine Zahl >= 1. */ private int laufendeNummer = 1; 92 93 94 95 96 97 98 99 100 101 102 103 104 105 /** Bestimmt die starken Komponenten des Graphen. */ public StrongComponents( ) { // Depth-First-Nummerierung des Graphen: int nichtBesuchterKnoten; while( ( nichtBesuchterKnoten = nichtBesuchterKnoten( ) ) >= 0 ) depthFirstNumbering( nichtBesuchterKnoten ); // Depth-First-Traversierung des inversen Graphen: int maximalKnoten; while( ( maximalKnoten = maximalKnoten( ) ) >= 0 ) { inverseDepthFirstTraversal( maximalKnoten ); laufendeKomponente++; } } nodeSize 176 KAPITEL 4. GRAPHEN UND GRAPH-ALGORITHMEN 106 107 108 109 110 111 112 113 114 115 /** Depth-First-Nummerierung der von Knoten v aus erreichbaren Knoten. */ private void depthFirstNumbering( int v ) { nummer[ v ] = 1; // als besucht markiert; die Nummer wird spaeter bestimmt for( int w = 0; w < nodeSize( ); w++ ) if( isEdge( v, w ) && nummer[ w ] == 0 ) depthFirstNumbering( w ); nummer[v] = laufendeNummer; // die tatsaechliche Nummer wird bestimmt laufendeNummer++; } depthFirstNumbering 116 117 118 119 120 121 122 123 124 125 /** Gibt den ersten waehrend der Depth-First-Nummerierung noch nicht * besuchten Knoten zurueck, oder -1, falls kein solcher Knoten existiert. */ private int nichtBesuchterKnoten( ) { for( int v = 0; v < nodeSize( ); v++ ) if( nummer[v] == 0 ) // v ist noch nicht besucht return v; return −1; } nichtBesuchterKnoten 126 127 128 129 130 131 132 133 134 135 136 137 /** Depth-First-Traversierung des inversen Graphen beginnend bei Knoten v. * Die erreichbaren Knoten bilden die Komponente mit Nummer * laufendeKomponente. */ private void inverseDepthFirstTraversal( int v ) { komponente[ v ] = laufendeKomponente; nummer[ v ] = 0; // erreichten Knoten als besucht markieren for( int w = 0; w < nodeSize( ); w++ ) if( isEdge( w, v ) && nummer[ w ] != 0 ) inverseDepthFirstTraversal( w ); } inverseDepthFirstTraversal 138 139 140 141 142 143 144 145 146 147 148 149 150 /** Gibt den Knoten mit maximalem Wert im Array nummer zurueck, * oder -1, falls kein Knoten dort einen Wert > 0 hat. */ private int maximalKnoten( ) { int maximalWert = 0, maximalKnoten = −1; for( int i = 0 ; i < nummer.length; i++ ) if( maximalWert < nummer[i] ) { maximalWert = nummer[i]; maximalKnoten = i; } return maximalKnoten; } maximalKnoten 151 152 153 154 155 156 157 158 159 /** Gibt eine String-Darstellung der starken Komponenten zurueck. */ public String toString( ) { StringBuffer out = new StringBuffer( ); out.append( "Die starken Komponenten des Graphen:\n\n" ); // Die Anzahl der Komponenten ist laufendeKomponente - 1. for( int k = 1; k < laufendeKomponente; k++ ) { out.append( " Komponente " + k + ": { " ); boolean komma = false; toString 4.1. GERICHTETE GRAPHEN for( int v = 0; v < nodeSize( ); v++ ) if( komponente[v] == k ) { out.append( ( komma ? ", " : "" ) + String.valueOf( v ) ); komma = true; } out.append( " }\n" ); 160 161 162 163 164 165 } return out.toString( ); 166 167 168 177 } 169 170 } // class StrongComponents die Methoden conc, print und toString wie in Programm 4.1.10 207 208 209 210 211 212 213 214 215 216 public static void main( String[ ] args ) { DirectedGraph b = new DirectedGraph( 11 ); b.insertEdge( 0, 1 ); b.insertEdge( 0, 2 ); b.insertEdge( 1, 6 ); b.insertEdge( 1, 4 ); b.insertEdge( 2, 6 ); b.insertEdge( 3, 2 ); b.insertEdge( 3, 7 ); b.insertEdge( 5, 3 ); b.insertEdge( 5, 4 ); b.insertEdge( 6, 0 ); b.insertEdge( 7, 5 ); b.insertEdge( 7, 8 ); b.insertEdge( 8, 9 ); b.insertEdge( 9, 10 ); b.insertEdge( 10, 9 ); System.out.println( "Der Graph aus Beispiel 4.1.34:\n\n" + b + "\n" + b.new StrongComponents( ) ); } 217 218 } // class DirectedGraph Ein Testlauf: Der Graph aus Beispiel 4.1.34: | 0 1 2 3 4 5 6 7 8 9 10 ____|_________________________________ 0 | 0 1 1 0 0 0 0 0 0 0 0 1 | 0 0 0 0 1 0 1 0 0 0 0 2 | 0 0 0 0 0 0 1 0 0 0 0 3 | 0 0 1 0 0 0 0 1 0 0 0 4 | 0 0 0 0 0 0 0 0 0 0 0 5 | 0 0 0 1 1 0 0 0 0 0 0 6 | 1 0 0 0 0 0 0 0 0 0 0 7 | 0 0 0 0 0 1 0 0 1 0 0 8 | 0 0 0 0 0 0 0 0 0 1 0 9 | 0 0 0 0 0 0 0 0 0 0 1 10 | 0 0 0 0 0 0 0 0 0 1 0 Die starken Komponenten des Graphen: Komponente Komponente Komponente Komponente Komponente 1: 2: 3: 4: 5: { { { { { 3, 5, 7 } 8 } 9, 10 } 0, 1, 2, 6 } 4 } main 178 4.2 KAPITEL 4. GRAPHEN UND GRAPH-ALGORITHMEN Ungerichtete Graphen Definition 4.2.1. Ein ungerichteter Graph ist ein gerichteter Graph, bei dem die Kantenrelation E symmetrisch ist: (u, v) ∈ E genau dann, wenn (v, u) ∈ E. Zur Vereinfachung fassen wir die Kantenmenge {(u, v), (v, u)} als eine ungerichtete Kante zwischen u und v auf, die wir graphisch wie folgt darstellen: 89:; ?>=< u 89:; ?>=< v Die Begriffe Weg und Teilgraph lassen sich unmittelbar auf ungerichtete Graphen übertragen. Ein Zykel ist ein geschlossener einfacher Weg, der mindestens drei verschiedene Knoten durchläuft. Für einen Knoten v ist der Teilgraph, der von der Menge aller mit v verbundenen Knoten induziert wird, die (Zusammenhangs-)Komponente von v. Ein ungerichteter Graph heißt verbunden, wenn er aus nur einer Komponente besteht. Er heißt azyklisch, wenn er keine Zykeln enthält. Ein Wald ist ein azyklischer, ungerichteter Graph, und ein freier Baum ist ein verbundener Wald. Indem man einen Knoten als Wurzel auszeichnet und alle Kanten so orientiert, dass sie von der Wurzel weg zeigen, erhält man aus einem freien Baum einen Baum gemäß Definition 2.1.1. Ein gewichteter ungerichteter Graph ist ein ungerichteter Graph G = (V, E) zusammen mit einer Gewichtungsfunktion c : E → R+ , die symmetrisch ist, d.h. für alle u, v ∈ VPist c((u, v)) = c((v, u)). Die Kosten des Graphen G ergeben sich als c(G) = e∈E c(e). Freie Bäume bzw. Wälder haben die folgenden wichtigen Eigenschaften. Lemma 4.2.2. Ein freier Baum mit n Knoten hat n − 1 Kanten; fügt man eine Kante hinzu, so entsteht genau ein Zykel. Allgemeiner gilt: Ein Wald mit n Knoten und k Komponenten hat n − k Kanten; fügt man innerhalb einer Komponente eine Kante hinzu, so entsteht genau ein Zykel. Zur Implementierung ungerichteter Graphen kann man wieder (dann symmetrische) Adjazenzmatrizen (Def. 4.1.8) oder Adjazenzlisten (Def. 4.1.11) verwenden. Auch Tiefensuche (Algorithmus 4.1.19) und Breitensuche (Algorithmus 4.1.23) sind hier anwendbar. Diese Algorithmen liefern für ungerichtete Graphen auch die Zusammenhangskomponenten. 4.2.1 Minimale aufspannende Wälder Wir beschließen dieses Kapitel mit der Untersuchung eines weiteren GraphenAlgorithmus. Definition 4.2.3. Sei G ein gewichteter ungerichteter Graph. Ein aufspannender Wald für G ist ein Wald, der Teilgraph von G ist und dieselbe Knotenmenge 4.2. UNGERICHTETE GRAPHEN 179 sowie dieselbe Anzahl von Komponenten hat wie G. Ein minimaler aufspannender Wald für G ist ein aufspannender Wald für G mit minimalen Kosten. Ein (minimaler) aufspannender Baum ist ein verbundener (minimaler) aufspannender Wald, existiert also nur, wenn G verbunden ist. Wir wollen im Folgenden die Aufgabe lösen, zu einem gewichteten ungerichteten Graphen einen minimalen aufspannenden Wald zu bestimmen. Beispiel 4.2.4. Sei G der folgende ungerichtete Graph, wobei die Kosten einer Kante als Markierung an die Kante geschrieben sind: 13 GFED @ABC @ABC GFED A0 B oo o o 00 18ooo 009 ooo 4 12 o00oo 7 o @ABC GFED @ABC GFED E @ 000 C @@ 0 ~ ~ 0 @@ 0 ~~ 10 @@0 ~~~ 5 @ABC GFED D Der folgende Baum ist ein aufspannender Baum für G: 13 GFED @ABC A0 00 009 00 7 @ABC GFED E 000 00 00 @ABC GFED D @ABC GFED B @ABC GFED C 12 Seine Kosten betragen 41. Ist er minimal? Lemma 4.2.5. Sei G = (V, E) ein ungerichteter Graph mit symmetrischer Gewichtungsfunktion c. Sei H = (V, F ) ein Wald, der Teilgraph eines minimalen aufspannenden Waldes H 0 = (V, F 0 ) für G ist. Sei ferner (u, v) ∈ E eine Kante, die verschiedene Komponenten von H verbindet und minimales Gewicht unter allen solchen Kanten hat. Dann ist auch (V, F ∪ {(u, v)}) Teilgraph eines minimalen aufspannenden Waldes für G. Beweis: Für (u, v) ∈ F 0 gilt die Aussage. Andernfalls enthält (V, F 0 ∪ {(u, v)}) nach Lemma 4.2.2 einen Zykel, also gibt es in H 0 einen Weg von v nach u. Da v und u in verschiedenen Komponenten von H liegen, enthält dieser Weg auch eine Kante (ū, v̄), die verschiedene Komponenten von H verbindet. Damit ist der Graph H 00 = (V, F 0 \ {(ū, v̄)} ∪ {(u, v)}) 180 KAPITEL 4. GRAPHEN UND GRAPH-ALGORITHMEN ein aufspannender Wald für G, und es gilt c(H 00 ) = c(H 0 ) − c((ū, v̄)) + c((u, v)) ≤ c(H 0 ) wegen c((u, v)) ≤ c((ū, v̄)). Also ist auch H 00 ein minimaler aufspannender Wald für G, der nun (V, F ∪ {(u, v)}) als Teilgraphen enthält. Auf diesem Lemma basiert der folgende Algorithmus von Kruskal zur Bestimmung eines minimalen aufspannenden Waldes für G. Er arbeitet wie folgt: Zu Beginn wird der zu berechnende Wald als H = (V, ∅) gewählt. Nun werden die Kanten von G der Reihe nach angeschaut, wobei die Kanten nach aufsteigendem Gewicht sortiert sind. Hierfür eignet sich also eine Prioritätsschlange, wie sie etwa durch einen Heap realisiert wird. Verbindet die betrachtete Kante zwei Komponenten von H, so wird sie zu H hinzugenommen, andernfalls wird die Kante ignoriert. Am Ende ist H dann der gesuchte minimale aufspannende Wald. Zur Verwaltung der Komponenten verwenden wir die Algorithmen aus Abschnitt 3.6. Algorithmus 4.2.6. Kruskals Algorithmus zur Bestimmung eines minimalen aufspannenden Waldes für G = (V, E). Ein Heap speichert Kanten (v, w) als Tripel (v, w, c(v, w)); minimales Gewicht hat maximale Priorität. 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 WeightedUndirectedGraph minimalSpanningForest( ) { Partition komponenten = new Partition( |V | ); WeightedUndirectedGraph minimalSpanningForest = new WeightedUndirectedGraph( |V | ); Heap heap = new Heap( |E| ); for all( (v, w) ∈ E ) heap.einfuegenInHeap( (v, w, c((v, w))) ); while( !heap.istLeer( ) ) { Edge minimalEdge = heap.loeschenAusHeap( ); int a = komponenten.find( minimalEdge.start ); int b = komponenten.find( minimalEdge.destination ); if( a != b ) { // minimalEdge verbindet zwei Komponenten komponenten.union( a, b ); minimalSpanningForest.insertEdge( minimalEdge ); } } return minimalSpanningForest; } Beispiel 4.2.7. (Forts. von Beispiel 4.2.4) Für den Graphen G liefert Algorithmus 4.2.6 den folgenden minimalen aufspannenden Baum mit Kosten 28: GFED @ABC A @ABC GFED E 4 @ABC GFED B @ABC GFED C ~ ~ 5 ~ ~~ ~~ @ABC GFED D 12 7 minimalSpanningForest 4.2. UNGERICHTETE GRAPHEN 181 Lemma 4.2.8. Kruskals Algorithmus hat einen Zeitbedarf von O(|E| log |E|). Beweis: Die Partition {{0}, . . . , {|V | − 1}} wird in Zeit O(|V |) initialisiert. Nach der Analyse von HEAPSORT (Beweis von Lemma 2.3.13) kann die Prioritätsschlange in Zeit O(|E|) initialisiert werden, wenn sie durch einen Heap implementiert wird. Die while-Schleife wird |E|-mal durchlaufen. Insgesamt werden dabei |E| Elemente aus dem Heap entfernt, was Zeit O(|E| log |E|) kostet, und es werden 2|E| Finde- und maximal |V | − 1 Vereinigungs-Operationen ausgeführt. Nach Satz 3.6.11 reichen hierfür O(|E|α(2|E|, |V | − 1)) Schritte aus. Insgesamt haben wir also einen Rechenzeitbedarf von O(|E| log |E|). Programm 4.2.9. Die folgende Implementierung von Kruskals Algorithmus benutzt als Heaps Instanzen der Klasse VollstaendigerBinaererBaum aus Programm 2.3.14; im Heap werden Kanten als Tripel aus Startknoten, Zielknoten und Gewicht gespeichert (Typ Edge), die bezüglich ihres Gewichts geordnet sind. Die Klasse Partition aus Programm 3.6.12 dient zur Verwaltung der Zusammenhangskomponenten. 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 import java.util.LinkedList; import java.util.Random; /** Die Klasse WeightedUndirectedGraph implementiert ungerichtete Graphen * mit Gewichtungsfunktion ueber Adjazenzmatrizen. Knoten sind Zahlen >= 0 * vom Typ int, Gewichte sind Zahlen > 0 vom Typ double. * Mehrfachkanten sind nicht zugelassen. */ public class WeightedUndirectedGraph { /** Die Anzahl der Knoten des Graphen. * Knoten sind Zahlen v mit 0 <= v < N. */ private final int N; /** Das maximale Kantengewicht im Graphen * (0 wenn keine Kante vorhanden ist). */ private double maximalWeight = 0; /** Die Summe aller Kantengewichte im Graphen * (0 wenn keine Kante vorhanden ist). */ private double sumOfWeights = 0; /** Die symmetrische Adjazenzmatrix des Graphen. */ private double[ ][ ] matrix; /** Konstruiert einen Graphen mit n Knoten und keiner Kante. */ public WeightedUndirectedGraph( int n ) { N = n; matrix = new double[N][N]; } WeightedUndirectedGraph 182 KAPITEL 4. GRAPHEN UND GRAPH-ALGORITHMEN 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 /** Konstruiert einen Zufallsgraphen mit n Knoten. Jede Kante ist mit * Wahrscheinlichkeit p vorhanden. Das Gewicht einer Kante ist als Zahl * vom Typ int gleichverteilt aus (0, maxWeight] gewaehlt. */ public WeightedUndirectedGraph( int n, double p, double maxWeight ) { this( n ); for( int v = 0; v < N; v++ ) for( int w = 0; w < v; w++ ) if( Math.random( ) < p ) insertEdge( v, w, (int)( Math.random( ) * maxWeight ) + 1 ); } /** Gibt die Knotenzahl zurueck. */ public int nodeSize( ) { return N; } /** Gibt die Kantenzahl zurueck. */ public int edgeSize( ) { int e = 0; for( int v = 0; v < N; v++ ) for( int w = 0; w < v; w++ ) e += isEdge( v, w ) ? 1 : 0; return e; } /** Gibt die Summe aller Kantengewichte zurueck. */ public double sumOfWeights( ) { return sumOfWeights; } /** Gibt true zurueck, wenn eine Kante zwischen Knoten v und w vorhanden ist, * sonst false. Falls nicht 0 <= v, w < N gilt, wird eine Ausnahme ausgeloest. */ public boolean isEdge( int v, int w ) { if( v < 0 | | w < 0 | | v >= N | | w >= N ) throw new IllegalArgumentException( "Knoten nicht vorhanden." ); return matrix[v][w] > 0; } /** Fuegt eine Kante mit Gewicht weight zwischen Knoten v und w ein, * falls zwischen v und w keine Kante vorhanden ist. Falls nicht * 0 <= v, w < N und weight > 0 gilt, wird eine Ausnahme ausgeloest. */ public void insertEdge( int v, int w, double weight ) { if( v < 0 | | w < 0 | | v >= N | | w >= N ) throw new IllegalArgumentException( "Knoten nicht vorhanden." ); if( weight <= 0 ) throw new IllegalArgumentException( "Gewicht ist nicht positiv." ); WeightedUndirectedGraph nodeSize edgeSize sumOfWeights isEdge insertEdge 4.2. UNGERICHTETE GRAPHEN if( !isEdge( v, w ) ) { if( maximalWeight < weight ) maximalWeight = weight; sumOfWeights += weight; matrix[v][w] = matrix[w][v] = weight; // die Matrix ist symmetrisch } 84 85 86 87 88 89 90 183 } 91 92 93 94 95 96 97 98 /** Fuegt die Kante e ein, falls zwischen e.start und e.destination keine * Kante vorhanden ist. Falls nicht 0 <= e.start, e.destination < N und * e.weight > 0 gilt, wird eine Ausnahme ausgeloest. */ public void insertEdge( Edge e ) { insertEdge( e.start, e.destination, e.weight ); } insertEdge 99 100 101 102 103 /** Die Klasse Edge implementiert Kanten des ungerichteten Graphen. * Kanten sind Tripel aus Startknoten, Zielknoten und Gewicht. */ public class Edge implements Comparable { 104 105 106 /** Der Startknoten der Kante. */ private int start; 107 108 109 /** Der Zielknoten der Kante. */ private int destination; 110 111 112 /** Das Gewicht der Kante. */ private double weight; 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 /** Konstruiert eine Kante mit Startknoten start, Zielknoten destination * und Gewicht weight. Falls nicht 0 <= start, destination < N und weight > 0 * gilt, wird eine Ausnahme ausgeloest. */ public Edge( int start, int destination, double weight ) { if( start < 0 | | destination < 0 | | start >= nodeSize( ) | | destination >= nodeSize( ) ) throw new IllegalArgumentException( "Knoten nicht vorhanden." ); if( weight <= 0 ) throw new IllegalArgumentException( "Gewicht ist nicht positiv." ); this.start = start; this.destination = destination; this.weight = weight; } Edge 128 129 130 131 132 133 /** Kanten werden bezueglich ihres Gewichts verglichen. */ public int compareTo( Object otherEdge ) { double otherWeight = ( (Edge)otherEdge ).weight; return weight < otherWeight ? −1 : weight > otherWeight ? +1 : 0; } 134 135 136 } // class Edge compareTo 184 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 KAPITEL 4. GRAPHEN UND GRAPH-ALGORITHMEN /** Kruskals Algorithmus bestimmt einen minimalen aufspannenden Wald. */ public WeightedUndirectedGraph minimalSpanningForest( ) { Partition komponenten = new Partition( N ); WeightedUndirectedGraph minimalSpanningForest = new WeightedUndirectedGraph( N ); // Ein Heap, der alle Kanten enthaelt; // minimales Gewicht hat maximale Prioritaet: VollstaendigerBinaererBaum heap = new VollstaendigerBinaererBaum( edgeSize( ) ); for( int v = 0; v < N; v++ ) for( int w = 0; w < v; w++ ) if( isEdge( v, w ) ) heap.einfuegenInHeap( new Edge( v, w, matrix[v][w] ) ); while( !heap.istLeer( ) ) { Edge minimalEdge = (Edge)heap.loeschenAusHeap( ); int a = komponenten.find( minimalEdge.start ); int b = komponenten.find( minimalEdge.destination ); if( a != b ) { // minimalEdge verbindet zwei Komponenten komponenten.union( a, b ); minimalSpanningForest.insertEdge( minimalEdge ); } } return minimalSpanningForest; } minimalSpanningForest die Methoden conc und print wie in Programm 4.1.10 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 /** Gibt eine String-Darstellung des Graphen zurueck. * Gewichte werden zu ganzen Zahlen abgerundet. */ public String toString( ) { StringBuffer out = new StringBuffer( ); // Das Maximum der Laengen der Darstellungen von N und maximalWeight: int n = Math.max( Integer.toString( N ).length( ), Integer.toString( (int)maximalWeight ).length( ) ); // Die Spaltenueberschrift: out.append( conc( " ", n+2 ) + "|" ); for( int v = 0; v < N; v++ ) out.append( print( v, n+1 ) ); out.append( "\n" + conc( "_", n+2 ) + "|" + conc( "_", N*(n+1) ) + "\n" ); // Die Matrix: for( int v = 0; v < N; v++ ) { out.append( print( v, n+1 ) + " |" ); for( int w = 0; w < v; w++ ) out.append( conc( " ", n+1 ) ); for( int w = v; w < N; w++ ) out.append( print( (int)matrix[v][w], n+1 ) ); out.append( "\n" ); } return out.toString( ); } toString 4.2. UNGERICHTETE GRAPHEN 200 201 202 203 204 205 206 207 208 209 210 211 public static void main( String[ ] args ) { WeightedUndirectedGraph usa = new WeightedUndirectedGraph( 8 ); usa.insertEdge( 1, 0, 300 ); usa.insertEdge( 2, 1, 800 ); usa.insertEdge( 2, 0, 1000 ); usa.insertEdge( 3, 2, 1200 ); usa.insertEdge( 4, 3, 1500 ); usa.insertEdge( 4, 5, 250 ); usa.insertEdge( 5, 3, 1000 ); usa.insertEdge( 5, 6, 900 ); usa.insertEdge( 5, 7, 1400 ); usa.insertEdge( 6, 7, 1000 ); usa.insertEdge( 7, 0, 1700 ); System.out.println( "Der Graph aus Beispiel 4.1.31:\n\n" + usa ); WeightedUndirectedGraph usaForest = usa.minimalSpanningForest( ); System.out.println( "Ein minimaler aufspannender Wald mit Gewicht " + usaForest.sumOfWeights( ) + ":\n\n" + usaForest ); 212 WeightedUndirectedGraph u = new WeightedUndirectedGraph( 5 ); u.insertEdge( 0, 1, 13 ); u.insertEdge( 0, 3, 9 ); u.insertEdge( 0, 4, 4 ); u.insertEdge( 1, 2, 12 ); u.insertEdge( 1, 4, 18 ); u.insertEdge( 2, 3, 5 ); u.insertEdge( 2, 4, 7 ); u.insertEdge( 3, 4, 10 ); System.out.println( "\nDer Graph aus Beispiel 4.2.4:\n\n" + u ); WeightedUndirectedGraph uForest = u.minimalSpanningForest( ); System.out.println( "Ein minimaler aufspannender Wald mit Gewicht " + uForest.sumOfWeights( ) + ":\n\n" + uForest ); 213 214 215 216 217 218 219 220 221 222 } 223 224 } // class WeightedUndirectedGraph Ein Testlauf: Der Graph aus Beispiel 4.1.31: | 0 1 2 3 4 5 6 7 ______|________________________________________ 0 | 0 300 1000 0 0 0 0 1700 1 | 0 800 0 0 0 0 0 2 | 0 1200 0 0 0 0 3 | 0 1500 1000 0 0 4 | 0 250 0 0 5 | 0 900 1400 6 | 0 1000 7 | 0 Ein minimaler aufspannender Wald mit Gewicht 5450.0: | 0 1 2 3 4 5 6 7 ______|________________________________________ 0 | 0 300 0 0 0 0 0 0 1 | 0 800 0 0 0 0 0 2 | 0 1200 0 0 0 0 3 | 0 0 1000 0 0 4 | 0 250 0 0 5 | 0 900 0 6 | 0 1000 7 | 0 185 main 186 KAPITEL 4. GRAPHEN UND GRAPH-ALGORITHMEN Der Graph aus Beispiel 4.2.4: | 0 1 2 3 4 ____|_______________ 0 | 0 13 0 9 4 1 | 0 12 0 18 2 | 0 5 7 3 | 0 10 4 | 0 Ein minimaler aufspannender Wald mit Gewicht 28.0: | 0 1 2 3 4 ____|_______________ 0 | 0 0 0 0 4 1 | 0 12 0 0 2 | 0 5 7 3 | 0 0 4 | 0 Kapitel 5 Sortieralgorithmen In diesem Kapitel kommen wir zum Problem des Sortierens zurück, das wir in Abschnitt 2.3 schon angesprochen hatten. Dort hatten wir zwei Algorithmen für das Sortieren kennengelernt, nämlich TREE SORT und HEAP SORT, die beide binäre Bäume als unterliegende Datenstruktur für das Sortieren verwenden. Hier werden wir einige weitere Sortieralgorithmen kennenlernen, analysieren und miteinander vergleichen. 5.1 Elementare Sortieralgorithmen Zunächst wollen wir die Problemstellung fixieren. Definition 5.1.1. Das Sortierproblem für die durch die Ordnung ≤ linear geordnete Menge Item ist das Problem, folgende Aufgabe zu lösen: Eingabe: Eine endliche Folge (a0 , a1 , . . . , an−1 ) von Elementen aus Item. Aufgabe: Bestimme eine Permutation π : {0, . . . , n − 1} → {0, . . . , n − 1}, so dass aπ(0) ≤ aπ(1) ≤ · · · ≤ aπ(n−1) gilt, d.h. so dass die Folge (aπ(0) , aπ(1) , . . . , aπ(n−1) ) aufsteigend sortiert ist. Ein Algorithmus, der diese Aufgabe löst, ist ein Sortieralgorithmus. Falls aus i < j und ai = aj stets π −1 (i) < π −1 (j) folgt (d.h. falls auch in der sortierten Folge ai vor aj kommt), so heißt der Sortieralgorithmus stabil. Bemerkung 5.1.2. Man macht sich leicht klar, dass TREE SORT (aus Abschnitt 2.3.1) so realisiert werden kann, dass es ein stabiles Verfahren ist, während HEAP SORT (aus Abschnitt 2.3.2) inhärent instabil ist. Hier betrachten wir zunächst zwei elementare Sortierverfahren. Immer nehmen wir an, dass die Eingabefolge (a0 , . . . , an−1 ) in einem Array a der Größe n über dem Typ Comparable steht. Am Ende der Berechnung enthält das Array die sortierte Ausgabefolge. 187 188 KAPITEL 5. SORTIERALGORITHMEN Algorithmus 5.1.3. SELECTION SORT (Sortieren durch Auswählen): 1 2 3 4 5 6 7 8 9 10 11 selectionSort( Comparable[ ] a ) { for( int i = 0; i < n−1; i++ ) { // Erstes minimales Element in (a[i], . . . , a[n − 1]) finden . . . int min = i; // Position des aktuellen minimalen Elements for( int j = i+1; j < n; j++ ) if( a[ j ].compareTo( a[ min ] ) < 0 ) min = j; // . . . und mit a[i] vertauschen: swap( a, min, i ); } } selectionSort 12 13 /** Vertauscht die Array-Elemente a[i] und a[j] (0 ≤ i, j < n). */ swap( Comparable[ ] a, int i, int j ) { Comparable tmp = a[ i ]; 16 a[ i ] = a[ j ]; 17 a[ j ] = tmp; 18 } 14 15 swap Satz 5.1.4. Algorithmus SELECTION SORT sortiert ein Array mit n Elementen in Zeit O(n2 ). Dabei werden O(n2 ) viele Schlüsselvergleiche und O(n) viele Vertauschungen durchgeführt. Beweis: Die äußere Schleife wird (n − 1)-mal, die − i)-mal Pinnere jeweils (n − 1P n−1 durchlaufen. Dies ergibt einen Zeitbedarf von c · n−2 (n − 1 − i) = c · i=0 i=1 i ∈ 2 O(n ). Beim Sortieren durch Auswählen wird im i-ten Schritt das i-te Element der sortierten Folge bestimmt und an seinen Platz gebracht. Beim folgenden Verfahren, dem Sortieren durch Einfügen, wird die Anfangsfolge der Länge i + 1 sortiert, indem das (i + 1)-te Element der unsortierten Folge in die bereits sortierte Teilfolge der ersten i Elemente an der richtigen Stelle eingefügt wird. Algorithmus 5.1.5. INSERTION SORT (Sortieren durch Einfügen): 1 2 3 4 5 6 7 8 9 insertionSort( Comparable[ ] a ) { for( int i = 1; i < n; i++ ) { Comparable tmp = a[ i ]; int j = i; for( ; j > 0 && tmp.compareTo( a[ j−1 ] ) < 0; j−− ) a[ j ] = a[ j−1 ]; a[ j ] = tmp; } } In der inneren Schleife wird Platz für das Element a[i] gemacht, indem alle größeren Elemente zwischen a[i − 1] und a[0] um jeweils einen Platz nach rechts geschoben werden. DiePäußere Schleife wird (n − 1)-mal durchlaufen, die innere 2 im schlechtesten Fall n−1 i=1 i ∈ O(n ) mal. Also gilt: insertionSort 5.2. SORTIERVERFAHREN MIT DIVIDE-AND-CONQUER 189 Satz 5.1.6. Algorithmus INSERTION SORT sortiert ein Array mit n Elementen in Zeit O(n2 ). Dabei werden O(n2 ) Schlüsselvergleiche und Umspeicherungen vorgenommen. Statt das Anfangsstück von a[i−1] bis a[0] linear zu durchlaufen, um die richtige Position für das Element a[i] zu bestimmen, kann man auf diesem Breich auch Binärsuche für diese Aufgabe einsetzen. Dann wird die Position für a[i] in Zeit O(log i) gefunden. Allerdings brauchen wir im schlechtesten Fall noch immer i Umspeicherungen, um a[i] an der gefundenen Position unterzubringen, d.h. diese Variante von INSERTION SORT kommt mit O(n log n) Schlüsselvergleichen aus, braucht aber dennoch Rechenzeit O(n2 ). Algorithmus 5.1.7. INSERTION SORT mit Binärsuche : 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 insertionSortBinarySearch( Comparable[ ] a ) { for( int i = 1; i < n; i++ ) { Comparable tmp = a[ i ]; // Position fuer a[i] im Bereich a[0] bis a[i − 1] binaer suchen: int left = 0; int right = i−1; while( left <= right ) { int mid = ( left + right ) / 2; if( a[ i ].compareTo( a[ mid ] ) < 0 ) right = mid − 1; else if( a[ i ].compareTo( a[ mid ] ) > 0 ) left = mid + 1; else { left = mid; break; } } // Dann a[i] an Position left bringen: for( int j = i; j > left; j−− ) a[ j ] = a[ j−1 ]; a[ left ] = tmp; } } 5.2 Sortierverfahren mit Divide-and-Conquer Die nächsten beiden Sortierverfahren beruhen auf der Strategie Divide and ” Conquer“ (Teile und Herrsche): Die Folge (a0 , . . . , an−1 ) wird in zwei Teilfolgen aufgeteilt, etwa (a0 , . . . , am ) und (am+1 , . . . , an−1 ), die dann getrennt voneinander nach demselben Verfahren sortiert und anschließend zu einer sortierten Gesamtfolge zusammengefügt werden. Beim ersten Verfahren ist das Aufteilen trivial, und die gesamte Arbeit wird beim Zusammenfügen der sortierten Teilfolgen geleistet. Beim zweiten Verfahren erfordert das Aufteilen die hauptsächliche Arbeit, und das Zusammenfügen ist trivial. insertionSortBinarySearch 190 KAPITEL 5. SORTIERALGORITHMEN Als wichtigen Bestandteil des ersten Verfahrens brauchen wir also einen Algorithmus, der zwei sortierte Teilfolgen zu einer sortierten Folge zusammenmischt. Algorithmus 5.2.1. MERGE: Mischen zweier sortierter Teil-Arrays a[left] bis a[mid] und a[mid + 1] bis a[right] zu einem sortierten Array a[left] bis a[right]. Im Array b wird das Ergebnis zwischengespeichert. 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 merge( Comparable[ ] a, Comparable[ ] b, int left, int mid, int right ) { int i1 = left; // laeuft hoch bis mid int i2 = mid + 1; // laeuft hoch bis right int bPos = left; // kopiere nach b ab bPos while( i1 <= mid && i2 <= right ) { if( a[ i1 ].compareTo( a[ i2 ] ) <= 0 ) b[ bPos++ ] = a[ i1++ ]; else b[ bPos++ ] = a[ i2++ ]; } while( i1 <= mid ) // ersten Bereich vollends kopieren b[ bPos++ ] = a[ i1++ ]; while( i2 <= right ) // zweiten Bereich vollends kopieren b[ bPos++ ] = a[ i2++ ]; // Zuletzt von b nach a zurueckkopieren: for( bPos = left; bPos <= right; bPos++ ) a[ bPos ] = b[ bPos ]; } merge Hat ein Teil-Array m und das andere Teil-Array n Elemente, so gilt für die Komplexität des Algorithmus MERGE offensichtlich folgendes: Anzahl der Schlüsselvergleiche: CMERGE (m, n) ≤ m + n − 1 Zeitbedarf: TMERGE (m, n) ∈ O(m + n) Platzbedarf: SMERGE (m, n) = 2(m + n) + c Algorithmus 5.2.2. MERGE SORT (Sortieren durch Mischen): Die rekursive Hilfemethode sortiert den Bereich a[left] bis a[right]. Beachte: Gilt left = right, so ist dieser Bereich bereits sortiert. Gilt left < right und ist mid = b(left + right)/2c, so gelten left ≤ mid und mid + 1 ≤ right. mergeSort( Comparable[ ] a ) { mergeSort( a, new Comparable[ n ], 0, n−1 ); 3 } 1 2 mergeSort 4 5 6 7 8 9 10 11 12 mergeSort( Comparable[ ] a, Comparable[ ] b, int left, int right ) { if( left < right ) { int mid = ( left + right ) / 2; mergeSort( a, b, left, mid ); mergeSort( a, b, mid+1, right ); merge( a, b, left, mid, right ); } } mergeSort 5.2. SORTIERVERFAHREN MIT DIVIDE-AND-CONQUER 191 Zum Aufwand: Die Anzahl der Schlüsselvergleiche ist CMERGE SORT (1) = 0 und CMERGE SORT (n) = 2 · CMERGE SORT (n/2) + (n − 1) für n > 1, also gilt: Anzahl der Schlüsselvergleiche: CMERGE SORT (n) ∈ O(n log n) Zeitbedarf: TMERGE SORT (n) ∈ O(n log n) Platzbedarf: SMERGE SORT (n) = 2n + c Hieraus und aus der Art und Weise, wie in MERGE die beiden Teilfolgen zusammengemischt werden, ergibt sich die folgende Aussage. Satz 5.2.3. Algorithmus MERGE SORT ist ein stabiles Sortierverfahren, das ein Array mit n Elementen in Zeit O(n log n) sortiert. MERGE SORT arbeitet ausgehend von dem Gesamtbereich gewissermaßen TopDown. Für die Abarbeitung der rekursiven Aufrufe wird also ein Keller gebraucht. Durch ein entsprechendes Bottom-Up-Verfahren lässt sich dieser Keller einsparen. Algorithmus 5.2.4. MERGE SORT BOTTOM UP: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 mergeSortBottomUp( Comparable[ ] a ) { Comparable[ ] b = new Comparable[ n ]; for( int step = 1; step < n; step *= 2 ) { // Je zwei Bereiche der Groesse step werden zusammengemischt: for( int left = 0; left < n; left += 2*step ) { if( left + step < n ) { // sind noch zwei Bereich uebrig? int mid = left + step − 1; // Der letzte Bereich muss an Position n − 1 enden. if( mid + step >= n ) merge( a, b, left, mid, n − 1 ); else merge( a, b, left, mid, mid + step ); } } } } Bei MERGE SORT wird die zu sortierende Folge in der Mitte geteilt, die entstehenden Teilfolgen werden unabhängig voneinander sortiert, und schließlich werden die sortierten Teilfolgen zu einer sortierten Folge zusammengemischt. Es wäre natürlich vorteilhaft, wenn man die zu sortierende Folge stets so teilen könnte, dass die Teilfolgen nach dem Sortieren nicht mehr zusammengemischt werden müssten. Dies kann man erreichen, wenn man die Folge vor dem Teilen so umordnet, dass für eine Stelle m (0 ≤ m < n) folgendes gilt: 0≤i<m<j<n impliziert ai ≤ am ≤ aj . mergeSortBottomUp 192 KAPITEL 5. SORTIERALGORITHMEN Dieses Umordnen wird als Partitionieren bezeichnet, wobei das Element am der Schnittpunkt (oder: das Pivotelement) ist. Alle Elemente, die in der umgeordneten Folge vor dem Schnittpunkt auftreten, sind also ≤ am , die dahinter ≥ am . Die noch zu sortierenden Teilfolgen sind dann (a0 , . . . , am−1 ) und (am+1 , . . . , an−1 ). Für die Wahl des Schnittpunkts gibt es mehrere Strategien: • Der Schnittpunkt ist das erste Element a0 der Eingabefolge. • Der Schnittpunkt ist das mittlere Element ab(n−1)/2c der Eingabefolge. • Der Schnittpunkt ist ein zufällig gewähltes Element der Eingabefolge. • Betrachte das erste, das mittlere und das letzte Element der Eingabefolge, d.h. die Menge {a0 , ab(n−1)/2c , an−1 }. Der Schnittpunkt ist das zweitgrößte Element dieser Menge ( Median-von-drei“). ” Bei zufällig verteilten Eingaben ist jede dieser Strategien akzeptabel, bei vorsortierten oder umgekehrt sortierten Eingabefolgen schneidet aber beispielsweise die erste Strategie sehr schlecht ab, da die Partitionierung sehr ungleich große Teilfolgen erzeugt. Algorithmus 5.2.5. PARTITION: Aufteilen des Bereichs a[left] bis a[right] mit der Strategie Median-von-drei“ zur Wahl des Schnittpunkts: ” 1 2 3 4 5 6 7 8 9 10 11 12 int partition( Comparable[ ] a, int left, int right ) { // “Median von drei”: int mid = ( left + right) / 2; if( a[ mid ].compareTo( a[ left ] ) < 0 ) swap( a, mid, left ); if( a[ right ].compareTo( a[ left ] ) < 0 ) swap( a, right, left ); if( a[ right ].compareTo( a[ mid ] ) < 0 ) swap( a, right, mid ); Comparable cut = a[ mid ]; // der Schnittpunkt der Partitionierung swap( a, mid, right−1 ); // der Schnittpunkt kommt an Position right-1 // Das eigentliche Partitionieren: int i1 = left; // laeuft hinauf int i2 = right−1; // laeuft hinunter while( i1 < i2 ) { do i1++; while( a[ i1 ].compareTo( cut ) < 0 ); // nun ist a[i1] ≥ cut do i2−−; while( a[ i2 ].compareTo( cut ) > 0 ); // nun ist a[i2] ≤ cut if( i1 < i2 ) swap( a, i1, i2 ); } swap( a, i1, right − 1 ); // den Schnittpunkt wieder an seinen Platz bringen return i1; 13 14 15 16 17 18 19 20 21 22 23 24 25 26 } partition 5.2. SORTIERVERFAHREN MIT DIVIDE-AND-CONQUER 193 Beispiel 5.2.6. Die unsortierte Folge (93, 82, 30, 75, 13, 28, 33, 50, 11, 39) steht bei (i). Bei (ii) wird die Situation nach Programmzeile 10 gezeigt. Der Schnittpunkt ist 39 als der Median der Menge {93, 13, 39}. Nach dem Vertauschen in Zeile 11 entsteht das Array (iii); die initialen Zeigerpositionen (Zeile 14 und 15) sind markiert. Das Vertauschen in Zeile 22 findet zweimal statt, bei (vi) und bei (v); am Ende ist i1 = 5 und i2 = 4. Bei (vi) muss noch das Vertauschen in Zeile 24 stattfinden. Das Resultat steht bei (vii). a[0] a[1] a[2] a[3] a[4] a[5] a[6] a[7] a[8] a[9] 82 82 82 30 30 30 75 75 75 13 39 11 28 28 28 33 33 33 50 50 50 30 75 11 28 13 30 11 39 93 13 13 33 33 30 30 28 ↑ 75 39 50 (vi) (vii) 75 ↑ 28 28 33 ↑ 82 50 (v) 82 ↑ 33 11 11 39 ↑ 39 39 93 93 (iv) 93 13 13 ↑ 13 82 82 50 50 39 75 93 93 (i) (ii) (iii) 11 11 93 Zum Aufwand (sei n = right − left + 1): Anzahl der Schlüsselvergleiche: CPARTITION (n) ≤ n + 2 Zeitbedarf: TPARTITION (n) ∈ O(n) Algorithmus 5.2.7. QUICK SORT (Sortieren durch Partitionieren): Zur Optimierung werden Arrays der Größe CUTOFF oder kleiner hierbei mit INSERTION SORT sortiert. CUTOFF muss dabei größer als 1 sein; typische Werte sind zwischen 5 und 20. void quickSort( Comparable[ ] a ) { quickSort( a, 0, n − 1 ); 3 } 1 2 quickSort 4 5 6 7 8 9 10 11 12 13 void quickSort( Comparable[ ] a, int left, int right ) { if( left + CUTOFF > right ) // fuer kleine Bereiche insertionSort( a, left, right ); else { // fuer grosse Bereiche int m = partition( a, left, right ); quickSort( a, left, m − 1 ); // kleine Elemente sortieren quickSort( a, m + 1, right ); // grosse Elemente sortieren } } quickSort 194 KAPITEL 5. SORTIERALGORITHMEN Satz 5.2.8. Algorithmus QUICK SORT sortiert ein Array mit n Elementen im schlechtesten Fall in Zeit O(n2 ) mit O(n2 ) Schlüsselvergleichen. Beweis: Wir haben gesehen, dass beim Partitionieren höchstens n+2 Schlüsselvergleiche und Zeit O(n) gebraucht werden. Im schlechtesten Fall wird beim rekursiven Partitionieren der Schnittpunkt an den Rand gelegt, d.h. die Folge wird nicht wirklich geteilt. Dann sind n geschachtelte Aufrufe möglich, wobei der der Größe n − i + 1 bearbeitet. Damit erhalten wir Pn i-te Aufruf eine Teilfolge 2 2 i=1 (n − i + 3) ∈ O(n ) Schlüsselvergleiche und Rechenzeit O(n ). Damit ist QUICK SORT im schlechtesten Fall sehr ineffizient. Für die mittlere Rechenzeit sieht es aber wesentlich besser aus. Satz 5.2.9. Für das Verhalten im Mittel gilt für QUICK SORT folgendes: Sind alle Schlüssel verschieden und wird der Schnittpunkt zufällig gewählt, dann ist die durchschnittliche Anzahl der Schlüsselvergleiche C(n) ∈ O(n log n). Beweis: Sei (a0 , . . . , an−1 ) eine Folge, in der alle Schlüssel verschieden sind. Die Wahl des Schnittpunktes beim Partitionieren soll nun zufällig geschehen1 . Wir nehmen an, dass beim Aufruf von partition(a, `, r) jedes Element der Teilfolge (a` , . . . , ar ) mit Wahrscheinlichkeit 1/(r − ` + 1) als Schnittpunkt gewählt wird. Für jedes m (` ≤ m ≤ r) entstehen also mit Wahrscheinlichkeit 1/(r − ` + 1) die beiden Teilfolgen (a` , . . . , am−1 ) und (am+1 , . . . , ar ). Damit erhalten wir C(n) = (n + 1) + n−1 1 X · C(k) + C(n − k − 1) n k=0 und C(0) = C(1) = 0. Also gilt n · C(n) = n(n + 1) + 2 · n−1 X C(k) n−2 X C(k). k=0 und insbesondere für n > 1 (n − 1) · C(n − 1) = (n − 1)n + 2 · k=0 Subtrahiert man die letzte von der vorletzten Gleichung, so ergibt sich n · C(n) − (n − 1) · C(n − 1) = 2n + 2 · C(n − 1), also ist C(n) 2 C(n − 1) = + , n+1 n+1 n 1 Man überzeuge sich, dass dies mit n + 1 Schlüsselvergleichen implementiert werden kann. 5.2. SORTIERVERFAHREN MIT DIVIDE-AND-CONQUER 195 woraus n+1 X1 C(n) 2 2 2 2 C(1) = + + + ··· + + =2· n+1 n+1 n n−1 3 2 k k=3 folgt. Nun ist aber Hn = Pn k=1 1/k die n-te Harmonische Zahl, für die ln n ≤ Hn ≤ 1 + ln n gilt. Damit erhalten wir die Abschätzung n+1 X1 C(n) =2· = 2(Hn+1 − 3/2) < 2 ln(n + 1), n+1 k k=3 die wiederum C(n) < 2(n + 1) ln(n + 1) ∈ O(n log n) liefert. Bemerkung 5.2.10. An zusätzlichem Speicherplatz braucht QUICK SORT einen Keller zum Abarbeiten der Rekursion, der im schlechtesten Fall Platz O(n) braucht. Ändert man QUICK SORT aber so ab, dass nach dem Teilen einer Folge stets zuerst die kleinere Teilfolge weiterbearbeitet wird, so braucht dieser Keller nur Platz O(log n). Diese Verbesserung ist im folgenden Algorithmus realisiert. void quickSortTuned( Comparable[ ] a ) { 2 quickSortTuned( a, 0, n − 1 ); 3 } 1 quickSortTuned 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 void quickSortTuned( Comparable[ ] a, int left, int right ) { if( left + CUTOFF > right ) // fuer kleine Bereiche insertionSort( a, left, right ); else { // fuer grosse Bereiche int m = partition( a, left, right ); // Rekursiven Aufruf zuerst fuer den kleineren Bereich: if( m − left < right − m ) { quickSortTuned( a, left, m − 1 ); // die kleinen Elemente sortieren quickSortTuned( a, m + 1, right ); // die grossen Elemente sortieren } else { quickSortTuned( a, m + 1, right ); // die grossen Elemente sortieren quickSortTuned( a, left, m − 1 ); // die kleinen Elemente sortieren } } } Bemerkung 5.2.11. Das Sortierverfahren QUICK SORT ist nicht stabil. quickSortTuned 196 5.3 KAPITEL 5. SORTIERALGORITHMEN Sortieren durch Fachverteilen Die bisher betrachteten Sortierverfahren benutzen von den verwendeten Schlüsselwerten nur die Eigenschaft, dass sie aus einer linear geordneten Menge stammen. In diesen Verfahren werden Entscheidungen in Abhängigkeit davon getroffen, wie gewisse Schlüsselvergleiche ausgehen. Für das Sortieren von n Elementen braucht man bei dieser Vorgehensweise im schlechtesten Fall Θ(n log n) Vergleiche. Hier wollen wir nun noch ein Verfahren vorstellen, das Informationen über den Aufbau der Schlüsselelemente ausnutzt. Sei dazu Item ⊆ {0, 1, . . . , mk − 1} für ein m ≥ 2 und ein k ≥ 1. Dann lässt sich jedes Element p ∈ Item eindeutig darstellen als p= k−1 X d i · mi i=0 mit 0 ≤ di < m. Damit ist dk−1 dk−2 . . . d1 d0 die Darstellung der Zahl p zur Basis m. Wir nehmen hier also an, dass der Schlüsselbereich Item endlich ist. Das Sortierverfahren RADIX SORT sortiert eine Folge nun dadurch, dass die einzelnen Ziffern der Darstellung der Schlüssel zur Basis m von rechts nach links durchgegangen werden. Für jede Stelle wird die Folge einmal durchlaufen und anhand der Werte dieser Stelle umgeordnet. Nach k Durchläufen ist die Folge schließlich sortiert, der Aufwand beträgt also nur O(k · n). Algorithmus 5.3.1. RADIX SORT sortiert hier der Einfachheit halber ein Array über dem Typ Integer. Wir nehmen Item ⊆ {0, . . . , mk − 1} an für feste Werte m ≥ 2 und k ≥ 1. 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 void radixSort( Integer[ ] a ) { Erzeuge m leere Listen list[0] bis list[m − 1]. Erzeuge eine Hilfsliste tmpList, die zu Beginn die Elemente von a enthaelt. for( int i = 0; i < k; i++ ) { // Stelle i betrachten while( !tmpList.isEmpty( ) ) { int p = tmpList.removeFirst( ); Sei p = dn−1 , . . . , d1 d0 als Darstellung zur Basis m. list[ di ].addLast( p ); // p hinten in list[di ] einfuegen } for( int j = 0; j < m; j++ ) { list[j] an tmpList anhaengen und list[j] leer machen. } } // tmpList enthaelt jetzt alle Elemente von a in sortierter Folge. tmpList.toArray( a ); // Schreibe diese Liste zurueck ins Array a } Beispiel 5.3.2. Seien k = 2 und m = 10, also Item ⊆ {0, . . . , 99}. Wir sortieren die Liste (3, 18, 14, 6, 47, 7, 56, 92, 98, 60, 75, 12). Für i = 0 werden die Elemente wie folgt auf die Listen list[0] bis list[9] verteilt: radixSort 5.4. SORTIERVERFAHREN IM VERGLEICH list[0] 60 list[1] list[2] 92 12 list[3] 3 list[4] 14 list[5] 75 197 list[6] 6 56 list[7] 47 7 list[8] 18 98 list[9] Daraus entsteht die Liste (60, 92, 12, 3, 14, 75, 6, 56, 47, 7, 18, 98). Für i = 1 liefert die Verteilung der neuen Liste folgendes: list[0] 3 6 7 list[1] 12 14 18 list[2] list[3] list[4] 47 list[5] 56 list[6] 60 list[7] 75 list[8] list[9] 92 98 Wir erhalten also die sortierte Liste (3, 6, 7, 12, 14, 18, 47, 56, 60, 75, 92, 98). Man kann sich leicht klarmachen, dass dieses Verfahren auch auf Schlüsselmengen übertragen werden kann, die aus verschiedenartigen Komponenten bestehen, wie beispielsweise das Datum. Dann wird erst nach dem Tag, dann nach dem Monat und schließlich nach dem Jahr sortiert, wobei diese letzte Runde selbst wieder aus mehreren Runden bestehen kann. 5.4 Sortierverfahren im Vergleich Die folgende Tabelle fasst die wichtigsten Eigenschaften der betrachteten Sortierverfahren zusammen. Verfahren TREE SORT HEAP SORT SELECTION SORT INSERTION SORT mit Binärsuche MERGE SORT QUICK SORT im Mittel RADIX SORT Vergleiche O(n log n) O(n log n) O(n2 ) O(n2 ) O(n log n) O(n log n) O(n2 ) O(n log n) O(k · n) Zeitbedarf O(n log n) O(n log n) O(n2 ) O(n2 ) O(n2 ) O(n log n) O(n2 ) O(n log n) O(k · n) Platzbedarf 2n + c n+c n+c n+c n+c 2n + c n + c log n 2n + c stabil + − − + − + − + Die folgenden beiden Tabellen geben die Ergebnisse einiger Experimente zum Vergleich des Laufzeitverhaltens von Sortierverfahren wieder. Immer wurden Arrays der Größe n zufällig mit ganzzahligen Werten aus dem Intervall [0, n) belegt. Die erste Tabelle gibt die benötigte Rechenzeit pro Array der jeweiligen Größe in Millisekunden an, die zweite Tabelle in Sekunden. Die Zahlen ergeben sich als Durchschnittswerte für große Stichproben. Laufzeitmessungen dieser Art sind in der Regel zurückhalten zu interpretieren, da sie von vielen Faktoren (Plattform, Compiler, etc.) abhängen. Es zeigt sich 198 KAPITEL 5. SORTIERALGORITHMEN immerhin deutlich, dass QUICK SORT unter allen betrachteten Verfahren das mit Abstand schnellste ist. Und dies gilt, obwohl QUICK SORT das schlechteste Verhalten im schlechtesten Fall unter den sechs schnellsten Sortierverfahren ist. n SELECTION SORT INSERTION SORT INSERTION S. BINARY SEARCH MERGE SORT MERGE S. BOTTOM UP HEAP SORT QUICK SORT QUICK SORT TUNED RADIX SORT 10 0,009 0,006 0,009 0,017 0,015 0,028 0,007 0,006 0,030 50 0,21 0,11 0,08 0,11 0,10 0,23 0,05 0,05 0,16 n SELECTION SORT INSERTION SORT INSERTION S. BINARY SEARCH MERGE SORT MERGE S. BOTTOM UP HEAP SORT QUICK SORT QUICK SORT TUNED RADIX SORT 2000 0,411 0,192 0,026 0,008 0,008 0,019 0,005 0,005 0,012 10000 10,64 5,16 0,57 0,05 0,05 0,12 0,03 0,03 0,10 100 0,8 0,4 0,2 0,3 0,2 0,5 0,1 0,1 0,3 200 3,3 1,6 0,6 0,6 0,5 1,3 0,3 0,3 0,8 50000 270,33 147,26 14,50 0,29 0,35 0,79 0,20 0,21 1,63 500 21,1 10,5 2,3 1,6 1,5 3,8 0,9 0,9 2,1 100000 — — 58,42 0,65 0,73 1,82 0,44 0,43 3,08 1000 94,9 45,5 7,6 3,5 3,5 8,5 2,0 2,0 4,4 200000 — — — 1,4 1,6 3,9 1,0 1,0 12,8 Programm 5.4.1. Zuletzt geben wir hier noch eine Implementierung aller diskutierten Sortierverfahren an, die auch den obigen Tests zugrunde lag: 1 2 3 import java.util.Arrays; import java.util.List; import java.util.LinkedList; 4 5 /** Die Klasse Sort implementiert verschiedene Sortieralgorithmen. * Alle Algorithmen sortieren ein Array ueber dem Typ Comparable * bezueglich der durch compareTo definierten Ordnung. 8 */ 9 public class Sort { 6 7 10 11 12 13 14 15 16 17 18 19 20 21 22 23 /** Implementiert den Algorithmus SELECTION SORT. */ public static void selectionSort( Comparable[ ] a ) { for( int i = 0; i < a.length−1; i++ ) { // Erstes minimales Element in (a[i],. . .,a[n-1]) finden . . . int min = i; // Position des aktuellen minimalen Elements for( int j = i+1; j < a.length; j++ ) if( a[ j ].compareTo( a[ min ] ) < 0 ) min = j; // . . . und mit a[i] vertauschen: swap( a, min, i ); } } selectionSort 5.4. SORTIERVERFAHREN IM VERGLEICH 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 /** Hilfsmethode zur Vertauschung der Array-Elemente a[i] und a[j]. * Wir setzen 0 <= i, j < a.length voraus. */ private final static void swap( Comparable[ ] a, int i, int j ) { Comparable tmp = a[ i ]; a[ i ] = a[ j ]; a[ j ] = tmp; } /** Implementiert den Algorithmus INSERTION SORT. */ public static void insertionSort( Comparable[ ] a ) { for( int i = 1; i < a.length; i++ ) { Comparable tmp = a[ i ]; int j = i; for( ; j > 0 && tmp.compareTo( a[ j−1 ] ) < 0; j−− ) a[ j ] = a[ j−1 ]; a[ j ] = tmp; } } /** Implementiert den Algorithmus INSERTION SORT als Hilfmethode fuer * QUICK SORT auf dem Teil-Array a[left] bis a[right]. */ public static void insertionSort( Comparable[ ] a, int left, int right ) { for( int i = left+1; i <= right; i++ ) { Comparable tmp = a[ i ]; int j = i; for( ; j > left && tmp.compareTo( a[ j−1 ] ) < 0; j−− ) a[ j ] = a[ j−1 ]; a[ j ] = tmp; } } /** Implementiert den Algorithmus INSERTION SORT mit Binaersuche. */ public static void insertionSortBinarySearch( Comparable[ ] a ) { for( int i = 1; i < a.length; i++ ) { Comparable tmp = a[ i ]; // Position fuer a[i] im Bereich a[0] bis a[i-1] binaer suchen: int left = 0; int right = i−1; while( left <= right ) { int mid = ( left + right ) / 2; if( a[ i ].compareTo( a[ mid ] ) < 0 ) right = mid − 1; else if( a[ i ].compareTo( a[ mid ] ) > 0 ) left = mid + 1; else { left = mid; break; } } 199 swap insertionSort insertionSort insertionSortBinarySearch 200 KAPITEL 5. SORTIERALGORITHMEN // Dann a[i] an Position left bringen: for( int j = i; j > left; j−− ) a[ j ] = a[ j−1 ]; a[ left ] = tmp; 75 76 77 78 } 79 80 } 81 82 83 84 85 /** Implementiert den Algorithmus MERGE SORT. */ public static void mergeSort( Comparable[ ] a ) { mergeSort( a, new Comparable[ a.length ], 0, a.length−1 ); } mergeSort 86 87 88 89 90 91 92 93 94 95 96 97 98 /** Eine rekursive Hilfsmethode fuer die Methode mergeSort * sortiert den Bereich a[left] bis a[right]. */ private static void mergeSort( Comparable[ ] a, Comparable[ ] b, int left, int right ) { if( left < right ) { int mid = ( left + right ) / 2; mergeSort( a, b, left, mid ); mergeSort( a, b, mid+1, right ); merge( a, b, left, mid, right ); } } mergeSort 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 /** Implementiert den Algorithmus MERGE SORT ohne Rekursion. */ public static void mergeSortBottomUp( Comparable[ ] a ) { Comparable[ ] b = new Comparable[ a.length ]; for( int step = 1; step < a.length; step *= 2 ) { // Je zwei Bereiche der Groesse step werden zusammengemischt: for( int left = 0; left < a.length; left += 2*step ) { if( left + step < a.length ) { // sind noch zwei Bereich uebrig? int mid = left + step − 1; // Der letzte Bereich muss an Position a.length-1 enden. if( mid + step >= a.length ) merge( a, b, left, mid, a.length − 1 ); else merge( a, b, left, mid, mid + step ); } } } } mergeSortBottomUp 117 118 119 120 121 122 123 124 125 126 127 /** Eine Hilfsmethode fuer die Methode mergeSort. * Die sortierten Bereiche a[left] bis a[mid] und a[mid+1] bis a[right] * werden zu einem sortierten Bereich a[left] bis a[right] gemischt. * Im Array b wird das Ergebnis zwischengespeichert. */ private static void merge( Comparable[ ] a, Comparable[ ] b, int left, int mid, int right ) { int i1 = left; // laeuft hoch bis mid int i2 = mid + 1; // laeuft hoch bis right int bPos = left; // kopiere nach b ab bPos merge 5.4. SORTIERVERFAHREN IM VERGLEICH while( i1 <= mid && i2 <= right ) { if( a[ i1 ].compareTo( a[ i2 ] ) <= 0 ) b[ bPos++ ] = a[ i1++ ]; else b[ bPos++ ] = a[ i2++ ]; } while( i1 <= mid ) // ersten Bereich vollends kopieren b[ bPos++ ] = a[ i1++ ]; while( i2 <= right ) // zweiten Bereich vollends kopieren b[ bPos++ ] = a[ i2++ ]; // Zuletzt von b nach a zurueckkopieren: for( bPos = left; bPos <= right; bPos++ ) a[ bPos ] = b[ bPos ]; 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 201 } /** Implementiert den Algorithmus QUICK SORT. */ public static void quickSort( Comparable[ ] a ) { quickSort( a, 0, a.length−1 ); } quickSort /** Arrays der Groesse CUTOFF oder kleiner werden bei QUICK SORT * mit INSERTION SORT sortiert. CUTOFF muss > 1 sein. */ private final static int CUTOFF = 8; /** Eine rekursive Hilfsmethode fuer die Methode quickSort. */ private static void quickSort( Comparable[ ] a, int left, int right ) { if( left + CUTOFF > right ) // fuer kleine Bereiche insertionSort( a, left, right ); else { // fuer grosse Bereiche int m = partition( a, left, right ); quickSort( a, left, m − 1 ); // kleine Elemente sortieren quickSort( a, m + 1, right ); // grosse Elemente sortieren } } /** Implementiert den Algorithmus QUICK SORT mit kleiner Verfeinerung. */ public static void quickSortTuned( Comparable[ ] a ) { quickSortTuned( a, 0, a.length−1 ); } /** Eine rekursive Hilfsmethode fuer die Methode quickSortTuned. */ private static void quickSortTuned( Comparable[ ] a, int left, int right ) { if( left + CUTOFF > right ) // fuer kleine Bereiche insertionSort( a, left, right ); else { // fuer grosse Bereiche int m = partition( a, left, right ); // Rekursiven Aufruf zuerst fuer den kleineren Bereich: if( m − left < right − m ) { quickSortTuned( a, left, m − 1 ); // die kleinen Elemente sortieren quickSortTuned( a, m + 1, right ); // die grossen Elemente sortieren } quickSort quickSortTuned quickSortTuned 202 KAPITEL 5. SORTIERALGORITHMEN else { quickSortTuned( a, m + 1, right ); // die grossen Elemente sortieren quickSortTuned( a, left, m − 1 ); // die kleinen Elemente sortieren } 180 181 182 183 } 184 185 } 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 /** Eine Hilfsmethode fuer die Methode quickSort. * Gibt die Position des Schnittpunkts zurueck. */ private static int partition( Comparable[ ] a, int left, int right ) { // “Median von drei”: int mid = ( left + right) / 2; if( a[ mid ].compareTo( a[ left ] ) < 0 ) swap( a, mid, left ); if( a[ right ].compareTo( a[ left ] ) < 0 ) swap( a, right, left ); if( a[ right ].compareTo( a[ mid ] ) < 0 ) swap( a, right, mid ); Comparable cut = a[ mid ]; // der Schnittpunkt der Partitionierung swap( a, mid, right−1 ); // der Schnittpunkt kommt an Position right-1 partition 201 // Das eigentliche Partitionieren: int i1 = left; // laeuft hinauf int i2 = right−1; // laeuft hinunter while( i1 < i2 ) { do i1++; while( a[ i1 ].compareTo( cut ) < 0 ); // nun ist a[i1] groesser gleich cut do i2−−; while( a[ i2 ].compareTo( cut ) > 0 ); // nun ist a[i2] kleiner gleich cut if( i1 < i2 ) swap( a, i1, i2 ); } swap( a, i1, right − 1 ); // den Schnittpunkt wieder an seinen Platz bringen return i1; 202 203 204 205 206 207 208 209 210 211 212 213 214 215 } 216 217 218 219 220 221 222 223 224 225 226 /** Implementiert den Algorithmus RADIX SORT. * Jede Zahl im Array a muss < 10^k sein. */ public static void radixSort( Integer[ ] a, int k ) { LinkedList[ ] list = new LinkedList[ 10 ]; // die Listen list[0] bis list[9] for( int i = 0; i < 10; i++ ) list[ i ] = new LinkedList( ); LinkedList tmpList = new LinkedList( ); // die Hilfsliste for( int i = 0; i < a.length; i++ ) tmpList.addLast( a[ i ] ); 227 228 229 230 231 232 for( int i = 0, mult = 1; i < k; i++ ) { while( !tmpList.isEmpty( ) ) { Integer p = (Integer)tmpList.removeFirst( ); list[ ( p.intValue( ) / mult ) % 10 ].addLast( p ); } radixSort 5.4. SORTIERVERFAHREN IM VERGLEICH for( int j = 0; j < 10; j++ ) { tmpList.addAll( list[ j ] ); // list[j] an tmpList anhaengen list[ j ].clear( ); // list[j] leer machen } mult *= 10; 233 234 235 236 237 } // tmpList enthaelt jetzt alle Elemente von a in sortierter Folge tmpList.toArray( a ); 238 239 240 241 203 } 242 243 244 245 246 247 248 249 250 251 252 253 /** Testet, ob zwei Arrays identisch sind, d.h. gleich lang sind und an * gleichen Positionen Elemente enthalten, die true beim Vergleich mit == liefern. */ public static boolean identical( Object[ ] a, Object[ ] b ) { identical if( a.length != b.length ) return false; for( int i = 0; i < a.length; i++ ) if( a[ i ] != b[ i ] ) return false; return true; } 254 255 public static void main( String[ ] args ) { main 256 257 258 259 260 261 262 263 264 265 final int n = 20; final int max = 100; // Erzeuge ein Array der Laenge n, das zufaellige ganzzahlige Werte // gleichverteilt aus dem Bereich [0, max) enthaelt: Integer[ ] a = new Integer[ n ]; for( int i = 0; i < n; i++ ) a[ i ] = new Integer( (int)( Math.random( ) * max ) ); System.out.println( "Das unsortierte Array:\n" + Arrays.asList( a ) ); System.out.println( ); 266 267 268 269 270 271 272 273 274 275 276 // Teste Integer[ ] Integer[ ] Integer[ ] Integer[ ] Integer[ ] Integer[ ] Integer[ ] Integer[ ] Integer[ ] die verschiedenen Sortieralgorithmen: a1 = (Integer[ ])a.clone( ); a2 = (Integer[ ])a.clone( ); a3 = (Integer[ ])a.clone( ); a4 = (Integer[ ])a.clone( ); a5 = (Integer[ ])a.clone( ); a6 = (Integer[ ])a.clone( ); a7 = (Integer[ ])a.clone( ); a8 = (Integer[ ])a.clone( ); a9 = (Integer[ ])a.clone( ); 277 278 279 280 281 282 283 284 285 long time0 = System.currentTimeMillis( selectionSort( a1 ); long time1 = System.currentTimeMillis( insertionSort( a2 ); long time2 = System.currentTimeMillis( insertionSortBinarySearch( a3 ); long time3 = System.currentTimeMillis( mergeSort( a4 ); ); ); ); ); 204 286 287 288 289 290 291 292 293 294 295 296 KAPITEL 5. SORTIERALGORITHMEN long time4 = System.currentTimeMillis( ); mergeSortBottomUp( a5 ); long time5 = System.currentTimeMillis( ); a6 = (Integer[ ]) new VollstaendigerBinaererBaum( a ).heapSort( ); long time6 = System.currentTimeMillis( ); quickSort( a7 ); long time7 = System.currentTimeMillis( ); quickSortTuned( a8 ); long time8 = System.currentTimeMillis( ); radixSort( a9, Integer.toString( max − 1 ).length( ) ); long time9 = System.currentTimeMillis( ); 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 System.out.println( "Das mit selectionSort sortierte Array:\n" + Arrays.asList( a1 ) ); System.out.println( "Das mit insertionSort sortierte Array:\n" + Arrays.asList( a2 ) ); System.out.println( "Das mit insertionSortBinarySearch sortierte Array:\n" + Arrays.asList( a3 ) ); System.out.println( "Das mit mergeSort sortierte Array:\n" + Arrays.asList( a4 ) ); System.out.println( "Das mit mergeSortBottomUp sortierte Array:\n" + Arrays.asList( a5 ) ); System.out.println( "Das mit heapSort sortierte Array:\n" + Arrays.asList( a6 ) ); System.out.println( "Das mit quickSort sortierte Array:\n" + Arrays.asList( a7 ) ); System.out.println( "Das mit quickSortTuned sortierte Array:\n" + Arrays.asList( a8 ) ); System.out.println( "Das mit radixSort sortierte Array:\n" + Arrays.asList( a9 ) ); System.out.println( ); 317 318 // Laufzeiten: 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 System.out.println( "Laufzeiten in Millisekunden:" ); System.out.println( "selectionSort:\t\t\t" + (time1 − time0) + " ms" ); System.out.println( "insertionSort:\t\t\t" + (time2 − time1) + " ms" ); System.out.println( "insertionSortBinarySearch:\t" + (time3 − time2) + " ms" ); System.out.println( "mergeSort:\t\t\t" + (time4 − time3) + " ms" ); System.out.println( "mergeSortBottomUp:\t\t" + (time5 − time4) + " ms" ); System.out.println( "heapSort:\t\t\t" + (time6 − time5) + " ms" ); System.out.println( "quickSort:\t\t\t" + (time7 − time6) + " ms" ); System.out.println( "quickSortTuned:\t\t\t" + (time8 − time7) + " ms" ); System.out.println( "radixSort:\t\t\t" + (time9 − time8) + " ms" ); System.out.println( ); 5.4. SORTIERVERFAHREN IM VERGLEICH // Test, ob alle Sortierverfahren identische Resultate geliefert haben: System.out.println( "Alle Algorithmen liefern sortierte Folgen: " + ( Arrays.equals( a1, a2 ) && Arrays.equals( a1, a3 ) && Arrays.equals( a1, a4 ) && Arrays.equals( a1, a5 ) && Arrays.equals( a1, a6 ) && Arrays.equals( a1, a7 ) && Arrays.equals( a1, a8 ) && Arrays.equals( a1, a9 ) ) ); System.out.println( ); 341 342 343 344 345 346 347 348 349 // Test, ob Stabilitaet verletzt ist: System.out.println( "Test auf Stabilitaet am Beispiel:" ); // Wir wissen, dass insertionSort stabil ist, also dient a2 als Referenzarray: System.out.println( "selectionSort:\t\t\t" + ( identical( a2, a1 ) ? "+" : "-" ) ); System.out.println( "insertionSort:\t\t\t" + "+ (immer +)" ); System.out.println( "insertionSortBinarySearch:\t" + ( identical( a2, a3 ) ? "+" : "-" ) ); System.out.println( "mergeSort:\t\t\t" + ( identical( a2, a4 ) ? "+" : "-" ) + " (immer +)" ); System.out.println( "mergeSortBottomUp:\t\t" + ( identical( a2, a5 ) ? "+" : "-" ) + " (immer +)" ); System.out.println( "heapSort:\t\t\t" + ( identical( a2, a6 ) ? "+" : "-" ) ); System.out.println( "quickSort:\t\t\t" + ( identical( a2, a7 ) ? "+" : "-" ) ); System.out.println( "quickSortTuned:\t\t\t" + ( identical( a2, a8 ) ? "+" : "-" ) ); System.out.println( "radixSort:\t\t\t" + ( identical( a2, a9 ) ? "+" : "-" ) + " (immer +)" ); 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 } } // class Sort 205 206 KAPITEL 5. SORTIERALGORITHMEN Literatur A. Aho, J. Hopcroft, J. Ullman, The Design and Analysis of Computer Algorithms. Addison-Wesley, 1974 K. Arnold, J. Gosling, D. Holmes, Die Programmiersprache Java. Addison-Wesley, 2001 R.H. Güting, Datenstrukturen und Algorithmen. Teubner, 1992 E. Horowitz, S. Sahni, Fundamentals of Data Structures in PASCAL. 3rd Edition, Computer Science Press, 1990 K. Mehlhorn, Data Structures and Algorithms. Band 1 und 2, Springer, 1984 Datenstrukturen und Algorithmen. Band 1 und 2, Teubner, 1986 T. Ottmann, P. Widmayer, Algorithmen und Datenstrukturen. BI-Wissenschaftsverlag, 1990 U. Schöning, Algorithmen – kurz gefaßt. Spektrum Akademischer Verlag, 1997 R. Sedgewick, Algorithmen. 2. Auflage, Addison-Wesley, 2002 R. Sedgewick, Algorithmen in C++, Teil 1-4. 3. Auflage, Addison-Wesley, 2002 M.A. Weiss, Data Structures & Algorithm Analysis in Java. Addison-Wesley, 1999 M.A. Weiss, Data Structures & Problem Solving using Java. 2nd Edition, Addison-Wesley, 2002 207