并查集

并查集

对于一组数据,主要支持两个动作:

  • union(p, q):将 p 和 q 合并在一起
  • find§:查看 p 在哪个组中
    用来回答一个问题
  • isConnected(p, q):p 和 q 是否相连在一起

实现并查集的一种思路(quick find)

查找效率很高,但是执行变的过程却不尽人意。

1. 并查集的基本数据表示

在一个数组中有 0 - 9 10个元素,它们的 id 号分别为 0 或 1,id 号相同的元素表示它们是互相连接的。
在这里插入图片描述
上图表示 [0 - 4] 它们之间是相互连接的,[5 - 9] 它们之间是相互连接的。

下面是 一个设计的并查集的数据结构

#ifndef UNIONFIND_H
#define UNIONFIND_H
#include <iostream>
#include <cassert>
using namespace std;
class UnionFind
{
	private:
        int* id;		// 元素所属的组别号,id 相同,表示是相互连接的
        int count;		// 该并查集中的元素个数
    public:
        UnionFind(int n)
        {
            count = n;
            id = new int[n];
            for(int i = 0; i < n; i++)
            {
                id[i] = i;
            }
        }
        virtual ~UnionFind()
        {
            delete[] id;
        }
};

2. 并查集的功能

  1. 找到每个元索所属的组别的 id 号
 int find(int p)
 {
     assert(p >= 0 && p < count);
     return id[p];
 }
  1. 判断 p 和 q 是否连接在一起
 bool isConnected(int p, int q)
 {
     return find(p) == find(q);
 }
  1. 合并连个元素(即把 p 和 q 变成同一组别的元素)
 void unionElements(int p, int q)
 {
     int pID = find(p);
     int qID = find(q);
     if(pID == qID)
         return;
     for(int i = 0; i < count; i++)
     {
         if(id[i] == pID)
             id[i] = qID;
     }
 }

实现并查集的另一种思路(Quick Union)

  1. 将每个元素都看作一个节点,每个节点有一个指向父节点的指针。如果该节点为根节点,那么指针指向自己
    在这里插入图片描述
  2. 如果想要将 5 和 2 连接起来,只需要将 5 的指针指向 2 即可。

实现

#include <iostream>
#include <cassert>
using namespace std;

class UnionFind2
{
    private:
        int *parent;
        int count;
    public:
        UnionFind2(int n)
        {
            count = n;
            parent = new int[n];
            for(int i = 0; i < n; i++)
            {
                parent[i] = i;
            }
        }
        int find(int p)
        {
            assert(p >= 0 && p < count);
            while(p != parent[p])     // 直到找到根节点
            {
                p = parent[p];
            }
            return p;
        }
        bool isConnected(int p, int q)
        {
            return find(p) == find(q);
        }
        void unionElements(int p, int q)
        {
            int pRoot = find(p);
            int qRoot = find(q);
            if(pRoot == qRoot)
                return;
            parent[pRoot] = qRoot;
        }
        virtual ~UnionFind2()
        {
            delete[] parent;
        }

    protected:


};

2. 第一种和第二种性能比较

测试代码:

#include <iostream>
#include <ctime>
#include "UnionFind.h"
#include "UnionFind2.h"
using namespace std;
int n = 10000;
void test1()
{
    srand(time(NULL));
    UnionFind uf(n);

    time_t startTime = clock();

    for(int i = 0; i < n; i++)
    {
        int a = rand() % n;
        int b = rand() % n;
        uf.unionElements(a, b);
    }
    for(int i = 0; i < n; i++)
    {
        int a = rand() % n;
        int b = rand() % n;
        uf.isConnected(a, b);
    }
    time_t endTime = clock();
    cout << "UF1, " << 2 * n << " ops, " << double(endTime - startTime) /CLOCKS_PER_SEC << " s" << endl;
}
void test2()
{
    srand(time(NULL));
    UnionFind2 uf2(n);

    time_t startTime = clock();

    for(int i = 0; i < n; i++)
    {
        int a = rand() % n;
        int b = rand() % n;
        uf2.unionElements(a, b);
    }
    for(int i = 0; i < n; i++)
    {
        int a = rand() % n;
        int b = rand() % n;
        uf2.isConnected(a, b);
    }
    time_t endTime = clock();
    cout << "UF2, " << 2 * n << " ops, " << double(endTime - startTime) /CLOCKS_PER_SEC << " s" << endl;
}
int main()
{
    test1();
    test2();
    return 0;
}

【运行结果】这是在 10000 的数据下的性能比较
在这里插入图片描述
测试一下 100000 的数据性能比较
在这里插入图片描述
我们可以看到两者的性能差并不是很明显。还有待于优化。

接下来,看一下下图的并查集中有没有什么问题。

下面,我们想要合并 9 和 4。
在这里插入图片描述
只需要将 9 的指针指向 8 即可。
在这里插入图片描述
那么,如果想要 union 4 和 9 的话,理论上,应该与合并 9 和 4 的道理相同,但是实际上最后实现结果却不同。因为我们总把第一个元素的根节点指向第二个元素的根节点造成的。那么合并 4 和 9 的效果如下:
在这里插入图片描述
可以看到,在以 9 为根节点的这棵树的高度就比较高,查找 3 和 4 这两个节点花费的时间就更多,而这只是 10 个元素,那么如果更多的元素而言,性能就可想而知了。那么怎么改善这个问题呢?

我们不应该固定地将一个元素的根节点指向另一个元素的根节点。应该在做这项操作之前先做一下判断,将元素少的集合的根节点指向元素多的根节点。换句话说吗,就在合并 4 和 9 的过程中,9 所在的集合的元素少,所以把 9 所属的集合的根节点指向 4 所属的集合的根节点即可。

其实我们只需要在上面的集合中添加一个数据结构即可。添加了一个 sz 数组,sz[i] 用来表示以 i 为根的集合中的元素个数。

 private:
        int *parent;
        int* sz;    // sz[i] 表示以 i 为根的集合中元素个数
        int count;

只需要修改 unionElements 即可

		void unionElements(int p, int q)
        {
            int pRoot = find(p);
            int qRoot = find(q);
            if(pRoot == qRoot)
                return;
            if(sz[pRoot] <= sz[qRoot])
            {
                parent[pRoot] = qRoot;
                sz[qRoot] += sz[pRoot];
            }
            else
            {
                parent[qRoot] = pRoot;
                sz[pRoot] += sz[qRoot];
            }
        }

下面,让我们比较一下三种方式下的性能差:还是先测试 10000 个数据量,此时优化过的为 UF3,可见性能提升是相当明显的。
在这里插入图片描述
让我们再来测试一下 100000 个数据量:
在这里插入图片描述
此时,性能提升多大就不用我说了吧。就目前来说,UF3 的性能已经能够满足我们的需求了。

但是仍然有一种情况会影响集合的性能。下面请看例子:
在这里插入图片描述
我们想要合并 4 和 2,按照上面的实现方式,2 所属的节点的个数多,那应该把 4 所属的集合的根节点指向 2 所属的根节点(即 8 指向 7),那么就变成下列所示结果:

在这里插入图片描述
这两颗树之前一个层数为 2,一个为 3,经过这么一并,层数变成了 4。如果我们换一种方向,把 7 指向 8,那么就变成了如下结果:

在这里插入图片描述
这样层数就为 3。所以,光依赖集合的个数来决定最终谁指向谁的方法并不是很准确的,还得依赖于两个集合的层数。这就是接下来的基于 rank 的优化。rank[i] 表示根节点为 i 的树的高度。

在实现 unionElements 这个功能的实现逻辑是,首先比较两个集合中树的高度,如果两个集合中树的高度相等,在比较两个集合的元素个数,进行合并,修改代码如下:

private:
        int *parent;
        int* rank;    // rank[i] 表示以 i 为根的集合所表示的树的层数
        int count;

		void unionElements(int p, int q)
        {
            int pRoot = find(p);
            int qRoot = find(q);
            if(pRoot == qRoot)
                return;
            if(rank[pRoot] < rank[qRoot])
            {
                parent[pRoot] = qRoot;
            }
            else if(rank[pRoot] > rank[qRoot])
            {
                parent[qRoot] = pRoot;
            }
            else
            {
                    parent[pRoot] = qRoot;
                    rank[qRoot] += 1;
            }
        }

到这里,就要介绍最后一种优化方式了,路径压缩(Path Compression)

下面,举个栗子。我们要 find 4。如下图所示:
在这里插入图片描述
按照之前的思路,我们需要先找到 4,查看 4 的指针是否指向它自己,不是,则继续向上查找,在查找的过程中,我们需要依次遍历 3,2,1,0。那么我们是不是可以做一些变化,让树的层数更少一些,答案是可以的。我们可以试图在 find 的过程中从底向上,如果没有找到根的话,我们就要想办法把这些节点再向上挪一挪,那么这个过程就叫做路径压缩。

下面请看图例:还是假设我们要 find 4
在这里插入图片描述
首先我们找到 4 的 parent 是 3,和 4 不一样,说明 4 不是根节点。在之前的 find 中,我们要继续向上找。现在,我们要实现路径压缩。那么我们压缩一步,也就是让 4 去连接它父亲的父亲(也就是 节点 2)。
在这里插入图片描述

那么,这棵树就变矮了,这就是压缩的意思。也要将 4 的父亲修改为节点 2。接下来,我们考察节点 2,节点 2 的父亲依旧没有指向他自己,说明 2 也不是根节点,此时我们需要将 节点 2 也指向它父亲的父亲。

在这里插入图片描述
那么接下来可以考察 0 节点,此时 0 节点即为根节点,到此 find 过程结束。

在这里插入图片描述
下面来看代码的修改:只需要对 find 代码进行修改:

	int find(int p)
	 {
	     assert(p >= 0 && p < count);
	     while(p != parent[p])     // 直到找到根节点
	     {
	         parent[p] = parent[parent[p]];
	         p = parent[p];
	     }
	     return p;
	 }

在下面,我们进行性能测试:UF4 是我们刚刚优化后的代码。这是测试的 1000000 个数据量,可见性能还是非常高的。
在这里插入图片描述

那么,我们能不能实现下列这种情况,让所有的节点都指向根节点,那这样我们查找元素就只需要遍历一次即可。
在这里插入图片描述
这个实现就要借助递归实现了。修改代码如下:

 		int find(int p)
        {
            assert(p >= 0 && p < count);
            if(p != parent[p])
                parent[p] = find(parent[p]);
            return parent[p];
        }

性能比较如下:
在这里插入图片描述
我们可以看见这种递归的方法好像比上一种方法时间要稍稍长一点,这就是递归所带来的开销,但是递归的这种压缩方法从理论上来说,是性能更优的。但是理论和实践稍微有点出入。

并查集的操作,时间复杂度近乎是 O(1);

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值