1. 程式人生 > 實用技巧 >網路流入門

網路流入門

【模板】網路最大流

什麼是網路流?

對於一張有向\(G(V,E)\)(網路),包含\(N\)個點,\(M\)條邊和源點\(S\),匯點\(T\)

其中的每條邊\((u,v)\)都有一個容量\(c(u,v)\)。這些容量\(c\)構建了一張容量網路,邊在網路中也稱作

如何理解這些概念?

可以把它具體化為一個城市的水網,每根水管就是,水管的容量就是容量,當然,它不能傳輸大於容量的水,也就是流量不可能大於容量,自來水廠就是源點,你家就是匯點。源點的水可以理解為無限,但是運到你家的水是有限的。網路最大流就是讓自來水廠,即源點用最大的功率往外運水,求此時你家也就是匯點收到的水。

為什麼會出現不同的情況呢?因為每條邊的容量是有限的,對應的,源點之外的點如果有多條出路,需要考慮把自己所能接收到的水以最優的方案分配到各條出路,使得最終匯點收到的水最多。

對於這樣一個水網,即網路流模型,有三個性質:

1.容量限制。每條邊的流量(實際流過的水)不大於該邊的容量。設流量為\(f(x,y)\),那麼則有\(f(x,y)\leq c(x,y)\)

2.反對稱性正向邊流量等於反向邊流量。即\(f(x,y)=-f(y,x)\)。後面再細說。

3.流守恆。對於\(G\)中任意一個節點\(u\),如果它不是源點或匯點,那麼它到相鄰節點的流量和為0。也就是流入多少水就會流出多少水。即\(\forall u\in V-\{s,t\},\sum_{w\in V}f(u,w)=0\)

這裡的\(f\)函式即為流函式。如果我們能夠構造合法的\(f\)函式使其滿足以上三個性質,那這就是一個可行流

。其中最大的可行流就是我們要求的最大流

殘量網路:即容量網路減去流函式\(c_f(u,v)=c(u,v)-f(u,v)\)。其實就是在\((u,v)\)這條容量為\(c(u,v)\)的邊上,流過了\(f(u,v)\)的水,還剩的殘量*\(c_f(u,v)\)就是用容量減去流量。

接下來就是演算法部分咯


\(Edmonds-Karp\)增廣路演算法

\(EK\)演算法。

所謂增廣路,就是從\(S\)\(T\)找到的一條路徑上殘量都大於0的一條路徑。

暴力\(EK\)演算法選擇了用\(BFS\)來尋找增廣路。也就是讓一股流從\(S\)出發,一路尋找殘量大於0的弧前進,直到到達\(T\)。這樣網路的流量就會增大。直到找不到一條增廣路為止。

我們在搜尋的時候,可以記錄路徑上最小的殘量,這就是我們可以讓網路的流量增大的值\(dis\)路徑上所有的正向邊都要減去\(dis\),反向邊都要加上\(dis\)。正向邊要減比較容易理解,但是反向邊加,就是因為上面的反對稱性

為什麼這麼做?

一條邊可能在多條增廣路上,為了找到所有增廣路使得全域性最優,我們需要允許反悔。也就是如果當前正向邊已經被包含在了某條已經找到的增廣路中,但是如果把它包含在另一條增廣路中,會使得網路流量增加的更多,那就肯定要選擇更優的。

反向邊的初始權值為0。方向與原弧(邊)相反。

什麼是反悔?假設\(u\)\(v\)原本的正邊權是\(dis\),被增廣之後正邊權就更改為\(0\),反邊權變為\(dis\)。如果還要反過來回去的話便可以通過反邊權回去。或者說是通過反向加壓把一部分水可以壓回去(?)

如果成功反悔,部分流量又會從反邊權回到正邊權,達到分流取得最優的目的。所以在\(EK\)演算法中,我們要遍歷所有正向邊和反向邊,增廣最短的一條。

如何方便的在正向和反向之間跳轉?我們可以把一對邊儲存在\((i,i+1)\)的位置,\(i\)從1開始。比如\((1,2),(3,4)\)等。這樣對於任意一條邊的編號\(p\),它的另一條邊就是\(p\ xor \ 1\)

