高级数据结构:并查集(Java 实现)

2019-01-25  本文已影响15人  李威威
image

并查集的内容非常简单,代码的每个方法的实现都很短,难在灵活应用。

并查集基础

为什么叫并查集?因为在这个数据结构中,我们要针对“并”和“查”这两种操作展开研究。并是并什么?并是把两个元素合并在一起。查是查什么?查两个元素是不是连接在一起;

  1. find(int p):查找元素 p 所对应的集合。

  2. union(int p, int q):合并元素 p 和元素 q 所在的集合。

  3. connected(int p, int q):查询元素 p 和元素 q 是不是在同一个集合中。

因此,我们的并查集其实就是要实现下面的这个接口:

public interface IUnionFind {

    // 并查集的版本名称,由开发者指定
    String versionName();

    // p (0 到 N-1)所在的分量的标识符
    int find(int p);

    // 如果 p 和 q 存在于同一分量中则返回 true
    boolean connected(int p, int q);

    // 在 p 与 q 之间添加一条连接
    void union(int p, int q);

}

并查集算法第 1 版:quick-find

通过这一节的学习,我们可以知道:

  1. 并查集的第 1 种实现(使用 id 数组管理查和并操作),能够快速地实现 find(int p) 这个操作;
  2. quick-find 对于 union(int p, int q) 这个操作是低效的,因为需要遍历整个并查集。
基于 id 的 quick-find

find(int p) 这个操作的时间复杂度是 O(1),而 union(int p, int q) 这个操作的时间复杂度是 O(n),要全部遍历并查集中的元素,把其中一个分量标识的所有结点的编号更改为另一个一个分量标识。

例如上面的表格中,如果我们要将第 1 行的 0 和 1 执行 union(int p, int q) 操作,有两种方式:第 1 种方式:把第 1 行的 0,2,4,6,8 的 id 全改成 1;第 2 种方式:把第 1 行的 1,3,5,7,9 的 id 全改成 0。我们可以看到,union(int p, int q) 的操作完全是和问题的规模成正比的,所以 quick-find 下的 union(int p, int q) 操作时间复杂度是 O(n)

public class UnionFind1 implements IUnionFind {

    private int[] id; // 分量 id

    private int count; // 联通分量的数量

    public UnionFind1(int n) {
        this.count = n;
        // 初始化分量 id 数组
        id = new int[n];
        for (int i = 0; i < n; i++) {
            id[i] = i;
        }
    }

    @Override
    public String versionName() {
        return "并查集的第 1 个版本,基于 id 数组,quick-find";
    }

    // 以常数时间复杂度,返回分量的标识符,与并查集的规模是无关的,这一步很快
    // 因此我们称这个版本的并查集是 quick-find
    @Override
    public int find(int p) {
        return id[p];
    }

    @Override
    public boolean connected(int p, int q) {
        return find(p) == find(q);
    }

    // 因为需要遍历数组中的全部元素,所以其实这个版本效率并不高
    @Override
    public void union(int p, int q) {
        int pid = find(p);
        int qid = find(q);

        // 如果 p 和 q 已经在相同的分量之中,则什么都不做
        if (pid == qid) {
            return;
        }

        // 将 p 的分量重新命名为 q 的名称
        for (int i = 0; i < id.length; i++) {
            if (find(i) == pid) {
                id[i] = qid;
            }
        }
        // 每次 union 以后,连通分量减 1
        count--;
    }
}

并查集算法第 2 版:quick-union

从孩子结点指向父亲结点
parent[pRoot] = qRoot;
初始化的时候,我们将每个元素都指向自己

画出的示意图如下。

初始化的时候,我们将每个元素都指向自己

从正确性上来说,谁的根指向谁的根都是可以的。但是在实际运行的时候,我们会发现,我们应该将层级较少的根指向层级较多的根,这样做是为了保证我们的并查集形成的树的高度不会增加,这样在 find 的时候,追溯的层数不会增加。我们在查找根的时候,应该使得查找的层数最少。

