2024042701-disjoint-set

并查集 Disjoint-Set

一、前言

并查集的历史

1964年, Bernard A. Galler 和 Michael J. Fischer 首次描述了不相交的并查集,1975 年,Robert Tarjan 是第一个证明O(ma(n))(逆阿克曼函数)算法时间复杂度的上限,并且在 1979 年表明这是受限情况的下限。

2007 年,Sylvain Conchon 和 Jean-Christophe Filliâtre 开发并查集数据结构的半持久版本,并使用证明助手 Coq 将其正确性形式化。 “半持久”意味着结构的先前版本被有效地保留,但访问数据结构的先前版本会使以后的版本无效。它们最快的实现了几乎与非持久算法一样高效的性能且不执行复杂性分析。

二、并查集数据结构

并查集数据结构(也称为联合-查找数据结构或合并-查找集)基于数组实现的一种跟踪元素的数据结构,这些元素被划分为多个不相交(非重叠)的子集。

它提供了近乎恒定的时间操作(以逆阿克曼函数为界)来添加新集合、合并现有集合以及确定元素是否在同一个集合中。除了推荐算法、好友关系链、族谱等,并查集在 Kruskal 的算法中扮演着关键角色,用于寻找无向边加权图的最小生成树。

并查集的定义乍一看有些抽象,也不知道到底在什么场景使用。所以小傅哥给大家举个例子;在以前江湖上有很多门派,各门派见的徒子徒孙碰面难免切磋。为了不让大家打乱套,都要喊一句:”报上名来“ —— 在下叶问,佛山咏春派,师承陈华顺。那么对于这样的场景,我们可以使用并查集给各门派成员合并,方便汇总查询。如图;

  • 张无忌:既然你不是明教,也不是武当的,我就不客气了。
  • 赵敏:不客气你还能咋!我学过咏春!
  • 张无忌:看招!
  • 赵敏:张无忌放开啊,我讨厌你!😒

🤔 但各门派徒子徒孙众多,如果下回遇到赵敏的A丫鬟的Aa丫鬟,没等Aa报家门找族谱完事,也被抠脚了咋办?所以基于这样的情况,要对并查集的各级元素进行优化合并,减少排查路径。

01:粗暴合并02:数量合并03:排序合并04:压缩路径
0→6、6→0 不控制合并数量少合并到数量多排序小合并到排序大排序合并时压缩路径

为了尽可能少的检索次数到根元素,在01:粗暴合并的基础上,有了基于数量、排序的合并方式,同时还包括可以压缩路径。这样再索引到根节点的时间复杂度就又降低了。接下来小傅哥就带着大家看看各个场景的在代码中的操作过程。

三、并查集结构实现

并查集的实现非常巧妙,只基于数组就可以实现出一个树的效果(基于数组实现的还有二叉堆也是树的结构)。

public class DisjointSet {
	  // 元素
    public int[] items;
    // 数量【可选】
	public int[] count;
	// 排序【可选】
	public int[] rank;
}

并查集的元素存放在数组中,通过对数组元素的下标索引指向其他元素,构成一棵树。count 数量、rank 排序,是用于对并查集合并元素时的优化处理。

1. 默认合并 - union(1, 8)

@Override
public int find(int i) {
    if (i < 0 || i >= items.length) throw new IllegalArgumentException("Index out of range.");
    return items[i];
}

@Override
public void union(int parent, int child) {
    int parentVal = find(parent);
    int childVal = find(child);
    if (parentVal == childVal) return;
    for (int i = 0; i < items.length; i ++){
        // 所有值等于原孩子节点对应值的都替换为新的父节点值
        if (items[i] == childVal){
            items[i] = parentVal;
        }
    }
}

目标:union(1, 8) 将8的根节点合并到1的根节点

  • union 是合并元素的方法,两个入参意思是把 child 指向的根节点,指向 parent 指向的根节点。后面所有案例中 union 方法属性字段意思相同。
  • find 找到元素对应的根节点值,之后使用 union 方法对 items 数组内的元素全部遍历,把所有值等于 child 的节点,都替换为 parent 节点值。
  • 每次合并都for循环比较耗时,所以后续做了一些列的优化。

2. 粗暴合并 - union(1, 8)

@Override
public int find(int i) {
    if (i < 0 || i >= items.length)
        throw new IllegalArgumentException("Index out of range.");
    // 找到元素的根节点,当i == item[i],就是自己指向自己,这个节点就是根节点
    while (i != items[i]) {
        i = items[i];
    }
    return i;
}

@Override
public void union(int parent, int child) {
    // 父亲节点的根节点下标值
    int parentRootIdx = find(parent);
    // 孩子节点的根节点下标值
    int childRootIdx = find(child);
    if (parentRootIdx == childRootIdx) return;
    // 孩子节点值替换为父节点值
    items[childRootIdx] = items[parentRootIdx];
}

目标:union(1, 8) 将8的根节点合并到1的根节点

  • find 循环找到置顶节点的最终根节点,例如;8 → 6、6 → 6,那么说明8的根节点是6,因为6自己指向自己了,它就是根节点。
  • union 将 8 指向的根节点 6,更换为 1 指向的根节点 0。最终替换完就是 6 → 0,那么8的根节点有也是0了。
  • 这样虽然减少了每次 for 循环更新,但粗暴的合并会对节点的索引带来一定的复杂度。所以还需要继续优化。

3. 数量合并 - union(1, 8)

@Override
public int find(int i) {
    if (i < 0 || i >= items.length)
        throw new IllegalArgumentException("Index out of range.");
    // 找到元素的根节点,当i == item[i],就是自己指向自己,这个节点就是根节点
    while (i != items[i]) {
        i = items[i];
    }
    return i;
}

@Override
public void union(int parent, int child) {
    // 父亲节点的根节点下标值
    int parentRootIdx = find(parent);
    // 孩子节点的根节点下标值
    int childRootIdx = find(child);
    if (parentRootIdx == childRootIdx) return;
    if (count[parentRootIdx] >= count[childRootIdx]) {
        items[childRootIdx] = items[parentRootIdx];
        count[parentRootIdx] += count[childRootIdx];
    } else {
        items[parentRootIdx] = items[childRootIdx];
        count[childRootIdx] += count[parentRootIdx];
    }
}

目标:union(1, 8) 将8的根节点合并到1的根节点 & 基于节点的 count 值合并

  • find 循环找到置顶节点的最终根节点,例如;8 → 6、6 → 6,那么说明8的根节点是6,因为6自己指向自己了,它就是根节点。
  • union 在进行元素的根节点合并时,会判断哪个根下的元素少,用少的元素合并到多的元素下。因为这样可以减少多的元素因为处于更低位置所带来的索引耗时。树越深,子叶节点越多,越耗时。

4. 排序合并 - union(8, 1)

@Override
public int find(int i) {
    if (i < 0 || i >= items.length)
        throw new IllegalArgumentException("Index out of range.");
    // 找到元素的根节点,当i == item[i],就是自己指向自己,这个节点就是根节点
    while (i != items[i]) {
        i = items[i];
    }
    return i;
}

@Override
public void union(int parent, int child) {
    // 父亲节点的根节点下标值
    int parentRootIdx = find(parent);
    // 孩子节点的根节点下标值
    int childRootIdx = find(child);
    if (parentRootIdx == childRootIdx)
        return;
    if (rank[parentRootIdx] > rank[childRootIdx]) {
        items[childRootIdx] = items[parentRootIdx];
    } else if (rank[parentRootIdx] < rank[childRootIdx]) {
        items[parentRootIdx] = items[childRootIdx];
    } else {
        items[childRootIdx] = items[parentRootIdx];
        rank[parentRootIdx]++;
    }
}

目标:union(8, 1) 将1的根节点合并到8的根节点(其实效果和union(1,8)是一样的,之所以用union(8, 1)主要体现基于 rank 排序后的合并)& 基于节点的 rank 值合并

  • find 循环找到置顶节点的最终根节点,例如;8 → 6、6 → 6,那么说明8的根节点是6,因为6自己指向自己了,它就是根节点。
  • union 在进行元素的根节点合并时,会判断哪个根的排序小,用少的元素合并到大的根元素下。因为这样可以减少树深大的元素因为处于更低位置所带来的索引耗时。树越深,子叶节点越多,越耗时。
  • 那么此时基于 count、rank 都可以进行优化,不过优化过程中 1→0、0→2 还有2个树高,也可以优化。这就是压缩路径的作用

