网络流
前序
在将网络里实现算法之前,我们得聊聊网络流究竟是个什么东西,毕竟只有知道它的样貌,才能继续看懂下面的定义,对吧?
首先,网络流不仅仅指的是什么FF算法、dinic算法。算法只是用来解决问题的(稍后我们会更加能体会这一点),而网络流,指的就是这一系列存在图论中的,关于“流(Flow)”的问题。
网络流中有以下几种问题:
- 最大流问题
- 最小费用流问题
- 多商品流问题
- 非零流问题
本文着重于最大流问题,铁路网络中两点间最大运输量的问题
增广路的含义:
增广路就是一条从源点到汇点的路,并且带有一个值,表示该增广路的最大流量,该值得大小取决于该增广路中拥有最小流量的边。
最大流求解
基本定义:
有向网络
首先,我们要做的就是将实际问题抽象。以上面铁轨网络为例子,如何抽象火车网络成为一种数学模型?
噢,即便你没有学过图论,凭借直觉你也能想到。车站可以作为点,铁轨作为线,然后用线把点连起来,这样不就是表示出火车网络了吗?
是的,你想没错。不过你还可以想的更深——火车的行驶当然有方向,尤其是在我们想要求解特定两点之间的情况下,所以作为铁轨的线应该改成向量。
而这就是有向图
容量
网络有了,流呢?不要着急,我们在建立流的概念之前还有一些东西需要确定下来。
每列火车都有承载量,理所应当的,刚刚建立的有向图中每个向量也应当有一个容量值,限制了这条边所能运输的最大货量。
流
你可能会想流是怎样的定义的?其实关于流的定义大家感受一下就好,甚至直接按照字面意义理解也不为过。电流的流、河流的流、最大流的流,都差不多。
人工解决
我们把源点、汇点和边的代号给编辑一下。
从S汇出的流能走几条路?
- a-d
- b-c-d
- b-e-f
三条,这三条路中的流量是多少?
- a-d:8,因为a是8限制了流
- b-c-d:3,但是除了因为c限制了3的流量之外,还有另一件事需要注意。因为a-d这条路占用了d中的8个容量,所以在进行b-c-d计算的时候要记住,3的由来是min(10,3,4).
- b-e-f:3,因为min(7,3,5)
于是,总的流量加起来是8+3+3 = 14
看起来还是挺好弄得吗,似乎只要遍历每条路,然后便利的时候每条边取最小值然后减去最小值就行了
OK,换了例子。
求得该图得最大流
- a-d-g:3
- a-e-h:2
- b-f-g:1 = min(6,4,1)
- b-c-h:4 = min(5,4,6)
最大流为10。
接下来我们将引出计算机得缺陷了——他不懂顺序得重要性。我们首先遍历从a边出去的流,得到的答案没什么问题,但是计算机不知道,我们完全无法得知他会先计算那条边,假设我们先从b边开始
- b-f-g:4
- b-c-h:2 = min(2,4,8)
- a-d-g:0 = min(7,3,0)
- a-e-h:2 = min(7,2,6)
最终,我们得出图的最大流为8。
这就是计算机的问题所在,我们需要找到一种方法(算法),让计算机能够不在乎先后完美的计算出正确的答案。
问题分析
我们不妨分析分析为什么顺序会影响结果。
当我们率先计算b-f-g这条边的时候,我们贪心的把整条路都填满,导致g的容量全部被来自b的量占据了。这代表着a无法再利用这条边,只能向下从e流出。
甚至我们可以想象,在复杂的网络中,一个节点的流出的边很可能全部被其他流占用,导致流向该节点的“货物”全部失效了。如果这部分货物占据了总流量的很大部分,那么结果就很有可能出现错误。
为了避免这个现象
Ford-Fulkerson算法建立了反向边,即“反悔”机制。
它,是这样做的。
算法沿着路径反向建立额外的路,这条额外的路的容量就等于整条路的流量。
此时,我们完成了第一步,即b-f-g:4
然后我们继续
- b-c-h:2
- a-d- f’ -c-h:2
- a-e-h:2
最大流4+2+2+2=10。结果正确。
在这里你能看到“反悔”机制是如何作用的,他将多余的流量回退。本来正确的路线是b-f-g:1,结果我们因为顺序的不同导致b-f-g率先占据更多的容量,而反向边的建立使得我们有机会将多余的3份流量回退回去,产生正确的结果。
到这里其实你已经能懂个大概了,甚至也许你不要更精进一步,对于FF算法最核心部分的反向边建立的必要性已经得知了之后你便可以直接去看算法源码,然后再着基础上去看EK算法以及Dinic算法,接下想必对于你来说只是时间问题了。
额外内容
真的反悔了吗?
而我接下来要反驳一下“反悔”这个理解(注意,我并非反对这个机制,而是“反悔”这个词的用法。)
请看下面的例子:
按照FF算法求最大流是12。
正确计算顺序是这样的:
- a-c:5
- a-e-d:5
- b-d:2
但顺序打乱一下,同时建立反向边:
- a-e-d:8
- a-c:2
- b-e’-c:2
结果正确,但是这个时候我们再以“反悔”的视角来看,按照刚才的顺序,a-e-d这条路应该是5,应该反悔3个流才能得到正确的答案,但是这里我们仅仅反悔了两个流,也就是说,现在成了这个样子:
- a-e-d:8-2=6
- a-c:2+2=4
- b-d:0+2=2
a的分配明显不符合贪心的思想。
但关键就在于这一点,即便不符合贪心,即便没有完全的“反悔”,算法依旧健壮,依旧完美的运作,其中所蕴含的数学究竟是什么?是什么保证了它如此完美的运作?
接下来,才是我想讨论的重点。
不是“反悔”,是“借用”
接下来我们将进行一系列的分类讨论,可能会有点绕,有点晕,所以我尽量讲的清晰一点。
为了使例子更具有普遍性,我们讲每条边的容量值用任意值代替。
1. a先走c:这个时候我们先让 a 优先占用 c 边,有两种情况。
a<c:这个时候不需要建立反向边,因为a不走e,a的流量全部被c给拿走了。
a>c:当a大于c时,那么a剩余的流量会走e同时建立反向边。但是这个时候反向边也是无用的,试想,当b流通过反向边来到c时,发现c的容量早已经被a占满了。所以反向边的建立起不到任何作用,b还是老老实实走d才行。
可以看到,a先走c是符合我们计算顺序的,这个时候无论建不建立反向边都是不影响结果的。
2. a先走e:两种情况
a<e:这个时候建立反向边,但是往下b和e汇合的时候还分两种情况
b+e<d:这说明d容量足够大,足以容纳a和b的总容量,b这个时候也有可能不走反向边
如果b不走反向边,正常运作
如果b走反向边,那么b就会和反向边的容量判断大小,之后剩余容量肯定能走d, 通过反向边的流量则借用原来a需要走的c边。
b+e>d:这个时候b的剩余容量就必须要走反向边,借用a原来走的c边。
a>e:如果a有剩余流量,它会讲剩余流量走c边,同时建立反向边让b边有机会借用c自己的c 边,防止b被阻塞。
好吧,我承认上述的分类有些乱糟糟的,确实这方面也不太好说,所以我下面以一种方便的形式展示一下 “借用” 的意思。
当a流来到中间上面的节点的时候,它会发现有两条路,分别是e和c。而a最讨厌选择了,因为选择意味着你需要照顾其他路的情况。
a完全不知道它流向的路是否被其他流共享,比如说当a流流向e-d的时候,它完全不知道c的存在,也不知道自己的流量会不会阻塞c。
a是个自私的人,它选择时候不在乎别人的想法,但a是个聪明的人。
当a选择自己的x份流量流向某一路的时候,那么a知道自己其他路的流量就会减少x份。
a表示:“我自己不想管其他人,反正我这里随便分配自己的流量,别人要是因为我的流量被堵住了,那他们来借用我减少流量的路好了。”
以上面的例子来说,就是这个例子
当a先走e的时候,他知道自己c路将缺少流量,于是它决定建立反向的桥梁让其他路能够沿着这条路取借用自己的c边。
于是b再遍历的时候发现自己的d边被a占用了,虽然它很不爽,但是它只能沿着a架起的桥梁取走c的边。
事实上,当a走d的时候它也在借用d的容量,借用的数量取决于这条路上容量的最小值。
a说:“抱歉我用了你们的路,但是相反你们也能借用我的路,但是你们借用的不能超过我借用的!”
也就是a再建立反向边的同时设定反向边的容量等于该路的流量。
在上面的图中,虽然b的流量不足以填满a借用的,但是假如我再下面中间节点的底下再连上一条流,这条流也会借用反向边走b,一直到把a的借债填满为止。
这就是我自己对于最大流反向边建立算法的理解。
a在决定流的流入方向的同时,还不想阻塞其他流,所以它建立反向边允许其他流借用自己的边。这样,互相借用达成一种平衡,就是算法正确性的保证。
FF算法:最基础的最大流算法
通过DFS増广,直到不能増广为止。
记最大流的流量为F,FF算法最多进行F次DFS,所以其复杂度为O(F|E|),每一次DFS的复杂度不确定,但是最坏的情况几乎是不存在的,所以还是比较快的。
FF算法就好像我们上面的人工过程,找到一个节点,从这个节点开始贪心填满与他相连的每一条边,用dfs同时建立反向边给予其他节点借用的机会,然后一点点算下去就行了。
最大流算法的精髓就是加了一条反向边,给了程序有一个后悔的机会,在一次DFS结束之后,每条正向边减去流向汇点的流量,每条反向边加上流向汇点的流量。
以下面这个图为例,第一次寻找的増广路是1->2->3->4,流量是5,如果没有反向边的话,就已经不能再増广了,但是很明显这不是最佳策略。
在引入反向边后可以发现,有一条新的増广路1->3->2->4,流量为5,最后发现没有増广了,求得最大流为10。
在FF算法中边是用邻接表来存储的,但是每次正向边和反向边的加减是要同时进行,所以在知道正向边的同时,要知道反向边的位置,所以结构体中有一个rev来存储反向边的位置。
#include <stdio.h>
#include <string.h>
#include <algorithm>
#include <vector>
#define N 10020
using namespace std;
int n, m, inf=0x7f7f7f;
bool book[N];
struct edge {
int v;
int w;
int rev; //在反向边中存储的位置
};
vector<edge>e[N];
void add(int u, int v, int w) //加边
{
e[u].push_back(edge{ v, w, e[v].size() });
e[v].push_back(edge{ u, 0, e[u].size() - 1 });
}
int dfs(int s, int t, int f)
{
if (s == t)
return f; //找到终点
book[s] = true;
for (int i = 0; i < e[s].size(); i++)
{
edge &G = e[s][i];
if (G.w > 0 && book[G.v] == false)
{
int d = dfs(G.v, t, min(f, G.w)); //两者之间流量较小的一个
if (d > 0)
{
G.w -= d; //改变正向边和反向边
e[G.v][G.rev].w += d;
return d;
}
}
}
return 0;
}
int FF(int s, int t)
{
int ans = 0;
while (1)
{
memset(book, false, sizeof(book)); //每次找増广路
int d = dfs(s, t, inf);
if (d == 0) //找不到增广路返回总流量
return ans;
ans += d;
}
}
int main()
{
int u, v, w;
while (scanf("%d%d", &m, &n) != EOF)
{
for (int i = 1; i <= n; i++)
e[i].clear();
for (int i = 0; i < m; i++)
{
scanf("%d%d%d", &u, &v, &w);
add(u, v, w);
}
printf("%d\n", FF(1, n));
}
return 0;
}
EK算法:每次BFS寻找増广路
EK算法是基于FF算法的,只是边的存储变为邻接矩阵存储,求増广路的过程变为BFS,在正向边和反向边的改变上也有变化,但是总体上的思路是一样的,或者说FF, EK, Dinic这三者的思想是相同的。
适合边较少的稀疏图。
因为EK算法图的存储是用邻接矩阵存储,所以每次在输入流量的时候应该加上原有的流量,在这道题上也有体现。
因为EK算法不能像FF算法中递归改变边的流量,所以在EK算法中记录的每一个点的匹配点,通过匹配点来改变边的流量,这一点只要仔细想想应该能明白。
#include <stdio.h>
#include <queue>
#include <string.h>
#include <algorithm>
#define N 220
using namespace std;
int n, m, e[N][N], pre[N], flow[N], inf=0x7f7f7f;
int bfs(int s, int t)
{
memset(pre, -1, sizeof(pre));
queue<int>q;
q.push(s);
flow[s] = inf; //最开始流量为无穷
while (!q.empty())
{
int u = q.front();
q.pop();
for (int v = 1; v <= n; v++) //求増广路
{
if (e[u][v] > 0 && v != s && pre[v] == -1) //注意v!=s
{
pre[v] = u;
q.push(v);
flow[v] = min(flow[u], e[u][v]); //现在的流量是流过来的流量和可以流走的量的最小值
}
}
}
if (pre[t] == -1) //如果没有到达终点
return -1;
return flow[t]; //返回流量
}
int EK(int s, int t)
{
int ans = 0;
while (1)
{
int d = bfs(s, t);
if (d == -1) //无法在找増广路
break;
ans += d;
int p = t;
while (p != s) //边的流量发生改变
{
e[pre[p]][p] -= d;
e[p][pre[p]] += d;
p = pre[p];
}
}
return ans;
}
int main()
{
int u, v, w;
while (scanf("%d%d", &m, &n) != EOF)
{
memset(e, 0, sizeof(e));
while (m--)
{
scanf("%d%d%d", &u, &v, &w);
e[u][v] += w; //这里要加上原有的流量
}
printf("%d\n", EK(1, n));
}
return 0;
}
Dinic算法:EK算法的优化
Dinic算法是EK算法的优化,实际上和FF算法也是很像的, Dinic通过BFS分层,在用DFS求増广路,可以达到多路増广的效果,基本上Dinic算法是比较优秀的算法了。
众所周知,网络流题目会卡FF和EK,但是不会卡Dinic
可以看到加边操作是和FF算法是一样的,分层也是一个比较常规的操作。
Dinic算法引入了一个当前弧优化:在一次BFS分层中已经搜索过的边不用再搜。
#include <stdio.h>
#include <string.h>
#include <queue>
#include <vector>
#include <algorithm>
#define N 220
using namespace std;
struct edge {
int v;
int w;
int rev;
};
vector<edge>e[N];
int n, m, inf=99999999, dis[N], iter[N];
bool book[N];
void add(int u, int v, int w) //加边
{
e[u].push_back(edge{ v, w, e[v].size() });
e[v].push_back(edge{ u, 0, e[u].size() - 1 });
}
void bfs(int s) //分层
{
queue <int>q;
memset(dis, -1, sizeof(dis));
dis[s] = 0;
q.push(s);
while (!q.empty())
{
int u = q.front();
q.pop();
for (int v = 0; v < e[u].size(); v++)
{
edge G = e[u][v];
if (dis[G.v] == -1 && G.w) //有流量时才加入
{
dis[G.v] = dis[u] + 1;
q.push(G.v);
}
}
}
}
int dfs(int s, int t, int f)
{
if (s == t)
return f;
for (int &v = iter[s]; v < e[s].size(); v++) //当前弧优化
{
edge &G = e[s][v];
if (dis[G.v] == dis[s] + 1 && G.w) //只有层数是+1时才増广
{
int d = dfs(G.v, t, min(G.w, f));
if (d > 0)
{
G.w -= d;
e[G.v][G.rev].w += d;
return d;
}
}
}
return 0;
}
int Dinic(int s, int t)
{
int ans = 0;
while (1)
{
bfs(s);
if (dis[t] == -1)
return ans;
int d;
memset(iter, 0, sizeof(iter));
while ((d = dfs(s, t, inf)) > 0) //多次dfs
ans += d;
}
return ans;
}
int main()
{
int u, v, w;
while (scanf("%d%d", &m, &n) != EOF)
{
for (int i = 1; i <= n; i++) //初始化
e[i].clear();
while (m--)
{
scanf("%d%d%d", &u, &v, &w);
add(u, v, w);
}
printf("%d\n", Dinic(1, n));
}
return 0;
}