問題引入
在每年的校賽里,所有進入決賽的同學都會獲得一件很漂亮的福大數計學院吉祥物公仔。但是每當我們的工作人員把上百件的吉祥物從商店運回到賽場的時候,卻是非常累的!所以現在他們想要尋找最短的從商店到賽場的路線,你可以幫助他們嗎?(問題背景來源於生活)
題意轉化
給定一個有邊權的有向圖 \(G(V, E)\),起點 \(S\) 和終點 \(T\),求一條從 \(S\) 到 \(T\) 的最短路徑。
解決方案
深度優先搜索
簡稱最朴素最暴力的做法。深度優先算法的核心思想是順着一條路一直走下去,不撞南牆不回頭,直到遍歷所有情況。這就好比是走迷宮,你沿着一條路要一直走,直到遇到死胡同才返回。形式化而言就是從一個點出發,遞歸 + 回溯遍歷每一條與它相連的邊,直至到達終點,更新最短路。
void dfs(int u)
{
if (u == t)
return ans = min(ans, val), void();
for (auto [v, w] : G[u])
val += w, dfs(v), val -= w;
}
但是此算法並不是完美的。對“時間復雜度”略有所知的同學可以清晰地發現,此算法的時間復雜度是和圖上 \(S\) 到 \(T\) 的路徑總數同階的,而路徑總數在具有某些特征的圖上是可以達到指數級別的。舉一個清晰的例子,比如當點數邊數達到 40 ~ 50 的時候,一般計算機就無法在規定時間內得到答案。
Bellman-Ford 算法
由國外計算機科學家 \(Bellman\) 和 \(Ford\) 聯合提出。定義一種“松弛”操作:如果存在某條邊連接的兩個點 \(u\) 和 \(v\) 滿足 \(dis_v > dis_u + w\) ,則令 \(dis_v = dis_u + w\) 。設點數為 \(|V|\),進行 \(|V| - 1\) 次循環,每次循環遍歷圖中所有的邊,檢查能否進行松弛操作,如果某次循環中沒有松弛操作就停止循環。
\(Bellman-Ford\) 算法相比於深度優先搜索已經有了極大的改進,它使得解決問題的時間復雜度將至 \(O(n(n + m))\)。但是,在大數據規模的最短路問題中仍然顯得心有余而力不足。
void Bellman_Ford()
{
for (int i = 1; i <= n; i++) dis[i] = inf;
dis[s] = 0;
for (int i = 1; i <= n - 1; i++)
for (int u = 1; u <= n; u++)
for (auto [v, w] : G[u])
dis[v] = min(dis[v], dis[u] + w);
}
SPFA 算法
接下來介紹一個由國內研發出來基於 \(Bellman-Ford\) 的一個優化算法。定義一個隊列 \(Q\),記 \(dis_u\) 為當前 \(s\) 到 \(u\) 的最短路徑長度。首先將起點 \(S\) 放進隊列 \(Q\) 中;接下來每次從隊列中取出隊首節點 \(u\),遍歷與 \(u\) 相鄰的所有點 \(v\),設 \(u\) 到\(v\) 的路徑長度為 \(w\),如果 \(dis_v > dis_u + w\) ,則令 \(dis_v = dis_u + w\) 。重復這個操作直到隊列為空。
void spfa()
{
for (int i = 1; i <= n; i++) dis[i] = inf;
for (int i = 1; i <= n; i++) exist[i] = 0;
dis[s] = 0; exist[s] = 1; Q.push(s);
while (!Q.empty())
{
int u = Q.front(); Q.pop(); exist[u] = 0;
for (auto [v, w] : G[u])
if (dis[v] > dis[u] + w)
{
dis[v] = dis[u] + w;
if (!exist[v]) Q.push(v), exist[v] = 1;
}
}
}
對比於 \(Bellman-Ford\) 算法而言,\(SPFA\) 看似已經有了極大的改進。在隨機圖上,他的期望時間復雜度甚至達到了 \(O(m)\)。但是實際上而言,對於一些特殊構造的圖上,它的時間復雜度依然是劣的:例如在網格圖上,已經達到平方的復雜度。這在大數據規模的最短路問題上依然是不可接受的。
Dijkstra 算法
終於來介紹本文的主角,由著名計算機科學家 \(Dijkstra\) 提出的 \(Dijkstra\) 算法。該算法只能適用於非負邊權圖。
先來闡述一下該算法的流程:定義一個數據結構 \(Q\)(可以是數組之類的),記 \(dis_u\) 為當前 \(s\) 到 \(u\) 的最短路徑長度。首先將起點 \(S\) 放進 \(Q\) 中;接下來每次從 \(Q\) 中取出 \(dis\) 值最小的節點 \(u\),遍歷與 \(u\) 相鄰的所有還在 \(Q\) 中的點 \(v\),設 \(u\) 到\(v\) 的路徑長度為 \(w\),如果 \(dis_v > dis_u + w\) ,則令 \(dis_v = dis_u + w\) 。重復這個操作直到 \(Q\) 為空。
使用 \(Dijkstra\) 算法求解最短路問題時的某個狀態,其中黑點表示仍在 \(Q\) 中。此時 \(1\) 號點已從 \(Q\) 中取出,接下來應取出 \(6\) 號點
證明:先證明任何時候第一個集合中的元素的 一定不大於第二個集合中的。再證明第一個集合中的元素的最短路已經確定。第一步,初始狀態時必然成立(基礎),在每一步中,加入集合的元素一定是最大值,且是另一邊最小值,每次松弛操作又是加上非負數,所以仍然成立(歸納)(利用非負權值的性質)。第二步,考慮每次加進來的結點,到它的最短路,上一步必然是第一個集合中的元素(否則他不會是第二個集合中的最小值,而且有第一步的性質),又因為第一個集合內的點已經全部松弛過了,所以最短路顯然確定了。
介紹了算法流程以及證明了正確性,接下來還有一個懸而未決的問題:數據結構 \(Q\) 應當是什么?
思考一下本算法中 \(Q\) 的用途:從中取出 \(dis\) 最小的元素以及加入元素。擁有數據結構基礎的同學不難發現:這不就是個堆嘛?對,就是個堆(限於篇幅在此不做對堆的基本知識的贅述)。如果使用 C++ 進行編寫 \(Dijkstra\) 算法的話,在 STL 庫中有一個封裝好的類叫做 \(priority\_queue\)(優先隊列),它能夠在 \(O(log_2n)\) 的復雜度能完成堆的基本操作。這樣一來,綜上所述我們最終優化出來的 \(Dijkstra\) 算法復雜度為 \(O((n + m) log_2n)\)。這已經能夠解決當前工業學術界的絕大多數問題。
typdef pair<int, int> pii;
priority_queue<pii, vector<pii>, greater<pii> > Q;
void dijkstra()
{
for (int i = 1; i <= n; i++) dis[i] = inf;
for (int i = 1; i <= n; i++) done[i] = 0;
Q.push(make_pair(dis[s] = 0, s));
while (!Q.empty())
{
int u = Q.top().second; Q.pop();
if (done[u]) continue;
done[u] = 1;
for (auto [v, w] : G[u])
if (dis[v] > dis[u] + w)
Q.push(make_pair(dis[v] = dis[u] + w, v);
}
}
\(Dijkstra\) 是一種高效的最短路問題算法。但是他的缺點也很明顯:只能適用於非負邊權圖。在 \(Dijstra\) 提出該問題后,又有 \(Johnson\) 提出了經典的 \(Johnson\) 算法,通過轉化用以消除負邊權使得 \(Dijkstra\) 再能顯出“神通”。在當前的學術工業界中,\(Dijkstra\) 算法也為人工智能的尋路算法提供了一些啟示,具有先進意義。