Cycle Detection
A cycle exists in a graph when you can start at a node and follow edges back to the same node. Detecting cycles is a fundamental problem that appears in dependency resolution, deadlock detection, scheduling, and linked-list problems. The technique you use depends on whether the graph is directed or undirected, and whether you are working with a graph or a linked list.
Context | Best Algorithm | Time | Space |
|---|---|---|---|
Undirected graph | DFS + parent tracking | O(V+E) | O(V) |
Undirected graph (union-find) | Union-Find (DSU) | O(E·α(V)) | O(V) |
Directed graph | DFS + 3-color marking | O(V+E) | O(V) |
Directed graph (no DFS) | Kahn's algorithm (topo sort) | O(V+E) | O(V) |
Linked list | Floyd's tortoise and hare | O(n) | O(1) |
Cycle Detection in Undirected Graphs — DFS
In an undirected graph, each edge is bidirectional. When doing DFS, if you reach a node that is already visited and it is not the parent of the current node, you have found a back edge — a cycle. Tracking the parent prevents falsely flagging the edge you just came from.
Mark the current node as visited.
For each neighbor, skip the parent node (the edge you arrived on).
If the neighbor is already visited, a cycle exists.
Otherwise recurse into the unvisited neighbor.
Undirected cycle detection — DFS with parent tracking
/**
* Detect cycle in an undirected graph (adjacency list).
* @param {number} n — number of nodes (0-indexed)
* @param {number[][]} edges — list of [u, v] pairs
* @returns {boolean}
*/
function hasCycleUndirected(n, edges) {
// Build adjacency list
const adj = Array.from({ length: n }, () => [])
for (const [u, v] of edges) {
adj[u].push(v)
adj[v].push(u)
}
const visited = new Array(n).fill(false)
function dfs(node, parent) {
visited[node] = true
for (const neighbor of adj[node]) {
if (!visited[neighbor]) {
if (dfs(neighbor, node)) return true
} else if (neighbor !== parent) {
// Visited neighbor that is NOT the parent → back edge → cycle
return true
}
}
return false
}
// Check every connected component
for (let i = 0; i < n; i++) {
if (!visited[i]) {
if (dfs(i, -1)) return true
}
}
return false
}
// Example
console.log(hasCycleUndirected(4, [[0,1],[1,2],[2,3],[3,1]])) // true (1-2-3-1)
console.log(hasCycleUndirected(4, [[0,1],[1,2],[2,3]])) // false (linear chain)Undirected Cycle Detection — Union-Find (DSU)
Union-Find is an alternative that avoids recursion depth issues on large graphs. The idea: process edges one by one. For each edge (u, v), check whether u and v already share the same component (same root). If they do, adding this edge would create a cycle. Otherwise, merge the components.
Undirected cycle detection — Union-Find
class UnionFind {
constructor(n) {
this.parent = Array.from({ length: n }, (_, i) => i)
this.rank = new Array(n).fill(0)
}
find(x) {
// Path compression
if (this.parent[x] !== x) {
this.parent[x] = this.find(this.parent[x])
}
return this.parent[x]
}
union(x, y) {
const px = this.find(x)
const py = this.find(y)
if (px === py) return false // Already same component → cycle!
// Union by rank
if (this.rank[px] < this.rank[py]) this.parent[px] = py
else if (this.rank[px] > this.rank[py]) this.parent[py] = px
else { this.parent[py] = px; this.rank[px]++ }
return true
}
}
function hasCycleUnionFind(n, edges) {
const uf = new UnionFind(n)
for (const [u, v] of edges) {
if (!uf.union(u, v)) return true // union failed → same root → cycle
}
return false
}
console.log(hasCycleUnionFind(4, [[0,1],[1,2],[2,3],[3,1]])) // true
console.log(hasCycleUnionFind(4, [[0,1],[1,2],[2,3]])) // falseCycle Detection in Directed Graphs — 3-Color DFS
Directed graphs require a different approach. An undirected-style parent check is insufficient because a node can be reachable via multiple paths. The classic solution uses three states per node:
Color | Meaning | Implementation |
|---|---|---|
White (0) | Not yet visited | color[node] === 0 |
Gray (1) | Currently being processed — on the DFS stack | color[node] === 1 |
Black (2) | Fully processed — all descendants explored | color[node] === 2 |
If during DFS we encounter a gray node (currently on the recursion stack), we found a back edge — which means there is a cycle. Black nodes are safe to skip: their subtree has already been fully explored without finding a cycle.
Directed cycle detection — 3-color DFS
/**
* Detect cycle in a directed graph.
* @param {number} n — number of nodes (0-indexed)
* @param {number[][]} edges — list of directed edges [from, to]
* @returns {boolean}
*/
function hasCycleDirected(n, edges) {
const adj = Array.from({ length: n }, () => [])
for (const [u, v] of edges) {
adj[u].push(v)
}
// 0 = white (unvisited), 1 = gray (in stack), 2 = black (done)
const color = new Array(n).fill(0)
function dfs(node) {
color[node] = 1 // Mark gray — we are processing this node
for (const neighbor of adj[node]) {
if (color[neighbor] === 1) {
// Reached a gray node → back edge → cycle detected
return true
}
if (color[neighbor] === 0) {
// White → recurse
if (dfs(neighbor)) return true
}
// Black → already fully explored, skip safely
}
color[node] = 2 // Mark black — done with this node
return false
}
for (let i = 0; i < n; i++) {
if (color[i] === 0) {
if (dfs(i)) return true
}
}
return false
}
// A → B → C → A (cycle)
console.log(hasCycleDirected(3, [[0,1],[1,2],[2,0]])) // true
// A → B → C (no cycle)
console.log(hasCycleDirected(3, [[0,1],[1,2]])) // false
// A → B → C, A → C (diamond, no cycle)
console.log(hasCycleDirected(3, [[0,1],[1,2],[0,2]])) // falseCycle Detection via Topological Sort — Kahn's Algorithm
Kahn's algorithm computes a topological ordering by repeatedly removing nodes with in-degree 0. If the graph is a DAG (no cycles), every node will eventually be removed. If any nodes remain after the algorithm finishes, those nodes are part of a cycle — they could never reach in-degree 0 because their cycle edges kept their counts above zero.
Cycle detection via Kahn's algorithm
function hasCycleKahn(n, edges) {
const adj = Array.from({ length: n }, () => [])
const inDeg = new Array(n).fill(0)
for (const [u, v] of edges) {
adj[u].push(v)
inDeg[v]++
}
// Enqueue all nodes with in-degree 0
const queue = []
for (let i = 0; i < n; i++) {
if (inDeg[i] === 0) queue.push(i)
}
let processed = 0
while (queue.length > 0) {
const node = queue.shift()
processed++
for (const neighbor of adj[node]) {
inDeg[neighbor]--
if (inDeg[neighbor] === 0) queue.push(neighbor)
}
}
// If we could not process all nodes, a cycle exists
return processed !== n
}
console.log(hasCycleKahn(3, [[0,1],[1,2],[2,0]])) // true (cycle)
console.log(hasCycleKahn(4, [[0,1],[1,2],[2,3]])) // false (DAG)Floyd's Cycle Detection (Linked List) — Tortoise and Hare
Floyd's algorithm uses two pointers moving at different speeds through a linked list. The slow pointer moves one step at a time; the fast pointer moves two. If a cycle exists, the fast pointer will eventually "lap" the slow pointer and they will meet inside the cycle. If fast reaches null, there is no cycle.
Why they always meet inside the cycle: Once both pointers enter the cycle, consider the distance between them. Each step, the fast pointer closes the gap by 1 (it gains 2 steps, slow gains 1). Eventually the gap reaches 0 and they meet. The meeting is guaranteed in at most cycle_length steps after both enter the cycle.
Linked list node definition
class ListNode {
constructor(val, next = null) {
this.val = val
this.next = next
}
}LeetCode 141 — Linked List Cycle (detect only)
/**
* @param {ListNode} head
* @returns {boolean}
*/
function hasCycle(head) {
let slow = head
let fast = head
while (fast !== null && fast.next !== null) {
slow = slow.next // 1 step
fast = fast.next.next // 2 steps
if (slow === fast) return true // They met → cycle exists
}
return false // fast reached end → no cycle
}
// Build a list: 1 → 2 → 3 → 4 → 2 (cycle back to node 2)
const n1 = new ListNode(1)
const n2 = new ListNode(2)
const n3 = new ListNode(3)
const n4 = new ListNode(4)
n1.next = n2; n2.next = n3; n3.next = n4; n4.next = n2 // cycle
console.log(hasCycle(n1)) // trueFinding the Cycle Start — Mathematical Derivation
Once the meeting point is found, we can locate the exact node where the cycle begins with the following insight:
Let F = distance from head to the cycle start.
Let C = length of the cycle.
Let k = distance from cycle start to the meeting point (inside the cycle).
When they meet: slow traveled F + k steps. Fast traveled F + k + m·C steps (m full extra laps).
Since fast moves twice as fast: 2(F + k) = F + k + m·C → F + k = m·C → F = m·C − k.
If we now move one pointer to head and keep the other at the meeting point, both moving 1 step at a time, they meet exactly at the cycle start after F steps.
LeetCode 142 — Linked List Cycle II (find start node)
/**
* @param {ListNode} head
* @returns {ListNode|null} — the node where the cycle begins, or null
*/
function detectCycle(head) {
let slow = head
let fast = head
// Phase 1: find meeting point inside the cycle
while (fast !== null && fast.next !== null) {
slow = slow.next
fast = fast.next.next
if (slow === fast) break
}
// No cycle (fast hit null)
if (fast === null || fast.next === null) return null
// Phase 2: find cycle start
// Move one pointer back to head; keep the other at meeting point.
// Both advance 1 step — they meet at the cycle start.
let pointer1 = head
let pointer2 = slow // meeting point
while (pointer1 !== pointer2) {
pointer1 = pointer1.next
pointer2 = pointer2.next
}
return pointer1 // cycle start node
}
// 1 → 2 → 3 → 4 → 5 → 3 (cycle at node 3)
const a = new ListNode(1)
const b = new ListNode(2)
const c = new ListNode(3)
const d = new ListNode(4)
const e = new ListNode(5)
a.next = b; b.next = c; c.next = d; d.next = e; e.next = c
const start = detectCycle(a)
console.log(start?.val) // 3Finding the Cycle Length
After finding any node inside the cycle (the meeting point from Phase 1), simply keep one pointer stationary and advance the other until it returns to the same node. Count the steps.
Find cycle length after detecting meeting point
function cycleLength(meetingNode) {
let current = meetingNode.next
let length = 1
while (current !== meetingNode) {
current = current.next
length++
}
return length
}Classic Problems
Problem 1: Detect Cycle in Undirected Graph (LeetCode 684-style) Given n nodes and a list of edges, return true if the graph contains a cycle. Use Union-Find for clean O(E·α(V)) solution.
Detect cycle — undirected graph
// Uses Union-Find (see full implementation above)
function solve(n, edges) {
return hasCycleUnionFind(n, edges)
}
console.log(solve(5, [[0,1],[1,2],[3,4],[1,3],[2,4]])) // trueProblem 2: Detect Cycle in Directed Graph Return true if a directed graph (given as adjacency list) has a cycle. Use 3-color DFS. This is the exact pattern used for Course Schedule below.
Problem 3: Course Schedule (LeetCode 207) There are numCourses courses (0 to n-1). prerequisites[i] = [a, b] means you must take course b before a. Return true if you can finish all courses — i.e., the prerequisite graph is a DAG (no cycles).
LeetCode 207 — Course Schedule
/**
* @param {number} numCourses
* @param {number[][]} prerequisites
* @returns {boolean}
*/
function canFinish(numCourses, prerequisites) {
const adj = Array.from({ length: numCourses }, () => [])
const color = new Array(numCourses).fill(0) // 0=white, 1=gray, 2=black
for (const [course, pre] of prerequisites) {
adj[pre].push(course) // pre → course
}
function dfs(node) {
color[node] = 1 // Mark as being visited (gray)
for (const next of adj[node]) {
if (color[next] === 1) return false // Back edge → cycle → can't finish
if (color[next] === 0 && !dfs(next)) return false
}
color[node] = 2 // Fully processed (black)
return true
}
for (let i = 0; i < numCourses; i++) {
if (color[i] === 0 && !dfs(i)) return false
}
return true
}
console.log(canFinish(2, [[1,0]])) // true (0→1, no cycle)
console.log(canFinish(2, [[1,0],[0,1]])) // false (0→1→0, cycle)Problem 4: Course Schedule II (LeetCode 210) — Return the order Same setup, but return the topological order. If a cycle exists, return []. Use Kahn's algorithm to get both detection and order in one pass.
LeetCode 210 — Course Schedule II
function findOrder(numCourses, prerequisites) {
const adj = Array.from({ length: numCourses }, () => [])
const inDeg = new Array(numCourses).fill(0)
for (const [course, pre] of prerequisites) {
adj[pre].push(course)
inDeg[course]++
}
const queue = []
for (let i = 0; i < numCourses; i++) {
if (inDeg[i] === 0) queue.push(i)
}
const order = []
while (queue.length > 0) {
const cur = queue.shift()
order.push(cur)
for (const next of adj[cur]) {
inDeg[next]--
if (inDeg[next] === 0) queue.push(next)
}
}
return order.length === numCourses ? order : []
}
console.log(findOrder(4, [[1,0],[2,0],[3,1],[3,2]])) // [0,1,2,3] or [0,2,1,3]
console.log(findOrder(2, [[0,1],[1,0]])) // [] (cycle)Summary: Choosing the Right Algorithm
Scenario | Recommended Approach | Why |
|---|---|---|
Undirected graph — one-shot | Union-Find | Iterative, O(1) extra per edge, no recursion limit |
Undirected graph — need path info | DFS + parent tracking | Can reconstruct the cycle path |
Directed graph | 3-color DFS | Distinguishes back edges from cross/forward edges |
Directed graph + need order | Kahn's algorithm | Single pass gives both detection and topological sort |
Linked list — O(1) space required | Floyd's tortoise & hare | Only two pointers, no hash set needed |
Linked list — find start node | Floyd's Phase 2 | Mathematical guarantee using F = mC − k |