← All Posts
DSA Series · Graphs · Cycle Detection

Cycle Detection in Graphs

A cycle in a graph is a path that starts and ends at the same vertex, visiting at least one edge. Detecting cycles is one of the most fundamental graph problems — it appears in deadlock detection, dependency resolution, compiler analysis, and dozens of competitive programming problems. In this post we will cover every major technique: DFS with parent tracking for undirected graphs, the elegant 3-color DFS for directed graphs, Kahn’s algorithm (BFS topological sort), Union-Find, and more. Each method gets a detailed visual walkthrough, an interactive animation, clean C++ code, and a complexity analysis.

Why Cycle Detection Matters

Cycles show up everywhere in computing and real-world modeling:

Key insight: The technique you use depends on whether the graph is directed or undirected. Directed and undirected graphs have fundamentally different notions of cycles, and using the wrong algorithm gives incorrect results.

Types of Cycles

Cycles in Undirected Graphs

In an undirected graph, a cycle is a path of length ≥ 3 that starts and ends at the same vertex, without repeating any edge. The key subtlety: traversing an edge u—v and immediately going back v—u does not constitute a cycle. That’s why we track the parent node during DFS.

0 1 2 3 4

The dashed red edge 4—0 closes the cycle 0→1→2→3→4→0. Without that edge, the graph is a tree (no cycles).

Cycles in Directed Graphs

In a directed graph, a cycle requires that you follow edge directions. Even if nodes u and v are connected by edges u→v and v→w and w→u, the cycle only exists because each edge points in the right direction around the loop.

A B C

The directed cycle is A→B→C→A. If we reversed any one edge, the cycle would break.

Self-Loops & Back Edges

A self-loop is a trivial cycle: an edge from a vertex to itself. In DFS terminology, a back edge is an edge that connects a vertex to one of its ancestors in the DFS tree. Every cycle in a graph contains at least one back edge, and every back edge creates a cycle. This is the foundation of DFS-based cycle detection.

DFS Edge Classification (directed graph):
  • Tree edge: leads to an unvisited vertex (WHITE → discovered)
  • Back edge: leads to an ancestor currently on the recursion stack (GRAY vertex) — indicates a cycle
  • Forward edge: leads to a descendant already fully processed (BLACK)
  • Cross edge: leads to a vertex in a different subtree, already processed (BLACK)

Undirected Graph: DFS with Parent Tracking

The simplest cycle detection: run DFS and track each node’s parent. If DFS encounters a visited neighbor that is not the parent of the current node, we’ve found a cycle. Why? In an undirected graph, DFS explores a spanning tree. Any non-tree edge (one connecting to an already-visited, non-parent node) creates a cycle with tree edges.

function hasCycleUndirected(node, parent, visited, adj):
visited[node] = true
for neighbor in adj[node]:
if not visited[neighbor]:
  if hasCycleUndirected(neighbor, node, visited, adj): return true
else if neighbor ≠ parent:
  return true  // back edge found → cycle!
return false

Visual Walkthrough

Let’s trace DFS on this 6-node undirected graph starting from node 0. The graph has one cycle: 0—1—3—4—2—0.

Begin DFS(0, parent=-1)

All nodes start unvisited (white). We mark node 0 as visited (green fill) and explore its first neighbor: node 1.

0 1 2 3 4 5
DFS stack: [0]
Parent: 0→-1

DFS(1, parent=0) → DFS(3, parent=1)

Visit node 1 (parent=0), then explore its unvisited neighbor 3. Visit node 3 (parent=1). Node 3 has neighbors: 1 (parent, skip), 4 (unvisited), 5 (unvisited).

0 1 2 3 4 5
DFS stack: [0, 1, 3]
Parent: 1→0, 3→1

DFS(4, parent=3) — Explore Neighbor 2

From node 3, explore unvisited neighbor 4 (parent=3). Node 4’s neighbors: 3 (parent, skip), 2 (unvisited). Recurse into DFS(2, parent=4). Node 2 is now visited.

0 1 2 3 4 5
DFS stack: [0, 1, 3, 4, 2]
Parent: 4→3, 2→4

DFS(2): Neighbor 0 Is Visited & Not Parent — CYCLE!

Node 2 checks its neighbors. Neighbor 4 is the parent (skip). Neighbor 0 is already visited and is NOT node 2’s parent. This is a back edge — it closes the cycle 0→1→3→4→2→0.

0 1 2 3 4 5 🚨 Cycle detected: 0 → 1 → 3 → 4 → 2 → 0

The algorithm returns true immediately. Node 5 is never explored because we short-circuit on the first cycle found.

Why check neighbor ≠ parent? In an undirected graph, the edge u—v appears in both adjacency lists. When DFS goes from u to v, it will see u in v’s neighbor list. Without the parent check, every edge would be falsely reported as a cycle.

Directed Graph: 3-Color DFS

The parent-tracking trick does not work for directed graphs. Consider edges A→B and A→C→B. When DFS from A reaches B via C, it finds B already visited. Is it a cycle? No — B was visited via a different path, not an ancestor. We need a finer-grained approach: the 3-color method.

The Three Colors

WHITE — unvisited GRAY — in current DFS path (on recursion stack) BLACK — fully processed (all descendants explored)

The rule is simple: if DFS encounters a GRAY vertex, there is a cycle. A GRAY vertex is on the current path from the DFS root to the current node. An edge to a GRAY vertex is a back edge that closes a cycle. Edges to BLACK vertices are harmless (forward or cross edges).

function hasCycleDirected(node, color, adj):
color[node] = GRAY
for neighbor in adj[node]:
if color[neighbor] == GRAY:
  return true  // back edge → cycle!
if color[neighbor] == WHITE:
  if hasCycleDirected(neighbor, color, adj): return true
color[node] = BLACK
return false

Visual Walkthrough

Let’s trace the 3-color DFS on this 7-node directed graph. It contains the cycle 1→3→5→1.

Begin DFS(0): Color 0 GRAY

All nodes start WHITE. We color node 0 GRAY (orange stroke) and explore its first neighbor: node 1.

0 1 2 3 4 5 6
Recursion stack: [0]
GRAY: {0}

DFS(1): GRAY → DFS(3): GRAY

Color node 1 GRAY. Its neighbor is 3 (WHITE), so recurse. Color node 3 GRAY. Node 3’s neighbor is 5 (WHITE).

0 1 2 3 4 5 6
Recursion stack: [0, 1, 3]
GRAY: {0, 1, 3}

DFS(5): Color 5 GRAY — Check Neighbor 1

Color node 5 GRAY. Its first neighbor is node 1. We check: color[1] == GRAY! Node 1 is on the current recursion path. This edge 5→1 is a back edge.

0 1 2 3 4 5 6 🚨 Back edge 5→1 found! Cycle: 1 → 3 → 5 → 1

The 3-color method correctly identifies the cycle. Note that nodes 2, 4, and 6 were never visited — the algorithm short-circuits on the first back edge. Also note that if we had only used a simple visited[] boolean array (like the undirected method), DFS from node 0 going 0→2→4→6 would mark 6 as visited. Then later, DFS from 0→1→3→5→6 finds 6 visited — but there’s no cycle through 6! The 3 colors prevent this false positive.

Why not just 2 colors for directed graphs? Because a visited BLACK node might be reachable from multiple DFS paths without forming a cycle. GRAY specifically marks “currently on the DFS recursion stack,” which is exactly the condition for a back edge. This distinction is what makes the 3-color method correct.

Finding the Actual Cycle Path

Detecting that a cycle exists is often not enough — we need to print the cycle. We can reconstruct it using a parent array.

Parent Array Reconstruction

During DFS, maintain a parent[] array: parent[v] = u means we reached v from u. When we find a back edge v→u (where u is GRAY), the cycle is:

// Found back edge: current_node → gray_ancestor
function extractCycle(current_node, gray_ancestor, parent):
cycle = [gray_ancestor]
node = current_node
while node ≠ gray_ancestor:
cycle.append(node)
node = parent[node]
cycle.append(gray_ancestor)  // close the cycle
cycle.reverse()
return cycle

For our example, when we find the back edge 5→1:

For undirected graphs, the same technique works. When DFS at node u finds visited non-parent neighbor v, trace parent pointers from u back to v to reconstruct the cycle.

Cycle Detection via BFS (Kahn’s Algorithm)

Kahn’s Algorithm is a BFS-based approach for topological sorting. Its beautiful side effect: if the topological sort doesn’t include all nodes, the graph has a cycle.

The Idea

Kahn’s algorithm repeatedly removes nodes with in-degree 0 (no incoming edges). In a DAG, this process eventually removes all nodes. If there’s a cycle, the nodes in the cycle always have in-degree ≥ 1 (because of the cycle edges), so they can never be removed.

function hasCycleKahn(V, adj):
in_degree = [0] * V
for u in 0..V-1:
for v in adj[u]: in_degree[v] += 1

queue = [v for v if in_degree[v] == 0]
count = 0
while queue not empty:
u = queue.dequeue()
count += 1
for v in adj[u]:
  in_degree[v] -= 1
  if in_degree[v] == 0: queue.enqueue(v)
return count ≠ V  // true = cycle exists
When to prefer Kahn’s over DFS?
  • When you also need a topological ordering (Kahn’s gives you both)
  • In iterative environments where recursion depth is a concern (BFS avoids deep stacks)
  • When you need to identify which nodes are in cycles (they’re the ones not processed)

Quick Example

On our 7-node directed graph (with cycle 1→3→5→1), initial in-degrees are: 0:0, 1:1, 2:1, 3:1, 4:1, 5:1, 6:2. Kahn’s processes: node 0 (in-degree 0), then 2 (becomes 0), then 4 (becomes 0), then 6 (becomes 0). After processing 4 nodes, the queue is empty but count=4 ≠ 7. The remaining nodes {1, 3, 5} form the cycle and can never reach in-degree 0.

Cycle Detection with Union-Find (DSU)

For undirected graphs, the Disjoint Set Union (Union-Find) data structure provides an elegant alternative to DFS. The idea: process edges one by one. For each edge (u, v), check if u and v are already in the same connected component. If yes, adding this edge creates a cycle. If no, union their components.

function hasCycleDSU(V, edges):
parent = [0..V-1]  // each node is its own root
rank = [0] * V

for (u, v) in edges:
root_u = find(u)
root_v = find(v)
if root_u == root_v:
  return true  // u and v already connected → cycle!
union(root_u, root_v)
return false

Walkthrough

Using our 6-node undirected graph (edges: 0-1, 0-2, 1-3, 3-4, 2-4, 3-5):

Process edge 0—1

find(0)=0, find(1)=1. Different roots → union. Components: {0,1}, {2}, {3}, {4}, {5}.

Process edge 0—2

find(0)=0, find(2)=2. Different → union. Components: {0,1,2}, {3}, {4}, {5}.

Process edge 1—3

find(1)=0, find(3)=3. Different → union. Components: {0,1,2,3}, {4}, {5}.

Process edge 3—4

find(3)=0, find(4)=4. Different → union. Components: {0,1,2,3,4}, {5}.

Process edge 2—4 — CYCLE!

find(2)=0, find(4)=0. Same root! Nodes 2 and 4 are already connected. Adding this edge creates a cycle.

🚨 Cycle detected via Union-Find

Edge 3—5 (never reached)

The algorithm already returned true. Edge 3—5 would have been processed normally (no cycle).

Union-Find vs DFS for undirected graphs: Both are O(V+E) in practice (Union-Find is technically O(E · α(V)) with path compression + union by rank, where α is the inverse Ackermann function — effectively constant). Union-Find is particularly useful when edges arrive incrementally (online) and you need to check for cycles after each insertion. It also works naturally with Kruskal’s MST algorithm.

Interactive Animation: 3-Color DFS

Step through the 3-color DFS cycle detection on a directed graph. Watch nodes transition from WHITE to GRAY to BLACK, and see the back edge that reveals the cycle.

