【数据结构与算法】并查集详解

1,605 阅读11分钟

并查集

1. 引入

并查集是一种能够将集合快速合并的结构。

假设有5个样本ABCDE,我们将每一种样本单独构成一个集合:{A},{B},{C},{D},{E}。

现在我们需要对外提供两个操作:

  • 第一个是:查询任意两个样本是否属于同一个集合isInSameSet(a,b)。
  • 第二个是:将任意两个不是同一个集合的样本所在的集合合并union(a,b)。

其实可以用很多结构来实现上述两个功能。但是想让两个功能运行的很快,很复杂,使用经典的一些结构是无法做到的。

  • 如果使用链表来实现,那么union(a,b)很快,能达到O(1);但是isInSameSet(a,b)很慢,时间复杂度为O(N),因为需要遍历整个链表。

  • 如果使用哈希表来实现,那么isInSameSet(a,b)很快,能达到O(1);但是union(a,b)很慢,哈希表之间数据迁移的时间复杂度很高。

我们设计一个什么结构能够让isInSameSet(a,b)和union(a,b)时间复杂度都是O(1)?并查集。

2. 原理

使用一种特殊逻辑的图结构来表示并查集。

每一个样本都是图中的一个Node,每一个Node都会有一个指针,在并查集初始化时,每一个Node的指针都会指向节点自身。

isInSameSet(a,b)的实现逻辑是:分别找样本a和样本b的代表节点,如果代表节点是同一个节点,则表示样本a和样本b在同一个集合中;如果不是同一个节点,则表示样本a和样本b不在同一个集合中。

代表节点就是一个集合的代表。

代表节点找法就是:从当前样本Node的指针指向开始遍历,直到遍历到指针指向自身的节点为止,该节点就是Node的代表节点。

20211007183559.png

union(a,b)的实现逻辑是:首先调用isInSameSet(a,b)判断样本a和样本b是否在同一个集合中,如果在,则无需做任何操作;如果不在,则可以进行合并。合并的具体实现是:将节点数少的集合的代表节点的指针指向节点数多的集合的代表节点(如果两个集合节点数一样,则指向顺序随意)。

3. 优化

并查集中有一个非常重要的优化,优化的就是根据指针指向进行遍历的过程

20211007205405.png

如上图,假设在某一个时刻并查集成了该种结构,假设现在调用isInSameSet(a,g)或者union(a,g)(union底层调用的还是isInSameSet)。那么此时,a和g节点就会根据自身的指针指向开始遍历,直到遍历到指针指向自己的节点位置。a指针指向自己,因此无需遍历;g需要根据指针指向依次遍历f、e和b最终才能找到a。

20211007191308.png

该优化的操作就是在g —> f —> e —> b —> a的过程中,需要将g遍历的路径 "扁平化"。

扁平化的具体操作就是:将遍历路径上的所有节点的指针指向最后的代表节点,也就是让g、f、b和e节点的指针直接指向a。

在没有优化并查集前,性能的瓶颈很明显。如果并查集的某次操作时间复杂度过高,一定是某一个节点遍历寻找代表节点的单向链表过长导致的,这也是并查集唯一一个需要优化的问题。

"扁平化" 操作能够不断压缩单向链表的长度,且不违反原本的结构。

那么如何评估为什么 "扁平化" 的优化能够让并查集操作的时间复杂度变得很低?

证明太复杂,并查集从1964年被发明出来后直到1989年才证明完毕,整整证了25年。要想了解详细证明,请看《算法导论》23章。

我们只说结论:如果有N个样本,当findRepresentativeNode的调用次数已经逼近了O(N)的水平或者再往上的水平之后,单次findRepresentativeNode的平均代价O(1)。也就是说,当样本很大,且findRepresentativeNode的调用次数为有限次,那么可以保证单次调用findRepresentativeNode的复杂度很低;但是如果findRepresentativeNode的调用非常频繁,频繁到逼近样本数,那么调用的越频繁,单次调用findRepresentativeNode的复杂度越低,达到O(1),且常数非常小不超过6。

4. 实现

public class UnionFindSet<V> {

    // 节点类型
    static class Node<V> {
        // 样本
        public V value;

        public Node(V value) {
            this.value = value;
        }
    }

    // 样本——Node 对应表
    public HashMap<V, Node<V>> nodeMap;

    // Node——父Node 对应表
    public HashMap<Node<V>, Node<V>> fatherMap;

    // 代表Node——对应集合中Node总数 对应表(只有代表节点才会存入该表,且计算总数时包含代表节点)
    public HashMap<Node<V>, Integer> sizeMap;

