【图论】网络流:Dinic 算法模板实现原理及解题技巧

题目链接:P3376 【模板】网络最大流 - 洛谷

网络流的代码并不复杂,但很多时候题目考察却很难辨别出来这是网络流可以解决的问题。而在讲解如何识别出网络流的技巧之前,我们还是要先明白,网络流是什么,解决的问题是什么,以及如何完成其代码。

原理讲解

给一个有向图,我们可以想象这是一个输水网络,在某个可以源源不断地提供水流的称为源点 ,而将水流输送至的目标节点我们可称之为汇点 。在这个有向图上,每一条边都有一个流量上限,也就是说单位时间内最多能通过多少流量即为这条边的容量 。而网络流要解决的问题,就是要找在单位时间内在这个网络上能通过的最大流量,也就是最大流

从 P3376 的题目示例数据我们就能大致理解找最大流的问题了。可以看到数据要求是从源点 4 到汇点 3 的最大流,当然第一眼就能发现 4 -> 3 直接就连着一条边容量为 20 ,然后从 4 -> 2 这条边容量为 30 ,当水流到了 2 这个点,无论是沿着 2 -> 1 -> 3 ,还是 2 -> 3 我们都发现完全足够把这 30 的水流全部送过去汇点。所以最大流也就是 50 了。

这么一看似乎求出最大流还是很简单的,可以发现,在网络流这个图上,控制着整个网络的流量的只有两种边:容量较小的边和割边。容量较小的边很好理解,从源点到汇点的输水路径上,容量较小的边由于它的限制,即使前面来再多的水流这个边也只能通过这么多;同样即使后面的边容量再多,可由于这个容量较小边的限制,即使它容量再多也只能最多通过来自这个容量较小边的流量。

而割边,也就是指一种割断一些边,使得从源点到汇点没有合适的路径方案 的方法。割断这些边的代价定义为这些边的容量,最小割 就是割断这些边的最小代价。我们观察可以发现,最小割割断了这些边使得源点和汇点不连通,控制了所有容量,而且他们这些边还是容量相对较小的。由此推导出来一个结论是:最小割其实就等于最大流

Dinic 算法是目前求解最大流的一个比较高效和稳定的算法了。根据我们刚刚的分析其实还是能感觉到,这个最小割并不是这么好通过代码找出来的。所以 Dinic 的大致思路就是通过 DFS 的搜索,找出从源点到汇点的路径,并累加这个流量值从而找到最大值。但是问题是我们无法保证我们的 DFS 一次就能找出真正正确的路径,DFS 的过程中是有可能出现走错路,或者说破坏了其他节点的路径的问题的。比如下面的例子

这张图的源点是 1 汇点是 4 ,每一条边容量都是 1 。由这张图可以看出,最大流明显是 1 -> 2 -> 4 和 1 -> 3 -> 4 即为 2 。但是如果仅仅简单的 DFS 搜搜,很可能就找出了 1 -> 2 -> 3 -> 4 这样的路径,这个路径虽然也算 1 流量,可他的出现堵塞了 1 -> 3 -> 4 这个合法流量了,所以最终得到的答案也就是 1 。这显然是不对的。

所以为了解决这个问题,Dinic 算法加入了反向边 的机制,使得走过的路可以"反悔"。也就是说对于一条边 u -> v ,容量为 w ,我们不仅需要建立一条容量为 w 的 u -> v 的正向边,还需要建立一条容量为 0 的 v -> u 的反向边。反向边的初始容量必须为 0,它代表的是可被退回的流量空间。在 DFS 的过程中,如果某个 f 流量的水流走过了 u -> v 的这条边就要使得正向边减去 f ,而反向边要加上 f 。在图中,w 其实代表着当前这条边的剩余容量。

cpp 复制代码
struct edge{
    //rev是指当前这条边的反向边所在的对应下标位置,方便查找
    int to,rev;
    int w;
};
//邻接表储存图
vector<vector<edge>> g(300);

为了看看反向边的作用,我们可以假设刚刚的图 DFS 真的走出了 1 -> 2 -> 3 -> 4 这样的路径。那么,接下来图就会变成如下这样。

