網路最大流 - 從入門開始,詳細講到實用易懂的 Dinic 演算法
2019-01-02 更新
理解網路
一張網路,是一張帶權有向圖。比喻成輸水系統可能好理解——
源頭是大水庫,想輸出多少就輸出多少。
但是,想要輸出到目的地,需要經過中轉點。中轉點不產生新流量、也不私吞;接受多少流量,就同時輸出多少流量。
點與點之間管道的容量(可以理解為水流量限制;注意“流量”指單位時間通過量,可以想象一下)是有限的;一條邊上的流量不能超出其容量,容量很可能是有殘餘的。因為這些邊上的限制,源頭並不能無限輸出。
於是有的人就想要求出:這張網路運作起來的時候,總流量最大能有多少。由於容量限制比較複雜,似乎不容易規劃一個最佳方案。
下面來了一張經典圖和一隻猴子。
邊上的數字是其容量限制。
紅色的路徑,是猴子隨便新增的一條可行流(電腦不能看見整張圖,所以它跟猴子沒啥區別)。這個時候總流量是 \(1\)。
然後猴子繼續隨機嘗試。想要讓水流通過下方的黑邊,但接下來沒法把這份流量傳到 \(T\)。猴子決定吃水果去了。
現在水正在按照紅色路徑流動,流量只有 \(1\),就沒法操作了。但你憑藉聰明才智,很容易發現這是一種糟糕的策略,只要開始的時候 \(S\) 沿著上下兩條路走到 \(T\),總流量就有 \(2\) 了。
機智的猴子決定回來暴搜所有流法,它能解決這種規模的資料就已經很滿意了……當然我們有更好的方法:
在水順著管子流過去的時候,新增一條反向邊,邊的容量等於此次流量,允許其它路徑過來。
想象紅色管子裡這個水正在咕咕流動,就這個方向。網路中暗暗顯現了它們對應的反向邊。
這時候再次嘗試找路。\(S\) 順著下方管道給 \(b\) 傳過去 \(1\) 流量。然後 \(b\) 找到了一條反向邊流到 \(a\)?有點顛覆世界觀啊。
不必理解成 \(b\) 流回 \(a\),而可當作 \(a\) 的反悔。
以點為主體進行考慮。\(a\) 看到源點向 \(b\) 給予 \(1\) 流量,於是 \(a\) 決定收回它給予的等量流量,拿去做別的事情,於是管道被空置了。
“我先前幫助 \(b\),給他 \(1\) 流量流到終點去;現在源點跟他關係那麼好,支援他 \(1\)
(其實你不拿回的話,\(b\) 就不平衡了)
於是,對中間的邊,\(a\) 不進行輸出。
當然,在演算法執行的時候,“某次找到這條路並嘗試輸出、接下來某次找路時退回”的過程是存在的。退回方向的反向邊(即原邊)獲得了 \(1\) 份新的殘餘容量,已經回到最早的狀態了。\(a\) 收回流量後,它決定流向終點,網路又增加了 \(1\) 流量(原來的流量沒有被破壞)。
從源點開始,順著有流量的邊,只要能找到一條能到達匯點的路(不管路徑上是否有“退回”操作),就會使網路總流量增加,這種路叫增廣路。上面是兩次增廣,然後就發現網路就沒有增廣路了,並且成果好像還不錯。
最大流的演算法
對一張網路,是不是隻要不斷找增廣路(增廣過程中記得提供退回操作的機會),直到找不到,就是網路最大流?
抱歉,這是真的。為什麼呢?
...無數實踐已經證明。
Part 1
一個網路正在按照猴子的計劃流動。
現在任意割斷一些邊,把原來的點,分成不相連的兩個點集,包括源點所在的點集,叫做 \(S\),匯點所在的叫做 \(T\)。(只是為了分成兩個點集,即為了構成割,才進行一下形式上的割斷)
對於那張網路,除了源點和匯點,其它點都是“流入多少,就流出多少”,沒有實際貢獻或收入。
那麼 \(T\) 輸出到匯點的流量哪兒來的呢?只能是 \(S\) 給的;\(S\) 則是源點給的。
原本 \(S\) 到 \(T\) 那幾條邊,它們的淨流量稱為 \(S\) 到 \(T\) 的淨流量,(注意,也可能有邊從 \(T\) 流向 \(S\),它們對淨流量是負貢獻)
- \(S\) 到 \(T\) 的淨流量 = 源點輸出 = 匯點收入 = 網路總流量
不管怎麼構造割,都有這個結果:
- 圖上任何一種割的淨流量 = 網路總流量
因為割的淨流量不可能超過割本身的容量(當然啦),所以,
- 網路總流量 <= 圖上任何一種割的容量
Part 2
經過猴子的調整,它的網路上沒有增廣路了。源點順著殘量網路到不了匯點了。
源點順著殘量網路能夠到達的點,叫做點集 \(S\)(至少包含自己啊),剩下所有不能到達的點,叫做點集 \(T\)。則 \(S\) 連向 \(T\) 的邊都沒有殘餘容量(如果有殘量邊,\(S\) 也順著這條邊包含了對面的點)。
如果把 \(S\) 和 \(T\) 中間的邊割斷,那麼這個割的淨流量 = 這個割的容量(因為邊上都沒有殘餘容量了)。
又因為 Part 1 證的“網路總流量 = 任意一個割的淨流量”,所以網路總流量 = 此割的容量(①)。
其實這很不容易做到的,因為 Part 1 證了“網路總流量 <= 圖上任何一種割的容量”,所以“①”一定是面這個不等式的交界處。
所以,這個網路流已經是眾望所歸。
一個總體的印象是:只要沒有增廣路,即順著殘量網路到不了匯點,就說明存在一個無殘量的割,其淨流量等於容量;因為網路總流量同時等於圖上任何一個割的淨流量,也就同時絕不會超過圖上任何一個割的容量,所以當總流量 = 某割的容量時,已經沒有比它更大的流,分號以前達到了這個條件,所以現在是最大流。
(還順便證明了最大流 = 最小割)
增廣路定理的證明其實很重要,它把網路最大流問題化簡為:
不斷找增廣路就好了
但是考慮到每次流過去,反向邊容量又增加了,提供太多的反悔機會,會死迴圈嗎?
不會的。首先明確,最大流是有限的(當然);而每次找增廣路,總流量不會減少,只會增加(重申一下,一條邊的“收回流量”操作並不會減少流量,畢竟收回的前提是對方獲得支援;增加流量本身沒有任何代價,只是有各邊的容量限制著),在有限次增廣後,總流量一定會達到最大值,然後找不到增廣路,結束。
實現起來也有很多細節和技巧。
#include <cstdio>
#include <cstring>
#define N 10010
#define E 200010
int n, m, s, t;
int first[N];
int to[E], nxt[E], val[E]/*殘餘容量*/;
int cnt = 1;
inline int min(int x, int y) { return (x < y) ? x : y; }
//cnt初值1,第一條邊的標號為2(二進位制10),第二條是3(二進位制11)
//有啥好處呢?
//我們加入一條邊時,緊接著加入它的反向邊(初始容量0)
//這兩條邊的標號就是二進位制最後一位不相同,一個0、一個1
//所以要召喚 p 這條邊的反向邊,只需用 p ^ 1
//如果cnt初值為0,就做不到。當然初值-1也可以,略需改動
//關於圖中真正的反向邊,可能引起顧慮,應該讓它們標號相鄰?
//其實不用。該找到的增廣路都會找到的
bool vis[N];//限制增廣路不要重複走點,否則很容易爆棧
//兜一大圈走到匯點,還不如直接走到匯點
void addE(int u, int v, int w) {
++cnt;
to[cnt] = v;
val[cnt] = w;
nxt[cnt] = first[u];
first[u] = cnt;
}
int dfs(int u, int flow) {
//注意,在走到匯點之前,無法得知這次的流量到底有多少
if (u == t)
return flow;//走到匯點才return一個實實在在的流量
vis[u] = true;
for (int p = first[u]; p; p = nxt[p]) {
int v = to[p];
if (val[p] == 0 or vis[v])//無殘量,走了也沒用
continue;
int res = 0;
if ((res = dfs(v, min(flow, val[p]))) > 0) {
//↑順著流過去,要受一路上最小容量的限制
val[p] -= res;//此邊殘餘容量減小
val[p ^ 1] += res;//以後可以順著反向邊收回這些容量,前提是對方有人了
return res;
}
}
return 0;//我與終點根本不連通(依照殘量網路),上一個點不要信任我
}
int main() {
scanf("%d %d %d %d", &n, &m, &s, &t);
for (int i = 1; i <= m; ++i) {
int u, v, w;
scanf("%d %d %d", &u, &v, &w);
addE(u, v, w);
addE(v, u, 0);//和正向邊標號相鄰
//反向邊開始容量為0,表示不允許平白無故走反向邊
//只有正向邊流量過來以後,才提供返還流量的機會
}
int res = 0, tot = 0;
while (memset(vis, 0, sizeof(vis)) and (res = dfs(s, 2e9/*水庫很強*/)) > 0)
tot += res;//進行若干回合的增廣
printf("%d\n", tot);
return 0;
}
這種直接深搜找增廣路的辦法叫做 Ford-Fulkerson(FF)演算法。
由於每次只找一條路,這條路還可能繞遠路(可能經過 n 個點才到達匯點),而且增加流量是路上最小的權值,效率低,好像很容易被卡掉。但上面的程式碼能是通過模板題的,最慢一個點 500ms。
不過你可以看出,這個演算法的複雜度和流量有關,令人擔心。
Maximum Flow Faster Algorithm
我們還是有辦法解決 FF 效率低的問題。
每次多路增廣:u 點通過一條邊,向 v 輸出流量以後,v 會嘗試到達匯點(到達匯點才真正增廣),然後 v 返回實際增廣量。這時,如果 u 還有沒用完的供給,就繼續嘗試輸出到其它邊。
但是要警惕繞遠路、甚至繞回的情況,不加管制的話極易發生。怎麼管?
源點順著殘量網路想要到達其它點,需要經過一些邊對吧?按照經過的邊數(即源點出發以後的距離)把圖分層,即用 bfs 分層。每次嘗試給予時,只考慮給予自己下一層的點,就可以防止混亂。
綜合上面兩條。每回合也是從源點出發,先按照當前殘量網路分一次層,隨後多路增廣,儘可能增加流量。增廣過程中,會加入一些反向邊,這些反向邊逆著層次圖,本回合併不會走。所以還需要進入下一回合。一直到 bfs 分層時搜不到匯點(即殘量網路斷了)為止。
這是 Dinic 演算法。如果懂 FF,這個演算法也很塊能懂。
可是它每次只按照 bfs 分層的固定方向進行增廣,還能保證正確性嗎?這個好理解。只要圖中還有增廣路(源點順著殘量網路到達匯點的路),bfs 分層就會搜尋到匯點,於是增廣就不會停止,最終也止於沒有增廣路的局面。
雖然從它“每次多路增廣、繞彎少”能夠大致體會它比 FF 快,但它保證會快嗎?
...無數實踐也已經證明。
要是卡 Dinic 到上界的話,模板題都過不了。
#include <cctype>
#include <cstdio>
#include <cstring>
inline int min(int x, int y) { return (x < y) ? x : y; }
int n, m, s, t, ans = 0;
int cnt = 1, first[10010], nxt[200010], to[200010], val[200010];
inline void addE(int u, int v, int w) {
to[++cnt] = v;
val[cnt] = w;
nxt[cnt] = first[u];
first[u] = cnt;
}
int dep[10010], q[10010], l, r;
bool bfs() {//按照“到源點的距離(邊數)”分層,方法是 bfs
//這是個bool型函式,返回是否搜到了匯點
memset(dep, 0, (n + 1) * sizeof(int));//記得開局先初始化
q[l = r = 1] = s;
dep[s] = 1;
while(l <= r) {
int u = q[l++];
for(int p = first[u]; p; p = nxt[p]) {
int v = to[p];
if(val[p] and !dep[v]) {//按照有殘量的邊搜過去
dep[v] = dep[u] + 1;
q[++r] = v;
}
}
}
return dep[t];//dep[t] != 0 就是搜到了匯點
}
int dfs(int u, int in/*u收到的支援(不一定能真正用掉)*/) {
//注意,return 的是真正輸出的流量
if(u == t)
return in;//到達匯點是第一個有效return
int out = 0;
for(int p = first[u]; p and in; p = nxt[p]) {
int v = to[p];
if(val[p] and dep[v] == dep[u] + 1) {//僅允許流向下一層
int res = dfs(v, min(val[p], in/*受一路上最小流量限制*/));
//res是v最終真正輸出的流量
val[p] -= res;
val[p ^ 1] += res;
in -= res;
out += res;
}
}
if(out == 0)//我與終點(順著殘量網路)不連通
dep[u] = 0;//上一層的點請別再信任我,別試著給我流量
return out;
}
int main() {
scanf("%d %d %d %d", &n, &m, &s, &t);
for(int i = 1; i <= m; ++i) {
int u, v, w;
scanf("%d %d %d", &u, &v, &w);
addE(u, v, w);
addE(v, u, 0);
}
while(bfs())
ans += dfs(s, 2e9);
printf("%d\n", ans);
return 0;
}
注意理解多路增廣:一個點要列舉所有出邊,但實質仍然是 dfs,過程圖類似於樹。
複雜度可能的證明:《淺談基於分層思想的網路流演算法》 P4 ~ P14。個人認為這裡的核心證明中有一段的某句話忽略了一個條件。
Dinic 能應付絕大多數網路流題目了,可能因為網路流題目主要考察建模能力吧。當然最大流還有更高效的演算法。