public class UnionFind2 implements IUnionFind {

    private int[] parent; // 第 i 个元素存放它的父元素的索引

    private int count; // 联通分量的数量

    public UnionFind2(int n) {
        this.count = n;
        parent = new int[n];
        for (int i = 0; i < n; i++) {
            parent[i] = i;
        }
    }

    @Override
    public String versionName() {
        return "并查集的第 2 个版本,基于 parent 数组,quick-union";
    }

    @Override
    public int find(int p) {
        // 跟随链接找到根结点
        while (parent[p] != p) { // 只要不是根结点
            p = parent[p];
        }
        return p;
    }

    @Override
    public boolean connected(int p, int q) {
        return find(p) == find(q);
    }

    @Override
    public void union(int p, int q) {
        int pRoot = find(p); // 将 p 归并与之相同的分量中
        int qRoot = find(q); // 将 q 归并与之相同的分量中

        // 如果 p 和 q 已经在相同的分量之中,则什么都不做
        if (pRoot == qRoot) {
            return;
        }
        // 如果 parent[qRoot] = pRoot; 也是可以的,即将其中一个结点指向另一个结点
        parent[pRoot] = qRoot;
        // 每次 union 以后,连通分量减 1
        count--;
    }
}

并查集算法第 3 版:quick-union 基于 size 的优化

// union-find 算法的实现(加权 quick-union 算法)
public class UnionFind3 implements IUnionFind {

    private int[] parent; // 第 i 个元素存放它的父元素的索引

    private int count; // 联通分量的数量

    private int[] size; // 以当前索引为根的树所包含的元素的总数

    public UnionFind3(int n) {
        this.count = n;
        parent = new int[n];
        size = new int[n];
        for (int i = 0; i < n; i++) {
            parent[i] = i;
            // 初始化时,所有的元素只包含它自己,只有一个元素,所以 size[i] = 1
            size[i] = 1;
        }
    }

    @Override
    public String versionName() {
        return "并查集的第 3 个版本,基于 parent 数组,quick-union,基于 size";
    }

    // 返回索引为 p 的元素的根结点的索引
    @Override
    public int find(int p) {
        // 跟随链接找到根结点
        while (p != parent[p]) {
            p = parent[p];
        }
        return p;
    }

    @Override
    public boolean connected(int p, int q) {
        int pRoot = find(p);
        int qRoot = find(q);
        return pRoot == qRoot;
    }

    @Override
    public void union(int p, int q) {
        int pRoot = find(p);
        int qRoot = find(q);
        if (pRoot == qRoot) {
            return;
        }
        // 这一步是与第 2 版不同的地方,我们不是没有根据地把一个结点的根结点的父结点指向另一个结点的根结点
        // 而是将小树的根结点连接到大树的根结点
        if (size[pRoot] > size[qRoot]) {
            parent[qRoot] = pRoot;
            size[pRoot] += size[qRoot];
        } else {
            parent[pRoot] = qRoot;
            size[qRoot] += size[pRoot];
        }
        // 每次 union 以后,连通分量减 1
        count--;
    }
}

并查集算法第 4 版:quick-union 基于 rank 的优化

因为我们总是期望这棵树的高度比较低,这样,我们在 find 的时候,就能通过很少的步数来找到根结点,进而提高 find 的效率。

public class UnionFind4 implements IUnionFind {

    private int[] parent;

    private int count;

    // 以索引为 i 的元素为根结点的树的深度(最深的那个深度)
    private int[] rank;

    public UnionFind4(int n) {
        this.count = n;
        parent = new int[n];
        rank = new int[n];
        for (int i = 0; i < n; i++) {
            parent[i] = i;
            // 初始化时,所有的元素只包含它自己,只有一个元素,所以 rank[i] = 1
            rank[i] = 1;
        }
    }

    @Override
    public String versionName() {
        return "并查集的第 4 个版本,基于 parent 数组,quick-union,基于 rank";
    }

