一篇文章彻底搞懂并查集!
最近在复习数据结构,又复习到了并查集 - - ,复习完之后发现之前写的那个笔记写的什么鬼,然后我就把知识点重新整理一下,顺便水一篇博客(bushi)。本文章是结合个人见解整理的并查集知识,如果有不当的地方欢迎交流指出。
前置基础知识
-
树的概念:(这里以二叉树为例)
- 树(tree)是n**(n>0)**个结点的有限集。
- 树中每个结点对应两个指针,其中一个指针指向左子树,另一个指针指向右子树。
-
森林的概念:
- 森林(forest)是m (m≥0) 棵互不相交的树的 集合。(区别于现实的独木不成林)
-
树的储存结构:(这里只介绍目前笔者常见两种)
- 孩子兄弟表示法:
- 这个结构我在刷题用的很多,放在二叉树里就是定义一个结点,其中开辟一个储存数据的空间,接着开辟储存左右孩子的指针的空间,结点定义如下:
typedef struct TreeNode {
elemtype data;
struct TreeNode *left;
struct TreeNode *right;
} NodeType, *CSTree;
- 双亲表示法:
-
并查集使用双亲表示法的原因是:
-
每个结点都有独一无二的下标,通过该下标可以直接找到该结点以及对应的双亲结点,而孩子兄弟表示法(上一种方法)则不能做到。 -
双亲表示法可以更好的表示森林,而孩子兄弟法需要开辟额外空间记录根结点来记录森林。
并查集要解决的问题是
?
? 连通性问题。 举个栗子,假如我们现在在A点,现在A可以走到B,B又可以走到C,问我们是否可以走到C点,这个问题就可以用并查集来解决。
? 当然会遇到一些具体问题,比如判断成环问题,与图结合有关问题等,我们遇到具体问题需要具体分析。
几种并查集版本
quick_find:
- 思路:如果两个结点连通,那么所有连通的结点就统一都用一个结点的编号标记。类似于染色分队,只要连通的都用同一个颜色标记,我就能判断两个结点是否连通了。如下图所示,我们可以根据颜色区分任意两个人是否连通(属于同一个队伍)了。
- 举个栗子,现在我们定义了(0 - 9)十个结点,每个结点的双亲如下图所示。现在我们要将下标5和下标9连通,可以看到下标5的双亲为1,下标9的双亲为8。那么此时我们要做的就是把双亲为1的结点更新其双亲为8。如下图所示:
int quick_find(int* parents,int node)
{
return parents[node];
}
void merge(int* parents, int size, int a, int b)
{
if(parents[a] == parents[b]) return;
int target = parents[b];
for(int i = 0; i <= size; i++)
if(parents[i] == target) parents[i] = parents[a];
}
-
时间复杂度:
- 查找:O(1),下标对应双亲就是根结点,无需往上遍历。
- 合并:O(n),每次合并都要遍历数组,将符合条件的结点的双亲更新。
quick_union:
-
代码: int find_root(int* parents, int node)
{
while(parents[node] != node) node = parents[node];
return node;
}
void quick_union(int* parents, int a, int b)
{
parents[find_root(parents, b)] = find_root(parents, a);
}
-
时间复杂度:
weighted_quick_union:
-
回到刚刚的情况(上图所示),第二步合并0和2中,假如我们在合并操作的时候选择1为2的根结点的话,这样树就不会退化成链表了,因此我们需要将合并操作进行改进。
-
改进思路: ? 增加一个数组,用来记录结点的权重(权重可以为该结点所在子树所有结点的数量,也可以为该结点的高度。本文采用结点数量作为权重)。在合并操作中,我们需要把权重小的根结点的双亲设置成权重大的根结点的双亲,这样就可以避免树退化成链表的情况了。 -
quick_union 和 weighted_quick_union 的比较:
-
代码: typedef struct Set
{
int* data;
int* weight;
int size;
}Set;
Set *init_set(int n)
{
Set *set = (Set *)malloc(sizeof(Set));
set -> data = (int *)malloc(sizeof(int) * (n + 5));
set -> weight = (int *)malloc(sizeof(int) * (n + 5));
set -> size = n;
for(int i = 0; i < (n + 5); i++)
set -> data[i] = i, set -> weight[i] = 1;
return set;
}
void free_set(Set* set)
{
free(set -> data);
free(set -> weight);
free(set);
}
int find_root(Set* set, int node)
{
if(node == set -> data[node]) return node;
return find_root(set, set -> data[node]);
}
void quick_union_weighted(Set* set, int a, int b)
{
int roota = find_root(set, a);
int rootb = find_root(set, b);
if(roota == rootb) return;
if(set -> weight[roota] < set -> weight[rootb])
{
set -> weight[rootb] += set -> weight[roota];
set -> data[roota] = rootb;
}
else
{
set -> weight[roota] += set -> weight[rootb];
set -> data[rootb] = roota;
}
}
-
时间复杂度:
路径压缩优化:
? quick_union的弊端所构建的树在于极端情况下会退化成链表,我们可以看到,经过weighted_quick_union改良后,树的高度明显减小。
? 我们知道,树的高度减小,意味着我们查找的时间效率提升。那么有没有一种方法,使得我们树的高度尽可能小呢?(非常理想的情况:连通的所有结点的双亲都指向同一个根结点),如下图所示:
? 那如果我们在查找双亲的时候,顺便把图中查找过的结点的双亲结点全部指向根结点,那这颗树不就变得很扁平了?
? 这就是路径压缩算法的原理。
-
代码: 其实也非常简单,我们只用在查找过程中递归返回更新每一个结点的双亲结点为根结点就可以了。 int find_root2(Set* set, int node)
{
if(node == set -> data[node]) return node;
return set -> data[node] = find_root2(set, set -> data[node]);
}
-
时间复杂度:
相关题目:
- 冗余连接
- 冗余连接2
- 岛屿数量
- 被围绕的区域
- 最长连续序列
参考资料
- 数据结构——树和森林
- 并查集(Union-Find)算法介绍
- 学习笔记:图解,以小白的思维角度详细理解并查集(c++代码示例)
|