数据结构与算法面试宝典 - 前微软资深软件工程师 - 拉勾教育

并查集是一种树型的数据结构,用于处理一些不交集(Disjoint Sets)的合并及查询问题。通常会用到两种操作。

  • Find:确定元素属于哪一个子集。它可以被用来确定两个元素是否属于同一子集。
  • Union:将两个子集合并成同一个集合。

因此,这种数据结构称为并查集

在工程中,并查集往往较多用于数据清理分类等操作,并且能够以 O(N) 的时间复杂度处理较大的数据量,出现在大厂的面试题中也就不奇怪了。

学完这一讲,你将会收获:

  • 并查集的模板代码
  • 如何利用并查集解决连通域问题
  • 如何利用虚拟点与虚拟边
  • 如何利用路径压缩的技巧

并查集基础

首先来看一下并查集要解决的问题,主要有两个。

  • Find:查询 item 属于哪个集合
  • Union:将两个集合进行合并

我们以一个有趣的问题展开。在《倚天屠龙记》这部武侠小说中,有很多帮派,比如:

07 | 并查集:如何利用两行代码写并查集? - 图1

其中张无忌、谢逊、韦一笑属于明教,而张三丰、莫声谷、宋远桥属于武当派。

方法 1

我们首先设计这样一种方案:采用数组 / 哈希的方法,记录每个人所在的门派。伪代码如下:

  1. Map<String, String> = new HashMap<>();
  2. H["谢逊"] = "明教"
  3. H["张无忌"] = "明教"
  4. H["韦一笑"] = "明教"
  5. H["莫声谷"] = "武当"
  6. H["张三丰"] = "武当"
  7. H["宋远桥"] = "武当"

那么就可以这样查询:

  1. String Find(String person) {
  2. return H.get(person);
  3. }

至此,我们已经完成一个功能了。时间复杂度也很低,可以达到 O(1)。

那我们再看一下合并。假设某一天,张三丰要闭关修炼,决定将武当派暂时交给张无忌代管理,为了方便管理两个帮派,张无忌号令明教的人前往武当派。那么此时就需要进行一个合并 Union 操作,也就是将所有 “明教” 的人归入“武当”。代码如下:

  1. void Union(String A, String B) {
  2. for (item : H) {
  3. if item.value == "明教":
  4. item.value = "武当"
  5. }
  6. }

但是如此一来,整个时间复杂度就上去了,Union 的时候,时间复杂度变成 O(N)。如果 Union 操作很频繁,那么这种算法就变得不可接受。

方法 2

在这里我们换一种思路,看看能不能解决 Union 复杂度过高的问题。采用江湖中通常的做法,认帮主!当帮主一样的时候,就认为我们是一个帮派的。

07 | 并查集:如何利用两行代码写并查集? - 图2

每个人都指向自己的大哥,帮主最牛,指向帮主自己。那么要进行 Union 操作的时候。直接修改指针就可以了。代码如下:

  1. void Union(String A, String B) {
  2. String A帮主 = Find(A);
  3. String B帮主 = Find(B);
  4. H.put(A帮主, B帮主);
  5. }

在 Union 的最后,我们只需要将 A 帮主指向 B 帮主就可以了。比如,将明教与武当合并,如下图所示:

07 | 并查集:如何利用两行代码写并查集? - 图3

我们再看一下 Find 函数,代码如下:

  1. String Find(String A) {
  2. while (A != H.get(A)) {
  3. A = H.get(A);
  4. }
  5. return A;
  6. }

虽然这种办法在 Union 时比较方便,但是在 Find 时却容易引入较高的复杂度。下面我们一起来看一下为什么 Find 起来比较麻烦:

07 | 并查集:如何利用两行代码写并查集? - 图4

在这种情况下,Find 查询时,总是会查询很多次 O(N)。也就是说,Union 的时间复杂度较低的时候,Find 的时间复杂度又上升了。

那么,有没有更好一点的办法呢?能让 Union 和 Find 的时间复杂度都低一点。

路径压缩

办法还是有的,就叫路径压缩,我们发现,在方法 2 中,如果能将层级结构 “拍扁”,那么 Find 和 Union 的时间复杂度都会特别低。

因此,我们还需要在 Find 函数里面做一些手脚。当我们找到一帮主之后,就把这条路径上的所有人的大哥都改成帮主。代码如下(解析在注释里):

  1. String Find(String A) {
  2. String start = A;
  3. while (A != H.get(A)) {
  4. A = H.get(A);
  5. }
  6. while (H.get(start) != A) {
  7. String next = H.get(start);
  8. H.put(start, A);
  9. start = next;
  10. }
  11. return A;
  12. }

再看这个例子:经过合并,成立糖葫芦帮之后。如下图所示:

07 | 并查集:如何利用两行代码写并查集? - 图5

如果一旦执行 Find(“韦一笑”),那么糖葫芦帮派就会变成大饼帮派。

07 | 并查集:如何利用两行代码写并查集? - 图6

所有人的帮主都会指向张三丰。也就是说,除了第一次 Find 复杂度为 O(N),后面的查询复杂度都是 O(1)。至此,我们已经讲清楚带路径压缩的并查集的原理。接下来我们看代码如何实现。

并查集模板

前面使用的都是比较形式化的语言和伪代码。接下来我们看一下具体如何实现并查集。这里我以整数替换前面的人名,操作起来更加方便。

初始化

首先假设有 N 个整数,范围为 [0, N)。那么记录每个人的信息,就需要一个长度为 N 的数组。

在初始化的时候,每个人都是自成一派。

  1. for (int i = 0; i < N; i++) {
  2. F[i] = i;
  3. }

查询

根据前面所讲,可以得到查询操作的代码如下(解析在注释里):

  1. int Find(int x) {
  2. int b = x;
  3. while (F[x] != x) {
  4. x = F[x];
  5. }
  6. while (F[b] != x) {
  7. int p = F[b];
  8. F[b] = x;
  9. b = p;
  10. }
  11. return x;
  12. }

合并

完成查询操作,我们就要把两个集合进行合并,代码如下:

  1. int Union(int x, int y) {
  2. F[find(x)] = find(y);
  3. }

这两个函数的代码还是显得有点长,并且不太容易记。我在刷题和面试时,更喜欢,或者说常用一份精简过的代码。下面我将分享给你。

两行代码

这里我整理了:两行并查集核心代码模板(用 C 语言实现,方便记忆):

  1. int F[N];
  2. int Find(int x) {
  3. return x == F[x] ? x : F[x] = Find(F[x]);
  4. }
  5. void Union(int x, int y) {
  6. F[find(x)] = find(y);
  7. }

注:根据不同的语言,你可能需要修改不同的 Find 函数。

两个功能

当真正使用并查集的时候,面试官可能会问你两个问题:

  • 有多少个集合?
  • 每个集合里面有多少个元素?

下面我们依次回答这两个问题。

1. 集合数目:在执行 Find 的时候,集合个数不可能有变化。如果发生变化,只可能发生在两个集合合并的时候。

再来具体看一下初始化和合并操作。

  • 初始化:在并查集开始初始化的时候,一共有 N 个元素,那么一开始集合个数为 N。
  • 合并:合并的时候,需要查看合并的两个集合是不是同一个,如果不是,那么集合个数减 1。
    2. 每个集合中元素的个数:在执行 Find 的时候,每个集合中元素的个数不可能发生变化。如果发生变化,只可能是两个集合合并的时候。

下面我们具体看一下初始化和合并操作。

  • 初始化:在并查集开始初始化的时候,每个元素都属于独立的元素,那么一开始每个集合里面的个数都是 1。如果我们用 Count[] 数组记录每个元素的个数,那么一开始初始化 Count[] = 1。
  • 合并:当 A 集合要合并到 B 集合里面的时候,可以认为 A 集合里面所有的元素都变成 B 集合里面的元素。当然是 B 集合里面的个数增加了,那么 Count[Find(B)] + = Count[Find(A)]。

注意:在记录集合中元素个数的时候,只有根结点的信息是准确的。当查询结点 i 所属集合的信息时,只能使用 Count[Find(i)],而不能使用 Count[i]。因为如果要保证每个点 Count[i] 的信息都是准确的,那么每次合并的时候,整个集合中的元素的信息都要更新,这样时间复杂度就很高了,Union 操作的时间复杂度就不再是 O(lgN),而变成 O(N)。

为了方便你刷题和应对面试,这里我给出了并查集的完整代码,你可以作为参考。

完整 Java 代码

  1. int[] F = null;
  2. int count = 0;
  3. int[] Cnt = null;
  4. void Init(int n) {
  5. F = new int[n];
  6. Cnt = new int[n];
  7. for (int i = 0; i < n; i++) {
  8. F[i] = i;
  9. Cnt[i] = 1;
  10. }
  11. count = n;
  12. }
  13. int Find(int x) {
  14. if (x == F[x]) {
  15. return x;
  16. }
  17. F[x] = Find(F[x]);
  18. return F[x];
  19. }
  20. void Union(int x, int y) {
  21. int xpar = Find(x);
  22. int ypar = Find(y);
  23. if (xpar != ypar) {
  24. F[xpar] = ypar;
  25. Cnt[ypar] += Cnt[xpar];
  26. count--;
  27. }
  28. }
  29. int Size(int i) {
  30. return Cnt[Find(i)];
  31. }

注:这里是以整数数组为例。如果关键字是 String,也可以使用哈希表将字符串映射到整数再进行并查集的操作。

代码:Java/C++/Python

