摘要:本文主要講解在競賽中如何求解圖中存在環的最短路問題。其中涉及的算法有Floyd算法,Dijkstra算法,使用鄰接表和優先隊列優化的Dijkstra算法,Bellman-Ford算法,簡要總結各算法的基本思想和實現以及使用注意事項。
最短路問題主要分為單源最短路問題和多源最短路問題。給出頂點數和邊數,以及邊的權值,讓我們計算從某個頂點到某個頂點的最短路徑,單源最短路就是求其中一個頂點到其他各個頂點的最短路,多源最短路就是求解任意兩點間的最短路。
先總結一下只有五行的Floyd算法,一句話概括就是:從i號頂點到j號頂點只經過前k號頂點松弛過的最短路徑。
模板習題:http://www.cnblogs.com/wenzhixin/p/7327981.html
AC代碼:

1 #include<stdio.h> 2 int main() 3 { 4 int n,m,e[210][210],inf=99999999,t1,t2,t3,s,t,i,j,k; 5 while(scanf("%d%d",&n,&m) != EOF) 6 { 7 for(i=0;i<n;i++) 8 { 9 for(j=0;j<n;j++) 10 { 11 if(i==j) 12 e[i][j]=0; 13 else 14 e[i][j]=inf; 15 } 16 } 17 for(i=1;i<=m;i++) 18 { 19 scanf("%d%d%d",&t1,&t2,&t3); 20 if(e[t1][t2] > t3)//道路可能存在重復,去最小值即可 21 e[t1][t2]=e[t2][t1]=t3; 22 } 23 scanf("%d%d",&s,&t); 24 25 for(k=0;k<n;k++) 26 for(i=0;i<n;i++) 27 for(j=0;j<n;j++) 28 if(e[i][j] > e[i][k]+e[k][j]) 29 e[i][j]=e[i][k]+e[k][j]; 30 if(e[s][t]==inf) 31 printf("-1\n"); 32 else 33 printf("%d\n",e[s][t]); 34 } 35 return 0; 36 }
該算法的優勢在於可以計算頂點數不太多的圖的任意兩點的最短路徑,很容易實現求解多源最短路徑。
缺點在於不能計算頂點數太多的圖的最短路徑,一是二維數組開不了那么大,二是該算法時間復雜度太高,達到O(n*n*n),很容易超時(超過1000個頂點就不能使用)。另外該算法可以解決帶負權邊的圖,並且均攤到每一對點上,但是不能解決帶有負權回路的圖(其實如果一個圖中存在負權回路,那么該圖就不存在最短路徑了)。
接下來,總結一下求解單源最短路徑常用的Dijkstra算法。
基本步驟:1. 將所有的頂點分為兩個集合,已知最短路徑的頂點集合P和未知最短路徑的頂點集合Q。起初,已知最短路徑的頂點集合P中只有一個源點這一個頂點,我們還需要有一個數組來記錄哪些頂點在集合P中,哪些在集合Q中。比如可以用book數組進行標記,當book[i]=1;表示i號頂點在集合P中,當book[i]=0;表示i號頂點在集合Q中。
2. 設置源點s到自己的最短路徑為0即dis[s]=0;,如果存在有源點能夠直接到達的頂點i,則將dis[i]賦值為e[s][i];如果不存在該源點直接到達的頂點i,則將
dis[i]賦值正無窮大。
3. 在集合Q的所有頂點中選擇一個離源點最近的頂點u加入到集合P。並考察所有以u為起點的邊,對每一條路徑進行松弛操作。
4. 重復第3步,如果P集合為空,算法結束。
模板習題:http://www.cnblogs.com/wenzhixin/p/7387613.html
AC代碼:

