本文已收录到 GitHub AndroidFamily,有 Android 进阶知识系统,欢迎 Star。技能和职场问题,请关注大众号 [彭旭锐] 私信我提问。

前语

大家好,我是小彭。

今日分享到的是一种相对冷门的数据结构 —— 并查集。虽然冷门,可是它背后表现的算法思维却十分精妙,在处理特定问题上能做到出奇制胜。那么,并查集是用来处理什么问题的呢?


学习路线图:

如何使用并查集解决朋友圈问题?


1. 知道并查集

除了并查集之外,不相交调集(Disjoint Sets)、兼并-查找调集(Merge-find Set)、联合-查询数据结构(Union-find Data Structure)、联合-查询算法(Union-find algorithm),均表明相同的数据结构或思维。

1.1 并查集用于处理什么问题?

并查集是一种用来高效地判别 “动态连通性 ” 的数据结构: 即给定一个无向图,要求判别某两个元素之间是否存在相连的途径(连通),这便是连通问题,也叫 “朋友圈” 问题。听起来有点懵,你先别着急哈,咱来一点一点地把这个知识系统建立起来。

先举个例子,给定一系列航班信息,问是否存在 “北京” 到 “广州” 的途径,这便是连通性问题。而如果是问 “北京” 到 “广州” 的最短途径,这便是途径问题。并查集是专心于处理连通性问题的数据结构,而不关心元素之间的途径与间隔,所以最短途径等问题就超出了并查集的能够处理的范围,不是它考虑的问题。

连通问题与途径问题示意图

如何使用并查集解决朋友圈问题?

另一个要害点是,并查集也十分适宜处理动态数据的连通性问题。 由于在完成旧数据的处理后,旧数据的连通联系是记载在并查会集的。即便后续动态添加新的数据,也不需求重新检索整个数据集,只需求将新数据供给的信息补充到并查会集,这带有空间换时刻的思维。

动态连通问题

如何使用并查集解决朋友圈问题?

了解了并查集的运用场景后,下面评论并查集是如何处理连通性的问题。

1.2 并查集的逻辑结构

已然要处理连通性问题,那么在并查集的逻辑结构里,就必须用某种方式表现出两个元素或许一堆元素之间的衔接联系。那它是怎么表现的呢 —— 代表元法。

并查集运用 “代表元法” 来表明元素之间的衔接联系:将相互连通的元素组成一个子集,并从中选取一个元素作为代表元。而判别两个元素之间是否连通,便是判别它们的代表元是否相同,代表元相同则说明处于相同子集,代表元不同则说明处于不同的子集。

例如,咱们将航班信息构建为并查集的数据结构后,就有 “重庆” 和 “北京” 两个子集。此刻,问是否存在 “北京” 到 “广州” 的途径,便是看 “北京” 和 “广州” 的代表元是否相同。可见它们的代表元是相同的,因而它们是连通的。

并查集的逻辑结构和物理结构

如何使用并查集解决朋友圈问题?

了解了并查集的逻辑结构后,下面评论如何用代码完成并查集。

1.3 并查集的物理结构

并查集的物理结构能够是数组,亦能够是链表,只需能够表现节点之间衔接联系即可。

  • 链表完成: 为每个元素创建一个链表节点,每个节点持有指向父节点的指针,通过指针的的指向联系来构建调集的衔接联系,而根节点(代表元)的父节点指针指向节点自身;
  • 数组完成: 创建与元素个数相同大小的数组,每个数组下标与每个元素一一对应,数组的值表明父节点的下标方位,而根节点(代表元)所在方位的值便是数组下标,表明指向自身。

数组完成相对于链表完成愈加常见,另外,在数组的基础上还衍生出散列表的完成,要害看元素个数是否固定。例如:

  • 在 LeetCode 990. 等式方程的可满意性 这道题中,节点是已知的 26 个字母,此刻运用数组即可;
  • 在 LeetCode 684. 冗余衔接 这道题中,节点个数是不知道的,此刻运用散列表更适宜。

提示: 咱们这儿将父节点指向节点自身界说为根节点,也有题解将父节点指向 null 或许 -1 的节点界说为根节点。两种办法都能够,只需能够区分出普通节点和根节点。可是指向节点自身的写法更简练,不需求忧虑 Union(x, x) 呈现死循环。

以下为根据数组和根据散列表的代码模板:

根据数组的并查集

// 数组完成适宜元素个数固定的场景
class UnionFind(n: Int) {
    // 创建一个长度为 n 的数组,每个方位上的值初始化数组下标,表明初始化时有 n 个子集
    private val parent = IntArray(n) { it }
    ...
}

