Computerorientierte Mathematik II mit Java Rolf H. Möhring Technische Universität Berlin Institut für Mathematik Sommersemester 2005 ii Vorbemerkungen Diese Vorlesung ist der zweite Teil des Zyklus Computerorientierte Mathematik und schließt sich direkt an die Computerorientierte Mathematik I an. Dieses Skript basiert auf meiner Vorlesung vom Sommersemester 2004. Zwei Studenten der Vorlesung, Elisabeth Günther und Olaf Maurer, haben im Sommer 2004 eine ausgezeichnete Ausarbeitung der Vorlesung angefertigt, die von mir nur noch leicht überarbeitet und ergänzt wurde. Das Resultat ist dieses Skript, das auch online unter http://www.math.tu-berlin.de/coga/moehring/Coma/Skript-II-Java/ zur Verfügung steht. Die Vorlesung umfasst folgende Punkte: Wir behandeln zunächst ein Sortierverfahren namens Bucketsort, das durch besondere Anforderungen an die Schlüsselmenge schon in linearer Zeit sortieren kann. Dann werden Bäume, insbesondere binäre Bäume besprochen und wie diese zur Datenkompression mit dem Huffman-Algorithmus genutzt werden können. Bäume finden als Suchbäume und insbesondere als AVL-Bäume weitere Verwendung. Wir kommen dann zu optimalen statischen Suchbäumen und besprechen eine Alternative zum Suchen in Bäumen, das sogenannte Hashing. Den Abschluss des Semesters bildet ein Kapitel über Schaltkreistheorie und Programmierbare Logische Arrays. iii iv VORBEMERKUNGEN Inhaltsverzeichnis Vorbemerkungen iii Inhaltsverzeichnis v 1 Bucketsort 1 1.1 Einfaches Bucketsort . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2 1.1.1 Definition . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2 1.1.2 Implementation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4 1.1.3 Aufwandsanalyse . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4 Sortieren von Strings mit Bucketsort . . . . . . . . . . . . . . . . . . . . . . . . . . 5 1.2.1 Sortieren von Strings der Länge k . . . . . . . . . . . . . . . . . . . . . . . 6 1.2.2 Sortieren von Binärzahlen . . . . . . . . . . . . . . . . . . . . . . . . . . . 8 1.2.3 Sortieren von Strings variabler Länge . . . . . . . . . . . . . . . . . . . . . 9 Literaturhinweise . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 13 1.2 1.3 2 Bäume und Priority Queues 15 2.1 Bäume . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 15 2.1.1 Grundbegriffe . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 15 2.1.2 Implementation von binären Bäumen . . . . . . . . . . . . . . . . . . . . . 17 2.1.3 Traversierung von Bäumen . . . . . . . . . . . . . . . . . . . . . . . . . . . 22 Priority Queues . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 25 2.2.1 Mögliche Implementationen einer Priority Queue . . . . . . . . . . . . . . . 26 Literaturhinweise . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 27 2.2 2.3 3 Huffman Codes und Datenkompression 29 v vi INHALTSVERZEICHNIS 3.1 Codierung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 29 3.1.1 Präfixcode . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 31 3.2 Der Huffman Algorithmus . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 33 3.3 Weitere Datenkompressionsverfahren . . . . . . . . . . . . . . . . . . . . . . . . . 41 3.3.1 Der adaptive Huffmancode . . . . . . . . . . . . . . . . . . . . . . . . . . . 41 3.3.2 Der run length code“ . . . . . . . . . . . . . . . . . . . . . . . . . . . . . ” Der Lempel-Ziv Code . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 41 3.4 Abschließende Bemerkungen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 43 3.5 Literaturhinweise . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 44 3.3.3 4 Suchbäume 45 4.1 Basisoperationen in Suchbäumen . . . . . . . . . . . . . . . . . . . . . . . . . . . . 46 4.1.1 Suchen nach Schlüssel k . . . . . . . . . . . . . . . . . . . . . . . . . . . . 46 4.1.2 Einfügen eines Knoten . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 46 4.1.3 Löschen eines Knoten . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 46 Literaturhinweise . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 51 4.2 5 AVL-Bäume 53 5.1 Grundsätzliche Eigenschaften . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 53 5.2 Rotationen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 57 5.3 Die Basisoperationen in AVL-Bäumen . . . . . . . . . . . . . . . . . . . . . . . . . 61 5.3.1 Suchen eines Knotens v . . . . . . . . . . . . . . . . . . . . . . . . . . . . 62 5.3.2 Einfügen eines neuen Knotens v . . . . . . . . . . . . . . . . . . . . . . . . 62 5.3.3 Löschen eines Knotens v . . . . . . . . . . . . . . . . . . . . . . . . . . . . 65 Literaturhinweise . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 67 5.4 6 7 41 Optimale statische Suchbäume 69 6.1 Statische Suchbäume allgemein . . . . . . . . . . . . . . . . . . . . . . . . . . . . 69 6.2 Optimalität statischer Suchbäume . . . . . . . . . . . . . . . . . . . . . . . . . . . 70 6.3 Konstruktion eines optimalen statischen Suchbaumes . . . . . . . . . . . . . . . . . 73 6.4 Literaturhinweise . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 81 B-Bäume 83 7.1 83 Definition und Eigenschaften . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . INHALTSVERZEICHNIS 7.2 7.3 8 Basisoperationen in B-Bäumen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 86 7.2.1 Suchen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 86 7.2.2 Einfügen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 86 7.2.3 Löschen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 88 Literaturhinweise . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 91 Hashing 93 8.1 Hash-Funktionen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 94 8.1.1 Divisionsmethode . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 95 8.1.2 Multiplikationsmethode . . . . . . . . . . . . . . . . . . . . . . . . . . . . 95 Kollisionsbehandlung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 96 8.2.1 Chaining . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 96 8.2.2 Offene Adressierung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 97 8.2 8.3 9 vii Literaturhinweise . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 106 Schaltkreistheorie und Rechnerarchitektur 107 9.1 Schaltfunktionen und Schaltnetze . . . . . . . . . . . . . . . . . . . . . . . . . . . . 107 9.2 Vereinfachung von Schaltnetzen . . . . . . . . . . . . . . . . . . . . . . . . . . . . 115 9.3 9.4 9.5 9.2.1 Das Verfahren von Karnaugh . . . . . . . . . . . . . . . . . . . . . . . . . . 117 9.2.2 Das Verfahren von Quine und McCluskey . . . . . . . . . . . . . . . . . . . 120 9.2.3 Das Überdeckungsproblem . . . . . . . . . . . . . . . . . . . . . . . . . . . 125 Schaltungen mit Delays . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 126 9.3.1 Addierwerke . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 128 9.3.2 Das Fan-In-Problem . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 130 PLAs und das Prinzip der Mikroprogrammierung . . . . . . . . . . . . . . . . . . . 131 9.4.1 Aufbau eines PLAs . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 131 9.4.2 Zur Programmierung von PLAs . . . . . . . . . . . . . . . . . . . . . . . . 133 Literaturhinweise . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 135 Literaturverzeichnis 141 Index 142 viii INHALTSVERZEICHNIS Kapitel 1 Bucketsort Bucketsort ist ein Sortierverfahren, das grundsätzlich anders als alle Sortierverfahren funktioniert, die wir bisher kennen gelernt haben. Es zeichnet sich dadurch aus, dass es nicht wie die Verfahren aus Teil I der Vorlesung auf paarweisen Vergleichen von Schlüsseln basiert, sondern voraussetzt, dass die Schlüsselmenge klein und bekannt ist, und dass es Objekte direkt dem richtigen Bucket“ (Fach) ” zuordnet. Anschaulich lässt sich dieses Verfahren mit der Verteilung der Post im Postamt auf die Häuser einer Straße vergleichen. Der Briefträger hat eine Reihe von Fächern, die den Hausnummern entsprechen. Er geht die Briefe der Reihe nach durch und legt jeden Brief in O(1) (also konstanter) Zeit in das Fach mit der entsprechenden Hausnummer. Dabei können in einem Fach natürlich mehrere Briefe sein, die aber aus Sicht der Ordnungsrelation gleich sind (da sie in das gleiche Bucket sortiert werden, haben sie ja die gleiche Nummer) und daher nicht mehr innerhalb des Fachs sortiert werden müssen. Der Briefträger entnimmt die Briefe den Fächern der Reihe nach und hat sie damit nach Hausnummern sortiert. Bei m Hausnummern und n Briefen sortiert er also in O(m + n). Da in der Regel m ≤ n gilt, wenn Bucketsort angewendet wird, befindet sich der Algorithmus dann in der Komplexitätsordnung Θ(2n) = Θ(n) und man erhält so also einen Sortieralgorithmus, dessen Aufwand linear von der Anzahl der zu sortierenden Schlüssel abhängt. Man beachte, dass die in der CoMa I ermittelte untere Komplexitätsschranke von Ω(n log n) nur Sortieralgorithmen betrifft, die auf paarweisen Vergleichen beruhen. Bucketsort beruht jedoch nicht auf paarweisen Vergleichen von Schlüsseln und setzt außerdem zusätzliche Informationen über die Schlüsselmenge voraus. Daher liegt Bucketsort nicht in der Klasse der Sortieralgorithmen, die von dieser Schranke betroffen sind. Umgesetzt auf Datenstrukturen bedeutet dies: Version vom 24. März 2006 1 2 KAPITEL 1. BUCKETSORT Wirklichkeit Fächer Hausnummern Stapel im Fach Briefe am Anfang Briefe am Ende 1.1 1.1.1 Datenstruktur Array Array-Indizes Liste an jedem Array-Index Liste Liste Einfaches Bucketsort Definition Wir geben jetzt einen Algorithmus für die oben erklärte Situation an. Gegeben seien also n Objekte a1 , a2 , . . . , an mit Schlüsselwerten s(a1 ), s(a2 ), . . . , s(an ) in einer Liste L. O.B.d.A. seien die Schlüsselwerte Zahlen zwischen 0 und m − 1, also s(a j ) ∈ {0, 1, . . . , m − 1}, j = 1, . . . , n. Es gibt dann also genau m paarweise verschiedene Schlüsselwerte. Algorithmus 1.1 (Einfaches Bucketsort) 1. Initialisiere ein Array mit m leeren Queues Qi (Buckets), je eine für jeden Wert i = 0, 1, . . . , m−1 und je einer Referenz (head bzw. tail) auf den Anfang und das Ende der Queue Qi . 2. Durchlaufe L und füge das Objekt a j entsprechend seines Schlüsselwertes in die Queue Qs(a j ) ein. 3. Konkateniere die Queues Q0 , Q1 , . . . , Qm−1 über die head- und tail-Referenzen zu einer Liste L und gebe L zurück. Beispiel 1.1 Sei m = 5 und n = 9. Die Liste L ist gegeben durch Abbildung 1.1. head r - a1 - a2 - a3 - a4 - a5 - a6 - a7 - a8 - a9 2 r 1 r 0 r 2 r 2 r 4 r 0 r 4 r 1 Abbildung 1.1: Liste L der zu sortierenden Elemente Es wird ein Array A mit je 2 Referenzen auf head und tail der Queues Qi eingerichtet. Abbildung 1.2 zeigt die Queues nach Abarbeitung der Liste L, also am Ende von Schritt 2. Dann werden die einzelnen Listen konkateniert. Das Konkatenieren der Listen ist sehr einfach, da nur das letzte Element (über tail) auf das erste Element (über head) der nächsten nichtleeren Liste gesetzt werden muss, siehe Abbildung 1.3. 3 1.1. EINFACHES BUCKETSORT A r A[0] A[1] A[2] A[3] A[4] - a3 - a7 0 r r r r r r r r 2 r 2 r - a6 - a8 4 r 1 - a1 - a4 - a5 2 r r r - a2 - a9 1 r r 0 r 4 r Abbildung 1.2: Queues nach Abarbeitung der Liste A r A[0] A[1] A[2] A[3] A[4] - a3 - a7 0 r r r r r r r r 2 r 2 r - a6 - a8 4 r 1 - a1 - a4 - a5 2 r r r - a2 - a9 1 r r 0 r 4 r Abbildung 1.3: Queues nach Konkatenation der Einzellisten 4 KAPITEL 1. BUCKETSORT 1.1.2 Implementation Eine Implementation in Java könnte in etwa wie folgt aussehen: class QueuePointer { public ListNode head; public ListNode tail; } QueuePointer[] A = new QueuePointer[n]; // array with head- and tail-reference in each field Das Einfügen des Knoten node mit Schlüsselwert i in die i-te Queue Qi geschieht dann mit einer Anweisung der Form A[i].tail.setNext(node); Die Konkatenation zweier Queues erfolgt über die Anweisung A[i].tail.setNext(A[j].head); Dabei ist j die erste nichtleere Liste nach i. 1.1.3 Aufwandsanalyse Satz 1.1 (Aufwand von einfachem Bucketsort) Algorithmus 1.1 sortiert die Liste korrekt in O(m + n) Zeit. Beweis: Am Ende von Schritt 2 enthält jede Queue Qi nur Objekte a j mit Schlüssel s(a j ) = i. Die Konkatenation der Queues in der Reihenfolge Q0 , Q1 , . . . , Qm−1 liefert also eine korrekt sortierte Liste. Das Einfügen von a j in die Queue Qi mit i = s(a j ) erfolgt durch Umhängen von Referenzen in O(1) Zeit. Beim Durchlaufen der Liste L sind alle Vorgänger von a j bereits aus L entfernt und der Listenzeiger current der Liste L zeigt auf a j . Die Sequenz A[i].tail.setNext(current); A[i].tail = current; current = current.getNext(); hängt a j aus L aus und in Qi ein. Das Einfügen aller Objekte a j geschieht also in O(n) Zeit. Das Konkatenieren der Queues kann in einer Schleife mit O(1) Aufwand pro Queue geschehen. Wir geben ein Code-Fragment an, das die Konkatenierung durchführt: 1.2. SORTIEREN VON STRINGS MIT BUCKETSORT 5 k = 0; while ((A[k].head == NULL) && (k < m)) k++; // k is now the first nonempty list in A, if there is one i = k + 1; while (i < m) { // while-loop: O(m) // at this point k is the last list we have already concatenated // we will now look for the next nonempty list after k while ((A[i].head == NULL) && (i < m)) i++; if (i==m) break; // if (i==m), we have iterated through all nonempty lists // in A and are finished // we have found a new nonempty list, concatenate it to k A[k].tail.setNext(A[i].head); // and prepare k for the next iteration k = i; i++; } // endwhile Weil das Konkatenieren pro Queue nur einen Aufwand in O(1) benötigt und maximal m Buckets konkateniert werden müssen, ist der Aufwand damit insgesamt O(n) + O(m) = O(m + n). Falls m ≤ n gilt, ist der Aufwand in der Komplexitätsklasse O(n). Bucketsort schlägt dann also die untere Komplexitätsschranke für Sortieralgorithmen, die auf paarweisen Vergleichen beruhen. Dafür benötigt Bucketsort allerdings Informationen über die Werte der auftretenden Schlüssel, weil sonst m nicht klein gehalten werden kann und der Aufwand von Bucketsort nur dann in O(n) liegt, wenn m ≤ n gilt. 1.2 Sortieren von Strings mit Bucketsort Wir wollen jetzt Bucketsort zum Sortieren von Strings gemäß der lexikographischen Ordnung nutzen. Wir definieren zuerst, was wir unter lexikographisch kleiner“ verstehen wollen: ” Sei S die Menge der Zeichen und ≤ eine lineare Ordnung auf S. Seien A = a1 a2 . . . a p und B = b1 b2 . . . bq zwei Strings der Länge p bzw. q über S. Dann heißt A lexikographisch kleiner als B, in Zeichen A ≤lex B 6 KAPITEL 1. BUCKETSORT falls einer der folgenden Fälle zutrifft: 1. p ≤ q und ai = bi für i = 1, . . . , p (d.h. A ist ein Anfangsstück von B). 2. Es gibt j ∈ {1, . . . , p} mit a j ≤ b j und ai = bi für i = 1, . . . , j − 1 (d.h. an der ersten Stelle j, an der A und B verschieden sind, ist a j kleiner oder gleich b j bezüglich der linearen Ordnung auf S). Beispiel 1.2 Hall ≤lex Hallo, Arbeit ≥lex Album Wir sortieren mit Bucketsort lexikographisch, indem wir für jede Komponente einfaches Bucketsort verwenden. Zunächst betrachten wir den Spezialfall, dass alle Strings die gleiche Länge k haben. Dies beinhaltet insbesondere das Sortieren k-stelliger Binärzahlen beziehungsweise von Strings auf der Zeichenmenge Sb = {0, 1}. 1.2.1 Sortieren von Strings der Länge k Die Idee besteht darin, die Strings bezüglich der Stellen mit Bucketsort zu sortieren, wobei die Stellen von hinten nach vorn durchlaufen werden. Dies gewährleistet, dass vor dem Bucketsort bezüglich Stelle i (also nach k − i Iterationen) die Strings bereits nach den letzten Stellen i + 1, . . . , k lexikographisch sortiert sind. Diese Sortierung wird trotz der späteren Durchläufe erhalten, weil durch Bucketsort die Elemente, die an einer Stelle i gleich sind, in der gleichen Reihenfolge eingefügt werden, in der sie schon waren (durch die vorherige Iteration beziehungsweise von Anfang an). Diese Eigenschaft eines Sortieralgorithmus bezeichnet man als Stabilität, Bucketsort ist ein stabiler Sortieralgorithmus. Algorithmus 1.2 (Bucketsort) Input: Eine Liste L mit Strings A1 , A2 , . . . , An der Länge k mit Ai = ai1 , ai2 , . . . , aik und ai j ∈ S = {0, 1, . . . , m − 1} Output: Eine Permutation B1 , . . . , Bn von A1 , . . . , An mit Bi ≤lex B2 ≤lex · · · ≤lex Bn Methode: 1. Richte eine Queue Q ein und füge A1 , . . . , An in Q ein. 1 2. Richte ein Array Bucket von m Buckets ein (wie beim einfachen Bucketsort) 3. for jede Stelle r := k downto 1 do 3.1 Leere alle Buckets Bucket[i] 3.2 while Q nicht leer ist do 1 Einfügen bedeutet hier immer, dass nur Referenzen auf A eingefügt werden. Das Einfügen geschieht also in O(1) und i nicht wie beim zeichenweisen Einfügen des Strings in O(k). 1.2. SORTIEREN VON STRINGS MIT BUCKETSORT 7 • Sei A j das erste Element in Q • Entferne A j aus Q und füge es in Bucket[i] mit i = a jr ein endwhile 3.3 Konkateniere die nichtleeren Buckets in die Queue Q endfor Beispiel 1.3 Sei S = {0, 1} und 0 < 1. Seien A1 = 010, A2 = 011, A3 = 101, A4 = 100. Wir sortieren zunächst nach der letzten Komponente: 010 011 101 100 Dadurch erhalten wir Folgendes: 0 : 010 100 ⇒ 010 100 011 101 1 : 011 101 Wir sortieren dann nach der zweitletzten Komponente: 010 100 011 101 Wir erhalten: 0 : 100 101 ⇒ 100 101 010 011 1 : 010 011 Nun die letzte Iteration, also Sortierung nach der ersten Komponente: 010 100 011 101 Wir erhalten: 0 : 010 011 ⇒ 010 011 100 101 1 : 100 101 Nach dem Durchlauf der for-Schleife stehen die Strings in folgender Reihenfolge in Q: r=3 r=2 r=1 010 100 010 100 101 011 011 010 100 101 011 101 nach letzter Stelle sortiert nach letzten 2 Stellen sortiert nach letzten 3 Stellen sortiert Wir sehen also: Das Sortierverfahren arbeitet (bei diesem Beispiel) korrekt. Das motiviert folgenden Satz: Satz 1.2 (Aufwand von Bucketsort) Algorithmus 1.2 sortiert A1 , . . . , An lexikographisch korrekt in O((m + n) · k) Zeit. Beweis: Wir beweisen folgende Invariante: Nach dem i-ten Durchlauf sind die Strings bezüglich der letzten i Zeichen lexikographisch aufsteigend sortiert. Daraus folgt dann insbesondere, dass beim Sortieren von k-stelligen Strings nach dem k-ten Durchlauf die Strings bezüglich der letzten k Stellen (also allen Stellen) lexikographisch korrekt sortiert sind und damit die Behauptung. Der Beweis der Invariante erfolgt durch vollständige Induktion über die Iterationsschritte, hier bezeichnet mit r. 8 KAPITEL 1. BUCKETSORT r = 1: (einfaches Bucketsort nach letzter Komponente): In diesem Fall folgt die Korrektheit aus Satz 1.1, da die Strings nach Satz 1.1 lexikographisch korrekt nach der letzten Stelle sortiert werden. r → r + 1: Die Behauptung sei also für r bewiesen. Betrachte nun die beiden Strings Ai und A j in der (r + 1)-ten Iteration. Wir unterscheiden zwei Fälle: Fall I: Ai und A j werden in der (r + 1)-ten Iteration in unterschiedliche Buckets sortiert. Da Ai und A j in unterschiedliche Buckets sortiert werden, unterscheiden sich Ai und A j also an der gerade betrachteten Stelle. Die lexikographische Korrektheit der Sortierung folgt dann wieder aus Satz 1.1, da wir ja wieder einfaches Bucketsort an der (r + 1)sten Stelle von hinten betrachten. Daher sind sie lexikographisch korrekt sortiert. Fall II: Ai und A j werden in der (r + 1)-ten Iteration in das gleiche Bucket sortiert. Da die einzelnen Buckets durch Queues realisiert werden, werden die Strings in der Reihenfolge, in der sie im vorigen Durchgang schon waren, hinten eingefügt und in der nächsten Iteration beziehungsweise in der Konkatenation wieder in dieser Reihenfolge ausgelesen. Da die Strings aber nach der r-ten Iteration schon lexikographisch korrekt sortiert waren, sie sich aber an der (r + 1)-ten Stelle von hinten nicht unterscheiden, sind sie dann nach den letzten (r + 1) Stellen lexikographisch korrekt sortiert. Da für jedes Zeichen in dem String genau ein Durchlauf erfolgt, erfolgen genau k Durchläufe. Da jeder dieser Durchläufe (wie in Satz 1.1 bewiesen) in O(n + m) erfolgt, erfolgt die ganze Sortierung daher in O (k · (n + m)). 1.2.2 Sortieren von Binärzahlen Als spezielle Anwendung gibt es das Sortieren von n k-stelligen Binärzahlen in O(k · n). In CoMa I wurde aber bewiesen, dass zum Sortieren von n Zahlen ein Aufwand in der Größenordnung O(n log n) erforderlich ist. Der scheinbare Widerspruch ist aber keiner: Mit k Stellen kann man nur 2k paarweise verschiedene Binärzahlen bilden. Für die Darstellung von n paarweise verschiedenen k-stelligen Binärzahlen muss daher gelten: 2k ≥ n ⇔ k ≥ log n Also gilt für dieses von n abhängige k: n log n ≤ k · n 1.2. SORTIEREN VON STRINGS MIT BUCKETSORT 1.2.3 9 Sortieren von Strings variabler Länge Erste Idee: Die erste Idee für diesen Sortieralgorithmus ist es, ein Bucket hinzuzufügen, in das die Strings sortiert werden, die im aktuellen Durchlauf an der betrachteten Stelle kein Zeichen haben. Weil diese Strings lexikographisch kleiner sind, steht dieses Bucket vor allen anderen Buckets. Die Sortierung erfolgt dann einfach mit Algorithmus 1.2. Beispiel 1.4 Wir betrachten die Strings bab, abc und a und wollen sie in ihre lexikographisch korrekte Reihenfolge sortieren. In der ersten Iteration erhalten wir kein Zeichen : a a: ⇒ a bab abc b: bab c: abc Dann sortieren wir nach dem zweitletzten Zeichen: kein Zeichen : a a: bab ⇒ a bab abc b: abc c: Und schließlich sortieren wir noch nach dem ersten Zeichen: kein Zeichen : : a a abc ⇒ a abc bab b: bab c: Durch diesen Ansatz werden in jeder Iteration alle Strings betrachtet. Bezeichne `max die Länge des längsten dieser Strings. Dann hat dieser Sortieralgorithmus den gleichen Aufwand wie der Sortieralgorithmus 1.2 zur Sortierung von Strings der festen Länge k = `max . Nach Abschnitt 1.2.1 ist also der Gesamtaufwand dieses Algorithmus in der Klasse O (`max · (n + m)). Es geht aber besser: Bezeichne `total die Gesamtanzahl der Zeichen. Wir können den Algorithmus so modifizieren, dass er in O(`total + m) liegt. Ideen für einen besseren Algorithmus Sei wieder `max die Länge des Strings mit der größten Länge. 1. Sortiere die Strings Ai nach absteigender Länge `i . 2. Verwende `max -mal Bucketsort wie vorher, aber betrachte in Phase r nur die Strings Ai , für die `i ≥ `max − r + 1 gilt (also die Strings, die an der aktuell betrachteten Stelle ein Zeichen haben, weil sie genügend lang sind). 10 KAPITEL 1. BUCKETSORT 3. Um leere Buckets zu vermeiden, bestimme vorab die nötigen Buckets in jeder Phase und konkateniere am Ende einer Phase nur die nichtleeren Buckets. (Verringert den Aufwand zur Konkatenation auf O(#nichtleer) statt O(m).) Algorithmus 1.3 Input: Strings (Tupel) A1 , . . . , An ai j ∈ {0, . . . , m − 1} Ai = (ai1 , ai2 , . . . , ai`i ), (oder auch ein beliebiges anderes Alphabet) `max = max `i i Output: Permutation B1 , . . . , Bn von A1 , . . . , An mit B1 ≤lex B2 ≤lex · · · ≤lex Bn Methode: 1. Generiere ein Array von Listen NONEMPTY[] der Länge `max und für jedes `, 1 ≤ ` ≤ `max eine Liste in NONEMPTY[`], die angibt, welche Zeichen an einer der `-ten Stellen vorkommen und welche Buckets daher in der (`total − `)-ten Iteration benötigt werden. Dazu: 1.1 Erschaffe für jedes ai` , 1 ≤ i ≤ n, 1 ≤ ` ≤ `i ein Paar (`, ai` ) (das bedeutet: das Zeichen ai` kommt an `-ter Stelle in einem der Strings vor) 1.2 Sortiere die Paare lexikographisch mit Algorithmus 1.2, indem man sie als zweistellige Strings betrachtet. 1.3 Durchlaufe die sortierte Liste der (`, ai` ) und generiere im Array NONEMPTY[], sortierte Listen, wobei das Array NONEMPTY[`], 1 ≤ ` ≤ `max eine sortierte Liste aller ai` enthält. Dabei lassen sich auch gleich auf einfache Weise eventuell auftretende Duplikate entfernen. 2. Bestimme Länge `i jedes Strings und generiere Listen LENGTH[`] aller Strings mit Länge ` (nur Referenzen auf die Strings in LENGTH[`] verwalten, daher nur O(1) für Referenzen umhängen) 3. Sortiere Strings analog zu Algorithmus 1.2.3, beginnend mit `max . Aber: • nach der r-ten Phase enthält Q nur die Strings der Länge ≥ `max − r + 1; diese sind lexikographisch korrekt sortiert bezüglich der letzten r Komponenten. • NONEMPTY [] wird benutzt, um die Listen in BUCKET[] neu zu generieren und außerdem zur schnelleren Konkatenation der Einzellisten. Dies ist nötig, weil wir nur die nichtleeren Buckets verwalten wollen. • vor dem r + 1-ten Durchlauf wird LENGTH [`max − r] am Anfang2 der Queue Q eingefügt. Die kurzen Strings stehen dann am Anfang und damit am lexikographisch richtigen Platz, falls sie mit anderen im selben Bucket landen. 2 Das ist zwar ein wenig ungewöhnlich, bereitet aber grundsätzlich keine Probleme. 11 1.2. SORTIEREN VON STRINGS MIT BUCKETSORT Wir erinnern noch einmal: BUCKET[] ist ein Array von Queues, in das sortiert wird, und Q ist eine Queue, die die Strings enthält, die zur Zeit betrachtet werden, also genügend lang sind. Wir geben nun Pseudocode für Teil 3 von Algorithmus 1.3 an. Algorithmus 1.4 1. Leere Q 2. for j:=0 to m − 1 do 2.1 leere BUCKET[j] 3. for `:=`max downto 1 do 3.1 Füge LENGTH[`] am Anfang von Q ein 3.2 while Q nicht leer do 3.2.1 Sei Ai erster String in Q 3.2.2 lösche Ai in Q und füge Ai in BUCKET[ai` ] ein 3.3 for jedes j in NONEMPTY[`] do 3.3.1 füge BUCKET[j] am Ende von Q ein 3.3.2 leere BUCKET[j] Beispiel 1.5 Sortieren wir nun die gleichen Strings wie vorher, also a, bab und abc. Weil wir mit Referenzen arbeiten, spielt die Stringlänge für das Einfügen keine Rolle. Teil 1 des Algorithmus erzeugt durch einfaches Durchlaufen der Strings folgende Paare: (1, a), (1, b), (2, a), (3, b), (1, a), (2, b), (3, c) in der Liste. Daraus liefert Algorithmus 1.2 dann die sortierte Liste (1, a), (1, a), (1, b), (2, a), (2, b), (3, b), (3, c) Durch einfaches Durchlaufen dieser sortierten Liste von links nach rechts werden daraus die Listen im Array NONEMPTY[] mit den richtigen Einträgen gefüllt: = a, b = a, b NONEMPTY [3] = b, c NONEMPTY [1] NONEMPTY [2] Dann werden die Längen der einzelnen Strings bestimmt: `1 = 1, `2 = 3, `3 = 3 Mit Hilfe dieser Information erzeugt der Algorithmus dann das Array eine Liste aller Strings der Länge ` enthält: LENGTH [], wobei LENGTH [`] 12 KAPITEL 1. BUCKETSORT = a = 0/ LENGTH [3] = bab, abc LENGTH [1] LENGTH [2] Nun führen wir den dritten Teil des Algorithmus aus, dessen Pseudocode wir angegeben haben: Zuerst werden in Q alle Elemente von letzten Stelle sortiert: LENGTH [`max ] = LENGTH [3] eingefügt und dann nach der BUCKET[a] = 0/ BUCKET[b] = bab BUCKET[c] = abc Durch das Array NONEMPTY[] wissen wir, dass wir das erste Bucket gar nicht betrachten müssen. Es werden daher nur die letzten beiden Listen konkateniert und die Elemente am Ende von Q eingefügt. Q enthält nun bab,abc. Es wird daraufhin LENGTH[2] = 0/ am Anfang von Q eingefügt. Daraufhin wird Q nach der zweitletzten Stelle sortiert: BUCKET[a] = bab = abc BUCKET[c] = 0/ BUCKET[b] Wieder wissen wir schon, dass das letzte Bucket leer ist und brauchen es daher nicht zu betrachten. Es werden die ersten beiden Listen konkateniert und am Ende von Q eingefügt. Q enthält nun bab, abc. Daraufhin wird LENGTH[1] = a am Anfang von Q eingefügt, Q enthält nun also a, bab, abc. Daraufhin wird Q nach der ersten Stelle sortiert: Q[a] = a, abc Q[b] = bab Q[c] = 0/ Dann werden die Listen konkateniert und wir erhalten als Ergebnis: a, abc, bab. Zum Aufwand Um die Paare zu generieren, müssen alle Strings durchlaufen werden und für jedes Zeichen in jedem der Strings muss genau ein Paar erzeugt werden. Der Aufwand dafür ist n O(`1 + `2 + · · · + `n ) = O ∑ `i ! = O(`total ) i=1 Beim Sortieren der Paare ist `max = 2. Dieser Teil erfordert einen Aufwand von 13 1.3. LITERATURHINWEISE 2. Komponente 1.Komponente z }| { z }| { `max ≤`total O 2 · `total + |{z} m + `total + `max ⊆ O(`total + m) |{z} |{z} |{z} Elemente Buckets Elemente Buckets Um NONEMPTY[] einzurichten, müssen wir nur die sortierte Liste der Paare einmal durchlaufen, das geht also in O(`total ). Das Berechnen der Länge (O(`total )) und Erzeugen des Arrays LENGTH[] (das lmax Elemente besitzt) geht in O(`total + `max ) ⊆ O(`total ). Satz 1.3 Algorithmus 1.3 sortiert die Liste korrekt in O(`total + m) Zeit. Beweis: Die Korrektheit ist klar, folgt wie in Algorithmus 1.2. Zum Aufwand: O(`total ) + O(`total + m) + O(`total ) + Teile 1,2 Paare generieren Paare sortieren NONEMPTY einrichten O(`total ) LENGTH einrichten Teil 3: Sei n` die Anzahl der Strings, deren Länge `i größer gleich ` ist. Sei m` die Anzahl der verschiedenen Symbole, die an der Stelle ` auftreten. m` ist dann auch gleichzeitig die Länge von NONEMPTY[`]. Ein Durchlauf der WHILE-Schleife 3.2 hat dann als Aufwand O(n` ) (weil sich in jeder Iteration n` Elemente in Q befinden). Ein Durchlauf der FOR-Schleife 3.3 hat einen Aufwand von O(m` ) (weil wir genau m` Buckets verbinden müssen). `max Der Aufwand des Pseudocodes ist dann insgesamt: O ∑ (m` + n` ) . `=1 `max O ! ∑ (m` + n` ) `=1 = O `max `max `=1 `=1 ∑ m` + ∑ n` ! ⊆ O(`total ) + O(`total ) ⊆ O(`total ) 1.3 Literaturhinweise Die Darstellung von Bucketsort folgt [HU79]. Varianten von Bucketsort (Counting Sort, Radix Sort) und eine average vase Analyse werden in [CLRS01] behandelt. 14 KAPITEL 1. BUCKETSORT Kapitel 2 Bäume und Priority Queues 2.1 Bäume Bisher haben wir als dynamische Datenstrukturen Listen kennengelernt. Da der Zugriff in Listen in der Regel nur sequentiell erfolgen kann, ergibt sich für das Einfügen bzw. Suchen in einer (sortierten) Liste bei Länge n ein linearer Aufwand. Das heißt: O(n) im worst case und O(n/2) im average case. Dies ist für viele Anwendungen, in denen ein sich dynamisch ändernder Datenbestand verwaltet werden muss, zu langsam (z.B. Verwaltung von Identifiern in einem Programm durch den Compiler, Autorenkatalog einer Bibliothek, Konten einer Bank, usw.). Bessere Methoden bietet unter anderem die Datenstruktur der Bäume, die in diesem Kapitel erläutert wird. 2.1.1 Grundbegriffe Gerichtete Bäume (kurz Bäume) kann man auf zwei Arten erklären. Eine graphentheoretische Definition 1 wurde bereits in der Coma I im Zusammenhang mit Graphen behandelt. Etwas abstrakter ist die rekursive Definition, die in der Coma I in Zusammenhang mit der Rekursion erläutert wurde. Sie wird hier noch einmal erklärt und in Abbildung 2.1 visualisiert: 1 Ein gerichteter Baum ist ein Digraph T = (V, E) mit folgenden Eigenschaften: – Es gibt genau einen Knoten r, in dem keine Kante endet (die Wurzel von T ). – Zu jedem Knoten i 6= r gibt es genau einen Weg von der Wurzel r zu i. Dies bedeutet, dass keine zwei Wege in den gleichen Knoten einmünden. Der Graph kann sich ausgehend von der Wurzel also nur verzweigen. Daher kommt auch der Name Baum. 15 16 KAPITEL 2. BÄUME UND PRIORITY QUEUES Ein Baum T • ist entweder leer • oder er entsteht aus endlich vielen, voneinander verschiedenen Bäumen T1 , . . . , Tn mit Wurzeln w1 , . . . , wn , die in T als Teilbäume unter der Wurzel w von T (einem neuen Knoten) hängen. w1 wn A A T1 A Tn A ... A A A A A A w r @ @ =⇒ w1 ... A T1 A A A A @ @ wn A Tn A A A A Abbildung 2.1: Baum, rekursiv aufgebaut Beispiele für die Verwendung von Bäumen sind: • Darstellung von Hierarchien • Auswertung arithmetischer Ausdrücke z.B.: ((a + b) ∗ (c + d))/e + f /g (siehe Abb. 2.6, Seite 24) • Rekursionsbaum Im Zusammenhang mit Bäumen ist die folgenden Terminologie üblich: Blätter, innere Knoten, Wurzel, Kinder / Söhne / Brüder, Vater / Eltern, Nachfolger, Vorgänger und Teilbäume. Ein Knoten v kann einen Vater und Söhne haben. Die Söhne eines Vaters sind Brüder. Hat ein Knoten keinen Vater, ist er die Wurzel des Baumes. Hat er keine Söhne, ist er ein Blatt. Wenn ein Knoten verschieden von der Wurzel ist und mindestens einen Sohn hat, ist er ein innerer Knoten. Eine besondere Rolle spielen die binären Bäume. Sie sind entweder leer oder bestehen aus der Wurzel und einem linken und einem rechten binärem Baum (den Teilbäumen). Jeder Knoten hat maximal zwei Söhne, man spricht vom linken und vom rechten Sohn. In den folgenden Abschnitten werden wir ausschließlich binäre Bäume behandeln und deshalb das Wort Baum in der Bedeutung binärer Baum verwenden. Bekannte Beispiele binärer Bäume sind der Stammbaum mit Vater, Mutter und einer Person als deren Nachfolger (!) oder die Aufzeichnung eines Tennisturniers, in der jedes Spiel durch einen Knoten mit dem Namen des Gewinners charakterisiert ist und die beiden vorausgehenden Spiele als dessen Nachfolger aufgeführt sind. Die rekursive Struktur von Bäumen ist von großer Bedeutung für viele Algorithmen auf Bäümen. Auch viele charakteristische Größen von Bäumen lassen sich rekursiv beschreiben oder definieren. 17 2.1. BÄUME Ein Beispiel dafür ist die Höhe von Bäumen. Die Höhe gibt den längsten Weg von der Wurzel bis zum Blatt gemessen in Anzahl der Kanten an. Sie ergibt sich wie folgt: h(T ) = n −1 falls T leer max{h(T1 ), h(T2 )} + 1 sonst (2.1) Besteht T beispielsweise nur aus einem Knoten, ergibt sich aus Gleichung (2.1) die Höhe von T zu h(T ) = max{−1, −1} + 1 = 0. 2.1.2 Implementation von binären Bäumen Im Folgenden wird gezeigt, wie sich binäre Bäume als abstrakte Datenstruktur implementieren lassen. Ein Baum besteht aus Knoten und Kanten zwischen den Knoten. Die Knoten sind hier Objekte der inneren Klasse BinTreeNode. Für die Kanten nutzt man die Zeigereigenschaft von Referenzobjekten. So kennt ein BinTreeNode das Objekt, das im Knoten steht, seinen linken und seinen rechten Sohn und in manchen Implementationen auch seinen Vater. Das wird in Abbildung 2.2 deutlich. Zusätzlich sind get und set Methoden sinnvoll sowie Methoden, die testen, ob der linke bzw. rechte Sohn vorhanden sind. class BinTreeNode { Object BinTreeNode BinTreeNode data; lson; rson; // saved object // left son // right son // sometimes also usefull BinTreeNode parent; // parent ... // constructors, get methods, // set methods ... } Objekt r Ref. auf linken Sohn r A AAU Ref. auf rechten Sohn Abbildung 2.2: Struktur eines Knotens Wie in Abb. 2.3 dargestellt, ist ein Baum eine Verzeigerung“ von Knoten. Jeder BinTreeNode zeigt ” auf seine Söhne und, wie oben schon erwähnt, in manchen Implementationen auch auf seinen Vater. 18 KAPITEL 2. BÄUME UND PRIORITY QUEUES Es gibt einen BinTreeNode, hier root“ genannt, dessen rechter (oder linker) Sohn immer auf die ” eigentliche Wurzel des Baumes zeigt. Zusätzlich gibt es eine Referenz curr“ (lies: karr), die auf ” einen beliebigen Knoten im Baum zeigt und die auf jeden Knoten umgesetzt werden kann. root qH H HH j Objekt q q Q Q q Q + Q s Q Objekt q q Objekt q @ @ R @ Objekt q q @ R @ q @ @ R @ Objekt q @ curr q @ ... @ R @ ... Abbildung 2.3: Baum, dargestellt als verkettete Struktur class BinTree { BinTreeNode dummy; BinTreeNode curr; // dummy node whose left son is the root // points at the current node ... } Das folgende Programm 2.1 stellt ein Beispiel einer abstrakten Klasse dar, von der binäre Bäume abgeleitet werden können. Einige Methoden werden im Folgenden genauer erklärt. Programm 2.1 BinTree /** * abstract base class for all sorts of binary trees * * @author N.N. */ abstract class BinTree { /** * class for tree nodes */ protected class BinTreeNode { 19 2.1. BÄUME public BinTreeNode() { } // default constructor public BinTreeNode(Object obj) { // init constructor } public boolean isLeaf() { } // is node a leaf in tree? public boolean isRoot() { } // is node root of tree? public boolean isLeftChild() { } // is node left child // of parent? public BinTreeNode getLeftChild() { } // get left child public BinTreeNode getRightChild() { // get right child } public BinTreeNode getParent() { } public String toString() { } } // get parent // conversion to string // class BinTreeNode /*** data ******************************************************/ /*** constructors **********************************************/ // default constructor, initializes empty tree public BinTree() { } /*** get methods ***********************************************/ public boolean isEmpty() { } // is tree empty? 20 KAPITEL 2. BÄUME UND PRIORITY QUEUES // root node of tree // -> what should be returned if tree is empty?? protected BinTreeNode _getRoot() { } // current number of tree nodes public int getSize() { } // height of tree public int getHeight() { } /*** set methods ***********************************************/ // switch debugging mode public static void setCheck(boolean mode) { } /*** methods for current node **********************************/ // reset current node to first node in inorder sequence public void reset() { } // does current node stand at end of inorder sequence? public boolean isAtEnd() { } // reset current node to successor in inorder sequence public void increment() { } // object referenced by current node public Object currentData() { } // ist current node a leaf? public boolean isLeaf() { } 21 2.1. BÄUME /*** conversion methods ****************************************/ // convert tree to string // use getClass() somewhere so that class name of "this" shows public String toString() { } /*** debugging methods *****************************************/ // check consistency of links in entire tree protected boolean _checkLinks() { } } Es gibt viele Methoden, die man an oder mit Bäumen durchführen kann. Dazu gehören beispielsweise Methoden zum Einfügen und Löschen von Knoten, zum Durchlaufen des Baumes (vgl. Abschnitt 2.1.3 usw. Wir wollen uns eine mögliche Methode zum Berechnen der Höhe eines Baumes genauer anschauen. Diese benutzt die Gleichung 2.1 zur Berechnung der Höhe und nutzt die rekursive Struktur von Bäumen. Programm 2.2 getHeight() int getHeight() { if (isEmpty()){ // empty tree return -1; } else { int lheight = _getRoot().getLeftSon().getHeight(); int rheight = _getRoot().getRightSon().getHeight(); return Math.max(rheight,lheight)+1; } } Implementation im Array Bäume können auch mit Hilfe von Arrays implementiert werden. Hierbei handelt es sich zwar nicht um eine dynamische Datenstruktur, diese Umsetzung ist allerdings für manche Programmiersprachen (z.B. FORTRAN) erforderlich. Die Idee hierbei ist, die Indizes als Zeiger auf die Söhne zu nutzen. Das lässt sich explizit (durch Abspeicherung) oder implizit (durch Berechnung) lösen. Bei der expliziten Variante sehen die Knoten so aus: class ArrayBinTreeNode { Object data; int lson; 22 KAPITEL 2. BÄUME UND PRIORITY QUEUES int rson; } Der Baum wird dann, wie auch in Abbildung 2.4 veranschaulicht, als Array umgesetzt: ArrayBinTreeNode[] tree = new ArrayBinTreeNode[n]; 0 1 i s ... n−2 n−1 j ... ? Objekt i ... j Abbildung 2.4: Baum als Array Dazu gehören natürlich noch die oben schon dargestellten Zugriffsfunktionen. Die Höhe wird ebenfalls auf die schon erklärte Weise rekursiv berechnet. Bei der impliziten Variante werden die beiden Söhne nicht im Knoten gespeichert, sondern in getMethoden berechnet. Die Indizes der Söhne des Knoten i ergeben sich bei binären Bäumen immer zu 2i + 1 für den linken Sohn und 2i + 2 für den rechten Sohn. Der Nachteil an einer Implementation mit Arrays ist leider, dass man bei nicht vollen Bäumen im Vergleich zur üblichen Implementation mehr Speicherplatz benötigt. 2.1.3 Traversierung von Bäumen Mit Traversierung eines Baumes bezeichnet man den Durchlauf von Knoten zu Knoten, um in jedem Knoten etwas zu tun. In den Knoten sind Daten, ähnlich wie in einer Liste, und um mit diesen arbeiten zu können, müssen sie nacheinander erreicht werden. Jedoch ist die Reihenfolge des Durchlaufens eines Baumes nicht mehr eindeutig wie bei einer Liste. Standardmäßig benutzt man die folgenden drei Traversierungen: WLR: Der Preorder-Durchlauf. Hier wird zuerst die Wurzel betrachtet, dann der linke Teilbaum mit derselben Regel und dann der rechte Teilbaum wieder mit der selben Regel. LWR: Der Inorder-Durchlauf. Hier wird zuerst der linke Teilbaum, dann die Wurzel und dann der rechte Teilbaum besucht, wobei die Teilbäume wieder mit derselben Regel durchlaufen werden. LRW: Der Post-Durchlauf. Die Wurzel wird erst erreicht, nachdem zuerst der linke und dann der rechte Teilbaum jeweils mit derselben Regel durchlaufen wurden. Die Kürzel WLR, LWR und LRW zeigen vereinfacht jeweils die Reihenfolge des Durchlaufens an. Die Vorsilben Pre-, In- und Post- beziehen sich jeweils auf die Rolle der Wurzel. 23 2.1. BÄUME A B D C E F Abbildung 2.5: Beispielbaum für die Traversierung Beispiel 2.1 Dieses Beispiel zeigt die drei Traversierungsmöglichkeiten für den Baum in Abbildung 2.5. WLR: A, B, D, E, C, F LWR: D, B, E, A, C, F LRW: D, E, B, F, C, A Ist es einfach nur wichtig, unabhängig von der Reihenfolge alle Knoten zu erreichen, spielt es keine Rolle, welche Traversierung gewählt wird. Allerdings gibt es verschiedene Anwendungen, die jeweils unterschiedliche Reihenfolgen benutzen. Beim Aufrufbaum oder beim Rekursionsbaum beispielsweise, die in Coma I behandelt wurden, werden die Methoden in Postorder Reihenfolge abgearbeitet. Im folgenden Beispiel wird verdeutlicht, welchen Einfluss die verschiedenen Reihenfolgen auf arithmetische Ausdrücke haben. Beispiel 2.2 Der arithmetische Ausdruck ((a + b) ∗ (c + d))/e + f /g wird vom Compiler in einen Baum, wie in Abb. 2.6, umgewandelt. In diesem Baum stehen die Identifier in den Blättern. In den inneren Knoten und der Wurzel stehen Operatoren. Diese verknüpfen jeweils ihren linken Teilbaum als arithmetischen Ausdruck mit dem Ausdruck ihres rechten Teilbaums. Durchläuft man den Baum in Inorder, ergibt sich der arithmetische Ausdruck in Infix-Notation: ((a + b) ∗ (c + d))/e + f /g Durchläuft man den Baum aber in Postorder, erhält man den Ausdruck in Postfix-Notation beziehungsweise umgekehrter polnischer Notation (UPN): ab + cd + ∗e/ f g/+ Dieser wird dann vom Computer, wie in Coma I behandelt, mit Hilfe eines Stacks berechnet. Im Gegensatz zur Infix-Notation ist der Baum aus der Postfix-Notation arithmetischer Ausdrücke ohne Hilfe von Klammern (re)konstruierbar. Indem man den Ausdruck in Postfix-Notation von hinten durchläuft, kann man den Baum über die Postorder-Reihenfolge von hinten nach vorne (wieder) aufbauen. 24 KAPITEL 2. BÄUME UND PRIORITY QUEUES + / / e ∗ + a f g + b c d Abbildung 2.6: Ein arithmetischer Ausdruck als Baum dargestellt Implementation Um einen Baum in den verschiedenen Reihenfolgen zu durchlaufen, kann man sich in den JavaMethoden die rekursive Struktur der Bäume nützlich machen. Die Umsetzung zeigt die folgenden Methoden, die sinnvollerweise zur Klasse BinTree gehören. Programm 2.3 Traversierung eines Baumes void preOrderTraversal() { if (isEmpty()) { return; } // work on root getLeftSon().preOrderTraversal(); getRightSon().preOrderTraversal(); } void inOrderTraversal(){ if (isEmpty()) { return; } getLeftSon().inOrderTraversal(); // work on root getRightSon().inOrderTraversal(); } void postOrderTraversal() { 2.2. PRIORITY QUEUES 25 if (isEmpty()) { return; } getLeftSon().postOrderTraversal(); getRightSon().postOrderTraversal(); // work on root } } Neben den rekursiven Methoden gibt es auch die Möglichkeit den Baum iterativ zu durchlaufen. Exemplarisch wird hier nur die Inorder Traversierung angesprochen. Die Umsetzung wird in der Übung behandelt. Zur iterativen Traversierung werden drei Methoden benötigt: 1. public void reset() 2. public void increment() 3. public boolean isAtEnd() Die Methode reset() sucht sich den am weitesten links stehenden Knoten des Baumes und setzt den curr-Zeiger auf diesen Knoten. Die Methode increment() setzt den curr-Zeiger auf den Nachfolger, also auf den nächsten Knoten entsprechend der Inorder-Reihenfolge. Die Methode isAtEnd() prüft, ob der Inorder-Durchlauf das Ende erreicht hat. Objekte mit solchen Methoden bezeichnet man als Iterator und die Methoden werden dementsprechend Iteratormethoden genannt. 2.2 Priority Queues Bei einer Priority Queue handelt es sich um eine Datenstruktur mit folgenden Kennzeichen: • Sie hat einen homogenen Komponententyp, wobei jede Komponente einen Schlüssel (Wert) besitzt. • Die folgenden Operationen sind möglich: 1. Einfügen einer Komponente 2. Zugriff auf die Komponente mit dem kleinsten Wert 3. Entfernen der Komponente mit dem kleinsten Wert 4. Änderung des Wertes einer Komponente Die Priority Queue wurde schon in Coma I im Zusammenhang mit Heapsort behandelt. Jedoch lag dort die Aufmerksamkeit auf der Komponente mit dem größten Wert, nicht auf der mit dem kleinsten Wert. 26 2.2.1 KAPITEL 2. BÄUME UND PRIORITY QUEUES Mögliche Implementationen einer Priority Queue a) Als sortiertes Array Wenn die Anzahl n der zu speichernden Elemente bekannt ist, können die Elemente in einem Array, Abb. 2.7, gespeichert werden, wobei das kleinste Element in der ersten Komponente des Arrays steht und die übrigen aufwärts sortiert folgen. Damit ist ein sehr schneller Zugriff auf das kleinste Element gewährleistet, jedoch dauern die übrigen Operationen lange, wie in der folgenden Auflistung zu sehen ist. 1. Einfügen: O(n) (binäre Suche + Verschieben) 2. Zugriff: O(1) 3. Entfernen: O(n) 4. Wert ändern: O(n) 0 1 2 3 4 5 6 7 12 18 24 35 44 53 63 72 6 kleinstes Element Abbildung 2.7: Priority Queue als sortiertes Array Eine bessere Variante ist die folgende: b) Als Heap Wie bei Heapsort wird das Array als Baum mit Heap-Eigenschaft aufgefasst. Die Heapeigenschaft ist dann erfüllt, wenn die Wege von der Wurzel zu jedem Blatt jeweils aufsteigend sortiert sind. Zur Herstellung der Heapeigenschaft wird die Methode heapify()“ verwendet. Ihre genauere Funktionsweise ” wurde bereits in Coma I erläutert. 0 12 1 18 35 4 HH H 2 53 63 6 @ @ 3 7 24 @ @ 5 72 44 Abbildung 2.8: Priority Queue als Heap Für die Operationen im Heap ergibt sich dann dieser Aufwand im worst case: 27 2.3. LITERATURHINWEISE 1. Einfügen: O(log n) 2. Zugriff: 3. Entfernen: 4. Wert ändern: O(1) O(log n) O(log n) als Blatt in die letzte Arraykomponente einfügen und nach oben wandern lassen letzte Komp. an die 0-te Stelle tauschen und absinken lassen aufsteigen oder absinken lassen Also sind neben dem sehr schnellen Zugriff auf das kleinste Element auch die anderen Operationen schneller als im sortierten Array. Es gibt aber noch andere Implementationen, die die Operationen noch schneller, allerdings nur amortisiert, schaffen. Dazu gehören zum Beispiel die Fibonacci Heaps. 2.3 Literaturhinweise Bäume und Priority Queues werden in jedem Buch über Datenstrukturen behandelt, vgl. etwa [CLRS01, Knu98, OW02, SS02]. 28 KAPITEL 2. BÄUME UND PRIORITY QUEUES Kapitel 3 Huffman Codes und Datenkompression Das Ziel der Datenkompression ist es, Daten mit weniger Speicherplatz abzuspeichern. Abhängig von den Daten geschieht das verlustfrei oder nicht verlustfrei. Audio-, Video- und Bilddateien werden in der Regel komprimiert, indem Informationen weggelassen werden. Das Prinzip bei MP3-Dateien beispielsweise ist es, all die Informationen wegzulassen, die das menschliche Ohr nicht wahrnehmen kann. Somit stellt der Informationsverlust keinen Qualitätsverlust für die gespeicherte Musik dar. Textdateien möchte man ohne Verlust von Informationen komprimieren. Um zu verstehen, wie das funktioniert, muss man verstehen, wie Texte abgespeichert werden. Der folgende Abschnitt soll Aufschluss darüber geben. 3.1 Codierung Wir betrachten einen Zeichensatz C (z.B. das Alphabet, ein Java Zeichensatz, alle Wörter im Duden) und ein Zeichen c ∈ C. Diese Zeichen werden im Computer mit Codewörtern codiert, und zwar über dem Alphabet {0, 1}. Die Gesamtheit der Codewörter für den Zeichensatz C heißt dann Code für C. Üblicherweise haben die Codewörter aller Zeichen eines Zeichensatzes C die selbe Länge. Ein solcher Code heißt Blockcode. So sind auch der ASCII Code, bei dem alle Codewörter die Länge 8 haben, und der Unicode, bei dem alle Codewörter die Länge 16 haben, Blockcodes. Beispiel 3.1 (Blockcode der Länge 3) Wenn C = {a, b, c, d, e, f , g, } ist, ist a b c d e f g −→ −→ −→ −→ −→ −→ −→ −→ 29 000 001 010 011 100 101 110 111 30 KAPITEL 3. HUFFMAN CODES UND DATENKOMPRESSION ein zugehöriger Blockcode der Länge 3. Es gibt auch Codes, bei denen die Länge der Codewörter der einzelnen Zeichen eines Zeichensatzes C unterschiedlich lang ist, die sogenannten variable length codes. Beispiel 3.2 ( variable length code“) ” Wir betrachten wieder C = {a, b, c, d, e, f , g, }. a b c d e f g −→ −→ −→ −→ −→ −→ −→ −→ 0 1 10 11 100 101 110 111 Eine Textdatei ist nun eine Folge von Zeichen c ∈ C. Um sie abzuspeichern, muss sie verschlüsselt werden. Die Verschlüsselung oder auch Codierung entspricht dem Ersetzen jedes Zeichens durch sein Codewort. Damit die Datei später wieder lesbar ist, müssen die Codewörter wieder in die Zeichen des Zeichensatzes umgewandelt werden. Die Entschlüsselung bzw. Decodierung entspricht dem umgekehrten Prozess. Das Codieren/Decodieren einer Nachricht entspricht also einer bijektiven Abbildung. Beispiel 3.3 (Codierung/Decodierung mit Blockcode) Angenommen, die Datei besteht aus diesen Zeichen: fa gaga gaff fege Wird die Datei mit der Codetabelle von Beispiel 3.1 codiert, ergibt sich diese Bitfolge: f a g a g a g a f f f e g e z}|{ z}|{ z}|{ z}|{ z}|{ z}|{ z}|{ z}|{ z}|{ z}|{ z}|{ z}|{ z}|{ z}|{ z}|{ z}|{ z}|{ 101 000 111 110 000 110 000 111 110 000 101 101 111 101 100 110 100 Die Decodierung ist hier sehr einfach, da wir wissen, dass drei Bits immer einem Zeichen entsprechen. Es entsteht wieder eindeutig unsere Datei: 000 |{z} 111 |{z} 101 |{z} 111 |{z} 100 101 |{z} 000 |{z} 111 |{z} 110 |{z} 000 |{z} 110 |{z} 110 |{z} 000 |{z} 101 |{z} 101 |{z} 100 |{z} 110 |{z} |{z} f a g a g a g a f f f e g e 31 3.1. CODIERUNG Beispiel 3.4 (Codierung / Decodierung mit variable length code) Betrachten wir jetzt diese beiden Dateien: Datei 1: abba Datei 2: ag Mit der Codetabelle von Beispiel 3.2 codiert, sehen sie so aus: Datei 1: (abba) 0110 (a g ) Datei 2: 0110 Doch mit der Decodierung wird es schwer, denn die Dateien sind nicht mehr eindeutig entschlüsselbar. Man sagt, der Code von Beispiel 3.2 ist nicht eindeutig entzifferbar. Ein Code heißt also eindeutig entzifferbar, wenn verschiedene Dateien, codiert mit dem selben Code, auch zu verschiedenen Codierungen führen. Der Grund dafür, dass der Code von Beispiel 3.2 nicht eindeutig entzifferbar ist, liegt in der Beschaffenheit der Codewörter. Das Problem besteht darin, dass es Codewörter gibt, die auch durch Zusammensetzen anderer Codewörter entstehen können. So ist zum Beispiel das Codewort von g, die 110, identisch mit den hintereinander geschriebenen Codewörtern von d und a, die auch 110 ergeben. Es gibt keine Möglichkeit zu unterscheiden, ob dort ein g oder ein d und ein a steht. Eine Lösung für dieses Problem sind präfixfreie Codes. 3.1.1 Präfixcode Ein Code heißt Präfixcode1 , wenn kein Codewort Anfangsstück eines anderen Codewortes ist. Jeder Blockcode ist zum Beispiel ein Präfixcode, weil kein Codewort in einem anderen enthalten sein kann. Aber auch variable length codes können präfixfrei sein. Lemma 3.1 Jeder Präfixcode ist eindeutig entzifferbar. Beweis: Beim Lesen der codierten Datei ist eindeutig klar, wann ein Codewort zu Ende ist. Dann lässt sich eindeutig sagen, welches Zeichen mit diesem Codewort codiert wurde. Die Umkehrrichtung von Lemma 3.1 gilt aber nicht. Nicht jeder eindeutig entzifferbare Code muss ein Präfixcode sein. Das zeigt das folgende Beispiel: 1 Der Name ist etwas verwirrend, denn eigentlich müsste der Code präfixfreier Code heißen. Aber Präfixcode hat sich in der Literatur durchgesetzt. 32 KAPITEL 3. HUFFMAN CODES UND DATENKOMPRESSION Beispiel 3.5 Wir betrachten diesen Code: −→ −→ −→ a b c 1 100000 00 Auch wenn dieser Code nicht präfixfrei ist, kann man codierte Dateien wieder decodieren, indem man die Nullen zählt. Steht hinter einer Eins eine gerade Anzahl von Nullen, handelt es sich um ein a mit entsprechend vielen c’s dahinter. Steht aber hinter einer Eins eine ungerade Anzahl von Nullen, und zwar mindestens fünf, handelt es sich um ein b mit entsprechend vielen c’s dahinter. Dafür ist es aber unter Umständen nötig, sich erst die ganze Datei anzusehen, um zu wissen wie viele Nullen nach den Einsen kommen. Es ist also möglich, den folgenden codierten Text zu entschlüsseln: 1 |{z} 00 |{z} 00 |{z} 1 |{z} 00 100000 |{z} | {z } a c c a c b Im weiteren Verlauf werden wir uns nur noch mit Präfixcodes befassen, da vor allem diese in der Praxis üblich sind. Lemma 3.2 Präfixcodes lassen sich mit binären Bäumen T identifizieren, bei denen die Zeichen c ∈ C in den Blättern stehen und die Wege von der Wurzel bis zum Blatt die Codewörter bilden (eine Kanten nach links entspricht der 0, eine Kante nach rechts entspricht der 1). Beweis: =⇒: Sei C ein Zeichensatz mit einem zugehörigen Präfixcode. Konstruiere einen Baum T , in dem pro Codewort der Weg gegangen wird, der durch die 0 bzw. die 1 vorgegeben ist. Bei der 0 gehe nach links, bei der 1 nach rechts. Da der Code präfixfrei ist, endet man immer in einem Blatt. Es ergibt sich also ein Baum mit den geforderten Eigenschaften. ⇐=: Sei C ein Zeichensatz und T ein Baum mit den Zeichen c ∈ C in seinen Blättern. Betrachte den Weg von der Wurzel zu den Zeichen c als Codewort für jedes einzelne Zeichen, wobei der Weg nach links der 0 und der Weg nach rechts der 1 entspricht. Da die Zeichen in den Blättern des Baumes stehen, ist klar, dass kein Weg zu einem Zeichen in einem Weg zu einem anderen Zeichen enthalten sein kann. Daher kann kein Codewort Anfangsstück eines anderen Codewortes sein. Also handelt es sich bei dem Code um einen Präfixcode. Beispiel 3.6 Der Baum T in Abb. 3.1 entspricht diesem Präfixcode: a b c d e −→ −→ −→ −→ −→ 00 01 100 11 101 33 3.2. DER HUFFMAN ALGORITHMUS 1 0 0 1 a 0 0 b 1 1 c d e Abbildung 3.1: Präfixcode als Baum Kommen wir nun zurück zur Frage der Komprimierung. Der benötigte Speicherplatz für eine Textdatei entspricht immer der Anzahl der Bits bezüglich ihrer Codierung. Betrachten wir noch einmal Beispiel 3.3 fa gaga gaff fege und codieren es mit dem Code von Beispiel 3.1, f a g a g a g a f f f e g e z}|{ z}|{ z}|{ z}|{ z}|{ z}|{ z}|{ z}|{ z}|{ z}|{ z}|{ z}|{ z}|{ z}|{ z}|{ z}|{ z}|{ 101 000 111 110 000 110 000 111 110 000 101 101 111 101 100 110 100 so benötigen wir 17 · 3 = 51 Bits. Codiert man aber das Beispiel mit dem Code von Beispiel 3.2, f a g a g a g a f f f e g e z}|{ z}|{ z}|{ z}|{ z}|{ z}|{ z}|{ z}|{ z}|{ z}|{ z}|{ z}|{ z}|{ z}|{ z}|{ z}|{ z}|{ 101 0 111 110 0 110 0 111 110 0 101 101 111 101 100 110 100 benötigt man 43 Bits. Verschiedene Codierungen nehmen also unterschiedlich viel Speicherplatz in Anspruch. Bei der Komprimierung wird also (dateiabhängig) ein Code gefunden, der den Speicherplatz reduziert. Dieser Code muss natürlich ein Präfixcode sein, damit die Datei wieder einfach decodierbar ist. 3.2 Der Huffman Algorithmus Textdateien werden standardmäßig im Blockcode codiert und gespeichert. Der Huffman Algorithmus konstruiert einen Präfixcode variabler Länge, so dass die Anzahl der benötigten Bits kleiner wird. Der Code wird so konstruiert, dass Zeichen, die sehr häufig auftreten, kurze Codewörter bekommen, und weniger häufige Zeichen längere Codewörter. So wird, abhängig von der Datei, der benötigte Speicherplatz verringert. Der Huffman Algorithmus ist sogar so gut, das der entstehende Präfixcode optimal bezüglich des benötigten Speicherplatzes ist. Es sind Speicherplatzeinsparungen von 20% bis 90% üblich, je nach Beschaffenheit der Datei. 34 KAPITEL 3. HUFFMAN CODES UND DATENKOMPRESSION Um den optimalen Präfixcode zu konstruieren, muss die Datei erst einmal gelesen werden, wobei die Häufigkeiten f (c) für alle Zeichen c ∈ C ermittelt werden. Ist die Häufigkeit der Zeichen bekannt, und wird die Datei mit dem Präfixcode T (als binärer Baum aufgefasst) codiert, so ergibt sich der benötigte Speicherplatz der Dateiwie folgt: B(T ) = ∑ f (c) · hT (c) (3.1) c∈C Dabei gibt hT (c) die Höhe des Zeichens c im Baum T an, also die Anzahl der Kanten des Baumes von der Wurzel bis zu dem Blatt, in der das Zeichen c steht, an. Dies entspricht nach Lemma 3.1 der Länge des Codewortes für das Zeichen c. Beispiel 3.7 zeigt, wie der Speicherplatz bei unterschiedlichen Codierungen variieren kann. Zeichen c a b c d e f f (c) in 1000 45 13 12 16 9 5 Code 1 000 001 010 011 100 101 Code 2 0 101 100 111 1101 1100 Code 1 0 0 a 0 1 1 0 b c Code 2 1 0 a 0 1 d 0 e 1 1 f 0 c 0 1 1 0 1 b d 0 1 f e Abbildung 3.2: Die Bäume der beiden Codes Dann ergibt sich der benötigte Speicherplatz für Code 1 zu: B(T1 ) = (45 + 13 + 12 + 16 + 9 + 5) · 1000 · 3 = 300000 Der benötigte Speicherplatz für Code 2 beträgt hingegen: B(T2 ) = (45 · 1 + 13 · 3 + 12 · 3 + 16 · 3 + 9 · 4 + 5 · 4) · 1000 = 224000 35 3.2. DER HUFFMAN ALGORITHMUS Wir wollen wir uns nun Huffman Algorithmus ansehen, der für jede Textdatei einen optimalen Präfixcode ermittelt. Algorithmus 3.1 (Huffman Algorithmus) 1. Fasse jedes Zeichen c ∈ C als einelementigen Baum auf und füge es in eine Priority Queue Q ein, wobei die Häufigkeit f (c) als Schlüsselwert dient. 2. Solange Q mehr als einen Baum enthält: • Wähle die beiden Bäume T1 und T2 mit den kleinsten Häufigkeiten (muss nicht eindeutig sein). • Entferne sie aus Q. • Konstruiere einen neuen Baum aus den T1 , T2 als Teilbäume unter einer neuen Wurzel und gebe ihm die Häufigkeit f (T1 ) + f (T2 ). • Füge diesen Baum in Q ein. 3. Gebe (den einzig übrig gebliebenen Baum) T in Q zurück. Dieser Baum (der so genannte Huffman Baum oder Huffman Code) ist ein optimaler Präfixcode. Bei der Codierung einer Datei gemäß T muss neben der codierten Datei natürlich der Code z.B. als Baum mit abgespeichert werden, denn er ist zur Decodierung notwendig. Der Speicherplatz dafür ist aber bei genügend großen Dateien im Vergleich zum eingesparten Speicherplatz so gering, dass er vernachlässigt werden kann. Das folgende Beispiel zeigt, wie der Huffman Algorithmus funktioniert: Beispiel 3.8 (Konstruktion eines Huffman Baumes) Zeichen c f (c) a 45 b 13 c 12 d 16 e 9 f 5 Die Zeichen werden alle als einknotige Bäume mit f (c) als Schlüssel in die Priority Queue Q eingefügt: 45 Q: a 13 b 12 c 16 d 9 e 5 f Die beiden Bäume mit den kleinsten Schlüsseln werden aus Q entfernt, zu einem neuen Baum zusammengefügt und wieder in Q eingefügt: 36 KAPITEL 3. HUFFMAN CODES UND DATENKOMPRESSION 45 Q: a 13 12 b 16 14 d c f e Das wird fortgesetzt, bis nur noch ein Baum in Q ist: 45 Q: 25 14 d b c 45 Q: 16 a f e 25 30 a b d c f e 45 Q: 55 a b c d e f 37 3.2. DER HUFFMAN ALGORITHMUS 45 Q: a b d c f e Am Ende wird der fertige Huffman Baum von Q entfernt: 45 a b c d e f Die Laufzeit des Huffman Algorithmus Ist die Priority Queue als Heap implementiert (siehe Abschnitt 2.2.1 auf Seite 26) und hat man n Zeichen, so ergibt sich folgende Laufzeit für den Huffman Algorithmus: 1. 2. alle Zeichen einfügen in Q: n − 1 Phasen: - die beiden Kleinsten aus Q entfernen: - neuen Baum bauen: - wieder einfügen in Q: 3. Baum zurück geben: insgesamt: O(3n) n − 1 mal: O(2 log n) O(1) O(log n) O(1) O(n log n) 38 KAPITEL 3. HUFFMAN CODES UND DATENKOMPRESSION Die Optimalität des Huffman Codes Ein Präfixcode T heißt optimal für einen Zeichensatz C und Häufigkeiten f (c), c ∈ C, wenn B(T ) ≤ B(T 0 ), (3.2) für jeden anderen Präfixcode T 0 (zu C und denselben Häufigkeiten f (c)). Lemma 3.3 Sei C eine Menge von Zeichen mit den Häufigkeiten f (c). Seien x, y die Zeichen mit den niedrigsten Häufigkeiten. Dann gibt es einen optimalen Präfixcode T , in dem x und y die größte Höhe und gemeinsamen Vater haben. Die beiden Codewörter für x und y haben dann dieselbe Länge und unterscheiden sich nur im letzten Bit. Beweis: Die Idee des Beweises ist es, einen Baum T zu betrachten, der einen optimalen Präfixcode repräsentiert und ihn gegebenenfalls so zu modifizieren, dass er optimal bleibt und die beiden Zeichen x und y in den Blättern mit der größten Höhe stehen und denselben Vater haben. Ist das möglich, sind die beiden gewünschten Eigenschaften im optimalen Präfixcode erfüllt. Abbildung 3.3 skizziert diese Vorgehensweise. T s s y - HH Hs s @ x @s @ s @s a b s y T 0s T 00s - HH Hs s @ a @s @ s @s x b s b HH Hs s @ a @s @ s @s x y Abbildung 3.3: Modifizieren des Baumes T für den Beweis von Lemma 3.3 Zunächst überlegen wir uns, dass es einen optimalen Präfixcode gibt. Dies folgt daraus, dass der Wert B(T ) immer ganzzahlig und positiv ist. In dieser Menge von Zahlen existiert ein kleinster Wert B(T ), und der zugehörige Baum T ist ein optimaler Präfixcode. Sei also nun T ein optimaler Präfixcode. Sei a ein Blatt in T mit größter Höhe hT (a). Dann hat a aufgrund der Optimalität von T einen Bruder b, denn hätte a keinen Bruder, könnte man a einen Level höher hängen“ und hätte den Wert B(T ) verbessert, was im Widerspruch zur Optimalität von ” T stünde. Betrachte nun x und y, die beiden Zeichen mit den geringsten Häufigkeiten. Vertauscht man a und x im Baum T , so entsteht der Baum T 0 . Für den Speicherplatz der beiden Bäume gilt dann: B(T ) − B(T 0 ) = ∑ f (c) · hT (c) − ∑ f (c) · hT (c) 0 c∈C = c∈C f (x) · hT (x) + f (a) · hT (a) − f (x) · hT 0 (x) − f (a) · hT 0 (a) | {z } | {z } =hT (a) = ( f (a) − f (x)) · (hT (a) − hT (x)) | {z } | {z } ≥0 ≥ 0 =⇒ B(T ) ≥ B(T 0 ) ≥0 =hT (x) 39 3.2. DER HUFFMAN ALGORITHMUS Da T aber optimal ist, kann es keinen Präfixcode geben, der weniger Speicherplatz benötigt. Also gilt: B(T ) = B(T 0 ) Der Präfixcode T 0 ist also auch optimal. Entsteht T 00 , indem man in T 0 b und y vertauscht, lässt sich auf analoge Weise zeigen, dass auch T 00 optimal ist. T 0 erfüllt dann die Aussagen des Lemmas. Lemma 3.4 (Prinzip der optimalen Substruktur) Sei C ein Zeichensatz mit den Zeichen c ∈ C und den Häufigkeiten f (c), und seien x und y die beiden Zeichen mit den geringsten Häufigkeiten. Sei T ein Präfixcode für C und f (c), in dem x und y einen gemeinsamen Vater z haben. Sei T 0 der Baum, der aus T entsteht, indem x und y wegfallen, statt dessen aber z als neues Zeichen mit der Häufigkeit f (z) = f (x) + f (y) hinzukommt. T 0 ist dann ein Präfixcode für C 0 := C \ {x, y} ∪ {z}. Unter diesen Voraussetzungen gilt: T ist optimal für C ⇐⇒ T 0 ist optimal für C 0 Beweis: Da wir nur die Rückrichtung benötigen, wollen wir nur zeigen: T 0 ist optimal für C 0 =⇒ T ist optimal für C Für T und T 0 gilt: B(T ) − B(T 0 ) = f (x)hT (x) + f (y)hT (y) − f (z) hT 0 (z) | {z } =:α (hT (x) = hT (y) = α + 1, da x und y Söhne von z) = f (x)(α + 1) + f (y)(α + 1) − ( f (x) + f (y))α = ( f (x) + f (y))(α + 1) − ( f (x) + f (y))α = ( f (x) + f (y))(α + 1 − α) =⇒ 0 B(T ) − B(T ) = =⇒ f (x) + f (y) B(T ) = B(T 0 ) + f (x) + f (y) (3.3) Sei T 0 optimal, T aber nicht. Betrachte den optimalen Baum T ∗ für C, d.h. B(T ∗ ) < B(T ). Nach Lemma 3.3, darf angenommen werden, dass x und y an der tiefsten Stelle im Baum T ∗ stehen und einen gemeinsamen Vater w haben. Betrachte nun den Baum T ∗ 0 , der entsteht, wenn x und y im Baum T ∗ wegfallen und w als neues Zeichen mit der Häufigkeit f (w) = f (x) + f (y) hinzukommt. Dann ergibt sich nach Gleichung 3.3 für den Speicherbedarf: B(T ∗ ) = B(T ∗ 0 ) + f (x) + f (y) 40 KAPITEL 3. HUFFMAN CODES UND DATENKOMPRESSION Da T 0 optimal für C 0 ist und T ∗ 0 ein Präfixcode für C 0 und dieselben Häufigkeiten ist, folgt B(T 0 ) ≤ B(T ∗ 0 ). Damit gilt: 3.3 B(T ∗ ) < B(T ) = B(T 0 ) + f (x) + f (y) ≤ B(T ∗ 0 ) + f (x) + f (y) = B(T ∗ ) Das ergibt einen Widerspruch. Also muss auch T optimal sein. Satz 3.1 (Optimalität) Der Huffman Code T ist optimal unter allen Präfixcodes, das heißt: B(T ) := ∑ f (c) · hT (c) ≤ B(T 0 ) c∈C für alle Präfixcodes T 0 . Beweis: (Induktion nach der Anzahl der Zeichen n = |C|) Induktionsanfang: Für n = 2 liefert der Algorithmus eine Codierung, bei denen die Codewörter nur aus einer 0 bzw. 1 bestehen. Das entspricht dem optimalen Speicherplatz. Induktionsschritt von n − 1 auf n: Sei n = |C|. Der Huffman Algorithmus ermittelt die Zeichen x und y mit den kleinsten Häufigkeiten und ersetzt sie durch einen neuen Baum, mit dem Zeichen z in der Wurzel und den beiden Söhnen x und y, wobei f (z) = f (x) + f (y) ist. Danach wird mit dem Zeichensatz C 0 = C\{x, y} ∪ {z} weitergearbeitet. Nach Induktionsvoraussetzung liefert der Huffman Algorithmus einen optimalen Baum T 0 für C 0 . Ersetzt man z wieder entsprechend Lemma 3.4, so erhält man einen optimalen Präfixcode T für C. Da der Huffman Algorithmus gerade auf diese Weise den Baum T konstruiert, ist der Huffman Code optimal unter allen Präfixcodes. Bemerkungen zu Huffman Codes Hier soll nochmal auf besondere Eigenschaften des Huffman Algorithmus und auf Alternativen dazu hingewiesen werden. Der Huffman Algorithmus entspricht einer zeichenweisen Codierung. Es wird jedem Zeichen c ∈ C ein eigenes Codewort zugewiesen. Wie wir später noch sehen werden, gibt es auch Algorithmen, die Codewörter für Teilstrings konstruieren. Auch beim Huffman Algorithmus wäre das möglich, indem man Strings der Länge k als Zeichen betrachtet. Beim Huffman Code handelt es sich um einen statischen Code. Das bedeutet, dass die ganze Nachricht vorab gelesen und analysiert wird, um die Häufigkeiten der einzelnen Zeichen zu ermitteln und 3.3. WEITERE DATENKOMPRESSIONSVERFAHREN 41 dementsprechend feste Codewörter zu konstruieren. Danach muss die Datei noch einmal gelesen werden, um sie mit den entstandenen Codewörtern zu codieren. Das Gegenteil hierzu wären die dynamischen bzw. adaptiven Codes. Bei diesen Codierungen werden die Codewörter während des Lesens des Textfiles erstellt und im Laufe des Lesens geändert, wenn es mehr Informationen über die Nachricht gibt. Die Datei wird während des Lesens codiert, und zwar mit sich ändernden Codewörtern. Diese Vorgehensweise erspart ein zweimaliges Lesen. Das dynamische Codieren wird auch Komprimierung on the fly“ genannt. ” Die Datenkompression mittels des Huffman Algorithmus ist im Gegensatz zur Kompression von Audio-, Video- und Graphikdateien verlustfrei. Die ursprüngliche Nachricht kann also ohne Verlust von Informationen wieder rekonstruiert werden. 3.3 3.3.1 Weitere Datenkompressionsverfahren Der adaptive Huffmancode Im Gegensatz zum statischen Huffmancode, wird bei diesem Verfahren ein dynamischer Code erstellt. Im Laufe des Lesens werden zu jedem Zeitpunkt, abhängig von der schon gelesenen Nachricht, die wahrscheinlichen Häufigkeiten abgeschätzt und auf dieser Grundlage die Codewörter erstellt. Der Präfixcode wird also immer so verändert, dass er für die aktuellen Abschätzungen optimal ist. Beim Verschlüsseln wird die Beschaffenheit der Quelldatei gelernt“. Beim Entschlüsseln muss ” der Huffman Baum kontinuierlich aktualisiert werden, damit die Zeichen wieder mit den richtigen Codewörtern übersetzt werden können. Wie oben schon beschrieben liegt der Vorteil darin, dass die Quelldatei nur einmal gelesen werden muss. Abhängig von der Datei, kann diese Vorgehensweise bessere, aber auch schlechtere Leistung bringen. Der Algorithmus bildet die Basis für den Unixbefehl compact, der in der Regel eine Kompressionsrate von 30-40% erbringt. 3.3.2 Der run length code“ ” Dieses Verfahren wird zum Komprimieren von Bildern verwendet. Der Computer speichert Bilder, indem er sie in viele Bildpunkte (Pixel) aufteilt und sich die genaue Farbe eines Pixels merkt. Die Pixel werden dann in einer bestimmten Reihenfolge (z.B. zeilenweise) abgespeichert. Um das Bild nun zu komprimieren, werden nicht alle Pixel gespeichert, sondern es wird ausgenutzt, dass oft Wiederholung eintritt (z.B. schwarze Fläche). Daher werden nur Pixel mit der Vielfachheit ihres wiederholten Auftretens gespeichert. Damit wird Speicherplatz eingespart. 3.3.3 Der Lempel-Ziv Code Dieser Algorithmus wurde 1977 entwickelt. Er arbeitet verlustfrei und adaptiv. Es ist leider nicht möglich für ihn eine Optimalitätsaussage zu treffen, jedoch ist er empirisch gut. Typisch sind Leistungen, die in der Größenordnung von 50 − 60% liegen. Da er in zip, compress und gzip genutzt wird, soll er hier genauer erklärt werden. 42 KAPITEL 3. HUFFMAN CODES UND DATENKOMPRESSION Der Lempel-Ziv Algorithmus erstellt sog. Ketten“, die Strings wachsender Länge entsprechen. In ” jedem Schritt des Algorithmus wird von der Rest-Nachricht, die noch nicht codiert wurde, der längste Präfixcode ermittelt, der einer bereits definierten Kette α entspricht. Diese wird dann mit dem danach kommenden Zeichen c als String αc in eine Tabelle, dem sog. Wörterbuch“, eingetragen und sie wird ” mit ic codiert, wobei i dem Codewort von α entspricht. Die Kette ic erhält dann ein eigenes Codewort. Die Codewörter haben dieselbe vordefinierte Länge, die dann die Größe der Tabelle und die Länge der längsten Kette bestimmt. Um den Lempel-Ziv Algorithmus besser zu verstehen, folgen hier zur Veranschaulichung zwei Beispiele. Beispiel 3.9 Gegeben sei die Nachricht: aa bbb cccc ddddd eeeeee fffffffgggggggg und der dazugehörige Blockcode: Zeichen a b c d e f g Codewort 000 001 010 011 100 101 110 111 Daraus ergibt sich dann mit dem beschriebenen Verfahren der Lempel-Ziv Code: String Kette Codewortnummer leer 0 0 a 0a 1 a 1 2 b 0b 3 String Kette Codewortnummer eee 13e 14 f 5f 15 f 0f 16 ff 16f 17 bb 3b 4 fff 17f 18 c 0c 6 0 5 g 0g 19 cc 6c 7 gg 19g 20 c 6 8 d 0d 9 dd 9d 10 dd 10 11 e 0e 12 ee 12e 13 ggg 20g 21 Die komprimierte Nachricht sieht dann so aus: (0a)(1 )(0b)(3b)(0 )(0c)(6c)(6 )(0d)(9d)(10 )(0e)(12e)(13e)(5f)(0f)(16f)(17f)(0g)(19g)(20g)(20) Um 21 Codewortnummern abzuspeichern, benötigt man dlog 21e = 5 Bit. Für die Zeichen an sich werden zum Abspeichern die ursprünglichen 3 Bit benötigt. Dann folgt für den Speicherplatz: 21 Zeichen + 1 Zeichen a a 5 Bit 5 Bit + 3 Bit =⇒ =⇒ 168 Bit 5 Bit 173 Bit 43 3.4. ABSCHLIESSENDE BEMERKUNGEN Beispiel 3.10 Das nun folgende Beispiel benutzt den uns bereits bekannten ASCII Code, der eine Länge von 8 Bit hat: fischers fritze fischt frische fische frische fische fischt fischers fritze Der Lempel-Ziv Code sieht dann wie folgt aus String Kette Codewortnummer leer 0 0 String Kette Codewortnummer sc 3c 14 String Kette Codewortnummer isch 23h 27 f 0f 1 ht 5t 15 t 0t 28 i 0i 2 s 0s 3 c 0c 4 0 16 fri 9i 17 sch 14h 18 fi 26i 29 h 0h 5 e f 12f 19 scher 25r 30 s f 8f 31 e 0e 6 is 2s 20 ri 7i 32 r 0r 7 s 3 8 ch 4h 21 fr 1r 9 it 2t 10 z 0z 11 e fr 19r 22 isc 20c 23 e fi 19i 24 e 6 12 fi 1i 13 sche 18e 25 f 16f 26 tz 28z 33 und codiert die Nachricht folgendermaßen: (0f)(0i)(0s)(0c)(0h)(0e)(0r)(3 )(1r)(2t)(0z)(6 )(1i)(3c)(5t)(0 )(9i)(14h)(12f) (2s)(4h)(19r)(20c)(19i)(18e)(16f)(23h)(0t)(26i)(25r)(8f)(7i)(28z)(6) Zum Abspeichern von 33 Codewörtern werden dlog 33e = 6 Bit und zum Abspeichern eines Zeichens mit ASCII Code werden 8 Bit benötigt. Das heißt für den Speicherplatzbedarf: 33 · (6Bit + 8Bit) + 6Bit = 468Bit 3.4 Abschließende Bemerkungen Abschließend soll darauf hingewiesen werden, dass es eine von den Häufigkeiten abhängige untere Schranke für Datenkompression, die Entropie, gibt. Um diese zu erklären benötigen wir die folgenden beiden Begriffe. Zum Einen gibt es den Begriff der normierten Häufigikeit P(c), die die Häufigkeit eines Zeichens c in Abhängigkeit von der Summe der Häufigkeiten aller Zeichen der Quelldatei angibt: P(c) = f (c) ∑ f (u) (3.4) u∈C Wird der Speicherplatz B(T ), der die Gesamtheit der Wortlängen angibt, normiert, ergibt sich die mittlere Wortlänge: A(T ) = ∑ P(c) · h(c) (3.5) c∈C Nun können wir die Entropie H(C) einer Quelldatei C definieren: H(C) = ∑ [P(c) · (− log P(c))] c∈C (3.6) 44 KAPITEL 3. HUFFMAN CODES UND DATENKOMPRESSION Der Satz von Shannon sagt nun, dass die mittlere Wortlänge jeder verlustfreien Komprimierung über der Entropie liegt: A(T ) ≥ H(C) für jede Codierung T von C (3.7) Um nun quantitativ sagen zu können wie gut ein Kompressionsverfahren ist, gibt es den Begriff der Redundanz . Die Redundanz kann als Differenz zwischen der mittleren Wortlänge der codierten Datei und der Entropie der Quelldatei definiert werden: R(T ) = A(T ) − H(C) für eine Codierung T von C (3.8) Beispiel 3.11 In der gegebenen Quelldatei treten die folgenden Zeichen mit den angegebenen normierten Häufigkeiten auf: c∈C P(c) a 0, 45 b 0, 13 c 0, 12 d 0, 16 e 0, 09 f 0, 05 Dann folgt mit Gleichung 3.6, dass die Entropie H(c) = 2, 2199 beträgt. Komprimiert man die Datei mit dem Huffman Code, ergibt sich mit Gleichung 3.5 eine mittlere Wortlänge von A(T ) = 2, 24. Die Redundanz beträgt dann nach Gleichung 3.8 R(T ) = 0.0201. Die Redundanz ist also beim Huffman Code sehr gering. Der Huffman Code ist asymptotisch optimal unter allen Codierungsverfahren. Betrachtet man nämlich den verallgemeinerten Huffman Block Code, der Teilstrings fester Länge k als einzelne Zeichen behandelt, gilt folgender Satz: Satz 3.2 Zu jedem ε > 0 gibt es ein k, so dass für den Huffman Block Code Tk mit den Teilstrings der Länge k gilt: A(Tk ) ≤ H(C) + ε 3.5 Literaturhinweise Der Huffman Algorithmus wird ausführlich [CLRS01] behandelt. Eine ausgezeichnete Übersicht und ach tiefergehende Informationen über Kompressionsverfahren findet man unter http://www.data-compression.com/ Kapitel 4 Suchbäume Die Hintergrundanwendung für Suchbäume ist das Verwalten von dynamischen Daten. Wir möchten die Operationen • Einfügen • Löschen • Suchen schnell ausführen. Suchbäume haben viele Anwendungen, zum Beispiel in Datenbanken oder Dateisystemen auf Festplatten. Wir kennen dafür bisher nur Listen; der Hauptnachteil an Listen ist der rein sequentielle Zugriff, der zu einem Worst Case Aufwand von O(n) für das Suchen führt. Wir beschleunigen die Suche durch die Verwendung von Suchbäumen und erhalten dabei O(log n) statt O(n) als Aufwand. Was ist also ein Suchbaum? Ein Suchbaum hat in jedem Knoten einen Datensatz und die Suchschlüssel sind aufsteigend sortiert bezüglich der Inorder-Traversierung. 7 8 5 2 6 10 9 45 46 KAPITEL 4. SUCHBÄUME Äquivalent dazu ist folgende Definition: Für jeden Knoten v gilt: 1. Alle Knoten im linken Teilbaum zu v haben einen kleineren Schlüssel als v 2. Alle Knoten im rechten Teilbaum zu v haben einen größeren Schlüssel als v 4.1 Basisoperationen in Suchbäumen 4.1.1 Suchen nach Schlüssel k 1. Beginne in der Wurzel w: falls der Schlüssel in w den Wert k hat, gebe w zurück 2. sonst falls der Schlüssel in w > k: suche im linken Teilbaum weiter 3. sonst suche im rechten Teilbaum weiter Der Aufwand, gemessen in der Anzahl der Vergleiche, entspricht der Höhe des zu suchenden Knoten plus eins. Der Worst-Case-Aufwand ist daher h(T ) + 1 = O(h(T )). 4.1.2 Einfügen eines Knoten Das natürlichen“ Einfügen funktioniert folgendermaßen: ” 1. Nach dem neuen Schlüssel suchen, bis man in einem Blatt angelangt ist 2. Dann gemäß Schlüsselwert des Blattes den neuen Schlüssel links oder rechts an das Blatt anhängen Als Beispiel fügen wir die Sequenz 5,7,8,3,2,12,0,6,9 in einen Suchbaum ein und stellen einige Teilschritte in Abbildung 4.1 dar. Der Aufwand zum Einfügen in den Suchbaum T entspricht der Höhe des Blattes, an das angehängt wird, plus eins. Der Worst Case hat also den Aufwand h(T ) + 1 = O(h(T )). Ein Problem beim natürlichen Einfügen besteht darin, dass der Baum zu einer Liste entarten kann und Suchen und Einfügen dann einen Worst-Case von O(n) haben, siehe Abblidung 4.2. 4.1.3 Löschen eines Knoten 1. Suche den zu löschenden Knoten v. 2. Falls v Blatt ist, so lösche v. 3. Falls v nur einen Sohn w hat, so hänge w an den Vater von v und lösche v. 47 4.1. BASISOPERATIONEN IN SUCHBÄUMEN 5 5 5 7 7 3 6 2 0 8 12 9 Abbildung 4.1: Natürliches Einfügen der Sequenz 5,7,8,3,2,12,0,6,9 in einen Suchbaum. 1 2 3 4 Abbildung 4.2: Durch natürliches Einfügen zu einer Liste entarteter Suchbaum. 4. Falls v zwei Söhne hat: 4.1 Suche den Knoten w im linken Teilbaum von v, der am weitesten rechts steht (dieser ist Vorgänger von v bzgl. Inorder). 4.2 Tausche die Daten von v und w. 4.3 Lösche w (w ist Blatt oder hat nur einen Sohn und kann daher mit Schritt 1 oder 2 gelöscht werden). Statt den am weitesten rechts stehenden Knoten im linken Teilbaum zu suchen, kann man natürlich auch den am weitesten links stehenden Knoten im rechten Teilbaum suchen und analog verfahren. Beispiel 4.1 Es soll die 7 aus dem Suchbaum links in Abbildung 4.3 gelöscht werden. Zuerst wird der am weitesten rechts stehende Knoten im linken Teilbaum der 7 gesucht und liefert die 6 (Mitte). Daraufhin werden die 6 und die 7 vertauscht und die 7 gelöscht (rechts). Wie beim Suchen und Einfügen beträgt der Aufwand im Worst Case h(T ) + 1 = O(h(T )). Satz 4.1 Der Aufwand für das Suchen, das natürliche Löschen und das natürliche Einfügen erfordert im Worst Case O(h(T )) Vergleiche. 48 KAPITEL 4. SUCHBÄUME 2 1 12 8 6 10 5 10 5 4 6 7 7 4 2 9 6 12 8 9 4 8 7 2 1 3 3 10 5 12 9 1 3 Abbildung 4.3: Löschen des Knoten 7. Im Extremfall kann h(T ) gleich n − 1 sein, der Aufwand dann also O(n). Deswegen werden wir die Operationen so verbessern, dass h(T ) klein“ bleibt. Die dafür entscheidenden Operationen sind die ” Rotationen, mit denen die Gestalt eines Baumes verändert werden kann um die Höhe klein zu halten. Wir unterscheiden zwischen Links- und Rechtsrotation, die Linksrotation RotLinks(v, w) um die Knoten v und w, und die dazu symmetrische Rechtsrotation RotRechts(v, w). Beide sind in ist in Abbildung 4.4 dargestellt. Man beachten, dass das Resultat in beiden Fällen wieder ein Suchbaum ist, die Teilbäume T1 , T2 , T3 also korrekt umgehängt“ werden. ” v w RotLinks(v,w) w v T1 T3 T2 T3 T1 T2 v w RotRechts(v,w) w T3 T1 T2 v T1 T2 Abbildung 4.4: Linksrotation RotLinks(v,w) T3 4.1. BASISOPERATIONEN IN SUCHBÄUMEN 49 Linksrotation und Rechtsrotation sind in O(1) durchführbar, da nur konstant viele Referenzen geändert werden müssen. Eine Java-Methode für die Linksrotation könnte in etwa wie folgt aussehen: TreeNode rotateLeft (TreeNode v) { TreeNode aux = v.rson; v.rson = aux.lson; aux.lson = v; v = aux; return v; } Diese Anweisungen werden genauer in Abbildung 4.5 erläutert. Die erste Zeile TreeNode aux = v.rson; setzt die Hilfsreferenz aux auf den rechten Sohn des zu rotierenden Knotens v. Die Zeile v.rson = aux.lson; setzt den rechten Sohn für den zu rotierenden Knoten v neu. Danach setzt die Zeile aux.lson = v; den linken Sohn des von aux referenzierten Knotens auf den zu rotierenden Knoten v. Dann wird durch v = aux; v neu gesetzt und schließlich durch return v; v zurückgegeben. Der folgende Satz sagt, dass Rotationen ausreichen, um Suchbäume beliebig zu ändern und daher insbesondere ausreichen, um h(T ) klein zu halten. Satz 4.2 Seien T1 , T2 Suchbäume zur selben Schlüsselmenge. Dann gibt es eine endliche Folge von Rechts- und Linksrotationen, die T1 in T2 transformiert. Beweis: Wir beweisen den Satz durch vollständige Induktion nach der Anzahl der Schlüssel. Abbildung 4.6 illustriert das Prinzip. Für n = 1 ist nichts zu zeigen, denn zwei Bäume mit nur einem Schlüssel und der gleichen Schlüsselmenge sind gleich. 50 KAPITEL 4. SUCHBÄUME v aux v aux w w T1 T1 T3 T2 T2 T3 aux aux w v w v T3 T1 T2 T3 T1 T2 Abbildung 4.5: Umhängen der Referenzen bei der Linksrotation RotLinks(v, w). Für n = 2 gibt es nur zwei verschiedene Suchbäume zur gleichen Schlüsselmenge, die durch genau eine Links- beziehungsweise Rechtsrotation ineinander überführt werden können. n → n + 1 : Sei T1 der Ausgangsbaum, w(T1 ) seine Wurzel und T1` der linke und T1r der rechte Teilbaum von T1 . Sei T2 der Baum, in den T1 durch Rotationen umgewandelt werden soll. Das Wurzel w(T2 ) von T2 ist, da die beiden Bäume die gleiche Schlüsselmenge haben, auch in T1 enthalten, etwa o.B.d.A. im rechten Teilbaum T1r von T1 . Auf die Teilbäume von T1 trifft die Induktionsvoraussetzung zu, und wir können daher T1` mit Rotationen so transformieren, dass der transformierte Baum T̃1` eine linke“ Liste wird (siehe Baum links unten in Abbildung 4.6). Entsprechend können ” wir T1r mit Rotationen so transformieren, dass der transformierte Baum T̃1r eine rechte“ Liste wird. ” O.b.d.A. war w(T2 ) im rechten Teilbaum von T1 enthalten. Wegen der speziellen Listengestalt der Bäume T̃1` und T̃2r können wir durch eine Folge von Linksrotationen um die Wurzel des ganzen Baumes dafür sorgen, dass w(T2 ) die Wurzel wird (siehe Baum rechts unten in Abbildung 4.6). Wegen der Suchbaumeigenschaft haben die Teilbäume T 0` und T 0r des so entstehenden BaumesT 0 mit Wurzel w(T2 ) dieselben Schlüssel wie T2` und T2r . Nach Induktionsvoraussetzung können wir daher mit Rotationen T 0` in T2` und T 0r in T2r überführen. Die Zusammensetzung dieser Folgen von Rotationen (zunächst Teilbäume von T1 zu Listen, dann w(T2 ) an die Wurzel, dann Teilbäume von T 0 zu T2` und T2r ) liefert dann die gewünschte Folge von Rotationen, die T1 in T2 überführt. 51 4.2. LITERATURHINWEISE 5 6 3 1 7 4 4 1 6 7 5 3 5 4 3 6 6 5 7 4 7 1 3 1 Abbildung 4.6: Illustration des Beweises von Satz 4.2. Rotationen reichen also aus, um Suchbäume zu balancieren. Wir definieren daher: Eine Klasse T von Suchbäumen heißt balanciert zur Höhe f (n), wenn gilt: 1. Jede Schlüsselmenge mit n Knoten (n = 1, 2, . . . ,) kann durch einen Suchbaum T ∈ T dargestellt werden und es gilt h(T ) ≤ f (n). 2. Basisoperationen können für T ∈ T in O(h(T )) durchgeführt werden. 3. Die Klasse T ist abgeschlossen gegenüber Basisoperationen, d.h. Einfügen und Löschen in T ∈ T führen wieder zu einem Baum in T. Gesucht ist eine Klasse von Suchbäumen mit f (n) ∈ O(log n). Es gibt verschiedene solche Klassen, die historische älteste ist die der sogenannten AVL-Bäume. Für sie ist f (n) ≤ 1.4405 log(n + 2) − 0.3277. Wir werden jedoch nur zeigen, dass f (n) ≤ 2 · log n gilt. 4.2 Literaturhinweise Suchbäume werden in jedem Buch über Datenstrukturen behandelt, vgl. etwa [CLRS01, Knu98, OW02, SS02]. 52 KAPITEL 4. SUCHBÄUME Kapitel 5 AVL-Bäume Die historisch älteste Klasse balancierter Suchbäume zur Höhe f (n) ∈ O(log n), die Klasse der AVLBäume, wurde 1962 von Adelson-Velski und Landis aus der UdSSR definiert. Die grundsätzliche Idee hierbei ist, das Entarten der Suchbäume zu einer Liste durch eine Forderung an die Höhendifferenz der beiden Teilbäume eines jeden Knotens zu verhindern. Mit dieser Forderung wird erreicht, dass die Höhe von f (n) = 1, 44 . . . log n nicht überschritten wird. 5.1 Grundsätzliche Eigenschaften Um AVL-Bäume definieren zu können, benötigen wir zunächst die Definition der Balance: Sei T ein binärer Suchbaum und sei v ∈ V ein Knoten. Seien T` (v) und Tr (v) die (evtl. leeren Teilbäume) von v. Dann heißt β (v) := h(Tr (v)) − h(T` (v)) mit h(0) / = −1 (5.1) die Balance des Knoten v. v T` (v) h(T` (v)) h(Tr (v)) Tr (v) β (v) Abbildung 5.1: Balance als Höhendifferenz Wie in Abb. 5.1 zu sehen, beschreibt sie die Höhendifferenz zwischen dem rechten und linken Teilbaum des Knoten v. Damit können wir AVL-Bäume definieren: 53 54 KAPITEL 5. AVL-BÄUME Ein binärer Suchbaum T heißt AVL-Baum genau dann, wenn für alle v ∈ V gilt: |β (v)| ≤ 1. (5.2) Beispiel 5.1 Abbildung 5.2 zeigt die beiden Bäume T und T 0 . Die Zahlen an den Knoten geben ihre Balance an. Bei T handelt es sich um einen AVL-Baum. T 0 erfüllt die AVL-Eigenschaft nicht, da die Balance der Wurzel −2 beträgt. T 1 T0 −1 1 1 0 0 −2 −1 0 0 −1 0 0 Abbildung 5.2: Der AVL-Baum T und der Suchbaum T 0 Aus der Definition von AVL-Bäumen folgt, dass auch jeder Teilbaum eines AVL-Baums wieder ein AVL-Baum ist. Wir zeigen nun, dass die Balance-Bedingung dafür sorgt, dass AVL-Bäume nicht allzu hoch werden. Satz 5.1 Sei T ein AVL-Baum mit n Knoten (n ≥ 2). Dann gilt für seine Höhe h(T ): h(T ) ≤ 2 log n (5.3) Beweis: Um diese Abschätzung zu beweisen, betrachten wir extremale AVL-Bäume, die zu vorgegebener Höhe h eine minimale Knotenzahl aufweisen. Sei deshalb n(h) := min{n | T ist AVL-Baum mit n Knoten und h(T ) = h} (5.4) Abbildung 5.3 zeigt, wie solche extremalen AVL-Bäume aufgebaut sind. Bezüglich der Struktur eines extremalen AVL-Baumes T , lassen sich daher folgende Eigenschaften vermuten: 1. Die Höhendifferenz der Teilbäume eines extremalen AVL-Baumns ist 1 oder −1 (außer der Baum hat nur einen Knoten). 2. Die Teilbäume T` und Tr eines extremalen AVL-Baums der Höhe h ≥ 1 sind selber wieder extremale AVL-Bäume zu den Höhen h − 1 und h − 2 bzw. h − 2 und h − 1. Wir wollen nun diese Vermutungen beweisen: 55 5.1. GRUNDSÄTZLICHE EIGENSCHAFTEN h 0 1 2 3 ... ... ... ... Abbildung 5.3: Extremale AVL-Bäume. zu 1.: Angenommen, T sei ein extremaler AVL-Baum mit einer Höhendifferenz 0. Nimmt man nun, wie in Abb. 5.4, im linken Teilbaum alle Blätter weg, ergibt sich wieder ein AVL-Baum, da das Löschen der Blätter die Balancebedingung |β (u)| ≤ 1 für alle u ∈ V nicht verletzt. Dieser AVL-Baum hat dieselbe Höhe, aber weniger Knoten als T . Damit ist T nicht mehr extremal. Das ergibt einen Widerspruch. zu 2.: Da nach Definition von AVL-Bäumen klar ist, dass alle Teilbäume von AVL-Bäumen wieder AVL-Bäume sind, bleibt zu zeigen, dass seine Teilbäume extremal zu den Höhen h − 1 und h − 2 sind. Angenommen, T sei extremal, aber ein Teilbaum, o.B.d.A. T` , nicht. Dann gibt es einen AVL-Baum T` 0 mit weniger Knoten als T` , aber derselben Höhe. Ersetze nun T` durch T` 0 in Baum T . Damit ergibt sich ein neuer Baum T 0 mit weniger Knoten, aber derselben Höhe. Da T` 0 und Tr beides AVL-Bäume sind und die Höhe von T` 0 gleich der Höhe von T` ist, sich also die Balance in der Wurzel nicht ändert, ist auch T 0 ein AVL-Baum. Da dieser aber weniger Knoten hat als T , kann T nicht extremal sein. Das ergibt einen Widerspruch. Also sind die Teilbäume von T extremal. Dass sie die Höhen h − 1 und h − 2 haben, ergibt sich aus der ersten Vermutung. T −1 0 −1 1 Abbildung 5.4: Löschen der Blätter im linken Teilbaum Die bewiesene Vermutung führt zu der zentralen Rekursionsformel für die Knotenzahl extremaler AVL-Bäume in Abhängigkeit von der Höhe: n(0) = 1, n(1) = 2 n(h) = n(h − 2) + n(h − 1) + 1 für h ≥ 2 (5.5) 56 KAPITEL 5. AVL-BÄUME Dabei wird zu der Anzahl der Knoten der beiden Teilbäume noch ein Knoten für die Wurzel addiert. Aus der Rekursionsformel 5.5 folgt dann dieses Lemma: Lemma 5.1 Für die minimale Knotenzahl n(h) eines AVL-Baumes der Höhe h, (h ≥ 0) gilt: h n(h) ≥ 2 2 (5.6) Beweis: (vollständige Induktion nach h) Induktionsanfang: h h = 0 =⇒ n(h) = 1 = 20 = 2 2 √ h h = 1 =⇒ n(h) = 2 ≥ 2 = 2 2 h h = 2 =⇒ n(h) = 4 ≥ 2 = 2 2 Induktionsschritt von h − 1 auf h: Sei also das Lemma bewiesen für 1, 2, . . . , h − 1. Dann folgt: 5.5 n(h) = n(h − 1) +n(h − 2) + 1 | {z } 5.5 = n(h − 2) + n(h − 3) + 1 + n(h − 2) + 1 > 2n(h − 2) IV ≥ 2·2 = 2 h−2 2 h−2 2 +1 h = 22 h h h Zwar ist hier n(h) echt größer als 2 2 , aber für h = 0 ist n(h) gleich 2 2 . Deswegen ist n(h) ≥ 2 2 . 4 Aus dem Lemma folgt dann durch Logarithmieren: h 2 ⇒ h ≤ 2 log n(h) log n(h) ≥ ⇒ h ≤ 2 log n(T ) für jeden AVL-Baum T mit n(T ) Knoten und der Höhe h(T ). Um zu beweisen, dass h ≤ 1, 44 . . . log n ist, benutzt man diesen Ansatz: Die Rekursionsformel 5.5 ähnelt der der Fibonacci-Folge: f0 = 0, f1 = 1 fi = fi−1 + fi−2 57 5.2. ROTATIONEN i 0 1 2 3 4 5 6 7 .. . n(i) fi 1 0 L 2 L 1 4 LLL 1 L 7 LLL 2 L L 12 L 3 L 20L LL 5 L 33L LL 8 L 54 LL 13 .. L .. . . Abbildung 5.5: Zusammenhang zwischen Rekursionsformel 5.5 und der Fibonacci-Folge. Daher nennt man extremale AVL-Bäume auch Fibonacci-Bäume. Den genauen Zusammenhang zwischen diesen beiden Folgen zeigt die Tabelle in Abbildung 5.5. Es ist leicht zu sehen, dass fi = n(i − 3) + 1. Damit kann man n(h) analog zu der Fibonacci-Folge in geschlossener Form abschätzen. Die Formel für die Fibonacci-Zahlen lautet: √ √ n Φn − Φ 1+ 5 1− 5 fi = √ , wobei Φ = und Φ = 2 2 5 Daraus folgt dann (mit etwas Arbeit) die obige genauere Abschätzung. Wir verzichten hier auf den genauen Beweis. 5.2 Rotationen Um die schon bei Suchbäumen angesprochenen Basisoperationen durchführen zu können, ohne den AVL-Baum außer Balance zu bringen, benötigt man Rotationen. Im letzten Kapitel über Suchbäume wurden bereits die einfachen Rotationen RotLinks(v, w) und RotRechts(v, w) erklärt. Neben diesen benutzt man bei AVL-Bäumen noch die Doppelrotationen. Zur Erklärung beschränken wir uns auf eine anschauliche Darstellung in den Abbildungen 5.6 und 5.7: Die Doppelrotationen haben diese Eigenschaften: 1. Der nach der Doppelrotation resultierende binäre Baum T 0 ist wiederum ein Suchbaum. 2. Die Doppelrotationen lassen sich durch die einfachen Rotationen RotLinks und RotRechts ausdrücken: DRotLinks (v, v 0 , v 00 ) = RotLinks (v, v 00 ) ◦ RotRechts (v 0 , v 00 ) und DRotRechts (v, v 0 , v 00 ) = RotRechts (v, v 00 ) ◦ RotLinks (v 0 , v 00 ) . Rotationen und Doppelrotationen reichen aus, um AVL-Bäume nach dem Löschen bzw. Einfügen eines Knotens zu rebalancieren. Basis hierfür ist das folgende Lemma: 58 KAPITEL 5. AVL-BÄUME v 00 v 2 v0 v 00 v0 v =⇒ 1 T1 T4 T2 T1 T2 T3 T4 T3 Abbildung 5.6: Doppellinksrotation DRotLinks(v, v 0 , v 00 ). v 00 v 2 v0 v0 =⇒ 1 v 00 T4 T1 T1 T2 v T2 T3 T4 T3 Abbildung 5.7: Doppelrechtsrotation DRotRechts(v, v 0 , v 00 ). Lemma 5.2 (Rotationslemma für AVL-Bäume) Sei T ein Baum mit Wurzel v. Der linke und der rechte Teilbaum T` , Tr von T seien AVL-Bäume. Die Wurzel v sei geringfügig außer Balance, d.h. |β (v)| = 2. Dann folgt: a) T kann durch eine Rotation bzw. Doppelrotation in einen AVL-Baum T 0 überführt werden mit h(T 0 ) ≤ h(T ). b) Die Art der Rotation (einfach links, . . . , doppelt rechts) kann mit O(1) Aufwand ermittelt werden. c) Die Rotation kann in O(1) Aufwand durchgeführt werden. d) Alle veränderten Balancen in T 0 können mit O(1) Aufwand aus denen in T berechnet werden. 59 5.2. ROTATIONEN Beweis: Wir unterscheiden 4 Fälle: 1.) Der Weg von v in die größte Tiefe geht links – links (LL) T T0 v v0 =⇒ v0 v T3 2 T1 ≤1 T1 T2 T3 T2 Abbildung 5.8: Rechtsrotation bei dem Fall LL. Aus Abbildung 5.8 folgt: β (v) = −2 und β (v 0 ) ∈ {0, −1}. Durch Anwenden der Rechtsrotation RotRechts(v, v 0 ) auf T entsteht T 0 : Für die Balancen β 0 in T 0 gilt offenbar: 0 β (v) = 0 −1 falls β (v 0 ) = 0 0 falls β (v 0 ) = −1 (5.7) 1 falls β (v 0 ) = 0 0 falls β (v 0 ) = −1 (5.8) 0 β (v ) = β 0 (u) = β (u) , für alle u 6= v, v 0 (5.9) Ferner ist 0 h(T ) = h(T ) falls β (v 0 ) = 0 h(T ) − 1 falls β (v 0 ) = −1 (5.10) Da für alle u ∈ V gilt, dass |β 0 (u)| ≤ 1 ist und h(T 0 ) ≤ h(T ) ist, folgt sofort Aussage a). Die beiden Balancen für v und w bestimmen nun die Art der Rotation. Die Balancen können leicht in O(1) ermittelt werden. Also folgt Aussage b). Dass die Rotation mit einem Aufwand von O(1) durchführbar ist und somit Aussage c) stimmt, wurde bereits im letzten Kapitel bewiesen. Aussage d) stimmt, weil sich die veränderten Balancen mit den obigen Gleichungen in O(1) bestimmen lassen. 60 KAPITEL 5. AVL-BÄUME T T v V0 v 00 V0 v =⇒ v 00 T4 2 1 T1 T2 T3 T4 T1 T2 T3 Abbildung 5.9: Doppelrechtsrotation bei dem Fall LR und nicht LL. 2.) Weg von v in die größte Tiefe geht links – rechts und nicht links – links (LR) Für diesen Fall gilt: β (v) = −2, β (v 0 ) = 1 und β (v 00 ) ∈ {−1, 0, 1}. Wendet man die Doppelrechtsrotation DRotRechts(v, v 0 , v 00 ) an, entsteht T 0 . Für ihn gilt: 0 falls β (v 00 ) = 0 0 00 0 falls β (v 00 ) = 1 β (v ) = 0 falls β (v 00 ) = −1 (5.11) 0 falls β (v 00 ) = 0 −1 falls β (v 00 ) = 1 β 0 (v 0 ) = 0 falls β (v 00 ) = −1 (5.12) 0 falls β (v 00 ) = 0 0 0 falls β (v 00 ) = 1 β (v) = 1 falls β (v 00 ) = −1 (5.13) β 0 (u) = β (u) , für alle u 6= v, v 0 , v 00 (5.14) h(T 0 ) = h(T ) − 1 (5.15) Ferner ist Daraus ergeben sich analog zum 1. Fall die Aussagen a), b), c) und d). 3.) Weg von v in die größte Tiefe geht rechts - rechts (RR) Dieser Fall ist symmetrisch zum 1. Fall mit einer Linksrotation und wird analog bewiesen. 61 5.3. DIE BASISOPERATIONEN IN AVL-BÄUMEN 4.) Weg von v in die größte Tiefe geht rechts - links und nicht rechts - rechts (RL) Dieser Fall ist symmetrisch zum 2. Fall mit einer Doppellinksrotation und wird analog bewiesen. Hierbei ist unbedingt zu beachten: Geht der Weg in die größte Tiefe sowohl LL als auch LR (1. Fall), so erzeugt eine Doppelrotation DRotRechts im Allgemeinen keinen AVL-Baum, wie das Beispiel in Abbildung 5.10 zeigt. Dies gilt natürlich ebenso für den symmetrischen Fall. T T0 6 4 DRotRechts(6, 3, 4) 3 2 7 =⇒ 3 2 4 5 1 1 6 5 7 β 0 (3) = −2 ⇒ kein AVL-Baum =⇒ RotRechts(6, 3) T 00 3 2 1 6 4 ⇒ AVL-Baum 7 5 Abbildung 5.10: Die unterschiedlichen Rotationen bei LL und LR. 5.3 Die Basisoperationen in AVL-Bäumen Zur effizienten Durchführung der Basisoperationen Suchen, Einfügen und Löschen muss jeder Knoten u seine Balance β (u) kennen“, d.h., wir setzen voraus, dass sie in jedem Knoten u ∈ V abgespeichert ” ist. Die Basisoperationen werde zunächst wie in allgemeinen Suchbäumen durchgeführt, danach wird ggf. durch Rotationen die AVL-Eigenschaft wieder hergestellt und werden die neuen Balancen für die 62 KAPITEL 5. AVL-BÄUME Knoten mit veränderter Balance ermittelt und abgespeichert. 5.3.1 Suchen eines Knotens v Die Suchen eines Knotens in einem AVL-Baum entspricht der normalen Suche in einem Suchbaum, da die Struktur des Baumes unverändert bleibt. Die AVL-Eigenschaft bleibt also erhalten. Da der Aufwand für diese Basisoperation im Suchbaum in O(h(T )) liegt, folgt für den AVL-Baum, dass das Suchen in O(log n(T )) arbeitet. 5.3.2 Einfügen eines neuen Knotens v Zunächst muss der Platz gesucht werden, an dem der neue Knoten entsprechend der Suchbaumeigenschaft eingefügt werden soll. Den dabei gegangenen Weg von der Wurzel bis zu der Einfügestelle nennen wir die Spur. Dann wird der Knoten an der entsprechenden Stelle eingefügt und es ergibt sich ein neuer Baum T 0 . Der Aufwand hierfür liegt in O(h(T )) = O(log n(T )). Beim Übergang von T zum neuen Baum T 0 kann allerdings die AVL-Eigenschaft verloren gehen, aber nur entlang der Spur zur Wurzel. Alle anderen Teilbäume bleiben gleich und es gilt β 0 (u) = β (u) für alle Knoten u, die nicht auf der Spur liegen, wobei β 0 und β die Balancen in den Bäumen T 0 und T sind. Also sind nur die Balancen entlang der Spur zu prüfen und gegebenenfalls in Ordnung zu bringen. Wie dies geschieht, zeigt der folgende Algorithmus. Algorithmus 5.1 Rebalancieren beim Einfügen: (i) Setze β 0 (v) = 0, da v ein Blatt ist. (ii) Akualisiere die Balance β 0 (u) des Vaters u von v. (iii) Gilt |β 0 (u)| ≤ 1 und u 6= root, so setze v := u und gehe zu (ii). (iii) Ist |β 0 (u)| = 2, so folgt: // Beide Teilbäume sind noch AVL-Bäume // Die Situation aus dem Rotationslemma (Lemma 5.2) liegt vor. ⇒ Führe eine entsprechende Rotation bzw. Doppelrotation durch, die die Balance wieder herstellt und aktualisiere die Balancen. // War w der Vater von u vor der Rotation, so setze v := w und gehe zu (ii). Dabei gilt: Satz 5.2 Wird beim Einfügen von v eine Rotation bzw. Doppelrotation in v1 ausgeführt, so erfüllen danach alle Vorgänger u von v1 auf der Spur die Balancebedingung |β 0 (u)| ≤ 1. 63 5.3. DIE BASISOPERATIONEN IN AVL-BÄUMEN T T0 v2 v2 v1 w =⇒ v1 w 2 2 v 1 v Abbildung 5.11: Nach dem Einfügen genügt es einmal zu rotieren (doppelt oder einfach), um die AVL-Eigenschaft wieder herzustellen. Beweis: Angenommen, das Einfügen von v erzeugt einen Baum T 0 mit den folgenden Balancen in v1 und v2 , die auf der Spur von T 0 liegen: β 0 (v1 ) = β 0 (v2 ) = −2, wobei v1 der erste Knoten (von unten) ist, der außer Balance geraten ist. In T musste also |β (v1 )| = 1 und |β (w)| = 0 gelten, sonst wäre v1 nicht der erste Knoten mit |β 0 (v1 )| = 2. Abhängig davon, ob v am linken oder am rechten Teilbaum eingefügt wurde, ist entweder eine Doppelrechtsrotation oder eine einfache Rechtsrotation notwendig, um v1 in Balance zu bringen. Durch Anwendung der Rechtsrotation RotRechts(v1 , w) verringert sich die Höhe des Teilbaums, der nun die Wurzel w hat, um 1. Vergleiche dazu den 1. Fall des Rotationslemmas (Lemma 5.2). Doppelrotationen machen nach dem 2. Fall des Rotationslemmas (Lemma 5.2) den entstehenden Teilbaum immer um 1 weniger hoch. Also wird der Teilbaum mit der Wurzel v1 , unabhängig davon, ob eine einfache Rechtsrotation oder eine Doppelrechtsrotation durchgeführt wird, danach durch einen um 1 weniger hohen Teilbaum ersetzt. Nun ist die Balancebedingung in v2 nicht mehr verletzt, da der Teilbaum ja wieder dieselbe Höhe hat wie vor dem Einfügen. Die symmetrischen Fälle folgen entsprechend. Daraus folgt insgesamt: Um nach dem Einfügen den Baum zu rebalancieren, genügt es, entlang der Spur soweit nach oben zu gehen, bis zum ersten Mal eine Balanceverletzung (|β (u)| = 2) auftritt, und dort die entsprechende Rotation anzuwenden. Also ist nur eine Rotation bzw. eine Doppelrotation nötig, aber die Stelle ist möglicherweise erst an der Wurzel. Das bedeutet, dass der Aufwand in O(h(T 0 )) = O(h(T )) = O(log n(T )) liegt. Beispiel 5.2 Betrachte das sukzessive Einfügen von 4, 5, 7, 2, 1, 3, 6 ausgehend von einem leeren Baum: 64 KAPITEL 5. AVL-BÄUME 0 4 ∅ 1 5 4 2 7 4 0 RR 4 5 RotLinks(4, 5) 0 1 5 0 5 0 4 7 0 7 −1 2 −2 1 5 −1 LL 5 5 RotRechts(4, 2) −1 −2 0 4 7 4 0 0 7 2 −1 0 2 0 7 0 0 1 2 4 0 1 −2 3 0 LR 5 4 DRotRechts(5, 2, 4) 1 0 2 0 7 −1 0 1 1 2 5 0 4 0 1 0 3 7 0 3 1 6 0 RL 4 4 DRotLinks(5, 7, 6) 2 0 2 0 2 −1 0 0 1 0 5 3 7 6 0 1 0 3 0 5 0 6 Abbildung 5.12: Einfügen von Knoten in einen AVL-Baum 0 7 65 5.3. DIE BASISOPERATIONEN IN AVL-BÄUMEN 5.3.3 Löschen eines Knotens v Das Löschen (ohne rebalacierende Rotationen) funktioniert wie bei allgemeinen Suchbäumen. Es soll hier aber trotzdem kurz wiederholt werden: (i) Suche v. Falls v ein Blatt ist oder nur einen Sohn hat, so lösche v und hänge gegebenenfalls den einen Sohn an den Vater von v. Die (ii) Falls v zwei Söhne hat, so suche im linken Teilbaum den am weitesten rechts stehenden Knoten u. Tausche die Inhalte von u und v und lösche v. Schritte (i), (ii) sind in O(h(T )) = O(log n(T )) durchführbar. Sei T 0 der neue Baum. Das Löschen des Knotens v kann die Balancen seiner Vorfahren (und zwar nur der Vorfahren) ändern. Also müssen alle Knoten auf dem Pfad zur Wurzel (der Spur) ausgehend vom gelöschten Knoten untersucht undihre Balancen aktualisiert werden. Tritt dabei in einem Knoten u eine gestörte Balance auf, ist β 0 (u) = 2 und die Teilbäume sind AVL-Bäume. Das Rotationslemma (Lemma 5.2) kann also angewendet werden. Dies wird entlang der gesamten Spur durchgeführt. Beispiel 5.3 Betrachte das schrittweise Löschen der Knoten 4, 8, 6, 5, 2, 1, 7 ausgehend von einem AVL-Baum: 5 3 8 2 4 2 7 1 10 6 9 3 7 11 2 10 9 9 1 11 11 5 7 6 10 6 6 2 3 8 1 5 8 1 5 4 10 3 7 11 9 66 KAPITEL 5. AVL-BÄUME 3 5 7 2 2 10 1 3 7 11 10 1 9 11 9 7 1 3 10 9 10 7 3 11 11 9 Abbildung 5.13: Löschen von Elementen im ausgeglichenen Baum Das Löschen des Knoten mit dem Schlüssel 4 selbst ist einfach, da dieser ein Blatt ist. Es führt aber zu einem unausgeglichenen Baum, denn β (3) = −2. Die Ausgleichsoperation bedingt eine einfache Rechtsrotation. Ausgleichen wird erneut nach dem Löschen von Knoten 6 notwendig. Dieses Mal kann der Teilbaum mit der Wurzel 7 durch eine einfache Linksrotation ausgeglichen werden. Löschen von Knoten 2 ist zwar selbst eine direkte Operation, da nur ein Nachfolger existiert, es impliziert aber eine Doppellinksrotation. Um den Knoten mit dem Schlüssel 7 zu löschen, wird zunächst die 7 mit dem größten Element seines linken Teilbaumes, d.h. mit der 3, vertauscht und dann wird der neue Knoten mit der 7 gelöscht. Schließlich wird durch das Löschen dieses Knotens noch eine Linksrotation verursacht. Im Allgemeinen muss nach dem Löschen, im Unterschied zum Einfügen, mehrmals rebalanciert werden. 1 Da aber jede Rotation bzw. Doppelrotation in konstanter Zeit durchführbar ist, folgt, dass das Löschen in O(h(T )) = O(log n(T )) möglich ist. Im schlimmsten Fall kann das Löschen eines Knotens sogar eine Rotation für jeden Knoten entlang der Spur verursachen. Diese Situation ist jedoch ein eher unglückliches Zusammentreffen von Zufällen. Wie wahrscheinlich sind nun Rotationen im Allgemeinen? Das überraschende Resultat empirischer Tests ist, dass zwar eine Rotation bei etwa jedem zweiten Einfügen, aber nur bei jedem fünften Löschen erforderlich ist. Löschen in AVL-Bäumen ist daher im Mittel etwa gleich aufwändig wie Einfügen. Insgesamt zeigt sich, dass die Klasse der AVL-Bäume balanciert zur Höhe f (n) = 2 log n ist. 1 Aber auch beim Löschen gibt es einige Spezialfälle in denen man mit maximal einer Rotation auskommt (Übung). 5.4. LITERATURHINWEISE 67 Abschließend ist zu bemerken, dass es auch noch andere Klassen balancierter Suchbäume gibt, die zur Höhe 2 log n ausgeglichen sind. Bei ihnen sind sowohl beim Einfügen als auch beim Löschen nur O(1) Rotationen durchzuführen. Dazu gehören zum Beispiel die Red-Black-Trees. 5.4 Literaturhinweise Balancierte Suchbäume werden in jedem Buch über Datenstrukturen behandelt. Für AVL-Bäume und RedBlack-Trees sei auf [CLRS01] verwiesen. 68 KAPITEL 5. AVL-BÄUME Kapitel 6 Optimale statische Suchbäume 6.1 Statische Suchbäume allgemein Bisher haben wir den dynamischen Fall betrachtet, bei dem sich die Schlüsselmenge über die Zeit ändert und deshalb die Operationen Löschen und Einfügen nötig sind. Jetzt behandeln wir den statischen Fall. Hier gibt es eine feste Schlüsselmenge, für die wir die Zugriffswahrscheinlichkeiten kennen. Dazu kann man sich vor dem Speichern die richtige Gestalt des Suchbaumes auswählen bzw. konstruieren, um die mittlere Zugriffszeit klein zu halten. In der Praxis werden optimale statische Suchbäume beispielsweise für das Speichern auf einer CD-Rom verwendet. Beispiel 6.1 Für die folgenden Schlüssel mit den entsprechenden Zugriffshäufigkeiten wurden die beiden statischen Suchbäume T1 und T2 in Abbildung 6.1 zum Abspeichern konstruiert. Schlüssel si Häufigkeit βi in % T1 1 20 2 5 3 10 4 25 T2 3 1 4 6 30 6 2 5 2 5 10 6 1 3 4 5 Abbildung 6.1: Die beiden statischen Suchbäume T1 und T2 zu Bsp. 6.1 69 70 KAPITEL 6. OPTIMALE STATISCHE SUCHBÄUME Die mittleren Zugriffszeiten der beiden Suchbäume (in Anzahl von Vergleichen): Für T1 1 · 10 für 3 2 · 20 für 1 2 · 10 für 5 3 · 5 für 2 3 · 25 für 4 3 · 30 für 6 250 Für T2 1 · 30 für 6 2 · 5 für 2 3 · 20 für 1 3 · 10 für 3 4 · 25 für 4 4 · 10 für 5 270 Das ergibt also im Mittel 2, 5 Vergleiche bzgl. T1 und 2, 7 Vergleiche bzgl. T2 . Beim optimalen statischen Suchbaum T3 (Abbildung 6.2) aber werden im Mittel nur 1, 95 Vergleiche benötigt. T3 4 1 6 3 5 2 Abbildung 6.2: Der optimale statische Suchbaum für Bsp. 6.1. Wie ermittelt man nun den optimalen statischen Suchbaum? Um das zu beantworten, müssen wir erst klären, was optimal überhaupt heißen soll. 6.2 Optimalität statischer Suchbäume Zunächst benötigen wir einige Definitionen. Sei S = {s1 , . . . , sn }, mit s1 < s2 < · · · < sn , die Schlüsselmenge, die in einem Suchbaum T abgespeichert werden soll. Seien β1 , . . . , βn die Zugriffshäufigkeiten, mit denen auf die Schlüssel si der Schlüsselmenge S zugegriffen wird. Dann ist n C(T ) := ∑ βi · (1 + hT (si )) | {z } i=1 (6.1) (∗) die Gesamtzugriffszeit für T . Das C steht dabei für cost“. Der Ausdruck (∗) entspricht der Anzahl ” der Vergleiche, die benötigt werden, um si zu finden. Sei nun βn β1 ,..., p= ∑ βi ∑ βi 6.2. OPTIMALITÄT STATISCHER SUCHBÄUME 71 die Häufigkeitsverteilung. Dann ist C(T ) ∑ni=1 βi E p (T ) := (6.2) die mittlere Zugriffszeit pro Schlüssel. Nun können wir uns der Definition der Optimalität zuwenden: T heißt optimaler statischer Suchbaum für S und β1 , . . . βn , falls C(T ) ≤ C(T 0 ) ⇔ E p (T ) ≤ E p (T für alle Suchbäume T 0 zu S und β1 , . . . , βn (6.3) 0) Satz 6.1 Sei T ein Suchbaum für S und T 0 ein Teilbaum von T . Dann gilt: 1. Die Schlüsselmenge S 0 der Schlüssel in T 0 bildet eine konsekutive Teilsequenz (Intervall) si < si+1 < · · · < s j von s1 < s2 < · · · < sn . 2. Prinzip der optimalen Substruktur“ ” Ist T optimal für S und β1 , . . . , βn , so ist T 0 optimal für die zugehörige Schlüsselmenge S 0 = {si , si+1 , . . . , s j } und die Häufigkeiten βi , βi+1 , . . . , β j ist. Beweis: zu 1.: T Spur zu Wurzel sk von T 0 sk T0 Abbildung 6.3: Statischer Suchbaum T mit einem Teilbaum T 0 . Seien si der kleinste Schlüssel in T 0 , s j der größte Schlüssel in T 0 und sk der Schlüssel in der Wurzel von T 0 . Sei s` ∈ S mit si < s` < s j . Dann ist zu zeigen, dass s` aus der Schlüsselmenge S 0 ist, der Knoten mit dem Schlüssel s` also im Teilbaum T 0 liegt. Angenommen, das sei nicht so. Dann sind zwei Fälle zu unterscheiden. 72 KAPITEL 6. OPTIMALE STATISCHE SUCHBÄUME Fall (i): s` liegt auf der Spur zu sk . s` Sei o.b.d.A. sk im linken Teilbaum von s` . Dann würde aber folgen, dass s j < s` ist, was einen Widerspruch zur Voraussetzung ergibt. sk T0 Fall (ii): s` liegt außerhalb der Spur zu sk . sr sk s` Betrachte den kleinsten gemeinsamen Vorfahren sr von sk und s` . Sei o.b.d.A. T 0 links von sr . Dann folgt: s j < sr < s` , da s` rechts von sr liegen muss. damit ergibt sich allerdings ein Widerspruch zur Voraussetzung. T0 zu 2.: T ` `i = ` + `i 0 sk `i 0 si T0 Abbildung 6.4: Der statische Suchbaum T zur Veranschaulichung von `, `i und `i 0 Sei `i = hT (si ) + 1 = ` + `i 0 , wobei `i 0 = hT 0 (si ) + 1 ist und ` = hT (sk ). Dann gilt: 6.3. KONSTRUKTION EINES OPTIMALEN STATISCHEN SUCHBAUMES C(T ) = β1 `1 + · · · + βn `n = β1 `1 + · · · + βi−1 `i−1 + βi `i + · · · + β j ` j + β j+1 ` j+1 + · · · + βn `n = ∑ vorher 73 (Schlüssel vor S 0 =: ∑ vorher) (Schlüssel in S 0 ) (Schlüssel nach S 0 =: ∑ nachher) + (`i 0 + `)βi + · · · + (` j 0 + `)β j + = ∑ nachher ∑ vorher + `i 0 βi + · · · + ` j 0 β j + `βi + · · · + `β j + = ∑ nachher ∑ vorher + ∑ nachher + `i 0 βi + · · · + ` j 0 β j +` · (βi + · · · + β j) {z } | =C(T 0 ) = vorher + ∑ nachher + ` · (βi + · · · + β j) +C(T 0 ) ∑ | {z } unabhängig von T 0 0 ⇒ C(T ) = Konstante +C(T ) Ist also T optimal, folgt, dass auch T 0 optimal ist. Der Satz zeigt, dass sich jeder optimale statische Suchbaum aus optimalen statischen Suchbäumen für eine kleinere Schlüsselmenge (mit den entsprechenden Häufigkeiten) zusammensetzt. Das bedeutet, dass optimale statische Suchbäume rekursiv aus kleineren optimalen statischen Suchbäumen aufgebaut werden können. 6.3 Konstruktion eines optimalen statischen Suchbaumes Dazu zunächst etwas Notation. Sei Ti j ein Suchbaum für die Schlüssel si+1 , . . . , s j . Wenn Ti j die Wurzel sk hat, so hat Ti j die Teilbäume Ti,k−1 und Tk, j , vgl. Abbildung 6.5. Dann seien Ci j := C(Ti j ) die Kosten von Ti j und ωi j := βi+1 + · · · + β j die Summe der Häufigkeiten der Schlüssel in Ti j . Sei `irj = hTi j (sr ) + 1 die Anzahl der Vergleiche, um den Knoten mit dem Schlüssel sr im Teilbaum Ti j zu finden. Damit gilt: j + · · · + β j · `ijj Ci j = βi+1 · `ii+1 j j j = βi+1 · `ii+1 + · · · + βk−1 · `ik−1 + βk · (0 + 1) + βk+1 · `ik+1 + · · · + β j · `ijj 74 KAPITEL 6. OPTIMALE STATISCHE SUCHBÄUME Ti, j Ti,k−1 Sk Tk, j Abbildung 6.5: Der statische Suchbaum Ti j mit der Wurzel sk Da die Teilbäume Ti,k−1 und Tk, j den gemeinsamen Vater sk und daher alle Knoten in ihnen eine um 1 geringere Höhe als im Baum Ti, j haben, gilt dann: kj kj i,k−1 Ci j = βi+1 · (`i,k−1 i+1 + 1) + · · · + βk−1 · (`k−1 + 1) + βk + βk+1 · (`k+1 + 1) + · · · + β j · (` j + 1) i,k−1 = βi+1 · (`i,k−1 i+1 ) + βi+1 + · · · + βk−1 · (`k−1 ) + βk−1 + βk j +βk+1 · (`kk+1 ) + βk+1 + · · · + β j · (`kj j ) + β j i,k−1 = βi+1 · (`i,k−1 i+1 + · · · + βk−1 +βk i+1 ) + · · · + βk−1 · (`k−1 ) + β {z } {z } | | ωi,k−1 Ci,k−1 j ) + · · · + β j · (`kj j ) + βk+1 + · · · + β j + βk+1 · (`kk+1 {z } | | {z } ωk j Ck j = Ci,k−1 + ωi,k−1 + βk + ωk j +Ck, j | {z } ωi j Also ist Ci j = ωi j +Ci,k−1 +Ck j . Die Kosten für den Teilbaum Ti j mit der Wurzel sk ergeben sich also aus der Summe der Häufigkeiten in Ti j , den Kosten für den linken Teilbaum Ti,k−1 und den Kosten für den rechten Teilbaum Tk j . Seien nun Ci j opt die Kosten eines optimalen statischen Suchbaumes für die Schlüssel si+1 , . . . , s j . Sie lassen sich wegen Satz 6.1 folgendermaßen berechnen: Ci j opt = min ωi j +Ci,k−1 opt +Ck j opt (6.4) k=i+1,..., j Jeder Schlüsselwert der Schlüsselmenge wird als Wurzel ausprobiert und es werden jeweils die Kosten der optimalen Teilbäume ermittelt, um sie zu der Summe der Häufigkeiten zu addieren. Der optimale Baum hat dann den Schlüsselwert als Wurzel, für den die Kosten am geringsten sind. Diese Rekursionsformel wird dann zur Konstruktion von optimalen statischen Suchbäumen verwendet. Sie ähnelt der Bellman-Gleichung des Kürzeste-Wege-Problems. Das folgende Beispiel illustriert die Konstruktion. Beispiel 6.2 Konstruktion eines optimalen statischen Suchbaums: i si βi 1 1 0, 1 2 2 0, 1 3 3 0, 2 4 4 0, 05 5 5 0, 3 6 6 0, 25 (∑ βi = 1) 6.3. KONSTRUKTION EINES OPTIMALEN STATISCHEN SUCHBAUMES 75 Intervalllänge 0: Cii opt = 0 für alle i Intervalllänge 1: C0,1 opt C1,2 opt C2,3 opt C3,4 opt C4,5 opt C5,6 opt = = = = = = ω0,1 +C0,0 opt +C1,1 opt ω1,2 +C1,1 opt +C2,2 opt ω2,3 +C2,2 opt +C3,3 opt ω3,4 +C3,3 opt +C4,4 opt ω4,5 +C4,4 opt +C5,5 opt ω5,6 +C5,5 opt +C6,6 opt = = = = = = ω0,1 ω1,2 ω2,3 ω3,4 ω4,5 ω5,6 = = = = = = β1 β2 β3 β4 β5 β6 = = = = = = 0, 1 0, 1 0, 2 0, 05 0, 3 0, 25 Der zugehörige Baum besteht jeweils nur aus der Wurzel. Intervalllänge 2: C0,2 opt min ω0,2 +C0,k−1 opt +Ck,2 opt k=1,2 = min 0, 2 +C0,0 opt +C1,2 opt ; 0, 2 +C0,1 opt +C2,2 opt = = min [0, 2 + 0 + 0, 1; 0, 2 + 0, 1 + 0] = 0, 3 (für k = 1 oder k = 2) Da der Baum für beide k optimal ist, kann zwischen den beiden Möglichkeiten gewählt werden. Der zugehörige Baum T0,2 für k = 1 sieht dann so aus: s1 T0,2 = = s2 T0,0 C1,3 opt s1 T1,2 min ω1,3 +C1,k−1 opt +Ck,3 opt k=2,3 = min 0, 3 +C1,1 opt +C2,3 opt ; 0, 3 +C1,2 opt +C3,3 opt = = min [0, 3 + 0 + 0, 2; 0, 3 + 0, 1 + 0] = 0, 4 (für k = 3) s3 T1,3 s3 = = s2 T1,2 T3,3 76 KAPITEL 6. OPTIMALE STATISCHE SUCHBÄUME C2,4 opt min ω2,4 +C2,k−1 opt +Ck,4 opt k=3,4 = min 0, 25 +C2,2 opt +C3,4 opt ; 0, 25 +C2,3 opt +C4,4 opt = = min [0, 25 + 0 + 0, 05; 0, 25 + 0, 2 + 0] = 0, 3 (für k = 3) s3 T2,4 = = s4 T2,2 C3,5 opt s3 T3,4 min ω3,5 +C3,k−1 opt +Ck,5 opt k=4,5 = min 0, 35 +C3,3 opt +C4,5 opt ; 0, 35 +C3,4 opt +C5,5 opt = = min [0, 35 + 0 + 0, 3; 0, 35 + 0, 05 + 0] = 0, 4 (für k = 5) s5 T3,5 = = s4 T3,4 C4,6 opt s5 T5,5 min ω4,6 +C4,k−1 opt +Ck,6 opt k=5,6 = min 0, 55 +C4,4 opt +C5,6 opt ; 0, 55 +C4,5 opt +C6,6 opt = = min [0, 55 + 0 + 0, 25; 0, 55 + 0, 3 + 0] = 0, 8 (für k = 5) s5 T4,6 s5 = = s6 T4,4 T5,6 Bei den zweielementigen Bäumen ist das Ergebnis klar, da von den beiden Knoten der mit der größeren Wahrscheinlichkeit die Wurzel bildet. 6.3. KONSTRUKTION EINES OPTIMALEN STATISCHEN SUCHBAUMES Intervalllänge 3: C0,3 opt min ω0,3 +C0,k−1 opt +Ck,3 opt k=1,2,3 = ω0,3 + min C0,k−1 opt +Ck,3 opt k=1,2,3 = ω0,3 + min C0,0 opt +C1,3 opt ; C0,1 opt +C2,3 opt ; C0,2 opt +C3,3 opt = = 0, 4 + min [0 + 0, 4; 0, 1 + 0, 2; 0, 3 + 0] = 0, 4 + 0, 3 = 0, 7 (für k = 2 oder k = 3) In der Abbildung des entsprechenden Baumes wurde k = 2 gewählt: s2 T0,3 = = s1 T0,1 C1,4 opt s2 s3 T2,3 = ω1,4 + min C1,k−1 opt +Ck,4 opt k=2,3,4 = ω1,4 + min C1,1 opt +C2,4 opt ; C1,2 opt +C3,4 opt ; C1,3 opt +C4,4 opt = 0, 35 + min [0 + 0, 3; 0, 1 + 0, 05; 0, 4 + 0] = 0, 35 + 0, 15 = 0, 5 (für k = 3) s3 T1,4 = = s2 T1,2 C2,5 opt s3 s4 T3,4 = ω2,5 + min C2,k−1 opt +Ck,5 opt k=3,4,5 = ω2,5 + min C2,2 opt +C3,5 opt ; C2,3 opt +C4,5 opt ; C2,4 opt +C5,5 opt = 0, 55 + min [0 + 0, 4; 0, 2 + 0, 3; 0, 3 + 0] = 0, 55 + 0, 4 = 0, 5 (für k = 5) s5 T2,5 = s5 = T2,4 T5,5 s3 s4 77 78 KAPITEL 6. OPTIMALE STATISCHE SUCHBÄUME C3,6 opt = ω3,6 + min C3,k−1 opt +Ck,6 opt k=4,5,6 = ω3,6 + min C3,3 opt +C4,6 opt ; C3,4 opt +C5,6 opt ; C3,5 opt +C6,6 opt = 0, 6 + min [0 + 0, 8; 0, 05 + 0, 25; 0, 4 + 0] = 0, 6 + 0, 3 = 0, 9 (für k = 5) s5 T3,6 = s5 = s4 T3,4 s6 T5,6 Intervalllänge 4: C0,4 opt = ω0,4 + min C0,k−1 opt +Ck,4 opt k=1,2,3,4 = ωo,4 + min C0,0 opt +C1,4 opt ; C0,1 opt +C2,4 opt ; C0,2 opt +C3,4 opt ; C0,3 opt +C4,4 opt = 0, 45 + min [0 + 0, 5; 0, 1 + 0, 3; 0, 3 + 0, 05; 0, 7 + 0] = 0, 45 + 0, 35 = 0, 8 (für k = 3) s3 T0,4 = = T0,2 C1,5 opt s3 s1 s4 s2 T3,4 = ω1,5 + min C1,k−1 opt +Ck,5 opt k=2,3,4,5 = ω1,5 + min C1,1 opt +C2,5 opt ; C1,2 opt +C3,5 opt ; C1,3 opt +C4,5 opt ; C1,4 opt +C5,5 opt = 0, 65 + min [0 + 0, 85; 0, 1 + 0, 4; 0, 4 + 0, 3; 0, 5 + 0] = 0, 65 + 0, 5 = 1, 15 (für k = 3 oder k = 5) In der Abbildung ist k = 3: s3 T1,5 = s3 = T1,2 T3,5 s2 s5 s4 6.3. KONSTRUKTION EINES OPTIMALEN STATISCHEN SUCHBAUMES C2,6 opt = ω2,6 + min C2,k−1 opt +Ck,6 opt k=3,4,5,6 = ω2,6 + min C2,2 opt +C3,6 opt ; C2,3 opt +C4,6 opt ; C2,4 opt +C5,6 opt ; C2,5 opt +C6,6 opt = 0, 8 + min [0 + 0, 9; 0, 2 + 0, 8; 0, 3 + 0, 25; 0, 85 + 0] = 0, 8 + 0, 55 = 1, 35 (für k = 5) s5 T2,6 s5 = = T2,4 s6 s3 s4 T5,6 Intervalllänge 5: C0,5 opt min C0,k−1 opt +Ck,5 opt k=1,2,3,4,5 = ω0,5 + min C0,0 opt +C1,5 opt ; C0,1 opt +C2,5 opt ; C0,2 opt +C3,5 opt ; C0,3 opt +C4,5 opt ; C0,4 opt +C5,5 opt = ω0,5 + = 0, 75 + min [0 + 1, 15; 0, 1 + 0, 85; 0, 3 + 0, 4; 0, 7 + 0, 3; 0, 8 + 0] = 0, 75 + 0, 7 = 1, 45 (für k = 3) s3 T0,5 = = T0,2 C1,6 opt s3 s1 s5 s2 T3,5 s4 min C1,k−1 opt +Ck,6 opt k=2,3,4,5,6 = ω1,6 + min C1,1 opt +C2,6 opt ; C1,2 opt +C3,6 opt ; C1,3 opt +C4,6 opt ; C1,4 opt +C5,6 opt ; C1,5 opt +C6,6 opt = ω1,6 + = 0, 9 + min [0 + 1, 35; 0, 1 + 0, 9; 0, 4 + 0, 8; 0, 5 + 0, 25; 1, 15 + 0] = 0, 9 + 0, 75 = 1, 65 (für k = 5) s5 T1,6 = s5 = T1,4 T5,6 s3 s2 s6 s4 79 80 KAPITEL 6. OPTIMALE STATISCHE SUCHBÄUME Intervalllänge 6: C0,6 opt min C0,k−1 opt +Ck,6 opt k=1,2,3,4,5,6 = ω0,6 + min C0,0 opt +C1,6 opt ; C0,1 opt +C2,6 opt ; C0,2 opt +C2,6 opt ; C0,3 opt +C4,6 opt ; C0,4 opt +C5,6 opt ; C0,5 opt +C6,6 opt = ω0,6 + = 1 + min [0 + 1, 65; 0, 1 + 1, 35; 0, 3 + 0, 9; 0, 7 + 0, 8; 0, 8 + 0, 25; 1, 45 + 0] = 1 + 1, 05 = 2, 05 (für k = 5) So ergibt sich dann schließlich der optimale statische Suchbaum für die gegebene Schlüsselmenge mit den zugehörigen Häufigkeiten zu s5 T0,6 s5 = = T0,4 s3 s1 T5,6 s4 s2 Das Prinzip dieses Algorithmus heißt auch dynamische Optimierung oder dynamisches Programmieren. Aufwand Abschließend wollen wir uns noch ansehen, wie aufwändig der Algorithmus ist. Dazu betrachten wir die folgende Tabelle, die in Zeile i die Größenordnung des Aufwands für die Ermittlung des optimalen Baums mit Intervalllänge i angibt. n n−1 n−2 .. . Bäume mit Bäume mit Bäume mit 1 2 3 .. . Knoten Knoten Knoten ⇒ ⇒ ⇒ n·1 (n − 1) · 2 (n − 2) · 3 .. . 2 1 Bäume mit Bäume mit n−1 n Knoten Knoten ⇒ ⇒ 2 · (n − 1) 1·n n−1 ⇒ ∑ i=0 |{z} (n − i) (i + 1) | {z } | {z } ≤n ∈ Θ(n3 ) ≤n n Summen Der Aufwand des Gesamtalgorithmus beträgt für n Schlüssel also Θ(n3 ) elementare Schritte; er kann aber durch geschicktere Bestimmung der besten Wurzel auf O(n2 ) elementare Schritte reduziert werden. Da der Aufwand sehr hoch ist, findet der Algorithmus eigentlich nur dort Anwendung, wo es sich wirklich lohnt, wenn also auf den statischen Suchbaum wirklich sehr oft zugegriffen werden soll. 6.4. LITERATURHINWEISE 6.4 81 Literaturhinweise Der hier dargestellte Algorithmus wird u.a. in [CLRS01] behandelt. Die Verbesserung auf O(n2 ) geht auf [Knu71] zurück. 82 KAPITEL 6. OPTIMALE STATISCHE SUCHBÄUME Kapitel 7 B-Bäume 7.1 Definition und Eigenschaften Im letzten Kapitel zum Thema Bäume soll es um die sogenannten B-Bäume gehen. Es gibt unterschiedliche Ansichten darüber, wofür das B in B-Baum steht. Die häufigste Interpretation ist, dass es für balanciert steht, da alle Blätter auf der gleichen Ebene im Baum stehen. Eine weitere Interpretation ist, dass das B nach dem Namen seines Erfinders Rudolf Bayer für Bayer steht. Die bisher betrachteten Suchbäume wurden nur im Hauptspeicher, also nur zur Laufzeit, genutzt. Jetzt wollen wir große Datenmengen im Hintergrundspeicher, wie zum Beispiel auf Festplatten, Bändern, etc., abspeichern. Das Problem hierbei ist, das der Hintergrundspeicher längere Zugriffszeiten auf einen bestimmten Speicherort hat. Das wirkt sich natürlich auf die Lese- und Schreibzeit aus. Eine Lösung für dieses Problem ist es, große Blöcke von Daten auf einmal zu lesen bzw. zu schreiben. Dabei sind Blockgrößen von 211 − −214 Byte, also 2 – 16 kB, üblich. Die Blöcke werden in Suchbäumen organisiert, wobei ein Block mehrere Datensätze mit ihrem entsprechenden Schlüssel enthält. Diese Suchbäume nennt man dann B-Bäume. Da nun mehrere Datensätze mit unterschiedlichen Schlüsseln in einem Knoten gespeichert sind, werden mehrere Teilbäume in den Knoten benötigt, um zu den richtigen Suchschlüsseln verzweigen zu können. Die Anzahl der Teilbäume hängt von der Anzahl der Datensätze ab, die in einem Knoten gespeichert sind. In Abbildung 7.1 sind zur Veranschaulichung zwei Datensätze in einem Knoten mit drei Teilbäumen gespeichert. D H Schlüssel ≤D Schlüssel ≥ D aber ≤ H Schlüssel ≥H Abbildung 7.1: Dieser B-Baum hat drei Teilbäume 83 84 KAPITEL 7. B-BÄUME Beispiel 7.1 In Abbildung 7.2 sieht man ein Beispiel eines B-Baumes, bei dem Buchstaben den Datensätzen entsprechen. Diese stellen auch gleichzeitig den Suchschlüssel dar. M DH BC FG QTX JKL NP RS VW YZ Abbildung 7.2: Beispiel für einen B-Baum Betrachten wir jetzt die genaue Definition von B-Bäumen der Ordnung t: 1. Jeder Knoten x • kennt die Anzahl n[x] seiner Datensätze • kennt die n[x] Datensätze selbst, die aufsteigend sortiert sind key1 [x] ≤ key2 [x] ≤ · · · ≤ keyn[x] [x] • weiß durch die Boolesche Variable lea f [x], ob er ein Blatt ist (true) oder nicht (false) 2. Ist der Knoten x kein Blatt, so enthält er n[x] + 1 Zeiger (c1 [x], . . . cn[x]+1 [x]) auf seine n[x] + 1 Kinder 3. Die n[x] + 1 Teilbäume enthalten nur Daten mit Schlüsseln, die die Suchbaum-Eigenschaft“ ” nicht verletzen, d.h.: x key1 [x] k1 k2 ... ... keyn[x] [x] kn[x] kn[x]+1 k1 ≤ key1 [x] ≤ k2 ≤ · · · ≤ keyn[x] [x] ≤ kn[x]+1 4. Alle Blätter haben dieselbe Höhe. 5. Jeder Knoten enthält mindestens t − 1 Datensätze. Die Wurzel kann auch weniger Datensätze, aber mindestens einen, besitzen. Jeder Knoten enthält höchstens 2t − 1 Datensätze. Punkt 5 der Definition zeigt, dass die Speicherausnutzung bei fester Blockgröße und bei fester Größe der Datensätze zwischen 50% und 100% liegt. Es wird Speicherplatz geopfert, damit die Basisoperationen Suchen, Einfügen und Löschen schnell durchgeführt werden können. Bevor wir uns die Basisoperationen genauer ansehen, schätzen wir erst einmal ab, wieviele Datensätze n in einen B-Baum fester Höhe mindestens passen. Dazu folgender Satz: 85 7.1. DEFINITION UND EIGENSCHAFTEN Satz 7.1 Für einen extremalen (knotenminimalen) B-Baum der Ordnung t mit n Datensätzen und einer Höhe h gilt: n+1 (7.1) n ≥ 2t h − 1 bzw. h ≤ logt 2 Beweis: Betrachte einen extremalen B-Baum der Ordnung t mit n Datensätzen und einer Höhe h. Da in diesem extremalen B-Baum die Knotenzahl minimal zur Höhe ist und diese aber von der Anzahl der Datensätze pro Knoten abhängt, müssen in jedem Knoten so wenig Datensätze wie möglich stehen. Deswegen ist in der Wurzel nur 1 Datensatz und in allen übrigen Knoten sind t − 1 Datensätze. Damit hat die Wurzel 2 Söhne und die anderen Knoten haben jeweils t Söhne. 1 t −1 t −1 0 1 Datensatz in der Wurzel 1 1 t − 1 Datensätze pro Knoten +2 · (t − 1) 2 t − 1 Datensätze pro Knoten +2 · t · (t − 1) 3 t − 1 Datensätze pro Knoten +2 · t 2 · (t − 1) t Söhne t −1 t −1 t −1 t −1 ... h t − 1 Datensätze pro Knoten ... t −1 ... ... ... t −1 +2 · t h−1 · (t − 1) ⇒ n ≥ 1 + 2 · (t − 1) + 2 · t · (t − 1) + 2 · t 2 · (t − 1) + · · · + 2 · t h−1 · (t − 1) h−1 = 1 + 2 · (t − 1) · ∑ t i = 1 + 2 · (t − 1) · i=0 th − 1 t −1 = 2 · th − 1 n+1 ⇒ h ≤ logt 2 Beispiel 7.2 Illustration der Speicherkapazität von B-Bäumen Speichert man in einem Block ungefähr 2 kB, dann kann man bei einer üblichen Datensatzgröße von Telefonbuchdatensätzen von 100 Byte pro Datensatz maximal 20 Datensätze pro Block speichern. Das entspricht der Ordnung t = 10. In einem B-Baum der Höhe h = 5 kann man bereits 2 · 105 − 1 ≈ 200000 Datensätze speichern. Das entspricht dem Telefonbuch einer mittleren Stadt. Da eine sehr große Datenmenge bei kleiner Höhe in B-Bäumen abgespeichert werden kann, bilden sie die Standardtechnik für große Datenbanken und für die File-Organisation auf der Festplatte. 86 KAPITEL 7. B-BÄUME 7.2 Basisoperationen in B-Bäumen 7.2.1 Suchen Das Suchen entspricht im Großen und Ganzen dem Suchen in binären Suchbäumen. Der einzige Unterschied besteht darin, dass die einzelnen Knoten noch durchsucht werden müssen, bis der gesuchte Schlüssel oder die Stelle zum Absteigen in den Teilbaum gefunden wurde. 7.2.2 Einfügen In B-Bäumen wird ein neuer Datensatz nur in einem Blatt eingefügt. Das entsprechende Blatt muss erst gesucht werden. Das Problem ist, dass das Blatt voll sein könnte, also 2t − 1 Datensätze in diesem Blatt stehen könnten. Um dieses Problem zu vermeiden, wollen wir eine vorsichtige Strategie fahren. Beim Absteigen soll nämlich dafür gesorgt werden, dass alle Vorfahren des entsprechenden Blattes nicht voll sind. Dazu werden unterwegs volle Knoten gesplittet. Dies ist möglich, da die Vorgänger der Knoten auf der Spur nicht voll sind, weil sie wegen eben dieser Strategie sonst ja schon gesplittet worden wären. y x ... y =⇒ ... k ... ... k ... ... x0 ... x 00 Abbildung 7.3: Splitten eines Knoten x Abbildung 7.3 zeigt, wie das Splitten funktioniert. Es wird in der folgenden Situation beim Einfügen angewendet. Seien die Knoten x und y auf der Spur zu dem Blatt, in das eingefügt werden soll, und sei y der Vater von x. Wir wissen, dass y nicht voll ist, denn, wenn er voll gewesen war, wurde er im vorherigen Schritt gesplittet. Es kann also noch mindestens ein Datensatz dazukommen. Sei nun der Knoten x voll. Er hat also 2t − 1 Datensätze. Dann wird der mittlere Datensatz k nach oben an die entsprechende Stelle in Knoten y verschoben. Alle Datensätze aus x, die kleiner gleich k sind, kommen nun in den Knoten x 0 , der links von k steht und alle, die größer als k sind, kommen in den Knoten x 00 , der rechts von k steht. Die beiden Knoten x 0 und x 00 sind nun nicht mehr voll, sondern haben t − 1 Datensätze. Danach wird weiter nach dem Blatt gesucht, in dass der neue Datensatz eingefügt werden soll und gegebenenfalls wieder gesplittet. Diese vorsichtige Vorgehensweise bewirkt, dass man in einem Durchlauf einfügen kann. Ist speziell die Wurzel voll, muss diese gesplittet werden. Die neue Wurzel hat dann nur einen Knoten. So wächst der B-Baum in seiner Höhe. Beispiel 7.3 Einfügen in einen B-Baum 87 7.2. BASISOPERATIONEN IN B-BÄUMEN In den folgenden B-Baum der Ordnung t = 3 sollen nacheinander die Datensätze B, Q, L, und F eingefügt werden. In jedem Knoten müssen sich dann mindestens 2 Datensätze befinden und dürfen maximal 5 Datensätze sein. Dabei wird die Position, an der der Knoten eingefügt werden soll, durch einen blauen Pfeil angezeigt. Der Datensatz, der durch Splitten eine Ebene nach oben geht, ist blau eingekreist. GMPX ACDE JK NO RSTUV YZ B einfügen Das B kann einfach zwischen A und C eingefügt werden, ohne das ein Knoten gesplittet werden muss. GMPX ABCDE JK NO RSTUV YZ Q einfügen Da der Knoten (RSTUV) voll ist, muss er, bevor das Q vor das R eingefügt werden kann, gesplittet werden. Dabei kommt das T einen Knoten nach oben, also in die Wurzel. GMPTX ABCDE JK NO QRS UV YZ L einfügen Das L wird hinter das K eingefügt. Allerdings muss vorher die Wurzel gesplittet werden. Dabei kommt das P in die neue Wurzel und der Baum wird höher. P GM ABCDE F einfügen JKL TX NO QRS UV YZ 88 KAPITEL 7. B-BÄUME Um das F hinter dem E einfügen zu können, muss der Knoten (ABCDE) erst gesplittet werden, wobei das C nach oben vor das G wandert. P CGM AB DEF TX JKL NO QRS UV YZ Da man in einem Durchlauf einfügen kann, hat das Einfügen von einem Datensatz einen Aufwand von O(h). 7.2.3 Löschen Beim Löschen verwenden wir eine ähnlich vorsichtige Strategie, wie beim Einfügen. Sind nämlich Knoten fast leer, d.h. haben sie t − 1 Datensätze, so wird die Anzahl der Datensätze erhöht, indem man von einem Nachbarn einen Datensatz borgt“ oder zwei Knoten miteinander verschmilzt“. ” ” Algorithmus 7.1 (Löschen eines Schlüssels s) Starte die Suche nach dem zu löschenden Schlüssel s. 1. Der Schlüssel s ist im momentanen Knoten x und x ist ein Blatt. Dann wird s einfach gelöscht. (Durch entsprechende Vorbereitung, die weiter unten erklärt wird, wurde dafür gesorgt, dass x genügend Datensätze enthält.) 2. Der Schlüssel s ist im momentanen Knoten x und x ist ein innerer Knoten. (a) Das Kind y von x vor dem Schlüssel s hat mindestens t Datensätze. Finde den Vorgänger s 0 von s in dem Teilbaum mit der Wurzel y. Tausche s und s 0 . Lösche s rekursiv. x ... s ... y ... ≥t s0 (b) Das Kind z von x nach dem Schlüssel s hat mindestens t Datensätze. Gehe entsprechend (a) vor. (c) Die Knoten y und z haben beide t − 1 Schlüssel. Verschmelze y, s, z zu y 0 , der dann 2t − 1 Datensätze enthält. Diese Verschmelzung ist möglich, da x mehr als t Schlüssel enthält. Lösche s nun rekursiv. 89 7.2. BASISOPERATIONEN IN B-BÄUMEN x x ... s ... ... =⇒ y z ... ... t −1 t −1 ... ... s y0 2t − 1 3. Ist der Schlüssel s nicht im momentanen Knoten x, so bestimme die Wurzel y des Teilbaumes, der s enthält. Falls y nur t − 1 Datensätze enthält, so wende (a) oder (b) an, um mehr als t Schlüssel sicherzustellen. Suche dann rekursiv in y weiter. (a) y hat nur t −1 Schlüssel, aber einen Bruder z mit t Schlüsseln. Mache einen Schlüsseltransfer von z zu y und hänge den entsprechenden Teilbaum um: x x ... k ... ... ... =⇒ y ... ... t −1 ≥t y z z ... k ... ≥ t −1 t (b) Haben y und alle seine Brüder nur t − 1 Schlüssel, verschmelze y mit einem Bruder z. x x ... k ... ... =⇒ y z ... ... t −1 t −1 ... k ... y0 2t − 1 4. Sonderfall: Wenn die Wurzel leer wird, lösche die Wurzel. Beispiel 7.4 In den folgenden B-Baum der Ordnung t = 3 sollen nacheinander die Datensätze F, M, G, und D gelöscht werden. In jedem Knoten müssen sich dann mindestens 2 Datensätze befinden und dürfen maximal 5 Datensätze sein. Der Pfeil zeigt immer auf den zu löschenden Datensatz. Blaue Knoten werden jeweils zusammen mit dem eingekreisten Datensatz verschmolzen. P CGM AB F löschen DEF JKL TX NO QRS UV YZ 90 KAPITEL 7. B-BÄUME Hier muss der 1. Fall des Algorithmus angewendet werden. Da F in einem Blatt steht, kann es einfach gelöscht werden. P CGM AB DE JKL TX NO QRS UV YZ M löschen Es trifft Fall (2a) zu. M steht in dem inneren Knoten (CGM). Da der Knoten (JKL) 3 Datensätze enthält, wird M mit seinem Vorgänger L (hier blau eingekreist) ausgetauscht. Dann wird M nach dem 1. Fall gelöscht. P CGL AB DE JK TX NO QRS UV YZ G löschen G steht im inneren Knoten (CGL). Da die beiden Söhne vor und nach dem G (Knoten (DE) und (JK)) jeweils nur 2 Datensätze enthalten, müssen sie zusammen mit dem G entsprechend Fall (2c) verschmolzen werden. Dann steht G in einem Blatt und kann einfach wie üblich gelöscht werden. P TX CL AB DEJK NO QRS UV YZ D löschen D steht im Blatt (DEJK). Aber auf der Suche danach stellen wir fest, dass die Knoten (CL) und (TX) jeweils nur 2 Datensätze enthalten. Deswegen müssen sie zusammen mit dem P aus der Wurzel entsprechend Fall (3b) verschmolzen werden. Damit tritt der 4. Fall auf, da die Wurzel leer wird. Sie wird dann einfach gelöscht. Nun kann D aus seinem Blatt gelöscht werden. 91 7.3. LITERATURHINWEISE CLPTX AB EJK NO QRS UV YZ Auch das Löschen kann also in einem Durchlauf entlang des Weges bis zum Blatt geschehen, also in O(h) Operationen mit Knoten. Pro Knoten sind dabei maximal 2t − 1 Operationen mit Datensätzen durchzuführen. Dies ergibt insgesamt den Aufwand von O(h ·t) für die Operationen Suchen, Einfügen und Löschen in B-Bäumen. 7.3 Literaturhinweise Die hier gewählte Darstellung lehnt sich eng an [CLRS01] an. Von dort sind auch die Beispiele entnommen. 92 KAPITEL 7. B-BÄUME Kapitel 8 Hashing Hashing ist ein anderes Vorgehen, das auch ohne Baumstrukturen ein effizientes Suchen ermöglicht. Wie bei Bucketsort ist auch hier eine der grundsätzlichen Eigenschaften, dass Hashing nicht auf paarweisen Vergleichen beruht. Beim Hashing werden die Datensätze in einem Array mit Indizes 0, . . . , m − 1, der sogenannten Hash-Tabelle gespeichert. Wenn wir nun wüssten, dass als Schlüssel nur die Zahlen 0, . . . , N, N ∈ N vorkommen, könnten wir die Schlüssel direkt als Array-Indizes verwenden. Das ist aber im Allgemeinen nicht erfüllt. Viel öfter tritt der Fall auf, dass die Anzahl der tatsächlichen Schlüssel viel kleiner ist als die Anzahl der möglichen Schlüssel. Daher berechnet man sich beim Hashing einen Array-Index aus dem Schlüssel: Bezeichne U das Universum aller möglichen Schlüssel und K die Menge der Schlüssel, die auch tatsächlich vorkommen. Dann verwendet man eine Hash-Funktion h, h : U → {0, . . . , m−1}, die jedem Schlüssel k einen Index h(k) zuordnet, seine Hash-Adresse, und speicher es dort ab. Wenn man das Element dann in der Hash-Tabelle wiederfinden möchte, so berechnet man nach Vorschrift der HashFunktion die Hash-Adresse und findet es dann (idealerweise) in O(1) wieder. Leider ist es im Allgemeinen nicht ganz so einfach: Wenn das Universum der möglichen Schlüssel mehr Elemente enthält als die Hash-Tabelle, kann die Funktion nach dem Schubfachprinzip nicht injektiv sein. Dann gibt es also zwei verschiedene Schlüssel k1 und k2 , so dass h(k1 ) = h(k2 ). Das bezeichnet man beim Hashing als Kollision. Der zweite Teil beim Hashing besteht also in der Kollisionsbehandlung, die entweder darin bestehen kann, es zu erlauben, auf einer Hash-Adresse mehrere Elemente zu speichern (Chaining) oder sich bei einer Kollision eine andere Hash-Adresse zu berechnen (Offene Addressierung). Hashing ist ein gutes Beispiel für einen Kompromiss zwischen den beiden Gütefaktoren eines Algorithmus (Speicherplatzbedarf und Ausführungszeit). Wenn wir beliebig viel Zeit zur Verfügung hätten, könnten wir einfach alle Elemente sequentiell durchsuchen; und hätten wir beliebig viel Speicherplatz, könnten wir einfach den Schlüsselwert als Array-Index nehmen (beziehungsweise das Ergebnis einer injektiven Hash-Funktion). Wir wenden uns nun dem Problem zu, eine gute Hash-Funktion zu finden. 93 94 KAPITEL 8. HASHING 0 U h(k1 ) h(k4 ) k3 h(k2 ) = h(k5 ) k4 k1 K k2 k5 h(k3 ) m−1 Abbildung 8.1: Hier werden die Elemente mit den Schlüsseln k2 und k5 auf den gleichen Eintrag in der Hash-Tabelle abgebildet. 8.1 Hash-Funktionen Was zeichnet also eine gute Hash-Funktion aus? Sie sollte • leicht und schnell berechenbar sein • die Datensätze möglichst gleichmäßig verteilt auf die Hash-Tabelle abbilden • deterministisch sein (sonst findet man seine Schlüssel ja nicht wieder) • Kollisionen vermeiden Es kann ziemlich schwierig sein, eine Hash-Funktion zu finden, die injektiv ist, selbst wenn die Zielmenge kleiner ist als die Ausgangsmenge. Beispiel 8.1 Es gibt 4131 ≈ 1050 verschiedene Funktionen, die von einer 31-elementigen Menge in eine 41-elementige Menge abbilden; injektiv sind davon aber nur 41 · 40 · 39·. . . ·11 = 41!/10! ≈ 1043 , also nur ungefähr jede 10millionste! Beispiel 8.2 [Geburtstagsparadoxon] Die Frage beim Geburtstagsparadoxon ist: Wieviele Leute müssen in einem Raum sein, damit die Wahrscheinlichkeit, dass zwei von ihnen am gleichen Tag Geburtstag haben, höher als 21 ist? (Das Jahr hat dabei 365 Tage, wir berücksichtigen keine Schaltjahre.) Das lässt sich auf folgende Art berechnen: Sei m die Anzahl der möglichen Tage und k die Anzahl der Personen. Die Wahrscheinlichkeit q, dass es keine Kollision gibt (also, dass alle Personen an verschiedenen Tagen Geburtstag haben), ist dann q(m, k) = m · (m − 1) · (m − 2) · (m − 3) · · · · (m − k + 1) m! = m·m·m·m····m (m − k)! · mk 95 8.1. HASH-FUNKTIONEN Dann gilt 1 2 Es genügen also schon 23 Leute, damit die Wahrscheinlichkeit, dass zwei von ihnen am gleichen Tag Geburtstag haben, größer als 12 ist. q(365, 23) ≈ 0, 49270276567601459277458277 < Beispiel 8.2 übertragen auf Hash-Funktionen bedeutet folgendes: Haben wir ein Universum von 23 Schlüsseln, eine Hash-Tabelle mit 365 Einträgen und eine zufällige Hash-Funktion, so ist die Wahrscheinlichkeit, dass zwei Schlüssel auf den gleichen Eintrag in der Hash-Tabelle abgebildet werden, 23 größer als 12 . Und dabei haben wir nur eine Auslastung der Hash-Tabelle von 365 ≈ 6, 301%. Wir nehmen im Folgenden an, dass die Schlüsselwerte natürliche Zahlen sind; andernfalls kann man eine bijektive Funktion finden, die von den Schlüsselwerten in eine Teilmenge der natürlichen Zahlen abbildet. (Wir nehmen also an, dass U höchstens abzählbar viele Elemente enthält.) 8.1.1 Divisionsmethode Bei der Divisionsmethode wird für festes m ∈ N folgende Hash-Funktion verwendet: h : U → {0, . . . , m − 1}, h(k) := k mod m In diesem Fall sind einige Werte von m aber besser als andere. Wenn zum Beispiel m gerade ist, so wird h(k) genau dann gerade sein, wenn k schon gerade war. Außerdem sollte man berücksichtigen, dass wir im Binärsystem rechnen. Daher ist es ungünstig, wenn m eine Potenz von 2 ist (m = 2 p ), weil dann bei der Berechnung des Hash-Werts von k nur die letzten p Bits berücksichtigt werden (natürlich kann man diese Hash-Funktion verwenden, wenn man weiß, dass alle 1-0-Verteilungen in den letzten p Bits gleich wahrscheinlich sind). Es wird empfohlen, für m eine Primzahl zu verwenden, die keine der Zahlen ri ± j teilt, wobei i, j ∈ N kleine Zahlen und r die Basis des Zahlensystems ist. Das ist im Allgemeinen eine gute Wahl. 8.1.2 Multiplikationsmethode Sei 0 < A < 1 beliebig, aber fest. Dann benutzt man bei der Multiplikationsmethode folgende HashFunktion: h : U → {0, . . . , m − 1}, h(k) := bm(kA mod 1)c = bm(kA − bkAc)c Es wird also k mit einer Konstante A zwischen 0 und 1 multipliziert, die Vorkommastellen werden abgeschnitten, das Ergebnis wird mit m multipliziert (der dabei entstehende Wert q ist ∈ R und es gilt 0 ≤ q ≤ m). Dann wird abgerundet und wir erhalten einen ganzzahligen Wert q0 mit 0 ≤ q0 ≤ m − 1. Dabei ist die Wahl von m nicht so entscheidend wie bei der Divisionsmethode. Nach [Knu98] führt dabei eine Wahl von √ A ≈ ( 5 − 1)/2 = 0.6180339887 . . . 96 KAPITEL 8. HASHING zur gleichmäßigsten Verteilung von allen Zahlen zwischen 0 und 1. 8.2 Kollisionsbehandlung 8.2.1 Chaining Beim Chaining steht in der Hash-Tabelle an jeder Stelle eine Liste. Tritt nun beim Einfügen eine Kollision auf, so werden beide Elemente in die Liste an dieser Stelle gehängt. Beim Suchen muss dann — nachdem die Hash-Funktion die Stelle berechnet hat, an der das gesuchte Element zu finden ist — die dortige Liste noch durchsucht werden. 0 U k1 k4 k6 k2 k5 k7 k3 k4 k6 k1 K k2 k7 k3 k5 m−1 Abbildung 8.2: Beim Chaining enthält jeder Eintrag der Hash-Tabelle eine Liste der Elemente, die auf diese Stelle gehasht wurden. Sei h die benutzte Hash-Funktion. Wir betrachten die drei Operationen, die wir durchführen wollen: Einfügen Beim Einfügen können wir annehmen, dass das Element nicht bereits in der Hash-Tabelle T vorhanden ist; falls es nötig sein sollte, das zu überprüfen, können wir vorher das Element suchen. Die Einfügen-Methode hat also die Form chainedHashInsert(T,x) füge x am Kopf der Liste T[h(x.key)] ein Die Worst-Case-Laufzeit für das Einfügen ist also O(1). Suchen chainedHashSearch(T,k) suche in der Liste T[h(k)] nach einem Element x mit x.key==k Die Worst-Case-Laufzeit vom Suchen ist also von der Länge der Liste abhängig. 8.2. KOLLISIONSBEHANDLUNG 97 Löschen chainedHashDelete(T,x) lösche x aus der Liste T[h(x.key)] Wenn wir das Element bereits gesucht haben und eine doppelt verkettete Liste verwenden, so können wir es nach der Suche in O(1) löschen. Wir betrachten jetzt den Aufwand für das Suchen etwas genauer. Dazu geben wir erst einmal eine Definition: Sei T eine Hash-Tabelle mit m ∈ N Plätzen, die gerade N 3 n > 0 Elemente speichert. Dann definieren wir den Auslastungsfaktor α der Tabelle als n α= m Weil beim Chaining auch mehr Elemente gespeichert werden können als die Hash-Tabelle Einträge hat, kann hier α auch größer als 1 sein. Das Worst-Case-Verhalten von Hashing mit Chaining tritt dann auf, wenn alle Elemente auf den gleichen Eintrag der Hash-Tabelle abgebildet werden. Dann hat das Suchen die gleiche Laufzeit wie bei einer einfach verketteten Liste, also Θ(n). Wir wollen nun das Durchschnittsverhalten von Hashing analysieren. Dazu treffen wir folgende Annahme: Gleichverteilungsannahme: Die Wahrscheinlichkeit, dass ein Schlüssel k auf die Adresse i in der Hash-Tabelle abgebildet wird, ist unabhängig von i stets gleich m1 und hängt nicht davon ab, welche Elemente bereits in der Tabelle gespeichert sind. Wir nehmen an, dass der Wert der Hash-Funktion in O(1) berechnet werden kann und die Suchdauer daher von der Länge der Liste dominiert wird. Der Erwartungswert für die Länge der Liste ist unter der Gleichverteilungsannahme offenbar gleich α. Satz 8.1 Beim Hashing mit Chaining ist unter der Gleichverteilungsannahme der Aufwand für das Suchen im Mittel O(1 + α). Beweis: Wegen der Gleichverteilungsannahme wird Schlüssel k mit gleicher Wahrscheinlichkeit auf jeden der Einträge gehasht. Wir können den Wert der Hash-Funktion in O(1) berechnen. Wir speichern die Elemente in der Hash-Tabelle in Listen. Diese haben eine mittlere Länge von α. Daher ist der Aufwand für eine Suche im Mittel α und damit der Aufwand für das Suchen O(1 + α). Korollar 8.1 Falls m proportional zu n ist, also n = O(m) gilt, ist der Aufwand für das Suchen im Mittel konstant. Da das Einfügen und Löschen (nach dem Suchen) in O(1) gehen, können daher alle drei Operationen im Mittel in konstanter Zeit ausgeführt werden. 8.2.2 Offene Adressierung Bei der offenen Adressierung werden alle Elemente in der Hash-Tabelle selbst gespeichert, ohne dass Listen verwendet werden. Wenn die Hash-Tabelle m Plätze hat, können dann natürlich auch nur maximal m Elemente gespeichert werden. 98 KAPITEL 8. HASHING Die Kollisionsbehandlung erfolgt so, dass für jeden Schlüssel k auf eine bestimme Weise eine Sondierungssequenz zur Suche nach einer Ersatzadresse angegeben wird, die nacheinander abgearbeitet wird, wenn das Element mit Schlüssel k eingefügt beziehungsweise gesucht werden soll. Falls wir ein Element suchen, das nicht in der Tabelle enthalten ist, suchen wir dabei entweder die ganze Tabelle durch oder können abbrechen, sobald wir in der Sondierungssequenz auf einen Eintrag in der Hash-Tabelle stoßen, der leer ist (wäre das Element schon gespeichert, so wäre es ja an dieser Stelle, weil die Sondierungssequenz für festes k ja jedesmal die gleiche ist). Wir erweitern also die Hash-Funktion zu einer Funktion h : U × {0, 1, . . . , m − 1} → {0, 1, . . . , m − 1} wobei das zweite Argument die Anzahl der schon erfolgten Sondierungen (beginnend bei 0) sein soll. Dabei muss gelten: Permutationsbedingung (h(k, 0), h(k, 1), . . . , h(k, m − 1)) ist eine Permutation von (0, 1, . . . , m − 1). Folgende Methode fügt ein Element in eine Hash-Tabelle T ein. Dabei sei int h(int, int) die Hash-Funktion und T die Hash-Tabelle, realsiert als Array von Integer Objekten. boolean hashInsert(Integer[] T, int k) { // returns true, if element is successfully inserted, else false int i = 0; do { int j = h(k,i); if (T[j] == NULL) { T[j] = new Integer(k); return true; } i++; } while (i!=T.length); return false; } Folgender Algorithmus sucht dann den Datensatz mit Schlüssel k: int hashSearch(Integer[] T, int k) { // returns index of hash table entry, if element is found, else -1 int i = 0; do { 99 8.2. KOLLISIONSBEHANDLUNG int j = h(k,i); if (T[j].equals(k)) return j; i++; } while (T[j]!=NULL && i!=m); return -1; } Aus einer Hash-Tabelle zu löschen, die offene Adressierung verwendet, ist im Allgemeinen schwierig. Wir können ein Element nicht einfach löschen, weil dann an dieser Stelle beim Suchen von anderen Elementen die Sondierungssequenz abbrechen könnte und daher andere Elemente nicht mehr gefunden werden, obwohl sie noch in der Tabelle enthalten sind. Eine mögliche Lösung besteht darin, einen Spezialwert GEL ÖSCHT zu definieren, den wir statt NULL in die Hash-Tabelle schreiben, damit die Sondierungssequenz nicht abbricht. Man müsste dann HashInsert ein bisschen modifizieren, damit dieser Wert dann so behandelt wird, als wäre die Tabelle an dieser Stelle leer. Das Problem ist, dass beim Verwenden von GEL ÖSCHT die Suchzeit länger wird und nicht mehr nur vom Auslastungsfaktor α abhängt. Daher wird, falls gelöscht werden muss, meistens Chaining als Kollisionsbehandlung gewählt. Wir betrachten jetzt drei verschiedene Hash Verfahren, die auf offener Adressierung basieren. Lineare Sondierung / Linear Probing Sei h0 : U → {0, 1, . . . , m − 1} eine gewöhnliche Hash-Funktion. Dann benutzt man beim Linear Probing folgende Hash-Funktion: h(k, i) := h0 (k) + i mod m Die davon generierte Sondierungssequenz hat folgende Form: h0 (k), h0 (k) + 1, h0 (k) + 2, . . . , m − 1, 0, 1, . . . , h0 (k) − 1 Ein Problem beim Linear Probing besteht darin, dass sich leicht Ketten von schon belegten Feldern bilden, weil die Wahrscheinlichkeit, dass ein Element an Stelle k eingefügt wird, wobei vor Stelle k schon i belegte Felder sind, gleich i+1 m ist. Das bezeichnet man als Primäres Clustering. Dadurch steigt die Durchschnittszeit für das Einfügen und Suchen stark an, wenn sich der Auslastungsfaktor α der 1 nähert. Das Problem wird besonders drastisch, wenn als Hash-Funktion h0 dabei die Divisionsmethode verwendet wird und direkt aufeinanderfolgende Schlüssel {k, k + 1, k + 2, . . . } eingefügt werden, weil diese dann auch auf Felder gehasht werden, die direkt aufeinanderfolgen. 100 KAPITEL 8. HASHING h0 (k) Abbildung 8.3: Lineare Sondierung Beim Linear Probing gibt es noch eine Möglichkeit, das Löschen so unterzubringen, dass der Aufwand nicht anwächst, wenn man das Löschen geringfügig abändert (siehe [Knu98]). Beim folgenden Verfahren ist das aber nicht mehr praktikabel: Quadratische Sondierung / Quadratic Probing Beim Quadratischen Sondieren wird eine Hash-Funktion der Form h(k, i) := h0 (k) + c1 i + c2 i2 mod m verwendet. h0 (k) + 12 h0 (k) + 20 h0 (k) + 2 h0 (k) + 6 h0 (k) Abbildung 8.4: Quadratische Sondierung mit c1 = c2 = 1 Dabei ist die Permutationsbedingung kompliziert zu erfüllen, denn sie hängt von c1 , c2 und m ab. Dieses Hashing-Verfahren vermeidet primäres Clustering, führt aber zu sogenanntem sekundärem Clus- 101 8.2. KOLLISIONSBEHANDLUNG tering, denn wenn Schlüssel k1 , k2 den gleichen Hash-Wert haben (h(k1 , 0) = h(k2 , 0)), so haben sie auch die gleiche Sondierungssequenz. Doppelhash / Double Hashing Beim Double-Hashing werden zwei Hash-Funktionen verwendet. Dies ist eine der besten Arten, Kollisionen durch offene Addressierung zu behandeln, weil die vom Double Hashing erzeugten Permutationen viele Charakteristika von zufälligen Permutationen besitzen. Die Hash-Funktion hat also die Form h(k, i) = (h1 (k) + ih2 (k)) mod m wobei h1 und h2 einfache Hash-Funktionen sind. Die Verwendung der zweiten Hash-Funktion behebt dabei das Problem des sekundären Clustering. Satz 8.2 (Doppelhashing) Die Permutationsbedingung ist beim Doppelhashing genau dann erfüllt, wenn die Länge m der Hash-Tabelle und h2 (k) für alle k relativ prim sind, also gilt: ∀k : ggT (m, h2 (k)) = 1 Beispiel 8.3 Wir machen erst einmal plausibel, dass die Permutationsbedingung nicht erfüllt ist wenn m und h2 (k) nicht relativ prim sind. Dazu geben wir ein Gegenbeispiel explizit an. Seien also h2 (k) = 8, m = 12. Dann ist ggT (h2 (k), m) = 4 =: d. Sei h1 (k) = 1. Die Folge der Adressen lautet dann 1, 9, 5, 1. Es werden dann also nur md = 12 4 = 3 Adressen in der Hash-Tabelle besucht. Wir betrachten diesen Sachverhalt nun genauer. Beweis: (von Satz 8.2) Definiere h2 (k) =: w und sei ggT = (m, w) =: d ∈ N. Dann gibt es p, q ∈ N mit p·d = w (8.1) q·d = m (8.2) und Sei o.B.d.A. h1 (k) = 0. Die Folge der durchlaufenen Adressen hat dann die Form 8.1 8.2 0, w, 2w, 3w, . . . , q · w = q · p · d = q · d ·p = m · p |{z} |{z} w (8.3) m Ein Vielfaches von m modulo m ist aber wieder 0. Für die Zykellänge `, also die Anzahl der Schritte, bis man zum ersten Mal wieder am Ausgangspunkt angelangt ist, gilt also: `≤q Dann sind zwei Fälle zu unterscheiden: 102 KAPITEL 8. HASHING (1) ` = q Es werden also q Adressen besucht. Da nach Voraussetzung m = q · d gilt, folgt d = 1 ⇔ q = m, also die Aussage des Satzes. (2) ` < q Nach ` Sprüngen wird das erste Mal der Ausgangspunkt wieder besucht. Aber nach q Sprüngen wird auch der Ausgangspunkt wieder besucht, also muss der Zykel der Länge ` mehrfach durchlaufen worden sein und damit ist q ein Vielfaches von `. Es gilt also: q = s · `, s>1 (8.4) Weil nach ` Sprüngen der Weite w wieder der Ausgangspunkt erreicht wird, ist also ` · w ein Vielfaches von m. Es gilt also: ` · w = r · m, r≥1 (8.5) Also gilt 8.3 8.4 8.5 p·m = q·w = s·`·w = s·r·m ⇒ p = s·r und q = s·` Es folgen 8.2 m = d ·q = d ·s·` und 8.1 w = d · p = d ·s·r Aber weil s > 1, ist d · s > d und damit teilt nicht nur d, sondern auch ds schon m und w und ist ein größerer Teiler als d. Aber nach Voraussetzung war d der größte gemeinsame Teiler von m und w. Widerspruch! Also tritt (2) nicht auf. Analyse unter Gleichverteilungsannahme Die Gleichverteilungsannahme bedeutet hier, dass die nächste Sondierung gleichverteilt unter den m Adressen eine auswählt, also jede mit Wahrscheinlichkeit m1 . Satz 8.3 Bei Auslastungsfaktor α = 1 gleich 1−α . Beispiel 8.4 α = Mittel). 1 2 ⇒ 1 1−α n m < 1 ist die erwartete Anzahl der Sondierungen beim Einfügen = 2 Sondierungen (im Mittel), α = 0, 8 ⇒ 1 1−α = 5 Sondierungen (im 103 8.2. KOLLISIONSBEHANDLUNG Der Beweis beruht auf einer allgemeinen wahrscheinlichkeitstheoretischen Aussage. Satz 8.4 (Urnenmodell) Gegeben seien m Kugeln in einer Urne, davon sind w weiß und s schwarz, w + s = m. Es werde aus der Urne gleichverteilt mit Zurücklegen gezogen. Dann ist die erwartete Anzahl von Ziehungen bis zur Ziehung einer weißen Kugel m w , also E(# Ziehungen bis zur ersten weißen Kugel) = m . w Beispiel 8.5 Ein Spezialfall des Urnenmodells ist die mittlere Zahl von Würfeln eines Würfels bis zur ersten 6. Dabei entspricht die 6 einer weißen Kugel und die Zahlen 1 bis 5 schwarzen Kugeln. Also ist w = 1 und s = 5. Jede Zahl (Kugel) wird mit derselben Wahrscheinlichkeit von 16 gezogen und das Urnenmodell ergibt E(# Würfe, bis das erste mal eine 6 geworfen wird) = 6 = 6. 1 Entsprechend ergibt sich E(# Würfe, bis das erste mal eine 1 oder eine 2 geworfen wird) = 6 = 3. 2 Bevor wir Satz 8.4 beweisen, rekapituliern wir kurz die Definition des Erwartungswertes für abzählbar diskrete Zufallsvariable. X bezeichne eine diskrete Zufallsgröße, die die Werte x1 , x2 , . . . , xn annehmen kann, wobei diese Werte mit den Wahrscheinlichkeiten p1 , p2 , . . . , pn auftreten. Der Erwartungswert (Mittelwert) von X ist definiert als n E(X) := ∑ xi · pi i=1 ∞ Im abzählbar unendlichen Fall ist der Erwartungswert definiert als E(X) = ∑ xi · pi , falls diese Reihe i=1 konvergiert. Im Urnenmodell entspricht zw dem Ziehen einer weißen, zs dem Ziehen einer schwarzen Kugel und die Ereignisse treten mit den Wahrscheinlichkeiten p, q ein, wobei p + q = 1. (8.6) Sei X die Anzahl der Ziehungen, bis zw eintritt. X ist dann eine abzählbar unendlich diskrete Zufallsvariable mit den Werten x1 = 1, x2 = 2, . . . , xi = i, . . .. Diese Werte treten mit folgenden Wahrscheinlichkeiten auf: ∧ x1 = 1 = Folge zw ∧ x2 = 2 = Folge zs zw .. . xi = i ∧ mit Wahrscheinlichkeit: p mit Wahrscheinlichkeit: q · p = Folge zs zs . . . zs zw mit Wahrscheinlichkeit: qi−1 · p =: pi | {z } (i−1)−mal 104 KAPITEL 8. HASHING Zur Kontrolle addieren wir die Wahrscheinlichkeiten noch einmal auf: ∞ ∞ ∞ i=1 i=1 i=0 1 1 ∑ pi = ∑ qi−1 · p = p · ∑ qi = p · 1 − q = p · p = 1 8.6 Wir stellen nun die Verbindung zwischen dem Sondieren beim Doppelhash und dem Urnenmodell her. Urnenmodell Ziehen einer weißen Kugel Ziehen einer schwarzen Kugel Ziehen mit Zurücklegen Hashing Hash-Funktion wählt eine leere Stelle in der Hash-Tabelle Hash-Funktion wählt eine besetzte Stelle in der Hash-Tabelle blindes Wählen eines neuen Platzes Dann folgt mit Satz 8.4: E(# Sondierungen) ≤ E(# Sondierungen bei Blindwahl) = m 1 1 = , n = m−n 1− m 1−α also der Beweis von Satz 8.3. Wir müssen daher noch die wahrscheinlichkeitstheoretische Aussage in Satz 8.4 zeigen. Beweis: (von Satz 8.4) Dazu betrachten wir Folgen von Ziehungen, bis das erste Mal eine weiße Kugel gezogen wird: 1. Mal: 2. Mal: 3. Mal: .. . w Wahrscheinlichkeit : p mit p = m Wahrscheinlichkeit : q · p mit q = Wahrscheinlichkeit : q2 · p .. . (w) (s, w) (s, s, w) .. . s m i-tes Mal: (s, s, . . . , s, w) Wahrscheinlichkeit : qi−1 · p | {z } (i−1)−mal .. . .. . .. . Der Erwartungswert für die Anzahl der Ziehungen ergibt sich dann aus der Definition, indem man jeden Wert xi = i der Zufallsvariablen X := # Ziehungen bis zur ersten weißen Kugel mit der zugehörigen Wahrscheinlichkeit pi multipliziert und diese Produkte alle aufsummiert. Er ist also E(X) = 1 · p + 2 · qp + 3 · q2 p + · · · + i · qi−1 p + . . . ∞ = ∑ i · qi−1 · p i=0 ∞ = p · ∑ i · qi−1 i=1 | {z } :=S 105 8.2. KOLLISIONSBEHANDLUNG Wir dürfen die Potenzreihe S umordnen, weil sie absolut konvergent ist. Also ist S = 1 · q0 + 2q1 + 3q2 + 4q3 + . . . ∞ = q0 + q1 + q2 + q3 + . . . +q1 + q2 + q3 . . . +q2 + q3 + . . . = i=0 ∞ q · ∑ qi ) (→ i=0 ∞ i q2 · ∑ q ) (→ ∞ +q3 + . . . (→ ∑ qi ) (→ i=0 q3 · ∑ qi ) ∞ ∞ ∞ i=0 i=0 i=0 ∞ i=0 ∑ qi + q · ∑ qi + q2 · ∑ qi + . . . = (1 + q + q2 + q3 + . . . ) ∑ qi i=0 ∞ = ∑ qi i=0 = = = ∞ ! ∑ qi ! i=0 1 1 · 1−q 1−q 1 1 · p p 1 p2 Also gilt für die erwartete Anzahl E(X) der Ziehungen bis zur ersten weißen Kugel E(X) = = w p= m = p·S 1 p m . w Korollar 8.2 Die Anzahl der Sondierungen ist im Mittel also gleich gehen Einfügen und Suchen in Hash-Tabellen im Mittel in O(1). 1 1−α und damit konstant. Also Man könnte sich nun die Frage stellen, warum wir nicht Bäume statt Hashtabellen verwenden und auch dort den Aufwand im Mittel betrachten, da sich doch das Löschen bei Bäumen sehr viel einfacher gestaltet, und wir den Vorteil der Sortierung gemäß Inorder-Durchlauf haben. Es gilt jedoch: 106 KAPITEL 8. HASHING Satz 8.5 Der mittlere Aufwand für das Einfügen und Suchen ist bei Bäumen O(log n). Beweis: Zum Beweis dieses Satzes betrachten wir das Suchen in einem Suchbaum mit n Datensätzen. Der bestmögliche (weil höhenminimale) Baum für diese Suche ist ein voller Baum, also ein Baum, bei dem alle Schichten bis auf die letzte voll sind (falls n = 2 p − 1 mit p ∈ N, so ist auch die letzte voll). Betrachte dabei einen Baum T , der auf der untersten Schicht nur ein Element enthält, so dass n−1 n= ∑ 2i + 1 = 2h − 1 + 1 = 2h i=0 gilt. Wir analysieren das Suchen im Baum T unter der Gleichverteilungsannahme, in diesem Fall also der Annahme, dass jeder Schlüssel mit der gleichen Wahrscheinlichkeit n1 gesucht wird. Dann gilt: E(# Anzahl Vergleiche zum Suchen) = ≥ = = = ∈ Summe der Vergleiche zum Suchen aller Knoten n h · #Knoten auf Schicht (h-1) n 1 · h · 2h−1 n 1 n · log n · n 2 1 log n 2 Ω(log n) Hash-Tabellen haben also den Vorteil eines konstanten mittleren Aufwandes für die Basisoperationen gegenüber Suchbäumen. Suchbäume haben dagegen einen Worst Case Aufwand von O(log n) für die Basisoperationen und beinhalten per Inorder-Durchlauf eine Sortierung der Datensätze. Eshängt also sehr von der Anwendung ab, ob Hash-Tabellen oder Suchbäume geeigneter sind. 8.3 Literaturhinweise Hashtabellen werde in nahezu allen Büchern über Datenstrukturen und Algorithmen behandelt. Die hier gewählte Darstellung lehnt sich an [CLRS01] an. Kapitel 9 Schaltkreistheorie und Rechnerarchitektur 9.1 Schaltfunktionen und Schaltnetze Wir wollen nun die logischen Grundlagen der Rechnerarchitektur besprechen. In starker Vereinfachung ist ein Rechner eine Blackbox, die zu einer bestimmen Eingabe ein bestimmte Ausgabe liefert, also abstrakt gesehen: n Blackbox m Es werden n Input-Bits in m Output-Bits transformiert. Ein Rechner entspricht also gewissermaßen einer Schaltkreisfunktion F : Bn → Bm , B = {0, 1}. Beispiel 9.1 Bei der Addition von zwei k-stelligen Binärzahlen benutzt man eine Schaltkreisfunktion F : B2k → Bk+1 . Sie bildet also 2k-Bitvektoren auf k + 1-Bitvektoren ab. F (yk−1 , yk−2 , . . . , y1 , y0 , xk−1 , xk−2 , . . . , x1 , x0 ) → (zk , zk−1 , . . . , z1 , z0 ) | {z } | {z } ∈B2k Bk+1 Ein Beispiel mit k = 2 ist in Abbildung 9.1 dargestellt. Beispiel 9.2 Beim Sortieren von 30 16-stelligen Binärzahlen kann man eine Schaltkreisfunktion S : B480 → B480 verwenden. 107 108 KAPITEL 9. SCHALTKREISTHEORIE UND RECHNERARCHITEKTUR y1 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 Input y0 x1 0 0 0 0 0 1 0 1 1 0 1 0 1 1 1 1 0 0 0 0 0 1 0 1 1 0 1 0 1 1 1 1 x0 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 Output z2 z1 z0 0 0 0 0 0 1 0 1 0 0 1 1 0 0 1 0 1 0 0 1 1 1 0 0 0 1 0 0 1 1 1 0 0 1 0 1 0 1 1 1 0 0 1 0 1 1 1 0 Interpretation y+x = z 0+0=0 0+1=1 0+2=2 0+3=3 1+0=1 1+1=2 1+2=3 1+3=4 2+0=2 2+1=3 2+2=4 2+3=5 3+0=3 3+1=4 3+2=5 3+3=6 Abbildung 9.1: Addition von 2 k-stelligen Dualzahlen als Schaltkreisfunktion. Beispiel 9.3 Primzahltest für 480-stellige Binärzahlen: p : B480 → B1 (einstelliger Output) 0 x ist keine Primzahl p(x) := 1 x ist eine Primzahl Schaltkreisfunktionen mit m = 1 heißen Boolesche Funktionen und werden mit kleinen Buchstaben bezeichnet, Schaltkreisfunktionen mit m > 1 werden mit Großbuchstaben bezeichnet. Satz 9.1 Jede Schaltkreisfunktion F : Bn → Bm mit m > 1 kann äquivalent durch m Boolesche Funktionen f1 , f2 , . . . , fm mit fi : Bn → B beschrieben werden. Beweis: Als Schaltkreisfunktion hat F die Form F(x1 , x2 , . . . , xm ) = (y1 , y2 , . . . , ym ) Setze yi =: fi (x1 , x2 , . . . , xn ). Dann ist F durch F(x1 , x2 , . . . , xn ) = ( f1 (x1 , x2 , . . . , xn ), f2 (x1 , x2 , . . . , xn ), . . . , fn (x1 , x2 , . . . , xn )) vollständig dargestellt. 109 9.1. SCHALTFUNKTIONEN UND SCHALTNETZE Zum Verstehen von Schaltkreisfunktionen reicht es also aus, Boolesche Funktionen zu verstehen. Wir werden zeigen, dass jede Boolesche Schaltkreisfunktion bereits durch die Negation, das O DER und das U ND darstellbar ist und es daher ausreicht, diese einfachen Schaltelemente in Hardware bereitzustellen um damit jede Schaltkreisfunktion bauen zu können. Wir betrachten zunächst Boolesche Funktionen mit wenigen Argumenten: n=1: f0 (x) 0 0 false x 0 1 f1 (x) 0 1 Identität f2 (x) 1 0 Negation f6 0 1 1 0 f7 0 1 1 1 f8 1 0 0 0 f9 1 0 0 1 XOR OR NOR x⇔y f3 (x) 1 1 true n=2: x 0 0 1 1 y 0 1 0 1 f0 0 0 0 0 f1 0 0 0 1 FALSE AND f2 0 0 1 0 f3 0 0 1 1 f4 0 1 0 0 f5 0 1 0 1 f10 1 0 1 0 f11 1 0 1 1 f12 1 1 0 0 f13 1 1 0 1 f14 1 1 1 0 f15 1 1 1 1 x⇒y NAND TRUE Die wichtigsten fünf dieser Operationen sind • f1 : Konjunktion (∧, AND) • f7 : Disjunktion (∨, OR) • f13 : Implikation (⇒, IMPL) • f9 : Äquivalenz (⇔, EQUIV) • f6 : Antivalenz, ausschließende Disjunktion (=, XOR) Außerdem haben eigene Namen • f8 : Piercescher Pfeil (↓, NOR) • f14 : Shefferscher Strich (↑, NAND) Zur Vorbereitung des Darstellungssatzes benötigen wir erst einige Definitionen. Zunächst werden wir Boolesche Ausdrücke im Folgenden in arithmetischer Schreibweise schreiben, also x · y oder xy für x ∧ y, x + y für x ∨ y und x für ¬x. Damit bringen wir die arithmetischen Eigenschaften von B = {0, 1} zum Ausdruck, wobei 1 + 1 = 1 gilt. 110 KAPITEL 9. SCHALTKREISTHEORIE UND RECHNERARCHITEKTUR Sei f : Bn → B eine Boolesche Funktion mit n Variablen x1 , . . . , xn . Belegungen dieser Variablen mit Werten 0 und 1 entsprechen Bitvektoren der Länge n, also allen 2n Dualzahlen von 0 bis 2n − 1. Ist i1 , . . . , in ein solcher Bitvektor, so heißt die Zahl i, deren Dualdarstellung gleich i1 , . . . , in ist, der Index zu i1 , . . . , in . Ein einschlägiger Index i von f ist ein Index, für den f die Dualdarstellung (i1 , . . . , in ) des Index auf die 1 abbildet, also f (i1 , . . . , in ) = 1 gilt. Beispiel 9.4 Sei f die durch Tabelle 9.1 definierte Boolesche Funktion. Die einschlägigen Indizes dieser Funktion sind dann 1, 2 und 5. Index i 0 1 2 3 4 5 6 7 x1 0 0 0 0 1 1 1 1 x2 0 0 1 1 0 0 1 1 x3 0 1 0 1 0 1 0 1 f (x1 , x2 , x3 ) 0 1 1 0 0 1 0 0 Tabelle 9.1: Boolesche Funktion zu Beispiel 9.4 Sei i ein Index von f : Bn → B und (i1 , i2 , . . . , in ) seine Dualdarstellung. Dann heißt die Boolesche Funktion mi : Bn → B mit mi (x1 , x2 , . . . , xn ) := x1i1 · x2i2 · · · · · xnin und i x jj := x j wenn i j = 1 x j wenn i j = 0 j = 1, . . . , n der i-te Minterm. Ein Beispiel: für n = 3 und i = 5 ergibt sich als Dualdarstellung (i1 , i2 , i3 ) von i der Bitvektor (1, 0, 1) und damit als 5-ter Minterm die Boolesche Funktion m5 (x1 , x2 , x3 ) = x1 x2 x3 . Satz 9.2 (Darstellungssatz für Boolesche Funktionen) Jede Boolesche Funktion f : Bn → B mit f 6= 0 ist eindeutig darstellbar als Summe der Minterme ihrer einschlägigen Indizes. Beispiel 9.5 Nach Satz 9.2 gilt für Beispiel 9.4 f (x1 , x2 , x3 ) = m1 (x1 , x2 , x3 ) + m2 (x1 , x2 , x3 ) + m5 (x1 , x2 , x3 ) = x1 x2 x3 + x1 x2 x3 + x1 x2 x3 9.1. SCHALTFUNKTIONEN UND SCHALTNETZE 111 Wir überprüfen das an einigen Eingaben: (x1 , x2 , x3 ) = (1, 0, 0) f (1, 0, 0) = 1̄0̄0 + 1̄00̄ + 10̄0 = 010 + 001 + 110 = 0 (x1 , x2 , x3 ) = (0, 1, 0) f (0, 1, 0) = 0̄1̄0 + 0̄10̄ + 01̄0 = 100 + 111 + 000 = 1 Beweis: Aus der Definition von Mintermen folgt mi (x1 , . . . , xn ) = 1 Wir müssen zeigen, dass ⇔ (x1 , . . . , xn ) ist die Dualdarstellung (i1 , . . . , in ) von i. (9.1) f (x1 , x2 , . . . , xn ) = ∑ mi (x1 , x2 , . . . , xn ) i∈I gilt, wobei I die Menge der einschlägigen Indizes bezeichnet. Wegen f 6= 0 ist I 6= 0/ und damit ∑i∈I mi wohldefiniert. Wir zeigen die Gleichheit argumentweise für jeden Bitvektor, indem wir eine Fallunterscheidung durchführen. Sei dazu (k1 , . . . , kn ) ein konkreter Bitvektor und k der zugehörige Index. Fall I k ist einschlägiger Index von f . Dann ist f (k1 , . . . , kn ) = 1. Wegen (9.1) ist mk (k1 , . . . , kn ) = 1 und damit wegen k ∈ I auch ∑i∈I mi (k1 , . . . , kn ) = 1. Fall II k ist kein einschlägiger Index von f . Dann ist f (k1 , . . . , kn ) = 0 und wegen (9.1) mi (k1 , . . . , kn ) = 0 für alle i ∈ I. Daher ist auch ∑i∈I mk (k1 , . . . , kn ) = 0. Bleibt noch die Eindeutigkeit der Darstellung zu zeigen. Wir nehmen dazu an, dass f auf zwei verschiedene Weisen als Summen von Mintermen darstellbar ist und führen dies zum Widerspruch. Seien also f = ∑ mi = ∑ m j i∈I j∈J zwei Darstellungen von f als Summe von Mintermen mit I 6= J. O.B.d.A. existiere ein k ∈ I \ J (sonst vertausche I und J, wegen f 6= 0 gibt es für einen der beiden Fälle ein solches k). Sei dann (k1 , . . . , kn ) die Dualdarstellung von k. Wegen (9.1) gilt dann mk (k1 , . . . , kn ) = 1 und daher ∑i∈I mi (k1 , . . . , kn ) = 1. Weil aber k nicht in J enthalten ist, folgt aus (9.1), dass m j (k1 , . . . , kn ) = 0 für alle j ∈ J, und daher 112 KAPITEL 9. SCHALTKREISTHEORIE UND RECHNERARCHITEKTUR auch ∑ j∈J m j (k1 , . . . , kn ) = 0, was ein Widerspruch zu ∑i∈I mi = ∑ j∈J m j ist. Die in Satz 9.2 konstruierte Darstellung nennt man disjunktive Normalform (DNF) einer Booleschen Funktion. Korollar 9.1 Jede Boolesche Funktion ist schon durch die logischen Operationen AND, NOT und darstellbar. Die Von Satz 9.2 nicht erfasste Ausnahme f = 0 kann zum Beispiel durch OR f = x1 x1 dargestellt werden. Der Darstellungssatz bildet die Grundlage für den Bau von Schaltkreisen. Dazu müssen nur die drei logischen Operationen AND, NOT und OR als Hardware-Bausteine vorliegen. Dies ist Aufgabe der Elektrotechnik und wir werden darauf nicht weiter eingehen sondern annehmen, dass diese Bausteine zur Verfügung stehen. Wir verwenden dafür die in Abbildung 9.2 dargestellten Symbole. Abbildung 9.2: Inverter, UND-Gatter und ODER-Gatter (von links nach rechts). Als Beispiel für die Anwendung des Darstellungssatzes betrachten wir die Konstruktion eines Addiernetzes auf Basis der stellenweise Addition von zwei k-stelligen Dualzahlen. Beispiel 9.6 Halbaddierer Ein Halbaddierer hat zwei Inputs und zwei Outputs. Er dient zur Addition der niedrigstwertigsten Bits x0 und y0 von zwei Binärzahlen x, y und liefert als Output das niedrigstwertigste Bit z0 der Ergebniszahl z und den Übertrag u1 für die 1-te Stelle. Seine Schaltkreisfunktion ist in Tabelle 9.2 dargestellt. Abbildung 9.3 gibt die Black Box“ Darstellung des Halbaddierers an. ” x0 0 0 1 1 y0 0 1 0 1 u1 0 0 0 1 z0 0 1 1 0 Tabelle 9.2: Die Schaltkreisfunktion des Halbaddierers. 113 9.1. SCHALTFUNKTIONEN UND SCHALTNETZE x0 y0 u1 HA z0 Abbildung 9.3: Halbaddierer als Black Box Der Halbaddierer besteht aus den Booleschen Funktionen u1 und z0 . Die Funktion u1 hat nur einen einschlägigen Index, nämlich i = 3. Ihre disjunktive Normalform ist also u1 (x0 , y0 ) = x0 y0 Die Funktion z0 dagegen hat zwei einschlägige Indizes, nämlich i = 1 und i = 2. Die Minterme dazu sind x0 y0 und x0 y0 und damit ergibt sich als disjunktive Normalform z0 (x0 , y0 ) = x0 y0 + x0 y0 Wir können mit diesen Bausteinen den Halbaddierer wie in Abbildung 9.4 dargestellt als Hardware realisieren. Diese Darstellungsart bezeichnet man als Schaltnetz. x0 z0 y0 u1 Abbildung 9.4: Halbaddierer als Schaltnetz. Beispiel 9.7 Volladdierer Nun betrachten wir den sogenannten Volladdierer, der zwei Ziffern xi , yi an Position i und den Übertrag ui von der vorigen Position addiert und als Ergebnis die Ziffer zi der Summe sowie den Übertrag ui+1 an der nächsten Position ausgibt. Sein Verhalten ist in Tabelle 9.3 angegeben. Für die beiden Booleschen Funktionen zi und ui+1 ergeben sich dann folgende Darstellungen als DNF: zi (xi , yi , ui ) = xi yi ui + xi yi ui + xi yi ui + xi yi ui 114 KAPITEL 9. SCHALTKREISTHEORIE UND RECHNERARCHITEKTUR xi 0 0 0 0 1 1 1 1 yi 0 0 1 1 0 0 1 1 ui 0 1 0 1 0 1 0 1 zi 0 1 1 0 1 0 0 1 ui+1 0 0 0 1 0 1 1 1 Tabelle 9.3: Die Schaltkreisfunktion des Volladdierers. ui+1 (xi , yi , ui ) = xi yi ui + xi yi ui + xi yi ui + xi yi ui Wir geben auch hierzu den entsprechenden Schaltkreis an, siehe Abbildung 9.5. Dabei verwenden wir erweiterte Gatter, die eine erhöhte Zahl von Eingängen erlauben. Es ist klar, dass sich ein Gatter mit ` Eingängen aus ` − 1 Gattern mit je 2 Eingängen zusammensetzen lässt. Die Art und Weise, in der der Volladdierer in Abbildung 9.5 konstruiert wird, ist nicht die einzige mögliche Art. Es können durchaus auch verschiedene Varianten von Innenelektronik“ dasselbe Input” Output-Verhalten haben und damit die gleiche Schaltkreisfunktion realisieren. Eine andere Möglichkeit, den Volladdierer zu konstruieren, besteht in der Verwendung des schon konstruierten Halbaddierers als Baustein wie in Abbildung 9.6. Mit Hilfe der nun bekannten Halb- und Volladdierer konstruieren wir jetzt ein sogenanntes asynchrones Paralleladdierwerk. Es heißt asynchron, weil der Schaltkreis nicht getaktet wird und es heißt Paralleladdierwerk, weil alle Inputbits nicht sequentiell, sondern parallel zur Verfügung stehen. Die Realisierung als Schaltnetz ist in Abbildung 9.7 angegeben. Die Zeit bis zum Vorliegen des gesamten Ergebnisses hängt von der Signallaufzeit ab. Diese ist für zk und zk−1 sehr lang, da alle Zwischenüberträge uk−1 , uk−2 , . . . , u2 , u1 vorher berechnet werden müssen. Dies funktioniert aber nur sequentiell. Bei Binärzahlen der Länge n haben wir also einen Schaltungsweg der Länge n. Für die Praxis ist das nicht tauglich. Eine Beschleunigung ist möglich durch sogenannte Carry-Look-Ahead“-Techniken. Das ist eine Zu” satzschaltung, die den Übertrag (zum Beispiel u5 ) bereits sofort aus y4 , x4 , y3 , x3 , y2 , x2 , y1 , x1 , y0 und x0 berechnet, in diesem Fall also eine Funktion u5 : B10 → B1 Dieses Prinzip kann dann rekursiv angewendet werden. 115 9.2. VEREINFACHUNG VON SCHALTNETZEN yi zi xi ui+1 ui Abbildung 9.5: Schaltnetz des Volladdierers. 9.2 Vereinfachung von Schaltnetzen Wie wir beim Volladdierer gesehen haben, gibt es verschiedene Möglichkeiten, die gleiche Schaltkreisfunktion in Hardware zu realisieren, so dass der dabei entstehende Schaltkreis das gleiche Verhalten zeigt. Die Frage, der wir uns nun zuwenden wollen, ist: Was ist eine gute Art, eine Schaltkreisfunktion in Hardware zu realisieren? Wie können die vorhandenen Freiheitsgrade optimal ausgenutzt werden? Eine Antwort auf diese Frage ist die Miniaturisierung: Wenn wir nur wenige Gatter verwenden, benötigen wir weniger Fläche und damit werden die Schaltkreise kleiner und die Signallaufzeiten kürzer. Wir wollen zwei Verfahren betrachten: • Verfahren von Karnaugh • Verfahren von Quine-McCluskey 116 KAPITEL 9. SCHALTKREISTHEORIE UND RECHNERARCHITEKTUR u yi ui+1 HA z xi u HA z ui zi Abbildung 9.6: Der Volladdierer mit Halbaddierern konstruiert. yk−1 xk−1 y2 ... VA uk−1 zk = uk zk−1 x2 y1 x1 VA VA z2 z1 y0 x0 HA u3 Abbildung 9.7: Schaltnetz des asynchronen Paralleladdierwerks. z0 117 9.2. VEREINFACHUNG VON SCHALTNETZEN Die beiden Verfahren beruhen logisch gesehen auf der Resolution, also der Tatsache, dass x+x = 1 gilt. Beispiel 9.8 f (x1 , x2 , x3 ) = x1 x2 x3 + x1 x2 x3 = (x1 + x1 )x2 x3 | {z } 1 = x2 x3 Die dabei erreichte Vereinfachung veranschaulichen wir durch die Schaltnetze. Ohne die Vereinfachung würden wir das Schaltnetz in Abbildung 9.8 benutzen, mit Vereinfachung das aus Abbildung 9.9. x1 f (x1 , x2 , x3 ) x2 x3 Abbildung 9.8: Nicht vereinfachtes Schaltnetz. 9.2.1 Das Verfahren von Karnaugh Das Verfahren von Karnaugh ist nur für Boolesche Funktionen f : Bn → B1 mit wenigen Argumenten geeignet (n ≤ 4), illustriert aber gut das Prinzip der Resolution. Für n = 3 benutzt man das Diagramm aus Abbildung 9.10. Zeilen- beziehungsweise Spaltennachbarn unterscheiden sich dabei zyklisch um genau ein Bit in ihrer Variablenbelegung. Für n = 4 nutzt man ein entsprechendes Diagramm mit 4 Spalten und Zeilen. Bei allen einschlägigen Indizes schreibt man dann eine 1 in das Karnaugh-Diagramm. Ein Beispiel einer Funktion für n = 4 ist in Abbildung 9.11 angegeben. Zum Beispiel ist 1011 ein einschlägiger Index und x1 x2 x3 x4 der dazugehörige Minterm. Sein Nachbar 1111 ist ebenfalls einschlägiger Index 118 KAPITEL 9. SCHALTKREISTHEORIE UND RECHNERARCHITEKTUR x1 x2 f (x1 , x2 , x3 ) x3 Abbildung 9.9: Vereinfachtes Schaltnetz. x1 x2 x3 00 01 11 10 0 1 Abbildung 9.10: Anordnung der Variablen im Karnaugh Diagramm für n = 3. mit Minterm x1 x2 x3 x4 . Offenbar kann auf im Karnaugh-Diagramm benachbarte Einsen wegen der speziellen Anordnung der Variablen Resolution angewendet werden. Hier wäre x1 x2 x3 x4 + x1 x2 x3 x4 = x1 x3 x4 Die Resolution kann dabei sowohl in Spalten als auch in Zeilen angewendet werden. Es würde zum Beispiel auch gelten: x1 x2 x3 x4 + x1 x2 x3 x4 = x1 x2 x4 Produktterme dürfen wegen der Regel x + x = x in der Resolution auch mehrfach verwendet werden: f (x1 , x2 , x3 , x4 ) = x1 x3 x4 + x1 x2 x4 + x1 x2 x4 = x1 x3 x4 + x2 x4 Wir geben noch ein Beispiel für eine weitreichende mehrfache Resolution an. g(x1 , x2 , x3 , x4 ) = x1 x2 x3 x4 + x1 x2 x3 x4 + x1 x2 x3 x4 + x1 x2 x3 x4 + x1 x2 x3 x4 = x2 x3 x4 + x2 x3 x4 + x1 x2 x4 + x1 x3 x4 = x2 x4 + x1 x2 x4 + x1 x3 x4 119 9.2. VEREINFACHUNG VON SCHALTNETZEN x1 x2 x3 x4 00 01 11 10 00 01 1 11 1 1 1 1 10 Abbildung 9.11: Karnaugh Diagramm einer Booleschen Funktion für n = 4. Das Karnaugh-Verfahren lässt sich auf größere Blöcke erweitern. Betrachten wir zum Beispiel das partielle Karnaugh Diagramm in Abbildung 9.12. Wir können es mit Resolution vereinfachen: f (x1 , x2 , x3 , x4 ) = x1 x2 x3 x4 + x1 x2 x3 x4 + x1 x2 x3 x4 + x1 x2 x3 x4 = x1 x3 x4 + x1 x3 x4 = x1 x3 x1 x2 x3 x4 00 01 00 1 1 01 1 1 Abbildung 9.12: Blöcke im Karnaugh Diagramm. Allgemein müssen die Blöcke als Seitenlängen 2er-Potenzen haben. Das allgemeine Vorgehen lautet also: Wähle möglichst große Blöcke von 2k · 2l , k, l ∈ N, die nur aus Einsen bestehen und deren Vereinigung alle Einsen enthält, und schreibe die zugehörigen Produktterme auf. Die zugehörige Boolesche Funktion ergibt sich dann als Summe dieser Produktterme, Die Funktion f in Abbildung 9.11 lässt sich mit einem 4×4 Block und einem 2×1 Block überdecken“ ” und es folgt f (x1 , . . . , x4 ) = x2 x4 + x1 x3 x4 . 120 KAPITEL 9. SCHALTKREISTHEORIE UND RECHNERARCHITEKTUR Manchmal werden nicht alle Eingabe-Bitvektoren in der Booleschen Funktion benötigt und man kann dann frei wählen, ob man für diese bitvektoren eine 1 oder eine 0 als Ergebnis haben möchte (die sogenannten don’t-cares“). Dadurch ist unter Umständen eine bessere Vereinfachung möglich. Wir ” erläutern dies an einem Beispiel. Beispiel 9.9 Wir wollen eine Schaltkreisfunktion für die folgende Funktion konstruieren: f : {0, 1, 2, . . . , 9} → {0, 1}, f (x) := 1 falls x ∈ {1, 5, 8, 9} 0 sonst Wir modellieren diese Funktion als Boolesche Funktion, wobei wir die Eingaben auf natürliche Weise durch ihre Bitvektoren Kodieren: Die Schaltkreisfunktion bezeichnen wir ebenfalls mit f , gesucht ist also f : B4 → B1 , deren Output nur für folgende Bitvektoren Eingabe 0 1 2 3 4 5 6 7 8 9 Bitvektor 0000 0001 0010 0011 0100 0101 0110 0111 1000 1001 Der Rest der möglichen Inputs wird nicht benötigt. Das zugehörige Karnaugh-Diagramm ist in Abbildung 9.13 angegeben, wobei das Ergebnis für nicht benötigte Inputs mit einem D für don’t-care“ ” gekennzeichnet ist. Indem wir alle don’t-cares auf 1 setzen, sehen wir, dass bereits folgende Schaltfunktion ausreicht: f (x1 , x2 , x3 , x4 ) = x1 + x3 x4 9.2.2 Das Verfahren von Quine und McCluskey Eine Boolesche Funktion f : Bn → B liegt in disjunktiver Form vor, wenn f als Summe von Termen k f = ∑ Mi i=1 dargestellt ist. Ein Term Mi ist ein Produkt der Form ` ∏ xj j=1 mit xα ∈ {x, x}. αj ` , `≥1 121 9.2. VEREINFACHUNG VON SCHALTNETZEN x1 x2 x3 x4 00 00 01 1 11 01 1 10 D 1 D 1 11 D D 10 D D Abbildung 9.13: Karnaugh Diagramm mit Don’t Cares Beispiel 9.10 f : B4 → B, f := x1 + x3 x4 liegt in disjunktiver Form vor. Ein Spezialfall der disjunktiven Form ist die sogenannte disjunktive Normalform; bei ihr sind alle Terme Minterme. Zur Bewertung einer Miniaturisierung führen wir jetzt ein Kostenmaß ein. Inverter betrachten wir als kostenlos. Gatter kosten eine Einheit und haben nur zwei Eingänge. Den Kosten einer Booleschen Funktion in disjunktiver Normalform entspricht die Anzahl der benötigten Gatter (mit zwei Eingängen) zum Bau eines Schaltkreises auf Basis der disjunktiven Normalform. Die Boolesche Funktion f : B4 → B, die wir mit dem erweiterten Verfahren von Karnaugh vereinfacht haben, hat in der Darstellung als Summe von 4 Mintermen Kosten 15 und in der vereinfachten Form f (x1 , x2 , x3 , x4 ) = x1 x3 x4 nur noch Kosten 2. Definition (Vereinfachungsproblem für Boolesche Funktionen) Bestimme zu einer gegebenen Booleschen Funktion f : Bn → B, die als Tabelle oder in disjunktiver Normalform gegeben ist, eine Darstellung in disjunktiver Form mit minimalen Kosten. Bei der Lösung dieses Problems spielt der Begriff des Implikanten eine große Rolle, den wir nun einführen: Sei f : Bn → B eine Boolesche Funktion. Ein Term M heißt Implikant von f (in Zeichen (M ≤ f ), wenn gilt: M(x1 , x2 , . . . , xn ) = 1 ⇒ f (x1 , x2 , . . . , xn ) = 1 Ein Implikant M von f heißt Primimplikant, falls keine echte Verkürzung (das heißt Streichung eines xi bzw. eines xi ) von M Implikant von f ist. 122 KAPITEL 9. SCHALTKREISTHEORIE UND RECHNERARCHITEKTUR Beispiel 9.11 Wir betrachten die Boolesche Funktion f (x1 , x2 , x3 ) = x1 x2 x3 + x1 x2 x3 + x1 x2 x3 . x1 x2 x3 ist ein Implikant von f , denn aus x1 x2 x3 = 1 folgt f (x1 , x2 , x3 ) = 1. x1 x2 x3 ist jedoch kein Primimplikant von f , denn wegen der Resolution hat f die Darstellung f (x1 , x2 , x3 ) = x1 x3 + x1 x2 und daher ist x1 x3 ein Implikant von f und eine echte Verkürzung von x1 x2 x3 . x1 x3 ist ein Implikant von f , denn f lässt sich als f (x1 , x2 , x3 ) = x1 x3 + x1 x2 darstellen. Weil die einzigen möglichen echten Verkürzungen x1 und x3 keine Implikanten von f sind, ist x1 x3 ein Primimplikant von f . x1 x2 x3 ist ein Implikant, jedoch kein Primimplikant, denn die echte Verkürzung x1 x2 ist ein Implikant von f . Satz 9.3 Sei f : Bn → B eine Boolesche Funktion ( f 6≡ 0). Ist d = M1 + M2 + · · · + Mk eine disjunktive Darstellung von f mit minimalen Kosten, so ist jeder Term Mi ein Primimplikant von f . Beweis: Zum Beweis nehmen wir das Gegenteil an, sei also o.B.d.A. M1 kein Primimplikant, aber M1 + M2 · · · + Mk eine kostenminimale Darstellung der Booleschen Funktion f . Weil M1 kein Primimplikant ist, hat M1 eine echte Verkürzung M 1 , die Implikant von f ist. Damit hat f eine Darstellung d = M 1 + M2 + · · · + Mk Aber dann sind die Kosten von d geringer als die von d, ein Widerspruch zur Annahme. Das Verfahren von Quine-McCluskey arbeitet wie folgt: 1. Bestimmung aller Primimplikanten von f 2. Auswahl einer kostenminimalen Untermenge, die schon f darstellt Für Schritt 1 können wir Resolution systematisch verwenden, Teil 2 entspricht dem (weiter unten behandelten) Überdeckungsproblem. Zu Schritt 1: Schritt 1 wird wie folgt durchgeführt: • Teile die Minterme in Gruppen mit gleicher Anzahl von Negationen. • Wende Resolution auf benachbarte Gruppen an. • Iteriere, bis keine Verkürzung mehr möglich ist. • Die nicht verkürzbaren Terme sind die Primimplikanten (wird unten bewiesen). Abbildung 9.14 illustriert diese Vorgehensweise. Zu Schritt 2: Die Auswahl der Primimplikanten erfolgt nach folgenden Kriterien: 123 9.2. VEREINFACHUNG VON SCHALTNETZEN Gruppen 1 2 3 4 gegeben x1 x2 x3 x4 x1 x2 x3 x4 x1 x2 x3 x4 x 1 x2 x3 x 4 x1 x2 x3 x4 x 1 x2 x 3 x 4 x1 x2 x3 x4 Runde 1 x1 x2 x4 x1 x2 x3 x2 x3 x4 x1 x2 x4 x2 x3 x4 x1 x3 x4 x2 x 4 Abbildung 9.14: Ermittlung der Primimplikanten nach Quine-McCluskey. Die Terme in den Kästen sind die Primimplikanten. • Bestimme eine Auswahl von Primimplikanten, so dass jeder Minterm durch einen Primimplikanten verkürzt wird (aber nicht unbedingt echt verkürzt). • Treffe die Auswahl so, dass die Kosten minimal werden. Dazu erstellen wir eine Tabelle, bei der die Spalten den Mintermen und die Zeilen den Primimplikanten entsprechen. Ist dann ein Primimplikant eine Verkürzung eines Minterms, so schreiben wir an die entsprechende Stelle der Tabelle eine 1, ansonsten eine 0. Abbildung 9.15 gibt ein Beispiel. Gesucht ist nun eine Auswahl von Zeilen, also Primimplikanten, so dass die zugehörigen Einsen alle Spalten, also Minterme, überdecken. Dies ist ein als algorithmisch schwierig bekanntes allgemeines Problem, das sogenannte Überdeckungsproblem. Doch dazu später mehr. x1 x 2 x3 x4 x1 x2 x3 x1 x3 x4 x2 x 4 x1 x2 x3 x4 1 x1 x2 x3 x4 x1 x2 x3 x4 x1 x2 x3 x4 1 x1 x2 x3 x4 x 1 x2 x 3 x 4 x 1 1 1 1 1 1 1 Abbildung 9.15: Die Tabelle für das Überdeckungsproblem zum Beispiel aus Abbildung 9.14. In Abbildung 9.15 gibt es nur eine mögliche Auswahl von Primimplikanten (nämlich alle). Im Allgemeinen sind jedoch mehrere Auswahlen von Mengen von Primimplikanten möglich. Ein Beispiel dazu im Karnaugh-Diagramm ist in Abbildung 9.16 angegeben. Wir zeigen nun, dass die fortgesetzte Resolution in Schritt 1 tatsächlich alle Primimplikanten ergibt. Satz 9.4 Seien M1 , M2 , . . . , Mk die Terme, die durch fortgesetzte Resolution, bis keine echte Verkürzung mehr möglich ist, aus der disjunktiven Normalform von f entstehen (dabei ist Mi 6= M j für alle i, j). Dann sind M1 , . . . , Mk genau die Primimplikanten von f . Beweis: Wir müssen zeigen: 124 KAPITEL 9. SCHALTKREISTHEORIE UND RECHNERARCHITEKTUR 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 Abbildung 9.16: Ein Überdeckungsproblem mit zwei verschiedenen Lösungen (aber gleichen Kosten). 1. Jeder Term Mi ist Primimplikant von f . 2. Es gibt keine weiteren Primimplikanten von f . Zu 1: Weil jedes Mi durch Resolution entsteht, ist jedes Mi Implikant von f . Wir nehmen nun an, dass Mi ein Implikant, aber kein Primimplikant ist. Dann gibt es eine echte Verkürzung Mi0 von Mi . Diese ist gekennzeichnet durch den Wegfall eines x j beziehungsweise eines x j . Weil Mi Implikant war, ist Mi0 ebenfalls Implikant. Betrachten wir nun folgenden Fall: Das x j beziehungsweise x j , das in Mi , aber nicht in Mi0 vorkommt, sei so mit einem Wert belegt, dass Mi = 0 wird. Belege alle anderen Variablen mit Werten so, dass Mi0 = 1 wird und gleichzeitig alle anderen Terme M j = 0 werden. (Das ist möglich, da sich die anderen Terme nach Voraussetzung von Mi unterscheiden). Sei y = (y1 , y2 , . . . , yn ) die entstehende Belegung der xi mit 0 bzw. 1. Dann gilt Mi0 (y) = 1, M` (y) = 0, ` = 1, . . . , k, und es folgt k f (y) = ∑ Mi (y) = 0. `=1 Aber nach Voraussetzung war Mi0 Implikant von f , so dass aus Mi0 (y) = 1 auch f (y) = 1 folgt. Dies ist ein Widerspruch. Zu 2: Wir nehmen also an, dass noch ein weiterer Primimplikant M von f existiert. Wir betrachten in der disjunktiven Normalform alle Minterme mi , für die M eine Verkürzung ist. Sei I die Indexmenge dieser i. Dann können wir aus der Summe über alle diese mi , i ∈ I den Term M ausklammern: ∑ mi = M(∑ m0i ) i∈I i∈I 125 9.2. VEREINFACHUNG VON SCHALTNETZEN Falls nun ∑i∈I m0i = 1 gilt, so ist nichts mehr zu zeigen, denn dann entsteht M durch Resolution und wir sind fertig, denn die Terme, die durch Resolution entstehen, sind genau die Terme, die wir im Verfahren von Quine-McCluskey erhalten. Sei also ∑i∈I m0i 6= 1. Dann gibt es eine Belegung y0 der Variablen in ∑i∈I m0i , die den Wert 0 ergibt. Wir können die restlichen Variablen so belegen, dass M(y) = 1 gilt und dass mi (y) = 0 für alle i 6∈ I, i einschlägiger Index von f , erfüllt ist. Es folgt f (y) = 0, aber M(y) = 1. Dies ist ein Widerspruch dazu, dass M ein Implikant von f ist. 9.2.3 Das Überdeckungsproblem Das Überdeckungsproblem ist von übergeordneter Bedeutung und tritt an vielen Stellen in der angewandten Mathematik auf. Zum Beispiel bei der Zuordnung von Mitarbeitern i (Zeilen der Tabelle) zu Aufgaben j (Spalten der Tabelle), wobei eine 1 bedeutet, dass der Mitarbeiter i die Aufgabe j bearbeiten kann. Zusätzlich kann man eine Gewichtung wi j einführen, die angibt, wie gut Mitarbeiter i Aufgabe j bearbeiten kann. Das Überdeckungsproblem besteht in der ungewichteten Version darin, eine Auswahl von Mitarbeitern zu finden, so dass alle Aufgaben bearbeitet werden. In der gewichteten Version möchte man zusätzlich die Qualität maximieren, also die Summe aller Gewichte wi j der einer Aufgabe zugeordneten Mitarbeiter möglichst groß machen. Das Überdeckungsproblem ist fundamental schwerer als alle Probleme, die wir bisher in der CoMa behandelt haben. Alle bisherigen Aufgaben waren effizient (lies: in polynomialer Zeit) lösbar. Zum Beispiel: Optimaler Präfixcode Kürzeste Wege Optimaler statischer Suchbaum Sortieren .. . O(n log n) ≤ O(n2 ) O(n4 ) O(n3 ) O(n log n) ≤ O(n2 ) .. . Die Klasse der Probleme, für die ein solcher polynomialer Algorithmus existiert, bezeichnet man mit P. Für das Überdeckungsproblem ist offen, ob es einen solchen Algorithmus gibt. Nach der Klassifizierung der Schwierigkeit von Problemen gehört das Überdeckungsproblem zu der Klasse NP besonders schwieriger Probleme (das wird in den Vorlesungen ADM I oder Effiziente Algorithmen präzisiert). Abbildung 9.17 gibt eine Übersicht über einige Komplexitätsklassen. Es ist ein offenes Problem, ob P = NP oder P 6= NP gilt. Würde man einen effizienten Algorithmus für das Überdeckungsproblem finden, würde das P = NP implizieren. Für die Lösung dieses Problems hat das Clay Institute of Mathematics 1.000.000$ ausgeschrieben. 126 KAPITEL 9. SCHALTKREISTHEORIE UND RECHNERARCHITEKTUR Universum aller Probleme Halteproblem berechenbare Probleme NP P exponentielle Laufzeit effizient lösbar (zum Beispiel Kürzeste-Wege-Problem) Abbildung 9.17: Komplexitätsklassen. 9.3 Schaltungen mit Delays Wir haben bisher nur azyklische Schaltwerke betrachtet und haben keine Rückkopplung zugelassen. Das schränkt die Konstruktionsmöglichkeiten aber sehr ein und wir möchten jetzt dazu übergehen, in unseren Schaltungen bereits berechnete Ergebnisse erneut als Input zu verwenden. Beispiel 9.12 (Ringzähler) (siehe Übung) F(xn−1 , xn−2 , . . . , x0 ) = ( | {z } Binärdarstellung von x y , y , . . . , y0 |n−1 n−2 {z } ) Binärdarstellung von x+1 mod z Einen Ringzähler benutzt man dazu, Zeitschritte im Rechner zu zählen. Die herkömmlich Realisierung als Schaltfunktion ist zwar möglich, aber nicht sinnvoll, weil der Ringzähler die Zeitschritte ja zyklisch zu zählen hat. Dafür ist es offensichtlich nötig, die Ausgabe des Zählers als neue Eingabe aufzufassen. Eine solche Rückkopplung ist jedoch nicht so ohne Weiteres möglich und führt zu undefiniertem Outputverhalten, wie die Flimmerschaltung in Abbildung 9.18 zeigt. Zur Realisierung der Rückkopplung benötigt man ein neues Schaltelement, das sogenannte Delay. Dieses ist in der Lage, ein Bit zu speichern und gemäß eines äußeren, getakteten Signals auszugeben. Es ist schematisch in Abbildung 9.19 dargestellt. Bei einem Delay werden zwei Phasen unterschieden: 127 9.3. SCHALTUNGEN MIT DELAYS 0/1 0 1/0 Abbildung 9.18: Flimmerschaltung. Der Output flimmert“ zwischen 0 und 1. ” xi V S yi Fan-Out Takt Abbildung 9.19: Delay mit Fanout. • Arbeitsphase: In der Rechenphase wird der Inhalt von S als Signal yi abgegeben und das Signal xi in V abgelegt. • Setzphase: In der Setzphase wird durch einen Taktgeber die Sperre zwischen V und S aufgehoben und dadurch der Inhalt von S durch den Inhalt von V ersetzt. Die Setzphase erfolgt zentral gesteuert durch Signalleitungen für alle Delays gleichzeitig; die Zeit zwischen zwei Signalen wird die Taktzeit genannt. Fan-Out bedeutet dabei, dass der Ausgang an mehrere Schaltelemente gleichzeitig weitergereicht wird. Delays sind auf elektronischer Ebene durch sogenannte Latches oder Flipflops realisierbar, uns interessiert aber nur die logische Seite. Wir können die Flimmerschaltung nun so modifizieren, dass sie abwechselnd 0 und 1 pro Takt ausgibt, siehe Abbildung 9.20. Ein Delay kann so aber nur ein Bit speichern. Begrifflich fasst man mehrere Delays, genauer 2erPotenzen von Delays, zu einem sogenannten Register zusammen, siehe Abblildung 9.21. Inhalte von Registern werden als Oktal- oder Hexadezimalziffern dargestellt. Der Inhalt entspricht dabei dem Wort im Register. Operationen im Rechner erfordern i. A. mehrere Takte. Um den richtigen Takt für das Ablesen des Ergebnisses einer mehrtaktigen Operation festzustellen, wird ein Ringzähler benutzt. 128 KAPITEL 9. SCHALTKREISTHEORIE UND RECHNERARCHITEKTUR 0 ... 0/1 Abbildung 9.20: Flimmerschaltung mit Delay. ... ... ... ... Wortlänge Abbildung 9.21: Register. 9.3.1 Addierwerke Wir haben schon Addierwerke kennengelernt, die in der Lage sind, für Binärzahlen einer festen Stellenzahl n zu addieren. Dabei haben wir nicht berücksichtigt, woher die Summanden kommen und wohin das Ergebnis dann geht. Darum wollen wir uns jetzt kümmern. Dazu treffen wir folgende Vereinbarung: Ein Addierwerk soll zwei Register enthalten, einen Akkumulator, der am Beginn den einen Summanden enthält, und einen Puffer, der den anderen Summanden enthält. Das Ergebnis der Rechnung soll dann wieder im Akkumulator stehen. Wir kennen schon das asynchrone (ohne Takt arbeitende) Paralleladdierwerk, das nach der gerade getroffenen Vereinbarung die in Abbildung 9.22 angegebene Form hat. Wir erweitern dieses Addierwerk jetzt mit einem Delay, das den Übertrag aufnimmt (bei Addition von zwei n-stelligen Binärzahlen kann eine n + 1-stellige Binärzahl entstehen). Unser synchrones, also getaktetes, 4-Bit-Addierwerk hat die in Abbildung 9.23 angegebene Form. Die Schaltung zeigt, dass einige Schaltelemente mehrere Inputs haben (Fan-In), die je nach Takt Input von außen oder Zwischenergebnis einer internen Rechnung sein können. Wir gehen später darauf ein, wie dieses Problem gelöst wird. Ein Nachteil dieses Synchronaddierwerks besteht darin, dass die Signallaufzeiten vergleichsweise lang sind, da die Volladdierer auf den Übertrag der vorigen Stufe“ warten müssen. Wir müssen also ent” weder lange Taktzeiten verwenden oder mehrere Takte lang auf das Ergebnis warten, also eine Zusatzschaltung mit Ringzähler verwenden. 129 9.3. SCHALTUNGEN MIT DELAYS Akkumulator (1. Input) ... (in Register) Addierwerk ... Puffer (2. Input) (in Register) Abbildung 9.22: Asynchrones Paralleladdierwerk. x3 x2 x1 x0 VA VA VA HA y3 y2 y1 y0 Akkumulator Übertrag an Stelle n + 1 Puffer Abbildung 9.23: Synchrones Paralleladdierwerk. Abhilfe schafft ein serielles Addierwerk, das wir jetzt betrachten wollen (siehe Abbildung 9.24. Dabei sind Akkumulator und Puffer Schieberegister. Wir verwenden Rechts-Verschieben, bei jedem Schiebevorgang wird die am weitesten linke Stelle frei und der Inhalt des am weitesten rechts stehenden Delays kommt nach dem Verschieben im Register nicht mehr vor. Das serielle Addierwerk ist leicht auf mehr Stellen erweiterbar, man benötigt lediglich größere Register, aber keine zusätzliche Logik, die beim Paralleladdierwerk erforderlich wäre. Die Signallaufzeit ist jetzt kurz, aber das serielle Addierwerk liefert das Ergebnis erst nach n Schritten. Nun stellt sich natürlich die Frage, welches von den beiden Addierwerken in der Praxis eingesetzt wird. Die Antwort darauf lautet: Es wird keines der beiden Addierwerke eingesetzt. Man kann nämlich die Vorteile der beiden Addierwerke verknüpfen. Dazu kombiniert man die beiden Addierwerke zu einem Addierwerk, dessen Schrittzahl von den Summanden abhängt – zum sogenannten von-NeumannAddierwerk. Der logischen Aufbau dieses Addierwerkes ist in Abbildung 9.25 dargestellt. Der Witz beim von Neumann Addierwerk besteht darin, Überträge erst in späteren Runden zu verarbeiten. Im Puffer sind dabei Überträge, im Akkumulator das Zwischenergebnis gespeichert. Das Delay S gibt an, ob die Rechnung beendet ist. Sie ist genau dann beendet, wenn keine Überträge mehr 130 KAPITEL 9. SCHALTKREISTHEORIE UND RECHNERARCHITEKTUR A3 A2 A1 A0 x3 x3 x3 x3 0 y3 y2 y1 y0 P3 P3 P3 P3 VA Abbildung 9.24: Synchrones serielles Addierwerk. vorhanden sind. Daher hängt die Anzahl der Runden von den entstehenden Überträgen und damit vom Input ab. Tabelle 9.26 zeigt ein Beispiel. Bzgl. der Anzahl der Runden gilt folgender Satz: Satz 9.5 Die Anzahl der Runden bei bei der Addition von zwei n-stelligen Dualzahlen mit dem vonNeumann-Addierwerk beträgt im Mittel log n . Der Grund dafür ist, dass sich die erwartete Anzahl von Einsen in der Überträgen pro Runde halbiert. Wir geben keinen genauen Beweis für diesen Sachverhalt an, sondern wenden uns dem noch nicht geklärten Problem des Fan-In zu. 9.3.2 Das Fan-In-Problem Ein Delay kann also mehrere Eingänge haben, von denen taktabhängig aber nur einer berücksichtigt werden soll. Abbildung 9.27 zeigt den Standardfall. Abhängig vom Takt soll entweder I (neuer Input) oder R (Rechenergebnis) die Eingabe für das Delay sein. Wir überlegen uns eine Boolesche Funktion, die das Problem löst. Eine solche Boolesche Funktion muss folgendes Verhalten zeigen: f (S, I, R) := I falls S = 0 R falls S = 1 Die Boolesche Variable S unterscheidet dabei, ob der Input von I oder von R kommt. Die Wertetabelle dieser Booleschen Funktion ist in Abbildung 9.28 dargestellt. Eine Realisierung als Schaltznetz zeigt Abbildung 9.29. 131 9.4. PLAS UND DAS PRINZIP DER MIKROPROGRAMMIERUNG A3 A2 A1 A0 Akkumulator U HA HA HA HA P3 P2 P1 P0 Puffer s Abbildung 9.25: von Neumann Addierwerk. 9.4 PLAs und das Prinzip der Mikroprogrammierung Als Programmierbares Logisches Array (PLA) bezeichnet man einen standardisierten Baustein zur Realisierung von Schaltfunktionen (auf Basis von Booleschen Funktionen in disjunktiver Form). Die Schaltfunktion kann durch Mikroprogrammierung“ eingestellt beziehungsweise verändert werden. ” Wir zeigen nun, wie sich solche Bausteine mit den bisherigen Überlegungen realisieren lassen. 9.4.1 Aufbau eines PLAs Ein PLA hat prinzipiell die in Abbildung 9.30 angegebene Form. Auf jedem Knotenpunkt kann genau eins von vier verschiedenen Schaltelementen benutzt werden. Dies sind in Abbildung 9.31 dargestellt. Typischerweise haben PLAs zwei Ebenen, die UND- und die ODER-Ebene. In der UND-Ebene werden Terme für erzeugt und anschließend in der ODER-Ebene zu disjunktiven Formen addiert. Dies ist schematisch in Abblildung 9.32 illustriert. Wir erläutern die Realsierung einer Schaltfunktion mit einem PLA exemplarisch an der Schaltfunktion F(x, y, z) = (yz + xyz, xz + xyz). Die entsprechende Belegung des PLA mit Bausteinen ist in Abbildung 9.33 angegeben. Kommen wir nun zur Realisierung der PLA-Schaltelemente. Die vier Bausteine lassen sich direkt aus 132 KAPITEL 9. SCHALTKREISTHEORIE UND RECHNERARCHITEKTUR Summand I Summand II Ergebnis ohne Überträge Überträge Ergebnis ohne Überträge Überträge Ergebnis ohne Überträge Überträge Ergebnis 1 1 0 1 0 1 1 1 0 0 0 1 1 0 1 0 1 1 1 0 0 0 0 0 1 1 0 0 0 0 0 1 1 1 0 1 0 1 1 0 0 0 0 1 1 1 0 0 0 0 0 1 1 0 0 0 0 0 0 0 Abbildung 9.26: Eine Rechnung im von Neumann Addierwerk. R Delay I Abbildung 9.27: Fan-In bei einem Delay. ihrer disjunktiven Normalform als Schaltkreise realisieren. Diese sind in Abbildung 9.34 angegeben. Auf einem PLA mit n Inputs, m Outputs und k Spalten können also alle Schaltfunktionen realisiert werden, die höchstens n Inputs haben, höchstens m Outputs und insgesamt höchstens k Terme (gegebenenfalls mit Quine-McCluskey reduzieren). Es gibt eine Kurzschreibweise für PLAs, in der nur die Typen 1, 2, 3, 4 der Bausteine notiert werden, vgl. Abbildung 9.35 für das letzte Beispiel. Es gibt natürlich i. A. verschiedene Möglichkeiten zur Realisierung einer Schaltkreisfunktion auf dem einem PLA. Für die bereits betrachtete Schaltkreisfunktion F(x, y, z) = (yx + xyz, xz + xyz) | {z } | {z } u v können wir die Komponenten u und v auch folgendermaßen ausdrücken u = yz + xyz = (x + x)yz + xyz = xyz + xyz + xyz v = xz + xyz = x(y + y)z + xyz = xyz + xyz + xyz, und damit das PLA alternative wie in Abbildung 9.36 belegen. Dies realisiert die gleiche Schaltfunktion in nicht optimierter disjunktiver Normalform auf einem PLA derselben Größe. Wenn also das PLA groß genug ist, muss man die disjunktive Form nicht optimieren. 9.4. PLAS UND DAS PRINZIP DER MIKROPROGRAMMIERUNG S 0 0 0 0 1 1 1 1 I 0 0 1 1 0 0 1 1 R 0 1 0 1 0 1 0 1 133 Output 0 0 1 1 0 1 0 1 Abbildung 9.28: Fan-In Schaltfunktion. Delay 0 0 S I R 1 0 0 Abbildung 9.29: Fan-In Schaltkreis. 9.4.2 Zur Programmierung von PLAs In der bisherigen Betrachtung haben wir nur logische Arrays verwendet, aber noch nicht ihre Programmiermöglichkeiten berücksichtigt. Diese ergibt sich dadurch, dass man den Bausteintyp durch eine zusätzliche Steuerung verändern kann. Dazu bringt man an jedem Baustein eines PLAs Steuerleitungen an, die bestimmen, ob dieser Baustein gerade Typ 0, 1, 2 oder 3 realisiert. Da 4 verschiedene Zustände angesteuert werden sollen, benötigt man dafür zwei Steuerleitungen. Bezeichnet man die Inputs dieser Steuerleitungen mit s und t, so kann das Verhalten eines Bausteins durch folgende Tabelle beschrieben werden. Bausteintyp 0 1 2 3 s 0 0 1 1 t 0 1 0 1 u y x+y y y v x x xy xy Hieraus berechnet man sofort u = y + stx, v = sx + stxy + stxy und erhält die Schaltung aus Abbildung 9.37. Diese Bauweise erlaubt also die Steuerung des Bausteintyps über die Steuerleitungen s und t. 134 KAPITEL 9. SCHALTKREISTHEORIE UND RECHNERARCHITEKTUR auf jedem Gitterpunkt 4 Typen von Schaltelementen Abbildung 9.30: Schema eines PLA. x y y 0 x 1. Identer x x y x+y 1 x 2. Addierer y x y 2 xy 3. Multiplizierer y y 3 xy 4. Negatmultiplizierer Abbildung 9.31: Die vier Schaltelemente eines PLA. Auf dem gesamten PLA benötigt man 2 Signale pro Baustein, also insgesamt einen Vektor mit 2(n + m) · k Bits zur Steuerung aller Bausteine des PLA. Dieser Vektor ist sehr lang; zur Vereinfachung werden verschiedene Steuervektoren daher über ein ROM (read-only memory) verwaltet. Das ROM selbst wird wieder durch ein PLA realisiert, dessen Bauweise in Abbildung 9.38 angegeben ist. Ein solches ROM hat in der UND-Ebene die üblichen PLA-Bausteine, in der ODER-Ebene stehen pro Spalte die Werte eines Signalvektors zur Steuerung eines anderen PLAs. Die UND-Ebene des ROMs dient zur Ansteuerung einer ganz bestimmen Spalte, woraufhin der Inhalt der Spalte der ODER-Ebene ausgelesen wird (also der Inhalt der Steuersignale). Dazu werden in der UND-Ebene in Spalte i genau die Bausteine benutzt, so dass an die ODER-Ebene eine 1 genau dann weitergegeben wird, wenn als Input die Binärdarstellung von i vorliegt. In Spalte i der UND-Ebene stehen also die Bausteine b1 , b2 , . . . , b` mit 2 wenn Bitdarstellung von i an Stelle j den Wert 1 hat bj = 3 wenn Bitdarstellung von i an Stelle j den Wert 0 hat 135 9.5. LITERATURHINWEISE Termerzeugung UND-Ebene Addition von Termen ODER-Ebene Abbildung 9.32: UND/ODER Ebenen eines PLA. Die Eingabe der Bitfolge einer Addresse bewirkt also, dass genau die zugehörige Spalte der ODEREbene am Output erzeugt wird und keine andere. Ein konkretes Beispiel ist in Abbildung 9.39 angegeben. Abbildung 9.40 zeigt diesen Vorgang schematisch. Das Einlesen einer Adresse bewirkt die Ausgabe genau eines Vektors von Steuersignalen an ein PLA, das dann entsprechend eine Schaltfunktion realisiert. Diese Idee kann noch erweitert werden, indem Steuersignale nicht aus einem ROM abgerufen werden, sondern (teilweise) direkt aus Schaltkreisen heraus erzeugt werden. Dadurch kann der Rechner also das Verhalten seiner PLAs und damit sein eigenes Verhalten auf der Hardware-Ebene ändern! Diese Möglichkeit bezeichnet man als Mikroprogrammierung. Sie ist schematisch in Abbildung 9.41 dargestellt. 9.5 Literaturhinweise Dieses Kapitel folgt der Darstellung in [OV03]. 136 KAPITEL 9. SCHALTKREISTHEORIE UND RECHNERARCHITEKTUR 1 1 x 0 1 1 2 2 2 2 0 2 2 2 3 1 y 3 y z 2 yz xyz xyz 1 1 0 0 0 1 1 u = yz + xyz yz ODEREbene 0 v = xz + xyz Abbildung 9.33: PLA Belegung für die Schaltfunktion F(x, y, z) = (yz + xyz, xz + xyz). x y x y x y y y x y x·y 1. Identer 2. Addierer 3. Multiplizierer x·y 4. Negatmultiplizierer Abbildung 9.34: Schaltkreise für die Bausteine eines PLA. 137 9.5. LITERATURHINWEISE 0 2 2 2 3 2 0 2 2 2 2 3 1 1 0 0 0 0 1 1 Abbildung 9.35: Kurzschreibweise für den PLA zur Schaltkreisfunktion F(x, y, z) = (yz + xyz, xz + xyz). x 2 3 2 2 y 3 3 2 2 z 2 2 2 3 xyz xyz xyz xyz 1 1 1 0 1 0 1 1 Abbildung 9.36: Alternative PLA Belegung zur Schaltkreisfunktion F(x, y, z) = (yz + xyz, xz + xyz). s x t y u v u = y + stx v = sx + stxy + stxy Abbildung 9.37: PLA Baustein mit Steuerleitungen. 138 KAPITEL 9. SCHALTKREISTHEORIE UND RECHNERARCHITEKTUR dlog Le Zeichen für Bitkodierung der Spalten Addresse der Spalte U ND -E BENE O DER -E BENE L jede Spalte des ROM entspricht einem Vektor von Steuersignalen zur Erzeugung einer ganz bestimmten Schaltfunktion auf dem Ausgangs-PLA Abbildung 9.38: Funktionsweise eines ROM als PLA. Baustein-Typen 1 3 3 3 3 2 2 2 2 0 3 3 2 2 3 3 2 2 1 3 2 3 2 3 2 3 2 0 0 0 0 0 1 0 0 6 7 3 3 3 3 Addressen 0 1 2 3 4 5 ein Vektor von Steuersignalen an Addresse 5 Abbildung 9.39: Beispiel für ein ROM. Die Adresse (1, 0, 1) wird in der UND-Ebene dekodiert und der Inhalt (der Steuervektor (3, 3, 3)) in der ODER-Ebene ausgegeben. 139 9.5. LITERATURHINWEISE 1 0 1 0 ... b1 b2 .. . ... ... dlog Le Zeichen b` L Addressen/Vektoren Steuersignal - Vektor an Addresse ` PLA Steuersignale Abbildung 9.40: Beispiel für ein ROM. Die Adresse (1, 0, 1) wird in der UND-Ebene dekodiert und der Inhalt (der Steuervektor (3, 3, 3)) in der ODER-Ebene ausgegeben. Worte, gewisse Steuerleitungen x Delays PLA ein Teil des Outputs des PLAs ist Teil der Steuersignale für nächsten Takt Abbildung 9.41: Das Prinzip der Mikroprogrammierung. 140 KAPITEL 9. SCHALTKREISTHEORIE UND RECHNERARCHITEKTUR Literaturverzeichnis [CLRS01] Thomas H. Cormen, Charles E. Leiserson, Ronald R. Rivest, and Clifford Stein. Introduction to Algorithms. The MIT Press, Cambridge, MA, second edition, 2001. 1.3, 2.3, 3.5, 4.2, 5.4, 6.4, 7.3, 8.3 [HU79] John E. Hopcroft and Jeffrey D. Ullman. Introduction to Automata Theory, Languages, and Computation. Addison-Wesley, Reading, NY, 1979. 1.3 [Knu71] Donald E. Knuth. Optimum binary search trees. Acta Inform., 1:14–25, 1971. 6.4 [Knu98] Donald. E. Knuth. The Art of Computer Programming, volume 3 Sorting and Searching. Addison-Wesley, Reading, NY, second edition, 1998. 2.3, 4.2, 8.1.2, 8.2.2 [OV03] Walter Oberschelp and Gottfried Vossen. Rechneraufbau und Rechnerstrukturen. Oldenburg Verlag, München, 9 edition, 2003. 9.5 [OW02] Thomas Ottmann and Peter Widmayer. Algorithmen und Datenstrukturen. Spektrum Akademischer Verlag, 2002. 4. Auflage. 2.3, 4.2 [SS02] Gunter Saake and Kai-Uwe Sattler. Algorithmen und Datenstrukturen: eine Einfürung mit Java. dpunkt.verlag, Heidelberg, 2002. 2.3, 4.2 141 Index Äquivalenz, 111 Überdeckungsproblem, 127 Addierer, 138 Addierwerk asynchrones Parallel-, 131 serielles, 132 synchrones Parallel-, 131 Von-Neumann-, 133 AND, 111 Antivalenz, 111 Auslastungsfaktor, 99 AVL-Baum, 55 B-Baum, 85 Balance, 55 Basisoperationen in AVL-Bäumen, 63 in B-Bäumen, 88 Bäume, 15 binäre, 16 Traversierung von, 22 Block, 85 Blockcode, 29 Boolesche Funktion, 110 Bucketsort, 1 einfaches, 2 Chaining, 98 Code, 29 adaptiver, 40 dynamischer, 40 statischer, 40 Codierung, 29 zeichenweise, 40 Delay, 129 Disjunktion, 111 disjunktive Normalform, 114 Divisionsmethode, 97 DNF, 114 Doppelrotation, 59 Double Hashing, 102 dynamische Optimierung, 82 echte Verkürzung, 123 Einfügen eines Knotens in AVL-Bäumen, 64 in B-Bäumen, 88 Entropie, 43 Fan-In, 134 Flimmerschaltung, 129 Gleichverteilungsannahme, 99 Halbaddierer, 114 Hash-Funktion, 96 Hashing, 95 Double, 102 Häufigkeitsverteilung, 73 Huffman Code, 33 adaptiver, 41 Identer, 138 Implikant, 123 Prim-, 123 Implikation, 111 Index, 111 einschlägiger, 112 Inorder-Durchlauf, 22 Iterator, 25 142 143 INDEX Karnaugh-Verfahren, 119 Kollision, 95 Kollisionsbehandlung, 95 Konjunktion, 111 Lempel-Ziv Code, 41 Löschen eines Knotens in AVL-Bäumen, 67 in B-Bäumen, 90 Minterm, 112 Multiplikationsmethode, 97 Multiplizierer, 138 Negatmultiplizierer, 138 normierte Häufigkeit, 43 Offene Adressierung, 100 Open Addressing, 100 Optimale Substruktur, 38, 73 Optimalität, 37, 72 asymptotische, 44 OR, 111 Paralleladdierwerk asynchrones, 131 synchrones, 131 Permutationsbedingung, 100 Pierce-Pfeil, 111 PLA, 135 Postorder-Durchlauf, 22 Präfixcode, 31 Preorder-Durchlauf, 22 Primimplikant, 123 Priority Queue, 25 Probing linear, 101 quadratic, 102 Quine-McCluskey-Verfahren, 123 Redundanz, 43 Register, 130 Resolution, 118 Ringzähler, 129 Rotation, 49, 59 run length code, 41 Schaltkreisfunktion, 109 Schaltkreistheorie, 109 Schaltnetz, 116 Sheffer-Strich, 111 Sondierung lineare, 101 quadratische, 102 Splitten eines Knoten, 88 Suchbaum, 45 -eigenschaft, 46 Einfügen im, 46 Löschen im, 47 optimaler statischer, 71 Suchen im, 46 Suchbaumeigenschaft, 46 Suchen eines Knotens in AVL-Bäumen, 64 in B-Bauen, 88 Urnenmodell, 105 variable length code, 30 Verfahren von Karnaugh, 119 Quine-McCluskey, 123 verlustfrei, 29, 40 Volladdierer, 116 XOR, 111 Zugriffshäufigkeit, 72 Zugriffszeit, 73