Back to Graph Theory Series

Dijkstra's Algorithm

August 30, 2026 Wasil Zafar 16 min read

Designed in 1956 by Edsger Dijkstra in about 20 minutes without pen or paper, as a demonstration of what a new computer could do — it remains the default answer to "what's the fastest way to get there?" nearly 70 years later.

Contents

  1. A Bit of History
  2. Working Principle
  3. Worked Example
  4. Why Greedy Works Here
  5. Complexity Analysis
  6. Implementation
  7. Real-World Applications
  8. Exercises
  9. Limitations

A Bit of History

In 1956, Dutch computer scientist Edsger W. Dijkstra was thinking about the shortest route between two cities in the Netherlands — partly to demonstrate the capabilities of a new computer to a general audience. By his own later account, he designed the algorithm in about twenty minutes, without pencil or paper, while having coffee with his fiancée at a café terrace in Amsterdam. He didn't publish it until 1959, in a short three-page paper that also introduced an algorithm for minimum spanning trees (closely related to Kruskal's algorithm and Prim's algorithm, covered in an upcoming deep dive). Dijkstra later reflected that the algorithm's elegance came from deliberately avoiding pencil and paper — forcing him to keep the method simple enough to hold entirely in his head.

Working Principle

Dijkstra's Algorithm: Always Expand the Cheapest Route First

Think of Dijkstra's algorithm like planning the cheapest route from one city to every other city.

Each edge has a cost — such as distance, travel time, or price. Starting from the source, Dijkstra always chooses the unprocessed vertex with the smallest known total cost from the source.

In other words: don't simply visit the closest-looking neighbor — continue from whichever vertex is cheapest to reach overall.

7 2 2 3 8 2 S A B C D d = 0 d = 7 d = 2 ✓ d = 5 d = 10 Cheapest route to C S → B → C 2 + 3 = 5

Notice something important in the graph above: there is a direct-looking route from S to A with cost 7.

But Dijkstra does not choose a vertex simply because it is visually nearby. Vertex B costs only 2 to reach, so B is processed first.

Key idea:

At every step, Dijkstra asks: “Which unprocessed vertex can I currently reach with the smallest total cost?”

1. Keep a tentative distance for every vertex

Dijkstra stores a value d[v] for every vertex. This means:

d[v] = cheapest route to v that we know about so far

Initially, we only know how to reach the source:

S
d[S] = 0
A
d[A] = ∞
B
d[B] = ∞
C
d[C] = ∞
D
d[D] = ∞

The value ∞ simply means: “we have not found a route to this vertex yet.”

2. Use a min-priority queue

BFS uses a normal queue, but Dijkstra uses a min-priority queue.

Instead of processing vertices in the order they were discovered, the priority queue gives us the vertex with the smallest tentative distance.

Priority queue after examining S:
B
cost = 2
<
A
cost = 7

Because 2 < 7, Dijkstra removes B first and explores routes through B.

3. Improve routes using relaxation

Whenever Dijkstra processes a vertex, it asks whether going through that vertex gives us a cheaper route to one of its neighbors.

This operation is called relaxation.

B
d[B] = 2
edge cost = 3
→
C
d[C] = 9
Route through B: d[B] + w(B,C) = 2 + 3 = 5

Since 5 < 9 , we improve C:

d[C] : 9 → 5
Relaxation:
$$ d[v] \leftarrow \min\left(d[v],\, d[u] + w(u,v)\right) $$

In plain English:

Keep the old route unless going through the current vertex gives us a cheaper one.

How Dijkstra's algorithm proceeds

  1. Set the source distance to 0 and every other distance to ∞.
  2. Put the source into a min-priority queue.
  3. Remove the vertex u with the smallest tentative distance.
  4. Its shortest distance is now finalized.
  5. Examine every edge from u to a neighbor v.
  6. If going through u gives a cheaper route to v, update d[v].
  7. Continue until there are no reachable unprocessed vertices left.
💡 Why can Dijkstra finalize the cheapest vertex?

Suppose u currently has the smallest tentative distance of every unprocessed vertex.

Because every edge weight is non-negative, taking additional edges cannot create a cheaper hidden route that somehow comes back and beats d[u]. Therefore, once u is selected as the cheapest remaining vertex, its distance is final.

⚠️ Important: no negative edge weights

Dijkstra's algorithm requires non-negative edge weights. A negative edge could make a route cheaper after Dijkstra has already finalized a vertex, breaking the algorithm's greedy assumption.

Easy way to connect BFS and Dijkstra
BFS

Every edge effectively costs 1.

Process vertices by fewest edges from the source.

Uses a FIFO queue.

Dijkstra

Edges may have different non-negative costs.

Process vertices by smallest total path cost.

Uses a min-priority queue.

Simple memory trick:

BFS asks “Which vertex is fewest edges away?”
Dijkstra asks “Which vertex is cheapest to reach?”

Analogy: Filling a Reservoir from the Cheapest Sources First

Think of \(d[v]\) as "the cheapest known price of water reaching town \(v\)." Every time you finalize the cheapest-so-far town, you check whether routing water through it makes any neighboring town's price cheaper than what you'd previously found. Because you always finalize the globally cheapest remaining option first, once a town is finalized, no future (necessarily more expensive) route could ever beat its price — which is exactly why the greedy choice is safe.

Worked Example

Consider a small weighted graph: \(A \to B\) (weight 4), \(A \to C\) (weight 1), \(C \to B\) (weight 1), \(B \to D\) (weight 1), \(C \to D\) (weight 5).

Dijkstra's Algorithm — Example Graph
flowchart LR
    A -->|4| B
    A -->|1| C
    C -->|1| B
    B -->|1| D
    C -->|5| D
            
StepFinalizedd[A]d[B]d[C]d[D]
Init—0∞∞∞
1A041∞
2C (dist 1)02 (via C)16 (via C)
3B (dist 2)0213 (via B)
4D (dist 3)0213

Notice how \(d[B]\) drops from 4 to 2 once \(C\) is finalized and its outgoing edge \(C \to B\) is relaxed — the direct edge \(A \to B\) (weight 4) was never actually the shortest route; going through \(C\) (weight \(1+1=2\)) is cheaper. This is exactly why Dijkstra's algorithm must consider all edges out of a vertex before that vertex's neighbors can be trusted as final.

Why Greedy Works Here

This is the direct-proof and induction machinery from Part 1 in action. Claim: when a vertex \(u\) is extracted from the priority queue (finalized), \(d[u]\) already equals the true shortest-path distance from the source.

Proof by induction on the order vertices are finalized. Base case: the source is finalized first with \(d[\text{source}]=0\), trivially correct. Inductive step: suppose every previously finalized vertex has a correct final distance. Let \(u\) be the next vertex extracted, with the smallest tentative distance among all unfinalized vertices. Any path to \(u\) must, at some point, leave the "finalized" set for the last time — crossing some edge \((x, y)\) where \(x\) is finalized and \(y\) is not. Since edge weights are non-negative, the length of that path is at least \(d[x] + w(x,y) \geq d[y] \geq d[u]\) (the last inequality holds because \(u\) was chosen to have the smallest tentative distance). So no unfinalized route can beat \(d[u]\) — it is already optimal.

The Proof Breaks with Negative Weights

The inequality \(d[x] + w(x,y) \geq d[y]\) is where non-negativity is used — if \(w(x,y)\) could be negative, a later, cheaper route through a currently-unfinalized vertex could beat an already-finalized vertex's distance, silently breaking correctness (with no error or warning — Dijkstra's algorithm will simply return a wrong, too-large answer). This is precisely the gap that the Bellman-Ford algorithm (an upcoming deep dive) is designed to fill.

Complexity Analysis

With a binary-heap priority queue, each of the \(V\) extract-min operations costs \(O(\log V)\), and each of the \(E\) relaxations may trigger a \(O(\log V)\) decrease-key (or, in the common simplified implementation, a fresh \(O(\log V)\) insertion, discarding stale entries lazily):

$$\text{Time: } O((V+E)\log V) \qquad \text{Space: } O(V)$$

A naive \(O(V^2)\) array-based implementation (scan all unfinalized vertices for the minimum, every time) is actually faster on very dense graphs where \(E\) approaches \(V^2\) — a good reminder that asymptotic complexity depends on the graph's shape, not just its algorithm.

Implementation

import heapq
from collections import defaultdict

def dijkstra(adj, source):
    """
    adj: dict[vertex] -> list[(neighbor, weight)], weights must be >= 0.
    Returns dist: dict[vertex] -> shortest distance from source.
    """
    dist = {source: 0}
    visited = set()
    heap = [(0, source)]      # (tentative distance, vertex)

    while heap:
        d, u = heapq.heappop(heap)
        if u in visited:
            continue           # stale entry — a shorter one was already processed
        visited.add(u)

        for v, weight in adj[u]:
            nd = d + weight
            if v not in dist or nd < dist[v]:
                dist[v] = nd
                heapq.heappush(heap, (nd, v))

    return dist

adj = defaultdict(list, {
    "A": [("B", 4), ("C", 1)],
    "B": [("D", 1)],
    "C": [("B", 1), ("D", 5)],
    "D": [],
})

print(dijkstra(adj, "A"))   # {'A': 0, 'C': 1, 'B': 2, 'D': 3}
#include <vector>
#include <queue>
#include <unordered_map>
#include <unordered_set>
#include <iostream>
#include <limits>
using namespace std;

unordered_map<string, long long>
dijkstra(unordered_map<string, vector<pair<string, int>>>& adj, const string& source) {
    unordered_map<string, long long> dist;
    unordered_set<string> visited;
    // min-heap of (distance, vertex)
    priority_queue<pair<long long, string>,
                   vector<pair<long long, string>>,
                   greater<>> heap;

    dist[source] = 0;
    heap.push({0, source});

    while (!heap.empty()) {
        auto [d, u] = heap.top(); heap.pop();
        if (visited.count(u)) continue;   // stale entry
        visited.insert(u);

        for (auto& [v, w] : adj[u]) {
            long long nd = d + w;
            if (!dist.count(v) || nd < dist[v]) {
                dist[v] = nd;
                heap.push({nd, v});
            }
        }
    }
    return dist;
}

int main() {
    unordered_map<string, vector<pair<string, int>>> adj = {
        {"A", {{"B", 4}, {"C", 1}}},
        {"B", {{"D", 1}}},
        {"C", {{"B", 1}, {"D", 5}}},
        {"D", {}}
    };

    auto dist = dijkstra(adj, "A");
    cout << "Distance A->D: " << dist["D"] << endl;  // 3
    return 0;
}
import java.util.*;

class Dijkstra {
    record Edge(String to, int weight) {}

    static Map<String, Long> dijkstra(Map<String, List<Edge>> adj, String source) {
        Map<String, Long> dist = new HashMap<>();
        Set<String> visited = new HashSet<>();
        PriorityQueue<long[]> heap = new PriorityQueue<>(Comparator.comparingLong(a -> a[0]));
        Map<Long, String> idToVertex = new HashMap<>(); // simple id trick omitted for brevity

        dist.put(source, 0L);
        // Using a small wrapper since Java PQ needs comparable primitives cleanly:
        PriorityQueue<Object[]> pq = new PriorityQueue<>(Comparator.comparingLong(o -> (long) o[0]));
        pq.add(new Object[]{0L, source});

        while (!pq.isEmpty()) {
            Object[] top = pq.poll();
            long d = (long) top[0];
            String u = (String) top[1];
            if (visited.contains(u)) continue;    // stale entry
            visited.add(u);

            for (Edge e : adj.getOrDefault(u, Collections.emptyList())) {
                long nd = d + e.weight();
                if (!dist.containsKey(e.to()) || nd < dist.get(e.to())) {
                    dist.put(e.to(), nd);
                    pq.add(new Object[]{nd, e.to()});
                }
            }
        }
        return dist;
    }

    public static void main(String[] args) {
        Map<String, List<Edge>> adj = new HashMap<>();
        adj.put("A", List.of(new Edge("B", 4), new Edge("C", 1)));
        adj.put("B", List.of(new Edge("D", 1)));
        adj.put("C", List.of(new Edge("B", 1), new Edge("D", 5)));
        adj.put("D", List.of());

        Map<String, Long> dist = dijkstra(adj, "A");
        System.out.println("Distance A->D: " + dist.get("D"));  // 3
    }
}

Real-World Applications

Case Study

GPS Navigation and Network Routing

Every "fastest route" calculation in a mapping app is, at its core, a shortest-path problem on a weighted graph where intersections are vertices and road segments are edges weighted by estimated travel time. Production systems use highly optimized variants (contraction hierarchies, bidirectional search, A* with geographic heuristics — previewed in an upcoming deep dive), but the correctness guarantee they all build on is exactly Dijkstra's greedy relaxation argument. The same algorithm (or its variants) also computes optimal routing tables inside internet routers (OSPF, a link-state routing protocol, literally runs a Dijkstra computation on the network topology).

GPS RoutingNetwork Protocols

Exercises

  1. Run Dijkstra's algorithm by hand on the graph in the worked example, but starting from vertex \(C\) instead of \(A\).
  2. Construct a small graph with a negative edge weight where Dijkstra's algorithm produces an incorrect shortest-path distance, and explain exactly which step of the correctness proof fails.
  3. Modify the Python implementation above to also track and reconstruct the shortest path (not just its length) using a parent map, matching the technique from the BFS deep dive.
  4. Challenge: Explain why running Dijkstra's algorithm from a single source and stopping early the moment your specific target vertex is finalized is still correct and often faster in practice, even though it doesn't compute distances to every vertex.

Limitations

No Negative Weights, No Free Lunch on Density

Dijkstra's algorithm requires non-negative edge weights (see the correctness proof above) — use the Bellman-Ford algorithm when negative weights are possible. And while \(O((V+E)\log V)\) is excellent for sparse graphs, on very dense graphs the constant-factor overhead of heap operations can make a simpler \(O(V^2)\) array scan faster in practice — always profile before assuming the asymptotically better algorithm wins on your actual data.