并查集,解决连通问题的利器

1,723 阅读4分钟

「这是我参与2022首次更文挑战的第28天,活动详情查看:2022首次更文挑战

什么是并查集

并查集是一种数据结构,并查集这个词应该拆成三个字来解释
“集”:即 “集合”,说明这种数据结构是用来操作集合的
“并”:即 “合并”,说明这种数据结构中含有将两个不相交的集合合并在一起的方法
“查”:即 “查找”,说明在这种数据结构中,能够查找到当中的某个元素是属于哪个集合的

当然根据并查集的概念或许还无法直观的知道这个数据结构能干什么,那么接下来聊聊并查集都能够解决什么问题

并查集解决什么问题

并查集擅长解决的问题就是题目所说的连通性的问题(先别打,等我举个例子说明一下)

在一开始的时候,每个人都是孤单一人,没有朋友

1644771446(1).png

后面有人主动出手,和另一个人成为了朋友,而另一个人又和其他人成为了朋友,那么这三个人就形成了一个朋友圈,如果有其他人想要加入这个朋友圈的话,只需要和朋友圈中的其中一个人成为朋友就好了,那么后面也会和这个朋友圈的其他人成为朋友

演示文稿 4.gif

那么最后如何将两个朋友圈合并成一个大朋友圈呢?只需要两个朋友圈中各出一个人成为朋友就可以了

这样,我们可以很容易找到某一个人是属于哪一个朋友圈的,或者是总共有几个朋友圈

以上,我们使用了 “朋友圈” 来解释 “并查集” 的概念,同理还可以引申为 “亲戚问题”、“省份问题”,但这些都是被具体化的问题。其实并查集是一个抽象的概念,它能够解决的问题取决于你的想象力,只要可以用集合的连通去思考的问题,大部分都能够使用并查集来实现或者解决

实现并查集的四种方式

一、Quick-Find

Quick-Find 方法可以理解为不同的 “朋友圈” 都有自己的信物,当一个新朋友加入朋友圈的时候,那么会给这个新朋友发放朋友圈的信物,如果是两个朋友圈的合并,那么需要给其中一个朋友圈中的所有人发放另一个朋友圈的信物。最后每一个朋友圈都会有一个发起人,他手上的信物就是他一开始的信物,因此我们只需要检查有多少个人手上的信物和初始一样,就可以判断有多少个朋友圈(集合)了

演示文稿 4(1).gif

代码实现

class UnionSet {
    constructor(n) {
        this.colors = []
        // 初始化数组,每个点都是单独的
        for (let i = 0; i < n; i++) {
            this.colors[i] = i
        }
    }
    
    // 查找元素属于哪个集合(返回该集合的颜色)
    find (v) {
        return this.colors[v]
    }
    
    // 合并两个集合
    merge (a, b) {
        let ca = this.find(a), cb = this.find(b)
        // 如果两个点颜色相同,说明已经在同一个集合中了,不需要操作
        if (ca === cb) return
        // 将一个集合中的所有节点颜色都替换为另一个集合中的颜色
        for (let i = 0; i < this.colors.length; i++) {
            if (this.colors[i] === cb) this.colors[i] = ca
        }
    }
}

二、Quick-Union

Quick-Union 方法使用的是一种树状结构,它类似于公司的关系,想要找到一个人属于哪个公司,需要一层层向上查找,直到找到董事长(公司的话事人),合并关系类似于公司之间的收购关系,公司被收购后,被收购的公司董事长只能听命于收购公司的董事长(即将一个数的根节点挂载到另一棵树的根节点下)

演示文稿 4(2).gif

代码实现

class UnionSet {
    constructor (n) {
        // fa 记录每个节点的父节点
        this.fa = []
        // 开始时父节点是自身
        for (let i = 0; i < n; i++) {
            this.fa[i] = i
        }
    }
    
    // 查找元素属于哪个集合(返回集合的根节点)
    find (v) {
        // 如果找到父节点等于自身的节点,说明找到了根节点
        if (this.fa[v] === v) return v
        // 否则继续寻找父节点的父节点
        return this.find(this.fa[v])
    }
    
