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.