并查集
并查集是一种可以动态维护若干个不重叠的集合,并支持合并与查询的数据结构。
例题:P1551 亲戚
题目描述: 如果 \(x\) 和 \(y\) 是亲戚,\(y\) 和 \(z\) 是亲戚,那么 \(x\) 和 \(z\) 也是亲戚。如果 \(x\) 和 \(y\) 是亲戚,那么 \(x\) 的亲戚都是 \(y\) 的亲戚,\(y\) 的亲戚也都是 \(x\) 的亲戚。现在给出某个亲戚关系图,求任意给出的两个人是否具有亲戚关系。
输入格式: 输入 \(3\) 个整数 \(n,m,p \ (n,m,p \le 5000)\),分别表示有 \(n\) 个人,\(m\) 对亲戚关系,\(p\) 对亲戚关系的询问。接下来 \(m\) 行,每行两个数说明这两个人是亲戚。接下来 \(p\) 行,每行询问两个人是否是亲戚。
输出格式: 对于每次查询,需要输出Yes或者No表示这次查询是否是亲戚关系。
分析: 将所有有亲戚关系的人归为同一个集合中(同一个家族)。如果想查询两个人是否有亲属关系,只需要判断这两个人是否为同一个家族内。

那么,怎么判断两个人是否在同一个家族内呢?可以从每个家族中选出一位“族长”来代表整个家族,这样只需要知道两个人的族长是否为同一人,就能判断出是否属于同一个家族。
规定所有的成员都有一名“负责人”,最开始的时候,所有人都“自成一族”,每个成员都有一个指向自己的箭头,意思是自己的“负责人”就是自己,这时本人就是族长。

假如得知 \(1\) 和 \(2\) 是亲戚关系,那么就需要将 \(1\) 和 \(2\) 合并为同一个家族,将 \(1\) 的“负责人”改成 \(2\) 即可(反过来也可以)。于是,关系就变为:

由于 \(1\) 号的负责人变成了 \(2\),族长换成了 \(2\),所以家族 \(1\) 不复存在。这时,\(1\) 号和 \(2\) 号在同一个家族中,他们的族长都是 \(2\) 号。
假如得知 \(1\) 和 \(5\) 也是亲戚关系。直接将 \(1\) 号的“负责人”改成 \(5\) 是不行的(不然和 \(2\) 号建立起的关系就丢失了),不过可以把 \(1\) 号的族长(也就是 \(2\) 号)的负责人变成 \(5\) 号。这样 \(1,2,5\) 三个人都成为亲戚关系了,他们的族长是 \(5\) 号。

接下来假如得知 \(3\) 和 \(4\) 是亲戚关系,只需要将 \(3\) 的负责人变成 \(4\),都归为家族 \(4\)。假如 \(2\) 和 \(5\) 是亲戚关系,由于发现 \(2\) 和 \(5\) 的族长都是 \(5\),已经是同一个家族了,所以可以忽略掉。
假如 \(1\) 和 \(3\) 是亲戚关系,将 \(1\) 的族长(\(5\) 号)的负责人变成 \(3\) 的族长(\(4\) 号),至此一共就只剩下两个家族了。需要注意的是,查询某位成员的族长要沿着负责人关系一层一层往上遍历,直到发现自己的负责人就是自己,这位成员就是族长。

