最近做算法题又碰到一类老朋友,题目描述换了好几种皮,但内核永远是一个套路:用并查集维护区间染色。比如有 N 个位置排成一行,M 次操作,每次把 [l, r] 这段全部涂成某种颜色,最后问每个位置是什么颜色。这类题在竞赛题和面试手写题里都挺常见,因为它考察的不是什么偏门数据结构,而是两个很核心的算法思想:倒序处理和路径压缩。
如果你理解了这个套路,区间染色问题就不再需要纠结线段树还是树状数组,代码量短、常数小、思维量也不大,非常适合当成“模板级算法”储备起来。这篇文章我把这类题目从暴力到并查集优化完整拆一遍,包含我平时写代码时踩过的坑和调试对拍的经验,适合刚接触并查集的同学,也适合已经会用但想系统梳理一下的选手。
1. 这类题目长什么样:从暴力到并查集的思路转折
1.1 典型题面与输入输出
先给一个最经典的问题原型。假设有 n 个瓶子排成一排,初始都没有颜色。现在有 m 次操作,第 i 次操作把区间 [l_i, r_i] 内的所有瓶子涂成颜色 c_i,后涂的颜色会覆盖先涂的颜色。问全部操作结束后,每个瓶子最终是什么颜色。
约束条件往往是 n 和 m 都到 1e5 甚至 1e6。这时候如果你直接按题意正向模拟,每次暴力循环去涂区间,总复杂度是 O(n*m),在 1e5 的数据范围下直接爆炸。所以必须有更聪明的做法。
这类题有个非常关键的观察:一个位置的颜色,只取决于“最后一次覆盖到它的那次操作”,之前的操作对它没有任何影响。也就是说,如果我们把操作倒着往前看,第一次遇到某个位置时,这个位置的颜色就已经确定了,之后不需要再管它。
1.2 暴力模拟为什么不行
很多人第一反应是开一个数组 color[1..n],然后每次操作从 l 到 r 循环赋值。这个思路本身没有错,问题出在复杂度上。最坏情况下,m 次操作都覆盖整个区间,每次操作要循环 n 个位置,总共是 n*m 量级的操作,n=1e5、m=1e5 的时候就是 1e10,显然不可能在时限内跑完。
另一方面,你会发现很多位置被反复染色了很多次,但只有最后一次染色是有效的。理论上,如果每个位置只处理一次,总体复杂度最多就是 O(n + m)。问题在于正向模拟时很难做到“只处理一次”,因为你不知道当前这个位置在后续操作里是否还会被覆盖。
这就自然引出了核心思路:能不能让每个已经被“定稿”的位置快速消失,让后面的操作不再遍历到它? 这正是并查集能发挥作用的地方。
1.3 逆向思维:从最后一次操作往前推
倒序遍历操作的逻辑很简单:第 m 次操作是最后一次操作,所以它涂到的位置一定是最終颜色,可以直接确定;第 m-1 次操作是倒数第二次,它涂到的位置中,那些没有被第 m 次操作覆盖过的位置,就可以确定成第 m-1 次操作的颜色,依次类推。
但问题来了:倒序处理时,怎么快速知道第 i 次操作的区间里有哪些位置“还没被后来的操作覆盖”?朴素做法还是遍历区间,复杂度仍然没有改善。真正的关键是把已经确定颜色的位置“删掉”,下次不再访问。
这里用并查集维护的就是“下一个还没被定稿的位置”。每个点一旦被确定颜色,就把它合并到右侧最近未确定点的位置。find(x) 查询的是从 x 开始的第一个未着色点,整个算法就变得非常简洁。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 并查集在这个问题里到底在维护什么
2.1 数组含义:每个下标存“下一个没有被染色的人”
这是我刚开始学时最容易懵的地方。普通的并查集是维护集合关系,比如判断两个元素是否在同一个集合、合并两个集合;而区间染色问题里的并查集,每个 fa[x] 表达的是:如果 x 已经被染色了,那么 fa[x] 指向 x 右侧第一个还没被染色的下标;如果 x 本身还没被染色,那么 fa[x] = x。
初始化时所有位置都没染色,所以 fa[i] = i。当染色到位置 pos 后,执行 fa[pos] = find(pos + 1),意思是“pos 已经失效,请后续操作直接跳到 pos 下一个还没染色的位置”。注意这里必须用 find(pos + 1) 而不是直接写 pos + 1,因为 pos + 1 可能也已经被染色了,需要沿并查集链跳到真正有效的下标。
这一步是理解整个算法的核心:并查集在这里不是一个“分类集合”,而是一张“跳跃指针表”。每次染色就是一个“删除”操作,把当前点从可处理集合中移除。
2.2 路径压缩为什么能加速
如果没有路径压缩,find 操作会沿着 fa 链一路跳,最坏情况下可能 O(n)。但加上路径压缩后,find 会顺手把链上所有经过的节点直接指向最终的根节点,后续查询就是 O(1) 级别。
具体到区间染色场景,路径压缩带来的效果如下:假设 1 到 100 都被染过色,fa[1] 可能在第一次删除后就指向 101,之后查询 find(1) 会直接返回 101,不需要从 1 一直跳到 2、3、4……这样跳跃次数就摊还得非常小。整体复杂度可以看成 O(n * α(n) + m),其中 α(n) 是反阿克曼函数,在实用范围内基本等于常数 4 以内。
2.3 区间从 l 开始向右枚举,还是从 find(l) 开始
处理一次操作 [l, r] 时,第一个要染色的位置不能用 l,而应该用 find(l)。因为 l 可能在之前(倒序角度看是“未来”)已经被染过色了,真正的起点是有可能的第一个未染色位置。
典型写法是这样的:
cpp复制int pos = find(l);
while (pos <= r) {
ans[pos] = c;
fa[pos] = find(pos + 1);
pos = find(pos);
}
每次染色完一个位置,就把它“删除”,pos 再跳到下一个未染色位置,直到超出 r 结束。如果整个区间已经被全染过色,那么 find(l) 会直接大于 r,循环体一次都不会执行,相当于这次操作被完全跳过,非常高效。
3. C++ 完整实现与逐段解读
3.1 完整代码
下面的代码用纯 C++ 实现,风格是竞赛里的常见写法,n 最多开到 1e6 也不会很吃力:
cpp复制#include <bits/stdc++.h>
using namespace std;
const int MAXN = 1000005;
int fa[MAXN];
int ans[MAXN];
int L[MAXN], R[MAXN], color[MAXN];
int find(int x) {
return fa[x] == x ? x : fa[x] = find(fa[x]);
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, m;
cin >> n >> m;
for (int i = 1; i <= n + 1; i++) {
fa[i] = i;
}
for (int i = 1; i <= m; i++) {
cin >> L[i] >> R[i] >> color[i];
}
for (int i = m; i >= 1; i--) {
int l = L[i], r = R[i], c = color[i];
int pos = find(l);
while (pos <= r) {
ans[pos] = c;
fa[pos] = find(pos + 1);
pos = find(pos);
}
}
for (int i = 1; i <= n; i++) {
cout << ans[i] << " \n"[i == n];
}
return 0;
}
这里把操作全部存下来是因为要倒序使用,所以必须离线读取所有输入。如果你的输入流前面还有其他数据,记得排列好存储顺序。
3.2 三个边界条件处理
第一个边界是 fa[n + 1]。当染色到最后一个位置 n 时,find(n + 1) 会被调用,所以初始化一定要处理 n + 1 这个哨兵点,否则越界。更稳妥的做法是把数组开到 n + 2 并初始化到 n + 1。
第二个边界是区间完全被跳过的情况。倒序遍历时,如果某个操作的区间内所有点都已经被“未来”的操作覆盖过,那么 find(l) > r,这时候这次操作其实什么都没改,不能因为没染色就报错或者数组越界,while 条件天然处理了这种情况。
第三个边界是单点操作,也就是 l == r。这种情况下同样按普通流程处理,find(l) 等于 r 就染色,然后 fa[l] = find(l + 1),没有任何特殊逻辑,但要注意别把 l + 1 写错成 l。
3.3 时间复杂度与空间复杂度
空间复杂度是 O(n),因为只用到了几个长度为 n 级别的数组。时间复杂度方面,外层 m 次操作,每次操作循环染色若干点,但每个点只会被染色一次就被删掉了,所以总染色次数不超过 n,加上 find 的路径压缩,整体复杂度近似 O(n + m)。
这个复杂度非常优秀,在 n=m=1e6 的数据规模下也能轻松跑完。对比线段树做区间赋值、最终单点查询的 O(m log n),并查集做法的常数明显更小。
提示:如果 n 和 m 都是 1e5 级别,两种做法差距还不明显;一旦到 1e6 或者更大,并查集方案的运行优势会非常直观。
4. 实际写代码最容易踩的 5 个坑
4.1 合并时写成 fa[pos] = pos + 1 而不是 find(pos + 1)
这是我见过最多的错误。如果你写 fa[pos] = pos + 1,那么当 pos + 1 已经被染色时,后面的 find(pos) 会返回 pos + 1,但 pos + 1 其实是个无效点,应该继续往后跳。正确写法是 fa[pos] = find(pos + 1),这样能够一次跳到真正可用的位置。
这属于“逻辑对了一半”的典型错误,小数据看着没问题,大数据一下子超时或者答案错误。写的时候多想想:你在删除一个节点后,它的父节点必须指向“右侧第一个有效位置”,而不是简单的坐标 +1。
4.2 初始化和多组数据
如果你读入多组测试数据,每组都必须重新初始化 fa[i] = i 和 ans[i] = 0。很多人第一组数据跑完,第二组直接跳过初始化,导致 find 返回一些旧数据里的下标,答案完全错乱。
我习惯把初始化写成 for (int i = 1; i <= n + 1; i++),因为 n + 1 这个哨兵点太容易被漏掉。一旦漏掉,最后一格的染色操作就可能越界访问。
4.3 递归爆栈问题与迭代版 find
C++ 写递归 find 在链比较长的时候有可能栈溢出,尤其路径压缩之前递归深度可能达到 O(n)。虽然大多数判题环境的栈空间能扛住 1e6 左右的递归深度,但有些环境并不宽裕,而且这不是什么好习惯。
如果担心爆栈,可以写一个迭代版 find,逻辑完全等价:
cpp复制int find(int x) {
int root = x;
while (fa[root] != root) {
root = fa[root];
}
while (x != root) {
int nxt = fa[x];
fa[x] = root;
x = nxt;
}
return root;
}
两个循环,第一遍找根,第二遍把路径上的节点全部压缩到根节点。这个版本没有递归,任何环境下都不会栈溢出,可以放心用。
4.4 0 下标还是 1 下标
如果题目位置编号从 0 开始,那么初始化要写成 for (int i = 0; i <= n; i++) fa[i] = i,区间边界都相应减去 1。很多人写着写着和 1 下标版本搞混,导致边界判断出错。
我的建议是,不管题目的原话是 0 还是 1,读进来后统一转换成 1 下标处理,最后输出答案时再转换回去。这样代码逻辑统一,不容易在合并时写错。
4.5 用颜色 0 表示无色导致混淆
有些题目初始为无色,染色操作也可能把区间涂成颜色 0,这时候如果直接用 ans[i] = 0 表示无色,就会被后续真实染色操作覆盖,很难区分“根本没有被染过”和“被染成了颜色 0”。
一个稳妥方案是初始化 ans[i] = -1 或者其他特殊值,最后输出时再映射成题目要求的无色标记。注意这是很多人忽略的细节,因为样例数据往往不会卡这种边界。
5. 变体题与扩展思路
5.1 区间赋值为 0/1 的开关问题
有一类题是每次把区间内所有元素改为 0 或 1,问最终有多少个位置是 1。这种题是普遍的区间染色问题的特例,只要颜色值改成 0 或 1 即可,算法完全一样。
还有一个常见问法:倒序处理完所有操作后,还要统计每种颜色的数量,或者连续颜色段的个数。统计颜色数量很简单,在染色时直接 cnt[c]++,每个点只被染色一次,所以统计也是 O(n) 的。
统计连续颜色段稍微麻烦一点,需要在染色一个点 pos 时,观察它左右两个邻居的颜色是否等于当前颜色。如果左右都不是当前颜色,说明新增了一个颜色段;如果左右都是当前颜色,说明这段把两个段连成了一段;如果只有一边是当前颜色,段数不变。当然这是建立在最终整体逐点染色完成后统计的前提下,不要和动态过程混淆。
5.2 按权重而不是时间倒序
有些题目并不是“后涂覆盖先涂”,而是“权重大的覆盖权重小的”。例如每次操作有一个优先级 p_i,优先级高的结果覆盖优先级低的结果,而不是时间上靠后的一定赢。
这种情况可以先把所有操作按权重从大到小排序,然后用同样的并查集倒序处理。因为权重大的先处理,处理完的位置永远不会再被权重小的操作覆盖,于是整体逻辑完全一致。
如果你对“倒序”这个概念有深刻理解,就会发现真正的本质是“从决定性最强的操作开始,逐步处理决定性较弱的部分”。时间只是最常见的决定性指标,权重、颜色大小都是类似思路。
5.3 染色后需要查询某个点被哪次操作覆盖
有时题目不是问最终颜色,而是问每个位置最后一次被覆盖是第几次操作。这个问题其实更容易,只要在染色时记录 ans[pos] = i(操作编号),而不记录颜色本身,最后输出编号数组即可。代码上连颜色数组都不用建,只保留操作编号数组。
这种变形往往出现在交互类题或者需要复盘的题目里。你会发现只要理解了并查集的作用,变来变去都只是改一下 ans 数组存什么的问题。
5.4 二维平面染色(进阶)
如果问题扩展成二维,即 n*m 的网格,每次把一个子矩形染成某种颜色,最后问每个格子的颜色,能否也用并查集?
可以,但实现要小心。一种做法是外层枚举每一行,对内层用一维并查集处理该行的区间染色,总复杂度是 O(行数 * (每行染色点数 + 操作数)),如果矩形很大且行数很多,效率会退化。另一种做法是“并查集套并查集”,行用一个并查集,列再用一个并查集,这样复杂度接近 O(总面积 α),但是实现细节比一维复杂不少,需要额外判断行列分别什么时候跳到下一个有效位置。
二维扩展不推荐在初学阶段深究,先把一维版本吃透,遇到具体题目时再根据数据范围决定方案。
5.5 和线段树对比:什么时候用哪个
有人问:区间染色直接用线段树不也是很好的办法吗?确实,线段树做区间赋值、单点查询,复杂度是 O(m log n),完全可以处理 1e5 级别的数据。但两者有本质区别:
| 方案 | 时间复杂度 | 空间复杂度 | 在线/离线 | 适用场景 |
|---|---|---|---|---|
| 朴素模拟 | O(n*m) | O(n) | 在线 | n、m 都小,比如 1000 以下 |
| 线段树 | O(m log n) | O(4n) | 在线 | 需要在线查询、需要动态维护区间信息 |
| 并查集倒序 | O(n + m) | O(n) | 离线 | n、m 很大,只需要最终状态 |
并查集方案最主要的限制是:它要求必须离线拿到所有操作,并且只关心最终状态。如果题目在操作过程中需要随时查询某个位置的颜色,或者需要在区间染色过程中支持其他区间操作,并查集就力不从心了。反过来,如果只是“给定一批操作,求最终局面”,并查集是更好用的选择。
6. 调试与对拍经验:怎么确保并查集写法是对的
6.1 写一个朴素暴力做交叉验证
算法写完了,最怕的就是样例过了、一提交就莫名其妙错。这时候最有效的办法是对拍。对拍的意思是你同时写一个朴素正确但可能很慢的解法,用随机小数据跑两边结果对比,一旦结果不同,马上就能定位到出错案例。
朴素解法就是最简单直接的模拟:
cpp复制for (int i = 1; i <= m; i++) {
for (int j = L[i]; j <= R[i]; j++) {
ans[j] = color[i];
}
}
n 和 m 都取 10 以内,随机生成多组数据,分别跑并查集写法和暴力写法,对比输出数组。如果发现不一致,把这一组数据单独拎出来调试,很快就能找到问题。
6.2 用随机数据对拍的思路
C++ 里可以用随机数生成 n、m 以及每次操作的 l、r、颜色值,然后把数据同时喂给两个程序。为了效率,可以把数据写入文件,或者用一个脚本循环调用两个可执行文件并比较输出。网上有专门的对拍脚本模板,也可以用简单的 Python 脚本辅助。
我在实际对拍时习惯把区间长度控制在比较小的范围,保证暴力跑得快,同时多生成几百组数据。一旦出现不一致,把那一组数据保存下来,单独跑并查集版本,打印中间变量,比如每次操作后 ans 数组的变化,这样非常直观。
6.3 输出最终数组时注意未覆盖位置
程序结束后,ans 数组中那些值仍然为 -1(或者你设定的初始特殊值)的位置,表示它们在 m 次操作里从没有被覆盖到。这类位置不应被当成颜色 0 输出,要根据题目要求单独处理。比较隐蔽的情况是:区间 [l, r] 可能无效,比如 l > r,题目故意给这种数据,此时并查集倒序循环不会进入 while,也不会出错,但你的暴力程序必须同样处理 l > r 的情况,否则对拍会出现“假不一致”。
6.4 常见误区:正序也能做吗?什么时候正序能做?
这个问题很多人都会问。实际上,正序用并查集不是完全不能做,只要你换一种“删除”策略。比如用 std::set 维护所有还没被染色的位置,每次操作从 set 里把区间内的位置删除并赋值,复杂度 O((n+m) log n),也能过很多题。
但并查集的正序版本有个问题:如果后涂的颜色会覆盖先涂的颜色,你正序删掉位置后,后面操作还是要更新这个位置,就违背了“每个点只处理一次”的初衷。所以要正序用并查集,前提是操作之间必须有“不可覆盖”的关系,比如每个点只被第一个覆盖它的操作决定,而不是最后一个。
如果你理解了这一点,就能明白为什么很多题解一上来就说“倒序+并查集”,并不是因为正序不可以,而是倒序最贴合“最后一个操作决定最终颜色”这个逻辑,也最能发挥每个点只处理一次的优势。
注意:有的题目给出的是“每次操作把区间内尚未染色的位置染成颜色 c”,这就是正序直接能做的版本。这种题如果硬套倒序反而是错的,因为先处理的操作决定了点的颜色,后处理的操作对已经染色的点没有影响。做题前一定要先读懂操作规则,而不是无脑倒序。
7. 个人实践中的几点体会
这类并查集维护的区间染色题刷多了之后,我越来越觉得它的核心不是代码本身,而是“每个点只处理一次”这个直觉。一旦建立起这个直觉,很多看起来复杂的问题都能简化成“能不能把已处理点快速删除”的模型。
具体到代码层面,我个人的习惯是固定使用迭代版 find,并且把哨兵 n+1 初始化好,再在本地用暴力对拍兜底。这几步看着不起眼,实战中帮我省下了大量排查时间。如果这篇文章能帮你少踩几个坑,那它就值了。
