文章目录
- 单源最短路
- 朴素Dijkstra
- 堆优化版Dijkstra
- Bellman Ford算法
- SPFA
- SPFA求负环
- 多源汇最短路
- Floyd
- 最短路练习题
- 849. Dijkstra求最短路 I
- 850. Dijkstra求最短路 II
- 853. 有边数限制的最短路
- 851. spfa求最短路
- 852. spfa判断负环
- 854. Floyd求最短路
源点表示起点,汇点表示终点
一些认识:
m和 n 2 n^2 n2一个级别是稠密图,m和n一个级别是稀疏图
最短路问题不区分有向图与无向图,因为无向图是一种特殊的有向图,能解决有向图的最短路问题,就能解决无向图的最短路问题
单源最短路
起点确定,终点是除起点外的其他点
默认n表示点数,m表示边数
- 所有边权为正数
- 朴素Dijkstra O( n 2 n^2 n2),和边数无关,用于稠密图
- 堆优化版Dijkstra O(mlogn) m = n 2 n^2 n2,用于稀疏图
- 边权存在负数
- Bellman-Ford O(nm)
- SPFA O(m),最坏O(nm),是Bellman-Ford的优化
朴素Dijkstra
基于贪心思想,每次选择距离源点最近的点
维护一个s集合,存储已经确定最短路径的点
一开始该集合中只有源点,不断地将点加入到s集合中,直到图中所有点都属于s集合,最短路求解结束
如何将点加入s集合?
- 遍历图中不属于s的点,选择其中与源点距离最小的点加入s
- 对于此时不属于s的点,用新加入s的点尝试更新其他点的最短距离
重复以上两个步骤,直到图中所有点都属于s
如何用代码实现?
dis数组
存储每个点与源点的最短距离,初始化:源点到源点的距离为0
g数组为邻接矩阵,存储图中每条边的权值(若是稀疏图则使用邻接表)
vt数组用来维护s集合,存储该点是否在s中的bool值
- 初始化
dis[源点的编号] = 0, dis[其他点] = +∞
- 遍历
dis数组
中不属于s的点,选择其中与源点距离最短的点加入s - 根据新加入s的点,更新
dis数组
中其他不属于s的点,假设新加入点的编号为x
,`dis[y] = min(dis[y], dis[x] + g[x][y]) - 重复2,3两个步骤,直到所有点属于s
第三步中,对于已经确定最短路径的点(属于s中的点) ,需要根据该点更新其他不属于s中的点。此时进行操作dis[y] = min(dis[y], dis[x] + g[x][y])
,其实不需要特别要求该点不属于s,因为属于s的点已经确定了最短距离,不论是否进行min操作都不影响已经确定的最短距离。所以在min操作之前判断该点是否属于s的操作是无关紧要的
模板:
bool vt[N];
int dis[N], g[x][y];
void dijkstra()
{
memset(dis, 0x3f, sizeof(dis));
dis[1] = 0;
for (int i = 0; i < n; ++ i )
{
int x = -1;
for (int j = 1; j <= n; ++ j)
if (!vt[j] && (x == -1 || dis[j] < dis[x]))
x = j;
vt[x] = true; // 从不属于s的点中选择距离源点最近的点
for (int y = 1; y <= n; ++ y) // 用新加入的点更新其他点
dis[y] = min(dis[y], dis[x] + g[x][y]);
}
}
外循环迭代n次,每次选择与源点距离最近的点放入集合中,集合中的点为已经确定最短距离的点。迭代n次后,图中所有点都进入了s集合,即确定了所有点的最短距离
每一次迭代要做的事:
- 在不属于s中的点中,找到与源点距离最近的点
- 用该点更新其他(不属于s的)点
堆优化版Dijkstra
用堆优化朴素Dijkstra算法,时间复杂度可以达到O(mlogn)
若手写一个支持修改任意位置的堆,空间复杂度为O(n)
若使用优先队列,空间复杂度将达到O(m),存储稠密图比较浪费空间
朴素Dijkstra算法中,在不属于s的点中,找距离源点最近的点,时间复杂度为O(n)
外循环需要迭代n次,所以总得时间复杂度为O(
n
2
n^2
n2),这是朴素Dijkstra算法的效率瓶颈
用堆结构对朴素算法进行优化,我们能以O(1) 的时间取出距离源点最近的点,不过代价就是提高了空间复杂度,从O(n) 提高到了O(m)。以及每次维护堆时,时间复杂度为O(logn)
同时,若使用邻接矩阵存储边,用新加入的点更新其他点时,时间复杂度为O(n),总的时间复杂度为O( n 2 n^2 n2)。也是一个瓶颈,不过很好解决,用邻接表存储边,总的时间复杂度为O(m)
而使用邻接表存储,不用考虑重边问题。邻接矩阵只能存储两点间的一条边,所以要选择重边中最小的边
代码如何实现?
用优先队列存储pair
,first
为点到源点的距离,second
为点的编号
由于优先队列无法修改元素,所以图中的每条边都会进入一次优先队列,此时存在冗余且无效的数据
具体情况是:队列中存在second
相同的pair
,此时first
较大的pair
为无效数据。但我们无法删除任意位置的pair
,只能删除堆顶的pair
。所以取出堆顶元素时需要判断该pair
的second
是否已经在s中(最短距离是否已经确定)
若已经在s中,说明不需要进行接下来的操作(加入s和用该点更新其他点),所以此时直接continue
模板:
typedef pair<int, int> PII;
int h[N], e[N], ne[N], w[N], idx = 1;
priority_queue<PII, vector<PII>, greater<PII>> q;
int dis[N];
bool vt[N];
void dijkstra()
{
memset(dis, 0x3f, sizeof(dis));
q.push({ 0, 1 }), dis[1] = 0;
while (q.size())
{
auto t = q.top();
q.pop();
int x = t.second, d = t.first;
if (vt[x]) continue;
vt[x] = true;
for (int i = h[x]; i != -1; i = ne[i])
{
int y = e[i];
if (!vt[y] && d + w[i] < dis[y])
{
dis[y] = d + w[i];
q.push({ dis[y], y });
}
}
}
}
Bellman Ford算法
暴力美学
用于处理负权边
存储边的结构没有要求,可以用简单的结构体存储
struct Edge
{
int x, y, w;
}edges[M];
循环n次,每次都遍历所有边
遍历某条边时,对其进行松弛操作:dis[y] = min(dis[y], dis[x] + w[i])
,其中x是当前边的起点,y是当前边的终点,w[i]
是当前边的权重
每次的松弛操作其实是在尝试更新dis[y]
,而更新依赖于dis[x]
。若dis[x]
不是无穷大,而是被其他松弛操作更新了,那么dis[y]
也可以被当前松弛操作更新
外循环与dis数组
的关系:若循环进行了k次,dis数组
存储从源点开始走,不超过k条边,递达其他点的最短距离
若存在负环,则最短路不一定存在(负环与源点和目标点不连通时则最短路存在)
用Bellman-Ford判断负环:
第n次外循环时,若更新了dis数组
,说明某条最短路中有n条边,即n+1个点,根据抽屉原理,该图存在负环
但Bellman-Ford的时间复杂度较高,一般用SPFA解决负环问题
三角不等式:dis[y] <= dis[x] + w
串联问题:每次对所有边进行松弛操作时,应该基于上一次对所有边进行松弛操作后的状态。什么意思呢?对所有边进行了一次松弛操作后,我们要备份此时的dis数组
,作为上一次的状态保存
若不备份dis数组
,而直接进行松弛操作。修改dis数组
的某些数据后,此时的dis数组
不再是上一次对所有边进行松弛操作后的状态,基于这个状态进行松弛操作得到的结果多半是错误的
串联问题与dp状态压缩导致的问题类似
模板:
struct Edge
{
int x, y, w;
}edges[M];
int dis[N], bup[N];
void bellman_ford()
{
memset(dis, 0x3f, sizeof(dis));
dis[1] = 0;
for (int i = 0; i < k; ++ i )
{
memcpy(bup, dis, sizeof(dis));
for (int j = 0; j < m; ++ j )
{
auto t = edges[j];
dis[t.y] = min(dis[t.y], bup[t.x] + t.w);
}
}
}
SPFA
SPFA是对Bellman-Ford算法的一个优化
注意松弛操作:dis[y] = min(dis[y], dis[x] + w[i]) 什么时候这个操作才是有效的?只有当
dis[x]变小,
dis[y]才可能变小 当
dis[x]不变时,
dis[y]`一定不会变换,所以Bellman-Ford中,大部分的更新多余且无效
如何使得每次的更新都是有效的呢?
运用广搜的思想,将每次更新(有效松弛)的点进入队列
初始时,将源点入队,表示源点的最短路被更新
每次取出队头的点,对连接该点的所有边进行松弛操作。通过邻接表将与该点邻接的点入队,因为这些点的dis距离被更新了
直到队列为空,此时整张图更新完成
注意,为防止一个点的重复地进入队列,要维护每个点的状态:某个点入队之前,先判断该点是否已经在队列中
模板:
// N为点数,M为边数
int dis[N];
int h[N], e[M], ne[M], w[M], idx = 1;
int q[N], hh, tt = -1;
bool st[N];
void add(int x, int y, int d)
{
e[idx] = y, ne[idx] = h[x], w[idx] = d, h[x] = idx ++ ;
}
void spfa()
{
memset(dis, 0x3f, sizeof(dis));
q[++ tt] = 1, st[1] = true, dis[1] = 0; // 假设1号点为源点
while (tt >= hh)
{
int x = q[hh ++ ];
st[x] = false;
for (int i = h[x]; i != -1; i = ne[i] )
{
int y = e[i];
if (dis[x] + w[i] < dis[y])
{
dis[y] = dis[x] + w[i];
if (!st[y]) st[y] = true, q[++ tt ] = y;
}
}
}
}
出题者可能故意卡SPFA的时间,使时间复杂度达到最坏的情况O(nm)
此时只能使用其他算法
SPFA求负环
和Bellman-Ford一样,SPFA也是运用抽屉原理判断图中是否有负环
在SPFA求负环的算法中,虽然求解过程与原SPFA差不多,但是有些思想是完全不一样的
比如dis数组
不再表示其他点到源点的距离,可以认为该数组没有任何含义
初始化时,将dis数组
的每个值置为0,甚至任意数都是可以的,只要保证每个位置的值一样
由于现在要判断图中是否存在负环,若从一个点出发找负环,在非连通图中可能无法实现,所以不能用单源点判断负环。所以初始化时,将所有的点入队,表示每个点都是源点,此时就算是非连通图也能找到负环
dis[x] + w[i] < dis[y]
:什么时候会执行该操作?当dis
所有值都相同时,只有w[i]
为负数(存在负权边)时,该操作才会执行。此时维护cnt数组,cnt[y] = cnt[x] + 1
表示遍历了某条存在负权边的路径中的一条边,当cnt数组
中的某个值大于等于n,即cnt[i] >= n
时,根据抽屉原理,该图中存在负环
所以,求解负环时,SPFA不再是单源求解过程,而是多源求解过程。求解的问题也不再是最短路,而是负环的判断
此时,dis数组
的含义发生变化,同时引入cnt数组
,存储遍历某条存在负权边的路径的边的次数
非连通图中的负环,边数不可能大于等于n吧。那么根据cnt[i] >= n
判断图中是否存在负环有问题吗?cnt数组存储在某条存在负权边的路径中遍历的次数,当一个负环的边数小于n,那么我们将一直遍历这个负环,直到cnt[i] >= n
停止
最坏的情况时,所有的点共同构成了负环,负环中点的数量与边的数量都等于n,此时我们将遍历图中所有点一次,接着cnt[i] == n
,遍历结束,说明图中存在负环
模板:
int h[N], e[M], ne[M], w[M], idx = 1;
int q[N], hh, tt = -1;
int dis[N], cnt[n];
bool st[N];
void add(int x, int y, int d)
{
e[idx] = y, ne[idx] == h[x], w[idx] = d, h[x] = idx ++ ;
}
bool spfa()
{
for (int i = 1; i <= n; ++ i )
q[++ tt] = i, st[i] = true;
while (tt >= hh)
{
int x = q[hh ++];
st[x] = false;
for (int i = h[x]; i != -1; i = ne[i])
{
int y = e[i];
if (dis[x] + w[i] < dis[y])
{
dis[y] = dis[x] + w[i];
cnt[y] = cnt[x] + 1;
if (cnt[y] >= n) return true;
if (!st[y]) st[y] = true, q[++ tt] = y;
}
}
}
return false;
}
多源汇最短路
Floyd
任意起点与终点的最短路问题
Floyd的时间复杂度为: O(
n
3
n^3
n3)
d[k][i][j]
:从i号点出发,只经过1~k号这些中间点,到达j号点的最短距离
状态更新:d[k][i][j] = d[k - 1][i][k] + d[k - 1][k][j]
,观察表达式,发现第一个维度可以压缩,即d[i][j] = d[i][k] + d[k][j]
过程很简单,三重循环进行动态规划
模板:
void Floyd()
{
for (int k = 1; k <= n; ++ k )
for (int i = 1; i <= n; ++ i )
for (int j = 1; j <= n; ++ j )
d[i][j] = min(d[i][j], d[i][k] + d[k][j]);
}
注意d数组要初始化:初始化的值与邻接矩阵一样,由于要求解的问题是多源汇最短路,所以要初始化源点到源点的距离为0
最短路练习题
849. Dijkstra求最短路 I
849. Dijkstra求最短路 I - AcWing题库
对图中节点进行编号,题目要求1号点到n号点的最短距离
以1号点作为源点,用朴素Dijkstra更新dis数组,获取单源最短路
#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
const int N = 510, M = 1e5 + 10;
int dis[N], g[N][N];
bool vt[N];
int n, m;
void dijkstra()
{
memset(dis, 0x3f, sizeof(dis));
dis[1] = 0;
for (int i = 0; i < n; ++ i )
{
int x = -1;
for (int j = 1; j <= n; ++ j)
if (!vt[j] && (x == -1 || dis[j] < dis[x]))
x = j;
vt[x] = true; // 从不属于s的点中选择距离源点最近的点
for (int y = 1; y <= n; ++ y) // 用新加入的点更新其他点
dis[y] = min(dis[y], dis[x] + g[x][y]);
}
}
int main()
{
memset(g, 0x3f, sizeof(g));
scanf("%d%d", &n, &m);
int x, y, d;
while (m -- )
{
scanf("%d%d%d", &x, &y, &d);
g[x][y] = min(g[x][y], d);
}
dijkstra();
if (dis[n] == 0x3f3f3f3f) printf("-1\n");
else printf("%d\n", dis[n]);
return 0;
}
850. Dijkstra求最短路 II
850. Dijkstra求最短路 II - AcWing题库
题目给定的图为稀疏图,使用邻接表存储
和第一题一样,要求1号点到n号点的最短距离,以1号点为源点,用Dijkstra求得单源最短路
#include <iostream>
#include <cstring>
#include <algorithm>
#include <queue>
using namespace std;
typedef pair<int, int> PII;
priority_queue<PII, vector<PII>, greater<PII>> q;
const int N = 2e5 + 10;
int h[N], e[N], ne[N], w[N], idx = 1;
int dis[N];
bool vt[N];
int n, m;
void add(int x, int y, int d)
{
e[idx] = y, ne[idx] = h[x], w[idx] = d, h[x] = idx ++ ;
}
void dijkstra()
{
memset(dis, 0x3f, sizeof(dis));
q.push({ 0, 1 }), dis[1] = 0;
while (q.size())
{
auto t = q.top();
q.pop();
int x = t.second, d = t.first;
if (vt[x]) continue;
vt[x] = true;
for (int i = h[x]; i != -1; i = ne[i])
{
int y = e[i];
if (!vt[y] && d + w[i] < dis[y])
{
dis[y] = d + w[i];
q.push({ dis[y], y });
}
}
}
}
int main()
{
memset(h, -1, sizeof(h));
scanf("%d%d", &n, &m);
int x, y, d;
while (m -- )
{
scanf("%d%d%d", &x, &y, &d);
add(x, y, d);
}
dijkstra();
if (dis[n] == 0x3f3f3f3f) printf("-1\n");
else printf("%d\n", dis[n]);
return 0;
}
debug:优先队列需要建小堆,默认是建立大堆,这个真的没有注意到
queue<PII, vector<PII>, greater<PII>>
:传greater建立小堆
853. 有边数限制的最短路
853. 有边数限制的最短路 - AcWing题库
求解具有负权边的最短路问题一般使用SPFA,因为其时间复杂度较低。但有些最短路问题只能用Bellman-Ford求解,因为这类题有边数的限制
限制的边数就是外循环的次数,内循环还是要对所有边进行松弛操作
由于图中可能存在负权边,当源点无法递达某一个点时,该点的dis距离可能不是最大值,而是小于最大值的较大值
假设源点无法到达目标点y点,但x点能到达y点,此时源点也无法递达x点,即dis[x] = +∞
。而连接x与y的边权为负数,那么目标点y的dis距离将被更新:dis[y] = dis[x] + w
,其中的dis[x]
为正无穷,w为负数,那么dis[y]
将被更新成一个小于正无穷的较大数
所以判断源点是否能递达其他点时,不能判断dis[i] == 0x3f3f3f3f
,而应该判断dis[i] > 0x3f3f3f3f / 2
以下代码存在问题,无法AC
#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
const int N = 510, M = 10010;
int dis[N], bup[N];
int n, m, k;
struct Edge
{
int x, y ,w;
}edges[M];
int Bellman_Ford()
{
memset(dis, 0x3f, sizeof(dis));
dis[1] = 0;
for (int i = 0; i < k; ++ i )
{
memcpy(bup, dis, sizeof(dis));
for (int j = 0; j < m; ++ j )
{
auto e = edges[j];
dis[e.y] = min(dis[e.y], bup[e.x] + e.w);
}
}
if (dis[n] > 0x3f3f3f3f / 2) return -1;
return dis[n];
}
int main()
{
scanf("%d%d%d", &n, &m, &k);
int x, y, w;
for (int i = 0; i < m; ++ i )
{
scanf("%d%d%d", &x, &y, &w);
edges[i] = { x, y, w };
}
int t = Bellman_Ford();
if (t == -1) puts("impossible");
else printf("%d\n", t);
return 0;
}
debug:某些最短路可能是负值,当最短路为-1时,Bellman_Ford()返回的t也是-1,此时-1不能作为区分是否存在最短路的值
所以应该直接在main函数中判断dis[n]
与0x3f3f3f3f
的关系
以下是ac代码
#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
const int N = 510, M = 10010;
struct Edge
{
int x, y, w;
}edges[M];
int dis[N], bup[N];
int n, m, k;
void bellman_ford()
{
memset(dis, 0x3f, sizeof(dis));
dis[1] = 0;
for (int i = 0; i < k; ++ i )
{
memcpy(bup, dis, sizeof(dis));
for (int j = 0; j < m; ++ j )
{
auto t = edges[j];
dis[t.y] = min(dis[t.y], bup[t.x] + t.w);
}
}
}
int main()
{
scanf("%d%d%d", &n, &m, &k);
int x, y, d;
for (int i = 0; i < m; ++ i )
{
scanf("%d%d%d", &x, &y, &d);
edges[i] = { x, y, d };
}
bellman_ford();
if (dis[n] > 0x3f3f3f3f / 2) puts("impossible");
else printf("%d\n", dis[n]);
return 0;
}
851. spfa求最短路
851. spfa求最短路 - AcWing题库
debug:以下spfa是错误的,错在松弛操作的判断条件,当满足dis[x] + w[i] < dis[y]
时,就要更新dis[y] = dis[x] + w[i]
。然后再判断y是否在队列中,不能将y是否在队列作为松弛操作的判断条件
void spfa()
{
memset(dis, 0x3f, sizeof(dis));
q[++ tt] = 1, dis[1] = 0, st[1] = true;
while (tt >= hh)
{
int x = q[hh ++ ];
st[x] = false;
for (int i = h[x]; i != -1; i = ne[i])
{
int y = e[i];
if (!st[y] && dis[x] + w[i] < dis[y])
st[y] = true, dis[y] = dis[x] + w[i], q[++ tt] = y;
}
}
}
以下是ac代码:
#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
const int N = 1e5 + 10;
int h[N], e[N], ne[N], w[N], idx = 1;
int q[N], hh, tt = -1;
int dis[N];
bool st[N]; // 某个点是否在队列中
int n, m;
void add(int x, int y, int d)
{
e[idx] = y, ne[idx] = h[x], w[idx] = d, h[x] = idx ++ ;
}
void spfa()
{
memset(dis, 0x3f, sizeof(dis));
q[++ tt] = 1, st[1] = true, dis[1] = 0; // 假设1号点为源点
while (tt >= hh)
{
int x = q[hh ++ ];
st[x] = false;
for (int i = h[x]; i != -1; i = ne[i] )
{
int y = e[i];
if (dis[x] + w[i] < dis[y])
{
dis[y] = dis[x] + w[i];
if (!st[y]) st[y] = true, q[++ tt ] = y;
}
}
}
}
int main()
{
memset(h, -1, sizeof(h));
scanf("%d%d", &n, &m);
int x, y, d;
while (m -- )
{
scanf("%d%d%d", &x, &y, &d);
add(x, y, d);
}
spfa();
if (dis[n] == 0x3f3f3f3f) puts("impossible");
else printf("%d\n", dis[n]);
return 0;
}
852. spfa判断负环
852. spfa判断负环 - AcWing题库
#include <iostream>
#include <cstring>
using namespace std;
const int N = 2010, M = 10010;
int h[N], e[M], ne[M], w[M], idx = 1;
int q[M], hh, tt = -1;
int dis[N], cnt[N];
bool st[N];
int n, m;
void add(int x, int y, int d)
{
e[idx] = y, ne[idx] = h[x], w[idx] = d, h[x] = idx ++ ;
}
bool spfa()
{
for (int i = 1; i <= n; ++ i )
q[++ tt] = i, st[i] = true;
while (tt >= hh)
{
int x = q[hh ++ ];
st[x] = false;
for (int i = h[x]; i != -1; i = ne[i])
{
int y = e[i];
if (dis[x] + w[i] < dis[y])
{
dis[y] = dis[x] + w[i];
cnt[y] = cnt[x] + 1;
if (cnt[y] >= n) return true;
if (!st[y]) q[++ tt] = y, st[y] = true;
}
}
}
return false;
}
int main()
{
memset(h, -1, sizeof(h));
scanf("%d%d", &n, &m);
int x, y, d;
while (m -- )
{
scanf("%d%d%d", &x, &y, &d);
add(x, y, d);
}
if (spfa()) puts("Yes");
else puts("No");
return 0;
}
debug:用原生数组模拟队列时,队列的长度要设置为M,之前设置为int q[N]
,导致了TLE,排查了很久的逻辑错误,结果是这里错了。看来这种边界问题需要提前想好啊,懒得想就用STL的,但是时间差距嘛,看下图
854. Floyd求最短路
854. Floyd求最短路 - AcWing题库
#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
const int N = 210, M = 20010;
int n, m, k;
int d[N][N];
void flody()
{
for (int k = 1; k <= n; ++ k )
for (int i = 1; i <= n; ++ i )
for (int j = 1; j <= n; ++ j )
d[i][j] = min(d[i][j], d[i][k] + d[k][j]);
}
int main()
{
memset(d, 0x3f, sizeof(d));
scanf("%d%d%d", &n, &m, &k);
for (int i = 1; i <= n; ++ i ) d[i][i] = 0;
int x, y, w;
while (m -- )
{
scanf("%d%d%d", &x, &y, &w);
d[x][y] = min(d[x][y], w);
}
flody();
while (k -- )
{
scanf("%d%d", &x, &y);
int t = d[x][y];
if (t > 0x3f3f3f3f / 2) puts("impossible");
else printf("%d\n", t);
}
return 0;
}