網路流

qzhwlzy發表於2021-08-20

網路流

網路流(network-flows)是一種類比水流的解決問題方法,與線性規劃密切相關。網路流的理論和應用在不斷髮展,出現了具有增益的流、多終端流、多商品流以及網路流的分解與合成等新課題。網路流的應用已遍及通訊、運輸、電力、工程規劃、任務分派、裝置更新以及計算機輔助設計等眾多領域。

最大流

定義

管道網路中每條邊的最大通過能力(容量)是有限的,實際流量不超過容量。
最大流問題(\(maximum\ flow\ problem\)),一種組合最優化問題,就是要討論如何充分利用裝置的能力,使得運輸的流量最大,以取得最好的效果。求最大流的標號演算法最早由福特和福克遜於1956年提出,20世紀50年代福特(\(Ford\))、福克遜(\(Fulkerson\))建立的“網路流理論”,是網路應用的重要組成成分。


說得通俗點,可以把這幅圖看作是自來水廠的供水路線:\(s\)點是自來水廠,有\(\infty\)個單位的水源源不斷地向外輸出,但是,到了\(t\)點。即住戶家裡水量卻是有限的。這是因為每條水管的容量有限,才導致水的流量也是有限的,且滿足流量\(\le\)容量。回到圖中,“自來水廠”\(s\)點稱作源點,“住戶家”\(t\)點稱為匯點,“容量”即為邊的邊權。
你想讓到你家的水儘量多,所以就衍生出了最大流的問題:即從源點有無限多的水輸出,嚴格滿足流量\(\le\)容量時,能夠到達匯點的最大流量為多少?
為了解決這個問題,我們首先分析一下每條邊的權值是如何影響這條路徑的流量的:
如下圖圖\(1\),顯然最大流為\(4\),因為儘管\(s\rightarrow 1\)的容量為\(5\),可以通過最大為\(5\)的水流,但是\(1\rightarrow 2\)的容量只有\(4\),最多隻能容納\(4\)個單位的水流過。

如圖\(2\),儘管其它節點的容量很大,但是\(3\rightarrow t\)的容量只有\(1\),所以最大流為\(1\)
所以,某條路徑上的最大流量取決於整條路上容量的最小值,即一條路徑{\(v_1,v_2,v_3,\)\(v_n\)}的最大流\(max=\min\limits^{n}_{i=1}(a_{v_i}.dis)\)

求最大流的演算法

EK(Edmond—Karp)演算法

首先引入增廣路的概念:

  • 增廣路

增廣路指從\(s\)\(t\)的一條路,水流流過這條路,使得當前可以到達\(t\)的流量可以增加。

通過定義,我們顯然可以看出求最大流其實就是不斷尋找增廣路的過程。直到找不到增廣路時,到匯點的流量即為最大流。
那麼,如何不斷尋找增廣路呢?爆搜!(其實就是不斷\(BFS\)),從\(s\)\(t\)進行廣搜,搜尋一條增廣路,將這條路的流量和答案都加上\(min\),即整條路徑的最小容量。但是,為了減少變數(其實就是太懶),我們完全可以用當前路徑的所有邊的容量減去\(min\)代替將每條邊的流量增加\(min\)即可,即得到的結果為該邊還能容納多大的水流,也就是剩下的容量。所以,\(BFS\)的時候只要不斷地找所有邊權均\(>0\)的邊即可。等到找不到增廣路的時候,我們得到的答案就是最大流了
但是,這個結論似乎有一點不對勁?
如圖\(1\),此時如果\(BFS\),計算機很可能找到的是\(s\rightarrow 1\rightarrow 2\rightarrow t\)的增廣路(如圖中橙色路徑),並將其權值都減掉\(1\),得到圖\(2\)。此時找不到任何增廣路了,於是程式結束,最後得到的最大流為\(1\)。但是,顯然如果是\(s\rightarrow 1\rightarrow t\)\(s\rightarrow 2\rightarrow t\)兩條路的話,得到的最大流應該是\(2\)。換句話說,我們目前的程式有一個問題:\(BFS\)直接找增廣路並操作得到的並不一定是最大流。
針對這個問題,我們可以引入圖的反向邊來解決。如圖\(3\),在建圖時加入反向邊,且權值為\(0\),方向與原邊相反

當有反向邊存在後,找到增廣路後將權值減少\(min\)後,將反向邊的權值加\(min\)(如圖\(4\))。然後繼續\(BFS\),反向邊也算進去,就會找到如圖\(5\)橙色部分所示的一條增廣路\(s\rightarrow 2\rightarrow 1\rightarrow t\)。再找到最小容量\(min\),同樣操作後得到圖\(6\)。這時我們發現:現在的圖和通過我們之前說的\(s\rightarrow 1\rightarrow t\)\(s\rightarrow 2\rightarrow t\)兩條路之後的結果一樣。所以我們可以得出反向邊的用途:
\(\color{red}{給用過的邊做一個標記,找到更好的情況時可以把以前的情況給“撤銷”了}\)
也可以理解成,反向邊的作用是(以上述例子解釋):當有更好情況但是已經被前面搜到的增廣路佔用時(上述例子中為\(v_{2,t}\)),利用反向邊\(v_{2,1}\)退給\(v_{1,2}\)\(min\)的流量,即有\(min\)的流量不往\(v_{1,2}\)\(v_{2,t}\)流而改往\(v_{1,t}\)流,因為我們搜第二條增廣路時將\(v_{1,t}\)的容量也減了\(min\),也就是流量增加了\(min\)。這樣就給想往\(v_{2,t}\)流的流量留出了位置,於是就會有\(min\)的流量往\(v_{2,t}\)流,而\(v_{2,t}\)的流量相當於減了\(min\)又加了\(min\),相當於不變。

  • 程式碼

P3376 【模板】網路最大流 為例

  1. 建圖
    建圖的方法很多,我比較喜歡採用鄰接表(鏈式前向星)儲存:
struct node{//結構體
	int to,dis,nex;
        //to:邊指向的節點 dis:權值/容量 nex:同起點的下一條邊的下標
}a[maxm*2];
int head[maxn],num=1;
//head[i]表示以i為起點的第一條邊在a中的下標,num記錄a中最大下標(總邊數)
void add(int from,int to,int dis){//增加一條邊
	a[++num].to=to;
	a[num].dis=dis;
	a[num].nex=head[from];
	head[from]=num;
}
  1. \(BFS\)找增廣路
    從源點\(s\)開始,\(BFS\)搜每個節點,當且僅當邊權(即容量)\(>0\)時進入,到達匯點\(t\)時即為一條增廣路。
bool bfs(){
	queue<int> q;//佇列
	memset(vis,0,sizeof(vis));
	vis[s]=1;//是否搜過
	q.push(s);
	while(!q.empty()){
		int top=q.front();//隊首
		q.pop();
		for(int i=head[top];i;i=a[i].nex){//搜所有從top開始的邊
			if(!vis[a[i].to]&&a[i].dis){//若沒去過且容量大於0
				vis[a[i].to]=1;//標記去過
				p[a[i].to].pre=top;//記錄路徑,pre為上一個點
				p[a[i].to].edge=i;//記錄路徑,edge為邊
				if(a[i].to==t){//到匯點返回1,表示有增廣路
					return 1;
				}
				q.push(a[i].to);
			}
		}
	}
	return 0;//若全部搜完還沒有出現增廣路,返回0
}
  1. \(EK\)演算法核心
    不斷\(BFS\)搜增廣路,更新權值。搜不到增廣路時答案即為最大流。
  • 特別注意,程式碼中將下標異或\(1\)後得到的即為其反向邊。因為建邊時邊和其反向邊總是成對出現且反向邊為奇數。
void ek(){
	long long ans=0;
	while(bfs()){//不斷找增廣路
		int minn=99999999;
		for(int i=t;i!=s;i=p[i].pre){
			minn=min(minn,a[p[i].edge].dis);//找最小容量
		}
		for(int i=t;i!=s;i=p[i].pre){//更改
			a[p[i].edge].dis-=minn;
			a[p[i].edge^1].dis+=minn;
		}
		ans+=minn;
	}
	printf("%lld",ans);//輸出最大流
}
  1. 完整程式碼
