👉并查集的原理👈
在一些应用问题中,需要将 n 个不同的元素划分成一些不相交的集合。开始时,每个元素自成一个单元素集合,然后按一定的规律将归于同一组元素的集合合并。在此过程中要反复用到查询某一个元素归属于那个集合的运算。适合于描述这类问题的抽象数据类型称为并查集(union-find set)。
并查集需要建立映射关系,那么下面的代码是建立映射关系的一种方法(并查集的实现不采用这种方法)。
template <class T> class UnionFindSet { public: UnionFindSet(const T* a, size_t n) { for (size_t i = 0; i < n; ++i) { // 建立映射关系 _a.push_back(a[i]); _indexMap[a[i]] = i; } } private: vector<T> _a; // 编号找人 map<T, int> _indexMap; // 人找编号 };
比如:某公司今年校招全国总共招生 10 人,西安招 4 人,成都招 3 人,武汉招 3 人,10 个人来自不同的学校,起先互不相识,每个学生都是一个独立的小团体,现给这些学生进行编号:{0, 1, 2, 3, 4, 5, 6, 7, 8, 9};给以下数组用来存储该小集体,数组中的数字代表:该小集体中具有成员的个数。(负号下文解释,初始状态给的是 -1,表示 10 棵树,10 个集合)
毕业后,学生们要去公司上班,每个地方的学生自发组织成小分队一起上路。西安学生小分队 s1 = {0,6,7,8},成都学生小分队 s2 = {1,4,9},武汉学生小分队 s3 = {2,3,5} 就相互认识了,10 个人形成了三个小团体。假设有三个群主0,1,2 担任队长,负责大家的出行。
一趟火车之旅后,每个小分队成员就互相熟悉,称为了一个朋友圈。
从上图可以看出:编号 6,7,8 同学属于 0 号小分队,该小分队中有 4 人(包含队长 0);编号为 4 和 9 的同学属于 1 号小分队,该小分队有 3 人(包含队长 1),编号为 3 和 5 的同学属于 2 号小分队,该小分队有 3 个人(包含队长 1)。
仔细观察数组中内数据,可以得出以下结论:
1. 数组的下标对应集合中元素的编号
2. 数组中如果为负数,负号代表根,数字的绝对值代表该集合中元素个数
3. 数组中如果为非负数,代表该元素双亲在数组中的下标
4. 并查集的表示方法与堆类似,用下标表示关系,采用的是双亲表示法(存储父亲的下标)
在公司工作一段时间后,西安小分队中 8 号同学与成都小分队 1 号同学奇迹般的走到了一起,两个小圈子的学生相互介绍,最后成为了一个小圈子。
现在 0 集合有 7 个人,2 集合有 3 个人,总共两个朋友圈。
通过以上例子可知,并查集一般可以解决一下问题:
查找元素属于哪个集合
沿着数组表示树形关系以上一直找到根(即:树中中元素为负数的位置)
查看两个元素是否属于同一个集合
沿着数组表示的树形关系往上一直找到树的根,如果根相同表明在同一个集合,否则不在
将两个集合归并成一个集合
将两个集合中的元素合并
将一个集合名称改成另一个集合的名称
集合的个数
遍历数组,数组中元素为负数的个数即为集合的个数。
👉并查集的实现👈
#pragma once #include <map> #include <vector> #include <iostream> #include <string> using namespace std; class UnionFindSet { public: // 初始状态 UnionFindSet(size_t n) : _ufs(n, -1) {} // 将x1和x2所在的集合合并 void Union(int x1, int x2) { int root1 = FindRoot(x1); int root2 = FindRoot(x2); // x1和x2已经在同一个集合了 if (root1 == root2) return; // x1和x2不在同一个集合,需要合并两个集合 // 假设下标小的去做根 if (root1 > root2) swap(root1, root2); _ufs[root1] += _ufs[root2]; _ufs[root2] = root1; } // 判断x1和x2是否在同一个集合中 bool Inset(int x1, int x2) { return FindRoot(x1) == FindRoot(x2); } // 集合(树)的个数 = 负数的个数 size_t SetSize() { size_t size = 0; for (size_t i = 0; i < _ufs.size(); ++i) { if (_ufs[i] < 0) ++size; } return size; } private: vector<int> _ufs; // 找出x的根 int FindRoot(int x) { // 值为负数时即为根 while (_ufs[x] >= 0) { x = _ufs[x]; } return x; } };
👉并查集的应用👈
并查集的主要应用就是求取朋友圈的数量,即是否在同一个集合中等问题。
省份数量
有 n 个城市,其中一些彼此相连,另一些没有相连。如果城市 a 与城市 b 直接相连,且城市 b 与城市 c 直接相连,那么城市 a 与城市 c 间接相连。
省份是一组直接或间接相连的城市,组内不含其他没有相连的城市。
给你一个 n x n 的矩阵 isConnected ,其中isConnected[i][j] = 1 表示第 i 个城市和第 j 个城市直接相连,而 isConnected[i][j] = 0 表示二者不直接相连。
返回矩阵中省份的数量。
思路:先构造一个并查集,当 isConnected[i][j] = 1 时,说明第 i 个城市和第 j 个城市直接相连,将它们放在同一个集合;isConnected[i][j] = 0 表示二者不直接相连,不在同一个集合中。遍历结束,并查集中集合的个数就是省份的数量。
// 注:需要将并查集的代码拷贝到最前面 class Solution { public: int findCircleNum(vector<vector<int>>& isConnected) { size_t n = isConnected.size(); UnionFindSet ufs(n); for(size_t i = 0; i < n; ++i) { for(size_t j = i + 1; j < n; ++j) { if(isConnected[i][j] == 1) ufs.Union(i, j); } } return ufs.SetSize(); } };
因为我们实现了并查集,所以我们做起来就会很简单。但是如果我们没有实现并查集,我们也可以通过一个数组来模拟实现并查集最核心的接口。
class Solution { public: int findCircleNum(vector<vector<int>>& isConnected) { size_t n = isConnected.size(); vector<int> ufs(n, -1); // lambda表达式:找x的根 auto FindRoot = [&ufs](int x) { while(ufs[x] >= 0) { x = ufs[x]; } return x; }; for(size_t i = 0; i < n; ++i) { for(size_t j = i + 1; 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(size_t i = 0; i < n; ++i) { if(ufs[i] < 0) ++count; } return count; } };
等式方程的可满足性
给定一个由表示变量之间关系的字符串方程组成的数组,每个字符串方程 equations[i] 的长度为 4,并采用两种不同的形式之一:“a==b” 或 “a!=b”。在这里,a 和 b 是小写字母(不一定不同),表示单字母变量名。
只有当可以将整数分配给变量名,以便满足所有给定的方程时才返回 true,否则返回 false。
本道题也是可以通过并查集来解决的。因为 equations[i][0] 和 equations[i][3] 是小写字母,所以我们申请一个能存储 26 个元素的数组(相对映射)。当 equations[i][1] 为 ‘=’ 时,说明两个小写字母在同一个集合中;当 equations[i][1] 为 ‘!’ 时,说明两个小写字母不在同一个集合中。第一次遍历,将相等的小写字母放在同一个集合中;第二次遍历,看不在同一个集合的小写字母是否在同一个集合中。如果是,说明等式方程不满足,返回 false;如果第二次遍历结束,说明等式方程满足,返回 true。
class Solution { public: bool equationsPossible(vector<string>& equations) { vector<int> ufs(26, -1); auto FindRoot = [&ufs](int x) { while(ufs[x] >= 0) { x = ufs[x]; } return x; }; // 第一次遍历,将相等的小写字母放在一个集合里 for(auto& str : equations) { if(str[1] == '=') { // 找根 int root1 = FindRoot(str[0] - 'a'); int root2 = FindRoot(str[3] - 'a'); // 合并集合 if(root1 != root2) { ufs[root1] += ufs[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; // 等式方程满足 } };
👉并查集的优化👈
并查集的优化方式就是路径压缩和将节点少的集合向节点多的集合合并。当层数过多时,会影响查找的效率。那如何进行路径压缩呢?路径压缩可以在查找根的过程中进行,路径压缩就是修改当前下标的父节点的下标。为什么要将节点少的集合向节点多的集合合并呢?因为这样可以让更少的节点的层数变高,提高查找效率。
#pragma once #include <map> #include <vector> #include <iostream> #include <string> using namespace std; class UnionFindSet { public: // 初始状态 UnionFindSet(size_t n) : _ufs(n, -1) {} // 将x1和x2所在的集合合并 void Union(int x1, int x2) { int root1 = FindRoot(x1); int root2 = FindRoot(x2); // x1和x2已经在同一个集合了 if (root1 == root2) return; // x1和x2不在同一个集合,需要合并两个集合 // 默认root1是节点多的集合 // 将节点少的集合合并到节点多的集合中 if (abs(_ufs[root1]) < abs(_ufs[root2])) swap(root1, root2); _ufs[root1] += _ufs[root2]; _ufs[root2] = root1; } // 判断x1和x2是否在同一个集合中 bool Inset(int x1, int x2) { return FindRoot(x1) == FindRoot(x2); } // 集合(树)的个数 = 负数的个数 size_t SetSize() { size_t size = 0; for (size_t i = 0; i < _ufs.size(); ++i) { if (_ufs[i] < 0) ++size; } return size; } // 找出x的根 int FindRoot(int x) { int root = x; // 值为负数时即为根 while (_ufs[root] >= 0) { root = _ufs[root]; } // 压缩路径 while (_ufs[x] >= 0) { // 修改该路径中节点的父亲节点下标 int parent = _ufs[x]; _ufs[x] = root; x = parent; } return root; } private: vector<int> _ufs; }; void UnionFindSetTest() { UnionFindSet ufs(10); ufs.Union(8, 9); ufs.Union(7, 8); ufs.Union(6, 7); ufs.Union(5, 6); ufs.Union(4, 5); ufs.FindRoot(9); }
👉总结👈
本篇博客主要讲解了什么是并查集、并查集的模拟实现、并查集的应用:省份数量和等式方程的可满足性以及并查集的优化等等。那么以上就是本篇博客的全部内容了,如果大家觉得有收获的话,可以点个三连支持一下!谢谢大家!💖💝❣️