下班前几分钟,我彻底弄懂了并查集

news2024/11/27 13:47:04

目录

  • 一、并查集的由来
  • 二、代表元法
    • 2.1 初始化
    • 2.2 查询
    • 2.3 合并
    • 2.4 设计理念
  • 三、并查集的应用
    • 3.1 合并集合
    • 3.2 连通块中点的数量
    • 3.3 亲戚
    • 3.4 省份数量
  • References

一、并查集的由来

考虑这样一个场景。

现有 n n n 个元素,编号分别为 1 , 2 , ⋯   , n 1,2,\cdots,n 1,2,,n(编号仅用来说明 n n n 个元素互不相同)。初始时,我们给每个元素分配唯一的标识,称为 id,并规定如果两个元素的 id 相同,则它们属于同一集合。在这种定义下,初始时每个元素都单独属于一个集合。简便起见,令每个元素的 id 等于它自身的编号

⚠️ 注意区分编号id 的含义,下文会经常提到这两个概念,不要混淆。

假设 n ≤ 1 0 5 n\leq 10^5 n105,我们可以开一个 id 数组用来记录初始时每个元素所属的集合

#include <iostream>
#include <numeric>

using namespace std;

const int N = 1e5 + 10;

int id[N];

int main() {
    int n;
    cin >> n;
    iota(id + 1, id + n + 1, 1);
    for (int i = 1; i <= n; i++) cout << id[i] << ' ';  // 输出元素i所属的集合(即元素i的id)
    return 0;
}

给定元素 i , j    ( 1 ≤ i , j ≤ n ) i,j\;(1\leq i,j\leq n) i,j(1i,jn),如何判断两个元素是否属于同一集合呢?很简单,只需要 O ( 1 ) O(1) O(1) 的时间:

cout << (id[i] == id[j] ? "Yes" : "No") << endl;

问题来了,如果我们想把元素 i i i 所属的集合与元素 j j j 所属的集合进行合并,该如何操作呢?根据 id 数组的定义,我们需要将其中一个集合中的所有元素的 id 赋值成另一个集合的 id,因此只能遍历:

for (int k = 1; k <= n; k++)
    if (id[k] == id[i] && id[k] != id[j])
        id[k] = id[j];

然而这种操作的时间复杂度将达到恐怖的 O ( n ) O(n) O(n),在绝大多数情况下会TLE(例如当查询数量也为 n n n 时,总时间复杂度为 O ( n 2 ) O(n^2) O(n2))。

这个时候,并查集这种数据结构就派上用场了,它可以在近乎 O ( 1 ) O(1) O(1) 的时间内完成上述两种操作。下面给出并查集的正式定义。

并查集(Union Find)也叫「不相交集合(Disjoint Set)」,顾名思义,它专门用于动态处理不相交集合的「合」与「询」问题。

并查集主要支持以下两种操作:

  • 合并:合并两个元素所属集合;
  • 查询:查询某个元素所属集合。这可以用于判断两个元素是否属于同一集合

二、代表元法

代表元法的主要思想是:把每个集合看成一棵(不一定是二叉树),树中的每一个节点都对应了一个元素,树的根节点称为该集合的代表元。对于树中的每个节点,我们不再存储它的 id,而是存储它父节点的编号。因此这里抛弃 id 数组,取而代之的是 parent 数组(以下简称 p 数组)。p 数组的定义是:p[i] 表示编号i 的节点的父节点的编号。特别地,定义根节点的父节点是其自身(即若 r 是根节点的编号,那么有 p[r] == r 成立)。

没有了 id,我们该如何区分每个集合呢?定义树的根节点的编号为整个树(集合)的 id,于是对于任一元素 x,我们可以「自底向上」追溯到根节点来判断它属于哪个集合:

while (p[x] != x) x = p[x];
cout << x << endl;  // 输出元素x所属集合的id(即根节点的编号)

2.1 初始化

本小节关心的问题是,p 数组如何初始化?

