想象你管理着一个包含许多家庭的大村庄。每个家庭都有自己的族谱,族谱记录着“谁是谁的父亲”,一路往上追,最后会追到一位老祖宗。两个年轻人如果最后追到同一位老祖宗,就能确认两家同宗同族;如果两个家族决定联姻,那就把两本族谱合并成一册,以后所有人都算一家人。这个看起来简单的“认祖归宗”问题,正是并查集要解决的核心。 生活中我们经常要回答两类问题:“这两个东西是不是同一个大集体里的?”“两个大集体要不要合并成一个?”如果把每个集体画成树,并查集就是记录这棵树的父节点、寻找根节点、然后把两棵树并成一棵树的精巧工具。它最迷人的地方在于两种优化:一种叫“路径压缩”,就像查完族谱后,把沿途每个人的父亲栏直接写成老祖宗,下次再查立刻就知道答案;另一种叫“按秩合并”,就像让比较矮小的祠堂挂到比较高大的祠堂下面,避免族谱变成一根深不见底的长链。有了这两种优化,并查集处理海量数据的均摊时间几乎接近常数级。 简单说,并查集是“动态亲戚关系”的万能族谱:既能快速查询两个元素是否同族,也能快速让两族合一。接下来,我们就从一张族长登记表开始,一步步把这个数据结构拆开看明白。

岗位关联

掌握并查集,你会在处理连通性、聚类划分、图算法等大规模数据问题时拥有一个常驻的直觉利器,也是 AI 工程中数据预处理与关系推理的基本功。

目录
  1. 可视化演示
  2. 基本概念
  3. 树形表示
  4. 核心操作
  5. 路径压缩
  6. 按秩合并
  7. 复杂度分析
  8. 经典应用
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

交互展示并查集的合并与查找操作,路径压缩与按秩合并优化策略,以及在Kruskal最小生成树中的应用。

本节配有交互动效(进入视口后自动加载,约 3KB)。

并查操作 union合并两棵树 find查找根节点 union(A,B): parent[find(A)]=find(B) 均摊接近O(1)

基本概念

村口有两个年轻人,一个说自己是大房的,一个说是二房的。只要两房最后都归到同一个老祖宗门下,那就是一家人;但如果一个姓林的跑到姓陈的祠堂里去认亲,那就乱套了。这个生活场景告诉我们,整个世界被分成了许多互不相交的“家族”,每个人只能属于一个家族。这些家族就是数学里说的“不相交集合”。

并查集正是用来维护这些不相交集合的数据结构。它把问题抽象成两个动作:查询某个元素属于哪个集合,以及把两个集合合并成一个。查询时,我们关心的是“代表元”是否相同;合并时,我们关心的是两个集合能否重新划分。由于集合关系会随着合并不断变化,这也就是“动态连通性”的直观含义。

在形式化描述中,我们可以把“两个元素属于同一个集合”理解为一个等价关系。等价关系满足自反、对称、传递,而集合划分恰恰对应着一种等价关系。对并查集来说,判断两个元素是否等价,只需看它们找到的代表元是否相同。

回到村庄的例子:只要两个村民最终站到同一个祠堂前,不管他们中间隔了多少辈分,他们一定属于同一个家族;这就是并查集最基本的判断逻辑。

x∼y  ⟺  find⁡(x)=find⁡(y)x \sim y \iff \operatorname{find}(x) = \operatorname{find}(y)
通俗地说:两个人是不是一家人,就看他们最终拜的祠堂是不是同一个。
  1. 等价记号$x \sim y$ 表示元素 $x$ 和 $y$ 属于同一个集合,也就是“同族”。
    x∼yx \sim y
  2. 查询代表元$\operatorname{find}(x)$ 返回 $x$ 所在集合的代表元,可以理解为“查到老祖宗”。
    find⁡(x)=find⁡(y)\operatorname{find}(x) = \operatorname{find}(y)
  3. 双向等价$\iff$ 表示左右两边可以互相推出,所以判断同族等价于判断根是否相同。
      ⟺  \iff

树形表示

家族的族谱不会把几万人画在一张平面图上,否则根本看不清。更自然的做法是让每个人只记一个“父亲”:父亲再记自己的父亲,一路往上,直到某个人的父亲栏写着自己,那个人就是老祖宗。整个村庄因此形成多棵“倒挂的树”,每棵树代表一个家族,树根就是家族代表元。

