DSACycle Detection

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

JS
/**
 * 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)
Note
The `parent` check only works correctly for **simple graphs** (no self-loops, no multi-edges). Self-loops are trivially detected before DFS (any edge [u,u] immediately means a cycle).
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

JS
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]]))        // false
Tip
Union-Find is preferred in competitive programming for undirected graphs because it processes edges in O(α(n)) amortized time per edge and is iterative — no recursion stack overflow risk.
Cycle 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

JS
/**
 * 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]])) // false
Warning
A common mistake is using a plain `visited` boolean for directed graphs. A node can be `visited` from a previous DFS call (black) without being part of the current path. Only **gray** nodes confirm a cycle.
Cycle 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

JS
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)
Note
Kahn's algorithm is especially useful when you need both cycle detection AND the topological order in the same pass, for example in course-schedule problems.
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

JS
class ListNode {
  constructor(val, next = null) {
    this.val  = val
    this.next = next
  }
}

LeetCode 141 — Linked List Cycle (detect only)

JS
/**
 * @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)) // true
Finding 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)

JS
/**
 * @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) // 3
Finding 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

JS
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

JS
// 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]])) // true

Problem 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

JS
/**
 * @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

JS
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

Tip
For interview purposes, always clarify whether the graph is directed or undirected before writing code — the algorithms are fundamentally different. Mention time and space complexity and whether your approach handles disconnected graphs (multiple connected components).