A graph with n nodes is a valid tree exactly when it satisfies two conditions simultaneously: it is connected (every node reachable from every other) and it is acyclic (no cycles). A classic graph-theory fact ties these together with the edge count: any connected graph on n nodes needs at least n - 1 edges, and any graph with n nodes and exactly n - 1 edges is a tree if and only if it is also connected (equivalently, if and only if it is acyclic β with exactly n - 1 edges, connected and acyclic become the same condition). That means a single upfront check β len(edges) == n - 1 β combined with a connectivity check is enough.
Running example: n = 5, edges = [[0,1],[0,2],[0,3],[1,4]].
DFS Reachability + Parent-Aware Cycle Check
Time O(V + E)Space O(V + E)First reject immediately if there are not exactly n - 1 edges β too few canβt connect everything, too many guarantees a cycle. Otherwise, build an adjacency list and DFS from node 0, tracking each nodeβs parent in the DFS tree so that walking back along the edge you just came from is not mistaken for a cycle. If DFS reaches an already-visited node that is not the immediate parent, that is a genuine cycle. Afterward, check that DFS reached all n nodes.
class Solution: def validTree(self, n: int, edges: list[list[int]]) -> bool: if len(edges) != n - 1: return False
graph = [[] for _ in range(n)] for a, b in edges: graph[a].append(b) graph[b].append(a)
visited = set()
def dfs(u, parent): visited.add(u) for v in graph[u]: if v == parent: continue if v in visited: return False if not dfs(v, u): return False return True
if not dfs(0, -1): return False return len(visited) == nOn the example: edge count check passes (4 == 5 - 1). dfs(0, -1) visits 0, then walks to 1 (dfs(1, 0)), which walks to 4 (dfs(4, 1), a dead end) and back to 0 β but 0 is 1βs parent, so thatβs skipped, not flagged as a cycle. Back at 0, DFS continues to 2 and 3, both dead ends. All 5 nodes end up visited with no cycle found, so the result is True.
Why itβs correct: the n - 1 edge count rules out both βtoo sparse to connectβ and βtoo dense to be acyclicβ in one comparison; the parent-aware DFS then confirms both remaining properties in a single pass β reaching an already-visited non-parent node proves a cycle, and len(visited) == n proves connectivity. Complexity: the adjacency list is built in O(E) and DFS visits each node and edge once β O(V + E) time, O(V + E) space for the graph, visited set, and recursion stack.
Union-Find
OptimalTime O(V + E)Space O(V)Process edges one at a time, unioning the two endpointsβ components. If an edge ever connects two nodes that are already in the same component, that edge closes a cycle β immediate False. If every edge unions two previously-separate components, and there are exactly n - 1 of them, all n nodes have been merged into a single component by construction, so connectivity is guaranteed without a separate check.
class Solution: def validTree(self, n: int, edges: list[list[int]]) -> bool: if len(edges) != n - 1: return False
parent = list(range(n))
def find(x): while parent[x] != x: parent[x] = parent[parent[x]] # path compression x = parent[x] return x
def union(x, y): root_x, root_y = find(x), find(y) if root_x == root_y: return False # already connected -> this edge creates a cycle parent[root_x] = root_y return True
return all(union(a, b) for a, b in edges)Run the trace on the example (n = 5, edges = [[0,1],[0,2],[0,3],[1,4]]). Watch the mechanism that makes the algorithm work β before every edge, find climbs the parent arrows to each endpointβs root, and a union is allowed only when those two roots differ:
Example 1 β a valid tree
parent array
The edge count gate passes first: 4 edges for 5 nodes, and 4 = 5 - 1. Every node starts as its own component, so `parent[i] = i` makes each node its own root. Now watch the mechanism: each edge runs find on both endpoints, and the climb to the root is what lets union decide. The code also path-compresses each climb; the chains below skip that cosmetic rewiring, but every root, union, and verdict shown equals exactly what the code computes.
All four edges merged different roots, so no cycle formed and every union succeeded β the parent chain ends as 0 β 1 β 2 β 3 β 4. Count the components from the start: 5 singletons, and each successful union reduces that count by 1, so 4 successful unions leave exactly one component β connected and acyclic, a valid tree.
The edge-count check cannot see every cycle, though. This second run uses exactly 4 edges on the same 5 nodes, so the gate passes β but edge (2, 0) will try to connect two nodes that already share a root. Watch both find climbs land on root 3:
Example 2 β one cycle inside the edge budget
parent array
The edge count gate passes again: 4 edges, 5 nodes. The count check cannot help here, so the loop must do the work. The first three edges build a component the same way as before; the fourth edge, (2, 0), will try to connect two nodes that already share a root. (The second example in the problem statement needs no trace at all β 5 edges on 5 nodes fails `len(edges) != n - 1` on the very first line. This 4-edge variant is what the union cycle branch exists to catch.)
The shared root makes union return False, so all(...) short-circuits and the answer is False immediately. (The statementβs second example β five edges on five nodes β is caught even earlier by the len(edges) != n - 1 check, before the loop ever runs.) Every successful union merges two previously-separate components, so with n - 1 successful edges and no cycle, all n nodes are guaranteed to sit in one component.
Why itβs correct: two nodes share a root if and only if they are already connected through previously-processed edges; unioning them anyway would add a redundant, cycle-creating connection. Requiring exactly n - 1 edges and every union to succeed means every edge strictly grows the number of connected nodes by one, which forces full connectivity by the time all edges are processed. Complexity: n - 1 union-find operations, each nearly O(1) amortized with path compression β O(V + E) time (effectively O(V Β· Ξ±(V))), and O(V) space for the parent array β no adjacency list needed at all, which is why this is the leaner approach.