11.4 网络流初步

网络流是一个适用范围相当广的模型,相关的算法也非常多。尽管如此,网络流中的概念、思想和基本算法并不难理解。

11.4.1 最大流问题

如图11-9所示,假设需要把一些物品从结点s(称为源点)运送到结点t(称为汇点),可以从其他结点中转。图11-9(a)中各条有向边的权表示最多能有多少个物品从这条边的起点直接运送到终点。例如,最多可以有9个物品从结点v3运送到v2

图11-9(b)展示了一种可能的方案,其中每条边中的第一个数字表示实际运送的物品数目,而第二个数字就是题目中的上限。

 

  (a)     (b)  

图11-9 物资运送问题

这样的问题称为最大流问题(Maximum-Flow Problem)。对于一条边(u,v),它的物品上限称为容量(capacity),记为c(u,v)(对于不存在的边(u,v),c(u,v)=0);实际运送的物品称为流量(flow),记为f(u,v)。注意,“把3个物品从u运送到v,又把5个物品从v运送到u”没什么意义,因为它等价于把两个物品从v运送到u。这样,就可以规定f(u,v)和f(v,u)最多只有一个正数(可以均为0),并且f(u,v)=-f(v,u)。这样规定就好比“把3个物品从u运送到v”等价于“把-3个物品从v运送到u”一样。

最大流问题的目标是把最多的物品从s运送到t,而其他结点都只是中转,因此对于除了结点st外的任意结点u(这些f中有些是负数)。从s运送出来的物品数目等于到达t的物品数目,而这正是此处最大化的目标。

提示11-2:在最大流问题中,容量c和流量f满足3个性质:容量限制(f(u,v)≤c(u,v))、斜对称性(f(u,v)=-f(v,u))和流量平衡(对于除了结点st外的任意结点u)。问题的目标是最大化,即从s点流出的净流量(它也等于流入t点的净流量)。

11.4.2 增广路算法

介绍完最大流问题后,下面介绍求解最大流问题的算法。算法思想很简单,从零流(所有边的流量均为0)开始不断增加流量,保持每次增加流量后都满足容量限制、斜对称性和流量平衡3个条件。

计算出图11-10(a)中的每条边上容量与流量之差(称为残余容量,简称残量),得到图11-10(b)中的残量网络(residual network)。同理,由图11-10(c)可以得到图11-10(d)。注意残量网络中的边数可能达到原图中边数的两倍,如原图中c=16,f=11的边在残量网络中对应正反两条边,残量分别为16-11=5和0-(-11)=11。

 

  (a)     (b)  
  (c)     (d)  

图11-10 残量网络和增广路算法

该算法基于这样一个事实:残量网络中任何一条从st的有向道路都对应一条原图中的增广路(augmenting path)——只要求出该道路中所有残量的最小值d,把对应的所有边上的流量增加d即可,这个过程称为增广(augmenting)。不难验证,如果增广前的流量满足3个条件,增广后仍然满足。显然,只要残量网络中存在增广路,流量就可以增大。可以证明它的逆命题也成立:如果残量网络中不存在增广路,则当前流就是最大流。这就是著名的增广路定理。

提示11-3:当且仅当残量网络中不存在s-t有向道路(增广路)时,此时的流是从st的最大流。

“找任意路径”最简单的办法无疑是用DFS,但很容易找出让它很慢的例子。一个稍微好一些的方法是使用BFS,它足以应对数据不刁钻的网络流题目。这就是Edmonds-Karp算法。下面是完整的代码。注意Edge结构体多了flow和cap两个变量,但是AddEdge却和Dijkstra中的同名函数很接近。这便是得益于Edge结构体这一设计。


struct Edge {
  int from, to, cap, flow;
  Edge(int u, int v, int c, int f):from(u),to(v),cap(c),flow(f) {}
};

