拓撲排序,YYDS

labuladong發表於2021-09-26

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

207.課程表

210.課程表 II

-----------

​很多讀者留言說要看「圖」相關的演算法,那就滿足大家,結合演算法題把圖相關的技巧給大家過一遍。

前文 學習資料結構的框架思維 說了,資料結構相關的演算法無非兩點:遍歷 + 訪問。那麼圖的基本遍歷方法也很簡單,前文 圖演算法基礎 就講了如何從多叉樹的遍歷框架擴充套件到圖的遍歷。

圖這種資料結構還有一些比較特殊的演算法,比如二分圖判斷,有環圖無環圖的判斷,拓撲排序,以及最經典的最小生成樹,單源最短路徑問題,更難的就是類似網路流這樣的問題。

不過以我的經驗呢,像網路流這種問題,你又不是打競賽的,除非自己特別有興趣,否則就沒必要學了;像最小生成樹和最短路徑問題,雖然從刷題的角度用到的不多,但它們屬於經典演算法,學有餘力可以掌握一下;像拓撲排序這一類,屬於比較基本且有用的演算法,應該比較熟練地掌握。

那麼本文就結合具體的演算法題,來說兩個圖論演算法:有向圖的環檢測、拓撲排序演算法。

判斷有向圖是否存在環

先來看看力扣第 207 題「課程表」:

函式簽名如下:

int[] findOrder(int numCourses, int[][] prerequisites);

題目應該不難理解,什麼時候無法修完所有課程?當存在迴圈依賴的時候。

其實這種場景在現實生活中也十分常見,比如我們寫程式碼 import 包也是一個例子,必須合理設計程式碼目錄結構,否則會出現迴圈依賴,編譯器會報錯,所以編譯器實際上也使用了類似演算法來判斷你的程式碼是否能夠成功編譯。

看到依賴問題,首先想到的就是把問題轉化成「有向圖」這種資料結構,只要圖中存在環,那就說明存在迴圈依賴

具體來說,我們首先可以把課程看成「有向圖」中的節點,節點編號分別是 0, 1, ..., numCourses-1,把課程之間的依賴關係看做節點之間的有向邊。

比如說必須修完課程 1 才能去修課程 3,那麼就有一條有向邊從節點 1 指向 3

所以我們可以根據題目輸入的 prerequisites 陣列生成一幅類似這樣的圖:

如果發現這幅有向圖中存在環,那就說明課程之間存在迴圈依賴,肯定沒辦法全部上完;反之,如果沒有環,那麼肯定能上完全部課程

好,那麼想解決這個問題,首先我們要把題目的輸入轉化成一幅有向圖,然後再判斷圖中是否存在環。

如何轉換成圖呢?我們前文 圖論基礎 寫過圖的兩種儲存形式,鄰接矩陣和鄰接表。

以我刷題的經驗,常見的儲存方式是使用鄰接表,比如下面這種結構:

List<Integer>[] graph;

graph[s] 是一個列表,儲存著節點 s 所指向的節點

所以我們首先可以寫一個建圖函式:

List<Integer>[] buildGraph(int numCourses, int[][] prerequisites) {
    // 圖中共有 numCourses 個節點
    List<Integer>[] graph = new LinkedList[numCourses];
    for (int i = 0; i < numCourses; i++) {
        graph[i] = new LinkedList<>();
    }
    for (int[] edge : prerequisites) {
        int from = edge[1];
        int to = edge[0];
        // 修完課程 from 才能修課程 to
        // 在圖中新增一條從 from 指向 to 的有向邊
        graph[from].add(to);
    }
    return graph;
}

圖建出來了,怎麼判斷圖中有沒有環呢?

先不要急,我們先來思考如何遍歷這幅圖,只要會遍歷,就可以判斷圖中是否存在環了

前文 圖論基礎 寫了 DFS 演算法遍歷圖的框架,無非就是從多叉樹遍歷框架擴充套件出來的,加了個 visited 陣列罷了:

// 防止重複遍歷同一個節點
boolean[] visited;
// 從節點 s 開始 BFS 遍歷,將遍歷過的節點標記為 true
void traverse(List<Integer>[] graph, int s) {
    if (visited[s]) {
        return;
    }
    /* 前序遍歷程式碼位置 */
    // 將當前節點標記為已遍歷
    visited[s] = true;
    for (int t : graph[s]) {
        traverse(graph, t);
    }
    /* 後序遍歷程式碼位置 */
}

那麼我們就可以直接套用這個遍歷程式碼:

// 防止重複遍歷同一個節點
boolean[] visited;

