讀完本文,你不僅學會了演算法套路,還可以順便去 LeetCode 上拿下如下題目:
- 判斷二分圖(中等)
- 可能的二分法(中等)
-----------
我之前寫了好幾篇圖論相關的文章:
今天繼續來講一個經典圖論演算法:二分圖判定。
二分圖簡介
在講二分圖的判定演算法之前,我們先來看下百度百科對「二分圖」的定義:
二分圖的頂點集可分割為兩個互不相交的子集,圖中每條邊依附的兩個頂點都分屬於這兩個子集,且兩個子集內的頂點不相鄰。
其實圖論裡面很多術語的定義都比較拗口,不容易理解。我們甭看這個死板的定義了,來玩個遊戲吧:
給你一幅「圖」,請你用兩種顏色將圖中的所有頂點著色,且使得任意一條邊的兩個端點的顏色都不相同,你能做到嗎?
這就是圖的「雙色問題」,其實這個問題就等同於二分圖的判定問題,如果你能夠成功地將圖染色,那麼這幅圖就是一幅二分圖,反之則不是:
在具體講解二分圖判定演算法之前,我們先來說說計算機大佬們閒著無聊解決雙色問題的目的是什麼。
首先,二分圖作為一種特殊的圖模型,會被很多高階圖演算法(比如最大流演算法)用到,不過這些高階演算法我們不是特別有必要去掌握,有興趣的讀者可以自行搜尋。
從簡單實用的角度來看,二分圖結構在某些場景可以更高效地儲存資料。
比如前文 介紹《演算法 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 演算法)完全一樣,也是根據相鄰節點 v
和 w
的顏色來進行判斷的。關於 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,歡迎點贊!