想象你管理着一个包含许多家庭的大村庄。每个家庭都有自己的族谱,族谱记录着“谁是谁的父亲”,一路往上追,最后会追到一位老祖宗。两个年轻人如果最后追到同一位老祖宗,就能确认两家同宗同族;如果两个家族决定联姻,那就把两本族谱合并成一册,以后所有人都算一家人。这个看起来简单的“认祖归宗”问题,正是并查集要解决的核心。 生活中我们经常要回答两类问题:“这两个东西是不是同一个大集体里的?”“两个大集体要不要合并成一个?”如果把每个集体画成树,并查集就是记录这棵树的父节点、寻找根节点、然后把两棵树并成一棵树的精巧工具。它最迷人的地方在于两种优化:一种叫“路径压缩”,就像查完族谱后,把沿途每个人的父亲栏直接写成老祖宗,下次再查立刻就知道答案;另一种叫“按秩合并”,就像让比较矮小的祠堂挂到比较高大的祠堂下面,避免族谱变成一根深不见底的长链。有了这两种优化,并查集处理海量数据的均摊时间几乎接近常数级。 简单说,并查集是“动态亲戚关系”的万能族谱:既能快速查询两个元素是否同族,也能快速让两族合一。接下来,我们就从一张族长登记表开始,一步步把这个数据结构拆开看明白。
掌握并查集,你会在处理连通性、聚类划分、图算法等大规模数据问题时拥有一个常驻的直觉利器,也是 AI 工程中数据预处理与关系推理的基本功。
可视化演示
交互展示并查集的合并与查找操作,路径压缩与按秩合并优化策略,以及在Kruskal最小生成树中的应用。
本节配有交互动效(进入视口后自动加载,约 3KB)。
并查操作 union合并两棵树 find查找根节点 union(A,B): parent[find(A)]=find(B) 均摊接近O(1)
基本概念
村口有两个年轻人,一个说自己是大房的,一个说是二房的。只要两房最后都归到同一个老祖宗门下,那就是一家人;但如果一个姓林的跑到姓陈的祠堂里去认亲,那就乱套了。这个生活场景告诉我们,整个世界被分成了许多互不相交的“家族”,每个人只能属于一个家族。这些家族就是数学里说的“不相交集合”。
并查集正是用来维护这些不相交集合的数据结构。它把问题抽象成两个动作:查询某个元素属于哪个集合,以及把两个集合合并成一个。查询时,我们关心的是“代表元”是否相同;合并时,我们关心的是两个集合能否重新划分。由于集合关系会随着合并不断变化,这也就是“动态连通性”的直观含义。
在形式化描述中,我们可以把“两个元素属于同一个集合”理解为一个等价关系。等价关系满足自反、对称、传递,而集合划分恰恰对应着一种等价关系。对并查集来说,判断两个元素是否等价,只需看它们找到的代表元是否相同。
回到村庄的例子:只要两个村民最终站到同一个祠堂前,不管他们中间隔了多少辈分,他们一定属于同一个家族;这就是并查集最基本的判断逻辑。
- 等价记号$x \sim y$ 表示元素 $x$ 和 $y$ 属于同一个集合,也就是“同族”。
- 查询代表元$\operatorname{find}(x)$ 返回 $x$ 所在集合的代表元,可以理解为“查到老祖宗”。
- 双向等价$\iff$ 表示左右两边可以互相推出,所以判断同族等价于判断根是否相同。
树形表示
家族的族谱不会把几万人画在一张平面图上,否则根本看不清。更自然的做法是让每个人只记一个“父亲”:父亲再记自己的父亲,一路往上,直到某个人的父亲栏写着自己,那个人就是老祖宗。整个村庄因此形成多棵“倒挂的树”,每棵树代表一个家族,树根就是家族代表元。
并查集也采用同样的树形表示。它用一个数组 parent 来存放每个节点的父节点:如果 parent[x] == x,说明 $x$ 是根;如果 parent[x] != x,说明 $x$ 的父亲是 parent[x]。多个这样的树放在一起,就叫森林。查询根节点时,只需要顺着父指针一路向上走。
在这种表示下,“一个元素属于哪个集合”变成了“它所在的树的根是谁”。根节点不仅代表一棵树,也代表整个集合的身份。任何两个元素只要根相同,就属于同一个集合。
“父指针指向自己”就是祠堂门前的族徽。只要看到这个标记,我们就知道已经追到了家族源头,这一趟寻亲之路可以结束了。
- 根节点判断$\text{isRoot}(i)$ 表示节点 $i$ 是根,也就是一个家族的老祖宗。
- 自身指向$\mathrm{parent}[i] = i$ 表示 $i$ 的父亲是自己,说明族谱到这里就到头了。
- 等价关系$\iff$ 说明“是根”和“父指针指向自己”这两种说法完全等价。
核心操作
并查集有三种基本操作:初始化、查找和合并。初始化时,每个元素都自成一个家族,所以 parent[i] = i。查找操作负责从某个节点出发,沿着父指针一路向上,找到树根。合并操作则先找两个元素的根,如果根不同,就把其中一棵树的根挂到另一棵树的根下面,两族从此成为一族。
查找操作通常写成 find(x)。如果当前节点的父亲不是自己,就继续找父亲的父亲,直到遇到根。这是一个跟随父指针不断向上的过程。合并操作 union(a, b) 则表达为:先得到 $a$ 的根和 $b$ 的根,如果二者不同,就让其中一个根成为另一个根的父节点。
我们可以把合并写成下面的形式。这里 $\operatorname{root}(a)$ 和 $\operatorname{root}(b)$ 分别代表 $a$、$b$ 所在树的根。
这里还没有引入优化,所以谁挂到谁下面都可以。但如果我们不小心让高树挂到矮树下面,族谱就有可能会变成一条很长的链,查询时就要走很远。这正是后面两种优化要解决的问题。
- 条件判断$\operatorname{root}(a) \ne \operatorname{root}(b)$ 表示两个元素现在不在同一棵树里,合并才有意义。
- 认祖归宗$\mathrm{parent}[\operatorname{root}(b)] = \operatorname{root}(a)$ 表示让 $b$ 这棵树的根认 $a$ 这棵树的根为父亲。
路径压缩
想象你为了确认自己的老祖宗,沿着族谱一级一级往上爬,爬了十代人终于到了祠堂。如果下次还要再爬一次,实在累人。聪明的做法是,回去之后立刻把沿途所有人的父亲栏都直接改成老祖宗。这样下次再有人问路,一步就能到达祠堂。
这个操作就是路径压缩。它发生在查找过程中:当 find(x) 找到了根之后,递归返回时会把路径上每个节点的父指针都直接指向根。于是整条路径被“压扁”,以后的查找代价大大降低。
路径压缩的实现非常简洁。如果当前节点不是根,就递归调用 find,并把结果赋给当前节点的父指针。这个赋值就是“把父亲栏改成老祖宗”的动作。
路径压缩让树变得更扁,但不会增加树的高度,反而只会降低高度。因此它和按秩合并可以同时使用,而且互不冲突。
- 递归出口如果 $\mathrm{parent}[x] = x$,说明 $x$ 就是根,直接返回 $x$。
- 递归查找否则沿着父指针继续找,并把结果写回 $\mathrm{parent}[x]$。
- 返回值最终返回的是整棵树的根,于是路径上每个节点的父指针都变成了根。
按秩合并
如果两族联姻时,每次都让高大的祠堂挂到矮小的祠堂下面,过不了多久族谱就会歪成一根细长的藤蔓。更稳妥的做法是:让矮树挂到高树下面,这样整体高度不会增加;只有两棵树一样高时,才不得不增加一层,并把新根的“秩”加一。
这个“秩”就是树高的上界。在并查集中,我们给每个根维护一个 rank 值,近似表示这棵树有多高。合并时比较两个根的秩,秩小的根挂到秩大的根下面;如果秩相等,则任选一个根作新根,并让新根的秩加一。这保证了树的高度始终被控制在 $O(\log n)$ 级别。
假设 $r_a = \operatorname{root}(a)$,$r_b = \operatorname{root}(b)$,按秩合并的规则可以写成:
按秩合并保证了即使没有路径压缩,每次查找的最坏时间也只有 $O(\log n)$。路径压缩再把这个上界进一步压缩到几乎常数级别。
- 矮树让位如果 $r_a$ 的秩小于 $r_b$,就让 $r_a$ 的父亲指向 $r_b$,树高不会增加。
- 高树保留如果 $r_a$ 的秩大于 $r_b$,就让 $r_b$ 的父亲指向 $r_a$。
- 等秩合并秩相等时,只能选一个根作新根,并把新根的秩加一。
复杂度分析
有人会担心:路径压缩每次都会改动树的结构,按秩合并又需要维护秩,这些操作加起来会不会很慢?实际上,并查集的魅力正在于它的“均摊”效率极高。均摊的意思是:把一长串操作的总代价加起来平均,每次操作的成本低得惊人。
设共有 $n$ 个元素,连续执行 $m$ 次 find 和 union 操作,同时使用路径压缩与按秩合并,总时间复杂度为:
其中 $\alpha(n)$ 是反阿克曼函数。它的增长速度极其缓慢,以至于在宇宙原子数级别的 $n$ 下,$\alpha(n)$ 也不会超过 5。因此每次操作均摊下来几乎就是 $O(1)$,这也是并查集被称为“近乎常数级”数据结构的原因。
正因为均摊接近常数,并查集才敢被用于千万级甚至亿级节点的大规模图计算中。它把“动态连通性”这个听起来很重的问题,变成了几乎每个操作只需眨一下眼的轻活。
- 时间函数$T(m,n)$ 表示 $m$ 次操作、$n$ 个元素的总时间。
- 复杂度上界$O(m\,\alpha(n))$ 表示总代价与 $m$ 乘以 $\alpha(n)$ 同阶。
- 反阿克曼函数$\alpha(n)$ 是反阿克曼函数,增长极慢,实际中可看作不超过 5 的常数。
经典应用
家族族谱不仅能用来认亲,还能解决许多图论问题。第一个典型应用是连通分量判定:给定一张无向图和若干条边,判断两个顶点是否连通。我们只需把所有边依次做 union,最后查询两个顶点的根是否相同即可。这就像根据村庄道路,逐步把能互通的村子合并到同一个家族里。
第二个典型应用是 Kruskal 最小生成树算法。算法的思路是把所有边按权重从小到大排序,然后逐条尝试加入。如果一条边的两个端点已经连通,加入它就会形成环,必须跳过;如果没有连通,就加入这条边并把两个端点合并。这里 union 的返回值恰好能告诉我们“是否成功合并”,从而自然地避免成环。
判断一条边是否能加入最小生成树,可以写成:
第三个典型应用是等价类判定。比如在编译器中判断两个变量是否属于同一个“等价类”,或在社会网络中判断用户是否属于同一个群体。只要把“相等”“互粉”“同源”这些关系看成一条条待合并的边,并查集就能快速维护所有等价类。
- 边的表示$e = (u,v)$ 表示一条连接顶点 $u$ 和 $v$ 的边。
- 加入条件$\operatorname{find}(u) \ne \operatorname{find}(v)$ 表示两个端点目前不连通,可以安全加入。
- 属于最小生成树$e \in \text{MST}$ 表示这条边最终出现在最小生成树中。
AI 应用场景
图像分割结果的连通区域标记
阶段:数据预处理与后处理
解决的问题:图像分割模型输出的是每个像素的类别概率,我们需要把相邻且类别相同的像素合并成一个个独立目标区域。传统 flood fill 需要递归或显式栈,处理高分辨率图像时容易栈溢出且慢;并查集通过两遍扫描即可完成连通分量标记,稳定又高效。
具体案例:在医学影像分割中,模型把 MRI 图像里每个像素标成“肿瘤”或“正常”。后处理阶段用并查集把相邻的“肿瘤像素”合并成一个连通区域,再统计每个区域的体积和数量,辅助医生判断病灶范围。
用户多账号统一画像
阶段:特征工程与数据清洗
解决的问题:一个用户可能有手机号、设备 ID、邮箱等多个标识,AI 系统需要判断哪些标识属于同一个人。用并查集把“互相指向同一用户”的标识不断合并,就能把传递关系也一并处理,最终得到完整的用户画像。
具体案例:电商推荐系统从日志中收集设备 ID、登录手机号、收货电话等字段。数据团队把每个标识看作节点,把“出现在同一订单中”作为合并条件,用并查集把所有标识归并成同一个用户 ID,再训练个性化推荐模型。
大规模图数据中的连通分量计算
阶段:图算法与聚类分析
解决的问题:图神经网络或知识图谱常常面临千万级节点,需要快速统计图中有多少个连通分量,或判断两个节点是否可达。并查集均摊接近 $O(1)$,非常适合流式边输入和动态增量场景。
具体案例:在构建商品知识图谱时,系统不断接收“商品 A 和商品 B 是同款”的边,并查集实时把同款商品合并成同一簇。每来一条边只做一次 union,算法工程师就能实时维护整个商品库的等价类数量。
Python 演示
# -*- 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))
self.parent[x] = self.find(self.parent[x])路径压缩,查找时直接把当前节点挂到根上。if self.rank[ra] < self.rank[rb]按秩合并,矮树挂到高树下面。else: self.parent[rb] = ra; self.rank[ra] += 1两棵等秩树合并时,新根秩加一。len({uf.find(i) for i in range(n)})统计有多少个根,就是多少个连通分量。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. 并查集最擅长解决的问题是?
- A.维护一个始终保持有序的数组
- B.判断两个元素是否在同一集合,并快速合并集合← 正确答案
- C.在一堆数里查找中位数
- D.对图做深度优先遍历
答案:B。并查集的核心能力就是 find 查询和 union 合并。A 是排序结构,C 是选择问题,D 是图遍历算法,都不是并查集的主要目标。
Q2. 在父指针数组中,怎样判断节点 x 是它所在树的根?
- A.parent[x] == -1
- B.parent[x] == x← 正确答案
- C.parent[x] == 0
- D.parent[x] == x + 1
答案:B。并查集的根节点特征是“自己的父亲是自己”,也就是 parent[x] == x。它是查找操作的终止条件,相当于族谱中老祖宗不再往上追溯。
Q3. 路径压缩的核心效果是?
- A.把查询路径上所有节点直接指向根← 正确答案
- B.把根指向某个子节点
- C.把两个集合的元素全部复制一份
- D.让每个集合的秩自动加一
答案:A。路径压缩在 find 返回根时,把路径上经过的节点父指针都改成根,从而把树压扁。B 方向反了,C 是拷贝,D 是等秩合并时才做的操作。
Q4. 按秩合并中的 rank 表示的是?
- A.集合中元素个数
- B.树高度的上界← 正确答案
- C.节点在数组中的下标
- D.两个集合之间的距离
答案:B。rank 是对树高的估计,不是集合大小。它作为合并时让矮树挂到高树下的依据,从而控制树高,保证查找效率。
Q5. 判断元素 a 和 b 是否在同一连通分量中,正确的是?
- 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,并配合路径压缩与按秩合并,均摊时间复杂度约为?
- A.Θ(mn)
- B.O(m log n)
- C.O(m α(n))← 正确答案
- D.O(m n²)
答案:C。路径压缩加按秩合并的经典结论是总复杂度 O(m α(n)),其中 α 是反阿克曼函数,增长极慢,实际接近常数。A、D 是朴素实现的代价,B 是没有路径压缩时单次操作的保守上界。
Q7. Kruskal 最小生成树算法使用并查集的主要目的是?
- A.对边按权重排序
- B.判断一条边的两个端点是否已经连通,从而避免成环← 正确答案
- C.在图中计算最短路径
- D.存储邻接矩阵
答案:B。Kruskal 每次按权重从小到大取边,需要快速知道两端是否已连通。如果已连通则边会被丢弃,否则加入并合并。排序由普通排序完成,最短路径是 Dijkstra 等问题。
Q8. 并查集支持的核心操作有哪些?
- A.find(x) 查询 x 所在集合的代表元← 正确答案
- B.union(a,b) 合并 a 和 b 所在集合← 正确答案
- C.split(x) 把某个集合拆成两个
- D.insert(x) 在集合指定位置插入元素
答案:AB。find 和 union 是并查集的基础操作。标准并查集不支持集合拆分,也不存在“集合内指定位置插入”的概念,因此 C、D 错误。
Q9. 下列哪些是并查集的经典优化策略?
- A.路径压缩← 正确答案
- B.按秩合并← 正确答案
- C.快速排序
- D.哈希冲突探测
答案:AB。路径压缩让查找路径变平,按秩合并控制树高,两者共同保证接近常数的均摊复杂度。快速排序是排序算法,哈希冲突探测属于哈希表设计。
Q10. 关于“路径压缩 + 按秩合并”,下列说法正确的是?
- A.单次 find 的最坏时间仍可能达到 O(log n)← 正确答案
- B.连续多次操作的均摊时间接近常数 O(α(n))← 正确答案
- C.α(n) 是反阿克曼函数,增长速度极其缓慢← 正确答案
- D.该组合会让所有元素在所有操作结束后一定都直接指向根
答案:ABC。路径压缩+按秩合并最坏单次操作仍是 O(log n),但均摊是 O(α(n));α 增长极慢。D 错误,因为只有被 find 查询路径覆盖到的节点才会被压缩,从未查询过的节点不会主动直连根。