Graphs/All-Pairs and Negative Weights

Lesson 6.74,207 words

All-Pairs and Negative Weights

Dijkstra's greedy schedule breaks the moment an edge goes negative. We give it up for dynamic programming: Bellman-Ford derived as a DP over edge budgets, with its negative-cycle detector, and Floyd-Warshall computing the distance between every pair of vertices via a DP over which vertices a path may pass through.

╌╌╌╌

This builds on Shortest Paths, which developed the relaxation primitive and Dijkstra's greedy algorithm for non-negative weights. Dijkstra's whole correctness argument leaned on non-negativity in one place — extending a path never lowers its cost — and one negative edge breaks it. Handling negative weights means abandoning the greedy schedule for dynamic programming, which is where we pick up.

Bellman-Ford as a dynamic program

Dijkstra breaks when edges can be negative — its greedy finalization assumes a finalized estimate can never improve, which negative edges violate. trades speed for generality. It is best derived not as relax everything repeatedly but as a genuine dynamic program. That framing is worth keeping, because it explains where the comes from.

The subproblem. Bound the number of edges a walk may use. For each vertex and each budget , define

The full answer we want is for , since (as we prove below) shortest paths never need more than edges.

The recurrence. A cheapest walk to using at most edges either uses at most edges already, or it takes one final edge after a cheapest -edge walk to . Minimizing over both:

with base cases and for . This is relaxation in another guise: filling row of the table from row is one full pass that relaxes every edge. The tabular form makes the layering explicit, with row holding the best walk of length :

Algorithm 3:Bellman-Ford-DP(G,w,s)\textsc{Bellman-Ford-DP}(G, w, s) — the explicit DP table
  1. 1
    OPT[0,s]0\text{OPT}[0, s] \gets 0; OPT[0,v]\text{OPT}[0, v] \gets \infty for vsv \neq s
  2. 2
    for k1k \gets 1 to n1n - 1 do
    one pass over all edges per row
  3. 3
    foreach vertex vVv \in V do
  4. 4
    OPT[k,v]OPT[k1,v]\text{OPT}[k, v] \gets \text{OPT}[k-1, v]
    inherit: no extra edge
  5. 5
    foreach edge (u,v)E(u, v) \in E do
  6. 6
    if OPT[k1,u]+w(u,v)<OPT[k,v]\text{OPT}[k-1, u] + w(u, v) < \text{OPT}[k, v] then
  7. 7
    OPT[k,v]OPT[k1,u]+w(u,v)\text{OPT}[k, v] \gets \text{OPT}[k-1, u] + w(u, v)
  8. 8
    v.πuv.\pi \gets u
  9. 9
    return OPT[n1,]\text{OPT}[n-1, \cdot] and π\pi

Why stop after rounds? This is the theorem that licenses the whole algorithm.

So the rows of the DP stop changing by row .

A second, sharper argument gives the same bound, phrased directly in terms of relaxation.1 It explains not just that rounds suffice but which vertices are already correct after each round.

Round of Bellman-Ford relaxes every edge, in particular the -th edge of any shortest path you care to name, and it does so after round handled the -st. So after round , every vertex whose shortest path uses at most edges holds its exact distance, and since no shortest path needs more than edges, rounds settle every vertex. The correct region grows along every shortest path by at least one edge per round:

Path relaxation along one shortest path . Round relaxes every edge, in particular edge of this path; each bracket marks the prefix whose estimates are guaranteed exact after that round.

Saving space. The recurrence only ever reads row , so we collapse the table to a single 1-D array updated in place, recovering the familiar form of Bellman-Ford as rounds of relaxing every edge. (In-place updates can only make estimates better than the strict row-by-row schedule; the path-relaxation lemma tolerates the extra interleaved relaxations.) Negative edges are fine; only a negative cycle breaks the theorem.

