Tiefensuche
# Tiefensuche

Tiefensuche (englisch depth-first search, DFS) ist in der Informatik ein Verfahren zum Suchen von Knoten in einem Graphen. Sie zählt zu den uninformierten Suchalgorithmen. Im Gegensatz zur Breitensuche wird bei der Tiefensuche zunächst ein Pfad vollständig in die Tiefe beschritten, bevor abzweigende Pfade beschritten werden. Dabei sollen alle erreichbaren Knoten des Graphen besucht werden. Für Graphen mit potenziell wenigen, langen Pfaden bietet sich die beschränkte Tiefensuche an, bei der jeder Pfad nur bis zu einer bestimmten Tiefe beschritten wird.
Eine Verbesserung der Tiefensuche ist die iterative Tiefensuche.

## Arbeitsweise

Die Tiefensuche ist ein uninformierter Suchalgorithmus, welche durch Expansion des jeweils ersten auftretenden Nachfolgeknotens im Graphen nach und nach vom Startknoten aus weiter in die Tiefe sucht. In welcher Reihenfolge die Nachfolger eines Knotens dabei bestimmt werden, hängt von der Repräsentation der Nachfolger ab. Bei der Repräsentation über eine Adjazenzliste mittels einer verketteten Liste werden beispielsweise die Knoten in der Reihenfolge ihres Eintrags in dieser Liste durchlaufen. Im oben angegebenen Bild wird implizit davon ausgegangen, dass die Nachfolger von links nach rechts ausgewählt werden.
Für ungerichtete Graphen sieht das Verfahren wie folgt aus: Zuerst wird ein Startknoten $\left.u\right.$ ausgewählt. Von diesem Knoten aus wird nun die erste Kante $\left(u,v\right)$ betrachtet und getestet, ob der gegenüberliegende Knoten $\left.v\right.$ schon entdeckt wurde bzw. das gesuchte Element ist. Ist dies noch nicht der Fall, so wird rekursiv für diesen Knoten die Tiefensuche aufgerufen, wodurch wieder der erste Nachfolger dieses Knotens expandiert wird. Diese Art der Suche wird solange fortgesetzt, bis das gesuchte Element entweder gefunden wurde oder die Suche bei einer Senke im Graphen angekommen ist und somit keine weiteren Nachfolgeknoten mehr untersuchen kann. An dieser Stelle kehrt der Algorithmus nun zum zuletzt expandierten Knoten $\left.u\right.$ zurück und untersucht den nächsten Nachfolger des Knotens. Sollte es hier keine weiteren Nachfolger mehr geben, geht der Algorithmus wieder Schritt für Schritt zum jeweiligen Vorgänger zurück und versucht es dort erneut. Ein Beispiel für die Anwendung der Tiefensuche auf einen Baum zeigt die Animation.
Die Kanten des Graphen, die vom Algorithmus zum Durchlaufen des Graphen benutzt werden, werden als Baumkanten bezeichnet. Diejenigen Kanten, die nicht benutzt werden und von einem Knoten zu einem anderen Knoten im selben Teilbaum führen, der bei der Tiefensuche später besucht wird, heißen Vorwärtskanten. Diejenigen Kanten, die nicht benutzt werden und von einem Knoten zu einem anderen Knoten im selben Teilbaum führen, der bei der Tiefensuche bereits vorher besucht wurde, heißen Rückwärtskanten. Diejenigen Kanten, die „quer“ von einem Teilbaum zu einem anderen Teilbaum verlaufen, heißen Querkanten. Innerhalb des Tiefensuchbaums würde der Pfad zwischen zwei über eine Querkante verbundenen Knoten zunächst ein Auf- und dann ein Absteigen im Baum erfordern. Vorwärtskanten, Rückwärtskanten, Querkanten und Baumkanten ergeben insgesamt die Kantenmenge des Graphen.
| Eine graphentheoretische Landkarte von Deutschland, Österreich und der Schweiz mit den Verbindungen zwischen einigen Städten. Dieses Beispiel ist ein ungerichteter Graph mit 10 Knoten. | Der Baum, welcher entsteht, wenn man Tiefensuche auf die Landkarte anwendet und in Hannover startet. Dieser Baum hat die Höhe 6. Daher hat die Tiefensuche in diesem Fall die maximale Rekursionstiefe 6. |
Der Index der Rekursionstiefe der rekursiven Methode oder Prozedur für die Tiefensuche entspricht dem Knotenabstand des aktuell durchlaufenen Knotens vom Startknoten im in der rechten Abbildung gezeigten Baum. Dieser Index müsste, um zum Beispiel auf der Konsole ausgegeben zu werden, in einer Variablen gespeichert werden. Diese rekursive Methode oder Prozedur wird so oft aufgerufen, wie die Anzahl der Knoten des Graphen ist. Die Rekursion bricht ab, wenn der aktuell durchlaufene Knoten nur Nachbarknoten hat, die schon vorher durchlaufen wurden.
Im Beispiel mit den Städten (siehe oben) gibt es folgende rekursive Aufrufe:
* Hannover (Startknoten, Rekursionstiefe 0)
* Frankfurt (Nachbarknoten von Hannover, Rekursionstiefe 1)
* Zürich (Nachbarknoten von Frankfurt, Rekursionstiefe 2)
* München (Nachbarknoten von Zürich, Rekursionstiefe 3)
* Stuttgart (Nachbarknoten von München, Rekursionstiefe 4)
* Hamburg (Nachbarknoten von Stuttgart, Rekursionstiefe 5)
* Mannheim (Nachbarknoten von Hamburg, Rekursionstiefe 6)
* Dresden (Nachbarknoten von Stuttgart, Rekursionstiefe 5)
* Wien (Nachbarknoten von München, Rekursionstiefe 4)
* Berlin (Nachbarknoten von Wien, Rekursionstiefe 5)
Der Knotenabstand bezieht sich immer auf den Startknoten. Im links dargestellten ungerichteten Graphen ist es Hannover. Bei gerichteten Graphen ist der Knotenabstand zwischen zwei verschiedenen Knoten nicht unbedingt symmetrisch.
Der Baum, den die Tiefensuche durchläuft, ist ein Spannbaum des Graphen und hängt vom Startknoten ab. Es ist außerdem wichtig, ob es sich um einen gerichteten Graphen oder ungerichteten Graphen handelt.
Der Knotenabstand und die Rekursionstiefe ist nur für zusammenhängende Graphen definiert und hängt vom Startknoten ab. Für nicht zusammenhängende Graphen ist der Knotenabstand und die Rekursionstiefe nur innerhalb jeder Zusammenhangskomponente definiert.
Hinweis: Der in der Abbildung links oben mit den Städten gezeigte Graph ist ein ungerichteter Graph. Die Reihenfolge der durchlaufenen Knoten kann sich ändern, wenn stattdessen ein anderer Startknoten oder ein gerichteter Graph genommen wird, der nicht symmetrisch ist.

