1. 并查集基础回顾与竞赛应用场景
在算法竞赛中,并查集(Disjoint Set Union, DSU)是一种处理不相交集合合并与查询的高效数据结构。我第一次接触并查集是在准备区域赛时遇到的一道图论题,当时被它简洁优雅的实现和惊人的效率所震撼。
并查集的核心操作包含三个部分:
- 初始化:每个元素自成一个集合,父节点指向自己
- 查找(Find):确定元素所属集合的代表元(路径压缩优化)
- 合并(Union):将两个集合合并为一个集合(按秩合并优化)
cpp复制class DSU {
private:
vector<int> parent, rank;
public:
DSU(int n) : parent(n), rank(n, 0) {
iota(parent.begin(), parent.end(), 0);
}
int find(int x) {
return parent[x] == x ? x : parent[x] = find(parent[x]);
}
void unite(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]++;
}
}
};
在竞赛中,基础并查集常应用于:
- 动态连通性问题(如判断图中两个节点是否连通)
- 最小生成树算法(Kruskal算法中的集合合并)
- 离线处理问题(配合Tarjan算法处理LCA查询)
提示:路径压缩和按秩合并同时使用时,单次操作均摊时间复杂度为O(α(n)),其中α是反阿克曼函数,在人类可预见的范围内不超过4。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 带权并查集:关系维护的高级技巧
当问题需要维护元素间的相对关系而不仅仅是连通性时,基础并查集就显得力不从心。这时带权并查集就派上用场了——我在2021年ICPC区域赛就遇到过一道需要维护节点距离的题目,正是靠带权并查集解决的。
带权并查集的核心思想是在每个节点上维护一个到父节点的权值,在查找和合并时同步更新这些权值。具体实现需要修改find和union操作:
cpp复制class WeightedDSU {
private:
vector<int> parent, rank;
vector<int> weight; // 到父节点的权值
public:
WeightedDSU(int n) : parent(n), rank(n, 0), weight(n, 0) {
iota(parent.begin(), parent.end(), 0);
}
pair<int, int> find(int x) {
if(parent[x] != x) {
auto [root, w] = find(parent[x]);
parent[x] = root;
weight[x] += w;
}
return {parent[x], weight[x]};
}
bool unite(int x, int y, int w) {
auto [fx, wx] = find(x);
auto [fy, wy] = find(y);
if(fx == fy) return wx - wy == w; // 检查是否矛盾
if(rank[fx] < rank[fy]) {
parent[fx] = fy;
weight[fx] = w + wy - wx;
} else {
parent[fy] = fx;
weight[fy] = wx - w - wy;
if(rank[fx] == rank[fy]) rank[fx]++;
}
return true;
}
};
典型应用场景包括:
- 食物链问题:维护三种生物之间的捕食关系
- 等式方程的可满足性:处理类似x-y=3这样的约束条件
- 奇偶校验:判断01序列中某段区间1的个数奇偶性
注意:带权并查集的权值更新必须满足结合律,通常要求关系是线性的(如加法关系)。在竞赛中遇到环形关系判断时,带权并查集往往是解题关键。
3. 拓展域并查集:解决复杂关系问题的利器
当元素间的关系更加复杂(如敌对、友好等多重关系)时,拓展域并查集(又称种类并查集)就显示出其强大威力。我在刷POJ题库时,发现很多关于罪犯分监狱、朋友敌人判断的题目都可以用这个技巧解决。
拓展域的核心思想是将每个元素拆分成多个逻辑节点,通过在不同域中的连接表示复杂关系。最常见的实现方式是使用2倍或3倍大小的数组:
cpp复制class ExtendedDSU {
private:
DSU dsu;
int n;
public:
ExtendedDSU(int n) : dsu(2*n), n(n) {}
// 朋友关系:x和y属于同类
void make_friends(int x, int y) {
dsu.unite(x, y);
dsu.unite(x + n, y + n);
}
// 敌对关系:x和y属于不同类
void make_enemies(int x, int y) {
dsu.unite(x, y + n);
dsu.unite(x + n, y);
}
// 检查x和y是否是朋友
bool are_friends(int x, int y) {
return dsu.find(x) == dsu.find(y);
}
// 检查x和y是否是敌人
bool are_enemies(int x, int y) {
return dsu.find(x) == dsu.find(y + n);
}
};
实战应用案例:
- 二分图判断:通过敌对关系连接判断是否可以二分
- 罪犯分监狱问题:将不能关在一起的罪犯分配到不同监狱
- 逻辑推理题:处理"A说B是好人"这类命题的真假判断
在2022年CCPC网络赛中有一道关于网络节点信任关系的题目,就是典型的拓展域应用。题目要求判断某些节点能否同时为诚实节点,通过将每个节点拆分为"诚实"和"欺骗"两个域,可以高效解决问题。
4. 动态并查集:处理集合分裂与历史版本
传统并查集只支持集合合并,但在某些竞赛题目中(如Codeforces上的数据结构题),可能需要支持集合的分裂操作或查询历史版本。这时就需要更高级的动态并查集实现。
4.1 可撤销并查集
基于栈实现的回滚操作,可以在某些离线处理问题中发挥作用:
cpp复制class RollbackDSU {
private:
vector<int> parent, rank;
vector<tuple<int, int, int>> history;
public:
RollbackDSU(int n) : parent(n), rank(n, 0) {
iota(parent.begin(), parent.end(), 0);
}
int find(int x) {
return parent[x] == x ? x : find(parent[x]);
}
void unite(int x, int y) {
x = find(x); y = find(y);
if(x == y) return;
if(rank[x] < rank[y]) {
history.emplace_back(x, parent[x], rank[y]);
parent[x] = y;
} else {
history.emplace_back(y, parent[y], rank[x]);
parent[y] = x;
if(rank[x] == rank[y]) {
history.emplace_back(x, -1, rank[x]);
rank[x]++;
}
}
}
void rollback(int steps) {
while(steps--) {
auto [x, px, r] = history.back();
history.pop_back();
if(px == -1) {
rank[x] = r;
} else {
parent[x] = px;
rank[x] = r;
}
}
}
};
4.2 持久化并查集
对于需要查询历史版本的问题,可以采用基于树的持久化数据结构实现:
cpp复制class PersistentDSU {
private:
struct Node {
int parent, rank;
Node *left, *right;
Node(int p, int r) : parent(p), rank(r), left(nullptr), right(nullptr) {}
};
Node* build(int l, int r, const vector<int>& parents) {
if(l == r) return new Node(parents[l], 1);
int mid = (l + r) / 2;
Node* node = new Node(0, 0);
node->left = build(l, mid, parents);
node->right = build(mid+1, r, parents);
return node;
}
Node* update(Node* node, int l, int r, int idx, int p, int rank) {
if(l == r) return new Node(p, rank);
Node* newNode = new Node(0, 0);
int mid = (l + r) / 2;
if(idx <= mid) {
newNode->left = update(node->left, l, mid, idx, p, rank);
newNode->right = node->right;
} else {
newNode->left = node->left;
newNode->right = update(node->right, mid+1, r, idx, p, rank);
}
return newNode;
}
pair<Node*, int> find(Node* node, int l, int r, int idx) {
if(l == r) return {node, node->parent};
int mid = (l + r) / 2;
if(idx <= mid) return find(node->left, l, mid, idx);
else return find(node->right, mid+1, r, idx);
}
vector<Node*> versions;
int n;
public:
PersistentDSU(int n) : n(n) {
vector<int> parents(n);
iota(parents.begin(), parents.end(), 0);
versions.push_back(build(0, n-1, parents));
}
int find(int version, int x) {
auto [node, p] = find(versions[version], 0, n-1, x);
if(p == x) return x;
return find(version, p);
}
void unite(int version, int x, int y) {
x = find(version, x);
y = find(version, y);
if(x == y) {
versions.push_back(versions.back());
return;
}
auto [xNode, xParent] = find(versions[version], 0, n-1, x);
auto [yNode, yParent] = find(versions[version], 0, n-1, y);
Node* newVersion = versions[version];
if(xNode->rank < yNode->rank) {
newVersion = update(newVersion, 0, n-1, x, y, xNode->rank);
} else {
newVersion = update(newVersion, 0, n-1, y, x, yNode->rank);
if(xNode->rank == yNode->rank) {
newVersion = update(newVersion, 0, n-1, x, x, xNode->rank + 1);
}
}
versions.push_back(newVersion);
}
};
这些高级并查集结构在Codeforces和AtCoder的某些数据结构专场比赛中经常出现,特别是那些需要处理时间倒流或者多时间线查询的题目。
5. 并查集优化技巧与竞赛实战
在真正的算法竞赛中,仅仅知道并查集的实现原理是不够的。根据我的参赛经验,以下这些优化技巧和实战策略往往能决定比赛的成败:
5.1 内存访问优化
并查集的性能极度依赖内存访问速度。在ICPC等比赛中,使用简单的数组实现往往比面向对象的封装更快:
cpp复制int parent[MAXN], rank[MAXN];
void init(int n) {
for(int i = 0; i < n; ++i) parent[i] = i;
}
int find(int x) {
return parent[x] == x ? x : parent[x] = find(parent[x]);
}
void unite(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]++;
}
}
5.2 离线处理技巧
有些题目看似需要动态处理,但实际上可以通过离线处理转化为静态问题。例如,将删除操作逆序处理为添加操作:
cpp复制// 处理动态图连通性问题(有边删除操作)
void solve() {
vector<tuple<int, int, int>> queries; // 0-查询 1-加边 2-删边
// 读取所有查询
// 记录每条边的存活时间段
map<pair<int, int>, vector<int>> edge_times;
for(int i = 0; i < queries.size(); ++i) {
auto [type, x, y] = queries[i];
if(type == 1) edge_times[{x, y}].push_back(i);
else if(type == 2) edge_times[{x, y}].push_back(i);
}
// 建立时间线
vector<vector<pair<int, int>>> timeline(queries.size());
for(auto& [edge, times] : edge_times) {
for(int i = 0; i < times.size(); i += 2) {
int start = times[i];
int end = (i+1 < times.size()) ? times[i+1] : queries.size();
timeline[start].push_back(edge);
if(end < queries.size()) timeline[end].push_back(edge);
}
}
// 逆序处理
RollbackDSU dsu(n);
vector<int> ans;
for(int i = queries.size()-1; i >= 0; --i) {
for(auto& [x, y] : timeline[i]) {
dsu.unite(x, y);
}
auto [type, x, y] = queries[i];
if(type == 0) {
ans.push_back(dsu.find(x) == dsu.find(y));
}
}
reverse(ans.begin(), ans.end());
// 输出答案
}
5.3 并查集与其他算法的结合
在区域赛和ICPC总决赛中,并查集常常需要与其他算法结合使用:
- 与DFS/BFS结合:处理连通块大小、数量等问题
- 与线段树/树状数组结合:处理带权图的动态连通性问题
- 与Tarjan算法结合:处理离线LCA查询
- 与Kruskal算法结合:求解最小生成树
例如,在2023年ICPC亚洲区域赛某站有一道题,需要动态维护图中连通块的大小,并支持查询第k大连通块。这可以通过并查集配合一个动态统计的数据结构(如树状数组)来实现:
cpp复制class DynamicConnectivity {
private:
vector<int> parent, rank, size;
map<int, int> cnt; // 连通块大小的计数
int n;
void change_size(int s, int delta) {
cnt[s] += delta;
if(cnt[s] == 0) cnt.erase(s);
}
public:
DynamicConnectivity(int n) : parent(n), rank(n, 0), size(n, 1), n(n) {
iota(parent.begin(), parent.end(), 0);
cnt[n] = 1;
}
int find(int x) {
return parent[x] == x ? x : parent[x] = find(parent[x]);
}
void unite(int x, int y) {
x = find(x); y = find(y);
if(x == y) return;
change_size(size[x], -1);
change_size(size[y], -1);
if(rank[x] < rank[y]) {
parent[x] = y;
size[y] += size[x];
change_size(size[y], 1);
} else {
parent[y] = x;
size[x] += size[y];
change_size(size[x], 1);
if(rank[x] == rank[y]) rank[x]++;
}
}
int query_kth_largest(int k) {
int sum = 0;
for(auto it = cnt.rbegin(); it != cnt.rend(); ++it) {
sum += it->second;
if(sum >= k) return it->first;
}
return 0;
}
};
在实际比赛中,这类题目往往需要选手快速识别问题本质,并选择合适的数据结构组合。我的经验是:当题目涉及"动态连通性"、"关系传递"、"集合合并"等关键词时,首先考虑并查集及其变种是否适用。
