并查集(UnionFind)及优化

829 阅读5分钟

并查集(UnionFind)及优化

内容学习自小码哥的《恋上数据结构与算法》,图片来自视频截图。

什么是并查集?先看下面问题。

解决上面这个问题我们需要设计一种新的数据结构,能够快速合并2组数据为一组。同时又能快速识别两个数是否为一组。

符合这种条件的就是 并查集

并查集两个核心方法就是:Union和Find。

下面是以整数为例。

Quick Find

这是第一种实现方式,注意不是推荐实现。

思路:

用数组的index代表整数,value代表他的父元素。

父元素或者父节点相同的我们视为一组元素。

所以每个元素的初始父元素就是自己,每个元素单独为一组。

 
public class UnionFind {

    private int[] parents;

    public UnionFind(int capacity) {
        if (capacity < 0) {
            throw new IllegalArgumentException("capacity must be >0");
        }
        parents = new int[capacity];

        for (int i = 0; i < parents.length; i++) {
            parents[i] = i;//每个元素的父元素是自己
        }
    }
...
  
}

Union

如何合并两组元素?

其实只要改变组内所有元素的父节点都为为一个。

比如下图,将左边的值合并到右边:union(v1,v2)就是将与v1父字节点一样的结点都将自己的父节点改为v2的父节点。

image-20201216155923662

可以看到树的高度最高就是2。

public class UnionFind {
  
  ...

/**
     * 将元素v1所在的组合并到元素v2所在的组中
     *
     * @param v1 元素
     * @param v2 元素
     *           其实就是修改他们的父元素
     */
    public void union(int v1, int v2) {
        int parent1 = find(v1);
        int parent2 = find(v2);
        if (parent1 == parent2) return;

        for (int i = 0; i < parents.length; i++) {
            if (parents[i] == parent1) {
                parents[i] = parent2;
            }
        }
    }
 ... 
}

注意需要改变v1所在组中所有元素(即父节点跟v1的所有元素)的父节点。

Find

直接获取数组位置的值就是获取父元素,父元素相同 就是一组的。

所以find的时间复杂度是O(1)

public class UnionFind {
  
  ...

/**
     * 查找当前元素的父元素
     *
     * @param element 当前元素
     * @return 父元素
     */
    public int find(int element) {
        rangCheck(element);
        return parents[element];
    }

 /**
     * 判断这两个元素是否在一组,父元素相同认为是一组
     *
     * @param v1 元素1
     * @param v2 元素2
     * @return 是否为一组
     */
    public boolean isSame(int v1, int v2) {
        return find(v1) == find(v2);
    }
...
}

写完了,其实很简单。时间复杂度是O(N)

根据下图编写测试代码:

public class UninFindMain {

    public static void main(String[] args) {
        UnionFind uf=new UnionFind(12);

        uf.union(0, 1);
        uf.union(0, 3);
        uf.union(0, 4);
        uf.union(2, 3);
        uf.union(2, 5);

        uf.union(6, 7);

        uf.union(8, 10);
        uf.union(9, 10);
        uf.union(9, 11);

        System.out.println(uf.isSame(0,6));
        System.out.println(uf.isSame(6,7));
        uf.union(1,6);
        System.out.println(uf.isSame(0,7));
    }
}

输出结果如下:

false
true
true

Process finished with exit code 0

可以看到上面Find步骤复杂度是O(1)的,find很快所以称作QuickFind。但是Union的发咋度确实O(N)。

Quick Union

区别于QuickFind,QuickUnion的写法是find和union的时间复杂度都是O(logN)。

Union

与QuickFind的区别是,找到当前组的 根节点,改变根节点指向即可,不需要改变所有结点的指向。

Find

与QuickFind的区别在于,这里需要不停向上找,直到找到根节点返回。根节点相同的认为是一组的。

Find的时间复杂度其实就是树的高度,是O(logN)。

Union的时间复杂度也是O(logN),因为要做通过find找到两个根节点,然后跟换指向。O(logN)+O(logN)+O(1)=O(logN)

代码实现很简单:

 @Override
    public int find(int element) {
        rangCheck(element);
        while (element != parents[element]) {
            element = parents[element];
        }
        return element;
    }

    @Override
    public void union(int v1, int v2) {
        //找到根节点
        int p1 = find(v1);
        int p2 = find(v2);
        if (p1 == p2) return;
        //将根节点的元素的值 改成v2根节点
        parents[p1] = p2;
    }

