【狂热算法篇】并查集:探秘图论中的 “连通神器”,解锁动态连通性的神秘力量
嘿,朋友们!喜欢这个并查集的讲解吗 记得点个关注哦,让我们一起探索算法的奥秘,别忘了一键三连,你的支持是我最大的动力!
欢迎拜访:羑悻的小杀马特.-CSDN博客本篇主题:
深度剖析并查集算法及一系列优化制作日期:2025.01.13
隶属专栏:美妙的算法世界
下面我们就介绍一下并查集是啥吧:
目录
一·概念:
二·数据结构表示:
三·基本操作及实现:
3.1初始化:
3.2查找操作(root):
3.3 合并操作(merge):
朴素版:
四.优化策略:
4.1路径压缩:
4.2按秩合并:
4.3按大小合并(启发合并):
五·优化前后对比:
5.1 优化前:
5.2优化后:
六·例题测试:
七.应用场景:
7.1网络连通性问题:
7.2社交网络中的朋友圈问题:
7.3:图论中的最小生成树算法(如 Kruskal 算法):
八·个人小结:
一·概念:
并查集是一种树型的数据结构,用于处理不相交集合的合并及查询问题。它主要支持两种操作:“并”(Union)操作,即将两个不相交的集合合并为一个集合;“查”(Find)操作,即查找元素所在的集合(大概它的外形也可以理解为多插树的形式)。
并查集在解决元素分组和动态连通性问题上展现出强大的能力,能够高效地处理元素之间的关系,判断元素是否属于同一集合,以及将不同集合进行合并操作。
这里我们就记住:同根就连通,合并总发生在根上。
二·数据结构表示:
数组存储:
最常见的实现方式是使用一个数组 pre[]
来存储每个元素的父节点信息。初始时,每个元素自成一个集合,所以 pre[i] = i
,表示元素 i
是它自己集合的代表元素即根节点.
当然了,还会有其他表示方法:
也可以使用链表、树等数据结构表示并查集,但数组是最常用的,因为其实现简单,操作相对便捷,并且在经过优化后可以达到理想的性能。
三·基本操作及实现:
3.1初始化:
void init() {for (int i = 1; i <= n; i++) {pre[i] = i;//无连接把自身初始化成前驱节点}return;
}
这里就不用多解释了把,一开始还没给关系,它们每个节点自己就是一个集合,自己是自己的根节点。
3.2查找操作(root):
功能:查找元素所在集合的代表元素(根节点)。
通过不断查找元素的父节点,直到找到父节点为自身的元素,该元素即为集合的代表元素。
这里我们可以用循环来实现也可以用递归;但是如果它深度太高还是循环比较友好,但是一般数据又不会太大。
比如我们举个例子:
例如,对于集合
{0, 1, 2}
,其中pre[0] = 0
,pre[1] = 0
,pre[2] = 1
,查找元素2
的根节点时,先找到pre[2] = 1
,继续查找pre[1] = 0
,最终找到根节点为0。
下面就用普通的递归实现我们的查找操作:
int root(int x) {return pre[x]==x?x:root(pre[x]);//递归找到根节点
}
3.3 合并操作(merge):
功能:将两个元素所在的集合合并为一个集合。
首先找到两个元素的根节点,如果根节点不同,则将其中一个根节点作为另一个根节点的子节点,完成集合的合并。
注意:合并操作不是就是找根节点嘛,这里我们可以规定方向但是也可以不规定:前提是,我们保证所合并的集合的每个节点的通过父亲节点找到的根节点一定是同一个即可。(但是对于朴素法,也就是我们上面所写的,还没有做优化,可以认为后者的根节点点是前者的根节点的根节点)
下面就是简单版本的合并:
void merge(int i, int j) {int x = root(i), y = root(j);pre[x]=y;return;
}
这样就是实现好了。其实上面实现的就是我们的朴素版本。
朴素版:
const int N = 5005;/// <summary>
/// 多插树
/// </summary>
int pre[N];//类似前驱节点,pre[i]是i指向的前一个节点j:i->j
int rk[N];//每个节点的秩:高度
int sz[N];//以i号节点作为根节点的集合的节点个数
int n, m, p;void init() {for (int i = 1; i <= n; i++) {pre[i] = i;//无连接把自身初始化成前驱节点}return;
}
//找根节点:
int root(int x) {return pre[x]==x?x:root(pre[x]);//递归找到根节点
}
//合并总发生在根节点:
void merge(int i, int j) {int x = root(i), y = root(j);pre[x]=y;return;
}
但是这样肯定是时间复杂度肯定比较高的,可以认为为O(N);那么下面我们来介绍一下它的优化策略。
四.优化策略:
下面我们分三种情况来把朴素版给优化一下:
4.1路径压缩:
介绍:在查找操作时,将查找路径上的元素的父节点都直接指向根节点,以减少后续查找的时间复杂度。
那么我们只需要,在查找元素的根节点时,将路径上的元素的父节点直接更新为根节点。
也就是我们最后的目的就是让每个点的父亲节点都是他们的根节点即可。
当然了它的实现可以是循环解决也可以是递归,但是对于数据不是特别大,我们就选递归,因为它比较好写:
4.1.1递归版本:
//找根节点:
int root(int x) {return pre[x]=pre[x] == x ? x : root(pre[x]);//递归找到根节点,归的时候完成对当前节点的最终指向(根节点)}
4.1.2迭代版本:
int root(int x){int r=x;while(pre[r]>=0){r=pre[r];}//拿到根:while(pre[r]>=0){int res=pre[x];pre[x]=r;x=res;} return r;}
这个优化只需要我们更改找根操作即可,其他和朴素法一样。
但是这样就一定好嘛,我们之前说的是它是一个多插树,这样就破坏了,树的结构,当然也是可以用的(只限于单个数据结构,也就是只有它自己);但是如果还要和其他数据结构的功能配合使用,那么就麻烦了;因此我们就要建议使用后面的优化方法了。
时间复杂度就降到O(1)了。
总代码:
const int N = 5005;/// <summary>
/// 多插树
/// </summary>
int pre[N];//类似前驱节点,pre[i]是i指向的前一个节点j:i->j
int rk[N];//每个节点的秩:高度
int sz[N];//以i号节点作为根节点的集合的节点个数
int n, m, p;路径压缩:破坏树结构void init() {for (int i = 1; i <= n; i++) {pre[i] = i;//无连接把自身初始化成前驱节点}return;
}
//找根节点:
int root(int x) {return pre[x]=pre[x] == x ? x : root(pre[x]);//递归找到根节点,归的时候完成对当前节点的最终指向(根节点)}
//合并总发生在根节点:
void merge(int i, int j) {int x = root(i), y = root(j);pre[x] = y;return;
}
4.2按秩合并:
目的:避免合并操作使树变得过于不平衡,影响查找性能。
为每个节点维护一个秩(rank),可以是树的高度或集合的大小,即叶子节点秩为0,合并时将秩小的集合合并到秩大的集合,秩相等时任意合并并更新秩。
说白了也就是让我们的这颗树变的矮一点,胖一点而不是高高瘦瘦的;那这样为什么能起到优化作用呢?
我们的秩就是树高,如果我们让秩小的和并时候指向高的,也就是让高树的根节点成为矮树根节点的父亲节点;这样我们在查询高树的N多个底层节点的时候就可以少走一次了 ;矮树多走一步,但是它相对走的少啊。
那么这就得出结论了:
让秩小的根节点指向秩大的根节点;如果相同呢?那么随便指向,但是此时我们就要更新最终根节点的秩,也就是加一了(这里其实就不完全考虑我们上面朴素法定义的关系指向了,只需要保证同集合共根即可)。
时间复杂度就近似O(logN)了。
定义rk数组存放每个节点的秩:
void merge(int i, int j) {int x = root(i), y = root(j);if (rk[x] < rk[y]) swap(x, y);//保证x的秩大。秩大的指向秩小的:(根节点)pre[y] = x;if (rk[x] == rk[y])rk[x]++;//如果相等则根节点可以互相指向,但是被指向的节点秩要更新return;
}
然而这里我们只需要更改合并操作;对于查找的操作(我们也可以修改成循环形式):
总代码:
const int N = 5005;/// <summary>
/// 多插树
/// </summary>
int pre[N];//类似前驱节点,pre[i]是i指向的前一个节点j:i->j
int rk[N];//每个节点的秩:高度
int sz[N];//以i号节点作为根节点的集合的节点个数
int n, m, p;/// /按秩合并:根据高度关系让root函数查找的时候找的更快(每个节点都少走一个,提高效率)
void init() {
for (int i = 1; i <= n; i++) {pre[i] = i;//无连接把自身初始化成前驱节点
}
return;
}
//找根节点:
int root(int x) {//return pre[x] == x ? x : root(pre[x]);//递归找到根节点while (pre[x] != x) x = pre[x];return x;
}
//合并总发生在根节点:
void merge(int i, int j) {int x = root(i), y = root(j);if (rk[x] < rk[y]) swap(x, y);//保证x的秩大。秩大的指向秩小的:(根节点)pre[y] = x;if (rk[x] == rk[y])rk[x]++;//如果相等则根节点可以互相指向,但是被指向的节点秩要更新return;
}
4.3按大小合并(启发合并):
这里其实和按秩排序优化效果上一样;只不过不是根据秩划分;而是根据集合的大小来划分罢了。
就是我们合并的时候,肯定会有多个集合;那么我们要想集合里的元素越多,那么它向上找根次数肯定越多,那么如果一个大集合和一个小集合合并是不是让大集合的根指向小集合的根就可以少走很多次了(类似按秩合并思想,只是写法不同罢了)
时间复杂度就近似O(logN)了。
因此得到总结:
小集合根节点指向大集合根节点;如果相同,两个根节点可以随机指向;其次注意合并后要更新集合大小。
因此我们搞一个数组为sz[]记录每个集合大小:
也就是:int sz[N];以i号节点作为根节点的集合的节点个数 。
但是这里还有个细节(很容易忽略):我们这个操作不仅要改变指向,集合大小,还要注意初始化,每个节点一开始的集合大小就是1;不像按秩合并一样(因为叶子节点本身秩就是0):
void init() {for (int i = 1; i <= n; i++) {pre[i] = i;//无连接把自身初始化成前驱节点sz[i] = 1;//注意数组含义(集合节点数)/细节/}return;
}
合并:
void merge(int i, int j) {int x = root(i), y = root(j);if (sz[x] < sz[y]) swap(x, y);//保证x是大集合:pre[y] = x;sz[x] += sz[y];//有y集合的纳入,x变大,的sz也增大return;
}
总代码:
const int N = 5005;/// <summary>
/// 多插树
/// </summary>
int pre[N];//类似前驱节点,pre[i]是i指向的前一个节点j:i->j
int rk[N];//每个节点的秩:高度
int sz[N];//以i号节点作为根节点的集合的节点个数
int n, m, p;/// /按大小合并(启发合并):
void init() {for (int i = 1; i <= n; i++) {pre[i] = i;//无连接把自身初始化成前驱节点sz[i] = 1;//注意数组含义(集合节点数)/细节/}return;
}
//找根节点:
int root(int x) {// return pre[x] == x ? x : root(pre[x]);//递归找到根节点while (pre[x] != x) x = pre[x];return x;
}
//合并总发生在根节点:
void merge(int i, int j) {int x = root(i), y = root(j);if (sz[x] < sz[y]) swap(x, y);//保证x是大集合:pre[y] = x;sz[x] += sz[y];//有y集合的纳入,x变大,的sz也增大return;
}
五·优化前后对比:
5.1 优化前:
单次查找或合并操作的最坏情况时间复杂度为 o(N),因为树可能退化成链,查找元素时可能需要遍历整个链。
5.2优化后:
经过路径压缩和按秩合并优化,单次查找或合并操作的平均时间复杂度接近o(
(N)) ,其中
(N) 是阿克曼函数的反函数,其增长速度非常慢,在实际应用中可近似认为是常数时间复杂度,因此优化后的并查集在效率上有显著提升。
六·例题测试:
下面我们就以一道洛谷的并查集模版题测试一下吧:
输入:
6 5 3 1 2 1 5 3 4 5 2 1 3 1 4 2 3 5 6
输出:
Yes Yes No
数据范围(还是比较小的):
链接: 亲戚 - 洛谷
首先我们每个写法无论优化还是不优化都是可以通过的:
这里数据范围比较小,所以优化肯定是成立的但是我们一般看不太出来:
这里按大小合并确实有点离谱了,但是数据还是比较少的;因此理论应该是和按秩合并一样的。
main函数实现:
int main() {ios::sync_with_stdio(0), cin.tie(0), cout.tie(0);//取消同步cin >> n >> m >> p;init();//初始化,自己指向自己while (m--) {int i, j;cin >> i >> j;merge(i, j);//合并:完成节点的指向i->j。}while (p--) {int a, b;cin >> a >> b;if (root(a) == root(b)) cout << "Yes" << endl;//根节点相同属于同一个节点else cout << "No" << endl;}}
七.应用场景:
7.1网络连通性问题:
在计算机网络中,判断两台计算机是否处于同一子网,将计算机视为元素,当新的连接建立时,可以使用并查集合并集合。
例如,在网络拓扑中,每台计算机开始是独立的集合,当连接两台计算机时,通过并查集的合并操作将它们所在集合合并,表示它们处于同一连通区域。
7.2社交网络中的朋友圈问题:
判断两个人是否属于同一朋友圈,添加好友时可以合并两个朋友圈集合。
比如在社交软件中,每个人开始是一个独立的朋友圈,当添加好友时,使用并查集将两人所在的朋友圈集合合并。
7.3:图论中的最小生成树算法(如 Kruskal 算法):
用于判断边的两个端点是否在同一连通分量,若不在,则合并它们所在的连通分量。
在 Kruskal 算法中,对边进行排序,依次取出边,若边的两端不在同一集合,使用并查集的合并操作,最终形成最小生成树。
因此:
并查集作为一种高效的数据结构,通过简单的数组存储和优化的查找、合并操作,在元素分组、动态连通性判断等方面具有广泛的应用。它在解决网络、社交和图算法等领域的问题时,能够提供简洁有效的解决方案,优化后的并查集在性能上表现出色,是处理集合操作和图论问题的重要工具之一。
八·个人小结:
个人认为,我们在进行实现时候,要注意两点:同根即连通,合并总发生在根上(指向无所谓,只要保证同一个集合元素一定都同根即可);其次就是使用:判断是否有关系,组别划分等;根据题目分析即可。
相关文章:

【狂热算法篇】并查集:探秘图论中的 “连通神器”,解锁动态连通性的神秘力量
嘿,朋友们!喜欢这个并查集的讲解吗 记得点个关注哦,让我们一起探索算法的奥秘,别忘了一键三连,你的支持是我最大的动力! 欢迎拜访:羑悻的小杀马特.-CSDN博客 本篇主题:深度剖析并查…...

SpringBoot中实现动态数据源切换
SpringBoot中实现动态数据源切换 文章目录 SpringBoot中实现动态数据源切换SpringBoot中实现动态数据源切换基础知识1. 什么是数据源?2. 动态数据源切换的概念3. Spring Boot 中的默认数据源配置4. 动态数据源的挑战5. Spring 中的数据源切换方式 设计思路1. 明确应…...

数据结构及排序算法
数据结构 线性结构 ◆线性结构:每个元素最多只有一个出度和一个入度,表现为一条线状。线性表按存储方式分为顺序表和链表。 存储结构: ◆顺序存储:用一组地址连续的存储单元依次存储线性表中的数据元素,使得逻辑上相邻的元素物理上也相邻。 ◆链式存储:存储各数据元素的结点…...

Python基础-元组tuple的学习
在 Python 中,元组(tuple)是一种不可变的序列类型,允许存储不同类型的元素。元组非常类似于列表(list),但与列表不同的是,元组一旦创建,就不能修改其内容。 1 元组的创建…...

【手写公式识别】MEMix: Improving HMER with Diverse Formula Structure Augmentation 论文阅读
发表于:ICME 2024 原文链接:https://ieeexplore.ieee.org/document/10687521 源码:无 Abstract 手写数学表达式识别(HMER)旨在将数学表达式(MEs)的图像转换为相应的LaTeX序列。然而࿰…...

使用deepseek写一个飞机大战游戏
说明: 安装pygame:在运行代码之前,需要先安装 pygame 库。可以通过以下命令安装: pip install pygame图像文件:需要将玩家、敌人和子弹的图像文件(player.png, enemy.png, bullet.png)放在与脚本…...

用Kibana实现Elasticsearch索引的增删改查:实战指南
在大数据时代,Elasticsearch(简称 ES)和 Kibana 作为强大的数据搜索与可视化工具,受到了众多开发者的青睐。Kibana 提供了一个直观的界面,可以方便地对 Elasticsearch 中的数据进行操作。本文将详细介绍如何使用 Kiban…...

C# 封送和远程编程介绍
.NET学习资料 .NET学习资料 .NET学习资料 在 C# 编程领域中,封送(Marshaling)和远程编程(Remote Programming)是两个极为重要的概念,它们为开发者提供了与不同环境、不同进程或不同机器上的代码进行交互的…...

MybatisPlus较全常用复杂查询引例(limit、orderby、groupby、having、like...)
MyBatis-Plus 是一个 MyBatis 的增强工具,在 MyBatis 的基础上只做增强不做改变,为简化开发、提高效率而生。以下是 MyBatis-Plus 中常用复杂查询(如 LIMIT、ORDER BY、GROUP BY、HAVING、LIKE 等)的引例: 1. 环境准备…...

02.07 TCP服务器与客户端的搭建
一.思维导图 二.使用动态协议包实现服务器与客户端 1. 协议包的结构定义 首先,是协议包的结构定义。在两段代码中,pack_t结构体都被用来表示协议包: typedef struct Pack {int size; // 记录整个协议包的实际大小enum Type type; …...

Jenkins数据备份到windows FTP服务器
文章目录 背景1. 安装配置 FileZilla Server(Windows)1.1 下载并安装 FileZilla Server1.2 配置 FTP 用户和共享目录 2. 安装并配置 FTP 客户端(CentOS)2.1 在 CentOS 安装 lftp 3. 编写 Jenkins 备份脚本3.1 赋予执行权限3.2 测试…...

【R语言】卡方检验
一、定义 卡方检验是用来检验样本观测次数与理论或总体次数之间差异性的推断性统计方法,其原理是比较观测值与理论值之间的差异。两者之间的差异越小,检验的结果越不容易达到显著水平;反之,检验结果越可能达到显著水平。 二、用…...

ASP.NET Core托管服务
目录 托管服务的异常问题 托管服务中使用DI 托管服务案例:数据的定时导出 场景,代码运行在后台。比如服务器启动的时候在后台预先加载数据到缓存,每天凌晨3点把数据导出到备份数据库,每隔5秒钟在两张表之间同步一次数据。托管服…...

HarmonyOS 5.0应用开发——全局自定义弹出框openCustomDialog
【高心星出品】 文章目录 全局自定义弹出框openCustomDialog案例开发步骤完整代码 全局自定义弹出框openCustomDialog CustomDialog是自定义弹出框,可用于广告、中奖、警告、软件更新等与用户交互响应操作。开发者可以通过CustomDialogController类显示自定义弹出框…...

如何在C++ QT 程序中集成cef3开源浏览器组件去显示网页?
文章目录 1. **准备工作**1.1 下载CEF31.2 配置Qt项目2. **集成CEF3到Qt窗口**2.1 创建Qt窗口容器2.2 初始化CEF33. **处理CEF3消息循环**4. **处理多进程架构**5. **完整代码示例**`main.cpp`6. **常见问题**6.1 黑屏问题6.2 窗口嵌入失败6.3 多进程调试7.**Github源码参考**8…...

深入讲解MyBatis
1. MyBatis 的背景和优势 背景:在 Java 开发中,传统的 JDBC 操作数据库代码繁琐,需要手动管理数据库连接、编写 SQL 语句、处理结果集等,开发效率低且容易出错。MyBatis 应运而生,它通过将 SQL 语句与 Java 代码分离&a…...

使用matlab 对传递函数分析bode图和阶跃函数
如果已知一个系统的传递函数,想看一下bode图,可以通过simulink 建模,但是simulink运行起来相对比较慢,我一般都是直接通过matlab 的m语言写脚本实现。可以快速的获得结果 如 我们有一个一阶低通传递函数 syswn/(swn) 在matlab中…...

2025牛客寒假算法基础集训营5(补题)
C 小L的位运算 显然,如果两次反置的价格小于等于交换的价格,那么直接全部反置就好了。 反之,由于交换一定低于两次反置,我们尽可能用交换来消去不正确的位置。不正确的位置类型只有00,01,10,11&…...

FaceFusion如何设置公开链接和端口
有时候我们想在局域网内的其他设备上使用 FaceFusion,这时候需要设置公开链接和端口。 当你运行 FaceFusion 的时候,会发现有这样的一段提示: To create a public link, set shareTrue in launch().但是这个提示是错的,如果你查…...

神经网络常见激活函数 6-RReLU函数
文章目录 RReLU函数导函数函数和导函数图像优缺点pytorch中的RReLU函数tensorflow 中的RReLU函数 RReLU 随机修正线性单元:Randomized Leaky ReLU 函数导函数 RReLU函数 R R e L U { x x ≥ 0 a x x < 0 \rm RReLU \left\{ \begin{array}{} x \quad x \ge 0…...

计算机网络面经
文章目录 基础HTTPHTTP报文结构 (注意)RPC和http的区别TCPTCP报文结构(注意)IP基础 HTTP HTTP报文结构 (注意) 请求行:请求方法get/post,url,http版本 请求头:用户标识,请求体长度,类型,cookie 请求体:内容 状态行:状态码,状态消息、(http版本) 响应头:内…...

Qt:常用控件
目录 控件概述 控件体系的发展 按钮类控件 QPushButton QRadioButton QCheckBox QToolButton 显示类控件 QLabel QLCDNumber QProgressBar QCalendarWidget 输入类控件 QLineEdit QTextEdit QComboBox QSpinBox QDateEdit & QTimeEdit QDial QSlider …...

算法设计-找第二大数(C++)
一、问题描述 用于在给定的整数数组中找到 第二大值。 二、详细代码 #include<iostream> #include<limits.h> using namespace std; //初始化最大值为a[0],次大值为a[1],遍历一次,每次比较并更新最大值和次大值,最…...

【C++高并发服务器WebServer】-14:Select详解及实现
本文目录 一、BIO模型二、非阻塞NIO忙轮询三、IO多路复用四、Select()多路复用实现 明确一下IO多路复用的概念:IO多路复用能够使得程序同时监听多个文件描述符(文件描述符fd对应的是内核读写缓冲区),能够提升程序的性能。 Linux下…...

redis项目
短信登录 这一块我们会使用redis共享session来实现 商户查询缓存 通过本章节,我们会理解缓存击穿,缓存穿透,缓存雪崩等问题,让小伙伴的对于这些概念的理解不仅仅是停留在概念上,更是能在代码中看到对应的内容 优惠…...

Spring统一修改RequestBody
我们编写RestController时,有可能多个接口使用了相同的RequestBody,在一些场景下需求修改传入的RequestBody的值,如果是每个controller中都去修改,代码会比较繁琐,最好的方式是在一个地方统一修改,比如将he…...

NCV4275CDT50RKG 车规级LDO线性电压调节器芯片——专为新能源汽车设计的高可靠性电源解决方案
产品概述: NCV4275CDT50RKG 是一款符合 AEC-Q100 车规认证的高性能LDO(低压差线性稳压器),专为新能源汽车的严苛工作环境设计。该芯片支持 输出调节为 5.0 V 或 3.3 V,最大输出电流达 450mA,具备超低静态电流…...

前端开发架构师Prompt指令的最佳实践
前端开发架构师Prompt 提示词可作为系统提示词使用,可基于用户的需求输出对应的编码方案。 本次提示词偏向前端开发的使用,如有需要可适当修改关键词和示例。 推荐使用 Cursor 中作为自定义指令使用Cline 插件中作为自定义指令使用在力所能及的范围内使…...

【AI实践】Windsurf AI编程voice对话应用
Android Studio新建一个安卓 hello world 应用,使用gitee插件,推送到个人gitee仓库。 本文要写一个基于GLM4-voice的一个语音对话应用,参考 bigmodel.cn平台和开发文档:智谱AI开放平台 第一轮 打开cursor,model切换到…...

【自学笔记】文言一心的基础知识点总览-持续更新
提示:文章写完后,目录可以自动生成,如何生成可参考右边的帮助文档 文章目录 文心一言知识点总览一、文心一言简介二、文心一言的核心功能三、文心一言的技术特点四、文心一言的应用场景五、文心一言的使用技巧六、文心一言的未来发展 总结 文…...