图论基础+最短路问题(Floyd、Dijkstra、SPFA、Bellman-Ford)

前言

图论,说白了就是在一张图上进行各种操作,包括简单的如求最短路,难的如强连通分量。本文将带你走入图论,了解图论的基础知识与图上最短路问题。

图论基础

图论其实没什么太多底层的知识,如果真有那就只能是图的存储了。

每一道题我们都需要找到一个合适的存图方式,今天我就在这里讲一讲图的三种基本存储方式。

1. 邻接矩阵

邻接矩阵是最直观且最浪费空间的一种存储方式,它大致就是定义一个数组 g[N][N],其中 g[i][j] 的值表示 i i i j j j 有没有路。所以说这个 g[N][N] 其实是一个布尔类型的数组。(你想定义成 int 也行。)

但是光是 g 一个数组就占了 O ( N 2 ) O(N^2) O(N2) 的空间复杂度,实在太高,一般不建议使用这个。

2. 链式前向星

聪明的计算机学家们就又想出了一个办法:按照链表的方式存图。

因为我们不需要把所有的任意两个点之间的关系都描述一遍,只需要看看每个点与那些点连在一起就行。于是,我们只需要定义一个结构体数组 g[N],存储这个点里面有哪些点与之相连,因为是一个链表,所以还要多存储一个下一个位置在几号。

链式前向星的原代码是用指针完成的,但是由于指针过分难写,现在一般都改成这种样子:

struct edge{
	int ed,nx;
}g[N];
int cnt,head[N];
void add(int st,int ed)//连边
{
	g[++cnt].ed=ed;
	g[cnt].nx=head[st];
	head[st]=cnt;
}

但是这种写法过分难以理解,对于许多初学者来说很不友好。

3. 邻接表

在 STL 被发明以后,计算机学家们看中了一个 STL:vector。这个 STL 具有自动延长空间的性质,在你不需要时不会多借一点内存,很省空间,于是计算机学家们就发明了第三种存图方式:邻接表。

大致原理是这样的:定义一个 vector<int>g[N];,其中 g[i] 内的元素都是与 i 相连边的点,这种写法清晰、直观,是很多初学者常用的存图方式。

具体代码如下:

vector<int>g[N];
int main()
{
	cin>>n;//n 条边
	for(int i=1,x,y;i<=n;i++){
		cin>>x>>y;
		g[x].emplace_back(y);//x 与 y 相连
		g[y].emplace_back(x);//y 与 x 相连
	}
}

注:这里我建的是双向边,如果要建单向边,只需要其中一个就行了。

最短路

存图方法介绍完了,该讲讲最短路了。

1. Floyd

Floyd 算法是所有最短路中最暴力、时间复杂度最高的算法,它主要是类似于 DP 的思想:对于从 i i i j j j 的问题,有两种情况:

  1. i → j i\to j ij
  2. i → k → j i\to k\to j ikj

如下图:

其中红色路径是一种,黑色路径是一种,然后看看哪种最短就行了。

代码也是非常的粗暴:

for(int i=1;i<=n;i++)
{
	for(int j=1;j<=n;j++)
	{
		for(int k=1;k<=n;k++)
		{
			if(i!=j&&j!=k)//大前提(很好奇怎么从自己走向自己)
			{
				dis[i][j]=min(dis[i][j],dis[i][k]+dis[j][k]);
			}
		}
	}
}

其中,dis[i][j] 表示从 i i i j j j 的最短距离(英文单词:distance,中文意思:距离),这种算法很好理解,但时间复杂度也很高,一眼就能看出是 O ( n 3 ) O(n^3) O(n3)

2. Bellman-Ford

为了能够解决数据更大的最短路问题,聪明的计算机学家们又发明了 Bellman-Ford(贝尔曼-福特)算法。(为了不累死作者,后面一律把 Bellman-Ford 简写为 BF。)

BF 算法其实有个很好玩的现实对应:问路。

假设一个路人在 i i i 号点,他问 i i i 号点的人: j j j 号点怎么走最短。 i i i 不知道,但他会问他相邻的点上的人,直到问到了 j j j 号点的人, j j j 号点的人又再把信息传回去。

所以 BF 算法的步骤就如下:

  1. 找到一条边的起点 s t st st 与终点 e d ed ed
  2. 看看通过 s t st st j j j 的距离是否比当前从 e d ed ed j j j 的距离短,如果是,更新距离。
  3. 重复执行上述操作 n n n 次。

这里稍微用到了一下抽屉原理,因为抽屉原理告诉我们:按照前两步操作执行 n n n 次就一定能找到最短路。

算一下时间复杂度:一共进行 n n n 轮,每一轮遍历了 m m m 条边,时间复杂度: O ( n m ) O(nm) O(nm)

代码如下:

for(int i=1;i<=n;i++){
	for(int j=1;j<=m;j++){
		if(dis[g[j].st]>dis[g[j].ed]+g[j].s) dis[g[j].st]=dis[g[j].ed]+g[j].s;
	}
}

(别小看这五行代码,它可是起到了大作用的。)

所以从上述定义来看,BF 算法好像只能解决单源最短路问题(即探究从一个固定的点到另一个其他点的最短路),而 Floyd 则可以解决任意两点之间的最短路问题。

但是,计算机学家们又发现了个新问题:有时候在跑完最短路之后发现居然能跑出比最短路还短的路。这就是负环独有的特性。对于负环的处理,计算机学家们决定采用再跑一次的策略,如果还能找到最短路,那就肯定是负环了。

3. SPFA

