Cycle detection
A cycle in an undirected graph is an edge back to an already visited vertex other than the one you came from; in a directed graph it is an edge to a vertex that is still on the recursion stack.
Learning goals
- Detect cycles in undirected graphs while handling the parent edge correctly
- Detect directed cycles with three colours
- Detect an undirected cycle with union-find
- Classify every edge as tree, back, forward or cross
Prerequisites
A build system with circular dependencies can never finish, and a tree must have no cycle. The two kinds of graph need different checks, because an undirected edge can be walked in both directions.
Undirected DFS parent
In an undirected graph DFS from u sees v, and v sees u on its adjacency list, so the edge you just came along looks like a visit to an old vertex. It is not a cycle. The fix is to skip the **edge** you arrived by, not the parent vertex. Skipping by vertex wrongly ignores *parallel edges*, which form a cycle of length 2.
Give each edge an id and pass it down the recursion. A self-loop is found at once, because the neighbour of u is u itself and it is already visited. Choose *Cycle detection with DFS and a parent edge* in the animation; the "repeated edge is a cycle" preset shows the parallel-edge case.
1static boolean hasCycleByEdgeId(int n, int[][] edges) { // undirected; parallel edges and self-loops count2List<List<int[]>> adj = new ArrayList<>(); // each entry: {neighbour, edge id}3for (int i = 0; i < n; i++) adj.add(new ArrayList<>());4for (int id = 0; id < edges.length; id++) {5adj.get(edges[id][0]).add(new int[]{edges[id][1], id});6adj.get(edges[id][1]).add(new int[]{edges[id][0], id});7}8boolean[] seen = new boolean[n];9for (int s = 0; s < n; s++)10if (!seen[s] && cycleFrom(adj, s, -1, seen)) return true;11return false;12}13static boolean cycleFrom(List<List<int[]>> adj, int u, int viaEdge, boolean[] seen) {14seen[u] = true;15for (int[] e : adj.get(u)) {16if (e[1] == viaEdge) continue; // do not walk straight back along the same edge17if (seen[e[0]]) return true; // a different edge to a visited vertex closes a cycle18if (cycleFrom(adj, e[0], e[1], seen)) return true;19}20return false;21}
Components, two-colouring and cycle detection in an undirected graph
Interactive · edit the inputundirected graph · 8 vertices · 6 edges
component of each vertex
Search from an unlabelled vertex: everything the search reaches is one component. Repeat from the next unlabelled vertex until every vertex has a label.
1int[] components(List<List<Integer>> g) { // component id per vertex2int n = g.size(); int[] comp = new int[n]; Arrays.fill(comp, -1);3int id = 0;4for (int s = 0; s < n; s++) {5if (comp[s] != -1) continue;6Deque<Integer> q = new ArrayDeque<>(); q.add(s); comp[s] = id;7while (!q.isEmpty()) {8int u = q.poll();9for (int v : g.get(u)) if (comp[v] == -1) { comp[v] = id; q.add(v); }10}11id++; // the search ended: one whole component labelled12}13return comp;14}15boolean isBipartite(List<List<Integer>> g) {16int n = g.size(); int[] color = new int[n]; Arrays.fill(color, -1);17for (int s = 0; s < n; s++) { // check every component18if (color[s] != -1) continue;19color[s] = 0; Deque<Integer> q = new ArrayDeque<>(); q.add(s);20while (!q.isEmpty()) {21int u = q.poll();22for (int v : g.get(u)) {23if (color[v] == -1) { color[v] = color[u] ^ 1; q.add(v); }24else if (color[v] == color[u]) return false; // an odd cycle25}26}27}28return true;29}30boolean hasCycle(int n, List<int[]>[] g) { // undirected; g[u] holds {v, edgeId}31boolean[] seen = new boolean[n];32for (int s = 0; s < n; s++) if (!seen[s] && dfsCycle(g, s, -1, seen)) return true;33return false;34}35boolean dfsCycle(List<int[]>[] g, int u, int viaEdge, boolean[] seen) {36seen[u] = true;37for (int[] e : g[u]) {38if (e[1] == viaEdge) continue; // do not walk back along the edge we came by39if (seen[e[0]] || dfsCycle(g, e[0], e[1], seen)) return true; // a seen vertex reached by another edge: a cycle40}41return false;42}
Live state
vertices
8
edges
6
Execution trace (dry run table)Show 13 stepsHide
| Step | Line | State | What happens |
|---|---|---|---|
| 1 | L2 | vertices=8 edges=6 | Search from an unlabelled vertex: everything the search reaches is one component. Repeat from the next unlabelled vertex until every vertex has a label. |
| 2 | L6 | start=0 component=0 | Vertex 0 is unlabelled: start component C1 here. |
| 3 | L9 | u=0 v=1 component=0 | 0 - 1: 1 is unlabelled, so it belongs to C1 too. |
| 4 | L9 | u=1 v=2 component=0 | 1 - 2: 2 is unlabelled, so it belongs to C1 too. |
| 5 | L11 | components=1 | The queue is empty: component C1 is complete. 1 so far. |
| 6 | L6 | start=3 component=1 | Vertex 3 is unlabelled: start component C2 here. |
| 7 | L9 | u=3 v=4 component=1 | 3 - 4: 4 is unlabelled, so it belongs to C2 too. |
| 8 | L11 | components=2 | The queue is empty: component C2 is complete. 2 so far. |
| 9 | L6 | start=5 component=2 | Vertex 5 is unlabelled: start component C3 here. |
| 10 | L9 | u=5 v=6 component=2 | 5 - 6: 6 is unlabelled, so it belongs to C3 too. |
| 11 | L9 | u=5 v=7 component=2 | 5 - 7: 7 is unlabelled, so it belongs to C3 too. |
| 12 | L11 | components=3 | The queue is empty: component C3 is complete. 3 so far. |
| 13 | L13 | return=3 sizes=[3, 2, 3] | 3 components with sizes [3, 2, 3]. A vertex with no edge is a component of size 1. Cost O(V + E). |
Directed three-color DFS
In a directed graph, reaching a visited vertex is not always a cycle: it may be a finished (black) vertex in a different branch. A cycle exists exactly when DFS follows an arrow to a **grey** vertex, one that is still on the recursion stack, because the stack path leads from that vertex to the current one and the arrow closes the loop.
Run the *cycle detection with three colours* option on the two presets: one finds a grey vertex and stops, and in the acyclic one every vertex turns black without a grey hit. A directed graph with no cycle is a DAG, the subject of the next chapter.
1static boolean hasDirectedCycle(List<List<Integer>> g) {2int[] colour = new int[g.size()]; // 0 white, 1 grey (on the stack), 2 black (finished)3for (int s = 0; s < g.size(); s++)4if (colour[s] == 0 && greyHit(g, s, colour)) return true;5return false;6}7static boolean greyHit(List<List<Integer>> g, int u, int[] colour) {8colour[u] = 1;9for (int v : g.get(u)) {10if (colour[v] == 1) return true; // an arrow back into the stack: a cycle11if (colour[v] == 0 && greyHit(g, v, colour)) return true;12}13colour[u] = 2;14return false;15}
Depth-first search: times, tree, edge classes, three colours
Interactive · edit the inputundirected graph · 6 vertices · 6 edges
stack (bottom to top)
discovery / finish time
Depth-first search follows an edge to a new vertex as long as it can, and backs up only when a vertex has no unexplored neighbour. White = undiscovered, grey = on the current path, black = finished.
1int[] disc, fin; int time;2void dfs(List<List<Integer>> g, int u) { // recursive3disc[u] = ++time; // u turns grey4for (int v : g.get(u))5if (disc[v] == 0) dfs(g, v); // tree edge: go deeper6fin[u] = ++time; // u turns black7}8void dfsIterative(List<List<Integer>> g, int s) {9int[] next = new int[g.size()]; // position in each vertex's neighbour list10Deque<Integer> stack = new ArrayDeque<>();11disc[s] = ++time; stack.push(s);12while (!stack.isEmpty()) {13int u = stack.peek();14if (next[u] < g.get(u).size()) {15int v = g.get(u).get(next[u]++);16if (disc[v] == 0) { disc[v] = ++time; stack.push(v); }17} else { fin[u] = ++time; stack.pop(); }18}19}20// directed graphs: classify the edge u -> v when it is examined21String classify(int u, int v) {22if (disc[v] == 0) return "tree";23if (fin[v] == 0) return "back"; // v is still on the stack (grey): a cycle24return disc[u] < disc[v] ? "forward" : "cross";25}
Live state
start
0
Execution trace (dry run table)Show 15 stepsHide
| Step | Line | State | What happens |
|---|---|---|---|
| 1 | L1 | start=0 | Depth-first search follows an edge to a new vertex as long as it can, and backs up only when a vertex has no unexplored neighbour. White = undiscovered, grey = on the current path, black = finished. |
| 2 | L3 | u=0 disc[u]=1 time=1 | Discover 0 at time 1: it turns grey and goes on the stack (a new DFS tree starts here). |
| 3 | L5 | u=0 v=1 disc[v]=2 | 0 - 1: 1 is white, so this is a tree edge. Discover 1 at time 2: it turns grey, and the search goes deeper. |
| 4 | L5 | u=1 v=3 disc[v]=3 | 1 - 3: 3 is white, so this is a tree edge. Discover 3 at time 3: it turns grey, and the search goes deeper. |
| 5 | L5 | u=3 v=2 disc[v]=4 | 3 - 2: 2 is white, so this is a tree edge. Discover 2 at time 4: it turns grey, and the search goes deeper. |
| 6 | L5 | u=2 v=0 class=back | 2 - 0: 0 is grey, still on the stack: an edge to an ancestor, a back edge. It closes a cycle. |
| 7 | L6 | v=2 fin[v]=5 time=5 | 2 has no unexplored neighbour left: it turns black at time 5 and is popped. Back to 3. |
| 8 | L5 | u=3 v=4 disc[v]=6 | 3 - 4: 4 is white, so this is a tree edge. Discover 4 at time 6: it turns grey, and the search goes deeper. |
| 9 | L5 | u=4 v=5 disc[v]=7 | 4 - 5: 5 is white, so this is a tree edge. Discover 5 at time 7: it turns grey, and the search goes deeper. |
| 10 | L6 | v=5 fin[v]=8 time=8 | 5 has no unexplored neighbour left: it turns black at time 8 and is popped. Back to 4. |
| 11 | L6 | v=4 fin[v]=9 time=9 | 4 has no unexplored neighbour left: it turns black at time 9 and is popped. Back to 3. |
| 12 | L6 | v=3 fin[v]=10 time=10 | 3 has no unexplored neighbour left: it turns black at time 10 and is popped. Back to 1. |
| 13 | L6 | v=1 fin[v]=11 time=11 | 1 has no unexplored neighbour left: it turns black at time 11 and is popped. Back to 0. |
| 14 | L6 | u=0 fin[u]=12 time=12 | 0 finishes at time 12: all vertices reachable from it are black. |
| 15 | L6 | return=[0, 1, 3, 2, 4, 5] tree edges=5 back edges=1 | Preorder (discovery order): [0, 1, 3, 2, 4, 5]. 5 tree edges and 1 back edge; the numbers under each vertex are discovery/finish times, and the nesting of the intervals mirrors the DFS tree. |
DSU undirected cycle detection
Union-find gives a search-free test for undirected graphs. Process edges one at a time: if the two endpoints already have the same root, they were already connected, so this edge closes a cycle; otherwise union them. The first edge that fails is a **redundant edge**.
This also works when edges arrive as a stream, which a DFS cannot handle without rebuilding the graph. It does not apply to directed graphs, where connectivity through arrows is one-way. The animation for union-find on edges includes the redundant-edge question.
1static int firstRedundantEdge(int n, int[][] edges) { // index of the first edge that closes a cycle, or -12int[] parent = new int[n];3for (int i = 0; i < n; i++) parent[i] = i;4for (int i = 0; i < edges.length; i++) {5int a = find(parent, edges[i][0]), b = find(parent, edges[i][1]);6if (a == b) return i; // already connected, so this edge adds a cycle7parent[a] = b;8}9return -1;10}11static int find(int[] parent, int x) {12while (parent[x] != x) { parent[x] = parent[parent[x]]; x = parent[x]; } // path halving13return x;14}
Union-find on edges: components, redundant edge, Kruskal
Interactive · edit the inputdisjoint-set forest (arrow = parent, roots on top) · 6 nodes
edges in the order given
Start with n components, one per vertex. Every edge whose endpoints are in different sets merges two components (count - 1); an edge inside one set changes nothing.
1int countComponents(int n, int[][] edges) {2DSU d = new DSU(n); int comps = n;3for (int[] e : edges) if (d.union(e[0], e[1])) comps--; // a successful union merges two components4return comps;5}6int[] firstCycleEdge(int n, int[][] edges) { // the first edge whose endpoints are already connected7DSU d = new DSU(n);8for (int[] e : edges) if (!d.union(e[0], e[1])) return e;9return null;10}11long kruskal(int n, int[][] edges) { // edges are {a, b, w}12int[][] sorted = edges.clone();13Arrays.sort(sorted, Comparator.comparingInt(e -> e[2])); // stable: ties keep the input order14DSU d = new DSU(n); long total = 0;15for (int[] e : sorted) if (d.union(e[0], e[1])) total += e[2]; // keep an edge only if it joins two components16return total;17}
Live state
vertices
6
edges
5
Execution trace (dry run table)Show 7 stepsHide
| Step | Line | State | What happens |
|---|---|---|---|
| 1 | L1 | vertices=6 edges=5 | Start with n components, one per vertex. Every edge whose endpoints are in different sets merges two components (count - 1); an edge inside one set changes nothing. |
| 2 | L3 | edge=0-1 components=5 | Edge 0-1 joins the sets of roots 0 and 1: 5 components left. |
| 3 | L3 | edge=1-2 components=4 | Edge 1-2 joins the sets of roots 0 and 2: 4 components left. |
| 4 | L3 | edge=3-4 components=3 | Edge 3-4 joins the sets of roots 3 and 4: 3 components left. |
| 5 | L3 | edge=2-0 root of a=0 root of b=0 | Edge 2-0: both ends have root 0, so they are already connected. Skip it. |
| 6 | L3 | edge=4-5 components=2 | Edge 4-5 joins the sets of roots 3 and 5: 2 components left. |
| 7 | L4 | return=2 | 2 connected components (provinces). The edges kept form a spanning forest with 4 edges. |
Edge classification
DFS on a directed graph sorts every edge u -> v into one of four kinds by the state of v when the edge is examined. **Tree**: v is white and becomes a child of u. **Back**: v is grey, an ancestor of u. **Forward**: v is black and a descendant of u (disc[u] < disc[v]). **Cross**: v is black and in another subtree (disc[v] < disc[u]).
A directed graph has a cycle exactly when it has a back edge. In an undirected graph only tree and back edges occur. Choose *Directed: classify every edge* in the animation: the preset contains one edge of each kind.
1static List<String> classifyArrows(List<List<Integer>> g) { // "u->v tree|back|forward|cross" in the order DFS examines them2int n = g.size();3int[] colour = new int[n], disc = new int[n];4int[] clock = {0};5List<String> out = new ArrayList<>();6for (int s = 0; s < n; s++)7if (colour[s] == 0) classify(g, s, colour, disc, clock, out);8return out;9}10static void classify(List<List<Integer>> g, int u, int[] colour, int[] disc, int[] clock, List<String> out) {11colour[u] = 1;12disc[u] = clock[0]++;13for (int v : g.get(u)) {14if (colour[v] == 0) { out.add(u + "->" + v + " tree"); classify(g, v, colour, disc, clock, out); }15else if (colour[v] == 1) out.add(u + "->" + v + " back"); // v is still on the stack16else out.add(u + "->" + v + (disc[u] < disc[v] ? " forward" : " cross")); // v is already finished17}18colour[u] = 2;19}
Depth-first search: times, tree, edge classes, three colours
Interactive · edit the inputundirected graph · 6 vertices · 6 edges
stack (bottom to top)
discovery / finish time
Depth-first search follows an edge to a new vertex as long as it can, and backs up only when a vertex has no unexplored neighbour. White = undiscovered, grey = on the current path, black = finished.
1int[] disc, fin; int time;2void dfs(List<List<Integer>> g, int u) { // recursive3disc[u] = ++time; // u turns grey4for (int v : g.get(u))5if (disc[v] == 0) dfs(g, v); // tree edge: go deeper6fin[u] = ++time; // u turns black7}8void dfsIterative(List<List<Integer>> g, int s) {9int[] next = new int[g.size()]; // position in each vertex's neighbour list10Deque<Integer> stack = new ArrayDeque<>();11disc[s] = ++time; stack.push(s);12while (!stack.isEmpty()) {13int u = stack.peek();14if (next[u] < g.get(u).size()) {15int v = g.get(u).get(next[u]++);16if (disc[v] == 0) { disc[v] = ++time; stack.push(v); }17} else { fin[u] = ++time; stack.pop(); }18}19}20// directed graphs: classify the edge u -> v when it is examined21String classify(int u, int v) {22if (disc[v] == 0) return "tree";23if (fin[v] == 0) return "back"; // v is still on the stack (grey): a cycle24return disc[u] < disc[v] ? "forward" : "cross";25}
Live state
start
0
Execution trace (dry run table)Show 15 stepsHide
| Step | Line | State | What happens |
|---|---|---|---|
| 1 | L1 | start=0 | Depth-first search follows an edge to a new vertex as long as it can, and backs up only when a vertex has no unexplored neighbour. White = undiscovered, grey = on the current path, black = finished. |
| 2 | L3 | u=0 disc[u]=1 time=1 | Discover 0 at time 1: it turns grey and goes on the stack (a new DFS tree starts here). |
| 3 | L5 | u=0 v=1 disc[v]=2 | 0 - 1: 1 is white, so this is a tree edge. Discover 1 at time 2: it turns grey, and the search goes deeper. |
| 4 | L5 | u=1 v=3 disc[v]=3 | 1 - 3: 3 is white, so this is a tree edge. Discover 3 at time 3: it turns grey, and the search goes deeper. |
| 5 | L5 | u=3 v=2 disc[v]=4 | 3 - 2: 2 is white, so this is a tree edge. Discover 2 at time 4: it turns grey, and the search goes deeper. |
| 6 | L5 | u=2 v=0 class=back | 2 - 0: 0 is grey, still on the stack: an edge to an ancestor, a back edge. It closes a cycle. |
| 7 | L6 | v=2 fin[v]=5 time=5 | 2 has no unexplored neighbour left: it turns black at time 5 and is popped. Back to 3. |
| 8 | L5 | u=3 v=4 disc[v]=6 | 3 - 4: 4 is white, so this is a tree edge. Discover 4 at time 6: it turns grey, and the search goes deeper. |
| 9 | L5 | u=4 v=5 disc[v]=7 | 4 - 5: 5 is white, so this is a tree edge. Discover 5 at time 7: it turns grey, and the search goes deeper. |
| 10 | L6 | v=5 fin[v]=8 time=8 | 5 has no unexplored neighbour left: it turns black at time 8 and is popped. Back to 4. |
| 11 | L6 | v=4 fin[v]=9 time=9 | 4 has no unexplored neighbour left: it turns black at time 9 and is popped. Back to 3. |
| 12 | L6 | v=3 fin[v]=10 time=10 | 3 has no unexplored neighbour left: it turns black at time 10 and is popped. Back to 1. |
| 13 | L6 | v=1 fin[v]=11 time=11 | 1 has no unexplored neighbour left: it turns black at time 11 and is popped. Back to 0. |
| 14 | L6 | u=0 fin[u]=12 time=12 | 0 finishes at time 12: all vertices reachable from it are black. |
| 15 | L6 | return=[0, 1, 3, 2, 4, 5] tree edges=5 back edges=1 | Preorder (discovery order): [0, 1, 3, 2, 4, 5]. 5 tree edges and 1 back edge; the numbers under each vertex are discovery/finish times, and the nesting of the intervals mirrors the DFS tree. |
Practice ladder
Now try it yourself
Easy
- Find if Path Exists in Graph
Medium
- Course Schedule
- Redundant Connection
- Graph Valid Tree
- Find Eventual Safe States
Advanced
- Redundant Connection II
- Course Schedule II
Key takeaways
- Undirected: an edge to a visited vertex, other than the edge you arrived by, is a cycle; skip by edge id so parallel edges count.
- Directed: a cycle exists exactly when DFS meets a grey vertex (a back edge).
- Union-find finds the first edge that closes a cycle in an undirected graph, even when edges stream in.
- DFS sorts directed edges into tree, back, forward and cross edges.