首先看看什么叫并查集。
并查集
并查集(Union-find Sets)是一种非常精巧而实用的数据结构,它主要用于处理一些不相交集合的合并问题。一些常见的用途有求连通子图、求最小生成树的 Kruskal 算法和求最近公共祖先(Least Common Ancestors, LCA)等。
使用并查集时,首先会存在一组不相交的动态集合 S={S1,S2,⋯,Sk},一般都会使用一个整数表示集合中的一个元素。
每个集合可能包含一个或多个元素,并选出集合中的某个元素作为代表。每个集合中具体包含了哪些元素是不关心的,具体选择哪个元素作为代表一般也是不关心的。我们关心的是,对于给定的元素,可以很快的找到这个元素所在的集合(的代表),以及合并两个元素所在的集合,而且这些操作的时间复杂度都是常数级的。
并查集的基本操作有三个:
makeSet(s):建立一个新的并查集,其中包含 s 个单元素集合。
unionSet(x, y):把元素 x 和元素 y 所在的集合合并,要求 x 和 y 所在的集合不相交,如果相交则不合并。
find(x):找到元素 x 所在的集合的代表,该操作也可以用于判断两个元素是否位于同一个集合,只要将它们各自的代表比较一下就可以了。
并查集的实现原理也比较简单,就是使用树来表示集合,树的每个节点就表示集合中的一个元素,树根对应的元素就是该集合的代表,如图 1 所示。
图 1 并查集的树表示
图中有两棵树,分别对应两个集合,其中第一个集合为 {a,b,c,d},代表元素是 a;第二个集合为 {e,f,g},代表元素是 e。
树的节点表示集合中的元素,指针表示指向父节点的指针,根节点的指针指向自己,表示其没有父节点。沿着每个节点的父节点不断向上查找,最终就可以找到该树的根节点,即该集合的代表元素。
现在,应该可以很容易的写出 makeSet 和 find 的代码了,假设使用一个足够长的数组来存储树节点(很类似之前讲到的静态链表),那么 makeSet 要做的就是构造出如图 2 的森林,其中每个元素都是一个单元素集合,即父节点是其自身:
图 2 构造并查集初始化
Find版本
public static class UnionFind1{
//保存自己属于哪个集合
private int[] array;
//构造方法
public UnionFind1(int size) {
// TODO Auto-generated constructor stub
array = new int[size];
for(int i=0;i<array.length;i++) array[i]=i;
}
//返回数组大小
public int size() {
return array.length;
}
//返回p是属于哪个集合
public int find(int p) {
return array[p];
}
//判断两个元素是不是属于同一集合
public boolean isConnected(int a, int b) {
return find(a) == find(b);
}
//合并两个集合,比如,下面的循环就是将和p是一个集合的合并到和q是一个集合的去了
public void unionElements(int p, int q) {
int pID = find(p);
int qID = find(1);
//他们两个本来就是相连的
if(qID == pID) return;
for(int i=0;i<array.length;i++) {
if(array[i]==pID ) array[i] = qID;
}
}
}
Union版本
public static class UnionFind2 {
//保存根结点元素
private int[] parents;
//构造方法,初始化全是指向自己
public UnionFind2(int size) {
// TODO Auto-generated constructor stub
parents = new int[size];
for(int i=0;i<parents.length;i++) parents[i]=i;
}
//返回数组长度
public int size() {
return parents.length;
}
//判断两个元素是否在同一集合
public boolean isConnected(int a, int b) {
return find(a)== find(b);
}
//向上找到根结点,形象起来就是一个树
public int find(int e) {
while(e!=parents[e]) e = parents[e];
return e;
}
//合并两个
public void unionElements(int p, int q) {
int pRoot = find(p);
int qRoot = find(q);
//本身就是一个根结点,说明在同一个集合
if(qRoot == pRoot) return;
parents[pRoot] = qRoot;
}
}
测试一下Find版本和Union版本
public static double testfind(UnionFind1 UF, int m) {
//设置开始时间
long startTime = System.nanoTime();
Random random = new Random();
//合并集合
for(int i=0;i<m;i++) {
int p = random.nextInt(UF.size());
int q = random.nextInt(UF.size());
UF.unionElements(p, q);
}
//判断是否同一集合
for(int i=0;i<m;i++) {
int p = random.nextInt(UF.size());
int q = random.nextInt(UF.size());
UF.isConnected(p, q);
}
//结束时间
long endtime = System.nanoTime();
//返回时差
return (endtime-startTime)/1000000000.0;
}
public static double testunion(UnionFind2 UF, int m) {
long startTime = System.nanoTime();
Random random = new Random();
for(int i=0;i<m;i++) {
int p = random.nextInt(UF.size());
int q = random.nextInt(UF.size());
UF.unionElements(p, q);
}
for(int i=0;i<m;i++) {
int p = random.nextInt(UF.size());
int q = random.nextInt(UF.size());
UF.isConnected(p, q);
}
long endtime = System.nanoTime();
return (endtime-startTime)/1000000000.0;
}
main函数调用上面的test
public static void main(String[] args) {
int size = 100000;//元素个数
int m = 10000;//操作次数(合并或者比较是否同一集合)
System.out.println(testfind(new UnionFind1(size), m));
System.out.println(testunion(new UnionFind2(size), m));
}
测试1
size=100000,m=10000
size=100000 , m=100000
由上面测试结果可以看出,操作次数增加哀乐,我们的Union版本明显变慢了。因为元素个数太多,我们Union版本的树的深度也就高了,要知道Union版本不管是合并还是查找是否同一集合时间复杂度都是O(h):h是树高度。而Find版本明显就是查找是否相等的时间复杂度是O(1),而合并复杂度是O(N)
对Union版本进行优化
基于size优化
我们上面的union版本其实有个问题:就是合并的时候没有注意高度的变化,看这样子:
可以看到,那么不断合并,树的高度越来越高,而基于size就是让节点个数少的往节点个数高的合并
这样多次合并还是只有高度2,不会增加那么快。
public static class UnionFind3 {
private int[] parents;
private int[] sz;//记录每棵树的节点个数
public UnionFind3(int size) {
// TODO Auto-generated constructor stub
parents = new int[size];
sz = new int[size];
for(int i=0;i<parents.length;i++) {
parents[i]=i;
sz[i]=1;//每个根结点的一开始都只有一个节点
}
}
public int size() {
return parents.length;
}
public boolean isConnected(int a, int b) {
return find(a)== find(b);
}
//向上找到根结点
public int find(int e) {
while(e!=parents[e]) e = parents[e];
return e;
}
public void unionElements(int p, int q) {
int pRoot = find(p);
int qRoot = find(q);
//本身就是一个根结点,说明在同一个集合
if(qRoot == pRoot) return;
//节点数少的合并到节点数多的。
if(sz[pRoot]<sz[qRoot]) {
parents[pRoot] = qRoot;
sz[qRoot] += sz[pRoot];
}else {
parents[qRoot]= pRoot;
sz[pRoot] += sz[qRoot];
}
}
}
基于rank树的高度优化
上面基于size方法看似很优秀,但是也有缺点,它只注意size的数值,并没有真正从树的高度去下手,比如
但是基于rank就是这样:
那么我们的层级明显就要比基于size的要少一些,所以查找就方便很多了。
public static class UnionFind4 {
private int[] parents;
//rank[i]表示i为根的集合所表示的树的层数,而不是上面的基于元素个数大小
private int[] rank;
public UnionFind4(int size) {
// TODO Auto-generated constructor stub
parents = new int[size];
rank = new int[size];
for(int i=0;i<parents.length;i++) {parents[i]=i; rank[i]=1;}
}
public int size() {
return parents.length;
}
public boolean isConnected(int a, int b) {
return find(a)== find(b);
}
//向上找到根结点
public int find(int e) {
while(e!=parents[e]) e = parents[e];
return e;
}
public void unionElements(int p, int q) {
int pRoot = find(p);
int qRoot = find(q);
//本身就是一个根结点,说明在同一个集合
if(qRoot == pRoot) return;
//根据根结点树的高度来判断合并方向
//层级矮的树往层级高的树合并并不需要维护rank
if(rank[pRoot] <rank[qRoot]) {
parents[pRoot] = qRoot;
}else if(rank[pRoot]>rank[qRoot]) {
parents[qRoot] = pRoot;
}else {
//这时候就会高度+1,比如最开始1,2两个元素合并,就是1<-2,高度为2,如果有1<-2,3<-4合并,就是1<-2,1<-3<-4高度就是3
parents[pRoot] = qRoot;
rank[qRoot] +=1;
}
}
}
测试一下上面四个版本
结果
可见基于size和基于rank优化结果都挺不错的。
可以看到,在十万级别的数据量,rank可能还略微差一点,但是当你到百万级别,就rank好一些了,而且我当前数据合并是顺序合并,并不是扰乱合并,所以实验结果会有些差别。
下面基于rank继续优化:路径压缩
直接修改find方法,就是如果当前节点的父亲节点,仍然不是根结点,那么就可能祖父节点可能是根结点,干脆直接让他直接指向祖父节点也不会影响对吧。
private int find(int p) {
while (p != parents[p]) {
parents[p] = parents[parents[p]];
p = parents[p];
}
return p;
}
先将测试数据跳到百万级别
只测试基于size,基于rank和基于rank的路径压缩
结果这么叼,百万级别的基于路径压缩结果都这么好。
看个题目吧。
按公因数计算最大组件大小
给定一个由不同正整数的组成的非空数组 A,考虑下面的图:
- 有 A.length 个节点,按从 A[0] 到 A[A.length - 1] 标记;
- 只有当 A[i] 和 A[j] 共用一个大于 1 的公因数时,A[i] 和 A[j] 之间才有一条边。
返回图中最大连通组件的大小。
考虑这个题目,明显就是一个并查集的问题
首先我们要知道,找公因数方法,很多人一对于这种东西,马上就要去找性能最好,比如辗转相除法,但是呢,经过我的实验,它的计算会超时,因为它要不断重复相除,有许多重复计算,这里就直接采用质因数分解
这里的话,我们先找到最大的数W,那么我们计算它的公因数最多循环到sqrt(W)即可。而且,因为数组中每个数肯定小于W,那么公因数遍历最多到sqrt(x)即可。
那么呢,我们提取了公因数之后,就将他们合并,然后找出最大的。
这样我们仔细看代码,时间复杂度是多少,首先要遍历数组个数A,然后遍历到他们的sqrt(x),这里为了取上限,最多是sqrt(w),所以时间复杂度就是O(A*sqrt(w)),空间复杂度就是O(A)
package ByteDance;
/*
* 给定一个由不同正整数的组成的非空数组 A,考虑下面的图:
有 A.length 个节点,按从 A[0] 到 A[A.length - 1] 标记;
只有当 A[i] 和 A[j] 共用一个大于 1 的公因数时,A[i] 和 A[j] 之间才有一条边。
返回图中最大连通组件的大小。
来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/largest-component-size-by-common-factor
著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。
* */
public class p13 {
public static void main(String[] args) {
p13 paP13 = new p13();
paP13.largestComponentSize(new int[] {4,6,15,35});
}
public int largestComponentSize(int[] A) {
int maxVal = 0;
for (int num : A) {
maxVal = Math.max(maxVal, num);
}
// 0 位置不使用,因此需要 + 1
UnionFind unionFind = new UnionFind(maxVal + 1);
for (int num : A) {
double upBound = Math.sqrt(num);
for (int i = 2; i <= upBound; i++) {
if (num % i == 0) {
unionFind.unionElements(num, i);
unionFind.unionElements(num, num / i);
}
}
}
// 将候选数组映射成代表元,统计代表元出现的次数,找出最大者
int[] cnt = new int[maxVal + 1];
int res = 0;
for (int num : A) {
int root = unionFind.find(num);
cnt[root]++;
res = Math.max(res, cnt[root]);
}
return res;
}
public int zhan(int a,int b) {
// System.out.print(a+":"+b+":::");
int r = -1;
if(a<b) {r=a;a=b;b=r;}
while(r!=0) {
r = a%b;
a=b;
b=r;
}
// System.out.println(a);
return a;
}
public static class UnionFind {
private int[] parents;
//rank[i]表示i为根的集合所表示的树的层数,而不是上面的基于元素个数大小
private int[] rank;
private int[] size;
public UnionFind(int size) {
// TODO Auto-generated constructor stub
parents = new int[size];
rank = new int[size];
this.size = new int[size];
for(int i=0;i<parents.length;i++) {parents[i]=i; rank[i]=1; this.size[i]=1;}
}
public int size() {
return parents.length;
}
public boolean isConnected(int a, int b) {
return find(a) == find(b);
}
//向上找到根结点
public int find(int e) {
while(e!=parents[e])
{
parents[e] = parents[parents[e]];
e = parents[e];
}
return e;
}
public int unionElements(int p, int q) {
int pRoot = find(p);
int qRoot = find(q);
//本身就是一个根结点,说明在同一个集合
if(qRoot == pRoot) return size[qRoot];
//根据根结点树的高度来判断合并方向
//层级矮的树往层级高的树合并并不需要维护rank
if(rank[pRoot] <rank[qRoot]) {
parents[pRoot] = qRoot;
size[qRoot] += size[pRoot];
return this.size[qRoot];
}else if(rank[pRoot]>rank[qRoot]) {
parents[qRoot] = pRoot;
size[pRoot]+= size[qRoot];
return this.size[pRoot];
}else {
//这时候就会高度+1,比如最开始1,2两个元素合并,就是1<-2,高度为2,如果有1<-2,3<-4合并,就是1<-2,1<-3<-4高度就是3
parents[pRoot] = qRoot;
size[qRoot] += size[pRoot];
rank[qRoot] +=1;
return this.size[qRoot];
}
}
}
}