🚀🚀🚀🚀🚀订阅专栏👉 趣学算法(dog) 👈 带你学习算法原理 + 算法模板🚀🚀🚀🚀🚀
write in front
朋友们好啊,好久没写过优质博客和算法博客了,所以今天打算把我最近学习的算法—最短路算法给大家讲一讲,我们将由浅入深地去讲解,以 “初学者” 的角度去探索式地学习。会一步步地推进讲解,而不是直接把枯燥的知识点倒出来,应该会有不错的阅读体验。如果觉得不错,可以 “一键三连” 支持一下博主!你们的关注就是我更新的最大动力!Thanks ♪ (・ω・)ノ
七夕节那天,咱们的狗蛋约了豆花打算去万达广场约会看电影,为了彰显自己的绅士风度,狗蛋决定早点过去,于是他思考了一个问题:我该怎么走才能最快到达万达广场呢? 于是狗蛋打开了地图打算找到最短的路线,如下:
狗蛋发现了三条路都可以到达附近的万达广场,所以运用了自己小学时学的两点之间线段最短的知识 ,找到了2号
路线,到了万达广场。
约会完成后的狗蛋在想,在计算机里面我们该如何找到去到万达广场最短的路呢? 在好奇心的驱使下,他找到了夏目学长来帮助他解决问题。
Ⅰ. Floyd 求最短路
0x00 算法介绍
夏目学长听到狗蛋的问题后 立刻给狗蛋说出了一个词 “Floyd算法” 然后说:先学习Floyd
算法来打基础,进而解决你所面临的问题。
Floyd算法又称为插点法,是一种利用动态规划的思想寻找给定的加权图中多源点之间最短路径的算法,与Dijkstra算法类似。该算法名称以创始人之一、1978年图灵奖获得者、斯坦福大学计算机科学系教授罗伯特·弗洛伊德命名。
简单的来说,算法的主要思想是动态规划(dp),而求最短路径需要不断松弛(看过《啊哈算法》的应该清楚什么是松弛)。 不过我不打算讲dp的解法,夏目学长觉得对于狗蛋来讲,会增加学习成本。
Floyd
属于多源最短路径算法,能够求出任意2个顶点之间的最短路径,支持负权边
时间复杂度:O(N * N * N)
这里先给出算法模板,然后进行讲解。
✅ 模板:C++
1:for(int k=1;k<=n;k++)
2: for(int i=1;i<=n;i++)
3: for(int j=1;j<=n;j++)
4: if(e[i][j]>e[i][k]+e[k][j])
5: e[i][j]=e[i][k]+e[k][j];
0x01 算法原理
夏目学长身为《啊哈算法》的粉丝就拿出了《啊哈算法》书上的算法例子了。
上图中有4个城市8条公里,公路上的数字表示这条公路的长短,并且公里是单向的,现在夏目学长要求我们求出任意两个城市之间的最短路程,也就是求任意两个点之间的最短路经,这就是多源最短路问题。
我们可以使用二维数组e (邻接矩阵) 来存储信息。比如1号城市到2号城市的路程为2,则e[1][2] = 2,2号城市无法到达4号城市,则设置为0x3f3f3f3f(最大值相当于正无穷大) ,城市自己到自己的距离为0,也就是e[1][1]=0 。
于是夏目学长让狗蛋思考如何让任意两点之间的距离变短?
当然是:只能引入第三个点k,通过k进行中转,也就是a->k->b,才能缩短路线a到b的路程
💬 eg1:
4号到3号,原本e[4][3]=12,通过1中转后,e[4][1]+e[1][3]=5+6=11,通过1和2号城市中转的话,e[4][1]+e[1][2]+e[2][3]=10,通过这个例子狗蛋明白了:每个顶点都有可能使得另外两个顶点之间的路程变短。
夏目学长又说:假设我们只允许经过1号城市,求任意两城市之间的最短路程,应该如何求呢?
只需判断e[ i ][1]+e[1][ j ]是否比e[ i ][ j ]要小即可。
💬 eg2:
由于是任意两个城市之间的最短距离,所以 i 的范围是1 ~ n 同理 j 也是 1 ~ n
💬 代码演示:
for(int i=1;i<=n;++i) //遍历起点城市
for(int j=1;j<=n;++j) //遍历被缩小距离的城市
if(e[i][j] > e[i][1]+e[1][j]) //如果我通过1城市进行中转后的距离比你现在直接到要近
e[i][j]=e[i][1]+e[1][j];//则直接赋值给给e[i][j]即可
夏目学长又双叒叕说:假设我们允许经过1号城市和2号城市,求任意两点之间的最短路程,应该如何求呢?
我们需要在只允许经过 1号顶点时任意两点的最短路程的结果下,再判断如果经过2号顶点是否可以使得 i 号顶点到 j 号顶点之间的路程变得更短,即判断e[ i ][2]+e[2][ j ] 是否要比 e[ i ][ j ] 要小。
💬 代码演示:
//经过一号顶点
for(int i=1;i<=n;++i)//遍历起点城市
for(int j=1;j<=n;++j)//遍历被缩小距离的城市
if(e[i][j] > e[i][1]+e[1][j])//如果我通过1城市进行中转后的距离比你现在直接到要近
e[i][j]=e[i][1]+e[1][j];//则直接赋值给给e[i][j]即可
//经过二号顶点
for(int i=1;i<=n;++i)//遍历起点城市
for(int j=1;j<=n;++j)//遍历被缩小距离的城市
if(e[i][j] > e[i][2]+e[2][j])//如果我通过2城市进行中转后的距离比你现在直接到要近
e[i][j]=e[i][2]+e[2][j];//则直接赋值给给e[i][j]即可
因此以此类推:当我们允许通过所有顶点中转,我们可以轻松的写出我们前面写的算法模板。
这个时候狗蛋觉得自己行了,就让夏目学长给他出一个算法题…
0x02 算法应用
夏目学长拿到了AcWing算法基础课的一道模板题目给他:
854. Floyd求最短路
狗蛋看到后思索了一番便写出了代码:
💬 代码演示:
#include <cstring>
#include <iostream>
#include <algorithm>
using namespace std;
const int N = 210, INF = 0x3f3f3f3f;
int n, m, Q;
int e[N][N];
void floyd()//我们讲的算法模板
{
for (int k = 1; k <= n; k ++ )
for (int i = 1; i <= n; i ++ )
for (int j = 1; j <= n; j ++ )
e[i][j] = min(e[i][j], e[i][k] + e[k][j]);
}
int main()
{
scanf("%d%d%d", &n, &m, &Q);//输入
for (int i = 1; i <= n; i ++ )//初始化
for (int j = 1; j <= n; j ++ )
if (i == j) e[i][j] = 0;
else e[i][j] = INF;
while (m -- )
{
int a, b, c;
scanf("%d%d%d", &a, &b, &c);//建图
e[a][b] = min(e[a][b], c);//建图
}
floyd();
while (Q -- )
{
int a, b;
scanf("%d%d", &a, &b);
int t = e[a][b];//查询
if (t > INF / 2) puts("impossible");
else printf("%d\n", t);
}
return 0;
}
🚩 运行结果:
如果想进一步练习Floyd算法的话👉 Floyd算法+并查集算法专题训练👈详细看这篇博客所写的内容和题目,帮你迅速掌握这个算法
Ⅱ. Dijkstra 求最短路
0x00 算法介绍
当狗蛋学会了Floyd最短路算法之后,内心就开始蠢蠢欲动了,还没等到夏目学长开口,狗蛋就打算自己动手用Floyd算法去求自己到达万达广场的最短路,于是他像晚上打codeforces那样坐牢了起来,迟迟不见他敲代码,于是他突然想到夏目学长说过Floyd算法好像是求得多源最短路问题,而他实际遇到的是单源最短路问题,这时夏目学长才语重心长的说,不用着急,等你学会了Dijkstra算法后,自然就会啦
Dijkstra算法是典型的单源最短路径算法,基于贪心思想,用于计算一个节点到其他所有节点的最短路径。主要特点是以起始点为中心向外层层扩展,直到扩展到终点为止。Dijkstra算法是很有代表性的最短路径算法,在很多专业课程中都作为基本内容有详细的介绍,如数据结构,图论,运筹学等等。注意该算法要求图中不存在负权边。
简单来说就是:Dijkstra是一个基于贪心思想的单源最短路算法,从起点开始层层扩展,直到找到终点。
Dijkstra算法
属于单源最短路径算法,能够求出起点到终点的最短路径,不支持负权边
时间复杂度:O(N * N)
这里先给出算法模板,然后进行讲解。
✅ 模板:C++
//解释dist数组含义
用一个 dist 数组保存源点到其余各个节点的距离,dist[i] 表示源点到节点 i 的距离。
初始时,dist 数组的各个元素为无穷大。
//解释st数组得含义
用一个状态数组 st 记录是否找到了源点到该节点的最短距离,st[i]
如果为真,则表示找到了源点到节点 i 的最短距离,st[i]
如果为假,则表示源点到节点 i 的最短距离还没有找到。
初始时,st 各个元素为假(false)
int dijkstra()
{
memset(dist,0x3f,sizeof dist);//初始化各个点距离为正无穷(0x3f3f3f3f)
dist[1]=0;//起始为止设置成 0
for(int i=0;i<n;i++)//迭代 n 此
{
int t=-1;
for(int j=1;j<=n;j++)
{//遍历 dist 数组,找到没有确定最短路径的节点中距离源点最近的点t
if(!st[j] && (t==-1 || dist[t] > dist[j]))
t=j;
}
st[t]=true;//st[i] 置为 true。
for(int j=1;j<=n;j++)//遍历 t 所有可以到达的节点 i
dist[j]=min(dist[j],dist[t]+g[t][j]);//更新 dist[j]
}
if(dist[n]==0x3f3f3f3f) return -1;
return dist[n];
}
估计在这里看到模板,你会和狗蛋一样,一下子就蒙了,不过没关系,下面夏目学长就开始讲解算法原理
0x01 算法原理
夏目学长就在黑板上画了一张这样得图,来帮助狗蛋理解Dijkstra算法原理。
上图总共有五个城市,每个城市如果连线和箭头,则表示这两个城市可以从一个城市到达另一个城市,并且两者之间得距离也从图中标注了出来,现在夏目学长要求狗蛋去求起点1号城市到其余城市之间的最短距离,也就是求起点到任意点的最短路经,这就是单源最短路问题。
-
用一个 dist 数组保存源点到其余各个城市的距离,dist[i] 表示源点到城市 i 的距离。
初始时,dist 数组的各个元素为无穷大(0x3f3f3f3f)。 -
用一个状态数组 st 记录是否找到了源点到该节点的最短距离,st[i]
如果为真,则表示找到了源点到城市 i 的最短距离,st[i]
如果为假,则表示源点到城市 i 的最短距离还没有找到。
初始时,st 各个元素为假(false)
-
然后先把我们的起点初始化一下,也就是dist[1] = 0,接着我们找到1号城市可以到达的城市的最近的城市,显然我们举得例子是2号城市,
-
遍历 dist 数组,找到一个节点,这个节点是:没有确定最短路径的节点中距离源点最近的点。假设该节点编号为 i。此时就找到了源点到该节点的最短距离,st[i] 置为 true。
-
遍历 i 所有可以到达的节点 j,如果 dist[j] 大于 dist[i] 加上 i -> j 的距离,即 dist[j] > dist[i] + w[i][j](w[i][j] 为 i -> j 的距离) ,则更新 dist[j] = dist[i] + w[i][j]。
-
重复 3 4 步骤,直到所有节点的状态都被置为 1。
此时 dist 数组中,就保存了源点到其余各个节点的最短距离。
这个时候算法原理就讲解完毕了,勇敢狗蛋不怕困难,开始了算法实现,然后夏目学长问他,你知道为什么Dijkstra算法不能求负权边么? 狗蛋挠挠头说不知道,于是夏目学长就徐徐道来:
dijkstra
不能解决负权边是因为 dijkstra
要求每个点被确定后st[j] = true
,dist[j]
就是最短距离了,之后就不能再被更新了 (一锤子买卖),而如果有负权边的话,那已经确定的点的dist[j]不一定是最短了
0x02 算法应用
在在狗蛋的哀求下夏目学长拿到了AcWing算法基础课的一道模板题目给他:
849. Dijkstra求最短路 I
狗蛋像是奖励自己一样,扑到电脑面前,写起了代码
💬 代码演示:
#include<iostream>
#include<cstring>
#include<algorithm>
using namespace std;
const int N = 510;
int g[N][N];//邻接矩阵存储城市
bool st[N];//该点距离是否被确定了已经
int dist[N];//存储起点到终点的距离
int n,m;
int dijkstra()
{
memset(dist,0x3f,sizeof dist);//初始化各个点距离为正无穷(0x3f3f3f3f)
dist[1]=0;//起始为止设置成 0
for(int i=0;i<n;i++)//迭代 n 此
{
int t=-1;
for(int j=1;j<=n;j++)
{//遍历 dist 数组,找到没有确定最短路径的节点中距离源点最近的点t
if(!st[j] && (t==-1 || dist[t] > dist[j]))
t=j;
}
st[t]=true;//st[i] 置为 true。
for(int j=1;j<=n;j++)//遍历 t 所有可以到达的节点 i
dist[j]=min(dist[j],dist[t]+g[t][j]);//更新 dist[j]
}
if(dist[n]==0x3f3f3f3f) return -1;
return dist[n];
}
int main()
{
memset(g, 0x3f, sizeof g);
cin>>n>>m;
while(m--)
{
int a,b,w;
cin>>a>>b>>w;//输入边和权重
g[a][b]=min(g[a][b],w);//建图
}
int t=dijkstra();
printf("%d\n",t);
return 0;
}
🚩 运行结果:
如果想进一步练习Dijkstra算法的话👉 最短路Dijkstra,spfa,图论二分图算法AYIT—ACM训练(模板版)👈详细看这篇博客所写的内容和题目,帮你迅速掌握这个算法
Ⅲ. bellman-ford 求最短路
0x01 算法介绍
当狗蛋以为自己能够挑战所有的最短路的题目的时候,突然遇到了一道有边数限制的最短路问题,于是他又屁颠屁颠的找到了夏目学长进行求教,这是不是很好的一种品质呢?不断学习,不断求教,不断进步!
于是夏目学长看到了算法题的题目如下:
看完后夏目学长 哦~ 了一声,就跟狗蛋说,这是bellman-ford
最短路算法,下面就来给你讲解一下这个算法:
Bellman - ford 算法是求含负权图的单源最短路径的一种算法,效率较低,代码难度较小。其原理为连续进行松弛,在每次松弛时把每条边都更新一下,若在 n-1 次松弛后还能更新,则说明图中有负环,因此无法得出结果,否则就完成。
( 通俗的来讲就是:假设 1 号点到 n 号点是可达的,每一个点同时向指向的方向出发,更新相邻的点的最短距离,通过循环 n-1 次操作,若图中不存在负环,则 1 号点一定会到达 n 号点,若图中存在负环,则在 n-1 次松弛后一定还会更新)
Bellman-ford算法
属于单源最短路径算法,能够求出起点到终点的最短路径,支持负权边
时间复杂度:O(N * M)
这里先给出算法模板,然后进行讲解。
✅ 模板:C++
const int N = 510,M=10010;
int n,m,k;
int dist[N];//备份dist数组 //dist[N]表示从起点到当前点的当前最短距离
int backup[N];//备份dist数组 backup[j]表示每次进入第2重循环的dist数组的备份
struct Edge
{
int a,b,w;
}e[M];
void bellman_ford()
{
memset(dist,0x3f,sizeof dist);//初始化距离
dist[1]=0;//起点设置为 0
for(int i=0;i<k;i++)//迭代K次
{
memcpy(backup,dist,sizeof dist);//备份dist数组的距离
for(int j=0;j<m;j++)//迭代 m 次
{
int a=e[j].a,b=e[j].b,w=e[j].w;
dist[b]=min(dist[b],backup[a]+w);//松弛操作
}
}
}
大家看到这里肯定和狗蛋一样一头雾水,下面就给大家讲解一下算法原理。♪ (・ω・)ノ
0x01 算法原理
for n次
for 所有边 a,b,w (松弛操作)
dist[b] = min(dist[b],backup[a] + w)
注意:backup[] 数组是上一次迭代后 dist[] 数组的备份,由于是每个点同时向外出发,因此需要对 dist[] 数组进行备份,若不进行备份会因此发生串联效应,影响到下一个点
在下面代码中,是否能到达n号点的判断中需要进行if(dist[n] > INF/2)判断,而并非是if(dist[n] == INF)判断,原因是INF是一个确定的值,并非真正的无穷大,会随着其他数值而受到影响,dist[n]大于某个与INF相同数量级的数即可
0x02 算法应用
AcWing 853. 有边数限制的最短路
狗蛋按照自己的理解,学出了代码。
💬 代码演示:
#include<iostream>
#include<algorithm>
#include<cstring>
using namespace std;
const int N = 510,M=10010;
int n,m,k;
int dist[N];//备份dist数组 //dist[N]表示从起点到当前点的当前最短距离
int backup[N];//备份dist数组 backup[j]表示每次进入第2重循环的dist数组的备份
struct Edge
{
int a,b,w;
}e[M];
void bellman_ford()
{
memset(dist,0x3f,sizeof dist);//初始化距离
dist[1]=0;//起点设置为 0
for(int i=0;i<k;i++)//迭代K次
{
memcpy(backup,dist,sizeof dist);//备份dist数组的距离
for(int j=0;j<m;j++)//迭代 m 次
{
int a=e[j].a,b=e[j].b,w=e[j].w;
dist[b]=min(dist[b],backup[a]+w);//松弛操作
}
}
}
int main()
{
cin>>n>>m>>k;
for(int i=0;i<m;i++)
{
int a,b,w;
cin>>a>>b>>w;
e[i]={a,b,w};
}
bellman_ford();
//在下面代码中,是否能到达n号点的判断中需要进行if(dist[n] > INF/2)判断,而并非是if(dist[n] == INF)判断,
//原因是INF是一个确定的值,并非真正的无穷大,
//会随着其他数值而受到影响,dist[n]大于某个与INF相同数量级的数即可
if(dist[n] > 0x3f3f3f3f / 2) puts("impossible");
else cout<<dist[n]<<endl;
return 0;
}
🚩 运行结果:
Ⅳ. spfa 求最短路
0x01 算法介绍
夏目学长讲解完毕Bellman-ford算法后,就又给狗蛋讲了一个新的算法知识—spfa算法求最短路。
SPFA 算法是 Bellman-Ford算法 的队列优化算法的别称,通常用于求含负权边的单源最短路径,以及判负权环。SPFA 最坏情况下时间复杂度和朴素 Bellman-Ford 相同,为 O(VE)。
spfa算法
属于单源最短路径算法,能够求出任意2个顶点之间的最短路径,支持负权边
时间复杂度:O(M * N)
✅ 模板:C++
const int N = 100010;
int h[N], e[N], w[N], ne[N], idx;//邻接表,存储图
int st[N];//标记顶点是不是在队列中
int dist[N];//保存最短路径的值
int q[N], hh, tt = -1;//数组模拟实现队列 也可以使用STL队列
void add(int a, int b, int c){//图中添加边和边的端点
e[idx] = b, w[idx] = c, ne[idx] = h[a], h[a] = idx++;
}
void spfa(){
q[++tt] = 1;//从1号顶点开始松弛,1号顶点入队
dist[1] = 0;//1号到1号的距离为 0
st[1] = 1;//1号顶点在队列中
while(tt >= hh){//不断进行松弛
int a = q[hh++];//取对头记作a,进行松弛
st[a] = 0;//取完队头后,a不在队列中了
for(int i = h[a]; i != -1; i = ne[i])//遍历所有和a相连的点
{
int b = e[i], c = w[i];//获得和a相连的点和边
if(dist[b] > dist[a] + c){//如果可以距离变得更短,则更新距离
dist[b] = dist[a] + c;//更新距离
if(!st[b]){//如果没在队列中
q[++tt] = b;//入队
st[b] = 1;//打标记
}
}
}
}
}
还没等狗蛋说自己没看懂,夏目学长就开始讲解spfa算法的算法原理了。
0x02 算法原理
先明确一下松弛的概念。
- 考虑节点u以及它的邻居v,从起点跑到v有好多跑法,有的跑法经过u,有的不经过。
- 经过u的跑法的距离就是distu+u到v的距离。
- 所谓松弛操作,就是看一看distv和distu+u到v的距离哪个大一点。
- 如果前者大一点,就说明当前的不是最短路,就要赋值为后者,这就叫做松弛。
然后理解spfa算法的算法原理:
- 建立一个队列,初始时队列里只有起始点。
- 再建立一个数组记录起始点到所有点的最短路径(该表格的初始值要赋为极大值,该点到他本身的路径赋为0)。
- 再建立一个数组,标记点是否在队列中。
- 队头不断出队,计算始点起点经过队头到其他点的距离是否变短,如果变短且被点不在队列中,则把该点加入到队尾。
- 重复执行直到队列为空。
- 在保存最短路径的数组中,就得到了最短路径。
看图解:
- 给定一个有向图,如下,求A~E的最短路。
- 源点A首先入队,然后A出队,计算出到BC的距离会变短,更新距离数组,BC没在队列中,BC入队
- B出队,计算出到D的距离变短,更新距离数组,D没在队列中,D入队。然后C出队,无点可更新。
- D出队,计算出到E的距离变短,更新距离数组,E没在队列中,E入队。
- E出队,此时队列为空,源点到所有点的最短路已被找到,A->E的最短路即为8
学完真的是舒服的要飞起来了,不过还有最后一步,加油!
0x02 算法应用
851. spfa求最短路
这次因为是扩展知识,所以夏目学长亲自给狗蛋敲了代码。
💬 代码演示:
#include <iostream>
#include <algorithm>
#include <cstring>
using namespace std;
const int N = 100010;
int h[N], e[N], w[N], ne[N], idx;//邻接表,存储图
int st[N];//标记顶点是不是在队列中
int dist[N];//保存最短路径的值
int q[N], hh, tt = -1;//队列
void add(int a, int b, int c){//图中添加边和边的端点
e[idx] = b, w[idx] = c, ne[idx] = h[a], h[a] = idx++;
}
void spfa()
{
q[++tt] = 1;//从1号顶点开始松弛,1号顶点入队
dist[1] = 0;//1号到1号的距离为 0
st[1] = 1;//1号顶点在队列中
while(tt >= hh){//不断进行松弛
int a = q[hh++];//取对头记作a,进行松弛
st[a] = 0;//取完队头后,a不在队列中了
for(int i = h[a]; i != -1; i = ne[i])//遍历所有和a相连的点
{
int b = e[i], c = w[i];//获得和a相连的点和边
if(dist[b] > dist[a] + c){//如果可以距离变得更短,则更新距离
dist[b] = dist[a] + c;//更新距离
if(!st[b]){//如果没在队列中
q[++tt] = b;//入队
st[b] = 1;//打标记
}
}
}
}
}
int main()
{
memset(h, -1, sizeof h);//初始化邻接表
memset(dist, 0x3f, sizeof dist);//初始化距离
int n, m;//保存点的数量和边的数量
cin >> n >> m;
for(int i = 0; i < m; i++){//读入每条边和边的端点
int a, b, w;
cin >> a >> b >> w;
add(a, b, w);//加入到邻接表
}
spfa();
if(dist[n] == 0x3f3f3f3f )//如果到n点的距离是无穷,则不能到达
cout << "impossible";
else cout << dist[n];//否则能到达,输出距离
return 0;
}
🚩 运行结果:
📌 [ 笔者 ] 夏目浅石.
📃 [ 更新 ] 2023.9
❌ [ 勘误 ] /* 暂无 */
📜 [ 声明 ] 由于作者水平有限,本文有错误和不准确之处在所难免,
本人也很想知道这些错误,恳望读者批评指正!
📜 参考文献:
Acwing算法基础课—https://www.acwing.com
《啊哈算法》人民邮电出版社出版—啊哈磊
安阳工学院ACM实验室2023上半年第四周训练题
y总头号粉丝Hasity题解https://www.acwing.com/user/myspace/index/55289/
百度百科[EB/OL]. []. https://baike.baidu.com/.
维基百科[EB/OL]. []. https://zh.wikipedia.org/wiki/Wikipedia
B. 比特科技. C/C++[EB/OL]. 2021[2021.8.31]
C程序设计案例教程 清华大学出版社—钟家民,周晏,张珊靓
如果侵权,请联系作者夏目浅石,立刻删除