Graphs/Shortest Paths

Lesson 6.62,309 words

Shortest Paths

Finding the cheapest route through a weighted network is one of the most-used algorithms in computing, and a single operation — relaxation — underlies every method. We build the primitive, prove the triangle inequality and optimal substructure that make it work, then meet Dijkstra's algorithm: the greedy solution for non-negative weights, traced vertex by vertex, with the cut argument that proves each extraction is final.

╌╌╌╌

Every navigation app, every network router, every game pathfinder is solving the same problem: given a weighted graph, find the cheapest route from one place to another. BFS already solved this when every edge counts as one step. Now the edges carry weights (distances, times, costs), and we want to minimize the total weight along a path. This lesson builds the shortest-path toolkit from a single primitive shared by every algorithm in it.

The problem and its primitive

Every algorithm maintains two arrays. For each vertex , an estimate is an upper bound on , always the true distance, shrinking toward it. A predecessor records the previous vertex on the best path found so far, forming a shortest-path tree. We initialize and for every other vertex.

The one operation that updates these estimates is relaxation: testing whether going through improves our route to .

Algorithm 1:Relax(u,v,w)\textsc{Relax}(u, v, w) — try the edge (u,v)(u,v) as a shortcut to vv
  1. 1
    if u.d+w(u,v)<v.du.d + w(u, v) < v.d then
  2. 2
    v.du.d+w(u,v)v.d \gets u.d + w(u, v)
    cheaper route to v via u
  3. 3
    v.πuv.\pi \gets u

Relaxation never produces an estimate below the true distance, and it can only ever lower an estimate. Every shortest-path algorithm below is a different discipline for deciding which edges to relax, and in what order. Two facts make relaxation work: the triangle inequality, and optimal substructure: any subpath of a shortest path is itself a shortest path.1 The latter is what makes greedy and dynamic-programming approaches both viable.

One relaxation step looks like this. Before, 's best-known route costs ; we test the edge of weight against 's settled estimate . Since , the edge is a shortcut: drops to and is rewired to point back through .

One step. The edge beats 's current estimate (), so falls to and its predecessor is rewired to .

We will trace the algorithms on this small weighted digraph. The negative edge of weight is harmless here (there is no negative cycle), but edges like it are what break Dijkstra and force a dynamic program.

A small weighted digraph with one negative edge to of weight , used to trace the algorithms.

Dijkstra's algorithm

When all edge weights are non-negative, we can be greedy. 's algorithm grows a set of vertices whose shortest distances are finalized. At each step it picks the non-finalized vertex with the smallest estimate , finalizes it, and relaxes its outgoing edges. A min-priority queue keyed by supplies the next vertex.

Algorithm 2:Dijkstra(G,w,s)\textsc{Dijkstra}(G, w, s) — SSSP for non-negative weights
  1. 1
    foreach vertex vVv \in V do
  2. 2
    v.dv.d \gets \infty
  3. 3
    v.πnilv.\pi \gets \text{nil}
  4. 4
    s.d0s.d \gets 0
  5. 5
    SS \gets \emptyset
  6. 6
    QVQ \gets V
    min-PQ keyed by d
  7. 7
    while QQ \neq \emptyset do
  8. 8
    uu \gets Extract-Min(Q)\textsc{Extract-Min}(Q)
    closest unfinalized
  9. 9
    SS{u}S \gets S \cup \set{u}
    u.d now final
  10. 10
    foreach vv adjacent to uu do
  11. 11
    call Relax(u,v,w)\textsc{Relax}(u, v, w)
    Decrease-Key updates Q
  12. 12
    return dd and π\pi

Correctness rests on a cut argument — the same versus split that powered the exchange proofs for minimum spanning trees. The correctness claim is a loop invariant, and unwinding it across the whole run gives the theorem that justifies the greedy commitment.

