并查集(Disjoint Set Union,DSU)是一种处理非连通性问题的经典数据结构,特别适合解决元素分组和动态连通性问题。这个数据结构在解决"白雪皑皑"这类问题时表现出色,因为它能高效处理大规模集合的合并与查询操作。
并查集的核心操作包含三个关键部分:
在标准实现中,我们使用路径压缩和按秩合并两种优化策略:
cpp复制int parent[MAXN];
int rank[MAXN];
void init(int n) {
for (int i = 1; i <= n; ++i) {
parent[i] = i;
rank[i] = 0;
}
}
int find(int x) {
if (parent[x] != x) {
parent[x] = find(parent[x]); // 路径压缩
}
return parent[x];
}
void unionSet(int x, int y) {
x = find(x);
y = find(y);
if (x == y) return;
if (rank[x] < rank[y]) { // 按秩合并
parent[x] = y;
} else {
parent[y] = x;
if (rank[x] == rank[y]) rank[x]++;
}
}
注意:路径压缩和按秩合并同时使用时,按秩合并的"秩"已经不能准确反映树的深度,但仍然是一个有效的启发式策略。
"白雪皑皑"问题描述的是一个序列被反复染色,最终需要查询每个位置的颜色。这类问题通常具有以下特征:
并查集在此类问题中的独特优势体现在:
问题转化思路:
逆向处理是解决此类覆盖问题的关键技巧:
cpp复制struct Operation {
int l, r, c;
} ops[MAXM];
int color[MAXN];
DSU dsu(n);
for (int i = m; i >= 1; --i) {
int l = ops[i].l, r = ops[i].r, c = ops[i].c;
for (int pos = dsu.find(l); pos <= r; pos = dsu.find(pos)) {
color[pos] = c;
dsu.unionSet(pos, pos + 1);
}
}
在这种场景下,并查集的使用方式与常规不同:
find(x):返回≥x的第一个未染色位置unionSet(x, y):将x连接到y,表示x已被处理这种变种并查集被称为"跳跃指针"或"链表式并查集",其时间复杂度分析:
对于1e6规模的数据,需要考虑:
迭代式find实现:
cpp复制int find(int x) {
int root = x;
while (parent[root] != root) {
root = parent[root];
}
while (parent[x] != x) {
int next = parent[x];
parent[x] = root;
x = next;
}
return root;
}
以下是结合所有优化后的完整解决方案:
cpp复制#include <iostream>
#include <vector>
using namespace std;
const int MAXN = 1e6 + 5;
const int MAXM = 1e6 + 5;
class DSU {
private:
vector<int> parent;
public:
DSU(int n) {
parent.resize(n + 2); // 多开两个位置避免边界检查
for (int i = 1; i <= n + 1; ++i) {
parent[i] = i;
}
}
int find(int x) {
if (parent[x] != x) {
parent[x] = find(parent[x]);
}
return parent[x];
}
void unionSet(int x, int y) {
parent[find(x)] = find(y);
}
};
struct Operation {
int l, r, c;
} ops[MAXM];
int color[MAXN];
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n, m, p, q;
cin >> n >> m >> p >> q;
// 生成操作序列
for (int i = 1; i <= m; ++i) {
ops[i].l = (i * p + q) % n + 1;
ops[i].r = (i * q + p) % n + 1;
if (ops[i].l > ops[i].r) swap(ops[i].l, ops[i].r);
ops[i].c = i;
}
DSU dsu(n);
// 逆向处理操作
for (int i = m; i >= 1; --i) {
int l = ops[i].l, r = ops[i].r, c = ops[i].c;
for (int pos = dsu.find(l); pos <= r; pos = dsu.find(pos)) {
color[pos] = c;
dsu.unionSet(pos, pos + 1);
}
}
// 输出结果
for (int i = 1; i <= n; ++i) {
cout << color[i] << "\n";
}
return 0;
}
在不同规模数据下的性能表现:
| 数据规模(n,m) | 时间复杂度 | 实际运行时间(ms) | 内存使用(MB) |
|---|---|---|---|
| 1e5 | O(n α(n)) | 50-80 | 4-6 |
| 5e5 | O(n α(n)) | 200-300 | 15-20 |
| 1e6 | O(n α(n)) | 400-600 | 30-40 |
常见性能瓶颈及解决方案:
将一维思路扩展到二维:
支持两种操作:
统计每个位置被不同颜色覆盖的次数:
正向处理导致超时
cpp复制// 错误示范:正向处理会有O(mn)复杂度
for (int i = 1; i <= m; ++i) {
for (int j = ops[i].l; j <= ops[i].r; ++j) {
color[j] = ops[i].c;
}
}
并查集初始化不足
cpp复制// 错误示范:没有初始化到n+1会导致越界
DSU dsu(n);
for (int pos = dsu.find(l); pos <= r; pos = dsu.find(pos)) {
color[pos] = c;
dsu.unionSet(pos, pos); // 应该连接到pos+1
}
小数据验证:
性能分析工具:
对拍测试:
python复制# 生成随机测试用例
import random
n = 1000
m = 1000
print(n, m)
for _ in range(m):
l = random.randint(1, n)
r = random.randint(1, n)
if l > r: l, r = r, l
print(l, r)
线段树也可以解决此类问题,但实现更复杂:
cpp复制void update(int l, int r, int c, int node, int nl, int nr) {
if (l > nr || r < nl) return;
if (l <= nl && nr <= r) {
tree[node] = c;
return;
}
pushDown(node);
int mid = (nl + nr) / 2;
update(l, r, c, node*2, nl, mid);
update(l, r, c, node*2+1, mid+1, nr);
}
对比分析:
| 指标 | 并查集解法 | 线段树解法 |
|---|---|---|
| 时间复杂度 | O(n α(n)) | O(n log n) |
| 空间复杂度 | O(n) | O(n) |
| 代码复杂度 | 简单 | 较复杂 |
| 适用性 | 离线问题 | 在线问题 |
分块处理也是一种选择:
优势:
劣势:
在算法竞赛中处理此类问题的经验:
识别问题模式:
解题模板:
python复制def solve():
初始化并查集
for 操作 in 逆序操作序列:
l, r, c = 操作
pos = find(l)
while pos <= r:
染色(pos, c)
union(pos, pos+1)
pos = find(pos)
卡常技巧:
常见陷阱:
推荐学习路线:
基础学习:
专题训练:
进阶应用:
典型题目序列: