网络流概述

uptada 2023/11/7:局部重构。

算法

基础概念:

  • 网络 : 一张有向图,其中每个边都有一个边权,被称为容量
  • 流:可以类比水流,从源点流出流入汇点
  • 源点和汇点:所有流流出的节点(S)和所有流流入的节点(T)
  • 对于一条边 \((x,y)\) 我们称 \(f(x,y)\) 为它的流量, 称 \(c(x,y)\) 为它的容量,称 \(c(x,y)-f(x,y)\) 为它的残余容量

注意这里 流量 \(\neq\) 容量

网络中的每一条边满足以下三条限制:

  • \(f(x,y) \le c(x,y)\)

    即经过一条边的流量不超过它的容量,即容量限制

  • \(f(x,y) = -f(y,x)\)

    一条边的流量与其反向边的流量之和为0,即斜对称性

  • \(\forall \ x \in V,x\neq S,x\neq T, \sum _( \ _u \ _ , \ _ x \ _ ) \ _ \in \ _ E \ f(u,x) = \sum _( \ _x \ _ , \ _ v \ _ ) \ _ \in \ _ E \ f(x,v)\)
    一个点总流入的流量 = 总流出的流量,即流量守恒

一般来说我们认为源点流出的流是无限的。

EK算法

前置概念

  • 增广路:一条从源点到汇点的路径,其所有边的残余容量都非负
  • 残余网络:在任意时刻,网络中的所有节点和残余容量大于零的边构成的子图

解决最大流问题的一个基本做法是不断寻找增广路,这里不加证明的给出结论

增广路算法总能找到一张网络的最大流。

朴素的 dfs 实现是 FF 算法,复杂度和容量相关,而基于 bfs 增广的 EK 算法有着更优秀的复杂度。

算法原理

EK 的思想是朴素的:首先,找到一条从源点到汇点流量为 0 的路径一定是可行的,之后再把这条路径上的流量加大到不能加为止就找到了一条增广路,只要不断地寻找增广路就能找到最大流。

如果你模拟一下这个过程就会发现,这样做正确性似乎有些问题,比如下面这张图


如果选择 1 -> 2 -> 3 -> 4 进行增广得到的最大流为 1

但是最大流显然是通过 (1 -> 3 -> 4) + (1 -> 2 -> 4) 得到的 2

为了解决这个问题,我们引入反向边的概念,给程序一个反悔的机会,具体来说,流经一条边和其反向边相当于并未流经这一条边。

反向边,顾名思义就是在每条正向边所连接的两个节点之间连一条相反的,边权为零的边,根据斜对称性,在每条边流过大小为 f 的流量时(表现在代码中就是边权减去 f),其反向边容量应该加上 f。

为了方便寻找反向边,我们采用成对变换的技巧,即把两点间的两条边存在 a 和 a+1 处,则 e[i] 和 e[i^1] 就表示两条边。

算法流程

  • 找到一条增广路
  • 找到这条增广路上的最小容量
  • 增加最大流
  • 重复第一步直到源点和汇点不连通

最后放上代码

luoguP3376 【模板】网络最大流

点击查看代码
inline bool bfs( ){
	memset( inq, 0, sizeof( inq ) );
	while( !q.empty( ) ) q.pop( );
	q.push( s ); inq[s] = 1;
	while( !q.empty( ) ){
		int x = q.front( ); q.pop( );
		for( int i = head[x]; i; i = e[i].nxt ){
			int y = e[i].v;
			if( inq[y] or !e[i].w ) continue;
			pre[y].v = x;
			pre[y].e = i;
			if( y == t ) return 1;
			inq[y] = 1;
			q.push( y );
		}
	}
	return 0;
}

inline int EK( ){
	int ans = 0;
	while( bfs( ) ){
		int mi = inf;
		for( int i = t; i != s; i = pre[i].v )
			mi = min( mi, e[pre[i].e].w );
		for( int i = t; i != s; i = pre[i].v ){
			int x = pre[i].e;
			e[x].w -= mi;
			e[x^1].w += mi;
		}
		ans += mi;
	}
	return ans;
}

