Adjazenzlisten-Modell

In der Graphentheorie sind Adjazenzlisten, auch Nachbarschaftslisten genannt, eine Möglichkeit, Graphen zu repräsentieren. Dabei wird für jeden Knoten eine Liste, die Adjazenzliste, aller seiner Nachbarn (in ungerichteten Graphen) beziehungsweise Nachfolger (in gerichteten Graphen) angegeben. Betrachten wir den folgenden Graphen:

Allgemeiner Beispielgraph
Allgemeiner Beispielgraph

Der Beispielgraph ist gerichtet, wie an den Pfeilen, welche die Kanten zieren, leicht zu erkennen ist. Nicht alle Knoten sind verbunden. An den Kanten sind Gewichte notiert. Grundsätzlich könnten auch mehrere Gewichte mit unterschiedlicher Semantik angegeben sein. Uns reicht hier ein Gewicht. – Wir werden dieses daher auch direkt in der Adjazenzliste notieren und keine diesbezügliche Normalisierung vornehmen.

>In SQL speichern wir die Knoten in einer Tabelle. Hier soll das die Tabelle knoten sein. Die Adjazenzliste, die den Graphen modelliert, nennen wir hier graph. Weil wir nur jeweils ein Gewicht pro Kante betrachten wollen, schreiben wir dieses direkt mit in die Tabelle.

Beachten Sie, dass wir es untersagt haben, Kanten zuzulassen, über die sich ein Knoten selbst referenziert. Grundsätzlich sind solche Kanten zulässig. Wir gehen hier jedoch implizit davon aus, dass jeder Knoten sich selbst erreichen kann. Die dabei entstehenden Kosten sind dann 0. Allerdings interessieren uns diese im Weiteren nicht. Auch in der Praxis macht eine explizite Betrachtung dieses Sachverhalts nur selten Sinn.

Eigenschaften von Graphen

Alle Kanten eines Graphen

Kanten sind die Verbindungen zwischen zwei unmittelbar verbundenen Knoten eines Graphen. Ist E eine Kante, dargestellt durch das Knotenpaar (n0, n1), dann ist n1 von n0 mit einer einfachen Traverse direkt erreichbar. Die Fragestellung, alle Kanten eines Graphen auszugeben, ist trivial. Die Kanten entsprechen den in der Adjazenzliste, der Tabelle graph, abgelegten Datensätzen.

PostgreSQL gestattet es, Sichten zu sortieren. Das ist ein Feature, welches nicht jedes Datenbankmanagementsystem unterstützt. Auf die Verknüpfung mit der Knotentabelle habe ich verzichtet. Die kann, wenn gewünscht, ergänzt werden oder auch im Nachhinein in Verbindung mit der Abfrage der implementierten Sicht erfolgen.

+------+-------+
|von_id|nach_id|
+------+-------+
|1     |2      |
|1     |3      |
|1     |4      |
|1     |5      |
|3     |2      |
|4     |2      |
|4     |3      |
|5     |4      |
+------+-------+

Alle verbundenen Knoten eines Graphen

Verbundene Knoten sind all die Knoten, die über Kanten in Beziehung stehen.

UNION sorgt dafür, Knoten, die sowohl Ausgangs- als auch Eingangsknoten sind, nicht mehrfach auszugeben.

+---------+
|knoten_id|
+---------+
|1        |
|2        |
|3        |
|4        |
|5        |
+---------+

Endpunkte von Pfaden

Pfadendpunkte sind die Start- und Endknoten eines Pfades. Dabei werden unterschiedliche Pfade mit gleichen Start- und Endknoten, wie beispielsweise A-B und A-C-B, zusammenfasst zu (A, B).

Zur Traversierung des Graphen nutzen wir einen rekursiven CTE (Common Table Expression).

