OI中常用的四种最短路算法

OI中常用的四种最短路算法

Floyd 算法

Floyd 算法是我接触到的第一个算法,也是最简单的一种算法。

算法思想

我们将最短路的情况进行讨论,可以按经过点数分为两类:
1、从源点直接到达汇点
2、从源点经过一个或多个中间点后到达汇点

于是我们可以从一号点开始来尝试用它作为中间点能否缩短当前的最短路的估计值,若可以更新当前最短路,就更新我们的估计值。
当我们把所有点都作为中间点尝试后,就能得到最短路的准确值了。

算法实现

下文中若无特殊说明,则m指边数,n指点数,dis数组用于存最短路的值,Inf表示无穷大,u指源点

(1)初始化:数组dis:
1、if u == v , dis[u][v]=0
2、elif u->v有一条长为w的边,dis[u][v]=w
3、elif dis[u][v]=Inf
(2)算法核心:从1号点开始依次将每一个点作为中间点更新dis数组
(3)更新完毕后,dis数组就是任意两点间最短路的准确值了。

C++代码:

#include<bits/stdc++.h>
#define Inf 0x3f3f3f3f 
using namespace std;

int main()
{
	int dis[101][101];//邻接矩阵存储 
	int n,m;
	cin>>n>>m;
	for(int i=1;i<=n;i++)//初始化 
		for(int j=1;j<=n;j++)
			dis[i][j]=Inf;
//	memset(dis,0x3f,sizeof(dis));//用memset(...,0x7f,...)会发生溢出 
	for(int i=0;i<m;i++)
	{
		int x,y,m;
		cin>>x>>y>>m;
		dis[x][y]=m;//有向图 
//		dis[x][y]=dis[y][x]=m;//无向图		
	}
	for(int i=1;i<=n;i++)
		dis[i][i]=0;
		
//Floyd算法的核心块 
	for(int k=1;k<=n;k++)
		for(int i=1;i<=n;i++)
			for(int j=1;j<=n;j++)
				if(dis[i][j]>dis[i][k]+dis[k][j])
					dis[i][j]=dis[i][k]+dis[k][j];
					
	for(int i=1;i<=n;i++)
	{
		for(int j=1;j<=n;j++)
			printf("%5d ",dis[i][j]);
		cout<<endl; 
	}
	return 0;
}

注意

在初始化的时候,如果用memset(dis,0x7f,sizeof(dis)),在代码第28行计算时可能会发生溢出,因为0x7f7f7f7f+0x7f7f7f7f>INT_MAX,这样就很有可能导致结果错误(结果变成一个极小的负数)

分析

(1)时间复杂度:核心代码三重n循环,所以时间复杂度为O(n3)
(2)空间复杂度:用邻接矩阵存图,空间复杂度为O(n2)
(3)缺点:
1)时间复杂度较高,在很多题上并不能通过
(4)优点:
1)代码简短,易写
2)如果题目需要求多源最短路,其实Floyd算法效率是比执行n次单源最短路算法更优的。
3)无法处理含负权回路的情况

Dijkstra算法

Dijkstra求最短路可能是我用得最多的,但它是一个应用受限的算法,它只适用于正权图

算法思想

我们现在有两个集合s,u,s集合中存放最短路已经确定的点,u中存放最短路未确定的点。
每次从集合u中取当前估计值最小的一个点放入集合s,并以这个点来松弛与它相连的点。

为什么是这样?

这里有一个明显的贪心策略:每次取u集合中最小的元素,为什么u中最小元素一定是它最短路的准确值了呢?
其实这个很好理解,因为s中所有元素都已经对它所能到的点进行过松弛了,所以要想对u中元素再次松弛成功,只能是u中的元素对u中元素进行松弛。
如果k是u中当前最小元素,l是u中任意一个元素,无论如何,都不可能满足dis[k]>dis[l]+e[l][k](因为Dijkstra只针对正权图,e[l][k]>0)

代码实现

(1)初始化:
将dis数组初始化为Inf,dis[u]=0
用book数组标记点是否已经进入集合s,book数组初始化为False
(2)核心算法:每次取u中最小点,并将与之相连的点进行松弛。
(3)当所有点都进入s后,dis数组就为最短路的值

下面是C++(邻接矩阵存储)代码:

#include<bits/stdc++.h>
using namespace std;
#define Inf 0x3f3f3f3f

int main()
{
	int e[101][101],dis[101],m,n,begin;
	bool book[101];
	cin>>n>>m>>begin;
	memset(e,0x3f,sizeof(e));
	memset(book,false,sizeof(book));
	book[begin]=true;
	
	for(int i=1;i<=n;i++)
		e[i][i]=0;
	
	for(int i=0;i<m;i++)
	{
		int u,v,w;
		cin>>u>>v>>w;
		e[u][v]=w;
	}
	
	for(int i=1;i<=n;i++)
		dis[i]=e[begin][i];
	
//Dijkstra算法核心语句
	for(int i=1;i<n;i++)
	{
		//找一个离得最近的点
		int min=Inf,now;
		for(int j=1;j<=n;j++)
			if(!book[j]&&dis[j]<min)
			{
				min=dis[j];
				now=j;
			}
		book[now]=true;
		for(int j=1;j<=n;j++)
			if(dis[j]>dis[now]+e[now][j])
				dis[j]=dis[now]+e[now][j];
	}
	
	for(int i=1;i<=n;i++)
		cout<<dis[i]<<' ';
	return 0;
}

