Connected Components — Graph Decomposition
Counting and Labeling Components
Read aloud is unavailable in this browser.
Beyond Finding: Organizing Component Information
Finding connected components is just the first step. In practice, we often need to answer sophisticated queries about components:
- "How many components exist?"
- "Which component does vertex v belong to?"
- "Are vertices u and v in the same component?"
- "What's the size of the largest component?"
- "Give me all vertices in vertex v's component."
The naive approach—run a full traversal for each query—is expensive. With proper preprocessing, we can answer these queries in O(1) time.
This page covers techniques for efficiently counting components, labeling vertices with component identifiers, tracking component sizes, and building data structures that support fast queries.
By the end of this page, you'll be able to preprocess a graph to support constant-time component queries, track component sizes efficiently, find the largest component, and understand when to use component labeling versus Union-Find for dynamic scenarios.
Counting Components Efficiently
The simplest component operation is counting: "How many connected components does this graph have?"
The Algorithm:
Counting components is a natural byproduct of our traversal approach. Each time we start a new traversal from an unvisited vertex, we've found a new component. Just count how many times this happens.
Implementation:
1234567891011121314151617181920212223242526272829303132333435
/** * Count the number of connected components in an undirected graph * Time: O(V + E) Space: O(V) */function countComponents(graph: Map<number, number[]>): number { const visited = new Set<number>(); let componentCount = 0; function dfs(vertex: number): void { visited.add(vertex); for (const neighbor of graph.get(vertex) || []) { if (!visited.has(neighbor)) { dfs(neighbor); } } } for (const vertex of graph.keys()) { if (!visited.has(vertex)) { dfs(vertex); componentCount++; // Each new DFS = one new component } } return componentCount;} // Example usageconst graph = new Map([ [0, [1, 2]], [1, [0, 2]], [2, [0, 1]], // Component 1 [3, [4]], [4, [3]], // Component 2 [5, []] // Component 3 (isolated)]); console.log(countComponents(graph)); // Output: 3Why This Is Optimal:
Counting requires O(V + E) time—the same as a single full traversal. You cannot count faster without knowing the answer ahead of time, because:
- You must check every vertex (to find isolated components)
- You must examine edges (to determine connectivity)
Checking Connectivity:
A graph is connected if and only if it has exactly one component. This gives us an efficient connectivity check:
123456789101112131415161718192021222324252627
/** * Check if a graph is connected (all vertices mutually reachable) * Time: O(V + E) Space: O(V) */function isConnected(graph: Map<number, number[]>): boolean { const vertices = Array.from(graph.keys()); // Empty graph or single vertex is connected by convention if (vertices.length <= 1) return true; // Run one DFS from any vertex const visited = new Set<number>(); function dfs(v: number): void { visited.add(v); for (const neighbor of graph.get(v) || []) { if (!visited.has(neighbor)) { dfs(neighbor); } } } dfs(vertices[0]); // Connected iff all vertices were reached return visited.size === vertices.length;}For connectivity checking, we can stop as soon as we've visited all vertices. In practice, if the graph is connected, DFS will visit all vertices naturally. If it's disconnected, we discover this when DFS terminates but visited.size < vertices.length.
Labeling Vertices with Component IDs
Counting tells us how many components exist, but often we need to know which component each vertex belongs to. By assigning each vertex a component ID during traversal, we enable O(1) queries.
The Labeling Process:
- Initialize a component counter to 0
- Initialize a mapping: vertex → component ID
- For each unvisited vertex:
- Traverse its component, assigning the current component ID to all vertices
- Increment the component counter
- Return the mapping
Benefits of Component IDs:
- Same component check: Compare IDs (O(1))
- Component lookup: Get vertices with same ID
- Component size: Count vertices with each ID
- Graph partitioning: Process each component separately
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556
interface LabelingResult { componentId: Map<number, number>; // vertex -> component ID componentCount: number; componentSizes: number[]; // componentSizes[id] = size} /** * Label each vertex with its component ID and compute sizes * Time: O(V + E) Space: O(V) */function labelComponents(graph: Map<number, number[]>): LabelingResult { const componentId = new Map<number, number>(); const componentSizes: number[] = []; let currentComponent = 0; function dfs(vertex: number, componentIdx: number): number { componentId.set(vertex, componentIdx); let size = 1; // Count this vertex for (const neighbor of graph.get(vertex) || []) { if (!componentId.has(neighbor)) { size += dfs(neighbor, componentIdx); } } return size; } for (const vertex of graph.keys()) { if (!componentId.has(vertex)) { const size = dfs(vertex, currentComponent); componentSizes.push(size); currentComponent++; } } return { componentId, componentCount: currentComponent, componentSizes };} // Usage exampleconst graph = new Map([ [0, [1, 2]], [1, [0, 2]], [2, [0, 1]], // Component 0 (size 3) [3, [4]], [4, [3]], // Component 1 (size 2) [5, []] // Component 2 (size 1)]); const result = labelComponents(graph);console.log("Component of vertex 0:", result.componentId.get(0)); // 0console.log("Component of vertex 4:", result.componentId.get(4)); // 1console.log("Component sizes:", result.componentSizes); // [3, 2, 1]console.log("Same component (0, 2)?", result.componentId.get(0) === result.componentId.get(2)); // trueThe O(V + E) preprocessing time is paid once. After that, every same-component query is O(1). If you have Q queries on a static graph, total time is O(V + E + Q) instead of O(Q × (V + E)) without preprocessing.
Finding the Largest Component
A common query in network analysis is finding the largest connected component—the biggest cluster of interconnected vertices. In social networks, this is often called the "giant component."
Approach 1: Track Sizes During Traversal
The most efficient approach computes component sizes during the initial traversal, then finds the maximum:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253
interface LargestComponentResult { vertices: number[]; size: number; componentId: number;} /** * Find the largest connected component * Time: O(V + E) Space: O(V) */function findLargestComponent( graph: Map<number, number[]>): LargestComponentResult | null { if (graph.size === 0) return null; const visited = new Set<number>(); let largestVertices: number[] = []; let largestSize = 0; let largestId = -1; let currentId = 0; function dfs(vertex: number, component: number[]): void { visited.add(vertex); component.push(vertex); for (const neighbor of graph.get(vertex) || []) { if (!visited.has(neighbor)) { dfs(neighbor, component); } } } for (const vertex of graph.keys()) { if (!visited.has(vertex)) { const component: number[] = []; dfs(vertex, component); if (component.length > largestSize) { largestVertices = component; largestSize = component.length; largestId = currentId; } currentId++; } } return { vertices: largestVertices, size: largestSize, componentId: largestId };}Approach 2: Use Pre-computed Component Sizes
If you've already computed component sizes using the labeling function, finding the largest is trivial:
12345678910111213141516171819202122232425262728293031
function getLargestComponentFromLabeling( result: LabelingResult): { componentId: number; size: number } { let maxSize = 0; let maxId = 0; for (let id = 0; id < result.componentSizes.length; id++) { if (result.componentSizes[id] > maxSize) { maxSize = result.componentSizes[id]; maxId = id; } } return { componentId: maxId, size: maxSize };} // Get vertices in a specific componentfunction getComponentVertices( componentId: number, labelResult: LabelingResult): number[] { const vertices: number[] = []; for (const [vertex, id] of labelResult.componentId.entries()) { if (id === componentId) { vertices.push(vertex); } } return vertices;}In many real-world networks, one component contains a significant fraction of all vertices (often 50-90%). This 'giant component' emerges through a phase transition as network density increases. Understanding this helps predict network behavior and guides algorithm design.
Computing Component Statistics
For comprehensive graph analysis, we often need various statistics about components. Let's build a complete component analyzer.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104
interface ComponentStats { totalComponents: number; totalVertices: number; totalEdges: number; componentSizes: number[]; largestComponentSize: number; smallestComponentSize: number; averageComponentSize: number; isolatedVertices: number; // Components of size 1 giantComponentRatio: number; // Fraction in largest component} class ComponentAnalyzer { private graph: Map<number, number[]>; private componentId: Map<number, number>; private componentSizes: number[]; private componentCount: number; constructor(graph: Map<number, number[]>) { this.graph = graph; this.componentId = new Map(); this.componentSizes = []; this.componentCount = 0; this.analyze(); } private analyze(): void { let currentComponent = 0; const dfs = (vertex: number, compId: number): number => { this.componentId.set(vertex, compId); let size = 1; for (const neighbor of this.graph.get(vertex) || []) { if (!this.componentId.has(neighbor)) { size += dfs(neighbor, compId); } } return size; }; for (const vertex of this.graph.keys()) { if (!this.componentId.has(vertex)) { const size = dfs(vertex, currentComponent); this.componentSizes.push(size); currentComponent++; } } this.componentCount = currentComponent; } getStats(): ComponentStats { const totalVertices = this.graph.size; // Count edges (each edge counted once) let edgeCount = 0; for (const neighbors of this.graph.values()) { edgeCount += neighbors.length; } edgeCount = edgeCount / 2; // Undirected: divide by 2 const largest = Math.max(...this.componentSizes, 0); const smallest = Math.min(...this.componentSizes, 0); const average = totalVertices / Math.max(this.componentCount, 1); const isolated = this.componentSizes.filter(s => s === 1).length; const giantRatio = totalVertices > 0 ? largest / totalVertices : 0; return { totalComponents: this.componentCount, totalVertices, totalEdges: edgeCount, componentSizes: [...this.componentSizes], largestComponentSize: largest, smallestComponentSize: smallest, averageComponentSize: average, isolatedVertices: isolated, giantComponentRatio: giantRatio }; } // O(1) queries after preprocessing sameComponent(u: number, v: number): boolean { return this.componentId.get(u) === this.componentId.get(v); } getComponentOf(vertex: number): number | undefined { return this.componentId.get(vertex); } getComponentSize(vertex: number): number { const compId = this.componentId.get(vertex); if (compId === undefined) return 0; return this.componentSizes[compId]; } isInLargestComponent(vertex: number): boolean { const compId = this.componentId.get(vertex); if (compId === undefined) return false; const largest = Math.max(...this.componentSizes); return this.componentSizes[compId] === largest; }}Usage Example:
1234567891011121314151617181920212223242526272829303132
// Build a sample graphconst graph = new Map<number, number[]>([ [0, [1, 2, 3]], [1, [0, 2]], [2, [0, 1]], [3, [0]], [4, [5]], [5, [4]], [6, []], [7, []],]); const analyzer = new ComponentAnalyzer(graph);const stats = analyzer.getStats(); console.log(`Total components: ${stats.totalComponents}`);// Output: Total components: 4 console.log(`Component sizes: ${stats.componentSizes}`);// Output: Component sizes: [4, 2, 1, 1] console.log(`Giant component ratio: ${(stats.giantComponentRatio * 100).toFixed(1)}%`);// Output: Giant component ratio: 50.0% console.log(`Isolated vertices: ${stats.isolatedVertices}`);// Output: Isolated vertices: 2 console.log(`Are 0 and 3 connected? ${analyzer.sameComponent(0, 3)}`);// Output: Are 0 and 3 connected? true console.log(`Are 0 and 4 connected? ${analyzer.sameComponent(0, 4)}`);// Output: Are 0 and 4 connected? falseComputing all statistics takes O(V + E) time—the same as basic labeling. If you need multiple statistics, compute them together. If you only need one specific piece of information, you might optimize to compute just that.
Building a Component Inverted Index
Sometimes you need to efficiently retrieve all vertices in a given component. While you can scan the component ID map (O(V)), an inverted index provides O(1) access to any component's vertex list.
Structure:
- Forward index: vertex → component ID (what we've built so far)
- Inverted index: component ID → list of vertices
Building Both Indices:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061
interface ComponentIndices { vertexToComponent: Map<number, number>; // Forward: vertex -> component componentToVertices: number[][]; // Inverted: component -> vertices componentCount: number;} /** * Build both forward and inverted component indices * Time: O(V + E) Space: O(V) */function buildComponentIndices( graph: Map<number, number[]>): ComponentIndices { const vertexToComponent = new Map<number, number>(); const componentToVertices: number[][] = []; let currentComponent = 0; function dfs(vertex: number, componentVertices: number[]): void { vertexToComponent.set(vertex, currentComponent); componentVertices.push(vertex); for (const neighbor of graph.get(vertex) || []) { if (!vertexToComponent.has(neighbor)) { dfs(neighbor, componentVertices); } } } for (const vertex of graph.keys()) { if (!vertexToComponent.has(vertex)) { const vertices: number[] = []; dfs(vertex, vertices); componentToVertices.push(vertices); currentComponent++; } } return { vertexToComponent, componentToVertices, componentCount: currentComponent };} // Usageconst indices = buildComponentIndices(graph); // O(1): Get component ID of a vertexconst compId = indices.vertexToComponent.get(0); // 0 // O(1): Get all vertices in a componentconst verticesInComp0 = indices.componentToVertices[0]; // [0, 1, 2, 3] // O(1): Check if two vertices are in same componentconst same = indices.vertexToComponent.get(0) === indices.vertexToComponent.get(1); // true // O(1): Get size of vertex's componentconst size = indices.componentToVertices[ indices.vertexToComponent.get(0)!].length; // 4The inverted index uses O(V) additional space but enables O(1) access to component vertex lists. Only build it if you need to frequently enumerate component members. For just same-component queries or size queries, the forward index suffices.
Handling Edge Cases in Labeling
Robust component labeling must handle various edge cases correctly. Let's examine them systematically.
| Case | Expected Result | Common Bug |
|---|---|---|
| Empty graph (no vertices) | 0 components, empty indices | Null pointer / empty iteration failure |
| Single vertex, no edges | 1 component of size 1 | Missing isolated vertex handling |
| All isolated vertices | n components of size 1 | Not iterating over all vertices |
| Fully connected graph | 1 component of size n | Inefficient for dense graphs |
| Non-contiguous vertex IDs | Correct components | Array index out of bounds |
| Self-loops | No effect on connectivity | Infinite loop if not handled |
| Parallel edges | No effect on connectivity | Should just work |
123456789101112131415161718192021222324252627282930313233343536373839404142434445
// Edge case test suitefunction testEdgeCases(): void { // Test 1: Empty graph const empty = new Map<number, number[]>(); const emptyResult = labelComponents(empty); console.assert(emptyResult.componentCount === 0); console.assert(emptyResult.componentSizes.length === 0); // Test 2: Single isolated vertex const single = new Map([[0, []]]); const singleResult = labelComponents(single); console.assert(singleResult.componentCount === 1); console.assert(singleResult.componentSizes[0] === 1); // Test 3: All isolated vertices const allIsolated = new Map([ [0, []], [1, []], [2, []], [3, []] ]); const allIsoResult = labelComponents(allIsolated); console.assert(allIsoResult.componentCount === 4); console.assert(allIsoResult.componentSizes.every(s => s === 1)); // Test 4: Non-contiguous IDs const nonContig = new Map([ [100, [200, 500]], [200, [100]], [500, [100]], [999, []] ]); const ncResult = labelComponents(nonContig); console.assert(ncResult.componentCount === 2); console.assert(ncResult.componentId.get(100) === ncResult.componentId.get(200)); console.assert(ncResult.componentId.get(100) !== ncResult.componentId.get(999)); // Test 5: Self-loop (vertex with edge to itself) const selfLoop = new Map([ [0, [0, 1]], // 0 has edge to itself [1, [0]] ]); const slResult = labelComponents(selfLoop); console.assert(slResult.componentCount === 1); console.assert(slResult.componentSizes[0] === 2); // Self-loop doesn't add size console.log("All edge case tests passed!");}Self-loops can cause issues if your DFS doesn't properly check visited status. When vertex 0 has edge to itself, exploring 0's neighbors includes 0—but since we mark 0 visited before exploring neighbors, it's correctly skipped. Always mark visited BEFORE exploring neighbors.
When Labeling Isn't Enough: Union-Find Preview
The labeling approach works beautifully for static graphs—graphs that don't change after the initial preprocessing. But what about dynamic graphs where edges are added or removed?
The Problem with Relabeling:
If edges change, our component labels become invalid. Options:
- Recompute from scratch: O(V + E) per update—expensive
- Incremental update: Complex and error-prone with DFS labels
Enter Union-Find (Disjoint Set Union):
Union-Find is a data structure specifically designed for dynamic connectivity:
- Union: Merge two components when an edge is added → O(α(n)) ≈ O(1)
- Find: Determine which component a vertex belongs to → O(α(n)) ≈ O(1)
Here, α(n) is the inverse Ackermann function—grows so slowly it's effectively constant for any practical input size.
- •Graph is static (no edge changes)
- •You need all components at once
- •You want to iterate over components
- •You need component statistics
- •Memory is constrained (slightly more efficient)
- •Edges are added dynamically
- •You only need same-component queries
- •Building graph from edge stream
- •Kruskal's MST algorithm
- •Real-time connectivity updates
Quick Union-Find Sketch:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970
class UnionFind { private parent: Map<number, number>; private rank: Map<number, number>; private componentCount: number; constructor(vertices: number[]) { this.parent = new Map(); this.rank = new Map(); this.componentCount = vertices.length; // Initially, each vertex is its own component for (const v of vertices) { this.parent.set(v, v); this.rank.set(v, 0); } } // Find with path compression - O(α(n)) find(x: number): number { if (this.parent.get(x) !== x) { this.parent.set(x, this.find(this.parent.get(x)!)); } return this.parent.get(x)!; } // Union by rank - O(α(n)) union(x: number, y: number): boolean { const rootX = this.find(x); const rootY = this.find(y); if (rootX === rootY) return false; // Already same component // Attach smaller tree under larger tree const rankX = this.rank.get(rootX)!; const rankY = this.rank.get(rootY)!; if (rankX < rankY) { this.parent.set(rootX, rootY); } else if (rankX > rankY) { this.parent.set(rootY, rootX); } else { this.parent.set(rootY, rootX); this.rank.set(rootX, rankX + 1); } this.componentCount--; return true; } connected(x: number, y: number): boolean { return this.find(x) === this.find(y); } getComponentCount(): number { return this.componentCount; }} // Usage: Build components from edge streamconst uf = new UnionFind([0, 1, 2, 3, 4, 5]);console.log(uf.getComponentCount()); // 6 (all isolated) uf.union(0, 1);console.log(uf.getComponentCount()); // 5 uf.union(1, 2);console.log(uf.getComponentCount()); // 4 console.log(uf.connected(0, 2)); // true (0-1-2 connected)console.log(uf.connected(0, 3)); // falseUnion-Find is covered comprehensively in Chapter 24 (Advanced Graph Algorithms). This preview shows why it exists and when to consider it. For static component finding, the DFS/BFS approach from this module is simpler and sufficient.
KEEP LEARNING
There’s more to understand.
You’re reading the introduction. Create a free account to read the complete lesson, save notes and keep track of your progress.