Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Coding Interview Patterns  Module 23 – Weighted graphs: shortest paths and spanning trees

Dijkstra's algorithm with a heap

Find the lowest-cost route with Dijkstra's algorithm: a heap with lazy deletion, the route rebuilt from parents, why negative costs break it, in five languages.

  • Intermediate
  • 35 minutes
  • Examples run with Python 3.14.8, Pyodide 314.0.7, Node.js 24.21.0, quickjs 0.32.0, Java 25, Clang 21.0.0 and Go 1.27.1
  • By MySmartCoPilot
Free: 35 minutes

What you will learn

  • Implement Dijkstra's algorithm with a binary heap and lazy deletion of stale entries
  • Explain why a node's cost is final when it leaves the heap, and why negative costs break that
  • Rebuild a cheapest route from parent pointers
  • Estimate the cost as O((V + E) log V) and check it by counting heap operations
  • Choose between breadth-first search, the heap version and the O(V²) array version for a given graph

Before you start

What you’ll learn · Before you start

What you will learn

  • Implement Dijkstra's algorithm with a binary heap and lazy deletion of stale entries
  • Explain why a node's cost is final when it leaves the heap, and why negative costs break that
  • Rebuild a cheapest route from parent pointers
  • Estimate the cost as O((V + E) log V) and check it by counting heap operations
  • Choose between breadth-first search, the heap version and the O(V²) array version for a given graph

Before you start

On this page

Dijkstra’s algorithm finds the cheapest route from one start node to every other node of a graph whose edges carry costs, provided no cost is negative. It keeps the cheapest cost found so far for every node and a min-heap of candidates. Again and again it takes the cheapest candidate out of the heap: that node’s cost is now final, so the algorithm settles it and tries every edge leaving it, lowering a neighbour’s cost whenever the edge gives a cheaper route. That step is called relaxing the edge. With a binary heap, a graph of V nodes and E edges takes O((V + E) log V) time. Use it for tolls, travel times, prices and any other cost that is zero or positive. When every edge costs the same, breadth-first search is simpler, and when some costs are negative, Dijkstra’s algorithm can return wrong answers.

This lesson builds the version that coding rounds use, with lazy deletion: a node that gets cheaper is pushed again, and its old, stale entry is skipped when it comes out. It counts what the heap does on four graphs, breaks the algorithm with one negative edge, and solves a problem in full, the cheapest delivery route through a toll network, from the brute force to code in Python, JavaScript, Java, C++ and Go.

When Dijkstra’s algorithm fits

Three signs in a statement point to it:

  • Edges have a cost, and the question asks for the cheapest, fastest or shortest total: tolls on roads, minutes on train links, fees on currency conversions, metres of cable.
  • Every cost is zero or positive. A route then never gets cheaper as it grows longer, which is the one property the algorithm relies on.
  • There is one starting point, or several that all start at cost 0, and the answer is a cost for one target or for every node.

Breadth-first search, from the lesson on modelling problems as graphs, is the special case in which every edge costs 1: its queue reaches nodes in order of distance only because the costs are equal. When they differ, the route with the fewest edges is not always the cheapest. The two searches compare like this:

Breadth-first search or Dijkstra's algorithm?
Criterion Breadth-first searchDijkstra's algorithm
Edge costs All equal, or none at allAny cost that is zero or positive
Time O(V + E)O((V + E) log V) with a binary heap
A node's cost is final When it is first queuedWhen it leaves the heap
Typical question Fewest moves, hops or clicksLowest toll, fastest journey, lowest fee
When to choose Every step costs the sameCosts differ and none is negative

The third standard search for a single start, Bellman–Ford, accepts negative costs too and reports a cycle whose total is negative, at a price of O(V · E); refunds and limits on the number of edges call for it.

Settling the cheapest node first

Picture the start node with cost 0 and every other node with an unknown cost, written as infinity. The nodes fall into two groups: settled nodes, whose cheapest cost is known for certain, and the rest, each holding the cheapest cost found so far, its tentative cost. Each step does two things:

  1. Among the nodes not yet settled, pick the one with the smallest tentative cost, and settle it.
  2. Relax every edge u → v leaving the node u that was just settled: if the cost of u plus the cost of the edge is less than v’s tentative cost, lower v’s cost to that sum and record u as v’s parent, the node v is best reached from.

Why may the cheapest unsettled node be settled? Every route to it from the start must leave the settled group somewhere, and every node just outside that group already costs at least as much as the node being settled. With no negative edges, the rest of such a route can only add cost, so no route found later can be cheaper. The section on correctness below turns this into a proof.

Edsger W. Dijkstra published the method in a three-page note in 1959, beside a method for the shortest spanning tree of a graph. His version keeps no heap: it scans all the unsettled nodes for the cheapest one, which costs O(V²) in all. Coding rounds use a binary heap instead, so that finding the cheapest candidate costs O(log V).

The template, with lazy deletion

Python’s heapq, Java’s PriorityQueue and C++’s std::priority_queue have no operation that lowers the cost of an entry already in the heap. Python’s documentation describes the way round it for priority queues in general: mark the old entry as removed and add a new one. Dijkstra’s algorithm does not even need the mark. When a node gets cheaper, the code pushes a new entry and leaves the old one in place; once the old one reaches the top, its cost is larger than the node’s recorded cost, so it is stale and is skipped. Here is the whole algorithm on a graph of five nodes, printing every step:

Dijkstra's algorithm, traced on five nodes

Python · template/dijkstra.py

"""Dijkstra's algorithm with a binary heap and lazy deletion, traced on a graph of five nodes."""
import heapq
import math

# One-way edges (from, to, cost).
EDGES = [(0, 1, 6), (0, 2, 2), (2, 1, 3), (1, 3, 1), (2, 3, 7), (3, 4, 2)]


def dijkstra(n, edges, s):
    """The cheapest cost from s to every node (math.inf where s cannot reach)."""
    out = [[] for _ in range(n)]
    for u, v, cost in edges:
        out[u].append((v, cost))
    dist = [math.inf] * n
    dist[s] = 0
    heap = [(0, s)]
    while heap:
        d, u = heapq.heappop(heap)
        if d > dist[u]:
            print(f"pop ({d}, {u})  stale: {u} was reached for {dist[u]}")
            continue
        print(f"pop ({d}, {u})  settle {u}")
        for v, cost in out[u]:
            if d + cost < dist[v]:  # relax the edge u -> v
                dist[v] = d + cost
                heapq.heappush(heap, (dist[v], v))
                print(f"    push ({dist[v]}, {v})")
    return dist


print("dist:", dijkstra(5, EDGES, 0))

Output

pop (0, 0)  settle 0
    push (6, 1)
    push (2, 2)
pop (2, 2)  settle 2
    push (5, 1)
    push (9, 3)
pop (5, 1)  settle 1
    push (6, 3)
pop (6, 1)  stale: 1 was reached for 5
pop (6, 3)  settle 3
    push (8, 4)
pop (8, 4)  settle 4
pop (9, 3)  stale: 3 was reached for 6
dist: [0, 5, 2, 6, 8]

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 dijkstra.py

JavaScript · template/dijkstra.mjs

// Dijkstra's algorithm with a binary heap and lazy deletion, traced on a graph of five nodes.
// JavaScript has no heap in its standard library, so a small one comes first: entries are
// [cost, node] pairs, smallest cost first, and the smaller node first on a tie (as Python's tuples).

class MinHeap {
  constructor() {
    this.a = [];
  }
  get size() {
    return this.a.length;
  }
  static less(x, y) {
    return x[0] < y[0] || (x[0] === y[0] && x[1] < y[1]);
  }
  push(item) {
    const a = this.a;
    a.push(item);
    for (let i = a.length - 1; i > 0; ) {
      const p = (i - 1) >> 1;
      if (!MinHeap.less(a[i], a[p])) break;
      [a[i], a[p]] = [a[p], a[i]];
      i = p;
    }
  }
  pop() {
    const a = this.a;
    const top = a[0];
    const last = a.pop();
    if (a.length > 0) {
      a[0] = last;
      for (let i = 0; ; ) {
        const l = 2 * i + 1;
        const r = l + 1;
        let m = i;
        if (l < a.length && MinHeap.less(a[l], a[m])) m = l;
        if (r < a.length && MinHeap.less(a[r], a[m])) m = r;
        if (m === i) break;
        [a[i], a[m]] = [a[m], a[i]];
        i = m;
      }
    }
    return top;
  }
}