Use Step for manual control, Play for auto-advance, or Reset to start over.

0 1 2 3 4 5 6
WHITE GRAY BLACK Back Edge Target

C++ Implementations

Here are clean, competitive-programming-ready implementations of all three major approaches.

1. Undirected Graph — DFS with Parent Tracking

#include <bits/stdc++.h>
using namespace std;

class UndirectedCycle {
    int V;
    vector<vector<int>> adj;

    bool dfs(int u, int parent, vector<bool>& visited) {
        visited[u] = true;
        for (int v : adj[u]) {
            if (!visited[v]) {
                if (dfs(v, u, visited))
                    return true;
            } else if (v != parent) {
                return true;  // back edge found
            }
        }
        return false;
    }

public:
    UndirectedCycle(int V) : V(V), adj(V) {}

    void addEdge(int u, int v) {
        adj[u].push_back(v);
        adj[v].push_back(u);
    }

    bool hasCycle() {
        vector<bool> visited(V, false);
        for (int i = 0; i < V; i++) {
            if (!visited[i]) {
                if (dfs(i, -1, visited))
                    return true;
            }
        }
        return false;
    }
};

int main() {
    UndirectedCycle g(6);
    g.addEdge(0, 1); g.addEdge(0, 2);
    g.addEdge(1, 3); g.addEdge(3, 4);
    g.addEdge(2, 4); g.addEdge(3, 5);

    cout << (g.hasCycle() ? "Cycle found" : "No cycle") << endl;
    return 0;
}
Parallel edges caveat: If the graph can have multiple edges between the same pair of nodes, tracking just the parent node is insufficient. Instead, track the edge index used to reach the current node, and skip only that specific edge (not all edges to the parent).

2. Directed Graph — 3-Color DFS

#include <bits/stdc++.h>
using namespace std;

class DirectedCycle {
    int V;
    vector<vector<int>> adj;
    enum Color { WHITE, GRAY, BLACK };

    bool dfs(int u, vector<Color>& color, vector<int>& parent,
             int& cycle_start, int& cycle_end) {
        color[u] = GRAY;
        for (int v : adj[u]) {
            if (color[v] == GRAY) {
                cycle_start = v;
                cycle_end = u;
                return true;  // back edge: cycle!
            }
            if (color[v] == WHITE) {
                parent[v] = u;
                if (dfs(v, color, parent, cycle_start, cycle_end))
                    return true;
            }
        }
        color[u] = BLACK;
        return false;
    }

public:
    DirectedCycle(int V) : V(V), adj(V) {}

    void addEdge(int u, int v) {
        adj[u].push_back(v);
    }

    bool hasCycle() {
        vector<Color> color(V, WHITE);
        vector<int> parent(V, -1);
        int cycle_start = -1, cycle_end = -1;

        for (int i = 0; i < V; i++) {
            if (color[i] == WHITE) {
                if (dfs(i, color, parent, cycle_start, cycle_end)) {
                    // Reconstruct and print cycle
                    vector<int> cycle;
                    cycle.push_back(cycle_start);
                    for (int v = cycle_end; v != cycle_start; v = parent[v])
                        cycle.push_back(v);
                    cycle.push_back(cycle_start);
                    reverse(cycle.begin(), cycle.end());

                    cout << "Cycle: ";
                    for (int v : cycle) cout << v << " ";
                    cout << endl;
                    return true;
                }
            }
        }
        return false;
    }
};

int main() {
    DirectedCycle g(7);
    g.addEdge(0, 1); g.addEdge(0, 2);
    g.addEdge(1, 3); g.addEdge(2, 4);
    g.addEdge(3, 5); g.addEdge(4, 6);
    g.addEdge(5, 1); g.addEdge(5, 6);

    if (!g.hasCycle())
        cout << "No cycle" << endl;
    return 0;
}

Output:

Cycle: 1 3 5 1

3. Union-Find (DSU) for Undirected Graphs

#include <bits/stdc++.h>
using namespace std;

class DSU {
    vector<int> parent, rank_;
public:
    DSU(int n) : parent(n), rank_(n, 0) {
        iota(parent.begin(), parent.end(), 0);
    }

