HARD Lab · Graphs · 46

261. Graph Valid Tree

A graph is a valid tree iff it has exactly n−1 edges and is fully connected with no cycles. Union-Find lets you check both conditions in a single pass: each edge either merges two components (good) or connects two nodes already in the same component (cycle — bad).

MediumUnion-FindConnectivityTypeScript

PROBLEM What we're solving

Given n nodes (labeled 0 to n−1) and a list of undirected edges, return true if the edges form a valid tree. A valid tree has no cycles and is fully connected — every node is reachable from every other. Example: n=5, edges=[[0,1],[0,2],[0,3],[1,4]] true. If you add edge [2,4] you get a cycle → false.

KEY IDEA Tree ⇔ exactly n−1 edges AND no cycle

Insight → a connected graph on n nodes is a tree iff it has exactly n−1 edges. That single constraint guarantees both "no cycle" and "fully connected" — you only need to verify one of them. Check edge count first (O(1) reject), then use Union-Findto catch any cycle: if an edge tries to connect two nodes that are already in the same component, you've found a cycle.

RECIPE Edge-count check, then Union-Find

  • 0 · Quick reject. If edges.length !== n − 1, return false immediately — too few means disconnected, too many means a cycle exists.
  • 1 · Initialize Union-Find. parent[i] = i so each node is its own component. Optionally maintain a rank array for union by rank.
  • 2 · Process each edge. For edge [a, b], call find(a) and find(b). If their roots are equal, they're already connected — a cycle exists, return false. Otherwise call union to merge the components.
  • 3 · Return true. We verified n−1 edges, no cycle → exactly one connected component → valid tree.
Classic confusion → many people skip the edge-count check and try to count components at the end (components === 1). That works but is unnecessary: n−1 edges + no cycle guaranteesexactly one component. Conversely, n−1 edges alone doesn't guarantee connectivity (e.g. two separate smaller trees), so you still need both conditions — but the cycle check via Union-Find handles connectivity implicitly.

COST Complexity & alternatives

DFS / BFS cycle check
O(n + e)
O(n) extra space for visited set + recursion stack.
Union-Find (rank + halving)
O(n · α(n))
Near-linear; O(n) space; iterative (no stack overflow).

Which to use?

Both are accepted. Union-Find with path compression and union-by-rank runs in O(n · α(n)) where α is the inverse Ackermann function — practically O(n). DFS is simpler to write and also O(n + e) but has recursive overhead and requires careful "parent" tracking to avoid treating the undirected back-edge as a false cycle.

Pattern transfer → the same Union-Find skeleton solves Number of Connected Components in an Undirected Graph (LC 323), Redundant Connection (LC 684 — return the exact offending edge), Accounts Merge (LC 721), and Number of Provinces(LC 547). Whenever you see "merge groups" or "detect a cycle" in an undirected graph, reach for Union-Find.

RUN IT Union-Find: merge or detect a cycle

step 0 / 5
STARTn=5, 4 edges (need exactly 4 for a tree). Running Union-Find.
0-10-20-31-4
State
[0, 1, 2, 3, 4]
parent
5
components
union succeededalready processedcycle — same componentmultiple components remaining
slowfast

TYPESCRIPT The solution, annotated

validTree.ts
function validTree(n: number, edges: number[][]): boolean {
  // A valid tree has exactly n-1 edges AND is fully connected (no cycles).
  if (edges.length !== n - 1) return false;

  const parent: number[] = Array.from({ length: n }, (_, i) => i);
  const rank: number[] = new Array(n).fill(0);

  function find(x: number): number {
    while (parent[x] !== x) {
      parent[x] = parent[parent[x]]; // path compression (halving)
      x = parent[x];
    }
    return x;
  }

  function union(a: number, b: number): boolean {
    const ra = find(a);
    const rb = find(b);
    if (ra === rb) return false; // already in the same component → cycle
    if (rank[ra] < rank[rb]) {
      parent[ra] = rb;
    } else if (rank[ra] > rank[rb]) {
      parent[rb] = ra;
    } else {
      parent[rb] = ra;
      rank[ra]++;
    }
    return true;
  }

  for (const [a, b] of edges) {
    if (!union(a, b)) return false; // cycle found
  }
  return true; // n-1 edges, no cycle → connected tree
}