根据散列表的并查集

// 散列表完成适宜元素个数不固定的场景
class UnionFind() {
    // 创建一个空散列表,
    private val parent = HashMap<Int, Int>()
    // 查询操作
    fun find(x: Int): Int {
        // 1. parent[x] 为 null 表明首次查询,先加入散列表中并指向自身
        if (null == parent[x]) {
            parent[x] = x
            return x
        }
        // 下文说明查询操作细节...
    }
}

2. 并查集的基本概念

2.1 兼并操作与查询操作

“并查集,并查集”,顾名思义并查集便是由 “并” 和 “查” 这两个最基本的操作组成的:

  • Find 查询操作: 沿着只用链条找到根节点(代表元)。如果两个元素的根节点相同,则说明两个元素是否归于同一个子集,否则归于不同自己;
  • Union 兼并操作: 将两个元素的根节点兼并,也表明将两个子集兼并为一个子集。

例如,以下是一个根据数组的并查集完成,其间运用 Find(x) 查询元素的根节点运用 Union(x, y) 兼并两个元素的根节点:

根据数组的并查集

class UnionFind(n: Int) {
    // 创建一个长度为 n 的数组,每个方位上的值初始化数组下标,表明初始化时有 n 个子集
    val parent = IntArray(n) { it }
    // 查询操作(遍历写法)
    fun find(x: Int): Int {
        var key = x
        while (key != parent[key]) {
            key = parent[key]
        }
        return key
    }
    // 兼并操作
    fun union(x: Int, y: Int) {
        // 1. 别离找出两个元素的根节点
        val rootX = find(x)
        val rootY = find(y)
        // 2. 恣意挑选其间一个根节点成为另一个根节点的子树
        parent[rootY] = rootX
    }
    // 判别连通性
    fun isConnected(x: Int, y: Int): Boolean {
        // 判别根节点是否相同
        return find(x) == find(y)
    }
    // 查询操作(递归写法)
    fun find(x: Int): Int {
        var key = x
        if (key != parent[key]) {
            return find(parent[key])
        }
        return key
    }
}

兼并与查询示意图

如何使用并查集解决朋友圈问题?

2.2 连通重量

并查集的连通重量,表明的是整个并查会集独立的子集个数,也便是森林中树的个数。要核算并查集的连通重量,其实便是在兼并操作中保护连通重量的计数,在兼并子集后将计数减一。

class UnionFind(n: Int) {
    private val parent = IntArray(n) { it }
    // 连通重量计数,初始值为元素个数 n
    var count = n
    // 兼并操作
    fun union(x: Int, y: Int) {
        val rootX = find(x)
        val rootY = find(y)
        if(rootX == rootY){
            // 未产生兼并,则不需求减一
            return
        }
        // 兼并后,连通重量减一
        parent[rootY] = rootX
        count --
    }
    ...
}

连通重量示意图

如何使用并查集解决朋友圈问题?


3. 典型例题 等式方程的可满意性

了解以上概念后,就已经具备处理连通问题的必要知识了。咱们看一道 LeetCode 上的典型例题: LeetCode 990.

LeetCode 例题

如何使用并查集解决朋友圈问题?

咱们能够把每个变量看作看作一个节点,而等式表明两个节点相连,不等式则表明两个节点不相连。那么,咱们能够分 2 步:

  • 1、先遍历一切等式,将等式中的两个变量兼并到同一个子会集,终究构造一个并查集;
  • 2、再遍历一切不等式,判别不等式中的两个变量是否处于同一个子集。是则说明有冲突,即等式方程不成立。

如何使用并查集解决朋友圈问题?

—— 图片引证自 LeetCode 官方题解

题解示例如下:

题解

// 未优化版本
class Solution {
    fun equationsPossible(equations: Array<String>): Boolean {
        // 26 个小写字母的并查集
        val unionFind = UnionFind(26)
        // 兼并一切等式
        for (equation in equations.filter { it[1] == '=' }) {
            unionFind.union(equation.first(), equation.second())
        }
        // 查看不等式是否与连通性冲突
        for (equation in equations.filter { it[1] == '!' }) {
            if (unionFind.isConnected(equation.first(), equation.second())) {
                return false
            }
        }
        return true
    }
    private fun String.first(): Int {
        return this[0].toInt() - 97
    }
    private fun String.second(): Int {
        return this[3].toInt() - 97
    }
    private class UnionFind() {
        // 代码略
    }
}

