Problem overview
The method validTree receives n, the number of nodes (numbered 0 to n - 1), and a list edges of pairs. Each pair [a, b] is an undirected edge. The method returns True when the graph is a tree and False otherwise.
A graph is a tree when it is connected, so you can get from any node to any other, and has 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: every node is reachable from node 0 and no edge closes a loop. With 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. With n = 4 and edges = [[0, 1], [1, 2], [2, 0]] it is also False: there are 3 edges, which is the number a tree of 4 nodes has, but they form a cycle and node 3 is isolated.
The statement asks for O((n + e) · α(n)) time, which is nearly O(n + e), 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. That gives a quick first test. With fewer edges, some node is cut off from the rest. With more, some edge must close a cycle. So if len(edges) isn't n - 1, the answer is False right away.
The count alone isn't enough, as the third example shows. But once the count is right, one condition remains: the graph is a tree exactly when no edge closes a cycle. The reason is that each edge that doesn't close a cycle joins two separate groups of nodes. After n - 1 such edges, the n single nodes have merged into one group, which means the graph is connected.
To detect a cycle, the lesson uses union-find. It keeps the groups of connected nodes as small trees, where every node points to a parent and the root, the one node that is its own parent, names its group. Two nodes are in the same group exactly when they lead to the same root. When an edge joins two nodes that already share a root, they were already connected, so this edge adds a second path between them: a cycle.
Two refinements keep the trees shallow. Union by size hangs the smaller tree under the larger one. Path halving shortens paths while searching for a root. Together they make each step cost O(α(n)) on average, where α is the inverse Ackermann function, below 5 for any size of input a computer can hold.
Here are the parent and size lists after each edge of the first example, n = 5:
[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]
All five nodes end up under root 0 and no edge was rejected. For the second example, the first three edges merge nodes 0 to 3 under root 0 in the same way. The fourth edge, [1, 3], finds both nodes under root 0, so the function returns False.
A depth-first or breadth-first search from node 0 that checks every node was reached also works once the count is right. On large inputs it needs a loop with its own stack, because a recursive search down a path of 100,000 nodes can exceed Python's recursion limit.
Step by step
- Check the edge count. If
len(edges) != n - 1, returnFalse. - Create
parentaslist(range(n)). Every node starts as its own root, a group of one. - Create
sizeasnones. For a rootr,size[r]is the number of nodes in its group. Only the roots' entries are kept up to date. - Loop over
edgeswithedge. Each edge either merges two groups or closes a cycle. - Find
rootAwithself.find(parent, edge[0]). The helper is written last, which keeps the loop short. - Find
rootBforedge[1]in the same way. - Compare the roots. If
rootA == rootB, the ends were already connected, so returnFalse. - Union by size. If
size[rootA] < size[rootB], swap the two roots with a tuple assignment, so thatrootAis the root of the larger group. A node moves one level deeper only when its group at least doubles in size, so no path to a root is longer than about log2(n) steps. - Merge: set
parent[rootB] = rootA, outside the shortif, so that it runs every time. - Add the sizes:
size[rootA] += size[rootB]. The entry forrootBis never read again, because it is no longer a root. - After the loop, return
True. Every one of then - 1edges merged two groups, so the graph is connected and has no cycle. - Write the method
findin the class. It takesself,parentand a nodex, and returns the root ofx's group. - Loop while
parent[x] != x. The root is the only node that is its own parent. A loop needs no call stack, however long the walk is. - Point
xat its grandparent:parent[x] = parent[parent[x]]. This is path halving: each node passed skips one level. - Move up:
x = parent[x], which is now the old grandparent. - Return
x, the root.
The solution in Python
class Solution:
def validTree(self, n: int, edges: List[List[int]]) -> bool:
if len(edges) != n - 1:
return False
parent = list(range(n))
size = [1] * n
for edge in edges:
rootA = self.find(parent, edge[0])
rootB = self.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
def find(self, parent: List[int], x: int) -> int:
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
Python details worth a closer look:
list(range(n))turns the range into the list[0, 1, ..., n - 1], where each node is its own parent.- Tuple assignment.
rootA, rootB = rootB, rootAevaluates the right side first, so the two names swap without a temporary variable. - Lists are shared, not copied.
findreceives the same list object asvalidTree, so its changes toparentare seen by the caller. That is what makes path halving useful across calls. - Path halving in action. With
parent = [0, 0, 1, 2, 3], a chain,find(parent, 4)returns 0 and leavesparentas[0, 0, 0, 2, 2]. Node 4 now points to 2, and node 2 to 0. - Iteration instead of recursion. A recursive
findon a long chain could hit Python's default recursion limit. Thewhileloop has no such limit.
Complexity
Time: O((n + e) · α(n)). Building the lists takes O(n). Because the edge count is checked first, the loop runs at most n - 1 times, each with two find calls costing O(α(n)) on average. That is nearly O(n + e).
Space: O(n). The parent and size lists have n entries each.
Edge cases and common mistakes
- Only counting edges.
n = 4with[[0, 1], [1, 2], [2, 0]]has the right count and is not a tree. The cycle check catches it. - Only checking for cycles. Without the count,
n = 3with[[0, 1]]would have no cycle and returnTrue, although node 2 is disconnected. The function returnsFalsebecause the count 1 is notn - 1 = 2. - A single node.
n = 1with no edges is a tree:0 == 1 - 1, the loop doesn't run, and the result isTrue. Withn = 2and no edges the count fails and the result isFalse. - Comparing the nodes instead of the roots. Edges
[0, 1],[1, 2],[2, 0]never repeat a pair of nodes, yet the last one closes a cycle. Only the roots show it. - Updating
sizefor the wrong root. After the swap,rootAis the larger group's root, so the sizes add intorootA. - Forgetting to return the root. If
findreturned the lastparent[x]read instead ofxafter the loop, the roots compared would be wrong.
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 = 5,edges = [[0, 1], [1, 2], [2, 0], [3, 4]]givesFalse: right count, but a cycle and a separate pair.n = 1,edges = []givesTrue.n = 2,edges = []givesFalse.n = 2,edges = [[0, 1]]givesTrue.n = 4,edges = [[0, 1], [1, 2], [2, 3]]givesTrue.
