【再探图论】深入理解图论经典算法
一、bellman_ford
1. 是什么松弛
在《算法四》中,对松弛的解释是:relax the edge,看起来比较抽象,不过如果我们从生活中的实例去理解,就简单多了:
试想一根绳索,当你握着绳索的两头使劲用力拉伸时,绳子紧绷,长度会变长;而当你减小用力时,绳子松弛,长度会变短。当你没有用任何力时,此时绳子的长度最短。
这里当用力减小时,绳子收到的拉力减小,进而长度变短的过程,就是“松弛的过程”
体现在图论中,就是对于任意边 a->b,所谓的松弛操作就是通过 dist[b]=min(dist[b], dist[a]+g[a][b]) 来更新最短路的过程。
通过松弛操作,绳子变短,即起点到终点的距离逐渐趋近于最短路。
所以说,图论中常说的松弛操作就是一个“比喻”说法。它将最短路和绳子做类比。很有意思😊
在不同的算法中,松弛的具体操作(形式)可能不同,不过它们的本质都是相同的,那就是减小起点到终点的距离使其最终趋近于最短路。
2. 限定最短路的边数
为什么 bellman_ford 算法的松弛操作可以限定最短路?
从图中我们就可以发现,对所有边的松弛过程有点像“层序遍历”的过程,具体来说,以起点为出发点的层次遍历:我们每次从上一轮拓展出的结点出发,拓展下一个相邻的结点。形式上就相当于多走了一条边。对于图示,就是:
第一轮:从起点1拓展2,3
第二轮:从2,3拓展出4,5
第三轮:从4,5拓展出3,6
可以发现,3被拓展了两次
通过这我们也能证明出 bellman_ford 算法的正确性,只要我们松弛 n-1 次,那么就一定能得到起点到终点的最短路。因为此时我们是相当于枚举了所有从起点走到终点的情况。
即,先枚举走 1 条边到终点的情况;再枚举走 2 条边到终点的情况,...,最后枚举走 n-1 条边到终点的情况。并且对于走 k 条边的情况,还会枚举所有可能的路线,最后对其结果取最小值。
但是这也说明了,bellman_ford 求最短路的过程是 “暴力枚举”,因此它的时间效率不高。主要用于限定边数的最短路。
3. 不可达的点
对于不可达的点,我们的松弛会导致从起点到该点的距离减小。例如我们只有一条边 2->3,权值为 -1,那么松弛之后将导致 dist[3]=dist[2]-1。在初始状态下,dist[2]=dist[3]=INF。
你可能很聪明的想到,哎,按你这么说,bellman_ford 算法看起来完全不像层次遍历了啊,因为在初始状态下,我们只拓展了起点(1),它可以不从起点进行拓展呀(2->3)。
没错,但问题是,不从起点(上一轮拓展的点)开始拓展的点,是无意义的,因为我们不能保证其可达性,就如我们上面的说的,1->2 和 1->3 都不可达,那么从 2 开始拓展 3 有什么意义呢?
二、spfa
SPFA算法(Shortest Path Faster Algorithm),也称为 bellman_ford 队列优化算法。
SPFA的称呼来自 1994年西南交通大学段凡丁的论文,其实Bellman_ford 提出后不久 (20世纪50年代末期) 就有队列优化的版本,国际上不承认这个算法是是国内提出的。 所以国际上一般称呼 该算法为 Bellman_ford 队列优化算法(Queue improved Bellman-Ford)
1. spfa优化了什么
在上面 bellman_ford 算法中,每一轮松弛需要对所有边进行操作,时间复杂度为 O(M),效率很低。
但其实就如我们上面所提到的,每次松弛看起来就是层序遍历,而对于层序遍历来说,每一次只需要从最末端元素开始遍历即可,之前已经遍历过的元素就无需再遍历了。
其实这里的松弛操作也是类似的,对于 1->2 这条边,我们只需要在第一轮遍历即可,在第二轮和第三轮中就无需对这条边进行松弛了,因为 dist[a] 值已经确定了,它不会在变化。
由此可见,每一轮都枚举所有边是无意义的。并且,我们可以总结出:对于 a->b 这条边,只要当 dist[a] 发生变化时,松弛这条边才有意义。否则如果 dist[a] 不变,那么 dist[a]+g[a][b] 也不变,判断 dist[a]+g[a][b] 和 dist[b] 的大小从而进行松弛就完全没有意义了。
因此,我们可以用一个队列来保存这些 dist 发生变化的起始点 a,然后只对以 a 为起点的边进行松弛操作。这就是队列优化的 bellman_ford 算法。
同样我们可能理解为什么在 spfa 中,一个点可以多次入队了,就如我们上面说的,对于 2->5 这条边,我们需要在第二轮和第三轮时进行遍历。由于它需要分别在两轮进行松弛操作,那么 2 这个点就需要入队两次。每次入队就相当于一轮松弛。
同样的我们也能解释为什么 spfa 一个点不可达,但是从起点到他的距离变小了,因为 spfa 本质上就是 bellman_ford 啊,所以他继承了 bellman_ford 的这个问题。
2. 时间复杂度
如何设计卡掉spfa的数据
队列优化版Bellman_ford 的时间复杂度 并不稳定,效率高低依赖于图的结构。
例如 如果是一个双向图,且每一个节点和所有其他节点都相连的话,那么该算法的时间复杂度就接近于 Bellman_ford 的 O(N * E), N 为节点数量,E为边的数量。
在这种图中,每一个节点都会重复加入队列 n-1 次,因为 这种图中每个节点都有 n-1 条指向该节点的边,每条边指向该节点,就需要加入一次队列。
当然这种图是比较极端的情况,也是最稠密的图。所以如果图越稠密,则 SPFA的效率越接近与 Bellman_ford。反之,图越稀疏,SPFA的效率就越高。
一般来说,SPFA 的时间复杂度为 O(K * N), K 为不定值,因为节点需要计入几次队列取决于图的稠密度。
如果图是一条线形图且单向的话,每个节点的入度为1,那么只需要加入一次队列,这样时间复杂度就是 O(N)。
所以 SPFA 在最坏的情况下是 O(N * E),但一般情况下时间复杂度为 O(K * N)。
尽管如此,以上分析都是 理论上的时间复杂度分析。
并没有计算 出队列 和 入队列的时间消耗。 因为这个在不同语言上 时间消耗也是不一定的。
以C++为例,以下两段代码理论上,时间复杂度都是 O(n) :
for (long long i = 0; i < n; i++) {
k++;
}
for (long long i = 0; i < n; i++) {
que.push(i);
que.front();
que.pop();
}
在 MacBook Pro (13-inch, M1, 2020) 机器上分别测试这两段代码的时间消耗情况:
n = 10^4,第一段代码的时间消耗:1ms,第二段代码的时间消耗: 4 ms
n = 10^5,第一段代码的时间消耗:1ms,第二段代码的时间消耗: 13 ms
n = 10^6,第一段代码的时间消耗:4ms,第二段代码的时间消耗: 59 ms
n = 10^7,第一段代码的时间消耗: 24ms,第二段代码的时间消耗: 463 ms
n = 10^8,第一段代码的时间消耗: 135ms,第二段代码的时间消耗: 4268 ms
在这里就可以看出 出队列和入队列 其实也是十分耗时的。
SPFA(队列优化版Bellman_ford) 在理论上 时间复杂度更胜一筹,但实际上,也要看图的稠密程度,如果 图很大且非常稠密的情况下,虽然 SPFA的时间复杂度接近Bellman_ford,但实际时间消耗 可能是 SPFA耗时更多。
4. 负环问题
在用 spfa 求负环时,不需要建立虚拟源点。我们只需要以任意结点为起点执行 spfa 就一定能判断负环是否存在。因此只要存在符号,就一定存在负权边,就一定能进行松弛操作,就一定能入队。
3. 限制边数的最短路
既然 spfa 是 bellman_ford 的改进版,那么按理来说,spfa 也能实现限制边数的最短路问题。没错,问题是,如何在 spfa 的执行逻辑中限制松弛次数?
还是从层序遍历的思想来看,在层序遍历中,每一轮遍历需要取出当前队列中所有保存的结点。由于 spfa 是对 bellman_ford 的优化,所以 spfa 在形式上和层序遍历更相似了。
这里的实现思路和层序遍历非常相似:
题目链接
#include <iostream>
#include <cstring>
#include <algorithm>
#include <queue>
using namespace std;
const int N = 510, M = 10010, INF = 0x3f3f3f3f;
int n, m, k;
int h[N], e[M], ne[M], w[M], idx;
int dist[N], backup[N];
bool st[N];
void add(int a, int b, int c)
{
w[idx] = c;
e[idx] = b, 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;
while(k -- )
{
memset(st, false, sizeof st);
// (1)这里要将所有st置位false,因为上一轮置位true的点和这一轮需要加入的点“不一样”
// 上一轮入队的点,我们使用的是backup[]进行松弛的
// 而这一轮加入的点,我们希望使用 dist[] 进行
// 可以发现,这一轮对 dist 的更新不会体现在上一轮中,因为上一轮使用 backup 而不是 dist
// 因此对于上一轮入队,st置位true的点,这一轮依然需要入队
int cnt = q.size();
// (2)和bellman_ford一样,使用上一轮的dist进行松弛操作
memcpy(backup, dist, sizeof(dist));
while(cnt -- ) // (3)遍历上一层所有加入的点,进行松弛
{
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] > backup[t] + w[i])
{
dist[j] = backup[t] + w[i];
if(!st[j])
{
q.push(j);
st[j] = true;
}
}
}
}
}
return dist[n];
}
int main()
{
memset(h, -1, sizeof h);
cin >> n >> m >> k;
while(m -- )
{
int a, b, c; cin >> a >> b >> c;
add(a, b, c);
}
int t = spfa();
if(t >= INF / 2) puts("impossible");
else cout << t << endl;
return 0;
}
通过上面的代码其实可以发现,形式上和 spfa 差不多,不过有几个细节需要注意,特别是 st 数组和 backup 数组的处理,详细内容可以看代码中的注释部分。
三、堆优化dijkstra
1. 时间复杂度分析
reference
在C++中一般通过优先队列priority_queue作为数据结构来优化dijkstra
当使用优先队列时,如果同一个点的最短路被更新多次,因为先前更新时插入的元素不能被删除,也不能被修改,只能留在优先队列中,故优先队列内的元素个数是 O(m) 的
每次从队列中取出一个元素的时间复杂度为 O(logm)。故,总的时间复杂度是 O(mlogm)
四、AStar
1. 权值转换
对于很多情景来说,我们不好直接使用 AStar 算法,特别是在需要确保“最短路”特性的前提下,找到一个合适的估价函数比较困难!例如:题目链接
在上面这道题目中,骑士按照日字形移动,如果我们使用 AStar 算法,是不容易找出一个合适的估价函数使得估价值小于等于真实距离的。
就比如,骑士可以从 [0,0] 移动到 [1,2]。此时的:
- 哈密顿距离为 3
- 欧拉距离为 \(\sqrt{5}\)
- 切比雪夫距离为 2
他们都大于骑士的实际移动距离 1,所以说,按照实际的移动权值 1 进行移动时,很难找出合适的估价函数。
不过如果我们将这个权值 1 看作 5(=22+11,分别沿着 x 轴和 y 轴移动) 的话。然后以欧拉距离的平方作为估价函数。那么此时估价值就一定小于等于实际值。
因此说,通过改变权值,我们可以将原本不适用的估价函数修改为合法。
#include <iostream>
#include <cstring>
#include <algorithm>
#include <queue>
#include <cmath>
using namespace std;
const int N = 1000;
const int DISTANCE = 5; // 2 * 2 + 1 * 1
typedef pair<int, int> PII;
int dist[N + 1][N + 1];
int dx[8] = {1, 1, -1, -1, 2, 2, -2, -2};
int dy[8] = {2, -2, 2, -2, 1, -1, 1, -1};
typedef struct Node {
PII p; // point
int g, h; // f = g(grape_dist) + h(heuristic)
bool operator<(const Node &rhs) const {
return g + h > rhs.g + rhs.h;
}
} Node; // astar_node_type
int eular_distance(PII x, PII y)
{
return (x.first - y.first) * (x.first - y.first) + (x.second - y.second) * (x.second - y.second);
}
int astar_bfs(PII start, PII goal)
{
if(start == goal) return 0;
memset(dist, 0, sizeof dist);
dist[start.first][start.second] = 1;
priority_queue<Node> q;
q.push({start, 5, eular_distance(start, goal)});
while(q.size())
{
auto t = q.top(); q.pop();
auto [x, y] = t.p;
int g = t.g;
for(int i = 0; i < 8; i ++ )
{
int a = x + dx[i], b = y + dy[i];
if(a <= 0 || a > N || b <= 0 || b > N) continue;
if(!dist[a][b] || dist[a][b] > dist[x][y] + 1)
{
if(a == goal.first && b == goal.second) return dist[x][y];
q.push({{a, b}, g + DISTANCE, eular_distance({a,b}, goal)});
dist[a][b] = dist[x][y] + 1;
}
}
}
return -1; // cant move the goal
}
int main()
{
int T; cin >> T;
while(T -- ) {
int sx, sy, ex, ey;
cin >> sx >> sy >> ex >> ey;
cout << astar_bfs({sx, sy}, {ex, ey}) << endl;
}
return 0;
}
2. 只能确保终点的最短路
在 AStar 算法中,我们只能确保从起点到终点的最短路,而不能确保从起点到其余节点的路线也是最短路。
具体来说,当终点第一次出队时,我们能确保它是最短路,但对于其余节点来说,我们并不能保证这一点。
这是因为我们的估价函数保证的是:任意节点到终点的估价值小于等于实际值。
如果我们相求非终点的最短路,需要确保任意节点到该节点的估价值小于等于实际值,但我们的估价函数并未保证这一点。
当然,很显然的,如果运气好的话,对于某些节点来说,我们的估价函数或许也满足其余节点到该节点的估计值小于等于实际值,但这完全是运气,它完全不可靠!
所以说,即使我们求出了非终点的最短路,也只是运气好而已。
因此说,由于非终点第一次出队可能不是最短路,就意味着它可能在后续再次被更新,此时就需要再次入队。这不同于 dijkstra 和 bfs 的单次入队。
因为 dijkstra 和 bfs 可以保证某个节点第一次出队时一定是最短路。但 Astar 由于估计值的原因,只能保证终点。
从上面的代码中,我们可能看出这一处理:if(!dist[a][b] || dist[a][b] > dist[x][y] + 1)
其中 dist[a][b] > dist[x][y] + 1
对某个节点做了允许多次入队处理。如果我们去掉这段代码,那么 bfs 的正确性完全看运气了。
如果此时遍历到的其余节点恰好能满足最短路的特性,程序正确;反之,如果其余节点(中间节点)不满足最短路,那么从其余节点出发走向终点的路线也必然不是最短路了。