根据代表元法,最初每个元素都是一棵树,树中只有根节点,因此对于任一元素 i i i i i i 是编号),都有 p [ i ] = i p[i] = i p[i]=i 成立。

使用 iota 函数初始化即可:

#include <iostream>
#include <numeric>

using namespace std;

const int N = 1e5 + 10;

int p[N];

int main() {
    int n;
    cin >> n;
    iota(p, p + n + 1, 0);  // 注意编号i的范围是1~n
    return 0;
}

2.2 查询

前面提到过,给定元素 x,我们可以使用如下代码来查询该元素所属集合

while (p[x] != x) x = p[x];

但是这种方法的时间复杂度为 O ( h ) O(h) O(h),其中 h h h 是树的高度。当树为链式结构时,每次查询的时间复杂度均为 O ( n ) O(n) O(n),因此需要做进一步的优化。

优化方案有两种:「路径压缩」和「按秩合并」。后者的优化效果并不明显,故本文主要讲前者。

从元素 x 不断追溯到根节点会形成一条路径,如果在查询结束后,我们将该路径上的每个节点(除了根节点)都直接连接到根节点上,那么在后续的查询过程中,若查询的是该路径上的点,则时间复杂度将缩减至 O ( 1 ) O(1) O(1),如下图所示:

路径压缩的实现十分简洁:

int find(int x) {
    if (p[x] != x) p[x] = find(p[x]);
    return p[x];
}

find 函数返回元素 x 所属集合的 id,且在递归执行的过程中实现了路径压缩。这段代码的含义是,如果元素 x 不是根节点,那么就让元素 x 直接指向根节点,最后返回 x 的父节点的编号(即根节点的编号)。该函数是并查集中最为核心的部分,务必熟练掌握。

2.3 合并

合并两个集合相当于合并两棵树,我们只需要将其中一棵树的根节点指向另一棵树的根节点就可以了,如下图所示:

只需一行代码即可完成合并:

p[find(a)] = find(b);

2.4 设计理念

以下「三个不重要」概括了「代表元法」的设计理念:

  • 谁作为根节点不重要:根节点与非根节点只是位置不同,并没有附加的含义;
  • 树怎么形成的不重要:合并的时候任何一个集合的根节点指向另一个集合的根节点就可以;
  • 树的形态不重要:理由同「谁作为根节点不重要」。

三、并查集的应用

3.1 合并集合

原题链接:AcWing 836. 合并集合

该题是并查集的模板题,思路就是本文的内容,因此不再赘述,直接给出AC代码:

#include <iostream>
#include <numeric>

using namespace std;

const int N = 1e5 + 10;

int p[N];

int find(int x) {
    if (p[x] != x) p[x] = find(p[x]);
    return p[x];
}

int main() {
    ios::sync_with_stdio(false), cin.tie(nullptr);

    int n, m;
    cin >> n >> m;
    iota(p, p + n + 1, 0);

    while (m--) {
        char op;
        int a, b;
        cin >> op >> a >> b;
        if (op == 'M') p[find(a)] = find(b);
        else cout << (find(a) == find(b) ? "Yes" : "No") << endl;
    }

    return 0;
}

3.2 连通块中点的数量

原题链接:AcWing 837. 连通块中点的数量

不难看出,本题中的「连通块」就是集合,前两个操作我们已经见过,对于第三个操作,我们需要额外维护一个 cnt 数组,其中 cnt[i] 表示编号为 i i i 的节点所属集合的点的数量。确切地说,我们只维护每个集合中根节点的 cnt,即只保证根节点的 cnt 是有意义的。

对于操作 Q1,只需判断 find(a) == find(b) 是否成立即可。

对于操作 Q2,因为只保证了根节点的 cnt 是有意义的,所以输出 cnt[find(a)] 而不是 cnt[a]

