14 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]