4.5 B-Bäume
B-Bäume sind ähnlich wie binäre Suchbäume, allerdings erlauben wir nun, dass ein Knoten mehrere Schlüssel speichert. Wir führen folgende Schreibweise ein: wenn \(X\) eine Menge von Schlüsseln und \(y\) ein einzelner Schlüssel ist, dann bedeutet \(X \lt y\), dass \(x \lt y\) für alle \(x \in X\) gilt. Für einen Baum oder Teilbaum \(T\), der Schlüssel speichert, bezeichnen wir mit \(\keys(T)\) die Menge der Schlüssel, die in \(T\) gespeichert sind.- jedes Blatt gleichen Abstand der Wurzel hat,
- jeder innere Knoten, der Kinder \(T_0, T_1, \dots, T_k\) hat, eine Folge von genau \(k\) Schlüsseln \(x_1,\dots,x_k\) speichert, so dass \begin{align*} \keys(T_0) \lt x_1 \lt \keys(T_1) \lt x_2 \lt \dots \lt \keys(T_{k-1}) \lt x_k \lt \keys(T_k) \ \end{align*} gilt.
Ich gebe zu, es ist kein schöner Anblick, dass der Elternknoten von \(e\) keinen Schlüssel
speichert und nur ein Kind hat. Gewöhnen Sie sich aber an diesen Anblick; sie werden
ihn gleich in der delete
-Operation zumindest zeitweise ertragen müssen.
Das obige Beispiel ist also kein \((2,3)\)-Baum: der Elternknoten von \(e\) hat nur ein Kind, also zu wenige, und der Wurzelknoten hat vier Kinder, also zu viele. Hier wäre ein \((2,3)\)-Baum für die gleiche Schlüsselmenge:
Als Datenstruktur ist ein \((2,3\)-Baum etwas schwieriger zu speichern und zu unterhalten als ein Treap; dafür ist die Laufzeitanalyse ein Kinderspiel:
Die Laufzeiten aller Operationen insert, find, delete
werden
propotional zur Höhe des Baumes und somit \(O(\log n)\) sein.
Wir werden sie nun im Einzelnen beschreiben.
find
geht so wie in BSTs, nur dass wir in Knoten mit zwei Schlüsseln \(x_1,x_2\) den gesuchten Schlüssel \(k\) im Allgemeinen mit \(x_1\) und mit \(x_2\) vergleichen müssen, um zu bestimmen, in welchem Teilbaum wir fortfahren.insert x
sucht zu allererst den tiefsten inneren Knoten und fügt ihn dort an der richtigen Stelle ein (ich zögere, Blatt zu sagen, weil nach Definition die Blätter \(\square\) keine Schlüssel speichern und in den obigen Abbildungen auch gar nicht gezeichnet wurden). Wenn in dem untersten Knoten noch Platz ist, dann ist alles in Ordnung: Allerdings kann es vorkommen, dass wir nun unten einen Knoten mit zu vielen, also drei Schlüsseln haben:Der Knoten ist nun zu schwer geworden und zerfällt zwei Teilknoten und sendet den mittleren Schlüssel nach oben. Der Knoten darüber, also \(l n\), wird dadurch zu \(l n p\) und zerfällt seinerseits. Wir stellen das in dieser kleinen Animation dar:
Der Zyklus zu schwer - zerbrechen - Mittelschlüssel nach oben weiterschieben wird wiederholt, bis der Schlüssel in einen Elternknoten hochgeschoben wird, der noch Kapazität hat (also zuvor nur einen Schlüssel), oder bis zur Wurzel. Wenn die Wurzel zerfällt, entsteht ein neuer Wurzelknoten mit nur einem Schlüssel, und die Höhe des Baumes wächst um 1. Auch in diesem Fall finde ich das konkrete Beispiel fast weniger lehrreich als den "graphischen Pseudocode" für
insert:
Die Buchstaben \(L, R\) stehen für die Teillisten Baum/Schlüssel.../Baum, die links bzw. rechts von dem Pfeil stehen und von denen einen auch leer sein kann.
delete x
ist etwas komplizierter alsinsert
. Zuerst lokalisieren wir \(x\) (falls \(x \not \in \keys(T)\), dann werden wir das feststellen, wenn wir an einem Blatt landen, und müssen nichts weiter tun); wenn \(x\) in einem untersten Knoten liegt, löschen wir es einfach erst mal; ansonsten, wenn \(x\) weiter oben liegt, sei \(y\) das Maximum im linken Teilbaum von \(x\); der Schlüssel \(y\) liegt in einem untersten Knoten; wir verschieben \(y\) an die Position von \(x\) und sind nun in einer Situation, als hätten wir \(y\) gelöscht. Wir müssen uns also nur noch um den Fall kümmern, dass ein Element in einem untersten Knoten gelöscht wird. Im obigen Beispiel hatten wir Glück: der unterste Knoten hatte zwei Schlüssel und hat nach Löschen noch einen; der Baum ist also immer noch ein \((2,3)\)-Baum und wir müssen nichts weiter tun. Anders sieht es aus, wenn im untersten Knoten der letzte Schlüssel gelöscht wird, wir also nun 0 Schlüssel haben. Im allgemeinen werden wir mit der Situation konfrontiert sein, dass es (genau einen) Knoten gibt, der 0 Schlüssel (und somit genau ein Kind) hat, und dieser Knoten muss nicht unbedingt ganz unten liegen. In diesem Falle gibt es drei Möglichkeiten, den Fehler zu "reparieren":- 1. Den kleinen Bruder anschnorren. Wenn der leere Knoten einen linken Geschwisterknoten hat und dieser ein Element entbehren kann, können wir eine Rechtsrotation durchführen:
- 2. Die große Schwester anschnorren. Dies ist symmetrisch zum vorherigen Fall:
-
Verschmelzen.
Eventuell können wir keine der zwei vorherigen Maßnahmen durchführen,
z.B. weil die Geschwister selbst nur einen Schlüssel besitzen.
In diesem Falle müssen wir zwei Geschwister verschmelzen.
Zuerst beachten Sie, dass der leere Knoten in jedem Fall mindestens
ein Geschwister hat: der Elternknoten selbst ist nicht leer (es wird
temporär nur maximal ein Knoten leer sein) und hat somit mindestens zwei
Kinder. Im folgenden zeige ich die Operation, die durchzuführen ist,
wenn der leere Knoten einen kleinen Bruder hat, der selbst nur
einen Schlüssel enthält:
Rechnen wir nach: kleiner Bruder und leerer Knoten hatten zusammen drei Kinder und einen Schlüssel; nach der Operation hat der entstandene Knoten zwei Schlüssel und drei Kinder. Der Elternknoten hatte \(k \geq 1\) Schlüssel und \(k+1\) Kinder; nun verliert er einen Schlüssel \(w\), und die Zahl der Kinder nimmt durch das Verschmelzen um 1 ab: er hat nun also \(k-1\) Schlüssel und \(k\) Kinder. Wir erhalten also einen gültigen B-Baum. Wenn \(k-1 \geq 1\), dann ist das sogar ein \((2,3)\)-Baum; wenn \(k-1 = 0\), dann ist nun der Elternknoten leer und hat nur ein Kind; wir müssen nun also die gleiche Reparaturoperation am Elternknoten fortsetzen. Dies kann sich bis zur Wurzel fortsetzen; wenn der Wurzelknoten selbst leer geworden ist (und ein Kind hat), dann löschen wir den Wurzelknoten und ernennen sein Kind zur neuen Wurzel; die Tiefe des Baumes nimmt nun um 1 ab.
Es sollte klar sein, dass für Operationen die Laufzeit proportional zur Höhe des Baumes und somit \(O(\log n)\) ist.
Verallgemeinerung.
Ein B-Baum heiße \((a,b)\)-Baum, wenn jeder innere Knoten
mindestens \(a\) und höchstens \(b\) Kinder haben darf.
Oben haben wir \(a = 2, b= 3\) gewählt. Welche Werte
sind "erlaubt"?
Hierfür müssen wir uns delete
und
insert
ansehen.
Bei delete
kann ein "zu leichter" Knoten entstehen, der also
nur \(a-1\) Kinder hat; wenn wir ihn mit einem Geschwisterknoten
verschmelzen sollten, dann, weil dieser nur \(a\) Kinder hat, also
keins davon entbehren kann. Nach der Verschmelzung erhalten wir nun
einen Knoten mit \(2a - 1\) Knoten; dabei verliert der Elternknoten
ein Kind (und einen Schlüssel) und könnte seinerseits wieder "zu leicht" werden.
Kann der neu entstandene Kindknoten "zu schwer" werden? Nicht, wenn
wir verlangen, dass \(2a - 1 \leq b\) gelten muss.
Betrachten wir nun eine insert
-Operation.
Dabei kann ein "zu schwerer" Knoten mit \(b+1\) Kindern
(und \(b\) Schlüsseln) entstehen. Diesen zerbrechen wir
in zwei Teilknoten mit \(\floor{\frac{b+1}{2}}\) bzw.
\(\ceil{\frac{b+1}{2}}\) Kindern. Dabei wird der "mittlere"
Schlüssel in den Elternknoten eingefügt, wodurch dieser wiederum
zu schwer werden kann. Kann aber einer der durch Teilung neu entstandenen
Knoten zu leicht sein?
Nicht, wenn wir \(\floor{\frac{b+1}{2}} \geq a\) verlangen,
was äquivalent zu \(b+1 \geq 2a\) bzw. \(2a - 1\leq b\).
Solange also \(b \geq 2a - 1\) ist, können wir \((a,b)\)-Bäume wie oben implementieren. In der Literatur wird \(b\) manchmal als die Ordnung des B-Baumes bezeichnet. Unseren Beispielen oben haben B-Bäume der Ordnung \(3\) verwendet. Größere Ordnung hat den Vorteil, dass die Tiefe geringer wird, wir also weniger Knoten durchlaufen müssen; der Nachteil ist, dass wir pro Knoten mehr Zeit zum Suchen brauchen. Wenn wir es mit einem zweistufigen Rechnerspeicher zu tun haben, beispielsweise einem großen aber (vergleichsweise) langsamen SSD-Speicher und einem kleinen aber sehr schnellen Cache, dann bietet es sich an, die Ordnung \(b\) so zu wählen, dass ein Knoten mit allen in einen Cache-Block hineinpasst, wir also mit einem Memory-Zugriff gerade einen ganzen Knoten laden können. Der Grund ist, dass das Suchen innerhalb des Knotens nun ausschließlich im Cache stattfindet und somit viel schneller ist als vergleichbare Hauptspeicherzugriffe.