『网络流算法及其运用』

<更新提示>

本文将重点从运用角度讲解网络流,不会过多阐述网络流基本理论,只讲必要的理论内容,更详细的理论内容可以参考算法导论。

<第一次更新>


<正文>

网络流的定义

一个流网络\(G=(V,E)\)为一张满足以下条件的有向图

  • 每一条边有一个非负容量,即对于任意\(E\)中的\((u,v)\) , 有\(c(u,v)≥0\)
  • 如果\(G\)中存在边\((u,v)\),那么不存在\((v,u)\) 。我们将图中不存在的边的容量定为\(0\)
  • 图中含有两个特殊节点:源\(s\)与汇\(t\)

一个流\(f\)是定义在节点二元组\((u\in V,v\in V)\)上的实数函数,满足以下两个个性质:

  • 容量限制:对于任意\((u,v)\),满足\(0\leq f(u,v)\leq c(u,v)\)
  • 流量守恒:对于任何非源汇的中间节点\(u\),有\(\sum_{v\in V}f(v,u)=\sum_{v\in V}f(u,v)\)

一个流\(f\)流量\(|f|\)定义为:\(|f|=\sum_{v\in V}f(s,v)-\sum_{v\in V}f(v,s)\)

最大流问题

定义

由于图\(G\)中不存在反向边,所以在我们一般只关注流量定义式的前半部分,即:\(|f|=\sum_{v\in V}f(s,v)\)

那么对于一个网络\(G\),我们称\(\max\{|f|\}=\max\{\sum_{v\in V}f(s,v)\}\)为这个网络的最大流

预备知识

残量网络:对于网络\(G\),其残量网络\(G_f\)\(G\)的差别在于每条边的边容量修改为\(G\)中边容量减去当前流的该边流量。具体来说,\(c_f(u,v)=c(u,v)-f(u,v)\)

另外,残量网络中还包含原图中所有边的反向边,容量等同于正向边在\(f\)中当前流量,用于"反悔"时将流送回起点\(c_f(v,u)=f(u,v)\)

简单的理解,残量网络就是原网络流了一股流以后剩下的网络,容量也对应的相减。

而反向边的含义就是退流的机会,容易得知存在反向边的流网络才是和原网络等价的,有了反向边我们才能正确地实现下文的网络流算法。