如果需要查询两个人是否是同一个家族的,只需要查询这两个人的族长是否是同一个人。如果要把两个家族合并,就把其中一个家族的族长的负责人指向另外一个家族的族长即可。
这种处理不相交可合并集合关系的数据结构叫做并查集。并查集具有查询、合并这两种基本操作。使用并查集时要先初始化并查集,然后处理这 \(m\) 对亲戚关系,如果两个人是亲戚,那么就在并查集上所对应的集合合并起来,然后对于每个询问,只需要把这两个人的集合的代表元素找出来,判断是否相等即可——如果相等,那么这两个人在同一个集合中,即为亲戚,否则不是。
参考代码
#include <cstdio>
const int N = 5005;
int fa[N];
int query(int x) {
return x == fa[x] ? x : query(fa[x]);
}
int main()
{
int n, m, p; scanf("%d%d%d", &n, &m, &p);
for (int i = 1; i <= n; i++) fa[i] = i;
for (int i = 1; i <= m; i++) {
int x, y; scanf("%d%d", &x, &y);
int fx = query(x), fy = query(y);
if (fx != fy) fa[fx] = fy;
}
for (int i = 1; i <= p; i++) {
int x, y; scanf("%d%d", &x, &y);
int fx = query(x), fy = query(y);
if (fx == fy) printf("Yes\n");
else printf("No\n");
}
return 0;
}
这里的实现是暴力的,单次操作时间复杂度最差是 \(O(n)\),实际使用时还要学习几种优化方式。
例题:P3367 【模板】并查集
路径压缩
因为只关心每个集合里有哪些点,而不关心这个集合对应的树长什么样,于是可以把点都直接挂在根节点下面,让树高尽量小。
实现的时候只需要在查询操作中,把沿路经过的每个点的父节点都设成根节点即可。
参考代码
#include <cstdio>
const int N = 200005;
int fa[N];
int query(int x) {
return fa[x] == x ? x : fa[x] = query(fa[x]);
}
int main()
{
int n, m; scanf("%d%d", &n, &m);
for (int i = 1; i <= n; i++) fa[i] = i;
for (int i = 1; i <= m; i++) {
int z, x, y; scanf("%d%d%d", &z, &x, &y);
int fx = query(x), fy = query(y);
if (z == 1) {
if (fx != fy) fa[fx] = fy;
} else {
printf("%s\n", fx == fy ? "Y" : "N");
}
}
return 0;
}
优化后单次操作时间复杂度最差情况是 \(O(\log n)\),一般到不了,平均情况下系数很小,已经足够使用了,不过路径压缩以后树的结构就不是原有结构了。
按秩合并
既然希望树高尽量小,那如果每次让树高小的那边合并到树高大的那边,得到的新树的高度就相对较小了,这就叫按秩合并。
用一个数组 \(rk\) 维护树高,如果树高不等,设树高小的那棵树的根节点的父节点为树高大的那棵树的根节点,如果树高相等,则谁当新的根都行,不过 \(rk\) 要加 \(1\)。
参考代码
#include <cstdio>
const int N = 200005;
int fa[N], rk[N];
int query(int x) {
return fa[x] == x ? x : query(fa[x]);
}
int main()
{
int n, m; scanf("%d%d", &n, &m);
for (int i = 1; i <= n; i++) {
fa[i] = i; rk[i] = 1;
}
for (int i = 1; i <= m; i++) {
int z, x, y; scanf("%d%d%d", &z, &x, &y);
int fx = query(x), fy = query(y);
if (z == 1) {
if (fx != fy) {
if (rk[fx] < rk[fy]) {
fa[fx] = fy;
} else if (rk[fx] > rk[fy]) {
fa[fy] = fx;
} else {
fa[fy] = fx; rk[fx]++;
}
}
} else {
printf("%s\n", fx == fy ? "Y" : "N");
}
}
return 0;
}
单次操作时间复杂度最差 \(O(\log n)\),假设一个点时树高为 \(1\),那么需要两个点能合并成树高为 \(2\) 的,需要两个树高为 \(2\) 即至少 \(4\) 个点才能合并出树高为 \(3\) 的,以此类推,树高最多 \(O(\log n)\) 级别。
启发式合并
另一种按秩合并的方法,按集合中节点数大小合并,这个合并方法也叫启发式合并,有时在其它的合并问题中也会有应用。
用一个数组 \(sz\) 维护集合大小,\(sz\) 小的树的根的父节点设为 \(sz\) 大的树的根,\(sz\) 也要相应调整。
参考代码
#include <cstdio>
const int N = 200005;
int fa[N], sz[N];
int query(int x) {
return fa[x] == x ? x : query(fa[x]);
}
int main()
{
int n, m; scanf("%d%d", &n, &m);
for (int i = 1; i <= n; i++) {
fa[i] = i; sz[i] = 1;
}
for (int i = 1; i <= m; i++) {
int z, x, y; scanf("%d%d%d", &z, &x, &y);
int fx = query(x), fy = query(y);
if (z == 1) {
if (fx != fy) {
if (sz[fx] > sz[fy]) {
fa[fy] = fx; sz[fx] += sz[fy];
} else {
fa[fx] = fy; sz[fy] += sz[fx];
}
}
} else {
printf("%s\n", fx == fy ? "Y" : "N");
}
}
return 0;
}
单次操作最差时间复杂度 \(O(\log n)\),如果一个点所对应的根变化了,那么一定是把这个集合合并到了一个更大的集合里,就是该点所在集合点数起码乘 \(2\),那么对于这个点,它所在的集合的根最多变化 \(O(\log n)\) 次,每变化一次是该点深度加 \(1\),因此该点所在深度不会超过 \(O(\log n)\)。
如果同时使用启发式合并和路径压缩,单次操作的时间复杂度是反阿克曼函数,可以认为系数非常小。
参考代码
#include <cstdio>
const int N = 200005;
int fa[N], sz[N];
int query(int x) {
return fa[x] == x ? x : fa[x] = query(fa[x]);
}
int main()
{
int n, m; scanf("%d%d", &n, &m);
for (int i = 1; i <= n; i++) {
fa[i] = i; sz[i] = 1;
}
for (int i = 1; i <= m; i++) {
int z, x, y; scanf("%d%d%d", &z, &x, &y);
int fx = query(x), fy = query(y);
if (z == 1) {
if (fx != fy) {
if (sz[fx] > sz[fy]) {
fa[fy] = fx; sz[fx] += sz[fy];
} else {
fa[fx] = fy; sz[fy] += sz[fx];
}
}
} else {
printf("%s\n", fx == fy ? "Y" : "N");
}
}
return 0;
}
拓展:有 \(n\) 个 vector(记作 v1 到 vn),刚开始每个 vector 里只有一个数,会有若干次操作,每次合并两个 vector(把 vx 和 vy 合并,合并完以后放在 vx 里),不需要管内部元素顺序。
核心思想:小的合并到大的里。先比较一下 vx.size() 和 vy.size(),如果 vx.size() 更小,则通过 swap(vx, vy) 保证 vx 是较大的那个。然后把 vy 里的元素,挨个放进 vx 里。整体的合并时间复杂度为 \(O(n \log n)\)。
swap 的时间复杂度:在 C++11 及以后标准(现在比赛是 C++14),除了数组和 array 以外,都是 \(O(1)\) 的。
如果是 set 和 map 做启发式合并,那么时间复杂度就是 \(O(n \log n \log n)\)。
例题:P1955 [NOI2015] 程序自动分析
相等的一定都可以满足,先把相等的用并查集合并(相等具有传递性)。
对于不等关系,如果不等关系的两个点属于不同集合,那没事;如果属于相同集合,那么前后矛盾。
需要离散化(sort + unique + lower_bound),时间复杂度 \(O(n \log n)\)。注意一个式子可能含有两个不同的变量,离散化后最多可能会有 \(2 \times 10^5\) 个。
参考代码
#include <cstdio>
#include <vector>
#include <algorithm>
const int N = 200005;
int x[N], y[N], e[N], fa[N];
std::vector<int> v;
int query(int x) {
return x == fa[x] ? x : fa[x] = query(fa[x]);
}
int discretize(int x) {
return std::lower_bound(v.begin(), v.end(), x) - v.begin() + 1;
}
void solve() {
int n; scanf("%d", &n);
v.clear();
for (int i = 1; i <= n; i++) {
scanf("%d%d%d", &x[i], &y[i], &e[i]);
v.push_back(x[i]); v.push_back(y[i]);
}
std::sort(v.begin(), v.end());
v.erase(std::unique(v.begin(), v.end()), v.end());
for (int i = 1; i <= n; i++) {
x[i] = discretize(x[i]); y[i] = discretize(y[i]);
}
for (int i = 1; i <= v.size(); i++) fa[i] = i;
for (int i = 1; i <= n; i++) {
if (e[i] == 1) {
int fx = query(x[i]), fy = query(y[i]);
if (fx != fy) fa[fx] = fy;
}
}
for (int i = 1; i <= n; i++) {
if (e[i] == 0) {
int fx = query(x[i]), fy = query(y[i]);
if (fx == fy) {
printf("NO\n"); return;
}
}
}
printf("YES\n");
}
int main()
{
int t; scanf("%d", &t);
for (int i = 1; i <= t; i++) {
solve();
}
return 0;
}
拓展:如果找到一个最大的 \(k\),在只满足前 \(k\) 个要求的情况下,是不矛盾的,\(n \le 10^5\)。
解题思路
二分 \(k\),然后对前 \(k\) 个约束条件还是按先处理等式,再处理不等式的方法做。
习题:P1892 [BalticOI 2003] 团伙
解题思路
朋友的朋友是朋友,这是很容易实现的并查集操作。关键在于“敌人的敌人是朋友”,这说明如果我们记录输入过程中一个人所有的敌人,那么最终把这些敌人都合并起来(因为这些敌人们之间会变成朋友)。
由于并查集合并具有传递性,并不需要把敌人们两两合并,每当读取到一个新的敌人将其和上一次读取到的敌人合并建立朋友关系即可。
在处理完所有关系后,并查集中每个独立的集合都代表一个最终的朋友团体。只需要统计并查集中有多少个独立的集合即可,这可以通过遍历所有人,计算有多少人的根节点是他们自己来得到,这个数量就是最终的团体数。
参考代码
#include <iostream>
#include <string>
using namespace std;
const int N = 1005;
// fa 数组用于并查集,存储每个元素的父节点
// enemy 数组用于记录每个人的最近输入的一个敌人
int fa[N], enemy[N];
// 并查集的查询操作(带路径压缩)
// 找到 x 所在集合的根节点
int query(int x) {
if (x == fa[x]) {
return x;
}
// 路径压缩:将路径上的所有节点直接指向根节点
return fa[x] = query(fa[x]);
}
int main()
{
// n: 人数, m: 关系数
int n, m;
cin >> n >> m;
// 初始化并查集,每个人最初都是一个独立的集合
for (int i = 1; i <= n; i++) {
fa[i] = i;
// enemy[i] 默认为 0,表示尚无记录的敌人
}
// 循环处理 m 个关系
for (int i = 1; i <= m; i++) {
char opt; // 关系类型 'F' 或 'E'
int p, q; // 关系涉及的两个人
cin >> opt >> p >> q;
if (opt == 'F') {
// 如果 p 和 q 是朋友,则合并他们所在的集合
// 将 p 的根节点的父节点设为 q 的根节点
fa[query(p)] = query(q);
} else { // opt == 'E'
// 如果 p 和 q 是敌人,应用 "敌人的敌人是朋友" 规则
// 1. 处理 p 的敌人
// 如果 p 已经有一个记录在案的敌人 enemy[p]
if (enemy[p] != 0) {
// 那么 q (p的新敌人) 和 enemy[p] (p的旧敌人) 成为朋友
// 合并 q 和 enemy[p] 的集合
fa[query(enemy[p])] = query(q);
}
// 2. 处理 q 的敌人
// 如果 q 已经有一个记录在案的敌人 enemy[q]
if (enemy[q] != 0) {
// 那么 p (q的新敌人) 和 enemy[q] (q的旧敌人) 成为朋友
// 合并 p 和 enemy[q] 的集合
fa[query(enemy[q])] = query(p);
}
// 3. 记录新的敌人关系
// 更新 p 和 q 的敌人信息,以便后续关系处理
enemy[p] = q;
enemy[q] = p;
}
}
// 统计最终的团体(集合)数量
int ans = 0;
for (int i = 1; i <= n; i++) {
// 如果一个人的根节点是它自己,说明它是一个独立集合的代表
if (query(i) == i) {
ans++;
}
}
// 输出结果
printf("%d\n", ans);
return 0;
}
习题:P1525 [NOIP 2010 提高组] 关押罪犯
解题思路
希望最大的冲突值尽可能小,反过来想,如果一个冲突值非常大,就应该优先避免它发生,因此,最自然的贪心策略是:将所有罪犯的仇恨关系按怨气值从大到小排序。
从怨气值最大的那一对罪犯开始处理,对于每一对罪犯 \((a,b)\),他们的怨气值为 \(c\),希望将他们分到不同的监狱。如果可以将他们分到不同监狱,就记录下这个约束,然后继续处理下一对怨气值稍小的罪犯。如果无法将他们分到不同监狱(因为之前的、怨气值更大的约束导致他们必须在同一监狱),那么这个怨气值 \(c\) 就是无法避免的冲突,由于是从大到小处理的,这第一个无法避免的冲突就是最终答案里的“最大冲突值”,此时,就可以直接输出 \(c\) 并结束程序。
如何实现“分到不同监狱”的约束?这实际上类似于 P1892 [BalticOI 2003] 团伙,相当于到目前为止针对某一个犯人如果其已经避开了很多“仇敌”,那么这些“仇敌”实际上就必须关在一个监狱,用并查集维护这些必须关在一个监狱的罪犯关系。
参考代码
#include <cstdio>
#include <algorithm>
using namespace std;
const int N = 20005;
const int M = 100005;
// fa 数组用于并查集,维护“在同一监狱”的关系
// enemy 数组用于记录每个人的最近输入的一个“敌人”(必须在不同监狱的人)
int fa[N], enemy[N];
// 描述罪犯关系的结构体
struct Relation {
int a, b, c; // 罪犯 a, 罪犯 b, 怨气值 c
// 重载小于号,用于 sort 排序
// 按怨气值 c 从大到小排序
bool operator<(const Relation& other) const {
return c > other.c;
}
};
// 存储所有关系的数组
Relation r[M];
// 并查集的查询操作(带路径压缩)
// 找到 x 所在集合的根节点
int query(int x) {
return x == fa[x] ? x : fa[x] = query(fa[x]);
}
int main()
{
int n, m; // n: 罪犯数, m: 关系数
scanf("%d%d", &n, &m);
// 初始化并查集,每个罪犯最初都是一个独立的集合
for (int i = 1; i <= n; i++) {
fa[i] = i;
}
// 读取 m 个关系
for (int i = 1; i <= m; i++) {
scanf("%d%d%d", &r[i].a, &r[i].b, &r[i].c);
}
// 按怨气值从大到小对所有关系进行排序
sort(r + 1, r + m + 1);
int ans = 0; // 存储最终答案,即最小的最大冲突值
// 遍历排序后的所有关系
for (int i = 1; i <= m; i++) {
int p = r[i].a;
int q = r[i].b;
// 检查 p 和 q 是否已经被分到同一个监狱
if (query(p) == query(q)) {
// 如果是,说明无法将他们分开,产生了冲突
// 因为是按怨气值从大到小处理的,这是第一个无法避免的冲突
// 所以这个冲突值就是最终答案
ans = r[i].c;
break; // 找到答案,退出循环
}
// 如果 p 和 q 不在同一监狱,则将他们标记为敌人,并维护关系
// p 的上一个敌人 和 q 成为朋友 (关在同一监狱)
if (enemy[p] != 0) { // 如果 p 已经有敌人
fa[query(enemy[p])] = query(q);
}
// q 的上一个敌人 和 p 成为朋友 (关在同一监狱)
if (enemy[q] != 0) { // 如果 q 已经有敌人
fa[query(enemy[q])] = query(p);
}
// 记录 p 和 q 互为最近输入的敌人
enemy[p] = q;
enemy[q] = p;
}
// 输出结果
printf("%d\n", ans);
return 0;
}
例题:P1196 [NOI2002] 银河英雄传说
解题思路
合并集合,想到并查集,问题在于如何求操作 2 的答案。
边带权并查集,用 \(v_i\) 表示 \(i\) 与其父节点之间的信息,比如这里就可以是 \(i\) 与其父节点之间的距离,那么对于询问,如果询问的两个点不在同一集合就是 \(-1\),否则就是它们到根的距离之差的绝对值再减 \(1\)。
求一个点到根的距离,可以在并查集查询根节点的时候沿路去加,结合上路径压缩,可以是先计算 \(x\) 的父节点到根的距离,再把 \(v_x\) 加上计算出来的这个数,再把 \(fa_x\) 调整成根。
int query(int x) {
if (x == fa[x]) return x;
int fx = query(fa[x]);
v[x] += v[fa[x]];
return fa[x] = fx;
}
而当 \(x\) 和 \(y\) 合并的时候:
fa[fx] = fy; v[fx] = sz[fy]; sz[fy] += sz[fx];
参考代码
#include <cstdio>
#include <cmath>
const int N = 30005;
char cmd[5];
int fa[N], v[N], sz[N];
int query(int x) {
if (fa[x] == x) return x;
int fx = query(fa[x]);
v[x] += v[fa[x]];
return fa[x] = fx;
}
int main()
{
int t; scanf("%d", &t);
for (int i = 1; i < N; i++) {
fa[i] = i; sz[i] = 1;
}
for (int i = 1; i <= t; i++) {
int x, y;
scanf("%s%d%d", cmd, &x, &y);
int fx = query(x), fy = query(y);
if (cmd[0] == 'M') {
if (fx != fy) {
fa[fx] = fy;
v[fx] = sz[fy];
sz[fy] += sz[fx];
}
} else {
printf("%d\n", fx != fy ? -1 : abs(v[x] - v[y]) - 1);
}
}
return 0;
}
习题:P1197 [JSOI2008] 星球大战
解题思路
按照常规的思维,这里需要在并查集上实现删点操作,这是比较麻烦的。这里我们可以采用逆向思维,将逐渐摧毁的过程倒过来考虑,变成逐渐重建的过程。
先将所有要摧毁的点摧毁,并将并查集维护好,记录此时的连通块个数,这就是整个摧毁过程完成后的答案。
从最后一次被摧毁的点开始“时光倒流”,让被摧毁的点一个个加回并查集中,在这个过程中记录连通块的个数。
参考代码
#include <cstdio>
#include <vector>
using namespace std;
const int N = 400005;
vector<int> graph[N];
int ans[N], target[N], fa[N];
bool destroy[N]; // 用于标记某点是否被摧毁
int query(int x) {
return fa[x] == x ? x : fa[x] = query(fa[x]);
}
int main()
{
int n, m; scanf("%d%d", &n, &m);
for (int i = 0; i < n; i++) fa[i] = i; // 并查集初始化
for (int i = 1; i <= m; i++) {
int x, y; scanf("%d%d", &x, &y);
graph[x].push_back(y); graph[y].push_back(x); // 建图
}
int k; scanf("%d", &k);
for (int i = 1; i <= k; i++) {
scanf("%d", &target[i]);
destroy[target[i]] = true; // 标记这个点已经被摧毁
}
int cnt = n - k;
for (int i = 0; i < n; i++)
if (!destroy[i]) {
for (int to : graph[i])
if (!destroy[to]) { // i和to都没被摧毁
int qi = query(i), qt = query(to);
if (qi != qt) {
fa[qi] = qt; cnt--; // 合并后连通块的数量减1
}
}
}
ans[k + 1] = cnt; // 整个摧毁过程完成后剩下的连通块个数
for (int i = k; i >= 1; i--) { // “时光倒流”
int cur = target[i];
destroy[cur] = false; cnt++; // 修复该点
for (int to : graph[cur])
if (!destroy[to]) { // 修复i<->to这条边
int qc = query(cur), qt = query(to);
if (qc != qt) {
fa[qc] = qt; cnt--; // 合并
}
}
ans[i] = cnt; // 修复当前点后,连通块的个数
}
for (int i = 1; i <= k + 1; i++) printf("%d\n", ans[i]);
return 0;
}
例题:CF1213G Path Queries
直接对每个查询 \(q\) 进行处理是困难的,因为每次 \(q\) 不同,“可用”的边集就不同,需要重新计算,这会导致效率低下。
注意到查询的答案具有单调性,如果一个查询是 \(q_1\),另一个是 \(q_2\) 且 \(q_2 \lt q_1\),那么在 \(q_2\) 下满足条件的路径集合,必然包含在 \(q_1\) 下满足条件的所有路,这意味着答案 \(ans(q)\) 是一个关于 \(q\) 的非递减函数。这种单调性提示我们可以使用离线处理,将所有查询读入并按其查询值 \(q\) 从小到大排序。同时,也把树的所有边按其权值 \(w\) 从小到大排序。
按排序后的顺序处理查询,当处理某个查询时,需要把所有权值 \(w \le q\) 的边都加进来。由于查询的 \(q\) 值是递增的,所以不需要每次都从头加边,只需要在上一个查询的基础上,继续加入那些新满足条件的边即可,这个过程可以用一个指针来维护当前处理到哪条边了。
如何计算路径数量?当只考虑权值 \(\le q\) 的边时,原树可能会分裂成若干个连通分量。一条路径 \((u,v)\) 的所有边权都 \(\le q\),当且仅当 \(u\) 和 \(v\) 在只考虑这些边的情况下是连通的,即它们属于同一个连通分量。在一个大小为 \(s\) 的连通分量中,任意两个不同的点都可以形成一条满足条件的路径,这样的点对的数量是 \(\dfrac{s(s-1)}{2}\),即组合数 \(C_s^2\)。因此,总的路径数量就是所有连通分量各自的路径数之和。
如何维护连通分量和计算路径数?可以使用并查集来维护,每个集合代表一个连通分量。用一个 \(sz\) 数组来记录每个集合的大小。当合并两个大小分别为 \(sz_x\) 和 \(sz_y\) 的连通分量时:首先,从总答案中减去这两个旧连通分量各自的贡献;然后,执行并查集的合并操作,并更新 \(sz\);最后,将新连通分量的贡献加回到总答案中。
由于对查询重新排过序,每次查询计算出来的答案应当放到它原本顺序的位置上,最终按原始查询顺序输出每次查询的答案。
参考代码
#include <cstdio>
#include <algorithm>
using namespace std;
using ll = long long;
const int MAXN = 2e5 + 5;
// 查询结构体,存储查询值和原始ID
struct Query {
int id, val;
// 重载小于号,用于按查询值 val 从小到大排序
bool operator<(const Query& other) const {
return val < other.val;
}
};
Query q[MAXN];
// 边结构体,存储边的两个端点和权值
struct Edge {
int u, v, w;
// 重载小于号,用于按权值 w 从小到大排序
bool operator<(const Edge& other) const {
return w < other.w;
}
};
Edge e[MAXN];
// --- 并查集 (DSU) 相关 ---
int fa[MAXN]; // fa[i] 存储节点 i 的父节点
ll ans; // 全局变量,记录当前总路径数
ll sz[MAXN]; // sz[i] 存储以 i 为根的集合的大小
ll res[MAXN]; // 存储每个查询的最终答案
// 查询操作(带路径压缩)
int query(int x) {
return x == fa[x] ? x : fa[x] = query(fa[x]);
}
// 合并操作
void merge(int x, int y) {
int fx = query(x), fy = query(y);
if (fx != fy) {
// 1. 从总答案中减去旧集合的贡献
ans -= sz[fx] * (sz[fx] - 1) / 2;
ans -= sz[fy] * (sz[fy] - 1) / 2;
// 2. 按大小合并(启发式合并),小集合并入大集合
if (sz[fx] < sz[fy]) {
fa[fx] = fy;
sz[fy] += sz[fx];
// 3. 将新集合的贡献加回总答案
ans += sz[fy] * (sz[fy] - 1) / 2;
} else {
fa[fy] = fx;
sz[fx] += sz[fy];
// 3. 将新集合的贡献加回总答案
ans += sz[fx] * (sz[fx] - 1) / 2;
}
}
}
int main()
{
int n, m;
scanf("%d%d", &n, &m);
// --- 初始化 ---
for (int i = 1; i <= n; i++) {
fa[i] = i; // 每个节点自成一个集合
sz[i] = 1; // 每个集合大小为1
}
// --- 读入与排序 ---
for (int i = 1; i < n; i++) {
scanf("%d%d%d", &e[i].u, &e[i].v, &e[i].w);
}
sort(e + 1, e + n); // 按边权排序
for (int i = 1; i <= m; i++) {
q[i].id = i;
scanf("%d", &q[i].val);
}
sort(q + 1, q + m + 1); // 按查询值排序
// --- 离线处理查询 ---
int idx = 1; // 指向当前待处理的边
for (int i = 1; i <= m; i++) {
// 将所有权值小于等于当前查询值的边加入并查集
while (idx < n && e[idx].w <= q[i].val) {
merge(e[idx].u, e[idx].v);
idx++;
}
// while 循环结束后,全局变量 ans 就是对当前查询 q[i] 的答案
res[q[i].id] = ans; // 按原始ID存储答案
}
// --- 输出结果 ---
for (int i = 1; i <= m; i++) {
printf("%lld ", res[i]);
}
return 0;
}
习题:P4185 [USACO18JAN] MooTube G
解题思路
查询条件等价于从 \(u\) 到 \(v\) 的路径上,所有边的相关性 \(r\) 都必须大于或等于 \(k\)。
直接对每个查询进行遍历会超时(\(O(NQ)\)),需要更高效的方法。观察查询条件 \(r \ge k\),如果把 \(k\) 值变小,那么满足条件的边会变多或者保持不变;如果把 \(k\) 值变大,满足条件的边会变少或保持不变,这揭示了答案关于 \(k\) 的单调性。这种单调性是使用离线处理的强烈信号,可以不按输入顺序回答查询,而是按一个自己定的、更容易处理的顺序。将所有查询 \((k,v)\) 按照 \(k\) 值从大到小排序,同时,也把所有边按照相关性 \(r\) 从大到小排序。
按 \(k\) 值降序的顺序处理排好序的查询,当处理一个查询(其阈值为 \(k\))时,把所有相关性 \(r \ge k\) 的边都加进来。由于查询的 \(k\) 值是递减的,所以只需要在上一个查询(\(k\) 值更大)的基础上,继续加入那些满足 \(r \ge k\) 的边即可,这个过程可以用一个指针来维护处理到哪条边了。
如何回答查询?当只考虑相关性 \(r \ge k\) 的边时,所有满足 \(\text{relevance}(u,v) \ge k\) 的节点 \(u\) 和 \(v\),必然在目前可用的边下是连通的。换句话说,对于一个查询 \((k,v)\),所有能推荐给 \(v\) 的视频 \(u\),都和 \(v\) 属于同一个连通分量。因此,问题的答案就是:在只考虑 \(r \ge k\) 的边的情况下,\(v\) 所在的连通分量的大小,再减去 \(v\) 自身(因为题目要求是“其他”视频)。
如何维护连通分量?可以使用并查集,每个集合代表一个连通分量。用一个 \(sz\) 数组来记录每个集合的大小(即连通分量中的节点数)。当加入一条边 \((x,y)\) 时,如果 \(x\) 和 \(y\) 原本不在同一个集合,合并后,新集合的大小就是原来两个集合大小之和。
参考代码
#include <cstdio>
#include <vector>
#include <algorithm>
using namespace std;
const int N = 1e5 + 5;
// 边结构体
struct Edge {
int p, q, r; // p, q: 端点, r: 相关性
};
Edge e[N];
// --- 并查集 (DSU) 相关 ---
int fa[N]; // fa[i] 存储节点 i 的父节点
int sz[N]; // sz[i] 存储以 i 为根的集合的大小
int ans[N]; // 存储每个查询的最终答案
// 查询操作(带路径压缩)
int query(int x) {
return fa[x] == x ? x : fa[x] = query(fa[x]);
}
// 查询结构体
struct Query {
int k, v, id; // k: 阈值, v: 起始视频, id: 原始查询编号
};
Query qu[N];
int main() {
int n, q;
scanf("%d%d", &n, &q);
// --- 初始化并查集 ---
for (int i = 1; i <= n; i++) {
fa[i] = i; // 每个节点自成一个集合
sz[i] = 1; // 每个集合大小为1
}
// --- 读入边并排序 ---
for (int i = 1; i < n; i++) {
int x, y, z;
scanf("%d%d%d", &x, &y, &z);
e[i] = {x, y, z};
}
// 按相关性 r 从大到小排序边
sort(e+1, e+n, [&](Edge e1, Edge e2) {
return e1.r > e2.r;
});
// 把询问先离线存下来
// --- 读入查询并排序 (离线处理) ---
for (int i = 1; i <= q; i++) {
scanf("%d%d", &qu[i].k, &qu[i].v);
qu[i].id = i;
}
// 调整回答询问的顺序,按阈值 k 从大到小排序查询
sort(qu+1, qu+1+q, [&](Query q1, Query q2) {
return q1.k > q2.k;
});
// --- 处理查询 (双指针思想) ---
int idx = 1; // 指向当前待处理的边
for (int i = 1; i <= q; i++) {
// 对于当前查询 qu[i],其阈值为 qu[i].k
// 将所有相关性 r >= qu[i].k 的边加入图中
while (idx < n && e[idx].r >= qu[i].k) {
// 把e[idx]这条边纳入到图中
// 用并查集合并这条边的两个端点
int x = e[idx].p, y = e[idx].q;
// 找到它们各自所在集合的根
int fx = query(x), fy = query(y);
// 如果不在同一个集合,则合并
if (fx != fy) {
fa[fy] = fx; // 合并集合
sz[fx] += sz[fy]; // 更新新集合的大小
}
idx++; // 处理下一条边
}
// 上面这个循环相当于把所有>=本次询问的k的边加完
// 查询 qu[i].v 所在连通块的大小
int fv = query(qu[i].v);
// 答案是连通块大小减一(不包括v自身)
ans[qu[i].id] = sz[fv] - 1;
}
// --- 按原始顺序输出答案 ---
for (int i = 1; i <= q; i++) printf("%d\n", ans[i]);
return 0;
}
扩展域并查集
在普通的并查集中,通常只有一个维度(元素本身)。而在扩展域并查集中,会对元素进行扩展,让它拥有多个维度(通常是某些性质)。将原本的一个点拆成多个点,分别表示与该点满足某种关系的点构成的集合。
例题:P2024 [NOI2001] 食物链
对于动物 \(x\) 和 \(y\),可能有 \(x\) 吃 \(y\),\(x\) 与 \(y\) 是同类,\(x\) 被 \(y\) 吃三种关系。因此考虑扩展到三倍空间,分别表示三类不同物种的域。
设有 \(n\) 只动物,则并查集的空间为 \(3n\),其中 \([1,n]\) 部分为 \(A\) 类物种域,\([n+1,2n]\) 部分为 \(B\) 类物种域,\([2n+1, 3n]\) 部分为 \(C\) 类物种域。
当 \(A\) 中的 \(x\) 与 \(B\) 中的 \(y\) 合并时,相当于关系 \(x\) 吃 \(y\);当 \(C\) 中的 \(x\) 与 \(C\) 中的 \(y\) 合并时,相当于关系 \(x\) 和 \(y\) 是同类······。
由于不知道某个动物具体属于 \(A,B\) 还是 \(C\),所以三种情况都要考虑。
也就是说,每当有一句真话时,需要做三次合并。
#include <cstdio>
const int N = 50005;
int fa[N * 3];
int query(int x) {
return fa[x] == x ? x : fa[x] = query(fa[x]);
}
int main()
{
int n, k; scanf("%d%d", &n, &k);
for (int i = 1; i <= n * 3; i++) fa[i] = i;
int ans = 0;
while (k--) {
int op, x, y; scanf("%d%d%d", &op, &x, &y);
if (x > n || y > n || op == 2 && x == y) {
ans++; continue;
}
if (op == 1) {
if (query(x) == query(y + n) || query(y) == query(x + n)) {
ans++; continue;
}
fa[query(x)] = query(y);
fa[query(x + n)] = query(y + n);
fa[query(x + n * 2)] = query(y + 2 * n);
} else {
if (query(x) == query(y) || query(y) == query(x + n)) {
ans++; continue;
}
fa[query(x)] = query(y + n);
fa[query(x + n)] = query(y + n * 2);
fa[query(x + n * 2)] = query(y);
}
}
printf("%d\n", ans);
return 0;
}
习题:P5937 [CEOI1999] Parity Game
解题思路
分析:首先可以想到前缀和,可以把 \(l\) 到 \(r\) 的区间和看成前缀和之差 \(sum[r]-sum[l-1]\),而这个式子的结果要么是奇数要么是偶数,当其为偶数时,说明 \(sum[r]\) 和 \(sum[l-1]\) 的奇偶性需要相同,当其为奇数时,说明 \(sum[r]\) 和 \(sum[l-1]\) 的奇偶性需要不同。
注意到 \(2m\) 远小于 \(n\),需要对最多 \(2m\) 个位置做离散化处理。把每一个位置扩展成两个域:奇数域和偶数域。不妨令 \([1,2m]\) 表示奇数域,\([2m+1,4m]\) 表示偶数域,而 \(i\) 和 \(i+2m\) 是同一个位置分别对应到奇数域和偶数域上。则两个位置的前缀和奇偶性相同可以被表达为奇数域和奇数域合并、偶数域和偶数域合并;奇偶性不同可以被表达为奇数域和对方的偶数域合并、偶数域和对方的奇数域合并。
#include <cstdio>
#include <algorithm>
using namespace std;
const int N = 5005;
int a[N], b[N], num[N * 2], n, m, fa[N * 4];
bool eq[N];
char q[10];
int getid(int x) {
return lower_bound(num + 1, num + 2 * m + 1, x) - num;
}
int query(int x) {
return fa[x] == x ? x : fa[x] = query(fa[x]);
}
int main()
{
scanf("%d%d", &n, &m);
for (int i = 1; i <= m; i++) {
scanf("%d%d%s", &a[i], &b[i], q);
a[i]--; num[i] = a[i]; num[i + m] = b[i];
eq[i] = q[0] == 'e';
}
sort(num + 1, num + 2 * m + 1);
for (int i = 1; i <= 4 * m; i++) fa[i] = i;
int ans = 0;
for (int i = 1; i <= m; i++) {
int x = getid(a[i]), y = getid(b[i]); // 离散化后对应的值
if (!eq[i]) { // 奇偶性不等
// 如果之前出现过奇偶性相同的情况,说明矛盾
if (query(x) == query(y)) break;
fa[query(x)] = query(y + 2 * m);
fa[query(y)] = query(x + 2 * m);
} else { // 奇偶性相等
// 如果之前出现过奇偶性相反的情况,说明矛盾
if (query(x) == query(y + 2 * m) || query(y) == query(x + 2 * m)) break;
fa[query(x)] = query(y);
fa[query(x + 2 * m)] = query(y + 2 * m);
}
ans++;
}
printf("%d\n", ans);
return 0;
}
习题:P9869 [NOIP2023] 三值逻辑
解题思路
直接模拟所有可能的初始赋值(\(3^n\) 种)效率太低,需要找到一种更高效的方法来刻画变量之间的约束关系。
核心思想是将赋值操作转化为等价关系,并使用并查集来维护这些关系。
为了统一处理变量和常量,建立一个符号系统:
- 对于每个变量 \(x_i\),创建两个节点:
- 节点 \(i\):代表 \(x_i\) 的值为真(T)这个状态。
- 节点 \(n+i\):代表 \(x_i\) 的值为假(F)这个状态。
- 这两个状态互为“相反”状态。
- 对于三个逻辑常量,也创建对应的节点:
- 节点 \(2n+1\):代表常量 T。
- 节点 \(2n+2\):代表常量 F。
- 节点 \(2n+3\):代表常量 U。
- 逻辑非 \(\lnot\) 运算可以看作是在这些节点间进行转换:\(\neg T\) 变为 \(F\),\(\neg F\) 变为 \(T\),\(\neg U\) 变为 \(U\),\(\neg x_i\) 的状态 \(i\) 变为 \(n+i\),反之亦然。可以用一个函数函数实现这个转换,记作 \(\text{opposite}\)。
题目中的赋值语句是按顺序执行的,后面的赋值会覆盖前面的结果,因此,一个变量的最终值取决于最后一次对它的赋值。但是,赋值语句的右侧(如 \(x_j\))取的是 \(x_j\) 在那一刻的即时值,而不是它的初始值。
为了解决这个问题,可以用一个数组 \(a\),其中 \(a_i\) 记录变量 \(x_i\) 在经过了一系列赋值后,其值等价于哪个符号节点的初始状态。
顺序遍历所有 \(m\) 条语句:
- \(x_i \leftarrow x_j\):意味着 \(x_i\) 的值变成了 \(x_j\) 当时的值,更新 \(a_i \leftarrow a_j\)。
- \(x_i \leftarrow \neg x_j\):意味着 \(x_i\) 的值变成了 \(x_j\) 当时值的非,更新 \(a_i \leftarrow \text{opposite}(a_j)\)。
- \(x_i \leftarrow v\):意味着 \(x_i\) 的值变成了常量 \(v\),更新 \(a_i\) 为 \(v\) 对应的符号节点。
在遍历完所有语句后,\(a_i\) 就代表了 \(x_i\) 的最终值与哪个初始状态等价。
题目的核心要求是初始值等于最终值,对于每个变量 \(x_i\),它的初始值被符号化为 \(i\)(代表初值为 T)或 \(n+i\)(代表初值为 F),它的最终值由 \(a_i\) 决定。所以,相当于要求 \(a_i = i\)。
可以使用并查集来处理这些等价关系,对于每个 \(i\) 从 \(1\) 到 \(n\),合并 \(i\) 和 \(a_i\),同时,如果两个状态等价,它们各自的“相反”状态也必须等价,所以也要合并 \(\text{opposite}(i)\) 和 \(\text{opposite}(a_i)\)。
在建立了所有等价关系后,判断哪些变量必须被赋值为 U。一个常量 \(x_i\) 必须为 U,只有两种情况:
- 逻辑矛盾:如果 \(x_i\) 的状态被推导出必须同时为 T 和 F,即 \(i\) 和 \(n+i\) 在同一个并查集集合中,这对应于方程 \(P \lnot P\),只有 \(P = U\) 是解。
- 直接赋值:如果 \(x_i\) 的状态被推导出等价于常量 U,即 \(i\) 和 \(2n+3\) 在同一个并查集集合中。
遍历所有 \(x_i\),如果满足上述两个条件之一,那么它对 U 的数量贡献为 1。这样统计出来的总数,就是最少的 U 的数量。任何其他不满足这两个条件的变量,总能给它赋一个 T 或 F 的值,同时满足所有约束,从而不去增加 U 的数量。
参考代码
#include <cstdio>
const int N = 200005;
// a[i] 数组:记录变量 i 在所有赋值操作后,其最终值等价于哪个“符号”的初始值
// fa[i] 数组:并查集的父节点数组
// 符号约定:
// [1, n] : 变量 x_i 的值为 True
// [n+1, 2n] : 变量 x_i 的值为 False
// 2n+1 : 常量 T
// 2n+2 : 常量 F
// 2n+3 : 常量 U
int a[N], fa[N];
char v[5]; // 用于读取操作符
// 计算一个符号节点的“相反”节点
// 例如,x_i为T (i) 的相反是 x_i为F (n+i)
// 常量T (2n+1) 的相反是常量F (2n+2)
// 常量U (2n+3) 的相反是它自身
int opposite(int x, int n) {
if (x <= n) return x + n;
else if (x <= 2 * n) return x - n;
else if (x == 2 * n + 1) return x + 1;
else if (x == 2 * n + 2) return x - 1;
else return x;
}
// 并查集的查询操作(带路径压缩)
int query(int x) {
return fa[x] == x ? x : fa[x] = query(fa[x]);
}
int main()
{
int c, t; scanf("%d%d", &c, &t); // 读取测试点编号和数据组数
while (t--) {
int n, m; scanf("%d%d", &n, &m);
// 初始化:
// a[i] = i 表示变量i的初始值就是它自己,没有经过任何赋值
// fa[i] = i 是并查集的标准初始化
for (int i = 1; i <= 2 * n + 3; i++) {
if (i <= n) a[i] = i;
fa[i] = i;
}
// 步骤1: 顺序处理m条赋值语句,确定每个变量的最终值表达式
for (int i = 1; i <= m; i++) {
scanf("%s", v);
if (v[0] == '+' || v[0] == '-') {
int x, y; scanf("%d%d", &x, &y);
// 核心:赋值语句是计算等号右边的“即时”表达式。
// a[y] 代表 y 当时的符号值,所以 x 的新符号值由 a[y] 决定
// 例如:x_2 <- not x_1; x_3 <- x_2;
// a[2] 会先变成 opposite(a[1]),然后 a[3] 会变成 a[2] 当时的值
if (v[0] == '+') a[x] = a[y];
else a[x] = opposite(a[y], n);
} else {
int x; scanf("%d", &x);
if (v[0] == 'T') a[x] = 2 * n + 1;
else if (v[0] == 'F') a[x] = 2 * n + 2;
else a[x] = 2 * n + 3;
}
}
// 步骤2: 根据“初始值=最终值”的原则,建立并查集等价关系
for (int i = 1; i <= n; i++) {
// 关系1: 变量i的初始状态(i) 等价于 其最终状态(a[i])
fa[query(i)] = fa[query(a[i])];
// 关系2: 那么它们各自的相反状态也必须等价
fa[query(opposite(i, n))] = fa[query(opposite(a[i], n))];
}
// 步骤3: 统计必须为 U 的变量个数
int ans = 0;
for (int i = 1; i <= n; i++) {
// 一个变量 x_i 必须为 U 的情况有两种:
// 1. 它的 T 状态和 F 状态在同一个集合里 (query(i) == query(n+i)),产生了 P = not P 的矛盾
// 2. 它的状态被直接或间接地设为了常量 U (query(i) == query(2n+3))
if (query(i) == query(2 * n + 3) || query(i) == query(n + i))
ans++;
}
printf("%d\n", ans);
}
return 0;
}
用并查集压缩沿线段跳跃
这个技巧通常用于解决离线的区间覆盖、区间染色或区间赋值问题。
想象一下有一个长度为 \(N\) 的数组或线段,初始时所有位置都是“未处理”状态。现在有一系列操作,每个操作要求将区间 \([l,r]\) 内所有未处理的位置标记为“已处理”(或者给它们赋一个值、染上一种颜色)。
朴素的做法是对于每个操作 \([l,r]\),都写一个循环处理未被处理的位置。这种做法的瓶颈在于,如果一个位置已经被处理过了,后续的很多操作可能还会反复访问它,进行“是否已处理”的判断,浪费了大量时间。如果区间重叠很多,效率会非常低。
可以用并查集来跳过那些连续的、已经被处理过的区间。
建立一个并查集,其中包含 \(N+1\) 个元素,并查集中的 \(fa_i\) 数组被赋予一个新的含义:\(fa_i\) 表示位置 \(i\) 及其右侧第一个“未处理”的位置。
- 初始化:\(fa_i = i\),这表示在最开始,每个位置 \(i\) 自己就是它自己右侧第一个未处理的位置(因为它本身就未处理)。
- 处理 \(i\):当处理完位置 \(i\) 后,它就不再是“未处理”的了,那么现在 \(i\) 右侧第一个未处理的位置,就和 \(i+1\) 右侧第一个未处理的位置是同一个了。因此,将 \(i\) 合并到 \(i+1\) 的集合中。
- 查找从 \(i\) 开始(包括 \(i\))向右的第一个未处理的位置:并查集的路径压缩特性在这里起到了关键作用,如果 \([i,j]\) 这整一段都已经被处理了,那么经过路径压缩后,下一次查询可以一步调到 \(j+1\),而不需要逐个检查 \(i,i+1,\dots,j\)。
for (int i = query(l); i <= r; i = query(i)) {
...
}
这就是“沿线段跳跃”这个名字的由来:查找操作就像一个跳板,直接跳过一整段已处理的区域。
习题:P9715 「QFOI R1」头
解题思路
直接模拟 \(n \times m\) 的网格进行涂色是不可行的,因为 \(n,m,q\) 的数据范围都很大,会导致超时,需要找到一种更宏观、更高效的计算方法。
题目中有两种操作模式:覆盖(\(t=1\))和不覆盖(\(t=0\)),一个格子的最终颜色取决于最后一次对它生效的涂色操作。
- 覆盖操作(\(t=1\)):会无视格子当前是否有颜色,强制染上新色。
- 不覆盖操作(\(t=0\)):只会对尚未染色的格子进行染色。
这引出了操作间的优先级关系:
- 任何覆盖操作的优先级都高于不覆盖操作。
- 对于两次覆盖操作,后发生的会覆盖先发生的。
- 对于两次不覆盖操作,先发生的会抢先染色,后发生的无效。
总结下来,一个格子的最终颜色由“最后一次覆盖它的 \(t=1\) 操作”决定,如果没有 \(t=1\) 操作覆盖它,则由“第一次覆盖它的 \(t=0\) 操作”决定。
根据上述优先级,可以设计一个巧妙的处理流程,从而避免复杂的判断。
- 先处理所有覆盖操作(\(t=1\)):因为后发生的 \(t=1\) 操作优先级最高,可以从后往前处理所有 \(t=1\) 操作。这样,遇到的第一个能染到某行/列的 \(t=1\) 操作,就一定是决定该行/列最终颜色的那个 \(t=1\) 操作。
- 再处理所有不覆盖操作(\(t=0\)):在 \(t=1\) 操作处理完后,一些行/列的颜色已经被“锁定”。对于剩下的未锁定的行/列,需要应用 \(t=0\) 操作。这样,遇到第一个能染到某行/列的 \(t=0\) 操作,就是决定其最终颜色的操作。
当要对某一行进行涂色时,它能成功染色的格子数,等于当前还未被任何列操作“锁定”的列数,反之亦然。
- 维护两个计数器 \(cnt_r\) 和 \(cnt_c\),分别表示当前还未被染色的行数和列数。
- 当处理一个行操作(例如,给第 \(i\) 行染色)时,如果该行是首次被染色,那么它对颜色的贡献就是 \(cnt_c\)。
- 同理,处理列操作时,贡献为 \(cnt_r\)。
- 通过这种方式,每个格子 \((x,y)\) 的颜色贡献只会被计算一次:要么在行 \(x\) 被染色时计算,要么在列 \(c\) 被染色时计算,取决于哪个操作最终决定了它的颜色。
在处理操作时,需要对一个区间 \([l,r]\) 内所有未被染过色的行/列进行操作,这一步可以使用并查集压缩沿线段跳跃。
参考代码
#include <cstdio>
using ll = long long;
const int N = 2000005;
// op, l, r, c, t 数组用于存储所有查询操作
int op[N], l[N], r[N], c[N], t[N];
// far, fac 分别是行和列的并查集数组
int far[N], fac[N];
// ans[i] 存储颜色 i 最终的格子总数
ll ans[N];
// row[i], col[i] 标记第 i 行/列是否已被染色
bool row[N], col[N];
// 并查集查询函数(带路径压缩)
int query(int x, int fa[N]) {
return fa[x] == x ? x : fa[x] = query(fa[x], fa);
}
// 并查集合并函数
void merge(int x, int y, int fa[N]) {
int qx = query(x, fa), qy = query(y, fa);
if (qx != qy) {
fa[qx] = qy;
}
}
/**
* @brief 对一个区间进行染色
* @param left 区间左端点
* @param right 区间右端点
* @param color 颜色
* @param fa 使用的并查集 (far 或 fac)
* @param vis 标记数组 (row 或 col)
* @param cnt1 另一个维度的未染色数量 (若染行,则为cntc;若染列,则为cntr)
* @param cnt2 当前维度的未染色数量 (若染行,则为cntr;若染列,则为cntc)
*/
void paint(int left, int right, int color, int fa[N], bool vis[N], int cnt1, int& cnt2) {
// 核心优化:使用并查集跳过已染色的位置
// i = query(i, fa) 使得 i 每次都能跳到下一个未被染色的位置
for (int i = query(left, fa); i <= right; i = query(i, fa)) {
if (vis[i]) continue;
vis[i] = true; // 标记当前行/列为已染色
merge(i, i + 1, fa); // 将当前位置合并到下一个,方便后续跳跃
ans[color] += cnt1; // 增加的颜色格子数 = 另一个维度上还自由的格子数
cnt2--; // 当前维度上,未染色的行/列数减一
}
}
int main()
{
int n, m, k, q; scanf("%d%d%d%d", &n, &m, &k, &q);
// 初始化并查集,每个元素自成一派
for (int i = 1; i <= n; i++) far[i] = i;
far[n + 1] = n + 1; // 设置一个边界,防止合并到 n 时越界
for (int i = 1; i <= m; i++) fac[i] = i;
fac[m + 1] = m + 1; // 同理
// 读入所有操作
for (int i = 1; i <= q; i++)
scanf("%d%d%d%d%d", &op[i], &l[i], &r[i], &c[i], &t[i]);
// cntr, cntc 分别记录未被染色的行数和列数
int cntr = n, cntc = m;
// 步骤1: 逆序处理所有覆盖操作 (t=1)
for (int i = q; i >= 1; i--) {
if (t[i] == 1) {
if (op[i] == 1) paint(l[i], r[i], c[i], far, row, cntc, cntr); // 染行
else paint(l[i], r[i], c[i], fac, col, cntr, cntc); // 染列
}
}
// 步骤2: 顺序处理所有不覆盖操作 (t=0)
for (int i = 1; i <= q; i++) {
if (t[i] == 0) {
if (op[i] == 1) paint(l[i], r[i], c[i], far, row, cntc, cntr); // 染行
else paint(l[i], r[i], c[i], fac, col, cntr, cntc); // 染列
}
}
// 输出结果
for (int i = 1; i <= k; i++) printf("%lld%c", ans[i], i == k ? '\n' : ' ');
return 0;
}
习题:P2391 白雪皑皑
解题思路
考虑到“后来的操作覆盖先前的操作”这一特性,可以发现,一片雪花的最终颜色,是由最后一次染到它的操作决定的。
所以,从后往前处理这些操作,即按照 \(i=m,m-1,\dots,1\) 的顺序。当处理第 \(i\) 次操作时,只对那些尚未被染上颜色的雪花进行染色。
通过逆序处理,任何一片雪花一旦被染色,它的颜色就固定了,后续(在逆序中)的操作都无法再改变它。
使用并查集压缩沿线段跳跃的技巧快速“跳过”那些已经被染过色的雪花。
参考代码
#include <cstdio>
#include <algorithm>
using namespace std;
const int N = 1e6 + 5;
// fa: 并查集的父节点数组。fa[i] 指向 i 或 i 之后第一个未被染色的雪花
// color: 存储每片雪花的最终颜色
int fa[N], color[N];
// 并查集查询函数(带路径压缩)
int query(int x) {
// 如果 fa[x] == x,说明 x 就是它所在集合的代表元(即下一个未染色位置)
// 否则,递归查找,并把路径上的节点直接连到根上(路径压缩)
return fa[x] == x ? x : fa[x] = query(fa[x]);
}
// 并查集合并函数
void merge(int x, int y) {
int qx = query(x), qy = query(y);
if (qx != qy) {
fa[qx] = qy; // 将 x 所在集合合并到 y 所在集合
}
}
int main()
{
int n, m, p, q;
scanf("%d%d%d%d", &n, &m, &p, &q);
// 初始化并查集,每个节点指向自己
// 多初始化一个 n+1 作为边界,防止访问 fa[n] 时合并到 n+1 出现问题
for (int i = 1; i <= n + 1; i++) {
fa[i] = i;
}
// 核心逻辑:从 m 到 1 逆序处理操作
for (int i = m; i >= 1; i--) {
// 根据题目公式计算染色区间的两个端点
// 使用 1ll (long long) 防止 i*p 溢出 int
int x = (1ll * i * p + q) % n + 1;
int y = (1ll * i * q + p) % n + 1;
// 保证 x <= y,方便区间遍历
if (x > y) swap(x, y);
// 并查集优化循环:
// t 的初始值是 query(x),即从 x 开始第一个未被染色的位置
// 每次循环后,t 更新为 query(t),即跳到下一个未被染色的位置
for (int t = query(x); t <= y; t = query(t)) {
if (color[t]) continue;
// 将雪花 t 染成最终颜色 i
color[t] = i;
// 将 t 合并到 t+1 的集合中,表示 t 已被染色
// 下次任何对 t 的 query 操作都会直接跳到 t+1 所指向的位置
merge(t, t + 1);
}
}
// 输出 n 片雪花的最终颜色
for (int i = 1; i <= n; i++) {
printf("%d\n", color[i]);
}
return 0;
}

浙公网安备 33010602011771号