本文讲解图中点与点之间的最短路径问题,主要讲解三个算法:
- Dijkstra 算法
- Bellman-Ford 算法
- Floyed 算法
其中 Dijkstra 与 Bellman-Ford 是解决单源最短路径问题,即它只能求解某节点到其他节点的最短路径,而 Floyed 算法能够求解所有节点之间的最短路径。
Dijkstra 算法
Dijkstra 算法它能够求解单源最短路径问题,并且使用该算法有一个前提,图中不能存在负权边,即图中的每条边的权值都必须是大于等于 0 的。
Dijkstra 算法使用一个 dis
数组来表示从源 s 到其他节点的最短距离,例如 dis[v]
表示的就是源 s 到达节点 v
的最短距离,在初始时,除了 dis[s]
为 0,其余的 dis[v]
均为无穷大。
Dijkstra 算法的思想是,每次从未遍历的节点,寻找在 dis 数组中值最小的节点,然后根据这个节点更新其未被遍历过的相邻节点在 dis
数组中的值。例如遍历到节点 v,其相邻节点为 w,并且节点 w 未被遍历过,如果 dis[v] + weight(v,w) < dis[w]
,那么就更新 dis[w]
,其中 weight(v, w)
是边 v-w
的权值。
举个例子,假设源为节点 0,我们求解节点 0 到各个节点的最短路径
初始节点 0 到节点 0 的距离为 0,其余节点距离节点 0 的距离为 $\infin$。根据 Dijkstra 算法,每次从未遍历的节点,寻找在 dis
数组中值最小的节点,所以此时我们找到了节点 0,dis[0]
为 0,比其他元素小。我们来到节点 0,根据公式 dis[0] + weight(0, w) < dis[w]
更新与 0 相邻的节点 w
继续从未遍历的节点,寻找在 dis
数组中值最小的节点,是节点 2,它的值为 1,并且此时我们可以断定节点 0 离节点 2 的最短距离就是 1。
为什么呢? 假设存在一条更短的路径从 0 到 2,例如是 0-1-2,但是因为此时 0-2 是最小的,而图中又不存在负权边,所以 0-1-2 比 0-1 还大,也就是比 0-2 大,所以 0-2 就是从 0 到 2 的最短路径。来到节点 2 之后,根据条件 dis[2] + weight(2, w) < dis[w]
是否成立更新其相邻节点。
继续从未遍历的节点,寻找在 dis
数组中值最小的节点,此时 dis[1]
与 dis[6]
的值都是 2,随便选一个就可以,这里我选择节点 1,并且此时可以判定节点 0 到节点 1 的最短路径就是 dis[1]
为 2。为什么?
假设存在一条路径经过未遍历的节点从 0 到达节点 1,比如是节点 4 好了,因为 dis[1] 此时是未遍历的节点中最小的,所以 dis[1] < dis[4]
,更别说还要加上节点 4 到节点 1 的距离,矛盾,所以找不到一条经过未遍历节点来到节点 1。
那么是否存在一条路径只经过已经遍历过的节点到达节点 1,并且路径比 dis[1] 小,不妨假设存在这么一条路径 0 -> ... -> s -> 1
,它的值比 dis[1]
小,即 dis[s] + weight(s, 1) < dis[1]
,那么当我们之前遍历到节点 s
对相邻节点更新的时候就会更新 dis[1]
为更小的值,也产生了矛盾,因此也不存在一条路径只经过已经遍历过的节点到达节点 1。所以我们可以说此时的 dis[1]
就是从 0 到 1 的最短路径。
来到节点 1 之后,更新其未被遍历过的相邻节点的值,当然图中节点 1 此时不存在未被遍历过的相邻节点,所以不进行任何的更新
继续从未遍历的节点,寻找在 dis
数组中值最小的节点,找到节点 6,此时 dis[6]
的值为 2,并且此时我们可以判定从节点 0 到节点 6 的最短距离就是 2。
分析方法同上,先假设存在一条从未被遍历过的节点到达节点 6 得到的更短路径,但是因为 dis[6]
已经是从 0 到未被遍历的节点最小的,图中又不存在负权边,所以矛盾,再做假设存在只经过已被遍历过的节点到达节点 6,那么在之前 dis[6]
的值就会被更新为更小的值,也会产生矛盾,从而得到结论,不存在另外一条路径从 0 到 6 更短,此时 dis[6]
就是从 0 到 6 的最短距离。
来到节点 6 之后根据条件更新其未被遍历的相邻节点,图中节点 6 不存在未被遍历过的相邻节点,因此不作更新
继续从未遍历的节点,寻找在 dis
数组中值最小的节点,发现 dis[3]
和 dis[4]
都是 3,我们就随便选择一个,这里选择节点 3,并且此时可以判定 dis[3]
是从节点 0 到节点 3 的最短距离,已经分析过多次,不再分析。
来到节点 3 之后,根据条件更新其未被遍历过的相邻节点,与节点 3 相邻未被遍历过的节点就是节点 4,但是并不满足 dis[3] + 3 < dis[4]
的条件,所以不更新 dis[4]
重复上面的过程,我们可以得到下面这张表
0 | 1 | 2 | 3 | 4 | 5 | 6 | |
---|---|---|---|---|---|---|---|
dis | 0 | 2 | 1 | 3 | 3 | 4 | 2 |
第二行的值表示原点到各点的最短路径。
所以 Dijkstra 算法就始终在重复两步:
- 寻找未被遍历节点中在 dis 数组中最小的那个节点
- 根据条件更新未被遍历过的相邻节点
寻找最小节点的操作我们使用一个优先队列来完成,直接从队首取出的值就是最小的,无需寻找
import java.util.*; |
Bellman-Ford 算法
Bellman-Ford 算法也是求解单源最短路径的算法,与 Dijkstra 算法不同的是,它能够处理负权边,即使允许图中存在权值为负数的边。
在讲解 Bellman-Ford 算法之前,我们来看一组操作
dis[w] = Math.min(dis[w], dis[v] + graph.getWeight(v, w)); |
这个操作叫做松弛操作。
为什么叫松弛,因为本来可以直接到达点 w,现在经过上面的操作之后,偏要经过点 v 然后到达 w (不一定会经过,只有当
dis[v] + graph.getWeight(v, w) < dis[w]
才会经过点 v),如果把边看做橡皮筋的话,不就相当于松弛了吗?
而 Bellman-Ford 算法就是,对所有的边进行 $V - 1$ 轮松弛操作,这个 $V$ 指的是图中的节点数目,经过 $V - 1$ 轮松弛操作后,此时 dis
数组中保存的就是源点到各个节点的最短路径。
为了理解 Bellman-Ford 算法为什么这么神奇,每个节点经过 $V-1$ 轮松弛操作之后就可以得到最短路径,我们还是要研究一下松弛操作
dis[w] = Math.min(dis[w], dis[v] + graph.getWeight(v, w)); |
如果进行了松弛操作,意味源多经过了一条边到 w
,所以如果我们设定 dis[w]
为源点最多经过 $k$ 条边到达点 w
的最短路径,那么经过一次松弛操作之后 dis[w]
语义变为源点最多经过 $k + 1$ 条边到达点 w 的最短路径,因为一个节点到达另一个节点最多经过 $V-1$ 条边,因此经过 $V - 1$ 轮的松弛操作之后,dis[w]
语义变为了源点最多经过 $V - 1$ 条边到达点 w
的最短路径,考虑到了源点到达节点 w
能经过的边数的所有情况,所以最终的 dis[w]
就是源点到点 w
的最短路径。
我们来看一个例子,假设源点为 0
图中有 4 个节点,因此要进行 3 轮松弛操作。第一次对所有边进行松弛操作
第二次对所有边进行松弛操作
第三次对所有边进行松弛操作
最后得到的结果为
0 | 1 | 2 | 3 | |
---|---|---|---|---|
dis | 0 | 4 | 1 | 2 |
Bellman-Ford 虽然能够处理负权边,但是如果图中有负权环的话,那么就无法求解了,所谓的负权环是指环中所以边的路径和为负值,这样的只要我每次经过一次环,每个节点的 dis 值都在减少,如果图中存在负权环的话,那么根本就不存在最小路径。
大家在考虑一个问题,对于无向图来说,如果图中出现了负权边意味着什么? 假设 0-1 是一个负权边,因为是无向图,可以从 0 到 1,也可以从 1-0,那么我们可以从 0 来到 1,然后从 1 来到 0,这就形成一个负权环。所以 Bellman-Ford 算法其实是一个有向图算法。
另外我们可以通 Bellman-Ford 算法检测图中是否存在负权环,我们知道如果如果不存在负权环的话,经过 $V-1$ 次松弛操作后,dis 数组中的元素的值是不会再改变的,所以我们可以经过 $V-1$ 次松弛操作后再次进行一次松弛操作,如果在这次的松弛操作中,dis 中有元素的值发生改变,那么就说明存在负权环。
import java.util.ArrayList; |
Floyed 算法
上面的两个算法解决的都是单源最短路径问题,而 Floyed 算法解决的所有点对之间的最短问题。我们其实可以通过使用 $V$ 次 Dijkstra 算法就可以求解所有点对之间的最短路径,不过 Dijkstra 算法不能处理负权边的问题,而 Floyed 算法可以处理负权边的问题。
但是同 Bellman-Ford 算法一样,如果图中存在负权环的话,就根本不存在最短路径,而我们知道如果无向图中有负权边的话,就相当于存在负权环,所以 Floyed 算法其实也是一个有向图算法。
Floyed 算法的思想也很简单,对于 v-w
,假设它们之间的最短路径表示为 dis[v][w]
,每一次都要尝试一下绕道点 t
会不会得到更短的路径(t 从节点 $0$ 取值到节点 $V-1$),相当于
for(int t = 0; t < V; t++) { |
当经历过 $V$ 次的绕道尝试后,最终 dis[v][w]
就表示节点 v
与节点 w
之间的最短路径。
另外我们也可以通过 Floyed 算法对负权环进行检测,我们知道如果没有负权环的话 dis[v][v]
的值始终是 0,如果存在负权环的话,那么 dis[v][v]
的值就会更新为小于 0 的值,在经过 $V$ 次绕道之后检查所有的 dis[v][v]
是否等于 0 即可知道图中是否有负权环。
import java.util.Arrays; |
Floyed 算法也有时称为 3-for 算法,因为它的算法核心就是三个嵌套的 for 循环,Floyed 算法的实现是这三个算法中最简单的。