前言
这里没有路径压缩+按秩合并时证明反Ackermann函数的时间复杂度的内容,如有兴趣请查看《算法导论》第21章。
本文重点强调对并查集的理解,以及各种优化算法的实现。除上面的优化算法外都会给出时间复杂度证明。
并查集
什么是并查集,解决的是什么问题?
并查集问题又叫做在线等价类问题,涉及将n个不同的元素分为一组不相交的集合。这些集合涉及三个操作,初始化(initialize),寻找(find)和合并(union)。
- 初始化:每个元素独自构成一个集合,如果有n个元素,则有n个集合。
- 寻找:查找元素所在的集合,元素所在的集合由根节点标识,查找元素所在的集合即查找根节点。
- 合并:将两个集合合并为一个集合,假设两个集合元素的数量为m、n,合并后的集合元素数量为m+n。合并之后的集合根节点是原来两个集合的根节点之一。
应用场景:应用场景很广泛,在信息学、计算机网络等领域都有应用。
并查集的结构:并查集的实现十分简单,最基础的并查集结构甚至不需要结构体来表示,只需要一个数组。并查集的逻辑结构为树结构,含有一个指针指向父结点。
并查集的优化
- 初始结构
- 优化合并:重量规则与高度规则(按秩合并)
- 优化查找:路径压缩、路径分割与路径对折(不展开说明)
- 综合优化:路径压缩+按秩合并
初始结构
// 初始表示
void initialize(int numberOfElements)
{
parent = new int[numberOfElements + 1]; // parent是并查集类私有数组指针,并查集节点从1开始
for(int i = 1; i <= numberOfElements; i++)
{
parent[i] = i; //初始化父节点为其本身
}
}
int findRoot(int theElement)
{
while(theElement != parent(theElement)) // 根节点的父节点是其本身
{
theElement = parent(theElement); //逐层向上寻找
}
return theElement;
}
void union(int ElemA, int ElemB)
{
int x = findRoot(ElemA);
int y = findRoot(ElemB);
parent[y] = x;
}
性能分析:
说明:节点的个数为n,树的高度为h。
- 构造函数:因为是动态分配数组空间,所以构造函数的时间复杂度无法优化,时间复杂度为O(n)。
- 查找函数:决定查找的时间复杂度为树的高度,时间复杂度为O(h),在最坏的情况下,时间复杂度为Θ(n)。
- 合并函数:如果仅考虑合并函数中的合并操作,时间复杂度为O(1)。但是在我的实现中,合并函数还包括查找操作,所属时间复杂度为O(n)。
优化合并
优化合并,其实是优化合并方法使树的高度缓慢增长,进而使查找函数的时间性能提高。
按照重量规则合并
重量规则:若根为i的树的节点数少于根为j的树的节点数,则将j作为i的父节点。否则,将i作为j的父节点。
// 优化合并,重量规则实现
struct unionFindNode
{
int parent; // 当root为真时,parent表示树的重量,当root为假时,表示父节点的指针
bool root; //当节点为根时,为真,否则为假
unionFindNode() // 初始化时树的重量为1,为根节点
{
parent = 1;
root = true;
}
};
void initialize(int numberOfElements) //初始化方法改为初始化结构体
{
nodes = new unionFindNode[numberOfElements + 1];
}
int findRoot(int theElement)
{
while(!node[theElement].root)
{
theElement = node[theElement].parent;
}
return theElement;
}
void union(int ElemA, int Elem B)
{
int x = findRoot(ElemA);
int y = findRoot(ElemB);
if(node[x].parent > node[y].parent) // 质量小的树的根节点的父节点指向质量大的树的根节点
{
node[x].parent += node[y].parent;
node[y].root = false;
node[y].parent = x;
}else
{
node[y].parent += node[x].parent;
node[x].root = false;
node[x].parent = y;
}
}
注意:规定[ ]符号表示某个数的下界,比如[3.5] = 3。,log默认以2为底
重量规则引理: 从单元素集合出发,用重量规则进行合并操作。若依次方式构建一颗具有p个节点的树t,则t的高度最多是[logp] + 1。
证明:
- 利用数学归纳法证明,i为树的节点数
- 当i=1时,对于只有一个节点的树,高度为1,假设成立。
- 假设当i<=p-1时,对所有具有i个节点的树,结论成立
- 下面证明对于i=p时,结论成立
- 对于最后一次合并的两棵树k,j。假设k的节点数为m,j的节点数为p-m。不失一般性,k的取值范围为1 <= k <= p/2。
- 假设合并后的树为t,那么t的高度要么比j的高度大1,要么和k的高度相等。(这里一开始可能不好理解,我来捋一捋。我们设的j的节点数<=k的节点数,即经过一系列合并操作,k树比j树要重,合并后,j的根节点要指向k的根节点。当j树的高度小于k树的高度时,合并后为k的高度;当j树的高度大于等于k的高度时,合并后为j树的高度加1。因为合并后要在j树原有的高度上加上k的根节点,多了一层。)
- 合并后,如果与k树的高度相同,t的高度就为[log(p-m)]+1 <= [logp] + 1.
- 合并后,如果比j树的高度大一,t的高度就为[logm] + 1 +1 <= [log(p/2)] + 1 +1 <=[logp] + 1.
- 证毕
按照高度规则合并
高度规则: 若根为i的树的高度少于根为j的树的高度,则将j作为i的父节点。否则,将i作为j的父节点。
高度规则引理: 从单元素集合出发,用高度规则进行合并操作。若依次方式构建一颗具有p个节点的树t,则t的高度最多是[logp] + 1。
说明:按照高度规则合并的代码实现和高度规则引理的证明和重量规则相近,感兴趣的读者可以尝试证明。
因为find函数的时间复杂度和树的高度直接相关,所以优化后,find函数的时间复杂度降为O(log(numberOfElements))。
优化查找
路径压缩(path compression)
**路径压缩:**在find方法中,从待查节点到根节点的路径上,所有parent指针都被改为指向根节点。路径压缩会改变树的结构。
find函数代码实现:
// 路径压缩中的find方法
int findRoot(int theElement)
{
int theRoot = theElement;
while(!node[theRoot].root)
theRoot = node[theRoot].parent;
int currentNode = theElement;
while(currentNode != theRoot) // 当路径上的节点不是指向父节点时
{
int parentNode = node[currentNode].parent;
node[currentNode].parent = theRoot;
currentNode = parentNode;
}
return theRoot;
}
综合优化
结论:
展示结论,当合并优化中的任一策略和查找优化的任一策略结合使用时,执行系列交错的合并和查找操作所需的时间与合并与查找的次数呈线性关系。
一组m个初始化、查找、合并操作序列,其中n个是初始化操作,综合优化的时间复杂度为O(m*α(n)).
其中α函数是Ackermann函数的倒数函数。