数据结构之并查集

43 阅读4分钟

我正在参加「掘金·启航计划」

概念

基础知识

并查集被认为是最简洁而优雅的数据结构之一,主要用于解决一些元素分组的问题。它管理一系列不相交的集合,并支持两种操作:

  • 合并(Union):把两个不相交的集合合并为一个集合。
  • 查询(Find):查询两个元素是否在同一个集合中。

当然,这样的定义未免太过学术化,看完后恐怕不太能理解它具体有什么用。所以我们先来看看并查集最直接的一个应用场景:亲戚问题

亲戚问题

现在有一组人之间的亲戚关系(远亲关系也是亲戚),例如:A->B, B-C, B->D, E->F ( X->Y 表示X和Y是亲戚关系),现在的问题是,B和F有亲戚关系吗?

总结

并查集是一种抽象度很高的数据结构,可以解决连通性问题。

注意:本文代码用Javascript来实现。

Quick-Find

集合染色,两个集合变成同一种颜色,以此来判断是否在一个集合中。

这里说的染色,是把其中一个集合的值赋给另一个集合中所有元素,至于具体谁把谁染色,并不重要,反正染色完成后,他们都是一个颜色,也就是同样的值,是什么值,我们并不关心。

class UnionSet {
    constructor(n) {
        this.color = new Array(n).fill(0).map((val, ind) => ind);
        this.setCount = n;
    }

    find(i) {
        return this.color[i];
    }

    merge(a, b) {
        if(this.color[a] === this.color[b]) return;

        let cb = this.color[b];

        for(let i=0; i<this.color.length; i++) {
            if(this.color[i] == cb) {
                this.color[i] = this.color[a];
            }
        }

        this.setCount--;
    }

    isConnected(a, b){
        return this.color[a] === this.color[b];
    }

    getCount(){
        return this.setCount;
    }
}

// 0,1,2,3,4
let arr = [[1,2],[2,3]];
// 1.判断1和3是否在一个集合中;2.集合个数
let u = new UnionSet(5);
u.merge(1,2);
u.merge(2,3);

let isCon = u.isConnected(1,3);
let count = u.getCount();

由于上面的并查集实现中,find的时间复杂度是O(1),很快,所以称为quick-find。

Quick-Union

Quick-Find的实现查找速度很快,但是每次merge合并要遍历所有被合并集合中所有的节点,如果我们把连通关系转换为树形结构,通过迭代或递归的方式向上查找出根节点,这样就可以优化merge的效率。

每个树根代表了这个集合。

这是一个状的结构,要寻找集合的代表元素,只需要一层一层往上访问父节点(图中箭头所指的圆),直达树的根节点(图中橙色的圆)即可。根节点的父节点是它自己。我们可以直接把它画成一棵树:

class UnionSet {
    constructor(n) {
        this.parent = new Array(n).fill(0).map((val, ind) => ind);
        this.setCount = n;
    }

    find(i) {
        // 根节点的值是它本身
        if(this.parent[i] === i) {
            return i;
        }

        return this.find(this.parent[i]);
    }

    merge(i, j) {
        let rootI = this.find(i), rootJ = this.find(j);

        // i和j已经在一个集合中,无需合并
        if(rootI === rootJ) return;

        // 合并,把i挂在了j下面
        this.parent[rootI] = rootJ;

        this.setCount--;
    }

    isConnected(i, j){
        return this.find(i) === this.find(j);
    }

    getCount(){
        return this.setCount;
    }
}

// 0, 1, 2, 3, 4
let arr = [[1,2],[2,3]];
// 1.判断1和3是否在一个集合中;2.集合个数
let u = new UnionSet(5);
u.merge(1,2);
u.merge(2,3);

let isCon = u.isConnected(1,3);
let count = u.getCount();

上面的Quick-Merge改善了merge的效率,但是merge的时候应该考虑一下合并完的集合,应该平均查找次数最少,因为我们可以把A集合merge到B集合,也可以把B集合merge到A集合。

Weighted Quick Union

merge的时候应该减少平均查找次数。如果把节点少的节点merge到节点多的集合中,这样新集合的平均查找次数更少。平均查找次数等于,总的查找次数/节点总数。

按秩合并

class UnionSet {
    constructor(n) {
        this.parent = new Array(n).fill(0).map((val, ind) => ind);
        this.counts = new Array(n).fill(1);
        this.setCount = n;
    }