#include<iostream>
#include<cstdio>
#include<cstring>
#include<queue>
#define maxn 210
#define maxm 5005
using namespace std;
int n,m,s,t,u,v,w;
struct node{
	int to,dis,nex;
}a[maxm*2];
int head[maxn],num=1;
void add(int from,int to,int dis){
	a[++num].to=to;
	a[num].dis=dis;
	a[num].nex=head[from];
	head[from]=num;
}
bool vis[maxn];
struct path{
	int pre,edge;
}p[maxm*2];
bool bfs(){
	queue<int> q;
	memset(vis,0,sizeof(vis));
	vis[s]=1;
	q.push(s);
	while(!q.empty()){
		int top=q.front();
		q.pop();
		for(int i=head[top];i;i=a[i].nex){
			if(!vis[a[i].to]&&a[i].dis){
				vis[a[i].to]=1;
				p[a[i].to].pre=top;
				p[a[i].to].edge=i;
				if(a[i].to==t){
					return 1;
				}
				q.push(a[i].to);
			}
		}
	}
	return 0;
}
void ek(){
	long long ans=0;
	while(bfs()){
		int minn=99999999;
		for(int i=t;i!=s;i=p[i].pre){
			minn=min(minn,a[p[i].edge].dis);
		}
		for(int i=t;i!=s;i=p[i].pre){
			a[p[i].edge].dis-=minn;
			a[p[i].edge^1].dis+=minn;
		}
		ans+=minn;
	}
	printf("%lld",ans);
}
int main(){
	scanf("%d%d%d%d",&n,&m,&s,&t);
	for(int i=1;i<=m;i++){
		scanf("%d%d%d",&u,&v,&w);
		add(u,v,w);
		add(v,u,0);
	}
	ek();
	return 0;
}

Dinic演算法

Dinic演算法(又稱Dinitz演算法)是一個在網路流中計算最大流的強多項式複雜度的演算法,設想由以色列(前蘇聯)的電腦科學家Yefim (Chaim) A. Dinitz在1970年提出。

讓我們優化一下\(EK\)演算法。
注意到,\(EK\)中,我們是一條一條地搜增廣路,那麼,可不可以一次搜出所有(或多條)增廣路呢?於是,\(Dinic\)演算法解決了需要多路增廣的問題。
\(Dinic\)演算法的過程如下:

  1. \(BFS\)將圖分層;
  2. \(DFS\)搜出所有增廣路;
  3. 重複前兩步,直到找不到增廣路為止。

首先看到第一步,分層有什麼用呢?實際上,分層是為了讓我們找到的增廣路是最短的,如圖\(1\)(分的層用紅色數字標在節點旁邊),我們就會搜\(s\rightarrow 1\rightarrow 2\)的增廣路而不是另一條更長的路了。

分完層後,我們就進行到第\(2\)\(DFS\)搜增廣路了。用\(DFS\)看似時間更長,實際上它在時間變化不大的同時還能搜到所有增廣路。如圖\(2\),分完層後,用\(DFS\)搜出的兩條路徑如圖中橙色部分。進行完後和\(EK\)一樣減容量、加反向邊容量,再繼續分層、搜尋……直到沒有增廣路(即所有邊都到不了匯點)即可結束。具體看程式碼解析。

  • 程式碼(例題還是一樣的)
  1. \(BFS\)分層
    \(BFS\)時,先將源點\(s\)的層數設為\(0\)\(1\)其實也沒什麼關係),之後廣搜,找到能更新的邊就更新。
bool bfs(){
	memset(dep,0x3f,sizeof(dep));//層數
	memset(vis,0,sizeof(vis));//是否入過隊
	queue<int> q;
	dep[s]=0;
	q.push(s);
	vis[s]=1;
	while(!q.empty()){
		int top=q.front();
		q.pop();
		for(int i=head[top];i;i=a[i].nex){
			if(dep[a[i].to]>dep[top]+1&&a[i].dis){//可更新的就更新
				dep[a[i].to]=dep[top]+1;
				if(!vis[a[i].to]){//沒入過隊的入隊
					vis[a[i].to]=1;
					q.push(a[i].to);
				}
			}
		}
	}
	if(dep[t]==dep[0]){//若匯點t的層數和0號節點一樣,即為初始值,意味著沒有增廣路了,返回false
		return 0;
	}
	return 1;
}
  1. \(DFS\)搜尋
    \(minn\)表示這條路的最大容量(其實就是上面介紹\(EK\)時的\(min\)),\(use\)表示用過的容量。\(use\)的主要作用是,記錄當前搜過的增廣路要減去的流量,當\(use\)的值等於\(minn\)時,意味著當前的邊已經達到了最大容量,此時停止搜尋。
