Three different problems:

  • shortest walks (vertices may repeat): pop each vertex up to times in Dijkstra, ; or Eppstein’s sidetrack heaps, .
  • shortest simple paths: Yen’s algorithm, .
  • disjoint paths of minimum total cost: min-cost flow of value (Suurballe for ); “shortest, then delete it, then shortest again” is wrong.

shortest walks by repeated popping

vector<ll> kWalks(const vector<vector<pair<int, ll>>>& g, int s, int t, int k) {   // CSES Flight Routes
    vector<int> cnt(sz(g)); vector<ll> res;
    priority_queue<pair<ll, int>, vector<pair<ll, int>>, greater<>> pq; pq.push({0, s});
    while (sz(pq) && sz(res) < k) {
        auto [d, u] = pq.top(); pq.pop();
        if (cnt[u]++ >= k) continue;
        if (u == t) res.push_back(d);
        for (auto [v, w] : g[u]) pq.push({d + w, v});
    }
    return res;
}

Eppstein: sidetrack heaps

Compute = distance from to and the shortest-path tree towards . For a non-tree edge the sidetrack cost is . Every – walk is the tree path with a sequence of sidetracks, and its length is . Store, for every vertex , a persistent leftist heap of all sidetracks leaving the tree path from to : . A best-first search over these heaps enumerates walks in order.

vector<ll> eppstein(int n, const vector<array<ll, 3>>& E, int s, int t, int k) {   // edges u -> v, w >= 0
    const ll INF = LLONG_MAX / 4;
    vector<vector<pair<int, int>>> rg(n);                       // reversed: (from, edge id)
    for (int i = 0; i < sz(E); i++) rg[E[i][1]].push_back({(int)E[i][0], i});
    vector<ll> d(n, INF); vector<int> tree(n, -1), order;      // tree[u]: edge u -> next(u)
    priority_queue<pair<ll, int>, vector<pair<ll, int>>, greater<>> pq; d[t] = 0; pq.push({0, t});
    while (sz(pq)) {
        auto [du, u] = pq.top(); pq.pop(); if (du != d[u]) continue; order.push_back(u);
        for (auto [v, e] : rg[u]) if (du + E[e][2] < d[v]) d[v] = du + E[e][2], tree[v] = e, pq.push({d[v], v});
    }
    if (d[s] >= INF) return {};
    struct Node { ll key; int to, l, r, rk; };
    vector<Node> T{{0, 0, 0, 0, 0}};                           // node 0 = empty heap
    auto merge = [&](auto&& self, int a, int b) -> int {      // persistent leftist heap merge
        if (!a || !b) return a ^ b;
        if (T[a].key > T[b].key) swap(a, b);
        T.push_back(T[a]); int c = sz(T) - 1;
        int r = self(self, T[c].r, b); T[c].r = r;
        if (T[T[c].l].rk < T[T[c].r].rk) swap(T[c].l, T[c].r);
        T[c].rk = T[T[c].r].rk + 1;
        return c;
    };
    vector<vector<int>> out(n); for (int i = 0; i < sz(E); i++) out[E[i][0]].push_back(i);
    vector<int> H(n, 0);
    for (int u : order) {                                       // next(u) is popped before u
        if (u != t) H[u] = H[E[tree[u]][1]];
        for (int e : out[u]) {
            int v = E[e][1]; if (e == tree[u] || d[v] >= INF) continue;
            T.push_back({E[e][2] + d[v] - d[u], v, 0, 0, 1}); int x = sz(T) - 1;
            H[u] = merge(merge, H[u], x);
        }
    }
    vector<ll> res{d[s]};
    priority_queue<pair<ll, int>, vector<pair<ll, int>>, greater<>> q;
    if (H[s]) q.push({d[s] + T[H[s]].key, H[s]});
    while (sz(q) && sz(res) < k) {
        auto [c, x] = q.top(); q.pop(); res.push_back(c);
        for (int y : {T[x].l, T[x].r}) if (y) q.push({c - T[x].key + T[y].key, y});   // swap this sidetrack for a sibling
        if (int y = H[T[x].to]) q.push({c + T[y].key, y});                              // add one more sidetrack after it
    }
    return res;
}

