Skip to content
Work Practice Products Coding interviews Machine learning coding Quant developer HFT course Blog Method Why me About Enquire Book a call

Coding · Graphs

Cycles in an undirected graph

Lesson · 8 min read · Graphs · Cycle detection

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.

  1. Step 1 / 2
    012345

    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.

  2. Step 2 / 2
    abc

    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)

  1. Mark u visited.
  2. For each neighbour v of u:
  3. if v is parent, skip it (the edge just used);
  4. else if v is visited, return true;
  5. else if hasCycle(v, u) is true, return true.
  6. Return false.

main

  1. For every unvisited node s: if hasCycle(s, none), return true.
  2. 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.

  1. Step 1 / 11
    012345
    current0parent—

    Start at 0; it has no parent.

  2. Step 2 / 11
    012345
    current1parent0

    Enter 1 from 0. Its parent is 0.

  3. Step 3 / 11
    012345
    current1parent0

    0 is visited, but it is 1's parent: the edge 1–0 is the one just used, not a cycle.

  4. Step 4 / 11
    012345
    current2parent1

    Enter 2 from 1. Its parent is 1.

  5. Step 5 / 11
    012345
    current2parent1

    1 is visited, but it is 2's parent: the edge 2–1 is the one just used, not a cycle.

  6. Step 6 / 11
    012345
    current5parent2

    Enter 5 from 2. Its parent is 2.

  7. Step 7 / 11
    012345
    current5parent2

    2 is visited, but it is 5's parent: the edge 5–2 is the one just used, not a cycle.

  8. Step 8 / 11
    012345
    current4parent1

    Enter 4 from 1. Its parent is 1.

  9. Step 9 / 11
    012345
    current4parent1

    1 is visited, but it is 4's parent: the edge 4–1 is the one just used, not a cycle.

  10. Step 10 / 11
    012345
    current3parent4

    Enter 3 from 4. Its parent is 4.

  11. Step 11 / 11
    012345
    current3parent4

    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

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.

TimeO(n + m)
SpaceO(n)

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.