The previous lesson ended with a thorn in our side: BFS insists the Hospital is "2 segments" from the Warehouse… via a congested 16-minute avenue, when a 13-minute path exists. When edges have weights, counting segments no longer works: you have to add up minutes. This lesson presents the three classic shortest-path algorithms: Dijkstra (single source, non-negative weights — here we finally cash in the heap seed we planted in 01-04), Bellman-Ford (single source, allows negative weights and detects negative cycles) and Floyd-Warshall (all pairs, pure dynamic programming, direct heir of 01-03). At the end we will build the matrix of real travel times between all of Rutalia's zones — the same kind of matrix that fed the TSP distances in module 2.

Contents

  1. Why BFS is not enough when edges have weights
  2. Dijkstra: the priority queue cashes in its seed
  3. Dijkstra's invariant and its complexity
  4. Why Dijkstra fails with negative weights
  5. Bellman-Ford: relax to exhaustion
  6. Negative cycles: when the model breaks
  7. Floyd-Warshall: all pairs with dynamic programming
  8. Rutalia's travel-time matrix and its connection to the TSP
  9. Comparison table and a mention of A*

Why BFS is not enough when edges have weights

BFS processes nodes by number of edges from the source, and its correctness depends on "discovered earlier = closer". With weights, that breaks: in the canonical network, ALM→CEN via the direct avenue is 1 edge and 12 minutes, while ALM→MER→CEN is 2 edges and 9 minutes. BFS "closes" CEN at level 1 and never reconsiders.

The conceptual repair is elegant: instead of processing nodes in discovery order (FIFO queue), process them by provisional accumulated distance, always the closest one first. Which structure efficiently hands over "the current minimum" among changing candidates? Exactly the one we studied in 01-04 for prioritizing orders: the binary heap (heapq). Dijkstra is, literally, BFS with the queue swapped for a heap.

Dijkstra: the priority queue cashes in its seed

import heapq

# Canonical graph of Rutalia (03-01): {node: {neighbor: minutes}}
NETWORK = {
    "ALM": {"RIO": 3, "MER": 4, "EST": 7, "CEN": 12},
    "MER": {"ALM": 4, "CEN": 5, "UNI": 6},
    "EST": {"ALM": 7, "UNI": 3, "IND": 9},
    "UNI": {"MER": 6, "EST": 3, "CEN": 8, "HOS": 5},
    "RIO": {"ALM": 3, "CEN": 6, "PAR": 8},
    "CEN": {"ALM": 12, "MER": 5, "UNI": 8, "RIO": 6, "HOS": 4},
    "IND": {"EST": 9, "HOS": 6},
    "HOS": {"UNI": 5, "CEN": 4, "IND": 6, "PAR": 7},
    "PAR": {"RIO": 8, "HOS": 7},
}

def dijkstra(graph, source):
    """Minimum distances in minutes from source, plus the parent tree."""
    dist = {source: 0}
    parent = {source: None}
    settled = set()                      # nodes whose distance is already FINAL
    heap = [(0, source)]                 # (provisional distance, node)

    while heap:
        d, u = heapq.heappop(heap)       # the CLOSEST candidate right now
        if u in settled:
            continue                     # stale heap entry: ignore
        settled.add(u)                   # d is final for u (the invariant)

        for v, weight in graph[u].items():
            new_dist = d + weight        # reaching v through u
            if v not in dist or new_dist < dist[v]:
                dist[v] = new_dist       # RELAXATION: better path found
                parent[v] = u
                heapq.heappush(heap, (new_dist, v))
    return dist, parent

dist, parent = dijkstra(NETWORK, "ALM")
print(dist)
# {'ALM': 0, 'RIO': 3, 'MER': 4, 'EST': 7, 'CEN': 9,
#  'UNI': 10, 'PAR': 11, 'HOS': 13, 'IND': 16}

