東哥帶你刷圖論第四期:二分圖的判定

labuladong發表於2021-11-03

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

  1. 判斷二分圖(中等)
  2. 可能的二分法(中等)

-----------

我之前寫了好幾篇圖論相關的文章:

圖遍歷演算法

名流問題

並查集演算法計算連通分量

環檢測和拓撲排序

Dijkstra 最短路徑演算法

今天繼續來講一個經典圖論演算法:二分圖判定。

二分圖簡介

在講二分圖的判定演算法之前,我們先來看下百度百科對「二分圖」的定義:

二分圖的頂點集可分割為兩個互不相交的子集,圖中每條邊依附的兩個頂點都分屬於這兩個子集,且兩個子集內的頂點不相鄰。

其實圖論裡面很多術語的定義都比較拗口,不容易理解。我們甭看這個死板的定義了,來玩個遊戲吧:

給你一幅「圖」,請你用兩種顏色將圖中的所有頂點著色,且使得任意一條邊的兩個端點的顏色都不相同,你能做到嗎

這就是圖的「雙色問題」,其實這個問題就等同於二分圖的判定問題,如果你能夠成功地將圖染色,那麼這幅圖就是一幅二分圖,反之則不是:

在具體講解二分圖判定演算法之前,我們先來說說計算機大佬們閒著無聊解決雙色問題的目的是什麼。

首先,二分圖作為一種特殊的圖模型,會被很多高階圖演算法(比如最大流演算法)用到,不過這些高階演算法我們不是特別有必要去掌握,有興趣的讀者可以自行搜尋。

從簡單實用的角度來看,二分圖結構在某些場景可以更高效地儲存資料。

比如前文 介紹《演算法 4》 文章中的例子,如何儲存電影演員和電影之間的關係?

如果用雜湊表儲存,需要兩個雜湊表分別儲存「每個演員到電影列表」的對映和「每部電影到演員列表」的對映。

但如果用「圖」結構儲存,將電影和參演的演員連線,很自然地就成為了一幅二分圖:

每個電影節點的相鄰節點就是參演該電影的所有演員,每個演員的相鄰節點就是該演員參演過的所有電影,非常方便直觀。

類比這個例子,其實生活中不少實體的關係都能自然地形成二分圖結構,所以在某些場景下圖結構也可以作為儲存鍵值對的資料結構(符號表)。

好了,接下來進入正題,說說如何判定一幅圖是否是二分圖。

二分圖判定思路

判定二分圖的演算法很簡單,就是用程式碼解決「雙色問題」。

說白了就是遍歷一遍圖,一邊遍歷一遍染色,看看能不能用兩種顏色給所有節點染色,且相鄰節點的顏色都不相同

既然說到遍歷圖,也不涉及最短路徑之類的,當然是 DFS 演算法和 BFS 皆可了,DFS 演算法相對更常用些,所以我們先來看看如何用 DFS 演算法判定雙色圖。

首先,基於 學習資料結構和演算法的框架思維 寫出圖的遍歷框架:

/* 二叉樹遍歷框架 */
void traverse(TreeNode root) {
    if (root == null) return;
    traverse(root.left);
    traverse(root.right);
}

/* 多叉樹遍歷框架 */
void traverse(Node root) {
    if (root == null) return;
    for (Node child : root.children)
        traverse(child);
}

/* 圖遍歷框架 */
boolean[] visited;
void traverse(Graph graph, int v) {
    // 防止走回頭路進入死迴圈
    if (visited[v]) return;
    // 前序遍歷位置,標記節點 v 已訪問
    visited[v] = true;
    for (TreeNode neighbor : graph.neighbors(v))
        traverse(graph, neighbor);
}

因為圖中可能存在環,所以用 visited 陣列防止走回頭路。

這裡可以看到我習慣把 return 語句都放在函式開頭,因為一般 return 語句都是 base case,集中放在一起可以讓演算法結構更清晰

其實,如果你願意,也可以把 if 判斷放到其它地方,比如圖遍歷框架可以稍微改改:

/* 圖遍歷框架 */
boolean[] visited;
void traverse(Graph graph, int v) {
    // 前序遍歷位置,標記節點 v 已訪問
    visited[v] = true;
    for (int neighbor : graph.neighbors(v)) {
        if (!visited[neighbor]) {
            // 只遍歷沒標記過的相鄰節點
            traverse(graph, neighbor);
        }
    }
}

