Cycles in an undirected graph
A depth-first search finds a cycle the moment it meets a visited node by an edge other than the one it arrived on.
What gives a cycle away
Run a depth-first search and keep the edges it travels along: they form a tree, because each node is entered exactly once, from one other node. Every edge of the graph that is not in that tree joins two nodes that the tree already connects, so adding it closes a loop. A graph has a cycle exactly when the search meets an edge outside its tree.
-
Step 1 / 2
Solid: the edges a search from 0 followed. Dashed: 3–0 joins two nodes the tree already connects, closing the cycle 0–1–4–3–0.
-
Step 2 / 2
Entering b from a, the search sees a among b's neighbours. a is b's parent, so this is the edge just used.
Seeing such an edge is easy: from the current node, it leads to a node already visited. The one exception is the edge the search has just come along. In an undirected graph the edge u–v appears in both adjacency lists, so from v the search sees u again, already visited. That is not a cycle; it is the same edge seen from the other end.
The algorithm
Pass each call the node it came from, and ignore that node among the neighbours. Any other visited neighbour means a cycle.
Cycle in an undirected graph
hasCycle(u, parent)
- Mark
uvisited. - For each neighbour
vofu: - if
visparent, skip it (the edge just used); - else if
vis visited, return true; - else if
hasCycle(v, u)is true, return true. - Return false.
main
- For every unvisited node
s: ifhasCycle(s, none), return true. - Return false.
Skipping the parent assumes there is only one edge between any two nodes. If the input can repeat an edge, two parallel edges u–v do form a cycle, and the check must skip the parent edge by its index, not by the parent node.
Step by step
On the graph from the first figure, neighbours tried in increasing order:
Depth-first search with parents. The search stops at the first edge that closes a cycle.
-
Step 1 / 11current0parent—
Start at 0; it has no parent.
-
Step 2 / 11current1parent0
Enter 1 from 0. Its parent is 0.
-
Step 3 / 11current1parent0
0 is visited, but it is 1's parent: the edge 1–0 is the one just used, not a cycle.
-
Step 4 / 11current2parent1
Enter 2 from 1. Its parent is 1.
-
Step 5 / 11current2parent1
1 is visited, but it is 2's parent: the edge 2–1 is the one just used, not a cycle.
-
Step 6 / 11current5parent2
Enter 5 from 2. Its parent is 2.
-
Step 7 / 11current5parent2
2 is visited, but it is 5's parent: the edge 5–2 is the one just used, not a cycle.
-
Step 8 / 11current4parent1
Enter 4 from 1. Its parent is 1.
-
Step 9 / 11current4parent1
1 is visited, but it is 4's parent: the edge 4–1 is the one just used, not a cycle.
-
Step 10 / 11current3parent4
Enter 3 from 4. Its parent is 4.
-
Step 11 / 11current3parent4
0 is visited and is not 3's parent. There is a second way from 0 to 3: a cycle.
current nodevisitedtree edgeedge that closes a cycle
Implementation
The solution below uses an explicit stack of (node, parent) pairs, so it handles long paths without recursion. A node is marked when it is pushed; meeting a marked node that is not the parent means that node has been reached a second way.
def has_cycle(n, edges):
adj = [[] for _ in range(n)]
for u, v in edges:
adj[u].append(v)
adj[v].append(u)
seen = [False] * n
for s in range(n):
if seen[s]:
continue
seen[s] = True
stack = [(s, -1)] # (node, the node it was reached from)
while stack:
u, parent = stack.pop()
for v in adj[u]:
if v == parent:
continue # the edge we arrived on
if seen[v]:
return True # reached a second way: a cycle
seen[v] = True
stack.append((v, u))
return False#include <utility>
#include <vector>
bool solve(int n, const std::vector<std::vector<int>>& edges) {
std::vector<std::vector<int>> adj(n);
for (const auto& e : edges) {
adj[e[0]].push_back(e[1]);
adj[e[1]].push_back(e[0]);
}
std::vector<bool> seen(n, false);
for (int s = 0; s < n; ++s) {
if (seen[s]) continue;
seen[s] = true;
std::vector<std::pair<int, int>> stack{{s, -1}}; // (node, parent)
while (!stack.empty()) {
auto [u, parent] = stack.back();
stack.pop_back();
for (int v : adj[u]) {
if (v == parent) continue; // the edge we arrived on
if (seen[v]) return true; // reached a second way
seen[v] = true;
stack.push_back({v, u});
}
}
}
return false;
}import java.util.*;
class Solution {
public boolean hasCycle(int n, int[][] edges) {
List<List<Integer>> adj = new ArrayList<>();
for (int i = 0; i < n; i++) adj.add(new ArrayList<>());
for (int[] e : edges) {
adj.get(e[0]).add(e[1]);
adj.get(e[1]).add(e[0]);
}
boolean[] seen = new boolean[n];
ArrayDeque<int[]> stack = new ArrayDeque<>();
for (int s = 0; s < n; s++) {
if (seen[s]) continue;
seen[s] = true;
stack.push(new int[]{s, -1}); // {node, parent}
while (!stack.isEmpty()) {
int[] top = stack.pop();
int u = top[0], parent = top[1];
for (int v : adj.get(u)) {
if (v == parent) continue; // the edge we arrived on
if (seen[v]) return true; // reached a second way
seen[v] = true;
stack.push(new int[]{v, u});
}
}
}
return false;
}
}function hasCycle(n, edges) {
const adj = Array.from({ length: n }, () => []);
for (const [u, v] of edges) { adj[u].push(v); adj[v].push(u); }
const seen = new Array(n).fill(false);
for (let s = 0; s < n; s++) {
if (seen[s]) continue;
seen[s] = true;
const stack = [[s, -1]]; // [node, the node it was reached from]
while (stack.length) {
const [u, parent] = stack.pop();
for (const v of adj[u]) {
if (v === parent) continue; // the edge we arrived on
if (seen[v]) return true; // reached a second way: a cycle
seen[v] = true;
stack.push([v, u]);
}
}
}
return false;
}fn solve(n: i64, edges: &[[i64; 2]]) -> bool {
let n = n as usize;
let mut adj = vec![Vec::new(); n];
for e in edges {
let (u, v) = (e[0] as usize, e[1] as usize);
adj[u].push(v);
adj[v].push(u);
}
let mut seen = vec![false; n];
for s in 0..n {
if seen[s] {
continue;
}
seen[s] = true;
let mut stack = vec![(s, usize::MAX)]; // (node, parent)
while let Some((u, parent)) = stack.pop() {
for &v in &adj[u] {
if v == parent {
continue; // the edge we arrived on
}
if seen[v] {
return true; // reached a second way
}
seen[v] = true;
stack.push((v, u));
}
}
}
false
}import qualified Data.Map.Strict as M
import qualified Data.Set as S
solve :: Int -> [[Int]] -> Bool
solve n edges = go [0 .. n - 1] S.empty
where
adj = M.fromListWith (++) (concat [[(u, [v]), (v, [u])] | [u, v] <- edges])
nbrs u = M.findWithDefault [] u adj
-- start a search from every node not yet seen
go [] _ = False
go (s : rest) seen
| S.member s seen = go rest seen
| otherwise = case search [(s, -1)] (S.insert s seen) of
Nothing -> True
Just seen' -> go rest seen'
-- Nothing means a cycle was found; Just carries the visited set on
search [] seen = Just seen
search ((u, parent) : stack) seen = step (nbrs u) stack seen
where
step [] st sn = search st sn
step (v : vs) st sn
| v == parent = step vs st sn
| S.member v sn = Nothing
| otherwise = step vs ((v, u) : st) (S.insert v sn)
The same code is the reference solution to Does an undirected graph contain a cycle?, where it is compiled and run against the tests in all six languages.
Each node is marked once and each edge examined from both ends at most. The search can stop early at the first cycle.
Two shortcuts worth knowing. A graph with m ≥ n edges always has a cycle, before any search. And union-find detects cycles as edges arrive: an edge whose two ends are already in the same group closes one. That is the method when edges come one at a time.