《并查集算法详解及实用模板》

server/2024/11/28 11:47:06/

《重生我要成为并查集高手🍔🍔🍔》
并查集:快速查询和快速合并, 路径压缩, 按大小,高度,秩合并。 静态数组实现

😇前言

在数据的海洋中,有一种悄然流淌的力量,它无声地将孤立的元素连结成整体,化解纷繁的复杂与混乱。如同星辰之间看似遥远,实则早已彼此相连的轨道,它用简单的规则架起了信息流动的桥梁,带领我们在无形中寻找到隐藏的联系与秩序。
本篇向您介绍并查集这种数据结构, 它具有简洁优雅的代码实现。
前置知识: 推荐去了解一下并查集的概念和两种优化, 可以看一下我之前写的数据结构并查集
编程语言: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);
}
合并操作

并查集的合并操作非常好理解, 想象链表之间如何合并的? 就是改变一下指针指向
两个原本不相交的集合=>合并为一个集合。 实现逻辑就是将一个集合代表元素的父级指针指向另外一个集合的根节点。
在这里插入图片描述

注意: 如果两个合并元素本身就在同一集合, 那么不执行合并操作。
算法流程:

  1. 查找x,y所在集合的根节点
  2. 若它们之的根节点不为同一个, 那么执行合并操作。 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).
如果不优化,那么最坏是线性时间。

实际实现可以掌握路径优化的极简写法。 最坏情况是对数时间的复杂度足以应付算法题了。

😆总结

通过这篇文章,我们了解了并查集这一高效的数据结构及其在图论中的应用。在算法竞赛中,我们只需要用静态数组和路径压缩就可以完全应付所有并查集相关的题目了。
有关具体并查集应用解题部分还有未完成的岛屿问题, 下篇再说。

再见!~
在这里插入图片描述


http://www.ppmy.cn/server/145614.html

相关文章

Python 将彩色视频转换为黑白视频(MP4-格式可选)

以下是使用 Python 将彩色视频转换为黑白电视风格的示例代码&#xff0c;主要借助了opencv-python库来实现&#xff1a; python import cv2def convert_to_black_and_white_video(input_video_path, output_video_path):# 打开视频文件cap cv2.VideoCapture(input_video_pat…

机器学习模型——线性回归

文章目录 前言1.基础概念2.代价函数3.单变量线性回归3.1加载数据3.2初始化超参数3.3梯度下降算法3.3.1初次梯度下降3.3.2 多次梯度下降3.3.3结果可视化 前言 随着互联网数据不断累积&#xff0c;硬件不断升级迭代&#xff0c;在这个信息爆炸的时代&#xff0c;机器学习已被应用…

嵌入式linux C++通用makefile模板

编译生成spdlog_app可执行程序 #Makefile # 编译器 #COMPLITEarm-none-linux-gnueabi- #COMPLITE/root/share/nuvoton_cross/arm_linux_4.8/bin/arm-nuvoton-linux-uclibceabi- #/root/share/nuvoton_cross/host/usr/bin/ COMPLITEarm-nuvoton-linux-gnueabi- CC$(COMPLITE)gc…

redis中的bigkey及读取优化

一、bigKey介绍 1、简介 在 Redis 中,Big Key(大键)指的是占用大量内存的单个键。通常,Redis 是一个高性能的内存数据库,但是当某些键变得非常大时,会带来性能上的影响。例如,大量的内存消耗、长时间的操作延迟,甚至可能导致 Redis 停止响应或崩溃。 通俗的来说,指…

macOS 开发环境配置与应用开发指南

macOS 开发环境配置与应用开发指南 macOS作为苹果公司推出的操作系统&#xff0c;因其稳定性、优雅的用户界面和强大的开发支持&#xff0c;已成为开发者和创意专业人士的首选平台之一。无论是开发iOS、macOS桌面应用&#xff0c;还是Web应用、跨平台程序&#xff0c;macOS都提…

【Linux网络编程】TCP套接字

TCP与UDP的区别&#xff1a; udp是无连接的、面向数据报&#xff08;通信时以数据报为单位传输&#xff09;的传输层通信协议&#xff0c;其中每个数据报都是独立的&#xff0c;通信之前不需要建立连接&#xff0c;bind绑定套接字后直接可以进行通信。 tcp是面向连接的、基于字…

Python 爬虫入门教程:从零构建你的第一个网络爬虫

网络爬虫是一种自动化程序&#xff0c;用于从网站抓取数据。Python 凭借其丰富的库和简单的语法&#xff0c;是构建网络爬虫的理想语言。本文将带你从零开始学习 Python 爬虫的基本知识&#xff0c;并实现一个简单的爬虫项目。 1. 什么是网络爬虫&#xff1f; 网络爬虫&#x…

yum源配置(本地和网络源)

本地 需要先使用命令创建目录 mkdir -p /mnt/cdrom [rootlocalhost ~]# cd /etc/yum.repos.d [rootlocalhost yum.repos.d]# tar -czf CentOS-Base.tar.gz CentOS* [rootlocalhost ~]#rm -rf *repo [rootlocalhost yum.repos.d]# vi local.repo [local] namelocal_yum…