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

本文為原創文章,轉載請注明出處
查看[數據結構及算法]系列內容請點擊:http://www.lxweimin.com/nb/45938463

本文通過一個例子及其變種,由淺入深解析常見的圖搜索算法。

例子如下:

假設有如下矩陣:


S表示開始節點,E表示結束節點

目標:假如一個人站在圖中的S方格,他一次可以往上下左右方向移動一格,但不能走到黑色的方格上,計算從S(Start)方格到E(End)方格最少需要走幾格?

這個問題及其變種有很多種解決方案,下面我們說【方格】或者說【點】指的都是方格,下面就從這個問題開始一一介紹這些算法。

DFS:深度優先搜索算法

最簡單(但不一定最適合)的方法就是深度優先搜索算法,深度優先一般使用遞歸策略,可以理解為窮舉從SE的所有路徑,然后找到一個最短的路徑即可。需要注意的是,在一次搜索路徑中,已經被走過的方格不能再次走上去。

假設我們搜索的方法是從S的12點方向順時針搜索,即遵循先找最上面相鄰的點,其次找右上角,再次右邊的點...如此往復... 那么第一次從S搜索到E的路徑如下所示:

第一次搜索路徑

第一次搜索完成后記錄下這個路徑的長度,然后繼續從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}};

    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的路線為:


步數為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點就可以結束了。同時,由于后擴展的節點步長肯定比前面擴展的步長更長,所以可以設置全局訪問過的節點不再繼續訪問。基本擴展步驟如下:

擴展步驟:不同的顏色代表不同的擴展步驟

如圖所示,第一次擴展使用紅色箭頭表示,擴展其相鄰方哥,第二次擴展用綠色箭頭表示...如此往復,等到第四次擴展訪問到了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值的點,隨便選取一個擴展即可):

第一次搜索擴展,灰色S代表加入到close list中的點

第二次搜索擴展如下:


第二次搜索擴展,灰色字體格子代表加入close list的點

經過多輪擴展后的最終結果:


最終結果,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<Point> 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<Point> 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,實際上就是我們為邊進行了加權,為了更加方便的討論,我們從這里開始,將上面的矩陣進行簡化,簡化為圖的形式,并進行一部分點的簡化和邊的加權簡化。

簡化后的圖 如下所示:


圖,邊上的數字表示從一個點到另外一個點所需的代價

如上圖所示,一共有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的最短路徑為:

綠色代表最短路線

所需的步數最小為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<Point, Integer> 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<Point> children = new ArrayList<>(); // 子節點
        List<Integer> 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算法一般為了好計算,使用鄰接矩陣來進行表示圖。
如下圖,用鄰接矩陣對每個點都進行編號,表示如下:


圖中為每個節點進行了編號

圖的鄰接矩陣表示,不可直達的點距離為無窮大

算法基本思路:

每次選取一個中間點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算法用到的圖添加一條邊:

從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<Point, Integer> dijkstra(Point S, int pointCount) {
        Map<Point, Integer> 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<Point> children = new ArrayList<>(); // 子節點
        List<Integer> 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<Point> points = Arrays.asList(S, p1, p2, p3, p4, E);
        System.out.println(bellmanForm(S, points)); //輸出 null
    }

    // 這里改成了返回從`S`點到到其他所有點的最短路徑
    public static Map<Point, Integer> bellmanForm(Point S, List<Point> points) {
        Map<Point, Integer> 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<Point> children = points.get(i).children;
                List<Integer> 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<Point> children = new ArrayList<>(); // 子節點
        List<Integer> 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做雙層的循環。

以上。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,837評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,196評論 3 414
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,688評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,654評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,456評論 6 406
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,955評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,044評論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,195評論 0 287
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,725評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,608評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,802評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,318評論 5 358
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,048評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,422評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,673評論 1 281
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,424評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,762評論 2 372