网络流学习笔记 1 - 最大流、最小割、上下界网络流

终于有时间来学学这个东西了,再不学就落后于时代了(

前置知识:对 dfs 和 bfs 有一定了解,而且掌握了最短路算法。(实际上 spfa 和 dijkstra 都可以)

因为我前几次沪粤联赛打得不好就是因为 C 是网络流板题,所以就来学学。

现在我们讲最大流和最小割。

网络流,英文 Network FlowNetwork 就是网络,Flow 是流的意思。

这个“流”可以是流水(边就是管道),可以是流电(边就是光纤),也可以是流“人”(边可以是航班也可以是高铁等等)以及其他的一些东西。


为什么说是“网络”而不是“图”呢?因为这个“网络”中的边有不同之处。

首先,边一定是有向的。

网络中的边也是有边权的,但是这不是普通的边权,而是容量,指的是一条边上面最多能通过的流的数量总量。

而且,中途的一个结点进入了多少流量,最终就必须要想办法把这些流量给走出去。一点也不能多,一点也不能少。

最大流 max flow 的意思就是从源点到汇点,最多能通过的流的数量。(允许流量走多条不相同的路径)

很显然最小流并没有意义,就是 \(0\)

解释一下一些术语:

源点:就是起始点的形象表述。因为可以想象一条流水一定有源头。

汇点:就是终点的形象表述。因为一条流水最终可能会汇入大海。

例如这个图,最左边的点是源点,最右边的点是汇点,则从源点到汇点最多可以容纳 \(8\) 个流。当然这是最简单的情况,还有更加复杂的。

假设源点为 \(4\),汇点为 \(3\),那么这个图就可以容纳 \(50\) 个流:\(4 \to 2 \to 3\) 可以容纳 \(20\) 个流量,\(4\to 3\) 可以 \(20\) 个,而 \(4 \to 2 \to 1 \to 3\)\(10\) 个。显然这样就不是再路径上面取最小值那么简单了。


请注意,源点和汇点可以有很多个。

当然,除了最大流以外,还有其他将来我们要学会求解的东西。当然这些东西后面才会讲到,这里先简单阐述一下它们的定义。

最小割 min cut选择总权值和最小的一些边,使得如果断开这些边,从源点到汇点不存在任何的流。

有上下界的最大流:就是相当于在边的上面有通过流的总数的上界和下界。 我们原本就有了上界,但是现在增加了下界,也就是说通过的流的总数不能太少。这个时候的最大值和最小值都要有所考虑。

最小费用流(min cost max flow): 一般都默认是最小费用最大流,但是也有时候不仅仅是“最大”,而可以是“最优”。这个时候类似上下界最大流,还加了一个代价限制,导致题目难了许多。

最大流

前面铺垫了很多,现在终于可以讲一下到底怎么做了。

前面我们说到源点和汇点有很多个,这个时候我们就可以建两个虚点,一个是超源点,一个是超汇点。

建完虚点之后可以这么处理:将超源点想所有的源点连一条边权正无穷的边,所有的汇点向超汇点连一条边权正无穷的边。

很容易理解,正确性就不讲了。所以现在我们就变成了一个源点 \(s\) 和一个汇点 \(t\)


这个问题在生活中运用很多,在旅游规划、网络线路规划都有体现出来。

很容易发现依靠我们以前的图上算法都不能解决这个问题(\(dp\) 也无法解决,当时的沪粤联赛的那道题我写了 DAG \(dp\) 挂了 \(64\) 分)。


Ford - Fulkerson 算法

开始进入正题,这个东西到底该怎么求?

冷知识:这里的 Ford 就是那个发明 Bellman_Ford 算法的人。

为了表述方便,先来讲解几个以后需要用到的概念(这段东西必须要掌握,要不然后面看不懂)。

增广路径 augment path:这个东西我们应该不陌生了,二分图里面就出现过这个东西。

但是这个时候增广路的意思改变了一下:当前的某一条流量 \(>0\)\(s \to t\) 的路径。

而这个算法本身就是在找增广路。


首先我们有一种比较直观的思路:

假设我们目前找到了一条增广路,其流量为 \(3\)

很容易发现,这个时候我们可以把这个流量是 \(3\) 的提出来然后计入答案了。变成这个样子:

我们可以重新改一下边权,然后继续找增广路,在最后如果发现没有一条流 \(>0\) 的增广路就结束了,我们就找到了最大流。


这个时候又出现了一个问题:

  • 你这样做,真的可以最后得到正确的最大流吗?

这个问题是很容易就可以提出来的:你随便找到一条增广路径你就把它消耗了,有没有可能后面会存在流量更大的增广路径不能用了呢?

然后我们就会发现这个方法是假的

举个例子:

例如这个图,如果我们先走了中间这条增广路径,图就会变成这样:

但是我们就会发现此时没有增广路了。所以答案为 \(3\),是这样吗?

如果我们走的是最上面的和最下面的路径,答案就是 \(6\) 了。这告诉我们这个方法是假的。


不要气馁,思考解决方案。那么怎么办呢?

因为我们的假做法是贪心的,所以考虑反悔贪心。我们考虑对边的使用反悔。

因为一条边的总流量是不变的,所以可以将提出来的流量总数作为反悔贪心的反悔筹码。 提出来的流量总数就是图中的绿字。

这个时候我们可以这样处理:将还剩下的流量总数存在正向的边上,把提取出来的流量总数存在反向的边上。

而我们这个时候允许让增广路走正向的边或者是反向的边。想想,这个时候会发生什么?

  • 如果走的是正向的边,那么最终一起提取出来的流量(很显然是增广路上面的边权最小值)会被正向的边权减去,而反向的边权加上这个值。这种就相当于减少了当前的总量,增加了反悔的余量。是正常的贪心操作。

  • 如果走的是反向的边,那么最终一起提取出来的流量(很显然是增广路上面的边权最小值)会被反向的边权减去,而正向的边权加上这个值。这种就相当于增加了当前的总量,减少了反悔的余量。这就是另一种反悔贪心操作。

所以发现如果建正向反向边的话就可以非常自然地处理贪心和反悔操作。 个人感觉还是很妙的。也可以感性理解一下,这个算法是正确的(因为允许反悔)。

注意我们这个时候找到一条增广路径就直接让答案增加这条增广路径的边权最小值即可。

例如我们现在的有一条边 \((u,v)\) 的容量是 \(6\),而我们提取出来了 \(4\),还剩下 \(2\) 的容量。则我们可以设 \((u,v)\) 的容量为 \(2\),而 \((v,u)\) 的容量为 \(4\)

很容易发现我们这样就可以使得上面的例子满足要求了,因为我们可以:

走红色的路径,就可以获得一条新的增广路径。

然后再看看,发现上面的三条边和下面的三条边的正向边都变成了 \(0\),而中间的正向边变成了 \(3\),和走最上面和最下面的路径是等价的!!这就是反悔贪心的威力。

一直这样找,每一次更新反边,就可以最终得到答案。

最大流最小割定理

最小割就是从图里面切断一些边使得图不存在增广路,这些边的最小权值。而最大流就是图中所流通的最大流量。

很容易想到二分图中差不多的定义:最大流对应二分图中的最大匹配,最小割对应二分图中的最大独立集。

在二分图中我们有 \(\text{Konig}\) 定理,也就是 最大匹配 = 最大独立集。

那么在更加复杂的有向图里面还存不存在这个定理呢?是存在的。

直接说定理,稍后再讲正确性:最大流最小割定理:任意有向图中的最大流等于最小割。

最大流最小割定理证明

设源点为 \(s\),汇点为 \(t\)源点及其某些能够到达的点构成 \(U\) 集合,汇点及其某些能够到达它的点构成 \(V\) 集合。

Note:\(U\)\(V\) 都可以放弃加入一些结点,但是一定要保证每一个结点要么在 \(U\) 要么在 \(V\),其中 \(s\) 一定在 \(U\) 里面,\(t\) 一定在 \(V\) 里面。

并设 \(E(U,V)\) 表示一端在 \(U\) 一端在 \(V\) 的边的集合。很显然一共分两种边,一种是前者连向后者,一种是后者连向前者。

容易发现,从 \(U\)\(V\) 的三条边恰好就是一个割。

扩展到一般情况,我们会发现:对于任意的 \(U\)\(V\),一定存在恰好一种割的方案与之对应,就是从 \(U\) 集合到 \(V\) 集合的所有边。

所以我们可以更改割的定义:将点集划分成两个子集 \(U,V\)\(U\) 不应包含 \(s\) 不可达的结点,\(V\) 不应包含不可达 \(t\) 的结点,则割就是所有从 \(U\) 中结点到 \(V\) 中结点的边集。


根据 MO 的思想,我们如果需要证明 \(mc=mf\),就需要同时证明 \(mc \ge mf\)\(mc \le mf\)。(这个思想比较重要)

最小割 \(\ge\) 最大流

考虑直观理解。不妨将流量想象成水流。

想象水流从源点 \(s\) 流向汇点 \(t\)。割 \((U,V)\) 就像一道“闸门”,所有水流必须通过从 \(S\)\(T\) 的边才能到达 \(t\)

然后就会发现,整个网络的流量不可能超过这个闸门的总容量(即割的容量)。

可以感性理解一下,如果整个网络的流量超过了这个闸门的总流量,那就很神奇了,你这个闸门总共也就只能通过 \(x\) 这么多的水,然后你告诉我实际上通过了 \(>x\) 的水?

所以发现流的总容量一定小于等于割的总容量,所以发现最大流一定 \(\le\) 最小割,证毕。

最小割 \(\le\) 最大流

显然这个时候直接直观理解比较困难,所以使用一个新的概念:残余网络。

设点集为 \(X\),原网络的边集为 \(E\),而且 \((u,v)\) 这条边的权值为 \(c(u,v)\)。并设 \(U,V\) 两个集合的割(也就是从 \(U\)\(V\) 的所有边)为 \(c(U,V)\)

并设 \((u,v)\)流函数\(f^*(u,v)\),也就是最大流方案中 \((u,v)\) 上经过的流量。

首先考虑构造残余网络:设 \(f^*\) 是最大流,构建残量网络 \(G_{f^*}\)

  • 正向边容量:\(c(u,v) - f^*(u,v)\)
  • 反向边容量:\(f^*(v,u)\)

说人话,就是我们跑完 Ford - Fulkerson 算法之后剩下的网络。其中包括正向边和反向边,定义没有改变。

然后考虑定义割集:

\[U = \{ v \in X \mid \text{在 } G_{f^*} \text{ 中 } s \to v \text{ 可达} \}, \quad V =X \setminus U \]

由最大流性质,\(t \notin U\)(如果 \(s\) 能够通过走正权边到达 \(t\) 的话那么就还有增广路,Ford - Fulkerson 算法这个时候还没有跑完),所以 \(c(U, V)\) 是合法割。

考虑分析这个时候的割边的性质:

  • \(\forall (u,v) \in E\)\(u \in U, v \in V\):(也就是原网络中的正向边)

则显然在最终的残余网络中,\((u,v)\) 不存在了。(否则 \(v\) 应该属于 \(U\)

则有:

\[f^*(u,v) = c(u,v) \]

  • \(\forall (v,u) \in E\)\(v \in V, u \in U\):(也就是原网络中的反向边)

根据上面同理 \((v,u)\) 这条反向边不存在于残余网络中,否则 \(v\) 也应该属于 \(U\)

则有:

\[f^*(v,u) = 0 \]


然后就考虑计算一下流的值,很显然就是这个式子:

\[|f^*| = \sum_{\substack{u \in S \\ v \in T}} f^*(u,v) - \sum_{\substack{v \in T \\ u \in S}} f^*(v,u) \]

代入上面我们推出来的割边性质:

\[|f^*| = \sum_{\substack{u \in S \\ v \in T}} c(u,v) - 0 = c(S, T) \]

\(\min c(S, T) \leq c(S, T) = |f^*| = \max |f|\)。所以整理一下就可以知道 \(\min c(S,T) \le \max |f|\)

所以这部分证毕


综上可得:

  • 最小割 \(\ge\) 最大流。
  • 最小割 \(\le\) 最大流。

综上可得最小割 \(=\) 最大流,证毕。

Ford - Fulkerson 算法实现

我们可以从某个点开始深搜,搜到增广路就放手,然后重新来一遍。时间复杂度是玄学,只能拿到 84 分

现在的问题是:如何找到两条互相相反的边。注意,图并不保证没有重边,所以我们不能使用 map。

但是我们可以在每一条边新开一个数,记录 \((u,v)\) 这条边的反边 \((v,u)\)\(v\) 的邻接表里面的位置,这个可以在加边的时候就记录下来。然后就没有然后了。

模板题:P3376。

#include <bits/stdc++.h>
#define int long long
using namespace std;
int n, m, s, t;
const int N = 210;

struct edge {
	int to, val;//要去的点,这个边的容量
	int id;//反边的位置
};
vector<edge> v[N];//邻接表
bool vis[N];//因为环只会让增广路上面能提取出来的流量越来越少,所以这里要排除环

int dfs(int u, int fl) {//目前还需要找 x -> t 的增广路,返回 x->t 路径的最小边权(即能通过的流量)
//记 s->x 已经走的路径的最大边权为 fl
	if (u == t)
		return fl;//如果已经搜到了终点就直接返回
	vis[u] = 1;//记录
	for (auto &[to, val, id] : v[u])//枚举边。因为我们待会要修改 val 所以要采用指针
		if (val > 0 && !vis[to]) {//如果要去的点没有被走过而且这条边能够通过流量
			int f = dfs(to, min(fl, val));//计算流量
			if (f > 0) {//如果这是一条增广路
				val -= f, v[to][id].val += f;//那么就更新正边和反边的容量
				return f;
			}
		}
	return 0;//没有找到增广路
}

signed main() {
	cin >> n >> m >> s >> t;
	for (int i = 1; i <= m; i++) {
		int x, y, val;
		cin >> x >> y >> val;
		v[x].push_back({y, val, v[y].size()});//加边,注意要记录反边在对面邻接表的位置
		v[y].push_back({x, 0, v[x].size() - 1});//因为这个时候 v[x] 的长度已经比原来的多了一个,所以需要 -1
	}
	int ans = 0, f;//求最大流
	while ((f = dfs(s, 1e15)) != 0) {//找增广路
		memset(vis, 0, sizeof vis);//先初始化,为了待会更好地深搜
		ans += f;//直接加上增广路上面能提取出来的流量
	}
	cout << ans << endl;//输出
	return 0;
}
//Ford - Fulkerson O(?)

很容易发现,这个代码虽然很短,但是时间复杂度不能保证,以至于连 \(n=100,m=5000\) 的数据也没法通过。

(如果我没有记错的话,这个东西的时间复杂度最差有 \(O(nV)\)

所以我们需要新的算法,这个算法就是 Edmond-Karp 算法。

Edmond - Karp 算法

Edmond - Karp 算法就是 Ford - Fulkerson 算法的一个优化话说怎么名字都这么长。

其最大的特点就是将 Ford - Fulkerson 的深搜找增广路改成了广搜找增广路。

很容易发现,Ford - Fulkerson 就是找到任意一条增广路就返回,重新更新 vis 数组了。但是有时候可以连续多找几条增广路,从而实现节省时间的作用。

这个时间复杂度是多项式的,可以通过模板题。但是已经有了更加好写的 Dinic 算法了,为什么不将其替代呢?

我们后面可以证实学习这种算法意义并不大。因为我们还有一种比它时间复杂度更加优秀,也更好写的做法。这就是 Dinic 算法。

Dinic 算法

Dinic 算法是 Edmond - Karp 算法的优化版,也就是 Ford - Fulkerson 的终极优化版。

其核心思想就一句话:先广搜分层,然后再按照层来找增广路。

注意,Dinic 算法仍然是每一次找一条增广路,然后重来继续找增广路。但是 Dinic 和 Ford - Fulkerson 也有很大的不同点。


前面我们谈到广搜分层,那么到底是个什么玩意呢?

第一层就是与 \(s\) 直连的点。

第二层就是与第一层直连的点。以此类推。

而我们的 Dinic 算法是这样的:只找 \(s\to\) 第一层的结点 \(\to\) 第二层的结点 \(\to \cdots t\) 的增广路径,直到没有为止。

没有增广路径了怎么办呢?我们可以对剩下的残余网络进行重新分层。

补充说明:可能有些同学会问:“网络一直都没有变,分层怎么可能变呢?”,上面重新分层我少说了一个点,就是必须要走剩余容量 \(>0\) 的边。

这样,每一次找到的都是当前最短的(即边数最少的)增广路。(因为显然总共的层数就是 \(s \to t\) 的最短路径,没有更短的了,这个很容易理解)


那走这样有什么好的呢?好处就是,我们可以计算时间复杂度了。

举个例子:

中间的边的容量为 \(1\)

跑普通的 Ford - Fulkerson 算法的时候,我们会走很多增广路径,但是每一个增广路径只要通过了中间的边就一定不会很大。所以我们要很久才能得到正确的答案。具体的话呢, \(2\) 条增广路就可以解决的事情我们用了 \(200\) 次,非常地浪费时间。

但是如果我们跑 Dinic 的话,我们就可能可以走最短的路径,最终得到正确答案 200。所以我们可以从直观理解来看,Dinic 确实会要比其他的算法要优秀的多。

Dinic 代码实现

在说明 Dinic 的时间复杂度之前,我们先来看一下实现。

具体就是每一次先 bfs 一遍,然后再跑增广路。能在模板题里面拿 92 分。注意到有一个点 T 了,而其他点的时间都还行。

#include <bits/stdc++.h>
#define int long long
using namespace std;
int n, m, s, t;
const int N = 210;

struct edge {//意义同上
	int to, val;
	int id;
};
vector<edge> v[N];
int d[N];//记录i的层数,-1为未访问

void bfs(int s) {//从 s 出发,采用广搜来分层
	memset(d, -1, sizeof d);
	queue<int> q;
	q.push(s), d[s] = 0;
	while (!q.empty()) {
		int f = q.front();
		q.pop();
		for (auto [to, val, id] : v[f])
			if (d[to] == -1 && val > 0)//注意,只能访问正权边
				d[to] = d[f] + 1, q.push(to);
	}
}

int dfs(int u, int fl) {//意义同上
	if (u == t)//到达就结束
		return fl;
	for (auto &[to, val, id] : v[u])
		if (d[to] == d[u] + 1 && val > 0) {//只走正权边,而且是在两个相邻层数之间的边
			int f = dfs(to, min(fl, val));//继续前进的最大流 f
			if (f > 0) {//选择当前路径
				val -= f, v[to][id].val += f;
				return f;
			}
		}
	return 0;
}

int dinic(int s, int t) {
	int ans = 0;//总流量
	while (1) {//每次一个阶段:先分层,再找当前层的所有增广路
		int x = 0;//当前增广路径流量
		bfs(s);//分层
		if (d[t] == -1)//不可到达就直接退出了
			break;
		while ((x = dfs(s, 1e15)) > 0)//找增广路
			ans += x;
	}
	return ans;
}

signed main() {
	cin >> n >> m >> s >> t;
	for (int i = 1; i <= m; i++) {
		int x, y, val;
		cin >> x >> y >> val;
		v[x].push_back({y, val, v[y].size()});
		v[y].push_back({x, 0, v[x].size() - 1});//连边
	}
	cout << dinic(s, t) << endl;//跑最大流
	return 0;
}

Dinic 常数优化

我们来介绍一下当前弧优化。这个优化在其他东西里面也出现过但是我忘了。

当前弧优化这个优化我们一般都会写在 Dinic 里面,因为这个优化很重要,加上会对 Dinic 时间复杂度产生很大的提升。

PS:感谢 lml 巨佬指出问题。


进入正题,当前弧优化。

注意到某一个点 \(x\) 可以有很多入边,也可以有很多出边。如果对应的每一条入边,都需要走 \(x\) 的每一条出边来找到 \(t\) 的增广路,就会非常地浪费时间,甚至会导致时间复杂度退化。这也就是为什么有一个点比其他点多出那么多时间。

但是我们会发现,当一个 \(x\) 的出边 \(x \to y\),对应的 \(y \to t\) 已经无法找到增广路的时候,其他点再走 \(y\) 已经完全没有任何意义了。因为走了 \(y\) 也找不到增广路,走它干啥。

所以这个时候可以把 \(y\) 设为不可达的,并使 \(x\) 在之后也不要访问 \(y\)cur 数组就是干这个活的)。

加上这个优化之后就可以 AC 模板题了(这个模板题感觉数据造的不错),直接从 TLE 华丽变为 12ms AC。

#include <bits/stdc++.h>
#define int long long
using namespace std;
int n, m, s, t;
const int N = 210;

struct edge {//意义同上
	int to, val;
	int id;
};
vector<edge> v[N];
int d[N];//记录i的层数,-1为未访问

void bfs(int s) {//从 s 出发,采用广搜来分层
	memset(d, -1, sizeof d);
	queue<int> q;
	q.push(s), d[s] = 0;
	while (!q.empty()) {
		int f = q.front();
		q.pop();
		for (auto [to, val, id] : v[f])
			if (d[to] == -1 && val > 0)//注意,只能访问正权边
				d[to] = d[f] + 1, q.push(to);
	}
}
int cur[N];//当前弧优化,记录点i上一个未失败的边为e[i][cur[i]]

int dfs(int u, int t, int fl) { //意义同上
//这里需要增加 t 来表示终点,要不然就再多次跑最大流的时候容易混淆
	if (u == t)//到达就结束
		return fl;
	//增加cur[x]是因为如果本次i号边找到了增广路,已经return
	for (int i = cur[u]; i < (int)v[u].size(); i = ++cur[u])
		if (d[v[u][i].to] == d[u] + 1 && v[u][i].val > 0) {//只走正权边,而且是在两个相邻层数之间的边
			int f = dfs(v[u][i].to, t, min(fl, v[u][i].val)); //继续前进的最大流 f
			if (f > 0) {//选择当前路径
				v[u][i].val -= f, v[v[u][i].to][v[u][i].id].val += f;
				return f;
			} else//如果找不到就直接设为未可达了,因为其他点走到这个点也没有用处
				d[v[u][i].to] = -1;
		}
	return 0;
}

int dinic(int s, int t) {
	int ans = 0;//总流量
	while (1) {//每次一个阶段:先分层,再找当前层的所有增广路
		int x = 0;//当前增广路径流量
		bfs(s);//分层
		if (d[t] == -1)//不可到达就直接退出了
			break;
		memset(cur, 0, sizeof cur);
		while ((x = dfs(s, t, 1e15)) > 0) //找增广路
			ans += x;
	}
	return ans;
}

signed main() {
	ios::sync_with_stdio(0);
	cin >> n >> m >> s >> t;
	for (int i = 1; i <= m; i++) {
		int x, y, val;
		cin >> x >> y >> val;
		v[x].push_back({y, val, v[y].size()});
		v[y].push_back({x, 0, v[x].size() - 1});//连边
	}
	cout << dinic(s, t) << endl;//跑最大流
	return 0;
}

Dinic 时间复杂度证明

参考

论文

前面一部分就是算法的思路介绍,然后是伪代码,可以自行理解。后面就是证明了。当然后面的证明和论文的证明好像不太一样。


先说结论:Dinic 的时间复杂度是 \(O(n(nm+m))\)

然后先介绍一下阻塞流(blocking flow):就是在当前分层的图里面已经找不到增广路了,以前在这个分层的图里面找到的增广路的流量的和


第一步,先来证明总共分层的次数不超过 \(O(n)\)

很容易发现,在一个分层图的增广路已经全部消耗的时候,\(s\)\(t\) 的任意路径上都至少一定有一条边的正向边边权变为 \(0\)(这是显然的,不然就一定还存在增广路)

而这会使得在下一轮 BFS 分层的时候导致 \(s \to t\) 的最短路径增加至少 \(1\),而 \(s \to t\) 的最短路径至多也就 \(n-1\),所以 BFS 分层的次数一定不超过 \(O(n)\)


然后我们就只差证明 DFS 找所有的增广路的时间复杂度是 \(O(nm+m)\) 了。

默认这个时候的 Dinic 是我们的最终形态,即使用了当前弧优化。

使用当前弧优化(记录每个节点下次应尝试的边),就确保每条边在整个阻塞流计算中仅被访问一次(无论是否成功增广)。

考虑分别讨论成功找到增广路和找不到增广路的情形。很容易发现,找不到增广路一共就只有 \(1\) 次,因为发现就停止找增广路了。

  • 成功找到增广路:路径长度 \(O(n)\),走过去再回溯回来,总共 \(O(n)\)。而这种情况的次数肯定 \(\le m\)。(因为每条边都至多容量被归零 \(1\) 次)
    • 所以时间复杂度 \(O(nm)\)
  • 没有找到增广路:每一条边至多在失败的寻找中出现 \(1\) 次,时间复杂度 \(O(m)\)

然后把两个东西一加,然后乘上 \(O(n)\) 就可以得到 \(O(n(nm+m))\) 了。

注意,这个复杂度为 \(O(n(nm+m))\) 是当前弧优化的功劳,如果 Dinic 里面没有用当前弧优化,那么时间复杂度就不是这样子的。

最重要的一点:Dinic 的时间复杂度显然跑不满,一般来说只要不是很毒瘤的数据都可以跑过 \(n \le 5000,m \le 50000\) 的点。

为什么 Dinic 不多找几条增广路径?

根据前文和代码可以发现 Dinic 和 Ford-Fulkerson 都是每一次找增广路径都只找一条,但是如果我们可以多找几条路径,那么这个时间复杂度会不会减少呢?

例如这个例子:

这个时候我们显然可以同时提取两条增广路径,就可以获得 \(8\) 的流量。而两条路径一条一条地找的话就需要遍历两次,乍一看很快速的是不是?

教练和我一开始都是这么想的,然后教练一写,这是代码:

(很容易发现这就是某知名算法书籍的代码)

这是评测记录:

发现慢了很多!!!直接从 12ms 退化到了 279 ms!!!差了 20 倍时间!


考虑这是为什么。

首先仔细想想就可以知道这个“优化”效果并不大。我们只是少走了一点路径。原本需要走全程,现在只需要走一部分,因为你增广路本来就不会很多,而且每一条增广路还至多减少 \(n\) 个点的访问,而 \(n,m\) 都很小,显然不会有显著的优化。

而且在这个“优化”效果不大的同时,还造成了一个非常大的问题。

假设中间的点有很多条出边。

我们走最上面的哪条路径,发现成功了,然后这条路径就用完了。最后我们会发现中间的边会被访问多次,即使是使用了当前弧优化也很浪费时间

所以时间复杂度又退化了个 \(m\),不过这次没有完全退化,所以数据放这个做法过了。

P1345 [USACO5.4] 奶牛的电信Telecowmunication

突然发现 USACO 好喜欢玩谐音梗。

题意就是给定一个无向图,问你要删多少点才能使 \(s,t\) 不连通。

注意是删点而不是删边,所以不能直接使用最小割来求。所以考虑变换一下题目模型。

经典 trick:将一个点 \(a\) 拆成两个点 \(x_a,y_a\),其中 \(x_a\) 只处理入边,\(y_a\) 只处理出边。对于一条边 \((a,b)\)\(y_a \to x_b,y_b \to x_a\) 连边权为 \(+\infty\)。而 \(x_a \to y_a\) 连无向边边权为 \(1\)

这样就发现可以使用最小割处理了!

直接把板子粘过来即可。

#include <bits/stdc++.h>
#define int long long
using namespace std;
int n, m;
const int N = 210;

struct edge {
	int to, val;
	int id;
};
vector<edge> v[N];
int d[N];

void add(int x, int y, int val) {
	v[x].push_back({y, val, v[y].size()});
	v[y].push_back({x, 0, v[x].size() - 1});
}

void bfs(int s) {
	memset(d, -1, sizeof d);
	queue<int> q;
	q.push(s), d[s] = 0;
	while (!q.empty()) {
		int f = q.front();
		q.pop();
		for (auto [to, val, id] : v[f])
			if (d[to] == -1 && val > 0)
				d[to] = d[f] + 1, q.push(to);
	}
}
int cur[N];

int dfs(int u, int t, int fl) {
	if (u == t)
		return fl;
	for (int i = cur[u]; i < (int)v[u].size(); i = ++cur[u])
		if (d[v[u][i].to] == d[u] + 1 && v[u][i].val > 0) {
			int f = dfs(v[u][i].to, t, min(fl, v[u][i].val));
			if (f > 0) {
				v[u][i].val -= f, v[v[u][i].to][v[u][i].id].val += f;
				return f;
			} else
				d[v[u][i].to] = -1;
		}
	return 0;
}

int dinic(int s, int t) {
	int ans = 0;
	while (1) {
		int x = 0;
		bfs(s);
		if (d[t] == -1)
			break;
		memset(cur, 0, sizeof cur);
		while ((x = dfs(s, t, 1e15)) > 0)
			ans += x;
	}
	return ans;
}

signed main() {
	int s, t;
	ios::sync_with_stdio(0);
	cin >> n >> m >> s >> t;
//对于一个点 i,x_i 的编号为 i,y_i 的编号为 i + n
	for (int i = 1; i <= m; i++) {
		int x, y;
		cin >> x >> y;
		add(x + n, y, 1e9);
		add(y + n, x, 1e9);//加边
	}
	for (int i = 1; i <= n; i++)
		add(i, i + n, 1), add(i + n, i, 1);//加边
	cout << dinic(s + n, t) << endl;
	return 0;
}

有上下界的网络最大流(无源点无汇点)

这个时候,不止有流量的上界,还有了流量的下界。所以这个东西理所应当难一些些。

我们采用这样的一种方式:先找出可行流,然后再考虑找出这些可行流里面权值最大的。

找到可行流(无源点无汇点)

我们思考怎么找到可行流。

先考虑一个简单的问题:

  • \(5\) 的苹果,\(3\) 个小朋友,每一个小朋友分到 \(\ge 0\) 个苹果。

这个东西可以使用组合数学或者是枚举。我们小学五年级就学过了。

问题升级了一下:

  • \(5\) 的苹果,\(3\) 个小朋友,第 \(i\) 个小朋友分到 \(\ge i\) 个苹果。

我们可以通过先给第 \(i\) 个小朋友 \(i\) 个苹果来将这个问题转换成上面的问题。


那么对于有上下界的网络流问题,我们也是这样子解决的。

对于这个网络图:

我们可以将这个网络图拆成两个边权不同的网络图,一个是每条边的容量下界,一个是每条边的容量上界减去容量下界:

第一个网络图在跑完网络流之后,必须要每一个边都是饱和的(也就是该用的都用上了)。

而第二个网络图没有特殊规定。随便走都可以得到一个可行流。


第二个网络图就直接就是普通的网络流了,关键在于如何保证第一个网络图的每一条边都用完了。

我们可以设立两个虚点。一个是超源点 \(S\),一个是超汇点 \(T\)

对于点 \(1\),我们希望它进入 \(4\) 的流,出去 \(2\) 的流。所以我们可以从 \(S \to 1\) 连一条边权为 \(4-2=2\) 的边。

对于点 \(3\),我们希望它进入 \(3\) 的流,出去 \(4\) 的流。所以我们可以从 \(3 \to T\) 连一条边权为 \(4-3=1\) 的边。

对于点 \(2\),我们希望它进入 \(2\) 的流,出去 \(3\) 的流。所以我们可以从 \(2 \to T\) 连一条边权为 \(3-2=1\) 的边。

就是这个图片。

为什么要这样呢?这样我们是为了对于每一个点,不够出的都出去,不够入的都进入。

如果一个点出去的流容量大于进来的流容量,就说明需要补的是进来的流容量,也就是用这个点向 \(T\) 连边。否则是从 \(S\) 向这个点连边。


我先说一下我们要干什么,然后再说明一下我们为什么要这么干。

然后我要做一件事情,把超源点和超汇点搬到第二个网络图,并且保留边和边权。

然后对第二个网络图跑最大流。得出最终的最大流流量 \(f\)

现在我们可以判断可行性:如果 \(f\) 同时等于 \(S\) 的出边边权之和,也等于 \(T\) 的入边边权之和,则该上下界网络图可以找到一种流分配方案。 否则一定不能。

注意,这里的最大流只是用来判断可行性的,和上下界网络最大流完全没有任何关系。


那这样为什么可以呢?

我们可以想象,在第一个网络图中,为了使 \(1\) 的入边容量之和等于其出边容量之和,我们让 \(1\) 硬灌了 \(2\) 的入边流量

而如果在第二个网络图中,存在方法使这个硬灌的流量被完全消耗掉,就说明这个时候 \(1\) 确实可以通过 \(S\)\(2\) 的入边容量,那这个时候一定存在一种可行流。

硬灌某一个点的出边容量也是同理的。

那为社么要保证最大流呢?

其实我们这个时候不必求最大流,而是只需要保证 \(S\) 的出边和 \(T\) 的入边是满流的即可, 但是最大流显然是最有可能满足条件的,所以采用最大流肯定不会更差。

那为什么要在第二个图里面跑最大流呢?

因为我们一开始只跑第一个图的三条白色边肯定是不行的,这样并不能保证一定可以把所有的边的容量都用完。

而接下来我们只能寄希望于第二个图里面的剩余的可以使用的流量来收拾烂摊子,把第一个图里面的下界给达到。


但是我们不止需要判断可行性,而是需要求出可行流!!!

怎么办呢?我们可以求出来第二张图跑最大流的时候所有的增广路:

然后把这些增广路上面通过的流都加到第一张图的里面:

这样我们就可以求出来一种可行流。

好了,怎么找出可行流的方法搞完了,考虑找到可行流中的最大流。

有源和汇的可行流

这个时候不止有点,还有了源点和汇点。而且边还有上下界。

很容易发现,对于多源多汇的图,我们仍然可以通过建超源点和超汇点来把他们变成单源单汇,所以我们只讨论单源单汇即可。

这个时候虽然有了起点和终点限制,但是我们仍然可以建超源点和超汇点。

我们可以继续使用上面的方法连边:

注意我们连这种边的时候,我们要把 \(s,t\) 都看成普通的点。


但是注意我们这个时候并不能使问题得到解决,因为 \(s\) 必须是起点,\(t\) 必须是终点,比无源汇的上下界可行流还复杂一些。

于是,我们只需要略施小计,从 \(t\) 连一条边权为 \(+ \infty\) 的边到 \(s\)。这样就可以了。

为什么?

假设在过程中 \(t\) 多了 \(3\) 的流量,而 \(s\) 少了 \(3\) 的流量。这个东西本来是允许的,但是在跑算法的时候却不允许。怎么办呢?

我们就可以从 \(t\)\(s\) 连一条边权为正无穷的边,相当于从 \(t\)\(s\) 缴纳 \(3\) 的流量。

这样的话,算法就允许我们在现实允许的所有东西了。

这样就转换为了一个无源汇的可行流问题,可以直接使用上面的办法来判断可行性,也可以求出可行流了。

有源有汇的上下界最大流

求出来可行流之后,我们怎么办呢?

结论本身非常简单粗暴。是这样的:此时在残余网络上直接算 \(s\to t\) 的最大流再加上找可行流的时候得出来的最大流即可。

为什么呢?

我们可以这样想:

刚才我们使用无源汇的可行流求解方法求的是最小流,而此时第二个网络图已经为了让第一个网络图出的流和入的流平衡牺牲了一些容量。

这个时候我们也可以把第二个网络图的可调节的部分也消耗完毕,也就是在残余网络上算 \(s \to t\) 的最大流加上原来的最大流,这个时候就得出来了最大流。

#include <bits/stdc++.h>
#define int long long
using namespace std;
int n, m, s, t;
const int N = 210;

struct edge {
	int to, val;
	int id;
};
vector<edge> v[N];
int d[N];

void bfs(int s) {
	memset(d, -1, sizeof d);
	queue<int> q;
	q.push(s), d[s] = 0;
	while (!q.empty()) {
		int f = q.front();
		q.pop();
		for (auto [to, val, id] : v[f])
			if (d[to] == -1 && val > 0)
				d[to] = d[f] + 1, q.push(to);
	}
}
int cur[N];

int dfs(int u, int t, int fl) {
	if (u == t)
		return fl;
	for (int i = cur[u]; i < (int)v[u].size(); i = ++cur[u])
		if (d[v[u][i].to] == d[u] + 1 && v[u][i].val > 0) {
			int f = dfs(v[u][i].to, t, min(fl, v[u][i].val));
			if (f > 0) {
				v[u][i].val -= f, v[v[u][i].to][v[u][i].id].val += f;
				return f;
			} else
				d[v[u][i].to] = -1;
		}
	return 0;
}
int sum[N];

void add(int x, int y, int w) {
	v[x].push_back({y, w, v[y].size()});
	v[y].push_back({x, 0, v[x].size() - 1});
}

int dinic(int s, int t) {
	int ans = 0;
	while (1) {
		int x = 0;
		bfs(s);
		if (d[t] == -1)
			return ans;
		memset(cur, 0, sizeof cur);
		while ((x = dfs(s, t, 9e18)) > 0)
			ans += x;
	}
	return 0;
}//最大流板子

signed main() {
	ios::sync_with_stdio(0);
	cin >> n >> m >> s >> t;
	for (int i = 1; i <= m; i++) {
		int x, y, l, w;
		cin >> x >> y >> l >> w;
		add(x, y, w - l);//连边
		sum[x] += l, sum[y] -= l;//这里使用 sum 来记录入边的容量和 和 出边的容量和 的 差。
	}
	add(t, s, 9e18);//t 到 s 还需要连一条边权正无穷的边
	int a = 0;
	for (int i = 1; i <= n; i++)
		if (sum[i] > 0)
			add(i, n + 2, sum[i]), a += sum[i];//这里设 n+1 为超源点,n+2 为超汇点
		else if (sum[i] < 0)
			add(n + 1, i, -sum[i]);//连边
	int ans = dinic(n + 1, n + 2);//先跑一遍可行性
	if (a != ans)
		cout << "please go home to sleep\n";//发现不可行
	else
		cout << dinic(s, t) << endl;//否则得到答案
	return 0;
}

有源有汇的上下界最小流

我们说过上下界网络图不止有最大流还有最小流,那么最小流该怎么求呢?

首先我们应该满足下限的要求,要不然这甚至不是一个可行流。

很容易发现可行流不一定是最小流。 所以需要从可行流中减去一个值。

具体是那个值呢?就是残余网络上面 \(t \to s\) 的最大流。

为什么这样是可行的?

首先我们要思考:减去这样 \(t \to s\) 的最大流会使得实际方案不合法吗?

显然不会。因为是在残余网络上面跑的,而这个时候所有 \(s \to t\) 的反边(相当于 \(t \to s\) 的正边)都是剩余的可以分配的流。

然后我们就会发现:减去残余网络上 \(t \to s\) 的最大流不就是一个贪心的退流的过程吗?

于是最终得出来的就是最小流了。

但是这个时候还是有一个特坑的点,写模板的时候一定要注意啊!!!

注意,这个时候 \(t \to s\) 的那条边权为正无穷的边一定要删掉,要不然就是错的。

#include <bits/stdc++.h>
#define int long long
using namespace std;
int n, m, s, t;
const int N = 50010;

struct edge {
	int to, val;
	int id;
};
vector<edge> v[N];
int d[N];

void bfs(int s) {
	memset(d, -1, sizeof d);
	queue<int> q;
	q.push(s), d[s] = 0;
	while (!q.empty()) {
		int f = q.front();
		q.pop();
		for (auto [to, val, id] : v[f])
			if (d[to] == -1 && val > 0)
				d[to] = d[f] + 1, q.push(to);
	}
}
int cur[N];

int dfs(int u, int t, int fl) {
	if (u == t)
		return fl;
	for (int i = cur[u]; i < (int)v[u].size(); i = ++cur[u])
		if (d[v[u][i].to] == d[u] + 1 && v[u][i].val > 0) {
			int f = dfs(v[u][i].to, t, min(fl, v[u][i].val));
			if (f > 0) {
				v[u][i].val -= f, v[v[u][i].to][v[u][i].id].val += f;
				return f;
			} else
				d[v[u][i].to] = -1;
		}
	return 0;
}

int dinic(int s, int t) {
	int ans = 0;
	while (1) {
		int x = 0;
		bfs(s);
		if (d[t] == -1)
			return ans;
		memset(cur, 0, sizeof cur);
		while ((x = dfs(s, t, 9e18)) > 0)
			ans += x;
	}
	return 0;
}
int sum[N];

void add(int x, int y, int w) {
	v[x].push_back({y, w, v[y].size()});
	v[y].push_back({x, 0, v[x].size() - 1});
}


signed main() {
	ios::sync_with_stdio(0);
	cin >> n >> m >> s >> t;
	for (int i = 1; i <= m; i++) {
		int x, y, l, w;
		cin >> x >> y >> l >> w;
		add(x, y, w - l);
		sum[x] += l, sum[y] -= l;
	}
	add(t, s, 9e18);
	int a = 0;
	for (int i = 1; i <= n; i++)
		if (sum[i] > 0)
			add(i, n + 2, sum[i]), a += sum[i];
		else if (sum[i] < 0)
			add(n + 1, i, -sum[i]);
	if (a != dinic(n + 1, n + 2))
		cout << "please go home to sleep\n";//上面的和有源汇上下界最大流一模一样
	else {
		int ans = 0;
		for (auto &[to, val, id] : v[s])
			if (to == t)
				ans = max(ans, val);//求出来 s 到 t 的可行流,
		for (auto &[to, val, id] : v[t])
			if (to == s && val > 1e13)//注意这个时候 t 到 s 的那条边权正无穷的边这个时候可能不再是 9e18,因为有一部分被用到反边上面去了
				val = 0, v[s][id].val = 0;//删去这条边权极大的边
		cout << ans - dinic(t, s) << endl;//得到答案
	}
	return 0;
}

posted @ 2025-06-02 08:52  wusixuan  阅读(137)  评论(2)    收藏  举报