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 the edges make the graph a tree: connected, so you can get from any node to any other, and with 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 edges = [[0, 1], [1, 2], [2, 3], [1, 3], [1, 4]] the answer is false, because the edges 1-2, 2-3 and 1-3 form a cycle. For n = 4 and edges = [[0, 1], [1, 2], [2, 0]] the answer is false too: the count of 3 edges is right for 4 nodes, but 0, 1 and 2 form a cycle and node 3 has no edge at all.
The statement asks for nearly O(n + e) time (precisely O((n + e)·α(n))) and O(n) extra space, where e is the number of edges.
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 if edges.count != n - 1, the answer is false immediately.
With exactly n - 1 edges, the graph is a tree exactly when no edge closes a cycle. Counting alone isn't enough, as the third example shows, so the solution checks each edge with union-find. It keeps the nodes in groups, each stored as a small tree: every node has a parent, and the root, the one node that is its own parent, names the group. At the start every node is alone.
For each edge, find the roots of both ends. If they are the same root, the two nodes were already connected, so this edge adds a second path between them: a cycle, and the answer is false. Otherwise merge the two groups. If no edge closes a cycle, every one of the n - 1 edges merged two groups, so the n single nodes became one group, and the graph is a tree.
Two tricks keep the walks to a root short. Union by size hangs the smaller group under the larger one's root. Path halving makes every node passed during a walk point to its grandparent. Together they make each step cost O(α(n)) on average, where α is the inverse Ackermann function, below 5 for any size a computer can hold.
A depth-first or breadth-first search from node 0 that checks every node is reached also works once the edge count is right. With up to 100,000 nodes, use a queue or your own stack rather than recursion, since a recursive search down a path that long can overflow the call stack.
The state after each edge of the first example (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]
At the end every node leads to root 0, and the group has size 5.
Step by step
- Check the edge count. If
edges.countis notn - 1, returnfalse. - Create
parentwithArray(0..<n), soparent[i]isi: every node is the root of a group of one. It is avarbecausefindchanges it. - Create
sizeasnones.size[r]counts the nodes in the group whose root isr. - Loop over
edges. Each edge either merges two groups or closes a cycle. - Find the root of
edge[0]withfind(&parent, edge[0])intorootA. The&is required becausefindtakes the array as aninoutparameter. It is avarbecause the size step may swap it. - Find the root of
edge[1]intorootB. - If the roots are equal, return
false. The two ends are already connected, so the edge closes a cycle. - Make
rootAthe root of the larger group. Ifsize[rootA] < size[rootB], swap them with one tuple assignment. A node only moves one level deeper when its group at least doubles, so no path to a root grows beyond aboutlog2(n)steps. - Merge: set
parent[rootB]torootA. - Update the size: add
size[rootB]tosize[rootA]. - Return
trueafter the loop, since no edge closed a cycle. - Write
findbelowvalidTree, as a method taking_ parent: inout [Int]and_ node: Intand returning anInt. - Copy
nodeinto avar x, since parameters are constants andxwalks up the tree. - Loop while
parent[x] != x. The root is the only node that is its own parent. The loop needs no call stack, however long the walk. - Halve the path: set
parent[x]toparent[parent[x]]. - Climb: set
xtoparent[x], the grandparent it now points to. - Return
x, the root.
The solution in Swift
class Solution {
func validTree(_ n: Int, _ edges: [[Int]]) -> Bool {
if edges.count != n - 1 {
return false
}
var parent = Array(0..<n)
var size = [Int](repeating: 1, count: n)
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] {
(rootA, rootB) = (rootB, rootA)
}
parent[rootB] = rootA
size[rootA] += size[rootB]
}
return true
}
func find(_ parent: inout [Int], _ node: Int) -> Int {
var x = node
while parent[x] != x {
parent[x] = parent[parent[x]]
x = parent[x]
}
return x
}
}
The Swift details worth a closer look:
inoutand&. Arrays are value types, so a function normally works on a copy. Aninoutparameter letsfindchange the caller's array, and the caller must pass it with&. Leaving it out is a compile error:passing value of type '[Int]' to an inout parameter requires explicit '&'.Array(0..<n). It turns the range into the array[0, 1, ..., n - 1], which is exactly the starting parent table.- Tuple swap.
(rootA, rootB) = (rootB, rootA)builds a pair from the right side, then unpacks it into the left side, so the swap needs no temporary variable. varfor the roots.rootAandrootBare reassigned by the swap, so they can't belet.- Parameters are constants. That is why
findcopiesnodeintovar x.
Complexity
Time: O((n + e)·α(n)), nearly O(n + e). The count check is constant. Building the arrays is O(n). Each of the at most n - 1 edges (after the count check) does two find calls and a merge, each costing O(α(n)) on average.
Space: O(n). The parent and size arrays hold n integers each.
Edge cases and common mistakes
- Counting edges only.
n = 4with[[0, 1], [1, 2], [2, 0]]has the right count but is not a tree. The cycle check is needed. - A single node.
n = 1with no edges returnstrue: zero edges isn - 1, and the loop does nothing. - No edges, several nodes.
n = 2with[]returnsfalse, because the count is 0, not 1. - Edge direction. The edges are undirected, so
[[1, 0]]withn = 2returnstruejust like[[0, 1]]. - Order of the edges.
[[0, 1], [2, 3], [1, 2]]withn = 4returnstrue: the groups {0, 1} and {2, 3} are merged by the last edge. - Too few edges.
[[0, 1], [1, 2]]withn = 4returnsfalse, because the count is 2 instead of 3. - Forgetting
&. The call won't compile without it, as shown above.
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,[]givestrue.n = 2,[]givesfalse.n = 2,[[1, 0]]givestrue.n = 4,[[0, 1], [2, 3], [1, 2]]givestrue.n = 4,[[0, 1], [1, 2]]givesfalse.
