图解并查集,外加几道Leetcode练手题.md

前言

并查集是一种非常有用且高效的数据结构,千万不要被这个极具专业性的名字吓到了,它的算法思想和代码实现都非常简单,不需要花太大力气就可以轻松掌握。下面就通过画图等方式为大家介绍一下这种神奇的数据结构。

一、 图解并查集

并查集有两个英文名:1、Disjoint Set,2、Union Find。它的作用就是把一个数据集分成若干个子集,每个子集内部数据可以互联互通,而子集之间则不具有连通性。并查集的底层结构类似于堆(不熟悉堆的同学赶紧去复习一下堆排序,面试频率很高哦),也是用数组描述一种树结构,但不同的是,堆是一棵独立的二叉树,并查集的树是多叉树,而且可能不止一棵树(也就是森林)。在并查集中,我们把相互独立的数据集称为连通分量,连通分量在逻辑上可以形象地描述为一棵树,每棵树都有一个根节点,其他的元素都会直接或间接指向根节点。

比如下图这个并查集,我们维护一个parent数组,每个元素初始化为对应的数组下标,代表自己是独立的一棵树,且是树根。以第一棵树为例,在后续数据处理过程中,我们把与所有与"2"同属一个连通分量的元素都连到"2"上,并把数组对应下标的元素赋值为2,其中"5"先连接到了"1"上,"1"又连接到了"2"上。最后,数组每个元素都代表其指向的父节点。

并查集底层结构

知道了并查集的底层结构,那我们该如何去构建这个结构并进行查找操作呢?并查集有两个最重要的方法:union 和 find,这里先给出UnionFind 最完善的 API 框架伪代码:

public class UnionFind {
  public UnionFind(int N) {}                       // 构造方法
  
  public int find(int x) {}                        //查找某个元素的根节点
  public void union(int x, int y) {}               // 为x和y建立联系
  public boolean connected(int x, int y) {}        //判断x和y是否相连(在同一棵树也就是连通分量中)
  public int count() {}                            // 返回连通分量的个数,也就是多少棵树
}

1. find 方法实现

find方法的目的是寻找某个元素所在树的根节点,代码如下:

public int find(int x) {
  while (x != parent[x]) {
    x = parent[x];
  }
  return x;
}

解释一下这短短几行代码做了什么,前面的介绍我们已经知道,根节点元素的数组值就是自身的下标,也就是parent[x] = x; 那么当数组元素值不等于其下标时,说明它不是根节点,就一直循环找下去,直到找到根节点。 如下图是寻找5的根节点的过程:

find 操作

2. union 方法实现

union 方法顾名思义就是把两个元素联系起来,具体的做法先找到各自的根节点,再把其中一个元素的根节点连接到另一个元素的根节点上,代码如下:

public void union(int x, int y) {
    int rootX = find(x);
    int rootY = find(y);
    // 根节点相同则无需操作
    if (rootX == rootY) {
        return;
    }
    parent[rootX] = rootY;
}

下图是对元素 4 和 5 进行 union 的操作示意图:

union 操作

union 操作

3. 并查集优化:路径压缩和按秩合并

从上图中我们可以看出一个问题,如果 union 操作直接将两棵树合并,那么多次 union 之后,树的高度可能会很高,那么寻找一个节点的根节点的路径就会很长,导致查找效率降低,那该如何对其进行优化呢?主要有两种优化方式,那就是路径压缩和按秩合并,路径压缩是在 find 方法里进行,而按秩合并则是在 union 方法中进行,二者选其一即可,前者代码更简洁。

3.1 路径压缩

路径压缩又有两种方式:隔代压缩和彻底压缩

  • 「隔代压缩」性能比较高,虽然压缩不完全,不过多次执行「隔代压缩」也能达到比较好的效果,只需要在原 find 方法中加上一句parent[x] = parent[parent[x]];这句代码的意思是把路径上的每个节点的父节点指向其祖父节点。
  • 「彻底压缩」需要借助系统栈,使用递归的写法 。或者使用迭代写法,先找到当前结点的根结点,然后把沿途上所有的节点都指向根节点,需要遍历两次。彻底压缩需要消耗更多的性能,但是压缩的更彻底,可以提高查询效率。
