javascript:并查集(Union-find)及经典问题

1,550 阅读2分钟

一.并查集的基本知识

并查集可以解决连通性问题,是一类抽象化程度很高的数据结构。
连通性具有传递性。

二.并查集的实现

Quick-Find算法

思路:将同一组的节点染成相同的颜色

  1. 基于染色的思想,一开始所有点的颜色不同
  2. 连接两个点的操作,可以看成将一种颜色的点染成另一种颜色
  3. 如果两个点的颜色一样,证明联通,否则不联通
  4. 联通判断:O(1),合并操作:O(n) 问题思考:
  5. quick-Find算法的联通判断非常快,可是合并操作非常慢
  6. 本质上问题中只是需要知道一个点与哪些点的颜色相同
  7. 而若干点的颜色可以通过间接指向同一个节点
  8. 合并操作时,实际上是将一棵树作为另一颗树的子树
class UnionSet {
  // 初始化拥有n个节点的并查集
  constructor(n) {
    // 数组空间代表每个点的颜色
    this.color = new Array(n + 1);
    // 可以理解为,给第i个点附上第i种颜色
    for (let i = 0; i <= n; i++) {
      this.color[i] = i;
    }
  }
  // 查找,返回x点的集合颜色 O(1)
  find(x) {
    return this.color[x];
  }
  // 合并 O(n)
  merge(a, b) {
    // 如果a和b本身就在一个集合中,那么无需合并
    if (this.color[a] === this.color[b]) return;
    // 把其中一个集合中的元素颜色染成另外一个集合颜色,这里把b所在集合染成a
    let cb = this.color[b];
    for (let i = 0; i <= n; i++) {
      if (this.color[i] === cb) {
        this.color[i] = this.color[a];
      }
    }
    return;
  }
}

Quick-Union算法

思路:将连通关系转换为树形结构,通过递归的方式快速判定。

  1. 采用树形结构,记录每个点的根节点编号,处在同一颗树中的点,就属于同一个集合。
  2. 联通判断:tree-height树高,合并操作:tree-height树高 问题思考:
  3. 极端情况下会退化成一条链表
  4. 将节点数量多的接到少的树上面,导致了退化
  5. 将树高深的接到浅的上面,导致了退化
class UnionSet {
  // 初始化拥有n个节点的并查集
  constructor(n) {
    // 数组空间代表每个节点根节点的编号
    this.father = new Array(n + 1);
    // 刚开始每个节点根节点就是自己
    for (let i = 0; i <= n; i++) {
      this.father[i] = i;
    }
  }
  // 查找,返回x点,根节点编号
  find(x) {
    // x点,根节点还是x,就是根节点
    if (this.father[x] === x) return x;
    // 否则继续向上找
    return this.find(this.father[x]);
  }
  // 合并
  merge(a, b) {
    // 首先分别找到a,b两点的根节点
    let fa = this.find(a),
      fb = this.find(b);
    // 如果a和b的根节点是同一个,说明在一个集合中,那么无需合并
    if (fa === fb) return;
    // 把a集合挂在b集合下,或者b集合挂在a集合下,都可行,之后谈论优化
    this.father[fa] = fb;
    return;
  }
}

Weighted-Quick-Union算法

思路:通过考虑平均查找次数的方式,对合并过程进行优化。
平均查找次数 = 节点的总查找次数 / 节点总数

  1. 联通判断:log(N),合并操作:log(N) 假设要把a树和b树进行合并,a树节点数量Sa,所有节点深度相加La,b树节点数量Sb,所有节点深度相加Lb,当a作为根节点合成的新树,b树中所有节点的深度增加了一层,平均查找次数为(La + Lb + Sb) / (Sa + Sb),同理当b作为根节点合成的新树,平均查找次数为(La + Lb + Sa) / (Sa + Sb),由此我们可以得出,如果希望该指标尽可能小,两棵树在合并时,节点少的作为子树,这种优化方式叫按质优化
class UnionSet {
  // 初始化拥有n个节点的并查集
  constructor(n) {
    // 数组空间代表每个节点根节点的编号
    this.father = new Array(n + 1);
    // 如果当前节点是根节点,当前根节点所在子树的节点总数
    this.size = new Array(n + 1);
    // 刚开始每个节点根节点就是自己,所在子树节点数量是1
    for (let i = 0; i <= n; i++) {
      this.father[i] = i;
      this.size[i] = 1;
    }
  }
  // 查找,返回x点,根节点编号
  find(x) {
    // x点,根节点还是x,就是根节点
    if (this.father[x] === x) return x;
    // 否则继续向上找
    return this.find(this.father[x]);
  }
  // 合并
  merge(a, b) {
    // 首先分别找到a,b两点的根节点
    let fa = this.find(a),
      fb = this.find(b);
    // 如果a和b的根节点是同一个,说明在一个集合中,那么无需合并
    if (fa === fb) return;
    // 节点多的作为根节点
    if(this.size[fa] < this.size[fb]){
      this.father[fa] = fb;
      // fb作为根节点后,fb所在子树的节点数量,需要加上fa的节点数量
      this.size[fb] += this.size[fa];
    }else{
      // 同理
      this.father[fb] = fa;
      this.size[fa] += this.size[fb];
    }
    return;
  }
}

带路径压缩的Weighted-Quick-Union算法

思路:每次查询连通性关系时,对节点到根节点的路径上所有点进行优化,避免连通性关系从树型结构退化成链表型结构。

  1. 联通判断:O(1),合并操作:O(1) 在并查集中树形结构不重要,find操作时,只是想找到根节点,我们可以查找到根节点后,直接把当前节点挂到根节点下,这种优化叫路径压缩
class UnionSet {
  // 初始化拥有n个节点的并查集
  constructor(n) {
    // 数组空间代表每个节点根节点的编号
    this.father = new Array(n + 1);
    // 如果当前节点是根节点,当前根节点所在子树的节点总数
    this.size = new Array(n + 1);
    // 刚开始每个节点根节点就是自己,所在子树节点数量是1
    for (let i = 0; i <= n; i++) {
      this.father[i] = i;
      this.size[i] = 1;
    }
  }
  // 查找,返回x点,根节点编号
  find(x) {
    // x点,根节点还是x,就是根节点
    if (this.father[x] === x) return x;
    // 否则继续向上找,返回根节点编号
    let root = this.find(this.father[x]);
    // 直接把x节点挂在root下,下次再查询时,效率就非常高
    this.father[x] = root;
    return root;
  }
  // 合并
  merge(a, b) {
    // 首先分别找到a,b两点的根节点
    let fa = this.find(a),
      fb = this.find(b);
    // 如果a和b的根节点是同一个,说明在一个集合中,那么无需合并
    if (fa === fb) return;
    // 节点多的作为根节点
    if(this.size[fa] < this.size[fb]){
      this.father[fa] = fb;
      // fb作为根节点后,fb所在子树的节点数量,需要加上fa的节点数量
      this.size[fb] += this.size[fa];
    }else{
      // 同理
      this.father[fb] = fa;
      this.size[fa] += this.size[fb];
    }
    return;
  }
}

带路径压缩的并查集模板

在实际编码中,路径压缩的编码复杂度小,给代码的提升明显,而按质优化编码复杂度高,所以平常使用只使用路径压缩,不加按质优化。

class UnionSet {
  constructor(n) {
    this.father = new Array(n + 1);
    for (let i = 0; i <= n; i++) {
      this.father[i] = i;
    }
  }
  get(x) {
    return this.father[x] = (this.father[x] === x ? x : this.get(this.father[x]));
  }
  merge(a, b) {
    this.father[this.get(a)] = this.get(b);
  }
}

三.经典面试题-并查集基础题目

547. 省份数量

  1. 遍历矩阵isConnected,如果两个城市之间有相连关系,就对他们进行合并。
  2. 省份数量就等于联通关系中根节点数量
var findCircleNum = function(isConnected) {
    let n = isConnected.length;
    // 节点数量就是城市数量
    let u = new UnionSet(n);
    for(let i = 0; i < n; i++){
        // 无需遍历全部,i与j相连等同于j与i相连
        for(let j = 0; j < i;j++){
            if(isConnected[i][j]) u.merge(i, j);
        }
    }
    let ans = 0;
    // 查看并查集中有多少个集合,即有多少个根节点
    for(let i = 0; i < n; i++){
        if(u.get(i) === i) ans += 1;
    }
    return ans;
};
// 后续代码中不包含并查集的构造方法,可以回来查阅
class UnionSet {
  constructor(n) {
    this.father = new Array(n + 1);
    for (let i = 0; i <= n; i++) {
      this.father[i] = i;
    }
  }
  get(x) {
    return this.father[x] = (this.father[x] === x ? x : this.get(this.father[x]));
  }
  merge(a, b) {
    this.father[this.get(a)] = this.get(b);
  }
}

200. 岛屿数量

当前的'1'(陆地)和周围的'1'是连通的,可以给每个节点编号,进行相互连通。连通时无须连通4个方向,只需要连通上和左即可,连通时需要把坐标转化成编号。
编程技巧:把二维坐标转成一维的编号

