Definition (DAG). 6.4.1
Ein gerichteter Graph, der keinen Kreis enthält, heißt azyklsicher gerichteter Graph,
auf Englisch directed acyclic graph, als Abkürzung DAG.
Ein gerichteter azyklischer Graph, absichtlich chaotisch gezeichnet, damit
es nicht so offensichtlich ist, dass er keine Kreise besitzt.
Topologische Sortierung 6.4.2
Sei ein Digraph. Eine topologische Sortierung ist eine
Auflistung der Knotenmenge (Auflistung heißt, dass jeder
Knoten genau einmal vorkommt) mit der Eigenschaft
In Worten, alle Kanten von gehen von
links
nach rechts in der Liste.
Falls zum Beispiel eine "Selbstschleife" hat (nach Definition von
Digraphen nicht verboten),
dann kann er keine topologische Sortierung haben: müsste ja als vorkommen,
und die Bedingung würde dann verlangen, was natürlich falsch ist.
Theorem 6.4.3
Ein Digraph ist genau dann azyklisch, wenn er eine topologische Sortierung hat.
Beweis. Wir müssen zwei Richtungen zeigen; erstens, falls
einen Kreis hat, dass er keine topologische Sortierung hat; zweitens,
falls keinen Kreis hat, dass er eine topologische Sortierung hat.
Die erste Richtung ist einfacher. Wenn einen Kreis hat, dann können wir
den hinschreiben: mit für
(mit modulo interpretiert). Angenommen nun,
hätte eine topologische Sortierung. Sei der Knoten,
der in der Auflistung als letztes vorkommt, also am weitesten rechts,
sagen wir . Weil ist, ist es weiter links in ,
also für . Das heißt aber, die Kante ) geht in
von rechts nach links. Ein Widerspruch.
Die andere Richtung ist anstrengender. Nehmen wir an, hätte keinen Kreis,
und versuchen wir, eine topologische Ordnung zu finden. Verstehen Sie bitte,
warum das konzeptuell schwieriger ist. Im ersten Fall hatten wir etwas:
einen Kreis; damit konnten wir beginnen, zu arbeiten. Im zweiten Fall wissen
wir um die Nichtexistenz von etwas. Wir haben also kein Objekt in der Hand,
von dem wir ausgehen können. Um zu beweisen, dass ein DAG eine topologische
Sortierung besitzt, werden wir einen Algorithmus entwickeln, der eine solche
erzeugt. Effizienz ist uns hierbei egal. Die werden wir später verbessern.
Als erstes brauchen wir eine Definition und eine Beobachtung.
Definition. 6.4.4
Sei ein gerichteter Graph. Ein Knoten heißt
Quellknoten oder einfach Quelle, wenn es keine Kante gibt.
Er heißt Senkenknoten oder einfach Senke, wenn es keine Kante gibt.
Lemma 6.4.5 (DAGs, Quellen und Senken)
Jeder endliche DAG hat mindestens einen Senkenknoten.
Beweis. Wir beschreiben einen Algorithmus, der entweder (1) einen
Senkenknoten
findet oder (2) einen Kreis.
Nimm einen beliebigen Knoten und initialisiere einen Pfad .
Setze
while mit :
füge hinten an an und nenne es
In jedem Schritt wächst (\P\) um 1; es ist sicherlich ein Pfad, denn
stellt sicher,
dass kein Knoten zweimal in die Liste eingefügt wird. Da endlich ist, muss
die Schleife
spätestens nach durchläufen terminieren. Warum terminiert sie? Entweder gibt es
keine Kanten, die aus dem Endknoten ausgehen; dann ist eine Senke.
Oder es gibt eine solche Kante; nennen wir sie , aber ist bereits
in , also . Dann bilder
einen Kreis.
Mit der gleichen Technik können Sie beweisen, dass jeder DAG einen Quellknoten hat: Sie
müssen einfach
den Pfad "rückwärts" bauen. Kehren wir nun zu unserem ursprünglichen Vorhaben
zurück.
Wir haben einen DAG und wollen eine topologische Sortierung. Hier ist der
Algorithmus:
, die leere Liste
while noch Knoten besitzt:
finde einen Quellknoten in
lösche und alle angrenzenden Kanten aus
hänge hinten an die Liste.
return
Stellen wir noch kurz sicher, dass die Liste eine topologische Sortierung
darstellt. Betrachten wir eine
Kante im Graphen (hier bezeichnet den ursprünglichen Graphen, also
vor der Schleife, die nach und nach alle Knoten löscht;
wir stellen uns vor, dass wir nach der Schleife wiederherstellen; beachten Sie:
wir sind hier innerhalb eines mathematischen Beweises, nicht
innerhalb eines Programms; wir müssen also nicht explizit eine "Kopie" von
anfertigen und verwahren, solange wir uns im klaren sind,
was bedeutet). Wir müssen zeigen, dass in vor vorkommt. Dies ist
einfach: kann kein Quellknoten
sein, solange nicht gelöscht worden ist; also wird vor gelöscht - und
in die Liste
eingefügt.
Ein Algorithmus für topologisches Sortieren
Wir haben oben nicht nur bewiesen, dass jeder DAG eine topologische Sortierung hat; wir
haben
auch einen Algorithmus angegeben, der eine solche findet. Allerdings ist dieser nicht
besonders effizient, wenn wir ihn
naiv implementieren; wie zum Beispiel finden wir einen Quellknoten schnell? Wenn wir hierfür
Zeit brauchen,
dann ist der ganze Algorithmus , also nicht gut. Besser geht es, wenn wir die
in-degrees jedes Knoten
in einer separate Liste speichern.
Definition 6.4.6
Sei ein Digraph und . Die Raus-Nachbarn von
sind alle Knoten, zu denen eine Kante hat; die Rein-Nachbarn von
sind alle Knoten, die eine Kante nach haben:
Der Raus-Grad (bzw. Rein-Grad) von
ist die Anzahl seiner Raus-Nachbarn (bzw. Rein-Nachbarn):
Wenn aus dem Kontext klar ist, über welchen Graphen wir reden, dann lassen wir
den Subscript gerne weg und schreiben einfach
auch einfach
.
Wenn ein Graph ist, also ungerichtet, dann schreiben wir
und und sprechen von
den Nachbarn und dem
Grad von (Englisch degree).
Hier ist unser Algorithmus für topologisches Sortieren:
Algorithmus: Topologisches Sortieren mit Quellknoten-Löschen
Legen eine Liste currentIndeg = [indeg(u) for u in V] an, die in
Rein-Grade im derzeitigen Graphen repräsentiert (erinnern Sie sich: wir
"verstümmeln" den Graphen,
indem wir Knoten löschen; jetzt simulieren wir das durch eine Liste von
Rein-Graden.
Sammle alle Quellknoten:
currentSources = [u in V if indeg(u) == 0]
while currentSources not empty:
Nimm einen Knoten aus der Liste currentSources und
entferne ihn aus ihr.
for v in outNeighbors(u)
currentIndeg[v]--
if currentIndeg[v] == 0: add v to currentSources
Dieser Algorithmus ist schneller, weil wir das Löschen nur simulieren, indem wir
die Rein-Grade immer aktuell halten. Schritt 1 braucht Zeit, weil
wir einfach zweimal durch gehen müssen. Da wir unseren Graphen mit Adjazenzlisten
implementiert haben, können wir in konstanter Zeit ablesen.
Die while-Schleife Schritt 2 führt genau Durchgänge aus, weil sie in
jedem Durchgang genau einen Knoten löscht. Schritt 2.a geht in konstanter Zeit;
Schritt 2.b benötigt Zeit. Die Gesamtzeit ist also
da gilt. Unser Algorithmus hat lineare Laufzeit.
Topologisches Sortieren mit Tiefensuche
Ich werde Ihnen nun einen weiteren Algorithmus vorstellen, der in linearer Zeit eine
topologische
Sortierung findet. Dieser hat zwei Vorteile. Erstens ist er vom Code her einfacher.
Zweitens wird er
später Baustein für einen nichttrivialen Algorithmus sein, der die starken
Zusammenhangskomponenten
(werden wir später erklären) eines Digraphs bestimmt.
Algorithmus: Topologisches Sortieren mit Tiefensuche (DFS)
def topSortDFS(G): L = [] das wird unsere topologisch sortierte Liste visited = [False, ..., False] Markierung für die Tiefensuche for u in V: if visited[u]: continue stack = new Stack(u) visited[u] = True while (stack.nonEmpty()): v = stack.top() if v has unvisited neighbor w: visited[w] = True stack.push(w) else: # wir haben alle Nachbarn von v erkundet stack.pop() L.append(v) return L
Schauen wir uns ein Beispiel an.
Hier noch einmal das Endergebnis mit den Bäumen, die die Tiefensuche konstruiert hat:
Wir werden nun sehen, dass eine topologische Sortierung in umgekehrter Reihenfolge
darstellt:
Theorem 6.4.7 Wenn ein DAG ist und , dann
gilt . Der Knoten steht also rechts von in der Liste.
Beweis.
Wir betrachten zwei Fälle.
Fall 1: wird vor auf den Stack gelegt. Sei die
Menge der Knoten,
die auf den Stack gelegt (und wieder entfernt) werden, bevor wieder entfernt
wird.
sind also die Nachfahren von im DFS-Baum.
Diese Menge enthält . Warum? Irgendwann wird die Tiefensuche die Kante
betrachten;
entweder ist da schon als visited markiert und ist in , oder ist
noch nicht
visited; in letzterem Fall wird es jetzt auf den Stack gelegt und ist somit
auch in . Der Knoten wird somit (wie auch alle anderen Knoten in ) vor
vom Stack genommen
und somit auch vor an die Liste angehängt: steht links von in der Liste.
Fall 2: wird vor auf den Stack gelegt. Sei die Menge
der Knoten, die auf den Stack gelegt werden, bevor wieder entfernt wird, also
die Nachfahren von im DFS-Baum. Für alle gibt es einen Pfad
von nach . Daher gilt , denn ansonsten würde der Pfad
von nach zusammen mit Kante einen Zyklus geben. Zu dem
Zeitpunkt, wo vom Stack genommen wird, wurde also weder auf den Stack gelegt
noch wieder weggenommen. Der Knoten wird daher zuerst an angehängt und
steht links von .