关于并查集,推荐一篇非常好的博文《超有爱的并查集》,不足的是只使用了并查集两个主要优化中的"路径压缩"优化,其中代码主要采用迭代的方式,其实很多情况下采用递归的写法要易懂很多。本文将基于C++实现并查集并使用“按秩合并”和”路径压缩“优化并查集。
并查集的基本概念
并查集(Union Find),在一些有N个元素的集合应用问题中,我们通常是在开始时让每个元素构成一个单元素的集合,然后按一定顺序将属于同一组的元素所在的集合合并,其间要反复查找一个元素在哪个集合中。
对于一个集合 ,我们还可以对集合 进一步划分: ,我们希望能够快速确定 中的两两元素是否属于 的同一子集。
举个栗子, ,如果我们按照一定的规则对集合 进行划分,假设划分后为 , , ,任意给定两个元素,我们如何确定它们是否属于同一子集?某些合并子集后,又如何确定两两关系?基于此类问题便出现了并查集这种数据结构。
并查集的基本结构
并查集是一种树型的数据结构,用于处理一些不相交集合(Disjoint Sets)的合并及查询问题。常常在使用中以森林来表示。
我们可以使用树这种数据结构来表示集合,不同的树就是不同的集合,并查集中包含了多棵树,表示并查集中不同的子集,树的集合是森林,所以并查集属于森林。
有一个联合-查找算法(union-find algorithm)定义了两个用于此数据结构的操作:
- Find:确定元素属于哪一个子集。这个确定方法就是不断向上查找找到它的根节点,它可以被用来确定两个元素是否属于同一子集。
- Union:将两个子集合并成同一个集合。
由于支持这两种操作,一个不相交集也常被称为联合-查找数据结构(union-find data structure)或合并-查找集合(merge-find set)。其他的重要方法,MakeSet,用于建立单元素集合。有了这些方法,许多经典的划分问题可以被解决。
为了更加精确的定义这些方法,需要定义如何表示集合。一种常用的策略是为每个集合选定一个固定的元素,称为代表,以表示整个集合。接着,Find(x) 返回 x 所属集合的代表,而 Union 使用两个集合的代表作为参数。
上图中简单演示了并查集的两个重要操作,一个是FIND,一个UNION。
下面我们看一个简单的实例:
若集合
,最初每一个元素都是一棵树。
对于
操作,我们只需要将两棵树合并,例如合并
得到
,合并3和4得到
对于 操作,我们只需要返回该元素所在树的根节点。所以,如果我们想要比较判断 和 是否在一个集合,只需要通过 和 返回各自的根节点比较是否相等便可。已知树中的一个节点,找到其根节点的时间复杂度为 , 为节点的深度。
我们可以使用数组来表示树,数组下标表示树的一个节点,下标所对应的值表示树的父节点。例如
表示元素
的父节点。对于的集合,我们可以存储在下面的数组中(第二行为数组下标),对于树的根节点,我们规定其元素值为其本身(即父节点为自己)。
并查集森林及基本操作实现
并查集森林是一种将每一个集合以树表示的数据结构。其中每一个节点保存着到它的父节点的引用。
在并查集森林中,每个集合的代表即是集合的根节点。“查找”根据其父节点的引用向根行进直到到底树根。“联合”将两棵树合并到一起,这通过将一棵树的根连接到另一棵树的根。
再来回顾一下基本操作:
1、初始化 : 把每个点所在集合初始化为其自身。
通常来说,这个步骤在每次使用该数据结构时只需要执行一次,无论何种实现方式,时间复杂度均为 。
2、查找 : 查找元素所在的集合,即根节点。
3、合并 : 将两个元素所在的集合合并为一个集合。
通常来说,合并之前,应先判断两个元素是否属于同一集合,这可用上面的“查找”操作实现。
class Disjoint_set {
private:
/* 数组模拟树,其下标所对应的值为相应的父节点 */
vector<int> parent;
public:
/* 初始化 */
Disjoint_set(vector<int> vec);
/* 查找 */
int Find(int item);
/* 合并 */
void Union(int node1, int node2);
};
/* 初始化:初始化每一个元素的根节点都为自身 */
Disjoint_set::Disjoint_set(vector<int> vec)
{
int length = vec.size();
for (int i = 0; i < length; i++)
{
parent[vec[i]] = vec[i];
}
}
/* 查找:查找元素所在的集合,即根节点 */
int Disjoint_set::Find(int item)
{
/* 根节点的父节点就是根节点,因此查询到即返回根节点 */
if (parent[item] == item)
{
return item;
}
else
{
/* 向根节点递归,直到查找到根返回 */
return Find(parent[item]);
}
}
/* 合并: 将两个元素所在的集合合并为一个集合,前提是它们不属于同一个集合 */
void Disjoint_set::Union(int node1, int node2)
{
int set1 = Find(node1);
int set2 = Find(node2);
if(set1 != set2)
{
parent[set1] = set2;
}
}
/* 查找的迭代版本:一个while循环即可解决 */
int Disjoint_set::Find(int item)
{
while (parent[item] != item)
{
item = parent[item];
}
return item;
}
这是并查集森林的最基础的表示方法,这个方法不会比链表法好,这是因为创建的树可能会严重不平衡。
通过上面的实现,可以看出每一次Find操作的时间复杂度为O(N),N为树的高度,由于我们没有对树做特殊处理,所以树的不断合并可能会使树严重不平衡,最坏情况每个节点都只有一个子节点,如下图(第一个点为根节点)
此时Find操作的时间复杂度为O(n),这显然不是我们想要的。针对树的不平衡问题,下面我们引入两种优化。
并查集的两种优化
按秩合并
第一种方法,称为“按秩合并”,即总是将更小的树连接至更大的树上。因为影响运行时间的是树的深度,更小的树添加到更深的树的根上将不会增加秩除非它们的秩相同。在这个算法中,术语“秩”替代了“深度”,因为同时应用了路径压缩时秩将不会与高度相同。单元素的树的秩定义为0,当两棵秩同为
的树联合时,它们的秩
。
void Disjoint_set::Union(int node1, int node2)
{
int set1 = Find(node1);
int set2 = Find(node2);
/* node1 与 node2 属于同一个集合 */
if (set1 == set2)
return;
/* set1的秩大于set2的秩,那么set2合并到set1 */
if (rank[set1] > rank[set2])
{
parent[set2] = set1;
}
/* set1的秩小于set2的秩,那么set1合并到set2 */
else if (rank[set1] < rank[set2])
{
parent[set1] = set2;
}
else
{
parent[set1] = set2;
/* 两个秩相等,可任意合并到一个上,但是需要更新相应的rank */
rank[set2] += 1;
}
}
路径压缩
第二个优化,称为“路径压缩”,是一种在执行“查找”时扁平化树结构的方法。关键在于在路径上的每个节点都可以直接连接到根上;他们都有同样的表示方法。为了达到这样的效果,Find递归地经过树,改变每一个节点的引用到根节点。得到的树将更加扁平,为以后直接或者间接引用节点的操作加速。
int Disjoint_set::Find(int item)
{
if (parent[item] == item)
{
return item;
}
else
{
/* 递归回溯时写入item对应的父节点 */
return parent[item] = Find(parent[item]);
}
}
下面看一下迭代版本,便于理解:
int Disjoint_set::Find(int item)
{
/* 保存item结点,最后更新父节点要使用 */
int child = item;
/* 找到根item */
while (parent[item] != item)
{
item = parent[item];
}
while (child != item)
{
/* 先保存父节点 */
int tmp = parent[child];
/* 父节点指向根item */
parent[child] = item;
child = tmp;
}
return item;
}
下面是路径压缩与按秩合并的完整代码:
class Disjoint_set {
private:
/* 数组模拟树,其下标所对应的值为相应的父节点 */
vector<int> parent;
/* 存储树的秩 */
vector<int> rank;
public:
/* 初始化 */
Disjoint_set(vector<int> vec);
/* 查找 */
int Find(int item);
/* 合并 */
void Union(int node1, int node2);
};
Disjoint_set::Disjoint_set(vector<int> vec)
{
int length = vec.size();
for (int i = 0; i < length; i++)
{
parent[vec[i]] = vec[i];
/* 初始化秩 */
rank[vec[i]] = vec[i];
}
}
int Disjoint_set::Find(int item)
{
if (parent[item] == item)
{
return item;
}
else
{
/* 递归回溯时写入item对应的父节点 */
return parent[item] = Find(parent[item]);
}
}
void Disjoint_set::Union(int node1, int node2)
{
int set1 = Find(node1);
int set2 = Find(node2);
/* node1 与 node2 属于同一个集合 */
if (set1 == set2)
return;
/* set1的秩大于set2的秩,那么set2合并到set1 */
if (rank[set1] > rank[set2])
{
parent[set2] = set1;
}
/* set1的秩小于set2的秩,那么set1合并到set2 */
else if (rank[set1] < rank[set2])
{
parent[set1] = set2;
}
else
{
parent[set1] = set2;
/* 两个秩相等,可任意合并到一个上,但是需要更新相应的rank */
rank[set2] += 1;
}
}
/* 查找的迭代实现 */
int Disjoint_set::Find(int item)
{
/* 保存item结点,最后更新父节点要使用 */
int child = item;
/* 找到根item */
while (parent[item] != item)
{
item = parent[item];
}
while (child != item)
{
/* 先保存父节点 */
int tmp = parent[child];
/* 父节点指向根item */
parent[child] = item;
child = tmp;
}
return item;
}
题目示例
题目:假如已知有 个人和 对好友关系 (存于数字 ) 。 如果两个人是直接或间接的好友 , 则认为他们属于同一个朋友圈,请写程序求出这 n 个人里一共有多少个朋友圈。 假如: , , ,表示有 5 个人,1 和 2 是好友,2 和 3 是好友,4 和 5 是好友,则 1、2、3 属于一个朋友圈,4、5 属于另一个朋友圈,结果为 2 个朋友圈。
算法分析:本题属于典型的并查集分析问题,较为简单,使用并查集的基本代码框架,将初始数组合并构建树,然后根据测试数组中的元素使用Find来进行查找匹配(这里我们封装了一个API:isSameSet() 测试是否处于同一集合)
define N 100
int parent[N + 1] = { 0 };
unordered_map<int, set<int>> map;
void initialize()
{
for (int i = 1; i <= N; i++)
{
parent[i] = i;
}
}
int Find(int item)
{
/* 保存item结点,最后更新父节点要使用 */
int child = item;
/* 找到根item */
while (parent[item] != item)
{
item = parent[item];
}
/* 根 - 集合的映射 */
if(map.count(item) == 0)
{
set<int> set;
set.insert(child);
map[item] = set;
}
else
{
auto it = map.find(item);
it->second.insert(child);
}
#if 0
while (child != item)
{
/* 先保存父节点 */
int tmp = parent[child];
/* 父节点指向根item */
parent[child] = item;
child = tmp;
}
#endif
return item;
}
/* 合并 */
void Union(int node1, int node2)
{
int set1 = Find(node1);
int set2 = Find(node2);
if (set1 != set2)
parent[set2] = set1;
}
void isSameSet(int node1, int node2)
{
int m = Find(node1);
int n = Find(node2);
cout << "{" << node1 << ", " << node2 << "} : ";
if (m == n)
{
cout << "YES" << endl;
}
else
{
cout << "NO" << endl;
}
}
/* 初始数组 */
int input[] =
{
3, 4,
4, 2,
7, 6,
5, 1,
3, 9,
11, 8,
6, 10,
9, 13,
11, 12,
};
/* 测试数组 */
int test[] =
{
3, 2,
9, 4,
7, 10,
6, 7,
13, 4,
8, 12,
6, 9,
4, 7,
11, 10,
1, 2,
12, 13,
7, 13,
};
int main()
{
int numSum = 13;
int n = sizeof(input) / sizeof(input[0]) / 2;
/* 初始化 */
initialize();
int i = 0;
int j = 0;
/* 合并同属 */
for (j = 0; j < n; ++j)
{
int m = input[i++];
int n = input[i++];
Union(m, n);
}
cout << "----------------Parent-------------------" << endl;
for (int i = 1; i < numSum; i++)
{
cout << "parent[" << i << "] = " << parent[i] << endl;
}
i = 0;
n = sizeof(test) / sizeof(test[0]) / 2;
cout << "----------------Result-------------------" << endl;
for (j = 0; j < n; j++)
{
int m = test[i++];
int n = test[i++];
isSameSet(m, n);
}
cout << "------------------Set--------------------" << endl;
auto it = map.begin();
while(it != map.end())
{
set<int> set;
set = it->second;
if(set.size() != 1)
{
cout << it->first << " : ";
for(int val : set)
cout << val << " ";
cout << endl;
}
++it;
}
}
运行上述程序后,我们可以清晰的看到parent数组、测试集的结果情况以及分组情况:
注:本文基础知识部分、配图部分参考网上其他优秀博文。