Allgemeines

Gegeben sei ein zusammenhängender, ungerichteter Graph \(G = (V, E, c)\) mit Kostenfunktion \(c: E \rightarrow \mathbb {R}^+\). Gesucht ist \(E’ \subseteq E\) mit \(G’ = (V, E’)\) als zusammenhängender Teilgraph, sodass \(\sum _{e’ \in E’} c(e’)\) minimal wird. Ein solcher Teilgraph heißt minimaler Spannbaum oder auch MST (minimal spanning tree).

Anwendungen: Kommikationsnetzwerke (Unternehmen möchte Kommunikationsnetz aufbauen, dabei alle mit minimalen Kosten verbinden) und als Hilfsmittel z. B. für die Lösung des TSP (travelling salesman problem).

Was ist ein Baum? Ein ungerichteter Graph heißt Baum, falls

  • er minimal zusammenhängend ist

  • er zusammenhängend ist und \(n - 1\) Kanten hat (\(n\) Knoten)

  • er maximal zyklenfrei ist.

Beobachtung: Die \(E’\) formen einen Baum.

Beweis: Die \(E’\) müssen einen zusammenhängenden Graph induzieren. Falls dieser kein Baum ist, gibt es einen Zyklus. Wegnahme einer Kante des Zyklus verletzt den Zusammenhang nicht, macht aber die Lösung billiger. Widerspruch, denn \(E’\) muss minimale Kosten haben.   ƒ

Prims Algorithmus

Man fängt mit einem beliebigen Knoten an. Nun betrachtet man alle Kanten, die zu den bisherigen aufgenommen Knoten inzident sind, und fügt die Kante mit dem kleinsten Gewicht hinzu. Dies macht man solange, bis alle Knoten aufgenommen wurden.

Laufzeit: naiv \(\O (n \cdot m)\), da \(n\) Knoten aufgenommen werden und jedes Mal aus maximal \(m\) Kanten die billigste ausgesucht werden muss.

besser: Organisiere Knoten, die bislang noch nicht im Spannbaum sind, in einem Heap.

Seien \(S\) die Knoten im bereits konstruierten Spannbaum und \(V \setminus S\) der Rest. Man organisiert \(V \setminus S\) in einem Min-Heap gemäß ihrem minimalen Abstand zu einem Knoten in \(S\). Zu Beginn ist \(|S| = 1\) und alle Knoten in \(V \setminus S\) sind mit ihrem Kantengewicht zum Startknoten im Heap (\(\O (n)\), da max. \(n\) Knoten aufgenommen werden müssen).

Wird ein Knoten \(v\) nun hinzugenommen, so entferne das Minimum aus dem Heap (also der Knoten, der am billigsten angebunden werden kann, \(\O (\log n)\)). Gehe dann alle Kanten \((v, w)\) durch, falls \(w \in V \setminus S\) und der Distanzwert von \(w\) im Heap größer als \(c(v, w)\) ist, muss der Schlüssel von \(w\) in \(c(v, w)\) geändert werden (\(\O (\log n)\), change_key). Insgesamt werden so alle \(m\) Kanten einmal betrachtet, also beträgt die Gesamtlaufzeit \(\O (m \log n)\), da \(n \le m\) ist.

Korrektheit: Prims Algorithmus berechnet einen MST.

Beweis: In jeder „Runde“ wird die billigste Kante zwischen \(S\) und \(V \setminus S\) hinzugenommen. Gemäß cut property ist diese Kante Teil jeden MSTs. Der Algorithmus terminiert erst für \(S = V\), die Kanten sind alle Teil jeden MSTs, also ist \(S\) am Ende auch ein MST.   ƒ

Lemma (cut property): Sei \(S \subseteq V\) und \(e = (v, w)\) die Kante mit minimalem Gewicht zwischen \(S\) und \(V \setminus S\). Dann ist \(e\) in jedem MST von \(G\) enthalten.

Beweis: Betrachte alle Kanten \(E^\ast \) eines MST, der \(e\) nicht enthält. In \(E^\ast \) muss eine Kante \(e’\) zwischen \(S\) und \(V \setminus S\) verlaufen, da der MST ein Spannbaum ist.
Nimmt man \(e\) zu \(E^\ast \) hinzu, so entsteht ein Zyklus. Dieser Zyklus übertritt die Grenze zwischen \(S\) und \(V \setminus S\) ein weiteres Mal, dieser Übertritt ist teurer als \(e\) (da \(e\) minimales Gewicht). Also verringert das Aufnehmen von \(e\) und das Löschen des Übertritts die Kosten und erhält den Zusammenhang. Damit war der MST nicht minimal, ein Widerspruch.   ƒ

Kruskals Algorithmus

Man ordnet zunächst alle Kanten aufsteigend nach ihrem Gewicht. Betrachte dann alle Kanten nacheinander: Wenn die Kante zwei beliebige bisher nicht verbundene Knoten verbindet, nimmt man sie in \(E’\) auf, ansonsten betrachtet man sie nicht mehr.

Korrektheit: 1. \(E’\) bildet einen zusammenhängenden Graph.   2. \(E’\) bildet einen Baum.
3. \(E’\) bildet einen MST.

Beweis: 1. Angenommen, \(E’\) bildet nicht einen zusammenhängenden Graph, dann zerfällt \((V, E’)\) in mehrere ZHKs. Damit existiert in \(G = (V, E)\) eine Kante, die zwei dieser ZHKs verbindet. Sie muss vom Algorithmus weggeworfen sein (andernfalls wäre sie in \(E’\)), ein Widerspruch, da der Algorithmus die Kante hätte aufnehmen müssen.
2. \(E’\) bildet einen Baum, da zyklenschließende Kanten weggeworfen werden.
3. Wenn eine Kante \(e = (v, w)\) in \(E’\) aufgenommen wird, kann man folgende Partitionierung vornehmen: Zu \(P\) gehört die ZHK von \(v\) und \(V \setminus P\) ist die ZHK von \(w\) sowie alle anderen Knoten. Nach der cut property ist \(e\) in jeder MST enthalten, da es keine billigere Kante zwischen \(P\) und \(V \setminus P\) gibt.   ƒ

Datenstruktur für effiziente Implementierung: Eine solche Datenstruktur muss folgende Operationen unterstützen: 1. teste, ob Knoten \(v\) und \(w\) in gleicher ZHK sind
2. vereinige ZHKs von \(v\) und \(w\), d. h. drücke aus, dass \(v\) und \(w\) ab jetzt in gleicher ZHK sind.

Union-Find-Datenstruktur: Gegeben sei ein Universerum \(U = \{1, \dotsc , N\}\). Man will eine Partition von \(U\), also einer Zerlegung in disjunkte Teilmengen, verwalten, wobei die folgenden Operationen zulässig sein sollen:

  • InitPartition(\(N\)): legt Partition in \(N\) Teilmengen an

  • Find(\(x\)):
    gibt für \(x \in U\) einen eindeutigen Bezeichner der Teilmenge, in der \(x\) liegt, zurück

  • Union(\(x, y\)):
    vereinigt für \(x, y \in U\) (\(x, y\) nicht in gleicher Teilmenge) die beiden Teilmengen

Anwendung im Fall von Kruskals Algorithmus: Das Universum entspricht den Knoten des Graphen, die Teilmengen entsprechen den ZHKs während des Ablaufs des Algorithmus.
Wenn eine Kante \(e = (v, w)\) betrachtet wird, muss entschieden werden, ob \(v\) und \(w\) in gleicher ZHK liegen, d. h. es muss überprüft werden, ob Find(\(v\)) \(=\) Find(\(w\)).
Falls dies nicht der Fall ist, wird die Kante als Teil des MST gewählt und die ZHKs werden verschmolzen, d. h. Union(\(v, w\)) muss aufgerufen werden.
Kruskals Algorithmus führt dabei höchstens \(m\) Finds und \(n - 1\) Unions aus.

Implementierung: Stelle ein Array TM[] der Größe \(N\) zur Verfügung, in dem für jedes Element \(v\) ein kanonisches Element der Teilmenge, die \(v\) enthält, als Repräsentant gespeichert wird. Zu Beginn ist jede Teilmenge einelementig: TM[\(v\)] \(= v\).
Zusätzlich soll noch für jeden Repräsentanten \(v\) eine Liste der Elemente der Teilmenge, deren Repräsentant \(v\) ist, sowie die Länge dieser Liste gespeichert werden.

  • InitPartition(\(N\)): klar

  • Find(\(v\)): gib TM[\(v\)] zurück (Kosten \(\O (1)\))

  • Union(\(v, w\)): Ohne Einschränkung befinde sich \(w\) in der kleineren Teilmenge.
    Dann setze TM[\(x\)] \(:=\) Find(\(v\)) für alle \(x\) mit TM[\(x\)] \(=\) Find(\(w\)), hänge die Liste von Find(\(w\)) an Find(\(v\)) und aktualisiere die Listenlängen
    (Kosten \(\O (\)Länge der Liste von Find(\(w\))\()\)).

Laufzeitanalyse: Union muss im schlimmsten Fall \(\frac {N}{2}\) Knoten umsetzen. Die Gesamtkosten für \(n\) Unions sind \(G = \sum _{i=1}^n \;(\)Kosten für \(i\)-te Union-Operation\()\). Es gilt nun \(G \le N \log N\), d. h. es kann nicht sein, dass jede der Union-Operationen \(\frac {N}{2}\) kostet.

Beweis: Man betrachtet die Anzahl der Umsetzungen eines bestimmten Knotens \(v\), d. h. man schaut, wie oft sich TM[\(v\)] ändert. Die Gesamtkosten sind dann \(G = \sum _v \;(\)Anzahl, wie oft sich TM[\(v\)] ändert\()\). Mit jeder Änderung von TM[\(v\)] verdoppelt sich die Teilmenge, die \(v\) enthält, mindestens. Also kann sich TM[\(v\)] maximal \(\log N\)-mal ändern.
Daher ist \(G \le \sum _v (\log N) \le N \log N\).   ƒ

Für Kruskals Algorithmus bedeutet dies, dass der Algorithmus in \(\O (m \log n)\) implementiert werden kann, denn das Sortieren der Kanten benötigt \(\O (m \log m) = \O (m \log n)\),
es gibt höchstens \(m\) Finds (\(\O (m)\)) sowie \(n - 1\) Unions (\(\O (n \log n)\)).
Ein einzelner Union-Schritt kann jedoch \(\O (n)\) kosten.

falls man garantieren will, dass jeder Union-Schritt \(\O (\log n)\) kostet:

Bislang waren die Kosten für Find bzw. Union \(\O (1)\) bzw. evtl. \(\O (n)\). Im Folgenden wird gezeigt, wie man Find in \(\O (\log n)\) durchführt, dafür aber Union in \(\O (1)\).

Idee: Verwalte die Teilmengen als gewurzelte Bäume. Zu Beginn ist jede Teilmenge der Baum mit nur einem Element, der Wurzel. Eine Union-Operation auf zwei Teilmengen verschmelzt die entsprechenden Bäume, indem der kleinere Baum (der mit weniger Knoten) direkt unter die Wurzel des größeren gehängt wird.

Kosten nun: Find(\(v\)) kostet \(\O (\text {Tiefe des Baums})\) (laufe im Baum, der \(v\) enthält, von \(v\) zur Wurzel, gib diese als eindeutige ID für Teilmenge zurück). Union kostet \(\O (1)\).

Lemma: Die Tiefe der auftretenden Bäume ist höchstens \(\log n\).

Beweis: Betrachte das tiefste Blatt \(v\) eines Baums. Die Tiefe von \(v\) hat genau dann um \(1\) zugenommen, wenn der Baum von \(v\) unter die Wurzel eines anderen Baums gehängt wurde. Der andere Baum ist in diesem Fall mindestens so groß gewesen wie der Baum, der \(v\) enthält. Daher kann der Baum von \(v\) höchstens \(\log n\)-mal unter einen anderen Baum gehängt werden. Deswegen ist die Tiefe von \(v\) höchstens \(\log n\).   ƒ

Optimierungsidee: Wenn Find(\(x\)) auf einen Knoten aufgerufen wird, so wird im Baum von \(x\) von \(x\) aus nach oben bis zur Wurzel gelaufen. Wenn man die Knoten auf dem Weg zur Wurzel alle direkt unter die Wurzel hängt, so werden spätere Finds nach diesen Knoten billiger (\(\O (1)\)).