bigonotes
DSA: Patterns & PythonGraphs

Cycle Detection

Find cycles with a parent check (undirected) or an on-path set (directed), and cache finished nodes to stay O(V + E).

5 min readLast updated

A cycle is a path that returns to where it started. In dependency problems a cycle is a deadlock (each item waits on another, so nothing can start), and in "is this a tree?" problems it is the thing that breaks the tree. The check looks different for undirected and directed graphs, and mixing the two up is the most common bug.

The idea#

A cycle means a walk can come back to a node it already visited. How you spot one depends on whether edges have a direction, so the two cases get their own rules.

Undirected: ignore the edge you came in on#

In an undirected graph every edge appears in both directions, so from a node you will always "see" the parent you came from. Pass the parent into the DFS and skip it. Any other neighbor that is already visited means there are two different routes to it, which is a cycle.

Directed: watch the current path#

In a directed graph, reaching an already visited node is not enough: two branches can lead to the same node without any cycle. What matters is whether that node is still on the current path. Track two sets:

  • on_path: nodes in the chain you are exploring right now (the recursion stack).
  • done: nodes whose whole subtree has been explored and proven cycle-free.

Meeting a node in on_path is a back edge, so a cycle. Meeting a node in done costs nothing: skip it.

How to spot it#

The cues split cleanly by edge type.

Cue Graph Tool
"Is this a valid tree?", "find the extra edge" Undirected Parent check, or Union-Find
"Can all courses be finished?", deadlock, circular dependency Directed On-path DFS, or topological sort
"Which nodes always end at a terminal?" Directed On-path DFS with safe/unsafe caches

Template#

Here are both checks, directed first. In each one a node is fully explored at most once, so both run in O(V+E)O(V + E).

on_path = empty set; done = empty set
dfs(node):
    if node in on_path: return cycle   # back edge
    if node in done: return no cycle   # already proven safe
    add node to on_path
    for each nei in neighbors of node:
        if dfs(nei) finds a cycle: return cycle
    remove node from on_path           # leaving the path
    add node to done                   # cache: never redo it
    return no cycle
run dfs from every node; any cycle means the graph has one

The undirected check is simpler: it needs only one seen set and the parent. Add every edge in both directions.

seen = empty set
dfs(node, parent):
    add node to seen
    for each nei in neighbors of node:
        if nei == parent: skip         # the edge you arrived on
        if nei in seen or dfs(nei, node) finds a cycle:
            return cycle
    return no cycle
run dfs(node, none) from every node not in seen

From TLE to O(V + E)#

The directed version shows why the done cache matters. A first attempt often runs the cycle check from every node with a fresh set() each time. Nothing carries over between starts, so each DFS can redo O(V+E)O(V + E) work and the total is O(V⋅(V+E))O(V \cdot (V + E)), which times out on Course Schedule.

Caching nodes that are already proven safe fixes it: a later DFS that reaches a cached node returns in O(1)O(1). Each node is then explored fully at most once, each edge is followed at most once, and the total drops to O(V+E)O(V + E).

The other easy slip is forgetting on_path.remove(node) on the way out. Without it, a node reached later through a different, cycle-free branch looks like a back edge.

Pitfalls#

These are the mistakes that most often cost a correct answer.

  • Using the directed check on an undirected graph: the edge back to the parent looks like a cycle.
  • Using "already visited" as the directed test: two paths meeting at a node is not a cycle.
  • A fresh visited set for every start node without a cache, giving O(V⋅(V+E))O(V \cdot (V + E)).
  • Forgetting to remove a node from the path set when the DFS leaves it.
  • Starting the DFS from one node only when the graph may have several components.

Problems in this pattern#

These problems use this pattern. Solve them before you open the hints below. Start with Course Schedule.

Hints#

Open a hint only after you have tried the problem for a while.

Hint: Graph Valid Tree

A tree with nn nodes has exactly n−1n - 1 edges. Check len(edges) == n - 1 first. If it holds, you only need to confirm the graph is connected, because a connected graph with n−1n - 1 edges can't contain a cycle.

Hint: Redundant Connection

Add the edges one at a time. Before adding u–v, ask whether a path from u to v already exists; if so, this edge closes the cycle. A DFS per edge is O(n2)O(n^2). Union-Find is faster: if union(u, v) finds both already in one group, that edge is the answer.

Hint: Course Schedule

The prerequisites form a directed graph, and the courses can all be finished exactly when it has no cycle. Use the on-path DFS with the done cache, or topological sort.

Hint: Find Eventual Safe States

Terminal nodes (no outgoing edges) are safe. A node is safe only if every path from it reaches safety, so no path from it may run into a cycle. Caching only the safe nodes isn't enough: an unsafe node gets re-explored from every node that reaches it. Cache unsafe nodes as well, so each node is decided once.

Key takeaways#

  • Undirected: skip the parent; any other visited neighbor is a cycle.
  • Directed: a cycle means you met a node that is still on the current path.
  • Cache finished nodes, or repeated DFS starts cost O(V⋅(V+E))O(V \cdot (V + E)).
  • n−1n - 1 edges plus connected is enough to prove a tree.
  • For undirected graphs built edge by edge, Union-Find is the faster cycle check.
Revision notes · 2 min

Core idea#

A cycle is a path that returns to its start. Undirected graphs need a parent check. Directed graphs need to know which nodes are on the current path.

Key points#

  • Undirected: skip the parent. Any other visited neighbor is a cycle.
  • Directed: a visited node is not enough. Two branches can meet without a cycle.
  • Directed: keep on_path (the recursion stack) and done (proven cycle-free).
  • A node in on_path is a back edge, so a cycle. A node in done costs nothing.
  • Without the done cache, repeated starts cost O(V⋅(V+E))O(V \cdot (V + E)) and time out. With it, the cost is O(V+E)O(V + E).
  • Graph Valid Tree: check len(edges) == n - 1, then check that the graph is connected.
  • Redundant Connection: Union-Find finds the edge that closes a loop in near-constant time per edge.
  • Find Eventual Safe States: cache unsafe nodes as well as safe ones.

Spot it#

Cue Graph Tool
Valid tree, extra edge Undirected Parent check or Union-Find
Finish all courses, deadlock Directed On-path DFS or topological sort
Nodes that always reach a terminal Directed On-path DFS with safe and unsafe caches

Template#

on_path = empty set; done = empty set
dfs(node):
    if node in on_path: return cycle   # back edge
    if node in done: return no cycle
    add node to on_path
    for each nei in neighbors of node:
        if dfs(nei) finds a cycle: return cycle
    remove node from on_path
    add node to done
    return no cycle
run dfs from every node

Pitfalls#

  • Using the directed check on an undirected graph. The edge to the parent looks like a cycle.
  • Treating "already visited" as a directed cycle.
  • A fresh visited set for every start without a cache.
  • Forgetting on_path.remove(node) when the DFS leaves the node.
  • Starting from one node when the graph has several components.

Say it in an interview#

  • "Undirected: skip the parent. Directed: look for a node still on the current path."
  • "The done cache keeps it O(V plus E)."

Found a mistake? Email ask@bigonotes.com