区间染色类的问题,在竞赛里算是一类特别常见但又特别容易被人忽视的题目。很多人一看到“染色”“覆盖”这几个字,第一反应就是线段树区间赋值,然后开始考虑 lazy 标记怎么传、怎么 pushdown,代码写了一大堆,结果发现数据范围直接让 O(m log n) 也过不去。这时候其实应该停下来想一想:这题到底需不需要在线维护?最终答案是不是只跟最后一次覆盖有关?如果答案是肯定的,那么恭喜你,这道题大概率可以用并查集在接近线性的复杂度里解决。今天这篇文章就专门聊聊这一类用并查集维护的区间染色问题,从原理到模板到二维扩展再到经典例题推演,一次性把它讲透。
1. 这道题长什么样:区间染色问题的暴力解法为何总会超时
1.1 问题模型与经典题型
先把这个问题的模型定下来。你有一个长度为 n 的数组,初始每个位置都是 0(或者某种底色)。接下来会依次给出 m 次操作,每次操作给定一个区间 [l, r] 和一个颜色 c,操作的含义是:把区间 [l, r] 内的所有位置的颜色都变成 c。题目最后往往只问你一件事:所有操作结束之后,每个位置上最终是什么颜色。
这类题在 OJ 上的名字五花八门,什么“疯狂的馒头”“白雪皑皑”“区间涂色”“粉刷匠”等等,但剥掉故事的外壳,核心模型完全一样。有些题还会把颜色换成一个具体的数值,或者把区间换成若干个离散点,本质不变。
这类题有一个非常关键的结构特征:所有操作都是“覆盖式”的。也就是说,后面的操作会把前面操作留下的颜色完全盖住,不存在叠加、累加或者取最值这类复杂合并逻辑。这一点是整个并查集做法的根基。
1.2 暴力的复杂度瓶颈
最朴素的做法自然是拿到一次操作就暴力 for 一遍 [l, r],把每个位置的颜色改掉。一次操作的最坏代价是 O(n),m 次操作就是 O(n·m)。当 n 和 m 都到 10^5、10^6 级别时,这个复杂度是根本不可能接受的。
用线段树呢?区间赋值 + 懒标记,每次操作 O(log n),总复杂度 O(m log n)。n 和 m 在 10^5 级别时,线段树确实能过。但问题在于,有些题目数据范围会开到 n、m 都接近 10^6,此时 O(m log n) 虽然理论可行,但常数很大,而且如果你还要求最后单点查值,线段树的代码量也不小。更关键的是,线段树做法其实没有利用到“覆盖式操作”带来的一个特殊性质——大量的重复染色其实是无效功。
举个例子:第 1 次操作把 [1, 1000000] 全染成红色,第 2 次操作把 [1, 1000000] 全染成蓝色。作为人类你一眼就能看出来,最后全是蓝色,红色根本不需要真正被写上。但暴力算法和线段树都会老老实实地把这个区间处理两遍。如果能做到“已经被最终颜色确定的位置,就不再被后续处理触碰”,那效率就会发生质变。
1.3 一个反直觉的观察:后发生的操作反而优先
这里有一个非常反直觉、但恰恰是解题关键的观察:在覆盖式染色问题中,时间上越靠后的操作,优先级越高。
这个道理很简单:位置 x 的最终颜色,等于所有覆盖到 x 的操作中,编号最大的那一次操作的颜色。因为最后一次覆盖它的操作会把它之前的所有颜色全部盖掉。
所以,如果我们倒着处理操作序列,从最后一次操作往前扫,那么当某个位置第一次被覆盖到时,这个颜色就是它的最终颜色;从此以后,这个位置再也不会被更早的操作影响,可以直接把它“删掉”不再参与后续处理。
这样一来,每个位置在整个算法过程中只会被真正染色一次。剩下的问题就变成了:怎么快速找到当前操作区间内“还没被染色”的位置?“删掉”一个位置之后,怎么在下次查询时跳过它?答案就是并查集。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心思想:倒序处理加并查集跳点,每个位置只染一次色
2.1 为什么倒序之后每个格子只需处理一次
我们先把刚才的结论再往深里推一步。倒序处理时,当前正在枚举的操作是第 i 次操作,我们给区间 [l_i, r_i] 内的所有“尚未确定最终颜色”的位置染上颜色 c_i。
为什么可以只处理“尚未确定最终颜色”的位置?因为对于已经确定颜色的位置来说,它已经被某次编号更大的操作覆盖过了。而编号更大的操作一定发生在第 i 次操作之后,所以第 i 次操作对这个位置的影响最终会被覆盖掉。既然最终颜色已经被确定了,那再对它染色就是纯粹的浪费,直接跳过即可。
于是算法的流程非常清晰:
- 初始化所有位置为“未染色”状态。
- 从第 m 次操作开始,倒序枚举到第 1 次操作。
- 对于当前操作区间 [l, r],找到区间内所有未染色位置,把它们染成当前颜色。
- 被染色的位置立即标记为“已染色”,并且在后续寻找中不再出现。
这个过程每个位置只会被染色一次,所以染色操作的总次数是 O(n)。
2.2 并查集在这里到底维护了什么
现在的问题非常具体:如何快速找到一个位置右边最近的一个“未染色”位置?
并查集的经典应用场景是维护集合的合并与查询,但在这里我们用它维护一种特殊的“跳转关系”:fa[i] 表示从位置 i 开始向右看,第一个尚未被染色的位置。初始时所有位置都未染色,所以 fa[i] = i。
当一个位置 i 被染色后,我们把它“合并”到 i + 1 那边去,也就是执行 fa[i] = find(i + 1)。这样一来,以后再有人问“从 i 往右第一个未染色位置是哪里”,直接 find(i) 就能得到答案。
find(x) 的作用就是顺着这条“已染色 → 下一个位置”的链一直往右跳,直到找到一个 fa[x] == x 的位置,也就是尚未被染色的位置。路径压缩会把这个查找过程优化到近似 O(1)。
这里有一个很容易混淆的点:fa[i] = i 到底表示 i 是未染色还是已染色?在染色问题里,我们约定 fa[x] == x 表示这个位置还没被染色,是可以直接使用的;一旦 fa[x] != x 或者 find(x) != x,就说明 x 已经被染色了,需要继续往后跳。这个约定和普通并查集里“根代表集合代表元”的直觉不太一样,写代码前一定要先想清楚。
2.3 路径压缩的作用与复杂度分析
并查集在路径压缩之后,单次 find 的均摊复杂度是反阿克曼函数 α(n),这个值在 n 不超过 10^7 时基本可以看作一个不超过 5 的常数。
在这个问题里,每个位置被染色一次,每次染色都会触发一次 fa[pos] = find(pos + 1) 的合并操作。也就是说,合并操作总次数是 O(n)。此外,每次操作开始时调用一次 find(l) 找到区间内第一个未染色位置,共 m 次;内层循环里每染色一个位置,下一次寻找也会调用 find,但这些调用可以摊到合并上。所以总复杂度是 O((n + m) · α(n)),线性级别。
对比一下:线段树是 O(m log n),而且常数不小;并查集是 O(n + m) 量级,代码还短得多。这就是这个技巧最大的吸引力所在。
3. 代码实现与容易写错的几个边界
3.1 一维区间染色的完整模板
直接上代码。这里我给出一个通用的一维区间染色模板,适用所有“倒序处理 + 并查集跳点”的场景。
cpp复制#include <bits/stdc++.h>
using namespace std;
const int MAXN = 1e6 + 5;
int fa[MAXN]; // fa[i] 表示从 i 开始向右第一个未染色位置
int color[MAXN]; // 每个位置最终颜色
// 非递归版本 find,防止递归过深爆栈
int find(int x) {
int root = x;
while (fa[root] != root) {
root = fa[root];
}
while (fa[x] != x) {
int nxt = fa[x];
fa[x] = root;
x = nxt;
}
return root;
}
int main() {
int n, m;
scanf("%d%d", &n, &m);
vector<int> l(m + 1), r(m + 1), c(m + 1);
for (int i = 1; i <= m; i++) {
scanf("%d%d%d", &l[i], &r[i], &c[i]);
}
// 初始化:fa[i] = i,表示所有位置都未染色
// 注意要多开一个 n + 1 作为哨兵
for (int i = 1; i <= n + 1; i++) {
fa[i] = i;
}
// 倒序处理所有操作
for (int i = m; i >= 1; i--) {
int pos = find(l[i]);
while (pos <= r[i]) {
color[pos] = c[i];
fa[pos] = find(pos + 1); // 染色后,指向下一个未染色位置
pos = find(pos); // 跳到下一个未染色位置
}
}
for (int i = 1; i <= n; i++) {
printf("%d ", color[i]);
}
printf("\n");
return 0;
}
这段代码的核心只有两个地方:fa[pos] = find(pos + 1) 和 pos = find(pos)。前者是“删除”当前已染色位置,后者是“跳转”到下一个未染色位置。注意,第二个位置千万不能写成 pos++。因为 pos + 1 可能已经染色了,直接 ++ 会让算法退化成 O(n·m) 的暴力。
3.2 边界细节:哨兵、循环条件、递归与非递归 find
第一次写这类题的时候,最容易炸的地方有三个,我一个个说。
第一个是哨兵节点的设计。数组开的大小不是 n,而是 n + 1,并且要初始化 fa[n + 1] = n + 1。为什么要这样?因为当最后一个位置 n 被染色后,要执行 fa[n] = find(n + 1)。如果数组只开到 n,这一步访问 n + 1 就直接越界了;如果 n + 1 存在但初始化为 0,find 会跳到奇怪的地方。把 fa[n + 1] 设置成自身,本质上就是告诉算法:“右边已经没有未染色位置了,你该停下来了。”这样 while 循环的条件 pos <= r[i] 才能正确兜底。
第二个是递归 find 的爆栈问题。很多人习惯把并查集的 find 写成递归形式,几行就完事:
cpp复制int find(int x) {
return fa[x] == x ? x : fa[x] = find(fa[x]);
}
在普通并查集题里这么写没问题,但在区间染色里要小心。虽然路径压缩会让大部分 find 很快,但最坏情况下,连续染色一整段区间时,fa 数组会被串成一条长链。比如先染 [1, n],从位置 1 开始一直跳到 n + 1,递归深度可能达到 O(n)。n 是 10^6 时,递归深度大概率会爆栈。所以我个人建议直接用非递归版本,或者用循环加栈模拟。代码多一点,但稳。
第三个是循环条件的写法。内层 while 的标准写法是 while (pos <= r[i]),这个条件天然保证了 pos 落在当前操作区间内。有些初学者会写成 while (true) 然后在循环体里判断,或者把 pos <= r[i] 和 find(pos) <= r[i] 混在一起写,都容易出错。记住一个原则:pos 应该始终是“未染色位置”,所以每次取到 pos 后直接判断它是否在区间内即可。
3.3 遍历方向与 fa 数组定义的两种配套写法
上面给的模板是“从左边往右扫”的写法:fa[i] 指向右边第一个未染色位置,染色后跳到右边。这是最常见的一种写法,配套的操作是从第 m 次倒推到第 1 次,内层从左端点开始往右跳。
但还有一种对称的写法:从右边往左扫。fa[i] 指向左边第一个未染色位置,染色后跳到左边。这种写法适合某些特殊题目,比如规定了从右往左处理,或者你想让代码和题解的遍历方向保持一致。
两种写法的模板区别很小,就是把 fa[pos] = find(pos + 1) 换成 fa[pos] = find(pos - 1),把 pos = find(pos) 换成从右端点开始往左跳,同时哨兵节点要放在 0 的位置。我个人建议是:当前这道题用的是哪种遍历方向,就写配套的那种 fa 定义。混用容易在找“下一个位置”或者“上一个位置”时把方向搞反,调试半天才发现。
4. 从一维到二维:矩形染色怎么用并查集降维
4.1 行内跳点加行间跳行的双层并查集
一维区间染色的并查集技巧能不能拓展到二维矩形染色?可以,而且思路非常自然。
问题模型变成这样:一个 n 行 m 列的矩阵,初始全为 0。进行 q 次操作,每次把某个矩形区域 (x1, y1) 到 (x2, y2) 内的所有格子染成颜色 c。最后问整个矩阵每个格子的颜色。
直接暴力是 O(q · n · m),显然不行。用二维线段树?实现复杂度直接起飞,而且常数巨大,大多数竞赛选手不会首选它。
并查集的做法本质上是对一维做法的复用。我们维护两层并查集:
- 第一层是行内并查集,对每一行单独维护一个一维并查集,用来处理这行内部的区间染色跳点。每一行的
fa_row[row][col]表示从第 row 行的第 col 列开始向右第一个未染色的列位置。 - 第二层是行间并查集,用来跳过那些已经整行处理完毕的行。
fa_line[row]表示从第 row 行开始向下第一行尚未被完全染色的行。
算法流程如下:
- 倒序枚举每个矩形操作。
- 找到当前覆盖区域的第一行
x1。 - 对于这一行,在 [y1, y2] 区间内做一次标准的一维区间染色。
- 如果这一行在本次操作中被染色的过程中,整行都已经处理完毕,就把这一行从行间并查集中移除(即
fa_line[row] = find_line(row + 1))。 - 跳到下一行继续处理,直到超过
x2。
每一行内部的每个格子,在整个算法中只被染色一次;每一行被“整行移除”也只会发生一次。所以总复杂度是 O(n · m · α(m) + q · α(n)),也就是矩阵格子总量乘以一个常数。
4.2 二维扩展的复杂度变化与适用前提
二维做法的时间复杂度依然是线性的,但这里的“线性”指的是矩阵总格子数 n · m。如果 n、m 都是 1000,格子总数 10^6,这个算法完全没问题。如果 n、m 都是 10^5,那矩阵本身就有 10^10 个格子,任何算法都无从谈起,因为光输出答案就需要这么多时间。
所以二维并查集做法的适用前提是:矩阵总格子数可控,操作数和格子数都在可接受范围内。还有一个隐含前提是 q 不能太大,否则每一次操作跳行的开销会比较大。实际上这个做法在 n 行 m 列都达到 2000 级别时,就已经比二维差分预处理最终覆盖次数要慢了,因为二维差分只能处理“最后一次覆盖编号”或“操作次数”这类问题,而并查集能直接处理“覆盖即终止”的染色动作。
我个人的建议是:矩形染色题,数据范围在 n × m ≤ 10^6 且 q ≤ 10^5 时,用并查集是性价比最高的方案;如果 n × m 很大但操作数很少,可以考虑直接维护边界集合或者离散化;如果题目要求在线查询中间状态,那就得回到线段树的体系里去。
5. 经典题目推演:两道题看出这个技巧的全部套路
5.1 BZOJ 2054 疯狂的馒头
这道题可以说是区间染色并查集做法的祖师爷级题目,几乎每个算法选手都刷过。题目大意是:有 n 个馒头,初始没有颜色。进行 m 次操作,第 i 次操作把区间 [l, r] 内的馒头染成颜色 i。问最后每个馒头的颜色。
这道题的颜色是操作编号本身,也就是第 i 次操作有颜色 i。因为后面的编号更大,颜色值也更大,所以问题就等价于:每个位置最终颜色等于覆盖它的最大操作编号。
用并查集倒序处理,思路非常直接:从第 m 次操作开始往前扫,每个位置第一次被覆盖到的颜色就是最终颜色。但是注意,这道题有一个特殊之处:如果直接用我上面给的模板,内层循环对每个位置染色一次,能过,但需要保证 m 不爆炸。这道题 n 和 m 都可以到 10^6,直接跑模板没问题。
完整代码可以直接套 3.1 的模板,只不过在输入时把颜色记为操作编号 i,然后倒序处理。这里贴一下关键循环:
cpp复制for (int i = m; i >= 1; i--) {
int L, R, ci;
// 读入时已经把 l[i], r[i] 存好了
int pos = find(l[i]);
while (pos <= r[i]) {
color[pos] = i; // 颜色就是操作编号
fa[pos] = find(pos + 1);
pos = find(pos);
}
}
实测下来,这道题用并查集比线段树快很多,因为线段树每次操作都要 O(log n),而并查集在数据随机时经常提前 break——当整个区间都已经被染色后,find(l[i]) 会直接返回一个大于 r[i] 的位置,内层循环一次都不执行,相当于这个操作 O(1) 被跳过。这个“天然剪枝”的效果在思路里很容易被忽略,但实际运行中非常明显。
5.2 洛谷 P2391 白雪皑皑
白雪皑皑是另一道非常经典的同类题。题目大意:有 n 个点排成一排,初始都是颜色 0。进行 m 次操作,每次把区间 [l, r] 内的点染成颜色 i(同样,颜色就是操作编号)。最后输出每个点的颜色。
等等,这个描述是不是和疯狂的馒头一模一样?其实两者的区别主要在于数据范围和出题角度。白雪皑皑的 n 和 m 也可以到 10^6,所以同样是并查集的模板题。但它的另一层考点在于:有些位置可能在所有操作之后依然保持颜色 0。如果你用并查集倒序处理,那些从未被覆盖到的位置,color 数组保持初始值 0,输出自然正确,不需要额外特殊处理。
所以这道题我在写的时候,主要提醒大家注意的就是一个点:并查集模板里 fa[i] = i 的初始化范围是 1 到 n + 1,但输出范围是 1 到 n。color 数组默认初始化为 0,正好对应题目里的“无颜色”状态。很多人在读题时看到颜色编号从 1 到 m,就下意识觉得 color 初始应该是一个特殊值,其实用 0 就行了,因为所有操作的颜色编号都 ≥ 1。
如果题目改成颜色编号是任意整数,包括负数,那就需要单独用一个标记数组或者把初始值设成一个不可能出现的值,比如 INT_MAX。这个细节在通用化模板时要留个心眼。
5.3 两道题放在一起看出的通用模式
这两道题放在一起看,套路完全一致,可以总结成一张“答题卡”:
- 确认题目是“覆盖式染色”,最终答案取决于最后一次覆盖。
- 确认可以离线处理,也就是所有操作一次性给出,最后统一输出。
- 倒序枚举操作。
- 用并查集维护每个位置右边第一个未染色位置。
- 每次操作只处理区间内未染色位置,染色后立即从并查集中删除。
- 输出 color 数组。
只要这六步走完,代码量不会超过 50 行。遇到变种题,比如染色区间是“每次选两个点之间的路径染色”(树上的版本),核心思想也一样,只是需要把“区间”换成“树上路径”,用 LCA 拆成两条链再分别处理。
6. 我在这类题上踩过的坑,以及什么时候别用并查集
6.1 四个反复出现的卡壳点
这一节写点真正实战中才会碰到的坑,每一个我都实打实踩过。
第一个坑是没有处理哨兵节点 n + 1。第一次写这个模板时,我数组只开了 n 大小,初始化也只初始化到 n,结果当 pos = n 被染色后执行 find(n + 1),直接访问越界,程序在本地跑得好好的,交到 OJ 上却随机 RE。后来才意识到,n + 1 这个哨兵不仅要有,还得保证它的 fa 指向自身,否则倒序扫到最后几个位置时会跳进一个未定义的状态。
第二个坑是内层循环用了 pos++ 而不是 pos = find(pos)。看起来只是一个小差别,但一旦 pos + 1 已经被染色,pos++ 就会访问到一个已经删除的位置,后续把它当作有效位置处理。短区间数据测不出来,数据量一大就会超时或者答案错误。记住一个原则:在这个模板里,只有 find 返回的位置才是有效位置,普通的位置递推全是隐患。
第三个坑是读入顺序和操作顺序混淆。有些题目的操作输入顺序不是按时间先后给的,比如输入时会先把所有区间区间给你,然后额外给出一个执行顺序数组。如果你直接按输入顺序倒序处理,就会把先后关系搞错。我建议在读入时先把操作序列整理成一个 vector,确认编号顺序就是执行顺序,再开始倒序扫。
第四个坑是递归 find 爆栈。前面提到过,连续区间染色会把 fa 串成长链,递归深度可能达到 n。有些 OJ 的栈空间很小,10^6 深度直接段错误。所以但凡 n 超过 10^5,我都直接用非递归 find。
6.2 和线段树、珂朵莉树的适用边界对比
很多人会问:既然并查集这么好,那是不是所有区间染色题都无脑用并查集?当然不是。我用一个表格把这个技巧和其他做法的边界条件列出来。
| 数据结构/方法 | 单次操作复杂度 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|---|
| 暴力 | O(n) | 实现最简单 | 慢到没法用 | 仅限 n, m 都 ≤ 10^3 |
| 线段树(懒标记) | O(log n) | 支持在线操作与动态查询 | 常数大,代码长,不能利用“最终状态”剪枝 | n, m 在 10^5 级别且需要在线查询 |
| 珂朵莉树(ODT) | 均摊 O(log n)(随机数据下) | 实现短,可处理区间赋值和区间统计 | 构造数据下复杂度退化 | 随机数据场次较多,或者题目专门卡暴力但没卡 ODT |
| 并查集跳点(本文方法) | 均摊 O(α(n)) | 代码极短,常数小,线性复杂度 | 只能处理离线且最终状态问题 | 所有操作一次性给出,最后统一输出颜色 |
这里重点强调一下珂朵莉树。它本质上是维护一些值相同的连续段,区间赋值时可以快速合并段。在随机数据下表现确实好,但 OJ 上有很多题目是专门构造数据卡 ODT 的,会让它退化成 O(n^2)。而并查集跳点是一个严格线性算法,无论数据怎么构造都能稳定通过,这一点在竞赛里很重要。
6.3 一句话判断能不能用这个套路
判断一个题目能不能用“并查集维护区间染色”这个套路,只需要问自己三个问题:
- 操作是不是覆盖式的?后发生的操作会不会把前面的颜色完全盖掉?
- 是不是只需要最终状态?题目有没有要求在操作中途查询某个区间内有多少种颜色?
- 操作能不能离线?操作序列是不是在一开始就全部给出?
如果三个问题的答案都是肯定的,那就可以放心大胆地用并查集。如果第二个答案是“需要中途查询”,那就退回去用线段树;如果第三个答案是“在线操作”,那也得用线段树或者其他在线数据结构。
我自己的习惯是:拿到题先看数据范围。如果 n 和 m 都到 10^6,想都不想直接并查集;如果 n 和 m 在 10^5 级别,再看一下是不是只问最终状态,是的话也优先并查集,因为代码短出错概率低。这个套路用熟了之后,遇到区间染色题基本就是刻进 DNA 里的反射动作,先画一个倒序处理的草图,再补上 find 和 while 循环,十分钟就能交题。
