Rekursionen - Carl Hanser Verlag

Werbung
Rekursionen
Das Problem
Ich möchte zu einem bestimmten Beitrag in einem Online-Forum alle Antworten/Beiträge ermitteln. Die
entsprechende Beispieltabelle liegt auf einem PostgreSQL-Server und hat folgenden Aufbau:
1. CREATE TABLE beitrag (
2.
beitrag_id
SERIAL,
3.
account_id
INT NOT NULL,
4.
bezug_beitrag_id INT NOT NULL DEFAULT 1,
5.
zeitstempel
TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
6.
nachricht
TEXT ,
7.
deleted
SMALLINT NOT NULL DEFAULT 0,
8.
PRIMARY KEY(beitrag_id),
9.
FOREIGN KEY(bezug_beitrag_id) REFERENCES beitrag(beitrag_id)
13. );
Sie erkennen sicherlich, dass die Spalte bezug_beitrag_id ein Fremdschlüssel auf die eigene Tabelle ist
und markiert, auf welchen Beitrag sich die Zeile bezieht. Die Spalte account_id ist eigentlich auch ein
Fremdschlüssel auf die account-Tabelle, aber ich habe die entsprechende Spezifikation der
Übersichtlichkeit hier weg gelassen.
Der Inhalt der Tabelle ist beispielhaft wie folgt:
1. INSERT INTO
2. beitrag (beitrag_id, account_id, bezug_beitrag_id, zeitstempel, nachricht)
3. VALUES
4.
(1, 1, 1, '')
5. , (2, 2, 1, '2011-05-01 14:13:00', 'Der Lieferservice ist super.')
6. , (3, 3, 2, '2011-05-02 11:45:00', 'Das finde ich auch.')
7. , (4, 5, 2, '2011-05-01 17:01:00', 'Aber ein wenig langsam.')
8. , (5, 2, 4, '2011-05-01 17:15:00', 'Finde ich nicht.')
9. , (6, 5, 1, '2011-06-12 09:07:00', 'Angebot könnte besser sein.');
Der Beitrag mit der id=1 ist mein „Wurzelbeitrag“. So verhindere ich NULL-Werte im Fremdschlüssel und
kann dadurch auch ermitteln, welche Beiträge keine Antwort auf einen andern Beitrag sind. Die Beiträge 3,
4 und 5 sind direkte oder indirekt Antworten auf Beitrag 2 und Beitrag 6 hat keine Antworten.
Wir wollen nun alle Antworten auf Beitrag 2. Ein erster naiver Ansatz ist die Verwendung eines einfachen
INNER JOINs:
1.
2.
3.
4.
oshop=# SELECT a.beitrag_id, a.bezug_beitrag_id, a.nachricht
oshop-# FROM beitrag a INNER JOIN beitrag b ON a.bezug_beitrag_id=b.beitrag_id
oshop-# WHERE b.beitrag_id=2
oshop-# ;
5. beitrag_id | bezug_beitrag_id |
nachricht
6. ------------+------------------+------------------------7.
3 |
2 | Das finde ich auch.
8.
4 |
2 | Aber ein wenig langsam.
Das Ergebnis ist unbefriedigend. Zunächst fällt auf, dass der Beitrag 2 selbst nicht aufgezeigt wird und dann,
dass die indirekten Antworten fehlen. Das erste Problem ließe sich noch durch ein UNION lösen, aber für
das zweite Problem ist eine Rekursion nötig.
Exkurs: Was ist eine Rekursion?
Formal betrachtet ist der Rekursionsbegriff in der Informatik, besonders in der theoretischen Informatik
sehr stark ausformuliert worden (z.B. primitive Rekursion, μ-Rekursion). In der Programmierung ist damit
aber eine Funktion gemeint, die sich selbst aufruft.
Das klassische Beispiel einer Rekursion ist die Berechnung der Fakultät der Zahl n (mathematisch n!). Die
Fakultät einer Zahl ist definiert als das Produkt aller natürlichen Zahlen ohne 0 kleiner oder gleich der Zahl,
also n! = 1*2*3*...*n; für n=4: 4! = 1 * 2 * 3 * 4 = 24.
Rekursiv lässt sich das – hier als Python-Funktion – wie folgt formulieren:
1.
2.
3.
4.
5.
def fakultaet(n):
if n == 1:
return 1
else:
return n * fakultaet(n-1)
Anhand dieses Beispiels können Sie gut die Elemente einer Rekursion erkennen:
1. Rekursionsfuß: Der Rekursionsfuß ist der Teil der Rekursion, welcher die Rekursion beendet oder
begründet. Im Beispiel ist dies der grün hinterlegte Teil. Meist kann man diesen Teil daran erkennen,
dass der Übergabeparameter überprüft wird und im if- oder else-Teil einer Berechnung ohne
Selbstaufruf vorkommt und im anderen Teil eine Berechnung mit dem Selbstaufruf.
2. Berechnungsvorschrift: Die eigentliche Verarbeitung der Daten. Im Beispiel der gelb hinterlegte Teil.
3. Selbstaufruf: Die eigene Funktion wird mit verändertem Funktionsparameter aufgerufen. Im
Beispiel der rot hinterlegte Teil.
Eine wichtige Frage ist, ob die Funktion irgendwann aufhört, sich selbst aufzurufen. Tut die rekursive
Funktion dies nicht, läuft sie potentiell unendlich lange bzw. wird in der Realität irgendwann einen
Stackoverflow erzeugen. In unserem Fall ist die Frage leicht zu beantworten: Da sich der
Übergabeparameter jeweils um 1 verringert, kommt er irgendwann bei 1 an. Dann wird aber der
Rekursionsfuß ausgeführt, der keinen Selbstaufruf enthält und die Rekursion endet.
Hinweis: Die Frage, ob eine Rekursion endet, ist auch schon bei einfachen Aufgabenstellungen nicht mehr
trivial. Einige Beispiele sind bis heute sogar ungelöst. Ich verweise auf das Collatz-Problem (unbedingt lesen:
https://de.wikipedia.org/wiki/Collatz-Problem).
Zurück zum Problem
Wie können Sie aber nun die Beiträge mit Hilfe der Rekursion ermitteln? Hier ein Ansatz in Pseudo Code,
damit ich mich und Sie nicht mit Sprachdetails herumplagen müssen:
1.
2.
3.
4.
5.
6.
7.
8.
alle_beiträge(such_beitrag_id)
begin
print beitrag mit beitrag_id=such_beitrag_id;
für alle beiträge x mit der bezug_beitrag_id=such_beitrag_id
begin
alle_beiträge(x.beitrag_id);
end;
end;
Die Rekursion endet, wenn kein Beitrag mehr gefunden wird, der als bezug_beitrag_id den
such_beitrag_id enthält. Dazu muss sichergestellt sein, dass die Daten keine Zyklen aufweisen. Würde
beispielsweise der Beitrag 2 auf Beitrag 5 zeigen, terminiert die Rekursion nicht.
Wie können Sie das jetzt aber in SQL umsetzen? Werden wir eine Prozedur oder sowas ähnliches schreiben?
Nun eine Prozedur, die das Ergebnis in eine temporäre Tabelle wegschreibt, ist nicht die schlechteste
Lösung. Aber in einigen DBMSen (MySQL/MariaDB: nein, PostgreSQL: ja) gibt es die Möglichkeit, einen
SELECT rekursiv aufzurufen.
Dazu werden common table expressions verwendet. Die funktionieren ungefähr so wie temporären
Tabellen, die aus einem SELECT abgeleitet werden. Zunächst ein Beispiel mit temporären Tabellen.
1.
2.
3.
4.
5.
6.
7.
8.
9.
oshop=# CREATE TEMPORARY TABLE t_anz AS
oshop-# SELECT kunde_id, COUNT(bestellung_id) AS anzahl
oshop-#
FROM
oshop-#
bestellung RIGHT JOIN kunde USING(kunde_id)
oshop-#
GROUP BY kunde_id
oshop-#
HAVING COUNT(bestellung_id) > 0;
oshop=#
oshop=# SELECT kunde_id, iban
oshop-# FROM bankverbindung RIGHT JOIN t_anz USING(kunde_id);
10. kunde_id |
iban
11. ----------+-----------------------------------12.
1 | 100100101111111111
13.
2 |
Die temporäre Tabelle t_anz enthält alle Kunden (kunde_id, anzahl), welche überhaupt Bestellungen
aufgegeben haben. Im zweiten SELECT wird diese temporäre Tabelle für einen OUTER JOIN mit der Tabelle
bankverbindung genutzt und könnte auch noch für weitere Auswertungen in der selben Session genutzt
werden.
So sähe das Beispiel unter Verwendung einer common table expression (CTE) aus:
1.
2.
3.
4.
5.
6.
7.
8.
9.
0.
oshop=#
oshop-#
oshop(#
oshop(#
oshop(#
oshop(#
oshop(#
oshop(#
oshop-#
oshop-#
WITH cte_anz AS
(
SELECT kunde_id, COUNT(bestellung_id) AS anzahl
FROM
bestellung RIGHT JOIN kunde USING(kunde_id)
GROUP BY kunde_id
HAVING COUNT(bestellung_id) > 0
)
SELECT kunde_id, iban
FROM bankverbindung RIGHT JOIN cte_anz USING(kunde_id);
11. kunde_id |
iban
12. ----------+-----------------------------------13.
1 | 100100101111111111
14.
2 |
In der Zeile 1 wird die Definition der CTE eingeleitet. Hinter dem Namen cte_anz könnte man in Klammern
den zwei Spalten dieser Tabelle noch neue Namen vergeben. Erfolgt dies nicht, haben die Spalten die
Namen, die sich aus dem nachfolgenden SELECT ergeben (hier: kunde_id und anzahl). In den runden
Klammern wird nun der SELECT angegeben, der die Tabellen definiert. Bitte beachten Sie, dass hinter der
schließenden runden Klammer in Zeile 8 kein Semikolon steht, da die Anweisung noch nicht abgeschlossen
ist. Die Definition der CTE ist Teil der Anweisung und nicht die Anweisung selbst.
Nach der schließenden runden Klammer erfolgt nun die eigentliche Anweisung in Zeile 9, hier ein SELECT. In
dieser kann die CTE wie eine vorhandene Tabelle verwendet werden. Anders als bei temporären Tabellen
steht die CTE aber nur dieser Anweisung zur Verfügung und nicht noch weiteren nachfolgenden.
Bauen wir den obigen naiven Ansatz auf CTE um, ergibt sich folgendes:
1.
2.
3.
4.
5.
6.
oshop=#
oshop-#
oshop(#
oshop(#
oshop-#
oshop-#
WITH ant_auf AS
(
SELECT * FROM beitrag WHERE bezug_beitrag_id = 2
)
SELECT beitrag_id, bezug_beitrag_id, nachricht
FROM ant_auf;
7.
beitrag_id | bezug_beitrag_id |
nachricht
8. ------------+------------------+------------------------9.
3 |
2 | Das finde ich auch.
10.
4 |
2 | Aber ein wenig langsam.
Das ist alles nicht sehr überraschend, aber jetzt kommt der Clou: Innerhalb einer CTE kann der SELECT die
CTE wieder aufrufen; es erfolgt also ein rekursiver Aufruf! Und genau dieses Feature können Sie anwenden,
endlich :-)
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
oshop=#
oshop-#
oshop(#
oshop(#
oshop(#
oshop(#
oshop(#
oshop(#
oshop(#
oshop-#
WITH RECURSIVE ant_auf AS
(
SELECT * FROM beitrag WHERE beitrag_id = 2
UNION ALL
SELECT beitrag.*
FROM
beitrag INNER JOIN ant_auf orig
ON beitrag.bezug_beitrag_id = orig.beitrag_id
)
SELECT beitrag_id, bezug_beitrag_id, nachricht FROM ant_auf;
12. beitrag_id | bezug_beitrag_id |
nachricht
13. ------------+------------------+-----------------------------14.
2 |
1 | Der Lieferservice ist super.
15.
3 |
2 | Das finde ich auch.
16.
4 |
2 | Aber ein wenig langsam.
17.
5 |
4 | Finde ich nicht.
Der Rekursionsfuß besteht aus zwei Teilen. Einmal der SELECT auf Beitrag 2 und zum zweiten die Bedingung
des INNER JOINs. Falls diese nämlich keine passenden Primär-/Fremdschlüsselpaare mehr findet, werden
keine neun Zeilen in der CTE mehr generiert.
Doch der Reihe nach: In Zeile 1 steht das Schlüsselwort RECURSIVE. Dieses kündigt dem SQL-Interpreter
einen rekursiven Aufruf an; fehlt es, gibt's eine hässliche Fehlermeldung, da dann die Tabelle ant_auf
innerhalb der CTE unbekannt ist. In Zeile 3 wird die erste Zeile der CTE-Tabelle ant_auf erzeugt. Es ist der
Beitrag 2 aus der Tabelle beitrag. In Zeile 5 steht nun ein weiterer SELECT und der UNION in Zeile 4 sorgt
dafür, dass die Ergebnisse beider SELECTs als eine Ergebnismenge betrachtet werden. Der SELECT in Zeile 5
beinhaltet in Zeile 7 einen JOIN auf die CTE selbst, sie ruft sich somit wieder auf. Nun enthält die CTE beim
ersten Durchgang ja nur die eine Zeile vom Beitrag 2. Durch den INNER JOIN in Zeile 5 werden nun alle
Beiträge verknüpft, die im Fremdschlüssel bezug_beitrag_id eine 2 stehen haben. Dadurch enthält die
CTE jetzt 3 Beiträge, nämlich 2, 3, und 4. Nun ruft der INNER JOIN wieder ant_auf auf, so dass er die CTE
mit allen Beiträgen in der Tabelle beitrag verknüpft, die im Fremdschlüssel den Wert 2, 3 oder 4 haben; es
kommt der Beitrag 5 hinzu. Jetzt findet der INNER JOIN keine weiteren neuen Beiträge, die die Werte 2, 3, 4
oder 5 als Fremdschlüsselwert enthalten, und der rekursive Aufruf stoppt.
Zusammenfassung
In bestimmten Situationen sind Daten nicht nur einfach über eine Primär-/Fremdschlüsselpaarung
miteinander verknüpft, sondern auch indirekt. Beispiele sind Forenbeiträge, Familienbeziehungen,
Mitarbeiterorganisation, Nachbarschaftsverhältnisse etc. In solchen Fällen ist liefert ein einfacher JOIN
immer nur die direkten Antworten auf einen Forenbeitrag, die direkten Nachfahren einer Person, die
unmittelbaren Mitarbeiter eines Vorgesetzten, die direkte Nachbarschaft etc. Durch eine Rekursion können
diese Verhältnisse vollständig ermittelt werden. Die Rekursion kann durch entsprechende Prozeduren,
client-seitige Programmierung oder eben einer common table expression gelöst werden.
Dabei ist darauf zu achten, dass die Daten zyklenfrei sind oder eine Tiefenzähler verwendet wird, der die
Rekursion ab einer bestimmten Tiefe abbricht.
Quellen
[1] Adams, Ralf; SQL: Der Grundkurs für Ausbildung und Praxis. Mit Beispielen in MySQL/MariaDB;
September 2016; Hanser Verlag; ISBN 978-3-4464-5074-5
[2] https://msdn.microsoft.com/de-de/library/ms175972.aspx
[3] https://en.wikipedia.org/wiki/Hierarchical_and_recursive_queries_in_SQL
[4] http://www.essentialsql.com/introduction-common-table-expressions-ctes/
Herunterladen