3123. Find Edges in Shortest Paths
Problem Description
You have an undirected weighted graph with n
nodes (numbered from 0 to n - 1
) and m
edges. The edges are given as a 2D array edges
, where each edges[i] = [ai, bi, wi]
represents an edge between nodes ai
and bi
with weight wi
.
Your task is to find all edges that belong to at least one shortest path from node 0 to node n - 1
.
You need to return a boolean array answer
of length m
where:
answer[i] = true
if the i-th edge is part of at least one shortest path from node 0 to noden - 1
answer[i] = false
otherwise
Note that the graph may not be connected, meaning there might not be any path from node 0 to node n - 1
.
For example, if there are multiple shortest paths from node 0 to node n - 1
, and an edge appears in any of these paths, then that edge should be marked as true
in the answer array. If an edge doesn't participate in any shortest path (either because it's not on the route or because using it would make the path longer), it should be marked as false
.
Flowchart Walkthrough
First, let's pin down the algorithm using the Flowchart. Here's a step-by-step walkthrough:
Is it a graph?
- Yes: The problem explicitly states we have an undirected weighted graph with nodes and edges.
Is it a tree?
- No: The graph is not necessarily a tree. It can have cycles and multiple paths between nodes.
Is the problem related to directed acyclic graphs?
- No: The graph is undirected (not directed), and it may contain cycles.
Is the problem related to shortest paths?
- Yes: The core of the problem is finding edges that belong to at least one shortest path from node 0 to node
n-1
.
Is the graph weighted?
- Yes: Each edge has a weight
wi
associated with it.
Dijkstra's Algorithm
- Yes: Since we need to find shortest paths in a weighted graph, Dijkstra's algorithm is the appropriate choice.
Additional DFS consideration:
While the flowchart leads us to Dijkstra's algorithm for finding shortest paths, the problem requires an additional step. After computing shortest distances using Dijkstra's, we need to identify which edges are part of shortest paths. This is done through a reverse traversal (using DFS/BFS) from the destination node n-1
, checking which edges satisfy the shortest path condition: dist[a] = dist[b] + weight
.
Conclusion: The flowchart correctly identifies Dijkstra's algorithm as the primary approach for this shortest path problem in a weighted graph. The solution combines Dijkstra's algorithm with a DFS/BFS traversal to mark edges that participate in shortest paths.
Intuition
To find which edges are part of shortest paths, we need to think about what makes an edge "special" in the context of shortest paths.
First, we need to know the actual shortest distances from the source (node 0) to all other nodes. This is a classic shortest path problem, and since we have weighted edges, Dijkstra's algorithm is the natural choice.
But knowing shortest distances alone isn't enough. We need to identify which specific edges contribute to these shortest paths. Here's the key insight: an edge (a, b)
with weight w
is part of a shortest path if and only if it connects two nodes whose shortest distances differ by exactly w
. In other words, if dist[a] + w = dist[b]
or dist[b] + w = dist[a]
, then this edge is a "bridge" in at least one shortest path.
Think of it like this: imagine you're traveling from city 0 to city n-1
, and you know the minimum travel time to reach every city. An road between cities A and B is part of your optimal route if taking that road from A gets you to B at exactly the minimum time needed to reach B (or vice versa).
The clever part of the solution is working backwards from the destination. Once we have all shortest distances, we start from node n-1
and trace back through the graph. For each node we visit, we check all its edges. If an edge satisfies our shortest path condition (dist[current] = dist[neighbor] + edge_weight
), we know this edge is part of a shortest path, so we mark it as true
and continue exploring from the neighbor.
This backward traversal ensures we only mark edges that are actually reachable on a shortest path from source to destination. If there's no path at all (distance to n-1
is infinity), we simply return all false
values.
Learn more about Depth-First Search, Breadth-First Search, Graph, Shortest Path and Heap (Priority Queue) patterns.
Solution Approach
The implementation consists of two main phases: finding shortest distances using Dijkstra's algorithm, and then identifying edges on shortest paths through backward traversal.
Phase 1: Building the Graph and Finding Shortest Distances
First, we create an adjacency list representation of the graph. For each edge (a, b, w)
, we store it in both directions since the graph is undirected. We also store the edge index i
with each edge to track which original edge it corresponds to:
g = defaultdict(list)
for i, (a, b, w) in enumerate(edges):
g[a].append((b, w, i)) # neighbor, weight, edge_index
g[b].append((a, w, i))
Next, we apply Dijkstra's algorithm using a min-heap to find shortest distances from node 0 to all other nodes:
dist = [inf] * n dist[0] = 0 q = [(0, 0)] # (distance, node)
The algorithm proceeds by:
- Extracting the node with minimum distance from the heap
- Skipping if we've already processed a better path to this node
- Relaxing edges: for each neighbor, if we find a shorter path through the current node, we update its distance and add it to the heap
while q: da, a = heappop(q) if da > dist[a]: continue for b, w, _ in g[a]: if dist[b] > dist[a] + w: dist[b] = dist[a] + w heappush(q, (dist[b], b))
Phase 2: Identifying Edges on Shortest Paths
After computing shortest distances, we check if node n-1
is reachable. If dist[n-1] == inf
, there's no path, so all edges return false
.
For the backward traversal, we use a queue (BFS approach) starting from the destination node n-1
:
ans = [False] * m q = deque([n - 1]) while q: a = q.popleft() for b, w, i in g[a]: if dist[a] == dist[b] + w: ans[i] = True q.append(b)
The key condition dist[a] == dist[b] + w
checks if edge (b, a)
with weight w
is part of a shortest path. If true:
- We mark this edge as part of a shortest path (
ans[i] = True
) - We continue exploring from node
b
to find more edges on shortest paths
This backward traversal ensures we only mark edges that actually connect nodes on valid shortest paths from source to destination. The BFS approach prevents infinite loops and ensures each node is processed in the context of shortest paths.
The time complexity is O(E log V)
for Dijkstra's algorithm plus O(E)
for the backward traversal, where E
is the number of edges and V
is the number of vertices.
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 work through a small example to illustrate the solution approach.
Example Graph:
n = 4, edges = [[0,1,1], [1,2,1], [0,3,4], [3,2,1], [1,3,1]]
This creates the following graph:
1 1 0 ----> 1 ----> 2 \ | / \4 |1 /1 \ v / --> 3 <--
Phase 1: Finding Shortest Distances with Dijkstra's Algorithm
Starting from node 0, we initialize:
dist = [0, inf, inf, inf]
- Priority queue:
[(0, 0)]
Step-by-step execution:
-
Pop
(0, 0)
from queue- Check neighbors of node 0:
- Node 1: dist[1] = min(inf, 0+1) = 1, add (1,1) to queue
- Node 3: dist[3] = min(inf, 0+4) = 4, add (4,3) to queue
dist = [0, 1, inf, 4]
- Check neighbors of node 0:
-
Pop
(1, 1)
from queue- Check neighbors of node 1:
- Node 0: dist[0] = 0 (no update needed)
- Node 2: dist[2] = min(inf, 1+1) = 2, add (2,2) to queue
- Node 3: dist[3] = min(4, 1+1) = 2, add (2,3) to queue
dist = [0, 1, 2, 2]
- Check neighbors of node 1:
-
Pop
(2, 2)
from queue (node 2)- Check neighbors of node 2:
- Node 1: dist[1] = 1 (no update needed)
- Node 3: dist[3] = min(2, 2+1) = 2 (no update needed)
- Check neighbors of node 2:
-
Pop
(2, 3)
from queue (node 3)- Check neighbors of node 3:
- Node 0: dist[0] = 0 (no update needed)
- Node 2: dist[2] = min(2, 2+1) = 2 (no update needed)
- Node 1: dist[1] = min(1, 2+1) = 1 (no update needed)
- Check neighbors of node 3:
Final distances: dist = [0, 1, 2, 2]
Phase 2: Backward Traversal to Find Edges on Shortest Paths
We want to reach node 3 (n-1). Since dist[3] = 2 (not infinity), there is a path.
Starting from node 3, we check which edges lead to it on a shortest path:
-
Process node 3 (dist[3] = 2):
- Edge [0,3,4]: Is dist[3] == dist[0] + 4? Is 2 == 0 + 4? No
- Edge [1,3,1]: Is dist[3] == dist[1] + 1? Is 2 == 1 + 1? Yes!
- Mark edge index 4 as true
- Add node 1 to queue
-
Process node 1 (dist[1] = 1):
- Edge [0,1,1]: Is dist[1] == dist[0] + 1? Is 1 == 0 + 1? Yes!
- Mark edge index 0 as true
- Add node 0 to queue
- Edge [1,2,1]: Is dist[1] == dist[2] + 1? Is 1 == 2 + 1? No
- Edge [1,3,1]: Already processed from the other direction
- Edge [0,1,1]: Is dist[1] == dist[0] + 1? Is 1 == 0 + 1? Yes!
-
Process node 0 (dist[0] = 0):
- Node 0 is the source, no incoming edges on shortest path
Result:
- Edge 0 [0,1,1]: true (part of path 0β1β3)
- Edge 1 [1,2,1]: false (not needed to reach node 3)
- Edge 2 [0,3,4]: false (creates a longer path)
- Edge 3 [3,2,1]: false (not on any shortest path to node 3)
- Edge 4 [1,3,1]: true (part of path 0β1β3)
Final answer: [true, false, false, false, true]
The shortest paths from node 0 to node 3 are:
- 0 β 1 β 3 (total weight: 2)
This path uses edges [0,1,1] and [1,3,1], which are correctly identified by our algorithm.
Solution Implementation
1from collections import defaultdict, deque
2from heapq import heappush, heappop
3from typing import List
4from math import inf
5
6class Solution:
7 def findAnswer(self, n: int, edges: List[List[int]]) -> List[bool]:
8 # Build adjacency list representation of the graph
9 # Each node maps to list of (neighbor, weight, edge_index) tuples
10 graph = defaultdict(list)
11 for edge_idx, (node_a, node_b, weight) in enumerate(edges):
12 graph[node_a].append((node_b, weight, edge_idx))
13 graph[node_b].append((node_a, weight, edge_idx))
14
15 # Initialize distances array for Dijkstra's algorithm
16 distances = [inf] * n
17 distances[0] = 0
18
19 # Priority queue for Dijkstra's algorithm: (distance, node)
20 priority_queue = [(0, 0)]
21
22 # Run Dijkstra's algorithm to find shortest distances from node 0
23 while priority_queue:
24 current_dist, current_node = heappop(priority_queue)
25
26 # Skip if we've already found a better path to this node
27 if current_dist > distances[current_node]:
28 continue
29
30 # Explore neighbors
31 for neighbor, edge_weight, _ in graph[current_node]:
32 new_dist = distances[current_node] + edge_weight
33 if distances[neighbor] > new_dist:
34 distances[neighbor] = new_dist
35 heappush(priority_queue, (distances[neighbor], neighbor))
36
37 # Initialize result array for all edges
38 num_edges = len(edges)
39 result = [False] * num_edges
40
41 # If destination is unreachable, return all False
42 if distances[n - 1] == inf:
43 return result
44
45 # BFS from destination to mark edges that are part of shortest paths
46 bfs_queue = deque([n - 1])
47 visited = set([n - 1])
48
49 while bfs_queue:
50 current_node = bfs_queue.popleft()
51
52 # Check all edges connected to current node
53 for neighbor, edge_weight, edge_idx in graph[current_node]:
54 # If this edge is part of a shortest path
55 if distances[current_node] == distances[neighbor] + edge_weight:
56 result[edge_idx] = True
57 # Add neighbor to queue if not visited
58 if neighbor not in visited:
59 visited.add(neighbor)
60 bfs_queue.append(neighbor)
61
62 return result
63
1class Solution {
2 public boolean[] findAnswer(int n, int[][] edges) {
3 // Build adjacency list representation of the graph
4 // Each node stores: [neighbor, weight, edge_index]
5 List<int[]>[] graph = new List[n];
6 Arrays.setAll(graph, k -> new ArrayList<>());
7
8 int edgeCount = edges.length;
9
10 // Populate the graph with bidirectional edges
11 for (int i = 0; i < edgeCount; i++) {
12 int nodeA = edges[i][0];
13 int nodeB = edges[i][1];
14 int weight = edges[i][2];
15
16 // Add edge in both directions with edge index
17 graph[nodeA].add(new int[] {nodeB, weight, i});
18 graph[nodeB].add(new int[] {nodeA, weight, i});
19 }
20
21 // Initialize distances array for Dijkstra's algorithm
22 int[] distances = new int[n];
23 final int INFINITY = 1 << 30;
24 Arrays.fill(distances, INFINITY);
25 distances[0] = 0; // Starting node distance is 0
26
27 // Priority queue for Dijkstra's algorithm: [distance, node]
28 PriorityQueue<int[]> minHeap = new PriorityQueue<>((a, b) -> a[0] - b[0]);
29 minHeap.offer(new int[] {0, 0}); // Start from node 0 with distance 0
30
31 // Run Dijkstra's algorithm to find shortest distances from node 0
32 while (!minHeap.isEmpty()) {
33 int[] current = minHeap.poll();
34 int currentDistance = current[0];
35 int currentNode = current[1];
36
37 // Skip if we've already found a shorter path to this node
38 if (currentDistance > distances[currentNode]) {
39 continue;
40 }
41
42 // Explore all neighbors
43 for (int[] edge : graph[currentNode]) {
44 int neighbor = edge[0];
45 int edgeWeight = edge[1];
46 int newDistance = distances[currentNode] + edgeWeight;
47
48 // Update distance if we found a shorter path
49 if (distances[neighbor] > newDistance) {
50 distances[neighbor] = newDistance;
51 minHeap.offer(new int[] {distances[neighbor], neighbor});
52 }
53 }
54 }
55
56 // Initialize result array for edges on shortest paths
57 boolean[] result = new boolean[edgeCount];
58
59 // If destination is unreachable, return all false
60 if (distances[n - 1] == INFINITY) {
61 return result;
62 }
63
64 // BFS from destination to mark edges that are part of shortest paths
65 Deque<Integer> queue = new ArrayDeque<>();
66 queue.offer(n - 1); // Start from destination node
67
68 while (!queue.isEmpty()) {
69 int currentNode = queue.poll();
70
71 // Check all edges connected to current node
72 for (int[] edge : graph[currentNode]) {
73 int neighbor = edge[0];
74 int edgeWeight = edge[1];
75 int edgeIndex = edge[2];
76
77 // If this edge is part of a shortest path
78 // (neighbor's distance + edge weight equals current node's distance)
79 if (distances[currentNode] == distances[neighbor] + edgeWeight) {
80 result[edgeIndex] = true;
81 queue.offer(neighbor);
82 }
83 }
84 }
85
86 return result;
87 }
88}
89
1class Solution {
2public:
3 vector<bool> findAnswer(int n, vector<vector<int>>& edges) {
4 // Build adjacency list representation of the graph
5 // Each node stores: {neighbor, weight, edge_index}
6 vector<vector<array<int, 3>>> adjacencyList(n);
7 int edgeCount = edges.size();
8
9 for (int i = 0; i < edgeCount; ++i) {
10 auto edge = edges[i];
11 int nodeA = edge[0];
12 int nodeB = edge[1];
13 int weight = edge[2];
14
15 // Add bidirectional edges with their original index
16 adjacencyList[nodeA].push_back({nodeB, weight, i});
17 adjacencyList[nodeB].push_back({nodeA, weight, i});
18 }
19
20 // Initialize distances for Dijkstra's algorithm
21 const int INF = 1 << 30;
22 vector<int> distance(n, INF);
23 distance[0] = 0; // Start node is 0
24
25 // Min-heap priority queue: {distance, node}
26 using PairIntInt = pair<int, int>;
27 priority_queue<PairIntInt, vector<PairIntInt>, greater<PairIntInt>> minHeap;
28 minHeap.push({0, 0});
29
30 // Dijkstra's algorithm to find shortest paths from node 0
31 while (!minHeap.empty()) {
32 auto [currentDist, currentNode] = minHeap.top();
33 minHeap.pop();
34
35 // Skip if we've already found a better path to this node
36 if (currentDist > distance[currentNode]) {
37 continue;
38 }
39
40 // Relax edges from current node
41 for (auto [neighbor, edgeWeight, edgeIndex] : adjacencyList[currentNode]) {
42 if (distance[neighbor] > distance[currentNode] + edgeWeight) {
43 distance[neighbor] = distance[currentNode] + edgeWeight;
44 minHeap.push({distance[neighbor], neighbor});
45 }
46 }
47 }
48
49 // Initialize result array for each edge
50 vector<bool> result(edgeCount, false);
51
52 // If destination is unreachable, return all false
53 if (distance[n - 1] == INF) {
54 return result;
55 }
56
57 // BFS from destination to mark edges on shortest paths
58 queue<int> bfsQueue;
59 bfsQueue.push(n - 1); // Start from destination node
60
61 while (!bfsQueue.empty()) {
62 int currentNode = bfsQueue.front();
63 bfsQueue.pop();
64
65 // Check all edges connected to current node
66 for (auto [neighbor, edgeWeight, edgeIndex] : adjacencyList[currentNode]) {
67 // If this edge is part of a shortest path
68 // (neighbor's distance + edge weight equals current node's distance)
69 if (distance[currentNode] == distance[neighbor] + edgeWeight) {
70 result[edgeIndex] = true;
71 bfsQueue.push(neighbor);
72 }
73 }
74 }
75
76 return result;
77 }
78};
79
1function findAnswer(n: number, edges: number[][]): boolean[] {
2 // Build adjacency list representation of the graph
3 // Each node stores: [neighbor, weight, edgeIndex]
4 const adjacencyList: number[][][] = Array(n).fill(null).map(() => []);
5 const edgeCount = edges.length;
6
7 // Populate adjacency list with bidirectional edges
8 for (let i = 0; i < edgeCount; i++) {
9 const [nodeA, nodeB, weight] = edges[i];
10
11 // Add bidirectional edges with their original index
12 adjacencyList[nodeA].push([nodeB, weight, i]);
13 adjacencyList[nodeB].push([nodeA, weight, i]);
14 }
15
16 // Initialize distances for Dijkstra's algorithm
17 const INF = 1 << 30;
18 const distance: number[] = Array(n).fill(INF);
19 distance[0] = 0; // Start node is 0
20
21 // Min-heap priority queue: [distance, node]
22 // Using array and sorting to simulate priority queue
23 const minHeap: [number, number][] = [[0, 0]];
24
25 // Dijkstra's algorithm to find shortest paths from node 0
26 while (minHeap.length > 0) {
27 // Sort to maintain min-heap property
28 minHeap.sort((a, b) => a[0] - b[0]);
29 const [currentDist, currentNode] = minHeap.shift()!;
30
31 // Skip if we've already found a better path to this node
32 if (currentDist > distance[currentNode]) {
33 continue;
34 }
35
36 // Relax edges from current node
37 for (const [neighbor, edgeWeight, edgeIndex] of adjacencyList[currentNode]) {
38 const newDistance = distance[currentNode] + edgeWeight;
39
40 if (distance[neighbor] > newDistance) {
41 distance[neighbor] = newDistance;
42 minHeap.push([distance[neighbor], neighbor]);
43 }
44 }
45 }
46
47 // Initialize result array for each edge
48 const result: boolean[] = Array(edgeCount).fill(false);
49
50 // If destination is unreachable, return all false
51 if (distance[n - 1] === INF) {
52 return result;
53 }
54
55 // BFS from destination to mark edges on shortest paths
56 const bfsQueue: number[] = [n - 1]; // Start from destination node
57 const visited: boolean[] = Array(n).fill(false);
58 visited[n - 1] = true;
59
60 while (bfsQueue.length > 0) {
61 const currentNode = bfsQueue.shift()!;
62
63 // Check all edges connected to current node
64 for (const [neighbor, edgeWeight, edgeIndex] of adjacencyList[currentNode]) {
65 // If this edge is part of a shortest path
66 // (neighbor's distance + edge weight equals current node's distance)
67 if (distance[currentNode] === distance[neighbor] + edgeWeight) {
68 result[edgeIndex] = true;
69
70 // Add neighbor to queue if not visited
71 if (!visited[neighbor]) {
72 visited[neighbor] = true;
73 bfsQueue.push(neighbor);
74 }
75 }
76 }
77 }
78
79 return result;
80}
81
Time and Space Complexity
Time Complexity: O(m Γ log m)
The algorithm consists of two main phases:
-
Dijkstra's algorithm phase: The algorithm uses a min-heap to find shortest distances from node 0 to all other nodes. In the worst case, each edge is processed once, and for each edge, we perform a heap push operation. Since there are
m
edges and each heap operation takesO(log m)
time (the heap can contain at mostm
elements), this phase takesO(m Γ log m)
time. -
BFS phase: Starting from node
n-1
, the algorithm performs a BFS traversal to mark edges that lie on shortest paths. Each edge is visited at most once during this traversal, takingO(m)
time.
The overall time complexity is dominated by the Dijkstra's algorithm phase: O(m Γ log m) + O(m) = O(m Γ log m)
.
Space Complexity: O(n + m)
The space usage includes:
- Graph adjacency list
g
: stores all edges with their endpoints and weights, requiringO(m)
space - Distance array
dist
: stores distances forn
nodes, requiringO(n)
space - Priority queue
q
in Dijkstra's: can contain at mostO(m)
elements in the worst case - BFS queue
q
: can contain at mostO(n)
nodes - Answer array
ans
: stores boolean values form
edges, requiringO(m)
space
The total space complexity is O(n + m)
.
Learn more about how to find time and space complexity quickly.
Common Pitfalls
Pitfall: Processing the same node multiple times during backward traversal
The most critical issue in the provided code is that nodes can be added to the BFS queue multiple times during the backward traversal phase. When identifying edges on shortest paths, if a node has multiple incoming edges that are part of shortest paths, it will be added to the queue repeatedly. This leads to:
- Redundant processing: The same node and its edges are checked multiple times
- Performance degradation: In dense graphs or graphs with many shortest paths, this can significantly slow down the algorithm
- Potential infinite loops: In certain graph structures, this could theoretically cause extremely long processing times
Example scenario: Consider a diamond-shaped graph where node 0 connects to nodes 1 and 2, both of which connect to node 3. If both paths (0β1β3 and 0β2β3) are shortest paths, node 1 and node 2 might be added to the queue multiple times when traversing backward from node 3.
Solution:
Track visited nodes during the backward traversal to ensure each node is processed only once:
# BFS from destination to mark edges that are part of shortest paths
bfs_queue = deque([n - 1])
visited_nodes = set([n - 1]) # Track visited nodes
while bfs_queue:
current_node = bfs_queue.popleft()
# Check all edges connected to current node
for neighbor, edge_weight, edge_idx in graph[current_node]:
# If this edge is part of a shortest path
if distances[current_node] == distances[neighbor] + edge_weight:
result[edge_idx] = True
# Only add neighbor if not yet visited
if neighbor not in visited_nodes:
visited_nodes.add(neighbor)
bfs_queue.append(neighbor)
This modification ensures:
- Each node is added to the queue at most once
- All edges on shortest paths are still correctly identified
- The time complexity remains O(E) for the backward traversal phase
- No redundant processing occurs
The key insight is that once we've processed a node during backward traversal, we've already identified all its relevant edges that participate in shortest paths, so there's no need to process it again.
Which of the following is a good use case for backtracking?
Recommended Readings
https assets algo monster cover_photos dfs svg Depth First Search Prereqs Recursion Review problems recursion_intro Trees problems tree_intro With a solid understanding of recursion under our belts we are now ready to tackle one of the most useful techniques in coding interviews Depth First Search DFS As the name suggests
https assets algo monster cover_photos bfs svg Breadth First Search on Trees Hopefully by this time you've drunk enough DFS Kool Aid to understand its immense power and seen enough visualization to create a call stack in your mind Now let me introduce the companion spell Breadth First Search BFS
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!