06.1-并查集(数据结构基础篇)

29 阅读5分钟

连通性问题

连通性问题可以看成是将具有相同或相似特性的个体连接成一个集合。当我们遇到一些具有相似特性的或具有传递特性的问题时,使用并查集或许能够高效快速的解决问题。

底层实现

大多数时候,我们的并查集底层是一个树形结构,不过不是二叉树而是多叉树。不过由于我们不需要关心并查集兄弟元素之间的关系,通常只需要关心其父节点是谁,因此,我们在实际实现并查集时,可以不需要使用特定的树形结构来表示。我们通常使用一个数组就可以表示并查集,其中数组元素的索引代表当前节点,而值则代表父节点的索引。

维护连通性的方法

染色法(Quick-Find)

查找快,合并慢。当两个集合合并时,将合并后的集合全部“染上同一种颜色”,也就是打上相同的标记(Quick Find算法)

查找时间复杂度:O(1)

合并时间复杂度:O(n)

代码实现

class UnionSet {
  // 用于存储集合中每一个点的“颜色”
  private colors: number[];
  constructor(private n:number){
    // 当编号从1...n时,数组长度为n+1,如果编号为0...n-1时,数组长度为n即可
    this.colors = new Array<number>(n+1);
    // 初始时,将每一个点染上个字不同的“颜色”
    for(let i=0;i<=n;i++) {
      this.colors[i] = i;
    }
  }
  // 要查找某个元素,只需要从集合中直接查询即可,查找速度极快,因此叫做Quick-Find
  find(x: number): number {
    return this.colors[x];
  }
  // 合并时,需要让b点所在集合的所有点都“染上a点的颜色”,因此需要遍历所有点并找出与b点颜色相同的点,即b集合的点。
  merge(a: number, b: number): void {
    // 如果a、b两点本来就颜色一样,也就是在同一个集合之内,那么无需合并
    if(this.colors[a] === this.colors[b]) return;
    const bColor = this.colors[b];
    for(let i=0;i<=this.n;i++) {
      if(this.colors[i] === bColor) this.colors[i] = this.colors[a]; 
    }
  }
}
Quick-Union算法

查找慢,合并快。使用树形结构维护集合,合并集合时,只需将a集合挂在b集合根节点下面作为b集合的子节点即可

由于采用了树形结构,那么无论是查找还是合并操作,其时间复杂度都跟树的树高密切相关,在极端的情况下,我们的集合上的点会拼接成一个链表,即节点数量就等于树高,因此,使用Quick-Union算法时,有效的减小树高能够提升算法的效率。

代码实现

未经优化的Quick-Union算法实现

class UnionSet {
  // 用于存储每一个节点父级节点的索引
  private boss: numbers[];
  constructor(private n: number) {
    this.boss = new Array<number>(n+1);
    // 初始时所有节点的父级都是他自己,自己当自己的老板
    for(let i =0;i<=n;i++) {
      this.boss[i] = i;
    }
  }
  // 如果父节点的索引是他本身,那么无需寻找,直接返回x,否则递归寻找期父级节点
  find(x: number): number {
    if(x === this.boss[x]) return x;
    return this.find(this.boss[x])
  }
  // 如果a、b的父级是统一个父级,那无需合并,因为他们本来就在同一个集合里,否则,把a挂在b下面,即让a树作为b的子树
  merge(a: number, b: number): void {
    const fa = this.boss[a];
    const fb = this.boas[b];
    if(fa === fb) return;
    this.boss[fa] = fb;
  }
}

为了防止从树形结构退化成链表结构,我们在合并集合时,应该遵循谁的节点少谁作为子树,即“节点数量少就做儿子”。证明过程如下:

假设现在要合并a,b两个集合,a的节点总数为Sa,树高为Ha,b的节点总数为Sb,树高为Hb,那我们我们可以分别求出两个集合的平均查找次数。

当把a集合的根节点作为b集合的父节点时,a集合的平均查找次数(求平均查找次数我们可以用两个结合对应的树高Ha与Hb相加,由于把b挂在了a下面,所以b的所有节点的树高都增加了1,总共增加了Sb,因此,需要再加上Sb之和,除于两棵树节点数量之和):

(Ha+Hb+Sb)/(Sa+Sb)

当把b集合的根节点作为a集合的父节点时,a集合的平均查找次数(求平均查找次数我们可以用两个结合对应的树高Ha与Hb相加,由于把a挂在了b下面,所以a的所有节点的树高都增加了1,总共增加了Sa,因此,需要再加上Sa之和,除于两棵树节点数量之和):

(Ha+Hb+Sa)/(Sa+Sb)

将a和b两个集合的平均查找次数的公式化简可见,我们平均查找次数根本上是跟我们的节点数量直接相关,节点数量越少,作为合并时的父节点的话,我们的平均查找平局查找次数就越高,因此,我们只需要遵循节点数量少的集合作为子树即可有效的提升算法效率