struct EdmondsKarp {
  int n, m;
  vector<Edge> edges;        //边数的两倍
  vector<int> G[maxn];       //邻接表,G[i][j]表示结点i的第j条边在e数组中的序号
  int a[maxn];                //当起点到i的可改进量
  int p[maxn];                //最短路树上p的入弧编号

  void init(int n) {
    for(int i = 0; i < n; i++) G[i].clear();
    edges.clear();
  }

  void AddEdge(int from, int to, int cap) {
    edges.push_back(Edge(from, to, cap, 0));
    edges.push_back(Edge(to, from, 0, 0)); //反向弧
    m = edges.size();
    G[from].push_back(m-2);
    G[to].push_back(m-1);
  }

  int Maxflow(int s, int t) {
    int flow = 0;
    for(;;) {
      memset(a, 0, sizeof(a));
      queue<int> Q;
      Q.push(s);
      a[s] = INF;
      while(!Q.empty()) {
        int x = Q.front(); Q.pop();
        for(int i = 0; i < G[x].size(); i++) {
          Edge& e = edges[G[x][i]];
          if(!a[e.to] && e.cap > e.flow) {
            p[e.to] = G[x][i];
            a[e.to] = min(a[x], e.cap-e.flow);
            Q.push(e.to);
          }
        }
        if(a[t]) break;
      }
      if(!a[t]) break;
      for(int u = t; u != s; u = edges[p[u]].from) {
        edges[p[u]].flow += a[t];
        edges[p[u]^1].flow -= a[t];
      }
      flow += a[t];
    }
    return flow;
  }
};

注意上面代码中的一个技巧:每条弧和对应的反向弧保存在一起。边0和1互为反向边;边2和3互为反向边……一般地,边i的反向边为i^1,其中“^”为二进制异或运算符(想一想,为什么)。

正如所见,上面的代码和普通的BFS并没有太大的不同。唯一需要注意的是,在扩展结点的同时还需递推出从s到每个结点i的路径上的最小残量a[i],则a[t]就是整条s-t道路上的最小残量。另外,由于a[i]总是正数,所以用它代替了原来的vis标志数组。上面的代码把流初始化为零流,但这并不是必需的。只要初始流是可行的(满足3个限制条件),就可以用增广路算法进行增广。

11.4.3 最小割最大流定理

有一个与最大流关系密切的问题:最小割。如图11-11所示,把所有顶点分成两个集合ST=V-S,其中源点s在集合S中,汇点t在集合T中。

如果把“起点在S中,终点在T中”的边全部删除,就无法从s到达t了。这样的集合划分(S,T)称为一个s-t割,它的容量定义为:,即起点在S中,终点在T中的所有边的容量和。

还可从另外一个角度看待割。如图11-12所示,从s运送到t的物品必然通过跨越ST的边,所以从st的净流量等于

 

  图11-11 网络中的割     图11-12 流和割的关系  

注意这里的割(S,T)是任取的,因此得到了一个重要结论:对于任意s-tf和任意s-t割(S, T),有

下面来看残量网络中没有增广路的情形。既然不存在增广路,在残量网络中st并不连通。当BFS没有找到任何s-t道路时,把已标号结点(a[u]>0的结点u)集合看成S,令T=V-S,则在残量网络中ST分离,因此在原图中跨越ST的所有弧均满载(这样的边才不会存在于残量网络中),且没有从T回到S的流量,因此成立|f|≤(S,T)成立。

前面说过,对于任意的f和(S,T),都有|f|≤(S,T),而此处又找到了一组让等号成立的f和(S,T)。这样,便同时证明了增广路定理和最小割最大流定理:在增广路算法结束时,fs-t最大流,(S,T)是s-t最小割。

提示11-4:增广路算法结束时,令已标号结点(a[u]>0的结点)集合为S,其他结点集合为T=V-S,则(S,T)是图的s-t最小割。

11.4.4 最小费用最大流问题

