老白学算法 - 并查集(Union-find disjoint sets)

843 阅读3分钟

并查集(Union-find)

并查集是一种树型的数据结构,用于处理一些不相交集合(disjoint sets)的合并及查询问题。常常在使用中以森林来表示。

并查集基础知识

并查集解决的问题

连通性问题

  1. 基于染色的思想,一开始所有的点颜色不同
  2. 链接两个点的操作,可以看成将一种颜色染成另一种颜色
  3. 如果两个点颜色一样,证明联通,否则不连通
  4. 这种方法叫做并查集的:【Quick-Find算法】

// 并查集 -> Quick-Find算法
export class QuickFind {
  color: number[];
  n: number;
  constructor(n: number) {
    this.color = [];
    this.n = n;
    for (let i = 0; i < n; i++) {
      this.color[i] = i;
    }
  }
  find(ind: number): number {
    return this.color[ind];
  }
  merge(a: number, b: number): void {
    if (this.color[a] === this.color[b]) return;
    const cb = this.color[b];
    for (let i = 0; i < this.n; i++) {
      if (this.color[i] === cb) this.color[i] = this.color[a];
    }
    return;
  }
}


Quick-Find算法总结

联通判断:O(1)

合并操作:O(n)

问题思考:

  • Quick-Find算法的联通判断非常快,可是合并操作非常慢
  • 本质上问题中只是需要知道一个点与哪些点的颜色相同
  • 而若干点的颜色可以通过间接指向同一个节点
  • 合并操作时,实际上是将一棵树作为另一棵树的子树 ---> Quick-Union算法
// 并查集 -> Quick-Union算法
export class QuickUnion {
  boss: number[];
  n: number;
  constructor(n: number) {
    this.boss = [];
    this.n = n;
    for (let i = 0; i < n; i++) {
      this.boss[i] = i;
    }
  }
  find(ind: number): number {
    return this.boss[ind] === ind ? ind : this.find(this.boss[ind]);
  }
  merge(a: number, b: number): void {
    const fa = this.find(a);
    const fb = this.find(b);
    if (fa === fb) return;
    this.boss[fa] = fb; // 比 QuickFind 快很多 O(1)
    return;
  }
}

Quick-Union 算法总结

联通判断(find): tree-height 树高

合并操作(merge): tree-height 树高

问题思考

  1. 极端情况下会退化成一条链
  2. 将节点数量多的接到少的树上面,导致了退化
  3. 将树高深的接到了浅的上面,导致了退化
  4. 若要改进,是按节点数量还是按照树的高度为合并参考? 指标:平均查找次数 = 总查找数/节点数 找到最小的平均查找次数为最优 设 a树的所有节点数和为S(a),所有节点树高之和(总查找数)为l(a) b树的所有节点数和为S(b),所有节点树高之和(总查找数)为l(b)a树作为父节点,则 合并后的平均查找次数 = l(a) + l(b) + S(b)/ S(a) +S(b) // 分子 + S(b)是因为``b树所有结点的深度都比原来的深度+1 若 b`树作为父节点,则 合并后的平均查找次数 = l(a) + l(b) + S(a)/ S(a) +S(b)
  5. 加入4步骤的优化算法叫做 Weighted-Quick-Union算法
export class WeightedQuickUnion {
  fa: number[];
  size: number[]; // 子树数量
  n: number;
  constructor(n: number) {
    this.fa = [];
    this.size = [];
    this.n = n;
    for (let i = 0; i < n; i++) {
      this.fa[i] = i;
      this.size[i] = 1;
    }
  }
  find(x: number): number {
    return this.fa[x] === x ? x : this.find(this.fa[x]);
  }
  merge(a: number, b: number): void {
    const ra = this.find(a);
    const rb = this.find(b);
    if (this.size[ra] < this.size[rb]) { // 按质优化
      this.fa[ra] = rb;
      this.size[rb] += this.size[ra];
    } else {
      this.fa[rb] = ra;
      this.size[ra] += this.size[rb];
    }
    return;
  }
}

还有可以优化的地方吗?我们发现我们每次find的时候都要遍历到root的节点上,经历了很多层,那我们能不能把查找的过程优化一下呢? 当然是可以的,我们可以让每一个子节点都直接指向root即可实现路径的优化。

代码如下

export class WeightedQuickUnionPC {
  // pc: path compress 路径压缩
  fa: number[];
  size: number[]; // 子树数量
  n: number;
  constructor(n: number) {
    this.fa = [];
    this.size = [];
    this.n = n;
    for (let i = 0; i < n; i++) {
      this.fa[i] = i;
      this.size[i] = 1;
    }
  }
  find(x: number): number {
    // return this.fa[x] === x ? x : this.find(this.fa[x]);

    // if (this.fa[x] === x) return x;
    // const root = this.find(this.fa[x]);
    // this.fa[x] = root; // 路径压缩优化
    // return root;
    return this.fa[x] === x ? x : (this.fa[x] = this.find(this.fa[x]));
  }
  merge(a: number, b: number): void {
    const ra = this.find(a);
    const rb = this.find(b);
    if (this.size[ra] < this.size[rb]) {
      this.fa[ra] = rb;
      this.size[rb] += this.size[ra];
    } else {
      this.fa[rb] = ra;
      this.size[ra] += this.size[rb];
    }
    return;
  }
}

至此,我们完成并查集的最优实现了。

使用手感

在运行速度的测试中我们会发现路径压缩优化的好处要远远大于按节点优化。而且路径压缩代码实现非常简单,好记。所以在日常算法做题时我们一般对并查集的一般实现为

export class QuickUnionPC {
  // pc: path compress 路径压缩
  fa: number[];
  n: number;
  constructor(n: number) {
    this.fa = [];
    this.n = n;
    for (let i = 0; i < n; i++) {
      this.fa[i] = i;
    }
  }
  find(x: number): number {
    // return this.fa[x] === x ? x : this.find(this.fa[x]);

    // if (this.fa[x] === x) return x;
    // const root = this.find(this.fa[x]);
    // this.fa[x] = root; // 路径压缩优化
    // return root;
    return this.fa[x] === x ? x : (this.fa[x] = this.find(this.fa[x]));
  }
  merge(a: number, b: number): void {
    const fa = this.find(a);
    const fb = this.find(b);
    if (fa === fb) return;
    this.fa[fa] = fb; // 比 QuickFind 快很多
    return;
  }
}

至此,我们的并查集学习就告一段落,我们一起用新学的知识点刷题吧。