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.
Wir haben uns in den letzten Stunden damit beschäftigt, wie wir Datenstrukturen auf bekannte Zugriffsmuster hinzuoptimieren.
Bei linearen Listen und vorab bekannte Häufigkeiten haben wir gesehen, dass dies ganz einfach geht: Wir sortieren die Elemente in absteigender Zugriffshäufigkeit. Für binäre Suchbäume haben wir gestern gesehen, dass wir mittels dynamischer Programmierung die statisch optimale Struktur ebenfalls finden können.
Mit der Move-to-Front-Regel haben wir gezeigt, dass wir mit selbstanordnenden linearen Listen auch bei dynamischen Zugriffen gut abschneiden können. Was nun also noch fehlt: Können wir uns das auch für binäre Suchbäume überlegen?
Was wäre das Analoge zu Move-to-Front in einem Suchbaum? Wir greifen auf ein Element zu und wollen dabei die Datenstruktur so anpassen, dass danach der erneute Zugriffe nur noch
Nachdem wir ein Element zugegriffen habe, legen wir es an die Wurzel. So wird der nächste Zugriff auf dieses Element nur
Wie führt man ein solches Move-to-Root in einem Suchbaum durch? Wir können das Element x
nicht einfach rausschneiden und oben einsetzen und auch ein Vertauschen mit der Wurzel ist problematisch, da dabei potentiell die Suchbaumeigenschaft zerstört wird. Wie können wir x
hochblubbern lassen? Was für eine Operation kennen wir, die Schlüssel umgruppiert ohne die Suchbaumeigenschaft zu verletzen? Die Rotation!
Wir rotieren also x
von unten zur Wurzel hin nach oben. Egal wo der Schlüssel x
liegt, können wir ihn dem Suchpfad entlang immer zur Wurzel hochrotieren.
Wir interessieren uns dafür, wie sich das über eine Folge von Zugriffsoperationen entwickelt. Eine einzelne Operation darf durchaus lange dauern, aber was ist im Schnitt? Es kann uns also nur eine amortisierte Analyse helfen.
Wir sehen leicht, dass Move-to-Root für eine Folge von Operationen nicht effizient ist, indem wir eine Folge angeben, die in jedem Zugriff Kosten
Wir starten mit einem Baum in der Form einer linearen Liste und greifen wiederholt auf das unterste Element zu. Das Hochrotieren belässt die Gestalt in der Form einer linearen Liste, weshalb wir Gesamtkosten
Es ist nicht leicht zu sehen, wie wir das Move-to-Root-Verfahren anpassen können, damit wir eine bessere Selbstanordnung bekommen. 1985 haben Sleator und Tarjan genau das geschafft. Eine kleine Variation von Move-to-Root funktioniert erstaunlich gut.
Intuitiv liegt das Problem von Move-to-Root darin, dass beim Hochziehen von x
die benachbarten Knoten (z.B. das y
im Beispiel oben) nicht mit zur Wurzel hochgezogen werden. Der Trick ist simpel: Statt zuerst um y
und dann um z
zu rotieren machen wir es in umgekehrter Reihenfolge. Warum das funktioniert ist alles andere als offensichtlich.
Wir unterscheiden drei Fälle:
x
unmittelbar unter der Wurzel, so rotieren wir x
nach oben (wie gewohnt).y
das linke Kind von z
und x
das rechte Kind von y
so rotieren wir zuerst um y
und erst dann um z
(wie gewohnt).x
und y
je das linke Kind ihrer Väter, so rotieren wir zuerst um z
und erst dann um y
(neu!).Die verbleibenden Fälle Zag, Zag-Zig und Zag-Zag sind einfach spiegelverkehrt.
Der verblüffende Trick ist also lediglich das Zig-Zig, der Rest ist wie bei Move-to-Root. Intuitiv zieht die Zig-Zig Operation die Nachbarschaft von x
(den Knoten y
im Beispiel) mehr mit nach oben, im Gegensatz zu den Move-to-Root Operationen.
Diese neue Datenstruktur heisst Splay-Baum (oder Splay Tree) und das Hochrotieren nach dieser Methode splayen[1].
Nun sollten wir uns vergewissern, dass dieses splayen auch effektiv funktioniert. Wir führen eine amortisierte Analyse durch. Wir erinnern uns an die Zutaten einer amortisierten Analyse mit einem Kontostand (Potentialfunktion):
In unserer Situation ist das nicht ganz einfach und wir müssen uns einen cleveren Kontostand ausdenken. Bei unseren bisherigen Überlegungen war der Kontostand zu Beginn jeweils gleich Null. Dann genügte es zu zeigen, dass wir nie ins Minus gehen, um zu argumentieren, dass die tatsächlichen Kosten die amortisierten Kosten nicht überschreiten.
Jetzt wollen wir das verallgemeinern: Wir sagen, dass wir mit irgendeiner Baumstruktur beginnen, welcher wir auch schon einen Kontostand zuordnen. Vom imaginären Konto dieses Initialbaumes dürfen wir dann auch abheben aber müssen dann am Ende vorsichtig vergleichen mit der Strukturänderung, die wir durchführen.
Wie definieren wir den Kontostand? Wir wählen ein Gewicht
Als Kontostand wählen wir
Die tatsächlichen Kosten wählen wir als
Jetzt schauen wir wie die Operationen den Kontostand verändern,
Rotation 0: Gesucht ist der Schlüssel an der Wurzel
Die tatsächlichen Kosten sind
Rotation 1: Zig
Die Grössen und damit auch die Ränge von v
und w
ändern sich bei der Rotation. Für alle anderen Knoten im Baum ändert sich aber nicht.
Wir sehen, dass
Rotation 2: Zig-Zag
Das kann man nun im gleichen Stil, aber mit etwas mehr Rechenaufwand, für die Zig-Zag und die Zag-Zig Operationen machen. Dabei sieht man, dass gilt:
Ein Zugriff auf ein Element führt aber mehrere Operationen aus. Wie ändert sich der Kontostand über all diese Hochrotationen hinweg?
Für eine zweiten Operation ergibt sich sowas wie:
Zugriffslemma: Die amortisierte Kosten fürs Splayen am Knoten
Jetzt müssen wir noch die Gewichte definieren und erklären wie sich der Kontostand verändert. Wir betrachten
Als Gewicht wählen wir
Setzen wir ins Zugriffslemma ein:
Daher können wir nun sagen, dass das Hochsplayen amortisiert nur
Pro Knoten
Für die Gesamtdifferenz gilt also
Die Gesamtkosten sind also in
Wir haben hiermit gezeigt, dass sich Splay-Bäume asymptotisch genauso verhalten wir AVL-Bäume. Warum das so ist, versteht niemand so genau. Die Intuition warum Zig-Zig besser funktioniert als Move-to-Root haben wir oben gegeben und dass es rechnerisch aufgeht, haben wie soeben gezeigt.
Was man auch zeigen kann: Splay-Bäume sind häufigkeitsoptimal. Wir haben also statische Optimalität, was bedeutet, dass ein Splay-Baum höchstens einen konstanten Faktor langsamer ist, als der häufigkeitsoptimale Suchbaum, den wir gestern mit dynamischer Programmierung aufwändig generiert haben.
Das ist das Faszinierende an Splay-Bäumen: Er ist von alleine gleich gut wie ein AVL-Baum, ohne bewusst zu balancieren, und gleichzeitig auch so gut wie der optimale statische Suchbaum.
Wir würden gerne auch sagen: Splay-Bäume sind auch dynamisch optimal, also auch gegen einen dynamisch angepassten Suchbaum höchstens um einen konstanten Faktor schlechter. Das wäre dann eine sehr starke Aussage, genauso wie wir es bei Move-To-Front gezeigt hatten. Man vermutet, dass der Splay-Baum diese Eigenschaft hat und in der Praxis beobachtet man sie auch häufig, aber ob dem wirklich so ist, ist bis heute offen und ein Forschungsthema bekannt als Dynamic-Optimality-Conjecture.
Jetzt fragen wir uns noch ob Splay-Bäume auch gute Wörterbücher sind? Wie implementieren wir also die drei Operationen?
k'
des Blattes an die Wurzel. Wir knipsen den linken Teilbaum dieser neuen Wurzel k'
ab und fügen k
als neue Wurzel ein.Für diese Operationen gibt es keine formale amortisierten Analysen aber man beobachtet einfach, dass sie in der Praxis gut funktionieren.
Für die meisten Anwendungen sind Splay-Bäume unglaublich gut. Es gibt aber schon auch Nachteile, z.B. sind die Operationen nur amortisiert schnell[1] und ein Splay-Baum lässt sich deutlich schlechter parallelisieren als ein AVL-Baum, da wir bei jeder Operation den ganzen Baum sperren müssen. Durch die einfache Implementierung und die Kombination von logarithmischer Laufzeit und statischer Optimalität überwiegen in der Praxis aber oft die Vorteile.