LeetCode 2646 - Minimize the Total Price of the Trips
The problem gives us an undirected tree with n nodes. Every node has a price associated with it, and we are also given several trips between pairs of nodes. Since the graph is a tree, there is exactly one simple path between any two nodes.
Difficulty: 🔴 Hard
Topics: Array, Dynamic Programming, Tree, Depth-First Search, Graph Theory
Solution
Problem Understanding
The problem gives us an undirected tree with n nodes. Every node has a price associated with it, and we are also given several trips between pairs of nodes. Since the graph is a tree, there is exactly one simple path between any two nodes.
For every trip, we travel along the unique path between the starting node and the ending node. The total cost of that trip is the sum of the prices of all nodes on that path.
Before any trips begin, we are allowed to choose some nodes and halve their prices. However, there is one important restriction: no two chosen nodes can be adjacent in the tree. This transforms the problem into a tree optimization problem involving independent node selection.
Our goal is to minimize the total cost across all trips.
The input consists of:
n, the number of nodesedges, describing the tree structureprice, whereprice[i]is the price of nodeitrips, where each trip specifies a start and end node
The output is the minimum total price after optimally selecting which non-adjacent nodes should have their prices halved.
The constraints are relatively small:
n <= 50trips.length <= 100
These constraints are important because they allow us to perform DFS traversals repeatedly without performance concerns. A solution with complexity around O(n^2) or O(n^3) is completely acceptable.
There are several important observations and edge cases:
- Because the graph is a tree, there is exactly one path between any two nodes.
- A node may appear in many trips, so halving a frequently used node can produce large savings.
- The adjacency restriction means we cannot greedily halve every expensive node.
- Trips may start and end at the same node, producing a path of length one.
- Since all prices are even integers, halving always produces an integer value.
The key challenge is balancing global savings while respecting the non-adjacent constraint.
Approaches
Brute Force Approach
The brute force solution tries every possible subset of nodes that satisfies the non-adjacent condition.
For each valid subset:
- Halve the selected node prices.
- Compute the cost of every trip.
- Sum all trip costs.
- Track the minimum total.
To check whether a subset is valid, we ensure no edge connects two selected nodes.
To compute trip costs, we can run DFS to find the unique path for each trip.
This approach is correct because it exhaustively explores all legal node selections. Eventually, it will evaluate the optimal configuration.
However, it is far too slow. There are 2^n subsets of nodes. Even though n = 50 is small for polynomial algorithms, it is enormous for exponential enumeration.
Optimal Approach
The important insight is that the trips only matter through how many times each node is used.
Suppose node i appears in all trip paths freq[i] times. Then:
- Without halving, node
icontributes:
freq[i] * price[i]
- With halving, node
icontributes:
freq[i] * (price[i] / 2)
So the problem becomes:
Choose a set of non-adjacent nodes to maximize total savings.
This is a classic tree dynamic programming problem, specifically the "maximum weighted independent set on a tree" pattern.
We first compute how many times each node appears across all trip paths. Then we perform tree DP:
dp[node][0]= minimum cost if this node is NOT halveddp[node][1]= minimum cost if this node IS halved
If a node is halved, none of its children may be halved.
This converts the problem into a clean DFS dynamic programming solution.
| Approach | Time Complexity | Space Complexity | Notes |
|---|---|---|---|
| Brute Force | O(2^n × n × trips) | O(n) | Enumerates all valid subsets |
| Optimal | O(n × trips + n) | O(n) | Counts node frequencies, then performs tree DP |
Algorithm Walkthrough
Step 1: Build the adjacency list
Since the input graph is a tree, we represent it using an adjacency list.
This allows efficient DFS traversal between connected nodes.
Step 2: Count how many times each node is used
We create an array:
frequency[i]
This stores how many trips pass through node i.
For every trip:
- Run DFS from
starttoend - Recover the unique path
- Increment the frequency of every node on that path
Because the graph is a tree, DFS always finds exactly one valid simple path.
Step 3: Convert node usage into total contribution
Once frequencies are known:
- Normal cost of node:
frequency[node] * price[node]
- Halved cost:
frequency[node] * price[node] // 2
Now the trips themselves no longer matter. We only care about minimizing total node contribution.
Step 4: Root the tree
We root the tree arbitrarily at node 0.
This allows us to perform parent-child DP transitions cleanly.
Step 5: Perform tree DP
For every node, compute two states:
not_halvedhalved
If current node is not halved:
- Children may either be halved or not halved
- Take minimum of both states for each child
If current node is halved:
- Children cannot be halved
- Only use child
not_halvedstate
Step 6: Return the minimum
At the root:
min(not_halved, halved)
This is the globally optimal answer.
Why it works
The algorithm works because the total trip cost can be decomposed into independent node contributions.
After counting node frequencies, each node contributes a fixed amount depending only on whether it is halved. The only interaction between nodes is the adjacency restriction.
This transforms the problem into selecting a minimum-cost configuration on a tree with parent-child constraints, which is exactly what tree DP solves optimally.
Python Solution
from typing import List
class Solution:
def minimumTotalPrice(
self,
n: int,
edges: List[List[int]],
price: List[int],
trips: List[List[int]]
) -> int:
graph = [[] for _ in range(n)]
for u, v in edges:
graph[u].append(v)
graph[v].append(u)
frequency = [0] * n
def find_path(node: int, parent: int, target: int, path: List[int]) -> bool:
path.append(node)
if node == target:
return True
for neighbor in graph[node]:
if neighbor == parent:
continue
if find_path(neighbor, node, target, path):
return True
path.pop()
return False
for start, end in trips:
path = []
find_path(start, -1, end, path)
for node in path:
frequency[node] += 1
def dfs(node: int, parent: int):
not_halved = frequency[node] * price[node]
halved = frequency[node] * (price[node] // 2)
for neighbor in graph[node]:
if neighbor == parent:
continue
child_not_halved, child_halved = dfs(neighbor, node)
not_halved += min(child_not_halved, child_halved)
halved += child_not_halved
return not_halved, halved
return min(dfs(0, -1))
The implementation begins by constructing the adjacency list representation of the tree.
Next, the algorithm computes node frequencies. For each trip, DFS finds the unique path between the start and end nodes. Every node on that path has its frequency incremented.
Once frequencies are known, the algorithm performs tree DP using another DFS traversal.
For every node:
not_halvedstores the minimum subtree cost when this node keeps its original price.halvedstores the minimum subtree cost when this node's price is halved.
When a node is halved, its children cannot be halved because adjacent discounted nodes are forbidden.
Finally, the root returns the minimum of the two possible states.
Go Solution
package main
func minimumTotalPrice(n int, edges [][]int, price []int, trips [][]int) int {
graph := make([][]int, n)
for _, edge := range edges {
u := edge[0]
v := edge[1]
graph[u] = append(graph[u], v)
graph[v] = append(graph[v], u)
}
frequency := make([]int, n)
var findPath func(int, int, int, *[]int) bool
findPath = func(node int, parent int, target int, path *[]int) bool {
*path = append(*path, node)
if node == target {
return true
}
for _, neighbor := range graph[node] {
if neighbor == parent {
continue
}
if findPath(neighbor, node, target, path) {
return true
}
}
*path = (*path)[:len(*path)-1]
return false
}
for _, trip := range trips {
start := trip[0]
end := trip[1]
path := []int{}
findPath(start, -1, end, &path)
for _, node := range path {
frequency[node]++
}
}
var dfs func(int, int) (int, int)
dfs = func(node int, parent int) (int, int) {
notHalved := frequency[node] * price[node]
halved := frequency[node] * (price[node] / 2)
for _, neighbor := range graph[node] {
if neighbor == parent {
continue
}
childNotHalved, childHalved := dfs(neighbor, node)
if childNotHalved < childHalved {
notHalved += childNotHalved
} else {
notHalved += childHalved
}
halved += childNotHalved
}
return notHalved, halved
}
a, b := dfs(0, -1)
if a < b {
return a
}
return b
}
The Go implementation closely mirrors the Python version.
The main difference is explicit slice management during DFS path construction. Since slices are passed by value, the path slice is passed by pointer so recursive modifications are preserved correctly.
Go also lacks tuple syntax, so multiple return values are used for DP states.
Integer overflow is not a concern because the maximum possible answer is small relative to Go's integer range.
Worked Examples
Example 1
n = 4
edges = [[0,1],[1,2],[1,3]]
price = [2,2,10,6]
trips = [[0,3],[2,1],[2,3]]
Step 1: Build frequency table
Tree:
0 - 1 - 2
|
3
Process trips one by one.
| Trip | Path | Frequency Updates |
|---|---|---|
| [0,3] | 0 → 1 → 3 | freq = [1,1,0,1] |
| [2,1] | 2 → 1 | freq = [1,2,1,1] |
| [2,3] | 2 → 1 → 3 | freq = [1,3,2,2] |
Final frequencies:
freq = [1,3,2,2]
Step 2: Compute contributions
| Node | Price | Frequency | Normal Cost | Halved Cost |
|---|---|---|---|---|
| 0 | 2 | 1 | 2 | 1 |
| 1 | 2 | 3 | 6 | 3 |
| 2 | 10 | 2 | 20 | 10 |
| 3 | 6 | 2 | 12 | 6 |
Step 3: Tree DP
Start from leaves.
Node 2
| State | Cost |
|---|---|
| Not halved | 20 |
| Halved | 10 |
Node 3
| State | Cost |
|---|---|
| Not halved | 12 |
| Halved | 6 |
Node 1
If not halved:
6 + min(20,10) + min(12,6)
= 6 + 10 + 6
= 22
If halved:
3 + 20 + 12
= 35
Node 0
If not halved:
2 + min(22,35)
= 24
If halved:
1 + 22
= 23
Answer:
23
Example 2
n = 2
edges = [[0,1]]
price = [2,2]
trips = [[0,0]]
Step 1: Frequency counting
Path:
0
Frequency:
freq = [1,0]
Step 2: DP values
| Node | Normal | Halved |
|---|---|---|
| 0 | 2 | 1 |
| 1 | 0 | 0 |
Root result:
min(2,1) = 1
Answer:
1
Complexity Analysis
| Measure | Complexity | Explanation |
|---|---|---|
| Time | O(trips × n + n) | DFS path search for each trip plus tree DP |
| Space | O(n) | Adjacency list, recursion stack, frequency array |
The path-finding DFS may visit all nodes for each trip, giving O(trips × n) complexity. Since n <= 50, this is very efficient.
The DP traversal processes each edge exactly once, contributing linear complexity.
Test Cases
from typing import List
class Solution:
def minimumTotalPrice(self, n: int, edges: List[List[int]], price: List[int], trips: List[List[int]]) -> int:
graph = [[] for _ in range(n)]
for u, v in edges:
graph[u].append(v)
graph[v].append(u)
frequency = [0] * n
def find_path(node, parent, target, path):
path.append(node)
if node == target:
return True
for neighbor in graph[node]:
if neighbor == parent:
continue
if find_path(neighbor, node, target, path):
return True
path.pop()
return False
for start, end in trips:
path = []
find_path(start, -1, end, path)
for node in path:
frequency[node] += 1
def dfs(node, parent):
not_halved = frequency[node] * price[node]
halved = frequency[node] * (price[node] // 2)
for neighbor in graph[node]:
if neighbor == parent:
continue
child_not, child_half = dfs(neighbor, node)
not_halved += min(child_not, child_half)
halved += child_not
return not_halved, halved
return min(dfs(0, -1))
solution = Solution()
# Example 1 from problem statement
assert solution.minimumTotalPrice(
4,
[[0,1],[1,2],[1,3]],
[2,2,10,6],
[[0,3],[2,1],[2,3]]
) == 23
# Example 2 from problem statement
assert solution.minimumTotalPrice(
2,
[[0,1]],
[2,2],
[[0,0]]
) == 1
# Single node tree
assert solution.minimumTotalPrice(
1,
[],
[8],
[[0,0]]
) == 4
# Chain tree
assert solution.minimumTotalPrice(
3,
[[0,1],[1,2]],
[2,4,6],
[[0,2]]
) == 8
# Star topology
assert solution.minimumTotalPrice(
5,
[[0,1],[0,2],[0,3],[0,4]],
[10,2,2,2,2],
[[1,2],[3,4]]
) == 18
# Multiple repeated trips
assert solution.minimumTotalPrice(
3,
[[0,1],[1,2]],
[2,10,2],
[[0,2],[0,2],[0,2]]
) == 21
# No benefit from halving center node
assert solution.minimumTotalPrice(
3,
[[0,1],[1,2]],
[100,2,100],
[[0,2]]
) == 102
# Balanced tree
assert solution.minimumTotalPrice(
7,
[[0,1],[0,2],[1,3],[1,4],[2,5],[2,6]],
[4,6,8,2,2,2,2],
[[3,4],[5,6],[3,6]]
) > 0
| Test | Why |
|---|---|
| Example 1 | Validates general tree DP behavior |
| Example 2 | Validates single-node path handling |
| Single node tree | Tests smallest possible tree |
| Chain tree | Tests linear topology |
| Star topology | Tests adjacency restriction around center |
| Multiple repeated trips | Ensures frequency accumulation works |
| Expensive endpoints | Ensures optimal node selection |
| Balanced tree | Tests larger recursive structure |
Edge Cases
One important edge case is a trip where the start and end nodes are the same. In this situation, the path contains exactly one node. A buggy implementation might incorrectly assume paths always contain edges. The DFS path construction in this solution correctly handles this because it immediately returns when the current node equals the target.
Another important case is a chain-shaped tree. In a linear structure, every node except the ends has two neighbors, making adjacency constraints more restrictive. Greedy strategies often fail here because halving one node prevents halving adjacent high-value nodes. The tree DP handles this naturally through its parent-child state transitions.
A third critical edge case is when one node appears in many trips. Such nodes contribute disproportionately to the total cost, making them attractive candidates for halving. However, if neighboring nodes also have large contributions, choosing the locally largest savings may not produce the global optimum. The DP explores both possibilities for every subtree, ensuring the globally minimal configuration is found.