boolean canFinish(int numCourses, int[][] prerequisites) {
    List<Integer>[] graph = buildGraph(numCourses, prerequisites);
    
    visited = new boolean[numCourses];
    for (int i = 0; i < numCourses; i++) {
        traverse(graph, i);
    }
}

void traverse(List<Integer>[] graph, int s) {
    // 程式碼見上文
}

注意圖中並不是所有節點都相連,所以要用一個 for 迴圈將所有節點都作為起點呼叫一次 DFS 搜尋演算法。

這樣,就能遍歷這幅圖中的所有節點了,你列印一下 visited 陣列,應該全是 true。

前文 學習資料結構和演算法的框架思維 說過,圖的遍歷和遍歷多叉樹差不多,所以到這裡你應該都能很容易理解。

那麼如何判斷這幅圖中是否存在環呢?

我們前文 回溯演算法核心套路詳解 說過,你可以把遞迴函式看成一個在遞迴樹上游走的指標,這裡也是類似的:

你也可以把 traverse 看做在圖中節點上游走的指標,只需要再新增一個布林陣列 onPath 記錄當前 traverse 經過的路徑:

boolean[] onPath;

boolean hasCycle = false;
boolean[] visited;

void traverse(List<Integer>[] graph, int s) {
    if (onPath[s]) {
        // 發現環!!!
        hasCycle = true;
    }
    if (visited[s]) {
        return;
    }
    // 將節點 s 標記為已遍歷
    visited[s] = true;
    // 開始遍歷節點 s
    onPath[s] = true;
    for (int t : graph[s]) {
        traverse(graph, t);
    }
    // 節點 s 遍歷完成
    onPath[s] = false;
}

這裡就有點回溯演算法的味道了,在進入節點 s 的時候將 onPath[s] 標記為 true,離開時標記回 false,如果發現 onPath[s] 已經被標記,說明出現了環。

PS:參考貪吃蛇沒繞過彎兒咬到自己的場景

這樣,就可以在遍歷圖的過程中順便判斷是否存在環了,完整程式碼如下:

// 記錄一次 traverse 遞迴經過的節點
boolean[] onPath;
// 記錄遍歷過的節點,防止走回頭路
boolean[] visited;
// 記錄圖中是否有環
boolean hasCycle = false;

boolean canFinish(int numCourses, int[][] prerequisites) {
    List<Integer>[] graph = buildGraph(numCourses, prerequisites);
    
    visited = new boolean[numCourses];
    onPath = new boolean[numCourses];
    
    for (int i = 0; i < numCourses; i++) {
        // 遍歷圖中的所有節點
        traverse(graph, i);
    }
    // 只要沒有迴圈依賴可以完成所有課程
    return !hasCycle;
}

void traverse(List<Integer>[] graph, int s) {
    if (onPath[s]) {
        // 出現環
        hasCycle = true;
    }
    
    if (visited[s] || hasCycle) {
        // 如果已經找到了環,也不用再遍歷了
        return;
    }
    // 前序遍歷程式碼位置
    visited[s] = true;
    onPath[s] = true;
    for (int t : graph[s]) {
        traverse(graph, t);
    }
    // 後序遍歷程式碼位置
    onPath[s] = false;
}

List<Integer>[] buildGraph(int numCourses, int[][] prerequisites) {
    // 程式碼見前文
}

這道題就解決了,核心就是判斷一幅有向圖中是否存在環。

不過如果出題人繼續噁心你,讓你不僅要判斷是否存在環,還要返回這個環具體有哪些節點,怎麼辦?

你可能說,onPath 裡面為 true 的索引,不就是組成環的節點編號嗎?

不是的,假設下圖中綠色的節點是遞迴的路徑,它們在 onPath 中的值都是 true,但顯然成環的節點只是其中的一部分:

這個問題留給大家思考,我會在公眾號留言區置頂正確的答案。

那麼接下來,我們來再講一個經典的圖演算法:拓撲排序

拓撲排序

看下力扣第 210 題「課程表 II」:

這道題就是上道題的進階版,不是僅僅讓你判斷是否可以完成所有課程,而是進一步讓你返回一個合理的上課順序,保證開始修每個課程時,前置的課程都已經修完。

函式簽名如下:

int[] findOrder(int numCourses, int[][] prerequisites);

這裡我先說一下拓撲排序(Topological Sorting)這個名詞,網上搜出來的定義很數學,這裡乾脆用百度百科的一幅圖來讓你直觀地感受下:

直觀地說就是,讓你把一幅圖「拉平」,而且這個「拉平」的圖裡面,所有箭頭方向都是一致的,比如上圖所有箭頭都是朝右的。

