圖論演算法遍歷基礎

labuladong發表於2022-01-21

讀完本文,你不僅學會了演算法套路,還可以順便去 LeetCode 上拿下如下題目:

797. 所有可能的路徑(中等)

-----------

經常有讀者問我「圖」這種資料結構,其實我在 學習資料結構和演算法的框架思維 中說過,雖然圖可以玩出更多的演算法,解決更復雜的問題,但本質上圖可以認為是多叉樹的延伸。

面試筆試很少出現圖相關的問題,就算有,大多也是簡單的遍歷問題,基本上可以完全照搬多叉樹的遍歷。

那麼,本文依然秉持我們號的風格,只講「圖」最實用的,離我們最近的部分,讓你心裡對圖有個直觀的認識,文末我給出了其他經典圖論演算法,理解本文後應該都可以拿下的。

圖的邏輯結構和具體實現

一幅圖是由節點構成的,邏輯結構如下:

什麼叫「邏輯結構」?就是說為了方便研究,我們把圖抽象成這個樣子

根據這個邏輯結構,我們可以認為每個節點的實現如下:

/* 圖節點的邏輯結構 */
class Vertex {
    int id;
    Vertex[] neighbors;
}

看到這個實現,你有沒有很熟悉?它和我們之前說的多叉樹節點幾乎完全一樣:

/* 基本的 N 叉樹節點 */
class TreeNode {
    int val;
    TreeNode[] children;
}

所以說,圖真的沒啥高深的,就是高階點的多叉樹而已。

不過呢,上面的這種實現是「邏輯上的」,實際上我們很少用這個 Vertex 類實現圖,而是用常說的鄰接表和鄰接矩陣來實現。

比如還是剛才那幅圖:

用鄰接表和鄰接矩陣的儲存方式如下:

鄰接表很直觀,我把每個節點 x 的鄰居都存到一個列表裡,然後把 x 和這個列表關聯起來,這樣就可以通過一個節點 x 找到它的所有相鄰節點。

鄰接矩陣則是一個二維布林陣列,我們權且稱為 matrix,如果節點 xy 是相連的,那麼就把 matrix[x][y] 設為 true(上圖中綠色的方格代表 true)。如果想找節點 x 的鄰居,去掃一圈 matrix[x][..] 就行了。

如果用程式碼的形式來表現,鄰接表和鄰接矩陣大概長這樣:

// 鄰接矩陣
// graph[x] 儲存 x 的所有鄰居節點
List<Integer>[] graph;

// 鄰接矩陣
// matrix[x][y] 記錄 x 是否有一條指向 y 的邊
boolean[][] matrix;

那麼,為什麼有這兩種儲存圖的方式呢?肯定是因為他們各有優劣

對於鄰接表,好處是佔用的空間少。

你看鄰接矩陣裡面空著那麼多位置,肯定需要更多的儲存空間。

但是,鄰接表無法快速判斷兩個節點是否相鄰。

比如說我想判斷節點 1 是否和節點 3 相鄰,我要去鄰接表裡 1 對應的鄰居列表裡查詢 3 是否存在。但對於鄰接矩陣就簡單了,只要看看 matrix[1][3] 就知道了,效率高。

所以說,使用哪一種方式實現圖,要看具體情況。

好了,對於「圖」這種資料結構,能看懂上面這些就綽綽夠用了。

那你可能會問,我們這個圖的模型僅僅是「有向無權圖」,不是還有什麼加權圖,無向圖,等等……

其實,這些更復雜的模型都是基於這個最簡單的圖衍生出來的

有向加權圖怎麼實現?很簡單呀:

如果是鄰接表,我們不僅僅儲存某個節點 x 的所有鄰居節點,還儲存 x 到每個鄰居的權重,不就實現加權有向圖了嗎?

如果是鄰接矩陣,matrix[x][y] 不再是布林值,而是一個 int 值,0 表示沒有連線,其他值表示權重,不就變成加權有向圖了嗎?

如果用程式碼的形式來表現,大概長這樣:

// 鄰接矩陣
// graph[x] 儲存 x 的所有鄰居節點以及對應的權重
List<int[]>[] graph;

// 鄰接矩陣
// matrix[x][y] 記錄 x 指向 y 的邊的權重,0 表示不相鄰
int[][] matrix;

無向圖怎麼實現?也很簡單,所謂的「無向」,是不是等同於「雙向」?

如果連線無向圖中的節點 xy,把 matrix[x][y]matrix[y][x] 都變成 true 不就行了;鄰接表也是類似的操作,在 x 的鄰居列表裡新增 y,同時在 y 的鄰居列表裡新增 x

把上面的技巧合起來,就變成了無向加權圖……

好了,關於圖的基本介紹就到這裡,現在不管來什麼亂七八糟的圖,你心裡應該都有底了。

下面來看看所有資料結構都逃不過的問題:遍歷。

圖的遍歷

學習資料結構和演算法的框架思維 說過,各種資料結構被發明出來無非就是為了遍歷和訪問,所以「遍歷」是所有資料結構的基礎

圖怎麼遍歷?還是那句話,參考多叉樹,多叉樹的遍歷框架如下:

/* 多叉樹遍歷框架 */
void traverse(TreeNode root) {
    if (root == null) return;

    for (TreeNode child : root.children) {
        traverse(child);
    }
}

圖和多叉樹最大的區別是,圖是可能包含環的,你從圖的某一個節點開始遍歷,有可能走了一圈又回到這個節點。

