《重生我要成为并查集高手🍔🍔🍔》
并查集:快速查询和快速合并, 路径压缩, 按大小,高度,秩合并。 静态数组实现
😇前言
在数据的海洋中,有一种悄然流淌的力量,它无声地将孤立的元素连结成整体,化解纷繁的复杂与混乱。如同星辰之间看似遥远,实则早已彼此相连的轨道,它用简单的规则架起了信息流动的桥梁,带领我们在无形中寻找到隐藏的联系与秩序。
本篇向您介绍并查集这种数据结构, 它具有简洁优雅的代码实现。
前置知识: 推荐去了解一下并查集的概念和两种优化, 可以看一下我之前写的
数据结构并查集
编程语言:Java/C++
, 分别是java8和c++11。
参考:Oiki, 高水平玩家可以参考算法导论
,只需要对照我写的c++,java代码即可。
书籍参考:《程序员代码面试指南》
😛引入
并查集是一系列的集合。 它是一种以简洁优雅高效著称的数据结构。
在图论中,连通分量是指图中节点彼此连通且与其他节点没有连接的部分。并查集正是通过将这些连通分量划分成不同的集合,帮助我们快速找到两个节点是否属于同一连通分量。
并查集可以来解决图论中连通分量
问题。它将图论中一个复杂图划分为一个一个不相交的图。 具体特点是同一个连通分量的元素顶点是相互连通的, 但连通分量之间是不相交的。 因此, 并查集也被称为不相交集。
并查集是一种划分阵营的结构, 有关系的被划分到同一个集合。
并查集的两个核心api,查询和合并
。就如它的名字所说,"并查"集
。不相交集这个名字说明了集合与集合之间的状态是不相交的。 如何将集合之间联系起来, 就要用到合并操作了。 如何证明两个集合是不相交的, 就要通过查询这个api了。
以下是并查集的定义:
并查集 (不相交集) 是一种描述不相交集合的数据结构,即若一个问题涉及多个元素,它们可划归到不同集合,同属一个集合内的元素等价(,不同集合内的元素不等价。
🙄 岛屿问题
从实际的一道题出发理解并查集吧。
给你一个由 ‘1’(陆地)和 ‘0’(水)组成的的二维网格,请你计算网格中岛屿的数量。
岛屿总是被水包围,并且每座岛屿只能由水平方向和/或竖直方向上相邻的陆地连接形成。
此外,你可以假设该网格的四条边均被水包围。
输入:grid = [ ["1","1","1","1","0"], ["1","1","0","1","0"], ["1","1","0","0","0"], ["0","0","0","0","0"] ]
输出:1
在这个数据的世界里,每个元素就像是一个孤独的小岛,而并查集就像是一个连通这些小岛的桥梁。它通过一些简单的规则,帮助我们快速找出哪些岛屿是相连的,哪些是独立的。
并查集把元素当成一个个集合, 若两个集合之间是存在联系的就把它们合并在一起。例如, 本题是把1附近的1关联在一起, 因为它们属于同一个岛屿。
如何将它们关联在一起呢? 用指针或者引用,像链表一样把它们串起来。 这是链表的基本想法。
这种画图很像多个链表相交的情况; 或者说它是一种树结构, 最顶部的节点是根节点。
这种结构必然存在一个根节点, 这个根节点就是该集合的代表节点。 为了方便, 我们让这个根节点的指针指向它自己。
你似乎明白了如何区分并查集的两个集合了, 看给定元素的所在集合的代表元素是不是同一个。
示例二
输入:grid = [ ["1","1","0","0","0"], ["1","1","0","0","0"], ["0","0","1","0","0"], ["0","0","0","1","1"] ]
输出:3
后续,我们实现完所有并查集的核心功能然后解决此题。
🤩API实现
并查集的两个核心API和其它辅助API
int find(x)
:查询元素x所在集合的代表元素, 代表元素又称代表元, 该集合的根节点。
bool isSameSet(x,y)
:查询元素x与y是否隶属同一集合。
void build(n)
:初始化建立并查集, 写题的时候记得初始化!!!
void union(x,y)
: 合并x与y所在的集合(如果它们不在同一集合)。
初始化
选用什么容器呢? 单链表,哈希表, 数组?
作为一种算法题的模板, 要做到即写即用。
将所有n个抽象的元素进行编号1,2,3,4…n 。 其中抽象的对应可以用哈希表建立对象和编号的关系, 实际解题往往会之间给编号。
因此,最终选择简单的数组。 实际工程需求并查集用哈希表实现, 可以参考我数据结构篇的用哈希表实现的内容。
初始将各元素的父级指针指向它本身, 那么并查集内部各个集合的不相交状态就处理完毕。
const int MAX = 10001;//根据题目量确定。int father[MAX];//元素的编号->父亲(祖先)节点void build(int n){//初始时, 让元素父级指针指向它本身for(int i=1;i<=n;i++){father[i] = i;}
}
查询操作
前面的引入部分我们提过, 区分两个集合就是找到集合之间的根(祖先)节点, 通过一个辅助函数find
实现。
由于我们设定代表元素的父级指针指向它自己, 因此以i == father[i]
作为循环结束标志。
find
int find(int i){while(i != father[i]){i = father[i];}return i;
}
isSameSet
:查询并查集内部的两个集合是否为同一个。
isSameSet(x, y), x和y是并查集的两个元素, 我们的目的是确定两个元素所在的集合是否为同一个, 结果返回布尔值。
//实现逻辑:就是比较两个代表元素是否为同一个。
bool isSameSet(int x,int y){return find(x) == find(y);
}
合并操作
并查集的合并操作非常好理解, 想象链表之间如何合并的? 就是改变一下指针指向
。
两个原本不相交的集合=>合并为一个集合。 实现逻辑就是将一个集合代表元素的父级指针指向另外一个集合的根节点。
注意: 如果两个合并元素本身就在同一集合, 那么不执行合并操作。
算法流程:
- 查找x,y所在集合的根节点
- 若它们之的根节点不为同一个, 那么执行合并操作。
father[fy] = fx
void union(int x,int y){int fx = find(x);int fy = find(y);//若x,y不在同一集合, 那么进行合并if(fx != fy){father[fy] = fx;}
}
😉并查集极简版本实现
并查集的简化版本, 即压缩了代码之后, 只需要做扁平化(路径压缩)。
既然查询过程中, 我们只关心元素所在集合的代表元素(根节点), 那么father数组指向x编号节点的父级节点就显得很多余, 为什么不在查询过程中将father[x]指向这个集合的代表元素呢?
略微修改find函数, 改成递归写法, 递归调用将集合的根节点编号向下返回, 修改所有孩子节点的father指向, 使得满足上图的情况。 这样下次查询就不用爬那么高找根节点了。
int find(int i){if (i != father[i]) father[i] = find(father[i]);return father[i];
}
洛谷-并查集模板
注释由ChatGPT生成。
c++
代码
#include <bits/stdc++.h>
using namespace std;const int MAXN = 10001;
int parent[MAXN]; // parent[i] 表示节点 i 的父节点
int n, m; // n 是元素数量,m 是操作数量// 初始化并查集
void build(int n) {for (int i = 1; i <= n; i++) {parent[i] = i; // 每个元素的父节点初始化为它自己}
}// 查找操作,带路径压缩
int find(int i) {if (i != parent[i]) {parent[i] = find(parent[i]); // 路径压缩}return parent[i];
}// 合并操作,将 x 和 y 合并到同一个集合中
void unionSets(int x, int y) {parent[find(x)] = find(y); // 找到 x 和 y 的根节点,将 x 的根节点指向 y 的根节点
}int main() {cin >> n >> m; // 读取元素数 n 和操作数 mbuild(n); // 初始化并查集int z, x, y;for (int i = 0; i < m; i++) {cin >> z >> x >> y; // 读取操作类型和两个元素 x, yif (z == 1) {// 如果 z == 1,进行合并操作unionSets(x, y);} else if (z == 2) {// 如果 z == 2,查询是否在同一个集合中if (find(x) == find(y)) {cout << "Y" << endl; // 如果 x 和 y 在同一集合,输出 Y} else {cout << "N" << endl; // 否则输出 N}}}return 0;
}
Java
代码
java">import java.io.*;public class Main {public static int MAX = 10001;public static int[] father = new int[MAX];public static int n, m;static void build(int n) {for (int i = 1; i <= n; i++) {father[i] = i; // 每个元素的父节点初始化为它自己}}static int find(int i) {if (i != father[i]) {father[i] = find(father[i]); // 路径压缩}return father[i];}static void union(int x, int y) {father[find(x)] = find(y); // 合并操作}public static void main(String[] args) throws IOException {BufferedReader br = new BufferedReader(new InputStreamReader(System.in));StreamTokenizer in = new StreamTokenizer(br);PrintWriter out = new PrintWriter(new OutputStreamWriter(System.out));while (in.nextToken() != StreamTokenizer.TT_EOF) {n = (int) in.nval;in.nextToken();m = (int) in.nval;int x, y, z;build(n); // 初始化并查集for (int i = 0; i < m; i++) {in.nextToken();z = (int) in.nval;in.nextToken();x = (int) in.nval;in.nextToken();y = (int) in.nval;switch (z) {case 1:union(x, y); // 合并操作break;case 2:// 查询操作,输出 "Y" or "N"out.println(find(x) == find(y) ? "Y" : "N");break;}}}out.flush();out.close();br.close();}
}
🧐按秩合并优化
事实上, 并查集只需要做到路径压缩, 或者只做到路径压缩的极简优化就可以做算法题了。
这里在上面的路径压缩补充按秩合并的优化。
什么是按秩优化呢?
按秩合并是为了减少树的高度,防止树变得过于“高”,导致查询操作变慢。通过将较小的树合并到较大的树下,可以有效减小树的高度,从而提高操作效率。
实际实现中, 可以粗略用集合的大小(即节点数量)或者集合中树的高度来代替秩。~不过大小和高度实际对于路径压缩版本都不能准确描述。~
一种直观的感受, 合并过程中小集合挂大集合或者较矮的树挂高的树可以减少寻找代表元素的路径长度。
说明size版本
引入一个size数组, size数组的含义是以当前编号的节点为代表元素所在集合的大小,
#include<bits/stdc++.h>
#define MAXN 10001
using namespace std;int father[MAXN];
int size[MAXN];
int path[MAXN];void build(int n){for(int i=1;i<=n;i++){father[i] = i;size[i] = 1;}
}
显然, 初始化时每个编号的size应该为1。
你可能好奇这个path数组的作用, path数组是充当容器实现find函数的路径压缩, 你可以仍旧保留递归的写法。
int find(int i){int size = 0;while(i != father[i]){path[size++] = i;i = father[i];}while(size > 0){father[path[--size]] = i;}return i;
}
合并操作要按照小集合挂大集合.
若x,y的代表元素是同一个, 那么不进行合并。这条逻辑前面提过。
如果x所在的集合, 那么y的根节点连接x; 否则反之。
size数组的更新, 若y挂x上, 那么y原先所在代表的元素fy的size成为一个无效值, 不会在用上, 现在y的代表元素是fx, 因此size[fx] += size[fy];
, x所在的集合吸收y所在集合的大小。
void unionSets(int x,int y){int fx = find(x);int fy = find(y);if(fx != fy){if(size[fx]>=size[fy]){father[fy] = fx;size[fx] += size[fy];}else{father[fx] = fy;size[fy] += size[fx];}}
}
cpp代码完整实现
//洛谷并查集size版本: https://www.luogu.com.cn/problem/P3367
//vscode上编辑的cpp代码, 去掉注释直接提交即可。// #include<bits/stdc++.h>
// #define MAXN 10001
// using namespace std;// int father[MAXN];
// int size[MAXN];
// int path[MAXN];// void build(int n){
// for(int i=1;i<=n;i++){
// father[i] = i;
// size[i] = 1;
// }
// }
// int find(int i){
// int size = 0;
// while(i != father[i]){
// path[size++] = i;
// i = father[i];
// }
// while(size > 0){
// father[path[--size]] = i;
// }
// return i;
// }
// void unionSets(int x,int y){
// int fx = find(x);
// int fy = find(y);
// if(fx != fy){
// if(size[fx]>=size[fy]){
// father[fy] = fx;
// size[fx] += size[fy];
// }else{
// father[fx] = fy;
// size[fy] += size[fx];
// }
// }
// }
// int main(void){
// int n,m,x,y,z;
// cin>>n>>m;
// build(n);
// for(int i=0;i<m;i++){
// cin>>z>>x>>y;
// if(z==1){
// unionSets(x,y);
// }else{
// printf("%c\n",find(x)==find(y)?'Y':'N');
// }
// }
// return 0;
// }
java代码实现
java">package UnionSet;//洛谷并查集size版本: https://www.luogu.com.cn/problem/P3367
//去除包名,修改类名为Main就可提交通过。
import java.io.IOException;
import java.io.BufferedReader;
import java.io.PrintWriter;
import java.io.StreamTokenizer;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;public class Code01_UnionSet {public static int MAXN = 10001;public static int[] father = new int[MAXN];public static int[] size = new int[MAXN];public static int[] stack = new int[MAXN];public static int n;public static void build(int n){for(int i=1;i<=n;i++){father[i] = i;size[i] = 1;}}public static int find(int i){int size = 0;while(i != father[i]){stack[size++] = i;i = father[i];}while(size > 0){father[stack[--size]] = i;}return i;}public static boolean isSameSet(int x,int y){return find(x) == find(y);}public static void union(int x,int y){int fx = find(x);int fy = find(y);if(fx != fy){if(size[fx]>=size[fy]){father[fy] = fx;size[fx] += size[fy];}else{father[fx] = fy;size[fy] += size[fx];}}}public static void main(String[] args) throws IOException{BufferedReader br = new BufferedReader(new InputStreamReader(System.in));StreamTokenizer in = new StreamTokenizer(br);PrintWriter out = new PrintWriter(new OutputStreamWriter(System.out));while(in.nextToken() != StreamTokenizer.TT_EOF){n = (int)in.nval;build(n);in.nextToken();int m = (int)in.nval;int z,x,y;for(int i=0;i<m;i++){in.nextToken();z = (int)in.nval;in.nextToken();x = (int)in.nval;in.nextToken();y = (int)in.nval;switch(z){case 1:union(x,y);break;case 2:out.println(isSameSet(x,y)?"Y":"N");}}}out.flush();out.close();br.close();}
}
height数组优化
高度小的挂高度大的集合, 似乎比按大小挂更加地精确。
修改步骤很简单
将size数组用height数组替代。
初始化时,每个元素的集合高度均为1。
然后是合并操作
。
- 如果两个集合的高度不同,则将较小高度的树挂到较大高度的树下。
- 如果两个集合的高度相同,则任选一个作为根,并将其高度加 1。
Java代码实现:差别部分已用注释区分
:
java">package UnionSet;//洛谷并查集height版本: https://www.luogu.com.cn/problem/P3367
//去除包名,修改类名为Main就可提交通过。
import java.io.IOException;
import java.io.BufferedReader;
import java.io.PrintWriter;
import java.io.StreamTokenizer;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;public class Code02_UnionSet {public static int MAXN = 10001;public static int[] father = new int[MAXN];//这里是height数组public static int[] height = new int[MAXN];public static int[] stack = new int[MAXN];public static int n;public static void build(int n){for(int i=1;i<=n;i++){father[i] = i;height[i] = 1;//初始化高度为1}}public static int find(int i){int size = 0;while(i != father[i]){stack[size++] = i;i = father[i];}while(size > 0){father[stack[--size]] = i;}return i;}public static boolean isSameSet(int x,int y){return find(x) == find(y);}public static void union(int x, int y) {int fx = find(x); // 找到 x 的根int fy = find(y); // 找到 y 的根if (fx != fy) {if (height[fx] > height[fy]) {father[fy] = fx; // 将 y 的根挂到 x 的根下} else if (height[fx] < height[fy]) {father[fx] = fy; // 将 x 的根挂到 y 的根下} else {father[fy] = fx; // 任选一个作为根height[fx]++; // 如果高度相同,则增加新的根的高度}}
}public static void main(String[] args) throws IOException{BufferedReader br = new BufferedReader(new InputStreamReader(System.in));StreamTokenizer in = new StreamTokenizer(br);PrintWriter out = new PrintWriter(new OutputStreamWriter(System.out));while(in.nextToken() != StreamTokenizer.TT_EOF){n = (int)in.nval;build(n);in.nextToken();int m = (int)in.nval;int z,x,y;for(int i=0;i<m;i++){in.nextToken();z = (int)in.nval;in.nextToken();x = (int)in.nval;in.nextToken();y = (int)in.nval;switch(z){case 1:union(x,y);break;case 2:out.println(isSameSet(x,y)?"Y":"N");}}}out.flush();out.close();br.close();}
}
cpp版本
//洛谷并查集height版本: https://www.luogu.com.cn/problem/P3367
//vscode上编辑的cpp代码, 去掉注释直接提交即可。
#include<bits/stdc++.h>
#define MAXN 10001
using namespace std;int father[MAXN];
int height[MAXN];
int path[MAXN];void build(int n){for(int i=1;i<=n;i++){father[i] = i;height[i] = 1;}
}
int find(int i){int size = 0;while(i != father[i]){path[size++] = i;i = father[i];}while(size > 0){father[path[--size]] = i;}return i;
}
void unionSets(int x,int y){int fx = find(x);int fy = find(y);if(fx != fy){if(height[fx]>height[fy]){father[fy] = fx;}else if(height[fy]>height[fx]){father[fx] = fy;}else{father[fy] = fx;height[fx]++;}}
}
int main(void){int n,m,x,y,z;cin>>n>>m;build(n);for(int i=0;i<m;i++){cin>>z>>x>>y;if(z==1){unionSets(x,y);}else{printf("%c\n",find(x)==find(y)?'Y':'N');}}return 0;
}
最后就是按秩优化。
秩并不直接表示树中节点的数量或深度,而是一个 估计树高度 或 树的“复杂度” 的值。
无论前面的size还是height都是树的具体某些属性。
用秩写出来的代码和高度height写出的代码一模一样, 但为什么起一个新名字秩呢?
因为路径压缩过程中每个元素的实际高度都有可能下降,但height中并没有维护这些更新, 导致实际的height在经过find后不能反映真实的高度。 因此, 引入秩的概念作为树近似高度的替代。
那么实际代码的修改, 就是对于上面height数组将其重命名为rank(秩)。
秩就是集合的近似高度或者近似深度
- 找到 x 和 y 的根节点(使用 find(x) 和 find(y)),如果它们不在同一集合,就进行合并。
- 如果 x 和 y 的秩值不同,将秩较小的树挂到秩较大的树下。
- 如果 x 和 y 的秩值相同,任选一个作为根并将其秩加 1,树的高度只会增加 1。
😤时间复杂度
直接给结论, 如果不满足结论可以自行去看算法导论
中的推导。
如果实现按秩优化和路径压缩, 那么并查集的api时间复杂度均为 O ( 1 ) O(1) O(1)
如果只实现路径压缩, api时间复杂度最坏也才 O ( l o g n ) O(logn) O(logn).
如果只实现按秩优化,api时间复杂度最后是 O ( l o g n ) O(logn) O(logn).
如果不优化,那么最坏是线性时间。
实际实现可以掌握路径优化的极简写法。 最坏情况是对数时间的复杂度足以应付算法题了。
😆总结
通过这篇文章,我们了解了并查集这一高效的数据结构及其在图论中的应用。在算法竞赛中,我们只需要用静态数组和路径压缩就可以完全应付所有并查集相关的题目了。
有关具体并查集应用解题部分还有未完成的岛屿问题
, 下篇再说。
再见!~