Algorithm 4:Bellman-Ford(G,w,s)\textsc{Bellman-Ford}(G, w, s) — space-saved; with cycle check
  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
    for i1i \gets 1 to n1n - 1 do
    V-1 full-relaxation rounds
  6. 6
    foreach edge (u,v)E(u, v) \in E do
  7. 7
    call Relax(u,v,w)\textsc{Relax}(u, v, w)
  8. 8
    foreach edge (u,v)E(u, v) \in E do
    extra pass: detect neg cycle
  9. 9
    if u.d+w(u,v)<v.du.d + w(u, v) < v.d then
  10. 10
    return false
    neg cycle reachable from s
  11. 11
    return true

Detecting negative cycles. After rounds, if any edge can still be relaxed, some walk was improved using edges — only possible if a negative cycle lets the cost descend without bound. So one extra pass serves as the test: relax everything once more, and any successful relaxation certifies a negative cycle reachable from (where cheapest cost to reach a vertex is no longer even well-defined).2 The extra pass is a decision procedure, and it is both sound and complete: a relaxation can succeed only when a reachable negative cycle exists (soundness, no false alarm), and any such cycle forces some edge to relax on that pass (completeness, none is missed). Dijkstra entirely lacks that capability. The cost is : passes, each relaxing all edges.

Laid out as the DP table , the layering is plain: row holds the cheapest walk of at most edges, each row computed from the one above by a single full pass of relaxation. The entries that improved on each row are shaded; the table stops changing after row , well within the bound:

The Bellman-Ford DP table for the trace digraph. Row = cheapest walk of edges; shaded cells improved that round. Rows freeze after .
bellman_ford.pypython
from collections.abc import Hashable
from typing import Generic, NamedTuple, Optional, TypeVar

from graph import Graph

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

class BellmanFordResult(NamedTuple, Generic[Label]):
  """
    A Bellman-Ford run. `has_negative_cycle` is True when a negative cycle is\n
    reachable from the source, in which case the distances are not meaningful.\n
    Otherwise `distance` and `predecessor` describe the shortest-path tree.\n
  """
  distance: dict[Label, float]
  predecessor: dict[Label, Optional[Label]]
  has_negative_cycle: bool

  def path_to(self, target: Label) -> Optional[list[Label]]:
    """
      The shortest path from the source to `target`, or None if it is\n
      unreachable. Meaningless when `has_negative_cycle` is True.\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 bellman_ford(graph: Graph[Label], source: Label) -> BellmanFordResult[Label]:
  """
    Shortest paths from `source` over a graph that may carry negative edges.\n
    Relaxes every edge V-1 times, then one extra pass: any edge that can\n
    still relax proves a negative cycle is reachable from `source`.\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
  vertex_count: int = len(graph)

  # V-1 rounds; each round is one full pass relaxing every edge.
  for _ in range(vertex_count - 1):
    relaxed_any: bool = False
    for edge in graph.edges():
      source_label: Label = edge.source.label
      target_label: Label = edge.target.label
      candidate: float = distance[source_label] + edge.weight

      # a cheaper route to the target improves its estimate this round.
      if candidate < distance[target_label]:
        distance[target_label] = candidate
        predecessor[target_label] = source_label
        relaxed_any = True

    # an early fixpoint means no further round can change anything.
    if not relaxed_any:
      break

  # extra pass: a successful relaxation now can only come from a neg cycle.
  for edge in graph.edges():
    source_label = edge.source.label
    if distance[source_label] + edge.weight < distance[edge.target.label]:
      return BellmanFordResult(distance, predecessor, True)

  return BellmanFordResult(distance, predecessor, False)
dag_shortest_paths.pypython
from collections.abc import Hashable
from typing import Generic, NamedTuple, Optional, TypeVar

from graph import Graph

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

