数据结构学习 -- 并查集

Posted 庸人冲

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了数据结构学习 -- 并查集相关的知识,希望对你有一定的参考价值。

概念

并查集 (Union Find) 是一种由孩子指向父亲的树结构,可以高效的处理连接问题 (Collection Problem)。比如:快速判断网络(抽象概念)中节点间的连接状态。

对于一组数据,并查集主要支持两个操作:

  1. union(p, q); 在并查集内部将 pq 以及它们所在的集合合并。
  2. isConnected(p , q); 判断 pq 是否属于同一个集合 。

并查集的实现

QuickFind

QuickFind ,顾名思义,查询很快。在底层使用一个数组,数组的下标来表示具体的元素,对应下标上的数值用来表示元素所属的集合。如下图:


代码实现:

/**
 * Quick Find
 * 顾名思义,查询很快,但是合并相对较慢
 * 查询函数 find() 和 isConnected() 时间复杂度O(1)
 * 合并函数 union() 时间复杂度O(n)
 */
public class UnionFind1 implements UF {

    private int[] id;  // 存放每一个数据所对应所属集合的编号

    /**
     * 初始化 id 数组, 以用户传入的 size 作为数组的长度, 初始化时将所有元素的集合编号设置为不同,表示所有元素都属于不同的集合。
     *
     * @param size 用户需要处理数据的规模
     */
    public UnionFind1(int size) {
        id = new int[size];

        for (int i = 0; i < size; i++)
            id[i] = i;
    }

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

    /**
     * 合并元素 p 和 q 及其所属集合
     * 例如:
     * 合并前:
     * 0 1 0 1 0 1 0 1
     * ----------------
     * 1 p 3 4 q 5 6 7
     * <p>
     * 合并后:
     * 1 1 1 1 1 1 1 1
     * ----------------
     * 1 p 3 4 q 5 6 7
     */
    @Override
    public void union(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] == qID)
                id[i] = pID;
        }
    }

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

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

对于 isConnected(p, q) 操作使用 Quick Find 的方式实现,时间复杂度可以达到O(1)

但是对于 union(p, q) 操作使用 QuickFind 的方式实现,时间复杂度为 O(n),对于操作 m 次,时间复杂度 O(nm),还是非常慢的。

Quick Union

因为 Quick Findunion 操作上的不尽如人意,所以一般标准的并查集实现都使用上面提到的树结构,这种实现的思路也叫做 Quick Union

具体实现是将每一个元素看做一个节点,节点间连接形成树的结构,不过并查集中的树结构是孩子节点指向父亲节点。父亲节点还指向它的父亲节点,直到根节点,根节点指向自己。

当需要合并两个元素时,只需要让其中一个元素指向另一个元素的父亲节点即可。

如果元素 p 和 元素 q 同属于两个不同的集合,那么让其中一个元素 q 的根节点指向元素 p 的根节点。

如果查询元素 p 和 元素 q 是否同属于一个集合,只需要看它们指向的父节点,或者父节点的父节点是否相同。

对于 Quick U nion ,其底层实现还是使用的数组,使用下标代表每个元素,下标之上所对应的数值代表了父亲节点(下标),初始化时,每个元素都指向了自己,也就是每个元素都是一个根节点,表示所有元素都不同属于同一个集合。

当进行合并操作后(以上面的树状图为例):

可以先对基于树结构的并查集进行时间复杂度的分析,树化后对于查询操作 isConnected(p, q) 需要获取到 pq 的根节点并继续比较,所以时间复杂度为 O(h)hpq 两个元素所在树高度的较大值。而对于合并操作union(p, q) 同样只需要让 p 或者 q 元素的根节点指向另一个元素的根节点,所以时间复杂度也为 O(h)


对于合并操作来说,比第一版的并查集提高了很多,但是对于查询操作却慢了一些,一般在一颗树中树的高度 h 都远远小于节点个数 n ,所以 O(h) 的时间复杂度也是很快的。不过也有特殊情况比如在二分搜索树中如果向树中添加元素是按顺序添加的,那么二分搜索树很容易退化成一个链表。对于并查集也是一样,如果在合并的过程中每一次都是元素较多集合的根节点指向元素较少的根节点,也很容使得并查集退化成一个链表。

解决方法是再使用一个数组来记录以每个元素为根节点的集合中元素的个数,定义为 sz ,那么 sz[i] 就表示元素 i 为根节点的集合中元素的个数。具体为,每次添加操作时,不仅要获取 p q 元素的根节点(这里定义为 pRoot、qRoot),还要获取以 pRoot、qRoot 为根的集合中元素的个数,并让元素少的集合根节点指向元素多的集合根节点,当然在最后还需要维护 sz 这个数组,元素多的集合在 sz 中保存的元素个数要加上元素少的集合元素个数。

代码实现:

/**
 * Quick Union 实现
 */
public class UnionFind2 implements UF {
    private int[] parent;   // 保存每个元素的父节点
    private int[] sz;       // sz[i] 表示以元素 i 为根的集合元素个数

    /**
     * 初始化 parent 数组
     * 以用户传入的 size 作为 parent 和 sz 数组的长度,parent 数组初始化时每个元素都指向自己,sz 数组中所有位置初始化为
     * 1 表示每个元素所属集合中只存在一个元素。
     *
     * @param size 用户需要处理数据的规模
     */
    public UnionFind2(int size) {
        if (size < 0)
            throw new IllegalArgumentException("sizes must be non-negative");

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

        for (int i = 0; i < size; i++) {
            parent[i] = i;
            sz[i] = 1;
        }
    }


