数据结构与算法——迪杰斯特拉(Dijkstra)算法

tip:这个算法真的很难讲解,有些地方只能意会了,多思考多看几遍还是可以弄懂的。

应用场景-最短路径问题

战争时期,胜利乡有 7 个村庄 (A, B, C, D, E, F, G) ,现在有六个邮差,从 G 点出发,需要分别把邮件分别送到 A, B, C , D, E, F 六个村庄,各个村庄的距离用边线表示(权) ,比如 A – B 距离 5公里

问:如何计算出 G 村庄到 其它各个村庄的 最短距离? 如果从其它点出发到各个点的最短距离又是多少?

迪杰斯特拉算法介绍

迪杰斯特拉(Dijkstra)算法是 典型 最短路径算法,用于 计算一个节点到其他节点的最短路径。 它的主要特点是 以起始点为中心向外层层扩展(广度优先搜索思想),直到扩展到终点为止。

迪杰斯特拉算法过程

设:

  • v:出发顶点

  • V:为顶点集合 V{v1,v2,vi...}

  • vs:已经访问过的顶点

    • 0 :未访问
    • 1:已访问
  • dis:v 到 V 中各个顶点的距离 dis{d1,d2,di...}

    到自身(v 到 v)的距离为 0,v 到 Vi 的距离为 di

那么有如下规则:

  1. 从 dis 中选择值最小的 di 并移除 dis 集合,同时移除 V 集合中对应的顶点 vi,此时的 v 到 vi 即为最短路径
  2. 更新 dis 集合,更新规则为:比较 v 到 V 集合中顶点的距离,与 v 通过 Vi 到 V 集合中顶点的距离值,保留较小的一个(同时也应该更新顶点的 前驱节点 为 Vi,表名是通过 Vi 达到的。)
  3. 重复执行两步骤,直到最短路径顶点为目标顶点即可结束

上面的算法过程脱离了具体的实现,很抽象,下面通过具体一点的实现步骤过程来看看到底是怎么弄的

迪杰斯特拉算法-步骤

需要以下三个重要的数组:

  • already_arr:记录各个顶点是否访问过

    1 表示访问过,0 表示未访问过,每搜索一层(广度优先),都会被动态更新。

    比如从 G 点出发,访问过 G,A ,此时 A 不算访问过,G 算被访问的节点,因为要计算 G 到 A 的距离,但是 A 不是 访问节点出发节点

  • pre_visited_arr:每个下标对应的值为前一个顶点下标(前驱节点),统一动态更新

    比如:从 G 点出发,会访问 G,AG,BG,EG,F,那么 A、B、E、F 的前驱节点就是 G

  • dis_arr:记录出发点到其他所有顶点的距离

    比如:从 G 出发,到过了 G,A ,再从 A,C 到 C,则记录的是 G 到 C 的路径距离

过程如下:

  1. 以 G 为起点,的各个数组的初始状态

     对应的村庄    [A, B, C, D, E, F, G]
          下标     [0, 1, 2, 3, 4, 5, 6]
    		 
    already_arr 	 = [0, 0, 0, 0, 0, 0, 1]
    pre_visited_arr = [N, N, N, N, N, N, 0]
    dis_arr         = [0, 0, 0, 0, 0, 0, 0]  
    

    含义:

    • already_arr[n] = 0 :则表示,该点还没有访问过
    • already_arr[6] = 1:该点已经访问过,因为从 G 出发,那么 G 点自己就被访问过了
    • dis_arr 中都为 0,因为从 G 点开始,还没有到达过其他点
    • pre_visited_arr[n] = N:其中 N 为一个较大的值,表示不能直接连接通的权值
  2. 计算过从 G 点能到达的点 A,B,E,F 之后的情况如下

      对应的村庄   [A, B, C, D, E, F, G]
           下标    [0, 1, 2, 3, 4, 5, 6]
    already_arr 	 = [0, 0, 0, 0, 0, 0, 1]
    pre_visited_arr = [6, 6, N, N, 6, 6, 0]
    dis_arr         = [2, 3, 0, 0, 4, 6, 0]
    

    含义:

    • already_arr[6] = 1:表示 G 点被访问过
    • pre_visited_arr[0]=6:表示 节点 A 的前驱节点是 6,即 G
    • dis_arr[0]:表示 G 到 A 的距离是 2
  3. 根据广度优先原则(并不是以 GA,GB A 在前面而选择 A,这里的广度优先指的是:以 G 为起点,那么则把与 G 直连的全部计算,这样一层一层的计算),会先判定 A 点是否需要作为 访问顶点(注意不是出发顶点),需要满足以下条件:

    1. A 点没有被访问过

    2. 并且 G 点到 A 点的距离,要小于 dis_arr 中对应 G 到 A 的距离,因为可能出现以下情况:

      有可能其他通过其他节点到达 A 点的距离是最短的。

    那么此轮会计算 A,BA,C,访问之后的情况如下

       对应的村庄     [A, B, C, D, E, F, G]
            下标     [0, 1, 2, 3, 4, 5, 6]
    		 
    already_arr 	 =    [1, 0, 0, 0, 0, 0, 1]
    pre_visited_arr =   [6, 0, 0, N, 6, 6, 0]
    dis_arr         =   [2, 7, 9, 0, 4, 6, 0]
    此次变动的有             ↑  ↑
    
    • already_arr[0] = 1: 表示当前访问的是 A
    • dis_arr[1] = 7: 表示 G 到 B (中间经过 A)的距离是 7
    • dis_arr[2] = 9: 表示 G 到 C (中间经过 A)的距离是 9

    这里就出现一个问题:

    如上图:GAB = 7,但是 GB=3,并且在访问 G 点的时候,已经计算出了 GB 的距离,所以这里 GAB > GB 的,它不应该被更新到这里来。最终调节之后的结果为:

        对应的村庄       [A, B, C, D, E, F, G]
             下标        [0, 1, 2, 3, 4, 5, 6]
    		 
    already_arr 	 = [1, 0, 0, 0, 0, 0, 1]
    pre_visited_arr = [6, 6, 0, N, 6, 6, 0]
    dis_arr         = [2, 3, 9, 0, 4, 6, 0]
    