复杂度分析:并查集的初始化时间复杂度为 O(N),而 Find 和 Union 的操作时间复杂度都是 O(lgN),其中 N 为点的总数。这里只使用了长度为 N 的数组,所以空间复杂度为 O(2N)。

例 1:最小生成树

题目】给定一个图的点集,边集和权重,返回构建最小生成树的代价。

输入:N = 2, conn = [[1, 2, 37], [2, 1, 17], [1, 2, 68]]

输出:17

解释:图中只有两个点 [1, 2],当然是选择最小连接 [2, 1, 17]

分析】利用并查集 + 贪心算法,可以生成一个图的最小生成树,这种方法也被称为 Kruskal 算法。并查集可以用来将两个点进行 Union,不过在并查集的 Union 代码中,并没有权重这一项,那我们该怎么办呢?

在 Union 的时候,就直接根据边的权重来排序,然后再处理,这不就是经典的 Kruskal 算法

这里我们可以讲一下最小生成树的思路:

  • 首先初始化并查集
  • 将边集按照权重排序
  • 利用边集将不同的两点进行 Union
  • 将不同的集合进行 Union 时需要加上新加入的边的代价(即边的权重)。

代码】这里我们可以写出经典的 Kruskal 算法,代码如下(解析在注释里):

  1. class Solution {
  2. private long cost = 0;
  3. private int[] F = null;
  4. private void Init(int n) {
  5. F = new int[n+1];
  6. for (int i = 0; i <= n; i++) {
  7. F[i] = i;
  8. }
  9. cost = 0;
  10. }
  11. private int Find(int x) {
  12. if (x == F[x]) {
  13. return x;
  14. }
  15. F[x] = Find(F[x]);
  16. return F[x];
  17. }
  18. private void Union(int x, int y, int pay) {
  19. if (Find(x) != Find(y)) cost += pay;
  20. F[Find(x)] = Find(y);
  21. }
  22. public long Kruskal(int n, int m, int[][] conn) {
  23. Init(n);
  24. Arrays.sort(conn, 0, m, new Comparator<int[]>() {
  25. public int compare(int[] a, int[] b) {
  26. return a[2] - b[2];
  27. }
  28. });
  29. for (int i = 0; i < m; i++) {
  30. Union(conn[i][0], conn[i][1], conn[i][2]);
  31. }
  32. return cost;
  33. }
  34. }

代码:Java/C++/Python

复杂度分析:程序主要分为两块,一部分为边集 E 的排序,复杂度为 O(ElgE);另外一部分为每条边的 Union 操作,复杂度为 O(ElgN)。在大部分时候,边的数目往往比点的数目要多,因此时间复杂度为 O(ElgE)。

小结】本质上 Kruskal 算法就是并查集算法 + 贪心算法。使用 Kruskal 算法有一个很重要的前提——题目是假设输入边能将所有的点加到一个连通域中,也就是保证最后必然能够生成一棵树。

这里给你留一道练习题,你可以利用它检验和巩固自己的学习成果。

练习题 1:给定点集和边集,求最小生成树的代价,如果最后不能生成最小生成树,那么返回 MAX_INT。

代码:Java/C++/Python

接下来我们一起看一下关于并查集的其他考察形式与考点。

连通域的数目

我们可以把最小生成树当成一个连通域,只不过需要用最小的代价来生成这么一个连通域。除了求解最小生成树,并查集的另外一个常见的用途是求解连通域的数目。在微软和 EMC 的面试中都出现过,但是可能会通过两种方式给出图的结构,比如:

  • 点集和边集,告诉你有哪些点,以及哪些边;
  • 矩阵表示。

不管是通过哪一种图表示,利用并查集解决连通域数目的步骤都是以下两步

  1. 用 F[] 数组和点集进行初始化
  2. 利用边集进行 Union

最后的集合数目就是连通域的数目。

利用本讲前面学过的模板和思路,相信你已经可以解决面试中的高频出现的算法题了。

例 2:帮派的数目

题目】江湖上有 N 个人,编号从 [1 ~ N],现在只能告诉你,其中两人是一个帮派的,请你输出帮派的数目。

输入:N = 4, [[1, 2], [2,3]]

输出:2

解释:一共有 4 个人,[1,2, 3] 成为一个帮派,[4] 独自成为一个帮派,那么一共有 2 个帮派。

分析】在一开始,你可以认为他们都是独自成为一个帮派,当告诉你每两个人是一个帮派时,相当于要把这两个人合并到一个集合中。问题是一共有多少个帮派,显然这就是一个非常标准的并查集的问题了。我们可以直接套用前面所讲的并查集的模板进行求解。

