本系列是来自对一个算法题库算法珠玑的模仿🤣。
我个人比较喜欢尽可能好一点的解法,因此许多问题我都会瞥一眼 LeetCode 排行里最佳的答案(一般都是屡有收获),或者看一下网上的解答。不过,也许依然不能穷尽所有的最佳解法,如有更好的思路烦请不吝赐教。
并查集的相关问题我基本上都是一次 AC 的,这个算法可太实用了……
在所有题目中,并查集用不用权重方法效率都一样,应该是测试用例的数据量不太大。不过这也是 LeetCode 的一贯问题了。
130. Surrounded Regions
一个类似于围棋检测的例子。这类题目(比如还有第200题岛屿数量,第547题朋友圈,第1319题连通网络)基本上都是和连通分量有关,使用并查集也是可以解决的,但是没有 DFS 效率高,因为它本质上只需要进行一次查找,所以比较适合 DFS 压栈一次性处理。
684. Redundant Connection
这道题展示了并查集的另一种用法:用于环检测。当然环检测也可以使用 DFS 的变种拓扑排序。
public int[] findRedundantConnection(int[][] edges) {
UF set = new UF(edges.length + 1);
for (int i = 0; i < edges.length; i++) {
if (!set.union(edges[i][0], edges[i][1])) {
return edges[i];
}
}
return new int[] { -1, -1 };
}
我对并查集做了一点小小的定制,将union
从 void 变成了 boolean 返回值,这样在调用时就把find
方法省去了。
还有一个细节是这里并查集的大小是数组长度加1(题目要求)。
765. Couples Holding Hands
这道题最简单的方法是贪心法,逐个搜索它的 couple 然后交换即可,类似于选择排序中找到满足条件的点,然后不断扩大有序区的概念。不过,怎么证明它是全局最优解呢?一次交换最好情况是让2对情侣匹配。那么贪心法如果不是最优解,只能是最优解中包含了额外的同时匹配2对的情况。但这是不可能的。
使用并查集解决更简单。我们把每一对情侣看作是一个节点。最终全部牵手意味着所有人都两两一组,此时所有的节点之间都不互连。而如果有人占了情侣原本的位置——也就是说,A 的邻居 B 不是 A 的情侣——说明此时需要一次交换,我们在 A 和 B 之间增加一个连接(边)。
如果一次交换可以让2对情侣匹配,那么这两个节点可以独立地组成一个并查集。因为每个并查集需要的交换次数总是为(
表示节点个数),所以最后的步数等于总情侣对数减去并查集个数。
那么为什么并查集需要的交换次数总是为,不论原先的无向图是什么形状呢?交换本质上可以看作是图中两条边转化为一条边的过程。而每个
的并查集一定有 n 条边。每次交换去掉1个节点和2条边,增加一条新的边,直到最后变成一个2节点的并查集。
我们只连接 2N 和 2N + 1 位置的节点。这是因为最终所有的情侣都会在 2N 和 2N + 1 位置(比如说,数组的第2个和第3个不可能组成情侣)。
public int minSwapsCouples(int[] row) {
UF set = new UF(row.length / 2);
for (int i = 0; i < row.length; i += 2) {
set.union(row[i + 1] / 2, row[i] / 2);
}
return row.length / 2 - set.count();
}
不过这道题同样用并查集并不是时间最优的,只能说写起来比较简单。
803. Bricks Falling When Hit
这道题不会,有空再说……在网上搜到了一个题目的图解:

947. Most Stones Removed with Same Row or Column
假设把每个石子看作是一个点,同列同行的关系看作是连接,那么题目就是要求在不断移除这个图中节点的时候,不能产生孤立的节点。对于一个连通分量而言,可以移除个节点。因此,该问题转化为求连通分量的个数。显然,这里使用并查集很好解决,但复杂度比较高:每次新增加一个节点,都需要和之间同一列所有的节点连一次。这说明其实我们需要连接的真正对象是行和列——节点的连通分量个数和行列组成的无向图的连通分量个数是相同的。这一点非常容易说明:我们可以把之前石子组成的无向图的每个连通分量所包含的行和列看做是一个集合。那么,这些石子对应的行列都在这个集合里,因此二者从并查集的角度是等价的。
这道题比较麻烦的地方在于我们并不知道究竟这个数组的最值是多少,因此不太好直接设置并查集的初始大小。当然题目也给了一个限制,理论上可以直接设置为这个上限。这样做会让强迫症不太舒服,所以我设置了一个哈希表来进行编号对照。
不过,这个时间和空间的排名不太好……我在想可能是我考虑了比较极端的情况。
public int removeStones(int[][] stones) {
UF set = new UF(stones.length * 2);
Map<Integer, Integer> map = new HashMap<Integer, Integer>(stones.length);
int no = 0;
for (int[] stone : stones) {
if (!map.containsKey(stone[0] * 2)) {
map.put(stone[0] * 2, no++);
}
if (!map.containsKey(stone[1] * 2 + 1)) {
map.put(stone[1] * 2 + 1, no++);
}
set.union(map.get(stone[0] * 2), map.get(stone[1] * 2 + 1));
}
return 3 * stones.length - map.size() - set.count();
}
959. Regions Cut By Slashes
这道题我感觉有点难……主要是要将问题划分为两两连接的情况,还有要理解,不存在3个以上的方块之间特殊的关系。
这道题我和一般的解法时间复杂度相同,但是空间复杂度低一些(大约是超过98%),因为我在这里不是把方块划分为4个区域,而是2个。想象如果设置为4个,那么并查集的槽位是 4N 个,但实际上有 2N 个完全不会使用。因此,这里相当于是复用了 2N 个槽位。
public int regionsBySlashes(String[] grid) {
int N = grid.length;
/* id of left smaller than right's */
UF set = new UF(N * N * 2);
for (int i = 0; i < N; i++) {
for (int j = 0; j < N; j++) {
int no = (i * N + j) * 2;
/* left union right */
if (j > 0) {
set.union(no - 1, no);
}
/* top union bottom */
if (i > 0) {
int top = grid[i - 1].charAt(j) == '\\' ? no - 2 * N : no - 2 * N + 1;
int bottom = grid[i].charAt(j) == '/' ? no : no + 1;
set.union(top, bottom);
}
if (grid[i].charAt(j) == ' ') {
set.union(no, no + 1);
}
}
}
return set.count();
}
990. Satisfiability of Equality Equations
这道题是连通问题的一个变种,主要是增加了不连通这个条件。目前除了使用一个集合进行标记,还没想到比较好的方法,而且如果要进行标记的话要注意在集合保存的是两个树的树根(因为方程式具有传递性)。我是直接用两次循环的,结果看了一下比别人用标记的方式还要快:
public boolean equationsPossible(String[] equations) {
UF set = new UF(26);
for (int i = 0; i < equations.length; i++) {
if (equations[i].charAt(1) == '=') {
int pos1 = equations[i].charAt(0) - 'a';
int pos2 = equations[i].charAt(3) - 'a';
set.union(pos1, pos2);
}
}
for (int i = 0; i < equations.length; i++) {
if (equations[i].charAt(1) == '!') {
int pos1 = equations[i].charAt(0) - 'a';
int pos2 = equations[i].charAt(3) - 'a';
if (set.find(pos1, pos2)) {
return false;
}
}
}
return true;
}

1202. Smallest String With Swaps
本身也是求连通分量,但增加了排序。从理论上对每个连通分量排序即可,但操作起来复杂度比较高。第一次写的时候我直接超时了……
尤其是空间上需要按照连通分量个数分为若干个集合单独处理,而原字符串本来就是一个比较大的字符串。幸好题目给了一个额外的条件:字符串只会出现26个小写字母,这样就可以使用计数排序缩小开销。不过最后我对比了一下,和其他并查集写法差距不大……
public String smallestStringWithSwaps(String s, List<List<Integer>> pairs) {
int N = s.length();
UF set = new UF(N);
for (int i = 0; i < pairs.size(); i++) {
set.union(pairs.get(i).get(0), pairs.get(i).get(1));
}
StringBuilder res = new StringBuilder(s);
Map<Integer, int[]> map = new HashMap<Integer, int[]>(N);
for (int i = 0; i < N; i++) {
int root = set.root(i);
if (set.size(root) == 1) {
continue;
}
int[] alphabets = map.computeIfAbsent(root, unused -> new int[26]);
alphabets[s.charAt(i) - 'a']++;
}
for (int i = 0; i < N; i++) {
int root = set.root(i);
int[] alphabets = map.get(root);
if (alphabets == null)
continue;
int j = 0;
for (; j < alphabets.length; j++) {
if (alphabets[j] > 0) {
break;
}
}
res.setCharAt(i, (char) (j + 'a'));
alphabets[j]--;
}
return res.toString();
}