高阶数据结构——并查集

avatar
作者
筋斗云
阅读量:1

1. 并查集的介绍

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

在一些应用问题中,需要将n个不同的元素划分成一些不相交的集合。开始时,每个元素自成一个单元素集合,然后按一定的规律将归于同一组元素的集合合并。在此过程中要反复用到查询某一个元素归属于那个集合的运算。适合于描述这类问题的抽象数据类型称为并查集(union-find set)。

比如:某公司今年校招全国总共招生10人,西安招4人,成都招3人,武汉招3人,10个人来自不同的学校,起先互不相识,每个学生都是一个独立的小团体,我们给这10个人进行编号。

每个地方的学生自发组织成小分队一起上路,于是:西安的4名同学(编号为0,6,7,8)组成了一个团体,成都的三名同学(编号为1,4,9)组成了一个团体,武汉的3名同学(编号为2,3,5)组成了一个团体。假设由编号最小的同学担任队长。

有了这个结构之后,我们很容易就能判断出来某两位同学是否属于同一个团体,并且合并两个集合也非常方便,这就是并查集的价值所在。

2. 并查集的原理

在实际中我们可以通过一个数组来实现并查集,还是上面那个例子。我们定义一个数组,这个数组的下标就是每个成员的编号,数组中的值有两层意思:

1.对队长(根节点)来说,数组中保存这个集合中所有元素的个数 * -1。

2.对成员(除了根节点的其他结点)来说,数组中保存的是他的父节点的下标

所以数组中值 < 0 的都是根节点,根节点得绝对值是这个集合的元素个数。

最初一开始所有同学都是一个独立的团体,所以先初始化为 -1。

后续相同城市的同学组成了一个团体。

物理结构图如下:

逻辑结构图如下:

并查集可以用来解决哪些问题?

1.查找元素属于哪个集合

由于数组中保存的是他父节点的下标,所以我们可以根据这个特点,一路向上查找,直到找到一个是值为负数的结点即可

2.查找两个元素是否属于同一集合

沿着数组表示的树形关系往上一直找到树的根,如果根相同表明在同一个集合,否则不在

3.将两个集合合并成一个集合

需要做以下两步

  • 将两个集合中的元素合并
  • 将一个集合名称改成另一个集合的名称

比如上面的例子,{0,6,7,8}集合和{1,4,9}进行合并。

注意观察数组的变化,1号由原来的-3变成0,0号由原来的-4变成了-7。

4.集合的个数

遍历数组,数组中元素为负数的个数即为集合的个数。

3. 并查集的实现

并查集的实现通常包含如下接口:

  • 1.查找父节点下标
  • 2.合并两个集合
  • 3.返回集合的个数
  • 4.判断两个值是否在同一个集合中
