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:
- The starting and ending nodes must have the same value
- 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 as1 -> 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 nodesai
andbi
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.
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:
- The endpoints have the same value
v
- 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 neighborsb
that have been processed (values ≤vals[a]
) - If
a
andb
are in different connected components, merge them - Count the good paths formed: if component 1 has
x
nodes with valuevals[a]
and component 2 hasy
nodes with valuevals[a]
, then merging createsx * 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, initiallyp[i] = i
(each node is its own parent)size
: a dictionary wheresize[i][v]
tracks how many nodes with valuev
exist in the connected component with rooti
- Initially,
size[i][vals[i]] = 1
for each nodei
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
:
- Check all neighbors
b
ofa
- Skip if
vals[b] > v
(neighbor hasn't been processed yet) - Find the root components
pa
andpb
using path compression - 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 componentpa
and the other endpoint is a node with valuev
in componentpb
- Merge components: set
p[pa] = pb
- Update size:
size[pb][v] += size[pa][v]
- Calculate contribution:
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 EvaluatorExample 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 itfind(0)=0
,find(1)=1
(different components)- Contribution:
size[0][3] × size[1][3] = 0 × 1 = 0
- Merge:
p[0]=1
, updatesize[1][1] = 1+1 = 2
- Check neighbor 2:
vals[2]=2 ≤ 3
, process itfind(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 itfind(3)=3
,find(1)=1
(different components)- Contribution:
size[3][3] × size[1][3] = 0 × 1 = 0
- Merge:
p[3]=1
, updatesize[1][1] = 2+1 = 3
ans = 5 + 0 = 5
Processing node 4 (value=3):
- Check neighbor 3:
vals[3]=1 ≤ 3
, process itfind(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
, updatesize[4][3] = 1+1 = 2
ans = 5 + 1 = 6
Final Answer: 6 good paths
- Single node 0:
[0]
- Single node 1:
[1]
- Single node 2:
[2]
- Single node 3:
[3]
- Single node 4:
[4]
- 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)
wheren
is the number of nodes - Building the adjacency list from edges:
O(E)
whereE
is the number of edges, and since it's a tree,E = n - 1
, so this isO(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 ofO(α(n))
whereα
is the inverse Ackermann function, practically constant - Union operations and size updates are
O(1)
- For each node, we iterate through its neighbors (at most
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)
sinceE = 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 ofans = 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
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
Everything About Trees A tree is a type of graph data structure composed of nodes and edges Its main properties are It is acyclic doesn't contain any cycles There exists a path from the root to any node Has N 1 edges where N is the number of nodes in the tree and
Union Find Disjoint Set Union Data Structure Introduction Prerequisite Depth First Search Review problems dfs_intro Once we have a strong grasp of recursion and Depth First Search we can now introduce Disjoint Set Union DSU This data structure is motivated by the following problem Suppose we have sets of elements
https assets algo monster cover_photos graph svg Graph Fundamentals Tree with 0 cycle At this point you should be pretty familiar with trees A tree is a special kind of graph a connected acyclic cycle less graph A graph may contain cycle s and nodes could be disconnected A tree
Want a Structured Path to Master System Design Too? Don’t Miss This!