LeetCode 2203 - Minimum Weighted Subgraph With the Required Paths
This problem gives us a weighted directed graph with n nodes and a list of directed edges. Each edge has a positive weight. We are also given two source nodes, src1 and src2, along with a destination node dest.
Difficulty: 🔴 Hard
Topics: Graph Theory, Heap (Priority Queue), Shortest Path
Solution
LeetCode 2203 - Minimum Weighted Subgraph With the Required Paths
Problem Understanding
This problem gives us a weighted directed graph with n nodes and a list of directed edges. Each edge has a positive weight. We are also given two source nodes, src1 and src2, along with a destination node dest.
The goal is to find a subgraph whose total edge weight is as small as possible while still allowing both src1 and src2 to reach dest.
A key detail is that the two paths are allowed to share edges. Since the weight of a subgraph counts each edge only once, shared portions are beneficial because they reduce the total cost. This means we are not minimizing the sum of two independent shortest paths. Instead, we are minimizing the total weight of the union of the edges used by both paths.
Another important observation is that the graph is directed. A path that exists from u to v does not imply that a path exists from v to u.
The constraints are large:
- Up to
10^5nodes - Up to
10^5edges
These limits immediately rule out expensive graph algorithms such as Floyd-Warshall or repeated DFS/BFS from every node. We need something close to O((V + E) log V).
The graph weights are all positive, which strongly suggests Dijkstra's algorithm for shortest paths.
Several edge cases are important:
- One or both sources may not be able to reach the destination.
- The graph may contain disconnected components.
- Multiple edges may exist between the same pair of nodes.
- The optimal solution may involve both paths merging at some intermediate node before reaching
dest. - The optimal solution may also consist of two completely separate paths.
The problem guarantees that src1, src2, and dest are distinct nodes, which simplifies handling trivial self-paths.
Approaches
Brute Force Approach
A brute-force solution would attempt to enumerate all possible meeting points and all possible path combinations from src1 and src2 to dest.
One way to think about this is:
- Choose every possible node
xas a merge point. - Find all paths from
src1tox. - Find all paths from
src2tox. - Find all paths from
xtodest. - Compute the union cost of the chosen edges.
This approach is correct because every valid subgraph corresponds to some combination of paths that eventually reach dest.
However, the number of possible paths in a graph can be exponential. Even attempting to enumerate them is infeasible for graphs with 10^5 nodes and edges.
A slightly less naive brute-force idea would compute shortest paths between every pair of nodes using Floyd-Warshall, then test every merge node. While this avoids path enumeration, Floyd-Warshall requires O(n^3) time, which is impossible for n = 10^5.
Key Insight
The critical observation is that if both sources eventually merge at some node x, then the optimal structure looks like:
- Shortest path from
src1tox - Shortest path from
src2tox - Shortest path from
xtodest
Because all edge weights are positive, replacing any section with a shorter path can only improve the solution.
Therefore, for every node x, we want:
dist(src1 -> x) + dist(src2 -> x) + dist(x -> dest)
The minimum value across all nodes is the answer.
The challenge is efficiently computing distances from every node to dest.
For directed graphs, shortest paths to a destination can be computed by reversing all edges and running Dijkstra from dest. This transforms:
x -> dest
into:
dest -> x
in the reversed graph.
So we perform exactly three Dijkstra runs:
- From
src1on the original graph - From
src2on the original graph - From
deston the reversed graph
Then we test every node as a potential merge point.
Approach Comparison
| Approach | Time Complexity | Space Complexity | Notes |
|---|---|---|---|
| Brute Force | Exponential or O(n^3) |
O(n^2) |
Enumerating paths or all-pairs shortest paths is infeasible |
| Optimal | O((V + E) log V) |
O(V + E) |
Uses three Dijkstra runs and checks all merge points |
Algorithm Walkthrough
- Build the original adjacency list.
Since the graph is sparse and large, an adjacency list is much more efficient than an adjacency matrix. Each node stores its outgoing edges. 2. Build the reversed adjacency list.
For every edge:
u -> v
add:
v -> u
to the reversed graph.
This allows us to compute shortest distances to dest by running Dijkstra from dest on the reversed graph.
3. Run Dijkstra from src1.
This computes:
dist1[x] = shortest distance from src1 to x
- Run Dijkstra from
src2.
This computes:
dist2[x] = shortest distance from src2 to x
- Run Dijkstra from
deston the reversed graph.
This computes:
distDest[x] = shortest distance from x to dest
because traversing reversed edges from dest corresponds to traversing original edges toward dest.
6. Try every node as a meeting point.
For every node x:
- If any of the three distances is unreachable, skip it.
- Otherwise compute:
total = dist1[x] + dist2[x] + distDest[x]
Keep the minimum value. 7. Return the answer.
If no valid node exists, return -1.
Why it works
Suppose the optimal subgraph allows both sources to reach dest. At some point, the two routes may merge. Let the first shared node be x.
The subgraph can then be decomposed into:
- A path from
src1tox - A path from
src2tox - A shared path from
xtodest
Replacing any of these sections with their shortest possible versions can only reduce or preserve the total weight. Therefore, some optimal solution must consist entirely of shortest paths.
By evaluating every possible merge node x, we guarantee that the globally optimal configuration is considered.
Python Solution
from typing import List
import heapq
class Solution:
def minimumWeight(
self,
n: int,
edges: List[List[int]],
src1: int,
src2: int,
dest: int
) -> int:
graph = [[] for _ in range(n)]
reverse_graph = [[] for _ in range(n)]
for u, v, w in edges:
graph[u].append((v, w))
reverse_graph[v].append((u, w))
def dijkstra(start: int, adjacency: List[List[tuple]]) -> List[int]:
INF = float('inf')
distances = [INF] * n
distances[start] = 0
min_heap = [(0, start)]
while min_heap:
current_dist, node = heapq.heappop(min_heap)
if current_dist > distances[node]:
continue
for neighbor, weight in adjacency[node]:
new_dist = current_dist + weight
if new_dist < distances[neighbor]:
distances[neighbor] = new_dist
heapq.heappush(
min_heap,
(new_dist, neighbor)
)
return distances
dist1 = dijkstra(src1, graph)
dist2 = dijkstra(src2, graph)
dist_dest = dijkstra(dest, reverse_graph)
INF = float('inf')
answer = INF
for node in range(n):
if (
dist1[node] != INF and
dist2[node] != INF and
dist_dest[node] != INF
):
total_weight = (
dist1[node] +
dist2[node] +
dist_dest[node]
)
answer = min(answer, total_weight)
return -1 if answer == INF else answer
The implementation begins by constructing two adjacency lists. The first stores the original directed graph, while the second stores the reversed graph.
The dijkstra helper function computes shortest distances from a starting node. It uses a min-heap priority queue so that the next node processed is always the one with the smallest currently known distance.
The stale-entry check:
if current_dist > distances[node]:
continue
is important because multiple entries for the same node may exist in the heap. Only the best one should be processed.
After running Dijkstra three times, the algorithm iterates through every node and treats it as a potential merge point. If all three required distances exist, the combined cost is computed and used to update the answer.
Finally, if no valid merge point exists, the function returns -1.
Go Solution
package main
import (
"container/heap"
"math"
)
type Edge struct {
to int
weight int
}
type State struct {
node int
dist int64
}
type PriorityQueue []State
func (pq PriorityQueue) Len() int {
return len(pq)
}
func (pq PriorityQueue) Less(i, j int) bool {
return pq[i].dist < pq[j].dist
}
func (pq PriorityQueue) Swap(i, j int) {
pq[i], pq[j] = pq[j], pq[i]
}
func (pq *PriorityQueue) Push(x interface{}) {
*pq = append(*pq, x.(State))
}
func (pq *PriorityQueue) Pop() interface{} {
old := *pq
n := len(old)
item := old[n-1]
*pq = old[:n-1]
return item
}
func dijkstra(start int, graph [][]Edge) []int64 {
n := len(graph)
dist := make([]int64, n)
for i := range dist {
dist[i] = math.MaxInt64
}
dist[start] = 0
pq := &PriorityQueue{}
heap.Init(pq)
heap.Push(pq, State{
node: start,
dist: 0,
})
for pq.Len() > 0 {
current := heap.Pop(pq).(State)
if current.dist > dist[current.node] {
continue
}
for _, edge := range graph[current.node] {
newDist := current.dist + int64(edge.weight)
if newDist < dist[edge.to] {
dist[edge.to] = newDist
heap.Push(pq, State{
node: edge.to,
dist: newDist,
})
}
}
}
return dist
}
func minimumWeight(
n int,
edges [][]int,
src1 int,
src2 int,
dest int,
) int64 {
graph := make([][]Edge, n)
reverseGraph := make([][]Edge, n)
for _, edge := range edges {
u := edge[0]
v := edge[1]
w := edge[2]
graph[u] = append(graph[u], Edge{
to: v,
weight: w,
})
reverseGraph[v] = append(reverseGraph[v], Edge{
to: u,
weight: w,
})
}
dist1 := dijkstra(src1, graph)
dist2 := dijkstra(src2, graph)
distDest := dijkstra(dest, reverseGraph)
answer := int64(math.MaxInt64)
for i := 0; i < n; i++ {
if dist1[i] == math.MaxInt64 ||
dist2[i] == math.MaxInt64 ||
distDest[i] == math.MaxInt64 {
continue
}
total := dist1[i] + dist2[i] + distDest[i]
if total < answer {
answer = total
}
}
if answer == math.MaxInt64 {
return -1
}
return answer
}
The Go version follows the same logic as the Python solution but requires explicit heap implementation using Go's container/heap package.
One important difference is integer handling. Since path sums can become large, the implementation uses int64 for distances and the final answer.
Go also does not provide a built-in priority queue, so a custom heap type implementing the required interface methods is necessary.
Worked Examples
Example 1
Input
n = 6
edges = [
[0,2,2],
[0,5,6],
[1,0,3],
[1,4,5],
[2,1,1],
[2,3,3],
[2,3,4],
[3,4,2],
[4,5,1]
]
src1 = 0
src2 = 1
dest = 5
Step 1: Run Dijkstra from src1 = 0
Shortest distances:
| Node | Distance |
|---|---|
| 0 | 0 |
| 1 | 3 |
| 2 | 2 |
| 3 | 5 |
| 4 | 7 |
| 5 | 6 |
Step 2: Run Dijkstra from src2 = 1
| Node | Distance |
|---|---|
| 0 | 3 |
| 1 | 0 |
| 2 | 5 |
| 3 | 8 |
| 4 | 5 |
| 5 | 6 |
Step 3: Run Dijkstra from dest = 5 on reversed graph
| Node | Distance to 5 |
|---|---|
| 0 | 6 |
| 1 | 6 |
| 2 | 4 |
| 3 | 3 |
| 4 | 1 |
| 5 | 0 |
Step 4: Try every node as merge point
| Merge Node | dist1 | dist2 | distDest | Total |
|---|---|---|---|---|
| 0 | 0 | 3 | 6 | 9 |
| 1 | 3 | 0 | 6 | 9 |
| 2 | 2 | 5 | 4 | 11 |
| 3 | 5 | 8 | 3 | 16 |
| 4 | 7 | 5 | 1 | 13 |
| 5 | 6 | 6 | 0 | 12 |
Minimum value is 9.
Final Answer
9
Example 2
Input
n = 3
edges = [
[0,1,1],
[2,1,1]
]
src1 = 0
src2 = 1
dest = 2
Dijkstra Results
From src1:
| Node | Distance |
|---|---|
| 0 | 0 |
| 1 | 1 |
| 2 | INF |
From src2:
| Node | Distance |
|---|---|
| 0 | INF |
| 1 | 0 |
| 2 | INF |
To dest:
| Node | Distance |
|---|---|
| 0 | INF |
| 1 | INF |
| 2 | 0 |
No node has all three finite distances.
Final Answer
-1
Complexity Analysis
| Measure | Complexity | Explanation |
|---|---|---|
| Time | O((V + E) log V) |
Three Dijkstra runs dominate the complexity |
| Space | O(V + E) |
Adjacency lists, reverse graph, distance arrays, and heap storage |
The graph is stored using adjacency lists, which require linear space relative to nodes and edges.
Each Dijkstra run processes every edge at most once and performs heap operations that cost log V. Since we execute Dijkstra three times, the total complexity remains:
O(3 * (V + E) log V)
which simplifies to:
O((V + E) log V)
This is efficient enough for graphs with up to 10^5 nodes and edges.
Test Cases
from typing import List
class Solution:
def minimumWeight(self, n: int, edges: List[List[int]], src1: int, src2: int, dest: int) -> int:
import heapq
graph = [[] for _ in range(n)]
reverse_graph = [[] for _ in range(n)]
for u, v, w in edges:
graph[u].append((v, w))
reverse_graph[v].append((u, w))
def dijkstra(start, adjacency):
INF = float('inf')
dist = [INF] * n
dist[start] = 0
heap = [(0, start)]
while heap:
current_dist, node = heapq.heappop(heap)
if current_dist > dist[node]:
continue
for neighbor, weight in adjacency[node]:
new_dist = current_dist + weight
if new_dist < dist[neighbor]:
dist[neighbor] = new_dist
heapq.heappush(heap, (new_dist, neighbor))
return dist
dist1 = dijkstra(src1, graph)
dist2 = dijkstra(src2, graph)
dist_dest = dijkstra(dest, reverse_graph)
INF = float('inf')
answer = INF
for i in range(n):
if (
dist1[i] != INF and
dist2[i] != INF and
dist_dest[i] != INF
):
answer = min(
answer,
dist1[i] + dist2[i] + dist_dest[i]
)
return -1 if answer == INF else answer
sol = Solution()
# Example 1 from problem statement
assert sol.minimumWeight(
6,
[
[0,2,2],
[0,5,6],
[1,0,3],
[1,4,5],
[2,1,1],
[2,3,3],
[2,3,4],
[3,4,2],
[4,5,1]
],
0,
1,
5
) == 9
# Example 2 from problem statement
assert sol.minimumWeight(
3,
[
[0,1,1],
[2,1,1]
],
0,
1,
2
) == -1
# Both paths merge before destination
assert sol.minimumWeight(
5,
[
[0,2,2],
[1,2,3],
[2,4,1]
],
0,
1,
4
) == 6
# Completely separate paths
assert sol.minimumWeight(
6,
[
[0,3,2],
[1,4,3],
[3,5,4],
[4,5,1]
],
0,
1,
5
) == 10
# Multiple edges between same nodes
assert sol.minimumWeight(
4,
[
[0,1,10],
[0,1,1],
[1,3,2],
[2,1,3]
],
0,
2,
3
) == 6
# Destination unreachable from one source
assert sol.minimumWeight(
4,
[
[0,1,1],
[1,3,1]
],
0,
2,
3
) == -1
# Minimal graph size
assert sol.minimumWeight(
3,
[
[0,2,5],
[1,2,7]
],
0,
1,
2
) == 12
# Shared suffix is cheaper than separate routes
assert sol.minimumWeight(
6,
[
[0,2,1],
[1,2,1],
[2,3,1],
[3,5,1],
[0,4,10],
[1,4,10],
[4,5,10]
],
0,
1,
5
) == 4
| Test | Why |
|---|---|
| Example 1 | Verifies the official optimal merge behavior |
| Example 2 | Verifies unreachable destination handling |
| Merge before destination | Ensures shared suffix logic works |
| Separate paths | Ensures independent routes are handled |
| Multiple edges | Verifies shortest edge selection |
| One unreachable source | Confirms -1 is returned correctly |
| Minimum graph size | Tests smallest valid input |
| Shared suffix cheaper | Confirms merging reduces total cost |
Edge Cases
Unreachable Destination
One of the most important edge cases occurs when either src1 or src2 cannot reach dest.
A naive implementation might accidentally treat infinite distances as valid values or overflow when adding them together.
This implementation explicitly checks whether all three required distances are finite before considering a node as a merge point:
if (
dist1[node] != INF and
dist2[node] != INF and
dist_dest[node] != INF
):
If no valid node exists, the algorithm correctly returns -1.
Multiple Edges Between the Same Nodes
The graph may contain multiple directed edges connecting the same pair of nodes with different weights.
A buggy implementation might overwrite edges or incorrectly assume uniqueness.
Using adjacency lists naturally handles this situation because all edges are stored independently. Dijkstra automatically chooses the cheapest option during relaxation.
Paths That Never Merge
Some solutions incorrectly assume that the two source paths must share edges.
In reality, the optimal subgraph may consist of two completely separate paths that only meet at dest, or never overlap at all before the destination.
This implementation correctly handles that case because every node, including dest itself, is considered as a potential merge point. If the best configuration uses separate routes until the end, choosing dest as the merge node naturally represents that structure.