A Bit of History
In 1972, Robert Tarjan published Depth-First Search and Linear Graph Algorithms, showing how information gathered during DFS could solve several structural graph problems in linear time. The paper's influence is easy to underestimate: its low-link idea underlies classic algorithms for strongly connected components, articulation points, and bridges.
The SCC version is especially elegant. Kosaraju's algorithm separates the work into two DFS passes and reverses every edge between them. Tarjan instead keeps the unresolved part of the first DFS in memory and recognizes the exact moment an entire component becomes complete. Both are linear-time algorithms; Tarjan's distinctive advantage is a single traversal without explicitly constructing the transpose graph.
What Are We Actually Finding?
In a directed graph, a strongly connected component (SCC) is a maximal set of vertices in which every vertex can reach every other vertex. “Maximal” matters: a directed cycle is strongly connected, but if another vertex can travel into the cycle and back out to itself, that vertex belongs to the same SCC too.
SCCs partition the graph. Every vertex belongs to exactly one component, including an isolated vertex or a vertex with no return path; such a vertex forms a singleton SCC. Once each SCC is contracted into one super-vertex, the result is always a directed acyclic graph called the condensation graph. If the condensation contained a directed cycle, all super-vertices on that cycle would be mutually reachable and should have been one SCC.
The Three Pieces of State
Tarjan's algorithm assigns two integers and one membership flag to each vertex. The names vary across implementations—disc, index, or id—but their jobs should remain distinct.
When was it discovered?
A unique, increasing DFS timestamp. It is assigned once and never changes. Smaller means discovered earlier.
How early can it reconnect?
The smallest discovery index reachable from v's DFS region while staying connected to the unresolved, active search.
Is its SCC unresolved?
True after discovery and false after assignment to a completed SCC. Only active targets can lower a low-link value.
The Tarjan Stack Is Not the Recursion Stack
A recursive call can return while its vertex remains on Tarjan's stack. The recursion stack represents unfinished function calls; Tarjan's stack represents vertices whose final SCC has not yet been emitted. A vertex may leave the first but remain in the second until an ancestor is recognized as the component root.
Rooms awaiting a sealed folder
Imagine exploring rooms and placing each room's record into an open folder. An arrow back to an earlier room says, “these records may belong together.” The low-link is the earliest still-open record this region can reconnect to. When a room's low-link equals its own index, nothing beneath it reaches an older open folder: close the folder by removing records through that room. Edges into folders already sealed cannot merge them back in.
The Algorithm, Step by Step
- Discover a vertex. Set
index[v] = low[v]to the next timestamp, pushvonto the active stack, and mark it active. - Inspect each outgoing edge
v → w. The update depends on whetherwis unvisited, active, or already assigned to a component. - Finish the vertex. After every outgoing edge has been processed, test whether
low[v] == index[v]. - Emit a component. If equality holds, repeatedly pop the active stack until
vis popped. Those popped vertices form exactly one SCC. - Cover disconnected regions. Start DFS from every still-unvisited vertex, not only from an arbitrary first vertex.
The root test is the algorithm's hinge:
$$\operatorname{low}[v] = \operatorname{index}[v] \quad\Longrightarrow\quad v\text{ begins a complete SCC on the active stack.}$$
The Three Edge Rules
Most implementation bugs come from collapsing the following cases into one generic “visited neighbor” rule. Keep them separate.
Edge v → w | Action | Why |
|---|---|---|
w is unvisited | Recurse into w, then set low[v] = min(low[v], low[w]). | The entire unresolved region below w may reconnect to an earlier active vertex. |
w is active | Set low[v] = min(low[v], index[w]). | The direct edge reaches the active vertex w. Use its fixed discovery index—not its low value. |
w was popped | Do nothing to low[v]. | w already belongs to a sealed SCC. A one-way edge into it does not make the current region mutually reachable with it. |
Memory hook: child edge → inherit low[child]; active non-tree edge → use index[target]; popped target → ignore.
Visual Walkthrough
Consider two cycles connected by a one-way edge: $A \to B \to C \to A$, $D \leftrightarrow E$, and $C \to D$. DFS follows the vertices in alphabetical neighbor order. The labels below are index / low after the relevant back edges have propagated.
D from C, there is no return path from the blue component to C. Therefore D becomes its own root and the blue vertices pop before the teal cycle.| Event | Effect | Active stack (bottom → top) |
|---|---|---|
| Discover A, B, C | C → A makes low[C] = 0. | ABC |
| Discover D, E | E → D makes low[E] = 3. | ABCDE |
| Finish D | low[D] = index[D] = 3; pop E and D. | ABC |
| Return to C | The edge into now-popped D cannot lower C. | ABC |
| Finish A | low[A] = index[A] = 0; pop C, B, and A. | empty |
Why the Root Test Works
A useful proof can be organized around three invariants rather than one leap of intuition.
1. The active stack contains exactly the unresolved vertices
A vertex is pushed when discovered and popped only when assigned to an SCC. Consequently, an edge to an inactive vertex enters a component that has already been finalized. Letting that edge lower a current low-link would incorrectly combine a one-way dependency with mutual reachability.
2. Low-link values summarize active return routes
After processing v's outgoing edges, low[v] is the earliest active discovery index reachable from the DFS region rooted at v, as captured by the recurrence. A tree child contributes its entire unresolved reachability summary, low[child]. A direct edge to an active vertex contributes that target's fixed index.
3. Equality marks the oldest vertex in one unresolved SCC
If low[v] < index[v], the unresolved region beneath v can reconnect to an older active vertex, so closing a component at v would be premature. If low[v] == index[v], no vertex in that unresolved region can reach an active vertex older than v. The vertices above v that belong to its DFS region are reachable from v; the propagated low-link relationships that kept them unresolved provide return routes within the same active region. Popping through v therefore extracts a mutually reachable set. It is maximal because any older active vertex is unreachable from that set, and any already-popped vertex belongs to a previously sealed SCC.
Every vertex is eventually popped exactly once, so these maximal sets are disjoint and cover the entire graph.
Complexity Analysis
Every vertex is discovered once, pushed once, and popped once. Every directed edge is inspected once in an adjacency-list representation.
$$\text{Time: } O(V + E) \qquad \text{Auxiliary state: } O(V)$$
The adjacency list itself occupies $O(V+E)$ space. A recursive implementation can also consume $O(V)$ call-stack frames in the worst case, such as one long directed path. For very deep or adversarial graphs, use an iterative implementation or a language/runtime with a safely configured stack.
Implementation
These versions keep all mutable state local to one run, cover disconnected graphs, and use the exact three-way edge logic described above. The Python version also returns component IDs and condensation edges; the C++ and Java versions expose component IDs so the same final edge scan can be added directly.
From Components to a Condensation DAG
Finding SCCs is often only the preprocessing step. Assign each vertex a component ID while popping it, then scan the original edges once more. For every edge $u \to v$ with different component IDs, add the edge $\operatorname{comp}(u) \to \operatorname{comp}(v)$. Deduplicate repeated component edges with a set.
The Python implementation above returns this mapping and edge set directly. Because the result is a DAG, problems that were awkward on the cyclic original graph often become straightforward:
- topologically order dependency groups;
- run shortest-path, longest-path, or counting DP over components;
- identify source components with no incoming dependencies and sink components with no outgoing dependencies;
- reason about reachability at component scale instead of vertex scale.
Output Order Is Useful—but Not Canonical
Tarjan normally emits SCCs in reverse topological order of the condensation DAG: sink components close before the components that point to them. The order of vertices inside a component and the ordering among unrelated components still depend on the chosen start vertex and adjacency-list order. Sort only if your tests or output format require a canonical representation.
Tarjan vs. Kosaraju
| Aspect | Kosaraju | Tarjan |
|---|---|---|
| Asymptotic time | $O(V+E)$ | $O(V+E)$ |
| DFS passes | Two | One |
| Transpose graph | Required explicitly or through reverse adjacency | Not required |
| Main bookkeeping | Finish order plus visited state | Index, low-link, active membership, stack |
| Reasoning style | Clean global two-phase argument | Compact local invariant, but subtler updates |
| Component availability | During the second pass | As each root finishes during the DFS |
Neither algorithm is asymptotically faster. Choose Kosaraju when two simple passes and a reverse graph fit naturally. Choose Tarjan when one pass, no transpose, or immediate component emission fits the surrounding system better. For memory-constrained or enormous graphs, representation details, recursion depth, and data locality may matter more than the headline complexity.
Where SCCs Earn Their Keep
Dependencies
Collapse mutually dependent packages or build targets, then order the resulting acyclic dependency graph.
Compilers
Group mutually recursive functions and cyclic data-flow regions before optimization or fixed-point analysis.
Deadlocks
Cycles in wait-for graphs reveal processes that participate in circular waiting relationships.
Model Checking
Analyze recurrent states, liveness conditions, and closed behavior in transition systems.
Web & Networks
Identify mutually navigable regions in link graphs or strongly connected zones in directed infrastructure.
Graph Compression
Replace cyclic regions by super-vertices so downstream algorithms can operate on a smaller DAG.
Pitfalls and Defensive Checks
| Common mistake | Consequence | Defensive habit |
|---|---|---|
Using low[w] for every visited neighbor | Can merge vertices through reachability not justified by the direct edge. | Use index[w] in the active-neighbor branch. |
| Updating through a popped vertex | Can fuse the current region with an already-completed SCC. | Require w in on_stack before the non-tree update. |
| Confusing the two stacks | Popping on function return splits valid components. | Pop Tarjan's stack only at low[v] == index[v]. |
| Starting DFS only once | Misses disconnected or unreachable regions. | Keep the outer loop over all vertices. |
| Listing only edge sources | Omits isolated and destination-only vertices. | Declare the vertex set or infer both endpoints, as the Python version does. |
| Reusing global state | A second run inherits timestamps and components. | Keep state inside a solver instance or function closure. |
| Relying on recursion for a deep graph | Stack overflow or recursion-limit failure. | Use an iterative DFS frame stack for unbounded inputs. |
| Comparing raw output order in tests | Correct solutions appear different. | Compare sets of vertex sets, or sort a copy for display. |
A Small but Powerful Test Suite
Test more than one friendly cycle. Include:
- an empty graph, one isolated vertex, and a long one-way chain;
- one large cycle, a self-loop, and duplicate edges;
- two SCCs joined in one direction—the case that catches incorrect popped-edge updates;
- multiple disconnected regions;
- random small graphs cross-checked against an independent SCC implementation.
Exercises
Build the Invariant, Not Just the Code
- Trace. Add the edge $E \to B$ to the worked example. Predict every final
indexandlowvalue and the SCCs before running code. - Counterexample. Construct the smallest graph where updating
low[v]through an already-popped neighbor produces a wrong answer. - Condense. Extend the C++ or Java implementation to return unique condensation edges and verify that the result has no directed cycle.
- Canonicalize. Write a helper that compares two SCC partitions without depending on component order or vertex order.
- Challenge. Replace recursion with explicit DFS frames that remember the next outgoing edge to inspect. Preserve the moment at which a child's low-link is propagated to its parent.
Hint for 2: make DFS finish one SCC, then later discover a vertex with a one-way edge into that finished component.