4. 并查集的优化

前面提到并查集逻辑上是一种根据森林的数据结构。已然与树有关,咱们天然能想到它的复杂度就与树的高度有关。在极点条件下(依照特别的兼并次序),有可能呈现树的高度刚好等于元素个数 n 的状况,此刻,单次 Find 查询操作的时刻复杂度就退化到 O(n)O(n)

那有没有优化的办法呢?

4.1 父节点重要吗?

在介绍详细的优化办法前,我先提出来一个问题:在已经选定调集的代表元后,一个元素的父节点是谁还重要吗?答案是不重要。

由于无论父节点是谁,终究都是去找根节点的。至于中间是通过哪些节点抵达根节点的,这个并不重要。举个例子,以下 3 个并查集是完全等价的,但明显第 3 个并查会集树的高度更低,查询的时刻复杂度更好。

父节点并不重要

如何使用并查集解决朋友圈问题?

了解了这个点之后,再了解并查集的优化战略就简单了。在并查集里,有 2 种防止链表化的优化战略 —— 途径紧缩 & 按秩兼并。

4.2 途径紧缩(Path Compression)

途径紧缩指在查询的过程中,逐步调整父节点的指向,使其指向更高层的节点,使得很多深层的阶段逐步放到更接近根节点的方位。 根据调整的急进程度又分为 2 种:

  • 隔代紧缩: 调整父节点的指向,使其指向父节点的父节点;
  • 完全紧缩: 调整父节点的指向,使其直接指向根节点。

途径紧缩示意图

如何使用并查集解决朋友圈问题?

途径紧缩示例程序

// 遍历写法
fun find(x: Int): Int {
    var key = x
    while (key != parent[key]) {
        parent[key] = parent[parent[key]] 
        key = parent[key]
    }
    return key
}
// 递归写法
fun find(x: Int): Int {
    var key = x
    if (key != parent[key]) {
        parent[key] = find(parent[key])
        return parent[key]
    }
    return key
}

4.3 按秩兼并(Union by Rank)

第 2.1 节 提到兼并操作时,咱们采取的兼并操作是相对随意的。咱们在兼并时会恣意挑选其间一个根节点成为另一个根节点的子树,这就有可能让一棵较大子树成为较小子树的子树,使得树的高度添加。

而按秩兼并便是要打破这种随意性,在兼并的过程中让较小的子树成为较大子树的子树,防止兼并今后树的高度添加。 为了表明树的高度,需求保护运用 rank 数组,记载根节点对应的高度。

按秩兼并示意图

如何使用并查集解决朋友圈问题?

按秩兼并示例程序

private class UnionFind(n: Int) {
    // 父节点
    private val parent = IntArray(n) { it }
    // 节点的高度
    private val rank = IntArray(n) { 1 }
    // 连通重量
    var count = n
        private set
    // 查询(途径紧缩)
    fun find(x: Int): Int {
        var key = x
        while (key != parent[key]) {
            parent[key] = parent[parent[key]]
            key = parent[key]
        }
        return key
    }
    // 兼并(按秩兼并)
    fun union(key1: Int, key2: Int) {
        val root1 = find(key1)
        val root2 = find(key2)
        if (root1 == root2) {
            return
        }
        if (rank[root1] > rank[root2]) {
            // root1 的高度更大,让 root2 成为子树,树的高度不变
            parent[root2] = root1
        } else if (rank[root2] > rank[root1]) {
            // root2 的高度更大,让 root1 成为子树,树的高度不变
            parent[root1] = root2
        } else {
            // 高度相同,谁当子树都一样
            parent[root1] = root2
            // root2 的高度加一
            rank[root2]++
            //  或
            //  parent[root2] = root1
            //  rank[root1] ++
        }
        count--
    }
}

4.4 优化后的时刻复杂度剖析

在一起运用途径紧缩和按秩兼并两种优化战略时,单次兼并操作或查询操作的时刻复杂度几乎是常量,全体的时刻复杂度几乎是线性的。

以对 N 个元素进行 N – 1 次兼并和 M 次查询的操作序列为例,单次操作的时刻复杂度是 O(a(N))O(a(N)),而全体的时刻复杂度是 O(M⋅a(N))O(Ma(N))。其间 a(x)a(x) 是逆阿克曼函数,是一个增加十分十分慢的函数,只有运用那些十分大的 “天文数字” 作为变量 xx,否则 a(x)a(x) 的取值都不会超越 4,基本上能够当作常数。

