作者:禅与计算机程序设计艺术
1.背景介绍
什么是并查集?
在计算机科学中,并查集是一个经典的数据结构,它用于处理一些动态集合的问题,比如连接问题、路径压缩、按秩合并等。
所谓动态集合,就是指集合中的元素可以动态添加或者删除的集合。例如,一个班级中有若干学生,每天都会有新的加入或离开,而这些操作会对学生之间的关系产生影响。如果直接用常规的数组和指针来实现这种动态集合,可能会导致复杂性过高,难以维护。
因此,就需要一种新的动态数据结构来管理这样的集合。这就是并查集。
为何要用并查集?
很多动态集合的问题都可以使用并查集来解决。
1.连接问题
在一些连通性问题中,比如无向图的连通分量、有向图的强连通分支,都可以用并查集来解决。例如,假设有N个节点,M条边构成了一个无向图。初始时,所有节点都属于不同的连通分量,即每个节点有一个父节点。然后,我们按照一定的规则(如DFS)遍历图,将相邻的节点放到同一连通分量中。最终,所有的节点都被归类到相同的连通分量中。
通过并查集的连接操作,就可以快速判断两个节点是否属于同一连通分量,进而完成连通性判断。
2.路径压缩
当我们进行路径压缩操作时,路径上所有的节点都会指向根节点,从而减少内存占用,提升效率。
3.按秩合并
如果我们要求查询两个节点的祖先,通常需要通过一个过程——路径压缩。但由于路径压缩过程的存在,可能导致某些路径上的节点指向了根节点的祖先,无法准确表示祖先关系。
为了解决这个问题,按秩合并便应运而生。按秩合并是在路径压缩的基础上进行的,它把具有相同祖先的节点合并成一个组,并更新所有子节点的父节点。这样,就能更好地表示祖先信息。
总结一下,并查集是一种经典的数据结构,其作用主要是用来管理动态集合,包括连接问题、路径压缩、按秩合并等。使用并查集可以有效地降低时间复杂度,提升运行效率。但是,并查集并不是万能钥匙,它也可能出现一些其他问题。
2.核心概念与联系
定义
集合
集合是由零个或多个元素组成的整体,其中元素之间没有明显的顺序关系。
并查集
并查集是一个结构,它包含了若干个互不相交的集合,并提供了一些操作,使得这些集合中的元素可以快速合并、查询、判断等操作。
并查集中的每个元素都对应着一个集合,称为该元素所在的集合。集合中的元素可以是任何类型的数据,包括自己。
并查集提供以下两种操作:
- 连接操作:将两个元素所在的集合合并。
- 查询操作:返回两个元素所在的集合。
基本概念
代表元
设S是一颗树,S的代表元是一棵树T,满足T的任意结点都是其他结点的祖先。则S的高度等于T的高度,且在此高度上,各结点所对应的集合划分相同。
根
在并查集的树形结构中,设x是集合A的一个元素。若x本身就是其所在集合的根,则称x为根;否则,对于x的父亲y,x为根,当且仅当y的另一个子节点不是根。
大小
在并查集中,集合的大小指的是该集合中元素的个数。
祖先
设x和y是两个集合A和B的元素。如果集合A中的某个元素z(包括自身)能在集合A中找到另一个集合C中的元素w,使得zw是集合B中的元素,则z、w、zw共同称作A的祖先,记作uzw。
路径长度
设x和y是两个集合A和B的元素。从根到x的唯一路径上元素个数称为集合A中元素x的路径长度,记作|xu|,其中u是集合A的代表元。同样,从根到y的唯一路径上元素个数称为集合B中元素y的路径长度,记作|yu|.如果存在两个元素z1、z2,使得z1是集合A中元素x的祖先,z2是集合A中元素y的祖先,且z1、z2间不存在着根,那么z1、z2、x、y共同称作x和y之间的路径,其路径长度为1+|z1|+|z2|.
次祖先
设x和y是两个集合A和B的元素。如果存在集合A中元素v、w,且v和w分别为x和y的祖先,且它们都是集合B中元素,且同时处于集合B的子树中,则v、w、x、y共同称作v的次祖先,记作vzxy。
森林
森林是由一组互不相交的树或集合构成的集合。
连通分量
在有向图G=(V,E)中,顶点的度为入度减去出度为0的顶点称为一个连通分量。
连通性
在有向图G=(V,E)中,若从任意顶点v出发,都可达至图中的每个顶点,则称G是连通的。
性能分析
路径压缩
路径压缩可以在一次查询操作后期望的次数内缩短查找元素所需的时间。压缩的过程就是把查找过程中经过的所有结点都直接指向根结点。因此,一次路径压缩的时间复杂度为O(1)。
按秩合并
按秩合并可以在一步操作中将两个元素的根结点统一,从而消除路径上的结点。按秩合并的操作时间复杂度为O(|V|log|V|)。
3.核心算法原理和具体操作步骤以及数学模型公式详细讲解
创建并查集
创建并查集需要指定一组元素,创建一个包含了每个元素的一个集合。
class UnionFind:
def __init__(self):
self._parent = [] # parent[i]表示第i个元素所属的集合编号
self._rank = [] # rank[i]表示集合编号为i的集合目前的秩
def create_set(self, n):
"""初始化n个元素的并查集"""
self._parent = [i for i in range(n)] # 初始化每个元素所属的集合编号
self._rank = [0]*n # 每个集合编号为i的集合目前的秩为0
查找根
查找根即为搜索路径上的最后一个结点,也就是其父结点一定是其祖先结点。查找根的过程采用路径压缩,把查找路径上的所有结点都直接指向根结点,以加速后续查找。
def find_root(self, x):
"""查找元素x所在的集合的根"""
if x!= self._parent[x]: # 如果当前元素不是集合根结点
self._parent[x] = self.find_root(self._parent[x]) # 对当前元素的父结点递归调用查找函数
return self._parent[x] # 返回当前元素的根结点
合并集合
合并集合是指把两个集合合并成为一个集合。为了保证集合的连通性,需要将两个集合的根结点置为同一个值。合并集合的过程采用按秩合并方法,以减小树的高度。
def union(self, x, y):
"""合并元素x所在的集合与元素y所在的集合"""
rootx = self.find_root(x) # 获取元素x所在集合的根结点
rooty = self.find_root(y) # 获取元素y所在集合的根结点
if rootx == rooty: # 如果根结点相同,说明两个集合已经连通,不需要再做合并
return False
elif self._rank[rootx] < self._rank[rooty]: # 集合x的秩比集合y的秩小
self._parent[rootx] = rooty # 将集合x的根结点设为集合y的根结点
elif self._rank[rootx] > self._rank[rooty]: # 集合y的秩比集合x的秩小
self._parent[rooty] = rootx # 将集合y的根结点设为集合x的根结点
else: # 集合x和集合y秩相等,按秩合并
self._parent[rooty] = rootx # 将集合y的根结点设为集合x的根结点
self._rank[rootx] += 1 # 集合x的秩增加1
return True # 操作成功
判断是否属于同一集合
判断是否属于同一集合的操作,只需要比较两个元素所在集合的根结点即可。
def is_same_set(self, x, y):
"""判断元素x和元素y是否属于同一集合"""
return self.find_root(x) == self.find_root(y) # 比较元素x和元素y所在集合的根结点
获取集合大小
获取集合大小的操作,只需要统计元素所在集合中元素的个数即可。
def get_size(self, x):
"""获取元素x所在集合的大小"""
return -self._parent.count(-self.find_root(x)) + 1 # 获取元素x所在集合的大小,-self.find_root(x)+1计算集合的大小
路径压缩
路径压缩是为了防止后续查询的路径过长,因此需要对路径进行压缩。在查找根结点时,如果当前结点不是根结点,则重新设置它的父结点为它的根结点。
def compress(self):
"""对整个并查集进行路径压缩"""
p = list(range(len(self._parent))) # 使用新列表p保存原始父结点列表
for i in range(len(self._parent)): # 对每个元素,将其父结点设置为其根结点
j = self.find_root(i) # 从i开始一直追溯到根结点
while p[j]!= j: # 只要结点没有变化,则一直循环
k = p[j] # 当前结点的新父结点
p[j] = i # 设置当前结点的父结点为i
j = k # 继续下一个结点的追溯
self._parent = p # 更新并查集的父结点列表
迭代器版本的并查集
迭代器版本的并查集使用了生成器的方式,并提供了相应的方法,方便用户遍历并查集中的元素。
class UnionFindIter:
class Iterator:
def __init__(self, parent):
self._parent = parent
def __iter__(self):
return iter((k for k, v in enumerate(self._parent) if v >= 0))
def __init__(self, elements=None):
self._parent = [-1]*(elements or 0) # 初始化每个元素的初始父结点为-1
@property
def iterator(self):
return UnionFindIter.Iterator(self._parent)
def find_root(self, x):
"""查找元素x所在的集合的根"""
parent = self._parent
if not (0 <= x < len(parent)): # 检测输入参数是否合法
raise IndexError('index out of range')
while parent[x] >= 0: # 不断寻找父结点直到遇到负值(根结点)
x, parent[x] = parent[x], parent[parent[x]] # 用点数法压缩路径
return x # 返回根结点的索引
def union(self, *args):
"""合并元素所在的集合"""
parent = self._parent
args = sorted([arg+(not isinstance(arg, int),) for arg in args]) # 支持索引形式参数,并将索引转换为整数
roots = set() # 使用集合记录根结点集合
size = lambda x: -parent.count(-x) + 1 # 根据根结点获取集合大小
for arg, index in args: # 对参数列表中的每个元素
if index and type(index)!= bool: # 参数是索引形式,用该索引获取真正的参数
x = arg
break
elif not isinstance(arg, int): # 参数是可迭代对象,用第一个元素作为参数
x = next(iter(arg))
arg = tuple(sorted([(i,False)+(isinstance(x,int),)+(not isinstance(i,bool),)
for i in arg if i!=x]))
continue # 用第一个元素作为参数
else: # 参数是整数,用参数本身作为参数
x = arg
arg = None # 消耗掉arg参数
rx, ry = map(self.find_root, (x, *[i for i,_ in arg])) # 获取元素所在集合的根结点
if rx == ry: # 如果两个集合根结点相同,说明已经连通,不需要再做合并
continue
roots.discard(rx) # 删除集合ry中的根结点
roots.discard(ry) # 删除集合rx中的根结点
szx, szy = map(size, (rx, ry)) # 获取集合rx和集合ry的大小
parent[ry] = rx # 设置集合ry的根结点为集合rx的根结点
roots.add(rx) # 添加集合rx的根结点到根结点集合中
parent[ry]+=[szy-1]*szz # 补充每个元素所在集合的大小差项
for r in roots: # 对剩余的根结点集合进行大小减半操作
size = size(r)//2 # 集合大小除以2
for i in filter(lambda x:-parent[x]<=-size,range(len(parent))):
parent[i]-=1 # 每个集合中的元素大小减半
return True # 操作成功
def same_set(self, x, y):
"""判断元素x和元素y是否属于同一集合"""
return self.find_root(x) == self.find_root(y)
def get_size(self, x):
"""获取元素x所在集合的大小"""
return -sum(map(abs, self._parent)) + 1
def compress(self):
"""对整个并查集进行路径压缩"""
parents = dict(enumerate(filter(lambda x: x>=0, self._parent))) # 构建父结点字典
parent = [parents[i] for i in range(len(self._parent))] # 生成新的父结点列表
for i, pi in enumerate(parent): # 遍历每一个元素
parent[i] = parents[pi] # 将所有非根元素父结点设置为其根元素的值
self._parent = parent # 更新并查集的父结点列表