EK算法时间复杂度上界是 \(O(nm^2)\) 的,虽然网络流算法运行时间很玄学能跑过 1e3~1e4 的数据,但我们需要更快。

dinic算法

在下面这张图中,EK算法暴露出一个致命的问题

如果选择了 s->a->b->t 这条路径那么EK算法需要执行大量不必要的操作,运行时间直接爆炸,这是我们不能容忍的。

所以就需要请出 dinic 算法,一般来说在 oi 中大部分的网络流问题都是采用 dinic 解决的。国际法规定不能卡dinic

算法原理

首先在残余网络上建立分层图(注意不是最短路的分层图),即对于一条边 \((x,y)\) ,都有 y 的层次d[y] = d[x] + 1,每次增广的时候只走下一层的节点。实际上相当于把 EK 中的 dfs 增广换成了 bfs 增广,一次可以增广多条增广路。

算法流程

  • 首先通过bfs对残余网络进行分层
  • 然后寻找增广路并更新残余网络
  • 最后累加最大流

放代码(无优化)

点击查看代码
bool bfs( ){
	while( !q.empty( ) ) q.pop( );
	for( int i = 1; i <= (n << 2); i++ ){
		dis[i] = 0;
	}
	dis[s] = 1;
	q.push( s );
	while( !q.empty( ) ){
		int x = q.front( ); q.pop( );
		for( int i = head[x]; i; i = e[i].nxt ){
			int y = e[i].v;
			if( dis[y] or !e[i].w ) continue;
			dis[y] = dis[x] + 1;
			q.push( y );
		}
	}
	if( dis[t] ) return 1;
	return 0;
}

int dfs( int x, int low ){
	int rlow = 0, used = 0;
	if( x == t ){
		ans += low;
		return low;
	}
	for( int i = head[x]; i; i = e[i].nxt ){
		int y = e[i].v;
		if( dis[y] == dis[x] + 1 and e[i].w ){
			rlow = dfs( y, min( low - used, e[i].w ) );
			if( rlow ){
				used += rlow;
				e[i].w -= rlow;
				e[i^1].w += rlow;
				if( used == low ) break;
			}
		}
	}
	return used;
}

void dinic( ){
	while( bfs( ) ){
		dfs( s, inf );
	}
}

这里直接在 dfs 中累加了最大流。

dinic 还有一个非常有用的优化,当前弧优化,通过它可以不访问那些不可能到达汇点的边,从而大大加快算法效率。

代码也很简单,只需要在代码里加上两行
for( int i = 1; i <= (n << 2); i++ ) cur[i] = head[i];

for( int &i = cur[x]; i; i = e[i].nxt )

完整代码如下

点击查看代码
bool bfs( ){
	while( !q.empty( ) ) q.pop( );
	for( int i = 1; i <= (n << 2); i++ ){
		cur[i] = head[i];
		dis[i] = 0;
	}
	dis[s] = 1;
	q.push( s );
	while( !q.empty( ) ){
		int x = q.front( ); q.pop( );
		for( int i = head[x]; i; i = e[i].nxt ){
			int y = e[i].v;
			if( dis[y] or !e[i].w ) continue;
			dis[y] = dis[x] + 1;
			q.push( y );
		}
	}
	if( dis[t] ) return 1;
	return 0;
}

int dfs( int x, int low ){
	int rlow = 0, used = 0;
	if( x == t ){
		ans += low;
		return low;
	}
	for( int &i = cur[x]; i; i = e[i].nxt ){
		int y = e[i].v;
		if( dis[y] == dis[x] + 1 and e[i].w ){
			rlow = dfs( y, min( low - used, e[i].w ) );
			if( rlow ){
				used += rlow;
				e[i].w -= rlow;
				e[i^1].w += rlow;
				if( used == low ) break;
			}
		}
	}
	return used;
}

void dinic( ){
	while( bfs( ) ){
		dfs( s, inf );
	}
}
} 

dinic算法时间复杂度是\(O(n^2m)\),可以胜任大多数最大流题目了。

费用流

现在给每个边加上一维费用表示每单位的流量会产生这么多的代价,要求求出最小费用/最大费用最大流。

只需要把最大流中的 bfs 换成 spfa 即可。

点击查看代码
namespace mcmf{
    const int N = 2e4 + 10;
    struct edge{
        int u, v, f, w, nxt;
    } e[N];
    int head[N], cnt = 1;
    int s, t, cst, fl;
    int flow[N], pre[N], dis[N], inq[N], prew[N];
    void add(int u, int v, int f, int w){
        e[++cnt] = (edge){u, v, f, w, head[u]};
        head[u] = cnt;
        e[++cnt] = (edge){v, u, 0, -w, head[v]};
        head[v] = cnt;
    }
    queue<int> q;
    bool spfa( ){
        memset(dis, 0x3f, sizeof(dis));
        flow[s] = inf; dis[s] = 0; pre[t] = -1;
        q.push(s); inq[s] = 1;
        while(!q.empty( )){
            int x = q.front( ); q.pop( ); inq[x] = 0; 
            for(int i = head[x]; i; i = e[i].nxt){
                int y = e[i].v;
                if(dis[x] + e[i].w < dis[y] and e[i].f){
                    dis[y] = dis[x] + e[i].w;
                    pre[y] = x; prew[y] = i;
                    flow[y] = min(flow[x], e[i].f);
                    if(!inq[y]) q.push(y), inq[y] = 1;
                }
            }
        }
        return pre[t] != -1;
    }
    int EK( ){
        cst = fl = 0;
        while(spfa( )){
            int now = t;
            fl += flow[now];
            cst += dis[now] * flow[now];
            while(now != s){
                e[prew[now]].f -= flow[t];
                e[prew[now] ^ 1].f += flow[t];
                now = pre[now]; 
            }
        }
        return cst;
    }
};

最小割

求选出一个边集使得源汇不连通的总权值和最小的边集,有一个非常重要的定理是最大流等于最小割。
证明很简单,如果你会线性规划的话你会发现最大流和最小割实际上是对偶问题,从残量网络上来理解的话,最终的残量网络上源汇是不连通的,而这时候求出的最大流就刚好对应使得源汇不连通的边权和。

建模

网络流的题目大多都是考察建模能力的,上面的算法熟练掌握板子就差不多了。

problem1 【练手题】

对每个人和座位分别建点,源点连向每个人,每个人连向他们想要的座位,每个座位连向汇点,边权为2,因为每个座位上可以坐两个人。

image

problem2【三分图匹配】

考虑限制,每个人可以选择 文/理,可以考虑用二分图,选特定集合的人有额外收益,考虑新建一个节点表示选择这个节点相连的所有节点。

最终建图:每个点拆成两个表示文理,源点向文连边,汇点向理连边,容量为选文/理的价值,文理之间连inf,源点连向虚拟节点连边,容量为same,虚拟节点向对应的相邻节点连边,容量为 inf,汇点同理。

这样割断了源点和对应节点的边代表放弃了选文的收益,如果要获得相邻的人全选文的收益就要割掉所有选理的边,也可以直接放弃这个收益。
image


problem3【最小边数最小割】

题意是求边数最小的最小割,二维费用首先想到费用流,但是很遗憾这题需要求最小割。观察一下代价的性质,最小割边数不超过 n,由此引发出一个清奇的思路,把原来每条边的容量设成 \(c*n+1\),这样走一条边相当是同时累加了二维费用,跑 dinic 即可。

problem4【优化建图】

考虑把点按 popcount 奇偶性分类,按题中规则连边,点数 \(n2^n\),考虑优化,每次增广都会减少 \(n*2-1\) 组匹配,有用的只有 \(k*(n*2-1)\) 组,贪心的选取最大的即可。

problem5【集合划分模型】

一种非常重要的最小割模型,考虑一个集合里的作物的共同代价要么不选,要么就得全放到一个集合里,等价于全不能放在另一个集合里,非常符合最小割的定义。建图:每个点向源汇点分别连容量为放入A/B代价的边,建新点连向源点容量全为放入A的代价,连向在此集合中的点,容量为 inf。

参考文献

https://zhuanlan.zhihu.com/p/122375531

posted @ 2022-08-25 12:16  Kun_9  阅读(162)  评论(1编辑  收藏  举报