The non-negativity is doing all the work in that last inequality: it guarantees extending a path never decreases its cost, so the closest frontier vertex can be safely frozen. A single negative edge breaks , so the greedy commitment becomes unsound; the failure is exhibited concretely below.

A complete run

Here is a full run: every , every successful relaxation, and the queue contents after each step. The graph has vertices and edges (), (), (), (), (), (), (). Read a table entry as with ; bold marks a finalized estimate, which never moves again.

StepExtracted (key)Queue after (vertex: key)
init
1
2
3
4
5

Two steps in the trace show the mechanism. In step 2, extracting triggers a on : the tentative (the direct edge) is beaten by through , so 's queue key drops and its predecessor is rewired. In step 3, the same thing happens to : falls to . Both improvements arrive before the affected vertex is extracted; the theorem guarantees this ordering can never fail with non-negative weights. The final predecessor array spells out the shortest-path tree: .

The run as a state sequence, one panel per . Shaded vertices are finalized; the label beside each vertex is its current key (black once finalized, blue while still in the queue), and the blue edges are the ones relaxed in that step.

Vertices finalize in nondecreasing order of distance () — a direct consequence of the greedy invariant. Notice that is finalized at distance via the two-hop route , beating the direct edge of weight — the relaxation through fired before was ever extracted:

Dijkstra finalizes vertices in nondecreasing distance: . Vertex settles at via , beating the direct edge of weight .

Running time. Like Prim, Dijkstra does exactly operations (each vertex leaves the queue once) and at most operations (each edge is relaxed once, when its tail is extracted, and each successful relaxation is one key decrease). The total is therefore

With a binary heap both operations cost , giving , which is whenever every vertex is reachable, since then . A Fibonacci heap makes amortized , improving the bound to . The gap matters most on dense graphs: with , the binary heap pays while the Fibonacci heap pays .

Why negative edges break it

The theorem leaned on non-negativity exactly once, in the step the rest of only adds cost, and one negative edge is enough to break it. Take three vertices: with weight , with weight , and with weight . The true distance to is via . But Dijkstra extracts , then extracts (key , the current minimum) and freezes . Only afterward does it extract and try the edge : the relaxation would succeed, but has already left the queue, and the algorithm never revisits a finalized vertex. The greedy schedule processed before the cheap route to it existed.

A negative edge poisons the greedy choice. Here via , but Dijkstra extracts second with and freezes it; the improving relaxation of fires only after is extracted — too late for a finalized vertex.

A tempting repair, adding a constant to every edge weight until none is negative, fails because it penalizes paths in proportion to their hop count: a three-edge path gains while a one-edge path gains only , so the reweighted graph can have a different shortest path. Handling negative edges requires giving up the greedy schedule in favor of dynamic programming.

dijkstra.pypython
import heapq
from collections.abc import Hashable
from typing import Generic, NamedTuple, Optional, TypeVar

from graph import Graph

Label = TypeVar("Label", bound=Hashable)

class ShortestPaths(NamedTuple, Generic[Label]):
  """
    The result of a single-source search: the distance to every vertex and\n
    the predecessor on the shortest-path tree (None for the source and for\n
    unreachable vertices).\n
  """
  distance: dict[Label, float]
  predecessor: dict[Label, Optional[Label]]

  def path_to(self, target: Label) -> Optional[list[Label]]:
    """
      The shortest path from the source to `target` as a vertex list,\n
      or None if `target` is unreachable.\n
    """
    if self.distance.get(target, float("inf")) == float("inf"):
      return None

    # walk predecessors back from the target to the source, then flip.
    path: list[Label] = []
    cursor: Optional[Label] = target
    while cursor is not None:
      path.append(cursor)
      cursor = self.predecessor[cursor]

    path.reverse()
    return path

