文章目录
-
一、为什么需要并查集?------ 解决痛点,高效取舍
-
二、并查集核心原理------极简逻辑,一看就懂
-
三、并查集核心优化------路径压缩+按秩合并(面试必写)
-
四、C++面试版并查集(完整版,可直接手写)
-
五、面试高频考点解析(重中之重)
-
六、C++实战应用------面试真题解析(直接套用模板)
-
七、学习建议与总结(面试备考重点)
前言
在C++数据结构进阶学习中,有一类结构看似简单,却能高效解决"集合合并"与"元素归属查询"的核心问题------它就是并查集(Union-Find)。不同于二叉树的复杂遍历、哈希表的冲突处理,并查集的核心逻辑极简,却能在连通性问题、集合管理等场景中发挥巨大作用,是面试中高频考察的"性价比之王"。
本文专为C++学习者、面试备考者打造,全程贴合笔试面试场景,从"为什么需要并查集"出发,拆解核心原理、手写可直接复用的面试代码、讲解优化技巧,再到实战真题解析,帮你从"懂原理"到"能手写、会应用",彻底吃透并查集的所有高频考点。
适合人群:已掌握C++基础(vector、类与对象)、了解基本数据结构,想要进阶面试必备知识点,或需要解决连通性、集合合并问题的学习者。
一、为什么需要并查集?------ 解决痛点,高效取舍
在日常开发和算法题中,我们经常会遇到这类问题:
-
判断两个元素是否属于同一个集合(如:两个节点是否连通、两个学生是否在同一个班级);
-
将两个集合合并为一个集合(如:合并两个班级、连接两个网络节点);
-
统计集合的数量(如:岛屿数量、连通分量个数)。
如果用数组、链表或哈希表来实现这些操作,要么查询效率低(O(n)),要么合并操作繁琐,无法适配大规模数据(如十万、百万级元素)。而并查集的核心优势的就是:查询、合并操作均接近O(1)时间复杂度,且实现简单,手写代码量极少,是这类问题的最优解决方案。
举个通俗例子:假设我们有10个独立的人,一开始每个人都是自己的"小团体"(一个集合)。当两个人成为朋友,就将他们的团体合并;想知道两个人是不是朋友,只需查询他们是否属于同一个团体------这就是并查集的核心应用场景,简单且高效。
二、并查集核心原理------极简逻辑,一看就懂
并查集的核心思想是用树的结构表示集合,每个集合对应一棵树,树的根节点就是这个集合的"代表"(标识)。所有操作都围绕"找到代表"和"合并代表"展开,核心只有两个操作:Find(查找)和Union(合并)。
1. 核心概念拆解
-
集合的表示:用一个数组parent存储每个元素的"父节点",parent[i]表示元素i的父节点是谁;
-
根节点:当parent[i] == i时,说明i是这棵树的根节点(集合的代表),每个集合有且只有一个根节点;
-
Find(查找):找到元素x所在集合的根节点,判断两个元素是否属于同一个集合(根节点相同则属于同一集合);
-
Union(合并):找到两个元素x、y所在集合的根节点,将其中一个根节点的父节点指向另一个根节点,实现两个集合的合并。
2. 未优化的基础实现(理解核心)
先看最基础的并查集实现,不考虑优化,重点理解Find和Union的逻辑(面试中不要直接写这个版本,会有效率问题,但能帮你吃透原理):
cpp
#include <iostream>
#include <vector>
using namespace std;
// 基础版并查集(未优化,仅用于理解原理)
class UnionFindBasic {
private:
vector<int> parent; // 存储每个元素的父节点
public:
// 初始化:n个元素,每个元素自成一个集合(父节点指向自身)
UnionFindBasic(int n) {
parent.resize(n);
for (int i = 0; i < n; i++) {
parent[i] = i; // 根节点的父节点是自己
}
}
// Find操作:查找x的根节点(未优化,路径长)
int find(int x) {
// 递归/循环找到根节点(parent[x] == x)
while (parent[x] != x) {
x = parent[x]; // 向上追溯父节点
}
return x;
}
// Union操作:合并x和y所在的集合(未优化,可能导致树退化成链表)
void unite(int x, int y) {
int rootX = find(x); // 找到x的根节点
int rootY = find(y); // 找到y的根节点
if (rootX == rootY) return; // 已属于同一个集合,无需合并
// 直接将rootY的父节点指向rootX(随意合并,无优化)
parent[rootY] = rootX;
}
// 判断x和y是否属于同一个集合
bool isSameSet(int x, int y) {
return find(x) == find(y);
}
};
// 测试基础版
int main() {
UnionFindBasic uf(5); // 5个元素(0-4)
uf.unite(0, 1);
uf.unite(1, 2);
uf.unite(3, 4);
cout << "0和2是否连通:" << (uf.isSameSet(0, 2) ? "是" : "否") << endl; // 是
cout << "0和3是否连通:" << (uf.isSameSet(0, 3) ? "是" : "否") << endl; // 否
return 0;
}
3. 核心问题:未优化的并查集为什么效率低?
基础版并查集存在一个致命问题:合并时随意指向,会导致树退化成链表。比如连续合并0-1、1-2、2-3、3-4,树会变成一条单链(4→3→2→1→0),此时Find操作的时间复杂度会退化为O(n),失去高效优势。
因此,面试中必须掌握并查集的两个核心优化技巧------路径压缩 和按秩合并,这两个优化能让操作效率接近O(1),也是面试官重点考察的细节。
三、并查集核心优化------路径压缩+按秩合并(面试必写)
两个优化技巧无需复杂逻辑,只需在基础实现上稍作修改,就能大幅提升效率,也是面试手写并查集的"加分项",必须掌握。
1. 优化一:路径压缩(Find操作优化)
核心思想:在执行Find操作时,将路径上的所有节点直接指向根节点,减少后续Find操作的路径长度。相当于"扁平化"树的结构,让后续查询更快。
举个例子:原本Find(4)需要经过4→3→2→0(根节点),路径压缩后,4、3、2会直接指向0,下次查询时直接就能找到根节点。
实现方式(递归/循环均可,面试推荐递归,代码更简洁):
cpp
// 路径压缩优化后的Find操作(递归版,面试首选)
int find(int x) {
// 如果x不是根节点,递归找到根节点,并将x的父节点直接指向根节点
if (parent[x] != x) {
parent[x] = find(parent[x]); // 路径压缩核心代码
}
return parent[x];
}
2. 优化二:按秩合并(Union操作优化)
核心思想:合并两个集合时,将"秩"(树的高度)较小的树合并到"秩"较大的树的根节点下,避免树的高度过高,进一步防止树退化成链表。
补充:"秩"可以理解为树的高度(或深度),初始时每个元素的秩为1(自身为根节点,高度为1);当两个秩相等的树合并时,合并后的根节点秩加1。
实现方式:新增一个rank数组,存储每个根节点的秩,合并时判断秩的大小,选择最优合并方式。
四、C++面试版并查集(完整版,可直接手写)
结合路径压缩和按秩合并,这是面试中最标准、最高效的并查集实现,代码简洁、无冗余,可直接用于笔试手写、算法题解题,注释详细,面试官一看就懂。
cpp
#include <iostream>
#include <vector>
using namespace std;
// C++ 并查集(面试完整版,含路径压缩+按秩合并)
class UnionFind {
private:
vector<int> parent; // parent[i]:元素i的父节点
vector<int> rank; // rank[i]:元素i为根节点时,树的高度(秩)
public:
// 初始化:n个元素,每个元素自成一个集合
UnionFind(int n) {
parent.resize(n);
rank.resize(n, 1); // 初始秩为1,每个元素都是根节点
for (int i = 0; i < n; i++) {
parent[i] = i; // 父节点指向自身
}
}
// Find操作:查找x的根节点,同时进行路径压缩(递归版)
int find(int x) {
if (parent[x] != x) {
parent[x] = find(parent[x]); // 路径压缩:直接指向根节点
}
return parent[x];
}
// Union操作:合并x和y所在的集合,按秩合并
void unite(int x, int y) {
int rootX = find(x); // 找到x的根节点
int rootY = find(y); // 找到y的根节点
if (rootX == rootY) return; // 已在同一集合,直接返回
// 按秩合并:秩小的树合并到秩大的树下
if (rank[rootX] < rank[rootY]) {
parent[rootX] = rootY; // rootX的父节点设为rootY
} else {
parent[rootY] = rootX; // rootY的父节点设为rootX
// 若秩相等,合并后根节点的秩加1
if (rank[rootX] == rank[rootY]) {
rank[rootX]++;
}
}
}
// 判断x和y是否属于同一个集合(面试高频接口)
bool isSameSet(int x, int y) {
return find(x) == find(y);
}
// 可选接口:统计集合的数量(面试偶尔考察)
int getSetCount() {
int count = 0;
for (int i = 0; i < parent.size(); i++) {
if (parent[i] == i) { // 根节点的父节点是自身,统计根节点数量
count++;
}
}
return count;
}
};
// 测试完整版并查集
int main() {
UnionFind uf(5); // 5个元素(0-4)
uf.unite(0, 1);
uf.unite(1, 2);
uf.unite(3, 4);
cout << "0和2是否连通:" << (uf.isSameSet(0, 2) ? "是" : "否") << endl; // 是
cout << "0和3是否连通:" << (uf.isSameSet(0, 3) ? "是" : "否") << endl; // 否
cout << "当前集合数量:" << uf.getSetCount() << endl; // 2({0,1,2}, {3,4})
// 合并两个集合
uf.unite(2, 3);
cout << "合并后0和3是否连通:" << (uf.isSameSet(0, 3) ? "是" : "否") << endl; // 是
cout << "合并后集合数量:" << uf.getSetCount() << endl; // 1
return 0;
}
五、面试高频考点解析(重中之重)
并查集的面试考察集中在"原理理解""代码手写""场景应用"三个层面,以下是高频考点,必须逐一掌握,避免踩坑。
1. 核心考点(必背)
-
考点1:并查集的核心操作(Find和Union)的实现,尤其是路径压缩和按秩合并的原理和代码(面试必写,少一个优化都会丢分);
-
考点2:路径压缩和按秩合并的作用------避免树退化成链表,将操作时间复杂度优化到接近O(1);
-
考点3:并查集的适用场景(连通性问题、集合合并、统计集合数量);
-
考点4:并查集的时间复杂度------Find和Union操作均为近乎O(1)(严格来说是O(α(n)),α是阿克曼函数,增长极慢,可视为常数)。
2. 面试避坑点(丢分重灾区)
-
坑1:忘记写路径压缩或按秩合并,只写基础版并查集(面试官会认为你没吃透并查集的优化逻辑);
-
坑2:Union操作中,直接合并元素x和y,而不是合并它们的根节点(会导致集合结构混乱,查询结果错误);
-
坑3:初始化时,parent数组未赋值为自身(根节点判断失效,整个并查集无法正常工作);
-
坑4:混淆"秩"的含义,合并时搞反秩的大小(应该将秩小的合并到秩大的树下,反之会导致树变高)。
3. 手写代码注意事项(面试细节)
-
代码简洁:面试手写无需冗余注释,关键步骤(路径压缩、按秩合并)保留即可;
-
边界处理:初始化时注意n的范围(避免数组越界),可在构造函数中增加n>0的判断(可选,体现代码健壮性);
-
接口完整:至少实现find、unite、isSameSet三个核心接口,getSetCount可作为补充(提升印象分);
-
命名规范:变量名清晰(parent、rank),避免晦涩命名(如用f代替find)。
六、C++实战应用------面试真题解析(直接套用模板)
并查集的面试题大多是"模板题",只需套用上面的面试版代码,稍作修改就能解决。以下是3道高频真题,覆盖不同应用场景,帮你巩固实战能力。
真题1:岛屿数量(LeetCode 200)
题目描述:给定一个由'1'(陆地)和'0'(水)组成的二维网格,计算岛屿的数量。岛屿由相邻的陆地连接形成,相邻指上下左右四个方向。
解题思路:将每个陆地('1')视为一个元素,相邻的陆地合并到同一个集合,最终集合的数量就是岛屿的数量。
cpp
#include <iostream>
#include <vector>
using namespace std;
class UnionFind {
// 直接复用面试版并查集代码
private:
vector<int> parent;
vector<int> rank;
public:
UnionFind(int n) {
parent.resize(n);
rank.resize(n, 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 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;
if (rank[rootX] == rank[rootY]) rank[rootX]++;
}
}
int getSetCount() {
int count = 0;
for (int i = 0; i < parent.size(); i++) {
if (parent[i] == i) count++;
}
return count;
}
};
// 岛屿数量解题函数
int numIslands(vector<vector<char>>& grid) {
if (grid.empty() || grid[0].empty()) return 0;
int m = grid.size(), n = grid[0].size();
UnionFind uf(m * n);
int waterCount = 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') {
waterCount++;
continue;
}
// 遍历四个方向的相邻陆地,合并
for (auto& dir : dirs) {
int x = i + dir[0];
int y = j + dir[1];
if (x >= 0 && x < m && y >= 0 && y < n && grid[x][y] == '1') {
// 将二维坐标转换为一维索引(关键:i*n + j)
uf.unite(i * n + j, x * n + y);
}
}
}
}
// 总元素数是m*n,减去水的数量,就是陆地的集合数(岛屿数)
return uf.getSetCount() - 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;
}
真题2:朋友圈(LeetCode 547)
题目描述:有n个学生,每个学生都有自己的朋友圈,朋友圈是相互的。给定一个n x n的矩阵isConnected,isConnected[i][j] = 1表示第i和j个学生是朋友,0表示不是。求朋友圈的数量。
解题思路:直接套用并查集,学生为元素,朋友关系为合并条件,最终集合数量就是朋友圈数量。
cpp
// 复用面试版并查集,此处省略(与真题1一致)
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.getSetCount();
}
真题3:冗余连接(LeetCode 684)
题目描述:在一棵无向树中,添加一条边后会出现一个环,找出这条导致环的冗余边。
解题思路:遍历每条边,合并两个节点,若合并前两个节点已属于同一个集合,说明这条边是冗余边(会形成环)。
cpp
// 复用面试版并查集,此处省略
vector<int> findRedundantConnection(vector<vector<int>>& edges) {
int n = edges.size();
UnionFind uf(n + 1); // 节点编号从1开始,所以size为n+1
for (auto& edge : edges) {
int x = edge[0], y = edge[1];
if (uf.isSameSet(x, y)) {
return edge; // 已在同一集合,这条边是冗余边
}
uf.unite(x, y);
}
return {};
}
七、学习建议与总结(面试备考重点)
并查集是C++数据结构进阶中最"好拿分"的知识点------逻辑简单、代码量少、应用场景固定,只要掌握好以下几点,就能轻松应对面试:
-
- 先理解原理,再手写代码:不要死记硬背代码,先搞懂"树表示集合""Find找根""Union合并根"的逻辑,再动手写优化版代码,每天默写1遍,直到脱稿;
-
- 重点掌握优化技巧:路径压缩和按秩合并是面试核心,必须能说清原理、写对代码,这是区分"会用"和"吃透"的关键;
-
- 多刷真题巩固:LeetCode 200(岛屿数量)、547(朋友圈)、684(冗余连接)是必刷真题,套用模板后,重点练习"坐标转换""边界处理"等细节;
-
- 灵活适配场景:记住并查集的核心应用------连通性、集合合并、集合统计,遇到这类问题,优先考虑并查集,比DFS/BFS更高效、代码更简洁。
最后总结:并查集的核心不是"复杂的代码",而是"高效合并与查询"的设计思想。面试中,只要能流畅手写优化版并查集代码,结合真题场景灵活应用,就能轻松拿下并查集相关的所有考点。
小练习:用并查集解决"省份数量"问题(LeetCode 547的变种),试试能不能直接套用模板写出代码?欢迎在评论区交流你的思路~