1. 并查集概念
问题的输入是一列整数对,其中每个整数都表示一个某种类型的对象,一对整数 p,q 可以被理解为“p 和 q 是相连的”。我们假设“相连”是一种等价关系,这也就意味着它具有以下3个性质:
- 自反性:p和p是相连的
- 对称性:p与q相连则q与p也相连
- 传递性:p与q相连,q与r相连,则p与r也相连
作用:
- 并查集可以进行集合合并的操作(并)
- 并查集可以查找元素在哪个集合中(查)
- 并查集维护的是一堆集合(集)
主要API:
1.UF(int N)
:以整数标识(0 到 N-1)初始化 N 个触点
2. void union(int p, int q)
: 在 p 和 q 之间添加一条连接
3. int find(int p)
: p(0 到 N-1)所在的分量的标识符
4. boolean connected(int p, int q)
: 如果 p 和 q 存在于同一个分量中则返回 true
5. int count()
: 连通分量的数量
2. 并查集的3种实现
2.1 quick-find实现
查找得快
对于节点p和节点q
- 当id[p]==id[q]时 说明p,q属于同一个连通分量,二者连通
- 在同一个连通分量中id值完全相同
- 判断connected(p,q)时只需要指定id[p]是否等于id[q]即可
- connected(p,q) 如果p,q不连通,那么将所有和id[p]相同的连通分量号全部变为id[q](反之亦可) 这样就保证p,q在一个连通分量中了
import java.util.Scanner;
public class UnionFind {
private int[] id;
private int count;
public UnionFind(int N)
{
count=N;
id=new int[N];
for(int i=0;i<N;i++)
{
id[i]=i;//初始时相当于0-N-1个连通分量 编号为0-N-1
}
}
public int count()
{
return count;
}
public boolean connected(int p,int q)
{
return find(p)==find(q);
}
public int find(int p)
{
return id[p];//返回连通分号
}
public void union(int p,int q)
{
int pID=find(p);
int qID=find(q);
if(pID==qID)//p q如果已经在同一个连通分量中则不需要合并
return;
for(int i=0;i<id.length;i++)//将和p属于同一个连通分量的元素的连通编号改成和q连通分号一致
{
if(id[i]==pID)
id[i]=qID;
}
count--;//连通分量减一
}
public static void main(String[] args) {
Scanner sc=new Scanner(System.in);
int N=sc.nextInt();
UnionFind uf=new UnionFind(N);
while(sc.hasNext())
{
int p=sc.nextInt();
int q=sc.nextInt();
if(uf.connected(p, q))//p q如果已经连通则不打印连接
continue;
uf.union(p, q);
System.out.println(p+"---"+q);
}
System.out.println(uf.count()+" components");
}
}
在 quick-find 算法中,每次 find() 调用只需要访问数组一次,而归并两个分量的union() 操作访问数组的次数在 (N+3) 到 (2N+1) 之间。
优势:查找快
不足:合并较慢
2.2 quick-union实现
合并得快
quick-union和quick-find的比较:
- quick-union加快了union的速度,时间复杂度是O(1), 但是find的速度低了,是O(n)
- quick-find加快了find的速度,时间复杂度是O(1), 但是union的速度降低了,是O(n)
思想:
在实现 find() 方法时,我们从给定的触点开始,由它的链接得到另一个触点,再由这个触点的链接到达第三个触点,如此继续跟随着链接直到到达一个根触点,即链接指向自己的触点(你将会看到,这样一个触点必然存在)。当且仅当分别由两个触点开始的这个过程到达了同一个根触点时它们存在于同一个连通分量之中
可以理解成最上面一行是数组的下标0-9,下面一行是数值的元素值,当数组下标和对应的元素不相等时,就将数组元素作为下标达到下一个数组元素, 以find[5]为例:
- id[5]==0!=5 下一步 id[0]
- id[0]==1!=0 下一步 id[1]
- id[1]==1 结束
为了保证这个过程的有效性,我们需要union(p, q) 来保证这一点。它的实现很简单:我们由 p 和 q 的链接分别找到它们的根触点,然后只需将一个根触点链接到另一个即可将一个分量重命名为另一个分量,因此这个算法叫做 quick-union。
import java.util.Scanner;
public class UnionFind {
private int[] id;
private int count;
public UnionFind(int N)
{
count=N;
id=new int[N];
for(int i=0;i<N;i++)
{
id[i]=i;//初始时相当于0-N-1个连通分量 编号为0-N-1
}
}
public int count()
{
return count;
}
public boolean connected(int p,int q)
{
return find(p)==find(q);
}
public int find(int p)
{
while(p!=id[p])
p=id[p];//寻找p所在的根节点
return p;
}
public void union(int p,int q)
{
int pROOT=find(p);
int qROOT=find(q);
if(pROOT==qROOT)//p q如果已经在同一个连通分量中则不需要合并
return;
id[pROOT]=qROOT;//p所在分量的根节点连接到q所在分量的根节点上
count--;//连通分量减一
}
public static void main(String[] args) {
Scanner sc=new Scanner(System.in);
int N=sc.nextInt();
UnionFind uf=new UnionFind(N);
while(sc.hasNext())
{
int p=sc.nextInt();
int q=sc.nextInt();
if(uf.connected(p, q))
continue;
uf.union(p, q);
System.out.println(p+"---"+q);
}
System.out.println(uf.count()+" components");
}
}
quick-union 算法中的 find() 方法访问数组的次数为 1 加上给定触点所对应的节点的深度的两倍。union() 和 connected() 访问数组的次数为两次 find() 操作(如果 union() 中给定的两个触点分别存在于不同的树中则还需要加 1)。
2.3 加权quick-union
上面的quick-union存在以下问题:
由于在union中是随意将一棵树连接到另外一棵树上,因此会出现树的深度过高的问题,在加权quick-union中可以保证小树连接到大的树上:
import java.util.Scanner;
public class UnionFind {
private int[] id;//每个位置对应的连通号
private int[] sz;//每个连通量(树)的节点数目
private int count;
public UnionFind(int N)
{
count=N;
id=new int[N];
for(int i=0;i<N;i++)
{
id[i]=i;//初始时相当于0-N-1个连通分量 编号为0-N-1
}
sz=new int[N];
for(int i=0;i<N;i++)
sz[i]=1; //初始时,每个连通分量只有1个节点,节点数目是1
}
public int count()
{
return count;
}
public boolean connected(int p,int q)
{
return find(p)==find(q);
}
public int find(int p)
{
while(p!=id[p])
p=id[p];
return p;
}
public void union(int p,int q)
{
int pROOT=find(p);
int qROOT=find(q);
if(pROOT==qROOT)//p q如果已经在同一个连通分量中则不需要合并
return;
if(sz[pROOT]<sz[qROOT])//p所在的树小
{
id[pROOT]=qROOT;//p所在树的根节点连接到q所在树的根节点上
sz[qROOT]+=sz[pROOT];
}
else
{
id[qROOT]=pROOT;
sz[pROOT]+=sz[qROOT];
}
count--;//连通分量减一
}
public static void main(String[] args) {
Scanner sc=new Scanner(System.in);
int N=sc.nextInt();
UnionFind uf=new UnionFind(N);
while(sc.hasNext())
{
int p=sc.nextInt();
int q=sc.nextInt();
if(uf.connected(p, q))
continue;
uf.union(p, q);
System.out.println(p+"---"+q);
}
System.out.println(uf.count()+" components");
}
}
2.4 路径压缩
这里是对find算法的一种优化:
原始的find:
public int find(int p)
{
while(p!=parent[p])
p=parent[p];
return p;
}
优化后的find:
//调用find函数每次向树根遍历的同时,顺手将树高缩短了,最终所有树高都不会超过 3
public int find(int p)
{
while(p!=parent[p])
{
parent[p]=parent[parent[p]];//添加部分
p=parent[p];
}
return p;
}
即遍历到某个节点X时,假设此时X的父节点是Y,Y的父节点是Z,直接将X连接到Z上,Z成为X的父节点