最大流筆記

摘要

正如可以通過將道路交通圖模型化爲有向圖來找到從一個城市到另一個城市之間的最短路,我們也可以將一個有向圖看作一個“流網絡”,並使用它來回答關於物料流動方面的問題。這種流網絡可以用來建模很多實際問題,包括液體在管道中的流動、裝配線上的部件的流動、電網中電流的流動和通信網絡中信息的流動。
本文首先介紹網絡流中的相關概念,然後給出最大流問題的一般解法思路,以及優化後的算法。我們應該將主要精力放在思維鍛鍊和題目分析上,而不要過於執着於那些經典算法的細節優化。本文中整理的算法在算法競賽中已經夠用。

概念介紹

流網絡: 流網絡 G = (V , E) 是一個有向圖,圖中每條邊 (u , v) 都有一個非負的容量值c(u , v) 。而且,如果邊集合 E 包含一條邊(u , v) ,則圖中不存在反向邊(v , u)。

源節點s和匯點t: 在流網絡的所有節點中,我們分辨出兩個特殊的節點,分別是源點s和匯點t;源點是所有路徑的起點,而匯點是路徑的終點。在某些情況下,源點和匯點需要人爲指定。

流網絡 G 中的流: G中流的大小(值)用 f 表示,f(u , v)表示從點 u 到點 v 的流的大小。整張圖的流是 f=vϵVf(s,v)vϵVf(v,s)|f| = \sum_{v \epsilon V}f(s, v) - \sum_{v \epsilon V}f(v ,s)(前後兩個 v 含義不同),即從源點 s 流出的流量和,或稱爲流入匯點 t 的流量和。

容量限制: 對於所有的節點u,vϵVu, v \epsilon V,要求0<=f(u,v)<=c(u,v)0 <= f(u , v) <= c(u , v)
流量守恆: 對於所有的節點 uϵVu \epsilon V - { s , t },要求流入 u 的流量等於流出 u 的流量,即:
vϵVf(v,u)=vϵVf(u,v)\sum_{v \epsilon V}f(v,u) = \sum_{v \epsilon V}f(u,v)
(前後兩個 v 的含義不同,一個是入邊的起點,一個是出邊的終點)

問題模型: 在最大流問題中,給定一個流網絡G 、一個源節點s、一個匯點t,我們希望找到值最大的一個流。

Edmonds-Karp算法

若一條從源點 s 到匯點 t 的路徑上的各條邊的剩餘容量都大於 0,則稱這條路徑爲一條增廣路徑。顯然,可以讓一股流沿着增廣路徑從 s 流到 t,使網絡的流量增大。Edmonds-Karp算法思想就是不斷用BFS尋找增廣路,直至網絡上不存在增廣路爲止。
該算法的時間複雜度爲O(nm2)O(nm^2)。然而在實際運用中則遠遠達不到這個上界,效率較高,一般能夠處理 10310410^3 - 10^4規模的網絡。

算法思路:
這裏介紹的是利用 bfs 尋找增廣路的Edmonds-Karp增廣路算法。在該算法中,我們不斷的尋找增廣路,並增加增廣路上的流;重複這一步驟直至不存在增廣路。
在每輪尋找增廣路的過程中,Edmonds-Karp算法只考慮圖中所有 f(x ,y) < c(x, y)的邊,用BFS找到任意一條從 s 到 t 的路徑,同時計算出路徑上各邊的剩餘容量的最小值 minf,則網絡的流量就可以增加 minf。

