7. Graphen mit Kantenkosten

7.6 Union by Rank with Path Compression

Wir fügen der UnionByRank-Datenstruktur eine kleine Optimierung hinzu: wenn wir find(u) ausführen und den Pfad von u hinauf zur Wurzel x gelaufen sind, biegen wir alle parent-Zeiger der Knoten auf dem Pfad um und lassen sie auf x zeigen. Es gilt danach also parent[v]=x für x und alle seine Vorfahren. Durch diesen Trick werden die Bäume "flacher" und zukünftige find-Operationen billiger. Man nennt dies Path Compression, weil lange Pfade "zusammengepresst" werden. Die Datenstruktur nennen wir UnionByRankPathCompression.

Der Code unterscheidet sich nur minimal von UnionByRank:

class UnionByRankPathCompression:
    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])
            self.parent[u] = root # hier findet die Path Compression statt. Wir richten den parent-Zeiger von u direkt auf die Wurzel 
            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 

        if 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 < < > > & & 

Beachten Sie, dass nach Ausführung von und Umbiegen der Zeiger aber gilt:

Die Gleichung gilt also nicht mehr notwenigerweise. Allerdings gilt immer noch:

Lemma 7.6.1 Zu jedem Zeitpunkt gibt es in einer UnionByRankPathCompression-Datenstruktur mit Elementen höchstens Elemente von Rang .

Daraus folgt insbesondere, dass der Rang eines Elements höchstens sein kann: für gibt es weniger als Element vom Rang , also gar keins!

Beweis. Am Besten stellen Sie sich vor, wir würden eine UnionByRankPathCompression- und eine UnionByRank-Datenstruktur parallel initialisieren und dann auch die find- und union-Operationen auf beiden parallel ausführen. In der UnionByRankPathCompression-Datenstruktur werden eventuell ab einem Zeitpunkt die Werte niedriger sein, weil wir ja Zeiger direkt zur Wurzel umbiegen. Die Werte werden allerdings genau gleich bleiben, da wir diese unabhängig von der genauen Zeigerstruktur führen. Daher ist auch zu jedem Zeitpunkt die Anzahl der Elemente von Rang in beiden Datenstrukturen die gleiche.

Laufzeitanalyse

Das Umbiegen der Zeiger direkt zur Wurzel hin wirkt wie eine kleine Optimierung der Implementierung. Tatsächlich aber führt es aber zu einer asymptotisch besseren Laufzeit. Und deren Analyse stellt sich als äußerst überraschend heraus.

Dicke Kanten und normale Kanten. Wenn ist, dann gilt . Soviel ist kar. Manchmal allerdings ist deutlich größer als . Wir definieren: Die Kante ist eine dicke Kante wenn . Der Knoten heißt dick wenn seine ausgehende Kante dick ist. Kanten und Knoten, die nicht dick sind, heißen dünn.

Wir führen nun eine amortisierte Laufzeitanalyse durch. Die Kosten einer find-Operation sind Anzahl der Zeiger-Umbiegungen. Der Einfachheit halber zählen wir nur die Zeiger-Umbiegungen. Im Worst-Case werden die auch dominieren. Die Kosten einer union-Operation werden von denen der beiden find-Operationen (Zeile 16, 17) dominiert. Es langt also völlig aus, die Gesamtzahl der Zeiger-Umbiegungen zu zählen. Jede Umbiegung kostet einen Euro, und wir müssen nun festlegen, wem diese Kosten in Rechnung gestellt werden. Betrachten wir also einen Zeitpunkt, wo parent[x] geändert wird.

  1. Wenn ein dünner Knoten ist aber nicht die Wurzel, dann muss einen Euro bezahlen.
  2. Wenn dick ist oder die Wurzel, dann bezahlt die find-Operation einen Euro.

Die Gesamtkosten über find-Operationen ergeben sich aus den zwei folgenden Behauptungen:

Behauptung 7.6.2 Ein gerichteter Pfad von einem Blatt zur Wurzel enthält höchstens dicke Knoten.

Beweis. Seien die dicken Knoten auf diesem Pfad. Es gilt . Weiterhin gilt

Per Induktion über folgt und somit . Andererseits gilt nach Lemma 7.6.1. Daher folgt und eben .

