Facebook Pixel

2421. Number of Good Paths

Problem Description

You are given a tree with n nodes numbered from 0 to n - 1, connected by exactly n - 1 edges. Each node i has a value vals[i].

A good path is a simple path in the tree that satisfies two conditions:

  1. The starting and ending nodes must have the same value
  2. All nodes along the path (including start and end) must have values less than or equal to the value of the starting/ending nodes

In other words, if you walk from one endpoint to the other, the endpoints have the maximum value along the entire path, and both endpoints share this same maximum value.

Your task is to count the total number of distinct good paths in the tree.

Important notes:

  • A path and its reverse are considered the same (e.g., path 0 -> 1 is the same as 1 -> 0)
  • A single node by itself counts as a valid good path
  • The tree is represented as an undirected graph with edges[i] = [ai, bi] indicating an edge between nodes ai and bi

For example, if we have nodes with values [1, 3, 2, 1, 3] and appropriate edges, we need to find all paths where:

  • Both endpoints have value 1 (and all nodes on the path have values ≤ 1)
  • Both endpoints have value 2 (and all nodes on the path have values ≤ 2)
  • Both endpoints have value 3 (and all nodes on the path have values ≤ 3)
  • Plus all single nodes count as good paths

The solution uses a Union-Find approach combined with sorting. By processing nodes in increasing order of their values, we can efficiently merge connected components and count good paths where the current node's value serves as the maximum value along the path.

Quick Interview Experience
Help others by sharing your interview experience
Have you seen this problem before?

Intuition

The key insight is that for a good path, the endpoints must have the maximum value along the entire path. This suggests we should process nodes in a specific order - from smallest to largest values.

Why does this ordering help? When we process nodes by increasing value, we can guarantee that any previously processed node has a value less than or equal to the current node. This means when we're at node a with value v, any adjacent node b that we've already processed (with value ≤ v) can potentially form good paths with a as one endpoint.

Here's the crucial observation: if we connect two groups of nodes through an edge where both endpoints have value v or less, then any node with value v in one group can form a good path with any node with value v in the other group. Why? Because:

  1. The endpoints have the same value v
  2. All intermediate nodes were processed earlier, so they have values ≤ v

This naturally leads to a Union-Find approach:

  • Process nodes in increasing order of their values
  • When processing node a, look at its neighbors b that have been processed (values ≤ vals[a])
  • If a and b are in different connected components, merge them
  • Count the good paths formed: if component 1 has x nodes with value vals[a] and component 2 has y nodes with value vals[a], then merging creates x * y new good paths

The beauty of this approach is that by processing nodes in sorted order, we ensure that when we're looking for good paths with maximum value v, we've already built all the necessary connections between nodes with values ≤ v. Each node also counts as its own good path (a path of length 0), so we start with n good paths.

Learn more about Tree, Union Find, Graph and Sorting patterns.

Solution Approach

The implementation uses Sorting + Union-Find to efficiently count good paths.

Step 1: Build the adjacency list First, we create an adjacency list g to represent the tree structure, making it easy to find neighbors of any node.

Step 2: Initialize Union-Find structures

  • p: parent array for Union-Find, initially p[i] = i (each node is its own parent)
  • size: a dictionary where size[i][v] tracks how many nodes with value v exist in the connected component with root i
  • Initially, size[i][vals[i]] = 1 for each node i

Step 3: Sort and process nodes We create pairs of (vals[i], i) and sort them. This ensures we process nodes in increasing order of their values.

