DSA sheet · Graphs · Cycle detection pattern (BFS)

Detect a Cycle in an Undirected Graph (BFS)

The same question as Problem 11 (does an undirected graph have a loop?), solved again with BFS: a queue that explores the graph level by level. The rule doesn't change: a neighbour that is already visited and is not my parent means a cycle. What changes is where the parent is kept: in DFS it was a function argument; in BFS it travels inside the queue, next to the node, as a pair (node, parent). The teacher also shows that the "story" of the cycle sounds different in BFS, even though the code check is identical.

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

build an adjacency list (undirected)
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)
    graph[v].append(u)        # both ways

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

BFS in 5 lines (Problem 3 has the full version)

Recap of the DFS version (Problem 11)

In DFS, each call knew its parent (the node it came from). Seeing the parent again is normal, because the edge we came along is stored in both lists. Seeing any other visited node means we reached it a second way, so there's a loop. We keep exactly this rule.

Part A · Cycle detection with BFS + parent

GFG: "Undirected Graph Cycle"

1The question in simple words

Given V and an edge list of an undirected graph (possibly in several pieces), return True if there is a cycle anywhere, else False.

V = 4, [[0,1],[0,2],[1,2],[2,3]] → True
  (0)———(1)
    \   /
     (2)———(3)
V = 5, [[0,1],[0,2],[2,3]] → False
  (0)———(1)      (4)
    \
     (2)———(3)

2What the constraints tell us

Why the outer loop starts at 0 and goes to V−1

The teacher's example: nodes 0, 1, 2 form a triangle (a cycle), and nodes 3, 4 form a separate piece with no cycle. If you only started a search at 3, you'd never see the triangle. Starting at 0 isn't magic: any order works as long as every node gets a chance. Going 0, 1, 2, … simply makes sure no piece is skipped.

  (0)———(1)        (3)———(4)
    \   /
     (2)            start only at 3 → "no cycle" ✗   loop over all nodes → True ✓

3Intuition: two parents claim the same child

BFS spreads out from the start like a ripple in water. Each node is "discovered" by exactly one node: its parent, the one that first pushed it into the queue.

Now suppose node 1 looks at its neighbour 2, and 2 is already visited, and 2 is not 1's parent. Who discovered 2? Some other node (here 0). So 2 can be reached from the start in two different ways: through 0, and through 1. Two different routes to the same node join up into a loop. The teacher tells it as a conversation:

In DFS the story was a single walk 0 → 1 → 2 that bumps back into 0. In BFS it's two branches that meet. The stories differ, but the code check is the same: visited and not my parent. Her everyday picture: you're on a trip and spot a mall you have already seen earlier. You must have looped round.

4Building the logic from her example

Point 1: the queue must hold the parent too

In plain BFS the queue holds only nodes. But the rule needs "who is my parent?" when we look at a node's neighbours. There's no recursion here to carry it, so the parent travels with the node: we push the pair (node, parent). The start has no parent, so it goes in as (start, -1).

Point 2: why the parent check is needed (counter-example)

one edge, no cycle
  (0)———(1)
  graph[0] = [1]
  graph[1] = [0]
BFS without the parent check
  pop (0, −1): 1 new → push (1, 0)
  pop (1, 0):  0 is visited → "cycle" ✗ WRONG
  with the check: 0 is my parent → ignore ✓

Exactly as in DFS: every undirected edge is stored both ways, so a node always sees the node it came from. That alone is not a loop.

Point 3: unvisited neighbour → push it with me as its parent, and mark it right away

If the neighbour is not visited, push (nb, node): the current node becomes its parent. The teacher's question here: should we push a neighbour that's already visited? No. In her example, 1's neighbours are 0 and 2; pushing 0 again would send us back and forth forever. So we push only unvisited neighbours.

And she marks a node visited at the moment it is pushed, not when it is popped. The start node is pushed before the loop, so it's marked before the loop too.

Doubt: why mark on push and not on pop?
→ If we waited until the pop, a node could sit in the queue unmarked, and another node could push it a second time. Marking on push means each node enters the queue exactly once. That keeps the queue at most V long and keeps the reasoning simple: "visited" now means "already discovered by someone".

Point 4: visited neighbour → check the parent

If the neighbour is visited and it is my parent → fine, skip. If it is visited and not my parent → return True.

Doubt: the visited neighbour might still be sitting in the queue, not processed yet. Is it still a real cycle?
→ Yes. Visited only needs to mean "discovered". If w was discovered by some node other than me, and I am a neighbour of w that didn't discover it, then there are two chains of parent links from the start: one to w, and one to me. Joining them with the edge me—w gives a loop. Whether w was already popped doesn't matter.
            start
           /     \
         ...      ...       (parent links)
         /          \
        u ——————————— w     u—w is not a parent link → loop

Point 5: when the queue empties

If the queue runs out and we never returned True, this whole component has no cycle → return False for it. The outer loop then moves on to the next unvisited node.

5Approach steps

  1. Build the adjacency list (both ways) and visited = [False] * V.
  2. For each node i not yet visited: if bfs(i) is True → return True.
  3. In bfs(start): push (start, -1) and mark start visited.
  4. While the queue isn't empty: pop (node, parent).
  5. For each neighbour: not visited → mark it and push (nb, node). Visited and ≠ parent → return True.
  6. Queue empty → return False.
  7. After the outer loop → return False.

6Code (Python)