(Library Checker “K-Shortest Walk”. Recursion in merge is deep.) For specifically, don’t reach for this machinery: a forward Dijkstra, a reverse Dijkstra, and one scan over the edges for the best sidetrack gives the second shortest walk directly.

Yen: shortest simple paths

For the -th path, for each spur position on the previous path: fix the root (first edges), forbid the next edge of every already-found path with the same root, forbid the root’s vertices, and find the shortest spur path to . Candidates go into a set; the cheapest becomes the next path. Lawler’s modification (worth having): remember, per already-found path, the deviation index it was branched from, and only re-spur at positions at or after that index — this skips recomputing spurs that provably can’t produce anything new, a constant-factor speedup. On a DAG, shortest walks is simpler still: a DP keeping the best values at each vertex, no heap needed.

vector<pair<ll, vector<int>>> yen(int n, const vector<array<ll, 3>>& E, int s, int t, int K) {   // paths as edge-id lists
    const ll INF = LLONG_MAX / 4; vector<vector<int>> out(n);
    for (int i = 0; i < sz(E); i++) out[E[i][0]].push_back(i);
    auto sp = [&](int src, const vector<char>& banV, const vector<char>& banE) -> pair<ll, vector<int>> {
        vector<ll> d(n, INF); vector<int> pe(n, -1); priority_queue<pair<ll, int>, vector<pair<ll, int>>, greater<>> pq;
        d[src] = 0; pq.push({0, src});
        while (sz(pq)) {
            auto [du, u] = pq.top(); pq.pop(); if (du != d[u]) continue;
            for (int e : out[u]) { int v = E[e][1]; if (banE[e] || banV[v] || du + E[e][2] >= d[v]) continue; d[v] = du + E[e][2]; pe[v] = e; pq.push({d[v], v}); }
        }
        if (d[t] >= INF) return {INF, {}};
        vector<int> p; for (int v = t; v != src; v = E[pe[v]][0]) p.push_back(pe[v]);
        reverse(all(p)); return {d[t], p};
    };
    vector<pair<ll, vector<int>>> A; set<pair<ll, vector<int>>> B;
    vector<char> banV(n), banE(sz(E));
    auto first = sp(s, banV, banE); if (first.first >= INF) return A;
    if (s == t) return {{0, {}}};
    A.push_back(first);
    while (sz(A) < K) {
        const auto prev = A.back().second; ll rootCost = 0; int spur = s;
        for (int i = 0; i < sz(prev); i++) {
            vector<int> root(prev.begin(), prev.begin() + i);
            for (auto& [c, p] : A) if (sz(p) > i && equal(root.begin(), root.end(), p.begin())) banE[p[i]] = 1;
            for (int e : root) banV[E[e][0]] = 1;
            auto [c, spurPath] = sp(spur, banV, banE);
            if (c < INF) { auto full = root; full.insert(full.end(), all(spurPath)); B.insert({rootCost + c, full}); }
            fill(all(banV), 0); fill(all(banE), 0);
            rootCost += E[prev[i]][2]; spur = E[prev[i]][1];
        }
        if (B.empty()) break;
        A.push_back(*B.begin()); B.erase(B.begin());
    }
    return A;
}

Disjoint paths

  • edge-disjoint (vertex-disjoint after splitting) paths minimising total cost: min-cost flow of value with unit capacities (Min-Cost Flow). For this is Suurballe’s algorithm: Dijkstra, reweight by potentials, reverse the first path’s edges, Dijkstra again, cancel opposite edges. For general , run rounds of successive shortest augmenting paths the same way (Suurballe–Tarjan), — the telecom motivation is a primary route plus backups that each survive a single node failure.
  • Maximum number of disjoint paths: max flow with unit capacities; Menger’s theorem says it equals the minimum cut (Min Cut).
  • Two vertex-disjoint paths between fixed pairs in undirected graphs is polynomial but intricate (2-linkage); in directed graphs it is NP-hard.