【数据结构】【学习笔记】并查集
⚙️定义
并查集是一种树形数据结构,用于处理一些不相交集合的合并及查询问题。
它主要用于维护若干个不相交的集合,并支持合并与查询两种核心操作。
在并查集中,每个集合由一棵树来代表,树的根节点作为该集合的代表元。底层通常只需要用一个数组 parent 记录每个节点的父节点。
-
初始化:把每个点所在集合初始化为其自身。
-
查找:查找某个元素所在的集合(即找祖宗,一直向上追溯到树根)。如果两个元素的根节点相同,说明它们属于同一个集合。
注意:查找时,采用递归的方法找其祖宗,祖宗集合号等于本身时停止。在回归时,把当前节点到祖宗路径上的所有节点统一为祖宗的集合号。
-
合并:将两个元素所在的集合合并为一个。只需找到各自的根节点,然后将其中一个根节点指向另一个根节点即可。
注意:合并时只需要把一个元素的祖宗集合号改为另一个元素的祖宗集合号。只改祖宗即可。
🔎特性
- 只合并,不拆分
没有查询 “删除元素 / 拆分集合” 操作 - 每个元素属于且仅属于一个集合
集合之间互不相交;一个元素不能同时在两个集合。 - 集合用代表元(根结点)标识
同一个集合内所有元素拥有同一个根;判断两个元素是否连通:find(a) == find(b)。
🤓实现
class UnionFind {
private parent: number[]; // parent[i] 表示 i 的父节点,根节点满足 parent[i] === i
private rank: number[]; // 树的高度(秩)
constructor(n: number) {
this.parent = Array.from({ length: n }, (_, i) => i);
this.rank = new Array(n).fill(1);
}
// 查找
find(x: number): number {
if (this.parent[x] !== x) { // 当祖宗不是自己的下标,说明自己不是祖宗,继续寻找
this.parent[x] = this.find(this.parent[x]); // 递归进行路径压缩
}
return this.parent[x];
}
// 合并
unite(x: number, y: number): boolean {
let rootX = this.find(x);
let rootY = this.find(y);
if (rootX === rootY) return false; // 相等时不用合并
// 如果 rootX 较矮,交换两者,确保 rootX 总是代表较高的那棵树
if (this.rank[rootX] < this.rank[rootY]) {
[rootX, rootY] = [rootY, rootX];
}
// 统一把较矮的 rootY 挂到较高的 rootX 下
this.parent[rootY] = rootX;
// 如果两树原本一样高,挂载后 rootX 树高加 1
if (this.rank[rootX] === this.rank[rootY]) this.rank[rootX]++;
return true;
}
// 根节点是否相同
connected(x: number, y: number): boolean {
return this.find(x) === this.find(y);
}
}
🎨思维导图
❔题目
1. 最长连续序列
解题思路
本题有两种思路:并查集和哈希集合。
-
并查集
维护两个哈希表,parent记录数字对应的根节点,size记录以该数字为根的连通块节点总数。
遍历每个数字 \(num\),若 \(num + 1\) 存在则执行合并,并在合并过程中更新最大连通块的节点数(即最长连续序列长度)。 -
哈希集合(推荐)
先将数组存入哈希集合。
遍历集合中的每个数字,若 \(num - 1\) 不存在,说明 \(num\) 是某个连续序列的起点,
此时通过while循环向后不断查找 \(num + 1\) 并统计长度,更新最长长度;
若 \(num - 1\) 存在则直接跳过(剪枝)。
实现
TypeScript是并查集法,C#和C++为哈希集合法(注意:循环必须遍历去重后的 Set,避免重复元素导致时间复杂度退化)。
function longestConsecutive(nums: number[]): number {
const n = nums.length;
if(n <= 1) return n;
const parent = new Map<number, number>(); // 记录数字和对应的根节点
const size = new Map<number, number>(); // 节点总数
let maxSize = 1;
function find(x: number): number{
if(parent.get(x) !== x){
parent.set(x, find(parent.get(x))!); // 递归压缩路径
}
return parent.get(x)!;
}
function union(x: number, y: number){
if(!parent.has(y)) return; // 只有当y(即x+1存在时才合并)
let rootX = find(x);
let rootY = find(y);
if(rootX !== rootY){
parent.set(rootX, rootY); // 将rootX挂在rootY下
const newSize = size.get(rootX)! + size.get(rootY)!; // 更新当前节点总数
size.set(rootY, newSize);
maxSize = Math.max(maxSize, newSize);
}
}
// 初始化每个独立数字的集合
for(const num of nums){
if(!parent.has(num)){
parent.set(num, num);
size.set(num, 1);
}
}
// 遍历所有数字,尝试与num+1合并
for(const num of nums){
union(num, num + 1);
}
// 合并后,最大的节点总数就是最长连续序列
return maxSize;
};
public class Solution {
public int LongestConsecutive(int[] nums) {
if(nums == null || nums.Length == 0) return 0;
var set = nums.ToHashSet();
int maxLen = 0;
foreach(int num in set){
if(!set.Contains(num-1)){
int len = 1;
int cur = num;
while(set.Contains(++cur)){
len++;
}
maxLen = Math.Max(maxLen, len);
}
}
return maxLen;
}
}
class Solution {
public:
int longestConsecutive(vector<int>& nums) {
std::unordered_set<int> st(nums.begin(), nums.end());
int res = 0;
for(auto num : st){
if(!st.contains(num-1)){
int cur = num, len = 1;
while(st.contains(++cur)){
len++;
}
res = max(res, len);
}
}
return res;
}
};
复杂度分析
并查集
- 时间复杂度: \(O(Nα(N))\)
其中 α(N) 为阿克曼函数的反函数,实际可近似看作 O(N)。 - 空间复杂度: \(O(N)\)
哈希集合
- 时间复杂度: \(O(N)\)
建立哈希集合:\(O(N)\)
每个数字最多被连续序列扫描一次:\(O(N)\) - 空间复杂度: \(O(N)\)
2. 按公因数计算最大组件大小
解题思路
根据题目,可以逆转为,使用并查集,将每个数字与它所有大于1并小于等于其本身的因数合并;(这里不是质因数)
将数字与其因数连接后,如果两个数字的根节点相同,说明它们通过公因数形成了同一个连通分量,因此属于同一个组件。
所以需要找到最大的数,来创建从1~maxVal的数组,进行合并。
注意:并查集中的节点不仅包含 nums 中的数字,还包含因数,因此不能直接使用集合大小作为答案,必须最后只统计 nums 中数字的数量。
实现
function largestComponentSize(nums: number[]): number {
const n = nums.length;
if(n <= 1) return n;
// 反过来,将每一个数字与其所有因数合并
const maxVal = Math.max(...nums);
// 初始化所有的根节点都为自己
const parent = Array<number>(maxVal+1);
for(let i = 0; i <= maxVal; i++){
parent[i] = i;
}
function find(x: number): number{
if(parent[x] !== x){
parent[x] =find(parent[x]);
}
return parent[x];
}
function union(x: number, y: number){
let rootX = find(x);
let rootY = find(y);
if(rootX !== rootY){
parent[rootX] = rootY;
}
}
// 将每个数字与它的所有大于1的因数合并
for(const num of nums){
for(let i = 2; i * i <= num; i++){
if(num % i === 0){
union(num, i);
union(num, Math.floor(num / i));
}
}
}
// 统计每个连通块里实际属于nums的数字个数
const count = new Map<number, number>();
let maxLen = 0;
for(const num of nums){
// root 相同,就说明它们在同一条连通路径上,属于同一个组件
const root = find(num);
const cnt = (count.get(root) || 0) + 1;
count.set(root, cnt);
maxLen = Math.max(maxLen, cnt);
}
return maxLen;
};
public class Solution {
public int LargestComponentSize(int[] nums) {
int maxVal = 0;
foreach(int num in nums){
if(num > maxVal) maxVal = num;
}
int[] parent = new int[maxVal+1];
for(int i = 0; i <= maxVal; i++) parent[i] = i;
int Find(int x){
if(parent[x] != x){
parent[x] = Find(parent[x]);
}
return parent[x];
}
void Union(int x, int y){
int rootX = Find(x);
int rootY = Find(y);
if(rootX != rootY){
parent[rootX] = rootY;
}
}
foreach(int num in nums){
for(int i = 2; i * i <= num; i++){
if(num % i == 0){
Union(num, i);
Union(num, num / i);
}
}
}
var count = new Dictionary<int, int>();
int maxlen = 0;
foreach(int num in nums){
int root = Find(num);
if(!count.ContainsKey(root)) count[root] = 0;
count[root]++;
maxlen = Math.Max(maxlen, count[root]);
}
return maxlen;
}
}
class Solution {
public:
int largestComponentSize(vector<int>& nums) {
int maxVal = *max_element(nums.begin(), nums.end());
vector<int> parent(maxVal+1);
iota(parent.begin(), parent.end(), 0);
auto find = [&](auto& self, int x)->int{
if(parent[x] != x){
parent[x] = self(self, parent[x]);
}
return parent[x];
};
auto unite = [&](int x, int y){
int rootX = find(find, x);
int rootY = find(find, y);
if(rootX != rootY){
parent[rootX] = rootY;
}
};
for(auto num : nums){
for(int i = 2; i*i <= num; i++){
if(num % i == 0){
unite(num, i);
unite(num, num /i);
}
}
}
std::unordered_map<int, int> count;
int maxLen = 0;
for(auto num : nums){
int root = find(find, num);
maxLen = max(maxLen, ++count[root]);
}
return maxLen;
}
};
复杂度分析
并查集
- 时间复杂度:\(O(N \sqrt{M})\)
其中 \(M = \max(nums)\)。每个数字找因数需要 \(O(\sqrt{nums[i]})\),。 - 空间复杂度:\(O(N + M)\)
其中 \(M = \max(nums)\)。因为 parent 数组的大小取决于数组中的最大值 \(M\)。
3. 处理含限制条件的好友请求
解题思路
-
初始思路
先用二重数组把restrictions分解成,第一层是人,第二层是这个人不可以做朋友的人。然后用并查集,有requests的就进行合并,但是假如根在rest里面,就请求失败。成功合并的就请求成功。 -
优化
“不能成为朋友”的本质是:若将 \(u\) 和 \(v\) 的朋友圈合并,不能导致任何限制对 \([x, y]\) 中的 \(x\) 和 \(y\) 落入同一个朋友圈。
由于数据范围较小(\(N \le 1000\),限制和请求数量都在 \(1000\) 以内),处理每个请求 \([u, v]\) 时只需执行三步:- 查根节点:求出 \(rootU = find(u)\) 和 \(rootV = find(v)\)。
- 已是朋友:若 \(rootU == rootV\),直接成功返回 true。
- 尝试合并(冲突校验):
- 若 \(rootU \neq rootV\),遍历 restrictions 中的每一对 \([x, y]\):分别求出 \(rootX = find(x)\) 和 \(rootY = find(y)\)。
- 如果 \((rootX == rootU \text{ 且 } rootY == rootV)\) 或者 \((rootX == rootV \text{ 且 } rootY == rootU)\),说明合并后 \(x\) 和 \(y\) 将变成朋友,触发冲突,该请求失败,返回 false(绝对不执行 union)。
- 如果遍历完所有限制均未冲突,说明安全,执行 union(rootU, rootV) 并返回 true。
实现
function friendRequests(n: number, restrictions: number[][], requests: number[][]): boolean[] {
const parent = new Int32Array(n);
for (let i = 0; i < n; i++) parent[i] = i;
function find(i: number): number {
return parent[i] === i ? i : (parent[i] = find(parent[i]));
}
const res: boolean[] = [];
for (const [u, v] of requests) {
const rootU = find(u);
const rootV = find(v);
// 假如根相同,说明已经在一条路上了,是朋友了
if (rootU === rootV) {
res.push(true);
continue;
}
// 检查是否触犯任何一条限制
let canMerge = true;
for (const [x, y] of restrictions) {
const rootX = find(x);
const rootY = find(y);
// 若 x 和 y 的根节点正好分别是 rootU 和 rootV,说明合并会导致 x 和 y 连通
if ((rootX === rootU && rootY === rootV) || (rootX === rootV && rootY === rootU)) {
canMerge = false;
break;
}
}
if (canMerge) {
parent[rootU] = rootV; // 校验通过,执行合并
res.push(true);
} else {
res.push(false); // 冲突,拒绝请求
}
}
return res;
};
public class Solution {
public bool[] FriendRequests(int n, int[][] restrictions, int[][] requests) {
int[] parent = new int[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];
}
int len = requests.Length;
bool[] res = new bool[len];
for(int i = 0; i < len; i++){
int u = requests[i][0], v = requests[i][1];
int rootU = Find(u), rootV = Find(v);
if(rootU == rootV){
res[i] = true;
continue;
}
bool canFriend = true;
foreach(var cur in restrictions){
int x = cur[0], y = cur[1];
int rootX = Find(x), rootY = Find(y);
if((rootX == rootU && rootY == rootV) || (rootX == rootV && rootY == rootU)){
canFriend = false;
break;
}
}
if(canFriend){
parent[rootU] = rootV;
res[i] = true;
}
}
return res;
}
}
class Solution {
public:
vector<bool> friendRequests(int n, vector<vector<int>>& restrictions, vector<vector<int>>& requests) {
vector<int> parent(n);
iota(parent.begin(), parent.end(), 0);
auto find = [&](auto& self, int i) -> int {
return parent[i] == i ? i : parent[i] = self(self, parent[i]);
};
vector<bool> res;
for (const auto& req : requests) {
int rootU = find(find, req[0]);
int rootV = find(find, req[1]);
if (rootU == rootV) {
res.push_back(true);
continue;
}
bool canMerge = true;
for (const auto& resPair : restrictions) {
int rootX = find(find, resPair[0]);
int rootY = find(find, resPair[1]);
if ((rootX == rootU && rootY == rootV) || (rootX == rootV && rootY == rootU)) {
canMerge = false;
break;
}
}
if (canMerge) {
parent[rootU] = rootV;
res.push_back(true);
} else {
res.push_back(false);
}
}
return res;
}
};
复杂度分析
- 时间复杂度: \(O(M \cdot R \cdot \alpha(N))\)
其中 \(M\) 为 requests 长度,\(R\) 为 restrictions 长度,\(\alpha\) 为反阿克曼函数。
每次处理请求时,都需要遍历一遍包含 \(R\) 条记录的限制数组;而请求总共有 \(M\) 个,因此总计算量约为 \(M \times R\)。 - 空间复杂度: \(O(N)\)
并查集只需要维护每个人的父节点。
引用
[1] 力扣探索模式
[2] 陈小玉. 趣学数据结构(第2版)[M]. 微信读书版
注:本文为个人学习与刷题笔记,部分文本结构与排版格式由 AI 辅助整理。

浙公网安备 33010602011771号