C++代码(邻接表存储)

#include<bits/stdc++.h>
#define Inf 0x7f7f7f7f
using namespace std;

int main()
{
	int u[10005],v[10005],w[10005],next[10005],first[1005],m,n,begin,dis[1005];
	bool book[1005];
	cin>>n>>m>>begin;
	memset(next,-1,sizeof(next));
	memset(first,-1,sizeof(first));
	memset(dis,0x7f,sizeof(dis));
	memset(book,false,sizeof(book));
	dis[begin]=0;
	for(int i=0;i<m;i++)
	{
		cin>>u[i]>>v[i]>>w[i];
		next[i]=first[u[i]];
		first[u[i]]=i;
	}
	for(int T=1;T<n;T++)
	{
		int minn = Inf+1,mini;
		for(int i=1;i<=n;i++)
			if(!book[i]&&dis[i]<minn)
			{
				minn = dis[i];
				mini = i;
			}
		book[mini] = true;
		int k = first[mini];
		while(k!=-1)
		{
			if(dis[mini]+w[k]<dis[v[k]])
				dis[v[k]]=dis[mini]+w[k];
			k=next[k];
		}
	}
	for(int i=1;i<=n;i++)
		cout<<dis[i]<<' ';
	return 0;
} 

分析

(1)时间复杂度:O(n2)(邻接矩阵),O(nm)(邻接表)
(2)空间复杂度:O(n2)(邻接矩阵),O(m)(邻接表)
(3)优点:时间复杂度较小,不易被卡
(4)缺点:不能解决负权边

优化

这个算法的效率瓶颈在哪?
找到最小值
我们完全不用再全部扫一遍来找最小值
可以借助我们学过的数据结构来优化
怎么更快的找最小值?
堆 OR 优先队列

实现

我们已经知道了可以用堆来优化,但我们还有两个细节问题需要处理:
(1)堆中的元素是什么?
(2)在改变dis数组后如何改变堆中对应的值呢?

(1)我认为我们需要在堆中存两个值,一是节点编号,用于计算,而是当前估计值,用于排序。
(2)我们其实无需改变堆中对应值,因为堆总是会先弹出最小值,所以我们只需在弹出是判断弹出点是否已经在s中就行了

C++代码

#include<bits/stdc++.h>
#define Inf 0x7f7f7f7f
using namespace std;
int u[200005],v[200005],w[200005],next[200005],first[100005],m,n,begin,dis[100005];
bool book[100005];
struct Node
{
	int v,num;
    bool operator < (const Node &other) const//重载<运算符
    {
        return v>other.v;
    }
};
int main()
{
	cin>>n>>m>>begin;
	memset(next,-1,sizeof(next));
	memset(first,-1,sizeof(first));
	memset(dis,0x7f,sizeof(dis));
	memset(book,false,sizeof(book));
	dis[begin]=0;
	priority_queue <Node> q;
	Node kk;
	kk.v=0;
	kk.num = begin;
	q.push(kk);
	for(int i=0;i<m;i++)
	{
		cin>>u[i]>>v[i]>>w[i];
		next[i]=first[u[i]];
		first[u[i]]=i;
	}
	for(int T=1;T<n;T++)
	{
		int mini = q.top().num;//取最小值的点
		q.pop();
		while(book[mini])//如果是已进入s的点,则继续去最小值
		{
			mini=q.top().num;
			q.pop();
		}
		book[mini] = true;
		int k = first[mini];
		while(k!=-1)
		{
			if(dis[mini]+w[k]<dis[v[k]])
			{
				dis[v[k]]=dis[mini]+w[k];
				kk.v=dis[v[k]];
				kk.num=v[k];
				q.push(kk);//只需将改变过的入队
			}
			k=next[k];
		}
	}
	for(int i=1;i<=n;i++)
		cout<<dis[i]<<' ';
	return 0;
} 
分析

用堆优化后时间复杂度降为O(nlogn),是一个较优秀的算法。

Dijkstra算法的局限性

我们之前一直说Dijkstra算法针对的是正权边,那么到底它可不可以处理负权边呢?
其实,如果负权边仅存在于由源点作为起点的边那Dijkstra依然是有效的
但是对于一般的含负权边的图呢?
在这里插入图片描述
对于这张图,我们求A到C的最短路
按Dijkstra算法的步骤:
(1)A入集合s,松弛B,C;
(2)C入集合s,松弛
(3)B入集合s,松弛
得到dis[C]=2,
但其实dis[C]=1;

产生这种现象的原因:
Dijkstra对于标记过的点就不再进行更新了,所以即使有负权导致最短距离的改变也不会重新计算已经计算过的结果。

Bellman-Ford算法

