0 定义
并查集(Union-find Sets)是一种非常精巧而实用的数据结构,它主要用于处理一些不相交集合的合并问题。一些常见的用途有求连通子图、求最小生成树的 Kruskal 算法和求最近公共祖先(Least Common Ancestors, LCA)等。
常用操作
- 查询元素a和元素b是否属于同一组
- 合并元素a和元素b所在的组
1 结构
1.1、初始化
我们准备n个节点来表示n个元素。最开始时没有边。
2.2、合并
像下图一样,从一个组的根向另一个组的根连边,这样两棵树就变成了一棵树,也就是把两个组合并为了一个组。
3.3、查询
为了查询两个节点是否属于同一组,我们需要沿着树向上走,来查询包含这个元素的树的根是谁。如果两个节点走到了同一个根,那么就可以知道他们属于同一组。
在下图,2和5都走到了1,因此他们为同一组。另一方面,由于7走到的是6,因此与2和5属于不同组
2 优化
2.1、路径压缩
上述方法虽然可以找到父结点,可是每次查找的时候都要从当前结点位置一步一步地走到根节点处才能返回答案,如果数据量较大的时候,显然是很耗时的
所以:路径压缩————将某个根结点下的所有子结点都指向该根结点
如此一来,我们就可以直接从目标结点的位置一步找到其根结点
2.2、按秩合并
使用秩来表示树高度的上界,在合并时,总是将具有较小秩的树根指向具有较大秩的树根。简单的说,就是总是将比较矮的树作为子树,添加到较高的树中。
- 对于每棵树,记录这棵树的高度(rank)
- 合并时如果两个数的rank不同,那么从rank小的向rank大的连边。
实现
class USet{
public int par[];//父亲
public int rank[];//树的高度
//初始化n个元素
public USet(int n){
par=new int[n];
rank=new int[n];
for(int i=0;i<n;i++){
par[i]=i;
rank[i]=0;
}
}
//查询树的根
public int find(int x){
if(par[x]==x) return x;
else
return find(par[x]);
}
//合并x和y所属的集合
public void unite(int x,int y){
x=find(x);
y=find(y);
if(x==y) return;
if(rank[x]<rank[y]){
par[x]=y;
}else{
par[y]=x;
if(rank[x]==rank[y]) rank[x]++;
}
}
//判断x和y是否属于同一个集合
public boolean same(int x,int y){
return find(x)==find(y);
}
}
案例
1、547-朋友圈
班上有 N 名学生。其中有些人是朋友,有些则不是。他们的友谊具有是传递性。如果已知 A 是 B 的朋友,B 是 C 的朋友,那么我们可以认为 A 也是 C 的朋友。所谓的朋友圈,是指所有朋友的集合。
给定一个 N * N 的矩阵 M,表示班级中学生之间的朋友关系。如果M[i][j] = 1,表示已知第 i 个和 j 个学生互为朋友关系,否则为不知道。你必须输出所有学生中的已知的朋友圈总数。
示例 1:
输入:
[[1,1,0],
[1,1,0],
[0,0,1]]
输出:2
解释:已知学生 0 和学生 1 互为朋友,他们在一个朋友圈。
第2个学生自己在一个朋友圈。所以返回 2 。
示例 2:
输入:
[[1,1,0],
[1,1,1],
[0,1,1]]
输出:1
解释:已知学生 0 和学生 1 互为朋友,学生 1 和学生 2 互为朋友,所以学生 0 和学生 2 也是朋友,所以他们三个在一个朋友圈,返回 1 。
提示:
- 1 <= N <= 200
- M[i][i] == 1
- M[i][j] == M[j][i]
代码
//引入一下上面并查集的类
class Solution {
public int findCircleNum(int[][] M) {
int n=M.length;
USet uset=new USet(n);
for(int i=0;i<n;i++){
for(int j=i;j<n;j++){
if(M[i][j]==1) uset.unite(i,j);
}
}
int res=0;
for(int i=0;i<n;i++){
if(uset.find(i)==i) res++;
}
return res;
}
}
2、924-尽量减少恶意软件的传播
在节点网络中,只有当 graph[i][j] = 1 时,每个节点 i 能够直接连接到另一个节点 j。
一些节点 initial 最初被恶意软件感染。只要两个节点直接连接,且其中至少一个节点受到恶意软件的感染,那么两个节点都将被恶意软件感染。这种恶意软件的传播将继续,直到没有更多的节点可以被这种方式感染。
假设 M(initial) 是在恶意软件停止传播之后,整个网络中感染恶意软件的最终节点数。
我们可以从初始列表中删除一个节点。如果移除这一节点将最小化 M(initial), 则返回该节点。如果有多个节点满足条件,就返回索引最小的节点。
请注意,如果某个节点已从受感染节点的列表 initial 中删除,它以后可能仍然因恶意软件传播而受到感染。
示例 1:
输入:graph = [[1,1,0],[1,1,0],[0,0,1]], initial = [0,1]
输出:0
示例 2:
输入:graph = [[1,0,0],[0,1,0],[0,0,1]], initial = [0,2]
输出:0
示例 3:
输入:graph = [[1,1,1],[1,1,1],[1,1,1]], initial = [1,2]
输出:1
提示:
- 1 < graph.length = graph[0].length <= 300
- 0 <= graph[i][j] == graph[j][i] <= 1
- graph[i][i] == 1
- 1 <= initial.length < graph.length
- 0 <= initial[i] < graph.length
思路
给并查集添加一个计数的属性count,也就是每个根节点存储一下这棵树的节点数量。
代码
//引入一下上面并查集的类,添加一个count属性
class Solution {
public int minMalwareSpread(int[][] graph, int[] initial) {
int n=graph.length;
USet uset=new USet(n);
for(int i=0;i<n;i++){
for(int j=i;j<n;j++){
if(graph[i][j]==1) uset.unite(i,j);
}
}
int res=301;
int k=0;
int count[]=new int [n];
for(int i:initial){
int temp=0;
for(int r=0;r<n;r++){
count[r]=uset.count[r];
}
for(int j:initial){
if(i!=j){
temp+=count[uset.find(j)];
count[uset.find(j)]=0;
}
}
if(res>=temp){
if(res==temp){
if(k>i) k=i;
}else{
k=i;
res=temp;
}
}
}
return k;
}
}
3、1319-连通网络的操作次数
用以太网线缆将 n 台计算机连接成一个网络,计算机的编号从 0 到 n-1。线缆用 connections 表示,其中 connections[i] = [a, b] 连接了计算机 a 和 b。
网络中的任何一台计算机都可以通过网络直接或者间接访问同一个网络中其他任意一台计算机。
给你这个计算机网络的初始布线 connections,你可以拔开任意两台直连计算机之间的线缆,并用它连接一对未直连的计算机。请你计算并返回使所有计算机都连通所需的最少操作次数。如果不可能,则返回 -1 。
示例 1:
输入:n = 4, connections = [[0,1],[0,2],[1,2]]
输出:1
解释:拔下计算机 1 和 2 之间的线缆,并将它插到计算机 1 和 3 上。
思路
只要能够实现,那么答案就是网络的数量减一
代码
//引入一下上面并查集的类
class Solution {
public int makeConnected(int n, int[][] connections) {
int m=connections.length;
if(m<n-1) return -1;
USet uset=new USet(n);
for(int i=0;i<m;i++){
uset.unite(connections[i][0],connections[i][1]);
}
//找出其中网络的数量
int res=0;
for(int i=0;i<n;i++){
if(uset.find(i)==i) res++;
}
return res-1;
}
}