可贪心的计算机学家们还是认为不够啊,他们想把时间复杂度压到更低。终于,在 1994 年,来自我国西南交大的段凡丁老师提出了一种全新的算法:SPFA(Shortest Path Faster Algorithm)。在他的证明中,SPFA 可以把时间复杂度降到 O ( n ) O(n) O(n),即把每个点遍历一遍就行。但是因为他的证明有误,所以 SPFA 实际上在最坏情况下时间复杂度是和 BF 一样的。

但是 SPFA 的失败却带来了意外的成功,因为它在最好情况下可以达到 O ( n ) O(n) O(n) 的时间复杂度,而且它可以判负环,所以直到现在很多信竞生仍在使用它。

SPFA 的原理其实就是用队列优化了一下 BF,具体操作如下:

  1. 把起点入队。
  2. 让队列中的第一个点出队,更新与之相邻的点。
  3. 如果某个点能被更新且不再队列里,把这个点推入队列。
  4. 重复执行上述操作,直到队列为空。

SPFA 算法很巧妙地把一些没用的计算给扔掉,而保留了重要的部分,这也是为什么它能如此——不稳定了。

代码如下:

vector<pair<int,int>>g[N];//pair<当前点到达的终点,当前两点间的距离>
void SPFA(int st)
{
	memset(dis,0x3f,sizeof(dis));//最短路 
	memset(inq,0,sizeof(inq));//是否在队列里(in queue) 
	memset(num,0,sizeof(num));//每个点被遍历的次数
	//如果某个点被遍历了超过 n 次,那就有负环,这一点与 BF 是一致的 
	inq[st]=1;
	dis[st]=0;
	queue<int>q;
	q.push(st);
	while(!q.empty()){
		int x=q.front();
		q.pop();
		inq[x]=0;
		if(num[x]>n){//有负环
			cout<<"-1";
			return;
		}
		for(auto i:g[x]){
			if(dis[i.first]>dis[x]+i.second){
				dis[i.first]=dis[x]+i.second;
				if(!inq[i.first]){
					q.push(i.first);
					inq[i.first]=1;
					num[i.first]++;
				}
			}
		}
	}
}

SPFA 已死?

SPFA 的不稳定性,一直是许多 OIer 的心头之痛,每一次写 SPFA 就像是在赌运气一样,这使得 SPFA 成了“能不用,就不用”的一种“小算法”。可,SPFA 的失败不也带来了意外之喜吗?我们有了除 BF 之外的其他时间复杂度更低、能判负环的算法,所以还是请大家好好珍惜 SPFA 吧。

4. Dijkstra

在 STL 被发明过后,计算机学家们便找到了一种更高效、简单的算法:Dijkstra!(为了不被累死,后面都使用 Dij 来代指 Dijkstra。)

Dij 的本质就是一个加了贪心的 BFS,大致过程如下:

  1. 把起点推入优先队列里。
  2. 取出优先队列里的第一个点,如果这个点已经被遍历过,那么就直接跳过,否则更新它周围的点。
  3. 如果某个点能被更新且没有被遍历过,把当前点推入优先队列。
  4. 重复执行上述操作直到优先队列为空。

这个原因也很简单:我们通过优先队列排序会找到当前离起点最近的点,那么这个点就已经找到它的最短路了,如果还有其他点想要来更新它,那它使一定不会变的,所以可以直接跳过。

代码如下:

vector<pair<int,int>>g[N];
void dijkstra(int st)
{
	priority_queue<pair<int,int>,vector<pair<int,int>>,greater<pair<int,int>>>pq;//小根堆,按照 pair 的第二个元素排序 
	memset(dis,0x3f,sizeof(dis));
	memset(vis,0,sizeof(vis));
	dis[st]=0;
	pq.push(make_pair(st,dis[st]));
	while(!pq.empty()){
		pair<int,int>p=pq.top();
		pq.pop();
		int u=p.first;
		if(vis[u]){//已经求到最小值了 
			continue;
		}
		vis[u]=1;
		for(auto i:g[u]){
			if(vis[i.first]){//邻居已经找到最小值了 
				continue;
			}
			if(dis[i.first]>dis[u]+i.second){
				dis[i.first]=dis[u]+i.second;
				pq.push(make_pair(i.first,dis[i.first]));//优先队列里面会自动排好序,计算 dis 最小的 
			}
		}
	}
}

这种算法的时间复杂度只有 O ( m log ⁡ 2 ( n ) ) O(m\log_2(n)) O(mlog2(n)),非常的优秀。

可惜,这种优秀的算法有个极大地缺点:不能跑有负边权的图。因为 BFS 是一个逐渐向外扩散的过程,有可能你给你邻居算的最短路并不是最短路,可能在经过一些负边权后得到了一条更短的路,但由于 BFS 的局限性,这种算法一开始就会认定第一次的最短路就是最短的,而不会更新真正的最短值。

如果还不懂,可以输入下面样例:

输入格式: 第一行两个数 n n n m m m,表示有 n n n 个点、 m m m 条边。第 2 2 2 m + 1 m+1 m+1 行,每行三个数 x , y , s x,y,s x,y,s,表示起点、终点、两点间的边长。注意,这是个双向图

输出格式: 输出仅一行,表示从 1 1 1 n n n 的最短距离。

3 3
1 3 10
1 2 20
2 3 -20

不会敲代码的小白可以看这张图按照上述流程模拟一下:

结语

再伟大的算法,都有自己的缺陷;再完美的人,都有自己的弱点。有时候,表面上的失败可能会有意外的成功,拼运气的随机也有着底层的逻辑。或许,几千年后 OIer 在回看这 OI 界时,会不会有些许感慨,又会不会有些许享受?有时候,完美不一定是无瑕,失败不一定是不成功,随机不一定是赌运气。所以还是请大家享受随机、享受成功的失败吧。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值