class UnionFindSet { public: 	UnionFindSet(size_t n) 		: _ufs(n, -1) 	{}  	//返回父节点的下标 	size_t FindRoot(size_t posi);     //合并两个集合 	bool Union(size_t x1, size_t x2);     //返回集合的个数 	size_t Count();     //判断两个值是否在同一个集合中     bool InSameSet(size_t x1, size_t x2);   private: 	vector<int> _ufs; };

并查集的特点:

1.数组的下标对应集合中元素的编号

2. 数组中如果为负数,负号代表根,数字代表该集合中元素个数

3. 数组中如果为非负数,代表该元素双亲在数组中的下标

3.1 找到根节点

size_t FindRoot(size_t posi) { 	while (_ufs[posi] >= 0) 	{ 		posi = _ufs[posi]; 	}  	return posi; }

查找思路:如果一个结点是根节点,那么直接返回下标即可,如果不是根节点,那么就一路向上查找,直到找到根。

以上图为例,比如我们要查找9的根。根据数组我们可以看到9的父节点是1。继续向上查找,1号的父节点是0,到了0号,我们发现数据中保存的是-7,一个小于0的数,表示这个结点就是一个父节点。所以我们最终得到了9的根节点是0号。

3.2 合并两个集合

bool Union(size_t x1, size_t x2) { 	int root1 = FindRoot(x1); 	int root2 = FindRoot(x2);  	// 如果他们本身就在同一个集合中 	if (root1 == root2) 		return false;  	// 默认将root1为较大的集合 	if (_ufs[root1] > _ufs[root2]) 		swap(root1, root2);  	//将root2合并到root1下 	_ufs[root1] += _ufs[root2]; 	_ufs[root2] = root1;  	return true; }

先找到两个集合的根,如果这两个不在一个集合我们就进行合并,合并的方式是将一个集合当作另一个集合的根。

当然,上面的只是逻辑图,物理模型如下:

还有一点值得注意,就是我们要让root1为较大的那个集合,root2为较小的那个集合,这样做可以提高一点效率,因为我们是将root2合并到root1上的,如果root2的元素过多,root2再接到root1下,那么将会导致,root2中的所有元素每次都要多找一个值才能找到。

3.3 集合的个数

size_t Count() { 	size_t count = 0; 	for (auto e : _ufs) 	{ 		if (e < 0) 			++count; 	} 	return count; }

遇到一个小于0的数说明这是一个集合,每遇到一个集合直接++,最后返回最终的结果。

3.4 判断两个数是否在同一个集合

bool InSameSet(size_t x1, size_t x2) { 	int root1 = FindRoot(x1); 	int root2 = FindRoot(x2);  	if (root1 == root2) 		return true; 	else 		return false; }

直接找根即可,如果是同一个根就说明在同一个集合。

3.5 路径压缩

并查集的优势就是,能快速的查看两个数是否属于同一集合。但是有个问题,我们的合并两个集合是将一个集合直接变成另一个集合的孩子。也就是说,如果数据量较大的情况下,会导致并查集的高度变得很大,也就影响了查找效率,所以我们可以使用路径压缩的方式,减少树的高度。

路径压缩的方式是:我们使用FindRoot查找一个结点时,就把这个结点到根节点路径上的所有结点都变成根的孩子结点。

以下图为例:我们使用FIndRoot查找9号结点的根。

将9到根结点0路径上所有的结点都变成根节点0的孩子,也就是将9和1变成0的孩子。

我们发现以后再查找9所在的集合,只需找两次就行了。

//返回父节点的下标 size_t FindRoot(size_t posi) { 	int root = posi; 	while (_ufs[root] >= 0) 	{ 		root = _ufs[root]; 	}  	//路径压缩 	while (_ufs[posi] >= 0) 	{ 		int parent = _ufs[posi]; 		_ufs[posi] = root; 		posi = parent; 	}  	return posi; }

3.6 完整代码

class UnionFindSet { public: 	UnionFindSet(size_t n) 		: _ufs(n, -1) 	{}  	//返回父节点的下标 	size_t FindRoot(size_t posi) 	{ 		int root = posi; 		while (_ufs[root] >= 0) 		{ 			root = _ufs[root]; 		}  		//路径压缩 		while (_ufs[posi] >= 0) 		{ 			int parent = _ufs[posi]; 			_ufs[posi] = root; 			posi = parent; 		}  		return posi; 	}  	bool Union(size_t x1, size_t x2) 	{ 		int root1 = FindRoot(x1); 		int root2 = FindRoot(x2);  		// 如果他们本身就在同一个集合中 		if (root1 == root2) 			return false;  		// 默认将root1为较大的集合 		if (_ufs[root1] > _ufs[root2]) 			swap(root1, root2);  		//将root2合并到root1下 		_ufs[root1] += _ufs[root2]; 		_ufs[root2] = root1;  		return true; 	}  	size_t Count() 	{ 		size_t count = 0; 		for (auto e : _ufs) 		{ 			if (e < 0) 				++count; 		} 		return count; 	}  	bool InSameSet(size_t x1, size_t x2) 	{ 		int root1 = FindRoot(x1); 		int root2 = FindRoot(x2);  		if (root1 == root2) 			return true; 		else 			return false; 	}  private: 	vector<int> _ufs; };

4. 并查集相关题目

4.1 省份数量

题目链接:LCR 116. 省份数量 - 力扣(LeetCode)

题目描述:

解题思路:如果两个城市连接了,就将他们加入同一个集合,最终判断有几个集合即可。

方法一:使用我们自己写的并查集

class UnionFindSet { public: 	UnionFindSet(size_t n) 		: _ufs(n, -1) 	{}  	//返回父节点的下标 	size_t FindRoot(size_t posi) 	{ 		int root = posi; 		while (_ufs[root] >= 0) 		{ 			root = _ufs[root]; 		}  		//路径压缩 		while (_ufs[posi] >= 0) 		{ 			int parent = _ufs[posi]; 			_ufs[posi] = root; 			posi = parent; 		}  		return posi; 	}  	bool Union(size_t x1, size_t x2) 	{ 		int root1 = FindRoot(x1); 		int root2 = FindRoot(x2);  		// 如果他们本身就在同一个集合中 		if (root1 == root2) 			return false;  		//将root2合并到root1下 		_ufs[root1] += _ufs[root2]; 		_ufs[root2] = root1;  		return true; 	}  	size_t Count() 	{ 		size_t count = 0; 		for (auto e : _ufs) 		{ 			if (e < 0) 				++count; 		} 		return count; 	}  	bool InSameSet(size_t x1, size_t x2) 	{ 		int root1 = FindRoot(x1); 		int root2 = FindRoot(x2);  		if (root1 == root2) 			return true; 		else 			return false; 	}      void Print()     {         for (auto e : _ufs)         {             std::cout << e << " ";         }         std::cout << std::endl;     }  private: 	vector<int> _ufs; };   class Solution { public:     int findCircleNum(vector<vector<int>>& isConnected) {         int n = isConnected.size();         UnionFindSet ufs(n);          for (size_t i = 0; i < n; i++)         {             for (size_t j = 0; j < n; j++)             {                 if (isConnected[i][j] == 1)                 {                     ufs.Union(i, j);                 }             }         }        ufs.Print();          return ufs.Count();     } };

发现相同省份就将他们加入同一个集合即可。

但是这种方式非常麻烦,要先手动实现一个并查集,实际上我们可以发现,我们只用到了Union函数和Count函数。

方案二:模拟并查集

我们完全可以用数组模拟出一个并查集,只需要实现其中的合并功能就行了。

class Solution { public:     int findCircleNum(vector<vector<int>>& isConnected) {         int n = isConnected.size();         vector<int> ufs(n, -1);          auto FindRoot = [&ufs](size_t index)         {             int root = index;             while (ufs[root] >= 0)             {                 root = ufs[root];             }             return root;         };          for (size_t i = 0; i  < n; i++)         {             for (size_t j = 0; j < n; j++)             {                 if (isConnected[i][j] == 1)                 {                     //先找根                     int root1 = FindRoot(i);                     int root2 = FindRoot(j);                      if (root1 != root2)                     {                         ufs[root1] += ufs[root2];                         ufs[root2] = root1;                     }                 }             }         }          size_t count = 0;         for (auto e : ufs)         {             if (e < 0)                 ++count;         }                  return count;     } };

4.2 等式方程的可满足性

题目链接:990. 等式方程的可满足性 - 力扣(LeetCode)

题目描述:

解题思路:如果表达式的符号为==,就将左右两个操作数加入同一个集合中,遍历一遍之后,再次遍历,找到表达式为 != 的符号,判断左右两边是否在同一个集合中,如果不满足条件则返回false,满足则返回true。

class Solution { public:     bool equationsPossible(vector<string>& equations) {         int n = equations.size();         vector<int> ufs(26, -1);          auto FindRoot = [&ufs](size_t index)         {             while (ufs[index] >= 0)             {                 index = ufs[index];             }             return index;         };          for (auto& str : equations)         {             if (str[1] == '=')             {                 int root1 = FindRoot(str[0] - 'a');                 int root2 = FindRoot(str[3] - 'a');                  if (root1 != root2)                 {                     ufs[root2] = root1;                 }             }         }          for (auto& str : equations)         {             if (str[1] == '!')             {                 int root1 = FindRoot(str[0] - 'a');                 int root2 = FindRoot(str[3] - 'a');                  if (root1 == root2)                     return false;             }         }          return true;     } };

广告一刻

为您即时展示最新活动产品广告消息,让您随时掌握产品活动新动态!