Durchsuchen von Graphen 2 - 19 - Durchsuchen von Graphen Das vollständige Durchsuchen eines Graphen entlang seiner Kanten und Knoten, wobei alle Knoten erreicht werden müssen, ist eine grundlegende Aufgabe, die in vielen Graphalgorithmen als Teilaufgabe auftritt. Zwei besonders häufig verwendete Standardverfahren hierfür sind die Tiefensuche und die Breitensuche. Reihenfolgebeziehungen zwischen Objekten können mit Hilfe eines gerichteten azyklischen Graphen modelliert werden. Der Algorithmus des topologischen Sortierens ermöglicht es, eine lineare Anordnung der Objekte zu berechnen, so dass die definierten Reihenfolgebeziehungen nicht verletzt werden. Nach dem Durcharbeiten dieses Kapitels sollen Sie • die Funktionsweise der Suchverfahren verstanden haben und nachvollziehen können, • Beispiele von Problemen kennen, die mit Hilfe dieser Standardverfahren gelöst werden können, • für gegebene Problemstellungen einschätzen können, welches Suchverfahren angemessen ist und • auf Basis dieses Verfahrens einen Algorithmus konstruieren können. 2.1 Tiefensuche Angenommen, wir wollen ermitteln, ob ein Graph zusammenhängend ist. Für “kleine” Graphen könnten wir die Antwort auf diese Frage leicht einem Diagramm des Graphen entnehmen. Haben wir aber einen Graphen mit mehreren Tausend Knoten, dann lässt sich diese Frage nicht mehr so einfach beantworten. Vielleicht steht uns auch gar kein Diagramm zur Verfügung, sondern der Graph ist nur im Computer repräsentiert. Für diesen Fall benötigen wir einen Algorithmus, der berechnet, ob ein Graph zusammenhängend ist. Laut Definition 1.10 ist ein Graph zusammenhängend, wenn je zwei Knoten über einen Weg verbunden sind. Für eine Berechnung können wir es uns etwas einfacher machen. Offensichtlich genügt es, ausgehend von einem Startknoten zu jedem Knoten einen Weg zu finden. Abbildung 11 verdeutlicht solch einen Suchalgorithmus für Bäume. Die gestrichelten Pfeile geben an, in welcher Reihenfolge die Knoten besucht wer- Lernziele - 20 - Graphentheorie den. Wir beginnen die Suche im Knoten a und laufen von dort zu Knoten b, dem ersten Nachbarn von Startknoten a. Von b aus gehen wir zu dessem ersten Nachbarn c, kommen dort aber nicht weiter. Also gehen wir zu b zurück und fahren mit d, dem zweiten Nachbarn von b, fort. Auch in d kommen wir nicht weiter, und weil b keinen weiteren Nachbarn hat, gehen wir zurück bis a. Knoten a hat den unbesuchten Nachbarn e und von e gehen wir wiederum zurück zu a, wo die Suche endet. a b c e d Abbildung 11: Tiefensuche in einem Baum Tiefensuche für Bäume Das allgemeine Prinzip für das Durchlaufen des Baumes lautet also: Wenn ein Knoten v betreten wurde, dann arbeite rekursiv alle Nachbarn von v ab und kehre anschließend zu dem Knoten zurück, von dem aus v betreten wurde. In Abbildung 11 bedeutet dies, dass wir zuerst in die Tiefe gehen, bevor wir einen weiteren Nachbarn eines Knotens besuchen. Während es in einem Baum genau einen Weg zwischen zwei Knoten gibt, können in einem allgemeinen Graphen mehrere Wege zwischen zwei Knoten existieren. Daher müssen wir darauf achten, Teile des Graphen nicht mehrfach zu durchlaufen. Wir erreichen dies, indem wir eine simple Technik einsetzen: Immer wenn wir einen Knoten betreten, markieren wir diesen und so markierte Knoten betreten wir nicht ein weiteres Mal. Markiert werden die Knoten v mit einer Tiefensuchnummer t(v). Ausgehend von t(v) = 1 für den Startknoten gibt die Tiefensuchnummer die Reihenfolge an, in der die Knoten besucht wurden. Für noch nicht besuchte Knoten gilt t(v) = ∞. Tiefensuchalgorithmus Algorithmus 2.1 (Tiefensuche) Es sei G = (V, E) ein Graph. Für einen Knoten v ∈ V ist die rekursive Prozedur DFSEARCH(v) wie folgt definiert: i := i + 1; t(v) := i; N(v) := {w|{v, w} ∈ E}; while ∃w ∈ N(v) mit t(w) = ∞ do N(v) := N(v) \ {w}; B := B ∪ {{v, w}}; DFSEARCH(w); end Durchsuchen von Graphen - 21 - Der Algorithmus für die Tiefensuche lautet: B := ∅; i := 0; for all v ∈ V do t(v) := ∞; while ∃v ∈ V mit t(v) = ∞ do DFSEARCH(v); Die Kantenmenge B liefert für jede Zusammenhangskomponente von G einen Baum. Da in der Tiefensuche nicht festgelegt ist, in welcher Reihenfolge die Prozedur DFSEARCH(w) für die Knoten w ∈ N(v) aufgerufen wird, hängen t(v) und B von der Repräsentation des Graphen ab. Beispiel 2.1 Abbildung 12 zeigt im oberen Teil einen Graph mit seiner Adjazenzlistendarstellung. Wenn die Knoten in der durch die Adjazenzlisten definierten Reihenfolge besucht werden, dann stellt der untere Teil der Abbildung das Ergebnis der Tiefensuche mit den Kanten der Menge B und den Tiefensuchnummern t(v) dar. Eine andere Reihenfolge der Knoten in den Adjazenzlisten könnte zu einem anderen Ergebnis führen. c b a b c d e f g h d g e a h f b a b b a g f f e c d c d h h g d e 3 2 4 7 1 5 6 8 Abbildung 12: Tiefensuche Die while-Schleife der Tiefensuche wird für jede Zusammenhangskomponente genau einmal aufgerufen. Daher genügt es, einen Zähler in diese while-Schleife einzubauen, um die Anzahl der Zusammenhangskomponenten zu berechnen. Anwendungen der Tiefensuche - 22 - Graphentheorie nZHK := 0; while ∃v ∈ V mit t(v) = ∞ do nZHK := nZHK + 1; DFSEARCH(v); end In den Übungen betrachten wir weitere Anwendungen für die Tiefensuche. Zeitaufwand der Tiefensuche Abschließend wollen wir noch untersuchen, wie groß der Zeitaufwand für die Tiefensuche ist. In der Prozedur DFSEARCH wird jede Kante {v, w} zweimal betrachtet, einmal beim Aufruf von DFSEARCH(v) und ein weiteres Mal bei dem von DFSEARCH(w). Die Gesamtanzahl der Iterationen für diese whileSchleife beträgt somit höchstens 2 · |E|. Alle Operationen innerhalb der while-Schleife können in konstanter Zeit ausgeführt werden. Darüber hinaus wird die Prozedur DFSEARCH genau |V |-mal aufgerufen. Somit erhalten wir O(|V | + |E|) als Gesamtaufwand für die Tiefensuche. Satz 2.1 Der Zeitaufwand für Algorithmus 2.1 beträgt O(|V | + |E|). ✍ Übungsaufgaben 2.1 Geben Sie für den folgenden Graphen die Nummern t(v) sowie die Menge B der Baumkanten an, die sich bei der Tiefensuche ergeben. Starten Sie die Suche im Knoten a. Die Adjazenzlisten seien alphabetisch sortiert. c j f d b h a Erkennung von Artikulationspunkten e g k l i 2.2 Für einen Graphen G = (V, E) heißt ein Knoten a ∈ V Artikulationspunkt, wenn die Anzahl der Zusammenhangskomponenten von G(V \ {a}) größer ist als die von G. Anschaulich: Durch die Wegnahme von a und den dazu inzidenten Kanten zerfällt der Graph in weitere Teile. Wie kann man mit Tiefensuche Artikulationspunkte erkennen? Konstruieren Sie einen entsprechenden Algorithmus (ohne Beweis der Korrektheit). Durchsuchen von Graphen 2.2 - 23 - Breitensuche Die Tiefensuche, die wir im letzten Abschnitt kennen gelernt haben, ist natürlich nicht die einzig mögliche Vorgehensweise, um einen Graphen systematisch zu durchsuchen. Wenn wir uns bei der Tiefensuche am Knoten v befinden, wählen wir einen noch nicht besuchten Nachbarn von v aus, um von dort aus die Suche fortzusetzen. Bei der Breitensuche dagegen tragen wir alle Nachbarn von v in eine Warteschlange W ein, die diejenigen Knoten enthält, von denen aus der Graph weiter durchsucht werden muss. In jeder Iteration der Breitensuche nehmen wir den ersten Knoten w aus der Warteschlange W heraus, ermitteln alle Nachbarn von w, die noch nicht erreicht worden sind und fügen diese Nachbarn in die Warteschlange ein. Damit durchläuft die Breitensuche einen Baum wie in Abbildung 13 Ebene für Ebene. a b c e d Abbildung 13: Breitensuche in einem Baum Analog zur Tiefensuche werden die Knoten mit einer Breitensuchnummer b(v) markiert. Zusätzlich wird ein Knoten v als “erreicht” markiert, wenn er in die Warteschlange W aufgenommen wird. Hierzu dient die Markierung r(v). Algorithmus 2.2 (Breitensuche) Es sei G = (V, E) ein Graph. Für einen Knoten v ∈ V ist die Prozedur BFSEARCH(v) wie folgt definiert: i := i + 1; b(v) := i; N(v) := {w|{v, w} ∈ E} for all w ∈ N(v) mit r(w) = false do B := B ∪ {{v, w}}; füge w an W an; r(w) := true; end Der Algorithmus für die Breitensuche lautet: Breitensuchalgorithmus - 24 - Graphentheorie B := ∅; i := 0; W := (); for all v ∈ V do b(v) := ∞; r(v) := false; end while W = () and ∃v ∈ V : b(v) = ∞ do W := (v); r(v) := true; while W 6= () do wähle ersten Knoten w aus W ; entferne w aus W ; BFSEARCH(w); end end Die Prozedur BFSEARCH(v) wird jeweils für den ersten Knoten der Warteschlange W ausgeführt. Die Kantenmenge B liefert wiederum ein Gerüst für jede Zusammenhangskomponente des Graphen. Der Algorithmus terminiert, wenn die Warteschlange leer ist und kein Knoten v mit b(v) = ∞ mehr existiert. Die äußere while-Schleife der Breitensuche wird für jede Zusammenhangskomponente genau einmal durchlaufen, die innere while-Schleife genau einmal für jeden Knoten. Beispiel 2.2 Abbildung 14 zeigt das Ergebnis der Breitensuche für den Graph von Abbildung 12. 4 2 5 7 1 3 6 8 Abbildung 14: Breitensuche Zeitaufwand der Breitensuche Berechnung von Knotenabständen Satz 2.2 Der Zeitaufwand für Algorithmus 2.2 beträgt O(|V | + |E|). Beweis: Wir argumentieren analog zu Satz 2.1: In BFSEARCH wird jede Kante genau zweimal betrachtet, die weiteren Anweisungen von BFSEARCH sind mit konstantem Aufwand verbunden und im Hauptalgorithmus wird jeder Knoten genau einmal selektiert und aus W gelöscht. 2 Welche Anwendungen gibt es für die Breitensuche? Wenn wir einen Zähler in die äußere while-Schleife einbauen, können wir mit der Breitensuche ebenfalls die Anzahl der Zusammenhangskomponenten berechnen. Eine andere Anwendung für die Breitensuche ist die Bestimmung der Abstände da (v) zwischen einem ausgezeichneten Knoten a und allen anderen Knoten v ∈ V . Die Zahl da (v) sei definiert als die Länge eines kürzesten Durchsuchen von Graphen - 25 - Weges zwischen a und v. Da die Breitensuche Ebene für Ebene verläuft, wird implizit solch ein kürzester Weg ermittelt. Wir müssen uns im Verlauf der Breitensuche also nur noch die Abstände für die schon erreichten Knoten merken. Hierzu erweitern wir die for all-Schleife in der Prozedur BFSEARCH(v) um die Anweisung: da (w) := da (v) + 1 und setzen da (a) := 0 zu Beginn der Breitensuche. Abbildung 15 zeigt ein Beispiel für solch eine Abstandsberechnung. Das Gerüst in der unteren Abbildung entspricht der Menge B, die Zahlen an den Knoten geben die Abstände zum Knoten a an. Wir gehen dabei davon aus, dass die Adjazenzlisten alphabetisch sortiert sind. c d f e g b a 2 2 1 0 3 2 1 Abbildung 15: Abstandsberechnung mit Hilfe der Breitensuche ✍ Übungsaufgaben 2.3 Geben Sie für den Graphen aus Aufgabe 2.1 die Breitensuchnummern b(v) und die Menge B an. - 26 - Schichtengraph Graphentheorie 2.4 Die Knoten des folgenden Graphen können wir in fünf Schichten (die Bereiche zwischen den gestrichelten Linien) aufteilen: 0 : {1}, 1 : {2, 3}, 2 : {4}, 3 : {5, 6}, 4 : {7}. b e d g a c 0 1 f 2 3 4 Jede gerichtete Kante verbindet einen Knoten aus Schicht i mit einem Knoten aus Schicht i + 1. Für die Knoten einer Schicht entspricht die Schichtennummer der Entfernung vom Knoten a. Betrachten Sie nun allgemein einen gerichteten kreisfreien Graphen G = (V, A), der genau einen Knoten v mit indeg(v) = 0 hat. Wie kann man mit Breitensuche erkennen, ob solch ein Graph in Schichten eingeteilt werden kann? 2.3 Topologisches Sortieren Gerichtete kreisfreie Graphen eignen sich unter anderem zur Modellierung von Reihenfolgebeziehungen, z.B. innerhalb von Projekten. Ein Projekt besteht aus auszuführenden Tätigkeiten t1 , . . . , tn , die voneinander abhängen. Wenn Tätigkeit ti vor Tätigkeit tj ausgeführt werden muss, dann repräsentieren wir dies im Graphen durch die gerichtete Kante (ti , tj ). Für die Planung des Projekts benötigen wir eine Anordnung der einzelnen Tätigkeiten, so dass alle Reihenfolgebeziehungen berücksichtigt sind. Solch eine Anordnung nennen wir topologische Sortierung. Topologische Sortierung Definition 2.1 Es sei G = (V, A) ein gerichteter kreisfreier Graph mit V = {v1 , . . . , vn }. Eine Knotenreihenfolge L = (vi1 , . . . , vin ) aller Knoten aus V heißt topologische Sortierung von G, wenn die folgende Bedingung erfüllt ist: für alle (vij , vik ) ∈ A gilt ij < ik Das heißt, für jede Kante (v, w) ∈ A ist v vor w in L. Durchsuchen von Graphen - 27 - Beispiel 2.3 Jede topologische Sortierung der Projekttätigkeiten repräsentiert eine erlaubte Ausführungsreihenfolge. Abbildung 16 zeigt einen gerichteten kreisfreien Graph mit einer zugehörigen topologischen Sortierung. a c b d e f 1 2 3 4 5 6 c e a d b f Abbildung 16: Gerichteter kreisfreier Graph mit topologischer Sortierung Unser Ziel ist es natürlich, einen Algorithmus zu konstruieren, der eine topologische Sortierung für einen gerichteten kreisfreien Graphen G berechnet. Es bietet sich an, die topologische Sortierung von links nach rechts zu konstruieren, d.h. im i-ten Schritt entscheiden wir, welcher Knoten Position i in der Sortierung einnimmt. Welche Tätigkeiten könnten als erste ausgeführt werden? Dies sind offensichtlich diejenigen Tätigkeiten, die nicht von anderen Tätigkeiten abhängen. In G sind das die Knoten, die nicht Endknoten einer gerichteten Kante sind, d.h. alle Knoten v mit indeg(v) = 0. Wir wählen einen Knoten v mit indeg(v) = 0 aus, der den ersten Rang in der topologischen Sortierung einnimmt. Alle Reihenfolgebeziehungen, die v betreffen, sind damit erfüllt, so dass wir v zusammen mit seinen inzidenten Kanten aus G entfernen können. Der so entstandene Graph ist wieder kreisfrei und enthält genau die Reihenfolgebeziehungen, die noch zu berücksichtigen sind. Damit haben wir die gleiche Situation wie zu Beginn. Eine iterative Anwendung der erläuterten Vorgehensweise liefert uns somit die gewünschte topologische Sortierung. Algorithmus 2.3 Gegeben sei ein gerichteter kreisfreier Graph G = (V, A). Berechnet wird eine topologische Sortierung, die durch die Nummerierung rang(v) gegeben ist. Q := {v ∈ V |indeg(v) = 0} k := 0; while Q 6= ∅ do Algorithmus zum topologischen Sortieren - 28 - Graphentheorie wähle ein v aus Q; k := k + 1; rang(v) := k; Q := Q \ {v}; for all (v, w) ∈ A do indeg(w) := indeg(w) − 1; if indeg(w) = 0 then Q := Q ∪ {w}; end end Was passiert, wenn wir Algorithmus 2.3 auf einen Graphen G anwenden, der einen gerichteten Kreis enthält? Zwischen den Knoten eines Kreises existieren zyklische Abhängigkeiten, so dass sie nie in die Menge Q aufgenommen werden können. Daher wird der Algorithmus terminieren, ohne dass den Kreisknoten v ein rang(v) zugewiesen wurde. Zeitaufwand des topologischen Sortierens Satz 2.3 Der Zeitaufwand für Algorithmus 2.3 beträgt O(|V | + |A|). Beweis: Die while-Schleife wird genau |V |-mal durchlaufen, denn jeder Knoten wird genau einmal in Q aufgenommen bzw. aus Q gelöscht. Jede Kante e ∈ A wird in der for all-Schleife genau einmal berücksichtigt, d.h. der Gesamtaufwand summiert über alle Iterationen der for all-Schleife beträgt O(|A|). 2 ✍ Übungsaufgaben 2.5 Bestimmen Sie eine topologische Sortierung für den gerichteten Graphen G = (V, A) mit V = {a, b, c, d, e, f, g, h, i, j, k, l} A = {(a, c), (b, d), (c, d), (e, c), (e, d), (e, f ), (e, g), (f, c), (f, h), (g, f ), (g, h), (g, i), (h, j), (i, h), (i, j), (i, k), (l, k)} Zusammenfassung Tiefensuche und Breitensuche sind Algorithmen, mit denen wir in linearer Zeit Graphen systematisch untersuchen können. Eine topologische Sortierung stellt eine Reihenfolge der Knoten dar, bei der die durch die Kanten definierten Abhängigkeiten erfüllt sind.