DSA sheet · Graphs · Cycle detection pattern (DFS)

Detect a Cycle in an Undirected Graph (DFS)

We get an undirected graph and must say whether it contains a cycle: a loop you can walk around and come back to where you started. The teacher solves it with DFS and one extra piece of information carried into every call: the parent, meaning the node we just came from. "I found a node I have already seen, and it is not the one I just came from" = cycle. This idea comes back in the BFS version (Problem 13), and it is the reason the directed version (Problem 12) needs a different trick. Cycle detection is also the heart of course scheduling and deadlock checks, so it is worth getting fully clear.

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

  has a cycle                 no cycle (a "tree")
  (0)———(1)                   (0)———(1)
    \   /                       \
     (2)———(3)                   (2)———(3)
  loop: 0 → 1 → 2 → 0          every route back would reuse an edge

The three ways a graph can be given

formwhat it looks like (graph above, with the cycle)notes
edge list[[0,1],[0,2],[1,2],[2,3]]Each pair [u, v] is one edge. This question gives us this.
adjacency matrixa V×V grid, mat[u][v] = 1 if there is an edgeTakes V² memory. With V up to 10⁵ that would be 10¹⁰ cells, far too big.
adjacency listadj[0]=[1,2], adj[1]=[0,2], adj[2]=[0,1,3], adj[3]=[2]For each node, the list of its neighbours. Memory V + 2E. We build this.

Building the adjacency list from an edge list

Because the graph is undirected, the edge [u, v] means "v is a neighbour of u" and "u is a neighbour of v". So we store it twice, once in each list. The teacher's reasoning: if 0 can be the parent of 1, then 1 can just as well be the parent of 0.

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

graph = [[] for _ in range(V)]   # one empty list per node
for u, v in edges:
    graph[u].append(v)           # u can go to v
    graph[v].append(u)           # and v can go to u (undirected)

print(graph)   # [[1, 2], [0, 2], [0, 1, 3], [2]]
Doubt: list of lists, or a dictionary (map)?
→ Either works. The teacher points out that both give a node's neighbours in O(1): the list by index, the map by key. Since nodes are numbered 0 … V−1, a list is the simplest.

The visited array, and why it's not optional

visited[x] says whether we have already reached node x. Without it, DFS would go 0 → 1 → 0 → 1 → … forever, because in an undirected graph every edge leads back. The teacher stresses this: in graphs, marking visited is what stops you from going round and round.

Connected components

A connected component is one separate piece of the graph: inside a piece you can reach every node, but there is no edge between pieces. This question says the graph may have several pieces (we met this in Number of Provinces, Problem 4). The teacher's examples:

  V = 8, E = 8:  two pieces of 4 nodes each
  (0)—(1)   (4)—(5)
   |   |     |   |
  (3)—(2)   (7)—(6)

  V = 5, E = 4:  the 5th node sits alone, with no edge at all
  (0)—(1)   (4)
   |   |
  (3)—(2)

A DFS from node 0 only reaches its own piece. If the cycle lives in another piece, we must start a new DFS there too. So the answer is: "is there a cycle in any piece?"

DFS with recursion, in 4 lines (see Problem 5 for the full version)

BFS (a queue, level by level, Problem 3) solves the same question in Problem 13. The teacher's point: this problem is only "traverse, mark visited, notice a repeat", so DFS and BFS both work. Pick whichever feels easier.

Part A · Cycle detection with DFS + parent

GFG: "Undirected Graph Cycle"

1The question in simple words

You are given V (number of nodes) and edges, a list of pairs [u, v]. Each pair is an undirected edge. The adjacency list is not given; we build it. Return True if the graph has a cycle anywhere, otherwise False. The graph may be split into several components.

Example 1: V = 4, edges [[0,1],[0,2],[1,2],[2,3]] → True
  (0)———(1)
    \   /
     (2)———(3)
  0 → 1 → 2 → 0 is a loop
Example 2: V = 4, edges [[0,1],[1,2],[2,3]] → False
  (0)———(1)———(2)———(3)

  a straight line: no loop

2What the constraints tell us

3Intuition: a walk where you notice a familiar place

Picture walking through a town and leaving a chalk mark on every corner you pass. You keep walking forward, never turning straight back. If you reach a corner that already has your chalk mark, you must have walked in a loop. That's a cycle.

There is one catch. In an undirected graph, every road can be walked both ways. So from any corner, the corner you just came from is always one step away, and it always has a chalk mark. Seeing that mark proves nothing; you are just looking over your shoulder.

So the rule becomes: a neighbour that is already visited AND is not my parent (the node I came from) means a cycle. To use this rule, every DFS call must know its parent. That's the only extra thing compared to plain DFS.

4Building the logic from her examples

