《重生我要成为并查集高手🍔🍔🍔》
并查集:快速查询和快速合并, 路径压缩, 按大小,高度,秩合并。 静态数组实现
😇前言
在数据的海洋中,有一种悄然流淌的力量,它无声地将孤立的元素连结成整体,化解纷繁的复杂与混乱。如同星辰之间看似遥远,实则早已彼此相连的轨道,它用简单的规则架起了信息流动的桥梁,带领我们在无形中寻找到隐藏的联系与秩序。
本篇向您介绍并查集这种数据结构, 它具有简洁优雅的代码实现。
前置知识: 推荐去了解一下并查集的概念和两种优化, 可以看一下我之前写的
数据结构并查集
编程语言: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 和操作数 m
build(n); // 初始化并查集
int z, x, y;
for (int i = 0; i < m; i++) {
cin >> z >> x >> y; // 读取操作类型和两个元素 x, y
if (z == 1) {
// 如果 z == 1,进行合并操作
unionSets(x, y);
} else if (z == 2) {
// 如果 z == 2,查询是否在同一个集合中
if (find(x) == find(y)