网络流学习笔记

故事扮演 提交于 2020-01-23 07:26:30

网络流学习笔记:

$ by~~wch $

前言:

本文篇幅较长,结合右上角的目录了浏览会方便一些

然后本文主要还是自己复习所用,会偏向讲一些(博主经常忘的)核心,有些地方讲得粗略请谅解,所以大家可以对书看,书上都是大佬写的比较全面。(额,假定大家都有认真看书)

然后基本概念库里的知识比较多,实在接受不了直接往后看算法,博主尽量会在算法前标注需要的基本概念(或者直接讲),大家再回来挑着反复看就好。但是尽量要明白算法核心,只要懂了最大流的原理,这些基本概念会显得比较简单。另外带*号的可以忽略,博主也不会讲。

然后我们要有能学好网络流的信心,这个很重要!




一、基本概念库:

本文中弧和边是一个东西,然后大家要注意容量流量是不同的两个概念;残留容量和剩余流量是一个东西,但他们和实际流量要区分开来!

容量网络和网络最大流:

容量网络: 设 $ G(V, E) $ 是一个有向图, $ V $ 为点集, $ E $ 为边集,在 $ V $ 中有两个指定的特殊顶点:源点( $ S $ )和汇点( $ T $ )。每一条弧(边) $ <u, v>∈E $ ,都有一个给定权值 $ c(u, v) $ ,称为弧的容量。这样的有向网络 $ G $ 被称为容量网络。

弧的流量: 通过容量网络 $ G $ 中每条弧 $ <u, v> $ 上的实际流量(简称流量),记为 $ f(u, v) $ ,其基本定律如下 :

  1. 容量限制: $ 0\leq f(x,y)\leq c(x,y) ~~ $ 讲人话:边流量不超过其容量
  2. 斜对称: $ f(x,y)=-f(y,x) ~~ $ 讲人话:存在流量为负的反向边
  3. 流量守恒: $ \forall x \not= S,T\quad \sum_{(u,x)\in E}f(u,x)=\sum_{(x,v)\in E}f(x,v) ~~ $ 讲人话:流进这条边的总流量等于流出这条边的总流量

注意这三个基本定律很重要,然后第二个定律不要和后文的反向边 $ f(x,y)=c(y,x) $ 弄混!

网络流: 所有弧上流量(流量可以为零)的集合 $ f(S,T)=\sum_{(u,v)\in E} f(u, v) $ ,称为该容量网络 $ G $ 的一个网络流。
可行流: 在容量网络 $ G(V, E) $ 中,满足以下条件的网络流 $ f $ ,称为可行流:

  1. 弧流量限制条件:可行流上所有弧流量不超过其容量,且不为负。
  2. 平衡条件:除了源点和汇点,其他点和边流入的流量等于流出的流量。

其中 $ S $ 流出的流量总和 = $ T $ 流入的流量总和 = $ f $ , 并且称 $ f $ 为可行流的流量.

伪流: 如果一个网络流只满足弧流量限制条件,不满足平衡条件,则这种网络流称为伪流,或称为容量可行流。
最大流: 在容量网络 $ G(V, E) $ 中,满足弧流量限制条件和平衡条件、且具有最大流量的可行流,称为网络最大流,简称最大流。



链与增广路:

在容量网络 $ G(V, E) $ 中,设有一可行流 $ f =\sum_{(u,v)\in E} f(u, v) $ ,根据每条弧上流量的多少、以及流量和容量的关系,可将弧分四种类型:

  1. 饱和弧:即达到容量上限的弧 $ f(u,v)=c(u,v) $ ;
  2. 非饱和弧:即没有流满的弧 $ f(u,v)<c(u,v) $ ;
  3. 零流弧:即流量为零 $ f(u,v)=0 $ ;
  4. 非零流弧:即流量大于零 $ f(u,v)>0 $ 。

链:在容量网络中,称一串连续相邻的顶点序列 $ (u,u_1,u_2,…,u_n,v) $ 为一条链,相邻两个顶点之间都有一条弧,沿着源点到汇点方向的一条链,各弧可分为两类:

前向弧:方向与链的正方向一致的弧,其集合记为 $ P+ $ ;
后向弧:方向与链的正方向相反的弧,其集合记为 $ P- $ ;
增广路:设 $ f $ 是一个容量网络 $ G $ 中的一个可行流, $ P $ 是从 $ S $ 到 $ T $ 的一条链,若 $ P $ 满足下列条件:

  1. 在 $ P $ 的所有前向弧 $ <u, v> $ 上, $ 0≤f(u,v)<c(u,v) $ ,即 $ P+ $ 中每一条弧都是非饱和弧
  2. 在 $ P $ 的所有后向弧 $ <u, v> $ 上, $ 0<f(u,v)≤c(u,v) $ ,即 $ P– $ 中每一条弧都是非零流弧

则称 $ P $ 为关于可行流 $ f $ 的一条增广路。沿着增广路改进可行流(增加可行流流量)的操作称为增广。



* 二分图最大匹配:

必须边: 若 $ (u,v) $ 属于任意一个最大匹配方案
必须边性质: $ (u,v) $ 是当前二分图的匹配边,且 $ u $ 和 $ v $ 属于两个不同的强连通分量
可行边:若 $ (u,v) $ 属于至少一个最大匹配方案
可行边性质: $ (u,v) $ 是当前二分图的匹配边,且 $ u $ 和 $ v $ 属于两个不同的强连通分量



残留容量与残留网络:

残留容量: 给定容量网络 $ G(V, E) $ 及可行流 $ f $ ,弧 $ <u, v> $ 上的残留容量记为 $ c′(u,v)=c(u,v)–f(u,v) $ 。每条弧的残留容量表示该弧上可以增加的流量

反向边: 一条实际不存在的边,它是正向边的另一种表达方式(好比我的流量增加-10,反过来就是我们流量减少10),所以正向边发生改变反向边也要同时发生改变!