    // 并查集能够使用的前提是要有初始化,并且初始化时,要求样本必须全部注册入并查集(传入List)
    public UnionFindSet(List<V> samples) {
        nodeMap = new HashMap<>();
        fatherMap = new HashMap<>();
        sizeMap = new HashMap<>();

        for (V sample : samples) {
            // 将每一个样本构建成一个Node
            Node<V> node = new Node<V>(sample);

            // 将样本与Node一一对应
            nodeMap.put(sample, node);
            // 初始化时先将每个节点的指针指向自身
            fatherMap.put(node, node);
            // 初始化时每个Node都构成一个集合,每个Node都是代表Node
            sizeMap.put(node, 1);
        }
    }

    // 通过Node找到该Node所属集合的代表节点(扁平优化)
    private Node<V> findRepresentativeNode(Node<V> node) {
        // 使用一个栈辅助优化
        Stack<Node<V>> stack = new Stack<>();

        // 直到遍历到指针指向自身的代表节点位置
        while (fatherMap.get(node) != node) {
            // 将沿途所有Node压栈
            stack.push(node);
            // node赋值为其父节点
            node = fatherMap.get(node);
        }

        // 进行扁平优化
        while (!stack.isEmpty()) {
            // 将沿途所有Node的指针指向代表节点
            fatherMap.put(stack.pop(), node);
        }

        // 返回代表节点
        return node;
    }

    // 判断a,b两个样本是否在同一个集合中
    public boolean isInSameSet(V a, V b) {
        // 确保样本a和b都注册
        if (nodeMap.containsKey(a) && nodeMap.containsKey(b)) {
            // 判断两个样本对应Node的代表Node是否是同一个
            return findRepresentativeNode(nodeMap.get(a)) == findRepresentativeNode(nodeMap.get(b));
        }

        return false;
    }

    // 如果样本a和样本b所属集合不同,将两集合合并
    public void union(V a, V b) {
        // 确保样本a和b都注册
        if (nodeMap.containsKey(a) && nodeMap.containsKey(b)) {
            // 取出他们的代表节点
            Node<V> aRNode = findRepresentativeNode(nodeMap.get(a));
            Node<V> bRNode = findRepresentativeNode(nodeMap.get(b));

            // 如果代表节点不是同一个,则进行集合合并
            if (aRNode != bRNode) {
                // 判断哪一个集合的节点数多
                Node<V> moreNode = sizeMap.get(aRNode) > sizeMap.get(bRNode) ? aRNode : bRNode;
                Node<V> lessNode = moreNode == aRNode ? bRNode : aRNode;

                // 将集合节点数少的代表节点指向集合节点数多的代表节点
                fatherMap.put(lessNode, moreNode);
                // 更新合并后集合的节点数
                sizeMap.put(moreNode, sizeMap.get(lessNode) + sizeMap.get(moreNode));
                // 集合节点数少的代表节点不再是代表节点
                sizeMap.remove(lessNode);
            }

        }
    }

}

岛问题

1. 经典问题

题目

一个矩阵中只有0和1两种值,每个位置都可以和自己的上、下、左、右四个位置相连。如果有一片1连在一起,这个部分叫做一个岛。求一个矩阵中有多少个岛?

例如下面矩阵有3个岛。

0 0 0 1 0 0 0

0 0 1 1 0 0 0

0 0 0 0 0 1 0

0 0 0 0 0 1 0

分析

该题目为一道经典面试题,通过该题能够引出一个结构。

经典问题很好解决,首先定义一个感染过程infect,然后遍历矩阵。在遍历过程中只要遇到1,就调用infect将该1上下左右能连的1全部连起来了并且全部置2。直到矩阵遍历结束,此时调用几次infect就有几个岛。

如果使用题目所给矩阵,infect的递归调用树如下:

20211007104326.png

整个过程时间复杂度为:O(NM),其中N为矩阵的宽,M为矩阵的长。

该算法虽然有递归过程,但是时间复杂度并不高,只是一个矩阵的规模。

具体时间复杂度如何估算,我们看矩阵中每一个位置会被访问几次。在整体遍历阶段,每个位置被访问一次。在infect阶段,每个位置最多被它的上下左右各访问了一次。总体来看,每个位置最多会被调用5次,是一个有限次数。因此时间复杂度为O(5NM),取出常数项为O(NM)。

代码

public static int islandProblem(int[][] matrix) {
    if (matrix == null || matrix.length == 0) {
        return 0;
    }

    // 调用infect的次数
    int count = 0;

    for (int i = 0; i < matrix.length; i ++) {
        for (int j = 0; j < matrix[i].length; j ++) {
            if (matrix[i][j] == 1) {
                // 进行感染过程
                infect(matrix, i, j);
                // 感染次数自增
                count ++;
            }
        }
    }

    return count;
}

