Bäume und Hierachien

In der Informatik bezeichnet der Begriff „Baum“ eine Datenstruktur und abstrakten Datentypen, mit dem sich hierarchische Strukturen abbilden lassen. Damit stellt der Baum eine spezielle Struktur derjenigen Strukturen dar, die Gegenstand der so genannten Graphentheorie sind. Wie alle Strukturen, die in der Graphentheorie behandelt werden, also beispielsweise den schon behandelten Beispielgraphen aus Kapitel „Allgemeiner Graph als Adjazenzliste“, besteht ein Baum aus Knoten und Kanten.

Eine Hierarchie ist wiederum ein spezieller Baum, der durch Über-/Unterordnungsverhältnisse gekennzeichnet ist. Im Falle einer Mitarbeiterhierarchie, wie wir sie in Unternehmen vorfinden, kann man auch sagen, dass Macht von oben nach unten vererbt wird. Der Chef oder die Chefin vererben einen Teil ihrer Macht an die jeweils untergeben Mitarbeiterinnen und Mitarbeiter. Die geben ihrerseits Teile an die Ihnen Untergebenen weiter. Fällt eine Person in der Hierarchie aus, zerbricht diese nicht. Diejenigen, deren Vorgesetzte(r) ausfällt, unterstehen dann dem/der Vorgesetzten der ausgefallenen Person. Ein einfacher Baum zerbricht dagegen in zwei Bäume, die dann einen sogenannten Wald bilden.

Ein Baum bezeichnet also die Datenstruktur als solches. Eine Hierarchie ist ein Baum, jedoch angereichert um eine gewisse Semantik, durch welche mögliche Operationen eingeschränkt werden. Weil gerade diese Einschränkungen eine Herausforderung darstellen können, wollen wir uns im Folgenden als Beispiel eine Hierarchie anschauen und implementieren.

Beispielhierarchie
Beispielhierarchie

In Klammern sind die Mitarbeiternummern der Personen des Unternehmens notiert. Ihre jeweilige Funktion/Rolle ist klein in eckigen Klammern angegeben.

Ein Baum, also ebenso eine Hierarchie, ist eine rekursive Datenstruktur. Ein Baum ist entweder leer oder besteht aus einem Knoten. Diesem Knoten können kein, ein Element oder gar mehrere Elemente zugeordnet sein, für die selbiges gilt.

Wir können, Sie ahnen es, auch eine solche Struktur problemlos mithilfe einer Adjazenz-liste abbilden. Ich habe hier jedoch absichtlich eine Hierarchie als Beispiel gewählt, um Ihnen zu zeigen, wie Sie mit Beschränkungen umgehen, diese implementieren, die Ihnen die Semantik der Struktur auferlegt.

Schauen wir uns an, welche Regeln wir formulieren können/müssen, um zu gewährleisten, dass die eingegebenen Daten immer eine Hierarchie bilden. Hierzu möchte ich eine Überlegung vorwegschicken. Wir wollen für jeden Mitarbeiter erfassen, wer dessen Vorgesetzter ist. Ein solches Paar bildet eine Kante. Der Chef, der Geschäftsführer, hat keinen Vorgesetzten. Er bildet die Wurzel des Baumes. Die soll dadurch gekennzeichnet sein, als Vorgesetztenangabe den Wert NULL zu haben. Man kann das auch anders modellieren. Diese Entscheidung ist jedoch günstig für alle weiteren Regeln und Entscheidungen, die wir zu treffen haben.

  1. Ein nicht-leerer Baum hat genau eine Wurzel.
  2. Eine Hierarchie zerbricht nicht.
  3. Ein Baum/eine Hierarchie ist zyklenfrei.

Um die Integrität der Daten zu gewährleisten, müssen weitere Sachverhalte beachtet werden:

  1. Es dürfen nur Personen in der Hierarchie aufgenommen werden, ob als vorgesetzte oder untergebene Person, die auch Mitarbeiter sind.
  2. Ein untergeordneter Mitarbeiter darf nur auf eine vorgesetzte Person verweisen, wenn diese in der Hierarchie bereits erfasst ist.
  3. Jeder Mitarbeiter hat genau einen Vorgesetzten. Ausnahme: Der Chef hat keinen Vorgesetzten.
  4. Ein Mitarbeiter ist sich nicht selbst vorgesetzt.

Die obigen Regeln sind nicht frei von Redundanzen, helfen uns aber, wenn wir sie so ausführlich formulieren, die erforderlichen Regeln für unsere Beispielhierarchie zu implementieren.

Implementierung

Strukturelle Implementierung der Beispielhierarchie

Nicht alle Regeln lassen sich in der Datenstruktur selbst implementieren, weshalb wir die Implementierung aufteilen.

In der Hierarchie bilden wir nun, soweit möglich, einige der oben genannten Regeln als Zeilen- oder Tabellenregeln ab.

Was haben wir bereits wie erreicht?

  1. Durch einen Fremdschlüsselverweis stellen wir sicher, nur Mitarbeiter (u_id) in der Hierarchie erfassen zu können, die auch in der Mitarbeitertabelle verzeichnet sind.
  2. Durch einen selbstverweisenden Fremdschlüsselverweis stellen wir sicher, nur auf Vorgesetzte (v_id) zu verweisen, die a) bereits in der Hierarchie als Mitarbeiter erfasst sind, wodurch wir b) ebenso sicherstellen, dass es sich um Personen aus der Mitarbeitertabelle handelt. Andernfalls könnten wir sie nicht als Mitarbeiter (u_id) erfasst haben.
  3. Der erste zu erfassende Datensatz muss den Wurzelknoten repräsentieren. v_id ist optional einzugeben, widerspricht also nicht den definierten referentiellen Integritätsregeln. Die Wurzel ist der einzige Datensatz, der keinen Vorgesetzten benennen muss. Alle weiteren Datensätze müssen einen solchen konkret benennen. Wegen des UNIQUE-Indexes für das virtuelle Feld wurzel kann es nur eine einzige Wurzel geben.
  4. Weil u_id der Primärschlüssel ist, kann jeder Mitarbeiter nur einfach in der Hierarchie als untergebener Mitarbeiter aufgenommen werden. Gleichzeitig kann er auch nur maximal einen Vorgesetzten haben.
  5. Bei der Dateneingabe ist es nicht möglich einen Zyklus zu etablieren, weil der Verweis auf den Vorgesetzten nur auf eine Person erfolgen kann, die bereits erfasst ist. Ein untergebener Mitarbeiter kann also nicht erfasst sein, sodass ein solcher Zyklus ausgeschlossen ist.

  6. Selbstverweise, die einen Mitarbeiter zu seinem eigenen Vorgesetzten machen, sind ausgeschlossen.

Obgleich PostgreSQL kaskadierendes Löschen auch bei sich selbst referenzierenden Tabellen unterstützt, habe ich es hier explizit untersagt, um das versehentliche Löschen ganzer Teilbäume zu unterbinden. In dieser Form können lediglich Mitarbeiter aus der Hierarchie gelöscht werden, die nicht auch Vorgesetzte sind. – Daraus folgt eine interessante Art und Weise des Löschens von Teilbäumen. Dabei müssen wir nämlich die Reihenfolge der zu löschenden Mitarbeiter (Knoten) beachten.

Geben wir unsere Beispieldatensätze ein:

Was haben wir noch nicht erreicht?

  1. Aktuell können wir via UPDATE einen Mitarbeiter zu einem Untergebenen eines untergebenen Mitarbeiters machen. Das etabliert a) einen Zyklus und b) zerbricht die Hierarchie.
    +----+----+----------------+------+
    |u_id|v_id|rolle           |wurzel|
    +----+----+----------------+------+
    |1   |null|Geschäftsführer |-     |
    |2   |1   |Leiterin Abt. A |null  |
    |7   |2   |Sachbearbeiter  |null  |
    |8   |2   |Sachbearbeiterin|null  |
    |4   |3   |Sachbearbeiter  |null  |
    |5   |3   |Sachbearbeiter  |null  | -- Zyklus: 3-5-9-3
    |6   |3   |Sachbearbeiterin|null  | -- Hierarchie dadurch zerbrochen
    |9   |5   |Azubi           |null  |
    |10  |5   |Azubi           |null  |
    |3   |9   |Leiter Abt. B   |null  | 
    +----+----+----------------+------+
    
  2. Wir können nur Mitarbeiter löschen, die nicht als Vorgesetzte registriert sind. Das ist gegebenenfalls mühsam. Auch wenn es sinnvoll ist, das Löschen ganzer Teilbäume durch ein versehentliches DELETE zu unterbinden, sollte dennoch eine Möglichkeit geschaffen werden, diese Aufgabe einfach und effizient zu bewältigen.

Gedanken zur Prozeduralen Implementierung der Beispielhierarchie

Um die genannten Mängel in unserer Hierarchie zu beheben, benötigen wir prozedurale Programmierung. Das mangelhafte Verhalten, bei einem UPDATE die Hierarchie zu zerbrechen und einen Zyklus zu etablieren, können wir mit einem entsprechenden Trigger abfangen. Für das Löschen ganzer Teilbäume schreiben wir uns eine Prozedur.

Bevor wir uns an die Arbeit machen können, müssen wir jedoch noch einige der wichtigsten Abfragen kennenlernen, die auf Bäumen/Hierarchien angewendet werden.

Wichtige Abfragen auf Bäumen/Hierarchien

Finden der Wurzel

Das Finden der Wurzel ist trivial. Sie ist gegenüber den anderen Knoten dadurch ausgezeichnet, keinen Verweis auf einen Vorgesetzten zu haben. Allgemein sprechen wir von Kind- und Elternknoten. Die Wurzel hat als einziger Knoten keinen Elternknoten. Wir haben in unserem Beispiel NULL gewählt. Bei jeder anderen Modellierung, wäre die Wurzel jedoch ebenso leicht zu identifizieren.

Blätter finden

Die Blätter eines Baumes sind dadurch gekennzeichnet, keine Kindknoten (Untergebene) zu besitzen. Anders und in Bezug auf das konkrete Beispiel formuliert, sind die Blätter jene Knoten, deren Mitarbeiter keine Untergebenen haben. Diese Mitarbeiter werden also nicht von anderen Knoten als Vorgesetzte referenziert. Daraus ergibt sich direkt die entsprechende Abfrage, die mit Hilfe einer korrelierten Unterabfrage formuliert ist.

Levels/Ebenen

Die Länge eines Pfades zwischen Knoten eines Subordinationsverhältnisses bezeichnen wir als Pfadlänge. Die Maßeinheit ist die Anzahl der Level (Ebenen), die wir vom Start- zum Endknoten gehen müssen. Der Wurzelknoten hat per Konvention das Level 0.

Wir traversieren den kompletten Baum der Hierarchie, beginnend beim Wurzelknoten. Dazu nutzen wir einen rekursiven CTE, in dessen Ankerabfrage die Wurzel adressieren. Genauer gesagt suchen wir die Kante, die die Wurzel kennzeichnet. Das ist die eine Kante, deren Vorgesetztenverweis NULL ist. Innerhalb der Rekursionsabfrage suchen wir dann jeweils die Kindknoten bzw. die Anschlusskanten. Die sind dadurch gekennzeichnet, als Elternverweis (v_id) auf eine bereits gefundene u_id zu verweisen. – Das Ganze ist vollkommen analog zum Traversieren eines allgemeinen Graphen, mit dem Unterschied, eine einzige Kante als Startkante in der Ankerabfrage zu nutzen.

Für die Fragestellung wären u_id und ebene ausreichend. Ich bevorzuge es jedoch, auch die weiteren Informationen, wie den Vorgesetztenverweis und die Rolle mit auszugeben.

Da wir die Informationen direkt und ohne Tabellenverknüpfung erlangen können, kostet sie uns nichts.

+----+----+----------------+-----+
|u_id|v_id|rolle           |ebene|
+----+----+----------------+-----+
|1   |null|Geschäftsführer |0    |
|2   |1   |Leiterin Abt. A |1    |
|3   |1   |Leiter Abt. B   |1    |
|7   |2   |Sachbearbeiter  |2    |
|8   |2   |Sachbearbeiterin|2    |
|4   |3   |Sachbearbeiter  |2    |
|5   |3   |Sachbearbeiter  |2    |
|6   |3   |Sachbearbeiterin|2    |
|9   |5   |Azubi           |3    |
|10  |5   |Azubi           |3    |
+----+----+----------------+-----+

Die Ausgabe ist wie folgt zu verstehen:

  • Der Knoten 1 hat keinen Vorgesetzten (NULL). Ihm ist die Rolle „Geschäftsführer“ zugewiesen und befindet sich in der Hierarchie auf Ebene 0.
  • Konten 2 hat Knoten 1 als vorgesetzte Person. Die Rolle lautet „Leiterin Abt. A„. Der Knoten befindet sich auf Ebene 1.

Teilbaum ermitteln

Das Ermitteln eines Teilbaums entspricht prinzipiell der Traverse durch den ganzen Baum. Statt jedoch bei der Wurzel zu starten, beginnt die Traverse bei einem anderen Knoten.

Weil Sichten nicht parametrisierbar sind, bietet es sich an, für derartige Zwecke Funktionen zu verwenden.

Finden eine bestimmten Pfades

Wenn wir den Baum aufspannen, so wie wir es bereits getan haben, ist es leicht, die Pfade zwischen den Knoten zu protokollieren. Suchen wir einen bestimmten Pfad, die Verbindung zwischen zwei konkreten Knoten, ist es jedoch ineffizient den gesamten Baum aufspannen zu wollen. Das gilt selbst dann, wenn wir die Rekursion abbrechen, wenn der gesuchte Unterknoten gefunden wurde.

Machen wir uns klar, dass es in einem Baum, so es einen Pfad zwischen zwei Knoten gibt, dieser Pfad eindeutig ist. Dann können wir die Suche von unten starten. Wir beginnen beim untergeordneten Knoten und wandern den Baum aufwärts. Dabei verfolgen wir lediglich einen einzigen Pfad. Der endet spätestens bei der Wurzel.

Wird der untergeordnete Knoten nicht gefunden, erübrigt sich eine weitere Suche bereits. Wird der Unterknoten gefunden, wird nur der Pfad verfolgt, der von diesem Unterknoten in Richtung Wurzel führt. Wird auf dem Weg dorthin der Oberknoten gefunden, gibt es einen Pfad, andernfalls nicht.

Diese Art der Suche ist sehr viel effizienter als das Aufspannen des Baumes. Wir können so suchen, weil die Struktur eines Baumes für jeden Pfad im Baum gewährleistet, einzigartig zu sein. Es gibt keine verschiedenen Verbindungen zwischen zwei Knoten.

Beispiel: Suche einen Pfad von Mitarbeiter Nr. 9 zu Mitarbeiter Nr.3.
+---+---+------+-----+
|uid|vid|laenge|pfad |
+---+---+------+-----+
|9  |3  |2     |3,9,5|
+---+---+------+-----+

Die Ausgabe eines Pfades habe ich hier von oben nach unten gestaltet, dem Subordina-tionsverhältnis entsprechend.

Prozedurale Programmierung der Beispielhierarchie

UPDATE-Trigger

Die oben erörterten Abfragen geben uns das Rüstzeug an die Hand, die Struktur unseres Baumes, der Unternehmenshierarchie, abzusichern bzw. das Löschen von Teilbäumen auch ohne kaskadierendes DELETE komfortabel zu lösen.

