演算法圖鑑
Graph Algorithms · 06 / 11

Bipartite Check二分圖判定

兩色染色,相鄰不同色

用在:配對問題、衝突分組

時間複雜度O(V+E)
空間複雜度O(V)
難度進階
前置知識BFS、DFS、Union-Find

01為什麼需要它

期末考只借到兩個時段

系辦只借到上午、下午兩個考場時段,要排 60 門課的期末考。只要有學生同時修兩門課,那兩門就不能排在同一個時段,選課資料裡這樣的課程組合有 400 組。

為什麼用它課程當節點、衝突當邊,問題就變成「能不能用兩種顏色塗、相鄰不同色」。任選一門課放上午,和它衝突的課只能放下午,顏色一路被逼出來,一次 BFS 就有答案。排不下時,演算法找到的奇環(例如三門課兩兩衝突)就是「為什麼排不下」的具體證據,知道該把哪幾門移到補考時段。

雙層電路板的走線分層

一塊雙層板上有 90 條走線,畫在同一個平面時有 140 對會交叉。交叉的兩條不能在同一層,只能一條走正面、一條走背面。

為什麼用它把「會交叉」建成衝突圖,二分圖判定直接給出每條線走哪一層,O(V+E) 的成本在設計工具裡可以每改一次線就重跑。判定失敗時,奇環指出是哪幾條線互相卡住,工程師只要針對這一小群線加導通孔(via)或改道。

跑配對演算法之前先分出兩邊

家教媒合平台要匯入舊系統的 8,000 個帳號與 25,000 筆「上過課」紀錄,再用二分匹配自動排下學期的課。但舊資料每筆只有兩個帳號 ID,沒有欄位標出誰是老師、誰是學生。

為什麼用它二分匹配(例如 Hopcroft–Karp)要先知道左右兩邊。BFS 染色一遍就把帳號分成兩組,順便抓出髒資料:某個連通分量染色失敗代表紀錄裡有奇環,例如三個帳號兩兩上過課,這群帳號要先人工檢查。染色只能確定兩組「彼此對立」,哪組是老師,要看分量裡任一個已知身分的帳號。

看到這些關鍵字就想到它:分成兩組、兩兩衝突不能同組、只有兩個時段/兩層/兩隊、相鄰不同色、奇數長度的環、二分匹配的前置、敵人的敵人是朋友。

02核心概念

二分圖是節點能分成兩組、每條邊都橫跨兩組、同組之間沒有邊的圖。換個說法:能不能用兩種顏色塗每個節點,讓相鄰的節點不同色。判定的關鍵是顏色沒有選擇空間:起點塗 0,它的鄰居只能是 1,鄰居的鄰居只能是 0,整個連通分量的顏色都被起點這一個決定逼出來。所以不需要試誤或回溯,用 BFS(或 DFS)走一遍,邊走邊塗,檢查每條邊兩端是否同色就好。在 BFS 裡,節點的顏色其實就是它到起點距離的奇偶 dist % 2

正確性靠一個定理:圖是二分圖,若且唯若圖裡沒有奇數長度的環。一個方向很直接:沿著環走,顏色必須 0、1、0、1 交替,走奇數步回到起點時顏色對不上,所以有奇環一定塗不出來。另一個方向正是演算法的保證:若發現邊 (u, v) 兩端同色,表示 depth[u]depth[v] 奇偶相同;從 uv 各自沿 BFS 樹往上走到會合點 w,兩段長度 depth[u] − depth[w]depth[v] − depth[w] 相加是偶數,再加上邊 (u, v) 就是一個奇環。因此衝突不是「起點塗錯色」造成的假警報,換任何塗法都救不回來;反過來,走完沒有衝突,手上的顏色本身就是一組合法的分法。