var numIslands = function(grid) {
    // n:行数,m:列数
    let n = grid.length, m = grid[0].length;
    // 编号最大n * m
    let u = new UnionSet(n * m);
    // 坐标转化成编号,行号乘列数加上行号
    let ind = (i, j)=> i * m + j;
    for(let i = 0; i < n; i++){
       for(let j = 0; j < m; j++){
           if(grid[i][j] === '0') continue;
            // 当前点是1,上面点是1的话,连通上面,左面点是1的话,连通左面,连通时需要把坐标转化成编号
           if(i > 0 && grid[i - 1][j] === '1') u.merge(ind(i - 1, j), ind(i, j));
           if(j > 0 && grid[i][j - 1] === '1') u.merge(ind(i, j - 1), ind(i, j));
       }
    }
    let ans = 0;
    // 统计当前集合中有多少个联通块,即并查集中根节点数量
    for(let i = 0; i < n; i++){
       for(let j = 0; j < m; j++){
        //    当前是陆地且是根节点,根节点标志:当前节点根节点就是它自本身
           if(grid[i][j] === '1' && u.get(ind(i, j)) === ind(i, j)) ans += 1;
       }
    }
    return ans;
};

990. 等式方程的可满足性

相等关系就是连通性关系,把所有相等的元素放到一个集合中,然后再判断在所有不等关系中,如果前后两个元素出现在同一个集合,就冲突了。
简而言之,就是先用相等关系建立并查集,再判断所有不等关系,不等关系中两个元素在同一集合,说明出现冲突。

var equationsPossible = function(equations) {
    let u = new UnionSet(26);
    // 1、第一次循环扫描,遇到 “=” 将两边的字符合并。
    for(let s of equations){
        if(s[1] === '!') continue;
        let a = s[0].charCodeAt() - 'a'.charCodeAt();
        let b = s[3].charCodeAt() - 'a'.charCodeAt();
        u.merge(a,b);
    }
    // 2、第二次扫描,遇到 “!”,如果两边的字符拥有共同的祖先,返回false。
    for(let s of equations){
        if(s[1] === '=') continue;
        let a = s[0].charCodeAt() - 'a'.charCodeAt();
        let b = s[3].charCodeAt() - 'a'.charCodeAt();
        if(u.get(a) === u.get(b)) return false;
    }
    return true;
};

四.经典面试题-并查集进阶题目

684. 冗余连接

树形结构,所有点都是连通的,多的那一条边也不影响连通性,也就意味着多的那条边,两端的端点原本就是连通的。我们可以把所有边依次插入并查集中,但是在插入之前,判断插入的边,两端的端点原本是否连通,如果原本就是连通的,那么当前这条边就是多余的。

var findRedundantConnection = function(edges) {
    // 点的数量等于边的数量加一,因为有一条多余的边,所以点的数量就是数组的长度
    let u = new UnionSet(edges.length);
    for(let e of edges){
        let a = e[0];
        let b = e[1];
        if(u.get(a) === u.get(b)) return e;
        u.merge(a, b);
    }
    return [];
};

1319. 连通网络的操作次数

1.当计算机的数量为n 时,我们至少需要n-1根线才能把他们进行连接。如果线的数量少于n-1,那么我 们无论如何都无法将这n台计算机进行连接。
2.判断集合数量,连接n个集合,需要n-1次操作

var makeConnected = function(n, connections) {
    // 判断电缆数量够不够
    if(connections.length < n - 1) return -1;
    let u = new UnionSet(n);
    for(let c of connections){
        let a = c[0];
        let b = c[1];
        u.merge(a, b);
    }
    // 判断有多少个集合,集合数量等于根节点数量
    let cnt = 0;
    for(let i = 0; i < n; i++){
        if(u.get(i) === i) cnt += 1;
    }
    return cnt - 1;
};

128. 最长连续序列

记录每个位置之前出现过的数字以及下标,遍历每个数字,把遍历到当前数字与和它前后相差1的数字进行连通,最后找元素数量最多的集合。
数字的相邻关系是一种连通关系

