学习笔记:最短路

最短路

Floyd 算法

  • 是用来求任意两个结点之间的最短路的。

  • 复杂度比较高,但是常数小,容易实现。(我会说只有三个 for 吗?)

  • 适用于任何图,不管有向无向,边权正负,但是最短路必须存在。(不能有负环)

实现

我们定义一个数组 f[k][i][j],表示只允许经过结点 1k(也就是说,在子图 V=1,2,,k 中的路径,注意,xy 不一定在这个子图中),结点 x 到结点 y 的最短路长度。

很显然,f[n][i][j] 就是结点 x 到结点 y 的最短路长度(因为 V=1,2,,n 即为 V 本身,其表示的最短路径就是所求路径)。

接下来考虑如何求出 f 数组的值。

f[0][i][j]xy 的边权,或者 0,或者 +f[0][i][j] 什么时候应该是 +?当 xy 间有直接相连的边的时候,为它们的边权;当 x=y 的时候为零,因为到本身的距离为零;当 xy 没有直接相连的边的时候,为 +)。

f[k][i][j] = min(f[k - 1][i][j], f[k - 1][i][k] + f[k - 1][k][j])f[k-1][i][j],为不经过 k 点的最短路径,而 f[k-1][i][k] + f[k-1][k][j],为经过了 k 点的最短路)。

上面两行都显然是对的,所以说这个做法空间是 O(n3),我们需要依次增加问题规模(k1n),判断任意两点在当前问题规模下的最短路。

for(int k = 1 ; k <= n ; k ++)
    for(int i = 1 ; i <= n ; i ++)
        for(int j = 1 ; j <= n ; j ++)
            f[k][i][j] = min(f[k - 1][i][j], f[k - 1][i][k] + f[k - 1][k][j]);

因为第一维对结果无影响,我们可以发现数组的第一维是可以省略的,于是可以直接改成 f[i][j] = min(f[i][j], f[i][k] + f[k][j])

证明:

注意到如果放在一个给定第一维 k 二维数组中,f[i][k]f[k][j] 在某一行和某一列。而 f[i][j] 则是该行和该列的交叉点上的元素。

现在我们需要证明将 f[k][i][j] 直接在原地更改也不会更改它的结果:我们注意到 f[k][i][j] 的含义是第一维为 k-1 这一行和这一列的所有元素的最小值,包含了 f[k-1][i][j],那么我在原地进行更改也不会改变最小值的值,因为如果将该三维矩阵压缩为二维,则所求结果 f[i][j] 一开始即为原 f[k-1][i][j] 的值,最后依然会成为该行和该列的最小值。

故可以压缩。

for(int k = 1 ; k <= n ; k ++)
    for(int i = 1 ; i <= n ; i ++)
        for(int j = 1 ; j <= n ; j ++)
            f[i][j] = min(f[i][j], f[k - 1][i][k] + f[k][j]);

综上时间复杂度是 O(n3),空间复杂度是 O(n2)

Bellman–Ford 算法

Bellman–Ford 算法是一种基于松弛(relax)操作的最短路算法,可以求出有负权的图的最短路,并可以对最短路不存在的情况进行判断。

在国内 OI 界,你可能听说过的「SPFA」,就是 Bellman–Ford 算法的一种实现(准确来说,SPFA 在国外通常被称为 队列优化的 Bellman–Ford)。

过程

先介绍 Bellman–Ford 算法要用到的松弛操作(Dijkstra 算法也会用到松弛操作)。

对于边 (u,v),松弛操作对应下面的式子:dis(v)=min(dis(v),dis(u)+w(u,v))

这么做的含义是显然的:我们尝试用 Suv(其中 Su 的路径取最短路)这条路径去更新 v 点最短路的长度,如果这条路径更优,就进行更新。

Bellman–Ford 算法所做的,就是不断尝试对图上每一条边进行松弛。我们每进行一轮循环,就对图上所有的边都尝试进行一次松弛操作,当一次循环中没有成功的松弛操作时,算法停止。

每次循环是 O(m) 的,那么最多会循环多少次呢?

在最短路存在的情况下,由于一次松弛操作会使最短路的边数至少 +1,而最短路的边数最多为 n1,因此整个算法最多执行 n1 轮松弛操作。故总时间复杂度为 O(nm)

但还有一种情况,如果从 S 点出发,抵达一个负环时,松弛操作会无休止地进行下去。注意到前面的论证中已经说明了,对于最短路存在的图,松弛操作最多只会执行 n1 轮,因此如果第 n 轮循环时仍然存在能松弛的边,说明从 S 点出发,能够抵达一个负环。

