《算法导论》第 21 章-用于不相交集合的数据结构

引言

        不相交集合(Disjoint Set),也称为并查集(Union-Find),是一种非常实用的数据结构,主要用于处理一些元素分组的问题。它支持高效的集合合并和元素查找操作,在很多算法中都有重要应用,如 Kruskal 最小生成树算法、图的连通性问题、等价关系问题等。

        本文将详细讲解《算法导论》第 21 章内容,包括不相交集合的基本操作、两种主要表示方法(链表和森林)以及相关的优化策略,并提供完整可运行的 C++ 代码实现。

思维导图

21.1 不相交集合的操作

不相交集合数据结构主要支持以下三种操作:

  1. MAKE_SET(x):创建一个新的集合,其中只包含元素 x,且 x 没有父节点(即自身为根)。
  2. UNION(x, y):将包含 x 的集合和包含 y 的集合合并成一个新的集合。
  3. FIND(x):找到包含 x 的集合的根节点,可用于判断两个元素是否属于同一个集合。

操作流程图

应用场景示例

        不相交集合最典型的应用之一是判断无向图的连通分量。例如,我们可以用不相交集合来跟踪网络中哪些节点是连通的:

#include <iostream>
#include <vector>
using namespace std;// 简化的不相交集合实现(后续会完善)
class DisjointSet {
private:vector<int> parent;
public:DisjointSet(int n) {parent.resize(n);for (int i = 0; i < n; ++i) {parent[i] = i;  // 初始化,每个元素自己是根}}// 查找根节点int find(int x) {while (parent[x] != x) {x = parent[x];}return x;}// 合并两个集合void unite(int x, int y) {int rootX = find(x);int rootY = find(y);if (rootX != rootY) {parent[rootY] = rootX;}}// 判断两个元素是否在同一个集合bool isConnected(int x, int y) {return find(x) == find(y);}
};// 示例:判断图的连通性
int main() {int n = 5;  // 5个节点DisjointSet ds(n);// 添加边vector<pair<int, int>> edges = {{0,1}, {1,2}, {3,4}};for (auto& edge : edges) {ds.unite(edge.first, edge.second);}// 测试连通性cout << "节点0和节点2是否连通?" << (ds.isConnected(0, 2) ? "是" : "否") << endl;cout << "节点0和节点3是否连通?" << (ds.isConnected(0, 3) ? "是" : "否") << endl;// 连接两个连通分量ds.unite(2, 3);cout << "连接节点2和3后,节点0和节点3是否连通?" << (ds.isConnected(0, 3) ? "是" : "否") << endl;return 0;
}

运行结果:

21.2 不相交集合的链表表示

        不相交集合的一种简单表示方法是使用链表。每个集合用一个链表表示,每个元素包含一个指向自身的指针、一个指向链表中下一个元素的指针,以及一个指向集合代表(通常是链表的第一个元素)的指针

链表表示的结构

class Element {- value: int- next: Element- set: Set
}class Set {- head: Element- tail: Element- size: int+ make_set(x): Set+ union_set(x, y): Set+ find_set(x): Element
}Element "n" -- "1" Set : belongs to

链表表示

链表表示的 C++ 实现

#include <iostream>
#include <unordered_map>
using namespace std;// 链表节点
struct Node {int value;Node* next;class Set* set;  // 指向所属的集合Node(int val) : value(val), next(nullptr), set(nullptr) {}
};// 不相交集合(链表表示)
class Set {
private:Node* head;  // 集合的头节点(代表元素)Node* tail;  // 集合的尾节点(方便合并操作)int size;    // 集合的大小(用于优化合并)public:// 创建一个只包含val的新集合Set(int val) {head = new Node(val);tail = head;head->set = this;size = 1;}// 添加一个获取头节点的公有方法(解决私有成员访问问题)Node* getHead() const {return head;}// 查找元素val所在的集合static Set* find(int val, unordered_map<int, Node*>& nodes) {if (nodes.find(val) == nodes.end()) {return nullptr;  // 元素不存在}return nodes[val]->set;}// 合并两个集合static void unite(Set* set1, Set* set2) {if (set1 == nullptr || set2 == nullptr || set1 == set2) {return;  // 无效操作或已在同一集合}// 优化:将较小的集合合并到较大的集合中if (set1->size < set2->size) {swap(set1, set2);}// 将set2的元素接到set1的尾部set1->tail->next = set2->head;// 更新set2中所有元素的set指针Node* current = set2->head;while (current != nullptr) {current->set = set1;current = current->next;}// 更新set1的尾部和大小set1->tail = set2->tail;set1->size += set2->size;// 释放set2(可选操作)delete set2;}// 获取集合的代表元素(头节点的值)int getRepresentative() const {return head->value;}// 打印集合中的所有元素void printSet() const {Node* current = head;cout << "集合(代表元素:" << head->value << "):";while (current != nullptr) {cout << current->value << " ";current = current->next;}cout << "(大小:" << size << ")" << endl;}
};// 示例用法
int main() {// 用于存储所有节点,方便查找unordered_map<int, Node*> nodes;// 创建多个单元素集合Set* set1 = new Set(1);nodes[1] = set1->getHead();  // 使用公有方法获取头节点Set* set2 = new Set(2);nodes[2] = set2->getHead();  // 使用公有方法获取头节点Set* set3 = new Set(3);nodes[3] = set3->getHead();  // 使用公有方法获取头节点Set* set4 = new Set(4);nodes[4] = set4->getHead();  // 使用公有方法获取头节点// 打印初始集合cout << "初始集合:" << endl;set1->printSet();set2->printSet();set3->printSet();set4->printSet();// 合并集合Set::unite(set1, set2);cout << "\n合并1和2后:" << endl;set1->printSet();Set::find(2, nodes)->printSet();  // 2现在也属于set1// 合并更多集合Set::unite(set3, set4);cout << "\n合并3和4后:" << endl;set3->printSet();Set::unite(set1, set3);cout << "\n合并{1,2}和{3,4}后:" << endl;set1->printSet();// 查找元素所属集合cout << "\n元素4所属集合的代表元素:" << Set::find(4, nodes)->getRepresentative() << endl;cout << "元素2所属集合的代表元素:" << Set::find(2, nodes)->getRepresentative() << endl;// 清理内存for (auto& pair : nodes) {delete pair.second;}return 0;
}

运行结果:

21.3 不相交集合森林

        不相交集合的另一种更高效的表示方法是使用森林(一组树)每个集合用一棵树表示,树中的每个节点都有一个父节点,根节点代表整个集合

这种表示方法可以通过两种启发式策略进行优化:

  1. 按秩合并:总是将秩较小的树合并到秩较大的树中
  2. 路径压缩:在 find 操作时,将路径上的所有节点直接指向根节点

森林表示的结构

class DisjointSetForest {- parent: array[int]- rank: array[int]+ DisjointSetForest(n: int)+ find(x: int): int+ union(x: int, y: int): void
}

按秩合并流程图

路径压缩流程图

不相交集合森林的 C++ 实现

#include <iostream>
#include <vector>
using namespace std;// 不相交集合森林实现(带路径压缩和按秩合并)
class DisjointSetForest {
private:vector<int> parent;  // 父节点数组vector<int> rank;    // 秩数组(用于按秩合并)public:// 构造函数:初始化n个元素,每个元素自成一个集合DisjointSetForest(int n) {parent.resize(n);rank.resize(n, 0);  // 初始秩都为0for (int i = 0; i < n; ++i) {parent[i] = i;  // 每个元素的父节点是自身}}// 查找x所在集合的根节点(带路径压缩)int find(int x) {// 如果x不是根节点,递归查找其父节点的根,并将x直接指向根节点(路径压缩)if (parent[x] != x) {parent[x] = find(parent[x]);}return parent[x];}// 合并x和y所在的集合(按秩合并)void unite(int x, int y) {int rootX = find(x);  // 找到x的根int rootY = find(y);  // 找到y的根if (rootX == rootY) {return;  // 已经在同一个集合中}// 按秩合并:将秩较小的树合并到秩较大的树中if (rank[rootX] < rank[rootY]) {parent[rootX] = rootY;} else if (rank[rootX] > rank[rootY]) {parent[rootY] = rootX;} else {// 秩相等时,合并后秩加1parent[rootY] = rootX;rank[rootX]++;}}// 判断x和y是否在同一个集合中bool isConnected(int x, int y) {return find(x) == find(y);}// 打印当前的父节点和秩信息(用于调试)void printInfo() {cout << "索引: ";for (int i = 0; i < parent.size(); ++i) {cout << i << " ";}cout << endl;cout << "父节点: ";for (int p : parent) {cout << p << " ";}cout << endl;cout << "秩: ";for (int r : rank) {cout << r << " ";}cout << endl << endl;}
};// 示例:使用不相交集合森林解决连通性问题
int main() {int n = 6;  // 6个元素DisjointSetForest ds(n);cout << "初始状态:" << endl;ds.printInfo();// 执行一些合并操作ds.unite(0, 1);cout << "合并0和1后:" << endl;ds.printInfo();ds.unite(1, 2);cout << "合并1和2后:" << endl;ds.printInfo();ds.unite(3, 4);cout << "合并3和4后:" << endl;ds.printInfo();ds.unite(4, 5);cout << "合并4和5后:" << endl;ds.printInfo();ds.unite(2, 5);cout << "合并2和5后:" << endl;ds.printInfo();// 测试连通性cout << "0和5是否连通?" << (ds.isConnected(0, 5) ? "是" : "否") << endl;cout << "1和3是否连通?" << (ds.isConnected(1, 3) ? "是" : "否") << endl;return 0;
}

运行结果:

*21.4 带路径压缩的按秩合并的分析

        不相交集合森林在使用按秩合并和路径压缩两种优化策略后,其时间复杂度非常接近常数

        具体来说,对于 m 个操作(MAKE_SET、UNION 和 FIND)和 n 个 MAKE_SET 操作,使用带路径压缩的按秩合并的不相交集合森林的时间复杂度为 O (mα(n)),其中 α 是 Ackermann 函数的反函数。

Ackermann 函数定义如下:

  • A (1, j) = 2^j ,对于 j ≥ 1
  • A (i, 1) = A (i-1, 2) ,对于 i ≥ 2
  • A (i, j) = A (i-1, A (i, j-1)) ,对于 i ≥ 2 和 j ≥ 2

反 Ackermann 函数 α(n) 是使得 A (k, k) ≥ n 的最小整数 k。

        α(n) 的增长极其缓慢,对于实际应用中可能遇到的所有 n 值(甚至 n 高达 10^600),α(n) 都不会超过 4。因此,在实际应用中,我们可以认为带路径压缩的按秩合并的不相交集合操作是常数时间的。

不同实现的时间复杂度对比

实现方式查找操作合并操作m 个操作的总时间
简单链表(无优化)O(1)O(n)O(mn)
链表(带大小优化)O(1)O(log n)O(m log n)
森林(无优化)O(n)O(n)O(mn)
森林(按秩合并)O(log n)O(log n)O(m log n)
森林(按秩合并 + 路径压缩)O(α(n))O(α(n))O(m α(n))

综合案例: Kruskal 算法求最小生成树

        Kruskal 算法是一种用于寻找最小生成树的经典算法,它广泛应用了不相交集合数据结构来高效地判断新加入的边是否会形成环。

#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;// 不相交集合森林实现(带路径压缩和按秩合并)
class DisjointSetForest {
private:vector<int> parent;vector<int> rank;public:DisjointSetForest(int n) {parent.resize(n);rank.resize(n, 0);for (int i = 0; i < n; ++i) {parent[i] = i;}}int find(int x) {if (parent[x] != x) {parent[x] = find(parent[x]);}return parent[x];}void unite(int x, int y) {int rootX = find(x);int rootY = find(y);if (rootX == rootY) return;if (rank[rootX] < rank[rootY]) {parent[rootX] = rootY;} else if (rank[rootX] > rank[rootY]) {parent[rootY] = rootX;} else {parent[rootY] = rootX;rank[rootX]++;}}
};// 边的结构:起点、终点、权重
struct Edge {int u, v, weight;Edge(int u_, int v_, int weight_) : u(u_), v(v_), weight(weight_) {}// 用于排序bool operator<(const Edge& other) const {return weight < other.weight;}
};// Kruskal算法求最小生成树
vector<Edge> kruskal(int n, vector<Edge>& edges) {vector<Edge> mst;  // 最小生成树的边集合DisjointSetForest ds(n);  // 不相交集合,用于检测环// 按权重从小到大排序所有边sort(edges.begin(), edges.end());// 依次加入边for (const Edge& e : edges) {// 如果两个顶点不在同一个集合中,加入这条边不会形成环if (ds.find(e.u) != ds.find(e.v)) {mst.push_back(e);ds.unite(e.u, e.v);// 当已加入n-1条边时,最小生成树完成if (mst.size() == n - 1) {break;}}}return mst;
}int main() {int n = 6;  // 6个顶点vector<Edge> edges;// 添加边edges.emplace_back(0, 1, 4);edges.emplace_back(0, 2, 2);edges.emplace_back(1, 2, 5);edges.emplace_back(1, 3, 10);edges.emplace_back(2, 3, 3);edges.emplace_back(2, 4, 8);edges.emplace_back(2, 5, 12);edges.emplace_back(3, 4, 7);edges.emplace_back(4, 5, 9);// 求最小生成树vector<Edge> mst = kruskal(n, edges);// 输出结果cout << "最小生成树的边:" << endl;int totalWeight = 0;for (const Edge& e : mst) {cout << e.u << " - " << e.v << " 权重:" << e.weight << endl;totalWeight += e.weight;}cout << "最小生成树的总权重:" << totalWeight << endl;return 0;
}

运行结果:

思考题

  1. 实现一个不相交集合数据结构,支持以下操作:

    • MAKE_SET (x):创建一个新集合
    • UNION (x, y):合并两个集合
    • FIND (x):查找元素所在集合
    • COUNT (x):返回 x 所在集合的元素个数
  2. 如何使用不相交集合数据结构来解决以下问题:
    给定一个包含 n 个元素的数组,以及一系列操作,每个操作是以下两种之一:

    • 合并索引 i 和 j 处的元素
    • 查询索引 i 和 j 处的元素是否在同一个集合中
      请实现一个高效的数据结构来处理这些操作。
  3. 证明:带路径压缩和按秩合并的不相交集合森林的每个 FIND 操作的摊还时间复杂度为 O (α(n))。

  4. 设计一个算法,使用不相交集合数据结构来检测一个无向图是否包含环。

本章注记

        不相交集合数据结构,也称为并查集,最初是由 Galler 和 Fischer 于 1964 年提出的。按秩合并和路径压缩这两种优化策略也源自他们的工作。

        Tarjan 在 1975 年对带路径压缩的按秩合并的不相交集合进行了深入分析,证明了其时间复杂度为 O (mα(n))。他还在 1981 年证明了如果仅使用路径压缩,而不使用按秩合并,则最坏情况下的时间复杂度为 O (m log n)。

不相交集合数据结构在计算机科学中有广泛应用,除了前面提到的最小生成树算法外,还包括:

  • 图像处理中的区域标记
  • 编译器中的类型等价性检查
  • 社交网络中的连通分量分析
  • 动态连通性问题
  • 等价关系问题

        在实际应用中,不相交集合数据结构通常以森林形式实现,并结合按秩合并和路径压缩两种优化策略,以获得接近常数的时间复杂度。

总结

        不相交集合数据结构是一种高效处理元素分组问题的工具,主要支持 MAKE_SET、UNION 和 FIND 三种操作。通过本章的学习,我们了解到:

  1. 不相交集合可以用链表或森林来表示
  2. 森林表示结合按秩合并和路径压缩两种优化策略后,能达到接近常数的时间复杂度
  3. 不相交集合在许多算法中都有重要应用,如 Kruskal 最小生成树算法

        掌握不相交集合数据结构,对于解决各种涉及元素分组和连通性的问题具有重要意义。其高效性和简洁性使其成为算法设计者的重要工具。

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

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

相关文章

基于51单片机RFID智能门禁系统红外人流量计数统计

1 系统功能介绍 本设计基于STC89C52单片机&#xff0c;集成RFID读卡器、红外避障传感器、继电器、LCD1602液晶显示和蜂鸣器&#xff0c;实现智能门禁与人流量统计功能。系统能够识别合法的RFID卡开门&#xff0c;并实时统计通过人数&#xff0c;具有安全报警和直观显示功能。具…

c#,vb.net全局多线程锁,可以在任意模块或类中使用,但尽量用多个锁提高效率

Public ReadOnly LockObj As New Object() 全局多线程锁 VB.NET模块中的LockObj 可以在任意模块或类中使用吧 在 VB.NET 中&#xff0c;模块&#xff08;Module&#xff09;中声明的 Public ReadOnly LockObj 可以被其他模块或类访问和使用&#xff0c;但需要注意其可见性范围…

企业安全运维服务计划书

安全运维服务计划书 一、概述 为保障企业信息系统安全、稳定、高效运行,防范各类网络安全风险,提升整体安全防护能力,特制定本安全运维服务计划书。本计划旨在通过系统化、规范化的安全运维流程,全面识别、评估、处置并持续监控企业网络环境中的安全风险,构建主动防御与…

小杰python高级(four day)——matplotlib库

1.绘制子图的方式pyplot中函数subplotFigure类中的函数add_subplotpyplot中函数subplotsfig, ax plt.subplots(nrows1, ncols1, *, sharexFalse, shareyFalse,squeezeTrue, subplot_kwNone, gridspec_kwNone, **fig_kw) 功能&#xff1a;绘制多个子图&#xff0c;可以一次生成…

C# 编程out 参数需要在函数体内部初始化,然后引用的时候无需初始化

核心规则方法内部必须初始化&#xff1a;在方法体中&#xff0c;必须在方法返回前对 out 参数显式赋值&#xff08;未赋值会导致编译错误&#xff09;调用时无需初始化&#xff1a;调用方传递 out 参数前不需要初始化变量&#xff08;可直接使用未赋值的局部变量&#xff09;下…

【Redis在数据治理与数据隐私保护策略中的优化】

## Redis的自动补全功能&#xff1a;用户体验的无缝之助Redis作为一款高效的开源缓存数据库&#xff0c;始终在用户体验优化方面走在前列。其自动补全功能的引入&#xff0c;为用户带来了全新的搜索体验。这种功能不仅提升了搜索效率&#xff0c;更为用户提供了更智能化的服务。…

Sklearn 机器学习 异常值检测 局部异常因子算法LOF

💖亲爱的技术爱好者们,热烈欢迎来到 Kant2048 的博客!我是 Thomas Kant,很开心能在CSDN上与你们相遇~💖 本博客的精华专栏: 【自动化测试】 【测试经验】 【人工智能】 【Python】 Sklearn 机器学习异常值检测:局部异常因子算法(LOF) 在实际的机器学习任务中,异常…

衡量机器学习模型的指标

为了进一步了解模型的能力&#xff0c;我们需要某个指标来衡量&#xff0c;这就是性能度量的意义。有了一个指标&#xff0c;我们就可以对比不同的模型了&#xff0c;从而知道哪个模型相对好&#xff0c;哪个模型相对差&#xff0c;并通过这个指标来进一步调参以逐步优化我们的…

Day24|学习前端CSS

HTML把一大段杂乱无章的话&#xff0c;调整变成文章格式颜色rgba&#xff0c;16进制CSS选择器&#xff08;从上往下&#xff0c;权重越低&#xff09;类选择器#&#xff08;为多个元素设计相同样式伪类选择器&#xff1a;和类选择器.元素选择器p&#xff0c;div&#xff0c;li通…

初识数据结构——优先级队列(堆!堆!堆!)

数据结构专栏 ⬅(click) 今天就让我们来聊聊这个让无数程序员又爱又恨的数据结构——堆&#xff08;Heap&#xff09;。 一、优先级队列 vs 普通队列 特性普通队列优先级队列出队顺序FIFO&#xff08;先进先出&#xff09;按优先级高低&#xff08;默认小的先出&#xff09;底…

嵌入式学习day25

fwrite&#xff1a;fread&#xff1a;fread/fwrite&#xff1a;拷贝图片&#xff1a;#include <stdio.h>int main(void) {FILE *fsrc NULL;FILE *fdst NULL;char tmpbuff[4096] {0};size_t nret 0;fsrc fopen("src.jpg", "r");if (NULL fsrc){…

2025年中科院2区红杉优化算法Sequoia Optimization Algorithm-附Matlab免费代码

1. 简介 提出了红杉优化算法&#xff08;SequoiaOA&#xff09;&#xff0c;这是一种受红杉森林生态系统自我调节动力学和弹性启发的新型元启发式方法&#xff0c;不同于传统的奇异生物学或现象学灵感。开发一个全面的生态系统驱动框架&#xff0c;包括数学建模、系统分析和通过…

【C#】从 Queue 到 ConcurrentQueue:一次对象池改造的实战心得

背景 最近在做一个图像处理的 WPF 项目&#xff0c;底层使用 Halcon 的 HObject 来存放图像。为了减少频繁创建和释放对象带来的开销&#xff0c;我实现了一个对象池&#xff0c;用来存放 HObject&#xff0c;方便后续流程复用。 最初的实现用的是 .NET 自带的 Queue<T>&…

深度解析 AS32S601 芯片 CAN Bus Off 机制:从原理到应用的全流程指南

一、前言在汽车电子、工业自动化等众多领域&#xff0c;CAN 总线作为一种可靠的通信协议被广泛应用。而 AS32S601 芯片凭借其卓越的性能和可靠性&#xff0c;在这些领域也发挥着重要作用。其中&#xff0c;CAN Bus Off 功能作为 CAN 总线通信中的关键错误处理机制&#xff0c;对…

PyCharm Community 2024.2.3.exe 安装教程(详细步骤,附安装包下载)

​1. 下载安装包​ 安装下载地址&#xff1a;https://pan.quark.cn/s/ca11cb817ee5&#xff0c;你已经下载好了 pycharm-community-2024.2.3.exe 这个文件&#xff08;通常是从 JetBrains 官网下的&#xff09;。双击这个 .exe 文件开始安装。 ​2. 开始安装向导​ 双击后&am…

JAVA:SpringBoot 集成 Selenium 实现高效爬虫

🌐 1、简述 在互联网数据采集中,传统基于 Jsoup 或 HttpClient 的爬虫方案面对复杂 JavaScript 渲染页面时经常力不从心。此时,Selenium WebDriver 提供了更强大的模拟真实浏览器行为能力,成为爬取动态网站的利器。 为了绕过反爬机制,结合 IP 代理池 是提升稳定性和并发…

终端安全检测和防御技术

目录 1. 终端安全风险 2. 终端安全检测和防御技术 3. 网关杀毒技术 3.1 计算机病毒工作步骤 3.2 杀毒防御产品 3.3 网关杀毒功能优势 3.4 网关杀毒实现方式 4.僵尸网络检测和防御技术 4.1 僵尸网络 4.2 僵尸网络的形成过程&#xff08;APT场景下&#xff09; 4.3 检测…

Java缓冲流

字节缓冲流&#xff1a;原理&#xff1a;底层自带长度为8192的缓冲区提高性能拷贝文件一次读一个字节一次读一个字节数组字节缓冲流的读写原理字符缓冲流&#xff1a;特定方法字符缓冲输入流基本写法输入所有数据字符缓冲流输出总结

web服务器tomcat内部工作原理以及样例代码

目录 一、Tomcat 运行原理与 Servlet 机制 1、为什么 Java Web 项目需要 Tomcat 2. 进程模式 vs 线程模式 3、Servlet / Controller 是怎么跟 Tomcat 对接的? 4、java反射与代理机制 ※--高级知识点 (1)原理 (1)样例:用反射和注解模拟 Tomcat 处理 HTTP 请求时,动…

AI赋能IT服务管理:从被动响应到智能驱动的跃迁

过去十年&#xff0c;IT服务管理&#xff08;ITSM&#xff09;经历了从纸质工单到数字化平台的变革&#xff0c;但无论工具多么先进&#xff0c;大多数IT团队依然面临着相同的困境&#xff1a;事件处理速度跟不上业务变化人工重复操作占用大量时间数据虽多&#xff0c;却缺乏可…