k-th Shortest Path

Prerequisites: Dijkstra's algorithm, A* algorithm, and persistent mergeable heaps

Problem statement

Given a directed graph with nn vertices and mm edges, find among all distinct paths from ss to tt the path of length rank kk.

“Path”\n>\n> In this article, a “path” may traverse the same edge or vertex multiple times. Strictly speaking, it should therefore be called a “walk” rather than a “path”. Strictly speaking, the problem discussed here is the kk-th shortest walk (kk shortest walk) problem. However, following common usage, this article continues to use “path”, and calls a path with no repeated vertices a “simple path”.

A* algorithm

A* is a search algorithm. It assigns each current state xx an evaluation function f(x)=g(x)+h(x)f(x)=g(x)+h(x), where g(x)g(x) is the actual cost from the initial state to the current state and h(x)h(x) is the estimated cost of the best path from the current state to the target state. During the search, it repeatedly extracts the state with the best f(x)f(x) and expands all successor states of that state xx. A priority queue can maintain these values.

When solving the kk-shortest-path problem, let h(x)h(x) be the shortest-path length from the current vertex to the destination tt. Run single-source shortest path from tt on the reversed graph to precompute this value for every vertex. Each state records two values: the current vertex xx and the distance g(x)g(x) already traveled; denote such a state by (x,g(x))(x,g(x)). Initially, insert (s,0)(s,0) into the priority queue. Each time, extract a state with minimum evaluation value f(x)=g(x)+h(x)f(x)=g(x)+h(x), enumerate all outgoing edges of its vertex xx, and insert the corresponding successor states into the priority queue. When a vertex is visited for the kk-th time, the corresponding state's g(x)g(x) is the length of the path from the starting vertex ss with rank kk.

This search can be optimized. Since we only need the kk-th shortest path from the initial vertex to the target, once states reaching a vertex have been extracted more than kk times, we need not expand their successors. Such a state cannot affect the final answer: the previous kk extractions of that vertex have already produced kk valid paths to it, enough to construct the kk shortest paths to the target.

With a priority-queue-optimized Dijkstra algorithm, all edges can be inserted into the priority queue at most kk times, so the time complexity is O(kmlog⁡km)O(km\log km) and the space complexity is O(km)O(km). Compared with direct search, A* prunes with respect to the target vertex tt, but this improves only the constant factor, not the asymptotic complexity. Although the algorithm described here does not have an excellent complexity, within the same complexity it can find from the starting vertex ss to every vertex in the shortest-path tree rooted at tt the first kk shortest paths.

Implementation

Library Checker - K-Shortest Walk template reference implementation
// Submission: https://judge.yosupo.jp/submission/311622
#include <iostream>
#include <queue>
#include <vector>

constexpr long long inf = 0x3f3f3f3f3f3f3f3f;

struct Edge {
  int u, v, c;

  Edge(int u, int v, int c) : u(u), v(v), c(c) {}
};

int n, m, s, t, k;
std::vector<std::vector<int>> gr, ig;  // Graph and inverse graph.
std::vector<Edge> edges;               // Edges.
std::priority_queue<std::pair<long long, int>,
                    std::vector<std::pair<long long, int>>, std::greater<>>
    pq;

std::vector<long long> dist_t;

// Calculate distances to the destination node t. (Dijkstra)
void calc_distances_to_t() {
  dist_t.assign(n, inf);
  dist_t[t] = 0;
  pq.emplace(dist_t[t], t);
  while (!pq.empty()) {
    long long di = pq.top().first;
    int cur = pq.top().second;
    pq.pop();
    if (di > dist_t[cur]) continue;
    for (auto e : ig[cur]) {
      int nxt = edges[e].u;
      auto di_nxt = di + edges[e].c;
      if (di_nxt < dist_t[nxt]) {
        dist_t[nxt] = di_nxt;
        pq.emplace(di_nxt, nxt);
      }
    }
  }
}

std::vector<long long> ans;

// Find the k shortest walk. (A* search)
// Complexity: O(k * n * log(k * n)).
void find_k_shortest_walks() {
  ans.assign(k, -1);
  std::vector<int> cnt(n);
  pq.emplace(dist_t[s], s);
  while (!pq.empty()) {
    long long cost = pq.top().first;
    int cur = pq.top().second;
    pq.pop();
    // Skip unreachable nodes.
    if (cost >= inf) continue;
    if (cur == t) ans[cnt[t]] = cost;
    ++cnt[cur];
    // Terminate when the destination has been visited k times.
    if (cnt[t] >= k) break;
    // Expand the same node at most k times.
    if (cnt[cur] > k) continue;
    for (auto e : gr[cur]) {
      int nxt = edges[e].v;
      auto cost_nxt = cost - dist_t[cur] + edges[e].c + dist_t[nxt];
      pq.emplace(cost_nxt, nxt);
    }
  }
}

