【一题多解系列】图搜索算法深度剖析:DFS、BFS、A*、Dijkstra、Floyd、BF

本文为原创文章,转载请注明出处
查看[数据结构及算法]系列内容请点击:https://www.jianshu.com/nb/45938463

本文通过一个例子及其变种,由浅入深解析常见的图搜索算法。

例子如下:

假设有如下矩阵:


【一题多解系列】图搜索算法深度剖析:DFS、BFS、A*、Dijkstra、Floyd、BF_第1张图片
S表示开始节点,E表示结束节点

目标:假如一个人站在图中的S方格,他一次可以往上下左右方向移动一格,但不能走到黑色的方格上,计算从S(Start)方格到E(End)方格最少需要走几格?

这个问题及其变种有很多种解决方案,下面我们说【方格】或者说【点】指的都是方格,下面就从这个问题开始一一介绍这些算法。

DFS:深度优先搜索算法

最简单(但不一定最适合)的方法就是深度优先搜索算法,深度优先一般使用递归策略,可以理解为穷举从SE的所有路径,然后找到一个最短的路径即可。需要注意的是,在一次搜索路径中,已经被走过的方格不能再次走上去。

假设我们搜索的方法是从S的12点方向顺时针搜索,即遵循先找最上面相邻的点,其次找右上角,再次右边的点...如此往复... 那么第一次从S搜索到E的路径如下所示:

【一题多解系列】图搜索算法深度剖析:DFS、BFS、A*、Dijkstra、Floyd、BF_第2张图片
第一次搜索路径

第一次搜索完成后记录下这个路径的长度,然后继续从 E点退回到上一个点,继续搜索其他方向,如下图绿色箭头所示:

【一题多解系列】图搜索算法深度剖析:DFS、BFS、A*、Dijkstra、Floyd、BF_第3张图片
第二次搜索路径

其中,灰色箭头表示回退到上一个点,绿色箭头表示继续搜索的路径。如此往复直到穷举完所有的路径为止,用代码表示如下:

public class MainTest {
    // 定义移动方向
    private static final int[][] directions = new int[][]{{-1, 0}, {-1, 1}, {0, 1}, {1, 1}, {1, 0}, {1, -1}, {0, -1}, {-1, -1}};

    private static int allPathCount = 0; // 记录下所有走法的个数

    public static void main(String[] args) {
        int[][] g = new int[][]{
                {0, 0, 0, 0, 0},
                {0, 0, 1, 0, 0},
                {0, 0, 1, 0, 0},
                {0, 0, 1, 0, 0},
                {0, 0, 0, 0, 0}
        };

        System.out.println(dfs(g, 2, 1, 2, 4)); // 输出 4
        System.out.println("共有:" + allPathCount + "种走法"); // 共有:163506种走法
    }

    /**
     * g 表示初始化的图,这里用0表示空白方格,1表示不可走方格,在程序里面,我们会用-1表示已经走过的方格
     * 这里为了方便表示,使用行列来表示,而不是使用x、y来表示
     * (startLine, startColumn)和(endLine, endColumn) 分别表示起始和终止方格的行列
     */
    public static int dfs(int[][] g, int startLine, int startColumn, int endLine, int endColumn) {
        if (startLine == endLine && startColumn == endColumn) { // 到达E点
            allPathCount++;
            return 0;
        }

        g[startLine][startColumn] = -1; // 标记为已经走过

        int minStep = Integer.MAX_VALUE / 2;
        for (int[] direct : directions) {
            int newLine = startLine + direct[0];
            int newColumn = startColumn + direct[1];

            // 判断不可走的格子
            if (newLine < 0 || newLine >= g.length || newColumn < 0 || newColumn >= g[0].length || g[newLine][newColumn] != 0)
                continue;

            int step = 1 + dfs(g, newLine, newColumn, endLine, endColumn); // 递归查找
            if (step < minStep) minStep = step;
        }
        g[startLine][startColumn] = 0; // 释放标记,让其他路线可走

        return minStep;
    }
}

可以看到,上述结束输出为4,实际上走的步数为4的路线为:


【一题多解系列】图搜索算法深度剖析:DFS、BFS、A*、Dijkstra、Floyd、BF_第4张图片
步数为4的走法之一

可以看到,走法一共有163506种,我们实际上需要将这么多种走法全部穷举完,才可以计算出来走的步数的最小值,当然,在实际运用过程中,DFS可以在运行过程中对其搜索树进行剪枝(比如对于当前步数已经大于全局到达目标点步数的最小值了就没必要继续往下搜索了),可以很大程度上减少不必要的重复。

根据上面DFS的走法记录,我们看到,基本思想是使用递归构建一个隐式的搜索树,然后穷举树的深度从而取出最小深度,其所要求的时间复杂度较高。时间复杂度分析如下:

  • 第一个点具有8个方向,第二个点具有7个方向,那么整体需要搜索的次数约为:8×7×7×...×7 ,具有多少个7呢?我们分析,搜索树的深度最多为n × mnm分别为矩阵行列数。总搜索节点数大约为8 ×7^(n×m),所以总体上时间复杂度为 O(8^(n×m)),如果只能向上下左右4个方向移动,那么时间复杂度为 O(4^(n×m))。总之,在最坏情况下,时间复杂度是指数级的。
  • 如果我们不关注最短的步数,而是随机找出一条路径能够到达终点,那么,时间复杂度为O(V+E),其中,V是图中边的个数,E是图中点的个数。
  • 使用递归的话,空间复杂度与时间复杂度相等,使用栈来解决的话,空间复杂度为 O(n×m)

BFS:广度优先搜索算法

对于简单的寻路问题,我们也可以使用广度优先搜素算法解决,从而大大缩小其时间复杂度。BFS的基本思想是每次从当前节点扩展一层,每次遍历子节点的时候,依次将子节点往外扩展一层,由于这样的话步数最小的步骤总是在前面,所以当某一次扩展扩展到了E点就可以结束了。同时,由于后扩展的节点步长肯定比前面扩展的步长更长,所以可以设置全局访问过的节点不再继续访问。基本扩展步骤如下:

【一题多解系列】图搜索算法深度剖析:DFS、BFS、A*、Dijkstra、Floyd、BF_第5张图片
扩展步骤:不同的颜色代表不同的扩展步骤

如图所示,第一次扩展使用红色箭头表示,扩展其相邻方哥,第二次扩展用绿色箭头表示...如此往复,等到第四次扩展访问到了 E点,整个算法就结束了,代码如下:

public class MainTest {
    // 定义移动方向
    private static final int[][] directions = new int[][]{{-1, 0}, {-1, 1}, {0, 1}, {1, 1}, {1, 0}, {1, -1}, {0, -1}, {-1, -1}};

    public static void main(String[] args) {
        int[][] g = new int[][]{
                {0, 0, 0, 0, 0},
                {0, 0, 1, 0, 0},
                {0, 0, 1, 0, 0},
                {0, 0, 1, 0, 0},
                {0, 0, 0, 0, 0}
        };

        System.out.println(bfs(g, 2, 1, 2, 4)); // 输出 4
    }

    /**
     * g 表示初始化的图,这里用0表示空白方格,1表示不可走方格,在程序里面,我们会用-1表示已经走过的方格
     * 这里为了方便表示,使用行列来表示,而不是使用x、y来表示
     * (startLine, startColumn)和(endLine, endColumn) 分别表示起始和终止方格的行列
     */
    public static int bfs(int[][] g, int startLine, int startColumn, int endLine, int endColumn) {
        Triple[] path = new Triple[g.length * g[0].length]; // 这里实际用Queue队列更方便

        int head = 1, tail = 0;
        path[0] = new Triple(startLine, startColumn, 0); // 初始点入队列
        g[startLine][startColumn] = -1; // 标记初始点不可走

        while (head > tail) {
            Triple t = path[tail];
            for (int i = 0; i < directions.length; i++) {
                int newLine = t.line + directions[i][0];
                int newColumn = t.column + directions[i][1];

                // 找到直接返回
                if (newLine == endLine && newColumn == endColumn) return t.step + 1;
                if (newLine < 0 || newLine >= g.length || newColumn < 0 || newColumn >= g[0].length || g[newLine][newColumn] != 0)
                    continue;

                g[newLine][newColumn] = -1; // 标记为已走过
                path[head++] = new Triple(newLine, newColumn, t.step + 1); //  继续将子节点入队列
            }

            tail++;
        }
        return -1;
    }

