Problem overview
The function valid_tree receives n, the number of nodes numbered 0 to n - 1, and edges, a vector of pairs where each pair is an undirected edge. It returns true when the edges make the graph a tree. A tree is connected (you can get from any node to any other) and has no cycle. Put another way, 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 can be reached from node 0, and no edge closes a cycle. For edges = [[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 edges = [[0, 1], [1, 2], [2, 0]] it is also false: 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 lesson asks for nearly linear time, O((n + e)·α(n)) where e is the number of edges, and O(n) extra space.
Approach: count the edges, then look for a cycle
A tree with n nodes has exactly n - 1 edges. That gives a quick first test: with any other count, the answer is false. Fewer edges leave some node cut off, and more edges force a cycle.
The count alone isn't enough, as the third example shows. With exactly n - 1 edges, though, the graph is a tree exactly when no edge closes a cycle. If every edge joins two separate groups of nodes, each edge merges two groups into one, and the n single nodes we started with end up as a single group: the graph is connected. A connected graph with no cycle is a tree.
Union-find does the bookkeeping. It keeps each group as a tree of its own: every node points to a parent, 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:
- find the roots of both ends;
- if the roots are equal, the ends are already connected, so this edge would add a second path between them: a cycle;
- otherwise merge the two groups.
Two refinements keep the trees shallow. Union by size hangs the smaller tree under the larger one. Path halving makes each node passed during a search skip one level. Together they make each step cost O(α(n)) on average, where α is the inverse Ackermann function, below 5 for any n a computer can hold.
A breadth-first or depth-first search from node 0 that checks that every node is reached also works once the count is right. This lesson uses union-find. The searching loop has no recursion, so a long chain of 100,000 nodes can't overflow the call stack.
Here is the first example, edge by edge. The vectors are parent and size after each edge:
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 joined two different groups, and at the end all nodes lead to root 0, so the answer is true.
Step by step
- Check the edge count. If
edges.len() as i32is notn - 1, returnfalse. The cast turns theusizefromlen()into the type ofn, so the two can be compared. - Create
parentas aVec<usize>holding 0, 1, …,n - 1, by collecting the range0..n as usize. Every node starts as the root of its own group. Nodes areusizeso that they can index the vectors directly. - Create
sizewithvec![1; n as usize]. For a rootr,size[r]counts the nodes in its group. Only the entries of roots are kept up to date. - Loop over
&edges, borrowing the vector. - Find the root of
edge[0]and store it in a mutableroot_a, passing&mut parentbecausefindchanges the vector. The edge values arei32, soas usizeturns them into indexes. - Find the root of
edge[1]inroot_b. - Return
falsewhen the roots are equal. This is the cycle test. - Make
root_athe larger group's root. Ifsize[root_a] < size[root_b], swap the two variables withstd::mem::swap. A node then moves one level deeper only when its group at least doubles, so no path to a root is longer than aboutlog2(n)steps. - Merge: set
parent[root_b]toroot_a. - Update the size:
size[root_a] += size[root_b]. The entry ofroot_bis never read again, because it is no longer a root. - Return
trueafter the loop. Every one of then - 1edges merged two groups, so the graph is connected and has no cycle. - Write
findas an associated function takingparent: &mut Vec<usize>andmut x: usize, returning the root. - Loop while
parent[x] != x, that is, whilexis not a root. - Path halving: set
parent[x]toparent[parent[x]], soxpoints to its grandparent. - Climb: set
xtoparent[x], which is now the old grandparent. - Return
x, the node that is its own parent.
The solution in Rust
impl Solution {
fn valid_tree(n: i32, edges: Vec<Vec<i32>>) -> bool {
if edges.len() as i32 != n - 1 {
return false;
}
let mut parent: Vec<usize> = (0..n as usize).collect();
let mut size = vec![1; n as usize];
for edge in &edges {
let mut root_a = Self::find(&mut parent, edge[0] as usize);
let mut root_b = Self::find(&mut parent, edge[1] as usize);
if root_a == root_b {
return false;
}
if size[root_a] < size[root_b] {
std::mem::swap(&mut root_a, &mut root_b);
}
parent[root_b] = root_a;
size[root_a] += size[root_b];
}
true
}
fn find(parent: &mut Vec<usize>, mut x: usize) -> usize {
while parent[x] != x {
parent[x] = parent[parent[x]];
x = parent[x];
}
x
}
}
The Rust details worth a closer look:
- Collecting a range.
(0..n as usize)is a range ofusizevalues, andcollect()gathers them into the collection named by the type onparent. WithoutVec<usize>on the left, the compiler wouldn't know what to build. - Type of
size.vec![1; n as usize]names no element type. It is inferred fromsize[root_a] += size[root_b]and the comparison of two entries, and defaults toi32. - Borrowing.
for edge in &edgesborrows the vector, andedgeis a&Vec<i32>.&mut parentlendsparentmutably for the duration of thefindcall only, so the next line can use it again. - Why
root_aandroot_baremut. They are reassigned only by the swap.std::mem::swap(&mut root_a, &mut root_b)takes two mutable borrows and exchanges the values behind them. mut x. The parameter is a copy of the caller's number.mutlets the function move it up the tree without changing the caller's variable.- Loop instead of recursion. A recursive
findcould go as deep as the tree is tall. The loop uses no call stack, so the depth of the tree doesn't matter. - Final expressions.
trueandxhave no semicolon, so they are the values the functions return.
Complexity
Time: O((n + e)·α(n)). The edge count is checked first, so at most n - 1 edges are processed, and building the vectors takes O(n). Each edge makes two find calls, which cost O(α(n)) on average thanks to union by size and path halving. That is nearly O(n + e).
Space: O(n). The parent and size vectors hold one number per node.
Edge cases and common mistakes
- Counting edges and stopping. The third example has the right count and is still not a tree. The cycle check is required.
- Skipping the count. Without it,
n = 2with no edges would reachtrue, since no edge closes a cycle, but the two nodes aren't connected. The function returnsfalsebecause 0 is notn - 1 = 1. - A single node. For
n = 1and no edges, the count is 0, which equalsn - 1, the loop doesn't run, and the answer istrue. - Disconnected pieces. For
n = 4andedges = [[0, 1], [2, 3]]the count is 2, not 3, so the answer isfalse. - Edge order and direction. The edges are undirected, so
[[3, 2], [2, 1], [1, 0]]withn = 4is a tree too, and the order of the edges doesn't matter. - Comparing nodes instead of roots. The test is on roots. Two nodes can be connected without either being the root of anything.
- Forgetting the size update. Union by size depends on
sizebeing right for the roots. Skipping the update makes the trees deeper but doesn't change any answer. - Mixed integer types.
nand the edge values arei32, while indexes areusize. Each cast sits where a value is used as an index or compared withlen().
Testing
n = 5,edges = [[0, 1], [0, 2], [0, 3], [1, 4]]givestrue.n = 5,edges = [[0, 1], [1, 2], [2, 3], [1, 3], [1, 4]]givesfalse.n = 4,edges = [[0, 1], [1, 2], [2, 0]]givesfalse.n = 1,edges = []givestrue.n = 2,edges = []givesfalse.n = 4,edges = [[0, 1], [2, 3]]givesfalse.n = 4,edges = [[0, 1], [1, 2], [2, 3]]givestrue.n = 4,edges = [[3, 2], [2, 1], [1, 0]]givestrue.
