简介
并查集是一种树型的数据结构,用于处理一些不相交集合(disjoint sets)的合并及查询问题。常常在使用中以森林来表示。
其表示方法有以下特点:
- 类似于堆,用下标表示关系
- 双亲表示法
双亲表示法即使用数组,对每个结点各增加一个表示其双亲位置的变量。如下图:用双亲表示法表示一棵树
其中根结点无双亲,parent表示为-1
- 一个位置的值是负数,那么它就是树的根,这个负数的绝对值就是这棵树的结点个数。
- 一个位置的值是非负数,那么它就是双亲的下标。
具体例子:
假设有三支小队共10个人出门春游,给这10个人分别编号0~9,然后将{0, 6, 7, 8}分到第一组,{1, 4, 9}分到第二组,{2, 3, 5}分到第三组。这样分出三个小组,然后选0, 1, 2担任各小组的组长。
树形表示:
数组表示:
这里的下标就是data
将第二组合并到第一组:
并查集实现
-
初始化:每个结点都是一棵树,数组初始化为 -1
-
合并:将x2所在树合并到x1上
-
找根:给定一个结点,找其根结点
-
判断两个结点是否在一棵树上
-
返回并查集中树的个数
具体实现也不难,关键在于找根。有时候合并可能会导致一棵树变得很高,这样会对找根的效率产生影响,为了让树变矮,需要用到路径压缩算法。
class UnionFindSet
{
public:
UnionFindSet(size_t n)
: _ufs(n, -1)
{}
// 合并
void Union(int x1, int x2)
{
// 如果两个结点在同一棵树,则没必要合并
int root1 = FindRoot(x1);
int root2 = FindRoot(x2);
if (root1 == root2) return;
// 小的往大的合并
if (_ufs[root1] > _ufs[root2]) swap(root1, root2);
_ufs[root1] += _ufs[root2];
_ufs[root2] = root1;
}
// 找根
int FindRoot(int x)
{
// 找双亲,直到一个位置的值为负数,则这个位置就是根
int root = x;
while (_ufs[root] >= 0)
{
root = _ufs[root];
}
// 路径压缩
while (_ufs[x] >= 0)
{
int parent = _ufs[x];
_ufs[x] = root;
x = parent;
}
return root;
}
// 判断两个结点是否在一棵树上
bool InSet(int x1, int x2)
{
return FindRoot(x1) == FindRoot(x2);
}
// 有多少棵树,也就是数组中负数的个数
size_t SetSize()
{
size_t size = 0;
for (size_t i = 0; i < _ufs.size(); ++i)
{
if (_ufs[i] < 0) ++size;
}
return size;
}
private:
vector<int> _ufs;
};
关于合并,我们建议把小的树往大的树上并,因为被并的树每个结点的深度都会+1,我们希望让尽量少的结点深度增加。
关于路径压缩,这里选择在找根的时候进行,当我们找到某个结点的 root 后,再走一遍刚刚找根的这个路径,并把路径上的所有结点的双亲结点都设为 root
题目
并查集特别适合于做具有传递性关系的题目,如一棵树表示一个家庭,a
和 b
是一家人,c
和 d
是一家人,现在告诉你 b
和 c
的结婚了,那么 a
b
c
d
就都是一家人了,它们两个家庭可以合并为一个家庭,最后树的个数即为家庭个数。
省份数量
有
n
个城市,其中一些彼此相连,另一些没有相连。如果城市a
与城市b
直接相连,且城市b
与城市c
直接相连,那么城市a
与城市c
间接相连。省份 是一组直接或间接相连的城市,组内不含其他没有相连的城市。
给你一个
n x n
的矩阵isConnected
,其中isConnected[i][j] = 1
表示第i
个城市和第j
个城市直接相连,而isConnected[i][j] = 0
表示二者不直接相连。返回矩阵中 省份 的数量。
示例 1:
输入:isConnected = [[1,1,0],[1,1,0],[0,0,1]] 输出:2
示例 2:
输入:isConnected = [[1,0,0],[0,1,0],[0,0,1]] 输出:3
提示:
1 <= n <= 200
n == isConnected.length
n == isConnected[i].length
isConnected[i][j]
为1
或0
isConnected[i][i] == 1
isConnected[i][j] == isConnected[j][i]
将我们写好的并查集拿过来。
将相连的城市在并查集中合并,最后查看有多少棵树就有多少个省份
class UnionFindSet
{
public:
UnionFindSet(size_t n)
: _ufs(n, -1)
{}
// 合并
void Union(int x1, int x2)
{
// 如果两个结点在同一棵树,则没必要合并
int root1 = FindRoot(x1);
int root2 = FindRoot(x2);
if (root1 == root2) return;
// 小的往大的合并
if (_ufs[root1] > _ufs[root2]) swap(root1, root2);
_ufs[root1] += _ufs[root2];
_ufs[root2] = root1;
}
// 找根
int FindRoot(int x)
{
// 找双亲,直到一个位置的值为负数,则这个位置就是根
int root = x;
while (_ufs[root] >= 0)
{
root = _ufs[root];
}
// 路径压缩
while (_ufs[x] >= 0)
{
int parent = _ufs[x];
_ufs[x] = root;
x = parent;
}
return root;
}
// 判断两个结点是否在一棵树上
bool InSet(int x1, int x2)
{
return FindRoot(x1) == FindRoot(x2);
}
// 有多少棵树,也就是数组中负数的个数
size_t SetSize()
{
size_t size = 0;
for (size_t i = 0; i < _ufs.size(); ++i)
{
if (_ufs[i] < 0) ++size;
}
return size;
}
private:
vector<int> _ufs;
};
class Solution {
public:
int findCircleNum(vector<vector<int>>& isConnected) {
UnionFindSet ufs(isConnected.size());
for (int i = 0; i < isConnected.size(); ++i)
{
for (int j = 0; j < isConnected[i].size(); ++j)
{
if (isConnected[i][j] == 1)
{
ufs.Union(i, j);
}
}
}
return ufs.SetSize();
}
};
一般做题我们不去手撸一个并查集类,代码量太大了。如下采用面向过程的思想完成题目即可。
class Solution {
public:
int findCircleNum(vector<vector<int>>& isConnected) {
vector<int> ufs(isConnected.size(), -1);
auto findRoot = [&ufs](int x)
{
while (ufs[x] >= 0)
x = ufs[x];
return x;
};
for (int i = 0; i < isConnected.size(); ++i)
{
for (int j = 0; j < isConnected[i].size(); ++j)
{
if (isConnected[i][j] == 1)
{
int root1 = findRoot(i);
int root2 = findRoot(j);
if (root1 != root2)
{
ufs[root1] += ufs[root2];
ufs[root2] = root1;
}
}
}
}
int res = 0;
for (auto e : ufs)
{
if (e < 0) ++res;
}
return res;
}
};
等式方程的可满足性
给定一个由表示变量之间关系的字符串方程组成的数组,每个字符串方程
equations[i]
的长度为4
,并采用两种不同的形式之一:"a==b"
或"a!=b"
。在这里,a 和 b 是小写字母(不一定不同),表示单字母变量名。只有当可以将整数分配给变量名,以便满足所有给定的方程时才返回
true
,否则返回false
。示例 1:
输入:["a==b","b!=a"] 输出:false 解释:如果我们指定,a = 1 且 b = 1,那么可以满足第一个方程,但无法满足第二个方程。没有办法分配变量同时满足这两个方程。
示例 2:
输入:["b==a","a==b"] 输出:true 解释:我们可以指定 a = 1 且 b = 1 以满足满足这两个方程。
示例 3:
输入:["a==b","b==c","a==c"] 输出:true
示例 4:
输入:["a==b","b!=c","c==a"] 输出:false
示例 5:
输入:["c==c","b==d","x!=z"] 输出:true
提示:
1 <= equations.length <= 500
equations[i].length == 4
equations[i][0]
和equations[i][3]
是小写字母equations[i][1]
要么是'='
,要么是'!'
equations[i][2]
是'='
依然是一道并查集的简单题,a == b 可以理解为 a 和 b 在一棵树上,a != b 则表示 a b不在一棵树上,我们先遍历等式方程,将所有相等的变量放在一棵树中,然后遍历不等式方程,如果出现矛盾,则返回 false。
class Solution {
public:
bool equationsPossible(vector<string>& equations) {
int n = equations.size();
vector<int> ufs(26, -1);
auto findRoot = [&ufs](int x)
{
while (ufs[x] >= 0)
x = ufs[x];
return x;
};
for (int i = 0; i < n; ++i)
{
if (equations[i][1] == '=')
{
int root1 = findRoot(equations[i][0] - 'a');
int root2 = findRoot(equations[i][3] - 'a');
if (root1 != root2)
{
ufs[root1] += ufs[root2];
ufs[root2] = root1;
}
}
}
for (int i = 0; i < n; ++i)
{
if (equations[i][1] == '!')
{
int root1 = findRoot(equations[i][0] - 'a');
int root2 = findRoot(equations[i][3] - 'a');
if (root1 == root2)
{
return false;
}
}
}
return true;
}
};
2023-1-9 更新:
C++ 模板
上面的是并查集的递推+类封装的实现,我们平时做题的时候完全可以写得更简洁一些。
基础版:
-
p[i]
表示编号为i
的结点的父结点的编号,如果i
是根结点,那么p[i] == i
。这一点和上面的实现大不相同。 -
下面的
find
函数是递归的写法,可以返回一个结点所在集合的根,同时实现路径压缩。
const int N = 100010;
int p[N];
int find(int x) {
return p[x] == x ? x : p[x] = find(p[x]);
}
void unite(int x, int y) {
p[find(x)] = find(y);
}
初始化可以让n个元素各自为一个集合:
for (int i = 0; i < n; ++i) p[i] = i;
带有查找集合元素个数的功能的版本:
- 增加一个
cnt
数组:cnt[i]
表示i
结点所在集合的元素个数(i
为根结点)
此时 unite
需要判断 x
和 y
是否在一个集合。否则,若 x
和 y
在同一个以 i
为根结点的集合,cnt[i] += cnt[i]
,集合大小计算出错。
const int N = 100010;
int p[N], cnt[N];
int find(int x) {
return p[x] == x ? x : p[x] = find(p[x]);
}
void unite(int x, int y) {
x = find(x), y = find(y);
if (x == y) return;
cnt[x] += cnt[y];
p[y] = x;
}
初始化 cnt
数组里的元素都初始化成 1:
for (int i = 1; i <= n; ++i) {
p[i] = i;
cnt[i] = 1;
}
启发式合并优化的版本:
const int N = 100010;
int p[N], cnt[N];
int find(int x) {
return p[x] == x ? x : p[x] = find(p[x]);
}
void unite(int x, int y) {
x = find(x), y = find(y);
if (x == y) return;
if (cnt[x] < cnt[y]) swap(x, y);
cnt[x] += cnt[y];
p[y] = x;
}
只要在合并的时候加一行 if (cnt[x] < cnt[y]) swap(x, y);
就行了。