前言
初三的时候就知道以后注定会重新写网络流的博客了。
但是呢,之前的博客是不会删的。水数量
因为之前碰了很多杂七杂八的东西。
万一删了不就前功尽弃了,如果有少数几个读得懂我所写的文章的,可以结合两篇一起看,遇到重复的地方以这篇为参考,加上自己的理解。
需要注意的是,这篇文章可能对于信息学新手不会太友好,如果你只是个新手,建议去看看我之前的那篇,那篇提供了一个例子的讲解,会比较好,而这篇文章注重的是理论,比较干。说实话,就是懒得写例子
当然,这篇文章写的比较仓促,因为还要备战NOIP。
关于最大流
最大流是啥?
想象一坨有向的水管,每个水管连接着两个点,且图中有个入水口,有个出水口,出水口可以无限出水,入水口可以瞬间收水。
但是呢,在一个单位时间,一个水管只能通过(c_{i})单位体积的水,同时水瞬间通过这个水管,并在同一个单位时间通过其余的水管,且水管不能存水,如果水不能通过这个水管一瞬间流到终点,那么水不会流过来。有生命的水
那么,一个单位时间内入水口最多入多少体积的水?这就是最大流问题。
其中(a/b)分别表示流过的水和最多流过多少的水,(A)为出水口,(E)为入水口。
可以看到,上面两个就是同一个图的最大流,有两种,其中,为什么第一个图中,(AC)流过的流量是(0)呢?因为如果从(A)点流出了一体积的水到了(C)又到了(D),无法到达终点(E),所以这一体积的水不会流过(C),而是选择留在原地。
当然,上述的表示非常的SB,因为我确实不知道如何比较规范的用中文表述。
我们用((i,j))表示一条边,(c(i,j))表示这条边最多流过的水的体积,(f(i,j))表示这条边流过的水的体积。
那么有以下规定。
- ((i,j)∈E,0≤f(i,j)≤c(i,j),(i,j)∉E,c(i,j)=0)
- (sumlimits_{(x,i)∈E}f(x,i)=sumlimits_{(j,x)∈E}f(j,x)(x≠st,ed))
- 在(E)中,(st)不存在入边,(ed)不存在出边。(存在就删了)
- (sumlimits_{(st,i)∈E}f(st,i)=sumlimits_{(j,ed)∈E}f(j,ed))
然后要求最大化(sumlimits_{(st,i)∈E}f(st,i))。
这个时候就有人很敏锐的意识到,那是不是图外面有个自环,这个自环也满足要求?是的,没错,但是我们要求最大化(sumlimits_{(st,i)∈E}f(st,i)),这个环我们管不管无所谓。
神奇的术语
请注意:由于我的学习经常都是不学术的,所以这些术语的表达甚至意思可能与真实的术语有一定的偏差,见谅。但是应该不影响看这篇文章
弧:说白了就是有向边。
反向弧:如果((x,y)∈E),那么((y,x))就是((x,y))的反向弧。
我看算法导论的时候发现正规的网络流定义(E)中的反向弧必须不属于(E),但是实际上如果属于(E)也不会影响算法,但是呢,为了后面的表述方便,我们也是默认((y,x))不属于(E),但是如果题目要求呢?实际上我们有类似的转换:
可以在不影响结果的情况下保证反向弧不在(E)中。
反向弧的(f,c):((i,j)∈E,f(j,i)=-f(i,j),c(j,i)=0),(有没有人规定非(E)元素的(f)一定非负)。
网络:就是点边形成的有向图,其中有意义的边只有(E)和其反向弧。
流量网络:网络每条边标出其(f)。
容量网络:网络每条边标出其(c)。
残余网络:流量网络(-)残余网络。(这里"(-)"的意思就是边上标号相减)
增广路径:从(st)到(ed)的一条路径,且路径上的每条边(f<c),而这条路径(p)的流量(f(p))就是路径中(f-c)的最小值。
举个例子:
当然,这里默认网络中没有意义的边(比如残余网络中标号为(0)的边,容量网络中标号为(0)的边)直接消失即可,画图方便一点。(updata:当然,在后面证明的过程中,我们会发现,这些容量流量都为(0)的边没有意义,不予讨论,而且,在某些证明中,是只针对(f>0)的(E)的边,在你发现证明看不懂的时候,或者存在很大的逻辑问题的时候,可以考虑看看是不是考虑了没有意义的边)
EK算法
这个算法是根据一个依据:网络中不存在增广路时就是最大流来搞的。
精髓就是每次只增广最短的路径(默认边的长度都是(1)),因此只要不断的跑分层图,然后不断增广即可。
需要注意的是,一条边的(f)改变时,其反向弧也要改变。
但是我没有代码QMQ,因为直接用Dinic的。
Dinic
我们发现,一次建图就跑一条增广路,真的是浪费!!!!
因此我们多跑几次增广路。
例题:https://www.luogu.com.cn/problem/P3376
代码如下:
#include<cstdio>
#include<cstring>
using namespace std;
typedef long long LL;
struct node
{
LL x,y,c/*还能流多少的流量*/,next,other/*反向弧的编号*/;
}a[250000];LL len,last[1300],st,ed,n,m;
void ins(LL x,LL y,LL c)
{
LL k1,k2;
len++;k1=len;
a[len].x=x;a[len].y=y;a[len].c=c;
a[len].next=last[x];last[x]=len;
len++;k2=len;
a[len].x=y;a[len].y=x;a[len].c=0;
a[len].next=last[y];last[y]=len;
a[k1].other=k2;
a[k2].other=k1;
}
LL list[1300],head,tail,h[1300];
bool bt_h()
{
memset(h,0,sizeof(h));h[st]=1;
list[1]=st;head=1;tail=2;
while(head!=tail)
{
LL x=list[head];
for(LL k=last[x];k>0;k=a[k].next)
{
LL y=a[k].y;
if(a[k].c>0 && h[y]==0)
{
h[y]=h[x]+1;
list[tail++]=y;
}
}
head++;
}
if(h[ed])return true;
else return false;
}
LL mymin(LL x,LL y){return x<y?x:y;}
LL findflow(LL x,LL f)
{
if(x==ed)return f;
LL s=0,t;
for(LL k=last[x];k>0;k=a[k].next)
{
LL y=a[k].y;
if(a[k].c>0 && h[y]==h[x]+1 && s<f/*没有跑满*/)
{
s+=(t=findflow(y,mymin(a[k].c,f-s)));
a[k].c-=t;a[a[k].other].c+=t;
}
}
if(s<f)h[x]=0;//如果这个点跑不满,以后都不到这个点了
return s;
}
int main()
{
scanf("%lld%lld%lld%lld",&n,&m,&st,&ed);
for(LL i=1;i<=m;i++)
{
LL x,y,c;
scanf("%lld%lld%lld",&x,&y,&c);
ins(x,y,c);
}
LL s=0;
while(bt_h()==true)
{
s+=findflow(st,LL(999999999999));
}
printf("%lld
",s);
return 0;
}
时间复杂度
EK
事实上,对于(h[x]),不断的增广,(h[x])只会非严格单调递增,设增广后为(h'),增广前在残余网络中有意义的边构成的集合为(E)。
什么?如何证明?
我们设一次增广后(h[x])减少了,且(x)是所有减少的点中(h')最小的点。
设(st)到(x)的路径为(st⇝y→x)。
分类讨论(y->x)。
如果((y,x)∈E),(h[x]≤h[y]+1≤h[y]'+1≤h[y])。
如果((y,x)∉E),说明((x,y))是在增广路径中的,所以(h[x]=h[y]-1≤h[y]'-1,h[x]'=h[y]'+1≥h[x]+2)。
所以(h)单调递增。
现在证明,一条增广路径(p),如果一条边在路径上且(c-f)等于(f(p)),那么这条边被称为关键边,本次增广完之后便会在残余网络中没有意义。
那么其重新有意义需要图中带来怎样的变化呢?其重新有意义, 必须其反向弧存在于增广路中。
即对于弧((x,y)),反向弧存在于增广路中:(h[x]=h[y]-1,h[x]'=h[y]'+1≥h[y]+1≥h[x]+2)。
即要求(x)的深度加(2),那么对于一条边,其最多变成关键边(frac{n-1}{2})次,所以时间复杂度就是:(O(nm^2))的。
但是需要注意的是,(x)的深度(+2)并不代表只让一条边有意义,比较容易陷入的误区是:深度之和最多是(n^2)的,那么一个点深度(+2)不是只会让一条边有意义吗?那不就是最多(frac{n^2}{2})次增广。但是一个点深度(+2)不一定只让一条边有意义啊!!!!例子以后补。
当然,这也有个推论,只要我每次找到的增广路都保证是图中长度最小的,那么增广路长度一定非严格递增。
Dinic
(Dinic)时间复杂度为什么是正确的?依据(EK)算法的推论,我们可以在一次建分层图的时候直接把本次分层图中所有增广路一下子找出来,这样不就免了多次减分层图的时间了吗?
因此,时间复杂度还是(O(nm^2))的。(但事实上这是不是理论上界呢?不是说(n^2m)吗?这个会在弧优化的时候具体分析)
细节与一些神奇的性质
反向弧的作用以及代码边中的c
下文默认是找增广路,不管是用什么算法,反正就是找增广路。
有人可能会问,为什么反向弧这条边不存在于原图当中,为什么能够在增广路径的时候走过它?
先思考反向弧在残余网络中有意义的原因?
是因为原弧曾经存在于增广路径中。
其实反向弧的存在,就是提供了一次后悔操作。
在下图中:
(红边表示正在找增广的边)
我们发现,(B)堵住了,其原因是因为((A,C))没有走((C,D)),别跟我说什么长度最小,随便改一下照样卡,那么我们就设置反向边,叫做后悔,至于其意义,后面具体讲,至少我们发现设置反向边后,((A,B))就可以走((B,C))直接到(D)了。
其意义现在讲,对于路径(p1):(st⇝x→y⇝ed),对于路径(p2):(st⇝y→x⇝ed),那么其实质上就是走了两条路径:(st⇝x⇝ed,st⇝y⇝ed)。
即:
这就是反向边的真正含义,在对应到(f)上面,对于((i,j)∈E),如果本次流过((j,i))流了(k)(很明显(k≤f(i,j)),因为(f(i,j)≥0)),那么其意义上就是((i,j))之前有(k)的流量取消了(上图中(x->y)),所以在(f(j,i)+=k,f(i,j)-=k)。
现在聊聊代码实现中的边的(c)代表什么。
对应在代码中的实现,边的(c)表示残余流量(下文用(c')表示,其实就是残余网络中边的标号),即(c(i,j)-f(i,j)),就是(c'(i,j))(不难发现在代码中(c')严格(≥0),满足上面的对于(c,f)的约束),对应一下就是(c'(j,i)-=k,c'(i,j)+=k)。
而对于((i,j))流过的流量也是同样如此,同样是(c'(i,j)-=k,c'(j,i)+=k)。
因此,对于代码中的(c')的处理,是完美的符合其应该代表的含义的。
合法的f对应流
如果(f)合法,是不是绝对对应着一种流呢?
发现图中只有(st)的入边(f=0),出边(f≥0),出边反之,定义一种网络中只包含(f>0)且属于(E)的边,这个网络中边的容量为(f),每次拿(1)流量去从(st)跑到(ed),最终一定会找到(sumlimits_{(st,i)∈E}f(st,i))条路径(到达一个点的流量和这个点流出的流量相等)。
当然,图中可能还会剩一些环。
需要注意的是,即使你用的是Dinic,也一样可能存在环,举一个例子:
st有入边,ed有出边
不难发现,(ed)的出边(100)%不会被经过,(st)的入边也不可能被经过(除非增广路走环),因此不用去提前处理使得(st)没有入边,(ed)没有出边。(除非你用的是其他算法)
双向边的两种处理方法
上文也讲了,其实如果原图中就存在双向边有两种处理方法:
- 新建一个点,把一条边变成链,即上文做法。
- 如果你足够理解反向弧的话,你就会明白,其实反向边可以直接放在一起,如果对于(c(i,j)=3,c(j,i)=5),那么你就按其说的在图中如此设定,这样是完全没有问题的。
针对(c(i,j)=3,c(j,i)=5),我们说明一下,帮助理解。
首先,对于这两边只会走一条,两条都走可以交换执行反向边操作,因此,(f(i,j)≤0)或者(f(j,i)≤0)是成立的,这样子的话,如果(f(i,j)>0)表示原图中只走((i,j)),反之亦然。 - 非常SB的方法,当两条边处理,各自建立反向弧,证明方法同2,100%不推荐,除非你有很大的怨念。
s<f优化
为什么代码中(s<f)就(h[x]=0)呢?(相当于认为这个点不能走)
难道其不能再做贡献了吗?
事实上是非常肯定的,为了更加直观的理解,我们定义合法网络:
如果对于一条边((x,y)),(h[x]=h[y]-1),那么这条边就在合法网络中。
单纯为了直观理解,其实也没必要
可以发现,边和反向边一定不会同时在合法网络,所以,只有在合法网络中的弧流量才会增加,且绝对不会减少。
因此,(x)到(ed)的路径的集合为(P),这些路径上的边流量只会增加,不会减少,所以这些路径不可能在本次分层图(DFS)中再度成为增广路,所以(x)以后都不用再找了。
当然,如果你不加这个优化,可以被分分钟卡掉,如下图:
反向边本次无用性
其实从上面大家都看出来了,由于合法网络中弧和反向弧不可能同时出现,所以在这次(DFS)中,反向弧的流量在减少,但是并不会对(DFS)产生贡献,所以本次(DFS)中,你可以先不给反向弧添加流量,放外面添加。
好像并没个卵用
Dinic深度严格单调递增性
定理:Dinic中(h[ed])严格单调递增。
反证法:
本次我们建完了分层图,跑完了流量,这个时候,下一次分层图的(h[ed])是一样的!!!这意味着原本存在一条长度为(h[ed])的增广路径但是我们没跑到!!!!
什么辣鸡东西???
- 假设最终增广了(k)条增广路,因此长度之和为:(k*h[ed])。
其走了本次增广的反向边,因此不能在原有的分层图上增广,假设(p1)走了(p2)的反向边,这个时候,你就会惊奇的发现,用反向边的真实含义,把(p1,p2)调换一下,得到了(p1',p2'),(p1',p2')的长度之和为(p1,p2)的长度之和(-2),然后不断执行反向边的真实含义,最终导致(k)条增广路径的长度和小于(k*h[ed]),那么一定存在一条路径长度小于(h[ed]),违反了(EK)的那个啥推论。 - 都没有走反向边,对于路径(p),其一定在合法网络中。
反证法:
设(d[x])为路径(p)上(x)到(st)的距离,且(x)在路径(p)上,如果(h[x]<d[x]),则一定存在一条增广路径小于(h[ed]),如果(h[x]>d[x]),怎么可能?所以一定在合法网络上,所以应该本次就增广了。
从起点跑和从终点跑
好了,开始讲一个完全没有多大用处的优化:从终点开始建分层图会快一点。
从根本上讲,这种优化是针对于DFS找不到增广路径的搜索而言的,实际效果表现不佳很大一部分因为大数据下表现不佳以及BFS本身对于不同的搜索顺序也会有一定的效率影响,在这里提出只是单纯的因为这个优化对于DFS确实是正优化。(同时帮助理解网上说的从(ed)建图更加快的理论)
从(st)跑有个非常SB的事情,就是但凡从一个点延伸出来一条链,都很容易跑到这条链里面去。
但是就有人发现了,从终点开始跑可以避免此情况,这不是吹的,这是有科学的依据的。
首先,增广路一定是(ed)到(st)的一条路径,从(st)到(ed)的深度单调递减且固定减一。
因此,要么这条边一定在(st)到(ed)的一条最短路径上,就会被(DFS)(从起点开始跑同样会走这条边),否则其的深度绝对不会是单调递增的,因此(st)不会去访问他(但是从起点开始跑是可能会去访问的),所以你会发现,从终点开始跑可以在(DFS)中减掉一些没有必要的状态。
反向边处理方法
对于反向边,代码中采用的是(.other),但是有个更加简单粗暴的方法,一开始设定(len=1),这样建边就是(2,3),(4,5)这样的编号,而这些编号亦或(1)就可以互相转换了。
当前弧优化
可以发现,一次(DFS),在一个点(x)在跑((x,y))的边的时候,会出现两种情况,(a[k].c<(f-s)),这个时候,(x)会给(y)等于(a[k].c)的流量,如果到达终点的流量不足(a[k].c),那么(y)无法访问,这条边作废,如果到达了(a[k].c)的流量,这条边满流,作废。
((f-s)≤a[k].c)时,会给这条边(f-s)的流量,如果跑满了,说明这条边尚有余温存在,下次还可以给,如果没有,则(y)无法到达,照样作废。
观察上文,其实就是如果跑完这条边之后,(s<f),这条边就作废,所以可以设置(cur)数组,直接跳过废掉的边,并进行搜索(至于初始化可以在(BFS)的时候初始化,或者直接用memcpy把(last)赋给(cur))。
当然,你可能会问,(f-s=a[k].c)时,跑满了不照样爆废?反正下次访问也可以(O(1))重置。
LL find(int x,LL f)
{
if(x==ed)return f;
LL s=0,t;
for(int k=cur[x];k;k=a[k].next)
{
int y=a[k].y;
if(h[y]==h[x]-1 && a[k].c>0)
{
s+=(t=find(y,mymin(a[k].c,f-s)));
a[k].c-=t;a[k^1/*.other*/].c+=t;
if(s==f)return f;//满足就退出,这步也很重要
}
cur[x]=k;//这条边没有全部跑满,直接溜走
}
if(s<f)h[x]=0;
return s;
}
完整代码:
#include<cstdio>
#include<cstring>
#define N 310
#define M 11000
using namespace std;
typedef long long LL;
struct node
{
int y,next;
LL c;
}a[M];int last[N],n,m,len=1/*用异或代替.other*/,st,ed;
int cur[N];//当前弧
inline void ins(int x,int y,LL c)
{
len++;a[len].y=y;a[len].c=c;a[len].next=last[x];last[x]=len;
len++;a[len].y=x;a[len].c=0;a[len].next=last[y];last[y]=len;
}
int h[N],list[N],head=1,tail=n;
inline bool bt_()
{
memset(h,0,sizeof(h));h[ed]=1;
head=1;tail=2;list[1]=ed;
while(head!=tail)
{
int x=list[head++];cur[x]=last[x];/*只对能走到的点记录当前弧*/
for(int k=last[x];k;k=a[k].next)
{
int y=a[k].y;
if(a[k^1].c>0 && h[y]==0)
{
list[tail++]=y;
h[y]=h[x]+1;
}
}
}
return h[st];
}
template<class T>
inline T mymin(T x,T y){return x<y?x:y;}
LL find(int x,LL f)
{
if(x==ed)return f;
LL s=0,t;
for(int k=cur[x];k;k=a[k].next)
{
int y=a[k].y;
if(h[y]==h[x]-1 && a[k].c>0)
{
s+=(t=find(y,mymin(a[k].c,f-s)));
a[k].c-=t;a[k^1/*.other*/].c+=t;
if(s==f)return f;//满足就退出,这步也很重要
}
cur[x]=k;//这条边没有全部跑满,直接溜走
}
if(s<f)h[x]=0;
return s;
}
int main()
{
LL ans=0;
scanf("%d%d%d%d",&n,&m,&st,&ed);
for(int i=1;i<=m;i++)
{
int x,y;
LL c;
scanf("%d%d%lld",&x,&y,&c);
ins(x,y,c);
}
while(bt_()==true)ans+=find(st,LL(9999999999999));
printf("%lld
",ans);
return 0;
}
当然,也有人的当前弧是这样写的:
template<class T>
inline T mymin(T x,T y){return x<y?x:y;}
LL find(int x,LL f)
{
if(x==ed)return f;
LL s=0,t;
for(int &k=cur[x];k;k=a[k].next)
{
int y=a[k].y;
if(h[y]==h[x]-1 && a[k].c>0)
{
s+=(t=find(y,mymin(a[k].c,f-s)));
a[k].c-=t;a[k^1/*.other*/].c+=t;
if(s==f)return f;//满足就退出,这步也很重要
}
}
if(s<f)h[x]=0;
return s;
}
我不是很喜欢这样写,因为这可能会破坏(Dinic)中(h[ed])单调递增的性质,导致分层图做的比较多(事实上确实会)。
好了,重新分析一波(Dinic)的时间复杂度吧。
(EK,Dinic)慢在了找增广的时间。
原本没有当前弧优化的时候,每个点(x)如果一个流量都没有,那么其不可以到达,所以讨论有流量的情况,有流量最坏情况下可能需要把(x)的边全部遍历一遍,这意味一条路径可能还需要(O(m))去找,只不过常数较小(这也是为什么(Dinic)实际表现非常优秀的原因),但是呢,加了当前弧优化(我的写法),点(x)每条边要么有流量,要么被废除,算上废除边的时间复杂度:(O(m)),没经过一条边就会有一条增广路,所以一条增广路的花费是(O(n))的,所以是(O(n^2m))。当然,至于大众写法,我不会分析QMQ。
放上一张评测图吧:
事实上,如果你能想到有什么方法可以使得一条边经过完之后绝对废除,且不会影响(h[ed])单调递增的性质,你就自创了(O(nm))的算法,当然,这很难。现在虽然已经有nm算法,但是太难懂了
参考资料
论如何卡掉Dinic(我没看懂)
咕咕讨论,Zadeh Construction是个什么东西
算法导论爷Orz
坑
学习HLPP。(感觉这辈子都看不懂时间复杂度得证明)
卡掉Dinic。误
证明二分图中Dinic的时间复杂度。
EK时间复杂度证明中提到的例子。