并查集

本文主要来自于慕课网liuyubobobo老师的算法课程笔记,我在GitHub对该课程进行了详细的总结,查看本文源码可以点击此处。

本节主要介绍的是并查集这种数据结构,全文主要包含以下部分:

  1. 并查集的概念和简单实现
  2. union操作由浅入深的进行了三次优化。
  3. find操作进行了两次优化,又称之为路径压缩
  4. 最终得到一个并查集的版本,在日常刷题和面试中可用。

概述

本节详述了并查集这一数据结构,它常常被用来解决图中两个节点是否连接的问题。实现这一功能主要是靠内置了一个id数组,该数组用来存储对应节点的根节点是谁。

比如1,2,3,4,5都是属于根节点1下的,6,7,8,9,10都是属于根节点6下的,那么我们判断两个节点是否是连接的,只需要看它们存储的根节点是否相同,如2和3是连接的,因为id[2]=id[3]=1,而4和7则是不连接的,因为id[4]!=id[7]。所以借助id数组,我们可以很方便的判断两个节点是否连接。所以我们最终只要能够高效的维护这个数组即可。

代码设计

在整个实现过程中,有几次优化的过程,所以我们设计一个基类UnionFind,所有的优化基于继承该基类。

该基类中包含的成员变量和虚函数如下:

int count; // 数据的个数 
int *id; // 创建一个数组存储对应的根节点

int find(int x); // 根据传入的元素,寻找对应的根节点
void unionElements(int x, int y); // 将两个元素合并到一个组中去,也就是共享同一个根节点,使得二者连接,需要注意合并是两个组之间的合并,而不只是两个元素之间的合并
bool isConnected(int x, int y); // 判断两个元素是否连接

实现和优化

UnionFind1 实现

第一个版本的实现非常简单,具体实现参考这里,时间复杂度是O(n),可见效率也不会太高,但却是后面实现的基础。

  • 对于find函数,直接返回数组中对应元素的值,代码如下:
int find(int x) {
    return id[x];
}
  • 对于unionElements函数,如果传入的x和y本身id相同,则直接返回,否则选择将所有等于id[x]的元素id全部置为id[y],实现两个组的合并, 也就是在此处使得算法的复杂度为O(n),具体代码如下:
void unionElements(int x, int y){
    int xId = find(x);
    int yId = find(y);
    if (xId == yId) return;
    // 将所有为xId的全部加入yId中
    for (int i = 0; i < count; ++i) {
        if (find(i) == yId) {
            id[i] = xId;
        }
    }
  • 最后实现的isConnected,直接用find函数判断是否连接即可,代码如下:
bool isConnected(int x, int y) override {
	return find(x) == find(y);
}

UnionFind2 实现

在第一个版本的实现中,我们的id数组存储的是当前节点根节点,也就是当前节点属于哪一个组中,这个版本的实现参考此处。

举个并查集的经典例子,在倚天屠龙记中,现在要判断殷野王宋青书是不是一个门派(组)的,那么版本一中id[殷野王]=张无忌id[宋青书]=张三丰,所以凭借id数组,find函数查找起来非常快,但是unionElements就很慢了,毕竟像明教这样人多势众的,如果要合并门派就要把所有人的id改一下。

版本二中,我们试着让unionElements快一点,我们让id改成存节点的父亲,而不是根节点。以上面的例子来说,现在id[殷野王]=白眉鹰王,这样存储了以后,很明显find函数变成了一个不断向上查找的过程,最终找到根节点,比如id[殷野王]=白眉鹰王id[白眉鹰王]=张无忌,所以通过这样查找出了殷野王是张无忌这一派的。

那它使得unionElement快在哪了呢?比如现在我想让殷野王宋青书合成一派,直接把张三丰链接到张无忌下面即可,所以从原来版本一中查找武当所有弟子变成了只需要从宋青书顺藤摸瓜找到张三丰即可,这样查找的次数就变成了树的高度,而我们知道大部分树的高度是远小于树节点个数的。所以虽然最坏条件下时间复杂度仍是O(n),但也提高了效率,所以最终三个函数的实现如下:

  • 首先注意:在版本二中id数组存储的为当前节点的父节点。
  • 对于find函数,在这个版本中需要不断向上寻找根节点,实现如下
// 查找过程, 查找元素x所对应的集合编号
// O(h)复杂度, h为树的高度
int find(int x) {
    while (x != id[x]) {
        x = id[x];
    }
    return x;
}
  • 对于unionElements函数,这个版本中只需要把x的根节点合并到y的根节点中即可,实现如下:
// 合并元素x和元素y所属的集合
// O(h)复杂度, h为树的高度
void unionElements(int x, int y) {
    int rootX = find(x);
    int rootY = find(y);
    if (rootX == rootY) {
        return;
    }
    id[rootX] = rootY;
}
  • 对于isConnected并无改变,不做补充了,同时isConnected函数我们发现所有版本的实现基本一样,可以考虑提升到基类UnionFind中。

最终两个版本在main函数中对比测试100000步:版本一耗时19秒,版本二耗时8秒,可见提升很明显。

UnionFind3 实现

第三个版本实现的代码参考此处。

在第二个版本的基础上,我们能不能够进一步优化效率呢?先一起来看一个例子:
假设有两个组情况是1->2->3->4->5->6(记作x组)和7->8->9(记作y组)总上一步unionElements函数的代码中,我们知道执行的是id[rootX] = rootY;,所以将上面两个组合并,按照代码是将x组合并到y组中去,合并以后的新组比x组高度大1。可以看到这样的合并会使得树的高度增加,而我们现在不管是合并还是寻找,时间复杂度都和高度相关,所以这样子的合并是应当避免的。

那么应该如何改进呢?直观来说,我们应当把个数少的组合并到个数多的组中去,如上述例子,大概率个数越多,高度越高,所以这样大概率可以提升效率(注意不是绝对)。

所以可以维护一个数组szsz[i]表示以i为根的集合中元素个数,那么在版本二的基础上,我们对unionElements函数稍作修改如下,核心代码主要是增加了一段判断组数量大小的逻辑,把数量少的合并到数量多组中去,同时合并以后维护新组的数量。(关于sz数组的维护具体可以参考详细代码)

void unionElements(int x, int y) {
    int rootX = find(x);
    int rootY = find(y);
    if (rootX == rootY) {
        return;
    }
    // 希望将数量少的合并到数量大的上
    if (sz[rootX] > sz[rootY]) {
        // 将rootY合并到rootX中
        id[rootY] = rootX;
        sz[rootX] += sz[rootY];
    } else{
        id[rootX] = rootY;
        sz[rootY] += sz[rootX];
    }
}

同样对10万次进行测试,版本二花费9秒,而版本三只花费0.02秒,可见速度有了极大提升。

UnionFind4 实现

在第三个版本的实现中,我们使用了一个sz数组,来存储每棵树的节点数量,并且我们认为大多数情况下,节点越多,树的高度越高。说白了,我们还是想利用树的高度,来将高度比较低的挂到高度比较高的树上去,那么最直观的不是记树的节点个数,而是存储每颗树的高度,所以本节我们从这个角度来实现一个版本的并查集,实现的代码参考此处。

首先维护一个数组rankrank[i]表示根节点为i的树的高度,初始化让每个节点的高度为1,在进行unionElements操作的时候,我们要同步维护数组的高度,其他的方法实现同版本三种一致。

void unionElements(int x, int y) {
    int rootX = find(x);
    int rootY = find(y);
    if (rootX == rootY) return;
    if (rank[rootX] > rank[rootY]) {
        // 当x的高度比y大,则需要把y链接到x上
        id[rootY] = rootX;
    } else if (rank[rootX] < rank[rootY]) {
        id[rootX] = rootY;
    } else{
        // 两者相等,则随机选择一个链接,并增加对应高度
        id[rootX] = rootY; // 将x链接到y上,则y的高度需要增加
        rank[rootY] ++;
    }
}

同样进行10万次运行,版本三花费0.02,版本四是0.03,时间上几乎是一致的。

至此,经过了四个版本,三次优化,我们最终得到了一个日常或者面试中能用的版本,该版本代码参考此处,并且有以下的点:

  1. id数组用来存储每个节点对应的父节点。
  2. rank数组,用来存储根节点的高度。
  3. find函数,通过不断寻找父亲,最终i==id[i]的节点为根节点。
  4. unionElements函数,将两个组合并成一个,并且是将高度比较小的组合并到高度比较大的组中。
  5. isConnected函数,通过find函数判断根节点是否一样,进而来判断是否连接。

UnionFind5 实现

在第五个版本中,进行的是大名鼎鼎的路径压缩,具体来说如下解释,代码可参考此处。

假设现在有一棵子节点指向父节点的树,0->1->2->3->4,可以看出根节点是4,并且这是一颗极端情况的树,使用find进行查找时,每次都需要遍历树上所有的节点。这就让我们萌生了一种想法,如果在find的过程中,将树的结构变短,这样下次再寻找该树上的节点时,自然可以做到更快。所以首先我们可以这样对find函数进行优化:
每一次的寻找,都将当前节点指向它父亲的父亲,比如上面例子中,对于0节点,进行一次查找后变成下图中间部分的样子,再经过一次查找,最终变成下图最右部分的形状,可见路径确实得到压缩,下一次再进行寻找的时候,路径自然短了。在实现这个算法的过程中,我们也不会担心越界的问题,因为根节点的父节点是它本身,所以永远也不会有越界的事。
并查集_第1张图片

按照上述算法的思想,我们只需对find函数修改即可,其余函数同第四个版本中一致,代码如下:

int find(int x){
    while (x != id[x]) {
        id[x] = id[id[x]];
        x = id[x];
    }
    return x;
}

不难发现,整个修改中只加入了id[x] = id[id[x]];这一句,表示将当前x的父亲id[x]指向父亲的父亲id[id[x]]

我们在一百万次的基础上进行测试,版本四花费0.202,版本五花费0.109,可见提升还是非常之大的。

但是理论上,上面的这种优化还不够好,请看下面对比的图片,很明显右边路径压缩的比左边更好,右边使得最终形成的树的高度为2,那么这个代码要怎么实现呢?

并查集_第2张图片
在这里需要借助递归进行实现,代码如下:

int find(int x) {
    // 第二个版本的路径压缩的优化
    // 使得所有节点都直接挂到根节点下面,树只有两层
    if (x != id[x]) {
        id[x] = find(id[x]);
    }
    return id[x];
}

这里对这个递归函数稍作解释:

  1. 为什么没有while循环了?因为一层层的递归代替了循环。
  2. id[x] = find(id[x]);如何理解?首先是右边返回的一定是根节点,所有将x的父亲直接指向根节点,没毛病。其次,上一个问题中说递归代替了循环,那么递归如何一步步往上呢?所以这里选择的是find(id[x]),这样每次都会访问父节点,直到最终寻找到了根节点,然后回溯,将每个父节点指向根节点。
  3. 为何最后返回的是id[x],因为在上一步id[x]=root

最终得到了这个版本的路径压缩,同样经过一百万次的运算,这个版本的路径压缩花费的时间是0.125秒,比上一次略多,是因为递归时候栈的调用花费了一定的时间,但并不影响其理论上更优!

最终经过三次对unionElements函数的优化,我们知道了使用rank数组存储树的高度,然后将高度较低的树合并到高度较高的树上,两次对find函数的优化,我们最终使用了递归实现了路径压缩,使得树的高度变为2,这个就是我们最终版本的并查集的实现!

你可能感兴趣的:(LeetCode)