• 并查集(Union Find)


    并查集介绍

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

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

    本文设计的并查集主要支持两个操作:

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

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

    public interface UF {
    
        int getSize();
        boolean isConnected(int p ,int q);
        void unionElements(int p,int q);
    
    }
    

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

    第一种实现

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

    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,连接操作图如下:

    代码实现如下:

    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;
        }
    }
    

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

    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号练习题,题目描述如下:

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

        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),为了避免这样极端的情况,我们可以在每个节点中添加一个元素个数的变量,即该节点下连接的节点个数,这样在进行连接操作时,就将节点加到节点个数少的集合中去,如图:


    代码实现如下:

    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高的集合上

    代码实现如下:

    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,即只有根节点和叶子节点。但实际情况并不都是如此,所以我们只能尽可能降低树得高度,这就是我们的路径压缩优化。那我们的路径压缩是如何实现的呢?我们在查找某个节点的时候,让这个节点指向它父亲节点的父亲节点,然后判断这个节点是否是待查找节点的根节点,如不是,再使用待查找节点指向它父亲节点的父亲节点,如下图:


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

        // 查找过程, 查找元素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;
        }
    
  • 相关阅读:
    iOS tableHeaderView有默认高度?
    flutter 自定义tabbar 给tabbar添加背景功能
    jar各个版本号的意义
    【转载】springboot + swagger
    分表需要解决的问题 & 基于MyBatis 的轻量分表落地方案
    解决Spring Boot中,通过filter打印post请求的 request body 问题
    SpringBoot自动配置xxxAutoConfiguration 的使用
    Shell
    Spring踩坑记录
    Spring中可复用工具类&&特性记录&&技巧
  • 原文地址:https://www.cnblogs.com/reminis/p/12766497.html
Copyright © 2020-2023  润新知