[图论入门]网络最大流 - 增广路算法

#1.0 基本概念

先来介绍一下这个基本概念。

网络流是算法竞赛中的一个重要的模型,它分为两部分:网络

网络,其实就是一张有向图,其上的边权称为容量。额外地,它拥有一个源点汇点

,顾名思义,就像水流或电流,也具有它们的性质。如果把网络想象成一个自来水管道网络,那流就是其中流动的水。每条边上的流不能超过它的容量,并且对于除了源点和汇点外的所有点(即中继点),流入的流量都等于流出的流量。

源点可以无限量的向外提供流量,而汇点可以无限量的接受流量。

#2.0 最大流

这是一个比较常见的问题,也是本篇博客主要讨论的问题。

假设源点提供的流量足够多,问汇点最多可以接收到多少流量。

#2.1 Ford-Fullkerson 算法

\(\texttt{Ford-Fullkerson}\) 算法(\(\texttt{FF}\) 算法)是一个最大流的基础算法,其核心思想为寻找图中的增广路(Augmenting Path),实际就是网络中从源点到汇点的仍有剩余流量的路径。

我们来看一个例子:

在上图中,\(1\to3\to2\to4\) 是一条增广路,我们可以用这条路来更新残量网络,这时网络变为了

但是不难发现,我们如果开始选择 \(1\to3\to4,\ 1\to2\to4\) 这两条增广路,所得到的答案会更优,所以我们如果想要反悔这一操作,那么,我们可以加入反向边

反向边最初的容量为 \(0\),但是如果该反向边对应的边容量减少了 \(a\),那么就给反向边的容量加上 \(a\),这样我们就可以进行反悔,上面的例子变为

于是就有了这样一条增广路

然后我们这张网络的最大流就求得了。

\(\texttt{Ford-Fullkerson}\) 找增广路的过程是 \(\texttt{DFS}\),这里不加以实现。因为一(jue)般(dui)用不到。

#2.2 Edmond-Karp 算法

实际上,\(\texttt{Edmond-Karp}\) 算法(\(\texttt{EK}\) 算法)本身只是 \(\texttt{FF}\) 算法的 \(\texttt{BFS}\) 实现。但由于 \(\texttt{DFS}\) 找到的增广路可能是七拐八绕的,而 \(\texttt{BFS}\) 却可以保证找到的增广路是最短的,因而在实际的时间复杂度上有了一定的优化,而且省去了递归导致的一系列问题。

当然,反向边的编号可以是运用成对变换的方法,即 \(0\ \hat{}\ 1=1,1\ \hat{}\ 1=0,2\ \hat{}\ 1=3,3\ \hat{}\ 1=1,\cdots\) 同时,我们也需要记录我们找到的增广路,为了更新残量网络。时间复杂度为 \(O(ve^2).\)

const ll N = 100010;
const int INF = 0x3fffffff;

struct Edge{
    int u,v;
    int nxt;
    ll w;
};
Edge e[N << 1];

ll n,m,cnt,head[N],vis[N],incf[N],pre[N],s,t,maxflow;

inline ll Min(const ll &a,const ll &b){
    return a < b ? a : b;
}

inline void add(const int &u,const int &v,const ll &w){
    e[cnt].u = u;e[cnt].v = v;e[cnt].w = w;
    e[cnt].nxt = head[u];head[u] = cnt ++;

    e[cnt].u = v;e[cnt].v = u;e[cnt].w = 0;
    e[cnt].nxt = head[v];head[v] = cnt ++;
}

inline bool EK(){
    mset(vis,0);queue <int> q;
    q.push(s);vis[s] = true;
    incf[s] = INF;
    while (q.size()){
        int x = q.front();q.pop();
        for (int i = head[x];i != -1;i = e[i].nxt)
          if (e[i].w){
              if (vis[e[i].v]) continue;
              incf[e[i].v] = Min(incf[x],e[i].w);
              pre[e[i].v] = i;
              q.push(e[i].v);vis[e[i].v] = true;
              if (e[i].v == t) return true;
          }
    }
    return false;
}

inline void update(){
    int x = t;
    while (x != s){
        int i = pre[x];
        e[i].w -= incf[t];
        e[i ^ 1].w += incf[t];
        x = e[i ^ 1].v;
    }
    maxflow += incf[t];
}

int main(){
    mset(head,-1);
    scanf("%d%d%d%d",&n,&m,&s,&t);
    for (int i = 1;i <= m;i ++){
        int u,v;ll w;
        scanf("%d%d%lld",&u,&v,&w);
        add(u,v,w);
    }
    while (EK()) update();
    printf("%lld",maxflow);
    return 0;
}

#2.3 Dinic 算法

\(\texttt{EK}\) 算法似乎已经能满足我们了。。。吗?如果是稠密图,看起来 \(\texttt{EK}\) 算法就要爆炸了,那么我们就需要更优的算法。

\(\texttt{Dinic}\) 算法采用了分层图的思想,将图中的每一个点按照距离源点的远近进行分层。每次查找增广路时仅找比当前节点层数加一的节点进行扩展。

分层时采用 \(\texttt{BFS}\),保证分层远近的正确性,一个点能被分层的前提是通向这个点的边仍有残量,这样可以保证没有残量的边不会被遍历,当我们分层到汇点就可以结束了,因为其他的没被分层的点所在层数必然比汇点要远,根据我们上面的实现思路,这样的点是不会扩展到汇点的。

扩展采用 \(\texttt{DFS}\) 实现,所以不用 update() 函数,可直接进行更新。注意进行一次分层后可以进行多次找增广路的操作,直到不能找到新的流量。

还有一点小优化:

  • 如果在 \(\texttt{DFS}\) 的过程中,发现某一点返回的可拓展的流量为 \(0\),那么就可以将该点的层数设置为 \(0\),因为显然已经不可能通过该点更新残量网络了。
  • 记录一个 now[x],表示点 \(x\) 当前可以从哪一条相连的边开始进行探索,在后面的过程中,到点 \(x\) 便可以从编号为 now[x] 的边开始。原因也很简单:探索某条边时必然会将当前分层图上从这条边能得到的流量都拿到,之后再次探索这条边就没有意义了。
const int N = 100010;
const int INF = 0x3fffffff;

struct Edge{
    int u,v;
    int nxt;
    ll val;
};
Edge e[N << 1];

int n,m,s,t;
ll maxflow;
int cnt,head[N],d[N],now[N];
queue <int> q;

inline void add(const int &u,const int &v,const ll &w){
    e[cnt].u = u;e[cnt].v = v;e[cnt].val = w;
    e[cnt].nxt = head[u];head[u] = cnt ++;

    e[cnt].u = v;e[cnt].v = u;e[cnt].val = 0;
    e[cnt].nxt = head[v];head[v] = cnt ++;
}

inline bool bfs(){
    mset(d,0);
    while (q.size()) q.pop();
    q.push(s);d[s] = 1;now[s] = head[s];
    while (q.size()){
        int x = q.front();q.pop();
        for (int i = head[x];i != -1;i = e[i].nxt)
          if (e[i].val && !d[e[i].v]){
              q.push(e[i].v);
              now[e[i].v] = head[e[i].v];
              d[e[i].v] = d[x] + 1;
              if (e[i].v == t) return true;
          }
    }
    return false;
}

inline ll dinic(int x,ll flow){
    if (x == t) return flow;
    ll rest = flow,k,i;
    for (i = now[x];(i != -1) && rest;i = e[i].nxt){
        if (e[i].val && d[e[i].v] == d[x] + 1){
            k = dinic(e[i].v,min(rest,e[i].val));
            if (!k) d[e[i].v] = 0;
            e[i].val -= k;e[i ^ 1].val += k;
            rest -= k;
        }
        now[x] = i;
    }
    return flow - rest;
}

int main(){
    mset(head,-1);
    scanf("%d%d%d%d",&n,&m,&s,&t);
    for (int i = 1;i <= m;i ++){
        int u,v;ll w;
        scanf("%d%d%lld",&u,&v,&w);
        add(u,v,w);
    }
    ll flow = 0;
    while (bfs()) while (flow = dinic(s,INF))
      maxflow += flow;
    printf("%lld",maxflow);
    return 0;
}

时间复杂度为 \(O(v^2e)\)。值得注意的一点是,\(\texttt{Dinic}\) 算法在 二分图最大匹配 上使用的时间复杂度是 \(O(v\sqrt e)\),优于匈牙利算法。

#3.0 其他应用

#3.1 二分图匹配最大匹配

对于一张二分图,我们将左图和右图之间的边容量设为 \(1\),在左边加一个源点,向左图所有点连一条容量为 \(1\) 的边,在右边加汇点,所有右图的点向汇点连一条容量为 \(1\) 的边,在该图上跑最大流,得到的结果就是该图的最大匹配。

正确性显然,不证。

#3.2 最大流最小割定理

#3.2.1 概念

:对于一个网络流图 \(G=(V,E)\),割定义为一种点的划分方式:将所有的点分为 \(S\)\(T=V-S\) 两个集合,其中 \(s\in S,t\in T.\)

割的容量:定义割的容量 \(c(S,T)\) 为所有从 \(S\)\(T\) 的边的容量和,即

\[c(S,T)=\sum\limits_{s\in S,v\in T}c(u,v) \]

#3.2.2 定理

最小割的容量等于最大流流量

证明:

\(s\)\(t\) 的净流量为

\[|f|=f(S,T)=\sum\limits_{u\in S,v\in T}f(u,v)\leq \sum\limits_{s\in S,v\in T}c(u,v)=c(S,T). \]

这里的割 \((S,T)\) 的选取是任意的,于是得到结论:对于任意 \(s-t\)\(f\) 和任意 \(s-t\)\((S,T)\),有 \(|f|\leq c(S,T).\)

假设我们已经找到了最大流,即残量网络中不存在增广路,也就是说 \(s\)\(t\) 不再联通,那我们将从 \(s\) 出发仍可到达(所经边容量未满)的点的集合看做 \(S\),令 \(T=V-S\),那么在残量网络中 \(S\)\(T\) 分离,显然在原图中跨越 \(S\)\(T\) 的边都应满载,且没有从 \(T\) 回到 \(S\) 的流量(假设有,那么反向边容量大于 \(0\),可以 被到达),所以此时有

\[|f|=\sum\limits_{u\in S,v\in T}f(u,v)=\sum\limits_{s\in S,v\in T}c(u,v)=c(S,T). \]

结合上面的不等式,可知此时的割为最小割。

#4.0 例题

#4.1 P1343 地震逃生

最大流的模板题。

const int N = 100010;
const int INF = 0x3fffffff;

struct Edge{
    int u,v;
    int nxt;
    ll val;
};
Edge e[N << 1];

int n,m,s,t,O;
ll maxflow;
int cnt,head[N],d[N],now[N];
queue <int> q;

inline void add(const int &u,const int &v,const ll &w){
    e[cnt].u = u;e[cnt].v = v;e[cnt].val = w;
    e[cnt].nxt = head[u];head[u] = cnt ++;

    e[cnt].u = v;e[cnt].v = u;e[cnt].val = 0;
    e[cnt].nxt = head[v];head[v] = cnt ++;
}

inline bool bfs(){
    mset(d,0);
    while (q.size()) q.pop();
    q.push(s);d[s] = 1;now[s] = head[s];
    while (q.size()){
        int x = q.front();q.pop();
        for (int i = head[x];i != -1;i = e[i].nxt)
          if (e[i].val && !d[e[i].v]){
              q.push(e[i].v);
              now[e[i].v] = head[e[i].v];
              d[e[i].v] = d[x] + 1;
              if (e[i].v == t) return true;
          }
    }
    return false;
}

