Algorithms Β· Graph Theory
πŸ“… July 2026 ⏱ β‰ˆ 11 min read 🎯 Intermediate Β· Last updated: 9 July 2026

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.

TL;DR: This article proves why Dijkstra's algorithm actually finds shortest paths, using two facts: distance estimates never drop below the true value, and the vertex picked next always already has its final correct distance. It also shows, with a concrete counterexample, why negative edge weights break this proof, and gives a priority-queue implementation with its complexity.

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.

Invariant we must prove: at the moment vertex u is "settled" (removed from the frontier for the last time), d[u] = Ξ΄(s, u)

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

RELAX(u, v, w):
  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:

d[v]_new = d[u] + w(u, v) β‰₯ Ξ΄(s, u) + w(u, v) β‰₯ Ξ΄(s, v)
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:

d[y] ≀ Ξ΄(s, x) + w(x, y) = Ξ΄(s, y)  (by the inductive hypothesis for x, and since y lies on a shortest path through x)
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 crux: the step "d[u] ≀ d[y]" only holds because the algorithm always extracts the minimum unsettled d-value. And the step "Ξ΄(s,y) ≀ Ξ΄(s,u)" only holds because weights are non-negative β€” a path can never get shorter by continuing past y. Both steps are exactly where negative weights would break the proof.

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.

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.

StepSettledd[a]d[b]Correct?
0{s}21βœ“ so far
1{s, b}21βœ— b settled too early β€” aβ†’b not yet relaxed
2{s, a, b}21βœ— true Ξ΄(s,b) = 2+(βˆ’3) = βˆ’1, but d[b] frozen at 1
Root cause: the proof's induction step assumed "everything after y on the shortest path is at least as far as y" β€” which requires non-negative weights. With negative weights, a path can keep getting shorter after being settled, and greedy "settle the minimum and never revisit" is no longer safe. Bellman-Ford (O(VE), allows negative weights, detects negative cycles) or Johnson's algorithm (re-weighting + Dijkstra) are the correct tools when negative edges are possible.

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 };
}
"Stale heap entry β€” skip": a standard binary heap doesn't support efficient decrease-key, so most implementations simply push a new (vertex, distance) pair on every relaxation and discard stale, already-settled entries when popped. This costs a constant factor in heap size but keeps the code simple.

Complexity summary

Priority queueExtract-minDecrease-keyTotal complexity
Array (linear scan)O(V)O(1)O(VΒ²)
Binary heapO(log V)O(log V)O((V + E) log V)
Fibonacci heapO(log V) amortisedO(1) amortisedO(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.

β–Ά Live Demo

πŸ—ΊοΈ Watch Dijkstra run live

Step through the relaxation invariant on a maze grid, compare it to A* and BFS side-by-side

Open simulation β†’

πŸ”— Related Simulations

πŸ—ΊοΈPathfinding πŸ—οΈMaze Algorithms 🀝TSP 🎲Markov Chains