// One-way edges [from, to, cost].
const EDGES = [[0, 1, 6], [0, 2, 2], [2, 1, 3], [1, 3, 1], [2, 3, 7], [3, 4, 2]];

function dijkstra(n, edges, s) {
  const out = Array.from({ length: n }, () => []);
  for (const [u, v, cost] of edges) out[u].push([v, cost]);
  const dist = new Array(n).fill(Infinity);
  dist[s] = 0;
  const heap = new MinHeap();
  heap.push([0, s]);
  while (heap.size > 0) {
    const [d, u] = heap.pop();
    if (d > dist[u]) {
      console.log(`pop (${d}, ${u})  stale: ${u} was reached for ${dist[u]}`);
      continue;
    }
    console.log(`pop (${d}, ${u})  settle ${u}`);
    for (const [v, cost] of out[u]) {
      if (d + cost < dist[v]) { // relax the edge u -> v
        dist[v] = d + cost;
        heap.push([dist[v], v]);
        console.log(`    push (${dist[v]}, ${v})`);
      }
    }
  }
  return dist;
}

console.log(`dist: [${dijkstra(5, EDGES, 0).join(", ")}]`);

Output

pop (0, 0)  settle 0
    push (6, 1)
    push (2, 2)
pop (2, 2)  settle 2
    push (5, 1)
    push (9, 3)
pop (5, 1)  settle 1
    push (6, 3)
pop (6, 1)  stale: 1 was reached for 5
pop (6, 3)  settle 3
    push (8, 4)
pop (8, 4)  settle 4
pop (9, 3)  stale: 3 was reached for 6
dist: [0, 5, 2, 6, 8]

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node dijkstra.mjs

Read the trace from the top:

  1. The start goes in at cost 0. dist holds the cheapest cost found so far for every node, infinity for nodes not reached yet, and the heap holds (cost, node) pairs, cheapest first.
  2. Settling node 0 relaxes its two edges: node 1 for 6 and node 2 for 2.
  3. Settling node 2 reaches node 1 again, for 2 + 3 = 5, which beats 6: dist[1] becomes 5 and (5, 1) goes in. The entry (6, 1) stays in the heap.
  4. (5, 1) comes out before (6, 1). When (6, 1) reaches the top later, 6 is more than dist[1], so the line if d > dist[u] skips it. That one comparison is the whole of lazy deletion.
  5. Node 3 goes the same way: (9, 3) from node 2, then (6, 3) from node 1, and the stale (9, 3) is the last entry out.

Seven entries came out of the heap, and two of them were stale. A node is settled once, when an entry with its current cost comes out, and its edges are relaxed only then. Every push follows one successful relaxation, so the heap never receives more than E + 1 entries: at most one per edge, plus the start.

The JavaScript tab begins with a binary heap of about 35 lines, because JavaScript’s standard library has none. It breaks ties by node number, as Python’s tuples do, so both tabs print the same trace.

What it costs, counted

Every settled node reads its list of edges once, which is O(V + E) in all. On top of that, every push and every pop moves an entry up or down the heap, through at most log₂ of its size in levels. The program below runs the template on four graphs and counts the heap’s work:

Pushes, pops and stale entries on four graphs Python · cost/counts.py
"""The work of Dijkstra's algorithm, counted: heap operations on three graphs, then the array version on the dense one."""
import heapq
import math
import random


def dijkstra_counted(n, out, s):
    """Lazy-deletion Dijkstra that counts its pushes, pops, stale pops and the largest heap."""
    dist = [math.inf] * n
    dist[s] = 0
    heap = [(0, s)]
    pushes, pops, stale, largest = 1, 0, 0, 1
    while heap:
        d, u = heapq.heappop(heap)
        pops += 1
        if d > dist[u]:
            stale += 1
            continue
        for v, w in out[u]:
            if d + w < dist[v]:
                dist[v] = d + w
                heapq.heappush(heap, (dist[v], v))
                pushes += 1
                largest = max(largest, len(heap))
    return dist, pushes, pops, stale, largest


def dijkstra_array(n, out, s):
    """Dijkstra's O(n^2) form: no heap, a scan of every node for the closest one not yet settled."""
    dist = [math.inf] * n
    dist[s] = 0
    done = [False] * n
    scans = 0
    for _ in range(n):
        u = -1
        for v in range(n):
            scans += 1
            if not done[v] and (u < 0 or dist[v] < dist[u]):
                u = v
        if dist[u] == math.inf:
            break
        done[u] = True
        for v, w in out[u]:
            if dist[u] + w < dist[v]:
                dist[v] = dist[u] + w
    return dist, scans


def grid(side, rng):
    """A side x side grid, every cell joined to its 4 neighbours; each one-way step costs 1 to 9."""
    n = side * side
    out = [[] for _ in range(n)]
    for r in range(side):
        for c in range(side):
            for dr, dc in ((0, 1), (1, 0), (0, -1), (-1, 0)):
                rr, cc = r + dr, c + dc
                if 0 <= rr < side and 0 <= cc < side:
                    out[r * side + c].append((rr * side + cc, rng.randint(1, 9)))
    return n, out


def sparse(n, m, rng):
    """n nodes and m one-way edges between random nodes, each costing 1 to 1,000."""
    out = [[] for _ in range(n)]
    for _ in range(m):
        out[rng.randrange(n)].append((rng.randrange(n), rng.randint(1, 1000)))
    return n, out


def complete(n, rng):
    """Every ordered pair of n nodes joined by a one-way edge costing 1 to 1,000."""
    return n, [[(v, rng.randint(1, 1000)) for v in range(n) if v != u] for u in range(n)]


def complete_worst(n):
    """A complete graph whose costs make every edge from a settled node lower a later node's distance:
    u -> v costs 2(v - u) - 1 when u < v, so nodes settle in the order 0, 1, 2, ... at distances 0, 1, 2, ..."""
    return n, [[(v, 2 * (v - u) - 1 if u < v else n * n) for v in range(n) if v != u] for u in range(n)]


rng = random.Random(23)
graphs = [("grid 300 x 300", grid(300, rng)), ("sparse, 100,000 nodes", sparse(100_000, 300_000, rng)),
          ("complete, 1,000 nodes", complete(1_000, rng)), ("complete, 1,000 nodes, worst costs", complete_worst(1_000))]
for name, (n, out) in graphs:
    e = sum(len(x) for x in out)
    dist, pushes, pops, stale, largest = dijkstra_counted(n, out, 0)
    reached = sum(d < math.inf for d in dist)
    print(f"{name}: V = {n:,}, E = {e:,}, reached {reached:,}")
    print(f"  pushes {pushes:,} (at most E + 1 = {e + 1:,}), pops {pops:,}, stale {stale:,}, largest heap {largest:,}")
    print(f"  heap operations {pushes + pops:,}; each sifts at most {math.ceil(math.log2(largest + 1))} levels")

n, out = graphs[3][1]
dist_array, scans = dijkstra_array(n, out, 0)
assert dist_array == dijkstra_counted(n, out, 0)[0]
print(f"array version on the worst complete graph: {scans:,} scans and {sum(len(x) for x in out):,} edge checks, no heap")

Output

grid 300 x 300: V = 90,000, E = 358,800, reached 90,000
  pushes 112,312 (at most E + 1 = 358,801), pops 112,312, stale 22,312, largest heap 744
  heap operations 224,624; each sifts at most 10 levels
sparse, 100,000 nodes: V = 100,000, E = 300,000, reached 94,051
  pushes 111,754 (at most E + 1 = 300,001), pops 111,754, stale 17,703, largest heap 37,006
  heap operations 223,508; each sifts at most 16 levels
complete, 1,000 nodes: V = 1,000, E = 999,000, reached 1,000
  pushes 6,181 (at most E + 1 = 999,001), pops 6,181, stale 5,181, largest heap 5,609
  heap operations 12,362; each sifts at most 13 levels
complete, 1,000 nodes, worst costs: V = 1,000, E = 999,000, reached 1,000
  pushes 499,501 (at most E + 1 = 999,001), pops 499,501, stale 498,501, largest heap 333,000
  heap operations 999,002; each sifts at most 19 levels
array version on the worst complete graph: 1,000,000 scans and 999,000 edge checks, no heap

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 counts.py

Graph Heap work
Grid of 300 × 300, costs 1 to 9: 90,000 nodes, 358,800 edges 112,312 pushes, 22,312 of them stale; at most 744 waiting
Random and sparse, costs 1 to 1,000: 100,000 nodes, 300,000 edges 111,754 pushes, 17,703 stale; at most 37,006 waiting
Complete, costs 1 to 1,000: 1,000 nodes, 999,000 edges 6,181 pushes, 5,181 stale; at most 5,609 waiting
Complete, built to push on every forward edge: 1,000 nodes, 999,000 edges 499,501 pushes, 498,501 stale; at most 333,000 waiting

On the first three graphs only a fraction of the edges ever lowers a cost: about one in three on the grid and on the sparse graph, and about one in 160 on the complete graph with random costs, where the cheap edges out of the start already reach most nodes for little. The fourth graph is built so that every edge from a settled node to a later node is a cheaper route: u → v costs 2(v − u) − 1. Then half of all edges push an entry, the heap grows to a third of a million entries, and each push or pop may sift through 19 levels, up to about 19 million steps in all, an upper estimate from the counts. Dijkstra’s original array version does 1,000 scans of 1,000 nodes on the same graph, a million steps, with no heap at all.

So the binary heap pays off on sparse graphs, where E is a small multiple of V. Grids, road networks and flight maps, which is most interview input, are sparse. On a dense graph, with E close to V², the O(V²) array version can be faster. A Fibonacci heap brings the bound down to O(E + V log V), as Fredman and Tarjan showed in 1987, but its constant factors make it rare in practice and in interviews.

Why negative costs break it

The argument for settling the cheapest node assumed that a route never gets cheaper as it grows. A negative cost, such as a refund on a road, breaks exactly that. The program runs two versions on four nodes with one refund: the textbook version, which never touches a node again once it is settled, and this lesson’s template:

One refund, two versions of the algorithm Python · negative/negative.py
"""One negative edge, two versions of Dijkstra: the one that settles each node once gives a wrong answer."""
import heapq
import math

# 0 -> 1 costs 2, 0 -> 2 costs 5, 2 -> 1 costs -4 (a refund), 1 -> 3 costs 1.
EDGES = [(0, 1, 2), (0, 2, 5), (2, 1, -4), (1, 3, 1)]
N = 4


def adjacency(n, edges):
    out = [[] for _ in range(n)]
    for u, v, cost in edges:
        out[u].append((v, cost))
    return out


def settle_once(n, edges, s):
    """The textbook form: a node is settled when it is popped, and settled nodes are never relaxed again."""
    out = adjacency(n, edges)
    dist, done, pops = [math.inf] * n, [False] * n, []
    dist[s] = 0
    heap = [(0, s)]
    while heap:
        d, u = heapq.heappop(heap)
        if done[u]:
            continue
        done[u] = True
        pops.append(u)
        for v, cost in out[u]:
            if not done[v] and d + cost < dist[v]:
                dist[v] = d + cost
                heapq.heappush(heap, (dist[v], v))
    return dist, pops


def lazy(n, edges, s):
    """The lesson's form: skip only stale entries, so a node whose distance drops is popped again."""
    out = adjacency(n, edges)
    dist, pops = [math.inf] * n, []
    dist[s] = 0
    heap = [(0, s)]
    while heap:
        d, u = heapq.heappop(heap)
        if d > dist[u]:
            continue
        pops.append(u)
        for v, cost in out[u]:
            if d + cost < dist[v]:
                dist[v] = d + cost
                heapq.heappush(heap, (dist[v], v))
    return dist, pops


for name, run in [("settle once", settle_once), ("lazy", lazy)]:
    dist, pops = run(N, EDGES, 0)
    print(f"{name:12} dist {dist}   nodes expanded, in order: {pops}")
print("true cost of 0 -> 3: 0 -> 2 -> 1 -> 3 = 5 - 4 + 1 =", 5 - 4 + 1)

Output

settle once  dist [0, 2, 5, 3]   nodes expanded, in order: [0, 1, 3, 2]
lazy         dist [0, 1, 5, 2]   nodes expanded, in order: [0, 1, 3, 2, 1, 3]
true cost of 0 -> 3: 0 -> 2 -> 1 -> 3 = 5 - 4 + 1 = 2

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 negative.py

The cheapest route from 0 to 3 goes 0 → 2 → 1 → 3, for 5 − 4 + 1 = 2. The textbook version settles node 1 at cost 2 through the direct edge, settles node 3 at 3, and only then reaches node 2, whose refund would make node 1 cheaper. Node 1 is settled already, so it is left alone, and node 3 stays at 3: a wrong answer. The lazy template gets 2, because it pushes node 1 again when its cost drops and expands it a second time, and node 3 after it.

That self-repair is not a fix. Every extra expansion can set off more of them, and the work can grow exponentially with the number of nodes: Donald B. Johnson showed in 1973 a family of networks on which this kind of search takes O(n · 2ⁿ) steps. The program below builds a similar family of chained gadgets, each with a refund twice as large as the one before, and counts the expansions:

Expansions of the lazy template on chained gadgets with refunds Python · blowup/blowup.py
"""Negative edges without a negative cycle: the lazy version stays right, but its work doubles with every gadget.

Gadget i joins a source a_i to the previous source a_(i-1) twice: directly, for 0, and through b_i, for i and then
-(i + 2^i). The direct edge comes out of the heap first, so everything behind a_(i-1) is expanded; then b_i comes out
and lowers a_(i-1) by 2^i, and everything behind it is expanded again. The refunds double from gadget to gadget, so
every improvement of a_i still improves a_(i-1) twice. There is no cycle at all, so no negative cycle.
"""
import heapq
import math


def gadgets(k):
    """Nodes a_0..a_k are 0..k, b_1..b_k are k+1..2k; the search starts at a_k."""
    edges = []
    for i in range(1, k + 1):
        a, a_prev, b = i, i - 1, k + i
        edges += [(a, a_prev, 0), (a, b, i), (b, a_prev, -(i + 2**i))]
    return 2 * k + 1, edges


def lazy_pops(n, edges, s):
    out = [[] for _ in range(n)]
    for u, v, w in edges:
        out[u].append((v, w))
    dist = [math.inf] * n
    dist[s] = 0
    heap, pops = [(0, s)], 0
    while heap:
        d, u = heapq.heappop(heap)
        if d > dist[u]:
            continue
        pops += 1
        for v, w in out[u]:
            if d + w < dist[v]:
                dist[v] = d + w
                heapq.heappush(heap, (dist[v], v))
    return dist, pops


def bellman_ford(n, edges, s):
    """Rounds over every edge until nothing changes: at most n - 1 rounds without a negative cycle."""
    dist = [math.inf] * n
    dist[s] = 0
    relaxations = 0
    for _ in range(n - 1):
        changed = False
        for u, v, w in edges:
            relaxations += 1
            if dist[u] + w < dist[v]:
                dist[v] = dist[u] + w
                changed = True
        if not changed:
            break
    return dist, relaxations


print(" k  nodes  edges  lazy Dijkstra pops  Bellman-Ford edge checks   cost of a_0")
for k in [1, 2, 4, 8, 12, 16, 20]:
    n, edges = gadgets(k)
    dist, pops = lazy_pops(n, edges, k)
    bf, relax = bellman_ford(n, edges, k)
    assert dist == bf
    print(f"{k:2}  {n:5}  {len(edges):5}  {pops:18,}  {relax:24,}  {dist[0]:>12,}")

Output

 k  nodes  edges  lazy Dijkstra pops  Bellman-Ford edge checks   cost of a_0
 1      3      3                   4                         6            -2
 2      5      6                  10                        18            -6
 4      9     12                  46                        60           -30
 8     17     24                 766                       216          -510
12     25     36              12,286                       468        -8,190
16     33     48             196,606                       816      -131,070
20     41     60           3,145,726                     1,260    -2,097,150

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 blowup.py

Every gadget adds 2 nodes and 3 edges and doubles the work: 3 · 2ᵏ − 2 expansions for k gadgets, 3,145,726 on a network of 41 nodes and 60 edges. The network has no cycle at all, so the answers are still right, but twenty more gadgets, 40 more nodes, would multiply the work by about a million. Bellman–Ford, which relaxes every edge in rounds until nothing changes, needs 1,260 edge checks on the same network. When costs can be negative, use Bellman–Ford. It also finds a cycle whose total cost is negative, on which the lazy template would never stop, because every lap lowers the costs again.

