Ch 22 / 30 Shortest Paths: Dijkstra, Bellman-Ford, A* 0/0 exercises Exercises ↓

AmouAI Hub/Courses/Data Structures & Algorithms/Chapter 22

Part 5 · Graphs · 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.

Reading
CLRS Ch. 24
Focus
Relaxation
Cost
O(E log V)
Needs
Heaps, Ch. 17

By the end of this chapter you can

  1. Explain why BFS stops working the moment edges carry weights
  2. Implement relaxation, and describe every shortest-path algorithm as a relaxation schedule
  3. Write Dijkstra with a binary heap and lazy deletion, and state its exact precondition
  4. Write Bellman-Ford, detect a negative cycle, and say when you would accept its slower running time
  5. Add a heuristic to get A*, and state what makes a heuristic admissible and consistent
  6. 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.

Two routes home

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.

The tempting wrong fix

“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 s to t passes through v, then the part from s to v is itself a shortest s–v path. 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 prev dictionary 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:

relax.py
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 good
A noticeboard of best offers

Picture 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 to v, 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.

One sentence per algorithm
  • 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.

Why the lock-in is safe

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.

dijkstra.pythe standard version
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, prev
Lazy deletion, and why there is no decrease_key

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

path.py
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]
Stopping early

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.

Dijkstra, binary heap
O(E log V)
Dijkstra, Fibonacci heap
O(E + V log V)
Dijkstra, scan for the min
O(V²)

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:

broken.pyDijkstra gets this wrong
#        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:

broken2.py
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.
The failure is silent

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.

Cashback at the toll booth

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.

The counting argument

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.

bellman_ford.py
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, prev

That 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 early exit is not a micro-optimisation

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.

DijkstraBellman-Ford
TimeO(E log V)O(V · E)
Negative edgesWrong answer, silentlyCorrect
Negative cyclesWrong answer, silentlyDetected and reported
Needs a heapYesNo — just a list of edges
ParallelisesPoorly (inherently sequential)Well (each round is independent)
Typical useAlmost everythingCurrency arbitrage, difference constraints, distance-vector routing
Rumours in an office

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.

Searching a city for one address

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.

astar.pyspot the one changed line
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], prev
Admissible and consistent

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

ProblemHeuristicAdmissible because
Roads, travel distanceStraight-line distanceNo road is shorter than the crow's flight
Grid, 4-way movementManhattan distance |Δx| + |Δy|Every step changes x or y by one
Grid, 8-way movementChebyshev distance max(|Δx|, |Δy|)A diagonal step changes both by one
Roads, travel timeStraight-line / max speed limitNothing legal is faster
15-puzzleSum of tile Manhattan distancesEach move fixes at most one tile by one
Anything at all0Trivially. This is Dijkstra.
The heuristic that ruins everything

“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?

SituationUseCost
All weights equal (or 1)BFSO(V + E)
Weights are only 0 or 10-1 BFS with a dequeO(V + E)
Non-negative weights, one sourceDijkstraO(E log V)
Non-negative weights, known goal, good heuristicA*O(E log V), far fewer expansions
Any weights, one sourceBellman-FordO(V · E)
Any weights, the graph is a DAGTopological order + one relaxation passO(V + E)
Any weights, all pairs, small VFloyd-WarshallO(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.

zero_one.pyDijkstra without a heap
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
Reweighting tricks worth recognising
  • Multiplicative costs (path reliability = product of edge probabilities): take -log of 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 + w with min(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 beats O(V³) when the graph is sparse.
What this chapter is really teaching

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 while h never overestimates (admissible), and keeps the closed set while h is consistent.
  • h = 0 turns A* back into Dijkstra, which is the cleanest way to remember that A* is a generalisation and not a different algorithm.
  • Store prev pointers, 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.

scratch.pypython not loaded
Real Python, running in your browser. Nothing is installed or uploaded.
Output appears here. The first run takes a few seconds while Python loads.

Exercises

Checked automatically the moment you submit. Work top to bottom — each one assumes the last. Your answers are saved in this browser.

All Warm-up Core Challenge Reset chapter

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.

Continue →