然而,逆阿克曼函数毕竟不是常数,因而咱们不能说并查集的时刻复杂度是线性的,但也几乎是线性的。关于并查集时刻复杂度的证明过程,详细能够看参考资料中的两本算法书本,我是看不懂的。


5. 典型例题 岛屿数量(二维)

前面咱们讲的是一维的连通性问题,那么在二维世界里的连通性问题,并查集还仍旧好用吗?咱们看 LeetCode 上的另一道典型例题: LeetCode 200.

LeetCode 例题

如何使用并查集解决朋友圈问题?

这个问题直接上 DFS 广度查找天然是能够的:遍历二维数组,每找到 1 后运用 DFS 遍历将一切相连的 1 消除为 0,直到整块相连的岛屿都消除去,记载岛屿数 +1。最后,输出岛屿数。

用并查集的来解的话,要害技巧便是建立长度为 M * N 的并查集:遍历二维数组,每找到 1 后,将它与右边和下边的 1 兼并起来,终究输出并查会集连通重量的个数,便是岛屿树。

并查集解法

class Solution {
    fun numIslands(grid: Array<CharArray>): Int {
        // 方位
        fun position(row: Int, column: Int) = row * grid[0].size + column
        // 并查集
        val unionFind = UnionFind(grid)
        // 偏移量数组(向右和向下)
        val directions = arrayOf(intArrayOf(0, 1), intArrayOf(1, 0))
        // 边界查看
        fun checkBound(row: Int, column: Int): Boolean {
            return (row in grid.indices) and (column in grid[0].indices)
        }
        for (row in grid.indices) {
            for (column in grid[0].indices) {
                if ('1' == grid[row][column]) {
                    // 消费(防止后续的遍历中重复查找)
                    grid[row][column] = '0'
                    for (direction in directions) {
                        val newRow = row + direction[0]
                        val newColumn = column + direction[1]
                        if (checkBound(newRow, newColumn) && '1' == grid[newRow][newColumn]) {
                            unionFind.union(position(newRow, newColumn), position(row, column))
                        }
                    }
                }
            }
        }
        return unionFind.count
    }
    private class UnionFind(grid: Array<CharArray>) {
        // 父节点
        private val parent = IntArray(grid.size * grid[0].size) { it }
        // 节点高度
        private val rank = IntArray(grid.size * grid[0].size) { 1 }
        // 连通重量(取格子 1 的总数)
        var count = grid.let {
            var countOf1 = 0
            for (row in grid.indices) {
                for (column in grid[0].indices) {
                    if ('1' == grid[row][column]) countOf1++
                }
            }
            countOf1
        }
            private set
        // 兼并(按秩兼并)
        fun union(key1: Int, key2: Int) {
            val root1 = find(key1)
            val root2 = find(key2)
            if (root1 == root2) {
                // 未产生兼并,则不需求减一
                return
            }
            if (rank[root1] > rank[root2]) {
                parent[root2] = root1
            } else if (rank[root2] > rank[root1]) {
                parent[root1] = root2
            } else {
                parent[root1] = root2
                rank[root2]++
            }
            // 兼并后,连通重量减一
            count--
        }
        // 查询(运用途径紧缩)
        fun find(x: Int): Int {
            var key = x
            while (key != parent[key]) {
                parent[key] = parent[parent[key]]
                key = parent[key]
            }
            return key
        }
    }
}

6. 总结

到这儿,并查集的内容就讲完了。文章开头也提到了,并查集并不算面试中的高频标题,可是它的规划思维确实十分妙。不知道你有没有这种阅历,在看到一种十分美妙的解题 / 规划思维后,会不自觉地拍案叫绝,直呼熟行,并查集便是这种。

更多同类型标题:

并查集 题解
990. 等式方程的可满意性 【题解】
200. 岛屿数量 【题解】
547. 省份数量 【题解】
684. 冗余衔接 【题解】
685. 冗余衔接 II
1319. 连通网络的操作次数 【题解】
399. 除法求值
952. 按公因数核算最大组件大小
130. 被围绕的区域
128. 最长接连序列
721. 账户兼并
765. 情侣牵手

参考资料

  • 数据结构与算法剖析 Java 言语描述(第 8 章 不相互交集类)—— [美] Mark Allen Weiss 著
  • 算法导论(第 21 章 用于不相交调集的数据结构)—— [美] Thomas H. Cormen 等 著
  • 专题 并查集 —— LeetCode 出品
  • 标题 等式方程的可满意性 —— LeetCode 出品

本文为稀土技能社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!

如何使用并查集解决朋友圈问题?