Cycle detection in directed graphs is a fundamental algorithmic problem with applications in dependency resolution, task scheduling, and deadlock detection. This solution presents a robust DFS-based approach with clear time complexity analysis and practical implementation considerations.
1. Pattern Restatement
Clarifying questions to ask the interviewer:
- Are we dealing with a directed graph represented as adjacency list, adjacency matrix, or edge list?
- Can the input graph be modified (e.g., nodes can be marked) or must we work with a read-only view?
- What is the expected return format - just a boolean indicating cycle existence, or do we need to identify the specific cycle?
- Are there constraints on node values (e.g., node IDs are integers from 0 to n-1)?
- What is the target time complexity for follow-up - O(V+E) is expected but what if they want better?
Restatement of the pattern: Cycle detection in directed graphs is a fundamental graph algorithm that determines whether a directed graph contains any cycles (directed loops). The problem involves analyzing the structure of a directed graph to identify if there exists a path that starts and ends at the same node, forming a closed loop.
Signal words and constraint shape: Key indicators include "directed graph", "cycles", "loops", "deadlock detection", "topological sorting prerequisites", and problems that involve dependencies or ordering constraints. The constraint shape typically involves directed edges between nodes where the direction matters for cycle formation.
Assumptions:
- Graph is represented as a collection of nodes with directed edges
- Nodes can be identified by unique identifiers (integers or objects)
- We're looking for any cycle in the graph, not just specific types of cycles
- The graph may be disconnected (multiple components)
Scenario suitability: This pattern is best suited for static graph analysis scenarios where the complete graph structure is available. It's not ideal for streaming graphs where new edges arrive continuously, though it can be adapted for incremental cycle detection in some cases. It's particularly useful in dependency resolution, topological sorting, and deadlock detection applications.
The core insight is that a directed graph has a cycle if and only if there exists a back edge in its DFS traversal, which creates a path from a node to one of its ancestors in the DFS tree.
2. When to Use This Pattern
Signal words and constraint shapes:
- "Find if a cycle exists in a graph"
- "Detect loops in dependencies"
- "Check for circular references"
- Graphs with directed edges where nodes can point to other nodes
- Problems asking whether a topological sort is possible
- "Course prerequisites with circular dependencies"
- "Task scheduling with interdependent tasks"
Why this pattern fits better than alternatives: This pattern is specifically designed for detecting cycles in directed graphs, which is fundamentally different from undirected graph cycle detection. Unlike BFS/DFS for general traversal or union-find for disjoint sets, cycle detection in directed graphs requires tracking the visitation state of nodes to distinguish between different types of paths. The key insight is that we need to detect back edges that lead to ancestors in the DFS tree, which indicates a cycle.
Conditions where variations apply:
- For undirected graphs, use Union-Find or simple DFS with visited tracking
- For topological sorting problems, cycle detection is a prerequisite but the full solution requires building the actual ordering
- For strongly connected components, use Kosaraju's or Tarjan's algorithm instead of simple cycle detection
- For detecting cycles in undirected graphs, we only need to check for back edges in the DFS tree
Core insight: The fundamental insight is that in directed graphs, we can have a path that returns to an ancestor node, creating a cycle. We must track the recursion stack or visitation states to distinguish between nodes that are currently being processed (in the current DFS path) versus those that have been completely explored. This allows us to detect when a back edge exists from a descendant to an ancestor in the DFS tree, which proves the existence of a cycle.
The pattern works because it leverages the fact that in any valid DFS traversal of a directed graph, if we encounter a node that is currently in the recursion stack (being processed), then we have found a back edge and thus a cycle. This approach is optimal for detecting cycles in directed graphs with O(V + E) time and space complexity.
3. Approach Evolution
Interview communication script:
"My first instinct is to use a brute force approach where I traverse each node and check if I can reach it again, but that's O(n²) because for each node I might have to traverse the entire graph to detect cycles. Let me think about how to optimize this.
I notice that if I maintain a coloring system for nodes - white (unvisited), gray (visiting), and black (visited) - I can detect cycles in a single DFS traversal. This is much better than O(n²) because each node is visited at most once.
The key insight is that in a directed graph, if I'm currently visiting a node and I encounter a gray node (one that's currently being processed), then I've found a cycle. This is because there's a path from the current node back to an ancestor in the DFS tree.
Let me think about this more systematically. I could use a simple DFS with three states, but I need to be careful about the termination conditions and how I track the visiting state. The invariant I want to maintain is that any node in the recursion stack is gray, and if I encounter a gray node during DFS, there's a back edge creating a cycle.
This approach reduces to O(V + E) time complexity where V is vertices and E is edges, which is optimal for graph traversal problems."
Brute-force approach and why it is not ideal:
The brute force approach would involve running a DFS from each node to detect cycles. For each starting node, we'd traverse the entire graph and check if we can return to any previously visited node. This leads to O(n²) time complexity in the worst case, where n is the number of nodes. The algorithm would repeatedly explore the same paths and perform redundant work, making it inefficient for large graphs.
Improved approach and the key optimization:
The improved approach uses three-color marking during DFS traversal:
- White (unvisited): Node has not been processed yet
- Gray (visiting): Node is currently being processed and is in the recursion stack
- Black (visited): Node has been completely processed
The key optimization is that we only traverse each node once, and we detect cycles by checking if we encounter a gray node during DFS. This single pass through the graph gives us O(V + E) time complexity, which is optimal for cycle detection in directed graphs.
Final approach with step-by-step reasoning:
- Use DFS traversal with three-color marking system
- For each unvisited node, initiate DFS and mark it as gray (visiting)
- During DFS, if we encounter a gray node, we've found a cycle (back edge)
- When DFS completes for a node, mark it as black (visited)
- Continue until all nodes are processed or cycle detected
- The invariant maintained is that gray nodes are in the current DFS path, so any gray node encountered during traversal indicates a cycle
Important invariants that must hold:
- Each node is visited at most once (ensuring O(V + E) time complexity)
- Gray nodes represent the current DFS recursion stack (cycle detection condition)
- Black nodes are completely processed and will never be re-visited
- White nodes are unprocessed and can be safely visited for the first time
4. Data Structure Choice
For cycle detection in directed graphs, I'll use a combination of data structures to efficiently track the state of each node during traversal:
Primary Data Structures:
-
Adjacency List (
Map<Integer, List<Integer>>) -- Used to represent the directed graph structure
- Allows O(1) access to neighbors of any node
- Space efficient for sparse graphs
-
Color/State Array (
int[]) -- Tracks visitation state of each node with three states:
- 0 = Unvisited (white)
- 1 = Visiting (gray)
- 2 = Visited (black)
- Enables O(1) state lookup and updates
- Space complexity: O(V) where V is number of vertices
- Tracks visitation state of each node with three states:
-
Stack (
Deque<Integer>) -- Maintains the current DFS path for backtracking
- Allows efficient path reconstruction if needed
- Space complexity: O(V) in worst case
Why This Choice Works:
The color array is crucial for cycle detection using DFS - it distinguishes between nodes that are currently being processed (gray) versus those that have been completely processed (black). This prevents false positives in cycle detection.
The adjacency list is the natural representation for directed graphs and supports efficient traversal. Using an array instead of a hash set for the color tracking provides O(1) access time and better cache locality.
Helper Structures:
- No additional helper structures needed - The three primary structures cover all requirements for cycle detection
- No custom classes required - Standard Java collections provide sufficient functionality
This combination provides optimal time complexity O(V + E) for graph traversal while maintaining clean, readable code structure suitable for FAANG interviews.
5. Algorithm Walkthrough
Let me trace through the algorithm using a concrete example to show how it works step by step.
Consider this directed graph with 4 nodes and edges: 1->2, 2->3, 3->4, 4->2 (creating a cycle).
Step 1: Initialize tracking structures
visitedset: {}recursionStackset: {}- Start with node 1
Step 2: Visit node 1
- Add 1 to
visitedandrecursionStack - Check neighbors: node 2
- Recursively visit node 2
Step 3: Visit node 2
- Add 2 to
visitedandrecursionStack - Check neighbors: node 3
- Recursively visit node 3
Step 4: Visit node 3
- Add 3 to
visitedandrecursionStack - Check neighbors: node 4
- Recursively visit node 4
Step 5: Visit node 4
- Add 4 to
visitedandrecursionStack - Check neighbors: node 2 (this is the cycle!)
- Since 2 is already in
recursionStack, we've found a back edge - Return true - cycle detected
Why it works: The key insight is that when we encounter a node that's already in our recursion stack, it means we've found a path from the current node back to itself - this is exactly what creates a cycle in a directed graph.
Edge case handling: If we had a node with no outgoing edges, we'd simply not find any neighbors to process and continue. If we had multiple disconnected components, we'd need to check each one separately.
public boolean hasCycle(int[][] graph) {
int n = graph.length;
boolean[] visited = new boolean[n];
boolean[] recStack = new boolean[n];
// Check each node as starting point (in case of disconnected components)
for (int i = 0; i < n; i++) {
if (!visited[i]) {
if (detectCycleUtil(graph, i, visited, recStack)) {
return true;
}
}
}
return false;
}
private boolean detectCycleUtil(int[][] graph, int node, boolean[] visited, boolean[] recStack) {
// Mark current node as visited and add to recursion stack
visited[node] = true;
recStack[node] = true;
// Visit all neighbors
for (int neighbor : graph[node]) {
// If neighbor is not visited, recursively check it
if (!visited[neighbor]) {
if (detectCycleUtil(graph, neighbor, visited, recStack)) {
return true;
}
}
// If neighbor is in recursion stack, we found a cycle
else if (recStack[neighbor]) {
return true;
}
}
// Remove current node from recursion stack
recStack[node] = false;
return false;
}
6. Java Solution
import java.util.*;
public class CycleDetection {
public boolean hasCycle(int[][] edges) {
// Build adjacency list representation
Map<Integer, List<Integer>> graph = buildGraph(edges);
// Three-color marking system:
// 0 = unvisited, 1 = visiting (in current DFS path), 2 = visited
int[] colors = new int[getMaxNodeValue(edges) + 1];
// Check each node to handle disconnected components
for (int node : graph.keySet()) {
if (colors[node] == 0) {
if (hasCycleDFS(node, graph, colors)) {
return true;
}
}
}
return false;
}
private boolean hasCycleDFS(int node, Map<Integer, List<Integer>> graph, int[] colors) {
// Mark as currently visiting
colors[node] = 1;
// Visit all neighbors
for (int neighbor : graph.getOrDefault(node, new ArrayList<>())) {
// If we encounter a node that's currently being visited, we found a cycle
if (colors[neighbor] == 1) {
return true;
}
// If neighbor hasn't been visited, recursively check it
if (colors[neighbor] == 0) {
if (hasCycleDFS(neighbor, graph, colors)) {
return true;
}
}
}
// Mark as fully visited
colors[node] = 2;
return false;
}
private Map<Integer, List<Integer>> buildGraph(int[][] edges) {
Map<Integer, List<Integer>> graph = new HashMap<>();
for (int[] edge : edges) {
int from = edge[0];
int to = edge[1];
graph.computeIfAbsent(from, k -> new ArrayList<>()).add(to);
}
return graph;
}
private int getMaxNodeValue(int[][] edges) {
int max = 0;
for (int[] edge : edges) {
max = Math.max(max, Math.max(edge[0], edge[1]));
}
return max;
}
}
This solution implements the classic three-color DFS approach for cycle detection in directed graphs. It uses a color marking system where:
- 0 = unvisited nodes
- 1 = currently visiting (in the DFS recursion stack)
- 2 = fully processed nodes
The algorithm handles disconnected components by checking all nodes, and uses the three-color system to detect back edges that indicate cycles. The adjacency list representation provides efficient neighbor access while keeping space proportional to the number of edges.
7. Complexity Analysis
Time Complexity: O(V + E) where V is the number of vertices and E is the number of edges in the directed graph.
The algorithm performs a single DFS traversal of the graph, visiting each vertex and edge at most once. Each vertex is processed exactly once, and each edge is examined once during the traversal to check for back edges. The work done per vertex is constant (checking visited status, maintaining recursion stack state), so the overall time complexity is linear in terms of the graph's size.
Space Complexity: O(V) for storing the visited status and recursion stack in the worst case.
The space usage comes from:
- The
visitedarray of size V to track visited vertices - The recursion stack in the worst case (when the graph is a linear chain), which can be as deep as O(V)
- The
recStackarray of size V to track vertices in the current DFS recursion path
In the worst case, when the graph is a linear chain (no cycles), the recursion stack depth equals V. In a balanced tree or sparse graph, the space usage will be less but still bounded by O(V).
The solution is optimal for this problem since we must examine each vertex and edge at least once to detect cycles in a directed graph. Any algorithm that detects cycles must have at least linear time complexity relative to the input size, as there are no shortcuts available for cycle detection in general directed graphs.
The algorithm achieves the theoretical lower bound by making exactly one pass through all vertices and edges, with constant work per element. This makes it near-optimal for the cycle detection problem in directed graphs.
8. Edge Cases & Pitfalls
Empty and Minimal Inputs
- Empty graph: An empty graph (no nodes) should return
false- no cycles possible - Single node: A single node with no edges should return
false- no cycle possible - Self-loop: A single node with a self-loop should return
true- cycle detected
Duplicate Values and Repeated States
- Duplicate node values: Nodes can have duplicate values, but the algorithm tracks by reference/ID, not value
- Multiple paths to same node: The visited set ensures we don't reprocess nodes, preventing infinite loops in the DFS traversal
Boundary Conditions
- Disconnected components: The algorithm must check all nodes to detect cycles in disconnected subgraphs
- Multiple cycles: Algorithm correctly identifies any cycle regardless of how many exist
- Complex graph structures: Deep nesting, multiple incoming edges to same node, etc. - handled by proper state tracking
Common Implementation Mistakes
- Incorrect visited state management: Using a single
visitedarray instead of separatevisitingandvisitedstates leads to false negatives - Not handling disconnected components: Only checking from one starting node instead of all nodes in the graph
- Failing to reset state properly: Not clearing the
visitingset when backtracking in recursive DFS - Wrong cycle detection logic: Confusing "node is currently being processed" with "node was already processed"
- Missing null checks: Not handling null node references in the graph structure
// Example of incorrect visited state management:
// This would incorrectly report no cycles in a graph like: 1->2, 2->3, 3->1
// where the single visited array would mark all nodes as visited after processing
The key insight is that we need three distinct states: unvisited, visiting (in current DFS path), and visited (completely processed) to properly detect cycles in directed graphs.
9. Alternative Approaches
There are several alternative approaches to cycle detection in directed graphs, each with different trade-offs:
1. DFS with Three-Color Marking: Instead of using a separate visited set, we can use three states - WHITE (unvisited), GRAY (visiting), and BLACK (visited). This approach uses a single color array and can detect cycles during DFS traversal. The key insight is that if we encounter a GRAY node while processing a GRAY node, we've found a back edge, indicating a cycle.
2. Kahn's Algorithm (Topological Sort): This approach uses in-degree tracking and BFS/queue-based processing. We start with nodes having zero in-degree, process them, and decrement in-degrees of neighbors. If we can't process all nodes, there's a cycle. This is particularly useful when we want to determine if the graph has a valid topological ordering.
3. Union-Find with Path Compression: While less common for cycle detection in directed graphs, we could potentially adapt union-find by tracking the parent of each node. However, this is more complex and less intuitive than the standard approaches.
4. Floyd's Cycle Detection (Tortoise and Hare): This is typically used for cycle detection in linked lists, but can be adapted for graphs by treating each node as a "next" pointer. However, this is not the standard approach for directed graph cycle detection.
The main trade-offs are:
- Time Complexity: DFS-based approaches are O(V + E), while Kahn's algorithm is also O(V + E) but with higher constant factors due to in-degree calculations
- Space Complexity: DFS with three-color marking uses O(V) space, while Kahn's algorithm uses O(V) for the queue and in-degrees
- Implementation Complexity: The three-color DFS is more intuitive once understood, while Kahn's algorithm requires more careful handling of in-degrees
The chosen solution (DFS with visited set) is preferred because it's more intuitive, requires fewer data structures, and has clean implementation logic that's easy to debug during interviews.
10. Test Cases
import java.util.*;
public class CycleDetectionTestCases {
public static void main(String[] args) {
// Test Case 1: Simple cycle
// 1 -> 2 -> 3 -> 1 (cycle)
Map<Integer, List<Integer>> graph1 = new HashMap<>();
graph1.put(1, Arrays.asList(2));
graph1.put(2, Arrays.asList(3));
graph1.put(3, Arrays.asList(1));
System.out.println("Test 1 - Simple cycle: " + hasCycle(graph1)); // Expected: true
// Test Case 2: No cycle
// 1 -> 2 -> 3, 4 -> 5 (no cycle)
Map<Integer, List<Integer>> graph2 = new HashMap<>();
graph2.put(1, Arrays.asList(2));
graph2.put(2, Arrays.asList(3));
graph2.put(3, new ArrayList<>());
graph2.put(4, Arrays.asList(5));
graph2.put(5, new ArrayList<>());
System.out.println("Test 2 - No cycle: " + hasCycle(graph2)); // Expected: false
// Test Case 3: Self loop
// 1 -> 1 (self cycle)
Map<Integer, List<Integer>> graph3 = new HashMap<>();
graph3.put(1, Arrays.asList(1));
System.out.println("Test 3 - Self loop: " + hasCycle(graph3)); // Expected: true
// Test Case 4: Empty graph
Map<Integer, List<Integer>> graph4 = new HashMap<>();
System.out.println("Test 4 - Empty graph: " + hasCycle(graph4)); // Expected: false
// Test Case 5: Single node with no edges
Map<Integer, List<Integer>> graph5 = new HashMap<>();
graph5.put(1, new ArrayList<>());
System.out.println("Test 5 - Single node: " + hasCycle(graph5)); // Expected: false
// Test Case 6: Complex cycle
// 1 -> 2 -> 4 -> 5 -> 2 (cycle)
Map<Integer, List<Integer>> graph6 = new HashMap<>();
graph6.put(1, Arrays.asList(2));
graph6.put(2, Arrays.asList(4));
graph6.put(4, Arrays.asList(5));
graph6.put(5, Arrays.asList(2));
System.out.println("Test 6 - Complex cycle: " + hasCycle(graph6)); // Expected: true
// Test Case 7: Disconnected components with cycles
// Component 1: 1 -> 2 -> 3 -> 1 (cycle)
// Component 2: 4 -> 5 -> 6 (no cycle)
Map<Integer, List<Integer>> graph7 = new HashMap<>();
graph7.put(1, Arrays.asList(2));
graph7.put(2, Arrays.asList(3));
graph7.put(3, Arrays.asList(1));
graph7.put(4, Arrays.asList(5));
graph7.put(5, Arrays.asList(6));
graph7.put(6, new ArrayList<>());
System.out.println("Test 7 - Disconnected with cycle: " + hasCycle(graph7)); // Expected: true
}
public static boolean hasCycle(Map<Integer, List<Integer>> graph) {
if (graph == null || graph.isEmpty()) return false;
Set<Integer> visited = new HashSet<>();
Set<Integer> recStack = new HashSet<>();
for (int node : graph.keySet()) {
if (!visited.contains(node)) {
if (hasCycleUtil(graph, node, visited, recStack)) {
return true;
}
}
}
return false;
}
private static boolean hasCycleUtil(Map<Integer, List<Integer>> graph, int node,
Set<Integer> visited, Set<Integer> recStack) {
visited.add(node);
recStack.add(node);
for (int neighbor : graph.getOrDefault(node, new ArrayList<>())) {
if (!visited.contains(neighbor)) {
if (hasCycleUtil(graph, neighbor, visited, recStack)) {
return true;
}
} else if (recStack.contains(neighbor)) {
return true;
}
}
recStack.remove(node);
return false;
}
}
Sample Test Cases:
- Simple cycle: Nodes 1→2→3→1 forms a cycle - Expected:
true - No cycle: Linear chain 1→2→3 and 4→5 - Expected:
false - Self loop: Node pointing to itself - Expected:
true - Empty graph: No nodes or edges - Expected:
false - Single node: One isolated node with no edges - Expected:
false - Complex cycle: 1→2→4→5→2 creates a cycle - Expected:
true - Disconnected components: One component has cycle, another doesn't - Expected:
true
Tricky Cases:
- Self loops and self-referencing nodes require careful handling in the recursion stack
- Disconnected graphs must check all components for cycles
- Multiple cycles in different parts of the graph should be detected correctly
Boundary Cases:
- Empty graph (no nodes)
- Single node with no edges
- Single node with self-loop
- Graph with only one component that has no cycles
11. Final Summary
The core idea is to detect cycles in a directed graph by tracking node visitation states using three-color marking (white, gray, black) to identify back edges that form cycles. This solution works efficiently by leveraging a single DFS traversal with state tracking to detect both direct and indirect cycles, making it optimal for the cycle detection problem in directed graphs with O(V + E) time and space complexity.