Graph Valid Tree

Medium DFS

Problem

Given an integer n and a list of edges representing an undirected graph, determine if the graph is a valid tree, meaning it is connected and contains no cycles.

  • 1 ≤ n ≤ 10⁴
  • 0 ≤ edges.length ≤ 10⁴
  • edges[i].length == 2
  • 0 ≤ edges[i][j] < n
  • No duplicate edges
  • No self-loops

Example

Input: n = 5, edges = [[0,1],[0,2],[0,3],[1,4]]
Output: true

The graph has 5 nodes and 4 edges, which is n-1 edges, a necessary condition for a tree. Starting DFS from node 0, all nodes are visited without encountering a cycle. The graph is connected and acyclic, so it is a valid tree.

Approach

Straightforward Solution

A brute-force approach might check all possible subsets of edges to find a spanning tree, which is exponential and infeasible. Alternatively, one might attempt to detect cycles without verifying connectivity, which can lead to false positives.

Core Observation

A valid tree with n nodes must have exactly n-1 edges and be fully connected without cycles. The absence of cycles can be detected by DFS with a parent tracking mechanism, and connectivity is verified by ensuring all nodes are visited.

Path to Optimal

The key recognition signals are 'valid tree', 'connected', and 'no cycles'. These indicate a graph traversal approach with cycle detection. DFS is a natural choice to explore connectivity and detect cycles by tracking visited nodes and the immediate parent to avoid false cycle detection from bidirectional edges. After DFS, verifying that all nodes are visited confirms connectivity. The edge count check (edges == n-1) is a quick precondition that can shortcut impossible cases.

Optimal Approach

Build an adjacency list to represent the graph. Perform a DFS starting from node 0, passing the previous node to avoid revisiting the immediate parent. If a visited node is encountered again (not the parent), a cycle exists and return false. After DFS, check if all nodes were visited to confirm connectivity. Return true only if no cycles were found and all nodes are visited. This approach runs in O(n + e) time and uses O(n + e) space.

Time

O(n + e)

Building the adjacency list takes O(e). DFS visits each node and edge once, resulting in O(n + e) total time.

Space

O(n + e)

The adjacency list stores all edges, requiring O(n + e) space. The visited set stores up to n nodes. The recursion stack can go up to O(n) in the worst case.

Pattern Spotlight

DFS (Cycle Detection and Connectivity Check)

When verifying if an undirected graph is a tree, use DFS to detect cycles by tracking the parent node to avoid false positives from bidirectional edges, and confirm connectivity by ensuring all nodes are visited.

Solution

1class Solution:
2 def validTree(self, n: int, edges: list[list[int]]) -> bool:
3 if not n:
4 return True
5
6 adj = {i: [] for i in range(n)}
7 for n1, n2 in edges:
8 adj[n1].append(n2)
9 adj[n2].append(n1)
10
11 visit = set()
12 def dfs(i, prev):
13 if i in visit:
14 return False
15
16 visit.add(i)
17 for j in adj[i]:
18 if j == prev:
19 continue
20 if not dfs(j, i):
21 return False
22 return True
23
24 return dfs(0, -1) and n == len(visit)

Step-by-Step Solution

1

Construct Adjacency List to Represent the Graph

6adj = {i: [] for i in range(n)}
7for n1, n2 in edges:
8 adj[n1].append(n2)
9 adj[n2].append(n1)

Objective

To build a graph representation that enables efficient traversal and neighbor lookup.

Key Insight

An adjacency list is the most space- and time-efficient way to represent sparse graphs. It allows quick access to neighbors of any node, which is essential for DFS traversal. Initializing the adjacency list with empty lists for all nodes ensures all nodes are accounted for, even isolated ones.

Interview Quick-Check

Core Logic

The adjacency list maps each node to a list of its neighbors, enabling O(1) average-time neighbor access during DFS.

Common Pitfalls & Bugs

Forgetting to add edges in both directions for an undirected graph leads to incorrect traversal and missed connections.

2

Detect Cycles and Track Visited Nodes Using DFS with Parent Tracking

11visit = set()
12def dfs(i, prev):
13 if i in visit:
14 return False
16 visit.add(i)
17 for j in adj[i]:
18 if j == prev:
19 continue
20 if not dfs(j, i):
21 return False
22 return True

Objective

To recursively explore the graph, detect cycles, and mark nodes as visited to verify connectivity.

Key Insight

DFS explores nodes deeply, and by passing the previous node, it avoids mistaking the bidirectional edge back to the parent as a cycle. If a node is revisited that is not the parent, a cycle exists. Marking nodes as visited prevents infinite loops and allows later verification that the graph is connected by comparing the visited set size to n.

Interview Quick-Check

Core Logic

DFS uses the visited set to detect cycles by checking if a neighbor has been visited and is not the parent, which indicates a back edge and thus a cycle.

State & Boundaries

The base case returns false immediately upon detecting a cycle, pruning further exploration.

Common Pitfalls & Bugs

