7. Graphen mit Kantenkosten
7.6 Union by Rank with Path Compression
Wir fügen der UnionByRank
-Datenstruktur eine kleine
Optimierung hinzu: wenn wir parent
-Zeiger der Knoten auf dem Pfad
um und lassen sie auf 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
Die Gleichung
Lemma 7.6.1 Zu jedem Zeitpunkt gibt es
in einer UnionByRankPathCompression
-Datenstruktur mit
Daraus folgt insbesondere, dass der Rang eines Elements höchstens
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
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
Wir führen nun eine amortisierte Laufzeitanalyse durch. Die
Kosten einer find-Operation sind parent[x]
geändert wird.
- Wenn
ein dünner Knoten ist aber nicht die Wurzel, dann muss einen Euro bezahlen. - Wenn
dick ist oder die Wurzel, dann bezahlt diefind
-Operation einen Euro.
Die Gesamtkosten über
Behauptung 7.6.2 Ein gerichteter Pfad
von einem Blatt zur Wurzel enthält höchstens
Beweis.
Seien
Per Induktion über
Als nächstes betrachten wir ein Element parent[x]
verändert und
Behauptung 7.6.3 Sei
Beweis.
Jedes mal, wenn parent[x] = root
ausgeführt und der
Zeiger
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
Werten wir nun die unendliche Summe aus (das sollten Sie sich sowieso merken):
Also gilt
Fassen wir zusammen: jede find-Operation muss
Beobachtung 7.6.4 Die Gesamtkosten, die
Mit Path Compression und dem Begriff der dicken Kante / des dicken Knotens
konnten wir die Laufzeit also von
UnionByRank
auf
Dünn, dick, ultra-dick
Warum sollen wir mit dick aufhören? Könnten wir nicht folgendermaßen
argumentieren: nachdem es
Bisher war Knoten
Dann wird sich, wenn parent[u]
geändert wird, der Rang
von parent[u]
auf mindestens parent[u]
auf
mindestens
- 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
In jeder realistischen Anwendung ist bereits ein Knoten wie
Wie sieht es mit den Kosten aus, die ein Element bezahlen muss?
Sei
Wir sehen: dadurch, dass wir den Begriff der ultra-dicken Knoten
eingeführt haben, konnten wir die Laufzeitanalyse von
- Ohne Path Compression:
- Mit Path Compression, Analyse mit dünnen und dicken Knoten:
- 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
Im folgenden werden wir für die
Um ein Gefühl für die
Definition 7.6.6 (Dickheit eines Elements UnionByRankPathCompression
-Datenstruktur,
das keine Wurzel ist, und sei
gilt.
Da
-
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
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
Anfangs ist
Durch das Umbiegen steigt also der "Eltern-Rang" von
und
Fassen wir nun die Kosten zusammen: jede find-Operation bezahlt maximal
Theorem 7.6.8 Die Kosten
von UnionByRankPathCompression
-Datenstruktur
mit
Um ein Gefühl dafür zu bekommen, wie extrem langsam dieses
Wie groß ist ?
In Definition 7.6.5 haben wir
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 for
-Schleifen, und
es ist gar nicht klar, ob man es mit substantiell weniger Schleifen hinkriegt.
Natürlich könnten wir für 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 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 for
-Schleifen berechnen kann (in einer
passend formalisierten Programmiersprache). In der Literatur
verwendet man allerdings meist nicht die obige Funktion 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
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, 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 while
-Schleife und Listen wie
[2,7,5,1,0,1]
als Datenstruktur.