并查集(Union-Find)算法详解

2023-05-16

并查集(Union-Find)是解决动态连通性问题的一类非常高效的数据结构。本文中,我将尽我所能用最简单,最清晰的逻辑展示出并查集的构造过程,同时还将对其中的关键步骤给出相应的Python代码。

动态连通性

可以想象一张地图上有很多点,有些点之间是有道路相互联通的,而有些点则没有。如果我们现在要从点A走向点B,那么一个关键的问题就是判断我们能否从A走到B呢?换句话说,A和B是否是连通的。这是动态连通性最基本的诉求。现在给出一组数据,其中每个元素都是一对“点”,代表这对点之间是联通的,我们需要设计一个算法,让计算机依次读取这些数据,最后判断出其中任意两点是否连通。注意,并查集所涉及的动态连通性只是考虑“是否连通”这一二值判别问题,而不涉及连通的路径到底是什么。后者不在本文的考虑范围之内。

举个例子,比如下图。为了简单起见,我们以整数 0~9 表示图中的10个点,然后给出两两连通的数据如下:[(4, 3), (3, 8), (6, 5), (9, 4), (2, 1), (8, 9), (5, 0), (7, 2), (6, 1), (6, 7)]


我们将这些“点对”依次通过画图连接的方式在图中表示出来。当然,如果其中某“对”点本身就是连通的,比如点对 (8,9) ,还有 (6,7) 在连接前就已经是连通的,我们可以自动忽略这种情况。通过这张图,可以直观的看出,有些点对,像0与1是相互连通的,而有些像0与9是不连通的。现在的问题是,如何让计算机读取这些数据来构建一种数据结构(合并连通点),然后在这种数据结构上高效的做出对某个点对是否连通的判断(查询连通性)。这也就是并查集名字的由来了。

为了实现上面所描述的功能,一个简单的思路就是分组。也就是说,我们可以把相互连通的点看成一个组,如果现在查询的点对分别在不同的组中,则这个点对不连通,否则连通。下面我们先来简单分析一下这种操作的具体过程,分“并”和“查”两个方面来分析。为了方便描述,我这里先举一个例子:比如上图的10个点,现在就令每个点的值为其初始组别,我们可以得到下面这个表:

element0123456789
group number0123456789

现在,“并”的操作可以这样来描述:观察第一个点对 (4,3) ,于是先找到点4和3,发现所在组别不一样,再将点4和3的组别都变成3(当然都变成4也行,这个随意设计),然后就产生了如下的表:

element0123456789
group number0123356789

而“查”的操作其实就是“并”操作的第一步:找到点对中两个点所在的组别,看是否相同。

也就是说,并查集的两类基本操作中,都涉及了“根据点找组别”的过程,因此我们先给出一个高效的“查”的算法。我把它叫做Quick-Find 算法。

Quick-Find 算法

设计高效的查询算法非常简单,从上面的表格我们就能联想到,这种元素和组别之间的一一对应关系可以用“键值对”的存储方式实现。我直接给出Python的实现代码,非常简单。

def con_eleGroupNum(eleList):
    """
    construct the element-group number map
    :param eleList: the list of distinct elements
    :return: the dictionary with the form {element: group number}
    """
    result = {}
    num = 1
    for i in eleList:
        result[i] = num
        num += 1
    return result

这样,因为可以直接找到“键”,并通过“键”访问其对应的值,所以查询的复杂度为 O(1) ,perfect!

但是如果是并呢?这就有点麻烦了,因为不知道到底是哪些“键”(点)对应着某个“值”(组别),所以需要对整个列表进行遍历,逐一修改。假设现在有 N 个点,需要添加的路径由M个点对组成,那么“并”的时间复杂度为 O(MN) 。这是个平方级别的时间复杂度,显然,在数据量极大的情况下,平方级别的算法是有问题的。下面的代码展示了根据一个点对修改组别(合并)的过程,我们以每次点对元素的最小值为新的组别:

def change_GroupNum(pairGroupNum, eleGroupNum):
    """
    connect
    :param pairGroupNum: the two group numbers of the pair of elements
    :param eleGroupNum: the dictionary with the form {element: group number}
    :return: None
    """
    newGroupNum = min(pairGroupNum)
    for i in eleGroupNum:
        if eleGroupNum[i] == pairGroupNum[0] or eleGroupNum[i] == pairGroupNum[1]:
            eleGroupNum[i] = newGroupNum