Step 1: why the parent check is needed (counter-example)

Try the naive rule "a visited neighbour means a cycle" on the smallest graph there is: one edge.

2 nodes, 1 edge: no cycle
  (0)———(1)

  graph[0] = [1]
  graph[1] = [0]   ← the same edge, stored backwards
what the naive rule does
  dfs(0): mark 0
    neighbour 1: not visited → dfs(1)
      dfs(1): mark 1
        neighbour 0: visited! → "cycle" ✗ WRONG

Node 1 sees node 0 as visited only because we came from 0. The same edge 0—1 is stored as 1→0 too, so it shows up again. A cycle needs a different way back. That's why the teacher says the visited parent is "allowed" to be seen: you are looking backwards, not finding a loop.

Rule 1: skip the parentWhen a neighbour is visited and it is my parent → ignore it, it's the edge I just walked on.

Step 2: when a visited neighbour really means a cycle

Her main example, V = 4, edges [[0,1],[0,2],[1,2],[2,3]]. Start a DFS at 0 and carry the parent along:

  (0)———(1)          dfs(0, parent −1): mark 0, go to 1
    \   /            dfs(1, parent 0):  mark 1. 0 is visited but it is my parent → fine. Go to 2
     (2)———(3)       dfs(2, parent 1):  mark 2. Look at neighbour 0:
                       0 is visited, and my parent is 1, NOT 0
                       → someone else reached 0 before me → cycle!

Node 2 came from 1, so seeing 1 is normal. But 0 is also marked, and 2 didn't come from 0. Somebody reached 0 by another route. Her picture: you start at A, go to B (new), go to C (new), and then the road from C leads to a place you have already seen. You have been walking in a circle.

Rule 2: visited and not the parent → cycleif visited[nb] and nb != parent: return True
Doubt: why is "visited and not my parent" always a real cycle? Could it be a false alarm?
→ Draw it. The DFS has reached node u by one chain of edges from the start, and it reached w earlier by a chain too. Now u finds an edge to w, and w is not the node u came from. So the edge u—w is not one of the edges DFS walked to get to u. Follow the chain from w down to u, then take the new edge u—w back to w: that's a loop with no edge used twice.
        start
          |
          w   ← visited earlier
         /  \
     (tree edges)
        ...    \
         u ———— w      the extra edge u—w closes the loop
  path w ⇢ u (tree edges) + edge u—w = cycle
Doubt (not in the video): what if the input has a self-loop [2, 2], or the same edge twice, [0, 1] and [1, 0]?
→ A self-loop puts 2 into its own list; when dfs(2) looks at it, 2 is visited and is not its own parent → True. A doubled edge is two different roads between the same two nodes, which is a loop of length 2; the code also reports True (when the parent's loop meets the child a second time, the child is visited and is not the parent's parent). Both count as cycles, so the answer is right. Most test data has neither.

Step 3: the starting node has no parent

The first call has come from nowhere, so the teacher passes −1 as its parent. −1 is not a real node number, so the check "nb != parent" is always true for the start node: every visited neighbour of the start (other than itself) would be a real cycle.

Step 4: what to do with an unvisited neighbour

Go into it: call dfs(nb, node). The neighbour is the new node, and the current node becomes its parent. If that call finds a cycle somewhere below, it returns True, and we must pass that True straight up. The teacher's example: calls go 8 → 9 → 10, and inside the call for 10 we see 8 visited (not 10's parent) → True. That True must travel back through 9 and 8 to the top. So we write if dfs(...): return True, not just dfs(...).

Doubt: why not return dfs(nb, node) directly?
→ That would stop after the first neighbour even when it found no cycle. Other neighbours might still lead to one. We return early only on True. If the loop finishes with nothing found, then this node's part is cycle-free → return False.

Step 5: many components → loop over all nodes, but start only at unvisited ones

Her example: suppose nodes 1 … 7 form one piece with no cycle, and 8, 9, 10 form a triangle. A DFS from 1 marks 1 … 7 and finds nothing. Do we start again from 2? No: 2 is already visited, so it was checked as part of 1's piece. Starting from it again would only repeat work. The first unvisited node is 8, which is a new piece. DFS from 8 finds the triangle → True.

  piece 1: no cycle                  piece 2: cycle
  (1)—(2)—(3)                        (8)———(9)
       |                               \   /
      (4)—(5)   (6)—(7)                 (10)
  (also joined somewhere, all reached from 1)

  outer loop: i = 1 → not visited → dfs → False
              i = 2 … 7 → visited → skip
              i = 8 → not visited → dfs → True → answer True
Rule 3: outer loopfor i in range(V): if not visited[i]: if dfs(i, -1): return True · after the loop, return False.
Doubt: if one piece has a cycle, do we still check the other pieces?
→ No. The question asks "is there a cycle anywhere?". One is enough, so we return True at once and skip the rest.