Als nächstes betrachten wir ein Element und die Anzahl von Euros, die es bezahlen muss. Wenn es überhaupt etwas bezahlen muss, dann wird irgendwann erstmals parent[x] verändert und hört zu diesem Zeitpunkt auf, eine Wurzel zu sein. Sei der Wert von zu diesem Zeitpunkt. Beachten Sie, dass sich dieser Wert von nun an nicht mehr ändert.

Behauptung 7.6.3 Sei ein Element und der Rang von zu dem Zeitpunkt, wo aufhört, Wurzel zu sein. Dann muss höchstens Euro bezahlen.

Beweis. Jedes mal, wenn einen Euro bezahlen muss, wird die Codezeile parent[x] = root ausgeführt und der Zeiger ändert sich. Sei der Knoten nach genau Umbiegungen von . Es gilt , weil anfangs selbst Wurzel ist. Da jedes ein Vorfahre nicht nur von , sondern auch von ist, gilt und somit Nach Umbiegungen gilt und ist von nun an ein dicker Knoten! Von nun übernimmt die jeweilige find-Operation die Kosten. Das heißt, dass selbst nur Euros bezahlen muss.

Rechnen wir nun aus, wieviele Euros von den Elementen selbst bezahlt werden müssen. Dafür betrachten wir den Zustand der Datenstruktur nach Abschluss aller union- und find-Operationen und die Werte von zu diesem Zeitpunkt. Die Gesamtkosten, die die Elemente selbst tragen müssen, sind also nach Behauptung 7.6.3 höchstens

Werten wir nun die unendliche Summe aus (das sollten Sie sich sowieso merken):

Also gilt und somit . Daher

Fassen wir zusammen: jede find-Operation muss Euro bezahlen tragen, und die Elemente müssen insgesamt höchstens Euro bezahlen. Daher schließen wir:

Beobachtung 7.6.4 Die Gesamtkosten, die union- und find-Operationen verursachen, sind .

Mit Path Compression und dem Begriff der dicken Kante / des dicken Knotens konnten wir die Laufzeit also von für UnionByRank auf verbessern. Um es noch einmal zu formulieren: ein dünnes Nicht-Wurzel-Element von Rang muss höchstens Euro bezahlen, danach wird es nämlich dick und muss nichts mehr bezahlen.

Dünn, dick, ultra-dick

Warum sollen wir mit dick aufhören? Könnten wir nicht folgendermaßen argumentieren: nachdem es Euros bezahlt hat, wird ein Element dick; nach weiteren Euros wird es ultradick; es von jetzt an übernimmt die find-Operation die Kosten. Wie sollen wir ultra-dick definieren?

Bisher war Knoten vom Rang dick wenn . Wenn es oberhalb von einen weiteren dicken Knoten gibt, die Dinge also so aussehen:

Dann wird sich, wenn parent[u] geändert wird, der Rang von parent[u] auf mindestens erhöhen. Das gilt aber nur, wenn es oberhalb von einen weiteren dicken Knoten gibt. Nach solches Umbiegungen wird der Rang von parent[u] auf mindestens angewachsen sein. Nach Umbiegungen also auf mindestens . Wir definieren nun: sei ein Knoten von Rang , der keine Wurzel ist, und . Wir bezeichnen als

  • dünn, wenn gilt;
  • dick, wenn gilt;
  • ultra-dick, wenn .

Als nächstes legen wir fest, wer welche anfallenden Kosten bezahlen muss. Zur Erinnerung: jedes Mal, wenn ein parent[u]-Zeiger umgebogen wird, wird ein Euro fällig. Wir legen fest:

  • Die find-Operation zahlt die Euros für die Wurzel und die ultra-dicken Knoten; darüberhinaus für den obersten dicken und den obersten dünnen Knoten auf dem Pfad.
  • Die restlichen Euros werden von den jeweiligen Knoten selbst getragen.

Überlegen wir uns informell, wie hoch die Anzahl von ultradicken Knoten auf einem Pfad werden kann. Seien die ultradicken Knoten auf dem Pfad, von unten nach oben aufgelistet. Sei . Da ein Vorfahre von ist, gilt . Da die Kante dick ist, gilt . Also gilt

In jeder realistischen Anwendung ist bereits ein Knoten wie völlig unrealistisch. Die maximal mögliche Anzahl ultradicker Knoten auf einem Pfad ist also eine extrem langsam wachsende Funktion in . In der Tat in etwa . Dies ist die Anzahl, wie oft Sie auf dem Taschenrechner drücken müssen, um von zu oder weniger zu kommen. Jede find-Operation muss also höchstens Euros bezahlen.

