LeetCode 2421 - Number of Good Paths
The problem is asking us to count the number of good paths in a tree. A tree is a connected acyclic graph with n nodes and n - 1 edges. Each node has an integer value assigned by the array vals.
Difficulty: 🔴 Hard
Topics: Array, Hash Table, Tree, Union-Find, Graph Theory, Sorting
Solution
Problem Understanding
The problem is asking us to count the number of good paths in a tree. A tree is a connected acyclic graph with n nodes and n - 1 edges. Each node has an integer value assigned by the array vals. A good path is defined as a simple path (no repeated nodes) where the first and last node have the same value, and all intermediate nodes have values less than or equal to this maximum value. A single node counts as a valid path because it trivially satisfies the conditions.
The input consists of an array vals of length n, representing node values, and an array edges of length n - 1, representing undirected connections between nodes. The output is a single integer representing the total number of distinct good paths.
The constraints indicate that the number of nodes can be as large as 3 * 10^4, and node values can be as large as 10^5. Because the input is guaranteed to form a tree, we know there are no cycles, every node is connected, and we do not need to check for disconnected components.
Important edge cases include trees with a single node, multiple nodes with the same value, and paths where intermediate nodes exceed the starting node's value. These scenarios can trip up a naive implementation if intermediate values are not carefully considered.
Approaches
Brute Force
A brute-force approach would involve generating all possible simple paths in the tree. For each path, we would check if the start and end nodes have the same value and if all intermediate nodes are less than or equal to this value. While this approach would be correct, it is computationally infeasible. Generating all simple paths in a tree with n nodes can result in exponential complexity, far exceeding the given constraints of n <= 3 * 10^4.
Optimal Approach
The key observation for an optimal solution is that the tree structure allows us to use a Union-Find (Disjoint Set Union) data structure combined with sorting by node values. By processing nodes in increasing order of value, we ensure that when we union nodes, all paths we form satisfy the "all intermediate nodes are less than or equal" constraint. Nodes with the same value can then form good paths with each other. Counting combinations within connected components of nodes with equal values gives us the number of additional good paths beyond the single-node paths.
This approach works because in a tree, any path between two nodes is unique. Processing nodes by value ensures that we only consider valid paths and do not violate the maximum value constraint.
| Approach | Time Complexity | Space Complexity | Notes |
|---|---|---|---|
| Brute Force | O(2^n) | O(n) | Generates all simple paths and validates each one; infeasible for large n |
| Optimal | O(n log n) | O(n) | Sort nodes by value, use Union-Find to group connected components, count good paths combinatorially |
Algorithm Walkthrough
- Initialize Data Structures: Create a Union-Find structure to manage connected components efficiently. Use a dictionary to map node values to lists of nodes with that value.
- Sort Nodes by Value: Extract unique values from
valsand process them in ascending order. This ensures that when we connect nodes, we respect the "intermediate nodes ≤ start node" constraint. - Union Connected Nodes: For each node of the current value, iterate through its neighbors. If a neighbor’s value is less than or equal to the current value, union the two nodes. This step builds connected components that are valid good paths.
- Count Good Paths in Components: After unioning, for each connected component, count how many nodes have the current value. If there are
ksuch nodes, they formk * (k - 1) / 2additional good paths (pairwise combinations), plus theksingle-node paths. - Sum Results: Keep a running total of the number of good paths. Return this value at the end.
Why it works: By processing nodes in order of increasing value and unioning only nodes that respect the value constraint, every good path counted in a component is valid. The tree’s uniqueness of paths guarantees that no path is double-counted, and counting combinations of equal-valued nodes within components accounts for all valid multi-node paths.
Python Solution
from typing import List
from collections import defaultdict
class UnionFind:
def __init__(self, n: int):
self.parent = list(range(n))
self.size = [1] * n
def find(self, x: int) -> int:
if self.parent[x] != x:
self.parent[x] = self.find(self.parent[x])
return self.parent[x]
def union(self, x: int, y: int):
xr, yr = self.find(x), self.find(y)
if xr == yr:
return
if self.size[xr] < self.size[yr]:
xr, yr = yr, xr
self.parent[yr] = xr
self.size[xr] += self.size[yr]
class Solution:
def numberOfGoodPaths(self, vals: List[int], edges: List[List[int]]) -> int:
n = len(vals)
uf = UnionFind(n)
graph = [[] for _ in range(n)]
for u, v in edges:
graph[u].append(v)
graph[v].append(u)
val_to_nodes = defaultdict(list)
for i, val in enumerate(vals):
val_to_nodes[val].append(i)
result = 0
for val in sorted(val_to_nodes):
nodes = val_to_nodes[val]
for node in nodes:
for neighbor in graph[node]:
if vals[neighbor] <= val:
uf.union(node, neighbor)
count = defaultdict(int)
for node in nodes:
root = uf.find(node)
count[root] += 1
for c in count.values():
result += c * (c + 1) // 2
return result
Implementation Explanation: We first build the graph adjacency list. Then we map values to the corresponding nodes. Processing values in ascending order allows union operations only among nodes that satisfy the good path constraints. Finally, we count pairwise combinations of nodes in each connected component to determine the total number of good paths.
Go Solution
package main
func numberOfGoodPaths(vals []int, edges [][]int) int {
n := len(vals)
parent := make([]int, n)
size := make([]int, n)
for i := 0; i < n; i++ {
parent[i] = i
size[i] = 1
}
var find func(int) int
find = func(x int) int {
if parent[x] != x {
parent[x] = find(parent[x])
}
return parent[x]
}
union := func(x, y int) {
xr, yr := find(x), find(y)
if xr == yr {
return
}
if size[xr] < size[yr] {
xr, yr = yr, xr
}
parent[yr] = xr
size[xr] += size[yr]
}
graph := make([][]int, n)
for _, e := range edges {
u, v := e[0], e[1]
graph[u] = append(graph[u], v)
graph[v] = append(graph[v], u)
}
valToNodes := make(map[int][]int)
for i, val := range vals {
valToNodes[val] = append(valToNodes[val], i)
}
keys := make([]int, 0, len(valToNodes))
for k := range valToNodes {
keys = append(keys, k)
}
sort.Ints(keys)
result := 0
for _, val := range keys {
nodes := valToNodes[val]
for _, node := range nodes {
for _, neighbor := range graph[node] {
if vals[neighbor] <= val {
union(node, neighbor)
}
}
}
count := make(map[int]int)
for _, node := range nodes {
root := find(node)
count[root]++
}
for _, c := range count {
result += c * (c + 1) / 2
}
}
return result
}
Go Implementation Notes: Go uses slices for adjacency lists and maps for counting components. The Union-Find is implemented with slices for parent and size. Sorting and map iteration mirrors Python behavior. Integer overflow is not a concern because constraints are within int range.
Worked Examples
Example 1
vals = [1,3,2,1,3]
edges = [[0,1],[0,2],[2,3],[2,4]]
Processing values in order: 1, 2, 3
- Value 1: nodes
[0, 3]. Union connected nodes <=1. Components: {0}, {3}. Count: 2 single-node paths + 0 pairs = 2. - Value 2: node
[2]. Union connected nodes <=2. Component: {2}. Count: 1 path. - Value 3: nodes
[1,4]. Union neighbors <=3. 1-0-2-4 connected, root counts: {1:2, 4:1}. Compute pairwise: 3