Quick Union优化

QuickUnion在合并的时候可能会出现树不平衡状况,甚至树退化成了链表。这样union的复杂度就是O(N)了。

基于size的优化

之前在union时都是将左边的元素合并到右边。如果是上图的情况完全可以将右边合并到左边。这样就又回到了树结构。

思路:元素少的合并到元素多的组中。

开辟一个数组size[]用来存在每个树的size。存储时以这个树的根节点作为index,value存储size值。

那么一开始,value都是1。

/**
 * QuickUnion size的优化
 */
public class UnionFind_QU_S extends UnionFind_QU {

    private int sizes[];

    protected UnionFind_QU_S(int capacity) {
        super(capacity);
        sizes = new int[capacity];
        for (int i = 0; i < capacity; i++) {
            sizes[i] = 1;
        }
    }

  ...
}

public int find(int element)方法不需要动,只需要优化public void union(int v1, int v2)

		/**
     * 将v1的根节点指向v2的根节点位置,从而实现合并
     *
     * @param v1 元素
     * @param v2 元素
     */
    @Override
    public void union(int v1, int v2) {
        //找到根节点
        int p1 = find(v1);
        int p2 = find(v2);
        if (p1 == p2) return;

        if (sizes[p1] < sizes[p2]) {
            //p1作为根节点的树size小于 p2作为根节点的树的size 将根节点的元素的值 改成v2根节点
            parents[p1] = p2;
            //p2的size增加
            sizes[p2] += sizes[p1];
        } else {
            //与上面的想反
            parents[p2] = p1;
            sizes[p1] += sizes[p2];
        }

    }

基于rank的优化

即矮的树合并到高的树中去。

很明显这样树更平衡,比基于size要好。

同上使用一个数组来存储树的高度rank。起始树的高度都是1:

/**
 * QuickUnion rank的优化
 */
public class UnionFind_QU_R extends UnionFind_QU {

    private int ranks[];

    protected UnionFind_QU_R(int capacity) {
        super(capacity);
        ranks = new int[capacity];
        for (int i = 0; i < capacity; i++) {
            ranks[i] = 1;
        }
    }
...
}

同样 public int find(int element)方法不需要动,只需要优化public void union(int v1, int v2)

需要注意:rank的高度在矮的合并到高的树时,高的树的高度是不变的。只要当两个树的高度相同时,新合成的树的高度需要增加。

/**
     * 将v1的根节点指向v2的根节点位置,从而实现合并
     *
     * @param v1 元素
     * @param v2 元素
     */
    @Override
    public void union(int v1, int v2) {
        //找到根节点
        int p1 = find(v1);
        int p2 = find(v2);
        if (p1 == p2) return;

        if (ranks[p1] < ranks[p2]) {
            //p1作为根节点的树高度小于 p2作为根节点的树的高度
            parents[p1] = p2;
            //矮的嫁接到高的 高的高度不用改动
        } else if (ranks[p1] > ranks[p2]) {
            //与上面的想反
            parents[p2] = p1;
        } else {
            //两个数的高度一样,树的高度+1,谁合并到谁无所谓,主要是高度别加错
            parents[p1] = p2;
            ranks[p2]++;
        }
    }

PathCompression(路径压缩)

虽然基于rank的优化,树会相对平衡点。

但是树这合并的次数增加,树的高度仍然会越来越高。从而导致每次find的时间复杂度增大。

由于在执行find操作时,我们会遍历树上的一条路径。我们可以在此时进行优化--> 路径压缩

在执行find时,将路径上的所有结点都指向根节点。从而降低树的高度。

由此可见,我们只需要重写基于rank优化的find(element)方法即可:

/**
 * QuickUnion rank的优化 的路径压缩(Path Compression)
 * <p>
 * 重写find即可
 */
public class UnionFind_QU_R_PC extends UnionFind_QU_R {
    
    protected UnionFind_QU_R_PC(int capacity) {
        super(capacity);
    }

    @Override
    public int find(int v) {
        rangCheck(v);
        if (parents[v] != v) {
            parents[v] = find(parents[v]);
        }
        //路径压缩后 v和parents[v]不一样了
        return parents[v];
    }

}

随机找3000000个数。对比下速度。