5. 压缩路径 - union(8, 1)

@Override
public int find(int i) {
    if (i < 0 || i >= items.length)
        throw new IllegalArgumentException("Index out of range.");
    while (i != items[i]) {
        // 路径压缩
        items[i] = items[items[i]];
        i = items[i];
    }
    return i;
}

@Override
public void union(int parent, int child) {
    // 父亲节点的根节点下标值
    int parentRootIdx = find(parent);
    // 孩子节点的根节点下标值
    int childRootIdx = find(child);
    if (parentRootIdx == childRootIdx)
        return;
    if (rank[parentRootIdx] > rank[childRootIdx]) {
        items[childRootIdx] = items[parentRootIdx];
    } else if (rank[parentRootIdx] < rank[childRootIdx]) {
        items[parentRootIdx] = items[childRootIdx];
    } else {
        items[childRootIdx] = items[parentRootIdx];
        rank[parentRootIdx]++;
    }
}

目标:union(8, 1) 在rank合并下,压缩路径长度。

  • 这里的 union 方法与4. 排序合并相比并没有变化,变化的地方主要在 find 过程中压缩路径。
  • find 基于查找根元素时,对当前元素值对应的父节点值,替换给当前元素。减少一级路径,做到压缩路径的目的。

四、并查集实现测试

单元测试

@Test
public void test_04() {
    IDisjointSet disjointSet = new DisjointSet04(9);
    System.out.println(disjointSet);
    System.out.println("\n合并元素:\n");
    disjointSet.union(0, 1);
    disjointSet.union(2, 3);
    disjointSet.union(2, 1);
    disjointSet.union(6, 4);
    disjointSet.union(6, 5);
    disjointSet.union(6, 7);
    disjointSet.union(6, 8);
    
    System.out.println(disjointSet);
    disjointSet.union(8, 1);
    System.out.println(disjointSet);
}
  • 关于并查集的测试共有6个案例,文中测试举例测试第4个,基于 Rank 优化合并。

测试结果

坐标 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 
-----------------------------------------
排序 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 
-----------------------------------------
指向 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 


合并元素:

坐标 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 
-----------------------------------------
排序 | 2 | 1 | 3 | 1 | 1 | 1 | 2 | 1 | 1 | 
-----------------------------------------
指向 | 2 | 0 | 2 | 2 | 6 | 6 | 6 | 6 | 6 | 

坐标 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 
-----------------------------------------
排序 | 2 | 1 | 3 | 1 | 1 | 1 | 2 | 1 | 1 | 
-----------------------------------------
指向 | 2 | 0 | 2 | 2 | 6 | 6 | 2 | 6 | 6 | 
  • 经过测试对比图例和控制台输出结果可以看到,(4、5、6、7)→6,6→2,1→0,(0、3)→2,这也是最终树的体现结果。
  • 其他案例源码读者可以测试验证调试,这也可以更好的学习掌握。