如果还不懂,就结合下面的代码进行理解。

代码实现

构建无向图

老规矩,这种图结构,都需要先构建出它的图结构,这里还是使用之前学过的 邻接矩阵 构建

/**
 * 迪杰斯特拉算法-最短路径问题
 */
public class DijkstraAlgorithm {
    // 不连通的默认值
    int N = 100000;

    /**
     * 图:首先需要有一个带权的连通无向图
     */
    class MGraph {
        int vertex;  // 顶点个数
        int[][] weights;  // 邻接矩阵
        char[] datas; // 村庄数据
        int edgeNum; // 共有多少条边

        /**
         * @param vertex  村庄数量, 会按照数量,按顺序生成村庄,如 A、B、C...
         * @param weights 需要你自己定义好那些点是连通的,那些不是连通的
         */
        public MGraph(int vertex, int[][] weights) {
            this.vertex = vertex;
            this.weights = weights;

            this.datas = new char[vertex];
            for (int i = 0; i < vertex; i++) {
                // 大写字母 A 从 65 开始
                datas[i] = (char) (65 + i);
            }
            // 计算有多少条边
            for (int i = 0; i < weights.length; i++) {
                /*
                        A       B       C       D       E       F       G
                A       0       12      100000  100000  100000  16      14
                B       12      0       10      100000  100000  7       100000
                j = i + 1:比如:
                        i=0,j=1, 那么就是 A,B 从而跳过了 A,A
                        i=1,j=2, 那么就是 B,C 从而跳过了 B,A  B,B
                        那么含义就出来了:跳过双向边的统计,也跳过自己对自己值得为 0 的统计
                 */
                for (int j = i + 1; j < weights.length; j++) {
                    if (weights[i][j] != N) {
                        edgeNum++;
                    }
                }
            }
        }

        public void show() {
            System.out.printf("%-8s", " ");
            for (char vertex : datas) {
                // 控制字符串输出长度:少于 8 位的,右侧用空格补位
                System.out.printf("%-8s", vertex + " ");
            }
            System.out.println();
            for (int i = 0; i < weights.length; i++) {
                System.out.printf("%-8s", datas[i] + " ");
                for (int j = 0; j < weights.length; j++) {
                    System.out.printf("%-8s", weights[i][j] + " ");
                }
                System.out.println();
            }
        }
    }

    @Test
    public void mGraphTest() {
        int[][] weights = new int[][]{
                //     A  B  C  D  E  F  G
                /*A*/ {N, 5, 7, N, N, N, 2},
                /*B*/ {5, N, N, 9, N, N, 3},
                /*C*/ {7, N, N, N, 8, N, N},
                /*D*/ {N, 9, N, N, N, 4, N},
                /*E*/ {N, N, 8, N, N, 5, 4},
                /*F*/ {N, N, N, 4, 5, N, 6},
                /*G*/ {2, 3, N, N, 4, 6, N}
        };
        MGraph mGraph = new MGraph(7, weights);
        mGraph.show();
        System.out.printf("共有 %d 条边\n", mGraph.edgeNum);
    }
}

