本文大概是做者對圖論大部份內容的分析和總結吧,\(\text{OI}\)和語文能力有限,且部分說明和推導可能有錯誤和不足,但願能指出。
創做本文是爲了提供彼此學習交流的機會,也算是做者在忙碌的中考後對此部分的複習和延伸吧。
本文顧名思義是探討\(\text{DFS}\)在圖論中的重要做用,可能心情比較好會丟個連接做拓展,下面就步入正文。算法
\(1.1\) 圖的定義和深度優先搜索
\(1.2\) 圖的連通份量和二分圖染色數組
\(2.1\) 割頂和橋
\(2.2\) 無向圖的雙連通份量(\(\text{BCC}\))和有向圖的強連通份量(\(\text{SCC}\))
\(2.3\) 二分圖匹配問題網絡
深度優先搜索(\(\text{DFS}\))、圖的遍歷、連通份量、二分圖染色、二分圖匹配、割頂、橋、雙連通份量、強連通份量、\(\text{Tarjan}\)、增廣路。框架
總言:這裏是\(\text{PJ}\)內容,相對來講較爲簡單。學習
這一部分比較簡單,大佬能夠直接跳過~
在\(\text{OI}\)中圖被抽象成點和邊,邊鏈接着兩個頂點,可分紅無向邊和有向邊,全部的點和邊組在一塊兒構成一個圖,記做\(G=<V,E>\),\(G\)表示圖,\(V,E\)分別表示點集和邊集。以下圖所示,均可稱做圖。優化
圖的存儲主要有兩種:鄰接矩陣和鄰接表。
鄰接矩陣:就是用矩陣的行和列來記錄兩個結點之間是否有邊相連,若是有邊\(u \rightarrow v\),則\(e[u,v]=1\),不然爲\(0\)。
優勢:訪問速度\(\text{O}(1)\)。
缺點:佔用內存\(\text{O}(n^2)\)。spa
int e[maxn][maxn]; // 鄰接矩陣 void add(int u, int v) { // 添加新邊 e[u][v] = e[v][u] = 1; // 無向圖 e[u][v] = 1; // 有向圖 }
例如中間的圖,鄰接矩陣即爲\[\begin{bmatrix} \text{u\v} & V1 & V2 & V3 & V4 & V5 & V6 \\ V1 & 0 & 1 & 0 & 0 & 0 & 0 \\ V2 & 0 & 0 & 1 & 0 & 0 & 0 \\ V3 & 1 & 0 & 0 & 0 & 0 & 0 \\ V4 & 0 & 0 & 0 & 0 & 1 & 0 \\ V5 & 0 & 0 & 0 & 1 & 0 & 0 \\ V6 & 0 & 0 & 0 & 0 & 0 & 1 \end{bmatrix}\] 鄰接矩陣:就是經過鏈表的形式將與當前結點有關聯的結點連起來。
優勢:所需內存大小隻與邊的多少有關。
缺點:隨機訪問某條邊的速度較慢。不過若是按順序遍歷目標結點速度很快。code
// 實現1 : STL vector<int> e[maxn]; void add(int u, int v) { e[u].push_back(v); e[v].push_back(u); // 無向圖時使用 } // 實現2 : 前向星 struct Edge { int u, v, pre; // e[i]表示第i+1條邊,pre表示連接,若爲-1則說明已經指向表頭 } e[maxn * maxn]; int G[maxn], m; // G[i]表示所構成的i結點有關的結點構成的鏈的最後一條邊,m表示邊數 void init() { m = 0; memset(G, -1, sizeof(G)); // 清空G數組 } void add(int u, int v) { e[m++] = (Edge){u, v, G[u]}; // 添加新邊,新邊指向邊G[u] G[u] = m-1; // 將G[u]指向新邊 // 處理無向圖用如下 e[m++] = (Edge){v, u, G[v]}; G[v] = m-1; } // summary : 方案2比方案1好在常數較小 // 方案2中邊的連接順序相較於讀入順序相反。若是要一致能夠改連接方式
例如最後一個圖中,連接的狀況:\[\begin{array}{ll} V1 \rightarrow 2 \\ V2 \rightarrow 1 \rightarrow 3 \rightarrow 5 \\ V3 \rightarrow 2 \rightarrow 4 \rightarrow 6 \\ V4 \rightarrow 3 \\ V5 \rightarrow 2 \\ V6 \rightarrow 3 \end{array}\] 接着再說深搜(\(\text{DFS}\))和遍歷。深搜顧名思義就是一直往下搜索,遇到阻礙再回頭一步,再繼續向下,直到全部的狀況都搜索過。
深搜用於遍歷圖的話,好處不少,好比說代碼短小精悍且複雜度爲線性。對於上面最後一個圖,若是起點在\(1\)號結點,那麼訪問的順序:\(1\rightarrow 2 \rightarrow 3 \rightarrow 6 \rightarrow 4 \rightarrow 5\)。component
// 在此代碼以後所有都採用前向星存儲圖 bool vis[maxn]; // 是否訪問過某結點 void dfs(int u) { vis[u] = 1; // 訪問過的標記 cout << u; // 輸出遍歷順序 for (register int i = G[u]; ~i; i = e[i].pre) { // 遍歷鄰接表,~i表示當i=-1時結束 int v = e[i].v; // 邊指向的結點 // do something before dfs if (!vis[v]) dfs(v); // 若未訪問過指向的結點,訪問 // do something after dfs } } // 這個代碼展示了dfs的基本框架,下文及之後的dfs基本上與此大同小異
連通份量:在無向圖中,若是從結點\(u\)能夠到達結點\(v\),那麼結點\(v\)必然能夠到達結點\(u\)(對稱性);若是從結點\(u\)能夠到達結點\(v\),而結點\(v\)能夠到達結點\(w\),則結點\(u\)必定能夠到達結點\(w\)(傳遞性),再加上原地不動的話,結點自身能夠到達自身(自反性),這些結點知足等價關係,能夠組成一個等價類,咱們把這些相互可達的結點稱做一個連通份量(\(\text{CC, connected component}\))。例以下面的圖,有\(3\)個連通份量,分別爲\(\{1,2,3,4\},\{5,6,7\},\{8\}\)。blog
原理:找到一個未標記的點,而後將全部可以直接或間接到達的結點所有標記。不斷重複其操做。
int cc[maxn], cc_cnt; // 記錄結點所在連通份量的編號,同時若cc不爲0,則說明該結點被訪問過 void dfs(int u) { cc[u] = cc_cnt; // 標記連通份量的編號 for (register int i = G[u]; ~i; i = e[i].pre) { int v = e[i].v; if (!cc[v]) dfs(v); // 繼續訪問 } } void work() { cc_cnt = 0; // 清空連通份量數 memset(cc, 0, sizeof(cc)); // 清空 標號&&訪問 for (register int i = 1; i <= N; i++) if (!cc[i]) { // 沒被標記 cc_cnt++; // 新的連通份量 dfs(i); // 將全部能訪問到的連通份量訪問 } }
二分圖:若是一個圖\(G=<V,E>\),將\(V\)分紅\(X\)和\(Y=V-X\),能使得\(E\)中任意一條邊,兩個端點分別在\(X\)集和\(Y\)集中,則此圖爲二分圖。下圖的左圖即爲二分圖,而右圖不是。
右圖中出現了大小爲\(3\)的奇環\(5\rightarrow 6 \rightarrow 9 \rightarrow 5\),顯然沒法將其分爲兩部分。
將二分圖分紅兩部分即爲二分圖染色,\(X\)中全部結點染成黑色,\(Y\)中全部結點染成白色,以下圖中\(X = \{1,\ 3,\ 5,\ 7,\ 9\}\),\(Y = \{2,\ 4,\ 6,\ 8\}\)是一種方案。
實現的思路就是隨便找一個起始結點開始染色,而後將相鄰的結點進行染色,若是相鄰的結點已經染過色,判斷顏色是否不一樣,若是相同,說明這條邊鏈接着兩個端點在同一個點集中。正確性在於只要不是二分圖,圖中存在奇環,那麼必定存在某一時刻訪問了該環中顏色相同的兩個端點,染色會失敗,反之必定會染色成功。
bool bipartite(int u) { for (register int i = G[u]; ~i; i = e[i].pre) { int v = e[i].v; if (!color[v]) { // 未染色 color[v] = 3 - color[u]; // 染不一樣的顏色,這裏是簡潔的寫法 if (!bipartite(v)) return false; // 繼續向下染色+判斷 } else if (color[v] == color[u]) return false; // 染過色,判斷兩個結點是否顏色相同而衝突 } return true; // 是二分圖 } void work() { memset(color, 0, sizeof(color)); // 清空 for (register int i = 1; i <= N; i++) if (!color[i]) { // 未染色 color[i] = 1; // 染黑色,白色也行 if (!bipartite(i)) { // 染色+判斷 printf("Failed"); // 失敗 return; } } // 打印結果 printf("Black :"); for (register int i = 1; i <= N; i++) if (color[i] == 1) printf(" %d", i); // 黑 printf("\nWhite :"); for (register int i = 1; i <= N; i++) if (color[i] == 2) printf(" %d", i); // 白 }
二分圖除了染色,還有匹配等相關問題,這些放到後面再說。
總言:這裏的算法難度有提高,大概在\(\text{NOIPtg}\)水平。
在基礎篇中,咱們討論了連通份量的問題。若是在一個無向圖中,刪去某一個結點可使圖中連通份量數目增長,則該結點被稱爲割頂;若是刪去某一條邊能使圖中連通份量數目增長,則該邊被稱爲橋。在某些問題中咱們要經過找出割頂和橋來解決,首先來看如何找出割頂。
方案一:枚舉全部結點,並求出刪去這個結點後連通份量數目是否增長,但很遺憾,複雜度爲\(\text{O}(n^2)\)。
方案二:利用\(\text{DFS}\)的特色來解決問題。指望爲\(\text{O}(N+M)\)。
首先易知\(\text{DFS}\)訪問圖時按照遍歷時的順序能夠獲得一棵樹,以下圖所示。
從\(1\)號結點開始,訪問\(2\),再回來訪問\(3\)、\(4\),又經過\(\text{A}\)邊來到了\(1\),發現\(1\)來過,回頭到\(3\)再到\(5\)、\(6\),又經過\(\text{B}\)邊來到了\(3\),\(3\)來過,\(6\)退回了\(3\),以後又經過\(\text{B}\)來到了\(6\)(注意\(\text{DFS}\)過程當中會這樣子的),\(6\)來過,退回\(1\),而後發現還有\(\text{A}\)這條邊沒走,因而又來到了\(4\),發現\(4\)來過,回退,算法結束。
注意到\(\text{DFS}\)第一次發現某個結點時經過的邊,在右圖中用的是實線,這些構成了一棵樹,咱們將這些邊稱做樹邊;\(\text{A、B}\)兩條邊用的是虛線。這兩條邊在原圖中存在,但不在這棵樹上。咱們發如今\(\text{DFS}\)過程當中,有從\(4\rightarrow 1\)、\(6\rightarrow 3\)這兩次,由於是這棵樹上的結點經過這條邊回到了其祖先結點(或者回到自身),咱們將這條邊稱做反向邊;還有兩次從\(1\rightarrow 4\)、\(3 \rightarrow 6\),從樹上的結點經過這條邊來到了其子輩結點,這條邊卻又不是樹邊,咱們將這條邊稱做前向邊。然而在無向圖中,前向邊\(=\)反向邊,因此這裏就只討論反向邊。還有一種邊叫作橫跨邊,就是除了以上\(3\)中邊之外的邊,好比說假若有一條邊\(2\rightarrow 4\),然而在無向圖中必定不會存在(這條邊在\(\text{DFS}\)會以樹邊的形式呈現)。
咱們看右邊的樹。手算能夠知道\(\{1,3\}\)兩個結點是割頂。割頂就是刪去它能增長連通份量,若是某個結點它不是割頂,在\(\text{DFS}\)樹中這個結點的全部子樹中必定有一條反向邊指向這個結點的祖先結點(不包括這個結點),在這個結點被刪除後,其子樹能經過這條邊與祖先結點連通。若是存在一個子樹中沒有指向這個結點的祖先結點的邊,說明刪去這個結點後這顆子樹中的全部結點會成爲一個新的連通份量,也就說明這個結點是割頂。沒有孩子天然就不是割頂了。好比說\(3\)號結點,其中一棵以\(5\)爲根節點的子樹中沒有反向邊指回\(1\),因此\(3\)是割頂。同理\(1\)是割頂。
這樣子算法的大概框架就出來了。
咱們用\(low_u\)表示\(u\)結點可以訪問到的最遠的祖先。像上面這個圖同樣,若是對於結點\(u\),其全部子樹以下圖狀況\(①\),即全部的\(v\)知足\(\text{dep}(low_u)<\text{dep}(u)\);反之如狀況\(②、③\),\(\text{dep}(low_u)\geq \text{dep}(u)\),則\(u\)爲割頂。由於沒有橫跨邊,用深度判斷沒有問題,無需考慮連向其餘結點的祖先。利用時間戳可讓算法更加簡單:結點訪問時間越靠前,越多是其它結點的祖先,下面的代碼用了這種方法。
還要考慮\(\text{DFS}\)樹的根節點:若是它只有一個孩子,它也不是割頂。這個須要特判。
int cut[maxn], low[maxn], pre[maxn], dfs_clock; // cut表示結點是否爲割頂,low記錄該結點以及後代可以訪問到的最早前的結點(pre),pre記錄第一次訪問結點時的時間,dfs_clock表示時間 void dfs(int u, int fa) { pre[u] = low[u] = ++dfs_clock; // 時間戳:訪問一次加一次時間 int child = 0; // 用來記錄dfs樹中u結點的子結點數 for (register int i = G[u]; ~i; i = e[i].pre) { int v = e[i].v; if (!pre[v]) { // 該結點還未被訪問 dfs(v, u); // 訪問v及其後代 child++; // 樹上子結點數增長 low[u] = min(low[u], low[v]); // 經過該子樹能訪問到的最遠的祖先維護low if (low[v] >= pre[u]) cut[u] = 1; // 若是該子樹不能訪問到u的祖先結點,則u爲割頂 } else if (v != fa) low[u] = min(low[u], pre[v]); // 經過該結點經過反向邊可以訪問到的結點維護low; v!=fa避免其又是樹邊 } if (u == fa && child == 1) cut[u] = 0; // 特判dfs樹的根節點 } void work() { memset(cut, 0, sizeof(cut)); memset(pre, 0, sizeof(pre)); // 圖可能自己不連通 for (register int i = 1; i <= N; i++) if (!pre[i]) dfs(i, i); // 打印結果 printf("Cut-vertex :"); for (register int i = 1; i <= N; i++) if (cut[i]) printf(" %d", i); }
對於橋,在求割頂的代碼上進行小改動便可。
首先,橋必定是樹邊,由於刪了橋會產生新的連通份量,因此在遍歷時必定會通過橋首次訪問橋對面的結點。其次若是一個結點的全部子樹中沒有一條反向邊連向這個結點的祖先結點,那麼說明該結點與父親結點鏈接的邊是橋。
// >>符號表示其與求割頂的代碼所添加的部分 int cut[maxn], low[maxn], pre[maxn], dfs_clock; >> pair<int, int> bri[maxm]; // 橋記錄 >> int bri_cnt; // 橋的總數 void dfs(int u, int fa) { pre[u] = low[u] = ++dfs_clock; int child = 0; for (register int i = G[u]; ~i; i = e[i].pre) { int v = e[i].v; if (!pre[v]) { dfs(v, u); child++; low[u] = min(low[u], low[v]); if (low[v] >= pre[u]) cut[u] = 1; } else if (v != fa) low[u] = min(low[u], pre[v]); // 這裏v!=fa很重要 } >> if (u != fa && low[u] >= pre[u]) bri[bri_cnt++] = make_pair(u, fa); // 若是非根節點u的全部子樹沒法回到祖先結點,則(u,fa)是橋 if (u == fa && child == 1) cut[u] = 0; } void work() { memset(cut, 0, sizeof(cut)); memset(pre, 0, sizeof(pre)); for (register int i = 1; i <= N; i++) if (!pre[i]) dfs(i, i); // 輸出 printf("Bridge(s) : %d\n", bri_cnt); for (register int i = 0; i < bri_cnt; i++) printf("[%d] : (%d, %d)\n", i+1, bri[i].first, bri[i].second); }
還有幾種說法:\(①\)鏈接兩個割點的邊必定是橋;\(②\)橋鏈接的兩個頂點必定都是割頂。
哪些是對的?若是是對的,爲何不從這些角度來實現上面的算法呢?你們能夠來思考一下,這裏就不說了。
在無向圖中,對於一個連通圖,若是任意兩點間存在兩條點不重複的路徑,則說明這個圖是點-雙連通的(通常簡稱雙連通)。這個的等價要求就是圖中無割頂。
同理,對於一個連通圖,若是任意兩點間存在兩條邊不重複的路徑,則說明這個圖是邊-雙連通的。這個的等價要求就是每條邊都在一個環中,也就是內部無橋。
對於無向圖,點-雙連通的極大子圖被稱爲雙連通份量(\(\text{Biconnected Component, BCC}\))。顯然每條邊都屬於一個雙連通份量,且兩個雙連通份量可能有且只有一個公共點,且其必定是割頂。反過來,任一割頂必定是兩個或兩個以上的雙連通份量的公共點。
如上圖所示,顯然雙連通份量爲\(\{1,2\},\{1,3,4\},\{3,5,6\}\)。對於每一條邊,都必定出如今某一個雙連通份量中,且僅此一個雙連通份量。對應右邊的圖,若是一個結點的某個子樹能追溯的最遠的祖先就是這個結點,那麼這個結點和這個子樹中的全部不在其它雙連通份量的邊和邊鏈接的頂點都囊括在這個新的雙連通份量中。前提是以\(\text{DFS}\)遍歷順序,這樣就能找全。換句話說就是根據割頂找雙連通份量,利用一個棧就能夠實現。
stack<pair<int, int> > s; vector<int> bcc[maxn]; int bcc_cnt, bccno[maxn]; int cut[maxn], pre[maxn], low[maxn], dfs_clock; void dfs(int u, int fa) { low[u] = pre[u] = ++dfs_clock; int child = 0; for (register int i = G[u]; ~i; i = e[i].pre) { int v = e[i].v; if (!pre[v]) { child++; s.push(make_pair(u, v)); dfs(v, u); if (low[v] >= pre[u]) { cut[u] = 1; bcc_cnt++; // 找到一個雙連通份量,此時棧頂的一部分就在這個雙連通份量中 for (;;) { pair<int, int> e = s.top(); s.pop(); if (bccno[e.first] != bcc_cnt) bccno[e.first] = bcc_cnt, bcc[bcc_cnt].push_back(e.first); // 若是這個結點的編號不是當前編號,更新並計入雙連通份量 if (bccno[e.second] != bcc_cnt) bccno[e.second] = bcc_cnt, bcc[bcc_cnt].push_back(e.second); if (e.first == u) break; // 直到邊的起點已是u結束 } } low[u] = min(low[u], low[v]); } else if (v != fa) low[u] = min(low[u], pre[v]); } if (u == fa && child == 1) cut[u] = 0; }
邊雙連通份量十分相似。理解起來更加簡單:刪去橋後找連通份量。不過下面的算法不須要這樣麻煩,一次\(\text{DFS}\)解決。
int cut[maxn], pre[maxn], low[maxn], dfs_clock; stack<int> s; vector<int> bcc[maxn]; // 記錄每一個邊-雙連通份量 int bcc_cnt, bccno[maxn]; // 邊-雙連通份量的數量、編號 void dfs(int u, int fa) { low[u] = pre[u] = ++dfs_clock; s.push(u); int child = 0; for (register int i = G[u]; ~i; i = e[i].pre) { int v = e[i].v; if (!pre[v]) { child++; dfs(v, u); if (low[v] >= pre[u]) cut[u] = 1; low[u] = min(low[u], low[v]); } else if (v != fa) low[u] = min(low[u], pre[v]); } // 遇到了橋就將棧內全部元素彈出,此時彈出的即爲一個邊-雙連通份量 if (low[u] == pre[u]) { bcc_cnt++; for (;;) { int x = s.top(); s.pop(); bccno[x] = bcc_cnt; // 根據須要通常和下面一句二選一 bcc[bcc_cnt].push_back(x); if (x == u) break; // 找完了 } } if (u == fa && child == 1) cut[u] = 0; }
在有向圖中,和無向圖的連通份量相似,有向圖中有強連通份量(\(\text{Strongly Connected Componet, SCC}\))。在一個強連通份量中,任意兩點相互可達,也構成了一個等價類。若是把每個強連通份量當作一個點,也叫作縮點,那麼全部的\(\text{SCC}\)構成了一個\(\text{SCC}\)圖,這個圖中必定不會存在環,因此是一個\(\text{DAG}\)。
如何去求強連通份量呢?咱們仍是經過\(\text{DFS}\)來求。
前面說無向圖中沒有橫跨邊,前向邊等於反向邊,但在有向圖中這四種邊都是獨立的,事實上前向邊依然沒有價值:經過前向邊連通的兩個結點等價於經過樹邊連通。但思路仍然很簡單:在\(\text{DFS}\)樹中,若是當前結點的全部子樹中沒有一個結點能返回到當前結點的祖先結點,那麼其父節點到當前結點的有向邊必定不在任一\(\text{SCC}\)中。如此下去,剩下的若干連通塊,每一個連通塊就是一個\(\text{SCC}\)。
正確性在於咱們這樣劃分後全部連通塊中能夠經過樹邊和反向邊或橫跨邊構成環使得結點兩兩互相可達,並且沒法經過刪去的邊和已有的邊在兩個或多個\(\text{SCC}\)之間構成另外一個環(不然某個結點必定會被子樹的結點連回,致使這些點都在\(\text{SCC}\)中)。
算法的實現上還要注意橫跨邊:若是它指向已標記的\(\text{SCC}\)中,更新\(low\)會出錯,因此要判掉。
int pre[maxn], low[maxn], dfs_clock; int sccno[maxn], scc_cnt; // 結點的SCC編號和總數量 vector<int> scc[maxn]; // 對應編號的結點 stack<int> s; void dfs(int u, int fa) { s.push(u); low[u] = pre[u] = ++dfs_clock; for (register int i = G[u]; ~i; i = e[i].pre) { int v = e[i].v; if (!pre[v]) { dfs(v, u); low[u] = min(low[u], low[v]); } else if (!sccno[v]) low[u] = min(low[u], pre[v]); // 這裏的!sccno[v]判斷很重要:由於可能遇到橫跨邊指向更先前標記的SCC使得結果不正確。 } if (low[u] >= pre[u]) { // 判斷是否分離出SCC scc_cnt++; for (;;) { int v = s.top(); s.pop(); sccno[v] = scc_cnt; scc[scc_cnt].push_back(v); if (u == v) break; // 分離完畢 } } }
從2.1到這裏的算法,都是一位有名的計算機科學家\(\text{Tarjan}\)提出的。在這裏%%%。
求強連通份量還有一個叫\(\text{Kosaraju}\)算法。該算法理解起來十分簡單:由於將全部強連通份量縮點後是一個\(\text{DAG}\),因此咱們能夠經過拓撲順序來求出強連通份量,從拓撲序靠後的開始遍歷全部未遍歷過的結點,能遍歷到的必定與其在同一個\(\text{SCC}\)中(根據\(\text{SCC}\)相互可達的性質)。但一開始咱們不知道拓撲序,不過不要緊,由於遍歷時越靠前訪問到的結點所在的\(\text{SCC}\)拓撲序必定儘量靠前,再經過轉置圖(將全部邊反向,名稱和矩陣的轉置有關),拓撲序靠前的就會變成靠後的,這樣一個一個訪問便可分離\(\text{SCC}\)。
代碼上有一些細節要注意。
// G爲原圖,G2爲轉置圖。e在這裏只是存邊,(u,v)和(v,u)兩條對應與e中的邊不一樣 int vis[maxn]; vector<int> s; int sccno[maxn], scc_cnt; vector<int> scc[maxn]; void dfs(int u) { vis[u] = 1; for (register int i = G[u]; ~i; i = e[i].pre) { int v = e[i].v; if (!vis[v]) dfs(v); } s.push_back(u); // 放後面能夠保證屢次dfs後s總體上從後往前大體上(不必定就是)呈拓撲順序,但必定不影響後面操做 } void find(int u) { sccno[u] = scc_cnt; scc[scc_cnt].push_back(u); for (register int i = G2[u]; ~i; i = e[i].pre) { int v = e[i].v; if (!sccno[v]) find(v); } } void work() { memset(vis, 0, sizeof(vis)); s.clear(); scc_cnt = 0; for (register int i = 1; i <= N; i++) if (!vis[i]) dfs(i); for (register int i = N-1; ~i; i--) // 從後往前經過轉置圖依次標記SCC if (!sccno[s[i]]) scc_cnt++, find(s[i]); }
這兩種算法的複雜度均爲\(\text{O}(N+M)\)。
前面討論過二分圖染色,本部分將討論二分圖匹配。首先先說二分圖最大匹配。
圖論中匹配指兩兩沒有公共點的邊集,而二分圖最大匹配是指找一個邊數最大的匹配,即選擇儘量多的邊,使得任意兩條選中的邊均沒有公共點。若是全部的點都被匹配,那麼稱這個匹配是完美匹配。
以下圖所示,兩圖均爲該二分圖的最大匹配,其中\((\text{b})\)是二分圖的完美匹配。
後面方便敘述用\(\text{X}\)和\(\text{Y}\)或左右來表示兩邊的點集。
該問題的解法能夠利用網絡流:設兩個結點源點和匯點,源點向全部\(\text{X}\)的結點連一條邊,全部\(\text{Y}\)的結點向匯點連一條邊,圖中全部邊的容量爲\(1\),跑一遍最大流,流量即爲匹配數,而載有流量的邊即爲匹配上的邊。
網絡流太複雜了,有一個比它更簡單的算法:匈牙利算法。敘述這個算法前要敘述下增廣路定理。
從未被匹配的頂點開始,依次通過非匹配邊、匹配邊、非匹配邊、匹配邊······所獲得的路徑被稱做交替路,若交替路的終點也是一個未被匹配的頂點,則稱之爲增廣路。不難發現增廣路中非匹配邊必定比匹配邊多一條,並且若是將增廣路中的邊取反(匹配邊變成非匹配邊,非匹配邊變成匹配邊),匹配中不會出現衝突且匹配數\(+1\),事實上是經過這種方法將兩個未被匹配的頂點歸入了匹配中。當沒法再找到增廣路時,此時爲最大匹配。不難證實,假設不是最大匹配,則必定有兩個未歸入匹配的頂點之間存在增廣路,矛盾,因此必定爲最大匹配。
舉下面一個例子。有這樣的一個二分圖,
首先從\(①\)開始找增廣路,取反;
再從\(②\)開始找增廣路,取反;
繼續,直到結束。
代碼以下:
// 這裏的e中(u,v)指X點集中的點u和Y點集中的點v有一條邊,u=v並非同一個點 int maxmatch; // 最大匹配數 int vis[maxn], link[maxn]; // vis表示Y點集的點是否訪問過;link表示Y點集中的點所匹配的X點集中的點 bool dfs(int u) { // 經過dfs尋找增廣路,同時進行取反,若成功返回true for (register int i = G[u]; ~i; i = e[i].pre) { int v = e[i].v; if (!vis[v]) { // 這裏是一個優化:在一次增廣中,若是經過這個頂點沒法找到增廣路,則以後也沒法經過此找到 vis[v] = 1; if (!link[v] || dfs(link[v])) { link[v] = u; return true; } } } return false; } void hungarian() { memset(link, 0, sizeof(link)); maxmatch = 0; for (register int i = 1; i <= N; i++) { memset(vis, 0, sizeof(vis)); if (dfs(i)) maxmatch++; } }
匈牙利算法的複雜度爲\(\text{O}(NM)\),相較於網絡流中\(\text{Dinic}\)的\(\text{O}(M\sqrt{N})\),理論複雜度要更大,但事實上匈牙利算法並不能徹底達到理論上限,實測結果比較優秀,且代碼較短。匈牙利算法的實現也可用\(\text{BFS}\)實現。
接下來講二分圖最佳完美匹配。
假設有一個完美二分圖\(G\)(全部頂點都可以被匹配),每條邊都有一個權值(能夠爲負數),當匹配邊的權值和最大時稱之爲最佳完美匹配。
如何解決呢?\(\text{Kuhn-Munkres}\)算法(\(\text{KM}\)算法)能夠解決。
該算法引入了頂標解決問題。頂標就是每一個點設有一個權值,在這個問題中,點集\(\text{X}\)的頂標記做\(Lx\),\(\text{Y}\)的頂標記做\(Ly\),對於全部的邊,知足:\(Lx_i+Ly_j\geq e_{ij}\)。全部點和知足\(Lx_i+Ly_j=e_{ij}\)的邊所構成的圖稱做相等子圖。若是相等子圖中有完美匹配,則這個完美匹配就是該二分圖的最優匹配。證實很簡單,由於相等子圖中匹配上的邊的權值和\(=\Sigma Lx_i + \Sigma Ly_i\),即全部頂標和,而\(\Sigma Lx_i + \Sigma Ly_i \geq \Sigma e_{ij}, e_{ij} \in \text{任一匹配中的邊集E'}\)。
\(\text{KM}\)算法的思路是:首先使\(Lx_i=\max\{e_{ij}\},1\leq j\leq N\),即與\(\text{X}\)的點\(i\)關聯的權值最大的邊,這樣能保證不等式\(Lx_i+Ly_j\geq e_{ij}\)恆成立;依次增廣左邊\(\text{X}\)點\(1\text{、}2···N\),每次增廣若是成功,繼續下一個點的增廣,不然調整頂標直到這個點增廣成功。從這個點增廣的過程當中一條條交替路組成了一棵交錯樹(又叫匈牙利樹),好比說下面的圖,圖中的黑邊和部分黃邊爲交錯樹上的邊,其中黃邊表示匹配上的邊,\(\text{S}\)表示在交錯樹中的\(\text{X}\)點集的點,\(\text{T}\)表示在交錯樹中的\(\text{Y}\)點集中的點,\(\overline{\text{S}}\)表示不在交錯樹中的\(\text{X}\)點集中的點,依次類推。
咱們但願經過調整能使更多的邊加入相等子圖中,且鏈接着\(\text{S}\)和\(\overline{\text{T}}\)中的點,只有這樣才能使交錯樹擴展,結點更有可能被匹配。
咱們把全部在\(\text{S}\)中的點的頂標\(+d\),\(d\)是一個常數,把\(\text{T}\)中的點的頂標\(-d\),這樣有什麼好處呢?
①對於一端在\(\text{S}\),另外一端在\(\text{T}\)中的邊(指在交錯樹中的邊),修改事後仍然在相等子圖中;對於一端不在\(\text{S}\),另外一端也不在\(\text{T}\)中的邊(好比說不在交錯樹中的邊但倒是匹配邊)也不會影響。
②一端在\(\text{S}\)中,另外一端不在\(\text{T}\)中的邊,修改以後可能會加入相等子圖中(由於頂標和\(-d\),可能會與邊權相等了)。這正是咱們想要的。
③一端不在\(\text{S}\)中,另外一端在\(\text{S}\)中的邊的變化與否不影響。
那麼關鍵在於\(d\)等於多少,應取\(d=\min\{Lx_i+Ly_j-e_{ij}\}\),其中結點\(i\)在\(\text{S}\)中,結點\(j\)在\(\text{T}\)中。\(d\)取這個值,一方面要有邊加入,取得更小,就會致使沒有邊加入;另外一方面,\(d\)取得更大,會致使上面關於頂標和的不等式不成立。
直到所有匹配上時,算法結束。
int N, M, e[maxn][maxn]; // e[i][j]表示i->j的權值 int S[maxn], T[maxn], Lx[maxn], Ly[maxn], link[maxn]; // S、T、Lx、Ly如上文所述,link表示右邊結點匹配上的左邊的結點 bool dfs(int u) { // 增廣 S[u] = 1; for (register int v = 1; v <= N; v++) if (!T[v] && Lx[u] + Ly[v] == e[u][v]) { // 條件是v未被訪問且該邊在相等子圖中 T[v] = 1; if (!link[v] || dfs(link[v])) { link[v] = u; return true; } } return false; } void update() { int d = 1<<30; for (register int i = 1; i <= N; i++) if (S[i]) for (register int j = 1; j <= N; j++) if (!T[j]) d = min(d, Lx[i] + Ly[j] - e[i][j]); // 尋找最小的d for (register int i = 1; i <= N; i++) { // 頂標修改 if (S[i]) Lx[i] -= d; if (T[i]) Ly[i] += d; } } void KM() { memset(Lx, 0, sizeof(Lx)); memset(Ly, 0, sizeof(Ly)); memset(link, 0, sizeof(link)); for (register int i = 1; i <= N; i++) for (register int j = 1; j <= N; j++) Lx[i] = max(Lx[i], e[i][j]); // 初始化 for (register int i = 1; i <= N; i++) // 依次增廣每一個結點 for (;;) { // 無限循環 memset(S, 0, sizeof(S)); memset(T, 0, sizeof(T)); if (dfs(i)) break; else update(); // 增廣成功退出循環,不然修改頂標繼續 } int ans = 0; // 求出答案 for (register int i = 1; i <= N; i++) ans += Lx[i] + Ly[i]; printf("%d", ans); }
分析複雜度:增廣\(\text{O}(N)\)次,每次最壞又要\(\text{O}(N)\)次\(dfs\)(每次\(dfs\)交錯樹最少擴大左右各\(1\)個結點,最多能擴大\(\text{O}(N)\)次),\(dfs\)最壞又要\(\text{O}(M)=\text{O}(N^2)\)次,與此同時\(\text{update}\)又須要\(\text{O}(N^2)\)次,總複雜度爲\(\text{O}(N^4+N^2\times M)=\text{O}(N^4)\)。
考慮優化:對於總複雜度的第一項,用\(slack_j=\min\{Lx_i+Ly_j-e_{ij}\}\),那麼最後修改頂標時就變成求\(d=\min\{slack_j\}\)了。若是在\(dfs\)的過程當中順帶把\(slack_j\)維護,最後\(\text{update}\)時的複雜度就能降成\(\text{O}(N)\)了;對於總複雜度的第二項,將\(dfs\)改進,由於在修改頂標的過程當中,交錯樹只是擴大,沒有必要從新進行\(dfs\),而是在原有的基礎上\(dfs\),這樣\(\text{O}(N)\)次的\(dfs\)的總複雜度就變成了\(\text{O}(M)\)了。
經過這樣,\(\text{KM}\)算法的複雜度降爲\(\text{O}(N^3)\)。
本來想說更多的,但說多了就不得再深了。且不免會有疏漏,甚至會有錯誤之處,但願你們能不吝指出。 本文就介紹到此,其實內容仍有不少不少,詳見個人別的博文。