增广:设\(f\)为网络\(G\)上的一个流,\(f'\)为残量网络\(G_f\)上的一个流,那么定义增广后的网络为:
\[(f↑ f')(u,v)=\begin{cases}f(u,v)+f'(u,v)-f'(v,u)\ \ \ \ \ if\ (u,v)\in E \\ 0\ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \mathrm{otherwise}\end{cases}\]

引理\(1\):增广后网络的流量等于两个流量直接相加,即:\(|f↑ f'|=|f|+|f'|\)

这个引理为我们之后的最大流算法铺垫了基础,含义即为我们得知了残量网络上的一次增广操作可以直接由原流量和增广流量计算得到新的网络流量。

增广路:残量网络中从\(s\)\(t\)的一条简单路径定义为一条增广路,增广路的流量\(c_f(p)\)定义为\(\min\{c_f(u,v)|(u,v)\in p\}\)

结论\(1\) :增广后流量增加。令\(f_p\)为残量网络\(G_f\)上的一条增广路的流,则有:\(|f↑ f_p|=|f|+|f_p|>|f|\)

有了结论\(1\),我们就可以尝试思考如何设计求解网络最大流的算法了。我们得知,只要找到一条增广路,就能增加原网络的流量,并且可以快速计算出新的流量。所以,最初的想法就是不断地在残量网络中找增广路,不断扩大流量。

同时,我们也得知:当残量网络不存在增广路时,原网络的流量即为最大流

Ford-Fulkerson方法

由预备知识可知,我们有一种最简单的求网络最大流的方法,那就是不断寻找残量网络的增广路,并将增广路的流量累加到答案中。直到残量网络不存在增广路,我们就得到了网络最大流。

于是我们就得到了著名的\(\mathrm{Ford-Fulkerson}\)方法。注意,这只是求解最大流问题的一种重要思想方法,并不是具体算法,我们将在后面继续讲解具体算法。

function Ford-Fulkerson(G,s,t)
    maxflow = 0
    for each edge (u,v) belongs to G.E
        (u,v).f = 0
    while there exists a path p from s to t in the residue network Gf
        cf(p) = min { cf(u,v) | (u,v) belongs to p }
        maxflow = maxflow + cf(p)
        for each edge (u,v) belongs to p
            if (u,v) belongs to E
                (u,v).f = (u,v).f + cf(p)
            else (v,u).f = (v,u).f - cf(p)
    return maxflow

最小割问题

定义

:一个对点集\(V\)的划分称为,其中点集被划分为两部分\(S\)\(T\),源\(s\)\(S\)中,汇\(t\)\(T\)中。对于一个流\(f\)而言,割\((S,T)\)的流定义为:\[f(S,T)=\sum_{u\in S}\sum_{v\in T}f(u,v)-\sum_{u\in S}\sum_{v\in T}f(v,u)\]
\((S,T)\)的容量定义为:
\[c(S,T)=\sum_{u\in S}\sum_{v\in T}c(u,v)\]

那么,对于一个网络\(G\),我们称\(\min\{c(S,T)\}\)为这个网络的最小割

预备知识

引理\(2\):对于任意流\(f\),任意割的网络流量不变,即\(f(S,T)=|f|\)

由流量守恒我们得知这条引理的正确性,而有了这一条引理,我们就可以进一步地推导流与割之间的联系,找到解决最小割问题的算法。

结论\(2\):任意流\(f\)的流量不超过任意割的容量,即\(|f|\leq c(S,T)\)

证明:

\[|f|=f(S,T)\\ \ \\=\sum_{u\in S}\sum_{v\in T}f(u,v)-\sum_{u\in S}\sum_{v\in T}f(v,u) \\ \ \\\leq \sum_{u\in S}\sum_{v\in T}f(u,v) \\ \ \\ \leq \sum_{u\in S}\sum_{v\in T}c(u,v)=c(S,T)\]

根据结论\(2\),我们得知了割与流之间的整体关系。我们也发现,流的流量和割的容量可能存在重合点(两值相等),此时最小割等于最大流,那么我们能否证明更一般的结论呢?

最大流最小割定理

最大流最小割定理:对于一个网络\(G\),以下三个命题总是等价的:

  • \(f\)\(G\)的最大流
  • 当前流\(f\)的残量网络\(G_f\)上不存在增广路
  • 存在某个割使得\(|f|=c(S,T)\)成立,此时割\((S,T)\)即为网络\(G\)的最小割

证明:

\(Part1\ (1)⇒(2):\)

反证法,若当前流\(f\)的残量网络\(G_f\)上存在增广路,则由结论\(1\)可知经过这次增广流量\(|f|\)可以增大,与流\(f\)\(G\)的最大流矛盾。

\(Part2\ (2)⇒(3):\)

构造点集\(S\)为源\(s\)在残量网络上能够到达的点的集合,\(T=V-S\),那么汇\(t\)\(T\)中,进而\((S,T)\)时原网络\(G\)的一个割。

考虑割\((S,T)\)间的任意点对\((u,v)\),若\((u,v)\)在原网络中存在,则必然有\(f(u,v)=c(u,v)\),否则与残量网络\(G_f\)不存在增广路矛盾,若\((v,u)\)在原网络中存在,则必然有\(f(v,u)=0\),否则这股流无法回流(由上可知,只存在满流边)。

那么就有:
\[f(S,T)=\sum_{u\in S}\sum_{v\in T}f(u,v)-\sum_{u\in S}\sum_{v\in T}f(v,u)\\ \ \\=\sum_{u\in S}\sum_{v\in T}c(u,v)-\sum_{u\in S}\sum_{v\in T}0\\ \ \\=\sum_{u\in S}\sum_{v\in T}c(u,v)=c(S,T)\]

那么由引理\(3\)可知:\(|f|=c(S,T)\),结论成立。

\(Part3\ (3)⇒(1):\)

由结论\(2\)可知:\(|f|\leq c(S,T)\),那么当等号成立时,所对应的流\(f\)必然是最大流。

于是,我们就得到了有关最小割问题最重要的定理,那么最小割问题就可以转换为最大流问题解决。

最大权闭合子图问题

最大权闭合子图:给出一张有向图,每个点都有一个点权。在有向图中选取一张权值最大的子图,使得每个节点的后继也都在子图中。

最大权闭合子图问题较难直接求解,但是我们可以将其转换为最小割问题,这是最小割问题最经典的一个运用。

我们建立超级源点\(s\)\(s\)连所有正权点,容量为点权,建立超级汇点\(t\)\(t\)连所有负权点,容量为点权的相反数。对于原图中的所有边\((u,v)\),连接\((u,v)\)容量为\(\infty\)。然后我们在图上求最小割,答案即为正权点权值和\(-\)最小割。

考虑最小割\((S,T)\),那么最大权闭合子图的节点即为\(S\)中的节点。首先,对于一条容量为正无穷的边\((u,v)\),它一定不会选在最小割中,这就保证了每个点和它的后继被分在同一个集合中。假设一开始所有正权点都在我们选中的最大权闭合子图中,那么:

  • 对于一个正权点\(u\),如果割掉\((s,u)\),就代表不把这个点选入闭合子图,答案刚好减去\(val_u\)
  • 对于一个负权点\(v\),如果割掉\((v,t)\),就代表把这个点选入闭合子图中,答案加上\(val_v\),刚好减去\(-val_v\)

至此,最大权闭合子图问题也得到了较好的解决。

费用流问题

定义

我们不妨假设流网络中的每一条有向边还具有另一个属性:单位费用,含义即为这条边流过单位流量所需的费用。那么由最大流问题我们可以进一步得到另一个问题:费用流问题。一般来说,费用流问题分为以下两种:

最小费用最大流:在保证最大流的情况下花费最小的费用。
最大费用最大流:在保证最大流的情况下花费最大的费用。

预备知识

我们知道,在最大流问题的解决方案中,我们每一次寻找一条增广路来扩充流量,那么不难类比出一种费用流问题的解决方案。

引理\(3\):在残量网络上选择权值最短路(最长路)增广时,费用最小(最大)。

根据结论\(1\),我们不难理解不存在增广路时网络的流量最大,那么只要我们持续增广,就一定可以得到最大流。于是,我们每一次寻找增广路时运用最短路算法,选择最短增广路增广,就可以得到费用最优的最大流。

网络流算法

上文中我们已经简单地了解了网络流的定义以及几个问题,也简单阐述了解决问题的思想方法。接下来,我们将具体讲解算法的实现并比较各个算法的优劣。

Edmonds-Karp算法

\(\mathrm{Edmond-Karp}\)算法是对\(\mathrm{FF}\)方法的一种实现,用于求解网络最大流问题。

引理\(4\):按照最短路径增广路增广,每次使所有顶点\(v∈V− \{s,t\}\)\(s\)的最短距离\(d_v\)增大。

证明:

反证法,假设存在点\(v\in V-\{s,t\}\)使得\(d'_v<d_v\)。那么取\(v\)为第一个成立的节点,并且令\(u\)\(v\)在最短路径上的前驱节点。由此我们可以得到:
\[d_u=d_v-1,d'_u\geq d_u\]

若边\((u,v)\in E\),则有\(d_v\leq d_u+1\leq d'_u+1=d'_v\),与假设矛盾。

若边\((u,v)\not\in E\),则边\((v,u)\)在增广路上,有\(d_v=d_u-1\leq d'_u-1=d'_v-2\),与假设矛盾。

结论\(4\):按照最短路径增广路增广,每条边最多作为瓶颈边\(\frac{n}{2}-1\)次。

证明:

如果\((u,v)\)是瓶颈边,则\((u,v)\)\(s\)\(t\)的增广路上,有\(d_v=d_u+1\)。而增广后,\((u,v)\)将会从残量网络中消失,若边\((u,v)\)重新出现,当且仅当\((v,u)\)在增广路上,而此时又有\(d'_v=d'_u+1\)

由引理可知,\(d'_v>d_v\),故有\(d'_u\geq d_v+1=d_u+2\)。所以每次重新出现会使最短路的最短距离\(+2\),而最短距离最大为\(n-2\),所以每条边最多作瓶颈边\(\frac{n}{2}-1\)次。

由结论\(2\)我们就能得知,这样增广的总次数不会超过\(nm\)次,如果采用\(bfs\)实现找最短路径增广路,时间复杂度为\(O(nm^2)\),我们称这种最大流算法为\(\mathrm{Edmonds-Karp}\)算法。

\(\mathrm{Code}:\)

inline bool EdmondsKarp(void)
{
    memset( vis , 0x00 , sizeof vis );
    queue < int > q; q.push( s );
    vis[s] = true , Min[s] = INF;
    while ( !q.empty() )
    {
        int x = q.front(); q.pop();
        for (int i=Head[x];i;i=e[i].next)
        {
            if ( !e[i].val ) continue;
            int y = e[i].ver;
            if ( vis[y] ) continue;
            Min[y] = min( Min[x] , e[i].val );
            pre[y] = i;
            q.push( y ) , vis[y] = true;
            if ( y == t ) return true; 
        }
    }
    return false;
}
inline void Update(void)
{
    int x = t;
    while ( x != s )
    {
        int i = pre[x];
        e[i].val -= Min[t];
        e[i^1].val += Min[t];
        x = e[i^1].ver;
    }
    Maxflow += Min[t];
}
int main(void)
{
    input();
    while ( EdmondsKarp() ) Update();
    printf("%d\n",Maxflow);
    return 0;
} 

Dinic算法

我们不妨对\(\mathrm{EK}\)算法的增广过程进行思考,发现\(\mathrm{EK}\)算法的本质就是每次在残量网络上构建最短路树,然后找到一条增广路进行增广。其实,这当中不难发现\(\mathrm{EK}\)算法还有优化的余地。

在残量网络构建的最短路树当中,很可能存在多条增广路,而\(EK\)算法却每次只增广一条,就重新构建最短路树了。那么,我们能否设计一个算法,在一次构建最短路树以后实现多路增广,同时处理掉所有的增广路呢?

可行网络:在残量网络上由最短路树构成的子网络我们称为可行网络。

阻塞流:在可行网络上无法在扩充的流称为阻塞流。阻塞流不必要是残量网络的最大流。

利用上述的思路,我们每次构建残量网络的可行网络(最短路树),并用\(dfs\)实现多路增广,直接增广掉可行网络的阻塞流,就能得到一个更高效的算法,我们称之为\(\mathrm{dinic}\)算法。

\(\mathrm{Code}:\)

inline bool Bfs(void)
{
    memset( d , 0x00 , sizeof d );
    memcpy( cur , Head , sizeof Head );
    queue <int> q; q.push(S); d[S] = 1;
    while ( !q.empty() )
    {
        int x = q.front(); q.pop();
        for (int i = Head[x] , y; i; i = e[i].next)
            if ( e[i].val && !d[ y = e[i].ver ] )
            {
                d[y] = d[x] + 1 , q.push(y);
                if ( y == T ) return true;
            }
    }
    return false;
}
inline int Dinic(int x,int flow)
{
    if ( !flow || x == T ) return flow; // 剪枝1
    int residue = flow;
    for (int i = cur[x] , y; cur[x] = i; i = e[i].next) // 剪枝2
        if ( e[i].val && d[ y = e[i].ver ] == d[x] + 1 )
        {
            int k = Dinic( y , min( e[i].val , residue ) );
            if (!k) d[y] = 0; residue -= k; // 剪枝3
            e[i].val -= k , e[i^1].val += k;
            if ( residue == 0 ) break; // 剪枝4
        }
    return flow - residue;
}
int main(void)
{
    Input();
    int Maxflow = 0;
    while ( Bfs() )
        Maxflow += Dinic( S , INF );
    printf( "%d\n" , Maxflow );
    return 0;
}

可以发现,除了之前提到的算法流程外,我们还在\(dinic\)函数中加了若干剪枝,其中最重要的剪枝为:

\(1.\) 当前弧优化(剪枝\(2\)):不增广同一条边多次,每次记录增广到的最后一条边。
\(2.\) 无效点优化(剪枝\(3\)):对于一个流入流量却没有有效流出任何流量的点,我们不再重复访问。

可以证明,当我们在\(dinic\)算法中加入了如上\(4\)个剪枝后,\(dinic\)算法的时间复杂度为\(O(n^2m)\),实际运行速度则更快。

ISAP算法

\(\mathrm{ISAP(Improved\ Shortest\ Augment\ Path)}\) 算法其实是通过\(dfs\)中不断修改距离标号\(d\)的方式省去了每次的\(bfs\),所以称为\(\mathrm{improved}\),其时间复杂度上界和\(\mathrm{Dinic}\)算法相同,均为\(O(n^2m)\)

具体可以参考这篇博客

HLPP算法

我们之前一直都在围绕\(\mathrm{FF}\)方法进行讨论,其实,网络最大流问题还有另一种思路的算法,叫做预流推进算法。

预留推进算法的思想很简单,首先假设源点\(s\)有无限多的余流,然后不断将余流推送给相邻的节点,对于其他的点也是同理,直到不能推送了,\(t\)点的余流即为最大流。

预流推进算法中比较优秀的一种叫做\(\mathrm{HLPP}\)算法,时间复杂度的理论上界为\(O(n^2\sqrt m)\),这里不再详解,可以参照这篇博客

最大流算法总结

至此,我们已经介绍完了基本的网络最大流算法,我们不妨对它们进行比较。

算法 时间复杂度 代码实现难度 时间常数 优缺点
\(\mathrm{Edmonds-Karp}\) \(O(m^2n)\) 较小 较小 实现简易,但是理论复杂度较大
\(\mathrm{Dinic}\) \(O(n^2m)\) 较小 较小 理论复杂度较小,可以\(O(m\sqrt n)\)求解二分图最大匹配问题
\(\mathrm{ISAP}\) \(O(n^2m)\) 一般 较小 相比\(\mathrm{Dinic}\)有更高效的时间常数,但是不能\(O(m\sqrt n)\)求解二分图最大匹配问题
\(\mathrm{HLPP}\) \(O(n^2\sqrt m)\) 较大 较大 理论复杂度较小,但是实现难度高,常数大

(题外话:\(\mathrm{Dinic}\)算法可以用动态树来优化,时间复杂度\(O(nm\log_2n)\)参考资料1参考资料2

Edmonds-Karp费用流算法

上文的费用流问题中已经提到,我们可以每次寻找花费最短路来增广,就可以得到最优费用的最大流,只需在原本的\(\mathrm{EK}\)算法上改进一下即可:用一个最短路算法代替\(bfs\)

考虑到方向边会存在负权(注意,在费用流问题中反向边的花费就是正向边花费的相反数,相当于退还花费),那么我们可以采用\(SPFA\)来求解最短路。

\(\mathrm{Code:}\)

inline bool EdmondsKarp(void)
{
    memset( inq , 0x00 , sizeof inq );
    memset( dis , 0x3f , sizeof dis );
    queue < int > q; q.push( s );
    dis[s] = 0 , inq[s] = 1 , Min[s] = INF;
    while ( !q.empty() )
    {
        int x = q.front(); q.pop();
        inq[x] = false;
        for (int i=Head[x];i;i=e[i].next)
        {
            if ( !e[i].vol ) continue;
            int y = e[i].ver;
            if ( dis[y] > dis[x] + e[i].cos )
            {
                dis[y] = dis[x] + e[i].cos;
                Min[y] = min( Min[x] , e[i].vol );
                pre[y] = i;
                if ( !inq[y] ) 
                    q.push( y ) , inq[y] = true;
            } 
        } 
    }
    return dis[t] >= INF ? false : true;
} 
inline void Update(void)
{
    int x = t;
    while ( x != s )
    {
        int i = pre[x];
        e[i].vol -= Min[t];
        e[i^1].vol += Min[t];
        x = e[i^1].ver;
    }
    Maxflow += Min[t];
    Mincost += Min[t] * dis[t];
} 

类Dinic算法

同样的,我们可以改造最大流的\(\mathrm{Dinic}\)来求解最大流,实现可以参见这篇博客

(注:博文中实现的确实是类\(\mathrm{Dinic}\)算法,而非真正的\(\mathrm{zkw}\)费用流算法)

zkw费用流算法

\(\mathrm{zkw}\)提出的一个费用流算法,思路是效仿\(\mathrm{KM}\)算法修改顶标的方式实现最短路\(dis\)数组的维护,然后求解最小费用最大流,原博客这里,这篇博客的讲解也不错。

\(\mathrm{zkw}\)算法在最终流量较大, 而费用取值范围不大的图, 或者是增广路径比较短的图 (如二分图)运行的比较快,时间常数较小。但遗憾的是\(\mathrm{zkw}\)费用流算法只能解决最小费用最大流问题,本质是因为其不能处理负权边。

如下,笔者给出了一种\(\mathrm{zkw}\)费用流算法的实现方式。

\(\mathrm{Code:}\)

#include <bits/stdc++.h>
using namespace std;
const int N = 5020 , M = 50020 , INF = 0x3f3f3f3f;
int n,m,s,t,tot=1,maxflow,mincost;
int Head[N],flag[N],d[N],cur[N];
struct edge { int ver,vol,cos,next; } e[M*2];
inline void insert(int x,int y,int vol,int cos)
{
    e[++tot] = (edge){y,vol,cos,Head[x]} , Head[x] = tot;
    e[++tot] = (edge){x,0,-cos,Head[y]} , Head[y] = tot;
}
inline void input(void)
{
    scanf("%d%d%d%d",&n,&m,&s,&t);
    for (int i=1;i<=m;i++)
    {
        int x,y,v,c;
        scanf("%d%d%d%d",&x,&y,&v,&c);
        insert( x , y , v , c );
    }
}
inline bool modify(void)
{
    if ( flag[t] ) return true;
    memcpy( cur , Head , sizeof Head );
    int x,y,Min = INF;
    for (int i=2;i<=tot;i++)
    {
        int x = e[i^1].ver , y = e[i].ver;
        if ( e[i].vol && flag[x] && !flag[y] )
            Min = min( Min , d[x] - d[y] + e[i].cos );
    }
    if ( Min == INF ) return false;
    for (int i=1;i<=n;i++)
        if ( flag[i] ) d[i] -= Min;
    return true;
}
inline int zkw(int x,int flow)
{
    flag[x] = true;
    if ( x == t ) { mincost -= d[s] * flow; return flow; }
    int residue = flow;
    for (int i=cur[x];i;i=e[i].next)
    {
        int y = e[i].ver; cur[x] = i;
        if ( flag[y] ) continue;
        if ( e[i].vol && d[y] == d[x] + e[i].cos )
        {
            int k = zkw( y , min( residue , e[i].vol ) );
            e[i].vol -= k , e[i^1].vol += k;
            residue -= k;
            if ( !residue ) break;
        }
    }
    return flow - residue;
}
int main(void)
{
    input();
    do memset( flag , 0 , sizeof flag ) , maxflow += zkw( s , INF );
    while ( modify() );
    printf("%d %d\n",maxflow,mincost);
    return 0;
}

Primal-Dual原始对偶算法

\(\mathrm{Primal-Dual}\)原始对偶算法采用了一种重赋权技术,给了每条边的花费加上了一个势,得到了一个新的权值:\(cost'(e_i)=cost(e_i)+h(e_u)-h(e_v)\)

这是什么意思呢?我们知道在实现费用流算法的过程中我们要执行多次最短路算法,每次执行完毕后,我们就会得到一个\(d\)数组,表示单源最短路的距离,那么我们就可以利用\(d\)数组作为势\(h\)对边进行重赋权,进行下一轮的最短路。

首先,我们不难发现这样重赋权之后对最短路路径权值和不会有太大的影响,因为最短路上连接同一个点的边会抵消一部分势,可以得到\(d_T=d'_T-h(S)+h(T)\),那么最短路就方便计算了。

其次,这样重赋权之后我们会得到一个很好的性质,就是边权变得非负了,因为最短路的三角形不等式满足\(d_y\leq d_x+e(x,y)\),就有\(e(x,y)+d_x-d_y\geq 0\)

边权非负有什么好处?相信大家已经想到了,我们甚至可以使用\(\mathrm{Dijkstra}\)算法来执行最短路,这样就可以更高效的执行费用流算法了。


<后记>

猜你喜欢

转载自www.cnblogs.com/Parsnip/p/12215753.html