代码】直接利用并查集的代码模板,代码如下(解析在注释里):

  1. int count = 0;
  2. int[] F = null;
  3. void Init(int n) {
  4. F = new int[n + 1];
  5. for (int i = 0; i <= n; i++) {
  6. F[i] = i;
  7. }
  8. count = n;
  9. }
  10. int Find(int x) {
  11. if (x == F[x]) {
  12. return x;
  13. }
  14. F[x] = Find(F[x]);
  15. return F[x];
  16. }
  17. void Union(int x, int y) {
  18. if (Find(x) != Find(y))
  19. count--;
  20. F[Find(x)] = Find(y);
  21. }
  22. int findGangNumber(int n, int[][] conn) {
  23. Init(n);
  24. int m = conn.length;
  25. for (int i = 0; i < m; i++) {
  26. Union(conn[i][0], conn[i][1]);
  27. }
  28. return count;
  29. }

代码:Java/C++/Python
复杂度分析:整个算法的时间复杂度为 O(mlogN) ,这里 n 表示人的数目,而 m 表示两两成对的输入数目。

小结】在这里我们直接利用并查集的模板搞定了一道题目。

延伸:如果将这里的每个点都当成一个 “图” 结构中的一个点,将两两成对的输入当成 “图” 结构中的边。那么问题就变成了求解图的连通域个数。

下面我们一起来看一下这个曾经在微软的电面中出现的 2 道题目。

练习题 2:给定一个黑白图像,其中白色像素用 ‘1’ 表示,黑色像素用 ‘0’ 表示。如果把上下左右相邻的白色像素看成一个连通域,给定一幅图(用矩阵表示),请问图中有几个连通域。

输入:A = [[‘1’, ‘1’, ‘0’], [‘0’, ‘1’, ‘0’]]

输出:1

解释:图中所有的 ‘1’ 都是连在一起的,所以只有一个连通域。

代码:Java/C++/Python

练习题 3:给定一个图(不是图像)的矩阵,A[i][j] = 1 表示点 i 与点 j 相连,求这个图里面连通域的数目。

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

输出:3

解释:[0, 1, 2] 三个点中,每个点都不与其他点相连,所以连通域有 3 个。

代码:Java/C++/Python

例 3: 换工位

题目】因为要实施结对编程,想让两个员工的工位挨在一起:要求 [0,1] 员工坐在一起,[2, 3] 员工坐在一起,以此类推。不过挨着具体坐的位置并不重要,只要能挨在一起就可以了。比如 [0, 1, 3, 2] 与 [2, 3, 1, 0] 都是满足要求的。现在给定一个数组 A[],求换工位的最少次数,尽量让两个员工坐在一起。(给定 N 个员工,他们的编号总是 [0~N-1] ,并且 N 总是偶数)。

输入:A[] = [0, 3, 2, 1]

输出:1

解释:只需要换 1 次就可以了,比如,将 0 号员工与 2 号员工交换。

分析】初看这道题的时候,没有什么思路,那么我们进行一下模拟,看看能不能发现什么规律。

1. 模拟

当 N = 2 时,无论是 [0, 1] 还是 [1, 0] 这两种排列都满足要求,因为我们总是想让 [0, 1] 这两个员工坐在一起,而只有两个员工时,他们总是挨在一起的。假设结对成功的两个人坐在一起的时候,就像做在链条上的环一样。

由于 N 必须为偶数,所以接下来我们看一下 N = 4 时的情况。比如 A = [0, 3, 2, 1],此时 4 个人都没有结对成功,相当于两个环还扣一起。

这时我们只需要交换 0, 2 形成 [2,3,0,1],如果按配对划分,那就是 [2, 3] 和 [0, 1]。结对成功之后,这两个环就可以拆开了。操作如下图所示:

07 | 并查集:如何利用两行代码写并查集? - 图7

通过这个示例,还可以发现,如果不经过交换,虽然 [3, 2] 这两个员工已经坐在一起了,但是不操作,那么 0 号员工和 1 号员工是无法结对编程的。

07 | 并查集:如何利用两行代码写并查集? - 图8

因此,我们可以得到结论 1:结对的时候,数组中只能偶数下标与奇数下标配比。比如 A[0] 与 A[1] 结对。不能奇数下标与偶数下标结对,比如 A[1] 与 A[2] 结对。

接下来我们再看一下 N = 6 的情况, 比如 A = [0, 2, 3, 5, 1, 4]:我们在执行交换的时候,可以这样操作:

07 | 并查集:如何利用两行代码写并查集? - 图9

所以成功切分出三个配对集合 [0, 1], [3, 2], [5, 4],需要 2 步。

2. 规律

通过前面的模拟,我们还需要进一步的总结规律。将里面没有成功结对的序列看成一条锁链。并且拆分出结对成功的两个元素,独立位于一个环中,并不与别人相扣在一起。

07 | 并查集:如何利用两行代码写并查集? - 图10

每 1 次操作,交换两个元素,就相当于从锁链中成功拆一个环下来。