    private static class Triple {
        int line;
        int column;
        int step;

        public Triple(int line, int column, int step) {
            this.line = line;
            this.column = column;
            this.step = step;
        }
    }
}

后面很多的算法都借鉴了BFS算法,并进行了一定的变化。

  • 使用边和点来表示的话,时间复杂度为O(V+E),其中V是点的个数,E是边的个数,上面的例子里面,边的个数约有n×m×8个(忽略边缘和不可走的点周边的边不到8个的情况),所以时间复杂度为:O(n×m×9)

A*算法:启发式寻路

下面我们把上面的题目变换一下:

假设还是上面的图,从任意方格横向或者竖向走,所需要走的步数为2,斜向走需要走的步数为3,请问最少需要多少步数能从S点走到E点?(后续将步数也称为代价)

很明显,这里就是一个带权的图搜索问题了。当我们可以大概评估从任意一格走到终点E的代价的时候,就可以使用A算法,A算法实际上是一种贪心算法,由Dijkstra算法改进而来,下面详细介绍。

A*算法基本元素

  • 在A*算法中,需要维护两个列表,一个是open list列表,用来存储在下一次搜索中可能会被搜索到的点,open list是一个按照后面说的F值从小到大排好序的list,open list一开始只有一个起点。
  • 第二个列表是close list列表,已经检测过的节点放入close list,在close list中的点不会再次被检测。
  • 父节点,记录回溯的节点,如果只是找出最短路径的长度,不进行回溯的话,则不需要父节点。
  • 路径排序,F值,F = G + H,其中,G为从起点到当前节点的代价,H为从当前节点到终点的估算代价,即:启发函数。
  • 启发函数,即H,用来估算从当前节点到终点的代价,启发函数的选择好坏会直接影响算法的性能与结果。这里就使用曼哈顿距离,即横纵向走的代价之和:H=|(iS-iK)|×2 + |(jS-jK)|×2,2是走一格的代价。

算法流程

  • 1、把起点S加入到open list中,并估算S点的F值;
  • 2、从open list中取出一个F值最小的点P,向周围一格(横竖向或斜向)扩展点,并遵循3、4、5规则;
  • 3、若周围的某个格子不可走或在close list中,则忽略;
  • 4、若周围的某个格子不在open list或close list中,且可走,则估算其F值,并按照F值从小到大将该格子加入open list;
  • 5、若周围某个格子在open list中,且该节点的F值大于:P点的F值+该节点的H值+P到该节点的代价,则更新该节点的F值,并将该节点的父节点设置为P点。
  • 6、重复步骤2,直到找到S点为止。

按照以上步骤,我们可以看到第一次的搜索路径如下(对于具有相同的F值的点,随便选取一个扩展即可):

【一题多解系列】图搜索算法深度剖析:DFS、BFS、A*、Dijkstra、Floyd、BF_第6张图片
第一次搜索扩展,灰色S代表加入到close list中的点

第二次搜索扩展如下:


【一题多解系列】图搜索算法深度剖析:DFS、BFS、A*、Dijkstra、Floyd、BF_第7张图片
第二次搜索扩展,灰色字体格子代表加入close list的点

经过多轮扩展后的最终结果:


【一题多解系列】图搜索算法深度剖析:DFS、BFS、A*、Dijkstra、Floyd、BF_第8张图片
最终结果,F=11

最终计算,最先到达E点的路径F=11,这里F值就代表其最终代价。
可以看到,H函数的选择对于评估具有非常重要的作用更高,H函数会直接影响最终结果。
A*算法实现的代码如下:

import java.util.*;