1 #include<stdio.h> 2 #include<string.h> 3 int e[1010][1010],dis[1010],bk[1010]; 4 int main() 5 { 6 int i,j,min,t,t1,t2,t3,n,u,v; 7 int inf=99999999; 8 while(scanf("%d%d",&t,&n)!=EOF) 9 { 10 for(i=1;i<=n;i++) 11 { 12 for(j=1;j<=n;j++) 13 { 14 if(i==j) 15 e[i][j]=0; 16 else 17 e[i][j]=inf; 18 } 19 } 20 for(i=1;i<=t;i++) 21 { 22 scanf("%d%d%d",&t1,&t2,&t3); 23 if(e[t1][t2]>t3) 24 { 25 e[t1][t2]=t3; 26 e[t2][t1]=t3; 27 } 28 } 29 for(i=1;i<=n;i++) 30 dis[i]=e[1][i]; 31 memset(bk,0,sizeof(bk)); 32 bk[1]=1; 33 for(i=1;i<=n-1;i++) 34 { 35 min=inf; 36 for(j=1;j<=n;j++) 37 { 38 if(bk[j]==0&&dis[j]<min) 39 { 40 min=dis[j]; 41 u=j; 42 } 43 } 44 bk[u]=1; 45 for(v=1;v<=n;v++) 46 { 47 if(e[u][v]<inf && dis[v]>dis[u]+e[u][v]) 48 dis[v]=dis[u]+e[u][v]; 49 } 50 } 51 printf("%d\n",dis[n]); 52 } 53 return 0; 54 }
不過競賽題大多是對Dijkstra算法的變形應用,例如求解最短路徑的最大權值,最長路徑的最小權值。
最長路徑的最小權值:http://www.cnblogs.com/wenzhixin/p/7336948.html
最短路徑的最大權值:http://www.cnblogs.com/wenzhixin/p/7412176.html
其實懂得了算法基本思想,根據要求稍微變化一下就好了。
上述算法的時間復雜度為O(n*n),還是很高的,對於一些要求高的題目需要使用進一步優化的算法,時間復雜度是O(mlogn),下面借鑒《算法入門經典》中的優化算法進行講解。具體的優化方法是使用鄰接表存圖,再使用優先隊列優化。使用vector數組保存邊的編號,遍歷從某一頂點出發的所有邊,更新d數組,就可以寫成“for(int i = 0; i < G[u].size(); i++) 更新操作;”。使用優先隊列保證每次彈出的是d值最小的結點,並彈出結點編號,方便后序松弛操作。
代碼實現如下:

1 #include<vector> 2 #include<queue> 3 #include<cstring> 4 #include<cstdio> 5 using namespace std; 6 7 const int INF = 99999999; 8 const int maxn = 1001; 9 10 struct Edge{ 11 int from, to, dist; 12 Edge(int u, int v, int d) :from(u), to(v), dist(d) { };//注釋1 13 }; 14 15 //使用優先隊列存儲的結點 16 struct HeapNode { 17 int d, u;//最小的d值及其結點編號 18 bool operator < (const HeapNode& a) const {//注釋2 19 return d > a.d; 20 } 21 }; 22 23 //為了使用方便,將算法中用到的數據結構封裝在一個結構體中,有點類的意思,實際上結構體和類只是在權限上有一點差別 24 struct Dijkstra{ 25 int n, m; 26 vector<Edge> edges; //使用vector數組存儲鄰接表,更容易理解, 27 vector<int> G[maxn]; 28 bool done[maxn]; //標記數組 29 int d[maxn]; //s到各個頂點的最短路徑 30 int p[maxn]; //最短路中的上一條弧 31 32 void init(int n){//初始化 33 this->n = n; //注釋3 34 for(int i = 0; i < n; i++) 35 G[i].clear();//清空鄰接表 36 edges.clear(); //清空邊表 37 } 38 39 void AddEdge(int from, int to, int dist) {//添加邊 40 edges.push_back(Edge(from, to, dist)); 41 m = edges.size();//每加入一條邊對其進行編號 42 G[from].push_back(m - 1);//以from為起點的邊的編號分別是多少 43 } 44 45 void dijkstra(int s){//Dijkstra 46 priority_queue<HeapNode> q; 47 48 for(int i = 0; i < n; i++) d[i] = INF; 49 d[s] = 0; 50 51 memset(done, 0, sizeof(done)); 52 q.push((HeapNode){0, s}); 53 54 while(!q.empty()){ 55 HeapNode x = q.top(); 56 q.pop(); 57 int u = x.u; 58 59 if(done[u]) continue; 60 done[u] = 1; 61 62 for(int i = 0; i < G[u].size(); i++){//遍歷所有從u出發的邊 63 Edge e = edges[G[u][i]]; 64 if(d[e.to] > d[u] + e.dist){//更新s到e.to的最短距離 65 d[e.to] = d[u] + e.dist; 66 67 p[e.to] = G[u][i];//記錄使用編號為G[u][i]的邊走到e.to這個頂點 68 q.push((HeapNode){d[e.to], e.to});//加入d[e.to]及其結點的編號表示可以從這個點松弛其他路徑 69 } 70 } 71 } 72 } 73 }; 74 75 int main() 76 { 77 //有了結構體的封裝,使用的時候操作如下 78 struct Dijkstra solver;//類似定義一個求解單源最短路的對象,有一點需要特別注意,當圖特別大的時候要將此語句放在主函數外 79 //原因是局部變量沒有太大的存儲空間,所以需要變成全局變量 80 int n,m; 81 scanf("%d%d",&n, &m); // 讀入頂點數和邊的條數 82 solver.init(n);//將鄰接表清空 83 84 for(int i = 0; i < m; i++){ 85 int u, v, w; 86 scanf("%d%d%d",&u, &v, &w);//注意點的編號是從0開始還是從1開始 87 u--; v--;//如果是從1開始計數的,需要減1 88 solver.AddEdge(u, v, w);//如果雙向路需要反過來再調用一次即可 89 } 90 91 solver.dijkstra(0); //求頂點0到其他各個頂點的最短距離,注意減1 92 93 //如何使用p數組打印最短路徑的方案 94 for(int i = 1; i < solver.n; i++){ 95 printf("從1到%d的最短路徑方案是:\n", i+1); 96 97 vector<int> path;//取出路徑編號,倒序輸出結點 98 int tmp = i; 99 do { 100 tmp = solver.p[tmp]; 101 path.push_back(tmp); 102 }while(solver.edges[tmp].from != 0); 103 104 for(int j = path.size() - 1; j >= 1; j--){ 105 printf("%d->%d,", solver.edges[path[j]].from+1, solver.edges[path[j]].to+1); 106 } 107 printf("%d->%d\n", solver.edges[path[0]].from+1, solver.edges[path[0]].to+1); 108 109 printf("最短距離是%d\n", i+1, solver.d[i]); 110 } 111 return 0; 112 } 113 /* 114 5 5 115 1 2 20 116 2 3 30 117 3 4 20 118 4 5 20 119 1 5 100 120 */
程序中有三個注釋分別如下:
注釋1,初始化列表,作用就是給邊的各個屬性賦值。
注釋2,操作符重載重const的使用,第一個const保證這個結構體中的變量不被修改,第二個const保證之前的結構體const不被修改。
注釋3,this指針,當參數與成員變量名相同時使用this指針,如this->n = n (不能寫成n = n)。
測試樣例結果如圖:
這里給出一道練習題http://acm.hdu.edu.cn/showproblem.php?pid=1535,參考解答https://www.cnblogs.com/wenzhixin/p/9062574.html。
可以發現,當一個圖中存在負權邊的時候,不一定存在最短路,當存在最短路的時候,Dijkstra算法就不適用了,因為松弛過程中會不斷找到更短的“松弛路徑”,導致最短路徑為負,顯然是不正確的。這就需要Bellman-Ford算法來解決圖中帶有負權邊的單源最短路問題了。
如果說Dijkstra算法是操作結點來進行路徑松弛的話,那么Bellman-Ford算法就是操作邊來松弛路徑了。代碼如下:

1 /* 2 n為頂點數,m為邊數 3 邊的描述為u[i]->v[i]的權值為w[i] 4 */ 5 for(int j = 1; j <= n-1; j++){ 6 for(int i = 1; i <= m; i++){ 7 if(d[v[i]] > d[u[i]] + w[i]) 8 d[v[i]] > d[u[i]] + w[i]; 9 } 10 }
內層循環m次,意思就是依次枚舉每一條邊,如果u[i]->v[i]這條邊能使源點到v[i]的距離變短,就更新d[v[i]]的值。為什么外層循環需要n-1次呢,因為在一個含有n個頂點的,不包含負環的圖中,最短路最多經過n-1個點(因為不包括起點),通過n-1輪松弛就可以得到。
反過來說,如果經過n-1輪松弛后,還能更新,就說明圖中存在負環。利用這個特性還可以用Bellman-Ford算法判斷圖中是否存在負環。具體例子如POJ 3259 Wormholes
AC代碼:

