Cycle Detection
Decide whether a graph has a cycle: three-colour DFS for directed graphs; DFS with parent tracking or union-find for undirected graphs.
Overview
Cycle detection asks whether a graph contains a closed walk with no repeated edges. The answer technique depends on whether edges are directed, because the definition of a "back edge" differs.
Directed: run Depth-First Search (DFS) with three colours. White = unvisited, grey = on the current recursion path, black = finished. An edge to a grey vertex is a back edge to an ancestor and closes a directed cycle. An edge to a black vertex is harmless (a forward or cross edge). This is the same machinery as DFS Topological Sort; equivalently, Kahn's Algorithm reports a cycle when fewer than n vertices are emitted.
Undirected: every edge u — v is seen twice (from u and from v), so a plain "visited" check would report a cycle for any edge. Instead, during DFS an edge from u to an already-visited v is a cycle unless v is the vertex we came from (parent[u]). Alternatively, process edges with Union-Find (Disjoint Set Union): an edge whose endpoints are already in the same set closes a cycle. That is the tool of choice when edges arrive incrementally or the graph is given as an edge list.
Intuition
A mental model before the formal terms.
Directed: you are following one-way corridors, leaving a lit lamp in every room you are currently *inside* (your path of open doors) and switching it off when you back out. Stepping into a room whose lamp is lit means you have walked in a loop. Stepping into a dark room you have already finished is fine — you only reach it again from a different direction, and everything beyond it was already explored without finding a lit lamp.
Undirected: walking on a two-way trail, every path you take can be walked straight back. So bumping into the trail marker you *just* laid does not count — that is merely turning around. Bumping into any older marker means you reached a known spot by a genuinely different route: a loop.
Union-find view: each edge you add either joins two islands (no cycle) or adds a second bridge between islands already joined — that second bridge together with the old route is a cycle.
How it works
- Directed, three colours: for each white vertex run
dfs(u): colour grey; for eachvinadj[u]: grey → return "cycle"; white → recurse; black → skip. Colour black on exit. - Directed, Kahn: compute in-degrees, peel sources; if the count of emitted vertices
< n, a cycle exists (see Kahn's Algorithm). - Undirected, DFS parent check:
dfs(u, parent): mark visited; for eachvinadj[u]: if not visited, recurse with parentu; else ifv != parent, a cycle exists. With multi-edges, compare edge *ids* rather than parent vertices, otherwise a double edgeu — vis missed. - Undirected, union-find: for each edge
(u, v): iffind(u) == find(v)there is a cycle (that edge is redundant); elseunion(u, v). TotalO(E α(V)). - To *return* the cycle in the DFS variants, keep
parent[]and, on finding the back edgeu → v, walk fromuup the parent chain tov.
Why it works
Directed: a grey vertex v is an ancestor of u in the DFS tree, so a tree path v ⇝ u exists; the edge u → v closes it into a cycle. Conversely, if a cycle exists, let c be the first cycle vertex DFS discovers; every other cycle vertex is reached while c is grey (they are reachable from c through cycle edges and nothing on the cycle finishes before c), so the cycle edge into c is scanned while c is grey.
Black neighbours cannot indicate a cycle: everything reachable from a black vertex has finished, so there is no path from it back to any grey vertex (that grey vertex would have finished earlier).
Undirected: in a DFS of an undirected graph every non-tree edge is a back edge to an ancestor (there are no cross edges), so any visited neighbour other than the parent is an ancestor and closes a cycle with the tree path.
Union-find: components are trees exactly as long as every added edge joined two different components; the first edge inside one component creates the first cycle.
Recognition
How to tell a problem wants this.
- "Is this dependency graph consistent / can all tasks be completed?" — directed cycle check.
- "Which edge can be removed to make this a tree?" (Redundant Connection) — union-find on undirected edges.
- "Is the graph a tree / forest?" — undirected: no cycle and (for a tree) connected with
n - 1edges. - Deadlock detection in a wait-for graph, circular imports, reference cycles — directed.
Interactive visualization
Play, step, change the input. ← → and space work too.
1color[v] = WHITE for all v2def dfs(u):3 color[u] = GRAY # on the current path4 for v in neighbors(u):5 if color[v] == GRAY: return cycle (back edge u→v)6 if color[v] == WHITE and dfs(v): return true7 color[u] = BLACK; return false8for u in nodes: if color[u] == WHITE and dfs(u): report cyclePseudocode
1# directed2color[*] = WHITE3dfs(u): color[u] = GRAY4 for v in adj[u]: if color[v] == GRAY: return true; if WHITE and dfs(v): return true5 color[u] = BLACK; return false6# undirected7dfs(u, parent): visited[u] = true8 for v in adj[u]: if !visited[v]: if dfs(v, u): return true9 else if v != parent: return true10 return falseImplementations
1# Cycle detection splits by graph kind. Directed graphs need the three-colour2# DFS (a back edge into the *active path*); undirected graphs need only a3# visited set plus a parent check, because every edge looks like a 2-cycle.4 5 61 · Directed: an edge into a vertex still on the DFS path closes a cycle7def has_cycle_directed(out: list[list[int]]) -> bool:8 n = len(out)9 state = [0] * n # 0 = new, 1 = on the path, 2 = finished10 for s in range(n):11 if state[s] != 0:12 continue13 stack = [[s, 0]]14 state[s] = 115 while stack:16 frame = stack[-1]17 u, i = frame[0], frame[1]18 if i < len(out[u]):19 frame[1] += 120 v = out[u][i]21 if state[v] == 1:22 return True # back edge23 if state[v] == 0:24 state[v] = 125 stack.append([v, 0])26 else:27 state[u] = 228 stack.pop()29 return False30 31 322 · Undirected: any edge to a visited non-parent vertex closes a cycle33def has_cycle_undirected(adj: list[list[int]]) -> bool:34 n = len(adj)35 seen = [False] * n36 for s in range(n):37 if seen[s]:38 continue39 stack = [(s, -1)] # (vertex, parent)40 seen[s] = True41 while stack:42 u, parent = stack.pop()43 for v in adj[u]:44 if v == parent:45 continue # the edge we arrived on, not a cycle46 if seen[v]:47 return True48 seen[v] = True49 stack.append((v, u))50 return False51 52 533 · Undirected via union-find: an edge inside one component closes a cycle54def has_cycle_union_find(n: int, edges: list[tuple[int, int]]) -> bool:55 parent = list(range(n))56 57 def find(x: int) -> int:58 while parent[x] != x:59 parent[x] = parent[parent[x]]60 x = parent[x]61 return x62 63 for u, v in edges:64 a, b = find(u), find(v)65 if a == b:66 return True67 parent[b] = a68 return False69 70 714 · Floyd's tortoise and hare finds a cycle in a functional graph72def find_cycle_start(nxt: list[int], start: int) -> int:73 slow = fast = start74 while True:75 slow = nxt[slow]76 fast = nxt[nxt[fast]]77 if slow == fast:78 break795 · Reset one pointer to the start; they meet at the cycle entrance80 slow = start81 while slow != fast:82 slow = nxt[slow]83 fast = nxt[fast]84 return slow- The directed version uses mutable *list* frames so
frame[1] += 1advances the cursor; the undirected version uses immutable tuples because it pops immediately and needs no cursor. u, parent = stack.pop()unpacks the tuple in one statement.findis a closure overparent, which keeps the union-find variant compact without a class.slow = fast = startchains the assignment, and thewhile Truewith an internalbreakis the Python spelling ofdo...while, which the language does not have.- Floyd needs constant space and works on any structure with a "next" operation, which is why it is the linked-list answer rather than a set of seen nodes.
- Python has no
do...while, sowhile True:with a trailingif ...: breakis the standard equivalent — needed here because both pointers start equal. - The directed frame must be a list; a tuple would raise
TypeErroron the cursor increment. The undirected frame can stay a tuple because it is never modified. networkx.find_cycleraisesNetworkXNoCyclewhen there is none, and handles both directed and undirected graphs.- Closures capturing a mutable list (
parent) work withoutnonlocal, because the list is mutated rather than rebound.
- Translating
do...whileas a plainwhile slow != fast, which never executes because the pointers start equal. - Using a tuple for the directed DFS frame and hitting
TypeErroron the increment. - Rebinding
parentinsidefind(rather than mutating it), which would neednonlocaland is a sign the logic has drifted.
- Python is the only one of the four without a
do...while, which matters here because Floyd's algorithm genuinely needs one —while Trueplusbreakis the workaround. - Mutable versus immutable stack frames: the directed DFS needs a mutable frame everywhere (C++ reference binding, JS/TS object, Python list), while the undirected version can use an immutable pair in all four.
- Recursion is avoided in every language, but for different limits:
RecursionErrornear 1000 frames in CPython,RangeErrornear 10000 in JS engines, and a silent stack overflow in C++. - Library support: Python has
networkx.find_cycleandgraphlib(which raisesCycleError); C++ has Boost.Graph visitors; JS/TS have neither.
Complexity
Union-find on an edge list: O(E α(V)) time, O(V) space; stops at the first cycle edge.
Compare growth rates in the Complexity Explorer →When to use — and when not to
- Validating dependency graphs, detecting deadlocks, checking that constraints are satisfiable (directed).
- Checking whether an undirected graph is a forest / tree, or finding the one redundant edge.
- As a guard before algorithms that require a DAG (Topological Sort, DP on DAGs) or a tree.
- You need the *shortest* or *smallest-weight* cycle — that is a different (harder) problem, usually BFS from each vertex or Floyd-Warshall.
- You need all cycles enumerated — exponential in general; think again about the problem.
- Directed graph handed as an edge list only and you reach for union-find — union-find ignores direction and answers the wrong question for directed graphs.
Alternatives
Common mistakes
- Using a single visited flag in a directed graph: a cross edge to a finished vertex is misreported as a cycle (e.g.
0→1,0→2,1→2has no cycle). - Omitting the parent check in an undirected graph: every edge is then "seen twice" and reported as a cycle.
- Using a parent-*vertex* check with multi-edges: two parallel edges
u — vform a cycle but are skipped; compare edge ids instead. - Applying union-find to a directed graph.
- Only running DFS from vertex
0— cycles in other components are missed.
Interview patterns
- Course Schedule: directed cycle check is the whole problem.
- Redundant Connection: union-find; the first edge whose endpoints are already connected is the answer.
- Graph Valid Tree: no undirected cycle and exactly
n - 1edges (or: connected andn - 1edges). - Find Eventual Safe States: vertices whose DFS finishes black without hitting grey.
- Choosing between BFS, DFS, Dijkstra and DPAdvanced
- Stack versus queueBeginner
- Recursion versus iterationIntermediate
- When space complexity mattersIntermediate
- Course ScheduleIntermediate
- Number of IslandsIntermediate
- Word SearchAdvanced