    int find(int x) {
        if (parent[x] != x)
            parent[x] = find(parent[x]);  // path compression
        return parent[x];
    }

    bool unite(int x, int y) {
        int rx = find(x), ry = find(y);
        if (rx == ry) return false;  // already same set
        if (rank_[rx] < rank_[ry]) swap(rx, ry);
        parent[ry] = rx;
        if (rank_[rx] == rank_[ry]) rank_[rx]++;
        return true;
    }
};

bool hasCycleDSU(int V, vector<pair<int,int>>& edges) {
    DSU dsu(V);
    for (auto& [u, v] : edges) {
        if (!dsu.unite(u, v))
            return true;  // u and v already connected
    }
    return false;
}

int main() {
    int V = 6;
    vector<pair<int,int>> edges = {
        {0,1}, {0,2}, {1,3}, {3,4}, {2,4}, {3,5}
    };

    cout << (hasCycleDSU(V, edges) ? "Cycle found" : "No cycle") << endl;
    return 0;
}

Output:

Cycle found

Complexity Analysis

Time Complexity

MethodTimeGraph TypeNotes
DFS (parent tracking)O(V + E)UndirectedSingle DFS pass
DFS (3-color)O(V + E)DirectedSingle DFS pass
Kahn’s (BFS)O(V + E)DirectedAlso gives topological order
Union-Find (DSU)O(E · α(V))Undirectedα ≈ constant; great for online queries

Space Complexity

All methods use O(V + E) space for the adjacency list plus O(V) for auxiliary data structures (visited array, color array, parent array, or DSU arrays). The DFS-based methods also use O(V) recursion stack space in the worst case (a chain graph).

Comparison of Methods

DFS (Parent / 3-Color)

  • Works for both directed & undirected
  • Can reconstruct cycle path
  • Simple to implement
  • Recursion depth can be O(V)
  • Natural for most problems

Kahn’s / Union-Find

  • Kahn’s: directed only; DSU: undirected only
  • Kahn’s gives topo-sort as bonus
  • DSU supports online edge insertion
  • Both are iterative (no stack overflow)
  • DSU is key for Kruskal’s MST

Competitive Programming Patterns

Cycle detection appears in many disguises in competitive programming. Here are the most common patterns:

1. Cycles in Permutations

A permutation P of [0..n-1] defines a functional graph: node i has exactly one outgoing edge to P[i]. Every functional graph decomposes into disjoint cycles. To find all cycles, iterate through each node, follow the chain i→P[i]→P[P[i]]→… until you return to the start.

// Count the number of cycles in a permutation
int countCycles(vector<int>& perm) {
    int n = perm.size(), cycles = 0;
    vector<bool> visited(n, false);
    for (int i = 0; i < n; i++) {
        if (!visited[i]) {
            cycles++;
            int j = i;
            while (!visited[j]) {
                visited[j] = true;
                j = perm[j];
            }
        }
    }
    return cycles;
}

Key fact: The minimum number of swaps to sort a permutation is n − (number of cycles). This is a classic interview question.

2. Floyd’s Tortoise & Hare (Functional Graphs)

In a functional graph (each node has exactly one outgoing edge, like a linked list that may loop), Floyd’s algorithm detects cycles in O(1) space using two pointers:

// Floyd's cycle detection in a functional graph
// f(x) returns the single neighbor of x
pair<int,int> floydCycle(int start, function<int(int)> f) {
    int slow = f(start), fast = f(f(start));
    while (slow != fast) {
        slow = f(slow);
        fast = f(f(fast));
    }
    // Find cycle entry
    slow = start;
    while (slow != fast) {
        slow = f(slow);
        fast = f(fast);
    }
    // Find cycle length
    int len = 1;
    int p = f(slow);
    while (p != slow) { p = f(p); len++; }
    return {slow, len};  // {entry_node, cycle_length}
}

This is the technique behind LeetCode’s Linked List Cycle II and Find the Duplicate Number.

3. Negative Cycles (Bellman-Ford)

