并查集的基本概念
- 并查集支持查找一个元素所属的集合以及两个元素各自所属的集合的合并等运算。当给出两个元素的一个无序对(a,b)时,需要快速“合并”a和b分别所在的集合,这期间需要反复“查找”某元素所在的集合,“并”、“查”和“集”3个字由此而来。在这种数据类型中,n个不同的元素被分为若干组,每组是一个集合,这种集合叫分离集合,称之为并查集(disjoint-set)。
- 对于给定的编号为1~n的n个元素,x表示其中的一个元素,设并查集为S,并查集的实现需要支持如下运算:
- MAKE_SET(S,m):初始化并查集S,即
S
=
S
1
,
S
2
,
…
,
S
n
S={S1,S2,…,Sn}
S=S1,S2,…,Sn,每个动态集合
S
i
(
1
≤
i
≤
n
)
S_i(1≤i≤n)
Si?(1≤i≤n)仅仅包含一个编号为i的元素,该元素作为集合
S
i
S_i
Si?的“代表”。
- FIND_SET(S,x):返回并查集S中x元素所在集合的代表。
- UNION(S,x,y):在并查集S中将x和y两个元素所在的动态集合(例如
S
x
S_x
Sx?和
S
y
S_y
Sy?合并为一个新的集合
S
x
∪
S
y
S_x∪S_y
Sx?∪Sy?。并且假设在此运算前这两个动态集合是分离的,通常以
S
x
S_x
Sx?或者
S
y
S_y
Sy?的代表作为新集合的代表。
并查集的算法实现
- 并查集必须借助某种数据结构来实现,数据结构的选择是一个重要的环节,选择不同的数据结构可能会在查找和合并的操作效率上有很大的差别。并查集的数据结构的实现方法很多,使用比较多的有数组实现、链表实现和树实现。这里主要介绍树实现方法,树的存储结构主要有三种:双亲存储结构、孩子链存储结构以及孩子兄弟存储结构。对于并查集的操作而言,需要快速找到一个结点的双亲结点,因此适用双亲存储结构是最合适不过的。
- 用有根树表示集合,树中的每个结点包含集合的一个元素,每棵树表示一个集合。多个集合形成一个森林,以每棵树的树根作为集合的代表,树中每个结点有一个指向双亲结点的指针,根结点的双亲结点指针指向其自身。
- !注意:在同一棵树中的结点属于同一个集合,虽然它们在树中存在父子结点关系,但并不意味着它们之间存在从属关系。树中的指针起的只是联系集合中元素的作用在并查集中,每个分离集合对应的一棵树称为分离集合树。整个并查集也就是一个分离集合森林。
- 为了方便,采用顺序方法存储森林,其结点的类型声明如下:
#define SIZE 100
int UFSets[SIZE];
并查集树的初始化
void Initial(int S[])
{
for (int i = 0; i < SIZE; i++)
S[i] = -1;
}
查找一个元素所属的集合
- 在分离集合森林中,每一棵分离集合树对应一个集合。如果要查找某一元素所属的集合,就是要找这个元素对应的结点所在的分离集合树。
- 不妨以分离集合树的根结点的编号来标识这个分离集合树,这样查找一个结点所在的分离集合树也就是查找该结点所在分离集合树的根结点。
- 查找树的根结点的方法很简单,只需任取树中的一个结点(不妨取要查找的那个结点),沿双亲结点方向一直往树根走:初始时,取一个结点,走到它的双亲结点,然后以双亲结点为基点,走到双亲结点的双亲结点,…,直至走到一个没有双亲结点的结点为止,这个结点就是树的根结点。
int Find(int S[], int x)
{
while (S[x] >= 0) x = S[x];
return x;
}
- 对于n个结点,查找一个元素所在的集合最好的时间复杂度为O(1),最坏的时间复杂度为O(n)。
两个元素各自所属的集合的合并
- 给定两个元素,首先要找到它们各自所属的集合,如果二者已属于同一个集合,则不需要进行任何操作,如果二者所属不同的集合(二者所在树的根结点不同),才有进行合并的必要。
void Union(int S[], int x, int y)
{
int Root1 = Find(S, x);
int Root2 = Find(S, y);
if (Root1 == Root2) return;
S[Root2] = Root1;
}
- 合并两个集合的元素首先要分别找到二者所在的树的根,因此时间复杂度与查找相同,找到两个根结点再进行合并只需要O(1)的时间复杂度。
并查集的优化
- 显然在一棵高度较低的树中查找根结点的编号(即该集合的代表)所花的时间较少。因此,并查集的优化主要是想办法让树变得"矮胖",从而提高查找的效率。
合并时调整
- 如果有两棵分离集合树A和B,高度分别为
h
A
h_A
hA?和
h
B
h_B
hB?,若
h
A
>
h
B
h_A>h_B
hA?>hB?,应将B树作为A树的子树;否则,应将A树作为B树的子树。总之,总是将高度较小的分离集合树作为子树。得到了新的分离集合树C的高度
h
c
h_c
hc?,如以B树作为A树的子树,
h
c
=
M
A
X
{
h
A
,
h
B
+
1
}
h_c=MAX\{h_A,h_B+1\}
hc?=MAX{hA?,hB?+1}。
void Union1(int S[], int x, int y)
{
int Root1 = Find(S, x);
int Root2 = Find(S, y);
if (Root1 == Root2) return;
if (S[Root2] > S[Root1])
{
S[Root1] += S[Root2];
S[Root2] = Root1;
}
else
{
S[Root2] += S[Root1];
S[Root1] = Root2;
}
}
- 这样合并得到的分离集合树的高度不会超过
l
o
g
2
n
log_2^n
log2n?,是一个比较平衡的树,对应的查找与合并的时间复杂度也就稳定在
O
(
l
o
g
2
n
)
O(log_2^n)
O(log2n?)了。
进行路径压缩
- 除了在集合合并时调整之外,路径压缩也是一种良好的方案。它是指:将某个要查找的结点到根结点路径上所有的结点直接连接到根结点的下方。这样做的好处是,在下次要查找该结点时,时间复杂度仅需要O(1)。
int Find1(int S[], int x)
{
int root = x;
while (S[root] >= 0) root = S[root];
while (x != root)
{
int t = S[x];
S[x] = root;
x = t;
}
return root;
}
- 每次Find操作,先找根,再"压缩路径",可使树的高度不超过
O
(
α
(
n
)
)
O(α(n))
O(α(n))。
α
(
n
)
α(n)
α(n)是 一个增长很缓慢的函数,对于常见的n值,通常
α
(
n
)
α(n)
α(n)<4,因此优化后并查集的Find、Union操作时间开销都很低。
总结
完整代码
#include<stdio.h>
#define SIZE 50
typedef char ElemType;
typedef struct
{
ElemType data;
int parent;
}UFSets[SIZE];
void Initial(UFSets S[])
{
for (int i = 0; i < SIZE; i++)
{
S[i]->data = -999;
S[i]->parent = -1;
}
}
void Create(UFSets S[], int i)
{
ElemType data;
int parent;
printf("\n输入结点的数据:");
getchar();
scanf("%c", &data);
S[i]->data = data;
printf("——输入该元素的双亲结点对应的数组下标:");
scanf("%d", &parent);
S[i]->parent = parent;
while (S[i]->parent >= 0)
{
i = S[i]->parent;
if (S[i]->parent < 0) S[i]->parent -= 1;
}
}
int Find(UFSets S[], ElemType x, int num)
{
int i = 0;
while (S[i]->data != x&&i<num) i++;
if (i >= num) return - 1;
while (S[i]->parent >= 0) i = S[i]->parent;
return i;
}
bool Union(UFSets S[], char x, char y, int num)
{
int Root1 = Find(S, x, num);
int Root2 = Find(S, y, num);;
if (Root1 == Root2) return false;
else
{
S[Root1]->parent += S[Root2]->parent;
S[Root2]->parent = Root1;
return true;
}
}
void Print(UFSets S[], int num)
{
for (int i = 0; i < num; i++)
printf("\n%c(%d)", S[i]->data, S[i]->parent);
}
int main()
{
UFSets S[SIZE];
int num;
Initial(S);
printf("-----创建集合-----");
printf("\n输入要创建的单个元素个数:");
scanf("%d", &num);
for (int i = 0; i < num; i++) Create(S, i);
printf("\n-----打印信息表-----");
Print(S, num);
ElemType data;
printf("\n\n-----在并查集S中查找并返回包含元素x的树的根-----");
printf("\n输入要查找的元素的数据:");
data = getchar();
scanf("%c", &data);
int i = Find(S, data, num);
printf("包含元素%c的树的根的数据为:%c", data, S[i]->data);
ElemType x, y;
bool result;
printf("\n\n-----求两个不相交子集的并集-----");
printf("\n输入两个不相交的数据元素");
printf("\n输入第一个数据:");
x = getchar();
scanf("%c", &x);
printf("输入第二个数据:");
y = getchar();
scanf("%c", &y);
result = Union(S, x, y, num);
printf("\n合并结点%c和%c所在的两个集合——合并成功了吗?——%d(0代表失败,1代表成功)", x, y, result);
if (result) Print(S, num);
return 0;
}
运行结果
补充:在使用scanf("%c",&data);读取一个字符时,有时会遇到scanf吞回车符的情况。 这里所搜到几种常用的解决方法:
- 在scanf()中使用’\n’屏蔽回车符号。
scanf("\n%c",&c);
- 在scanf()格式串最前面添加空格,屏蔽回车字符
scanf(" %c",&c);
3.在接收字符前,使用getchar()来读取一次回车符号
getchar();
scanf("%c",&c);
!此次代码读取字符采取的是这种形式
- 在接收字符前,使用fflush()清空输入流中缓冲区中的内容
fflush(stdin);
scanf("%c",&c);
|