【狂热算法篇】并查集:探秘图论中的 “连通神器”,解锁动态连通性的神秘力量

嘿,朋友们!喜欢这个并查集的讲解吗 记得点个关注哦,让我们一起探索算法的奥秘,别忘了一键三连,你的支持是我最大的动力!  

 欢迎拜访:羑悻的小杀马特.-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] = 0pre[1] = 0pre[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路径压缩:

介绍:在查找操作时,将查找路径上的元素的父节点都直接指向根节点,以减少后续查找的时间复杂度。 

那么我们只需要,在查找元素的根节点时,将路径上的元素的父节点直接更新为根节点。

也就是我们最后的目的就是让每个点的父亲节点都是他们的根节点即可。 

 当然了它的实现可以是循环解决也可以是递归,但是对于数据不是特别大,我们就选递归,因为它比较好写:

//找根节点:
int root(int x) {
   return pre[x]=pre[x] == x ? x : root(pre[x]);//递归找到根节点,归的时候完成对当前节点的最终指向(根节点)
   
}

这个优化只需要我们更改找根操作即可,其他和朴素法一样。

 但是这样就一定好嘛,我们之前说的是它是一个多插树,这样就破坏了,树的结构,当然也是可以用的(只限于单个数据结构,也就是只有它自己);但是如果还要和其他数据结构的功能配合使用,那么就麻烦了;因此我们就要建议使用后面的优化方法了。

时间复杂度就降到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(\alpha(N)) ,其中 \alpha(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 算法中,对边进行排序,依次取出边,若边的两端不在同一集合,使用并查集的合并操作,最终形成最小生成树。

 因此:

并查集作为一种高效的数据结构,通过简单的数组存储和优化的查找、合并操作,在元素分组、动态连通性判断等方面具有广泛的应用。它在解决网络、社交和图算法等领域的问题时,能够提供简洁有效的解决方案,优化后的并查集在性能上表现出色,是处理集合操作和图论问题的重要工具之一。

八·个人小结:

 个人认为,我们在进行实现时候,要注意两点:同根即连通,合并总发生在根上(指向无所谓,只要保证同一个集合元素一定都同根即可);其次就是使用:判断是否有关系,组别划分等;根据题目分析即可。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/965150.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

Jupyter Lab的使用

Lab与Notebook的区别: Jupyter Lab和Jupyter notebook有什么区别&#xff0c;这里找到一篇博客不过我没细看&#xff0c; Jupyter Lab和Jupyter Notebook的区别 - codersgl - 博客园 使用起来Lab就是一个更齐全、功能更高级的notebook&#xff0c; 启用滚动输出: 有时候一个…

C++【深入 STL--list 之 迭代器与反向迭代器】

接前面的手撕list(上)文章&#xff0c;由于本人对于list的了解再一次加深。本文再次对list进行深入的分析与实现。旨在再一次梳理思路&#xff0c;修炼代码内功。 1、list 基础架构 list底层为双向带头循环链表&#xff0c;问题是如何来搭建这个list类。可以进行下面的考虑&am…

Games104——游戏引擎Gameplay玩法系统:基础AI

这里写目录标题 寻路/导航系统NavigationWalkable AreaWaypoint NetworkGridNavigation Mesh&#xff08;寻路网格&#xff09;Sparse Voxel Octree Path FindingDijkstra Algorithm迪杰斯特拉算法A Star&#xff08;A*算法&#xff09; Path Smoothing Steering系统Crowd Simu…

2024最新版Node.js详细安装教程(含npm配置淘宝最新镜像地址)

一&#xff1a;Node.js安装 浏览器中搜索Nodejs&#xff0c;或直接用网址:Node.js — 在任何地方运行 JavaScript 建议此处下载长期支持版本&#xff08;红框内&#xff09;: 开始下载&#xff0c;完成后打开文件: 进入安装界面&#xff0c;在此处勾选&#xff0c;再点击n…

高效 MyBatis SQL 写法一

高效 MyBatis SQL 写法一 前言 MyBatis 作为一款优秀的持久层框架&#xff0c;极大地简化了数据库操作。 然而&#xff0c;在实际开发中&#xff0c;XML 配置的编写仍然可能显得繁琐。 本文将分享一些 MyBatis 动态 SQL 的优质写法&#xff0c;帮助开发者提升效率并减少错误…

C语言按位取反【~】详解,含原码反码补码的0基础讲解【原码反码补码严格意义上来说属于计算机组成原理的范畴,不过这也是学好编程初级阶段的必修课】

目录 概述【适合0基础看的简要描述】&#xff1a; 上述加粗下划线的内容提取版&#xff1a; 从上述概述中提取的核心知识点&#xff0c;需背诵&#xff1a; 整数【包含整数&#xff0c;负整数和0】的原码反码补码相互转换的过程图示&#xff1a; 过程详细刨析&#xff1a;…

如何安装PHP依赖库 更新2025.2.3

要在PHP项目中安装依赖&#xff0c;首先需要确保你的系统已经安装了Composer。Composer是PHP的依赖管理工具&#xff0c;它允许你声明项目所需的库&#xff0c;并管理它们。以下是如何安装Composer和在PHP项目中安装依赖的步骤&#xff1a; 一. 安装Composer 对于Windows用户…

【通俗易懂说模型】线性回归(附深度学习、机器学习发展史)

&#x1f308; 个人主页&#xff1a;十二月的猫-CSDN博客 &#x1f525; 系列专栏&#xff1a; &#x1f3c0;深度学习_十二月的猫的博客-CSDN博客 &#x1f4aa;&#x1f3fb; 十二月的寒冬阻挡不了春天的脚步&#xff0c;十二点的黑夜遮蔽不住黎明的曙光 目录 1. 前言 2. …

硬件电路基础

目录 1. 电学基础 1.1 原子 1.2 电压 1.3 电流 1.电流方向&#xff1a; 正极->负极,正电荷定向移动方向为电流方向&#xff0c;与电子定向移动方向相反。 2.电荷&#xff08;这里表示负电荷&#xff09;运动方向&#xff1a; 与电流方向相反 1.4 测电压的时候 2. 地线…

【含文档+PPT+源码】基于Python爬虫二手房价格预测与可视化系统的设计与实现

项目介绍 本课程演示的是一款基于Python爬虫二手房价格预测与可视化系统&#xff0c;主要针对计算机相关专业的正在做毕设的学生与需要项目实战练习的 Java 学习者。 包含&#xff1a;项目源码、项目文档、数据库脚本、软件工具等所有资料 带你从零开始部署运行本套系统 该项…

【数据结构】树哈希

目录 一、树的同构1. 定义2. 具体理解(1) 结点对应(2) 孩子相同(3) 递归性质 3. 示例 二、树哈希1.定义2.哈希过程&#xff08;1&#xff09;叶节点哈希&#xff08;2&#xff09;非叶节点哈希&#xff08;3&#xff09;组合哈希值 3.性质&#xff08;1&#xff09; 唯一性 \re…

渗透测试之文件包含漏洞 超详细的文件包含漏洞文章

目录 说明 通常分为两种类型&#xff1a; 本地文件包含 典型的攻击方式1&#xff1a; 影响&#xff1a; 典型的攻击方式2&#xff1a; 包含路径解释&#xff1a; 日志包含漏洞&#xff1a; 操作原理 包含漏洞读取文件 文件包含漏洞远程代码执行漏洞: 远程文件包含…

Mysql:数据库

Mysql 一、数据库概念&#xff1f;二、MySQL架构三、SQL语句分类四、数据库操作4.1 数据库创建4.2 数据库字符集和校验规则4.3 数据库修改4.4 数据库删除4.4 数据库备份和恢复其他 五、表操作5.1 创建表5.2 修改表5.3 删除表 六、表的增删改查6.1 Create(创建):数据新增1&#…

ChatGPT怎么回事?

纯属发现&#xff0c;调侃一下~ 这段时间deepseek不是特别火吗&#xff0c;尤其是它的推理功能&#xff0c;突发奇想&#xff0c;想用deepseek回答一些问题&#xff0c;回答一个问题之后就回复服务器繁忙&#xff08;估计还在被攻击吧~_~&#xff09; 然后就转向了GPT&#xf…

Vue 中如何嵌入可浮动的第三方网页窗口(附Demo)

目录 前言1. 思路Demo2. 实战Demo 前言 &#x1f91f; 找工作&#xff0c;来万码优才&#xff1a;&#x1f449; #小程序://万码优才/r6rqmzDaXpYkJZF 1. 思路Demo 以下Demo提供思路参考&#xff0c;需要结合实际自身应用代码 下述URL的链接使用百度替代&#xff01; 方式 1…

【Linux】23.进程间通信(2)

文章目录 3. 进程间通信3.1 进程间通信介绍3.1.1 进程间通信目的3.1.2 进程间通信发展 3.2 什么是进程间通信3.3 管道3.4 匿名管道pipe()3.4.1 站在文件描述符角度-深度理解管道3.4.2 站在内核角度-管道本质3.4.3 用fork来共享管道原理3.4.5 管道相关知识3.4.6 代码一&#xff…

AI大模型开发原理篇-8:Transformer模型

近几年人工智能之所以能迅猛发展&#xff0c;主要是靠2个核心思想&#xff1a;注意力机制Attention Mechanism 和 Transformer模型。本次来浅谈下Transformer模型。 重要性 Transformer模型在自然语言处理领域具有极其重要的地位&#xff0c;为NLP带来了革命性的突破‌。可以…

html2canvas绘制页面并生成图像 下载

1. 简介 html2canvas是一个开源的JavaScript库&#xff0c;它允许开发者在用户的浏览器中直接将HTML元素渲染为画布&#xff08;Canvas&#xff09;&#xff0c;并生成图像。以下是对html2canvas的详细介绍&#xff1a; 2. 主要功能 html2canvas的主要功能是将网页中的HTML元…

基于RK3588/RK3576+MCU STM32+AI的储能电站电池簇管理系统设计与实现

伴随近年来新型储能技术的高质量规模化发展&#xff0c;储能电站作为新能源领域的重要载体&#xff0c; 旨在配合逐步迈进智能电网时代&#xff0c;满足电力系统能源结构与分布的创新升级&#xff0c;给予相应规模 电池管理系统的设计与实现以新的挑战。同时&#xff0c;电子系…

机器学习-线性回归(参数估计之结构风险最小化)

前面我们已经了解过关于机器学习中的结构风险最小化准则&#xff0c;包括L1 正则化&#xff08;Lasso&#xff09;、L2 正则化&#xff08;Ridge&#xff09;、Elastic Net&#xff0c;现在我们结合线性回归的场景&#xff0c;来了解一下线性回归的结构风险最小化&#xff0c;通…