算法周刊——并查集


并查集

一个小需求:

众所周知,在qq空间里能够看到您好友以及好友的好友所发布的秘密说说,您想匿名发布吐槽但又不希望让“敏感人”看到,所以每次发布前都需要先查询您的吐槽是否会被该说说的“敏感人”所看到。善于管理人际关系、精通人性的您已经拥有了您每位好友的列表名单,但由于您是一个中央空调,每次发条说说前都要逐个检验浪费了许多时间,如何妥善管理这些信息使其便于查询呢?

您曾经从朋友的列表中删除了重复的q友以减少数据量,保证了每个q友只会出现一次,那么现在将问题提炼一下,也就是“如何管理若干不相交的集合,使其具备合并集合,以及查询两个元素是否在一个集合中等功能”的问题。

很容易想到,既然不相交,那么就可以用一个连续的数组来存储元素(下标)与集合(值)的对应关系,在每个集合中任选一个元素来代表这个集合。每次需要合并两个集合时,就遍历整个数组中找到其中一个集合的元素存放的位置,将其内容修改为与要合并的集合相同就好了。

不过,这样每次在合并集合时都需要遍历整个数组,如果集合很多而每次只是进行两两合并,那么这样的方案显然效率太低。如何优化来提高合并效率?

可以想到,树在直接合并的时候只要用O(1)的时间“把一棵挂到另一棵上”就可以了,这里可以使用树来维护这些集合,把一个集合的所有元素不按顺序地放在同一棵树里,合并的时候直接把一棵挂到另一棵上就可以了。而判断是不是在同一个集合中只需要通过判断树唯一的根结点是否相同即可。

在存储结构上,根据大多数题目要求,可以考虑用一个一维数组来存储:数组下标表示结点的关键词,对应的值表示结点的双亲(孩子结点指向双亲结点)。特别地,让每棵树的根结点都指向自己,这样在一个数组中也能保持每棵树的独立性,从而只需要一个数组就可以存放一片森林。

根据这个思路设计一个类:

class UF{
private:
	int fa[MaxSize];//用一个一维数组来存储这些不相交的集合
public:
    //在构造函数中初始化,先默认每个元素分属一个集合,即每个结点都指向自己
	UF(int n){
		for(int i=1;i<=n;i++)fa[i]=i;
	}
    //查:查找根节点
	int find(int n){
		return fa[n]==n?n:find(fa[n]);//如果结点指向自己则为根节点,返回;否则继续递归地向上寻找根结点。
	}
    //并:合并两个集合(树)
	bool Union(int x,int y){
		int xroot=find(x),yroot=find(y);//找到两个集合(树)的根节点
		if(xroot==yroot)return 1;//如果相同,则为同一棵树,不需要合并
		fa[xroot]=yroot;//不同则令一个根节点指向另一个根节点,完成合并
		return 0;
	}
};

一个小问题:

仔细审视下这个方法,发现合并的时间从\(O(n)\)变成了\(O(1)\),但是查找的时间从\(O(1)\)变成了\(O(h)\)\(h\)为结点的深度(\(1<=h<=n\)),这个时间最大能够到达\(O(n)\)。而在实际生活中,查找的需求显然更加频繁。所以这样的做法更好了,但还是不够好。

考虑优化时应当从结点深度h入手,将h尽量降低到1,让树的子孙节点都直接指向根节点,这个操作是可以放在find()函数的执行过程中的。

//改造后的find()
int find(int n){
	return fa[n]==n?n:fa[n]=find(fa[n]);//如果结点指向自己则为根节点,返回;否则继续递归地向上寻找根结点,将fa[n]赋值为找到的根节点的返回值。
	}

这样将结点寻找根时所经过结点的路径进行压缩,让树更加扁平的方法就叫做路径压缩。

进一步优化:

经过了路径压缩一般已经满足要求了,更进一步还可以“按秩合并”。

按秩合并总的来说就是把规模更小的树挂在更大的树的树根上,有的说法是根据高度,有的是根据结点数量。这里笔者简单地理解为未经过路径压缩时根据深度按秩合并优化结果很直观;而经过了路径压缩,深度的改变不好记录,可以选择根据结点数来合并。朴素地想:被合并的树除了根节点都不是直接接在合并树的根节点上的,因此被合并的树结点数越多,合并后不直接接在根节点上的结点就越多,所以被合并树的结点数应当尽可能地少。按照以上路径压缩+按秩合并的思路最后优化得到:

class UF{
private:
	int fa[MaxSize];
	int cnt[MaxSize];//集合中的结点总数 
public:
	UF(int n){
		for(int i=1;i<=n;i++){
		fa[i]=i;cnt[i]=1;}//初始化森林,初始化结点数
	}
	int find(int n){
		return fa[n]==n?n:fa[n]=find(fa[n]);//查 
	}
	bool Union(int x,int y)//集 
	{
		int xroot=find(x),yroot=find(y);
		if(xroot==yroot)return 1;
		if(cnt[xroot]>cnt[yroot]){
		fa[yroot]=xroot;cnt[xroot]+=cnt[yroot]; 
		}//x树更大,让y根指向x根,增加结点数
		else{
		fa[xroot]=yroot;cnt[yroot]+=cnt[xroot];
		}//y树更大,让x根指向y根,增加结点数
		return 0;
	}
};
  • 测试主函数:
int main()
{
	int N,M,z,x,y;cin>>N>>M;
	UF s(N);
	for(int i=0;i>z>>x>>y;
		if(z==1)s.Union(x,y);
		else{
		if(s.find(x)==s.find(y))cout<<"Y"<

故事的最后:

上面这种数据结构在一些书中被很直观地叫做“不相交集合森林”,根据功能又有了并查集这个名字。你利用高效的并查集很好地管理着你秘密说说的可见范围,你的好友们也每天看着精彩露骨又不失分寸的吐槽。你如获至宝,像得到了打开新世界的钥匙......