大家好,很高兴又和大家见面啦!!!
在上一篇内容中我们从数据结构的三要素初步认识了并查集这种数据结构,但是上一篇对并查集的介绍并不准确。
那我们对并查集的认识存在哪些错误呢?我们又应该如何实现并查集呢?在今天的内容中,我们将会从数据结构的三要素出发,进一步学习并实现并查集。
并查集是一种高效管理不相交集合的数据结构,专门解决动态连通性问题。它通过树形结构组织集合元素,支持以下核心操作:
并查集是一种高效管理不相交集合的数据结构
从第一句话的前部分中,我们需要提取3个关键信息:
在上一篇内容中,我们认识了并查集是一种数据结构,其逻辑结构是集合,但是为什么说并查集是高效管理呢?
这是因为在实际的使用过程中,我们通过对并查集实现优化后能够做到近似常数级的时间复杂度。具体如何实现,后面我们会继续介绍,这里先不展开。
这里说的高效管理不相交集合实际上是指并查集中各个不相交的子集。
专门解决动态连通性问题
在第一句话的后半部分中,我们需要理解什么是动态连通性问题。
简单来说,就是查询两个元素是否连通,对两个不联通的元素建立连通关系。这也就是我们所说的查找与合并。
通过树形结构组织集合元素
这里所说的树形结构与传统的树形结构是有区别的:
现在我们从定义出发,对并查集做出了一个更详细的介绍,下面我们继续来看一下并查集这种数据结构的三要素。
并查集在逻辑上是由各棵不相交的子集组成的集合,子集中的元素在逻辑上呈现树形结构,每棵树的根结点作为子集的代表元素。
在上一篇内容中,我们介绍了两种并查集的存储结构——顺序存储和链式存储。
但是上一篇的介绍是基于我们对并查集的初步理解而实现的,因此上一篇中提到的两种存储方式显然不能更加准确的表示并查集。
那对于并查集这个数据结构,我们又应该使用怎样的存储结构呢?下面我们就来分析一下;
在树与森林中,常用的存储结构有3种:
//双亲表示法
typedef struct ParentNode {
ElemType data; // 数据域
int parent; // 双亲域
}PNode;//双亲结点
typedef struct Parent {
PNode* list;//通过顺序表实现
int n;//结点数量
}PTree;//双亲树
//孩子表示法
typedef struct ChildNode {
int pi;//孩子位置信息
struct ChildNode* next_child;//孩子指针
}CNode;//孩子结点
typedef struct ParentNode {
ElemType data;//结点数据域
CNode* child;//孩子指针域
}PN;//双亲结点
typedef struct ChildTree {
PN* list;//顺序表
int n;//结点数量
}CTree;//孩子树
//孩子兄弟表示法
typedef struct ChildBrotherNode {
ElemType data;//数据域
struct ChildBroherNode* lchild, * rbrother;//左孩子、右兄弟指针域
}CBNode, * CBTree;//孩子兄弟结点、孩子兄弟树
在并查集中,元素是自下而上的方式组成的一棵树,这种形态与树的双亲表示法一致,都是通过孩子找双亲。因此双亲表示法更符合并查集。
因此我们可以借助双亲表示法来作为并查集的存储结构,每个结点会存放两个信息——结点数据以及双亲位置信息:
//顺序存储
typedef struct DSUNode {
ElemType data;//数据域
int parent;//双亲位置信息
}DSUNode;//并查集结点
typedef struct DSU {
DSUNode* list;//顺序表
int len;//表长
}DSU;//并查集
在并查集中,支持3种操作:
Initial(S)
:将集合S中的每个元素都初始化为只有一个单元素的子集合;Find(S,x)
:查找集合S中元素x所在子集,并返回子集的根结点;Union(S,Root1,Root2)
:将集合S中不相交的子集Root1与Root2进行合并;那也就是说,我们不会对并查集中的元素进行任何的添加与修改的问题,因此我们能否认为并查集实际上就是一个映射表,通过并查集这个映射表来实现对元素的查找与逻辑上的合并。
可能有朋友不理解我想表达的是什么意思,下面我们就来对这个观点进行说明。
首先并查集中的元素是在逻辑上呈现自下而上的树形结构,并不是物理上的自下而上,因此我们可以认为并查集中的元素在物理上并没有限制;
其次,通过并查集的双亲指针的指向,我们实现了将物理上无限制的元素,在逻辑上进行了连接,也就是通过并查集,指示了各个结点之间的关系,对元素本身并没有任何改变;
最后,在并查集中,我们是对元素所在的集合进行查找,对两个不相交的子集进行合并,也就是说我仅通过双亲指针即可实现全部操作。
因此在实际的使用过程中,我们并不需要关心元素的数据信息,只需要关系该元素的双亲指针,那么我们不妨通过创建一个元素与双亲指针的映射关系来实现对指定元素的查找与合并即可。
如对于小写字母而言,我们可以采用长度为26的字符数组,通过数组下标与'a'~'z'
进行一一对应,数组中存放该元素的双亲位置信息,这样我们就能通过这种映射关系快速的找到指定元素所在集合,如下所示:
//顺序存储
typedef struct DSU {
char parent[26];//双亲位置信息
}DSU;//并查集
//元素与下标之间的映射关系
int GetKey(char x) {
return x - 'a';
}
在这种思路下,那么我们对查找与合并的实现就很简单了:
理解了这种映射思路,那我们就可以通过C语言来实现并查集了;
在具体的实现中,我们以字符元素为例,简单一点,我们就直接采用小写字母为例进行实现。
小写字符总共有26个,因此并查集的最大长度也是26个。这里我们直接创建一个长度为26的字符数组,并将数组中的元素初始化为-1,表示该数组中的元素全部为独立的子集:
#define MAXSIZE 26
void Initial(DSU* s) {
assert(s);
for (int i = 0; i < MAXSIZE; i++) {
s->parent[i] = -1;//将元素初始化为单个子集
}
printf("完成初始化\n");
}
查找操作是查找该元素所在的子集,而并查集中,每棵子树的根结点作为该子集的代表元素,因此查找某个元素所在子集,实际上就是查找该元素的所在子集的根节点。
这里我们以双亲指针为-1代表该结点为根结点,因此我们只需要根据双亲指针查找到值为-1的根结点即可:
//元素与下标之间的映射关系
int GetKey(char x) {
return x - 'a';
}
//查找
int Find(DSU* s, char x) {
int key = GetKey(x);//获取该元素所映射的双亲指针下标
while (s->parent[key] != -1) {
key = s->parent[key];//自下而上查找根结点
}
return key;//返回根结点所映射的下标
}
并查集的合并操作是将两个不相交的子集进行合并,如果,此时的两个子集相等,我们则不需要执行任何操作,如果不相等,我们只需要将其中一个子集的双亲指针指向另一个子集的根结点即可:
//合并
void Union(DSU* s, int root1, int root2) {
if (root1 != root2) {
s->parent[root1] = root2;
printf("完成合并\n");
return;
}
printf("合并对象为同一个子集,无法合并\n");
}
现在我们简单的测试一下:
可以看到,此时我们完成了并查集的合并与查找操作。
这里我们对算法从时间复杂度和空间复杂度的角度做一个简单的分析;
在上述算法的实现中我们不难发现,在查找算法的过程中,是通过遍历数组的方式实现的查找。
那也就是说,如果在最坏的情况下完成查找工作,即对于n个元素,其组织成的树的树高也为n,那么这时的并查集就等价与一个顺序表,元素需要完成整个顺序表的遍历,因此对应的时间复杂度为O(N);
在最好的情况下,那就是查找的元素为根结点,那此时的时间复杂度为O(1);
正常情况下,查找的时间复杂度与元素所组成的树的高度相关,若高度为h,则时间复杂度为O(h)
在这里的测试中,我是通过申请了一个大小为sizeof(DSU)的空间,而改空间的大小为常数级的大小,因此对应的空间复杂度为O(1);
在今天的内容中我们进一步介绍了什么是并查集:
在并查集中,支持两种核心操作——合并与查找。
我们通过元素与双亲指针之间的映射关系,实现了仅借助存放双亲指针的数组,完成元素的查找与合并的操作。
在整个算法中,其对应的时间复杂度与空间复杂度分为为:
今天的内容到这里就全部结束了,在下一篇内容中我们将介绍《并查集的算法优化》,大家记得关注哦!
如果大家喜欢博主的内容,可以点赞、收藏加评论支持一下博主,当然也可以将博主的内容转发给你身边需要的朋友。最后感谢各位朋友的支持,咱们下一篇再见!!!