5Approach steps

  1. Build the adjacency list graph, adding every edge both ways.
  2. Make visited = [False] * V.
  3. For every node i from 0 to V−1: if it's not visited, run dfs(i, -1). If it returns True, return True.
  4. Inside dfs(node, parent): mark node visited.
  5. For each neighbour nb: if nb is not visited → if dfs(nb, node) is True, return True.
  6. Else (nb is visited): if nb is not the parent → return True (cycle).
  7. After all neighbours → return False.
  8. After the outer loop → return False (no piece had a cycle).

6Code (Python)

Cycle in undirected graph with DFS
import sys
sys.setrecursionlimit(10**6)       # DFS can go V calls deep

class Solution:
    def isCycle(self, V, edges):
        graph = [[] for _ in range(V)]
        for u, v in edges:             # undirected: store both ways
            graph[u].append(v)
            graph[v].append(u)

        visited = [False] * V
        for i in range(V):             # one DFS per component
            if not visited[i]:
                if self.dfs(i, -1, graph, visited):
                    return True
        return False

    def dfs(self, node, parent, graph, visited):
        visited[node] = True
        for nb in graph[node]:
            if not visited[nb]:
                if self.dfs(nb, node, graph, visited):   # node is nb's parent
                    return True
            elif nb != parent:         # seen before, and not where I came from
                return True
        return False

7Code line by line

linewhat it means
sys.setrecursionlimit(10**6)Python stops recursion at depth 1000 by default. A line graph of 10⁵ nodes needs 10⁵ nested calls, so we raise the limit.
graph = [[] for _ in range(V)]One empty neighbour list per node. Note: [[]] * V would be a bug, because all V entries would be the same list.
graph[u].append(v) graph[v].append(u)Undirected: the edge works both ways, so it goes in both lists.
visited = [False] * VNothing seen yet. The teacher uses an int array with 0/1; True/False is the same idea.
for i in range(V): if not visited[i]:Try every node as a start, but only nodes not reached before. Each such node is the first node of a new component.
if self.dfs(i, -1, ...): return TrueStart this component with parent −1 ("came from nowhere"). A cycle in any component is enough.
return FalseEvery component was checked and none had a cycle.
visited[node] = TrueChalk mark on this node.
for nb in graph[node]:Look at every neighbour, one by one.
if not visited[nb]: if self.dfs(nb, node, ...): return TrueNew node: walk into it, telling it "I am your parent". If anything below finds a cycle, pass True up.
elif nb != parent: return TrueAlready visited and not the node I came from → reached a second way → cycle.
return FalseAll neighbours checked, no cycle through this node's part. The call ends and the caller moves on.

8Dry run

Dry run 1: the teacher's graph (has a cycle)

V = 4, edges [[0,1],[0,2],[1,2],[2,3]] → graph: 0:[1,2] · 1:[0,2] · 2:[0,1,3] · 3:[2]. "V" in the visited column means visited.

stepcall (node, parent)neighbour checkedcall stack AFTERvisited afteranswer so far
1outer loop i = 0 → dfs(0, −1)mark 0dfs(0)0–
2dfs(0, −1)1: not visited → go indfs(0), dfs(1)0, 1–
3dfs(1, 0)0: visited, is parent → skipdfs(0), dfs(1)0, 1–
4dfs(1, 0)2: not visited → go indfs(0), dfs(1), dfs(2)0, 1, 2–
5dfs(2, 1)0: visited, parent is 1, 0 ≠ 1dfs(0), dfs(1), dfs(2)0, 1, 2True
6True travels backdfs(2) → dfs(1) → dfs(0) → outer loopempty0, 1, 2True
stack at step 2
dfs(0, −1)dfs(1, 0)
stack at step 5
dfs(0, −1)dfs(1, 0)dfs(2, 1) → True
  snapshot at step 5 ([x] = visited)
  [0]———[1]
     \   /
      [2]———(3)        2 looks at 0: marked, and 2 came from 1 → cycle

In her spoken walk-through, node 2 checks 1 first (its parent, skipped) and then 0. With this edge order the code checks 0 first, because graph[2] = [0, 1, 3]. The answer is the same either way. Node 3 is never even visited: we stop as soon as the cycle is found.

Dry run 2: two components, the cycle is in the second one

V = 7, edges [[0,1],[1,2],[1,3],[4,5],[5,6],[6,4]] → graph: 0:[1] · 1:[0,2,3] · 2:[1] · 3:[1] · 4:[5,6] · 5:[4,6] · 6:[5,4].

  piece A (no cycle)        piece B (triangle)
  (0)—(1)—(2)               (4)———(5)
       |                      \   /
      (3)                      (6)
