并查集:从入门到精通
什么是并查集?
并查集是一种树形数据结构,用于处理不相交集合的合并与查询问题。它的核心思想非常朴素:每个集合用一棵树表示,树根作为集合的代表元素。
生活中的例子
想象一个社交网络:
- 起初,每个人都自成一个群体
- 当两个人成为朋友,他们所属的群体就合并了
- 朋友的朋友也是朋友,所以群体具有传递性
- 我们要能快速判断两个人是否在同一群体
这就是并查集的典型应用场景。
核心操作
并查集主要支持两个操作:
- 查找(Find):确定元素属于哪个集合,即找到树的根节点
- 合并(Union):将两个集合合并为一个集合
基础实现
1. 朴素实现
最简单的想法是用数组 parent[i] 记录每个元素的父节点:
每个元素独立成集,父节点指向自己,秩(树高度)初始为 1。

查找(Find):找根节点(含路径压缩)
普通查找 :沿父节点向上寻根

cpp
class DisjointSet {
private:
vector<int> parent;
public:
// 初始化:每个元素自成一个集合
DisjointSet(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 unionSet(int x, int y) {
int rootX = find(x);
int rootY = find(y);
if (rootX != rootY) {
parent[rootX] = rootY;
}
}
// 判断是否在同一集合
bool isConnected(int x, int y) {
return find(x) == find(y);
}
};
问题:这种实现在最坏情况下会退化成链,查找复杂度 O(n)。
2. 路径压缩优化
在查找过程中,将路径上的所有节点直接连接到根节点:

cpp
int find(int x) {
if (parent[x] != x) {
parent[x] = find(parent[x]); // 递归向上查找并压缩路径
}
return parent[x];
}
效果展示:
查找前:1 → 2 → 3 → 4(根)
查找(1)后:1 → 4
2 → 4
3 → 4
3. 按秩合并优化
合并时,将深度较小的树合并到深度较大的树上:

cpp
class DisjointSet {
private:
vector<int> parent;
vector<int> rank; // 记录树的高度(秩)
public:
DisjointSet(int n) {
parent.resize(n);
rank.resize(n, 0);
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 unionSet(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)) 的时间复杂度(α 是反阿克曼函数,增长极慢):
cpp
#include <iostream>
#include <vector>
using namespace std;
class UnionFind {
private:
vector<int> parent;
vector<int> rank;
int count; // 连通分量数量
public:
UnionFind(int n) : count(n) {
parent.resize(n);
rank.resize(n, 0);
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 unite(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]++;
}
count--; // 合并后连通分量减少
}
// 判断是否连通
bool connected(int x, int y) {
return find(x) == find(y);
}
// 获取连通分量数量
int getCount() {
return count;
}
// 打印当前状态(调试用)
void printStatus() {
cout << "Parent array: ";
for (int i = 0; i < parent.size(); i++) {
cout << parent[i] << " ";
}
cout << endl;
cout << "Rank array: ";
for (int i = 0; i < rank.size(); i++) {
cout << rank[i] << " ";
}
cout << endl;
}
};
经典应用示例
1. 判断图中是否有环
cpp
bool hasCycle(int n, vector<pair<int, int>>& edges) {
UnionFind uf(n);
for (auto& edge : edges) {
int u = edge.first;
int v = edge.second;
if (uf.connected(u, v)) {
return true; // 已在同一集合,再加这条边就会形成环
}
uf.unite(u, v);
}
return false;
}
2. 朋友圈问题
cpp
int findCircleNum(vector<vector<int>>& M) {
int n = M.size();
UnionFind uf(n);
for (int i = 0; i < n; i++) {
for (int j = i + 1; j < n; j++) {
if (M[i][j] == 1) {
uf.unite(i, j);
}
}
}
return uf.getCount(); // 返回朋友圈数量
}
复杂度分析
| 操作 | 时间复杂度 | 空间复杂度 |
|---|---|---|
| 初始化 | O(n) | O(n) |
| 查找 | O(α(n)) ≈ O(1) | O(1) |
| 合并 | O(α(n)) ≈ O(1) | O(1) |
| 判断连通 | O(α(n)) ≈ O(1) | O(1) |
其中 α(n) 是反阿克曼函数,对于任何实际输入都可以视为常数。
进阶技巧
1. 带权并查集
维护节点到父节点的权值关系:
cpp
class WeightedUnionFind {
private:
vector<int> parent;
vector<int> weight; // 到父节点的权值
public:
WeightedUnionFind(int n) {
parent.resize(n);
weight.resize(n, 0);
for (int i = 0; i < n; i++) parent[i] = i;
}
int find(int x) {
if (parent[x] != x) {
int root = find(parent[x]);
weight[x] += weight[parent[x]];
parent[x] = root;
}
return parent[x];
}
void unite(int x, int y, int value) {
int rootX = find(x);
int rootY = find(y);
if (rootX != rootY) {
parent[rootX] = rootY;
weight[rootX] = weight[y] - weight[x] + value;
}
}
};
2. 可撤销并查集
支持撤销最近的合并操作(需按秩合并,不用路径压缩):
cpp
class UndoableUnionFind {
private:
vector<int> parent, rank;
stack<tuple<int, int, bool>> history; // 记录修改信息
public:
void unite(int x, int y) {
int rootX = find(x), rootY = find(y);
if (rootX == rootY) {
history.push({-1, -1, false});
return;
}
if (rank[rootX] < rank[rootY]) swap(rootX, rootY);
history.push({rootY, parent[rootY], rank[rootX] == rank[rootY]});
parent[rootY] = rootX;
if (rank[rootX] == rank[rootY]) rank[rootX]++;
}
void undo() {
auto [y, originalParent, rankChanged] = history.top();
history.pop();
if (y == -1) return;
int x = parent[y];
parent[y] = originalParent;
if (rankChanged) rank[x]--;
}
};
总结
并查集虽然简单,但极其精妙。记住三个核心要点:
- 路径压缩让查找变快
- 按秩合并防止树退化
- 两者结合达到近乎常数的时间复杂度
这个数据结构虽然代码量少,但在图论、动态连通性等领域有着广泛应用。掌握了它,你就多了一个处理集合合并问题的利器!
并查集的刷题应用场景全解析
识别并查集题目的特征
遇到以下特征时,考虑使用并查集:
- "是否连通"、"是否在同一集合" - 最直接的信号
- "合并"、"连接"、"组队" - 涉及动态合并操作
- 传递关系 - 如 a 和 b 有关系,b 和 c 有关系,问 a 和 c
- 动态添加边 - 边逐步添加,询问连通性
- 需要 O(1) 或近似 O(1) 查询连通性
- "最少操作次数让图连通" - 连通分量数 - 1
解题模板
cpp
// 标准解题步骤
void solve() {
// 1. 初始化并查集
UnionFind uf(n);
// 2. 遍历数据,进行合并
for (...) {
if (需要合并的条件) {
uf.unite(x, y);
}
}
// 3. 查询结果
// - 连通分量数: uf.getCount()
// - 两点是否连通: uf.connected(x, y)
// - 每个点的根: uf.find(x)
}
并查集虽然代码简单,但应用场景非常广泛。我按照题型难度和考察频率,把常见应用分为以下几类:
一、基础连通性问题
1. 图的连通分量计数
LeetCode 547. 省份数量
cpp
int findCircleNum(vector<vector<int>>& isConnected) {
int n = isConnected.size();
UnionFind uf(n);
for (int i = 0; i < n; i++) {
for (int j = i + 1; j < n; j++) {
if (isConnected[i][j] == 1) {
uf.unite(i, j);
}
}
}
return uf.getCount();
}
类似题目:
- LeetCode 323. 无向图中连通分量的数目
- LeetCode 1319. 连通网络的操作次数(需要的最少操作次数 = 连通分量数 - 1)
2. 判环问题
LeetCode 684. 冗余连接
找出图中冗余的边,使得图变成树:
cpp
vector<int> findRedundantConnection(vector<vector<int>>& edges) {
int n = edges.size();
UnionFind uf(n + 1);
for (auto& edge : edges) {
int u = edge[0], v = edge[1];
if (uf.connected(u, v)) {
return edge; // 已经连通,再加这条边就形成环
}
uf.unite(u, v);
}
return {};
}
类似题目:
- LeetCode 685. 冗余连接 II(有向图,需要分情况讨论)
- LeetCode 1559. 二维网格图中探测环
二、二维网格问题
3. 岛屿问题
LeetCode 200. 岛屿数量
cpp
int numIslands(vector<vector<char>>& grid) {
int m = grid.size(), n = grid[0].size();
UnionFind uf(m * n);
int water = 0;
int dirs[4][2] = {{1,0}, {-1,0}, {0,1}, {0,-1}};
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
if (grid[i][j] == '0') {
water++;
} else {
// 只合并右和下两个方向(避免重复)
for (auto& dir : dirs) {
int ni = i + dir[0], nj = j + dir[1];
if (ni >= 0 && ni < m && nj >= 0 && nj < n
&& grid[ni][nj] == '1') {
uf.unite(i * n + j, ni * n + nj);
}
}
}
}
}
return uf.getCount() - water; // 连通分量数减去水域数量
}
同类题:
- LeetCode 305. 岛屿数量 II(动态添加陆地)
- LeetCode 695. 岛屿的最大面积(需要维护集合大小)
- LeetCode 827. 最大人工岛
三、等式/不等式传递性问题
4. 变量关系判定
LeetCode 990. 等式方程的可满足性
cpp
bool equationsPossible(vector<string>& equations) {
UnionFind uf(26); // 26个小写字母
// 先处理所有等式
for (auto& eq : equations) {
if (eq[1] == '=') {
uf.unite(eq[0] - 'a', eq[3] - 'a');
}
}
// 再检查不等式
for (auto& eq : equations) {
if (eq[1] == '!') {
if (uf.connected(eq[0] - 'a', eq[3] - 'a')) {
return false; // 在同一集合中,矛盾
}
}
}
return true;
}
同类题:
- LeetCode 399. 除法求值(带权并查集)
- LeetCode 952. 按公因数计算最大组件大小
四、动态连通性问题
5. 连续区间合并
LeetCode 128. 最长连续序列
要求 O(n) 时间复杂度:
cpp
int longestConsecutive(vector<int>& nums) {
unordered_map<int, int> parent;
unordered_map<int, int> count; // 每个根节点的集合大小
// 初始化
for (int num : nums) {
parent[num] = num;
count[num] = 1;
}
for (int num : nums) {
// 如果相邻数字存在,合并
if (parent.count(num + 1)) {
unite(parent, count, num, num + 1);
}
if (parent.count(num - 1)) {
unite(parent, count, num - 1, num);
}
}
int maxLen = 0;
for (auto& [_, cnt] : count) {
maxLen = max(maxLen, cnt);
}
return maxLen;
}
// 带集合大小的合并函数
int find(unordered_map<int, int>& parent, int x) {
if (parent[x] != x) {
parent[x] = find(parent, parent[x]);
}
return parent[x];
}
void unite(unordered_map<int, int>& parent, unordered_map<int, int>& count,
int x, int y) {
int rootX = find(parent, x);
int rootY = find(parent, y);
if (rootX != rootY) {
parent[rootX] = rootY;
count[rootY] += count[rootX];
}
}
同类题:
- LeetCode 1562. 查找大小为 M 的最新分组
- LeetCode 1488. 避免洪水泛滥
五、带权并查集
6. 有相对关系的问题
LeetCode 399. 除法求值
cpp
class WeightedUnionFind {
vector<int> parent;
vector<double> weight; // weight[i] = i / parent[i]
public:
WeightedUnionFind(int n) {
parent.resize(n);
weight.resize(n, 1.0); // 初始化为 1
for (int i = 0; i < n; i++) parent[i] = i;
}
int find(int x) {
if (parent[x] != x) {
int origin = parent[x];
parent[x] = find(parent[x]);
weight[x] *= weight[origin]; // 路径压缩时更新权重
}
return parent[x];
}
void unite(int x, int y, double value) {
// value = x / y
int rootX = find(x), rootY = find(y);
if (rootX != rootY) {
parent[rootX] = rootY;
// weight[rootX] = (weight[y] * value) / weight[x]
weight[rootX] = weight[y] * value / weight[x];
}
}
double query(int x, int y) {
int rootX = find(x), rootY = find(y);
if (rootX != rootY) return -1.0;
return weight[x] / weight[y]; // x/y = (x/root) / (y/root)
}
};
同类题:
- LeetCode 1631. 最小体力消耗路径(也可以用二分+Dijkstra)
- 食物链问题(经典信息学竞赛题)
六、离线查询处理
7. 边权值限制的连通性查询
LeetCode 1697. 检查边长度限制的路径是否存在
cpp
vector<bool> distanceLimitedPathsExist(int n, vector<vector<int>>& edgeList,
vector<vector<int>>& queries) {
// 按边权重排序
sort(edgeList.begin(), edgeList.end(),
[](auto& a, auto& b) { return a[2] < b[2]; });
// 查询按限制排序(但需要保留原始索引)
vector<int> idx(queries.size());
iota(idx.begin(), idx.end(), 0);
sort(idx.begin(), idx.end(),
[&](int i, int j) { return queries[i][2] < queries[j][2]; });
UnionFind uf(n);
vector<bool> ans(queries.size());
int edgeIdx = 0;
for (int i : idx) {
int u = queries[i][0], v = queries[i][1], limit = queries[i][2];
// 添加所有小于限制的边
while (edgeIdx < edgeList.size() && edgeList[edgeIdx][2] < limit) {
uf.unite(edgeList[edgeIdx][0], edgeList[edgeIdx][1]);
edgeIdx++;
}
ans[i] = uf.connected(u, v);
}
return ans;
}
七、字符串/数组问题
8. 相似字符串组
LeetCode 839. 相似字符串组
cpp
int numSimilarGroups(vector<string>& strs) {
int n = strs.size();
UnionFind uf(n);
// 两两比较,如果是相似字符串则合并
for (int i = 0; i < n; i++) {
for (int j = i + 1; j < n; j++) {
if (isSimilar(strs[i], strs[j])) {
uf.unite(i, j);
}
}
}
return uf.getCount();
}
bool isSimilar(string& a, string& b) {
int diff = 0;
for (int i = 0; i < a.size(); i++) {
if (a[i] != b[i]) diff++;
if (diff > 2) return false;
}
return true;
}