Line-by-line breakdown of the important decisions:

  • (distance, node) tuples in the heap: heapq orders tuples by their first element, so heappop always returns the node with the smallest provisional distance. It is exactly the "prioritize urgent orders" pattern from 01-04, with urgency = accumulated minutes.
  • Relaxation (new_dist < dist[v]): the atomic operation of every algorithm in this lesson. It means "I have found a faster way to reach v; update".
  • Lazy deletion (if u in settled: continue): heapq does not allow updating the priority of an already-inserted element, so when we improve dist[v] we simply insert another entry. The old (worse) versions come out later and are discarded on seeing the node already settled. It is the same stale-entries technique we used in the best-first search of branch and bound (02-03) — not by coincidence: B&B with bound = accumulated cost is a relative of Dijkstra.
  • Route reconstruction is the reconstruct(parent, target) of 03-02, unchanged: ALM→HOS returns ['ALM', 'MER', 'CEN', 'HOS'], the true 13 minutes. BFS's thorn, extracted.

A trace of the first steps, to fix the intuition (from ALM):

Step Node settled Final distance Relaxations it triggers
1 ALM 0 RIO←3, MER←4, EST←7, CEN←12
2 RIO 3 CEN←9 (beats the avenue's 12!), PAR←11
3 MER 4 CEN←9 (ties, no change), UNI←10
4 EST 7 UNI←10 (ties), IND←16
5 CEN 9 HOS←13

Look at step 2: the 12-minute direct avenue is beaten before CEN gets settled. That is the entire secret.

Dijkstra's invariant and its complexity

Invariant: when a node u leaves the heap with distance d (and gets settled), d is the true minimum distance to u. Why? Any other path to u would have to leave the settled zone through some frontier node w still in the heap. But that w has provisional distance ≥ d (if it were smaller, it would have come out before u), and from w to u you can only add non-negative weights. Bottom line: any alternative measures ≥ d. No surprise is possible.

Complexity with adjacency list + binary heap: each edge triggers at most one heap insertion ⇒ O(m) insertions/extractions costing O(log n) each (the heap holds O(m) entries, and log m = O(log n)): O((n + m) log n). For the street map of a big city (n ≈ 10⁵, m ≈ 3·10⁵), a few million operations: milliseconds. The growth hierarchy of 01-01 in action.

Why Dijkstra fails with negative weights

The invariant's argument contains a phrase with fine print: "you can only add non-negative weights". If an edge can subtract, settling nodes becomes premature. A minimal example with logistic meaning: Rutalia rewards certain segments where the van picks up returns on the way back — the segment's net cost (time minus the equivalent saving) can turn out negative.

# DIRECTED graph of net costs (equivalent minutes)
BONUS = {
    "ALM": {"MER": 4, "HOS": 9},   # there is a direct ALM->HOS route of 9
    "MER": {"CEN": 5, "UNI": 6},
    "CEN": {"HOS": 4},
    "UNI": {"HOS": -2},            # rewarded segment: picks up returns
    "HOS": {},
}

True optimal path ALM→HOS: ALM→MER→UNI→HOS = 4 + 6 − 2 = 8. But Dijkstra settles HOS as soon as it leaves the heap with 9 (the direct route), before processing UNI (which sits at 10): by the time the reward appears, HOS is already settled and the improvement is discarded. Dijkstra's result: 9. Incorrect, and worst of all it fails silently: no error, no warning. With weights that may be negative, Dijkstra is disqualified.

Bellman-Ford: relax to exhaustion

Bellman-Ford gives up the heap's cleverness and applies systematic force: relax every edge, and repeat the pass |V| − 1 times.

def bellman_ford(graph, source):
    """Shortest paths with negative weights. Detects negative cycles."""
    import math
    dist = {u: math.inf for u in graph}
    parent = {u: None for u in graph}
    dist[source] = 0

    edges = [(u, v, w) for u in graph for v, w in graph[u].items()]

    for _ in range(len(graph) - 1):          # |V| - 1 passes
        changed = False
        for u, v, w in edges:
            if dist[u] + w < dist[v]:        # the same relaxation as always
                dist[v] = dist[u] + w
                parent[v] = u
                changed = True
        if not changed:
            break                            # already stable: we can stop early

    # Extra pass number |V|: if we can STILL relax, there is a negative cycle
    for u, v, w in edges:
        if dist[u] + w < dist[v]:
            raise ValueError(f"Reachable negative cycle (affects {v})")
    return dist, parent

dist, _ = bellman_ford(BONUS, "ALM")
print(dist["HOS"])   # 8  -> correct: it exploits the reward

Why do |V| − 1 passes suffice? A simple shortest path uses at most |V| − 1 edges. After pass k, all shortest paths of up to k edges are guaranteed (direct induction). The price of this robustness: O(n · m), far above Dijkstra's cost — on the 10⁵-node street map we are talking ~3·10¹⁰ operations versus Dijkstra's milliseconds. Generality has its price.

Dijkstra Bellman-Ford
Strategy Greedy: settle the closest Exhaustive relaxation in rounds
Negative weights No Yes
Detects negative cycles No Yes (extra pass)
Cost O((n+m) log n) O(n · m)

Negative cycles: when the model breaks

A negative-weight cycle is a cycle whose weights sum to < 0. If it is reachable, "shortest path" stops making sense: one more lap always improves things, all the way to −∞. In Rutalia it would appear if the rewards were badly calibrated: add to BONUS the edge HOS→MER with cost −9 and the cycle MER→UNI→HOS→MER sums 6 − 2 − 9 = −5. A van would "gain minutes" by circling forever — an unmistakable sign that the incentive model is broken, not that we have discovered perpetual motion. That is why the detection in pass |V| is no ornament: it is a model validation. (In finance, the same test detects arbitrage opportunities in currency cycles.)

Floyd-Warshall: all pairs with dynamic programming

Dijkstra and Bellman-Ford answer "how far is everything from this source?". Rutalia needs something more ambitious: the table of travel times between all zones. We could run Dijkstra from every node (perfectly valid: n runs), but there is an algorithm of remarkable elegance that does it directly on the adjacency matrix of 03-01: Floyd-Warshall.

Its DP subproblem is a gem of a definition (compare with the grid of 01-03, where the subproblem was "best cost up to cell (i,j)"):

D_k[i][j] = minimum cost from i to j using as intermediate nodes only the first k nodes of the list.

  • Base case D₀: the adjacency matrix (no intermediates: direct edges only).
  • Transition: when node k becomes allowed as an intermediate, either it is not used (D_{k−1}[i][j] stands) or it is used exactly once (D_{k−1}[i][k] + D_{k−1}[k][j]):
import math

def floyd_warshall(nodes, graph):
    n = len(nodes)
    idx = {v: i for i, v in enumerate(nodes)}
    D = [[math.inf] * n for _ in range(n)]
    for i in range(n):
        D[i][i] = 0
    for u in graph:
        for v, w in graph[u].items():
            D[idx[u]][idx[v]] = w

    for k in range(n):                   # intermediate node being enabled
        for i in range(n):
            for j in range(n):
                if D[i][k] + D[k][j] < D[i][j]:
                    D[i][j] = D[i][k] + D[k][j]   # going through k pays off
    return D

NODES = ["ALM", "MER", "EST", "UNI", "RIO", "CEN", "IND", "HOS", "PAR"]
D = floyd_warshall(NODES, NETWORK)

Details that matter:

  • The loop order is sacred: k (the intermediate) goes outside. With k inside, the algorithm is simply wrong. It is the most frequent mistake when writing it from memory.
  • As in the bottom-up DP of 01-03, we overwrite a single matrix instead of storing the n layers D_k: it can be shown that reusing already-updated values from layer k does not break correctness (it can only bring valid improvements forward).
  • Complexity: three full loops ⇒ O(n³) and O(n²) memory. For 9 zones, 729 steps: nothing. For 1,000 nodes, 10⁹: the borderline. For an entire street map: unworkable — there you use n runs of Dijkstra over an adjacency list, or the techniques of 06-03.
  • It admits negative weights (without negative cycles); a negative cycle betrays itself because some D[i][i] ends up < 0. A free test.

Rutalia's travel-time matrix and its connection to the TSP

Result of running the code on the canonical network — the matrix of minimum travel times in minutes between all zones:

ALM MER EST UNI RIO CEN IND HOS PAR
ALM 0 4 7 10 3 9 16 13 11
MER 4 0 9 6 7 5 15 9 15
EST 7 9 0 3 10 11 9 8 15
UNI 10 6 3 0 13 8 11 5 12
RIO 3 7 10 13 0 6 16 10 8
CEN 9 5 11 8 6 0 10 4 11
IND 16 15 9 11 16 10 0 6 13
HOS 13 9 8 5 10 4 6 0 7
PAR 11 15 15 12 8 11 13 7 0

Observe: D[ALM][CEN] = 9 (not 12: nobody in their right mind takes the congested avenue) and D[ALM][HOS] = 13 (the 3-segment path BFS scorned). The matrix is symmetric because the graph is undirected.

And here a circle closes with module 2: this matrix is what fed the TSP. In 02-02 we used Euclidean distances between the 10 stops for didactic simplicity, but in a real city the van does not fly in a straight line: the "distance" between two stops is the shortest-path time through the streets. The professional workflow is exactly this pipeline: (1) street graph → (2) Floyd-Warshall or n×Dijkstra → travel-time matrix → (3) TSP/B&B/GA/ACO on that matrix. Modules 2 and 3 were never separate topics: they were the two halves of one system.

Comparison table and a mention of A*

Algorithm Answers Negative weights Negative cycles Cost Ideal structure
BFS (03-02) Single source, unweighted edges O(n + m) Adjacency list
Dijkstra Single source No Not tolerated O((n+m) log n) List + heap
Bellman-Ford Single source Yes Detects them O(n·m) Edge list
Floyd-Warshall All pairs Yes Detects (D[i][i]<0) O(n³) Matrix

Selection guide: no weights → BFS; weights ≥ 0 and one source → Dijkstra; possible negative weights or a need to validate the model → Bellman-Ford; all pairs and moderate n → Floyd-Warshall (or n runs of Dijkstra if the graph is sparse and the weights non-negative).

A passing mention: when only one specific destination matters and an estimate of what remains is available (for example, straight-line distance), there is A*: essentially "Dijkstra with a compass", steering the exploration towards the goal instead of expanding in a circle. It belongs to heuristic state-space search and we will develop it in 04-03; for now it is enough to know that its heart is exactly the Dijkstra of this lesson.

Common Mistakes and Tips

  • Using Dijkstra with negative weights: it fails silently, as we saw with the reward (it returns 9 instead of 8). Add a non-negative-weights assert when building the graph if you are going to use Dijkstra; it is the most profitable validation in this lesson.
  • Forgetting lazy deletion: without the if u in settled: continue, every stale heap entry reprocesses the node and re-relaxes its edges; the result stays correct, but the cost blows up. With it, correct and efficient.
  • Putting Floyd-Warshall's k loop innermost: it produces incorrect results that are hard to spot on small graphs (sometimes it coincides by luck). The intermediate k goes outside, ALWAYS. Verify against the ALM row of the table.
  • Stopping Bellman-Ford after |V|−1 passes without the extra pass: you swallow the negative cycles and return meaningless distances. Pass n is the one that validates the model.
  • Rerunning Dijkstra for every source-destination pair of a full table: either Floyd-Warshall, or one Dijkstra per source, reusing its result for the n−1 destinations. Never n² runs.
  • Tip: always keep parent alongside dist. A distance without its route is half an answer; the courier needs the itinerary, not just the number.

Exercises

  1. Everyday route. Using dijkstra and the reconstruct of 03-02, compute the fastest route and its duration from EST to PAR in the canonical network. Check the result against the lesson's matrix.
  2. The avenue — who is it for? The ALM–CEN edge (12 min) looks useless: Dijkstra never uses it from ALM. Write code that, using Floyd-Warshall's matrix D, checks whether the edge belongs to the shortest path of any pair of zones (hint: edge {u,v} of weight w lies on some shortest path between i and j if D[i][u] + w + D[v][j] == D[i][j], in one of the two orientations). Should Rutalia ask the city council to remove it from the street map?
  3. Treacherous reward. Starting from the BONUS graph, add the edge HOS -> MER with cost −9 and verify that bellman_ford raises the negative-cycle exception. Then find the maximum (least negative) cost that edge can have without creating a negative cycle, reasoning over the cycle MER→UNI→HOS→MER.

Solutions

Exercise 1:

dist, parent = dijkstra(NETWORK, "EST")
print(dist["PAR"])                  # 15
print(reconstruct(parent, "PAR"))   # ['EST', 'UNI', 'HOS', 'PAR']

EST→UNI (3) →HOS (5) →PAR (7) = 15 minutes, matching cell [EST][PAR] = 15 in the matrix. Two different algorithms, one truth: that is how you validate software.

Exercise 2:

idx = {v: i for i, v in enumerate(NODES)}
u, v, w = idx["ALM"], idx["CEN"], 12

used = any(
    D[i][u] + w + D[v][j] == D[i][j] or D[i][v] + w + D[u][j] == D[i][j]
    for i in range(len(NODES)) for j in range(len(NODES)) if i != j
)
print(used)   # False

The avenue takes part in the shortest path of no pair: a faster alternative always exists (at the very least, going around through MER or RIO). Remove it? Not necessarily: shortest paths describe the nominal regime. If roadworks close MER–CEN and RIO–CEN at the same time, the avenue becomes the only fast way into the Center. Redundancy ≠ uselessness — the robustness analysis of 03-02 and the flow analysis of 03-05 complete the picture that shortest paths alone don't give.

Exercise 3:

BONUS2 = {u: dict(vs) for u, vs in BONUS.items()}
BONUS2["HOS"]["MER"] = -9
try:
    bellman_ford(BONUS2, "ALM")
except ValueError as e:
    print(e)   # Reachable negative cycle...

The cycle is MER→UNI (6) →HOS (−2) →MER (x), summing 4 + x. It is negative if x < −4. Therefore the minimum admissible cost for the edge is −4 (at −4 the cycle sums 0: legal though degenerate; at −5 it is already negative and the model breaks). Operational moral: Rutalia's rewards must be calibrated by looking at the graph's cycles, not at each segment in isolation.

Conclusion

We have gone from counting segments to adding minutes. Dijkstra — BFS with the heap of 01-04, seed cashed in — solves the standard case with non-negative weights in O((n+m) log n) thanks to a greedy invariant provable in three lines; Bellman-Ford pays O(n·m) in exchange for tolerating negative rewards and detecting negative cycles, which are modeling errors dressed up as bargains; and Floyd-Warshall, dynamic programming over the matrix with the intermediate node as a dimension, delivers in O(n³) Rutalia's complete travel-time matrix — the piece connecting this module to module 2's TSP: first shortest paths over the street map, then route optimization over the resulting matrix. Dijkstra has also taught us something deeper: a greedy algorithm can be provably optimal if the problem has the right structure. In 02-02 we saw greedy algorithms fail; in the next lesson, 03-04, we meet the other star case where greed wins with a certificate: minimum spanning trees, with Kruskal — time to cash in the second seed of 01-04, the union-find — and Prim.

© Copyright 2026. All rights reserved