Dijkstra's algorithm: a proof of correctness
Everyone learns to run Dijkstra's algorithm; far fewer learn why it works. The proof hinges on a single invariant (a property that stays true at every step of the algorithm) maintained across every iteration, and it explains exactly why the algorithm silently gives wrong answers the moment a negative edge weight appears.
Problem setup and notation
Let G = (V, E) be a directed, weighted graph with non-negative edge weights w(u, v) β₯ 0, and a source vertex s. Define Ξ΄(s, v) as the true shortest-path distance from s to v (the quantity we want to compute). Dijkstra's algorithm maintains, for every vertex v, an estimate d[v] which starts at β (except d[s] = 0) and only ever decreases.
The algorithm
Dijkstra maintains a set S of "settled" vertices (whose shortest distance is finalised) and repeatedly extracts the unsettled vertex u with the smallest d[u], adding it to S, then relaxes every outgoing edge (u, v):
if d[v] > d[u] + w(u, v):
d[v] = d[u] + w(u, v)
prev[v] = u
The entire proof of correctness rests on one property of RELAX: it never makes d[v] smaller than Ξ΄(s, v) β it can only approach the truth from above, never overshoot below it.
The relaxation invariant
Lemma (upper-bound property): at all times during the algorithm's execution, d[v] β₯ Ξ΄(s, v) for every vertex v.
Proof by induction on the number of RELAX calls. Initially d[s] = 0 = Ξ΄(s, s) and d[v] = β β₯ Ξ΄(s, v) for all other v β the base case holds trivially. Suppose the invariant holds before a call RELAX(u, v, w). If the call changes d[v], the new value is d[u] + w(u, v). By the inductive hypothesis d[u] β₯ Ξ΄(s, u), so:
last step: Ξ΄(s,v) β€ Ξ΄(s,u) + w(u,v) is the triangle inequality for shortest paths β going through u can never beat the direct shortest path
So the invariant is preserved by every RELAX call, and by induction holds throughout execution. This single fact β d never dips below the truth β is the load-bearing wall of the entire proof.
Proof by induction on settled order
Theorem: when Dijkstra's algorithm adds vertex u to S (settles it), d[u] = Ξ΄(s, u).
Proof by strong induction on the order in which vertices are settled. Let u be the k-th vertex settled, and assume the theorem holds for the first k β 1 settled vertices.
Consider the true shortest path P from s to u. Let y be the first vertex on P that is not yet in S at the moment u is settled (y = u is possible if the whole path is already settled up to u), and let x be its predecessor on P (x β S, or x = s). Because all edge weights are non-negative, and because x was settled before u, RELAX(x, y, Β·) was called when x was settled, so by the invariant proved above:
combined with the upper-bound lemma: d[y] β₯ Ξ΄(s, y) β d[y] = Ξ΄(s, y)
Now, because edge weights are non-negative, every vertex after y on path P (including u itself) is at least as far from s as y is: Ξ΄(s, y) β€ Ξ΄(s, u). But Dijkstra chose u (not y) as the vertex with minimum d-value among all unsettled vertices, so d[u] β€ d[y] = Ξ΄(s, y) β€ Ξ΄(s, u). Combined with the upper-bound lemma d[u] β₯ Ξ΄(s, u), we get d[u] = Ξ΄(s, u). β
The greedy exchange argument
An equivalent, more intuitive way to see the same result: Dijkstra is a greedy algorithm, and greedy algorithms are correct exactly when a "greedy choice property" plus "optimal substructure" both hold.
- Optimal substructure: any sub-path of a shortest path is itself a shortest path between its endpoints β trivially true, since a shorter sub-path would give a shorter overall path.
- Greedy-choice property: among all unsettled vertices, the one with the smallest tentative distance already has its final, correct distance β this is precisely what the induction above establishes.
Because both properties hold under non-negative weights, the exchange argument concludes: replacing the greedy choice with any other choice cannot improve the solution, so the greedy strategy is optimal at every step, and therefore globally optimal.
Why negative weights break the proof
Consider s β a (weight 2), s β b (weight 1), a β b (weight β3). After relaxing both edges out of s, d[a] = 2 and d[b] = 1, so Dijkstra settles b first (smallest tentative distance) β before vertex a, which holds the only edge that could improve b's distance, has even been processed. Once b is settled, the algorithm never relaxes an edge into it again.
| Step | Settled | d[a] | d[b] | Correct? |
|---|---|---|---|---|
| 0 | {s} | 2 | 1 | β so far |
| 1 | {s, b} | 2 | 1 | β b settled too early β aβb not yet relaxed |
| 2 | {s, a, b} | 2 | 1 | β true Ξ΄(s,b) = 2+(β3) = β1, but d[b] frozen at 1 |
Priority-queue implementation
function dijkstra(graph, source) {
// graph: Map<vertex, Array<[neighbor, weight]>>, all weight >= 0
const dist = new Map();
const prev = new Map();
const settled = new Set();
const pq = new MinHeap(); // binary min-heap keyed by distance
for (const v of graph.keys()) dist.set(v, Infinity);
dist.set(source, 0);
pq.push(source, 0);
while (!pq.isEmpty()) {
const u = pq.pop(); // vertex with smallest tentative distance
if (settled.has(u)) continue; // stale heap entry β skip
settled.add(u); // u is now permanently finalised
for (const [v, w] of graph.get(u)) {
if (settled.has(v)) continue;
const candidate = dist.get(u) + w;
if (candidate < dist.get(v)) { // RELAX step
dist.set(v, candidate);
prev.set(v, u);
pq.push(v, candidate);
}
}
}
return { dist, prev };
}
Complexity summary
| Priority queue | Extract-min | Decrease-key | Total complexity |
|---|---|---|---|
| Array (linear scan) | O(V) | O(1) | O(VΒ²) |
| Binary heap | O(log V) | O(log V) | O((V + E) log V) |
| Fibonacci heap | O(log V) amortised | O(1) amortised | O(E + V log V) |
For dense graphs (E β VΒ²) the plain array implementation is actually fastest in practice due to low constant factors. For sparse graphs β the common case in road networks and game maps β the binary-heap version is the standard choice.
πΊοΈ Watch Dijkstra run live
Step through the relaxation invariant on a maze grid, compare it to A* and BFS side-by-side