Cairn
algorithms · shortest paths · O((V+E) log V)

back to Shortest Paths

Suurballe's Algorithm

The site's ninth Shortest Paths entry, and the second (after Yen's Algorithm) to answer something other than "what's the single cheapest path." Yen's asks for the K cheapest paths, ranked — and those paths are free to reuse each other's edges. Suurballe's algorithm asks a narrower, stricter question: find two paths from source to target that share no edge at all, minimizing their combined cost. That's the question a network operator actually has when planning a backup route — a second-best path that still crosses the one physical link most likely to fail alongside the first isn't a backup at all, it's the same single point of failure wearing a different name.

The question

It's tempting to reach for Yen's algorithm here — ask for the two cheapest ranked paths and use both. That's cheaper to compute, but it doesn't promise disjointness: the second-ranked path is just the next-cheapest loopless path, and nothing stops it from sharing an edge with the first if that edge happens to sit on both routes' cheapest way through. The demo below has exactly this shape — the two cheapest paths overall share a real edge, and Suurballe's algorithm pays more to specifically avoid it.

Suurballe's algorithm gets the disjointness guarantee from a three-step trick: run Dijkstra's Algorithm once to find the first path, use its own distances to reweight every edge non-negative — the exact technique Johnson's Algorithm already uses to let Dijkstra run safely after a negative-edge pass — then reverse the first path's own edges at cost zero and run Dijkstra a second time. Whatever the second search finds, combined with what's left of the first path after any overlap cancels out, decomposes into two genuinely edge-disjoint paths. Two Dijkstra calls, not a search per candidate deviation — cheaper than Yen's despite answering a stricter question.

Try it

