可爱的图论学习笔记
鲜花
2022年暑假期间主要训练了各类图论内容,于是有了这一篇帖子。
全文涵盖了基环树、差分约束、网络流、二分图等板块的基础知识。二次扫描与换根由于时间问题最后并没有写成。
是我的第二篇上万字的长文。
无关紧要的前言
7.13:时隔不知多少个月,我再一次大言不惭地提笔开始写算法总结。
7.28:时隔不知多少个月,我再一次大言不惭地提笔继续更新算法总结。
更新时间线:
7.28:二分图匹配
7.29:差分约束
7.30:差分约束习题 + 修正错误内容
7.31:最小割&例题
8.1:费用流,但是没更完
8.2:优化了语言表达,修正了一些肉眼可见的错误,补全了不详细的部分
8.3 - 8.5:被每日一题搞了,断更 最后放弃了8.4的题
回CQ了,应该可以日更awa
8.6:更新了上下界网络流;又爆肝1.5h更了经典模型,网络流部分至此完结。
8.7:更新了基环树的概念,但是因为题还没补完先咕了例题
8.8:终于补完了题,但是总结还没写完
8.9:终于更完了基环树的题,基环树部分至此完结
为什么写呢
主要原因是花的时间全部在冲难题上,有时就会冲不动了导致效率低下。但是如果能利用这些时间写总结来养精蓄锐,等总结完再做,加深了理解后应该可以增加效率和收获。
次要原因是恰逢最近时间巨多,而且我发现没写总结复习起来异常的难受,忘记了很多实用 Tricks,所以觉得写总结为以后做准备也很重要。
综上,我们赶快开始吧。
另外,这篇帖子出于复习&整理Tircks的目的而写,如果要深入了解算法建议看其他人的博客,估计讲得更详细。
参考复习链接
*注:是校内OJ的链接。过去这么久了原来的比赛把链接改对还能访问,也是惊叹于lifan的支持做的非常好了。
差分约束:新初三暑假 差分约束1、新初三暑假 差分约束2、周考6 E. 超人跳房子
二次扫描与换根:新初三暑假 二次扫描与换根1、新初三暑假 二次扫描与换根2
二分图匹配:新初三暑假重点巩固、初二51集训二分图匹配、周考2 F. 最大子集、KM算法、最大独立集、最小点覆盖 最大独立集
网络流:网络流——最大流,最大流巩固,网络流经典选练一,网络流经典选练二,上下界网络流基础,费用流,最小割,费用流练习
二分图匹配
一、二分图相关定义&性质
(一)二分图
- 定义:节点由两个集合组成,且两个集合内部没有边的图。
- 性质:二分图不存在长度为奇数的环(二分图的判别)
(二)匹配
- 定义:二分图的一个子图,满足任意两条不同的边都没有公共端点。
- 最大匹配:边数最多的匹配。
(三)增广路
- 定义:始于非匹配点且终于非匹配点的一条路径,满足匹配边与非匹配边交错出现。
(四)最小点覆盖
- 定义:选最少的点,满足每条边至少有一个端点被选。
- 性质:二分图中,最小点覆盖 \(=\) 最大匹配。
- OI Wiki的证明。显然之前写的证明我伪证了,Fixed。
(五)最大独立集
- 定义:选最多的点,满足两两之间没有边相连。
- 性质:二分图中,最大独立集 \(=\) \(n\) \(-\) 最小点覆盖。
- 最小点覆盖使得每一条边都至少有一个端点被选,所以它的补集中必然不存在一条边的两个端点都被选。
二、匈牙利算法
(一)应用场景
- 解决二分图最大匹配、最小点覆盖、最大独立集等问题。
(二)算法思路
- 注意到增广路匹配与未匹配边取反后,我们刚好多了一条匹配边,同时没有破坏原来已经匹配的点(只不过它们的匹配边变成了另一条)。
- 所以只需要从 \(1\) 到 \(n\) 每个点尝试找增广路,如果找到了就扩展当前答案,也就是
ans++然后对增广路上每条边的选中状态取反。 - 如何找增广路:从左部一个点 \(x\) 开始 DFS 找增广路,每次访问一条连接右部的边,如果连接的点是未匹配点,则找到增广路;否则尝试从这个点的左部匹配点出发继续找增广路。假设 \(match\) 数组存储右部点匹配的左部点。\(x \to to\) 是非匹配边,\(to \to match[to]\) 是匹配边,那么从 \(match[to]\) 出发的增广路可以接在 \(x \to to \to match[to]\) 后面,形成一条完整的由 \(x\) 出发的增广路,即可以使 \(x\) 点成功增广。
(三)实现例子
找增广路 & 取反
bool dfs(int x) {
for (int i = head[x]; i; i = nxt[i]) {
if (vis[to[i]]) continue; //避免重复找增广路
vis[to[i]] = 1;
if (!mat[to[i]] || dfs(mat[to[i]])) { //如果要拓展的点没有匹配点,即它是未匹配点,或 DFS 找到了一条可以接上的增广路
mat[to[i]] = x, mat[x] = to[i]; // 翻转增广路上的边的状态
return 1;
}
}
return 0;
}
增广每个点
int maxmatch() {
int ans = 0;
for (int i = 1; i <= n; i++) {
memset(vis, 0, sizeof vis);
if (dfs(i)) ans ++; // 匹配成功,ans++
}
}
例题
此类题目最重要的就是将题目要素划分为两个部分然后再对应使用最大独立集、最小覆盖、最大匹配等做法。
I类:题目要素本身就含有两个部分
Example:
- [SCOI2010] 连续攻击游戏:将属性值和装备划分为两个部分,通过匹配的限制使得装备只能用一个属性。
- [USACO05NOV] Asteroids G:将激光按照 行和列 划分,求最小点覆盖。
II类:题目要素没有划分为两个部分,但是可以自行划分
此类最重要的是保证划分后左部和右部内都不会存在矛盾/可以匹配,只有左右部之间才会产生矛盾/可以匹配。
Example:
-
bzoj8385 棋盘上的骑士:可以观察到没有奇环,可以 BFS 划分左右部。严格论证没有奇环:因为每次移动 \(x\) 与 \(y\) 的差都会 \(+1\) 或 \(-1\),所以要回到出发点一定 \(+1\) 和 \(-1\) 的移动个数相同,加起来总移动次数就是偶数。
-
bzoj48355 最大子集:可知奇偶性相同的两个数可以同时选,之间没有矛盾。按照奇偶性划分左右部。
差分约束
一、简介
- 使用场景:当出现有一个数组 \(a\) 需要满足一堆形如 \(a_i - a_j \; \text{op} \; c\) 的式子(\(op\) 为运算符,如 \(>、\geq、=\);\(c\) 必须是常量;注意数组中元素间的运算必须是 \(-\) ),并且希望知道是否有解或解中某两个数的极差等等。
- 实现方式:注意到单源最短路满足的三角形不等式 \(d_x + z \le d_y\),其中 \(z\) 为边 \(x \to y\) 的边权,与我们需要处理的式子相近(只需移项即可转化),则让 \(d_i\) 对应 \(a_i\),然后通过三角形不等式的限制来建边满足约束式子,再跑单源最短路 (SPFA) 以获取构造。如果有负环则无解。
- 求解 \(\text{max}(a_i-a_j)\)。注意到我们跑的最短路,即每个点的距离都是最大的可以满足所有三角形不等式的(即距离再大就会因为三角形不等式而被更新,这里很容易搞混,我写的时候就写反了,Fixed)。所以我们以 \(j\) 点为原点跑最短路,\(dis_i\) 即为 \(\max(a_i-a_j)\)。如果是求解 \(\min(a_i-a_j)\),则所有边取相反数跑最短路,最后 \(dis_i\) 再取相反数。
二、Tricks
(一)SLF 优化 SPFA
-
对于差分约束,最容易被卡的显然是不稳定的 SPFA。SPFA 的优化中折中地看,SLF 实现简单效果也不错。
-
实现方式:每次扩展出的点与队头元素比较,若该点距离小于队头的点对应距离,则进队头,否则进队尾。(需要 deque)
-
Code:
if(!q.empty() && dis[to[i]] < dis[q.front()]) q.push_front(to[i]);
else q.push_back(to[i]);
(二)不等式转化
- \(a_i - a_j = c\):限制 \(a_i - a_j \le c\)、\(a_i - a_j \ge c\)。
- \(a_i - a_j < c\),且 \(a_i、a_j \in \mathbb{Z}\),限制 \(a_i - a_j \le c-1\)。
(三)初始化
- 如果已经知道了某个点的距离最大值,初始化就不要赋值 \(\text{INF}\),初始值尽量小也可以优化。
(四)奇技淫巧
- 限制总更新次数。有的时候可能即使优化加满面对无解的情况要跑满还是容易被卡。直接限制总更新次数为 \(1e7\) 之类的数,如果更新次数超过了直接输出无解。
- 为了保证图的联通性,我们往往需要从超级原点向每个点连一些不影响解的边使 SPFA 可以进到每个点。但如果顺序建边,可能就无法快速地在算法初期深入图(我猜测的对于某些题可能存在的优化作用)。所以就需要将顺序建边改为按照随机顺序建边。(这样前向星遍历出边是随机的)
for (int i = 1; i <= n; i++) a[i] = i;
random_shuffle(a + 1, a + n + 1); // 随机打乱 a 数组,如果不随机打乱就是顺序建边。
for (int i = 1; i <= n; i++) {
add(n + 1, a[i], -1);
}
点名批评[SCOI2011] 糖果这道题。我至今没有想出来这个”优化“真正的意义在哪里,因为该题本质上只是数据专门恶心人卡了顺序建边,但是顺序建和随机建真的在这道题中本质相同啊。。。
- 能把边权取相反数写最短路就不要写最长路,最长路需要修改的细节较多。
三、注意事项
(一)建边
思考建边的时候从三角形不等式出发,一条 \((u, v, w)\) 的边就可以满足 \(d_v \le d_u + w\)。否则很容易建反。
(二)连通性
一定要检查建出来的图的联通性(原点是否可以跑到所有需要知道权值的点)。如果跑不到就加入一些不影响答案的边使得图联通,才可以跑 SPFA 获得每个点的值。
四、经典题目
bzoj48635 超人跳房子
建图是显然的。排序之后从矮到高再建边即可。但是两个非常易错的点。
- 求最矮房子与最高房子的最远距离:需要从最矮的房子处开始跑 SPFA,而不是什么超级原点。
- 如果我们用这个方法建图:
for (int i = 2; i <= n; i++)
add(i, i - 1, -1);
那就需要处理不连通的情况。如果最低的点为 \(u\),最高的点为 \(v\),且 \(u < v\),那么就需要从最高点开始跑 SPFA,答案取相反数。
[SCOI2008] 天平
三种情况相同,下面只讨论左边重。因为要求 \(A + B > C + D\),所以按照差分约束的式子转化得 $ A - C > D - B$。
我们可能很难确定每个砝码的重量,但是可以确定两个砝码之差的上下界(因为题目给的条件都是两个砝码的关系)。按照差分约束建图分别跑最短路最长路得出每两个砝码重量的最大差和最小差。若 \(\text{min}(A-C) > \text{max}(D-B)\) 则一定存在 $ A - C > D - B$。
这道题目的启发是,如果需要让两个形如 \(A-B\) 且无法确定准确值的式子一定满足大小关系,可以跑出最大差和最小差来比较。
[省选联考 2021 A 卷] 矩阵游戏
实在是好题啊。
首先我们需要满足两个条件,一个是 \(a_{i,j} \in [0,1e6]\),一个是题目中的式子。
直接推出什么公式构造出矩阵似乎不太可行。观察可以发现,如果无视第一个条件,我们一定可以得到一个 \(a\) 矩阵(比如令最后一行和列为 \(0\) 递推)。
那么,这个时候就可以使用调整法,在此基础上调整出一个满足条件的矩阵。
注意到我们构造不符合条件的 \(a\) 矩阵时令了最后一行和列的值就确定了整个 \(a\) 矩阵的值。所以我们只需要控制最后一行和列的值来调整即可。
进一步观察发现,如果我们设置第 \(n\) 行的调整值为 \(p\) 数组,且调整值是加上去,那么 \(n-1\) 行为了满足题中对于四方格和一定的式子需要减去 \(p\),然后 \(n-2\) 行又需要加上 \(p\),以此类推。再设第 \(m\) 列的调整值为 \(q\) 数组,我们很好拿到每个点更改后的值为 $ a_{i,j} \pm p_j \pm q_i\((\)a$ 矩阵是待调整矩阵)。注意到为了使得可以差分约束,我们第 \(n\) 行如果是 \(+p\) 数组。那么第 \(m\) 列必须是 \(-q\) 数组,这样地推下去每个 \(a_{i,j}\) 的总更改值都是 \(p_j-q_i\) 或 \(p_i-q_j\),形成了差分关系,转化式子即可差分约束。
建图代码
// R(i) 与 C(j) 代表每个行和列的 id。
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) {
if ((i + j) & 1) {
add(R(i), C(j), a[i][j]);
add(C(j), R(i), (int)1e6-a[i][j]);
} else {
add(C(j), R(i), a[i][j]);
add(R(i), C(j), (int)1e6-a[i][j]);
}
}
}
网络流系列
一、网络流相关定义&性质
(一)网络&流
-
网络:一张具有原点 \(S\) 与汇点 \(T\) 的有向图。
-
网络上的边 \((u,v)\)
- 定义 \(c(u,v)\) 为该条边的容量。
- 定义 \(f(u,v)\) 为该条边的流量。
- 定义 \(r(u,v)\) 为该条边剩余流量,即 \(r(u,v) = c(u,v)-f(u,v)\)。
-
流的性质
-
每条边满足 \(r(u,v) \le c(u,v)\)。
-
除去 \(S\) 与 \(T\),一个点流入的流量 \(=\) 这个点流出的流量。
-
对于 \(S\) 与 \(T\),\(S\) 流出的流量 \(=\) \(T\) 流入的流量 \(=\) 这个网络的流量。
-
对于 \((u,v)\) 的反向边 \((v,u)\),满足 \(f(u,v) = -f(v,u)\)。
-
对于网络中本来存在的有意义的 \((u,v)\),定义 \(f(v,u)=0\)。
这是为了保证 \(r(u,v)+r(v,u) = c(u,v)\),便于接下来建图。
-
(二)构造算法的基础
-
残量网络:网络中所有的节点和 \(r(u,v) > 0\) 的边构成的新图。
-
增广路:残量网络中一条由 \(S\) 出发、\(T\) 终止的路径,路径上每条边的剩余流量都大于 \(0\)。
- 注意到可以通过不断找增广路,来发现还可以利用的剩余流量并更新网络的总流量。如果这条路径上剩余流量最小的一条边剩余 \(x\) 的流量,那么我们让 \(S\) 多流出 \(x\) 的流量并通过这条路径流到 \(T\) 是完全满足流的性质的,且增加了网络的总流量。
-
建图的方法
因为我们是在残量网络中找增广路,所以直接建成残量网络就可以了。
对于一条边 \((u,v)\):
- 正向边:直接调用 \(\text{add}(u,v,c(u,v))\)。
- 反向边:调用 \(\text{add}(v, u, 0)\)。
Q:为什么双向建边
-
因为只建单向可能导致我们走边的顺序影响答案。可能我们选的第一条增广路更改流量过大(但实际上路径上的一些边还需要给其他增广路留一些流量),导致后面遍历到的的增广路连不起来。建双项边可以便于我们反悔,因为由反边流回去其实就是相当于减去了之前过多的更新值(参考流的性质)。
-
这里有一个带图的博客,便于直观理解为什么双向建边。
-
如何更新增广路
将增广路上的每条正向边的 \(r(u,v)\) 减去更新流量 \(x\),而反边的 \(r(v,u)\) 则加上更新流量 \(x\) (因为这两条边的 \(r\) 值相加永远等于正向边的 \(c\) 值)。
- 一个小小的Trick:如果你也和我一样热爱前向星,在初始化时使 \(cnt = 1\),然后保证正边与反边是紧挨着先后建立的。这样一条正边 \(x\) 的反边就是 \(x \oplus 1\) 了。
二、最大流相关算法(Ford–Fulkerson增广)
(一)朴素算法
- 思路:入门算法,就是通过按上述方法建图,DFS 找增广路,然后按上述方法更新增广路和最大流量,找不到增广路时算法结束。
Code:
int dfs(int x, int f) { // 找增广路、完成更新、返回更新的流量。
if (x == t) return f; // 返回这条路上最小的剩余流量,即我们可以更新的流量
vis[x] = 1;
for (int i = head[x], res; i; i = nxt[i]) {
if (c[i] && !vis[to[i]]) { // 如果 r(i,to[i]) 不为零,即可以成为增广路的一部分,且没有重复访问
res = dfs(to[i], min(f, c[i])); // 就从它这里往下继续尝试找增广路
if (!res) continue; // 如果没有找到增广路,就不管它
c[i] -= res, c[i^1] += res; // 正边减去更新流量,反边加上更新流量
return res; // 拓展成功,回溯让上一层更新
}
}
return 0; // 没有拓展出增广路
}
void FF() {
int ans = 0, res;
while (1) {
memset(vis, 0, sizeof vis);
res = dfs(s, MAX);
if (!res) break; // 找不出增广路,结束算法
ans += res;
}
printf("%d", ans);
}
(二)Edmonds–Karp 算法
- 思路:注意到 FF 算法找增广路很容易找到比较长的而不是相对而言处理更快的短一些的增广路(因为 DFS 的性质),所以我们将 DFS 找增广路换成 BFS,这样每次找到的增广路都是最短的,可以使遍历的开销尽量小以达到优化目的。
Code
int aug[MAXN], pre[MAXN]; queue<int> q, _q;
// aug[i]:S 到 i 的某条最短路径上的最小剩余流量,所以 aug[t] 就是找到的增广路可以更新的流量
// pre[i]: i 的 前驱
int bfs() {
mem(aug), mem(pre), q = _q;
aug[s] = MAX; q.push(s);
int x;
while (q.size()) {
x = q.front(); q.pop();
for (int i = head[x]; i; i = nxt[i]) {
if (!c[i] || aug[to[i]]) continue;
aug[to[i]] = min(aug[x], c[i]);
pre[to[i]] = x;
if (to[i] == t) return aug[t]; // 如果找到了直接返回更新的流量
q.push(to[i]);
}
}
return 0; // 没找到返回 0
}
void updpath(int dlt) {
int x = t;
while (x != s) {
for (int i = head[x]; i; i = nxt[i]) {
if (to[i] == pre[x]) {
c[i] += dlt; c[i^1] -= dlt;
x = pre[x]; break;
// 注意到这里我们从后往前遍历路径经过的都是反边,所以 i^1 其实才是正向边
}
}
}
}
void EK() {
int ans = 0, res;
while (1) {
res = bfs();
if (!res) break;
updpath(res);
ans += res;
}
printf("%d", ans);
}
(三)Dinic算法
- 优化思路:注意到前文提及的算法每次都只能找一条增广路,导致找增广路的开销非常高。有没有什么办法可以每次找一堆增广路呢?
- 核心思想:我们以 EK 算法的优化方式——每次只处理最短增广路为起点。为了既可以找多条增广路,又保证找出的增广路一直都是最短的,我们想到给每个点按照到 \(S\) 的距离 \(d\)(假设边长度为 \(1\))进行分层。同时,为了跑出多条增广路,即遍历所有可能的最短增广路径,我们的框架便是 DFS 找增广路。
- 初始化代码
bool bfs() {
for (int i = 1; i <= pt; i++)
d[i] = 0;
d[s] = 1; q = _q, q.push(s);
while (q.size()) {
int x = q.front(); q.pop();
for (int i = head[x]; i; i = nxt[i]) {
if (r[i] && !d[to[i]]) {
d[to[i]] = d[x] + 1; // 标记每个点的距离以分层
q.push(to[i]);
if (to[i] == t) return 1; // 可以走到 T,返回有增广路
}
}
}
return 0; // 无增广路
}
- 找多条增广路:我们从原点开始 DFS。假设我们现在处理经过 \(x\) 的增广路,设 \(S\) 到 \(x\) 的路径上最小剩余流量为 \(\text{flow}\),那么经过这个点的增广路的流量更新和必然不大于 \(\text{flow}\)。所以我们定义一个 \(\text{rest}\),每条出边找到增广路后都需要使 \(\text{rest}\) 减去流量更新值,必须保证 \(\text{rest} \ge 0\)。按照这样的规则,我们遍历 \(x\) 的所有出边,将 \(\text{min}(\text{rest},r[i])\) 作为最小剩余流量 \(\text{flow}\) 传入下一层 DFS(\(r[i]\) 为这条边的剩余流量),每条边都尝试找一次增广路即可。
- 更新找到的增广路:当 \(x\) 的出边的 DFS 完毕后,我们拿到更新流量的返回值,然后更新这条边的剩余流量即可。(注意正向边减去更新流量,反向边加上更新流量)。所以 DFS 返回值即为 \(x\) 点所有出边更新流量的总和。
- 核心代码
int dinic(int x, int flow) { // flow:S 到 x 的路径上最小剩余流量
if (x == t) return flow;
int res, rest = flow; // rest:为了保证流量更新和不大于 flow,我们定义一个剩余流量。
for (int i = head[x]; i; i = nxt[i]) {
if (r[i] && d[x] + 1 == d[to[i]]) {
res = dinic(to[i], min(rest, r[i])); // 接下来这个点的出边的增广路更新流量和不大于 x 可以接受的剩余流量
if (!res) continue;
rest -= res, r[i] -= res; // 更新 x 点走这条边的增广路带来的流量改变,同时更新剩余流量
r[i^1] += res;
}
}
return flow - rest; // 返回 x 更新的所有流量
}
- 非常重要的优化(如果打漏了任何一个优化,由于分层DAG的有效路径数是阶乘级别的,算法的效率都可能大大降低,所以优化一定要打全)
- 如果 \(\text{rest} = 0\),我们没有必要继续找下去了,直接
return。 - 如果扩展某个点失败了,为了防止其他边也去重复更新这个点,我们使 \(d[to] = 0\),相当于打一个标记。
- 当前弧优化:如果 \(i\) 点已经扩展过它的前 \(j\) 条出边,那么其他点来通过这个点扩展时,可以直接从第 \(j + 1\) 条边开始扩展(因为 \([1,j]\) 的边已经遍历过一次,分层图无环,肯定拓展不了)。
- 完整代码
bool bfs() {
for (int i = 1; i <= pt; i++)
d[i] = 0, now[i] = head[i]; // 优化3:初始化每个点都遍历到第一条边
d[s] = 1; q = _q, q.push(s);
while (q.size()) {
int x = q.front(); q.pop();
for (int i = head[x]; i; i = nxt[i]) {
if (r[i] && !d[to[i]]) {
d[to[i]] = d[x] + 1;
q.push(to[i]);
if (to[i] == t) return 1;
}
}
}
return 0;
}
int dinic(int x, int flow) {
if (x == t) return flow;
int i, res, rest = flow;
for (i = now[x]; i && rest; i = nxt[i]) { // 优化 1 & 优化3:从上一次遍历的最后一条边的下一条开始遍历
if (r[i] && d[x] + 1 == d[to[i]]) {
res = dinic(to[i], min(rest, r[i]));
if (!res) d[to[i]] = 0; // 优化 2
rest -= res, r[i] -= res;
r[i^1] += res;
}
}
now[x] = i; // 优化3:记录下一次遍历从 i 开始
return flow - rest;
}
三、最大流相关例题
题外话:最大流的核心当然在于各种各样巧妙的建图,把题目条件抽象成节点与边的方式。所以多总结多看看建图思路是很重要的。
bzoj8643 奶牛食品
-
与一般的最大匹配不同,我们需要使一只牛匹配两个东西(食物&饮料)才能使得总流量 \(+1\)。换句话说,这其实可以理解为一个 ”与“的条件,即既要满足前者又要满足后者。但是正常的网络流都是直来直去的,边对流量的限制无法满足与的条件的构造,所以只能考虑点上的构造。
-
如果一个点有流入,那么它必然有流出,那么流入边对应的条件和流出边对应的条件都可以满足,即满足了与的构造。
-
所以我们对于每只牛,让它喜欢的食物建一条边流向它,同时再让它建流向它喜欢的饮料的边,这样只要选了食物必然就会选饮料。最后饮料流向汇点就可以统计答案。
秘密挤奶机
- 题目简单不多说,就是二分套最大流。但是当你建两条正向的双向边(不管反向边,一条无向边我们需要建两个方向)的时候,是否有疑虑过 网络不是有向边构成的图吗?
- 其实无向边也是可以跑的,只是我们对于边权的考虑需要细致一点。如果我们对于一条无向边,两个方向都建一条容量为 \(1\) 的边(不管反向边),那么是否可能出现我们边权限制失效了,有可能在两条路径中一条无向边被从正向和反向各走了一次,但是其实一条边只可以出现在一条路径中?
- 显然这道题不存在这个问题。因为对于无向边 \((u,v)\) 如果存在两条其他部分不相交的路径 \(S \rightarrow u \rightarrow v \rightarrow T\),\(S \rightarrow v \rightarrow u \rightarrow T\),此时无向边被走了两次,我们完全可以构造出符合题目的另外两条不会使用一条边两次的路径:\(S \rightarrow u \rightarrow T\)、\(S \rightarrow v \rightarrow T\)。
- 换句话说,只要保证一条有向边只出现在一条路径中,就不会违背题意,这当然是网络流可以处理的。
猪
-
面对我们可以调整各个节点的权值的题目,低效的办法是把所有两两可以调节的边相连接。为了优化,我们其实可以把能互相调节的节点都连向一个辅助点,然后再从辅助点流到调节过权值的新点(不要再流回原来的点,因为题目中前面调节操作不能把后面调来的权值也调整了),网络流会帮我们自动匹配出最好的调节方式。
-
建图实例
void init() { m = read(), n = read(); s = 1, t = pt = 2, ecnt = 1; // pt表示包括辅助点的所有点数量 for (int i = 1, x; i <= m; i++) { top[i] = ++pt; x = read(); // 初始化最开始每个猪圈对应的点 add(s, top[i], x); // 初始化猪圈最开始的猪数量 add(top[i], s, 0); } for (int i = 1, a, b, k, mid; i <= n; i++) { a = read(), mid = ++pt; // 建一个辅助点 while (a--) { k = read(); add(top[k], mid, MAXP); //连向辅助点 add(mid, top[k], 0); top[k] = ++pt; // 更新这个猪圈对应的点 add(mid, top[k], MAXP); // 辅助点流向新点 add(top[k], mid, 0); } b = read(); add(mid, t, b); add(t, mid, 0); // 统计答案,辅助点的猪也可以直接卖掉 } }
企鹅游行
-
注意到我们按照题意,把每个冰作为一个点建完图的时候,还需要限制一个点的流出流量。常用的方法就是建一个它的辅助点,这个点只能先流向辅助点,且这条边的权值就是最大流出流量和,然后再从辅助点往外流,这样就可以限制流出流量了。
-
另一个小技巧就是我们要统计不同汇点的最大流时,可以直接修改汇点。所以一定不要用 \(1\) 或者 \(n\) 表示源点汇点,用 \(S\) 和 \(T\),不需要修改算法可以方便地更改源汇。
-
以及一个易错点,对于一张图多次跑最大流,每次都需要把剩余流量数组复制回去。(因为更新增广路会修改 \(r\))
-
建图实例
void init() { n = read(); scanf("%lf", &mdis); pt = s = n * 2 + 1, num = 0; for (int i = 1; i <= pt; i++) head[i] = 0; ecnt = 1; for (int i = 1, qi, ji; i <= n; i++) { qx[i] = (dec)read(), qy[i] = (dec)read(); qi = read(), ji = read(); add(s, i, qi), add(i, s, 0); add(i, i + n, ji), add(i + n, i, 0); // i + n 为 i 的辅助点,我们限制 (i,i+1) 的流量为 ji (最大的起跳次数),来限制流出流量 num += qi; } for (int i = 1; i <= n; i++) { for (int j = 1; j <= n; j++) { if (i == j) continue; ds = sqrt((qx[i] - qx[j]) * (qx[i] - qx[j]) + (qy[i] - qy[j]) * (qy[i] - qy[j])); if (ds <= mdis) { add(i + n, j, MAXJ); // 注意辅助点不可以直接跳到辅助点,否则跳过去的那个点的流量限制就失效了 add(j, i + n, 0); } } } memcpy(_r, r, sizeof r); // 备份 r 数组 }
ZQC 的游戏
-
问题可以抽象为,有 \(n\) 个需要被分配权值的点,以及 \(m\) 个有权值的点。两种点间有连边,表示有权值的点可以分权值给需要被分配的点。最后所有 \(m\) 个点的权值都需要分完,问能否使得被分配了权值的 \(n\) 个点每个点的权值都不大于 \(x\)。
-
注意到这是一种最大流判断方案的问题。因为 \(m\) 个点的权值要分配完,所以后 \(n\) 个点的总权值是已知的。那么,为了保证这 \(n\) 个点权值都不大于 \(x\),我们很显然需要给它向汇点连一条容量为 \(x\) 的出边。如果不存在满足题意的方案,那么必然有流量被容量卡住流不完,最大流也不会等于 \(m\) 个点的总权值。
-
Code
#include <cstdio> #include <algorithm> #include <queue> using namespace std; const int MAXN = 1000, MAXM = 100000, MAX = (int)1e9; int read(); int head[MAXN], nxt[MAXM], to[MAXM], r[MAXM], ecnt; void add(int x, int y, int z) { nxt[++ecnt] = head[x]; head[x] = ecnt; to[ecnt] = y, r[ecnt] = z; } int n, m, s, t, pt, zqch, sumh; struct player{ int x, y, r; int w; }a[105]; struct ball{ int x, y; int w; }b[405]; bool check(player p, ball q) { return ((p.x - q.x) * (p.x - q.x) + (p.y - q.y) * (p.y - q.y)) <= (p.r * p.r); } void init() { n = read() - 1, m = read(); for (int i = 0; i <= n; i++) { a[i].x = read(), a[i].y = read(); a[i].w = read(), a[i].r = read(); } zqch = a[0].w, sumh = 0; for (int i = 1; i <= m; i++) { b[i].x = read(), b[i].y = read(), b[i].w = read(); if (check(a[0], b[i])) { zqch += b[i].w; i--, m--; } } } bool build() { pt = n + m; s = ++pt, t = ++pt; for (int i = 1; i <= pt; i++) head[i] = 0; ecnt = 1; for (int i = 1; i <= m; i++) { for (int j = 1; j <= n; j++) { if (check(a[j], b[i])) { add(i + n, j, (int)1e4); add(j, i + n, 0); } } if (!head[i + n]) continue; add(s, i + n, b[i].w); add(i + n, s, 0); sumh += b[i].w; } for (int i = 1; i <= n; i++) { if (zqch < a[i].w) { puts("qaq"); return 1; } add(i, t, zqch - a[i].w); add(t, i, 0); } return 0; } int d[MAXN], now[MAXN]; queue<int> q, _q; bool bfs() { for (int i = 1; i <= pt; i++) d[i] = 0, now[i] = head[i]; d[s] = 1; q = _q, q.push(s); while (q.size()) { int x = q.front(); q.pop(); for (int i = head[x]; i; i = nxt[i]) { if (r[i] && !d[to[i]]) { d[to[i]] = d[x] + 1; q.push(to[i]); if (to[i] == t) return 1; } } } return 0; } int dinic(int x, int flow) { if (x == t) return flow; int i, res, rest = flow; for (i = now[x]; i && rest; i = nxt[i]) { if (r[i] && d[x] + 1 == d[to[i]]) { res = dinic(to[i], min(rest, r[i])); if (!res) d[to[i]] = 0; rest -= res, r[i] -= res; r[i^1] += res; } } now[x] = i; return flow - rest; } void solve() { int sum = 0; while (bfs()) { sum += dinic(s, MAX); } if (sum == sumh) puts("ZQC! ZQC!"); else puts("qaq"); } int T; int main() { T = read(); while (T--) { init(); if (build()) continue; solve(); } return 0; } int read() { char x = 0; int y = 0; bool f = 0; while (x < '0' || x > '9') { x = getchar(); if (x == '-') f = 1; } while (x >= '0' && x <= '9') { y = (y << 1) + (y << 3) + (x ^ 48); x = getchar(); } return f ? (-y) : y; }
四、最小割相关概念&例题
(一)定义
- 割:将所有的点划分为 \(S\) 和 \(T = V - S\) 两个集合,使得源点 \(\in S\),汇点 \(\in T\)。
- 割的容量:所有从 \(S\) 到 \(T\) 的边的容量和(注意 \(T\) 到 \(S\) 的边是没有影响的)。
- 最小割:容量最小的割。
(二)如何求
- 结论:一个网络的最小割 \(=\) 最大流。
- 为什么:
口胡一下,最小割是将图割去一些边的容量使其成为两部分,最大流跑完之后不存在增广路(即满流了一些边,割掉了这些容量)也是将图割成两部分。而最大流没跑完之前图都是存在增广路的,不满足割的条件。至于为什么是最小的,感性理解吧QWQ。 - 严谨证明
(三)例题
方格取数
- 经典题目。我们将相邻的格子之间连权值为 \(\text{INF}\) 的边,使得相邻格子一定不会被同时选。然后再将格子划成两个集合(按照横纵坐标和的奇偶性),一个连 \(S\) 一个连 \(T\),用边权连边。这样不选一个格子就相当于把这个格子的边割掉。
- 注意划分的两个集合内部一定不能有矛盾,因为这时连的 \(\text{INF}\) 边和 \(S\) 到 \(T\) 的连通性无关了,最小割的限制会失效。
Vote 善意的投票
- 另一道经典题目。我们对于割边的理解不仅仅是消除冲突,如果冲突在答案中本来就存在,强制割边时就是计算了保留冲突的代价。
- 想睡觉和不想睡的分两个集合,一个连 \(S\) 一个连 \(T\),边权为 \(1\)(即改变所属集合的代价)。注意在这类可以改变所属集合的题目中,一个点最后和 \(S\) 还是 \(T\) 相连才决定了它到底属于哪个集合。然后题目中存在的朋友关系 双向连边,因为要保证图的连通性,而且双向边只会割那条方向正确的边,另外一条反向边不影响最小割。割掉朋友之间的边代表这俩的确有矛盾,需要统计入答案。
- 同类题目:bzoj26304 最优标号。如果出现没有给定属于哪个集合的点,直接不划分集合,只连点之间的关系边,最后跑完最小割和哪个集合相连就是分配到了哪个集合。
bzoj26303 网络战争
-
处理平均数常见伎俩:二分答案,把边权都减去 \(mid\),看最后最小割是否 \(\le 0\)。
-
当然为了避免负容量,最开始就可以把负边都选了,这也是为什么题目对割边的定义和最小割不同。
-
非常易错的点:题目如果暗示或者干脆明指要建双向边就建,因为在 最小割 中双向边只有符合 \(S\) 到 \(T\) 的边的那一条才会生效,,但如果只建一条就可能方向不对(其实和上面那道题是同理的)。注意数组也要同时开大。
五、费用流
(一)定义
-
单位流量费用:每条边除了有流量限制,还有单位流量费用 \(w(u,v)\),表示一单位流量流过这条边的代价。单位流量费用满足 \(w(u,v) = -w(v,u)\)。
-
费用流:即在 最大流的前提下,求出网络最小 / 最大的费用。
(二)如何求
接下来的部分都是在最小费用的语境下,最大费用只需要转换为跑最长路即可。
-
最大流的数值对应增广路有几条,(假设每次增广只更新 \(1\) 的流量)。既然无论如何都要找这么多条增广路,如果每次找费用最小的那条更新,那么找完这么多条增广路,费用就一定是最小的。所以我们每次找增广路时在流量费用的最短路图上找就可以了。最后找一次更新的费用即 \(最短路长度 \times 更新流量\)。
-
处理增广路有两种方法,EK 和 dinic。EK 适用于图较稀疏,增广路较少的情况,处理方式一样是记录前缀,不赘述。Dinic 则相对比较高效,仍然可以一次处理多条增广路,但是也有注意事项。
-
注意 dinic 没有在分层图上跑,最短路图上是有环的,所以每个点访问到需要打 vis 标,也用不了当前弧等优化。但是考虑如果图很稠密,每个点的最短路有很多条,但是需要更新的增广路只有一条,那么 dinic 将遍历所有的路径,复杂度将大大上升。点名表扬订货,卡掉了这种错误的做法。
-
所以我们只能牺牲一部分找到多条增广路的机会,换取算法的稳定性,即添加当前弧优化,使一次找增广路的复杂度稳定到 \(O(nm)\)。注意到图中存在环,一个点走同一条边的增广路可能有多条,但当前弧优化限制了一个点的一条出边只会找一次,所以效率会下降。当然可以保证一定会找到至少一条增广路。
-
Code
queue<int> q; int dis[MAXN], now[MAXN]; bool vis[MAXN]; int bfs() { for (int i = 1; i <= pt; i++) { dis[i] = MAX; now[i] = head[i]; } dis[s] = 0; q.push(s); while (q.size()) { int x = q.front(); q.pop(); vis[x] = 0; for (int i = head[x]; i; i = nxt[i]) { if (re[i] && dis[x] + co[i] < dis[to[i]]) { dis[to[i]] = dis[x] + co[i]; if (!vis[to[i]]) { vis[to[i]] = 1; q.push(to[i]); } } } } return dis[t]; } int dinic(int x, int flow) { if (x == t) return flow; vis[x] = 1; int i, fl, rest = flow; for (i = now[x]; i && rest; i = nxt[i]) { now[x] = i; if (!re[i] || vis[to[i]]) continue; if (dis[x] + co[i] == dis[to[i]]) { fl = dinic(to[i], min(rest, re[i])); rest -= fl; re[i] -= fl; re[i^1] += fl; } } vis[x] = 0; return flow - rest; } void mincost() { int len, flow, ans = 0; while (1) { len = bfs(); if (len == MAX) break; flow = dinic(s, MAX); ans += len * flow; } printf("%d", ans); }
(三)消圈
-
费用流的实现涉及最短路,如果流量费用图上有负环就寄了,需要消圈。在洛谷看到了一种比较好实现的消圈方法,即负边强制满流,再建一条权值和方向都反的边来退流(这样最短路跑到的都是正权边了),跑有源汇上下界最大流最小费用流。板题:【模板】有负圈的费用流。非常建议做一下,综合性极强的一道题,可以检验你的网络流基础水平。
-
另一道和负环有关的题:Evacuation Plan。这道题是在按照题目方案建网络流后再找负环,如果出现负环意味着只要正向边流量 +1,反向边流量 -1,就可以保证流量守恒的同时(因为这个图中的环正向边有多少反向边就有多少)构造一个费用更小的流(使负环流量+1,费用更小)。
-
最核心的其实是找负环的方法:首先 SPFA 跑最短路记录前驱,找到第一个访问次数大于 \(n\) 的点,然后遍历前缀直到出现了一个点 \(x\) 的前缀 \(pre_x\) 已经访问过,从 \(x\) 开始找环。
for (int i = 1; i <= n; i++) fl[i] = 0; // fl 是标记数组
fl[now] = 1; // 第一个访问次数大于 n 的点
while (!fl[pre[now]]) {
now = pre[now];
fl[now] = 1;
}
cir[len=1] = now;
while (pre[now] != cir[1]) {
now = pre[now];
cir[++len] = now;
}
(四)例题
- 注意任何费用流都是在最大流背景下的,所以必须先构造最大流,再给边加权值。
餐巾计划问题
- 注意先保证最大流,每个代表消耗餐巾的点的流入和流出必须是当天需要的餐巾数。而且为了保证最大流就不能让代表消耗餐巾的点之间互相流。而是要新建一个代表当天用完的餐巾的点,从这个点来连接后面代表消耗餐巾的点。
学习小组
- 建图技巧:当出现与流的大小正相关但不是线性的加权,比如 \(人数的平方a^2 \times 奖励值c\),考虑将每种人数拆开看,按人数 \(i\) 从小到大建一些流量为 \(1\),权值为 \((i^2 - (i-1)^2) \times c\) 的边,使 \(a\) 个流的加和为 \(a^2c\)。
修车
- 建图技巧:考虑将一个技术人员的每个顾客按照来的顺序分析,每个顾客作为倒数第 \(i\) 个人来的,那么他对的等待时间的贡献就是 \(i \times 他的修车时间\)。这是前缀和类加权(每个人的权值是前面所有人加上自己的前缀和)的处理方法。
连连看
- 因为在题目的数据范围中这是个二分图,懒得 BFS 区分左右部,直接一个点 \(i\) 拆成两个点 \(i_1、i_2\),\(i_1\)连 \(S\),\(i_2\) 连 \(T\),如果存在 \((x,y)\) 满足条件,连 \((x_1,y_2)\)、\((x_2,y_1)\),边权是 \(x+y\)。这样跑出来最大流和最大费用刚好是两倍,除以二即可。感性理解,根据对称性,如果 \((x_1,y_2)\) 被选了,那么 \((x_2,y_1)\) 也会被选。
六、上下界网络流
即网络中边的流量有上下界的流。
(一)无源汇可行流
令每条边的流量上界为 \(up\)、下界为 \(low\)。
-
先令每条边的流量 \(f(u,v) = low(u,v)\),然后尝试通过调整使得流守恒。计算此时每个点的流入流量 \(in_i\) 和流出流量 \(out_i\)。
-
再用每条边的残量 \(up - low\) 建立残量网络,用于流量调整。给该图添加一个超级源点 \(S\) 和一个超级汇点 \(T\)。
-
最重要的部分:为了满足流守恒,对于 \(in_i < out_i\) 的点,在残量网络中的流入流量 \(in'_i\) 和 \(out'_i\) 必须满足 \(out_i - in_i = in'_i - out'_i\);对于 \(in_i > out_i\) 的点,必须满足 \(in_i - out_i = out'_i - in'_i\)。利用最大流的性质,对于 \(in_i < out_i\) 的点,从 \(S\) 点向 \(i\) 点连一条容量 \(out_i - in_i\) 的边,因为如果要得到最大流必须使得该边满流,所以除去这条边的入流量后,残量网络中 \(i\) 点便满足了 \(in'_i - out'_i = out_i - in_i\)。同理,对于 \(in_i > out_i\) 的点,从 \(i\) 点向 \(T\) 点连一条容量 \(in_i - out_i\) 的边即可使得 \(out'_i - in'_i = in_i - out_i\)。
-
最后,每条边的流量即为 \(low(u,v) + 残量网络中的流f(u,v)\)。若 \(S\) 的出边(或 \(T\) 的入边)没有全部满流,则说明无法保证流守恒,不存在可行流。
(二)有源汇可行流
-
若原网络的源点为 \(s\)、汇点为 \(t\),则建一条 \(t \to s\),流量上下界为 \([0,\text{INF}]\) 的边,使其转化为无源汇可行流。
-
该网络的流量即为这条 \(t \to s\) 的边的流量。
(三)有源汇最大流
- 在获得一个可行流之后,删掉超级源汇及相关边(其实不需要删,因为与 \(S\) 和 \(T\) 相连的边都是满流的),删掉 \(t \to s\) 的边。然后在已经得到可行流的残量网络上直接跑最大流。
- 最后的答案即为 \(可行流的流量 + 跑最大流时 s 多流出的流量\)。
(四)有源汇最小流
- 在获得一个可行流之后,删掉超级源汇及相关边,删掉 \(t \to s\) 的边。修改网络的源点为 \(t\),汇点为 \(s\),然后跑最大流。
- 最后的答案即为 \(可行流的流量 - 跑最大流时 t(新源点) 多流出的流量\)。这其实就是在退流,保证流守恒的前提下使得尽量多的流量流回去。
(五)有源汇上下界最大流最小费用流
- 和跑有源汇最大流的方法完全一样,在两次跑最大流算法时都添加统计费用的部分即可。注意辅助的边费用为 \(0\)。以及需要单独统计每条边的基础费用 \(low \times cost\)。
模板
- 加边
int head[MAXN], nxt[MAXM], to[MAXM], re[MAXM], ecnt;
int in[MAXN], out[MAXN];
void adde(int x, int y, int cap) {
nxt[++ecnt] = head[x]; head[x] = ecnt; to[ecnt] = y; re[ecnt] = cap;
nxt[++ecnt] = head[y]; head[y] = ecnt; to[ecnt] = x; re[ecnt] = 0;
}
void add(int x, int y, int low, int up) {
in[y] += low, out[x] += low;
adde(x, y, up - low);
}
- 建图
void build() {
for (int i = 1; i <= pt; i++) {
if (in[i] > out[i]) adde(s, i, in[i] - out[i]);
if (in[i] < out[i]) adde(i, t, out[i] - in[i]);
}
}
例题
bzoj48760 植树
- 对于矩阵中每行和每列加和的限制条件,我们给每行的和建一个虚点 \(h_i\),每列的和建一个虚点 \(l_i\)。\(S\) 点连 \(h_i\),\(l_i\) 连 \(T\)。每个点 \((i,j)\) 转换为 \(h_i \to l_j\) 的边,边的流量即为这个格子上分配的数。要限制 \(h_i\),则限制 \(S \to h_i\) 的流量上下界;要限制 \(l_i\),则限制 \(l_i \to T\) 的流量上下界。
七、经典网络流题目 & 模型
分层图
核心思想接近于 \(DP\)。如果某个要素会因移动等操作而改变,而且会影响图的连通性,那么就需要像 \(DP\) 那样单独拿出一维代表要素为某个值时的网络,当要素改变时就在不同层数之间转移。
-
汽车加油行驶问题:以剩余汽油量分层。每次移动除了位置的移动,因为油量的改变还需要向油量更少的一层移动。加油就是位置不变,只改变了油量,即改变层数。
-
孤岛营救问题/拯救大兵瑞恩:以拥有的钥匙分层,拿到钥匙的操作改变所在层。每层则按照拥有的钥匙建图。
-
星际转移问题:以时间分层,可以方便地在每个时间点所代表的层中得到太空船的位置,然后转移到下一层。
DAG的最小路径覆盖
- 例题:最小路径覆盖。
- 求法:每个点 \(i\) 拆成两个点,\(i_1、i_2\),\(S\)连 \(i_1\),\(i_2\) 连 \(T\),如果有一条边 \((x,y)\) ,连 \((x_1,y_2)\)、\((x_2,y_1)\)。所有边的容量为 \(1\)。然后求最大流。最开始有 \(n\) 个点,想象成 \(n\) 条路径。每条左部到右部的边被选了,都意味着一条路径的末尾接上了另一条路径,路径数减去了 \(1\),所以最小路径覆盖就是 \(n - 最大流\)。
- Ex:如果每个点可以被多条路径覆盖,用 Floyd 求出原图的传递闭包(即维护点间是否可达),加边时把满足 \(u\) 可以到 \(v\) 的所有 \((u,v)\) 都加上,然后转化为最小不相交路径覆盖。
- ExEx:如果不是 DAG,但是保证有向,你需要缩点之后再来跑。2024/10/5,G - Only One Product Name。
- ExExEx:如果是无向图跑最小不可重路径覆盖的话,你就需要交叉连边,\(in_1 \to out_2, in_2 \to out_1\)。由于对称性,这两条边肯定同时被选,而且也保证了一次同时选择两个方向的边。
最大权闭合子图
-
闭合子图:有向图的一个子图,满足它包含的所有点的出边指向的点都是点集内的点。最大权:即子图的所有点的点权和最大。
-
例题:太空飞行计划。容易看出每个实验的权值是正的,每个器材的权值是负的,实验向器材连边。如何求这个有向图的最大权闭合子图?
-
构造一个最小割。 \(S\) 向实验连边,权值为实验收益;器材向 \(T\) 连边,权值为器材价格。实验与器材之间连 \(\text{INF}\) 边。这样,如果 \(S\) 向实验连的边被割了,说明不做这个实验(它需要的器材就不会因为这个实验被割掉)。如果器材向 \(T\) 连的边被割掉,说明需要这个器材(因为实验边没有被割,为了保证割成两部分所有的器材都是被计算的)。注意最后答案为 \(实验价值总和 - 最小割\)。
-
如果输出方案,可以拿 BFS 得到的 \(dis\) 数组判断可达性(即是否属于 \(S\) 集合)
最长 k 可重区间集
-
例题:最长 k 可重区间集。
-
先离散化端点。问题可以转化为找 \(k\) 组组内不相互重复的线段,尝试在数轴上解决这个问题。对于数轴上的点 \(x\) 向 \(x+1\) 连接一条费用为 \(0\),流量为 \(\text{INF}\) 的边。对于线段,则从 \(l\) 向 \(r\) 连一条费用为长度,流量为 \(1\) 的边。然后找 \(k\) 条流量为 \(1\) 的增广路即可。
-
经本人测试,这种处理方式可以抽象为选择完序列中的一个东西之后,就跳到绝对不会产生矛盾的下一处位置继续选,然后流的大小就是可以允许矛盾的个数。可以用于解决例如bzoj182 修剪草坪之类的根本看不出网络流的题目。
基环树
先写基环树,因为基环树更可爱毒瘤一点。
*注:由于时间问题并没能把二次扫描与换根写完。现在回忆起当时看着一堆难以下手的题目的感觉还是非常的有意思,每天都是(因为菜)有一堆做不完的题,但是每天也都过得很简单充实。不知道这些题现在上手又感觉如何。
(一)定义
- 有 \(n\) 个结点,\(n\) 条边的图。可以理解为一棵树上多出一条边,形成了一个环。
- 基环外向树:一张有向弱连通图,满足每个点的入度都为 \(1\)。
- 基环内向树:一张有向弱连通图,满足每个点的出度都为 \(1\)。
重中之重:在搞基环树题前,必须读懂题目,区分清楚是内向树还是外向树,想清楚处理的时候需不需要内向建成外向,或外向建成内向。
(二)找环
对于无向图
- 并查集+BFS法:在输入每条边时检查两个端点是否在同一集合,如果是则记录该环边 \((u,v)\),且不加入原图。输入完毕后从 \(u\) 开始 DFS 找 \(u \to v\) 的路径,加上记录的环边就是一整个环。(记得在找完环之后考虑要不要把这条边加回去)
对于有向图
- 利用拓扑排序找环。
(三)题目总结
总的来说,解决基环树可以分为三部分,处理树上部分,处理环上部分,处理树和环的衔接,都各有难点和坑点。
骑士
- 技巧:破环为树。一般这种题目的连边表示的都是相斥的关系,所以可以人为强制两个相斥点自己必选,强制被选的点的入/出度必然就需要特判不选,这条入/出边也就没用了,删去之后就是一棵树。
- 技巧:翻转边的方向:如果题目限制和边的方向无关,或方向变化后可以转换,但是按照题目描述建出来不好处理(比如是个内向树,不好DFS),就可以翻转边的方向使其变成外向树。注意实现的细节可能相应改变,同时如果涉及输出图上的路径仍然需要翻回去。
- 坑:大多数基环树题都是基环树森林,不连通,需要处理多个连通块。也要注意处理多个连通块时清空数组。
[IOI 2008] Island
- 题意:求一堆基环树的直径和
- 方法:典型的将路径分成两种,经过环和不经过。后者可以树形DP,前者则需要环上DP。对于环的处理,可以破环为链(用得比较少但是有时很有用的方法),在链上处理成前缀类问题。也可以断一条边,然后分讨答案是否经过断边。这道题推荐破环为链。
- Roads in the Kingdom:这道题思路几乎一样,但是我用第二种方法分讨做很难受,现在发现第一种办法水得多QAQ。
HDU6403 卡片游戏
真的很难看出基环树,看出来了也很难。
- 思路:注意到一个数字只能出现一次,那么为了限制数字出现一次,可以想到限制数字对应节点的出度只有一个,那么牌就可以理解为一条连接两个数字的有向边,翻转就是反转边的方向。
- 然后分讨边数和点数的关系。如果 边数大于点数,那么必然无法分配。如果边数等于点数,那么基环树。如果边数等于点数减一,就是树。不存在更小的情况。
多角恋
- 坑:树上匹配特判细节很多,比如自环和已经符合题目条件的二元环。像这部分的答案保险起见可以写的时候就有的没有都特判掉,不然很可能出锅。
- 坑:这道题从入度为 \(0\) 的点开始爬基环树处理,然后爬到环上也一并处理了。但是注意本来就是环的连通块会遍历不到,因为没有入度为 \(0\) 的点。
枪战Maf
- 坑:注意处理完一个环需要标记环上节点为已处理,否则会反复遍历一个环多次。
bzoj48675 连连看
-
弥天大坑:注意到对环处理的时候枚举第一个连接的点然后 \(O(n)\) 扫一圈是很暴力的,环上的处理复杂度不对一不小心就会被卡。
-
弥天大坑:但是想到优化可以连一个点再往前跳到下一个需要连的点。这里又有坑,跳环的时候建议单独写个函数,特判是否经过我们断开的那条边,以及考虑有没有跳过了起点。
-
坑:数组不要搞混:遍历环的节点时,访问
DP一类的数组可以直接DP[i],但是访问环上节点的距离等值,不可以用dis[i],而是要用存的节点dis[s[i]]。还有许多同义数组,也很容易搞混。这道题中,容易把前向星的nxt数组和表示环上每个点的下一个距离大于 \(k\) 的点的数组nxt搞混。
Hobson 的火车
- 技巧:这道题既可以环上差分数据,也可以简单粗暴直接保存满足条件的点,用一些数据结构进行维护。在环上一起点处暴力 \(O(n)\) 存下满足条件的点,然后模拟在环上转移的过程,往后退的时候同时删去距离为 \(k\) 的点,再加入这棵子树下的满足条件的点。
- 坑:注意DP的时候需要二次扫描就不要一次算完,记得考虑递归需要的数据真的回溯算完了吗。
(四)其他
题外话:调了一堆基环树之后我深刻意识到这玩意就考一个细心,一定要快准狠,越是纠缠越是难搞。还有就是不要在精神不佳的情况下做基环树题,否则只会让精神更不佳。
- 调题:不要调题,最好一次性写对。调不出来可以每个点都插入输出中间变量,一定要耐得住性子定位出错位置。一定要善用对拍,肉眼调很难受。实在不行可以等一段时间再从头到尾分析一遍代码。写得时候也要保证可读性。
- 存图:不建议 vector,因为对于一般 \(O(n)\) 的基环树题往往常数较大。
- DP式子等先在草稿纸上清清楚楚地写出来,转移的三部分情况考虑清楚,一方面是检查思路有没有假,另一方面也是方便接下来敲代码。
Author:londono
共 32200 字。
2022-08-18 完成。
2025-05-15~17 整理收录。
浙公网安备 33010602011771号