【演算法筆記】最短路
前言
因為發現自己對好多演算法的理解都不深,所以想起來寫這麼個東西。
一些基礎的東西
-
我們每一次設的 \(dist\) 都是“對於最短路徑的估計”。
也就是說它最先是不確定的。
只有當我們求了最短路過了之後它才真正是“最短路徑”。
-
關於鬆弛
說白了就是這樣子的一個圖:
我們對於任意的 \(u,v\) ,如果 \(dist[s -> u] > dist[s->v]+w[v->v]\)
也就是說存在新的更短的路徑那麼就更新估計。
SSSP
SSSP(單源最短路徑)是求從源點 \(s\) 到其它所有點的最短路徑問題。
也可以說是:對在權圖 \(G=(V,E)\)
一般常用的有幾種演算法:
Dijkstra
SPFA
Bellman-ford
Floyd
那麼,先從最基礎也是最實用的 Dijkstra
說起。
Dijkstra 演算法:
定義&過程:
該演算法於 1959 年由荷蘭的電腦科學家 Dijkstra 發明。
簡單來說還是基於SSSP最基礎的思想:鬆弛。
Dijkstra採用貪心演算法的策略。
他每次遍歷到始點距離最近且未訪問過的頂點的鄰接節點,直到擴充套件到終點為止。
根據我們鬆弛的三角形不等式:
\[dist[x] + w[x][y] >= dist[y] \]這個演算法主要分為以下的幾個步驟:
-
將我們的頂點集合 \(V\) 分成兩個部分:\(S\) 和 \(T\) ,定義 \(S\) 是已經求出(就是跑過的)的頂點集合(開始的時候只有一個點,就是源點 \(s\)), 對於 \(T\) : \(V-S =T\) (就是剩下的部分)
-
將 \(T\) 中的所有元素依次(按編號遞增)加入到 \(S\) 裡面。 保證以下兩個條件:
\(\alpha\) : 源點 到 S 中的任意一個點的距離都不大於源點到 T 中的任意一個點的最短路長度
。
\(\beta\) : S中的點的 dist = 源點到這個點的距離,T中的點的 dist = 從源點到這個點的最短路長度,且中間經過的點只有在S中的頂點。
那麼有一個結論:
源點到 \(T\) 中頂點的,或是從源點到\(T\) 中頂點的直接路徑的權值;或是從源點經過 \(S\) 中的頂點到該頂點的路徑權值之和。
不過這裡有一個更加易於理解的過程:
1. memset(dist,INF,sizeof(dist);
2. dist[s]=0;
3.找出一個沒有被標記的節點 v,標記一下。
4.對於每個和 v 相連的節點,如果它滿足三角形不等式,更新。
5. 直到所有點都被標記。
但是這個演算法的複雜度是 \(\text{O}(n^2)\) 的。
根據它貪心的本質,有了用堆優化的方法。
對於它的實現是這樣的:
-
將源點 \(s\) 加入堆。
-
取出堆頂,維護一下堆的性質。
-
我們從這個堆頂的點出發,利用三角形不等式去鬆弛和它相鄰的所有點。 如果它不在堆裡(以前沒訪問過),就加入堆,並更新
dist
。 -
如果它已經在堆裡了,也就是說從源點到這個點有更短的路徑(以前來過),更新
dist
-
如果我們的堆頂取出來的時候是終點 \(t\) 了,根據貪心策略,可以知道這個時候就已經有了最短的路徑,不會再有更短的了,結束。
(當然如果是求整個圖的 \(dis\) ,就直接到堆為空(所有點被標記)停止就可以了)
例題:
(上面的兩道題都可以用dij過掉)
程式碼:
#include<bits/stdc++.h>
using namespace std;
const int si=2e6+2;
int n,m,s;
vector<pair<int,int> >g[si];
priority_queue<pair<int,int> >q;
int dis[si];
int main(){
cin>>n>>m>>s;
for(register int i=1,x,y,z;i<=m;++i){
cin>>x>>y>>z;
g[x].push_back(make_pair(y,z));
// g[y].push_back(make_pair(x,z));
}
for(register int i=1;i<=n;++i){
dis[i]=1e9;
}
dis[s]=0;
q.push(make_pair(0,s));
while(!q.empty()){
register int x,y;
x=q.top().second;
y=-q.top().first;
q.pop();
if(dis[x]!=y){
continue;
}
for(register int i=0,to,val;i<g[x].size();++i){
to=g[x][i].first;
val=g[x][i].second;
if(dis[to]>dis[x]+val){
dis[to]=dis[x]+val;
q.push(make_pair(-dis[to],to));
}//鬆弛
}
}
for(register int i=1;i<=n;++i){
cout<<dis[i]<<" ";
}
return 0;
}
//每次從dis最小的開始鬆弛
bellman-frod & SPFA
這兩個演算法對我來說其實一般是用來判斷負環的,所以簡單講一下。
Bellman-定義
\(\text{bellman}\) 是基於迭代思想的演算法。
做法十分簡單。
掃描所有邊,如果 \(dist[y] > dist[x] + w[x][y]\) ,那麼用 \(dist[x] + w[x][y]\) 更新 \(dist [y]\) 。直到它沒有更新操作發生。
說白了就是個暴力(
很明顯的,它的複雜度是 \(O(nm)\) 級別的。
SPFA-定義
而 \(\text{SPFA}\) , 則是對\(\text{bellman}\)的一個佇列優化。
這個佇列裡的元素是“有更新潛力的”。
也就是說它可能還能更新。
所以每次鬆弛擴充套件的時候就把索擴充套件的元素放進隊即可。
開始的時候會有一個佇列,讓源點處在其中。
每次執行\(\text{bellman}\)的那種鬆弛操作之後,將其放入佇列,直到佇列是空的為止(沒有有更新潛力的元素)。
但是 \(\text{SPFA}\) ,常常成為毒瘤出題人卡的主要物件。
(在隨機圖上是比較優秀的,但是如果出題人構造了一個菊花圖一類的東西……那您就可以原地去世了)。
例題:
(當然,如果您用堆優化Bellman-Frod,那麼它和Dij就沒有本質上的差別了。)
程式碼(P3371):
#include<bits/stdc++.h>
using namespace std;
const int si_e=1e6+10;
const int si_n=1e5+10;
int n,m,tot=0,s;
struct node{
int Next,ver,w,head;
}e[si_e];
int dist[si_n];
bool vis[si_n];
queue<int>q;
void add(int u,int v,int w){
e[++tot].ver=v,e[tot].w=w;
e[tot].Next=e[u].head,e[u].head=tot;
}
void SPFA(int s){
memset(dist,0x3f,sizeof dist);
memset(vis,false,sizeof vis);
dist[s]=0,vis[s]=true;
q.push(s);
while(!q.empty()){
int u=q.front();
q.pop();
vis[u]=false;
for(register int i=e[u].head;i;i=e[i].Next){
int v=e[i].ver,w=e[i].w;
if(dist[v]>dist[u]+w){
dist[v]=dist[u]+w;
if(!vis[v]) q.push(v),vis[v]=true;
}
}
}
}
int main(){
cin>>n>>m>>s;
for(register int i=1,u,v,w;i<=m;++i){
cin>>u>>v>>w;
add(u,v,w);
}
SPFA(s);
for(register int i=1;i<=n;++i){
cout<<dist[i]<<" ";
}
return 0;
}
關於SPFA和Bellman判斷負環
Bellman:
如果經過 \(n\) 輪迭代,迭代仍然未結束,那麼就沒有負環。
如果在 \(n-1\) 輪之內結束了,那麼就有負環。
(下面是我一兩個月以前寫差分約束的時候寫的\(\text{bellman}\)判負環)
(這個東西在差分約束裡面很有用)
bool bellman_ford(int n,int m){
for(register int i=1;i<=n;++i){
dist[i]=0;
}
for(register int i=1;i<=n;++i){
for(register int j=0;j<m;++j){
edge &e=es[j];
if(dist[e.to]>dist[e.from]+e.dist){
dist[e.to]=dist[e.from]+e.dist;
}
}
}
for(register int j=0;j<m;++j){
edge &e=es[j];
if(dist[e.to]>dist[e.from]+e.dist){
return false;
}
}
return true;
}
SPFA
假設 \(cnt[x]\) 表示 \(1\) 到 \(x\) 的最短路里有多少邊。
且 \(cnt[1] = 0\) 。
在鬆弛的時候,更新一下 \(cnt[]\) 。
那麼怎麼更新?
\(cnt[y]=cnt[x]+1\)。
如果更新的時候, $n \le cnt[y] $ ,那麼就有負環。
反之則亦然。
\(\text{SPFA}\) 就不放程式碼了。
其實和上面的 \(\text{bellman}\) 差不多。
當然還有一種辦法是判一個點的入隊次數。
附:關於負邊權處理
演算法 | 能否處理負邊權 |
---|---|
\(\text{Dijkstra} -\text{O}(n^2)\) | 不行 |
\(\text{HeapDij} - \text{O}(m \log n)\) | 不行 |
$\text{SPFA} -\text{O}(nm/km) $ | 可以 |
\(\text{Bellman-Ford} - \text{O}(nm)\) | 可以 |
(您會發現,所有基於貪心的演算法都不行,您可以想一想為什麼(這應該很好想))。
Floyd
本質上來說,\(\text{floyd}\) 是一個動態規劃演算法。
它能夠在 \(\text{O}(n^3)\) 的複雜度裡解決任意兩點之間的最短距離問題。
而且一般圖比較稠密。
假設 \(f[k][i][j]\) 表示經過一些編號不超過k的節點,從i到j的最短路長度
(當然這個k是可以改定義的)
考慮如何轉移。
因為是dp,所以\(k\)這個狀態可以從 \(k-1\) 轉移過來。
然後從 \(i\) 到 \(j\) 可能是直接過去(不是隻有一條邊,而是 \(f[k-1][i][j]\) 這個狀態)。
也可能先從 \(i\) 到 \(k\) 再到 \(j\) (\(f[k-1][i][k]+f[k-1][k][j]\))
然後轉移方程就出來了。
\[f[k][i][j]=\min(f[k-1][i][j],f[k-1][i][k]+f[k-1][k][j]) \]不過注意在寫迴圈的時候,k要放在最外層。
因為它代表“階段”,如果不是第一層那麼轉移順序會炸開來。
至於初始化。用鄰接矩陣就可以了。
根據定義 :\(f[0][i][j]=a[i][j]\) ,然後跑一個 \(n^3\) 的DP就行了。
當然因為轉移的時候 \(k\) 只會從 \(k-1\) 轉移而來。
所以也可以和揹包一樣滾動陣列把第一維滾掉。
也就是:
\[f[i][j]=\min(f[i][j],(f[i][k]+f[k][j])) \]程式碼:
#include<bits/stdc++.h>
using namespace std;
const int si=4e2+10;
int n,m;
int f[si][si];
int main(){
cin>>n>>m;
memset(f,0x3f,sizeof f);
for(register int i=1;i<=n;++i){
f[i][i]=0;
}
for(register int i=1,u,v,w;i<=m;++i){
cin>>u>>v>>w;
f[u][v]=min(f[u][v],w);
}
for(register int k=1;k<=n;++k){
for(register int i=1;i<=n;++i){
for(register int j=1;j<=n;++j){
f[i][j]=min(f[i][j],f[i][k]+f[k][j]);
}
}
}
//f[i][j]=dis[i][j];
return 0;
}
傳遞閉包
給定若干的元素和若干對二元關係。
在關係具有傳遞性的情況下,推匯出更多的二元關係。
這就是傳遞閉包。
比如給你 \(a,b,c\)
且 \(a<b,b<c\)
那麼你就要通過關係推出 \(a<c\)
我們可以用 \(\text{floyd}\) 原理解決。
設 \(f[i][j]=true\) 表示i和j有一個關係。
反之則沒有。
且 \(f[i][i]=true\)
比如在上面的例子裡就是 \(f[a][b]=1,f[b][c]=1\)
然後跑一遍\(\text{floyd}\)。
在轉移的時候將所有可以推出的關係更新即可。
最長路
一般使用SPFA(因為需要負數)。
我們只需要把所有邊權改成負數跑SPFA即可。
(dis初始化還是正無窮)。
然後取出來給dis取絕對值即可。
(當然如果圖中有負邊那就要小心點,如果全是正邊就直接改)。
練習題:
Bellman_Ford的證明
- 經過一輪鬆弛,從起點出發到某個相鄰點\(u\)的最短路徑一定會被確定下來。(不可能出現:所有點都還不是最短路徑值)
- 再經過一輪鬆弛,從 \(u\) 出發到某個相鄰點\(v\)的最短路徑一定會被確定下來。
- 不含有負環的圖中,一條簡單路徑最多 \(n\) 個點,\(n-1\) 條邊。
- 最多經過 \(n-1\) 輪鬆弛,便可以把到最遠的點的最短距離確定下來。
- \(n-1\) 輪必然就能把最遠點的最短路徑確定下來,最遠點的最短路徑都確定下來了,其他點肯定在之前已經確定最短路徑值。
Dijsktra的證明
- 沒有負邊權,那麼路徑值最小的點,就不可能在後續的過程中更新,將其染色。
- 比如起點的路徑值為 \(0\),這個值就不可能被更新,所以先染色。
- 因為每步都是走正邊權,從其他路徑繞過來,肯定路徑值必然增加。
- 被確定的最小值點,既然已經比其他點值小,就不可能從其他點迂迴後更新它。
- 所以每步的最小值,一定是路徑值最小的時候,立即染色,不可能在後邊更新了。