01 / The idea
A cycle tells you where one-way reachability comes back.
Checkout reaches pricing and tax, and tax reaches back to checkout. Receipt and email form a second loop, while audit and logger are only reachable in one direction.
A strongly connected component is a maximal set of nodes with a path from every node in the set to every other. The word “strongly” matters: a one-way chain of three nodes is not one component merely because it is connected.
A cycle is a group, not only a warning.
finish order: tax
Finish the forward walk
First, a depth-first walk from checkout follows the arrows and records a node only after everything it points to has finished; the numbers show that order. Tax finishes first; checkout, where the walk began, finishes last. All 7 finish times are the evidence for the second pass.
Reduced motion: choose a scene to see its completed state.
Read this scene
First, a depth-first walk from checkout follows the arrows and records a node only after everything it points to has finished; the numbers show that order. Tax finishes first; checkout, where the walk began, finishes last. All 7 finish times are the evidence for the second pass.
First, a depth-first walk from checkout follows the arrows and records a node only after everything it points to has finished; the numbers show that order. Tax finishes first; checkout, where the walk began, finishes last. All 7 finish times are the evidence for the second pass.
The first pass records finish order; the second pass on reversed edges turns that order into components.
The dependency graph contains four groups: checkout/pricing/tax, receipt/email, audit, and logger. Collapse each group and the larger graph becomes a directed acyclic graph, which is easier to reason about for build order and ownership.
02 / Name the rule
Finish the forward walk. Reverse the arrows. Collect.
In the first depth-first pass, record a node only after all nodes reachable from it have finished. In the second pass, traverse the reversed graph in the reverse of that finish order. Every search started there collects exactly one component.
Record the return order
A node that finishes late has already reached everything below it in the forward graph.
Swap every arrow
The same paths now point back toward the nodes that could reach the start.
Take one group
Start from the last finisher and stop at visited nodes in the reversed graph.
After the second pass, component edges can be treated as edges between groups. That condensation graph has no directed cycle: if two groups could reach one another, they would have belonged to one larger component.
Why not list every cycle?A component contains more than one named cycle
A group of a few nodes can hold dozens of overlapping cycles that share nodes, and listing them all says less than naming the group once. Mutual reachability is the definition; the two passes compute it once for the whole graph and also identify singleton nodes that are not part of a larger cycle.
03 / Read the shape
One traversal orders the evidence; the other names the groups.
Basic form builds forward and reverse adjacency lists and runs both passes. In the wild sorts the groups by name and collapses each one to a single node, the graph a build tool can order. At the call site prints the checkout graph’s four groups and the edges left between them.
Validate a directed graph, build forward and reverse lists, and run both of Kosaraju’s passes: record finish order, then collect one group per reverse search.
export const MAX_NODES = 64;
export const MAX_EDGES = 256;
export type Edge = { from: string; to: string };
export type Graph = { nodes: string[]; edges: Edge[] };
export type ComponentErrorCode =
'too-big' | 'bad-node' | 'duplicate-node' | 'unknown-node' | 'duplicate-edge';
export class ComponentError extends Error {
readonly code: ComponentErrorCode;
constructor(code: ComponentErrorCode, message: string) {
super(message);
this.name = 'ComponentError';
this.code = code;
}
}
function adjacency(graph: Graph) {
if (graph.nodes.length > MAX_NODES || graph.edges.length > MAX_EDGES)
throw new ComponentError('too-big', `up to ${MAX_NODES} nodes and ${MAX_EDGES} edges`);
const nodes = new Set<string>();
for (const node of graph.nodes) {
if (!/^[a-z][a-z0-9-]{0,23}$/.test(node))
throw new ComponentError('bad-node', `node ids are lowercase slugs: ${node}`);
if (nodes.has(node)) throw new ComponentError('duplicate-node', `node appears twice: ${node}`);
nodes.add(node);
}
const next = new Map<string, string[]>();
const reverse = new Map<string, string[]>();
for (const node of graph.nodes) {
next.set(node, []);
reverse.set(node, []);
}
const edges = new Set<string>();
for (const edge of graph.edges) {
if (!nodes.has(edge.from) || !nodes.has(edge.to))
throw new ComponentError(
'unknown-node',
`edge has an unknown endpoint: ${edge.from} → ${edge.to}`
);
const key = `${edge.from}\0${edge.to}`;
if (edges.has(key))
throw new ComponentError('duplicate-edge', `edge appears twice: ${edge.from} → ${edge.to}`);
edges.add(key);
next.get(edge.from)!.push(edge.to);
reverse.get(edge.to)!.push(edge.from);
}
return { next, reverse };
}
// Byte order, the same in every language, so the output never depends on a locale.
const byName = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0);
export type SccTrace = { finishOrder: string[]; collected: string[][] };
// Kosaraju: finish order on the graph, then one reverse-graph search per component.
export function kosaraju(graph: Graph): SccTrace {
const { next, reverse } = adjacency(graph);
const visited = new Set<string>();
const finishOrder: string[] = [];
function finish(node: string) {
visited.add(node);
for (const to of next.get(node)!) if (!visited.has(to)) finish(to);
finishOrder.push(node);
}
for (const node of graph.nodes) if (!visited.has(node)) finish(node);
// A fresh start for the second pass: leftover marks would hide every node.
visited.clear();
const collected: string[][] = [];
function collect(node: string, component: string[]) {
visited.add(node);
component.push(node);
for (const from of reverse.get(node)!) if (!visited.has(from)) collect(from, component);
}
for (const node of [...finishOrder].reverse()) {
if (visited.has(node)) continue;
const component: string[] = [];
collect(node, component);
collected.push(component.sort(byName));
}
return { finishOrder, collected };
} const MaxNodes, MaxEdges = 64, 256
type Edge struct{ From, To string }
type Graph struct {
Nodes []string
Edges []Edge
}
type ComponentError struct{ Code, Message string }
func (e *ComponentError) Error() string { return e.Message }
var nodePattern = regexp.MustCompile(`^[a-z][a-z0-9-]{0,23}$`)
func adjacency(graph Graph) (map[string][]string, map[string][]string, error) {
if len(graph.Nodes) > MaxNodes || len(graph.Edges) > MaxEdges {
return nil, nil, &ComponentError{"too-big", fmt.Sprintf("up to %d nodes and %d edges", MaxNodes, MaxEdges)}
}
nodes := map[string]bool{}
for _, node := range graph.Nodes {
if !nodePattern.MatchString(node) {
return nil, nil, &ComponentError{"bad-node", "node ids are lowercase slugs: " + node}
}
if nodes[node] {
return nil, nil, &ComponentError{"duplicate-node", "node appears twice: " + node}
}
nodes[node] = true
}
next, reverse := map[string][]string{}, map[string][]string{}
for _, node := range graph.Nodes {
next[node] = []string{}
reverse[node] = []string{}
}
seen := map[Edge]bool{}
for _, edge := range graph.Edges {
if !nodes[edge.From] || !nodes[edge.To] {
return nil, nil, &ComponentError{"unknown-node", fmt.Sprintf("edge has an unknown endpoint: %s → %s", edge.From, edge.To)}
}
if seen[edge] {
return nil, nil, &ComponentError{"duplicate-edge", fmt.Sprintf("edge appears twice: %s → %s", edge.From, edge.To)}
}
seen[edge] = true
next[edge.From] = append(next[edge.From], edge.To)
reverse[edge.To] = append(reverse[edge.To], edge.From)
}
return next, reverse, nil
}
type SccTrace struct {
FinishOrder []string
Collected [][]string
}
// Kosaraju: finish order on the graph, then one reverse-graph search per component.
func Kosaraju(graph Graph) (SccTrace, error) {
next, reverse, err := adjacency(graph)
if err != nil {
return SccTrace{}, err
}
visited, finishOrder := map[string]bool{}, []string{}
var finish func(string)
finish = func(node string) {
visited[node] = true
for _, to := range next[node] {
if !visited[to] {
finish(to)
}
}
finishOrder = append(finishOrder, node)
}
for _, node := range graph.Nodes {
if !visited[node] {
finish(node)
}
}
// A fresh start for the second pass: leftover marks would hide every node.
visited = map[string]bool{}
collected := [][]string{}
var collect func(string, *[]string)
collect = func(node string, component *[]string) {
visited[node] = true
*component = append(*component, node)
for _, from := range reverse[node] {
if !visited[from] {
collect(from, component)
}
}
}
for i := len(finishOrder) - 1; i >= 0; i-- {
node := finishOrder[i]
if visited[node] {
continue
}
component := []string{}
collect(node, &component)
sort.Strings(component)
collected = append(collected, component)
}
return SccTrace{finishOrder, collected}, nil
} Reading the TypeScriptFinish order and reverse adjacency
The first DFS pushes a node after its recursive calls. The second DFS reads the
resulting list backwards and walks reverse. Each pass needs a fresh visited
set. The code clears the same set between passes; leftover marks from the forward pass
would hide every node from the second.
Reading the GoClosures that recurse, and a pointer to a slice
finish and collect are closures declared with var first, so each can call itself. collect takes a *[]string,
because append can return a new slice and the caller has to see every node
added. The second pass starts from a new visited map where the TypeScript
clears its Set; either way no mark from the forward pass survives.
Duplicate edges are caught with the Edge struct itself as a map key. Each
group is sorted with sort.Strings, and StronglyConnectedComponents orders the groups with sort.Slice by their first name. That sort isn’t stable, but it doesn’t need to be: groups never share
a node, so no two first names are equal. Errors come back as a *ComponentError whose Code and Message match the TypeScript.
What is refusedBounded directed input
Both versions accept up to 64 unique lowercase node ids matching ^[a-z][a-z0-9-]{0,23}$ and 256 directed edges. Unknown endpoints and
duplicate edges are refused. Self-edges are valid: a node can be its own component even when
no other node joins it.
04 / Try a decision
Does audit join the email group?
Receipt, email, audit, and logger are connected in the everyday sense: one path runs through all four. Strong connectivity asks for more.
05 / Follow the cost
Two linear passes beat repeated pair checks.
| Operation | Time | Extra space | What it assumes |
|---|---|---|---|
| Build forward and reverse lists | O(V + E) | O(V + E) | Keep each directed edge once in each traversal direction. |
| First depth-first pass | O(V + E) | O(V) | Record a node after all of its outgoing descendants finish. |
| Second depth-first pass | O(V + E) | O(V) | Visit the reversed graph in decreasing finish order. |
| Sort component labels | O(V log V) | O(V) | The lesson sorts names only to make output deterministic; the two passes remain linear. |
| Collapse the groups | O(V + E log E) | O(V + E) | One pass over the edges keeps those between different groups; sorting them is for stable output. |
With V nodes and E edges, both traversals together are O(V + E), and the adjacency lists, visited sets, and finish order use O(V + E) space. Sorting names for stable display is separate from finding the components.
Tarjan’s algorithm finds the same groups in one depth-first pass with a stack and low-link values. Kosaraju spends another adjacency list and another pass to make the invariant easier to see.
06 / Give it a real job
Name the knot in the pull request.
A shop’s code lives in one repository, and a check runs on every pull request. It reads the
imports between modules, runs stronglyConnectedComponents, and compares the
groups with the ones on the main branch. A new group, or a module joining an old one, fails
the check with the names: “checkout, pricing, tax” is a knot three teams can talk about,
where a single cycle path would show only one way round it.
condense gives the rest of the pipeline an order it can trust. The collapsed graph
has no cycles, so groups can be built, tested, or reviewed with everything a group depends on
done first, even though the modules inside a group can’t be put in any order. Compilers do the
same with calls: LLVM’s documentation describes passes that “traverse the program bottom-up on
the call graph (callees before callers),” taking functions that call each other one group at a
time.
It leaves out the repair. Which import to cut, or whether the three modules are really one, is a person’s decision, and imports resolved only at run time never reach the graph. This runs in the build or the check over the import graph; nothing in a component computes it.
07 / Make the call
Use components to name the loop before choosing the repair.
Use SCCs for dependency cycles, mutually reachable states, web-link clusters, and implication graphs. Use topological sort after the cycles are removed or when the input should already be acyclic. Use BFS or DFS for reachability from one start, not for grouping all mutual reachability.
In Python, NetworkX’s strongly_connected_components uses Tarjan’s one-pass
method, and condensation collapses the groups for you. Kosaraju is here because its
two passes are easier to watch; the groups are the same.
SourcesDocumentation, checked 23 September 2026
- NetworkX 3.7,
strongly_connected_components: “Iterative version of Tarjan’s algorithm,” andcondensation: “After contracting all strongly connected components to a single node, the resulting graph is a directed acyclic graph.” - LLVM, Writing an LLVM Pass: “The CallGraphSCCPass is used by passes that need to traverse the program bottom-up on the call graph (callees before callers).”
08 / Take the idea with you
Explain a dependency knot without saying “strongly connected.”
“Some modules all lead back to each other, so none of them can go first. Find each bunch like that: two modules are in the same bunch when each can reach the other by following the arrows. Treat every bunch as one box, and the boxes line up in an order, even though what’s inside a box doesn’t.”
Before moving on, open a project you know and pick two modules that import each other, directly or through others. List every module in their knot, then name the one import that would untie it.
Connections to follow nextRelated lessons
- Topological sort orders the collapsed groups, once no cycle is left.
- BFS and DFS is the depth-first walk both passes are made of.
- PageRank meets the same groups: a group with no link out would hoard score without teleportation.