需要注意的是,當一條邊的流量 f(x ,y) > 0時,根據斜對稱性質,它的反向邊流量 f(y ,x) < 0,此時必定有 f(y , x) < c(y ,x)。故Edmonds-Karp算法在BFS時除了原圖的邊集 E 外,還應考慮遍歷 E 中每條邊的反向邊。
具體實現時,本文采用鄰接表“成對存儲”技巧(即’2’和’3’是一對,'4’和’5’是一對)。每條邊只記錄剩餘容量 c-f 即可,當一條邊 (x ,y) 流過大小爲 e 的流時,令 (x ,y) 的容量減少 e,(y ,x) 的容量增加 e 。

代碼模板:

#include<bits/stdc++.h>
using namespace std;
const int N = 1e3+10;	//最大點數
const int M = 1e3+10;	//最大邊數
const int INF = 0x3f3f3f3f;	//int範圍內的無窮大
int head[N], edge[M], ver[M], nex[M], tot = 1;
void addEdge(int x,int y,int z){
	ver[++tot] = y; edge[tot] = z;
	nex[tot] = head[x]; head[x] = tot;
}
int n,m,s,t;	// 共n個點,m條邊,s是源點,t是匯點
int pre[N],incf[N];//增廣路上各邊的最小剩餘容量
bool vis[N];	//標記數組
bool bfs(){
	memset(vis,0,sizeof vis);
	queue<int> q;
	q.push(s); vis[s] = true;
	incf[s] = INF;
	while(!q.empty()){
		int x = q.front(); q.pop();
		for(int i = head[x];i ;i = nex[i]){
			int y = ver[i],z = edge[i];
			if(!z || vis[y]) continue;
			incf[y] = min(incf[x],z);
			pre[y] = i;	//記錄前驅*邊*
			q.push(y); vis[y] = true;
			if(y == t) return true;//找到一條增廣路
		}
	}
	return false;
}
int EK(){
	/*返回最大流的值*/
	int flow = 0;
	while(bfs()){
		int x = t;
		while(x != s){
			int i = pre[x];	//前驅 *邊* 
			edge[i] -= incf[t];	
			edge[i^1] += incf[t];
			x = ver[i^1];	//前驅點 = 反向邊的終點
		}
		flow += incf[t];
	}
	return flow;
}
int main(){
	scanf("%d%d%d%d",&n,&m,&s,&t);
	for(int i = 1,x,y,z;i <= m;i++){
		scanf("%d%d%d",&x,&y,&z);
		addEdge(x,y,z); addEdge(y,x,0);
	}
	printf("%d\n",EK());
	return 0;
}

算法正確性需要用到最小割來證明,詳見《挑戰程序設計競賽第二版》P212。

Dinic算法

在任意時刻,網絡中所有節點以及剩餘容量大於0的邊構成的子圖被稱爲殘量網絡。Edmonds-Karp每輪可能會遍歷整個殘量網絡,但只找出 1 條增廣路,還有進一步優化的空間。
在寬度優先遍歷時,我們可以計算出節點的層次d[x] ,它表示 s 到 x 最少需要經過的邊數。在殘量網絡中,滿足d[y] = d[x] + 1 的邊(x ,y) 構成的子圖被稱爲分層圖 。分層圖顯然是一張有向無環圖。

Dinic 算法不斷重複以下步驟,直到殘量網絡中 s 不能到達 t:

  1. 在殘量網絡上 BFS 求出節點的層次,構造分層圖
  2. 在分層圖上 DFS 尋找增廣路,在回溯時實時更新剩餘容量。另外,每個點可以流向多條出邊,同時還加入了若干剪枝,詳情參考代碼示例。

Dinic 算法的時間複雜度是O(n2m)O(n^2m)。實際運用中遠遠達不到這個上界,可以說是比較容易實現的效率最高的網絡流算法之一,一般能夠處理10410510^4 - 10^5規模的網絡。特別地,Dinic算法求解二分圖最大匹配的時間複雜度爲O(mn)O(m\sqrt n),實際表現則更快。

代碼示例

#include<bits/stdc++.h>
using namespace std;
const int INF = 0x3f3f3f3f;	//int所能表示的最大範圍的一半
const int N = 1e5+10;
const int M = 5*N;
int head[N],edge[M],ver[M],nex[M], tot = 1;
int d[N];	//記錄節點的層次
void addEdge(int x,int y,int z){
	ver[++tot] = y; edge[tot] = z;
	nex[tot] = head[x]; head[x] = tot;
}
queue<int> q;
int n,m,s,t;
bool bfs(){
	/*利用bfs來求出節點的層次,構造分層圖*/
	memset(d,0,sizeof d);
	while(q.size()) q.pop();
	q.push(s); d[s] = 1;
	while(q.size()){
		int x = q.front(); q.pop();
		for(int i = head[x];i ;i = nex[i]){
			int y = ver[i],z = edge[i];
			if(!z || d[y]) continue;
			q.push(y);
			d[y] = d[x] + 1;
			if(y == t) return true;
		}
	}
	return false;
}
int dinic(int x,int flow){
	/* 利用遞歸在分層圖上找增廣路,返回本次增廣的流量 */
	if(x == t) return flow;
	int res = flow, k;
	for(int i = head[x];i ;i = nex[i]){
		int y = ver[i], z = edge[i];
		if(z && d[y] == d[x]+1){
			k = dinic(y,min(res,z));	//遞歸
			if(!k) d[y] = 0;	//剪枝,去掉增廣完畢的點
			edge[i] -= k; edge[i^1] += k;
			res -= k;
		}
	}
	return flow - res;
}
int main(){
	scanf("%d%d%d%d",&n,&m,&s,&t);
	for(int i = 1,x,y,z;i <= m;i++){
		scanf("%d%d%d",&x,&y,&z);
		addEdge(x,y,z); addEdge(y,x,0);
	}
	int flow = 0,maxflow = 0;
	while(bfs())
		while(flow = dinic(s,INF)) maxflow += flow;
	printf("%d\n",maxflow);
	return 0;
}

ISAP算法

ISAP算法沒有正式的名稱,首次出現於 Ahuja和Orlin的經典教材《Network Flows:Theory,Algorithms and Applications》中,作者稱它是一種:改進版的SAP(Improved SAP,ISAP)“。

該算法基於這樣一個事實:每次增廣後,任意節點到匯點(在殘量網絡中)的最短距離都不會減小。這樣,我們可以用一個函數d(x)來表示殘量網絡中節點 x 到匯點的距離的下界(在Dinic中是用數組 d[]),然後在增廣過程中不斷修正這個下界(而不是像Dinic 算法那樣多次增廣以後才重建層次圖),則增廣的時候和 Dinic 類似,只允許沿着 d(y) = d(x) + 1 的有向邊 (x , y) 走。

嚴格的說,算法中的 d 函數是滿足如下兩個條件的非負函數,即 d(t) = 0;對於殘量網絡中的任意弧 (x ,y),d(x) <= d(y) + 1。不難證明,只要滿足這兩個條件,d(x) 就是 x~t 距離的下界。而且當 d(s) >= n時,殘量網絡中不存在 s-t 路。

算法思路
和 Dinic 算法類似,找增廣路的過程是從 s 開始沿着“允許弧”(即在殘量網絡中的,滿足 d[x] = d[y] + 1 的弧 x --> y)往前走(ISAP 算法中叫Advance)如果走不動了怎麼辦?在Dinic算法中,直接“往回走一步”即可,因爲如果找不到增廣路,會重新構造層次圖;但在ISAP中,並沒有一個“一次性修改所有距離標號”的過程,只能邊增廣邊修改。具體來說,在從結點 x 往回走的時候,把 d(x) 修改爲 min{d(y) | (x , y) 是殘量網絡中的弧 } + 1(ISAP算法叫 Retreat)即可。注意,如果殘量網絡中從 x 出發沒有弧,則設 d(x) = n。
ISAP算法看上去不難理解,但是實現起來卻有諸多細節。首先,我們需要使用一種“當
前弧”的數據結構加速 允許弧 的査找,其次,還需要一個 gap 數組維護每個距離標號
結點編號。當把一個結點的距離標號從 x 改成 y 的時候,把 gap[x] 減1,gap[y]加1,然後
檢查 gap[x] 是否爲0。如果是 0 的話,說明 s-t 不連通,算法終止。這就是所謂的 gap 優化。最後,初始距離標號可以統一設爲 0 ,也可以用逆向BFS找,單次運行時效率相差不大,但如果是多次求解小規模網絡流,加上BFS以後速度往往會有明顯提升。

數據結構方面,只多了兩個數組:

int pre[N];        //可增廣路的上一條弧
int gap[N];       //距離標號計數 

代碼示例:

#include<bits/stdc++.h>
using namespace std;
const int N = 1e5+10;
const int M = 5*N;
const int INF = 0x3f3f3f3f;
/*以下6行是數組模擬鄰接表部分*/
int head[N],ver[M],edge[M],nex[M],tot;
int cur[N],dis[N],gap[N],pre[N];
/*
	gap[k]:k層有多少個節點
	pre[x]:x點前一條邊的編號
	cur[]是臨時數組
*/
void addEdge(int x,int y,int z){
	ver[++tot] = y, edge[tot] = z;
	nex[tot] = head[x]; head[x] = tot;
}
int n,m,s,t;
int que[N],front,rear;	//手工模擬隊列,節省部分時間
bool bfs(int n){
	front = rear = 0;
	for(int i = 1;i <= n;i++) 
		dis[i] = -1,cur[i] = head[i],gap[i]= 0;
	dis[t] = 0;  que[rear++] = t;
	while(front != rear){
		int x = que[front++];
		for(int i = head[x];i ;i = nex[i]){
			int y = ver[i],z = edge[i];
			if(dis[y] == -1) 
				que[rear++] = y, dis[y] = dis[x] + 1;
		}
	}
	return ~dis[t];	//-1的補碼是11111111,按位取反後是0
}
int ISAP(int n){
	int k = s, ans = 0, i;	//時刻注意i不能被覆蓋!!!
	bfs(n);	//一次bfs從匯點向前更新層次數組dis
	for(i = 1;i <= n;i++) gap[dis[i]]++;
	while(dis[s] < n){
		if(k == t){
			int mi = INF, loc = t;
			while(loc != s){
				mi = min(mi,edge[pre[loc]]);
				loc = ver[pre[loc]^1];	//前一個頂點
			}
			loc = t;
			while(loc != s){
				edge[pre[loc]] -= mi;
				edge[pre[loc]^1] += mi;
				loc = ver[pre[loc]^1];
			}
			ans += mi, k = s;
		}
		for(i = cur[k];i;i = nex[i]){
			int y = ver[i], z = edge[i];
			if(z && dis[k] == dis[y]+1){
				pre[y] = cur[k] = i; k = y;
				break;
			}
		}
		if(i) continue;
		int m = n;
		for(i = head[k];i;i = nex[i]){
			int y = ver[i],z = edge[i];
			if(z && dis[y] < m) m = dis[y], cur[k] = i;
		}
		if(--gap[dis[k]] == 0) break;
		dis[k] = m+1, ++gap[dis[k]];
		if(k != s) k = ver[pre[k]^1];
		
	}
	return ans;
}
int main(){
	int n,m;
	while(~scanf("%d%d%d%d",&n,&m,&s,&t)){
		memset(head,0 ,sizeof head); tot = 1;
		for(int i = 1,x,y,z;i <= m;i++){
			scanf("%d%d%d",&x,&y,&z);
			addEdge(x,y,z); addEdge(y,x,0);
		}
		printf("%d\n",ISAP(n));
	}
	return 0;
}

參考資料

  • 劉汝佳,算法競賽入門經典訓練指南,北京:清華大學出版社,2012,362-363.
  • 李煜東,算法競賽進階指南,鄭州:河南電子音像出版社,2017,410-415.
  • 秋葉拓哉,挑戰程序設計競賽第2版,北京:人民郵電出版社,2013,209-215.
  • Thomas H.Cormen,算法導論(原書第3版),北京:機械工業出版社,2013,414-417.
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章