Facebook Pixel

261. Graph Valid Tree πŸ”’

Problem Description

You are given a graph with n nodes labeled from 0 to n - 1. You also receive a list of edges where each edge edges[i] = [ai, bi] represents an undirected connection between nodes ai and bi.

Your task is to determine if these edges form a valid tree. Return true if the graph is a valid tree, and false otherwise.

A valid tree must satisfy these properties:

  • It must be connected (all nodes are reachable from any other node)
  • It must have no cycles (there's exactly one path between any two nodes)
  • For n nodes, a tree must have exactly n - 1 edges

The solution uses a Union-Find (Disjoint Set Union) data structure to detect cycles. The algorithm works by:

  1. Initializing each node as its own parent (creating n separate sets)
  2. For each edge [a, b], finding the root parents of both nodes
  3. If both nodes share the same root parent, a cycle exists (return false)
  4. Otherwise, merging the two sets by connecting one root to the other
  5. Decreasing the count of connected components by 1 for each successful merge
  6. After processing all edges, checking if exactly 1 connected component remains (indicating all nodes are connected in a single tree structure)

The find function implements path compression to optimize future lookups by directly connecting nodes to their root parent during the search process.

Flowchart Walkthrough

First, let's pin down the algorithm using the Flowchart. Here's a step-by-step walkthrough:

Is it a graph?

  • Yes: The problem explicitly states we have a graph with n nodes and a list of edges connecting these nodes.

Is it a tree?

  • Yes: The problem asks us to determine if the given graph forms a valid tree structure.

DFS

  • Yes: Since we're dealing with a tree validation problem, DFS (Depth-First Search) is an appropriate approach.

Conclusion: The flowchart suggests using DFS for this tree validation problem.

Why DFS Works Here

DFS is particularly suitable for this problem because:

  1. Cycle Detection: DFS can detect cycles by keeping track of visited nodes and checking if we revisit a node (excluding the immediate parent in an undirected graph).

  2. Connectivity Check: DFS can verify that all nodes are reachable from a starting node, ensuring the graph is connected.

  3. Tree Properties: A valid tree must have exactly n-1 edges for n nodes, be connected, and have no cycles - all properties that DFS can efficiently verify.

Alternative Approach in the Solution

While the flowchart leads us to DFS, the provided solution actually uses Union-Find (Disjoint Set Union), which also appears in the flowchart under the connectivity branch. This is equally valid because:

  • The problem fundamentally involves connectivity (checking if all nodes form one connected component)
  • Union-Find efficiently detects cycles (when two nodes being connected already share the same root)
  • Both DFS and Union-Find can solve this problem effectively, with Union-Find being particularly elegant for cycle detection and connectivity verification
Quick Interview Experience
Help others by sharing your interview experience
Have you seen this problem before?

Intuition

When we think about what makes a valid tree, we need to recall the fundamental properties: a tree is a connected graph with no cycles. For n nodes, it must have exactly n-1 edges.

The key insight is that we need to detect two potential failures:

  1. Cycles: If we can reach the same node through multiple paths
  2. Disconnected components: If some nodes are unreachable from others

Initially, we might think of using DFS to traverse the graph and check these properties. However, there's an elegant observation: as we build the graph edge by edge, we can detect cycles immediately using Union-Find.

Think of it this way - imagine each node starts in its own isolated group. As we add edges, we're connecting these groups. If an edge tries to connect two nodes that are already in the same group, we've found a cycle! This is because there was already a path between them, and adding this edge would create a second path.

The Union-Find approach naturally tracks connected components. We start with n separate components (each node by itself). Each valid edge should merge two different components, reducing our total count by 1. After processing all edges, if we end up with exactly 1 component, we know all nodes are connected.

The beauty of this solution is its efficiency - we detect cycles immediately as we process edges, and we simultaneously track connectivity. If at any point we find a cycle (two nodes already in the same set), we return false. If after processing all edges we have exactly one connected component (n == 1 after decreasing for each merge), we have a valid tree.

This approach avoids the complexity of tracking visited nodes and parent relationships that would be necessary in a DFS solution, making the code cleaner and more intuitive.

Solution Approach

The solution implements Union-Find (Disjoint Set Union) to validate whether the graph forms a tree. Let's walk through the implementation step by step:

Data Structure Initialization

p = list(range(n))

We create a parent array p where each node initially points to itself. This means we start with n separate components, where p[i] = i for all nodes.

Find Operation with Path Compression

def find(x: int) -> int:
    if p[x] != x:
        p[x] = find(p[x])
    return p[x]

The find function locates the root parent of a node. It implements path compression optimization - as we traverse up to find the root, we directly connect each node to the root, flattening the tree structure for faster future lookups.

Processing Edges

for a, b in edges:
    pa, pb = find(a), find(b)
    if pa == pb:
        return False
    p[pa] = pb
    n -= 1

For each edge [a, b]:

  1. Find roots: We find the root parents pa and pb of nodes a and b
  2. Cycle detection: If pa == pb, both nodes are already in the same component, meaning there's already a path between them. Adding this edge would create a cycle, so we return False
  3. Union operation: If they're in different components, we merge them by setting p[pa] = pb, making one root point to the other
  4. Component counting: We decrease n by 1, effectively tracking the number of remaining components

Final Validation

return n == 1

After processing all edges, we check if exactly one component remains. If n == 1, all nodes are connected in a single tree structure. If n > 1, the graph is disconnected with multiple components.

Why This Works

The algorithm cleverly uses the fact that a tree with n nodes must have exactly n-1 edges and be connected. By starting with n components and merging them one by one, we ensure:

  • No cycles exist (checked during union operation)
  • All nodes are connected (verified by final component count)
  • The edge count is implicitly correct (if we have cycles or disconnected components, we'll catch them)

The time complexity is O(E Γ— Ξ±(n)) where E is the number of edges and Ξ± is the inverse Ackermann function (practically constant), making this solution highly efficient.

Ready to land your dream job?

Unlock your dream job with a 5-minute evaluator for a personalized learning plan!

Start Evaluator

Example Walkthrough

Let's walk through a concrete example to understand how the Union-Find approach validates a tree structure.

Example 1: Valid Tree

  • n = 5 nodes (labeled 0-4)
  • edges = [[0,1], [0,2], [0,3], [1,4]]

Initial State:

Parent array p = [0, 1, 2, 3, 4]
Components: {0}, {1}, {2}, {3}, {4}
n = 5 (number of components)

Processing Edge [0,1]:

  • Find root of 0: find(0) = 0
  • Find root of 1: find(1) = 1
  • Different roots (0 β‰  1), so no cycle
  • Union: Set p[0] = 1
  • Decrease n: n = 4
  • State: p = [1, 1, 2, 3, 4], Components: {0,1}, {2}, {3}, {4}

Processing Edge [0,2]:

  • Find root of 0: find(0) β†’ p[0] = 1 β†’ find(1) = 1
  • Find root of 2: find(2) = 2
  • Different roots (1 β‰  2), so no cycle
  • Union: Set p[1] = 2
  • Decrease n: n = 3
  • State: p = [1, 2, 2, 3, 4], Components: {0,1,2}, {3}, {4}

Processing Edge [0,3]:

  • Find root of 0: find(0) β†’ find(1) β†’ find(2) = 2
  • Find root of 3: find(3) = 3
  • Different roots (2 β‰  3), so no cycle
  • Union: Set p[2] = 3
  • Decrease n: n = 2
  • State: p = [1, 2, 3, 3, 4], Components: {0,1,2,3}, {4}

Processing Edge [1,4]:

  • Find root of 1: find(1) β†’ find(2) β†’ find(3) = 3
  • Find root of 4: find(4) = 4
  • Different roots (3 β‰  4), so no cycle
  • Union: Set p[3] = 4
  • Decrease n: n = 1
  • State: p = [1, 2, 3, 4, 4], Components: {0,1,2,3,4}

Final Check: n == 1 βœ“ (All nodes connected in one component) Result: True - This is a valid tree!


Example 2: Invalid Tree (Contains Cycle)

  • n = 5 nodes
  • edges = [[0,1], [1,2], [2,3], [1,3], [1,4]]

Initial State:

Parent array p = [0, 1, 2, 3, 4]
n = 5

Processing first three edges [[0,1], [1,2], [2,3]]: After these edges, we have:

  • p = [1, 2, 3, 3, 4]
  • n = 2
  • Components: {0,1,2,3}, {4}

Processing Edge [1,3] (Creates a cycle!):

  • Find root of 1: find(1) β†’ find(2) β†’ find(3) = 3
  • Find root of 3: find(3) = 3
  • Same root (3 == 3)! This means nodes 1 and 3 are already connected
  • Adding this edge would create a cycle: 1β†’2β†’3 and 1β†’3
  • Return False immediately

The algorithm detects the cycle as soon as we try to connect two nodes that are already in the same component, preventing us from creating an invalid tree structure.

Solution Implementation

1class Solution:
2    def validTree(self, n: int, edges: List[List[int]]) -> bool:
3        """
4        Determines if the given edges form a valid tree with n nodes.
5        A valid tree must:
6        1. Be connected (all nodes in one component)
7        2. Have no cycles (exactly n-1 edges for n nodes)
8      
9        Args:
10            n: Number of nodes (labeled from 0 to n-1)
11            edges: List of undirected edges [node1, node2]
12      
13        Returns:
14            True if edges form a valid tree, False otherwise
15        """
16      
17        def find(node: int) -> int:
18            """
19            Finds the root parent of a node with path compression.
20          
21            Args:
22                node: The node to find the root parent for
23          
24            Returns:
25                The root parent of the node
26            """
27            if parent[node] != node:
28                # Path compression: directly connect node to root
29                parent[node] = find(parent[node])
30            return parent[node]
31      
32        # Initialize parent array where each node is its own parent
33        parent = list(range(n))
34      
35        # Track number of connected components (initially n separate nodes)
36        num_components = n
37      
38        # Process each edge
39        for node_a, node_b in edges:
40            # Find root parents of both nodes
41            root_a = find(node_a)
42            root_b = find(node_b)
43          
44            # If both nodes already have the same root, adding this edge creates a cycle
45            if root_a == root_b:
46                return False
47          
48            # Union: connect the two components by making one root the parent of the other
49            parent[root_a] = root_b
50          
51            # Decrease component count as two components are merged
52            num_components -= 1
53      
54        # A tree must have exactly one connected component
55        return num_components == 1
56
1class Solution {
2    // Parent array for Union-Find (Disjoint Set Union) data structure
3    private int[] parent;
4
5    /**
6     * Determines if the given edges form a valid tree.
7     * A valid tree must:
8     * 1. Be connected (all nodes in one component)
9     * 2. Have no cycles
10     * 3. Have exactly n-1 edges for n nodes
11     * 
12     * @param n     The number of nodes (labeled from 0 to n-1)
13     * @param edges The edges represented as pairs of nodes
14     * @return true if the edges form a valid tree, false otherwise
15     */
16    public boolean validTree(int n, int[][] edges) {
17        // Initialize parent array where each node is its own parent initially
18        parent = new int[n];
19        for (int i = 0; i < n; i++) {
20            parent[i] = i;
21        }
22      
23        // Process each edge
24        for (int[] edge : edges) {
25            // Find the root parents of both nodes in the edge
26            int parentA = find(edge[0]);
27            int parentB = find(edge[1]);
28          
29            // If both nodes already have the same parent, adding this edge creates a cycle
30            if (parentA == parentB) {
31                return false;
32            }
33          
34            // Union: Connect the two components by making one parent point to the other
35            parent[parentA] = parentB;
36          
37            // Decrease the number of components
38            n--;
39        }
40      
41        // After processing all edges, we should have exactly 1 component
42        // (started with n components, merged n-1 times)
43        return n == 1;
44    }
45
46    /**
47     * Finds the root parent of a node using path compression.
48     * Path compression optimizes future lookups by making nodes point directly to root.
49     * 
50     * @param x The node to find the root parent for
51     * @return The root parent of node x
52     */
53    private int find(int x) {
54        // If x is not its own parent, recursively find the root and compress path
55        if (parent[x] != x) {
56            parent[x] = find(parent[x]);  // Path compression
57        }
58        return parent[x];
59    }
60}
61
1class Solution {
2public:
3    bool validTree(int n, vector<vector<int>>& edges) {
4        // Initialize parent array for Union-Find (Disjoint Set Union)
5        // Each node initially points to itself as its parent
6        vector<int> parent(n);
7        iota(parent.begin(), parent.end(), 0);
8      
9        // Find function with path compression
10        // Returns the root parent of node x
11        function<int(int)> find = [&](int x) {
12            if (parent[x] != x) {
13                // Path compression: make x point directly to root
14                parent[x] = find(parent[x]);
15            }
16            return parent[x];
17        };
18      
19        // Process each edge
20        for (auto& edge : edges) {
21            // Find root parents of both nodes
22            int rootA = find(edge[0]);
23            int rootB = find(edge[1]);
24          
25            // If both nodes have the same root, adding this edge creates a cycle
26            if (rootA == rootB) {
27                return false;
28            }
29          
30            // Union: connect the two components by making one root point to the other
31            parent[rootA] = rootB;
32          
33            // Decrease the number of connected components
34            --n;
35        }
36      
37        // A valid tree should have exactly one connected component
38        return n == 1;
39    }
40};
41
1/**
2 * Determines if a given undirected graph is a valid tree.
3 * A valid tree must have exactly n-1 edges and be fully connected without cycles.
4 * 
5 * @param n - The number of nodes in the graph (labeled from 0 to n-1)
6 * @param edges - Array of edges where each edge is represented as [node1, node2]
7 * @returns true if the graph forms a valid tree, false otherwise
8 */
9function validTree(n: number, edges: number[][]): boolean {
10    // Initialize parent array for Union-Find data structure
11    // Each node initially points to itself as its parent
12    const parent: number[] = Array.from({ length: n }, (_, index) => index);
13  
14    /**
15     * Find operation with path compression for Union-Find.
16     * Finds the root parent of a node and compresses the path for optimization.
17     * 
18     * @param node - The node whose root parent we want to find
19     * @returns The root parent of the node
20     */
21    const find = (node: number): number => {
22        if (parent[node] !== node) {
23            // Path compression: make the node point directly to its root
24            parent[node] = find(parent[node]);
25        }
26        return parent[node];
27    };
28  
29    // Process each edge to build the Union-Find structure
30    for (const [nodeA, nodeB] of edges) {
31        // Find the root parents of both nodes
32        const rootA: number = find(nodeA);
33        const rootB: number = find(nodeB);
34      
35        // If both nodes have the same root, adding this edge would create a cycle
36        if (rootA === rootB) {
37            return false;
38        }
39      
40        // Union operation: connect the two components by making one root point to the other
41        parent[rootA] = rootB;
42      
43        // Decrease the count of connected components
44        n--;
45    }
46  
47    // A valid tree should have exactly one connected component remaining
48    return n === 1;
49}
50

Time and Space Complexity

Time Complexity: O(n Γ— Ξ±(n)) where Ξ±(n) is the inverse Ackermann function, which is effectively O(n Γ— log n) for practical purposes.

The algorithm uses Union-Find (Disjoint Set Union) with path compression. For each edge, we perform two find operations and one union operation. With path compression in the find function, each operation has an amortized time complexity of O(Ξ±(n)), where Ξ±(n) is the inverse Ackermann function. Since we process at most n-1 edges (for a valid tree), the total time complexity is O(n Γ— Ξ±(n)). The inverse Ackermann function grows extremely slowly and is effectively constant for all practical values of n, but can be upper bounded by O(log n), giving us O(n Γ— log n).

Space Complexity: O(n)

The space is primarily used for the parent array p which stores n elements. The recursive call stack for the find function with path compression can go up to O(log n) depth in the worst case, but since path compression flattens the tree structure, subsequent calls have reduced depth. The dominant space usage is the parent array, resulting in O(n) space complexity.

Common Pitfalls

1. Forgetting to Check for Disconnected Components

Pitfall: Many implementations focus only on cycle detection but forget to verify that all nodes are connected. Simply checking that no cycles exist is insufficient - the graph could consist of multiple disconnected trees.

Example of Incorrect Implementation:

def validTree(self, n: int, edges: List[List[int]]) -> bool:
    parent = list(range(n))
  
    def find(x):
        if parent[x] != x:
            parent[x] = find(parent[x])
        return parent[x]
  
    for a, b in edges:
        pa, pb = find(a), find(b)
        if pa == pb:
            return False  # Cycle detected
        parent[pa] = pb
  
    return True  # Wrong! Doesn't check if all nodes are connected

The Issue: This code would incorrectly return True for a disconnected forest like:

  • n = 4, edges = [[0,1], [2,3]]
  • This forms two separate trees (0-1 and 2-3), not a single valid tree

Solution: Always track the number of components and verify exactly one remains:

num_components = n
for a, b in edges:
    pa, pb = find(a), find(b)
    if pa == pb:
        return False
    parent[pa] = pb
    num_components -= 1  # Track component merging

return num_components == 1  # Must have exactly 1 component

2. Incorrect Edge Count Validation

Pitfall: Some solutions try to validate the edge count separately but place the check incorrectly or use it as the only validation.

Example of Incorrect Implementation:

def validTree(self, n: int, edges: List[List[int]]) -> bool:
    # Wrong: Checking edge count alone is insufficient
    if len(edges) != n - 1:
        return False
  
    # ... rest of union-find implementation

The Issue: While a tree must have exactly n-1 edges, having n-1 edges doesn't guarantee a tree structure. For example:

  • n = 4, edges = [[0,1], [1,2], [0,2]]
  • Has 3 edges (n-1), but contains a cycle (0-1-2-0)

Solution: The edge count check can be an early optimization but shouldn't replace cycle and connectivity validation:

def validTree(self, n: int, edges: List[List[int]]) -> bool:
    # Early return optimization (optional)
    if len(edges) != n - 1:
        return False
  
    # Still need full union-find validation
    parent = list(range(n))
    components = n
  
    def find(x):
        if parent[x] != x:
            parent[x] = find(parent[x])
        return parent[x]
  
    for a, b in edges:
        pa, pb = find(a), find(b)
        if pa == pb:
            return False
        parent[pa] = pb
        components -= 1
  
    return components == 1

3. Handling Edge Cases Incorrectly

Pitfall: Not handling special cases like empty graphs or single nodes properly.

Common Mistakes:

# Wrong: Doesn't handle n = 0 or n = 1 correctly
def validTree(self, n: int, edges: List[List[int]]) -> bool:
    if not edges:
        return False  # Wrong! A single node with no edges is a valid tree

Solution: Handle edge cases explicitly:

def validTree(self, n: int, edges: List[List[int]]) -> bool:
    # Edge cases
    if n == 0:
        return False  # Empty graph is not a tree
    if n == 1:
        return len(edges) == 0  # Single node is a tree only with no edges
  
    # For n >= 2, must have exactly n-1 edges
    if len(edges) != n - 1:
        return False
  
    # Continue with union-find...

4. Path Compression Implementation Error

Pitfall: Implementing path compression incorrectly, which doesn't affect correctness but loses the optimization benefit.

Incorrect Implementation:

def find(x):
    while parent[x] != x:
        x = parent[x]  # No path compression
    return x

Correct Implementation with Path Compression:

def find(x):
    if parent[x] != x:
        parent[x] = find(parent[x])  # Recursively compress path
    return parent[x]

The recursive version ensures all nodes along the path point directly to the root, significantly improving performance for subsequent operations.

Discover Your Strengths and Weaknesses: Take Our 5-Minute Quiz to Tailor Your Study Plan:

How many ways can you arrange the three letters A, B and C?


Recommended Readings

Want a Structured Path to Master System Design Too? Don’t Miss This!

Load More