隔代压缩与彻底压缩
3.2 按秩合并

这个“秩”一般是指树的高度,也有地方解释为树节点个数,我们这里取前者。具体实现就是新增一个ranks数组记录以各个元素为根节点的树的高度,在做合并操作时,把高度较小的树的根节点连接到高度较大的树的根节点上。如下图,在未优化前,合并后的树高度增加为4,而按秩合并后的树高仍然为3。这里要注意的是,如果两棵树高度相同,那么两者均可作为根节点,则合并后的新树高度需要加一。

按秩合并

代码如下:

// 如果代码片段看不懂,可以结合后面的完整版代码去理解
public void union(int x, int y) {
      int rootX = find(x);
      int rootY = find(y);
      // 根节点相同则不需要操作
      if (rootX == rootY) {
            return;
      }
      // 比较树高,高度小的树合并到另一颗树上,相等的话两者均可作为根节点,并把高度加一
      if (ranks[rootX] > ranks[rootY]) {
            parent[rootY] = rootX;
      } else if (ranks[rootX] < ranks[rootY]) {
            parent[rootX] = rootY;
      } else {
          parent[rootY] = rootX;
          ranks[rootX]++;
      }
      count--;
}

4. 完整代码

4.1 路径压缩版本
class UnionFind {
    private int[] parent;
    // count用来记录连通分量的个数
    private int count;

    public UnionFind(int n) {
        // count初始化为n,也就是最开始有n个连通分量(n棵树)
          count = n;
        // parent数组各个元素初始化为其自身下标,代表自己是一棵树
        parent = new int[n];
        for (int i = 0; i < n; i++) {
            parent[i] = i;
        }
    }
  
        /**查找根节点*/
    public int find(int x) {
        while (x != parent[x]) {
            // 路径压缩(隔代压缩)
            parent[x] = parent[parent[x]];
            x = parent[x];
        }
        return x;
    }
  
        /**合并操作*/
    public void union(int x, int y) {
        int rootX = find(x);
        int rootY = find(y);
        // 根节点相同则不需要操作
        if (rootX == rootY) {
            return;
        }
        parent[rootX] = rootY;
        // 合并之后连通分量(树)个数减一
        count--;
    }
  
        /**判断x和y是否在同一个连通分量(同一棵树)*/
    public boolean isConnected(int x, int y) {
        return find(x) == find(y);
    }
  
    /**返回连通分量个数*/
    public int count() {
        return count;
    }
}
4.2 按秩合并版本
class UnionFind {
    private int[] parent;
    //新加一个ranks数组,记录树的高度
    private int[] ranks;
    // count记录连通分量的个数
    private int count;

    public UnionFind(int n) {
        count = n;
        parent = new int[n];
        for (int i = 0; i < n; i++) {
            parent[i] = i;
        }
        // 高度都初始化为1
        ranks = new int[n];
        for (int i = 0; i < n; i++) {
            ranks[i] = 1;
        }
    }
        /**按秩合并版本的 find 方法不需要做优化*/
    public int find(int x) {
        while (x != parent[x]) {
            x = parent[x];
        }
        return x;
    }
        /**按秩合并*/
    public void union(int x, int y) {
        int rootX = find(x);
        int rootY = find(y);
        if (rootX == rootY) {
            return;
        }
        if (ranks[rootX] > ranks[rootY]) {
            parent[rootY] = rootX;
        } else if (ranks[rootX] < ranks[rootY]) {
            parent[rootX] = rootY;
        } else {
            parent[rootY] = rootX;
            ranks[rootX]++;
        }
        count--;
    }

