PATTERN 19 OF 22

Graph Shortest Path

Find shortest paths in weighted and unweighted graphs using Dijkstra's algorithm, Bellman-Ford, and modified variants. Master priority-queue-driven exploration, negative edge handling, and constrained shortest path problems like "cheapest flights within K stops."

Time: O((V + E) log V) for Dijkstra, O(V * E) for Bellman-Ford
Space: O(V + E) for adjacency list + distance array

Learn & Reference

Understanding the Pattern

Graph Shortest Path Pattern

The shortest path problem is one of the most fundamental graph problems: given a weighted graph, find the path from a source to a destination (or all destinations) with the minimum total weight. Different algorithms handle different graph constraints, and choosing the right one is half the battle in interviews.

Core Concept

In an unweighted graph, BFS gives shortest paths because it explores nodes level by level (each level = one more edge). But when edges have weights (costs, distances, times), BFS no longer works because a path with fewer edges might have a higher total cost. We need algorithms that account for edge weights.

Unweighted (BFS works):       Weighted (BFS fails):
A --1-- B --1-- D              A --1-- B --10-- D
|               |              |                |
1               1              1                1
|               |              |                |
C ------1------ E              C -------2------ E

BFS: A→B→D (2 edges)           BFS: A→B→D (cost 11)
Optimal: A→B→D (cost 2)        Optimal: A→C→E→D (cost 4)

Key Algorithms

1. Dijkstra's Algorithm (Most Important for Interviews)

Dijkstra finds shortest paths from a source to all nodes in a graph with non-negative edge weights. It uses a min-heap (priority queue) to always process the node with the smallest known distance next.

Algorithm:
1. Initialize: dist[source] = 0, dist[all others] = infinity
2. Push (0, source) onto min-heap
3. While heap is not empty:
   a. Pop (cost, node) with minimum cost
   b. If cost > dist[node], skip (already found a better path)
   c. For each neighbor of node:
      newCost = cost + edgeWeight
      If newCost < dist[neighbor]:
        dist[neighbor] = newCost
        Push (newCost, neighbor) onto heap

The "skip if cost > dist[node]" check is the lazy deletion optimization. Instead of decreasing a key in the heap (which is complex), we simply push a new entry and skip stale ones when popped.

2. Bellman-Ford Algorithm

Bellman-Ford handles negative edge weights and can detect negative cycles. It relaxes ALL edges V-1 times. It is slower than Dijkstra but more general.

Algorithm:
1. Initialize: dist[source] = 0, dist[all others] = infinity
2. Repeat V-1 times:
   For each edge (u, v, weight):
     If dist[u] + weight < dist[v]:
       dist[v] = dist[u] + weight
3. (Optional) One more pass to detect negative cycles:
   If any edge can still be relaxed, a negative cycle exists

Interview use case: The classic "cheapest flights within K stops" problem uses a modified Bellman-Ford where you only relax K+1 times (K stops = K+1 edges).

3. BFS for Unweighted Shortest Path

When all edges have equal weight (or weight = 1), BFS gives shortest paths directly. Each BFS level corresponds to one more edge, so the first time you reach a node is the shortest path. This is covered in depth in the Graphs (BFS/DFS) pattern.

4. Modified Dijkstra (With Constraints)

Many interview problems add constraints to shortest path, such as:

  • At most K stops (Cheapest Flights Within K Stops)
  • Minimum probability path (Path with Maximum Probability)
  • Track additional state beyond just distance

The key technique: expand the state in your priority queue. Instead of just (cost, node), push (cost, node, stopsRemaining) or (cost, node, fuelLeft). The dist array also becomes multi-dimensional: dist[node][stops].

Modified Dijkstra with K stops:
State: (cost, node, stopsLeft)
Push (0, source, K) onto heap
While heap not empty:
  Pop (cost, node, stops)
  If node == destination: return cost
  If stops == 0: continue (no more stops allowed)
  For each neighbor:
    newCost = cost + weight
    If newCost < dist[neighbor][stops-1]:
      dist[neighbor][stops-1] = newCost
      Push (newCost, neighbor, stops-1)

Choosing the Right Algorithm