def dijkstra(graph: Graph[Label], source: Label) -> ShortestPaths[Label]:
  """
    Shortest-path distances from `source` to every vertex of `graph`.\n
    Requires all edge weights to be non-negative; a negative edge can\n
    silently violate the greedy finalization and corrupt the result.\n
  """
  # every estimate starts at infinity except the source at zero.
  distance: dict[Label, float] = {label: float("inf") for label in graph.labels}
  predecessor: dict[Label, Optional[Label]] = {label: None for label in graph.labels}
  distance[source] = 0.0

  # heap entries are (estimate, label); a finalized set skips stale ones.
  frontier: list[tuple[float, Label]] = [(0.0, source)]
  finalized: set[Label] = set()
  while frontier:
    current_distance, current_label = heapq.heappop(frontier)

    # a label is popped once with its true distance; later pops are stale.
    if current_label in finalized:
      continue
    finalized.add(current_label)

    for edge in graph.vertex(current_label).outgoing:
      neighbor_label: Label = edge.target.label
      candidate: float = current_distance + edge.weight

      # relaxation: a cheaper route to the neighbor via the current vertex.
      if candidate < distance[neighbor_label]:
        distance[neighbor_label] = candidate
        predecessor[neighbor_label] = current_label
        heapq.heappush(frontier, (candidate, neighbor_label))

  return ShortestPaths(distance, predecessor)
graph.pypython
from collections.abc import Hashable, Iterator
from typing import Generic, Optional, TypeVar


Label = TypeVar("Label", bound=Hashable)


class Edge(Generic[Label]):
  """
    A directed connection from `source` to `target`, carrying a weight.\n
  """

  def __init__(
    self,
    source: Vertex[Label],
    target: Vertex[Label],
    weight: float = 1.0,
  ) -> None:
    self.source: Vertex[Label] = source
    self.target: Vertex[Label] = target
    self.weight: float = weight

  def __repr__(self) -> str:
    return f"Edge({self.source.label!r} -> {self.target.label!r}, w={self.weight})"


class Vertex(Generic[Label]):
  """
    A graph vertex: a label plus the list of edges leaving it.\n
  """

  def __init__(self, label: Label) -> None:
    self.label: Label = label
    self.outgoing: list[Edge[Label]] = []

  def neighbors(self) -> list[Vertex[Label]]:
    """
      The vertices reachable from this one by a single edge.\n
    """
    return [edge.target for edge in self.outgoing]

  def edge_to(self, label: Label) -> Optional[Edge[Label]]:
    """
      The outgoing edge to the vertex with `label`, or None.\n
    """
    for edge in self.outgoing:
      if edge.target.label == label:
        return edge
    return None

  def __repr__(self) -> str:
    return f"Vertex({self.label!r})"


class Graph(Generic[Label]):
  """
    A graph of Vertex objects linked by Edge objects.\n
    Pass `directed=True` for a digraph; otherwise each `add_edge` inserts\n
    the reverse edge too.\n
  """

  def __init__(self, directed: bool = False) -> None:
    self.directed: bool = directed
    self._vertices: dict[Label, Vertex[Label]] = {}

  def add_vertex(self, label: Label) -> Vertex[Label]:
    """
      Return the vertex for `label`, creating it if it is absent.\n
    """
    # reuse the existing vertex, or mint and register a fresh one.
    vertex = self._vertices.get(label)
    if vertex is None:
      vertex = Vertex(label)
      self._vertices[label] = vertex
    return vertex

  def add_edge(
    self,
    source_label: Label,
    target_label: Label,
    weight: float = 1.0,
  ) -> None:
    """
      Connect two labels (creating either vertex as needed).\n
      Adds the reverse edge as well when the graph is undirected.\n
    """
    source = self.add_vertex(source_label)
    target = self.add_vertex(target_label)

    # link source to target, and mirror it back when undirected.
    source.outgoing.append(Edge(source, target, weight))
    if not self.directed:
      target.outgoing.append(Edge(target, source, weight))

  def vertex(self, label: Label) -> Vertex[Label]:
    """
      The vertex carrying `label` (raises KeyError if absent).\n
    """
    return self._vertices[label]

  @property
  def vertices(self) -> list[Vertex[Label]]:
    """
      Every vertex, in insertion order.\n
    """
    return list(self._vertices.values())

  @property
  def labels(self) -> list[Label]:
    """
      Every vertex label, in insertion order.\n
    """
    return list(self._vertices)

  def edges(self) -> Iterator[Edge[Label]]:
    """
      Each edge once — an undirected edge is yielded a single time.\n
    """
    # track undirected endpoint pairs so each is emitted only once.
    seen: set[frozenset[Label]] = set()

    for vertex in self._vertices.values():
      for edge in vertex.outgoing:
        # skip an undirected edge already yielded from the other endpoint.
        if not self.directed:
          endpoints = frozenset((edge.source.label, edge.target.label))
          if endpoints in seen:
            continue
          seen.add(endpoints)

        yield edge

  def __contains__(self, label: Label) -> bool:
    return label in self._vertices

  def __iter__(self) -> Iterator[Vertex[Label]]:
    return iter(self._vertices.values())

  def __len__(self) -> int:
    return len(self._vertices)