In der Ankerabfrage ermitteln wir die Kanten eines Graphen. Die Rekursionsabfrage sucht dann, anhand des originären Graphen, nach Anschlusskanten zu den bereits ermittelten Kanten. Wir merken uns jeweils den alten Start- und neuen Endknoten eines so ermittelten Pfades.

Die Abfrage setzte einen zyklenfreien Graphen voraus! – Andernfalls würde die Abfrage eine endlose Rekursion auslösen. Wir finden die folgenden Kontenpaare, welche die möglichen Pfade beschreiben:

  • (1, 2), 1, 3), (1, 4), 1, 5),
  • (3, 2),
  • (4, 2), (4, 3),
  • (5, 2), (5, 3) und (5, 4).

Hinweis: Die Anzahl der Endpunktpaare ist größer oder gleich der Anzahl der Kanten ei-nes Graphen. Sie ist kleiner oder gleich der Anzahl der Pfade eines Graphen.

Ein- und Ausgangsgrade

Der Eingangsgrad eines Knoten n ist die Anzahl verschiedener Kanten, die in n münden.

Bei der Implementierung werden alle Knoten einbezogen, nicht nur die der Adjazenzliste. Es kann Knoten geben, und die gibt es im Beispielgraphen auch, deren Eingangsgrad 0 ist. Wir verwenden daher einen Outer-Join.

+---------+------------+
|knoten_id|eingangsgrad|
+---------+------------+
|1        |0           |
|2        |3           |
|3        |2           |
|4        |2           |
|5        |1           |
|6        |0           |
|7        |0           |
|8        |0           |
|9        |0           |
+---------+------------+

Analoges gilt für die Ausgangsgrade der Knoten. Der Ausgangsgrad eines Knotens n entspricht der Anzahl distinkter Kanten, die in n ihren Ursprung haben.

+---------+------------+
|knoten_id|ausgangsgrad|
+---------+------------+
|1        |4           |
|2        |0           |
|3        |1           |
|4        |2           |
|5        |1           |
|6        |0           |
|7        |0           |
|8        |0           |
|9        |0           |
+---------+------------+

Quelle, Senken, isolierte und interne Knoten

Ein Quellknoten hat einen positiven Ausgangsgrad und einen Eingangsgrad von 0, besitzt also nur von ihm ausgehende Kanten.

Im Beispielgraphen gibt es lediglich einen Quellknoten, den Knoten mit der ID 1.

Senken sind Knoten, die einen positiven Eingangsgrad und einen Ausgangsgrad von 0 haben, die also lediglich in sie eingehende Kanten aufweisen.

Im Beispielgraphen existiert lediglich eine Senke, der Knoten mit der ID 2.

Interne Knoten sind Knoten, die sowohl einen positiven Eingangsgrad als auch einen positiven Ausgangsgrad besitzen.

Interne Knoten sind:

+---------+
|knoten_id|
+---------+
|3        |
|4        |
|5        |
+---------+

Bei einem isolierten Knoten ist sowohl der Eingangs- als auch der Ausgangsgrad 0. Wir können auch sagen, isolierte Knoten sind nicht in der Menge der verbundenen Knoten enthalten. Ich beziehe mich bei meiner folgenden Abfrage auf die Ein- und Ausgangsgrade.

Isolierte Knoten im Beispielgraphen sind die Knoten mit den IDs 6, 7, 8 und 9.

Grundlegende Operationen auf Graphen

Pfade und Pfadlängen

Das Suchen und Finden von Pfaden innerhalb eines Graphen sind die wichtigsten Operationen in Anwendungen, die auf Graphen basieren. Das sind beispielsweise Anwendungen aus den Bereichen Logistik, Stromversorgung, Workflow und ähnlichem mehr. Jedem bekannt sind sicherlich Navigationssysteme, die Wegstrecken zwischen zwei Orten A und B ermitteln.

Dabei spielt das Enumerieren der Pfade, also das Aufzählen aller in einem Pfad befindlichen Knoten, in der Reihenfolge ihrer Traversierung, eine wichtige Rolle. Wir nennen das Pfadenumeration.