Step 4: Process each node and merge components For each node a with value v:

  1. Check all neighbors b of a
  2. Skip if vals[b] > v (neighbor hasn't been processed yet)
  3. Find the root components pa and pb using path compression
  4. If they're in different components (pa != pb):
    • Calculate contribution: size[pa][v] * size[pb][v]
    • This counts all good paths where one endpoint is a node with value v in component pa and the other endpoint is a node with value v in component pb
    • Merge components: set p[pa] = pb
    • Update size: size[pb][v] += size[pa][v]

Step 5: Count total good paths

  • Start with ans = n (each single node is a good path)
  • Add contributions from merging components as we process nodes
  • Return the final count

The find function implements path compression for efficiency:

def find(x):
    if p[x] != x:
        p[x] = find(p[x])  # Path compression
    return p[x]

Time Complexity: O(n log n) for sorting + O(n × α(n)) for Union-Find operations, where α is the inverse Ackermann function (practically constant).

Space Complexity: O(n) for the Union-Find structures and adjacency list.

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 small example to illustrate the solution approach.

Consider a tree with 5 nodes and values vals = [1, 3, 2, 1, 3]:

    0(1)---1(3)
           /   \
        2(2)   3(1)
               /
            4(3)

Edges: [[0,1], [1,2], [1,3], [3,4]]

Step 1: Initial Setup

  • Build adjacency list: g = {0:[1], 1:[0,2,3], 2:[1], 3:[1,4], 4:[3]}
  • Initialize Union-Find: p = [0,1,2,3,4] (each node is its own parent)
  • Initialize size tracker: size = {0:{1:1}, 1:{3:1}, 2:{2:1}, 3:{1:1}, 4:{3:1}}
  • Start with ans = 5 (each single node is a good path)

Step 2: Sort nodes by value Sorted pairs: [(1,0), (1,3), (2,2), (3,1), (3,4)]

Step 3: Process nodes in order

Processing node 0 (value=1):

  • Check neighbor 1: vals[1]=3 > 1, skip (not processed yet)
  • No merges, ans = 5

Processing node 3 (value=1):

  • Check neighbor 1: vals[1]=3 > 1, skip
  • Check neighbor 4: vals[4]=3 > 1, skip
  • No merges, ans = 5

Processing node 2 (value=2):

  • Check neighbor 1: vals[1]=3 > 2, skip
  • No merges, ans = 5

Processing node 1 (value=3):

  • Check neighbor 0: vals[0]=1 ≤ 3, process it
    • find(0)=0, find(1)=1 (different components)
    • Contribution: size[0][3] × size[1][3] = 0 × 1 = 0
    • Merge: p[0]=1, update size[1][1] = 1+1 = 2
  • Check neighbor 2: vals[2]=2 ≤ 3, process it
    • find(2)=2, find(1)=1 (different components)
    • Contribution: size[2][3] × size[1][3] = 0 × 1 = 0
    • Merge: p[2]=1
  • Check neighbor 3: vals[3]=1 ≤ 3, process it
    • find(3)=3, find(1)=1 (different components)
    • Contribution: size[3][3] × size[1][3] = 0 × 1 = 0
    • Merge: p[3]=1, update size[1][1] = 2+1 = 3
  • ans = 5 + 0 = 5

Processing node 4 (value=3):

  • Check neighbor 3: vals[3]=1 ≤ 3, process it
    • find(3)=1 (through path compression), find(4)=4
    • Different components!
    • Contribution: size[1][3] × size[4][3] = 1 × 1 = 1
    • This represents the good path from node 1 to node 4 (both have value 3)
    • Merge: p[1]=4, update size[4][3] = 1+1 = 2
  • ans = 5 + 1 = 6

Final Answer: 6 good paths

  1. Single node 0: [0]
  2. Single node 1: [1]
  3. Single node 2: [2]
  4. Single node 3: [3]
  5. Single node 4: [4]
  6. Path from 1 to 4: [1-3-4] (both endpoints have value 3, all intermediate nodes have values ≤ 3)

The key insight is that when we process node 4 (value=3), it can connect with node 1 (also value=3) through the already-processed nodes with smaller values, creating one additional good path.

Solution Implementation

1class Solution:
2    def numberOfGoodPaths(self, vals: List[int], edges: List[List[int]]) -> int:
3        # Find operation with path compression for Union-Find
4        def find(node: int) -> int:
5            if parent[node] != node:
6                parent[node] = find(parent[node])  # Path compression
7            return parent[node]
8      
9        # Build adjacency list for the graph
10        graph = defaultdict(list)
11        for node_a, node_b in edges:
12            graph[node_a].append(node_b)
13            graph[node_b].append(node_a)
14      
15        # Initialize Union-Find structure
16        n = len(vals)
17        parent = list(range(n))  # Each node is initially its own parent
18      
19        # Track count of each value in each component
20        # component_value_count[root][value] = count of nodes with that value
21        component_value_count = defaultdict(Counter)
22        for node_idx, node_val in enumerate(vals):
23            component_value_count[node_idx][node_val] = 1
24      
25        # Start with n good paths (each node forms a path with itself)
26        good_paths_count = n
27      
28        # Process nodes in ascending order of their values
29        # This ensures we only connect nodes with values <= current value
30        for current_val, current_node in sorted(zip(vals, range(n))):
31            # Check all neighbors of current node
32            for neighbor in graph[current_node]:
33                # Skip neighbors with larger values (they'll be processed later)
34                if vals[neighbor] > current_val:
35                    continue
36              
37                # Find roots of both components
38                root_current = find(current_node)
39                root_neighbor = find(neighbor)
40              
41                # If they're in different components, merge them
42                if root_current != root_neighbor:
43                    # Add new good paths formed by connecting nodes with same max value
44                    # from both components
45                    good_paths_count += (component_value_count[root_current][current_val] * 
46                                       component_value_count[root_neighbor][current_val])
47                  
48                    # Union: merge smaller component into larger
49                    parent[root_current] = root_neighbor
50                  
51                    # Update value count in the merged component
52                    component_value_count[root_neighbor][current_val] += component_value_count[root_current][current_val]
53      
54        return good_paths_count
55
1class Solution {
2    // Parent array for Union-Find (Disjoint Set Union)
3    private int[] parent;
4
5    public int numberOfGoodPaths(int[] vals, int[][] edges) {
6        int n = vals.length;
7        parent = new int[n];
8      
9        // Create array to store [value, nodeIndex] pairs for sorting
10        int[][] valueNodePairs = new int[n][2];
11      
12        // Build adjacency list representation of the graph
13        List<Integer>[] adjacencyList = new List[n];
14        Arrays.setAll(adjacencyList, k -> new ArrayList<>());
15        for (int[] edge : edges) {
16            int nodeA = edge[0];
17            int nodeB = edge[1];
18            adjacencyList[nodeA].add(nodeB);
19            adjacencyList[nodeB].add(nodeA);
20        }
21      
22        // Map to track count of nodes with specific values in each connected component
23        // componentRoot -> (nodeValue -> count)
24        Map<Integer, Map<Integer, Integer>> componentValueCounts = new HashMap<>();
25      
26        // Initialize Union-Find structure and value-node pairs
27        for (int i = 0; i < n; i++) {
28            parent[i] = i;  // Each node is initially its own parent
29            valueNodePairs[i] = new int[] {vals[i], i};
30            // Initially, each component has one node with its value
31            componentValueCounts.computeIfAbsent(i, k -> new HashMap<>()).put(vals[i], 1);
32        }
33      
34        // Sort nodes by their values in ascending order
35        Arrays.sort(valueNodePairs, (a, b) -> a[0] - b[0]);
36      
37        // Start with n good paths (each node forms a path with itself)
38        int goodPathCount = n;
39      
40        // Process nodes in order of increasing values
41        for (int[] pair : valueNodePairs) {
42            int currentValue = pair[0];
43            int currentNode = pair[1];
44          
45            // Check all neighbors of current node
46            for (int neighbor : adjacencyList[currentNode]) {
47                // Only consider neighbors with values <= current value
48                // This ensures we process edges in the correct order
49                if (vals[neighbor] > currentValue) {
50                    continue;
51                }
52              
53                // Find root representatives of both components
54                int rootCurrent = find(currentNode);
55                int rootNeighbor = find(neighbor);
56              
57                // If nodes are in different components, merge them
58                if (rootCurrent != rootNeighbor) {
59                    // Calculate new good paths formed by connecting these components
60                    // Good paths are formed between nodes with same value in different components
61                    int countInCurrentComponent = componentValueCounts.get(rootCurrent)
62                        .getOrDefault(currentValue, 0);
63                    int countInNeighborComponent = componentValueCounts.get(rootNeighbor)
64                        .getOrDefault(currentValue, 0);
65                    goodPathCount += countInCurrentComponent * countInNeighborComponent;
66                  
67                    // Merge components: make rootNeighbor the parent of rootCurrent
68                    parent[rootCurrent] = rootNeighbor;
69                  
70                    // Update count of nodes with currentValue in the merged component
71                    componentValueCounts.get(rootNeighbor).put(
72                        currentValue, 
73                        componentValueCounts.get(rootNeighbor).getOrDefault(currentValue, 0) + 
74                        componentValueCounts.get(rootCurrent).getOrDefault(currentValue, 0)
75                    );
76                }
77            }
78        }
79      
80        return goodPathCount;
81    }
82
83    /**
84     * Find operation with path compression for Union-Find
85     * Returns the root representative of the component containing node x
86     */
87    private int find(int x) {
88        if (parent[x] != x) {
89            parent[x] = find(parent[x]);  // Path compression
90        }
91        return parent[x];
92    }
93}
94
1class Solution {
2public:
3    int numberOfGoodPaths(vector<int>& vals, vector<vector<int>>& edges) {
4        int n = vals.size();
5      
6        // Initialize Union-Find parent array where each node is its own parent initially
7        vector<int> parent(n);
8        iota(parent.begin(), parent.end(), 0);
9      
10        // Find function with path compression for Union-Find
11        function<int(int)> find = [&](int x) {
12            if (parent[x] != x) {
13                parent[x] = find(parent[x]);  // Path compression
14            }
15            return parent[x];
16        };
17      
18        // Build adjacency list representation of the graph
19        vector<vector<int>> graph(n);
20        for (auto& edge : edges) {
21            int nodeA = edge[0];
22            int nodeB = edge[1];
23            graph[nodeA].push_back(nodeB);
24            graph[nodeB].push_back(nodeA);
25        }
26      
27        // Track the count of nodes with specific values in each connected component
28        // componentSize[root][value] = count of nodes with that value in the component
29        unordered_map<int, unordered_map<int, int>> componentSize;
30      
31        // Create pairs of (value, node_index) for processing nodes in ascending order of values
32        vector<pair<int, int>> nodeValuePairs(n);
33        for (int i = 0; i < n; ++i) {
34            nodeValuePairs[i] = {vals[i], i};
35            componentSize[i][vals[i]] = 1;  // Initially each node forms its own component
36        }
37      
38        // Sort nodes by their values in ascending order
39        sort(nodeValuePairs.begin(), nodeValuePairs.end());
40      
41        // Start with n good paths (each node forms a path with itself)
42        int goodPathCount = n;
43      
44        // Process nodes in ascending order of their values
45        for (auto [currentValue, currentNode] : nodeValuePairs) {
46            // Check all neighbors of the current node
47            for (int neighbor : graph[currentNode]) {
48                // Skip neighbors with larger values (they haven't been processed yet)
49                if (vals[neighbor] > currentValue) {
50                    continue;
51                }
52              
53                // Find the root of both components
54                int rootCurrent = find(currentNode);
55                int rootNeighbor = find(neighbor);
56              
57                // If they belong to different components, merge them
58                if (rootCurrent != rootNeighbor) {
59                    // Add the number of new good paths formed by connecting these components
60                    // Good paths are formed between nodes with the same maximum value
61                    goodPathCount += componentSize[rootCurrent][currentValue] * 
62                                    componentSize[rootNeighbor][currentValue];
63                  
64                    // Union: merge the two components
65                    parent[rootCurrent] = rootNeighbor;
66                  
67                    // Update the size of the merged component
68                    componentSize[rootNeighbor][currentValue] += componentSize[rootCurrent][currentValue];
69                }
70            }
71        }
72      
73        return goodPathCount;
74    }
75};
76
1function numberOfGoodPaths(vals: number[], edges: number[][]): number {
2    const n = vals.length;
3  
4    // Initialize Union-Find parent array where each node is its own parent initially
5    const parent: number[] = Array.from({ length: n }, (_, i) => i);
6  
7    // Find function with path compression for Union-Find
8    const find = (x: number): number => {
9        if (parent[x] !== x) {
10            parent[x] = find(parent[x]); // Path compression
11        }
12        return parent[x];
13    };
14  
15    // Build adjacency list representation of the graph
16    const graph: number[][] = Array.from({ length: n }, () => []);
17    for (const edge of edges) {
18        const nodeA = edge[0];
19        const nodeB = edge[1];
20        graph[nodeA].push(nodeB);
21        graph[nodeB].push(nodeA);
22    }
23  
24    // Track the count of nodes with specific values in each connected component
25    // componentSize[root][value] = count of nodes with that value in the component
26    const componentSize: Map<number, Map<number, number>> = new Map();
27  
28    // Create pairs of (value, nodeIndex) for processing nodes in ascending order of values
29    const nodeValuePairs: [number, number][] = [];
30    for (let i = 0; i < n; i++) {
31        nodeValuePairs.push([vals[i], i]);
32        // Initially each node forms its own component
33        componentSize.set(i, new Map([[vals[i], 1]]));
34    }
35  
36    // Sort nodes by their values in ascending order
37    nodeValuePairs.sort((a, b) => a[0] - b[0]);
38  
39    // Start with n good paths (each node forms a path with itself)
40    let goodPathCount = n;
41  
42    // Process nodes in ascending order of their values
43    for (const [currentValue, currentNode] of nodeValuePairs) {
44        // Check all neighbors of the current node
45        for (const neighbor of graph[currentNode]) {
46            // Skip neighbors with larger values (they haven't been processed yet)
47            if (vals[neighbor] > currentValue) {
48                continue;
49            }
50          
51            // Find the root of both components
52            const rootCurrent = find(currentNode);
53            const rootNeighbor = find(neighbor);
54          
55            // If they belong to different components, merge them
56            if (rootCurrent !== rootNeighbor) {
57                // Add the number of new good paths formed by connecting these components
58                // Good paths are formed between nodes with the same maximum value
59                const currentComponentCount = componentSize.get(rootCurrent)?.get(currentValue) || 0;
60                const neighborComponentCount = componentSize.get(rootNeighbor)?.get(currentValue) || 0;
61                goodPathCount += currentComponentCount * neighborComponentCount;
62              
63                // Union: merge the two components
64                parent[rootCurrent] = rootNeighbor;
65              
66                // Update the size of the merged component
67                const neighborMap = componentSize.get(rootNeighbor);
68                if (neighborMap) {
69                    neighborMap.set(currentValue, (neighborMap.get(currentValue) || 0) + currentComponentCount);
70                }
71            }
72        }
73    }
74  
75    return goodPathCount;
76}
77

Time and Space Complexity

Time Complexity: O(n × log n)

The time complexity is dominated by the following operations:

  • Sorting the values along with their indices: O(n × log n) where n is the number of nodes
  • Building the adjacency list from edges: O(E) where E is the number of edges, and since it's a tree, E = n - 1, so this is O(n)
  • The main loop iterates through all n nodes in sorted order: O(n)
    • For each node, we iterate through its neighbors (at most O(n) edges total across all iterations)
    • The find operation uses path compression, which has an amortized time complexity of O(α(n)) where α is the inverse Ackermann function, practically constant
    • Union operations and size updates are O(1)

Since the sorting step dominates with O(n × log n) and all other operations are at most O(n × α(n)) which is practically O(n), the overall time complexity is O(n × log n).

Space Complexity: O(n)

The space complexity consists of:

  • Adjacency list g: O(n + E) = O(n) since E = n - 1 for a tree
  • Parent array p: O(n)
  • Size dictionary with Counter objects: In the worst case, each component could have nodes with different values, but since we're tracking counts per value per component root, this is bounded by O(n)
  • Sorting uses O(n) space for the sorted list of tuples

Therefore, the total space complexity is O(n).

Learn more about how to find time and space complexity quickly.

Common Pitfalls

Pitfall 1: Incorrect Path Counting When Merging Components

The Problem: When merging two components, a common mistake is to incorrectly calculate the number of new good paths formed. Some might think to add size[pa][v] + size[pb][v] or use (size[pa][v] + size[pb][v]) * (size[pa][v] + size[pb][v] - 1) // 2.

Why It's Wrong:

  • Adding the sizes only counts the nodes, not the paths between them
  • The combination formula counts paths within the merged component, but we've already counted paths within each individual component in previous iterations

The Correct Approach: Use multiplication: size[pa][v] * size[pb][v]. This counts exactly the new paths created between nodes with value v from component A and nodes with value v from component B.

Example: If component A has 2 nodes with value 3 and component B has 3 nodes with value 3:

  • New paths created = 2 × 3 = 6 paths
  • Each node in A can form a path with each node in B

Pitfall 2: Processing Nodes in Wrong Order

The Problem: Processing nodes randomly or in decreasing order of values breaks the algorithm's logic.

Why It's Wrong: The algorithm relies on the invariant that when processing a node with value v, all nodes with values less than v have already been processed and potentially merged. This ensures that when we check vals[neighbor] <= v, we know the neighbor has been processed.

The Fix: Always sort nodes by their values in ascending order:

for current_val, current_node in sorted(zip(vals, range(n))):

Pitfall 3: Forgetting Path Compression

The Problem: Implementing find without path compression:

def find(x):
    while parent[x] != x:
        x = parent[x]
    return x

Why It's Wrong: Without path compression, repeated find operations can degrade to O(n) time in the worst case, making the overall algorithm inefficient.

The Fix: Always use path compression:

def find(x):
    if parent[x] != x:
        parent[x] = find(parent[x])  # Compress the path
    return parent[x]

Pitfall 4: Not Updating Component Size After Merge

The Problem: Forgetting to update the size dictionary after merging:

if root_current != root_neighbor:
    good_paths_count += size[root_current][v] * size[root_neighbor][v]
    parent[root_current] = root_neighbor
    # Missing: size[root_neighbor][v] += size[root_current][v]

Why It's Wrong: Future merges involving this component will have incorrect counts, leading to wrong results.

The Fix: Always update the size after merging:

size[root_neighbor][current_val] += size[root_current][current_val]

Pitfall 5: Double Counting or Missing Single-Node Paths

The Problem:

  • Starting with ans = 0 instead of ans = n
  • Or trying to count single-node paths during the merge process

Why It's Wrong: Each single node is a valid good path by definition, and these won't be counted during the merge process since merges only count paths between different components.

The Fix: Initialize the answer with n to account for all single-node paths:

good_paths_count = n  # Start with single-node paths
Discover Your Strengths and Weaknesses: Take Our 5-Minute Quiz to Tailor Your Study Plan:

What's the output of running the following function using the following tree as input?

1def serialize(root):
2    res = []
3    def dfs(root):
4        if not root:
5            res.append('x')
6            return
7        res.append(root.val)
8        dfs(root.left)
9        dfs(root.right)
10    dfs(root)
11    return ' '.join(res)
12
1import java.util.StringJoiner;
2
3public static String serialize(Node root) {
4    StringJoiner res = new StringJoiner(" ");
5    serializeDFS(root, res);
6    return res.toString();
7}
8
9private static void serializeDFS(Node root, StringJoiner result) {
10    if (root == null) {
11        result.add("x");
12        return;
13    }
14    result.add(Integer.toString(root.val));
15    serializeDFS(root.left, result);
16    serializeDFS(root.right, result);
17}
18
1function serialize(root) {
2    let res = [];
3    serialize_dfs(root, res);
4    return res.join(" ");
5}
6
7function serialize_dfs(root, res) {
8    if (!root) {
9        res.push("x");
10        return;
11    }
12    res.push(root.val);
13    serialize_dfs(root.left, res);
14    serialize_dfs(root.right, res);
15}
16

Recommended Readings

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

Load More