并查集
是一种巧妙的算法思想,主要解决连通性问题
的场景,比如:一组对象被划分成了若干个区域,求解被划分区域的数量,或者求解如何将这几个区域连接起来。
文章目录
1. 并查集(Union-Find)算法介绍
1.1 动态连通性
问题的输入是一列整数对,其中每个整数都表示一个某种类型的对象,一对整数p和q可以被理解为“p和q是相连的”。我们假设“相连”是一种等价关系,这意味着它具有:
自反性
:p和p是相连的对称性
:如果p和q是相连的,那么q和p也是相连的传递性
:如果p和q是相连的且q和r是相连的,那么p和r也是相连的
我们需要设计一个数据结构来保存程序已知的所有整数对的足够多的信息,并用它们来判断一对象是否是相连的。我们将这个问题通俗地叫做动态连通性
问题。
之后使用网络方面的术语,将对象
称为触点
,将整数对
称为连接
,将等价类
称为连通分量
或是简称分量。简单起见,假设我们有用0到N - 1 整数所表示的N个触点。这样做并不会降低算法的通用性。
1.2 union-find算法API
用一份API来封装所需的基本操作:初始化、连接两个触点、判断包含某个触点的分量、判断两个触点是否存在于同一个分量之中以及返回所有分量的数量。
- 用一个以触点为索引的
数组id[]
作为基本数据结构来表示所有分量。一开始,我们有N个分量,每个触点都构成了一个只含有它自己的分量,因此我们将id[i]的值初始化为i,其中i在0到N-1之间。 - 对于每个触点i,我们将
find()
方法用来判定它所在的分量所需的信息保存在id[i]
之中。 connected()方法
的实现只用一条语句find(p) == find(q)
,它返回一个布尔值,我们在所有方法的实现中都会用到connected()方法。
find()方法和union()方法的实现是以下内容要讨论的重点。
2 union-find的实现
public class UF {
private int[] id; //分量id(以触点作为索引)
private int count; //分量数量
public UF(int N) {
//初始化分量id数组,每个点指向自己
count = N;
id = new int[N];
for(int i = 0; i < N; i++) {
id[i] = i;
}
}
public int count() {
// 返回连通分量的个数
return count;
}
// 判断 p 和 q 是否在同一个集合中
public boolean connected(int p, int q) {
return find(p) == find(q);
}
public int find(int p) {
//见 quick-find、qucik-union、加权uick-union
return -1; //省略此条代码
}
public void union(int p, int q) {
//见 quick-find、qucik-union、加权uick-union
}
}
union-find的成本模型
:在研究实现union-find的API的各种算法时,我们统计的是数组的访问次数
(访问任意数组元素的次数,无论读写)。
2.1 quick-find算法
此方法保证当且仅当id[p] 等于 id[q]时p和q是连通
的。
- 在同一个连通分量中的所有触点在id[]中的值必须全部相同。这意味着connected(p, q)只需要判断id[p] == id[q],当且仅当p和q在同一连通分量之中该语句才会返回true。
- 为了调用union(p, q)确保这一点,我们首先要检查它们是否已经存于同一个连通分量之中。如果存在于同一分量中我们不需要采取任何行动,否则我们面对的情况就是p所在的连通分量中的所有触点的id[]值均为同一个值,而q所在的连通分量中的所有触点的id[]均为另一个值。
- 要将两个分量合二为一,我们必须将两个集合中所有触点对应的id[]元素变为同一个值。为此,我们需要遍历整个数组,将所有和id[p]相等的元素的值变为id[q]的值。我们也可以将所有和id[q]相等的元素的值变为id[p]的值,两者均可。
public class QuickFindUF {
private int[] id; //分量id(以触点作为索引)
private int count; //分量数量
public QuickFindUF(int N) {
//初始化分量id数组
count = N;
id = new int[N];
for(int i = 0; i < N; i++) {
id[i] = i;
}
}
public int count() {
return count;
}
public boolean connected(int p, int q) {
return find(p) == find(q);
}
public int find(int p) {
return id[p];
}
public void union(int p, int q) {
//将p和q归并到相同的分量中
int pID = find(p);
int qID = find(q);
//如果p和q已经在相同的分量之中则不需要采取任何行动
if(pID == qID) return;
//将p的分量重命名为q的名称
for(int i = 0; i < id.length; i++) {
if(id[i] == pID) id[i] = qID;
}
count--;
}
}
2.2 quick-union算法
此算法重点提高union()方法
的速度,它和quick-find算法是互补的。
- 每个触点对应的id[]元素都是同一个分量中另一个触点的名称(也可能是它自己)----我们将这种联系称为链接。
- 在实现find()方法时,我们从给定的触点开始,由它的链接得到另一个触点,再由这个触点到达第三个触点,如此继续跟随着链接直到到达一个根触点,即链接指向自己的触点(这样的触点必然存在)。当且仅当分别由两个触点开始的这个过程到达了同一个根触点时它们存在于同一个连通分量中。
- 为了保证这个过程的有效性,我们需要union(p, q)来保证这一点。它的实现很简单:我们由p和q的链接分别找到它们的根触点,然后只需将一个根触点连接到另一个根触点即可将一个分量重命名为另一个分量,因此这个算法叫做quick-union。
public class QuickUnionUF {
private int[] id; //分量id(以触点作为索引)
private int count; //分量数量
public QuickUnionUF(int N) {
//初始化分量id数组
count = N;
id = new int[N];
for(int i = 0; i < N; i++) {
id[i] = i;
}
}
public int count() {
return count;
}
public boolean connected(int p, int q) {
return find(p) == find(q);
}
public int find(int p) {
//找出分量的名称
while(p != id[p]) p = id[p];
return p;
}
public void union(int p, int q) {
//将p和q的根节点统一
int pRoot = find(p);
int qRoot = find(q);
if(pRoot == qRoot) return;
id[pRoot] = qRoot;
// id[find(p)] = find(q);
count--;
}
}
2.3 加权quick-union算法
与其在union()中随意将一棵树连接到另一棵树,我们现在会记录每一棵树的大小并总是将较小的树连接到较大的树上
。
这项改动需要添加一个数组和一些代码来记录树中的结点数
,它能够大大改进算法的效率,提高了查询根触点的速度。该算法构造的树的高度远远小于未加权的版本所构造的树的高度。
public class WeightedQuickUnionUF {
private int[] id; //父链接数组(由触点索引)
private int[] sz; //(由触点索引的)各个根节点所对应的分量的大小
private int count; //连通分量的数量
public WeightedQuickUnionUF(int N) {
count = N;
id = new int[N];
for(int i = 0; i < N; i++) id[i] = i;
sz = new int[N];
for(int i = 0; i < N; i++) sz[i] = 1;
}
public int count() {
return count;
}
public boolean connected(int p, int q) {
return find(p) == find(q);
}
public int find(int p) {
//跟随连接找到根节点
while(p != id[p]) p = id[p];
return p;
}
public void union(int p, int q) {
int pRoot = find(p);
int qRoot = find(q);
if(pRoot == qRoot) return;
//将小树的根节点连接到大树的根节点
if(sz[pRoot] < sz[qRoot]) {
id[pRoot] = qRoot;
sz[qRoot] += sz[pRoot];
}else {
id[qRoot] = id[pRoot];
sz[pRoot] += sz[qRoot];
}
count--;
}
}
2.4 使用路径压缩的加权quick-union算法
理想情况下,我们都希望每个节点都直接链接到它的根节点上,但我们又不想像quick-union算法那样通过修改大量链接来做到这一点。我们接近这种理想状态的方式很简单,就是在检查节点的同时将他们直接链接到根节点。
这种方法的实现很容易,而且这些树并没有阻止我们进行这种修改的特殊结构:如果这么做能够改进算法的效率,我们就应该实现它。
要实现路径压缩,只需要为find()添加一个循环
,将在路径上遇到的所有结点都直接链接到根节点
。我们所得到的结果是几乎完全扁平化的树,它和quick-find算法理想情况下所得到的树非常接近。这种方法既简单又高效,但在实际情况下已经不太可能对加权quick-union算法继续进行任何改进了。
public class PathCondenseWeightedQuickUnionUF {
private int[] id; //父链接数组(由触点索引)
private int[] sz; //(由触点索引的)各个根节点所对应的分量的大小
private int count; //连通分量的数量
public PathCondenseWeightedQuickUnionUF(int N) {
count = N;
id = new int[N];
for(int i = 0; i < N; i++) id[i] = i;
sz = new int[N];
for(int i = 0; i < N; i++) sz[i] = 1;
}
public int count() {
return count;
}
public boolean connected(int p, int q) {
return find(p) == find(q);
}
/*递归版本
public int find(int p) {
if(id[p] == p) return p;
id[p] = find(id[p]);
return id[p];
}
*/
public int find(int p) {
int root = p;
while (root != id[root]) {
root = id[root];
}
while (id[p] != root) {
int temp = p;
p = id[p];
id[temp] = root;
}
return root;
}
public void union(int p, int q) {
int pRoot = find(p);
int qRoot = find(q);
if(pRoot == qRoot) return;
//将小树的根节点连接到大树的根节点
if(sz[pRoot] < sz[qRoot]) {
id[pRoot] = qRoot;
sz[qRoot] += sz[pRoot];
}else {
id[qRoot] = id[pRoot];
sz[pRoot] += sz[qRoot];
}
count--;
}
}
2.5 算法比较
各种union-find算法的性能特点(存在N个触点时成本的增长数量级(最坏情况下))
参考: