并查集

问题背景

当下,我们每个人都有自己的亲感,但你能保证认识你所有的亲戚吗?如何确定一个陌生人是否是你的亲威呢? 如果有这样一个前提:拥有相同祖先的两个人是亲威。那么并查集就可以帮助我们判断某个陌生人是否是我们的亲戚。 通俗地讲一个故事:儿个家族进行宴会,但是家族音遍长寿,所以人数众多由于长时间的分离以及年龄的增长,这些人逐渐忘掉了自己的亲人,只记得自己的爸爸是谁了,而最长者(称为「祖先」)的父亲已经去世,他具知道自己是祖先。为了确定自己是哪个家族,他们想出了个办法,只要问自己的爸爸是不是祖先,一层一层的向上问,直到问到祖先。如果要判断两人是否在同一家族,只要看两人的祖先是不是同一人就可以了。

并查集应用

  • ”边带权“并查集与“扩展域”并查集
  • 判断图是否连通
  • 最小生成树 Kruskal 算法
  • 最近公共祖先LCA

并查集定义:

并查集是一种可以动态维护若干个不重叠的集合,并支持合并查询的树形数据结构。

主要有以下两个基本操作:

  • find,查询一个==元素==属于哪一个集合。
  • merge,把两个集合合并为一个集合。

ps: 元素的表现形式是多样的,但最终会抽象为数字

如何表示一个集合?

第一种:”代表元“ 法,每个集合选择一个固定的元素,树形结构中自然会想到树根作为该颗树的代表。

第二种:定义一个数组fa[]表示某个元素的父亲节点,特别地,根节点的父亲节点是自身(需要初始化)。

如何合并两个集合?

集合 a 的代表元是 a, fa[a] = a,集合 b 的代表元是 b,fa[b] = b

现在让集合 a 的父亲变为 b,使fa[a] = b,从而实现合并两个集合的操作。

这里我们具体问题具体分析,这里只是讲一下思想,方便了解。

在合并时,我们按秩合并:==“秩” = 树的深度或者集合的大小,把元素少的集合合并到元素多的集合上。==

路径压缩

集合中如果要查寻节点4,5的集合代表元,要向上搜索很多次,效率低。所以需要考虑路径压缩,在查询find的过程中,让集合中的每一个元素的父亲都变为根节点。

例如我们按照DFS来路径压缩。

不使用了路径压缩

1
2
3
4
5
6
7
//找到 x 的根节点
int find(int x){
if(x == fa[x])//本身是父亲节点
return x
else //否则向上递归查找
return find(fa[x]);
}

使用路径压缩

1
2
3
4
5
6
//找到 x 的根节点
int find(int x){
if(x != fa[x])
fa[x] = find(fa[x]); //返回的就是根节点
return fa[x]; //返回的就是当前这个集合的根节点
}

如何实现merge()函数

1
2
3
4
5
void merge(int x, int y){
x = find(x); //寻找 x 的祖先,在寻找的过程中路径压缩
y = find(y);
fa[x] = y; //将 x 的祖先变为 y,在一个集合中只有祖先能代表这个集合
}

初始化init()

1
2
3
4
5
void init(int n){
for(int i = 0; i < n; i++){
fa[i] = i; //初始化每个节点的父亲节点为本身
}
}

例题

P1551 亲戚

题目背景

若某个家族人员过于庞大,要判断两个是否是亲戚,确实还很不容易,现在给出某个亲戚关系图,求任意给出的两个人是否具有亲戚关系。

题目描述

规定:x 和 y 是亲戚,y 和 z 是亲戚,那么 x 和 z 也是亲戚。如果 x,y 是亲戚,那么 x 的亲戚都是 y的亲戚,y 的亲戚也都是 x 的亲戚。

输入格式

第一行:三个整数 n , m p,(n,m,p≤5000),分别表示有 n 个人,m个亲戚关系,询问 p 对亲戚关系。

以下 m 行:每行两个数 \[M_i,M_j, 1 ≤ M_i,M_j ≤ N \],表示 \[M_i\]\[M_j\]具有亲戚关系。

接下来 p行:每行两个数 \[p_i,p_j\],询问 \[p_i\]\[p_j\]是否具有亲戚关系。

输出格式

p 行,每行一个 YesNo。表示第 ii 个询问的答案为“具有”或“不具有”亲戚关系。

输入 #1

6 5 3

1 2

1 5

3 4

5 2

1 3

1 4

2 3

5 6

输出 #1

Yes

Yes

No

思路:

典型的并查集,我们可以注意到描述中说的,x 和 y 是亲戚,y 和 z 是亲戚,那么 x 和 z 也是亲戚。这表示了具有传递性,要具体问题具体分析。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
#include <bits/stdc++.h>
using namespace std;

typedef long long ll;
typedef long double ld;
typedef pair<int, int> pii;
typedef pair<ll, ll> pll;
typedef vector<int> vi;

const int N = 1e5 + 10;
int fa[N];
int n,m,p;

