在图论中,有一类算法,是专门拿来算两点之间最短距离的,被称之为最短路算法。
一共有四种最短路算法,分别是:Floyd,Dijkstra,Bellman-Ford 和 SPFA,它们时间复杂度各不相同,同时也具有各自的缺陷,今天就来介绍一下这四种算法。
参考题目:P1744 采购特价商品(四种算法参考代码均为这道题)
PS:因为图论刚起步,所以为了熟悉一下,代码都是用的链式前向星存图,其实这四种都有自己的方式,下面会讲到。
Floyed
Floyd 和其它三种不一样,是一种全源最短路径算法,也就是说,它能求出任意点为起点,任意点为终点的最短距离,而其它三种只能求出以某一点为起点,任意点为终点的最短距离,即单源最短路径。
Floyd 算法时间复杂度为O(n3),核心代码如下:
1 2 3 4
| for(int k=1;k<=n;k++) for(int i=1;i<=n;i++) for(int j=1;j<=n;j++) dis[i][j]=min(dis[i][j],dis[i][k]+dis[k][j]);
|
其中,k 为中间点,i 和 j 为起点和终点。
关于为什么要把 k 放在最外层,我也是纠结了很久(那肯定啊强迫症看了会很不舒服的嘛),直到看到了这张图片。
这东西把这个算法解释的很清楚了,我就不再说了。
代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39
| #include<cstdio> #include<cstring> #include<cmath> #define min(a,b) a<b?a:b using namespace std; struct Point { int x; int y; }; Point a[101]; int n,m,s,t,cnt,h[101],b[101]; double dis[101][101]; int main() { scanf("%d",&n); for(int i=1;i<=n;i++) { scanf("%d%d",&a[i].x,&a[i].y); for(int j=1;j<=n;j++) dis[i][j]=10000000; } scanf("%d",&m); for(int i=1;i<=m;i++) { int u,v; scanf("%d%d",&u,&v); dis[u][v]=dis[v][u]=sqrt((a[u].x-a[v].x)*(a[u].x-a[v].x)+(a[u].y-a[v].y)*(a[u].y-a[v].y)); } scanf("%d%d",&s,&t); for(int k=1;k<=n;k++) for(int i=1;i<=n;i++) for(int j=1;j<=n;j++) dis[i][j]=min(dis[i][j],dis[i][k]+dis[j][k]); printf("%.2lf",dis[s][t]); return 0; }
|
事实上 Floyd 算法并不用链式前向星,而是邻接矩阵,毕竟它是求任意点为起点和终点的最短路径,所以必须用邻接矩阵。
优点:简单易懂,全源最短路
缺点:很明显啊……时间复杂度太感人了,空间复杂度也很棒棒啊
Dijkstra
正如上面所说,Dijkstra 是一种单源最短路径算法,时间复杂度为O(n2),比较稳定。
Dijkstra 的核心思想是不停地找距离起点最近又没有更新过的点,然后对其所有相连的点进行更新。
因为从一个点到另一个点,中间必定经过至少一个中转点(把起点也算上),Dijkstra 就是不停地把距离起点最近的点作为中转点,来更新与之相连的点。
Dijkstra 默认不可能出现起点到 A 比起点到 B 距离长,但从起点经过 A 再到 B 距离却比直接到 B 短的情况,所以无法处理存在负边权的情况。
下面用图片来具体说明一下(标粗的点表示没有更新过):
第一步:
此时,初始化dis[1]=0,dis[2,3,4,5]=∞,全部标记为未更新。
第二步:
此时,因为dis[1]=0,距离起点最近,因此更新点 1,标记一下。然后,修改与点 1 相连的所有点,于是,dis[2]=2,dis[3]=4,dis[4]=7,但这时并不标记这些点,因为它们不一定是最短路径。
数据:dis[1]=0,dis[2]=2,dis[3]=4,dis[4]=7,dis[5]=∞
第三步:
第二轮循环后,找到点 2 距离起点最近且目前未被更新过,更新点 2,并循环与点 2 相连的所有点,即点 1,点 3,点 5,同样,这轮修改不标记,原因如上所述。因为dis[1]=0<dis[2]+2,所以不修改点 1。
数据:dis[1]=0,dis[2]=2,dis[3]=3,dis[4]=7,dis[5]=4
第四步:
第三轮循环,找到未被更新过的点中点 3 距起点最近,因此标记点 3,并修改与点 3 相连的点,即点 1,点 2,点 4,点 5,但因为dis[1]=0<dis[3]+4,dis[2]=2<dis[3]+1,dis[5]=4<dis[3]+6,因此这三个点不管,只有点 4 被修改(同样不标记)
数据:dis[1]=0,dis[2]=2,dis[3]=3,dis[4]=4,dis[5]=4
最后两轮更新将点 4 和点 5 更新,手动模拟一下发现没有点再修改了,循环结束。
综上所述,Dijkstra 算法简单来说就是,找距起点最近的点,用它来修改相连的点
代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66
| #include<cstdio> #include<cstring> #include<cmath> #define min(a,b) a<b?a:b using namespace std; struct Point { int x; int y; }; struct Edge { int next; int to; double w; }; Point a[101]; Edge edge[2001]; int n,m,s,t,cnt,h[101],b[101]; double dis[101]; void add_edge(int u,int v) { edge[cnt].next=h[u]; edge[cnt].to=v; edge[cnt].w=sqrt((a[u].x-a[v].x)*(a[u].x-a[v].x)+(a[u].y-a[v].y)*(a[u].y-a[v].y)); h[u]=cnt; cnt++; } int main() { memset(h,-1,sizeof(h)); scanf("%d",&n); for(int i=1;i<=n;i++) { scanf("%d%d",&a[i].x,&a[i].y); dis[i]=100000000; } scanf("%d",&m); for(int i=1;i<=m;i++) { int u,v; scanf("%d%d",&u,&v); add_edge(u,v); add_edge(v,u); } scanf("%d%d",&s,&t); dis[s]=0; for(int i=1;i<=n;i++) { double minn=100000000; int mj=0; for(int j=1;j<=n;j++) if(b[j]==0&&dis[j]<minn) { minn=dis[j]; mj=j; } if(mj==0) break; b[mj]=1; for(int j=h[mj];j!=-1;j=edge[j].next) dis[edge[j].to]=min(dis[edge[j].to],dis[mj]+edge[j].w); } printf("%.2lf",dis[t]); return 0; }
|
Dijkstra 算法可以用链式前向星,也可以用邻接矩阵,但个人认为链式前向星会更快一些,因为可以省去判断是否与当前点相连。
应当注意的是,Dijkstra 不能处理存在负边权的情况,如下图:
如图,从 A 到 C 最短应该是 A—>B—>C,长度为-7,但 Dijkstra 在第一轮循环的时候,就会先更新点 C 为 1,再用点 C 去修改点 B,然后更新点 B 为-9,所以无法求出最短路径。
优点:时间复杂度稳定,不容易被卡
缺点:无法处理存在负边权的情况
Bellman-Ford
Bellman-Ford 也是一种单源最短路径,其时间复杂度为O(nm),较 Dijkstra 来说没有那么稳定,容易被卡。
与 Dijkstra 不同,Bellman-Ford 的核心思想是,在一个图中,总有边是连接着修改过的点和未修改过的点的,通过枚举这些边,来不断修改未修改过的点的值。
具体我也不好直接说明,还是用图来手动模拟一下清晰。
PS:加粗的表示值未被修改过。
第一步:
老规矩,初始化所有点的值为无穷大,但点 1 不用,默认为 0。
数据:dis[1]=0,dis[2,3,4,5]=∞
第二步:
枚举所有的边,发现dis[1]+2<dis[2],dis[1]+4<dis[3],dis[1]+7<dis[4],因此修改这些点的值dis[2]=dis[1]+2=2,dis[3]=dis[1]+4=4,dis[4]=dis[1]+7=7,但在接下来的循环中,又发现dis[2]+1<dis[3],修改dis[3]=dis[2]+1=3,dis[3]+1<dis[4],于是修改dis[4]=dis[3]+1=4。
数据:dis[1]=0,dis[2]=2,dis[3]=3,dis[4]=4,dis[5]=∞
第三步:
再次枚举所有的边,发现dis[2]+2<dis[5],于是修改dis[5]=dis[2]+2=4,而后又发现dis[3]+6>dis[5],于是dis[5]修改完毕,至此,所有的点都修改完了。
关于为什么 Bellman-Ford 要循环n次,其实很简单,虽然上面这个图只用了 3 次就完成了,但不能排除有链的情况,比如这样:
这时,每次循环所有边就只能改变一个点的值,所以至少要循环 5 次。
参考代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56
| #include<cstdio> #include<cstring> #include<cmath> #define min(a,b) a<b?a:b using namespace std; struct Point { int x; int y; }; struct Edge { int next; int to; double w; }; Point a[101]; Edge edge[2001]; int n,m,s,t,cnt,h[101]; double dis[101]; void add_edge(int u,int v) { edge[cnt].next=h[u]; edge[cnt].to=v; edge[cnt].w=sqrt((a[u].x-a[v].x)*(a[u].x-a[v].x)+(a[u].y-a[v].y)*(a[u].y-a[v].y)); h[u]=cnt; cnt++; } int main() { memset(h,-1,sizeof(h)); scanf("%d",&n); for(int i=1;i<=n;i++) { scanf("%d%d",&a[i].x,&a[i].y); dis[i]=100000000; } scanf("%d",&m); for(int i=1;i<=m;i++) { int u,v; scanf("%d%d",&u,&v); add_edge(u,v); add_edge(v,u); } scanf("%d%d",&s,&t); dis[s]=0; for(int i=1;i<=n;i++) for(int j=1;j<=n;j++) for(int k=h[j];k!=-1;k=edge[k].next) dis[edge[k].to]=min(dis[j]+edge[k].w,dis[edge[k].to]); printf("%.2lf",dis[t]); return 0; }
|
Bellman-Ford 可以不用链式前向星,而直接单纯地存边的起讫点,在时间上区别个人认为不大,但空间的话还是后者要优秀一点。
Bellman-Ford 无法处理存在负权回路的情况,即这条回路上所有边的权值加起来为负数的回路(废话这样一直转下去每次减一点值就无穷小了呀),就像这样:
尽管 Bellman-Ford 无法处理负权回路的情况,但可以判断是否存在负权回路,如果全部循环完了,还存在某条边使得从点 A 到点 B 的距离更小,就存在负权回路。
直接在两重循环完了后面加上这样几行代码即可:
1 2 3 4
| for(int i=1;i<=n;i++) for(int j=head[i];j!=-1;j=edge[j].next) if(dis[edge[j].to]>dis[i]+edge[j].w) printf("啊啊啊有负权回路啊!!!");
|
优点:可以处理负边权的情况,可以检测负权回路
缺点:容易被卡!!!
SPFA
SPFA 是 Bellman-Ford 的队列优化版本,时间复杂度和 Bellman-Ford 一样,是O(nm)(不要信《信息学奥赛一本通》上的鬼话,它说时间复杂度是 O(km),其中 k 是常数,约为 2,根本不对,详见 [百度](https://baike.baidu.com/item/SPFA 算法/8297411?fromtitle=SPFA&fromid=11018124&fr=aladdin))
SPFA 的思路非常简单,每次从队头取一个点出来,判断从这个点到相邻的点是否更短,如果是,则将这个相邻的点入队,其实就和 BFS 很像,但不同的是,SPFA 中的点可以多次入队,因为第一次找到的最短路径不一定就是真正的最短路径。同时,也正因为可以多次入队,所以它无法像普通的 BFS 一样估算队列的长度,所以在实现时,要么采用循环队列(即把队列当成一个圆环,当队尾到达一定位置时,就把尾指针移到队头,相当于从头再开始存,一般是用一个取模来完成,这样比较方便),这时队列只需开到2∗n+5即可(《信息学奥赛一本通》原话,具体证明我也不会,如果哪位大佬知道的话麻烦在评论里指出,蟹蟹!),要么还可以用 STL 库中的 queue 容器,这里我用的是 queue,但也要学会手打队列,不能养成 STL 依赖症。
参考代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62
| #include<cstdio> #include<cmath> #include<queue> #define min(a,b) a<b?a:b using namespace std; struct Point { int x; int y; }; struct Edge { int next; int to; double w; }; Point a[101]; Edge edge[2001]; int n,m,s,t,cnt,head[101]; double dis[101]; queue<int> q; void add_edge(int u,int v) { edge[cnt].next=head[u]; edge[cnt].to=v; edge[cnt].w=sqrt((a[u].x-a[v].x)*(a[u].x-a[v].x)+(a[u].y-a[v].y)*(a[u].y-a[v].y)); head[u]=cnt; cnt++; } int main() { scanf("%d",&n); for(int i=1;i<=n;i++) { scanf("%d%d",&a[i].x,&a[i].y); dis[i]=10000000; head[i]=-1; } scanf("%d",&m); for(int i=1;i<=m;i++) { int u,v; scanf("%d%d",&u,&v); add_edge(u,v); add_edge(v,u); } scanf("%d%d",&s,&t); q.push(s); dis[s]=0; while(!q.empty()) { for(int i=head[q.front()];i!=-1;i=edge[i].next) if(dis[q.front()]+edge[i].w<dis[edge[i].to]) { dis[edge[i].to]=dis[q.front()]+edge[i].w; q.push(edge[i].to); } q.pop(); } printf("%.2lf",dis[t]); return 0; }
|
整个过程其实和 BFS 几乎完全一样,这里我介绍一下 queue 容器的使用。
使用 queue 容器要包含#include<queue>
这个头文件。
queue 的定义格式是queue<元素类型>队列名称
,如:queue<int>qwq
就是定义了一个名字为 qwq,元素类型为 int 的队列
queue 一共有 6 个常用函数:
-
qwq.push(x)
:将 x 元素入队
-
qwq.pop()
:将队头元素出队
-
qwq.front()
:返回队头元素的值(但不出队)
-
qwq.back()
:返回队尾元素的值(个人觉得用处不大)
-
qwq.empty()
:判断队列是否为空,如果为空,则返回true
-
qwq.size()
:返回队列中元素个数
差不多就是这些了,如果还有什么,我后面再补充。
特别注意:SPFA 容易被卡!
在此放上一张经久不衰的图片:
2018 年 7 月 19 日,某位同学在 NOI Day 1 T1 里非常熟练地使用了一个广为人知的算法 SPFA 求最短路。
然后呢?
100→60;
Ag→Cu;
最终,他因此没能与理想的大学达成契约。
由于 SPFA“优秀”的时间复杂度,所以它经常在各大 OJ 和 OI 上被卡,再引用一个人的话。
卡 SPFA 已经是一个共识了好吧……
所以在比赛时,最好还是不要用 SPFA 了……
优点:比较简单易懂,而且可以处理负边权的情况
缺点:容易被卡
总结
四种最短路径算法个人觉得还是根据具体的题目来看吧,但一般来说我觉得应该是没有负边权就用 Dijkstra(实在不行加个堆优化),有负边权就用 SPFA 好了,也没有必要一味地说 SPFA 死了,毕竟既然这玩意还没有完全被淘汰不用,就说明它还是有自己的用途的。