public class MainTest {
    // 定义移动方向
    private static final int[][] directions = new int[][]{{-1, 0}, {-1, 1}, {0, 1}, {1, 1}, {1, 0}, {1, -1}, {0, -1}, {-1, -1}};
    private static final int[] walkStep = new int[]{2, 3, 2, 3, 2, 3, 2, 3}; // 八个方向走一步的代价

    public static void main(String[] args) {
        int[][] g = new int[][]{
                {0, 0, 0, 0, 0},
                {0, 0, 1, 0, 0},
                {0, 0, 1, 0, 0},
                {0, 0, 1, 0, 0},
                {0, 0, 0, 0, 0}
        };

        System.out.println(aStar(g, 2, 1, 2, 4)); // 输出 11
    }

    public static int aStar(int[][] g, int startLine, int startColumn, int endLine, int endColumn) {
        List openList = new ArrayList<>(); // close list中的节点我们直接将其值置为-1,不再单独开list保存,在open list中的点标记为2
        Point[][] pointIndex = new Point[g.length][g[0].length]; // 用于加快从(line, column)查找Point的效率

        openList.add(new Point(startLine, startColumn, 0, endLine, endColumn));
        g[startLine][startColumn] = 2; // 这一行不是必须的,只是为了让逻辑更清晰
        pointIndex[startLine][startColumn] = openList.get(0);

        while (openList.size() > 0) {
            Point p = openList.remove(0);
            g[p.line][p.column] = -1;

            for (int i = 0; i < directions.length; i++) {
                int newLine = p.line + directions[i][0];
                int newColumn = p.column + directions[i][1];

                // 找到直接返回
                if (newLine == endLine && newColumn == endColumn) return p.G + walkStep[i];

                // 不可走或已经在close list中,则忽略
                if (newLine < 0 || newLine >= g.length || newColumn < 0 || newColumn >= g[0].length || (g[newLine][newColumn] != 0 && g[newLine][newColumn] != 2))
                    continue;

                // 若没有在open list中则加入
                if (g[newLine][newColumn] != 2) {
                    g[newLine][newColumn] = 2;
                    Point p1 = new Point(newLine, newColumn, p.G + walkStep[i], endLine, endColumn);
                    insertOpenList(openList, p1);
                    pointIndex[newLine][newColumn] = p1;
                } else { // 若在open list中,则根据情况更新
                    int newF = p.G + walkStep[i] + Math.abs((endLine - newLine) * 2) + Math.abs((endColumn - newColumn) * 2);
                    if (newF < pointIndex[newLine][newColumn].F) {
                        Point p1 = new Point(newLine, newColumn, p.G + walkStep[i], endLine, endColumn);
                        openList.remove(pointIndex[newLine][newColumn]);
                        insertOpenList(openList, p1);
                        pointIndex[newLine][newColumn] = p1;
                    }
                }
            }
        }

        return -1;
    }

    // 插入列表
    public static void insertOpenList(List openList, Point p) {
        int insertIndex = Collections.binarySearch(openList, p, (a, b) -> a.F - b.F); // 二分查找需要插入的地方
        if (insertIndex < 0) insertIndex = -(insertIndex + 1);

        openList.add(insertIndex, p);
    }

    private static class Point {
        int line;
        int column;
        int F;
        int G;
        int H;

        // 自动计算F值
        public Point(int line, int column, int G, int endLine, int endColumn) {
            this.line = line;
            this.column = column;
            this.G = G;
            this.H = Math.abs((endLine - line) * 2) + Math.abs((endColumn - column) * 2);
            this.F = this.G + this.H;
        }
    }
}

A*算法的时间复杂度不是很好估计,但是可以初步估计其 空间复杂度为O(V)量级,时间复杂度取决于H函数的计算策略、排序方法等,我们这里的H函数复杂度为O(1),排序算法为O(log(V)),对于每一个需要插入的点都需要一次或多次排序,在极限情况下可以认为对检查每条边都需要进行一次排序,那么排序次数为E次,点的个数为V,边的个数为E,那么按照排序来算,时间复杂度为O(E×log(V))

Dijkstra算法