stepcall (node, parent)neighbour checkedcall stack AFTERvisited afteranswer so far
1i = 0 → dfs(0, −1)1: new → go indfs(0), dfs(1)0, 1–
2dfs(1, 0)0: parent → skip · 2: new → go indfs(0), dfs(1), dfs(2)0, 1, 2–
3dfs(2, 1)1: parent → skip · no more → Falsedfs(0), dfs(1)0, 1, 2–
4dfs(1, 0)3: new → go indfs(0), dfs(1), dfs(3)0 – 3–
5dfs(3, 1)1: parent → skip → Falsedfs(0), dfs(1)0 – 3–
6dfs(1), then dfs(0) finishno more neighbours → False, Falseempty0 – 3False for piece A
7i = 1, 2, 3already visited → don't startempty0 – 3–
8i = 4 → dfs(4, −1)5: new → go indfs(4), dfs(5)0 – 5–
9dfs(5, 4)4: parent → skip · 6: new → go indfs(4), dfs(5), dfs(6)0 – 6–
10dfs(6, 5)5: parent → skip · 4: visited, 4 ≠ 5True goes back up; stack empties0 – 6True

Piece A alone would give False. Only because the outer loop also starts at 4 do we find the triangle. That's the whole reason for Rule 3.

9Complexity & remember

Remember Carry the parent into every call. Visited neighbour that is the parent → ignore. Visited neighbour that is not the parent → cycle. Unvisited → recurse with (nb, node) and pass True upward. Outer loop over all unvisited nodes for many components.

Part B · The same idea with an explicit stack

Not from the video, but useful in Python: if V is 10⁵ and the graph is one long line, the recursive version goes 10⁵ calls deep. Raising the recursion limit usually works, but on some machines a very deep recursion can still crash. Here is the same parent rule without recursion.

1What stays the same, what changes

Doubt: is the parent rule still correct when the order of visiting changes?
→ Yes. The proof in Part A never used the order. It only used "u was reached by a chain of edges, w was reached by a chain of edges, and u—w is not the edge that brought u in". That is true for any traversal that remembers each node's parent and marks on push. In fact, this code is exactly the BFS code of Problem 13 with pop() in place of popleft().

2Code (Python)

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

        visited = [False] * V
        for start in range(V):
            if visited[start]:
                continue
            visited[start] = True
            stack = [(start, -1)]          # (node, parent)
            while stack:
                node, parent = stack.pop()
                for nb in graph[node]:
                    if not visited[nb]:
                        visited[nb] = True     # mark when pushing
                        stack.append((nb, node))
                    elif nb != parent:
                        return True
        return False

3Complexity

Same as Part A: O(V + 2E) time and space. The stack replaces the call stack, and lives in normal memory, so depth is never a problem.


Part C · Revision page

situation when looking at neighbour nbwhat it meansaction
nb not visiteda new noderecurse dfs(nb, node); if True → return True
nb visited, nb == parentthe edge I just came along, seen from the other endignore
nb visited, nb != parentreached a second wayreturn True (cycle)
undirected cycle (this page)directed cycle (Problem 12)
edge storedboth waysone way
extra info per nodethe parent"is it on the current path?" (path visited / state 1)
cycle whenvisited and not the parentvisited and still on the current path
timeO(V + 2E)O(V + E)
If you remember only 5 lines 1. Build the adjacency list both ways; visited array of size V.
2. Outer loop: start a DFS only from unvisited nodes (one per component), parent = −1.
3. In DFS: mark visited, then look at each neighbour.
4. Unvisited → recurse with (nb, node), pass True up. Visited and ≠ parent → cycle.
5. O(V + 2E) time and space, because the degrees add up to 2E.
Mistakes to avoid ✗ "visited neighbour = cycle" without the parent check (every edge would look like a cycle)
✗ starting only from node 0 (misses cycles in other components)
✗ return self.dfs(...) instead of if self.dfs(...): return True (stops after the first neighbour)
✗ adding the edge only one way
✗ [[]] * V for the adjacency list (all nodes share one list)
✗ forgetting sys.setrecursionlimit in Python for deep graphs
test it yourself (paste under any of the solutions above)
s = Solution()
print(s.isCycle(4, [[0, 1], [0, 2], [1, 2], [2, 3]]))               # True
print(s.isCycle(4, [[0, 1], [1, 2], [2, 3]]))                       # False
print(s.isCycle(2, [[0, 1]]))                                       # False
print(s.isCycle(7, [[0, 1], [1, 2], [1, 3], [4, 5], [5, 6], [6, 4]]))  # True
print(s.isCycle(5, []))                                             # False

Based on this video: Detect Cycle in an Undirected Graph using DFS