\(k\) 短路問題簡介
所謂“\(k\) 短路”問題,即給定一張 \(n\) 個點,\(m\) 條邊的有向圖,給定起點 \(s\) 和終點 \(t\),求出所有 \(s\to t\) 的簡單路徑中第 \(k\) 短的。而且一般來說 \(n, m, k\) 的范圍在 \(10^5\) 級別,於是爆搜或者 \(k\) 次最短路這樣的算法我們不做討論。
本文將介紹求解 \(k\) 短路問題的兩種經典方法:\(A^*\) 算法 以及 可持久化可並堆做法。
\(A^*\) 算法
\(A^*\) 思想簡述
很顯然地,我們有一個暴力的 Bfs 做法:第 \(k\) 次搜到點 \(t\) 的就是所求。然而這樣太慢了,我們考慮優化方案。
對於搜索中的一個狀態 \(x\),令 \(g(x)\) 為當前狀態下該點到 \(s\) 的路徑長。
朴素的 Bfs 就是直接暴力地拓展,但我們可以設計一種方案,使得 相對接近終點的狀態優先拓展。
具體
A* 算法中,我們會引入一個函數 \(f(x)\),表示 \(x\) 的 估值,那么函數 \(f\) 也被稱為 估價函數。函數 \(f\) 的計算有一個通式:
其中 \(g(x)\) 代表 狀態 \(x\) 當前的代價,\(h(x)\) 表示 狀態 \(x\) 到終點狀態在最佳狀態下的代價。一般而言,\(h\) 的計算方式由自己決定,但需要根據以下原則:
- 必須不小於真實代價,否則沒有意義,即跑出來的答案會錯誤;
- 盡量向真實代價靠攏,這樣使得算法盡可能的快。
在搜索時,我們優先拓展 \(f(x)\) 值最小的狀態。一般會選用 堆(優先隊列) 實現。
\(A^*\) 算法在本題的運用
所幸,\(h(x)\) 的定義在本題還算比較顯然——到終點 \(t\) 的最短距離。而且可以發現 \(h\) 已經是最優的了。
於是算法就不難了:
- 用一個小根堆存儲狀態 \((x, g(x))\)。堆頂元素為 \(f\) 最小的。
- \(g\) 函數的值不難預處理,只要在 反圖 上以 \(t\) 為原點跑 Dijkstra 算法即可。
- 開始時,很顯然有 \(g(s) = 0\),那把 \((s, g(s))\) 存入堆中。
- 每次取出堆頂元素 \((x, g(x))\)。如果 \(x=t\) 那么表示找到一條。
- 向相鄰點拓展並放入堆中。
- 如此往復知道找到 \(k\) 條即可。
代碼實現
struct statu { // 定義狀態
int pos; double g, f;
bool operator < (const statu& rhs) const {
return f > rhs.f; // 為了方便比較將 f 值也記下來
}
};
int aStar(int k) {
priority_queue<statu> pq;
pq.push(statu{1, 0, dist[s]}); // 初始狀態
while (!pq.empty()) {
statu x = pq.top(); pq.pop(); // 抽出當前最優狀態
if (x.pos == t) // 到終點
if (--k == 0) return x.g; // 如果這是第 k 條,返回
for (auto e : G[x.pos])
pq.push(statu{ // 拓展狀態
e.to, x.g + e.val,
x.g + e.val + dist[e.to]
});
}
return -1; // 沒有第 k 條
}
復雜度
隨機數據這個算法跑的很快,但如果圖是一個 \(n\) 元環時,復雜度會達到 \(O(nk\log n)\) 級別。
可持久化可並堆做法
最短路樹
所謂最短路樹,就是從根通過樹邊到每個點的路徑長和原圖上的最短路徑長相同,那么這樣的樹就是最短路樹。
最短路樹可以通過求最短路的算法(Dijkstra/Spfa)求出。
這里我們選定終點 \(t\) 為根,在反圖上求出最短路樹 \(T\)。那么每個點通過樹邊都是到 \(t\) 點的路徑最短路。
最短路樹與一般路徑之間的關聯
對於一條 \(s\to t\) 的路徑 \(p\),我們選取其中的 非樹邊,作為一個 集合 列表,記為 \(side(p)\)。即 \(side(p) = p \setminus (p \cap T)\)。
為方便說明,我們引入一些記號:
- \(front(e), back(e)\):表示邊 \(e\) 的前端點(靠近起點)和后端點(靠近終點)。
- \(len(e)\):邊或路徑 \(e\) 的長度。
- \(dist(x)\):點 \(x\) 到終點 \(t\) 的最短距離。
\(side(p)\) 有如下性質:
- 設一條邊 \(e\) 的增長量 \(\delta(e) = dist(front(e)) + len(e) - dist(back(e))\),可以理解為把這條邊換掉原來的 額外增加的長度 。那么路徑 \(p\) 的長 \(len(p) = dist(s) + \sum\limits_{e\in side(p)} \delta(e)\)。
- 我們將 \(side(p)\) 中的邊按 路徑的順序 排列好,並選取其中相鄰的兩條邊 \(e_1, e_2\)(\(e_1\) 在前,注意 \(e_1, e_2\) 在原圖中不一定相鄰)。那么 \(u=back(e_1),v=front(e_2)\) 兩點要么是同一個點,要么 \(v\) 是 \(u\) 的祖先。原因比較顯然:兩邊如果在圖上相鄰那么就是同一個點,反之就由樹邊向樹根 \(t\) 的方向相連。
- 對於一個確定的 \(side(p)\) 只有一個 \(s\to t\) 的路徑 \(p\) 與之對應。因為最短路樹上兩點只有一條只經過樹邊的路徑,而 \(side(p)\) 其他連續的路徑段也是確定的。
將問題轉化
根據性質 1,可以發現答案就是 \(dist(s) + \sum\limits_{e\in side(p)}\delta(e)\) 的第 \(k\) 小值。
那么我們只要不斷構造出這 \(k\) 個 \(side(p)\) 即可。
如何構造 \(side(p)\)
根據第二個性質,我們可以對一個現有的 \(side(p)\) 推出另一個新的 \(side(q)\):
\(side(p)\) 最尾端的邊為 \(e\),令 \(u = front(e), v = back(e)\)。那么有兩種構造策略:
- 用與 \(u\) 相連的一條邊 \(e^\prime\) 替換 掉 \(e\),滿足 \(\delta(e^\prime)\ge \delta(e)\) 並且 \(\delta(e^\prime)\) 盡可能小;
- 在 \(v\) 后面新接上一條 最短路樹上祖先方向出去的 \(\delta\) 值最小的邊 \(e^\prime\)。
順帶一提,這兩個方法分別對應性質 2 的兩種情況。
於是我們實現了通過現有的一條 \(s\to t\) 的路徑得出另一條更長的路徑。如果我們可以通過某種手段達到在可以承受的時間內完成一次構造,那么只要每次選取一個最小的 \(side(p)\),重復執行構造,直至選出第 \(k\) 個即可。這個 \(side(p)\) 的集合可以用小根堆維護。
快速構造 \(side(p)\) 需要我們在每個節點維護一個 與之相連的所有非樹邊和祖先出去的邊的最小邊;
很自然的想到堆,堆中存儲路徑的尾邊對應的堆節點以及路徑長度。
如果建出了每個節點的堆,那么上述的構造策略可以轉化為(設當前堆節點為 \(x\)):\(x\) 的左右兒子替換掉當前的堆節點,或者 \(x\) 對應邊的尾端點對應堆的根。
那么我們實現了一次 \(O(\log k)\) 的轉移(維護 \(side(p)\) 的小根堆的大小為 \(O(k)\))。
關於堆
堆的話,可以通過最短路樹從祖先向葉子的方向合並構造。
但很顯然用一般的二叉堆是很難避免 MLE 的結果的。不過對於可並堆我們可以考慮一下這個問題:可並堆之所以有問題是因為 每次合並都需要保留前兩個堆的信息。
於是要解決這個問題,就得讓信息保留。而整個堆復制是不現實的,只能 共用一些節點。那么顯而易見我們需要——
可持久化可並堆
一般我們用 可持久化左偏樹 實現,這樣時空復雜度都是線性對數級別的。
其實會左偏樹的話這玩意也不難寫。可以參考 OI-Wiki 可持久化可並堆 標簽頁學習。
小結
好像一切都明朗了。來歸納一下算法的步驟吧:
- 在反圖上跑 Dijkstra;
- 構造可持久化左偏樹:
-
- 對於每個節點都掃一遍鄰邊(除樹邊),然后將其 \(\delta\) 值與另一端點編號一並插入當前點的左偏樹中;
- 然后向樹邊祖先方向將堆合並到此。
- 構造前 \(k\) 個 \(side(p)\):
-
- 取出堆頂;
- 向左偏樹節點兒子拓展;
- 向對應邊結束點的左偏樹的根拓展。
- 如此在堆中取出的第 \(k\) 個記為答案。
代碼實現
P2483 【模板】k短路 / [SDOI2010]魔法豬學院 代碼
#include <algorithm>
#include <cstring>
#include <iostream>
#include <queue>
#include <vector>
using namespace std;
const int N = 5e4 + 5;
const int M = 2e5 + 5;
const double inf = 1e16;
const double eps = 1e-8;
struct Graph {
struct Edge {
int to, nxt;
double len;
} e[M];
int head[N], ecnt = 0;
Graph() { memset(head, -1, sizeof(head)), ecnt = 0; }
inline void insert(int u, int v, double w) {
e[ecnt] = Edge{v, head[u], w}; head[u] = ecnt++;
}
inline int nxt(int i) { return e[i].nxt; }
inline int to(int i) { return e[i].to; }
inline double len(int i) { return e[i].len; }
} G, R;
int n, m;
double E;
int fa[N];
double dist[N];
bool book[N];
struct vtx {
int pos; double dist;
bool operator < (const vtx& rhs) const {
return dist > rhs.dist;
}
};
priority_queue<vtx> pq;
void Dijkstra() {
fill(dist + 1, dist + 1 + n, inf);
pq.push(vtx{n, dist[n] = 0.0});
while (!pq.empty()) {
int x = pq.top().pos; pq.pop();
if (book[x]) continue;
book[x] = true;
for (int i = R.head[x]; ~i; i = R.nxt(i)) {
int y = R.to(i); double l = R.len(i);
if (dist[y] > dist[x] + l) {
dist[y] = dist[x] + l;
fa[y] = i;
pq.push(vtx{y, dist[y]});
}
}
}
}
namespace LefT {
struct lef {
int ch[2], dist;
int end; double delta;
} tr[N << 5];
int total = 0;
inline int create(double d, int e) {
int x = ++total;
tr[x] = lef{{0, 0}, 1, e, d};
return x;
}
inline int copy(int x) {
return tr[++total] = tr[x], total;
}
int merge(int x, int y) {
if (!x || !y) return x | y;
if (tr[x].delta > tr[y].delta) swap(x, y);
int z = copy(x);
tr[z].ch[1] = merge(tr[x].ch[1], y);
if (tr[tr[z].ch[0]].dist < tr[tr[z].ch[1]].dist)
swap(tr[z].ch[0], tr[z].ch[1]);
tr[z].dist = tr[tr[z].ch[1]].dist + 1;
return z;
}
};
int root[N];
void initLefTr() {
using namespace LefT;
for (int i = 1; i <= n; i++)
pq.push(vtx{i, dist[i]});
tr[0].dist = -1;
while (!pq.empty()) {
int x = pq.top().pos; pq.pop();
for (int i = G.head[x]; ~i; i = G.nxt(i)) if (fa[x] != i)
root[x] = merge(root[x], create(G.len(i) + dist[G.to(i)] - dist[x], G.to(i)));
root[x] = merge(root[x], root[G.to(fa[x])]);
}
}
int calc() {
using namespace LefT;
int ret = 0;
if (dist[1] > E) return 0;
E -= dist[1], ++ret;
if (!root[1]) return ret;
pq.push(vtx{root[1], tr[root[1]].delta});
while (!pq.empty()) {
int x = pq.top().pos;
double d = pq.top().dist;
pq.pop();
if (dist[1] + d > E) break;
++ret, E -= dist[1] + d;
for (int* c = tr[x].ch, s = 2; s; --s, ++c) if (*c)
pq.push(vtx{*c, d - tr[x].delta + tr[*c].delta});
if (root[tr[x].end])
pq.push(vtx{root[tr[x].end], d + tr[root[tr[x].end]].delta});
}
return ret;
}
signed main() {
ios::sync_with_stdio(false);
cin >> n >> m >> E;
for (int i = 1; i <= m; i++) {
int u, v; double w;
cin >> u >> v >> w;
if (u == n) continue;
G.insert(u, v, w);
R.insert(v, u, w);
}
Dijkstra(), initLefTr();
cout << calc() << endl;
return 0;
}
復雜度
\(O(n\log n+k\log k)\) 時間,\(O(n\log n)\) 空間。設 \(n, m\) 同階。
總結
兩個算法各有優缺點:
- A* 算法在大多數情況下表現優秀,但是可以被刻意構造的數據(\(n\) 元環)卡爆。不過它的編寫難度小,因此可能是更適合考場上選擇的算法。
- 可持久化可並堆的做法雖然編寫比前者復雜的多,但是復雜度卻是一定正確的,根本不會擔心被卡的情況。
兩個都建議讀者掌握,以便在不同情況下有更多的選擇。