刚刚在A*算法中,我们认为上下左右走的时候,其步数为2,斜向走的时候步数为3,实际上就是我们为边进行了加权,为了更加方便的讨论,我们从这里开始,将上面的矩阵进行简化,简化为图的形式,并进行一部分点的简化和边的加权简化。

简化后的图 如下所示:


【一题多解系列】图搜索算法深度剖析:DFS、BFS、A*、Dijkstra、Floyd、BF_第9张图片
图,边上的数字表示从一个点到另外一个点所需的代价

如上图所示,一共有6个点,点之间可以互相连接,也可以不互相连接,箭头上的数字代表了从一个点到另外一个点所需要走的步数(代价),试求从S点到E点所需要的最小代价。

Dijkstra算法属于典型的贪心算法,算法思想是记录从S点到达每一个点的当前最短路径,然后不断通过“松弛”操作来进行调整最短路径长度。算法的基本步骤如下:

  • 1、初始化一个列表L,记录其余的各个点和S到达他们的距离,不能直达的则标记距离为无穷大,对于L中能直达的点集合,我们标记为L1,不能直达的点集合标记为L2,所以:L = L1 ∪ L2,然后进行第2步的松弛操作;
  • 2、从L2中取出一个点P,点P需要满足条件:PL2中距离L1中所有的点距离最近的点。然后将P加入L1并计算最短的距离,如果L2中的某点Q距离S的距离满足:distance(S -> Q) 大于 distance(s -> P ->Q),那么更新其距离为distance(s -> P ->Q)
  • 3、重复操作2,直到所有L2为空,或者L2中的点不能加入到L1(非连通图)为止。

按照上述步骤模拟,最终会找出从SE的最短路径为:

【一题多解系列】图搜索算法深度剖析:DFS、BFS、A*、Dijkstra、Floyd、BF_第10张图片
绿色代表最短路线

所需的步数最小为14。

Java实现的代码如下:

import java.util.*;

public class MainTest {

    public static void main(String[] args) {
        // 初始化图
        Point S = new Point('S');
        Point p1 = new Point('p');
        Point p2 = new Point('p');
        Point p3 = new Point('p');
        Point p4 = new Point('p');
        Point E = new Point('E');

        S.addChild(p1, 3);
        S.addChild(p2, 12);
        p2.addChild(p1, 5);
        p1.addChild(p3, 8);
        p3.addChild(p4, 1);
        p2.addChild(p4, 30);
        p3.addChild(E, 10);
        p4.addChild(E, 2);

        System.out.println(dijkstra(S, E)); // 输出 14
    }

    public static int dijkstra(Point S, Point E) {
        Map L1 = new HashMap<>(); // L1列表,为了加快效率这里使用HashMap, key=Point value=length
        L1.put(S, 0);

        while (true) {
            Point P = null;
            int minLen = Integer.MAX_VALUE / 2;

            // 寻找L1中的点的所有子节点
            for (Point Q : L1.keySet()) {
                int existWeight = L1.get(Q);

                for (int i = 0; i < Q.children.size(); i++) {
                    if (L1.containsKey(Q.children.get(i))) continue; // 在L1列表中的忽略

                    if (existWeight + Q.weight.get(i) < minLen) {
                        minLen = existWeight + Q.weight.get(i);
                        P = Q.children.get(i);
                        break; // 这里是一个小优化,插入的时候把权重最小的放在前面,这里可以直接break
                    }
                }
            }

            if (P == null) break;
            if (P == E) return minLen;
            L1.put(P, minLen);
// TODO 这里由于上面是两层循环所以没有做松弛操作
        }

        return -1;
    }

    // 定义节点类型
    private static class Point {
        char name;
        List children = new ArrayList<>(); // 子节点
        List weight = new ArrayList<>(); // 边的权重

        Point(char n) {
            this.name = n;
        }

        // 插入的时候注意,权重最小的放在最前面
        void addChild(Point p, int w) {
            int insertIndex = Collections.binarySearch(weight, w, (a, b) -> a - b); // 二分法查找插入index
            if (insertIndex < 0) insertIndex = -(insertIndex + 1);

            children.add(insertIndex, p);
            weight.add(insertIndex, w);
        }
    }
}