所以,如果圖包含環,遍歷框架就要一個 visited 陣列進行輔助:

// 記錄被遍歷過的節點
boolean[] visited;
// 記錄從起點到當前節點的路徑
boolean[] onPath;

/* 圖遍歷框架 */
void traverse(Graph graph, int s) {
    if (visited[s]) return;
    // 經過節點 s,標記為已遍歷
    visited[s] = true;
    // 做選擇:標記節點 s 在路徑上
    onPath[s] = true;
    for (int neighbor : graph.neighbors(s)) {
        traverse(graph, neighbor);
    }
    // 撤銷選擇:節點 s 離開路徑
    onPath[s] = false;
}

注意 visited 陣列和 onPath 陣列的區別,因為二叉樹算是特殊的圖,所以用遍歷二叉樹的過程來理解下這兩個陣列的區別:

上述 GIF 描述了遞迴遍歷二叉樹的過程,在 visited 中被標記為 true 的節點用灰色表示,在 onPath 中被標記為 true 的節點用綠色表示,這下你可以理解它們二者的區別了吧。

如果讓你處理路徑相關的問題,這個 onPath 變數是肯定會被用到的,比如 拓撲排序 中就有運用。

另外,你應該注意到了,這個 onPath 陣列的操作很像 回溯演算法核心套路 中做「做選擇」和「撤銷選擇」,區別在於位置:回溯演算法的「做選擇」和「撤銷選擇」在 for 迴圈裡面,而對 onPath 陣列的操作在 for 迴圈外面。

在 for 迴圈裡面和外面唯一的區別就是對根節點的處理。

比如下面兩種多叉樹的遍歷:

void traverse(TreeNode root) {
    if (root == null) return;
    System.out.println("enter: " + root.val);
    for (TreeNode child : root.children) {
        traverse(child);
    }
    System.out.println("leave: " + root.val);
}

void traverse(TreeNode root) {
    if (root == null) return;
    for (TreeNode child : root.children) {
        System.out.println("enter: " + child.val);
        traverse(child);
        System.out.println("leave: " + child.val);
    }
}

前者會正確列印所有節點的進入和離開資訊,而後者唯獨會少列印整棵樹根節點的進入和離開資訊。

為什麼回溯演算法框架會用後者?因為回溯演算法關注的不是節點,而是樹枝,不信你看 回溯演算法核心套路 裡面的圖。

顯然,對於這裡「圖」的遍歷,我們應該把 onPath 的操作放到 for 迴圈外面,否則會漏掉記錄起始點的遍歷。

說了這麼多 onPath 陣列,再說下 visited 陣列,其目的很明顯了,由於圖可能含有環,visited 陣列就是防止遞迴重複遍歷同一個節點進入死迴圈的。

當然,如果題目告訴你圖中不含環,可以把 visited 陣列都省掉,基本就是多叉樹的遍歷。

題目實踐

下面我們來看力扣第 797 題「所有可能路徑」,函式簽名如下:

List<List<Integer>> allPathsSourceTarget(int[][] graph);

題目輸入一幅有向無環圖,這個圖包含 n 個節點,標號為 0, 1, 2,..., n - 1,請你計算所有從節點 0 到節點 n - 1 的路徑。

輸入的這個 graph 其實就是「鄰接表」表示的一幅圖,graph[i] 儲存這節點 i 的所有鄰居節點。

比如輸入 graph = [[1,2],[3],[3],[]],就代表下面這幅圖:

演算法應該返回 [[0,1,3],[0,2,3]],即 03 的所有路徑。

解法很簡單,以 0 為起點遍歷圖,同時記錄遍歷過的路徑,當遍歷到終點時將路徑記錄下來即可

既然輸入的圖是無環的,我們就不需要 visited 陣列輔助了,直接套用圖的遍歷框架:

// 記錄所有路徑
List<List<Integer>> res = new LinkedList<>();
    
public List<List<Integer>> allPathsSourceTarget(int[][] graph) {
    // 維護遞迴過程中經過的路徑
    LinkedList<Integer> path = new LinkedList<>();
    traverse(graph, 0, path);
    return res;
}

/* 圖的遍歷框架 */
void traverse(int[][] graph, int s, LinkedList<Integer> path) {

    // 新增節點 s 到路徑
    path.addLast(s);

    int n = graph.length;
    if (s == n - 1) {
        // 到達終點
        res.add(new LinkedList<>(path));
        path.removeLast();
        return;
    }

    // 遞迴每個相鄰節點
    for (int v : graph[s]) {
        traverse(graph, v, path);
    }
    
    // 從路徑移出節點 s
    path.removeLast();
}

這道題就這樣解決了,注意 Java 的語言特性,向 res 中新增 path 時需要拷貝一個新的列表,否則最終 res 中的列表都是空的。

最後總結一下,圖的儲存方式主要有鄰接表和鄰接矩陣,無論什麼花裡胡哨的圖,都可以用這兩種方式儲存。

在筆試中,最常考的演算法是圖的遍歷,和多叉樹的遍歷框架是非常類似的。

當然,圖還會有很多其他的有趣演算法,比如 二分圖判定環檢測和拓撲排序(編譯器迴圈引用檢測就是類似的演算法),最小生成樹Dijkstra 最短路徑演算法 等等,有興趣的讀者可以去看看,本文就到這了。

_____________

檢視更多優質演算法文章 點選我的頭像,手把手帶你刷力扣,致力於把演算法講清楚!我的 演算法教程 已經獲得 90k star,歡迎點贊!

相關文章