def quick_find(elePair, eleGroupNum):
    """
    find and connect
    :param elePair: the pair of elements
    :param eleGroupNum: the dictionary
    :return: None
    """
    pairGroupNum = (eleGroupNum[elePair[0]], eleGroupNum[elePair[1]])
    change_GroupNum(pairGroupNum, eleGroupNum)

Quick-Union 算法

为了解决这种低效的并,我们需要重新考虑一下这个问题。现在的关键在于如何能快速地通过一个点找到其相应的组别和这个组中的所有点,并且批量改变这些点的组别。显然,这里面设计了数据的存储和更新,我们考虑从设计新的数据结构入手。既然简单的键值对无法解决,那么别的数据结构呢?链表,树,图?琢磨一下,你会发现树结构其实很适合:比起链表和图,树结构有一个非常“显眼”的根节点,我们可以用它来代表这个树所有节点的组别,修改了根节点,就相当于是修改了全树,此外,树的层次化结构决定了由一个节点查询到组别的过程也是非常高效的。

用树结构实现并查集的算法思路可以如下描述,假设现在要添加多个路径(点对):

  • 初始化:每个点看做一棵树,当然这是一棵只有根节点的树,存储了这个节点本身的值作为组别(你也可以令其他不会产生冲突的记号做组别);

  • 查询:对于点对 (a,b) ,通过 a b向其根节点回溯(当然初始时就是它们本身),判断其所在组别;

  • 合并:若不在同一组别,令其中一个点(比如 a 吧)所在树的根节点成为另一个点(比如b)的根节点的孩子。这样即便再查询到 a ,通过上面的查询过程,程序也会最终判断得到的是现在b的根节点所在的组别,相当于是改变了 a 所在组的全部元素的组别;

这样,在计算过程中的所有树其实就是一棵多叉树。然而我们发现,现在产生了一个新的问题,那就是因为查询算法也改了,导致现在Quick-Union的查询过程效率好像不那么令人满意了。甚至在最坏的情况下,这样的多叉树退化成了一个链表(不具体说了,大家想想应该能明白)。

但是没办法啊,因为你要找树根,怎么着复杂度也得是O(H)的,其中 H 是树高。那再想想,能不能尽可能地降低这里的树高呢?也就是说在产生树合并树的时候就尽量使得每棵树都是“扁平化”的。

大树小树的合并技巧

先看看树的合并,对于我们上面说的这种合并(一棵树的根直接变成另一棵树根的孩子),有一个基本的原则是小树变成大树的子树,会比大树变成小树的子树更加不易增加树高,这一点通过下面的图就能看出来。所以我们可以在生成树的时候,令根节点存储一个属性 weight,用来表示这棵树所拥有的节点数,节点数多的是“大树”,少的就是“小树”。


压缩路径

再看看树的生成,这一点就要在查询过程中做文章了,因为每次我们是从树的一个节点回溯到其根节点,所以一个最直接的办法是,将这条路径上的所有中间节点记录下来,全部变成根节点的子节点。但是这样一来会增加算法的空间复杂度(反复开辟内存和销毁)。所以一个备选的思路是每遍历到一个节点,就将这个节点变成他的爷爷节点的孩子(和其父节点在同一层了)。相当于是压缩了查询的路径,这样,频繁的查询当然会导致树的“扁平化”程度更彻底。

代码展示

经过上面大小树的合并原则以及路径的压缩,其实“并”和“查”两种操作的时间复杂度都非常趋近于O(1)了。下面我给出一些关键函数的代码。完整的代码参加我的github主页:https://github.com/guoziqingbupt/Union-Find

class UFTreeNode(object):
    def __init__(self, num):
        # the group number
        self.num = num

        # its children
        self.children = []

        # its parent
        self.parent = None

        # the number of nodes that rooted by this node
        self.weight = 1


def genNodeList(eleList):
    """
    generating the node of each element
    :param eleList: the list of elements
    :return: a dictionary formed as {element: corresponding node}
    """
    result = {}
    for ele in eleList:
        result[ele] = UFTreeNode(ele)
    return result


def locPair(elePair, eleNodeMap):
    """
    locate the positions of the pair of elements
    :param elePair:
    :param eleNodeMap: a dictionary formed as {element: corresponding node}
    :return: the two nodes of the pair of elements
    """

    return [eleNodeMap[elePair[0]], eleNodeMap[elePair[1]]]