可以看到,实际上Dijkstra算法在计算的过程中,在找到目标节点的时候不返回,那么就求出了从S点到其他任意一点的最短距离。所以该算法比较适合用来求从某个特定的点到另外其他的所有点的最短距离。

Dijkstra算法的时间复杂度,内层的for循环需要循环K次,K = 1 + 2 + 3 + ... + (V-1) = (V^2) / 2,总体时间复杂度为O(V^2)。这里为了加快搜索速度,使用了一个HashMap来存储Point,所以空间复杂度为O(V)

Floyd算法:寻找图中任意两点的最短距离

我们将上面题目变一下:

请输出图中任意两点之间的距离。

最简单的办法是对每个节点都跑一次Dijkstra算法,时间复杂度为O(V^3),这里我们不介绍这种方法。我们主要介绍Floyd算法。

Floyd算法是一种使用动态规划思想来进行计算路径的算法。对于图中的任意三个点i, j, k,我们用distance(i,j)表示从ij的距离,用distance(i, k, j)表示i经过k再到j的距离,主要思路是:

if distance(A, B) > distance(A, C, B) then: distance(A, B) = distance(A, C, B)

Floyd算法一般为了好计算,使用邻接矩阵来进行表示图。
如下图,用邻接矩阵对每个点都进行编号,表示如下:


【一题多解系列】图搜索算法深度剖析:DFS、BFS、A*、Dijkstra、Floyd、BF_第11张图片
图中为每个节点进行了编号

【一题多解系列】图搜索算法深度剖析:DFS、BFS、A*、Dijkstra、Floyd、BF_第12张图片
图的邻接矩阵表示,不可直达的点距离为无穷大

算法基本思路:

每次选取一个中间点k,穷举ij点,如果distance(i,j) > distance(i,k,j)则更新distance(i,j)=distance(i,k,j)

代码实现:

public class MainTest {
    private static int X = Integer.MAX_VALUE / 2; // 表示无穷大

    public static void main(String[] args) {
        int[][] g = new int[][]{
                {0, 3, X, 12, X, X},
                {X, 0, 8, X, X, X},
                {X, X, 0, X, 1, 10},
                {X, 5, X, 0, 30, X},
                {X, X, X, X, 0, 2},
                {X, X, X, X, X, 0}
        };

        int[][] distance = floyd(g);
        for (int i = 0; i < distance.length; i++) {
            for (int j = 0; j < distance[i].length; j++)
                System.out.println("" + i + " -> " + j + " : " + distance[i][j]);
        }
    }

    public static int[][] floyd(int[][] g) {
        int[][] distance = g;  // 这里偷个懒,直接用图的原始权重表示距离

        for (int k = 0; k < g.length; k++) {
            for (int i = 0; i < g.length; i++) {
                for (int j = 0; j < g.length; j++) {
                    if (i == j) continue;
                    if (distance[i][j] > distance[i][k] + distance[k][j])
                        distance[i][j] = distance[i][k] + distance[k][j];
                }
            }
        }

        return distance;
    }
}

输出结果:

0 -> 0 : 0
0 -> 1 : 3
0 -> 2 : 11
0 -> 3 : 12
0 -> 4 : 12
0 -> 5 : 14
1 -> 0 : 1073741823
1 -> 1 : 0
1 -> 2 : 8
1 -> 3 : 1073741823
1 -> 4 : 9
1 -> 5 : 11
2 -> 0 : 1073741823
2 -> 1 : 1073741823
2 -> 2 : 0
2 -> 3 : 1073741823
2 -> 4 : 1
2 -> 5 : 3
3 -> 0 : 1073741823
3 -> 1 : 5
3 -> 2 : 13
3 -> 3 : 0
3 -> 4 : 14
3 -> 5 : 16
4 -> 0 : 1073741823
4 -> 1 : 1073741823
4 -> 2 : 1073741823
4 -> 3 : 1073741823
4 -> 4 : 0
4 -> 5 : 2
5 -> 0 : 1073741823
5 -> 1 : 1073741823
5 -> 2 : 1073741823
5 -> 3 : 1073741823
5 -> 4 : 1073741823
5 -> 5 : 0

