ACM-最短路径算法(Dijkstra、SPFA、Floyd)及模板

引入

在大城市C,道路交通网络错综复杂,你因为有急事需要从A处赶往B处。一路上有若干个路口。你已经预知了各个相连的路口之间的预计通行时间,且A和B是连通的。那么你要怎么来选择你要走的路线,来让你可以最快地到达B呢?

该问题是经典的单源最短路问题,给定一张带权图(可以有向,也可以无向),标定起点和终点,你的目标是求出起点到终点的最短距离。

在求解最短路之前,我们首先约定,给定的图一定是不带负环的。带负环图显然没有最短路。

算法

Bellman-Ford/SPFA

Bellman-Ford

Bellman-Ford算法是一种动态规划算法。

不妨约定起点为0,则规定dis[i]为点i到0的距离,顶点总数为n,则Bellman-Ford的过程可用如下步骤描述:

  1. 将dis数组中所有值设为INF,然后将dis[0]设为0.
  2. 遍历每一条边,检查边的两个端点v和w,更新它们的dis.
  3. 将步骤2执行n-1次.

解释:

  1. dis的更新类似于动态规划的状态转移,其转移方程如下:
    d i s [ i ] = m i n ( d i s [ i ] , d i s [ j ] + w [ j ] [ i ] ) dis[i] = min(dis[i], dis[j] + w[j][i]) dis[i]=min(dis[i],dis[j]+w[j][i])
    其中 w [ j ] [ i ] w[j][i] w[j][i]是从j走到i的花费。

  2. 执行n-1的原因是,最短路的长度最长为n-1(因为最短路一定不包含环路),而每次遍历,我们至少会使最终正确的那条路长度+1.

Bellman-Ford代码如下(使用邻接矩阵,方便理解):

const int maxn = 1005;
// 使用结构体存边
struct edge{
	int u, v, w;
}a[maxn * maxn];
cosnt int INF = 0x3f3f3f3f;
int d[maxn];
int n, m; // 假设有n个顶点,m条边
int Bellman_Ford(int start, int des) // 传入起点与终点
{
	for(int i = 1; i <= n; ++i) d[i] = INF;
	d[start] = 0;
	for(int i = 0; i < n - 1; ++i){
		for(int j = 0; j < m; ++j){
			d[a[j].u] = min(d[a[j].u], d[a[j].v] + a[j].w);
		}
	}
	return d[des];
}

Bellman-Ford的复杂度为 O ( n ∗ m ) O(n*m) O(nm).

SPFA优化

Bellman-Ford算法逐个遍历边的算法显然有点“暴力”,因此诞生了一种叫SPFA(Shortest Path Faster Algorithm,更快最短路)的算法,作为对Bellman-Ford算法的优化.

SPFA采用队列来进行优化,步骤如下:

  1. 开一个队列,将起点存入.
  2. 取出队头节点u,对所有与其相邻的节点进行松弛操作(即更新)
  3. 如果某个与u相邻的节点v被成功更新了,且v不在队列中,将v入队.
  4. 当队列空时,结束。

代码如下:

const int maxn = 1005;
// 使用邻接表存边
struct edge{
	int to, w;
};
cosnt int INF = 0x3f3f3f3f;
int d[maxn];
int n, m; // 假设有n个顶点,m条边
bool v[maxn] = {0}; // 标记该点是否在队列中
vector<edge> a[maxn];
void spfa(int start, int des)
{
	queue<int> q;
	for(int i = 0; i < n; ++i) d[i] = INF;
	d[start] = 0;
	v[start] = 1;
	while(!q.empty())
	{
		int now = q.front();
		q.pop();
		v[now] = 0;
		for(int j = 0; j < a[now].size(); ++j){
			if(d[a[now][j].to] > d[now] + a[now][j].w){
				d[a[now][j].to = d[now] + a[now][j].w;
				if(!v[a[now][j].to]){
					v[a[now[j].to] = 1;
					q.push(a[now][j].to);
				}
			}
		}
	}
}

SPFA的最优复杂度是 O ( n ) O(n) O(n),即每条边只访问了一次。这种情况最简单之一就是整个图是一条单链表。
由于每次寻找的是当前最有可能需要被更新的点,该贪心策略存在很大的随机性,很容易被数据卡掉,退化成 O ( n ∗ m ) O(n*m) O(nm),在ACM竞赛中,最坏情况数据几乎必定存在,这样的复杂度当然是难以接受的。(完全图中复杂度为 O ( n 3 ) O(n^3) O(n3)

所以,Bellman-Ford/SPFA真的没有用吗?

该算法有它自己的方便之处,那就是可以处理带负权的图,而且能检测出图中的负环。

如何检测负环?

对Bellman-Ford中的步骤,如果我们不在外层嵌套 n − 1 n-1 n1层的for循环,而是规定,当对边的一轮遍历中没有出现有效更新时结束,这样就能判断出负环。

因为最短路的长度最长为n-1,所以我们只要检查对边遍历的次数,如果遍历边的轮数超过了n-1,那么就说明我们找的路径中已经存在回路了,而回路还能发生有效更新,那必定是有负环了。

至于带负权的图,Dijkstra是可能得不到正确解的,因此SPFA或许还有它的用武之地?

然而我们可以魔改处理一下Dijkstra,来让他变得可以处理负权图。后文会介绍到,Dijkstra的复杂度是要优于SPFA的。

所以,有这样一句OI界传颂已久的话:SPFA已死

Floyd

与Bellman算法一样,Floyd也将使用动态规划思想解决问题。不同的是,它解决的并不是单源最短路的问题,而是多源最短路问题。也就是说,Floyd算法将可以算出任意两点间的最短路,复杂度稳定在 O ( n 3 ) O(n^3) O(n3)

Floyd的策略是,定义 d p [ i ] [ j ] dp[i][j] dp[i][j]为从 i 到 j 的最短路。枚举“中间点”,对每对点对 ( i , j ) (i,j) (i,j)以中间点为中继更新路程,状态转移方程为:
d p [ i ] [ j ] = m i n ( d p [ i ] [ j ] , d p [ i ] [ k ] + d p [ k ] [ j ] ) dp[i][j] = min(dp[i][j], dp[i][k] + dp[k][j]) dp[i][j]=min(dp[i][j],dp[i][k]+dp[k][j])

Floyd算法的思想非常简单,代码实现也十分简洁。

代码:

const int maxn = 1005;
//使用邻接矩阵存边(直接存入dp数组)
cosnt int INF = 0x3f3f3f3f;
int dp[maxn][maxn];
void Floyd()
{
	for(int k = 0; k < n; ++k)
		for(int i = 0; i < n; ++i)
			for(int j = 0; j < n; ++j)
				dp[i][j] = min(dp[i][k] + dp[k][j], dp[i][j]);
}

Dijkstra

从多源最短路回到单源最短路,还有另一种基于贪心而不是动态规划的Dijkstra算法。该算法在竞赛中应用最为广泛,可以吊打SPFA,较为重要。

普通Dijkstra

不妨先通过Dijkstra最初的模样来了解它。

Dijkstra的步骤是:

  1. 初始化。这个初始化操作和Bellman-Ford一样。
  2. 标记起点已访问过,然后更新所有与起点相邻的点的距离。
  3. 从所有点中找到当前dis[i]最小的点,然后将其标记为已访问。
  4. 更新所有与起点相邻的点的距离。
  5. 重复步骤3,4,直到所有点都被访问。

该贪心算法可以用下面的思考粗略证明:

首先更新出起点到某几个点i,j,k……的距离。

如果我们要更新某个与点i相邻的点g距离,且点i是i,j,k……这些点中距离起点最近的点。那么这个最短路必定是起点-> i -> g,因为如果我们选择任何其他路线作为g和起点的中间路线,其路线都一定比经过i点长。这是由i当前距起点最近决定的。

下面贴Dijkstra无优化的代码:

const int maxn = 1005;
// 使用邻接表存边
struct edge{
	int to, w;
};
cosnt int INF = 0x3f3f3f3f;
int d[maxn];
int n; // 假设有n个顶点
bool v[maxn] = {0}; // 标记该点是否访问过
vector<edge> a[maxn];
void Dijkstra(int start, int des)
{
	for(int i = 0; i < n; ++i) d[i] = INF;
	d[start] = 0;
	v[start] = 1;
	int cnt = 1;
	while(cnt < n)
	{
		int min_d = INF, pos = -1;
		for(int i = 0; i < n; ++i)
		{
			if(!v[i] && d[i] < min_d){
				min_d = d[i];
				pos = i;
			}
		}
		if(pos == -1) break; // 如果pos = -1而且所有点未全被访问,说明这个图没有连通,找不到单源最短路
		v[pos] = 1;
		cnt++;
		for(int i = 0; i < a[pos].size(); ++i)
		{
			int u = a[pos][i].to;
			if(d[u] > min_d + a[pos][i].w){
				d[u] = min_d + a[pos][i].w;
			}
		}
	}
}

显然,该算法的复杂度为 O ( n 2 ) O(n^2) O(n2).

堆优化

你是否注意到,寻找当前dis值最小的点,这个操作其实是可以“有序”的,我们大可以事先把最小的点处理出来。通过这种思想,堆优化的Dijkstra就诞生了。

我们把当前所有待处理的点按照距起点的距离排序,形成一个优先队列,这样就免去了每次寻找最小值的步骤。

代码:(记得定义排序方法)

const int maxn = 1005;
// 使用邻接表存边
struct edge{
	int to, w;
	bool operator < (const edge& x)const{
		return w > x.w;
	}
};
cosnt int INF = 0x3f3f3f3f;
int d[maxn];
int n; // 假设有n个顶点
bool v[maxn] = {0}; // 标记该点是否访问过
vector<edge> a[maxn];
void Dijkstra(int start, int des)
{
	for(int i = 0; i < n; ++i) d[i] = INF;
	d[start] = 0;
	priority_queue<edge> q;
	while(!q.empty())
	{
		int now = q.top().to;
		q.pop();
		if(v[now]) continue;
		v[now] = 1;
		for(int i = 0; i < a[now].size(); ++i){
			int u = a[now][i].to;
			int w = a[now][i].w;
			if(!v[u] && d[u] > d[now] + w){
				q.push(u);
			}
		}
	}
}

该优化算法中,我们需要处理 n n n个节点,同时也维护一个优先队列,故算法复杂度为 O ( m l o g n ) O(mlogn) O(mlogn).

可以看出,由于需要多次进行优先队列的调整,当图足够稠密时,该算法的复杂度也将退化到 O ( n 2 l o g n ) O(n^2logn) O(n2logn).

故我们的策略将是:稠密图使用普通版,稀疏图使用堆优化.

另外,该算法在稀疏图中求全源最短路也可以使用,对n个点各使用一次Dijkstra即可代替Floyd,效果拔群。

Johnson算法-负权图预处理Dijkstra(全源最短路)

前面说到,Dijkstra并不能处理负权图,这是它贪心的策略导致的,在图中含有负权边时,使用Dijkstra很可能求出错误答案,还是得用SPFA.

(反例就不给了吧,主要是懒得画图)

那么如何处理图/算法,来让我们的Dijkstra可以解决负权图呢?
(SPFA太太太太太太太太慢了)

这里我们只讲全源最短路的情况。

不妨新建一个虚拟节点,从这个点向其他所有点连一条边权为0的边。然后用SPFA跑一遍以该点为起点的单源最短路,存入一个数组(记为b),然后让每条边的权值从 w w w变为 w + h u − h v w+h_u-h_v w+huhv.其中, u u u, v v v分别为这条边的起点和终点。

这样处理后,我们再跑n遍Dijkstra就可以了。
(当然,稠密图我们也可以直接Floyd,不用花里胡哨)

为什么这样之后边权一定会变为正呢?

根据三角不等式,两边之和大于等于第三边,我们得到
h v ≤ w u , v + h u h_v≤w_{u,v}+h_u hvwu,v+hu,则新的边权 w + h u − h v ≥ 0 w+h_u-h_v≥0 w+huhv0,得证.

(证明参考自洛谷神犇StudyingFatherP5905的题解

诶,你问单源带负权怎么办?老老实实用SPFA吧(所以其实还没死,偶尔还能用

  • 1
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 3
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值