A problem solved in full: the cheapest delivery route

The problem

A courier company has n depots, numbered 0 to n − 1, and a list roads of one-way roads: (u, v, toll) lets a van drive from depot u to depot v for toll, an integer from 0 to 10,000. Given a start depot s and a target depot t, return the cheapest total toll from s to t and one route that pays it, as a list of depots. Return −1 and an empty route if t cannot be reached. Limits: 1 ≤ n ≤ 100,000 and 0 ≤ len(roads) ≤ 200,000.

The lesson’s network has nine depots and thirteen roads, drawn in the diagram further down. Four examples:

  1. From depot 0 to depot 7: a toll of 13, through depots 2, 1, 3, 4 and 5. The routes with the fewest roads, four of them, cost at least 14.
  2. From depot 3 to depot 7: 5, through depots 4 and 5.
  3. From depot 8 to depot 6: 6, through depots 3 and 4.
  4. From depot 7 to depot 0: −1. Every road is one-way, and none leaves depot 7.

The brute force: try every route

Walk every route from s that never visits a depot twice, depth first, and keep the cheapest one that reaches t. Nobody doubts its answers, so it is the reference that faster code gets tested against. The program lists the routes from depot 0 to depot 7, then counts the routes on complete networks, where every depot has a road to every other:

Every route from depot 0 to depot 7, then complete networks Python · brute/routes_brute.py
"""The cheapest toll found by trying every route: always right, and hopeless beyond a dozen depots."""
import math

ROADS = [
    (0, 1, 4), (0, 2, 1), (2, 1, 2), (1, 3, 5), (2, 3, 8), (2, 4, 10), (3, 4, 2),
    (3, 5, 6), (4, 5, 1), (4, 6, 3), (5, 7, 2), (6, 7, 4), (8, 3, 1),
]


def every_route(n, roads, s, t):
    """Yields (toll, route) for every route from s to t that visits no depot twice, depth first."""
    out = [[] for _ in range(n)]
    for u, v, toll in roads:
        out[u].append((v, toll))
    route, on_route = [s], [False] * n
    on_route[s] = True

    def extend(u, paid):
        if u == t:
            yield paid, list(route)
            return
        for v, toll in out[u]:
            if not on_route[v]:
                on_route[v] = True
                route.append(v)
                yield from extend(v, paid + toll)
                route.pop()
                on_route[v] = False

    yield from extend(s, 0)


routes = sorted(every_route(9, ROADS, 0, 7))
print(f"routes from 0 to 7: {len(routes)}")
for toll, route in routes:
    print(f"  toll {toll:2}, {len(route) - 1} roads: {' '.join(map(str, route))}")

# On a complete network, where every depot has a road to every other, the routes multiply.
print("depots  routes from 0 to the last depot")
for n in range(3, 9):
    roads = [(u, v, 1) for u in range(n) for v in range(n) if u != v]
    counted = sum(1 for _ in every_route(n, roads, 0, n - 1))
    formula = sum(math.perm(n - 2, k) for k in range(n - 1))  # k depots in between, in order
    assert counted == formula
    print(f"{n:6}  {counted:,}")
for n in [10, 12, 15, 20]:
    print(f"{n:6}  {sum(math.perm(n - 2, k) for k in range(n - 1)):,} (from the formula)")

Output

routes from 0 to 7: 11
  toll 13, 6 roads: 0 2 1 3 4 5 7
  toll 14, 5 roads: 0 1 3 4 5 7
  toll 14, 5 roads: 0 2 3 4 5 7
  toll 14, 4 roads: 0 2 4 5 7
  toll 16, 5 roads: 0 2 1 3 5 7
  toll 17, 4 roads: 0 1 3 5 7
  toll 17, 6 roads: 0 2 1 3 4 6 7
  toll 17, 4 roads: 0 2 3 5 7
  toll 18, 5 roads: 0 1 3 4 6 7
  toll 18, 5 roads: 0 2 3 4 6 7
  toll 18, 4 roads: 0 2 4 6 7
depots  routes from 0 to the last depot
     3  2
     4  5
     5  16
     6  65
     7  326
     8  1,957
    10  109,601 (from the formula)
    12  9,864,101 (from the formula)
    15  16,926,797,486 (from the formula)
    20  17,403,456,103,284,421 (from the formula)

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 routes_brute.py

The nine depots have 11 routes from 0 to 7, and the cheapest is one of the longest, with six roads. On complete networks the count explodes, because a route may visit the other depots in any order: 1,957 routes between two of 8 depots, 9,864,101 between two of 12, and about 1.7 × 10¹⁶ between two of 20. At 100,000 depots the brute force is hopeless.

What to notice

The cheapest route to depot 7 passes depot 5, and its part from depot 0 to depot 5 is itself a cheapest route to depot 5: if a cheaper one existed, the route to 7 could use it and get cheaper too. So cheapest routes are built from cheapest routes, and if depots were settled in increasing order of cost, each one’s cost could be worked out from depots already settled. That order is what a heap provides. And every toll is at least 0, so no route gets cheaper as it grows, which is the condition the method needs.

Dijkstra’s algorithm, step by step

  1. Build out[u], the list of (v, toll) pairs for the roads leaving depot u.
  2. Set dist[s] = 0, every other dist to infinity and every parent to −1, and put (0, s) in a min-heap.
  3. Take out the cheapest entry (d, u). If d > dist[u], it is stale: skip it.
  4. If u is t, stop: its toll is final.
  5. For every road (v, toll) in out[u] with d + toll < dist[v], set dist[v] = d + toll and parent[v] = u, and push (dist[v], v).
  6. Repeat from step 3 while the heap is not empty.
  7. If dist[t] is still infinity, return −1 and an empty route. Otherwise follow parent from t back to s, reverse the list, and return it with dist[t].

Step 4 lets the search stop early: once the target leaves the heap, nothing found later can make it cheaper. Without that step the same loop computes the cheapest toll to every depot, which is what the exercise below asks for.

Nine depots joined by one-way toll roads; the cheapest route from depot 0 to depot 7 runs through 2, 1, 3, 4 and 5 for a toll of 13.depot 0cost 0depot 1cost 3depot 2cost 1depot 3cost 8depot 4cost 10depot 5cost 11depot 6cost 13depot 7cost 13depot 8not reached12521234810641

The courier network, with the cheapest toll from depot 0 to every depot

Text description of the diagram

Nine depots, numbered 0 to 8, are joined by thirteen one-way roads, each marked with its toll. Every depot shows the cheapest total toll from depot 0.

  1. The thick roads form the cheapest route from depot 0 to depot 7: 0 to 2 for 1, 2 to 1 for 2, 1 to 3 for 5, 3 to 4 for 2, 4 to 5 for 1 and 5 to 7 for 2, a total of 13 over six roads.
  2. The plain road from 4 to 6, toll 3, completes the tree of cheapest routes: depot 6 costs 10 + 3 = 13.
  3. The dashed roads are never on a cheapest route from depot 0: 0 to 1 for 4 (going through depot 2 costs 3), 2 to 3 for 8, 2 to 4 for 10, 3 to 5 for 6, 6 to 7 for 4, and 8 to 3 for 1.
  4. Depot 8 has a dashed border and is not reached: its only road leads out of it, towards depot 3.

The cheapest costs are: depot 0, 0; depot 1, 3; depot 2, 1; depot 3, 8; depot 4, 10; depot 5, 11; depot 6, 13; depot 7, 13. The routes with the fewest roads to depot 7 use four roads but cost at least 14.

Why it is correct

Call a depot settled once an entry with its current cost has come out of the heap. Two facts hold throughout the run.

  1. dist[v] is always the toll of a real route from s to v, or infinity. It is only ever set to d + toll for a road out of a depot that was reached for d, so it is never less than the cheapest toll.
  2. Every settled depot has its cheapest toll. Suppose not, and let u be the first depot settled with a cost d larger than its cheapest toll c. Take a cheapest route R to u, let y be the first depot on R that is not settled yet, and x the depot just before it, which is settled. The start s was settled first, at cost 0, so x exists. When x was settled, its cost was right, because u is the first exception, and the road from x to y was relaxed, so dist[y] is at most the toll of R up to y. The rest of R, from y to u, costs at least 0, because no toll is negative, so dist[y] ≤ c < d. The last time dist[y] dropped, an entry with that cost went into the heap, and it is still there, because y is not settled. That entry is cheaper than d, so it would have come out before (d, u): a contradiction. If y is u itself, the same inequality says dist[u] < d, so (d, u) was stale and could not have settled u.

