1.应用场景:
并查集用于解决连接类问题,判断网络中节点间的连接状态。与路径类问题相比,并查集只回答了节点之间是否连通,而具体的连通路径并不能确定,因此并查集在某些场景下非常高效。
2.并查集的基本实现:
如前所述,此处的并查集实现只提供两个接口:是否连接,元素合并。下面代码使用一个数组来记录每个元素所对应的类别,如果两个元素的类别相同,则称该两个元素相互连接(属于同一组),合并操作则是将两个元素对应的类别修改一致。
//代码中的参数皆为数据索引而非数据本身
class UnionFind
{
public:
UnionFind(int n)
{
mp_count = n;
mp_ID = new int[n];
for(int i=0;i<n;i++) //所有元素单独成一个组
mp_ID[i] = i;
}
~UnionFind(void)
{
delete[] mp_ID;
}
int find(int n)
{
return mp_ID[n];
}
//p,q为索引,第p个元素与第q个元素是否属于同一组别
bool isConnected(int p,int q)
{
return find(p)==find(q); //时间复杂度O(1)
}
//p,q为索引,合并p和q所属的两个组为一个
void unionElements(int p,int q)
{
int pID = find(p);
int qID = find(q);
if(pID==qID)
return ;
for(int i=0;i<mp_count;i++) //时间复杂度O(N)
if(pID==mp_ID[i])
mp_ID[i]=qID;
}
private:
///mp_ID[n]表示第n个元素所属组别
int* mp_ID;
int mp_count;
};
经过测试,在笔者电脑上运行上述代码,在10000条数据中进行10000次合并操作与10000次是否连接时,耗时0.12秒左右。
3.基于树的并查集
上面的实现方式在查询是否连接时算法效率为O(1),但进行合并操作时算法效率变成了O(n^2)。接下来换一种实现思路:使用树形结构,在同一颗树中的元素拥有相同的根节点因此属于同一组别,在进行查询是否连接时拥有相同根节点视为连接,进行合并操作时将两个元素所在树的根节点进行连接。代码中仍然使用数组来存储父节点,比如:mp_ID[n] = m表示n索引所在元素的父节点是m索引所在元素,m,n属于同一组别,如果mp_ID[m] = m则表示m索引所在元素的父节点就是自身,即m为根节点。完整的代码实现如下:
//使用树实现并查集
class UnionFind
{
public :
UnionFind(int n)
{
mp_ID = new int[n];
for(int i=0;i<n;i++)
mp_ID[i] = i; //初始化时所有元素根节点指向自身
mp_count = n;
}
~UnionFind()
{
if(mp_ID)
{
delete[] mp_ID;
mp_ID = NULL;
}
}
//查找索引为n的元素的根节点,时间复杂度O(h),h为树的高度
int find(int n)
{
while(n!=mp_ID[n]) //当n==mp_ID[n]时,元素指向自身即不再有父节点
{
n = mp_ID[n];
}
return n;
}
//p,q为索引,第p个元素与第q个元素是否属于同一组别
bool isConnected(int p,int q)
{
return find(p)==find(q); //时间复杂度O(1)
}
//p,q为索引,合并p和q所属的两个组为一个,时间复杂度O(h)
void unionElements(int p,int q)
{
int pID = find(p);
int qID = find(q);
if(pID==qID)
return ;
mp_ID[pID] = qID; //将索引p所在元素的根节点连接到索引q所在元素的根节点
}
private:
int* mp_ID;
int mp_count;
};
经测试,代码性能显著提升。
4.基于size进行优化
上面的实现中在进行连接查询和合并操作时时间复杂度为O(h),h为树的深度且h<<mp_count。但是上面的代码在进行合并操作时会出现将深度较大的树连接到深度较小的树根节点上的情况,这样合并后的树深度会变大,然而将深度较小的树根节点连接到深度较大根节点上则可以保持合并后的树深度不变。于是再引入一个新数组mp_treeCount,用于记录以当前元素为根节点的树的元素个数,mp_treeCount[n]表示以n索引所在元素为根节点的树的元素个数,在进行合并操作时通过比较两个元素所在树的元素个数来确定连接的方式并维护mp_treeCount。完整代码如下:
//基于size(以元素为根节点所在树的元素个数)优化
class UnionFind
{
public:
UnionFind(int n)
{
mp_ID = new int[n];
mp_treeCount = new int[n];
mp_count = n;
for(int i=0;i<n;i++)
{
mp_ID[i]=i; //初始化所有元素的根节点都指向自身
mp_treeCount[i]=1; //所有元素所在树节点数都为1
}
}
~UnionFind()
{
if(mp_ID)
delete mp_ID;
if(mp_treeCount)
delete mp_treeCount;
}
//查找索引为n的元素的根节点元素索引,时间复杂度O(h),h为树的高度
int find(int n)
{
while(n!=mp_ID[n])
{
n = mp_ID[n];
}
return n;
}
//p,q为索引,第p个元素与第q个元素是否属于同一组别
bool isConnected(int p,int q)
{
return find(p)==find(q); //时间复杂度O(1)
}
//p,q为索引,合并p和q所属的两个组为一个,时间复杂度O(h)
void unionElements(int p,int q)
{
int pID = find(p);
int qID = find(q);
if(pID==qID)
return ;
if(mp_treeCount[pID]>mp_treeCount[qID]) //比较子节点树元素个数,将元素个数少的根节点挂接到元素个数多的根节点上,减少树的深度h
{
mp_ID[qID] == pID;
mp_treeCount[pID] += mp_treeCount[qID] ;
}
else
{
mp_ID[pID] = qID; //将索引p所在元素的根节点连接到索引q所在元素的根节点
mp_treeCount[qID] += mp_treeCount[pID] ;
}
}
private:
//存放根节点数组,mp_ID[n]表示n索引所在元素的父节点,mp_ID[n]表示元素指向自身即根节点
int* mp_ID;
//元素所在树节点数,mp_treeCount[n]表示以n索引元素为根节点的树中元素个数
int* mp_treeCount;
//元素个数
int mp_count;
};
5.基于rank进行优化
上面的实现很大程度上降低了合并操作的树深度,但是树的元素个数并不能完全反应树的深度,对于元素个数很多但树深度很小的树,上述实现则会增加合并树深度,下面使用mp_rank数组来记录树的深度,实现并查集基于rank的优化。值得注意的是基于rank的优化程序运行时间上接近基于size的优化,甚至会略低于基于size的优化,因为元素个数很多但树深度很小的树并不会经常出现且基于rank的优化增加了每次合并的判断次数,但是提高了程序的健壮性,总体来看这些微小的效率牺牲是值得的!
//基于rank(以元素为根节点所在树的深度)优化
class UnionFind
{
public:
UnionFind(int n)
{
mp_ID = new int[n];
mp_rank = new int[n];
mp_count = n;
for(int i=0;i<n;i++)
{
mp_ID[i]=i; //初始化所有元素的根节点都指向自身
mp_rank[i]=1; //所有元素所在树节点数都为1
}
}
~UnionFind()
{
if(mp_ID)
delete[] mp_ID;
if(mp_rank)
delete[] mp_rank;
}
//查找索引为n的元素的根节点元素索引,时间复杂度O(h),h为树的高度
int find(int n)
{
while(n!=mp_ID[n])
{
n = mp_ID[n];
}
return n;
}
//p,q为索引,第p个元素与第q个元素是否属于同一根节点
bool isConnected(int p,int q)
{
return find(p)==find(q); //时间复杂度O(1)
}
//p,q为索引,合并p和q所属的两个组为一个,时间复杂度O(h)
void unionElements(int p,int q)
{
int pID = find(p);
int qID = find(q);
if(pID==qID)
return ;
if(mp_rank[pID]>mp_rank[qID]) //比较子节点树元素个数,将深度小的根节点挂接到深度大的根节点上,减少树的深度h
{
mp_ID[qID] == pID;
}
else if(mp_rank[pID]<mp_rank[qID])
{
mp_ID[pID] = qID; //将索引p所在元素的根节点连接到索引q所在元素的根节点
}
else
{
mp_ID[pID] = qID; //两棵树深度相等,作为根节点的元素对应的rank值加1
mp_rank[qID]+=1;
}
}
private:
//存放根节点数组,mp_ID[n]表示n索引所在元素的父节点,mp_ID[n]表示元素指向自身即根节点
int* mp_ID;
//元素所在树节点数,mp_rank[n]表示以n索引元素为根节点的树的深度
int* mp_rank;
//元素个数
int mp_count;
};
6.路径压缩
上面的优化全部是从合并过程中降低树的深度来着手。鉴于我们关心的只是每个元素的根节点,在find查找根节点的过程中还可以通过改变树的结构来大幅降低树的深度。在这里路径压缩的基本思想是:在查找根节点的过程中,如果当前节点不是根节点,那么直接将当前节点挂接到父节点的父节点上(因为根节点父节点就是自身,所以父节点的父节点一定合法)。
//使用路径压缩进行优化(进行查找根节点操作时上移节点减小树的深度)
class UnionFind
{
public:
UnionFind(int n)
{
mp_ID = new int[n];
mp_rank = new int[n];
mp_count = n;
for(int i=0;i<n;i++)
{
mp_ID[i]=i; //初始化所有元素的根节点都指向自身
mp_rank[i]=1; //所有元素所在树节点数都为1
}
}
~UnionFind()
{
if(mp_ID)
delete[] mp_ID;
if(mp_rank)
delete[] mp_rank;
}
//查找索引为n的元素的根节点元素索引
int find(int n)
{
while(n!=mp_ID[n])
{
mp_ID[n] = mp_ID[mp_ID[n]]; //如果不是根节点,将当前节点上移到当前父节点的父节点
n = mp_ID[n];
}
return n;
}
//p,q为索引,第p个元素与第q个元素是否属于同一根节点
bool isConnected(int p,int q)
{
return find(p)==find(q);
}
//p,q为索引,合并p和q所属的两个组为一个
void unionElements(int p,int q)
{
int pID = find(p);
int qID = find(q);
if(pID==qID)
return ;
if(mp_rank[pID]>mp_rank[qID]) //比较子节点树元素个数,将深度小的根节点挂接到深度大的根节点上,减少树的深度h
{
mp_ID[qID] == pID;
}
else if(mp_rank[pID]<mp_rank[qID])
{
mp_ID[pID] = qID; //将索引p所在元素的根节点连接到索引q所在元素的根节点
}
else
{
mp_ID[pID] = qID; //两棵树深度相等,作为根节点的元素对应的rank值加1
mp_rank[qID]+=1;
}
}
private:
//存放根节点数组,mp_ID[n]表示n索引所在元素的父节点,mp_ID[n]表示元素指向自身即根节点
int* mp_ID;
//元素所在树节点数,mp_rank[n]表示以n索引元素为根节点的树的深度
int* mp_rank;
//元素个数
int mp_count;
};
7.基于路径压缩的思考
按照路径压缩的思路,最优的情况是:所有的树深度都不超过2,树中的所有元素都直接挂接在根节点上。下面的方法实现了该思路
int find(int n)
{
if(n!=mp_ID[n])
{
mp_ID[n] = find(mp_ID[n]); //将索引为n的元素挂接到父节点的父节点上,由于mp_ID值范围一定合法,不用考虑越界问题
}
return mp_ID[n];
}
但是,运行发现算法的效率反而变低了,因为每次查找每个节点都要进行递归,递归会降低效率。
8.总结
并查集能够非常高效的解决连接类问题,通过使用路径压缩,并查集的操作时间复杂度近似于O(1)。