Skip to content

Cycle Detection in Graphs ​

Finding cycles in directed and undirected graphs


Overview ​

Cycle detection is a fundamental graph problem with different approaches depending on whether the graph is directed or undirected.

Graph TypeRecommended Approach
UndirectedUnion-Find or DFS with parent tracking
DirectedDFS with three-color marking (white/gray/black)

Visual Overview ​

Cycle Detection Visualization


Part 1: Undirected Graph Cycle Detection ​

Problem ​

Given an undirected graph, determine if it contains a cycle.

Example ​

Input: n = 5, edges = [[0,1], [1,2], [2,3], [3,0], [0,4]]
Output: true

Graph:
  4
  |
  0 --- 1
  |     |
  3 --- 2

Cycle: 0 -> 1 -> 2 -> 3 -> 0

Approach 1: Union-Find ​

Key Insight: If an edge connects two nodes that are already in the same connected component, adding that edge creates a cycle.

python
class UnionFind:
    def __init__(self, n):
        self.parent = list(range(n))
        self.rank = [0] * n

    def find(self, x):
        if self.parent[x] != x:
            self.parent[x] = self.find(self.parent[x])  # Path compression
        return self.parent[x]

    def union(self, x, y):
        px, py = self.find(x), self.find(y)
        if px == py:
            return False  # Already connected = cycle!

        # Union by rank
        if self.rank[px] < self.rank[py]:
            px, py = py, px
        self.parent[py] = px
        if self.rank[px] == self.rank[py]:
            self.rank[px] += 1
        return True

def hasCycleUndirected(n: int, edges: list[list[int]]) -> bool:
    uf = UnionFind(n)

    for u, v in edges:
        if not uf.union(u, v):
            return True  # Cycle detected!

    return False

Complexity: Time O(E * alpha(N)) · Space O(N)

  • Time: Each edge is processed once; Union and Find operations take O(alpha(N)) amortized due to path compression and union by rank
  • Space: Parent and rank arrays each require O(N) space for N nodes

Approach 2: DFS with Parent Tracking ​

Key Insight: In DFS, if we encounter a visited node that is NOT our parent, we've found a cycle.

python
from collections import defaultdict

def hasCycleUndirected(n: int, edges: list[list[int]]) -> bool:
    # Build adjacency list
    graph = defaultdict(list)
    for u, v in edges:
        graph[u].append(v)
        graph[v].append(u)

    visited = set()

    def dfs(node: int, parent: int) -> bool:
        """Returns True if cycle detected."""
        visited.add(node)

        for neighbor in graph[node]:
            if neighbor == parent:
                continue  # Skip edge we came from

            if neighbor in visited:
                return True  # Back edge = cycle!

            if dfs(neighbor, node):
                return True

        return False

    # Check all components (graph may be disconnected)
    for node in range(n):
        if node not in visited:
            if dfs(node, -1):
                return True

    return False

Complexity: Time O(V + E) · Space O(V + E)

  • Time: Each node visited once, each edge examined twice (once from each endpoint in undirected graph)
  • Space: Visited set O(V), adjacency list O(E), recursion stack up to O(V) depth

Why Parent Tracking Matters ​

In undirected graphs, each edge appears twice in the adjacency list (once for each direction). Without parent tracking:

Edge: 0 -- 1
Adjacency list: {0: [1], 1: [0]}

DFS from 0:
  Visit 0, mark visited
  Go to neighbor 1
  Visit 1, mark visited
  Go to neighbor 0
  0 is visited! Is this a cycle?
  NO - this is just the edge we came from!

Part 2: Directed Graph Cycle Detection ​

Problem ​

Given a directed graph, determine if it contains a cycle.

Example ​

Input: n = 4, edges = [[0,1], [1,2], [2,3], [3,1]]
Output: true

Graph:
  0 --> 1 --> 2
        ^     |
        |     v
        +--3--+

Cycle: 1 -> 2 -> 3 -> 1

Approach: DFS with Three Colors ​

States:

  • White (0): Unvisited
  • Gray (1): Currently in recursion stack (being processed)
  • Black (2): Completely processed

Key Insight: If we encounter a gray node, we've found a back edge (cycle).

python
from collections import defaultdict

