去年408的大纲发生比较大的变化,其中就包括之前从大纲中删除的并查集,没错,它又回来了。后来我为了应付初试,只是稍微了解了一些概念性的知识,没有过度深究,现在初试考完了,复试可能会考到代码实现,因此好好学习一下,做些笔记。
并查集其实就是集合的一种表示方法,它是借助我们在树中学过的知识——双亲表示法处理集合的,即去集合中的一个结点作为根节点,后来的结点指向该结点表明它们是一个集合的。
并查集之所以称为并查集,重点就在于“并”和“查”,因此,我们接下来就介绍如何用代码实现。
并查集的初始化操作
对于并查集,我们采用数组进行存储,数组中每个元素都有一个下标,且下标是自然数,一开始,这些数都是独立的,因此它们本身都是一个根结点,对于根结点,我们只需要将其元素值设置为一个负数就代表这个结点是一个根结点:
void Initial(int *arr, int n){
for(int i = 1; i <= n; i++) arr[i] = -1;
}
这里我们统一设置成-1是有目的的,具体后面讲。
并查集的查找操作
并查集的查找操作其实就是找到给定结点的根结点,因此我们只需要递归找到一个 i ,其arr[i]是小于0的,则此时的 i 就是根结点:
int Find(int *arr, int x){
int root = x;
while(arr[root] > 0) root = arr[root];
while(x != root){
int t = arr[x];
arr[x] = root;
x = t;
}
return root;
}
注意:这里我们找到根结点之后还有一个循环,这个循环的作用是将一路往上找的所有结点直接指向根结点,这样的好处就在于,我们下次再查找的时候,可以更快找到根结点且树的深度不会太大,时间复杂度就会一直控制在O(logn)甚至是O(1),而不是O(n)。
并查集的合并操作
对于给定的两个数,若是要将其合并,首先就是要找到两个的根结点,若是根结点一样,则说明已经在一个集合中了,因此直接结束,否则需要合并,那么此时就有一个问题了,到底应该第一个合并到第二个,还是第二个合并到第一个呢?对于合并,我们当然是希望合并之后的树的深度不会变得更大,否则这样就会导致时间复杂度增加,因此之前初始化操作全部设置为-1的目的就来了,之所以设置成-1,就是为了借助负数来知道树的大小,-1代表这棵树只有一个结点,合并的时候,只需要将一个根结点的值加到另一个根结点上即可,这样我们就可以通过根结点的值来判断究竟是哪一棵树大,从而知道应该怎样合并:
void Union(int *arr, int x, int y){
int root1 = Find(arr, x);
int root2 = Find(arr, y);
if(root1 != root2){
if(arr[root2] > arr[root1]){
arr[root1] += arr[root2];
arr[root2] = root1;
}else{
arr[root2] += arr[root1];
arr[root1] = root2;
}
}
}
这里,我们是通过树中结点的个数来判断合并的,可能有人会说要额外加一个记录树高的变量来判断更加严谨,毕竟树的大小不能反映树的高度,但是因为在合并前,我们调用了Find()函数,而调用一次Find()函数,树的高度又可能下降,因此树的高度本就不会太高,所以其实没必要单独设置一个变量来记录,因为以上操作已经能够很有效的提高效率了,关键也是这些代码很好写!
并查集的应用范围
并查集的适用范围有:
判断图的连通分量数和连通性
这个可以通过依次将输入的结点进行合并操作,之后遍历数组,记录负数的个数即可。
判断图是否有环
在合并前的先找到两个根结点,然后判断根结点是否一样,若不一样,合并,否则说明有环,因为并查集构建的是一棵树,而树中任意两个结点之间再添加一条边都会出现环。
实现Kruskal算法
我们首先根据边的权重从小到大排序,然后依次将边连接的两个结点进行合并操作,若是两个结点已经在一个集合中了,那就丢弃这条边,否则就加上,如此遍历即可实现。