class DagPaths(NamedTuple, Generic[Label]):
  """
    Shortest-path distances and predecessors from a fixed source on a DAG.\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`, or None if 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 topological_order(graph: Graph[Label]) -> list[Label]:
  """
    A topological ordering of `graph`'s labels via Kahn's algorithm.\n
    Raises ValueError if the graph contains a directed cycle.\n
  """
  # count incoming edges for every vertex.
  indegree: dict[Label, int] = {label: 0 for label in graph.labels}
  for edge in graph.edges():
    indegree[edge.target.label] += 1

  # seed the queue with every source (indegree zero), in insertion order.
  ready: list[Label] = [label for label in graph.labels if indegree[label] == 0]
  order: list[Label] = []

  while ready:
    current_label: Label = ready.pop()
    order.append(current_label)

    # dropping this vertex may expose new sources among its successors.
    for edge in graph.vertex(current_label).outgoing:
      neighbor_label: Label = edge.target.label
      indegree[neighbor_label] -= 1
      if indegree[neighbor_label] == 0:
        ready.append(neighbor_label)

  # a leftover vertex was never freed, so some cycle trapped it.
  if len(order) != len(graph):
    raise ValueError("graph is not a DAG: a directed cycle was found")
  return order

def dag_shortest_paths(graph: Graph[Label], source: Label) -> DagPaths[Label]:
  """
    Shortest paths from `source` on a directed acyclic `graph`.\n
    Relaxes outgoing edges in one topological sweep; negative edges allowed.\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

  for current_label in topological_order(graph):
    # vertices unreachable from the source never relax their successors.
    if distance[current_label] == float("inf"):
      continue

    # one sweep: relax each outgoing edge of the now-finalized vertex.
    for edge in graph.vertex(current_label).outgoing:
      neighbor_label: Label = edge.target.label
      candidate: float = distance[current_label] + edge.weight
      if candidate < distance[neighbor_label]:
        distance[neighbor_label] = candidate
        predecessor[neighbor_label] = current_label

  return DagPaths(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)

Floyd-Warshall: all pairs at once

Sometimes we want for every pair of vertices, a full distance matrix. Running Bellman-Ford from each source costs , up to on dense graphs, but does better with a dynamic program parameterized not by edge count but by which vertices a path is allowed to pass through.

The recurrence lifts to by a case split that is exhaustive by construction: a shortest path counted by either uses vertex as an intermediate or it does not, and there is no third possibility. If it does not, its intermediates already lie in and it is counted by . If it does, then (assuming no negative cycles, we may take the path simple, so appears exactly once) splits the path into an half and a half, neither of which contains in its interior. Both halves have intermediates in , and by optimal substructure each half is a shortest path of its own class, so their costs are exactly and . Taking the better case:

Why the recurrence is exhaustive. A shortest path with intermediates in either avoids entirely — the case — or passes through exactly once, splitting into halves whose interiors use only vertices below .

Induction on turns this into correctness: is right by definition, and if holds every -limited distance, the case split shows the recurrence computes every -limited one. After round , no restriction remains.

Consider one round of the recurrence. Starting from — direct edges only — and admitting intermediates from produces : the entry drops from to via , and the once-unreachable become finite by routing through vertex then . The five matrices below trace one round per permitted intermediate; the entries that improved in each round are shaded:

Floyd-Warshall, one matrix per round. uses only vertices as intermediates; each round's pivot vertex is marked in blue on the indices (its row and column drive that round's updates), and the entries that improved when was admitted are shaded. holds the all-pairs shortest distances.

Three nested loops over , , evaluate this recurrence directly: the outer loop admits one intermediate vertex per round (exactly the five matrices above), and the inner two relax every pair against a route through it.

Algorithm:Floyd-Warshall(W)\textsc{Floyd-Warshall}(W) — all-pairs shortest paths in O(V3)O(V^3)
  1. 1
    dWd \gets W
    dijd_{ij}: edge weight; 00 on the diagonal, else \infty
  2. 2
    for k1k \gets 1 to nn do
    admit vertex kk as an intermediate
  3. 3
    for i1i \gets 1 to nn do
  4. 4
    for j1j \gets 1 to nn do
  5. 5
    if dik+dkj<dijd_{ik} + d_{kj} < d_{ij} then
    routing through kk is shorter
  6. 6
    dijdik+dkjd_{ij} \gets d_{ik} + d_{kj}
  7. 7
    return dd