可以看到,输出数字特别大的就是从ij不可到达的,我们看0 -> 5 : 14与上面Dijkstra计算的结果一致。

Floyd算法时间复杂度为O(V^3),由于一般需要(我们的代码里面并没有)有一个数组来存储计算结果,所以空间复杂度为O(V)

Bellman-Form算法:解决带负权回路的最短路径算法

我们将上面Dijkstra算法用到的图添加一条边:

【一题多解系列】图搜索算法深度剖析:DFS、BFS、A*、Dijkstra、Floyd、BF_第13张图片
从4到3多了一条权重为-15的边

43多了一条权重为-15的边,这时候再求从 S到其他所有点的最短距离,我们就能明显发现一个问题:

在进行松弛操作的时候,假如当前L1列表中包含了S, 1, 2, 4点,当想纳入3点的时候,会发现,经过3到达1点更近,从S到达1点直接距离为3,经过3点后从S到达1点的距离变为了2,同时也会涉及到2点的距离更新。而我们在Dijkstra中并没有这种更新机制。

实际上,对于上面的情况,我们无法求出S点到达1, 2, 3, 4, 5点的最短路径,或者最短路径为负无穷大。Bellman-Form算法就是在Dijkstra的基础上进行了负权环状回路的检测,检测到这种情况就返回并告知无法求出。

负权回路实际上指的是回路上所有权重的最小值相加后的值是负的,比如上面的图,如果点4到点3的权重为-5,那么就不会存在问题。

那么如何检测负权回路呢?这里给一个公式:

如果松弛完成以后还存在 distance(v) > distance(u) + w(u,v),那么就存在负权回路。带入上图,v就是点2u就是点1

首先,我们拿Dijkstra算法来改一下:

import java.util.*;

public class MainTest {

    public static void main(String[] args) {
        // 初始化图
        Point S = new Point('S');
        Point p1 = new Point('p');
        Point p2 = new Point('p');
        Point p3 = new Point('p');
        Point p4 = new Point('p');
        Point E = new Point('E');

        S.addChild(p1, 3);
        S.addChild(p2, 12);
        p2.addChild(p1, 5);
        p1.addChild(p3, 8);
        p3.addChild(p4, 1);
        p2.addChild(p4, 30);
        p3.addChild(E, 10);
        p4.addChild(E, 2);
        p4.addChild(p2, -15); // 添加一个权重为-15的边

        System.out.println(dijkstra(S, 6)); //输出 null
    }

    // 这里改成了返回从`S`点到到其他所有点的最短路径
    public static Map dijkstra(Point S, int pointCount) {
        Map L1 = new HashMap<>(); // L1列表,为了加快效率这里使用HashMap, key=Point value=length
        L1.put(S, 0);

        while (true) {
            Point P = null;
            int minLen = Integer.MAX_VALUE / 2;

            // 寻找L1中的点的所有子节点
            for (Point Q : L1.keySet()) {
                int existWeight = L1.get(Q);

                for (int i = 0; i < Q.children.size(); i++) {
                    if (L1.containsKey(Q.children.get(i))) continue; // 在L1列表中的忽略

                    if (existWeight + Q.weight.get(i) < minLen) {
                        minLen = existWeight + Q.weight.get(i);
                        P = Q.children.get(i);
                        break; // 这里是一个小优化,插入的时候把权重最小的放在前面,这里可以直接break
                    }
                }
            }

            if (P == null) break;
            L1.put(P, minLen);
        }

        // 如果有的点不可达,返回null
        if (pointCount != L1.size()) return null;

        // 检测负权回路
        for (Point v : L1.keySet()) {
            int distanceV = L1.get(v);
            for (Point u : L1.keySet()) {
                int distanceU = L1.get(u);
                int w = Integer.MAX_VALUE / 2;
                if (u.children.contains(v)) w = u.weight.get(u.children.indexOf(v));
                if (distanceV > distanceU + w) return null;
            }
        }

        return L1;
    }

    // 定义节点类型
    private static class Point {
        char name;
        List children = new ArrayList<>(); // 子节点
        List weight = new ArrayList<>(); // 边的权重