| Scenario | Algorithm | Why | |----------|-----------|-----| | Unweighted graph | BFS | Each edge = 1, level-order gives shortest path | | Non-negative weights | Dijkstra | Greedy with priority queue, O((V+E) log V) | | Negative weights possible | Bellman-Ford | Handles negatives, O(VE) | | K stops / bounded hops | Bellman-Ford (K+1 iterations) | Natural fit for hop-limited relaxation | | Constrained state | Modified Dijkstra | Expand state in priority queue | | Dense graph, no heap | Floyd-Warshall | All-pairs, O(V^3), rarely in interviews |

Common Mistakes

  1. Using Dijkstra with negative edges -- Dijkstra's greedy approach assumes once a node is settled (popped from the heap), its distance is final. Negative edges violate this assumption and produce wrong answers. If the problem mentions negative weights or costs that can decrease, use Bellman-Ford instead.
  2. Forgetting the "skip stale entries" check in Dijkstra -- Without the if cost > dist[node]: continue check, you process nodes multiple times with suboptimal distances. This does not produce wrong answers but degrades performance from O((V+E) log V) to potentially O(V^2 log V) on dense graphs.
  3. Initializing dist array with 0 instead of infinity -- Every non-source node must start with distance = infinity (or a very large number). Starting with 0 means the algorithm thinks it has already found a zero-cost path to every node, so no relaxation occurs.
  4. Not tracking visited/settled nodes properly -- In basic Dijkstra, once a node is popped from the heap, its shortest distance is finalized. But in modified Dijkstra with extra state (e.g., K stops), a node might be reached multiple times with different remaining stops. You need dist[node][stops] rather than just dist[node].
  5. Off-by-one in Bellman-Ford iterations for K stops -- If the problem says "at most K stops," you need K+1 edge relaxations (K stops means traversing K+1 edges). Running only K iterations gives paths with at most K-1 stops. Also, you must use the distances from the previous iteration (not the current one) to avoid relaxing more edges than intended in a single pass.
  6. Using Python list as a priority queue -- Using a sorted list or repeatedly calling min() on a list gives O(n) per extraction instead of O(log n). Always use heapq for Dijkstra in Python. Similarly, do not use a plain queue or deque for weighted shortest path -- BFS only works when all weights are equal.

When to Use

The problem asks for **shortest/cheapest/minimum-cost path** in a weighted graph
You see **weighted edges** with non-negative values (Dijkstra)
The problem involves **negative edge weights** or asks about negative cycles (Bellman-Ford)
There is a constraint like within K stops or at most K edges (modified Bellman-Ford or modified Dijkstra)
You need to find the cheapest flight, minimum cost, or shortest travel time between nodes
The graph has weighted adjacency list representation `(neighbor, weight)`
Keywords: shortest path, minimum cost, cheapest, Dijkstra, weighted graph, K stops, network delay

Template

1# Dijkstra's Algorithm — Single-Source Shortest Path Template
2import heapq
3from collections import defaultdict
4
5def dijkstra(n, edges, source):
6 # Build adjacency list: node -> [(neighbor, weight), ...]
7 graph = defaultdict(list)
8 for u, v, w in edges:
9 graph[u].append((v, w))
10 # graph[v].append((u, w)) # uncomment for undirected
11
12 # Initialize distances to infinity
13 dist = [float('inf')] * n
14 dist[source] = 0
15
16 # Min-heap: (cost, node)
17 heap = [(0, source)]
18
19 while heap:
20 cost, node = heapq.heappop(heap)
21
22 # Skip stale entries (lazy deletion)
23 if cost > dist[node]:
24 continue
25
26 # Relax neighbors
27 for neighbor, weight in graph[node]:
28 new_cost = cost + weight
29 if new_cost < dist[neighbor]:
30 dist[neighbor] = new_cost
31 heapq.heappush(heap, (new_cost, neighbor))
32
33 return dist # dist[i] = shortest distance from source to i
{ }

Syntax Reference

1# --- Priority Queue (Min-Heap) for Dijkstra ---
2import heapq
3
4heap = []
5heapq.heappush(heap, (cost, node)) # push (priority, item)
6cost, node = heapq.heappop(heap) # pop minimum
7peek = heap[0] # peek at minimum
8len(heap) # heap size
9
10# --- Build Weighted Adjacency List ---
11from collections import defaultdict
12graph = defaultdict(list)
13for u, v, w in edges:
14 graph[u].append((v, w)) # directed
15 # graph[v].append((u, w)) # add for undirected
16
17# --- Distance Array Initialization ---
18dist = [float('inf')] * n
19dist[source] = 0
20
21# --- 2D Distance (for constrained problems) ---
22dist = [[float('inf')] * (k + 2) for _ in range(n)]
23dist[source][0] = 0
📋

Common Recipes

1# Recipe 1: Dijkstra's Algorithm (Single-Source Shortest Path)
2import heapq
3from collections import defaultdict
4
5def dijkstra(n, edges, source):
6 graph = defaultdict(list)
7 for u, v, w in edges:
8 graph[u].append((v, w))
9 dist = [float('inf')] * n
10 dist[source] = 0
11 heap = [(0, source)]
12 while heap:
13 cost, node = heapq.heappop(heap)
14 if cost > dist[node]:
15 continue # skip stale entry
16 for neighbor, weight in graph[node]:
17 new_cost = cost + weight
18 if new_cost < dist[neighbor]:
19 dist[neighbor] = new_cost
20 heapq.heappush(heap, (new_cost, neighbor))
21 return dist
22
23# Recipe 2: Bellman-Ford (Handles Negative Edges)
24def bellman_ford(n, edges, source):
25 dist = [float('inf')] * n
26 dist[source] = 0
27 for _ in range(n - 1):
28 for u, v, w in edges:
29 if dist[u] != float('inf') and dist[u] + w < dist[v]:
30 dist[v] = dist[u] + w
31 # Detect negative cycle
32 for u, v, w in edges:
33 if dist[u] != float('inf') and dist[u] + w < dist[v]:
34 return None # negative cycle exists
35 return dist
36
37# Recipe 3: Modified Bellman-Ford (Cheapest Within K Stops)
38def cheapest_k_stops(n, flights, src, dst, k):
39 dist = [float('inf')] * n
40 dist[src] = 0
41 for _ in range(k + 1): # k stops = k+1 edges
42 prev = dist[:] # use previous iteration's values
43 for u, v, w in flights:
44 if prev[u] != float('inf') and prev[u] + w < dist[v]:
45 dist[v] = prev[u] + w
46 return dist[dst] if dist[dst] != float('inf') else -1
O(n)

Complexity

AlgorithmTimeSpaceNotes
Dijkstra (binary heap)O((V + E) log V)O(V + E)Non-negative weights only; V = vertices, E = edges
Bellman-FordO(V * E)O(V)Handles negative weights; detects negative cycles
BFS (unweighted)O(V + E)O(V)All edge weights = 1; level-order traversal
Modified Bellman-Ford (K stops)O(K * E)O(V)K+1 relaxation passes; uses prev-iteration copy
Modified Dijkstra (with state)O((V*S + E*S) log(V*S))O(V * S)S = state space size (e.g., K stops)

Watch Out

  • Using Dijkstra with negative edges -- Dijkstra's greedy approach assumes once a node is settled (popped from the heap), its distance is final. Negative edges violate this assumption and produce wrong answers. If the problem mentions negative weights or costs that can decrease, use Bellman-Ford instead.
  • Forgetting the "skip stale entries" check in Dijkstra -- Without the `if cost > dist[node]: continue` check, you process nodes multiple times with suboptimal distances. This does not produce wrong answers but degrades performance from O((V+E) log V) to potentially O(V^2 log V) on dense graphs.
  • Initializing dist array with 0 instead of infinity -- Every non-source node must start with distance = infinity (or a very large number). Starting with 0 means the algorithm thinks it has already found a zero-cost path to every node, so no relaxation occurs.
  • Not tracking visited/settled nodes properly -- In basic Dijkstra, once a node is popped from the heap, its shortest distance is finalized. But in modified Dijkstra with extra state (e.g., K stops), a node might be reached multiple times with different remaining stops. You need `dist[node][stops]` rather than just `dist[node]`.
  • Off-by-one in Bellman-Ford iterations for K stops -- If the problem says "at most K stops," you need K+1 edge relaxations (K stops means traversing K+1 edges). Running only K iterations gives paths with at most K-1 stops. Also, you must use the distances from the previous iteration (not the current one) to avoid relaxing more edges than intended in a single pass.
  • Using Python list as a priority queue -- Using a sorted list or repeatedly calling `min()` on a list gives O(n) per extraction instead of O(log n). Always use `heapq` for Dijkstra in Python. Similarly, do not use a plain queue or deque for weighted shortest path -- BFS only works when all weights are equal.