Not Only Algorithm,不仅仅是算法,关注数学、算法、数据结构、程序员笔试面试以及一切涉及计算机编程之美的内容 。。
你的位置:NoAlGo博客 » 数据结构 » , ,

并查集及其在最小生成树中的应用

并查集是一种用途广泛的数据结构,能够快速地处理集合的合并和查询问题,并且实现起来非常方便,在很多场合中都有着非常巧妙的应用,。
本文首先介绍并查集的定义、原理及具体实现,然后以其在最小生成树算法中的一个经典应用为例讲解其具体使用方法。

一 并查集原理及实现

并查集是一种树型的数据结构,用于处理一些不相交集合的合并及查询问题。

并查集在使用中通常以森林来表示,每个集合组织为一棵树,并且以树根节点为代表元素。实际中以一个数组father[x]即可实现,表示节点x的父亲节点。另外用一个变量n表示节点的个数。但为了提升性能,通常还用一个数组rnk[x]表示节点x的子树的深度,具体后面解释。

const int mx = 100000 + 1; //最大节点个数
int n;			//节点个数
int father[mx]; //节点的父亲
int rnk[mx];	//节点的子树深度

并查集通常支持三种操作:初始化、查找、合并。

1. 初始化

初始化把每个点所在集合初始化为其自身,即n个节点就有n个集合。遍历一次n个节点,把每个的父亲初始为自己,子树的深度初始化为0。

void makeSet() //初始化
{
	for (int i = 0; i < n; i++) father[i] = i, rnk[i] = 0;
}

2. 查找

查找元素所在的集合,即根节点。因为一个集合只用根节点作为代表元,查找的时候只要沿着father数组一直往上走直到根节点为止,而根节点的父亲为其本身,于是代码为:

int findSetOriginal(int x) //非路径压缩查找
{
	if (x != father[x]) x = father[x];
	return father[x];
}

但实际中会做一个称为路径压缩的优化。因为从该节点x到根节点可能会有很长的一条路径,查找的时间复杂度极端情况下为O(n)。我们可以在查找的过程中,把每个节点的父亲都指向跟节点,于是查找完成之后原本长度为n的一条路径变成了n条长度为1的路径,这些节点的查找时间复杂相应变成了O(1)。路径压缩的实现如下所示:

int findSet(int x) //递归路径压缩查找
{
	if (x != father[x]) father[x] = findSet(father[x]);
	return father[x];
}

但实际中递归算法可能会造成栈溢出的问题,以下为相应的非递归算法。主要思想是先找到该集合的根节点,然后把路径上的节点的父亲都改为根节点。

int findSetNonRecursive(int x) //非递归路径压缩查找
{
	int root = x;		//根节点
	while (root != father[root])
		root = father[root];
	int tem = x;
	while (tem != root)  //路径压缩
	{
		int temFather = father[tem];//暂存父亲节点
		father[tem] = root;			//更新父亲为根
		tem = temFather;			//移动到父亲节点
	}
	return root; //返回根节点
}

3. 合并

将两个元素所在的集合合并为一个集合。合并的时候先使用2中的查找函数找到两个集合的根节点。如果根节点相同,说明属于同一个集合,则不需要合并。如果不同,只需把一个根节点的父亲指向另一个根节点即可。

实际中使用了一个称为按秩合并的优化,因为直接合并可能产生一棵深度很深的树,这不利于后续的查找。前面的rnk[x]数组表示节点x的秩,即该节点子树的深度。合并时我们总是把秩小的节点的父亲指向秩大的节点之上,这样可以尽量较少新生成的树的深度。当两个节点的秩相同时,新生成树的根节点的秩需要加1,因为子树的深度增加了1,否则子树深度没有变化,秩也不需要改变。

int unionSet(int x, int y) //合并
{
	x = findSet(x), y = findSet(y);
	if (x == y) return 0;	//属于同一个集合,不需合并
	if (rnk[x] > rnk[y]) father[y] = x;
	else father[x] = y, rnk[y] += rnk[x] == rnk[y];
	return 1;				//属于不同集合,且已合并成功
}

二 并查集应用

并查集有很多经典的应用。在一些有N个元素的集合应用问题中,我们通常是在开始时让每个元素构成一个单元素的集合,然后按一定顺序将属于同一组的元素所在的集合合并,其间要反复查找一个元素在哪个集合中。

其中一个非常经典的应用是最小生成树的Kruskal算法。给定一个具有n个节点的连通图,它的生成树是原图的一个子图,包含所有n个节点,且有保持图连通的最少的边(n-1条边)。边权值最小的生成树是最小生成树。

kruskal算法是一个贪心算法,把所有的边按权值从小到大依次考虑,如果当前边加进生成树中会出现回路,则丢弃当前边,否则添加当前边。

一个简单的例子如下左图所示:

最小生成树

首先添加权最小的边(0,2),然后添加权第二小的边(0,1)。然后考虑权为3的边(1,2),因为添加进这条边之后,已有的三条件会构成一条回路,跟生成树的定义不符,所以(1,2)这条边不予以考虑。接下来考虑权为4的边(1,3),添加后不会产生回路,于是添加。最后考虑权为5的边(2,3),其添加后产生回路,同样丢弃。于是生成的最小生成树即为右图的绿色部分。

其实,当添加了3条边之后最小生成树已经产生,后面的边不用再继续考虑了,因为总共只有4个顶点,其最小生成树只有3条边。

现在从并查集的角度考虑这个问题。初始时我们把所有节点自身初始化为一个集合。每次添加一条边进入最小生成树时,实际上是把这条边的两个节点所在的集合合并。如果添加当前边之后会产生回路,实际上是指当前边的两个节点所在的集合是一样的。所以实际上可以把边按权值从小到大排序,然后逐个合并边的两个节点的集合,如果节点集合一样,就添加了一条新边,否则直接忽略。

先定义表示图的数据结构,这里只需要存储边就可以了,以下是边的结构体数组,并且包含必要头文件:

#include <iostream>
#include <algorithm>
using namespace std;

const int mxsz = 1000;
struct Edge{
	int st, ed, len;
} edge[mxsz];

然后可以用并查集实现Kruskal算法了,包含一个边排序的比较函数,并且以上图例子作为输入进行测试:

bool cmp(const Edge &a, const Edge &b) { return a.len < b.len; }
void Kruskal()
{
	//输入图
	int nodeNum, edgeNum; //节点数量、边的数量
	scanf("%d%d", &nodeNum, &edgeNum); 
	for (int i = 0; i < edgeNum; i++) //输入边
	{
		int st, ed, len; 
		scanf("%d%d%d", &st, &ed, &len);  
		edge[i].st = st, edge[i].ed = ed, edge[i].len = len;
	}

	//计算最小生成树权值
	sort(edge, edge + edgeNum, cmp); //边排序
	n = nodeNum;	makeSet();  //初始化
	int weight = 0;
	for (int i = 0; i < edgeNum; i++) //遍历边
		if (unionSet(edge[i].st, edge[i].ed) == 1) //合并边的两个节点所在集合
			weight += edge[i].len; //如果节点集合不同,加入最小生成树中
	printf("最小生成树权值:%d\n", weight);

	/*程序的一个输入输出为:
	4 5
	0 1 2
	0 2 1
	1 2 3
	1 3 4
	2 3 5
	最小生成树权值:7
	*/
}
上一篇: 下一篇:

我的博客

NoAlGo头像编程这件小事牵扯到太多的知识,很容易知其然而不知其所以然,但真正了不起的程序员对自己程序的每一个字节都了如指掌,要立足基础理论,努力提升自我的专业修养。

站内搜索

最新评论