    // 返回索引为 p 的元素的根结点
    @Override
    public int find(int p) {
        // 跟随链接找到根结点
        while (p != parent[p]) {
            p = parent[p];
        }
        return p;
    }

    @Override
    public boolean connected(int p, int q) {
        int pRoot = find(p);
        int qRoot = find(q);
        return pRoot == qRoot;
    }


    @Override
    public void union(int p, int q) {
        int pRoot = find(p);
        int qRoot = find(q);
        if (pRoot == qRoot) {
            return;
        }
        // 这一步是与第 3 版不同的地方
        if (rank[pRoot] > rank[qRoot]) {
            parent[qRoot] = pRoot;
        } else if (rank[pRoot] < rank[qRoot]) {
            parent[pRoot] = qRoot;
        } else {
            parent[qRoot] = pRoot;
            rank[pRoot]++;
        }
        // 每次 union 以后,连通分量减 1
        count--;
    }
}

并查集算法第 5 版:quick-union 基于路径压缩的非递归实现

极端的并查集的例子

我们开始找 4 的父亲结点,4 的父亲结点不是 4 ,说明不是根结点,此时,我们尝试 2 步一跳,将 4 的父亲结点“改成”父亲结点的父亲结点,于是得到一个等价的并查集:

与上一个并查集等价的并查集

下面我们该考察元素 2 了,2 的父亲结点是1,2 不是根结点,所以我们继续两步一跳,把 2 的父亲结点设置成它的父亲结点的父亲结点,于是又得到一个等价的并查集:

与上一个并查集等价的并查集

此时,整棵树的高度就在一次 find 的过程中被压缩了。

这里有一个疑问:即使我们在最后只差一步的情况下,我们跳两步,也不会出现无效的索引。其实,一步一跳,两步一跳,甚至三步一跳都没有关系。

画图画了这么多,代码实现起来只有一句:parent[p] = parent[parent[p]];编写的时候要注意这行代码添加的位置,画一个示意图,想想这个路径压缩的过程,不难写出。

public int find(int p) {
    // 在 find 的时候执行路径压缩
    while (p != parent[p]) {
        // 编写这句代码的时候可能会觉得有点绕,
        // 技巧是画一个示意图,就能很直观地写出正确的逻辑
        // 两步一跳完成路径压缩
        parent[p] = parent[parent[p]];
        p = parent[p];
    }
    return p;
}

根据上面的图,我们通过 find 操作执行路径压缩,最终形成的并查集如下:

与上一个并查集等价的并查集

并查集算法第 6 版:quick-union 基于路径压缩的递归实现

从极端不好的并查集到极端好的并查集

那么,代码又应该如何实现呢?我们须要使用递归的思想。这一版代码的实现难点就在于:应该定义 find(p) 返回的是 p 这个结点的父结点

/**
 * 返回索引为 p 的元素的根结点
 * 理解这个方法的关键点:我们就是要把这个结点的父结点指向根结点,
 * 既然父亲结点不是根结点,我们就继续拿父亲结点找根结点
 * 一致递归找下去,
 * 最后返回的时候,写 parent[p] 是可以的
 * 写 parent[parent[p]] 也是没有问题的
 *
 * @param p
 * @return
 */
public int find(int p) {
    // 在 find 的时候执行路径压缩
    assert p >= 0 && p < count;
    // 注意:这里是 if 不是 while,否则就变成循环
    if (p != parent[p]) {
        // 这一行代码的逻辑要想想清楚
        // 只要不是根结点
        // 就把父亲结点指向父亲结点的父亲结点
        parent[p] = find(parent[p]);
    }
    return parent[p];
}

最后,我们来比较一下基于路径压缩的两种方法。

路径压缩算法

并查集能够很好地帮助我们解决网络中两个结点是否连接的问题。但是,对于网络中的两个结点的路径,最短路径的问题,我们就要使用图来解决。

关于路径压缩的思考

写到这里,我们发现在路径压缩的过程中,我们之前引入的辅助合并的数组,不管是 rank 还是 size,它们的定义都不准确了,因为我们在路径压缩的过程中没有对它们的定义进行维护。这一点其实并不影响我们的算法的正确性,我们不去维护 rank 数组和 size 数组的定义,是因为第 1 点,难于实现,第 2 点 rank 数组还是 size 数组的当前实现仍然可以作为一个参考值,比起随机的做法要更有意义一些。

并查集算法第 7 版:易于理解的路径压缩算法

Python 实现:

class UnionFind7:
    def __init__(self, n):
        self.parent = [i for i in range(n)]
        self.count = n

    def find(self, p):
        """
        查找元素 p 根节点的编号
        :param p:
        :return:
        """
        assert 0 <= p < self.count
        root = p
        while root != self.parent[root]:
            root = self.parent[root]
        # 此时 root 就是大 boss
        # 下面这一步就是最直接的路径压缩:
        # 把沿途查找过的结点都指向 root
        while p != self.parent[p]:
            temp = self.parent[p]
            self.parent[p] = root
            p = temp
        return root

    def is_connected(self, p, q):
        """
        查询元素 p 和 q 是否属于同一个集合
        有共同的父亲,就表示它们属于同一个集合
        :param p:
        :param q:
        :return:
        """
        return self.find(p) == self.find(q)

    def union(self, p, q):
        """
        合并元素 p 和元素 q 所属于的集合
        O(n)复杂度
        :param p:
        :param q:
        :return:
        """

        p_id = self.find(p)
        q_id = self.find(q)
        if p_id == q_id:
            return
        # 任意指向即可
        self.parent[p_id] = q_id

LeetCode 上关于“并查集”的练习

LeetCode 第 200 题:岛屿的个数。

传送门:200. Number of Islands200. 岛屿的个数

LeetCode 第 547 题:朋友圈(这个题目的名字很接地气)

传送门:547. Friend Circles547. 朋友圈

我的 Python 解答:

class Solution(object):

    def findCircleNum(self, M):
        """
        :type M: List[List[int]]
        :rtype: int
        """
        class UnionFind:
            def __init__(self, n):
                self.parent = [i for i in range(n)]

            def find(self, p):
                root = p
                while root != self.parent[root]:
                    root = self.parent[root]
                while p != self.parent[p]:
                    temp = self.parent[p]
                    self.parent[p] = root
                    p = temp
                return root

            def is_connected(self, p, q):
                return self.find(p) == self.find(q)

            def union(self, p, q):
                p_id = self.find(p)
                q_id = self.find(q)
                if p_id == q_id:
                    return
                self.parent[p_id] = q_id

        m = len(M)
        union_find_set = UnionFind(m)
        for i in range(m):
            for j in range(i):
                if M[i][j] == 1:
                    union_find_set.union(j, i)
        counter = 0
        # print(union_find_set.parent)
        for index, parent in enumerate(union_find_set.parent):
            if index == parent:
                counter += 1
        return counter

参考资料

1、正月点灯笼《并查集》视频讲解:

https://www.bilibili.com/video/av38498175

2、subetter 的文章《并查集》

https://subetter.com/articles/union-find-set.html

3、wmathor 的文章《并查集例题》

博客地址:https://wmathor.com


其它关于“并查集”的参考资料

并查集 leetcode 编程题

https://blog.csdn.net/m0_37693059/article/details/78106392

并查集(Union-Find) 应用举例 --- 基础篇

https://blog.csdn.net/dm_vincent/article/details/7769159

并查集(Union-Find)算法介绍

https://blog.csdn.net/dm_vincent/article/details/7655764

Leetcode 130:被围绕的区域(最详细的解法!!!)

https://blog.csdn.net/qq_17550379/article/details/82688731

(完)

上一篇 下一篇

猜你喜欢

热点阅读