前言
本文大概是作者對圖論大部分內容的分析和總結吧,\(\text{OI}\)和語文能力有限,且部分說明和推導可能有錯誤和不足,希望能指出。
創作本文是為了提供彼此學習交流的機會,也算是作者在忙碌的中考后對此部分的復習和延伸吧。
本文顧名思義是探討\(\text{DFS}\)在圖論中的重要作用,可能心情比較好會丟個鏈接作拓展,下面就步入正文。
目錄
1 基礎篇
\(1.1\) 圖的定義和深度優先搜索
\(1.2\) 圖的連通分量和二分圖染色
2 進階篇
\(2.1\) 割頂和橋
\(2.2\) 無向圖的雙連通分量(\(\text{BCC}\))和有向圖的強連通分量(\(\text{SCC}\))
\(2.3\) 二分圖匹配問題
關鍵字
深度優先搜索(\(\text{DFS}\))、圖的遍歷、連通分量、二分圖染色、二分圖匹配、割頂、橋、雙連通分量、強連通分量、\(\text{Tarjan}\)、增廣路。
1 基礎篇
總言:這里是\(\text{PJ}\)內容,相對來說較為簡單。
1.1 圖的定義和深度優先搜索
這一部分比較簡單,大佬可以直接跳過~
在\(\text{OI}\)中圖被抽象成點和邊,邊連接着兩個頂點,可分成無向邊和有向邊,所有的點和邊組在一起構成一個圖,記作\(G=<V,E>\),\(G\)表示圖,\(V,E\)分別表示點集和邊集。如下圖所示,都可稱作圖。
圖的存儲主要有兩種:鄰接矩陣和鄰接表。
鄰接矩陣:就是用矩陣的行和列來記錄兩個結點之間是否有邊相連,如果有邊\(u \rightarrow v\),則\(e[u,v]=1\),否則為\(0\)。
優點:訪問速度\(\text{O}(1)\)。
缺點:占用內存\(\text{O}(n^2)\)。
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}$$ 鄰接矩陣:就是通過鏈表的形式將與當前結點有關聯的結點連起來。
優點:所需內存大小只與邊的多少有關。
缺點:隨機訪問某條邊的速度較慢。不過如果按順序遍歷目標結點速度很快。
// 實現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\)。
// 在此代碼之后全部都采用前向星存儲圖
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基本上與此大同小異
1.2 圖的連通分量和二分圖染色
連通分量:在無向圖中,如果從結點\(u\)可以到達結點\(v\),那么結點\(v\)必然可以到達結點\(u\)(對稱性);如果從結點\(u\)可以到達結點\(v\),而結點\(v\)可以到達結點\(w\),則結點\(u\)一定可以到達結點\(w\)(傳遞性),再加上原地不動的話,結點自身可以到達自身(自反性),這些結點滿足等價關系,可以組成一個等價類,我們把這些相互可達的結點稱作一個連通分量(\(\text{CC, connected component}\))。例如下面的圖,有\(3\)個連通分量,分別為\(\{1,2,3,4\},\{5,6,7\},\{8\}\)。
原理:找到一個未標記的點,然后將所有能夠直接或間接到達的結點全部標記。不斷重復其操作。
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); // 白
}
二分圖除了染色,還有匹配等相關問題,這些放到后面再說。
2 進階篇
總言:這里的算法難度有提升,大概在\(\text{NOIPtg}\)水平。
2.1 割頂和橋
在基礎篇中,我們討論了連通分量的問題。如果在一個無向圖中,刪去某一個結點可以使圖中連通分量數目增加,則該結點被稱為割頂;如果刪去某一條邊能使圖中連通分量數目增加,則該邊被稱為橋。在某些問題中我們要通過找出割頂和橋來解決,首先來看如何找出割頂。
方案一:枚舉所有結點,並求出刪去這個結點后連通分量數目是否增加,但很遺憾,復雜度為\(\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);
}
還有幾種說法:\(①\)連接兩個割點的邊一定是橋;\(②\)橋連接的兩個頂點一定都是割頂。
哪些是對的?如果是對的,為什么不從這些角度來實現上面的算法呢?大家可以來思考一下,這里就不說了。
2.2 無向圖的雙連通分量(BCC)和有向圖的強連通分量(SCC)
在無向圖中,對於一個連通圖,如果任意兩點間存在兩條點不重復的路徑,則說明這個圖是點-雙連通的(一般簡稱雙連通)。這個的等價要求就是圖中無割頂。
同理,對於一個連通圖,如果任意兩點間存在兩條邊不重復的路徑,則說明這個圖是邊-雙連通的。這個的等價要求就是每條邊都在一個環中,也就是內部無橋。
對於無向圖,點-雙連通的極大子圖被稱為雙連通分量(\(\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)\)。
2.3 二分圖匹配問題
前面討論過二分圖染色,本部分將討論二分圖匹配。首先先說二分圖最大匹配。
圖論中匹配指兩兩沒有公共點的邊集,而二分圖最大匹配是指找一個邊數最大的匹配,即選擇盡可能多的邊,使得任意兩條選中的邊均沒有公共點。如果所有的點都被匹配,那么稱這個匹配是完美匹配。
如下圖所示,兩圖均為該二分圖的最大匹配,其中\((\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)\)。
后記
原本想說更多的,但說多了就不得再深了。且難免會有疏漏,甚至會有錯誤之處,希望大家能不吝指出。
本文就介紹到此,其實內容仍有很多很多,詳見我的別的博文。