并查集结构用于将一定量的元素合并到同一个集合,并随时查看两个元素所在的集合是否是同一个。即并查集结构必须提供isSameSet()接口与uinon()接口。实现并查集时有多种结构可以考虑,如list、hashMap等,但这些结构在合并时都需要进行集合的合并,代价比较高,最高效的做法是底层结构采用一种类似单链表的结构。
实现单链表时,一个节点的值就是输入的元素的值,而节点的指针指向同一个集合的另一个节点,所以一个集合就是一个单链表,该单链表的头节点就是这个集合的代表节点。
现在有5个元素,初始化的结果如下。它们的指针都指向自身,即自身自成一个集合,或者说每一个节点都是自己所在集合的代表节点。
实现union()接口时,将代表两个元素的节点所在的链表的头节点变为1个节点即可。
1元素与2元素所在的链表的长度相同,所以可以将其中任意一个链表挂在另外一个的头节点上即可。原则上你可以将其中一个链表挂在另外一个的任意节点上,但挂在头节点在执行isSameSet时所需要的操作更少。同样的原因,一般会将链表长度较短的挂在长链表上。
在实现isSameSet()接口时,判断两个元素是否在同一个集合,就是判断代表两个元素的节点所在的链表的头节点是否相同。
可以看出,并查集结构最耗时的操作就是寻找头节点时的操作,针对这个操作有优化的方法。在找到头节点后,将寻找头节点过程中经过的节点的指针都指向头节点,使链表扁平化。
并查集结构的时间复杂度非常低,设输入的数据量为N,执行isSameSet()的次数为a,执行union()的次数为b,如果a + b 接近或超过O(N)的话,那么并查集的时间复杂度可以看作O(1)。
template<typename T> class Node{
public:
T value;
Node<T>* father;
int size;
Node(T value, T* father){
this->value = value;
this->father = father;
this->length = 0;
}
Node(T value){
this->value = value;
this->father = NULL;
this->length = 0;
}
Node(){
this->value = 0;
this->father = 0;
this->size = 0;
}
};
template<typename T> class UnionFind{
public:
Node<T>* nodes;
int nodeSize;
map<T,Node<T>* > headMap;//元素对应的Node
UnionFind(T arr[],int n){
nodes = new Node<T>[5]();
nodeSize = n;
for(int i = 0; i < n; i++){
nodes[i].value = arr[i];
nodes[i].father = &nodes[i];
nodes[i].size = 1;
headMap[arr[i]] = &nodes[i];;
}
}
~UnionFind(){
delete nodes;
}
//寻找一个集合的代表节点
//优化:将上溯寻找过程中经过的节点都直接挂在头节点上,使它扁平化。
Node<T>* findHead(Node<T>* node){
stack<Node<T>*> s;
for( ; node->father != node; node = node->father){
s.push(node);
}
while(!s.empty()){
Node<T>* n = s.top();
n->father = node;
s.pop();
}
return node;
}
bool isSameSet(T a,T b){
if(headMap.find(a) != headMap.end() || headMap.find(b) != headMap.end()){
return findHead(headMap.find(a)->second) == findHead(headMap.find(b)->second);
}
return false;
}
bool unionSet(T a,T b) {
if(headMap.find(a) != headMap.end() || headMap.find(b) != headMap.end()){
Node<T>* node_a = headMap.find(a)->second;
Node<T>* node_b = headMap.find(b)->second;
if(node_a != node_b){
Node<T>* smallHead = node_a->size > node_b->size ? node_b : node_a;
Node<T>* bigHead = smallHead == node_a ? node_b : node_a;
smallHead->father = bigHead;
bigHead->size += smallHead->size;
return true;
}
}
return false;
}
};
扩展:岛问题
【题目】 一个矩阵中只有0和1两种值,每个位置都可以和自己的上、下、左、右 四个位置相连,如 果有一片1连在一起,这个部分叫做一个岛,求一个矩阵中有多少个岛?
【举例】 001010 111010 100100 000000 这个矩阵中有三个岛
【进阶】 如何设计一个并行算法解决这个问题
这道题目的解法非常简单,难点在于并行。
//将与传入1相邻的所有的1都改为2
void infect(int* arr,int rowSize,int colSize,int row, int col){
if(arr[row*colSize + col] == 2 || arr[row*colSize + col] == 0 || row == rowSize || col == colSize || row == -1 || col == -1){
return ;
}
if(arr[row*colSize + col] == 1){
arr[row*colSize + col] = 2;
infect(arr,rowSize,colSize,row + 1,col);
infect(arr,rowSize,colSize,row - 1,col);
infect(arr,rowSize,colSize,row,col + 1);
infect(arr,rowSize,colSize,row,col - 1);
}
}
int islands(int* arr,int rowSize,int colSize){
if(arr == NULL || rowSize <= 0 || colSize <= 0)
return 0;
int res = 0;
for(int i = 0; i < rowSize; i++){
for(int j = 0; j < colSize; j++){
if(arr[i * colSize + j] == 1){
res++;
infect(arr,rowSize,colSize,i,j);
}
}
}
return res;
}
当我们在尝试使用并行算法解决这个问题时就可以使用并查集结构。先对输入域进行划分,比如划分为两部分:
对两个部分使用不同的CPU开始计算,得出结果后对进行合并,并查集就可以在合并时使用。
两边执行完上述算法后的结果如下图,a、b、c、d分别代表一块感染区域的感染源,在边界附近同时需要记录被感染时的感染源。
在判断a、b、c、d能否合并时,可以选出一个(如a),对他的边界进行判断,如果边界上有节点是2,而与它相邻区域的节点也是2,说明它们应该是联通在一起的,此时可以执行unionSet,将两个区域合并(合并ac),此时岛的数量 - 1;继续遍历a的边界,发现b可以c联通,并且bc不在同一个集合,继续合并,岛的数量 - 1;继续遍历,发现b可以d联通,并且bd不在同一个集合,继续合并,岛的数量 - 1;继续遍历,发现a可以d联通,但ad在同一个集合,不需要合并,岛的数量为1。可以看出,并查集结构对于解决成块联通非常方便。
所以并行解决这个问题分为2步:第一步计算两部分岛的数量并收集边界信息;第二步利用并查集实现减少岛的过程。如果有多个CPU或计算单元,可以将整个输入域划分为多个部分,对每个部分计算岛的数量并收集4个边界的信息;在合并时依然利用并查集实现任意两个相邻部分的合并,这样速度就更快了。