LeetCode 3123 - Find Edges in Shortest Paths
The problem gives us an undirected weighted graph with n nodes and m edges. Each edge connects two nodes and has a positive weight. We need to determine which edges belong to at least one shortest path from node 0 to node n - 1.
Difficulty: 🔴 Hard
Topics: Depth-First Search, Breadth-First Search, Graph Theory, Heap (Priority Queue), Shortest Path
Solution
LeetCode 3123 - Find Edges in Shortest Paths
Problem Understanding
The problem gives us an undirected weighted graph with n nodes and m edges. Each edge connects two nodes and has a positive weight. We need to determine which edges belong to at least one shortest path from node 0 to node n - 1.
The important detail is that we are not looking for just one shortest path. There may be many shortest paths with the same minimum total distance. An edge should be marked as true if it appears in at least one of those shortest paths.
The input graph is represented as an edge list:
edges[i] = [ai, bi, wi]
This means there is an undirected edge between ai and bi with weight wi.
The output is a boolean array of length m, where:
answer[i] = truemeans edgeiparticipates in at least one shortest path from0ton - 1answer[i] = falsemeans it never appears in any shortest path
The graph may not be connected, which means there may be no path from node 0 to node n - 1. In that case, every answer is false because no shortest path exists.
The constraints are large:
- Up to
5 * 10^4nodes - Up to
5 * 10^4edges
These limits immediately rule out expensive graph algorithms such as all-pairs shortest paths or repeated recomputation for every edge. Since all weights are positive, Dijkstra's algorithm is the natural candidate for shortest path computation.
Several edge cases are important:
- The destination node may be unreachable
- Multiple shortest paths may exist
- An edge may connect nodes that are individually reachable but still not belong to any shortest path
- The graph can contain cycles
- The shortest path may revisit regions through different equal-cost routes
A correct solution must efficiently identify every edge that satisfies the shortest path condition.
Approaches
Brute Force Approach
A brute-force solution would examine every edge independently.
For each edge, we could force that edge to appear in a path from 0 to n - 1 and check whether the resulting path length equals the true shortest distance.
One possible implementation would:
- Compute the shortest distance from
0ton - 1 - For every edge
(u, v, w):
- Compute shortest path from
0tou - Add edge weight
w - Compute shortest path from
vton - 1 - Also try the reverse direction because the graph is undirected
- Check whether either total equals the shortest distance
The issue is that this requires repeated shortest path computations. Running Dijkstra once costs O((V + E) log V). Repeating it for every edge becomes far too expensive.
With up to 5 * 10^4 edges, this approach is completely impractical.
Key Insight
The key observation is that an edge belongs to some shortest path if and only if it satisfies the shortest path distance equation.
Suppose:
distFromStart[x]is the shortest distance from node0to nodexdistFromEnd[x]is the shortest distance from noden - 1to nodexshortest = distFromStart[n - 1]
For an edge (u, v, w) to lie on a shortest path, one of these must hold:
distFromStart[u] + w + distFromEnd[v] == shortest
or
distFromStart[v] + w + distFromEnd[u] == shortest
Why does this work?
If the first equation holds, then:
- We can travel optimally from
0tou - Use edge
(u, v) - Then travel optimally from
vton - 1
The total distance equals the shortest possible distance, so this edge appears in a shortest path.
This means we only need:
- One Dijkstra from the source
- One Dijkstra from the destination
- A linear scan over all edges
That is efficient enough for the constraints.
Approach Comparison
| Approach | Time Complexity | Space Complexity | Notes |
|---|---|---|---|
| Brute Force | O(E × (V + E) log V) | O(V + E) | Runs shortest path repeatedly for each edge |
| Optimal | O((V + E) log V) | O(V + E) | Two Dijkstra runs plus one edge scan |
Algorithm Walkthrough
Step 1: Build the Adjacency List
Convert the edge list into an adjacency list because Dijkstra's algorithm works efficiently with adjacency lists.
For every edge:
[u, v, w]
store:
u -> (v, w)
v -> (u, w)
Since the graph is undirected, both directions are required.
Step 2: Run Dijkstra from Node 0
Compute the shortest distance from node 0 to every other node.
Store the result in:
distFromStart
We use a min-heap priority queue because edge weights are positive.
This gives us the minimum cost to reach every node from the source.
Step 3: Run Dijkstra from Node n - 1
Now run Dijkstra again, this time starting from node n - 1.
Store the result in:
distFromEnd
This gives us the minimum cost from every node to the destination.
Because the graph is undirected, running Dijkstra backward is equivalent to computing distances to the destination.
Step 4: Determine the Global Shortest Distance
The shortest path from 0 to n - 1 is:
shortest = distFromStart[n - 1]
If this value is infinity, the destination is unreachable. In that case, return an array of all false.
Step 5: Check Every Edge
For each edge (u, v, w):
Check whether:
distFromStart[u] + w + distFromEnd[v] == shortest
or:
distFromStart[v] + w + distFromEnd[u] == shortest
If either condition holds, the edge belongs to at least one shortest path.
Why it works
Dijkstra guarantees that distFromStart[x] and distFromEnd[x] are optimal shortest distances.
If an edge satisfies:
distFromStart[u] + w + distFromEnd[v] == shortest
then there exists:
- An optimal path from
0tou - The edge
(u, v) - An optimal path from
vton - 1
Combining them creates a complete shortest path containing that edge.
Conversely, if an edge belongs to a shortest path, then splitting that path around the edge must satisfy exactly this equation. Therefore the condition is both necessary and sufficient.
Python Solution
from typing import List
import heapq
class Solution:
def findAnswer(self, n: int, edges: List[List[int]]) -> List[bool]:
graph = [[] for _ in range(n)]
for idx, (u, v, w) in enumerate(edges):
graph[u].append((v, w))
graph[v].append((u, w))
def dijkstra(start: int) -> List[int]:
INF = float('inf')
dist = [INF] * n
dist[start] = 0
min_heap = [(0, start)]
while min_heap:
current_dist, node = heapq.heappop(min_heap)
if current_dist > dist[node]:
continue
for neighbor, weight in graph[node]:
new_dist = current_dist + weight
if new_dist < dist[neighbor]:
dist[neighbor] = new_dist
heapq.heappush(min_heap, (new_dist, neighbor))
return dist
dist_from_start = dijkstra(0)
dist_from_end = dijkstra(n - 1)
shortest_path = dist_from_start[n - 1]
if shortest_path == float('inf'):
return [False] * len(edges)
answer = []
for u, v, w in edges:
in_shortest_path = (
dist_from_start[u] + w + dist_from_end[v] == shortest_path
or
dist_from_start[v] + w + dist_from_end[u] == shortest_path
)
answer.append(in_shortest_path)
return answer
The implementation begins by constructing an adjacency list. Each node stores its neighboring nodes and corresponding edge weights. This representation allows Dijkstra's algorithm to efficiently traverse only connected edges.
The dijkstra helper function computes shortest distances from a given starting node. The algorithm uses a min-heap priority queue where each entry contains the current distance and node index.
When a node is extracted from the heap, we skip outdated entries using:
if current_dist > dist[node]:
continue
This optimization avoids unnecessary work caused by stale heap entries.
For each neighboring edge, we compute a candidate distance. If it improves the known shortest distance, we update the distance array and push the new state into the heap.
After running Dijkstra twice, once from node 0 and once from node n - 1, we know the optimal distance from both directions.
Finally, each edge is checked against the shortest path equation. If either direction satisfies the condition, the edge is part of at least one shortest path.
Go Solution
package main
import (
"container/heap"
"math"
)
type Edge struct {
to int
weight int
}
type State struct {
dist int
node int
}
type MinHeap []State
func (h MinHeap) Len() int {
return len(h)
}
func (h MinHeap) Less(i, j int) bool {
return h[i].dist < h[j].dist
}
func (h MinHeap) Swap(i, j int) {
h[i], h[j] = h[j], h[i]
}
func (h *MinHeap) Push(x interface{}) {
*h = append(*h, x.(State))
}
func (h *MinHeap) Pop() interface{} {
old := *h
n := len(old)
item := old[n-1]
*h = old[:n-1]
return item
}
func dijkstra(start int, graph [][]Edge) []int {
n := len(graph)
dist := make([]int, n)
for i := 0; i < n; i++ {
dist[i] = math.MaxInt64
}
dist[start] = 0
minHeap := &MinHeap{}
heap.Init(minHeap)
heap.Push(minHeap, State{dist: 0, node: start})
for minHeap.Len() > 0 {
current := heap.Pop(minHeap).(State)
if current.dist > dist[current.node] {
continue
}
for _, edge := range graph[current.node] {
newDist := current.dist + edge.weight
if newDist < dist[edge.to] {
dist[edge.to] = newDist
heap.Push(minHeap, State{
dist: newDist,
node: edge.to,
})
}
}
}
return dist
}
func findAnswer(n int, edges [][]int) []bool {
graph := 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})
graph[v] = append(graph[v], Edge{to: u, weight: w})
}
distFromStart := dijkstra(0, graph)
distFromEnd := dijkstra(n-1, graph)
shortestPath := distFromStart[n-1]
answer := make([]bool, len(edges))
if shortestPath == math.MaxInt64 {
return answer
}
for i, edge := range edges {
u := edge[0]
v := edge[1]
w := edge[2]
if distFromStart[u]+w+distFromEnd[v] == shortestPath ||
distFromStart[v]+w+distFromEnd[u] == shortestPath {
answer[i] = true
}
}
return answer
}
The Go implementation follows the same algorithmic structure as the Python version, but Go requires explicit heap implementation using the container/heap package.
A custom MinHeap type implements the heap interface methods:
LenLessSwapPushPop
Distances are initialized using math.MaxInt64 to represent infinity.
Slices are used for adjacency lists and distance arrays. Since Go does not have Python's built-in priority queue, we explicitly maintain heap operations through the standard library heap interface.
Worked Examples
Example 1
Input:
n = 6
edges = [
[0,1,4],
[0,2,1],
[1,3,2],
[1,4,3],
[1,5,1],
[2,3,1],
[3,5,3],
[4,5,2]
]
Step 1: Dijkstra from Node 0
Final distances:
| Node | Distance from 0 |
|---|---|
| 0 | 0 |
| 1 | 4 |
| 2 | 1 |
| 3 | 2 |
| 4 | 7 |
| 5 | 5 |
So:
shortest = 5
Step 2: Dijkstra from Node 5
| Node | Distance to 5 |
|---|---|
| 0 | 5 |
| 1 | 1 |
| 2 | 4 |
| 3 | 3 |
| 4 | 2 |
| 5 | 0 |
Step 3: Check Every Edge
| Edge | Formula Result | In Shortest Path |
|---|---|---|
| (0,1,4) | 0 + 4 + 1 = 5 | True |
| (0,2,1) | 0 + 1 + 4 = 5 | True |
| (1,3,2) | 2 + 2 + 1 = 5 | True |
| (1,4,3) | 4 + 3 + 2 = 9 | False |
| (1,5,1) | 4 + 1 + 0 = 5 | True |
| (2,3,1) | 1 + 1 + 3 = 5 | True |
| (3,5,3) | 2 + 3 + 0 = 5 | True |
| (4,5,2) | 7 + 2 + 0 = 9 | False |
Final answer:
[true, true, true, false, true, true, true, false]
Example 2
Input:
n = 4
edges = [
[2,0,1],
[0,1,1],
[0,3,4],
[3,2,2]
]
Step 1: Dijkstra from Node 0
| Node | Distance |
|---|---|
| 0 | 0 |
| 1 | 1 |
| 2 | 1 |
| 3 | 3 |
Shortest path length:
3
Step 2: Dijkstra from Node 3
| Node | Distance |
|---|---|
| 0 | 3 |
| 1 | 4 |
| 2 | 2 |
| 3 | 0 |
Step 3: Evaluate Edges
| Edge | Formula | Result |
|---|---|---|
| (2,0,1) | 0 + 1 + 2 = 3 | True |
| (0,1,1) | 0 + 1 + 4 = 5 | False |
| (0,3,4) | 0 + 4 + 0 = 4 | False |
| (3,2,2) | 1 + 2 + 0 = 3 | True |
Final answer:
[true, false, false, true]
Complexity Analysis
| Measure | Complexity | Explanation |
|---|---|---|
| Time | O((V + E) log V) | Two Dijkstra runs dominate the complexity |
| Space | O(V + E) | Adjacency list, heap, and distance arrays |
The graph contains up to 5 * 10^4 vertices and edges, making adjacency lists the correct representation. Each Dijkstra traversal processes every edge and heap operation costs O(log V). Since we run Dijkstra twice, the overall complexity remains within acceptable limits.
The space complexity comes from:
- The adjacency list storing all edges
- Two distance arrays
- The priority queue used during Dijkstra
Test Cases
from typing import List
class Solution:
def findAnswer(self, n: int, edges: List[List[int]]) -> List[bool]:
import heapq
graph = [[] for _ in range(n)]
for u, v, w in edges:
graph[u].append((v, w))
graph[v].append((u, w))
def dijkstra(start):
dist = [float('inf')] * n
dist[start] = 0
heap = [(0, start)]
while heap:
d, node = heapq.heappop(heap)
if d > dist[node]:
continue
for nei, w in graph[node]:
nd = d + w
if nd < dist[nei]:
dist[nei] = nd
heapq.heappush(heap, (nd, nei))
return dist
dist_start = dijkstra(0)
dist_end = dijkstra(n - 1)
shortest = dist_start[n - 1]
if shortest == float('inf'):
return [False] * len(edges)
result = []
for u, v, w in edges:
result.append(
dist_start[u] + w + dist_end[v] == shortest or
dist_start[v] + w + dist_end[u] == shortest
)
return result
sol = Solution()
# Example 1
assert sol.findAnswer(
6,
[[0,1,4],[0,2,1],[1,3,2],[1,4,3],
[1,5,1],[2,3,1],[3,5,3],[4,5,2]]
) == [True, True, True, False, True, True, True, False]
# Example 2
assert sol.findAnswer(
4,
[[2,0,1],[0,1,1],[0,3,4],[3,2,2]]
) == [True, False, False, True]
# Single edge graph
assert sol.findAnswer(
2,
[[0,1,5]]
) == [True]
# Unreachable destination
assert sol.findAnswer(
4,
[[0,1,1],[2,3,1]]
) == [False, False]
# Multiple equal shortest paths
assert sol.findAnswer(
4,
[[0,1,1],[1,3,1],[0,2,1],[2,3,1]]
) == [True, True, True, True]
# Extra heavy edge not in shortest path
assert sol.findAnswer(
3,
[[0,1,1],[1,2,1],[0,2,10]]
) == [True, True, False]
# Cycle graph
assert sol.findAnswer(
5,
[[0,1,1],[1,2,1],[2,3,1],[3,4,1],[0,4,10],[1,3,2]]
) == [True, True, True, True, False, False]
# Equal weights with branching
assert sol.findAnswer(
5,
[[0,1,2],[0,2,2],[1,4,2],[2,4,2],[1,2,1]]
) == [True, True, True, True, False]
Test Summary
| Test | Why |
|---|---|
| Example 1 | Validates multiple shortest paths |
| Example 2 | Validates unique shortest path |
| Single edge graph | Smallest connected graph |
| Unreachable destination | Ensures all false when no path exists |
| Multiple equal shortest paths | Confirms all valid edges are marked |
| Extra heavy edge | Ensures non-optimal edges are excluded |
| Cycle graph | Tests behavior in cyclic structures |
| Equal weights with branching | Validates tie handling |
Edge Cases
One important edge case occurs when the destination node is unreachable. In this situation, the shortest distance remains infinity after Dijkstra completes. A buggy implementation might still attempt edge comparisons using infinite values, leading to incorrect results. This implementation explicitly checks for unreachable destinations and immediately returns an array of all false.
Another important case is when multiple shortest paths exist. Many incorrect solutions only reconstruct one shortest path and mark those edges. That approach misses edges belonging to alternative shortest paths with the same total distance. This solution avoids that issue entirely by using the shortest path equality condition, which correctly identifies every valid edge.
Graphs containing cycles are also dangerous. A naive DFS-based shortest path search can repeatedly revisit nodes and become inefficient or incorrect. Dijkstra's algorithm safely handles cycles because it always processes nodes in increasing distance order and ignores stale heap entries.
A final subtle edge case involves undirected edges. Since an edge can be traversed in either direction, both equations must be checked:
distStart[u] + w + distEnd[v]
and
distStart[v] + w + distEnd[u]
Failing to check both directions would incorrectly reject valid shortest path edges.