    /**
     * 合并元素 p 和 q 及其所属集合
     * 获取 p 和 q 的根节点,如果两个元素的根节点不同,则让其中一个元素 q 的根节点执行元素 p 的根节点。
     * 优化:在合并过程中如果不对 p 和 q 所属集合的结构进行判断,很可能使得两个集合在合并后得到的并查集树退化成一个链表
     * ,所以每次合并前,需要判断元素 p 和 元素 q 所属集合的元素个数,让较小集合的根节点执行较大集合的根节点。
     */
    @Override
    public void union(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];
        }
    }

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

    /**
     * 返回元素 p 的根节点,即元素 p 所属集合
     * 时间复杂度O(h), h 为 元素p所在树的高度
     */
    private int find(int p) {
        if (p < 0 || p >= parent.length)
            throw new IllegalArgumentException("p is out of bound.");

        while (parent[p] != p)
            p = parent[p];
        return p;
    }

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

基于rank的优化

对于上面实现的并查集基于两个集合中元素个数的多少来决定哪一个集合应该并入到另一个集合中,不过这还是存在一个问题,比如,如果集合 a 中的元素多于集合 b,那么按照上面的实现方式,集合 b 被并入到集合 a中,但是集合 a 所代表树的深度是小于集合 b,那么在合并后,整个树的深度就会增加。

基于 rank 的优化,是用一个数组纪录以每个元素为根的树的深度,在合并时让深度更底的根节点指向深度更高的根节点,这样可以保证两个树合并之后,其深度不会增加。

代码实现:

/**
 *  基于 Rank 优化
 */
public class UnionFind3 implements UF {
    private int[] parent;    // 保存每个元素的父节点
    private int[] rank;      // rank[i] 表示以元素 i 为根的集合树的层级

    /**
     * 初始化 parent 数组
     * 以用户传入的 size 作为 parent 和 rank 数组的长度,parent 数组初始化时每个元素都指向自己,rank 数组中所有位置初始
     * 化为 1 表示每个元素所属的集合树只有元素本身。
     *
     * @param size 用户需要处理数据的规模
     */
    public UnionFind3(int size) {
        if (size < 0)
            throw new IllegalArgumentException("sizes must be non-negative");

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

        for (int i = 0; i < size; i++) {
            parent[i] = i;
            rank[i] = 1;
        }
    }


    /**
     * 合并元素 p 和 q 及其所属集合
     */
    @Override
    public void union(int p, int q) {

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

        if (pRoot == qRoot)
            return;

        if (rank[pRoot] < rank[qRoot]) {
            parent[pRoot] = qRoot;             // rank低的集合根节点 指向 rank高的集合根节点
        } else if (rank[pRoot] > rank[qRoot]) {
            parent[qRoot] = pRoot;             // 同理
        } else {   // rank[pRoot] == rank[qRoot]
            parent[qRoot] = pRoot;
            rank[pRoot]++;                     // 两颗集合树 rank 相等时,被合并的集合深度 + 1
        }
    }

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

    /**
     * 返回元素 p 的根节点,即元素 p 所属集合
     * 时间复杂度O(h), h 为 元素p所在树的高度
     */
    private int find(int p) {
        if (p < 0 || p >= parent.length)
            throw new IllegalArgumentException("p is out of bound.");

        while (parent[p] != p)
            p = parent[p];
        return p;
    }

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

路径压缩

路径压缩(Path Compression)是应用于并查集中非常经典的一种优化方式,解决问题是让一颗比较高的树压缩为比较矮的树。在上面的几轮优化后,我们已经可以很好的控制整个并查集中每颗集合树的高度尽量的低,不过随着数据规模的增加还是难免树的高度为随之增加,理想状态下每颗集合树应该只有2层,第一层为根节点,第二层为集合中其它所有元素。

不过在使用上面的实现方式,是很难去控制每个集合树都达到这种状态,除非是指定一组特殊的测试用例。其实观察可以发现对于集合树的高度超过2的情况,第二层以下的子节点都还是属于这个集合中,最终都还必须通过其父节点找到根节点,那么为何不让集合中所有的元素都直接指向根节点?路径压缩就很好的解决的这个问题,它的实现思路是在寻找根节点的过程中,将当前元素及其之上的所有元素都直接指向根节点,也就是 parent 数组中每一个元素(以下标体现),它们所对应的值最终都保存为根节点的下标。

具体实现上只需要修改 find§ 函数,将其修改为递归函数,递归的宏观语义为,如果当前节点 p 不是根节点,就递归去寻找根节点,如果找到了根节点就返回根节点的父节点,也就还是根节点,因为根节点指向自己。

代码实现如下:

/**
 * 基于 路径压缩 的优化
 * 在 find(p) 递归过程中将 p 及其之上所有节点都直接指向根节点
 */
private int find(int p) {
    if (p < 0 || p >= parent.length)
        throw new IllegalArgumentException("p is out of bound.");

    if (parent[p] != p)  // 不等于根节点时
        parent[p] = find(parent[p]);   // 递归找根节点

    return parent[p];   // parent[p] == p 时,p为根节点,返回根节点。 
}

以上是关于数据结构学习 -- 并查集的主要内容,如果未能解决你的问题,请参考以下文章

数据结构学习 -- 并查集

数据结构学习 -- 并查集

数据结构学习 -- 并查集

并查集——入门学习(java代码实现)

并查集——入门学习(java代码实现)

并查集学习总结