📖 第 3.13 章:并查集
⏱ 预计阅读时间:60 分钟 | 难度:🟡 中等
前置条件
在学习本章之前,请确保你已掌握:
- 数组与函数(第 2.3 章)
- 图的基本概念——节点、边、连通(第 5.1 章)
🎯 学习目标
学完本章后,你将能够:
- 用「路径压缩 + 按秩合并」实现 O(α(n)) 的并查集
- 用并查集判断图的连通性和环
- 实现带权并查集解决差值/关系问题
- 用种类并查集解决多关系分组问题
- 独立完成 10 道从基础到挑战的练习题
3.13.1 从一道真实问题出发
问题:网络连通
你负责管理一个由 N 台服务器(编号 1~N)构成的数据中心网络。网络工程师陆续在两台服务器之间建立直连链路(无向),你需要随时回答:服务器 A 和服务器 B 目前是否可以互相通信?
初始状态:每台服务器各自孤立
1 2 3 4 5
建立链路 (1,2):1——2 3 4 5
建立链路 (3,4):1——2 3——4 5
建立链路 (2,3):1——2——3——4 5
查询 (1,4):可以通信 ✓(1→2→3→4)
查询 (1,5):不可通信 ✗(5 是孤岛)
朴素解法的瓶颈:
| 做法 | 查询时间 | 合并时间 |
|---|---|---|
| 暴力 BFS/DFS | O(N+M) | O(1) |
维护 group[] 数组 | O(1) | O(N)(需遍历更新) |
| 并查集 | O(α(N)) ≈ O(1) | O(α(N)) ≈ O(1) |
当 N、M 高达 10^5,且操作交替出现时,并查集是唯一实用的选择。
3.13.2 核心思想:用「树」表示一个集合
关键洞察: 把同一个连通块里的服务器,组织成一棵树。树的根节点作为这个集合的「代表」。
- 判断两台服务器是否连通:看它们是否在同一棵树(根节点相同)
- 合并两个连通块:把一棵树的根,指向另一棵树的根
用一个 pa[](parent,父节点)数组来表示这片森林:
关键观察:
- 每一次
unite都是把一棵树的根指向另一棵树的根,而不是任意两个节点直接相连 unite(2, 3)实际执行的是pa[find(3)] ← find(2),即pa[3] ← 1,所以合并后4仍然挂在3下(而不是直接挂到1下)- 要让
4直接挂到根1下,需要借助 3.13.4 节的路径压缩
3.13.3 两个核心操作
Find(查找根节点)
沿着 pa[] 指针向上爬,找到根:
int find(int x) {
while (pa[x] != x)
x = pa[x];
return x; // pa[x] == x 时 x 就是根
}
判断 A、B 是否连通:find(A) == find(B)
Unite(合并两个集合)
将两棵树的根连起来:
void unite(int x, int y) {
int rx = find(x);
int ry = find(y);
if (rx != ry)
pa[rx] = ry; // 把 rx 的树接到 ry 下面
}
3.13.4 优化一:路径压缩
问题: 若一直把新树接到旧树下面,树可能变成一条长链:
1 ← 2 ← 3 ← 4 ← 5 ← 6
find(1) 需要爬 5 步,时间 O(N)
路径压缩: 在 find(x) 的过程中,把路径上所有节点直接连到根节点。
int find(int x) {
// 若 x 不是根,先递归找到根 root
// 然后顺手把 x 的父亲改为 root("压扁")
return pa[x] == x ? x : pa[x] = find(pa[x]);
}
3.13.5 优化二:按节点数合并
问题: 合并时如果总把大树接到小树下,大树变得更高,find 更慢。
按节点数合并: 把小树接到大树下,保证树高 ≤ O(log N)。
📄 C++ 完整代码
struct DSU {
vector<int> pa, sz; // sz[i] = i 为根时,这棵树的节点总数
int groups; // 当前连通块数量
explicit DSU(int n) : pa(n + 1), sz(n + 1, 1), groups(n) {
iota(pa.begin(), pa.end(), 0); // pa[i] = i
}
// 路径压缩查根
int find(int x) {
return pa[x] == x ? x : pa[x] = find(pa[x]);
}
// 按节点数合并,返回 true 表示两者原本不连通(发生了合并)
bool unite(int x, int y) {
x = find(x); y = find(y);
if (x == y) return false; // 已在同一集合
if (sz[x] < sz[y]) swap(x, y); // x 是大树
pa[y] = x; // 小树接到大树
sz[x] += sz[y];
groups--;
return true;
}
// 判断是否连通
bool connected(int x, int y) { return find(x) == find(y); }
// 所在连通块大小
int size(int x) { return sz[find(x)]; }
};
复杂度:
| 优化 | 单次操作 |
|---|---|
| 无优化 | O(N) |
| 仅路径压缩 | 均摊 O(α(N)) |
| 仅按节点数 | O(log N) |
| 两者同时(推荐) | 均摊 O(α(N)) ≈ O(1) |
α(N)是反 Ackermann 函数,增长极其缓慢,α(10^80) < 5。实践中视为常数。
3.13.6 回到网络连通问题:完整代码
现在用并查集完整解决开头的问题:
📄 现在用并查集完整解决开头的问题:
#include <bits/stdc++.h>
using namespace std;
struct DSU {
vector<int> pa, sz;
int groups;
explicit DSU(int n) : pa(n + 1), sz(n + 1, 1), groups(n) {
iota(pa.begin(), pa.end(), 0);
}
int find(int x) { return pa[x] == x ? x : pa[x] = find(pa[x]); }
bool unite(int x, int y) {
x = find(x); y = find(y);
if (x == y) return false;
if (sz[x] < sz[y]) swap(x, y);
pa[y] = x; sz[x] += sz[y]; groups--;
return true;
}
bool connected(int x, int y) { return find(x) == find(y); }
};
int main() {
int n, q;
cin >> n >> q;
DSU dsu(n);
while (q--) {
int op, a, b;
cin >> op >> a >> b;
if (op == 1) {
// 建立链路
if (dsu.unite(a, b))
cout << "新增链路:" << a << " - " << b << "\n";
else
cout << "已连通,无需新增\n";
} else {
// 查询
cout << (dsu.connected(a, b) ? "可以通信" : "无法通信") << "\n";
}
}
return 0;
}
示例追踪:
输入:5 6
1 1 2 → 建立链路 1-2,new(原本不通)
1 3 4 → 建立链路 3-4,new
1 2 3 → 建立链路 2-3,new
2 1 4 → 查询 1 和 4 → 可以通信(1→2→3→4)
2 1 5 → 查询 1 和 5 → 无法通信(5 是孤岛)
1 1 4 → 建立链路 1-4,已连通(无需新增)
3.13.7 进阶:带权并查集
问题引入
有 N 名学生,班主任陆续告诉你:「B 同学比 A 同学高 D 厘米(即 height[B] - height[A] = D)」。
你需要回答:
- B 比 A 高多少厘米?
- 某条信息是否与之前的矛盾?
朴素思路: 用图建模,但查询每次都要 BFS 遍历路径,O(N) 每次查询太慢。
带权并查集的思路: 在每个节点存储「它到根节点的高度差 dist[x]」,查询时直接用 dist 相减。
核心设计
dist[x]= height[x] - height[find(x)](x 的身高减去根的身高)- 路径压缩时,累加路径上的
dist,把 x 直接连到根:
压缩前:x → p → root
dist[x] = height[x] - height[p]
dist[p] = height[p] - height[root]
压缩后:x → root
新的 dist[x] 应该 = height[x] - height[root]
= dist[x] + dist[p]
int find(int x) {
if (pa[x] == x) return x;
int root = find(pa[x]);
dist[x] += dist[pa[x]]; // 压缩同时累加路径权值
pa[x] = root;
return root;
}
合并时计算新边权值
「声明 height[y] - height[x] = d」:
📄 「声明 height[y] - height[x] = d」:
我们有:dist[x] = height[x] - height[rx]
dist[y] = height[y] - height[ry]
若把 ry 接到 rx 下,则需要 dist[ry] 满足:
height[ry] - height[rx] = ?
由 height[y] - height[x] = d 推导:
(dist[y] + height[ry]) - (dist[x] + height[rx]) = d
height[ry] - height[rx] = d + dist[x] - dist[y]
所以 dist[ry] = d + dist[x] - dist[y]
📄 C++ 完整代码
// 完整带权并查集
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 100005;
int pa[MAXN], sz[MAXN];
long long dist[MAXN]; // dist[x] = height[x] - height[find(x)]
void init(int n) {
for (int i = 1; i <= n; i++) { pa[i] = i; sz[i] = 1; dist[i] = 0; }
}
int find(int x) {
if (pa[x] == x) return x;
int root = find(pa[x]);
dist[x] += dist[pa[x]];
pa[x] = root;
return root;
}
// 声明 height[y] - height[x] = d
// 返回 true = 无矛盾;false = 与已知矛盾
bool unite(int x, int y, long long d) {
int rx = find(x), ry = find(y);
long long dx = dist[x], dy = dist[y];
if (rx == ry) {
// 已在同集合,验证是否矛盾
return (dy - dx == d);
}
// 合并:小树接大树
if (sz[rx] < sz[ry]) {
swap(rx, ry); swap(dx, dy); d = -d;
}
pa[ry] = rx;
dist[ry] = d + dx - dy;
sz[rx] += sz[ry];
return true;
}
long long query(int x, int y) {
find(x); find(y);
return dist[y] - dist[x]; // height[y] - height[x]
}
int main() {
int n = 5;
init(n);
unite(1, 2, 3); // height[2] - height[1] = 3(2 比 1 高 3cm)
unite(2, 3, 5); // height[3] - height[2] = 5
// 查询 1 和 3 的差
cout << "3 比 1 高 " << query(1, 3) << " cm\n"; // 输出 8
// 添加矛盾信息
cout << (unite(1, 3, 10) ? "一致" : "矛盾") << "\n"; // 矛盾(应该是8)
cout << (unite(1, 3, 8) ? "一致" : "矛盾") << "\n"; // 一致
return 0;
}
3.13.8 进阶:种类并查集
问题引入
经典题: 动物王国中有三类动物 A、B、C,满足:A 吃 B,B 吃 C,C 吃 A。
依次输入 N 条信息,格式为:
1 X Y:X 和 Y 是同类2 X Y:X 吃 Y
若某条信息与前面的所有真实信息矛盾,则它是「假话」。求假话总数。
关键难点: 需要同时追踪「是否同类」「是否捕食关系」两种关系,普通并查集只能处理一种等价关系。
解法:把每个节点拆成三份
将每个动物 x 扩展为三个虚拟节点:
| 节点 | 含义 |
|---|---|
x(原始) | 与 x 同类的集合 |
x + n | 被 x 吃的集合(x 的猎物) |
x + 2n | 吃 x 的集合(x 的天敌) |
处理「X 和 Y 同类」:
x 的同类 = y 的同类 → unite(x, y)
x 的猎物 = y 的猎物 → unite(x+n, y+n)
x 的天敌 = y 的天敌 → unite(x+2n, y+2n)
处理「X 吃 Y」(X 的猎物就是 Y 的同类):
x 的猎物 = y 的同类 → unite(x+n, y)
x 的天敌 = y 的猎物 → unite(x+2n, y+n)
x 的同类 = y 的天敌 → unite(x, y+2n)
判断矛盾「X 和 Y 同类」时:
若 connected(x, y+n) → 矛盾(x 和 y 一个吃另一个,不可能同类)
若 connected(x, y+2n) → 矛盾
判断矛盾「X 吃 Y」时:
若 connected(x, y) → 矛盾(同类不可能有捕食关系)
若 connected(x, y+n) → 矛盾(y 吃 x,但说 x 吃 y)
📄 C++ 完整代码
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 150005;
int pa[MAXN * 3], sz[MAXN * 3];
void init(int n) {
for (int i = 0; i < 3 * (n + 1); i++) { pa[i] = i; sz[i] = 1; }
}
int find(int x) { return pa[x] == x ? x : pa[x] = find(pa[x]); }
bool same(int x, int y) { return find(x) == find(y); }
void unite(int x, int y) {
x = find(x); y = find(y);
if (x == y) return;
if (sz[x] < sz[y]) swap(x, y);
pa[y] = x; sz[x] += sz[y];
}
int main() {
int n, k;
cin >> n >> k;
init(n);
int lies = 0;
while (k--) {
int t, x, y;
cin >> t >> x >> y;
// 编号越界直接判假
if (x < 1 || x > n || y < 1 || y > n) { lies++; continue; }
if (t == 1) {
// 声明 x 和 y 同类
if (same(x, y + n) || same(x, y + 2 * n)) {
lies++; // 矛盾:x 和 y 之间有捕食关系
} else {
unite(x, y);
unite(x + n, y + n);
unite(x + 2 * n, y + 2 * n);
}
} else {
// 声明 x 吃 y
if (x == y) { lies++; continue; } // 自己不能吃自己
if (same(x, y) || same(x, y + n)) {
lies++; // 矛盾
} else {
unite(x + n, y);
unite(x + 2 * n, y + n);
unite(x, y + 2 * n);
}
}
}
cout << lies << "\n";
return 0;
}
⚠️ 常见错误
| 错误 | 原因 | 修复方案 |
|---|---|---|
| 带权并查集路径压缩后权值错误 | 没在递归中累加 dist | dist[x] += dist[pa[x]] 在改 pa[x] 前执行 |
合并时没更新 sz | 只改了 pa,忘记维护大小 | sz[rx] += sz[ry] |
| 种类并查集数组开小 | 需要 3N 个节点 | int pa[MAXN * 3] |
| 先 unite 再判矛盾 | 合并后信息已融合,无法判断 | 必须先判矛盾,再 unite |
find 递归爆栈 | 极长链时(N > 10^5)递归深度溢出 | 用迭代版路径压缩替代 |
💪 练习题(共 10 道,全部含完整解答)
🟢 基础练习(1~4)
题目 1:朋友圈计数
给 N 个人(编号 1~N)和 M 对朋友关系,同一朋友圈里的人可以互相联系。
求最终共有多少个朋友圈。
输入: N M,然后 M 行每行 A B 表示 A 和 B 是朋友。
输出: 朋友圈数量。
示例:
输入:5 3
1 2
2 3
4 5
输出:2
({1,2,3} 和 {4,5})
✅ 完整解答
思路: 每次合并时若两人原本不在同一集合(unite 返回 true),连通块数 -1。最终 dsu.groups 就是答案。
#include <bits/stdc++.h>
using namespace std;
struct DSU {
vector<int> pa, sz;
int groups;
explicit DSU(int n) : pa(n + 1), sz(n + 1, 1), groups(n) {
iota(pa.begin(), pa.end(), 0);
}
int find(int x) { return pa[x] == x ? x : pa[x] = find(pa[x]); }
bool unite(int x, int y) {
x = find(x); y = find(y);
if (x == y) return false;
if (sz[x] < sz[y]) swap(x, y);
pa[y] = x; sz[x] += sz[y]; groups--;
return true;
}
};
int main() {
ios_base::sync_with_stdio(false); cin.tie(NULL);
int n, m;
cin >> n >> m;
DSU dsu(n);
while (m--) {
int a, b; cin >> a >> b;
dsu.unite(a, b);
}
cout << dsu.groups << "\n";
return 0;
}
追踪(N=5, M=3):
初始 groups = 5
unite(1,2) → 不同集合,groups = 4,pa[2]=1
unite(2,3) → 不同集合,groups = 3,pa[3]=1
unite(4,5) → 不同集合,groups = 2,pa[5]=4
输出:2 ✓
题目 2:判断图中是否有环
给 N 个节点和 M 条无向边,判断这个图是否包含环。
输入: N M,然后 M 行每行 U V 表示一条边。
输出: YES(有环)或 NO(无环)。
✅ 完整解答
思路: 加一条边 (u, v) 时,若 u 和 v 已经连通(find(u)==find(v)),则这条边构成环。
#include <bits/stdc++.h>
using namespace std;
struct DSU {
vector<int> pa, sz;
explicit DSU(int n) : pa(n + 1), sz(n + 1, 1) {
iota(pa.begin(), pa.end(), 0);
}
int find(int x) { return pa[x] == x ? x : pa[x] = find(pa[x]); }
bool unite(int x, int y) {
x = find(x); y = find(y);
if (x == y) return false; // 已连通 = 加边后形成环
if (sz[x] < sz[y]) swap(x, y);
pa[y] = x; sz[x] += sz[y];
return true;
}
};
int main() {
ios_base::sync_with_stdio(false); cin.tie(NULL);
int n, m;
cin >> n >> m;
DSU dsu(n);
bool has_cycle = false;
while (m--) {
int u, v; cin >> u >> v;
if (!dsu.unite(u, v)) has_cycle = true;
}
cout << (has_cycle ? "YES" : "NO") << "\n";
return 0;
}
关键点: unite 返回 false 代表两端已连通 → 这条边是多余边 → 存在环。
题目 3:最大连通块
给 N 个节点和 M 条边,输出最大连通块包含的节点数。
✅ 完整解答
思路: 用带 sz[] 的并查集,合并结束后遍历所有节点,找 sz[find(i)] 的最大值。
#include <bits/stdc++.h>
using namespace std;
struct DSU {
vector<int> pa, sz;
explicit DSU(int n) : pa(n + 1), sz(n + 1, 1) {
iota(pa.begin(), pa.end(), 0);
}
int find(int x) { return pa[x] == x ? x : pa[x] = find(pa[x]); }
void unite(int x, int y) {
x = find(x); y = find(y);
if (x == y) return;
if (sz[x] < sz[y]) swap(x, y);
pa[y] = x; sz[x] += sz[y];
}
int size(int x) { return sz[find(x)]; }
};
int main() {
int n, m;
cin >> n >> m;
DSU dsu(n);
while (m--) {
int u, v; cin >> u >> v;
dsu.unite(u, v);
}
int ans = 0;
for (int i = 1; i <= n; i++)
ans = max(ans, dsu.size(i));
cout << ans << "\n";
return 0;
}
题目 4:Kruskal 最小生成树
给 N 个节点和 M 条带权无向边,求最小生成树的总权重。若图不连通输出 -1。
✅ 完整解答
思路: Kruskal 算法:将所有边按权重从小到大排序,依次尝试加入。若两端不在同一集合(不会成环),则加入 MST。最终若 MST 边数 = N-1,则图连通。
#include <bits/stdc++.h>
using namespace std;
struct DSU {
vector<int> pa, sz;
explicit DSU(int n) : pa(n + 1), sz(n + 1, 1) {
iota(pa.begin(), pa.end(), 0);
}
int find(int x) { return pa[x] == x ? x : pa[x] = find(pa[x]); }
bool unite(int x, int y) {
x = find(x); y = find(y);
if (x == y) return false;
if (sz[x] < sz[y]) swap(x, y);
pa[y] = x; sz[x] += sz[y];
return true;
}
};
int main() {
int n, m;
cin >> n >> m;
vector<tuple<int,int,int>> edges(m);
for (auto& [w, u, v] : edges) cin >> u >> v >> w;
sort(edges.begin(), edges.end()); // 按权重排序
DSU dsu(n);
long long total = 0;
int cnt = 0; // MST 中的边数
for (auto& [w, u, v] : edges) {
if (dsu.unite(u, v)) {
total += w;
cnt++;
if (cnt == n - 1) break; // 找够 n-1 条边
}
}
cout << (cnt == n - 1 ? total : -1) << "\n";
return 0;
}
追踪示例(N=4, 边:1-2权1, 2-3权2, 1-3权3, 3-4权4):
排序后:(1,1,2), (2,2,3), (3,1,3), (4,3,4)
加边(1,2)权1 → unite成功,cnt=1,total=1
加边(2,3)权2 → unite成功,cnt=2,total=3
加边(1,3)权3 → find(1)=find(3),已连通,跳过
加边(3,4)权4 → unite成功,cnt=3=n-1,total=7
输出:7
🟡 进阶练习(5~8)
题目 5:网络连通性查询
给 N 台服务器,M 次操作:
connect A B:在 A 和 B 之间建立链路query A B:询问 A 和 B 是否可以通信block A B:断开 A 和 B 之间的直连链路(注意:不是断开连通性!)
输出所有 query 的结果。
提示: 普通并查集不支持「断边」。解决方案:离线倒序处理——将操作逆序执行,把「断边」变成「加边」。
✅ 完整解答
核心思路:
- 先记录所有操作
- 从后向前处理:
block变成connect,正向的connect但时间上在block之前需要排除 - 用并查集 + 离线逆序处理,输出时逆序输出 query 结果
更简洁的方案:预处理每条边「实际存在的时间段」,再用离线并查集。
以下给出离线逆序 + 回收操作的简化版本(假设每对服务器最多断开一次):
#include <bits/stdc++.h>
using namespace std;
struct DSU {
vector<int> pa, sz;
explicit DSU(int n) : pa(n + 1), sz(n + 1, 1) {
iota(pa.begin(), pa.end(), 0);
}
int find(int x) { return pa[x] == x ? x : pa[x] = find(pa[x]); }
bool unite(int x, int y) {
x = find(x); y = find(y);
if (x == y) return false;
if (sz[x] < sz[y]) swap(x, y);
pa[y] = x; sz[x] += sz[y];
return true;
}
bool connected(int x, int y) { return find(x) == find(y); }
};
int main() {
int n, m;
cin >> n >> m;
vector<tuple<int,int,int>> ops(m); // {type, a, b},type: 0=connect,1=query,2=block
set<pair<int,int>> blocked; // 已断开的边
for (auto& [t, a, b] : ops) {
string op; cin >> op >> a >> b;
if (op == "connect") t = 0;
else if (op == "query") t = 1;
else { t = 2; blocked.insert({min(a,b), max(a,b)}); }
}
// 逆序处理
DSU dsu(n);
// 先加入所有「最终状态下存在」的边(connect 且未被 block 的)
for (auto& [t, a, b] : ops) {
if (t == 0) {
auto key = make_pair(min(a,b), max(a,b));
if (!blocked.count(key)) dsu.unite(a, b);
}
}
vector<string> answers;
for (int i = m - 1; i >= 0; i--) {
auto [t, a, b] = ops[i];
if (t == 2) {
// 逆序时 block 变 connect
dsu.unite(a, b);
} else if (t == 1) {
answers.push_back(dsu.connected(a, b) ? "YES" : "NO");
}
// connect 在逆序中不处理(已在初始化时加入)
}
reverse(answers.begin(), answers.end());
for (auto& s : answers) cout << s << "\n";
return 0;
}
题目 6:身高差查询(带权并查集)
N 名学生,M 条信息。每条信息格式为 A B D 表示「B 比 A 高 D cm(可为负)」。
然后 Q 次查询,每次询问「B 比 A 高多少 cm」,若无法推断输出 unknown,若已知矛盾输出 conflict。
✅ 完整解答
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 100005;
int pa[MAXN], sz_arr[MAXN];
long long dist[MAXN];
void init(int n) {
for (int i = 1; i <= n; i++) { pa[i] = i; sz_arr[i] = 1; dist[i] = 0; }
}
int find(int x) {
if (pa[x] == x) return x;
int root = find(pa[x]);
dist[x] += dist[pa[x]];
pa[x] = root;
return root;
}
// 声明 height[b] - height[a] = d,返回 false 表示矛盾
bool add_info(int a, int b, long long d) {
int ra = find(a), rb = find(b);
long long da = dist[a], db = dist[b];
if (ra == rb) return (db - da == d);
if (sz_arr[ra] < sz_arr[rb]) { swap(ra, rb); swap(da, db); d = -d; }
pa[rb] = ra;
dist[rb] = d + da - db;
sz_arr[ra] += sz_arr[rb];
return true;
}
int main() {
int n, m, q;
cin >> n >> m;
init(n);
bool global_conflict = false;
for (int i = 0; i < m; i++) {
int a, b; long long d;
cin >> a >> b >> d;
if (!add_info(a, b, d)) global_conflict = true;
}
cin >> q;
while (q--) {
int a, b; cin >> a >> b;
int ra = find(a), rb = find(b);
if (ra != rb) cout << "unknown\n";
else if (global_conflict) cout << "conflict\n";
else cout << dist[b] - dist[a] << "\n";
}
return 0;
}
输入示例:
5 3
1 2 3 → height[2] - height[1] = 3
2 3 5 → height[3] - height[2] = 5
1 3 8 → height[3] - height[1] = 8(与前两条一致)
2
1 3 → 输出 8
1 4 → 输出 unknown
题目 7:方格染色(种类并查集变形)
N × N 方格,每格初始为白色。M 次操作,每次给某行或某列的所有格子染色(黑←→白 翻转)。
操作结束后,询问 Q 个格子的颜色(黑或白)。
提示: 用「行并查集」和「列并查集」分别维护,结合奇偶性(翻转次数的奇偶)跟踪颜色。
✅ 完整解答(简化版:只处理行翻转)
用带权并查集,dist[x] 记录奇偶性(0=未翻转,1=翻转过奇数次):
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 200005;
int pa[MAXN];
int flip[MAXN]; // flip[x] = x 相对于根的翻转次数(mod 2)
void init(int n) {
for (int i = 0; i <= n; i++) { pa[i] = i; flip[i] = 0; }
}
int find(int x) {
if (pa[x] == x) return x;
int root = find(pa[x]);
flip[x] ^= flip[pa[x]]; // 路径上翻转次数的奇偶叠加
pa[x] = root;
return root;
}
// 声明「x 和 y 属于同组,且翻转关系为 d(0=同色,1=不同色)」
void unite(int x, int y, int d) {
int rx = find(x), ry = find(y);
int fx = flip[x], fy = flip[y];
if (rx == ry) return;
pa[ry] = rx;
flip[ry] = d ^ fx ^ fy;
}
// 查询 x 的颜色相对于根的偏移
int query(int x) {
find(x);
return flip[x];
}
此模板适用于所有「奇偶关系」类型的种类并查集问题。
题目 8:标准并查集模板(Luogu P3367)
即标准并查集模板题:M 次操作,每次 1 X Y(合并 X 和 Y 所在的集合)或 2 X Y(查询 X 和 Y 是否在同一集合,输出 Y 或 N)。
✅ 完整解答
这是标准并查集裸题,直接套模板:
#include <bits/stdc++.h>
using namespace std;
struct DSU {
vector<int> pa, sz;
explicit DSU(int n) : pa(n + 1), sz(n + 1, 1) {
iota(pa.begin(), pa.end(), 0);
}
int find(int x) { return pa[x] == x ? x : pa[x] = find(pa[x]); }
void unite(int x, int y) {
x = find(x); y = find(y);
if (x == y) return;
if (sz[x] < sz[y]) swap(x, y);
pa[y] = x; sz[x] += sz[y];
}
bool connected(int x, int y) { return find(x) == find(y); }
};
int main() {
ios_base::sync_with_stdio(false); cin.tie(NULL);
int n, m;
cin >> n >> m;
DSU dsu(n);
while (m--) {
int op, x, y;
cin >> op >> x >> y;
if (op == 1) dsu.unite(x, y);
else cout << (dsu.connected(x, y) ? "Y" : "N") << "\n";
}
return 0;
}
🔴 挑战练习(9~10)
题目 9:食物链(NOIP 2001 P2024)
N 种动物,三类关系(A 吃 B,B 吃 C,C 吃 A 循环)。K 条信息,格式同 3.13.8 节。
求假话数量。
✅ 完整解答
直接使用 3.13.8 节的「种类并查集」代码,注意细节:
- 自己吃自己(x == y 且 type=2):假话
- 编号越界(x > n 或 y > n):假话
- 矛盾检测先于合并
// 直接使用 3.13.8 节代码即可
// 关键测试:
// N=100, K=7
// 1 101 1 → x=101 > N=100,假话,lies=1
// 2 1 2 → 声明 1 吃 2,无矛盾,合并
// 2 2 3 → 声明 2 吃 3,无矛盾,合并
// 2 3 1 → 1 吃 2 吃 3 吃 1 ← 合法循环
// 1 1 3 → 1 和 3 同类?但 1 吃 3,矛盾,lies=2
// 2 3 3 → x==y,自己吃自己,lies=3
// 1 1 2 → 1 和 2 同类?但 1 吃 2,矛盾,lies=4
// 输出:4
完整代码见 3.13.8 节,直接提交即可。
题目 10:可持久化并查集(综合应用)
给 N 个元素和 M 次操作,每次操作为「合并两个集合」或「回滚到历史的某一版本」。每次操作后回答若干查询(例如某两个元素在当前版本下是否连通)。
提示: 需要「可持久化并查集」——用按秩合并(不用路径压缩)+ 线段树维护历史版本的 pa[] 数组。
✅ 核心思路(框架代码)
为什么不能路径压缩? 路径压缩会改变树的结构,版本回滚后结构会乱。只用按秩合并(树高 O(log N)),每次 find 最多 O(log N) 步,可接受。
// 可持久化并查集框架(用持久化线段树维护 pa[])
// 每次 unite 操作只改动 2 个节点(rx 和 ry),
// 对线段树做单点修改,生成新版本
#include <bits/stdc++.h>
using namespace std;
const int MAXN = 100005;
const int MAXLOG = 20;
struct Node {
int left, right, val, rnk; // rnk = 树的秩
} tr[MAXN * MAXLOG * 4];
int root[MAXN], cnt = 0; // root[i] = 第 i 个版本的根节点
// 建初始线段树(所有节点 pa[i] = i)
int build(int l, int r) {
int node = ++cnt;
if (l == r) {
tr[node].val = l; // pa[l] = l(自己是根)
tr[node].rnk = 0;
return node;
}
int mid = (l + r) / 2;
tr[node].left = build(l, mid);
tr[node].right = build(mid + 1, r);
return node;
}
// 持久化单点修改 pa[pos] = new_val
int update(int prev, int l, int r, int pos, int new_val, int new_rnk = -1) {
int node = ++cnt;
tr[node] = tr[prev]; // 复制上一版本
if (l == r) {
tr[node].val = new_val;
if (new_rnk >= 0) tr[node].rnk = new_rnk;
return node;
}
int mid = (l + r) / 2;
if (pos <= mid)
tr[node].left = update(tr[prev].left, l, mid, pos, new_val, new_rnk);
else
tr[node].right = update(tr[prev].right, mid + 1, r, pos, new_val, new_rnk);
return node;
}
// 查询 pa[pos]
int query(int node, int l, int r, int pos) {
if (l == r) return tr[node].val;
int mid = (l + r) / 2;
if (pos <= mid) return query(tr[node].left, l, mid, pos);
return query(tr[node].right, mid + 1, r, pos);
}
int n;
// 在版本 ver 中查找 x 的根(不做路径压缩!)
int find(int ver, int x) {
int pa = query(root[ver], 1, n, x);
if (pa == x) return x;
return find(ver, pa);
}
int main() {
int m;
cin >> n >> m;
root[0] = build(1, n);
for (int i = 1; i <= m; i++) {
int op; cin >> op;
if (op == 1) {
// 合并
int x, y; cin >> x >> y;
int rx = find(i - 1, x), ry = find(i - 1, y);
// ... 按秩合并,生成 root[i]
} else if (op == 2) {
// 回滚到第 k 版本
int k; cin >> k;
root[i] = root[k];
} else {
// 查询
int x; cin >> x;
cout << find(i - 1, x) << "\n";
root[i] = root[i - 1];
}
}
return 0;
}
完整实现参考:Luogu P3402 可持久化并查集
💡 章节联系: 并查集是图论最基础的工具之一——判连通性(第 5.2 章)、Kruskal MST(第 8.1 章)都依赖并查集。带权并查集在 USACO Gold 和信奥中频繁出现,建议重点掌握。