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

back to Shortest Paths

Vertex-Disjoint Shortest Paths

The site's twelfth Shortest Paths entry, and a direct extension of Suurballe's Algorithm: that page's own Pitfalls section names the gap and leaves it explicitly unbuilt — "edge-disjoint is not vertex-disjoint," and a caller who actually needs a backup route immune to one node failing, not just one link, needs something more. This page builds that something more, and it turns out to need no new search algorithm at all — just a graph transformation in front of the algorithm that already exists.

The question

Suurballe's own demo page works a small, deliberately constructed example: two paths, S→X→A→T and S→Y→X→T, that share zero edges and are confirmed optimal against brute force — but both pass through node X. For a link failure, that's a perfectly good disjoint pair. For a node failure — a router going down, not just a cable — it's useless: lose X and both "backup" paths die at once. The question this page answers is the strictly harder one: find two paths from source to target, minimizing combined cost, that share no vertex at all except the source and target themselves.

The fix, usually credited to Suurballe and Tarjan's own 1984 follow-up paper, is a node-splitting transform: split every node except the source and target into an in-copy and an out-copy, joined by a single edge of cost zero. Every original edge (u, v) becomes (uout, vin) in the transformed graph. Now the only way to pass all the way through a node is to cross its one in→out edge — so if two paths ever try to share that node, they collide on that shared edge, and edge-disjointness (which Suurballe's algorithm already guarantees) becomes vertex-disjointness for free. No new search logic; the exact same suurballe() call from the previous page runs unmodified on the transformed graph.

Try it

Same five stops as Suurballe's own demo — S, X, A, Y, T — and the same edge weights, so the first two steps below should look familiar: the cheapest edge-disjoint pair really does share X. From there, watch every node except S and T split into an in/out pair (dotted zero-cost edge between them), Suurballe's algorithm run once on that bigger graph, and the result collapse back onto the original five stops as two paths that share nothing but the endpoints — at one extra unit of cost.

Press Step or Run.

Why it works

The split edge is the only door through a node, and Suurballe's algorithm already refuses to use one door twice. In the transformed graph, every real edge lands on an out-copy's outgoing side and an in-copy's incoming side — there is no way to arrive at a node and leave it again without crossing that node's own in→out edge, because that's the only edge connecting the two halves. Suurballe's algorithm returns two paths guaranteed to share no edge in whatever graph it's given; apply it to a graph where "passing through node v" and "using edge vin→vout" are the same event, and edge-disjointness and vertex-disjointness become the same guarantee.

The split edge has to cost exactly zero, or the algorithm silently starts optimizing a different problem. Suurballe's algorithm minimizes total edge weight along both paths combined; giving every crossed node a nonzero toll would mean the "cheapest vertex-disjoint pair" it finds is cheapest under a cost function that includes made-up per-node charges, not the original edge weights the caller actually cares about. Zero-cost keeps the objective identical to the original problem — it exists purely to give the algorithm's own edge-disjointness machinery something to collide on, not to change what "cheapest" means.

Source and target don't get split, because they don't need the guarantee. Both returned paths visit S and T exactly once each, by definition — every source-to-target path starts at S and ends at T. There's nothing to protect against: two paths "sharing" their own shared endpoints isn't a failure mode, it's the question's own premise. Splitting them anyway doesn't just add unneeded bookkeeping — see the first pitfall below for what actually goes wrong.

Reference implementation

The transform, plus the exact suurballe()/dijkstra()/ reconstructEdges() functions from the previous page, unmodified — only the graph handed to them changes:

// nodes: array of node ids. edges: [{a, b, w}, ...]. source/target are NOT split.
function splitGraph(nodes, edges, source, target) {
  const tNodes = [];
  const tEdges = [];
  const inId = n => n + '_in', outId = n => n + '_out';
  for (const n of nodes) {
    if (n === source || n === target) { tNodes.push(n); continue; }
    tNodes.push(inId(n), outId(n));
    tEdges.push({ a: inId(n), b: outId(n), w: 0 }); // the one door through n
  }
  const outSide = n => (n === source || n === target) ? n : outId(n);
  const inSide = n => (n === source || n === target) ? n : inId(n);
  for (const { a, b, w } of edges) {
    tEdges.push({ a: outSide(a), b: inSide(b), w });
  }
  return { tNodes, tEdges };
}

// collapse a transformed path back to original node ids, dropping consecutive
// duplicates (an in-copy immediately followed by its own out-copy).
function collapsePath(path) {
  const out = [];
  for (const p of path) {
    const base = p.endsWith('_in') ? p.slice(0, -3) : p.endsWith('_out') ? p.slice(0, -4) : p;
    if (out.length === 0 || out[out.length - 1] !== base) out.push(base);
  }
  return out;
}

// nodes/edges: original graph. Returns { path1, path2, totalCost } in original node ids, or null.
function vertexDisjointPaths(nodes, edges, source, target) {
  const { tNodes, tEdges } = splitGraph(nodes, edges, source, target);
  const result = suurballe(tNodes, tEdges, source, target); // unmodified from Suurballe's page
  if (!result) return null;
  return {
    path1: collapsePath(result.path1),
    path2: collapsePath(result.path2),
    totalCost: result.totalCost,
  };
}

Pitfalls

Splitting the source or target too doesn't just add unnecessary work — it breaks the algorithm outright. If S gets split like every other node, both returned paths have to leave through the single edge Sin→Sout — but edge-disjointness forbids using that edge twice, so the second path can't leave S at all, and the search reports no path exists. On the demo's own graph, the correct answer (cost 9, routing around X) exists and is found; splitting S and T as well turns that into null instead. This isn't a one-off on this particular graph: across 5,740 random directed graphs (5-7 nodes) where a genuine vertex-disjoint pair exists, splitting the endpoints reported "impossible" in 100% of them — not occasionally worse, always wrong, because the source only ever has the one door out regardless of how many real edges leave it.

Wiring the transform's edges to bypass the split door lets two paths share a node without either of them ever touching the edge that's supposed to catch it. The transform above routes every original edge as out-copy → in-copy; a plausible-looking variant that instead wires edges as in-copy → in-copy (or out-copy → out-copy) still produces a graph Suurballe's algorithm happily runs on and returns two genuinely edge-disjoint paths for — the bug is silent, not a crash. But if every original edge lands on the in-copy at both ends, a path can walk straight through a node's in-copy and out again on another in-copy edge without ever crossing that node's own zero-cost door, so two different paths can each do this through the same node and never collide. On the demo's graph, this variant reproduces plain edge-disjoint Suurballe's original answer exactly — cost 8, both paths routed through X — silently giving up the entire guarantee this page exists to add. Checked against 5,740 random graphs with a genuine solution: this miswiring shares a vertex in 31 of them (0.54%) — rare on unstructured random graphs, because most don't have a real "chokepoint" node worth exploiting, which is exactly why the demo's graph was built by hand around one rather than found by random search.

Verification: the reference implementation above was checked against brute-force enumeration of every simple path pair (filtering for shared interior vertices, not shared edges) on 3,000 random directed graphs (5-7 nodes, sparse), 0 total-cost mismatches across every case where a vertex-disjoint pair existed at all — including agreement on the 2,116-of-3,000 cases (correctly) reporting none exists. Both pitfall variants above were checked against the same graph set, not just the hand-built demo example, before writing the percentages down.

Complexity

O((V + E) log V) — identical to plain Suurballe's algorithm, run once on the transformed graph. The transform itself is a single O(V + E) pass that at most doubles the node count (every node but two gains a partner) and adds at most V - 2 new zero-cost edges to the existing E, so the transformed graph's own V and E stay within a small constant factor of the original — same asymptotic class, more nodes and edges for the same two Dijkstra calls to walk. Space: O(V + E) for the transformed graph, same reasoning.

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