1. 程式人生 > >網路最大流 - 從入門開始,詳細講到實用易懂的 Dinic 演算法

網路最大流 - 從入門開始,詳細講到實用易懂的 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\)

流量,使他足夠維持輸出,那我也要拿回那 \(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 能應付大多數網路流題目了,可能因為網路流題目主要考察建模能力吧。當然最大流還有更高效的演算法。