第一次在 OJ 上看到 HDU 3038 这道题,题目其实很短:给你一个长度为 n 的数列,再给你 m 条陈述,每条陈述说"区间 [a, b] 的和是 s",让你数一数有多少条陈述和之前已经给出的信息是矛盾的。我原以为这就是个前缀和的水题,结果做完才发现,这题考的是带权并查集里最经典的思想——用集合来维护变量之间的差值关系,而且坑还不少。这篇就来把题目拆透,从原理推导到代码实现,再到调试过程中的踩坑记录,一次性说清楚。适合正在学并查集扩展用法的人,也适合准备把这题当作带权并查集入门题的选手参考。
1. 题目到底在说什么:把区间和翻译成并查集语言
1.1 核心矛盾:区间和如何变成两个点之间的关系
很多人第一次看到这题会往线段树、树状数组的方向想,但仔细看题目要求就明白了:每条陈述都是"区间 [a, b] 的和等于 s",而且需要判断这条陈述和前面已经出现过的陈述是否矛盾。换句话说,这是一堆"关系"之间的冲突检测,而不是在线维护区间和。
突破口是前缀和。设 pre[i] 表示数列前 i 个元素的和,那么区间 [a, b] 的和可以写成:
pre[b] - pre[a-1] = s
这样一来,每条陈述实际上是在说:pre[b] 和 pre[a-1] 这两个前缀和之间的差值是 s。这就变成了"两个变量之间的差值关系",和并查集天然契合。我举个例子你马上就懂:如果已知 pre[5] - pre[2] = 10,又知道 pre[2] - pre[0] = 3,那么 pre[5] - pre[0] 必然等于 13。如果来了条新陈述说 pre[5] - pre[0] = 14,那它和之前的信息就矛盾了。
所以这题的核心就是:用并查集维护一组"知道相对差值的变量",每次来一条新关系,先看两个变量是否已经在同一个集合里。如果在,就校验差值是否一致;如果不在,就把两个集合合并,并推导出新集合之间的差值关系。
1.2 为什么普通并查集不够用,带权到底带的是什么权
普通并查集只做两件事:合并两个集合、查询两个元素是否在同一个集合。它回答的是"这两个点是否连通",但不回答"这两个点之间差多少"。在区间和这题里,光知道 pre[5] 和 pre[2] 在同一个集合里没有任何用,必须知道它们俩的具体差值,才能判断新陈述是否矛盾。
带权并查集的思路是:给每个节点到父节点的边上挂一个权值,表示"子节点代表的变量 减去 父节点代表的变量"等于多少。路径压缩的时候,权值也随着父节点的更新而累加,最终每个节点都记录了自己到根节点的差值。
最直观的理解方式是把根节点当成一个"参考基准点"。val[x] 就表示"x 相对于根的偏差量",合并两个集合的时候,本质上是在计算两个根节点之间的偏差量。这样任意两个在同一集合内的点,它们的差值就等于 val[a] - val[b]。
我当初学到这里卡了很久,后来用生活中的例子才想通:普通并查集是"知道两个人认不认识",带权并查集是"知道两个人年龄相差几岁"。只要集合里每个人都记着自己和"群主"的年龄差,任何两个人之间的年龄差都能算出来。区间和这题里的"年龄差"就是前缀和的差值。
2. 带权并查集的核心原理:权值定义与合并公式推导
2.1 权值定义与 find 路径压缩过程
先定义数据结构:
fa[x] 表示 x 的父节点,val[x] 表示"x 到 fa[x] 的差值",具体含义是:
val[x] = pre[x] - pre[fa[x]]
注意,我这里的方向是"子节点减父节点",这个方向约定会直接影响后面的合并公式。写代码之前最好在纸上明确写下来自己用的是哪种方向,不然索引很容易写反。
find 函数要做两件事:找到根节点,同时把路径上所有节点的 val 累加起来,让 val[x] 最终表示"x 到根节点的差值"。递归写法非常清晰:
int find(int x) { if (x != fa[x]) { int t = fa[x]; // 先保存旧父节点 fa[x] = find(fa[x]); // 递归找根并压缩 val[x] += val[t]; // val[t] 此时已经是 t 到根的差值 } return fa[x]; }这里最关键的细节是:必须先保存 t = fa[x],再去递归调用 find,最后 val[x] += val[t]。如果直接写 val[x] += val[fa[x]],那 val[fa[x]] 可能已经被路径压缩更新成了"旧父节点到根的差值",虽然在很多情况下结果碰巧正确,但提前保存旧父节点才是最稳妥的写法。
路径压缩之后,每个节点直接指向根,val[x] 就成了"x 到根节点的差值"。以后任何两个节点之间的差值都可以用 val[a] - val[b] 直接计算。
2.2 union 合并时权值公式的完整推导
合并操作是这题最容易出错的地方,公式看着简单,但方向稍微写反就是 WA。先说结论,假设输入是区间 [a, b] 和为 s,我们令 a 自减一,得到真正的两个前缀和下标,然后:
int ra = find(a), rb = find(b); if (ra == rb) { if (val[b] - val[a] != s) ans++; } else { fa[ra] = rb; val[ra] = val[b] - val[a] - s; }很多人不理解 val[ra] 为什么是 val[b] - val[a] - s,我来完整推导一遍。
现在已知:
val[a] = pre[a] - pre[ra],也就是 a 到 ra 的差值 val[b] = pre[b] - pre[rb],也就是 b 到 rb 的差值 sum(a+1, b) = pre[b] - pre[a] = s
注意这里的 a 已经自减过了,所以 pre[b] - pre[a] 就是原题中区间 [a+1, b] 的和,也就是输入的 s。这有点绕,但代码里 val[b] - val[a] 必须是 pre[b] - pre[a]。
假设我们让 ra 的父节点指向 rb,那么接下来就要确定 val[ra]。合并完成后,a 到新的根 rb 的差值必须等于原本的 val[a] 加上 val[ra],即:
pre[a] - pre[rb] = val[a] + val[ra]
同时:
pre[b] - pre[rb] = val[b]
用第二条式子减去第一条式子:
(pre[b] - pre[rb]) - (pre[a] - pre[rb]) = val[b] - (val[a] + val[ra])
左边正好是 pre[b] - pre[a] = s,所以:
s = val[b] - val[a] - val[ra]
移项就得到:
val[ra] = val[b] - val[a] - s
这就是代码里那一行的来源。我建议你拿着纸笔把这个推导过程自己走一遍,因为网上很多博客直接把公式甩出来,而实际做题时只要稍微改一下题目背景,比如改成"吃与被吃""奇偶性",合并公式就会变成另一个样子,死记硬背是行不通的。
3. 完整实现与调试笔记:从模板到 AC 的最后一公里
3.1 标准模板与多组输入处理
HDU 3038 是多组输入,而且没有给定数据组数,所以要写到 EOF。完整代码如下:
#include <cstdio> #include <cstring> const int MAXN = 200010; int fa[MAXN], val[MAXN]; void init(int n) { for (int i = 0; i <= n; i++) { fa[i] = i; val[i] = 0; } } int find(int x) { if (x != fa[x]) { int t = fa[x]; fa[x] = find(fa[x]); val[x] += val[t]; } return fa[x]; } int main() { int n, m; while (scanf("%d%d", &n, &m) != EOF) { init(n); int ans = 0; for (int i = 0; i < m; i++) { int a, b, s; scanf("%d%d%d", &a, &b, &s); a--; // 把区间 [a+1, b] 转换为前缀和下标 a, b int ra = find(a), rb = find(b); if (ra == rb) { if (val[b] - val[a] != s) ans++; } else { fa[ra] = rb; val[ra] = val[b] - val[a] - s; } } printf("%d\n", ans); } return 0; }这里有一个必须注意的初始化细节:fa 和 val 数组要从 0 初始化到 n,而不是从 1 开始。因为 a 自减之后可能变成 0,pre[0] 是前缀和的起点,必须作为一个合法的节点存在。我最早写的时候只初始化了 1 到 n,结果跑样例没问题,一交上去就随机出错,查了半天才发现是 0 号节点的 fa 还是 0,val 还是 0,导致 pre[0] 参与了合并后出现了未定义行为。
3.2 我在实际提交中踩过的三个坑
第一个坑是权值方向写反。我第一次写 find 的时候用的是 fa[x] = pre[fa[x]] - pre[x] 的方向,也就是"父节点减子节点",结果合并公式也跟着写反,样例倒是过了,但一到大数据就 WA。后来我把两套方向在每个函数里都固定成"子节点减父节点",然后重新推导合并公式,才彻底解决。这个坑很难靠肉眼发现,最有效的办法是构造一组可以手算的数据,比如只有三条陈述的小样例,逐步验证 val 是否等于 pre[当前] - pre[根]。
第二个坑是区间自减之后语义混淆。输入给的是 [a, b],我们实际要处理的是 pre[b] - pre[a-1]。代码里用 a-- 之后,原来的 a 就变成了 a-1,b 不变。如果看代码不仔细,很容易在合并校验时写出 val[b] - val[a+1] 这种错误表达式。我的建议是不要用 a-- 这种简写,而是单独开一个变量 x = a - 1,这样代码读起来更直白,虽然会多一点代码量,但调试的时候能少掉很多头发。
第三个坑是输入输出效率。虽然这题 n 和 m 都不算特别大,但 OJ 上的数据量往往是上限值,用 cin/cout 默认同步流有时候会勉强卡过,有时候就超时。我习惯性在所有涉及多组大输入量的题目里直接用 scanf/printf,省得在性能边缘试探。如果你确实喜欢用 cin/cout,记得加 ios::sync_with_stdio(false) 和 cin.tie(nullptr)。
4. 经典问题排查:这道题最容易错的地方
4.1 常见错误与排查速查表
我把带权并查集做题时最容易翻车的几个场景整理成了一张表,每个都是实际验证过的:
| 症状 | 根本原因 | 解决方式 |
|---|---|---|
| 小样例通过,大数据 WA | 初始化只处理了 1..n,0 号节点未初始化 | 从 0 到 n 全部 init |
| 答案系统性偏大或偏小 | union 合并公式方向错误 | 重新按"子节点减父节点"推导,并且手推一条验证链 |
| 偶现错误,时好时坏 | find 路径压缩时没有保存旧父节点 | 用 int t = fa[x] 缓存后再递归 |
| 区间判断永远正确 | a-- 写错,导致 pre[b] - pre[a] 实际算的是别的区间 | 用独立变量保存 a-1,并验证语句中的下标语义 |
| 编译报错或数组越界 | val 数组大小只开了 n,却访问了下标 n | 数组开到 MAXN,比如 n+5 以上 |
| 多组样例之间互相污染 | fa/val 没有重置 | 每组数据前调用 init(n),并且 val 清零 |
这张表里的前两行是我自己踩过的,第三行是某位同学在交流群里贴代码时偶然发现的问题。反正带权并查集这种题,一旦出现了"样例过、提交挂"的经典情况,先别怀疑算法思路,优先检查权值方向和维护的变量含义,十有八九问题出在这些细节上。
4.2 如何用手算小数据验证正确性
写完了代码不要急着交,自己构造一个可控的小数据来验证。比如 n = 10,输入以下四条陈述:
1 10 100 1 4 30 5 10 60 1 3 20
先手推一下:第一条和第二条没有矛盾。第三条是在说 pre[10] - pre[4] = 60,但根据第一条 pre[10] - pre[0] = 100,第二条 pre[4] - pre[0] = 30,相减得到 pre[10] - pre[4] = 70,和 60 不相等,所以第三条矛盾。第四条 pre[3] - pre[0] = 20,目前没有信息能推出 pre[3] 的值,所以不矛盾。最终答案应该是 1。
把这段数据喂给程序,如果输出 1,说明基本逻辑是对的。如果输出 0 或者 2,就可以在调试过程中把每次 find 之后的 val 值打印出来,对照 pre 的定义手算一遍。我在调试的时候会把 val[a]、val[b]、ra、rb 全部打印,用这种小数据很快就能找到是哪个节点的权值更新错了。
5. 从这题延伸开去:带权并查集的通用套路
5.1 当权值不再是普通整数:模数关系与负数处理
HDU 3038 的权值就是普通整数,不用取模,直接相加就好。但很多类似的题目会在权值上加上模数关系,最著名的就是经典的三态关系题:A 吃 B、B 吃 C、C 吃 A。这类题里,权值通常定义成 0、1、2 三类(比如 0 表示同类,1 表示吃,2 表示被吃),合并的时候所有权值更新都需要对 3 取模。
一旦涉及取模,负数的处理就成了新坑。在 C++ 里,负数取模的结果仍然是负数,比如 -1 % 3 = -1,这会造成后续比较出错。所以遇到取模场景,公式必须要写成:
val[ra] = (val[b] - val[a] - s + MOD) % MOD
也就是先把权值调整到非负,再取模。这个做法不仅仅适用于三态关系题,奇偶性校验的题目(通常维护 0/1 两类状态,用 %2)也是一样的套路。我在做这类拓展题时,会把合并公式推导第一步写在注释里,然后对照注释去写代码,能有效避免"公式背错"的惨案。
5.2 做题时的通用思考步骤:一法通,法法通
带权并查集题目的解题套路其实高度统一。第一步,把题目给的约束转化为"两个变量之间的差值关系",比如区间和转前缀和差值,三态关系转模 3 的向量偏移。第二步,明确权值的定义方向,我建议一律使用"子节点到父节点的偏移量",并写清楚这个偏移量的物理意义。第三步,手推合并公式,也就是当前已知 val[a]、val[b],要把 ra 接到 rb 上,推导出 val[ra] 的表达式。第四步,根据题目要求判断是否需要取模、是否有边界条件(比如下标从 0 开始)。
这套流程走一遍之后,你会发现带权并查集本质上就是在维护一个"向量空间":每个节点到根的向量已知,任意两个节点之间的向量就能计算;合并集合时,本质上是把两个根之间的向量补齐。这也是为什么我带学生刷题时反复强调:不要背 val[ra] = val[b] - val[a] - s 这个具体公式,因为它会随着权值方向、题目定义的改变而改变,但"画一棵树,沿着边推向量"的思路永远不会变。
我个人在实际操作中的体会是,这类题把推导过程写下来比直接写代码更重要。我第一次做的时候也走了弯路,总觉得公式记住了就能 AC,结果一变体就翻车。后来每次遇到带权并查集,我都会在草稿纸上画树、写推导,哪怕多花两分钟,代码实现反而会快很多。最后再分享一个小技巧:如果在 OJ 上调试这类多组输入的题目,调试输出会刷屏,建议在本地测试时用一个小数据文件,并且把所有调试信息重定向到文件里,确认无误后再删掉调试代码提交,省得被输出干扰自己判断。