The pseudocode quietly drops the superscripts and updates one matrix in place, and this needs a word of justification: during round , could an entry or that the recurrence reads have already been overwritten with a round- value? It could, but harmlessly, because row and column do not change during round : , since (and whenever no negative cycle exists). Reading new values is the same as reading old ones, so one matrix suffices.

Running time. The algebra is the shortest of the lesson: three nested loops of iterations each, with a constant-time comparison in the body: time and space. No priority queue, no per-edge bookkeeping: compact, cache-friendly, and a clean win on dense graphs.3 It handles negative edges, and a negative entry on the diagonal () flags a negative cycle through . For sparse graphs with non-negative weights, running Dijkstra from every source costs , which beats when ; Floyd-Warshall wins on dense inputs and on any input where its tiny constant factor and trivial implementation matter more than asymptotics.

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

from graph import Graph

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

class AllPairsPaths(NamedTuple, Generic[Label]):
  """
    All-pairs shortest paths. `distance[source][target]` is the cheapest cost\n
    between the pair; `successor[source][target]` is the next vertex on that\n
    path, used to reconstruct it. `has_negative_cycle` flags a negative cycle.\n
  """
  distance: dict[Label, dict[Label, float]]
  successor: dict[Label, dict[Label, Optional[Label]]]
  has_negative_cycle: bool

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

    # follow successor pointers forward from the source to the target.
    path: list[Label] = [source]
    cursor: Label = source
    while cursor != target:
      next_label: Optional[Label] = self.successor[cursor][target]
      if next_label is None:
        return None
      cursor = next_label
      path.append(cursor)

    return path

def floyd_warshall(graph: Graph[Label]) -> AllPairsPaths[Label]:
  """
    Shortest distances between every ordered pair of vertices in `graph`.\n
    Handles negative edges; sets `has_negative_cycle` when some vertex can\n
    reach itself at negative cost.\n
  """
  labels: list[Label] = graph.labels
  infinity: float = float("inf")

  # initialize with direct edges; zero on the diagonal, infinity elsewhere.
  distance: dict[Label, dict[Label, float]] = {
    source: {target: (0.0 if source == target else infinity) for target in labels}
    for source in labels
  }
  successor: dict[Label, dict[Label, Optional[Label]]] = {
    source: {target: None for target in labels} for source in labels
  }
  for edge in graph.edges():
    source_label: Label = edge.source.label
    target_label: Label = edge.target.label

    # keep the cheapest parallel edge between a pair.
    if edge.weight < distance[source_label][target_label]:
      distance[source_label][target_label] = edge.weight
      successor[source_label][target_label] = target_label

  # admit one intermediate vertex per round; relax every pair through it.
  for intermediate in labels:
    for source_label in labels:
      if distance[source_label][intermediate] == infinity:
        continue
      for target_label in labels:
        candidate: float = (
          distance[source_label][intermediate]
          + distance[intermediate][target_label]
        )

        # routing through the intermediate beats the best known direct cost.
        if candidate < distance[source_label][target_label]:
          distance[source_label][target_label] = candidate
          successor[source_label][target_label] = successor[source_label][intermediate]

  has_negative_cycle: bool = any(distance[label][label] < 0 for label in labels)
  return AllPairsPaths(distance, successor, has_negative_cycle)
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)

Choosing an algorithm

AlgorithmSolvesNegative edges?Time
BFSSSSP, unweightedn/a
DAG relaxationSSSP on a DAGyes
SSSPno
SSSP, + cycle detectionyes
all-pairsyes

The decision tree is short: unweighted, use BFS; a DAG, relax in topological order; non-negative weights, use Dijkstra (the fastest for one source); negative edges possible, use Bellman-Ford and let it detect negative cycles; every pair at once, use Floyd-Warshall on dense graphs, or repeated Dijkstra () when the graph is sparse and the weights non-negative. All five are disciplined applications of the same primitive; they differ only in which edges they relax, and in what order.

