Graph Valid Tree
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
n = 5, edges = [[0,1],[0,2],[0,3],[1,4]]trueThe 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
| 1 | class 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
Construct Adjacency List to Represent the Graph
| 6 | adj = {i: [] for i in range(n)}
|
| 7 | for 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.
Detect Cycles and Track Visited Nodes Using DFS with Parent Tracking
| 11 | visit = set()
|
| 12 | def 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.
Verify Full Connectivity and Return Final Result
| 24 | return 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.
Handle Edge Case of Zero Nodes
| 3 | if 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.
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.
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.
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.
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.
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.
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.
if not dfs(j, i):
Recursively call DFS on neighbor node.
Recursion explores deeper nodes, enabling full traversal and cycle detection in the graph.
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.
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.
visit = set()
Initialize a set to track visited nodes.
The visited set prevents revisiting nodes, which is crucial for cycle detection and connectivity verification.
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.
visit.add(i)
Mark current node as visited.
Recording the current node as visited prevents infinite loops and supports connectivity checks after traversal.
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.
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.
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.
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.
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.
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