网络流入门
目录
一、网络流简介
网络
首先,分清楚流网络与流的概念。
流网络(G=(V,E))是一张有向图,图中每条有向边((u, v)in E)都有一个给定的权值(c(u, v)),称为边的容量。特别地,若((u, v) otin E),则(c(u, v) = 0)。图中还有两个指定的特殊结点(sin V)和(tin V(s eq t)),分别称为源点和汇点。
流
设(f(u, v))是定义在结点二元组((uin V, vin V))上的实数函数且满足:
-
容量限制:对于每条边,流经该边的流量不得超过该边的容量,即(f(u,v) leq c(u,v))
-
斜对称性:每条边的流量与其相反边的流量之和为 0,即(f(u,v)=-f(v,u))
-
流量守恒: 除源点和汇点以外,任何结点不储存流,其流入总量等于流出总量,即
(forall xin V-{s,t}, sum_{u,xin E}f(u,x)=sum_{x,vin E}f(x,v))
那么(f)称为网络(G)的流函数。对于((u,v)in E, F(u,v))称为边的流量,(c(u,v)-f(u,v))称为边的剩余容量。整个网络的流量为(sum_{s,vin E}f(s,v)),即从源点发出的所有流量之和。
一般而言也可以把网络流理解为整个图的流量。而这个流量必满足上述三个性质。
二、网络流的常见问题
网络流问题中常见的有以下三种:最大流,最小割,费用流。
最大流
流网络中,要求从源点流向汇点的最大流量(可以有很多条路到达汇点),就是求解最大流问题。
最小割
流网络(G=(V,E))中的一个切割((S,T))将结点集合(V)划分为(S)和(T=V-S)两个集合,使得(sin S, tin T)。切割((S,T))的容量是:
一个网络的最小切割是整个网络中容量最小的切割。
最小费用最大流
最小费用最大流问题是这样的:每条边都有一个费用,代表单位流量流过这条边的开销。我们要在求出最大流的同时,要求花费的费用最小(先满足最大流,再满足最小费用)。
三、求解最大流
计算最大流的算法有很多,如EK算法,Dinic算法,推送-重贴标签算法等,这里主要介绍EK算法和Dinic算法。
Ford-Fulkerson方法
Ford-Fulkerson方法循环增加流的值。在开始的时候,对于所有的结点(u, vin V),初始流量值(f(u,v) = 0)。在每一次迭代中,我们将图(G)的流值进行增加,方法就是在一个关联的“残存网络”(G_f)中寻找一条“增广路径”。一旦知道图(G_f)中一条增广路径的边,就可以对图(G)对应的边上的流量进行修改,从而增加流的值。虽然Ford-Fulkerson方法的每次迭代都增加流的值,但是对于图(G)的一条特定边来说,其流量可能增加,也可能减少;对某些边的流进行缩减可能是必要的(实际实现中可以通过建造反向边来做到这一点),以便让算法可以将更多的流从源结点发送到汇点。重复对流进行这一过程,直到残存网络中不再存在增广路径为止。最大流最小切割定理说明在算法终结时,该算法将获得一个最大流。
残存网络
从直观上看,给定流网络(G)和流量(f),残存网络(G_f)由那些仍有空间对流量进行调整的边构成。流网络的一条边可以允许的额外流量等于该边的容量减去该边上的流量。如果该差值为正,则将该条边置于图(G_f)中,并将其残存容量设置为(c_f(u,v)=c(u,v)-f(u,v))。对于图(G)中的边来说,只有能够允许额外流量的边才能加入到图(G_f)中。如果边((u, v))的流量等于其容量,则其(c_f(u, v)=0),该条边将不属于图(G_f)。
残存网络(G_f)还可能包含图(G)中不存在的边。算法对流量进行操作的目标是增加总流量,为此,算法可能对某些特定边上的流量进行缩减。为了表示对一个正流量(f(u, v))的缩减,我们将边((v, u))加入到图(G_f)中,并将其残存容量设置为(c_f(v, u)=f(u, v)),也就是说,一条边所能允许的反向流量最多将其正向流量抵消。残存网络中的这些反向边允许算法将已经发送出来的流量发送回去,而将流量从同一条边发送回去等同于缩减该条边的流量,这种操作在许多算法中都是必须的。
更形式化地,假定有一个流网络(G=(V,E)),设(f)为图(G)中的一个流,考虑结点对(u, vin V),定义残存容量(c_f(u,v))如下:
给定一个流网络(G=(V,E))和一个流(f),则由(f)所诱导的残存网络为(G_f=(V,E_f)),其中
增广路径
给定流网络(G=(V,E))和流(f),增广路径(p)是残存网络(G_f)中一条从源结点(s)到汇点(t)的简单路径。根据残存网络的定义,对于一条增广路径上的边((u,v)),我们可以增加其流量的幅度最大为(c_f(u,v)),而不会违反原始流网络(G)中对边((u,v))或((v, u))的容量限制。增广路径(p)的流量(f_p=min{c_f(u,v):(u,v)in p} > 0)。
《算法导论》中用不短的篇幅证明了在增广路径上增广后(即走掉这条路),流网络(G)中的流量是严格递增的,并引入流网络的切割证明了最大流最小切割定理,该定理表明了Ford-Folkerson方法计算最大流的正确性。下面给出最大流最小切割定理的描述,不再赘述其证明。
流网络的切割:流网络(G=(V,E))中的一个切割((S,T))将结点集合(V)划分为(S)和(T=V-S)两个集合,使得(sin S, tin T)。切割((S,T))的容量是:
最大流最小切割定理:设(f)为流网络(G=(V,E))中的一个流,该流网络的源结点为(s),汇点为(t),则下面的条件是等价的:
- (f)是(G)的一个最大流。
- 残存网络(G_f)不包括任何增广路径。
- (|f|=c(S,T)),其中((S,T))是流网络(G)的某个切割。
由于对于流网络(G)中的任意切割((S,T))和任意流(f),有
即(|f|leq c(S,T)),因此,条件3中(|f|=c(S,T))也隐含着容量与最大流的值相等的这个切割是最小切割,即(|f_{max}|=c(S,T)_{min})。
Edmonds-Karp算法
EK算法是FF方法的一种实现算法,算法思想就是不断用BFS找增广路,然后对其进行增广,直至网络上不存在增广路为止。
- 如何找?从源结点开始BFS走来走去,碰到汇点就停,然后增广。
- 如何增广?就是按照我们找的增广路再重新走一遍,走的时候每条边的剩余容量减去增广路上的流值(f_p),反向边的剩余容量增加(f_p),最后给答案加上(f_p)就可以了。
再讲一下反向边 。增广的时候要注意建造反向边,原因是这条路不一定是最优的,这样后面可以进行反悔。在具体实现时,我们可以按照邻接表“成对储存”技巧,把网络的每条有向边及其反向边存在邻接表下标为“2和3”、“4和5”……的位置上,这样每条边的下标与1异或后就是其反向边的下标。
EK算法的时间复杂度是(O(VE^2)),然而在实际运用中则远远达不到这个上界,效率较高,一般能够处理(10^3)~(10^4)规模的网络。对于这个时间复杂度,《算法导论》中给出了漂亮的证明,但篇幅较长,不在此处重复。
#include <iostream>
#include <cstdio>
#include <cstring>
#include <queue>
using std::queue;
const int INF = 1 << 29, N = 2010, M = 20010;
int head[N], incf[N], pre[N];
bool vis[N];
int n, m, s, t, tot, maxflow;
struct Edge
{
int to, cap, nex;
} edge[M];
queue<int> q;
void add(int x, int y, int z) {
edge[++tot].to = y, edge[tot].cap = z, edge[tot].nex = head[x], head[x] = tot;
edge[++tot].to = x, edge[tot].cap = 0, edge[tot].nex = head[y], head[y] = tot;
}
bool bfs() {
memset(vis, false, sizeof(vis));
while (q.size()) q.pop();
q.push(s); vis[s] = true;
incf[s] = INF; // 增广路上各边的最小剩余容量
while (q.size()) {
int x = q.front(); q.pop();
for (int i = head[x]; i; i = edge[i].nex) {
if (edge[i].cap) {
int y = edge[i].to;
if (vis[y]) continue;
incf[y] = std::min(incf[x], edge[i].cap);
pre[y] = i; // 记录前驱,便于重新遍历这条增广路
q.push(y);
vis[y] = true;
if (y == t) return true;
}
}
}
return false;
}
void update() { // 更新增广路及其反向边的剩余容量
int x = t;
while (x != s) {
int i = pre[x];
edge[i].cap -= incf[t];
edge[i^1].cap += incf[t];
x = edge[i^1].to;
}
maxflow += incf[t];
}
int main() {
tot = 1, maxflow = 0;
memset(head, 0, sizeof(head));
scanf("%d %d", &n, &m);
scanf("%d %d", &s, &t); // 源点、汇点
for (int i = 0; i < m; i++) {
int x, y, c;
scanf("%d %d %d", &x, &y, &c);
add(x, y, c);
}
while (bfs()) update();
printf("%d
", maxflow);
return 0;
}
Dinic算法
Dinic算法同样基于FF方法,是EK算法的优化。EK算法每轮BFS可能会遍历整个残存网络,但只找出1条增广路,还有进一步优化的空间。
Dinic算法的过程是这样的:每次增广前,我们先用BFS来将图分层。设源点的层数为1 ,那么一个点的层数便是它离源点的最近距离+1。接下来用DFS找增广路,在回溯时实时更新剩余容量,每次找增广路的时候,都只找比当前点层数多1的点进行增广,这样可以保证每次找到的增广路是最短的(与EK算法一样,优先找最短的增广路是为了运算时间上更优,EK算法时间复杂度的证明正是基于增广路都是残存网络上源点和汇点的最短路径的前提)。
Dinic算法有两个优化:
- 多路增广:每次找到一条增广路的时候,如果残余流量没有用完怎么办呢?我们可以利用残余部分流量,再找出一条增广路。这样就可以在一次 DFS 中找出多条增广路,大大提高了算法的效率。
- 当前弧优化:如果一条边已经被增广过,那么它就没有可能被增广第二次。那么,我们下一次进行增广的时候,就可以不必再走那些已经被增广过的边。
Dinic算法的时间复杂度为(O(V^2E))。实际运用中远远达不到这个上界,可以说是比较容易实现的效率最高的网络流算法之一,在稀疏图上效率和 EK 算法相当,但在稠密图上效率要比 EK 算法高很多,一般能够处理(10^4)~(10^5)规模的网络。特别地,Dinic算法求解二分图最大匹配的时间复杂度为(O(Esqrt V))。
#include <iostream>
#include <cstdio>
#include <cstring>
#include <queue>
using std::queue;
const int INF = 1 << 29, N = 50010, M = 300010;
int head[N], d[N];
int n, m, s, t, tot, maxflow;
struct Edge
{
int to, cap, nex;
} edge[M];
queue<int> q;
void add(int x, int y, int z) {
edge[++tot].to = y, edge[tot].cap = z, edge[tot].nex = head[x], head[x] = tot;
edge[++tot].to = x, edge[tot].cap = 0, edge[tot].nex = head[y], head[y] = tot;
}
bool bfs() { // 在残存网络上构造分层图
memset(d, 0, sizeof(d));
while (q.size()) q.pop();
q.push(s); d[s] = 1;
while (q.size()) {
int x = q.front(); q.pop();
for (int i = head[x]; i; i = edge[i].nex) {
int v = edge[i].to;
if (edge[i].cap && !d[v]) {
q.push(v);
d[v] = d[x] + 1;
if (v == t) return true;
}
}
}
return false;
}
int dinic(int x, int flow) { // 在当前分层图上增广
if (x == t) return flow;
int rest = flow, k;
for (int i = head[x]; i && rest; i = edge[i].nex) {
int v = edge[i].to;
if (edge[i].cap && d[v] == d[x] + 1) {
k = dinic(v, std::min(rest, edge[i].cap));
if (!k) d[v] = 0; // 剪枝,去掉增广完毕的点(当前弧优化)
edge[i].cap -= k;
edge[i^1].cap += k;
rest -= k;
}
}
return flow - rest;
}
int main() {
tot = 1, maxflow = 0;
memset(head, 0, sizeof(head));
scanf("%d %d", &n, &m);
scanf("%d %d", &s, &t); // 源点、汇点
for (int i = 0; i < m; i++) {
int x, y, c;
scanf("%d %d %d", &x, &y, &c);
add(x, y, c);
}
while (bfs()) maxflow += dinic(s, INF);
printf("%d
", maxflow);
return 0;
}
四、最小割
最小割就是求的一个切割((S,T))使得割的容量(c(S,T))最小。
上文中的最大流最小切割定理已经说明了(|f(s,t)_{max}|=c(s,t)_{min}),所以最小割的容量与最大流值在数值上是相等的,可用最大流算法求解。
问题模型
有(n)个物品和两个集合(A,B),如果将一个物品放入(A)集合会花费(a_i),放入(B)集合会花费(b_i),还有若干个形如(u_i,v_i,w_i)的限制条件,表示如果(u_i)和(v_i)同时不在一个集合会花费(w_i)。每个物品必须且只能属于一个集合,求最小的代价。
这是一个经典的二者选其一的最小割题目。我们对于每个集合设置源点(s)和汇点(t),第(i)个点由(s)连一条容量为(a_i)的边,向(t)连一条容量为(b_i)的边。对于限制条件(u,v, w),我们在(u,v)之间连容量为(w)的双向边。
注意到当源点和汇点不相连时,代表这些点都选择了其中一个集合。如果将连向(s)或(t)的边割开,表示不放在(A)或(B)集合,如果把物品之间的边割开,表示这两个物品不放在同一个集合。
最小割就是最小花费。
切割方案
我们可以从源点(s)开始DFS,每次走残量大于(0)的边,即可找到所有(S)集合内的点。
void dfs(int u) {
vis[u] = true;
for (int i = head[u]; i; i = edge[i].nex) {
int v = edge[i].to;
if (!vis[v] && edge[i].cap) dfs(v);
}
}
割边数量
只需要将每条边的容量变为1,然后重新跑EK或Dinic即可。
五、费用流
给定一个网络(G=(V,E)),每条边除了有容量限制(c(u,v)),还有一个给定的“单位费用”(w(u,v))。当边((u,v))的流量为(f(u,v))时,需要花费(f(u,v) imes w(u,v)),(w)也满足斜对称性,即(w(u,v)=-w(v,u))。该网络中总花费最小的最大流称为“最小费用最大流”,即在最大化(sum_{(s,v)in E}f(s,v))的前提下最小化(sum_{(u,v)in E}f(u,v) imes w(u,v)),总花费最大的最大流被称为“最大费用最大流”,二者合称为“费用流”模型。
类似于“二分图最大匹配”与最大流的关系,“二分图带权最大匹配”可直接用最大费用最大流求解,每条边的权值就是它的单位费用。
类Edmonds-Karp算法
在Edmonds-Karp求解最大流的基础上,把“用BFS寻找任意一条增广路”改为“用SPFA(由于有负权边,所以不能直接用Dijkstra)寻找一条单位费用之和最小的增广路”(也就是把(w(u,v))当做边权,在残存网络上求最短路)即可求出最小费用最大流,时间复杂度上界为(O(VEf))。注意:一条反向边((v,u))的费用应设为(-w(u,v))。为什么叫类Edmonds-Karp算法呢,因为这个算法没有正式的名字,国外根本不用SPFA。
【注】:SPFA算法是Bellman-Ford算法的优化,可以处理负权边,时间复杂度上界为(O(VE)),随机图上的期望时间为(O(kE))(k为小常数)。
下面来证明这个算法所求得的确实是最小费用流,此处引用《挑战程序设计竞赛》P224~225的证明并稍作修改:
设(f)为网络中的某个流,假设还有同样流量而费用比(f)更小的流(f')。让我们来观察一下流(f'-f)(可以理解为流(f')加上(f)的反向边对应的流形成的新流)。在流(f)中,除(s)和(t)以外的顶点的流入量等于流出量,在流(f')中亦然。并且,流(f’)由(s)流向(t),流(-f)由(t)流向(s),流量相同,故流(f'-f)中(s)和(t)的流入量也等于流出量,则流(f'-f)中所有顶点的流入量都等于流出量,即它是由若干圈组成的。因为流(f'-f)的费用是负的,所以在这些圈中,至少存在一个负圈。也就是说 (f)是最小费用流 (Leftrightarrow) 残存网络中没有负圈
利用这一点,我们就可以通过归纳法证明,在该算法中流量为(i)的流(f_i)是具有相同流量的流中费用最小的。
- 归纳基础:对于流量为(0)的流(f_0),其残存网络便是原图,只要原图不含负圈,那么(f_0)就是流量(0)的最小费用流。
- 归纳假设:当流量为(i)的流(f_i)是最小费用流并且下一步我们求得了流量为(i+1)的流(f_{i+1}),此时(f_{i+1}-f_i)就是(f_i)对应的残存网络中(s)到(t)的最短路,假设(f_{i+1})不是最小费用流,即存在费用更小的流(f'_{i+1})。
(f'_{i+1}-f_{i})中除(s)和(t)以外的顶点的流入量等于流出量,因而是一条从(s)到(t)的路径和若干圈组成的。又有(f_{i+1}-f_i)是一条从(s)到(t)的最短路,而(f'_{i+1})的费用比(f_{i+1})还要小,所以(f'_{i+1}-f_i)中至少含有一个负圈,这与(f_i)是最小费用流矛盾。所以,(f_{i+1})也是最小费用流。- 结论:由1、2可知,对任意的(i)都有(f_i)是最小费用流,所以当算法终止找到最大流(f_{max})时,其同时也是最小费用流,即(f_{max})是最小费用最大流。
模板代码:
#include <iostream>
#include <cstdio>
#include <cstring>
#include <queue>
using std::queue;
const int INF = 0x3f3f3f3f, N = 2010, M = 20010;
int head[N], incf[N], pre[N], dis[N];
bool vis[N];
int n, m, s, t, tot, maxflow, ans;
struct Edge
{
int to, cap, cost, nex;
} edge[M];
void add(int x, int y, int z, int c) {
// 正向边,初始容量z,单位费用c
edge[++tot].to = y, edge[tot].cap = z, edge[tot].cost = c, edge[tot].nex = head[x], head[x] = tot;
// 反向边,初始容量0, 单位费用-c,与正向边"成对储存"
edge[++tot].to = x, edge[tot].cap = 0, edge[tot].cost = -c, edge[tot].nex = head[y], head[y] = tot;
}
bool spfa() {
queue<int> q;
memset(vis, false, sizeof(vis));
memset(dis, 0x3f, sizeof(dis));
q.push(s); vis[s] = true; dis[s] = 0;
incf[s] = INF; // 增广路上各边的最小剩余容量
while (q.size()) {
int x = q.front(); q.pop();
vis[x] = false;
for (int i = head[x]; i; i = edge[i].nex) {
int y = edge[i].to, c = edge[i].cost;
if (!edge[i].cap || dis[y] <= dis[x] + c) continue;
dis[y] = dis[x] + c;
incf[y] = std::min(edge[i].cap, incf[x]);
pre[y] = i;
if (!vis[y]) vis[y] = true, q.push(y);
}
}
if (dis[t] == INF) return false;
return true;
}
void update() { // 更新增广路及其反向边的剩余容量
int x = t;
while (x != s) {
int i = pre[x];
edge[i].cap -= incf[t];
edge[i^1].cap += incf[t];
x = edge[i^1].to;
}
maxflow += incf[t];
ans += dis[t] * incf[t];
}
int main() {
tot = 1, maxflow = 0;
memset(head, 0, sizeof(head));
scanf("%d %d", &n, &m);
scanf("%d %d", &s, &t); // 源点、汇点
for (int i = 0; i < m; i++) {
int u, v, w, c;
scanf("%d %d %d %d", &u, &v, &w, &c);
add(u, v, w, c);
}
while (spfa()) update();
printf("%d %d
", maxflow, ans);
return 0;
}
利用改进的Dijkstra算法求解:
存在负边时无法用Dijkstra算法求解最短路,但可以通过导入势的概念,在最小费用流问题中用Dijkstra算法代替SPFA算法求解最短路,以获得更好的性能,能够在(O(FElogV))或是(O(FV^2))的时间内求出最小费用流。
这里的势,指的是给每个顶点赋予的一个标号(h(v)),在这个势的基础上,将边(e=(u,v))的长度变为(d'(e)=d(e)+h[u]-h(v))。于是从(d')中的(s ightarrow t)路径的长度中减去常数(h(s)-h(t)),就得到了(d)中对应路径的长度,因此(d')中的最短路也就是(d)中的最短路。所以,如果合理地选取势,使得对所有的(e)都有(d'(e)geq 0)的话,我们就可以在(d')中用Dijkstra算法求最短路,从而得到(d)的最短路。对于任意不含负圈的图,我们可以通过取(前一个残存网络中到的最短距离h(v)=(前一个残存网络中s到v的最短距离))做到这一点。这是因为对于边(e=(u,v))有(h(v)leq h(u)+d(e)),于是有(d'(e)=d(e)+h(u)-h(v) geq 0)。
以下为求解流量为 f 时的最小费用的模板代码,求解最小费用最大流也只需稍作修改:
#include<iostream>
#include<vector>
#include<queue>
#include<cstdio>
#include<cstring>
#define N 1000
#define INF 100000000
using namespace std;
typedef pair<int, int> P; //first保存最短距离,second保存顶点的编号
struct Edge
{
int to, cap, cost, rev; //终点,容量(指残量网络中的),费用,反向边编号
Edge(int t, int c, int cc, int r) :to(t), cap(c), cost(cc), rev(r){}
};
int V; //顶点数
vector<Edge>G[N]; //图的邻接表
int h[N]; //顶点的势
int dist[N]; //最短距离
int prevv[N]; //最短路中的父结点
int preve[N]; //最短路中的父边
void addedge(int from, int to, int cap, int cost)
{
G[from].push_back(Edge(to, cap, cost, G[to].size()));
G[to].push_back(Edge(from, 0, -cost, G[from].size() - 1 ));
}
int min_cost_flow(int s, int t, int f) //返回最小费用
{
int res = 0;
fill(h, h + V, 0);
while (f > 0) //f>0时还需要继续增广
{
priority_queue<P, vector<P>, greater<P> >q;
fill(dist, dist + V, INF); //距离初始化为INF
dist[s] = 0;
q.push(P(0, s));
while (!q.empty())
{
P p = q.top(); q.pop();
int v = p.second;
if (dist[v] < p.first)continue; //p.first是v入队列时候的值,dist[v]是目前的值,如果目前的更优,扔掉旧值
for (int i = 0; i < G[v].size(); i++)
{
Edge &e = G[v][i];
if (e.cap > 0 && dist[e.to] > dist[v] + e.cost + h[v] - h[e.to])//松弛操作
{
dist[e.to] = dist[v] + e.cost + h[v] - h[e.to];
prevv[e.to] = v; //更新父结点
preve[e.to] = i; //更新父边编号
q.push(P(dist[e.to], e.to));
}
}
}
if (dist[t] == INF) //如果dist[t]还是初始时候的INF,那么说明s-t不连通,不能再增广了
return -1;
for (int j = 0; j < V; j++) //更新h
h[j] += dist[j];
int d = f;
for (int x = t; x != s; x = prevv[x])
d = min(d, G[prevv[x]][preve[x]].cap); //从t出发沿着最短路返回s找可改进量
f -= d;
res += d * h[t]; //h[t]表示最短距离的同时,也代表了这条最短路上的费用之和,乘以流量d即可得到本次增广所需的费用
for (int x = t; x != s; x = prevv[x])
{
Edge &e = G[prevv[x]][preve[x]];
e.cap -= d; //修改残量值
G[x][e.rev].cap += d;
}
}
return res;
}
int main()
{
int m;
while (cin >> V >> m)
{
for (int i = 0; i <= V; i++) G[i].clear();
for (int i = 0; i < m; i++)
{
int from, to, cap, cost;
cin >> from >> to >> cap >> cost;
addedge(from, to, cap, cost);
}
int s, t, f;
cin >> s >> t >> f;
cout << min_cost_flow(s, t, f) << endl;
}
return 0;
}
整理:Kangkang
参考资料:《算法导论》第三版
《算法竞赛进阶指南》 -- 李煜东
《挑战程序设计竞赛》