Algorithm

Directed Cycle Detection

Graphs Pattern

Directed Cycle Detection

Given a directed graph, determine whether it contains a cycle — a sequence of vertices where you can follow edges forward from some vertex and eventually arrive back at it.

Return a boolean. The graph may be disconnected, so a cycle anywhere counts, not only in the component containing any particular starting vertex. A self-loop, an edge from a vertex to itself, is a cycle of length one.

CONSTRAINTS
  • V = number of vertices, E = number of edges
  • Edges are directed — following them backwards is not permitted
  • The graph may be disconnected, so every vertex must be considered as a potential start
  • Self-loops count as cycles
  • Runs in O(V + E) time and O(V) space
EXAMPLE 1
Input: 4 nodes, edges 0→1, 1→2, 2→3, 3→1
Output: true
Starting at node 1 and following edges gives 1, 2, 3 and then back to 1. Node 0 is not part of the loop, but a cycle anywhere in the graph makes the answer true.
EXAMPLE 2
Input: 3 nodes, edges 0→1, 0→2, 1→2
Output: false
Node 2 is reachable from node 0 by two different routes — directly, and via node 1 — but both run forward and neither returns to an earlier node. Multiple paths to the same vertex are not a cycle, which is the distinction that makes this problem harder than it looks.
EXAMPLE 3
Input: 2 nodes, edges 0→1, 1→0
Output: true
Two nodes pointing at each other form a cycle of length two. In an undirected graph this same pair would just be one ordinary edge — direction is what makes it a loop.
EXAMPLE 4
Input: 3 nodes, edges 0→1, 2→2
Output: true
Node 2 points to itself, which is a cycle of length one. It sits in a separate component from the 0→1 edge, so a search that only ever started at node 0 would miss it entirely.
Does reaching a node I have already visited mean I found a cycle?
No, and this is the central trap. In a directed graph a node can be reachable by several forward routes with no loop involved. A cycle requires arriving at a node that is still on the path you are currently exploring — not merely one you have seen before.
Can the graph be disconnected?
Yes, so the search must be launched from every vertex not yet visited. A cycle hiding in a component you never entered would be missed.
How should self-loops be treated?
As cycles of length one. The algorithm below handles them without a special case: on entering a vertex it is marked in-progress, so when its own edge back to itself is examined, that in-progress mark fires immediately.
Do I return just a boolean, or the vertices forming the cycle?
The boolean here. Reconstructing the cycle is a frequent follow-up, and the depth-first version supports it — the vertices currently marked in-progress at the moment of detection are exactly the cycle plus the tail leading into it.

A cycle is a route that leaves a vertex, follows edges forward, and returns to where it began. Detecting one sounds like it should need nothing more than a visited set — and that instinct is wrong in a way worth examining closely, because the correction is the whole lesson.

Why a visited set is not enough

Here is the rule that works on undirected graphs: if you reach a vertex already visited, you have found a second route to it, so a cycle exists. Transplanted to directed graphs, it breaks.

Consider four vertices arranged as a diamond: A → B, A → C, B → D, C → D.

Explore from A, go to B, then to D. Three vertices are now marked visited. Back up to A and take the other branch to C. From C, follow the edge to D — and D is already visited.

Under the undirected rule you would declare a cycle. But look at the graph: every edge runs forward, from A toward D, and there is no way back to A from anywhere. This is a perfectly acyclic graph. D simply has two things pointing at it.

The reason the rule transfers badly is that in an undirected graph, having two distinct routes to a vertex genuinely is a cycle — you can go out along one and back along the other, because edges work both ways. In a directed graph you cannot come back along an edge, so two forward routes to the same place prove nothing at all.

The reframe: "seen before" is two different states

The visited set is conflating two situations that need separating.

A vertex might be one your search is still in the middle of — you entered it, you are currently exploring things it points to, and you have not yet returned from it. In recursion terms, its stack frame is still open. Such a vertex is an ancestor of where you are now.

Or a vertex might be one your search completely finished — you entered it, explored everything reachable from it, found no cycle, and returned. It is behind you, not above you.

Now the correct rule falls out. Arriving at a vertex that is still in progress means there is a route from that vertex forward to where you now stand, and an edge from here back to it. Those two together close a loop. That is a cycle, and nothing else is.

Arriving at a finished vertex means only that something else already led there. No loop, and nothing worth re-exploring, since it was already cleared.

Key Insightthe question is never "have I seen this vertex?" It is "is this vertex an ancestor of me on the path I am currently walking?" That single change of question is what makes directed cycle detection work.
Three states

Encode it directly. Every vertex is in one of three states, traditionally coloured:

- White — never touched.
- Grey — entered, but not yet finished. Its recursive call is still open, so it is on the current path.
- Black — entered and fully explored. Cleared, permanently.

A vertex goes white to grey when the search enters it, and grey to black when the search leaves it. The rule is then a single line: a grey neighbour means a cycle.

