← Applied algorithms
Routes and connections When a graph has loops inside it

Strongly connected components

Turn cycles into groups.

A dependency graph can contain a cycle: checkout needs pricing, pricing needs tax, and tax reaches back to checkout. A warning that says “there is a cycle” is useful, but it does not tell you which modules must be considered together.

Strongly connected components find the maximal groups where every node can reach every other node. Kosaraju’s two passes make those groups visible without trying every pair of nodes.

TypeScriptGoOne dependency graph in each language

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.

Strongly connected components

A cycle is a group, not only a warning.

DEPENDENCY GRAPH · KOSARAJU1 of 7 finished
checkoutpricingtax1receiptemailauditlogger

finish order: tax

01/ 03
Finish the forward walk

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.

Finish

Record the return order

A node that finishes late has already reached everything below it in the forward graph.

Reverse

Swap every arrow

The same paths now point back toward the nodes that could reach the start.

Collect

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.

TypeScriptReading
components.ts
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 };
}
GoAlongside
components.go
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.

Receipt calls email and email calls receipt back. Email also calls audit, and audit calls logger. Does audit join the receipt and email group?

05 / Follow the cost

Two linear passes beat repeated pair checks.

Kosaraju: time and extra space
OperationTimeExtra spaceWhat it assumes
Build forward and reverse listsO(V + E)O(V + E)Keep each directed edge once in each traversal direction.
First depth-first passO(V + E)O(V)Record a node after all of its outgoing descendants finish.
Second depth-first passO(V + E)O(V)Visit the reversed graph in decreasing finish order.
Sort component labelsO(V log V)O(V)The lesson sorts names only to make output deterministic; the two passes remain linear.
Collapse the groupsO(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,” and condensation: “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.