Ein Pfad P der Länge L von Knoten n0 zu Knoten nk ist definiert als die Traverse von (L+1) direkt aufeinander folgender Knoten einer Sequenz von Kanten, wobei der erste Knoten mit 0 und der letzte Knoten mit k nummeriert ist. L entspricht also der Anzahl der Kanten eines Pfades.

Wir nutzen, analog zur Bestimmung der Pfadendpunkte, einen rekursiven CTE. Allerdings ist die Rekursionsabfrage dahingehend zu modifizieren, jetzt mit UNION ALL statt lediglich UNION eingeleitet zu werden. Da sich die Pfade alle voneinander unterscheiden, ist eine Komprimierung von Dubletten, die es nicht gibt, sinnlos und würde lediglich für Ineffizienz sorgen. Ansonsten merken wir uns wiederum den Startknoten eines Pfades und ergänzen den Pfad um den Endknoten einer gefundenen Anschlusskante. Zusätzlich protokollieren wir die Entwicklung eines jeden Pfades und schreiben dessen Länge fort. Mit jeder zusätzlich gefundenen Kante erhöht sich die Länge eines Pfades um 1.

+------+-------+---------+------+
|von_id|nach_id|pfad     |laenge|
+------+-------+---------+------+
|1     |2      |1,2      |1     |
|1     |3      |1,3      |1     |
|1     |4      |1,4      |1     |
|1     |5      |1,5      |1     |
|3     |2      |1,5,4,3,2|4     |
|3     |2      |3,2      |1     |
|3     |2      |4,3,2    |2     |
|3     |2      |1,3,2    |2     |
|3     |2      |5,4,3,2  |3     |
|3     |2      |1,4,3,2  |3     |
|4     |2      |5,4,2    |2     |
|4     |2      |1,4,2    |2     |
|4     |2      |4,2      |1     |
|4     |2      |1,5,4,2  |3     |
|4     |3      |1,5,4,3  |3     |
|4     |3      |4,3      |1     |
|4     |3      |5,4,3    |2     |
|4     |3      |1,4,3    |2     |
|5     |4      |1,5,4    |2     |
|5     |4      |5,4      |1     |
+------+-------+---------+------+

In unserem Beispielgraphen finden wir 20 verschiedene Pfade vor, die wir traversieren können.

Kürzeste Pfade

Der kürzeste Pfad zwischen zwei Knoten ist der Pfad mit der geringsten Länge, also der geringsten Anzahl zu traversierenden Kanten.

Zur Ermittlung der kürzesten Pfade benötigen wir sämtliche Pfade und deren Längen. Für jeden Pfad, gekennzeichnet durch seinen jeweiligen Start- und Endknoten, ist dann der Pfad zu wählen, dessen Länge minimal ist.

Wir nutzen die bereits ermittelten Pfade aus der Sicht enumerierte_pfade und deren Längen in einem neu zu erstellenden CTE. Wir nennen diese Teilabfrage pfade. Für die Ermittlung der kürzesten Verbindungen zwischen zwei Endpunkten von Pfaden, die zwar verschieden sind, jedoch gleiche Start- und Endknoten aufweisen, beziehen wir uns auf die Teilabfrage pfade. Das hat den Vorteil, die Sicht enumerierte_pfade nur einmalig auszuführen. Würden wir uns stattdessen nochmals explizit auf enumerierte_pfade beziehen, würde die Sicht doppelt ausgeführt. – Es ist ein großer Effizienzvorteil von CTEs, eine Abfrage nur einmalig ausführen zu müssen, sich aber dennoch mehrfach auf ein solches Ergebnis beziehen zu können.

Sobald wir sowohl die vollständigen Pfade als auch die kürzesten Verbindungen, gekennzeichnet durch ihre jeweiligen Start- und Endknoten sowie die Länge eines solchen Pfades ermittelt haben, können wir die Teilergebnisse zusammenfassen. Dazu verknüpfen wir sie anhand der Start- und Endknoten sowie der Pfadlänge.