int main() {
  // Input.
  std::cin >> n >> m >> s >> t >> k;
  gr.resize(n);
  ig.resize(n);
  edges.reserve(m);
  for (int i = 0; i < m; ++i) {
    int u, v, c;
    std::cin >> u >> v >> c;
    edges.emplace_back(u, v, c);
    gr[u].push_back(i);
    ig[v].push_back(i);
  }
  // Calculate.
  calc_distances_to_t();
  find_k_shortest_walks();
  // Output.
  for (auto x : ans) std::cout << x << '\n';
  return 0;
}

Persistent mergeable-heap approach

The preceding algorithm actually finds the kk shortest paths to every vertex. If we only want the shortest paths to a given target tt, specifically the kk shortest paths to it, this can be done faster. This section presents an O(mlog⁡m+klog⁡k)O(m\log m+k\log k) approach based on persistent mergeable heaps.

Shortest-path tree and sidetracks

The bottleneck of the preceding algorithm is that it updates the answer only when it reaches the target vertex tt. Yet different paths may differ very little. For example, a second-shortest path may differ from the shortest path only by taking one extra detour at a single edge, while all other parts are identical; the preceding algorithm may nevertheless have to search those identical edges again before finding the second-shortest path. Since only the detour matters, to obtain the kk shortest paths we need only consider the kk cheapest detour patterns. This leads to the concept of a shortest-path tree.

Starting from the target tt, run single-source shortest path on the reversed graph; for each vertex xx, record its shortest-path length to tt, denoted h(x)h(x), and for that vertex xx, record the first edge fxf_x on a shortest path; if there are multiple optimal choices, any one will do. All these edges fxf_x and their endpoints form a tree, and the simple path from every vertex xx in the tree to the root tt is a shortest path from xx to tt. This is the shortest-path tree TT.

After obtaining the shortest-path tree TT, we can compute how much extra cost each edge not in TT incurs. For an edge e=(u,v)∉Te=(u,v)\notin T with weight ww, define a new edge still directed from uu to vv, with cost Δ(e)=w+h(v)−h(u)\Delta(e)=w + h(v) - h(u). We call edges with weight Δ(e)\Delta(e) sidetracks, and call Δ(e)\Delta(e) the sidetrack cost. If an edge does not have both endpoints in the shortest-path tree TT, it cannot affect the computation at target tt of the kk shortest paths, so it can be deleted directly.

The left side of the figure below is a directed graph GG; the right side is its shortest-path tree TT (thick edges) and corresponding sidetracks (thin edges):

Figure: k shortest path 1

For a path from ss to tt, let its traversed edge set be PP; remove from PP its intersection with TT to obtain P′P'. When the edges in P′P' are listed in order, every adjacent pair e1=(u1,v1)e_1=(u_1,v_1) and e2=(u2,v2)e_2=(u_2,v_2) satisfies

  • Condition (∗)(*): the start u2u_2 of the latter is an ancestor of the end v1v_1 of the former in the shortest-path tree TT (including the endpoint itself).

This is because the original path PP connects v1v_1 and u2u_2 by several tree edges from TT. Conversely, for an edge set satisfying condition (∗)(*), namely a sidetrack set P′P', there is a unique path in graph GG, namely PP, corresponding to it, because the simple path between v1v_1 and u2u_2 in the shortest-path tree TT is unique. Thus every path PP in the original graph corresponds bijectively to a sequence satisfying condition (∗)(*) of sidetracks P′P'. Moreover, the length of path PP equals the shortest-path length h(s)h(s) plus the sum of the sidetrack costs:

h(s)+∑e∈P′Δ(e).h(s)+\sum_{e\in P'}\Delta(e).

These observations transform the task of finding the kk shortest paths into finding the kk-th cheapest sequence satisfying condition (∗)(*), namely a sidetrack sequence P′P'.

To handle condition (∗)(*), instead of searching for ancestors in the shortest-path tree at every query, propagate each vertex's set of sidetracks to its descendants in the tree. This is equivalent to constructing the following graph G′G':

Figure: k shortest path 2

In this graph, condition (∗)(*) becomes the requirement that consecutive sidetracks in P′P' connect end to end; in other words, P′P' is a path in graph G′G'. The problem is further transformed into finding a path starting from ss whose length is the kk-th smallest and that reaches any vertex. Unlike the original kk-shortest-path problem, the path no longer needs to end at the target vertex tt.

The transformed problem is easy to solve. Start at ss and run single-source shortest path. Each time a vertex is extracted from the priority queue, we have found a path in graph G′G', which corresponds to a path in graph GG reaching the target vertex tt.

Persistent mergeable-heap optimization

The algorithmic idea is now clear, but a naive implementation is too expensive. In graph G′G', the number of edges incident to a single vertex may be Θ(m)\Theta(m), so each single-source shortest-path computation may need to push an edge set of size Θ(m)\Theta(m) into the priority queue. In fact, it is unnecessary to push all edges: often, only the smallest of the queued edges can be popped later. We can therefore treat the entire edge set at a vertex as one stored object and push it into the priority queue, as long as we can access its minimum edge quickly.

This suggests using a min-heap to store the edge set at each vertex. The single-source shortest-path priority queue needs to store only these heaps, with each heap's cost equal to the shortest-path cost represented by its top element. Each time the queue head is popped, the top edge must also be popped from the heap at the head. The heap after removing its top must be pushed back into the priority queue, and the heap corresponding to the sidetrack set at the popped edge's endpoint must also be pushed into the priority queue.

Using heaps to store edge sets also solves the problem of propagating edge sets down the shortest-path tree. Propagating a set means merging the current vertex's edge set into its child, so the heap must support merging. At the same time, the current vertex's edge set must not be destroyed, so the heap must also be persistent. This is exactly a persistent mergeable heap.

This gives the complete algorithm:

  1. Start from the target vertex tt, run single-source shortest path, and find the shortest-path tree.
  2. Build the sidetrack set for every vertex in the shortest-path tree and store it in a persistent mergeable heap.
  3. Along the edges of the shortest-path tree, start from the target vertex tt and merge each vertex's heap into the heaps of its children.
  4. Start from the initial vertex ss and push its heap into the priority queue.
  5. Pop the heap at the head of the queue and record the answer; then push the heap after removing its top back into the priority queue, and push the heap at the endpoint of the top edge into the priority queue.

A leftist tree or randomized heap is generally used to implement a persistent mergeable heap. The last step can be optimized further. These heaps are binary trees. After popping the top, one would normally merge the two child heaps and push the resulting heap's top into the priority queue. In this algorithm, however, we can skip the merge and push the heaps corresponding to the two child nodes into the priority queue separately. This eliminates the O(log⁡m)O(\log m) cost of one merge. Since at most three new heaps are pushed after each head heap is popped, the priority queue has size O(k)O(k). Thus the time complexity of one query is reduced to O(log⁡k)O(\log k), and the total query complexity is O(klog⁡k)O(k\log k).

Since both constructing the shortest-path tree and constructing the persistent mergeable heap take O(mlog⁡m)O(m\log m) time, the total time complexity is O(mlog⁡m+klog⁡k)O(m\log m+k\log k).

Implementation

Library Checker - K-Shortest Walk template reference implementation
// Submission: https://judge.yosupo.jp/submission/311623
#include <algorithm>
#include <iostream>
#include <queue>
#include <random>
#include <vector>

std::mt19937_64 rng(static_cast<std::mt19937_64::result_type>(time(nullptr)));

constexpr long long inf = 0x3f3f3f3f3f3f3f3f;

// Persistent Randomized Heap.
struct PersistentRandomizedHeap {
  static constexpr int N = 1e7;
  int id;
  std::vector<int> rt, lc, rc, to;
  std::vector<long long> va;

  int new_node(long long cost, int _to) {
    ++id;
    va[id] = cost;
    to[id] = _to;
    return id;
  }

  int copy_node(int x) {
    ++id;
    lc[id] = lc[x];
    rc[id] = rc[x];
    va[id] = va[x];
    to[id] = to[x];
    return id;
  }

  int meld(int x, int y, std::mt19937_64::result_type rand) {
    if (!x || !y) return x | y;
    if (va[x] > va[y]) std::swap(x, y);
    x = copy_node(x);
    if (rand & 1) std::swap(lc[x], rc[x]);
    rc[x] = meld(rc[x], y, rand >> 1);
    return x;
  }

  PersistentRandomizedHeap() : id(0), rt(N), lc(N), rc(N), to(N), va(N) {}

  void insert(int i, long long cost, int _to) {
    rt[i] = meld(rt[i], new_node(cost, _to), rng());
  }

  void merge(int i, int j) { rt[i] = meld(rt[i], rt[j], rng()); }

} heaps;

struct Edge {
  int u, v, c;

  Edge(int u, int v, int c) : u(u), v(v), c(c) {}
};

int n, m, s, t, k;
std::vector<std::vector<int>> gr, ig;
std::vector<Edge> edges;
std::priority_queue<std::pair<long long, int>,
                    std::vector<std::pair<long long, int>>, std::greater<>>
    pq;

std::vector<long long> dist_t;
std::vector<int> out;

// Calculate distances to the destination node t. (Dijkstra)
// Record the optimal outgoing edges which form the shortest path tree T.
void calc_distances_to_t() {
  dist_t.assign(n, inf);
  dist_t[t] = 0;
  out.assign(n, -1);
  pq.emplace(dist_t[t], t);
  while (!pq.empty()) {
    long long di = pq.top().first;
    int cur = pq.top().second;
    pq.pop();
    if (di > dist_t[cur]) continue;
    for (auto e : ig[cur]) {
      int nxt = edges[e].u;
      auto di_nxt = di + edges[e].c;
      if (di_nxt < dist_t[nxt]) {
        dist_t[nxt] = di_nxt;
        out[nxt] = e;
        pq.emplace(di_nxt, nxt);
      }
    }
  }
}

// Construct sidetracks and propagate them through tree T.
void build_sidetracks() {
  // Insert all valid sidetracks into heaps.
  for (int i = 0; i < m; ++i) {
    auto edge = edges[i];
    if (out[edge.u] != i && dist_t[edge.u] < inf && dist_t[edge.v] < inf) {
      heaps.insert(edge.u, edge.c + dist_t[edge.v] - dist_t[edge.u], edge.v);
    }
  }
  // Propagate sidetracks down the shortest path tree.
  std::queue<int> q;
  q.push(t);
  while (!q.empty()) {
    auto cur = q.front();
    q.pop();
    for (auto e : ig[cur]) {
      auto nxt = edges[e].u;
      if (out[nxt] == e) {
        heaps.merge(nxt, cur);
        q.push(nxt);
      }
    }
  }
}

std::vector<long long> ans;

// Insert a non-empty heap into the priority queue.
// Total cost is the heap top value adjusted by accumulated cost.
void insert(int x, long long cost) {
  if (x) pq.emplace(cost + heaps.va[x], x);
}

// Find the k shortest paths in the sidetrack graph. (Dijkstra)
// These correspond to the k shortest walks in the original graph.
void find_k_shortest_walks() {
  int cnt = 0;
  ans.assign(k, -1);
  if (dist_t[s] >= inf) return;
  ans[cnt++] = dist_t[s];
  insert(heaps.rt[s], dist_t[s]);
  while (!pq.empty() && cnt < k) {
    auto cost = pq.top().first;
    int cur = pq.top().second;
    pq.pop();
    ans[cnt++] = cost;
    insert(heaps.lc[cur], cost - heaps.va[cur]);
    insert(heaps.rc[cur], cost - heaps.va[cur]);
    insert(heaps.rt[heaps.to[cur]], cost);
  }
}

int main() {
  // Input.
  std::cin >> n >> m >> s >> t >> k;
  gr.resize(n);
  ig.resize(n);
  edges.reserve(m);
  for (int i = 0; i < m; ++i) {
    int u, v, c;
    std::cin >> u >> v >> c;
    edges.emplace_back(u, v, c);
    gr[u].push_back(i);
    ig[v].push_back(i);
  }
  // Calculate.
  calc_distances_to_t();
  build_sidetracks();
  find_k_shortest_walks();
  // Output.
  for (auto x : ans) std::cout << x << '\n';
  return 0;
}

Exercises

References and notes

This content comes from OI Wiki: k-th Shortest Path. See the original for authors and revision history. AIOJ has adapted the formatting, in-site links, and resource paths.

Content is shared under CC BY-SA 4.0 and SATA license; additional terms are governed by the original notice. Thanks to the OI Wiki authors and contributors; per the SATA terms, please visit the OI Wiki repository and star it to show your support.