DSA sheet · Graphs · Cycle detection pattern (directed, DFS)

Detect a Cycle in a Directed Graph (DFS)

Same question as Problem 11, but now every edge is a one-way street. The teacher first shows that the undirected method (visited + parent) gives the wrong answer here, and why. Then she fixes it with a second array, path visited, that remembers which nodes are on the path we are walking right now. Finally she squeezes both arrays into one array with three values: 0 = never seen, 1 = on my current path, 2 = finished. This three-colour DFS is the same tool used later for Course Schedule and Eventual Safe States.

Every problem below follows the same order:
① the question in simple words → ② what the constraints tell us → ③ intuition → ④ building the logic from examples → ⑤ approach steps → ⑥ code → ⑦ code line by line → ⑧ dry run → ⑨ complexity & remember

Part 0 · Before starting

Graph words used on this page

  looks like a loop, but is NOT a cycle      a real directed cycle
        (0)                                         (0)
       ↙   ↘                                       ↙   ↖
    (1) ———→ (2) ———→ (3)                       (1) ———→ (2) ———→ (3)
  0→1→2 and 0→2: no arrow back to 0            0→1→2→0

Building the adjacency list (one way only)

The input is an edge list. Unlike Problem 11, we add each edge once, from u to v. Adding v→u too would invent roads that don't exist.

build an adjacency list (directed)
V = 4
edges = [[0, 1], [0, 2], [1, 2], [2, 3]]

graph = [[] for _ in range(V)]
for u, v in edges:
    graph[u].append(v)        # only u → v

print(graph)   # [[1, 2], [2], [3], []]

Other input forms you may see: an adjacency matrix (V×V of 0/1, too big for large V) or the adjacency list already given. Here we always build the list.

Visited, components, and DFS (recap)

Part A · Why the undirected method fails here

GFG: "Directed Graph Cycle"

1The question in simple words

You get V and a list of directed edges [u, v] meaning u → v. Return True if you can follow arrows from some node and come back to it, else False. The graph may have several components. Constraints are like Problem 11 (V, E up to about 10⁵), so we again need O(V + E); anything quadratic would be around 10¹⁰ steps and TLE.

2Counter-example 1: the parent rule says "cycle" when there is none

The teacher's graph: edges 0→1, 0→2, 1→2, 2→3. Run the Problem 11 rule ("visited neighbour that is not my parent → cycle"):

        (0)                    dfs(0, parent −1): mark 0, go to 1
       ↙   ↘                   dfs(1, parent 0):  mark 1, go to 2
    (1) ———→ (2) ———→ (3)      dfs(2, parent 1):  mark 2, go to 3
                               dfs(3, parent 2):  mark 3, no arrows out. Back up …
                        back in dfs(0): next neighbour is 2
                          2 is visited, and 0's parent is −1, 2 ≠ −1
                          → "cycle" ✗ WRONG

From 2 you can only go to 3. There is no arrow back to 0. Node 2 was visited because of an earlier, finished branch (0 → 1 → 2), not because we are walking around a loop. Two arrows meeting at the same node (like 0→2 and 1→2) is not a cycle in a directed graph.

Doubt: so is "visited" just useless information here?
→ Not useless, just not enough. "Visited" tells you "someone reached this node at some point". In an undirected graph that is enough, because a second route to a node can always be walked backwards to close a loop. With one-way arrows you can't walk backwards, so a second route is just two roads that end at the same town. What matters is whether the node is on the path we are on right now. The teacher's phrase: here the path also matters.

3Counter-example 2: the parent rule says "no cycle" when there is one

My own addition, to show the parent rule isn't only too strict but can also be too lenient.

  (0) ⇄ (1)        edges 0→1 and 1→0: a cycle of length 2

  dfs(0, parent −1): mark 0, go to 1
  dfs(1, parent 0):  neighbour 0 is visited, but 0 IS my parent → "ignore"
  → finishes with "no cycle" ✗ WRONG

In an undirected graph, skipping the parent is correct because "1 to 0" is the same edge we came along. In a directed graph, 1→0 is a different edge from 0→1, so going back to 0 is a real loop. The parent check would hide it.

So for directed graphsThrow the parent away completely. Instead, remember which nodes are on the current DFS path. Cycle ⇔ an arrow leads to a node that is still on the current path.

Part B · DFS with visited + path visited

1The idea in simple words

Keep two arrays of size V:

2What the constraints tell us

3Intuition: a trail of breadcrumbs you pick up on the way back

Think of exploring a one-way road network. You put a permanent flag on every town you have ever visited. You also drop a breadcrumb in each town on your way forward, and pick it up when you turn back. The breadcrumbs always show exactly the route from your starting town to where you stand.

