AI Knowledge Tree

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 O((V+E)log⁡V)O((V + E)\log V) 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 w(u,v)≥0w(u, v) \ge 0 on each edge, a source ss, and (optionally) a target tt, find the minimum-weight path from ss 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 ss.

Dijkstra's algorithm

Maintain a tentative distance d[v]d[v] for each vertex, initialized to 00 for ss and +∞+\infty for everyone else. A min-priority queue stores pairs (d,v)(d, v).

  1. Pop the vertex uu with the smallest tentative distance.
  2. If uu is already settled, skip. Otherwise mark it settled with the popped distance.
  3. Relax each outgoing edge: for (u,v)(u, v) with weight ww, if d[u]+w<d[v]d[u] + w < d[v], set d[v]=d[u]+wd[v] = d[u] + w and push (d[v],v)(d[v], v) 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 O(V+E)O(V + E), each costing O(log⁡V)O(\log V): total time O((V+E)log⁡V)O((V + E)\log V).

Why it works (the greedy invariant)

Theorem. When vertex uu is popped from the priority queue, d[u]d[u] is the true shortest-path distance from ss to uu.

Proof. Suppose not: let uu be the first vertex for which d[u]d[u] is wrong at pop time, and let PP be a true shortest path s=v0→v1→⋯→vk=us = v_0 \to v_1 \to \cdots \to v_k = u. Let viv_i be the first vertex on PP that was not yet settled when uu was popped. Its predecessor vi−1v_{i-1} was settled with its correct distance (it was popped before uu, by the choice of uu as the first failure). Relaxing (vi−1,vi)(v_{i-1}, v_i) at that time set d[vi]≤d[vi−1]+w(vi−1,vi)d[v_i] \le d[v_{i-1}] + w(v_{i-1}, v_i), which equals the true distance to viv_i. Because all weights are nonnegative, dist(s,vi)≤dist(s,u)\text{dist}(s, v_i) \le \text{dist}(s, u), so viv_i should have been popped before uu, contradiction. ■\qquad\blacksquare

The proof breaks if any weight is negative: the inequality dist(s,vi)≤dist(s,u)\text{dist}(s, v_i) \le \text{dist}(s, u) no longer holds, because adding a negative-weight edge later could undercut the distance through uu.

Where Dijkstra fails

  • Negative edges. Use Bellman-Ford: it relaxes every edge V−1V - 1 times in O(V⋅E)O(V \cdot E), handling negative weights and detecting negative cycles.
  • All-pairs shortest paths. Run Dijkstra from every vertex (O(V⋅(V+E)log⁡V)O(V \cdot (V+E)\log V)), or use Floyd-Warshall (O(V3)O(V^3), 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

python · numpy
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 AA on the graph with edges A→BA \to B (weight 4), A→CA \to C (weight 1), C→BC \to B (weight 2), C→DC \to D (weight 5), B→DB \to D (weight 1). Give the order vertices are settled and the final distances.

Solution

Step 1. Initialize d[A]=0d[A] = 0 and all others to ∞\infty. The queue holds (0,A)(0, A).

Step 2. Pop AA (d=0d = 0), settle it. Relax its edges: d[B]=4d[B] = 4, d[C]=1d[C] = 1.

Step 3. Pop the smallest, CC (d=1d = 1), settle it. Relax: A→C→BA \to C \to B gives 1+2=3<41 + 2 = 3 < 4, so d[B]=3d[B] = 3; C→DC \to D gives 1+5=61 + 5 = 6, so d[D]=6d[D] = 6.

Step 4. Pop BB (d=3d = 3), settle it. Relax B→DB \to D: 3+1=4<63 + 1 = 4 < 6, so d[D]=4d[D] = 4.

Step 5. Pop DD (d=4d = 4), settle it; no outgoing edges. Queue is drained of unsettled vertices.

Settle order: A,C,B,DA, C, B, D. Final distances: d[A]=0d[A] = 0, d[C]=1d[C] = 1, d[B]=3d[B] = 3, d[D]=4d[D] = 4.

Exercise 2. The correctness proof relies on dist(s,vi)≤dist(s,u)\text{dist}(s, v_i) \le \text{dist}(s, u) for a vertex viv_i on the shortest path to uu. 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 s,a,bs, a, b with edges s→as \to a (weight 2), s→bs \to b (weight 3), and b→ab \to a (weight −2-2).

Step 2. Dijkstra pops ss, relaxes to d[a]=2d[a] = 2 and d[b]=3d[b] = 3. It then pops aa (the smaller tentative distance, 2) and settles it as final.

Step 3. But the true shortest path to aa is s→b→as \to b \to a with weight 3+(−2)=1<23 + (-2) = 1 < 2. Dijkstra settled aa at 2 before exploring bb, and never revisits a settled vertex, so it reports 2.

Step 4. The failed proof step is exactly dist(s,a)≤dist(s,b)\text{dist}(s, a) \le \text{dist}(s, b): here dist(s,a)=1\text{dist}(s, a) = 1 but dist(s,b)=3\text{dist}(s, b) = 3, so aa'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 d[v]d[v], the count is inherited from d[u]d[u]; when an edge ties the current best, the counts add. Counting only on the settled (final) distance avoids double counting from stale queue entries.

python · numpy
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. [1]Dijkstra, E. W. (1959). "A note on two problems in connexion with graphs". Numerische Mathematik, 1, 269-271. doi:10.1007/BF01386390↩
  2. [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).↩