12/3日记 网络流之最大流

· · 算法·理论

网络流是一种“流问题”,就是与电流、水流等各种“流”为原型的题。最大流只是其中的一部分,求的是从起点出发,能输送到终点的水流最大是多少。最大流问题有三种算法,分别是Ford-Fulkerson、Edmond-Karp和Dinic。下面以P3376这个模版题为例子,逐个讲解。

1.Ford-Fulkerson算法

这个算法的思路是:在残存网络中不断寻找增广路径,每找到一条增广路径,就递增最大流f,并更新残存网络,直到残存网络中不存在增广路径,则此时f即为最终的最大流。

增广路径

概念:一条能从起点s到达终点t的流量大于0的路径

性质:通过增广路径,流量一定会增加

残存网络

概念:用边的剩余容量来表示每条边的网络图

性质:一定是经过寻找增广路径后,将一些边的容量减少了的网络图,才是残存网络

算法具体步骤:

  1. 寻找增广路径
  2. 如果找到了,更新最大值,并把正向边都减去这条路径上最小的容量;没找到就退出循环
  3. 添加增广路径的反向边,容量都是路径上最小的容量
  4. 形成了新的残存网络
  5. 回到步骤1

Q&A

$A$:因为如果每次都找到增广路径就减,直到没有,无法保证计算出的最大流是最优的。加上反向边就相当于加上了一个“反悔机制”,走反向边就意味着撤回上次流经正向边的若干流量,可以保证结果最大化。 **这个算法是采用$dfs$的思想实现**,下面分析一下代码所用到的函数: (1)$add **这个函数就是链式前向星存图法,分别记录这条边的第二节点、容量、第一节点上一次的编号,以及把它统计进第一节点的出边编号里。** ``` void add(int u,int v,long long w) { to[cnt] = v; a[cnt] = w; nt[cnt] = h[u]; h[u] = cnt++; } ``` (2)$dfs **这个$dfs$函数其实就是一个递归函数,在不断寻找增广路径,找到了就标记、更新;没找到或到了终点就返回。读入两个参数源点和流入的水流量,返回这次递归的水流量。** ``` long long dfs(int u,long long w) { if(u == t)//如果到了终点t,直接返回w,退出 { return w; } b[u] = 1;//有水流流过了 for(int i = h[u];i != -1;i = nt[i])//链式前向星遍历法 { if(a[i] > 0 && b[to[i]] == 0)//如果还有剩余流量,且这个点之前没有水流 { long long c = dfs(to[i],min(w,a[i]));//递归dfs if(c == -1)//不能找到增广路径 { break; } a[i] -= c;//正向边减少c a[i ^ 1] += c;//反向边增加c(^的意思是奇数+1,偶数-1,正好满足求反向边编号的条件) return c; } } return -1;//在残存网络中找不到增广路径了 } ``` 好了,函数都说完了,上最终代码:(main函数在干什么看注释) ``` #include <bits/stdc++.h> using namespace std; int n,m,s,t,cnt,to[10005],b[205],h[205],nt[10005]; long long a[10005]; void add(int u,int v,long long w) { to[cnt] = v; a[cnt] = w; nt[cnt] = h[u]; h[u] = cnt++; } long long dfs(int u,long long w) { if(u == t) { return w; } b[u] = 1; for(int i = h[u];i != -1;i = nt[i]) { if(a[i] > 0 && b[to[i]] == 0) { long long c = dfs(to[i],min(w,a[i])); if(c == -1) { continue; } a[i] -= c; a[i ^ 1] += c; return c; } } return -1; } int main() { memset(h,-1,sizeof(h));//初始化,防止循环时无法分辨编号0与没有赋值的区别 memset(nt,-1,sizeof(nt)); scanf("%d%d%d%d",&n,&m,&s,&t); for(int i = 1;i <= m;i++) { int u,v; long long w; scanf("%d%d%lld",&u,&v,&w); add(u,v,w);//存正向边 add(v,u,0);//存反向边 } long long ans = 0,c = dfs(s,1e18);//先递归出第一个结果 while(c != -1)//有增广路径 { memset(b,0,sizeof(b));//清空,方便下次递归 ans += c;//加上这次的流量 c = dfs(s,1e18);//继续dfs递归 } printf("%lld",ans); return 0; } ``` 因为FF算法的时间复杂度是$O(m*ans)$,$m$是边数,$ans$是最大流(结果),所以效率极低,此题只能得84分。下面的算法EK,就能AC。 ------------ ### 2.Edmond-Karp算法 此算法和FF算法几乎一样,只是把$dfs$改成了$bfs$实现。算法大致思路是:将残存网络看作一个无权图然后求$s$到$t$的最短路,这条最短路上的最小剩余流量如果大于$0$,那么就作为增广路径,进行残存网络的更新。 FF算法的时间复杂度受最大流大小影响,而EK算法只受节点数量和边的数量的影响,所以时间复杂度更低。 $add$函数不变,下面分析一下$bfs$函数: $l$数组是记录走向,就可以实现“反悔机制”;$f$是记录最小值;$q$这个优先队列相当于一个存储器,模拟$bfs$;其他的都和上面一样。 **此函数就是不停地遍历、递归、更新,到了终点就返回$1$,没到就返回$0$。** ``` int bfs() { memset(l,-1,sizeof(l));//初始化 queue<int> q; q.push(s);//先将起点加入队列 f[s] = 1e18;//取最大值,方便min while(!q.empty()) { int u = q.front(); q.pop(); if(u == t)//到了终点就退出 { break; } for(int i = h[u];i != -1;i = nt[i])//还是链式前向星 { if(a[i] > 0 && l[to[i]] == -1)//如果还有水流且要去的点没有其他水流流过 { l[to[i]] = i;//到to[i]需要走i这条边 f[to[i]] = min(f[u],a[i]);//递归bfs q.push(to[i]);//入队 } } } return l[t] != -1;//如果能送到终点,返回1,否则0 } ``` 好了,直接上代码: ``` #include <bits/stdc++.h> using namespace std; int n,m,s,t,h[205],nt[10005],to[10005],cnt,l[205]; long long a[10005],f[205]; void add(int u,int v,long long w) { to[cnt] = v; a[cnt] = w; nt[cnt] = h[u]; h[u] = cnt++; } int bfs() { memset(l,-1,sizeof(l)); queue<int> q; q.push(s); f[s] = 1e18; while(!q.empty()) { int u = q.front(); q.pop(); if(u == t) { break; } for(int i = h[u];i != -1;i = nt[i]) { if(a[i] > 0 && l[to[i]] == -1) { l[to[i]] = i; f[to[i]] = min(f[u],a[i]); q.push(to[i]); } } } return l[t] != -1; } int main() { memset(h,-1,sizeof(h)); memset(nt,-1,sizeof(nt)); scanf("%d%d%d%d",&n,&m,&s,&t); for(int i = 1;i <= m;i++) { int u,v; long long w; scanf("%d%d%lld",&u,&v,&w); add(u,v,w);//正向边 add(v,u,0);//反向边 } long long sum = 0; while(bfs() == 1)//把源点还能送出去的水全部都送出去,直到送不到终点 { sum += f[t];//累加 for(int i = t;i != s;i = to[l[i] ^ 1])//还有多余的水,送出去 { a[l[i]] -= f[t];//正向边减少 a[l[i] ^ 1] += f[t];//反向变边增加 } } printf("%lld",sum); return 0; } ``` EK算法的时间复杂度是$O(nm^2)$,可以过,愉快AC! ------------ ### 3.Dinic算法 前面两个算法分别用了$dfs$和$bfs$,这个算法就是把两个函数结合起来,先用$bfs$分层,再用$dfs$寻找。分层是预处理出源点到每个点的距离(注意每次循环都要预处理一次,因为有些边可能容量变为$0$不能再走)。我们只往层数高的方向增广,可以保证不走回头路也不绕圈子。 **优化:** 1. **多路增广**:节省很多花在重复路线上的时间,在某点$dfs$找到一条增广路后,如果还剩下多余的流量未用,继续在该点$dfs$尝试找到更多增广路。 2. **当前弧优化**:因为在Dinic算法中,一条边增广一次后就不会再次增广了,所以下次增广时不需要再考虑这条边。我们把$h$数组复制一份,但不断更新增广的起点。 这个算法,码量较大,有三个函数,但是和前面的函数几乎一样,所以直接上最终代码。(带注释) ``` #include <bits/stdc++.h> using namespace std; int n,m,s,t,h[205],nt[10005],to[10005],l[205],c[205],cnt; //l数组为各点到起点的深度,c数组为当前弧优化的增广起点 long long a[10005]; void add(int u,int t,long long d)//链式前向星存图 { to[cnt] = t; a[cnt] = d; nt[cnt] = h[u]; h[u] = cnt++; } int bfs()//计算边权为1的图,图上各点到源点的距离 { memset(l,-1,sizeof(l)); memcpy(c,h,sizeof(h));//把h数组复制到c数组里 l[s] = 0; c[s] = h[s]; queue<int> q; q.push(s); while(!q.empty()) { int u = q.front(); q.pop(); for(int i = h[u];i != -1;i = nt[i]) { if(a[i] > 0 && l[to[i]] == -1) { l[to[i]] = l[u] + 1;//深度+1 q.push(to[i]); if(to[i] == t) { return 1; } } } } return 0; } long long dfs(int u,long long f) { if(u == t) { return f; } long long x = f;//剩余流量 for(int i = c[u];i != -1 && x > 0;i = nt[i]) { c[u] = i;//当前弧优化 if(a[i] > 0 && l[to[i]] == l[u] + 1) { long long w = dfs(to[i],min(a[i],x)); if(w == 0)//剪枝,去除增广完毕的点 { l[to[i]] = -1; } x -= w;//剩余流量减少c a[i] -= w; a[i ^ 1] += w; } } return f - x;//返回用了的水流 } int main() { memset(h,-1,sizeof(h)); memset(nt,-1,sizeof(nt)); scanf("%d%d%d%d",&n,&m,&s,&t); for(int i = 1;i <= m;i++) { int u,v; long long w; scanf("%d%d%lld",&u,&v,&w); add(u,v,w);//建边 add(v,u,0); } long long ans = 0; while(bfs() == 1) { ans += dfs(s,1e9); } printf("%lld",ans); return 0; } ``` 这份代码的时间复杂度是$O(mn^2)$,因为边数往往大于顶点数,所以Dinic算法的时间复杂度小于EK算法。 ------------ 我们来总结一下: **①时间复杂度** FF:$O(m*ans)

EK:O(nm^2)

Dinic:O(mn^2)

②中心思想

FF:用dfs寻找增广路径,然后更新残存网络,直到找不到增广路径

EK:用bfs判断能否到达终点,能就累加到终点的水流,最后输出和

Dinic:先用bfs分层,再用dfs寻找

③用到的函数

FF:adddfs

EK:addbfs

Dinic:addbfsdfs

④应用场景

FF:当这个题数据很小时,可以用这个最简单的方法

EK:在n>m时通用,时间复杂度不高,也不麻烦

Dinic:数据很大,且m>n

⑤优点和缺点

FF:代码简单,时间复杂度高

EK:时间复杂度比FF高、代码相对简单,但有些题还是过不去

Dinic:时间复杂度最低、常用,码量大

最后,附上参考的博客链接:知乎、博客园、CSDN