07 | 并查集:如何利用两行代码写并查集? - 图11

那么,我们可以得到结论 2:有 2x 个元素,也就是 x 个环的锁链,就需要 x-1 次操作

至此,我们就将题目成功变成了:给定一个数组,需要找到里面有几条锁链。比如给定数组 A = [6, 4, 5, 2, 3, 7, 0, 1]。

此时应该可以分出两条锁链来,如下图所示:

07 | 并查集:如何利用两行代码写并查集? - 图12

我们再看每个锁链中的环的数目就可以得到最少操作次数。比如这里的 A[] 数组有 2 条锁链,需要的操作次数是 (3 - 1) + (1-1) = 2。也就是最少操作 2 次。

那么现在问题的关键就是,如何才能通过数组得到锁链呢?这里我们还发现一个有趣的结论 3:本就结对的两个员工必然在同一个链条中。比如 6 和 5 在没有结对的情况下,也必然在同一条锁链中。

3. 匹配

如果将锁链当成集合,就可以对应到并查集了。这里再细化一下:

  • 通过结论 3,我们应该将一个偶数 x 以及和它配对的数 x+1 先放到同一个集合中;
  • 偶数下标 A[i],需要与 A[i+1] 进行 Union,完成放到同一个锁链的操作。

虽然最后我们可以通过去数锁链中环的个数,再通过结论 2 得到答案。但是如果你能想到拆环的次数,实际上就是不同集合 Union 的次数。那么求解的时候,只需要在并查集模板的基础上对 Union 稍做更改就可以了。

4. 边界

注意处理空数组,注意结对的时候,要满足结论 1。

画图】接下来我们画图演示一下使用并查集的过程。这里我们以数组 A = [6, 4, 5, 2, 3, 7, 0, 1] 为例。

07 | 并查集:如何利用两行代码写并查集? - 图13

我们发现,不同集合的合并次数一共为 2 次,所以只需要 2 次操作就可以完成结对编程的要求。

代码】接下来我们可以写一下代码了(解析在注释里):

  1. int[] F = null;
  2. int unionCount = 0;
  3. void Init(int n) {
  4. F = new int[n];
  5. for (int i = 0; i < n; i++) {
  6. F[i] = i - (i & 0x01);
  7. }
  8. }
  9. int Find(int x) {
  10. if (x == F[x]) {
  11. return x;
  12. }
  13. F[x] = Find(F[x]);
  14. return F[x];
  15. }
  16. void Union(int x, int y) {
  17. if (Find(x) != Find(y)) {
  18. unionCount++;
  19. }
  20. F[Find(x)] = Find(y);
  21. }
  22. int minSwapsCouples(int[] A) {
  23. final int N = A == null ? 0 : A.length;
  24. Init(N);
  25. for (int i = 0; i < N; i += 2) {
  26. Union(A[i], A[i + 1]);
  27. }
  28. return unionCount;
  29. }

代码:Java/C++/Python

复杂度分析:一共有 N/2 对元素要合并,每次合并的时间复杂度为 O(lgN)。所以时间复杂度为 O(NlgN)。

小结】在这里,我们学习了将锁链处理成一个连通域,并且巧妙地通过求解合并次数解决了最小操作次数。

我认为这道题目最核心的考点是分析出结论 2有 2x 个元素,也就是 x 个环的锁链,就需要 x-1 次操作

一旦得到了每条锁链中的操作次数,然后利用并查集的模板,这道题目就解决了。我再给你留道练习题,希望你可以尝试做一下。

练习题 4:给定一个单词数组,如果两个单词相等,或者说其中一个单词 A 经过一次字符交换,可以得到单词 B,那么我们说单词 {A, B} 是同构的。请问单词数组中,一共有多少组这样的同构集合?

输入:{“AB”, “BA”, “AB”, “BC”, “CD”}

输出:3

解释:一共有三组同构集合,{“AB”, “BA”, “AB”}, {“BC”}, {“CD”}

代码:Java/C++/Python

接下来我们讲解并查集的进一步运用。

虚拟点与虚拟边

在求解连通域的过程中,我们经常利用现有的点与现有的边进行并查集的初始化与合并。

但是在有些题目中,需要加入一些虚拟的边和虚拟的点到并查集的点集与边集中。通过这种方式可以极大地方便我们使用并查集。

例 4: 替换字母

题目】给你一个矩阵 A,里面只包含字母 ‘O’ 和’X’,如果一个’O’ 上下左右四周都被’X’ 包围,那么这个’O’ 会被替换成’X’。请你写程序处理一下这个过程。

07 | 并查集:如何利用两行代码写并查集? - 图14

解释:由于中心的’O’ 四周都被包围,所以需要被换成’X’,而第 A[0][0] = ‘O’ 靠着边,所以不能被替换。

