• 网络最大流


    2019-02-22 更新

    理解网络

    一张网络,是一张带权有向图。比喻成输水系统可能好理解——

    源头是大水库,想输出多少就输出多少。

    但是,想要输出到目的地,需要经过中转点。中转点不产生新流量、也不私吞;接受多少流量,就同时输出多少流量

    点与点之间管道的容量(可以理解为水流量限制;注意“流量”指单位时间通过量,可以想象一下)是有限的;一条边上的流量不能超出其容量,容量很可能是有残余的。因为这些边上的限制,源头并不能无限输出。

    于是有的人就想要求出:这张网络运作起来的时候,总流量最大能有多少。由于容量限制比较复杂,似乎不容易规划一个最佳方案。

    下面来了一张经典图和一只猴子。

    边上的数字是其容量限制。

    红色的路径,是猴子随便添加的一条可行流(电脑不能看见整张图,所以它跟猴子没啥区别)。这个时候总流量是 (1)

    然后猴子继续随机尝试。想要让水流通过下方的黑边,但接下来没法把这份流量传到 (T)。猴子决定吃水果去了。

    现在水正在按照红色路径流动,流量只有 (1),就没法操作了。但你凭借聪明才智,很容易发现这是一种糟糕的策略,只要开始的时候 (S) 沿着上下两条路走到 (T),总流量就有 (2) 了。

    机智的猴子决定回来暴搜所有流法,它能解决这种规模的数据就已经很满意了……当然我们有更好的方法:

    在水顺着管子流过去的时候,添加一条反向边,边的容量等于此次流量,允许其它路径过来。啥玩意儿?

    想象红色管子里这个水正在咕咕流动,就这个方向。网络中暗暗显现了它们对应的反向边。

    这时候再次尝试找路(S) 顺着下方管道给 (b) 传过去 (1) 流量。然后 (b) 找到了一条反向边流到 (a)?有点颠覆世界观啊。

    不必理解成 (b) 流回 (a),而可当作 (a) 的反悔。

    为主体进行考虑。(a) 看到源点向 (b) 给予 (1) 流量,于是 (a) 决定收回它给予的等量流量,拿去做别的事情,于是管道被空置了。

    “我先前帮助 (b),给他 (1) 流量流到终点去;现在源点跟他关系那么好,支援他 (1) 流量,使他足够维持输出,那我也要拿回那 (1) 流量,不流向他了!”

    (其实你不拿回的话,(b) 就不平衡了)

    于是,对中间的边,(a) 不进行输出

    当然,在算法运行的时候,“某次找到这条路并尝试输出、接下来某次找路时退回”的过程是存在的。退回方向的反向边(即原边)获得了 (1) 份新的残余容量,已经回到最早的状态了。(a) 收回流量后,它决定流向终点,网络又增加了 (1) 流量(原来的流量没有被破坏)。

    从源点开始,顺着有流量的边,只要能找到一条能到达汇点的路(不管路径上是否有“退回”操作),就会使网络总流量增加,这种路叫增广路。上面是两次增广,然后就发现网络就没有增广路了,并且成果好像还不错。


    最大流的算法

    对一张网络,是不是只要不断找增广路(增广过程中记得提供退回操作的机会),直到找不到,就是网络最大流?

    抱歉,这是真的。为什么呢?

    ...无数实践已经证明。

    Part 1

    一个网络正在按照猴子的计划流动。

    于是猴子作了一个割。割是一个边集:这些边如果去掉,就会使图变成两个不相连的点集,一个点集 (S) 包含源点,一个点集 (T) 包含汇点。

    原本 (S)(T) 那几条边,它们的净流量(实际流量)称为 (S)(T) 的净流量。注意,可能有水从 (T) 流向 (S),它们对净流量是负的贡献。

    对于这张网络,除了源点和汇点,其它点都是“流入多少,就流出多少”,没有实际贡献或收入。

    汇点收到的流量,是 (T) 中所有点的综合作用;(T) 中的点本身(除了汇点),既不产生流量也不私藏流量,那么它们从哪里获得给汇点的流量呢?只能是 (S) 传过来的。所以,

    • (S)(T) 的净流量 = 源点输出 = 汇点收入 = 网络总流量

    不管怎么构造割,都有这个结果,所以,

    • 图上任何一种割的净流量 = 网络总流量

    因为割的净流量不可能超过此割的总容量(当然啦),所以,

    • 网络总流量 <= 图上任何一种割的容量

    Part 2

    经过猴子的调整,它的网络上没有增广路了。源点顺着残量网络到不了汇点了。

    源点顺着残量网络能够到达的点,叫做点集 (S)(至少包含自己),剩下所有不能到达的点,叫做点集 (T)。则 (S) 连向 (T) 的边都没有残余容量(如果有残量边,(S) 也顺着这条边包含了对面的点)。

    如果把 (S)(T) 中间的边割断,那么这个割的净流量 = 这个割的容量(因为边上都没有残余容量了)。

    又因为 Part 1 证的“网络总流量 = 任意一个割的净流量”,所以网络总流量 = 此割的容量(①)

    其实这很不容易做到的,因为 Part 1 证了“网络总流量 <= 图上任何一种割的容量”,所以“①”一定是面这个不等式的交界处。

    所以,这个网络流已经是众望所归。

    一个总体的印象是:只要没有增广路,即顺着残量网络到不了汇点,就说明存在一个无残量的割,其净流量等于容量;因为网络总流量同时等于图上任何一个割的净流量,也就同时绝不会超过图上任何一个割的容量,所以当总流量 = 某割的容量时,已经没有比它更大的流,分号以前达到了这个条件,所以现在是最大流。

    (还顺便证明了最大流 = 最小割)

    增广路定理的证明其实很重要,它把网络最大流问题化简为:

    不断找增广路就好了

    但是考虑到每次流过去,反向边容量又增加了,提供太多的反悔机会,会死循环吗?

    不会的。首先明确,最大流是有限的(当然);而每次找增广路,总流量不会减少,只会增加(重申一下,一条边的“收回流量”操作并不会减少流量,毕竟收回的前提是对方获得支援;增加流量本身没有任何代价,只是有各边的容量限制着),在有限次增广后,总流量一定会达到最大值,然后找不到增广路,结束。

    实现起来也有很多细节。

    #include <cstdio>
    #include <cstring>
    #include <algorithm>
    using std::min;
    
    const int N = 10010, E = 200010;
    
    int n, m, s, t;
    int first[N];
    int to[E], nxt[E], val[E]/*残余容量*/;
    int cnt = 1;
    //cnt初值1,第一条边的标号为2(二进制10),第二条是3(二进制11) 
    //有啥好处呢?
    //我们加入一条边时,紧接着加入它的反向边(初始容量0) 
    //这两条边的标号就是二进制最后一位不相同,一个0、一个1
    //所以要召唤 p 这条边的反向边,只需用 p ^ 1
    //如果cnt初值为0,就做不到。当然初值-1也可以,略需改动
    
    //关于图中真正的反向边,可能引起顾虑,应该让它们标号相邻? 
    //其实不用。该找到的增广路都会找到的 
    
    bool vis[N];//限制增广路不要重复走点,否则很容易爆栈 
    //兜一大圈走到汇点,还不如直接走到汇点
    
    void addE(int u, int v, int w) {
    	++cnt;
    	to[cnt] = v;
    	val[cnt] = w;
    	nxt[cnt] = first[u];
    	first[u] = cnt;
    }
    int dfs(int u, int flow) {
    	//注意,在走到汇点之前,无法得知这次的流量到底有多少 
    	if (u == t)
    		return flow;//走到汇点才return一个实实在在的流量 
    	
    	vis[u] = true;
    	for (int p = first[u]; p; p = nxt[p]) {
    		int v = to[p];
    		if (val[p] == 0 or vis[v])//无残量,走了也没用 
    			continue;
    		int res = 0;
    		if ((res = dfs(v, min(flow, val[p]))) > 0) {
    			//↑顺着流过去,要受一路上最小容量的限制
    			val[p] -= res;//此边残余容量减小
    			val[p ^ 1] += res;//以后可以顺着反向边收回这些容量,前提是对方有人了 
    			return res;
    		}
    	}
    	return 0;//我与终点根本不连通(依照残量网络),上一个点不要信任我
    }
    int main() {
    	scanf("%d %d %d %d", &n, &m, &s, &t);
    	for (int i = 1; i <= m; ++i) {
    		int u, v, w;
    		scanf("%d %d %d", &u, &v, &w);
    		addE(u, v, w);
    		addE(v, u, 0);//和正向边标号相邻
    		//反向边开始容量为0,表示不允许平白无故走反向边
    		//只有正向边流量过来以后,才提供返还流量的机会
    	}
    	int res = 0, tot = 0;
    	while (memset(vis, 0, sizeof(vis)) and (res = dfs(s, 2e9/*水库很强*/)) > 0)
    		tot += res;//进行若干回合的增广
    
    	printf("%d
    ", tot);
    	return 0;
    }
    
    

    这种直接深搜找增广路的办法叫做 Ford-Fulkerson(FF)算法。

    由于每次只找一条路,这条路还可能绕远路(可能经过 n 个点才到达汇点),而且增加流量是路上最小的权值,效率低,好像很容易被卡掉。但上面的代码能是通过模板题的,最慢一个点 500ms。

    不过你可以看出,这个算法的复杂度和流量有关,令人担心。

    Maximum Flow Faster Algorithm

    我们还是有办法解决 FF 效率低的问题。

    • 每次多路增广:u 点通过一条边,向 v 输出流量以后,v 会尝试到达汇点(到达汇点才真正增广),然后 v 返回实际增广量。这时,如果 u 还有没用完的供给,就继续尝试输出到其它边。

      但是要警惕绕远路、甚至绕回的情况,不加管制的话极易发生。怎么管?

    • 源点顺着残量网络想要到达其它点,需要经过一些边对吧?按照经过的边数(即源点出发以后的距离)把图分层,即用 bfs 分层。每次尝试给予时,只考虑给予自己下一层的点,就可以防止混乱。

    • 综合上面两条。每回合也是从源点出发,先按照当前残量网络分一次层,随后多路增广,尽可能增加流量。增广过程中,会加入一些反向边,这些反向边逆着层次图,本回合并不会走。所以还需要进入下一回合。一直到 bfs 分层时搜不到汇点(即残量网络断了)为止。

    这是 Dinic 算法。如果懂 FF,这个算法也很块能懂。

    可是它每次只按照 bfs 分层的固定方向进行增广,还能保证正确性吗?这个好理解。只要图中还有增广路(源点顺着残量网络到达汇点的路),bfs 分层就会搜索到汇点,于是增广就不会停止,最终也止于没有增广路的局面。

    虽然从它“每次多路增广、绕弯少”能够大致体会它比 FF 快,但它保证会快吗?

    ...无数实践也已经证明。

    要是把 Dinic 卡到上界的话,模板题都过不去了。

    #include <cstdio>
    #include <cstring>
    #include <algorithm>
    using std::min;
    
    const int N = 10010, E = 200010;
    
    int n, m, s, t, ans = 0;
    int cnt = 1, first[N], nxt[E], to[E], val[E];
    inline void addE(int u, int v, int w) {
    	to[++cnt] = v;
    	val[cnt] = w;
    	nxt[cnt] = first[u];
    	first[u] = cnt;
    }
    int dep[N], q[N], l, r;
    bool bfs() {//顺着残量网络,方法是 bfs;这是个bool型函数,返回是否搜到了汇点 
    	memset(dep, 0, (n + 1) * sizeof(int));//记得开局先初始化 
    
    	q[l = r = 1] = s;
    	dep[s] = 1;
    	while(l <= r) {
    		int u = q[l++];
    		for(int p = first[u]; p; p = nxt[p]) {
    			int v = to[p];
    			if(val[p] and !dep[v]) {//按照有残量的边搜过去 
    				dep[v] = dep[u] + 1;
    				q[++r] = v;
    			}
    		}
    	}
    	return dep[t];//dep[t] != 0,就是搜到了汇点 
    }
    int dfs(int u, int in/*u收到的支持(不一定能真正用掉)*/) {
    //注意,return 的是真正输出的流量
    	if(u == t)
    		return in;//到达汇点是第一个有效return 
    	int out = 0;
    	for(int p = first[u]; p and in; p = nxt[p]) {
    		int v = to[p];
    		if(val[p] and dep[v] == dep[u] + 1) {//仅允许流向下一层 
    			int res = dfs(v, min(val[p], in)/*受一路上最小流量限制*/);
    			//res是v真正输出到汇点的流量
    			val[p] -= res;
    			val[p ^ 1] += res;
    			in -= res;
    			out += res;
    		}
    	}
    	if(out == 0)//我与终点(顺着残量网络)不连通 
    		dep[u] = 0;//上一层的点请别再信任我,别试着给我流量
    	return out;
    }
    int main() {
    	scanf("%d %d %d %d", &n, &m, &s, &t);
    	for(int i = 1; i <= m; ++i) {
    		int u, v, w;
    		scanf("%d %d %d", &u, &v, &w);
    		addE(u, v, w);
    		addE(v, u, 0);
    	}
    	while(bfs()) 
    		ans += dfs(s, 2e9);
    	printf("%d
    ", ans);
    	return 0;
    }
    

    注意理解多路增广:虽然一个点要枚举所有出边,但实质仍然是 dfs,过程图类似于树。

    复杂度可能的证明:《浅谈基于分层思想的网络流算法》 P4 ~ P14。但是这里关于增广次数的证明中,某个结论好像忽略了一种情况。

    Dinic 能应付大多数网络流题目了,大概因为网络流题目主要考察建模能力吧。当然最大流还有更高效的算法。


    更新日志:

    2019-02-22 代码和注释改动。

  • 相关阅读:
    页面模板
    HTML,CSS,JaveScript
    TCP三次握手
    BLDC无刷直流电机的原理及驱动基础
    调试日志——基于stm32的智能声光报警器(三)
    调试日志——基于stm32的智能声光报警器(二)
    调试日志——基于stm32的智能声光报警器(一)
    Jlink-10 pin 的定义(stm32使用)官方定义
    关于学习新知识的一点想法
    前端笔记-javaScript-3
  • 原文地址:https://www.cnblogs.com/cicos/p/10258708.html
Copyright © 2020-2023  润新知