        Point(char n) {
            this.name = n;
        }

        // 插入的时候注意,权重最小的放在最前面
        void addChild(Point p, int w) {
            int insertIndex = Collections.binarySearch(weight, w, (a, b) -> a - b); // 二分法查找插入index
            if (insertIndex < 0) insertIndex = -(insertIndex + 1);

            children.add(insertIndex, p);
            weight.add(insertIndex, w);
        }
    }
}

如果可以不用Dijkstra算法,而定义另外一种松弛操作:

// w[j -> i]表示从j直接到i的权重
if distance[i] > distance[j] + w[j -> i] then distance[i] = distance[j] + w[j -> i]

代码如下:

import java.util.*;

public class MainTest {

    public static void main(String[] args) {
        // 初始化图
        Point S = new Point('S');
        Point p1 = new Point('p');
        Point p2 = new Point('p');
        Point p3 = new Point('p');
        Point p4 = new Point('p');
        Point E = new Point('E');

        S.addChild(p1, 3);
        S.addChild(p2, 12);
        p2.addChild(p1, 5);
        p1.addChild(p3, 8);
        p3.addChild(p4, 1);
        p2.addChild(p4, 30);
        p3.addChild(E, 10);
        p4.addChild(E, 2);
        p4.addChild(p2, -15); // 添加一个权重为-15的边

        List points = Arrays.asList(S, p1, p2, p3, p4, E);
        System.out.println(bellmanForm(S, points)); //输出 null
    }

    // 这里改成了返回从`S`点到到其他所有点的最短路径
    public static Map bellmanForm(Point S, List points) {
        Map L1 = new HashMap<>(); // L1列表,为了加快效率这里使用HashMap, key=Point value=length
        for (Point p : points) L1.put(p, p == S ? 0 : Integer.MAX_VALUE / 2);

        // 松弛操作
        for (int t = 1; t < points.size(); t++) {
            for (int i = 0; i < points.size(); i++) {
                List children = points.get(i).children;
                List weight = points.get(i).weight;
                for (int j = 0; j < children.size(); j++) {
                    if (L1.get(points.get(i)) > L1.get(children.get(j)) + weight.get(j)) {
                        L1.put(points.get(i), L1.get(children.get(j)) + weight.get(j));
                    }
                }
            }
        }

        // 检测负权回路
        for (Point v : L1.keySet()) {
            int distanceV = L1.get(v);
            for (Point u : L1.keySet()) {
                int distanceU = L1.get(u);
                int w = Integer.MAX_VALUE / 2;
                if (u.children.contains(v)) w = u.weight.get(u.children.indexOf(v));
                if (distanceV > distanceU + w) return null;
            }
        }

        return L1;
    }

    // 定义节点类型
    private static class Point {
        char name;
        List children = new ArrayList<>(); // 子节点
        List weight = new ArrayList<>(); // 边的权重

        Point(char n) {
            this.name = n;
        }

        // 插入的时候注意,权重最小的放在最前面
        void addChild(Point p, int w) {
            int insertIndex = Collections.binarySearch(weight, w, (a, b) -> a - b); // 二分法查找插入index
            if (insertIndex < 0) insertIndex = -(insertIndex + 1);

            children.add(insertIndex, p);
            weight.add(insertIndex, w);
        }
    }
}

如果我们不定义Point而是定义Edge数据结构,则松弛操作的写法会更简单,请读者自行实现。

关于为什么松弛操作要循环V-1次,这里简单说明下:每次松弛都只松弛一层,如果结果是可求的话,那么路径长度最多为V-1,在最坏的情况下,对于最前面的边的松弛操作需要V-1次才能传递到路径上的最后一条边(入下图所示),所以这里循环V-1次。

最开始的绿色箭头表示的松弛操作需要3次才能传递到最后一条边

Bellman-Form算法的时间复杂度为O(V*E),主要是在松弛的时候需要对VE做双层的循环。

以上。

你可能感兴趣的:(【一题多解系列】图搜索算法深度剖析:DFS、BFS、A*、Dijkstra、Floyd、BF)