Reading it block by block

Line 3 — O(1) edge-count reject. A tree on n nodes must have exactly n−1 edges. Checking this first eliminates impossible inputs before any expensive work. Too few edges → disconnected; too many → at least one cycle.
Lines 5–6 — initialize Union-Find. parent[i] = i makes each node its own root. The rank array controls tree height during union to keep find fast.
Lines 8–15 — find with path halving. Walking up the parent chain until we reach a root. Path halving (parent[x] = parent[parent[x]]) flattens the tree lazily, keeping future find calls O(α(n)).
Lines 17–29 — union by rank.Always attach the shorter tree under the taller one. When ranks are equal, break ties arbitrarily and increment the winner's rank. This keeps tree depth O(log n) worst-case before path compression.
Lines 31–34 — main loop. For each edge [a, b], call union(a, b). If it returns false, the two nodes share a root → adding this edge would create a cycle → return false immediately.
Line 35 — return true. We confirmed n−1 edges and zero cycles. A connected acyclic graph is by definition a tree.
Complexity → O(n · α(n)) time where α is the inverse Ackermann function — effectively O(n) for all practical inputs. O(n) space for the parent and rank arrays.

INTERVIEWFollow-ups they'll ask

  • "Return the redundant edge instead of true/false?"That's LC 684 (Redundant Connection). Same Union-Find loop; instead of returning false, return the offending [a, b].
  • "Solve with DFS instead?"Yes. BFS/DFS from node 0, track visited. If you revisit a node via a different parent, it's a cycle. After traversal check that all nodes were visited. Be careful: for undirected graphs, don't flag the parent edge as a back-edge.
  • "What if the graph is directed?"A directed tree (rooted DAG) needs a different check — detect back edges via DFS coloring (white/gray/black) or Kahn's algorithm for DAGs.
  • "Can you do it in O(1) extra space?" Not easily — Union-Find needs O(n) for parent. You could destructively modify an adjacency list in-place but that's impractical.
  • "What if n=1?" Zero edges required for a single-node tree. edges.length === 0 === n−1passes the check, the loop doesn't execute, and we return true correctly.

MNEMONIC The one-liner

"n−1 edges or bust; then Union-Find — same root means a cycle, different roots means merge."

TRIGGERS When you see ___ → reach for ___

"valid tree" / "no cycle + connected"edge count check + Union-Find
"connected components" in undirected graphUnion-Find with find/union
"redundant edge" / merge groupsUnion-Find — return the bad edge
cycle detection in undirected graphUnion-Find or DFS with parent tracking

SKELETON The reusable shape

skeleton.ts
if (edges.length !== n - 1) return false;

const parent = Array.from({ length: n }, (_, i) => i);
const rank = new Array(n).fill(0);

function find(x: number): number {
  while (parent[x] !== x) {
    parent[x] = parent[parent[x]];
    x = parent[x];
  }
  return x;
}

for (const [a, b] of edges) {
  const ra = find(a), rb = find(b);
  if (ra === rb) return false; // cycle
  // union by rank ...
}
return true;

FLASHCARDS Tap to flip

A connected graph on n nodes is a tree iff…
it has exactly n−1 edges (no more, no fewer).
tap to flip
1 / 7
Answer to reveal explanations. No penalty for retries.Score: 0 / 7
QUESTION 01
For n=5 and edges=[[0,1],[0,2],[0,3],[1,4]], what does validTree return?
QUESTION 02
What is the minimum edge count needed to conclude "not a valid tree" before running Union-Find?
QUESTION 03
During Union-Find processing, you call find(2) and find(4) for edge [2,4] and both return 0. What happens next?
QUESTION 04
What is the time complexity of the Union-Find approach with union by rank and path compression?
QUESTION 05
You have n=4 and edges=[[0,1],[2,3]]. What does validTree return and why?
QUESTION 06
Why doesn't DFS cycle detection need the edge-count check?
QUESTION 07
Path halving sets parent[x] = parent[parent[x]]. What does this accomplish?