Wie sieht es mit den Kosten aus, die ein Element bezahlen muss? Sei ein Element von Rang muss. Anfangs ist es ein dünnes Element. Nachdem es Euros bezahlt hat, hat sein Elternknoten mindestens Rang und zählt von nun an als dick. Nach weiteren Euros wird einen Elternknoten vom Rang mindestens haben: jeder zusätzliche Euro beschert einen Elternknoten mit mindestens doppelt so hohem Rang als zuvor. Nach insgesamt Euros ist ultra-dick geworden und muss von nun an nichts mehr selbst bezahlen. Die Gesamtkosten, die von den Elementen getragen werden müssen, sind also höchstens

Wir sehen: dadurch, dass wir den Begriff der ultra-dicken Knoten eingeführt haben, konnten wir die Laufzeitanalyse von auf verbessern. Fassen wir zusammen:

  1. Ohne Path Compression:
  2. Mit Path Compression, Analyse mit dünnen und dicken Knoten:
  3. Mit Path Compression und Analyse mit dünnen, dicken und ultra-dicken Knoten: .

Wir können nun immer weitermachen und neue Dickheitsgrade einführen.

Beliebig viele Dickheitsgrade

Definition 7.6.5 Wir definieren Funktionen wie folgt:

Im folgenden werden wir für die -fache Anwendung einer Funktion die Notation verwenden.

Um ein Gefühl für die zu bekommen, spielen wir mit kleinen Werten von herum. Was ist ? Nach Definition ist es das Ergebnis, wenn wir insgesamt mal auf anwenden. Also . Was ist ? Wir haben gerade gesehen, dass die Verdopplung ist. Wir müssen nun insgesamt mal verdoppeln. Wir erhalten . Alos gilt . Wir definieren nun den Dickheitsgrad eines Elements .

Definition 7.6.6 (Dickheit eines Elements ). Sei ein Element in der UnionByRankPathCompression-Datenstruktur, das keine Wurzel ist, und sei der Elternknoten. Sei . Die Dickheit von ist , wenn

gilt.

Da gilt, gibt es genau ein solches , und somit hat jedes Element eine Dickheit. Wir legen nun fest, welche Zeiger-Umbiegungen von wem getragen werden. Sei hierfür ein Element von Dickheit .

  • Wenn auf dem Pfad zur Wurzel das oberste Element von Dickheit ist, dann werden Umbiegungskosten von der find-Operation direkt getragen.
  • Wenn es oberhalb von einen weiteren Knoten von Dickheit gibt, dann muss die Umbiegungskosten selbst bezahlen.

Behauptung 7.6.7 Sei der höchste Dickheit aller Elemente in der Datenstruktur. Eine find-Operation muss höchstens Euros selbst bezahlen.

Dies gilt offensichtlich, weil die find-Operation nur für das jeweils oberste Element in einer Dickheitsstufe die Kosten übernimmt. Davon kann es höchstens viele geben. Etwas schwieriger wird es, wenn wir herausfinden wollen, wieviel Kosten ein einzelnes Element tragen muss. Hier ist eine Grafik, die illustriert, was geschieht, wenn ein Element von Rang und Dickheit Kosten tragen muss:

Anfangs ist . Wenn nun das nächste Mal einen Euro (in der Grafik einen Yuan) bezahlen muss, dann nur, weil es weiter oben einen weiteren Knoten mit Dickheit geben muss. Es gilt daher zu diesem Zeitpunkt:

Durch das Umbiegen steigt also der "Eltern-Rang" von von auf . Mit jedem weiteren Yuan kommt ein hinzu, und so steigt der Rang von nach Yuans auf mindestens

und hat von nun Dickheit mindestnes . Pro Dickheit bezahlt also höchstens Yuan. Anfangs ist die Dickheit , und zum Schluss ist sie höchstens , die höchste jemals in der Datenstruktur vorkommende Dickheit. Also muss insgesamt höchstens bezahlen. Runden wir es auf auf . Die Gesamtkosten, die von den Elementen getragen werden, sind höchstens

Fassen wir nun die Kosten zusammen: jede find-Operation bezahlt maximal , und die Elemente bezahlen zusammen höchstens . Es bleibt die Frage: wie groß kann werden? Wenn es ein Element von Dickheit gibt, dann gilt