我们再一次从源点沿着现在的图进行 DFS ,此时只能走 1 -> 3 了,但是到了 3 节点突然发现这个 3 -> 2 反向边也可以走,随后代码就走过了一个比较诡异的路径即 1 -> 3 -> 2 -> 4 。总流量算出来是 2 ,没有问题。我们发现虽然这样走并不符合常理,但是实际上 2 -> 3 这个边正向反向边的路径抵消 了,相当于根本就没走过。所以实际上和 1 -> 2 -> 4 和 1 -> 3 -> 4 的走法可以说是在数学上是等价的。

为了让 DFS 不出问题,Dinic 首先是对图上进行一次 BFS 确定当前源点和汇点是连通的,而且对整个图分配深度进行分层。在 DFS 里,我们并不区分正向边和反向边,只要遍历到的边是 w > 0 ,而且是深度符合比当前节点深度加一的就可以尝试往那边进行 DFS 搜索。但还有一个比较重要的优化就是当前弧优化。我们可以想象到,对当前节点我们似乎每次都要遍历一次所有的边,但是这样太慢了,实际上每条边只用遍历一次就可以不再处理了,所以利用 cur 数组,来表达 curi 是当前节点 i 遍历到哪个边的下标,以此继续接下来的遍历,减少遍历次数。

具体操作可查看以下代码及注释。

cpp 复制代码
#include <bits/stdc++.h>
using namespace std;
#define int long long
struct edge{
    int to;
    int rev;
    int w;
};
vector<vector<edge>> g(250);
int n,m,s,t;
//当前弧优化:cur[i]代表的是当前节点i遍历到g上的哪一条边,减少重复遍历开销
int cur[205];
int d[205];//深度
bool bfs(){
    //先初始化
    memset(cur,0,sizeof(cur));
    memset(d,-1,sizeof(d));
    queue<int> q;
    q.push(s);
    d[s]=0;//源点深度为0
    //BFS分配深度
    while(!q.empty()){
        int p=q.front();
        q.pop();
        for(const auto& i:g[p]){
            if(i.w>0&&d[i.to]==-1){
                d[i.to]=d[p]+1;
                q.push(i.to);
            }
        }
    }
    //检查源点和汇点是否连通
    return d[t]!=-1;
}
//DFS逻辑,p代表当前到达哪个节点,lim代表当前的容量限制,因为整条流量路径是受到容量较小边限制的
int dfs(int p,int lim){
    //如果到达汇点或者lim已经限制到0了
    if(p==t||lim<=0) return lim;
    int sum=0;//指当前这个节点传出的总流量
    //当前弧优化,对cur[p]引用,减少遍历次数
    for(int &i=cur[p];i<g[p].size();++i){
        //只有下一个点是深度比当前点大1,且w值还是大于0才能走,不区分正向反向边
        if(g[p][i].w>0&&d[g[p][i].to]==d[p]+1){
            int f=dfs(g[p][i].to,min(lim-sum,g[p][i].w));
            if(f>0){
                //正向边-f,反向边+f
                g[p][i].w-=f;
                g[g[p][i].to][g[p][i].rev].w+=f;//通过rev快速找到反向边下标
                //成功流出了f流量,sum加上f
                sum+=f;
                //如果sum==lim,说明目前这个点的流量已经达到上限了,直接break返回sum值
                if(sum==lim) break;
            }
        }
    }
    if(sum==0) d[p]=-1;//如果分配完sum都一直为0,那么说明这个点已经没有遍历必要了,赋值深度为-1保证后续不会再遍历这个点
    return sum;
}
void solve() {
    cin>>n>>m>>s>>t;
    for(int i=1;i<=m;++i){
        int u,v,w;
        cin>>u>>v>>w;
        //加入正向和反向边
        g[u].push_back({v,(int)g[v].size(),w});
        g[v].push_back({u,(int)g[u].size()-1,0});
    }
    int ans=0;
    while(bfs()){
        ans+=dfs(s,1e9);
    }
    cout<<ans<<'\n';
}

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    cout.tie(nullptr);

    int t = 1;
    // cin >> t;
    while (t--) solve();
    return 0;
}

