Disclaimer: Diese Mitschrift ist als Ergänzung zur Vorlesung gedacht. Wir erheben keinen Anspruch auf Vollständigkeit und Korrektheit. Wir sind jederzeit froh um Hinweise zu Fehlern oder Unklarheiten an grafdan@ethz.ch.
Wenn Sie an dieser Mitschrift mitarbeiten möchten, melden Sie sich hier.
Gestern haben wir gesehen, was ein binärer Suchbaum ist. Wir haben einen binären Baum mit folgender Eigenschaft: Für alle Knoten gilt, dass die Schlüssel im linken Teilbaum kleiner sind als der Schlüssel im Knoten und der Schlüssel im Knoten ist kleiner als alle Schlüssel im rechten Teilbaum.
Suche (ab Knoten p nach Schlüssel k)
if p null then erfolglos
else if k < key von p then suche(links von p, k)
else if k > key von p then suche(rechts von p, k)
else gefunden
Unser Wunsch an die Laufzeit für diesen Suchbaum war
Wir wollen das jetzt dynamisch machen, also auch Einfüge- und Löschoperationen unterstützen. Die natürlichste Art die Einfügeoperation zu unterstützen ist die folgende:
Einfügen(k)
Suche ab der Wurzel nach k
Finden wir k im Baum, müssen wir nichts einfügen
Sonst, landen wir in einem Blatt p
Ersetze das Blatt durch einen neuen Knoten mit dem Schlüssel k,
Erreichen wir damit logarithmische Laufzeit? Im schlechtesten Fall leider nicht. Wenn wir in einen leeren Baum die Schlüssel
Ist es wenigstens im Mittel gut? (gemittelt über alle möglichen Einfügereihenfolgen). Wie beim Quicksort, können wir argumentieren, dass der durchschnittliche Schlüssel ein guter Pivot, respektive eine gute Wurzel ist. Mit der selben Analyse erhalten wir eine mittlere Suchbaumhöhe von
Wie können wir Schlüssel entfernen?
Es genügt nicht, den entsprechenden Knoten zu finden und zu löschen, da wir dabei auch alle Schlüssel in seinen Teilbäumen verlieren. Zusätzlich müssen wir vorsichtig sein, dass wir die Suchbaumeigenschaft nicht zerstören.
Wir unterscheiden drei Fälle:
Entfernen(k)
Suche ab der Wurzel nach k
Endet mit Knoten p mit key(p) = k
1. Fall: p hat kein Kind -> Knoten einfach löschen
2. Fall: p hat ein Kind -> lenke Zeiger vom Vater aufs Kind
3. Fall: p hat zwei Kinder -> der interessante Fall!
Der spannende Fall ist also, wenn der zu löschende Knoten zwei Kinder hat. Wir können ihn dann nicht einfach löschen, da es danach keine einfache Möglichkeit gibt, die beiden darunterliegenden Teilbäume in der verbleibenden Suchbaum einzubetten. Der Trick ist es nun stattdessen ein anderes Element zu löschen, welches nur höchstens ein Kind hat.
Für dieses andere Element interessieren wir uns nun für den Knoten mit dem grössten Schlüssel im linken Teilbaum von
Warum können wir sicher sein, dass die nächstkleineren und grösseren Schlüssel in der global sortierten Folge wirklich immer im Teilbaum von
Was bringen uns diese Vorgänger und Nachfolger? Der Vorgänger und Nachfolger haben je höchstens ein Kind. Denn hätten sie zwei Kinder, so wären sie in der Traversierung nicht direkt vor/nach
Entfernen im dritten Fall bei Knoten k:
Finde den sym. Vorgänger;
> gehe 1 Schritt links,
> gehe solange rechts wir moeglich (so erreicht man den sym. Vorgänger)
Tausche k mit sym. Vorgänger
Knipse sym. Vorgänger ab oder leite von Vater um. (1. oder 2. Fall)
In der Praxis gibt es viele Tricks um den Code zu vereinfachen. Ein cooler Trick ist es statt Blätter mit null Zeigern ins Leere zeigen zu lassen, allozieren wir einen zusätzlichen Knoten auf den alle Blätter zeigen. Bevor wir dann jeweils eine Suche starten, schreiben wir diesen Schlüssel auf diesen zusätzlichen Knoten. Damit wissen wir, dass die Suche immer den Schlüssel finden wird.
Jetzt haben wir also eine Laufzeit von
Worst Case:
Best Case:
Und im Durchschnitt? Was heisst denn überhaupt im Durchschnitt bei so vielen verschiedenen Operationsmöglichkeiten? Wie schon oben beim Einfügen erwähnt, wenn wir wie bei Quicksort annehmen, dass wir eine zufällige Reihenfolge als Eingabe bekommen, also jede Permutation der Eingabe gleichwahrscheinlich ist, dann erhalten wir Mittel auch
Was passiert, wenn wir statt über die Reihenfolge über die Gestalt des Baumes mitteln? Eine solche Gestaltsanalyse ergibt nicht genau das gleiche, denn zum Beispiel für drei Schlüssel gibt es sechs Permutationen aber nur fünf Suchbaumgestalten (da 2 1 3 und 2 3 1 den selben Baum ergeben). Wenn man es genau ausrechnet kommt man auf
Im Schnitt funktionieren natürliche Suchbäume also ganz gut, aber wie immer hätten wir gerne Worst Case Garantien.
Wir sind auf der Suche nach einer Bedingung, die uns logarithmische Laufzeit garantiert.
Ganz simple Bedingung: logarithmische Höhe und immer die folgende Gestalt erhalten: (im letzten Level alles von links her aufgefüllt, wie beim Heap)
/\
/ \
/ \
/ ---
/___|
Änderungsoperationen müssen diese Bedingungen erhalten. Können wir in logarithmischer Zeit diese Bedingung wiederherstellen? Wenn wir eine neues Blatt irgendwo unten einfügen müssen wir unter Umständen linear viele Schlüssel verschieben, um die Form-Bedingung zu erhalten. Unsere Bedingung ist viel zu stark, um sie einfach einhalten zu können. Wir suchen also nach etwas, das uns eine leicht schwächere Höhenbedingung gibt, dafür aber sehr einfach einzuhalten bleibt.
Sowas gibt es und ist ein wahres Kunststück der Ingenieurwissenschaften.
Die beiden sowjetischen Mathematiker Adelson-Velskii und Landis hatten schon 1962 eine gute Idee für eine Bedingung, die einhaltbar ist und doch gute Laufzeiten garantiert. Für jeden Knoten muss gelten, dass sich die Höhe des linken und des rechten Teilbaumes um höchstens 1 unterscheiden (AVL-Bedingung).
Was sagt diese AVL-Bedingung über die Höhe? Die Suche geht natürlich in
Dazu können wir uns auch fragen: Wie viele Blätter hat ein AVL-Baum der Höhe
Um möglichst kleine Bäume zu bauen, nutzen wir das erlaubte Ungleichgewicht immer voll aus, also verknüpfen immer
0 | 1 | 2 | 3 | |
---|---|---|---|---|
1 | 2 | 3 | 5 |
Das sind die Fibonacci-Zahlen (leicht verschoben)
Die Anzahl Blätter wachst also exponentiell. Für eine fixe Anzahl Blätter können wir garantieren, dass die Höhe beschränkt ist durch
Durch diese Auflockerung der Balancierbedingung verlieren wir also höchstens 40 Prozent im Vergleich zum perfekt balancierten Baum, den wir dynamisch nur sehr schwer erhalten können.
Jetzt wo wir wissen, dass ein AVL-Baum nie zu hoch wird, wollen wir uns überlegen, wie wir bei den Operationen die Bedingung erhalten können. Starten wir mit dem Einfügen.
Versuchen wir es möglichst ähnlich wie im natürlichen Suchbaum zu machen. Suchen wir also das Blatt, bei dem der neue Schlüssel hingehört, und setzen dort den neuen Knoten ein.
Jetzt können wir potentiell bei einigen Knoten die AVL-Bedingung verletzt haben. Wo kann das passiert sein? Das neue Blatt liegt nur in den Teilbäumen der Knoten, welche auf dem Suchpfad des Schlüssels liegen. Also kann die AVL-Bedingung auch nur an solchen Knoten verletzt sein. Sie kann auch nur verletzt sein, wenn sich die Höhe eines Teilbaumes effektiv geändert hat. Wir können also vom neu eingefügten Blatt her nach oben aufsteigen und jeweils prüfen ob sich was geändert hat und ob wir die AVL-Bedingung verletzen.
Als Information brauchen wir dazu in jedem Knoten die Höhe der beiden Teilbäume. Diese Höhen können aber für grosse Bäume auch gross werden und genau genommen sind wir ja nur daran interessiert, ob die Teilbäume gleich hoch sind, ober welcher der beiden Bäume um eines höher ist. Alles was wir also speichern pro Knoten ist
Jetzt unterscheiden wir einige Fälle:
Fall I)
Wir unterscheiden, was die Balance
Fall i)
Fall ii)
Fall iii) upin(p)
, welche aufsteigt und diese Änderung dort behandelt.
upin(p)
Diese upin
Funktion hat als Invariante:
Es gibt wieder zwei symmetrische Fälle:
Fall I)
(Der Fall II) mit
Nun unterscheiden wir lokal wir nach der Balance des Vaters, nenne wir ihn
Fall i)
Fall ii) upin(q)
fortfahren müssen.
Fall iii)
Nehmen wir an, das neue Blatt sei im linken Teilbaum von
Können wir diese Teilbäume so umgruppieren, dass die Suchbaumeigenschaft erhalten bleibt, aber die AVL-Bedingung wieder hergestellt wird und wir nicht viel ändern müssen?
Ja, das geht! Mit folgenden Zeigeränderungen:
Wir nehmen den Baum quasi auseinander und setzen ihn mit
Danach ist
Im anderen Fall, liegt das neue Blatt im rechten Teilbaum von
Hier nützt eine solche Rechtsrotation nichts, da wir das Problem einfach auf die rechte Seite verlagern würden. Da müssen wir genauer hinschauen und den Teilbaum mit dem neuen Element etwas genauer auseinandernehmen.
Wir rotieren so rum, dass
Danach sind die neuen Balancen upin()
nicht weiter aufrufen. Im Fall, wo das neue Element an
Da wir dieses Ändern der Zeiger mittels zweier Rotationen implementieren können, nennt man diese Operation Doppelrotation.
Wir beobachten, dass wir beim Einfügen mit upin
nach einer einzigen Rotation jeweils aufhören können. Die Strukturänderung beim Einfügen ist also in
Fragen für nächste Woche:
upin
beim wiederholten Einfügen immer wieder weit dem Pfad entlang hochlaufen oder kann das nur ab und zu passieren?