3.4 Treaps: Zufällige Einfüge-Reihenfolge simulieren
Wenn \(n\) Elemente in sortierter Reihenfolge eingefügt werden, dann entsteht ein "entarteter" Baum, der Tiefe \(n-1\). Wenn die Elemente in "zufälliger" Reihenfolge eingefügt werden, dann sieht der Baum "schön" aus. Das Phänomen kennen wir bereits von Quicksort.In diesem Abschnitt lernen wir eine recht einfache Datenstruktur kennen, die unseren BST so aussehen lässt, als seien die Elemente in zufälliger Reihenfolge eingefügt worden, und erreicht damit eine effiziente Laufzeit beim Suchen, Einfügen und Löschen.
Gehen wir einen Schritt zurück. Ein Binärbaum ist entweder ein leerer Baum \(\square\) oder besteht aus einem Wurzelknoten und zwei Teilbäumen:
.So ein Binärbaum ist ein binärer Suchbaum (BST), wenn jeder innere Knoten (also der nicht ein Blatt ist) einen Schlüssel \(x\) (auch Key genannt) enthält und alle Keys, die im linken Teilbaum \(L\) vorkommen, kleiner als \(x\) sind, und alle Keys, die im rechten Teilbaum \(R\) vorkommen, größer als \(x\) sind.
Heaps
Zusätzlich verlangen wir in diesem Kapitel, dass alle Knoten unterschiedliche Gewichte haben.
Stellen Sie sich nun vor, wir hätten einen Binärbaum \(T\),
in dem jeder innere Knoten u
einen
Schlüssel key(u)
und auch ein
Gewicht weight(u)
hat. Wenn
\(T\) mit seinen Keys ein BST ist und gleichzeitig
mit seeinen Keys ein Heap, dann nennen wir \(T\)
einen Treap. Das ist ein Kofferwort
aus tree und heap. Im folgenden Beispiel
sind die Keys die blauen Buchstaben und die Gewichte
die roten Zahlen.
{(a, 84),
(b, 29),
(c, 95),
(d, 47),
(e, 12),
(f, 86),
(g, 17),
(h, 94)}
Die blauen Buchstaben sind die Keys, die roten Zahlen sind die Gewichte.
Erklären Sie ihrem Sitznachbarn, wie Sie vorgegangen sind.
(k, w)
,
in der kein Key und kein Gewicht doppelt vorkommen. Zeigen
Sie, dass es genau einen Treap für diese Menge gibt.
Suchen in einem Treap
Da Treaps Suchbäume in Bezug auf Ihre Keys sind, geht das Suchen genauso wie in BSTs.
Die Laufzeit von find
ist proportional zur
Tiefe des Knotens, der den gesuchte Key
x
enthält.
x
nicht enthält,
wie können Sie dann die Laufzeit
von find
beschreiben?
Einfügen in einen Treap
Um ein neues Element in einen Treap einzufügen, gehen
wir in zwei Phasen vor. In der ersten betrachten
wir nur die Keys und fügen das neue Paar
(x,
w)
ein wie in einen BST.
Das Problem ist, dass nach dieser Operation
unser Baum zwar ein BST ist, aber möglicherweise
kein Heap mehr: eventuell ist
w
zu schwer und
wir müssen es nach oben treiben lassen.
Dass wir es weiter nach unten sinken lassen
müssen, kann nicht sein: nach dem BST-Einfügen
ist
(x,
w)
ein Blatt (bzw. hat zwei leere Teilbäume, je nachdem, ob Sie
die Blätter \(\square\) zeichnen oder nicht).
In einer Phase lassen wir nun
(x,
w)
nach oben treiben, solange das Gewicht kleiner
ist als das des Elternknoten. Das ist
aber komplizierter als in Heapsort, weil wir
immer die BST-Bedingung mit im Kopf behalten müssen!
Wir können also nicht einfach
swap(u, parent(u))
aufrufen.
Wir demonstrieren es an einem Beispiel.
Meiner Meinung nach ist in diesem Falle der Pseudo-Code aber tatsächlich leichter zu verstehen als das Beispiel.
Löschen aus einem Treap
In einem BST löschen wir einen Key \(x\), indem wir zuerst den Teilbaum
finden, dessen Wurzelknoten \(x\) enthält, und dann den Wurzelknoten
mit dem Minimum des rechten Teilbaums (alternativ: dem Maximum des linken Teilbaums)
vertauschen. Mit Treaps geht das nicht, weil dieser Knoten eventuell das falsche
Gewicht hat. Wir gehen einen anderen Weg: wir lokalisieren den zu löschenden Knoten
(x, w)
und setzen dann sein Gewicht auf unendlich. Er ist nun also zu schwer
für seine Position und muss nach unten sinken, bis er zu einem Blatt geworden ist.
Dann können wir ihn ohne Probleme löschen.
Der erste Teil, das Lokalisieren, geht wie für BSTs:
Die eigentliche Arbeit findet nun in
delRoot
statt. Wir gehen recht anders vor als
für BSTs. Wir können nicht einfach das kleinste Element
\(R\) finden und an die Wurzel hochziehen, da das
womöglich die Heap-Bedingung verletzt (Gewicht könnte zu groß sein).
Nein, wir gehen anders, top-down vor. Falls
ein Teilbaum der Wurzel leer ist, ist es einfach:
Ansonsten sieht die Lage so aus:
wobei ich in die Wurzel keine Buchstaben reinschreibe, weil die ja eh gelöscht werden soll. Als erstes müssen wir entscheiden, welcher Knoten die Wurzel des Ergebnis-Treaps ist. Entweder(y, v)
oder
(z, w)
,
je nachdem, welcher davon "leichter" ist, wessen Gewicht also kleiner ist.
Also:
Finden Sie einen Treap und eine insert
-Operation,
die die Höhe reduziert!
Treap als randomisierte Datenstruktur und Programmierübung
Um Treaps zu einer "lieferfertigen" Datenstruktur zu machen, müssen wir festlegen, wie die Gewichte bestimmt werden. Sehen Sie, unsere "Kunden", also die User, die unseren Treap verwenden, wollen jatreap.insert("dog")schreiben und nicht
treap.insert("dog", 74)Die Gewichte dienen ausschließlich der internen Organisation und sollen nicht von außen sichtbar sein. Also wählen wir für jedes neue Element ein zufälliges Gewicht, und zwar eine zufällige reelle Zahl zwischen 0 und 1. In Java-Syntax:
private void insert(A key, B value, double weight) {
// many lines of code hidden here
}
public void insert(A key, B value) {
this.insert(key, value, Math.random());
}
Implementieren Sie mir einen BST. Die Programmiersprache
dürfen Sie sich aussuchen. Da ich mich nicht durch
hunderte Lines of Code wühlen will, geben Sie Ihrem
Programm ein "User-Interface". Es soll einfache Befehle
wie insert, find, delete
von der Konsole lesen können und sich folgendermaßen bedienen lassen:
java BST # oder eben 'python BST.py'
insert dog Hund
insert cat Katze
insert bear Baer
insert whale Wal
insert wolf Wolf
find wolf
true Wolf
find squid
false
insert squid Tintenfisch
find dog
true Hund
find cat
true Katze
delete dog
find dog
false
Wenn der Input zu Ende ist, soll das Programm terminieren.
Achten Sie genau auf das Format, damit ich mit
grep
Ihren Output mit meinem (hoffentlich korrekten)
Output vergleichen kann.
Wiederholen Sie alles, nun aber mit Treap. Das User-Interface soll identisch sein.
Schreiben Sie Ihren Namen als Kommentar in den Quelltext. Wir wollen ja dann einen "Sieger" verkünden.
Laufzeitanalyse
Sei \(S = \{(x_1, w_1), \dots, (x_n,w_n)\}\) eine Menge von Key-Weight-Paaren, in der kein Key und kein Schlüssel doppelt vorkommt. Für zwei Keys \(x, y\) bezeichnen wir mit \([x:y]\) die Menge der Key-Weight-Paare \((k,w) \in S\) für die \(x \leq k \leq y\) oder \(y \leq k \leq x\) gilt, wo also der Key \(k\) zwischen \(x\) und \(y\) liegt.
Beispiel. Wenn
{S = (a, 84),
(b, 29),
(c, 95),
(d, 47),
(e, 12),
(f, 86),
(g, 17),
(h, 94)}
,
dann ist
[e:b] = {(b, 29),
(c, 95),
(d, 47),
(e, 12)}
.
Die Richtung \(A \Longleftarrow B\). Wir werden das Kontrapositiv beweisen, also \(\neg A \Longrightarrow \neg B\). Wir nehmen an, dass \((x,u)\) kein Vorfahre von \((z,w)\) ist, und müssen nun ein Element in \([x:z]\) finden, das leichter ist als \((x,u)\). Wir unterscheiden zwei Fälle.
- 1. Fall: \(z\) ist ein Vorfahre von \(x\). Aus der Heap-Eigenschaft folgt nun, dass \(w \lt u\) ist, und somit ist \( (z,w)\) leichter als \( (x,u)\).
- 2. Fall: \(z\) ist kein Vorfahre von \(x\). Nun ist nach Annahme weder \(x\) Vorfahre von \(z\) noch umgekehrt. Also haben sie einen gemeinsamen Vorfahren: \( (y,v)\). Da \(T\) ein binärer Suchbaum ist, gilt \(x \lt y \lt z\) und somit \( (y,v) \in [x:z]\). Da \(T\) ein Heap ist, gilt \(v \lt u\), also ist \((y,v)\) leichter als \((x,u)\).
Die Richtung \(A \Longrightarrow B\). Wir zeigen wiederum das Kontrapositiv \(\neg B \Longrightarrow \neg \A\). Wir nehmen also an, dass \((x,u)\) nicht das leichteste Element in \([x:z]\) ist und müssen nun zeigen, dass \(x\) kein Vorfahre von \(z\) ist. Sei nun also \((y,v)\) das leichteste Element in \([x:z]\). Es ist nicht ausgeschlossen, dass \((y,v) = (z,w)\) ist, a allerdings gilt nach Annahme \((y,v) \ne (x,u)\).
Wir bauen nun den Treap, in dem wir die Elemente von leicht nach schwer sortiert in einen BST einfügen. Wir pausieren, nachdem \((y,v)\) eingefügt worden ist. Sei \(T'\) der nun entstandene Treap, und sei \(p\) der Pfad von der Wurzel bis zum Elternknoten von \((y,v)\). Kein Element auf \(p\) ist in \([x:z]\), also sind alle Keys in \(p\) entweder kleiner als \(x\) oder größer als \(z\). Das heißt: jedes weitere Element aus \([x:z]\), dass wir nach der "Pause" einfügen, wird zuerst den Pfad \(p\) entlanglaufen, bei \((y,v)\) ankommen, und dann entweder zum linken oder zum rechten Kind von \((y,v)\) weitergeschickt. Das Element \((x,u)\) landet im linken Teilbaum von \((y,v)\), weil ja \(x \lt y\) gilt. Das Element \((z,w)\) landet im rechten Teilbaum oder ist \((y,v)\) selbst. In beiden Fällen ist \((x,u)\) kein Vorfahre von \((z,w)\).
Wir haben nun beide Richtungen gezeigt. \(\square\)
Wir führen nun etwas Notation ein. Sei \(S = \{(x_1, w_1), \dots, (x_n,w_n)\}\) die Menge der Schlüssel-Wert-Paare im Treap \(T\) und \(K = \{x_1,\dots,x_n\}\) die Menge der Schlüssel. Für einen Schlüssel \(x \in K\) bezeichnen wir mit \(w(x)\) sein Gewicht, also das (eindeutige) \(w\), für das \((x,w)\in S\) gilt. Für zwei Schlüssel \(x,y\) definieren wir
\begin{align*} I_{x,y} := \begin{cases} 1 & \textnormal{ wenn \(x\) ein Vorfahre von \(y\) ist, }\\ 0 & \textnormal{ sonst.} \end{cases} \end{align*}Wir demonstrieren anhand mehrerer Beispiele, wie \(S\), der Treap für \(S\) und die Werte \(I_{x,y}\) zusammenhängen. Im folgenden Stellen wir \(I_{x,y}\) als \((n \times n)\)-Matrix \(I\) dar (wobei wir aber die Stellen, die 0 sind, einfach leer lassen).
Die Anzahl der Einsen in der Zeile von \(x\) ist die Anzahl der inneren Knoten im Unterbaum von \(x\).
Wenn wir nun die Gewichte zufällig wählen, dann werden Treap und die obige Matrix zu Zufallsvariablen, sie nehmen also je nach konkretem Ausgang des Zufallsexperiments verschiedene Gestalt an. Somit varriert auch \(\depth(x,T)\) von Experiment zu Experiment. Uns interessiert der Erwartungswert, wie weit \(x\) also im Schnitt von der Wurzel des Treaps entfernt ist. Fragen wir uns nun, was denn die Matrix \(I\) im Erwartungswert für eine Gestalt annimmt. Fokusieren wir auf zwei konkrete Schlüssel \(x, y\). Nach dem obigen Theorem nimmt \(I_{x,y}\) den Wert 1 genau dann an, wenn \(x\) das leichteste aller Elemente in \([x:y]\) ist. Mit welcher Wahrscheinlichkeit geschieht dies? Nun, alle Gewichte sind zufällig, und eines der Elemente in \([x:y]\) muss schließlich das Leichteste sein; jedes Element ist mit gleicher Wahrscheinlich das Leichteste. Also ist \(I_{x,y}\) mit Wahrscheinlichkeit \(\frac{1}{|[x:y]}\) gleich 1.
Die erwartete Tiefe des kleinsten Schlüssels (und auch des größten Schlüssels) ergibt sich daher nach der Formel
\begin{align*} \mathbb{E}[\depth(a,T)+1] = 1+ \frac{1}{2} + \frac{1}{3} + \cdots + \frac{1}{n} = H_n \ . \end{align*}Die obige Summe hat einen Namen: die harmonische Zahl \(H_n\). Wir kennen zwar keine "explizite" Formel dafür, werden aber weiter unten eine recht präzise Abschätzung zeigen. Für Schlüssel, die mehr "in der Mitte" kommen, ist die Formel für die erwartete Tiefe etwas komplizierter.
Als nächstes zeigen wir eine präzise Abschätzung für \(H_n\):
In der oberen Abbildung sehen Sie den Graph der Funktion \(x \mapsto \frac{1}{x}\) in blau; die Fläche unterhalb der Kurve zwischen \(x=1\) und \(x=n\) ist \begin{align*} \int_1^n \frac{1}{x} dx = \ln(n) \ . \end{align*} Die roten Balken haben eine Fläche von \(H_n-1\); da alle roten Balken unterhalb der blauen Kurve liegen, folgern wir \begin{align*} H_n - 1 \leq \ln(n) \ . \end{align*} Um eine obere Schranke herzuleiten, finden wir Balken, die die blaue Kurve von oben einschränken:
Die violetten Balken haben eine Gesamtfläche von \(H_n - \frac{1}{n}\); ihre Oberkanten liegen allesamt oberhalb der blauen Kurve, und daher gilt \begin{align*} \ln(n) \leq H_n - \frac{1}{n} \ . \end{align*} \(\square\)find,insert,delete
in einem Treap haben
erwartete Laufzeit \(O(\log n)\).
find,insert,delete
ist klar,
dass die Laufzeit höchstens proportional zur Tiefe des Baumes ist;
die Tiefe des Baumes ist die maximale Tiefe aller Knoten, also
\(\max_{x} \depth(x,T)\). Zwar gilt in der Tat
\(\depth(T) \in O(\log n)\), allerdings ist dies ein wenig komplizierter.
Was wir oben bewiesen haben, ist dass die erwartete Tiefe eines einzelnen
Elements \(x\) höchstens \(2H_n - 1\). Dies ist aber ausreichend,
um die Laufzeitschranke zu zeigen.
-
find x
. Falls \(x \in K\), dann ist die Laufzeit vonfind
proportional zu \(\depth(x,T)\), was im Erwartungswert höchstens \(2H_n-1 \in O(\log n)\) ist. Schwieriger wird es, wenn \(x \not \in K\). Dann wirdfind
in jedem Fall bis zu einem Blatt von \(T\) laufen und dannnull
bzw.Nothing
zurückliefern. Wie können wir die Tiefe dieses Blattes beschränken? Seien \(y \in K\) der größte Schlüssel mit \(y \lt x\) und \(z \in K\) der kleinste Schlüssel mit \(z \gt x\). Wir erkennen, dass der letzte innere Knoten, denfind x
besucht, entweder der Knoten von \(y\) oder \(z\) ist. Die erwartete Anzahl der besuchten inneren Knoten ist also \begin{align*} \mathbb{E}[\max (\depth(y,T), \depth(z,T))] \ . \end{align*} Ein Maximum zweier Zufallsvariablen abzuschätzen ist nicht immer einfach. Wir behelfen uns mit einem Trick: \(max (a,b) \leq a + b\), und daher gilt \begin{align*} \mathbb{E}[\max (\depth(y,T), \depth(z,T))] & \leq \E[\depth(y,T) + \depth(z,T)] \leq 4 H_n - 2 \in O(\log n) \ . \end{align*} -
insert x
. Beim Einfügen plazieren wir \(x\) erst einmal an einem Blatt von \(T\), so als wäre \(T\) nur ein BST und kein Treap. Dann lassen wir \(x\) mittels Rechts- oder Linksrotationen hochsteigen, bis es seinen richtigen Platz gefunden haben wird. Sei \(d := \depth(x,T)\) die Tiefe von \(x\) im resultierenden Treap. Sei \(t\) die Tiefe, die \(x\) eingenommen hat, als wir es als Blatt eingefügt haben, bevor wir also die Rotationen durchgeführt haben. Dies beschreibt auch die Laufzeit dieser ersten Phase. In Phase zwei schieben wir pro Rotationen \(x\) eine Ebene nach oben, also führen wir \(r := t-d\) Rotationen aus; die Gesamtlaufzeit ist also proportional zu \(t + t - d = d + 2r\). Wir wissen bereits, dass \(E[d] \in O(\log n)\) ist, weil das ja die Tiefe von \(x\) im wiederhergestellten Treap \(T\) ist. Wie steht es um \(r\), die Anzahl der Rotationen? Hier verwende ich eine wohl recht suboptimale Abschätzung: bei jeder Rotation gewinnt der an \(x\) hängende Unterbaum einen weiteren Knoten hinzu, \(x\) gewinnt also einen weiteren Nachkommen. Daher ist \(r+1\) höchstens die Anzahl der Knoten im Unterbaum von \(x\), also \begin{align*} r+1 = \sum_{y} I_{x,y} \ , \end{align*} und ist daher im Erwartungswert genau die Tiefe von \(x\). Wir folgern: \begin{align*} \E[d + 2r] = \E[3d] \in O(\log n)\ . \end{align*} -
delete x
. Dies führt zu allererst die gleichen Schritte aus diefind x
, also \(O(\log n)\) viele; sobald es \(x\) in \(T\) lokalisiert hat, ruft esdeleteRoot
am entsprechenden Unterbaum auf. Dies führt eine Reihe von \(r\) Rotationen aus, um \(x\) nach unten zu bewegen, bis eines seiner Kinder ein Blatt ist. In jeder Rotation nimmt die Anzahl der Nachkommen von \(x\) ab; \(x\) hatte anfangs also mindestens \(r+1\) Nachfahren (sich selbst eingeschlossen, daher die \(+1\)), und somit ist \begin{align*} r+1 \leq \sum_{y} I_{x,y} \ . \end{align*} Wir folgern, dass auchdelete x
höchstens \(O(\log n)\) Schritte durchführt.