并查集也采用同样的树形表示。它用一个数组 parent 来存放每个节点的父节点:如果 parent[x] == x,说明 $x$ 是根;如果 parent[x] != x,说明 $x$ 的父亲是 parent[x]。多个这样的树放在一起,就叫森林。查询根节点时,只需要顺着父指针一路向上走。

在这种表示下,“一个元素属于哪个集合”变成了“它所在的树的根是谁”。根节点不仅代表一棵树,也代表整个集合的身份。任何两个元素只要根相同,就属于同一个集合。

“父指针指向自己”就是祠堂门前的族徽。只要看到这个标记,我们就知道已经追到了家族源头,这一趟寻亲之路可以结束了。

isRoot(i)  ⟺  parent[i]=i\text{isRoot}(i) \iff \mathrm{parent}[i] = i
一个人是老祖宗,当且仅当他的父亲栏里写的是他自己。
  1. 根节点判断$\text{isRoot}(i)$ 表示节点 $i$ 是根,也就是一个家族的老祖宗。
    isRoot(i)\text{isRoot}(i)
  2. 自身指向$\mathrm{parent}[i] = i$ 表示 $i$ 的父亲是自己,说明族谱到这里就到头了。
    parent[i]=i\mathrm{parent}[i] = i
  3. 等价关系$\iff$ 说明“是根”和“父指针指向自己”这两种说法完全等价。
      ⟺  \iff

核心操作

并查集有三种基本操作:初始化、查找和合并。初始化时,每个元素都自成一个家族,所以 parent[i] = i。查找操作负责从某个节点出发,沿着父指针一路向上,找到树根。合并操作则先找两个元素的根,如果根不同,就把其中一棵树的根挂到另一棵树的根下面,两族从此成为一族。

查找操作通常写成 find(x)。如果当前节点的父亲不是自己,就继续找父亲的父亲,直到遇到根。这是一个跟随父指针不断向上的过程。合并操作 union(a, b) 则表达为:先得到 $a$ 的根和 $b$ 的根,如果二者不同,就让其中一个根成为另一个根的父节点。

我们可以把合并写成下面的形式。这里 $\operatorname{root}(a)$ 和 $\operatorname{root}(b)$ 分别代表 $a$、$b$ 所在树的根。

这里还没有引入优化,所以谁挂到谁下面都可以。但如果我们不小心让高树挂到矮树下面,族谱就有可能会变成一条很长的链,查询时就要走很远。这正是后面两种优化要解决的问题。

union⁡(a,b):if root⁡(a)≠root⁡(b),mathrmparent[root⁡(b)]=root⁡(a)\operatorname{union}(a,b):\quad\text{if } \operatorname{root}(a) \ne \operatorname{root}(b),\\mathrm{parent}[\operatorname{root}(b)] = \operatorname{root}(a)
两族联姻时,只要让一族的祠堂挂到另一族的祠堂名下,全族人都成了一家人。
  1. 条件判断$\operatorname{root}(a) \ne \operatorname{root}(b)$ 表示两个元素现在不在同一棵树里,合并才有意义。
    root⁡(a)≠root⁡(b)\operatorname{root}(a) \ne \operatorname{root}(b)
  2. 认祖归宗$\mathrm{parent}[\operatorname{root}(b)] = \operatorname{root}(a)$ 表示让 $b$ 这棵树的根认 $a$ 这棵树的根为父亲。
    parent[root⁡(b)]=root⁡(a)\mathrm{parent}[\operatorname{root}(b)] = \operatorname{root}(a)

路径压缩

想象你为了确认自己的老祖宗,沿着族谱一级一级往上爬,爬了十代人终于到了祠堂。如果下次还要再爬一次,实在累人。聪明的做法是,回去之后立刻把沿途所有人的父亲栏都直接改成老祖宗。这样下次再有人问路,一步就能到达祠堂。

这个操作就是路径压缩。它发生在查找过程中:当 find(x) 找到了根之后,递归返回时会把路径上每个节点的父指针都直接指向根。于是整条路径被“压扁”,以后的查找代价大大降低。

路径压缩的实现非常简洁。如果当前节点不是根,就递归调用 find,并把结果赋给当前节点的父指针。这个赋值就是“把父亲栏改成老祖宗”的动作。

路径压缩让树变得更扁,但不会增加树的高度,反而只会降低高度。因此它和按秩合并可以同时使用,而且互不冲突。