对于操作 C,在合并元素 a 和元素 b 所属集合时,cnt 数组也需要更新。将 a 所属集合的根节点指向 b 所属集合的根节点的操作为:p[find(a)] = find(b),此时 b 所属集合的大小要加上 a 所属集合的大小:cnt[find(b)] += cnt[find(a)]。需要注意的是,cnt 数组更新必须发生在集合合并之前,此外,如果 ab 属于同一集合,则什么也不用做。

AC代码:

#include <iostream>
#include <numeric>
#include <algorithm>

using namespace std;

const int N = 1e5 + 10;

int p[N], cnt[N];

int find(int x) {
    if (p[x] != x) p[x] = find(p[x]);
    return p[x];
}

int main() {
    ios::sync_with_stdio(false), cin.tie(nullptr);

    int n, m;
    cin >> n >> m;
    iota(p, p + n + 1, 0);
    fill(cnt, cnt + n + 1, 1);  // 最初每个集合只有一个点

    while (m--) {
        string op;
        int a, b;
        cin >> op;
        if (op == "C") {
            cin >> a >> b;
            if (find(a) != find(b)) {
                // 以下两句的顺序不能调换
                cnt[find(b)] += cnt[find(a)];
                p[find(a)] = find(b);
            }
        } else if (op == "Q1") {
            cin >> a >> b;
            cout << (find(a) == find(b) ? "Yes" : "No") << endl;
        } else {
            cin >> a;
            cout << cnt[find(a)] << endl;
        }
    }

    return 0;
}

3.3 亲戚

原题链接:洛谷 P1551 亲戚

建立亲戚关系的过程相当于集合的合并,本题比较简单,直接给出AC代码:

#include <iostream>
#include <numeric>

using namespace std;

const int N = 5e3 + 10;

int p[N];

int find(int x) {
    if (p[x] != x) p[x] = find(p[x]);
    return p[x];
}

int main() {
    ios::sync_with_stdio(false), cin.tie(nullptr);

    int n, m, q, a, b;
    cin >> n >> m >> q;  // 注意将原问题中的p改成q,防止与数组p冲突
    iota(p, p + n + 1, 0);

    while (m--) {
        cin >> a >> b;
        p[find(a)] = find(b);
    }

    while (q--) {
        cin >> a >> b;
        cout << (find(a) == find(b) ? "Yes" : "No") << endl;
    }

    return 0;
}

3.4 省份数量

原题链接:LeetCode 547. 省份数量

根据题意可知, n n n 个城市的编号分别为 0 , 1 , ⋯   , n − 1 0,1,\cdots,n-1 0,1,,n1。如果 isConnected[i][j] = 1 则说明第 i i i 个城市和第 j j j 个城市属于同一集合,此时应当合并。又注意到 isConnected 一定是一个对称矩阵,因此只需要遍历上三角部分即可。

合并结束后,我们会得到一棵棵树(一个个省份),于是省份的数量就等于树的数量等于根节点的数量。

class Solution {
public:
    int p[200];

    int find(int x) {
        if (p[x] != x) p[x] = find(p[x]);
        return p[x];
    }

    int findCircleNum(vector<vector<int>> &isConnected) {
        int n = isConnected.size();
        iota(p, p + n, 0);
        for (int i = 0; i < n; i++)
            for (int j = i + 1; j < n; j++)
                if (isConnected[i][j] == 1) p[find(i)] = find(j);
        int cnt = 0;
        for (int i = 0; i < n; i++)
            if (p[i] == i) cnt++;
        return cnt;
    }
};

References

[1] https://leetcode.cn/leetbook/read/disjoint-set/oviefi/
[2] https://oi-wiki.org/ds/dsu/
[3] https://www.acwing.com/activity/content/punch_the_clock/11/

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

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

【JVM】详解直接内存

文章目录1. 直接内存概述2. 直接内存的使用2.1 Java缓冲区2.2 直接内存3. 直接内存的释放3.1 直接内存释放原理4. 禁用显式回收对直接内存的影响1. 直接内存概述 下面是 《深入理解 Java 虚拟机 第三版》2.2.7 小节 关于 Java 直接内存的描述。 直接内存&#xff08;Direct Me…

