Shortest paths and Dijkstra
Find shortest paths with Dijkstra's algorithm and understand where it breaks.
BFS gives shortest paths when every edge counts as one step. With edge weights, the straightforward generalization is Dijkstra's algorithm: extend BFS to use a priority queue ordered by total path cost[1]. It runs in with a binary heap, dominates routing and pathfinding code, and has one critical prerequisite: every edge weight must be nonnegative[2].
The problem
Given a weighted directed graph with weights on each edge, a source , and (optionally) a target , find the minimum-weight path from to every other vertex. The weight of a path is the sum of its edge weights. The shortest-path tree records, at each vertex, the predecessor on the optimal path back to .
Dijkstra's algorithm
Maintain a tentative distance for each vertex, initialized to for and for everyone else. A min-priority queue stores pairs .
- Pop the vertex with the smallest tentative distance.
- If is already settled, skip. Otherwise mark it settled with the popped distance.
- Relax each outgoing edge: for with weight , if , set and push on the queue.
Each vertex is settled at most once, with each edge causing at most one push and one pop, so the total operations on the heap are , each costing : total time .
Why it works (the greedy invariant)
Theorem. When vertex is popped from the priority queue, is the true shortest-path distance from to .
Proof. Suppose not: let be the first vertex for which is wrong at pop time, and let be a true shortest path . Let be the first vertex on that was not yet settled when was popped. Its predecessor was settled with its correct distance (it was popped before , by the choice of as the first failure). Relaxing at that time set , which equals the true distance to . Because all weights are nonnegative, , so should have been popped before , contradiction.
The proof breaks if any weight is negative: the inequality no longer holds, because adding a negative-weight edge later could undercut the distance through .
Where Dijkstra fails
- Negative edges. Use Bellman-Ford: it relaxes every edge times in , handling negative weights and detecting negative cycles.
- All-pairs shortest paths. Run Dijkstra from every vertex (), or use Floyd-Warshall (, simpler and competitive when the graph is dense).
- Single-target with a heuristic. A* is Dijkstra with an admissible heuristic that biases the search toward the target; with a good heuristic it examines far fewer vertices.
Check it on arrays
import heapq
def dijkstra(graph, source):
# graph: dict u -> list of (v, weight)
dist = {u: float("inf") for u in graph}
dist[source] = 0
parent = {u: None for u in graph}
pq = [(0, source)] # (distance, vertex)
settled = set()
while pq:
d, u = heapq.heappop(pq)
if u in settled: continue
settled.add(u)
for v, w in graph[u]:
if d + w < dist[v]:
dist[v] = d + w
parent[v] = u
heapq.heappush(pq, (dist[v], v))
return dist, parent
# Weighted directed graph
g = {
"A": [("B", 4), ("C", 2)],
"B": [("C", 5), ("D", 10)],
"C": [("E", 3)],
"D": [("F", 11)],
"E": [("D", 4), ("F", 8)],
"F": [],
}
dist, parent = dijkstra(g, "A")
print("distances from A:", dist)
# Reconstruct a path: A to F
def path(parent, target):
out = []
while target is not None:
out.append(target); target = parent[target]
return list(reversed(out))
print("path A -> F:", path(parent, "F"))
# Negative-edge failure: Dijkstra settles B too early and never revisits D.
# Edges: A->B(1), A->C(2), C->B(-5), B->D(3).
# True shortest A->D: A->C->B->D = 2 - 5 + 3 = 0. Dijkstra reports 4.
g_neg = {
"A": [("B", 1), ("C", 2)],
"B": [("D", 3)],
"C": [("B", -5)],
"D": [],
}
dist_neg, _ = dijkstra(g_neg, "A")
print("Dijkstra dist[D] =", dist_neg["D"], " true shortest = 0 via A->C->B->D")Dijkstra reports the correct distances and reconstructs the path on the nonnegative graph, and silently returns the wrong answer on the negative-edge example, illustrating the precondition.
Exercises
Exercise 1. Run Dijkstra by hand from source on the graph with edges (weight 4), (weight 1), (weight 2), (weight 5), (weight 1). Give the order vertices are settled and the final distances.
Solution
Step 1. Initialize and all others to . The queue holds .
Step 2. Pop (), settle it. Relax its edges: , .
Step 3. Pop the smallest, (), settle it. Relax: gives , so ; gives , so .
Step 4. Pop (), settle it. Relax : , so .
Step 5. Pop (), settle it; no outgoing edges. Queue is drained of unsettled vertices.
Settle order: . Final distances: , , , .
Exercise 2. The correctness proof relies on for a vertex on the shortest path to . Show this step fails when one edge has negative weight, by giving a three-vertex graph where Dijkstra settles a vertex with the wrong distance.
Solution
Step 1. Take vertices with edges (weight 2), (weight 3), and (weight ).
Step 2. Dijkstra pops , relaxes to and . It then pops (the smaller tentative distance, 2) and settles it as final.
Step 3. But the true shortest path to is with weight . Dijkstra settled at 2 before exploring , and never revisits a settled vertex, so it reports 2.
Step 4. The failed proof step is exactly : here but , so 's true distance is not bounded by the order in which the queue pops vertices. The negative edge breaks the monotonicity the greedy argument needs.
Exercise 3 (code). Extend the Dijkstra implementation so that, alongside the distance, it returns the number of distinct shortest paths from the source to each vertex (paths of exactly the minimum weight). Assert the count on a small diamond graph.
Solution
When relaxing an edge that strictly improves , the count is inherited from ; when an edge ties the current best, the counts add. Counting only on the settled (final) distance avoids double counting from stale queue entries.
import heapq
def dijkstra_counts(graph, source):
dist = {u: float("inf") for u in graph}
ways = {u: 0 for u in graph}
dist[source] = 0
ways[source] = 1
pq = [(0, source)]
settled = set()
while pq:
d, u = heapq.heappop(pq)
if u in settled: continue
settled.add(u)
for v, w in graph[u]:
nd = d + w
if nd < dist[v]:
dist[v] = nd
ways[v] = ways[u] # new strictly-better path
heapq.heappush(pq, (nd, v))
elif nd == dist[v]:
ways[v] += ways[u] # another path of equal length
return dist, ways
# Diamond: A->B and A->C both cost 1, B->D and C->D both cost 1.
# Two shortest A->D paths (via B, via C), each of length 2.
g = {
"A": [("B", 1), ("C", 1)],
"B": [("D", 1)],
"C": [("D", 1)],
"D": [],
}
dist, ways = dijkstra_counts(g, "A")
assert dist["D"] == 2 and ways["D"] == 2
print(dist["D"], ways["D"])The next lesson finds the cheapest tree that connects every vertex: the minimum spanning tree.
References
- [1]Dijkstra, E. W. (1959). "A note on two problems in connexion with graphs". Numerische Mathematik, 1, 269-271. doi:10.1007/BF01386390↩
- [2]Cormen, T. H., Leiserson, C. E., Rivest, R. L., & Stein, C. (2009). Introduction to Algorithms (3rd ed.). MIT Press. Single-source shortest paths and the nonnegativity precondition (§24.3).↩