很顯然,如果一幅有向圖中存在環,是無法進行拓撲排序的,因為肯定做不到所有箭頭方向一致;反過來,如果一幅圖是「有向無環圖」,那麼一定可以進行拓撲排序。

但是我們這道題和拓撲排序有什麼關係呢?

其實也不難看出來,如果把課程抽象成節點,課程之間的依賴關係抽象成有向邊,那麼這幅圖的拓撲排序結果就是上課順序

首先,我們先判斷一下題目輸入的課程依賴是否成環,成環的話是無法進行拓撲排序的,所以我們可以複用上一道題的主函式:

public int[] findOrder(int numCourses, int[][] prerequisites) {
    if (!canFinish(numCourses, prerequisites)) {
        // 不可能完成所有課程
        return new int[]{};
    }
    // ...
}

那麼關鍵問題來了,如何進行拓撲排序?是不是又要秀什麼高大上的技巧了?

其實特別簡單,將後序遍歷的結果進行反轉,就是拓撲排序的結果

直接看解法程式碼:

boolean[] visited;
// 記錄後序遍歷結果
List<Integer> postorder = new ArrayList<>();

int[] findOrder(int numCourses, int[][] prerequisites) {
    // 先保證圖中無環
    if (!canFinish(numCourses, prerequisites)) {
        return new int[]{};
    }
    // 建圖
    List<Integer>[] graph = buildGraph(numCourses, prerequisites);
    // 進行 DFS 遍歷
    visited = new boolean[numCourses];
    for (int i = 0; i < numCourses; i++) {
        traverse(graph, i);
    }
    // 將後序遍歷結果反轉,轉化成 int[] 型別
    Collections.reverse(postorder);
    int[] res = new int[numCourses];
    for (int i = 0; i < numCourses; i++) {
        res[i] = postorder.get(i);
    }
    return res;
}

void traverse(List<Integer>[] graph, int s) {
    if (visited[s]) {
        return;
    }
    
    visited[s] = true;
    for (int t : graph[s]) {
        traverse(graph, t);
    }
    // 後序遍歷位置
    postorder.add(s);
}

// 參考上一題的解法
boolean canFinish(int numCourses, int[][] prerequisites);

// 參考前文程式碼
List<Integer>[] buildGraph(int numCourses, int[][] prerequisites);

程式碼雖然看起來多,但是邏輯應該是很清楚的,只要圖中無環,那麼我們就呼叫 traverse 函式對圖進行 BFS 遍歷,記錄後序遍歷結果,最後把後序遍歷結果反轉,作為最終的答案。

那麼為什麼後序遍歷的反轉結果就是拓撲排序呢

我這裡也避免數學證明,用一個直觀地例子來解釋,我們就說二叉樹,這是我們說過很多次的二叉樹遍歷框架:

void traverse(TreeNode root) {
    // 前序遍歷程式碼位置
    traverse(root.left)
    // 中序遍歷程式碼位置
    traverse(root.right)
    // 後序遍歷程式碼位置
}

二叉樹的後序遍歷是什麼時候?遍歷完左右子樹之後才會執行後序遍歷位置的程式碼。換句話說,當左右子樹的節點都被裝到結果列表裡面了,根節點才會被裝進去。

後序遍歷的這一特點很重要,之所以拓撲排序的基礎是後序遍歷,是因為一個任務必須在等到所有的依賴任務都完成之後才能開始開始執行

你把每個任務理解成二叉樹裡面的節點,這個任務所依賴的任務理解成子節點,那你是不是應該先把所有子節點處理完再處理父節點?這是不是就是後序遍歷?

再說一說為什麼還要把後序遍歷結果反轉,才是最終的拓撲排序結果。

我們說一個節點可以理解為一個任務,這個節點的子節點理解為這個任務的依賴,但你注意我們之前說的依賴關係的表示:如果做完 A 才能去做 B,那麼就有一條從 A 指向 B 的有向邊,表示 B 依賴 A

那麼,父節點依賴子節點,體現在二叉樹裡面應該是這樣的:

是不是和我們正常的二叉樹指標指向反過來了?所以正常的後序遍歷結果應該進行反轉,才是拓撲排序的結果

以上,我簡單解釋了一下為什麼「拓撲排序的結果就是反轉之後的後序遍歷結果」,當然,我的解釋雖然比較直觀,但並沒有嚴格的數學證明,有興趣的讀者可以自己查一下。

總之,你記住拓撲排序就是後序遍歷反轉之後的結果,且拓撲排序只能針對有向無環圖,進行拓撲排序之前要進行環檢測,這些知識點已經足夠了。

_____________

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

相關文章