4Building the logic from her example

The three situations for a neighbour

visited[nb]path_vis[nb]meaningwhat to do
00never seengo into it: call dfs(nb). If it returns True → return True.
11seen and on my current pathreturn True (cycle)
10seen in an earlier, finished branchdo nothing: no cycle through it, and no need to explore it again

The teacher points out that the third case needs no code: we neither return True nor make a call, so we just let the loop move to the next neighbour.

Re-running her example with path visited

Edges 0→1, 0→2, 1→2, 2→3. Go 0 → 1 → 2 → 3, marking visited = 1 and path_vis = 1 on each. Node 3 has no arrows out, so we return. On the way back, each node clears its own breadcrumb:

  node:        0  1  2  3
  going down   visited   1  1  1  1
               path_vis  1  1  1  1      (path is 0 → 1 → 2 → 3)
  back at 0    visited   1  1  1  1
               path_vis  1  0  0  0      (3, 2, 1 have returned)

Now 0 looks at its second neighbour, 2: visited = 1 but path_vis = 0. Node 2 is not on my path, so this is the harmless third case → no cycle. ✓ The false alarm of Part A is gone.

Why unmark only path_vis and not visited too?

Doubt: why two arrays? Couldn't I just set visited back to 0 when I return, and use visited as the path?
→ It would give correct answers, but it can be terribly slow. The teacher's argument: if you came back from a node, it means its whole branch had no cycle (otherwise you'd have returned True already). Clearing visited lets other paths walk into that same branch again and redo all of it. Imagine 100 nodes below node 2: every new route into 2 would re-walk all 100.
It gets worse with "diamonds": in the chain below, each diamond doubles the number of different routes, so with k diamonds the DFS walks about 2ᵏ routes. Keeping visited permanent means each node is explored once.
  (0)→(1)→(3)→(4)→(6)→ …
    ↘    ↗   ↘    ↗
     (2)       (5)          k diamonds → 2ᵏ routes from 0 to the end
Doubt: can a finished node (visited 1, path_vis 0) ever be part of a cycle with my current path?
→ No. Suppose X is finished and an arrow path leads from X back to some node P on my current path. P is on the path now, so while X was being explored, P was either not seen yet or already on the path (a node never goes from "finished" back to "on the path"). If P wasn't seen yet, X's DFS would have reached P and finished it, so P couldn't be on my path now. If P was already on the path while X was explored, X's DFS would have found P with path_vis = 1 and reported the cycle then. Either way, skipping finished nodes is safe.

5Approach steps

  1. Build the adjacency list one way (u → v).
  2. Make visited and path_vis, both all 0.
  3. For each node i that is not visited: if dfs(i) is True → return True.
  4. In dfs(node): set visited[node] = 1 and path_vis[node] = 1.
  5. For each neighbour: not visited → recurse, pass True up. Visited and on path → return True. Otherwise skip.
  6. After the loop: path_vis[node] = 0 (leaving the path), return False.
  7. After the outer loop: return False.

6Code (Python)

Directed cycle with visited + path visited
import sys
sys.setrecursionlimit(10**6)

class Solution:
    def isCyclic(self, V, edges):
        graph = [[] for _ in range(V)]
        for u, v in edges:
            graph[u].append(v)              # one way only

        visited = [0] * V
        path_vis = [0] * V
        for i in range(V):                  # every component
            if visited[i] == 0:
                if self.dfs(i, graph, visited, path_vis):
                    return True
        return False

    def dfs(self, node, graph, visited, path_vis):
        visited[node] = 1
        path_vis[node] = 1                  # node joins the current path
        for nb in graph[node]:
            if visited[nb] == 0:
                if self.dfs(nb, graph, visited, path_vis):
                    return True
            elif path_vis[nb] == 1:         # arrow back into my own path
                return True
        path_vis[node] = 0                  # leaving: off the path, but still visited
        return False

7Code line by line

linewhat it means
graph[u].append(v)Directed: only u → v. No reverse edge.
for i in range(V): if visited[i] == 0:Start a DFS from every node no earlier DFS reached.
visited[node] = 1 path_vis[node] = 1Flag (permanent) and breadcrumb (temporary). No parent is passed any more.
if visited[nb] == 0: if self.dfs(...): return TrueNew node → explore it; a cycle found below is passed up.
elif path_vis[nb] == 1: return Truenb is visited and on the current path → this arrow closes a loop.
(no else)Visited but off the path → finished earlier, nothing to do.
path_vis[node] = 0All of node's arrows are explored with no cycle. Pick up the breadcrumb: node is leaving the path. visited stays 1.
return FalseNo cycle reachable through this node.