【UnionFind_QU_S】
开始:18:41:28.591
结束:18:41:30.448
耗时:1.856秒
-------------------------------------
【UnionFind_QU_R】
开始:18:41:30.478
结束:18:41:32.371
耗时:1.893秒
-------------------------------------
【UnionFind_QU_R_PC】
开始:18:41:32.383
结束:18:41:34.075
耗时:1.692秒
-------------------------------------

Process finished with exit code 0

可以看出来路径压缩提升并不多。主要是find里面的操作比较耗时。

PathSpliting(路径分裂)

上一个方法路径压缩由于要求树过于平衡对其性能有一定影响。

所以我们可以修改下压缩路径的方式。

路径分列:使路径上每个节点都指向其祖父节点。

如下图:

将原有路径一分为二。

与路径压缩就是find方法有些区别。

/**
 * QuickUnion rank的优化 的路径分裂(Path Splitting)
 * <p>
 * 重写find即可
 */
public class UnionFind_QU_R_PS extends UnionFind_QU_R {

    protected UnionFind_QU_R_PS(int capacity) {
        super(capacity);
    }

    @Override
    public int find(int v) {
        rangCheck(v);
        while (parents[v] != v) {//找到根节点
            //先保留父节点
            int p = parents[v];
            //v指向它的祖父节点
            parents[v] = parents[parents[v]];
            v = p;//不修改就漏了2->4->6...
        }
        return parents[v];
    }

}

Path Halving(路径减半)

这个其实跟路径分裂差不多,只是路径压缩的另一种优化。

路径减半:使路径上每隔一个节点就指向其祖父节点。

如名字就是将原有路径长度缩成原有的一半。

/**
 * QuickUnion rank的优化 的路径减半(Path Halving)
 * <p>
 * 重写find即可
 */
public class UnionFind_QU_R_PH extends UnionFind_QU_R {

    protected UnionFind_QU_R_PH(int capacity) {
        super(capacity);
    }

    @Override
    public int find(int v) {
        rangCheck(v);
        while (parents[v] != v) {//找到根节点
            //v指向它的祖父节点
            parents[v] = parents[parents[v]];
            v = parents[v];// 1->3->5...
        }
        return parents[v];
    }

}


总结

随机生成了3000000个数值,对比下优化后的性能

【UnionFind_QU_S】 基于Size的优化
开始:22:32:10.819
结束:22:32:12.943
耗时:2.123秒
-------------------------------------
【UnionFind_QU_R】 基于Rank的优化
开始:22:32:12.971
结束:22:32:14.849
耗时:1.878秒
-------------------------------------
【UnionFind_QU_R_PC】 基于Rank的优化+路径压缩
开始:22:32:14.861
结束:22:32:16.425
耗时:1.564秒
-------------------------------------
【UnionFind_QU_R_PS】基于Rank的优化+路径分裂
开始:22:32:16.431
结束:22:32:18.008
耗时:1.577秒
-------------------------------------
【UnionFind_QU_R_PH】基于Rank的优化+路径减半
开始:22:32:18.024
结束:22:32:19.568
耗时:1.544秒
-------------------------------------

Process finished with exit code 0

测试代码如下:

public class UninFindMain {
    static final int count = 3000000;

    public static void main(String[] args) {
//        testTime(new UnionFind_QF(count));
//        testTime(new UnionFind_QU(count));
        testTime(new UnionFind_QU_S(count));
        testTime(new UnionFind_QU_R(count));
        testTime(new UnionFind_QU_R_PC(count));
        testTime(new UnionFind_QU_R_PS(count));
        testTime(new UnionFind_QU_R_PH(count));
    }

    static void testTime(UnionFind uf) {
        uf.union(0, 1);
        uf.union(0, 3);
        uf.union(0, 4);
        uf.union(2, 3);
        uf.union(2, 5);

        uf.union(6, 7);

        uf.union(8, 10);
        uf.union(9, 10);
        uf.union(9, 11);

        Asserts.test(!uf.isSame(2, 7));

        uf.union(4, 6);

        Asserts.test(uf.isSame(2, 7));

        Times.test(uf.getClass().getSimpleName(), () -> {
            for (int i = 0; i < count; i++) {
                uf.union((int) (Math.random() * count),
                        (int) (Math.random() * count));
            }

            for (int i = 0; i < count; i++) {
                uf.isSame((int) (Math.random() * count),
                        (int) (Math.random() * count));
            }
        });
    }

}

建议使用路径分裂/路径压缩/路径减半+基于Rank优化。时间复杂度能到 O( α(n)), α(n)<5。