public static void infect(int[][] matrix, int i, int j) {
    // Base case 边界过滤器
    if (
        i < 0 || i >= matrix.length
        || j < 0 || j >= matrix[i].length
        || matrix[i][j] != 1) {
        return ;
    }

    // 将自身置2
    matrix[i][j] = 2;

    // 将与该位置上下左右相连的一片1全部置2
    infect(matrix, i - 1, j);
    infect(matrix, i + 1, j);
    infect(matrix, i, j - 1);
    infect(matrix, i, j + 1);
}

2. 扩展问题

问题:如何设计一个并行算法解决来经典问题。

分析

面试、ACM中遇到大部分题目的默认环境都是单CPU、单内存系统,只关注于算法的逻辑。但是在面试过程中,也会遇到并行算法的题目,这种题目不需要代码实现,只需要阐述清楚过程即可。

明明经典解法的时间复杂度已经控制的非常好了,为什么还要设计一个并行算法来解决该问题?

假设矩阵非常大,如果使用经典解法,只能使用一台机器来解决,这样效率特别慢。如果使用并行算法,那么我们需要给矩阵设计一个分片和合并的策略,这样可以让多台机器并行计算矩阵中岛的数量,效率几何倍提高。

解决方案

在设计并行算法之前,需要了解并查集这个非常重要的结构,请先看上文。

假设我们先讨论使用两个CPU并行计算加速的情况:

如果我们将矩阵划分给不同CPU进行计算,那么岛的数量可能会上升。例如如下矩阵,我们发现原矩阵中所有的1都是连成一片,因此只有一个岛。如果我们将该矩阵从中间划分成两部分,CPU1去计算左半边矩阵中岛的数量,CPU2去计算右半边矩阵中岛的数量,那么最终CPU1会统计出2个岛,CPU2也会统计出2个岛,总岛数是4。

20211008174637.png

事实上是只有一个岛的,但是为什么划分矩阵之后会出现多个岛的情况呢?

因为划分的过程破坏了原本的连通性。因此我们还需要设计一个合并矩阵的方案,从而能够计算出正确岛的数量。

合并矩阵的方案是:

在CPU1计算出左半边矩阵中岛的数量后,利用infect过程去收集边界上的感染点的信息(边界指的是划分出来的边界)。在CPU2计算出右半边矩阵中岛的数量后,也利用infect过程去收集边界上的感染点的信息。

如何收集感染点的信息呢?首先需要CPU1记录左半边矩阵中第一个感染初始点A,然后记录所有被A感染的边界点。然后CPU1再记录左半边矩阵中第二个感染初始点B,然后记录所有被B感染的边界点。CPU2记录右半边矩阵中第一个感染初始点C,然后记录所有被C感染的边界点(C点自身也是被感染的边界点)。CPU2再记录右半边矩阵中第二个感染初始点D,然后记录所有被D感染的边界点(D点自身也是被感染的边界点)。

20211008195503.png

将A、B、C和D注册入并查集并进行初始化。开始各自为一个集合{ A }、{ B }、{ C } 和 { D }。然后看边界相碰的两个点是否是属于同一个集合。例如上图所圈出的里两个点,左边点是B感染的边界点,右边点是C感染的边界点 ,B和C不属于同一个集合,因此B和C的集合需要合并为{ B,C },然后总岛数减一(因为两个边界相碰的点在原矩阵中是连通的)。如果B和C属于同一个集合,说明之前讨论过的联通路径已经包含了此时边界相碰的两个点,总岛数不变。

按照上述操作,将所有边界相碰的情况讨论完,被两个CPU划分的矩阵就合并成功了。此时的总岛数就是原矩阵的岛数。

为什么这种方法快?

经典解决方案是一个CPU从左到右、从上到下遍历矩阵得到结果;现在是使用CPU1和CPU2同时遍历一半矩阵,最终再使用一个CPU3处理一下边界碰撞的点即可。

如果在多个CPU一起并行处理的情况下,我们就可以将矩阵划分成若干块,然后每个CPU先处理自己块的信息,再处理一下边界信息即可(边界信息的处理方式和2个CPU的方式相同)。例如下图中CPU1在处理完自身信息后,还需要处理四条边的边界信息。

20211008201957.png

3. 总结

扩展问题的解决方案贯彻了MapReduce的思想,MapReduce的整体思想就是将一个算法改成并行计算并能够整合正确结果的过程。其中,Map就是并行处理一个整体的不同块,Reduce就是将不同块的信息整合。

本题的解决方案比经典的Hadoop,Spark中Reduce的方案高级很多,是一种非常强的算法设计。