简单の暑假总结——最短路

本文深入探讨了图论中的经典问题——最短路径,重点讲解了Floyd算法和Dijkstra算法。Floyd算法通过动态规划思想求解多源最短路径,而Dijkstra算法则采用贪心策略解决单源最短路径问题。文章通过实例分析了两种算法的实现细节、优化方法以及适用场景,特别强调了Dijkstra算法不适用于带负权的图。此外,还提及了Bellman-Ford和SPFA算法作为负权图的解决方案。
摘要由CSDN通过智能技术生成

5.1 最短路

最短路应该是图论中非常经典的一个知识点了

那么,最短路是什么呢?

考虑下面这个无向图:

在这里插入图片描述

如果我们要求第 1 1 1 号结点到第 3 3 3 结点的最短距离,那么,我们就可以使用最短路算法

方法很多,我们依次了解

5.2 Floyd 算法

Flotd 算法应该是唯一一个可以处理多源最短路的的算法了

Floyd 本质上其实是一个 DP,那么,自然就有状态及其状态转移方程

定义状态:

d p [   i   ] [   j   ] dp[\ i\ ][\ j\ ] dp[ i ][ j ] 表示从第 i i i 号点到第 j j j 号点的最短路,那么,我们实际上是很容易想到状态转移方程式:

d p [   i   ] [   j   ] = min ⁡ ( d p [   i   ] [   j   ] , d p [   i   ] [   k   ] + d p [   k   ] [   j   ] ) ( i ≤ k ≤ j ) dp[\ i\ ][\ j\ ]=\min(dp[\ i \ ][\ j\ ],dp[\ i\ ][\ k\ ]+dp[\ k\ ][\ j\ ])(i\le k\le j) dp[ i ][ j ]=min(dp[ i ][ j ],dp[ i ][ k ]+dp[ k ][ j ])(ikj)

初始化也是非常简单的:

d p [   i   ] [   j   ] = { 0 i = j ∞ i ≠ j   and ⁡   i  与  j  之间无连线 a [   i   ] [   j   ] i ≠ j   and ⁡   i  与  j  之间有连线 dp[\ i\ ][\ j\ ]=\begin{cases}0&i=j\\\infty&i\ne j\ \operatorname{and}\ i\ \text{与}\ j\ \text{之间无连线}\\a[\ i\ ][\ j\ ]&i\ne j\ \operatorname{and}\ i\ \text{与}\ j\ \text{之间有连线}\end{cases} dp[ i ][ j ]= 0a[ i ][ j ]i=ji=j and i  j 之间无连线i=j and i  j 之间有连线

那么,我们似乎可以很轻松的完成代码了

Eg_1 非负权单源最短路

我们可以轻松的打出代码

#include<cstdio>
#include<algorithm>
using namespace std;
int a[2505][2505],n,m,Start,End,x,y,z;
void Floyd(){
	for(int i=1;i<=n;i++){
		for(int j=1;j<=n;j++){
			for(int k=1;k<=n;k++){
				a[i][j]=min(a[i][j],a[i][k]+a[k][j]);
			}
		}
	}			//上文已提,套公式
}
int main(){
	scanf("%d%d%d%d",&n,&m,&Start,&End);
	for(int i=1;i<=n;i++){
		for(int j=1;j<=n;j++){
			if(i==j){
				a[i][j]=0;
			}else{
				a[i][j]=0x3f3f3f3f;
			}
		}			//上文已提,初始化
	}
	for(int i=1;i<=m;i++){
		scanf("%d%d%d",&x,&y,&z);
		a[x][y]=z;			//建图
	}
	Floyd();
	printf("%d",a[Start][End]);			//输出
	return 0;
}

然后,我们轻松的将代码交给了评测姬

在这里插入图片描述

听取WA声一片

实际上,我们的状态定义是少了一维的(与背包类似)

那么,完整的状态长什么样?

定义状态:

d p [   k   ] [   i   ] [   j   ] dp[\ k\ ][\ i\ ][\ j\ ] dp[ k ][ i ][ j ] 表示经过前 k k k 个点(包含第 k k k 个点且不要求全部经过),从第 i i i 个点到达第 j j j 个点的最短路

那么,在讨论 d p [   k   ] [   i   ] [   j   ] dp[\ k\ ][\ i\ ][\ j\ ] dp[ k ][ i ][ j ] 时,我们有两种决策,一是不经过第 k k k 个点,二是经过第 k k k 个点

如果不经过第 k k k 个点,就有 d p [   k   ] [   i   ] [   j   ] = d p [   k − 1   ] [   i   ] [   j   ] dp[\ k\ ][\ i\ ][\ j\ ]=dp[\ k-1\ ][\ i\ ][\ j\ ] dp[ k ][ i ][ j ]=dp[ k1 ][ i ][ j ]

如果经过第 k k k 个点,就有 d p [   k   ] [   i   ] [   j   ] = d p [   k − 1   ] [   i   ] [   k   ] + d p [   k − 1   ] [   k   ] [   j   ] dp[\ k\ ][\ i\ ][\ j\ ]=dp[\ k-1\ ][\ i\ ][\ k\ ]+dp[\ k-1\ ][\ k\ ][\ j\ ] dp[ k ][ i ][ j ]=dp[ k1 ][ i ][ k ]+dp[ k1 ][ k ][ j ]

综合一下:

d p [   k   ] [   i   ] [   j   ] = min ⁡ ( d p [   k − 1   ] [   i   ] [   j   ] , d p [   k − 1   ] [   i   ] [   k   ] + d p [   k − 1   ] [   k   ] [   j   ] ) ( i ≤ k ≤ j ) dp[\ k\ ][\ i\ ][\ j\ ]=\min(dp[\ k-1\ ][\ i \ ][\ j\ ],dp[\ k-1\ ][\ i\ ][\ k\ ]+dp[\ k-1\ ][\ k\ ][\ j\ ])(i\le k\le j) dp[ k ][ i ][ j ]=min(dp[ k1 ][ i ][ j ],dp[ k1 ][ i ][ k ]+dp[ k1 ][ k ][ j ])(ikj)

我们发现: k k k 维的状态全部都由第 k − 1 k-1 k1 维的状态转移得到的

自然,我们可以舍去这一维,简化状态转移方程式

但是,因为进行了化简,所以要注意顺序,因为 k k k 原先是第一维,所以应该先遍历 k k k

那么,我们就有了正确的 Floyd 算法

#include<cstdio>
#include<algorithm>
using namespace std;
int a[2505][2505],n,m,Start,End,x,y,z;
void Floyd(){
	for(int i=1;i<=n;i++){
		for(int j=1;j<=n;j++){
			for(int k=1;k<=n;k++){
				a[i][j]=min(a[i][j],a[i][k]+a[k][j]);
			}
		}
	}			//上文已提,套公式
}
int main(){
	scanf("%d%d%d%d",&n,&m,&Start,&End);
	for(int i=1;i<=n;i++){
		for(int j=1;j<=n;j++){
			if(i==j){
				a[i][j]=0;
			}else{
				a[i][j]=0x3f3f3f3f;
			}
		}			//上文已提,初始化
	}
	for(int i=1;i<=m;i++){
		scanf("%d%d%d",&x,&y,&z);
		a[x][y]=z;			//建图
	}
	Floyd();
	printf("%d",a[Start][End]);			//输出
	return 0;
}

但是,由于 Floyd 算法时间复杂度极高,达到了 O ( n 3 ) O(n^3) O(n3) ,所以

在这里插入图片描述

恭喜蒟蒻喜提 TLE

我们就需要其它的算法

但是!请容我再废话两句:

5.2.1 Floyd 求路径

Eg_2 单源点最短路问题

我们需要定义一个数组 p r e [   i   ] [   j   ] pre[\ i\ ][\ j\ ] pre[ i ][ j ] ,表示从第 i i i 号点到第 j j j 号点的最短路中,第 j j j 号点的上一个的下标

具体详情可以见代码:

#include<cstdio>
int a[505][505],pre[505][505];
int n,m,Start,End,x,y,z;
void Floyd(){
	for(int k=1;k<=n;k++){
		for(int i=1;i<=n;i++){
			for(int j=1;j<=n;j++){
				if(a[i][j]>a[i][k]+a[k][j]){
					a[i][j]=a[i][k]+a[k][j];			//模板
					pre[i][j]=pre[k][j];			//注意,因为这里 k 是一个不确定的的值,不能将 k 的值赋给 pre[i][j] ,而是应该将 pre[k][j] ,即 j 的前驱赋给 pre[i][j]
				}
			}
		}
	}
}
void print(int x,int y){
	if(pre[x][y]==0){
		return ;
	}
	print(x,pre[x][y]);
	printf(" %d",y);
}
int main(){
	scanf("%d%d",&n,&m);
	for(int i=1;i<=n;i++){
		for(int j=1;j<=n;j++){
			a[i][j]=i==j?0:0x3f3f3f3f;
		}
	}
	for(int i=1;i<=m;i++){
		scanf("%d%d%d",&x,&y,&z);
		a[x][y]=z;
		pre[x][y]=x;			//初始化,y 的前驱自然是 x
	}
	Floyd();
	scanf("%d%d",&Start,&End);
	printf("%d\n%d",a[Start][End],Start);
	print(Start,End);
	return 0;
}

5.3 Dijkstra 算法

Dijkstra 算法是求最短路中最常用的算法

Dijkstra 算法本质上是一个贪心,它将图中所有的结点分为了两种,一种是已经确定了最短路的结点,一种是还没有确定最短路 的结点。

每一次操作时,我们在还没有确定最短路 的结点中找到一个路径最短的结点,因为该结点路径最短且为未被标记,所以该结点的路径必然是最短路,那么,我们就将该结点标记为已经确定了最短路的结点,并更新该结点周围结点的最短路径(以下简称松弛)

听不懂?我们可以结合一张图

5.3.1 举个例子

在这里插入图片描述

在该图中,我们设 1 1 1 号结点为起点

对于每一个结点,逗号前的数值表示序号,逗号后的元素表示从起点到该结点的最小距离

在初始化时,我们需要对原始的最短路进行处理:

d i s [   i   ] = { 0 i = i S t a r t ∞ i ≠ i S t a r t dis[\ i\ ]=\begin{cases}0&i=i_{Start}\\\infty&i\ne i_{Start}\end{cases} dis[ i ]={0i=iStarti=iStart

接下来,我们可以开始 Dijkstra 算法了

首先,我们在未确定最短路的点中找到与起点距离最短的一个点,标记为以确定最短路,并进行松弛操作:

在这里插入图片描述
这里,边缘标黑的结点称为已标记最短路的结点

这是我们的第一步

剩下的依葫芦画瓢,我们就可以得到剩下的操作后的结果

第二步,我们将 3 3 3 号结点作标记,并进行相应的松弛操作

在这里插入图片描述

第三步,由于路径最短的结点有两个, 4 4 4 号结点和 5 5 5 号结点,

这里随便选一个 5 5 5 号结点

在这里插入图片描述

第四步,我们将 4 4 4 号结点作标记,并进行相应的松弛操作

但是,由于 3 + 5 > 6 3+5>6 3+5>6 ,所以实际上它松了个寂寞无行路,若有人知春去处(文艺复兴?)

在这里插入图片描述
第四步,我们将 2 2 2 号结点作标记,并进行相应的松弛操作(实际上还是松了个寂寞

在这里插入图片描述
所以我们很容易得到 Dijkstra 算法的原始版本

Eg_3 非负权单源最短路

是的,还是它

相信大家应该都可以熟练的打出 Dijkstra 的原始算法了,下面给出代码:

#include<cstdio>
const int N=20005;
int dis[N],vis[N],a[N][N];
int n,m,Start,End,x,y,z;
void Dijkstra(){
	for(int i=1;i<=n;i++){
		dis[i]=a[Start][i];
	}			//初始化与起点相邻的点
	dis[Start]=0,vis[Start]=1;
	for(int i=1;i<n;i++){
		int minn=2147483647,tot;			//minn 用于求最小值,tot 记录下标
		for(int j=1;j<=n;j++){
			if(vis[j]==0&&minn>dis[j]){			//未被标记且可以更新
				minn=dis[j],tot=j;			//更新
			}
		}
		vis[tot]=1;			//标记
		for(int j=1;j<=n;j++){
			if(dis[tot]+a[tot][j]<dis[j]){			//对所有可以松弛的边进行松弛
													//因为所有未相邻的点全部为极大值,不用担心松弛未相邻的点
				dis[j]=dis[tot]+a[tot][j];
			}
		}
	}
}
int main(){
	scanf("%d%d%d%d",&n,&m,&Start,&End);
	for(int i=1;i<=n;i++){
		for(int j=1;j<=n;j++){
			a[i][j]=i==j?0:0x3f3f3f3f;			//初始化邻接矩阵
		}
		dis[i]=0x3f3f3f3f;			//初始化距离
	}
	for(int i=1;i<=m;i++){
		scanf("%d%d%d",&x,&y,&z);
		a[x][y]=a[y][x]=z;			//输入邻接矩阵
	}
	Dijkstra();
	printf("%d",dis[End]);
	return 0;
}

在这里插入图片描述

然而,我们可以对 Dijkstra 算法进行优化

5.3.2 邻接表或链式前向星优化

对于这个循环:

for(int j=1;j<=n;j++){
	if(dis[tot]+a[tot][j]<dis[j]){	
		dis[j]=dis[tot]+a[tot][j];
	}
}

很显然,在使用邻接表或链式前向星时,可以直接求到第 i i i 号结点相邻的所有结点,我们只需要对这些结点进行松弛即可

下面是链式前向星优化后的结果:

for(int i=head[xx];i;i=Next[i]){
	int yy=ver[i],zz=edge[i];
	if(dis[yy]>dis[xx]+zz){			//判断是否可以更新
		dis[yy]=dis[xx]+zz;
		q.push(make_pair(-dis[yy],yy));			//与优先队列优化有关,后文再提
	}
}

5.3.3 优先队列优化

对于这个循环:

for(int j=1;j<=n;j++){
	if(vis[j]==0&&minn>dis[j]){	
		minn=dis[j],tot=j;	
	}
}

他的本质上其实是要找一个最小值

说起找最小值,我们会想到一个东西——小根堆(优先队列)

自然,我们就可以使用优先队列来进行优化了

int xx=q.top().second;			//去除堆顶元素
								//结合上文 q.push(make_pair(-dis[yy],yy));——因为优先队列默认为大根堆,我们可以通过一个取反操作,将其编号按照小根堆的顺序压入,就避免了priorty_queue<int,vector<int>,greater<int> > q; 的麻烦写法
q.pop();			//记住:一定要pop!!!
if(vis[xx]){			//如果已经标记了,就不管它
	continue;
}

这样,我们就得到了优化后的 Dijkstra 算法

#include<queue>
#include<cstdio>
using namespace std;
priority_queue<pair<int,int> > q;
const int N=105;
int head[N],Next[N],ver[N],edge[N],len;
int dis[N],vis[N];
int n,m,x,y,z,Start,End;
void add(int x,int y,int z){
	ver[++len]=y,edge[len]=z,Next[len]=head[x],head[x]=len;
}
void Dijkstra(){
	for(int i=1;i<=n;i++){
		dis[i]=0x3f3f3f3f;
	}
	dis[Start]=0;			//初始化
	q.push(make_pair(0,Start));			//初始化优先队列
	while(!q.empty()){
		int xx=q.top().second;
		q.pop();
		if(vis[xx]){
			continue;
		}			//优先队列优化,上文已提
		vis[xx]=1;
		for(int i=head[xx];i;i=Next[i]){
			int yy=ver[i],zz=edge[i];
			if(dis[yy]>dis[xx]+zz){
				dis[yy]=dis[xx]+zz;
				q.push(make_pair(-dis[yy],yy));
			}
		}			//链式前向星优化,上文已提
	}
}
int main(){
	scanf("%d%d%d%d",&n,&m,&Start,&End);
	for(int i=1;i<=m;i++){
		scanf("%d%d%d",&x,&y,&z);
		add(x,y,z);
		add(y,x,z);
	}
	Dijkstra();
	printf("%d",dis[End]);			//主函数为模板
	return 0;
}

时间复杂度大大降低:

在这里插入图片描述

5.3.4 Dijkstra 使用注意事项

在使用 Dijkstra 时要注意:它不宜用于带负权的有向图

举个例子:

在这里插入图片描述

按照 Dijkstra 的流程,我们会有上图的一个结果:

肉眼发现:此时 3 3 3 号结点是可以松弛 2 2 2 号结点的,但因为 2 2 2 号结点在之前的算法中已经被标记了,所以计算机是不会更新 2 2 2 号结点的最短路的!

那么,我们就需要其它的算法

5.4 Bell_man Ford以及SPFA算法

个人不太喜欢这两种算法…

那么,对于 Bell_man Ford 算法,其实就是一个迭代的过程,以此对所有边进行松弛

SPFA 算法则是 Bell_man Ford 算法的优化,与 Dijkstra 算法优化类似

由于个人喜好,SPFA容易被卡,PPT 出现故障等缘故,下面直接亮代码吧

但是,我还是要罗嗦两句(烦不烦)

5.4.1 判断负环

我们可以用 SPFA 算法判断有无负环出现

所谓负环,是指在有向图中,从一个点出发,绕了一圈回到了该点,且所经过的权值和为负数

举个例子:

在这里插入图片描述

如上图就是一个负环

判断负环的方法在代码里

#include<queue>
#include<cstdio>
#include<cstdlib>
using namespace std;
queue<int> q;
const int N=40005;
int head[N],Next[N],ver[N],edge[N],len;
int dis[N],vis[N],flag[N];
int n,m,Start,End,x,y,z;
void add(int x,int y,int z){
	ver[++len]=y,edge[len]=z,Next[len]=head[x],head[x]=len;
}
void SPFA(){
	for(int i=1;i<=n;i++){
		dis[i]=0x3f3f3f3f;
	}
	dis[Start]=0,vis[Start]=flag[Start]=1;			//初始化,其中 flag 数组用来标记某个元素入了几次队列
	q.push(Start);
	while(!q.empty()){
		int xx=q.front();
		q.pop();
		vis[xx]=0;			//取消入队的标记
		for(int i=head[xx];i;i=Next[i]){
			int yy=ver[i],zz=edge[i];
			if(dis[yy]>dis[xx]+zz){
				dis[yy]=dis[xx]+zz;
				if(!vis[yy]){			//若当前点尚未入队
					vis[yy]=1;
					flag[yy]++;
					q.push(yy);
					if(flag[yy]==n){			//因为一共只有 n 个点,假设一次遍历只能确定一个点的最短路,那么,最多只会入 n-1 次队列
												//否则,说明有负环出现
						printf("-1");
						exit(0);
					}
				}
			}
		}
	}
}
int main(){
	scanf("%d%d",&n,&m);
	for(int i=1;i<=m;i++){
		scanf("%d%d%d",&x,&y,&z);
		add(x,y,z);
	}
	scanf("%d%d",&Start,&End);
	SPFA();
	printf("%d",dis[End]);
	return 0;
}

注意:Bell_man Ford和SPFA算法可以解决带负权的有向图,但不能解决带负权的无向图

那如何解决带负权的无向图呢?

那我劝你老老实实打 Floyd 吧

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值