Cycle in undirected graph with BFS
from collections import deque

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 i in range(V):                 # one BFS per component
            if not visited[i]:
                if self.bfs(i, graph, visited):
                    return True
        return False

    def bfs(self, start, graph, visited):
        queue = deque()
        queue.append((start, -1))          # (node, parent); start has no parent
        visited[start] = True
        while queue:
            node, parent = queue.popleft()
            for nb in graph[node]:
                if not visited[nb]:
                    visited[nb] = True         # mark when pushing
                    queue.append((nb, node))   # I am nb's parent
                elif nb != parent:             # discovered by someone else
                    return True
        return False

Compared with the DFS code of Problem 11, the teacher changes only the helper: dfs(node, parent) becomes bfs(start), and the parent moves from the argument list into the queue pairs. The outer loop is untouched.

7Code line by line

linewhat it means
graph[u].append(v) graph[v].append(u)Undirected: store each edge in both lists.
for i in range(V): if not visited[i]:Every node that no earlier BFS reached starts a new component.
if self.bfs(...): return TrueA cycle in any component is enough.
queue.append((start, -1)) visited[start] = TrueThe start goes in with "no parent" (−1) and is marked at once, because it has just been pushed.
while queue:Keep going until this component is fully explored.
node, parent = queue.popleft()Take the oldest pair from the front. We get both the node and who discovered it.
for nb in graph[node]:Look at every neighbour.
if not visited[nb]: visited[nb] = True queue.append((nb, node))New node: mark it and push it, recording me as its parent.
elif nb != parent: return TrueAlready discovered, and not by the edge that brought me here → cycle.
return False(inside bfs) queue empty, no cycle in this component. (outer) no cycle anywhere.

8Dry run

Dry run 1: the teacher's graph (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]. Queue entries are written (node, parent).

steppopped (node, parent)neighbours checkedqueue AFTERvisited afteranswer so far
0i = 0: startpush (0, −1), mark 0(0,−1)0–
1(0, −1)1: new → push (1,0) · 2: new → push (2,0)(1,0) (2,0)0, 1, 2–
2(1, 0)0: visited, is parent → skip · 2: visited, 2 ≠ 0(2,0)0, 1, 2True
start0,−1visited: 0
step 1pop (0,−1) → push 1 and 2, each with parent 0
1,02,0visited: 0, 1, 2
step 2pop (1,0): 0 is my parent ✓ skip · 2 is visited and my parent is 0, not 2 → return True
  snapshot at step 2 ([x] = visited, * = node being processed)
  [0]———[1]*
     \   /
      [2]———(3)      1 sees 2 already discovered by 0 → two routes to 2 → loop 0-1-2

Node 3 is never even discovered: we stop at the first cycle. Notice that (2, 0) is still waiting in the queue when 1 sees it; that's fine (see the doubt above).

Dry run 2: no cycle, two components

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

steppopped (node, parent)neighbours checkedqueue AFTERvisited afteranswer so far
0i = 0: startpush (0, −1), mark 0(0,−1)0–
1(0, −1)1: new → push · 2: new → push(1,0) (2,0)0, 1, 2–
2(1, 0)0: parent → skip(2,0)0, 1, 2–
3(2, 0)0: parent → skip · 3: new → push (3,2)(3,2)0 – 3–
4(3, 2)2: parent → skipempty0 – 3False (piece 1)
5i = 1, 2, 3visited → don't start–0 – 3–
6i = 4: start, pop (4, −1)no neighboursempty0 – 4False (piece 2)
7outer loop ends––allFalse
  level 0:  [0]
  level 1:  [1] [2]          every visited neighbour seen was the parent
  level 2:      [3]          → no second route anywhere → no cycle
  separate: [4]

9Complexity & remember

Remember Queue of (node, parent), start with (start, -1). Mark visited when pushing. Unvisited neighbour → mark + push (nb, node). Visited and ≠ parent → cycle. Outer loop for components.

Part B · Revision page

DFS (Problem 11)BFS (this page)
orderdeep along one path, then backlevel by level
pending work kept inthe call stacka deque
where the parent livesfunction argument dfs(node, parent)inside the queue pair (node, parent)
startdfs(i, -1)push (i, -1), mark i
visited markedwhen the call startswhen the node is pushed
cycle checkidentical: visited and not the parent → True
the "story"one walk bumps into an earlier nodetwo branches discover the same node
time / spaceO(V + 2E) / O(V + 2E)O(V + 2E) / O(V + 2E)
Python extraneeds a higher recursion limitno recursion at all
If you remember only 5 lines 1. Build the adjacency list both ways, visited of size V.
2. Outer loop: BFS from every unvisited node.
3. Queue holds (node, parent); start = (i, −1), marked at once.
4. New neighbour → mark + push (nb, node). Visited and ≠ parent → cycle.
5. O(V + 2E) time and space, same as DFS.
Mistakes to avoid ✗ pushing only the node and losing the parent
✗ forgetting to mark the start node before the loop
✗ pushing already-visited neighbours (infinite back-and-forth)
✗ "visited = cycle" without the parent check
✗ running BFS only from node 0 (misses other components)
✗ using this undirected rule on a directed graph (see Problem 12)
test it yourself (paste under the solution above)
s = Solution()
print(s.isCycle(4, [[0, 1], [0, 2], [1, 2], [2, 3]]))   # True
print(s.isCycle(5, [[0, 1], [0, 2], [2, 3]]))           # False
print(s.isCycle(2, [[0, 1]]))                           # False
print(s.isCycle(5, [[3, 4], [0, 1], [1, 2], [2, 0]]))   # True
print(s.isCycle(1, []))                                 # False

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