文章目录
1.弗洛伊德 Floyd-Warshall
主要想法是,通过逐渐增加允许经过的节点,来更新最短路,本质上是动态规划方法
-
求取图中任意两点之间的距离
-
f[k][x][y]
:只允许经过节点 1 到 k(不包括两个端点,两个端点自然允许),节点 x 到节点 y 的最短路长度 -
如果有 n 个节点,则
f[n][x][y]
就是节点 x 到节点 y 的最短路长度 -
状态转移方程如下所示:
-
-
我们可以将上面的三维数组优化为二维数组
- 因为在第 k 阶段,
f[x][k]
和f[k][y]
不会被更新,因为k
是路径上的端点(端点本来就允许,现在允许经过,和之前没有区别)f[k][x][k] = min(f[k - 1][x][k], f[k - 1][x][k] + 0) = f[k - 1][x][k]
,可见f[x][k]
不变- 同理
f[k][y]
不变
- 因为在第 k 阶段,
-
算法应用
- 用于求取图中任意两点之间的关系
- 多源最短路,任意两点的距离关系
- 图上的传递闭包,任意两点的连通关系
- 复杂度 O ( n 3 ) O(n^3) O(n3)
// n 是点的个数 , dis是距离数据, dis[i][j]: i 与 j 当前的最短距离
void Floyd(int n, int** dis){
for(int k = 1; k <=n; k++)
for(int i = 1; i <= n; i++)
for(int j = 1; j <= n; j++)
dis[i][j] = min(dis[i][j], dis[i][k] + dis[k][j])
}
2.迪杰斯塔拉 Dijkstra
主要用于解决图中没有负边的单源最短路问题,复杂度为 O ( ( n + m ) log n ) O((n + m)\log{n}) O((n+m)logn)
2.1.算法流程
void dijkstra(int s){
priority_queue<pa, vector<pa>, greater<pa>> q; // 优先队列,从小到大排序
for(int i = 1; i <= n; i++) dis[i] = inf, vis[i] = 0; // vis[i] = 1 代表 i 不用再访问了
dis[s] = 0;
q.push(make_pair(0, s)) // 前面是距离,后面是节点
while(!q.empty()){
int x = q.top().second;
q.pop();
if(vis[x]) continue; // 这保证了每个点只会进行下列操作一次
vis[x] = 1;
for(int i = point[x]; i != 0; i = nxt[i]) // 遍历所有邻接节点
if(dis[v[i]] > dis[x] + w[i]){
dis[v[i]] = dis[x] + w[i]; // 松弛
q.push(make_pair(dis[v[i]], v[i]));
}
}
}
2.2.一些解释
-
为什么一个节点会被多次加入堆中?
-
因为我们会通过不同的路走到,而且后面走到的时候,距离可能更小
- 在这个例子中,我们以A为起点,进行松弛操作,会将C放入堆中,此时
dis[A][C] = 7
。同时 B 也放入堆中 - 然后我们会取出 B,这时我们还会再更新 C,并将新的
dis[A][C] = 5
再次放入堆中
- 在这个例子中,我们以A为起点,进行松弛操作,会将C放入堆中,此时
-
-
为什么每个点只会被弹出最小堆一次?
- 其实不是只弹出一次,而是只有一次弹出之后会对其邻接节点做松弛操作
- 因为弹出一次之后,我们令
vis[x] = 1
,下次就会直接continue
了。这样合理么?- 合理。因为当我们弹出
x
的时候,说明,目前dis[x]
是堆中最小的了。我们通过堆中其他节点,不能够以更小的距离再次到达x
。所以,只要我们将x
从堆中弹出,我们就找到了最短的dis[x]
。第二次弹出的时候,没必要再松弛邻居节点了,因为肯定不如第一次松弛时短。 - 这有利于降低算法复杂度,保证只从
x
对其邻接节点进行松弛 1 次。这个操作导致我们不能处理负边
- 合理。因为当我们弹出
-
为什么不能处理负边?:为了降低算法复杂度
- 我们从 A 出发,扩张一次,会将
(10, B)
和(7, C)
放入最小堆中 - 然后取出小的
(7, C)
,并令vis[C] = 1, dis[C] = 7
,扩张一次,令vis[D] = 7 + 3 = 10
- 因此,在我们后面经过
B
走到C
时,会更新vis[C] = 10 - 5 = 5
,并将(5, C)
放入堆 - 我们会有取出
(5, C)
的时候,但是不会利用其进行扩张了,因为vis[C] = 1
。 - 这会导致,我们没有找到
A->B->C->D
这条更短的路,影响了C
之后点的最短距离的更新 - 我们可以删除
vis
,不论有没有弹出过,我们都可以以此为节点,来松弛其邻接节点,这样就可以应对负边。但是这样复杂度会变大。为了保证算法的高效,我们只在所有边权都是正的情况下应用此算法。这样就能保证,当我们弹出一个节点x
时,不会再找到一条到达x
的更短的路,从而避免更新其后面的节点
3.SPFA
3.1.前面两种算法的局限性
- Floyd是求多源最短路的,对于单源最短路来说,太复杂
- Dijkstra在图中存在负权边时,不能保证结果的正确性
3.2.Bellman-Ford算法
我们设初始点为 s
核心思想:我们以随机的顺序进行边的松弛,每次都对所有边进行松弛。如果 s
到 u
的最短路经过 k
条边,那么在第 k
轮松弛后,我们就能找到这条最短路。
- 以上图为例,第一轮松弛,无论我们选择的边的顺序是怎么样的,我们总能找到最短路
s->a
和s->c
。因为他们的最短路都是一条边的 - 我们进行第二轮松弛,能够确定最短路
s->a->b
。因为当我们松弛边ab
时,一定能够将b
的最距离更新到最短。(也有可能一轮就找到,如果我们在松弛ab
之前先松弛过sa
的话,两轮是一定可以) - 同理,如果我们进行第三轮松弛,因为两条边的最距离都已经找到了,当我们松弛任意一条边时,如果这条边是某个点最短路上的边(最短路经过边数为3),那么一定能够找到这个节点的最短路。
- 比如在
b
后面加个c
,因为s->b
的最短路已经找到,所以可以确定s->b + b->c
是s->c
的最短路
- 比如在
- 这样,我们说明了,若
s
到u
的最短路经过k
条边,那么在第k
轮松弛后,我们一定能找到这条最短路。
我们介绍完了核心思想,再给出一个引理:
- 如果一个节点数为n的图中没有负权环,那么其任意两个节点之间一定存在最短路径,且其边数不会超过n-1
这直观上容易理解。
我们默认,图中不存在负权环,那么,松弛 n - 1
轮(因为,最短路的边数不会超过 n - 1
),一定能找到单源最短路
for(int i = 1; i <= n; i++)
dis[i] = INF, pre[i] = 0; // pre[i] 代表是从哪走过来的,i节点的前一个是谁
dis[s] = 0; // 起点距离为 0
for(int k = 1; k < n; k++) // 第 k 轮松弛
for(int i = 1; i <= m; i++) // 对所有边进行松弛
if(dis[v[i]] > dis[u[i]] + w[i]){
dis[v[i]] = dis[u[i]] + w[i]; // 松弛成功
pre[v[i]] = u[i]; // 更新父节点
}
- 时间复杂度 O ( n m ) O(nm) O(nm)
Bellman-ford 算法能够解决负边权问题,但是复杂度比较高
我们注意到,每一轮松弛都有很多无效的松弛操作,因为有些最短路在之前的松弛中已经确定了,不用再松弛了
3.3.SPFA(Shortest Path Faster Algorithm)
通过观察,可得,松弛操作仅仅发生在最短路径前导结点中已经成功松弛过的结点上。因为如果 u
成功松弛了,则 dis[u]
就变小了,通过 u
连接的其他结点的 dis
也会变小(dis[v] = dis[u] + u->v
)
因此,我们每次只做有效的松弛操作
- 建立一个队列
- 队列中存储被成功松弛的点(可用于后面的松弛)
- 每次从队首取点并松弛其邻接点
- 如果邻接点松弛成功则将其放入队列
void spfa(int s){
for(int i = 1; i <= n; i++) vis[i] = 0, dis[i] = inf;
dis[s] = 0; vis[s] = 1;
queue<int> p;
p.push(s);
while(!p.empty()){
int now = p.front(); p.pop();
for(int i = point[now]; i != 0; i = nxt[i])
if(dis[v[i]] > dis[now] + len[i]){
dis[v[i]] = dis[now] + len[i];
pre[v[i]] = now;
if(!vis[v[i]]){ // 如果 v[i] 不在队列中,才放进去
vis[v[i]] = 1;
p.push(v[i]);
}
}
vis[now] = 0;
}
}
- 时间复杂度平均 O ( k m ) O(km) O(km), k是一个小于 n 的常数
- 但是特殊情况下 k 可能很大
- 特殊情况下,会退化到 O ( n m ) O(nm) O(nm)
个人觉得和上文说的去掉 vis
的 Dijkstra
很像,只不过因为有负边的存在,我们不用取队列中的最小值
-
上文取最小值是为了减少复杂度
-
但是这里,比如
dis[u] = 10
,dis[v] = 11
, v 可以通过走负的边来到达 u,我们先取出u
不代表到u
的最短路已经找到了。因此,没必要去最小值
总结起来就是,哪里更新过了(变小了),就可能影响后续的值,我们就要对其邻接结点进行松弛
4.负权环路
有些时候,是无解的
- 无法到达目标点,即
dis = INF
- 路径上存在负环,
dis
是负无穷
前面一种情况是容易判断的,我们如何判断图中存在负环呢?
对于 Bellman-ford 来说,若松弛完 n - 1
轮后,在第 n
轮松弛时,还有边能够被成功松弛,则有负环。
对于 SPFA,我们用 cnt[x]
来表示当前到达 x
的最短路的边数,如果 cnt[x] >= n
,则存在负环。
void spfa(int s){
for(int i = 1; i <= n; i++) vis[i] = 0, dis[i] = inf, cnt[i] = 0;
dis[s] = 0; vis[s] = 1;
queue<int> p;
p.push(s);
while(!p.empty()){
int now = p.front(); p.pop();
for(int i = point[now]; i != 0; i = nxt[i])
if(dis[v[i]] > dis[now] + len[i]){
dis[v[i]] = dis[now] + len[i];
cnt[v[i]] = cnt[now] + 1;
if(cnt[v[i]] >= n){
// 有负环
}
pre[v[i]] = now;
if(!vis[v[i]]){ // 如果 v[i] 不在队列中,才放进去
vis[v[i]] = 1;
p.push(v[i]);
}
}
vis[now] = 0;
}
}