从零开始的数模(七)层次分析法

一、概念 1.1定义 应用场景&#xff1a; 1、最佳方案选取 2、评价类问题 3、指标体系的优选 步骤&#xff1a; 1、建立层次结构模型&#xff1b; 2、构造判断(成对比较)矩阵&#xff1b; 3、层次单排序及其一致性检验&#xff1b; 4、层次总排序及其一致性检验&#xff1b; …

Mybatis 分页插件使用

1、分页插件的使用步骤 需求分析&#xff1a; 我们在前端界面获取用户表的时候&#xff0c;在界面上一次显示出成百上千条数据&#xff0c;用户体验&#xff0c;软件性能都会很糟糕&#xff0c;假设数据库内存储十万条记录&#xff0c;后端一次性返回这么多数据&#xff0c;前…

C语言深度解剖-关键字(2)

目录 1.关键字 static 源文件与头文件 static修饰全局变量 static修饰局部变量 写在最后&#xff1a; 1.关键字 static 源文件与头文件 平时我们在练习的时候&#xff0c;都只会开一个用来测试的源文件&#xff0c; 但是&#xff0c;当我们在写一个项目的时候&#xff…

Hal GPIO控制--LED/Delay实现

环境配置在CubeMx Pinout view 中点击可以设置管脚模式 &#xff0c;右击 可以配置管脚名称这里以点PB8灯为例&#xff0c;可以设置灯输出电平 &#xff0c;模式为输出&#xff0c;不进行上下拉&#xff0c; 速度 模式设置以及用户自定义名称。。时钟树配置&#xff0c;使用HSI…

FreeRTOS源码获取-->FreeRTOS移植-->FreeRTOS源码文件了解 | FreeRTOS二

目录 说明&#xff1a; 一、获取源码 1.1、FreeRTOS官网获取 1.2、正点原子开发板A盘资料\6&#xff0c;软件资料\13&#xff0c;版本-->V10.4.6 二、移植源码 2.1、移植步骤 2.1.1、添加源码、头文件路径 2.1.2、添加FreeRTOSConfig.h文件路径 2.1.3、添加或修改相…

VisualSVN Server Enterprise 5.1.1 Crack

