Problem overview
The method validTree receives n, the number of nodes (numbered 0 to n - 1), and edges, an array of pairs where each pair is an undirected edge. It returns true when these edges make the graph a tree and false otherwise. A graph is a tree when it is connected, so you can get from any node to any other, and has no cycle. Equivalently, there is exactly one path between any two nodes.
For n = 5 and edges = [[0, 1], [0, 2], [0, 3], [1, 4]] the answer is true: every node is reachable from node 0 and no edge closes a loop. For n = 5 and [[0, 1], [1, 2], [2, 3], [1, 3], [1, 4]] it is false, because the edges 1-2, 2-3 and 1-3 form a cycle. For n = 4 and [[0, 1], [1, 2], [2, 0]] it is false as well: there are 3 edges, the number a tree of 4 nodes needs, but they form a cycle and node 3 is cut off.
The statement asks for O((n + e) · α(n)) time, nearly O(n + e), where e is the number of edges, and O(n) extra space. With n and e up to 100,000, the solution has to be near linear.
Approach: count the edges, then look for a cycle
A tree with n nodes has exactly n - 1 edges. Fewer edges leave some node cut off from the rest. More edges must close a cycle. So the first check is simply edges.length != n - 1.
With exactly n - 1 edges, the graph is a tree precisely when no edge closes a cycle. The reason is that every edge that joins two separate groups of nodes lowers the number of groups by one. Starting from n single nodes, n - 1 such edges leave one group, which means the graph is connected. Example 3 shows why the count alone isn't enough: it has the right number of edges but one of them closes a loop.
To find a cycle, process the edges one by one with union-find (also called disjoint sets). It keeps every group of connected nodes as a small tree of parent pointers, and the root, the node that is its own parent, names the group. For an edge, find the root of each end. If the roots are equal, the ends are already connected through other edges, and this edge adds a second path between them: a cycle. If the roots differ, merge the two groups.
Two refinements keep the cost nearly constant per operation. Union by size always hangs the smaller tree under the larger one. Path halving makes each node visited during a lookup point to its grandparent, so later lookups are shorter. Together, each operation costs O(α(n)) on average, where α is the inverse Ackermann function, which stays below 5 for any n a computer can hold.
A breadth-first or depth-first search from node 0 that checks every node was reached also works once the edge count is right. This lesson uses union-find, and its lookup is a loop rather than a recursion, so a path of 100,000 nodes causes no stack problem.
Here is the state after each edge of the first example, showing the parent and size arrays:
[0, 1] parent=[0, 0, 2, 3, 4] size=[2, 1, 1, 1, 1]
[0, 2] parent=[0, 0, 0, 3, 4] size=[3, 1, 1, 1, 1]
[0, 3] parent=[0, 0, 0, 0, 4] size=[4, 1, 1, 1, 1]
[1, 4] parent=[0, 0, 0, 0, 0] size=[5, 1, 1, 1, 1]
Each edge joins two different groups, and at the end all five nodes lead to root 0. For the third example (n = 4), the edges [0, 1] and [1, 2] merge groups, and for [2, 0] both ends have root 0, so the method returns false.
Step by step
- Check the edge count. If
edges.length != n - 1, returnfalse. - Create
parent, anintarray of sizen, whereparent[x]is the parent of nodex. - Create
size, another array of sizen.size[r]counts the nodes in the group with rootr. Only the roots' entries stay up to date, and they decide which tree goes under which. - Loop over the nodes to start every node in a group of its own.
- Set
parent[i] = i, so each node is the root of its own group. - Set
size[i] = 1, because a newintarray holds zeros. - Loop over
edgeswith an enhancedforand anint[]namededge. - Find
rootAasfind(parent, edge[0]). The helper is written at the end. - Find
rootBthe same way foredge[1]. - If
rootA == rootB, returnfalse. The ends are already connected, so this edge closes a cycle. - Check whether the group of
rootAis the smaller one,size[rootA] < size[rootB]. - Save
rootAintempinside thatif. - Swap: copy
rootBintorootAandtempintorootB. NowrootAis the root of the larger group. - Merge by setting
parent[rootB]torootA. - Add the sizes:
size[rootA] += size[rootB]. The entry forrootBis never read again, because it is no longer a root. - Return
trueafter the loop: no edge closed a cycle. - Write
find, taking theparentarray and a nodexand returning the root. - Loop while
parent[x] != x, that is, whilexis not a root. - Halve the path: set
parent[x]toparent[parent[x]]. - Move up: set
xtoparent[x], which is now the old grandparent. - Return
xafter the loop.
The solution in Java
class Solution {
public boolean validTree(int n, int[][] edges) {
if (edges.length != n - 1) {
return false;
}
int[] parent = new int[n];
int[] size = new int[n];
for (int i = 0; i < n; i++) {
parent[i] = i;
size[i] = 1;
}
for (int[] edge : edges) {
int rootA = find(parent, edge[0]);
int rootB = find(parent, edge[1]);
if (rootA == rootB) {
return false;
}
if (size[rootA] < size[rootB]) {
int temp = rootA;
rootA = rootB;
rootB = temp;
}
parent[rootB] = rootA;
size[rootA] += size[rootB];
}
return true;
}
int find(int[] parent, int x) {
while (parent[x] != x) {
parent[x] = parent[parent[x]];
x = parent[x];
}
return x;
}
}
The Java details worth a closer look:
- Arrays are passed by reference. The variable
parentholds a reference to the array, andfindreceives a copy of that reference, not a copy of the array. Whenfindhalves a path,validTreesees the change. - Primitives are passed by value. Assigning to
xinsidefindchanges only that method's own variable. - The swap needs
temp. WritingrootA = rootBfirst would lose the oldrootA. int[][]is an array of arrays.edges.lengthis the number of edges, and eachedgeis anint[]of length 2, soedge[0]andedge[1]are its two ends.- A loop instead of recursion. A recursive
findon a long chain could overflow the call stack. The loop infinduses constant stack, and with union by size the trees are shallow anyway. - Compound assignment.
size[rootA] += size[rootB]adds in place, and the sizes never exceedn, so there is no overflow.
Complexity
The edge-count check is O(1). Setting up the arrays takes O(n). Each of the at most n - 1 edges costs two find calls and a constant amount of other work, and each find is O(α(n)) on average with path halving and union by size. The total is O((n + e) · α(n)), nearly O(n + e). The two arrays use O(n) extra space.
Edge cases and common mistakes
- Counting edges only. The edge count passes for
n = 4with a triangle plus an isolated node, which is not a tree. The cycle check is what rejects it. - Skipping the count. Without it, a graph with too few edges would return
truewhen it is disconnected, because no edge closes a cycle. Forn = 4and[[0, 1], [2, 3]], union-find alone would accept it. - A single node. With
n = 1and no edges, the count0 == n - 1holds, no loop runs, and the answer istrue. Withn = 2and no edges, the count fails and the answer isfalse. - Forgetting to set
sizeto 1. With zeros, union by size can't tell the groups apart and the trees can grow deep.
Testing
These results come from running the solution, including a path of 100,000 nodes, which has the largest size the statement allows:
n | edges | Result |
|---|---|---|
| 5 | [[0, 1], [0, 2], [0, 3], [1, 4]] | true |
| 5 | [[0, 1], [1, 2], [2, 3], [1, 3], [1, 4]] | false |
| 4 | [[0, 1], [1, 2], [2, 0]] | false |
| 1 | [] | true |
| 2 | [] | false |
| 4 | [[0, 1], [2, 3]] | false |
| 3 | [[0, 1], [1, 2]] | true |
| 100000 | the path [0, 1], [1, 2], … | true |
