Datenstrukturen für Wörterbücher II: Natürliche Suchbäume Tobias Pröger 24. November 2016 Erklärung: Diese Mitschrift ist als Ergänzung zur Vorlesung gedacht. Wir erheben keinen Anspruch auf Vollständigkeit und Korrektheit. Wir sind froh über Hinweise zu Fehlern oder Ungenauigkeiten. Bitte senden Sie diese an [email protected]. Wir haben mit Hashing bereits eine Implementierung von Wörterbüchern kennengelernt, die im besten sowie im durchschnittlichen Fall sehr schnelle Zugriffszeiten hat. Auch haben wir überlegt, wie man erreichen kann, dass man den schlechtesten Fall mit hoher Wahrscheinlichkeit umgehen kann. Schlimmer als eine lineare Suchzeit im schlechtesten Fall ist jedoch, dass Hashing manche Anfragen überhaupt nicht unterstützt. Bisher haben wir nur das Suchen, Einfügen und Entfernen von Schlüsseln diskutiert. In vielen realen Anwendungen möchte man aber beispielsweise auch die gespeicherten Schlüssel in aufsteigend sortierter Reihenfolge ausgeben können. Ebenso möchte man vielleicht für einen Schlüssel k, der nicht im Wörterbuch enthalten ist, den nächstkleineren Schlüssel k 0 finden, der im Wörterbuch enthalten ist. Beide Operationen können mit Hashing nicht implementiert werden. Wir haben bereits früher das Konzept eines binären Baums kennengelernt (z.B. bei der unteren Schranke für allgemeine Sortierverfahren und bei Heaps). Ein binärer Baum ist entweder Motivation Binärer Baum • ein Blatt, d.h. der Baum ist leer, oder • ein innerer Knoten v mit zwei Bäumen Tl (v) und Tr (v) als linkem bzw. rechtem Nachfolger. Der Baum Tl (v) heisst linker Teilbaum von v, Tr (v) heisst rechter Teilbaum von v. Jeder Baum besitzt genau einen Knoten ohne Vorgänger, die sog. Wurzel, die in den folgenden Algorithmenbeschreibungen Root genannt wird. In jedem inneren Knoten v speichern wir • einen Schlüssel v.Key, • einen Zeiger v.Left auf den linken Nachfolgerknoten (also auf die Wurzel des linken Teilbaums Tl (v) und nicht auf den Teilbaum als ganzes), sowie • einen Zeiger v.Right auf den rechten Nachfolgerknoten (also auf die Wurzel des rechten Teilbaums Tr (v)). Ist der linke (bzw. rechte) Nachfolger eines inneren Knotens ein Blatt, dann setzen wir v.Left (bzw. v.Right) auf null. Der gesamte Baum wird dann durch einen Zeiger auf die Wurzel gegeben. Der Baum wird also vollständig durch Zeiger auf die entsprechenden Nachfolger repräsentiert, und nicht z.B. als Array (wie bei Heaps). 1 Wurzel Abb. 1 Ein möglicher binärer Suchbaum zur Schlüsselmenge {5, 7, 8, 10, 11, 15}. Innere Knoten werden durch Kreise, Blätter durch Rechtecke dargestellt. Die Wurzel ist der Knoten 7 mit den Nachfolgerknoten 5 und 10. Binärer Suchbaum Suchbaumeigenschaft Trotzdem ist noch nicht klar, wie dies bei der Suche nach einem gegebenen Schlüssel hilft. Ein Heap könnte z.B. auch durch entsprechende Zeiger repräsentiert werden, aber es ist unklar, wie man dort effizient suchen kann. Daher führen wir nun ein weiteres Kriterium ein: Ein binärer Suchbaum ist ein binärer Baum, der die Suchbaumeigenschaft erfüllt: Jeder innere Knoten v speichert einen Schlüssel k, alle im linken Teilbaum Tl (v) von v gespeicherten Schlüssel sind kleiner als k und alle im rechten Teilbaum Tr (v) von v gespeicherten Schlüssel sind grösser als k. Suchen eines Schlüssels Abbildung 1 zeigt einen möglichen binären Suchbaum zur Schlüsselmenge {5, 7, 8, 10, 11, 15}. Man beachte, dass es im Allgemeinen sehr viele verschiedene Suchbäume gibt, die die gleiche Schlüsselmenge repräsentieren. So kann etwa jeder Schlüssel an der Wurzel stehen. Da aber alle Suchbäume die Suchbaumeigenschaft erfüllen, können wir eine binäre Suche simulieren, um nach einem gegebenen Schlüssel k zu suchen. Dazu starten wir an der Wurzel und prüfen, ob k dort gespeichert ist. Falls ja, dann haben wir k gefunden und beenden das Verfahren. Falls nein, dann fahren wir im linken Teilbaum von v fort, falls k kleiner als der Schlüssel der Wurzel ist, und ansonsten im rechten Teilbaum. Stossen wir auf ein Blatt, dann ist der Schlüssel k nicht vorhanden. Schlüssel suchen Höhe Search(k) 1 v ← Root 2 while v ist kein Blatt do 3 if k = v.Key then return true 4 else if k < v.Key then v ← v.Left 6 else v ← v.Right 7 return false . Element gefunden . Suche links weiter . Suche rechts weiter . k nicht gefunden Zur Analyse der Laufzeit definieren wir die Höhe eines Baums T . Ist T ein Blatt, dann ist die Höhe h(T ) = 0. Ist T ein Baum mit Wurzel v und den Teilbäumen Tl (v) bzw. Tr (v), dann ist h(T ) = 1 + max{h(Tl (v)), h(Tr (v))}. (1) Die Höhe gibt anschaulich an, aus wie vielen Ebenen der Baum besteht. Der in Abbildung 1 dargestellte Baum etwa hat Höhe 4. 2 Abb. 2 Der Suchbaum aus Abbildung 1 nach der Einfügung des Schlüssels 9. Abb. 3 Entfernen, Fall 1 (beide Nachfolger von v sind Blätter). Es ist nun leicht zu sehen, dass die zuvor vorgestellte Suche im schlechtesten Fall Zeit O(h) braucht, wenn h die Höhe des binären Suchbaums ist: Die Schritte 1 sowie 3–7 benötigen nur konstante Zeit. Jeder Weg von der Wurzel zu einem Blatt hat maximal Länge h, also wird die Schleife im zweiten Schritt maximal h Mal durchlaufen. Einfügen eines Schlüssels Beim Einfügen eines Schlüssels k führen wir zunächst eine Suche nach k durch. Wird k gefunden, dann wird der Schlüssel nicht erneut eingefügt und eine Fehlermeldung ausgegeben. Ansonsten endet die Suche erfolglos in einem Blatt. Dieses wird durch einen inneren Knoten mit dem Schlüssel k und zwei Blättern ersetzt. Fügen wir beispielsweise den Schlüssel 9 in den Suchbaum aus Abbildung 1 ein, dann besuchen wir die Schlüssel 7, 10 und 8, und fügen 9 als rechten Nachfolgerknoten von 8 ein. Da zum Einfügen im Wesentlichen nur eine Suche durchgeführt wird und die anschliessende Ersetzung eines Knotens nur Zeit O(1) kostet, kann in einen Suchbaum der Höhe h in Zeit O(h) eingefügt werden. Entfernen eines Schlüssels Es verbleibt zu zeigen, wie ein Schlüssel k aus einem binären Suchbaum entfernt werden kann. Sei dazu v der Knoten, in dem k gespeichert ist. O.B.d.A. sei v nicht die Wurzel des Baums und u der Vorgänger von v. Wir unterscheiden drei Fälle. 1. Fall: Beide Nachfolger von v sind Blätter. Dann kann der Knoten v direkt gelöscht werden, d.h. der entsprechende Nachfolger von u wird durch ein Blatt ersetzt (siehe Abbildung 3). 2. Fall: Genau ein Nachfolger von v ist ein Blatt. Sei w der innere Knoten, der der Nachfolger von v ist. Der entsprechende Nachfolger von u wird durch w ersetzt und v gelöscht (siehe Abbildung 4). 3 Laufzeit Abb. 4 Entfernen, Fall 2 (genau ein Nachfolger von v ist ein Blatt). Der Fall, in dem w der linke Nachfolger von v ist, wird analog aufgelöst. Abb. 5 Entfernen, Fall 3 (kein Nachfolger von v ist ein Blatt). Symmetrischer Nachfolger 3. Fall: Kein Nachfolger von v ist ein Blatt. Dann enthalten sowohl der linke Teilbaum Tl (v) als auch der rechte Teilbaum Tr (v) mindestens einen inneren Knoten. Sei w der Knoten mit minimalem Schlüssel in Tr (v) (dieser heisst symmetrischer Nachfolger von v). Wird der in v gespeicherte Schlüssel durch den in w gespeicherten Schlüssel ersetzt und anschliessend w gelöscht (siehe Abbildung 5), dann bleibt die Suchbaumeigenschaft erhalten (denn der Schlüssel in w ist minimal unter allen in Tr (v) gespeicherten Schlüsseln). Der Knoten w hat keinen linken Nachfolgerknoten, denn dort müsste ein Schlüssel von kleinerem Wert gespeichert sein (und der Schlüssel von w wäre nicht minimal in Tr (v)). Also tritt beim Löschen von w nur einer der beiden erstgenannten Fälle auf. Zu einem gegebenen Knoten v kann der symmetrische Nachfolger gefunden werden, indem man genau einmal nach rechts läuft und danach so lange dem linken Nachfolger folgt, bis dieser ein Blatt als linken Nachfolger besitzt. Dies führt zu folgendem Algorithmus. SymmetricSuccessor(v) 1 2 3 4 Symmetrischer Vorgänger w ← v.Right . Gehe genau einmal nach rechts x ← v.Left . Folge danach dem linken Nachfolger while x ist kein Blatt do w ← x; x ← w.Left return w . w ist symmetrischer Nachfolger von v Natürlich könnte auch der symmetrische Vorgänger (der Knoten mit grösstem Schlüssel in Tl (v)) gewählt werden. Die vorigen Überlegungen gelten dann analog. 4 Theorem 1 (Laufzeit des Entfernens). Sei T ein binärer Suchbaum der Höhe h. Das Entfernen eines Schlüssels in T erfordert im schlechtesten Fall Zeit O(h). Laufzeit des Entfernens Beweis. Der zu entfernende Knoten muss zunächst gefunden werden, was in Zeit O(h) möglich ist. Danach tritt einer der Fälle 1 bis 3 auf. In den ersten beiden Fällen werden lediglich Zeiger verändert und Speicher freigegeben. Daher fällt für sie nur Zeit O(1) an. Im dritten Fall muss der zunächst mithilfe des Algorithmus SymmetricSuccessor der symmetrische Nachfolger gefunden werden. Dies kostet maximal Zeit O(h), da die Pfadlänge von der Wurzel bis zu einem Blatt höchstens h beträgt. Danach werden lediglich die Schlüssel vertauscht und der symmetrische Nachfolger gelöscht, was in Zeit O(1) möglich ist. Damit wird im dritten Fall maximal Zeit O(h) benötigt. Durchlaufordnungen für Bäume Sei T ein binärer Suchbaum mit der Wurzel v, dem linken Teilbaum Tl (v) und dem rechten Teilbaum Tr (v). Wir können die Knoten von T auf verschiedene Arten durchlaufen: • In der Hauptreihenfolge (engl. Preorder) wird zunächst der in v gespeicherte Schlüssel ausgegeben. Danach wird zuerst rekursiv mit Tl (v) fortgefahren und anschliessend rekursiv Tr (v) verarbeitet. Hauptreihenfolge Preorder • In der Nebenreihenfolge (engl. Postorder) wird zunächst Tl (v) rekursiv verarbeitet und danach Tr (v). Schliesslich wird der in v gespeicherte Schlüssel ausgegeben. Nebenreihenfolge Postorder • In der symmetrischen Reihenfolge (engl. Inorder) wird zunächst Tl (v) rekursiv besucht, danach der in v gespeicherte Schlüssel ausgegeben und schliesslich Tr (v) rekursiv besucht. Da alle Schlüssel in Tl (v) kleiner und alle Schlüssel in Tr (v) grösser sind als der in v gespeicherte Schlüssel, gibt die symmetrische Reihenfolge die in T gespeicherten Schlüssel in sortierter Reihenfolge aus. Symmetrische Reihenfolge Inorder Beispiel Die Knoten des binären Suchbaums aus Abbildung 2 werden in den folgenden Reihenfolgen durchlaufen: • 7, 5, 10, 8, 9, 11, 15 bei Verwendung der Hauptreihenfolge, • 5, 9, 8, 15, 11, 10, 7 bei Verwendung der Nebenreihenfolge, • 5, 7, 8, 9, 10, 11, 15 bei Verwendung der symmetrischen Reihenfolge. Zusammenfassung und Ausblick Sei T ein binärer Suchbaum mit Höhe h, der n Schlüssel verwaltet. Binäre Suchbäume implementieren die Wörterbuchoperationen in Zeit O(h). Ausserdem werden eine Reihe weiterer Operationen unterstützt, zum Beispiel: • Min(T ): Liefert den Schlüssel aus T mit minimalem Wert zurück. Diese Operation kann in Zeit O(h) realisiert werden, indem ausgehend von der Wurzel von T so lange dem linken Nachfolgerknoten gefolgt wird, bis dieser ein Blatt als linken Nachfolger besitzt. Minimaler Schlüssel • Extract-Min(T ): Sucht und entfernt den Schlüssel mit minimalem Wert aus T . Auch diese Operation kann in Zeit O(h) durchgeführt werden. Minimum extrahieren 5 Schlüssel ausgeben • List(T ): Liefert eine sortierte Liste der in T gespeicherten Schlüssel zurück. Diese Funktion wird von einem Durchlauf in symmetrischer Reihenfolge in Zeit O(n) realisiert. Vereinigung • Join(T1 , T2 ): Seien T1 und T2 zwei Suchbäume zu den disjunkten Schlüsselmengen K1 und K2 , und zusätzlich der maximale Wert eines Schlüssels in K1 kleiner als der minimale Wert eines Schlüssels in K2 . Join(T1 , T2 ) berechnet einen binären Suchbaum zur Schlüsselmenge K1 ∪ K2 . Dazu führen wir zunächst Extract-Min(T2 ) aus, erhalten einen Schlüssel k sowie den aus T2 resultierenden Baum T20 . Dann erzeugen wir einen neuen Baum, dessen Wurzel den Schlüssel k speichert und die Teilbäume T1 sowie T20 besitzt. Die Laufzeit beträgt O(h). Verhalten im schlechtesten Fall Die soeben diskutierten natürlichen binären Suchbäume sind problematisch, wenn die Schlüssel (grösstenteils) in geordneter Reihenfolge eingefügt werden. Im schlechtesten Fall degenerieren sie zu einer linearen Liste, und die Laufzeiten für die Wörterbuchoperationen sind dann linear in der Anzahl der gespeicherten Schlüssel. 6