算法导入
在之前的文章中,我们讲述了:
- 经典入门的
Dijkstra算法
,属于非负权图的单源最短路径算法
。Dijkstra算法,经典入门 - 实现简单但能力有限的
Floyd算法
,属于全源最短路径算法
。Floyd算法,能力有限 - 能处理负权图但时间稍逊于 DIjkstra 的
BellmanFord算法
,属于单源最短路径算法
。BellmanFord算法,稍逊但实用 - 改进版BellmanFord算法,使用队列提高时间的
SPFA算法
。
讲了这么多,可能 帅哥靓女们想问了,有没有一种终极算法,又能处理大图(指结点和边较多),时间上又能满足要求呢!
高低要给各位介绍一下接下来的big boss,Johnson算法
了。上图,明差异!
算法核心
Johnson 和 Floyd 一样,是一种能求出无负环图上任意两点间最短路径的算法。该算法在 1977 年由 Donald B. Johnson 提出。
由于任意两点间的最短路可以通过枚举起点,跑 n 次 BellmanFord算法
解决,时间复杂度为 O(n^2 * m)
,也可以直接使用Floyd算法
,时间复杂度为 O(n^3)
。
从时间复杂度上考虑,我们肯定希望时间复杂度越小越好。O(n^2 * m)
的时间复杂度还能再降吗?既然你可以跑 n 次 BellmanFord
,那么能不能跑 n 次 Dijkstra
呢,毕竟后者的时间复杂度更低。这样想没错,但 Dijkstra
算法不能正确求解带负权边的最短路,因此Johnson算法的精髓就是预处理,确保所有边的边权均非负
。
容易想到的是给所有边的边权同时加上一个正数x,从而让所有边的边权均非负。如果新图上起点到重点的最短路经过了k条边,则讲最短路减去kx即可得到实际的最短路。
但这样的方法是错误的。考虑下图:
1-> 2
的最短路为 1 -> 5 -> 3 -> 2
,长度为 - 2。但如果把每条边的边权加上5之后。
新图上 1 -> 2
的最短路 为 1 -> 4 -> 2
。好像与原来的最短路不一样啊,那可不行啊。
这样不行,那咱们换一种方法。咱高中物理讲到过一个概念——势能
。
例如重力势能、电势能。势能有两个重要的特点:
- 势能的变化量只和起点和终点的相对位置有关,而与起点到终点所走的路径无关。
- 势能的绝对值往往取决于设置的零势能点,但无论将零势能点设置在哪里,两点间势能的差值是一定的。
明白这个含义之后,我们好像发现了新大陆。只要两个点相对位置不变,那么两点间势能差值一定。那么可以这样:
- 新建一个
虚拟结点 0
,可以理解为零势能点
,从0点向其他所有点连一条边权为 0的边。通过BellmanFord算法
,求出虚拟结点 0 到 其他结点的最短路,记为 h(i)
。这里可以用Dijkstra算法
吗?不可以!因为BellmanFord算法
能处理负权图,而Dijkstra算法
不能。 - 边权值重设。假如原图中
w(u,v) = x
,那么重设后的权值为x + h(u) - h(v)
。 - 接下来 以每个点为起点,跑 n 轮
Dijkstra算法
就可以求出任意两点间的最短路了。
那这样为什么正确呢?
咱来证明一下:
假设从 s -> t
之间的 任一条路径为 s -> p1 -> p2 -> ... -> pk -> t
,那么这条路的权值总和为:
w(s, p1) + w(p1, p2) + ... + w(pk, t)
将每条边重设权值为 w(u, v) + h(u) - h(v)
后,这条路的权值总和为:
w(s, p1) + h(s) - h(p1) + w(p1, p2) + h(p1) - h(p2) + ... + w(pk, t) + h(pk) - h(t)
= w(s,p1) + w(p1, p2) + ... + w(pk, t) + h(s) - h(t)
前后比较可得:无论从 s->t
的哪一条路径, h(s) - h(t)
的值是不变的,与势能概念相吻合,因此 原图上的 s->t
的最短路和新图上的s->t
的最短路相对应。
这保证了边权值修改后,两点间的最短路径仍然是原来的最短路径。那么这保证了新图中所有的边权非负吗?因为在非负权图上,Dijkstra 算法能够保证得出正确的结果。
根据三角形不等式,两边之和大于第三边
,那么w(1, 2) + h1 - h2 >= 0
,当且仅当 虚拟结点 0 与 结点重合时,权值和 为 0,由此可以保证任意条边权值都非负!
复杂度分析
在前面分析中,我们已得知该算法的优秀之处了,而且也能够大致算出具体的时间复杂度。
时间复杂度
- 1次BellmanFord算法,时间复杂度为
O(nm)
。 - n次Dijkstra算法,时间复杂度为
O(n * mlogm),采用优先队列实现Dijkstra
总时间复杂度为 O(nm) + O(n * mlogm) = O(nmlogm)
。
空间复杂度
取决于存储图的方式,由于Johnson算法
一般用于处理大图,采用邻接表的方式存储图,空间复杂度为 O(m)
。
代码实现
import java.util.*;
/**
* @author LKQ
* @date 2022/4/27 23:17
* @description 任意两点之间的最短路可以通过枚举起点,跑 n 次 BellmanFord算法实现,时间复杂度为 O(n^2· m)
* 由于 Dijkstra算法时间复杂度为 O(M * logM) 因此若使用此算法代替 BellmanFord, 跑 n 次,那么总时间复杂度为 O(N*M*logM)
* 在稀疏图上比Floyd算法的时间复杂度也更加优秀。但由于Dijkstra算法不能处理负权图,因此,需要先跑一次 BellmanFord 对原图上的边预处理。
*/
public class Solution {
/**
* 存储图
*/
List<int[]>[] graph;
/**
* 存储任意两点之间的最短路
*/
int[][] dis;
/**
* 正无穷
*/
int INF = Integer.MAX_VALUE / 2;
/**
* Johnson算法。
* @param n n个结点,由于需要使用虚拟结点,我们将 原图中点结点编号为 1 - n,虚拟结点 设定 为 0
* @param edges e[0] 到 e[1] 的权值 为 e[2]
*/
public void Johnson(int n, int[][] edges) {
// 1. 根据edges 建图
graph = new List[n + 1];
for (int i = 0; i <= n; i++) {
graph[i] = new ArrayList<>();
}
for (int[] e: edges) {
// 如果edges中,结点编号从1到n,那么直接赋值, 若结点编号 从 0到 n-1, 那么结点编号加一再赋值
// graph[e[0] + 1].add(new int[] {e[1] + 1, e[2]})
graph[e[0]].add(new int[]{e[1], e[2]});
}
// 2. 初始化dis数组
dis = new int[n + 1][n + 1];
for (int i = 1; i <= n; i++) {
Arrays.fill(dis[i], INF);
dis[i][i] = 0;
}
// 3. 建立虚拟结点0,0到其他结点的权值为 0
graph[0] = new ArrayList<>();
for (int i = 1; i <= n; i++) {
graph[0].add(new int[] {i, 0});
}
// 4. 跑一次 BellmanFord算法,求出虚拟结点到其他点的最短路,记为 h(u)
int[] h = BellmanFord(n, graph);
System.out.println("虚拟结点 0 到其他结点的最短路为 " + Arrays.toString(h));
if (h != null) {
// 5. 将原图中边的权值重新设置为 w + h(u) - h(v)
for (int u = 1; u <= n; u++) {
for (int[] e: graph[u]) {
int v = e[0], w = e[1];
e[1] = w + h[u] - h[v];
}
}
// 6. 以每个结点为起点,跑 n 轮 Dijkstra算法。
for (int u = 1; u <= n; u++) {
dis[u] = Dijkstra(n, u, graph);
// 注意,最后的最短路径还需要加上 h(v) - h(u),才能得出原来的最短路径长度
for (int v = 1; v <= n; v++) {
if (v != u) {
dis[u][v] += h[v] -h[u];
}
}
System.out.println("结点" + u + " -> other points " + print(dis[u]));
}
}else {
// 返回了 null, 说明虚拟结点到其他结点的最短路不存在,说明原图中存在负环。
System.out.println("图中存在负环,");
}
}
public int[] BellmanFord(int n, List<int[]>[] graph) {
// 初始化虚拟结点0 到其他结点的最短路
int[] dis = new int[n + 1];
Arrays.fill(dis, INF);
dis[0] = 0;
// 设置flag 标志,判断一轮循环过程中是否发生松弛
boolean flag = false;
// 4. bellman-Ford
for (int i = 0; i < n; i++) {
flag = false;
for (int u = 0; u < n; u++) {
// 无穷大与常数加减仍然为无穷大
// 因此最短路长度为 inf 的点引出的边不可能发生松弛操作
if (dis[u] == INF) {
continue;
}
for (int[] e: graph[u]) {
int v = e[0], w = e[1];
if (dis[u] + w < dis[v]) {
dis[v] = dis[u] + w;
flag = true;
}
}
}
// 没有可以松弛的边就停止算法
if (!flag) {
break;
}
}
// 第 n 轮循环仍然可以松弛说明s点可以抵达一个负环,那么返回 null, 否则返回 最短路数组dis
return flag ? null : dis;
}
public int[] Dijkstra(int n, int s, List<int[]>[] graph) {
// n个结点,编号 1-n
int[] dis = new int[n + 1];
Arrays.fill(dis, INF);
// 源点到自身的距离为0
dis[s] = 0;
// 初始化访问标志,默认为false
boolean[] vis = new boolean[n + 1];
// 使用优先队列维护最短路径中最短路最小的点,优化时间
PriorityQueue<int[]> pq = new PriorityQueue<>((a, b)-> a[1] - b[1]);
pq.add(new int[]{s, 0});
while (!pq.isEmpty()) {
// 弹出最短路长度最小的点数组
int[] t = pq.poll();
int u = t[0];
// 该点访问过,跳过
if (vis[u]) {
continue;
}
vis[u] = true;
// 遍历所以 u 能够到达的点
for (int[] q: graph[u]) {
// 下一个点 v, 即其边权值 w
int v = q[0], w = q[1];
if (dis[v] > dis[u] + w) {
// s->v 的距离 > s->u 的距离 + u->v 的距离,更新最短距离
dis[v] = dis[u] + w;
// 加入优先队列,s->v 的距离 dis[v]
pq.add(new int[]{v, dis[v]});
}
}
}
return dis;
}
public String print(int[] dis) {
StringBuffer sb = new StringBuffer();
sb.append("[");
for (int a: dis) {
if (a > INF / 2) {
sb.append("INF, ");
}else {
sb.append(a + ", ");
}
}
sb.replace(sb.length() - 2, sb.length(), "");
sb.append("]");
return sb.toString();
}
public static void main(String[] args) {
int[][] edges = {{1, 4, 2}, {4, 2, -3}, {1, 5, 0}, {5, 3, 2}, {3, 2, -4}};
Solution solution = new Solution();
solution.Johnson(5, edges);
}
}
以下图为例:
建立虚拟结点 0
,到其他结点的边权值为 0
以0为源点,跑一次 BellmanFord算法
,求得 0 到其他结点的最短路径为 [0,0,-4,0,0,0],下标从 0 开始
。
那么重新赋权值后,新图的边权值如下:
那么就可以每个点为起点,跑 n 次 Dijkstra算法
,求出任意点之间的最短路径了 。注意,最后返回最短路径时,由于原先权值加了h(u)- h(v),那么返回的时候需要加上 h(v) - h(u)
,这一点尤其需要注意。
测试用例全源最短路为:
参考资料
OI Wiki
图灵程序设计丛书 算法 第4版
End
你说懂了爱不是浪漫 我的本分给你安全感
许嵩 《医生》