def backtracking(node):
    """
    1. find the root of a node
    2. cut down the height of the tree
    :param node:
    :return: the root of node
    """
    root = node

    while root.parent:
        cur = root
        root = root.parent

        # the grandfather node of cur exists
        if cur.parent.parent:

            # make the father of cur is its grandfather
            grandfather = cur.parent.parent
            grandfather.children.append(cur)

    return root


def quickUnion(elePair, eleNodeMap):
    """
    union process
    :param elePair:
    :param eleNodeMap:
    :return:
    """
    nodePair = locPair(elePair, eleNodeMap)

    root_1, root_2 = backtracking(nodePair[0]), backtracking(nodePair[1])

    # if the two elements of the pair are not belongs to the same root (group)
    if root_1 is not root_2:

        if root_1.weight >= root_2.weight:

            # update weight
            root_1.weight += root_2.weight

            # make the root2 as a subtree of root1
            root_1.children.append(root_2)

            # update the group number of root2
            root_2.num = root_1.num

        else:
            root_2.weight += root_1.weight
            root_2.children.append(root_1)
            root_1.num = root_2.num
本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)

并查集(Union-Find)算法详解 的相关文章

  • 计算机的组成

    一 冯诺依曼系统 1 计算机硬件 由运算器 控制器 存储器 输入设备 输出设备组成 2 计算机内部采用二进制表示指令和数据 3 注 xff1a 1 输入设备 xff1a 键盘和鼠标等 2 输出设备 xff1a 显示屏 xff0c 打印机等
  • fd与FILE以及fork缓冲问题

    一 文件描述符 fd 1 文件描述符其实就是一个非负的小整数 是文件指针数组的下标 2 让我们看一看0 xff0c 1 xff0c 2 xff0c 代表什么 xff1f span class hljs preprocessor includ
  • Kali Linux使用体验简述

    在以前的版本里Kali Linux默认用户是root用户 xff0c 这样设计的目的是避免每次都要输入root密码 xff0c 而如今需要root密码的程序明显少于从前 xff0c Kali Linux也做出了相应的改革 xff0c 默认用
  • 随身WiFi410的板子刷Debian安装青龙面板+狗东脚本最详细教程

    前几天 xff0c 我发布了一个410刷入debian的教程 很多老哥可能觉得刷入debian没有什么用 xff0c 今天我就教大家如何安装青龙面板 xff0c 并且安装脚本实现自动白嫖狗东的豆子 青龙面板 43 狗东脚本 自动领豆子红包
  • inode以及软硬链接

    一 inode 使用ls l查看文件元数据 xff0c 用来描述数据属性 模式 硬链接数 文件所有组 组 大小 最后修改时间 文件名 使用stat查看 xff0c 查看文件信息 span class hljs comment Access
  • 静态库与动态库

    一 库 由于版权原因 xff0c 库函数的源代码一般是不可见的 xff0c 但在暴露的头文件中你可以看到它对外的接口 库函数简介 xff0c 使用的时候 xff0c 直接引入头文件 include lt gt 即可 二 静态库 1 概念 程
  • 【进程控制上】创建、终止、等待、程序替换

    进程的创建 终止 等待 程序替换 以及popen system与fork之间的区别 一 进程的创建 init进程将系统启动后 xff0c init将成为此后所有进程的祖先 xff0c 此后的进程都是直接或间接从init进程 复制 而来 完成
  • 【进程控制下】实现一个简易的shell

    1 shell原理 运用程序替换的原理来实现的 xff0c shell自己就是一个进程 span class hljs number 1 span 获取命令行 span class hljs number 2 span 解析命令行 span
  • VIM的基本使用

    一 VIM 1 概念 是一款文本编辑器 xff0c 和Emacs并列成为类Unix系统用户最喜欢的文本编辑器 2 优点 可以完成复杂的编辑与格式化功能 3 模式 其模式共有十二种 xff0c 基本模式有六种 span class hljs
  • 进程信号

    一 信号概念 1 一个信号产生及处理实例 1 在shell下 xff0c 启动一个进程 2 按下Ctrl 43 c xff0c 键盘输入产生一个硬件中断 3 如果CPU正在运行这个进程则代码暂停执行 xff0c CPU从用户态返回到内核态
  • 进程间关系和守护进程

    一 进程间关系 1 进程组 xff08 Process Group xff09 1 xff09 是一个或多个进程的集合 xff0c 通常 xff0c 这个集合与同一个作业相关联 xff0c 可以接受同一终端的各种信号 2 xff09 每一个
  • 多线程死锁

    一 死锁 1 xff09 提出 多线程与多进程提高了系统资源的利用率 xff0c 然而并发执行也会带来一些问题 xff0c 如死锁 2 xff09 概念 死锁是指两个或两个以上的进程在执行过程中 xff0c 由于竞争资源或者由于彼此通信而造
  • Proxy-Server

    一 摘录 二 背景 由于某些原因 xff0c 在我们国内无法访问google facebook等外国网站 xff0c 如果你想使用外网来学习 xff0c 聊天 xff0c 那么就可以使用一些翻墙代理 三 原理 1 要想翻墙 xff0c 首先
  • 【线程】概念与控制

    线程概念与控制 线程分离 一 线程的概念 1 概念 在一个程序里的一个执行流就叫做线程 xff0c 是一个进程内部的控制序列 线程是调度的基本单位 xff0c 在Linux下 xff0c 线程称为轻量级进程 2 线程与进程之间的区别 1 x
  • 使用Linux能显著降低家用电脑或服务器的功耗?

    就那我家里的电费举例子吧 xff08 心疼 xff09 xff0c 我家上个月电费比平时多了50元 xff08 你能想到50元是都少度电吧 xff1f xff09 xff0c 原因就是就我使用了一个月Linux 这么说Linux能增加电费开
  • 【线程同步与互斥】卖票问题(互斥锁)

    一 简述 1 共享变量 很多变量有时候需要在线程间共享 xff0c 可以通过数据的共享 xff0c 从而完成线程之间的交互 如果变量是只读的 xff0c 多个线程同时读取该变量不会有一致性的问题 xff0c 但是当一个线程可以修改的变量 x
  • 【网络基础】基本协议

    一 协议 1 概念 计算机与计算机之间通过网络实现通信时事先达成的一种约定 两台计算机只要遵循相同的协议就能够实现通信 网络也属于进程间通信 xff0c 公共资源是网络 xff0c 其本质是两个进程通过网络进行收发数据 2 多任务调度 操作
  • 面试必备:”三次握手与四次挥手

    TCP是如何建立连接与断开 xff1f 如何提高可靠性 xff1f 又是如何提高性能的 xff1f 一 TCP的连接与断开 1 连接前的准备 服务端 xff1a 分配文件描述符 gt 绑定 gt 监听 gt 阻塞等待客户端连接 客户端 xf
  • 实现八大排序算法

    八大常用排序实现地址 xff1a https gitee com CCTVYI Algorithm tree master Sort 一 背景 1 稳定性 两个相等的数A和B xff0c 倘若在未排序前 xff0c A在B的前面 xff0c
  • 复杂链表的复制

    一 复杂链表 1 什么叫复杂链表 xff1f 每个节点中有一个节点值 xff0c 以及两个指针 xff0c 一个指向下一个节点 xff0c 另一个特殊指针指向任意一个节点或NULL 2 结构体 struct RandomListNode i

