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 .
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 work and the total is , 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 . Each node is then explored fully at most once, each edge is followed at most once, and the total drops to .
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 .
- 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 nodes has exactly edges. Check len(edges) == n - 1 first. If it holds, you only need to confirm the graph is connected, because a connected graph with 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 . 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 .
- edges plus connected is enough to prove a tree.
- For undirected graphs built edge by edge, Union-Find is the faster cycle check.