解题技巧

其实网络流的模板非常简单而且也比较死板,基本上不会有什么其他大变化。难度主要是在做题的时候能够识别出来这道题能用网络流做,接下来看几道题可以稍微了解一点网络流的技巧方法。

题目链接:P2756 飞行员配对方案问题 - 洛谷

这道题看着就是二分图匹配,和网络流似乎没什么关系。但实际上我们可以通过建立一个虚拟源点和一个虚拟汇点 来解决这道题目。按题目意思,是让外籍和英国飞行员两两匹配,我们可以将一个外籍飞行员和一个英国飞行员可配合,看作是一个容量为 1 的有向边(因为只能飞行员匹配一次)。那么我们在建立一个虚拟源点和一个虚拟汇点,使得虚拟源点连接着所有外籍飞行员,所有英国飞行员连接着虚拟汇点,边权都是一个极大值即可。可以看到,在这个图上我们跑一次网络流找最大流其实就是我们要的答案了。

对于 Special Judge 我们也只需要遍历一次外籍飞行员,因为如果产生了配对,那么在图上连接着配对成功的那个边权会归 0 。以此就能找到谁和谁进行了匹配了。

cpp 复制代码
#include <bits/stdc++.h>
using namespace std;
#define int long long
int n,m;
struct edge{
    int to,rev;
    int w;
};
vector<vector<edge>> g(105);
int d[105];
int cur[105];
bool bfs(){
    queue<int> q;
    memset(d,-1,sizeof(d));
    memset(cur,0,sizeof(cur));
    q.push(0);
    d[0]=0;
    while(!q.empty()){
        int t=q.front();
        q.pop();
        for(const auto& i:g[t]){
            if(d[i.to]==-1&&i.w>0){
                d[i.to]=d[t]+1;
                q.push(i.to);
            }
        }
    }
    return d[n+1]!=-1;
}
int dfs(int p,int lim){
    if(p==n+1||lim<=0) return lim;
    int sum=0;
    for(int &i=cur[p];i<g[p].size();++i){
        if(g[p][i].w>0&&d[g[p][i].to]==d[p]+1){
            int f=dfs(g[p][i].to,min(lim-sum,g[p][i].w));
            if(f>0){
                g[p][i].w-=f;
                g[g[p][i].to][g[p][i].rev].w+=f;
                sum+=f;
                if(sum==lim) break;
            }
        }
    }
    if(sum==0) d[p]=-1;
    return sum;
}
void solve() {
    cin>>m>>n;
    int u,v;
    for(int i=1;i<=m;++i){
        g[0].push_back({i,(int)g[i].size(),1});
        g[i].push_back({0,(int)g[0].size()-1,0});
    }
    for(int i=m+1;i<=n;++i){
        g[i].push_back({n+1,(int)g[n+1].size(),1});
        g[n+1].push_back({i,(int)g[i].size()-1,0});
    }
    while(cin>>u>>v){
        if(u==-1&&v==-1) break;
        g[u].push_back({v,(int)g[v].size(),1});
        g[v].push_back({u,(int)g[u].size()-1,0});
    }
    int ans=0;
    while(bfs()){
        ans+=dfs(0,1e9);
    }
    cout<<ans<<'\n';
    for(int i=1;i<=m;++i){
        for(const auto& j:g[i]){
            if(j.to>m&&j.w==0){
                cout<<i<<' '<<j.to<<'\n';
                break;
            }
        }
    }
}

signed main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    cout.tie(nullptr);

    int t = 1;
    // cin >> t;
    while (t--) solve();
    return 0;
}

题目链接:P1345 USACO5.4 奶牛的电信 Telecowmunication - 洛谷

这道题其实看着还是很明显是网络流的,但是问题是他给的图似乎是个无向图 。但看他的意思,因为他是要找出破坏几个点使得不连通,显然是在求最小割,根据我们刚刚的结论可知我们求得最大流即为最小割。但问题是最小割是对于边而言的,而题目是对于点来破坏的,似乎怎么做都不对。

