网络流,二分图与图的匹配
CHANGE LOG
- 2021.12.5:更换模板代码。新增二分图部分。
- 2022.1.11:重构网络流部分。新增网络流的应用与模型。
- 2022.1.1.13:新增上下界网络流部分。
- 2022.5.11:重构网络流部分,更换模板代码。
- 2022.6.1:重构二分图部分,修改事实性错误。补充 dinic 算法的复杂度证明。
- 2022.7.17:新增图的匹配部分。
- 2022.8.31:新增 Hopcroft-Karp 算法。
1. 网络流
网络流的核心在于建图。建图是精髓,建图是人类智慧。
网络流的建图方法一定程度上刻画了贪心问题的内在性质,从而简便地支持了 反悔,不需要我们为每道贪心问题都寻找反悔策略。
1.1 基本定义
一个网络是一张 有向图
网络的可行流分为有源汇(通常用
- 首先给出定义,流函数
是从二元 有序对 向实数集 的映射,其中 。 称为边 的 流量。 满足 容量限制: 。每条边的流量不能超过容量。若 ,则称边 满流。 具有 斜对称 性质: 。 有 的流量,也可称 有 的流量。 具有 流量守恒 性质:除源汇点外(无源汇网络流则不存在源汇点),从每个节点流入和流出的流量相等,即 。每个节点 不储存流量,流进多少就流出多少。
以下是一些网络流相关定义。
-
对于 有源汇 网络,根据斜对称和容量守恒性质,可以得到
,此时这个相等的和称为当前流 的 流量。 -
定义流
在网络 上的 残量网络 为容量函数等于 的网络。根据容量限制,我们有 。若 ,则视 在残量网络上不存在, 。换句话说,将每条边的容量减去流量后,删去满流边即可得到残量网络。 -
定义 增广路
是残量网络 上从 源点 到 汇点 的一条路径。无源汇网络流不讨论增广路。 -
将
分成 互不相交 的两个点集 ,其中 , ,这种点的划分方式叫做 割。定义割的 容量 为 ,流量 为 。若 所属点集不同,则称有向边 为 割边。
接下来的讨论大部分与有源汇网络流相关。对于无源汇网络流,见 1.5.1 小节无源汇网络流部分。
1.2 网络最大流
网络最大流相关算法,最著名的是 Edmonds-Karp 和 dinic。对于更高级的 SAP / ISAP / HLPP,此处不做介绍。
给定网络
1.2.1 增广
接下来要介绍的两个算法均使用了 不断寻找增广路 和 能流满就流满 的贪心思想。
具体地,找到残量网络
我们在增广的过程中尽量流满一条增广路,同时每条边的流量在增广过程中不会减少。贪心的正确性如何保证?
在为当前边
上述操作称为一次 增广。
关于增广有一个常用技巧:成对变换。网络流建图一般使用链式前向星,我们将每条边与它的反向边按编号连续存储,编号分别记为 cnt
应设为
1.2.2 最大流最小割定理
在介绍 EK 和 dinic 之前,我们还需要一个贯穿网络流始终的最核心,最基本的结论:最大流等于最小割。
-
任意一组流的流量 不大于 任意一组割的容量:
考虑每单位流量,设其经过
的割边 的次数为 ,经过 的割边次数为 。必然有 ,否则不可能从 流到 。根据斜对称性质与割的流量的定义,每单位流量对割边流量之和的贡献为
,因此网络总流量等于割边流量之和。对每一种流的方案均应用上述结论,并根据容量限制,推出流的流量
割的容量。 -
存在一组流的流量 等于 一组割的容量:
我们断言最大流存在,此时 残量网络不连通:若连通则可以继续增广,与最大流的最大性矛盾。这为我们自然地提供了一组割,使其容量等于流量,即当前可行流的流量。
综上,结论得证。
1.2.3 Edmonds-Karp
1.2.3.1 算法简介
Edmonds-Karp 算法的核心是使用 bfs 寻找 长度最短 的增广路。为此,我们记录流向每个点的边的编号,然后从汇点
注意,任意选择增广路增广,复杂度将会退化成和流量相关(朴素的 FF 算法),因为 EK 的复杂度证明需要用到增广路长度最短的性质。
模板题 P3381 网络最大流 代码如下。
#include <bits/stdc++.h>
using namespace std;
const int N = 200 + 5, M = 5e3 + 5;
struct flow {
long long fl[N], limit[M << 1];
int cnt = 1, hd[N], nxt[M << 1], to[M << 1], fr[N];
void add(int u, int v, int w) {
nxt[++cnt] = hd[u], hd[u] = cnt, to[cnt] = v, limit[cnt] = w;
nxt[++cnt] = hd[v], hd[v] = cnt, to[cnt] = u, limit[cnt] = 0;
}
long long maxflow(int s, int t) {
long long flow = 0;
while(1) {
queue<int> q;
memset(fl, -1, sizeof(fl));
fl[s] = 1e18, q.push(s);
while(!q.empty()) {
int t = q.front();
q.pop();
for(int i = hd[t]; i; i = nxt[i]) {
int it = to[i];
if(limit[i] && fl[it] == -1) { // 剩余流量为 0,在残量网络上不存在,不能走
fl[it] = min(limit[i], fl[t]); // 记录流量
fr[it] = i, q.push(it); // 记录流向每个点的边
}
}
}
if(fl[t] == -1) return flow;
flow += fl[t];
for(int u = t; u != s; u = to[fr[u] ^ 1]) limit[fr[u]] -= fl[t], limit[fr[u] ^ 1] += fl[t]; // 从 T 一路反推到 S,并更新每条边的剩余流量
}
}
} g;
int n, m, s, t;
int main() {
cin >> n >> m >> s >> t;
for(int i = 1; i <= m; i++) {
int u, v, w;
cin >> u >> v >> w, g.add(u, v, w);
}
cout << g.maxflow(s, t) << endl;
return 0;
}
1.2.3.2 复杂度证明
为证明 EK 的时间复杂度,我们需要这样一条引理:每次增广后残量网络上
考虑反证法,假设存在节点
若
接下来证明 EK 的复杂度。
不妨设某次增广的增广路为
因为增广路是最短路,我们有
根据引理,
综上,每条边作为关键边的次数不超过
1.2.4 Dinic
1.2.4.1 算法介绍
Dinic 算法的核心思想是 分层图 以及 相邻层之间增广,通过 bfs 和 dfs 实现。首先 bfs 给图分层,分层后从
给图分层的目的是将网络视作 DAG,规范增广路的形态,防止流成一个环。
Dinic 算法有重要的 当前弧优化。增广时,容量等于流量的边无用,可直接跳过,不需要每次搜索到同一个点时都从邻接表头开始遍历。为此,记录从每个点出发第一条没有流满的边,称为 当前弧。每次搜索到一个节点就从其当前弧开始增广。
注意,每次多路增广前每个点的当前弧应初始化为邻接表头,因为并非一旦流量等于容量,这条边就永远无用。反向边流量的增加会让它重新出现在残量网络中。
当前弧优化后的 dinic 时间复杂度
- Dinic 实际上蕴含了 EK,因为 dinic 本质也是不断找最短路增广。相较于 EK,dinic 使用了多路增广和当前弧优化两个技巧。
1.2.4.2 当前弧优化的注意点
for(int i = cur[u]; res && i; i = nxt[i]) {
cur[u] = i;
// do something
}
上述代码不可以写成
for(int &i = cur[u]; res && i; i = nxt[i]) {
// do something
}
因为若 res
变成
另一种解决方法是在循环末尾判断 if(!res) return flow;
。总之,在写当前弧优化时千万注意不能跳过没有流满的边。
模板题 P3381 网络最大流 代码如下。
#include <bits/stdc++.h>
using namespace std;
const int N = 200 + 5, M = 5e3 + 5;
struct flow {
int cnt = 1, hd[N], nxt[M << 1], to[M << 1], limit[M << 1];
void add(int u, int v, int w) {
nxt[++cnt] = hd[u], hd[u] = cnt, to[cnt] = v, limit[cnt] = w;
nxt[++cnt] = hd[v], hd[v] = cnt, to[cnt] = u, limit[cnt] = 0;
}
int T, dis[N], cur[N];
long long dfs(int id, long long res) {
if(id == T) return res;
long long flow = 0;
for(int i = cur[id]; i && res; i = nxt[i]) {
cur[id] = i;
int c = min(res, (long long) limit[i]), it = to[i];
if(dis[id] + 1 == dis[it] && c) {
int k = dfs(it, c);
flow += k, res -= k, limit[i] -= k, limit[i ^ 1] += k;
}
}
if(!flow) dis[id] = -1;
return flow;
}
long long maxflow(int s, int t) {
T = t;
long long flow = 0;
while(1) {
queue<int> q;
memcpy(cur, hd, sizeof(hd));
memset(dis, -1, sizeof(dis));
q.push(s), dis[s] = 0;
while(!q.empty()) {
int t = q.front();
q.pop();
for(int i = hd[t]; i; i = nxt[i])
if(dis[to[i]] == -1 && limit[i])
dis[to[i]] = dis[t] + 1, q.push(to[i]);
}
if(dis[t] == -1) return flow;
flow += dfs(s, 1e18);
}
}
} g;
int n, m, s, t;
int main() {
cin >> n >> m >> s >> t;
for(int i = 1; i <= m; i++) {
int u, v, w;
cin >> u >> v >> w, g.add(u, v, w);
}
cout << g.maxflow(s, t) << endl;
return 0;
}
1.2.4.3 复杂度证明
Dinic 的复杂度证明也是一个技术活。
在证明 EK 的时间复杂度时,我们使用了一个引理,就是
我们现在尝试证明对于 dinic 的一次增广,
反证法,假设存在一次增广使得
考察 增广后 的一条从
由引理,增广前
若对于所有
因此,存在
又因为增广后
- 上述证明中我们没有用到
的任何性质,故同理可证一轮增广从 到每个点的最短路增加,前提是 在增广前可达该点。
这样,我们证明了增广轮数为
对于本身就没有流量的边,使用当前弧优化后这些边造成的总复杂度为
dfs 时,每次到达
找到增广路后,我们将回溯至增广路上第一条关键边(因为没有剩余流量了),并将所有关键边的流量置为零。这些关键边会在第二次遍历到时被直接跳过,这部分,即跳过已经作为某次增广的关键边的边的总复杂度同样为
只剩下增广的复杂度还没有计入。每条边最多会作为一次增广的关键边,即到达
1.3 无负环的费用流
费用流一般指 最小费用最大流(Minimum cost maximum flow,简称 MCMF)。
相较于一般的网络最大流,在原有网络
简单地说,
1.3.1 SSP
1.3.1.1 算法介绍
连续最短路算法 Successive Shortest Path,简称 SSP。这一算法的核心思想是每次找到 长度最短的增广路 进行增广,且仅在网络 初始无负环 时能得到正确答案。
SSP 算法有两种实现,一种基于 EK 算法,另一种基于 dinic 算法。这两种实现均要求将 bfs 换成 SPFA(每条边的长度即
时间复杂度
OI 界一般以 dinic 作为网络最大流的标准算法,以基于 EK 的 SSP 作为费用流的标准算法。「最大流不卡 dinic,费用流不卡 EK」是业界公约。
注意,SPFA 在队首为 break
,因为第一次取出 dis[T]
不一定取到最短路。
模板题 P3381 最小费用最大流 代码。
#include <bits/stdc++.h>
using namespace std;
const int N = 5e3 + 5, M = 5e4 + 5;
struct flow {
int cnt = 1, hd[N], nxt[M << 1], to[M << 1], limit[M << 1], cst[M << 1];
void add(int u, int v, int w, int c) {
nxt[++cnt] = hd[u], hd[u] = cnt, to[cnt] = v, limit[cnt] = w, cst[cnt] = c;
nxt[++cnt] = hd[v], hd[v] = cnt, to[cnt] = u, limit[cnt] = 0, cst[cnt] = -c;
}
int fr[N], fl[N], in[N], dis[N];
pair<int, int> mincost(int s, int t) {
int flow = 0, cost = 0;
while(1) { // SPFA
queue<int> q;
memset(dis, 0x3f, sizeof(dis));
memset(in, 0, sizeof(in));
fl[s] = 1e9, dis[s] = 0, q.push(s);
while(!q.empty()) {
int t = q.front();
q.pop(), in[t] = 0;
for(int i = hd[t]; i; i = nxt[i]) {
int it = to[i], d = dis[t] + cst[i];
if(limit[i] && d < dis[it]) {
fl[it] = min(limit[i], fl[t]), fr[it] = i, dis[it] = d;
if(!in[it]) in[it] = 1, q.push(it);
}
}
}
if(dis[t] > 1e9) return make_pair(flow, cost);
flow += fl[t], cost += dis[t] * fl[t];
for(int u = t; u != s; u = to[fr[u] ^ 1]) limit[fr[u]] -= fl[t], limit[fr[u] ^ 1] += fl[t];
}
}
} g;
int n, m, s, t;
int main() {
cin >> n >> m >> s >> t;
for(int i = 1; i <= m; i++) {
int u, v, w, c;
cin >> u >> v >> w >> c, g.add(u, v, w, c);
}
pair<int, int> ans = g.mincost(s, t);
cout << ans.first << " " << ans.second << endl;
return 0;
}
1.3.1.2 正确性证明
我们尝试证明每次增广
考虑两个流量相等的流
若流
考虑归纳证明。假设增广前
假设存在流
因此,不存在流量与
1.3.2 Primal-Dual
建议先学习 Johnson 全源最短路算法,详见 初级图论。
和 SSP 一样,Primal-Dual 原始对偶算法也仅适用于 无负环 的网络。其核心为尝试为每个点赋一个 势能
使用 Johnson 全源最短路算法的思想,我们先用一遍 SPFA 求出源点到每个点的最短路
找到增广路后,每次增广都会改变残量网络的形态。为此,我们用每次增广时 Dijkstra 跑出来的最短路加在
- 如果
在增广路上,有 。由于 ,所以 ,即 反边边权为 。 - 对于原有的边,我们有
,即 ,原边权仍然非负。
实际表现方面,Primal-Dual 相较于 SSP 并没有很大的优势,大概是因为 SPFA 本身已经够快了,且堆优化的 dijkstra 常数较大。
代码。
1.4 对网络流的理解
1.4.1 网络流与贪心
在费用流的过程中,我们的策略是 贪心 找到长度最短的增广路并进行增广,但当前决策并不一定最优,因此需要为反边添加流量,表示 支持反悔。因此,网络流本质上是 可反悔贪心,而运用上下界网络流等技巧可以很方便地处理问题的一些限制。
更一般的,网络流是一种特殊的贪心,它们之间可以相互转化。对于具有特定增广模式(网络具有某种性质)的网络流,可以从贪心的角度思考,从而使用数据结构维护。而大部分贪心题目也可以通过网络流解释。
换句话说,网络流 将贪心用图的形式刻画,而解决网络流问题的算法与某种支持反悔的贪心策略相对应,这使得我们不需要为每道贪心都寻找反悔策略,相反,建出图后就是一遍最大流或者费用流的事儿了。
1.4.2 网络流题目的技巧
网络流相关问题,关键在于发现 题目的每一种方案与一种流或割对应。例如在 P2057 [SHOI2007]善意的投票 一题中,直接将每个小朋友拆点不可行,因为无法考虑到他与他的朋友意见不一致时的贡献。
为此,我们应用 最小割等于最大流 这一结论,考虑如何 用一组割来表示一种意见方案,最终得到解法。每割掉一条边都表示付出
换句话说,对于一组割,其唯一对应了一种方案,残量网络上与
1.4.3 求方案的注意点
在应用最大流最小割定理求解问题时,刚学会网络流的同学可能会陷入一个误区,就是最大流对应的最小割以所有在最大流中流满的边作为割边。
仔细想想就会发现这是错误的。反例如
回想割的定义:将
在求解最大流的过程中,我们时刻维护了残量网络上
因此,如果一组割对应了题目的一种方案,在求解最大流之后,一定不能将所有流满的边视作割边,而是将两端所在集合不同的边视作割边。在解决集合划分模型相关问题时需要格外注意这一点。
1.4.4 反悔的性质
因为网络流算法本身自带反悔操作,所以在解决动态加边的 最大流 问题时,我们不需要担心原来的流方案会影响到算法求解新图最大流时的正确性。
但对于费用流,因为其正确性依赖于每一步增广路均为最短路,所以一旦给网络加入新边,就必须重新跑费用流才能得到正确费用。
1.5 上下界网络流
上下界网络流相较于原始网络
1.5.1 无源汇可行流
无源汇上下界可行流是上下界网络流的基础。我们需要为一张无源汇的网络寻找一个流函数
解决该问题的核心思想,是 先满足流量下界限制,再尝试调整。具体地,我们首先让每条边
这启发我们新建一个网络
若
代码。
1.5.2 有源汇可行流
从
1.5.3 有源汇最大流
有源汇上下界最大流算法基于一个非常有用的结论:给定 任意 一组 可行流,对其运行最大流算法,我们总能得到正确的最大流。这是因为最大流算法本身 支持撤销,即退流操作。所以,无论初始的流函数
因此,我们考虑先求出任意一组可行流,再进行 初步调整:首先对网络
接下来进行 二次调整。根据结论,我们只需要以
- 易错点 1:调整的整个过程在
上进行,千万不能在 上面跑最大流,因为 上面的退流操作会使得 不符合容量限制,而 不会。因为 的实际流量 等于 ,其中 是 上的流函数,所以只要 符合容量限制,那么 一定也符合。 - 易错点 2:可行流流量是
的反边流量,而不是 的流量!
代码。
1.5.4 有源汇最小流
根据
代码。
1.5.5 有源汇费用流
只需将最大流算法换成费用流即可,所有
初始费用为
代码可参考 1.6.4 小节给出的有负环的费用流代码。
1.6 应用与模型
1.6.1 最小割点
通常情况下题目要求的最小割是 最小割边,但如果问题变成删去每个 点
考虑应用网络流的常用技巧:点边转化,将每个点拆成入点
不难发现
1.6.2 集合划分模型
集合划分模型是网络流相关问题的常见模型,读者需要充分掌握这部分内容。
其中
给定
我们可以为上式赋予实际意义:
建模:将
与 相连,此时割开了 ,表示将 划分到 ,有 的代价。 与 相连,此时割开了 ,表示将 划分到 ,有 的代价。- 若
不属于同一集合,则 和 之间有一条被割开(因为 分别与 相连,如果不割开一条边, 就连通了),方向取决于 究竟与 还是 相连。
因此,对上述网络
接下来我们讨论一些扩展问题:
- 当
出现负值时,普通的最大流不能得到正确结果,因为我们无法解决容量为负的最大流问题。考虑将 同时加上 ,最后再在求出的最小割中减掉 。这是因为 必须在选或不选 任选一种 方案,所以同时为 加上 对最小割的影响为 。体现在图上即 ,为了使 不连通,必须 至少割掉一条边。同时我们也只会 恰好割掉一条边,因为 不能既不与 连通,也不与 连通,这与割的定义矛盾。 - 当
出现负值时,除非所有 均为负值且要求代价最大化,此时所有边权取反,否则问题不可做。取反可以通过 代价和贡献的转化 理解,即若代价为 ,则贡献为 ,一般我们希望最大化贡献,最小化代价。 - 如果限制形如 “当
在集合 且 在集合 中有代价 ”,此时连 权值为 单向边,表示如果 和 相连且 和 相连,则需要割掉这条边产生 的代价,否则 连通。在不同集合产生代价连双向边本质上就是将两种情况单独处理,连两条单向边。 - 当题目要求输出方案,见 1.4.3 小节。
1.6.3 最大权闭合子图
一张 有向图
最大权闭合子图问题,即每个点
考虑 集合划分模型,对于每个节点,我们可以将其划分到 选 或 不选 的集合当中,体现在建图上即
由于最大割是 NP-Hard 问题,所以考虑权值取相反数求最小割。对于
上述操作等价于先将所有正权点选入闭合子图,再考虑去掉不选的正权点的贡献。如果某个
综上,我们得到了求解最大权闭合子图的一般算法:对于
1.6.4 有负环的费用流
考虑运用上下界网络流将负权边强制满流,并令反边
模板题 P7173 有负圈的费用流 代码如下:
#include <bits/stdc++.h>
using namespace std;
const int N = 200 + 5, M = 2e4 + N;
struct flow {
int cnt = 1, hd[N], nxt[M << 1], to[M << 1], limit[M << 1], cst[M << 1];
void add(int u, int v, int w, int c) {
nxt[++cnt] = hd[u], hd[u] = cnt, to[cnt] = v, limit[cnt] = w, cst[cnt] = c;
nxt[++cnt] = hd[v], hd[v] = cnt, to[cnt] = u, limit[cnt] = 0, cst[cnt] = -c;
}
int fl[N], fr[N], dis[N], in[N];
pair<int, int> mincost(int s, int t) {
int flow = 0, cost = 0;
while(1) {
queue<int> q;
memset(dis, 0x3f, sizeof(dis));
q.push(s), fl[s] = 1e9, dis[s] = 0;
while(!q.empty()) {
int t = q.front();
q.pop(), in[t] = 0;
for(int i = hd[t]; i; i = nxt[i]) {
int it = to[i], d = dis[t] + cst[i];
if(limit[i] && d < dis[it]) {
dis[it] = d, fl[it] = min(fl[t], limit[i]), fr[it] = i;
if(!in[it]) in[it] = 1, q.push(it);
}
}
}
if(dis[t] > 1e9) return make_pair(flow, cost);
flow += fl[t], cost += dis[t] * fl[t];
for(int u = t; u != s; u = to[fr[u] ^ 1]) limit[fr[u]] -= fl[t], limit[fr[u] ^ 1] += fl[t];
}
}
};
struct bounded_flow {
int e, u[M], v[M], lo[M], hi[M], cst[M];
void add(int _u, int _v, int w, int c) {
if(c < 0) {
u[++e] = _u, v[e] = _v, lo[e] = w, hi[e] = w, cst[e] = c;
u[++e] = _v, v[e] = _u, lo[e] = 0, hi[e] = w, cst[e] = -c;
}
else u[++e] = _u, v[e] = _v, lo[e] = 0, hi[e] = w, cst[e] = c;
}
flow g;
pair<int, int> mincost(int n, int s, int t, int ss, int tt) {
static int w[N];
memset(w, 0, sizeof(w));
int flow = 0, cost = 0, tot = 0;
for(int i = 1; i <= e; i++) {
w[u[i]] -= lo[i], w[v[i]] += lo[i];
cost += lo[i] * cst[i];
g.add(u[i], v[i], hi[i] - lo[i], cst[i]);
}
for(int i = 1; i <= n; i++)
if(w[i] > 0) g.add(ss, i, w[i], 0), tot += w[i];
else if(w[i] < 0) g.add(i, tt, -w[i], 0);
g.add(t, s, 1e9, 0);
pair<int, int> res = g.mincost(ss, tt);
cost += res.second;
flow += g.limit[g.hd[s]];
g.hd[s] = g.nxt[g.hd[s]], g.hd[t] = g.nxt[g.hd[t]];
res = g.mincost(s, t);
return make_pair(flow + res.first, cost + res.second);
}
} f;
int n, m, s, t;
int main() {
cin >> n >> m >> s >> t;
for(int i = 1; i <= m; i++) {
int u, v, w, c;
cin >> u >> v >> w >> c, f.add(u, v, w, c);
}
pair<int, int> res = f.mincost(n, s, t, 0, n + 1);
cout << res.first << " " << res.second << endl;
return 0;
}
1.6.5 最大费用最大流
将所有权值取相反数转化为最小费用最大流,根据上一部分的技巧,求解(可能)有负环的费用流。
1.7 网络流 24 题
*I. P1251 餐巾计划问题
网络流相关问题,一个十分重要的技巧是 拆点。如果把每天仅看成一个点,我们无法区分干净的餐巾和脏餐巾,即干净的餐巾用完后还能作为脏餐巾继续流,而不是直接流到汇点去了。
因此考虑拆点,每天晚上得到
这题还是很巧妙的,例如通过从源点流入每天晚上所代表的节点,表示强制获得
代码。注意 LOJ 和洛谷输入格式不一样。
II. P2754 [CTSC1999] 家园 / 星际转移问题
一艘太空船所停靠的站点随着时间的变化而变化,这启发我们使用 分层图 来刻画整个星际转移过程。
考虑从时刻
容易对每艘太空船
此外,由于乘客可以在太站等待,且太空站容量无限,所以
注意地球
从
注意判断无解。两种方法,一是并查集判连通性,二是枚举到
代码。
III. P2756 飞行员配对方案问题
二分图最大匹配模板题。对于输出方案,只需找到所有满流(即剩余流量为
代码。
IV. P2761 软件补丁问题
注意到总的补丁数量很少,所以从初始态能够到达的态一定不会太多。状压 + SPFA 即可。
代码。
V. P2762 太空飞行计划问题
将所有实验和仪器抽象成点,从每个实验向它所有需要的仪器连边,就是最大权闭合子图问题。
代码。
VI. P2763 试题库问题
建模方法非常显然。
我们将每道题目抽象成左部点,每个类型抽象成右部点。源点向左部点连容量为
若最大流不等于
代码。
VII. P2764 最小路径覆盖问题
DAG 不交最小路径覆盖是网络流经典问题。
题目要求每个点都被覆盖到,但我们其实并没有什么方法表示一个点被覆盖。但注意到表示一条边被覆盖是容易的,这启发我们使用点边转化的技巧。
将点
考虑这样建模后如何求答案。首先,因为路径不可交,所以一个点最多有
将初始路径条数看成
跑一遍二分图最大匹配,得到流量
代码。
VIII. P2765 魔术球问题
由于按编号从小往大放,所以一个球的上方的编号一定比它大。
从小到大考虑每个球
直接做即可。如果加入
代码。
*IX. P2766 最长不下降子序列问题
一道还不错的题目,至少笔者没有想出来。以下用 LIS 代指最长不下降子序列。
对于第一问,我们有经典的方法,就是设
那我们尝试换一种 DP 方法,回归最原始的状态设计,设
这样有什么好处呢?我们发现一个至关重要的性质,在任何最长 LIS 当中,第
因此,为保证选出的 LIS 是最长的,我们只需保证任意相邻两个位置
通过上述分析,我们的网络流模型就呼之欲出了。为保证一个位置只被选择一次,我们拆点后将入点向出点连容量为
对于第三问,只需将
代码。
X. P2770 航空路线问题
题目等价于找到从
根据一个点只能被经过一次的限制,自然考虑拆点。
为最大化路径长度之和,我们使每个点被流过时产生贡献
本题也可以 DP 求解:设两条路径当前端点分别在
代码。
XI. P2774 方格取数问题
相邻格有限制的网格问题,一般都是通过黑白染色转化到二分图相关问题。本题就是很明显的二分图最大权独立集。
代码。
XIII. P3254 圆桌问题
二分图多重匹配模板题。
自然想到用一滴流量代表一个人,建图方式就很显然了。
若最大流不等于
代码。
XIV. P3355 骑士共存问题
题图提示我们对网格图黑白染色,有限制的两个格子之间颜色不同。二分图最大独立集直接做。
代码。
XV. P3356 火星探险问题
挺无聊的一道题。
首先拆点
跑一遍最大费用最大流,无环所以直接费用取反 MCMF。输出方案就记录每个位置有多少向右和向下的流量,从
代码。
XVI. P3357 最长 k 可重线段集问题
由于会出现平行于
稍作修补,拆点
发现拆点还是有些麻烦,直接令
*XVII. P3358 最长 k 可重区间集问题
如果一组方案符合条件,那么一定能用不超过
因此考虑用
但实际上我们发现,我们建出的大部分边都用于连接两个不相交的区间。转换一下思路,将每个坐标而不是区间看成点,这样只需在相邻两个点之间连边即可描述所有连接两个不相交的区间的边。
具体地,将区间所有端点离散化,从小到大依次为
此时我们做到了
XVIII. P4009 汽车加油行驶问题
设
令
XIX. P4011 孤岛营救问题
注意到钥匙种类很少,所以将每个钥匙种类是否拥有压成一个 mask,bfs 即可。
时间复杂度
XX. P4012 深海机器人问题
和 XV 火星探险一样,用建平行边的方式限制一条边的权值只贡献一次。建图方式显然,从所有源点向所有汇点跑最大费用最大流即可。代码。
XXI. P4013 数字梯形问题
通过拆点连容量为
XXII. P4014 分配问题
二分图最小 / 大权完美匹配模板题,代码。
XXIII. P4015 运输问题
除了边的容量改变,剩余部分和上道题一模一样,代码。
XXIV. P4016 负载平衡问题
类似上下界费用流,需要的货物从源点送,多出的货物送到汇点。
求出平均值
1.8 例题
现在你已经对网络流的基本原理有了一定了解,就让我们来看一看下面这些简单的例子,把我们刚刚学到的知识运用到实践中吧。
I. P2936 [USACO09JAN] Total Flow S
最大流模板题。
II. P1345 [USACO5.4] 奶牛的电信 Telecowmunication
有向图点边转化基础练习题。
*III. P2057 [SHOI2007] 善意的投票 / [JLOI2010] 冠军调查
集合划分模型,分析见 1.4.2 小节。代码。
IV. P2045 方格取数加强版
有向图点边转化,将每个点
V. P3410 拍照
最大权闭合子图板子题。
VI. P2805 [NOI2009] 植物大战僵尸
对于每个植物,从它的攻击位置向它连边,表示若选择攻击位置则必须选择该植物。
因为环以及能到达环的点都不可以选择,所以对反图拓扑排序。对遍历到的节点求解最大权闭合子图问题。代码。
VII. P2604 [ZJOI2010] 网络扩容
第一问建容量
VIII. CF1082G Petya and Graph
如果一条边被选,则其两个端点必须选。最大权闭合子图模型。
IX. P5192【模板】有源汇上下界最大流
对于每一天
注意在编号上区分
X. P4843 清理雪道
有源汇上下界最小流模板题。从
如果点边转化为 DAG 最小可交路径覆盖,则时间复杂度
XI. P4553 80 人环游世界
有源汇上下界费用流模板题。代码。
XII. P8215 [THUPC2022 初赛] 分组作业
裸的集合划分模型。
与
根据集合划分模型,我们可以用
合作是本题的一大难点,但只要想到独立每个人的状态和每个组的合作状态,问题就迎刃而解了。设组
首先,如果任何人
剩下来就好办了。对于每个关系
对上述网络跑最大流即可。代码。
*XIII. P2053 [SCOI2007] 修车
如果技术人员
考虑将每个技术人员拆成
XIV. P2153 [SDOI2009] 晨跑
将
*XV. CF103E Buying Sets
没有
对上述模型跑最大权闭合子图即可。代码。
XVI. P1231 教辅的组成
模型显然,将中间点拆点连容量为
XVII. P1361 小 M 的作物
考虑集合划分模型。
令作物与
对于联合贡献,同样先将贡献
XVIII. P4313 文理分科
考虑集合划分模型。
令学生与
对于联合贡献,新建点
2. 二分图
二分图是 OI 界常见的一类图,其延伸出的相关算法和模型非常广泛。我们将看到网络流在二分图上的广泛应用。
2.1 定义,性质与判定
定义:设无向图
简单地说,二分图就是可以将原图点集分成两部分,满足两个点集内部没有边的图。这也是它的名字的由来。
有了定义,我们自然希望对其进行判定。考虑满足条件的图有什么性质。
我们发现,从一个点开始,每走一条边就会切换一次所在集合。这说明从任意一个点出发,必须经过偶数条边才能回到这个点,即图上不存在奇环。反过来,若一张图不存在奇环,对其进行黑白染色就可以得到一组划分
综上,我们得到判定二分图的充要条件:不存在奇环。
-
什么是黑白染色?我们希望给每个点染上白色或黑色,使得任意一条边两端的颜色不同。
从某个点开始深搜,初始点的颜色任意。遍历当前点
的所有邻居 。如果 未被访问,则将 的颜色设为与 相反的颜色并向 深搜。否则检查 的颜色是否与 的颜色不同 —— 若是,说明满足限制;否则说明图上存在奇环,黑白染色无解。
黑白染色给予我们在
注意,接下来讨论的二分图均指点集划分方案
2.2 二分图的匹配
给定二分图
特别的,若
下文称节点
2.2.1 最大匹配
2.2.1.1 Dinic
对于给定二分图
尝试用网络流解决问题。一个节点最多与一条边相连,即节点度数
容易证明这样做的正确性:我们发现每个点最多与一个点相邻,因为限制了它到源点或汇点的流量为
使用 dinic 求解二分图最大匹配,时间复杂度是优秀的
2.2.1.2 增广路和交错路
在证明 dinic 求解二分图最大匹配的时间复杂度之前,我们需要补充二分图匹配的增广路和交错路的定义。
考虑匹配
用自然语言描述,增广路是从一个没有被匹配的点出发,依次走非匹配边,匹配边,非匹配边 …… 最后通过一条非匹配边到达 另外一部点 当中某个没有被匹配的点的路径。因此,不妨钦定增广路的方向为从左部端点走向右部端点。
如下图,红色边是匹配边
考察使用网络流求解二分图最大匹配时的增广路和二分图匹配本身的增广路形态,它们本质上一致:因为左部点向右部点连边,所以对于非匹配边,它在从左往右的方向上有流量;反之,对于匹配边,它在从右往左的方向上有流量。
网络上增广路的形态为从
容易发现,每次将一条增广路上所有边的状态取反,可得比原来匹配大
交错路 的限制则更弱一些,它只需满足路径上任意相邻两条边一条不在匹配内,另一条在匹配内。显然,增广路一定是交错路。
2.2.1.3 复杂度证明
根据 dinic 复杂度证明的结论,每次增广使得
设当前匹配为
忽略环,因为它是由
同理可证长度为偶数的路径不会使匹配大小增加。
每条长度为奇数的路径对应一条
由于路径不交且增广路长度至少为
根据每条边的容量为
上述证明结合了 “dinic 每轮增广使得增广路长度增加” 和 “长度
2.2.2 最大多重匹配
多重匹配指节点
求解最大多重匹配,只需将
Dinic 二分图匹配算法的时间复杂度证明中并没有用到与
2.2.3 带权最大匹配
对于最小权最大匹配,将最大流算法换成最小费用最大流。
对于最大权最大匹配,将最大流算法换成最大费用最大流。图中无正环,只需权值取反求最小费用最大流。
对于最大权 完美 匹配,有专门的 KM 算法 解决该问题。详见 3.3 小节。
2.3 二分图相关问题
2.3.1 最小点覆盖集
给定二分图
考虑一组点覆盖集,不存在边
但是这样会产生问题:一般集合划分模型只能处理
不过注意到我们还没有使用
相比求解最大匹配时建出的网络,上述操作进行的修改仅是将两部点之间连边的容量设为
进一步地,因为一个点最多流入或流出一单位流量,所以将两部点之间连边的容量设为
最小点覆盖集的应用:对于每条限制
2.3.2 König 定理
如果从匹配的角度理解点覆盖集,“不存在增广路” 这一性质使得我们可以根据最大匹配构造出最小点覆盖集。
以下讨论基于不存在增广路的最大匹配
从任意一个未被匹配的 右部点 出发走交错路,并依次标记所有经过的点。换言之,我们按遍历顺序依次标记从没有匹配的右部点开始的所有交错路上的所有点。注意,交错路可能退化成单点。
首先确定这些交错路的形态。交错路必然是从右部点出发,通过非匹配边走到左部点,再通过匹配边走到右部点,以此类推。这说明 从左到右走匹配边,从右到左走非匹配边。
取出所有被标记的左部点和未被标记的右部点,我们断言它是最小点覆盖集。证明如下:
考虑一条匹配边。它不可能是右端点先被标记:交错路从右部非匹配点开始,所以右端点的标记由另外一个被标记的左部点走到它而产生。又因为从左到右走匹配边,所以右端点和两条匹配边相连,矛盾。因此,它必然左端点先被标记,接下来走到右端点使得它被标记;或者两个端点同时未被标记。一条匹配边恰有一个端点属于点覆盖集。
考虑一条非匹配边。不可能出现它的左端点未被标记且右端点被标记的情况,因为此时可以从右到左走该非匹配边使得左端点被标记。因此一条非匹配边至少有一个端点属于生成的点覆盖集。
综上,我们证明了点覆盖集的合法性,每一条边被至少一个点覆盖。点覆盖集的最小性只需证明
首先证明
- 对于左部被标记的点,若它是非匹配点,考虑使得它被标记的交错路,发现这是一条增广路,矛盾。因此,被标记的左部点是匹配点。
- 对于右部点,若它是非匹配点,我们必然标记它,因为它可以作为交错路的开头:要么它是孤立点,此时交错路退化成单点;要么存在至少一条从它出发的非匹配边,考虑非匹配点的定义可得。因此,未被标记的右部点是匹配点。
结合上述两点以及一条匹配边恰有一个端点属于点覆盖集,匹配边与点覆盖集内的点一一对应。命题
而
综上,通过上述方法构造出的
König 定理:二分图的最大匹配大小等于最小点覆盖集大小。
2.3.3 最大独立集
给定二分图
考虑集合划分模型,限制形如不存在边
这启发我们考虑
综上,二分图最大独立集等于
2.3.4 最大团
给定二分图
- 作为补充,一般图的团定义为其完全子图。
同样,二分图最大团问题可以通过集合划分模型解决。方法类似,细节不再赘述。
整个过程本质等价于求补图最大独立集:考虑
2.3.5 某部点的极值
存在一些题目让我们求使得某部点的数量尽可能多的最小点覆盖集,最大独立集或最大团。
套入集合划分模型,总可以将问题转化为:对于二分图
- 例如,若希望使最大团左部点尽量多,根据最大团
补图最大独立集 补图最小点覆盖集的补集,我们希望使补图最小点覆盖集左部点尽量少。因为最小点覆盖集的集合划分模型形如被割掉的左部点加入覆盖集(已经讨论过这一点),所以我们希望被割掉的左部点尽量多。
如果仅在原图上跑最大流,我们无法控制某部点被割掉的数量。此时,集合划分模型就要发挥它的威力了。
考虑改变每个点划分入各个集合的代价,以给予每个点被割掉的优先级:将
为了保证割掉总边数的正确性,
2.4 应用与模型
2.4.1 DAG 最小路径覆盖
给定 DAG
最小 不交 路径覆盖:见网络流 24 题 VII.
最小 可交 路径覆盖:
一个点的出度和入度可能大于
接下来具体描述上述思考。
考虑一组可交路径覆盖方案,依次考虑其中的每个路径
设
一个点在路径集合
容易证明传递闭包的一组不交路径覆盖对应若干原图的可交路径覆盖。尽管方案不唯一,因为传递闭包上相邻两点在原 DAG 上之间可能有多条路径,但路径条数是不变的。
综上,DAG 最小可交路径覆盖是它传递闭包的最小不交路径覆盖。
2.5 例题
I. P2055 [ZJOI2009] 假期的宿舍
将所有需要住下的人视为左部点,所有空的床视为右部点。
对于左部点,源点向所有要在学校住下的人连边。对于右部点,所有回家的人向汇点连边。两部点之间认识的人连边,检查最大匹配是否与左部点个数相等。代码。
II. P3701 主主树
对于左边能打败右边的,连容量为 J
的生命值还要加上所属阵营中 YYY
的数量。求出带权最大匹配对
III. P6268 [SHOI2002] 舞会
二分图最大独立集模板题,需要先对可能不连通的图进行黑白染色。答案为
IV. P7368 [USACO05NOV] Asteroids G
一个小行星被消除当且仅当它所在的行或列被选中,建出二分图,则题目转化为二分图最小点覆盖集,跑最大匹配即可。代码。
V. CF1684G Euclid Guess
为使得余数为
考虑接下来的过程。若
当
这样一来就有了大致思路。找到所有
问题转化为怎么消灭掉较大的
考察欧几里得算法本身,我们发现对于一开始的
综上,枚举大
时间复杂度
VI. CF1139E Maximize Mex
因每个学生恰属于一个社团,所以一个学生可以看成其对应社团与能力值之间的连边。对于单组询问,只需从小到大枚举
删去学生不好考虑,倒过来变成加边,根据单调性用指针维护答案即可。代码。
3. 图的匹配
匹配 是一组没有公共点的边集,每个点要么有唯一匹配的点,要么是非匹配点。
乍一看,一般图最大匹配似乎和一般图最大独立集同样棘手,以至于笔者很长一段时间都以为一般图最大匹配是 NPC 问题,但事实并非如此。
3.1 相关定义
上一章我们介绍了二分图匹配,将相关概念迁移至一般图上,可得如下定义:给定图
- 称边集
为 的一组 匹配,当且仅当 中任意两条边没有公共点。 - 匹配的 大小 为边数
。 - 若
最大,则称 为 最大匹配。最大匹配不一定唯一。 - 边带权时,若边权和最大,则称
为 最大权匹配。最大权匹配不一定是最大匹配。 - 若一条边在
中,则称该边为 匹配边,反之称为 非匹配边。 - 若一个点是
中某条边的端点,则称该点为 匹配点,反之称为 非匹配点。 - 若基于
无法再增加匹配边,则称 为 极大匹配。极大匹配不一定最大。 - 若
的每个点均为匹配点,则称 为 完美匹配。
信息竞赛常见匹配问题由两个因素划分为四类。根据图是否是二分图以及边是否带权,分为二分图 / 一般图最大(权)匹配。接下来将依次介绍解决它们的常用算法。
3.2 二分图最大匹配
3.2.1 匈牙利算法
使用 Hopcroft-Karp 求解二分图最大匹配,时间复杂度
另一种常见方法是匈牙利算法,时间复杂度
由 2.2.1.2 小节可知将增广路上边的状态取反可得比原来大
考虑反转的过程,令增广路为
因此,考虑依次添加每一个左部点
- 若
已被访问,返回增广失败的信息。 - 标记
已被访问。 - 遍历
所有出边对应的右部点 :- 若
已被访问,则跳过。 - 否则,若
为非匹配点,令 匹配 ,并返回增广成功的信息。 - 否则,搜索
匹配的左部点 。若成功增广,据分析, 匹配点左部点应变为 ,并返回增广成功的信息。
- 若
- 若此时仍未返回,说明
的所有出边均无法增广,返回增广失败的信息。
进一步地,因每个左部点
增广成功次数之和即为最大匹配。注意,每次尝试从
因为每次尝试增广最坏情况下需遍历所有
#include <bits/stdc++.h>
using namespace std;
constexpr int N = 500 + 5;
int n, m, E, mch[N], vis[N];
vector<int> e[N];
bool dfs(int id) {
for(int it : e[id]) {
if(vis[it]) continue;
vis[it] = 1;
if(!mch[it] || dfs(mch[it])) return mch[it] = id, 1;
}
return 0;
}
int main() {
cin >> n >> m >> E;
for(int i = 1; i <= E; i++) {
int u, v;
cin >> u >> v;
e[u].push_back(v);
}
int ans = 0;
for(int i = 1; i <= n; i++) {
memset(vis, 0, sizeof(vis));
ans += dfs(i);
}
cout << ans << endl;
return 0;
}
3.2.2 Hopcroft-Karp
Hopcroft-Karp 和 dinic 求解二分图最大匹配的方法本质相同,但前者常数更小。HK 相当于将 dinic 特殊化,借用了 dinic 的流程,但专门用于求二分图最大匹配。
HK 首先从左部非匹配点开始 bfs 将图分层:考虑 dinic 从
和匈牙利一样,我们只 bfs 左部点,右部点仅作为中转点。因此,bfs 到左部点
- 若
已经访问过,则忽略。 - 否则令
。若 为匹配点,则与之匹配的左部点 入队,且 。因为一开始仅左部非匹配点入队,所以若 未访问,则 未访问。
注意,任意时刻若
接下来多路增广,且仅在 visit
数组,因为如果从某个右部点开始找不到增广路,那么接下来一定也找不到从它开始的增广路:整个过程中找到的增广路会在多路增广结束后同时作用于原匹配,而不是每找到一条增广路就扩大匹配。为了方便写代码,我们每找到一条增广路就改变匹配关系,但读者需注意这些增广路实质上应在多路增广结束后才被处理。
#include <bits/stdc++.h>
using namespace std;
constexpr int N = 500 + 5;
constexpr int inf = 1e9 + 7;
int n, m, E, dx[N], dy[N], mx[N], my[N], vis[N]; // mx[x] 表示 x 是否被匹配,my[y] 表示与 y 匹配的左部点
vector<int> e[N];
bool bfs() {
queue<int> q;
memset(dx, -1, sizeof(dx));
memset(dy, -1, sizeof(dy));
for(int i = 1; i <= n; i++) if(!mx[i]) dx[i] = 0, q.push(i);
int dT = inf;
while(!q.empty()) {
int t = q.front();
q.pop();
if(dx[t] >= dT) break; // 如果 dis[t] >= dis[T],直接退出
for(int it : e[t]) {
if(dy[it] != -1) continue;
dy[it] = dx[t] + 1;
if(!my[it]) dT = dy[it] + 1;
else dx[my[it]] = dy[it] + 1, q.push(my[it]);
}
}
return dT != inf;
}
bool dfs(int id) {
for(int it : e[id]) {
if(vis[it] || dx[id] + 1 != dy[it]) continue;
vis[it] = 1;
if(!my[it] || dfs(my[it])) return mx[id] = 1, my[it] = id, 1; // 类似匈牙利增广
}
return 0;
}
int main() {
cin >> n >> m >> E;
for(int i = 1; i <= E; i++) {
int u, v;
cin >> u >> v;
e[u].push_back(v);
}
int ans = 0;
while(bfs()) {
memset(vis, 0, sizeof(vis));
for(int i = 1; i <= n; i++) if(!mx[i]) ans += dfs(i);
}
cout << ans << "\n";
return 0;
}
3.3 二分图最大权完美匹配
给定二分图
最大权匹配不一定是完美匹配,如下图,最大权匹配为
3.3.1 理论分析
著名的 KM 算法用于求解二分图最大权 完美 匹配。若不存在完美匹配,则 KM 算法会死循环,故初始需对二分图进行特殊处理:
- 若求最大权匹配,则补点使得两部点大小相等,并将不存在的边补
。 - 若边权可以为负,则将不存在的边视为
。
KM 算法的核心步骤由线性规划引出,但笔者不了解线性规划,故直接给出结论,将显得不自然。待笔者学习线性规划后再做补充。
给每个点赋顶标。设左部点顶标为
结论:令满足
证明:求得最大权完美匹配的权值为当前顶标和。对于其它完美匹配,因
考虑在一组合法顶标基础上调整顶标,不断扩大相等子图直到其存在完美匹配。类似匈牙利,枚举每个左部点
从
考虑边
根据
- 若增加交错树上左部点顶标,减少右部点顶标,令变化量为
, ,则- 对于左部点属于交错树,右部点不属于交错树的边
,因 且 ,故 ,必然合法。 - 对于左部点不属于交错树,右部点属于交错树的边
,因 且 ,故 不大于所有 的 的最小值。令 取最大值 ,容易发现可将至少一条 加入相等子图。 - 对于左右部点均属于或均不属于交错树的边,无影响。
- 对于左部点属于交错树,右部点不属于交错树的边
- 同理,若减少交错树上左部点顶标,增加右部点顶标,令变化量为
,则 不大于所有左部点属于交错树,右部点不属于交错树的边 的 的最小值,且可以将至少一条这样的 加入相等子图。
哪种方法更优秀呢?感性理解,加入左部点属于交错树,右部点不属于交错树的边更优。增广路以右部点结尾,所以加入这样的边可扩展以
综上,不断将交错树上左部点顶标增加
不妨设按编号从小到大依次尝试加入每个左部点。考虑归纳法,假设前
考虑尝试加入第
当
3.3.2 实现方法
根据理论分析,容易得到一个朴素实现 KM 的算法。
设当前希望加入节点
类似匈牙利算法,设当前搜索到左部点
- 遍历
所有出边对应的右部点 :- 若
已被访问,则跳过。 - 否则,若
,则 在相等子图上。回忆匈牙利算法,当 为非匹配点或搜索 匹配的左部点 成功增广时,令 匹配 ,并返回增广成功的信息。否则跳过这条边。 - 否则
不在相等子图上,用 更新 。
- 若
- 若此时仍未返回,说明
的所有出边均无法增广或不在相等子图上,返回增广失败的信息。
无需记录
搜索完毕后,若未能成功增广,则令
- 所有被访问的左部点即
与所有被访问的右部点的左部匹配点。
因每次搜索的时间复杂度均为
模板题 P6577 代码如下。
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
constexpr int N = 500 + 5;
ll e[N][N], A[N], B[N], slack[N];
int n, m, mch[N], vis[N];
bool dfs(int id) {
for(int it = 1; it <= n; it++) {
if(vis[it]) continue;
if(A[id] + B[it] == e[id][it]) {
vis[it] = 1;
if(!mch[it] || dfs(mch[it])) return mch[it] = id, 1;
}
else slack[it] = min(slack[it], A[id] + B[it] - e[id][it]);
}
return 0;
}
int main() {
cin >> n >> m;
memset(e, 0xcf, sizeof(e));
for(int i = 1; i <= m; i++) {
int y, c, h;
cin >> y >> c >> h;
e[y][c] = h;
}
memset(A, 0xcf, sizeof(A));
for(int i = 1; i <= n; i++)
for(int j = 1; j <= n; j++)
A[i] = max(A[i], e[i][j]);
for(int i = 1; i <= n; i++) {
while(1) {
memset(vis, 0, sizeof(vis));
memset(slack, 0x3f, sizeof(slack));
if(dfs(i)) break;
ll d = 1e18;
for(int j = 1; j <= n; j++) if(!vis[j]) d = min(d, slack[j]);
for(int j = 1; j <= n; j++) if(vis[j]) B[j] += d, A[mch[j]] -= d;
A[i] -= d;
}
}
ll ans = 0;
for(int i = 1; i <= n; i++) ans += A[i] + B[i];
cout << ans << "\n";
for(int i = 1; i <= n; i++) cout << mch[i] << " ";
cout << "\n";
return 0;
}
我们发现调整顶标后重新搜索浪费时间,因为原交错树仍然存在,同时往相等子图中新加入一些边,这些边由于其左部点属于交错树,右部点不属于交错树的性质,也会加入交错树。为了不浪费已有信息,考虑 bfs。
设当前希望加入节点
- 枚举
的所有出边 :- 若
已被访问,则跳过。 - 否则,用
更新 。
- 若
- 令
为所有未被访问的右部点 的 , 为取到该最小值的 。 - 将所有被访问的左部点的顶标减少
,被访问的右部点的顶标增加 ,未被访问的右部点的 减少 :对于不与任何被访问左部点相连的右部点 ,其 为初始值 ,即使减去 也不会影响到 。 - 此时
,说明 在相等子图的交错树上。- 若
未被匹配,则找到增广路,退出 bfs。 - 否则,令
为 匹配的左部点,继续 bfs。
- 若
考虑记录额外信息从而更新增广路上所有节点的状态。仔细思考后发现可以记录每个右部点
由上图可知,从退出 bfs 对应的
如何维护
因使得
若
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
constexpr int N = 500 + 5;
ll e[N][N], A[N], B[N], slack[N];
int n, m, mch[N], pre[N], vis[N];
void bfs(int id) {
memset(vis, 0, sizeof(vis));
memset(slack, 0x3f, sizeof(slack));
int x = mch[0] = id, y = 0;
while(1) {
vis[y] = 1;
ll d = 1e18;
int _y = 0;
for(int i = 1; i <= n; i++) {
if(vis[i]) continue;
ll D = A[x] + B[i] - e[x][i];
if(D < slack[i]) slack[i] = D, pre[i] = y;
if(slack[i] < d) d = slack[i], _y = i;
}
A[id] -= d;
for(int i = 1; i <= n; i++) {
if(vis[i]) B[i] += d, A[mch[i]] -= d;
else slack[i] -= d;
}
if(!mch[y = _y]) break;
x = mch[y];
}
while(y) mch[y] = mch[pre[y]], y = pre[y];
}
int main() {
cin >> n >> m;
memset(e, 0xcf, sizeof(e));
for(int i = 1; i <= m; i++) {
int y, c, h;
cin >> y >> c >> h;
e[y][c] = h;
}
memset(A, 0xcf, sizeof(A));
for(int i = 1; i <= n; i++)
for(int j = 1; j <= n; j++)
A[i] = max(A[i], e[i][j]);
for(int i = 1; i <= n; i++) bfs(i);
ll ans = 0;
for(int i = 1; i <= n; i++) ans += A[i] + B[i];
cout << ans << "\n";
for(int i = 1; i <= n; i++) cout << mch[i] << " ";
cout << "\n";
return 0;
}
3.4 一般图最大匹配
NOI 后再更。
3.5 一般图最大权匹配
NOI 后再更。
参考文章
第一章:
第二章:
- Dinic 二分图匹配 / Hopcroft-Karp 算法 复杂度简单证明 - Itst。
- 二部图最大匹配 —— 新数据结构 Augmenting graph 与 Hopcroft-Karp 算法的复杂度证明 - JHack。
- Konig 定理及证明 - Bennettz。
第三章:
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· 单线程的Redis速度为什么快?
· SQL Server 2025 AI相关能力初探
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 展开说说关于C#中ORM框架的用法!