【笔记】网络流Ⅰ:最大流最小割
前言:本文内容原创,转载请注明出处。
引
网络流理论(network-flows)是一种类比水流的解决问题方法,与线性规划密切相关。网络流的理论和应用在不断发展,出现了具有增益的流、多终端流、多商品流以及网络流的分解与合成等新课题。网络流的应用已遍及通讯、运输、电力、工程规划、任务分派、设备更新以及计算机辅助设计等众多领域。
以上内容来自百度百科。
最大流可以解决什么问题?
一个简单例子:有个自来水场,水厂到你家可能要经过好多中转站和水管,每条水管有一个流量限度,就是水管最多能流多少单位的水。自来水厂源源不断的放水,问你家最多能收到几个单位的水。
一些定义
-
容量网络:一个有向网络(有向图) \(G\),指定一个顶点,称为源点(记为 \(s\)),以及另一个顶点,称为汇点(记为 \(t\));对于每一条弧 \(u\to v\) 属于 \(G\),对应有一个边权 \(c(u,v)>0\),称为 弧的容量 。这样的有向网络 \(G\) 称为容量网络。
-
弧的流量:通过容量网络 \(G\) 中每条弧 \(u\to v\) 的实际流量(简称流量),记为 \(f(u,v)\)。
-
网络流:所有弧上流量的集合 \(F={f(u,v)}\),称为该容量网络的一个网络流。
-
可行流:在容量网络 \(G\) 中满足以下条件的网络流 \(F\),称为可行流。
-
弧流量限制条件: \(0\le f(u,v)\le c(u,v)\)
-
平衡条件:即流入一个点的流量要等于流出这个点的流量,(源点和汇点除外)
-
-
最大流:在容量网络中,满足弧流量限制条件,满足平衡条件,并且具有最大流量的可行流,称为网络最大流,简称最大流。
-
割:设网络中一些边的集合为 \(E\),断开这些边,若能将网络分成分别包含源点和汇点的两个子集,则 \(E\) 为网络的割。
-
最小割:边权值之和最小的割。
以上图为例,源点为 \(1\),汇点为 \(5\),黑色数字表示弧的容量,红色数字表示实际流量。
可以发现,图中所示的是可行的一种最大流。
最大流算法
首先,显而易见的,单独一条路径上的流量(链上)由其边权的最小值决定。
所以最简单的想法是,从源点开始 \(\text{DFS}\),记录路径上的边权 \(\min\) 作为到达汇点后流量的增加值 \(f\),然后把该路径上的剩余容量都减去 \(f\),继续 \(\text{DFS}\)。
但该方法存在明显缺陷: \(\text{DFS}\) 的顺序不确定。
如下面的典例:
其答案显然为 \(2\)(\(1\to 2\to 4 \text{ and } 1\to 3\to 4\))。但若 \(\text{DFS}\) 顺序为 \(1\to 2\to 3\to 4\),则会得到 \(1\) 的答案。
这种做法出现错误,是因为其陷入了局部最优解。所以,我们需要反悔的机会。
Ford-Fulkerson 算法
考虑引入反向边。这是所有最大流算法的核心。
何为反向边?
字面意思,对于每一条现有的弧,建一条与它起点终点互换的弧,其权值初始为 \(0\)。
仍然以上图为例:
反向边有何作用?
每次求出一个流量的增加值 \(f\),把该路径上的剩余容量都减去 \(f\) 后,同时要把其对应的反向边剩余容量加上 \(f\)。
其中红色为已用流量,黑色为正向边剩余容量,绿色为反向边剩余容量。
这样 \(1\to 2\to 3\to 4\) 走完一次后,发现还有 \(1\to 3\to 2\to 4\) 一条路可以走。这样的走法称为 增广。
第二次走的流量用紫色标出。其中黄圈内的是这次路径反向增加的容量。
可以发现,这时剩余容量已不支持找到一条从源点到汇点的可行路径了,所以得出答案为 \(2\)。
如何理解其正确性呢?
对于上图,我们删掉一些无用内容,包括正反重复走的边(正负抵消)、没有走到过的边等。
可以发现,就是我们手算得到的结果。
例1:B3606 [图论与代数结构 501] 网络流_1
考虑到点数边数极小,边权随机,可以直接硬上 \(\text{DFS}\),同时偷懒,直接用邻接矩阵存图。
其中,\(res_{u,v}\) 表示 \(u\to v\) 的边还剩多少容量。
AC Code
#include<bits/stdc++.h>
#define IOS ios::sync_with_stdio(false)
#define TIE cin.tie(0),cout.tie(0)
#define int long long
#define inf 1e18
using namespace std;
int n,m,s,t,res[35][35];
int u,v,c;
bool vis[35];
int dfs(int x,int t,int f){
if(x==t) return f; //到终点了返回
for(int i=1;i<=n;i++){
if(res[x][i]>0&&!vis[i]){ //有剩余容量且当前次未访问
vis[i]=1;
int tmp=dfs(i,t,min(f,res[x][i])); //流量取min
if(tmp>0){
res[x][i]-=tmp,res[i][x]+=tmp; //正向减,反向加
return tmp;
}
}
}
return 0;
}
int maxflow(int s,int t){
int ans=0;
while(1){
memset(vis,0,sizeof(vis)); //每次找路径每个点只要访问一次,所以计 vis
vis[s]=1;
int tmp=dfs(s,t,inf);
if(tmp==0) return ans; //无路可走
ans+=tmp;
}
}
signed main(){
IOS;TIE;
cin>>n>>m>>s>>t;
for(int i=1;i<=m;i++) cin>>u>>v>>c,res[u][v]+=c; //注意肯有重复边,要 +=
cout<<maxflow(s,t)<<endl;
return 0;
}
为什么上面提到了 边权随机 呢?
因为该算法存在一定问题:其复杂度问号,容易被卡。如下例:
它要跑 \(2000\) 次才能得到正确答案。
如何优化?
EK 算法
是求最大流的一种容易实现、代码易懂的算法。
考虑引入 \(\text{BFS}\),每次找一条源点和汇点之间的可以流的最短路进行增广,可以有效降低时间复杂度。
每次从源点尝试找到一条到达汇点的路径,若路径上最小的残留量大于 \(0\),那么我们就可以把这条路上的最小残留量减去,累加到 \(ans\) 里。继续 \(\text{BFS}\) 直到找不到位置,此时 \(ans\) 就是最大流。
设点数为 \(n\),边数为 \(m\),复杂度上限为 \(O(nm^2)\)。
代码可以参考 这篇博客,这里不再给出。
Dinic 算法
考虑进行 多路增广。
何为 多路增广?
就是每次根据剩余容量构建一张分层图,在层次图中使用 \(\text{DFS}\) 进行增广直到不存在增广路。
实现:每次用 \(\text{BFS}\) 求出当前剩余容量下所有点到源点的深度,在当前图中增广。具体就是只走向深度 \(+1\) 的点。
为什么它的复杂度会更优?
再以此图为例。\(\text{EK}\) 算法第一次 \(\text{BFS}\) 后找到了 \(1\to 2\to 4\) 这一可行流,随后会再进行一次 \(\text{BFS}\) 找到 \(1\to 3\to 4\) 这一可行流。而 \(\text{Dinic}\) 算法再第一次 \(\text{BFS}\) 后会找到 \(2,3\) 两个深度为 \(2\) 的点(设源点深度为 \(1\)),就可以同时向两条路径进行增广。图简单的时候可能看不出差距,当横向边较多时 \(\text{Dinic}\) 的复杂度明显优于 \(\text{EK}\)。
注意, \(\text{Dinic}\) 算法中分层图是实时变化的,每当当前分层图路径走完后,就要根据剩余的容量 \(\text{BFS}\) 重新建分层图。当 \(\text{BFS}\) 后汇点的深度没有被更新时,说明了剩余容量不足以支持一条从源点到汇点的流,也就得出了最大流。
复杂度上限为 \(O(n^2m)\)。
例2:B3607 [图论与代数结构 502] 网络流_2
因为点数同样很少,所以偷懒还是邻接矩阵存图。
AC Code
#include<bits/stdc++.h>
#define IOS ios::sync_with_stdio(false)
#define TIE cin.tie(0),cout.tie(0)
#define int long long
#define inf 1e18
using namespace std;
int n,m,s,t,res[105][105],dep[105];
int u,v,c;
queue<int> q;
bool bfs(int s,int t){
while(q.size()) q.pop();
q.push(s);
memset(dep,0,sizeof(dep));
dep[s]=1;
while(q.size()){
int tmp=q.front();q.pop();
for(int i=1;i<=n;i++){
if(res[tmp][i]&&!dep[i]){ //有容量剩余
dep[i]=dep[tmp]+1; //求深度
q.push(i);
}
}
}
return dep[t];
}
int dfs(int x,int t,int f){
if(x==t) return f;
int ans=0;
for(int i=1;i<=n;i++){
if(res[x][i]&&dep[x]+1==dep[i]){ //有容量剩余且深度连续
int tmp=dfs(i,t,min(res[x][i],f));
res[x][i]-=tmp,res[i][x]+=tmp; //正向减,反向加
ans+=tmp,f-=tmp;
if(f<=0) break; //小优化:无剩余就可以跳过了
}
}
return ans;
}
int dinic(int s,int t){
int ans=0;
while(bfs(s,t)){ //还有增广路径
int tmp=dfs(s,t,inf);
ans+=tmp;
}
return ans;
}
signed main(){
IOS;TIE;
cin>>n>>m>>s>>t;
for(int i=1;i<=m;i++) cin>>u>>v>>c,res[u][v]+=c;
cout<<dinic(s,t)<<endl;
return 0;
}
双倍经验:P3376【模板】网络最大流
三倍经验:P1343 地震逃生
链星&当前弧优化
因为点数肯能会很多,所以用邻接表存图不现实。
笔者一开始想到使用 \(\text{vector}\) (用惯了),但发现增广有一个反向边加流量的过程,而 \(\text{vector}\) 不能根据起点终点直接定位反向边。
于是笔者写了一个复杂度问号的东西:
int dfs(int x,int t,int f){
if(x==t||!f) return f;
int ans=0;
for(int i=0;i<a[x].size();i++){
node &tmp=a[x][i];
if(tmp.f&&dep[x]+1==dep[tmp.to]){
int tt=dfs(tmp.to,t,min(tmp.f,f));
tmp.f-=tt;
for(int j=0;j<a[tmp.to].size();j++){ //暴力找反向边(
if(a[tmp.to][j].to==x){
a[tmp.to][j].f+=tt;
break;
}
}
ans+=tt,f-=tt;
if(f<=0) break;
}
}
return ans;
}
最离谱的是,P2763 试题库问题 居然这样艹过去了。
顺带一提,这题数据好像水的离谱,一开始笔者反向边加写成减居然也过了。
回归正题,不得不用链式前向星,否则反向边加权最劣也变成了 \(O(m)\)。
考虑两点间的正反边连续来建,这样边 \(i\) 的反向边编号就是 \(i\oplus 1\)。
再来说当前弧优化。
当前弧优化就是说我们在每次 \(\text{DFS}\) 找的时候,把已经没有容量的点删掉,直接从可以增加流量的边开始。具体做法就是记录一个 \(cur\) 数组,每次 \(\text{BFS}\) 直接把 \(cur\) 数组赋为链星中的 \(head\) 数组(头指针),随后在 \(\text{DFS}\) 中用 \(cur\) 代替 \(head\),每次访问 \(nxt\) 时将 \(cur\) 更新,可以省去不少无用访问。
例3:CF1423B Valuable Paper
首先考虑二分答案,二分出一个时间 \(x\),将权值 \(\le x\) 的边连上,然后就可以跑最大流了。
为什么可以用最大流解决?
考虑建图:首先定义一个超级源点,超级原点向所有工厂连边权为 \(1\) 的边,再定义一个超级汇点,所有机场向超级汇点连边权为 \(1\) 的边。然后当前联通的工厂、机场之间,工厂向机场连边权为 \(1\) 的边。注意,反向边也要建出来,初始容量为 \(0\)。
这时,若超级源点到超级汇点的最大流值为 \(n\),则说明机场工厂两两匹配了。本质上就是二分图最大匹配问题,也可以用 匈牙利算法 解决。想进一步了解二分图最大匹配问题的话可以去看 P2756 飞行员配对方案问题 。
AC Code
#include<bits/stdc++.h>
#define IOS ios::sync_with_stdio(false)
#define TIE cin.tie(0),cout.tie(0)
#define int long long
#define inf 1e18
using namespace std;
int k,n,m,p,x,to,dep[20005];
int head[20005],tot,cur[20005];
struct node{
int to,nxt,f;
}a[1000005];
void add(int u,int v,int f){
a[++tot]={v,head[u],f};
head[u]=tot;
}
struct edge{
int u,v,w;
}e[100005];
queue<int> q;
int bfs(int s,int t){
while(q.size()) q.pop();
q.push(s);
memset(dep,0,sizeof(dep));
dep[s]=1;
while(q.size()){
int tmp=q.front();q.pop();
for(int i=head[tmp];i;i=a[i].nxt){
node tt=a[i];
if(tt.f&&!dep[tt.to]){
dep[tt.to]=dep[tmp]+1;
q.push(tt.to);
}
}
}
return dep[t];
}
int dfs(int x,int t,int f){
if(x==t||!f) return f;
int ans=0;
for(int i=cur[x];i;i=a[i].nxt){
node &tmp=a[i];
cur[x]=i; //更新 cur 数组
if(tmp.f&&dep[x]+1==dep[tmp.to]){
int tt=dfs(tmp.to,t,min(tmp.f,f));
tmp.f-=tt,a[i^1].f+=tt; //反向边编号为 i^1
ans+=tt,f-=tt;
if(f<=0) break;
}
}
return ans;
}
int dinic(int s,int t){
int ans=0;
while(bfs(s,t)){
memcpy(cur,head,sizeof(head)); //cur 数组还原
ans+=dfs(s,t,inf);
}
return ans;
}
void build(int x){ //建图
tot=1;
for(int i=0;i<=to;i++) head[i]=0;
for(int i=1;i<=n;i++){ //超级原点向所有工厂连边权为 1 的边
add(0,i,1),add(i,0,0);
}
for(int i=n+1;i<=n+n;i++){ //所有机场向超级汇点连边权为 1 的边
add(i,to,1),add(to,i,0);
}
for(int i=1;i<=m;i++){
if(e[i].w<=x){ //联通的工厂、机场间连边
add(e[i].u,e[i].v+n,1);
add(e[i].v+n,e[i].u,0);
}
}
}
bool check(int x){
build(x);
return dinic(0,to)==n; //最大流 =n 则可行
}
signed main(){
IOS;TIE;
cin>>n>>m;
for(int i=1;i<=m;i++) cin>>e[i].u>>e[i].v>>e[i].w;
to=n*2+1;
int l=0,r=1e9;
while(l<=r){ //二分答案
int mid=(l+r)>>1;
if(check(mid)) r=mid-1;
else l=mid+1;
}
if(l==1e9+1) cout<<-1<<endl; //无解
else cout<<l<<endl;
return 0;
}
最小割
引理:
在一个网络流中,能够从源点到达汇点的最大流量等于如果从网络中移除就能够导致网络流中断的边的集合的最小容量和。即在任何网络中,最大流的值等于最小割的容量。
一句话:最大流 \(=\) 最小割。
为什么最大流是最小割?
个人感性理解:
最大流意味着无法增广,也就是说所以路径都被某几条边的容量限制住了。这些边的流量已经达到了容量上限,而最大流就是这些边的流量之和(瓶颈)。同时,这些边一定构成了原网络的一个割。它是最小的割,因为其他边不一定达到了容量上限,若舍弃这些边去选其他边,边权和只增。
例4:ABC010D 浮気予防
原题可以转化:
设高桥为超级源点。考虑将所有女生向超级汇点连权值为 \(1\) 的边,因为直接办掉她们的代价是 \(1\)。同时,好友建两两连权值为 \(1\) 的边,因为解除好友关系的代价也是 \(1\)。因此,原题转化为,删去一些边,使高桥和超级汇点分处于两个子集的最小代价,也就是最小割。
因为数据范围小,所以仍然偷懒用链接矩阵。
AC Code
#include<bits/stdc++.h>
#define IOS ios::sync_with_stdio(false)
#define TIE cin.tie(0),cout.tie(0)
#define int long long
#define inf 1e18
using namespace std;
int n,g,e,s,t,res[205][205],dep[205];
int u,v,c;
queue<int> q;
bool bfs(int s,int t){
while(q.size()) q.pop();
q.push(s);
memset(dep,0,sizeof(dep));
dep[s]=1;
while(q.size()){
int tmp=q.front();q.pop();
for(int i=1;i<=n;i++){
if(res[tmp][i]&&!dep[i]){
dep[i]=dep[tmp]+1;
q.push(i);
}
}
}
return dep[t];
}
int dfs(int x,int t,int f){
if(x==t) return f;
int ans=0;
for(int i=1;i<=n;i++){
if(res[x][i]&&dep[x]+1==dep[i]){
int tmp=dfs(i,t,min(res[x][i],f));
res[x][i]-=tmp,res[i][x]+=tmp;
ans+=tmp,f-=tmp;
if(f<=0) break;
}
}
return ans;
}
int dinic(int s,int t){
int ans=0;
while(bfs(s,t)){
int tmp=dfs(s,t,inf);
ans+=tmp;
}
return ans;
}
signed main(){
IOS;TIE;
cin>>n>>g>>e;
for(int i=1;i<=g;i++) cin>>u,res[u][n]++;
for(int i=1;i<=e;i++){
cin>>u>>v;
res[u][v]++,res[v][u]++;
}
if(g==0) cout<<0<<endl;
else cout<<dinic(0,n)<<endl;
return 0;
}
例5:P3931 SAC E#1 - 一道难题 Tree
设树根为源点,考虑将所有叶子结点向超级汇点连边,权值为无限大。
原题可以转化:删去一些边,使树根和超级汇点分处于两个子集的最小代价,也就是最小割。
找出叶子结点的方法:度为 \(1\) 的非根节点就是叶子结点。
连边方法:因为给出的顺序不一定是 \(father\to son\),所以还得先 \(\text{DFS}\) 一次,遍历整棵树,同时每个点向儿子连边。
AC Code
#include<bits/stdc++.h>
#define IOS ios::sync_with_stdio(false)
#define TIE cin.tie(0),cout.tie(0)
#define int long long
#define inf 1e18
using namespace std;
int k,n,root,to,dep[100005],du[100005];
int head[100005],tot,cur[100005],u,v,w;
vector<pair<int,int> > b[100005];
struct node{
int to,nxt,f;
}a[1000005];
void add(int u,int v,int f){
a[++tot]={v,head[u],f};
head[u]=tot;
}
queue<int> q;
int bfs(int s,int t){
while(q.size()) q.pop();
q.push(s);
memset(dep,0,sizeof(dep));
dep[s]=1;
while(q.size()){
int tmp=q.front();q.pop();
for(int i=head[tmp];i;i=a[i].nxt){
node tt=a[i];
if(tt.f&&!dep[tt.to]){
dep[tt.to]=dep[tmp]+1;
q.push(tt.to);
}
}
}
return dep[t];
}
int dfs(int x,int t,int f){
if(x==t||!f) return f;
int ans=0;
for(int i=cur[x];i;i=a[i].nxt){
node &tmp=a[i];
cur[x]=i;
if(tmp.f&&dep[x]+1==dep[tmp.to]){
int tt=dfs(tmp.to,t,min(tmp.f,f));
tmp.f-=tt,a[i^1].f+=tt;
ans+=tt,f-=tt;
if(f<=0) break;
}
}
return ans;
}
int dinic(int s,int t){
int ans=0;
while(bfs(s,t)){
memcpy(cur,head,sizeof(head));
ans+=dfs(s,t,inf);
}
return ans;
}
void dfs0(int x,int fa){
for(int i=0;i<b[x].size();i++){
pair<int,int> tmp=b[x][i];
if(tmp.first==fa) continue;
add(x,tmp.first,tmp.second);
add(tmp.first,x,0);
dfs0(tmp.first,x);
}
}
signed main(){
IOS;TIE;
cin>>n>>root;
to=n+1;
for(int i=1;i<n;i++){
cin>>u>>v>>w;
du[u]++,du[v]++;
b[u].push_back(make_pair(v,w));
b[v].push_back(make_pair(u,w));
}
dfs0(root,0);
for(int i=1;i<=n;i++){
if(du[i]==1&&i!=root){
add(i,to,inf),add(to,i,0);
}
}
cout<<dinic(root,to)<<endl;
return 0;
}
其他例题
咕咕咕