AmouAI Hub/Courses/Data Structures & Algorithms/Chapter 22
Shortest Paths: Dijkstra, Bellman-Ford, A*
What your maps app is doing. Dijkstra is BFS that respects distance, A* is Dijkstra with an intuition, and Bellman-Ford is the one that survives negative edges.
By the end of this chapter you can
- Explain why BFS stops working the moment edges carry weights
- Implement relaxation, and describe every shortest-path algorithm as a relaxation schedule
- Write Dijkstra with a binary heap and lazy deletion, and state its exact precondition
- Write Bellman-Ford, detect a negative cycle, and say when you would accept its slower running time
- Add a heuristic to get A*, and state what makes a heuristic admissible and consistent
- Choose between Dijkstra, Bellman-Ford, A*, 0-1 BFS and Floyd-Warshall for a given problem
1Fewest hops is not cheapest
BFS answers 'fewest edges'. Almost no real question is about edges — it is about minutes, dollars, or kilometres.
Route A: one motorway, straight through, 40 minutes. Route B: three small roads, 22 minutes.
Ask BFS and it hands you Route A without hesitation, because it counts turns. It has done exactly what you asked. You just asked the wrong question.
The moment an edge carries a number — time, cost, distance, risk — “shortest” stops meaning “fewest” and starts meaning “cheapest total”. And a cheap path with many edges can beat an expensive path with one.
Formally: in a weighted graph every edge (u, v) has a weight
w(u, v). The cost of a path is the sum of its edge weights, and a
shortest path from s to t is any path of minimum total
cost. There may be several; they all count.
BFS solves the special case where every weight is 1. That is not a limitation to be embarrassed about — it is the reason BFS is so fast. It can expand in rings because in an unweighted graph the ring is the distance.
“An edge of weight 5? Just replace it with five edges of weight 1 and run BFS.”
This works, and it is genuinely how some solvers handle small integer weights. But the graph you
build has ∑w edges instead of E — so an edge of weight 3,000,000
becomes three million edges. The running time depends on the magnitude of the weights, not
on the size of the graph, which is exactly the property you do not want.
(The one place it pays off: weights that are only 0 or 1. See §7.)
Three facts hold for every algorithm in this chapter, and each one is used later:
- Optimal substructure. If the shortest path from
stotpasses throughv, then the part fromstovis itself a shortests–vpath. If it were not, you could swap in a cheaper one and beat the path you called shortest. - A shortest path never repeats a vertex, provided there are no negative cycles. Going round a loop of non-negative weight cannot help, so it can always be cut out.
- Shortest paths form a tree. Every vertex needs to remember only one
predecessor, not a whole path. That is why every implementation below stores a
prevdictionary and reconstructs at the end.
2Relaxation: the only idea in this chapter
Every algorithm here is the same three lines, run in a different order. Learn the three lines and the rest is scheduling.
Keep a table dist of the best cost found so far to each vertex, starting at
infinity everywhere except the source. Then repeatedly ask one question about one edge:
def relax(dist, prev, u, v, w):
"""Can going u -> v improve our current best route to v?"""
if dist[u] + w < dist[v]:
dist[v] = dist[u] + w # yes: record the cheaper cost
prev[v] = u # and remember how we got here
return True
return False # no: the route we already had is at least as goodPicture a board with one row per city, each showing the cheapest fare anyone has yet found from your home city. Every row starts blank — infinity, no offer.
A travel agent walks up and says: “I can fly you Paris–Rome for €40.” You look up Paris (€90 on the board), add 40, get €130, and compare it to the Rome row. Cheaper? Rub out the Rome number, write €130, and pencil in “via Paris”. Not cheaper? Thank the agent and change nothing.
That is relaxation. Every algorithm below is a policy for deciding which agent to listen to next. The listening itself never changes.
Two invariants make relaxation safe, and they are worth stating because every proof in the chapter leans on them:
dist[v]is always the cost of some real path tov, or infinity. It is never optimistic.dist[v]never increases. It only ever falls toward the true shortest distance.
So dist[v] is an upper bound that tightens. The whole game is proving that it
reaches the true value, and that you can tell when it has.
- BFS: relax edges in order of hop count. Correct only when all weights are equal.
- Dijkstra: always relax out of the closest unfinished vertex. Correct when no weight is negative.
- Bellman-Ford: relax every edge, V−1 times, in any order. Correct even with negative weights.
- A*: like Dijkstra, but pick the vertex with the smallest estimated total trip. Correct when the heuristic never overestimates.
- DAG shortest path: relax in topological order, once each. Correct on any DAG, negative weights included. (You wrote this in Chapter 21 as longest path.)
3Dijkstra's algorithm
Repeatedly lock in the closest vertex you have not locked in yet. Everything else follows.
Dijkstra's insight is a claim about safety: the unfinished vertex with the smallest
dist value already has its final answer. Nothing discovered later can improve it.
Suppose u is the closest unfinished vertex, with dist[u] = d, and
suppose some cheaper route to u exists. That route must leave the finished set
somewhere — call the first unfinished vertex on it x.
Then dist[x] ≥ d, because u was the closest unfinished
vertex. And the rest of the route, from x onward, costs at least 0. So the whole route
costs at least d. It is not cheaper after all.
Read that last step again: “costs at least 0” is the entire precondition. Allow one negative edge and the argument collapses on that exact line.
The implementation needs a container that hands back the smallest dist quickly, which
is precisely the priority queue from Chapter 17.
import heapq
def dijkstra(graph, src):
"""graph: {vertex: [(neighbour, weight), ...]} with weight >= 0."""
dist = {v: float("inf") for v in graph}
prev = {v: None for v in graph}
dist[src] = 0
heap = [(0, src)] # (best-known distance, vertex)
done = set()
while heap:
d, u = heapq.heappop(heap)
if u in done: # a stale entry — we already finalised u
continue
done.add(u) # LOCK IN: dist[u] is final from here on
for v, w in graph[u]:
nd = d + w
if nd < dist[v]:
dist[v] = nd
prev[v] = u
heapq.heappush(heap, (nd, v)) # push, never decrease-key
return dist, prevdecrease_keyTextbooks describe a priority queue that can lower an existing key. Python's
heapq cannot do that, and almost nobody implements it.
Instead we push a second entry for the same vertex with the better distance. The old
entry is still in the heap, but it will be popped later with a worse distance — and by then
the vertex is in done, so the continue throws it away. This is
lazy deletion.
The cost: the heap holds up to E entries instead of V. The running
time is O(E log E), and since E < V² that is the same as
O(E log V). In exchange the code is fifteen lines. Everyone takes the trade.
Deleting the if u in done: continue line does not break the answer
— the relaxations simply do nothing. It breaks the running time, which is a subtler bug and
therefore a worse one.
Recovering the actual route uses the prev chain, exactly as in BFS:
def shortest_path(graph, src, dst):
dist, prev = dijkstra(graph, src)
if dist[dst] == float("inf"):
return None, float("inf") # unreachable
path, cur = [], dst
while cur is not None:
path.append(cur)
cur = prev[cur]
return path[::-1], dist[dst]If you only want the distance to one target t, you may
break the moment t is popped — that pop is the lock-in.
You may not break when t is first assigned a finite distance. That
value is a bound, not an answer, and a cheaper route can still arrive. This is a common and
expensive off-by-one-concept.
4Where Dijkstra breaks
A single negative edge does not slow Dijkstra down. It makes it answer incorrectly, confidently, and without warning.
Here is the smallest counterexample worth memorising:
# A --1--> B
# | |
# 4 (-3)
# | |
# v v
# C <--------+
#
# True cheapest A -> C: A -> B -> C = 1 + (-3) = -2
# Dijkstra says: 4
graph = {"A": [("B", 1), ("C", 4)],
"B": [("C", -3)],
"C": []}Trace it. Dijkstra pops A (0), relaxes to B = 1 and C = 4.
The closest unfinished vertex is now B at 1, so B locks in and relaxes
C to -2… but wait — walk the other order and it goes wrong:
with C at 4 and B at 1, B is closer, so this tiny graph
survives. Add one more vertex and it does not:
graph = {"A": [("B", 1), ("C", 2)],
"C": [("D", 1)],
"B": [("D", 100)],
"D": [("B", -100)], # a shortcut back, discovered too late
}
# Dijkstra locks B in at 1, long before it learns about the -100 edge.
# Real answer for B: A -> C -> D -> B = 2 + 1 - 100 = -97.Dijkstra does not crash, loop forever, or raise. It returns a plausible dictionary of numbers, some of which are wrong. If your weights can be negative — refunds, elevation drops, energy recovered, log-probabilities — you must notice before you run it, because nothing downstream will notice for you.
Dijkstra's whole strategy rests on one assumption: the further you walk, the more you pay. So the nearest unvisited place is settled business.
Now suppose one toll booth pays you €100 to drive through. Suddenly a place that looked expensive is worth revisiting, because the road beyond it refunds more than it cost to get there. “Nearest is final” is no longer true, and an algorithm built entirely on that sentence cannot be patched.
And if a cycle has negative total weight, the question itself stops making sense: go round it again and the path gets cheaper, forever. There is no shortest path, only an infinite descent. Any correct algorithm must report that rather than return a number.
5Bellman-Ford
Stop being clever about the order. Relax every edge, V-1 times, and let correctness fall out of counting.
Dijkstra is fast because it is careful about which edge to relax next. Bellman-Ford gives that up entirely and relaxes all of them, over and over. What it buys is that it no longer needs the non-negativity assumption anywhere.
A shortest path visits at most V vertices, so it uses at most V − 1
edges.
Claim: after round k of relaxing every edge, dist[v] is correct for
every vertex whose shortest path uses at most k edges.
Round 1 fixes all 1-edge paths, because the source is correct and every edge out of it gets
relaxed. Round 2 fixes all 2-edge paths, because their first edge was fixed in round 1. And so on
by induction. After V − 1 rounds, every shortest path has been covered —
whatever order the edges were relaxed in. That is the beauty of it: no ordering
assumption means no non-negativity assumption.
def bellman_ford(graph, src):
"""Returns (dist, prev), or (None, None) if a negative cycle is reachable."""
dist = {v: float("inf") for v in graph}
prev = {v: None for v in graph}
dist[src] = 0
edges = [(u, v, w) for u in graph for v, w in graph[u]]
for _ in range(len(graph) - 1): # V-1 rounds
changed = False
for u, v, w in edges:
if dist[u] != float("inf") and dist[u] + w < dist[v]:
dist[v] = dist[u] + w
prev[v] = u
changed = True
if not changed: # nothing improved: we are done early
break
for u, v, w in edges: # ONE extra round, as a test
if dist[u] != float("inf") and dist[u] + w < dist[v]:
return None, None # still improving => negative cycle
return dist, prevThat final loop is the part people skip and then miss. After V − 1 rounds
nothing can legitimately improve. So if a V-th round still finds an improvement, the only
possible explanation is a cycle you can go round to keep gaining — a negative cycle. The test
costs one pass over the edges and turns a wrong answer into a reported failure.
The if not changed: break line often turns V − 1 rounds into
three or four. On graphs that are nearly shortest-path trees already, Bellman-Ford with the early
exit is competitive with Dijkstra. Without it, it always pays the full O(V·E).
Do keep the extra verification round even when you break early — the break tells you the distances have settled, which is exactly the case where no negative cycle exists, but only if you confirm it.
| Dijkstra | Bellman-Ford | |
|---|---|---|
| Time | O(E log V) | O(V · E) |
| Negative edges | Wrong answer, silently | Correct |
| Negative cycles | Wrong answer, silently | Detected and reported |
| Needs a heap | Yes | No — just a list of edges |
| Parallelises | Poorly (inherently sequential) | Well (each round is independent) |
| Typical use | Almost everything | Currency arbitrage, difference constraints, distance-vector routing |
Dijkstra is a manager with a spreadsheet, deciding who to inform next in the optimal order.
Bellman-Ford is a rumour. Every round, everyone tells everyone they know. Nobody coordinates, nobody knows the global picture, and the true story still reaches every desk — it just takes as many rounds as the longest chain of desks.
This is why real network routers ran Bellman-Ford (as RIP) for decades: no router can see the whole internet, but each one can shout at its neighbours once a round.
6A*: Dijkstra with an opinion
Dijkstra explores in all directions equally, because it has no idea where the goal is. Tell it, and it stops wasting effort.
Dijkstra is a search party that spreads outward from the station in a perfect circle. Careful, thorough, and it will comb the entire northern suburbs before it walks the one street south where the address actually is.
A* is the same search party, told “it is somewhere south.” Every candidate street is now judged by distance walked so far plus how far south it still is. The party still checks northern streets if a southern route dead-ends — it is not committed, only biased — but it starts where the answer probably is.
Dijkstra picks the vertex minimising g(v): the cost to reach it. A* picks the vertex
minimising
f(v) = g(v) + h(v)
where h(v) is a heuristic: a guess at the remaining cost from
v to the goal. That is the entire difference. The code is Dijkstra with one addition
inside the push.
import heapq
def astar(graph, src, goal, h):
"""h(v) estimates the remaining cost from v to goal. Must never overestimate."""
g = {v: float("inf") for v in graph}
prev = {v: None for v in graph}
g[src] = 0
heap = [(h(src), src)] # priority is f = g + h
done = set()
while heap:
_, u = heapq.heappop(heap)
if u in done:
continue
if u == goal: # popping the goal IS the lock-in
break
done.add(u)
for v, w in graph[u]:
ng = g[u] + w
if ng < g[v]:
g[v] = ng
prev[v] = u
heapq.heappush(heap, (ng + h(v), v)) # <-- the only new part
return g[goal], prevAdmissible: h(v) ≤ the true remaining cost, for every
v. The heuristic may under-guess as much as it likes but must never over-guess. An
overestimate can convince A* that the correct route is expensive and talk it into a worse one.
Consistent (or monotone): h(u) ≤ w(u, v) + h(v) for every edge
— a triangle inequality on the guess. Consistency implies admissibility, and it is what lets
you keep the done set: with a merely admissible heuristic a vertex can need re-opening
after it was finalised.
h(v) = 0 is admissible and consistent for every graph, and makes A* into Dijkstra
exactly. So Dijkstra is the special case of A* that refuses to guess.
Good heuristics are geometric or otherwise structural — they exploit something you know about the problem that the graph itself does not encode:
| Problem | Heuristic | Admissible because |
|---|---|---|
| Roads, travel distance | Straight-line distance | No road is shorter than the crow's flight |
| Grid, 4-way movement | Manhattan distance |Δx| + |Δy| | Every step changes x or y by one |
| Grid, 8-way movement | Chebyshev distance max(|Δx|, |Δy|) | A diagonal step changes both by one |
| Roads, travel time | Straight-line / max speed limit | Nothing legal is faster |
| 15-puzzle | Sum of tile Manhattan distances | Each move fixes at most one tile by one |
| Anything at all | 0 | Trivially. This is Dijkstra. |
“Straight-line distance in metres” added to “cost in
minutes” is not a heuristic, it is a unit error. g and h
must be in the same units or the comparison is meaningless.
And an inadmissible heuristic — typically someone multiplying a good one by 3 to make the search faster — does make it faster. It also stops guaranteeing the shortest path. That is sometimes a deliberate trade (it is called weighted A*), but it should be deliberate.
7Picking the right one
Five algorithms, five preconditions. The decision takes about ten seconds once you know what to ask.
Ask, in order: are any weights negative? do I need all pairs? is the graph acyclic? do I know where the goal is? are the weights only 0 and 1?
| Situation | Use | Cost |
|---|---|---|
| All weights equal (or 1) | BFS | O(V + E) |
| Weights are only 0 or 1 | 0-1 BFS with a deque | O(V + E) |
| Non-negative weights, one source | Dijkstra | O(E log V) |
| Non-negative weights, known goal, good heuristic | A* | O(E log V), far fewer expansions |
| Any weights, one source | Bellman-Ford | O(V · E) |
| Any weights, the graph is a DAG | Topological order + one relaxation pass | O(V + E) |
| Any weights, all pairs, small V | Floyd-Warshall | O(V³) |
0-1 BFS deserves a mention because it is so cheap to write and so easy to miss. When every weight is 0 or 1, a plain deque replaces the heap: relax across a 0-edge and push the vertex to the front, relax across a 1-edge and push it to the back. The deque stays sorted by construction, so you get Dijkstra's answer at BFS's price.
from collections import deque
def zero_one_bfs(graph, src):
"""Every weight must be 0 or 1."""
dist = {v: float("inf") for v in graph}
dist[src] = 0
dq = deque([src])
while dq:
u = dq.popleft()
for v, w in graph[u]:
if dist[u] + w < dist[v]:
dist[v] = dist[u] + w
if w == 0:
dq.appendleft(v) # free: it belongs with the current layer
else:
dq.append(v) # costs 1: it belongs to the next layer
return dist- Multiplicative costs (path reliability = product of edge probabilities): take
-logof each weight and the product becomes a sum. Maximising probability becomes minimising distance, and Dijkstra applies unchanged. - Widest path (maximise the smallest edge on the route — bandwidth,
load rating): keep Dijkstra's structure but replace
d + wwithmin(d, w)and pop the largest. The lock-in argument survives the swap. - Johnson's algorithm: all-pairs on a sparse graph with negative edges. Run
Bellman-Ford once to compute a potential that makes every weight non-negative, then run Dijkstra
from each vertex.
O(V·E log V), which beatsO(V³)when the graph is sparse.
Relaxation plus a well-chosen order is a template, not just a shortest-path technique. Chapter 25 opens with dynamic programming and you will notice the same shape immediately: a table of best known values, a rule for improving an entry from its predecessors, and an evaluation order that guarantees the predecessors are final before you read them.
Bellman-Ford is a DP over “best cost using at most k edges”. Dijkstra is a greedy algorithm with a proof (Chapter 24). The DAG version is DP in topological order (Chapter 21). Three chapters converge here.
What to carry forward
- The cost of a path is the sum of its weights. BFS answers a different question — fewest edges — and only coincides when all weights are equal.
- Relaxation is the whole chapter:
if dist[u] + w < dist[v]: dist[v] = dist[u] + w. Every algorithm is a different schedule for it. - Dijkstra repeatedly locks in the closest unfinished vertex. Safe only because remaining edges cost at least 0 — that clause is the whole precondition.
- Use lazy deletion: push a duplicate rather than decrease a key, and skip vertices already finalised.
O(E log V)with a plain binary heap. - One negative edge makes Dijkstra silently wrong. It does not crash and does not warn.
- Bellman-Ford relaxes every edge V−1 times in any order, so it needs no non-negativity assumption. A V-th round that still improves proves a negative cycle.
- A* is Dijkstra prioritised by
f = g + h. It is correct whilehnever overestimates (admissible), and keeps the closed set whilehis consistent. h = 0turns A* back into Dijkstra, which is the cleanest way to remember that A* is a generalisation and not a different algorithm.- Store
prevpointers, not paths. Reconstruct backwards at the end. - Special cases pay: 0-1 BFS with a deque, topological order on a DAG, -log for multiplicative costs.
>_Playground
All three algorithms on the same graph, then a negative edge that breaks one of them, then A* counting how much work it saved.
✓Exercises
Checked automatically the moment you submit. Work top to bottom — each one assumes the last. Your answers are saved in this browser.
Chapter 23 — Minimum Spanning Trees and Union-Find
Connect every town with the least cable. The answer needs a structure that can answer 'are these two already connected?' faster than seems possible.