网络流
Part 1:前置知识
一个网络\(G=(V,E)\) 是一张有向图,图中每条有向边 \((x,y)\in E\) 都有一个给定的权值 \(c(x,y)\),称为边的容量。图中还有两个指定的特殊节点 \(S\in V\) 和 \(T\in V\:(S\ne T)\),分别称为源点和汇点
设 \(f(x,y)\) 是定义在节点二元组 \((x,y\in V)\) 上的实数函数,且满足:
-
容量限制:\(f(x,y)\leq c(x,y)\)
-
斜对称:\(f(x,y)=-f(y,x)\)
-
流量守恒:\(\forall x\ne S,x\ne T,\sum\limits_{(u,x)\in E}{f(u,x)}=\sum\limits_{(x,v)\in E}{f(x,v)}\)
则称 \(f\) 为网络的流函数。对于 \((x,y)\in E\),\(f(x,y)\) 称为边的流量,\(c_f(x,y)=c(x,y)-f(x,y)\) 称为边的剩余容量
\(\sum\limits_{(S,v)\in E}f(S,v)\) 称为整个网络的流量
Part 2:最大流
对于一个给定的网络,使得整个网络流量最大的流函数被称为网络的最大流,此时的流量被称为网络的最大流量
Ford–Fulkerson 增广(FF 增广)
我们将 \(G\) 中所有节点和剩余容量大于 \(0\) 的边构成的子图称为残量网络 \(G_f\),即 \(G_f=(V,E_f)\),其中 \(E_f=\left\{(x,y) \mid c_f(x,y)>0\right\}\)。
在残量网络中,我们将一条从源点 \(S\) 到汇点 \(T\) 的路径称为增广路。对于一条增广路,我们给每一条边 \((x, y)\) 都加上等量的流量,以令整个网络的流量增加,这一过程被称为增广。
由此,最大流的求解可以被视为若干次增广分别得到的流的叠加。值得注意的是,根据流的斜对称性,我们在增广时需要记得退流,即 \(f(x, y)\) 增加时 \(f(y, x)\) 应当减少同等的量。同时,\(c_f(x,y)-=\Delta\)
,\(c_f(y,x)+=\Delta\)
这个退流操作的意义就是可以让我们在之后“反悔“”以前的增广操作,使得我们无需担心按照错误的顺序选择了增广路
容易发现,只要 \(G_f\) 上存在增广路,那么对其增广就可以令总流量增加;否则说明总流量已经达到最大可能值,求解过程完成。这就是 \(\rm Ford-Fulkerson\) 增广的过程。
这样增广的复杂度上界是 \(O(|E||f|)\),\(\rm FF\) 增广还有其它不同的实现,下面介绍两个常用的算法
Edmonds–Karp 算法
贺一下 oi-wiki 上的算法流程:
-
如果在 \(G_f\) 上我们可以从 \(S\) 出发 \(\rm bfs\) 到 \(T\),则我们找到了新的增广路。
-
对于增广路 \(p\),我们计算出 \(p\) 经过的边的剩余容量的最小值 \(\Delta = \min\limits_{(x, y) \in p} c_f(x, y)\)。我们给 \(p\) 上的每条边都加上 \(\Delta\) 流量,并给它们的反向边都退掉 \(\Delta\) 流量,令最大流增加了 \(\Delta\)。
-
因为我们修改了流量,所以我们得到新的 \(G_f\),我们在新的 \(G_f\) 上重复上述过程,直至增广路不存在,则流量不再增加。
在具体实现时,可以使用“成对储存”的技巧去存储每一条边及其反向边
时间复杂度 \(O(|V||E|^2)\),实际效率极高,一般能够处理 \(10^3\) ~ \(10^4\) 规模的网络
#include<bits/stdc++.h>
#define LL long long
using namespace std;
const int N=210,M=5010,INF=(1<<30);
int n,m,s,t,incf[2*M],pre[N];
int head[N],ver[2*M],nxt[2*M],edge[2*M],tot=1;
LL maxflow;
bool v[N];
void add(int x,int y,int z)
{
ver[++tot]=y; edge[tot]=z; nxt[tot]=head[x]; head[x]=tot;
ver[++tot]=x; edge[tot]=0; nxt[tot]=head[y]; head[y]=tot;
}
bool bfs()
{
memset(v,0,sizeof(v));
queue <int> q;
q.push(s); v[s]=1;
incf[s]=INF;
while(q.size())
{
int x=q.front(); q.pop();
for(int i=head[x]; i; i=nxt[i])
{
if(edge[i])
{
int y=ver[i];
if(v[y])
continue;
incf[y]=min(incf[x],edge[i]);
pre[y]=i;
q.push(y); v[y]=1;
if(y==t)
return 1;
}
}
}
return 0;
}
void update()
{
int x=t;
while(x!=s)
{
int i=pre[x];
edge[i]-=incf[t];
edge[i^1]+=incf[t];
x=ver[i^1];
}
maxflow+=incf[t];
}
LL EK()
{
while(bfs())
update();
return maxflow;
}
int main()
{
scanf("%d%d%d%d",&n,&m,&s,&t);
for(int i=1; i<=m; i++)
{
int x,y,z;
scanf("%d%d%d",&x,&y,&z);
add(x,y,z);
}
printf("%lld",EK());
return 0;
}
Dinic 算法
设 \(d(x)\) 表示节点 \(x\) 到源点 \(S\) 的距离,根据其将节点分成若干层,只保留满足 \(d(y)=d(x)+1\) 的边 \((x,y)\),这样的图我们称为 \(G_f\) 的层次图。
形式化地,我们称 \(G_L = (V, E_L)\) 是 \(G_f = (V, E_f)\) 的层次图,其中 \(E_L = \left\{ (x, y) \mid (x, y) \in E_f, d(x) + 1 = d(y) \right\}\)。
如果我们在层次图 \(G_L\) 上找到一个最大的增广流 \(f_b\),使得仅在 \(G_L\) 上是不可能找出更大的增广流的,则我们称 \(f_b\) 是 \(G_L\) 的阻塞流。
定义层次图和阻塞流后,\(\rm Dinic\) 算法的流程如下。
-
在 \(G_f\) 上 \(\rm bfs\) 出层次图 \(G_L\)。
-
在 \(G_L\) 上 \(\rm dfs\) 出阻塞流 \(f_b\)。
-
将 \(f_b\) 并到原先的流 \(f\) 中,即 \(f + f_b \rightarrow f\)。
-
重复以上过程直到不存在从 \(s\) 到 \(t\) 的路径。
此时的 \(f\) 即为最大流。
当前弧优化
在 \(\rm dfs\) 的过程中,如果每次 \(x\) 都遍历所有出边的话时间复杂度是非常大的,所以我们用一个指针来维护节点 \(x\) 第一条还有必要尝试的出边,通常,我们称这个指针为当前弧
当前弧优化是必要的,否则复杂度很容易被卡到上界
时间复杂度的上界是 \(O(|V|^2|E|)\),实际效率极高,一般能处理 \(10^4\) ~ \(10^5\) 规模的网络
特别地,\(\rm Dinic\) 算法求解二分图最大匹配的时间复杂度为 \(O(\sqrt{|V|}|E|)\)
#include<bits/stdc++.h>
#define LL long long
using namespace std;
const int N=210,M=5010,INF=(1<<30);
int n,m,s,t,d[N];
int head[N],ver[2*M],nxt[2*M],edge[2*M],now[N],tot=1;
LL maxflow;
void add(int x,int y,int z)
{
ver[++tot]=y; edge[tot]=z; nxt[tot]=head[x]; head[x]=tot;
ver[++tot]=x; edge[tot]=0; nxt[tot]=head[y]; head[y]=tot;
}
bool bfs()
{
memset(d,0,sizeof(d));
queue <int> q;
q.push(s); d[s]=1;
now[s]=head[s];
while(q.size())
{
int x=q.front(); q.pop();
for(int i=head[x]; i; i=nxt[i])
{
if(edge[i] && !d[ver[i]])
{
int y=ver[i];
q.push(y);
now[y]=head[y];
d[y]=d[x]+1;
if(y==t)
return 1;
}
}
}
return 0;
}
int dinic(int x,int flow)
{
if(x==t)
return flow;
int rest=flow,k,i;
for(i=now[x]; i && rest; i=nxt[i])
{
if(edge[i] && d[ver[i]]==d[x]+1)
{
int y=ver[i];
k=dinic(y,min(rest,edge[i]));
if(!k)
d[y]=0;
edge[i]-=k;
edge[i^1]+=k;
rest-=k;
if(rest<=0)
break;
}
}
now[x]=i;
return flow-rest;
}
LL Dinic()
{
LL flow=0;
while(bfs())
while(flow=(LL)dinic(s,INF))
maxflow+=flow;
return maxflow;
}
int main()
{
scanf("%d%d%d%d",&n,&m,&s,&t);
for(int i=1; i<=m; i++)
{
int x,y,z;
scanf("%d%d%d",&x,&y,&z);
add(x,y,z);
}
printf("%lld",Dinic());
return 0;
}
常见技巧与模型
拆点:P1231 教辅的组成
为了保证每本书只被用一次,需要将同本书拆成 \(2\) 个点,并连一条容量为 \(1\) 的边
点边转化:P2472 [SCOI2007] 蜥蜴
将原图抽象成无向图,再进行点边转化,具体操作见下文
Part 3:最小割
对于一个给定的网络 \(G=(V,E)\),若一个边集 \(E'\subseteq E\) 被删去之后,源点 \(S\) 和汇点 \(T\) 不再联通,则称该边集为网络的割。边的容量之和最小的割被称为网络的最小割
最大流最小割定理
常见技巧与模型
能用最小割解决的题目往往有如下特征:
-
每个物体只有两种状态,要么选,要么不选;
-
给定所有物体被选择的状态,则问题能够得到唯一的答案。
理清逻辑关系,贡献与损失间的转化,在最小割的题目中尤为重要
点边转化: UVA1660 Cable TV Network
无向图的点转边分为三步:
-
将每个点 \(x\) 拆成 \(x\) 和 \(x'=x+n\) 两个点
-
对于 \(\forall x\ne S,x\ne T\),连有向边 \((x,x')\),容量为 \(1\)(其实也可以说是点权)
-
对于原无向图的每条边 \((x,y)\),在网络中连有向边 \((x',y),(y'x)\),容量为 \(+\infty\)(防止割断)
这样,\((x,x')\) 这条有向边就对应原无向图的节点 \(x\)
求补集:P2774 方格取数问题
由于最小割的特殊性,它往往可以用于求一些满足“答案 \(=\) 总和\(-\)舍弃和”的题目,“舍弃和”即为最小割
集合划分模型:P2057 [SHOI2007] 善意的投票 / [JLOI2010] 冠军调查
有 \(n\) 个物品和两个集合 \(S,T\)。如果将一个物品放入 \(S\) 会产生 \(a_i\) 的花费,放入 \(T\) 中会产生 \(b_i\) 的花费。还有若干形如 \((u,v,w)\) 的限制,表示当 \(u,v\) 两点不在同一个集合时,就会产生 \(w\) 的额外花费。求总花费最小值
对于每个点 \(x\),从 \(S\rightarrow x\) 连一条容量为 \(b_x\) 的边,从 \(x\rightarrow T\) 连一条容量为 \(a_x\) 的边。对于限制 \((x,y,z)\),在 \(x\) 和 \(y\) 之间连一条容量为 \(z\) 的双向边
当 \(x\) 放入 \(S\) 时,就相当于割掉 \((x,T)\) 这条边,花费为 \(a_i\)。放入 \(T\) 时就相当于割掉 \((S,x)\) 这条边,花费为 \(b_i\)。当割掉 \((x,y)\) 或 \((y,x)\) 时,就代表它们不放到同一个集合
因此,最小割即为最小花费
集合划分模型变式:P1646 [国家集训队] happiness
前两种情况建边方式同上。后四种情况,先考虑文科,对于 \((x,y,z)\) 的额外喜悦值,新建一个新节点 \(new\),从 \(S\rightarrow new\) 连一条容量为 \(z\) 的边,从 \(new\rightarrow x,y\) 连一条容量为 \(+\infty\) 的边,理科同理。
最小割的可行边与必须边:P4126 [AHOI2009] 最小割
首先求最大流,那么最小割的可行边和必须边都必须是满流
-
可行边 \((x,y)\):残量网络中不存在 \(x\) 到 \(y\) 的路径
-
必须边 \((x,y)\):残量网络中 \(S\) 能到 \(x\) 且 \(y\) 能到 \(T\)
具体实现时先跑一遍 \(\rm Dinic\),然后用 \(\rm Tarjan\) 求 \(\rm SCC\) 即可
最大权闭合子图
若有向图 \(G\) 的一个子图 \(G'\) 满足 \(G'\) 中所有的节点的出边均指向 \(G'\) 内部的节点,则称 \(G'\) 是 \(G\) 的一个闭合子图
使得子图中点权和最大的闭合子图被称为 \(G\) 的最大权闭合子图
构图方式
建立源点 \(S\) 和汇点 \(T\),\(S\) 向所有点权为正的点连一条容量为点权的边,其余点向 \(T\) 连一条容量为点权相反数的边。对于原图中的边 \((x,y)\),连边 \((x,y,+\infty)\)
定理
-
最大权闭合子图的点权和等于所有正点权和减去最小割
-
上述图的最小割上述图的最小割包含 \(S\) 到不在最大权闭合图内的正权节点的边和在最大权闭合图内的负权节点到 \(T\) 的边。
最大权闭合子图方案
在残量网络中 \(S\) 能访问到的节点,就构成一个点数最少的最大权闭合子图
[模板] Petya and Graph / P4174 [NOI2006] 最大获利
把所有点当作负权点,所有边当作正权点。
考虑一条边 \(e=(x,y,z)\) 能选择的条件,当 \(x,y\) 均被选择时就可以选,那对应到最大权闭合子图的模型中,就可以将 \((e,x),(e,y)\) 当作原图中的边。
建模完毕后用上述方法计算最大权闭合子图点权和即可
最小割树
对于一张带容量的无向图 \(G\),先任选两点 \(x,y\) 作为源汇点求出最小割 \(cut(x,y)\),将原图分成 \(V_x,V_y\) 两个不相交的集合
维护一个树形结构 \(T\),在 \(x,y\) 之间连边权为 \(cut(x,y)\) 的边。并递归求解 \(V_x,V_y\),直到集合内只剩一个点为止
容易发现,我们会得到一课树,这棵树被称为最小割树
一些引理
-
对于 \(u\in V_x,v\in V_y\),总有 \(cut(x,y)\leq cut(u,v)\)
-
任取三点 \(a,b,c\),\(cut(a,b),cut(b,c),cut(a,c)\) 之中,最小值至少出现两次
-
\(cut(a,c)\geq \min\{cut(a,b),cut(b,c)\}\)
由这些引理我们可以推出下面的定理
最小割树定理
【模板】最小割树(Gomory-Hu Tree)
#include<bits/stdc++.h>
#define mp make_pair
using namespace std;
const int N=510,M=4010,INF=1e9;
int n,m,q,node[N],t1[N],t2[N],dep[N],f[N][15],ans[N][15];
int s,t,maxflow,d[N];
int head[N],ver[2*M],nxt[2*M],edge[2*M],now[N],tot=1;
vector < pair<int,int> > g[N];
void add(int x,int y,int z)
{
ver[++tot]=y; edge[tot]=z; nxt[tot]=head[x]; head[x]=tot;
ver[++tot]=x; edge[tot]=0; nxt[tot]=head[y]; head[y]=tot;
}
void init()
{
maxflow=0;
for(int i=2; i<=tot; i+=2)
edge[i]+=edge[i^1],edge[i^1]=0;
}
bool bfs()
{
memset(d,0,sizeof(d));
queue <int> q;
d[s]=1; now[s]=head[s];
q.push(s);
while(q.size())
{
int x=q.front(); q.pop();
for(int i=head[x]; i; i=nxt[i])
{
if(edge[i] && !d[ver[i]])
{
int y=ver[i];
d[y]=d[x]+1;
now[y]=head[y];
q.push(y);
if(y==t)
return 1;
}
}
}
return 0;
}
int dinic(int x,int flow)
{
if(x==t)
return flow;
int rest=flow,k;
for(int &i=now[x]; i && rest; i=nxt[i])
{
if(edge[i] && d[ver[i]]==d[x]+1)
{
int y=ver[i];
k=dinic(y,min(rest,edge[i]));
if(!k)
d[y]=0;
edge[i]-=k;
edge[i^1]+=k;
rest-=k;
if(rest<=0)
break;
}
}
return flow-rest;
}
int Dinic()
{
init();
int flow=0;
while(bfs())
while(flow=dinic(s,INF))
maxflow+=flow;
return maxflow;
}
void build(int l,int r)
{
if(l>=r)
return;
s=node[l]; t=node[l+1];
int cut=Dinic();
g[s].push_back(mp(t,cut));
int cnt1=0,cnt2=0;
for(int i=l; i<=r; i++)
{
if(d[node[i]])
t1[++cnt1]=node[i];
else
t2[++cnt2]=node[i];
}
for(int i=l; i<=l+cnt1-1; i++)
node[i]=t1[i-l+1];
for(int i=l+cnt1; i<=r; i++)
node[i]=t2[i-l-cnt1+1];
build(l,l+cnt1-1); build(l+cnt1,r);
}
void dfs(int x,int fa)
{
dep[x]=dep[fa]+1;
f[x][0]=fa;
for(int i=1; i<=10; i++)
{
f[x][i]=f[f[x][i-1]][i-1];
ans[x][i]=min(ans[x][i-1],ans[f[x][i-1]][i-1]);
}
for(int i=0; i<g[x].size(); i++)
{
int y=g[x][i].first,z=g[x][i].second;
if(y==fa)
continue;
ans[y][0]=z;
dfs(y,x);
}
}
int query(int x,int y)
{
if(dep[x]<dep[y])
swap(x,y);
int res=INF;
for(int i=10; i>=0; i--)
if(dep[f[x][i]]>=dep[y])
res=min(res,ans[x][i]),x=f[x][i];
if(x==y)
return res;
for(int i=10; i>=0; i--)
if(f[x][i]!=f[y][i])
res=min(res,min(ans[x][i],ans[y][i])),x=f[x][i],y=f[y][i];
res=min(res,min(ans[x][0],ans[y][0]));
return res;
}
int main()
{
scanf("%d%d",&n,&m);
for(int i=1; i<=m; i++)
{
int x,y,z;
scanf("%d%d%d",&x,&y,&z);
add(x,y,z); add(y,x,z);
}
for(int i=1; i<=n; i++)
node[i]=i;
build(1,n);
dfs(1,0);
scanf("%d",&q);
for(int i=1; i<=q; i++)
{
int x,y;
scanf("%d%d",&x,&y);
printf("%d\n",query(x,y));
}
return 0;
}
Part 4:费用流
给定一个网络 \(G=(V,E)\),每条边除了有容量限制 \(c(x,y)\),还有一个给定的“单位费用” \(w(x,y)\)。当边 \((x,y)\) 的流量为 \(f(x,y)\) 时,就要花费 \(f(x,y)\times w(x,y)\)。该网络中花费最小的最大流被称为“最小费用最大流”,总花费最大的最大流被称为“最大费用最大流”,二者合称“费用流”模型。
注意:费用流的前提是最大流,然后才考虑费用的最值。
\(w\) 也满足斜对称性,即 \(w(x,y)=-w(y,x)\)
SSP 算法
\(\rm SSP\) 算法是一个贪心的算法。它的思路是每次寻找单位费用最小的增广路进行增广,直到图上不存在增广路为止。
\(\rm SSP\) 的实现方式有两种。一种基于 \(\rm EK\) 算法,一种基于 \(\rm Dinic\) 算法,在 OI 中一般采用基于 \(\rm EK\) 的 \(\rm SSP\) 来计算费用流。
在 \(\rm EK\) 求解最大流的基础上,把“用 \(\rm BFS\) 寻找任意一条增广路”改为“用 \(\rm SPFA\) 寻找一条单位费用之和最小的增广路”(也就是把 \(w(x,y)\) 当作边权,在残量网络上求最短路),即可求出最小费用最大流。
#include<bits/stdc++.h>
using namespace std;
const int N=5010,M=50010,INF=0x3f3f3f3f;
int n,m,s,t,maxflow,ans;
int d[N],incf[N],pre[N],v[N];
int head[N],ver[2*M],nxt[2*M],edge[2*M],cost[2*M],tot=1;
void add(int x,int y,int z,int c)
{
ver[++tot]=y; edge[tot]=z; cost[tot]=c;
nxt[tot]=head[x]; head[x]=tot;
ver[++tot]=x; edge[tot]=0; cost[tot]=-c;
nxt[tot]=head[y]; head[y]=tot;
}
bool spfa()
{
memset(d,0x3f,sizeof(d));
queue <int> q;
q.push(s); d[s]=0; v[s]=1;
incf[s]=INF;
while(q.size())
{
int x=q.front(); q.pop();
v[x]=0;
for(int i=head[x]; i; i=nxt[i])
{
if(!edge[i])
continue;
int y=ver[i];
if(d[y]>d[x]+cost[i])
{
d[y]=d[x]+cost[i];
incf[y]=min(incf[x],edge[i]);
pre[y]=i;
if(!v[y])
v[y]=1,q.push(y);
}
}
}
if(d[t]==INF)
return 0;
return 1;
}
void update()
{
int x=t;
while(x!=s)
{
int i=pre[x];
edge[i]-=incf[t];
edge[i^1]+=incf[t];
x=ver[i^1];
}
maxflow+=incf[t];
ans+=d[t]*incf[t];
}
void SSP()
{
while(spfa())
update();
}
int main()
{
scanf("%d%d%d%d",&n,&m,&s,&t);
for(int i=1; i<=m; i++)
{
int x,y,z,c;
scanf("%d%d%d%d",&x,&y,&z,&c);
add(x,y,z,c);
}
SSP();
printf("%d %d",maxflow,ans);
return 0;
}
常见技巧与模型
费用提前计算:P2053 [SCOI2007] 修车
由于每个人的等待时间受到前面人的影响,导致无法直接建模。但是我们发现,每个人对后面人的贡献是可以计算的,设第 \(i\) 辆车在第 \(j\) 个工人是倒数第 \(k\) 个修的,那么它对后面的贡献就是 \(k\times T_{i,j}\)
所以我们可以将每一个工人拆成 \(n\) 个点 \(x_1,x_2\dots x_n\),分别表示它倒数第 \(i\) 个修的谁,每个 \(x_i\rightarrow T\) 连一条容量为 \(1\),费用为 \(0\) 的边。对于每辆车 \(i\),从 \(S\rightarrow i\) 连一条容量为 \(1\),费用为 \(0\) 的边,并枚举 \(k=1,2\dots n\),从 \(i\rightarrow x_k\) 连一条容量为 \(1\),费用为 \(k\times T_{i,x}\) 的边。
最后跑 \(\rm SSP\) 即可0
动态开点:P2050 [NOI2012] 美食节
上一题的加强版
像上一题那样建图规模太大,无法通过此题。稍加思考可发现这样建图会有许多浪费的点,所以考虑动态开点,每增广一次再新开一个节点,这样可大大减少图的规模
费用流自行求解最短路/最小环覆盖:P6061 [加油武汉] 疫情调查
最小环覆盖,用二分图带权最大匹配解决
将每个点拆成 \(x\) 和 \(x'\) 两个点,\(x\rightarrow x'\) 连一条 \((1,a_x)\) 的边,\(S\rightarrow x,x'\rightarrow T\) 连一条 \((1,0)\) 的边。对于点对 \((x,y)\),可以 \(\rm floyd\) 预先处理两点间最短路,再 \(x\rightarrow y'\) 连一条 \((+\infty,dis_{x,y})\) 的边。
但是这样做边数达到 \(O(n^2)\) 级别,会超时,于是我们考虑只对题目给出的边连边,并对每个点 \(x'\rightarrow x\) 连一条 \((+\infty,0)\) 的边,借助费用流自行去找最短路

浙公网安备 33010602011771号