需要注意的是,以 S 点为源点跑 Bellman–Ford 算法时,如果没有给出存在负环的结果,只能说明从 S 点出发不能抵达一个负环,而不能说明图上不存在负环。因此如果需要判断整个图上是否存在负环,最严谨的做法是建立一个超级源点,向图上每个节点连一条权值为 0 的边,然后以超级源点为起点执行 Bellman–Ford 算法。

队列优化:SPFA

很多时候我们并不需要那么多无用的松弛操作。

很显然,只有上一次被松弛的结点,所连接的边,才有可能引起下一次的松弛操作。

那么我们用队列来维护「哪些结点可能会引起松弛操作」,就能只访问必要的边了。

SPFA 也可以用于判断 s 点是否能抵达一个负环,只需记录最短路经过了多少条边,当经过了至少 n 条边时,说明 s 点可以抵达一个负环。

#include <iostream>
#include <cstring>
#include <queue>
#define MAXN 10005
#define MAXM 500005
#define INF 0x7fffffff
using namespace std;
int n, m, s, u, v, w;
struct edge{int w, to, nxt;}e[MAXM];
int head[MAXN], cnt = 1, dis[MAXN];
bool vis[MAXN];
queue <int> q;
int read(){
    int t = 1, x = 0;char ch = getchar();
    while(!isdigit(ch)){if(ch == '-')t = -1;ch = getchar();}
    while(isdigit(ch)){x = (x << 1) + (x << 3) + (ch ^ 48);ch = getchar();}
    return x * t;
}
void write(int x){
    if(x < 0){putchar('-');x = -x;}
    if(x >= 10)write(x / 10);
    putchar(x % 10 ^ 48);
}
void add(int u, int v, int w){
    cnt++;e[cnt].w = w;e[cnt].to = v;e[cnt].nxt = head[u];head[u] = cnt;
}
int main(){
    n = read();m = read();s = read();
    for(int i = 1 ; i <= m ; i ++){
        u = read();v = read();
        w = read();add(u, v, w);
    }
    memset(dis, INF, sizeof(dis));
    dis[s] = 0;vis[s] = true;q.push(s);
    while(!q.empty()){
        u = q.front();q.pop();vis[u] = false;
        for(int i = head[u] ; i != 0 ; i = e[i].nxt){
            v = e[i].to;w = e[i].w;
            if(dis[v] > dis[u] + w){
                dis[v] = dis[u] + w;
                if(vis[v] == false)
                    vis[v] = true,q.push(v);
            }
        }
    }
    for(int i = 1 ; i <= n ; i ++){
        if(i != 1)putchar(' ');
        write(dis[i]);
    }
    return 0;
}

虽然在大多数情况下 SPFA 跑得很快,但其最坏情况下的时间复杂度为 O(nm),将其卡到这个复杂度也是不难的,所以考试时要谨慎使用(在没有负权边时最好使用 Dijkstra 算法,在有负权边且题目中的图没有特殊性质时,若 SPFA 是标算的一部分,题目不应当给出 Bellman–Ford 算法无法通过的数据范围)。

Dijkstra 算法

Dijkstra 算法由荷兰计算机科学家 E. W. Dijkstra 于 1956 年发现,1959 年公开发表。是一种求解 非负权图 上单源最短路径的算法。

过程

将结点分成两个集合:已确定最短路长度的点集(记为 S 集合)的和未确定最短路长度的点集(记为 T 集合)。一开始所有的点都属于 T 集合。

初始化 dis(s)=0,其他点的 dis 均为 +

然后重复这些操作:

  1. T 集合中,选取一个最短路长度最小的结点,移到 S 集合中。
  2. 对那些刚刚被加入 S 集合的结点的所有出边执行松弛操作。

直到 T 集合为空,算法结束。

时间复杂度

有多种方法来维护 1 操作中最短路长度最小的结点,不同的实现导致了 Dijkstra 算法时间复杂度上的差异。

  • 暴力:不使用任何数据结构进行维护,每次 2 操作执行完毕后,直接在 T 集合中暴力寻找最短路长度最小的结点。2 操作总时间复杂度为 O(m),1 操作总时间复杂度为 O(n2),全过程的时间复杂度为 O(n2+m)=O(n2)
  • 二叉堆:每成功松弛一条边 (u,v),就将 v 插入二叉堆中(如果 v 已经在二叉堆中,直接修改相应元素的权值即可),1 操作直接取堆顶结点即可。共计 O(m) 次二叉堆上的插入(修改)操作,O(n) 次删除堆顶操作,而插入(修改)和删除的时间复杂度均为 O(logn),时间复杂度为 O((n+m)logn)=O(mlogn)
  • 优先队列:和二叉堆类似,但使用优先队列时,如果同一个点的最短路被更新多次,因为先前更新时插入的元素不能被删除,也不能被修改,只能留在优先队列中,故优先队列内的元素个数是 O(m) 的,时间复杂度为 O(mlogm)
  • Fibonacci 堆:和前面二者类似,但 Fibonacci 堆插入的时间复杂度为 O(1),故时间复杂度为 O(nlogn+m),时间复杂度最优。但因为 Fibonacci 堆较二叉堆不易实现,效率优势也不够大,算法竞赛中较少使用。
  • 线段树:和二叉堆原理类似,不过将每次成功松弛后插入二叉堆的操作改为在线段树上执行单点修改,而 1 操作则是线段树上的全局查询最小值。时间复杂度为 O(mlogn)