反向边残留容量:因为从节点 $ u $ 到节点 $ v $ 流量的减少,等效于顶点 $ v $ 到顶点 $ u $ 流量增加,所以每条弧 $ <u, v> $ 上还有一个反方向的残留容量 $ c′(v,u)=f(u,v) $ 。(这是一个相对概念,你给我的东西少了,等于我将你本来要多给我的返还给了你

残留网络: 设有容量网络 $ G(V, E) $ 及其上的网络流 $ f $ , $ G $ 关于 $ f $ 的残留网络记为 $ G'(V', E') $ ,其中 $ G’ $ 的顶点集 $ V’ $ 和 $ G $ 的顶点集 $ V $ 相同,即 $ V’=V $ ,对于 $ G $ 中的任何一条弧 $ <u, v> $ ,如果 $ f(u,v)<c(u,v) $ ,那么在 $ G’ $ 中有一条对应的弧 $ <u, v>∈E' $ ,其容量为残留容量 $ c′(u,v)=c(u,v)–f(u,v) $ ,如果 $ f(u,v)>0 $ ,则在 $ G’ $ 中有一条反向边 $ <v, u>∈E' $ ,其容量为 $ c′(v,u)=f(u,v) $ ,残留网络也称为剩余网络。 (要注意括号内点的顺序)



割与最小割:

割: 在容量网络 $ G(V, E) $ 中, 设 $ E'⊆E $ (注意是边的集合), 如果在 $ G $ 的基图中删去 $ E’ $ 后 $ S $ 和 $ T $ 不再连通,则称 $ E’ $ 是 $ G $ 的割。

割的容量: 割的容量定义为割中所有前向弧(从源点到汇点为正方向)的容量总和,用 $ c(S, T) $ 表示。(注意是容量,不是流量!)

最小割: 容量网络 $ G(V, E) $ 的最小割是指容量最小的割。



相关定理:

残留网络与原网络的关系: 设 $ f $ 是容量网络 $ G(V, E) $ 的可行流, $ f’ $ 是残留网络 $ G’ $ 的可行流,则 $ f + f’ $ 仍是容量网络 $ G $ 的一个可行流。( $ f + f’ $ 表示对应弧上的流量相加)(这是一个增广操作)

网络流流量与割的容量之间的关系: 在一个容量网络 $ G(V, E) $ 中,设其任意一个网络流为 $ f(S,T) $ ,任意一个割为 $ (S, T) $ ,则必有 $ f(S,T)≤c(S,T) $ ,即网络流的流量小于或等于任何割的容量。

最大流最小割定理: 对容量网络 $ G(V, E) $ ,其最大流的流量等于最小割的容量。

增广路定理: 设容量网络 $ G(V, E) $ 的一个可行流为 $ f $ , $ f $ 为最大流的充要条件是在容量网络中不存在增广路。

几个等价命题: 设容量网络 $ G(V, E) $ 的一个可行流为 $ f $ 则以下为等价命题:

  1. $ f $ 是容量网络 $ G $ 的最大流;
  2. $ f $ 等于容量网络最小割的容量;
  3. 容量网络中不存在增广路;
  4. 残留网络 $ G’ $ 中不存在从源点到汇点的路径。



二、网络最大流:

1. $ EK $ 与 $ Dinic $ 原理讲解

注:请先阅读基本概念中的网络流和增广路的书面意义,以及残量网络和反向边,可适当结合书阅读下文

本文着重介绍 $ Edmonds-Karp $ 算法(在费用流里很重要),以及 $ Dinic $ 算法(跑最大流的常用算法,比前面那种快)。它们都是用增广路来求的最大流,增广路是一个核心概念算法基础,弄懂它学网络流会更得心应手,所以本段会花大篇幅讲解。

算法原理: $ EK $ 与 $ Dinic $ 其实都是增广路做法。那么增广路究竟是怎样使我们得到最大流的呢?我们在基本概念里提到过一个叫反向边的东西,它是增广路的核心。我们从源点到汇点随便一个 $ BFS $ (或 $ DFS $ )就能得到一个网络流,但是这个流不一定是最大流。我们要让它扩展成最大流,那么势必会有一个转向操作(就是我们将某个点流向某条边的流量收回,然后将这些流量流向另一条边)。这相当于我们需要对之前的选择“反悔”,而反向边成全了我们。

反向边是一个抽象概念,我们在脑海里构建一个模型(博主能力有限,暂难配图,谅解一下),有一条有流量的弧 $ f $ (或者说边),量为 $ c(u,v) $ ,量为 $ f(u,v) $ ,连接一个起点 $ u $ 和终点 $ v $ 。流过我们起点的流量可能大于 $ f(u,v) $ ,流过终点的流量也可能大于 $ f(u,v) $ (因为可能有流量从其它边流到这个点,再从另外一些边流走)。

这条边存在一条反向边,反向边是一个实际并不存在的抽象概念,它代表这条边我们可以收走(转走)的流量,所以其可用容量就是这条正向边的量 $ f(u,v) $ (注意反向边的剩余容量和正向边的流量密切相关,改变其中一个另一个就会改变)。我们 $ BFS $ (或 $ DFS $ )流过这条反向边就相当于:流过起点 $ u $ (注意字母,自己画图,“起点”二字是根据正向边定义的)流过起点 $ u $ 的流量不变,而流过这条边和终点 $ v $ 的流量减少了(抽象思考就是从终点 $ v $ 收回了一部分流量)然后在起点原本要流向这条边的流量,被收回又可以流向其它节点(就是继续 $ DFS $ )。(整个过程就相当于有一部分流量从终点流向了起点。)

具体地,我们在代码中用 $ j $ 表示当前边,反向边建在存边数组中下标为 $ j~xor~1 $ 的位置。因为我们的这两条边密切关联,需要同时更改,这样方便操作。我们的流量流过正向边时,正向边的剩余容量会减少,相当于我们反向边的剩余容量增加,这个我们要一起更改:

b[j].v-=f; //正向边剩余容量减少
b[j^1].v+=f; //反向边剩余流量增加

每一次我们增广路时,会使原网络的流量增加,增加的量就是增广路的流量(可以画图理解)。而当我们从源点到汇点,已经没有一条正向边与反向边组成的剩余容量(可用容量)均为正,就是没有增广路时,这个网络流就被扩展成了最大流! 可以证明一个非最大流一定含有增广路,因为我们可以通过转向(跑反向边)将这个非最大流变成最大流,这就说明存在一条正向边与反向边组成的剩余容量(可用容量)均为正的路径,即增广路!而寻找这个增广路 $ DFS $ 显然最擅长。



2. 算法的基本实现:

$ EK $ 算法

就是直接像上面讲的一样,用 $ BFS $ 不断求增广路,因为是 $ BFS $ ,所以每次只找一条增广路。博主看到这个做法时在想:为什么要用 $ BFS $ 呢? $ DFS $ 不行吗? $ DFS $ 一次或许还能找多条啊! 后来博主发现 $ DFS $ 会有走环(往回走)的现象,或者说它有可能重复走以前走过的点,为了防止它重复走点而跑环超时,我们设一个访问数组,强制每个点至多走一次。但是这样也就只能找一条增广路了,不过这样 $ DFS $ 确实可以跑最大流,复杂度和 $ BFS $ 相差不多,就是好写。

一个常用的技巧,我们对每一条正向边都建了一条反向边,这条边的容量与正向边的流量有直接关系,所以我们在改变其中一条边时,另一条边也会同时发生改变。为了方便操作,我们将反向边建到数组中以 $ j~XOR~1 $ 为下标的位置,详细看下面数组。自己可以理一理,然后我们的存边要从1开始 $ top=1 $ 为初值。因为 $ 2~XOR~1=3 $ 所以2和3才是配对的!

#include<bits/stdc++.h>
#define rg register int
using namespace std;

int n,m,S,T,t=1;
int res,ans,top=1; //注意top初值为1,才能保证j^1是反边
int tou[10005]; //链式前向星存边
int vis[10005]; //每个点只走一次

struct su{
    int to,v,next;
}b[200005];

inline bool dfs(int i,int w){ vis[i]=t;
    if(i==T||!w)return ans+=w,res=w,1; //找到了就直接回溯
    for(rg j=tou[i];j;j=b[j].next){
        if(b[j].v&&vis[b[j].to]!=t&&dfs(b[j].to,min(w,b[j].v))){ 
            //三个分别为:还有剩余容量,没被访问过,流向儿子的流量不超过其边的容量
            b[j].v-=res; b[j^1].v+=res; return 1; //边回溯边更新信息
        }
    }return 0; //没找到
}

int main(){
    //freopen(".in","r",stdin);
    //freopen(".out","w",stdout);
    scanf("%d%d%d%d",&n,&m,&S,&T);
    for(rg i=1;i<=m;++i){
        rg x,y,v; scanf("%d%d%d",&x,&y,&v);
        b[++top]=su{y,v,tou[x]}; tou[x]=top;
        b[++top]=su{x,0,tou[y]}; tou[y]=top; //直接++top,因为起始top=1
    } while(dfs(S,1e9))++t;
    printf("%d\n",ans);
    return 0;
}


$ Dinic $ 算法

上面我们说过 $ DFS $ 为了防止它跑重复的点我们让它只找一条增广路,有没有办法让它能找多条呢?有的,我们多一个 $ BFS $ 分层的过程,这样它每次 $ DFS $ 向下搜都只会向汇点靠近不会出现跑回来浪费时间的情况,更不可能跑环 。所以 $ Dinic $ 会不断重复以下过程,直到网络中没有增广路为止:

1. 在残量网络上 $ BFS $ 给节点分层,构造分层图:

inline bool bfs(){
    for(rg i=1;i<=n;++i)
        qi[i]=tou[i],dep[i]=0; 
    //第一个是当前弧优化的初始,我们只需要看后面那个节点层数(深度)的初始化
    queue<int> q; q.push(S); dep[S]=1; //用队列模拟bfs
    while(!q.empty()){
        rg i=q.front(); q.pop(); //取出队头
        for(rg j=tou[i];j;j=b[j].next)
            if(b[j].v&&!dep[b[j].to]){ //这条边必须走得通,然后被标记过的点不再标记
                dep[b[j].to]=dep[i]+1; //标记层数,为上一层+1
                if(b[j].to==T)return 1; //找到了就直接退(不用担心有些点没被标层)
                // 这里不用担心有些点没被标层,因为汇点的所有上一层都一定被标记了(这是bfs!)
                q.push(b[j].to); //没找到继续找
            }
    } return 0;
}

2. 在分层图上 $ DFS $ 找到所有增广路,回溯时更新节点信息(剩余流量):

inline int dfs(int i,int w){
    if(i==T||!w)return w;
    rg rest=w,f;
    for(rg &j=qi[i];j&&rest;j=b[j].next){ //&为当前弧优化,不用看,下面会讲
        if(b[j].v&&dep[b[j].to]==dep[i]+1){
            f=dfs(b[j].to,min(rest,b[j].v)); //注意这个min函数,它反映增广路的容量
            b[j].v-=f; b[j^1].v+=f; rest-=f; //更新剩余容量,反边要加容量
        } //我们还要更新剩余容量,保证流进来的等于流出去的
    }return w-rest;
}

因为 $ DFS $ 时,一个节点可以流向多个节点,所以可以找到多条增广路,同时剪枝与优化(下文会讲)让 $ Dinic $ 算法运行较快。虽然理论时间复杂度: $ O(n^2m) $ ,但实际上跑得非常快,所以做题算复杂是总比较纠结,书上默认可以跑 $ 10^4~10^5 $ 的数据范围。不过 $ Dinic $ 跑二分图可以说得心应手,复杂度为 $ m\sqrt{n} $ ,还总是能超常发挥。



3. $ Dinic $ 的当前弧优化和剪枝

当前弧优化:

(配合下面代码食用更佳)我们在一般的链式前向星存边的 $ DFS $ 里从 $ i $ 号节点向下搜索时,是从 $ tou[i] $ (似乎很多人用 $ Head[i] $ )所记录的边开始搜索。我们结合代码及算法原理发现,在如我们当前节点的 $ rest $ (剩余可用流量)不为零 的情况下:

  1. 如果跑 $ b[j].to $ (儿子节点)所得到可用增广流量 $ f $ 却为0,说明这个儿子节点已经搜索不到增广路了,那么之后再怎么搜索都不可能找到增广路;
  2. 如果跑 $ b[j].to $ (儿子节点)所得到可用增广流量 $ f $ 不为0 且 $ rest>f $ ,说明这个儿子节点已经被我们跑满了,那么以后这个父亲 $ i $ 在搜索这个儿子时不可能会得到增广路了。

综上,我们新建一个数组存链式前向星的起始边,并用取址符保证这一次 $ DFS $ 在跑第二次某个节点时,不会去跑那些已经不可能找到增广路的儿子节点:

for(rg &j=qi[i];j/* &&rest!=0 */;j=b[j].next)

注意,博主为什么在这个for循环里不用屏蔽的那句话呢?,其实我们要将它写在后面(如下方代码)。上文我们在当前弧优化的第二个情况里面说过一个条件 $ rest>f $ ,如果我们将屏蔽的那句话放在 $ for $ 循环里,这个节点跑的最后一个儿子并不满足这个条件,这个儿子还可能被增广!为了不让当前弧负优化,我们必须严谨!

for(rg &j=qi[i];j/* &&rest!=0 */;j=b[j].next){ //注意博主写了&取址符,这是当前弧优化的关键
    if(b[j].v&&dep[b[j].to]==dep[i]+1){
        f=dfs(b[j].to,min(rest,b[j].v));
        b[j].v-=f; b[j^1].v+=f; rest-=f; //更新剩余容量,反边要加容量
    } if(!rest)break; //注意在这里退出保证了当前弧优化的正确性,不然会负优化
} // 当前弧优化不能在for循环的判断里退出,因为j是取址的,会往后多改一次,将最后一条可能没跑满的边判错

可行性剪枝:

这个比当前弧要简单一些,当前弧优化核心是再次遍历某一个节点时,不会访问它没用的子节点(就是父亲访问儿子后不可能找到增广路)。那我们自然可以想到,如果一个节点对于所有节点都没用(就是所有这个节点的父亲访问这个节点后都不能找到增广路),那么这是一个废节点,我们可以及时排除。

原理: (请结合下面代码)如果我们当前节点 $ rest $ (剩余可用流量)不为零,然后在遍历某一个儿子后得到的增广容量也为0,那么任何其它节点在访问这个节点后一定也不能得到增广路。不然我们在第一次遍历这个儿子时一定可以得到一个增广路。但是这个剪枝有一个前提条件:这个节点通向它儿子的边一定还有剩余容量,不然就是儿子可以找到增广路,在这条边回溯时也就断掉了。

for(rg &j=qi[i];j;j=b[j].next){
    if(b[j].v&&dep[b[j].to]==dep[i]+1){ //不要少了前面那一个,它会影响我们剪枝
        f=dfs(b[j].to,min(rest,b[j].v));
        if(!f){dep[b[j].to]=-2; continue;} //剪枝:这个点已经不可能再被增广了(满流了)
        b[j].v-=f; b[j^1].v+=f; rest-=f; //更新剩余容量,反边要加容量
    } if(!rest)break;
}


4. 例题: 洛谷 P3376 最大流

没什么好说的,就是一道板子,照着上面说的写就好。数据还贼水。

#include<iostream>
#include<cstdio>
#include<iomanip>
#include<algorithm>
#include<cstring>
#include<cstdlib>
#include<ctime>
#include<cmath>
#include<vector>
#include<queue>
#include<map>
#include<set>

#define ll long long
#define db double
#define rg register int

using namespace std;

int n,m,S,T;
int ans,top=1; //注意top初值为1,才能保证j^1是反边
int dep[10005]; //节点层数
int tou[10005]; //链式前向星存边
int qi[10005]; //当前弧优化存边

struct su{
    int to,v,next;
}b[200005];

inline int qr(){
    register char ch; register bool sign=0; rg res=0;
    while(!isdigit(ch=getchar()))if(ch=='-')sign=1;
    while(isdigit(ch))res=res*10+(ch^48),ch=getchar();
    if(sign)return -res; else return res;
}

inline void add(int x,int y,int v){
    b[++top]=su{y,v,tou[x]}; tou[x]=top; //加边 (c++11)
}

inline bool bfs(){
    for(rg i=1;i<=n;++i)
        qi[i]=tou[i],dep[i]=0; //初始化
    queue<int> q; q.push(S); dep[S]=1; //初始化
    while(!q.empty()){
        rg i=q.front(); q.pop();
        for(rg j=tou[i];j;j=b[j].next)
            if(b[j].v&&!dep[b[j].to]){
                dep[b[j].to]=dep[i]+1; //标记层数
                if(b[j].to==T)return 1; //找到了就直接退(不用担心有些点没被标层)
                q.push(b[j].to); //没找到继续找
            }
    } return 0;
}

inline int dfs(int i,int w){
    if(i==T||!w)return w;
    rg rest=w,f;
    for(rg &j=qi[i];j;j=b[j].next){ //注意博主写了&取址符,这是当前弧优化的关键
        if(b[j].v&&dep[b[j].to]==dep[i]+1){ //不要少了前面那一个,它会影响我们剪枝
            f=dfs(b[j].to,min(rest,b[j].v));
            if(!f){dep[b[j].to]=-2; continue;} //剪枝:这个点已经不可能再被增广了(满流了)
            b[j].v-=f; b[j^1].v+=f; rest-=f; //更新剩余容量,反边要加容量
        } if(!rest)break; //注意这里退出保证了当前弧优化的正确性
    }return w-rest;
}

int main(){
    //freopen(".in","r",stdin);
    //freopen(".out","w",stdout);
    n=qr(); m=qr(); S=qr(); T=qr();
    for(rg i=1;i<=m;++i){
        rg x=qr(),y=qr(),v=qr();
        add(x,y,v); add(y,x,0);
    } while(bfs()) ans+=dfs(S,1e9); //Dinic有两步(bfs+dfs)
    printf("%d\n",ans);
    return 0;
}

5. 其它算法:ISAP与HLPP

这个暂时不会讲,给出两个思考,和一个链接

$ ISAP $ :在 $ Dinic $ 中,我们会先 $ BFS $ 给残量网络分层,然后再 $ DFS $ 寻找增广路。我们知道 $ BFS $ 分层是为了让 $ DFS $ 能一次找多条增广路,那有没有办法减少这个分层次数?我们可以先从汇点向源点跑一次 $ BFS $ 标记深度,然后动态维护这个深度。这是 $ ISAP $ 的核心。

$ HLPP $ :我们求最大流时,不难有一个脑补做法:从源点放肆灌水,每条边能流就流;当从一个大管子流向一个小管子,流量会减小,大管子上面的流量会因为压强自然流向另一边;最后在流过汇点的流量就是最大流。大家可以想一想这个怎么实现,事实上,这一个与增广路毫不相干的做法,称为预留推进,跑得很快,但比较难写。




三、最小割与最大流

注:需要先仔细阅读基本概念里的残量网络和割的定义

最大流最小割定理:

任意一个网络流的最大流等于其最小割。

首先我们可以证明任何一个网络流 $ \leq $ 任何一个最小割。反证法:如果最大流 $ > $ 最小割,那么我们将最小割所包含的边全部割掉,这是最大流一定还有部分流量可以从源点到汇点,这与最小割的定义(源汇点不连通矛盾)。所以任何一个网络流 $ \leq $ 任何一个最小割。

接下来我们只需证明最大流可以构造一组最小割,这里需要用到残量网络,它与割有着密不可分的联系。残量网络就是由网络中所有没流满的边(包括反向边)构成的图。我们知道一个网络的最大流,它所对应的残量网络一定是个不连通图(源汇点不连通),否则使它们联通的链就是增广路,与最大流矛盾。所以我们从源点出发找到所有可以标记的点,这些点和未标记点的连边构成最小割(这些边一定满流),而他们的流量等于最大流(这个要从定义出发思考)。



网络流例题及技巧

$ POJ~1966~Cable~TV~Network $


第一眼可能让人很难下手,但本就是冲着网络流来的,所以我们直接一点。这道题我们要让这个联通图断开,那么势必会有两个点变得不连通,这道题的数据范围很小,所以我们试着暴力枚举两个点。这样就变成了最小割。不过,嗯?割的东西怎么是点?

为了靠近我们已经学得知识,我们想办法看,能不能割点变成割边。反正网络流最喜欢千变万化、左右建模了。。。于是我们引进书上的一个东西:

  1. 一个节点可以拆成两个节点,将原节点用中间那条边表示
  2. 一条边可以拆成两条边,将原边用中间那个点表示
  3. 中间的边权为1代表这个点是否被割,旁边的边权为inf是为了排除其影响(因为它不可能被割掉)

我们用第一条和第三条性质可以解决这个问题。首先对于每个节点建立两个 $ i $ 和 $ i+n $ 节点。然后这两个节点之间用一条权值为1的有向边(从 $ i $ 到 $ i+n $ ) ,如果这条边在最小割中被割掉(等价于原本的点被割掉)。然后 $ i $ 节点连入边(权值正无穷), $ i+n $ 节点连出边(权值正无穷),连正无穷是为了让割掉的边只能是中间的边。然后我们跑一遍最大流,它对应的最小割里每条代表原来一个点,因为权值为1,所以流量就是答案。

注意:我们的源汇点也要被分为两个点,而网络流中的实际源点是 $ S+n $ ,它连出边。因为源汇点的性质,这两个点不可能被割掉,所以它们中间不连边。


$ code: $

#include<iostream>
#include<cstdio>
#include<iomanip>
#include<algorithm>
#include<cstring>
#include<cstdlib>
#include<ctime>
#include<cmath>
#include<vector>
#include<queue>
#include<map>
#include<set>

#define ll long long
#define db double
#define rg register int

using namespace std;

int n,m,S,T;
int ans,top=1;
int dep[505];
int tou[505];
int qi[505];
int f[55][55];

struct su{
    int to,v,next;
}b[5005];

inline int qr(){
    register char ch; register bool sign=0; rg res=0;
    while(!isdigit(ch=getchar()))if(ch=='-')sign=1;
    while(isdigit(ch))res=res*10+(ch^48),ch=getchar();
    if(sign)return -res; else return res;
}

inline void add(int x,int y,int v){ //注意博主加边自带反向
    b[++top]=su{y,v,tou[x]}; tou[x]=top;
    b[++top]=su{x,0,tou[y]}; tou[y]=top;
}

inline bool bfs(int x){
    for(rg i=1;i<=x;++i)
        qi[i]=tou[i],dep[i]=0;
    queue<int> q; q.push(S); dep[S]=1;
    while(!q.empty()){
        rg i=q.front(); q.pop();
        for(rg j=tou[i];j;j=b[j].next)
            if(b[j].v&&!dep[b[j].to]){
                dep[b[j].to]=dep[i]+1;
                if(b[j].to==T)return 1;
                q.push(b[j].to);
            }
    } return 0;
}

inline int dfs(int i,int w){
    if(i==T||!w)return w;
    rg rest=w,f;
    for(rg &j=qi[i];j;j=b[j].next){
        if(b[j].v&&dep[b[j].to]==dep[i]+1){
            f=dfs(b[j].to,min(w,b[j].v));
            if(!f){dep[b[j].to]=-2; continue;}
            b[j].v-=f; b[j^1].v+=f; w-=f;
        } if(!w)break;
    }return rest-w;
}

inline void solve(){
    rg res=0; top=1;
    for(rg i=1;i<=n*2+2;++i) tou[i]=0; //初始化
    for(rg i=1;i<=n;++i){
        if(i!=S&&i!=T)add(i,i+n,1); //一点拆成两点,中间连边
        for(rg j=1;j<=n;++j)
            if(f[i][j])add(i+n,j,1e9); //连边注意是否有加n操作
    } S=S+n;
    while(bfs(n*2+2)) res+=dfs(S,1e9); //DInic
    ans=min(res,ans);
}

int main(){
    //freopen(".in","r",stdin);
    //freopen(".out","w",stdout);
    rg t=qr();
    while(t--){
        n=qr();m=qr();
        for(rg i=1;i<=n;++i){
            for(rg j=i;j<=n;++j){
                f[i][j]=f[j][i]=0; //初始化
            }
        }
        for(rg i=1;i<=m;++i){
            rg x=qr()+1,y=qr()+1;
            f[x][y]=f[y][x]=1; //邻接矩阵读边
        }
        if(n==0||n==2){puts("0");continue;}
        if(m==0&&n&&n!=2){puts("1");continue;}//特判,这题有点卡细节
        ans=1e9;
        for(rg i=1;i<=n;++i){
            for(rg j=1;j<=n;++j){
                if(f[i][j]||i==j)continue; //注意两个相邻的点不可能通过割点不联通
                S=i;T=j; solve(); //枚举源汇点
            }
        } if(ans==1e9)ans=n; //无论怎么割点图都联通,就输出n
        printf("%d\n",ans);
    }
    return 0;
}



四、最小费用最大流

学会网络流之后,费用流就会比较好学,因为费用流一定是最大流,只是它还涉及在最大流的同时保证费用最大或最小。。。

费用流的求法

设 $ G(V, E) $ 是一个网络, $ V $ 为点集, $ E $ 为边集。其中每一条弧(边) $ <x,y>∈E $ ,都有一个给定容量 $ c(x,y) $ ,除此之外还有一个费用 $ v(x,y) $ 。当边 $ (x,y) $ 的流量为 $ f(x,y) $ 时,需要花费 $ f(x,y)\times v(x,y) $ ,现在要求网络里费用最小(大)的一个最大流。

思路1: 我们找到一个最大流,然后通过修改边的流量优化费用。这个不好实现,暂时不讲。相比之下,思路二会更接近我们已经学过的东西。

思路2: 我们考虑在跑最大流的同时优化费用,这个我们直接贪心,在跑最大流找增广路时去找一条费用最小的增广路,然后一直找到没有增广路为止。因为我们每次只找一条,所以我们不能用 $ Dinic $ ,而且如果要跑带负边权的最短路我们又只好用 $ SPFA $ ,且没有办法边找边回溯更新边的信息。只能用 $ SPFA $ 找到并记录一条费用最小的增广路,然后跑完再更新路径上所有边的信息。

例题:洛谷 P3381 最小费用最大流

就是一道板子:

#include<iostream>
#include<cstdio>
#include<iomanip>
#include<algorithm>
#include<cstring>
#include<cstdlib>
#include<ctime>
#include<cmath>
#include<vector>
#include<queue>
#include<map>
#include<set>

#define ll long long
#define db double
#define rg register int

using namespace std;

int n,m,s,t;
int ans1,ans2,top=1;
int fa[5005]; //记录节点的父亲,便于找到路径更新
int eg[5005]; //记录连接父亲的边,便于找到路径更新
int vv[5005]; //记录流过节点的流量
int dis[5005]; //记录节点距离,用来SPFA
int tou[5005]; //链式前向星
bool vis[5005]; //SPFA

queue<int> q;

struct su{
    int to,v1,v2,next;
}b[200005];

inline int qr(){
    register char ch; rg sign=0,res=0;
    while(!isdigit(ch=getchar()))if(ch=='-')sign=1;
    while(isdigit(ch))res=res*10+(ch^48),ch=getchar();
    if(sign)return -res; else return res;
}
int sdf;
inline bool spfa(){
    for(rg i=1;i<=n;++i)
        dis[i]=1e9, vv[i]=1e9; //跑最短路的更新
    q.push(s); dis[s]=0; //初始
    while(!q.empty()){
        rg i=q.front(); vis[i]=0; q.pop(); //取队头
        for(rg j=tou[i];j;j=b[j].next){
            rg to=b[j].to;
            if(b[j].v2&&dis[to]>dis[i]+b[j].v1){ //跑SPFA,不要少了第一个判断
                if(!vis[to])vis[to]=1,q.push(to);
                dis[to]=dis[i]+b[j].v1; fa[to]=i; //更新距离,更新父亲
                eg[to]=j; vv[to]=min(vv[i],b[j].v2); //更新边与流量
            }
        }
    }return dis[t]!=1e9;
}

inline void solve(){
    rg x=t,y,v=vv[t];
    while(x!=s){ //找到路径并更新信息
        y=eg[x];
        b[y].v2-=v;
        b[y^1].v2+=v; //反向边加权
        x=fa[x];
    }ans1+=v; ans2+=v*dis[t]; //记录答案
}

int main(){
    //freopen(".in","r",stdin);
    //freopen(".out","w",stdout);
    n=qr(); m=qr(); s=qr(); t=qr();
    for(rg i=1;i<=m;++i){
        rg x=qr(),y=qr(),v2=qr(),v1=qr();
        b[++top]=su{y,v1,v2,tou[x]}; tou[x]=top;
        b[++top]=su{x,-v1,0,tou[y]}; tou[y]=top;
    } while(spfa()) solve();
    printf("%d %d\n",ans1,ans2);
    return 0;
}



五、带上下界的网络流

带上下界的网络流最重要的就是无源汇上下界网络流,他是一个万能基础!所以本文会花大部分笔墨解读其核心,懂了它之后的几个上下界算法就是水到渠成。

1. 无源汇上下界网络可行流:

模型:有一个网络 $ G(V, E) $ , $ V $ 为点集, $ E $ 为边集,没有源汇点。其中每一条边都有一个流量限制: $ li \leq f \leq ri $ 。求一个可行流,即在这个可行流里对于所有的点:流入总量 = 流出总量。(边一定流量守恒所以我们只考虑点) (注意没有源汇点,所以这个流是一个循环流,无始无终!)

我们定义:某个点的流入总量为所有入边的实际流量总和,流出总量为所有出边的实际流量总和。

核心思路:

因为我们的可行流里,每条边的流量一定大于其下界,所以我们钦定先建立一个初始流它的每条边的流量恰好是其流量下界。然后我们考虑建立它对应的残量网络,因为原网络中每条边的实际容量被钦定成下界,所以残量网络中每一条边的残留容量即为原边的上界流量 - 下界流量

这句话敲重要:我们发现这个初始流不一定满足流量守恒(即对于所有的点:流入总量 = 流出总量),是个非可行流。于是我们考虑能否在残量网络中也寻找一个不满足流量守恒的非可行流(本文暂称为“添补流”),使得它们两个合并后能够变成一个我们想要的可行流!

而要找到这个合适的添补流,我们必须寻找它与初始流之间的关系:因为两流合并后为一个流量守恒的可行流,所以我们可以根据初始流算出残量网络的“添补流”中每个点的流入总量与流出总量应该满足的关系:

  1. 流量已经守恒:如果在初始流中某个点的流入总量 = 流出总量,那么残量网络的“添补流”所对应节点应满足:流入总量 = 流出总量
  2. 流入 > 流出:如果在初始流中某个点的流入总量 = 流出总量 ​-v​ ,那么残量网络的“添补流”所对应节点应满足:流入总量 ​=​ 流出总量+v 。这样当初始流和添补流加起来时,这个点才满足 流入总量 ​= 流出总量 (-v +v)
  3. 流入 < 流出:即如果在初始流中某个点的流入总量 = 流出总量 ​+v​ ,那么残量网络的添补流所对应节点应满足:流入总量 ​=​ 流出总量 -v 。这样最后合并时这个点才满足 流入总量 ​= 流出总量 (+v -v)

下面代码里:a数组表示在残量网络的添补流里某个节点应满足:流入总量比流出总量大多少(可以为负)

int a[]; //在残量网络的添补流里某个节点i应满足:流入总量-流出总量 = a[i]
for(rg i=1;i<=m;++i){
    scanf("%d%d%d%d",&x,&y,&l,&r) //l为下界,r为上界
    add(x,y,r-l,r); //加一条从x到y的剩余容量为r-l的边,最后一个上界用来最后求可行流流量
    a[x]+=l; //注意x为出发点,在初始流中流出总量增加,相应的添补流中流入总量也应增加
    a[y]-=l; //y为出发点,在初始流中流入总量增加,相应的添补流中流入总量需要减少
}

只要这三个条件满足,初始流和添补流合并后,我们就能得到所有点都流量守恒的可行流,也就是我们的答案。


如何求这个添补流:

首先我们要明白,我们目前还没办法在一个无源汇的网络中找一个非可行流。但是也正因为这个网络无源汇,我们找的流为非可行流,再联系到网络流的千变万化,我们想能不能通过添加源汇点和一些边,以跑网络流的形式,来实现对每个点流入总量和流出总量关系的限制!(也就是说在添加源汇点和一些边的情况下得到的网络流,在删去源汇点和这些边后,是一个我们想要的不满足流量守恒的“添补流”) 这是可以实现的,具体来说:

  1. 如果我们在残量网络中需要某个点的流入总量 > 流出总量(准确一点:流入总量 = 流出总量 -v),那么我们可以建一条从这个点出发的容量为 v 的边。这样我们在加这条边的情况下跑网络流,如果这条边能流满,那么在这个网络流里这个点:流入总量 = 流出总量 = 流向这条边的流量(v)+ 流向其他边的流量。这时只要我们将这条加进来的边再删除掉,得到的非流量守恒流里,这个点:流入总量 = 流出总量 - 流向这条边的流量(v)。达到了我们想要的效果!
  2. 如果我们在残量网络中需要某个点的流入总量 < 流出总量(准确一点:流入总量 = 流出总量 +v),那么我们可以建一条流向这个点的容量为 v 的边。这样我们在加这条边的情况下跑网络流,如果这条边能流满,那么在这个网络流里这个点:从这条边流进来的流量(v)+ 从其他边流进来的流量 = 流入总量 = 流出总量。这时只要我们将这条加进来的边再删除掉,得到的非流量守恒流里,这个点:流入总量 - 从这条边流进来的流量(v)= 流出总量
  3. 让某个点流入总量 = 流出总量:这个我们什么都不做就好,因为在有源汇的网络跑网络流自然满足流量守恒
  4. 我们发现第一步里建了入边,第二步里建了出边,为了让这些边有来处有去处,我们建一个虚拟源点和虚拟汇点:所有新建的指向节点的边从虚拟源点出发,所有新建的从节点出发的边流向虚拟汇点
  5. 然后我们发现我们的前两个性质都需要满足新建的边满流,才能达到构造一个可行的添补流,所以我们直接跑网络最大流。如果所有新建的边都能满流就可以构造出这样一个添补流。否则无法构建添补流,原问题无解。
//再强调一下:a[i]表示残量网络的添补流里某个点应满足的:流入总量比流出总量大多少!
for(rg i=1;i<=n;++i){
    if(a[i]>0)add(i,t,0,a[i]); //流入>流出,从该节点向汇点连边
    if(a[i]<0)add(s,i,0,-a[i]); //流入<流出,从源点向该节点连边
    if(a[i]>0)ans+=a[i]; //最大流需要跑满的流量
}

最后我们只需要将最大流里所有添加的点和边去掉,然后在得到的添补流里某条边的实际流量加上初始流中这条边的流量(就是其下界流量)就可得到这条边在原网络的可行流里的实际流量!


例题:

#include<iostream>
#include<cstdio>
#include<iomanip>
#include<algorithm>
#include<cstring>
#include<cstdlib>
#include<ctime>
#include<cmath>
#include<vector>
#include<queue>
#include<map>
#include<set>

#define ll long long
#define db double
#define rg register int

using namespace std;

int n,m,s,t;
int ans,top=1; //top从1开始
int a[205]; //添补流中某个点的应该的流入总量-流出总量
int qi[205]; //当前弧优化
int dep[205]; //层深度
int tou[205]; //链式前向星

struct su{
    int to,v,up,next; //v为剩余容量,up为上界
}b[200005];

inline int qr(){ //快读
    register char ch; register bool sign=0; rg res=0;
    while(!isdigit(ch=getchar()))if(ch=='-')sign=1;
    while(isdigit(ch))res=res*10+(ch^48),ch=getchar();
    if(sign)return -res; else return res;
}

inline void add(int x,int y,int v,int up){ //加边
    b[++top]=su{y,v,up,tou[x]}; tou[x]=top; //正向边
    b[++top]=su{x,0,up,tou[y]}; tou[y]=top; //反向边
}

inline bool bfs(int x){
    for(rg i=1;i<=x;++i)
        qi[i]=tou[i],dep[i]=0;
    queue<int> q; q.push(s); dep[s]=1;
    while(!q.empty()){
        rg i=q.front(); q.pop();
        for(rg j=tou[i];j;j=b[j].next){
            if(b[j].v&&!dep[b[j].to]){
                dep[b[j].to]=dep[i]+1;
                if(b[j].to==t)return 1;
                q.push(b[j].to);
            }
        }
    }return 0;
}

inline int dfs(int i,int w){
    if(i==t)return w;
    rg rest=w,f;
    for(rg &j=qi[i];j;j=b[j].next){ //&为当前弧优化
        if(b[j].v&&dep[b[j].to]==dep[i]+1){
            f=dfs(b[j].to,min(rest,b[j].v));
            if(!f){dep[b[j].to]=-2; continue;} //剪枝
            b[j].v-=f; b[j^1].v+=f; rest-=f; //更改信息
        }if(!rest)break; //当前弧优化
    }return w-rest; //返回增广流量
}

int main(){
    //freopen(".in","r",stdin);
    //freopen(".out","w",stdout);
    n=qr(); m=qr(); s=n+1; t=n+2;
    for(rg i=1;i<=m;++i){
        rg x=qr(),y=qr(),l=qr(),r=qr();
        add(x,y,r-l,r); a[x]+=l; a[y]-=l;
    }
    for(rg i=1;i<=n;++i){
        if(a[i]>0)add(i,t,0,a[i]); //流入>流出
        if(a[i]<0)add(s,i,0,-a[i]); //流入<流出
        if(a[i]>0)ans+=a[i]; //我需要跑满的流量
    } while(bfs(n+2)) ans-=dfs(s,1e9); //dinic
    if(!ans){ puts("YES"); //跑满了
        for(rg i=2;i<=m*2;i+=2)
            printf("%d\n",b[i].up-b[i].v); //上界减去剩余容量
    }else puts("NO"); //没跑满
    return 0;
}


2. 有源汇上下界网络可行流:

模型:现在有一个网络,存在一个源点和一个汇点,每条边都有一个流量上下界限制。求一个从源点到汇点的可行流,满足边的流量限制,且所有点流量守恒。

思路:

这是一个看起来很难但实际上比较傻的问题。这个网络里已经存在源汇点,看起来我们确实不能向无源汇一样加点加边了。但是联想上一个内容,无源汇的上下界网络流,它的可行流是一个循环流,无始无终。本题确实制指定了源汇点,但是我们可不可以想办法将源汇点变成一般点,所求的可行流变成一个循环流?其实到这里我们就应该要懂了:我们直接在从汇点向源点连一条容量无限大的边,所求的可行流所有流量在流入汇点后有流向源点,变成了一个循环流。这样我们又直接像无源汇一样做就行了!

关于我们最后求得的可行流:

首先每一条边的流量可以仿照无源汇做法得出。我们如果要快速得出整个可行流的流量,我们发现这个流量就是所有从原点出发,在汇点集合的流量的总和。而所有流入汇点的流量会通过我们加入的那条容量无限大的边流回源点,所以我们直接最后看看这条边的实际流量即可。



3. 有源汇上下界网络最大流

模型:现在有一个网络,存在一个源点和一个汇点,每条边都有一个流量上下界限制。求一个从源点到汇点的最大可行流,需满足边的流量限制,且所有点流量守恒。

思路:

(大家可以先独立思考一下)这道题其实可以转化为一个很普通的求网络最大流问题。只不过我们必须先按照有源汇上下界可行流的模式找到一个可行流。然后我们想办法将这个可行流转化成最大流,我们在求完可行流后的残量网络里去掉两个虚拟源汇点和与它们有连接的边,然后以题目所给的源汇点和边的剩余容量构成的残量网络里跑最大流,用这个最大流与初始流合并就是满足上下界的最大可行流

核心原理:(为什么这样做是对的)

首先我们是不改变初始流的,我们改变的是残量网络上的添补流(它和初始流合并变成可行流),我们可以在残量网络上对已经求得的添补流进行增广操作。而求添补流注重的是点的流入总量和流出总量的关系 ,我们已经求得了一个满足关系的添补流,而增广操作也并不会改变点的流入总量与流出总量的差,所以增广后的添补流和初始流合并后依旧流量守恒。然后这个残量网络上有所有的剩余容量,所以用它求出来的最大添补流再加上初始流就一定可以得到可行最大流。因为初始流包含所有边的下界,并且最后才被加上,所以最终流的边流量一定不会小于流量下界!



4. 有源汇上下界网络最小流

模型:现在有一个网络,存在一个源点和一个汇点,每条边都有一个流量上下界限制。求一个从源点到汇点的最小可行流,需满足边的流量限制,且所有点流量守恒。

思路1: 二分答案

我们之前求解有源汇的上下界可行流时,会从汇点向源点连一条容量无限的边。现在我们稍稍进行修改,我们对这条边设置一个上限 $ f $ 。 然后跑无源汇上下界可行流,如果存在可行流,那么原网络的最小可行流 $ minf \leq f $ ;如果不存在可行流,那么原网络最小可行流 $ minf > f $ 。

于是我们发现我们可以二分答案,并每次将这个答案作为源汇点之间边的最大容量,判是否有可行流即可。


思路2: 反向边性质

上面那一种做法复杂度需要乘上一个二分答案产生的复杂度,可是我们求有源汇上下界网络最大流时没有,难道最小流特殊一些?肯定不是。有源汇上下界最小流其实和有源汇上下界最大流一个思路,原理都是一个原理。

我们先跑一边有源汇上下界可行流,然后考虑将这个可行流转化成最小流。这个我们也可以变成普通最大流,我们在求完可行流后的残量网络里去掉两个虚拟源汇点和与它们有连接的边,然后以题目所给的源汇点和边的剩余容量构成的残量网络里跑从汇点流向源点的最大流,用这个最大流加上初始流合并就是满足上下界的最小可行流。(注意是加上初始流,所以不会出现小于流量下界的情况)

因为我们已经求得了一个满足关系的添补流,而反向增广操作也并不会改变点的流入总量与流出总量的差,所以增广后的添补流和初始流合并后依旧流量守恒。我们残量网络上的流量通过反向边其实就相当于正向边流量减少,所以一次从汇点到源点的增广操作,就相当于从源点到汇点的流量减少。(然后无论怎么增广,正向边的流量都不会为负的)



5. (待填)无源汇上下界最大最小流:



6. (待填)有源汇上下界费用流:




六、习题推荐

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!