Theorem 7.6.8 Die Kosten von find- und union-Operationen in einer UnionByRankPathCompression-Datenstruktur mit Elementen ist höchstens , wobei die größte natürliche Zahl mit ist.

Um ein Gefühl dafür zu bekommen, wie extrem langsam dieses als Funktion von wachsen kann, müssen wir uns vor Augen halten, wie schnell als Funktion von wächst.

Wie groß ist ?

In Definition 7.6.5 haben wir als die -fache Anwendung von auf definiert. Hier ist meine beispielhafte Implementierung von , , und in Python:

def f_0(k):
    return k+1

def f_1(k):
    result = k 
    for i in range(k):
        result = f_0(result)
    return result

def f_2(k):
    result = k
    for i in range(k):
        result = f_1(result)
    return result

def f_3(k):
    result = k 
    for i in range(k):
        result = f_2(result)
    return result

Da keine rekursiven Funktionsaufrufe vorkommen, können wir alles ohne Funkionsaufruf schreiben, indem wir beispielsweise in der Implementierung von f_3 den Aufruf von f_2 durch den Körper von f_2 selbst ersetzen. Hierbei müssen wir nur auf die richtige Benennung der Variablen achten:

def F_1(k):
    result = k 
    for i in range(k):
        result = result + 1
    return result

def F_2(k):
    result = k 
    for i_1 in range(k):
        result_0 = result 
        for i_0 in range(result):
            result_0 = f_0(result_0)
        result = result_0 
    return result 

def F_3(k):
    result = k 
    for i_2 in range(k):
        result_1 = result 
        for i_1 in range(result):
            result_0 = result_1
            for i_0 in range(result_1):
                result_0 = result_0 + 1 
            result_1 = result_0 
        result = result_1
    return result

Wir sehen: der Code für besteht aus verschachtelten for-Schleifen, und es ist gar nicht klar, ob man es mit substantiell weniger Schleifen hinkriegt. Natürlich könnten wir für einfach return k * 2**k schreiben, aber der **-Operator führt ja hinter den Kulissen auch nur eine Schleife aus. Unsere Implementierung oben würde für also for-Schleifen benötigen. Was, wenn wir Code für f(s,k) schreiben wollten? Die Anzahl der for-Schleifen kann ja nicht von s abhängen, da dies ja nun ein Eingabeparameter geworden ist, den wir zur Zeit des Codeschreibens noch nicht kennen. Und in der Tat stellt sich heraus, dass man die Funktion gar nicht mit einer festen Zahl an for-Schleifen berechnen kann (in einer passend formalisierten Programmiersprache). In der Literatur verwendet man allerdings meist nicht die obige Funktion sondern die Ackermann-Funktion. Diese wurde von Wilhelm Ackermann und Gabriel Sudan genau mit dem Ziel definiert, zu zeigen, dass es eine berechenbare Funktion gibt, die man nicht mit einer konstanten Anzahl an for-Schleifen berechnen kann (da man in den 1920-Jahren noch keine Computer und Programmiersprachen kannte, spricht man in diesem Zusammenhang von eher von primitiv-rekursiven Funktionen als von Programmen mit einer festen Anzahl an for-Schleifen). Wenn Sie mehr über die Ackermann-Funktion lernen wollen, ist Wikipedia ein guter Ausgangspunkt. Auch ich habe in meinen Vorlesungsskripten ein Kapitel darüber: Kapitel 3.3 von Berechenbarkeit und Kreativität.

Wenn rekursive Funktionsaufrufe erlaubt sind, dann ist es sehr einfach, die Funktion zu implementieren:

def f(s,k):
    if s == 0:
        return k+1
    result = k 
    for i in range(k):
        result = f(s-1, result)
    return result

Übungsaufgabe 7.6.1 Finden Sie einen Weg, in Python ohne Rekursion und nur mit for-Schleifen zu implementieren.

Hinweis: dies widerspricht scheinbar meiner obigen Behauptung, dies sei nicht möglich. Unmöglich ist es aber nur in einer passend formalisierten Programmiersprache, und Python kann etwas, dass es Ihnen erlaubt, es doch zu tun: Funktionen als Eingabe- und Rückgabewerte verwenden!

Übungsaufgabe 7.6.2 Implementieren Sie in Python ohne Funktionsaufrufe, Rekursion oder Funkionen als Rückgabe- oder Eingabewerte. Ich erlaube Ihnen aber etwas anderes: eine while-Schleife und Listen wie [2,7,5,1,0,1] als Datenstruktur.