A clear explanation of the Graph Valid Tree problem using Union Find to detect cycles and verify connectivity.
Problem Restatement
We are given:
- An integer
n - A list of undirected edges
The graph has nodes labeled from 0 to n - 1.
Each edge:
[a, b]means there is an undirected connection between node a and node b.
We need to return True if these edges form a valid tree. Otherwise, return False.
A valid tree must satisfy two properties:
| Property | Meaning |
|---|---|
| Connected | Every node can reach every other node |
| No cycle | There is no circular path |
The problem constraints include 1 <= n <= 2000, 0 <= edges.length <= 5000, no self-loops, and no repeated edges.
Input and Output
| Item | Meaning |
|---|---|
| Input | n nodes and an edge list |
| Output | Boolean |
| Graph type | Undirected |
| Goal | Check whether the graph is exactly one connected acyclic component |
Example function shape:
def validTree(n: int, edges: list[list[int]]) -> bool:
...Examples
Example 1:
n = 5
edges = [[0, 1], [0, 2], [0, 3], [1, 4]]This graph is connected and has no cycle.
Answer:
TrueExample 2:
n = 5
edges = [[0, 1], [1, 2], [2, 3], [1, 3], [1, 4]]There is a cycle:
1 -> 2 -> 3 -> 1Answer:
FalseExample 3:
n = 4
edges = [[0, 1], [2, 3]]There is no cycle, but the graph is disconnected.
Nodes 0 and 1 form one component.
Nodes 2 and 3 form another component.
Answer:
FalseFirst Thought: DFS Traversal
A tree is a connected graph without cycles.
So one natural method is:
- Build an adjacency list.
- Run DFS from node
0. - Detect whether DFS sees a cycle.
- Check whether all nodes were visited.
This works.
But Union Find gives a compact way to process edges and detect cycles directly.
Key Insight
A graph with n nodes is a tree if it has:
n - 1edges and no cycle.
Why this works:
- If an undirected graph has
n - 1edges and no cycle, it must be connected. - If it has fewer than
n - 1edges, it cannot connect all nodes. - If it has more than
n - 1edges, it must contain a cycle.
So we can first check:
len(edges) == n - 1If this is false, return False.
Then we only need to detect whether any edge creates a cycle.
Union Find is designed for this.
Union Find Idea
Initially, every node is its own component.
For example:
n = 5starts as:
{0}, {1}, {2}, {3}, {4}When we process edge [0, 1], we merge the components:
{0, 1}, {2}, {3}, {4}When we process edge [0, 2], we merge again:
{0, 1, 2}, {3}, {4}If an edge connects two nodes already in the same component, then there is already a path between them.
Adding that edge creates a cycle.
So the graph cannot be a tree.
Algorithm
- If
len(edges) != n - 1, returnFalse. - Create a parent array:
parent[i] = i - For each edge
[a, b]:- Find the root of
a. - Find the root of
b. - If both roots are equal, return
False. - Otherwise, union the two components.
- Find the root of
- Return
True.
The edge count check handles disconnected graphs.
The Union Find check handles cycles.
Walkthrough
Use:
n = 5
edges = [[0, 1], [0, 2], [0, 3], [1, 4]]There are 4 edges.
Since:
4 == 5 - 1the edge count is possible for a tree.
Start:
parent = [0, 1, 2, 3, 4]Process [0, 1].
Roots are different, so union them.
Process [0, 2].
Roots are different, so union them.
Process [0, 3].
Roots are different, so union them.
Process [1, 4].
Roots are different, so union them.
No cycle appears.
Because the graph has exactly n - 1 edges, it is connected.
Return:
TrueNow use:
n = 5
edges = [[0, 1], [1, 2], [2, 3], [1, 3], [1, 4]]There are 5 edges.
A tree with 5 nodes must have exactly 4 edges.
So we immediately return:
FalseCorrectness
A valid tree with n nodes must have exactly n - 1 edges. Therefore, if the edge count differs from n - 1, the graph cannot be a valid tree.
After passing the edge count check, the algorithm processes each edge with Union Find.
Union Find maintains connected components. If an edge connects two nodes already in the same component, then those nodes already have a path between them. Adding the edge creates a cycle, so the graph cannot be a tree.
If no edge creates a cycle, then the graph is acyclic.
An undirected acyclic graph with n nodes and n - 1 edges is connected. Therefore, after the edge count check and the cycle check both pass, the graph is a valid tree.
Thus the algorithm returns True exactly for valid trees.
Complexity
Let E be the number of edges.
| Metric | Value | Why |
|---|---|---|
| Time | O(E α(n)) | Each Union Find operation is almost constant |
| Space | O(n) | Parent and rank arrays |
Here, α(n) is the inverse Ackermann function. In practice, it behaves like a small constant.
Implementation
class Solution:
def validTree(self, n: int, edges: list[list[int]]) -> bool:
if len(edges) != n - 1:
return False
parent = list(range(n))
rank = [0] * n
def find(x: int) -> int:
if parent[x] != x:
parent[x] = find(parent[x])
return parent[x]
def union(a: int, b: int) -> bool:
root_a = find(a)
root_b = find(b)
if root_a == root_b:
return False
if rank[root_a] < rank[root_b]:
parent[root_a] = root_b
elif rank[root_a] > rank[root_b]:
parent[root_b] = root_a
else:
parent[root_b] = root_a
rank[root_a] += 1
return True
for a, b in edges:
if not union(a, b):
return False
return TrueCode Explanation
First, check the edge count:
if len(edges) != n - 1:
return FalseThis quickly rejects graphs that are too sparse or too dense to be a tree.
The parent array starts with each node as its own root:
parent = list(range(n))The rank array helps keep the Union Find tree shallow:
rank = [0] * nThe find function returns the root component of a node:
def find(x: int) -> int:
if parent[x] != x:
parent[x] = find(parent[x])
return parent[x]The line:
parent[x] = find(parent[x])performs path compression.
That means future lookups become faster.
The union function merges two components.
If both nodes already have the same root:
if root_a == root_b:
return Falsethen the edge creates a cycle.
Otherwise, we attach the smaller-rank tree under the larger-rank tree.
Finally, every edge is processed:
for a, b in edges:
if not union(a, b):
return FalseIf no cycle is found, the graph is a valid tree.
Testing
def run_tests():
s = Solution()
assert s.validTree(5, [[0, 1], [0, 2], [0, 3], [1, 4]]) is True
assert s.validTree(5, [[0, 1], [1, 2], [2, 3], [1, 3], [1, 4]]) is False
assert s.validTree(4, [[0, 1], [2, 3]]) is False
assert s.validTree(1, []) is True
assert s.validTree(2, [[0, 1]]) is True
assert s.validTree(3, [[0, 1], [1, 2]]) is True
assert s.validTree(3, [[0, 1], [0, 2], [1, 2]]) is False
assert s.validTree(4, [[0, 1], [1, 2], [2, 3]]) is True
print("all tests passed")
run_tests()Test meaning:
| Test | Why |
|---|---|
| Standard valid tree | Connected and acyclic |
| Cycle example | Rejects cycle |
| Disconnected graph | Rejects multiple components |
| Single node | Empty edge list is valid |
| Two connected nodes | Smallest non-trivial tree |
| Simple chain | Valid tree shape |
| Triangle | Cycle detection |
| Longer chain | Connected acyclic graph |