经过优化后的Quick-Union算法实现,我们也叫这个是权重并查集Weight-Quick-Union

// weightedUnionSet
class UnionSet {
  private boss: number[];
  private size: number[];
  constructor(private n: number) {
    this.boss = new Array<number>(n+1);
    this.size = new Array<number>(n+1);
    for(let i=0;i<=n;i++) {
      this.boss[i] = i;
      this.size[i] = 1;
    }
  }
  
  find(x: number): number {
    if(this.boss[x] === x) return x;
    return this.find(this.boss[x]);
  }
  
  merge(a: number, b: number): number {
    const ra = this.find(a);
    const rb = this.find(b);
    if(ra === rb) return;
    // 谁小谁当儿子
    if(this.size[ra] < this.size[rb]) {
      this.boss[ra] = rb;
      // 因为将a挂在了b下面,因此,b树的大小增加了ra个节点
      this.size[rb] += ra;
    } else {
      this.boss[rb] = ra;
      this.size[ra] = rb;
    }
  }
}
路径压缩

我们知道,当我们查找并查集时,层级越少,我们查找的效率就越高,那么,我们就可以在昨晚一次查找操作之后,直接把查找的那个节点直接挂在根节点上面,这样,下一次再查找这个节点的时候,效率就能明显提升了

代码实现

// weightedPathCompressUnionSet
class UnionSet {
  private boss: number[];
  private size: number[];
  constructor(private n: number) {
    this.boss = new Array<number>(n+1);
    this.size = new Array<number>(n+1);
    for(let i=0;i<=n;i++) {
      this.boss[i] = i;
      this.size[i] = 1;
    }
  }
  
  find(x: number): number {
    if(this.boss[x] === x) return x;
    const rootIdx = this.find(this.boss[x]);
    // 将要查找的节点挂在根节点下面,下次查找时效率便可显著提升,不再需要一层一层往上查找
    this.boss[x] = rootIdx;
    return rootIdx;
  }
  
  merge(a: number, b: number): number {
    const ra = this.find(a);
    const rb = this.find(b);
    if(ra === rb) return;
    // 谁小谁当儿子
    if(this.size[ra] < this.size[rb]) {
      this.boss[ra] = rb;
      // 因为将a挂在了b下面,因此,b树的大小增加了ra个节点
      this.size[rb] += ra;
    } else {
      this.boss[rb] = ra;
      this.size[ra] = rb;
    }
  }
}
几种并查集算法的对比

几种算法的对比

并查集的模版代码

上面说了很多种并查集的实现方式与优化算法,其实我们再实际使用时,最常用的优化就是路径压缩的方式来优化并查集。下面给出一个比较通用的并查集实现代码。这个代码将会在后续刷题巩固中反复使用。

// UnionSet
class UnionSet {
  	// 用于存储当前节点的父节点序号
  	// 虽然并查集逻辑上是树形结构,但我们实际实现时无需定义一个树形结构,因为我们想要知道的仅仅是当前节点的父级到底是谁就足够了,因此,我们使用数组来进行存储,数组的索引为当前节点的索引,值为当前节点父节点的索引
    private boss: number[];
    constructor(private n: number) {
      	// 为了防止部分场景序号是从1开始的,因此初始化数组长度为n+1
        this.boss = new Array<number>(n+1);
      	// 初始设置所有节点的父节点都为它本身
        for(let i=0;i<=n;i++) {
            this.boss[i] = i;
        }
    }
    // 使用路径压缩算法实现并查集的查找操作
  	// 其实原理就是每查找到一个节点,我们就把节点的父级改成根节点,这样我们下次查找相同节点时,效率便可显著提升
  	// 判断是否为根节点的依据:如果当前节点就等于他的根节点,即:this.boss[x]===x,就说明这个节点就是根节点
    get(x: number): number {
        return (this.boss[x] = (this.boss[x] === x ? x : this.get(this.boss[x])));
    }
		// 由于权重并查集的算法相较于路径压缩的算法带来的提升实际并不大,因为我们进行路径压缩时实际就已经极大的减少了树的层级了。因此,此处没有使用权重并查集的算法实现,直接把a树挂在b树上
    merge(a: number, b: number): void {
        this.boss[this.get(a)] = this.get(b);
    }
		// [debug] 用于调试
    output(): void {
        console.log(this.boss);
    }
}

并查集解决的经典问题

上面说了并查集是用来解决连通性问题的神兵利器,不过这说得有点抽象了,那么,我们在实际的开发过程中,有哪些情况可能会使用并查集来解决呢?

朋友圈

所谓的一个朋友圈子,不一定其中的人都互相认识。例如:

小王的朋友是小李,小李的朋友是小王那么他们三个人其实就是一个朋友圈