随机推荐

  • 高级IO模型

    一 网络IO 1 高级IO背景 对于网络IO xff0c IO效率提升是至关重要的 xff0c 一个数据在网络中的传输 xff0c 其传输时间主要由网络中的延迟所决定 xff0c 具有不确定性 xff08 什么时候来 xff09 xff0c
  • IO多路转接之select

    github链接 xff1a 通过代码讲解select 此代码先将数组初始化为 1 xff0c 在使用FD ISSET将事件全部设为读事件 xff0c 一旦发现有连接发起请求 xff0c 那么读事件就绪 xff0c 将该监听套接字fd添加到
  • IO多路转接之epoll

    github xff1a epoll代码 一 epoll 1 认识epoll 它是Linux内核为处理大批量句柄而做了改进的poll xff0c Linux下多路复用IO接口select poll的增强版本 xff0c 它能显著提高程序在大
  • IP地址与MAC地址缺一不可吗?

    答案是肯定的 xff0c 最近复习到了网络这块的知识 xff0c 才突然弄懂了 xff08 1 xff09 首先 xff0c 我们如果第一次将信息从A端发往B端 xff0c 那么信息需要从应用层到物理层一层一层进行封装 xff0c 到达对端
  • [剑指offer] 连续子数组最大和

    题目 xff1a 对于一个有正有负的整数数组 xff0c 请找出总和最大的连续数列 给定一个 span class hljs keyword int span 数组A和数组大小n xff0c 请返回最大的连续数列的和 1 思路 xff1a
  • Visual Studio连接wsl使用C/C++进行Linux开发

    首先打开Visual Studio xff0c 打开顶部菜单栏上的项目 然后选择属性 这样就会弹出一个窗口 xff0c 窗口的标题不重要 xff0c 我给项目起的名字叫Linux控制台项目 xff0c 他就显示成 Linux控制台项目 属性
  • 输入一个字符串,求字符串中包含的字符集合

    输入 xff1a abcqweracb 输出 xff1a abcqwer 一 剖析 采用数组的方式 xff0c 定义一个可以存放256个字符的数组 xff08 ASCII最多包含256个字符 xff09 xff0c 先将数组初始化1 xff
  • 求最小步数变为斐波那契数

    一 解析 xff1a 当我们一步一步走的时候 xff0c 一边计算斐波那契数 xff0c 一边计算左边的数和输入的N值进行差值运算 xff0c 直到N比斐波那契数小就直接退出 二 代码 span class hljs keyword int
  • 逆置链表

    题目 xff1a 将一个链表逆置 解析 xff1a 使用三个指针 xff0c 前 中 后 xff0c 改变中指针 xff0c 遍历后指针 ListNode ReverseList ListNode pHead span class hljs
  • 字符串中连续最长数字串

    一 题目要求 二 解析 使用左右下标来记录连续数字 xff0c 使用cur来记录最长连续数字的个数 三 代码 span class hljs preprocessor include lt iostream gt span using na
  • 输出链表中倒数第K个结点

    1 结构体类型 span class hljs keyword struct span ListNode span class hljs built in int span span class hljs keyword val span
  • C语言深度解剖

    一 关键字 1 关键字 是编译器能认识的特殊字符串符号 C语言共有32个关键字 xff0c 含sizeof xff0c 计算对象所占内存空间的大小 2 定义 创建一个对象并分配一块内存 3 声明 告诉编译器 xff0c 名字已经匹配到了一块
  • QT 实现窗口四周阴影

    网上好多写的不清楚 又搞了好长时间 这样应该最简单了 一 效果图 二 思路 1 先将所有窗口控件拖到一个QFrame里 xff0c 注意 xff0c QWidget与QFrame之间必须有间距 否则QFrame发散的阴影没有地方显示 2 设
  • cmd中执行批处理(.bat)文件,批处理文件调用python脚本

    记录我在cmd中操作遇到的一些问题 以及Bat脚本常用的一些命令 文章目录 一 bat批处理文件调用python脚本 xff0c 此时执行 bat文件出现了无模块的问题 xff08 安装python模块 xff09 二 cmd执行带参的ba
  • 修改window下的MessageBox中默认文字

    1 方法是修改系统的下的默认名称 放在博客上就当我记住了哈哈 xff01 include lt windef h gt LRESULT CALLBACK CBTHookProc int nCode WPARAM wParam LPARAM
  • 批处理脚本中切换目录

    一 场景 我要在bin main目录下操作v1文件 xff0c 然后在bin目录下操作v2文件 xff0c 但是最后v2文件没有被改写 xff0c 原因是你已经进入bin main 子目录下 xff0c 不能直接进入父目录bin 所以应该注
  • Linux下最强安卓模拟器,流畅又丝滑(附详细安装教程)此瓜保熟|Linux游戏党

    我打算完全从头开始 xff0c 写一个专门用于桌面办公的纯国产操作系统 xff0c 规避主流操作系统上影响用户体验的问题 xff0c 系统力求简洁 有兴趣加QQ群 xff1a 709652950 好东西让更多人发现 xff01 我找了整整两
  • python删除创建文件夹遇到的WindowsError: [Error 5]问题

    一 背景 实际操作中 xff0c 想删除一个文件夹并创建一个文件夹 xff0c 并定义了一个函数 xff0c 但总是遇到WindowsError Error 5 问题 xff0c 经过一番百度 xff0c 是说操作文件权限不够 xff0c
  • Windows下使用WinSW.NET4.exe 设置Nginx的开机自启(新版)

    WinSW NET4 exe 适合X64 xff1b WinSW NET2 exe 适合X86 对应的版本为 xff1a v2 9 0 一 下载地址 https github com winsw winsw releases 下载解压ngi
  • 并查集(Union-Find)算法详解

    并查集 xff08 Union Find xff09 是解决动态连通性问题的一类非常高效的数据结构 本文中 xff0c 我将尽我所能用最简单 xff0c 最清晰的逻辑展示出并查集的构造过程 xff0c 同时还将对其中的关键步骤给出相应的Py