测试输出

        A       B       C       D       E       F       G       
A       100000  5       7       100000  100000  100000  2       
B       5       100000  100000  9       100000  100000  3       
C       7       100000  100000  100000  8       100000  100000  
D       100000  9       100000  100000  100000  4       100000  
E       100000  100000  8       100000  100000  5       4       
F       100000  100000  100000  4       5       100000  6       
G       2       3       100000  100000  4       6       100000  
共有 10 条边

迪杰斯特拉算法求解

    @Test
    public void dijkstraTest() {
        int[][] weights = new int[][]{
                //     A  B  C  D  E  F  G
                /*A*/ {N, 5, 7, N, N, N, 2},
                /*B*/ {5, N, N, 9, N, N, 3},
                /*C*/ {7, N, N, N, 8, N, N},
                /*D*/ {N, 9, N, N, N, 4, N},
                /*E*/ {N, N, 8, N, N, 5, 4},
                /*F*/ {N, N, N, 4, 5, N, 6},
                /*G*/ {2, 3, N, N, 4, 6, N}
        };
        MGraph mGraph = new MGraph(7, weights);
        mGraph.show();
        System.out.printf("共有 %d 条边 \n", mGraph.edgeNum);

        dijkstra(mGraph, 'G');
    }

    // 记录各个顶点是否访问过
    private boolean[] already_arr;
    // 记录每个下标对应的值为前一个顶点下标(前驱节点)
    private int[] pre_visited_arr;
    // 记录出发点到其他所有顶点的距离
    private int[] dis_arr;
    private MGraph mGraph;

    private void dijkstra(MGraph mGraph, char start) {
        this.mGraph = mGraph;
        // 三个数组的长度为 顶点的个数
        already_arr = new boolean[mGraph.vertex];
        pre_visited_arr = new int[mGraph.vertex];
        dis_arr = new int[mGraph.vertex];
        // 找到开始节点的下标
        int v = 0;
        for (int i = 0; i < mGraph.datas.length; i++) {
            if (mGraph.datas[i] == start) {
                v = i;
                break;
            }
        }
        // 初始化所有前驱节点为默认状态,使用不可连通的 N 值表示
        Arrays.fill(pre_visited_arr, N);
        // 标记开始节点为访问状态
        already_arr[v] = true;
        //我们使用 N 表示没有前驱节点
        // v 是开始节点,那么它就没有前驱节点
        pre_visited_arr[v] = N;
        // 初始化从起点到到所有点的距离为最大值,后续方便通过它来与新路径距离比较
        Arrays.fill(dis_arr, N);
        // 初始化,当前访问节点的距离为 0
        dis_arr[v] = 0;

        // 准备工作完成:开始查找最短路径
        // 广度优先策略:从起始节点计算它能直达的点的所有距离
        update(v);
        // 一共只需要计算 6 层: 7 个站点 -1
        for (char data : mGraph.datas) {
            // 寻找下一个访问节点
            int index = findNext();
            // 标记该节点被访问过,然后再计算与它直连点的路径
            already_arr[index] = true;
            // 并继续计算路径
            update(index);
        }

        // 所有节点都访问过之后:dis_arr 中就保留了从起点 到各个点的最短距离
        System.out.println(Arrays.toString(already_arr));
        System.out.println(Arrays.toString(pre_visited_arr));
        System.out.println(Arrays.toString(dis_arr));

        System.out.println("从 " + start + " 到以下点的最短距离为:");
        // 为了结果好认一点,格式化最后的结果
        for (int i = 0; i < dis_arr.length; i++) {
             System.out.printf("%S(%d) ", mGraph.datas[i], dis_arr[i]);
        }
        System.out.println();
    }

    /**
     * 计算起点到:当前节点所有能直连的节点的距离
     *
     * @param v
     */
    private void update(int v) {
        int[][] weights = mGraph.weights; // 我们的邻接矩阵图

        int len = 0;
        // weights[v]:由于是广度优先,所以每次只计算与该点能直连的点,也就是该点所在的一行
        for (int i = 0; i < weights[v].length; i++) {
            if (weights[v][i] == N) { // 不能直连,跳过
                continue;
            }
            // 计算从起点到当前要连通节点的距离   = 起点到当前访问节点的距离 + 访问节点到直连节点的距离
            len = dis_arr[v] + weights[v][i];

            // 首先:起点G -> A, A 要没有被访问过
            // 其次:如果当前计算新的路径距离 小于 已经存在的 从 起点 G -> 当前计算点的距离
            //      说明之前可能从其他途径到达了 i 点,这个距离是比现在找到的距离远
            // 当前的近,那么就更新数组中的数据
            if (!already_arr[i] && len < dis_arr[i]) {
                dis_arr[i] = len;
                pre_visited_arr[i] = v; // 更改前驱节点,表示 经过了 v 这个点(当前正在访问的点),到达的 i 点
            }
        }
    }

    /**
     * 广度优先策略一层计算完成之后,寻找下一个节点再计算
     *
     * @return
     */
    private int findNext() {
        int min = N, index = 0;
       
        for (int i = 0; i < already_arr.length; i++) {
            // 该节点没有被访问过
            // 并且:从起点到达该节点的距离是最小的
            // 如果是第一层执行完成之后:那么有值的则只有:与起点能直连的那几个
            //      这里就类似与:原来广度优先中使用队列来保存搜索路径了
            if (!already_arr[i] && dis_arr[i] < min) {
                min = dis_arr[i];
                index = i;
            }
        }
        return index;
    }