So a depot’s toll is final when it is settled, which justifies both step 4 and the stale check: a later entry of a settled depot can never carry a lower cost. Each parent[v] is set together with dist[v] and names a depot settled earlier, so the walk back from t passes depots in reverse order of settling, pays exactly dist[t] and ends at s. Strike out “because no toll is negative” and the proof falls apart, which is what the refund example showed.

A dry run

The search from depot 0 to depot 7, one row for every entry taken out of the heap:

Entry out, and what happens Heap afterwards
(0, 0): settle 0; depot 1 for 4, depot 2 for 1 (1, 2), (4, 1)
(1, 2): settle 2; depot 1 for 3, depot 3 for 9, depot 4 for 11 (3, 1), (4, 1), (9, 3), (11, 4)
(3, 1): settle 1; depot 3 for 8 (4, 1), (8, 3), (9, 3), (11, 4)
(4, 1): stale, since depot 1 already costs 3 (8, 3), (9, 3), (11, 4)
(8, 3): settle 3; depot 4 for 10, depot 5 for 14 (9, 3), (10, 4), (11, 4), (14, 5)
(9, 3): stale (10, 4), (11, 4), (14, 5)
(10, 4): settle 4; depot 5 for 11, depot 6 for 13 (11, 4), (11, 5), (13, 6), (14, 5)
(11, 4): stale (11, 5), (13, 6), (14, 5)
(11, 5): settle 5; depot 7 for 13 (13, 6), (13, 7), (14, 5)
(13, 6): settle 6; nothing cheaper, since 6 → 7 would cost 17 (13, 7), (14, 5)
(13, 7): the target, so the search stops (14, 5), never taken out

The parents recorded on the way are 2 for depot 1, 0 for depot 2, 1 for depot 3, 3 for depot 4, 4 for depots 5 and 6, and 5 for depot 7. Walking them back from depot 7 gives 5, 4, 3, 1, 2 and 0, so the route is 0, 2, 1, 3, 4, 5, 7. Eleven entries came out of the heap and four of them were stale; the twelfth, (14, 5), was still waiting when the search stopped.

The code in five languages

cheapest_route in five languages

Python · route/cheapest_route.py

"""The cheapest toll from one depot to another, and one route with it: Dijkstra's algorithm with a heap."""
import heapq
import math

# The courier network of the lesson: one-way roads (from, to, toll).
ROADS = [
    (0, 1, 4), (0, 2, 1), (2, 1, 2), (1, 3, 5), (2, 3, 8), (2, 4, 10), (3, 4, 2),
    (3, 5, 6), (4, 5, 1), (4, 6, 3), (5, 7, 2), (6, 7, 4), (8, 3, 1),
]


def cheapest_route(n, roads, s, t):
    """(toll, route): the cheapest total toll from s to t and one route that pays it, or (-1, [])."""
    out = [[] for _ in range(n)]
    for u, v, toll in roads:
        out[u].append((v, toll))
    dist = [math.inf] * n
    parent = [-1] * n
    dist[s] = 0
    heap = [(0, s)]
    while heap:
        d, u = heapq.heappop(heap)
        if d > dist[u]:
            continue  # stale: u was reached more cheaply after this entry was pushed
        if u == t:
            break  # t has left the heap, so its toll is final
        for v, toll in out[u]:
            if d + toll < dist[v]:
                dist[v] = d + toll
                parent[v] = u
                heapq.heappush(heap, (dist[v], v))
    if dist[t] == math.inf:
        return -1, []
    route = [t]
    while route[-1] != s:
        route.append(parent[route[-1]])
    route.reverse()
    return dist[t], route


for s, t in [(0, 7), (3, 7), (8, 6), (7, 0)]:
    toll, route = cheapest_route(9, ROADS, s, t)
    print(f"{s} to {t}: {toll}" + (" via " + " ".join(map(str, route)) if route else ""))

Output

0 to 7: 13 via 0 2 1 3 4 5 7
3 to 7: 5 via 3 4 5 7
8 to 6: 6 via 8 3 4 6
7 to 0: -1

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 cheapest_route.py

JavaScript · route/cheapest_route.mjs

// The cheapest toll from one depot to another, and one route with it: Dijkstra's algorithm with a heap.
// JavaScript has no built-in heap, so a small binary min-heap of [toll, depot] pairs comes first.

class MinHeap {
  constructor() {
    this.a = [];
  }
  get size() {
    return this.a.length;
  }
  static less(x, y) {
    return x[0] < y[0] || (x[0] === y[0] && x[1] < y[1]);
  }
  push(item) {
    const a = this.a;
    a.push(item);
    for (let i = a.length - 1; i > 0; ) {
      const p = (i - 1) >> 1;
      if (!MinHeap.less(a[i], a[p])) break;
      [a[i], a[p]] = [a[p], a[i]];
      i = p;
    }
  }
  pop() {
    const a = this.a;
    const top = a[0];
    const last = a.pop();
    if (a.length > 0) {
      a[0] = last;
      for (let i = 0; ; ) {
        const l = 2 * i + 1;
        const r = l + 1;
        let m = i;
        if (l < a.length && MinHeap.less(a[l], a[m])) m = l;
        if (r < a.length && MinHeap.less(a[r], a[m])) m = r;
        if (m === i) break;
        [a[i], a[m]] = [a[m], a[i]];
        i = m;
      }
    }
    return top;
  }
}

// The courier network of the lesson: one-way roads [from, to, toll].
const ROADS = [
  [0, 1, 4], [0, 2, 1], [2, 1, 2], [1, 3, 5], [2, 3, 8], [2, 4, 10], [3, 4, 2],
  [3, 5, 6], [4, 5, 1], [4, 6, 3], [5, 7, 2], [6, 7, 4], [8, 3, 1],
];

function cheapestRoute(n, roads, s, t) {
  const out = Array.from({ length: n }, () => []);
  for (const [u, v, toll] of roads) out[u].push([v, toll]);
  const dist = new Array(n).fill(Infinity);
  const parent = new Array(n).fill(-1);
  dist[s] = 0;
  const heap = new MinHeap();
  heap.push([0, s]);
  while (heap.size > 0) {
    const [d, u] = heap.pop();
    if (d > dist[u]) continue; // stale entry
    if (u === t) break; // t has left the heap: its toll is final
    for (const [v, toll] of out[u]) {
      if (d + toll < dist[v]) {
        dist[v] = d + toll;
        parent[v] = u;
        heap.push([dist[v], v]);
      }
    }
  }
  if (dist[t] === Infinity) return { toll: -1, route: [] };
  const route = [t];
  while (route[route.length - 1] !== s) route.push(parent[route[route.length - 1]]);
  route.reverse();
  return { toll: dist[t], route };
}

for (const [s, t] of [[0, 7], [3, 7], [8, 6], [7, 0]]) {
  const { toll, route } = cheapestRoute(9, ROADS, s, t);
  console.log(`${s} to ${t}: ${toll}` + (route.length ? ` via ${route.join(" ")}` : ""));
}

Output

0 to 7: 13 via 0 2 1 3 4 5 7
3 to 7: 5 via 3 4 5 7
8 to 6: 6 via 8 3 4 6
7 to 0: -1

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node cheapest_route.mjs

Java · compiled/CheapestRoute.java

// The cheapest toll from one depot to another, and one route with it: Dijkstra's algorithm with a heap.
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.PriorityQueue;

public class CheapestRoute {
    record Result(long toll, List<Integer> route) {}

    // The courier network of the lesson: one-way roads {from, to, toll}.
    static final int[][] ROADS = {
        {0, 1, 4}, {0, 2, 1}, {2, 1, 2}, {1, 3, 5}, {2, 3, 8}, {2, 4, 10}, {3, 4, 2},
        {3, 5, 6}, {4, 5, 1}, {4, 6, 3}, {5, 7, 2}, {6, 7, 4}, {8, 3, 1},
    };

