文章目录
- 并查集简介
-
-
- 一、并查集(Union-Find)核心概念
- [二、C/C++ 完整实现](#二、C/C++ 完整实现)
- 三、代码关键部分解释
- 四、时间复杂度
- 五、并查集的典型应用
- 总结
-
- 并查集:从原理到实战的完整解析
并查集简介
一、并查集(Union-Find)核心概念
并查集是一种高效处理动态连通性问题的数据结构,主要用于解决"元素分组"和"判断两个元素是否属于同一组"的问题。它的核心操作只有两个:
- 查找(Find):找到某个元素所属的"根节点"(代表该组的唯一标识);
- 合并(Union):将两个元素所在的组合并为一个组。
为了提升效率,通常会加入两个优化:
- 路径压缩:在查找时,将路径上的所有节点直接指向根节点,减少后续查找的层数;
- 按秩(或大小)合并:合并时,将秩(树的高度)较小的树合并到秩较大的树下,避免树退化成链表。
二、C/C++ 完整实现
下面是一个完整的、带优化的并查集实现,包含核心操作和示例测试:
cpp
#include <iostream>
#include <vector>
using namespace std;
// 并查集类
class UnionFind {
private:
vector<int> parent; // 存储每个节点的父节点
vector<int> rank; // 存储每个根节点对应的树的秩(高度)
public:
// 构造函数:初始化并查集,每个节点的父节点是自己,秩为1
UnionFind(int n) {
parent.resize(n);
rank.resize(n, 1);
for (int i = 0; i < n; ++i) {
parent[i] = i;
}
}
// 查找:找到节点x的根节点,同时进行路径压缩
int find(int x) {
// 递归版路径压缩(简洁)
if (parent[x] != x) {
parent[x] = find(parent[x]); // 把x的父节点直接指向根节点
}
return parent[x];
// 迭代版路径压缩(避免递归栈溢出,适合大数据量)
// int root = x;
// while (parent[root] != root) {
// root = parent[root];
// }
// // 路径压缩:把x到根节点的所有节点直接指向根
// while (parent[x] != root) {
// int next = parent[x];
// parent[x] = root;
// x = next;
// }
// return root;
}
// 合并:将x和y所在的集合合并
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]++;
}
}
}
// 判断:x和y是否在同一个集合
bool isConnected(int x, int y) {
return find(x) == find(y);
}
};
// 测试示例
int main() {
// 初始化10个节点(0-9),每个节点独立为一个集合
UnionFind uf(10);
// 合并操作
uf.unite(0, 1);
uf.unite(1, 2);
uf.unite(3, 4);
uf.unite(4, 5);
uf.unite(5, 6);
uf.unite(2, 6); // 合并0-2和3-6两个集合
// 测试连通性
cout << "0和6是否连通:" << (uf.isConnected(0, 6) ? "是" : "否") << endl; // 是
cout << "0和7是否连通:" << (uf.isConnected(0, 7) ? "是" : "否") << endl; // 否
cout << "3和5是否连通:" << (uf.isConnected(3, 5) ? "是" : "否") << endl; // 是
// 合并7和0
uf.unite(7, 0);
cout << "7和6是否连通:" << (uf.isConnected(7, 6) ? "是" : "否") << endl; // 是
return 0;
}
三、代码关键部分解释
-
成员变量:
parent数组:parent[i]表示节点i的父节点,初始时parent[i] = i(每个节点自己是根);rank数组:rank[i]表示以i为根的树的高度,初始时都是1(单个节点的树高度为1)。
-
find 函数(查找):
- 核心逻辑是找根节点,同时通过
parent[x] = find(parent[x])实现路径压缩,让路径上的所有节点直接指向根,后续查找的时间复杂度接近O(1)。 - 提供了递归和迭代两种实现:递归简洁,迭代适合大数据量(避免栈溢出)。
- 核心逻辑是找根节点,同时通过
-
unite 函数(合并):
- 先找到两个节点的根,若根相同则无需合并;
- 按秩合并:把矮树合并到高树下,避免树退化成链表,保证合并后的树高度尽可能小。
-
isConnected 函数(判断连通性):
- 只需判断两个节点的根是否相同,相同则属于同一集合。
四、时间复杂度
- 无优化的并查集:查找和合并的时间复杂度为O(n);
- 带路径压缩 + 按秩合并的并查集:均摊时间复杂度为O(α(n)),其中α(n)是阿克曼函数的反函数,增长极慢(n为10^600时,α(n)也仅为5),几乎可以认为是O(1)。
五、并查集的典型应用
- 解决图的连通分量问题(如:判断图中有多少个连通块);
- 处理动态连通性问题(如:网络节点连通、朋友圈问题);
- 克鲁斯卡尔(Kruskal)算法求最小生成树(判断边是否会形成环);
- 解决分组问题(如:LeetCode 547. 省份数量、LeetCode 684. 冗余连接)。
总结
- 并查集的核心是查找(带路径压缩) 和合并(按秩/大小),这两个优化是提升效率的关键;
- 它的核心价值是高效处理动态连通性问题,时间复杂度接近常数;
- C/C++ 实现时,常用数组存储父节点和秩,递归/迭代均可实现路径压缩,优先选迭代(避免栈溢出)。
并查集:从原理到实战的完整解析
并查集(Union-Find Set)是一种专为处理动态连通性问题设计的高效数据结构,核心解决"元素分组"和"判断元素归属"问题。本文将从原理、实现到实战应用,全方位拆解并查集的核心逻辑,并结合C++代码完成落地。
一、并查集核心原理
在实际场景中,我们常需要将n个独立元素划分成若干不相交的集合,并支持"合并集合"和"查询元素归属"操作,这正是并查集的核心应用场景。
1.1 核心场景示例
以公司校招10名学生的社交关系为例:
- 初始状态:10名学生(编号0-9)互不相识,各自为独立集合;
- 分组阶段:西安小分队{0,6,7,8}、成都小分队{1,4,9}、武汉小分队{2,3,5}形成3个独立朋友圈;
- 合并阶段:西安8号和成都1号相识,两个小分队合并为1个大集合。
1.2 数组存储规则(核心)
并查集通常用数组存储集合关系,规则如下:
- 数组下标 = 元素编号(如下标0对应学生0);
- 数组值为负数:表示该下标是集合根节点 ,数值绝对值 = 集合元素个数(如
ufs[0] = -4表示0是根,集合有4个元素); - 数组值为非负数:表示该元素的父节点下标 (如
ufs[6] = 0表示6的父节点是0)。
1.3 核心操作定义
- 查找(Find):找元素的根节点(沿父节点向上,直到值为负数);
- 合并(Union):将两个集合合并为一个(把一个集合的根指向另一个集合的根);
- 计数(Count):统计集合数量(数组中负数的个数);
- 判同集:判断两个元素是否同属一个集合(根节点是否相同)。
二、并查集完整实现(C++)
基于上述原理,实现通用的并查集类,包含核心操作:
cpp
#include <vector>
#include <iostream>
using namespace std;
// 通用并查集类
class UnionFindSet {
public:
// 构造函数:初始化每个元素为独立集合(值为-1,代表根且集合大小为1)
UnionFindSet(size_t size) : _ufs(size, -1) {}
// 查找:返回元素index所在集合的根节点
int FindRoot(int index) {
// 边界检查:防止下标越界
if (index < 0 || index >= _ufs.size()) {
return -1;
}
// 沿父节点向上找根(值为负数时停止)
while (_ufs[index] >= 0) {
index = _ufs[index];
}
return index;
}
// 合并:将x1和x2所在集合合并,成功返回true,失败(同集)返回false
bool Union(int x1, int x2) {
int root1 = FindRoot(x1);
int root2 = FindRoot(x2);
// 同属一个集合,无需合并
if (root1 == root2 || root1 == -1 || root2 == -1) {
return false;
}
// 合并规则:将两个集合大小相加,把root2的根指向root1
_ufs[root1] += _ufs[root2]; // root1集合大小 = 原大小 + root2集合大小
_ufs[root2] = root1; // root2不再是根,父节点改为root1
return true;
}
// 统计:返回当前集合的数量(数组中负数的个数)
size_t Count() const {
size_t count = 0;
for (auto e : _ufs) {
if (e < 0) {
++count;
}
}
return count;
}
// 辅助打印:查看并查集数组状态(便于调试)
void Print() const {
for (size_t i = 0; i < _ufs.size(); ++i) {
cout << "ufs[" << i << "] = " << _ufs[i] << " ";
}
cout << endl;
}
private:
vector<int> _ufs; // 存储集合关系的核心数组
};
// 测试示例
int main() {
UnionFindSet ufs(10); // 初始化10个元素(0-9)
// 模拟西安/成都/武汉小分队分组
ufs.Union(0, 6);
ufs.Union(0, 7);
ufs.Union(0, 8); // 西安小分队:0为根,size=4
ufs.Union(1, 4);
ufs.Union(1, 9); // 成都小分队:1为根,size=3
ufs.Union(2, 3);
ufs.Union(2, 5); // 武汉小分队:2为根,size=3
cout << "初始集合数量:" << ufs.Count() << endl; // 输出3
ufs.Print(); // 打印数组状态
// 合并西安和成都小分队(8和1)
ufs.Union(8, 1);
cout << "合并后集合数量:" << ufs.Count() << endl; // 输出2
ufs.Print(); // 打印合并后状态
return 0;
}
代码关键说明
- 初始化:构造函数将数组所有元素设为-1,代表每个元素独立成集合(大小为1);
- FindRoot:通过循环向上找根,时间复杂度取决于树的高度(未优化版);
- Union:先找两个元素的根,不同根则合并(小集合合并到大集合);
- Count:遍历数组统计负数个数,直接反映当前集合数量。
三、并查集经典应用
3.1 应用1:省份数量(LeetCode 547)
问题描述
给定n x n的矩阵isConnected,isConnected[i][j] = 1表示第i个城市和第j个城市相连,求省份数量(相连的城市为一个省份)。
解题代码
cpp
#include <vector>
using namespace std;
class Solution {
public:
int findCircleNum(vector<vector<int>>& isConnected) {
int n = isConnected.size();
// 初始化并查集数组:每个城市独立成省
vector<int> ufs(n, -1);
// 定义查找根节点的lambda函数(复用逻辑)
auto findRoot = [&ufs](int x) {
while (ufs[x] >= 0) {
x = ufs[x];
}
return x;
};
// 遍历矩阵,合并相连的城市
for (int i = 0; i < n; ++i) {
for (int j = 0; j < n; ++j) {
// 仅处理相连且i≠j的情况(避免重复合并)
if (isConnected[i][j] == 1 && i != j) {
int root1 = findRoot(i);
int root2 = findRoot(j);
if (root1 != root2) {
ufs[root1] += ufs[root2];
ufs[root2] = root1;
}
}
}
}
// 统计省份数量(集合数)
int provinceCount = 0;
for (int val : ufs) {
if (val < 0) {
++provinceCount;
}
}
return provinceCount;
}
};
核心思路
- 矩阵中
isConnected[i][j] = 1表示i和j连通,需合并两个城市的集合; - 最终数组中负数的个数即为省份数量;
- 用lambda函数封装
findRoot,简化代码复用。
3.2 应用2:等式方程的可满足性(LeetCode 990)
问题描述
给定由字符串组成的数组equations,每个字符串形如a==b或a!=b,判断所有等式是否能同时成立。
解题代码
cpp
#include <vector>
#include <string>
using namespace std;
class Solution {
public:
bool equationsPossible(vector<string>& equations) {
// 26个小写字母,初始化并查集
vector<int> ufs(26, -1);
// 查找根节点
auto findRoot = [&ufs](int x) {
while (ufs[x] >= 0) {
x = ufs[x];
}
return x;
};
// 第一步:合并所有"=="的变量(构建连通关系)
for (const string& eq : equations) {
if (eq[1] == '=') {
int a = eq[0] - 'a'; // 字符转下标(a=0, b=1...)
int b = eq[3] - 'a';
int rootA = findRoot(a);
int rootB = findRoot(b);
if (rootA != rootB) {
ufs[rootA] += ufs[rootB];
ufs[rootB] = rootA;
}
}
}
// 第二步:检查所有"!="的变量(验证是否冲突)
for (const string& eq : equations) {
if (eq[1] == '!') {
int a = eq[0] - 'a';
int b = eq[3] - 'a';
int rootA = findRoot(a);
int rootB = findRoot(b);
// 若"!="的两个变量同属一个集合,等式不成立
if (rootA == rootB) {
return false;
}
}
}
// 所有等式无冲突
return true;
}
};
核心思路
- 先处理所有
==:将相等的变量合并到同一集合; - 再处理所有
!=:若两个变量同属一个集合,说明矛盾,返回false; - 字符转下标:通过
eq[0] - 'a'将a-z映射为0-25,适配数组存储。
四、优化方向(进阶)
本文实现的是基础版并查集,实际应用中可通过以下优化降低时间复杂度:
- 路径压缩:查找时将路径上的节点直接指向根节点(使树扁平化);
- 按秩合并:合并时将小秩(树高度)集合合并到大秩集合下(避免树退化成链表)。
优化后并查集的均摊时间复杂度接近O(1),适合处理大数据量场景。
五、总结
- 并查集核心是"查找(Find)"和"合并(Union)",通过数组存储父节点/集合大小,规则简单且高效;
- 数组存储规则:负数=根节点(绝对值=集合大小),非负数=父节点下标;
- 典型应用:连通分量统计(省份数量)、等式验证(可满足性)、最小生成树(Kruskal算法)等。
并查集是算法面试中的高频考点,掌握其原理和实现后,能快速解决各类连通性问题。