Problem overview
You get n nodes numbered 0 to n - 1 and an array edges of undirected edges, each an IntArray with two node numbers. The function validTree returns true when the graph is a tree. A tree is connected (every node can reach every other) and has no cycle, which is the same as saying that exactly one path joins any two nodes.
With n = 5 and edges [0, 1], [0, 2], [0, 3], [1, 4], every node hangs off node 0 and no edge closes a loop: the answer is true. With [0, 1], [1, 2], [2, 3], [1, 3], [1, 4] the edges 1-2, 2-3 and 1-3 form a cycle: false.
Approach
Two facts reduce the problem. A tree with n nodes has exactly n - 1 edges. And with exactly n - 1 edges, the graph is a tree exactly when no edge closes a cycle: if no edge ever joins two nodes that are already connected, every edge merges two separate groups, and the n single nodes end up as one group.
The count alone is not enough. With n = 4 and edges [0, 1], [1, 2], [2, 0] there are 3 edges, the right number, but they form a triangle and node 3 has no edge at all. The result is false, and it is the cycle check that finds it.
To detect cycles, use union-find (also called disjoint sets). Each group of connected nodes is stored as a tree of parent pointers, and the root, the one node that is its own parent, names the group. Two nodes are in the same group exactly when they lead to the same root. For each edge:
- if both ends already have the same root, the edge closes a cycle, so return
false; - otherwise merge the two groups by hanging one root under the other.
Two refinements keep the trees shallow. Union by size hangs the smaller tree under the larger one, so a node only gets deeper when its group at least doubles. Path halving makes each node passed during a search skip one level. Together they make each operation cost O(α(n)) on average, where α is the inverse Ackermann function, below 5 for any size a computer can hold. A DFS or BFS from node 0 also works, but a recursive DFS down a path of 100,000 nodes can run out of call stack.
The state after each edge of the first example, from a real run (parent and size by node number):
edge 0-1 parent=0,0,2,3,4 size=2,1,1,1,1
edge 0-2 parent=0,0,0,3,4 size=3,1,1,1,1
edge 0-3 parent=0,0,0,0,4 size=4,1,1,1,1
edge 1-4 parent=0,0,0,0,0 size=5,1,1,1,1
Every edge merges two groups, and at the end all five nodes share the root 0.
Step by step
- Check the edge count. If
edges.size != n - 1, returnfalse. Fewer edges leave some node cut off; more edges force a cycle. - Create the parents.
IntArray(n) { it }gives each node itself as its parent. The block runs once per index, anditis that index. Every node starts as the root of a group of one. - Create the sizes.
IntArray(n) { 1 }. Only the roots' entries are kept up to date. - Loop over the edges.
for (edge in edges); each edge merges two groups or closes a cycle. - Find the first root.
var rootA = find(parent, edge[0]). It is avarbecause the size comparison may swap it. - Find the second root.
var rootB = find(parent, edge[1]). - Detect the cycle. If
rootA == rootB, the ends are already connected, this edge adds a second path between them, and the function returnsfalse. - Choose the larger group. If
size[rootA] < size[rootB], swap the two roots with a temporary variable, so thatrootAalways ends up as the root of the larger group. - Merge.
parent[rootB] = rootAhangs the smaller tree under the larger. - Update the size.
size[rootA] += size[rootB]. The size ofrootBis never read again, because it is no longer a root. - Return
true. After the loop, no edge closed a cycle. Alln - 1edges merged two groups, so the graph is connected. - Write
find. It copiesnodeintovar x, then loops whileparent[x] != x: setparent[x] = parent[parent[x]](path halving), thenx = parent[x]. It returnsx, the root.
The solution in Kotlin
class Solution {
fun validTree(n: Int, edges: Array<IntArray>): Boolean {
if (edges.size != n - 1) {
return false
}
val parent = IntArray(n) { it }
val size = IntArray(n) { 1 }
for (edge in edges) {
var rootA = find(parent, edge[0])
var rootB = find(parent, edge[1])
if (rootA == rootB) {
return false
}
if (size[rootA] < size[rootB]) {
val temp = rootA
rootA = rootB
rootB = temp
}
parent[rootB] = rootA
size[rootA] += size[rootB]
}
return true
}
fun find(parent: IntArray, node: Int): Int {
var x = node
while (parent[x] != x) {
parent[x] = parent[parent[x]]
x = parent[x]
}
return x
}
}
Kotlin details worth knowing:
- Arrays are passed by reference.
findreceives the sameparentarray, not a copy, so its path halving changes the array thatvalidTreeuses. That is intended. - Parameters are read-only.
findcan't changenode, so it walks with its ownvar x. valandvar.parentandsizearevalbecause the arrays themselves are never replaced, although their contents change.rootAandrootBarevarbecause they are reassigned in the swap.IntArray(n) { it }builds the identity array without a loop of your own.- No recursion.
finduses awhileloop, so a long chain can't overflow the call stack.
Complexity
Each of the e edges costs two find calls and a few assignments. With union by size and path halving, a call takes O(α(n)) on average, so the total is O((n + e) · α(n)), nearly O(n + e). The count check at the start means the loop never runs on more than n - 1 edges. The two arrays hold n entries each, so the extra space is O(n).
Edge cases and common mistakes
- Counting edges only.
n = 4with the triangle[0, 1],[1, 2],[2, 0]has the right count and is not a tree. The union-find loop returnsfalseon the third edge. - Skipping the count check. Without it, a graph with too few edges (for example
n = 4and a single edge) would pass the cycle loop, since there is no cycle, even though it isn't connected. - Single node.
n = 1with no edges is a tree:edges.sizeis 0, which equalsn - 1, and the loop doesn't run. The result istrue.n = 2with no edges returnsfalse. - Edge direction. Edges are undirected, so
[1, 0]and[0, 1]behave the same. - Forgetting to compare roots. Comparing
edge[0]withedge[1]directly says nothing about connectivity; compare their roots. - Deep recursion. A recursive
findon a long chain can overflow the stack. The loop version has no such limit.
Testing
Results from running the solution:
n = 5,[[0,1],[0,2],[0,3],[1,4]]:truen = 5,[[0,1],[1,2],[2,3],[1,3],[1,4]]:falsen = 4,[[0,1],[1,2],[2,0]]:falsen = 1, no edges:truen = 2, no edges:falsen = 2,[[1,0]]:truen = 4,[[0,1],[2,3]]:false- A chain of 100,000 nodes,
[[0,1],[1,2],…]:true
