D äume, Heaps Alle bisher betrachteten Strukturen waren linear in dem Sinn, dass jedes Element höchstens einen Nachfolger hat. In einem Baum kann jedes Element keinen, einen oder beliebig viele Nachfolger haben. Bäume sind wichtig als Strukturen in der Informatik, da sie auch oft im Alltag auftauchen: zum Darstellen von Abhängigkeiten oder Strukturen, als Organigramme von Firmen, als Familienstammbaum, aber auch zum Beschleunigen der Suche. Definition: Ein Graph ist definiert als ein Paar B = (E, K) bestehend aus je einer endlichen Menge E von Ecken (Knoten, Punkten) und einer Menge von Kanten. Eine Kante wird dargestellt als Zweiermenge von Ecken {x, y}, den Endpunkten der Kante. Ein Baum ist ein Graph mit der zusätzliche Einschränkung, dass es zwischen zwei Ecken nur eine (direkte oder indirekte) Verbindung gibt1 . Wir befassen uns hier zuerst vor allem mit einer besonderen Art von Bäumen: den Binärbaumen. Ein Baum heisst binär, falls jeder Knoten höchstens zwei Nachfolger hat. 1 Ein Baum ist ein zusammenhängender Graph ohne Zyklen. 4 4 Datentypen: Bäume, Heaps Ein binärer Baum besteht aus einer Wurzel (Root) und (endlich vielen) weiteren Knoten und verbindenden Kanten dazwischen. Jeder Knoten hat entweder keine, ein oder zwei Nachfolgerknoten. Ein Weg in einem Baum ist eine Liste von disjunkten, direkt verbunden Kanten. Ein binärer Baum ist vollständig (von der Höhe n), falls alle inneren Knoten zwei Nachfolger haben und die Blätter maximal Weglänge n bis zur Wurzel haben. Jedem Knoten ist eine Ebene (level) im Baum zugeordnet. Die Ebene eines Knotens ist die Länge des Pfades von diesem Knoten bis zur Wurzel. Die Höhe (height) eines Baums ist die maximale Ebene, auf der sich Knoten befinden. Ein binärer Baum besteht also aus Knoten mit einem (Zeiger auf ein) Datenelement data , einem linken Nachfolgerknoten left und einem rechten Nachfolgerknoten right . left p !"# $ protected T data; protected BinaryTreeNode<T> leftChild; protected BinaryTreeNode<T> rightChild; right 4% public BinaryTreeNode(T item){ data=item; } // tree traversals public BinaryTreeNode<T> inOrderFind(T item) { . . . } public BinaryTreeNode<T> postOrderFind(T item) { . . . } public BinaryTreeNode<T> preOrderFind(T item) { . . .} // getter and setter methods . . . public class BinaryTree<T> { protected BinaryTreeNode<T> rootTreeNode; public BinaryTree(BinaryTreeNode<T> root) { this.rootTreeNode = root; } // tree traversals public BinaryTreeNode<T> inOrderFind(T item) { return rootTreeNode.inOrderFind(item); } public BinaryTreeNode<T> preOrderFind(T item) { ... } public BinaryTreeNode<T> postOrderFind(T item) { ... } public BinaryTreeNode<T> postOrderFindStack(T item) { ... } //getter and setter methods . . . 44 4 Datentypen: Bäume, Heaps 4.1 Baumdurchläufe Bäume können auf verschiedene Arten durchlaufen werden. Die bekanntesten Verfahren sind Tiefensuche (depth-first-search, DFS) und Breitensuche (breadth-first-search, BFS). Tiefensuche kann unterschieden werden in die drei Typen präorder, postorder und inorder, abhängig von der Reihenfolge der rekursiven Aufrufe. 4.1.1 Tiefensuche Präorder • Betrachte zuerst den Knoten (die Wurzel des Teilbaums), • durchsuche dann den linken Teilbaum, • durchsuche zuletzt den rechten Teilbaum. Inorder • Durchsuche zuerst den linken Teilbaum, • betrachte dann den Knoten, • durchsuche zuletzt den rechten Teilbaum. Postorder • Durchsuche zuerst den linken Teilbaum, • durchsuche dann den rechten Teilbaum, • betrachte zuletzt den Knoten. 4&' Baumdurchläufe 4-5 Präorder Inorder Postorder W() betrachten als Beispiel für die Tiefensuche den Präorder-Durchlauf. public BinaryTreeNode<T> preOrderFind(T item) { if (data.equals(item)) return this; if (leftChild != null) { BinaryTreeNode<T> result = leftChild.preOrderFind(item); if (result != null) return result; } if (rightChild != null) { BinaryTreeNode<T> result = rightChild.preOrderFind(item); if (result != null) return result; } 4* 4 Datentypen: Bäume, Heaps return null; } 4.1.2 Tiefensuche mit Hilfe eines Stacks Mit Hilfe eines Stacks können wir die rekursiven Aufrufe in der präorder Tiefensuche vermeiden. Auf dem Stack werden die später zu behandelnden Baumknoten zwischengespeichert. public BinaryTreeNode<T> preOrderFindStack(T item) { Stack<BinaryTreeNode<T>> stack = new Stack<BinaryTreeNode<T>>(); stack.push(this.rootTreeNode); while (!stack.isEmpty()) { BinaryTreeNode<T> tmp = stack.pop(); if (tmp.getData().equals(item)) return tmp; if (tmp.getRightChild() != null) stack.push(tmp.getRightChild()); if (tmp.getLeftChild() != null) stack.push(tmp.getLeftChild()); } return null; } 4&' Baumdurchläufe 4-7 4.1.3 Breitensuche mit Hilfe einer Queue Bei der Breitensuche besucht man jeweils nacheinander die Knoten der gleichen Ebene: • Starte bei der Wurzel (Ebene 0). • Bis die Höhe des Baumes erreicht ist, setze den Level um eines höher und gehe von links nach rechts durch alle Knoten dieser Ebene. Levelorder B+( diesem Verfahren geht man nicht zuerst in die Tiefe, sondern betrachtet von der Wurzel aus zuerst alle Elemente in der näheren Umgebung. Um mittels Breitensuche (levelorder) durch einen Baum zu wandern, müssen wir uns alle Baumknoten einer Ebene merken. Diese Knoten speichern wir in einer Queue ab, so dass wir später darauf zurückgreifen können. public BinaryTreeNode<T> levelOrderFind(T item) { QueueImpl<BinaryTreeNode<T>> queue = new QueueImpl<BinaryTreeNode<T>>(); queue.add(rootTreeNode); while (!queue.isEmpty()) { BinaryTreeNode<T> tmp = queue.poll(); if (tmp.getData().equals(item)) return tmp; if (tmp.getLeftChild() != null) queue.add(tmp.getLeftChild()); if (tmp.getRightChild() != null) queue.add(tmp.getRightChild()); } 4, 4 Datentypen: Bäume, Heaps return null; } 4.2 Binäre Suchbäume Ein binärer Suchbaum ist ein Baum, welcher folgende zusätzliche Eigenschaft hat: Alle Werte des linken Nachfolger-Baumes eines Knotens K sind kleiner, alle Werte des rechten Nachfolger-Baumes von K sind grösser als der Wert von K selber. Der grosse Vorteil von binären Suchbäumen ist, dass wir sowohl beim Einfügen als auch beim Suchen von Elementen immer bloss einen der zwei Nachfolger untersuchen müssen. Falls der gesuchte Wert kleiner ist als der Wert des Knotens, suchen wir im linken Teilbaum, anderenfalls im rechten Teilbaum weiter. Beispiel: Die folgenden zwei Bäume entstehen durch Einfügen der Zahlen 37, 43, 53, 11, 23, 5, 17, 67, 47 und 41 in einen leeren Baum. Einmal werden die Zahlen von vorne nach hinten eingefügt, das zweite Mal von hinten nach vorne. 41 37 11 5 23 17 17 43 41 5 53 47 67 47 23 11 67 43 37 53 4& Binäre Suchbäume public class BinarySearchTreeNode <T extends Comparable<T>> { public void add(T item) { int compare = data.compareTo(item); if (compare > 0) { // (data > item)? if (leftChild == null) leftChild = new BinarySearchTreeNode<T>(item); else leftChild.add(item); // left recursion } else { // (item >= data) if (rightChild == null) rightChild = new BinarySearchTreeNode<T>(item); else rightChild.add(item); // right recursion } } public BinarySearchTreeNode<T> find(T item) { int compare = data.compareTo(item); if (compare == 0) return this; if (compare > 0 && leftChild != null) // data > item return leftChild.find(item); if (compare < 0 && rightChild != null) // data < item return rightChild.find(item); return null; } . . . } 4-9 4'- 4 Datentypen: Bäume, Heaps 4.3 B-Bäume Ein B-Baum ist ein stets vollständig balancierter und sortierter Baum. Ein Baum ist vollständig balanciert, wenn alle Äste gleich lang sind. In einem B-Baum darf die Anzahl Kindknoten variieren. Ein 3-4-5 BBaum ist zum Beispiel ein Baum, in welchem jeder Knoten maximal 4 Datenelemente speichern und jeder Knoten (ausser der Wurzel und den Blättern) minimal 3 und maximal 5 Nachfolger haben darf (der Wurzelknoten hat 0-4 Nachfolger, Blätter haben keine Nachfolger). Durch die flexiblere Anzahl Kindknoten ist das Rebalancing weniger häufig nötig. Ein Knoten eines B-Baumes speichert: • • • • eine variable Anzahl s von aufsteigend sortierten Daten-Elementen k1 , . . . , ks eine Markierung isLeaf, die angibt, ob es sich bei dem Knoten um ein Blatt handelt. s + 1 Referenzen auf Kindknoten, falls der Knoten kein Blatt ist. Jeder Kindknoten ist immer mindestens zur Hälfte gefüllt. Die letzte Bedingung lautet formal: es gibt eine Schranke m, so dass m <= s <= 2m gilt. Das heisst, jeder Kindknoten hat mindestens m, aber höchstens 2m Daten-Elemente. Die Werte von k1 , . . . , ks dienen dabei als Splitter. Die Daten-Elemente der Kindknoten ganz links müssen kleiner sein als k1 , diejenigen ganz rechts grösser als ks . Dazwischen müssen die Daten-Elemente des i-ten Kindes grösser als ki und kleiner als ki+1 sein. Das folgende Bild zeigt einen B-Baum mit m gleich 2. Jeder innere Knoten hat also mindestens 2 und maximal 5 Nachfolger. 4&% B-Bäume 4-11 Operationen in B-Bäumen Suchen Die Suche nach einem Datenelement e läuft in folgenden Schritten ab: Beginne bei der Wurzel als aktuellen Suchknoten k. • Suche in k von links her die Position p des ersten Daten-Elementes x, welches grösser oder gleich e ist. • • • • Falls alle Daten-Elemente von k kleiner sind als e, führe die Suche im Kindknoten ganz rechts weiter. Falls x gleich e ist, ist die Suche zu Ende. Anderfalls wird die Suche beim p-ten Kindelement von k weitergeführt. Falls k ein Blatt ist, kann die Suche abgebrochen werden (fail). Einfügen Beim Einfügen muss jeweils beachtet werden, dass nicht mehr als 2m Daten-Elemente in einem Knoten untergebracht werden können. Zunächst wird das Blatt gesucht, in welches das neue Element eingefügt werden müsste. Dabei kann gleich wie beim Suchen vorgegegangen werden, ausser dass wir immer bis zur Blatt-Tiefe weitersuchen (sogar, wenn wir den Wert unterwegs gefunden haben). Falls es in dem gesuchten Blatt einen freien Platz hat, wird der Wert dort eingefügt. Einfügen des Werts 31 in den folgenden Baum: 4' 4 Datentypen: Bäume, Heaps Der Wert 31 sollte in das Blatt (30,34,40,44) eingefügt werden. Dieses ist aber bereits voll, muss also aufgeteilt werden. Dies führt dazu, dass der Wert in der Mitte (34) in den Vorgänger- Knoten verschoben wird. Da das alte Blatt ganz rechts vom Knoten (20,28) liegt, wird der Wert 34 rechts angefügt (neuer, grösster Wert dieses Knotens). Damit erhält dieser Knoten neu 3 Werte und 4 Nachfolger. .(+/+) Prozess muss eventuell mehrmals (in Richtung Wurzel) wiederholt werden, falls durch das Hochschieben des Elements jeweils der Vorgänger-Knoten ebenfalls überläuft. 4&% B-Bäume 4-13 Löschen von Elementen Beim Löschen eines Elementes muss umgekehrt beachtet werden, dass jeder Knoten nicht weniger als m Datenelemente enthalten muss. Falls das gelöschte Element in einem Blatt liegt, welches mehr als m Datenelemente hat, kann das Element einfach gelöscht werden. Andernfalls können entweder Elemente vom benachbarte Blatt verschoben oder (falls zu wenig Elemente vorhanden sind) zwei Blätter verschmolzen werden. Verschiebung Aus dem linken B-Baum soll das Element 18 gelöscht werden. Dies würde dazu führen, dass das linke Blatt zu wenig Datenelemente hat. Darum wird aus dem rechten Nachbarn das kleinste Element nach oben, und das Splitter-Element des Vorgängers in das linke Blatt verschoben. Analog könnte (falls vorhanden) aus einem linken Nachbarn das grösste Element verschoben werden. F011/ +(2 31+5+26 +(2+/ (22+)+2 7286+2/ 9:;B; <0/ 31+5+26 =>? @+1öscht wird, muss entweder von den linken Nachfolgern das grösste, oder von den rechten Nachfolgern das kleinste Element nach oben verschoben werden, damit weiterhin genügend Elemente (als Splitter) vorhanden sind, und die Ordnung bewahrt wird. 4'4 4 Datentypen: Bäume, Heaps Verschmelzung Aus dem linken B-Baum soll das Element 60 gelöscht werden. Dies würde dazu führen, dass das mittlere Blatt zu wenig Datenelemente hat. Weder der rechte noch der linke Nachbar hat genügend Elemente, um eine Verschiebung durch zu führen - es müssen zwei Blätter verschmolzen werden. .0/ linke Blatt erhält vom mittleren Blatt das Element 55, sowie von der Wurzel das Element 50. Die Wurzel muss ebenfalls ein Element abgeben, da nach der Verschmelzung bloss noch 2 Nachfolge-Knoten existieren. Das rechte Blatt bleibt unverändert. Mit Hilfe der Verschiebung- und Verschmelzungs-Operation können wir nun beliebige Elemente aus einem B-Baum löschen. Beispiel Aus dem folgenden Baum löschen wir zuerst das Element 75, danach das Element 85: 4&4 Priority Queues 4-15 4.4 Priority Queues In vielen Applikationen will man die verschiedenen Elemente in einer bestimmten Reihenfolge (Priorität) abarbeiten. Allerdings will man das (aufwändige!) Sortieren dieser Elemente nach möglichkeit vermeiden. Eine der bekanntesten Anwendungen in diesem Umfeld sind Scheduling-Algorithmen mit Prioritäten. Alle Prozesse werden gemäss ihrer Priorität in einer Priority Queue gesammelt, so dass immer das Element mit höchster Priorität verfügbar ist. Priority Queues haben aber noch weit mehr Anwendungen, zum Beispiel bei Filekomprimierungs- oder bei Graph-Algorithmen. Eine elegante Möglichkeit der Implementierung einer Priority Queue ist mit Hilfe eines Heaps. 4.4.1 Heaps Ein Heap ist ein (fast) vollständiger Baum, in welchem nur in der untersten Ebene ganz rechts Blätter fehlen dürfen. 65 56 52 37 25 48 31 18 45 6 3 15 4'* 4 Datentypen: Bäume, Heaps Definition: [Heap] Ein Heap ist ein vollständiger binärer Baum, dem nur in der untersten Ebene ganz rechts Blätter fehlen dürfen mit folgenden Zusatzeigenschaften. 1. Jeder Knoten im Baum besitzt eine Priorität und eventuell noch weitere Daten. 2. Die Priorität eines Knotens ist immer grösser als (oder gleich wie) die Priorität der Nachkommen. Diese Bedingung heisst Heapbedingung. Aus der Definition kann sofort abgelesen werden, dass die Wurzel des Baumes die höchste Priorität besitzt. Weil der Heap im wesentlichen ein vollständiger binärer Baum ist, lässt er sich einfach als Array2 implementieren. Wir numerieren die Knoten des Baumes von oben nach unten und von links nach rechts. Die so erhaltene Nummerierung ergibt für jeden Knoten seinen Index im Array. Die dargestellten Werte im Baum sind natürlich bloss die Prioritäten der Knoten. Die eigentlichen Daten lassen wir der Einfachheit halber weg. public class Heap<T extends Comparable<T>> { private List<T> heap; public Heap() { heap = new ArrayList<T>(); } public T removeMax() { . . . } public void insert(T data) { . . . } private private private private boolean isLeaf(int position) { . . . } int parent(int position) { . . . } int leftChild(int position) { . . . } int rightChild(int position){ . . . } 2 Dies hat den Nachteil, dass die maximale Anzahl Elemente (size ) beim Erzeugen des Heaps bekannt sein muss. 4&4 Priority Queues 4-17 0 65 2 1 56 3 7 25 4 37 8 31 A 9 18 52 5 45 48 10 6 6 15 11 3 1 2 3 4 5 6 7 8 9 10 11 65 56 52 37 48 45 15 25 31 18 6 3 ... W+)<+2 <(+ 7286+2 0CE <(+/+ W+(/+ (2 <+2 G))0H 0I@+1+@6J /8 @+16+2 Eür alle i, 0 ≤ i < length folgenden Regeln: • Der linke Nachfolger des Knotens i befindet sich im Array-Element 2*i+1 ≥ heap[ 2*i+1] • Der rechte Nachfolger des Knotens i befindet sich im Array Element 2*i + 2 Ferner gilt: heap[i] ≥ heap[ 2*i+2] • Der direkte Vorfahre eines Knotens i befindet sich im Array-Element (i-1)/2 Ferner gilt: heap[i] ≤ heap[ (i-1)/2] Ferner gilt: heap[i] Wir sind jetzt in der Lage, die beiden wichtigen Operationen insert und removeMax zu formulieren. die 4', 4 Datentypen: Bäume, Heaps insert Da ein Element hinzugefügt werden muss, erhöhen wir zuerst length um eins. Das neue Element wird dann an der Stelle length-1 eingefügt. Der Array repräsentiert immer noch einen vollständigen binären Baum mit nur rechts unten fehlenden Blättern. Das neue Element verletzt aber eventuell die Heapbedingung. Um wieder einen Heap zu erhalten, vertauschen wir das neue Element solange mit seinen direkten Vorgängern, bis die Heapbedingung wieder erfüllt ist. Diese Methode verfolgt einen direkten Weg von einem Blatt zur Wurzel. Da der binäre Baum vollständig ist, hat ein solcher Weg höchstens die Länge der Höhe des Baumes. Mit anderen Worten, wir brauchen höchstens log2 (n) Vertauschoperationen, um ein Element im Heap einzufügen. 65 55 52 56 37 25 48 31 18 55 45 52 6 3 15 55 55 45 p K ! LM !LN $ heap.add(data); int crt = heap.size() - 1; while ((crt != 0) // heap[crt] > heap[parent(crt)] && (heap.get(crt).compareTo(heap.get(parent(crt))) > 0)) { Collections.swap(heap, crt, parent(crt)); crt = parent(crt); } } } 4&4 Priority Queues 4-19 removeMax Das Element mit der höchsten Priorität befindet sich im Element heap[0] und wird vom Heap entfernt. heap[0] wird nun mit heap[length-1] überschrieben und length um eins verringert. Damit erhalten wir wieder einen fast vollständigen binären Baum. Das neue Element heap[0] verletzt nun vermutlich die Heapbedingung. Wir vertauschen also heap[0] mit dem grösseren seiner beiden Nachfolger und fahren so fort, bis die Heapbedingung wieder erfüllt ist. 45 56 65 48 45 56 55 45 37 25 48 31 18 52 6 3 45 65 p O KPQMN $ if (heap.isEmpty()) return null; Collections.swap(heap, 0, heap.size() - 1); T element = heap.remove(heap.size() - 1); if (heap.size() > 1) siftDown(0); return element; } 15 65 4- 4 Datentypen: Bäume, Heaps private void siftDown(int position) { while (!isLeaf(position)) { int j = leftChild(position); if ((j < heap.size() - 1) // heap[j] < heap[j+1] && (heap.get(j).compareTo(heap.get(j + 1)) < 0)) { j++; } // heap[position] >= heap[j] if (heap.get(position).compareTo(heap.get(j)) >= 0) { return; } Collections.swap(heap, position, j); position = j; } }