下面给网络流增加一个因素:费用。假设每条边除了有一个容量限制外,还有一个单位流量所需的费用(cost)。图11-13(a)中分别用ca来表示每条边的容量和费用,而图11-13(b)给出了一个在总流量最大的前提下,总费用最小的流(费用为10),即最小费用最大流。另一个最大流是从s分别运送一个单位到xy,但总费用为11,不是最优。

 

  (a)     (b)  

图11-13 最小费用最大流

在最小费用流问题中,平行边变得有意义了:可能会有两条从uv的弧,费用分别为1和2。在没有费用的情况下,可以把二者合并,但由于费用的出现,无法合并这两条弧。再如,若边(u,v)和(v,u)均存在,且费用都是负数,则“同时从u流向v和从v流向u”是个不错的主意。为了更方便地叙述算法,先假定图中不存在平行边和反向边。这样就可以用两个邻接矩阵cap和cost保存各边的容量和费用。为了允许反向增广,规定cap[v][u]=0并且cost[v][u]=-cost[u][v],表示沿着(u,v)的相反方向增广时,费用减小cost[u][v]。

限于篇幅,这里直接给出最小费用路算法。和Edmonds-Karp算法类似,但每次用Bellman-Ford算法而非BFS找增广路。只要初始流是该流量下的最小费用可行流,每次增广后的新流都是新流量下的最小费用流。另外,费用值是可正可负的。在下面的代码中,为了减小溢出的可能,总费用cost采用long long来保存。


struct Edge {
  int from, to, cap, flow, cost;
  Edge(int u, int v, int c, int f, int w):from(u),to(v),cap(c),flow(f),cost(w)
{}
};

struct MCMF {
  int n, m;
  vector<Edge> edges;
  vector<int> G[maxn];
  int inq[maxn];         //是否在队列中
  int d[maxn];           //Bellman-Ford
  int p[maxn];           //上一条弧
  int a[maxn];           //可改进量

  void init(int n) {
    this->n = n;
    for(int i = 0; i < n; i++) G[i].clear();
    edges.clear();
  }

  void AddEdge(int from, int to, int cap, int cost) {
    edges.push_back(Edge(from, to, cap, 0, cost));
    edges.push_back(Edge(to, from, 0, 0, -cost));
    m = edges.size();
    G[from].push_back(m-2);
    G[to].push_back(m-1);
  }

  bool BellmanFord(int s, int t, int& flow, long long& cost) {
    for(int i = 0; i < n; i++) d[i] = INF;
    memset(inq, 0, sizeof(inq));
    d[s] = 0; inq[s] = 1; p[s] = 0; a[s] = INF;

    queue<int> Q;
    Q.push(s);
    while(!Q.empty()) {
      int u = Q.front(); Q.pop();
      inq[u] = 0;
      for(int i = 0; i < G[u].size(); i++) {
        Edge& e = edges[G[u][i]];
        if(e.cap > e.flow && d[e.to] > d[u] + e.cost) {
          d[e.to] = d[u] + e.cost;
          p[e.to] = G[u][i];
          a[e.to] = min(a[u], e.cap - e.flow);
          if(!inq[e.to]) { Q.push(e.to); inq[e.to] = 1; }
        }
      }
    }
    if(d[t] == INF) return false;
    flow += a[t];
    cost += (long long)d[t] * (long long)a[t];
    for(int u = t; u != s; u = edges[p[u]].from) {
      edges[p[u]].flow += a[t];
      edges[p[u]^1].flow -= a[t];
    }
    return true;
  }

  //需要保证初始网络中没有负权圈
  int MincostMaxflow(int s, int t, long long& cost) {
    int flow = 0; cost = 0;
    while(BellmanFord(s, t, flow, cost));
    return flow;
  }
};

11.4.5 应用举例