Five stops, eight one-way roads. Press Step or Run to watch all five phases in order: first Dijkstra finds P1 (accent, thick); every edge gets relabeled w→w' with its reduced cost; P1's three edges flip direction and drop to cost 0 (dashed); a second Dijkstra runs on that modified graph and finds a path that doubles back along one of those reversed edges; that reversal cancels the P1 edge it reverses, and everything left combines into two final paths — path A (ink, solid) and path B (accent, solid) — that share zero edges. Watch the dist strip below the graph track whichever search is currently running (d1 for the first pass, d2' — reduced-cost units, not real distance — for the second).

Press Step or Run.

Why it works

Two separate facts have to both be true, and the demo above lets you check each one directly.

Reweighting via P1's own distances never breaks non-negativity, for the same reason Johnson's Algorithm relies on. The reduced weight of an edge (u, v) is w'(u, v) = w(u, v) + d1(u) - d1(v). Dijkstra's own shortest-path property guarantees d1(v) <= d1(u) + w(u, v) for every edge — otherwise d1(v) wasn't really shortest — which rearranges to exactly w'(u, v) >= 0. P1's own edges are the tight case: d1(v) = d1(u) + w(u, v) by construction, so their reduced weight lands on exactly 0. Johnson's own page proves this in more depth; Suurballe's algorithm reuses the identical fact for a completely different purpose — not to survive a negative edge, but to make the reversed copy of a zero-cost tree edge stay non-negative too.

The cancellation is what turns "two searches" into "two disjoint paths," not just two paths that happen not to collide. The second search is free to walk backward along any of P1's reversed edges — in the demo, it does, doubling back B→A across the reversed copy of P1's own A→B edge. That's not a bug or a coincidence: it means the second search found it cheaper to borrow part of P1's route and give part of it back than to build an entirely separate path from scratch. Once that reversal is walked, the corresponding forward edge is cancelled out of the combined result — P1 contributes S→A and B→T but not A→B anymore, and the second search's real (non-reversal) edges, S→B and A→T, fill the gap. What's left decomposes cleanly into two paths from S to T that touch every remaining edge exactly once, in one direction — this is the same successive-shortest-augmenting-path idea min-cost max-flow uses to push a second unit of flow through a residual graph; Suurballe's algorithm is that same technique specialized to unit-capacity edges and exactly two units.

Reference implementation

Adjacency-list version — edges is a flat list of {a, b, w} records, one per directed edge, matching every other graph algorithm on this site:

function dijkstra(nodes, adj, source) {
  const dist = new Map(nodes.map(n => [n, Infinity]));
  const prevEdge = new Map(); // node -> { from, isReversal, realW }
  dist.set(source, 0);
  const visited = new Set();
  while (true) {
    let u = null, best = Infinity;
    for (const n of nodes) {
      if (!visited.has(n) && dist.get(n) < best) { best = dist.get(n); u = n; }
    }
    if (u === null) break;
    visited.add(u);
    for (const e of (adj.get(u) || [])) {
      const nd = dist.get(u) + e.rw;
      if (nd < dist.get(e.to)) {
        dist.set(e.to, nd);
        prevEdge.set(e.to, { from: u, isReversal: e.isReversal, realW: e.realW });
      }
    }
  }
  return { dist, prevEdge };
}

function reconstructEdges(prevEdge, source, target) {
  const edges = [];
  let cur = target;
  while (cur !== source) {
    const pe = prevEdge.get(cur);
    if (pe === undefined) return null; // unreachable
    edges.push({ from: pe.from, to: cur, isReversal: pe.isReversal, realW: pe.realW });
    cur = pe.from;
  }
  return edges.reverse();
}

// edges: [{a, b, w}, ...]. Returns { path1, path2, totalCost } or null.
function suurballe(nodes, edges, source, target) {
  const adj1 = new Map();
  for (const { a, b, w } of edges) {
    if (!adj1.has(a)) adj1.set(a, []);
    adj1.get(a).push({ to: b, rw: w, isReversal: false, realW: w });
  }
  const { dist: d1, prevEdge: prevEdge1 } = dijkstra(nodes, adj1, source);
  if (d1.get(target) === Infinity) return null; // no path exists at all

  const p1Edges = reconstructEdges(prevEdge1, source, target);
  const p1Keys = new Set(p1Edges.map(e => e.from + '->' + e.to));

  // build the modified graph: every non-P1 edge gets its reduced weight,
  // every P1 edge is replaced by its own reverse at cost 0
  const adj2 = new Map();
  function addEdge(a, b, rw, isReversal, realW) {
    if (!adj2.has(a)) adj2.set(a, []);
    adj2.get(a).push({ to: b, rw, isReversal, realW });
  }
  for (const { a, b, w } of edges) {
    if (d1.get(a) === Infinity || p1Keys.has(a + '->' + b)) continue;
    addEdge(a, b, w + d1.get(a) - d1.get(b), false, w);
  }
  for (const e of p1Edges) addEdge(e.to, e.from, 0, true, e.realW);

  const { dist: d2, prevEdge: prevEdge2 } = dijkstra(nodes, adj2, source);
  if (d2.get(target) === Infinity) return null; // no *second* disjoint path exists

  const p2Edges = reconstructEdges(prevEdge2, source, target);

  // cancellation: a reversal hop in P2' removes the matching P1 edge instead of being added
  const kept = p1Edges.map(e => ({ ...e, active: true }));
  const added = [];
  for (const pe of p2Edges) {
    if (pe.isReversal) {
      kept.find(k => k.active && k.from === pe.to && k.to === pe.from).active = false;
    } else {
      added.push({ from: pe.from, to: pe.to, realW: pe.realW });
    }
  }
  const combined = kept.filter(e => e.active).concat(added);

  // decompose the combined edge set into two source-to-target paths
  const outAdj = new Map();
  combined.forEach((e, idx) => {
    if (!outAdj.has(e.from)) outAdj.set(e.from, []);
    outAdj.get(e.from).push(idx);
  });
  const used = new Array(combined.length).fill(false);
  function extractPath() {
    const path = [source];
    let cur = source, cost = 0;
    while (cur !== target) {
      const idx = (outAdj.get(cur) || []).find(i => !used[i]);
      if (idx === undefined) return null;
      used[idx] = true;
      cost += combined[idx].realW;
      cur = combined[idx].to;
      path.push(cur);
    }
    return { path, cost };
  }
  const path1 = extractPath(), path2 = extractPath();
  if (!path1 || !path2) return null;
  return { path1: path1.path, path2: path2.path, totalCost: path1.cost + path2.cost };
}

Pitfalls

Sometimes no second disjoint path exists, and that's a correct answer, not a bug. Checked directly: a 4-node graph where the target has only one real incoming edge (S→A→B→T plus a dead-end alternate S→B that never reconnects) makes the second Dijkstra's distance to the target come back Infinity. The reference implementation above returns null in exactly this case — a caller that assumes suurballe() always succeeds whenever a path from source to target exists will crash or silently misread null as "zero-cost," not "impossible."

Skipping the edge-reversal step can make a real, findable disjoint pair look impossible. On a small graph — S→A(1)→B(1)→T(1) as P1, plus only S→B(3) and A→T(5) as alternates, no other edges — deleting P1's edges outright (instead of reversing them) leaves the second search with nowhere to go once it reaches B: B→T is gone, and that's B's only remaining edge, so distance to T comes back Infinity. The real algorithm, run on the identical graph, reverses A→B into a usable B→A hop and finds S→A→T (cost 6) plus S→B→T (cost 4) — a genuine disjoint pair at total cost 10 that the delete-only version misses completely, not just prices worse. Checked against brute-force enumeration to confirm 10 really is optimal, not a coincidence of this one graph.

Edge-disjoint is not vertex-disjoint. The two paths this algorithm returns are guaranteed to share no edge, but nothing stops them from passing through the same intermediate node by different edges. Checked directly on a small constructed graph (S→X→A→T as P1, plus a second route S→Y→X→T that reaches T through a completely different edge out of X): the algorithm correctly returns both paths, and they share node X while sharing zero edges — confirmed against brute-force enumeration, not just eyeballed. A caller that actually needs vertex-disjoint backup routes — protection against a node failing, not just a link — needs the node-splitting extension (split every node into an in-copy and an out-copy joined by a zero-cost edge, forcing node reuse to cost an edge) usually credited to Suurballe and Tarjan's 1984 follow-up paper; not built on this site.

The two returned paths can differ from a brute-force search's own optimal pair while still being correct. When several disjoint pairs tie at the same minimum total cost, this algorithm's specific decomposition order picks one of them — checked directly against the stress harness below, where a brute-force check confirmed the total cost matched in every trial without requiring the exact same two paths.

Verification: the reference algorithm above was checked against brute-force enumeration of every simple path pair on 6,000 random directed graphs (5-8 nodes, with and without parallel edges) — 0 total-cost mismatches, 0 non-disjoint results, 0 crashes. An earlier draft of the verification harness itself had a real bug worth naming: it looked up an edge's weight from a plain (from, to)-keyed map to compute final path cost, which silently returned the wrong number whenever the graph had two parallel edges between the same pair of nodes. The fix was carrying each edge's actual weight alongside the path reconstruction instead of re-deriving it from a lookup table — the same class of mistake the algorithm itself has to avoid when a genuine edge and a same-pair reversal edge coexist in the modified graph.

Complexity

O((V + E) log V) with a heap-based Dijkstra: two Dijkstra calls dominate, reweighting is a single O(E) pass over the edge list, and cancellation plus path decomposition is O(V + E). That's the same order as running Dijkstra twice — nowhere near Yen's O(K · V · (V + E) log V), because there's no per-deviation search: the second Dijkstra call does the entire job of finding the best possible second path in one pass, using the reversal trick instead of trying every possible departure point from the first path by hand. Space: O(V + E) for the modified graph, the same as the input.

This site's guide, Choosing a Shortest-Path Algorithm, compares this entry against the other ten Shortest Paths entries side by side.