在稀疏图中,m=O(n),使用二叉堆实现的 Dijkstra 算法较 Bellman–Ford 算法具有较大的效率优势;而在稠密图中,m=O(n2),这时候使用暴力做法较二叉堆实现更优。

正确性证明

下面用数学归纳法证明,在 所有边权值非负 的前提下,Dijkstra 算法的正确性。

简单来说,我们要证明的,就是在执行 1 操作时,取出的结点 u 最短路均已经被确定,即满足 D(u)=dis(u)

初始时 S=,假设成立。

接下来用反证法。

u 点为算法中第一个在加入 S 集合时不满足 D(u)=dis(u) 的点。因为 s 点一定满足 D(u)=dis(u)=0,且它一定是第一个加入 S 集合的点,因此将 u 加入 S 集合前,S,如果不存在 su 的路径,则 D(u)=dis(u)=+,与假设矛盾。

于是一定存在路径 sxyu,其中 ysu 路径上第一个属于 T 集合的点,而 xy 的前驱结点(显然 xS)。需要注意的是,可能存在 s=xy=u 的情况,即 sxyu 可能是空路径。

因为在 u 结点之前加入的结点都满足 D(u)=dis(u),所以在 x 点加入到 S 集合时,有 D(x)=dis(x),此时边 (x,y) 会被松弛,从而可以证明,将 u 加入到 S 时,一定有 D(y)=dis(y)

下面证明 D(u)=dis(u) 成立。在路径 sxyu 中,因为图上所有边边权非负,因此 D(y)D(u)。从而 dis(y)D(y)D(u)dis(u)。但是因为 u 结点在 1 过程中被取出 T 集合时,y 结点还没有被取出 T 集合,因此此时有 dis(u)dis(y),从而得到 dis(y)=D(y)=D(u)=dis(u),这与 D(u)dis(u) 的假设矛盾,故假设不成立。

因此我们证明了,1 操作每次取出的点,其最短路均已经被确定。命题得证。

注意到证明过程中的关键不等式 D(y)D(u) 是在图上所有边边权非负的情况下得出的。当图上存在负权边时,这一不等式不再成立,Dijkstra 算法的正确性将无法得到保证,算法可能会给出错误的结果。

实现

这里给出 O(mlogm) 的优先队列做法实现。

#include <iostream>
#include <cstring>
#include <queue>
#define MAXN 100005
#define MAXM 200005
#define INF 0x7fffffffffffffff
using namespace std;
int n, m, s, u, v, w;
struct edge{int w, to, nxt;}e[MAXM];
int head[MAXN], cnt;
bool vis[MAXN];
long long dis[MAXN];
struct node{
    int x, w;
    bool operator<(node &a){
        return w > a.w;
    }
};
priority_queue <node> q;
int read(){
    int t = 1, x = 0;char ch = getchar();
    while(!isdigit(ch)){if(ch == '-')t = -1;ch = getchar();}
    while(isdigit(ch)){x = (x << 1) + (x << 3) + (ch ^ 48);ch = getchar();}
    return x * t;
}
void write(int x){
    if(x < 0){putchar('-');x = -x;}
    if(x >= 10)write(x / 10);
    putchar(x % 10 + '0');
}
void add(int u, int v, int w){
    cnt++;e[cnt].w = w;e[cnt].to = v;e[cnt].nxt = head[u];head[u] = cnt;
}
int main(){
    n = read();m = read();s = read();
    for(int i = 1 ; i <= m ; i ++){
        u = read();v = read();
        w = read();add(u, v, w);
    }
    memset(dis, INF, sizeof(dis));
    dis[s] = 0;q.push((node){s, 0});
    while(!q.empty()){
        node u = q.top();q.pop();
        if(vis[u.x] == true)continue;
        vis[u.x] = true;
        for(int i = head[u.x] ; i != 0 ; i = e[i].nxt){
            if(dis[e[i].to] > dis[u.x] + e[i].w){
                dis[e[i].to] = dis[u.x] + e[i].w;
                q.push((node){e[i].to, dis[e[i].to]});
            }
        }
    }
    for(int i = 1 ; i <= n ; i ++){
        if(i != 1)putchar(' ');
        write(dis[i]);
    }
    putchar('\n');return 0;
}

Johnson 全源最短路径算法