分析】这道题目曾经在微软的面试中出现过。看起来就是一个连通域的问题,所以可以使用并查集来处理。思路如下:

  • 首先用并查集标记所有’O’ 的连通域;
  • 将所有在边上的’O’ 的 “帮主” 放到 set 集合中;
  • 遍历每个’O’ 的 “帮主”,看看是不是在 set 集合中,如果在,那么这个’O’ 不能替换。

可以发现,有一步操作可以优化:将所有在边上的’O’ 的 “帮主” 放到 set 集合中,有两种办法:

  • 随便选择边上的一个点,作为所有边上点的 “帮主”;
  • 选一个虚拟的点,作为所有边上的点的 “帮主”。

你可以根据自己的喜好任选其一,这里我用第 2 种 “虚拟点” 的办法。下面就可以直接套用模板了。

代码】采用虚拟点的并查集的代码实现如下(解析在注释里):

  1. int[][] dir = {{0, 1}, {1, 0}};
  2. int[] F = null;
  3. void Init(int n) {
  4. F = new int[n];
  5. for (int i = 0; i < n; i++) {
  6. F[i] = i;
  7. }
  8. }
  9. int Find(int x) {
  10. if (x == F[x]) {
  11. return x;
  12. }
  13. F[x] = Find(F[x]);
  14. return F[x];
  15. }
  16. void Union(int x, int y) { F[Find(x)] = Find(y); }
  17. void solve(char[][] A) {
  18. if (A == null || A[0] == null) {
  19. return;
  20. }
  21. final int R = A.length;
  22. final int C = A[0].length;
  23. Init(R * C + 1);
  24. final int vNode = R * C;
  25. for (int r = 0; r < R; r++) {
  26. for (int c = 0; c < C; c++) {
  27. if (A[r][c] == 'O') {
  28. if (r == 0 || r == R - 1 || c == 0 || c == C - 1) {
  29. Union(r * C + c, vNode);
  30. }
  31. for (int d = 0; d < 2; d++) {
  32. final int nr = r + dir[d][0];
  33. final int nc = c + dir[d][1];
  34. if (!(nr < 0 || nr >= R || nc < 0 || nc >= C)) {
  35. if (A[nr][nc] == 'O') {
  36. Union(r * C + c, nr * C + nc);
  37. }
  38. }
  39. }
  40. }
  41. }
  42. }
  43. for (int r = 0; r < R; r++) {
  44. for (int c = 0; c < C; c++) {
  45. if (A[r][c] == 'O') {
  46. if (Find(r * C + c) != Find(vNode)) {
  47. A[r][c] = 'X';
  48. }
  49. }
  50. }
  51. }
  52. }

代码:Java/C++/Python

复杂度分析:由于每个点只遍历两遍。所有点的数目为 N,所以时间复杂度为 O(NlgN),此外,每个点都记录了所在集合,所以空间复杂度为 O(N)。

小结】在这里我们学习了一种新的处理技巧,那就是利用并查集 + 虚拟结点,将原本不在一起的结点,统一放到了一个虚拟集合中。

所以解决这道题目的考点我们可以总结如下:

07 | 并查集:如何利用两行代码写并查集? - 图15

在面试中,如果你有了并查集的模板,再加上虚拟点的思路,那么快速解决这类问题就轻而易举了。

例 5:上网的最小费用

题目】园区里面有很多大楼,编号从 1~N。第 i 大楼可以自己花钱买路由器上网,费用为 cost[i-1],也可以从别的大楼拉一根网线来上网,比如大楼 a 和大楼 b 之间拉网线的费用为 c,表示为一条边 [a, b, c]。输入为每个大楼自己买路由器和拉网线的费用,请问,让所有大楼都能够上网的最小费用是多少?上网具有联通性,只要与能够上网的大楼连通,即可上网。

输入:cost = [1, 2, 3], edges = [[1,2,100], [2,3,3]]

输出:6

解释:最优方案是 1 号大楼买路由器 cost[0] = 1,2 号楼买路由器 cost[1] = 2,然后和 3 号楼之间可拉一根网线,费用为 3,所以一共花费 6 元。如图(红色部分标记为费用 ):

07 | 并查集:如何利用两行代码写并查集? - 图16

分析】这是一道头条面试中出现过的题目。首先如果不考虑自己买路由器的情况,只依赖给定的边集构建这个图,且要求最小费用,这道题目就和最小生成树一模一样了。可是,这里与最小生成树不一样的地方在于:第 i 大楼可以自己花钱买路由器上网,费用为 cost[i-1]。

在最小生成树里面,可是没有说 “自己买路由” 这个操作。那怎么办?我们有什么方法可以转化一下吗?

可以采用加入虚拟点的方法。首先假设有一个结点 0 已经自己买了路由器,花费为 0 元。而其他结点要自己买路由器,本质等价于与结点 0 进行联通。只不过这个网线的费用,就是你自己买路由器的费用。