var longestConsecutive = function(nums) {
    // 哈希表记录当前数字以及所在下标
    let ind = new Map();
    let u = new UnionSet(nums.length);
    for(let i = 0; i < nums.length; i++){
        let x = nums[i];
        if(ind.has(x)) continue;
        // 当前数字是x,,把x和x + 1、x - 1连接到一起
        // 连接的是值所对应的下标,因为原数字可能很大
        if(ind.has(x - 1)){
            u.merge(i, ind.get(x - 1));
        }
        if(ind.has(x + 1)){
            u.merge(i, ind.get(x + 1));
        }
        ind.set(x, i);
    }
    let ans = 0;
    // 找到元素数量最多的集合
    for(let i = 0; i < nums.length; i++){
        if(u.get(i) === i && u.cnt[i] > ans) ans = u.cnt[i];
    }
    return ans;
};
// 为了获取元素数量最多的集合,需要一个记录元素数量的数组
class UnionSet {
  constructor(n) {
    this.father = new Array(n + 1);
    this.cnt = new Array(n + 1);
    for (let i = 0; i <= n; i++) {
      this.father[i] = i;
      this.cnt[i] = 1;
    }
  }
  get(x) {
    return this.father[x] = (this.father[x] === x ? x : this.get(this.father[x]));
  }
  merge(a, b) {
    if(this.get(a) === this.get(b)) return;
    // 需要先累加节点数量,再改变根节点,不然get(a)和get(b)会变成同一节点
    this.cnt[this.get(b)] += this.cnt[this.get(a)];
    this.father[this.get(a)] = this.get(b);
    return;
  }
}

947. 移除最多的同行或同列石头

将处于同一横坐标或处于同一纵坐标的的石头看作是相互连通的。一个集合中有n块石头就可以移除n-1块剩下一个石头,最终剩下的石头数量等于集合数量,所以可以移除石头数量等于总的石头数量减去集合数量。

var removeStones = function(stones) {
    // 用下标代表每块石头,连通的是编号
    let u = new UnionSet(stones.length);
    // 记录x轴上出现的石头和y轴上出现的石头
    let ind_x = new Map(), ind_y = new Map();
    for(let i = 0; i < stones.length; i++){
        let x = stones[i][0];
        let y = stones[i][1];
        // 判断在x轴中有没有出现过和当前石头相同的坐标
        if(ind_x.has(x)){
            u.merge(i, ind_x.get(x));
        }
        // y轴同理
        if(ind_y.has(y)){
            u.merge(i, ind_y.get(y));
        }
        ind_x.set(x, i);
        ind_y.set(y, i);
    }
    // 统计集合数量
    let cnt = 0;
    for(let i = 0; i < stones.length; i++){
        if(u.get(i) === i) cnt += 1;
    }
    return stones.length - cnt;
};

1202. 交换字符串中的元素

索引对本身就是一种连通关系,连通的字符位置可以随意交换,想拼凑字典序最小,把集合中的元素排序即可。
首先用并查集将可交换的字母连通组成一组,对组内的字母进行排序,然后根据字符串中字符的组号来获取组内的最小字符进行字符串拼接。最后得出的字符串就是我们所需要的字符串。

var smallestStringWithSwaps = function(s, pairs) {
    let len = s.length;
    let u = new UnionSet(len);
    for(let p of pairs){
        let i = p[0];
        let j = p[1];
        u.merge(i, j);
    }
    let h = new Array(len).fill(0).map(() => new Array());
    for(let i = 0; i < len; i++){
        h[u.get(i)].push(s[i]);
    }
    for(let i = 0; i < len; i++){
        if(h[i].length > 0){
            h[i].sort((a, b) => a.charCodeAt() - b.charCodeAt());
        }
    }
    let ret = '';
    for(let i = 0; i < len; i++){
        ret += h[u.get(i)].shift();
    }
    return ret;
};
// 在上面的基础上进行改进,js数组shift的比pop要慢的多,所以我们可以把排序改为从大到小,从数组末尾取
var smallestStringWithSwaps = function(s, pairs) {
    let u = new UnionSet(s.length);
    // 将可以交换的字符坐标进行连通
    for(let p of pairs){
        let i = p[0];
        let j = p[1];
        u.merge(i, j);
    } 
    // 创建和字符长度一样多的数组,根据根节点分组
    let h = new Array(s.length).fill(0).map(() => new Array());
    for(let i = 0; i < s.length; i++){
        // 把字符放到第i个字符根节点所在的数组中  
        h[u.get(i)].push(s[i]);
    }
    // 将组中的字符进行从大到小排序
    for(let i = 0; i < s.length; i++){
        if(h[i].length > 0){
            h[i].sort((a, b) => b.charCodeAt() - a.charCodeAt());
        }
    }
    // 通过原始字符坐标获取字符的组号然后进行取字符拼接
    let ret = '';
    for(let i = 0; i < s.length; i++){
        // 第i位的字符等于第i位所在数组中最小的字符,即最后一位元素
        ret += h[u.get(i)].pop();
    }
    return ret;
};