华为的一道经典的并查集试题:(看完解析回来看)
要求满足条件下的最多分组数,就先用并查集分组,分好之后查看并查集的根的个数就是做多分组数。
/*
题目描述:
幼儿园老师安排小朋友做游戏,现在需要给N个小朋友进行分组,老师让每个同学写一个名字,代表这位小朋友想和谁分到一组,请问老师在满足所有小朋友意思的情况下,最多可以将班级分为几组?
输入描述:
第一行输入N,0<N<=100000
接下来是N行代表每个小朋友希望和谁分到一组,如:”John Jack”,代表John希望和Jack分到一组,两个名字之间以空格分割,名字本身不存在空格。
输出描述:
分组的最多数量
输入:
6
Jack Tom
Alice John
Jessica Leonie
Tom Alice
John Jack
Leonie Jessica
输出:
2
*/
#include<iostream>
#include<map>
#include<set>
#include<string>
#include<vector>
using namespace std;
int findRoot(vector<int>& parent, int x){
// return x == parent[x] ? x : findRoot(parent, parent[x]); // 路径折叠后如下
if(x != parent[x]){
parent[x] = findRoot(parent, parent[x]);
}
return parent[x];
}
void unionElement(vector<int>& parent, vector<int>& rank, int a, int b){
int aRoot = findRoot(parent, a);
int bRoot = findRoot(parent, b);
if(rank[aRoot] < rank[bRoot]){
parent[aRoot] = bRoot;
}else{
parent[bRoot] = aRoot;
if(rank[aRoot] == rank[bRoot]){
rank[aRoot]++;
}
}
}
bool isSame(vector<int>& parent, int a, int b){
return findRoot(parent, a) == findRoot(parent, b);
}
int main(){
int n;
cin >> n;
string name1, name2;
map<string, int> nameIdMap;
vector<pair<string, string> > vec;
vector<int> parent(n);
vector<int> rank(n,0);
set<int> retSets;
int i = 0;
while(n--){
cin >> name1 >> name2;
nameIdMap[name1] = i;
parent[i] = i;
i++;
vec.emplace_back(pair<string, string>(name1, name2));
}
for(pair<string, string> p : vec){
int a = nameIdMap[p.first];
int b = nameIdMap[p.second];
if(isSame(parent, a, b)){
continue;
}
unionElement(parent, rank, a, b);
}
for(int index = 0; index < vec.size(); index++){
retSets.insert(findRoot(parent, index));
}
cout << retSets.size() << endl;
return 0;
}
1.为什么会有并查集?
如上图,任意两个节点是否连接?这如同所有的网络节点中,任意的两个节点的用户是否通过多层关系相互认识?
为了探讨这个问题,发明了并查集这种数据结构。
(并查集只是探讨了众多节点中的两个节点是否相连,但是没有探讨这两个节点具体的连接路径,这一点交给后面的图进行处理解决!)
2.什么是并查集?
百度解释:并查集是一种树型的数据结构,用于处理一些不相交集合(disjoint sets)的合并及查询问题。
3.怎样用代码表示并查集?
1.最原始的并查集
比如上图,总共有两个派别,元素是1的是一组,元素是0的是一组。那怎么判断某两个数组下标(0,1,2,3…)是否是一个派别,原始方法如下:
先在这个unionfind1.h文件中写出最简单并查集的类:
先在这个unionfind1.h问文件中写出最简单并查集的类:
#ifndef INC_02_QUICK_FIND_UNIONFIND1_H
#define INC_02_QUICK_FIND_UNIONFIND1_H
#include <iostream>//输入输出
#include <cassert>//查错用
using namespace std;
// 我们的第一版Union-Find
namespace UF1 {
class UnionFind {
private:
int *id; // 我们的第一版Union-Find本质就是一个数组
int count; // 数据个数
public:
// 构造函数
UnionFind(int n) {
count = n;
id = new int[n];
// 初始化, 每一个id[i]指向自己, 没有合并的元素
for (int i = 0; i < n; i++)
id[i] = i;
}
// 析构函数
~UnionFind() {
delete[] id;
}
// 查找过程, 查找元素p所对应的集合编号
int find(int p) {
assert(p >= 0 && p < count);
return id[p];
}
// 查看元素p和元素q是否所属一个集合
// O(1)复杂度
bool isConnected(int p, int q) {
return find(p) == find(q);
}
// 合并元素p和元素q所属的集合
// O(n) 复杂度
void unionElements(int p, int q) {
int pID = find(p);
int qID = find(q);
if (pID == qID)
return;
// 合并过程需要遍历一遍所有元素, 将两个元素的所属集合编号合并
for (int i = 0; i < count; i++)
if (id[i] == pID)
id[i] = qID;
}
};
}
#endif //INC_02_QUICK_FIND_UNIONFIND1_H
上述代码重新添加了新的命名空间,是为了避免和系统的同名函数重叠,前面用using namespace 该指令告诉编译器后续代码正在使用指定命名空间中的名称。具体可看下述链接:
命名空间的使用
然后使用一个测试文件测试一下这个并查集对于“查看是否属于同一个元素集合”和“将两个不同元素集合合并为同一个集合”的效率,测试代码如下:
#ifndef INC_02_QUICK_FIND_UNIONFINDTESTHELPER_H
#define INC_02_QUICK_FIND_UNIONFINDTESTHELPER_H
#include <iostream>
#include <ctime>
#include "UnionFind1.h"
using namespace std;
// 测试并查集的辅助函数
namespace UnionFindTestHelper{
// 测试第一版本的并查集, 测试元素个数为n
void testUF1( int n ){
srand( time(NULL) );
//实例化了一个对象,它在构造函数中就创建了一个数组
UF1::UnionFind uf = UF1::UnionFind(n);
time_t startTime = clock();
// 进行n次操作, 每次随机选择两个元素进行**合并**操作
for( int i = 0 ; i < n ; i ++ ){
int a = rand()%n;
int b = rand()%n;
uf.unionElements(a,b);
}
// 再进行n次操作, 每次随机选择两个元素, 查询他们是否同属一个集合
for(int i = 0 ; i < n ; i ++ ){
int a = rand()%n;
int b = rand()%n;
uf.isConnected(a,b);
}
time_t endTime = clock();
// 打印输出对这2n个操作的耗时
cout<<"UF1, "<<2*n<<" ops, "<<double(endTime-startTime)/CLOCKS_PER_SEC<<" s"<<endl;
}
}
#endif //INC_02_QUICK_FIND_UNIONFINDTESTHELPER_H
主函数中输入数组大小进行测试:
#include <iostream>
#include "UnionFindTestHelper.h"
using namespace std;
// 测试UF1
int main() {
// 使用10000的数据规模
int n = 10000;
// 虽然isConnected只需要O(1)的时间, 但由于union操作需要O(n)的时间
// 总体测试过程的算法复杂度是O(n^2)的
UnionFindTestHelper::testUF1(n);
return 0;
}
结果显示,如果执行100 000次操作,合并和查询两个数是否同一集合就需要40s的时间,因为在合并的测试那里是O(n*n)的时间复杂度。
2.对原始并查集进行改进
先把每一个元素都看作是一个节点,然后想办法构成一个子节点指向父节点的树!
用到树就离不开指针,假设最初每一个节点的指针都指向自己,现在要合并某两个节点,那就让其中一个节点指向另一个节点,比如下图中的5和6,再比如438和9合并,就让9指向8,也就是那一串的根节点,当然指向4也是可以,但是会增加树的深度,不方便后续查找,所以直接指向根(具体谁指向谁会在后期优化)。
升级后的代码如下:
1.下面该文件用了新的命名空间,这样就避免和第一个同类型的文件同名函数冲突。
2.改进后的代码添加了指针(突然发现第一版也用了指针,因为在开辟堆空间的时候要用),这个指针的目的是为了指向父节点,从find函数就能看到,上一版的find函数直接利用指针数组形式返回要查找的内容,升级后返回的直接是该集合的根节点!判断是否属于同一集合就是判断他们的根节点是否相等,而不是上面那种判断两个数是否相等!最后合并的时候就是把其中一个根节点指向另一个根节点!
3.这里的指针并不是真的指针,初始化的时候parent[i]=i就表示第i个元素指向自己的父节点!尽管形式和第一版相同但意义不同了!
#ifndef INC_03_QUICK_UNION_UNIONFIND2_H
#define INC_03_QUICK_UNION_UNIONFIND2_H
#include <cassert>
using namespace std;
// 我们的第二版Union-Find
namespace UF2{
class UnionFind{
private:
// 我们的第二版Union-Find, 使用一个数组构建一棵指向父节点的树
// parent[i]表示第i个元素所指向的父节点
int* parent;
int count; // 数据个数
public:
// 构造函数
UnionFind(int count){
parent = new int[count];
this->count = count;
// 初始化, 每一个parent[i]指向自己, 表示每一个元素自己自成一个集合
for( int i = 0 ; i < count ; i ++ )
parent[i] = i;
}
// 析构函数
~UnionFind(){
delete[] parent;
}
// 查找过程, 查找元素p所对应的集合编号
// O(h)复杂度, h为树的高度
int find(int p){
assert( p >= 0 && p < count );
// 不断去查询自己的父亲节点, 直到到达根节点
// 根节点的特点: parent[p] == p
while( p != parent[p] )
p = parent[p];
return p;
}
// 查看元素p和元素q是否所属一个集合
// O(h)复杂度, h为树的高度
bool isConnected( int p , int q ){
return find(p) == find(q);
}
// 合并元素p和元素q所属的集合
// O(h)复杂度, h为树的高度
void unionElements(int p, int q){
int pRoot = find(p);
int qRoot = find(q);
if( pRoot == qRoot )
return;
parent[pRoot] = qRoot;
}
};
}
#endif //INC_03_QUICK_UNION_UNIONFIND2_H
最后结果显示,同样20000个操作,上一个需要40多秒,而改进后的反向指针二叉树只需要20多秒,节省了一半的时间,效率提高了一倍!
3.再升级后的并查集
同样这个图里,834和9合并的时候,如果系统把8指向9了怎么办?这样的话,想要找到新集合的根节点,就需要更多次数的遍历循环,所以要对哪个根节点指向哪个根节点进行条件限制,**我们限制必须让集合元素少的指向集合元素多的!具体实现就需要重新定义一个元素个数指针,同样构造函数开辟空间并赋初值,然后在最后的合并函数中进行判断优化。**具体代码如下:
#ifndef INC_04_OPTIMIZE_BY_SIZE_UNIONFIND3_H
#define INC_04_OPTIMIZE_BY_SIZE_UNIONFIND3_H
#include <cassert>
using namespace std;
// 我们的第三版Union-Find
namespace UF3{
class UnionFind{
private:
int* parent; // parent[i]表示第i个元素所指向的父节点
int* sz; // sz[i]表示以i为根的集合中元素个数
int count; // 数据个数
public:
// 构造函数
UnionFind(int count){
parent = new int[count];
sz = new int[count];
this->count = count;
for( int i = 0 ; i < count ; i ++ ){
parent[i] = i;
sz[i] = 1;//每一个元素初值都是1
}
}
// 析构函数
~UnionFind(){
delete[] parent;
delete[] sz;
}
// 查找过程, 查找元素p所对应的集合编号
// O(h)复杂度, h为树的高度
int find(int p){
assert( p >= 0 && p < count );
// 不断去查询自己的父亲节点, 直到到达根节点
// 根节点的特点: parent[p] == p
while( p != parent[p] )
p = parent[p];
return p;
}
// 查看元素p和元素q是否所属一个集合
// O(h)复杂度, h为树的高度
bool isConnected( int p , int q ){
return find(p) == find(q);
}
// 合并元素p和元素q所属的集合
// O(h)复杂度, h为树的高度
void unionElements(int p, int q){
int pRoot = find(p);
int qRoot = find(q);
if( pRoot == qRoot )
return;
// 根据两个元素所在树的元素个数不同判断合并方向
// 将元素个数少的集合合并到元素个数多的集合上
if( sz[pRoot] < sz[qRoot] ){
parent[pRoot] = qRoot;
sz[qRoot] += sz[pRoot];
}
else{
parent[qRoot] = pRoot;
sz[pRoot] += sz[qRoot];
}
}
};
}
#endif //INC_04_OPTIMIZE_BY_SIZE_UNIONFIND3_H
最后结果显示,同样2万次操作,再升级后的版本只需要0.03s就可执行完毕,200万只需要0.7s!
4.第3次升级的并查集查询(最优秀的!)
当出现下图这种情况,如果依然按照元素少指向元素多,就会使本来深度为3变成了深度为4,所以新的办法就是让层数少的集合指向层数多的集合!
修改办法很简单,把上一个代码里的sz指针换成rank指针,rank就表示层数!
然后在最后的合并阶段,如果一个层数小,就把层数小的集合指向层数大的,而且还不需要修改合并后的层数,因为本身就没有变!只有当两个集合层数相同时才需要进行加一操作!
具体代码如下:
#ifndef INC_05_OPTIMIZE_BY_RANK_UNIONFIND3_H
#define INC_05_OPTIMIZE_BY_RANK_UNIONFIND3_H
#include <cassert>
using namespace std;
// 我们的第四版Union-Find
namespace UF4{
class UnionFind{
private:
int* rank; // rank[i]表示以i为根的集合所表示的树的层数
int* parent; // parent[i]表示第i个元素所指向的父节点
int count; // 数据个数
public:
// 构造函数
UnionFind(int count){
parent = new int[count];
rank = new int[count];
this->count = count;
for( int i = 0 ; i < count ; i ++ ){
parent[i] = i;
rank[i] = 1;
}
}
// 析构函数
~UnionFind(){
delete[] parent;
delete[] rank;
}
// 查找过程, 查找元素p所对应的集合编号
// O(h)复杂度, h为树的高度
int find(int p){
assert( p >= 0 && p < count );
// 不断去查询自己的父亲节点, 直到到达根节点
// 根节点的特点: parent[p] == p
while( p != parent[p] )
p = parent[p];
return p;
}
// 查看元素p和元素q是否所属一个集合
// O(h)复杂度, h为树的高度
bool isConnected( int p , int q ){
return find(p) == find(q);
}
// 合并元素p和元素q所属的集合
// O(h)复杂度, h为树的高度
void unionElements(int p, int q){
int pRoot = find(p);
int qRoot = find(q);
if( pRoot == qRoot )
return;
// 根据两个元素所在树的元素个数不同判断合并方向
// 将元素个数少的集合合并到元素个数多的集合上
if( rank[pRoot] < rank[qRoot] ){
parent[pRoot] = qRoot;
}
else if( rank[qRoot] < rank[pRoot]){
parent[qRoot] = pRoot;
}
else{ // rank[pRoot] == rank[qRoot]
parent[pRoot] = qRoot;
rank[qRoot] += 1; // 此时, 我维护rank的值
}
}
};
}
#endif //INC_05_OPTIMIZE_BY_RANK_UNIONFIND3_H
5.并查集的最大优化–路径压缩(时间复杂度近乎O(1))
这一次优化不再是合并时优化,而是在查找的时候进行优化,查找的时候不再依次向上查找,而是从叶子节点开始,比如寻找4的根节点,先让它指向父亲的父亲,这样4就指向2,再让2指向父亲的父亲,这样2就指向了0。这样寻找4的根节点两步就完成了!
修改方式就是把find函数添加一行代码即可:
int find(int p){
assert( p >= 0 && p < count );
// path compression 1
while( p != parent[p] ){
parent[p] = parent[parent[p]];//让p直接指向父亲的父亲
p = parent[p];
}
return p;
// path compression 2, 递归算法
// if( p != parent[p] )
// parent[p] = find( parent[p] );//直接得到根节点
// return parent[p];
}