find(x)={x,parent[x]=xparent[x]=find(parent[x]),otherwise\text{find}(x) =\begin{cases}x, & \mathrm{parent}[x] = x \\\mathrm{parent}[x] = \text{find}(\mathrm{parent}[x]), & \text{otherwise}\end{cases}
查一次族谱,顺便把整条路上的族人全部“一键直连”老祖宗。
  1. 递归出口如果 $\mathrm{parent}[x] = x$,说明 $x$ 就是根,直接返回 $x$。
    parent[x]=x\mathrm{parent}[x] = x
  2. 递归查找否则沿着父指针继续找,并把结果写回 $\mathrm{parent}[x]$。
    parent[x]=find(parent[x])\mathrm{parent}[x] = \text{find}(\mathrm{parent}[x])
  3. 返回值最终返回的是整棵树的根,于是路径上每个节点的父指针都变成了根。
    find(x)\text{find}(x)

按秩合并

如果两族联姻时,每次都让高大的祠堂挂到矮小的祠堂下面,过不了多久族谱就会歪成一根细长的藤蔓。更稳妥的做法是:让矮树挂到高树下面,这样整体高度不会增加;只有两棵树一样高时,才不得不增加一层,并把新根的“秩”加一。

这个“秩”就是树高的上界。在并查集中,我们给每个根维护一个 rank 值,近似表示这棵树有多高。合并时比较两个根的秩,秩小的根挂到秩大的根下面;如果秩相等,则任选一个根作新根,并让新根的秩加一。这保证了树的高度始终被控制在 $O(\log n)$ 级别。

假设 $r_a = \operatorname{root}(a)$,$r_b = \operatorname{root}(b)$,按秩合并的规则可以写成:

按秩合并保证了即使没有路径压缩,每次查找的最坏时间也只有 $O(\log n)$。路径压缩再把这个上界进一步压缩到几乎常数级别。

if rank(ra)<rank(rb),parent[ra]=rbif rank(ra)>rank(rb),parent[rb]=raotherwise,parent[rb]=ra, rank(ra)←rank(ra)+1\begin{array}{ll}\text{if } \mathrm{rank}(r_a) < \mathrm{rank}(r_b), & \mathrm{parent}[r_a] = r_b \\\text{if } \mathrm{rank}(r_a) > \mathrm{rank}(r_b), & \mathrm{parent}[r_b] = r_a \\\text{otherwise}, & \mathrm{parent}[r_b] = r_a,\ \mathrm{rank}(r_a) \gets \mathrm{rank}(r_a) + 1\end{array}
矮祠堂挂到高祠堂下面,全族人都少爬几级台阶。
  1. 矮树让位如果 $r_a$ 的秩小于 $r_b$,就让 $r_a$ 的父亲指向 $r_b$,树高不会增加。
    parent[ra]=rb\mathrm{parent}[r_a] = r_b
  2. 高树保留如果 $r_a$ 的秩大于 $r_b$,就让 $r_b$ 的父亲指向 $r_a$。
    parent[rb]=ra\mathrm{parent}[r_b] = r_a
  3. 等秩合并秩相等时,只能选一个根作新根,并把新根的秩加一。
    rank(ra)←rank(ra)+1\mathrm{rank}(r_a) \gets \mathrm{rank}(r_a) + 1

复杂度分析

有人会担心:路径压缩每次都会改动树的结构,按秩合并又需要维护秩,这些操作加起来会不会很慢?实际上,并查集的魅力正在于它的“均摊”效率极高。均摊的意思是:把一长串操作的总代价加起来平均,每次操作的成本低得惊人。

设共有 $n$ 个元素,连续执行 $m$ 次 find 和 union 操作,同时使用路径压缩与按秩合并,总时间复杂度为:

其中 $\alpha(n)$ 是反阿克曼函数。它的增长速度极其缓慢,以至于在宇宙原子数级别的 $n$ 下,$\alpha(n)$ 也不会超过 5。因此每次操作均摊下来几乎就是 $O(1)$,这也是并查集被称为“近乎常数级”数据结构的原因。

正因为均摊接近常数,并查集才敢被用于千万级甚至亿级节点的大规模图计算中。它把“动态连通性”这个听起来很重的问题,变成了几乎每个操作只需眨一下眼的轻活。

T(m,n)=O(m α(n))T(m,n) = O(m\,\alpha(n))
T(m,n)=O(m α(n))T(m,n) = O(m\,\alpha(n))
每一次认亲都像看祠堂门口挂的族徽一样快,而不是真的逐级爬完整棵族谱树。
  1. 时间函数$T(m,n)$ 表示 $m$ 次操作、$n$ 个元素的总时间。
    T(m,n)T(m,n)
  2. 复杂度上界$O(m\,\alpha(n))$ 表示总代价与 $m$ 乘以 $\alpha(n)$ 同阶。
    O(m α(n))O(m\,\alpha(n))
  3. 反阿克曼函数$\alpha(n)$ 是反阿克曼函数,增长极慢,实际中可看作不超过 5 的常数。
    α(n)\alpha(n)

经典应用

家族族谱不仅能用来认亲,还能解决许多图论问题。第一个典型应用是连通分量判定:给定一张无向图和若干条边,判断两个顶点是否连通。我们只需把所有边依次做 union,最后查询两个顶点的根是否相同即可。这就像根据村庄道路,逐步把能互通的村子合并到同一个家族里。

第二个典型应用是 Kruskal 最小生成树算法。算法的思路是把所有边按权重从小到大排序,然后逐条尝试加入。如果一条边的两个端点已经连通,加入它就会形成环,必须跳过;如果没有连通,就加入这条边并把两个端点合并。这里 union 的返回值恰好能告诉我们“是否成功合并”,从而自然地避免成环。

判断一条边是否能加入最小生成树,可以写成:

第三个典型应用是等价类判定。比如在编译器中判断两个变量是否属于同一个“等价类”,或在社会网络中判断用户是否属于同一个群体。只要把“相等”“互粉”“同源”这些关系看成一条条待合并的边,并查集就能快速维护所有等价类。

e=(u,v)∈MST  ⟺  find⁡(u)≠find⁡(v)e = (u,v) \in \text{MST} \iff \operatorname{find}(u) \ne \operatorname{find}(v)
修路之前先问一句:这两个村是不是已经通了?通了就不要再修,免得绕成环。
  1. 边的表示$e = (u,v)$ 表示一条连接顶点 $u$ 和 $v$ 的边。
    e=(u,v)e = (u,v)
  2. 加入条件$\operatorname{find}(u) \ne \operatorname{find}(v)$ 表示两个端点目前不连通,可以安全加入。
    find⁡(u)≠find⁡(v)\operatorname{find}(u) \ne \operatorname{find}(v)
  3. 属于最小生成树$e \in \text{MST}$ 表示这条边最终出现在最小生成树中。
    e∈MSTe \in \text{MST}

AI 应用场景

图像分割结果的连通区域标记

阶段:数据预处理与后处理

解决的问题:图像分割模型输出的是每个像素的类别概率,我们需要把相邻且类别相同的像素合并成一个个独立目标区域。传统 flood fill 需要递归或显式栈,处理高分辨率图像时容易栈溢出且慢;并查集通过两遍扫描即可完成连通分量标记,稳定又高效。

具体案例:在医学影像分割中,模型把 MRI 图像里每个像素标成“肿瘤”或“正常”。后处理阶段用并查集把相邻的“肿瘤像素”合并成一个连通区域,再统计每个区域的体积和数量,辅助医生判断病灶范围。

用户多账号统一画像

阶段:特征工程与数据清洗

解决的问题:一个用户可能有手机号、设备 ID、邮箱等多个标识,AI 系统需要判断哪些标识属于同一个人。用并查集把“互相指向同一用户”的标识不断合并,就能把传递关系也一并处理,最终得到完整的用户画像。

具体案例:电商推荐系统从日志中收集设备 ID、登录手机号、收货电话等字段。数据团队把每个标识看作节点,把“出现在同一订单中”作为合并条件,用并查集把所有标识归并成同一个用户 ID,再训练个性化推荐模型。

大规模图数据中的连通分量计算

阶段:图算法与聚类分析

解决的问题:图神经网络或知识图谱常常面临千万级节点,需要快速统计图中有多少个连通分量,或判断两个节点是否可达。并查集均摊接近 $O(1)$,非常适合流式边输入和动态增量场景。

具体案例:在构建商品知识图谱时,系统不断接收“商品 A 和商品 B 是同款”的边,并查集实时把同款商品合并成同一簇。每来一条边只做一次 union,算法工程师就能实时维护整个商品库的等价类数量。

Python 演示

python51 行
# -*- coding: utf-8 -*-class UnionFind:    def __init__(self, n):        self.parent = list(range(n))        self.rank = [0] * n     def find(self, x):        if self.parent[x] != x:            self.parent[x] = self.find(self.parent[x])        return self.parent[x]     def union(self, a, b):        ra = self.find(a)        rb = self.find(b)        if ra == rb:            return False        if self.rank[ra] < self.rank[rb]:            self.parent[ra] = rb        elif self.rank[ra] > self.rank[rb]:            self.parent[rb] = ra        else:            self.parent[rb] = ra            self.rank[ra] += 1        return True     def connected(self, a, b):        return self.find(a) == self.find(b)  def connected_components(n, edges):    uf = UnionFind(n)    for a, b in edges:        uf.union(a, b)    return len({uf.find(i) for i in range(n)})  def kruskal_mst(n, edges):    uf = UnionFind(n)    edges = sorted(edges, key=lambda e: e[2])    total = 0    for u, v, w in edges:        if uf.union(u, v):            total += w    return total  if __name__ == "__main__":    edges = [(0, 1), (1, 2), (3, 4)]    print("components:", connected_components(5, edges))    weighted = [(0, 1, 4), (1, 2, 2), (0, 2, 5), (2, 3, 3)]    print("mst weight:", kruskal_mst(4, weighted))
目的:演示并查集的查找、合并、路径压缩和按秩合并,并用它统计连通分量和计算 Kruskal 最小生成树权重。
  1. self.parent[x] = self.find(self.parent[x])路径压缩,查找时直接把当前节点挂到根上。
  2. if self.rank[ra] < self.rank[rb]按秩合并,矮树挂到高树下面。
  3. else: self.parent[rb] = ra; self.rank[ra] += 1两棵等秩树合并时,新根秩加一。
  4. len({uf.find(i) for i in range(n)})统计有多少个根,就是多少个连通分量。
  5. if uf.union(u, v):Kruskal 中合并成功则加入边,合并失败说明会成环。

常见误区

误区find 不是查询操作吗?为什么还要在查找过程中修改 parent 数组?这样不会把原来的树结构搞乱吗?

正解路径压缩并不会改变“谁和谁同族”这一事实。它只是让沿途节点直接指向根,相当于把族谱里的父亲栏改写成老祖宗。家族归属没变,但下次查询更快,这正是路径压缩的意义所在。

为何常见:初学者容易把查询看作“只读操作”,忽略了 find 天然会访问并修改父指针,而这恰好是并查集高效的关键。

误区按秩合并,当然应该把元素少的集合挂到元素多的集合下面,所以 rank 就是 size。

正解并查集中的 rank 是树高上界的估计,而不是节点个数。只有两个根的 rank 相等时,新根的 rank 才加一;路径压缩后真实高度可能更小,但 rank 仍然是一个安全上界。按元素个数合并是另一种策略,叫“按大小合并”,两者并不相同。

为何常见:中文里“秩”容易被理解成“规模、排名”;许多图解又用子树大小来辅助演示,于是把 rank 和 size 混为一谈。

误区既然 find 要沿着链往上走,最坏情况不是要遍历 n 个节点吗?那和链表有什么区别?

正解朴素并查集确实可能退化,但加上路径压缩和按秩合并后,单次操作最坏是 $O(\log n)$,而连续 $m$ 次操作的总代价是 $O(m \alpha(n))$,均摊几乎为常数级。实际工程中,这种组合已经足够应对千万级节点。

为何常见:只看单次最坏情况,没有理解均摊分析的意义。并查集的优点恰恰体现在大量操作整体平均之后。

误区网上很多代码都是一行递归,路径压缩是不是必须用递归才能实现?

正解路径压缩完全可以迭代实现。第一趟先沿父指针找到根,第二趟再把路径上每个节点的父指针直接改成根。递归写法只是更简洁,但树高不受控时可能造成栈溢出,迭代写法更稳健。

为何常见:教材为了展示算法本质,优先给出递归版本,导致初学者以为递归是唯一实现方式。

面试题

Q1. 并查集最擅长解决的问题是?

单选题 · easy

  • A.维护一个始终保持有序的数组
  • B.判断两个元素是否在同一集合,并快速合并集合← 正确答案
  • C.在一堆数里查找中位数
  • D.对图做深度优先遍历

答案:B。并查集的核心能力就是 find 查询和 union 合并。A 是排序结构,C 是选择问题,D 是图遍历算法,都不是并查集的主要目标。

Q2. 在父指针数组中,怎样判断节点 x 是它所在树的根?

单选题 · easy

  • A.parent[x] == -1
  • B.parent[x] == x← 正确答案
  • C.parent[x] == 0
  • D.parent[x] == x + 1

答案:B。并查集的根节点特征是“自己的父亲是自己”,也就是 parent[x] == x。它是查找操作的终止条件,相当于族谱中老祖宗不再往上追溯。

Q3. 路径压缩的核心效果是?

单选题 · mid

  • A.把查询路径上所有节点直接指向根← 正确答案
  • B.把根指向某个子节点
  • C.把两个集合的元素全部复制一份
  • D.让每个集合的秩自动加一

答案:A。路径压缩在 find 返回根时,把路径上经过的节点父指针都改成根,从而把树压扁。B 方向反了,C 是拷贝,D 是等秩合并时才做的操作。

Q4. 按秩合并中的 rank 表示的是?

单选题 · mid

  • A.集合中元素个数
  • B.树高度的上界← 正确答案
  • C.节点在数组中的下标
  • D.两个集合之间的距离

答案:B。rank 是对树高的估计,不是集合大小。它作为合并时让矮树挂到高树下的依据,从而控制树高,保证查找效率。

Q5. 判断元素 a 和 b 是否在同一连通分量中,正确的是?

单选题 · mid

  • A.rank[a] == rank[b]
  • B.parent[a] == parent[b]
  • C.find(a) == find(b)← 正确答案
  • D.union(a, b) == true

答案:C。同一连通分量意味着它们在同一个集合,而集合身份由根代表,所以必须比较 find 的结果。parent 可能指向不同父节点但最终同一根,rank 更与归属无关。

Q6. 同时对 n 个元素执行 m 次 find 和 union,并配合路径压缩与按秩合并,均摊时间复杂度约为?

单选题 · hard

  • A.Θ(mn)
  • B.O(m log n)
  • C.O(m α(n))← 正确答案
  • D.O(m n²)

答案:C。路径压缩加按秩合并的经典结论是总复杂度 O(m α(n)),其中 α 是反阿克曼函数,增长极慢,实际接近常数。A、D 是朴素实现的代价,B 是没有路径压缩时单次操作的保守上界。

Q7. Kruskal 最小生成树算法使用并查集的主要目的是?

单选题 · hard

  • A.对边按权重排序
  • B.判断一条边的两个端点是否已经连通,从而避免成环← 正确答案
  • C.在图中计算最短路径
  • D.存储邻接矩阵

答案:B。Kruskal 每次按权重从小到大取边,需要快速知道两端是否已连通。如果已连通则边会被丢弃,否则加入并合并。排序由普通排序完成,最短路径是 Dijkstra 等问题。

Q8. 并查集支持的核心操作有哪些?

多选题 · mid

  • A.find(x) 查询 x 所在集合的代表元← 正确答案
  • B.union(a,b) 合并 a 和 b 所在集合← 正确答案
  • C.split(x) 把某个集合拆成两个
  • D.insert(x) 在集合指定位置插入元素

答案:AB。find 和 union 是并查集的基础操作。标准并查集不支持集合拆分,也不存在“集合内指定位置插入”的概念,因此 C、D 错误。

Q9. 下列哪些是并查集的经典优化策略?

多选题 · mid

  • A.路径压缩← 正确答案
  • B.按秩合并← 正确答案
  • C.快速排序
  • D.哈希冲突探测

答案:AB。路径压缩让查找路径变平,按秩合并控制树高,两者共同保证接近常数的均摊复杂度。快速排序是排序算法,哈希冲突探测属于哈希表设计。

Q10. 关于“路径压缩 + 按秩合并”,下列说法正确的是?

多选题 · hard

  • A.单次 find 的最坏时间仍可能达到 O(log n)← 正确答案
  • B.连续多次操作的均摊时间接近常数 O(α(n))← 正确答案
  • C.α(n) 是反阿克曼函数,增长速度极其缓慢← 正确答案
  • D.该组合会让所有元素在所有操作结束后一定都直接指向根

答案:ABC。路径压缩+按秩合并最坏单次操作仍是 O(log n),但均摊是 O(α(n));α 增长极慢。D 错误,因为只有被 find 查询路径覆盖到的节点才会被压缩,从未查询过的节点不会主动直连根。