比如,给定 3 个点,分别自己买路由器的费用为 [1, 2, 3]。那么我们可以把图变成下图这样子:

07 | 并查集:如何利用两行代码写并查集? - 图17

也就是说,我们添加了一个虚拟结点 0,然后也添加了 3 条虚拟边。这里虚拟的元素我们都用绿色表示。

如果最后生成的连通图里面把 0~3 这四个点都包含进去,那么所有的大楼肯定都是可以上网的。此时最小代价问题就可以用最小生成树的方法来解决了。

代码】到这里,相信你已经知道可以怎么写代码了(解析在注释里):

  1. class Solution {
  2. private int[] F = null;
  3. private int totalCost = 0;
  4. private void Init(int n) {
  5. F = new int[n + 1];
  6. for (int i = 0; i <= n; i++) {
  7. F[i] = i;
  8. }
  9. totalCost = 0;
  10. }
  11. private int Find(int x) {
  12. if (x == F[x]) {
  13. return x;
  14. }
  15. F[x] = Find(F[x]);
  16. return F[x];
  17. }
  18. private void Union(int x, int y, int pay) {
  19. if (Find(x) != Find(y)) {
  20. totalCost += pay;
  21. }
  22. F[Find(x)] = Find(y);
  23. }
  24. public int minCostToSupplyWater(int N, int[] cost, int[][] es) {
  25. Init(N);
  26. int[][] conn = new int[es.length + N][3];
  27. for (int i = 0; i < es.length; i++) {
  28. conn[i][0] = es[i][0];
  29. conn[i][1] = es[i][1];
  30. conn[i][2] = es[i][2];
  31. }
  32. int to = es.length;
  33. for (int i = 1; i <= N; i++) {
  34. conn[to][0] = 0;
  35. conn[to][1] = i;
  36. conn[to][2] = cost[i - 1];
  37. to++;
  38. }
  39. Arrays.sort(conn, new Comparator<int[]>() {
  40. public int compare(int[] a, int[] b) {
  41. return a[2] - b[2];
  42. }
  43. });
  44. for (int i = 0; i < conn.length; i++) {
  45. Union(conn[i][0], conn[i][1], conn[i][2]);
  46. }
  47. return totalCost;
  48. }
  49. }

代码:Java/C++/Python

复杂度分析: 一共有 N 个点,M 条边,N 个点进行 Find/Union 的时间复杂度为 O(lgN),所以总的时间复杂度为 M(lgN)。

小结】接下来我们从面试官的角度看一下,这道题的考点是什么:

  • 将特殊条件转化为一般的条件,通过引入一些虚拟点,虚拟边来实现
  • 并查集的模板代码
  • 最小生成树的 Kruskal 算法

如果在面试中抓住了这 3 个点,就很容易击破这道算法题。接下来我们看一下并查集的另外一个的考点。

路径压缩

并查集除了前面提到了考点之外,还有一个比较不容易出现的考点。那就是关于路径压缩的考点。

处理这种题时,需要利用路径压缩同时将节点之间的信息进行层层压缩和汇总。求解过程还是很有趣的。下面让我们通过一个例题学习一下这个知识点。

例 6: 倍数关系

07 | 并查集:如何利用两行代码写并查集? - 图18

分析】那么首先我们进行一下模拟。

1. 模拟

变量之间的除法关系,我们需要记录一个链式信息。如果将除法当成一个有向边,然后变量与变量之间的除法就可以看成图结构。比如:
07 | 并查集:如何利用两行代码写并查集? - 图19

可以表示为下图:

07 | 并查集:如何利用两行代码写并查集? - 图20

如果我们将上图进行压缩,那么可以得到下图:

07 | 并查集:如何利用两行代码写并查集? - 图21

经过压缩之后,可以发现这几个元素之间的关系就变成了下面这个样子:

  • a = 8 * c
  • c = 1 * c
  • b = 4 * c

此时,可以得到任意两个变量之间的比值。实际上,这几个数也可以以 a 元素为根,如下图所示:

07 | 并查集:如何利用两行代码写并查集? - 图22

几个元素之间的关系就是这样:

  • b = 0.5 * a
  • a = 1 * a
  • c = 0.125 * a

此时,我们可以得到任意两个变量之间的比值。

2. 规律

在这里,可以通过模拟找到一个规律:只要是相连通的几个元素,可以选择任意一个结点做根结点。连通性好办,重点是:需要记录元素与根元素的比例

并且我们发现其实哪个点做根结点都一样。但是比例关系怎么办?再回看一下模拟的过程,可以发现:比例关系就是顺着图中,有向边的方向乘过去即可

这里我们画图表示如下:

07 | 并查集:如何利用两行代码写并查集? - 图23

也就是说,在压缩的时候,需要把路径上边的权重依次乘起来。