8Dry run

Her second graph: flip the arrow between 0 and 2 so it points 2 → 0. Edges 0→1, 1→2, 2→0, 2→3 → graph: 0:[1] · 1:[2] · 2:[0,3] · 3:[].

        (0)
       ↙   ↖
    (1) ———→ (2) ———→ (3)       0 → 1 → 2 → 0 is a real cycle
stepcallneighbour checkedcall stack AFTERvisited / path_vis afteranswer so far
1i = 0 → dfs(0)mark 0dfs(0)1000 / 1000–
2dfs(0)1: not visited → go indfs(0), dfs(1)1100 / 1100–
3dfs(1)2: not visited → go indfs(0), dfs(1), dfs(2)1110 / 1110–
4dfs(2)0: visited, path_vis = 1dfs(0), dfs(1), dfs(2)1110 / 1110True
5True goes back updfs(2) → dfs(1) → dfs(0) → outer loopempty–True

The 4 digits are nodes 0, 1, 2, 3. Node 3 is never visited: we stop at the first cycle.

stack at step 4 = the current path
dfs(0)dfs(1)dfs(2) sees 0 → True

Notice: the nodes with path_vis = 1 are exactly the calls on the stack. path_vis is just a quick way to ask "is this node on the stack?" in O(1).

9Complexity & remember

Remember Directed → no parent. Two marks: visited (forever) and path_vis (only while the node is on the stack). Cycle ⇔ the arrow points to a node with path_vis = 1. Clear path_vis when leaving, never visited.

Part C · One array with 0 / 1 / 2

1What changes from Part B

Look at the pairs (visited, path_vis) a node can have: (0,0) never seen, (1,1) on the path, (1,0) finished. Only three combinations ever happen. So one number per node is enough:

statesame as in Part Bmeaning
0visited 0never seen
1visited 1, path_vis 1on the current path (DFS entered it and hasn't left)
2visited 1, path_vis 0finished: fully explored, left the path, no cycle through it

The teacher's trick: we can't set visited back to 0 when returning (that would cause repeated work, see Part B). But we can change the 1 into a 2. The value then says both "seen" (not 0) and "not on my path any more" (not 1).

2The rules with one array

Only two lines change from Part B: path_vis[node] = 0 becomes state[node] = 2, and the check "visited and path_vis == 1" becomes just state[nb] == 1.

3Code (Python)

Directed cycle with one state array (0 / 1 / 2)
import sys
sys.setrecursionlimit(10**6)

class Solution:
    def isCyclic(self, V, edges):
        graph = [[] for _ in range(V)]
        for u, v in edges:
            graph[u].append(v)

        state = [0] * V                 # 0 new, 1 on path, 2 finished
        for i in range(V):
            if state[i] == 0:
                if self.dfs(i, graph, state):
                    return True
        return False

    def dfs(self, node, graph, state):
        state[node] = 1                 # entering: on the current path
        for nb in graph[node]:
            if state[nb] == 0:
                if self.dfs(nb, graph, state):
                    return True
            elif state[nb] == 1:        # back into my own path
                return True
            # state 2: finished earlier, ignore
        state[node] = 2                 # leaving: finished
        return False

4Code line by line (only the new parts)

linewhat it means
state = [0] * VOne array replaces visited and path_vis.
if state[i] == 0:Outer loop: "not visited" now means state 0.
state[node] = 1Same as setting both flags in Part B.
elif state[nb] == 1: return TrueOne check instead of "visited and path_vis".
state[node] = 2Leaving the path, but still "seen". Same as path_vis = 0 with visited still 1.

5Dry run: no cycle (the Part A graph)

Edges 0→1, 0→2, 1→2, 2→3 → graph 0:[1,2] · 1:[2] · 2:[3] · 3:[]. The state column lists nodes 0, 1, 2, 3.

stepcallneighbour checkedcall stack AFTERstate afteranswer so far
1i = 0 → dfs(0)enter 0dfs(0)[1,0,0,0]–
2dfs(0)1: state 0 → go indfs(0), dfs(1)[1,1,0,0]–
3dfs(1)2: state 0 → go indfs(0), dfs(1), dfs(2)[1,1,1,0]–
4dfs(2)3: state 0 → go in… dfs(2), dfs(3)[1,1,1,1]–
5dfs(3)no arrows → leave, set 2dfs(0), dfs(1), dfs(2)[1,1,1,2]–
6dfs(2)done → leave, set 2dfs(0), dfs(1)[1,1,2,2]–
7dfs(1)done → leave, set 2dfs(0)[1,2,2,2]–
8dfs(0)2: state 2 → finished, ignoredfs(0)[1,2,2,2]–
9dfs(0)done → leave, set 2empty[2,2,2,2]–
10i = 1, 2, 3state 2 → don't startempty[2,2,2,2]False
stack at step 4 (deepest)
dfs(0)dfs(1)dfs(2)dfs(3)
stack at step 8
dfs(0) looks at 2 (state 2)
  snapshot at step 8   (1 = on path, 2 = finished)
          [0:1]
         ↙     ↘
    [1:2] ———→ [2:2] ———→ [3:2]
  0's arrow to 2 hits a finished node → not a cycle

With the cycle graph (2→0 instead of 0→2), the run is the same as Part B's dry run: at dfs(2), node 0 has state 1 → True.

6Complexity & remember

Remember0 → 1 on entry, 1 → 2 on exit. Arrow into a 1 = cycle. Arrow into a 2 = ignore. Arrow into a 0 = explore.
Doubt: what about BFS for directed graphs?
→ The teacher leaves BFS as homework. Careful: copying the Problem 13 BFS (visited + parent) has the same bugs as Part A. A queue has no "current path", so the BFS way for directed graphs is different: count incoming arrows (indegree) and repeatedly remove nodes with indegree 0. That's Kahn's algorithm, which we meet in Course Schedule (Problem 21). If some nodes can never be removed, there's a cycle.

Part D · The same with an explicit stack

Not from the video. For a chain of 10⁵ nodes, the recursive version goes 10⁵ calls deep. Here the "current path" is our own list. Each entry is (node, index of the next neighbour to look at), so a node can pause and continue its neighbour loop exactly like a paused recursive call.

Directed cycle with an explicit stack
class Solution:
    def isCyclic(self, V, edges):
        graph = [[] for _ in range(V)]
        for u, v in edges:
            graph[u].append(v)

        state = [0] * V
        for start in range(V):
            if state[start] != 0:
                continue
            state[start] = 1
            stack = [(start, 0)]               # (node, next neighbour index)
            while stack:
                node, i = stack[-1]
                if i < len(graph[node]):
                    stack[-1] = (node, i + 1)  # next time, look at the next one
                    nb = graph[node][i]
                    if state[nb] == 0:
                        state[nb] = 1          # "call" nb
                        stack.append((nb, 0))
                    elif state[nb] == 1:
                        return True
                else:
                    state[node] = 2            # "return" from node
                    stack.pop()
        return False

The nodes in stack are exactly the nodes with state 1, the current path. Time and space O(V + E), with no recursion limit.


Part E · Revision page

undirected (Problem 11)directed (this page)
edge storedboth waysone way
extra infoparent of each callpath_vis, or state 1 = "on current path"
cycle whenvisited and ≠ parenton the current path
visited but not that(only the parent) ignorefinished (state 2) → ignore
on returningnothingclear path_vis / set state 2
timeO(V + 2E)O(V + E)
graphundirected rule sayscorrect answerwhy
0→1, 0→2, 1→2, 2→3cycleno cycle2 was reached twice, but there is no arrow back
0→1, 1→0no cyclecycle1→0 is a separate arrow, not the one we came along
If you remember only 5 lines 1. Directed: add edges one way; no parent.
2. Cycle = an arrow into a node that is on the current DFS path.
3. Two arrays: visited (forever) + path_vis (set on entry, cleared on exit).
4. Or one array: 0 new · 1 on path · 2 finished. 1 → cycle, 2 → ignore.
5. O(V + E) time and space; outer loop over unvisited nodes for all components.
Mistakes to avoid ✗ reusing the undirected parent rule (false alarms and missed 2-node cycles)
✗ calling every visited node a cycle (two arrows into one node is fine)
✗ clearing visited on return (correct but can be exponential)
✗ forgetting to clear path_vis / set state 2 when leaving
✗ starting only from node 0
✗ adding the reverse edge as if the graph were undirected
test it yourself (paste under any of the solutions above)
s = Solution()
print(s.isCyclic(4, [[0, 1], [0, 2], [1, 2], [2, 3]]))        # False
print(s.isCyclic(4, [[0, 1], [1, 2], [2, 0], [2, 3]]))        # True
print(s.isCyclic(2, [[0, 1], [1, 0]]))                        # True
print(s.isCyclic(6, [[0, 1], [1, 2], [3, 4], [4, 5], [5, 3]]))  # True
print(s.isCyclic(3, []))                                      # False

Based on this video: Detect Cycle in a Directed Graph using DFS