Prerequisites: Dijkstra's algorithm, A* algorithm, and persistent mergeable heaps
Problem statement
Given a directed graph with vertices and edges, find among all distinct paths from to the path of length rank .
“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 -th shortest walk ( 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 an evaluation function , where is the actual cost from the initial state to the current state and 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 and expands all successor states of that state . A priority queue can maintain these values.
When solving the -shortest-path problem, let be the shortest-path length from the current vertex to the destination . Run single-source shortest path from on the reversed graph to precompute this value for every vertex. Each state records two values: the current vertex and the distance already traveled; denote such a state by . Initially, insert into the priority queue. Each time, extract a state with minimum evaluation value , enumerate all outgoing edges of its vertex , and insert the corresponding successor states into the priority queue. When a vertex is visited for the -th time, the corresponding state's is the length of the path from the starting vertex with rank .
This search can be optimized. Since we only need the -th shortest path from the initial vertex to the target, once states reaching a vertex have been extracted more than times, we need not expand their successors. Such a state cannot affect the final answer: the previous extractions of that vertex have already produced valid paths to it, enough to construct the shortest paths to the target.
With a priority-queue-optimized Dijkstra algorithm, all edges can be inserted into the priority queue at most times, so the time complexity is and the space complexity is . Compared with direct search, A* prunes with respect to the target vertex , 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 to every vertex in the shortest-path tree rooted at the first 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 shortest paths to every vertex. If we only want the shortest paths to a given target , specifically the shortest paths to it, this can be done faster. This section presents an 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 . 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 shortest paths we need only consider the cheapest detour patterns. This leads to the concept of a shortest-path tree.
Starting from the target , run single-source shortest path on the reversed graph; for each vertex , record its shortest-path length to , denoted , and for that vertex , record the first edge on a shortest path; if there are multiple optimal choices, any one will do. All these edges and their endpoints form a tree, and the simple path from every vertex in the tree to the root is a shortest path from to . This is the shortest-path tree .
After obtaining the shortest-path tree , we can compute how much extra cost each edge not in incurs. For an edge with weight , define a new edge still directed from to , with cost . We call edges with weight sidetracks, and call the sidetrack cost. If an edge does not have both endpoints in the shortest-path tree , it cannot affect the computation at target of the shortest paths, so it can be deleted directly.
The left side of the figure below is a directed graph ; the right side is its shortest-path tree (thick edges) and corresponding sidetracks (thin edges):
For a path from to , let its traversed edge set be ; remove from its intersection with to obtain . When the edges in are listed in order, every adjacent pair and satisfies
- Condition : the start of the latter is an ancestor of the end of the former in the shortest-path tree (including the endpoint itself).
This is because the original path connects and by several tree edges from . Conversely, for an edge set satisfying condition , namely a sidetrack set , there is a unique path in graph , namely , corresponding to it, because the simple path between and in the shortest-path tree is unique. Thus every path in the original graph corresponds bijectively to a sequence satisfying condition of sidetracks . Moreover, the length of path equals the shortest-path length plus the sum of the sidetrack costs:
These observations transform the task of finding the shortest paths into finding the -th cheapest sequence satisfying condition , namely a sidetrack sequence .
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 :
In this graph, condition becomes the requirement that consecutive sidetracks in connect end to end; in other words, is a path in graph . The problem is further transformed into finding a path starting from whose length is the -th smallest and that reaches any vertex. Unlike the original -shortest-path problem, the path no longer needs to end at the target vertex .
The transformed problem is easy to solve. Start at and run single-source shortest path. Each time a vertex is extracted from the priority queue, we have found a path in graph , which corresponds to a path in graph reaching the target vertex .
Persistent mergeable-heap optimization
The algorithmic idea is now clear, but a naive implementation is too expensive. In graph , the number of edges incident to a single vertex may be , so each single-source shortest-path computation may need to push an edge set of size 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:
- Start from the target vertex , run single-source shortest path, and find the shortest-path tree.
- Build the sidetrack set for every vertex in the shortest-path tree and store it in a persistent mergeable heap.
- Along the edges of the shortest-path tree, start from the target vertex and merge each vertex's heap into the heaps of its children.
- Start from the initial vertex and push its heap into the priority queue.
- 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 cost of one merge. Since at most three new heaps are pushed after each head heap is popped, the priority queue has size . Thus the time complexity of one query is reduced to , and the total query complexity is .
Since both constructing the shortest-path tree and constructing the persistent mergeable heap take time, the total time complexity is .
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;
}