记录一下并查集的使用方式。
并查集是一种树型的数据结构,用于处理一些不相交集合的合并及查询问题。合并及查询即代表"并"和"查"。
并查集由一个整数数组parent[],两个函数find和connect构成。
parent[]数组记录每一个元素的前驱节点是什么,find用于查找指定元素属于哪个集合(表现形式一般为查找该元素的根节点),connect用于连接两个集合(表现形式一般为根节点不一样的两个元素)。
构建并查集的命名可以和上述不同。比如parent可以根据实际意义改为father,root等等。find一般不需要修改名称,因为在几乎所有的场景这个函数都代表查找的意思。connect也可以叫union等等。
并查集的主要作用是求连通分支数,比如无向图有多少连通分支,图中A和B两个顶点是否连通等。
一般情况数组parent初始化时将所有元素设为自己,但是也有特殊情况。比如各种岛屿包围的问题。具体情况要具体看。
对于数据是int型元素构建并查集可以使用数组,但是有时我们要为字符串等其它类型元素构建并查集,因此很多时候需要使用哈希,即unordered_map来做数组存储。
find作用是查找元素最前面的前驱元素。代码如下:
int find(int& node,vector& parent){
int q=node;
while(parent[q]!=q){
q=parent[q];
}
return q;
}
这段代码还是很好理解的。此时find复杂度最坏为O(n),平均为O(logN)。
直接看代码:
void connect(int& node1,int& node2,vector& parent){
int root1=find(node1,parent);
int root2=find(node2,parent);
if(root1==root2){
//两个元素属于同一顶点,已连通,无需作处理
return;
}
//两个元素不属于同一顶点,那么其中一个顶点的前驱节点变为另一个前驱节点。
//一般情况下parent[root1]=root2和parent[root2]=root1没有太大区别,但是
//有些情况可能只能parent[root1]=root2或parent[root2]=root1
parent[root1]=root2;
}
connect复杂度和find一样。因为调用了2次find。
举一个简单的例子,参照下题:
给定n和vector> array,n代表有n个顶点,array每个元素均是长度为2的数组,
代表array[i][0]与array[1]连通。
如下:
3,{{1,2},{0,1}}
代表3个顶点,1和2连通,0和1连通
构造完整并查集如下:
#include
#include
using namespace std;
//这里省去n和array的初始化,如果是标准输入需要在主函数进行多组输入操作。
//如果是力扣的模式就无需这一步
int n;
vector> array;
int find(int& node,vector& parent){
int q=node;
while(parent[q]!=q){
q=parent[q];
}
return q;
}
void connect(int& node1,int& node2,vector& parent){
int root1=find(node1,parent);
int root2=find(node2,parent);
if(root1==root2){
//两个元素属于同一顶点,已连通,无需作处理
return;
}
//两个元素不属于同一顶点,那么其中一个顶点的前驱节点变为另一个前驱节点。
//一般情况下parent[root1]=root2和parent[root2]=root1没有太大区别,但是
//有些情况可能只能parent[root1]=root2或parent[root2]=root1
parent[root1]=root2;
}
int main(){
vector parent(n,0);
for(int i=0;i
这种方式可以构建并查集。我们也可以构建一个类,让parent作成员变量,这样find和connect就不需要最后一个参数了。
这种方式复杂度比较高,每次connect都需要平均O(logN)的复杂度。我们可以尝试优化connect的效率。
优化一般有两种方式,路径压缩和按秩合并。
路径压缩是压缩find函数的时间,它的核心思想是在寻找根节点后递归的把所有中间节点的前驱节点改为根节点,代码如下:
int find(int& node,vector& parent){
if(parent[node]!=node){
parent[node]=find(parent[node],parent);
}
return parent[node];
}
这种方式可以一定程度压缩find函数,使得第二次查找起复杂度均为1,直到根节点下次更新。不过第一次的复杂度还是n。
单独使用路径压缩算法,每次connect的平均时间复杂度为O(α(n)),最坏为O(logN)。
α(n)为阿克曼函数的反函数,基本可以认为是常数,即便n取已知宇宙中包含的原子总数,α(n)也不会超过5,因此α(n)复杂度基本可以认为是O(1)。
按秩合并主要处理connect函数。它的原理是先建立一个parentSize数组,parentSize[i]表示以i为顶点的树的节点数或高度。我习惯使用高度。
代码如下:
void connect(int& node1,int& node2,vector& parent,vector& parentSize){
int root1=find(node1,parent);
int root2=find(node2,parent);
if(root1==root2){
//两个元素属于同一顶点,已连通,无需作处理
return;
}
//两个元素不属于同一顶点,将高度低的根节点指向为高度高的
if(parentSize[root1]
单独这种方式最坏复杂度和平均复杂度均为O(logN).
我们可以在两个函数分别使用路径压缩和按秩合并,联合使用的最好和最坏复杂度均为O(α(n))。
在力扣中我找到的最适合并查集的是第547题:力扣
这道题其实不能完全体现并查集的优势,因为使用DFS/BFS的复杂度是n^2,而并查集的复杂度是α(n)*n^2。如果将题目改为给出任意两点判断是否连通并查集优势就体现出来了。
解题代码如下:
class Solution {
public:
int find(int& num,vector& father){
if(father[num]!=num){
father[num]=find(father[num],father);
}
return father[num];
}
void connect(int& num1,int& num2,vector& father,vector& fatherSize){
int root1=find(num1,father);
int root2=find(num2,father);
if(root1==root2){
return;
}
if(fatherSize[root1]>& isConnected) {
int len=isConnected.size();
vector father(len);
vector fatherSize(len,1);
int i(0),j(0),t(0),n(len);
for(i=0;i