1 /* 2 題意描述 3 第一行輸入n個頂點和m條邊以及蟲洞的個數 4 接下來輸入m行邊,a->b花費的時間是c 5 接下來wn行是蟲洞的描述,a->b可以回到c秒前,也就是-c 6 問這個人能不能回到遇到之前的自己,也就是是否存在負環。 7 */ 8 #include<cstdio> 9 const int INF = 99999999; 10 const int maxn = 510; 11 int u[maxn*15], v[maxn*15], w[maxn*15]; 12 int n, m, wn, pn; 13 int Bellman_Ford(); 14 15 int main() 16 { 17 int F; 18 while(scanf("%d", &F) != EOF){ 19 while(F--) { 20 scanf("%d%d%d", &n, &m, &wn); 21 int a, b, c; 22 pn = 1; 23 for(int i = 1; i <= m; i++) { 24 scanf("%d%d%d", &a, &b, &c); 25 u[pn] = a; v[pn] = b; w[pn++] = c; 26 u[pn] = b; v[pn] = a; w[pn++] = c; 27 } 28 for(int i = 1; i <= wn; i++) { 29 scanf("%d%d%d", &a, &b, &c); 30 u[pn] = a; v[pn] = b; w[pn++] = -c; 31 } 32 pn --; 33 //利用pn計數每條邊 34 35 if(Bellman_Ford()) 36 printf("YES\n"); 37 else 38 printf("NO\n"); 39 } 40 } 41 return 0; 42 } 43 44 int Bellman_Ford() { 45 int dis[maxn]; 46 for(int i = 1; i <= n; i++){ 47 dis[i] = INF; 48 } 49 dis[1] = 0; 50 51 for(int i = 1; i <= n - 1; i++) { 52 for(int j = 1; j <= pn; j++) { 53 if(dis[v[j]] > dis[u[j]] + w[j]) 54 dis[v[j]] = dis[u[j]] + w[j]; 55 } 56 } 57 58 int flag = 0;//標記,如果還能更新就說明存在環 59 for(int j = 1; j <= pn; j++) { 60 if(dis[v[j]] > dis[u[j]] + w[j]) 61 flag = 1; 62 } 63 if(flag) 64 return 1; 65 return 0; 66 }
不過可惜的是這個算法時間復雜度太高,在競賽中我們利用隊列非空循環一直進行松弛,在含有n個頂點的圖中,如果不存在負環,包括源點在內的任意兩點間的最短路徑所利用點不超過n個,如果一個點被用超過n次,就說明有多余的點松弛最短路徑,只能說明存在負環。不存在負環,結點的標記不會取消,計算最短路徑,直至隊列為空,返回不存在負環。代碼如下:

1 bool bellman_ford(int s) { 2 queue<int> q; 3 memset(inq, 0, sizeof(inq)); 4 memset(cnt, 0, sizeof(cnt)); 5 6 for(int i = 0; i < n; i++) 7 d[i] = INF; 8 d[s] = 0; 9 inq[s] = true; 10 q.push(s); 11 12 while(!q.empty()){ 13 int u = q.front(); 14 q.pop();//彈出一個點 15 intq[u] = 0; 16 17 for(int i = 0; i < G[u].size(); i++){//枚舉以該點為起點的每一條邊 18 Edge e = edges[G[u][i]]; 19 if(d[u] < INF && d[e.to] > d[u] + e.dist) {//如果該邊能夠使源點到d[e.to]的距離變小,就更新 20 d[e.to] = d[u] + e.dist; 21 p[e.to] = G[u][i]; 22 if(!inq[e.to]){//沒有被標記過 23 q.push(e.to); 24 inq[e.to] = 1; 25 if(++cnt[e.to] > n)//如果彈出過程中,某一個點進隊了n+1次,直接判定存在負環 26 return 0; 27 } 28 } 29 } 30 } 31 return 1; 32 }
上面的寫法和優化后Dijkstra算法很像,不同的是它能讓一個節點多次進入FIFO隊列,從而判斷是否存在負環,同時也能計算最短路徑。使用的時候也用結構體封裝一下就能像優化后的Dijkstra算法一樣使用了。Bellman-Ford算法不論從思想上還是實現上都很成熟,在實際應用中也很廣泛,也有人稱隊列優化后的Bellman-Ford算法為SPFA(Shortest Path Faster Algorithm)算法。
下面給出一道例題:POJ 1860 Currency Exchange 參考解答:https://www.cnblogs.com/wenzhixin/p/9383074.html
至此,最短路問題的求解的幾種常見算法就介紹完了,各有個的優點,Foley算法,精簡,能解決帶負權邊但不存在負環的圖的最短路問題,但時間復雜度高,Dijkstra算法時間復雜度低,但是不能求解有負權邊的圖的最短路問題,Bellman-Ford算法相對比較完美,但是據說競賽的時候有很小的可能被惡意數據卡死。因此需要我們在競賽中根據具體問題,選擇合適的算法才行。