首先需要明确一点:虽然本节介绍了最大流的Edmonds-Karp算法,但在实践中一般不用这个算法,而是使用效率更高的Dinic算法或者ISAP算法。这两个算法虽然也不是很难理解,但是较之Edmonds-Karp来说还是复杂了许多。另一方面,最小费用流也有更快的算法,但在实践中一般仍用上述算法,因为最小费用流的快速算法(例如网络单纯型法)大都很复杂,还没有广泛使用。对此,笔者的建议是:理解Edmonds-Karp算法的原理(包括正确性证明),但在比赛中使用Dinic或者ISAP。《算法竞赛入门经典——训练指南》对这两个算法有较为详细介绍,还给出了完整的代码。事实上,读者无须搞清楚它们的原理,只需会使用即可。换句话说,可以把它们当作像STL一样的黑盒代码。在算法竞赛中,一般把这样的代码称为模板

二分图匹配。网络流的一个经典的应用是二分图匹配。在图论中,匹配是指两两没有公共点的边集,而二分图是指:可以把结点集分成两部分XY,使得每条边恰好一个端点在X,另一个端点在Y。换句话说,可以把结点进行二染色(bicoloring),使得同色结点不相邻。为了方便叙述,在画图时一般把X结点和Y结点画成左右两列。可以证明:一个图是二分图,当且仅当它不含长度为奇数的圈。

常见的二分图匹配问题有两种。第一种是针对无权图的,需要求出包含边数最多的匹配,即二分图的最大基数匹配(maximum cardinality bipartite matching),如图11-14(a)所示。

这个问题可以这样求解:增加一个源点s和一个汇点t,从s到所有X结点各连一条容量为1的弧,再从所有Y结点各连一条容量为1的弧到t,最后把每条边变成一条由X指向Y的有向弧,容量为1。只要求出st的最大流,则原图中所有流量为1的弧对应了最大基数匹配。

第二种是针对带权图的,需要求出边权之和尽量大的匹配,如图11-14(b)所示。有些题目要求这个匹配本身是完美匹配(perfect matching),即每个点都被匹配到,而有些题目并不对边的数量做出要求,只要权和最大就可以了。下面先考虑前一种情况,即最大权完美匹配(maximum weighted perfect matching)。

 

  (a)     (b)  

图11-14 二分图匹配

聪明的读者相信已经找到解决方法了:和最大基数匹配类似,只是原图中所有边的费用为权值的相反数(即前面加一个负号),然后其他边的费用为0,然后求一个st的最小费用最大流即可。如果从s出发的所有弧并不是全部满载(即流量等于容量),则说明完美匹配不存在,问题无解;否则原图中的所有流量为1的弧对应最大权完美匹配。

用这样的方法也可以求解第二种情况,即匹配边数没有限制的最大权匹配,只是需要在求解s-t最小费用流的过程中记录下流量为0, 1, 2, 3,…时的最小费用流,然后加以比较,细节留给读者思考。

例题11-7 UNIX插头(A Plug for UNIX, UVa753)

n个插座,m个设备和kn,m,k≤100)种转换器,每种转换器都有无限多。已知每个插座的类型,每个设备的插头类型,以及每种转换器的插座类型和插头类型。插头和插座类型都用不超过24个字母表示,插头只能插到类型名称相同的插座中。

例如,有4个插座,类型分别为A, B, C, D;有5个设备,插头类型分别为B, C, B, B, X;还有3种转换器,分别是B->X,X->A和X->D。这里用B->X表示插座类型为B,插头类型为X,因此一个插头类型为B的设备插上这种转换器之后就“变成”了一个插头类型为X的设备。转换器可以级联使用,例如插头类型为A的设备依次接上A->B,B->C,C->D这3个转换器之后会“变成”插头类型为D的设备。

要求插的设备尽量多。问最少剩几个不匹配的设备。

【分析】

首先要注意的是:k个转换器中涉及的插头类型不一定是接线板或者设备中出现过的插头类型。在最坏情况下,100个设备,100个插座,100个转换器最多会出现400种插头。当然,400种插头的情况肯定是无解的,但是如果编码不当,这样的情况可能会让你的程序出现下标越界等运行错误。