五、常见面试题

  • 并查集叙述?
  • 并查集的使用场景?
  • 并查集怎么合并元素?
  • 并查集合并元素的优化策略?
  • 如何压缩路径?
  • 37
    点赞
  • 30
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
C语言是一种广泛使用的编程语言,它具有高效、灵活、可移植性强等特点,被广泛应用于操作系统、嵌入式系统、数据库、编译器等领域的开发。C语言的基本语法包括变量、数据类型、运算符、控制结构(如if语句、循环语句等)、函数、指针等。在编写C程序时,需要注意变量的声明和定义、指针的使用、内存的分配与释放等问题。C语言中常用的数据结构包括: 1. 数组:一种存储同类型数据的结构,可以进行索引访问和修改。 2. 链表:一种存储不同类型数据的结构,每个节点包含数据和指向下一个节点的指针。 3. 栈:一种后进先出(LIFO)的数据结构,可以通过压入(push)和弹出(pop)操作进行数据的存储和取出。 4. 队列:一种先进先出(FIFO)的数据结构,可以通过入队(enqueue)和出队(dequeue)操作进行数据的存储和取出。 5. 树:一种存储具有父子关系的数据结构,可以通过中序遍历、前序遍历和后序遍历等方式进行数据的访问和修改。 6. 图:一种存储具有节点和边关系的数据结构,可以通过广度优先搜索、深度优先搜索等方式进行数据的访问和修改。 这些数据结构在C语言中都有相应的实现方式,可以应用于各种不同的场景。C语言中的各种数据结构都有其优缺点,下面列举一些常见的数据结构的优缺点: 数组: 优点:访问和修改元素的速度非常快,适用于需要频繁读取和修改数据的场合。 缺点:数组的长度是固定的,不适合存储大小不固定的动态数据,另外数组在内存中是连续分配的,当数组较大时可能会导致内存碎片化。 链表: 优点:可以方便地插入和删除元素,适用于需要频繁插入和删除数据的场合。 缺点:访问和修改元素的速度相对较慢,因为需要遍历链表找到指定的节点。 栈: 优点:后进先出(LIFO)的特性使得栈在处理递归和括号匹配等问题时非常方便。 缺点:栈的空间有限,当数据量较大时可能会导致栈溢出。 队列: 优点:先进先出(FIFO)的特性使得
C语言是一种广泛使用的编程语言,它具有高效、灵活、可移植性强等特点,被广泛应用于操作系统、嵌入式系统、数据库、编译器等领域的开发。C语言的基本语法包括变量、数据类型、运算符、控制结构(如if语句、循环语句等)、函数、指针等。下面详细介绍C语言的基本概念和语法。 1. 变量和数据类型 在C语言中,变量用于存储数据,数据类型用于定义变量的类型和范围。C语言支持多种数据类型,包括基本数据类型(如int、float、char等)和复合数据类型(如结构体、联合等)。 2. 运算符 C语言中常用的运算符包括算术运算符(如+、、、/等)、关系运算符(如==、!=、、=、<、<=等)、逻辑运算符(如&&、||、!等)。此外,还有位运算符(如&、|、^等)和指针运算符(如、等)。 3. 控制结构 C语言中常用的控制结构包括if语句、循环语句(如for、while等)和switch语句。通过这些控制结构,可以实现程序的分支、循环和多路选择等功能。 4. 函数 函数是C语言中用于封装代码的单元,可以实现代码的复用和模块化。C语言中定义函数使用关键字“void”或返回值类型(如int、float等),并通过“{”和“}”括起来的代码块来实现函数的功能。 5. 指针 指针是C语言中用于存储变量地址的变量。通过指针,可以实现对内存的间接访问和修改。C语言中定义指针使用星号()符号,指向数组、字符串和结构体等数据结构时,还需要注意数组名和字符串常量的特殊性质。 6. 数组和字符串 数组是C语言中用于存储同类型数据的结构,可以通过索引访问和修改数组中的元素。字符串是C语言中用于存储文本数据的特殊类型,通常以字符串常量的形式出现,用双引号("...")括起来,末尾自动添加'\0'字符。 7. 结构体和联合 结构体和联合是C语言中用于存储不同类型数据的复合数据类型。结构体由多个成员组成,每个成员可以是不同的数据类型;联合由多个变量组成,它们共用同一块内存空间。通过结构体和联合,可以实现数据的封装和抽象。 8. 文件操作 C语言中通过文件操作函数(如fopen、fclose、fread、fwrite等)实现对文件的读写操作。文件操作函数通常返回文件指针,用于表示打开的文件。通过文件指针,可以进行文件的定位、读写等操作。 总之,C语言是一种功能强大、灵活高效的编程语言,广泛应用于各种领域。掌握C语言的基本语法和数据结构,可以为编程学习和实践打下坚实的基础。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

武昌库里写JAVA

您的鼓励将是我前进的动力!

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值