P3376 【模板】网络最大流

一、题目

如题,给出一个网络图,以及其源点和汇点,求出其网络最大流。

输入格式

第一行包含四个正整数n,m,s,t,分别表示点的个数、有向边的个数、源点序号、汇点序号。

接下来 m 行每行包含三个正整数 ui,vi,wi,表示第 i 条有向边从 ui 出发,到达 vi,边权为 wi(即该边最大流量为 wi)。

输出格式

一行,包含一个正整数,即为该网络的最大流。

二、思路

(一)网络流初步

这里主要讨论一下网络流算法可能会涉及到的一些概念性问题

  • 定义

对于任意一张有向图(也就是网络),其中有N个点、M条边以及源点S和汇点T

然后我们把c(x,y)称为边的容量

  • 转换

为了通俗易懂,我们来结合生活实际理解上面网络的定义:

将有向图理解为我们城市的水网,有N户家庭、M条管道以及供水点S和汇合点T

  • 流函数

和上面的c差不多,我们把f(x,y)称为边的流量,则f称为网络的流函数,它满足三个条件:

  1. s(x,y)c(x,y)

  2. f(x,y)=f(y,x)

  3. x≠S,x≠T, (u,x)Ef(u,x)=(x,v)Ef(x,v)

这三个条件其实也是流函数的三大性质:

  1. 容量限制:每条边的流量总不可能大于该边的容量的(不然水管就爆了)

  2. 斜对称:正向边的流量=反向边的流量(反向边后面会具体讲)

  3. 流量守恒:正向的所有流量和=反向的所有流量和(就是总量始终不变)

  • 残量网络

在任意时刻,网络中所有节点以及剩余容量大于00的边构成的子图被称为残量网络

(二)最大流

对于上面的网络,合法的流函数有很多,其中使得整个网络流量之和最大的流函数称为网络的最大流,此时的流量和被称为网络的最大流量

最大流能解决许多实际问题,比如:一条完整运输道路(含多条管道)的一次最大运输流量,还有二分图(蒟蒻还没学二分图,学了之后会更新的qwq)

下面就来介绍计算最大流的两种算法:EK增广路算法和Dinic算法

(三)EdmondsKarp增广路算法

  • 首先来讲增广路是什么:

若一条从S到T的路径上所有边的剩余容量都大于0,则称这样的路径为一条增广路(剩余流量:c(x,y)f(x,y))

  • 然后就是EK算法的核心思想啦:

显然我们可以让一股流沿着增广路从S流到T,然后使网络的流量增大

EK算法的思想就是不断用BFS寻找增广路并不断更新最大流量值,直到网络上不存在增广路为止

  • 再来讲理论实现过程:

BFS寻找一条增广路时,我们只需要考虑剩余流量不为0的边,然后找到一条从S到T的路径,

同时计算出路径上各边剩余容量值的最小值dis,则网络的最大流量就可以增加dis(经过的正向边容量值全部减去dis,反向边全部加上dis

  • 反向边

插入讲解一下反向边这个概念,这是网络流中的一个重点

为什么要建反向边?

因为可能一条边可以被包含于多条增广路径,所以为了寻找所有的增广路经我们就要让这一条边有多次被选择的机会

 而构建反向边则是这样一个机会,相当于给程序一个反悔的机会!

为什么是反悔?

因为我们在找到一个dis后,就会对每条边的容量进行减法操作,而直接更改值就会影响到之后寻找另外的增广路

  • 邻接表“成对存储”

我们将正向边和反向边存在“2和3”、“4和5”、“6和7”····

为什么?

因为在更新边权的时候,我们就可以直接使用xor1的方式,找到对应的正向边和反向边(奇数异或1相当于-1,偶数异或1相当于+1

代码实现如下(整个更新边权的操作函数):

复制代码
inline void update() {
    int x=t;
    while(x!=s) {
        int v=pre[x];
        e[v].val-=dis[t];
        e[v^1].val+=dis[t];
        x=e[v^1].to;
    }
    ans+=dis[t];
}
复制代码
  • 适用范围

时间复杂度为O(nm2),一般能处理103~104规模的网络(n点的个数,m有向边的个数

  • 代码
复制代码
#include <bits/stdc++.h>
using namespace std;
int n, m, s, t, u, v;
/*
n表示图中的节点数;
m表示图中的边数;
s表示源点;
t表示汇点;
u、v、w表示一条边的起点、终点和边权;
*/
long long w, ans, dis[520010];
/*
ans表示最大流;
dis数组表示从源点到每个节点的最短路长度;
*/
int tot = 1, vis[520010], pre[520010], head[520010], flag[2510][2510];
/*
tot表示边的编号;
vis数组表示节点是否被访问过;
pre数组表示增广路上每个节点的前驱;
head数组表示每个节点的第一条边;
flag数组表示两个节点之间是否有边。
*/

struct node
{
    int to, net;
    long long val;
} e[520010];
// 结构体node,它表示一条边,其中to表示边的终点,net表示下一条边的编号,val表示边的权值。
// e[520010]表示边的数组,用来存储所有的边。

// 函数add,用来添加一条边。
inline void add(int u, int v, long long w)
{ // 它的参数u、v、w分别表示边的起点、终点和权值。
    e[++tot].to = v;
    e[tot].val = w;
    e[tot].net = head[u];
    head[u] = tot;
    e[++tot].to = u;
    e[tot].val = 0; // 具体实现是将这条边的正向边和反向边都加入到e数组中,并将它们的net和head设置为对方的编号和终点,flag数组用来判断是否有重边。
    e[tot].net = head[v];
    head[v] = tot;
}

// 函数bfs,用来寻找增广路。
inline int bfs() // 通过BFS遍历图,找到从源点到汇点的一条增广路。
{                // bfs寻找增广路
    for (register int i = 1; i <= n; i++)
        vis[i] = 0;
    queue<int> q;
    q.push(s);
    vis[s] = 1;
    dis[s] = 2005020600;
    while (!q.empty())
    {
        int x = q.front();
        q.pop();
        for (register int i = head[x]; i; i = e[i].net)
        {
            if (e[i].val == 0) // 在遍历的过程中,如果发现一条边的剩余流量为0或者已经访问过,就跳过这条边。
                continue;      // 我们只关心剩余流量>0的边
            int v = e[i].to;
            if (vis[v] == 1)
                continue; // 这一条增广路没有访问过
            dis[v] = min(dis[x], e[i].val);
            pre[v] = i; // 记录前驱,方便修改边权
            q.push(v);
            vis[v] = 1;
            if (v == t)   // 如果找到了一条增广路,就返回1。
                return 1; // 找到了一条增广路
        }
    }
    return 0;
}

// 函数update,用来更新增广路。
inline void update()
{ // 更新所经过边的正向边权以及反向边权
    int x = t;
    while (x != s)
    {
        int v = pre[x]; // 通过pre数组找到一条增广路,然后修改这条增广路上的边的权值,同时累加每一条增广路经过的最小流量值。
        e[v].val -= dis[t];
        e[v ^ 1].val += dis[t];
        x = e[v ^ 1].to;
    }
    ans += dis[t]; // 累加每一条增广路经的最小流量值
}

// 整个代码的实现思路是先将所有的边都加入到e数组中,然后不断地寻找增广路,直到不存在增广路为止。
int main()
{
    scanf("%d%d%d%d", &n, &m, &s, &t);
    for (register int i = 1; i <= m; i++) // 读入图的信息
    {
        scanf("%d%d%lld", &u, &v, &w);
        if (flag[u][v] == 0)
        { // 处理重边的操作(加上这个模板题就可以用Ek算法过了)
            add(u, v, w);
            flag[u][v] = tot;
        }
        else
        {
            e[flag[u][v] - 1].val += w;
        }
    }
    while (bfs() != 0) // 不断地寻找增广路,直到不存在增广路为止。
    {                  // 直到网络中不存在增广路
        update();
    }
    printf("%lld", ans); // 最后输出最大流。
    system("pause");
    return 0;
}
复制代码

(四)Dinic算法

EK算法每次都可能会遍历整个残量网络,但只找出一条增广路

是不是有点不划算?能不能一次找多条增广路呢?

答案是可以的:Dinic算法

  • 分层图&DFS

根据BFS宽度优先搜索,我们知道对于一个节点x,我们用d[x]来表示它的层次,即S到x最少需要经过的边数。在残量网络中,满足d[y]=d[x]+1的边(x,y)构成的子图被称为分层图(相信大家已经接触过了吧),而分层图很明显是一张有向无环图

为什么要建分层图?

讲这个原因之前, 我们还要知道一点:Dinic算法还需要DFS

DFS中,从S开始,每次我们向下一层次随便找一个点,直到到达T,然后再一层一层回溯回去,继续找这一层的另外的点再往下搜索

这样就满足了我们同时求出多条增广路的需求!

  • Dinic算法框架
  1. 在残量网络上BFS求出节点的层次,构造分层图

  2. 在分层图上DFS寻找增广路,在回溯时同时更新边权

  • 适用范围

时间复杂度:O(n2m),一般能够处理104~105规模的网络

相较于EK算法,显然Dinic算法的效率更优也更快:虽然在稀疏图中区别不明显,但在稠密图中Dinic的优势便凸显出来了(所以Dinic算法用的更多)

此外,Dinic算法求解二分图最大匹配的时间复杂度为O(mn1/2)

  • 代码

这份代码是本模板题的AC代码,但是使用到了Dinic算法的两个优化:当前弧优化+剪枝

复制代码
#include <bits/stdc++.h>
using namespace std;
const long long inf = 2005020600; // inf表示正无穷
int n, m, s, t, u, v;
long long w, ans, dis[520010]; // dis数组表示每个点到源点的距离
int tot = 1, now[520010], head[520010];
// tot表示边数,now数组表示当前弧优化的位置,head数组表示每个点的第一条边的编号

struct node
{
    int to, net;   // to表示边的终点,net表示下一条边的编号
    long long val; // val表示边的容量
} e[520010];

inline void add(int u, int v, long long w) // 添加一条边
{
    e[++tot].to = v;
    e[tot].val = w;
    e[tot].net = head[u];
    head[u] = tot;

    e[++tot].to = u;
    e[tot].val = 0;
    e[tot].net = head[v];
    head[v] = tot;
}

inline int bfs()                          // 在残量网络中构造分层图
{                                         // 在惨量网络中构造分层图
    for (register int i = 1; i <= n; i++) // 初始化距离数组
        dis[i] = inf;
    queue<int> q;
    q.push(s);
    dis[s] = 0;
    now[s] = head[s];
    while (!q.empty())
    {
        int x = q.front();
        q.pop();
        for (register int i = head[x]; i; i = e[i].net)
        {
            int v = e[i].to;
            if (e[i].val > 0 && dis[v] == inf) // 如果该边还有容量,并且该点还没有被访问过
            {
                q.push(v);
                now[v] = head[v];
                dis[v] = dis[x] + 1; // 更新距离
                if (v == t)
                    return 1; // 如果已经到达汇点,返回1表示找到了增广路
            }
        }
    }
    return 0; // 返回0表示已经没有增广路了
}

inline int dfs(int x, long long sum) // 寻找增广路
{                                    // sum是整条增广路对最大流的贡献
    if (x == t)                      // 如果已经到达汇点,返回增广路的流量
        return sum;
    long long k, res = 0;                                 // k是当前最小的剩余容量//k表示当前最小的剩余容量,res表示经过该点的所有流量和
    for (register int i = now[x]; i && sum; i = e[i].net) // 当前弧优化
    {
        now[x] = i; // 当前弧优化
        int v = e[i].to;
        if (e[i].val > 0 && (dis[v] == dis[x] + 1)) // 如果该边还有容量,并且该点在分层图中的层数比x大1
        {
            k = dfs(v, min(sum, e[i].val)); // 递归寻找增广路
            if (k == 0)                     // 如果已经找到了增广路,将该点的距离设为正无穷,以便下次寻找增广路时不再访问该点
                dis[v] = inf;               // 剪枝,去掉增广完毕的点
            e[i].val -= k;                  // 更新正向边的容量
            e[i ^ 1].val += k;              // 更新反向边的容量
            res += k;                       // res表示经过该点的所有流量和(相当于流出的总量)//更新经过该点的所有流量和
            sum -= k;                       // sum表示经过该点的剩余流量//更新剩余流量
        }
    }
    return res; // 返回经过该点的所有流量和
}

int main()
{
    scanf("%d%d%d%d", &n, &m, &s, &t);
    for (register int i = 1; i <= m; i++)
    {
        scanf("%d%d%lld", &u, &v, &w);
        add(u, v, w); // 添加一条边
    }
    while (bfs())
    {
        ans += dfs(s, inf); // 流量守恒(流入=流出)// 寻找增广路
    }
    printf("%lld", ans); // 输出最大流
    system("pause");
    return 0;
}
复制代码
  • 当前弧优化

对于一个节点x,当它在DFS中走到了第i条弧时,前i1条弧到汇点的流一定已经被流满而没有可行的路线了

那么当下一次再访问x节点时,前i1条弧就没有任何意义了

所以我们可以在每次枚举节点x所连的弧时,改变枚举的起点,这样就可以删除起点以前的所有弧,来达到优化剪枝的效果

对应到代码中,就是now数组

 

posted @   ImreW  阅读(16)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 全程不用写代码,我用AI程序员写了一个飞机大战
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· 记一次.NET内存居高不下排查解决与启示
· DeepSeek 开源周回顾「GitHub 热点速览」
· 白话解读 Dapr 1.15:你的「微服务管家」又秀新绝活了
点击右上角即可分享
微信分享提示