Johnson 全源最短路径算法 Java实现

算法导入

在之前的文章中,我们讲述了:

讲了这么多,可能 帅哥靓女们想问了,有没有一种终极算法,又能处理大图(指结点和边较多),时间上又能满足要求呢!
在这里插入图片描述
高低要给各位介绍一下接下来的big bossJohnson算法了。上图,明差异!
在这里插入图片描述

算法核心

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。好像与原来的最短路不一样啊,那可不行啊。

这样不行,那咱们换一种方法。咱高中物理讲到过一个概念——势能
例如重力势能、电势能。势能有两个重要的特点:

  • 势能的变化量只和起点和终点的相对位置有关,而与起点到终点所走的路径无关。
  • 势能的绝对值往往取决于设置的零势能点,但无论将零势能点设置在哪里,两点间势能的差值是一定的。

明白这个含义之后,我们好像发现了新大陆。只要两个点相对位置不变,那么两点间势能差值一定。那么可以这样:

  1. 新建一个虚拟结点 0,可以理解为 零势能点从0点向其他所有点连一条边权为 0的边。通过BellmanFord算法,求出 虚拟结点 0 到 其他结点的最短路,记为 h(i)。这里可以用Dijkstra算法吗?不可以!因为 BellmanFord算法能处理负权图,而Dijkstra算法不能。
  2. 边权值重设。假如原图中 w(u,v) = x,那么重设后的权值为 x + h(u) - h(v)
  3. 接下来 以每个点为起点,跑 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

你说懂了爱不是浪漫 我的本分给你安全感
许嵩 《医生》

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值