笔者第一次尝试本题时使用的方法如下:转换器有无限多,所以可以独立计算出每个设备i是否可以接上0个或多个转换器之后插到第j个插座上,方法是建立有向图G,结点表示插头类型,边表示转换器,然后使用Floyd算法,计算出任意一种插头类型a是否能转化为另一种插头类型b。

接下来构造网络:设设备i对应的插头类型编号为device[i],插座i对应的插头类型编号为target[i],则源点s到所有device[i]连一条弧,容量为1,然后所有target[i]到汇点t连一条弧,容量为1,对于所有设备i和插座j,如果device[i]可以转化为target[j],则从device[i]连一条弧到target[j],容量为无穷大(代表允许任意多个设备从device[i]转化为target[j]),最后求s-t最大流,答案就是m减去最大流量。

上述算法的优点是网络流模型中的点比较少(因为只有接线板和设备中出现过的插头类型),缺点是弧比较多(任意一对可以转化的结点之间都有弧),并且编程稍微麻烦一些。

还有一个更加简单的方法:直接把所有插头类型(包括仅在转换器中出现的类型)纳入到网络流模型中,则每个转换器对应一条弧,容量为无穷大。这个方法的优点是编程简单,并且弧的个数比较少(只有k条),缺点是点数比较多。建议读者实现这两种算法,然后自行比较它们的优劣。

例题11-8 矩阵解压(Matrix Decompressing, UVa 11082)

对于一个RC列的正整数矩阵(1≤R,C≤20),设Ai为前i行所有元素之和,Bi为前i列所有元素之和。已知R,C和数组AB,找一个满足条件的矩阵。矩阵中的元素必须是1~20之间的正整数。输入保证有解。

【分析】

首先根据AiBi计算出第i行的元素之和A'i和第i列的元素之和B'i。如果把矩阵里的每个数都减1,则每个A'i会减少C,而每个B'i会减少R。这样一来,每个元素的范围变成了0~19,它的好处很快就能看到。

建立一个二分图,每行对应一个X结点,每列对应一个Y结点,然后增加源点s和汇点t。对于每个结点Xi,从sXi连一条弧,容量为A'i-C;从Yit连一条弧,容量为B'i-R。而对于每对结点(Xi,Yj),从XiYj连一条弧,容量为19。接下来求s-t的最大流,如果所有s出发和到达t都满载,说明问题有解,结点Xi->Yj的流量就是格子(i,j)减1之后的值。

为什么这样做是对的呢?请读者思考。

例题11-9 海军上将(Admiral, ACM/ICPC NWERC 2012, UVa1658)

给出一个v(3≤v≤1000)个点e(3≤e≤10000)条边的有向加权图,求1~v的两条不相交(除了起点和终点外没有公共点)的路径,使得权和最小。如图11-15所示,从1到6的两条最优路径为1-3-6(权和为33)和1-2-5-4-6(权和为53)。

【分析】

把2到v-1的每个结点i拆成ii'两个结点,中间连一条容量为1,费用为0的边,然后求1到v的流量为2的最小费用流即可。

图11-15 从1到6的两条最优路径

本题的拆点法是解决结点容量的通用方法,请读者注意。

例题11-10 最优巴士路线设计(Optimal Bus Route Design, ACM/ICPC Taiwan 2005, UVa12264)

n个点(n≤100)的有向带权图,找若干个有向圈,每个点恰好属于一个圈。要求权和尽量小。注意即使(u,v)和(v,u)都存在,它们的权值也不一定相同。

【分析】

每个点恰好属于一个有向圈,意味着每个点都有一个唯一的后继。反过来,只要每个点都有唯一的后继,每个点一定恰好属于一个圈。“每个东西恰好有唯一的……”让我们想到了二分图匹配。把每个点i拆成XiYi,原图中的有向边u->v对应二分图中的边Xu->Yv,则题目转化为了这个二分图上的最小权完美匹配问题。