我们看到,Dijkstra算法虽然优秀,但是无法处理负权边。所以我们需要一个新算法——Bellman-Ford

引理

两点间的最短路一定没有回路
证明:
回路分三种:正权回路,零回路,负权回路
(1)正权回路:去掉后路径缩短
(2)零回路:去掉后不影响最短路
(3)负权回路:存在负权回路时不存在最短路

算法思想

每次对所有的边进行松弛
那么需要执行多少次呢?
在执行算法中我们发现
第一次执行可以求出走一次的最短路
第二次执行可以求出最多走两次的最短路
第三次执行可以求出最多走三次的最短路

第k次执行可以求出最多走k次的最短路
根据引理我们知道不会有回路,所以最多走n-1次

用Bellman-Ford解决负权回路

最短路中不会含回路,所以在最短路存在的情况下,执行n-1次后可以得到最短路,当然也意味着所有边都不能再松弛。
如果有负权回路会如何?
有负权回路就会出现执行完n-1次后仍能松弛成功。

代码实现

代码实现就很简单了,用邻接表存图,每次松弛所有边,执行n-1次。
C++代码:

#include<bits/stdc++.h>
using namespace std;

int main()
{
	int begin,u[1001],v[1001],w[1001],m,n;
	cin>>n>>m>>begin;
	for(int i=0;i<m;i++)
		cin>>u[i]>>v[i]>>w[i];
	int dis[101];
	memset(dis,0x7f,sizeof(dis));
	dis[begin]=0;
	
//bellman-ford核心语句 
	for(int i=1;i<n;i++)
		for(int j=0;j<m;j++)
			if(dis[v[j]]>dis[u[j]]+w[j])
				dis[v[j]]=dis[u[j]]+w[j];
	
	for(int i=1;i<=n;i++)
		cout<<dis[i]<<' ';
//用bellman-ford检测负权回路
//原理:我们已知最短路不会经过超过n-1条边
//所以当我们循环bellman-ford n-1次后
//如果还能松弛,说明图中含负权回路 
	for(int i=0;i<m;i++) 
		if(dis[v[i]]>dis[u[i]]+w[i])
		{
			cout<<"含负权回路";
			break; 
		}
	return 0;
} 

分析

(1)时间复杂度:O(n*m)
(2)空间复杂度:O(m)
(3)优点:能解决含负权的图和负权回路
(4)缺点:时间复杂度偏高

SPFA

SPFA是Shortest Path Faster Algorithm的缩写(听上去好像很厉害的样子)
其实 SPFA == 队列优化的Bellman-Ford

优化原理

在Bellman-Ford中,我们每次都松弛了每条边,但其实,很多边是不用松弛的,可能需要松弛的边有哪些呢?起点dis值发生了改变的点

算法实现

(1)初始化:
将dis数组初始化为Inf,dis[u]=0;
一个队列q,u入队
(2)算法核心:
取q队首顶点a
取a为起点的边进行松弛
若对a->b的边松弛成功且b不在队列q中,b入队
C++代码:

#include<bits/stdc++.h>
using namespace std;

int main()
{
	queue<int> q;
	int m,n,begin;
	cin>>n>>m>>begin;
	q.push(begin);
	int u[1001],v[1001],w[1001],next[1001],first[101],dis[101];
	bool book[101];
	memset(book,false,sizeof(book));
	memset(dis,0x7f,sizeof(dis));
	memset(next,-1,sizeof(next));
	memset(first,-1,sizeof(first));
	dis[begin]=0,book[begin]=true;
	
	for(int i=0;i<m;i++)//建立邻接表
	{
		cin>>u[i]>>v[i]>>w[i];
		next[i]=first[u[i]];
		first[u[i]]=i;
	}
	
	while(!q.empty())//队列不为空就循环 
	{
		int k=first[q.front()];//处理队首 
		book[q.front()]=false;
		q.pop();//队首出队 
		while(k!=-1)
		{
			if(dis[v[k]]>dis[u[k]]+w[k])
			{
				dis[v[k]]=dis[u[k]]+w[k];
				//book数组用于判断是否在队列中 
				//如果不这样就要扫一遍,或者用STL里面的count
				//扫一遍复杂度O(n),count复杂度O(logn),而用book数组只有O(1)的复杂度
				//所以选用book数组
				if(!book[v[k]])
				{
					q.push(v[k]);
					book[v[k]]=true;
				}
			}
			k=next[k];
		}
	}
	
	for(int i=1;i<=n;i++)
		cout<<dis[i]<<' ';
}

分析

(1)时间复杂度:最坏O(n*m)
(2)空间复杂度:O(m)
(3)优点:
时间复杂度较低
能解决负权边和负权回路
(4)缺点:
SPFA是我学过的算法中最好卡的一个算法了
很多数据强的题SPFA都会被卡掉一两个点

SPFA的优化

由于自己还没太搞清楚优化的原理是什么,就不在这献丑了。
给一篇大佬文章
大佬文章

最后一句

四个最短路算法没有明显的优劣,要根据实际情况选择合适的算法,才能A掉题啊。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值