最短路问题
可以分为两类:
-
单元最短路
求从一个点到其他所有点的最短距离,如从1号点到n号点的最短路
-
多源汇最短路
起点和终点数量不确定
n表示图中点的数量,m表示图中边的数量,一般m~\(n^2\)是稠密图
朴素Dijkstra适合稠密图,如边数比较多和\(n^2\)一个级别用朴素Dijkstra
m和n是一个级别,堆优化版比较好
SPFA是Bellman-Ford算法的一个优化,但是在某些时候SPFA是不能使用的,如对边数进行一个限制,经过不超过k条边就只能用Bellman-Ford
最短路不会让你去证明算法的正确性,最短路的难点在于如何把原问题抽象成最短路问题,如何定义我们的点和边使得这个问题变成一个最短路问题,从而套用公式去解决。难点在于建图。
Dijkstra基于贪心,Floyd基于动态规划,Bellman-Ford基于离散数学。
1、Dijkstra算法
一定不能存在负权边。
伪代码:
-
初始化距离:dis[1] = 0,dis[others] = $ +\infty $。
-
集合s:存储当前已经确定最短路的点
-
for(int i = 0;i < n; i++) {
$t \gets $找到不在s中的距离最近的点
\(s \gets to\)
用t更新其他点的距离。更新方式:看从t出去的所有的边,组成的路径能不能更新其他点的距离。如下图:
如果从1到x的距离大于从1到t再到x的距离,就用dis[t]+w代替1到x的距离。如下图:
初始状态:
①②③ 上的数字表示距离起点的距离,红色表示待定,绿色表示确定已经放入了集合s。
第一个点更新:
第二个点更新:
第三个点更新:
}
1.1 Dijkstra练习
1.2 朴素Dijkstra算法解答
存在重边只保留最短的那条边就可以了
解答:if (!st[j] && (t == -1 || dist[t] > dist[j]))
#include <cstring>
#include <iostream>
#include <algorithm>
using namespace std;
const int N = 510;
int n, m;
int g[N][N];
int dist[N]; // 从1号点走到其他每个点当前最短距离
bool st[N];
int dijkstra()
{
memset(dist, 0x3f, sizeof dist);
dist[1] = 0;
for (int i = 0; i < n - 1; i ++ )
{
int t = -1; // 初始还没有确定点的时候
for (int j = 1; j <= n; j ++ )
// 这个循环找的就是所有st[j]=false即距离还没确定的点
// 在这些点中找到距离最小的点
if (!st[j] && (t == -1 || dist[t] > dist[j]))
t = j;
for (int j = 1; j <= n; j ++ )
dist[j] = min(dist[j], dist[t] + g[t][j]);
st[t] = true;
}
if (dist[n] == 0x3f3f3f3f) return -1;
return dist[n];
}
int main()
{
scanf("%d%d", &n, &m);
memset(g, 0x3f, sizeof g);
while (m -- )
{
int a, b, c;
scanf("%d%d%d", &a, &b, &c);
g[a][b] = min(g[a][b], c); // 取min是可能存在重边
}
printf("%d\n", dijkstra());
return 0;
}
1.3堆优化Dijkstra算法解答
考虑如何优化:
因为t从1~n每次都是不同的点,所以用t更新其他点的距离这个操作就是遍历从t出去所有边,遍历n次即遍历所有点以后就是遍历了所有边,所以计算了m次
可以发现最慢的就是找最小距离这步,复杂度O(\(n^2\))。在一堆数中找最小的数就可以用堆来找。
堆中修改一个数O(\(log^n\))。
堆优化之后:
可以手写堆也可以用STL的priority_queue。
priority_queue使用的是冗余的思想,替换一个点的值实际是插入了一个新节点,所以原图有n个点但priority_queue里可能有m个点。待查证:priority_queue的替换实现实质。
#include <cstring>
#include <iostream>
#include <algorithm>
#include <queue>
using namespace std;
typedef pair<int, int> PII; // 表示编号和距离
const int N = 1e6 + 10;
int n, m;
int h[N], w[N], e[N], ne[N], idx;
int dist[N];
bool st[N];
void add(int a, int b, int c)
{
e[idx] = b, w[idx] = c, ne[idx] = h[a], h[a] = idx ++ ;
}
int dijkstra()
{
memset(dist, 0x3f, sizeof dist);
dist[1] = 0;
priority_queue<PII, vector<PII>, greater<PII>> heap;
heap.push({0, 1}); // 距离是0编号是1
while (heap.size())
{
auto t = heap.top(); // 最多有m个点,因为一共只有m条边,当前距离最小的点即堆的起点
heap.pop();
int ver = t.second, distance = t.first; // ver编号,dis距离
if (st[ver]) continue; // 之前这个点出来过了,说明这个点是冗余备份的,跳过就可以
st[ver] = true;
for (int i = h[ver]; i != -1; i = ne[i])
{
int j = e[i];
if (dist[j] > dist[ver] + w[i])
{
dist[j] = dist[ver] + w[i];
heap.push({dist[j], j});
}
}
}
if (dist[n] == 0x3f3f3f3f) return -1;
return dist[n];
}
int main()
{
scanf("%d%d", &n, &m);
memset(h, -1, sizeof h);
while (m -- )
{
int a, b, c;
scanf("%d%d%d", &a, &b, &c);
add(a, b, c);
}
cout << dijkstra() << endl;
return 0;
}
2、Bellman-Ford算法
存储边的方法任意,只要能让我找到这条边就行,开个结构体数组存储都可以。
伪代码:
-
for 1~n{ // 迭代几次就意味着最多不超过几条边
for 所有边a,b,w(左右端点和权重)
dist[b] = min(dist[b],dist[a]+w); // 这步叫做松弛操作
}
循环之后所有边一定满足dist[b]\(\le\)dist[a]+w(三角不等式)。
如果有负权回路,最短路不一定存在,如图:
求从1到5的最短距离,那么从1到2,转一圈距离就减少1,那么我们无限转下去,距离就会无限减少。
所以如果存在负权回路,最短路就不一定存在了。
所以Bellman-Ford算法可以用来判断是否存在负权回路。
如当前迭代了k次,那么求的最短距离的dist数组的含义就是:从1号点经过不超过k条边走到每个点的最短距离。
如果我们走到第n次迭代又更新了某些边,就说明存在一条最短路径,它上面边的数量是\(\ge n\),n条边就意味着有n+1个点,但实际只有n个点,所以一定有两个点重复了,即一定存在环。路径上存在环一定是第n次把第k次的更新了,说明第n次求得的距离比第k次已经确定的更短了,说明这个环一定是负环。但一般都会用SPFA去找负环。
2.1 Bellman-Ford练习
2.2 Bellman-Ford算法解答
串联问题:如下图:
k = 1即从1号点到3号点的路径不能超过一条。所以最短路的长度是3
而在Bellman-Ford算法中,如果我们不引入备份last[]数组,那么在更新枚举所有边的时候,可能发生串联。
dist数组的初始状态是:
- dist[1] = 0,dist[2]=\(+\infty\),dist[3] =\(+\infty\)。
比如我们第一次枚举的边是\(1 \to 2\),那么:
- dist[1] = 0,dist[2]=1,dist[3] =\(+\infty\)。
因为我们是枚举所有边,那么dist[3]也会被更新,因为dist[2]变成了1而不再是\(\infty\)。
- dist[1] = 0,dist[2]=1,dist[3] =2。
for 所有边:
dist[e.b] = min(dist[e.b], dist[e.a] + e.c)。
这时dist[]是被更改的,可能发生串联。
所以我们要引入备份数组:dist[e.b] = min(dist[e.b], last[e.a] + e.c);
这样对于dist[3]来说,\(+\infty+1 > +\infty\),所以dist[3]不会被更新。
- dist[1] = 0,dist[2]=1,dist[3] =\(+\infty\)。
#include <cstring>
#include <iostream>
#include <algorithm>
using namespace std;
const int N = 510, M = 10010;
struct Edge
{
int a, b, c;
}edges[M]; // 结构体数组存储所有边
int n, m, k;
int dist[N];
int last[N];
void bellman_ford()
{
memset(dist, 0x3f, sizeof dist);
dist[1] = 0;
for (int i = 0; i < k; i ++ )
{
// 每次操作前备份一次,last存储上一次迭代结果,防止串联
// 串联即我先更新了b,又用b来更新其他点,如本来b->c距离10
// 我用更新后的b更新了b->c,值变为5,会对其他运算造成影响
memcpy(last, dist, sizeof dist);
for (int j = 0; j < m; j ++ )
{
auto e = edges[j];
dist[e.b] = min(dist[e.b], last[e.a] + e.c); // 这里是用的上次结果更新就是避免串联
}
}
}
int main()
{
scanf("%d%d%d", &n, &m, &k);
for (int i = 0; i < m; i ++ )
{
int a, b, c;
scanf("%d%d%d", &a, &b, &c);
edges[i] = {a, b, c};
}
bellman_ford();
if (dist[n] > 0x3f3f3f3f / 2) puts("impossible"); // 这里0x3f/2是因为存在负权重,可能有的无穷被更新了,如从9999变成了9997
else printf("%d\n", dist[n]);
return 0;
}
3、SPFA算法
SPFA就是对Bellman-Ford进行了优化。
只要没有负环就可以用SPFA,比如DIjkstra的题目。
每次迭代时都对所有边进行了更新:
for 所有边a,b,w(左右端点和权重)
dist[b] = min(dist[b],dist[a]+w);
但实际每次迭代不一定每条边都会更新,这里就可以去做优化了。
每次迭代dist[b]变小一定是dist[a]变小了,这就可以用宽搜做优化:迭代时用一个queue来做。queue里就是所有便小的a。
- queue \(\gets\)起点
- while queue不空:
- 取出队头$t \gets $队头,queue.pop()删除队头
- 更新t的所有出边,如果更新成功就把该点加入队列
2.1 解答
#include <cstring>
#include <iostream>
#include <algorithm>
#include <queue>
using namespace std;
const int N = 100010;
int n, m;
int h[N], w[N], e[N], ne[N], idx;
int dist[N];
bool st[N];
void add(int a, int b, int c)
{
e[idx] = b, w[idx] = c, ne[idx] = h[a], h[a] = idx ++ ;
}
int spfa()
{
memset(dist, 0x3f, sizeof dist);
dist[1] = 0;
queue<int> q;
q.push(1);
st[1] = true; // st存储当前点是否在队列中,重复点是没意义的
while (q.size()) // 不空
{
int t = q.front();
q.pop();
st[t] = false;
for (int i = h[t]; i != -1; i = ne[i])
{
int j = e[i];
if (dist[j] > dist[t] + w[i])
{
dist[j] = dist[t] + w[i];
if (!st[j])
{
q.push(j);
st[j] = true;
}
}
}
}
return dist[n];
}
int main()
{
scanf("%d%d", &n, &m);
memset(h, -1, sizeof h);
while (m -- )
{
int a, b, c;
scanf("%d%d%d", &a, &b, &c);
add(a, b, c);
}
int t = spfa();
if (t == 0x3f3f3f3f) puts("impossible");
else printf("%d\n", t);
return 0;
}
2.2 求负环
如何用SPFA判断负环呢?
很简单,再开一个cnt数组存储路径长度。
如果cnt[x]\(\ge\)n,就意味着从1~x至少n条边,说明至少n+1个点,说明有2个点是相同的。路径上这个环不是白白存在的,他存在就一定会把dist[]变小,要不我根本就不会走这条边,因为我们是在找最短路。
2.3 求负环题目
2.4 解答
#include <cstring>
#include <iostream>
#include <algorithm>
#include <queue>
using namespace std;
const int N = 2010, M = 10010;
int n, m;
int h[N], w[M], e[M], ne[M], idx;
int dist[N], cnt[N]; // 分别存储1-x的最短距离,当前最短路边的数量
// cnt[x] >= n说明一定存在两个点一样,即负环
// 这个环不是白存在的,他一定会变小,如果不变小就不会存在这个环了
// 我们在找最短距离,不变小就应该去下个点,所以这个环就是负权的
bool st[N];
void add(int a, int b, int c)
{
e[idx] = b, w[idx] = c, ne[idx] = h[a], h[a] = idx ++ ;
}
bool spfa()
{
queue<int> q;
// 所有点放进队列,因为可能存在负环,但1这个点不经过
for (int i = 1; i <= n; i ++ )
{
st[i] = true;
q.push(i);
}
while (q.size())
{
int t = q.front();
q.pop();
st[t] = false;
for (int i = h[t]; i != -1; i = ne[i])
{
int j = e[i];
if (dist[j] > dist[t] + w[i])
{
dist[j] = dist[t] + w[i];
cnt[j] = cnt[t] + 1;
if (cnt[j] >= n) return true;
if (!st[j])
{
q.push(j);
st[j] = true;
}
}
}
}
return false;
}
int main()
{
scanf("%d%d", &n, &m);
memset(h, -1, sizeof h);
while (m -- )
{
int a, b, c;
scanf("%d%d%d", &a, &b, &c);
add(a, b, c);
}
if (spfa()) puts("Yes");
else puts("No");
return 0;
}
4、Floyd算法
可以处理负权,但是不能有负权回路。
基于动态规划,d[k,i,j]表示从i这个点出发只经过1~k这些中间点到达j的最短距离。更新的话首先枚举k,因为k是阶段,比如我们想算k,那么要先从k-1转移过来,d[k,i,j] = d[k-1,i,k]表示从i到k只经过k-1条边+d[k-1,k,j]从k到j只经过k-1条边。第一维没什么用,可以去掉,最后就是d[i,j] = d[i,k]+d[k,j]。
初始:邻接矩阵d[i,j]存储所有的边
伪代码:
for(int k = 0; k < n; k++)
for(int i = 0; i < n; i++)
for(int j = 0; j < n; j++)
d[i,j] = min(d[i,j]d[i,k]+d[k,j])
结束后d[i,j]存储从i到j的最短路的长度
4.1 练手题目
4.2 解答
#include <cstring>
#include <iostream>
#include <algorithm>
using namespace std;
const int N = 210, INF = 1e9;
int n, m, Q;
int d[N][N];
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]);
}
int main()
{
scanf("%d%d%d", &n, &m, &Q);
for (int i = 1; i <= n; i ++ )
for (int j = 1; j <= n; j ++ )
if (i == j) d[i][j] = 0;
else d[i][j] = INF;
while (m -- )
{
int a, b, c;
scanf("%d%d%d", &a, &b, &c);
d[a][b] = min(d[a][b], c);
}
floyd();
while (Q -- )
{
int a, b;
scanf("%d%d", &a, &b);
int t = d[a][b];
if (t > INF / 2) puts("impossible"); // 因为存在负权边,如果a,b之间不存在通路那么可能不是正无穷,会比正无穷小一些
else printf("%d\n", t);
}
return 0;
}
5、模板
5.1 朴素dijkstra算法
时间复杂是 O(n2+m)O(n2+m), nn 表示点数,mm 表示边数
int g[N][N]; // 存储每条边
int dist[N]; // 存储1号点到每个点的最短距离
bool st[N]; // 存储每个点的最短路是否已经确定
// 求1号点到n号点的最短路,如果不存在则返回-1
int dijkstra()
{
memset(dist, 0x3f, sizeof dist);
dist[1] = 0;
for (int i = 0; i < n - 1; i ++ )
{
int t = -1; // 在还未确定最短路的点中,寻找距离最小的点
for (int j = 1; j <= n; j ++ )
if (!st[j] && (t == -1 || dist[t] > dist[j]))
t = j;
// 用t更新其他点的距离
for (int j = 1; j <= n; j ++ )
dist[j] = min(dist[j], dist[t] + g[t][j]);
st[t] = true;
}
if (dist[n] == 0x3f3f3f3f) return -1;
return dist[n];
}
5.2 堆优化版dijkstra
时间复杂度 O(mlogn)O(mlogn), nn 表示点数,mm 表示边数
typedef pair<int, int> PII;
int n; // 点的数量
int h[N], w[N], e[N], ne[N], idx; // 邻接表存储所有边
int dist[N]; // 存储所有点到1号点的距离
bool st[N]; // 存储每个点的最短距离是否已确定
// 求1号点到n号点的最短距离,如果不存在,则返回-1
int dijkstra()
{
memset(dist, 0x3f, sizeof dist);
dist[1] = 0;
priority_queue<PII, vector<PII>, greater<PII>> heap;
heap.push({0, 1}); // first存储距离,second存储节点编号
while (heap.size())
{
auto t = heap.top();
heap.pop();
int ver = t.second, distance = t.first;
if (st[ver]) continue;
st[ver] = true;
for (int i = h[ver]; i != -1; i = ne[i])
{
int j = e[i];
if (dist[j] > distance + w[i])
{
dist[j] = distance + w[i];
heap.push({dist[j], j});
}
}
}
if (dist[n] == 0x3f3f3f3f) return -1;
return dist[n];
}
5.3 Bellman-Ford算法
时间复杂度 O(nm)O(nm), nn 表示点数,mm 表示边数
int n, m; // n表示点数,m表示边数
int dist[N]; // dist[x]存储1到x的最短路距离
struct Edge // 边,a表示出点,b表示入点,w表示边的权重
{
int a, b, w;
}edges[M];
// 求1到n的最短路距离,如果无法从1走到n,则返回-1。
int bellman_ford()
{
memset(dist, 0x3f, sizeof dist);
dist[1] = 0;
// 如果第n次迭代仍然会松弛三角不等式,就说明存在一条长度是n+1的最短路径,由抽屉原理,路径中至少存在两个相同的点,说明图中存在负权回路。
for (int i = 0; i < n; i ++ )
{
for (int j = 0; j < m; j ++ )
{
int a = edges[j].a, b = edges[j].b, w = edges[j].w;
if (dist[b] > dist[a] + w)
dist[b] = dist[a] + w;
}
}
if (dist[n] > 0x3f3f3f3f / 2) return -1;
return dist[n];
}
5.4 spfa算法(队列优化的Bellman-Ford算法)
时间复杂度 平均情况下 O(m)O(m),最坏情况下 O(nm)O(nm), nn 表示点数,mm 表示边数
int n; // 总点数
int h[N], w[N], e[N], ne[N], idx; // 邻接表存储所有边
int dist[N]; // 存储每个点到1号点的最短距离
bool st[N]; // 存储每个点是否在队列中
// 求1号点到n号点的最短路距离,如果从1号点无法走到n号点则返回-1
int spfa()
{
memset(dist, 0x3f, sizeof dist);
dist[1] = 0;
queue<int> q;
q.push(1);
st[1] = true;
while (q.size())
{
auto t = q.front();
q.pop();
st[t] = false;
for (int i = h[t]; i != -1; i = ne[i])
{
int j = e[i];
if (dist[j] > dist[t] + w[i])
{
dist[j] = dist[t] + w[i];
if (!st[j]) // 如果队列中已存在j,则不需要将j重复插入
{
q.push(j);
st[j] = true;
}
}
}
}
if (dist[n] == 0x3f3f3f3f) return -1;
return dist[n];
}
5、最短路总结
分类 | 算法名称 | 适用场景 | 时间复杂度 |
单源最短路 | 朴素Dijkstra算法 | 稠密图,且不存在负权边 | $O(n^2)$ |
堆优化Dijkstra算法 | 稀疏图,且不存在负权边 | $O(mlog^n)$ | |
Bellman-Ford算法 | 存在负权,但一般都用SPFA | O(nm) | |
SPFA算法 | 存在负权,是Bellman的优化,但有些情况用不了 | 一般O(m)最坏O(nm) | |
多源最短路 | Floyd算法 | 多源情况下 | $O(n^3)$ |