前提假设
现在我们设想有这样的一些集合:{a,…}{b,…}{c,…}{d,…}{e,…}
现在需要实现两个功能:
- 查出a,b所在的集合是不是一个集合 isSameSet(a,b)
- 合并两个集合 union(a,b)
我们有很多的方式实现这个功能,比如:用链表、用哈希表。
- 用链表的优点和缺点:合并两个集合比较快、查询是不是同一个集合就需要遍历a链表看里面有没有b,很慢
- 用哈希表的优点和缺点:查询是不是同一个集合比较快、但是合并两个集合就需要把a的集合中所有元素导入到b集合中,很慢
相关思考
怎样实现这两个方法都很快呢,都是O(1)呢
引出并查集结构 union-find disjoint sets
假设初始时每一个集合只有一个元素,我们将这些元素进行“包裹”,并使它们的父节点指向自己。
我们称向上走一直到不能再向上走(指向自己)的元素称为代表元素。
如图所示:
如何实现文章开始时的两个功能呢?
isSameSet(a,b)
- 查出a,b所在的集合是不是一个集合即 isSameSet(a,b)方法:找到这两个集合的代表元素,看它们是不是同一个元素,是就是在一个集合里
union(a,b)
- 合并两个集合即 union(a,b)方法:
- 先看这两个集合是不是一个集合,不是进行下一步
- 看两个集合中元素数量的多少,数量少的挂到数量多的那一方。
- 具体挂的方式是,数量少的那一方的代表元素指向数量多的那一方的代表元素,即数量少的那一方的代表元素的父节点是数量多的那一方的代表元素
//
相关的优化
在向上找代表元素的过程中是可以进行优化的。
在向上找代表的元素的过程中,将中间经历过的结点在最后都指向代表元素,这样这些点下一次再找代表元素的时候就会变快捷了。
相关代码
public class test1{
//将每一个样本上面进行包裹,成为元素
public static class Element<V>{
public V value;
public Element(V value){
this.value = value;
}
}
public static class UnionFindSet<V>{
//将每一个成为元素的样本进行记录
public HashMap<V, Element<V>> elementMap;
//记录元素以及其对应的父节点
public HashMap<Element<V>, Element<V>> fatherMap;
//记录每个集合的代表元素以及该集合的大小
public HashMap<Element<V>, Integer> rankMap;
//list是传入的列表,刚开始的时候列表中的每一个值仅仅代表归属于一个集合
public UnionFindSet(List<V> list){
elementMap = new HashMap<>();
fatherMap = new HashMap<>();
rankMap = new HashMap<>();
for (V value : list){
//使样本变为元素
Element<V> element = new Element<V>(value);
//将每一个成为元素的样本进行记录
elementMap.put(value, element);
//因为刚开始一个集合只有一个元素,所以代表元素就是这唯一的一个元素
//,所以父节点是自己
fatherMap.put(element, element);
//记录代表元素,以及对应所在集合的大小
rankMap.put(element, 1);
}
}
//向上找直到不能再向上,以此找到该集合的代表元素
private Element<V> findHead(Element<V> element){
//建立一个栈保存中途的元素
Stack<Element<V>> path = new Stack<>();
//当父节点不是自己时,继续向上
while (element != fatherMap.get(element)){
//将中途元素保存在栈中
path.push(element);
element = fatherMap.get(element);
}
while (!path.isEmpty()){
//进行优化操作,使中途的元素都指向代表元素,为之后找代表元素加快速度
fatherMap.put(path.pop(), element);
}
//返回代表元素
return element;
}
//判断两样本是不是同一个集合,因为用户只认样本,我们自己设的元素
public boolean isSameSet(V a, V b){
//判断之前是不是就没加过这个样本,没加过,集合中肯定就没有,直接返回false
if (elementMap.containsKey(a) && elementMap.containsKey(b)){
//看两个集合的代表节点是不是同一个元素,是就是在同一个集合内
return findHead(elementMap.get(a)) == findHead(elementMap.get(b));
}
return false;
}
//合并两个集合
public void union(V a, V b){
if (elementMap.containsKey(a) && elementMap.containsKey(b)){
Element<V> aF = findHead(elementMap.get(a));
Element<V> bF = findHead(elementMap.get(a));
//两集合代表元素一样的话,就是一个集合,就不需要合并了
if(aF != bF){
//分出两个代表元素所在集合的元素数量大小,以确定是谁指向谁
Element<V> big = rankMap.get(aF) >= rankMap.get(bF) ? aF : bF;
Element<V> small = big == aF ? bF : aF;
//数量小的指向数量大的
fatherMap.put(small, big);
//更新代表元素Map表,更新数量大的集合尺寸
rankMap.put(big, rankMap.get(aF) + rankMap.get(bF));
//现在小的已经不是代表元素了,移除它
rankMap.remove(small);
}
}
}
}
}
复杂度分析
因为在向上找的过程中,中途的元素都指向了代表元素,所以之后再通过集合中的样本找其代表元素的时候,就更加方便了。
注意:如果你有N个样本,如果你调用findhead次数到达O(N)级别,那么你调用findhead单次的平均代价是接近O(1)
相关题目-岛问题
一个矩阵中只有0和1两种值,每个位置都可以和自己的上、下、左、右四个位置相连,如果有一片1连在一起,这个部分叫做一个岛,求一个矩阵中有多少个岛?
只给你一个CPU
相关思路:依次遍历所有点,如果遇到1就把遇到的岛数加1。在遇到1的那一刻执行感染过程,将相连的岛全部变成2,以此达到感染的目的。
public class test1{
public static int countIslands(int[][] m){
//说实话,不太理解为啥m[0] == null要加上
if (m == null || m[0] == null){
return 0;
}
//行数
int N = m.length;
//列数
int M = m[0].length;
int res = 0;
//依次遍历所有点,如果遇到1就把遇到的岛数加1。
//并执行感染过程,将相连的岛全部变成2,以此达到感染的目的
for (int i = 0; i < N; i++){
for (int j = 0; j < M; j++){
if (m[i][j] == 1){
res++;
infect(m, i, j, N, M);
}
}
}
return res;
}
//感染过程,将1变成2
public static void infect(int[][] m, int i, int j, int N, int M) {
if (i < 0 || i >= N || j < 0 || j >=M || m[i][j] != 1) {
return;
}
m[i][j] = 2;
infect(m, i + 1, j, N, M);
infect(m, i - 1, j, N, M);
infect(m, i, j + 1, N, M);
infect(m, i, j - 1, N, M);
}
public static void main(String[] args) {
int[][] mm = null;
mm[0] = null;
int[][] m1 = { { 0, 0, 0, 0, 0, 0, 0, 0, 0 },
{ 0, 1, 1, 1, 0, 1, 1, 1, 0 },
{ 0, 1, 1, 1, 0, 0, 0, 1, 0 },
{ 0, 1, 1, 0, 0, 0, 0, 0, 0 },
{ 0, 0, 0, 0, 0, 1, 1, 0, 0 },
{ 0, 0, 0, 0, 1, 1, 1, 0, 0 },
{ 0, 0, 0, 0, 0, 0, 0, 0, 0 }};
System.out.println(countIslands(m1));
int[][] m2 = { { 0, 0, 0, 0, 0, 0, 0, 0, 0 },
{ 0, 1, 1, 1, 1, 1, 1, 1, 0 },
{ 0, 1, 1, 1, 0, 0, 0, 1, 0 },
{ 0, 1, 1, 0, 0, 0, 1, 1, 0 },
{ 0, 0, 0, 0, 0, 1, 1, 0, 0 },
{ 0, 0, 0, 0, 1, 1, 1, 0, 0 },
{ 0, 0, 0, 0, 0, 0, 0, 0, 0 } };
System.out.println(countIslands(m2));
}
给你两个CPU-并行算法
相关思路:和只有一个CPU的想法差不多,但是这个时候要将图分成两部分,比如左右部分,一个CPU管左边,一个CPU管右边。这样在中间相连的岛就会被拆开,所以需要额外注意边界问题。
在利用上面的方法感染完岛之后,收集一下边界的信息,主要是记录来两个方面:
- 这片岛屿的初始感染点
- 位于边界的岛屿是由哪个初始感染点来的
将所有岛屿执行好后,现在我们将左右两边的进行合并。将边界上的岛屿是由哪个感染点感染来的分别进行集合处理。{A}{B}{C}{D}
只有边界相碰的形式才讨论,看相碰的点左右是不是同一个集合【用并查集】,不是就合并集合【用并查集】,岛屿数-1。
初始岛屿/
//进行感染操作///
//记录边界信息与整合///
更多的CPU
原理一样,记得统计自己的边界信息,比如可能统计四周全部。设计合理的合并逻辑就可以。