6. Graphen

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. , die leere Liste
  2. while noch Knoten besitzt:
    1. finde einen Quellknoten in
    2. lösche und alle angrenzenden Kanten aus
    3. hänge hinten an die Liste.
  3. 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
  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 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 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: 
      dfsAndPrint(u)
  
  def dfsAndPrint(u):
      if visited[u]: 
          return 
      else :
          visited[u] = True 
          for v in outNeighbors(u):
              dfsAndPrint(v)
          L.append(u)      

  return L

Die ganze Animation finden Sie auch in topsort.pdf. Hier noch einmal das Endergebnis.

Wir sehen in der Mitte die verschiedenen DFS-Bäume. Nennen wir diese mal .

Beobachtung 6.4.7 Sei ein DAG. Die DFS-Bäume von haben folgende Eigenschaften:
  1. Wenn im gleichen Baum vorkommen und ein Vorfahre von ist, dann gibt es in einen Pfad von nach (den Pfad sehen Sie ja in selbst);
  2. aber nicht von nach (sonst hätte einen Kreis);
  3. der Knoten kommt später in als ; dies folgt direkt aus dem Code, weil dfsAndPrint(u) den Knoten erst am Schluß an anfügt, wenn alle rekursiven Aufrufe (zu denen ja auch dfsAndPrint(v) gehört), abgearbeitet sind.
  4. Wenn in liegt und in einem weiter rechts gelegenen , also , dann gibt es keinen Pfad von nach .
Beweis von Punkt 4.

Dies ist nicht so offensichtlich wie Punkte 1, 2, 3. Nehmen wir an, es gäbe einen solchen Pfad, also . Sei der erste Knoten auf diesem Pfad, für den dfsAndPrint aufgerufen worden ist; zu dem Zeitpunkt, wo dfsAndPrint(w) aufgerufen wird, sind alle Knoten auf dem Teilpfad unmarkiert. Die Tiefensuche wird also alle diese Knoten von aus erforschen; insbesondere liegt im gleichen DFS-Baum wie , also in . Da in einem weiter rechts gelegenen DFS-Baum als liegt, muss dfsAndPrint(w) später als dfsAndPrint(u) aufgerufen worden sein. Ein Widerspruch, weil wir ja angenommen haben, sei der erste solche Knoten.

Einfacher, aber nicht ganz formal korrekt ausgedrückt: gäbe es einen Pfad von nach , dann hätte die Tiefensuche spätestens von aus (aber vielleicht schon zuvor) den Knoten besucht; könnte also nicht in einem weiter rechts gelegenen Baum als sein.

Theorem 6.4.8 Die Liste , die von unserem Algorithmus topSortDFS erstellt wird, ist eine topologische Sortierung in umgekehrter Reihenfolge. Das heißt, wenn , dann kommt rechts von in .
Beweis. Für sei der DFS-Baum, der enthält. Da gilt, gibt es offensichtlich einen Pfad von nach . Nach unserer obigen Beobachtung kann also nicht nach in der Liste der DFS-Bäume vorkommen. Es bleiben zwei Möglichkeiten:
  1. steht links von . Dann steht sicherlich links von in der Output-Liste .
  2. . Dieser Fall ist schwieriger. Halten wir fest, dass kein Vorfahre von ist (ansonten würde der Pfad von nach zusammen mit der Kante einen Kreis schließen). Es gibt nun zwei Fälle:
    1. wenn dfsAndPrint(u) vor dfsAndPrint(v) aufgerufen wird, dann wird dfsAndPrint(u) sicherlich einen Aufruf von dfsAndPrint(v) nach sich ziehen, und somit wird zuerst an angefügt werden;
    2. wenn dfsAndPrint(v) vor dfsAndPrint(u), dann erforscht die Tiefensuche von aus einen Teilbaum mit als Wurzelknoten; ist nicht in ; alle Knoten von werden also an angefügt, bevor die Tiefensuche verlässt und weitermacht (und schließlich bei ankommt); also steht links von in
In allen Fällen und Unterfällen erkennen wir, dass zuerst an angefügt wird und somit weiter links steht.