def hasCycleDirected(n: int, edges: list[list[int]]) -> bool:
    # Build adjacency list
    graph = defaultdict(list)
    for u, v in edges:
        graph[u].append(v)

    # 0 = white (unvisited), 1 = gray (in progress), 2 = black (done)
    color = [0] * n

    def dfs(node: int) -> bool:
        """Returns True if cycle detected."""
        color[node] = 1  # Mark as in progress (gray)

        for neighbor in graph[node]:
            if color[neighbor] == 1:
                return True  # Back edge to node in current path = cycle!
            if color[neighbor] == 0:
                if dfs(neighbor):
                    return True

        color[node] = 2  # Mark as done (black)
        return False

    # Check all nodes (graph may be disconnected)
    for node in range(n):
        if color[node] == 0:
            if dfs(node):
                return True

    return False
java
public boolean hasCycleDirected(int n, int[][] edges) {
    List<List<Integer>> graph = new ArrayList<>();
    for (int i = 0; i < n; i++) graph.add(new ArrayList<>());
    for (int[] e : edges) graph.get(e[0]).add(e[1]);

    // 0 = white, 1 = gray, 2 = black
    int[] color = new int[n];

    for (int node = 0; node < n; node++)
        if (color[node] == 0 && dfsDirected(graph, color, node)) return true;

    return false;
}

private boolean dfsDirected(List<List<Integer>> graph, int[] color, int node) {
    color[node] = 1;
    for (int neighbor : graph.get(node)) {
        if (color[neighbor] == 1) return true;
        if (color[neighbor] == 0 && dfsDirected(graph, color, neighbor)) return true;
    }
    color[node] = 2;
    return false;
}

Complexity: Time O(V + E) · Space O(V + E)

  • Time: Each node colored once (white to gray to black), each directed edge followed once
  • Space: Color array O(V), adjacency list O(E), recursion stack up to O(V) for long paths

Alternative: Using Two Sets ​

python
def hasCycleDirected(n: int, edges: list[list[int]]) -> bool:
    graph = defaultdict(list)
    for u, v in edges:
        graph[u].append(v)

    visited = set()      # All visited nodes
    rec_stack = set()    # Nodes in current recursion path

    def dfs(node: int) -> bool:
        visited.add(node)
        rec_stack.add(node)

        for neighbor in graph[node]:
            if neighbor in rec_stack:
                return True  # Cycle!
            if neighbor not in visited:
                if dfs(neighbor):
                    return True

        rec_stack.remove(node)  # Backtrack
        return False

    for node in range(n):
        if node not in visited:
            if dfs(node):
                return True

    return False

Complexity: Time O(V + E) · Space O(V)

  • Time: Same as three-color approach; each vertex and edge processed once
  • Space: Two sets (visited, rec_stack) each up to O(V), plus recursion stack O(V)

Why Directed Graphs Need Different Approach ​

Directed graph:
  0 --> 1 --> 2
        ^
        |
        3

With edges: 0->1, 1->2, 3->1

DFS from 0:
  Visit 0 (gray)
  Visit 1 (gray)
  Visit 2 (gray)
  2 has no neighbors, mark black
  Back to 1, mark black
  Back to 0, mark black

DFS from 3:
  Visit 3 (gray)
  Neighbor 1 is BLACK (not gray)
  This is NOT a cycle - 1 was fully processed

If we used simple visited set (like undirected), we'd incorrectly report a cycle when visiting node 1 from node 3.


Cycle Detection Using Topological Sort ​

