How to detect cycles in a graph?
In this post, I’ve gathered the most commonly used algorithms to detect cycles.
But first, we have to decide what kind of cycles we are trying to detect.
Definitions.
Cycle: a path in a graph that starts and ends at the same vertex, visiting at least one edge.
Negative-weight cycle: a cycle in a weighted graph where the sum of the edge weights around the cycle is negative.
The concept of a “negative-weight cycle” is generally not applicable to undirected graphs, as the direction of traversal can be chosen to always yield the lower weight, effectively eliminating the concept of a “negative-weight cycle.”
Well, there are also Hamiltonian and Eulerian cycles (tours), but those are out of the scope of this post. We’ll just focus on the normal nonnegative-weight cycles and the negative-weight cycles.
Nonnegative-weight cycle detection
Depth-First Search
General idea: While exploring, keep track of visited nodes. If during the traversal, we encounter a node that has already been visited and is not the parent of the current node in the DFS tree, then a cycle exists. This method is efficient for detecting cycles in both directed and undirected graphs.
For the implementation, there are two ways—one is using a recursion stack and a visited tag, and the other is incorporating both together into one 3-color tag. The former is easier to understand in my opinion, but the latter is more popular on LeetCode.
Pseudo code (visited tag + recursion stack):
def has_cycle(graph):
visited = set()
rec_stack = set()
def dfs(node):
visited.add(node)
rec_stack.add(node)
for neighbor in graph[node]:
if neighbor not in visited:
if dfs(neighbor):
return True
elif neighbor in rec_stack:
return True
rec_stack.remove(node)
return False
for node in graph:
if node not in visited:
if dfs(node):
return True
return False
Pseudo code (3-color tags):
def has_cycle(graph):
color = {node: 0 for node in graph}
def dfs(node):
color[node] = 1 # Node is being processed
for neighbor in graph[node]:
if color[neighbor] == 0: # Unvisited node
if dfs(neighbor):
return True
elif color[neighbor] == 1: # Cycle detected
return True
color[node] = 2 # Node and all its descendants processed
return False
for node in graph:
if color[node] == 0:
if dfs(node):
return True
return False
Asymptotic analysis: .
Breadth-First Search
General idea: Traverse the graph level by level, keeping track of visited nodes. If during the traversal, we encounter a node that has already been visited and is not the parent of the current node, then a cycle exists. This method is particularly useful for detecting cycles in undirected graphs.
Pseudo code:
def has_cycle(graph):
visited = set()
for start_node in graph:
if start_node not in visited:
queue = [(start_node, None)] # (node, parent)
visited.add(start_node)
while queue:
node, parent = queue.pop(0)
for neighbor in graph[node]:
if neighbor not in visited:
visited.add(neighbor)
queue.append((neighbor, node))
elif neighbor != parent:
return True # Cycle detected
return False # No cycle found
Asymptotic analysis: .
Topological sort (for directed graphs only)
General idea: It works by ordering the vertices of a graph such that for every directed edge , vertex comes before in the ordering. If a valid topological sort exists, then the graph is acyclic. If no valid ordering can be found, it indicates the presence of a cycle.
Pseudo code:
def topological_sort(graph):
visited = set()
stack = []
def dfs(node):
if node in visited:
return False
visited.add(node)
for neighbor in graph[node]:
if not dfs(neighbor):
return False
stack.append(node)
return True
for node in graph:
if node not in visited:
if not dfs(node):
return None # Cycle detected
return stack[::-1] # Reverse the stack to get topological order
def has_cycle(graph):
return topological_sort(graph) is None
Asymptotic analysis: .
Negative-weight cycle detection
There is only one way of finding negative-weight cycles. You may encounter other algorithms (or rather, “other names”) on the internet, but they are actually just reskinned Bellman-Ford.
Bellman-Ford algorithm
General idea: The algorithm works by relaxing all edges times, and then checking for negative cycles by attempting one more relaxation. If any distance can still be reduced after iterations, it indicates the presence of a negative cycle.
Steps:
- Initialize distances from the source to all vertices as infinite and distance to the source itself as 0.
- Relax all edges times. For each edge with weight , if , then update .
- After step 2, if we can still relax an edge, then there is a negative weight cycle in the graph.
Pseudo code:
def bellman_ford(graph, source):
# Initialize distances
distances = {node: float('inf') for node in graph}
distances[source] = 0
# Relax edges |V| - 1 times
for _ in range(len(graph) - 1):
for node in graph:
for neighbor, weight in graph[node].items():
if distances[node] + weight < distances[neighbor]:
distances[neighbor] = distances[node] + weight
# Check for negative-weight cycles
for node in graph:
for neighbor, weight in graph[node].items():
if distances[node] + weight < distances[neighbor]:
return True # Negative cycle detected
return False # No negative cycle found
Asymptotic analysis: .