Failing to skip the parent node during neighbor iteration causes false cycle detection.

3

Verify Full Connectivity and Return Final Result

24return dfs(0, -1) and n == len(visit)

Objective

To confirm that all nodes are reachable from the starting node and that no cycles were detected.

Key Insight

A valid tree must be connected, so the visited set must contain all n nodes after DFS. Combining the cycle detection result with this connectivity check ensures the graph is a valid tree. Returning the logical AND of these conditions succinctly captures the problem requirements.

Interview Quick-Check

Core Logic

The final return statement ensures the graph is acyclic (DFS returned true) and connected (visited all nodes).

Common Pitfalls & Bugs

Neglecting to check connectivity can lead to false positives when the graph has multiple disconnected components.

4

Handle Edge Case of Zero Nodes

3if not n:
4 return True

Objective

To immediately return true if the graph has no nodes, as an empty graph is trivially a valid tree.

Key Insight

This early return handles the trivial edge case where n is zero, avoiding unnecessary computation and potential errors in subsequent logic. It clarifies the function's behavior for empty input.

Interview Quick-Check

State & Boundaries

Check for n == 0 before building the graph or running DFS to handle empty input gracefully.

Line Analysis

This solution has 8 Critical lines interviewers watch for.

Line 13 Critical
if i in visit:

Check if current node has already been visited.

Encountering a visited node during DFS (other than the parent) indicates a cycle, so returning false here detects cycles early.

Line 18 Critical
if j == prev:

Skip the neighbor if it is the previous node.

Ignoring the edge back to the parent prevents false cycle detection caused by bidirectional edges in undirected graphs.

Line 24 Critical
return dfs(0, -1) and n == len(visit)

Return true if DFS from node 0 found no cycles and all nodes were visited.

Combining cycle detection with connectivity check ensures the graph is a valid tree, as both conditions are necessary and sufficient.

Line 8 Critical
adj[n1].append(n2)

Add second node to the adjacency list of the first node.

Adding edges in both directions reflects the undirected nature of the graph, which is critical for correct traversal.

Line 9 Critical
adj[n2].append(n1)

Add first node to the adjacency list of the second node.

This symmetric addition ensures that the graph representation correctly models undirected edges, preventing traversal errors.

Line 14 Critical
return False

Return false upon cycle detection.

This immediate return prunes the search and signals that the graph is not a valid tree due to a cycle.

Line 20 Critical
if not dfs(j, i):

Recursively call DFS on neighbor node.

Recursion explores deeper nodes, enabling full traversal and cycle detection in the graph.

Line 21 Critical
return False

Return false if a cycle is detected in recursion.

Propagating the false return up the call stack ensures that any detected cycle invalidates the entire graph.

Line 6 Core
adj = {i: [] for i in range(n)}

Initialize adjacency list with empty lists for each node.

Creating an adjacency list for all nodes ensures that even isolated nodes are represented, enabling accurate traversal and connectivity checks.

Line 11 Core
visit = set()

Initialize a set to track visited nodes.

The visited set prevents revisiting nodes, which is crucial for cycle detection and connectivity verification.

Line 12 Core
def dfs(i, prev):

Define DFS function with current node and previous node parameters.

Passing the previous node allows the DFS to distinguish between a legitimate back edge and the edge leading to the parent, preventing false cycle detection.

Line 16 Core
visit.add(i)

Mark current node as visited.

Recording the current node as visited prevents infinite loops and supports connectivity checks after traversal.

Line 19 Core
continue

Continue to next neighbor if it is the parent.

This skip maintains correct cycle detection logic by not treating the parent edge as a cycle.

Line 22 Core
return True

Return true if no cycles found in neighbors.

Returning true indicates the current path is acyclic, allowing DFS to continue exploring other branches.

Line 3 Core
if not n:

Check if the number of nodes is zero.

An empty graph with zero nodes is trivially a valid tree, so returning true immediately handles this edge case efficiently.

Line 7 Core
for n1, n2 in edges:

Iterate over each edge to populate adjacency list.

Processing each edge to build the graph representation is essential for enabling efficient neighbor lookups during DFS.

Line 17 Core
for j in adj[i]:

Iterate over neighbors of the current node.

Exploring all adjacent nodes is necessary to traverse the entire connected component and detect cycles.

Line 4 Standard
return True

Return true for empty graph.

This early return prevents unnecessary processing and clarifies the function's behavior for empty input.

Test Your Understanding

Why is it necessary to track the previous node during DFS when detecting cycles in an undirected graph?

Because in an undirected graph, edges are bidirectional, so the immediate parent node will always appear as a neighbor. Without tracking the previous node, the algorithm would falsely detect a cycle when revisiting the parent. Tracking the previous node prevents this false positive by ignoring the edge back to the parent.

Related Problems

DFS pattern

Don't just read it. Drill it.

Reconstruct Graph Valid Tree from memory until it sticks. AlgoDrill blanks out key lines and makes you fill them back in, step by step.

Unlock the Graph Valid Tree drill

or drill a free problem