    static Result cheapestRoute(int n, int[][] roads, int s, int t) {
        List<List<int[]>> out = new ArrayList<>();
        for (int i = 0; i < n; i++) out.add(new ArrayList<>());
        for (int[] r : roads) out.get(r[0]).add(new int[] {r[1], r[2]});
        long[] dist = new long[n];
        Arrays.fill(dist, Long.MAX_VALUE);
        int[] parent = new int[n];
        Arrays.fill(parent, -1);
        dist[s] = 0;
        // Entries {toll, depot}, cheapest first; long because a route's toll can pass 2^31 on large inputs.
        PriorityQueue<long[]> heap = new PriorityQueue<>((x, y) -> Long.compare(x[0], y[0]));
        heap.add(new long[] {0, s});
        while (!heap.isEmpty()) {
            long[] top = heap.poll();
            long d = top[0];
            int u = (int) top[1];
            if (d > dist[u]) continue; // stale entry
            if (u == t) break; // t has left the heap: its toll is final
            for (int[] e : out.get(u)) {
                int v = e[0];
                if (d + e[1] < dist[v]) {
                    dist[v] = d + e[1];
                    parent[v] = u;
                    heap.add(new long[] {dist[v], v});
                }
            }
        }
        if (dist[t] == Long.MAX_VALUE) return new Result(-1, List.of());
        List<Integer> route = new ArrayList<>();
        route.add(t);
        while (route.get(route.size() - 1) != s) route.add(parent[route.get(route.size() - 1)]);
        Collections.reverse(route);
        return new Result(dist[t], route);
    }

    public static void main(String[] args) {
        int[][] queries = {{0, 7}, {3, 7}, {8, 6}, {7, 0}};
        for (int[] q : queries) {
            Result r = cheapestRoute(9, ROADS, q[0], q[1]);
            StringBuilder line = new StringBuilder(q[0] + " to " + q[1] + ": " + r.toll());
            if (!r.route().isEmpty()) {
                line.append(" via");
                for (int v : r.route()) line.append(' ').append(v);
            }
            System.out.println(line);
        }
    }
}

C++ · compiled/cheapest_route.cpp

// The cheapest toll from one depot to another, and one route with it: Dijkstra's algorithm with a heap.
#include <algorithm>
#include <cstdio>
#include <functional>
#include <limits>
#include <queue>
#include <utility>
#include <vector>

struct Road {
    int from, to, toll;
};

// The courier network of the lesson: one-way roads.
const std::vector<Road> ROADS = {
    {0, 1, 4}, {0, 2, 1}, {2, 1, 2}, {1, 3, 5}, {2, 3, 8}, {2, 4, 10}, {3, 4, 2},
    {3, 5, 6}, {4, 5, 1}, {4, 6, 3}, {5, 7, 2}, {6, 7, 4}, {8, 3, 1},
};

// Returns the cheapest toll from s to t (-1 if t cannot be reached) and fills route with one cheapest route.
long long cheapest_route(int n, const std::vector<Road>& roads, int s, int t, std::vector<int>& route) {
    std::vector<std::vector<std::pair<int, int>>> out(n);
    for (const Road& r : roads) out[r.from].push_back({r.to, r.toll});
    const long long INF = std::numeric_limits<long long>::max();
    std::vector<long long> dist(n, INF);
    std::vector<int> parent(n, -1);
    dist[s] = 0;
    // A min-heap of (toll, depot): std::priority_queue is a max-heap unless it is given std::greater.
    std::priority_queue<std::pair<long long, int>, std::vector<std::pair<long long, int>>, std::greater<>> heap;
    heap.push({0, s});
    while (!heap.empty()) {
        auto [d, u] = heap.top();
        heap.pop();
        if (d > dist[u]) continue;  // stale entry
        if (u == t) break;          // t has left the heap: its toll is final
        for (auto [v, toll] : out[u]) {
            if (d + toll < dist[v]) {
                dist[v] = d + toll;
                parent[v] = u;
                heap.push({dist[v], v});
            }
        }
    }
    route.clear();
    if (dist[t] == INF) return -1;
    for (int v = t;; v = parent[v]) {
        route.push_back(v);
        if (v == s) break;
    }
    std::reverse(route.begin(), route.end());
    return dist[t];
}

int main() {
    const std::pair<int, int> queries[] = {{0, 7}, {3, 7}, {8, 6}, {7, 0}};
    for (auto [s, t] : queries) {
        std::vector<int> route;
        long long toll = cheapest_route(9, ROADS, s, t, route);
        std::printf("%d to %d: %lld", s, t, toll);
        if (!route.empty()) {
            std::printf(" via");
            for (int v : route) std::printf(" %d", v);
        }
        std::printf("\n");
    }
}

Go · compiled/cheapest_route.go

// The cheapest toll from one depot to another, and one route with it: Dijkstra's algorithm with a heap.
package main

import (
	"container/heap"
	"fmt"
	"math"
	"slices"
	"strings"
)

// entry is one heap item: a depot and the toll it was reached for.
type entry struct {
	toll  int64
	depot int
}

// tollHeap satisfies heap.Interface; Less puts the cheapest entry first.
type tollHeap []entry

func (h tollHeap) Len() int           { return len(h) }
func (h tollHeap) Less(i, j int) bool { return h[i].toll < h[j].toll }
func (h tollHeap) Swap(i, j int)      { h[i], h[j] = h[j], h[i] }
func (h *tollHeap) Push(x any)        { *h = append(*h, x.(entry)) }
func (h *tollHeap) Pop() any {
	old := *h
	last := old[len(old)-1]
	*h = old[:len(old)-1]
	return last
}

// The courier network of the lesson: one-way roads {from, to, toll}.
var roads = [][3]int{
	{0, 1, 4}, {0, 2, 1}, {2, 1, 2}, {1, 3, 5}, {2, 3, 8}, {2, 4, 10}, {3, 4, 2},
	{3, 5, 6}, {4, 5, 1}, {4, 6, 3}, {5, 7, 2}, {6, 7, 4}, {8, 3, 1},
}

// cheapestRoute returns the cheapest toll from s to t and one route with it, or -1 and nil.
func cheapestRoute(n int, roads [][3]int, s, t int) (int64, []int) {
	out := make([][][2]int, n)
	for _, r := range roads {
		out[r[0]] = append(out[r[0]], [2]int{r[1], r[2]})
	}
	dist := make([]int64, n)
	parent := make([]int, n)
	for i := range dist {
		dist[i] = math.MaxInt64
		parent[i] = -1
	}
	dist[s] = 0
	h := &tollHeap{{0, s}}
	for h.Len() > 0 {
		top := heap.Pop(h).(entry)
		d, u := top.toll, top.depot
		if d > dist[u] {
			continue // stale entry
		}
		if u == t {
			break // t has left the heap: its toll is final
		}
		for _, e := range out[u] {
			v, toll := e[0], int64(e[1])
			if d+toll < dist[v] {
				dist[v] = d + toll
				parent[v] = u
				heap.Push(h, entry{dist[v], v})
			}
		}
	}
	if dist[t] == math.MaxInt64 {
		return -1, nil
	}
	route := []int{t}
	for route[len(route)-1] != s {
		route = append(route, parent[route[len(route)-1]])
	}
	slices.Reverse(route)
	return dist[t], route
}

func main() {
	for _, q := range [][2]int{{0, 7}, {3, 7}, {8, 6}, {7, 0}} {
		toll, route := cheapestRoute(9, roads, q[0], q[1])
		line := fmt.Sprintf("%d to %d: %d", q[0], q[1], toll)
		if len(route) > 0 {
			parts := make([]string, len(route))
			for i, v := range route {
				parts[i] = fmt.Sprint(v)
			}
			line += " via " + strings.Join(parts, " ")
		}
		fmt.Println(line)
	}
}
Java, C++ and Go, compiled and run Python · compiled/route_session.py
from session import run, versions

# CheapestRoute.java runs as a single source file, cheapest_route.cpp is built with the sanitizers on, and the Go
# program runs with go run.
versions("java -version", "clang++ --version", "go version")
run("""
java CheapestRoute.java
clang++ -std=c++23 -Wall -Wextra -fsanitize=address,undefined cheapest_route.cpp -o cheapest_route && ./cheapest_route
go run cheapest_route.go
""")

Output