The Bellman-Ford algorithm detects negative-weight cycles in weighted directed graphs. After V-1 relaxation passes, if any edge can still be relaxed, there exists a negative cycle reachable from the source.

// Returns true if a negative cycle is reachable from source
bool hasNegativeCycle(int V, vector<tuple<int,int,int>>& edges, int src) {
    vector<long long> dist(V, 1e18);
    dist[src] = 0;

    for (int i = 0; i < V - 1; i++)
        for (auto& [u, v, w] : edges)
            if (dist[u] < 1e18 && dist[u] + w < dist[v])
                dist[v] = dist[u] + w;

    // V-th pass: if any edge relaxes, negative cycle exists
    for (auto& [u, v, w] : edges)
        if (dist[u] < 1e18 && dist[u] + w < dist[v])
            return true;
    return false;
}

This is used in currency arbitrage detection, shortest-path problems with negative weights, and problems like SPOJ NEGCYC and Codeforces problems involving Bellman-Ford.

4. Cycle Parity & Bipartiteness

An undirected graph is bipartite if and only if it contains no odd-length cycle. You can check bipartiteness using a 2-coloring BFS/DFS, which is essentially cycle detection with a twist — you’re detecting cycles of odd length.

Practice Problems

Test your understanding with these problems, ordered roughly by difficulty:

Course Schedule (LeetCode 207)

Classic cycle detection in a directed graph. Given prerequisites, determine if all courses can be finished. Direct application of 3-color DFS or Kahn’s algorithm.

Course Schedule II (LeetCode 210)

Extension of the above: return a valid ordering or empty if impossible. Kahn’s algorithm is ideal here since it gives topological order and detects cycles simultaneously.

Find the Duplicate Number (LeetCode 287)

Use Floyd’s tortoise and hare algorithm to find the cycle entry in a functional graph defined by array indices.

Redundant Connection (LeetCode 684)

Find the edge that creates a cycle in an undirected graph. Perfect use case for Union-Find: the first edge whose endpoints are already in the same component is the answer.

Cyclic Components (Codeforces 977E)

Count connected components where every node has exactly degree 2 (i.e., each component is a simple cycle). Combines cycle detection with degree analysis.

Redundant Connection II (LeetCode 685)

Directed version: find the edge to remove to make a rooted tree. Requires handling two cases: a node with two parents, or a cycle. Combines Union-Find with careful case analysis.

Pure (AtCoder ABC142 F)

Find a directed cycle with no chords (a cycle where no shortcut edges exist between non-adjacent cycle nodes). Requires finding a cycle, then pruning it to remove chord edges.

Directing Edges (Codeforces 1385E)

Given a mixed graph (some directed, some undirected edges), orient the undirected edges so that the result is a DAG. If impossible, output “NO.” Requires topological sort + cycle detection.

Summary

Let’s recap the key takeaways:

  1. Undirected graphs: Use DFS with parent tracking or Union-Find. A visited non-parent neighbor means a cycle.
  2. Directed graphs: Use the 3-color DFS method. An edge to a GRAY node is a back edge that indicates a cycle. Alternatively, use Kahn’s algorithm — if topological sort can’t process all nodes, there’s a cycle.
  3. Cycle reconstruction: Maintain a parent array during DFS. When a back edge is found, trace parent pointers to reconstruct the cycle path.
  4. Union-Find is ideal for undirected graphs, especially with online edge insertion (Kruskal’s, incremental connectivity).
  5. Floyd’s algorithm detects cycles in functional graphs (each node has out-degree 1) using O(1) extra space.
  6. Bellman-Ford detects negative-weight cycles in weighted directed graphs.
  7. All DFS/BFS methods run in O(V + E) time. Union-Find runs in O(E · α(V)) ≈ O(E).
Practice tip: Most cycle detection problems in competitive programming boil down to “is this a DAG?” Master 3-color DFS and Kahn’s algorithm, and you’ll handle 90% of cases. For the remaining 10% (functional graphs, negative cycles), keep Floyd’s and Bellman-Ford in your toolkit.