python
def has_cycle(V, adj):
    WHITE, GREY, BLACK = 0, 1, 2
    state = [WHITE] * V

    def visit(u):
        state[u] = GREY                      # entering: now on the active path
        for v in adj[u]:
            if state[v] == GREY:
                return True                  # back-edge to an ancestor: cycle
            if state[v] == WHITE and visit(v):
                return True
        state[u] = BLACK                     # leaving: fully explored and safe
        return False

    return any(visit(u) for u in range(V) if state[u] == WHITE)
Crucial Notestate[u] = BLACK must run after the loop over neighbours, and state[u] = GREY before it. The window between them is precisely the interval during which u is an ancestor of everything the search is currently touching. Set BLACK too early and u stops being recognisable as an ancestor, so real cycles through it are missed. Never set BLACK at all and every vertex stays grey forever, so ordinary re-convergence — the diamond above — is misreported as a cycle.
Crucial Notethe BLACK state is what keeps the algorithm linear rather than exponential. Without it, a vertex reachable by many routes would be fully re-explored once per route. The answer would still be correct; the running time would not.
Crucial Notethe search must be launched from every white vertex, not just vertex 0. A directed graph is frequently disconnected — and even when it is not, no single vertex need reach all others, since edges are one-way. A cycle in a component you never entered is a cycle you never find.

An edge leading to a grey vertex has a name worth knowing, since interviewers use it: a back-edge. A directed graph contains a cycle if and only if a depth-first search of it finds a back-edge.

Cost: each vertex is entered once, thanks to the white check, and each edge is examined once. O(V + E) time, O(V) for the state array plus up to O(V) recursion depth.

Worked example

Edges 0→1, 1→2, 2→3, 3→1.

- visit(0). Node 0 turns grey. Its only neighbour is 1, which is white, so recurse.
- visit(1). Node 1 turns grey. State so far: 0 and 1 grey. Neighbour 2 is white, so recurse.
- visit(2). Node 2 turns grey. Grey set is now {0, 1, 2}. Neighbour 3 is white, so recurse.
- visit(3). Node 3 turns grey. Grey set {0, 1, 2, 3}. Its neighbour is 1 — and node 1 is grey. That means node 1's call is still open, so node 1 is an ancestor of node 3, and the edge 3→1 closes the loop 1 → 2 → 3 → 1. Return true immediately, unwinding the whole recursion.

Now the diamond, A→B, A→C, B→D, C→D, to see the failure case handled correctly.

- visit(A) turns A grey, recurses into B.
- visit(B) turns B grey, recurses into D.
- visit(D) turns D grey. D has no outgoing edges, so the loop body never runs and D turns black. Return false.
- Back in B: no more neighbours, so B turns black, return false.
- Back in A: next neighbour is C, white, so recurse. visit(C) turns C grey and examines its neighbour D — which is black, not grey. Black means finished and cleared, so it is skipped entirely. C turns black.
- A turns black. No cycle found: false. Correct.

The whole difference between the two traces is whether the re-encountered vertex was grey or black.

The alternative: peel instead of dive

The same question can be answered without recursion at all, using in-degree counting — Kahn's algorithm.

Count the incoming edges of every vertex. Repeatedly remove any vertex with zero incoming edges, decrementing the counts of everything it points to. If every vertex is eventually removed, the graph is acyclic. If some remain stuck with non-zero counts, those form a cycle.

The proof is the same backward-walk argument used in topological sorting: each stuck vertex has a stuck predecessor, and with finitely many vertices, following predecessors backwards must eventually repeat one.

python
from collections import deque

def has_cycle_kahn(V, adj):
    in_degree = [0] * V
    for u in range(V):
        for v in adj[u]:
            in_degree[v] += 1

    queue = deque(u for u in range(V) if in_degree[u] == 0)
    removed = 0
    while queue:
        u = queue.popleft()
        removed += 1
        for v in adj[u]:
            in_degree[v] -= 1
            if in_degree[v] == 0:
                queue.append(v)

    return removed != V          # anything left over is part of a cycle

Same O(V + E) cost. It avoids recursion entirely, which matters when V is large enough to overflow the call stack. The depth-first version has the compensating advantage that the grey vertices at the moment of detection hand you the actual cycle, which Kahn's does not.

That equivalence is worth stating outright: a directed graph is acyclic if and only if it has a topological ordering. Cycle detection and topological sorting are the same computation, differing only in what they report.

Train the reflex: in a directed graph, never ask "have I been here before?" Ask "am I still inside this vertex's exploration?" Two states are not enough; three are.

Interactive Strategy Visualization
DIRECTED CYCLE DETECTION
AVISITINGBCD
DFS Status

Start DFS at Node A. Add A to the Recursion Stack (Active Path).

Current Path
A
Key Rule

A cycle in a directed graph is only a cycle if the "climbing rope" (stack) loops back to itself.

Exponential Enumerate Every Path
O(V + E) DFS With Three-State Marking
O(V + E) Kahn's In-Degree Peeling