6.4 Topologisches Sortieren

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 G=(V,E) ein Digraph. Eine topologische Sortierung ist eine Auflistung v1,,vn der Knotenmenge V (Auflistung heißt, dass jeder Knoten genau einmal vorkommt) mit der Eigenschaft (vi,vj)Ei<j . In Worten, alle Kanten von G gehen von links nach rechts in der Liste.

Falls G zum Beispiel eine "Selbstschleife" u,u hat (nach Definition von Digraphen nicht verboten), dann kann er keine topologische Sortierung haben: u müsste ja als vi vorkommen, und die Bedingung würde dann i<i 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 G einen Kreis hat, dass er keine topologische Sortierung hat; zweitens, falls G keinen Kreis hat, dass er eine topologische Sortierung hat.

Die erste Richtung ist einfacher. Wenn G einen Kreis hat, dann können wir den hinschreiben: C={w0,w1,,wk1} mit (wi,wi+1)E für i=0,,k1 (mit i+1 modulo k interpretiert). Angenommen nun, G hätte eine topologische Sortierung. Sei wiC der Knoten, der in der Auflistung L=(v1,,vn) als letztes vorkommt, also am weitesten rechts, sagen wir wi=vj. Weil wi+1K ist, ist es weiter links in L, also wi+1=vl für l<j. Das heißt aber, die Kante (wi,wi+1) geht in L von rechts nach links. Ein Widerspruch.

Die andere Richtung ist anstrengender. Nehmen wir an, G 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 G 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 G=(V,E) ein gerichteter Graph. Ein Knoten uV heißt Quellknoten oder einfach Quelle, wenn es keine Kante (v,u) gibt. Er heißt Senkenknoten oder einfach Senke, wenn es keine Kante (u,v) 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.

  1. Nimm einen beliebigen Knoten u0 und initialisiere einen Pfad P:=[u0]. Setze k:=0
  2. while vP mit (uk,v)E:
    1. füge v hinten an P an und nenne es uk+1
    2. k++

In jedem Schritt wächst (\P\) um 1; es ist sicherlich ein Pfad, denn vP stellt sicher, dass kein Knoten zweimal in die Liste P eingefügt wird. Da G endlich ist, muss die Schleife spätestens nach n1 durchläufen terminieren. Warum terminiert sie? Entweder gibt es keine Kanten, die aus dem Endknoten uk ausgehen; dann ist uk eine Senke. Oder es gibt eine solche Kante; nennen wir sie (uk,v), aber v ist bereits in P, also v=ui,ik. Dann bilder ui,ui+1,,uk einen Kreis.

Mit der gleichen Technik können Sie beweisen, dass jeder DAG einen Quellknoten hat: Sie müssen einfach den Pfad P "rückwärts" bauen. Kehren wir nun zu unserem ursprünglichen Vorhaben zurück. Wir haben einen DAG G und wollen eine topologische Sortierung. Hier ist der Algorithmus:

  1. L:=[], die leere Liste
  2. while G noch Knoten besitzt:
    1. finde einen Quellknoten u in G
    2. lösche u und alle angrenzenden Kanten aus G
    3. hänge u hinten an die Liste.
  3. return L

Stellen wir noch kurz sicher, dass die Liste L eine topologische Sortierung darstellt. Betrachten wir eine Kante (u,v) im Graphen G (hier bezeichnet G den ursprünglichen Graphen, also vor der Schleife, die nach und nach alle Knoten löscht; wir stellen uns vor, dass wir G 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 G anfertigen und verwahren, solange wir uns im klaren sind, was G bedeutet). Wir müssen zeigen, dass u in L vor v vorkommt. Dies ist einfach: v kann kein Quellknoten sein, solange u nicht gelöscht worden ist; also wird u vor v 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 O(n) Zeit brauchen, dann ist der ganze Algorithmus O(nm), also nicht gut. Besser geht es, wenn wir die in-degrees jedes Knoten in einer separate Liste speichern.
Definition 6.4.6 Sei G=(V,E) ein Digraph und uV. Die Raus-Nachbarn von u sind alle Knoten, zu denen u eine Kante hat; die Rein-Nachbarn von u sind alle Knoten, die eine Kante nach u haben: outNeighborsG(u):={vV | (u,v)E}inNeighborsG(u):={wV | (w,u)E} Der Raus-Grad (bzw. Rein-Grad) von u ist die Anzahl seiner Raus-Nachbarn (bzw. Rein-Nachbarn): outdegG(u):=|outNeighborsG(u)|indegG(u):=|inNeighborsG(u)| Wenn aus dem Kontext klar ist, über welchen Graphen G wir reden, dann lassen wir den Subscript gerne weg und schreiben einfach auch einfach outdeg(u),indeg(u).

Wenn G ein Graph ist, also ungerichtet, dann schreiben wir neighborsG(u):={vV | {u,v}E} und degG(u):=|neighborsG(u)| und sprechen von den Nachbarn und dem Grad von u (Englisch degree).

Hier ist unser Algorithmus für topologisches Sortieren:
Algorithmus: Topologisches Sortieren mit Quellknoten-Löschen
  1. 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.
  2. Sammle alle Quellknoten: currentSources = [u in V if indeg(u) == 0]
  3. while currentSources not empty:
    1. Nimm einen Knoten u aus der Liste currentSources und entferne ihn aus ihr.
    2. for v in outNeighbors(u)
      1. currentIndeg[v]--
      2. 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 Θ(n) Zeit, weil wir einfach zweimal durch V gehen müssen. Da wir unseren Graphen mit Adjazenzlisten implementiert haben, können wir indeg(u) in konstanter Zeit ablesen. Die while-Schleife Schritt 2 führt genau n 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 Θ(outdeg(u)) Zeit. Die Gesamtzeit ist also

Θ(n+uVoutdeg(u) )=Θ(n+m), da uVoutdeg(u)=m 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

Hier noch einmal das Endergebnis mit den Bäumen, die die Tiefensuche konstruiert hat:

Wir werden nun sehen, dass L eine topologische Sortierung in umgekehrter Reihenfolge darstellt:

Theorem 6.4.7 Wenn G ein DAG ist und (u,v)E, dann gilt v<Lu. Der Knoten u steht also rechts von v in der Liste.

Beweis. Wir betrachten zwei Fälle.

Fall 1: u wird vor v auf den Stack gelegt. Sei X die Menge der Knoten, die auf den Stack gelegt (und wieder entfernt) werden, bevor u wieder entfernt wird. X sind also die Nachfahren von u im DFS-Baum. Diese Menge enthält v. Warum? Irgendwann wird die Tiefensuche die Kante (u,v) betrachten; entweder ist v da schon als visited markiert und ist in X, oder ist noch nicht visited; in letzterem Fall wird es jetzt auf den Stack gelegt und ist somit auch in X. Der Knoten v wird somit (wie auch alle anderen Knoten in X) vor u vom Stack genommen und somit auch vor u an die Liste angehängt: v steht links von u in der Liste.

Fall 2: v wird vor u auf den Stack gelegt. Sei X die Menge der Knoten, die auf den Stack gelegt werden, bevor v wieder entfernt wird, also die Nachfahren von v im DFS-Baum. Für alle wX gibt es einen Pfad von v nach w. Daher gilt uX, denn ansonsten würde der Pfad von v nach u zusammen mit Kante (u,v) einen Zyklus geben. Zu dem Zeitpunkt, wo v vom Stack genommen wird, wurde u also weder auf den Stack gelegt noch wieder weggenommen. Der Knoten v wird daher zuerst an L angehängt und steht links von u.