diff --git a/300_Datenstrukturen.tex b/300_Datenstrukturen.tex index dccd330f2147c3c247eeb1ed771b509f5bd94521..6c77917c0aaa7864f4185b91b1d5ad5e5f6e1bb4 100644 --- a/300_Datenstrukturen.tex +++ b/300_Datenstrukturen.tex @@ -4,4 +4,4 @@ \include{302_Hashtabellen} \include{303_Binaerbaeume} \include{304_Halden} -%\include{305_2-4-Baeume} +\include{305_RBT} diff --git a/305_RBT.tex b/305_RBT.tex new file mode 100644 index 0000000000000000000000000000000000000000..5e5c5bdc779032d5841fc95400498c49ce7202bc --- /dev/null +++ b/305_RBT.tex @@ -0,0 +1,232 @@ +\section{Red-Black-Trees (RBT)} + +\subsection{Balancierte Bäume und Definition} + +Wir haben in vorherigen Beispielen und Übungsaufgaben gesehen, dass Binärbäume leicht degenerieren können und ihre Höhe +dann nicht mehr $h \simeq \log_2(n)$ entspricht. Da alle Baumoperationen in $\mathcal{O}(h)$ liegen, ist das ein fatales +Problem. Der Lösungsansatz liegt auf der Hand: Wir müssen die Bäume balanciert halten. + +Dazu gibt es mehrere Ansätze, der älteste ist der AVL-Baum (1962). In dieser Verfeinerung eine Binärbaums wird sofort ein relativ ressourcensparender +Balanciermechanismus, das Rotieren (todo: PIC) gestartet, sobald die Höhe des Linken Unterbaums sich von der Höhe des rechten Unterbaums um +$\leq 2$ unterscheidet. Dieses Modell ist sehr strikt und einfach zu verstehen, aber durch die Striktheit wird der +Balanciermechanismus öfter aktiviert, als eigentlich nötig wäre. + +Eine andere Möglichkeit sind $(2-4)$-Bäume. Diese sind keine Binärbäume, sondern jedes nicht-Blatt hat zwei, drei oder +vier Kinder. +Durch das Speichern der Nutzdaten in Blättern ist damit die Höhe $\log_4 n ≤ h ≤ log_2 n$ garantiert. +Wird beim Einfügen die Obergrenze von vier Kindern verletzt, wird der entsprechende Knoten in zwei Knoten +aufgeteilt. Damit kann diese Verletzung nach oben weitergegeben werden, aber nach spätestens $h$ Schritten ist diese +Bedingung wieder erfüllt. +Analog kann beim Löschen eines Blattes die Untergrenze verletzt werden. Zur Korrektur werden nun Nachbarknoten +zusammengelegt, potentiell wieder rekursiv bis zur Wurzel. + +Da nicht-Binärbäume aufwendig in der Formalisierung und Implementierung sind, schauen wir uns ein sehr verwandtes Konzept an: +Den Red-Black-Tree. + +\begin{definition}[RBT] + Red-Black-Trees (RBT) über einen Datentyp $D$ sind Binäre Suchbäume, bei denen jeder Knoten zusätzlich eine Farbe aus + $\{R,B\}$ abspeichert. + + Folgende zwei Invarianzen müssen zudem immer gewahrt werden: + \begin{itemize} + \item[lokal] Ein Roter Knoten darf keinen roten Kindknoten haben. + \item[global] Jeder Pfad von der Wurzel zu einem Blatt durchschreitet die gleiche Anzahl schwarzer Knoten. + \end{itemize} +\end{definition} + +Per Konvention nehmen wir an, dass der Wurzelknoten immer Schwarz ist. +Wenn der kürzeste vorstellbare Pfad von der Wurzel zu einem Blatt in einem RBT-Tree durch $l$ ausschließlich durch schwarze Knoten geht, ist der längste +Pfad dann durch $2l$ Knoten, immer einen Schwarzen und einen roten. Dadurch ist gewährleistet, dass das höchste Blatt +maximal halb so hoch hängt wie eins auf der untersten Ebene. + +Dadurch folgt: +\begin{lemma} + In einem nichtlehren Red-Black-Tree der Größe $n$ liegt die Höhe $h$ in $Θ(\log n)$. + \label{lemma:RBT_height} +\end{lemma} +Die Details des Beweises sind eine Übungsaufgabe. + +\subsection{Einfügen in Red-Black-Trees} + +Da RBT insbesondere Binäre Suchbäume sind, sind die Operationen zum Suchen von Elementen, dem Finden des Maximums, +Minimums, Nachfolgers etc identisch. + +Auch die grundsätzliche Idee des Einfügens bleibt gleich: Wie bei BST gehen wir von der Wurzel abwärts und suchen dabei +den passenden Platz für das neue Element. Eingefügte Elemente sind also erst einmal Blätter. + +Würde man das neue Blatt schwarz färben, wäre sofort die globale Invarianz verletzt. Daher färben wir das neue Blatt +rot. Allerdings kann es dabei zu einer Verletzung der lokalen Invarianz kommen. Skizze \ref{fig:rbt_balance} +veranschaulicht dabei, welche Fälle auftreten können und wie der rebalancierte Baum aussieht: + +\begin{figure}[h!] + \centering + \input{bilder/rbt_balance.tex} + \caption{Die potentiellen Verletzung der lokalen Invarianz nach dem Einfügen und wie sie ausbalanciert werden.} + \label{fig:rbt_balance} +\end{figure} + +\paragraph{Suchen in (2-4)-Bäumen} + +Mit Hilfe der Hilfsinformationen in den inneren Knoten kann, von der Wurzel +absteigend, der entsprechende Wert gesucht werden. Pro Knoten +benötigt der Suchprozess eine konstante Zeit $\mathcal{O}(1)$. Die gesamte +Laufzeit hängt also direkt von der Höhe des Baumes ab und ist damit $Θ(\log n)$. +Wir starten dabei folgenden Algorithmus mit $\texttt{search\_24}(\texttt{root}(\mathcal{B}_{2,4}), v)$. + +\begin{algorithm}[h] + \SetNlSty{texttt}{[}{]} + \caption{\texttt{search\_24}($k, v$)} + \KwIn{A node $k$ and and a data value $v$.} + \KwOut{The node $k'$ of the subtree of $k$ which has $\texttt{data}(k') = v$. an error, if there is no such $k'$.} + \eIf{$α(k) = 0$}{ + \eIf{$\texttt{data}(k) = v$}{ + \KwRet $k$ \; + }{ + \texttt{error: value not found} \; + } + }{ + \uIf{$\texttt{child}_3(k) \neq \texttt{void\_node}$ and $m_2 < v$}{ + \KwRet $\texttt{search\_24}(\texttt{child}_3(k), v)$ \; + } + \uElseIf{$\texttt{child}_2(k) \neq \texttt{void\_node}$ and $m_1 < v$}{ + \KwRet $\texttt{search\_24}(\texttt{child}_2(k), v)$ \; + } + \uElseIf{$m_0 < v$}{ + \KwRet $\texttt{search\_24}(\texttt{child}_1(k), v)$ \; + } + \Else{ + \KwRet $\texttt{search\_24}(\texttt{child}_0(k), v)$ \; + } + } +\end{algorithm} + + +\paragraph{Einfügen in (2-4)-Bäumen} + +Wollen wir in den Baum $\mathcal{B}_{2,4}$ ein Element einfügen, so müssen wir zuerst mittels +$\texttt{search\_insert\_node}$ den richtigen inneren Knoten $k$ in der vorletzten Ebene suchen. +Das geschieht analog zu $\texttt{search\_24}$, nur, dass wir statt dem Blatt bzw Fehler den entsprechenden +Elternknoten inneren Knoten. +Dann fügen wir vermöge $\texttt{insert\_at\_node}(k,l)$ den neuen Knoten $l$ an der passenden Stelle bei $k$ ein (sodass +die Reihenfolge nicht verletzt wird). +Nun müssen wir zwei Fälle unterschieden: +\begin{enumerate} + \item $\alpha(k)\leq 4$ nach dem Einfügen: Damit ist $\mathcal{B}_{2,4}$ weiterhin ein (2-4)-Baum. + \item$\alpha(k)=5$ nach dem Einfügen: Es liegt nun \emph{kein} (2,4)-Baum vor. Um den Fehler zu beheben, führen wir + $\texttt{split}$ aus. Dieser Algorithmus spaltet die zwei rechtesten Knoten von $k$ ab in einen neuen Knoten + $k'$, welcher rechts von $k$ am gleichen Elternknoten eingehängt wird. + Dabei kann es vorkommen, dass auch der Elterknoten nun $5$ Kinder hat, weswegen wir unter Umständen + $\texttt{split}$ wiederholt aufrufen müssen, bis wir an der Wurzel sind. Muss auch die Wurzel aufgespalten werden, + wird eine neue Wurzel definiert, die dann die zwei Kinder $k$ und $k'$ hat. +\end{enumerate} +\begin{center} +\includegraphics[scale=0.2]{bilder/Spalten} +\par\end{center} +In Pseudocode: + +\begin{algorithm}[h] + \SetNlSty{texttt}{[}{]} + \caption{\texttt{insert\_24}($\mathcal{B}_{2,4}, k, v$)} + \KwIn{A (2-4)-Tree $\mathcal{B}_{2,4}$, the intended target node $k$ and a new value $v$ to be inserted.} + \KwOut{Side effects on the memory: $\mathcal{B}_{2,4}$ contains now $v$ in its correct place. Several splits of inner + nodes may happen to ensure (2-4)-integrity.} + $l = \text{new node with } \texttt{parent}(l) = k \text{ and } \texttt{data}(l) = v$ \; + $\texttt{insert\_at\_node}(k,l)$ \; + \uIf{$α(k) = 5$}{ + $\texttt{split}(\mathcal{B}_{2,4}, k)$ \; + } +\end{algorithm} +\begin{algorithm}[h] + \SetNlSty{texttt}{[}{]} + \caption{\texttt{split}($\mathcal{B}_{2,4}, k$)} + \KwIn{An almost-(2-4)-Tree $\mathcal{B}_{2,4}$ which is violated in node $k$ (too many child nodes).} + \KwOut{Side effects on the memory: $\mathcal{B}_{2,4}$ is modified by splitting the violating nodes recursivly. After + that, $\mathcal{B}_{2,4}$ is a flawless (2-4)-tree again.} + \uIf{$k = \texttt{root}(\mathcal{B}_{2,4})$}{ + $r = \text{new node with } \texttt{child}_0(r) = k$ \; + $\texttt{parent}(k) \leftarrow r$ \; + $\texttt{root}(\mathcal{B}_{2,4}) \leftarrow r$ \; + } + $k' = \text{new node with } \texttt{parent}(k') = \texttt{parent}(k)$ \; + $\texttt{child}_0(k') \leftarrow \texttt{child}_3(k)$ \; + $\texttt{child}_3(k) \leftarrow \texttt{void\_node}$ \; + $\texttt{child}_1(k') \leftarrow \texttt{child}_4(k)$ \; + $\texttt{child}_4(k) \leftarrow \texttt{void\_node}$ \; + \texttt{insert\_at\_node}(\texttt{parent}(k), k') \; + \uIf{$α(\texttt{parent}(k)) = 5$}{ + $\texttt{split}(\mathcal{B}_{2,4}, \texttt{parent}(k))$ \; + } +\end{algorithm} + +Der Aufwand für \texttt{insert\_24} beträgt im besten Fall $\mathcal{O}(1)$ und im schlimmsten Fall +$\mathcal{O}(\log n)$, falls wir rekursiv bis zur Wurzel spalten müssen. + +Man kann aber mittels aggregierter Analyse schnell zeigen, dass der amortisierte Aufwand fürs Einfügen nur +$\mathcal{O}(1)$ beträgt. + +\subsubsection{Entfernen in (2-4)-Bäumen} + +Auch hier wird zuerst der entsprechende Knoten $k$ gesucht. Nach dem Entfernen +muss man wieder zwei Fälle unterscheiden: +\begin{enumerate} + \item Ist $\alpha(k)\geq 2$ nach dem Entfernen, so haben wir wieder einen (2-4)-Baum. + \item Ist $\alpha(k)=1$ nach dem Entfernen: Es liegt nun \emph{kein} (2,4)-Baum mehr vor. Unser Knoten $k$ muss sich + nun Kinder von anderen Knoten besorgen. Es wird zuerst ein Knoten $k'$ ausgewählt, der ein direkter Bruder von + $k$ ist: Ist $k = \texttt{child}_i(\texttt{parent}(k))$, so ist $k' = \texttt{child}_{j}(\texttt{parent}(k))$ mit $j + \in \{i-1,i+1\}$, sofern vorhanden. Ein Bruderknoten muss immer vorhanden sein, da $\texttt{parent}(k)$ immernoch + der (2-4)-Bedinungen genügen muss und mindestens 2 Kinder hat. + + Hat man $k'$ ausgewählt und hat $k'$ genug Kinder ($α(k') \geq 3$), so stiehlt man nun das nächstgrößere / nächstkleinere + Blatt von $\texttt{child}_0(k)$ von $k'$ und hängt es bei $k$ ein. Damit hat $k$ wieder $2$ Kinder und der + (2-4)-Baum ist repariert. + + Hat $k'$ allerdings selber nur $α(k')=2$ Kinder, so würde ein Stehlen die $(2-4)-Baum-Bedingung$ bei $k'$ + verletzten. Deswegen verschmilzt man $k$ und $k'$ zu einem Knoten. Allerdings kann das, analog zu \texttt{split}, + bewirken, dass $\texttt{parent}(k)$ nun auch zu wenig Knoten hat. Deswegen muss auch die verschmelz-Operation potentiell + $h \in Θ(\log n)$ mal aufgerufen werden. +\end{enumerate} + + +\begin{center} +\includegraphics[scale=0.2]{bilder/Stehlen} +\par\end{center} + +\begin{center} +\includegraphics[scale=0.2]{bilder/Verschmelzen} +\par\end{center} + +Aufgrund der vielen zu behandelnden Fallunterscheidungen verzichten wir hier auf Pseudocode. + + +\subsection{Sortieren mit (2-4)-Bäumen} +Eine primitive Methode des Sortierens mit (2-4)-Bäumen ist schnell gefunden. Wir nehmen unser zu sortierendes Array und +fügen es von Anfang bis Ende in einen anfangs leeren (2-4)-Baum ein. Der Algorithmus ist worst-case-optimal, hat aber +ansonsten keine weiteren erstrebenswerten Eigenschaften. + +In unserem Algorithmus \texttt{24treesort} gehen wir ein bisschen geschickter vor und erreichen dadurch Adaptivität. Sei +also ein zu sortierendes Array $\mathcal{A} = [a_0, \dots, a_{n-1}]$ gegeben, welches wir nun von hinten nach vorne, +also $a_{n-1}$ bis $a_0]$, in einen anfangs leeren (2-4)-Baum einfügen. + +Die Kernidee ist nun, die Suche nach dem passenden Einfügeort nicht von der Wurzel, sondern von dem bisher +linkesten inneren Knoten $k$ zu beginnen! Ist $\mathcal{A}$ nun bereits sortiert, folgen in unserer umgekehrten +Einfügereihenfolge also immer kleinere Elemente und unser $k$ ist direkt das richtige $k$ für \texttt{insert\_24}. + +Damit müssen wir nicht aufwendig den Einfügeort $k$ für \texttt{insert\_24} suchen, sondern können direkt einfügen, was +einem amortisierter Aufwand von $\mathcal{O}(1)$ entspricht. Bei $n$ Einfügeoperationen haben wir im best-case also $\mathcal{O}(n)$ Aufwand. + +Haben wir nun aber ein Element $a_i$ mit Fehlstellung $f_i$, so müssen wir den Baum aufsteigen, bis wir zu einem +passenden Knoten $k'$ kommen ($k'$ hat $m_0 > a_i$) und von dort wieder absteigen. +Wie viele Knoten müssen wir aber hoch? Da alle Elemente $a_{n-1}$ bis $a_{i+1}$ bereits sortiert sind, müssen wir nur +genau $f_i$ viele Elemente überspringen, also maximal $\log_2 f_i$ Knoten aufsteigen und wieder absteigen. Dies entspricht +also dem Zusatzaufwand $\mathcal{O}(\log f_i)$ für jede Fehlstellung. + +Damit ist der Zeitaufwand für alle $n$ Elemente +\[ + \mathcal{O}(n + \sum_{i=1}^n \log f_i) = \mathcal{O}(n + n \log \left( \frac{F}{n} \right)), +\] +wobei wir für die Umformung $\sum_{i=1}^n \log f_i = \log Π_{i=1}^n f_i \leq \log \left( \frac{F}{n} \right)^n = n \log +\left( \frac{F}{n} \right)$ benutzen. + +Damit sind die Bedingungen für Adaptivität auch im strengeren Sinne erfüllt. + +TODO: Pseudocode für \texttt{24treesort}, falls es sich lohnt. diff --git a/bilder/rbt_balance.tex b/bilder/rbt_balance.tex new file mode 100644 index 0000000000000000000000000000000000000000..515b39b50c2f735af6dc8b6f24a413fd9c3207d2 --- /dev/null +++ b/bilder/rbt_balance.tex @@ -0,0 +1,69 @@ +\tikzsetnextfilename{rbt_balance} +\begin{tikzpicture}[ + treenode/.style = {align=center, inner sep=0pt, text centered, font=\sffamily}, + node_black/.style = {treenode, circle, white, font=\sffamily\bfseries, draw=black, %TODO: Make font thick. + fill=black, text width=1.5em},% arbre rouge noir, noeud noir + node_red/.style = {treenode, circle, red, draw=red, + text width=1.5em, very thick},% arbre rouge noir, noeud rouge + subtree/.style = {treenode, circle, text width=1.5em}, + level/.style={sibling distance = 5cm/#1, + level distance = 1.5cm}, + scale=0.4 + ] + \begin{scope} % transformation target + \node [node_red] (ty) {$y$} + child{ node [node_black] (tx) {$x$} + child{ node [subtree] (ta) {$a$}} + child{ node [subtree] (tb) {$b$}}} + child{ node [node_black] (tz) {$z$} + child{ node [subtree] (tc) {$c$}} + child{ node [subtree] (td) {$d$}}} + ; + \end{scope} + + \begin{scope}[shift={(10,0)}] % case 1: right-right + \node [node_black] (c1x) {$x$} + child{ node [subtree] (c1a) {$a$}} + child{ node [node_red] (c1y) {$y$} + child{ node [subtree] (c1b) {$b$}} + child{ node [node_red] (c1z) {$z$} + child{ node [subtree] (c1c) {$c$}} + child{ node [subtree] (c1d) {$d$}}}} + ; + \end{scope} + + \begin{scope}[shift={(0,8.5)}] % case 2: left-right + \node [node_black] (c2z) {$z$} + child{ node [node_red] (c2x) {$x$} + child{node [subtree] (c2a) {$a$}} + child{ node [node_red] (c2y) {$y$} + child {node [subtree] (c2c) {$c$}} + child {node [subtree] (c2b) {$b$}}}} + child{ node [subtree] (c2d) {$d$}} + ; + \end{scope} + + \begin{scope}[shift={(-10,0)}] % case 3: left-left + \node [node_black] (c3z) {$z$} + child{ node [node_red] (c3y) {$y$} + child{ node [node_red] (c3x) {$x$} + child {node [subtree] (c3a) {$a$}} + child {node [subtree] (c3b) {$b$}}} + child {node [subtree] (c3c) {$c$}}} + child{ node [subtree] (c3d) {$d$}} + ; + \end{scope} + + \begin{scope}[shift={(0,-7.5)}] % case 4: right-left + \node [node_black] (c4x) {$x$} + child{ node [subtree] (c4a) {$a$}} + child{ node [node_red] (c4z) {$z$} + child{ node [node_red] (c4y) {$y$} + child{ node [subtree] (c4b) {$b$}} + child{ node [subtree] (c4c) {$c$}}} + child{ node [subtree] (c4d) {$d$}}} + ; + \end{scope} + + +\end{tikzpicture}