How a map app really routes

Dijkstra explores in every direction at once, which is wasteful on a continent-sized road network. Production route planners keep the relaxation primitive but prune the search hard.

A* search. Give the algorithm a heuristic , a lower bound on the remaining distance from to the target (straight-line distance on a map). A* extracts the vertex minimizing instead of , biasing the frontier toward the goal.2 When is admissible (never overestimates) and consistent (), A* returns an exact shortest path while touching far fewer vertices than Dijkstra — and with it is Dijkstra, so the two sit on one spectrum. A* is really Dijkstra on the reweighted graph , and it is consistency that keeps those weights non-negative.

A* versus Dijkstra on a grid. Dijkstra's frontier (light) expands as a disk around ; A*'s (blue) is pulled toward by the heuristic, settling far fewer cells.

Bidirectional search runs two Dijkstras at once, one forward from and one backward from , and stops when their frontiers meet; each explores roughly a hemisphere instead of a full ball, halving the exponent of the searched area.

Contraction hierarchies go further for the road-network case where the graph is fixed and queried millions of times.3 A one-time preprocessing pass ranks vertices by importance and adds shortcut edges that bypass unimportant ones, so a query only ever climbs the hierarchy from and toward their meeting point. After preprocessing, continent-scale point-to-point queries finish in microseconds — the machinery behind the instant routes in a navigation app.

On the theory side, Duan, Mao, Mao, Shu, and Yin (2025) gave the first SSSP algorithm to break Dijkstra's sorting bottleneck on directed graphs with real non-negative weights, running in — evidence that even this settled-seeming problem still has room below .4

This continues in All-Pairs and Negative Weights, where we give up the greedy schedule to handle negative edges (Bellman-Ford as a dynamic program) and compute the distance between every pair of vertices (Floyd-Warshall).

Footnotes

  1. CLRS, Ch. 24 & 25 — Single-Source and All-Pairs Shortest Paths — relaxation, the triangle inequality, and optimal substructure.
  2. Hart, Nilsson & Raphael (1968), A Formal Basis for the Heuristic Determination of Minimum Cost Paths, IEEE Trans. Systems Science and Cybernetics 4(2), 100–107 — the A* algorithm and its admissibility conditions.
  3. Geisberger, Sanders, Schultes & Delling (2008), Contraction Hierarchies: Faster and Simpler Hierarchical Routing in Road Networks, Proc. WEA 2008 — shortcut-based preprocessing for fast road-network queries.
  4. Duan, Mao, Mao, Shu & Yin (2025), Breaking the Sorting Barrier for Directed Single-Source Shortest Paths, Proc. STOC 2025 — SSSP in , below Dijkstra's sorting bound.
Practice

╌╌ END ╌╌