40.
Depth-First Search
Written by Vincent Ngo
In the previous chapter, you looked at breadth-first search (BFS), in which you had to explore every neighbor of a vertex before going to the next level. In this chapter, you will look at depth-first search (DFS), another algorithm for traversing or searching a graph.
There are a lot of applications for DFS:
- Topological sorting.
- Detecting a cycle.
- Pathfinding, such as in maze puzzles.
- Finding connected components in a sparse graph.
To perform a DFS, you start with a given source vertex and attempt to explore a branch as far as possible until you reach the end. At this point, you would backtrack (move a step back) and explore the next available branch until you find what you are looking for or until you’ve visited all the vertices.
Example
Let’s go through a DFS example. The example graph below is the same as the previous chapter. This is so you can see the difference between BFS and DFS.
You will use a stack to keep track of the levels you move through. The stack’s last-in-first-out approach helps with backtracking. Every push on the stack means that you move one level deeper. You can pop to return to a previous level if you reach a dead end.
- As in the previous chapter, you choose
Aas a starting vertex and add it to the stack. - As long as the stack is not empty, you visit the top vertex on the stack and push the first neighboring vertex that has yet to be visited. In this case, you visit
Aand pushB.
Recall from the previous chapter that the order in which you add edges influences the result of a search. In this case, the first edge added to
Awas an edge toB, soBis pushed first.
- You visit
Band pushEbecauseAis already visited. - You visit
Eand pushF.
Note that every time you push on the stack, you advance farther down a branch. Instead of visiting every adjacent vertex, you continue down a path until you reach the end and then backtrack.
-
You visit
Fand pushG. -
You visit
Gand pushC.
-
The next vertex to visit is
C. It has neighbors[A, F, G], but all of these have been visited. You have reached a dead end, so it’s time to backtrack by poppingCoff the stack. -
This brings you back to
G. It has neighbors[F, C], but all of these have been visited. Another dead end, popG.
-
Falso has no unvisited neighbors remaining, so popF. -
Now, you’re back at
E. Its neighborHis still unvisited, so you pushHon the stack.
- Visiting
Hresults in another dead end, so popH. -
Ealso doesn’t have any available neighbors, so pop it.
-
The same is true for
B, so popB. -
This brings you all the way back to
A, whose neighborDstill needs to be visited, so you pushDon the stack.
- Visiting
Dresults in another dead end, so popD. - You’re back at
A, but this time, there are no available neighbors to push, so you popA. The stack is now empty and the DFS is complete.
When exploring the vertices, you can construct a tree-like structure, showing the branches you’ve visited. You can see how deep DFS went compared to BFS.
Implementation
Open up the starter playground for this chapter. This playground contains an implementation of a graph, as well as a stack, which you’ll use to implement DFS.
In your main playground file, you will notice a pre-built sample graph. Add the following:
extension Graph where Element: Hashable {
func depthFirstSearch(from source: Vertex<Element>)
-> [Vertex<Element>] {
var stack: Stack<Vertex<Element>> = []
var pushed: Set<Vertex<Element>> = []
var visited: [Vertex<Element>] = []
stack.push(source)
pushed.insert(source)
visited.append(source)
// more to come ...
return visited
}
}
Here, you’ve defined a method depthFirstSearch(from:), which takes in a starting vertex and returns a list of vertices in the order they were visited. It uses three data structures:
-
stackis used to store your path through the graph. -
pushedremembers which vertices have been pushed before so that you don’t visit the same vertex twice. It is aSetto ensure fast O(1) lookup. -
visitedis an array that stores the order in which the vertices were visited.
To start the algorithm, you add the source vertex to all three.
Next, complete the method by replacing the comment with:
outer: while let vertex = stack.peek() { // 1
let neighbors = edges(from: vertex) // 2
guard !neighbors.isEmpty else { // 3
stack.pop()
continue
}
for edge in neighbors { // 4
if !pushed.contains(edge.destination) {
stack.push(edge.destination)
pushed.insert(edge.destination)
visited.append(edge.destination)
continue outer // 5
}
}
stack.pop() // 6
}
Here’s what’s going on:
- You continue to check the top of the stack for a vertex until the stack is empty. You have labeled this loop
outerso that you have a way to continue to the next vertex, even within nested loops. - You find all the neighboring edges for the current vertex.
- If there are no edges, you pop the vertex off the stack and continue to the next one.
- Here, you loop through every edge connected to the current vertex and check if the neighboring vertex has been seen. If not, you push it onto the stack and add it to the
visitedarray. It may seem a bit premature to mark this vertex as visited (you haven’t peeked at it yet) but, since vertices are visited in the order in which they are added to the stack, it results in the correct order. - Now that you’ve found a neighbor to visit, you continue the
outerloop and move to the newly pushed neighbor. - If the current vertex did not have any unvisited neighbors, you know you’ve reached a dead end and can pop it off the stack.
Once the stack is empty, the DFS algorithm is complete! All you have to do is return the visited vertices in the order you visited them.
To try out your code, add the following to the playground:
let vertices = graph.depthFirstSearch(from: a)
vertices.forEach { vertex in
print(vertex)
}
Notice that the order of the visited nodes using a DFS:
0: A
1: B
4: E
5: F
6: G
2: C
7: H
3: D
Performance
DFS will visit every single vertex at least once. This process has a time complexity of O(V).
When traversing a graph in DFS, you have to check all neighboring vertices to find one available to visit. The time complexity of this is O(E) because you have to visit every edge in the graph in the worst case.
Overall, the time complexity for depth-first search is O(V + E).
The space complexity of depth-first search is O(V) since you have to store vertices in three separate data structures: stack, pushed and visited.
Key points
- Depth-first search (DFS) is another algorithm to traverse or search a graph.
- DFS explores a branch as far as possible until it reaches the end.
- Leverage a stack data structure to keep track of how deep you are in the graph. Only pop off the stack when you reach a dead end.