所谓union-find问题就是动态图连通性问题,为什么叫做union-find呢?我认为原因应该是在动态图连通性问题中,union操作是将两个连通子集(connected components)连接起来(不仅仅是连接两个点),find操作是查找是否连通(实际可能会有些不同),是两个十分重要的操作,因此称此类问题为union-find问题。
首先我们认为,连通关系(connected)是一种等价关系,这一点十分重要,这就说明连接关系有以下三个特点:
对于所有点进行分类原则十分简单,就是根据这些点是否属于同一个连通子集中,那么所有点就会分为若干个连通子集。举个例子,假设当图中有十个点,分成了三个连通子集{0,8,9},{1,4,5},{2,3,6,7}那么在只有在同一子集中的点才具有连通关系。显而易见,这些连通子集之间相交均为空集,所有连通子集的并集为全体。注意,根据三个子集可以画出很多种连通图来,但是由于连通关系是一种等价关系,那么其实这些图只是看起来不同,实际上含义是相同的。
其次,在这个问题中,我们需要实现的两个基本操作:
经过上面的分析,那么我们可以想到,利用整数数组作为数据结构是简单易行的,下面是对此的一些解释:
1.数据结构(data structure):整形数组(Integer Array),点P和点Q具备连通关系,当且仅当点P和点Q中存储的相同整数。P和Q相当于数组的索引,其中存储的值就是数组的元素。
2.Find操作:检查点P和点Q中的id是否相同;
3.union操作:连接点P和点Q,进行的操作是让点Q所在的连通子集的所有元素的值等于点P所在连通子集的所有元素的值,实现两个集合求并集。注意,若是仅仅让点Q的值等于点P的值,那么仅仅是将点Q从原子集中移到了点P所在的子集,这是不对的,违反了连通关系的传递性(transitive).
Sedgewick的《算法,第四版》上有Java代码实现,稍微修改就可以运行,因为我最近在学习C++,因此利用C++写了一遍。
这是定义的“unionfind.h”头文件
#include
using std::vector;
class QuickFindUF{
private:
vector<int> UF;
public:
//定义一个默认构造函数
QuickFindUF() = default;
//定义一个接收参数构造函数
QuickFindUF(int N){
UF = vector<int>(N);
for (int i = 0; i < N; i++){
UF[i] = i;
}
}
//定义find成员函数,目的是用来查找索引为i的元素的值
int find(int p);
//定义unionFunc成员函数,目的是连接给定的两个点
void unionFunc(int p, int q);
//定义connected函数,判断两个点是否连接
bool connected(int p, int q);
};
int QuickFindUF::find(int p){
return UF[p];
}
void QuickFindUF::unionFunc(int p, int q){
/*if (UF[p] != UF[q])
UF[p] != UF[q];*///这两行代码大错特错,原因就是这里的模型已经是联通集合,那么将两点相连就是将两个集合相连,在这个模型中,必须将两个集合中的标记统一
if (UF[p] != UF[q]){
int idp = UF[p];//这两个赋值语句必不可少
int idq = UF[q];
for (int i = 0; i != UF.size(); i++){
if (UF[i] == idq) UF[i] = idp;
}
}
}
bool QuickFindUF::connected(int p, int q){
return UF[p] == UF[q];
}
//这是代码的简单实现
#include
#include
#include
#include "unionfind.h"
using namespace std;
using std::vector;
int main(){
//新建立一个类的对象,令N = 10;
QuickFindUF qfObj(10);
int b = qfObj.find(1);
bool a = qfObj.connected(1, 2);
cout << b << endl;
cout << "connected?" << a << endl;
qfObj.unionFunc(1, 2);
bool a1 = qfObj.connected(1, 2);
cout << "after union,connected?" << a1 << endl;
qfObj.unionFunc(3, 4);
bool a2 = qfObj.connected(3, 4);
cout << "after union,connected?" << a2 << endl;
bool a3 = qfObj.connected(2, 3);
cout << "2and3,connected?" << a3 << endl;
qfObj.unionFunc(1, 4);
a3 = qfObj.connected(2, 3);
cout << "after union 1and4,2and3,connected?" << a3 << endl;
for (int i = 0; i != 10; i++){
int m = qfObj.find(i);
cout << "Number " << i << "is" << m << endl;
}
return 0;
}
经过上面的测试数据,正确实现了功能。
研究算法,对于算法性能分析十分重要,quick-find算法从名字就可以看出来,实现find操作很快,时间复杂度为O(1),也就是检查两个点是否是连通的。而进行初始化,建立长度为N的数组,时间复杂度为O(N),这个无法再优化了。union操作的时间复杂度为O(N),这个很容易看出来,当图中元素很多且union操作需要调用很多时,算法效率就会很低。
使用的数据结构仍然是整数数组,下面是对此的解释:
1.数据结构:整形数组,每个点中存储的数据是父节点的索引,根节点存储自身的索引,因此,根节点相同的点就属于同一个连通子集。
2.find操作:检查点P和点Q根节点是否相同。
3.union操作:连接点P和点Q,将点Q的根作为点P的根的根就可以了。
头文件”QuickUnion.h”
#include
using std::vector;
class QuickUnion{
private:
vector<int> UF;
public:
//默认构造函数
QuickUnion() = default;
QuickUnion(int N){
UF = vector<int>(N);//初始化数组,建立不连通图
for (int i = 0; i != N; i++){
UF[i] = i;
}
}
//find操作,查找给定节点的root
int find(int p);
//union操作,求并集
void uoionF(int p, int q);
//connected函数,检查是否连通
bool connected(int p, int q);
};
int QuickUnion::find(int p){
while (UF[p] != p){
p = UF[p];
}
return p;
}
void QuickUnion::uoionF(int p, int q){
//若点P的根和点Q的根不同,就讲点Q的根作为点P的根的根
int i = find(p);
int j = find(q);
UF[i] = j;
}
bool QuickUnion::connected(int p, int q){
return find(p) == find(q);
}
简单实现和验证,结果正确
#include
#include "QuickUnion.h"
using namespace std;
int main(){
//新建一个对象,无连接图
QuickUnion quObj(10);
int n0 = quObj.find(1);
cout << "点1的root是 " <1, 2);
int n1 = quObj.find(1);
cout << "和点2进行Union操作之后,点1的根是 " << n1 << endl;
quObj.uoionF(3,4);
quObj.uoionF(1, 3);
int n2 = quObj.find(1);
cout << "和点3进行Union操作之后,点1的根是 " << n2 << endl;
return 0;
}
初始化的时间复杂度是O(N),这个和之前一样。find操作由于需要寻找节点的root,因此最坏的情况是O(N),union中包含了两步find操作,因此最坏的情况也是O(N),表面看起来甚至不如QuickFind,但实际使用中,不会出现从底部一直遍历整个数组才找到根部的情况。因此,QuickUnion的union操作的执行效率远比QuickFind的union操作好。
分析QuickUnion方法,若是能让产生的“树形状的子集”(后面简称树)更加扁平化,那么find操作的消耗就会变少,相应的union操作也会变少。思考一个问题:高度为N和高度为M 树合并,如何使新树的高度最小?很简单,让M作为N的子树,M的root直接与N的root相连即满足要求,这就是带加权的QuickUnion(weighted QuickUnion)的做法:
1.数据结构:建立两个整形数组,一个用于存储节点,另一个用于存储每一点的权重。
2.find操作:同QuickUoion,只要两个节点的root相同即可。
3.union操作:首先判断两个子树那个包含的点更多,然后将较小的树作为较大的树的子树子树,改变其root值即可。
头文件”w_quickunion.h”
#include
using std::vector;
class WQuickUnion{
private:
vector<int> UF;
vector<int> SZ;
public:
//默认构造函数
WQuickUnion() = default;
WQuickUnion(int N){
UF = vector<int>(N);//初始化数组,建立不连通图
SZ = vector<int>(N);//初始化权值数组,每个元素初始值为1,表明高度为1
for (int i = 0; i != N; i++){
UF[i] = i;
SZ[i] = 1;
}
}
//find操作,查找给定节点的root
int find(int p);
//union操作,求并集
void uoionF(int p, int q);
//connected函数,QuickUoion
bool connected(int p, int q);
};
int WQuickUnion::find(int p){
while (UF[p] != p){
p = UF[p];
}
return p;
}
void WQuickUnion::uoionF(int p, int q){
//若点P的根和点Q的根不同,就讲点Q的根作为点P的根的根
int i = find(p);
int j = find(q);
if (SZ[i] > SZ[j]){ UF[j] = i; SZ[i] = SZ[j]; }
else{ UF[i] = j; SZ[j] = SZ[i]; }//这里对权值更新,点少的子树作为新子树
}
bool WQuickUnion::connected(int p, int q){
return find(p) == find(q);
}
测试文件
#include
#include "w_quickunion.h"
using namespace std;
int main(){
//新建一个对象,无连接图
WQuickUnion quObj(10);
int n0 = quObj.find(1);
cout << "点1的root是 " << n0 << endl;
quObj.uoionF(1, 2);
int n1 = quObj.find(1);
cout << "和点2进行Union操作之后,点1的根是 " << n1 << endl;
quObj.uoionF(3, 4);
quObj.uoionF(1, 3);
int n2 = quObj.find(1);
cout << "和点3进行Union操作之后,点1的根是 " << n2 << endl;
return 0;
}
基本思路是在进行查找节点的root时,将待查找节点与其root直接相连,那么也就是加一行代码就可以实现了;另一种做法是,将待查找节点所指向root的路径上的每一个节点都变为指向其爷爷节点,那么路径就会缩短一半,也是只需要一行代码就可以实现了。
由此表可以看出,经过不断地改进,算法的性能有了很大的提高,然而仅仅需要添加几行代码即可。
WQUPC就是路径压缩的带权值的quickunion算法,耗时由30年变为了6秒,所以好的算法十分重要。设计一个算法时,我们必须考虑算法的性能问题,不断分析和解决,这样才是正确的做法。
我认为UoionFind的问题的本质还是连通子集,中在不同的方法中,连通子集的变化: