『正睿OI 2019SC Day5』
<更新提示>
<第一次更新>
<正文>
网络流
网络流的定义
一个流网络G=(V,E)为一张满足以下条件的有向图:
- 每一条边有一个非负容量,即对于任意E中的(u,v) , 有c(u,v)\geq0。
- 如果G中存在边(u,v) ,那么不存在(v,u) 。我们将图中不存在的边的容量定为0。
- 图中含有两个特殊节点:源s与汇t。
一个流f是定义在节点二元组(u\in V,v\in V)上的实数函数,满足以下两个个性质:
- 容量限制:对于任意(u,v),满足0\leq f(u,v)\leq c(u,v)。
- 流量守恒:对于任何非源汇的中间节点u,有\sum_{v\in V}f(v,u)=\sum_{v\in V}f(u,v)
一个流f的流量|f|定义为:|f|=\sum_{v\in V}f(s,v)-\sum_{v\in V}f(v,s)。
最大流问题
定义
由于图G中不存在反向边,所以在我们一般只关注流量定义式的前半部分,即:|f|=\sum_{v\in V}f(s,v)。
那么对于一个网络G,我们称\max\{|f|\}=\max\{\sum_{v\in V}f(s,v)\}为这个网络的最大流。
预备知识
残量网络:对于网络G,其残量网络G_f与G的差别在于每条边的边容量修改为G中边容量减去当前流的该边流量。具体来说,c_f(u,v)=c(u,v)-f(u,v)。
另外,残量网络中还包含原图中所有边的反向边,容量等同于正向边在f中当前流量,用于"反悔"时将流送回起点:c_f(v,u)=f(u,v)。
简单的理解,残量网络就是原网络流了一股流以后剩下的网络,容量也对应的相减。
而反向边的存在,就给了反悔旧流的机会,也就是说,一股新的流,可以沿反向边流过,代表的涵义就是让之前流这条边的流不再流这条边,而是流向另一个方向。这样我们也就能够理解为什么反向边的容量就是原来的流量了。
增广:设f为网络G上的一个流,f'为残量网络G_f上的一个流,那么定义增广后的网络为:
引理1:增广后网络的流量等于两个流量直接相加,即:|f↑ f'|=|f|+|f'|。
这个引理为我们之后的最大流算法铺垫了基础,涵义即为我们得知了残量网络上的一次增广操作可以直接由原流量和增广流量计算得到新的网络流量。
增广路:残量网络中从s到t的一条简单路径定义为一条增广路,增广路的流量c_f(p)定义为\min\{c_f(u,v)|(u,v)\in p\}。
结论1 :增广后流量增加。令f_p为残量网络G_f上的一条增广路,则有:|f↑ f_p|=|f|+|f_p|>|f|。
有了结论1,我们就可以尝试思考如何设计求解网络最大流的算法了。我们得知,只要找到一条增广路,就能增加原网络的流量,并且可以快速计算出新的流量。所以,最初的想法就是不断地在残量网络中找增广路,不断扩大流量。
同时,我们也得知:当残量网络不存在增广路时,原网络的流量即为最大流。
Ford-Fulkerson算法
由预备知识可知,我们有一种最简单的求网络最大流的方法,那就是不断寻找残量网络的增广路,并将增广路的流量累加到答案中。直到残量网络不存在增广路,我们就得到了网络最大流。
于是我们就得到了著名的Ford-Fulkerson算法,容易写出如下的代码:
function Ford-Fulkerson(G,s,t)
maxflow = 0
for each edge (u,v) belongs to G.E
(u,v).f = 0
while there exists a path p from s to t in the residue network Gf
cf(p) = min { cf(u,v) | (u,v) belongs to p }
maxflow = maxflow + cf(p)
for each edge (u,v) belongs to p
if (u,v) belongs to E
(u,v).f = (u,v).f + cf(p)
else (v,u).f = (v,u).f - cf(p)
return maxflow
而如何找增广路呢?最简单的方法就是dfs,每一次的时间复杂度O(m)。于是Ford-Fulkerson算法的时间复杂度就是O(m|f_{max}|),f_{max}代表网络G的最大流。
Edmonds-Karp算法
直接找增广路太暴力了,于是我们想到要对FF算法进行一些优化。我们发现FF算法最大的瓶颈就是增广次数太多,那么是否存在一种增广方法,使得增广的次数得到限制呢?答案是肯定的。
我们改造FF算法,每一次寻找最短路径增广路,就能在不超过nm次增广后得到网络的最大流。
引理2:按照最短路径增广路增广,每次使所有顶点v∈V− \{s,t\}到s的最短距离d_v增大。
证明:
反证法,假设存在点v\in V-\{s,t\}使得d'_v<d_v。那么取v为第一个成立的节点,并且令u为v在最短路径上的前驱节点。由此我们可以得到:
若边(u,v)\in E,则有d_v\leq d_u+1\leq d'_u+1=d'_v,与假设矛盾。
若边(u,v)\not\in E,则边(v,u)在增广路上,有d_v=d_u-1\leq d'_u-1=d'_v-2,与假设矛盾。
结论2:按照最短路径增广路增广,每条边最多作为瓶颈边\frac{n}{2}-1次。
证明:
如果(u,v)是瓶颈边,则(u,v)在s到t的增广路上,有d_v=d_u+1。而增广后,(u,v)将会从残量网络中消失,若边(u,v)重新出现,当且仅当(v,u)在增广路上,而此时又有d'_v=d'_u+1。
由引理可知,d'_v>d_v,故有d'_u\geq d_v+1=d_u+2。所以每次重新出现会使最短路的最短距离+2,而最短距离最大为n-2,所以每条边最多作瓶颈边\frac{n}{2}-1次。
由结论2我们就能得知,这样增广的总次数不会超过nm次,如果采用bfs实现找最短路径增广路,时间复杂度为O(nm^2),我们称这种最大流算法为Edmonds-Karp算法。
Code:
inline bool EdmondsKarp(void)
{
memset( vis , 0x00 , sizeof vis );
queue < int > q; q.push( s );
vis[s] = true , Min[s] = INF;
while ( !q.empty() )
{
int x = q.front(); q.pop();
for (int i=Head[x];i;i=e[i].next)
{
if ( !e[i].val ) continue;
int y = e[i].ver;
if ( vis[y] ) continue;
Min[y] = min( Min[x] , e[i].val );
pre[y] = i;
q.push( y ) , vis[y] = true;
if ( y == t ) return true;
}
}
return false;
}
inline void update(void)
{
int x = t;
while ( x != s )
{
int i = pre[x];
e[i].val -= Min[t];
e[i^1].val += Min[t];
x = e[i^1].ver;
}
maxflow += Min[t];
}
int main(void)
{
input();
while ( EdmondsKarp() ) update();
printf("%d\n",maxflow);
return 0;
}
dinic算法
我们不妨对EK算法的增广过程进行思考,发现EK算法的本质就是每次在残量网络上构建最短路树,然后找到一条增广路进行增广。其实,这当中不难发现EK算法还有优化的余地。
在残量网络构建的最短路树当中,很可能存在多条增广路,而EK算法却每次只增广一条,就重新构建最短路树了。那么,我们能否设计一个算法,在一次构建最短路树以后实现多路增广,同时处理掉所有的增广路呢?
可行网络:在残量网络上由最短路树构成的子网络我们称为可行网络。
阻塞流:在可行网络上无法在扩充的流称为阻塞流。阻塞流不必要是残量网络的最大流。
利用上述的思路,我们每次构建残量网络的可行网络(最短路树),并用dfs实现多路增广,直接增广掉可行网络的阻塞流,就能得到一个更高效的算法,我们称之为dinic算法。
Code:
inline bool Search(void)
{
memset( d , 0x00 , sizeof d );
memcpy( cur , Head , sizeof Head );
queue < int > q; q.push( s );
d[s] = 1;
while ( !q.empty() )
{
int x = q.front(); q.pop();
for (int i=Head[x];i;i=e[i].next)
{
int y = e[i].ver;
if ( e[i].val && !d[y] )
{
d[y] = d[x] + 1;
q.push( y );
if ( y == t ) return true;
}
}
}
return false;
}
inline int dinic(int x,int flow)
{
if ( !flow || x == t ) return flow; // 剪枝1
int residue = flow;
for (int i=cur[x];i;i=e[i].next)
{
int y = e[i].ver; cur[x] = i; // 剪枝2
if ( e[i].val && d[y] == d[x] + 1 )
{
int k = dinic( y , min( residue , e[i].val ) );
if ( !k ) d[y] = 0; // 剪枝3
e[i].val -= k , e[i^1].val += k;
residue -= k;
if ( !residue ) break; // 剪枝4
}
}
return flow - residue;
}
int main(void)
{
input();
while ( Search() )
maxflow += dinic( s , INF );
printf("%d\n",maxflow);
return 0;
}
可以发现,除了之前提到的算法流程外,我们还在dinic函数中加了若干剪枝,其中最重要的剪枝为:
1. 当前弧优化(剪枝2):不增广同一条边多次,每次记录增广到的最后一条边。
2. 无效点优化(剪枝3):对于一个流入流量却没有有效流出任何流量的点,我们不再重复访问。
可以证明,当我们在dinic算法中加入了如上4个剪枝后,dinic算法的时间复杂度为O(n^2m),实际运行速度则更快。
于是,我们就得到了实现网络最大流最简单而又高效的算法。
其他增广路算法
我们发现,之前我们提到的三种最大流算法都基于一个最基础的思想:寻找增广路。其实,寻找增广路的算法还有一种:ISAP算法。
ISAP算法是SAP算法的优化,和EK算法的思路基本相同,不过只需要进行一次bfs。虽然ISAP算法的时间复杂度理论上界同样是O(n^2m),但是通常来说会有更好的表现。
这里将不再详细介绍ISAP算法,具体可以参照这篇博客。
预流推进算法
我们之前一直都在围绕增广路算法进行讨论,其实,网络最大流问题还有另一种思路的算法,叫做预流推进算法。
预留推进算法的思想很简单,首先假设源点s有无限多的余流,然后不断将余流推送给相邻的节点,对于其他的点也是同理,直到不能推送了,t点的余流即为最大流。
预流推进算法中比较优秀的一种叫做HLPP算法,时间复杂度的理论上界为O(n^2\sqrt m),这里不再详解,可以参照这篇博客。
最小割问题
定义
割:一个对点集V的划分称为割,其中点集被划分为两部分S,T,源s在S中,汇t在T中。对于一个流f而言,割(S,T)的流定义为:f(S,T)=\sum_{u\in S}\sum_{v\in T}f(u,v)-\sum_{u\in S}\sum_{v\in T}f(v,u)
割(S,T)的容量定义为:c(S,T)=\sum_{u\in S}\sum_{v\in T}c(u,v)
那么,对于一个网络G,我们称\min\{c(S,T)\}为这个网络的最小割。
预备知识
引理3:对于任意流f,任意割的网络流量不变,即f(S,T)=|f|。
由流量守恒我们得知这条引理的正确性,而有了这一条引理,我们就可以进一步地推导流与割之间的联系,找到解决最小割问题的算法。
结论3:任意流f的流量不超过任意割的容量,即|f|\leq c(S,T)
证明:
根据结论3,我们得知了割与流之间的整体关系。我们也发现,流的流量和割的容量可能存在重合点(两值相等),此时最小割等于最大流,那么我们能否证明更一般的结论呢?
最大流最小割定理
最大流最小割定理:对于一个网络G,以下三个命题总是等价的:
- 流f是G的最大流
- 当前流f的残量网络G_f上不存在增广路
- 存在某个割使得|f|=c(S,T)成立,此时割(S,T)即为网络G的最小割
证明:
Part1\ (1)⇒(2):
反证法,若当前流f的残量网络G_f上存在增广路,则由结论1可知经过这次增广流量|f|可以增大,与流f是G的最大流矛盾。
Part2\ (2)⇒(3):
构造点集S为源s在残量网络上能够到达的点的集合,T=V-S,那么汇t在T中,进而(S,T)时原网络G的一个割。
考虑割(S,T)间的任意点对(u,v),若(u,v)在原网络中存在,则必然有f(u,v)=c(u,v),否则与残量网络G_f不存在增广路矛盾,若(v,u)在原网络中存在,则必然有f(v,u)=0,否则这股流无法回流(由上可知,只存在满流边)。
那么就有:
那么由引理3可知:|f|=c(S,T),结论成立。
Part3\ (3)⇒(1):
由结论3可知:|f|\leq c(S,T),那么当等号成立时,所对应的流f必然是最大流。
于是,我们就得到了有关最小割问题最重要的定理,那么最小割问题就可以转换为最大流问题解决。
最大权闭合子图问题
最大权闭合子图:给出一张有向图,每个点都有一个点权。在有向图中选取一张权值最大的子图,使得每个节点的后继也都在子图中。
最大权闭合子图问题较难直接求解,但是我们可以将其转换为最小割问题,这是最小割问题最经典的一个运用。
我们建立超级源点s,s连所有正权点,容量为点权,建立超级汇点t,t连所有负权点,容量为点权的相反数。对于原图中的所有边(u,v),连接(u,v),容量为\infty。然后我们在图上求最小割,答案即为正权点权值和-最小割。
考虑最小割(S,T),那么最大权闭合子图的节点即为S中的节点。首先,对于一条容量为正无穷的边(u,v),它一定不会选在最小割中,这就保证了每个点和它的后继被分在同一个集合中。假设一开始所有正权点都在我们选中的最大权闭合子图中,那么:
- 对于一个正权点u,如果割掉(s,u),就代表不把这个点选入闭合子图,答案刚好减去val_u
- 对于一个负权点v,如果割掉(v,t),就代表把这个点选入闭合子图中,答案加上val_v,刚好减去-val_v
至此,最大权闭合子图问题也得到了较好的解决。
Code: (NOIP2009植物大战僵尸)
#include <bits/stdc++.h>
using namespace std;
#define fi first
#define se second
const int N = 35 , INF = 0x7f7f7f7f;
struct edge { int ver,val,next; } e[2*N*N*N*N+6*N*N];
int n,m,S,T,s[N][N],c[N][N],indeg[N*N],flag[N*N];
int tot=1,maxflow,Head[N*N],d[N*N],cur[N*N],ans;
pair < int , int > a[N][N][N*N];
vector < int > Link[N*N];
inline int id(int p,int q) { return ( p - 1 ) * m + q; }
inline int read(void)
{
int x = 0 , w = 0; char ch = ' ';
while ( !isdigit(ch) ) w |= ch=='-' , ch = getchar();
while ( isdigit(ch) ) x = x*10 + ch-48 , ch = getchar();
return w ? -x : x;
}
inline void input(void)
{
n = read() , m = read();
for (int i=1;i<=n;i++)
for (int j=1;j<=m;j++)
{
s[i][j] = read() , c[i][j] = read();
for (int k=1;k<=c[i][j];k++)
{
a[i][j][k].fi = read() , a[i][j][k].se = read();
++ a[i][j][k].fi , ++ a[i][j][k].se;
Link[id(i,j)].push_back
( id(a[i][j][k].fi,a[i][j][k].se) );
indeg[ id(a[i][j][k].fi,a[i][j][k].se) ] ++;
}
if ( j < m )
Link[id(i,j+1)].push_back( id(i,j) ) , indeg[id(i,j)] ++;
}
}
inline void insert(int x,int y,int v)
{
e[++tot] = (edge){y,v,Head[x]} , Head[x] = tot;
e[++tot] = (edge){x,0,Head[y]} , Head[y] = tot;
}
inline void Topsort(void)
{
queue < int > q;
for (int i=1;i<=n;i++)
for (int j=1;j<=m;j++)
if ( !indeg[id(i,j)] ) q.push( id(i,j) );
while ( !q.empty() )
{
int x = q.front(); q.pop();
flag[x] = true;
for ( auto y : Link[x] )
if ( ! --indeg[y] ) q.push(y);
}
}
inline void build(void)
{
S = n*m+1 , T = n*m+2;
for (int i=1;i<=n;i++)
{
for (int j=1;j<=m;j++)
{
if ( !flag[id(i,j)] ) continue;
if ( s[i][j] > 0 ) ans += s[i][j];
for (int k=1;k<=c[i][j];k++)
if ( flag[id(a[i][j][k].fi,a[i][j][k].se)] )
insert( id(a[i][j][k].fi,a[i][j][k].se) , id(i,j) , INF );
if ( j < m && flag[id(i,j+1)] ) insert( id(i,j) , id(i,j+1) , INF );
if ( s[i][j] > 0 ) insert( S , id(i,j) , s[i][j] );
if ( s[i][j] < 0 ) insert( id(i,j) , T , -s[i][j] );
}
}
}
inline bool Search(void)
{
memset( d , 0 , sizeof d );
memcpy( cur , Head , sizeof Head );
queue < int > q;
q.push( S ) , d[S] = 1;
while ( !q.empty() )
{
int x = q.front(); q.pop();
for (int i=Head[x];i;i=e[i].next)
{
int y = e[i].ver;
if ( e[i].val && !d[y] )
{
d[y] = d[x] + 1;
q.push( y );
if ( y == T ) return true;
}
}
}
return false;
}
inline int dinic(int x,int flow)
{
if ( !flow || x == T ) return flow;
int residue = flow;
for (int i=cur[x];i;i=e[i].next)
{
int y = e[i].ver; cur[x] = i;
if ( e[i].val && d[y] == d[x] + 1 )
{
int k = dinic( y , min( residue , e[i].val ) );
if ( !k ) d[y] = 0;
e[i].val -= k , e[i^1].val += k;
residue -= k;
if ( !residue ) break;
}
}
return flow - residue;
}
int main(void)
{
input();
Topsort();
build();
while ( Search() )
maxflow += dinic( S , INF );
printf("%d\n",ans-maxflow);
return 0;
}
<后记>
【推荐】还在用 ECharts 开发大屏?试试这款永久免费的开源 BI 工具!
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 软件产品开发中常见的10个问题及处理方法
· .NET 原生驾驭 AI 新基建实战系列:向量数据库的应用与畅想
· 从问题排查到源码分析:ActiveMQ消费端频繁日志刷屏的秘密
· 一次Java后端服务间歇性响应慢的问题排查记录
· dotnet 源代码生成器分析器入门
· 互联网不景气了那就玩玩嵌入式吧,用纯.NET开发并制作一个智能桌面机器人(四):结合BotSharp
· Vite CVE-2025-30208 安全漏洞
· 《HelloGitHub》第 108 期
· MQ 如何保证数据一致性?
· 一个基于 .NET 开源免费的异地组网和内网穿透工具