前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >并查集(Union Find)

并查集(Union Find)

作者头像
程序员波特
发布2024-01-19 09:42:13
1220
发布2024-01-19 09:42:13
举报
文章被收录于专栏:魔法书魔法书
并查集介绍

  我们之前讲的树结构,都是由父亲节点指向孩子节点,而并查集却是由孩子指向父亲的这样一种数据结构。

  给出图中任意的两点,问这两点之间是否可以通过一个路径连接起来?并查集就是处理这类连接问题的很好的数据结构。即用来处理网络中节点的连接状态,这里的网络是个抽象慨念,可以是用户之间形成 的网络。其实这类连接问题我们也可以使用集合类来进行实现,即求两个集合的并集。 本文设计的并查集主要支持两个操作:

  1. union(p,q) 并,对传入的两个数据p和q,在并查集内部将这两个数据,以及这两个数据所在的集合合并起来。
  2. isConnected(p,q)查询对于给定的两个数据,他们是否属于同一个集合。

由于并查集可以有不同的实现,我们可以设计一个并查集的接口:

代码语言:javascript
复制
public interface UF {

    int getSize();
    boolean isConnected(int p ,int q);
    void unionElements(int p,int q);

}

  对于具体元素是谁,我们并不关心,我们关心的是 id=p 和 id=q 这样的两个元素是否相连,而对于p和q对应具体的值,并不关心。

第一种实现

  表示每一个元素都属于不同的集合,没有任意两个元素是相连的

代码语言:javascript
复制
public class UnionFind01 implements UF {

    //我们第一种实现,Union-Find本质就是一个数组
    private int[] id;

    //有参构造:指定并查集中元素的个数
    public UnionFind01(int size){
        id = new int[size];
        //初始化, 每一个id[i]指向自己, 没有合并的元素
        for (int i=0; i<id.length; i++)
            id[i] = i;
    }

    @Override
    public int getSize() {
        return id.length;
    }

    // 查找元素p所对应的集合编号
    // O(1)复杂度
    private int find(int p){
        if (p < 0 || p >= id.length)
            throw  new IllegalArgumentException("p is out of bound");
        return id[p];
    }

    // 查看元素p和元素q是否所属一个集合
    @Override
    public boolean isConnected(int p, int q) {
        return find(p) == find(q);
    }

    // 合并元素p和元素q所属的集合
    // O(n) 复杂度
    @Override
    public void unionElements(int p, int q) {
        int pID = find(p);
        int qID = find(q);

        if (pID == qID)
            return;

        // 合并过程需要遍历一遍所有元素, 将两个元素的所属集合编号合并
        for (int i = 0; i < id.length; i++){
            if (id[i] == pID)
                id[i] = qID;
        }
    }
}
第二种实现

  我们的第二版Union-Find, 使用一个数组构建一棵指向父节点的树,如图:

  我们在初始化时,有10个元素,编号分别为0至9,即可以看成一棵只含有一个根节点的树结构,这个根节点的值即为索引值0至9,连接操作图如下:

代码实现如下:

代码语言:javascript
复制
public class UnionFind02 implements UF {

    //parent[i]表示第i个元素所指向的父节点
    private int[] parent;

    //构造函数
    public UnionFind02(int size) {
        parent = new int[size];

        // 初始化, 每一个parent[i]指向自己, 表示每一个元素自己自成一个集合
        for (int i = 0; i < size; i++){
            parent[i] = i;
        }
    }

    @Override
    public int getSize() {
        return parent.length;
    }

    // 查找过程, 查找元素p所对应的集合编号
    // O(h)复杂度, h为树的高度
    public int find(int p){
        if (p < 0 || p >= parent.length)
            throw new IllegalArgumentException("p id out of bound");

        // 不断去查询自己的父亲节点, 直到到达根节点
        // 根节点的特点: parent[p] == p
        while (p != parent[p])
            p = parent[p];
        return p;
    }

    // 查看元素p和元素q是否所属一个集合
    // O(h)复杂度, h为树的高度
    @Override
    public boolean isConnected(int p, int q) {
        return find(p) == find(q);
    }

