Inhaltsverzeichnis 1 Spezifikation von Datenstrukturen 2 2 Felder 2.1 Spezifikation von Feldern . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2.2 Spezifikation von unbeschränkten Feldern . . . . . . . . . . . . . . . . . . . . . . . . . . . 3 4 5 3 Arithmetik langer Zahlen 3.1 Spezifikation . . . . . . . . . . . . 3.2 Rekursive Multiplikation . . . . . . 3.3 Schlauere rekursive Multiplikation 3.4 Checker für die Multiplikation . . . . . . . 9 9 13 15 17 4 Listen 4.1 Spezifikation von doppelt verketteten Listen . . . . . . . . . . . . . . . . . . . . . . . . . . 4.2 Vergleich mit Feldern . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 19 19 23 5 Hashing 5.1 Hashing mit Verkettung . . . . . 5.2 Average-Case Analyse . . . . . . 5.3 Universelles Hashing . . . . . . . 5.4 Perfektes Hashing . . . . . . . . . 5.5 Hashing mit offener Adressierung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 24 24 27 28 29 32 6 Bäume 6.1 Binäre Suchbäume . . . . . . . . . . 6.2 2-5-Bäume . . . . . . . . . . . . . . . 6.3 Amortisierte Analyse für 2-5-Bäume 6.3.1 Gesamtheitsmethode . . . . . 6.3.2 Bankkontomethode . . . . . . 6.3.3 Potentialmethode . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 36 36 41 49 49 49 51 7 Prioritätswarteschlangen 7.1 Spezifikation von Prioritätswarteschlangen . . . . . . . . . . . . . . . 7.2 Implementierung von Prioritätswarteschlangen mit binären Heaps . . 7.3 Implementierung von Prioritätswarteschlangen mit Fibonacci Heaps 7.4 Amortisierte Analyse von Fibonacci Heaps . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 52 52 53 55 66 8 Union-Find 8.1 Spezifikation von Partitionen . . . . . . . . . . . . . 8.2 Einfache Implementierungen von Partitionen . . . . 8.3 Implementierung von Partitionen mit Bäumen . . . 8.4 Analyse von Partitionen mit Pfadkomprimierung . . 8.4.1 Die Ackermannfunktionen und ihre Inversen . 8.4.2 Die Analyse . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 70 70 70 74 78 78 81 . . . . . 1 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1 Spezifikation von Datenstrukturen Ein Ziel dieser Vorlesung ist es, verschiedene Datenstrukturen und Algorithmen kennenzulernen. Um eine Datenstruktur einzuführen, werden wir immer die folgende Methode zur Spezifikation verwenden. 1. Definition: Im ersten Abschnitt der Spezifikation werden die Instanzen eines Datentyps (z.B. konkrete Variablen des entsprechenden Typs) definiert. Wichtige Besonderheiten für diesen Datentyp werden hier auch beschrieben. 2. Instanziierung: Hier wird beschrieben, wie man eine Instanz des Datentyps erzeugt. Man kann hier auch festlegen, mit welchem Wert diese Instanz initialisiert wird. 3. Operationen: Hier werden die einzelnen Operationen, die auf Objekte des Datentyps angewendet werden können, beschrieben. Man beschreibt dabei die Schnittstelle der Operation, d.h. man gibt genau an, wie ein Benutzer die Operation aufrufen muß und welches Ergebnis er dann erhält. Oft braucht man Vorbedingungen an Parameter, die hier auch beschrieben werden sollten. Ein Benutzer, der die ersten drei Abschnitte der Spezifikation eines Datentyps kennt, sollte in seinem Programm diesen Datentyp korrekt anwenden können. 4. Implementierung: In diesem Abschnitt steht die Implementierung des Datentyps (mit den unter Abschnitt 3 angegebenen Operationen). 5. Laufzeit: Hier werden Laufzeitangaben für die Operationen gemacht. Dabei wird die Implementierung aus Abschnitt 4 zugrunde gelegt. 2 2 Felder Felder sind eine grundlegende Datenstruktur, die in fast allen Programmiersprachen eingebaut ist. Vorteile der eingebauten C++-Felder: • Sie sind sehr einfach zu verwenden. • Sie sind zeiteffizient. Ein Zugriff auf ein Array-Element kostet konstante Zeit. • Sie sind sehr platzeffizient. Man benötigt im allgemeinen nur soviel Speicherplatz, wie man Elemente hat. Beispiel: /* Hier muss die Groesse des Arrays mit angegeben werden, sonst funktioniert es nicht. */ void initialize(int* A, int d, int groesse) { int i; for(i=0; i<groesse; i++) A[i]=d; } void ausgabe(int* A, int groesse) { int i; for(i=0; i<groesse; i++) cout << A[i] << " "; cout << endl; } void main() { int A[5]; /* Initialisierung der Elemente */ initialize(A,3,5); /* Ausgabe */ ausgabe(A,5); /* Zugriff auf Elemente */ A[3] = A[1] + 2; ausgabe(A,5); /* keine Fehlermeldung bei falschen Grenzen */ A[7] = 5; ausgabe(A,10); } Nachteile der eingebauten C++-Felder: • Die Felder kennen ihre Größe nicht. • Wir können keine generischen Prozeduren schreiben, z.B. initialisieren mit Nullelementen. Aus diesen Gründen werden wir eine neue Datenstruktur array entwickeln. 3 2.1 Spezifikation von Feldern 1. Definition: Eine Instanz des parametrisierten Datentyps array<T> ist eine indizierte Menge von Variablen vom Typ T. Die Anzahl n der Variablen in dieser Menge heißt die Größe der Instanz. Die Indizes sind die Zahlen 0, . . . , n − 1. 2. Instanziierung: array<T> A(int n); erzeugt eine Instanz A von array<T> der Größe n. 3. Operationen: T& A[int i]; int A.size(); void A.init(T x); gibt die Variable mit Index i zurück. Vorbedingung: 0 ≤ i ≤ n − 1. gibt die Größe von A zurück. initialisiert alle Variablen von A mit dem Wert x. Das Beispiel von eben können wir jetzt folgendermaßen implementieren. void ausgabe(array<int> A) { int i; for(i=0; i<A.size(); i++) cout << A[i] << " "; cout << endl; } void main() { array<int> A(5); /* Initialisierung der Elemente */ A.init(3); /* Ausgabe */ ausgabe(A); /* Zugriff auf Elemente */ A[3] = A[1] + 2; ausgabe(A); /* hier Fehlermeldung bei falschen Grenzen */ //A[7] = 5; } 4. Implementierung: template <class T> class array { T* feld; // das Feld int groesse; // die Groesse des Feldes public: /* Konstruktor */ array(int n) { groesse = n; 4 feld = new T[n]; } /* Destruktor */ ~array() { delete[] feld; } // Feld wurde mit new deklariert // und muss deshalb mit delete[] // geloescht werden. /* Zugriffsoperator */ T& operator[](int i) { assert(0<=i && i<groesse); // Fehlermeldung, falls die // Arraygrenzen nicht stimmen. return feld[i]; } /* die Groesse */ int size() { return groesse; } /* initialisiert alle array-Elemente mit x */ void init(const T& x) { int i; for(i=0; i<groesse; i++) feld[i] = x; } }; 5. Laufzeit: Die Erzeugung einer Instanz der Größe n kostet Zeit O(1+Talloc (n)). Dabei ist Talloc (n) die Zeit, die ein Maschinenmodell braucht, um n Speicherzellen anzulegen. Falls n zur Kompilierzeit bekannt ist, braucht man zur Erzeugung nur O(1). In C++ wird bei der Erzeugung einer Instanz von array<T> der Default-Konstruktor von T aufgerufen (falls T nicht bereits ein eingebauter Typ ist, der keinen Default-Konstruktor besitzt). Dann ergibt sich die Zeit für die Erzeugung als O(n + Talloc (n)) bzw. O(n). Zugriff auf die Variable mit Index i, 0 ≤ i < n, kostet Zeit Θ(1). Initialisierung benötigt Zeit Θ(n). Man kann diese Felder nur verwenden, wenn man von vorneherein weiß, wie groß die Datenmenge, die man verwalten will, maximal wird. Vor Benutzen eines Feldes muß also die Größe der Instanz bekannt sein. Bei vielen Anwendungen ist aber diese Größe nicht unbedingt am Anfang bekannt. Wir könnten zum Beispiel den Anwender eine Reihe von Zahlen eingeben lassen, diese nacheinander in einem Feld speichern und das dann sortieren. 2.2 Spezifikation von unbeschränkten Feldern 1. Definition: Eine Instanz des parametrisierten Datentyps u_array<T> ist eine indizierte Menge von Variablen vom Typ T . Die aktuelle Größe einer Instanz ist der größte seit der Erzeugung benutzte Index +1. Am Anfang ist die aktuelle Größe der Instanz gleich 0. Die Indizes sind die Zahlen aus N0 . Bei der Erzeugung des Arrays wird ein Default-Wert angegeben, der jeder noch nicht anders spezifizierten Variablen zugewiesen wird. 5 2. Instanziierung: erzeugt eine Instanz A von u_array<T> der Größe 0 und Default-Element def. u_array<T> A(T def); 3. Operationen: T A[int i]; void A.put(int i, T e); int A.size(); gibt den Wert der Variable mit Index i zurück. Vorbedingung: 0 ≤ i speichert e in der Variablen mit Index i. Vorbedingung: 0 ≤ i gibt die aktuelle Größe von A zurück. 4. Implementierung: template <class T> class u_array { T* feld; int groesse; int max_index; T default_value; // // // // das Feld die Groesse des Feldes groesster verwendeter Index + 1 der Default-Wert /* vergroessert das Feld auf ein Feld der Groesse > i */ void make_room(int i) { do { groesse = naechste_groesse(groesse); } while(i>=groesse); T* neues_feld = new T[groesse]; assert(neues_feld != 0); // falls etwas schief geht for(int j=0; j<max_index; j++) neues_feld[j] = feld[j]; for(int j=max_index; j<groesse; j++) neues_feld[j] = default_value; delete[] feld; feld = neues_feld; } Wenn wir das Feld vergrößern, wird zunächst also ein größeres Feld allokiert und dann das kleiner Feld in das größere Feld umkopiert. Die leeren Positionen des neuen Feldes werden mit dem Default Element besetzt. 1 0 2 1 1 0 2 1 0 0 0 0 public: /* Konstruktor */ u_array(T d) { groesse = 1; max_index = 0; 6 feld = new T[1]; feld[0] = d; default_value = d; } /* Destruktor */ ~u_array() { delete[] feld; } /* Gibt die Groesse zurueck */ int size() { return max_index; } /* Gibt den Wert der i-ten Variablen zurueck */ T operator[](int i) { assert(i>=0); if(i>=groesse) make_room(i); // evtl. array vergroessern if(i>=max_index) max_index = i+1; return feld[i]; } /* Setzt den Wert der i-ten Variablen */ void put(int i, const T& e) { assert(i>=0); if(i>=groesse) make_room(i); // evtl. array vergroessern if(i>=max_index) max_index = i+1; feld[i] = e; } }; 5. Laufzeit: Die Erzeugung eines u_array geht in Zeit O(1). Die Laufzeit eines Zugriffs auf die Variable mit Index i (mit [] oder put) kann beliebig hoch sein, da man je nach Index das Array vergrößern muß. Um dennoch eine Laufzeitaussage machen zu können, sehen wir uns zunächst die Funktion make_room an. Wir haben hier noch nicht genauer gesagt, wie das Feld vergrößert wird. Zu Beginn hat das Feld die Größe s0 = 1. Wird es vergrößert, so erhalten wir die Größen s1 , . . . , sn mit si =naechste_groesse(si−1) für i = 1, . . . , n. Es sei imax der maximal verwendete Index. Dann ist auf jeden Fall sn > imax . Damit nicht zuviel Platz verwendet wird, sollte außerdem sn = O(imax ) sein. Als Ansatz verdoppeln wir immer die Größe: si = 2si−1 , d.h.naechste_groesse(si−1) = 2si−1 . Es ist dann si = 2i s0 = 2i für i = 1, . . . , n. 7 Da imax ≥ sn−1 ist, folgt sn = 2sn−1 ≤ 2imax und damit sn = O(imax ). Wir nehmen jetzt an, wir hätten m Zugriffsoperationen, die jeweils Laufzeit Tj benötigen. Die Gesamtlaufzeit für diese m Operationen ist dann m X Tj . j=1 Bei der j-ten Operation können 2 Fälle auftreten. Entweder muß das Feld nicht vergrößert werden, dann ist Tj = Θ(1), oder das Feld muß vergrößert werden. In diesem Fall sei die neue Größe des Feldes si . Dann ist Tj = Θ(1 + si ), da das Vergrößern des Feldes auf die Größe si durch das Umkopieren Zeit Θ(si ) kostet. Die Gesamtlaufzeit ist damit m X Tj = Θ m + j=1 n X sj i=0 ! . Die Summe können wir jetzt abschätzen. n X sj = i=0 n X 2i i=0 = 2n+1 − 1 = 2sn − 1 = Θ(sn ). Damit erhalten wir für die Gesamtlaufzeit m X Tj = Θ(m + sn ) = O(m + imax ). j=1 Ist imax = O(m), d.h. imax ≤ cm, so wird wirklich ein konstanter Bruchteil (nämlich ≥ imax c ) der array Elemente benutzt. Dann ist die Gesamtlaufzeit für m Zugriffe gerade O(m). Damit sieht man, daß die Laufzeit amortisiert konstant ist. 8 3 Arithmetik langer Zahlen Als Anwendung der u_arrays betrachten wir die Arithmetik langer Zahlen. Problem: Der eingebaute Datentyp int kann nur eine beschränkte Anzahl von Zahlen (meist 232 oder 264 ) darstellen. Idee: Sei B ≥ 2 eine natürlich Zahl (Basis), die man mit int darstellen kann. Sei a ∈ N. Dann gibt es ein n ∈ N mit B n−1 ≤ a < B n . Dann läßt sich a eindeutig darstellen als a= n−1 X ai B i = (an−1 , . . . , a0 )B i=0 mit 0 ≤ ai < B (B-adische Darstellung, z.B. B = 2, B = 10). Dabei ist n die Länge der Darstellung. Die Zahlen aus {0, 1, . . . , B − 1} heißen digits. Wir berechnen also für eine beliebige natürliche Zahl a ihre B-adische Darstellung und speichern diese in einem Feld. Da wir mit beliebig großen Zahlen rechnen wollen, können wir die Größe des Feldes nicht von vorneherein festlegen, wir müssen also die Datenstruktur u_array verwenden. 3.1 Spezifikation 1. Definition: Eine Instanz des Datentyps integer realisiert eine nichtnegative ganze Zahl. 2. Instanziierung: erzeugt eine Instanz A mit Wert 0 erzeugt eine Instanz A mit Wert n integer A; integer A(int n); 3. Operationen: gibt den Wert des i-ten Koeffizienten in der Basisdarstellung zurück. Vorbedingung: 0 ≤ i speichert d als i-ten Koeffizienten der Zahl, die durch A dargestellt ist. Vorbedingung: 0 ≤ i gibt die Größe der Basisdarstellung zurück. gibt den Wert von A + B zurück gibt den Wert von AB zurück. digit A[int i]; void A.put(int i, digit d); int A.size(); integer add(integer A, integer B); integer mult(integer A, integer B); 4. Implementierung: Wir nehmen an, daß wir einen Datenty digit zur Verfügung haben, der die Zahlen {0, . . . , B −1} realisiert. Dabei werden die üblichen Rechenoperationen auf diesem Datentyp modulo B durchgeführt. class integer { u_array<digit>* A; // Zeiger auf den Integer public: // Konstruktoren integer() { A = new u_array<digit>((digit) 0); } integer(int n) 9 { // hier wird ein int in ein Integer uebersetzt. A = new u_array<digit>((digit) 0); int i=0; do { int d = n % BASIS; A->put(i,(digit) d); n = n - d; n = n / BASIS; i++; } while(n!=0); } // gibt den i-ten Koeffizienten zurueck digit operator[](int i) { return (*A)[i]; } /* genau wie bei u_array braucht man put, um den Wert zu setzen. */ void put(int i, const digit& d) { A->put(i,d); } /* gibt die aktuelle Groesse zurueck */ int size() { return A->size(); } }; /* Addiert zwei integer */ integer add(integer a, integer b) { return school_add(a,b); } /* Multipliziert zwei integer */ integer mult(integer a, integer b) { return school_mult(a,b); } /* Addiert drei digits, Rueckgabewert: integer */ /* Bei der Berechnung wird geschummelt! */ integer add_three_digits(digit a, digit b, digit c) { int ai = a.get_digit(); int bi = b.get_digit(); int ci = c.get_digit(); integer s(ai+bi+ci); 10 return s; } /* Addiert zwei integer mit der Schulmethode */ integer school_add(integer a, integer b) { int n = max(a.size(), b.size()); integer s = 0; digit carry = (digit)0; int i; for(i=0; i<n; i++) { integer si = add_three_digits(a[i], b[i], carry); s.put(i, si[0]); carry = si[1]; } if(carry.get_digit()!=0) s.put(n, carry); return s; } /* Multipliziert ein integer mit B^i */ integer shift_left(integer a, int i) { integer b(0); // Hier wird automatisch alles auf 0 gesetzt. int n=a.size(); for(int j=0; j<n; j++) b.put(i+j, a[j]); return b; } /* Multipliziert zwei digits, Rueckgabewert ist integer */ /* Bei der Berechnung wird geschummelt! */ integer mult_two_digits(digit a, digit b) { int ai = a.get_digit(); int bi = b.get_digit(); integer p(ai*bi); return p; } /* Multipliziert ein integer mit einem digit */ integer mult_by_digit(integer a, digit d) { integer p(0); digit p_carry(0); digit carry(0); int n=a.size(); int i; for(i=0; i<n; i++) { integer mi = mult_two_digits(a[i],d); integer pi = add_three_digits(mi[0], p_carry, carry); 11 p.put(i, pi[0]); carry = pi[1]; p_carry = mi[1]; } // Nach Konstruktion kann hier nur etwas einstelliges rauskommen: // a*d < B^n*B = B^{n+1}, also ist das Ergebnis eine hoechstens // n+1 - stellige Zahl. integer pi = add_three_digits((digit)0, p_carry, carry); if((pi[0]).get_digit()!=0) p.put(n, pi[0]); return p; } /* Multipliziert zwei integer mit der Schulmethode */ integer school_mult(integer a, integer b) { integer p(0); int n = b.size(); for(int i=0; i<n; i++) p=school_add(p, shift_left(mult_by_digit(a,b[i]),i)); return p; } 5. Laufzeit: Primitive Operationen seien hier die Addition von drei digits (add_three_digits) bzw. die Multiplikation von zwei digits (mult_two_digits). Lemma 3.1 Um zwei n-stellige Zahlen zu addieren, benötigt school_add Θ(n) primitive Operationen. Beweis: Die for-Schleife in school_add wird n mal durchlaufen, jedes Mal wird add_three_digits aufgerufen. Lemma 3.2 Um zwei n-stellige Zahlen zu multiplizieren, benötigt school_mult O(n2 ) primitive Operationen. Beweis: Es sei Ai :=shift_left(mult_by_digit(a,b[i]),i) die im i-ten Durchlauf der forSchleife von school_mult berechnete Zahl. Wir zeigen zunächst: Ai < (B − 1)B n+i . Die Zahl Ai ist durch Multiplikation einer n-stelligen Zahl mit einem digit (und späterem Shiften) zustandegekommen. Die Zahl a war < B n , die Zahl b[i] ≤ B − 1. Damit ist mult_by_digit(a,b[i])< (B − 1)B n und Ai < (B − 1)B n+i . Jetzt zeigen wir durch Induktion: Im i-ten Durchlauf der for-Schleife von school_mult hat p vor der Addition school_add höchstens n + i Stellen. Induktionsanfang: i = 0: Vor dem ersten Schleifendurchlauf ist p = 0, hat also 0 ≤ n + 0 Stellen. Induktionsschritt: i → i+1: Nach Induktionsvoraussetzung wurde im i-ten Schritt p durch Addition einer höchstens n+i-stelligen Zahl (d.h. einer Zahl < B n+i ) zu der Zahl Ai < (B−1)B n+i bestimmt. Damit ist p zu Beginn des i + 1-ten Schritts eine Zahl, die < B n+i + (B − 1)B n+i = B n+i+1 12 ist, also eine höchstens n + i + 1-stellige Zahl. Es werden also n Additionen von Zahlen mit höchstens n + i bzw. höchstens n + i + 1 Stellen durchgeführt. Dafür benötigt man n−1 X 2 (n + i + 1) = n + i=0 n X i = n2 + i=1 n(n + 1) = O(n2 ) 2 primitive Additionen. Jetzt müssen wir noch die primitiven Additionen und Multiplikationen zählen, die bei der Multiplikation einer höchstens n-stelligen Zahl mit einem digit auftreten. In mult_by_digit wird in jedem Schleifendurchlauf eine primitive Multiplikation und eine primitive Addition durchgeführt, so daß man O(n) primitive Operationen für einen Aufruf dieser Funktion braucht. Damit erhält man insgesamt nO(n) + O(n2 ) = O(n2 ) primitive Operationen für die Multiplikation zweier höchstens n-stelliger Zahlen mit school_mult. 3.2 Rekursive Multiplikation Die Multiplikation nach der Schulmethode benötigt Laufzeit O(n2 ). Wir versuchen jetzt, die Multiplikation rekursiv zu berechnen und damit die Laufzeit zu verbessern (Prinzip: divide-and-conquer). Konkret: Wir haben zwei n-stellige Zahlen a = (an−1 . . . a0 )B und b = (bn−1 . . . b0 )B gegeben. Diese Zahlen teilen wir jetzt in zwei Hälften: a(1) = (an−1 . . . am )B und a(0) = (am−1 . . . a0 )B bzw. b(1) = (bn−1 . . . bm )B und b(0) = (bm−1 . . . b0 )B , wobei m = dn/2e. Es gilt dann a = a(1) · B m + a(0) und b = b(1) · B m + b(0) und somit a · b = (a(1) · B m + a(0) ) · (b(1) · B m + b(0) ) = a(1) · b(1) · B 2m + a(1) · b(0) + a(0) · b(1) · B m + a(0) · b(0) . Eine Multiplikation von zwei n-stelligen Zahlen läßt sich also auf vier Multiplikationen zweier höchstens dn/2e-stelligen Zahlen zurückführen (man beachte, daß dn/2e < n für alle n ≥ 2). integer recursive_mult(integer a, integer b) { make_equal_size(a,b); // a und b sollten die gleiche Groesse haben int n = a.size(); // Sind beide Zahlen einstellig -> primitive Multiplikation if(n==1) return mult_two_digits(a[0],b[0]); // m=ceil{n/2} int m = n/2; if((n%2)==1) m++; // Erzeuge integer a1 integer a0 integer b1 integer b0 a0,a1,b0,b1 = shift_right(a,m); = school_sub(a,shift_left(a1,m)); = shift_right(b,m); = school_sub(b,shift_left(b1,m)); // die 4 Multiplikationen von n/2 stelligen Zahlen integer p1 = recursive_mult(a0,b0); integer p2 = recursive_mult(a0,b1); integer p3 = recursive_mult(a1,b0); integer p4 = recursive_mult(a1,b1); 13 // p2 p2 p4 p1 p1 die Additionen fuer das Ergebnis = school_add(p2,p3); = shift_left(p2,m); = shift_left(p4,2*m); = school_add(p1,p2); = school_add(p1,p4); return p1; } Lemma 3.3 Um zwei n-stellige Zahlen miteinander zu multiplizieren benötigt recursive_mult Θ(n2 ) primitive Operationen. Beweis: Sei T (n) die Anzahl der primitiven Operationen, die recursive_mult für zwei n-stellige Zahlen benötigt. Ist n = 1, so führen wir nur eine primitive Operation durch. Ist n > 1, dann teilen wir die Zahlen in je zwei (etwa) gleichgroße Teile und führen vier recursive_mult’s für je zwei dn/2e-stellige Zahlen durch. Anschließend werden diese Zahlen mit school_add addiert. Wir addieren hier höchstens 2n-stellige Zahlen, so daß wir die Kosten für die drei Additionen mit 3 · 2n angeben können. (Die beiden Zahlen, die zu multiplizieren sind, sind < B n , also ist das Produkt < B 2n . Das Gesamtergebnis ist also eine höchstens 2n-stellige Zahl, d.h. es werden höchstens 2n-stellige Zahlen addiert.) Es gilt also die folgende Rekursionsgleichung: 1 falls n = 1; T (n) = 4 · T (dn/2e) + 3 · 2n falls n > 1. Zur Lösung dieser Rekursionsgleichung können wir das Mastertheorem anwenden. Sei c1 falls n = 1; T (n) = aT (dn/be) + cnk sonst. Dann ist Θ(nk ) Θ(nk log n) T (n) = Θ(nlogb a ) falls k > logb a, falls k = logb a, falls k < logb a. Für unsere Rekursionsgleichung erhalten wir k = 1, logb a = log2 4 = 2 und somit T (n) = Θ(n2 ). Fazit: Wir haben uns hier ziemlich angestrengt, um eine rekursive Multiplikation zu erhalten, sind aber größenordnungsmäßig genauso schlecht wie vorher. Bei Laufzeittests zeigt sich sogar, daß der konstante Faktor für recursive_mult ca. 40 mal höher liegt als für school_mult. Das liegt daran, daß Rekursion mit einem erheblichen Overhead verbunden ist, der bei diesem Algorithmus durch nichts aufgewogen wird. In der Tabelle stehen die Laufzeiten (in Sekunden) für die Multiplikation von n-stelligen integers mit school_mult (Ts ) und recursive_mult (Tr ). (Die Laufzeiten sind aus dem Skript vom Sommersemester 2000). n 8000 16000 32000 64000 128000 Ts 0.01 0.05 0.19 0.75 3.1 Tr 0.44 1.76 7.1 28.75 114.4 14 3.3 Schlauere rekursive Multiplikation Idee: (Karatsuba und Ofman, 1963) In unserem rekursiven Algorithmus für die Multiplikation läßt sich eine Multiplikation sparen, zum Preis von drei zusätzlichen Additionen bzw. Subtraktionen: (a, b, n, m, a(1) , a(0) , b(1) , b(0) wie vorher) a · b = (a(1) · B m + a(0) ) · (b(1) · B m + b(0) ) = a(1) · b(1) · B 2m + a(1) · b(0) + a(0) · b(1) · B m + a(0) · b(0) = a(1) · b(1) · B 2m + (a(1) + a(0) ) · (b(1) + b(0) ) − a(1) · b(1) − a(0) · b(0) · B m + a(0) · b(0) . Bemerkung: Wir müssen aufpassen; a(1) + a(0) und b(1) + b(0) haben möglicherweise (dn/2e + 1) Stellen, und dn/2e + 1 = n für n = 2, 3. Aufspalten macht daher nur Sinn für n ≥ 4. Für n ≤ 3 benutzen wir einfach irgendeine Methode, z.B. die Schulmethode. integer clever_mult(integer a, integer b) { make_equal_size(a,b); // a und b sollten die gleiche Groesse haben int n = a.size(); // Sind beide Zahlen <= dreistellig -> primitive Multiplikation if(n<=3) return school_mult(a,b); int m = n/2; if((n%2)==1) m++; // Erzeuge integer a1 integer a0 integer b1 integer b0 integer c1 integer c2 a0,a1,b0,b1 und a0+a1, b0+b1 = shift_right(a,m); = school_sub(a,shift_left(a1,m)); = shift_right(b,m); = school_sub(b,shift_left(b1,m)); = school_add(a1,a0); = school_add(b1,b0); // die 3 Multiplikationen von n/2 + 1 stelligen Zahlen integer p1 = clever_mult(a0,b0); integer p2 = clever_mult(a1,b1); integer p3 = clever_mult(c1,c2); // p3 p3 p3 p2 p1 p1 die Additionen fuer das Ergebnis = school_sub(p3,p1); = school_sub(p3,p2); = shift_left(p3,m); = shift_left(p2,2*m); = school_add(p1,p2); = school_add(p1,p3); return p1; } Lemma 3.4 Um zwei n-stellige Zahlen miteinander zu multiplizieren benötigt clever_mult O(nlog2 3 ) = O(n1.58 ) primitive Operationen. Beweis: Sei T (n) die Anzahl der primitiven Operationen, die clever_mult für zwei n-stellige Zahlen benötigt. Dann gilt O(1) falls n ≤ 3; T (n) ≤ 3 · T (dn/2e + 1) + O(n) falls n > 3. 15 Das ist nicht unbedingt ein brauchbares Format. Wir definieren daher eine etwas geänderte Funktion T̃ (n) := T (n + 2). Es ist T (d(n + 2)/2e + 1) = T (dn/2e + 2) = T̃ (dn/2e) und daher O(1) falls n ≤ 1; T̃ (n) ≤ 3 · T̃ (dn/2e) + O(n) falls n > 1. Das Mastertheorem gibt uns dann T̃ (n) = O(nlog2 3 ), und damit auch T (n) = T̃ (n−2) = O((n−2)log2 3 ) = O(nlog2 3 ). In der Tabelle stehen die Laufzeiten (in Sekunden) für die Multiplikation von n-stelligen integers mit school_mult (Ts ) und clever_mult (Tc ). (Die Laufzeiten sind aus dem Skript vom Sommersemester 2000). Am Anfang ist school_mult besser, obwohl die asymptotische Schranke schlechter ist. Verdoppelt man die Länge der Zahlen, dann vervierfacht sich die Laufzeit von school_mult, während sich die Laufzeit von clever_mult nur (etwa) verdreifacht. n 80000 160000 320000 640000 1280000 2560000 5120000 Ts 1.19 4.73 19.77 99.97 469.6 1907 7803 Tc 5.85 17.51 52.54 161 494 1457 4310 Da die Laufzeit von school_mult für kleinere Eingaben besser ist als die Laufzeit von clever_mult, macht es Sinn, unterhalb einer gewissen Größe n0 nicht mehr aufzuspalten, sondern das Ergebnis mit school_mult zu berechnen. Frage: Welche Größe n0 ist sinnvoll? Theoretische Antwort: Wir bestimmen die Arbeit T als Funktion von n, n0 : C1 · n2 falls n ≤ n0 T (n, n0 ) = 3T n2 , n0 + C2 · n sonst. Dabei sind C1 , C2 > 0 Konstanten. Gesucht ist ein optimales n0 , d.h. wir lösen die Extremwertaufgabe minn0 ∈N T (n, n0 ). Der Einfachheit halber nehmen wir an, daß n = n0 · 2k ist. Es folgt T (n, n0 ) = 3k C1 n20 + k−1 X (3/2)i C2 n i=0 = = k 3 C1 n20 3k C1 n20 + 2nC2 ((3/2)k − 1) + 2nC2 (3/2)k − 2nC2 . Mit 2k = n/n0 und (n/n0 )log2 3 = 2k log2 3 = 3k folgt 2−log2 3 1−log2 3 − 2nC2 . T (n, n0 ) = nlog2 3 C1 n0 + 2C2 n0 Um die Extremwertaufgabe zu lösen, müssen wir diese Funktion nach n0 ableiten: ∂ 1−log2 3 − log 3 T (n, n0 ) = nlog2 3 (2 − log2 3)C1 n0 + 2(1 − log2 3)C2 n0 2 . ∂n0 16 Danach müssen wir die Gleichung ∂ ∂n0 T (n, n0 ) = 0 lösen. Wir erhalten: ∂ 1−log2 3 − log 3 T (n, n0 ) = 0 ⇔ (2 − log2 3)C1 n0 + 2(1 − log2 3)C2 n0 2 = 0 ∂n0 1−log2 3 ⇔ (2 − log2 3)C1 n0 − log2 3 = −2(1 − log2 3)C2 n0 ⇔ (2 − log2 3)C1 n0 = 2(log2 3 − 1)C2 2(log2 3 − 1) C2 C2 ⇔ n0 = · ≈ 2.8 · . (2 − log2 3) C1 C1 Beachte insbesondere, daß n0 nicht von n abhängt (was erstmal nicht so klar ist). Experimentelle Antwort: Da n0 nicht von n abhängt, können wir auch versuchen, es experimentell zu bestimmen, indem wir für festes n einfach verschiedene Werte von n0 ausprobieren, und den nehmen, der die beste Laufzeit liefert. Bemerkung: Es gibt eine noch schnellere Multiplikation (Schönhage und Strassen, 1971). Damit lassen sich zwei n-stellige Zahlen mit O(n log n · log log n) primitiven Operationen multiplizieren. Der konstante Faktor, der hier in dem O versteckt ist, ist aber so riesig, daß der Algorithmus für alle denkbaren Werte von n sogar langsamer als school_mult ist. 3.4 Checker für die Multiplikation Ein Checker ist ein Programm, welches testet, ob das Ergebnis richtig sein kann. Dabei soll die Laufzeit für den Checker minimal im Vergleich zum ganzen Algorithmus sein. Ein Checker für die Multiplikation zweier Zahlen macht also folgendes. Wenn man drei Zahlen a, b, c eingibt, so testet der Checker, ob a · b = c sein kann. Es macht natürlich keinen Sinn, im Checker noch einmal die Multiplikation zu wiederholen. Statt dessen kann man einen Test machen, wie er z.B. auf dem 6. Übungsblatt erarbeitet wird. Der Checker gibt keine 100 prozentige Garantie, daß das Ergebnis stimmt, aber man kann i.a. eine Wahrscheinlichkeit ausrechnen, mit der das Ergebnis stimmt, wenn der Checker ohne Probleme durchläuft. Beispiel: Ein Beispiel für einen Checker für die Multiplikation ist die sogenannte Neunerprobe. Dabei bestimmt man die Quersumme der beiden Zahlen a und b, multipliziert diese und bestimmt die Quersumme des Ergebnisses. Diese muß gleich der Quersumme von c sein. Betrachten wir zum Beispiel die Multiplikation 5247 · 4678 = 24545466. hier ist a = 5247, b = 4678, und c = 24545466. Die Quersummen sind Q(a) = Q(5 + 2 + 4 + 7) = Q(18) = Q(1 + 8) = 9, Q(b) = Q(4 + 6 + 7 + 8) = Q(25) = Q(2 + 5) = 7, Q(Q(a) · Q(b)) = Q(9 · 7) = Q(63) = Q(6 + 3) = 9, Q(c) = Q(2 + 4 + 5 + 4 + 5 + 4 + 6 + 6) = Q(36) = Q(3 + 6) = 9. Damit wissen wir allerdings noch nicht, ob das Ergebnis stimmt. Man kann mit dieser Methode Fehler entdecken: Für 27 · 6 = 83 erhalten wir die Quersummen Q(27) = 9, Q(6) = 6, Q(Q(27) · Q(6)) = Q(54) = 9, Q(83) = Q(11) = 2, 17 also stimmt die Rechnung nicht. Leider findet man nicht alle Fehler, denn die Rechnung 27 · 6 = 153 erfüllt diesen Checker, obwohl das Ergebnis nicht stimmt (27 · 6 = 162). Wieso funktioniert diese Methode? Dazu sehen wir uns die Dezimaldarstellung näher an. Eine Zahl a in Dezimaldarstellung wird geschrieben als a= n−1 X ai 10i i=0 mit 0 ≤ ai ≤ 9. In der Übung zeigen wir a·b=c ⇒ a · b ≡ c mod 9 (und allgemeiner). Betrachten wir die Zahl a mod 9, so erhalten wir wegen 10 ≡ 1 mod 9 und damit 10i ≡ 1 mod 9 für alle i: n−1 X a≡ ai mod 9. i=0 Die Quersumme einer Zahl ist also nichts anderes als diese Zahl modulo 9. 18 4 Listen Neben Feldern sind auch Listen wichtige grundlegende Datenstrukturen. Wir betrachten hier nur doppelt verkettete Listen. 4.1 Spezifikation von doppelt verketteten Listen 1. Definition: Eine Instanz L des Datentyps List<T> stellt eine Folge von Elementen vom Typ T dar. Jedes Listenelement kennt seinen Nachfolger und seinen Vorgänger (doppelt verkettete Liste). Einfügen und Löschen von Elementen können in konstanter Zeit durchgeführt werden. Wir schreiben zur Abkürzung handle für einen Zeiger auf ein Listenelement. Das ist nicht ganz korrekt, da das Listenelement auch vom Typ T abhängt und wir somit handle<T> schreiben müßten. 2. Instanziierung: konstruiert eine leere Liste vom Typ List<T>. List<T> L; 3. Operationen: bool empty(); handle first(); handle last(); handle insert(handle pos, T& x); handle erase(handle pos); void clear(); void splice(handle pos, List<T>& L2, handle first, handle last); handle find(T& x); gibt true zurück, wenn die Liste leer ist, sonst false. liefert einen Zeiger auf das erste Element der Liste zurück. liefert einen Zeiger auf das letzte Element der Liste zurück. fügt das Element x vor Position pos ein und liefert einen Zeiger auf das Listenelement x. löscht das Element auf Position pos und liefert einen Zeiger auf das auf pos folgende Element zurück. löscht die komplette Liste (ohne den Destruktor aufzurufen) bewegt die Elemente aus dem Bereich [first, last] aus der Liste L2 in die Liste L und fügt sie vor der Position pos ein. Vorbedingungen: pos ist ein gültiger Zeiger in L und first, last sind gültige Zeiger in L2 findet erstes Vorkommen von x in L und liefert einen Zeiger auf dieses Element zurück. 4. Implementierung: Für die Implementierung definieren wir uns zunächst einen Datentyp, der ein Listenelement darstellt. template <class T> class list_node { public: list_node* prev; list_node* next; T inf; // der Vorgaenger // der Nachfolger // das Element selbst }; next prev inf Für einen Zeiger auf ein solches Listenelement schreiben wir kurz handle. 19 template <class T> class list { typedef list_node<T>* handle; handle head; // der Kopf der Liste Idee: Eine Liste besitzt immer einen Listenkopf. Dieser zeigt auf das erste Element der Liste. Das letzte Element der Liste zeigt auf den Listenkopf. Eine leere Liste besteht dann nur aus dem Kopf. H H public: // Loescht alle Listenelemente bis auf den Kopf void clear() { handle tmp = head->next; while(tmp!=head) { handle tmpnext = tmp->next; delete tmp; tmp = tmpnext; } head->next = head; head->prev = head; } // Konstruktor, erzeugt leere Liste list() { head = new list_node<T>; head->next = head; head->prev = head; } // Destruktor ~list() { clear(); delete head; } 20 // Testet, ob die Liste leer ist bool empty() { if(head->next==head) return true; return false; } // gibt das erste Listenelement zurueck handle first() { return head->next; } // gibt das letzte Listenelement zurueck handle last() { return head->prev; } // fuegt ein Element in die Liste vor pos ein handle insert(handle pos, T x) { handle tmp = new list_node<T>; tmp->inf = x; tmp->next = pos; tmp->prev = pos->prev; pos->prev ->next = tmp; pos->prev = tmp; return tmp; } // loescht das Element pos handle erase(handle pos) { assert(pos!=head); handle next_node = pos->next; handle prev_node = pos->prev; prev_node->next = next_node; next_node->prev = prev_node; delete pos; return next_node; } 21 /* Bewegt die Elemente aus dem Bereich [first, last] aus der Liste L2 in die Liste L und fuegt sie vor pos ein. */ void splice(handle pos, list<T>& L2, handle first, handle last) { // Hier sollten noch irgendwelche Abfragen rein, um die // Vorbedingungen zu ueberpruefen. // Hier passiert nichts if(first==last->next) return; if(pos==last->next) return; if(pos==first) return; // die Teilliste aus L2 loeschen first->prev->next = last->next; last->next->prev = first->prev; // die Teilliste in L einfuegen pos->prev->next = first; first->prev = pos->prev; last->next = pos; pos->prev = last; } // Sucht das Element x in der Liste handle find(T x) { handle tmp = head->next; while(tmp!=head) { if(tmp->inf == x) return tmp; tmp = tmp->next; } assert(tmp!=head); } 22 }; 5. Laufzeit: Die Operationen clear und find laufen i.a. die ganze Liste durch und brauchen deshalb O(n) Zeit (bei n Listenelementen). Alle anderen Operationen brauchen nur konstante Zeit O(1). 4.2 Vergleich mit Feldern Zunächst verhalten sich Felder und Listen ziemlich gleich. Um n Elemente zu speichern braucht man bei beiden Varianten O(n) Speicherplatz. Einfügen und Löschen geht jeweils in konstanter Zeit O(1). Unterschiede zwischen diesen Datenstrukturen gibt es nur, wenn man die Elemente als geordnet betrachtet. In einer Liste wie in einem Feld sind die Elemente in einer bestimmten Weise angeordnet. In einem Feld ist diese Ordnung durch den Index gegeben, in einer Liste wird sie durch Zeiger bestimmt. Hat man n geordnete Elemente gespeichert und will ein neues Element an seiner richtigen Stelle einfügen, so kann man das bei Listen in konstanter Zeit tun, da hier nur Zeiger umgehängt werden. Bei Feldern braucht man Zeit O(n), da man dann alle Elemente ab dieser Stelle umkopieren muß. Im schlechtesten Fall, wenn man das neue Element an die erste Stelle einfügt, muß man n Elemente umkopieren. Analog sieht es aus, wenn man ein bestimmtes Element löschen will. Will man dagegen bei n angeordneten Elementen das k-te Element (z.B. das k-t kleinste Element) finden, so eignen sich dafür Felder besser. In einem Feld steht das k-te Element an der k − 1-ten Stelle (falls es nur verschiedene Elemente gibt). Man kann in konstanter Zeit darauf zugreifen. Bei einer Liste muß man die Liste bis zum k-ten Element durchlaufen, was Zeit O(k) kostet. Je nach der Anwendung sollte man sich also genau überlegen, ob man Felder oder Listen verwendet. 23 5 Hashing Problem: Wir wollen eine Menge von Daten verwalten. Es soll leicht sein, Daten einzugeben und zu löschen, sowie Daten nach einem bestimmten Schlüssel zu suchen. Beispiele: 1. Wir wollen die Anwohner einer Straße nach Hausnummern geordnet auflisten. Später wollen wir die Anwohner der Hausnummer k finden. (Wir gehen hier davon aus, daß nicht mehrere Familien in einem Haus wohnen.) Für dieses Problem eignen sich Felder als Datenstruktur. In A[i] speichern wir die Anwohner der Hausnummer i. Einfügen, Löschen, und Suche (nach Hausnummern geordnet) benötigen Zeit O(1). Das Array benötigt gerade soviel Speicherplatz, wie es Daten gibt. 2. Wir wollen die Daten von 100 Studenten nach Matrikelnummern geordnet verwalten. Idee: Matrikelnummern sind auch Zahlen, also speichern wir die Daten in einem Feld an der entsprechenden Stelle, genau wie oben. Problem: Matrikelnummern sind zu groß (7-stellige Zahlen). Wir bräuchten ein Feld der Größe 108 (oder mindestens 3 · 107 , wenn man davon ausgeht, daß die höchste Matrikelnummer mit 2 anfängt), um nur 100 Daten zu speichern. Der Speicherplatzverbrauch ist also unangemessen hoch. 2. Idee: Ändere die Datenstruktur array so, daß eine untere und eine obere Schranke angegeben werden kann. Dann braucht man deutlich weniger Speicher, wenn man als untere Schranke die kleinste Matrikelnummer und als obere Schranke die größte verwendete Matrikelnummer angibt. Problem: Die Spannbreite der Matrikelnummern kann trotzdem ziemlich groß sein. Man braucht eventuell trotzdem ein Array der Größe 106 , um die 100 Daten zu speichern. Wenn man z.B. die Daten aus der Anmeldung zur Vorlesung Info 5 nimmt, braucht man immer noch ein Array der Größe 1.5 · 106 . 3. Idee: Speichere die Daten in einem Array der Größe 100, wobei nur die letzten beiden Stellen der Matrikelnummer betrachtet werden (d.h. die Matrikelnummer modulo 100). Es kann dabei natürlich passieren, daß mehrere Matrikelnummern die gleichen Ziffern haben. Dann werden sie in einer Liste an der entsprechenden Stelle gespeichert. Wir hoffen dabei, daß nicht zu viele Matrikelnummern an die gleiche Stelle geschrieben werden. Sprechweise: Es sei U das Universum, aus dem die Elemente stammen (oBdA U = {0, . . . , N − 1} für ein N ∈ N). In unserem Beispiel ist N = 108 . Unser Ziel ist es, eine Teilmenge S ⊂ U geeignet zu verwalten. Dabei sollen die Operationen Einfügen, Löschen und Suchen möglichst schnell realisiert sein. Eine Abbildung h : U → {0, . . . , m − 1} heißt Hashfunktion. Die Zahl m ist die Größe der Hashtabelle. die Hashtabelle selbst ist eine Datenstruktur, in der die Elemente aus U mit Hilfe der Hashfunktion verwaltet werden. Wie bei dem Beispiel oben mit den Datensätzen der 100 Studierenden macht es Sinn zwischen dem Datensatz zu unterscheiden (HashItem) und der Schlüssel-Information (Key), die einfach eine Komponente von einem HashItem ist und anhand derer sich das HashItem eindeutig identifizieren läßt; im Beispiel oben ist das die Matrikelnummer. 5.1 Hashing mit Verkettung Bei Hashing mit Verkettung ist die Hashtabelle einfach ein Feld von (einfach oder doppelt verketteten) Listen. 24 0 10 inf_10 30 inf_30 34 inf_34 14 inf_14 1 2 11 inf_11 20 inf_20 3 4 21 inf_21 37 inf_37 24 inf_24 5 6 7 10 inf_10 24 inf_24 27 inf_27 8 17 inf_17 9 34 inf_34 30 inf_30 14 inf_14 Hashtabelle: Hashing mit Verkettung 27 inf_27 31 inf_31 U S 1. Definition: Eine Instanz H des Datentyps HashTable stellt eine Hashtabelle mit Einträgen vom Typ HashItem dar. Die Hashtabelle realisiert Hashing mit Verkettung. Einfügen eines Elementes und Löschen eines Elementes (bei gegebener Position) geht in konstanter Zeit. Die Hashfunktion hash() zu dieser Tabelle wird extra angegeben. 2. Instanziierung: Konstruiert eine leere Hashtabelle der Größe size mit Einträgen vom Typ HashItem. HashTable H(int size); 3. Operationen: void H.insert(HashItem i); void H.erase(HashItem i); void H.erase(HashItem i, handle pos); handle H.find(Key k); Fügt das Element i in die Hashtabelle ein. Löscht das Element i. Löscht das Element i in Position pos. Findet die Position in der Hashtabelle, an der das HashItem mit Schlüssel k steht und gibt diese zurück. 4. Implementierung: Ich betrachte hier HashItems, deren Schlüssel vom Typ key und deren Information ein string ist. class HashTable { array<list<HashItem> >* A; // fuer die Listen typedef list_node<HashItem>* handle; public: // Konstruktor HashTable(int size) { A = new array<list<HashItem> >(size); } // Destruktor 25 ~HashTable() { delete A; } // ein HashItem einfuegen void insert(HashItem i) { key x = i.getKey(); // i wird an die Liste in (*A)[h(x)] angehaengt handle last = ((*A)[hash(x)]).last(); ((*A)[hash(x)]).insert(last, i); } // ein HashItem loeschen void erase(HashItem i) { key x = i.getKey(); handle pos = ((*A)[hash(x)]).find(i); ((*A)[hash(x)]).erase(pos); } // ein HashItem in einer bestimmten Position loeschen void erase(HashItem i, handle pos) { key x = i.getKey(); ((*A)[hash(x)]).erase(pos); } // ein HashItem finden handle find(key x) { // Erzeuge ein HashItem mit Schluessel x. // Bei Vergleich von HashItems wird nur der Schluessel // verglichen, deshalb findet man das HashItem (inf_x, x), // wenn man nach tmp sucht. HashItem tmp("", x); handle pos = ((*A)[hash(x)]).find(tmp); return pos; } }; 5. Laufzeit: Einfügen eines Elementes und Löschen eines Elementes bei gegebener Position gehen in konstanter Zeit O(1). Schwieriger wird es bei find. Dabei ist klar, daß Löschen eines Elementes ohne Angabe der Position die gleiche Laufzeit benötigt wie find. Wir nehmen jetzt an, daß die Anzahl der aktuell gespeicherten Elemente n ist. Diese Elemente bilden eine Teilmenge S von U . Im schlechtesten Fall entartet Hashing mit Verkettung zu einer einzigen Liste. Der Zugriff auf ein Element kann dann bis zu Θ(n) Zeit benötigen. Genauer: Für eine feste Hashfunktion h und für x ∈ U sei Cx,h (S) := |{y ∈ S | x 6= y, h(x) = h(y)}| 26 die Größe der Konfliktmenge von x bzgl. S. Ein Zugriff auf das Element x benötigt dann Zeit O(1 + Cx,h (S)). Dabei kann Cx,h (S) jeden Wert zwischen 0 und n − 1 annehmen. 5.2 Average-Case Analyse Um die mittlere Laufzeit der Zugriffe beim Hashing mit Verkettung zu bestimmen, muß man den Erwartungswert der Zahl Cx,h (S) berechnen. Wir gehen dabei von einem Universum U der Größe N und einer Hashtabelle der Größe m aus. Die Hashfunktion h : U → {0, . . . , m − 1} sei fest gewählt. Wir wollen eine Teilmenge S ⊂ U der Größe n mit der Hashtabelle verwalten. Dazu nehmen wir an, daß alle n-elementigen Teilmengen S von U gleichwahrscheinlich sind. Der Ereignisraum ist dann Ω = {S ⊂ U | |S| = n}. Die Wahrscheinlichkeit für die Auswahl einer Menge S ist P (S) = 1 1 . = |Ω| N n Für festes x ∈ U ist dann Cx,h (S) eine Zufallsvariable, die S auf die Anzahl der Elemente in S, die mit x kollidieren, abbildet. Lemma 5.1 Sei h : U → {0, . . . , m − 1} eine Funktion, die U gleichmäßig über {0, . . . , m − 1} verteilt, d.h. für alle i ∈ {0, . . . , m − 1} ist |{x ∈ U : h(x) = i}| ≤ dN/me, und sei, für ein n ∈ N, S eine zufällige n-elementige Teilmenge aus dem Universum U . Dann gilt für ein beliebiges x ∈ U , daß n E(Cx,h (S)) ≤ . m Beweis: Definiere die Indikatorvariablen 1 , falls x 6= y, h(x) = h(y); δh (x, y) := 0 , sonst. P Dann ist Cx,h (S) = δh (x, y) und es gilt y∈S X X Cx,h (S) = X δh (x, y) S⊆U,|S|=n y∈S S⊆U,|S|=n = X X δh (x, y) y∈U S⊆U,|S|=n,y∈S = = X N −1 δh (x, y) n−1 y∈U N −1 X δh (x, y). n−1 y∈U Die letzte Summe ist genau eins weniger wie die Anzahl der Elemente aus dem Universum, die auf h(x) abgebildet werden (eins weniger, weil δh (x, x) = 0), also nach der Annahme aus dem Lemma über h höchstens dN/me − 1 ≤ N/m. Mit X X 1 E(Cx,h (S)) = Cx,h (S)P (S) = Cx,h (S) N S⊆U,|S|=n S⊆U,|S|=n n folgt E(Cx,h (S)) ≤ N −1 · n−1 N n N m = (N −1)!·N (n−1)!·(N −n)!·m N! n!·(N −n)! 27 = n N ! · n! · (N − n)! = . N ! · (n − 1)! · (N − n)! · m m Definition 5.1 Für eine Hashtabelle der Größe m, in der n Elemente gespeichert sind, heißt β = n/m der Belegungsfaktor. Korollar 5.1 Unter den Annahmen des Lemmas ist die erwartete Laufzeit für einen Zugriff auf ein beliebiges Element stets O(1 + β), wenn β der Belegungsfaktor der Tabelle vor der Operation war (O(β) für die Lokalisierung des Elementes und O(1) für den eigentlichen Zugriff ). Problem: Das Lemma macht Annahmen über die Eingabe, die natürlich nicht immer realistisch sind. Es sind nicht immer alle n-elementigen Teilmengen gleich wahrscheinlich. 5.3 Universelles Hashing Idee: Wir machen gar keine (Zufälligkeits-)Annahmen über die Menge S der in der Hashtabelle zu speichernden Elemente und wählen stattdessen die Hashfunktion zufällig. Definition 5.2 Es sei c ∈ R. Eine Klasse H von Funktionen von U nach {0, . . . , m − 1} heißt cuniversell, falls für alle x, y ∈ U mit x 6= y gilt: |{h ∈ H : h(x) = h(y)}| ≤ c · |H|/m. Bei der Average-Case Analyse waren die Hashfunktion h und x fest, während wir eine Wahrscheinlichkeitsannahme über S gemacht haben. Jetzt wählen wir S und x fest, d.h. S ist die Menge der aktuell gespeicherten Elemente und x ∈ U ist fest gewählt. Dann definieren wir die Zufallsvariable Cx,S (h) := |{y ∈ S | x 6= y, h(x) = h(y)}|. Lemma 5.2 Sei h zufällig aus einer c-universellen Klasse von Funktionen von U → {0, . . . , m − 1} gewählt und S eine beliebige n-elementige Teilmenge von U . Dann gilt für ein beliebiges x ∈ U , daß E(Cx,S (h)) ≤ c · n/m. Beweis: Der Ereignisraum Ω ist jetzt einfach H; das Wahrscheinlichkeitsmaß ist wieder das uniforme: 1 . P (h) = |H| Der Beweis geht analog zu dem vorherigen, nur einfacher: X XX Cx,S (h) = δh (x, y) h∈H y∈S h∈H = XX δh (x, y) y∈S h∈H = X y∈S,y6=x ≤ ≤ X y∈S,y6=x |{h ∈ H : h(x) = h(y)}| c|H| m c|H|n , m da |S| = n ist. Dann ist E(Cx,S (h)) ≤ c|H|n = c · n/m. m|H| Korollar 5.2 Unter den Annahmen des Lemmas ist die erwartete Laufzeit für einen Zugriff auf ein beliebiges Element stets O(1 + cβ), wenn β der Belegungsfaktor der Tabelle vor der Operation war (O(cβ) für die Lokalisierung des Elementes und O(1) für den eigentlichen Zugriff ). 28 Wir zeigen jetzt, daß es solche c-universellen Klassen von Hashfunktionen überhaupt gibt. Beispiel: Sei m eine Primzahl und U = {0, . . . , mr − 1} (d.h. N = mr ). Für a, x ∈ U seien a = (ar−1 . . . a0 )m und x = (xr−1 . . . x0 )m die Darstellungen zur Basis m. Man definiert a × x := r−1 X a i xi . i=0 Für jedes a ∈ U betrachten wir jetzt die Funktion ha : U → {0, . . . , m − 1}, ha : x 7→ a × x mod m. Die Klasse dieser Funktionen hat also N Elemente. Sie ist 1-universell. Zum Beweis seien x, y ∈ U mit x 6= y. In der Darstellung zur Basis m ist x = (xr−1 . . . x0 )m , y = (yr−1 , . . . , y0 )m . Da diese beiden Zahlen verschieden sind, unterscheidet sich ihre Basisdarstellung an mindestens einer Stelle. Sei also xj 6= yj . Die Frage ist, für wieviele a ∈ U die Gleichheit ha (x) = ha (y) bzw. r−1 r−1 X X a i xi ≡ ai yi mod m i=0 i=0 gilt. Diese Gleichheit ist gleichbedeutend mit aj (xj − yj ) ≡ X 0≤i<r,i6=j ai (yi − xi ) mod m. Dabei ist, wegen xj 6= yj auf der linken Seite xj − yj 6≡ 0 mod m. Wieviele a ∈ U kann man wählen, so daß diese Gleichheit erfüllt ist? Für gegebene x und y kann man r − 1 Zahlen ai ∈ {0, . . . , m − 1} beliebig wählen und erhält dann eine Zahl A mod m auf der rechten Seite. Dann ist aj eindeutig bestimmt durch aj ≡ A/(xj − yj ) mod m. (Da m eine Primzahl ist, ist aj auch wirklich eindeutig.) Wir haben also ≤ mr−1 Möglichkeiten für die Wahl von a und damit |{ha | a ∈ U, ha (x) = ha (y)}| ≤ mr−1 = mr N = . m m Ein Beispiel für eine 2-universelle Klasse von Hashfunktionen findet man auf dem Übungsblatt. 5.4 Perfektes Hashing Bei dem oben beschriebenen Hashverfahren können immer noch Kollisionen auftreten. Diese werden dann dadurch gelöst, daß man die Daten in Listen verwaltet. In diesem Fall kann der Zugriff auf ein Element relativ teuer sein, da man erst eine Liste durchsuchen muß. Besser wäre es, wenn gar keine Kollisionen auftreten würden, d.h. wenn die Hashfunktion auf der Menge S der zu verwaltenden Elemente injektiv wäre. In diesem Fall würde jeder Elementzugriff in konstanter Zeit realisierbar sein. Eine solche Hashfunktion heißt perfekte Hashfunktion. Wie oben sei m die Größe der Hashtabelle und n = |S|. Eine perfekte Hashfunktion existiert natürlich immer, wenn m ≥ n ist. Damit diese Funktion sinnvoll ist, muß sie allerdings in konstanter Zeit berechenbar sein. Wir werden jetzt eine solche Funktion suchen. Dabei betrachten wir eine 1-universelle Klasse H von Hashfunktionen. Satz 5.1 Es sei H eine 1-universelle Klasse von Hashfunktionen, m die Größe der Hashtabelle und n = |S|. n • Ist m > , dann enthält H eine Funktion h, die auf S injektiv ist. Diese Funktion kann in 2 Zeit O(|H|n) gefunden werden. 29 n , dann sind mindestens die Hälfte der Funktionen aus H injektiv auf S. Eine solche 2 Funktion kann durch einen randomisierten Algorithmus in erwarteter Zeit O(n) gefunden werden. • Ist m > 2 • Ist m > n−1, dann ist für mindestens die Hälfte aller Funktionen h ∈ H die Anzahl der Kollisionen < n. Beweis: Wir betrachten die Zufallsvariable CS (h) := |{{x, y}| x, y ∈ S, x 6= y, h(x) = h(y)}|. Diese gibt die Zahl der Kollisionen von h in S an. Wir können sie auch als Summe X δh (x, y), CS (h) = {x,y}⊂S,x6=y mit δh wie oben, schreiben. Da H 1-universell ist, folgt für den Erwartungswert von δh (x, y) für feste x, y: E(δh (x, y)) = 0 · P (δh (x, y) = 0) + 1 · P (δh (x, y) = 1) = P (δh (x, y) = 1) ≤ |H|/m 1 = . |H| m Für den Erwartungswert von CS (h) folgt dann E(CS (h)) = X E(δh (x, y)) = {x,y}⊂S,x6=y 1 m X 1= {x,y}⊂S,x6=y n 2 · 1 . m n , dann ist E(CS (h)) < 1. Das bedeutet, daß im Durchschnitt CS (h) < 1 ist, also 2 muß es ein h ∈ H geben, für das CS (h) < 1 gilt. Nach Konstruktion ist dann für diese Funktion CS (h) = 0, d.h. die Funktion h ist injektiv auf S. Man kann jetzt alle Funktionen aus H testen und für jede Funktion in O(n) Zeit feststellen, ob diese injektiv auf S ist oder nicht (s. Übung). n , dann ist E(CS (h)) < 12 . In diesem Fall gilt für mindestens die Hälfte aller • Ist m > 2 2 Funktionen aus H, daß CS (h) = 0 ist. Es gilt nämlich wegen der Markow Ungleichung (s. Übung): • Ist m > P (CS (h) ≥ 1) ≤ E(CS (h)) < 1 . 2 Wäre für mindestens die Hälfte aller h ∈ H der Wert CS (h) ≥ 1, so wäre auch die Wahrscheinlichkeit ≥ 12 . Da dies nicht der Fall ist, muß für mindestens die Hälfte aller Funktionen CS (h) = 0 sein, d.h. diese Funktionen sind injektiv auf S. Wählt man sich eine solche Funktion zufällig, so kann man in Zeit O(n) testen, ob diese injektiv auf S ist. Dies ist mit Wahrscheinlichkeit ≥ 21 der Fall, so daß die erwartete Zahl der Wahlwiederholungen, bis man auf eine injektive Funktion stößt, höchstens 2 ist. • Ist m > n − 1, so folgt E(CS (h)) < n 2 und mit der Markow Ungleichung sieht man P (CS (h) ≥ n) ≤ 1 E(CS (h)) < . n 2 Die Argumentation geht genauso wie eben. n = 2 n(n − 1)), dann können wir in vernünftiger Zeit (erwarteter Zeit O(n)) eine Hashfunktion finden, die auf der Menge S injektiv ist. Die Zugriffe auf die einzelnen Elemente laufen dann in konstanter Zeit. Was wissen wir jetzt? Wenn wir die Größe der Hashtabelle groß genug wählen (m ≥ 2 30 Einen Nachteil hat dieses Verfahren allerdings: die Hashtabelle ist unnötig groß. Durch zweistufiges perfektes Hashing soll die Hashtabelle auf eine Größe O(n) reduziert werden. Idee: Genau wie bei Hashing mit Verkettung werden verschiedene Elemente auf eine Position gehasht. Allerdings werden die Elemente dann nicht in einer Liste gespeichert, sondern jeweils in einem Array, wobei wieder eine Hashfunktion (diesmal eine andere) angewendet wird. Diese zweite Hashfunktion muß injektiv sein. Dabei muß man natürlich darauf achten, daß nur eine beschränkte Anzahl von Elementen auf die gleiche Position gehasht wird, damit die Größen der einzelnen Hashtabellen nicht zu groß werden. Dazu beschreiben wir die Zufallsvariable CS (h) ein wenig anders. Es sei für 0 ≤ i < m BS,i (h) := |{x ∈ S | h(x) = i}|. Das ist also die Anzahl der Elemente aus S, die auf das Feld i abgebildet werden. Wieviele Kollisionen gibt es aufdem Feld i? Die Zahl der Kollisionen auf diesem Feld ist gerade die Zahl der Paare auf diesem BS,i (h) . Damit folgt Feld, also 2 CS (h) = m−1 X i=0 BS,i (h) 2 . Zweistufiges Perfektes Hashing: Wir wählen m = n und erhalten somit aus dem Satz, daß für mindestens die Hälfte aller h ∈ H gilt m−1 X i=0 BS,i (h) 2 = CS (h) < n. Ein solches h findet man in erwarteter Zeit O(n). Für jedes i sei BS,i (h) +1 mi = 2 2 und Hi eine Menge von universellen Hashfunktionen U → {0, 1, . . . , mi − 1}. Für jedes i sucht man ein hi ∈ Hi , welches injektiv auf der Menge {x ∈ S | h(x) = i} ist. Diese Suche geht nach dem Satz in erwarteter Zeit O(BS,i (h)). Insgesamt hat man für diesen Schritt die erwartete Laufzeit ! m−1 X O BS,i (h) = O(n). i=0 Für jedes i baut man sich jetzt eine Hashtabelle der Größe mi , in der mit der Hashfunktion hi die Elemente, die durch h auf i abgebildet werden, injektiv gespeichert werden. Die Gesamtgröße der Tabelle ist dann m+ m−1 X i=0 mi m−1 X BS,i (h) = n+ 2 +1 2 i=0 m−1 X BS,i (h) = n+2 +m 2 i=0 m−1 X BS,i (h) = 2n + 2 2 i=0 < 2n + 2n = 4n. Damit haben wir folgenden Satz bewiesen: Satz 5.2 Mit Hilfe des zweistufigen Verfahrens kann eine perfekte Hashtabelle der Größe O(n) in erwarteter Zeit O(n) erzeugt werden. 31 0 10 25 1 16 31 1 2 37 7 12 3 3 23 18 4 19 24 34 26 8 Hashfunktion: h(x) = x mod 5 0 25 1 26 2 7 10 1 h_0(x) = floor(x/2) mod 3 16 37 31 h_1(x) = x mod 13 12 h_2(x) = x mod 7 3 3 18 4 24 19 8 23 34 h_3(x) = x mod 13 h_4(x) = x mod 7 Hashfunktion: h(x) = x mod 5 5.5 Hashing mit offener Adressierung Eine andere Möglichkeit, Speicherplatz zu sparen, ist Hashing mit offener Adressierung. Idee: Für jedes Element werden solange verschiedene Hashwerte ausprobiert, bis eine freie Position gefunden ist. Auf diese Weise können bis zu m Elemente direkt in der Tabelle gespeichert werden, für Tabellengröße m. Formal: Unsere Hashfunktion ist nicht mehr von der Form h : U → {0, . . . , m − 1}, sondern eine Funktion h : U × {0, . . . , m − 1} → {0, . . . , m − 1}. Für ein x ∈ U probiert man der Reihe nach die Positionen h(x, 0), h(x, 1), . . . , h(x, m − 1) aus. Variante 1: (engl. linear probing) h(x, i) = (h(x) + i) mod m, wobei h : U → {0, . . . , m − 1}. Hier werden für jedes x alle Tabellenpositionen einmal ausprobiert. Variante 2: (engl. double hashing) h(x, i) = (h1 (x) + i · h2 (x)) mod m, wobei h1 : U → {0, . . . , m − 1} und h2 : U → {1, . . . , m}. Hier werden für ein x genau dann alle Tabellenpositionen ausprobiert, wenn m und h2 (x) teilerfremd sind (was nahe legt, m als eine Primzahl zu wählen). Es gibt allerdings auch einige Probleme bei dieser Variante des Hashing. • Anders als bei Hashing mit Verkettung darf der Belegungsfaktor nie > 1 werden (wenn die Tabelle voll ist, ist sie voll). • Löschen kann nicht so ohne weiteres unterstützt werden. Wenn man ein Element, das z.B. an Stelle h(x, i) steht, löscht, entsteht unter Umständen eine Lücke. Darauf muß man achten, wenn man später ein Element sucht oder ein neues Element einfügen will. 32 • Die Position eines Elementes hängt nicht nur von dem Element selbst ab, sondern auch von der Reihenfolge der Eingabe. Hashing mit offener Adressierung, h(x,i) = (x+i) mod 12 15, 47, 33, 26, 83, 58, 87, 34, 22, 46, 73, 24 83 26 15 33 47 87 83 26 15 33 58 47 34 83 26 15 87 26 15 87 33 58 47 33 58 47 ......... 83 34 22 46 73 24 Zur Average-Case Analyse nehmen wir an, daß die Positionen in einer zufälligen Reihefolge ausprobiert werden, und daß die Anzahl der gespeicherten Elemente n kleiner ist als die Größe der Hashtabelle m (also Belegungsfaktor β < 1). Weiter sei h : U × {0, . . . , m − 1} → {0, . . . , m − 1} und für x ∈ U sei die Folge h(x, 0), . . . , h(x, m − 1) eine zufällige Permutation der Folge 0, . . . , m − 1 ist (insbesondere werden also alle Positionen ausprobiert). Lemma 5.3 Für festes x ∈ U betrachten wir die Zufallsvariable X := min{i ∈ N0 : h(x, i) nicht belegt}. Dann ist m+1 =O E(1 + X) ≤ m−n+1 1 1−β Beweis: Es ist X ≥ i genau dann, wenn h(x, 0), . . . , h(x, i − 1) alle belegt sind, also wenn {h(x, 0), . . . , h(x, i−1)} eine Teilmenge von {j : j-te Stelle belegt} ist. Die Menge {j : j-te Stelle belegt} hat nun n Elemente, und {h(x, 0), . . . , h(x, i−1)} ist nach Annahme eine zufällige i-elementige Teilmenge von {0, . . . , m − 1}. Somit ist n n! i n! (m − i)! = i!·(n−i)! P (X ≥ i) = = · . m! m! (n − i)! m i!·(m−i)! i Wegen P (X ≥ n + 1) = 0 folgt E(X) = ∞ X i=1 P (X ≥ i) = n X i=1 n P (X ≥ i) = 33 n! X (m − i)! . m! i=1 (n − i)! Es ist für i = 1, . . . , n: n! (m − i)! · m! (n − i)! = = = = Man erhält also die Summe n!(m − n)! m! n!(m − n)! m! (m − i)! (n − i)!(m − n)! (m − i)! · (n − i)!((m − i) − (n − i))! · m−i (m−i)−(n−i) m n m−i m−n . m n n n m−1 n! X (m − i)! 1 X 1 X m−i i = m . = m m! i=1 (n − i)! m−n m−n n i=1 n i=m−n Jetzt kann man sich überlegen (s. Übung), daß für alle 0 ≤ k ≤ m − 1 gilt: m−1 X i=k i k = m . k+1 Damit folgt dann: n n! X (m − i)! m! i=1 (n − i)! = 1 m n = = = m m−n+1 n!(m − n)! m! · m! (m − n + 1)!(n − 1)! n!(m − n)! (m − n + 1)!(n − 1)! n . m−n+1 Es folgt, daß E(1 + X) = (m + 1)/(m − n + 1) = O(1/(1 − n/m)) ist. Korollar 5.3 Unter unseren Annahmen geht Einfügen eines Elementes in die Hashtabelle in Zeit O(1/(1 − n/m)) = O(1/(1 − β)). Beweis: Wenn man ein neues Element einfügt, muß man solange suchen, bis ein freier Platz gefunden wird. Das geht nach dem Lemma in Zeit O(1/(1 − n/m)). Dann wird das Element an die entsprechende Stelle geschrieben. Für die Laufzeit ist auch wichtig, wie lange man nach einem bestimmten Element sucht. Satz 5.3 Unter unseren Annahmen geht Suchen eines Elementes in die Hashtabelle in erwarteter Zeit m 1 1 1 1 O m ln + = O ln + n 1−n/m n β 1−β β . Beweis: Wir suchen nach dem Element x. Nehmen wir an, wir hätten x als i-tes Element eingefügt. 1 Nach dem Lemma haben wir beim Einfügen von x dann O 1−i/m Stellen durchsuchen müssen, bis wir 1 eine freie Stelle für x gefunden haben. Um x zu finden, müssen wir also O 1−i/m Stellen durchsuchen. Leider wissen wir nicht, als wievieltes Element x eingefügt wurde. Um dennoch eine Analyse machen 34 zu können, bilden wir den Mittelwert über alle Elemente. n−1 1 1X n i=0 1 − i/m n−1 1X m n i=0 m − i = n−1 mX 1 n i=0 m − i = m m X 1 n j=m−n+1 j = m (Hm − Hm−n ) n = mit Hk = k X 1 j=1 j . Für diese Zahl (k-te harmonische Zahl) hat man die Abschätzung ln(k) ≤ Hk ≤ ln(k) + 1. Damit erhält man n−1 1 1X n i=0 1 − i/m ≤ = m (ln(m) + 1 − ln(m − n)) n m m m + . ln n m−n n 35 6 Bäume Ziel: Entwerfe Datenstruktur zur Verwaltung einer Menge von Elementen mit Ordnung, die folgende Operationen effizient unterstützt: insert, erase, find, minimum, maximum, succ, pred. 6.1 Binäre Suchbäume Bei der Suche nach einem Element in einem Feld von geordneten Elementen kann man binäre Suche anwenden. Dabei betrachtet man immer das mittlere Element. Je nachdem, ob das gesuchte Element größer oder kleiner als das mittlere Element ist, sucht man in der linken oder in der rechten Hälfte weiter. Beispiel: Wir betrachten binäre Suche für ein sortiertes Feld ganzer Zahlen. Gleichzeitig bauen wir uns einen binären Baum, der die betrachtenden Zahlen enthält. 0 1 2 5 2 7 3 4 5 6 7 8 9 10 11 10 17 24 31 39 42 44 53 67 0 1 2 2 5 7 l 3 4 5 6 7 8 9 10 11 10 17 24 31 39 42 44 53 67 m 0 1 2 5 r m l 2 7 r 24 l 24 7 r m 3 4 5 6 7 8 9 10 11 10 17 24 31 39 42 44 53 67 42 24 7 42 5 2 17 10 39 31 53 44 67 Jetzt betrachten wir nur den binären Baum. Hier können wir genauso wie bei der binären Suche nach einem bestimmten Element suchen. Dabei fangen wir an der Wurzel des Baumes an. Ist das gesuchte Element kleiner als das Element im betrachteten Knoten, so gehen wir nach links weiter. Ist es größer, dann gehen wir nach rechts weiter. (Falls es gleich ist, haben wir es natürlich gefunden.) Enden wir an 36 einem Blatt, dann ist das Element nicht im Baum enthalten. Man kann sich leicht davon überzeugen, daß die binäre Suche in einem sortierten Feld genau das gleiche ist wie die Suche in einem entsprechenden binären Suchbaum. 1. Definition: Eine Instanz B des parametrisierten Datentyps bin_tree<T> stellt einen binären Baum mit Elementen vom Typ T dar. Dieser Typ muß eine Ordnung besitzen. Alle Elemente im linken Unterbaum eines Knotens sind kleiner, alle Elemente im rechten Unterbaum eines Knotens sind größer als das Element im Knoten selbst. (Wir gehen stillschweigend davon aus, daß wir nur verschiedene Elemente betrachten.) Wir schreiben zur Abkürzung handle für einen Zeiger auf einen Knoten des Baumes. Das ist nicht ganz korrekt, da der Knoten auch vom Typ T abhängt und wir somit handle<T> schreiben müßten. 2. Instanziierung: Konstruiert einen leeren binären Suchbaum vom Typ bin_tree<T>. bin_tree<T> B; 3. Operationen: Gibt das Minimum des Teilbaumes mit Wurzel v zurück. Gibt das Maximum des Teilbaumes mit Wurzel v zurück. Sucht das Element x im Baum und gibt es zurück. Findet das kleinste Element das größer v ist. Findet das größte Element das kleiner v ist. Löscht den Teilbaum mit Wurzel v. Fügt das Element x in die richtige Stelle im Baum ein. Löscht das Element v aus dem Baum. handle minimum(handle v) handle maximum(handle v) handle find(T x) handle succ(handle v) handle pred(handle v) void clear(handle v) handle insert(T x) void erase(handle v) 4. Implementierung: Für die Implementierung definieren wir uns zunächst einen Datentyp, der einen Knoten des binären Baums darstellt. /* Realisiert ein Element des binaeren Suchbaums */ template <class T> class bin_tree_node { public: bin_tree_node* parent; bin_tree_node* left; bin_tree_node* right; T inf; // // // // der das das das Vorgaenger linke Kind rechte Kind Element selbst }; Für einen Zeiger auf einen solchen Knoten schreiben wir kurz handle. template <class T> class bin_tree { typedef bin_tree_node<T>* handle; handle root; // die Wurzel des Baums public: 37 // Konstruktor bin_tree() { root = 0; } // loescht den Teilbaum mit Wurzel v void clear(handle v) { if(v->left) clear(v->left); if(v->right) clear(v->right); delete v; } // Destruktor ~bin_tree() { clear(root); } // findet ein Element handle find(T x) { handle v=root; while(v!=0 && v->inf!=x) { if(x<v->inf) v = v->left; else v = v->right; } return v; } handle succ(handle v) bestimmt das kleinste Element, das größer v->inf ist. Es gibt zwei Fälle, wo dieses Element stehen kann. Falls v ein rechtes Kind hat, dann sind in diesem Teilbaum alle Elemente größer v->inf. Das Minimum des rechten Teilbaums ist dann das gesuchte Element. v v 1. Fall 2. Fall Falls v kein rechtes Kind hat, findet man das nächstgrößte Element folgendermaßen. Man geht von v aus solange nach oben, wie man das rechte Kind des Elternknotens ist. Ist man das linke Kind des Elternknotens, dann ist der Elternknoten der gesuchte Knoten. 38 Analog findet man das größte Element welches kleiner als v->inf ist. // findet das naechstgroesste Element handle succ(handle v) { if(v->right!=0) return minimum(v->right); handle p = v->parent; while(p!=0 && v==p->right) { v = p; p = v->parent; } return p; } // fuegt ein Element ein handle insert(T x) { // neuen Knoten erzeugen handle u = new bin_tree_node<T>; u->inf = x; u->left = 0; u->right = 0; u->parent = 0; // finde die Stelle, an die u eingefuegt wird handle v = root; handle p = 0; while(v!=0) { p = v; if(x < v->inf) v = v->left; else v = v->right; } // fuege u ein u->parent = p; if(p==0) root = u; // falls u die Wurzel ist else // sonst { if(x < p->inf) p->left = u; else p->right = u; } return u; } Beim Löschen eines Elementes gibt es drei verschiedene Fälle. Dabei sind die ersten beiden Fälle ziemlich einfach. Wenn der Knoten v keine Kinder hat, kann man ihn einfach löschen. Hat er nur ein Kind, so wird er gelöscht und das Kind an den Elternknoten von v angehängt. Beispiele für diese Fälle sind: 39 v 1. Fall v 2. Fall Der dritte Fall ist komplizierter. Wenn der Knoten v zwei Kinder hat, können wir ihn nicht einfach löschen, da wir die zwei Kinder nicht beide an den Elternknoten anhängen können. Aber wir kennen in diesem Fall einen Knoten, der höchstens ein Kind hat: succ(v). Wir sind jetzt nämlich im ersten Fall von succ(v). Dieser Knoten ist das Minimum des Teilbaums, dessen Wurzel das rechte Kind von v ist. Wie wir bei minimum gesehen haben, hat succ(v) in diesem Fall höchstens ein Kind. Der Knoten succ(v) könnte also einfach wie in den ersten beiden Fällen gelöscht werden. Und genau das tun wir auch. Dazu kann man zunächst feststellen, daß man succ(v) an die Stelle von v schreiben kann. Dann wird der alte Knoten succ(v) gelöscht. // loescht ein Element aus dem Baum void erase(handle v) { handle u,w; if(v->left == 0 || v->right == 0) u = v; // Fall 1, Fall 2 else u = succ(v); // Fall 3 // Suche einen Nachfolger, der nicht leer ist. // Es existiert hoechstens einer. if(u->left != 0) w = u->left; else w = u->right; // Loesche den Knoten. if(w!=0) w->parent = u->parent; if(u->parent==0) root = w; // die Wurzel wird geloescht else { if(u == u->parent->left) u->parent->left = w; else u->parent->right = w; } if(u != v) { u->left = v->left; v->left->parent = u; u->right = v->right; v->right->parent = u; 40 u->parent = v->parent; if(v->parent == 0) root = u; else { if(v == v->parent->left) v->parent->left = u; else v->parent->right = u; } } delete v; } }; 5. Laufzeit: Es seien n die Anzahl der Elemente und t die Tiefe des Baumes. Im günstigsten Fall ist t = O(log n). Allerdings kann es auch passieren, daß t = n ist. Bei allen Operationen wird der Baum höchstens einmal von oben nach unten oder von unten nach oben durchlaufen. Damit haben alle Operationen eine Laufzeit von O(t). Da die Laufzeit der Operationen von der Tiefe des Baumes abhängt, ist es wichtig, die Tiefe möglichst gering zu halten. Bei n Elementen ist die minimale Tiefe eines binären Baumes log n. Das Ziel ist also, einen binären Suchbaum der Tiefe O(log n) zu verwalten. Eine Möglichkeit dazu erhält man durch Anwendung von Rotationen (s. 9. Übung). Eine zweite Möglichkeit sind Rot-Schwarz Bäume, die wir in der Vorlesung nicht betrachten. 6.2 2-5-Bäume In diesem Abschnitt betrachten wir Bäume, die anders strukturiert sind als binäre Suchbäume. Der wesentliche Unterschied ist dabei, daß das Speichern der Daten blattorientiert erfolgt, d.h. alle Daten stehen in den Blättern. In den Knoten, die keine Blätter sind, stehen Hinweise, mit denen man in einfacher Weise auf bestimmte Daten zugreifen kann. 1. Definition: Eine Instanz B des parametrisierten Datentyps two_five_tree<T> stellt einen Baum über einer Menge von Elementen vom Typ T dar. Auf dieser Menge existiert eine Ordnung. Die Elemente werden blattorientiert, in geordneter Reihenfolge gespeichert. Jeder Knoten, der kein Blatt ist, hat mindestens 2 aber höchstens 5 Kinder. Ein solcher Knoten enthält als Information den Wert des Maximums des Teilbaums mit ihm selbst als Wurzel. Wir schreiben zur Abkürzung handle für einen Zeiger auf einen Knoten des Baumes. Das ist nicht ganz korrekt, da der Knoten auch vom Typ T abhängt und wir somit handle<T> schreiben müßten. Beispiel: 47 15 3 6 10 31 15 17 22 47 23 41 25 31 39 47 Lemma 6.1 Für einen 2-5-Baum der Tiefe t mit n Blättern gilt log5 n ≤ t ≤ log2 n. Beweis: Da jeder Knoten höchstens 5 Kinder hat, gilt n ≤ 5t . Da jeder Knoten aber mindestens 2 Kinder hat, gilt n ≥ 2t . 2. Instanziierung: two_five_tree<T> B; Konstruiert einen leeren 2-5-Baum vom Typ two_five_tree<T>. 3. Operationen: handle minimum(handle v) handle maximum(handle v) handle find(T x) void clear(handle v) handle insert(T x) void erase(handle v) Gibt das Minimum des Teilbaumes mit Wurzel v zurück. Gibt das Maximum des Teilbaumes mit Wurzel v zurück. Sucht das Element x im Baum und gibt es zurück. Löscht den Teilbaum mit Wurzel v. Fügt das Element x in die richtige Stelle im Baum ein. Löscht das Element v aus dem Baum. 4. Implementierung: Für die Implementierung definieren wir uns zunächst einen Datentyp, der einen Knoten des 2-5-Baums darstellt. Die Kinder eines Knotens sind in einer Liste gespeichert. Gleichzeitig weiß jeder Knoten, wieviele Kinder er hat. Der Zugriff auf ein bestimmtes Kind bleibt trotz Liste konstant, da die Anzahl der Kinder (und damit die Liste) beschränkt ist. Man hätte die Kinder auch in einem Feld der Länge 5 speichern können. Trotzdem müßte man sich dann die Anzahl der Kinder merken (damit diese nicht unter 2 geht) und außerdem immer die Elemente umkopieren, sobald ein Kind eingefügt oder gelöscht wird. /* Realisiert ein Element des 2-5-Baums */ template <class T> class two_five_tree_node { public: two_five_tree_node* parent; int anzahl_kinder; list<two_five_tree_node*> kinder; T inf; // // // // der die die das Vorgaenger Anzahl der Kinder Kinder Element selbst }; Für einen Zeiger auf einen solchen Knoten schreiben wir kurz handle. template <class T> class two_five_tree { typedef two_five_tree_node<T>* handle; handle root; // die Wurzel des Baums public: // Konstruktor two_five_tree() 42 { root = 0; } // loescht den Teilbaum mit Wurzel v void clear(handle v) { while(v && v->anzahl_kinder) { clear(((v->kinder).last())->inf); v->anzahl_kinder--; } delete v; } // Destruktor ~two_five_tree() { clear(root); } // findet das Minimum handle minimum(handle v) { while(v->anzahl_kinder) v = ((v->kinder).first())->inf; return v; } // findet das Maximum handle maximum(handle v) { while(v->anzahl_kinder) v = ((v->kinder).last())->inf; return v; } // findet ein Element handle find(T x) { handle v=root; if(x>v->inf) return 0; list_node<two_five_tree_node<T>* > * kind; while(v->anzahl_kinder) { kind = (v->kinder).first(); while((kind->inf)->inf < x) kind = kind->next; v = kind->inf; } if(v->inf != x) return 0; return v; } Diese Operationen sind einfach zu implementieren, wenn man den Überblick über die pointer behält... 43 Schwieriger wird es, wenn man an der Baumstruktur etwas ändert, d.h. einen Knoten einfügt oder löscht. Das Problem dabei ist, die Invariante (jeder Knoten hat mindestens 2 und höchstens 5 Kinder) aufrechtzuerhalten. Zuerst betrachten wir das Einfügen. Dabei wird erst ein neuer Knoten erzeugt, dann die Stelle gefunden, an der der neue Knoten angehängt wird. Der neue Knoten kommt dann an die richtige Stelle. Der Elternknoten des neuen Knotens erhält also ein Kind mehr. Sind es insgesamt noch ≤ 5 Kinder, ist alles in Ordnung. Sind es 6 Kinder, müssen wir den Baum noch verändern, damit die Invariante aufrechterhalten bleibt. In diesem Fall werden wir die 6 Kinder aufspalten. Dazu erzeugen wir einen neuen Elternknoten, der auf der gleichen Ebene wie der Elternknoten mit 6 Kindern steht. Dieser erhält 3 der 6 Kinder. // fuegt ein Element ein handle insert(T x) { // neuen Knoten erzeugen handle u = new two_five_tree_node<T>; u->inf = x; u->anzahl_kinder = 0; // finde die Stelle, an die u eingefuegt wird handle v=root; if(!v) // u ist der erste Knoten des Baumes { v = new two_five_tree_node<T>; v->inf = x; v->anzahl_kinder = 1; (v->kinder).insert((v->kinder).last(), u); v->parent = 0; u->parent = v; root = v; return u; } // es gibt bereits Knoten im Baum list_node<two_five_tree_node<T>* > * kind; if(x>v->inf) // x ist groesser als das Maximum { while(v->anzahl_kinder) { kind = (v->kinder).last(); v = kind->inf; } kind = kind->next; } else { while(v->anzahl_kinder) { kind = (v->kinder).first(); while((kind->inf)->inf < x) kind = kind->next; v = kind->inf; } } 44 // u wird in die Liste von v->parent nach kind eingefuegt handle p = v->parent; (p->kinder).insert(kind, u); p->anzahl_kinder++; u->parent = p; while(p && p->inf < x) { p->inf = x; p = p->parent; } // aufspalten bei zuvielen Kindern if((v->parent)->anzahl_kinder > 5) spalten(v->parent); return u; } Spalten A B C D E F A B C D E F Spaltet man die Kinder eines Knotens v auf, so erhält der Elternknoten von v ein Kind mehr. Es kann dabei passieren, daß der Elternknoten dann 6 Kinder hat. Dann wird wieder aufgespalten. Dies zieht sich solange durch den Baum nach oben, bis die Invariante wieder erfüllt ist. Aufpassen muß man, wenn die Kinder der Wurzel aufgespalten werden. Dann wird eine neue Wurzel mit 2 Kindern erzeugt. Dadurch erhöht sich die Tiefe des Baumes dann um 1. // der Knoten v hat 6 Kinder -> aufspalten void spalten(handle v) { // erzeuge neuen Knoten handle w = new two_five_tree_node<T>; w->inf = v->inf; w->anzahl_kinder = 3; v->anzahl_kinder = 3; // und die Listen spalten list_node<two_five_tree_node<T>* > * kind; kind = (v->kinder).first(); kind = kind->next; kind = kind->next; // das 3. Listenelement v->inf = (kind->inf)->inf; (w->kinder).splice((w->kinder).last(), v->kinder, kind->next, (v->kinder).last()); kind = (w->kinder).first(); (kind->inf)->parent = w; kind = kind->next; (kind->inf)->parent = w; kind = kind->next; 45 (kind->inf)->parent = w; // was passiert, wenn v die Wurzel ist? -> neue Wurzel if(v==root) { // neue Wurzel erzeugen root = new two_five_tree_node<T>; root->anzahl_kinder = 2; root->inf = w->inf; v->parent = root; w->parent = root; (root->kinder).insert((root->kinder).last(), v); (root->kinder).insert(((root->kinder).last())->next, w); } else { // wird an den Elternknoten von v angehaengt handle p = v->parent; w->parent = p; p->anzahl_kinder++; (p->kinder).insert(((p->kinder).last())->next, w); // evtl. weiter aufspalten if(p->anzahl_kinder == 6) spalten(p); } } Beim Löschen wird zunächst das entsprechende Blatt gelöscht. Man darf dann nicht vergessen, evtl. die Knoteneinträge auf dem Weg von der Wurzel zu dem gelöschten Blatt zu ändern. Ein Problem tritt hier auf, wenn der Elternknoten des zu löschenden Knotens jetzt nur noch ein Kind hat. In diesem Fall hat man zwei Möglichkeiten. Der Elternknoten hat auf jeden Fall einen Geschwisterknoten (da jeder Knoten mindestens 2 Kinder hat). Hat der Knoten neben dem Elternknoten mindestens 3 Kinder, so kann man eines dieser Kinder verschieben (stehlen). Hat der Knoten neben dem Elternknoten nur 2 Kinder, dann wird einer der beiden Elternknoten gelöscht und der andere erhält die 3 Kinder. // loescht ein Element aus dem Baum void erase(handle v) { // Blatt v loeschen handle p = v->parent; p->anzahl_kinder--; list_node<two_five_tree_node<T>* > * pos; pos = (p->kinder).find(v); (p->kinder).erase(pos); // evtl. Knoteneintraege aendern if(v->inf == p->inf) { p->inf = (((p->kinder).last())->inf)->inf; handle u = p->parent; while(u && v->inf == u->inf) { u->inf = p->inf; 46 u = u->parent; } } // v loeschen delete v; // evtl. Baum umbauen if(p->anzahl_kinder == 1) { // Nachbar von p suchen pos = ((p->parent)->kinder).find(p); handle n = (pos->next)->inf; if(n->anzahl_kinder == 2) verschmelzen(p,n); else stehlen(p,n); // hier: >2 Kinder } } Achtung: Mir fällt gerade auf, daß in der Implementierung ein Fehler ist. Wenn der Knoten, der nur noch ein Kind hat, am Ende der Kinderliste steht, geht das Programm vermutlich schief. Man sollte dann den Knoten, der in der Kinderliste vornedran steht, nehmen, statt den nächsten Knoten (der der Listenkopf ist). Beim Stehlen/Verschmelzen muß man dann auch darauf achten, daß die richtige Seite genommen wird (damit die Daten hinterher immer noch geordnet sind). Momentan habe ich aber keine Lust/Zeit, diesen Fehler zu verbessern... Wenn ein Knoten v ein Kind gestohlen hat, dann hat sich an der Anzahl der Kinder des Elternknotens von v nichts geändert. In diesem Fall ist die Invariante wiederhergestellt und man muß den Baum nicht weiter rebalancieren. Stehlen A B C D A B C D // Knoten v hat 1 Kind, Nachbarknoten w >= 3 -> stehlen void stehlen(handle v, handle w) { //Knoten stehlen (v->kinder).splice(((v->kinder).last())->next, w->kinder, (w->kinder).first(), (w->kinder).first()); // der gestohlene Knoten handle k = ((v->kinder).last())->inf; k->parent = v; v->inf = k->inf; v->anzahl_kinder++; w->anzahl_kinder--; } Verschmelzt man einen Knoten v mit einem Nachbarknoten, dann wird dem Elternknoten von v ein Kind abgezogen. Hier muß man aufpassen, ob der Elternknten dann auch nur noch ein Kind 47 hat. Falls ja, muß man eine Ebene höher stehlen oder verschmelzen. Verschmelzen A B C A B C Ich glaube, ich habe hier auch wieder einen Fehler gemacht. Was passiert, wenn die Wurzel plötzlich nur noch ein Kind hat? Die Programme behandeln es jedenfalls nicht, wie es aussieht. Meine einzige Entschuldigung dafür: Wir sind kein Programmierkurs... // Knoten v hat 1 Kind, Nachbarknoten w 2 Kinder -> verschmelzen void verschmelzen(handle v, handle w) { // Liste verschmelzen (w->kinder).splice((w->kinder).first(), v->kinder, (v->kinder).first(), (v->kinder).first()); (((w->kinder).first())->inf)->parent = w; w->anzahl_kinder++; // Knoten v loeschen handle p = v->parent; p->anzahl_kinder--; list_node<two_five_tree_node<T>* > * pos; pos = (p->kinder).find(v); (p->kinder).erase(pos); delete v; // evtl. noch umbauen if(p->anzahl_kinder == 1) { // Nachbar von p suchen pos = ((p->parent)->kinder).find(p); handle n = (pos->next)->inf; if(n->anzahl_kinder == 2) verschmelzen(p,n); else stehlen(p,n); // hier: >2 Kinder } } }; 5. Laufzeit: Zuerst zu den einfachen Laufzeitabschätzungen. Es seien n Elemente im Baum gespeichert. Nach dem Lemma hat der Baum dann eine Tiefe von t = Θ(log n). Die Operationen minimum, maximum und find benötigen Zeit Θ(log n), da sie nur einmal von der Wurzel aus durch den Baum durchgehen. Dabei ist zu beachten, daß der Zugriff auf ein beliebiges Kind eines Knotens in konstanter Zeit erfolgt, da man höchstens 5 Listenelemente durchprobieren muß. Bei insert wird auch zunächst der Baum durchlaufen (Zeit Θ(log n)), dann das Element eingefügt (Zeit Θ(1)). Evtl. muß man die Einträge auf dem Weg noch mal ändern (Zeit O(log n)). Danach muß der Baum rebalanciert werden. Dabei wird evtl. die Methode spalten aufgerufen. Ein Aufspalten alleine geht in konstanter Zeit, allerdings kann es passieren, daß wir das Aufspalten bis zur Wurzel fortführen müssen. Damit brauchen wir insgesamt für ein insert im schlechtesten Fall Zeit O(log n). 48 Bei erase wird der Knoten in konstanter Zeit gelöscht und dann evtl. der Baum einmal von unten nach oben durchlaufen (Zeit O(log n)). Danach werden die Rebalancierungsmaßnahmen durchgeführt. stehlen geht in konstanter Zeit, ebenso wie ein einzelner Aufruf von verschmelzen. Da verschmelzen allerdings im schlechtesten Fall den ganzen Baum durchlaufen kann, hat man auch bei erase eine Laufzeit von O(log n). 6.3 Amortisierte Analyse für 2-5-Bäume Die 2-5-Bäume haben die schöne Eigenschaft, daß die Tiefe immer in Θ(log n) bleibt, wenn n die Anzahl der Blätter ist. Dabei stören allerdings die Rebalancierungsmaßnahmen etwas, die doch im schlechtesten Fall ziemlich viel Zeit verbrauchen. Wir werden jetzt zeigen, daß die Rebalancierung von 2-5-Bäumen in amortisiert konstanter Zeit läuft. Genauer gesagt: Satz 6.1 Startet man mit einem leeren 2-5-Baum und macht n insert- und erase-Operationen, so ist die Gesamtzahl der Rebalancierungsoperationen höchstens 2n. Um das zu beweisen, beschäftigen wir uns erst einmal näher mit amortisierter Analyse. Was ist amortisierte Analyse? Mit Hilfe von amortisierter Analyse kann man berechnen, wieviel Zeit eine Folge von Operationen, die im Einzelfall unterschiedlich viel Zeit kosten, als Gesamtfolge kosten. Im Gegensatz zur Average-Case Analyse wird hier keine Wahrscheinlichkeit berechnet, sondern ein genauer Wert. 6.3.1 Gesamtheitsmethode Es gibt verschiedene Möglichkeiten, amortisierte Analyse durchzuführen. Als erstes betrachten wir die Gesamtheitsmethode, die wir bereits angewendet haben. Dabei wird für n Operationen die Gesamtlaufzeit T (n) bestimmt. Man sagt dann, daß die Laufzeit für eine Operation amortisiert T (n)/n ist. Es gibt keine generelle Methode, um die Gesamtlaufzeit zu bestimmen. Man muß sich dabei meistens irgendwelche Tricks einfallen lassen. Eine solche Analyse haben wir bereits durchgeführt. Bei unbeschränkten Feldern (u_arrays) gab es Feldzugriffe mit unterschiedlichen Ausführungszeiten Tj . Unter der Annahme, daß auf einen konstanten Bruchteil der Feldelemente wirklich zugegriffen wird, n P Tj = O(n) ist. Daher ist die konnten wir zeigen, daß bei n Zugriffen die Gesamtausführungszeit T = j=1 Laufzeit amortisiert konstant. Um das zu zeigen, haben wir die Feldzugriffe, die mehr Zeit kosten, genauer angesehen. Wir konnten diese addieren (ohne zu wissen, wann sie auftreten) und damit die Gesamtsumme ausrechnen. 6.3.2 Bankkontomethode Eine andere Möglichkeit, amortisierte Analyse durchzuführen, ist die Bankkontomethode: Man zahlt bei gewissen Operationen (z.B. Eingabe, Löschen) eine gewisse Anzahl von RE (Recheneinheiten) ein. Dabei bezahlt man mehr, als die einzelne Operation eigentlich kostet. Wenn dann noch andere Operationen ausgeführt werden, bei denen man nichts einzahlt (oder nichts einzahlen kann), werden deren Kosten durch die überschüssigen RE’s gedeckt. (Bei dieser Methode ist auch der Name ’amortisiert’ sinnvoll.) Als einfaches Beispiel kann man die Realisierung einer Warteschlange (queue) mit Hilfe von zwei stacks auf dem 5. Übungsblatt sehen. Dabei werden die Elemente beim Einfügen zunächst auf den ersten Keller getan. Will man Elemente löschen, so werden sie aus dem zweiten Keller entnommen. Falls der zweite Keller leer ist, werden die Elemente aus dem ersten Keller in den zweiten Keller umkopiert. Nehmen wir an, ein Anfassen eines Elementes kostet 1RE. Ein Element wird in dieser Warteschlange viermal angefaßt: Einfügen in den ersten Keller, rausholen aus dem ersten Keller, einfügen in den zweiten Keller, rausholen aus dem zweiten Keller. Damit hatten wir damals argumentiert, daß wir für solche Warteschlangen amortisiert konstante Laufzeit haben. Jetzt wollen wir die Bankkontomethode anwenden. Beim Einfügen und beim Löschen eines Elementes bräuchte man jeweils nur 1RE. Allerdings muß auch noch das Umkopieren vom ersten in den zweiten Keller mit 2RE bezahlt werden. Es wäre sinnvoll, wenn jedes Element, das im ersten Keller ist, 2RE besitzt. 49 Beim Einfügen eines Elementes könnte man jedem Element auch 3RE mitgeben. Davon wird eine für das Einfügen verbraucht und zwei für das Umkopieren behalten. Das Löschen des Elementes bezahlen wir mit 1RE. Damit sieht man sofort, daß man (amortisiert) konstante Laufzeit hat: Für jede Operation werden nur konstant viele RE’s verbraucht. Jetzt wollen wir die Bankkontomethode für die amortisierte Analyse bei 2-5-Bäumen anwenden. Dazu müssen wir uns erst einmal überlegen, wieviel die einzelnen Rebalancierungsoperationen tatsächlich kosten. Da eine einzelne Operation spalten, stehlen oder verschmelzen jeweils nur konstante Laufzeit braucht, setzen wir die Kosten auf eine einzelne Rebalancierungsoperation auf 1RE. Wieviele RE sollten den verschiedenen Knoten zur Verfügung stehen? Dazu überlegen wir uns, wie sich die einzelnen Knoten unterscheiden. Die Blätter werden nur eingefügt oder gelöscht. Rebalancierungsmaßnahmen betreffen sie nicht, sondern nur die Knoten im Innern des Baumes. Die Knoten im Innern haben 1 bis 6 Kinder (vor der Rebalancierung). Hat ein solcher Knoten 1 oder 6 Kinder, so muß eine Rebalancierungsmaßnahme durchgeführt werden. Hat er 2 oder 5 Kinder, dann ist es ein gefährdeter Knoten, bei dem vermutlich demnächst eine Rebalancierungsmaßnahme durchgeführt werden muß. Ein Knoten mit 3 oder 4 Kindern wird in der nächsten Zeit nicht weiter betrachtet. Mit dieser Überlegung können wir die folgende Invariante formulieren: Den inneren Knoten des 2-5Baumes stehen unbenutzte RE gemäß der folgenden Tabelle zur Verfügung. Anzahl Kinder 1 2 3 4 5 6 RE 3 1 0 0 1 3 Um zu sehen, daß diese Invariante sinnvoll ist, sehen wir uns die einzelnen Rebalancierungsmaßnahmen genauer an. spalten: Ein Knoten v hat 6 Kinder, die aufgespalten werden. Nach der Invariante hat der Knoten 3RE zur Verfügung. Sein Vaterknoten p hat xRE zur Verfügung. Durch das Aufspalten wird 1RE verbraucht, v hat danach noch 2RE. Die gibt er an seinen Vater weiter, da er keine mehr braucht (er hat ja jetzt nur noch 3 Kinder). Der Vaterknoten p bekommt ein Kind mehr und hat jetzt x + 2RE. Hatte p vorher 2, 3 oder 4 Kinder, so hat er mehr RE als notwendig (was ja nicht weiter schlimm ist). Weiteres Aufspalten ist dann nicht nötig. Hatte er vorher 5 Kinder, so hat er jetzt 6 Kinder und 3RE, d.h. er ist in der gleichen Ausgangssituation wie v. stehlen: Ein Knoten v hat nur 1 Kind. Der Nachbarknoten w von v hat mindestens 3 Kinder. v hat 3RE und w hat xRE (x = 0 oder x = 1). Das Stehlen verbraucht 1RE von v. Nach dem Stehlen hat v zwei Kinder, sollte also nach der Invariante mindestens 1RE übrigbehalten. Die dritte RE wird von v an w übergeben. Falls w nämlich nur 3 Kinder hatte, hat es jetzt nur noch zwei Kinder und braucht dann diese RE. Ansonsten hat w eine RE zu viel. verschmelzen: Ein Knoten v hat nur ein Kind, der Nachbarknoten w hat nur zwei Kinder. Nach der Invariante hat v 3RE und w 1RE. Verschmelzen benötigt 1RE. Der Knoten, zu dem v und w verschmolzen wird, hat dann noch 3RE übrig, braucht aber keine (da er drei Kinder hat). Er übergibt die 3RE an seinen Vaterknoten, der ja ein Kind verloren hat. Hatte der Vaterknoten vorher xRE, so hat er jetzt x+3RE. Insbesondere hat er mindestens 4RE, falls er vorher nur zwei und jetzt nur noch ein Kind hat, die Rebalancierungsmaßnahmen können also weiter fortgeführt werden. Wir sehen also, daß wir mit der obigen Invariante die Rebalancierungsmaßnahmen immer bezahlen können. Jetzt zeigen wir, daß es genügt, jedem insert oder erase eines Blattes 2RE mitzugeben, um die Invariante aufrechtzuerhalten. Dabei nehmen wir an, daß Hinzufügen und Löschen eines Blattes jeweils 1RE kosten. Die andere RE, die wir mitgegeben haben, wird dabei dem Vaterknoten des Blattes übergeben. Man überlegt sich sofort, daß man diesem Knoten damit mindestens so viele RE gibt, wie in der Invariante gefordert. (Dabei lassen wir den trivialen Fall, daß wir einen Baum mit nur einem Blatt haben, einfach weg...) Damit können wir den Satz beweisen. Bei n insert- und erase-Operationen werden insgesamt 2nRE eingezahlt. Wir haben uns davon überzeugt, daß die Invariante damit erfüllt ist. Weiter haben wir gesehen, daß bei erfüllter Invariante soviele Rebalancierungsoperationen durchgeführt werden können, wie notwendig sind. Da jede Rebalancierungsmaßnahme 1RE verbraucht, können höchstens 2n solche Operationen durchgeführt werden. 50 6.3.3 Potentialmethode Eine dritte Methode, amortisierte Analyse durchzuführen, ist die Potentialmethode. Bei der Bankkontomethode wurde jedem Element bei bestimmten Operationen eine gewisse Anzahl an RE gegeben, die dieses Element dann für Operationen, die mit ihm ausgeführt wurden, verbrauchen konnte. Im Unterschied dazu wird bei der Potentialmethode jede unverbrauchte RE der gesamten Datenstruktur gutgeschrieben. Diese können dann bei beliebigen Operationen verbraucht werden. Dazu sei D0 die Datenstruktur vor Beginn der Operationen. Für i = 1, 2, . . . , n seien ci die wirklichen Kosten der i-ten Operation und Di sei die Datenstruktur, die entsteht, wenn man auf Di−1 die i-te Operation anwendet. Man definiert eine Potentialfunktion Φ : {D0 , . . . , Dn } → R≥0 mit Φ(D0 ) = 0 und Φ(Di ) ≥ 0 für alle i. Dann kann man die amortisierten Kosten der i-ten Operation (bzgl. der definierten Potentialfunktion) definieren als ĉi = ci + Φ(Di ) − Φ(Di−1 ). Die amortisierten Kosten sind höher als die tatsächlichen Kosten, falls die Differenz Φ(Di ) − Φ(Di−1 ) > 0 ist. Ist die Differenz < 0, so wird ein Teil der anfallenden Kosten aus dem Potential bezahlt. Bei der Definition der Potentialfunktion sollte man darauf achten, daß die amortisierten Kosten niemals < 0 werden, da das keinen Sinn macht. Die amortisierten Gesamtkosten für alle n Operationen sind dann n X i=1 ĉi = n X i=0 (ci + Φ(Di ) − Φ(Di−1 ) = n X ci + Φ(Dn ). i=0 Als Beispiel betrachten wir wieder die amortisierte Analyse der queue. Wir definieren Φ(Di ) = 2· aktuelle Anzahl der Elemente im ersten Keller. Da diese nie negativ wird, sind die Voraussetzungen an die Potentialfunktion erfüllt. Dann können wir die amortisierten Kosten der einzelnen Operationen berechnen. Für eine push-Operation erhalten wir die amortisierten Kosten ĉi = ci + Φ(Di ) − Φ(Di−1 ) = 1 + 2 = 3, da nach dieser Operation die queue ein Element mehr als vorher (und zwar im ersten Keller) besitzt. Für eine pop-Operation erhalten wir, falls der zweite Keller nicht leer ist, die amortisierten Kosten ĉi = ci + Φ(Di ) − Φ(Di−1 ) = 1 − 0 = 1. Es ist zwar ein Element weniger in der queue als vorher, aber dieses Element wurde aus dem zweiten Keller entnommen. Im ersten Keller ändert sich die Elementanzahl nicht. Falls der zweite Keller leer ist, müssen wir alle Elemente aus dem ersten Keller in den zweiten Keller umkopieren. Seien dies k Elemente. Die Differenz der Potentialfunktion ist dann Φ(Di )−Φ(Di−1 ) = −2k. Als tatsächliche Kosten erhalten wir 2k + 1, denn wir haben 2k Kosten, um die k Elemente umzukopieren und müssen dann noch ein Element aus der queue entfernen. Damit erhalten wir die amortisierten Kosten ĉi = ci + Φ(Di ) − Φ(Di−1 ) = 2k + 1 − 2k = 1. Man sieht, daß die amortisierten Kosten konstant sind. 51 7 Prioritätswarteschlangen 7.1 Spezifikation von Prioritätswarteschlangen 1. Definition: Eine Instanz Q des parametrisierten Datentyps pri_queue<T> stellt eine Menge von Elementen vom Typ T dar. Die Elemente vom Typ T besitzen jeweils einen Schlüssel (key), mit dem man eine Ordnung auf T definieren kann. In die Prioritätswarteschlange können die Elemente beliebig eingefügt werden. Man kann den Schlüssel eines Elementes runtersetzen. Man kann aus der Prioritätswarteschlange das Element mit dem kleinsten Schlüssel entfernen. 2. Instanziierung: pri_queue<T> Q; Konstruiert eine leere Prioritätswarteschlange vom Typ pri_queue<T>. 3. Operationen: handle minimum() handle erase_min() void decrease_key(T x, key k_old, key k_new) handle insert(T x) Gibt das Minimum der Prioritätswarteschlange zurück. Löscht das Minimum der Prioritätswarteschlange und gibt es zurück. Gibt dem Element x mit Schlüssel k_old den neuen Schlüssel k_new. Fügt das Element x in die Prioritätswarteschlange ein. Man kann Prioritätswarteschlangen natürlich auch umgekehrt definieren, so daß sie das Maximum (statt dem Minimum) zurückliefern und entsprechend den Schlüssel erhöhen. 4. Implementierung: Zuerst definieren wir eine Klasse, die die Elemente mit ihrem Schlüssel verwaltet. Der Einfachheit halber sind Schlüssel hier vom Typ int. template <class T> class pri_queue_element { T inf; // das Element selbst int key; // der Schluessel public: // Default-Konstruktor macht nichts pri_queue_element<T>() { } // Konstruktor pri_queue_element<T>(T x, int k) { inf = x; key = k; } // decrease_key void decrease_key(int k) { key = k; } 52 // Ein pri_queue_element ist durch seinen Schluessel eindeutig bestimmt. bool operator==(pri_queue_element x) { if(x.key == key) return true; return false; } bool operator!=(pri_queue_element x) { if(x.key != key) return true; return false; } bool operator>(pri_queue_element x) { if(key > x.key) return true; return false; } bool operator<(pri_queue_element x) { if(key < x.key) return true; return false; } bool operator>=(pri_queue_element x) { if(key >= x.key) return true; return false; } bool operator<=(pri_queue_element x) { if(key <= x.key) return true; return false; } }; Mit Hilfe dieser Klasse können wir jetzt die Prioritätswarteschlange implementieren. In der Vorlesung betrachten wir dazu zwei verschiedene Möglichkeiten. 7.2 Implementierung von Prioritätswarteschlangen mit binären Heaps Zuerst betrachten wir binäre Heaps. Diese haben wir bereits früher bei Heapsort kennengelernt. Ein Heap wird dabei in ein Feld eingebettet, wobei man die Eigenschaft A[i] ≤ A[2i + 1], A[i] ≤ A[2i + 2] hat, für alle i, für die A[i], A[2i+1], A[2i+2] definiert sind. Ein solcher Heap stellt natürlich einen binären Baum dar, wobei die Kinderknoten eines Knotens immer größer als der Knoten selbst sind (oder gleich). Die Kinderknoten des Knotens A[i] stehen dann in A[2i + 1] und A[2i + 2]. 53 3 7 5 11 15 9 21 3 7 13 5 11 9 6 10 6 17 19 17 15 21 13 10 19 Wir betrachten jetzt kurz die Realisierung eines Heaps. Die Elemente werden wie oben beschrieben in einem Feld gespeichert. Dadurch ist der entsprechende binäre Baum immer balanciert, d.h. er hat die Tiefe t = blog nc bei n Elementen. Durch die Baumstruktur kann man ein Element in O(t) = O(log n) Zeit finden. Mit einer Operation heapify kann man den Heap wiederherstellen, wenn er an einer Stelle verletzt ist, d.h. wenn ein Element größer als eines seiner Kinder ist. Dabei wird das Element mit einem der Kinder vertauscht. Das wird solange gemacht, bis die Heapeigenschaft wiederhergestellt ist. Die Laufzeit dafür ist also wieder O(t) = O(log n). Für insert setzen wir das neue Element erst an die letzte Stelle des Arrays. Dann gehen wir im Baum solange nach oben, wie der Elternknoten größer als der neue Knoten ist und vertauschen diese. Die Zeit dafür ist ebenfalls O(t) = O(log n). Genauso implementieren wir decrease_key. Dabei wird der Schlüssel eines Elementes (wonach die Elemente ja geordnet sind) erniedrigt und die Heapeigenschaft ist nicht mehr erfüllt. Wie eben gehen wir dann im Baum nach oben und vertauschen solange die Elemente, bis die Eigenschaft wieder erfüllt ist. Zeit: O(t) = O(log n). Das Minimum des Heaps findet man in Zeit O(1), es steht in der Wurzel (d.h. an der 0. Stelle im Feld). Die Funktion erase_min löscht das Minimum, indem einfach das letzte Element im Array an die Stelle der Wurzel gesetzt wird. Damit die Heapeigenschaft erhalten bleibt, wird danach heapify aufgerufen. Zeit: O(t) = O(log n). Mit Hilfe dieses binären Heaps lassen sich Prioritätswarteschlangen sehr einfach realisieren. Wir verwenden dazu die Klasse pri_queue_element. /* Realisiert Priorit"atswarteschlange mit binaeren Heaps */ template <class T> class pri_queue_heap { bin_heap<pri_queue_element<T> >* schlange; public: // Konstruktor pri_queue_heap(int n) { schlange = new bin_heap<pri_queue_element<T> >(n); 54 } // findet das Minimum pri_queue_element<T> minimum() { return schlange->minimum(); } // loescht das Minimum und gibt es zurueck pri_queue_element<T> erase_min() { return schlange->erase_min(); } // Schluessel wird erniedrigt void decrease_key(T x, int k_old, int k_new) { pri_queue_element<T> el(x,k_old); pri_queue_element<T> el_old(x,k_old); el.decrease_key(k_new); schlange->decrease_key(el_old,el); } // fuegt ein Element ein void insert(T x, int k) { pri_queue_element<T> el(x,k); schlange->insert(el); } }; Die Laufzeiten für die Operationen der Prioritätswarteschlange sind die gleichen wie für den binären Heap, d.h. minimum hat Laufzeit O(1), die anderen Operationen haben Laufzeit O(log n), wobei n die Anzahl der Elemente in der Warteschlange ist. 7.3 Implementierung von Prioritätswarteschlangen mit Fibonacci Heaps Ein Fibonacci Heap ist eine Liste von Wurzeln von Fibonacci Bäumen. Diese Bäume sind keine binären Bäume, jeder Knoten kann beliebig viele Kinder haben. Die Kinder eines Knotens müssen immer größer als der Knoten selbst sein. Da die Struktur von Fibonacci Heaps relativ kompliziert ist, schauen wir uns die Implementierung genauer an. Ein Knoten eines Fibonacci Baums ist ein normaler Baumknoten, der eine Kinderliste verwaltet. template <class T> class fib_node { public: fib_node* parent; int anzahl_kinder; list<fib_node*> kinder; T inf; bool mark; // // // // // // der Vorgaenger die Anzahl der Kinder die Kinder das Element selbst = true, falls der Knoten seit dem letzten Mal, als er ein Kind geworden ist, ein Kind 55 // verloren hat. ... }; Die Markierung brauchen wir später. Ein Fibonacci Baum ist einfach ein Baum, dessen Knoten Fibonacci Knoten sind. In der Wurzel des Baumes steht das Minimum. Die Kinder eines Knotens sind immer größer als der Knoten selbst. 3 10 5 13 17 15 8 9 19 /* Realisiert Fibonacci Baum */ template <class T> class fib_tree { typedef fib_node<T>* handle; handle root; // die Wurzel des Baums public: // Konstruktor fib_tree() { root = 0; } fib_tree(handle v) { root = v; } // loescht den Teilbaum mit Wurzel v void clear(handle v) { while(v && v->anzahl_kinder) { clear(((v->kinder).last())->inf); v->anzahl_kinder--; } delete v; 56 } // Destruktor ~fib_tree() { clear(root); } // gibt die Wurzel zurueck handle wurzel() { return root; } // findet das Minimum handle minimum(handle v) { // das Minimum ist der Wurzelknoten return v; } Um das Minimum des Fibonacci Baums zu bestimmen, braucht man Zeit O(1). Wenn wir nach einem Element suchen, sehen wir nach, ob es kleiner als die Wurzel ist (und somit nicht in diesem Teilbaum). Falls nicht, kann es die Wurzel selbst sein. Ansonsten suchen wir das Element rekursiv in allen Teilbäumen der Kinder der Wurzel. Bei n Elementen braucht man hierfür Zeit O(n), da im schlechtesten Fall alle Elemente angesehen werden müssen. // findet ein Element im Teilbaum mit Wurzel v handle find(T x, handle v) { if(!v || x<v->inf) return 0; if(x==v->inf) return v; if(!v->anzahl_kinder) return 0; list_node<fib_node<T>* > * kind = (v->kinder).first(); handle pos = 0; for(int i=1; i <= v->anzahl_kinder; i++) { pos = find(x, kind->inf); if(pos) return pos; kind = kind->next; } return 0; } handle find(T x) { return find(x, root); } Wir betrachten zwei verschiedene Einfügeoperationen. Bei der ersten Operation wird ein Knoten einfach als neues Kind der Wurzel eingefügt. Dabei gehen wir davon aus, daß der Knoten dort an der richtigen Stelle steht, d.h. daß er größer als der Wurzelknoten ist. Weiter kann der Knoten, der eingefügt wird, auch wieder Kinder haben, die dann mit eingefügt werden, so daß hier ein ganzer Teilbaum an die Wurzel gehängt wird. 57 Bei der zweiten Einfügeoperation wird ein neues Element in den Baum eingefügt. Dabei machen wir es uns einfach: Wir hängen das neue Element einfach an die Kinderliste der Wurzel an. Das geht gut, falls schon mindestens ein Element im Baum ist und falls das neue Element größer als die Wurzel ist. Ist das neue Element kleiner als die Wurzel, dann ist es das neue Element das neue Minimum und sollte dann in der Wurzel stehen. In diesem Fall wird die alte Wurzel das einzige Kind der neuen Wurzel. Die Tiefe des Baumes erhöht sich um 1. Für beide insert braucht nur konstante Laufzeit O(1). // fuegt einen Knoten als Kind der Wurzel ein handle insert(handle v) { v->parent = root; v->mark = false; // v ist gerade Kind geworden root->anzahl_kinder++; (root->kinder).insert((root->kinder).last(), v); return v; } // fuegt ein Element ein handle insert(T x) { // neuen Knoten erzeugen handle u = new fib_node<T>; u->inf = x; u->anzahl_kinder = 0; u->mark = false; // u wird Wurzel oder Kind // finde die Stelle, an die u eingefuegt wird handle v=root; if(!v) // u ist der erste Knoten des Baumes { u->parent = 0; root = u; return u; } // es gibt bereits Knoten im Baum if(v->inf > x) // u wird die neue Wurzel { u->parent = 0; root = u; // v wird das einzige Kind von u u->anzahl_kinder = 1; (u->kinder).insert((u->kinder).last(), v); v->parent = u; return u; } // u wird ein Kind von der Wurzel u->parent = v; v->anzahl_kinder++; (v->kinder).insert((v->kinder).last(), u); return u; } 58 Die nächste Operation erase_tree brauchen wir für später. Hier wird ein Unterbaum aus einem Baum herausgeschnitten. Dabei wird kein Knoten gelöscht, nur Zeiger verbogen. Die Laufzeit dafür ist O(n), da man den Knoten in der Kinderliste erst noch suchen muß. // schneidet einen Knoten samt Unterbaum aus dem Baum // ohne den Unterbaum selbst zu loeschen void erase_tree(handle v) { // v loeschen handle p = v->parent; p->anzahl_kinder--; p->mark = true; // p verliert ein Kind, evtl. noch mehr dazu. list_node<fib_node<T>* > * pos; pos = (p->kinder).find(v); (p->kinder).erase(pos); } Wenn wir tatsächlich ein Element löschen wollen, löschen wir diesen Knoten aus der Kinderliste des Elternknotens und fügen die Kinder des gelöschten Knotens (mitsamt Unterbäumen) in die Kinderliste des Elternknotens ein. // loescht ein Element aus dem Baum // das Element darf nicht die Wurzel sein void erase(handle v) { // v loeschen handle p = v->parent; p->anzahl_kinder--; p->mark = true; // p verliert ein Kind, evtl. noch mehr dazu. list_node<fib_node<T>* > * pos; pos = (p->kinder).find(v); (p->kinder).erase(pos); // Kinder von v in die Kinderliste von p eintragen if(v->anzahl_kinder) { list_node<fib_node<T>* > * kind = (v->kinder).first(); while(v->anzahl_kinder) { (kind->inf)->mark = false; // wird gerade Kind (p->kinder).insert((p->kinder).last(), kind->inf); p->anzahl_kinder++; v->anzahl_kinder--; kind = kind->next; } } // v loeschen delete v; } }; Jetzt kommen wir zu den Fibonacci Heaps, mit denen dann die Prioritätswarteschlangen implementiert werden können. Ein Fibonacci Heap besteht aus einer doppelt verketteten Liste, in der die Wurzeln von Fibonacci Bäumen verwaltet werden. Außerdem gibt es noch einen Zeiger auf das Minimum. Das Minimum aller Elemente muß in einer Wurzel stehen, d.h. der Zeiger zeigt auf ein Element der Wurzelliste. 59 Minimum 10 Fib_tree A 27 3 Fib_tree B 7 42 Fib_tree C Fib_tree D template <class T> class fib_heap { typedef fib_node<T>* handle; list<fib_tree<T>* > * wurzelliste; // die Wurzelliste list_node<fib_tree<T>* >* Minimum_node; // das Minimum fib_tree<T>* Minimum; // das Minimum int max_anzahl_kinder; // maximal auftretende Kinderzahl // der einzelnen Wurzeln // Macht aus v einen Baum mit Wurzel v fib_tree<T>* make_baum(fib_node<T>* v) { fib_tree<T>* baum = new fib_tree<T>(v); return baum; } // bestimmt die maximal auftretende Kinderzahl int max_zahl() { int m = 0; handle wurzel; list_node<fib_tree<T>* >* baum_node = wurzelliste->first(); list_node<fib_tree<T>* >* kopf_node = baum_node->prev; fib_tree<T>* baum = baum_node->inf; while(baum_node!=kopf_node) { wurzel = baum->wurzel(); if(m < wurzel->anzahl_kinder) m = wurzel->anzahl_kinder; baum_node = baum_node->next; baum = baum_node->inf; } return m; } Falls das Minimum sich ändert, muß man es aktualisieren. Dazu geht man die Wurzelliste durch und sucht das Minimum der Elemente in der Wurzelliste. 60 // aktualisiert das Minimum void aktual_min() { list_node<fib_tree<T>* >* baum_node = wurzelliste->first(); list_node<fib_tree<T>* >* kopf_node = baum_node->prev; fib_tree<T>* baum = baum_node->inf; handle wurzel = baum->wurzel(); Minimum = baum; Minimum_node = baum_node; T min = wurzel->inf; baum_node = baum_node->next; baum = baum_node->inf; while(baum_node!=kopf_node) { wurzel = baum->wurzel(); if(min > wurzel->inf) { min = wurzel->inf; Minimum = baum; Minimum_node = baum_node; } baum_node = baum_node->next; baum = baum_node->inf; } } Sucht man ein Element, so sucht man es in jedem einzelnen Baum in der Wurzelliste. Die Laufzeit dafür ist also O(n) bei n Elementen im Heap. Wir brauchen zwei verschiedene find-Funktionen. Eine, die einen Zeiger auf das gefundene Element zurückgibt, und eine, die einen Zeiger auf den Baum, in dem das gefundene Element liegt, zurückgibt. handle find(T x) { handle pos; list_node<fib_tree<T>* >* baum_node = wurzelliste->first(); list_node<fib_tree<T>* >* kopf_node = baum_node->prev; fib_tree<T>* baum = baum_node->inf; while(baum_node!=kopf_node) { pos = baum->find(x); if(pos) return pos; baum_node = baum_node->next; baum = baum_node->inf; } assert(baum_node!=kopf_node); } // Gibt Zeiger auf den Baum, in dem x ist, zurueck fib_tree<T>* find_tree(T x) { handle pos; list_node<fib_tree<T>* >* baum_node = wurzelliste->first(); list_node<fib_tree<T>* >* kopf_node = baum_node->prev; fib_tree<T>* baum = baum_node->inf; while(baum_node!=kopf_node) 61 { pos = baum->find(x); if(pos) return baum; baum_node = baum_node->next; baum = baum_node->inf; } assert(baum_node!=kopf_node); } public: fib_heap() { wurzelliste = new list<fib_tree<T>* >; Minimum = 0; Minimum_node = 0; max_anzahl_kinder = 0; } ~fib_heap() { delete wurzelliste; delete Minimum; delete Minimum_node; } Das Minimum wird in konstanter Laufzeit O(1) gefunden, da ein Zeiger in der Wurzelliste direkt auf das Minimum zeigt. T minimum() { handle min = Minimum->wurzel(); return min->inf; } Bei insert machen wir aus dem neuen Element zuerst einen Fibonacci Baum, der nur aus diesem Element besteht. Dieser Baum wird in die Wurzelliste eingetragen. Dann wird getestet, ob das neue Element kleiner als das Minimum ist, und ggf. das Minimum aktualisiert. Dabei muß dann aber nicht die Liste durchgegangen werden (wie oben), sondern nur der Zeiger auf den neuen Baum gesetzt werden. Dadurch kann man in Zeit O(1) ein neues Element einfügen. void insert(T x) { //erzeuge neuen Baum fib_tree<T>* neu = new fib_tree<T>; neu->insert(x); // fuege ihn in die wurzelliste ein list_node<fib_tree<T>* >* tmp; tmp = wurzelliste->insert(wurzelliste->last(), neu); // aktualisiere Minimum if(Minimum==0 || x < (Minimum->wurzel())->inf) { Minimum = neu; Minimum_node = tmp; 62 } } Jetzt betrachten wir die Operation decrease_key. Dabei haben wir drei verschiedene Schritte. Der erste Schritt hat eigentlich nichts mit decrease_key zu tun: Hier wird das Element, das geändert werden soll, gesucht. Dieser Schritt braucht Laufzeit O(n), wie wir oben bereits festgestellt haben. Wir hätten aber auch eine andere Spezifikation nehmen können, bei der man das Element mitgeben muß, so daß dieser Schritt wegfällt. Da wir nur an der Laufzeit von decrease_key interessiert sind, kümmern wir uns um diesen ersten Schritt nicht mehr. Im zweiten Schritt haben wir jetzt den Knoten v. Dieser wird aktualisiert (mit dem neuen Schlüssel). Dadurch ist er eventuell größer als sein Vaterknoten. Wir nehmen den Teilbaum mit Wurzel v, schneiden ihn aus dem Fibonacci Baum, in dem er drin liegt, raus, und setzen ihn als neuen Baum in die Wurzelliste (natürlich nur, falls v nicht bereits Wurzel eines Fibonacci Baums ist und schon in der Wurzelliste steht). Dann wird evtl. das Minimum aktualisiert, wie bei insert. Dieser Schritt, das eigentliche decrease_key, braucht nur konstante Zeit O(1). (Wer genau in die Implementierung sieht, bemerkt, daß hier ein Fehler vorliegt. In diesem Schritt wird der Baum gesucht, der v als Wurzelknoten hat. Das benötigt bei meiner Implementierung O(n) Zeit. Man hätte es aber auch irgendwie anders machen können, daß jeder Knoten z.B. weiß, zu welchem Baum er gehört, so daß man diesen Schritt wirklich in konstanter Zeit durchführen kann.) Dann kommt noch ein dritter Schritt, der den Rebalancierungsmaßnahmen bei 2-5-Bäumen entspricht. Für diesen Schritt brauchen wir auch die Markierung der Knoten. Zur Erinnerung: Die Markierung war true, wenn ein Knoten seit dem letzten Mal, als er ein Kind geworden ist, ein Kind verloren hat. Wir wollen damit erreichen, daß ein Knoten nicht zu viele Kinder (genauer: höchstens 1 Kind) verliert, seit er Kind eines anderen Knotens geworden ist. In diesem dritten Schritt gehen wir von dem Vaterknoten von v (der ja gerade ein Kind verloren hat) solange nach oben, wie die Markierung true ist (evtl. bis zur Wurzel). Wir entfernen jeweils den aktuellen Knoten aus der Kinderliste seines Vaterknotens und tragen ihn (als neuen Baum, der nur aus diesem Knoten besteht) in die Wurzelliste ein. Der Vaterknoten des aktuellen Knotens verliert dadurch natürlich wieder ein Kind, so daß sich diese Ablöseoperation bis zur Wurzel fortsetzen kann. Eine einzelne Ablöseoperation benötigt konstante Zeit O(1). Wir werden später zeigen, daß die Zahl der Ablöseoperationen amortisiert konstant ist, so daß man insgesamt für decrease_key amortisiert konstante Laufzeit angeben kann. // verlange, dass x_new.key < x_old.keY void decrease_key(T x_old, T x_new) { // finde Element x_old handle v = find(x_old); v->inf = x_new; // v ist in der Wurzelliste if(v->parent==0) { //aktualisiere Minimum if(x_new < (Minimum->wurzel())->inf) aktual_min(); } else if(x_new < (v->parent)->inf) { // entfernte v aus der Kindliste von v->parent und // fuege den Baum mit Wurzel v in die Wurzelliste ein und // aktualisiere Minimum handle p = v->parent; fib_tree<T>* baum = find_tree(p->inf); baum->erase_tree(v); wurzelliste->insert(wurzelliste->last(), make_baum(v)); 63 v->mark = false; v->parent = 0; if(x_new < (Minimum->wurzel())->inf) aktual_min(); // Markierung von v->parent: abarbeiten handle u = p->parent; while(u && p->mark) { // entfernte p aus der Kindliste von u und // fuege den Baum mit Wurzel p in die Wurzelliste ein baum->erase_tree(p); wurzelliste->insert(wurzelliste->last(), make_baum(p)); p->mark = false; p->parent = 0; p = u; u = p->parent; } if(u) p->mark = true; // aktualisiere maximale Kinderzahl max_anzahl_kinder = max_zahl(); } } Bei erase_min() wird zunächst das Minimum gelöscht (das man ja direkt findet). Dieses Minimum war die Wurzel eines Fibonacci Baumes. Die Kinder des Minimums werden mitsamt ihren Unterbäumen in die Wurzelliste eingetragen. Jetzt wäre man im Prinzip schon fast fertig: Man könnte einfach das Minimum aktualisieren und aufhören. Aber zum Aktualisieren des Minimums muß man hier die gesamte Wurzelliste durchgehen. Die Laufzeit für erase_min() wäre in diesem Fall O(Zahl der Kinder des Minimums + Zahl der Knoten in der Wurzelliste). Da die Laufzeit hier sowieso nicht mehr konstant ist, können wir auch noch einige Umbauaktionen durchführen. Wenn man diese wegläßt, hätte man praktisch nur eine Liste von Elementen, was nicht sonderlich effektiv ist. In diesem Schritt bauen wir also jetzt die Bäume zusammen. Das Ziel ist, alle Wurzelknoten mit gleicher Kinderzahl zu verschmelzen, so daß alle verbleibenden Knoten in der Wurzelliste unterschiedliche Kinderzahl haben. ’Verschmelzen’ bedeutet hier, daß die Wurzel, die größer ist, als neues Kind der kleineren Wurzel eingebaut wird. 6 17 k Kinder k Kinder 6 verschmelzen k+1 Kinder 17 Implementiert wird dieses Verschmelzen aller Knoten mit Hilfe eines Arrays. Man geht die Wurzelliste durch und schaut sich an, wieviele Kinder die aktuelle Wurzel hat. Hat man vorher bereits Wurzeln mit der entsprechenden Kinderzahl (d) gefunden, dann stehen diese im Array an der Stelle A[d]. Falls dort eine Wurzel steht, verschmilzt man diese mit der aktuellen Wurzel (das Minimum der beiden wird die aktuelle Wurzel, die andere wird ein Kind). Die aktuelle Wurzel hat jetzt d + 1 Kinder, man sieht bei A[d + 1] nach, ob bereits eine solche Wurzel existiert. Dies wird immer weitergeführt, bis man keine Wurzel im Array mehr findet. Dann wird die aktuelle Wurzel an die der Kinderzahl entsprechenden Stelle im Array eingetragen. 64 In der Wurzelliste stehen am Ende nur noch Wurzeln mit unterschiedlicher Kinderzahl. Ein Verschmelzen kosten konstante Zeit O(1). Nehmen wir an, m sei die maximal auftretende Kinderzahl. Dann brauchen wir für erase_min im worst case Laufzeit O(m+Zahl der Kinder des Minimums + Zahl der Knoten in der Wurzelliste). void erase_min() { // Minimum loeschen und Kinder in die Wurzelliste einfuegen handle min = Minimum->wurzel(); if(min->anzahl_kinder) { list_node<fib_node<T>* > * kind = (min->kinder).first(); while(min->anzahl_kinder) { (kind->inf)->mark = false; // wird in Wurzelliste eingetragen wurzelliste->insert(wurzelliste->last(), make_baum(kind->inf)); kind = kind->next; min->anzahl_kinder--; } max_anzahl_kinder = max_zahl(); } wurzelliste->erase(Minimum_node); // das komische Array A int m = max_anzahl_kinder; u_array<list_node<fib_tree<T>*>*> A(0); for(int d=0; d<= m; d++) A.put(d, 0); // forall v in der Wurzelliste list_node<fib_tree<T>* >* baum_node = wurzelliste->first(); list_node<fib_tree<T>* >* kopf_node = baum_node->prev; while(baum_node!=kopf_node) { list_node<fib_tree<T>* >* wurzel_node = baum_node; baum_node = baum_node->next; fib_tree<T>* wurzel_baum = wurzel_node->inf; handle wurzel = wurzel_baum->wurzel(); int d = wurzel->anzahl_kinder; list_node<fib_tree<T>* >* u_node = A[d]; while(u_node!=0) { fib_tree<T>* u_baum = u_node->inf; handle u_wurzel = u_baum->wurzel(); A.put(d, 0); if(u_wurzel->inf < wurzel->inf) { // loesche wurzel_baum aus der Wurzelliste wurzelliste->erase(wurzel_node); //fuege wurzel in die Kinderliste von u_wurzel ein u_baum->insert(wurzel); wurzel_node = u_node; wurzel_baum = u_baum; wurzel = u_wurzel; 65 } else { // loesche u_baum aus der Wurzelliste wurzelliste->erase(u_node); //fuege u_wurzel in die Kinderliste von wurzel ein wurzel_baum->insert(u_wurzel); } d++; u_node = A[d]; } A.put(d, wurzel_node); } // aktualisiere Minimum aktual_min(); } }; 7.4 Amortisierte Analyse von Fibonacci Heaps Die Fibonacci Folge ist folgendermaßen definiert: f0 = 0, f1 = 1, , fn = fn−1 + fn−2 für n ≥ 2. Wir haben gezeigt, daß für alle n ∈ N0 gilt 1 fn = √ (φn − φ̂n ) 5 mit den Zahlen √ 1− 5 φ̂ = . 2 √ 1+ 5 , φ= 2 Lemma 7.1 Sei v ein Knoten eines Fibonacci Heaps und seien u1 , u2 , . . . , uk die Kinder von v in der Reihenfolge, in der sie (zuletzt) Kinder von v geworden sind. Dann hat u i mindestens i − 2 Kinder. Beweis: Der Knoten ui ist Kind von v geworden, als v mindestens i − 1 Kinder hatte. Die Knoten u0 , . . . , ui−1 sind vorher Kinder geworden, es könnten aber auch noch andere Kinder dagewesen sein, die zwischendurch gelöscht wurden. Zu diesem Einfügezeitpunkt hatte ui nach Konstruktion genauso viele Kinder wie v, also mindestens i− 1 Kinder. Danach kann der Knoten ui keine weiteren Kinder mehr bekommen haben (nach Konstruktion bekommen nur Wurzelknoten Kinder). Er kann evtl. ein Kind verloren haben, aber höchstens eines. Also ist die Anzahl der Kinder von ui mindestens i − 2. Lemma 7.2 Sei sk die Anzahl der Knoten in den Unterbäumen eines Knotens v mit k Kindern (einschließlich v selbst). Dann gilt für k ≥ 2: sk ≥ φk−2 . Daher auch der Name Fibonacci. :-) Beweis: Es ist s0 = 1 und s1 ≥ 2. Dabei ist s0 = 1 klar. Hat ein Knoten 1 Kind, so hat dieses Kind keine negative Kinderanzahl. Also ist s1 ≥ 2. Sei jetzt k ≥ 2. Wir betrachten einen Knoten v mit k Kindern. Die Kinder u1 , . . . , uk seien in der gleichen Reihenfolge gegeben wie im letzten Lemma. Nach diesem Lemma wissen wir, daß u k mindestens k − 2 Kinder hat. Also ist die Anzahl der Knoten im Unterbaum mit Wurzel uk mindestens sk−2 . 66 Die Anzahl der restlichen Knoten im Teilbaum mit v als Wurzel können wir auch abschätzen. Diese ist nämlich mindestens sk−1 . Insgesamt folgt dann: sk ≥ sk−1 + sk−2 . Jetzt können wir mit Induktion die Abschätzung sk ≥ f k für alle k ∈ N0 beweisen. k = 0: s0 = 1 ≥ f0 = 0. k = 1: s1 ≥ 2 ≥ f1 = 1. k > 1: sk ≥ sk−1 + sk−2 ≥ fk−1 + fk−2 = fk . Dann schätzen wir wegen fk ≥ φk−2 (s. Übung) für k ≥ 2 ab: sk ≥ fk ≥ φk−2 . Korollar 7.1 Bei einem Fibonacci Heap mit n Elementen ist die maximale Kinderzahl der Wurzeln m = O(log n). Beweis: Nach dem Lemma ist die Anzahl der Elemente im Baum, dessen Wurzel m Kinder hat, sm ≥ φm−2 (für m ≥ 2). Andererseits ist n ≥ sm . Damit erhält man n ≥ φm−2 ⇔ m ≤ 2 + logφ n = O(log n). Jetzt können wir die amortisierte Analyse für Fibonacci Heaps durchführen. Satz 7.1 Für einen Fibonacci Heap mit n Elementen erhalten wir die folgenden amortisierten Kosten: minimum: O(1) insert: O(1) decrease_key: O(1) erase_min: O(log n) Beweis: Zur amortisierten Analyse verwenden wir die Potentialmethode. Dazu definieren wir die folgende Potentialfunktion. Φ(H) = w(H) + 2a(H), wobei w(H) die Anzahl der Wurzeln in der Wurzelliste und a(H) die Anzahl der markierten Knoten im Heap ist. Es ist klar, daß bei einem leeren Heap Φ(H) = 0 ist. Weiter ist Φ(H) ≥ 0 auch immer erfüllt. Wir berechnen jetzt die amortisierten Kosten der einzelnen Operationen. Dazu benötigen wir die genauen Kosten. minimum hat die genauen Kosten ci = 1RE (konstante Kosten). Damit ergeben sich die amortisieren Kosten cˆi = ci + Φ(Hi ) − Φ(Hi−1 ) = 1RE, da sich an der Struktur und somit auch an der Potentialfunktion nichts ändert. insert hat die genauen Kosten ci = 1RE. Es ändert sich aber etwas an der Struktur: Die Wurzelliste erhält ein Element mehr. Damit ist Φ(Hi ) − Φ(Hi−1 ) = 1RE. Wir erhalten als amortisierte Kosten cˆi = ci + Φ(Hi ) − Φ(Hi−1 ) = 2RE. 67 decrease_key: Das eigentliche decrease_key hat konstante Kosten 1RE (s. Bemerkung bei der Implementierung). Danach werden verschiedene Ablöseoperationen durchgeführt. Vom Vaterknoten des betrachteten Knotens aus geht man solange nach oben, wie man auf markierte Knoten trifft, schneidet diese aus und setzt sie in die Wurzelliste. Wir nehmen an, daß man auf diese Weise k Knoten ablöst. Dann sind die genauen Kosten für diesen Schritt ci = (1 + k)RE, da jeder Ablöseschritt konstante Kosten hat. Die Anzahl der Wurzelelemente hat sich um k +1 erhöht (der betrachtete Knoten und die k Knoten, die abgeschnitten wurden). Es ist also w(Hi ) − w(Hi−1 ) = k + 1. Die Zahl der markierten Knoten hat sich auch geändert. Eventuell war der betrachtete Knoten markiert, jetzt ist er es nicht mehr. Die k abgelösten Knoten sind nicht mehr markiert. Im letzten Ablöseschritt wird aber eventuell ein Knoten markiert (falls er nicht eine Wurzel ist). Damit haben wir a(Hi ) − a(Hi−1 ) ≤ −k + 1. Insgesamt folgt Φ(Hi ) − Φ(Hi−1 ) ≤ k + 1 + 2(−k + 1) = (−k + 3)RE. Für die amortisierten Kosten eines decrease_key erhält man dann cˆi = ci + Φ(Hi ) − Φ(Hi−1 ) ≤ (k + 1) + (−k + 3) = 4RE. erase_min: Bei erase_min wird zunächst das Minimum gelöscht und die Kinder des Minimums als neue Wurzeln in die Wurzelliste gesetzt. Es sei m die maximale Kinderzahl der Wurzeln. Wir haben gesehen, daß m = O(log n) ist. Damit sind die genauen Kosten für diesen Schritt O(1 + m) = O(log n). Danach werden Knoten mit gleicher Kinderzahl verschmolzen. Ein solches Verschmelzen kostet konstante Zeit O(1). Wir nehmen an, wir würden k dieser Verschmelzeoperationen durchführen. Zuletzt wird das aktuelle Minimum gesucht, wobei man einmal durch die Wurzelliste durchgehen muß. Die Wurzelliste hat am Ende w(Hi ) Elemente. Für diese Zahl können wir auch eine Abschätzung angeben. Am Ende gibt es in der Wurzelliste nur Wurzeln mit unterschiedlicher Kinderzahl. Da m die maximale Kinderzahl ist, kann es höchstens m + 1 solche Wurzeln geben. Insgesamt sind die genauen Kosten für ein erase_min also ci ≤ 1 + m + k + m + 1 = 2m + k + 2. Es ist w(Hi−1 ) die Anzahl der Elemte in der Wurzelliste vor dem erase_min. Ein Element wird aus der Wurzelliste entfernt, dafür kommen höchstens m Elemente dazu. Eine Verschmelzeoperation entfernt ein Element aus der Wurzelliste. Insgesamt erhalten wir damit w(Hi ) ≤ w(Hi−1 ) − 1 + m − k. Es folgt w(Hi ) − w(Hi−1 ) ≤ m − k − 1. Es werden keine Knoten neu markiert. Im ersten Schritt, wenn die Kinder der gelöschten Wurzel selbst zu Wurzeln werden, können aber bis zu m markierte Knoten wegfallen. Es folgt a(Hi ) − a(Hi−1 ) ≤ 0. Für die Potentialfunktion folgt damit Φ(Hi ) − Φ(Hi−1 ) ≤ m − k − 1 und für die amortisierten Kosten cˆi = ci + Φ(Hi ) − Φ(Hi−1 ) ≤ 2m + k + 2 + m − k − 1 = 3m + 1 = O(log n). Wir vergleichen nochmal die Laufzeiten der beiden Implementierungen von Prioritätswarteschlangen, die wir kennengelernt haben. 68 Kosten binäre Heaps minimum O(1) insert O(log n) decrease_key O(log n) erase_min O(log n) Dennoch sind Fibonacci Heaps meistens binäre Heaps verwendet. Das liegt an amortisierte Kosten Fibonacci Heaps O(1) O(1) O(1) O(log n) nur von theoretischem Interesse. In der Praxis werden eher • der Schwierigkeit, Fibonacci Heaps richtig zu implementieren (unter anderem sollten die Laufzeitabschätzungen auch stimmen), • der großen Konstante, die in der O-Notation versteckt ist und für kleinere Zahlen schlechtere Ergebnisse liefert. 69 8 Union-Find 8.1 Spezifikation von Partitionen Definition 8.1 Eine Partition einer Menge S ist eine Menge von paarweise disjunkten Teilmengen von S, deren Vereinigung gerade S ist. Beispiel: Es sei S = {1, 2, 3, 4, 5, 6, 7, 8, 9}. Dann ist z.B. die Menge {{3, 6, 9}, {1}, {2, 4, 8}, {5, 7}} eine Partition von S. Mengen werden durch einen eindeutigen Namen repräsentiert. Dabei muß für je zwei Elemente aus der gleichen Menge gelten: find(i)==find(j). Wir werden verschiedene Implementierungen für Partition kennenlernen. Zur Vereinfachung nehmen wir an, daß sowohl die Elemente als auch die Mengennamen vom Typ int sind. 1. Definition: Eine Instanz P des Datentyps Partition stellt eine Partition der Menge {1, 2, . . . , n} von Elementen vom Typ int dar. Man kann Mengen vereinigen (d.h. die Partition vergröbern) und zu einem gegebenen Element die Menge finden, in der dieses Element liegt. 2. Instanziierung: Partition P(int n); Konstruiert die Partition {{1}, {2}, . . . , {n}} der Menge {1, 2, . . . , n}. 3. Operationen: int union(int A, int B); int find(int x); 8.2 Vereinigt die Mengen A und B und gibt den Namen der neuen Menge zurück. Gibt den Namen der Menge zurück, in der das Element x liegt. Einfache Implementierungen von Partitionen Eine einfache Implementierung erhält man zum Beispiel dadurch, daß man für jedes Element explizit den Namen der Menge speichert. Dabei nehmen wir einfach als Mengennamen ein Element aus der Menge. class PartitionSimple { int *name; // name[i] = Name der Menge, in der Element i liegt int groesse; public: /* Konstruktor */ /* Erzeugt die Partition {{1},{2},...,{n}} */ PartitionSimple(int n) { groesse = n; name = new int[groesse+1]; for(int i=1; i<=groesse; i++) name[i]=i; } /* Destruktor */ ~PartitionSimple() { delete[] name; 70 } /* gibt den Namen der Menge, in der i liegt, zurueck */ int find(int i) { assert(i>=1 && i<=groesse); return name[i]; } /* Vereinigt die Mengen A und B, neuer Name der Menge: A */ int Union(int A, int B) { for(int i=1; i<=groesse; i++) { if(name[i]==B) name[i]=A; } return A; } }; Laufzeiten (bei n Elementen): Initialisierung Θ(n) union Θ(n) find Θ(1) Führt man auf einer Partition einer Menge mit n Elementen n − 1 union-Operationen und m findOperationen durch, so hat man eine Laufzeit von Θ(m + n2 ). Die schlechte Laufzeit bei union kommt daher, daß man bei jedem Aufruf alle Elemente der Menge durchgehen muß. Eigentlich würde es aber reichen, die Elemente der Teilmenge B zu durchlaufen. Dazu sollte es möglich sein, alle Elemente einer Teilmenge aufzulisten. Um das zu ermöglichen, erzeugen wir noch zusätzlich eine Listenstruktur. Dabei zeigt ein Element einer Teilmenge immer auf ein anderes Element der Teilmenge. Das letzte Element der Liste zeigt auf sich selbst. Als Mengennamen wählen wir das erste Element der Liste. class PartitionWithLists { int *name; // name[i] = Name der Menge, in der Element i liegt int *next; // next[i] = Nachfolgeelement von i, falls i letztest // Element der Liste ist: next[i]=i. int groesse; public: /* Konstruktor */ /* Erzeugt die Partition {{1},{2},...,{n}} */ PartitionWithLists(int n) { groesse = n; name = new int[groesse+1]; next = new int[groesse+1]; for(int i=1; i<=groesse; i++) { name[i]=i; next[i]=i; } } 71 /* Destruktor */ ~PartitionWithLists() { delete[] name; delete[] next; } /* gibt den Namen der Menge, in der i liegt, zurueck */ int find(int i) { assert(i>=1 && i<=groesse); return name[i]; } /* Vereinigt die Mengen A und B, neuer Name der Menge: A */ int Union(int A, int B) { // Umbenennung aller Elemente in der Menge B int anfangB=B; name[B] = A; while(next[B]!=B) { B = next[B]; name[B] = A; } // Fuege die 2. Liste hinter den Anfang der ersten Liste ein if(next[A]!=A) next[B] = next[A]; next[A] = anfangB; return A; } }; Laufzeiten (bei n Elementen): Initialisierung Θ(n) union Θ(Größe beider Mengen) find Θ(1) Führt man auf einer Partition einer Menge mit n Elementen n − 1 union-Operationen und m findOperationen durch, so hat man hier auch eine Laufzeit von Θ(m + n2 ). Man hat hier zwar i.a. bei union eine kleinere Laufzeit als bei der ersten Implementierung, allerdings kann die Laufzeit im schlechtesten Fall genauso schlecht sein. Beispiel: PartitionWithLists P(n); P.union(2,1); P.union(3,2); ... P.union(n,n-1); Hier wird im i-ten union die Menge i + 1 mit der Menge i vereinigt. Die Menge i + 1 hat dabei nur ein Element, die Menge i hat i Elemente. Die Laufzeit für dieses Beispiel ist dann n+ n−1 X i=1 i=n+ (n − 1)n = Θ(n2 ). 2 72 An dem Beispiel sieht man, daß es nicht zeiteffizient ist, wenn man immer die zweite Menge in die erste Menge integriert. Besser wäre es, die kleinere der beiden Mengen umzubenennen. Die Idee für die nächste Verbesserung ist also, bei der Vereinigung zweier Mengen die Elemente der kleineren Menge umzubenennen. Dazu müssen wir uns die Länge der Listen (die Größe der Mengen) speichern. Dies machen wir in dem Array size. Dabei steht in size[i] die Größe der Menge mit Namen i. Hier kann es zu (kleineren) Verwirrungen kommen: name und next haben als Indizes die Elemente selbst (also die Zahlen von 1 bis n). Das Array size hat als Indizes die Namen der Mengen in der Partition. Zufällig haben wir uns darauf geeinigt, daß diese Namen vom Typ int ist. Ebenso zufällig können in der angegebenen Implementierung nur Namen von 1 bis n auftreten, so daß wir size genauso wie name und next als Feld der Zahlen von 1 bis n betrachten. Dennoch darf man nicht vergessen, daß diese Zahlen bei size eine andere Bedeutung als bei name und next haben. /* Realisiert Union-Find: Partition der Menge {1,...,n} */ class PartitionWithLists_2 { int *name; // name[i] = Name der Menge, in der Element i liegt int *next; // next[i] = Nachfolgeelement von i, falls i letztest // Element der Liste ist: next[i]=i. int *size; // size[i] = Laenge der Liste mit Namen i int groesse; public: /* Konstruktor */ /* Erzeugt die Partition {{1},{2},...,{n}} */ PartitionWithLists_2(int n) { groesse = n; name = new int[groesse+1]; next = new int[groesse+1]; size = new int[groesse+1]; for(int i=1; i<=groesse; i++) { name[i]=i; next[i]=i; size[i]=1; } } /* Destruktor */ ~PartitionWithLists_2() { delete[] name; delete[] next; delete[] size; } /* gibt den Namen der Menge, in der i liegt, zurueck */ int find(int i) { assert(i>=1 && i<=groesse); return name[i]; } 73 /* Vereinigt die Mengen A und B, neuer Name der Menge: der Name der groesseren Menge */ int Union(int A, int B) { // welche Menge ist groesser? if(size[A]<size[B]) { int h = A; A = B; B = h; } // Jetzt ist Menge B kleiner als Menge A // Umbenennung aller Elemente in der Menge B int anfangB=B; name[B] = A; while(next[B]!=B) { B = next[B]; name[B] = A; } // Fuege die 2. Liste hinter den Anfang der ersten Liste ein if(next[A]!=A) next[B] = next[A]; next[A] = anfangB; // Groesse der Menge A aktualisieren size[A]+=size[B]; return A; } }; Laufzeiten (bei n Elementen): Initialisierung Θ(n) union Θ(Größe der kleineren Menge) find Θ(1) Jetzt berechnen wir die Gesamtlaufzeit für eine Folge von union-Operationen. Die Laufzeit entspricht der Zahl der Änderungen im Feld name. Wie oft kann der Eintrag in name[i] geändert werden? Wird er geändert, dann wird die Menge, in der sich i vorher befand, in eine andere Menge, die mindestens genauso groß ist, integriert. Die Größe der Menge hat sich also mindestens verdoppelt. Fängt man mit der kleinstmöglichen Menge (Größe 1) an und macht die kleinstmöglichsten unionOperationen (sprich: Verdoppeln der Größe), so kann man höchstens blog nc mal verdoppeln. Der Eintrag in name[i] kann also höchstens blog nc mal geändert werden. Betrachtet man jetzt alle Elemente, so erhält man die folgende Laufzeitabschätzung. Führt man auf einer Partition einer Menge mit n Elementen n − 1 union-Operationen und m findOperationen durch, so hat man hier eine Laufzeit von Θ(m + n log n). 8.3 Implementierung von Partitionen mit Bäumen Man kann die Datenstruktur zur Verwaltung der Partitionen ein wenig umstellen, so daß man ein billiges union aber ein teureres find hat. Dazu verwendet man Bäume, in denen jeder Knoten nur seinen Elternknoten kennen muß. Jeder Knoten kann beliebig viele Kinder haben. Der Elternknoten der Wurzel ist die Wurzel selbst. Ein solcher Baum entspricht gerade einer Teilmenge. Der Name der Menge ist das Element der Wurzel. Bei union setzen wir den Baum mit weniger Elementen als neuen Teilbaum unter die Wurzel des anderen 74 Baumes. Bei find gehen wir in dem Baum bis zur Wurzel hoch und geben die Wurzel zurück. class Partition { int *parent; // parent[i] = der von i aus naechste Knoten auf dem // Weg zur Wurzel. Ist i die Wurzel, dann parent[i]=i int *size; // size[i] = Groesse der Menge mit Namen i int groesse; public: /* Konstruktor */ /* Erzeugt die Partition {{1},{2},...,{n}} */ Partition(int n) { groesse = n; parent = new int[groesse+1]; size = new int[groesse+1]; for(int i=1; i<=groesse; i++) { parent[i]=i; size[i]=1; } } /* Destruktor */ ~Partition() { delete[] parent; delete[] size; } /* gibt den Namen der Menge, in der i liegt, zurueck */ int find(int i) { assert(i>=1 && i<=groesse); // suche die Wurzel des Baumes mit dem Element i while(parent[i]!=i) i = parent[i]; return i; } /* Vereinigt die Mengen A und B, neuer Name der Menge: der Name der groesseren Menge */ int Union(int A, int B) { // die kleinere Menge wird an die Wurzel gehaengt // welche Menge ist groesser? if(size[A]<=size[B]) { parent[A]=B; size[B]+=size[A]; return B; } else 75 { parent[B]=A; size[A]+=size[B]; return A; } } }; Laufzeiten (bei n Elementen): Initialisierung Θ(n) union Θ(1) find Θ(Länge des Pfades vom Element zur Wurzel) Die Länge des Pfades von einem beliebigen Element bis zur Wurzel des entsprechenden Baumes können wir mit O(log n) abschätzen, so daß ein find Laufzeit O(log n) hat. Wir bauen nämlich bei jedem union den kleineren Baum als Teilbaum unter die Wurzel des größeren Baumes an. Ein Element i (in der kleineren Menge) wird dabei um 1 nach unten verschoben, d.h. der Weg bis zur Wurzel verlängert sich um 1. Die kleinere Menge verdoppelt sich dabei mindestens, so daß man ein Element höchstens blog nc mal um 1 nach unten verschieben kann. Also hat der Weg eines beliebigen Elementes bis zur Wurzel höchstens die Länge blog nc. Führt man auf einer Partition einer Menge mit n Elementen n − 1 union-Operationen und m findOperationen durch, so hat man hier eine Laufzeit von Θ(n + m log n). Man kann diese Implementierung noch ein wenig verbessern. Bei der find-Operation gehen wir von einem Element bis zur Wurzel hoch. Die beste Laufzeit erhalten wir, wenn das Element direkt unter der Wurzel steht. Die Idee ist jetzt, auf dem Weg zur Wurzel alle Elemente mitzunehmen und direkt unter die Wurzel zu schreiben. Bei zukünftigen find-Operationen für diese Elemente (und für alle Elemente in den Unterbäumen dieser Elemente) ist man dann näher an der Wurzel dran, so daß die zukünftigen Operationen billiger werden. In der Implementierung von Partition muß dabei nur die find-Operation folgendermaßen geändert werden: /* gibt den Namen der Menge, in der i liegt, zurueck */ int find(int i) { assert(i>=1 && i<=groesse); // suche die Wurzel des Baumes mit dem Element i // haenge dabei alle betrachteten Elemente direkt unter die Wurzel if(parent[i]==i) return i; parent[i]=find(parent[i]); return parent[i]; } Laufzeiten (bei n Elementen, worst case): Initialisierung Θ(n) union Θ(1) find O(log n) Dabei erhält man die Abschätzung O(log n) für die Laufzeit von find wie oben. Man kann aber folgende amortisierte Analyse machen: Führt man auf einer Partition einer Menge mit n Elementen n − 1 union-Operationen und m find-Operationen durch, so hat man hier eine Laufzeit von Θ(n + mα(n, m/n)). Die Funktion α wird im nächsten Abschnitt behandelt. 76 9 2 3 10 5 1 7 14 15 8 11 4 17 find(4) 9 2 3 10 5 1 7 4 8 14 11 15 17 77 8.4 Analyse von Partitionen mit Pfadkomprimierung Diese Analyse ist sehr schwierig und wird hier auch nicht vollständig durchgeführt werden können. Wir benötigen zunächst einige Definitionen. 8.4.1 Die Ackermannfunktionen und ihre Inversen Notation: Sei f : N0 → N0 eine Funktion. Für k ∈ N0 bedeutet f (k) (n) das k-malige Hintereinanderausführen von f auf n: f (k) (n) = (f ◦ · ◦ f )(n). | {z } k mal Für k = 0 erhält man f (0) (n) = n. Definition 8.2 Die Ackermannfunktionen sind die Funktionen Ak : N0 → N0 für k ∈ N0 mit A0 (n) = 2n, Ak (n) = Ak−1 (1), k ≥ 1, = Ak−1 (Ak (n − 1)), (n) k ≥ 1, n > 0. Um zu sehen, wie sich die Ackermannfunktionen verhalten, betrachten wir die folgende Tabelle. A0 (n) A1 (n) A2 (n) A3 (n) n=0 n=1 0 2 1 2 1 1 2 2 n=2 4 4 = 22 n=3 6 8 = 23 n=4 8 16 = 24 4 = 22 4 = 22 16 = 24 65536 = 216 65536 = 216 2n 2n 22 .. .2 Die ersten Ackermannfunktionen sind noch relativ einfach zu beschreiben. A0 (n) = 2n ist nach Defi(n) nition klar. Weiter ist A1 (n) = A0 (1). Man wendet also A0 n mal auf die 1 an und erhält die Funktion n A1 (n) = 2 . Um A2 (n) zu bestimmen, müssen wir n mal A1 auf 1 anwenden. Wir erhalten die Funktion A2 (n) = 22 .. .2 , wobei hier n mal die 2 übereinandersteht. Bei A3 (n) wenden wir n mal A2 auf 1 an. Für den Wert von A3 (4) müßten wir 65536 mal die 2 übereinanderschreiben. Man sieht, daß diese Funktionen extrem schnell anwachsen. Die ersten drei Funktionswerte bleiben immer gleich. Lemma 8.1 Für alle k ∈ N0 ist Ak (0) = 1 (falls k > 0, A0 (0) = 0), Ak (1) = 2 und Ak (2) = 4. Beweis: Induktion über k. k = 0: s. Tabelle. k = 1: s. Tabelle. k → k + 1: Es ist (0) Ak+1 (0) = Ak (1) = 1, Ak+1 (1) = Ak (1) = 2, Ak+1 (2) = Ak (Ak (1)) = Ak (2) = 4, wobei die letzten beiden Zeilen mit Induktionsvoraussetzung folgen. Lemma 8.2 Für alle k ist Ak (n) streng monoton wachsend. Beweis: Es sei m > n. Zu zeigen ist, daß Ak (m) > Ak (n) ist. k = 0: ist klar. k → k + 1: Nach Induktionsvoraussetzung ist Ak streng monoton wachsend. Man kann dann leicht mit (i) (m−n) Induktion nach i zeigen, daß für alle i ∈ N gilt Ak (1) > 1. Damit folgt insbesondere Ak (1) > 1. (m) (n) Wendet man n mal Ak auf beide Seiten an, so folgt Ak (1) > Ak (1) und damit die Behauptung. 78 Lemma 8.3 Für alle k, n ∈ N0 (außer für k = n = 0) gilt Ak (n) > n. Beweis: Induktion nach k. k = 0, k = 1: klar nach Definition. (n) k → k + 1: Es ist Ak+1 (n) = Ak (1). Nach Induktionsvoraussetzung ist Ak (1) > 1 und damit Ak (1) ≥ 2. (2) Wendet man darauf Ak an, so erhält man, da die Funktion Ak streng monoton wachsend ist, Ak (1) ≥ Ak (2) > 2 nach Induktionsvoraussetzung. Wir wenden weiter Ak auf diese Ungleichung an und zeigen für (i) alle i: Ak (1) > i. Mit i = n folgt die Behauptung. Lemma 8.4 Für alle k ∈ N0 gilt Ak+1 (n) ≥ Ak (n) für alle n ∈ N0 . (n) (n−1) Beweis: Es ist Ak+1 (n) = Ak (1). Nach dem letzten Lemma wissen wir, daß Ak (1) > n − 1 ist (n) (wurde im Beweis auch gezeigt). Da Ak streng monoton wachsend ist, folgt Ak (1) ≥ Ak (n) und damit die Behauptung. Definition 8.3 Die Inversen der Ackermannfunktionen sind die Funktionen Ik : N → N für k ∈ N0 mit I0 (n) Ik (n) = dn/2e , (i) = min{i ∈ N | Ik−1 (n) = 1}, k ≥ 1. Lemma 8.5 Für alle k ∈ N0 gilt Ik (n) < n für alle n ≥ 2. Damit ist gezeigt, daß die Menge, aus der das Minimum genommen wird, nie leer ist. Beweis: Induktion über k. k = 0: Zeige für alle n ∈ N, n ≥ 2: n/2 ≤ n − 1. (⇔ n/2 ≥ 1 ⇔ n ≥ 2) Damit folgt I0 (n) ≤ n − 1 < n. (i−1) (i) k → k + 1: Nach Induktionsvoraussetzung ist für alle i ∈ N entweder Ik−1 (n) = 1 oder Ik−1 (n) = (i−1) (i−1) Ik−1 (Ik−1 (n)) < Ik−1 (n). Wegen Ik−1 (n) < n muß man Ik−1 also höchstens n − 1 mal anwenden, bis man auf 1 stößt. Es folgt also Ik (n) < n. Beispiel: Wir berechnen zunächst I1 (14). Es ist I0 (14) = 7, I0 (7) = 4, I0 (4) = 2, I0 (2) = 1. Damit folgt I1 (14) = 4. Jetzt versuchen wir uns an der Berechnung von I2 (5). Dazu berechnen wir: I1 (5) = 3 I1 (3) = 2 (I0 (5) = 3, I0 (3) = 2, I0 (2) = 1) (I0 (3) = 2, I0 (2) = 1) I1 (2) = 1 (I0 (2) = 1) Wir erhalten also: I2 (5) = 3. Beispiel: Wir zeigen noch: Für alle k ∈ N0 ist Ik (3) > 1. k = 0: Es ist I0 (3) = d3/2e = 2 > 1. k → k + 1: Nach Induktionsvoraussetzung ist Ik (3) > 1. Um durch Anwendung von Ik von 3 auf 1 zu kommen, muß man also mindestens 2 Schritte machen, d.h. Ik+1 (3) > 1. Analog kann man auch zeigen, daß für alle k ∈ N0 gilt: Ik (4) > 1. Lemma 8.6 Für alle k ∈ N0 ist Ik (Ak (n)) = n für alle n ∈ N. Beweis: Induktion über k. k = 0: I0 (A0 (n)) = d(2n)/2e = n. k − 1 → k: Ik (Ak (n)) (i) = min{i ∈ N | Ik−1 (Ak (n)) = 1} (i) (n) = min{i ∈ N | Ik−1 (Ak−1 (1)) = 1} (n−i) = min{i ∈ N, i ≤ n | Ak−1 (1)) = 1}. 79 (n−i) Der letzte Schritt folgt dabei aus der Induktionsvoraussetzung. Ist i < n, dann ist A k−1 (1) > 1. Für (n−i) i = n ist Ak−1 (1)) = 1, so daß wir uns im letzten Schritt auf i ≤ n beschränken konnten. Es folgt Ik (Ak (n)) = n. Lemma 8.7 Für alle k ∈ N0 , n ∈ N gilt Ik+1 (n) ≤ Ik (n). Beweis: Wir betrachten Ik (n). Entweder ist Ik (n) = 1 und damit Ik+1 (n) = 1 = Ik (n), oder es ist Ik (n) ≥ 2. In diesem Fall folgt mit Lemma 8.5, daß Ik (Ik (n)) < Ik (n) ist. Man braucht damit höchstens Ik (n) − 1 Schritte, um von Ik (n) auf 1 zu kommen. Es folgt Ik+1 (n) ≤ Ik (n) − 1 + 1 = Ik (n). Lemma 8.8 Für alle k ∈ N0 ist Ik (n) monoton wachsend. Beweis: Übung. Definition 8.4 Die inverse Ackermann Funktion α : N × N → N0 ist definiert als α(n, n0 ) = min{k ∈ N0 | Ik (n) ≤ 2 + n0 }. = min{k ∈ N0 | Ak (2 + n0 ) ≥ n}. Lemma 8.9 Die beiden Definitionen sind gleich. Beweis: Es ist wegen Ik (Ak (2 + n0 )) = 2 + n0 Ak (2 + n0 ) ≥ n ⇔ 2 + n0 ≥ Ik (n). Lemma 8.10 Für alle n ≥ 5 gilt: Ik (n) > 2 für alle k ∈ N0 . Die Funktion Ik kann für alle n ≥ 5 also nie kleiner als 3 werden, d.h. wir brauchen in der Definition 2 + n0 , damit die Menge, aus der wir das Minimum nehmen, nicht leer ist. Beweis: Induktion über k. k = 0: I0 (n) = dn/2e ≥ d5/2e = 3 > 2. (i) k → k + 1: Es ist Ik+1 (n) = min{i ∈ N | Ik (n) = 1}. Sei n ≥ 5. Nach Induktionsvoraussetzung wissen wir, daß Ik (n) > 2 ist. Also folgt Ik+1 (n) ≥ 2. Wir nehmen jetzt an, daß Ik+1 (n) = 2 ist, also daß Ik (Ik (n)) = 1 ist, und führen diese Aussage zu einem Widerspruch. Zunächst muß in diesem Fall Ik (n) < 5 sein, da sonst nach Induktionsvoraussetzung Ik (Ik (n)) > 2 > 1 ist. Also kann Ik (n) ∈ {3, 4} sein, wegen Ik (n) > 2. Ist Ik (n) = 3, so haben wir gezeigt, daß Ik (Ik (n)) = Ik (3) > 1 ist. Genauso für Ik (n) = 4. Wir haben also den Widerspruch, d.h. der Fall Ik+1 (n) = 2 kann nicht eintreten. Daher ist Ik+1 (n) > 2. Beispiel: n0 = 1: α(n, 1) = min{k ∈ N0 | Ak (3) ≥ n} Da A4 (3) extrem riesig ist, folgt, daß für alle Zahlen, die man so verwendet, α(n, 1) ≤ 3 ist. Diese Funktion verhält sich also so gut wie konstant. Allerdings ist sie nicht konstant, man kann im Gegenteil sogar zeigen, daß lim α(n, 1) = ∞ ist. n→∞ Das zeigen wir allgemeiner: Lemma 8.11 Für alle n0 ∈ N gilt lim α(n, n0 ) = ∞. n→∞ Beweis: Übung. 80 8.4.2 Die Analyse Wir haben die Funktion α eingeführt, um folgende Analyse für Partitionen mit Pfadkomprimierung durchzuführen: Führt man auf einer Partition einer Menge mit n Elementen n − 1 union-Operationen und m findOperationen durch, so hat man bei Verwendung von Bäumen mit Pfadkomprimierung eine Laufzeit von Θ(n + mα(n, m/n)). Diese Aussage können wir leider nicht beweisen. Wir werden eine ähnliche, etwas schwächere Aussage zeigen: Satz 8.1 Führt man auf einer Partition einer Menge mit n Elementen n − 1 union-Operationen und m find-Operationen durch, so hat man bei Verwendung von Bäumen mit Pfadkomprimierung eine Laufzeit von O((n + m)α(n, 1)). Beweis: Seien P1 , . . . , Pm die Pfade entlang denen die find-Operationen laufen. Diese laufen immer von einem Element zur Wurzel des entsprechenden Baumes. Wir wollen die Situation jetzt vereinfachen und nur einen Baum betrachen. Dazu nehmen wir an, daß wir zuerst alle union-Operationen durchführen, so daß wir einen einzigen Baum übrigbehalten. Dann erst werden alle find-Operationen durchgeführt. Dabei gehen wir in diesem Baum aber nicht bis zur Wurzel, sondern wir gehen wirklich die Pfade P1 , . . . , Pm entlang. Bei einem Pfad werden die durchlaufenen Knoten auch nicht an die Wurzel des Baumes, sondern an den Endknoten des Pfades gehängt. E E Pfadkomprimierung auf dem Pfad von A nach E 2 Die Knoten A und 1 werden an E gehaengt. 2 A 1 1 A Wir betrachten also jetzt m Pfadkomprimierungen P1 , . . . , Pm auf dem einen Baum mit n Knoten. Für einen Knoten u aus diesem Baum sei h(u) die Höhe von u im Baum. Dabei ist die Höhe von Blättern gleich 0. Die Höhe eines Knotens u mit mehreren Kindern ist das Maximum der Höhen seiner Kinder +1. 81 In jedem Knoten steht seine Hoehe. 4 0 3 0 1 1 2 0 0 0 0 0 Wir haben uns bereits vorher überlegt, daß die Pfade eines jeden Knotens zur Wurzel des Baumes höchstens blog nc lang sind. Das liegt daran, daß wir bei einem union immer den Baum mit weniger Elementen an die Wurzel des Baumes mit mehr Elementen hängen. Es folgt, daß die Höhe jedes Knotens ≤ log n ist. Definition 8.5 Gegeben sei ein Knoten u mit Elternknoten v in dem Baum. Der Höhenunterschied von u zu seinem Elternknoten v ist vom Typ k, falls h(v) + 3 ≥ Ak (h(u) + 3), h(v) + 3 < Ak+1 (h(u) + 3). (Die ’+3’ wird aus technischen Gründen benötigt, da wir sonst mit kleinen Höhen Probleme bekommen.) Ein Knoten u heißt vom Typ k, wenn er zu seinem Elternknoten einen Höhenunterschied vom Typ k hat. Lemma 8.12 Es kommen nur Höhenunterschiede der Typen 0, 1, . . . , α(n, 1) − 1 vor. Beweis: Die Höhe jedes Knotens ist nach obiger Überlegung ≤ log n. Wir interessieren uns für Laufzeitabschätzungen bei großen n, d.h. wir können n so groß wählen, daß log n < n − 3 ist. Dann kommen nur Höhen 0, 1, . . . , n − 4 vor. Nach Definition der Funktion α ist α(n, 1) = min{k ∈ N0 | Ak (3) ≥ n}. Damit folgt insbesondere, daß Aα(n,1) (3) ≥ n ist. Da die Ak monoton wachsen, ist damit Aα(n,1) (h(u) + 3) ≥ n > (n − 4) + 3 für alle Knoten u. (Hier würden wir Probleme bekommen, wenn wir die ’+3’ weglassen würden.) Damit folgt, daß die Höhenunterschiede nicht vom Typ α(n, 1) (oder größer) sein können, also folgt die Behauptung. Zu Beginn der Analyse führen wir also alle union-Operationen durch und erhalten einen Baum. Zu jedem Knoten dieses Baumes merken wir uns die Höhe. Bei den Pfadkompressionen, die danach durchgeführt werden, bleiben die Höhen der Knoten enthalten, die Knoten ändern nur ihre Lage. Dabei werden immer Knoten mit kleinerer Höhe an Knoten mit größerer Höhe gehängt. Mit dem Lemma haben wir gezeigt, daß die Höhenunterschiede nicht beliebig groß werden können. Definition 8.6 Eine Umhängung eines Knotens vom Typ k heißt effektiv, falls es weiter oben im Pfad noch einen Knoten gibt, der mindestens vom Typ k ist. 82 Lemma 8.13 Es gibt insgesamt O(mα(n, 1)) nichteffektive Umhängungen. Beweis: Auf jedem Pfad gibt es höchstens so viele nichteffektive Umhängungen, wie es verschiedene Typen gibt. Lemma 8.14 Für einen Knoten u gibt es höchstens (h(u) + 3)α(n, 1) effektive Umhängungen. Beweis: Der Knoten u sei vom Typ Ak . Die Höhe des Elternknotens v von u (+3) ist also kleiner als (i) Ak+1 (h(u) + 3). Es sei h(v) + 3 ≥ Ak (h(u) + 3). Nach einer effektiven Umhängung von u hat u einen neuen Elternknoten w, für dessen Höhe nach Definition der effektiven Umhängung gilt: (i+1) h(w) + 3 ≥ Ak (h(u) + 3). Nach h(u) + 3 effektiven Umhängungen hat u dann einen Elternknoten, dessen Höhe mindestens (h(u)+3) ≥ Ak (h(u)+3) (h(u) + 3) ≥ Ak (1) = Ak+1 (h(u) + 3) ist. (Dabei haben wir verwendet, daß Ak monoton wächst.) Der Typ von u hat sich also noch h(u) effektiven Umhängungen mindestens um 1 erhöht. Da der Typ nicht beliebig groß werden kann, kann es höchstens (h(u) + 3)α(n, 1) effektive Umhängungen von u geben. Die Gesamtzahl aller Umhängungen ergibt effektiven Umhängungen P sich also aus der Gesamtzahl aller P h(u)α(n, 1) + 3nα(n, 1) plus der Gesamtzahl aller (h(u) + 3)α(n, 1) = O O Knoten u Knoten u nichteffektiven Umhängungen O(mα(n, 1)): X Gesamtzahl aller Umhängungen = O h(u) + 3n + m α(n, 1) . Knoten u Dies ist auch gerade die Gesamtlänge der Pfade P1 , . . . , Pm , entlang derer die Komprimierung stattfindet. Der Rest wird in der Übung bewiesen. 83