并查集

并查集

一、什么是并查集?

并查集是一种简单的集合表示。

它支持以下 3 种操作:

  • Initial(S):将集合 S 中的所有元素初始化为一个个单元素集合
  • Union(S, Root1, Root2):把集合 S 中的子集合 Root2 并入子集合 Root1 中
  • Find(S, x):查找集合 S 中单元素 x 所在的子集合,并返回子集合的名字

通常用树表示每个子集合,子集合的合并就是树的合并。

子集合 Root2 合并到子集合 Root1,只需要把 Root2 的根节点指向 Root1 的根节点即可。

二、为什么要用并查集?

  • 并查集主要用于解决一些元素分组的问题
  • 并查集可用于管理一系列不相交的集合,执行集合的合并以及搜索元素所在集合

三、如何实现并查集?

3.1 集合初始化 Initial(S)

1
2
3
4
5
void initial (int[] s) {
for (int i = 0; i < s.length; i++) {
s[i] = i;
}
}

初始化后单个元素就是一个集合

1
2
3
4
5
6
7
8
9
集合 S 存储:

[0, 1, 2, 3, 4]

------------------------------------------

集合示意图:
___ ___ ___ ___ ___
| 0 | | 1 | | 2 | | 3 | | 4 |

3.2 子集合合并 Union(S, Root1, Root2)

1
2
3
void union(int[] s, int root1, int root2) {
s[root2] = root1;
}

比如合并 01 得到子集合 0

1
2
3
4
5
6
7
8
9
10
11
12
13
集合 S 存储:

[0, 0, 2, 3, 4]

------------------------------------------

集合示意图:
___ ___ ___ ___
| 0 | | 2 | | 3 | | 4 |
^
|
___
| 1 |

再合并 34 得到子集合 3

1
2
3
4
5
6
7
8
9
10
11
12
13
集合 S 存储:

[0, 0, 2, 3, 3]

------------------------------------------

集合示意图:
___ ___ ___
| 0 | | 2 | | 3 |
^ ^
| |
___ ___
| 1 | | 4 |

还可以把子集合 0 和子集合 3 也合并起来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
集合 S 存储:

[0, 0, 2, 0, 3]

------------------------------------------

集合示意图:
___ ___
| 0 | | 2 |
^
/ \
/ \
___ ___
| 1 | | 3 |
^
|
___
| 4 |

3.3 查找子集合 Find(S, x)

1
2
3
4
5
6
int find(int[] s, int x) {
while (s[x] != x) {
x = s[x];
}
return x;
}

比如查找 4 所在集合时,过程是这样的:

1
2
3
4
5
  ___________________   _____
| | | |
v | v |
[0, 0, 2, 0, 3]

一直往上找,直到根节点为止,就能得到 4 所在的集合。

3.4 元素合并 Union(S, X1, X2)

判断两个元素是否属于同一个集合,只需要看它们的根节点是否相同即可。

  • 合并两个元素时,找到它们的根节点合并即可

所以上面的子集合合并代码可以改成这样:

1
2
3
void union(int[] s, int x1, int x2) {
s[find(x2)] = find(x1);
}

四、路径压缩

简单的并查集,在某些场景下的性能是比较差的,例如:

  • 链条变得越来越长的时候,查找根节点也变得越来越耗时

比如,下面查找元素 5 所在的子集合时,每次都需要经过好几个节点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
 ___
| 1 |
^
|
___
| 3 |
^
|
___
| 4 |
^
|
___
| 5 |

在并查集链比较长的时候,每次搜索元素的根节点,都比较耗时。

那怎么解决这个问题呢?

  • 并查集中只关注根节点,因此可以采用路径压缩,将元素直接指向根节点

而实现的方法也很简单,就是:

  • 在查询时,把路径上所有节点的父节点都指向根节点

代码上实现的话,可以采用递归,类似这样:

1
2
3
4
5
6
7
8
int find(int[] s, int x) {
if (s[x] == x) {
return x;
} else {
s[x] = find(s, s[x]);
return s[x];
}
}

相当于把上面的一条直链,变成了多叉树:

1
2
3
4
5
6
7
8
       ___
| 1 |
^
/ | \
/ | \
/ | \
___ ___ ___
| 3 | | 4 | | 5 |

经过路径压缩后,就基本解决了链条过长引起的性能问题。

五、按秩合并

路径压缩虽然能解决查询时链条过长的问题,但是路径压缩是在查询时才做的。

  • 当没有查询时,路径不会压缩,还是会出现链路很长的情况
  • 每次查询时,只压缩一条路径,其他路径的链路还是很长

这种情况要如何避免呢?

在两个子集合进行合并时,只要尽量满足:

  • 把简单的树往复杂的树上合并

