-
We are given an undirected graph with
nnodes and anedgeslist. The goal is to determine whether these edges form a valid tree, assuming that no duplicate edges appear in theedgeslist. -
Intuition:
Before anything else, we check whether the graph has exactlyn β 1edges. A tree withnnodes must have exactlyn β 1edges, so if this fails we can return immediately.
Once this check passes, we only need to ensure that none of the provided edges connect two nodes that are already connected through some existing path. To reason about this, imagine that every time we connect two nodes, we are gradually forming new connected structures or expanding the existing ones. Each such connected structure has its own current root that all nodes in that structure ultimately originate from.
When we process a new edge[u, v], we look at the current roots ofuandv:- If their roots are different, they are not yet connected, so adding the edge is safe.
- If their roots are the same, then a path between them already exists, and adding this edge would create a cycle.
n β 1edges are present, if none of these edges forms a cycle, the graph must also be connected β and therefore it is a valid tree. -
Quick Pre-check:
A valid tree withnnodes must have exactlyn - 1edges. If this condition fails (edges.length !== n - 1), we immediately returnfalse. -
Disjoint-Set (UnionβFind) Structure:
We represent connectivity using two arrays:parent[i]β stores the parent reference of node i in the disjoint-set forest. Initially,parent[i] = ifor every node i (each node starts as its own singleton set).rank[i]β used as a union-by-rank heuristic. For a root node i,rank[i]represents an upper bound on the number ofparent[]links one might follow to reach node i from any node in its set. Since each node begins as a single-node set and so already refers to its own root, all ranks are initialized to 0. It is meaningful only for root nodes; for non-root nodes, it may be outdated and is not used.
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. Used together, they keep the forest almost flat at all times, ensuring eachfind()operation is performed in amortized near-constant time. -
Find(x) β Two-Pass Path Compression:
- Pass 1 β Root Discovery: Follow
parent[]references starting from node x until reaching the root r, whereparent[r] === r. - Pass 2 β Path Compression: Walk back from node x toward root r, updating each intermediate nodeβs
parent[]entry to point directly to the root r. - Effect: This flattens the disjoint-set forest, so future
find()operations on these nodes run in near-constant time.
- Pass 1 β Root Discovery: Follow
-
Union(x, y) β Merge by Rank:
-
We first find the roots of nodes x and y using
find(), storing them as rootX and rootY. Each represents the representative node (root) of its disjoint set. -
If both rootX and rootY are the same,
the two nodes already belong to the same component.
Adding this edge would create a cycle,
so we return
falseimmediately. - Otherwise, we apply Union by Rank in the following order:
-
π Case 1 β rank[rootX] > rank[rootY]:
rootX has a higher rank than rootY, so rootX becomes the parent and absorbs rootYβs set. 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 set. -
π Case 2 β rank[rootX] < rank[rootY]:
rootY has a higher rank than rootX, so rootY becomes the parent and absorbs rootXβs set. 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 set. -
π 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 sets have the same maximum number of parent links to their roots, attaching rootY under rootX makes each node in rootYβs set gain exactly one additional parent link to reach their new root. This makes the merged setβs maximum link depth one more than the maximum link depth of the individual sets before merging (considering their existing roots before the merge), which is why rank[rootX] is incremented.
-
We first find the roots of nodes x and y using
-
Edge Traversal and Union:
We iterate through each edge[u, v]inedgesand callunion(u, v)to connect their components if they belong to different roots. Ifunion(u, v)ever returnsfalse, it means the two nodes are already in the same component β revealing that this edge creates a cycle in the original tree, so we returnfalseimmediately.
If all edges are processed successfully (also considering that the total edge count equalsn β 1from the earlier pre-check), the graph is connected and acyclic, so it forms a valid tree and we returntrue.
Valid Tree - 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 SAFE_EDGE_COLOR = 'green', CYCLIC_EDGE_COLOR = 'red';
function visMainFunction(n, edgesInput) {
initClassProperties();
explanationAndColorSchemaNotifications();
const edges = new VisArray2D(...edgesInput.map(pair => new VisArray(...pair)));
if (edges.length !== n - 1) {
notification(`
β ${makeColoredSpan('Invalid number of edges!', 'red')} Expected <b>${n - 1}</b> but got <b>${edgesInput.length}</b>.
A valid tree must have exactly <b>n β 1</b> edges, so we return <b>false</b>.
`);
return false;
}
const parent = new VisArray(...new Array(n).fill(null).map((_, i) => i));
const rank = new VisArray(n).fill(0);
// nodes and graph are used just for visualization they are not part of 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 } });
notification(`
π§© The <code>parent[]</code> array stores, for each node, a reference to its parent
in the <b>disjoint-set forest</b>.
Initially, every node references itself (<code>parent[i] = i</code>), forming <b>n single-node sets</b> β
each representing an isolated component.<br><br>
The <code>rank[i]</code> value serves as a <b>union-by-rank heuristic</b>:
it represents an <b>upper bound</b> on the number of <code>parent[]</code> links one might follow
to reach the root <code>i</code> from any node in the set rooted at <code>i</code>.
It is meaningful only when <code>i</code> is the root of a set; for non-root nodes, it may be outdated and is not used.
The rank starts at <code>0</code> for all nodes, since each initially represents a single-element set.<br><br>
When two sets are merged, the root with the <b>higher rank</b> becomes the parent.
If both have equal rank, one is chosen arbitrarily as the new root and its rank is <b>incremented by 1</b>
(see the <b>Approach</b> section for an explanation of why this is done).
This balancing rule maintains a shallow forest structure, ensuring that <code>find</code> operations remain efficient.
`);
const find = (x) => {
startPartialBatch();
notification(`
π Starting root search for <b>node ${x}</b>.<br>
We first treat ${makeColoredSpan('node ' + x, ROOT_COLOR)} 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 ${makeColoredSpan('node ' + x, CURRENT_COLOR)}, 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 the root.<br>
<b>Why:</b> This process, known as <b>path compression</b>, flattens the disjoint-set forest
by redirecting intermediate parent links straight to the current root of the set.<br>
`);
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 reach it immediately.
`);
endBatch();
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) {
return false;
}
if (rank[rootX] > rank[rootY]) {
notification(`
π ${makeColoredSpan('Union by Rank:', POINTER1_COLOR)}
Root ${makeColoredSpan('node ' + rootX, POINTER1_COLOR)} has a <b>higher rank</b> than ${makeColoredSpan('node ' + rootY, POINTER2_COLOR)},
so it becomes the parent and absorbs ${makeColoredSpan('node ' + rootY, POINTER2_COLOR)}βs set β
the rank of ${makeColoredSpan('node ' + rootX, POINTER1_COLOR)} remains unchanged after the merge.
Adding ${makeColoredSpan('node ' + rootY, POINTER2_COLOR)} under ${makeColoredSpan('node ' + rootX, POINTER1_COLOR)}
introduces exactly <b>one additional parent link</b> for the nodes in the set previously rooted at ${makeColoredSpan('node ' + rootY, POINTER2_COLOR)}
to reach their new root (${makeColoredSpan('node ' + rootX, POINTER1_COLOR)}).
Therefore, the upper bound for maximum number of parent links needed to reach ${makeColoredSpan('node ' + rootX, POINTER1_COLOR)}
remains the same in the set that was already rooted at ${makeColoredSpan('node ' + rootX, POINTER1_COLOR)} and in the newly expanded combined set.
`);
parent[rootY] = rootX;
} else if (rank[rootX] < rank[rootY]) {
notification(`
π ${makeColoredSpan('Union by Rank:', POINTER2_COLOR)}
Root ${makeColoredSpan('node ' + rootY, POINTER2_COLOR)} has a <b>higher rank</b> than ${makeColoredSpan('node ' + rootX, POINTER1_COLOR)},
so it becomes the parent and absorbs ${makeColoredSpan('node ' + rootX, POINTER1_COLOR)}βs set β
the rank of ${makeColoredSpan('node ' + rootY, POINTER2_COLOR)} remains unchanged after the merge.
Adding ${makeColoredSpan('node ' + rootX, POINTER1_COLOR)} under ${makeColoredSpan('node ' + rootY, POINTER2_COLOR)}
introduces exactly <b>one additional parent link</b> for the nodes in the set previously rooted at ${makeColoredSpan('node ' + rootX, POINTER1_COLOR)}
to reach their new root (${makeColoredSpan('node ' + rootY, POINTER2_COLOR)}).
Therefore, the upper bound for maximum number of parent links needed to reach ${makeColoredSpan('node ' + rootY, POINTER2_COLOR)}
remains the same in the set that was already rooted at ${makeColoredSpan('node ' + rootY, POINTER2_COLOR)} and in the newly expanded combined set.
`);
parent[rootX] = rootY;
} else {
notification(`
π ${makeColoredSpan('Union by Rank:', POINTER1_COLOR)}
Roots ${makeColoredSpan('node ' + rootX, POINTER1_COLOR)} and ${makeColoredSpan('node ' + rootY, POINTER2_COLOR)} have <b>equal rank</b>,
so we choose ${makeColoredSpan('node ' + rootX, POINTER1_COLOR)} as the new root and increment its rank by <b>1</b>.
Adding ${makeColoredSpan('node ' + rootY, POINTER2_COLOR)} β whose upper bound for maximum number of parent links to its root
is the same as ${makeColoredSpan('node ' + rootX, POINTER1_COLOR)} β under ${makeColoredSpan('node ' + rootX, POINTER1_COLOR)}
causes every node in ${makeColoredSpan('node ' + rootY, POINTER2_COLOR)}βs set to gain exactly <b>one additional parent link</b>
to reach their new root.
This increases the upper bound for maximum number of parent links within the merged set by one,
which is why the rank of ${makeColoredSpan('node ' + rootX, POINTER1_COLOR)} is incremented.
`);
parent[rootY] = rootX;
rank[rootX] += 1;
}
vmX.removeClass(POINTER1);
vmY.removeClass(POINTER2);
return true;
};
endBatch();
for (let edgeIndex = 0; edgeIndex < edges.length; edgeIndex++) {
startPartialBatch();
const [u, v] = edges[edgeIndex];
const clearRow = highlightRow(edges, edgeIndex);
notification(`
π Processing edge <b>[${makeColoredSpan('node ' + u, POINTER1_COLOR)}, ${makeColoredSpan('node ' + v, POINTER2_COLOR)}]</b>.
Attempting to union their components β connecting the two nodesβ sets if they belong to different roots.
`);
graph.addEdge({ source: nodes[u], target: nodes[v] });
if (!union(u, v)) {
graph.makeVisManagerForEdge(nodes[u], nodes[v]).setProp('lineColor', CYCLIC_EDGE_COLOR);
notification(`
β ${makeColoredSpan('Cycle detected!', CYCLIC_EDGE_COLOR)}
Edge <b>[${makeColoredSpan('node ' + u, POINTER1_COLOR)}, ${makeColoredSpan('node ' + v, POINTER2_COLOR)}]</b> connects two nodes that already belong to the same component.
Therefore, this edge reveals the existence of a cycle in the original tree structure, so we return <b>false</b>.
`);
endBatch();
return false;
} else {
graph.makeVisManagerForEdge(nodes[u], nodes[v]).setProp('lineColor', SAFE_EDGE_COLOR);
notification(`
β
${makeColoredSpan('Safe edge', SAFE_EDGE_COLOR)} Edge <b>[${makeColoredSpan('node ' + u, POINTER1_COLOR)}, ${makeColoredSpan('node ' + v, POINTER2_COLOR)}]</b> connects two nodes
from different components. Therefore, adding this edge safely merges the components without creating a cycle in the original tree structure.
`);
}
clearRow();
endBatch();
}
notification(
`π <b>All edges processed successfully.</b>
Together with the earlier edge-count pre-check (<code>edges.length === n β 1</code>),
this confirms that the graph is ${makeColoredSpan('connected and acyclic', 'green')},
so it forms a <b>valid tree</b>. Returning <b>true</b>. β
`
);
return true;
}
function initClassProperties() {
setClassProperties(POINTER1, { backgroundColor: POINTER1_COLOR });
setClassProperties(POINTER2, { borderColor: POINTER2_COLOR });
setClassProperties(ROOT, { backgroundColor: ROOT_COLOR });
setClassProperties(CURRENT, { borderColor: CURRENT_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 used for the first node of the edge currently being processed, or its corresponding root in <code>rank[]</code> during <code>union()</code>.</li>
<li>${makeColoredSpan('Node v', POINTER2_COLOR)} β ${POINTER2_COLOR} border used for the second node of the edge currently being processed, or its corresponding root in <code>rank[]</code> during <code>union()</code>.</li>
<li>${makeColoredSpan('Root', ROOT_COLOR)} β ${ROOT_COLOR} background marks the representative (root) of a disjoint set during <code>find()</code> or <code>union()</code>.</li>
<li>${makeColoredSpan('Current node', CURRENT_COLOR)} β ${CURRENT_COLOR} border shows the node currently undergoing path compression (its <code>parent[]</code> entry being updated).</li>
<li>${makeColoredSpan('Safe edges', SAFE_EDGE_COLOR)} β edges safely connecting two different components (no cycle created).</li>
<li>${makeColoredSpan('Cyclic edges', CYCLIC_EDGE_COLOR)} β edges that connect two nodes already in the same component, revealing a cycle.</li>
</ul>
`);
}Solution explanation
Solution explanation
Approach
Time Complexity
If the number of edges is not equal to
For each edge
Each
Therefore, processing all
n β 1, we return immediately after the pre-check,
since the graph cannot form a valid tree in that case. Therefore, we can assume the number of edges to be n β 1
for the worst-case analysis.For each edge
[x, y] in edges, we call union(x, y)
to merge the components containing node x and node y.Each
union(x, y) internally performs up to two find() calls
(one for each node), and each find() runs in amortized O(Ξ±(n)) time
due to the combined effect of path compression and union by rank.
The remaining logic inside union() runs in O(1) time.Therefore, processing all
n β 1 edges takes
O((n β 1) Β· Ξ±(n)), which is asymptotically O(n Β· Ξ±(n)) overall,
where Ξ±(n) (the inverse Ackermann function) grows so slowly
that it is effectively constant for all practical input sizes.Space Complexity
The algorithm uses two auxiliary arrays β
parent[] and rank[] β each of size n.
Apart from these, only a few constant-size variables are used.
Therefore, the total space complexity is O(n).Visucodize editorial solution titled JS Solution for NeetCode's Valid Tree 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/valid-tree.