Problem overview
ValidTree receives n, the number of nodes (numbered 0 to n - 1), and edges, an int[][] in which each edges[i] holds two nodes joined by an undirected edge. It returns true when the graph is a tree: connected, with no cycle. Equivalently, there is exactly one path between any two nodes.
With n = 5 and edges 0-1, 0-2, 0-3, 1-4 the answer is true. With 1-2, 2-3, 1-3 among the edges there is a cycle, so it is false. The third example, n = 4 with edges 0-1, 1-2, 2-0, has three edges, which is the right count for four nodes, but they form a triangle and node 3 is cut off. Counting edges is necessary, not sufficient.
Approach: count the edges, then look for a cycle
A tree with n nodes has exactly n - 1 edges. With fewer, some node is cut off. With more, some edge closes a cycle. So the first check is edges.Length != n - 1.
When the count is right, the graph is a tree exactly when no edge closes a cycle. If every one of the n - 1 edges joins two separate groups, the n single nodes end up as one group, so the graph is connected and has no cycle.
Union-find detects this. It keeps each group of connected nodes as a small tree of parent links. The root, the one node that is its own parent, names the group. For each edge, find the roots of both ends. Equal roots mean the ends are already connected, so this edge closes a cycle. Different roots mean the edge merges two groups.
Two refinements keep the trees shallow: union by size puts the smaller tree under the larger one, and path halving makes each node passed during a search point to its grandparent. Together they make each operation almost constant time.
Step by step
- Check the count. If
edges.Length != n - 1, returnfalse. - Create
parentandsizeasintarrays of lengthn.parent[x]is the parent ofx, andsize[r]counts the nodes in the group whose root isr. - Initialise every node. A loop sets
parent[i] = i(each node is the root of its own group) andsize[i] = 1. New arrays hold zeros, so this loop is required. - Visit each edge with
foreach (int[] edge in edges). - Find both roots with
Find(parent, edge[0])andFind(parent, edge[1]), intorootAandrootB. - Detect the cycle. If
rootA == rootB, returnfalse. - Union by size. If
size[rootA] < size[rootB], swap the two variables with a tuple assignment, so thatrootAis the root of the larger group. - Merge. Set
parent[rootB] = rootAand addsize[rootB]tosize[rootA]. - Return
trueafter the loop: no edge closed a cycle. - Write
Find. Whileparent[x] != x, setparent[x] = parent[parent[x]]and thenx = parent[x]. When the loop stops,xis the root, and the method returns it.
The solution in C#
class Solution {
public bool 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;
}
foreach (int[] edge in edges) {
int rootA = Find(parent, edge[0]);
int rootB = Find(parent, edge[1]);
if (rootA == rootB) {
return false;
}
if (size[rootA] < size[rootB]) {
(rootA, rootB) = (rootB, rootA);
}
parent[rootB] = rootA;
size[rootA] += size[rootB];
}
return true;
}
public int Find(int[] parent, int x) {
while (parent[x] != x) {
parent[x] = parent[parent[x]];
x = parent[x];
}
return x;
}
}
The C# details worth knowing:
(rootA, rootB) = (rootB, rootA);builds a tuple from the right side and unpacks it into the variables on the left, which swaps them in one line without a temporary variable.- Arrays are reference types.
Findreceives the sameparentarray, so the path halving it does stays visible toValidTree. Its parameterxis anintcopy, so changingxinsideFinddoes not touch the caller's variable. Finduses a loop, not recursion, so a long chain of parents cannot overflow the call stack.int[][]is a jagged array: an array of arrays.edges.Lengthis the number of edges, and eachedge[0],edge[1]is a node.
Complexity
Time: O((n + e) * α(n)), nearly O(n + e), where e is the number of edges and α is the inverse Ackermann function, below 5 for any size a computer can hold. Since the count check forces e = n - 1, this is nearly linear in n.
Space: O(n) for the two arrays.
A breadth-first or depth-first search from node 0 that checks every node was reached also works once the count is right. For graphs this large, use a queue or an explicit stack: a recursive search down a path of 100,000 nodes can run out of call stack.
Edge cases and common mistakes
- One node, no edges.
n = 1with no edges returnstrue: zero edges isn - 1, and there is nothing to connect. - Several nodes, no edges.
n = 2with no edges returnsfalse, by the count check. - Right count, wrong shape.
n = 4with0-1, 1-2, 2-0returnsfalse: the third edge joins nodes that are already connected. - Checking cycles only. Without the count check, a graph with too few edges (a forest) would pass.
- Forgetting to initialise
parent. All zeros would make every node a child of node 0. - Comparing nodes instead of roots. Two nodes are connected when their roots are equal, not when the nodes are.
Testing
n = 5,{{0,1},{0,2},{0,3},{1,4}}givesTrue.n = 5,{{0,1},{1,2},{2,3},{1,3},{1,4}}givesFalse.n = 4,{{0,1},{1,2},{2,0}}givesFalse.n = 1, no edges, givesTrue.n = 2, no edges, givesFalse.n = 4,{{0,1},{2,3},{3,2}}givesFalse.