Sparse all-pairs and what negative cycles buy you

Johnson's algorithm. Floyd-Warshall's is fine on dense graphs but wasteful on sparse ones. Johnson's algorithm computes all-pairs shortest paths in — the cost of Dijkstra runs — while still tolerating negative edges.4 It uses a reweighting that removes the negatives without changing which paths are shortest. Add a dummy vertex with a zero-weight edge to every vertex, run Bellman-Ford once from to get a potential , then reweight each edge to

The triangle inequality makes every , and the terms telescope along any path, so a path's reweighted length differs from its true length by the constant — same ordering, same shortest paths. Now run Dijkstra from every source on the non-negative , and undo the shift. One Bellman-Ford pass establishes non-negativity; Dijkstra passes finish the job. (This is the same potential trick that underlies A* from the previous lesson.)

SPFA and the practical Bellman-Ford. In practice, few graphs force all Bellman-Ford rounds. The shortest-path faster algorithm keeps a queue of vertices whose estimate just improved and only relaxes out of those, often finishing in far fewer than operations — though its worst case is still , so it is a constant-factor win, not an asymptotic one. Bellman-Ford also parallelizes cleanly: every edge in a round relaxes independently, which is why GPU shortest-path codes favor it over Dijkstra's inherently sequential extraction order.

What a negative cycle is worth. Bellman-Ford's negative-cycle detector is useful in its own right: some problems ask for the cycle itself. In currency arbitrage, let each currency be a vertex and each exchange rate an edge of weight . A path's total weight is of the product of rates along it, so any cycle of negative total weight is a sequence of trades that multiplies the starting sum by more than .5 Detecting arbitrage is Bellman-Ford's extra pass, and reconstructing the offending cycle from the predecessor pointers recovers the trades.

Arbitrage as a negative cycle. Each edge carries weight , so the cycle USD to EUR to GBP to USD has total weight ; it is negative exactly when the product of rates exceeds , i.e. the loop is profitable.

The pattern generalizes: minimum-mean-cycle, difference constraints (systems of solved by a single Bellman-Ford), and deadlock detection all reduce to shortest paths with negative edges. Whenever a problem's cost is a sum you want to drive as low as possible around a loop, Bellman-Ford's cycle machinery is the tool.

Takeaways

  • Shortest paths rest on relaxation plus two structural facts: the triangle inequality and optimal substructure.
  • greedily finalizes the closest frontier vertex; the cut argument shows an extracted vertex's estimate is already exact. Correct only for non-negative weights; a single negative edge lets a cheap route arrive after its target is frozen. with a Fibonacci heap.
  • is a dynamic program: = cheapest walk of edges, and one DP row = one pass of relaxing all edges. Shortest paths need edges (short-circuit any cycle), so rounds suffice; one extra round detects negative cycles. Slower at but handles negative edges.
  • On a DAG, relaxing in topological order solves SSSP in .
  • computes all-pairs shortest paths in via a dynamic program over intermediate vertices.

Footnotes

  1. CLRS, Ch. 24 & 25 — Single-Source and All-Pairs Shortest Paths — relaxation, the triangle inequality, and optimal substructure.
  2. Erickson, Ch. 8 & 9 — Shortest Paths — Bellman-Ford's extra relaxation pass detecting a negative cycle.
  3. Skiena, §6 — Weighted Graph Algorithms — Floyd-Warshall's all-pairs dynamic program.
  4. Johnson, D. B. (1977), Efficient algorithms for shortest paths in sparse networks, Journal of the ACM 24(1), 1–13 — reweighting for all-pairs shortest paths on sparse graphs.
  5. Cormen, Leiserson, Rivest & Stein, Introduction to Algorithms (CLRS), Problem 24-3 — currency arbitrage as a negative-weight cycle under rates.
Practice

╌╌ END ╌╌