所以这个时候就要使用拆点 法。我们可以对于每一个节点,拆成一个入点和一个出点,入点和出点连接着一个有向边,根据题目意思,这个边的容量就是 1 了。如图所示

入点连接着所有到这个点的入边,出点连接着所有这个点的出边,比如按照题目的意思如果说 1 和 2 节点相连(无向边),就能得到图示的情况。

那么现在,我们不仅解决了无向图的问题,使图变成有向图,可用网络流,而且还解决了题目要求的割"点"的问题。我们只需要将点内部的边权设置为 1 ,将其他的无向边都设置成极大边权即可,因为边是不会出问题的,只有点会出问题。对这个图来一次网络流即可。

cpp 复制代码
//代码中1-n是入点,n+1-2n是对应的出点
#include <bits/stdc++.h>
using namespace std;
#define int long long
struct edge{
    int to,rev;
    int w;
};
vector<vector<edge>> g(300);
int n,m,c1,c2;
int d[300];
int cur[300];
void addedge(int u,int v,int w){
    g[u].push_back({v,(int)g[v].size(),w});
    g[v].push_back({u,(int)g[u].size()-1,0});
}
bool bfs(){
    memset(d,-1,sizeof(d));
    memset(cur,0,sizeof(cur));
    queue<int> q;
    q.push(c1+n);
    d[c1+n]=0;
    while(!q.empty()){
        int t=q.front();
        q.pop();
        for(const auto& i:g[t]){
            if(i.w>0&&d[i.to]==-1){
                d[i.to]=d[t]+1;
                q.push(i.to);
            }
        }
    }
    return d[c2]!=-1;
}
int dfs(int p,int lim){
    if(p==c2||lim<=0) return lim;
    int sum=0;
    for(int &i=cur[p];i<g[p].size();++i){
        if(g[p][i].w>0&&d[g[p][i].to]==d[p]+1){
            int f=dfs(g[p][i].to,min(lim-sum,g[p][i].w));
            if(f>0){
                g[p][i].w-=f;
                g[g[p][i].to][g[p][i].rev].w+=f;
                sum+=f;
                if(sum==lim) break;
            }
        }
    }
    if(sum==0) d[p]=-1;
    return sum;
}
void solve() {
    cin>>n>>m>>c1>>c2;
    for(int i=1;i<=n;++i){
        addedge(i,i+n,1);
    }
    for(int i=1;i<=m;++i){
        int a,b;
        cin>>a>>b;
        addedge(a+n,b,1e9);
        addedge(b+n,a,1e9);
    }
    int ans=0;
    while(bfs()){
        ans+=dfs(c1+n,1e9);
    }
    cout<<ans<<'\n';
}
signed main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    cout.tie(nullptr);

    int t = 1;
    // cin >> t;
    while (t--) solve();
    return 0;
}
相关推荐
千谦阙听1 小时前
【 C++篇】:模板初阶——泛型编程、函数模板与类模板
开发语言·c++·学习·visual studio
我不会起名字3221 小时前
一天一道力扣Hot100(37):深度优先算法--括号生成
java·数据结构·c++·后端·python·算法·go
m0_734571761 小时前
深入理解C++ 多态<三>(Polymorphism)模板方法模式
开发语言·c++
foolishlee1 小时前
SCRAM-SHA-256
数据库·算法·postgresql
Rabitebla2 小时前
【Linux 系统编程】权限(一):身份、提权,和那 9 个权限位
linux·数据结构·c++·算法
艾莉丝努力练剑2 小时前
【AI大模型接入SDK】SQLite基础概念与C的API开发
网络·c++·人工智能·学习·架构
東隅已逝,桑榆非晚2 小时前
List(类函数学习)
c++·笔记·学习
Dr_Fourier2 小时前
AWQ量化
c++·人工智能·pytorch·ai
-dzk-2 小时前
【回溯】LC 22.括号生成
算法·回溯