网络流解析及其应用
摘要
本文主要讲述了网络流中的最大流问题和费用流中的最小费用流问题,简单说明了Dinic算法和最小费用流算法,以题为例展示了使用算法的一些细节,意在能够根据问题建立模型,建图并套用该算法的模板解决问题。
网络和网络流
在图论中,边附带容量的图称为网络,表示为G(V, E, C, s, t),其中V表示顶点集, E表示边集,C便是每条边的容量限制,s是源点,t是汇点。网络流(network flow)是指在一个每一条边都有容量的有向图上分配流(每一条边的流量不会超过它的容量)。一个网络可以用来模拟道路系统的交通量、管中的液体、电路中的电流,或者类似的一些东西在一个网络中游动的任何事物。
定义f(u, v)为从顶点u到v的实际流量,c(u, v)为从顶点u到v的容量限制,它们满足三个性质:
f(u, v) <= c(u, v);(容量限制)
f(u, v) = - f(v, u);(斜对称性)
对于除了源点s和汇点t以外的任意结点u,净流入量等于净流出量(流量平衡)
接下来就引出一个问题,给出一个网络,包含每一条边的容量限制,问从源点出发到汇点的最大流量是多少,这样的问题被称为最大流问题。那该怎么求解呢?
在求解之前需要先了解另外两个概念:残余网络和增广路。
残余网络
定义流f上的残余网络G' = (V,E',C',s,t),顶点集不变,边集由如下条件生成的边构成:
(1)若f(e) < c(e), e = (u, v),则加入边e' = (u, v),容量为c(e) - f(e)。新生成的边容量表示沿着该边还能推进多少流。
(2)若f(e) > 0,e = (u, v),则加入边e' = (v, u),容量为f(e)。新生成的边容量表示沿着改变的逆方向能退回多少流。
其中e为图G中的边,e'为残余网络G'的边。
增广路
增广路p是在残余网络G'上的一条从源点s到汇点t的简单路径。路径的残余流量为该路径上的边e'容量的最小值。
可以证明的是如果f'是网络G的流f上的增广路径流,那么f + f'也是网络G的一个流。因此可以通过在原有的网络基础上建立残余网络并寻找增广路来使流增加,从而求得最大流。
至此直接引入求解最大流的优化算法之一Dinic算法,其思想核心是不断求解增广路,并且关注的是怎样减少增广次数。Dinic算法主要特点如下:
(1)在原图中构造分层网络,顶点u所在层数是源点s到它的最短距离level[u]。
(2)每次寻找增广路的时候,只沿着<u, v>的弧走,其中level[u] = level[u] - 1。
(3)通过回溯,使得DFS过程能找到多条增广路。
算法流程如下:
(1)在当前残余网络中建立分层网络。若无法到达,则当前流f为最大流,算法终止;否则,进行步骤(2)。
(2)在当前分层网络中寻找增广路并增广,重复步骤(1)。
算法实现:
1 struct Max_flow { 2 int S, T, n;//源点、汇点和最大点数 3 int lev[maxv], q[maxv], cur[maxv], f;//每个顶点的层次号,队列,当前结点的编号,队列指针 4 void init(int _S, int _T) {//初始化 5 tot = 0; 6 S = _S; 7 T = _T; 8 n = T + 1; 9 for(int i = 0; i <= n; i++) { 10 head[i] = -1; 11 } 12 } 13 void add(int a, int b, int c) { 14 E[tot].nxt = head[a]; 15 E[tot].to = b; 16 E[tot].c = c; 17 head[a] = tot++; 18 } 19 void Add(int a, int b, int c) { 20 add(a, b, c); 21 add(b, a, 0); 22 } 23 int bfs() {//建立分层网络 24 for(int i = 0; i <= n; i++) { 25 lev[i] = 0; 26 } 27 lev[S] = 1; 28 f = 0; 29 q[f++] = S; 30 for(int i = 0; i < f; i++) { 31 int u = q[i]; 32 for(int i = head[u]; i != -1; i = E[i].nxt) { 33 if(lev[E[i].to] == 0 && E[i].c > 0) { 34 int to = E[i].to; 35 lev[to] = lev[u] + 1; 36 q[f++] = to; 37 if(to == T)//如果遍历到汇点直接退出 38 return 1; 39 } 40 } 41 } 42 return 0; 43 } 44 int dfs(int u, int f) {//DFS回溯寻找多条增广路 45 if(u == T) return f; 46 int tag = 0, c; 47 for(int &i = cur[u]; i != -1; i = E[i].nxt) {//当前弧优化,流满的边无须访问 48 int to = E[i].to; 49 if(E[i].c > 0 && lev[to] == lev[u] + 1) { 50 c = dfs(to, min(f - tag, E[i].c)); 51 E[i].c -= c; 52 E[i^1].c += c; 53 tag += c; 54 if(tag == f) 55 return tag; 56 } 57 } 58 return tag; 59 } 60 int solve() { 61 int ans = 0; 62 while(bfs()) { 63 for(int i = 0; i <= n; i++) { 64 cur[i] = head[i];//当前弧优化 65 } 66 ans += dfs(S, inf); 67 } 68 return ans; 69 } 70 }max_flow;
下面看一道例题:POJ 3281 Dining
题意
给出N头牛、F种食物和D种饮料,每一头牛喜欢的食物和饮料的种类数Fi和Di,然后Fi个数表示这一头牛喜欢的食物种类的编号,然后Di个数表示这一头牛喜欢的饮料种类的编号,共N行。问最多能满足多少头牛的需要。
解题思路
先抛去饮料的因素,先求如何分配使得尽可能多的牛分配到其喜欢的食物,我们使用二分匹配的模型,建立源点和汇点,源点向所有的牛建立一条容量为1的弧,牛向各自喜爱的食物建立一条容量为1的弧,所有食物向汇点建立容量为1的弧。那么该图的最大流是所求解。
现在考虑这一道题,增加了饮料这一条件。如果还用之前的方法,只是单纯的增加饮料,可以证明求得的最大流是错误的,因为每一头牛只能分配一种食物和饮料,这样可能导致一头牛被多条流访问。这里采用拆点的方法,将每一头牛就拆分成出点和入点,食物向入点建立容量为1的弧,出点向饮料建立容量为1的弧,同时将各入点再向对应的出点建立容量为1的弧,该图的最大流就是问题的答案。
代码如下:
1 #include <iostream> 2 #include <cstdio> 3 #include <algorithm> 4 using namespace std; 5 6 const int maxv = 1e6 + 7, maxe = 3 * 1e6 + 7;//总的定点数和弧数 7 const int inf = 99999999; 8 int head[maxv], tot; //模拟邻接表示使用的数组和总弧数 9 10 struct Edge { 11 int to, c, nxt;//每条边到达哪些顶点,容量限制以及下一条边的编号 12 }E[maxe]; 13 14 struct Max_flow { 15 int S, T, n;//源点、汇点和最大点数 16 int lev[maxv], q[maxv], cur[maxv], f;//每个顶点的层次号,队列,当前结点的编号,队列指针 17 void init(int _S, int _T) {//初始化 18 tot = 0; 19 S = _S; 20 T = _T; 21 n = T + 1; 22 for(int i = 0; i <= n; i++) { 23 head[i] = -1; 24 } 25 } 26 void add(int a, int b, int c) { 27 E[tot].nxt = head[a]; 28 E[tot].to = b; 29 E[tot].c = c; 30 head[a] = tot++; 31 } 32 void Add(int a, int b, int c) { 33 add(a, b, c); 34 add(b, a, 0); 35 } 36 int bfs() {//建立分层网络 37 for(int i = 0; i <= n; i++) { 38 lev[i] = 0; 39 } 40 lev[S] = 1; 41 f = 0; 42 q[f++] = S; 43 for(int i = 0; i < f; i++) { 44 int u = q[i]; 45 for(int i = head[u]; i != -1; i = E[i].nxt) { 46 if(lev[E[i].to] == 0 && E[i].c > 0) { 47 int to = E[i].to; 48 lev[to] = lev[u] + 1; 49 q[f++] = to; 50 if(to == T)//如果遍历到汇点直接退出 51 return 1; 52 } 53 } 54 } 55 return 0; 56 } 57 int dfs(int u, int f) {//DFS回溯寻找多条增广路 58 if(u == T) return f; 59 int tag = 0, c; 60 for(int &i = cur[u]; i != -1; i = E[i].nxt) {//当前弧优化,流满的边无须访问 61 int to = E[i].to; 62 if(E[i].c > 0 && lev[to] == lev[u] + 1) { 63 c = dfs(to, min(f - tag, E[i].c)); 64 E[i].c -= c; 65 E[i^1].c += c; 66 tag += c; 67 if(tag == f) 68 return tag; 69 } 70 } 71 return tag; 72 } 73 int solve() { 74 int ans = 0; 75 while(bfs()) { 76 for(int i = 0; i <= n; i++) { 77 cur[i] = head[i];//当前弧优化 78 } 79 ans += dfs(S, inf); 80 } 81 return ans; 82 } 83 }max_flow; 84 85 int n, F, D, fi, di, x, S, T, le, ri; 86 int work() { 87 max_flow.init(S = 0, T = 2 * n + F + D + 1); 88 for(int i = 1; i <= F; i++) max_flow.Add(S, i, 1); //源点到各个食物建立一条容量为1的弧 89 for(int i = 1; i <= D; i++) max_flow.Add(F + i, T, 1);//各个饮料向汇点.... 90 for(int i = 1; i <= n; i++) { 91 scanf("%d%d", &fi, &di); 92 le = F + D + i * 2 - 1; 93 ri = le + 1; 94 max_flow.Add(le, ri, 1);//将牛拆点,自身连边 95 while(fi--) { 96 scanf("%d", &x); 97 max_flow.Add(x, le, 1);//食物向牛的入点连边 98 } 99 while(di--) { 100 scanf("%d", &x); 101 max_flow.Add(ri, F + x, 1);//出点向饮料连边 102 } 103 } 104 return max_flow.solve(); 105 } 106 107 int main() 108 { 109 while(scanf("%d%d%d", &n, &F, &D) == 3 && n + F + D != 0) { 110 printf("%d\n", work()); 111 } 112 return 0; 113 }
费用流
在之前最大流问题的基础上,在每一条边上增加一个费用的限制条件,问题变成了流量从s到t,如何选择路径以及分配经过路径的流量,可以在流量最大的前提下,达到所用的费用最小的目的。实际应用中体现在n辆卡车要运送货物,从A到B地,由于每条路段都有不同的路费要缴纳,每条路能容纳的车的数量有限制,那么最小费用最大流问题旨在解决如何分配卡车的出发路径使得可以费用最低,而且物品又能安全送到。
最小费用流问题
设有一网络G = (V,E,C,s,t),其中E中的每一条边都对应一个容量c(u, v) 和运输单位流所需的费用w(u, v)。相应的若有一个可行流f,则该流产生费用为W(f) = sum{f(e) * w(e)},其中最小费用流指的是所有最大流中费用最小的流。
有了最大流问题的基础,我们可以从之前的算法出发解决这个问题,从初始零流出发,每次寻找一条从源点到汇点的增广路,增广路必须是所有增广路中费用最小的一条。如果能够找到,就沿着这条路增广,得到新流。这样迭代下去,直到找不到增广路,此时的的流即为最小费用最大流。
可以看出,这种算法的思想其实和最大流的算法很相似,不同的是寻找的是最小费用增广路时,需要将其转换为一个寻求源点至汇点的最短路径问题。
最小费用流算法
费用网络只是在原网络中附加了边费用w(e),不同的是对于原有边<u, v>附加费用w(e),而对回退边<v, u>附加费用-w(e),表示沿该边回退所得费用。
建立完费用网络之后,便开始从初始流寻找费用最小的增广路,需要注意的是,需要这样的增广路时只是累加边的费用,而不关心边的容量(只需大于0),另外边权可能是负值,需要使用SPFA算法作为寻找增广路的算法。
为了减少因为负权边存在导致最短路算法的效率降低,在每次建立增流网络求得最短路径后,可以将网络G的权w(e)进行一次修正,使再建的增流网络不会出现负权边,并保证最短路径不至于因此而改变。
算法实现
1 const int maxv = 220, maxe = 1e6 + 7, inf = 99999999; 2 int head[maxv], tot, que[maxe], dis[maxv], pre[maxv]; 3 bool vis[maxv]; 4 5 struct Edge { 6 int to, c, w, nxt; 7 }E[maxe]; 8 9 struct MinCostFlow { 10 int S, T, n;//源点、汇点、最大点数 11 void init(int _S, int _T) { 12 tot = 0; 13 S = _S; 14 T = _T; 15 n = T + 1; 16 for(int i = 0; i <= n; i++) { 17 head[i] = -1; 18 } 19 } 20 void add(int a, int b, int c, int w) { 21 E[tot].nxt = head[a]; 22 E[tot].to = b; 23 E[tot].c = c; 24 E[tot].w = w; 25 head[a] = tot++; 26 } 27 void Add(int a, int b, int c, int w) { 28 add(a, b, c, w); 29 add(b, a, 0, -w); 30 } 31 bool spfa() { 32 int frt = 0, tal = 1; 33 for(int i = 0; i <= n; i++) { 34 dis[i] = inf; 35 vis[i] = 0; 36 } 37 dis[S] = 0; 38 que[0] = S; 39 vis[S] = 1; 40 while(tal != frt) { 41 int u = que[frt++]; 42 frt %= maxv; 43 44 for(int i = head[u]; i != -1; i = E[i].nxt) { 45 int v = E[i].to; 46 if(E[i].c && dis[v] > dis[u] + E[i].w) { 47 dis[v] = dis[u] + E[i].w; 48 pre[v] = i; 49 if(!vis[v]) { 50 vis[v] = 1; 51 que[tal++] = v; 52 tal %= maxv; 53 } 54 } 55 } 56 vis[u] = 0; 57 } 58 if(dis[T] == inf) 59 return 0; 60 return 1; 61 } 62 int endf(int &flow) { 63 int u, p, sum = inf, ans = 0; 64 for(u = T; u != S; u = E[p^1].to) { 65 //记录路径上的最小流值 66 p = pre[u]; 67 sum = min(sum, E[p].c); 68 } 69 for(u = T; u != S; u = E[p^1].to) { 70 p = pre[u]; 71 E[p].c -= sum; 72 E[p^1].c += sum; 73 ans += sum * E[p].w; 74 //const记录的为单位流量费用,必须乘以流量 75 } 76 flow += sum; 77 return ans; 78 } 79 int solve() { 80 int ans = 0, flow = 0; 81 while(spfa()) {//寻找增广路并增广 82 ans += endf(flow); 83 } 84 return ans; 85 } 86 }Flow;
看一道例题:POJ 2195 Going Home
题意
给出一个n*m的矩阵,其中.表示空地,H表示房子,m表示小人,每次小人移动一格花费一步,问每个小人都找到一个房子,最小的花费是多少。
解题思路
方案1
建立一个源点和一个汇点,源点到每个人都建立一条容量为1话费为0的边,每个人到每个房子都有容量为1花费的大小是它们之间曼哈顿距离,每个房子到汇点建立一条容量为1花费是0的边,然后跑一边最下费用流即可得到最小花费。
方案2
根据每个小人到每个房子的曼哈顿距离建立有权值的边,然后使用KM算法求得最大权匹配。
方案1代码如下:
1 #include <iostream> 2 #include <cstdio> 3 #include <algorithm> 4 using namespace std; 5 6 const int maxv = 220, maxe = 1e6 + 7, inf = 99999999; 7 int head[maxv], tot, que[maxe], dis[maxv], pre[maxv]; 8 bool vis[maxv]; 9 10 struct Edge { 11 int to, c, w, nxt; 12 }E[maxe]; 13 14 struct MinCostFlow { 15 int S, T, n;//源点、汇点、最大点数 16 void init(int _S, int _T) { 17 tot = 0; 18 S = _S; 19 T = _T; 20 n = T + 1; 21 for(int i = 0; i <= n; i++) { 22 head[i] = -1; 23 } 24 } 25 void add(int a, int b, int c, int w) { 26 E[tot].nxt = head[a]; 27 E[tot].to = b; 28 E[tot].c = c; 29 E[tot].w = w; 30 head[a] = tot++; 31 } 32 void Add(int a, int b, int c, int w) { 33 add(a, b, c, w); 34 add(b, a, 0, -w); 35 } 36 bool spfa() { 37 int frt = 0, tal = 1; 38 for(int i = 0; i <= n; i++) { 39 dis[i] = inf; 40 vis[i] = 0; 41 } 42 dis[S] = 0; 43 que[0] = S; 44 vis[S] = 1; 45 while(tal != frt) { 46 int u = que[frt++]; 47 frt %= maxv; 48 49 for(int i = head[u]; i != -1; i = E[i].nxt) { 50 int v = E[i].to; 51 if(E[i].c && dis[v] > dis[u] + E[i].w) { 52 dis[v] = dis[u] + E[i].w; 53 pre[v] = i; 54 if(!vis[v]) { 55 vis[v] = 1; 56 que[tal++] = v; 57 tal %= maxv; 58 } 59 } 60 } 61 vis[u] = 0; 62 } 63 if(dis[T] == inf) 64 return 0; 65 return 1; 66 } 67 int endf(int &flow) { 68 int u, p, sum = inf, ans = 0; 69 for(u = T; u != S; u = E[p^1].to) { 70 //记录路径上的最小流值 71 p = pre[u]; 72 sum = min(sum, E[p].c); 73 } 74 for(u = T; u != S; u = E[p^1].to) { 75 p = pre[u]; 76 E[p].c -= sum; 77 E[p^1].c += sum; 78 ans += sum * E[p].w; 79 //const记录的为单位流量费用,必须乘以流量 80 } 81 flow += sum; 82 return ans; 83 } 84 int solve() { 85 int ans = 0, flow = 0; 86 while(spfa()) {//寻找增广路并增广 87 ans += endf(flow); 88 } 89 return ans; 90 } 91 }Flow; 92 93 int n, m; 94 char ch[111][111]; 95 int X[111], Y[111]; 96 int pp, hh; 97 98 int main() 99 { 100 while(scanf("%d%d", &n, &m) == 2 && n + m != 0) { 101 pp = hh = 0; 102 Flow.init(maxv - 2, maxv - 3);//建立超级源点和超级汇点 103 for(int i = 0; i < n; i++) { 104 scanf("%s", ch[i]); 105 for(int j = 0; j < m; j++) { 106 if(ch[i][j] == 'H') { 107 Flow.Add(Flow.S, hh, 1, 0);//源点到每个房子建立边 108 X[hh] = i; 109 Y[hh++] = j; 110 } 111 } 112 } 113 for(int i = 0; i < n; i++) { 114 for(int j = 0; j < m; j++) { 115 if(ch[i][j] == 'm') { 116 Flow.Add(hh + pp, Flow.T, 1, 0);//每个人到源点建立边 117 for(int k = 0; k < hh; k++) {//每个房子和每个人建立边 118 Flow.Add(k, hh + pp, 1, abs(i - X[k]) + abs(j - Y[k])); 119 } 120 pp++; 121 } 122 } 123 } 124 printf("%d\n", Flow.solve()); 125 } 126 return 0; 127 }
建模与模型转换
在算法竞赛中,解决和网络流相关的问题往往重在建模。下面给出一些经典问题及其建模方法。
多源多汇问题
有多个源点和汇点,可以增加一个超级源点和超级汇点,然后超级源点连接多个源点,超级汇点连接过个汇点。
结点容量
每个结点都有一个允许通过的最大流量,称为结点容量,我们将每个原始结点u分裂成u1和u2,中间连一条弧,容量等于结点容量。原先到达u的弧改成到达u1,而原先从u出发的弧改成从u2出发。