If a directed graph has a valid topological ordering, it has NO cycles (it's a DAG).

python
from collections import defaultdict, deque

def hasCycleTopological(n: int, edges: list[list[int]]) -> bool:
    graph = defaultdict(list)
    in_degree = [0] * n

    for u, v in edges:
        graph[u].append(v)
        in_degree[v] += 1

    # Kahn's algorithm
    queue = deque([i for i in range(n) if in_degree[i] == 0])
    processed = 0

    while queue:
        node = queue.popleft()
        processed += 1

        for neighbor in graph[node]:
            in_degree[neighbor] -= 1
            if in_degree[neighbor] == 0:
                queue.append(neighbor)

    # If not all nodes processed, cycle exists
    return processed != n

Complexity: Time O(V + E) · Space O(V + E)

  • Time: Building graph O(E), computing in-degrees O(V), BFS processes each node and edge once
  • Space: Adjacency list O(E), in-degree array O(V), queue up to O(V) nodes

Finding the Cycle (Not Just Detecting) ​

Undirected: Return Cycle Nodes ​

python
def findCycleUndirected(n: int, edges: list[list[int]]) -> list[int]:
    graph = defaultdict(list)
    for u, v in edges:
        graph[u].append(v)
        graph[v].append(u)

    visited = set()
    parent = {}

    cycle_end = -1

    def dfs(node: int, par: int) -> int:
        """Returns the node where cycle starts, or -1."""
        nonlocal cycle_end
        visited.add(node)
        parent[node] = par

        for neighbor in graph[node]:
            if neighbor == par:
                continue
            if neighbor in visited:
                cycle_end = node
                return neighbor  # Cycle found!
            result = dfs(neighbor, node)
            if result != -1:
                return result

        return -1

    for node in range(n):
        if node not in visited:
            cycle_start = dfs(node, -1)
            if cycle_start != -1:
                # Reconstruct cycle by backtracking from cycle_end via parent
                cycle = [cycle_start]
                current = cycle_end
                while current != cycle_start:
                    cycle.append(current)
                    current = parent[current]
                cycle.append(cycle_start)
                return cycle[::-1]

    return []

Complexity: Time O(V + E) · Space O(V)

  • Time: DFS traversal O(V + E), path reconstruction O(cycle length) in worst case O(V)
  • Space: Visited set O(V), parent dictionary O(V), recursion stack O(V)

Directed: Return Cycle Nodes ​

python
def findCycleDirected(n: int, edges: list[list[int]]) -> list[int]:
    graph = defaultdict(list)
    for u, v in edges:
        graph[u].append(v)

    color = [0] * n
    parent = [-1] * n
    cycle_start = -1
    cycle_end = -1

    def dfs(node: int) -> bool:
        nonlocal cycle_start, cycle_end
        color[node] = 1

        for neighbor in graph[node]:
            if color[neighbor] == 1:
                cycle_start = neighbor
                cycle_end = node
                return True
            if color[neighbor] == 0:
                parent[neighbor] = node
                if dfs(neighbor):
                    return True

        color[node] = 2
        return False

    for node in range(n):
        if color[node] == 0:
            if dfs(node):
                # Reconstruct cycle
                cycle = [cycle_start]
                current = cycle_end
                while current != cycle_start:
                    cycle.append(current)
                    current = parent[current]
                cycle.append(cycle_start)
                return cycle[::-1]

    return []

Complexity: Time O(V + E) · Space O(V + E)

  • Time: DFS traversal O(V + E), cycle reconstruction by backtracking through parent pointers O(V)
  • Space: Color and parent arrays O(V), adjacency list O(E), recursion stack O(V)

Comparison Summary ​

AspectUndirectedDirected
Union-FindWorksNot recommended
DFS with parentWorksNot sufficient
DFS with colorsOverkillRequired
Topological sortN/AWorks (DAG check)
Key conditionVisited AND not parentGray (in recursion stack)

LeetCode Problems ​

ProblemTypeKey Concept
Graph Valid Tree (261)UndirectedNo cycle + connected
Course Schedule (207)DirectedCycle = can't complete
Course Schedule II (210)DirectedTopological sort if no cycle
Redundant Connection (684)UndirectedFind cycle-causing edge
Redundant Connection II (685)DirectedMore complex variant

Interview Tips ​

  1. Clarify graph type first: Directed or undirected?
  2. Choose appropriate method:
    • Undirected: Union-Find is elegant and efficient
    • Directed: DFS with colors is the standard approach
  3. Handle disconnected graphs: Always loop through all nodes
  4. Explain the "why": Why parent tracking for undirected, why colors for directed

Common Follow-ups ​

  • Q: Find the cycle, not just detect it?

    • A: Track parent pointers and backtrack when cycle found
  • Q: What if there are multiple cycles?

    • A: Return first found, or modify to find all
  • Q: What about weighted graphs?

    • A: Cycle detection logic is the same, weights don't matter

References ​