對於在路程中經過的點\(v\),為了便於更新正反邊的值,我們記錄一個\(pre[v]=\)當前遍歷邊的編號。每次增廣之後,我們就可以對邊權進行更新。

inline void ud()
{
	int u=t;//從匯點出發,走反向邊向源點出發
	while(u!=s)
	{
		int i=pre[u];//被增廣的邊
		val[i]-=dis[t];
		val[i^1]+=dis[t];//正減反加
		u=to[i^1];//走反向邊
	}
	ans+=dis[t];//匯點被增大的流量累加到答案中
}

但是\(EK\)演算法是比較慢的(\(O(VE^2)\),能處理\(1e3-1e4\)的資料),我們需要判重邊來過掉這道板子題...

詳細看看程式碼吧,主要是一點點細節的處理。

#include<bits/stdc++.h>
#define ll long long
using namespace std;
const int N=2020;
const int M=20000;
const int INF=0x3f3f3f3f;
int head[M],to[M],nxt[M],cnt=1;
ll val[M];
void add(int u,int v,ll w)
{
	cnt++;
	to[cnt]=v,nxt[cnt]=head[u],val[cnt]=w,head[u]=cnt;
	cnt++;
	to[cnt]=u,nxt[cnt]=head[v],val[cnt]=0,head[v]=cnt;
}
int n,m,s,t;
int f[N][N],vis[N],pre[N];
ll dis[N];
inline bool bfs()
{
	memset(vis,0,sizeof vis);
	queue<int> q;
	vis[s]=1,dis[s]=INF;
	q.push(s);
	while(!q.empty())
	{
		int u=q.front();
		q.pop();
		for(int i=head[u];i;i=nxt[i])
		{
			int v=to[i];
			if((val[i]==0)||(vis[v]==1))continue;
			dis[v]=min(dis[u],val[i]);
			pre[v]=i,q.push(v),vis[v]=1;
			if(v==t)return 1;
		}
	}
	return 0;
}
ll ans;
inline void ud()
{
	int u=t;
	while(u!=s)
	{
		int i=pre[u];
		val[i]-=dis[t];
		val[i^1]+=dis[t];
		u=to[i^1];
	}
	ans+=dis[t];
}
signed main()
{
	scanf("%d%d%d%d",&n,&m,&s,&t);
	for(int i=1;i<=m;i++)
	{
		int u,v;
		ll w;
		scanf("%d%d%lld",&u,&v,&w);
		if(!f[u][v])
		{
			add(u,v,w);
			f[u][v]=cnt;
		}
		else val[f[u][v]^1]+=w;
	}
	while(bfs()){ud();}
	printf("%lld\n",ans);
	return 0;
}

\(EK\)演算法時間複雜度上界達到了\(O(VE^2)\),如果在完全圖情況下,可以達到\(O(V^5)\)。它為什麼這麼慢?

讓我們來看看\(EK\)的執行:

我們找到了一條\(s→t\)的增廣路,所以我們去更新...再找到一條\(s→1→t\)的增廣路...

不對啊,\(s→2→t\)也是一條增廣路,但是要下一次搜尋才能再增廣到它,這樣不是很浪費時間嗎?

\(Dinic\)演算法應運而生。


\(Dinic\)演算法

\(Dinic\)演算法是\(EK\)演算法的替代品

首先繼承\(EK\)演算法的優點:每次增廣最短路,保留\(BFS\)

然後引入分層圖的概念。簡單來說,一個點的層次\(d[x]\)就是到源點的最短路長度。滿足\(d[y]=d[x]+1\)的邊\((x,y)\)構成的圖就是分層圖。

比如在上圖中,\(1,2,3,4,5\)都在同一層。

首先在殘量網路(殘量不為零的弧組成的網路)中用\(BFS\)求出節點的層次,構造分層圖。

然後在分層圖上用\(DFS\)進行增廣。

我們可以發現,增廣路之間是不會出現環的。否則一定能找到一種更優的選擇。所以,只要在分層圖上進行\(DFS\),那麼一定不會搜到一個環。

而且,在分層圖上的弧都在一條殘量網路的最短路上,這也為我們進行\(DFS\)提供了很便利的條件。

而且。。在分層圖上找最短路,反向邊都去和樑非凡共進晚餐了

所以,你只管開車,辦法由老爹來想,你只管\(DFS\)就完事了。


我們來分析一下時間複雜度吧!

首先尋找最短路的\(BFS\)很好說。每次判斷了下一個層次是否有增廣路,上界也就\(O(V)\)

\(DFS\)的複雜度不用說,我這種蒟蒻都知道是\(O(E)\)

所以它的時間複雜度是\(O(VE)\)!比\(EK\)快太多了。

我們\(Dinic\)真的太厲害啦!

然而是假的。

不會真的有人覺得\(O(E)\)就能找到一個層次的所有增廣路吧,不會吧不會吧?

我們增廣了\(S→2→3→6→T\)這條路之後,難道就不會經過路徑上的點了嗎?

當然不是。我們還要增廣\(S→1→3→4→T\)

所以\(vis\)標記是肯定不能打的,這個\(DFS\)是指數級的,\(Dinic\)就是個垃圾演算法,大家散了吧。。

然而還是假的。

對於一個節點\(x\),當它在\(DFS\)時如果已經走到了第\(i\)條邊,前\(i-1\)條邊一定已經被塞滿,所以下次再遍歷經過\(x\)的邊時,就沒有必要遍歷前\(i-1\)條邊了。所以每次\(DFS\)都記錄一個\(cur[u]\)初始為\(head[u]\),並且在遍歷到邊\(i\)的時候吧\(cur[u]\)更新為\(i\)。並且遍歷邊都從\(cur[u]\)而不是\(head[u]\)開始。

再來一個優化:

如果在同一輪\(DFS\)中,我們發現從\(u\)\(v\)無法增廣,那這個\(v\)就是無用點,我們把它的層數改為極大值或者極小值,目的在於不會再被遍歷。

加上了這兩個優化,\(Dinic\)就從一個垃圾的指數級搜尋演算法變成了一個\(O(V^2E)\),比\(EK\)降低了一個\(O(V)\)左右的時間。而且它在二分圖匹配問題中上界只有\(O(\sqrt V*E)\)


\(CODE\)

#include<bits/stdc++.h>
#define int long long
using namespace std;
const int INF=0x3f3f3f3f;
const int N=2020;
const int M=20020;
int head[M],to[M],val[M],nxt[M],cnt=1;
void add(int u,int v,int w)
{
	cnt++;
	nxt[cnt]=head[u];
	val[cnt]=w;
	to[cnt]=v;
	head[u]=cnt;
}
int n,m,s,t;
int dis[N],cur[M];
inline bool bfs()
{
	//memset(dis,0x3f,sizeof dis);
	for(int i=1;i<=n;i++)
		dis[i]=INF;
	queue<int> q;
	q.push(s);
	dis[s]=0,cur[s]=head[s];
	while(!q.empty())
	{
		int u=q.front();
		q.pop();
		for(int i=head[u];i;i=nxt[i])
		{
			int v=to[i];
			if(val[i]>0&&dis[v]==INF)
			{
				q.push(v);
				cur[v]=head[v];//當前弧優化 
				dis[v]=dis[u]+1;
				if(v==t)return 1;
			}
		}
	}
	return 0;
}
inline int dfs(int u,int sum)
{
	if(u==t)return sum;
	int k,res=0;
	for(int i=cur[u];i&&sum;i=nxt[i])
	{
		cur[u]=i;
		int v=to[i];
		if(val[i]>0&&dis[v]==dis[u]+1)//只遍歷分層圖
		{
			k=dfs(v,min(sum,val[i]));
			if(k==0)dis[v]=INF;
			val[i]-=k;
			val[i^1]+=k;
			res+=k;
			sum-=k;
		} 
	}
	return res;
}
signed main()
{
	scanf("%lld%lld%lld%lld",&n,&m,&s,&t);
	for(int i=1;i<=m;i++)
	{
		int u,v,w;
		scanf("%lld%lld%lld",&u,&v,&w);
		add(u,v,w);
		add(v,u,0);
	}
	int res=0;
	while(bfs()){res+=dfs(s,INF);}
	printf("%lld\n",res);
	return 0;
}