就能避免并查集的树深度过大,影响查询的效率。

比如说,2 个子集合长这样:

1
2
3
4
5
6
7
8
9
10
 ___               ___
| 0 | | 3 |
^
|
___
| 1 |
^
|
___
| 4 |

子集合的合并可分为 2 种情况:

  1. 3 合并到 0,树深度是还是 2,深度不变
  2. 0 合并到 3,树深度变成了 3,深度加深了

所以肯定是把深度小的 3 合并到深度大的 0 更好一些,因为这样可以避免节点深度变得更大。

那怎么知道哪个集合的深度更高呢?

  • 采用一个额外的数组,记录每个根节点的深度,合并子集合时同时更新

比如说,采用数组 rank 记录节点深度:

  • 初始化时,把所有元素的 rank(秩)设为默认值 1
  • 合并子集合时,比较两个根节点,把 rank 较小合并到较大的上面

代码实现的话就类似这样:

初始化:

1
2
3
4
5
6
void initial (int[] s, int[] rank) {
for (int i = 0; i < s.length; i++) {
s[i] = i;
rank[i] = 1;
}
}

合并子集合:

1
2
3
4
5
6
7
8
9
10
11
12
13
void union(int[] s, int[] rank, int x1, int x2) {
int root1 = find(x1);
int root2 = find(x2);
if (rank[root1] >= rank[root2]) {
s[root2] = root1;
// 两棵树深度一样的时候,合并起来会加深一层
if (rank[root1] == rank[root2] && root1 != root2) {
rank[root1]++;
}
} else {
s[root1] = root2;
}
}

两棵树深度一样的时候,合并起来会加深一层:

1
2
3
4
5
6
 ___               ___
| 0 | | 3 |
^ ^
| |
___ ___
| 1 | | 4 |
1
2
3
4
5
6
7
8
9
10
11
    ___
| 0 |
^
/ \
/ \
___ ___
| 1 | | 3 |
^
|
___
| 4 |

按秩合并和路径压缩一起用的时候,路径压缩会影响到 rank 值,导致它不准确。

虽然 rank 值不准确,但是不影响 2 者结合一起用。

总结

并查集是一种简单的集合表示,包括 3 种操作:

  • Initial(S):将集合 S 中的所有元素初始化为一个个单元素集合
  • Union(S, Root1, Root2):把集合 S 中的子集合 Root2 并入子集合 Root1 中
  • Find(S, x):查找集合 S 中单元素 x 所在的子集合,并返回子集合的名字

并查集的作用:

  • 并查集主要用于解决一些元素分组的问题
  • 并查集可用于管理一系列不相交的集合,执行集合的合并以及搜索元素所在集合

并查集有 2 种方法优化方式:

  • 路径压缩:查询时压缩节点路径,直接指向根节点
  • 按秩合并:合并时,深度小的合并到深度大的树上面

按秩合并和路径压缩一起用:

  • 路径压缩会影响到按秩合并的 rank 值,导致它不准确
  • 虽然 rank 值不准确了,但是不影响 2 者结合一起用

参考

《数据结构》王道考研系列

https://zhuanlan.zhihu.com/p/93647900

https://blog.csdn.net/weixin_38279101/article/details/112546053

https://blog.csdn.net/qq_40378034/article/details/103224445

https://destiny1020.blog.csdn.net/article/details/7655764

附录

完整代码

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
72
73
74
/**
* 并查集-路径压缩 + 按秩合并
*/
public class UnionFind {

/**
* 父节点存储
*/
private int[] parent;
/**
* 节点深度
*/
private int[] rank;

public UnionFind(int n) {
parent = new int[n];
rank = new int[n];
init(n);
}

/**
* 初始化并查集
*
* @param n 元素个数
*/
private void init(int n) {
for (int i = 0; i < n; i++) {
parent[i] = i;
rank[i] = 1;
}
}

/**
* 查找元素
*
* @param x 指定元素
* @return 元素根节点
*/
public int find(int x) {
if (parent[x] == x) {
return x;
} else {
// 路径压缩,会导致 rank 有误差
parent[x] = find(parent[x]);
return parent[x];
}
}

/**
* 合并元素子集合
*
* @param x1 元素
* @param x2 元素
*/
public void union(int x1, int x2) {
int root1 = find(x1);
int root2 = find(x2);
if (root1 == root2) {
return;
}

// 按秩合并
if (rank[root1] > rank[root2]) {
parent[root2] = root1;
} else if (rank[root1] < rank[root2]) {
parent[root1] = root2;
} else {
// 深度一样的时候,合并起来会加深一层
parent[root2] = root1;
rank[root1]++;
}
}

}
作者

jiaduo

发布于

2022-09-27

更新于

2023-04-03

许可协议