Facebook Pixel

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 node n - 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.

Quick Interview Experience
Help others by sharing your interview experience
Have you seen this problem before?

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:

  1. Extracting the node with minimum distance from the heap
  2. Skipping if we've already processed a better path to this node
  3. 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 Evaluator

Example 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:

  1. 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]
  2. 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]
  3. 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)
  4. 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)

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:

  1. 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
  2. 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
  3. 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:

  1. 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 takes O(log m) time (the heap can contain at most m elements), this phase takes O(m Γ— log m) time.

  2. 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, taking O(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, requiring O(m) space
  • Distance array dist: stores distances for n nodes, requiring O(n) space
  • Priority queue q in Dijkstra's: can contain at most O(m) elements in the worst case
  • BFS queue q: can contain at most O(n) nodes
  • Answer array ans: stores boolean values for m edges, requiring O(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:

  1. Redundant processing: The same node and its edges are checked multiple times
  2. Performance degradation: In dense graphs or graphs with many shortest paths, this can significantly slow down the algorithm
  3. 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.

Discover Your Strengths and Weaknesses: Take Our 5-Minute Quiz to Tailor Your Study Plan:

Which of the following is a good use case for backtracking?


Recommended Readings

Want a Structured Path to Master System Design Too? Don’t Miss This!

Load More