C++ 并查集详解:从理论到实践
在计算机科学中,并查集(Disjoint Set Union,DSU) 是一种高效处理集合合并与查询问题的数据结构。它特别适合解决连通性问题,如网络连接状态、图的连通分量、最小生成树等。本文将深入解析 C++ 中的并查集实现、优化技术及典型应用。
1. 并查集的基本原理
并查集的核心操作有两个:
- 查找(Find):确定元素所属的集合(即找到根节点)。
- 合并(Union):将两个集合合并为一个。
其数据结构通常用数组表示,每个元素存储其父节点的索引。初始时,每个元素自成一个集合,根节点指向自己。
2. C++ 实现:从朴素到优化
2.1 朴素实现
最基础的并查集实现包含初始化、查找和合并三个方法:
class UnionFind {
private:
vector<int> parent; // 存储每个节点的父节点
public:
// 初始化:每个节点的父节点是自己
UnionFind(int n) {
parent.resize(n);
for (int i = 0; i < n; i++) {
parent[i] = i;
}
}
// 查找根节点
int find(int x) {
while (parent[x] != x) {
x = parent[x];
}
return x;
}
// 合并两个集合
void unionSets(int x, int y) {
int rootX = find(x);
int rootY = find(y);
if (rootX != rootY) {
parent[rootY] = rootX;
}
}
};
问题:朴素实现的查找操作时间复杂度为 O (h),其中 h 是树的高度。当树退化为链表时,性能会显著下降。
2.2 路径压缩优化
路径压缩是指在查找过程中,直接将节点连接到根节点,从而减少后续查询的时间:
class UnionFind {
private:
vector<int> parent;
public:
UnionFind(int n) {
parent.resize(n);
for (int i = 0; i < n; i++) {
parent[i] = i;
}
}
// 路径压缩:递归将节点直接连接到根节点
int find(int x) {
if (parent[x] != x) {
parent[x] = find(parent[x]); // 递归查找并压缩路径
}
return parent[x];
}
void unionSets(int x, int y) {
int rootX = find(x);
int rootY = find(y);
if (rootX != rootY) {
parent[rootY] = rootX;
}
}
};
优化效果:单次查找的平均时间复杂度接近 O (1)。
2.3 按秩合并(Union by Rank)
按秩合并是指在合并时,将较小的树连接到较大的树的根节点,避免树的高度过快增长:
class UnionFind {
private:
vector<int> parent;
vector<int> rank; // 存储树的高度或节点数
public:
UnionFind(int n) {
parent.resize(n);
rank.resize(n, 1); // 初始秩为1
for (int i = 0; i < n; i++) {
parent[i] = i;
}
}
int find(int x) {
if (parent[x] != x) {
parent[x] = find(parent[x]);
}
return parent[x];
}
void unionSets(int x, int y) {
int rootX = find(x);
int rootY = find(y);
if (rootX == rootY) return;
// 将秩较小的树连接到秩较大的树
if (rank[rootX] < rank[rootY]) {
parent[rootX] = rootY;
} else if (rank[rootX] > rank[rootY]) {
parent[rootY] = rootX;
} else {
// 秩相同时,任选一个作为根,并增加其秩
parent[rootY] = rootX;
rank[rootX]++;
}
}
};
优化效果:结合路径压缩和按秩合并后,每个操作的平均时间复杂度为 O (α(n)),其中 α 是阿克曼函数的反函数,几乎可视为常数。
3. 高级应用:带权并查集
在某些场景中,我们需要维护节点间的关系(如距离、方向等),此时可使用带权并查集:
class WeightedUnionFind {
private:
vector<int> parent;
vector<int> weight; // 存储节点到父节点的权值
public:
WeightedUnionFind(int n) {
parent.resize(n);
weight.resize(n, 0); // 初始权值为0
for (int i = 0; i < n; i++) {
parent[i] = i;
}
}
// 带权路径压缩
int find(int x) {
if (parent[x] != x) {
int origParent = parent[x];
parent[x] = find(parent[x]);
weight[x] += weight[origParent]; // 更新权值
}
return parent[x];
}
// 带权合并
void unionSets(int x, int y, int w) {
int rootX = find(x);
int rootY = find(y);
if (rootX == rootY) return;
parent[rootX] = rootY;
weight[rootX] = weight[y] + w - weight[x]; // 更新权值
}
};
应用场景:
食物链问题(判断物种间的捕食关系)
区间求和(维护区间关系)
4. 典型问题与解决方案
4.1 连通分量计数
统计无向图中的连通分量个数:
int countConnectedComponents(int n, vector<vector<int>>& edges) {
UnionFind uf(n);
for (auto& edge : edges) {
uf.unionSets(edge[0], edge[1]);
}
// 计算根节点个数
unordered_set<int> roots;
for (int i = 0; i < n; i++) {
roots.insert(uf.find(i));
}
return roots.size();
}
4.2 Kruskal 最小生成树算法
并查集可高效判断加入一条边是否会形成环:
int kruskal(int n, vector<vector<int>>& edges) {
// 按边权排序
sort(edges.begin(), edges.end(), [](const vector<int>& a, const vector<int>& b) {
return a[2] < b[2];
});
UnionFind uf(n);
int mstWeight = 0;
int edgeCount = 0;
for (auto& edge : edges) {
int u = edge[0], v = edge[1], w = edge[2];
if (uf.find(u) != uf.find(v)) {
uf.unionSets(u, v);
mstWeight += w;
edgeCount++;
if (edgeCount == n - 1) break; // MST有n-1条边
}
}
return mstWeight;
}
4.3 岛屿数量问题
计算二维网格中的连通岛屿数量:
int numIslands(vector<vector<char>>& grid) {
if (grid.empty()) return 0;
int rows = grid.size(), cols = grid[0].size();
UnionFind uf(rows * cols);
// 方向数组:右、下
vector<pair<int, int>> directions = {{0, 1}, {1, 0}};
for (int i = 0; i < rows; i++) {
for (int j = 0; j < cols; j++) {
if (grid[i][j] == '0') continue;
// 将二维坐标映射为一维
int id = i * cols + j;
// 检查右侧和下方的相邻节点
for (auto& dir : directions) {
int ni = i + dir.first, nj = j + dir.second;
if (ni < rows && nj < cols && grid[ni][nj] == '1') {
int neighborId = ni * cols + nj;
uf.unionSets(id, neighborId);
}
}
}
}
// 统计根节点个数(排除水域)
unordered_set<int> roots;
for (int i = 0; i < rows; i++) {
for (int j = 0; j < cols; j++) {
if (grid[i][j] == '1') {
roots.insert(uf.find(i * cols + j));
}
}
}
return roots.size();
}
5. 性能分析与最佳实践
时间复杂度:结合路径压缩和按秩合并后,每个操作的平均时间复杂度为 O (α(n)),其中 α(n) ≤ 4(对于实际应用中的 n)。
空间复杂度:O (n),用于存储父节点和秩。
最佳实践:
优先使用路径压缩和按秩合并的组合优化。
对于带权并查集,需特别注意权值的传递和更新逻辑。
在实际问题中,灵活设计节点的映射关系(如二维坐标转一维)。
6. 总结与扩展
并查集是解决连通性问题的高效工具,其核心在于路径压缩和按秩合并的优化。在 C++ 中,通过模板和类封装可轻松实现一个通用的并查集框架。
扩展思考:
如何实现可持久化并查集(支持查询历史版本)?
并查集与其他数据结构(如线段树、树状数组)的结合应用?
在分布式系统中,如何设计并查集算法?
通过深入理解并查集的原理和应用,我们可以高效解决诸如最小生成树、图的连通性、社交网络分析等实际问题。