Module 23 · Graphs

Shortest Paths (BFS, Dijkstra)

Concept~14 min

What "shortest" means depends on the graph

"Find the shortest path from A to B" sounds like one problem, but the right algorithm changes completely depending on one property: are the edges weighted? An unweighted graph (or one where every edge costs exactly 1) has a shortest path that means "fewest edges" — plain BFS (this module's DFS & BFS lesson) already solves this, though it's worth re-deriving why, since the same argument sets up why weighted graphs need something stronger. A weighted graph's shortest path means "minimum total edge weight," and once weights differ, BFS's guarantee breaks — a path with more edges can still have lower total weight than a path with fewer, bigger ones. This lesson covers both: why BFS solves the unweighted case, and Dijkstra's algorithm for the non-negative- weighted case.

Picture a road network of towns connected by streets, and a GPS trying to find the shortest route between two of them. If every street were exactly one block long, "fewest turns" and "shortest distance" would be the same question — count the turns, you've counted the blocks. That's the unweighted case, and it's why plain BFS (which only ever counts edges) gets it right. But real streets aren't uniform: a highway segment might cover ten blocks' worth of distance in one "turn," while a side street covers one. The instant that's true, the route with fewer turns can easily be the LONGER one in actual miles — and a GPS that only counted turns would send you the wrong way.

0123456unweighted shortest path = BFS layer distance

BFS finds shortest paths, but only in unweighted graphs

BFS explores the graph in layers: everything reachable in 1 edge, then everything newly reachable in 2 edges, then 3, and so on — this is the level-order idea from Module 17 applied to a general graph instead of a tree. Because BFS never visits a vertex through a longer path before trying every shorter path first (it exhausts each "distance layer" completely before advancing to the next), the first time BFS reaches any vertex, it has done so via a shortest (fewest-edges) path to it. This is provable by induction on distance: every vertex at true distance d has at least one neighbor at true distance d - 1 (by definition of shortest path), and by the inductive hypothesis every distance-(d-1) vertex is discovered by BFS at exactly the moment its layer is processed — so the distance-d vertex is discovered no later than the following layer, i.e. at distance d, and not before (since a vertex at true distance d has no neighbor at distance < d - 1, so it cannot be reached in fewer than d steps by any path, BFS or otherwise).

This guarantee depends entirely on every edge costing the same (implicitly, 1). The instant edges carry different weights, "fewest edges" and "least total weight" can disagree — a two-edge path of weights [1, 1] is worse than a three-edge path of weights [0.5, 0.5, 0.5], and BFS, which only counts edges, has no way to notice.

Dijkstra's algorithm: greedy relaxation with a priority queue

Dijkstra's algorithm computes shortest paths from a single source in a graph with non-negative edge weights (the non-negative requirement matters — see below). The idea generalizes BFS's "process in increasing order of distance" strategy, but since edges no longer all cost the same, a priority queue (not a plain FIFO queue) is needed to always process the CURRENTLY closest unfinalized vertex next, whatever its edge-count distance happens to be — back to the road trip: instead of visiting towns in "number of turns away" order, you always drive next to whichever not-yet-visited town is currently closest by total miles driven so far, wherever on the map that happens to be.

Maintain a dist[] array (best known distance to each vertex, starting at infinity except the source at 0) and a min-heap of (distance, vertex) pairs. Repeatedly pop the vertex with the smallest known distance; if it's already been finalized, skip it (a stale heap entry — see below); otherwise, finalize it, and relax every outgoing edge: for edge (u, v, w), if dist[u] + w < dist[v], update dist[v] and push (dist[v], v) onto the heap.

import heapq

def dijkstra(num_vertices: int, adj: list[list[tuple[int, int]]], src: int) -> list[float]:
    # adj[u] = list of (neighbor, weight) — the weighted adjacency list
    # from the Graph Representation concept lesson.
    dist = [float("inf")] * num_vertices
    dist[src] = 0
    heap = [(0, src)]                 # (distance, vertex)

    while heap:
        d, u = heapq.heappop(heap)
        if d > dist[u]:
            continue                  # stale entry — a better one was already processed
        for v, w in adj[u]:
            if d + w < dist[v]:       # relax: found a shorter path to v through u
                dist[v] = d + w
                heapq.heappush(heap, (dist[v], v))

    return dist

The stale-entry check (if d > dist[u]: continue) is not defensive programming — it's load-bearing. Because a vertex can be pushed onto the heap multiple times (once per relaxation that improves its distance), the heap can hold several entries for the same vertex simultaneously, with different (decreasing) distances. Only the entry matching the CURRENT best-known dist[u] is meaningful; any entry popped later with a larger d is a leftover from an earlier, since-improved-upon relaxation and must be skipped, or the algorithm could re-relax edges using a worse distance than what's already been established.

Trace it on a different small graph — source S, with S → X (weight 10), S → Y (weight 3), Y → X (weight 2) — to keep this separate from the A/B/C example just ahead. Pop (0, S), finalize S, relax both its edges: push (10, X) and (3, Y). Pop (3, Y) — matches dist[Y] = 3, so finalize Y, and relax Y → X: dist[Y] + 2 = 5 < dist[X] = 10, so update dist[X] = 5 and push (5, X). The heap now holds two entries for X: the stale (10, X) from S's relaxation and the fresh (5, X) from Y's. Pop (5, X) next (it's smaller) — matches the current dist[X] = 5, so finalize X at its true shortest distance. Eventually (10, X) is popped too, but by then 10 > dist[X] = 5, so the check fires and it's skipped — without it, this stale entry would re-finalize X at the wrong, larger distance.

Why Dijkstra fails with negative edge weights

The greedy step "pop the smallest known distance and finalize it permanently" assumes that once a vertex is popped, no future discovery can ever produce a shorter path to it — true only when all remaining edge weights are non-negative (adding a non-negative weight to an already-larger path can never make it smaller than the just-finalized one). A negative edge breaks this: a longer path that later takes a sharply negative edge can end up shorter than a path that looked optimal when it was finalized. Imagine one road segment that isn't a toll road at all but a rebate — driving it actually PAYS you, subtracting from your trip's total cost. Dijkstra locks in a town's final distance and never revisits it, the moment it's popped as the current cheapest — so if the rebate road is discovered only after a town has already been locked in, there's no mechanism left to go back and say "actually, the longer-looking route through the rebate road was cheaper after all."

Concretely: vertices A, B, C with edges A→B (weight 5), A→C (weight 2), C→B (weight -10). Dijkstra processes A (distance 0), then greedily picks the smallest next: C (distance 2) before B (distance 5) — correct so far. But after finalizing C, relaxing C→B gives dist[B] = 2 + (-10) = -8, which is far better than the seemingly-final dist[B] = 5 computed earlier. If B had been popped and finalized before C (which could happen with a different graph shape), Dijkstra would have locked in the wrong, too-large answer for B and never revisited it. The algorithm has no mechanism to "un-finalize" a vertex, so negative edges can produce silently incorrect results.

Bellman-Ford: the fallback for negative weights

Bellman-Ford handles negative edge weights (as long as there's no negative cycle, which would make "shortest path" undefined — you could loop the cycle forever, decreasing the total without bound). Instead of greedily finalizing vertices in distance order, it relaxes every edge, V − 1 times (a straightforward loop, no priority queue), which is enough because any shortest path has at most V - 1 edges, and each full pass is guaranteed to correctly extend the confirmed shortest paths by at least one more edge. The cost follows directly from that shape: an outer loop of V - 1 passes, each doing an inner loop over all E edges — (V - 1) × E relaxations total, which is O(V · E) — markedly worse than Dijkstra's O((V + E) log V), which is precisely the price paid for tolerating negative edges: no greedy shortcut is safe, so every edge must be reconsidered repeatedly rather than each vertex being settled once. Bellman-Ford is not implemented in full here; know its existence, its cost, and the reason it's needed when Dijkstra's non-negative assumption doesn't hold.

Complexity

OperationCostWhy
BFS (unweighted shortest path)O(V + E)every vertex and edge visited once, exactly as in ordinary BFS
Dijkstra (non-negative weights)O((V + E) log V)each vertex popped once (amortized, ignoring stale entries which are O(1) to skip), each edge triggers at most one heap push, each heap operation is O(log V)
Bellman-Ford (tolerates negative weights)O(V · E)V-1 full passes over all E edges, since no greedy shortcut is safe without a non-negativity guarantee

Check yourself

3 questions

01

Why does plain BFS correctly compute shortest paths in an unweighted graph, but fail to do so once edges have different weights?

02

In Dijkstra's algorithm, why is the check 'if d > dist[u]: skip this heap entry' necessary for correctness, rather than just being a minor optimization?

03

Why specifically does a negative edge weight break Dijkstra's greedy 'finalize the vertex with the smallest known distance, permanently' strategy?