ll dfs(int x,int minn){//x為當前節點編號
	if(x==t){//到達匯點
		ans+=minn;//累加答案
		return minn;
	}
	int use=0;
	for(int i=head[x];i;i=a[i].nex){
		if(a[i].dis&&dep[a[i].to]==dep[x]+1){//滿足最短增廣路且還有剩餘容量
			int nex=dfs(a[i].to,min(minn-use,a[i].dis));//向下搜
			if(nex>0){//若結果大於0,說明有增廣路,因為若下面沒有增廣路了則會返回use的初值0
				use+=nex;//增加當前流量
				a[i].dis-=nex;
				a[i^1].dis+=nex;
				if(use==minn){//達到最大容量
					break;
				}
			}
		}
	}
	return use;
}
  1. \(Dinic\)演算法核心
    非常簡潔,因為主要部分都在兩個搜尋裡了。
void dinic(){
	while(bfs()){//若有增廣路
		dfs(s,99999999);//搜尋(minn的初值大一點就好)
	}
	printf("%lld",ans);//搜完輸出答案
}
  1. 完整程式碼
#include<iostream>
#include<cstdio>
#include<cstring>
#include<queue>
#define maxn 210
#define maxm 5005
#define ll long long
using namespace std;
int n,m,s,t,u,v,w;
ll ans=0;
struct node{
	int to,dis,nex;
}a[maxm*2];
int head[maxn],num=1;
void add(int from,int to,int dis){
	a[++num].to=to;
	a[num].dis=dis;
	a[num].nex=head[from];
	head[from]=num;
}
bool vis[maxn];
int dep[maxn];
bool bfs(){
	memset(dep,0x3f,sizeof(dep));
	memset(vis,0,sizeof(vis));
	queue<int> q;
	dep[s]=0;
	q.push(s);
	vis[s]=1;
	while(!q.empty()){
		int top=q.front();
		q.pop();
		for(int i=head[top];i;i=a[i].nex){
			if(dep[a[i].to]>dep[top]+1&&a[i].dis){
				dep[a[i].to]=dep[top]+1;
				if(!vis[a[i].to]){
					vis[a[i].to]=1;
					q.push(a[i].to);
				}
			}
		}
	}
	if(dep[t]==dep[0]){
		return 0;
	}
	return 1;
}
ll dfs(int x,int minn){
	if(x==t){
		ans+=minn;
		return minn;
	}
	int use=0;
	for(int i=head[x];i;i=a[i].nex){
		if(a[i].dis&&dep[a[i].to]==dep[x]+1){
			int nex=dfs(a[i].to,min(minn-use,a[i].dis));
			if(nex>0){
				use+=nex;
				a[i].dis-=nex;
				a[i^1].dis+=nex;
				if(use==minn){
					break;
				}
			}
		}
	}
	return use;
}
void dinic(){
	while(bfs()){
		dfs(s,99999999);
	}
	printf("%lld",ans);
}
int main(){
	scanf("%d%d%d%d",&n,&m,&s,&t);
	for(int i=1;i<=m;i++){
		scanf("%d%d%d",&u,&v,&w);
		add(u,v,w);
		add(v,u,0);
	}
	dinic();
	return 0;
}

但是,洛谷上卻\(TLE\)了一個點,時間達到了驚人的\(2.2s\)。此時,我們就要進行傳說中的當前弧優化了。

Dinic演算法+當前弧優化

當前弧優化實際上只是增加了一個陣列\(cur\),用\(cur_i\)代替鄰接表中的\(head_i\)
原理是:當我們已經搜過一條邊時,一定已經讓這條邊無法繼續增廣了,所以這條邊已經沒什麼用了,直接用\(cur\)記錄下一條有用的邊,搜尋時就可以省時間了。

  • 程式碼(最終版本)

剛才TLE的點直接減到了13ms

#include<iostream>
#include<cstdio>
#include<cstring>
#include<queue>
#define maxn 205
#define maxm 5005
#define ll long long
#define inf 0x3fffffff
using namespace std;
int n,m,s,t,u,v,w;
int head[maxn],tt=1;
struct node{
	int to,dis,nex;
}a[maxm*2];
void add(int from,int to,int dis){
	a[++tt].to=to;
	a[tt].dis=dis;
	a[tt].nex=head[from];
	head[from]=tt;
}
bool vis[maxn];
int dep[maxn],cur[maxn];
bool bfs(){
	for(int i=0;i<=n;i++){
		vis[i]=0;
		dep[i]=inf;
		cur[i]=head[i];
	}
	queue<int> q;
	vis[s]=1;
	q.push(s);
	dep[s]=0;
	while(!q.empty()){
		int top=q.front();
		q.pop();
		for(int i=head[top];i;i=a[i].nex){
			if(dep[top]+1<dep[a[i].to]&&a[i].dis){
				dep[a[i].to]=dep[top]+1;
				if(!vis[a[i].to]){
					vis[a[i].to]=1;
					q.push(a[i].to);
				}
			}
		}
	}
	if(dep[t]==dep[0]){
		return 0;
	}
	return 1;
}
ll ans=0;
int dfs(int x,int minn){
	if(x==t){
		ans+=minn;
		return minn;
	}
	int use=0;
	for(int i=cur[x];i;i=a[i].nex){
		cur[x]=i;
		if(dep[a[i].to]==dep[x]+1&&a[i].dis){
			int search=dfs(a[i].to,min(minn-use,a[i].dis));
			if(search>0){
				use+=search;
				a[i].dis-=search;
				a[i^1].dis+=search;
				if(use==minn){
					break;
				}
			}
		}
	}
	return use;
}
void dinic(){
	while(bfs()){
		dfs(s,inf);
	}
	printf("%lld",ans);
}
int main(){
	scanf("%d%d%d%d",&n,&m,&s,&t);
	for(int i=1;i<=m;i++){
		scanf("%d%d%d",&u,&v,&w);
		add(u,v,w);
		add(v,u,0);
	}
	dinic();
	return 0;
}

最小割

定義

對於一個網路流圖\(G=(V,E)\),其割的定義為一種點的劃分方式:將所有的點劃分為\(S\)\(T\)兩個集合,且\(T=V-S\),其中源點\(s\in S \),匯點\(t\in T\)
定義割\((S,T)\)的容量\(c(S,T)\)(或\(c(s,t)\))表示所有\(S\)\(T\)的邊容量之和,即\(c(S,T)=\sum\limits_{u\in S,v\in T}{c(u,v)}\)
最小割就是求得一個割\((S,T)\)使得\(c(S,T)\)最小。

也就是說,一個割就是:把圖的所有節點分成兩部分,割的容量就是所有從源點\(s\)所在的點集到另一個點集的邊的容量之和。如圖為一些割及其容量。

如圖綠色線為四種割,其中第三條為最小割,為\(9\)(標紅部分)。
注意到,這幅圖的最大流也是\(9\)。那麼,最大流與最小割有什麼關係呢?

最大流最小割定理

定理內容

  1. 如果\(f\)是網路中的一個流,\(c(S,T)\)是任意一個割,那麼\(f\)等於正向割邊的流量與負向割邊的流量之差。
  2. \(f(s,t)_{max}=c(s,t)_{min}\),即最大流等於最小割

證明

  1. 設兩點集分別為\(S\)\(T\),定義\(c(A,B)\)表示從\(A\)指向\(B\)的邊的容量和(其實就是割的容量)。則只需證明\(f=c(S,T)-c(T,S)\)即可。
    顯然若\(B_1\cap B_2=\varnothing\),則\(c(A,(B_1\cup B_2))=c(A,B_1)+c(A,B_2)\)\(c((B_1\cup B_2),A)=c(B_1,A)+c(B_2,A)\)\(\quad ①\)
    那麼若有一個節點\(X\in S\)
    \(X\)為源點,則\(c(X,S\cup T)-c(S\cup T,X)=f\)
    \(X\)不是源點,則\(c(X,S\cup T)-c(S\cup T,X)=0\)
    因為點集\(S\)中所有點都滿足上述關係式,相加得到\(c(S,S\cup T)-c(S\cup T,S)=f\)。用①式得到:

\[f=c(S,S\cup T)-c(S\cup T,S)=(c(S,S)+c(S,T))-(c(S,S)+c(T,S))=c(S,T)-c(T,S) \]

  1. \(1\)得到,對於每一個可行的流\(f(s,t)\)和一個割\((S,T)\),我們可以得到\(f(s,t)=c(S,T)-c(T,S)\le c(S,T)\)
    特別地,當\(f\)為最大流時,原圖中一定沒有增廣路,即\(S\)的出邊一定滿流,入邊一定零流,即\(f(s,t)=c(S,T)\)
    由以上兩個式子得到此時\(f\)一定最大,\(c\)一定最小。即此時\(f\)為最大流,\(c\)為最小割,最大流等於最小割。

程式碼

根據最大流最小割定理,用\(Dinic\)求出最大流即為最小割。

費用流—最小費用最大流

定義

給定網路\(G=(V,E)\),每條邊除了有容量限制\(c(u,v)\),還有一個單位流量的費用\(w(u,v)\)
當(u,v)的流量為\(f(u,v)\)時,需要花費\(f(u,v)*w(u,v)\)的費用。
則該網路中總花費最小的最大流稱為最小費用最大流,即在最大化\(\sum\limits_{(u,v)\in E}{f(u,v)}\)的前提下最小化\(\sum\limits_{(u,v)\in E}{f(u,v)\times w(u,v)}\)

那麼,在有了費用(即每條邊流過要繳的水費時),應該如何讓流量最大且費用最少呢?
\(Dinic\)一樣,我們要分層、搜增廣路並更新。那麼,我們應該更改哪一步呢?
顯然是分層,我們可以把\(BFS\)改成已經涼了的\(SPFA\),這樣,就可以完成了。

程式碼

#include<iostream>
#include<cstdio>
#include<cstring>
#include<queue>
#define maxn 5005
#define maxm 50005
#define ll long long
#define inf 0x3fffffff
using namespace std;
int n,m,s,t,u,v,w,cost;
int head[maxn],tt=1;
struct node{
	int to,dis,cost,nex;
}a[maxm*2];
void add(int from,int to,int dis,int cost){
	a[++tt].to=to;
	a[tt].dis=dis;
	a[tt].cost=cost;
	a[tt].nex=head[from];
	head[from]=tt;
}
bool vis[maxn];
int costs[maxn];
bool spfa(){
	memset(vis,0,sizeof(vis));
	memset(costs,0x3f,sizeof(costs));
	queue<int> q;
	vis[s]=1;
	q.push(s);
	costs[s]=0;
	while(!q.empty()){
		int top=q.front();
		q.pop();
		for(int i=head[top];i;i=a[i].nex){
			if(costs[top]+a[i].cost<costs[a[i].to]&&a[i].dis){
				costs[a[i].to]=costs[top]+a[i].cost;
				if(!vis[a[i].to]){
					vis[a[i].to]=1;
					q.push(a[i].to);
				}
			}
		}
	}
	if(costs[t]==costs[0]){
		return 0;
	}
	return 1;
}
ll ans=0,anscost=0;
int dfs(int x,int minn){
	if(x==t){
		vis[t]=1;
		ans+=minn;
		return minn;
	}
	int use=0;
	vis[x]=1;
	for(int i=head[x];i;i=a[i].nex){
		if((!vis[a[i].to]||a[i].to==t)&&costs[a[i].to]==costs[x]+a[i].cost&&a[i].dis){
			int search=dfs(a[i].to,min(minn-use,a[i].dis));
			if(search>0){
				use+=search;
				anscost+=(a[i].cost*search);
				a[i].dis-=search;
				a[i^1].dis+=search;
				if(use==minn){
					break;
				}
			}
		}
	}
	return use;
}
void dinic(){
	while(spfa()){
		vis[t]=1;
		while(vis[t]){
			memset(vis,0,sizeof(vis));
			dfs(s,inf);
		}
	}
	printf("%lld %lld",ans,anscost);
}
int main(){
	scanf("%d%d%d%d",&n,&m,&s,&t);
	for(int i=1;i<=m;i++){
		scanf("%d%d%d%d",&u,&v,&w,&cost);
		add(u,v,w,cost);
		add(v,u,0,-cost);
	}
	dinic();
	return 0;
}

相關文章