我们在一些应用当中,经常会遇到将n个不同的元素分成一组不相交的集合,例如某省调查城镇交通状况,得到现有城镇道路统计表,当我们知道每条道路直接连通的城镇时,问最少还需要建设多少条道路才能使全省任何两个城镇间都可以实现交通。类似这种应用,经常需要进行两种特别的操作:寻找包含给定元素的唯一集合和和合并两个集合。这里,我们介绍如何维护一种被称为“并查集”的数据结构来实现这些操作。
在此文中,我们综合了网络博客以及《算法导论》书等多处搜集到的资料(见文末),整理并分析了并查集算法。
首先,我们先介绍“连通性”,连通性在许多领域中都有提到,我最早接触连通,是在图论中,基本可以理解为一个图中的两个点间有路径可达,有通路,则称这两点连通。而在图像处理领域中,也有类似的概念,例如在二值图像中定义像素p与像素q连通,则两个像素之间应该存在一系列相互邻接的像素值相等。
而对于“动态连通性”,为更好理解,如图1所示,假设我们输入了一组整数对,即图中左侧的(4, 3) (3, 8)等等,每对整数代表这两个points/sites是连通的。那么随着数据的不断输入,整个图的连通性也会发生变化,从上图中可以很清晰的发现这一点。同时,对于已经处于连通状态的points/sites,直接忽略,比如上图中的(8, 9)。
动态连通性在许多领域中有所应用,就像文章开头处我们所介绍的一种情况,除此之外,我们再列举三种
在建模时,我们首先要明确需要解决的问题。首先,在并查集的问题中,我们只关心给定的节点是否连通,但并不关心具体的连通路径,例如,我们在道路连通问题中,我们只关心两个城镇之间是否能够走通,而并不关心是通过那条路走通的。所以,我们将相互连通的点表示为一个集合,而不是图。
另外,该问题还有一个要点,即划分后的集合是不相交的。我们在操作时,依次检查每一个元素,并加入对应的集合,我们所要表示的,是这些不相交的集合作为元素所构成的集合。在有些地方,也将此数据结构称为“不相交集合数据结构”,例如在《算法导论》中使用ξ={S1,S2,…,Sk}表示一个不相交动态集的集合,在这当中,用一个代表来标识每个集合,它是这个集合ξ的某个成员。Sp与Sq中不存在交集,如果有,则Sp与Sq将会被自动合并为同一个集合,并用一个标识表示。
在一些应用当中,我们不关心哪个成员被用来做代表,仅仅关心的是两次查询动态集合的代表中,如果这些查询没有修改动态集合,则这两次查询得到的结果应该是相同答案。当然,也有些应用中,会预设一个规则来选取这个代表,比如选择这个集合中最小的成员(当然假设集合中的元素可以被比较次序)。我们用x表示一个集合中的一个元素(比如一个城镇),则我们希望可以支持以下几种操作:
并查集的实现原理也比较简单,就是使用树来表示集合,树的每个节点就表示集合中的一个元素,树根对应的元素就是该集合的代表,如图2所示。
图中有两棵树,分别代表两个集合,第一个集合为{a,b,c,d},代表元素为a。第二个集合为{e,f,g},代表元素为e。
树的节点表示集合中的元素,指针表示指向父节点的指针,根节点的指针指向自己,表示其没有父节点。沿着每个节点的父节点不断向上查找,最终就可以找到该树的根节点,即该集合的代表元素。
现在,应该可以很容易的写出 makeSet和find的代码了,假设使用一个足够长的数组来存储树节点(很类似之前讲到的静态链表),那么 makeSet要做的就是构造出如图3的森林,其中每个元素都是一个单元素集合,即父节点是其自身:
为简单起见,我们将所有的节点以整数表示,即对N个节点使用0到N-1的整数表示。而在处理输入的Pair之前,每个节点必然都是孤立的,即他们分属于不同的组,可以使用数组来表示这一层关系,数组的index是节点的整数表示,而相应的值就是该节点的组号了。
在此处,我们首先介绍Quick-Find算法与Quick-Union算法,然后,再介绍优化策略Weighted quick-union、Union by rank和path compression
/**
* @brief 并查集算法,Quick-Find
*/
class DisjointSet
{
public:
/**
* 构造函数,并设置元素个数
* @param[in] size 初始化的并查集,设置的元素个数
*/
DisjointSet(int size)
{
id = new int[size];
for (int i = 0; i < size; ++i)
{
makeSet(i);
}
num = size;
}
/**
* @brief 析构函数
*/
~DisjointSet()
{
delete[] id;
}
/**
* @brief 将包含p和q的两个动态集合(表示为Sp和Sq)合并成一个新的集合
* @param[in] p 需要进行合并的其中一个集合元素
* @param[in] q 需要进行合并的其中一个集合元素
*/
inline void unionElem(int p, int q)
{
int pID = find(p);
int qID = find(q);
if (pID == qID) return;
//遍历一次,该表所有的组号,并该表其中一组的组号,使两组合并
for (int i = 0; i < num; ++i)
{
if (id[i] == pID)
id[i] = qID;
}
--num;
}
/**
* @brief 找到元素p的代表
* @param[in] p需要获取代表的元素
* @return 元素p的代表
*/
inline int find(int p)
{
return id[p];
}
/**
* @brief 获取集合的个数
* @return 当前集合的个数
*/
inline int getSetNum()
{
return num;
}
private:
/**
* @brief 建立一个新的集合,它的唯一成员(因而为代表)是p
* @param p 创建集合的唯一成员元素
*/
inline void makeSet(int p)
{
id[p] = p;
}
private:
int * id; //并查集的元素
int num; //并查集的组数
};
举个例子,比如输入的Pair是(5,9),那么首先通过find方法发现它们的组号并不相同,然后在union的时候通过一次遍历,将组号1都改成8。当然,由8改成1也是可以的,保证操作时都使用一种规则就行。
上述代码的find方法十分高效,因为仅仅需要一次数组读取操作就能够找到该节点的组号,但是问题随之而来,对于需要添加新路径的情况,就涉及到对于组号的修改,因为并不能确定哪些节点的组号需要被修改,因此就必须对整个数组进行遍历,找到需要修改的节点,逐一修改,这一下每次添加新路径带来的复杂度就是线性关系了,如果要添加的新路径的数量是M,节点数量是N,那么最后的时间复杂度就是MN,显然是一个平方阶的复杂度,对于大规模的数据而言,平方阶的算法是存在问题的,这种情况下,每次添加新路径就是“牵一发而动全身”,想要解决这个问题,关键就是要提高union方法的效率,让它不再需要遍历整个数组。
考虑一下,为什么以上的解法会造成“牵一发而动全身”?因为每个节点所属的组号都是单独记录,各自为政的,没有将它们以更好的方式组织起来,当涉及到修改的时候,除了逐一通知、修改,别无他法。所以现在的问题就变成了,如何将节点以更好的方式组织起来,组织的方式有很多种,但是最直观的还是将组号相同的节点组织在一起,想想所学的数据结构,什么样子的数据结构能够将一些节点给组织起来?常见的就是链表,图,树,什么的了。但是哪种结构对于查找和修改的效率最高?毫无疑问是树,因此考虑如何将节点和组的关系以树的形式表现出来。
如果不改变底层数据结构,即不改变使用数组的表示方法的话。可以采用parent-link的方式将节点组织起来,举例而言,id[p]的值就是p节点的父节点的序号,如果p是树根的话,id[p]的值就是p,因此最后经过若干次查找,一个节点总是能够找到它的根节点,即满足id[root] = root的节点也就是组的根节点了,然后就可以使用根节点的序号来表示组号。所以在处理一个pair的时候,将首先找到pair中每一个节点的组号(即它们所在树的根节点的序号),如果属于不同的组的话,就将其中一个根节点的父节点设置为另外一个根节点,相当于将一棵独立的树编程另一棵独立的树的子树。直观的过程如下图所示。但是这个时候又引入了问题。
在实现上,和之前的Quick-Find只有find和union两个方法有所不同:
/**
* @brief 将包含p和q的两个动态集合(表示为Sp和Sq)合并成一个新的集合
* @param[in] p 需要进行合并的其中一个集合元素
* @param[in] q 需要进行合并的其中一个集合元素
*/
inline void unionElem(int p, int q)
{
// Give p and q the same root.
int pRoot = find(p);
int qRoot = find(q);
if (pRoot == qRoot)
return;
id[pRoot] = qRoot; // 将一颗树(即一个组)变成另外一课树(即一个组)的子树
--num;
}
/**
* @brief 找到元素p的代表
* @param[in] p需要获取代表的元素
* @return 元素p的代表
*/
inline int find(int p)
{
//寻找p节点所在组的根节点,根节点具有性质id[root] = root
while (p != id[p]) p = id[p];
return p;
}
实际上,在大部分应用场景中,我们希望获得一个不错的Union效率,也不希望Find的时间太长。这时,我们有一些优化的策略对待这种情况。在上述Quick-Union中,当我们分析所出现的极端情况时,不难发现,造成这种查找链太长的原因在于,我们在Union两个集合时,直接约定了将p所在的树挂在q所在的树上,即“id[pRoot] = qRoot”。这种情况,当p所在的树规模比q所在的树规模大的多时,p和q结合之后形成的树就是十分不和谐的一头轻一头重的”畸形树“了。
所以,我们可以做些改进,来避免这种情况,这时,就出现了Weighted quick-union,总是使规模较小的树作为规模较大的树的子树进行合并,从而保证整个树尽量平衡。
那么,我们如何来衡量一个树的大小呢?有一种非常直观的方式,使用树中节点的个数。我们在根节点中记录这棵树中总共有的节点个数,然后,根据树的节点个数多少,来判定树的大小。具体完整的代码如下,其中,主要增加了一个weight序列来记录每棵树的权重。已经将其相对于Quick-Union改动的地方标记了出来,
/**
* @brief 并查集算法,Weighted Quick-Union
*/
class DisjointSet
{
public:
/**
* 构造函数,并设置元素个数
* @param[in] size 初始化的并查集,设置的元素个数
*/
DisjointSet(int size)
{
id = new int[size];
weight = new int[size];<
for (int i = 0; i < size; ++i)
{
makeSet(i);
}
num = size;
}
/**
* @brief 析构函数
*/
~DisjointSet()
{
delete[] id;
delete[] weight;
}
/**
* @brief 将包含p和q的两个动态集合(表示为Sp和Sq)合并成一个新的集合
* @param[in] p 需要进行合并的其中一个集合元素
* @param[in] q 需要进行合并的其中一个集合元素
*/
inline void unionElem(int p, int q)
{
// Give p and q the same root.
int pRoot = find(p);
int qRoot = find(q);
if (pRoot == qRoot)
return;
//将小树作为大树的子树
if (weight[pRoot] < weight[qRoot])
{
id[pRoot] = qRoot;
weight[qRoot] += weight[pRoot];
}
else
{
id[qRoot] = pRoot;
weight[pRoot] += weight[qRoot];
}
--num;
}
/**
* @brief 找到元素p的代表
* @param[in] p需要获取代表的元素
* @return 元素p的代表
*/
inline int find(int p)
{
//寻找p节点所在组的根节点,根节点具有性质id[root] = root
while (p != id[p]) p = id[p];
return p;
}
/**
* @brief 获取集合的个数
* @return 当前集合的个数
*/
inline int getSetNum()
{
return num;
}
private:
/**
* @brief 建立一个新的集合,它的唯一成员(因而为代表)是p
* @param p 创建集合的唯一成员元素
*/
inline void makeSet(int p)
{
id[p] = p;
weight[p] = 1; //每个节点初始化权重都是(都只含有一个节点)
}
private:
int * id; //并查集的元素
int * weight; //对应一棵树中节点的个数,标识树的大小,并将其作为权重
int num; //并查集的组数
};
经过以上的修改,用少许的Union代价,从而换取Find的效率提升,如下图,生成的树状结构的改变:
经过比较,可以发现,通过使用weighted quick union方法,最后得到的树的高度大幅度减少了。这十分有意义,因为在Quick-Union算法中任何操作,都不可避免的需要调用find方法,而该方法的执行效率依赖于树的高度。树的高度减小了,find方法的效率就增加了,从而也就增加了整个Quick-Union算法的效率。
通过分析,我们知道通过使用weighted quick-union方法,可以在整体上降低树的高度,从而加快find的效率。但是,你是否发现了一些问题?我们在选取树的大小时,选取的是树中节点的个数。而实际上与find效率有关的量是树的高度!也就是说,weighted quick-union之所以有效,是通过间接的影响树的高度完成的。那么,反过来说,我们为什么不直接拿树的高度作为权重参数,从而来指导树的构造。这时,就产生一个新的算法,即Union by rank。
我们只需要将weighted quick-union算法中的weight中的树的节点数目,更改为存储树的高度即可(在大部分实现中,使用rank一词,所以,称为union by rank)。为了实现这个算法,我们仅仅需要改动unionElem方法即可,将原有修改权重为树的大小的方法改为修改为树的高度。
/**
* @brief 将包含p和q的两个动态集合(表示为Sp和Sq)合并成一个新的集合
* @param[in] p 需要进行合并的其中一个集合元素
* @param[in] q 需要进行合并的其中一个集合元素
*/
inline void unionElem(int p, int q)
{
// Give p and q the same root.
int pRoot = find(p);
int qRoot = find(q);
if (pRoot == qRoot)
return;
//将高度较低的树做为高度较低的树的子树
if (weight[pRoot] < weight[qRoot])
{
id[pRoot] = qRoot;
}
else
{
id[qRoot] = pRoot;
if (weight[pRoot] == weight[qRoot])
++weight[pRoot];
}
--num;
}
通过对weighted quick-union与union by rank的分析,我们已经意识到,find的速度是与树的高度有关的,树越是扁平,则find速度越快。在find方法的实现当中,是经过一个while循环实现的。如果我们保存所有路过的中间节点到一个数组中,然后在while循环结束之后,将这些中间节点的父节点指向根节点,不就行了么?但是这个方法也有问题,因为find操作的频繁性,会造成频繁生成中间节点数组,相应的分配销毁的时间自然就上升了。那么有没有更好的方法呢?还是有的,即将节点的父节点指向该节点的爷爷节点,这一点很巧妙,十分方便且有效,相当于在寻找根节点的同时,对路径进行了压缩,使整个树结构扁平化。相应的实现如下,实际上只需要添加一行代码,即更改find方法如下:
/**
* @brief 找到元素p的代表
* @param[in] p需要获取代表的元素
* @return 元素p的代表
*/
inline int find(int p)
{
//寻找p节点所在组的根节点,根节点具有性质id[root] = root
while (p != id[p])
{
//将p节点的父节点设置为它的爷爷节点,完成路径压缩
id[p] = id[id[p]];
p = id[p];
}
return p;
}
/**
* @brief 找到元素p的代表
* @param[in] p需要获取代表的元素
* @return 元素p的代表
*/
inline int find(int p)
{
if (p != id[p])
id[p] = find(id[p]); //path compression
return id[p];
}
至此,并查集算法基本介绍完毕,我们列出这几种算法的复杂度如下:
Algorithm |
Constructor |
Union |
Find |
Quick-Find |
N |
N |
1 |
Quick-Union |
N |
Tree height |
Tree height |
Weighted Quick-Union |
N |
lgN |
lgN |
Union by rank |
N |
lgN |
lgN |
Weighted Quick-Union With Path Compression |
N |
Very near to 1 (amortized) |
Very near to 1 (amortized) |
Union by rank With Path Compression |
N |
Very near to 1 (amortized) |
Very near to 1 (amortized) |
对大规模数据进行处理,使用平方阶的算法是不合适的,比如简单直观的Quick-Find算法,通过发现问题的更多特点,找到合适的数据结构,然后有针对性的进行改进,得到了Quick-Union算法及其多种改进算法,最终使得算法的复杂度降低到了近乎线性复杂度。
在最后,我们附上最终完整的C++实现代码:
/**
* @brief 并查集算法,Union by rank With Path Compression
*/
class DisjointSet
{
public:
/**
* 构造函数,并设置元素个数
* @param[in] size 初始化的并查集,设置的元素个数
*/
DisjointSet(int size)
{
id = new int[size];
weight = new int[size];
for (int i = 0; i < size; ++i)
{
makeSet(i);
}
num = size;
}
/**
* @brief 析构函数
*/
~DisjointSet()
{
delete[] id;
delete[] weight;
}
/**
* @brief 将包含p和q的两个动态集合(表示为Sp和Sq)合并成一个新的集合
* @param[in] p 需要进行合并的其中一个集合元素
* @param[in] q 需要进行合并的其中一个集合元素
*/
inline void unionElem(int p, int q)
{
// Give p and q the same root.
int pRoot = find(p);
int qRoot = find(q);
if (pRoot == qRoot)
return;
//将高度较低的树做为高度较低的树的子树
if (weight[pRoot] < weight[qRoot])
{
id[pRoot] = qRoot;
}
else
{
id[qRoot] = pRoot;
if (weight[pRoot] == weight[qRoot])
++weight[pRoot];
}
--num;
}
/**
* @brief 找到元素p的代表
* @param[in] p需要获取代表的元素
* @return 元素p的代表
*/
inline int find(int p)
{
if (p != id[p])
id[p] = find(id[p]);
return id[p];
}
/**
* @brief 获取集合的个数
* @return 当前集合的个数
*/
inline int getSetNum()
{
return num;
}
private:
/**
* @brief 建立一个新的集合,它的唯一成员(因而为代表)是p
* @param p 创建集合的唯一成员元素
*/
inline void makeSet(int p)
{
id[p] = p;
weight[p] = 1; //每个节点初始化权重都是(都只含有一层)
}
private:
int * id; //并查集的元素
int * weight; //对应一棵树的高度,并将其作为权重,即rank
int num; //并查集的组数
};
本文参考资料:
http://acm.hdu.edu.cn/showproblem.php?pid=1232
http://www.luohanjie.com/tech/转并查集union-find算法介绍/
http://blog.csdn.net/dm_vincent/article/details/7655764
http://blog.csdn.net/jinzhuojun/article/details/8001597
http://www.cnblogs.com/cyjb/p/UnionFindSets.html
http://blog.sina.com.cn/s/blog_ac9fdc0b0101lcgt.html
http://blog.csdn.net/dellaserss/article/details/7724401
《算法导论》Thomas H.Cormen,Charles E.Leiserson等,第21章
特此说明,文章内的图片以及文字,很多摘自上述参考博文中,本人只进行了些整理,再次感谢原作者!