测试输出

        A       B       C       D       E       F       G       
A       100000  5       7       100000  100000  100000  2       
B       5       100000  100000  9       100000  100000  3       
C       7       100000  100000  100000  8       100000  100000  
D       100000  9       100000  100000  100000  4       100000  
E       100000  100000  8       100000  100000  5       4       
F       100000  100000  100000  4       5       100000  6       
G       2       3       100000  100000  4       6       100000  
共有 10 条边 

[true, true, true, true, true, true, true]
[6, 6, 0, 5, 6, 6, 100000]
[2, 3, 9, 10, 4, 6, 0]
从 G 到以下点的最短距离为:
A(2) B(3) C(9) D(10) E(4) F(6) G(0) 

从输出结果可以看到:

  • [true, true, true, true, true, true, true]

    所有节点都访问过了

  • [6, 6, 0, 5, 6, 6, 100000]

    从起点到达每个节点的前驱节点如数组上所示

    • G 到 A:前驱就是 G,表示从 G 到达的 A
    • G 到 B:前驱也是 G
    • G 到 C:前驱是 A,表示从 G 到 C,至少经过了 A,这里看就是 GAC
    • ...
    • G 到 G:这里用了最大值表示,是他自己
  • [2, 3, 9, 10, 4, 6, 0]A(2) B(3) C(9) D(10) E(4) F(6) G(0)

    • 从 G 到 A 最短路径为 2

    • 从 G 到 B 最短路径为 3

    • 从 G 到 C 最短路径为 9

      从图上人工校验可以知道 G 到 C 有两条:

      • G → A → C :9 里
      • G → E → C :12 里

      证明算法是正确的

    • ...

    • 最后一个 G 到 G,自己到自己,就是 0

换一个出发点验证

这里换一个触发点 C 来看看执行结果

    /**
     * 从 c 出发
     */
    @Test
    public void dijkstraTest2() {
        int[][] weights = new int[][]{
                //     A  B  C  D  E  F  G
                /*A*/ {N, 5, 7, N, N, N, 2},
                /*B*/ {5, N, N, 9, N, N, 3},
                /*C*/ {7, N, N, N, 8, N, N},
                /*D*/ {N, 9, N, N, N, 4, N},
                /*E*/ {N, N, 8, N, N, 5, 4},
                /*F*/ {N, N, N, 4, 5, N, 6},
                /*G*/ {2, 3, N, N, 4, 6, N}
        };
        MGraph mGraph = new MGraph(7, weights);
        mGraph.show();
        System.out.printf("共有 %d 条边 \n", mGraph.edgeNum);

        dijkstra(mGraph, 'C');
    }

测试输出

        A       B       C       D       E       F       G       
A       100000  5       7       100000  100000  100000  2       
B       5       100000  100000  9       100000  100000  3       
C       7       100000  100000  100000  8       100000  100000  
D       100000  9       100000  100000  100000  4       100000  
E       100000  100000  8       100000  100000  5       4       
F       100000  100000  100000  4       5       100000  6       
G       2       3       100000  100000  4       6       100000  
共有 10 条边 
[true, true, true, true, true, true, true]
[2, 0, 100000, 5, 2, 4, 0]
[7, 12, 0, 17, 8, 13, 9]
从 C 到以下点的最短距离为:
A(7) B(12) C(0) D(17) E(8) F(13) G(9) 

验证几个:

  • 从 C 到 A 最短路径为 7
  • 从 C 到 B 最短路径为 12
    • CAB:12
    • CEGB:15
    • CAGB:12,虽然有相同的,但是还是最短的
    • ....
  • 从 C 到 C 最短路径为 0
  • ...
  • 最后一个 C 到 G 最短路径为 9
posted @ 2021-10-06 15:59  海绵寳寳  阅读(918)  评论(2编辑  收藏  举报