+------+-------+-----+------+
|von_id|nach_id|pfad |laenge|
+------+-------+-----+------+
|1     |2      |1,2  |1     |
|1     |3      |1,3  |1     |
|1     |4      |1,4  |1     |
|1     |5      |1,5  |1     |
|3     |2      |3,2  |1     |
|4     |2      |4,2  |1     |
|4     |3      |4,3  |1     |
|5     |2      |5,4,2|2     |
|5     |3      |5,4,3|2     |
|5     |4      |5,4  |1     |
+------+-------+-----+------+

Kostenminimale Pfade

Die Ermittlung der kostenminimalen Pfade erfolgt analog zur Ermittlung der kürzesten Pfade. Wir verwenden lediglich die den Kanten zugewiesenen Gewichte statt die Pfadlängen zu inkrementieren.

Zuerst erstellen wir uns eine Sicht namens enumerierte_pfade_mit_kosten, die lediglich eine einfache Modifikation im genannten Sinne von enumerierte_pfade ist.

+------+-------+---------+------+
|von_id|nach_id|pfad     |kosten|
+------+-------+---------+------+
|1     |2      |1,2      |50    |
|1     |2      |1,3,2    |35    |
|1     |2      |1,4,2    |120   |
|1     |2      |1,4,3,2  |155   |
|1     |2      |1,5,4,2  |40    |
|1     |2      |1,5,4,3,2|75    |
|1     |3      |1,5,4,3  |70    |
|1     |3      |1,3      |30    |
|1     |3      |1,4,3    |150   |
|1     |4      |1,5,4    |20    |
|1     |4      |1,4      |100   |
|1     |5      |1,5      |10    |
|3     |2      |3,2      |5     |
|4     |2      |4,2      |20    |
|4     |2      |4,3,2    |55    |
|4     |3      |4,3      |50    |
|5     |2      |5,4,2    |30    |
|5     |2      |5,4,3,2  |65    |
|5     |3      |5,4,3    |60    |
|5     |4      |5,4      |10    |
+------+-------+---------+------+

Natürlich stimmt die Anzahl der so ermittelten Pfade mit der Anzahl überein, die wir mit der Sicht enumerierte_pfade ermittelt haben. Es gibt 20 verschiedene Pfade.

Mit der Bestimmung der minimalen Kosten verfahren wir ebenso wie bereits gehabt.

Die Anzahl der kostenminimalen Pfade kann von den kürzesten Pfaden abweichen. In unserem Beispielgraphen ist die Anzahl der Pfade jedoch gleich.

+------+-------+-----+------+
|von_id|nach_id|pfad |kosten|
+------+-------+-----+------+
|1     |2      |1,3,2|35    |
|1     |3      |1,3  |30    |
|1     |4      |1,5,4|20    |
|1     |5      |1,5  |10    |
|3     |2      |3,2  |5     |
|4     |2      |4,2  |20    |
|4     |3      |4,3  |50    |
|5     |2      |5,4,2|30    |
|5     |3      |5,4,3|60    |
|5     |4      |5,4  |10    |
+------+-------+-----+------+

Vergleichen Sie die Pfade, so erkennen Sie, dass die kürzesten Pfade nicht zwingend auch die kostengünstigsten sein müssen und umgekehrt. Von Knoten 1 zu Knoten 2, von A nach B, ist die direkte Verbindung der kürzeste Pfad. Die kostengünstigste Traverse ist jedoch A-C-B.

Zyklenerkennung