    // 合并元素p和元素q所属的集合
    // O(h)复杂度, h为树的高度
    @Override
    public void unionElements(int p, int q) {
        int pRoot = find(p);
        int qRoot = find(q);

        if (pRoot == qRoot)
            return;

        parent[pRoot] = qRoot;
    }
}

  现在让我们来简单测试下并查集的这两种实现方式的效率:

代码语言:javascript
复制
public class Test {

    //简单测试两种并查集实现方式的效率
    private static double testUF(UF uf, int m){
        int size = uf.getSize();
        Random random = new Random();

        long startTime = System.nanoTime();
        //测试连接操作
        for (int i = 0; i < m ; i ++){
            int a = random.nextInt(size);
            int b = random.nextInt(size);
            uf.unionElements(a,b);
        }

        //测试这两个元素是否连接
        for (int i = 0; i < m ; i ++){
            int a = random.nextInt(size);
            int b = random.nextInt(size);
            uf.isConnected(a,b);
        }

        long endTime = System.nanoTime();

        return (endTime - startTime) / 1000000000.0;
    }

    public static void main(String[] args) {
        int size = 100000;
        int m = 10000;
        //第一种实现
        UnionFind01 uf01 = new UnionFind01(size);
        System.out.println("UnionFind01 -> "+ testUF(uf01,m) +"s");
        //第二种实现
        UnionFind02 uf02 = new UnionFind02(size);
        System.out.println("UnionFind02 -> "+ testUF(uf02,m) +"s");

    }

}

测试代码运行结果:

通过测试代码的运行结果可以看出,我们的第二种实现方式是要比第一种实现方式 效率高一些的,现在我们看一个关于并查集的应用,就是力扣网上的547号练习题,题目描述如下:

现在就让我们基于并查集的第二种实现方式,来解决这个问题:

代码语言:javascript
复制
    public int findCircleNum(int[][] M) {

        int n = M.length;
        UnionFind2 uf = new UnionFind2(n);
        for(int i = 0 ; i < n ; i ++)
            for(int j = 0 ; j < i ; j ++)
                //如果M[i][j] = 1,则表示第 i 个和 j 个学生互为朋友,即属于同一个集合
                if(M[i][j] == 1)
                    uf.unionElements(i, j);

        TreeSet<Integer> set = new TreeSet<>();
        for(int i = 0 ; i < n ; i ++)
            set.add(uf.find(i));
        //获取不同集合的个数
        return set.size();
    }
并查集的优化方式
考虑size

  由于我们之前的实现方式,在进行连接操作时,总是将左边的元素指向右边的元素,这样很容易就会形成一个链表,这样的查询时间复杂度就会变成O(n),为了避免这样极端的情况,我们可以在每个节点中添加一个元素个数的变量,即该节点下连接的节点个数,这样在进行连接操作时,就将节点加到节点个数少的集合中去,如图:

代码实现如下:

代码语言:javascript
复制
public class UnionFind03 implements UF {

    private int[] parent; // parent[i]表示第i个元素所指向的父节点
    private int[] sz;     // sz[i]表示以i为根的集合中元素个数

    public UnionFind03(int size){
        parent = new int[size];
        sz = new int[size];

        // 初始化, 每一个parent[i]指向自己, 表示每一个元素自己自成一个集合
        for(int i = 0 ; i < size ; i ++){
            parent[i] = i;
            sz[i] = 1;
        }
    }

    @Override
    public int getSize() {
        return parent.length;
    }

    // 查找过程, 查找元素p所对应的集合编号
    // O(h)复杂度, h为树的高度
    public int find(int p){
        if (p < 0 || p >= parent.length)
            throw new IllegalArgumentException("p is out of bound.");

        // 不断去查询自己的父亲节点, 直到到达根节点
        // 根节点的特点: parent[p] == p
        while ( p != parent[p])
            p = parent[p];
        return p;
    }

    // 查看元素p和元素q是否所属一个集合
    // O(h)复杂度, h为树的高度
    @Override
    public boolean isConnected(int p, int q) {
        return find(p) == find(q);
    }

