¢¡¤£¦¥¤§© ¤

Werbung
http://www.mpi-sb.mpg.de/~hannah/info5-ws00
IS
UN
R
S
WS 2000/2001
E R SIT
S
Bast, Schömer
SA
IV
A
Grundlagen zu
Datenstrukturen und Algorithmen
A VIE N
Fragmente aus den Vorlesungen∗
4 Union-Find
2
4.1 Literatur . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
2
4.2 Die einfachste Variante . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
2
4.3 Mit Listen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
3
4.4 Mit Listen und Union nach Größe . . . . . . . . . . . . . . . . . . . . . . . . . . .
4
4.5 Mit Bäumen statt Listen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
5
4.6 Mit Pfadkomprimierung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
6
4.7 Die Ackermann-Funktion(en) und ihre Inverse(n) . . . . . . . . . . . . . . . . . .
6
4.8 Analyse von Union-Find mit Pfadkomprimierung . . . . . . . . . . . . . . . . . .
7
zuletzt geändert am 26. Februar 2001
∗
eine Html-Version findet sich unter http://www.mpi-sb.mpg.de/~hannah/info5-ws00/fragmente
4
Union-Find
Definition: Eine Partition einer Menge S ist eine Menge von paarweise disjunkten Teilmengen
von S, deren Vereinigung gerade S ist (d.h. jedes Element von S ist in genau einer Menge einer
Partition enthalten). Für S = {1, 2, 3, 4} ist zum Beispiel {{1}, {2}, {3}, {4}} eine Partition (die
feinstmögliche“) oder {{1, 2, 3, 4}} (die gröbstmögliche“) oder {{1, 3, 4}, {2}}.
”
”
Problem: Verwalten einer Partition einer Menge von Elementen unter den Operationen union
(Vereinigung zweier Mengen, d.h. Vergröberung der Partition) und find (Bestimmung der Menge, zu der ein gegebenes Element gehört). Mengen werden dabei durch einen eindeutigen Namen repräsentiert, wobei zu jeder Zeit gelten muss, dass für zwei beliebige Elemente i und j,
find(i) == find(j) genau dann, wenn i und j in derselben Menge sind. Wir nehmen im Folgenden immer an, dass sowohl die Mengennamen als auch die Elemente vom Typ int sind.
Anwendungen: Eine Anwendung haben wir schon gesehen: Kruskals Algorithmus zur Berechnug
eines minimal aufspannenden Baumes. Eine andere Anwendung wäre zum Beispiel das Verwalten
der Zusammenhangskomponenten eines Graphen mit gegebener Knotenmenge, in den nach und
nach Kanten eingefügt werden.
4.1
Literatur
Das Union-Find Problem, die diversen Datenstrukturen dafür und ihre Analysen sind sehr schön
und ausführlich im Buch von Cormen, Leiserson und Rivest (Kapitel 22) erklärt. Allerdings wird
dort statt Union nach Größe das einfacher zu analysierendere Union nach Rang betrachtet und
außerdem für die Implementierung mit Bäumen, Union nach Rang und Pfadkomprimierung nur
die schwächere Schranke von O(n + m · log∗ n) gezeigt. Die vollständigen Beweise findet man zum
Beispiel im Buch Datenstrukturen und effiziente Algorithmen“ von Mehlhorn (sehr komprimiert
”
geschrieben), oder ausführlicher (aber trotzdem noch relativ technisch) im Hagerup-Skript. Siehe
http://www.mpi-sb.mpg.de/~hannah/info5-ws00/literatur.html.
Eine Postscript-Version dieses Kapitels findet sich unter http://www.mpi-sb.mpg.de/~hannah/
info5-ws00/download/fragmente.union-find.ps.gz, eine Pdf-Version unter http://www.mpi-sb.
mpg.de/~hannah/info5-ws00/download/fragmente.union-find.pdf.
4.2
Die einfachste Variante
Idee: Für jedes Element wird explizit der Name der es enthaltenden Menge gespeichert. Zur
Vereinfachung nehmen wir als Mengennamen immer ein Element aus der Menge (das erfüllt obige
Forderung).
class PartitionSimple
{
int name[];
// name[i] = Name der Menge in der sich Element i befindet
public:
Partition(int n)
// Erzeugt die Partition {{1},...,{n}} mit Mengennamen 1,...,n
{
name = new int[n];
for(int i = 0; i < n; i++) { name[i] = i; }
}
int find(int i)
{
return name[i];
}
void union(int j, int k)
// Vereinigt die Mengen mit Namen j und k und nennt die neue Menge j
{
for(i = 0; i < n; i++)
{
if (name[i] == k) { name[i] = j; }
}
}
};
Laufzeit: Initialisierung Θ(n), eine find Operation O(1), eine union Operation Θ(n). Für n
Elemente, n − 1 union Operation (mehr kann es nicht geben, weil es dann nur noch eine Menge
gibt), und m find Operationen also Θ(m + n2 ).
4.3
Mit Listen
Idee: Zusätzliche Listenstruktur, um die Elemente einer Menge effizient durchlaufen zu können.
Statt expliziter Mengennamen, wähle einfach das erste Element einer Liste als Mengennamen.
class PartitionWithLists
{
int name[];
int next[];
// next[i] = Nachfolgeelement von Element i;
// next[i] = i, falls letztes Element in der Liste
public:
Partition(int n)
{
name = new int[n]; next = new int[n];
for(int i = 0; i < n; i++) { name[i] = next[i] = i; }
}
int find(int i)
{
return name[i];
// genau wie bei PartitionSimple
}
void union(int j, int k)
{
// Benenne erst alle Elemente in der zweiten Menge um
int l = k;
name[l] = j;
while(next[l] != l)
{
l = next[l];
name[l] = j;
}
// Füge dann die zweite Liste hinter dem Anfang(!) der ersten Liste ein
if (next[j] != j) { next[l] = next[j]; }
next[j] = k;
}
};
Laufzeit: Initialisierung Θ(n), eine find Operation Θ(1), eine union Operation Θ(Größe der
zweiten Menge). Für n Elemente, n − 1 union Operation und m find Operationen also schlechtestenfalls nach wie vor Θ(m + n2 ); siehe folgendes Beispiel:
PartitionWithLists P(n);
P.union(1,0);
P.union(2,1);
P.union(3,2);
.
.
.
P.union(n-1,n-2);
4.4
Mit Listen und Union nach Größe
Idee: Bei der Vereinigung zweier Listen werden die Elemente der kürzeren Liste umbenannt; dazu
müssen wir uns allerdings die Längen der Listen (= Größen der Mengen) merken.
class
{
int
int
int
PartitionWithListsAndUnionBySize
name[];
next[];
size[];
Partition(int n)
{
name = new name[n]; next = new int[n]; size = new int[n];
for(int i = 0; i < n; i++) { name[i] = next[i] = 1; size[i] = 1; }
}
int find(int i)
{
return name[i];
}
// genau wie bei PartitionSimple
void union(int i, int j)
{
if (size[i] < size[j]) { union(j, i); }
.
.
// genau wie bei PartitionWithLists
.
size[i] += size[j];
}
};
Laufzeit: Initialisierung Θ(n), eine find Operation Θ(1), eine union Operation Θ(Größe der
kleineren Menge). Die Gesamtkosten für eine Folge von union Operationen sind proportional zur
Anzahl der Änderungen von Einträgen in name. Aber wenn name[i] geändert wird, heißt das,
dass die Menge, in der sich Element i vorher befand, mit einer mindestens genauso großen Menge
vereinigt wurde, ihre Größe sich also verdoppelt. Weil jede Menge höchstens n groß werden kann,
wird also jeder Eintrag von name höchstens blog nc mal geändert. Die Laufzeit für n Elemente,
n − 1 union Operation und m find Operationen ist also schlechtestenfalls Θ(m + n · log n).
4.5
Mit Bäumen statt Listen
Idee: Für billigeres union etwas teureres find in Kauf nehmen, indem man Bäume statt Listen
verwendet und auf einen expliziten Verweis auf den Mengennamen verzichtet (stattdessen implizit,
indem man vom Element bis zur Wurzel läuft).
class Partition
{
int parent[];
// parent[i] = nächster Knoten auf dem Weg zur Wurzel
// parent[i] = i, falls i die Wurzel ist
int size[];
public:
Partition(int n)
{
parent = new int[n]; size = new int[n];
for(int i = 0; i < n; i++) { parent[i] = i; size[i] = 1; }
}
int find(int i)
{
while (parent[i] != i) { i = parent[i]; }
return i;
}
void union(int j, int k)
{
if (size[j] >= size[k])
{ parent[k] = j; size[j] += size[k]; }
else
{ parent[j] = k; size[k] += size[j]; }
}
};
Laufzeit: Initialisierung Θ(n), eine find Operation Θ(Länge des Pfades vom Element zur Wurzel) =
O(log n), eine union Operation Θ(1). Für n Elemente, n − 1 union Operation und m find Operationen also bestimmt O(n + m · log n), und man kann in der Tat einen Fall konstruieren, für
den die Laufzeit Θ(n + m · log n) ist.
4.6
Mit Pfadkomprimierung
Idee: Da man bei einer find Operation eh bis zur Wurzel hochlaufen muss, kann man bei der
Gelegenheit auch gleich alle Knoten auf dem Weg mitnehmen“ und direkt unter die Wurzel
”
hängen, was zukünftige finds für diese Elemente (und auch für alle Elemente in den Unterbäumen
dieser Elemente!) billiger machen wird.
Im Code für die Klasse Partition genügt dazu folgende leichte Modifikation
int find(int i)
{
if (parent[i] == i) { return i; }
parent[i] = find(parent[i]);
return parent[i];
}
Laufzeit: Für n Elemente, n−1 union Operationen und m find Operationen O(n+m·α(n, m/n));
diese Schranke wird im Folgenden erklärt und bewiesen.
4.7
Die Ackermann-Funktion(en) und ihre Inverse(n)
Notation: Wir schreiben f (k) (n) für die k-fache Anwendung einer Funktion f : N → N auf ein
Argument x, d.h.
f (k) = f ◦ · · · ◦ f ,
| {z }
k mal
oder, rekursiv definiert,
f (0) (n) = n und f (k+1) (n) = f (f (k) (n)) für alle n ∈ N.
Definition: Wir definieren rekursiv die Funktionen Ak : N → N
A0 (n) = 2 · n;
(n)
Ak (n) = Ak−1 (1),
für k ≥ 1,
und die Funktionen Ik : N → N (quasi die Inversen der Ak )
I0 (n) = dn/2e;
(i)
Ik (n) = min{i : Ik−1 (n) = 1},
für k ≥ 0.
Bemerkung: Über Induktion lässt sich leicht zeigen, dass für jedes der Ik gilt, dass Ik (n) < n
für alle n ≥ 2, so dass die Menge, von der das Minimum genommen wird, nie leer ist.
Lemma: Für alle k ∈ N0 gilt, dass Ik (Ak (n)) = n für alle n ∈ N.
Beweis: Induktion über k. Da I0 (A0 (n)) = d(2 · n)/2e = n, gilt die Behauptung für k = 0. Für
k ≥ 1 gelte jetzt die Behauptung für k − 1. Nun ist nach Definition von Ik und Ak
(i)
(n)
Ik (Ak (n)) = min{i : Ik−1 (Ak−1 (1)) = 1},
(i)
(n)
(n−i)
und weil sich Ik−1 und Ak−1 gerade umkehren, ist für i ≤ n Ik−1 (Ak−1 (1)) = Ak−1 (1), was > 1
ist für alle i < n und = 1 für i = n. Also ist Ik (Ak (n)) genau n.
Definition: Die häufig sogenannte inverse Ackermann-Funktion“ α : N × N → N ist dann
”
definiert als
α(n, n0 ) = min{k : Ik (n) ≤ 2 + n0 }.
Bemerkung: Das ist wohldefiniert, weil Ik (n) > 3 ⇒ Ik+1 (n) < Ik (n). Die 2+“ braucht man,
”
weil für alle n ≥ 5 gilt, dass Ik (n) > 2 für jedes k (und somit die Menge {k : Ik (n) ≤ 2} leer ist).
4.8
Analyse von Union-Find mit Pfadkomprimierung
Satz: Die Union-Find Datenstruktur mit Pfadkomprimierung benötigt bei n Elementen für n − 1
union und m find Operationen O(n + m · α(n, m/n)) Zeit.
Beweisidee: Ein find(u) ist teuer, wenn der Pfad von u zur Wurzel lang ist, aber dann werden
auch viele Knoten umgehängt und entsprechend viele Pfade verkürzt (der Baum wird buschi”
ger“). Wir zeigen, dass schon nach sehr wenigen teuren find Operationen die Bäume so buschig
werden, dass weitere finds alle billig sind.
Beweis: Wir zeigen O((n + m) · α(n, 1)); das geringfügig stärkere O(n + m · α(n, m/n)) folgt auf
genau demselben Weg, nur mit zwei zusätzlichen technischen Tricks, die alles etwas komplizierter
machen, aber nicht besonders spannend sind.
Sei compress eine Operation, die für einen gegeben Anfangsknoten u und einen Endknoten v,
alle Knoten auf dem Weg von u nach v direkt unter v hängt.
Beobachtung 1a: Ein find(u), das den Knoten v zurückgibt, und ein compress(u,v) haben
größenordnungsmäßig dieselbe Laufzeit und genau denselben Effekt auf die Datenstruktur.
Gehen wir also für die Analyse davon aus, dass wir eine Folge von n − 1 union und m compress
Operationen gegeben haben (nach obiger Beobachtung ist das höchstens allgemeiner als das ursprüngliche Problem).
Beobachtung 1b: Zwei Operationen, von denen eine ein union und eine ein compress ist, haben
denselben Effekt auf die Datenstruktur, unabhängig davon in welcher Reihenfolge sie ausgeführt
werden.
Gehen wir also für die Analyse davon aus, dass erst n−1 union und dann m compress Operationen
ausgeführt werden. Nach den union Operationen stehen alle n Elemente in einem Baum, den wir
T nennen. Elemente werden im Folgenden Knoten genannt und mit h(u) bezeichnen wir die Höhe
von u in T ; ein Blatt habe dabei die Höhe 0 und die Höhe eines Elternknotens ist eins mehr
als das Maximum der Höhen seiner Kinder. (Wohlbemerkt ändern sich die h(u) nicht durch ein
compress; es sind im Folgenden immer die Höhen im ursprünglichen Baum T , in dem noch nix
komprimiert wurde, gemeint.)
Beobachtung 2a: Der Höhenunterschied von jedem Knoten zu seinem Elternknoten ist mindestens 1 und bestimmt ist kein Knoten höher als n (sogar ≤ log n, aber das brauchen wir hier nicht).
Bei jedem compress werden nun Knoten umgehängt, und zwar unter Knoten von größerer Höhe;
der Höhenunterschied eines umgehängten Knoten zu seinem (neuen) Elternknoten vergrößert sich
also.
Nenne den Höhenunterschied eines Knoten u zu seinem Elternknoten v vom Typ Ak , falls h(v) ≥
Ak (h(u)), aber nicht h(v) ≥ Ak+1 (h(u)) (für u mit h(u) ≤ 2 ist das nicht wohldefiniert, dann sei
der Typ einfach der Typ, den man erhalten würde, wenn h(u) = 3 wäre).
Beobachtung 2b: Es können nur Höhenunterschiede vom Typ A0 , A1 , . . . , Aα(n,1)−1 vorkommen
(denn alle Höhen sind ≤ n und per Definition von α ist Aα(n,1) (3) > n).
Nenne eine Umhängung effektiv, falls es weiter oben im selben Pfad noch einen Knoten gibt,
dessen Höhenunterschied zu seinem Elternknoten vom selben Typ ist.
Beobachtung 3: Auf jedem Pfad gibt es höchstens eine nichteffektive Umhängung von jedem
Typ, also höchstens α(n, 1) viele. Also ist die Anzahl der nichteffektiven Umhängungen durch
O(m · α(n, 1)) beschränkt.
Sei jetzt u irgendein Knoten, dessen Höhenunterschied zu seinem Elternknoten vom Typ Ak ist.
(i)
Beobachtung 4a: Hat der Elternknoten von u Höhe ≥ Ak (h(u)), dann hat nach einer effektiven
(i+1)
Umhängung der neue Elternknoten Höhe ≥ Ak (h(u)).
Beobachtung 4b: Nach h(u) effektiven Umhängungen hat u also einen Elternknoten mit Höhe
(h(u))
≥ Ak
(h(u)), was nach Definition der Ackermann-Funktionen ≥ Ak+1 (h(u)) ist. Der Typ eines
Knotens u erhöht“ sich also spätestens nach h(u) effektiven Umhängungen.
”
Beobachtung 4c: Da nur α(n, 1) verschiedene Typen vorkommen können, gibt es für einen
Knoten u also höchstensP
h(u) · α(n, 1) effektive Umhängungen. Die Gesamtanzahl effektiver
Umhängungen ist also O( u h(u) · α(n, 1)).
P
i
Beobachtung
5:
Höchstens
n/2
Knoten
haben
die
Höhe
i;
insbesondere
ist
dann
u h(u) =
Pn−1
Pn−1
i
i=0 |{u : h(u) = i}| · i =
i=0 n/2 · i = O(n).
Somit ist die Anzahl der effektiven Umhängungen O(n·α(n, 1)) und die der nichteffektiven hatten
wir schon durch O(m · α(n, 1)) beschränkt, also wissen wir, dass es insgesamt höchstens O((n +
m) · α(n, 1)) Umhängungen gibt.
Herunterladen