Wir hatten uns angesehen, eine UPDATE-Operation durchführen zu können, bei welcher ein vorgesetzter Mitarbeiter zu einem Untergebenen seiner selbst werden konnte. Damit waren zwei Unannehmlichkeiten einhergegangen, die es zu vermeiden gilt. Wir hatten damit einen Zyklus implementiert und die Struktur ist in zwei Teilbäume zerbrochen.

Da wir jetzt aber herausgearbeitet haben, wie wir einen Pfad zwischen zwei Knoten suchen und finden, können wir solch Ungemach leicht unterbinden. Wir haben lediglich zu prüfen, ob eine potentielle UDATE-Operation eine vorgesetzte Person sich selbst zum Untergebenen macht. Hierzu spendieren wir uns eine Funktion vor_update_trigger(), die unsere Funktion suche_pfad() aufruft. Wird ein solcher Pfad gefunden, wird die Operation abgebrochen. Andernfalls ist die UPDATE-Operation erlaubt und wird durchgeführt.

Innerhalb der Trigger-Funktion müssen wir lediglich auf den korrekten Aufruf von suche_pfad() achten.

Ist der angegebene Vorgesetzte (new.v_id) bereits Untergebener des zu modifizierenden Mitarbeiters (old.u_id), würde ein Zyklus etabliert.

Datenmanipulationen auf einem Baum

Dank der getroffenen Vorbereitungen sind die Operationen, die Daten auf einem Baum, der als Adjazenzliste implementiert ist, weitestgehend trivial. Lediglich dem Vertauschen von Knoten innerhalb des Baumes werden wir uns etwas intensiver widmen müssen. Alle anderen Operationen, die wir zuerst behandeln werden, sind einfach zu implementieren. Die umgesetzten Regeln, struktureller und prozeduraler Natur, stellen die Integrität unseres Baumes, der Unternehmenshierarchie, sicher.

Einfügen eines Knotens

Das Einfügen eines Knotens ist trivial. Unter Berücksichtigung der Integritätsbedingungen ist ein Knoten (Mitarbeiter) anzulegen, der dann mit Hilfe eines einfachen INSERT-Statements in die Kantentabelle (Organisationsstruktur) eingefügt werden kann.

Löschen eines Knotens/Teilbaumes

Weil wir ein kaskadierendes Löschen bei der Definition der Hierarchie ausgeschlossen haben, sorgen die Fremdschlüsselbeziehungen dafür, lediglich Blätter im Baum löschen zu können. Wollen wir also einen ganzen Teilbaum löschen, müssen wir zwingend mit den Blättern beginnen und uns ebenenweise nach oben arbeiten. Die Werkzeuge dazu haben wir uns jedoch bereits geschaffen. Die Funktion teilbaum() liefert die benötigten Informationen, um mit dem PostgreSQL-Feature DELETE … USING ein solches Löschen vornehmen zu können.

loeschliste ist eine nach Ebenen absteigend sortierte Liste der Knoten des Teilbaumes, der durch teilbaum() aufgespannt wird. Die USING-Klausel veranlasst DELETE dazu, sich beim Löschen an die Reihenfolge der angegebenen Knoten zu halten. Auf diese Weise wird der Baum von unten nach oben, so wie es gestattet ist, gelöscht. Wird lediglich ein Blattknoten angegeben, leistet die Funktion freilich ebenso ihren Dienst.

Verschieben eines Knotens/Teilbaums

Das Verschieben entspricht einer UPDATE-Operation. Wir haben dafür einen Trigger implementiert.

Vertauschen zweier Knoten

Wie in der Programmierung üblich benötigen wir zum Vertauschen zweier Werte einen Zwischenspeicher, einen Dummy. Den erzeugen wir uns als Zufallswert. Das gewährleistet auch im Mehrbenutzerbetrieb unterschiedliche Werte für den Dummy und vermeidet Kollisionen.