VisualSVN Server 提供以下主要功能。 Active Directory 单点登录 允许用户使用他们当前的 Active Directory 域凭据访问 VisualSVN Server。使用安全 Kerberos V5 或 NTLM 身份验证协议。支持双因素身份验证和智能卡。 多站点存储库复制 使用 VisualSVN 分布式文件系统 (VDF…

MySQL基础(1)—— 卸载与安装

文章目录MySQL卸载【windows】1、停止MySQL服务2、软件的卸载2.1 通过控制面板卸载软件2.2 通过360软件管家等第三方软件进行删除2.3 通过MySQL安装包提供的卸载功能卸载3、残余文件的清理4、清理注册表5、删除环境变量配置MySQL安装【windows】1、下载安装包2、安装3、配置环境…

Sharding-JDBC(六)5.1.0版本,实现按月分表、自动建表、自动刷新节点

目录1.Maven 依赖2.创建表结构3.yml 配置4.TimeShardingAlgorithm.java 分片算法类5.ShardingAlgorithmTool.java 分片工具类6.ShardingTablesLoadRunner.java 初始化缓存类7.SpringUtil.java Spring工具类8.源码测试9.测试结果10.代码地址背景&#xff1a; 项目用户数据库表量…

vscode运行C/C++程序

一、vsocde对C/C的支持 Visual Studio Code对C/C语言的支持由Microsoft C/C扩展程序提供。它使得C/C在Windows、Linux和macOS等跨平台开发成为可能。 二、安装扩展程序 打开VS Code软件选择任务栏上的扩展视图图标&#xff08;下图红色方框&#xff09;或使用快捷键(CtrlShif…

【自然语言处理】情感分析(四):基于 Tokenizer 和 Word2Vec 的 CNN 实现

情感分析&#xff08;四&#xff09;&#xff1a;基于 Tokenizer 和 Word2Vec 的 CNN 实现本文是 情感分析 系列的第 444 篇&#xff0c;前三篇分别是&#xff1a; 【自然语言处理】情感分析&#xff08;一&#xff09;&#xff1a;基于 NLTK 的 Naive Bayes 实现【自然语言处…

服务发现Discovery和Eureka自我保护机制

目录 一、服务发现Discovery ​二、Eureka自我保护 &#xff08;一&#xff09;故障现象 &#xff08;二&#xff09;导致原因 &#xff08;三&#xff09;怎么禁止自我保护 三、Eureka2.0的停更 一、服务发现Discovery 对于注册进eureka里面的微服务&#xff0c;可以通…

外挂、破解软件理论与实战

外挂、破解软件理论与实战 1 理论 1.1 不同操作系统下的可执行文件 Windows【PE】 PE 格式&#xff0c;可移植可执行格式&#xff08;Portable Executable&#xff09;&#xff0c; 是 Windows 下的主要可执行文件格式。别被名字迷惑了&#xff0c;PE 文件必须是 Windows 下…

第四十六章 动态规划——状态机模型

第四十六章 动态规划——状态机模型一、通俗理解状态机DP1、什么是状态机2、什么是状态机DP二、例题1、AcWing 1049. 大盗阿福&#xff08;1&#xff09;问题&#xff08;2&#xff09;分析a.状态定义b.状态转移c.循环设计d.初末状态&#xff08;3&#xff09;代码2、AcWing 10…

C++学习/温习:新型源码学编程(三)

写在前面(祝各位新春大吉&#xff01;兔年如意&#xff01;) 【本文持续更新中】面向初学者撰写专栏&#xff0c;个人原创的学习C/C笔记&#xff08;干货&#xff09;所作源代码输出内容为中文&#xff0c;便于理解如有错误之处请各位读者指正请读者评论回复、参与投票&#xf…

01 课程简介、HTML标签【尚硅谷JavaWeb教程】

1. 课程体系设计 2. HTML标签 服务器—浏览器&#xff08;字符串"" &#xff09; demo01.html 1&#xff09;html语言是解释型语言&#xff0c;不是编译型 浏览器是容错的 2&#xff09;html页面中由一对标签组成&#xff1a; < html>称为 开始标签 < /htm…

Java基础语法——数组概念、数组内存图解(一个数组、二个数组)及二元数组的应用

目录 数组概述 数组定义格式 数组概念 数组的定义格式 数组的初始化 数组初始化概述 数组的初始化方式 Java中的内存分配 Java中一个数组的内存图解 Java中二个数组的内存图解 两个数组指向同一个地址的内存图解 数组操作中两个常见的小问题 二维数组 二维数组概述…

c++11 标准模板(STL)(std::forward_list)(十一)

定义于头文件 <forward_list> template< class T, class Allocator std::allocator<T> > class forward_list;(1)(C11 起)namespace pmr { template <class T> using forward_list std::forward_list<T, std::pmr::polymorphic_…

前端架构处理Cookie、Session、Token

1. Cookie Cookie 总是保存在客户端中。按在客户端中的存储位置&#xff0c;可分为内存 Cookie 和硬盘 Cookie。 内存 Cookie 由浏览器维护&#xff0c;保存在内存中&#xff0c;浏览器关闭后就消失了&#xff0c;其存在时间是短暂的。硬盘 Cookie 保存在硬盘里&#xff0c;…

Spring Boot、Spring MVC热部署

一、相关概述 JVM能够识别的是字节码.class文件每次重新运行都是一个重新编译的过程&#xff0c;也就是说会生成新的target字节码文件&#xff1b;但是每次修改了代码之后也必须要重新运行&#xff0c;这样比较麻烦。热部署就能较好地解决该问题&#xff0c;直接刷新页面就可以…