inline ll dinic(int x,ll flow){
    if (x == t) return flow;
    ll rest = flow,k,i;
    for (i = now[x];(i != -1) && rest;i = e[i].nxt){
        if (e[i].val && d[e[i].v] == d[x] + 1){
            k = dinic(e[i].v,min(rest,e[i].val));
            if (!k) d[e[i].v] = 0;
            e[i].val -= k;
            e[i ^ 1].val += k;
            rest -= k;
        }
        now[x] = i;
    }
    return flow - rest;
}

int main(){
    mset(head,-1);
    scanf("%d%d%d",&n,&m,&O);
    s = 1;t = n;
    for (int i = 1;i <= m;i ++){
        int u,v;ll w;
        scanf("%d%d%lld",&u,&v,&w);
        add(u,v,w);
    }
    ll flow = 0;
    while (bfs())
      while (flow = dinic(s,INF))
        maxflow += flow;
    if (maxflow){
        printf("%lld ",maxflow);
        printf("%d",O / maxflow + (O % maxflow ? 1 : 0));
    }
    else printf("Orz Ni Jinan Saint Cow!");
    return 0;
}

#4.2 P1345 [USACO5.4]奶牛的电信

这题是让我们求将两点分开需删掉的最少点数,联想到最小割问题,但是注意到网络流是在边上进行操作,所以不能直接采用最大流求最小割。

将点转化为边的经典操作便是拆点,即将一个点拆成两个点:入点分点,中间由一条有向边连接,所有终点为该点的边连向该点拆出的入点,所有起点为该点的边由该点拆出的出点连出。要注意,需要将无向边转化为两条有向边,图中的源点和汇点不需要拆点。

我们的策略是这样:原图中的边容量设为 \(\infty\),拆点造成的边容量设为 \(1\),在这张新图上求最小割。

const int N = 100010;
const int INF = 0x3fffffff;

struct Edge{
    int u,v;
    ll w;
    int nxt;
};
Edge e[N << 2];

int n,m,s,t,cnt = 2,head[N];
int q[N << 2],frt,tal,d[N],now[N];
ll maxflow;

inline void add(const int &u,const int &v,const ll &w){
    e[cnt].u = u;e[cnt].v = v;e[cnt].w = w;
    e[cnt].nxt = head[u];head[u] = cnt ++;

    e[cnt].u = v;e[cnt].v = u;e[cnt].w = 0;
    e[cnt].nxt = head[v];head[v] = cnt ++;
}

inline bool bfs(){
    mset(d,0);
    frt = 0,tal = -1;
    q[++ tal] = s;d[s] = 1,now[s] = head[s];
    while (frt <= tal){
        int x = q[frt ++];
        for (int i = now[x];i;i = e[i].nxt){
            if (e[i].w && !d[e[i].v]){
              q[++ tal] = e[i].v;
              now[e[i].v] = head[e[i].v];
              d[e[i].v] = d[x] + 1;
              if (e[i].v == t) return true;
          }
        }
          
    }
    return false;
}

inline ll dinic(int x,ll flow){
    if (x == t) return flow;
    ll rest = flow,k,i;
    for (i = now[x];i && rest;i = e[i].nxt){
        if (e[i].w && d[e[i].v] == d[x] + 1){
            k = dinic(e[i].v,rest < e[i].w ? rest : e[i].w);
            if (!k) d[e[i].v] = 0;
            e[i].w -= k,e[i ^ 1].w += k,rest -= k;
        }
        now[x] = i;
    }
    return flow - rest;
}

int main(){
    scanf("%d%d%d%d",&n,&m,&s,&t);
    for (int i = 1;i <= n;i ++)
      if (i != s && i != t) add(i,i + n,1);
    for (int i = 1;i <= m;i ++){
        int x,y;scanf("%d%d",&x,&y);
        add((x == s || x == t) ? x : x + n,y,INF);
        add((y == s || y == t) ? y : y + n,x,INF);
    }
    ll flow = 0;
    while (bfs()) while (flow = dinic(s,INF))
      maxflow += flow;
    printf("%lld",maxflow);
    return 0;
}

#4.3 P1402 酒店之王

第一眼的印象是二分图匹配,但发现实际是三个物品进行匹配(三分图最大匹配(bushi)?),我们发现房间和菜之间没有直接联系,只能靠客人联系起来,所以考虑将房间和菜分别看做点放在两边,客人看做点放在中间。

客人 \(a\) 如果喜欢房间 \(b\),就连边 \(b\to a\),如果喜欢菜品 \(c\),就连边 \(a\to c\),最后左边加源点连向房间,菜品连向右边汇点,所有边的容量都设为 \(1.\)

然后就结束了。。。吗?

我们发现,现在只限制了房间和菜品仅能被选一次,但是客人可能会造成重复贡献,如:

正确的答案应当是 \(1\),但该网络的最大流为 \(2\),原因是同一个客人被算了两次,那么我们就需要对客人这个点增加限制,这个点的容量只能为 \(1\),但是网络流只能处理边权,所以再次考虑拆点,将一个客人变成一条容量为 \(1\) 的边,再在这张图上跑最大流,就没有问题了。

const int N = 100010;
const int INF = 0x3fffffff;

struct Edge{
    int u,v;
    ll val;
    int nxt;
};
Edge e[N << 1];

int n,p,q,cnt = 2,head[N],s,t;
int qx[N],frt,tal,d[N],now[N];
ll maxflow;

inline void add(const int &u,const int &v,const int &w){
    e[cnt].u = u,e[cnt].v = v,e[cnt].val = w;
    e[cnt].nxt = head[u],head[u] = cnt ++;

    e[cnt].u = v,e[cnt].v = u,e[cnt].val = 0;
    e[cnt].nxt = head[v],head[v] = cnt ++;
}

inline bool bfs(){
    mset(d,0);frt = 0,tal = -1;
    qx[++ tal] = s;d[s] = 1;now[s] = head[s];
    while (frt <= tal){
        int x = qx[frt ++];
        for (int i = head[x];i;i = e[i].nxt)
          if (e[i].val && !d[e[i].v]){
              qx[++ tal] = e[i].v;
              now[e[i].v] = head[e[i].v];
              d[e[i].v] = d[x] + 1;
              if (e[i].v == t) return true;
          }
    }
    return false;
}

inline ll dinic(int x,ll flow){
    if (x == t) return flow;
    ll rest = flow,k,i;
    for (i = now[x];i && rest;i = e[i].nxt){
        if (e[i].val && d[e[i].v] == d[x] + 1){
            k = dinic(e[i].v,min(rest,e[i].val));
            if (!k) d[e[i].v] = 0;
            e[i].val -= k;
            e[i ^ 1].val += k;
            rest -= k;
        }
        now[x] = i;
    }
    return flow - rest;
}

int main(){
    scanf("%d%d%d",&n,&p,&q);
    for (int i = 1;i <= n;i ++)
      add(p + i,n + p + q + i,1);
    for (int i = 1;i <= n;i ++)
      for (int j = 1;j <= p;j ++){
          int x;scanf("%d",&x);
          if (x) add(j,p + i,1);
      }
    for (int i = 1;i <= n;i ++)
      for (int j = 1;j <= p;j ++){
          int x;scanf("%d",&x);
          if (x) add(n + p + q + i,n + p + j,1);
      }
    s = 0,t = 2 * n + p + q + 1;
    for (int i = 1;i <= p;i ++) add(s,i,1);
    for (int i = 1;i <= q;i ++) add(n + p + i,t,1);
    ll flow = 0;
    while (bfs()) while (flow = dinic(s,INF))
      maxflow += flow;
    printf("%lld",maxflow);
    return 0;
}

参考资料

[1] 初探网络流:dinic/EK算法学习笔记 - hyfhaha

[2] 算法学习笔记(28): 网络流 - Pecco

[3] 最小割 - OI Wiki

posted @ 2021-06-18 20:25  Dfkuaid  阅读(536)  评论(0编辑  收藏  举报