Die bisher besprochenen Abfragen, so sie rekursiv formuliert waren, setzen azyklische Graphen voraus. Nun können Graphen aber auch zyklisch sein. Streckennetze, Stadtpläne usw. sind typische Beispiele, in welchen mehr oder minder garantiert Zyklen vorhanden sind. Verwenden wir die bisherigen Rekursionsabfragen, die nicht in der Lage sind, einen Zyklus zu erkennen, terminieren sie nicht. – Am Ende terminieren sie schon, jedoch nur, weil die maximal zulässige Rekursionstiefe überschritten wird und das Datenbankmanagementsystem einen diesbezüglichen Fehler wirft.

Wollen wir in der Lage sein, auch zyklische Graphen zu verarbeiten, müssen wir in der Lage sein, Zyklen zu erkennen und eine Verarbeitung abzubrechen, sobald wir einen Zyklus erkennen.

Die bereits besprochene Pfadenumeration bildet die Basis für die Erkennung von Zyklen. Wird eine bereits im Pfad vorhandene Kante nochmals durchlaufen, haben wir einen Zyklus entdeckt. In diesem Fall ist die Verarbeitung abzubrechen. Um dies zu zeigen, werden wir zwei Dinge unternehmen:

  • Wir fügen eine weitere Kante in unseren Beispielgraphen ein. Diese neue Kante manifestiert mindestens einen Zyklus.
  • Wir werden die Pfadenumeration ergänzen. Bislang haben wir dazu lediglich eine Zeichenkette für die Ausgabe verwendet, um die traversierten Knoten in der korrekten Reihenfolge ausgeben zu können. Wir benötigen jedoch die durchlaufenen Kanten. PostgreSQL unterstützt Arrays. Mit ihrer Hilfe können wir jede durchlaufe-ne Kante in einem Array-Element das Datensatz (ROW) ablegen. So können wir leicht prüfen, ob eine in der Rekursion anzufügende Kante bereits im Pfad vorhanden ist.

Dies ist der Beispielgraph mit der zusätzlich eingefügten Kante:

Allgemeiner Beispielgraph mit Zyklen
Allgemeiner Beispielgraph mit Zyklen

Fügen wir die zusätzlich eingefügte Kante in die Adjazenzliste, Tabelle graph, ein und erzeigen so den oben gezeigten zyklischen Graphen:

Jeder Pfad, der den Knoten B, ID 2, enthält, wird durch diese Maßnahme potenziell zyklisch. Ein Zyklus liegt dann vor, wenn eine bereits traversierte Kante nochmals durchlaufen werden soll. Versuchen wir eine unserer bisherigen rekursiven Abfragen auszuführen, geschieht genau das und die Abfrage schlägt fehl. Wir modifizieren bzw. ergänzen daher die Pfadenumeration, so wie oben angesprochen, und etablieren eine Zykluserkennung.

Die Zeichenkettenverknüpfung für die Ausgabe der Pfade behalten wir bei. Wir spendieren uns aber ein Array namens kanten. Darin protokollieren wir die durchlaufenen Kanten in Form von Datensätzen, bestehend aus dem Start- und Endknoten einer Kante. Ein solches Array von Strukturen kann von Sichten nicht als Ausgabefeld zurückgegeben werden, weshalb wir es nur intern nutzen. In der Ankerabfrage mit der jeweils ersten Kante eines Pfades initialisiert, schreiben wir jedes Kanten-Array in der Rekursionsabfrage fort. Ist eine angefügte Kante bereits im Array enthalten, haben wir einen Zyklus entdeckt.

Bei PostgreSQL müssen wir die Zykluserkennung nicht selbst implementieren. PostgreSQL bietet eine CYCLE-Klausel, die das für uns übernimmt. Laut PostgreSQL-Dokumentation arbeitet die Klausel so, wie ich das im Rahmen der manuellen Zyklenerkennung vorgestellt habe. Bequemer ist es jedoch, die in PostgreSQL vorhandene CYCLE-Klausel zu nutzen.