## Algorithmen

* Bestimme den Knoten, an dem die Suche beginnen soll
* Expandiere den Knoten und speichere der Reihenfolge nach den kleinsten/größten (optional) noch nicht erschlossenen Nachfolger in einem Stack
* Rufe rekursiv für jeden der Knoten in dem Stack DFS auf
** Falls das gesuchte Element gefunden wurde, brich die Suche ab und liefere ein Ergebnis
** Falls es keine nicht erschlossenen Nachfolger mehr gibt, lösche den obersten Knoten aus dem Stack und rufe für den jetzt oberen Knoten im Stack DFS auf

### Erzeugen des Tiefensuchwaldes

Der folgende rekursive Algorithmus in Pseudocode erzeugt den Tiefensuchwald eines Graphen G mittels Setzen von Discovery- und Finishing-Times und Färben der Knoten. In Anlehnung an Cormen et al. werden zunächst alle Knoten weiß gefärbt. Anschließend startet die Tiefensuche per Definition beim alphabetisch kleinsten Knoten und färbt diesen grau. Danach wird, wie oben beschrieben rekursiv dessen weißer Nachbar betrachtet und grau gefärbt. Existiert kein weißer Nachbar mehr, kommt es zum Backtracking, während dessen alle durchwanderten Knoten schwarz gefärbt werden.
Den Zeitstempel (siehe Zeilen 2, 17, 18) kann man weiterhin für eine topologische Sortierung verwenden. Nachdem ein Knoten schwarz gefärbt wurde, wird er einer Liste, absteigend nach den Werten f[u] für die Zeitstempel, hinzugefügt und man erhält eine topologische Reihenfolge. Wird ein Zyklus (siehe Zeile 9) entdeckt, ist dies nicht mehr möglich.

## Programmierung

Das folgende Beispiel in der Programmiersprache C# zeigt die Implementierung der Tiefensuche für einen gerichteten Graphen. Der gerichtete Graph wird als Klasse DirectedGraph deklariert. Die Methode DepthFirstSearch, die die Knoten durchläuft und in einer Liste speichert, verwendet einfache Rekursion. Bei der Ausführung des Programms wird die Methode Main verwendet, die die Liste der markierten Knoten auf der Konsole ausgibt.
Hinweise: Für die Nachbarknoten wurde eine Liste als Datentyp verwendet, damit die Reihenfolge der durchlaufenen Knoten eindeutig ist und die Knoten in allen Ebenen von links nach rechts durchlaufen werden. Für den Datentyp Menge zum Beispiel muss das nicht der der Fall sein. Statt dem HashSet (Menge) visitedNodes kann auch eine Liste oder ein Array vom Typ bool (Boolesche Variable) verwendet werden, wie im Einzelnachweis gezeigt.

## Eigenschaften

Im Folgenden werden Speicherbedarf und Laufzeit des Algorithmus in Landau-Notation angegeben. Wir gehen außerdem von einem gerichteten Graphen aus.

### Speicherplatz

Der Speicherbedarf des Algorithmus wird ohne den Speicherplatz für den Graphen, wie er übergeben wird, angegeben, denn dieser kann in verschiedenen Formen mit unterschiedlichem Speicherbedarf vorliegen, z. B. als verkettete Liste, als Adjazenzmatrix oder als Inzidenzmatrix.
Für die oben beschriebene Prozedur DFS(G) werden zunächst alle Knoten weiß gefärbt und die Referenzen für deren Vorgänger entfernt. Diese Informationen benötigt also für jeden Knoten konstanten Speicherplatz, also ${\mathcal {O}}(1)$. Insgesamt ergibt sich ein linearer Speicherbedarf von ${\mathcal {O}}(\vert V\vert )$, abhängig von der Anzahl der Knoten $\vert V\vert$ (englisch vertices). Der Speicherbedarf der Variable time ist mit konstantem ${\mathcal {O}}(1)$ gegenüber ${\mathcal {O}}(\vert V\vert )$ vernachlässigbar. Anschließend wird für jeden Knoten u die Prozedur DFS-visit(u) aufgerufen. Da es sich hierbei nur um Kontrollstrukturen handelt, tragen sie nicht zum Speicherbedarf bei.
Die Prozedur DFS-visit(u) arbeitet auf der bereits aufgebauten Speicherstruktur in der alle Knoten abgelegt sind und erweitert diese pro Knoten noch um die Entdeckzeit und die Finishing-Time, jeweils konstant ${\mathcal {O}}(1)$. Das ändert nichts am bisherigen linearen Speicherbedarf ${\mathcal {O}}(\vert V\vert )$. Da sonst in DFS-visit(u) keine weiteren Variablen mehr eingeführt werden, hat die Tiefensuche einen Speicherbedarf von ${\mathcal {O}}(\vert V\vert )$.

### Laufzeit

Falls der Graph als Adjazenzliste gespeichert wurde, müssen im Worst Case alle möglichen Pfade zu allen möglichen Knoten betrachtet werden. Damit beträgt die Laufzeit von Tiefensuche ${\mathcal {O}}(\vert V\vert +\vert E\vert )$, wobei $\vert V\vert$ für die Anzahl der Knoten und $\vert E\vert$ für die Anzahl der Kanten im Graph stehen.

### Vollständigkeit

Falls ein Graph unendlich groß ist oder kein Test auf Zyklen durchgeführt wird, so ist die Tiefensuche nicht vollständig. Es kann also sein, dass ein Ergebnis – obwohl es existiert – nicht gefunden wird.

### Optimalität

Tiefensuche ist insbesondere bei monoton steigenden Pfadkosten nicht optimal, da eventuell ein Ergebnis gefunden wird, zu welchem ein sehr viel längerer Pfad führt als zu einem alternativen Ergebnis. Dafür wird ein solches Ergebnis im Allgemeinen deutlich schneller gefunden als bei der in diesem Fall optimalen, aber sehr viel speicheraufwendigeren Breitensuche. Als Kombination von Tiefen- und Breitensuche gibt es die iterative Tiefensuche.

## Anwendungen

Die Tiefensuche ist indirekt an vielen komplexeren Algorithmen für Graphen beteiligt. Beispiele:
* Das Auffinden aller starken Zusammenhangskomponenten eines Graphen.
* Das Ermitteln von 2-zusammenhängenden Komponenten.
* Das Ermitteln von 3-zusammenhängenden Komponenten.
* Das Ermitteln der Brücken eines Graphen.
* Einen Graphen testen, ob er ein planarer Graph ist.
* Bei einem Baum, der Abhängigkeiten darstellt, ergeben die sortierten finish-Zeiten (siehe Pseudocode oben) eine invers-topologische Sortierung.
* Mit der Tiefensuche kann man Graphen in Laufzeit ${\mathcal {O}}(\vert V\vert +\vert E\vert )$ auf Kreise testen und im Falle von Kreisen die dazugehörige Kantenfolge ausgeben.
* Ein kreisfreier Graph kann mit der Tiefensuche in Laufzeit ${\mathcal {O}}(\vert V\vert +\vert E\vert )$ topologisch sortiert werden.
* Das Lösen von Rätseln mit nur einer Lösung, z. B. Irrgärten. Die Tiefensuche kann angepasst werden, um alle Lösungen für einen Irrgarten zu finden, indem nur Knoten auf dem aktuellen Pfad in die besuchte Menge aufgenommen werden.
* Für das Erzeugen eines Irrgartens kann eine zufällige Tiefensuche verwendet werden.