1. 简介
并查集(
U
n
i
o
n
F
i
n
d
S
e
t
{\rm Union\ Find\ Set}
Union Find Set)是一种非常精巧且实用的数据集结构,它主要用于处理一些不相交集合的合并和查找问题。通常,我们将一个集合看作是一棵树,初始状态是每个元素均为一个集合。如下图:
然后,我们根据元素间的关系将上述单个集合合并。加入假如合并后的结果如下所示:
共产生四个集合:
【
A
、
B
、
F
】
{\rm 【A、B、F】}
【A、B、F】,
【
C
、
D
、
I
、
E
】
{\rm 【C、D、I、E】}
【C、D、I、E】,
【
G
、
H
】
{\rm 【G、H】}
【G、H】和
【
J
】
{\rm 【J】}
【J】。然后我们定义查找函数就可以快速确定某两个元素是否处于同一集合,如parent_A = find(A)
,parent _B = find(B)
,然后判断parent_A
和parent_B
是否相等。使用并查集解题的步骤如下:
(
1
)
(1)
(1)从题目中抽离各个元素构建并查集的初始状态;
(
2
)
(2)
(2)定义查找函数find
,实现的功能是给定元素或元素索引返回该元素的所在集合的根节点;
(
3
)
(3)
(3)定义合并函数union
,实现的功能是根据元素间的特性将若干小集合合并成一个大集合。
其实,后两个步骤的做法较为固定:使用一个parent
数组来存储一棵树,数组的下标表示树的节点,该下标对应的值表示该节点的父节点。规定对于树的根节点,其元素值为其本身。如上面集合可表示为:
由上面的过程可以知道,构造并查集是一个递归的过程。由于在合并子集合的时候,我们需要查找两个集合的根节点,并根据根节点合并两个集合。所以,我们先定义查找函数find
:
int find(int index):
// 如果该变量的父节点是其本身,则找到根节点
if(index == parent[index]){
return index;
}
// 以递归方式查找当前节点的根节点
parent[index] = find(parent[index]);
// 返回父节点
return parent[index];
}
然后将两棵树的根节点合并,即完成两个集合的合并:
void unite(int index1, int index2){
// 将变量index1的根节点和变量index2的根节点合并,
// 即让一个根节点作为另一个根节点的父节点即可
parent[find(index1)] = find(index2);
}
上面只是构建并查集的模板,具体问题还需要具体分析。截至目前,在 L e e t C o d e {\rm LeetCode} LeetCode中标签有并查集的题目共有 31 {\rm 31} 31道,且全是中等或困难的题目。使用并查集解题的难点是将题目信息抽象出来形成并查集的初始状态。下一部分将介绍几道 L e e t C o d e {\rm LeetCode} LeetCode中的几道有关并查集的题目。
2. 并查集经典例题
本文主要使用并查集的方法解决问题,具体其他方法可参考题末的 L e e t C o d e {\rm LeetCode} LeetCode官方给出的题解。
2.1 等式方程的可满足性
题目来源 990.等式方程的可满足性
题目描述 给定一个由表示变量之间关系的字符串方程组成的数组,每个字符串方程equations[i]
的长度为
4
4
4,并采用两种不同的形式之一:"a==b"
或a!=b
。在这里,a
和b
是小写字母,且不一定不同,表示但字母的变量名。编写程序,只有当可以将整数分配给变量名,以便满足所有给定的方程时才返回true
;否则返回false
。
如输入方程equations=["a==b", "b!=a"]
,则输出false
。解释:如果我们指定a=1
且b=1
,那么可以满足第一个方程,但不能满足第二个方程;如果我们将a
和b
指定为不同的数,那么可以满足第二个方程,但不能满足第一个方程;输入方程equations
=["a==b", "b==c","c==a"]
,则返回true
。我们可以指定三个变量的值相等,则可以同时满足三个方程等式。
这里,由于题目给定所有变量使用小写字母表示,我们可以将所有出现的小写字母抽离出来成为并查集的初始状态。为了验证等式方程的可满足性,我们可以利用并查集的特性:在合并集合时,将==
两端的变量合并。由于在合并时会调用查找函数,如果 !=
两端的变量处于同一集合则产生矛盾,我们可以直接返回false
。最后,返回true
。首先定义并查集:
class UnionFind {
private:
vector<int> parent;
public:
// 构造函数
UnionFind() {
// 由于变量只有小写字母
parent.resize(26);
// 使用0-25填充parent数组
iota(parent.begin(), parent.end(), 0);
}
// 查找函数
int find(int index) {
// 如果该变量的父节点是其本身
if (index == parent[index]) {
return index;
}
// 以递归方式查找当前变量的父节点以得到最终的根节点
parent[index] = find(parent[index]);
return parent[index];
}
// 合并函数
void unite(int index1, int index2) {
// 将变量1的根节点的父节点指向变量2的根节点
parent[find(index1)] = find(index2);
}
};
bool equationsPossible(vector<string>& equations) {
UnionFind uf;
// 遍历数组使用等式构造连通分量
for (string equation : equations) {
// 等式两端的变量同属一个集合
if (equation[1] == '=') {
int index1 = equation[0] - 'a';
int index2 = equation[1] - 'a';
uf.unite(index1, index2);
}
}
// 遍历数组根据不等式判断是否满足条件
for (string equation : equations) {
// 判断不等式两端的变量
if (equation[1] == '!') {
int index1 = equation[0] - 'a';
int index2 = equation[1] - 'a';
// 如果等式两端变量同属一个联通分量,产生矛盾
if (uf.find(index1) == uf.find(index2)) {
return false;
}
}
}
return true;
}
其他题解 官方题解
2.2 朋友圈
题目来源 547.朋友圈
题目描述 班上有N
名学生,其中有些人是朋友,有些人不是。他们的友谊具有传递性,如如果A
是B
的朋友,B
是C
的朋友,那么我们认为A
也是C
的朋友。所谓的朋友圈,就是所有朋友的集合。给定一个N × N
的矩阵M
,表示班级中中学之间的朋友关系。如果M[i][j] = 1
,则表示学生i
和学生j
互为朋友。编写程序返回该班级中朋友圈的总个数。
这道题的解法和上一题很类似,不过这道题我们需要返回的是最终构成的集合的数量。这就涉及到并查集常解决的另一类问题,即统计图中的连通集的数量。首先定义一个长度为N
的数组,初始化为-1
表示每个同学单独是一个朋友圈。然后遍历二维矩阵,如果M[i][j] = 1
,则我们需要将i
和j
合并。当需要对i
和j
处理时,如果parent[i] = -1
,则表示该同学的关系还没有被处理。令parent[i] = i
和parent[j] = j
表示父节点为其本身。然后,如果二者不同,我们将其合并。最后,我们返回parent
中为-1
的元素个数即为所求。首先定义并查集:
class UnionFind {
public:
int find(vector<int>parent, int i) {
// 找到i所在树的根节点
if (parent[i] == -1) {
return i;
}
// 以递归方式寻找根节点
return find(parent, parent[i]);
}
void unite(vector<int>& parent, int x, int y) {
int xSet = find(parent, x);
int ySet = find(parent, y);
// 如果两个节点不处于同一集合,合并
if (xSet != ySet) {
parent[xSet] = ySet;
}
}
};
int findCircleNum(vector<vector<int>>& M) {
UnionFind uf;
// 特判
int size = M.size();
if (!size) {
return 0;
}
// 用于存储每个同学的信息
vector<int> parent(size, -1);
// 循环
for (int i = 0; i < size; ++i) {
for (int j = 0; j < size; ++j) {
if (M[i][j] == 1 && i != j) {
uf.unite(parent, i, j);
}
}
}
// 计数
int count = 0;
for (int i = 0; i < size; ++i) {
if (parent[i] == -1) {
++count;
}
}
return count;
}
其他题解 官方题解
2.3 相似字符串组
题目来源 839.相似字符串组
题目描述 如果我们交换字符串X
中两个不同位置的字母,使得交换后的字符串与字符串Y
相等,那么称字符串X
和字符串Y
相似。注意,如果两个字符串本身是相等的,那么也认为它们相似。现在给定一个字符串数组,编写程序返回字符串数组中的相似字符串组数。
如输入的字符串数组为["tars", "rats", "arts", "star"]
,则返回结果为
2
2
2。解释如下:第一个字符串tars
的第一个字母t
和第三个字母r
交换,即得到第二个字符串rats
,则第一个字符串和第二个字符串相似;同理,第三个字符串arts
的第一个字母a
和第二个字母r
交换,即得到第二个字符串rats
,则第三个字符串和第二个字符串相似。第四个字符串star
不与任何字符串相似。则最终得到的相似字符串组为
2
2
2。
根据题目要求是得到相似字符串组数,显然是并查集的方法,这个上面两道题都非常类似。即根据某种规则将给定的初始状态分组,得到最终的分组数。针对这道题,规则就是两个字符串是否相似。由于并查集中数组的长度需要根据输入字符串的长度一定,我们将二者写在同一个类中。另外,为了方便,题目还对输入做了如下限制:所有字符串均只包含小写字母;所有的字符串均为彼此的异位词,即每个字符串的总字母一样,但字母的位置不同。
class Solution {
public:
int numSimilarGroups(vector<string>& A) {
// 特判
int size = A.size();
if (size <= 1) {
return size;
}
// 并查集数组,用于存储父节点的索引
vector<int> parent(size);
// 建立并查集
for (int i = 0; i < size; ++i) {
parent[i] = i;
}
// 循环遍历
for (int i = 0; i < size; ++i) {
for (int j = 0; j < size; ++j) {
if (isSimilar(A[i], A[j])) {
unite(parent, i, j);
}
}
}
// 统计
int cnt = 0;
for (int i = 0; i < size; ++i) {
if (parent[i] == i) {
++cnt;
}
}
return cnt;
}
// 并查集-查找
int find(vector<int> parent, int index) {
if (parent[index] == index) {
return index;
}
parent[index] = find(parent, parent[index]);
return parent[index];
}
// 并查集-合并
void unite(vector<int>& parent, int i, int j) {
parent[find(parent, i)] = find(parent, j);
}
// 判断两个字符串是否相似
bool isSimilar(string s1, string s2) {
// 不同的字母数
int count = 0;
for (int i = 0; i < s1.length(); ++i) {
if (s1[i] != s2[i]) {
++count;
if (count > 2) {
return false;
}
}
}
return true;
}
};
其他题解 官方题解
3. 总结
在计算机科学中,并查集是一种树型的数据结构,用于处理一些不相交集合的合并和查找问题。其中在查找函数中,我们需要确定当前元素属于哪一个集合;在合并函数中,我们将具有满足相似性规则的元素合并。因此,使用并查集解答的题目的特点是求组
的个数,如上面题目中的朋友圈数和相似字符串组数等。最后,给出
L
e
e
t
C
o
d
e
{\rm LeetCode}
LeetCode中关于并查集的题目,题目均为中等难度和困难难度。
参考
- https://leetcode-cn.com/tag/union-find/.