//寻找 x 的根节点
int find(int x){
//判断 x 是不是根节点
if(x != fa[x]){
//路径压缩
fa[x] = find(fa[x]);
}
return fa[x];
}

void merge(int x, int y){
//找到 x 和 y 的祖先
x = find(x);
y = find(y);
//这里可以进一步优化,按秩合并
fa[x] = y;
}

void init(){
for(int i = 1; i <= n; i++){
fa[i] = i;
}
}

int main(){
cin >> n >> m >> p;
init();
while(m--){
int x, y;
//x 和 y 是亲戚
cin >> x >> y;
merge(x,y);
}
while(p--){
int x, y;
cin >> x >> y;
//判断 x 和 y 的根节点是否相同
if(find(x) == find(y)){
cout << "Yes\n";
}else{
cout <<"No\n";
}
}
return 0;
}

130. 被围绕的区域

给你一个 m x n 的矩阵 board ,由若干字符 'X''O' ,找到所有被 'X' 围绕的区域,并将这些区域里所有的 'O''X' 填充。

###示例:

输入:board = [["X","X","X","X"],["X","O","O","X"],["X","X","O","X"],["X","O","X","X"]]

输出:[["X","X","X","X"],["X","X","X","X"],["X","X","X","X"],["X","O","X","X"]]

解释:被围绕的区间不会存在于边界上,换句话说,任何边界上的 'O' 都不会被填充为 'X'。 任何不在边界上,或不与边界上的 'O' 相连的 'O' 最终都会被填充为 'X'。如果两个元素在水平或垂直方向相邻,则称它们是“相连”的。

思路:

常规方法就是DFS,从边界开始搜,和前面的岛屿问题类似。具体可以看一下写的岛屿部分。

这里直接给出代码,我们具体将并查集做法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
class Solution {
public:
void solve(vector<vector<char>>& board) {
int m = board.size(), n = board[0].size();
if(m == 0) return;
for(int i = 0; i < n; i++){
//遍历上边
dfs(board, 0, i);
//遍历下边
dfs(board, m - 1, i);
}
for(int i = 0; i < m; i++){
//遍历左边
dfs(board, i, 0);
//遍历右边
dfs(board, i, n - 1);
}
//遍历整个区域,如果为 A 则表示没有被包围,为 O 则表示被包围
for(int i = 0; i < m; i++){
for(int j = 0; j < n; j++){
if(board[i][j] == 'A'){
board[i][j] = 'O';
}else if(board[i][j] == 'O'){
board[i][j] = 'X';
}
}
}
}
void dfs(vector<vector<char> >& board, int x, int y){
int m = board.size(), n = board[0].size();
if(x < 0 || x >= m || y < 0 || y >= n) return;
if(board[x][y] != 'O') return;
//变为A
board[x][y] = 'A';
dfs(board, x + 1, y);
dfs(board, x - 1, y);
dfs(board, x, y + 1);
dfs(board, x, y - 1);
}
};

并查集就是定义一个节点,把边界的O和这个根节点连通,

然后再遍历整个 board,那些和 dummy 不连通的 O 就是被围绕的区域,需要被替换。

这里需要用到把二维数组映射为一维数组,(x,y) = x * n + y

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
class Solution {
public:
int m, n;
vector<int> fa;
void init(int len){
fa.resize(len);
for(int i = 0; i < len; i++){
fa[i] = i;
}
}
int find(int x){
if(x != fa[x])
fa[x] = find(fa[x]);
return fa[x];
}
void to_union(int x, int y){
x = find(x);
y = find(y);
fa[x] = y;
}
void solve(vector<vector<char> >& board) {
m = board.size(), n = board[0].size();
init(n * m + 1);
//定义根节点
int dummy = m * n;
//将首列和末列的 O 与 dummy 连通
for(int i = 0; i < m; i++){
if(board[i][0] == 'O'){
//连通,把二维映射为一维
to_union(i * n, dummy);
}
if(board[i][n - 1] == 'O'){
to_union(i * n + n - 1, dummy);
}
}
//将首行和末行的 O 与 dummy 连通
for(int i = 0; i < n; i++){
if(board[0][i] == 'O'){
to_union(i, dummy);
}
if(board[m - 1][i] == 'O'){
to_union(n * (m - 1) + i, dummy);
}
}
//搜索上下左右的 O
int dx[4] = {0,0,1,-1};
int dy[4] = {1,-1,0,0};
for(int i = 1; i < m - 1; i++){
for(int j = 1; j < n - 1; j++){
if(board[i][j] == 'O'){
//将此 O 与上下左右的 O 连通
for(int k = 0; k < 4; k++){
int x = i + dx[k];
int y = j + dy[k];
if(board[x][y] == 'O'){
to_union(x * n + y, i * n + j);
}
}
}
}
}
//所有不和 dummy 连通的 O 都要被替换
for(int i = 1; i < m - 1; i++){
for(int j = 1; j < n - 1; j++){
if(find(dummy) != find(i * n + j)){
board[i][j] = 'X';
}
}
}
}
};