并查集(Union-Find)是一种高效管理动态集合的数据结构,核心解决两个问题:「查询两个元素是否属于同一集合」和「合并两个集合」。它广泛应用于图论(如连通分量检测、最小生成树Kruskal算法)、社交网络(好友关系连通性)、集合分类等场景,其优化后的时间复杂度接近常数级,是算法面试中的高频考点。
一、并查集的核心概念
1. 基本定义
- 元素与集合:每个元素初始时属于一个独立的集合(仅包含自身),集合用「根节点」标识(根节点的父节点是自身)。
- 核心操作 :
find(x):查询元素x所在集合的根节点(判断两个元素是否同集,只需比较根节点是否相同)。union(x, y):将元素x和y所在的两个集合合并为一个集合。init(n):初始化n个独立集合(父节点数组、秩/大小数组初始化)。
- 设计思想:用「树结构」表示集合(每个集合是一棵多叉树),根节点是集合的唯一标识,通过父节点指针串联所有元素。
二、朴素并查集实现(无优化)
1. 数据结构设计
用两个数组存储核心信息(C++中常用vector实现动态大小):
parent[]:parent[i]表示元素i的父节点,初始时parent[i] = i(自环为根)。
2. 核心操作实现
(1)初始化
cpp
#include <vector>
using namespace std;
class UnionFind {
private:
vector<int> parent; // 父节点数组
public:
// 初始化n个独立集合(元素编号0~n-1)
UnionFind(int n) {
parent.resize(n);
for (int i = 0; i < n; ++i) {
parent[i] = i; // 每个元素的父节点是自身
}
}
};
(2)find操作(递归版)
递归查找根节点,直到找到parent[x] == x的节点:
cpp
int find(int x) {
if (parent[x] != x) { // 不是根节点,递归查找父节点
return find(parent[x]);
}
return x; // 返回根节点
}
(3)union操作(朴素版)
找到两个元素的根节点,将其中一个根节点的父节点指向另一个根节点:
cpp
void unite(int x, int y) {
int rootX = find(x);
int rootY = find(y);
if (rootX != rootY) { // 不同集合才合并
parent[rootY] = rootX; // 把rootY挂到rootX上
}
}
3. 朴素版本的问题
- find操作效率低 :树可能退化为链表(如依次合并1→2→3→4→...),此时
find(x)的时间复杂度为O(n)。 - union操作无策略:随机合并导致树的高度激增,进一步恶化查询效率。
例如,若合并顺序为unite(0,1)、unite(1,2)、unite(2,3)...,树会变成一条长链,查询末尾元素的根节点需遍历所有节点。
三、并查集的两大优化
为将时间复杂度优化到近似O(α(n))(α是阿克曼函数的反函数,n为元素个数时α(n)≤5,可视为常数),需实现以下两个优化:
1. 优化一:路径压缩(Path Compression)
原理
在find(x)查询时,将路径上所有节点的父节点直接指向根节点,扁平化树结构,减少后续查询的路径长度。
实现(循环版,避免递归栈溢出)
cpp
int find(int x) {
// 找到根节点
int root = x;
while (parent[root] != root) {
root = parent[root];
}
// 路径压缩:将x到根节点的所有节点直接挂到根上
while (parent[x] != root) {
int next = parent[x]; // 保存x的原父节点
parent[x] = root; // x直接指向根
x = next; // 处理下一个节点
}
return root;
}
效果
一次find(x)后,后续查询该路径上的任何节点,都能直接找到根节点,查询效率大幅提升。
2. 优化二:按秩合并(Union by Rank)/ 按大小合并(Union by Size)
原理
合并两个集合时,让"矮树"挂到"高树"的根上(按秩合并)或"小树"挂到"大树"的根上(按大小合并),避免树的高度无意义增长。
- 秩(Rank):表示树的高度上限(初始时每个节点的秩为1)。
- 大小(Size):表示集合的元素个数(初始时每个集合的大小为1)。
实现(按秩合并)
需新增rank[]数组存储每个根节点的秩:
cpp
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;
}
}
// 带路径压缩的find
int find(int x) {
if (parent[x] != x) {
parent[x] = find(parent[x]); // 递归版路径压缩(简洁,适合n较小场景)
}
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 {
parent[rootY] = rootX;
// 若秩相等,合并后根的秩+1
if (rank[rootX] == rank[rootY]) {
rank[rootX]++;
}
}
}
};
实现(按大小合并)
需新增size[]数组存储每个集合的元素个数:
cpp
class UnionFind {
private:
vector<int> parent;
vector<int> size; // 大小数组:根节点的size表示集合元素个数
public:
UnionFind(int n) {
parent.resize(n);
size.resize(n, 1); // 初始大小为1
for (int i = 0; i < n; ++i) {
parent[i] = i;
}
}
int find(int x) {
// 循环版路径压缩(适合n较大,避免栈溢出)
int root = x;
while (parent[root] != root) root = parent[root];
while (parent[x] != root) {
int next = parent[x];
parent[x] = root;
x = next;
}
return root;
}
void unite(int x, int y) {
int rootX = find(x);
int rootY = find(y);
if (rootX == rootY) return;
// 小数组合并到大树(减少高度增长)
if (size[rootX] < size[rootY]) {
parent[rootX] = rootY;
size[rootY] += size[rootX]; // 更新大树的大小
} else {
parent[rootY] = rootX;
size[rootX] += size[rootY];
}
}
};
选择建议
- 按秩合并和按大小合并效果类似,均能保证树的高度不超过
logn。 - 按大小合并更直观(可直接获取集合元素个数),按秩合并在某些场景(如带权并查集)更灵活。
四、并查集的核心功能扩展
除了基础的find和unite,实际应用中常需以下扩展功能:
1. 查询集合元素个数
基于按大小合并的size[]数组,查询元素x所在集合的大小:
cpp
int getSize(int x) {
int root = find(x);
return size[root];
}
2. 判断两个元素是否连通
cpp
bool isConnected(int x, int y) {
return find(x) == find(y);
}
3. 统计连通分量个数
遍历所有元素,统计根节点的数量(parent[i] == i的节点数):
cpp
int countComponents() {
int cnt = 0;
for (int i = 0; i < parent.size(); ++i) {
if (parent[i] == i) {
cnt++;
}
}
return cnt;
}
五、并查集的经典应用场景(附C++示例)
场景1:无向图的环检测
问题描述
给定一个无向图,判断图中是否存在环。
原理
遍历所有边(u, v):
- 若
u和v已连通(find(u) == find(v)),则添加该边会形成环。 - 若未连通,则合并
u和v所在集合。
C++实现
cpp
#include <vector>
#include <iostream>
using namespace std;
class UnionFind {
private:
vector<int> parent;
vector<int> size;
public:
UnionFind(int n) {
parent.resize(n);
size.resize(n, 1);
for (int i = 0; i < n; ++i) parent[i] = i;
}
int find(int x) {
int root = x;
while (parent[root] != root) root = parent[root];
while (parent[x] != root) {
int next = parent[x];
parent[x] = root;
x = next;
}
return root;
}
void unite(int x, int y) {
int rootX = find(x), rootY = find(y);
if (rootX == rootY) return;
if (size[rootX] < size[rootY]) {
parent[rootX] = rootY;
size[rootY] += size[rootX];
} else {
parent[rootY] = rootX;
size[rootX] += size[rootY];
}
}
bool isConnected(int x, int y) { return find(x) == find(y); }
};
// 判断无向图是否有环(节点数n,边集edges)
bool hasCycle(int n, vector<vector<int>>& edges) {
UnionFind uf(n);
for (auto& edge : edges) {
int u = edge[0], v = edge[1];
if (uf.isConnected(u, v)) {
return true; // 存在环
}
uf.unite(u, v);
}
return false;
}
int main() {
int n = 5;
vector<vector<int>> edges = {{0,1}, {1,2}, {2,3}, {3,1}}; // 存在环
cout << "是否有环:" << (hasCycle(n, edges) ? "是" : "否") << endl; // 输出:是
return 0;
}
场景2:连通分量计数(岛屿数量变种)
问题描述
给定一个m×n的网格,1表示陆地,0表示水域,统计连通的陆地块数(上下左右为连通)。
原理
将每个陆地格子视为一个元素,编号为i×n + j(二维转一维),遍历每个陆地格子,与相邻陆地格子合并,最后统计连通分量个数。
C++实现
cpp
#include <vector>
#include <iostream>
using namespace std;
class UnionFind {
private:
vector<int> parent;
vector<int> size;
int count; // 连通分量个数
public:
UnionFind(int n) {
parent.resize(n);
size.resize(n, 1);
count = 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 unite(int x, int y) {
int rootX = find(x), rootY = find(y);
if (rootX == rootY) return;
if (size[rootX] < size[rootY]) {
parent[rootX] = rootY;
size[rootY] += size[rootX];
} else {
parent[rootY] = rootX;
size[rootX] += size[rootY];
}
count--; // 合并后分量数-1
}
int getCount() { return count; }
};
int numIslands(vector<vector<char>>& grid) {
if (grid.empty() || grid[0].empty()) return 0;
int m = grid.size(), n = grid[0].size();
int waterCount = 0; // 水域数量(无需参与合并)
UnionFind uf(m * n);
// 方向数组:上下左右
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') {
waterCount++;
continue;
}
// 遍历相邻格子
for (auto& dir : dirs) {
int x = i + dir[0], y = j + dir[1];
if (x >= 0 && x < m && y >=0 && y < n && grid[x][y] == '1') {
int idx1 = i * n + j;
int idx2 = x * n + y;
uf.unite(idx1, idx2);
}
}
}
}
// 总分量数 = 陆地分量数 = 总分量数 - 水域数
return uf.getCount() - waterCount;
}
int main() {
vector<vector<char>> grid = {
{'1','1','0','0','0'},
{'1','1','0','0','0'},
{'0','0','1','0','0'},
{'0','0','0','1','1'}
};
cout << "岛屿数量:" << numIslands(grid) << endl; // 输出:3
return 0;
}
场景3:Kruskal算法求最小生成树
原理
Kruskal算法的核心是"选边不形成环",利用并查集快速判断边的两个端点是否连通,优先选择权值最小的边合并,最终得到最小生成树。
六、带权并查集与扩展域并查集
1. 带权并查集(Weighted Union-Find)
核心思想
在parent[]基础上,新增weight[]数组,存储节点到父节点的权值 (如距离、偏移量、关系系数)。find(x)时不仅压缩路径,还需更新权值(维护路径上的权值累加);unite(x, y)时需根据已知关系计算根节点间的权值,确保权值一致性。
应用场景
- 维护节点间的相对关系(如"x比y大5""x是y的前驱")。
- 解决动态连通性中的权值传递问题(如LeetCode 399. 除法求值)。
C++简化示例(维护节点到根的距离)
cpp
class WeightedUnionFind {
private:
vector<int> parent;
vector<int> weight; // weight[x]:x到parent[x]的距离
public:
WeightedUnionFind(int n) {
parent.resize(n);
weight.resize(n, 0); // 初始时x到自身距离为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]; // 更新x到根的距离(累加原父节点到根的距离)
}
return parent[x];
}
// 合并x和y,已知x到y的距离为d(即x = y + d)
void unite(int x, int y, int d) {
int rootX = find(x), rootY = find(y);
if (rootX == rootY) return;
parent[rootX] = rootY;
// 计算rootX到rootY的距离:weight[x] + (rootX到rootY的距离) = d + weight[y]
weight[rootX] = d + weight[y] - weight[x];
}
// 查询x到y的距离(若不连通返回-1)
int getDistance(int x, int y) {
if (find(x) != find(y)) return -1;
return weight[x] - weight[y];
}
};
2. 扩展域并查集(Extended Union-Find)
核心思想
将每个元素的"状态"扩展为多个节点(如"x的朋友""x的敌人"),通过合并扩展节点间接维护元素间的复杂关系(如"敌人的敌人是朋友")。
应用场景
- 处理对立关系(如LeetCode 886. 可能的二分法)。
- 解决集合中的"黑白染色"问题。
核心思路
- 每个元素
x拆分为两个节点:x(表示"x属于A组")和x + n(表示"x属于B组")。 - 若
x和y是敌人,则合并x与y + n、x + n与y(x在A则y在B,x在B则y在A)。 - 若
x和y是朋友,则合并x与y、x + n与y + n(x和y同组)。
七、时间复杂度与空间复杂度分析
1. 时间复杂度
- 朴素版:
find和unite均为O(n)(树退化为链表)。 - 优化版(路径压缩+按秩/大小合并):单次操作时间复杂度为
O(α(n)),α是阿克曼函数的反函数,增长极慢(n≤1e6时α(n)=4),可视为O(1)。 - 带权/扩展域并查集:时间复杂度与优化版一致,仅增加常数级的权值计算/扩展节点操作。
2. 空间复杂度
- 基础版:
O(n)(parent、rank/size数组)。 - 带权版:
O(n)(新增weight数组)。 - 扩展域版:
O(kn)(k为扩展状态数,如敌人/朋友场景k=2)。
注意事项
1. 核心要点
- 并查集的灵魂是「路径压缩」和「按秩/大小合并」,缺一不可,否则效率会急剧下降。
- 优先使用循环版
find(避免递归栈溢出,尤其当n较大时)。 - 二维网格问题需将二维坐标转为一维编号(如
i×n + j),方便并查集管理。
2. 常见误区
- 合并时直接操作元素
x和y,而非它们的根节点(会导致合并失效)。 - 路径压缩时忘记更新带权并查集的权值(导致权值错误)。
- 扩展域并查集的状态拆分逻辑错误(如敌人关系合并方向颠倒)。
3. 适用场景总结
- 动态连通性查询(如好友关系、网络连通)。
- 集合合并与计数(如连通分量、岛屿数量)。
- 无向图环检测(如Kruskal算法)。
- 节点间关系维护(带权/扩展域并查集)。