# openjdk version "25.0.4.1" LTS
# Apple clang version 21.0.0 (clang-2100.3.34.2)
# go version go1.27.1 darwin/arm64
$ java CheapestRoute.java
0 to 7: 13 via 0 2 1 3 4 5 7
3 to 7: 5 via 3 4 5 7
8 to 6: 6 via 8 3 4 6
7 to 0: -1
$ clang++ -std=c++23 -Wall -Wextra -fsanitize=address,undefined cheapest_route.cpp -o cheapest_route && ./cheapest_route
0 to 7: 13 via 0 2 1 3 4 5 7
3 to 7: 5 via 3 4 5 7
8 to 6: 6 via 8 3 4 6
7 to 0: -1
$ go run cheapest_route.go
0 to 7: 13 via 0 2 1 3 4 5 7
3 to 7: 5 via 3 4 5 7
8 to 6: 6 via 8 3 4 6
7 to 0: -1

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 route_session.py

All five print the same four lines. Where they differ:

  • Python pushes (toll, depot) tuples with heapq; when two tolls tie, the depot numbers decide, which is harmless.
  • JavaScript has no heap in its standard library, so the file starts with a binary heap of [toll, depot] pairs. Its numbers are exact integers up to 2⁵³ − 1, far above any toll here.
  • Java keeps long[] pairs in a PriorityQueue with a comparator on the toll. Its documentation gives O(log n) for add and poll but linear time for remove(Object), one more reason to leave stale entries where they are. A cheapest route uses at most 99,999 roads of at most 10,000 each, below 2³¹, but long keeps the code safe for larger tolls.
  • C++ turns std::priority_queue, a max-heap by default, into a min-heap with std::greater<>. The build runs AddressSanitizer and UndefinedBehaviorSanitizer, which found nothing.
  • Go implements the five methods of heap.Interface. The package’s heap.Fix can lower an entry’s cost in place in O(log n), but only if the code tracks where each depot sits in the slice, bookkeeping that lazy deletion avoids.

Checked against the brute force

3,000 random networks, Dijkstra against every route Python · check/check_route.py
"""Dijkstra's answer checked against every route, on 3,000 random networks with repeated roads and zero tolls."""
import heapq
import math
import random


def cheapest_route(n, roads, s, t):
    out = [[] for _ in range(n)]
    for u, v, toll in roads:
        out[u].append((v, toll))
    dist, parent = [math.inf] * n, [-1] * n
    dist[s] = 0
    heap = [(0, s)]
    while heap:
        d, u = heapq.heappop(heap)
        if d > dist[u]:
            continue
        if u == t:
            break
        for v, toll in out[u]:
            if d + toll < dist[v]:
                dist[v], parent[v] = d + toll, u
                heapq.heappush(heap, (dist[v], v))
    if dist[t] == math.inf:
        return -1, []
    route = [t]
    while route[-1] != s:
        route.append(parent[route[-1]])
    return dist[t], route[::-1]


def every_toll(n, roads, s, t):
    """The toll of every route from s to t that repeats no depot (parallel roads give separate routes)."""
    out = [[] for _ in range(n)]
    for u, v, toll in roads:
        out[u].append((v, toll))
    seen, tolls = {s}, []

    def extend(u, paid):
        if u == t:
            tolls.append(paid)
            return
        for v, toll in out[u]:
            if v not in seen:
                seen.add(v)
                extend(v, paid + toll)
                seen.remove(v)

    extend(s, 0)
    return tolls


rng = random.Random(59)
unreachable = tied = zero_used = 0
for _ in range(3000):
    n = rng.randint(2, 7)
    roads = [(rng.randrange(n), rng.randrange(n), rng.randint(0, 9)) for _ in range(rng.randint(2, 16))]
    s, t = rng.randrange(n), rng.randrange(n)
    toll, route = cheapest_route(n, roads, s, t)
    tolls = every_toll(n, roads, s, t)
    assert toll == (min(tolls) if tolls else -1), (n, roads, s, t)
    if not tolls:
        unreachable += 1
        continue
    tied += tolls.count(toll) > 1
    # The route must start at s, end at t and pay exactly the toll, using the cheapest road of each step.
    assert route[0] == s and route[-1] == t
    steps = [min(w for u, v, w in roads if (u, v) == (a, b)) for a, b in zip(route, route[1:])]
    assert sum(steps) == toll
    zero_used += 0 in steps
print(f"3,000 networks agree: {unreachable:,} with no route, {tied:,} with tied cheapest routes, "
      f"{zero_used:,} whose route uses a zero-toll road")

Output

3,000 networks agree: 948 with no route, 105 with tied cheapest routes, 267 whose route uses a zero-toll road

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 check_route.py

The networks have 2 to 7 depots and up to 16 roads, chosen at random with repeats, so the awkward cases turn up often: 948 networks have no route from the start to the target, 105 have several cheapest routes, and in 267 the route returned uses a road with a toll of 0. In every one the toll equals the cheapest of all routes, and the route starts at the start, ends at the target and pays exactly that toll.

The cost

Method, for two of n depots joined by m roads Cost
Every route, depth first Exponential: about e · (n − 2)! routes on a complete network
Dijkstra’s algorithm with a binary heap O((n + m) log n) time and O(n + m) memory
Dijkstra’s algorithm with an array scan O(n²) time and O(n + m) memory

At the limits, 100,000 depots and 200,000 roads, the heap version does at most 200,001 pushes and as many pops, each through at most 18 levels: about 7 million steps. The array scan would look at 10¹⁰ entries.

Variants of the template

  • Two-way roads. Add each road to the lists of both of its ends: a two-way road is two one-way roads with the same toll.
  • Several starting points. Put every start in the heap at cost 0. The result is the cost from the nearest start, as if one extra node had roads costing 0 to all of them.
  • Costs on nodes. An entry fee at a depot can be added to the toll of every road into it, so that only edges carry costs.
  • The number of cheapest routes. When d + toll equals dist[v], add ways[u] to ways[v]; when it is less, replace ways[v] with ways[u]. This needs every toll to be positive: with tolls of 0, two depots at the same cost can come out in either order, and a count could be read before it is complete.
  • A budget. To list every node within a budget B, stop as soon as the entry taken out costs more than B.

The rest of this module changes one thing at a time: the way costs combine along a route, such as the largest single step or a product of probabilities; edges that cost only 0 or 1; a limit on the number of edges; all pairs at once; and extra state carried along, such as fuel or coupons.

Exercise

Exercise · Medium · Python

The cheapest toll to every town

A region has n towns, numbered 0 to n - 1. Each (u, v, toll) in roads is a two-way road between towns u and v, and driving it in either direction costs toll, an integer from 0 to 10,000. Two towns may be joined by several roads, and a road may even lead from a town back to itself. Finish cheapest_cost(n, roads, s) in costs.py: return a list whose entry i is the cheapest total toll from town s to town i, with -1 for every town that s cannot reach.

roads = [(0, 1, 7), (0, 2, 2), (2, 1, 3), (1, 3, 1), (2, 3, 9), (4, 5, 0)]
cheapest_cost(6, roads, 0)   # [0, 5, 2, 6, -1, -1]: town 1 costs 2 + 3 through town 2, not 7
cheapest_cost(6, roads, 4)   # [-1, -1, -1, -1, 0, 0]: the road from 4 to 5 costs nothing
cheapest_cost(1, [], 0)      # [0]

The starter code runs a correct breadth-first search, but it counts roads, not tolls. One test has 20,000 towns and 60,000 roads, so scanning every town for the cheapest one at each step, about 400 million checks, is too slow: keep the candidates in a heap.

Starter code · costs.py

from collections import deque


def cheapest_cost(n, roads, s):
    """The cheapest total toll from town s to every town, with -1 for towns that s cannot reach."""
    # This BFS reaches the right towns, but it counts the roads driven, not the tolls paid.
    out = [[] for _ in range(n)]
    for u, v, toll in roads:
        out[u].append(v)
        out[v].append(u)
    dist = [-1] * n
    dist[s] = 0
    queue = deque([s])
    while queue:
        u = queue.popleft()
        for v in out[u]:
            if dist[v] == -1:
                dist[v] = dist[u] + 1
                queue.append(v)
    return dist
The sample tests · test_costs.py
import random

from costs import cheapest_cost

ROADS = [(0, 1, 7), (0, 2, 2), (2, 1, 3), (1, 3, 1), (2, 3, 9), (4, 5, 0)]


def by_rounds(n, roads, s):
    """A different method for checking: relax every road in both directions until nothing changes."""
    inf = float("inf")
    dist = [inf] * n
    dist[s] = 0
    for _ in range(n):
        changed = False
        for u, v, w in roads:
            for a, b in ((u, v), (v, u)):
                if dist[a] + w < dist[b]:
                    dist[b] = dist[a] + w
                    changed = True
        if not changed:
            break
    return [-1 if d == inf else d for d in dist]


