目录
- 前言
- 一、全源最短路
- 1.1 Floyd
- 二、单源最短路
- 2.1 Dijkstra
- 2.1.1 堆优化版的Dijkstra
- 2.2 Bellman-Ford
- 2.2.1 队列优化版的Bellman-Ford:SPFA
前言
BFS是一种朴素的最短路算法,它可以找到无权图或边权都相同的图的最短路,但是对于边权不完全相同甚至可能是负数的图,BFS并不能得到正确的结果,此时我们就需要使用其他的最短路算法来求解。
本文仅介绍最基础的几种最短路算法,思维导图如下:
单源最短路指的是给定一个源点,计算该源点到图中所有其他点的最短路径长度,而全源最短路则是计算图中任意两点之间的最短路径长度。
为方便叙述,接下来均假定图中节点的数量为 n n n,边的数量为 m m m,节点的编号为 1 ∼ n 1\sim n 1∼n。
一、全源最短路
1.1 Floyd
Floyd算法是一种求解图中任意两点之间最短路径的经典算法,它适用于任何图,不管有向无向,边权正负,但是最短路必须存在(不能有个负环)。
该算法的基本思想是动态规划,具体来讲,定义一个三维数组 dp[k][x][y]
,表示从起点
x
x
x 经过子图
G
′
G'
G′ 后到达终点
y
y
y 的最短路径的长度,其中
G
′
G'
G′ 由节点
1
,
2
,
⋯
,
k
1,2,\cdots,k
1,2,⋯,k 构成(
x
x
x 和
y
y
y 不一定在
G
′
G'
G′ 中)。
由上述定义可知,dp[n][x][y]
即为
x
x
x 到
y
y
y 的最短路径长度(因为此时
G
′
=
G
G'=G
G′=G),dp[0][x][y]
为
x
x
x 到
y
y
y 的边权。
为计算 d p [ k ] [ x ] [ y ] dp[k][x][y] dp[k][x][y],我们可以将其分为以下两种情况考虑:
- 不经过节点 k k k:即 d p [ k − 1 ] [ x ] [ y ] dp[k-1][x][y] dp[k−1][x][y];
- 经过节点 k k k:那么从 x x x 到 y y y 的最短距离变成了 x x x 到 k k k 的最短距离加上 k k k 到 y y y 的最短距离,即 d p [ k − 1 ] [ x ] [ k ] + d p [ k − 1 ] [ k ] [ y ] dp[k-1][x][k]+dp[k-1][k][y] dp[k−1][x][k]+dp[k−1][k][y]。
于是可得递推式:
d p [ k ] [ x ] [ y ] = min ( d p [ k − 1 ] [ x ] [ y ] , d p [ k − 1 ] [ x ] [ k ] + d p [ k − 1 ] [ k ] [ y ] ) , k , x , y ≥ 1 dp[k][x][y] = \min(dp[k-1][x][y], \;dp[k-1][x][k]+dp[k-1][k][y]),\quad k,x,y\geq 1 dp[k][x][y]=min(dp[k−1][x][y],dp[k−1][x][k]+dp[k−1][k][y]),k,x,y≥1
对应求解代码如下:
for (int k = 1; k <= n; k++)
for (int i = 1; i <= n; i++)
for (int j = 1; j <= n; j++)
dp[k][i][j] = min(dp[k - 1][i][j], dp[k - 1][i][k] + dp[k - 1][k][j]);
我们可以使用滚动数组将其优化成二维的形式:
for (int k = 1; k <= n; k++)
for (int i = 1; i <= n; i++)
for (int j = 1; j <= n; j++)
dp[i][j] = min(dp[i][j], dp[i][k] + dp[k][j]);
注意到要想求 dp[1][..][..]
必须先求得 dp[0][..][..]
,而 dp[0][..][..]
实际上就是图的邻接矩阵,因此我们可以直接在图的邻接矩阵上进行动态规划。计算结束后,dp[a][b]
就代表了
a
a
a 到
b
b
b 的最短距离。
🔗 AcWing 854. Floyd求最短路
#include <bits/stdc++.h>
using namespace std;
const int N = 210, INF = 0x3f3f3f3f;
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() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> 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 x, y, z;
cin >> x >> y >> z;
d[x][y] = min(d[x][y], z); // 本来应当是d[x][y] = z,这里取min是为了处理重边
}
floyd();
while (q--) {
int a, b;
cin >> a >> b;
if (d[a][b] > INF / 2) cout << "impossible\n";
else cout << d[a][b] << "\n";
}
return 0;
}
容易看出,Floyd算法的时间复杂度为 O ( n 3 ) O(n^3) O(n3),空间复杂度为 O ( n 2 ) O(n^2) O(n2)。
二、单源最短路
2.1 Dijkstra
Dijkstra算法是一种求解非负权图上单源最短路径的贪心算法。
具体来说,Dijkstra算法维护一个集合 S S S,其中包含已经确定最短路径的节点,以及一个集合 V \ S V\backslash S V\S,其中包含未确定最短路径的节点。初始时, S S S 只包含源节点, V \ S V\backslash S V\S 包含其余所有节点。然后,算法不断从 V \ S V\backslash S V\S 中选出距离源节点最近的一个节点,将其加入到 S S S 中,并且更新其邻居节点到源节点的距离。重复执行这个过程,直到目标节点被加入到 S S S 中,或者 V \ S V\backslash S V\S 为空为止。
我们需要维护一个距离数组 d d d,不妨设编号为 1 1 1 的节点是源点,则 d d d 初始时应当满足 d [ 1 ] = 0 , d [ 2.. n ] = + ∞ d[1]=0,\,d[2..n]=+\infty d[1]=0,d[2..n]=+∞。
🔗 AcWing 849. Dijkstra求最短路 I
稠密图上,我们可以用邻接矩阵来实现:
#include <bits/stdc++.h>
using namespace std;
const int N = 510, INF = 0x3f3f3f3f;
int n, m;
int g[N][N]; // 稠密图用邻接矩阵
int d[N]; // 存储每个点到源点的距离
bool st[N]; // 用来标记一个节点是否已被加入到S中
int dijkstra() {
// 初始化距离数组
memset(d, 0x3f, sizeof(d));
d[1] = 0;
// 循环n-1次即可,因为第n次循环毫无意义
for (int i = 0; i < n - 1; i++) {
// 找到距离源点最近且不在S中的节点
int t = -1;
for (int j = 1; j <= n; j++)
if (!st[j] && (t == -1 || d[j] < d[t]))
t = j;
st[t] = true;
// 用该点去更新其邻居节点的距离
// 对于节点j,若j属于S,则d[j]并不会被覆盖掉,因为一定有d[j] <= d[t]
// 若t与j不相连,则d[j]也不会更新,因为g[t][j] == INF
for (int j = 1; j <= n; j++)
d[j] = min(d[j], d[t] + g[t][j]);
}
return d[n] == INF ? -1 : d[n];
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
memset(g, 0x3f, sizeof(g));
cin >> n >> m;
while (m--) {
int x, y, z;
cin >> x >> y >> z;
g[x][y] = min(g[x][y], z); // 处理重边
}
cout << dijkstra() << "\n";
return 0;
}
稀疏图上,我们可以用邻接表来实现(本题是稠密图,这里仅仅是为了展示邻接表的写法):
#include <bits/stdc++.h>
using namespace std;
const int N = 510, M = 1e5 + 10, INF = 0x3f3f3f3f;
int n, m;
int h[N], e[M], ne[M], w[M], idx;
int d[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(d, 0x3f, sizeof(d));
d[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 || d[j] < d[t]))
t = j;
st[t] = true;
for (int j = h[t]; ~j; j = ne[j]) {
int k = e[j];
d[k] = min(d[k], d[t] + w[j]);
}
}
return d[n] == INF ? -1 : d[n];
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
memset(h, -1, sizeof(h));
cin >> n >> m;
while (m--) {
int x, y, z;
cin >> x >> y >> z;
add(x, y, z);
}
cout << dijkstra() << "\n";
return 0;
}
容易看出,朴素版的Dijkstra算法的时间复杂度为 O ( n 2 ) O(n^2) O(n2)。
2.1.1 堆优化版的Dijkstra
先前我们在寻找 t
时(距离源点最近且不在
S
S
S 中的点)采用了暴力的做法,时间复杂度是
O
(
n
)
O(n)
O(n)。如果用小根堆来存储距离和编号,则查询 t
的时间复杂度将降至
O
(
1
)
O(1)
O(1)。
🔗 AcWing 850. Dijkstra求最短路 II
#include <bits/stdc++.h>
using namespace std;
typedef pair<int, int> PII;
const int N = 2e5, INF = 0x3f3f3f3f;
int n, m;
int h[N], e[N], ne[N], w[N], idx;
int d[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(d, 0x3f, sizeof(d));
d[1] = 0;
priority_queue<PII, vector<PII>, greater<>> pq;
pq.emplace(0, 1); // 第一个放距离,第二个放节点编号,因为pair总是优先排序第一个元素
while (!pq.empty()) {
auto [_, t] = pq.top(); // 结构化绑定,因为不需要第一个元素所以用_来占位
pq.pop();
if (st[t]) continue;
st[t] = true;
for (int i = h[t]; ~i; i = ne[i]) {
int j = e[i];
if (d[j] > d[t] + w[i]) {
d[j] = d[t] + w[i];
pq.emplace(d[j], j);
}
}
}
return d[n] == INF ? -1 : d[n];
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
memset(h, -1, sizeof(h));
cin >> n >> m;
while (m--) {
int x, y, z;
cin >> x >> y >> z;
add(x, y, z);
}
cout << dijkstra() << "\n";
return 0;
}
优化后的时间复杂度为 O ( m log n ) O(m\log n) O(mlogn)。
由此可见,在稠密图中, m ≈ n 2 m\approx n^2 m≈n2,此时应当用朴素版的Dijkstra算法;而在稀疏图中, m ≪ n 2 m\ll n^2 m≪n2,此时应当用堆优化版的Dijkstra算法。
2.2 Bellman-Ford
Dijkstra不能解决负权边是因为当标记 st[j] = true
后,d[j]
就是最短距离了,之后就不能再被更新了(当有负权边时,贪心算法容易得到局部最优而不是全局最优)。如下图所示:
Dijkstra算法会依次标记 1 -> 2 -> 4 -> 5
,当标记 5
之后,1
到 5
的最短路就确定了,而实际的最短路却是 1 -> 3 -> 4 -> 5
。
Bellman-Ford算法不断尝试对图上每一条边进行松弛,例如,对于边 a → w b a\xrightarrow{w} b awb,该边的松弛操作为
d [ b ] = min ( d [ b ] , d [ a ] + w ) d[b] = \min(d[b],\,d[a] + w) d[b]=min(d[b],d[a]+w)
其中 d [ x ] d[x] d[x] 表示 1 1 1 号点(起点)到 x x x 号点的最短距离。
每进行一轮循环,该算法就会对图上所有边都进行一次松弛操作。因此当循环 k k k 次后,边数不超过 k k k 的最短路就可以确定。
🔗 AcWing 853. 有边数限制的最短路
#include <bits/stdc++.h>
using namespace std;
const int N = 510, M = 10010, INF = 0x3f3f3f3f;
struct Edge {
int a, b, w;
} edges[M];
int n, m, k;
int d[N];
int backup[N];
void bellman_ford() {
memset(d, 0x3f, sizeof(d));
d[1] = 0;
for (int i = 0; i < k; i++) {
memcpy(backup, d, sizeof(d)); // 备份,防止发生串联更新,若无法理解可参考01背包问题中的dp数组的更新顺序
for (int j = 0; j < m; j++) {
auto e = edges[j];
d[e.b] = min(d[e.b], backup[e.a] + e.w); // 松弛操作
}
}
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> m >> k;
for (int i = 0; i < m; i++) {
int a, b, w;
cin >> a >> b >> w;
edges[i] = {a, b, w};
}
bellman_ford();
if (d[n] > INF / 2) cout << "impossible\n"; // 可能会有负权边使得d[n]略小于INF,所以不能用d[n] == INF来判断
else cout << d[n] << "\n";
return 0;
}
时间复杂度为 O ( n m ) O(nm) O(nm)。