    merge (a, b) {
        // 分别找到两个节点的根节点
        const sa = this.find(a), sb = this.find(b)
        // 如果两个集合的根节点相同,说明在同一个集合中,不需要合并
        if (sa === sb) return
        // 将 sa 节点挂到 sb 节点下
        this.fa[sa] = sb
    }
}

接下来的两种方法是对于 Quick-Union 的优化

三、按秩优化

评判并查集的效率有一个标准——平均查找时间(平均查找次数 = 节点的查找次数总和 / 节点数量)

在 Quick-Union 方法中,我们合并两个集合是随机将一个集合的根节点挂载到另一个集合的根节点下面,这样在最差的情况下,挂载之后会呈现链表的结构,而节点要找到根节点的次数总和在链表中是最多的,如下图所示

演示文稿 4(3).gif

如何解决这个问题呢?如果将较矮的树挂载到较高的树下,或者是将节点较少的挂载到节点较多的树下,就可以解决该问题了。那么应该用哪种挂载方式的效率会高一点呢?接下来一起推导一下

假设有两个集合 AB,他们的节点查找次数总和分别是 lalb,他们的节点数量分别是 sasb

如果将 A 集合挂载到 B 集合下,A 集合中的每个节点的查找次数都要 +1,查找次数总和就 +sa,得到公式 平均查找次数 = (la + lb + sa) / (sa + sb)

如果将 B 集合挂载到 A 集合下,同理可得 平均查找次数 = (la + lb + sb) / (sa + sb)

由此可见,合并后的平均查找次数和树的 节点数量 有关,将节点数少的挂载到节点数大的下面,能够提高查找效率

代码实现

class UnionSet {
    constructor (n) {
        this.fa = []
        // 记录每个集合的节点数量
        this.size = []
        for (let i = 0; i < n; i++) {
            this.fa[i] = i
            // 初始时每个集合只有本身1个节点,所以初始 size 为 1
            this.size[i] = 1
        }
    }
    
    find (v) {
        if (this.fa[v] === v) return v
        return this.find(this.fa[v])
    }
    
    mergr (a, b) {
        const sa = this.find(a), sb = this.find(b)
        if (sa === sb) return
        
        // 如果 a 节点数少,a 挂载到 b,b 的 size 需要加上 a 集合的数量
        // 如果 b 节点数少,b 挂载到 a,a 的 size 需要加上 b 集合的数量
        // 如果相等,谁挂载谁都行
        if (this.size[sa] > this.size[sb]) {
            this.fa[sb] = sa
            this.size[sa] += this.size[sb]
        } else {
            this.fa[sa] = sb
            this.size[sb] += this.size[sa]
        }
    }
}

四、Path-Compress

在现在的管理模式之中都在提倡扁平化管理,恨不得所有人都只对一个人汇报,这样不需要经过层层汇报,效率是最高的。同理,如果将所有的节点都挂载到根节点下面,那么除了根节点外,每个节点的查找次数都是 1,这样的平均查找次数是最少的,查找效率是最高的

这种方法就叫做路径压缩,路径压缩发生在 find 查找阶段

代码实现

class UnionSet {
    constructor (n) {
        this.fa = []
        for (let i = 0; i < n; i++) {
            this.fa[i] = i
        }
    }
    
    find (v) {
        if (this.fa[v] === v) return v
        const root = this.find(this.fa[v])
        // 在查找的过程中,将集合中的每个点都直接挂载到根节点下,这样下一次查找效率更快
        this.fa[v] = root
        return root
    }
    
    merge (a, b) {
        const sa = this.find(a), sb = this.find(b)
        if (sa === sb) return
        this.fa[sa] === sb
    }
}

在实际应用中,会根据情况选择不同的优化方法,例如在业务代码中可能会选择 按秩优化 + 路径压缩 的方法,但是在竞赛过程中,为了提高解题速度,可能会直接使用 路径压缩