    public boolean isConnected(int x, int y) {
        return find(x) == find(y);
    }

    public int count() {
        return count;
    }
}

二、并查集的应用

了解完并查集,最好可以在实际场景中实践一下,如果没有合适的生产环境的话,那就来趁热打铁做几道Leetcode算法题来检验一下学习成果吧。这里举三个比较典型且比较简单的题目,每个题目都代表了一个并查集的应用场景。

1、Leetcode原题1319 联通网络的操作次数

比如一个在一整个计算机网络中,有的计算机可以和其他一部分计算机通过线缆连接了起来,但是整个集群并不是互联互通的,那么我们就需要计算操作多少次可以把整个集群连接起来。

如果确定了一道题需要用并查集来求解,那我们上来二话就说先把 UnionFind 类写出来,然后再去处理题目逻辑,这样会简单很多。

class Solution {
    public int makeConnected(int n, int[][] connections) {
        UnionFind uf = new UnionFind(n);
        // 数组长度就是现有线缆数量
        int cables = connections.length;
        // 如果线缆数量小于计算机数量减一,那么肯定不可能把所有计算机都连接起来
        if (cables < n - 1) return -1;
        // 遍历数组,union 所有计算机
        for (int[] con : connections) {
            uf.union(con[0], con[1]);
        }
        //need是连通块的数量,也就是计算机集群的数量,把他们连起来所需线缆数量等于need-1
        int need = uf.count();
        return need - 1;
    } 
}
class UnionFind {...}    // 这里是 UnionFind 的完整代码,选择以上任一版本均可

2、 Leetcode原题547 朋友圈

典型的就是朋友圈,如果一个朋友圈里的人和圈外的人没有任何关系,那么如何去统计一群人中的朋友圈数量;

class Solution {
    public int findCircleNum(int[][] M) {
        int n = M.length;
        UnionFind uf = new UnionFind(n);
        for (int i = 0; i < n; i++) {
            for (int j = 0; j < n; j++) {
                if (M[i][j] == 1) {
                    uf.union(i, j);
                }
            }
        }
        // 直接返回连通分量个数即为朋友圈个数
        return uf.count();
    }
}
class UnionFind {...}      // 这里是 UnionFind 的完整代码,选择之前任一版本均可

3、Leetcode原题990 等式方程的可满足性

在编程中,有时候我们需要声明一些等价的变量名(也就是多个引用指向一个对象),在一系列声明之后,系统需要判断这些变量名是否等价。在下面这道题中,可以把方程等号两端的变量看做变量名。

class Solution {
    public boolean equationsPossible(String[] equations) {
        UnionFind uf = new UnionFind(26);
        // 先遍历一遍将"=="连接的变量进行 union 操作
        for (String s : equations) {
            char[] cs = s.toCharArray();
            if (cs[1] == '=') {
                uf.union(cs[0] - 'a', cs[3] - 'a');
            }
        }
        // 再遍历一遍,如果"!="连接的变量在同一连通分量,则出现矛盾,直接返回false
        for (String s : equations) {
            char[] cs = s.toCharArray();
            if (cs[1] == '!' && uf.isConnected(cs[0] - 'a', cs[3] - 'a')) {
                return false;
            }
        }
        return true;
    }
}
class UnionFind {...}      // 这里是 UnionFind 的完整代码,选择之前任一版本均可

可以看出,在提供了完善的 UnionFind API 后,题目的处理变得异常简单。 UnionFind 的使用是有固定套路的,那就是先把整个数据集的数据 union 一遍,然后再根据实际需求去判断两个数据是否连通或统计连通分量的个数。小伙伴们如果想尝试更多并查集相关算法题,可以在 Leetcode 查看并查集的标签分类。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,736评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,167评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,442评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,902评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,302评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,573评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,847评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,562评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,260评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,531评论 2 245
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,021评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,367评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,016评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,068评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,827评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,610评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,514评论 2 269