def test_the_examples():
    """the three calls of the prompt"""
    assert cheapest_cost(6, ROADS, 0) == [0, 5, 2, 6, -1, -1]
    assert cheapest_cost(6, ROADS, 4) == [-1, -1, -1, -1, 0, 0]
    assert cheapest_cost(1, [], 0) == [0]


def test_roads_run_both_ways():
    """the same network seen from town 3"""
    assert cheapest_cost(6, ROADS, 3) == [6, 1, 4, 0, -1, -1]


def test_parallel_roads_and_loops():
    """the cheaper of two parallel roads counts, and a road back to the same town changes nothing"""
    assert cheapest_cost(3, [(0, 1, 9), (1, 0, 4), (1, 1, 0), (1, 2, 5), (2, 2, 3)], 0) == [0, 4, 9]


def test_a_cheap_detour_beats_a_short_road():
    """five cheap roads cost less than one dear road"""
    roads = [(0, 5, 100)] + [(i, i + 1, 10) for i in range(5)]
    assert cheapest_cost(6, roads, 0) == [0, 10, 20, 30, 40, 50]


def test_the_list_of_roads_is_left_unchanged():
    """the function reads the roads and changes nothing in them"""
    roads = [(0, 1, 3), (1, 2, 4)]
    cheapest_cost(3, roads, 2)
    assert roads == [(0, 1, 3), (1, 2, 4)]


def test_random_networks():
    """400 small random networks, checked by relaxing every road until nothing changes"""
    rng = random.Random(41)
    for _ in range(400):
        n = rng.randint(1, 8)
        roads = [(rng.randrange(n), rng.randrange(n), rng.randint(0, 20)) for _ in range(rng.randint(0, 14))]
        s = rng.randrange(n)
        assert cheapest_cost(n, roads, s) == by_rounds(n, roads, s), (n, roads, s)


def test_twenty_thousand_towns():
    """a chain of 20,000 towns, 40,000 extra roads that never help and one cheap road between the two ends"""
    n = 20_000
    rng = random.Random(7)
    roads = [(i, i + 1, 2) for i in range(n - 1)] + [(0, n - 1, 1)]
    for _ in range(40_000):
        a, b = rng.randrange(n), rng.randrange(n)
        roads.append((a, b, 2 * abs(a - b) + rng.randint(0, 5)))
    rng.shuffle(roads)
    expected = [min(2 * i, 1 + 2 * (n - 1 - i)) for i in range(n)]
    assert cheapest_cost(n, roads, 0) == expected
A hint

Keep the towns waiting to be settled in a heap of (toll so far, town) pairs, so the cheapest comes out first. When you take out an entry whose toll is larger than the best toll already recorded for its town, skip it: that town was reached more cheaply after the entry went in. Roads are two-way, so add each road to the lists of both of its towns.

The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Python Online Compiler Paste the traced template, give one edge a negative cost, and watch which nodes are expanded twice.

Check yourself

6 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.

  1. Question 1 of 6 The top of the heap is (9, 3), but dist[3] is already 6. What does the template do with this entry?

    Choose one answer.

    Show the answer to question 1

    Answer: It skips it: node 3 was settled at cost 6, and its edges were relaxed then

    A stale entry is the trace of an older, dearer route to the same node. The entry (6, 3) came out first and settled node 3, so when (9, 3) reaches the top, 9 > 6 and the check if d > dist[u] skips it. Relaxing from cost 9 could never lower anything that cost 6 did not already lower.

  2. Question 2 of 6 On the courier network, what is the cheapest toll from depot 3 to depot 6? From depot 3, roads lead to depot 4 for 2 and to depot 5 for 6; from depot 4, roads lead to depot 5 for 1 and to depot 6 for 3; from depot 5, a road leads to depot 7 for 2.

    Type a number.

    Show the answer to question 2

    Answer: 5

    Depot 3 to depot 4 costs 2 and depot 4 to depot 6 costs 3, so 5 in all. Going through depot 5 does not help: no road leads from depot 5 to depot 6.

  3. Question 3 of 6 A graph has 1,000 nodes and 5,000 one-way edges, all with positive costs. At most how many entries does the template push onto the heap in one run, counting the start?

    Type a number.

    Show the answer to question 3

    Answer: 5001 entries

    An entry is pushed only after a successful relaxation, and each edge is relaxed once, when the node it leaves is settled. That is at most 5,000 pushes for the edges, plus the start: 5,001.

  4. Question 4 of 6 Which step of the correctness argument stops being true when some road has a negative cost?

    Choose one answer.

    Show the answer to question 4

    Answer: The part of a cheapest route after its first unsettled node costs at least 0

    The proof needs the rest of a route to cost at least 0, so that the first unsettled node on a cheaper route would already cost less than the node being settled. A refund lets the rest of the route lower the total, so a node can leave the heap before its cheapest route is known. The other three statements stay true.

  5. Question 5 of 6 Which of these can the lesson's template answer correctly? Choose every one.

    Choose every answer that is right.

    Show the answer to question 5

    Answer:

    • The fastest journey when every train link has a travel time and runs both ways
    • The cheapest toll when some roads have a toll of 0
    • The cheapest toll when two depots are joined by several roads with different tolls

    Zero costs, two-way links (two one-way edges each) and parallel roads all keep every cost at least 0, so the proof holds. A refund is a negative cost, which the template can get wrong or answer only with exponential work. Taking the most expensive entry first does not give the dearest route, because a route can still grow more expensive after its last node has been taken out.

  6. Question 6 of 6 Why may the search stop as soon as the target leaves the heap?

    Choose one answer.

    Show the answer to question 6

    Answer: Its cost is final once it leaves the heap, so nothing found later can lower it

    A node's cost is final when an entry with its current cost leaves the heap, which is what the correctness argument proves. Other nodes may still be waiting: in the dry run, (14, 5) was still in the heap when depot 7 came out.

Interview questions

Why does Dijkstra’s algorithm skip an entry whose cost is larger than the node’s recorded cost, and how many entries can the heap hold? (warm-up)

Python’s heapq, Java’s PriorityQueue and C++’s priority_queue have no operation that lowers the cost of an entry already in the heap. So when a cheaper route to a node turns up, the code records the new cost and pushes a second entry, leaving the first one behind. That old entry is stale: its cost is larger than the node’s recorded cost, and by the time it reaches the top, the node has been settled with the smaller cost. Skipping it stops the node from being expanded twice. Every push follows a successful relaxation, and with no negative costs each edge is relaxed at most once, when the node it leaves is settled. So the heap receives at most E + 1 entries, and since log E ≤ 2 log V, the total stays O((V + E) log V).

When is breadth-first search enough for a shortest path, and when do you need Dijkstra’s algorithm? (warm-up)

Breadth-first search finds the fewest edges, so it gives shortest paths exactly when every edge costs the same: moves on a board, hops in a network, clicks between pages. Its queue reaches nodes in layers of equal distance, so a node’s first visit is final, and the search takes O(V + E). When edges cost different amounts, the fewest edges is not the cheapest route: on this lesson’s courier network, the routes with four roads to depot 7 cost at least 14, while the cheapest, with six roads, costs 13. Dijkstra’s algorithm orders nodes by cost with a heap instead, in O((V + E) log V), and it needs every cost to be zero or positive. With costs of only 0 and 1, a double-ended queue gives the same answers in O(V + E).

Key takeaways

  • Dijkstra’s algorithm settles nodes in order of cost: take the cheapest entry out of a min-heap, skip it if it is stale, and relax the edges of the node it settles.
  • A node’s cost is final when it leaves the heap, provided no edge costs less than 0. That one assumption carries the whole proof.
  • Lazy deletion replaces a decrease-key operation: push a new entry when a cost drops, and skip entries whose cost is larger than the recorded one. The heap receives at most E + 1 entries.
  • With a binary heap it runs in O((V + E) log V). Measured: 112,312 pushes on a 300 × 300 grid with 358,800 edges. On a dense graph built to push on every forward edge, the O(V²) array version needed a million scans against up to about 19 million heap steps.
  • Rebuild the route from parent pointers, set together with the cost, by walking back from the target and reversing.
  • With negative costs, the textbook version returns wrong answers and the lazy one can need exponential work: 3,145,726 expansions on 41 nodes. Use Bellman–Ford instead.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.