-
We are given a graph with
nnodes labeled from0ton − 1and anedgeslist, where each edge is a pair[u, v]indicating an undirected connection between node u and node v. The goal is to determine how many connected components (disconnected groups of nodes) this graph has. -
Intuition:
At the very beginning, when we have no edges, each node stands alone as its own connected piece of the graph. So we conceptually start withnseparate components.
As we process edges one by one:-
If an edge
[u, v]connects two nodes that were in different components before encountering this edge, then it merges the component containing node u and the component containing node v into a single larger component, and the total number of components decreases by 1. -
If an edge
[u, v]connects two nodes that were already connected (i.e., already in the same component before encountering this edge), then it does not merge two components into one and therefore does not reduce the component count — it simply adds another connection inside that same component.
The key difficulty is efficiently answering the question: "Are these two nodes already in the same connected component, or not?" This is precisely what the Disjoint-Set / Union–Find structure is good at. -
If an edge
-
Disjoint-Set (Union–Find) Structure:
We maintain two arrays:-
parent[i]— stores the parent of node i in the disjoint-set forest. Initially,parent[i] = ifor every node i, meaning each node starts as its own separate set (component). -
rank[i]— used as a union-by-rank heuristic. For a root node i,rank[i]is an upper bound on the number ofparent[]links one might follow to reach node i from any node in its set. Since each node initially forms a single-node set (already pointing to itself as its own root), its maximum possible parent-link depth is 0, so allrank[i]values are initialized to 0. It is meaningful only for root nodes; for non-root nodes, it may be outdated and is not used to guide unions.
Why use rank and path compression? Rank makes sure trees grow minimally by always attaching the shallower tree under the deeper one, while path compression flattens existing paths duringfind()calls by making nodes point directly to their root. Used together, they keep the forest almost flat at all times, ensuring eachfind()operation runs in amortized near-constant time for all practical n. -
-
Find(x) — Two-Pass Path Compression:
-
Pass 1 – Root Discovery:
Starting from node x, we follow
parent[]references until we reach the root r whereparent[r] === r. This node r is the representative of the connected component containing node x. -
Pass 2 – Path Compression:
We then walk back along the same path from node x toward the root node r,
updating each intermediate node’s
parent[]entry to point directly to node r. -
Effect:
This flattening step drastically reduces the number of parent links inside each component,
so future
find()calls on those nodes become extremely fast.
-
Pass 1 – Root Discovery:
Starting from node x, we follow
-
Union(x, y) — Merge by Rank and Update Component Count:
-
To process an edge
[x, y], we first computerootX = find(x)androotY = find(y). These are the representative roots of the connected components containing node x and node y, respectively. -
If
rootX === rootY, then node x and node y are already in the same component — there is already some path connecting them. In this case, we perform no merge and simply returnfalseto indicate that these two nodes were already in the same component. -
If
rootX !== rootY, then the edge[x, y]connects two different components. We merge these two components into one using union by rank:-
🔄 Case 1 — rank[rootX] > rank[rootY]:
rootX has a higher rank than rootY, so rootX becomes the parent and absorbs rootY’s component. The rank of rootX remains unchanged after the merge. Adding rootY under rootX introduces exactly one additional parent link for the nodes previously rooted at rootY to reach their new root (rootX), but does not go beyond the maximum number of parent links needed to reach rootX within its own component. -
🔄 Case 2 — rank[rootX] < rank[rootY]:
rootY has a higher rank than rootX, so rootY becomes the parent and absorbs rootX’s component. The rank of rootY remains unchanged after the merge. Adding rootX under rootY introduces exactly one additional parent link for the nodes previously rooted at rootX to reach their new root (rootY), but does not go beyond the maximum number of parent links needed to reach rootY within its own component. -
🔄 Case 3 — rank[rootX] === rank[rootY]:
Both roots have equal rank, so we choose rootX as the new root and increment its rank by 1. Since both components have the same maximum number of parent links to their roots, attaching rootY under rootX makes each node in rootY’s component gain exactly one additional parent link to reach their new root. This makes the merged component’s maximum link depth one more than the maximum link depth of the individual components before merging (considering their existing roots before the merge), which is why rank[rootX] is incremented. -
Since early termination did not happen, it means that
rootX !== rootY, so node x and node y belonged to two different components before encountering this edge. Therefore, this edge merges the component containing x with the component containing y according to the union-by-rank rules, and we returntrueto indicate that a merge occurred.
-
🔄 Case 1 — rank[rootX] > rank[rootY]:
-
To process an edge
-
Edge Traversal and Component Counting:
We let the parameternserve as our running count of connected components. This works because at the start, each node is the root of its own single-node component.
Then we iterate over every edge[u, v]inedges:- We call
union(u, v)to attempt to merge the components containing u and v. -
If
union(u, v)merges two different components (i.e., returnstruein the implementation), we decrementnby 1 because two components became one. -
If
union(u, v)determines that u and v were already in the same component before this edge was processed, no merge occurs andnremains unchanged.
nexactly equals the number of connected components in the graph — which is the final answer. - We call
Count Connected Components - JS Solution
JSEditorial
Editorial solutions and visualizations are prepared with care, but we do not guarantee 100% accuracy or completeness. Please use your own judgment to verify results. Visucodize is not responsible for decisions made based on this content.
Solution code
const POINTER1 = 'p1', POINTER1_COLOR = 'purple';
const POINTER2 = 'p2', POINTER2_COLOR = 'orange';
const ROOT = 'root', ROOT_COLOR = 'gold';
const CURRENT = 'current', CURRENT_COLOR = 'blue';
const JUST_ADDED = 'just_added', JUST_ADDED_COLOR = 'green';
function visMainFunction(n, edgesInput) {
initClassProperties();
explanationAndColorSchemaNotifications();
startBatch();
const edges = new VisArray2D(...edgesInput.map(pair => new VisArray(...pair)));
const parent = new VisArray(...Array.from({ length: n }, (_, i) => i));
const rank = new VisArray(n).fill(0);
// note that nodes and graph are used for visualization purposes they are not part of the original algorithm
const nodes = new Array(n).fill(null).map((_, index) => ({ value: index }));
const graph = new VisNetworkGraph({ nodes, edges: [] }).options({ layoutOptions: { rankdir: 'LR', directed: false } });
makeVisVariable(n).options({ label: 'n (connected components count)' }).createVis();
endBatch();
notification(`
🧩 The <code>parent[]</code> array stores, for each node, a reference to its parent
in the <b>disjoint-set forest</b>.<br>
Initially, every <b>node i</b> is its own parent (<code>parent[i] = i</code>),
meaning all <b>${n}</b> nodes start as <b>separate components</b> — none are connected yet.<br><br>
The <code>rank[i]</code> value serves as a <b>union-by-rank heuristic</b>:
for a root <b>node i</b>, it represents an <b>upper bound</b> on the number of <code>parent[]</code> links
one might follow to reach <b>node i</b> from any node in its component.<br>
Since each node begins as a <b>single-node component</b> (already pointing to itself as its own root),
its maximum possible parent-link depth is <b>0</b>, so all ranks are initialized to <b>0</b>.<br>
Rank is meaningful only for roots; for non-root nodes it may be outdated and is not used.<br><br>
When two components are merged using <code>union(x, y)</code>, the root with the <b>higher rank</b> becomes the parent.
If both ranks are equal, one of the roots becomes the parent and its rank is <b>incremented by 1</b>.<br>
This balancing rule (later helped by <b>path compression</b> during <code>find()</code>) keeps components shallow,
ensuring that all future operations remain efficient.
`);
const find = (x) => {
notification(`
🔍 Starting root search for <b>node ${x}</b>.<br>
We first treat <b>node ${x}</b> as the ${makeColoredSpan('initial root candidate', ROOT_COLOR)}
and follow its <code>parent[]</code> links step by step
until we find the ${makeColoredSpan('true root', ROOT_COLOR)} of the set it belongs to —
i.e., the node whose parent reference points to itself (<code>parent[root] === root</code>).
`);
let root = x;
makeVisVariable(root).registerAsIndexPointer(parent, ROOT);
while (parent[root] !== root) {
root = parent[root];
}
notification(`
🛠️ Compressing the path from ${makeColoredSpan('node ' + x, CURRENT_COLOR)} to the
${makeColoredSpan('root', ROOT_COLOR)} of the set it belongs to (${makeColoredSpan('node ' + root, ROOT_COLOR)}).<br><br>
<b>How:</b> Starting from <b>node ${x}</b>, we follow its <code>parent[]</code> references step by step until reaching the root,
updating each intermediate node’s <code>parent[]</code> entry along the way to point <b>directly</b> to that root.<br>
<b>Why:</b> This process, known as <b>path compression</b>, flattens the forest by redirecting intermediate parent links
straight to the current root of the set.
`);
let current = x;
makeVisVariable(current).registerAsIndexPointer(parent, CURRENT);
while (parent[current] !== root) {
const next = parent[current];
parent[current] = root;
current = next;
}
notification(`
✅ Path compression complete.<br>
Every node along the path from ${makeColoredSpan('node ' + x, CURRENT_COLOR)} now points directly to the
${makeColoredSpan('root', ROOT_COLOR)} of its set (${makeColoredSpan('node ' + root, ROOT_COLOR)}),
ensuring future <code>find</code> calls on these nodes reach the root in (almost) constant time.
`);
return root;
};
const union = (x, y) => {
const rootX = find(x);
const vmX = rank.makeVisManagerForIndex(rootX).addClass(POINTER1);
const rootY = find(y);
const vmY = rank.makeVisManagerForIndex(rootY).addClass(POINTER2);
if (rootX === rootY) {
vmX.removeClass(POINTER1);
vmY.removeClass(POINTER2);
return false;
}
if (rank[rootX] > rank[rootY]) {
notification(`
🔄 ${makeColoredSpan('Union by Rank:', POINTER1_COLOR)}
Root ${makeColoredSpan(rootX, POINTER1_COLOR)} has a <b>higher rank</b> than ${makeColoredSpan(rootY, POINTER2_COLOR)},
so it becomes the parent and absorbs ${makeColoredSpan(rootY, POINTER2_COLOR)}’s component.
The rank of ${makeColoredSpan(rootX, POINTER1_COLOR)} remains <b>unchanged</b> after the merge.
Adding ${makeColoredSpan(rootY, POINTER2_COLOR)} under ${makeColoredSpan(rootX, POINTER1_COLOR)}
introduces exactly <b>one additional parent link</b> for the nodes previously rooted at ${makeColoredSpan(rootY, POINTER2_COLOR)}
to reach their new root (${makeColoredSpan(rootX, POINTER1_COLOR)}),
but does <b>not go beyond</b> the current upper bound on the number of parent links needed
to reach ${makeColoredSpan(rootX, POINTER1_COLOR)} within its own component.
`);
parent[rootY] = rootX;
} else if (rank[rootX] < rank[rootY]) {
notification(`
🔄 ${makeColoredSpan('Union by Rank:', POINTER2_COLOR)}
Root ${makeColoredSpan(rootY, POINTER2_COLOR)} has a <b>higher rank</b> than ${makeColoredSpan(rootX, POINTER1_COLOR)},
so it becomes the parent and absorbs ${makeColoredSpan(rootX, POINTER1_COLOR)}’s component.
The rank of ${makeColoredSpan(rootY, POINTER2_COLOR)} remains <b>unchanged</b> after the merge.
Adding ${makeColoredSpan(rootX, POINTER1_COLOR)} under ${makeColoredSpan(rootY, POINTER2_COLOR)}
introduces exactly <b>one additional parent link</b> for the nodes previously rooted at ${makeColoredSpan(rootX, POINTER1_COLOR)}
to reach their new root (${makeColoredSpan(rootY, POINTER2_COLOR)}),
but does <b>not go beyond</b> the current upper bound on the number of parent links needed
to reach ${makeColoredSpan(rootY, POINTER2_COLOR)} within its own component.
`);
parent[rootX] = rootY;
} else {
notification(`
🔄 ${makeColoredSpan('Union by Rank:', POINTER1_COLOR)}
Roots ${makeColoredSpan(rootX, POINTER1_COLOR)} and ${makeColoredSpan(rootY, POINTER2_COLOR)} have <b>equal rank</b>,
so we choose ${makeColoredSpan(rootX, POINTER1_COLOR)} as the new root and increment its rank by <b>1</b>.
Since both components have the same upper bound on the number of parent links to their roots,
attaching ${makeColoredSpan(rootY, POINTER2_COLOR)} under ${makeColoredSpan(rootX, POINTER1_COLOR)}
makes each node in ${makeColoredSpan(rootY, POINTER2_COLOR)}’s component gain exactly <b>one additional parent link</b>
to reach their new root.
This makes the merged component’s maximum link depth <b>one more</b> than that of the individual components before merging
(considering their previous roots),
which is why the rank of ${makeColoredSpan(rootX, POINTER1_COLOR)} is incremented.
`);
parent[rootY] = rootX;
rank[rootX] += 1;
}
vmX.removeClass(POINTER1);
vmY.removeClass(POINTER2);
return true;
};
for (let edgeIndex = 0; edgeIndex < edges.length; edgeIndex++) {
startPartialBatch();
const [u, v] = edges[edgeIndex];
const clearRow = highlightRow(edges, edgeIndex);
notification(`
🔗 Processing edge <b>[${makeColoredSpan(u, POINTER1_COLOR)}, ${makeColoredSpan(v, POINTER2_COLOR)}]</b>.<br>
We attempt to union their components — if <b>node ${u}</b> and <b>node ${v}</b> belong to different roots,
their components will be merged.
`);
graph.addEdge({ source: nodes[u], target: nodes[v] });
const newEdgeVm = graph.makeVisManagerForEdge(nodes[u], nodes[v]).addClass(JUST_ADDED);
if (union(u, v)) {
notification(`
✅ ${makeColoredSpan('New edge', JUST_ADDED_COLOR)} <b>[${u}, ${v}]</b> connects two <b>different components</b>.
These components merge into one, so we <b>decrease</b> the connected components count by <b>1</b>.
`);
n -= 1;
} else {
notification(`
⚠️ ${makeColoredSpan('New edge', JUST_ADDED_COLOR)} <b>[${u}, ${v}]</b> connects two nodes that are <b>already in the same component</b>.
No merge happens, so the connected components count remains <b>unchanged</b>.
`);
}
newEdgeVm.removeClass(JUST_ADDED);
clearRow();
endBatch();
}
notification(`
🏁 <b>All edges processed.</b><br>
The final number of <b>connected components</b> in the graph is <b>${n}</b>. ✅
`);
return n;
}
function initClassProperties() {
setClassProperties(POINTER1, { backgroundColor: POINTER1_COLOR });
setClassProperties(POINTER2, { borderColor: POINTER2_COLOR });
setClassProperties(ROOT, { backgroundColor: ROOT_COLOR });
setClassProperties(CURRENT, { borderColor: CURRENT_COLOR });
setClassProperties(JUST_ADDED, { lineColor: JUST_ADDED_COLOR });
}
function highlightRow(arr2D, rowIndex) {
startBatch();
const COL_CLASSES = [POINTER1, POINTER2];
const vms = arr2D[rowIndex].map((_, colIndex) => arr2D.makeVisManagerForIndexPair(rowIndex, colIndex).addClass(COL_CLASSES[colIndex]));
endBatch();
return () => {
startBatch();
vms.forEach((vm, index) => vm.removeClass(COL_CLASSES[index % 2]));
endBatch();
};
}
function explanationAndColorSchemaNotifications() {
explanationNotification();
notification(`
🎨 <b>Visual Cues (Color Schema)</b>
<ul>
<li>
${makeColoredSpan('Node u', POINTER1_COLOR)} — ${POINTER1_COLOR} background marks
the first node of the currently processed edge <code>[u, v]</code> in the edges table,
and also highlights the <b>root index</b> of its component in <code>rank[]</code> during <code>union()</code>.
</li>
<li>
${makeColoredSpan('Node v', POINTER2_COLOR)} — ${POINTER2_COLOR} border marks
the second node of the currently processed edge <code>[u, v]</code> in the edges table,
and also highlights the <b>root index</b> of its component in <code>rank[]</code> during <code>union()</code>.
</li>
<li>
${makeColoredSpan('Root in parent[]', ROOT_COLOR)} — ${ROOT_COLOR} background indicates
the <b>representative (root)</b> of the component in the <code>parent[]</code> array during <code>find()</code>.
</li>
<li>
${makeColoredSpan('Current node in parent[]', CURRENT_COLOR)} — ${CURRENT_COLOR} border shows
the node whose <code>parent[]</code> entry is currently being updated during <b>path compression</b>.
</li>
<li>
${makeColoredSpan('Currently processed edge', JUST_ADDED_COLOR)} — ${JUST_ADDED_COLOR} line color
temporarily highlights the edge being processed in the graph visualization while we attempt to merge its endpoints.
</li>
</ul>
`);
}Solution explanation
Solution explanation
Approach
Time Complexity
For each edge
[u, v], we call union(u, v), which in turn invokes two find() operations.
With path compression and union by rank, each find() runs in amortized O(α(n)),
where α(n) is the inverse Ackermann function (which grows extremely slowly and is effectively constant in practice).
The extra work inside union() is O(1).
Therefore, processing all edges takes O(m · α(n)) time overall, where m = edges.length,
which is usually described as “almost linear” in the size of the input.Space Complexity
We maintain two arrays,
parent[] and rank[], each of size n.
Other than these two arrays, only a few temporary variables with constant space usage are needed.
Therefore, the overall space usage is O(n).Visucodize editorial solution titled JS Solution for NeetCode's Count Connected Components coding problem. Includes code and may include explanation. You can animate the code step by step using the provided input by clicking the run button, or fork it locally to update the code and input for custom visualization. View the original problem at https://neetcode.io/problems/count-connected-components.