每個節點只會被塗色、進出佇列各一次,O(V);無向圖的每條邊在鄰接串列裡出現兩次,各檢查一次,O(E);外層迴圈掃過所有節點找未塗色的起點,再加 O(V)。總時間 O(V + E)。遇到衝突可以立刻停,所以「不是」可能很快就知道,但要確認「是」一定得看完每條邊。額外空間是顏色陣列加佇列 O(V)(不含圖本身);遞迴 DFS 也是 O(V),但一條長鏈就讓遞迴深度到 V,Python 容易超過遞迴上限。如果邊是一條一條加進來、每加一條就要回答,改用併查集:每個節點拆成「自己這一側」和「對面」兩份,每次加邊幾乎是常數時間(範例只做路徑減半;再加上按大小合併,就是嚴格的 O(E·α(V)))。

最常見的錯是只從節點 0 開始:圖不連通時其他分量完全沒檢查,外層一定要對每個還沒塗色的節點各起一次(LeetCode 785 題目就明說圖可能不連通)。每個連通分量的 0 和 1 可以整組對調,有 c 個分量就有 2^c 種塗法,題目若要某一組盡量大,要逐個分量決定。自環 (u, u) 讓節點和自己同色,直接判定不是二分圖;有向邊當成無向看。和環偵測不同,這裡有環沒關係,只怕奇環,正方形這種偶環照樣是二分圖。和一般的圖著色相比,兩色能在線性時間判定,但「能不能用三色塗」是 NP 完全問題,沒有已知的多項式演算法,兩色是特別好做的特例。

03演算法步驟

  1. 1建無向鄰接串列(有向邊也當成無向),開 color 陣列全部設為 -1,代表還沒塗色。
  2. 2依序掃每個節點 s:若 color[s] == -1,它是新連通分量的起點,塗 0 並放入佇列。
  3. 3從佇列取出 u,看每個鄰居 v:還沒塗色就塗 1 − color[u] 並放入佇列;已塗色且 color[v] == color[u]立刻回傳「不是二分圖」;顏色不同則略過。
  4. 4佇列清空後回到步驟 2 找下一個起點。全部跑完都沒有衝突就是二分圖,color 為 0 與 1 的節點各是一組。
  5. 5要奇環當證據:多記 parentdepth,從衝突邊 (u, v) 兩端讓較深的一端先往上爬,直到會合,兩段路徑接起來再加上這條邊。
  6. 6邊是動態加入時改用併查集,開 2n 個節點:加邊 (u, v) 前若 find(u) == find(v) 就衝突,否則合併 uv + nvu + n

04互動示範

兩個例子都是 A 到 F 六個節點、七條邊,從 A 開始 BFS,鄰居按字母順序處理。「例子 1:可二分」由 A–B–C–D 和 C–E–F–D 兩個四邊形組成,全是偶環,最後藍色(顏色 0)是 A、C、F,綠色(顏色 1)是 B、D、E。「例子 2:含奇環」把 D–F 換成 D–E,多了三角形 C–D–E:處理 C 時發現鄰居 E 和它同為顏色 0,演算法立刻停下,黃線標出兩條染色路徑加上衝突邊圍成的奇環 C–B–A–D–E,最粗的那條是衝突邊。黃框是還在佇列裡的節點,粗框是正在處理的節點;藍色實線是塗色時走過的邊,正在檢查的其他邊以虛線標出。留意找到的奇環不一定是最短的,但任何一個都足以證明塗不出來。

BFS 兩色染色 · 從 A 開始
未染色顏色 0顏色 1在佇列中(黃框)處理中(粗框)染色走過的邊奇環(最粗的是衝突邊)
ABCDEF
佇列(前 → 後)
兩組
0
1
步驟 0/22目標:把每個節點塗成 0 或 1,讓每條邊的兩端不同色。從 A 開始 BFS。

05程式碼

BFS 兩色染色是本體,外層迴圈處理不連通的圖。併查集版把每個節點拆成「這一側」與「對面」,適合邊一條一條加入、每次都要回答的情況,LeetCode 886 也能這樣寫。Python 另外示範在衝突時找出奇環,把「不是二分圖」變成看得見的證據。範例用的就是互動示範的兩張圖,A 到 F 編號為 0 到 5。

from collections import deque


def bipartite_colors(adj):
    """BFS 兩色染色。是二分圖回傳每個節點的顏色(0 或 1),否則回傳 None"""
    n = len(adj)
    color = [-1] * n                          # -1 表示還沒塗色
    for s in range(n):                        # 圖可能不連通:每個連通分量各起一次
        if color[s] != -1:
            continue
        color[s] = 0                          # 新分量的起點塗哪一色都可以
        queue = deque([s])
        while queue:
            u = queue.popleft()
            for v in adj[u]:
                if color[v] == -1:
                    color[v] = 1 - color[u]   # 鄰居被逼成相反色,沒有選擇
                    queue.append(v)
                elif color[v] == color[u]:
                    return None               # 兩端同色:圖裡有奇環
    return color


def find_odd_cycle(adj):
    """不是二分圖時回傳一個奇環(依序列出節點)當作證據;是二分圖回傳 None"""
    n = len(adj)
    depth, parent = [-1] * n, [-1] * n
    for s in range(n):
        if depth[s] != -1:
            continue
        depth[s] = 0
        queue = deque([s])
        while queue:
            u = queue.popleft()
            for v in adj[u]:
                if depth[v] == -1:
                    depth[v], parent[v] = depth[u] + 1, u
                    queue.append(v)
                elif depth[v] % 2 == depth[u] % 2:  # 顏色就是 BFS 層數的奇偶
                    a, b = [u], [v]
                    while a[-1] != b[-1]:           # 較深的一端先往上爬,直到會合
                        if depth[a[-1]] >= depth[b[-1]]:
                            a.append(parent[a[-1]])
                        else:
                            b.append(parent[b[-1]])
                    return a + b[-2::-1]            # u → 會合點 → v,再由邊 v–u 閉合
    return None


def first_conflict_edge(n, edges):
    """邊一條一條加入:回傳第一條讓圖不再是二分圖的邊的索引,沒有就回傳 -1"""
    parent = list(range(2 * n))               # x 代表「x 這一側」,x + n 代表「x 的對面」

    def find(x):
        while parent[x] != x:
            parent[x] = parent[parent[x]]     # 路徑減半
            x = parent[x]
        return x

    for i, (u, v) in enumerate(edges):
        if find(u) == find(v):                # u、v 早就被逼到同一側
            return i
        parent[find(u)] = find(v + n)         # u 和 v 的對面同側
        parent[find(v)] = find(u + n)         # v 和 u 的對面同側
    return -1


def build(n, edges):
    adj = [[] for _ in range(n)]
    for u, v in edges:
        adj[u].append(v)
        adj[v].append(u)
    return adj


if __name__ == "__main__":
    # 與互動示範同一張圖:A..F 編號為 0..5
    ok = [(0, 1), (0, 3), (1, 2), (2, 3), (2, 4), (3, 5), (4, 5)]
    odd = [(0, 1), (0, 3), (1, 2), (2, 3), (2, 4), (3, 4), (4, 5)]
    print(bipartite_colors(build(6, ok)))    # [0, 1, 0, 1, 1, 0]
    print(bipartite_colors(build(6, odd)))   # None
    print(["ABCDEF"[x] for x in find_odd_cycle(build(6, odd))])  # ['C', 'B', 'A', 'D', 'E']
    print(first_conflict_edge(6, odd))       # 5:加入 D–E 時,C–D–E 成了三角形

06練習題

  • LeetCode 785Is Graph Bipartite?(圖可能不連通)Medium
  • LeetCode 886Possible Bipartition(討厭關係建圖,也能用併查集)Medium
  • LeetCode 1042Flower Planting With No Adjacent(對照:四色且度數不超過 3,貪婪就夠)Medium
  • LeetCode 1129Shortest Path with Alternating Colors(每個節點拆成兩種狀態)Medium
  • LeetCode 2493Divide Nodes Into the Maximum Number of Groups(先判二分,再算每個分量的最多層數)Hard
  • LeetCode 2608Shortest Cycle in a Graph(非樹邊加兩條 BFS 路徑就是環)Hard