這種寫法把對 visited 的判斷放到遞迴呼叫之前,和之前的寫法唯一的不同就是,你需要保證呼叫 traverse(v) 的時候,visited[v] == false

為什麼要特別說這種寫法呢?因為我們判斷二分圖的演算法會用到這種寫法。

回顧一下二分圖怎麼判斷,其實就是讓 traverse 函式一邊遍歷節點,一邊給節點染色,嘗試讓每對相鄰節點的顏色都不一樣

所以,判定二分圖的程式碼邏輯可以這樣寫:

/* 圖遍歷框架 */
void traverse(Graph graph, boolean[] visited, int v) {
    visited[v] = true;
    // 遍歷節點 v 的所有相鄰節點 neighbor
    for (int neighbor : graph.neighbors(v)) {
        if (!visited[neighbor]) {
            // 相鄰節點 neighbor 沒有被訪問過
            // 那麼應該給節點 neighbor 塗上和節點 v 不同的顏色
            traverse(graph, visited, neighbor);
        } else {
            // 相鄰節點 neighbor 已經被訪問過
            // 那麼應該比較節點 neighbor 和節點 v 的顏色
            // 若相同,則此圖不是二分圖
        }
    }
}

如果你能看懂上面這段程式碼,就能寫出二分圖判定的具體程式碼了,接下來看兩道具體的演算法題來實操一下。

題目實踐

力扣第 785 題「判斷二分圖」就是原題,題目給你輸入一個 鄰接表 表示一幅無向圖,請你判斷這幅圖是否是二分圖。

函式簽名如下:

boolean isBipartite(int[][] graph);

比如題目給的例子,輸入的鄰接表 graph = [[1,2,3],[0,2],[0,1,3],[0,2]],也就是這樣一幅圖:

顯然無法對節點著色使得每兩個相鄰節點的顏色都不相同,所以演算法返回 false。

但如果輸入 graph = [[1,3],[0,2],[1,3],[0,2]],也就是這樣一幅圖:

如果把節點 {0, 2} 塗一個顏色,節點 {1, 3} 塗另一個顏色,就可以解決「雙色問題」,所以這是一幅二分圖,演算法返回 true。

結合之前的程式碼框架,我們可以額外使用一個 color 陣列來記錄每個節點的顏色,從而寫出解法程式碼:

// 記錄圖是否符合二分圖性質
private boolean ok = true;
// 記錄圖中節點的顏色,false 和 true 代表兩種不同顏色
private boolean[] color;
// 記錄圖中節點是否被訪問過
private boolean[] visited;

// 主函式,輸入鄰接表,判斷是否是二分圖
public boolean isBipartite(int[][] graph) {
    int n = graph.length;
    color =  new boolean[n];
    visited =  new boolean[n];
    // 因為圖不一定是聯通的,可能存在多個子圖
    // 所以要把每個節點都作為起點進行一次遍歷
    // 如果發現任何一個子圖不是二分圖,整幅圖都不算二分圖
    for (int v = 0; v < n; v++) {
        if (!visited[v]) {
            traverse(graph, v);
        }
    }
    return ok;
}

// DFS 遍歷框架
private void traverse(int[][] graph, int v) {
    // 如果已經確定不是二分圖了,就不用浪費時間再遞迴遍歷了
    if (!ok) return;

    visited[v] = true;
    for (int w : graph[v]) {
        if (!visited[w]) {
            // 相鄰節點 w 沒有被訪問過
            // 那麼應該給節點 w 塗上和節點 v 不同的顏色
            color[w] = !color[v];
            // 繼續遍歷 w
            traverse(graph, w);
        } else {
            // 相鄰節點 w 已經被訪問過
            // 根據 v 和 w 的顏色判斷是否是二分圖
            if (color[w] == color[v]) {
                // 若相同,則此圖不是二分圖
                ok = false;
            }
        }
    }
}

這就是解決「雙色問題」的程式碼,如果能成功對整幅圖染色,則說明這是一幅二分圖,否則就不是二分圖。

接下來看一下 BFS 演算法的邏輯:

// 記錄圖是否符合二分圖性質
private boolean ok = true;
// 記錄圖中節點的顏色,false 和 true 代表兩種不同顏色
private boolean[] color;
// 記錄圖中節點是否被訪問過
private boolean[] visited;

public boolean isBipartite(int[][] graph) {
    int n = graph.length;
    color =  new boolean[n];
    visited =  new boolean[n];
    
    for (int v = 0; v < n; v++) {
        if (!visited[v]) {
            // 改為使用 BFS 函式
            bfs(graph, v);
        }
    }
    
    return ok;
}

// 從 start 節點開始進行 BFS 遍歷
private void bfs(int[][] graph, int start) {
    Queue<Integer> q = new LinkedList<>();
    visited[start] = true;
    q.offer(start);
    
    while (!q.isEmpty() && ok) {
        int v = q.poll();
        // 從節點 v 向所有相鄰節點擴散
        for (int w : graph[v]) {
            if (!visited[w]) {
                // 相鄰節點 w 沒有被訪問過
                // 那麼應該給節點 w 塗上和節點 v 不同的顏色
                color[w] = !color[v];
                // 標記 w 節點,並放入佇列
                visited[w] = true;
                q.offer(w);
            } else {
                // 相鄰節點 w 已經被訪問過
                // 根據 v 和 w 的顏色判斷是否是二分圖
                if (color[w] == color[v]) {
                    // 若相同,則此圖不是二分圖
                    ok = false;
                }
            }
        }
    }
}

核心邏輯和剛才實現的 traverse 函式(DFS 演算法)完全一樣,也是根據相鄰節點 vw 的顏色來進行判斷的。關於 BFS 演算法框架的探討,詳見前文 BFS 演算法框架Dijkstra 演算法模板,這裡就不展開了。

最後再來看看力扣第 886 題「可能的二分法」:

函式簽名如下:

boolean possibleBipartition(int n, int[][] dislikes);

其實這題考察的就是二分圖的判定

如果你把每個人看做圖中的節點,相互討厭的關係看做圖中的邊,那麼 dislikes 陣列就可以構成一幅圖;

又因為題目說互相討厭的人不能放在同一組裡,相當於圖中的所有相鄰節點都要放進兩個不同的組;

那就回到了「雙色問題」,如果能夠用兩種顏色著色所有節點,且相鄰節點顏色都不同,那麼你按照顏色把這些節點分成兩組不就行了嘛。

所以解法就出來了,我們把 dislikes 構造成一幅圖,然後執行二分圖的判定演算法即可:

private boolean ok = true;
private boolean[] color;
private boolean[] visited;

public boolean possibleBipartition(int n, int[][] dislikes) {
    // 圖節點編號從 1 開始
    color = new boolean[n + 1];
    visited = new boolean[n + 1];
    // 轉化成鄰接表表示圖結構
    List<Integer>[] graph = buildGraph(n, dislikes);
    
    for (int v = 1; v <= n; v++) {
        if (!visited[v]) {
            traverse(graph, v);
        }
    }
    
    return ok;
}

// 建圖函式
private List<Integer>[] buildGraph(int n, int[][] dislikes) {
    // 圖節點編號為 1...n
    List<Integer>[] graph = new LinkedList[n + 1];
    for (int i = 1; i <= n; i++) {
        graph[i] = new LinkedList<>();
    }
    for (int[] edge : dislikes) {
        int v = edge[1];
        int w = edge[0];
        // 「無向圖」相當於「雙向圖」
        // v -> w
        graph[v].add(w);
        // w -> v
        graph[w].add(v);
    }
    return graph;
}

// 和之前的 traverse 函式完全相同
private void traverse(List<Integer>[] graph, int v) {
    if (!ok) return;
    visited[v] = true;
    for (int w : graph[v]) {
        if (!visited[w]) {
            color[w] = !color[v];
            traverse(graph, w);
        } else {
            if (color[w] == color[v]) {
                ok = false;
            }
        }
    }
}

至此,這道題也使用 DFS 演算法解決了,如果你想用 BFS 演算法,和之前寫的解法是完全一樣的,可以自己嘗試實現。

二分圖的判定演算法就講到這裡,更多二分圖的高階演算法,敬請期待。

_____________

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

相關文章