(还记得这些定义吗?在阅读下列内容之前,请务必了解 图论相关概念 中的基础部分。)
对于边权为正的图,任意两个结点之间的最短路,不会经过重复的结点。
对于边权为正的图,任意两个结点之间的最短路,不会经过重复的边。
对于边权为正的图,任意两个结点之间的最短路,任意一条的结点数不会超过 ,边数不会超过 。
为了方便叙述,这里先给出下文将会用到的一些记号的含义。
是用来求任意两个结点之间的最短路的。
复杂度比较高,但是常数小,容易实现。(我会说只有三个 for
吗?)
适用于任何图,不管有向无向,边权正负,但是最短路必须存在。(不能有个负环)
我们定义一个数组 f[k][x][y]
,表示只允许经过结点 到 (也就是说,在子图 中的路径,注意, 与 不一定在这个子图中),结点 到结点 的最短路长度。
(还记得这些定义吗?在阅读下列内容之前,请务必了解 图论相关概念 中的基础部分。)
对于边权为正的图,任意两个结点之间的最短路,不会经过重复的结点。
对于边权为正的图,任意两个结点之间的最短路,不会经过重复的边。
对于边权为正的图,任意两个结点之间的最短路,任意一条的结点数不会超过 ,边数不会超过 。
为了方便叙述,这里先给出下文将会用到的一些记号的含义。
是用来求任意两个结点之间的最短路的。
复杂度比较高,但是常数小,容易实现。(我会说只有三个 for
吗?)
适用于任何图,不管有向无向,边权正负,但是最短路必须存在。(不能有个负环)
我们定义一个数组 f[k][x][y]
,表示只允许经过结点 到 (也就是说,在子图 中的路径,注意, 与 不一定在这个子图中),结点 到结点 的最短路长度。
很显然,f[n][x][y]
就是结很显然,f[n][x][y]
就是结点 到结点 的最短路长度(因为 即为 本身,其表示的最短路径就是所求路径)。
接下来考虑如何求出 f
数组的值。
f[0][x][y]
: 与 的边权,或者 ,或者 (f[0][x][y]
什么时候应该是 ?当 与 间有直接相连的边的时候,为它们的边权;当 的时候为零,因为到本身的距离为零;当 与 没有直接相连的边的时候,为 )。
f[k][x][y] = min(f[k-1][x][y], f[k-1][x][k]+f[k-1][k][y])
(f[k-1][x][y]
,为不经过 点的最短路径,而 f[k-1][x][k]+f[k-1][k][y]
,为经过了 点的最短路)。
上面两行都显然是对的,所以说这个做法空间是 ,我们需要依次增加问题规模( 从 到 ),判断任意两点在当前问题规模下的最短路。
1 2 3 4 5 6 7 8 |
// C++ Version for (k = 1; k <= n; k++) { for (x = 1; x <= n; x++) { for (y = 1; y <= n; y++) { f[k][x][y] = min(f[k - 1][x][y], f[k - 1][x][k] + f[k - 1][k][y]); } } } |
---|---|
1 2 3 4 5 |
# Python Version for k in range(1, n): for x in range(1, n): for y in range(1, n): f[k][x][y] = min(f[k - 1][x][y], f[k - 1][x][k] + f[k - 1][k][y]) |
---|---|
因为第一维对结果无影响,我们可以发现数组的第一维是可以省略的,于是可以直接改成 f[x][y] = min(f[x][y], f[x][k]+f[k][y])
。
我们注意到如果放在一个给定第一维 k
二维数组中,f[x][k]
与 f[k][y]
在某一行和某一列。而 f[x][y]
则是该行和该列的交叉点上的元素。
现在我们需要证明将 f[k][x][y]
直接在原地更改也不会更改它的结果:我们注意到 f[k][x][y]
的涵义是第一维为 k-1
这一行和这一列的所有元素的最小值,包含了 f[k-1][x][y]
,那么我在原地进行更改也不会改变最小值的值,因为如果将该三维矩阵压缩为二维,则所求结果 f[x][y]
一开始即为原 f[k-1][x][y]
的值,最后依然会成为该行和该列的最小值。
故可以压缩。
1 2 3 4 5 6 7 8 |
// C++ Version for (k = 1; k <= n; k++) { for (x = 1; x <= n; x++) { for (y = 1; y <= n; y++) { f[x][y] = min(f[x][y], f[x][k] + f[k][y]); } } } |
---|---|
1 2 3 4 5 |
# Python Version for k in range(1, n): for x in range(1, n): for y in range(1, n): f[x][y] = min(f[x][y], f[x][k] + f[k][y]) |
---|---|
综上时间复杂度是 ,空间复杂度是 。
首先这一定是一个简单环。
想一想这个环是怎么构成的。
考虑环上编号最大的结点 u。
f[u-1][x][y]
和 (u,x), (u,y)共同构成了环。
在 Floyd 的过程中枚举 u,计算这个和的最小值即可。
时间复杂度为 。
该问题即是求 图的传递闭包。
我们只需要按照 Floyd 的过程,逐个加入点判断一下。
只是此时的边的边权变为 ,而取 变成了 或 运算。
再进一步用 bitset 优化,复杂度可以到 。
Bellman-Ford 算法是一种基于松弛(relax)操作的最短路算法,可以求出有负权的图的最短路,并可以对最短路不存在的情况进行判断。
在国内 OI 界,你可能听说过的“SPFA”,就是 Bellman-Ford 算法的一种实现。
先介绍 Bellman-Ford 算法要用到的松弛操作(Djikstra 算法也会用到松弛操作)。
对于边 ,松弛操作对应下面的式子:。
这么做的含义是显然的:我们尝试用 (其中 的路径取最短路)这条路径去更新 点最短路的长度,如果这条路径更优,就进行更新。
Bellman-Ford 算法所做的,就是不断尝试对图上每一条边进行松弛。我们每进行一轮循环,就对图上所有的边都尝试进行一次松弛操作,当一次循环中没有成功的松弛操作时,算法停止。
每次循环是 的,那么最多会循环多少次呢?
在最短路存在的情况下,由于一次松弛操作会使最短路的边数至少 ,而最短路的边数最多为 ,因此整个算法最多执行 轮松弛操作。故总时间复杂度为 。
但还有一种情况,如果从 点出发,抵达一个负环时,松弛操作会无休止地进行下去。注意到前面的论证中已经说明了,对于最短路存在的图,松弛操作最多只会执行 轮,因此如果第 轮循环时仍然存在能松弛的边,说明从 点出发,能够抵达一个负环。
需要注意的是,以 点为源点跑 Bellman-Ford 算法时,如果没有给出存在负环的结果,只能说明从 点出发不能抵达一个负环,而不能说明图上不存在负环。
因此如果需要判断整个图上是否存在负环,最严谨的做法是建立一个超级源点,向图上每个节点连一条权值为 0 的边,然后以超级源点为起点执行 Bellman-Ford 算法。
即 Shortest Path Faster Algorithm。
很多时候我们并不需要那么多无用的松弛操作。
很显然,只有上一次被松弛的结点,所连接的边,才有可能引起下一次的松弛操作。
那么我们用队列来维护“哪些结点可能会引起松弛操作”,就能只访问必要的边了。
SPFA 也可以用于判断 点是否能抵达一个负环,只需记录最短路经过了多少条边,当经过了至少 条边时,说明 点可以抵达一个负环。
虽然在大多数情况下 SPFA 跑得很快,但其最坏情况下的时间复杂度为 ,将其卡到这个复杂度也是不难的,所以考试时要谨慎使用(在没有负权边时最好使用 Dijkstra 算法,在有负权边且题目中的图没有特殊性质时,若 SPFA 是标算的一部分,题目不应当给出 Bellman-Ford 算法无法通过的数据范围)。
除了队列优化(SPFA)之外,Bellman-Ford 还有其他形式的优化,这些优化在部分图上效果明显,但在某些特殊图上,最坏复杂度可能达到指数级。
更多优化以及针对这些优化的 Hack 方法,可以看 fstqwq 在知乎上的回答。
Dijkstra(/ˈdikstrɑ/或/ˈdɛikstrɑ/)算法由荷兰计算机科学家 E. W. Dijkstra 于 1956 年发现,1959 年公开发表。是一种求解 非负权图 上单源最短路径的算法。
将结点分成两个集合:已确定最短路长度的点集(记为 集合)的和未确定最短路长度的点集(记为 集合)。一开始所有的点都属于 集合。
初始化 ,其他点的 均为 。
然后重复这些操作:
直到 集合为空,算法结束。
有多种方法来维护 1 操作中最短路长度最小的结点,不同的实现导致了 Dijkstra 算法时间复杂度上的差异。
在稀疏图中,,使用二叉堆实现的 Dijkstra 算法较 Bellman-Ford 算法具有较大的效率优势;而在稠密图中,,这时候使用暴力做法较二叉堆实现更优。
下面用数学归纳法证明,在 所有边权值非负 的前提下,Dijkstra 算法的正确性2。
简单来说,我们要证明的,就是在执行 1 操作时,取出的结点 最短路均已经被确定,即满足 。
初始时 ,假设成立。
接下来用反证法。
设 点为算法中第一个在加入 集合时不满足 的点。因为 点一定满足 ,且它一定是第一个加入 集合的点,因此将 加入 集合前,,如果不存在 到 的路径,则 ,与假设矛盾。
于是一定存在路径 ,其中 为 路径上第一个属于 集合的点,而 为 的前驱结点(显然 )。需要注意的是,可能存在 或 的情况,即 或 可能是空路径。
因为在 结点之前加入的结点都满足 ,所以在 点加入到 集合时,有 ,此时边 会被松弛,从而可以证明,将 加入到 时,一定有 。
下面证明 成立。在路径 中,因为图上所有边边权非负,因此 。从而 。但是因为 结点在 1 过程中被取出 集合时, 结点还没有被取出 集合,因此此时有 ,从而得到 ,这与 的假设矛盾,故假设不成立。
因此我们证明了,1 操作每次取出的点,其最短路均已经被确定。命题得证。
注意到证明过程中的关键不等式 是在图上所有边边权非负的情况下得出的。当图上存在负权边时,这一不等式不再成立,Dijkstra 算法的正确性将无法得到保证,算法可能会给出错误的结果。
这里同时给出 的暴力做法实现和 的优先队列做法实现。
Johnson 和 Floyd 一样,是一种能求出无负环图上任意两点间最短路径的算法。该算法在 1977 年由 Donald B. Johnson 提出。
任意两点间的最短路可以通过枚举起点,跑 次 Bellman-Ford 算法解决,时间复杂度是 的,也可以直接用 Floyd 算法解决,时间复杂度为 。
注意到堆优化的 Dijkstra 算法求单源最短路径的时间复杂度比 Bellman-Ford 更优,如果枚举起点,跑 次 Dijkstra 算法,就可以在 (取决于 Dijkstra 算法的实现)的时间复杂度内解决本问题,比上述跑 次 Bellman-Ford 算法的时间复杂度更优秀,在稀疏图上也比 Floyd 算法的时间复杂度更加优秀。
但 Dijkstra 算法不能正确求解带负权边的最短路,因此我们需要对原图上的边进行预处理,确保所有边的边权均非负。
一种容易想到的方法是给所有边的边权同时加上一个正数 ,从而让所有边的边权均非负。如果新图上起点到终点的最短路经过了 条边,则将最短路减去 即可得到实际最短路。
但这样的方法是错误的。考虑下图:
的最短路为 ,长度为 。
但假如我们把每条边的边权加上 呢?
新图上 的最短路为 ,已经不是实际的最短路了。
Johnson 算法则通过另外一种方法来给每条边重新标注边权。
我们新建一个虚拟节点(在这里我们就设它的编号为 )。从这个点向其他所有点连一条边权为 的边。
接下来用 Bellman-Ford 算法求出从 号点到其他所有点的最短路,记为 。
假如存在一条从 点到 点,边权为 的边,则我们将该边的边权重新设置为 。
接下来以每个点为起点,跑 轮 Dijkstra 算法即可求出任意两点间的最短路了。
一开始的 Bellman-Ford 算法并不是时间上的瓶颈,若使用 priority_queue
实现 Dijkstra 算法,该算法的时间复杂度是 。
为什么这样重新标注边权的方式是正确的呢?
在讨论这个问题之前,我们先讨论一个物理概念——势能。
诸如重力势能,电势能这样的势能都有一个特点,势能的变化量只和起点和终点的相对位置有关,而与起点到终点所走的路径无关。
势能还有一个特点,势能的绝对值往往取决于设置的零势能点,但无论将零势能点设置在哪里,两点间势能的差值是一定的。
接下来回到正题。
在重新标记后的图上,从 点到 点的一条路径 的长度表达式如下:
化简后得到:
无论我们从 到 走的是哪一条路径, 的值是不变的,这正与势能的性质相吻合!
为了方便,下面我们就把 称为 点的势能。
上面的新图中 的最短路的长度表达式由两部分组成,前面的边权和为原图中 的最短路,后面则是两点间的势能差。因为两点间势能的差为定值,因此原图上 的最短路与新图上 的最短路相对应。
到这里我们的正确性证明已经解决了一半——我们证明了重新标注边权后图上的最短路径仍然是原来的最短路径。接下来我们需要证明新图中所有边的边权非负,因为在非负权图上,Dijkstra 算法能够保证得出正确的结果。
根据三角形不等式,图上任意一边 上两点满足:。这条边重新标记后的边权为 。这样我们证明了新图上的边权均非负。
这样,我们就证明了 Johnson 算法的正确性。
最短路算法 | Floyd | Bellman-Ford | Dijkstra | Johnson |
---|---|---|---|---|
最短路类型 | 每对结点之间的最短路 | 单源最短路 | 单源最短路 | 每对结点之间的最短路 |
作用于 | 任意图 | 任意图 | 非负权图 | 任意图 |
能否检测负环? | 能 | 能 | 不能 | 能 |
推荐作用图的大小 | 小 | 中/小 | 大/中 | 大/中 |
时间复杂度 |
注:表中的 Dijkstra 算法在计算复杂度时均用 priority_queue
实现。