Hinter der CYCLE-Klausel werden die zu überwachenden Attribute als Liste notiert. SET definiert ein virtuelles Feld für die Überwachung, welches in der Ergebnisabfrage genutzt werden kann. Analoges gilt für pfad_enum, dem hinter USING anzugebenden Bezeichner. pfad_enum ist ein Array von Strukturen. Es entspricht dem Kanten-Array, welches wir vorher selbst konstruiert haben.

Ich möchte nochmals kurz auf den Begriff „Zyklus“ eingehen, um eventuelle Missverständnisse zu vermeiden. Eine Rundreise von Knoten B (2) über E (5), D (4) und wieder zu B (2) stellt noch keinen Zyklus dar. Wir sind zwar umgangssprachlich einmal im Kreis gegangen, haben aber keine Kante mehrfach traversiert. Wenn Sie derartige Rundreisen unterbinden wollen, müssen Sie zusätzlich verlangen, das Start- und Endknoten eines Pfades verschieden sind. Diese Bedingung ist jedoch umgekehrt, zur Vermeidung von Zyklen nicht hinreichend. Der Pfad A (1), B (2), E (5), D (4), B (2) ist nämlich, um nur ein Beispiel herauszugreifen, zyklisch. Start- und Endknoten des Pfades sind aber verschieden.

Egal, ob Sie mit oder ohne CYCLE-Klausel arbeiten wollen, die sich ergebenden Pfade aus unserem zyklischen Beispielgraphen lauten wie folgt:

+------+-------+---------+------+------------+
|von_id|nach_id|pfad     |laenge|ist_zyklisch|
+------+-------+---------+------+------------+
|1     |2      |1,2      |1     |false       |
|1     |5      |1,2,5    |2     |false       |
|1     |4      |1,2,5,4  |3     |false       |
|1     |3      |1,2,5,4,3|4     |false       |
|1     |3      |1,3      |1     |false       |
|1     |2      |1,3,2    |2     |false       |
|1     |5      |1,3,2,5  |3     |false       |
|1     |4      |1,3,2,5,4|4     |false       |
|1     |4      |1,4      |1     |false       |
|1     |2      |1,4,2    |2     |false       |
|1     |5      |1,4,2,5  |3     |false       |
|1     |3      |1,4,3    |2     |false       |
|1     |2      |1,4,3,2  |3     |false       |
|1     |5      |1,4,3,2,5|4     |false       |
|1     |5      |1,5      |1     |false       |
|1     |4      |1,5,4    |2     |false       |
|1     |2      |1,5,4,2  |3     |false       |
|1     |3      |1,5,4,3  |3     |false       |
|1     |2      |1,5,4,3,2|4     |false       |
|2     |5      |2,5      |1     |false       |
|2     |4      |2,5,4    |2     |false       |
|2     |2      |2,5,4,2  |3     |false       |
|2     |3      |2,5,4,3  |3     |false       |
|2     |2      |2,5,4,3,2|4     |false       |
|3     |2      |3,2      |1     |false       |
|3     |5      |3,2,5    |2     |false       |
|3     |4      |3,2,5,4  |3     |false       |
|3     |3      |3,2,5,4,3|4     |false       |
|4     |2      |4,2      |1     |false       |
|4     |5      |4,2,5    |2     |false       |
|4     |4      |4,2,5,4  |3     |false       |
|4     |3      |4,2,5,4,3|4     |false       |
|4     |3      |4,3      |1     |false       |
|4     |2      |4,3,2    |2     |false       |
|4     |5      |4,3,2,5  |3     |false       |
|4     |4      |4,3,2,5,4|4     |false       |
|5     |4      |5,4      |1     |false       |
|5     |2      |5,4,2    |2     |false       |
|5     |5      |5,4,2,5  |3     |false       |
|5     |3      |5,4,3    |2     |false       |
|5     |2      |5,4,3,2  |3     |false       |
|5     |5      |5,4,3,2,5|4     |false       |
+------+-------+---------+------+------------+

Zusätzlich, zu diesen 42 Pfaden, existieren 18 zyklische Pfade.