\( \newcommand{\dist}{\textnormal{dist}} \newcommand{\floor}[1]{\left\lfloor #1 \right\rfloor} \newcommand{\ceil}[1]{\left\lceil #1 \right\rceil} \newcommand{\R}{\mathbb{R}} \newcommand{\N}{\mathbb{N}} \newcommand{\pfrac}[2]{\left(\frac{#1}{#2}\right)} \newcommand{\inneighbors}{\textnormal{inNeighbors}} \newcommand{\outneighbors}{\textnormal{outNeighbors}} \newcommand{\neighbors}{\textnormal{neighbors}} \newcommand{\indeg}{\textnormal{indeg}} \newcommand{\outdeg}{\textnormal{outdeg}} \newcommand{\scc}{\textnormal{scc}} \newcommand{\Reached}{\textnormal{Reached}} \newcommand{\ETA}{\textnormal{ETA}} \newcommand{\pred}{\textnormal{pred}} \newcommand{\union}{\textnormal{union}} \newcommand{\find}{\textnormal{find}} \newcommand{\parent}{\textnormal{parent}} \newcommand{\rank}{\textnormal{rank}} \newcommand{\height}{\textnormal{height}} \newcommand{\desc}{\textnormal{desc}} \newcommand{\Desc}{\textnormal{Desc}} \)

7. Graphen mit Kantenkosten

7.5 Union by Rank

Wir beschreiben nun eine neue Union-Find-Datenstruktur. Jede Menge der Partition wird durch einen Baum mit Wurzel dargestellt. In dem Baum sind alle Kanten zur Wurzel hin gerichtet. Implementieren lässt sich das einfach durch ein Array: parent[v] ist der Elternknoten von $v$; wenn $v$ selbst Wurzel ist, setzen wir parent[v] := v. Das Label einer Menge ist der Name des Wurzelknotens. Für $\find(v)$ müssen wir also nur den $\parent$-Zeigern nachlaufen, bis wir an der Wurzel angelangt sind. Für $\union(u,v)$ finden wir erst einmal die jeweiligen Wurzeln $x$ und $y$ und hängen dann den einen Baum direkt an die Wurzel des anderen - setzen also $\parent[x] := y$. Oder umgekehrt. Wie sollen wir uns entscheiden? Die Datenstruktur RelabelMinority aus dem vorherigen Teilkapitel legt nahe, den kleineren Baum zum größeren zu verbinden. Wir tun hier etwas Ähnliches: wir verbinden den weniger tiefen Baum zum tieferen. Hier ist eine Demo:

Um das effizient zu implementieren, müssen wir etwas Buchführung treiben: für jeden Knoten $v$ merken wir seine Höhe $\height(v)$, also die Länge des längsten gerichteten Pfades $x \rightsquigarrow v$. Im unteren Bild haben also $e, c, b, g, f$ die Höhe $0$. Knoten $h$ hat Höhe 2. Knoten $i$ hat mit 3 die größte Höhe aller Knoten.

Aus Gründen, die später noch klar werden werden, weisen wir jedem Knoten einen Rang $\rank(v)$ zu. Diesen initialisieren wir anfangs auf 2. Wenn wir $\union(u,v)$ ausführen, finden wir zuerst die Wurzeln der jeweiligen Bäume: $x := \find(u)$ und $y := \find(v)$. Wir fügen nun eine Kante ein, von der Wurzel mit kleinerem Rang zu der mit größerem. Falls beide gleichen Rang haben, fügen wir die Kante $(x,y)$ ein und erhöhen den Rang von $y$ um eins. Der Kindknoten soll ja schließlich immer einen echt kleineren Rang haben als der Elternknoten. Hier sehen Sie meine Python-Implementierung:

class UnionByRank:
    def __init__(self, n):
        self.parent = [i for i in range(n)] # jedes Element ist Wurzel seines eigenen Baumes 
        self.rank = [2 for i in range(n)] # jedes Element hat Rang 2 

    def find(self,u):
        if self.parent[u] == u:
            return u 
        else:
            root = self.find(self.parent[u])
            return root
    
    def union(self, u, v):
        x = self.find(u)
        y = self.find(v)
        if (x == y):
            return # u, v sind bereits in der gleichen Menge 
        elif self.rank[x] < self.rank[y]:
            self.parent[x] = y 
        elif self.rank[x] > self.rank[y]:
            self.parent[y] = x 
        else: 
            self.parent[x] = y 
            self.rank[y] += 1

    def __str__(self):
        return (str(self.parent))

# example < < > > & & 

Übungsaufgabe Zeigen Sie, dass für jedes Element und zu jedem Zeitpunkt

\begin{align} \rank(v) = \height(v) + 2 \label{rank-is-height} \end{align}

gilt.

Nun könnten Sie sich fragen: wenn $\rank$ im Prinip das Gleiche ist wie $\height$ (nur zwei mehr), warum führen wir also die neue Bezeichnung $\rank$ ein? Der Grund ist, dass wir später die Implementierung ändern und Zeiger umbiegen werden, wodurch sich $\height(v)$ ändert, nicht aber $\rank(v)$. Die Gleichung (\ref{rank-is-height}) wird dann also nicht mehr gelten. Das obige Beispiel schaut also so aus:

Laufzeitanalyse

Die Laufzeit von find(u) ist proportional zur Länge des Pfades von $u$ zur Wurzel $x$. Dies ist höchstens die Höhe des Baumes und somit höchstens $\rank(x) - 2$. Es stellt sich nun die Frage, wie groß $\rank(x)$ werden kann.

Lemma Wenn es Knoten Höhe $k$ hat, dann hat er mindestens $2^k$ Nachfahren - sich selbst mitgerechnet. Somit gilt $2^{\height(x)} \leq n$ und $\height(x) \leq \log n$

Beweis. Wir bezeichnen mit $\height_t(x)$ die Höhe von $x$ nach $t$ union-Operationen. Mit $\desc_t(x)$ bezeichnen wir die Anzahl der Nachfahren zu diesem Zeitpunkt (als Abkürzung für descendants). Wir beweisen das Lemma mit Induktion über $t$.

Am Anfang gilt $t=0$ und $\height_0(x) = 0$ und $\desc_0(x) = 1$ und die Aussage gilt. Die Anzahl der Nachfahren kann nur zunehmen, also $\desc_{t+1}(x) \geq \desc_t(x)$, also können wir uns auf die Zeitpunkte konzentrieren, wo $\height_t(x)$ zunimmt. Wann ist $\height_t(x) \lt \height_{t+1}(x)$? Dies kann nur geschehen, wenn in der $i+1$-ten union-Operation eine Kante $(y,x)$ eingefügt wird. Dann gilt

\begin{align*} \height_{t+1}(x) = \max(\height_t(x), \height_t(y) + 1) \end{align*}

Der Fall $\height_t(x) \lt \height_{t+1}(x)$ kann also nur auf folgende Weise geschehen: es wird eine Kante $(y,x)$ eingefügt und $\height_t(x) = \height_t(y) =: k$ und $\height_{t+1} (x) = k+1$. Nach Induktionshypothese gilt $\desc_t(x), \desc_t(y) \geq 2^k$. Da nach der union-Operation alle Elemente aus $T_y$ nun auch Nachfahren von $x$ sind, gilt

\begin{align*} \desc_{t+1}(x) = \desc_t(x) + \desc_t(y) \geq 2^k + 2^k = 2^{k+1} = 2^{\height_{t+1}(x)} \ , \end{align*}

und die Aussage des Lemmas gilt nach wie vor. \(\square\)

Die Kosten einer jeden find-Operation sind also höchstens $O(\log n )$. Eine union-Operation besteht aus zwei find-Operationen plus $O(1)$ Rechenaufwand. Also gilt diese Schranke auch für sie.

In der Datenstruktur UnionByRank mit $n$ Elementen benötigt jede find- und jede union-Operation $O(\log n)$ Schritte.

Kruskals Algorithmus benötigt somit $O((m+n) \log m)$ Schritte.

Im Hinblick auf eine weitere Verbesserung im nächsten Teilkapitel zeigen wir eine weitere wichtige Eigenschaft der UnionByRank-Datenstruktur:

Lemma Zu jedem Zeitpunkt gilt: die Anzahl der Elemente mit Rang $k$ ist höchstens $\frac{n}{2^{k-2}}$.

Beweis. Wir betrachten einen spezifischen Zeitpunkt. Seien $x_1, \dots, x_s$ alle Elemente mit Rank $k$. Sie haben also Höhe $k-2$. Nach Lemma 7.5.1 gilt $\desc(x_i) \geq 2^{k-2}$ for alle $1 \leq i \leq s$. Kein $x_i$ ist ein Nachfahren von einem anderen $x_{i'}$ - Nachfahren haben ja schließlich echt kleineren Rang. Bezeichne $\Desc(x)$ die Menge der Nachfahren von $x$ zu diesem Zeitpunkt. Diese Mengen sind disjunkt und jede hat mindestens $2^{k-2}$ Elemente, und somit gilt

\begin{align*} s \cdot 2^{k-2} \leq |\Desc(x_1)| + |\Desc(x_2)| + \cdots + |\Desc(x_s)| = \left| \bigcup_{i=1}^s \Desc(x_i) \right| \leq n \ , \end{align*}

denn mehr als $n$ Elemente kann die Vereinigung aller dieser Mengen ja nicht enthalten. Wir schließen: $s \leq \frac{n}{2^{k-2}}$. \(\square\)