3. 匹配

通过前面的一番分析,可以发现题目具有两个特点:

  • 连通性
  • 路径压缩性

能匹配到这两个特点的算法刚好是今天所讲的并查集。

4. 边界

面试官提醒由于涉及除法,在面试中,你一定要主动提出是否可能存在除 0 的情况。如果给定的输入里面可能有,那么一定要记得处理

代码】我们已经有了并查集的代码,那么处理路径压缩,应该也不是什么问题,代码如下(解析在注释里):

  1. void addToMap(String key, Map<String, Integer> H) {
  2. final int id = H.size();
  3. if (!H.containsKey(key)) {
  4. H.put(key, id);
  5. }
  6. }
  7. int[] F = null;
  8. double[] C = null;
  9. void Init(int n) {
  10. F = new int[n];
  11. C = new double[n];
  12. for (int i = 0; i < n; i++) {
  13. F[i] = i;
  14. C[i] = 1;
  15. }
  16. }
  17. int Find(int x) {
  18. int b = x;
  19. double base = 1;
  20. while (x != F[x]) {
  21. base *= C[x];
  22. x = F[x];
  23. }
  24. int root = x;
  25. while (F[b] != root) {
  26. double next = base / C[b];
  27. C[b] = base;
  28. base = next;
  29. int par = F[b];
  30. F[b] = root;
  31. b = par;
  32. }
  33. return root;
  34. }
  35. void Union(int T, int D, double v) {
  36. int tpar = Find(T);
  37. int dpar = Find(D);
  38. F[tpar] = dpar;
  39. C[tpar] = v * C[D] / C[T];
  40. }
  41. double[] calcEquation(List<List<String>> equations,
  42. double[] values,
  43. List<List<String>> queries) {
  44. Map<String, Integer> H = new HashMap<>();
  45. for (List<String> l : equations) {
  46. String t = l.get(0), d = l.get(1);
  47. addToMap(t, H);
  48. addToMap(d, H);
  49. }
  50. Init(H.size());
  51. for (int i = 0; i < equations.size(); i++) {
  52. List<String> l = equations.get(i);
  53. Union(H.get(l.get(0)), H.get(l.get(1)), values[i]);
  54. }
  55. for (int i = 0; i < H.size(); i++) {
  56. Find(i);
  57. }
  58. double[] ans = new double[queries.size()];
  59. for (int i = 0; i < queries.size(); i++) {
  60. List<String> l = queries.get(i);
  61. int tidx = H.containsKey(l.get(0)) ? H.get(l.get(0)) : -1;
  62. int didx = H.containsKey(l.get(1)) ? H.get(l.get(1)) : -1;
  63. if (tidx == -1 || didx == -1) {
  64. ans[i] = -1;
  65. } else {
  66. int troot = Find(tidx);
  67. int droot = Find(didx);
  68. if (troot != droot) {
  69. ans[i] = -1;
  70. } else {
  71. ans[i] = C[tidx] / C[didx];
  72. }
  73. }
  74. }
  75. return ans;
  76. }

代码:Java/C++/Python

复杂度分析:假设有 N 个变量,构建并查集的时间复杂度为 O (NlgN),如果有 M 个 Query,每次在询问为 O(1),所以总的时间复杂度为 max(O(NlgN),M)。

小结】如果要解决这道题,那么需要注意掌握以下三点。

  • 连通域里面的所有变量都统一用一个变量表示倍数关系,那么任意的两个变量就可以直接询问倍数关系。
  • 倍数关系具有传递性,即:
    07 | 并查集:如何利用两行代码写并查集? - 图24
    这是我们进行路径压缩的关键。
  • Union 操作时,注意变量倍数关系的调整。

如果想到了这些,再加上我介绍的并查集的代码模板,那么解决这道面试题也就没什么难度了。以后在面试中,你如果发现题目具有传递性的特点,就可以使用并查集进行求解。

总结

在本讲中,我介绍了并查集面试时常见的考察点,并且给出了并查集的代码模板。最后我还给你准备了并查集的知识树,面试中并查集相关的问题基本上逃不出这个圈。希望你可以尝试自己对本讲的内容进行梳理,然后再对照下图查缺补漏。

07 | 并查集:如何利用两行代码写并查集? - 图25

思考题

如果我们把例 5 的变量看成图上的点,变量与变量之间的关系看成是边。一旦构建好了并查集,在 Query 的时候,就可以 O(1) 的时间查询到两个变量之间的代价。那么为什么在图算法中,我们需要用 Floyd 算法求解图中两个点之间的最短路径?

希望你可以把思考写在留言区,我们一起讨论,如果看到有趣的想法,我也会做成加餐和大家分享。:)

到这里,我们就要与并查集说再见了,接下来我们一起学习 08|排序:如何利用合并与快排的小技巧,解决算法难题。记得按时来探险。