    find(i) {
        // 根节点的值是它本身
        if(this.parent[i] === i) {
            return i;
        }

        return this.find(this.parent[i]);
    }

    merge(i, j) {
        let rootI = this.find(i), rootJ = this.find(j);

        // i和j已经在一个集合中,无需合并
        if(rootI === rootJ) return;

        // 合并,要看rootI和rootJ中节点的个数
        if(this.counts[rootI] < this.counts[rootJ]) {
            [rootI, rootJ] = [rootJ, rootI];
        }

        // 把节点数小的挂在节点数多的结合下
        this.parent[rootJ] = rootI;
        // 更新rootI中集合的个数
        this.counts[rootI] += this.counts[rootJ];

        this.setCount--;
    }

    isConnected(i, j){
        return this.find(i) === this.find(j);
    }

    getCount(){
        return this.setCount;
    }
}

// 0, 1, 2, 3, 4
let arr = [[1,2],[2,3]];
// 1.判断1和3是否在一个集合中;2.集合个数
let u = new UnionSet(5);
u.merge(1,2);
u.merge(2,3);

let isCon = u.isConnected(1,3);
let count = u.getCount();

Path-Compress Quick Union

带路径压缩的并查集,有些节点查找根的时候,如果很长的话,查找效率是很低的。

怎么优化呢?我们可以使用路径压缩的方法。既然我们只关心一个元素对应的根节点,那我们希望每个元素到根节点的路径尽可能短,最好只需要一步,像这样:

其实这说来也很好实现。只要我们在查询的过程中,把沿途的每个节点的父节点都设为根节点即可。下一次再查询时,我们就可以省很多事。这用递归的写法很容易实现:

find(x) {
  if(this.parent[x] !== x) {
    this.parent[x] = this.find(this.parent[x]);
  }

  return this.parent[x];
 }

Path-Compress & Weighted Quick Union

带路径压缩且按秩合并的并查集,就是同时优化了查找和合并。

并查集模板

class UnionSet {
    constructor(n) {
        this.parent = new Array(n).fill(0).map((val, ind) => ind);
        this.counts = new Array(n).fill(1);
        this.setCount = n;
    }

    find(i) {
        // 根节点的值是它本身
        if(this.parent[i] !== i) {
            this.parent[i] = this.find(this.parent[i]);
        }

        return this.parent[i];
    }

    merge(i, j) {
        let rootI = this.find(i), rootJ = this.find(j);

        // i和j已经在一个集合中,无需合并
        if(rootI === rootJ) return;

        // 合并,要看rootI和rootJ中节点的个数
        if(this.counts[rootI] < this.counts[rootJ]) {
            [rootI, rootJ] = [rootJ, rootI];
        }

        // 把节点数小的挂在节点数多的结合下
        this.parent[rootJ] = rootI;
        this.counts[rootI] += this.counts[rootJ];

        this.setCount--;
    }

    isConnected(i, j){
        return this.find(i) === this.find(j);
    }

    getCount(){
        return this.setCount;
    }
}

// 0, 1, 2, 3, 4
let arr = [[1,2],[2,3]];
// 1.判断1和3是否在一个集合中;2.集合个数
let u = new UnionSet(5);
u.merge(1,2);
u.merge(2,3);

let isCon = u.isConnected(1,3);
let count = u.getCount();

应用

有 n 个城市,其中一些彼此相连,另一些没有相连。如果城市 a 与城市 b 直接相连,且城市 b 与城市 c 直接相连,那么城市 a 与城市 c 间接相连。省份 是一组直接或间接相连的城市,组内不含其他没有相连的城市。

给你一个 n x n 的矩阵 isConnected ,其中 isConnected[i][j] = 1 表示第 i 个城市和第 j 个城市直接相连,而 isConnected[i][j] = 0 表示二者不直接相连

返回矩阵中 省份 的数量。

示例 1:

输入:isConnected = [[1,1,0],[1,1,0],[0,0,1]]

输出:2

var findCircleNum = function(isConnected) {
   let cnt = isConnected.length;
   let u = new UnionSet(cnt);

   for(let i=0;i<cnt;i++){
      for(let j=0;j<cnt;j++){
          if(isConnected[i][j] == 1) {
              u.merge(i, j);
          }
      }
   }

   return u.getCount();
};