Johnson 和 Floyd 一样,是一种能求出无负环图上任意两点间最短路径的算法。该算法在 1977 年由 Donald B. Johnson 提出。

任意两点间的最短路可以通过枚举起点,跑 n 次 Bellman–Ford 算法解决,时间复杂度是 O(n2m) 的,也可以直接用 Floyd 算法解决,时间复杂度为 O(n3)

注意到堆优化的 Dijkstra 算法求单源最短路径的时间复杂度比 Bellman–Ford 更优,如果枚举起点,跑 n 次 Dijkstra 算法,就可以在 O(nmlogm)(取决于 Dijkstra 算法的实现)的时间复杂度内解决本问题,比上述跑 n 次 Bellman–Ford 算法的时间复杂度更优秀,在稀疏图上也比 Floyd 算法的时间复杂度更加优秀。

但 Dijkstra 算法不能正确求解带负权边的最短路,因此我们需要对原图上的边进行预处理,确保所有边的边权均非负。

一种容易想到的方法是给所有边的边权同时加上一个正数 x,从而让所有边的边权均非负。如果新图上起点到终点的最短路经过了 k 条边,则将最短路减去 kx 即可得到实际最短路。

但这样的方法是错误的。考虑下图:

12 的最短路为 1532,长度为 2

但假如我们把每条边的边权加上 5 呢?

新图上 12 的最短路为 142,已经不是实际的最短路了。

Johnson 算法则通过另外一种方法来给每条边重新标注边权。

我们新建一个虚拟节点(在这里我们就设它的编号为 0)。从这个点向其他所有点连一条边权为 0 的边。

接下来用 Bellman–Ford 算法求出从 0 号点到其他所有点的最短路,记为 hi

假如存在一条从 u 点到 v 点,边权为 w 的边,则我们将该边的边权重新设置为 w+huhv

接下来以每个点为起点,跑 n 轮 Dijkstra 算法即可求出任意两点间的最短路了。

一开始的 Bellman–Ford 算法并不是时间上的瓶颈,若使用 priority_queue 实现 Dijkstra 算法,该算法的时间复杂度是 O(nmlogm)

正确性证明

为什么这样重新标注边权的方式是正确的呢?

在讨论这个问题之前,我们先讨论一个物理概念——势能。

诸如重力势能,电势能这样的势能都有一个特点,势能的变化量只和起点和终点的相对位置有关,而与起点到终点所走的路径无关。

势能还有一个特点,势能的绝对值往往取决于设置的零势能点,但无论将零势能点设置在哪里,两点间势能的差值是一定的。

接下来回到正题。

在重新标记后的图上,从 s 点到 t 点的一条路径 sp1p2pkt 的长度表达式如下:

(w(s,p1)+hshp1)+(w(p1,p2)+hp1hp2)++(w(pk,t)+hpkht)

化简后得到:

w(s,p1)+w(p1,p2)++w(pk,t)+hsht

无论我们从 st 走的是哪一条路径,hsht 的值是不变的,这正与势能的性质相吻合!

为了方便,下面我们就把 hi 称为 i 点的势能。

上面的新图中 st 的最短路的长度表达式由两部分组成,前面的边权和为原图中 st 的最短路,后面则是两点间的势能差。因为两点间势能的差为定值,因此原图上 st 的最短路与新图上 st 的最短路相对应。

到这里我们的正确性证明已经解决了一半——我们证明了重新标注边权后图上的最短路径仍然是原来的最短路径。接下来我们需要证明新图中所有边的边权非负,因为在非负权图上,Dijkstra 算法能够保证得出正确的结果。

根据三角形不等式,图上任意一边 (u,v) 上两点满足:hvhu+w(u,v)。这条边重新标记后的边权为 w(u,v)=w(u,v)+huhv0。这样我们证明了新图上的边权均非负。

这样,我们就证明了 Johnson 算法的正确性。

不同方法的比较

最短路算法 Floyd Bellman–Ford Dijkstra Johnson
最短路类型 每对结点之间的最短路 单源最短路 单源最短路 每对结点之间的最短路
作用于 任意图 任意图 非负权图 任意图
能否检测负环? 不能
时间复杂度 O(n3) O(nm) O(mlogm) O(nmlogm)

输出方案

开一个 pre 数组,在更新距离的时候记录下来后面的点是如何转移过去的,算法结束前再递归地输出路径即可。

比如 Floyd 就要记录 pre[i][j] = k;,Bellman–Ford 和 Dijkstra 一般记录 pre[v] = u

posted @   tsqtsqtsq  阅读(14)  评论(0编辑  收藏  举报  
相关博文:
阅读排行:
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· 单线程的Redis速度为什么快?
· SQL Server 2025 AI相关能力初探
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 展开说说关于C#中ORM框架的用法!
点击右上角即可分享
微信分享提示