Topologische Sortierung
Kurz gesagt: Die topologische Sortierung ordnet Aufgaben mit Abhängigkeiten so, dass jede nach ihren Abhängigkeiten kommt; der Algorithmus von Kahn macht das mit einer Warteschlange und erkennt einen Zyklus, falls es einen gibt.
Problem
Was wird zuerst gebaut
Der Online-Shop ist in sieben Module aufgeteilt. core ist der gemeinsame Code. db spricht mit der Datenbank und nutzt core. auth kümmert sich um die Anmeldung der Kunden und nutzt core und db. catalog, cart, payments und web stehen darauf.
Das Build-Tool muss jedes Modul nach allem kompilieren, was es braucht. Baut es cart vor catalog, findet der Compiler die Klassen des Katalogs nicht und bricht ab.
Die erste Lösung ist, die Reihenfolge von Hand in ein Skript zu schreiben. Das geht gut, bis jemand eine neue Abhängigkeit hinzufügt und das Skript vergisst. Ein großes Projekt hat Hunderte Module.
Und was, wenn auth anfängt, etwas aus web zu nutzen, das cart nutzt, das wiederum auth nutzt? Dann wartet jedes auf ein anderes, und der Build ist unmöglich. Das willst du sofort wissen, und auch, welche Module schuld sind.
Die Idee
Ein Abhängigkeitsgraph
Die Module sind die Knoten eines gerichteten Graphen (siehe Darstellung eines Graphen). Wir speichern ihn als Adjazenzliste: für jedes Modul, was es braucht.
Map<String, List<String>> needs = new LinkedHashMap<>();needs.put("core", List.of());needs.put("db", List.of("core"));needs.put("auth", List.of("core", "db"));needs.put("catalog", List.of("db"));needs.put("cart", List.of("catalog", "auth"));needs.put("payments", List.of("auth"));needs.put("web", List.of("cart", "payments"));Eine Reihenfolge, in der jedes Modul nach seinen Abhängigkeiten kommt, heißt topologische Reihenfolge, und sie zu finden ist die topologische Sortierung. Es gibt sie nur, wenn der Graph keinen Zyklus hat.
Wie findest du sie? Ein Modul ohne Abhängigkeiten kann sofort gebaut werden. Ist es gebaut, müssen die Module, die es nutzen, auf eine Abhängigkeit weniger warten. Die Zahl „wie viele Abhängigkeiten noch warten“ ist der Eingangsgrad des Moduls.
Das ist der Algorithmus von Kahn:
- Zähle den Eingangsgrad jedes Moduls.
- Die Module mit Eingangsgrad 0 kommen in eine Warteschlange
ready. - Nimm ein Modul aus der Warteschlange und lege es in
order. Zähle bei allen, die es nutzen, den Eingangsgrad herunter. Wer 0 erreicht, kommt in die Warteschlange. - Wiederhole, bis die Warteschlange leer ist.
Sieh es dir an
Kahn von innen
static List<String> buildOrder( Map<String, List<String>> needs) { Map<String, Integer> indegree = new HashMap<>(); Map<String, List<String>> users = new HashMap<>(); for (String m : needs.keySet()) { indegree.put(m, needs.get(m).size()); users.put(m, new ArrayList<>()); } for (String m : needs.keySet()) { for (String dep : needs.get(m)) { users.get(dep).add(m); } } Deque<String> ready = new ArrayDeque<>(); for (String m : needs.keySet()) { if (indegree.get(m) == 0) ready.add(m); } List<String> order = new ArrayList<>(); while (!ready.isEmpty()) { String m = ready.poll(); order.add(m); for (String user : users.get(m)) { indegree.put(user, indegree.get(user) - 1); if (indegree.get(user) == 0) ready.add(user); } } return order;}users ist die Umkehrung von needs: für jedes Modul, wer es nutzt. Ohne sie müsstest du nach jedem gebauten Modul den ganzen Graphen durchsuchen.
ArrayDeque ist die Warteschlange: add legt hinten ab, poll nimmt vorne heraus, beides in O(1).Map<String, List<String>> needs = new LinkedHashMap<>();needs.put("core", List.of());needs.put("db", List.of("core"));needs.put("auth", List.of("core", "db"));needs.put("catalog", List.of("db"));needs.put("cart", List.of("catalog", "auth"));needs.put("payments", List.of("auth"));needs.put("web", List.of("cart", "payments"));System.out.println("Reihenfolge: " + String.join(", ", buildOrder(needs)));Reihenfolge: core, db, auth, catalog, payments, cart, webstatic List<String> buildOrder( Map<String, List<String>> needs) { Map<String, Integer> indegree = new HashMap<>(); Map<String, List<String>> users = new HashMap<>(); for (String m : needs.keySet()) { indegree.put(m, needs.get(m).size()); users.put(m, new ArrayList<>()); } for (String m : needs.keySet()) { for (String dep : needs.get(m)) { users.get(dep).add(m); } } Deque<String> ready = new ArrayDeque<>(); for (String m : needs.keySet()) { if (indegree.get(m) == 0) ready.add(m); } List<String> order = new ArrayList<>(); while (!ready.isEmpty()) { String m = ready.poll(); order.add(m); for (String user : users.get(m)) { indegree.put(user, indegree.get(user) - 1); if (indegree.get(user) == 0) ready.add(user); } } return order;}Schritt für Schritt, in Worten
- Sieben Module. Ein Pfeil führt von einer Abhängigkeit zu dem Modul, das sie nutzt. Die Zahl über jedem Modul ist sein Eingangsgrad: wie viele seiner Abhängigkeiten noch nicht gebaut sind.
- Nur core wartet auf nichts (Eingangsgrad 0). Es kommt in die Warteschlange ready.
- core verlässt ready und wird gebaut. Alle, die es nutzen, warten jetzt auf eine Abhängigkeit weniger: db fällt auf 0 und kommt in die Warteschlange, auth fällt auf 1.
- db wird gebaut. auth und catalog fallen auf 0: beide haben alles, was sie brauchen, und kommen in die Warteschlange.
- auth wird gebaut. payments fällt auf 0 und kommt hinein, cart fällt auf 1: es wartet noch auf catalog.
- catalog wird gebaut und cart fällt auf 0. Jetzt kommt auch cart in die Warteschlange.
- payments wird gebaut. web fällt auf 1: es wartet noch auf cart.
- cart wird gebaut, web fällt auf 0 und kommt als Letztes hinein.
- web wird gebaut. Niemand nutzt es, und die Warteschlange ist leer.
- buildOrder gibt order mit 7 Modulen zurück, so vielen, wie in needs stehen: jedes steht nach seinen Abhängigkeiten. Weniger würde einen Zyklus bedeuten.
Die Warteschlange enthält nie mehr als zwei Module: nur die, die schon alles haben und auf ihren Einsatz warten.
Probier es aus
Sag die Ausgabe voraus
Die Aufgaben eines Releases: Stilprüfung, Kompilieren, Tests, Paketieren, Deploy, Benachrichtigung. Was gibt buildOrder zurück?
Map<String, List<String>> tasks = new LinkedHashMap<>();tasks.put("lint", List.of());tasks.put("compile", List.of());tasks.put("test", List.of("compile"));tasks.put("package", List.of("compile", "lint"));tasks.put("deploy", List.of("test", "package"));tasks.put("notify", List.of("deploy"));System.out.println(buildOrder(tasks));Probier es aus
Ergänze
Wann kommt ein Modul in die Warteschlange?
for (String user : users.get(m)) {
indegree.put(user, indegree.get(user) - 1);
if () ready.add(user);
}Das Modul kommt hinein, wenn seine letzte Abhängigkeit gebaut ist: Sein Eingangsgrad erreicht 0. Käme es bei jedem Herunterzählen hinein, wäre es mehrmals darin und würde vor seinen Abhängigkeiten gebaut.
Die Idee
Der Zyklus
Ein Kollege lässt auth einen Helfer aus web nutzen:
needs.put("auth", List.of("core", "db", "web"));Jetzt wartet auth auf web, web wartet auf cart, cart wartet auf auth. Keines der drei erreicht den Eingangsgrad 0 oder kommt in die Warteschlange. payments wartet auf auth und bleibt auch zurück. Die Warteschlange läuft leer, und in order stehen nur 3 der 7 Module.
Das ist auch die Prüfung: Weniger Module in order als im Graphen bedeutet einen Zyklus. Um zu sagen, welche Module schuld sind, beginnst du beim ersten ungebauten und folgst einer ungebauten Abhängigkeit. Jedes ungebaute Modul hat mindestens eine, also kommst du früher oder später zu einem Modul, an dem du schon vorbei bist:
static List<String> findCycle( Map<String, List<String>> needs, List<String> order) { Set<String> built = new HashSet<>(order); String m = needs.keySet().stream() .filter(x -> !built.contains(x)).findFirst().get(); List<String> path = new ArrayList<>(); while (!path.contains(m)) { path.add(m); m = needs.get(m).stream() .filter(d -> !built.contains(d)).findFirst().get(); } List<String> loop = new ArrayList<>( path.subList(path.indexOf(m), path.size())); loop.add(m); return loop;}List<String> order = buildOrder(needs);if (order.size() < needs.size()) { System.out.println("Gebaut: " + String.join(", ", order)); List<String> loop = findCycle(needs, order); System.out.println("Zyklus: " + String.join(" → ", loop));} else { System.out.println("Reihenfolge: " + String.join(", ", order));}Gebaut: core, db, catalogZyklus: auth → web → cart → authGenauer, für Neugierige
Echte Tools machen dasselbe und nennen den Zyklus. Gradle sagt „Circular dependency between the following tasks“ und listet sie auf. Maven sagt „The projects in the reactor contain a cyclic reference“. MSBuild lehnt ein Projekt ab, das sich über seine Referenzen selbst erreicht. Die Lösung liegt immer im Code, nicht im Tool: Was die beiden Module teilen, wandert in ein drittes, von dem beide abhängen.
findCycle findet einen Zyklus. Die Module außerhalb, die auf ein Modul des Zyklus warten (hier payments), sind nicht das Problem, sondern seine Opfer. Sie werden gebaut, sobald der Zyklus weg ist.
Probier es aus
Finde den Fehler
Ein Kollege hat die Zyklusprüfung geschrieben. Wenn auth auf web wartet, sagt das Programm:
List<String> order = buildOrder(needs);if (order.isEmpty()) { System.out.println("Gebaut: " + String.join(", ", order)); List<String> loop = findCycle(needs, order); System.out.println("Zyklus: " + String.join(" → ", loop));} else { System.out.println("Reihenfolge: " + String.join(", ", order));}Reihenfolge: core, db, catalogDer Build läuft mit 3 Modulen weiter, und 4 fehlen stillschweigend. Welche Zeile hat den Fehler?
Wähle die Zeile mit dem Fehler.
Die Prüfung schaut nur, ob nichts gebaut wurde. Bei einem Zyklus werden meistens trotzdem einige Module gebaut: hier core, db und catalog. Die richtige Prüfung ist order.size() < needs.size(): Mindestens ein Modul bleibt übrig.
Sieh es dir an
Dasselbe mit DFS
Es gibt einen zweiten Weg, mit der Tiefensuche aus der DFS-Lektion. Bevor du ein Modul in die Reihenfolge legst, besuche jede seiner Abhängigkeiten. Das Modul kommt erst in order, nachdem alle seine Abhängigkeiten darin sind. Das ist Post-Order: Du schreibst auf dem Rückweg, nicht auf dem Hinweg.
static List<String> dfsOrder(Map<String, List<String>> needs) { List<String> order = new ArrayList<>(); Set<String> done = new HashSet<>(); Set<String> onPath = new HashSet<>(); for (String m : needs.keySet()) { visit(m, needs, done, onPath, order); } return order;}
static void visit(String m, Map<String, List<String>> needs, Set<String> done, Set<String> onPath, List<String> order) { if (done.contains(m)) return; if (!onPath.add(m)) { throw new IllegalStateException("Zyklus bei " + m); } for (String dep : needs.get(m)) { visit(dep, needs, done, onPath, order); } onPath.remove(m); done.add(m); order.add(m);}Map<String, List<String>> needs = new LinkedHashMap<>();needs.put("core", List.of());needs.put("db", List.of("core"));needs.put("auth", List.of("core", "db"));needs.put("catalog", List.of("db"));needs.put("cart", List.of("catalog", "auth"));needs.put("payments", List.of("auth"));needs.put("web", List.of("cart", "payments"));System.out.println("Reihenfolge: " + String.join(", ", dfsOrder(needs)));Map<String, List<String>> broken = new LinkedHashMap<>();broken.put("core", List.of());broken.put("db", List.of("core"));broken.put("auth", List.of("core", "db", "web"));broken.put("catalog", List.of("db"));broken.put("cart", List.of("catalog", "auth"));broken.put("payments", List.of("auth"));broken.put("web", List.of("cart", "payments"));try { dfsOrder(broken);} catch (IllegalStateException e) { System.out.println(e.getMessage());}Reihenfolge: core, db, auth, catalog, cart, payments, webZyklus bei authDie Reihenfolge ist anders: Hier steht cart vor payments. Beide Reihenfolgen stimmen, weil cart und payments nicht voneinander abhängen. Eine topologische Reihenfolge ist selten die einzige.
Der Zyklus wird hier anders erkannt. onPath enthält die Module, die DFS begonnen, aber nicht beendet hat: Sie liegen gerade auf dem Aufrufstapel. Triffst du eines davon wieder, bist du im Kreis gegangen. In der DFS-Lektion sind das die grauen Knoten.
Genauer, für Neugierige
Was wählen? Beide kosten O(V + E): Jedes Modul wird einmal bearbeitet, jede Abhängigkeit einmal angesehen. Kahn hat keine Rekursion und liefert ganz natürlich „was jetzt starten kann“, deshalb denken Build-Tools, die parallel bauen, so. DFS ist kürzer, wenn der Graph ohnehin schon in die Tiefe durchlaufen wird.
Rekursives DFS in Java wirftStackOverflowError bei einer Kette von einigen Tausend bis einigen Zehntausend Modulen, je nach Stapelgröße. Kahn hat dieses Problem nicht.Die Idee
Wo noch
Überall, wo etwas auf etwas anderes wartet, gibt es eine topologische Reihenfolge:
- Paketabhängigkeiten. npm und pip lösen auf, welche Versionen genutzt werden, und installieren sie dann so, dass jedes Paket nach seinen eigenen kommt. Gradle und MSBuild bauen die Module eines Projekts in topologischer Reihenfolge, die unabhängigen parallel.
- Makefile. Jede Regel sagt, von welchen Dateien sie abhängt.
makebaut den Graphen und führt die Befehle so aus, dass jede Datei fertig ist, bevor eine andere sie braucht. - Die Tabellenkalkulation. Die Zelle
C1 = A1 + B1hängt vonA1undB1ab. Ändert sichA1, berechnet die Tabelle nur die Zellen danach neu, in topologischer Reihenfolge. Eine Formel, die sich selbst erreicht, ist ein Zyklus, und Excel sagt es: „Zirkelbezug“. - Datenbankmigrationen und CI-Jobs: Jeder Schritt wartet auf seine eigenen.
Übung
Wellen in der CI
Löse es in deinem Editor.
Wellen. Der CI-Server führt alle Jobs gleichzeitig aus, die schon starten können. Schreibe waves(needs), das die Jobs in Wellen zurückgibt: Die erste Welle sind die Jobs ohne Abhängigkeiten, und jede nächste sind die, deren letzte Abhängigkeit in der vorigen Welle liegt. Führe es mit den Jobs aus „Sag die Ausgabe voraus“ aus:
Welle 1: lint, compileWelle 2: test, packageWelle 3: deployWelle 4: notifyTipp: Das ist Kahn, aber statt einer Warteschlange behältst du die aktuelle Welle und baust die nächste auf.
Weitere Aufgaben
- Die längste Kette. Wie viele Wellen braucht die CI mindestens, wenn sie unbegrenzt viele Maschinen hat? Das ist der längste Weg im Graphen.
- Eine stabile Reihenfolge. Sorge dafür, dass die Ausgabe unabhängig von der Reihenfolge in
needsgleich ist: Wenn mehrere Module bereit sind, nimm das erste in alphabetischer Reihenfolge. Welche Struktur brauchst du statt der Warteschlange? - Alle Schuldigen. Gib alle Module aus, die wegen des Zyklus nicht gebaut werden, und trenne die, die selbst im Zyklus liegen.
Test
Teste dich
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: Big-O: wie die Arbeit wächst · Graph: Adjazenzliste und Matrix · BFS: die wenigsten Haltestellen · DFS: Inseln und Zyklen
- Geht weiter in: Dijkstra
- Aufgaben zum Thema: Graphen