Problem overview
The function validTree receives n, the number of nodes (numbered 0 to n - 1), and a vector<vector<int>> called edges, where each edge is a pair of nodes. It returns true when these edges make the graph a tree. A tree is connected, so 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.
With 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. With [0,1] [1,2] [2,3] [1,3] [1,4] it is false: the edges 1-2, 2-3 and 1-3 form a cycle. With n = 4 and edges [0,1] [1,2] [2,0] it is false too. The count of 3 edges is the number a tree of 4 nodes has, but 0, 1 and 2 form a cycle and node 3 has no edge at all.
The lesson asks for time close to O(n + e), more exactly 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. With fewer, some node is cut off from the rest. With more, some edge closes a cycle. So the first check is on the count.
With exactly n - 1 edges, the graph is a tree exactly when no edge closes a cycle. Add the edges one by one, keeping track of which nodes are already connected. If an edge joins two separate groups, it merges them. If both ends are already in the same group, they are connected through other edges, so this edge adds a second path between them: a cycle. When every one of the n - 1 edges has merged two groups, the n single nodes have become one group, so the graph is connected and has no cycle.
Keeping track of groups is the job of union-find. Each group is stored as a tree of parent pointers, and its 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. Two refinements keep the trees shallow: union by size hangs the smaller tree under the larger one, and path halving shortens the path every time a node is looked up. Together they make each operation cost O(α(n)), where α is the inverse Ackermann function, below 5 for any n a computer can hold.
A DFS or BFS from node 0 that checks that every node is reached also works once the count is right. On a graph this big, use a BFS or a DFS with its own stack, since a recursive DFS down a path of 100,000 nodes can run out of call stack. Union-find has no recursion at all.
Step by step
- Check the edge count. If
edges.size()is notn - 1, returnfalse. - Create
parent, avector<int>of sizen.parent[x]is the parent of nodex. - Create
size, avector<int>of sizenfilled with 1.size[r]counts the nodes of the group whose root isr. Only the roots' entries are kept up to date. - Loop over the nodes and set
parent[i] = i, so every node starts as the root of a group of one. - Loop over the edges with
auto& edge. - Find both roots:
rootAfromedge[0]androotBfromedge[1], using a helper namedfindthat the lesson writes at the end. - Detect the cycle. If
rootA == rootB, the two ends are already connected: returnfalse. - Union by size. If
size[rootA] < size[rootB],swapthe two roots, sorootAis the root of the larger group. - Merge: set
parent[rootB] = rootA. - Update the size:
size[rootA] += size[rootB].rootBis not a root any more, so its size is never read again. - Return
trueafter the loop: no edge closed a cycle. - Write
findas a method takingvector<int>& parentand anint x, returning the root ofx's group. - Loop while
parent[x] != x. A loop needs no call stack, however long the walk is. - Halve the path. Set
parent[x] = parent[parent[x]], soxskips one level. - Move up with
x = parent[x], which is now the old grandparent. - Return
x, the root.
The solution in C++
class Solution {
public:
bool validTree(int n, vector<vector<int>>& edges) {
if (edges.size() != n - 1) {
return false;
}
vector<int> parent(n);
vector<int> size(n, 1);
for (int i = 0; i < n; i++) {
parent[i] = i;
}
for (auto& edge : edges) {
int rootA = find(parent, edge[0]);
int rootB = find(parent, edge[1]);
if (rootA == rootB) {
return false;
}
if (size[rootA] < size[rootB]) {
swap(rootA, rootB);
}
parent[rootB] = rootA;
size[rootA] += size[rootB];
}
return true;
}
int find(vector<int>& parent, int x) {
while (parent[x] != x) {
parent[x] = parent[parent[x]];
x = parent[x];
}
return x;
}
};
The C++ details worth a closer look:
- Signed against unsigned.
edges.size()is unsigned andn - 1is anint, so the comparison converts the int to unsigned. That is harmless here becausenis at least 1, son - 1is never negative. Withn = 0it would become a huge number. vector<int> size(n, 1)andsize(). A local variable namedsizeis fine in this method, sinceedges.size()is a member call and is not affected by it.- References.
findtakesvector<int>&because it changesparentwhile halving paths. Without the&each call would work on a copy of the whole vector, which costsO(n)and loses the changes.auto& edgereads each edge in place instead of copying it. - The parameter
xis a copy. Movingxup insidefindchanges only the method's own number. swap(rootA, rootB)exchanges the values of two variables.
Complexity
Time: O((n + e)·α(n)), nearly O(n + e). Initialising the arrays is O(n). The loop makes two find calls and at most one merge per edge. Since e equals n - 1 after the count check, the whole function is close to linear.
Space: O(n). The parent and size vectors have n entries each. find uses a loop, so there is no recursion stack.
Edge cases and common mistakes
- A single node.
n = 1with no edges givestrue: the count 0 equalsn - 1, and the loop doesn't run. - Two nodes with no edge.
n = 2with no edges givesfalse: the count 0 is not 1. - Two nodes with one edge.
n = 2and[0,1]givestrue. - The right count is not enough. The example with 4 nodes and edges 0-1, 1-2 and 2-0 has 3 edges and still gives
false. The cycle check is what rejects it, as the third edge finds both ends under the same root. - No cycle is not enough either. Fewer than
n - 1edges can never connectnnodes, and the count check rejects them before any merging. - Merging roots incorrectly. The edge must be handled through
find, not through the nodes' ownparententries. A node's parent is not necessarily its root. - A recursive search on a long path. Nodes 0 to 99,999 in a line make a recursive DFS 100,000 calls deep. Union-find avoids that.
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 = 2,edges = {{0,1}}givestrue.n = 4,edges = {{0,1},{1,2},{2,3}}givestrue.
