Detect a Cycle in a Graph: Directed vs Undirected
8 min readBytePatterns
Why one visited set cannot find cycles in a directed graph, how white-grey-black colouring fixes it, and the parent check or union-find for undirected graphs.
"Does this graph contain a cycle?" sounds like a single question, but the answer depends on whether the edges have a direction. The same depth-first search that works perfectly on an undirected graph reports false cycles on a directed one, and the directed version reports a cycle on every edge of an undirected one. Knowing why is most of what an interviewer is checking.
The problem it solves
In a directed graph, a cycle is a path that follows edge directions and returns to its start: build → test → deploy → build. Finding one answers practical questions: can these tasks be scheduled at all, does this import chain loop, is this course plan possible? A directed graph with no cycle can be put in topological order; one with a cycle cannot.
In an undirected graph, a cycle is a closed path that uses each edge at most once. An undirected graph without cycles is a forest, and a connected one is a tree. Detecting a cycle there is the question "is this a tree?", or "would adding this cable create a loop in the network?".
The intuition
Start with a depth-first search and a visited set. In an undirected graph, meeting a visited node again usually means you found a second route to it — a cycle. There is one exception: the node you just came from. Every undirected edge can be walked back the way it came, and that is not a cycle. So you skip the parent and treat any other visited neighbour as proof of a cycle.
In a directed graph, that same test is wrong. Take four nodes with edges 0 → 1, 0 → 2, 1 → 3, 2 → 3. The search goes 0 → 1 → 3, finishes 3, and later reaches 3 again from 2. Node 3 is visited, but there is no cycle: both paths flow downhill. Reaching a visited node proves nothing unless that node is still on the current path.
Three colours capture exactly that:
- White: not visited yet.
- Grey: entered, and still on the current path — its descendants are not finished.
- Black: finished; every path from it has been explored.
An edge into a grey node is a back edge: it points to an ancestor on the current path, so the path loops. An edge into a black node is harmless, because nothing reachable from a black node leads back to the current path — otherwise that node would have found the cycle itself.
Watch it run
The animation runs the colouring on the lesson's build graph. build, test and deploy turn grey one after another as the search goes deeper. Then the edge deploy → build reaches a node that is still grey, and the cycle is found. The last frame points out docs, which nothing depends on: a black node could be met again safely; only grey is fatal.
Cycles in a Directed Graph
Step 1 of 7
Three colours, not one flag. White is untouched, grey is on the path right now, black is finished.
The same interactive animation as the lesson — step through it with the controls.
The code
The three-colour check for directed graphs, next to the tempting one-flag version:
WHITE, GREY, BLACK = 0, 1, 2
def directed_has_cycle(n, edges):
graph = [[] for _ in range(n)]
for u, v in edges:
graph[u].append(v)
colour = [WHITE] * n
def visit(u):
colour[u] = GREY # on the current path
for v in graph[u]:
if colour[v] == GREY: # back edge: the path loops
return True
if colour[v] == WHITE and visit(v):
return True
colour[u] = BLACK # finished, safe to meet again
return False
return any(colour[u] == WHITE and visit(u) for u in range(n))
def directed_visited_only(n, edges): # the tempting version: one flag
graph = [[] for _ in range(n)]
for u, v in edges:
graph[u].append(v)
seen = [False] * n
def visit(u):
seen[u] = True
for v in graph[u]:
if seen[v] or visit(v):
return True
return False
return any(not seen[u] and visit(u) for u in range(n))
diamond = [(0, 1), (0, 2), (1, 3), (2, 3)] # two routes to 3, no loop
loop = [(0, 1), (1, 2), (2, 0)]
print(directed_has_cycle(4, diamond), directed_visited_only(4, diamond)) # False True
print(directed_has_cycle(3, loop), directed_visited_only(3, loop)) # True True
For undirected graphs, two options. A DFS that skips the parent, or union-find, which processes edges one at a time: if both ends are already in the same component, the new edge closes a loop.
def undirected_has_cycle(n, edges):
graph = [[] for _ in range(n)]
for u, v in edges:
graph[u].append(v)
graph[v].append(u)
seen = [False] * n
def visit(u, parent):
seen[u] = True
for v in graph[u]:
if v == parent:
continue # the edge we came in on
if seen[v] or visit(v, u):
return True
return False
return any(not seen[u] and visit(u, -1) for u in range(n))
def union_find_has_cycle(n, edges):
root = list(range(n))
def find(x):
while root[x] != x:
root[x] = root[root[x]] # path halving
x = root[x]
return x
for u, v in edges:
ru, rv = find(u), find(v)
if ru == rv: # already connected: this edge closes a loop
return True
root[ru] = rv
return False
path = [(0, 1), (1, 2), (2, 3)]
print(undirected_has_cycle(4, path), union_find_has_cycle(4, path)) # False False
print(undirected_has_cycle(4, path + [(3, 1)]), union_find_has_cycle(4, path + [(3, 1)])) # True True
Each check is compared with a reference that uses a different idea. For directed graphs: Kahn's topological sort removes nodes with no incoming edges, and a cycle is exactly what stops it from removing all of them. For undirected graphs: a forest with c components has exactly n - c edges, so more edges than that means a cycle. The test uses 3,000 random graphs of each kind, with self-loops allowed in the directed ones:
import random
from collections import deque
def kahn_leftover(n, edges): # directed reference: topological sort
indeg = [0] * n
graph = [[] for _ in range(n)]
for u, v in edges:
graph[u].append(v)
indeg[v] += 1
queue = deque(u for u in range(n) if indeg[u] == 0)
removed = 0
while queue:
u = queue.popleft()
removed += 1
for v in graph[u]:
indeg[v] -= 1
if indeg[v] == 0:
queue.append(v)
return removed < n # something never reached in-degree 0
def more_edges_than_forest(n, edges): # undirected reference: count components
graph = [[] for _ in range(n)]
for u, v in edges:
graph[u].append(v)
graph[v].append(u)
seen, components = [False] * n, 0
for s in range(n):
if not seen[s]:
components += 1
seen[s] = True
stack = [s]
while stack:
for v in graph[stack.pop()]:
if not seen[v]:
seen[v] = True
stack.append(v)
return len(edges) > n - components # a forest has exactly n - components edges
random.seed(9)
ok, one_flag_wrong, cyclic = True, 0, 0
for _ in range(3000):
n = random.randint(1, 8)
pairs = [(u, v) for u in range(n) for v in range(n)]
directed = random.sample(pairs, random.randint(0, min(len(pairs), 10)))
truth = kahn_leftover(n, directed)
ok &= directed_has_cycle(n, directed) == truth
one_flag_wrong += directed_visited_only(n, directed) != truth
simple = [(u, v) for u, v in pairs if u < v]
undirected = random.sample(simple, random.randint(0, len(simple) // 2))
truth = more_edges_than_forest(n, undirected)
cyclic += truth
ok &= undirected_has_cycle(n, undirected) == truth == union_find_has_cycle(n, undirected)
print(ok, one_flag_wrong, cyclic) # True 401 603
All three correct checks agree with their references. The one-flag directed check is wrong on 401 of the 3,000 directed graphs, every time by reporting a cycle that is not there.
The complexity
Both DFS versions visit each node once and look at each edge once (twice for undirected edges, once from each end): O(V + E) time and O(V) extra space for the colours and the recursion. Union-find uses O(V) space and processes the edges in O(E log V) time with path halving alone, or in nearly linear time once union by size is added; it also works when edges arrive one at a time, which a DFS cannot do without starting over.
Where it goes wrong
- One visited flag on a directed graph. It confuses "finished earlier" with "on the path now". Shown above.
- Colours on an undirected graph. Store each undirected edge both ways and the colour check sees
u → v → uand reports a cycle for a single edge. Undirected graphs need the parent check. - Parallel edges. Two separate edges between the same pair form a cycle, but skipping the parent node skips both. If the input can repeat an edge, skip the parent edge by its index instead.
- Deep recursion. A long chain exceeds Python's default recursion limit of 1,000 frames; use an explicit stack for large inputs.
How to say it in an interview
"For a directed graph I run DFS with three colours. Grey means the node is on the current recursion path; an edge into a grey node is a back edge, and that's a cycle. Black nodes are finished and safe to meet again, which is why a single visited set would give false positives. For an undirected graph I skip the edge back to the parent and any other visited neighbour means a cycle, or I use union-find and check whether an edge joins two nodes already connected. Both are O(V + E)."
A cycle is also what makes topological sort fail, and components and cycles covers the union-find side in depth.