@rkenmi - 4 Line Depth-first Search

# 4 Line Depth-first Search

Simple recursive algorithm for depth-first search

### Context

Depth-first search (DFS) is a common searching algorithm for graphs, alongside Breadth-first search (BFS).

DFS is useful for finding ways to solve mazes. It is also a vital ingredient for topological sorting.

The key characteristic of DFS is that it is recursive in nature, thus a stack becomes very useful when defining the algorithm.

### The algorithm

def dfs(parent, vertex):
for neighbor in vertex:
if neighbor not in parent:
parent[neighbor] = vertex
dfs(parent, vertex)


The pseudocode above represents the core algorithm of depth-first search.

Inputs:

• parent: A hash-table (or dictionary) that stores a vertices as keys, and their respective parents as values.
• vertex: The vertex that we are currently evaluating.

Strategy:

The idea of this 4-liner algorithm is that we check the adjacencies (neighbors) of a vertex.

If we find new neighbors that we haven't visited before, then we want to record this neighbor in our parent dictionary. We don't particularly care for old neighbors, i.e. neighbors that are already recorded in our parent dictionary, because it will cost us a good chunk of performance for nothing.

We record the neighbor as a key in the dictionary and set its value to the neighbor's parent, which would be vertex. Because these neighbors are adjacent nodes to vertex, it makes sense that the vertex is the parent, and the neighbors are its children.

The last thing to do after recording the neighbor is to do another function call to itself: dfs(vertex). We are going depth-first, so if you envision a graph as a tree, we will keep traversing down this branch before even considering other neighbors at the current level of the tree.

### The high level view

The latter algorithm is appealing because it is short, concise, and effective. However, it begs the question of where or how do we start?

The best way to do this is to define our variables properly.
If we have a graph, then that means we should have a set of vertices that represent the graph.

i.e. nodes = [A, B, C, D, E]

We should also have our edges defined somewhere. Edges are best defined in an adjacency list, which should be used to represent the neighbors of a vertex.

### Starting Point

Once we have our vertices defined, we can now define an algorithm that accounts for all vertices in the highest-level view so that all nodes and edges in the graph are accounted for.

def dfs-top(nodes):
parent = {}
for node in nodes:
if node not in parent:
# we're starting at the top-level of a graph, so no parent per se
parent[node] = None
dfs(parent, node)


Inputs:

• nodes: An array (or list) of vertices

Strategy:

dfs-top is a function that starts the entire process of DFS in a graph.

We declare the parent dictionary here for the first time, which will be passed into the recursive dfs function calls.

By iterating through each vertex, we can guarantee that every vertex in the graph is given a chance to recursively go through its neighbors (via dfs(parent, node)).

If the current vertex we are evaluating has never been visited before, we mark it in the parent dictionary and set its value to be None. The None may be a bit misleading here, but the reason why it is marked None is because:

• In the highest-level view, we can't distinguish a vertex as a parent of some other vertex.
• We are iterating through all vertices available, so there is no concept of a parent

In contrast to the dfs function, the concept of a parent is clear because we are checking the neighbors of some given vertex.

### Topological Sort

Topological sort is a directed acyclic (no cycles) graph ordering that sorts based on the property that u comes before v when there is a directed edge from u that points to v.

Implementing DFS gives us a way to do a topological sort on a graph. Simply put, it is the reverse of the timing that the vertices finish in the recursive DFS stack.

We can modify our DFS algorithm above to account for this.

First we'll define some global stack list. Python Lists can be used very much like stacks with its append/pop that are synonymous to a Stack data structure's push/pop.

stack = []  # a stack defined in the global scope, for demonstration


Then in the recursive dfs function, we'll return the vertex that is finished (where the downward tree traversal ends) and we'll push that to our stack.

def dfs(parent, vertex):
for neighbor in vertex:
if neighbor not in parent:
parent[neighbor] = vertex
stack.append(dfs(parent, vertex))
return vertex


After dfs-top runs, the global stack list will have all the nodes visited, except the starting node. This starting node should be the only node in the visited dictionary that does not have a value.

Once we add that starting node to our global stack, all you have to do is reverse the stack list.

stack.reverse()


This will give you a list of graph nodes that are topologically sorted.

### Conclusion

With V = number of vertices and E = number of edges, the worst case performance of DFS is:
$$O(|V| + |E|)$$ This is linear time because it is linear in the size of the graph. The worst case space complexity is $$O(|V|)$$ because we are keeping track of each vertex entry in our parent dictionary above, and the size of our dictionary at its largest will have all the vertex keys in the graph.

There are numerous ways to implement DFS. For example, you can actually make use of a stack data structure, and use iterative code instead of recursive code. The entirety of DFS could also be implemented in one function, rather than splitting its logic into two like I have done here.

With that said however, there is something sweet and beautiful about having a nice search algorithm like DFS be written in 4 lines of code. I also feel that the distinction between the two functions dfs and dfs-top is disparate enough and it is a nice way of categorizing or mincing the logic of DFS into sub-components.

References