摘要:本文主要讲解在竞赛中如何求解图中存在环的最短路问题。其中涉及的算法有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 "); 32 else 33 printf("%d ",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 ",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的最短路径方案是: ", 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 ", solver.edges[path[0]].from+1, solver.edges[path[0]].to+1); 108 109 printf("最短距离是%d ", 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 "); 37 else 38 printf("NO "); 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算法相对比较完美,但是据说竞赛的时候有很小的可能被恶意数据卡死。因此需要我们在竞赛中根据具体问题,选择合适的算法才行。