    // 合并元素p和元素q所属的集合
    // O(h)复杂度, h为树的高度
    @Override
    public 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 {// sz[qRoot] <= sz[pRoot]
            parent[qRoot] = pRoot;
            sz[pRoot] += sz[qRoot];
        }
    }
}

我们可以通过测试方法,对这三种实现方式进行简单的比较,我们会发现,优化size之后的并查集,效率比前两种是要好很多的。

基于rank的优化

  rank[i]表示根节点为i的树的高度,即根据两个元素所在的输的rank不同判断合并方向,将rank低的合并到rank高的集合上

代码实现如下:

代码语言:javascript
复制
public class UnionFind04 implements UF {
    private int[] rank;   // rank[i]表示以i为根的集合所表示的树的层数
    private int[] parent; // parent[i]表示第i个元素所指向的父节点

    // 构造函数
    public UnionFind04(int size){

        rank = new int[size];
        parent = new int[size];

        // 初始化, 每一个parent[i]指向自己, 表示每一个元素自己自成一个集合
        for( int i = 0 ; i < size ; i ++ ){
            parent[i] = i;
            rank[i] = 1;
        }
    }

    @Override
    public int getSize(){
        return parent.length;
    }

    // 查找过程, 查找元素p所对应的集合编号
    // O(h)复杂度, h为树的高度
    private int find(int p){
        if(p < 0 || p >= parent.length)
            throw new IllegalArgumentException("p is out of bound.");

        // 不断去查询自己的父亲节点, 直到到达根节点
        // 根节点的特点: parent[p] == p
        while(p != parent[p])
            p = parent[p];
        return p;
    }

    // 查看元素p和元素q是否所属一个集合
    // O(h)复杂度, h为树的高度
    @Override
    public boolean isConnected( int p , int q ){
        return find(p) == find(q);
    }

    // 合并元素p和元素q所属的集合
    // O(h)复杂度, h为树的高度
    @Override
    public void unionElements(int p, int q){

        int pRoot = find(p);
        int qRoot = find(q);

        if( pRoot == qRoot )
            return;

        // 根据两个元素所在树的rank不同判断合并方向
        // 将rank低的集合合并到rank高的集合上
        if(rank[pRoot] < rank[qRoot])
            parent[pRoot] = qRoot;
        else if(rank[qRoot] < rank[pRoot])
            parent[qRoot] = pRoot;
        else{ // rank[pRoot] == rank[qRoot]
            parent[pRoot] = qRoot;
            rank[qRoot] += 1;   // 此时, 我维护rank的值
        }
    }
}

现在让我们对基于size的优化,和基于rank的优化进行简单的测试对比:

路径压缩优化

  为什么需要路径压缩优化呢?基于前面的四种实现方式,我们会发现下图中的三颗并查集数,无论是find()还是isConnected()都是等效的

在这里插入图片描述
在这里插入图片描述

  由于并查集的查找方法是和树得高度相关的,所以我们只要让树得高度降低,就都是对并查集的优化,理想情况下,我们树得高度为2,即只有根节点和叶子节点。但实际情况并不都是如此,所以我们只能尽可能降低树得高度,这就是我们的路径压缩优化。那我们的路径压缩是如何实现的呢?我们在查找某个节点的时候,让这个节点指向它父亲节点的父亲节点,然后判断这个节点是否是待查找节点的根节点,如不是,再使用待查找节点指向它父亲节点的父亲节点,如下图:

  根据图中分析,可以看出我们的路径压缩是在查找过程中实现的,所以我们修改查找方法即可:代码 实现如下:

代码语言:javascript
复制
    // 查找过程, 查找元素p所对应的集合编号
    // O(h)复杂度, h为树的高度
    private int find(int p){
        if(p < 0 || p >= parent.length)
            throw new IllegalArgumentException("p is out of bound.");

        while( p != parent[p] ){
            parent[p] = parent[parent[p]];
            p = parent[p];
        }
        return p;
    }
本文参与 腾讯云自媒体分享计划,分享自作者个人站点/博客。
原始发表:2024-01-18,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 并查集介绍
  • 第一种实现
  • 第二种实现
  • 并查集的优化方式
    • 考虑size
      • 基于rank的优化
        • 路径压缩优化
        领券
        问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档