3258 字
8 分钟
[启发式合并] 浅析 Union by Size、Small to Large 和 DSU on Tree
2026-08-23

三种启发式合并#

“启发式合并”并不是一个严格单一的算法名,而是一类按规模决定处理方向的思路:尽量让小对象并入大对象,避免反复处理同一批元素。Union by Size、Small to Large 和 DSU on Tree 都利用了这个想法,但它们保存的信息与具体操作并不相同。

它们共同的复杂度直觉是:当一个元素从小对象进入大对象后,它所属对象的规模至少翻倍。因此同一个元素不可能一直作为“小的一方”被处理,至多经历 O(log⁡n)O(\log n) 次这样的变化。这里的“对象”在并查集中是所在连通块,在 Small to Large 中是所在容器;DSU on Tree 则等价地表现为,一个节点到根路径上的轻边数量至多为 O(log⁡n)O(\log n)。

方法合并或复用的对象核心操作
Union by Size并查集连通块将小连通块的根挂到大连通块的根上
Small to Large子树维护的独立容器保留大容器,将小容器中的元素逐个加入
DSU on Tree全局统计器中的子树贡献保留重子树统计,轻子树先撤销、需要时再加入

下面按从基础到树上应用的顺序展开。

Union by Size#

在并查集中,每个集合只保存一个代表元和集合大小。合并两个集合时,不直接关心集合内部元素,而是让较小集合的根指向较大集合的根。对小集合中的任意节点而言,它所在集合的大小都会至少翻倍,因此至多经历 O(log⁡n)O(\log n) 次向上挂接,避免父指针形成很深的链。

牛客暑期多校训练营 6 A#

题目链接: Nowcoder A

题意: 给定一个排列 pp。每次可以选择一个非端点位置 ii,满足 pip_i 同时大于左右相邻元素,然后交换 pi−1p_{i-1} 和 pi+1p_{i+1}。求经过任意次操作后,能够得到多少个不同排列。

思路: 可以从大到小处理数值,并查集维护“当前数还能去到的未知位置”。在某一步中,同一个连通块内的未知位置对当前数是等价的;选择其中任意一个位置,都不会影响之后的选择数。

当前处理数值 ii,设它在原排列中的位置为 j=pos⁡[i]j=\operatorname{pos}[i]。执行三步:

  1. find(j) 所在连通块中的所有未知位置,都是 ii 的候选位置。因此将该连通块大小 sz[find(j)] 乘入答案。
  2. 选定 ii 的一个位置后,这个位置不再是未知位置,令该连通块大小减一。
  3. 位置 jj 已经处理完毕,它左右两边的未知位置可以通过 jj 互相到达。因此尝试合并 j−1j-1 与 j+1j+1 所在的两个连通块。

从 nn 一直处理到 11,每一步的候选数相互独立,乘法原理给出的答案就是所有连通块大小的乘积。越界的一侧或候选数已经耗尽的连通块不参与合并。

并查集维护这些候选集合。sz 同时表示集合内尚可选择的位置数;合并时让较小集合的根挂到较大集合的根,避免合并方向导致并查集退化。

实现: 先记录每个数值的位置 pos。倒序枚举 i,找到 pos[i] 的并查集根,将该根的 sz 乘入答案并减一;若位置左右均存在,再尝试合并左右位置所属的连通块。合并前需要判断两个集合是否相同,以及是否仍有可用候选位置。

复杂度: 每个数只进行常数次查找、合并和一次乘法。配合路径压缩与按大小合并,单个测试用例的时间复杂度为 O(nα(n))O(n\alpha(n)),额外空间复杂度为 O(n)O(n)。

另外笛卡尔树可以解释这里的候选集合为何等价:题目的操作不改变大根笛卡尔树的形态,只会交换同一结构中较小数的位置。本文不需要显式建树,并查集已经直接维护了从大到小处理时的可选位置集合。

Small to Large#

Small to Large 会为每个子树保留一份独立容器,例如 set、map 或计数表。后序处理节点时,直接复用最大的儿子容器,再枚举其他小容器中的元素并加入。与并查集按大小合并相比,这里真正发生了元素迁移。

牛客暑期多校训练营 9 D#

题目链接: Nowcoder D

题意: 给定一棵以 11 为根的树。有 mm 个人,第 ii 个人从节点 xix_i 在时刻 sis_i 出发,每单位时间沿父边向根走一步。两人只要在同一时刻到达同一节点,就都会死亡;否则到达根即逃脱。求每个人能否逃脱。

思路:

先将树根定为 11,求出每个节点的深度 depth。对于从 xx 在时刻 ss 出发的人,记:

key=s+depth[x].key=s+depth[x].

若他能活着走到祖先 uu,到达 uu 的时刻为:

key−depth[u].key-depth[u].

因此,在同一个节点 uu 汇合的两个人会相遇,当且仅当他们的 key 相同。这个 key 也可以理解为该人不受阻碍时到达根的时刻。

后序遍历整棵树。令节点 uu 返回一张 map<key, id>,表示 uu 的子树中仍然活着、且正在向上走的人;key 对应唯一的人编号。子树内部的相遇已经处理完毕,所以一张返回的 map 内不会有重复的 key。

处理 uu 时,先找出 map 最大的儿子并直接复用它作为 cur,再把其余儿子的 map 中的元素逐个加入 cur。最后再加入所有从 uu 出发的人。这就是这里的 Small to Large:始终保留最大的容器,只枚举较小容器中的元素。

加入一名 (key, id) 时分三种情况:

  1. cur 中没有 key:此人暂时存活,直接记录。
  2. cur 中已有 key:两人恰好在 uu 相遇,两人的答案都标记为死亡,并从 cur 删除这个 key。
  3. 这个 key 已经发生过碰撞:之后再加入同一 key 的人也会在 uu 相遇并死亡。

第三种情况不能只依赖 cur。例如三个不同儿子各有一人以相同 key 到达 uu:前两人碰撞后会从 cur 删除该键,第三人若直接插入就会被误判为存活。因此用 blocked 记录本节点已经发生过碰撞的 key;它不需要向父节点传递,因为对应的人已经全部死亡。

实现: 第一遍 DFS 计算根深度,并把每个人按起点分到 people[x] 中,记录其 key。第二遍 DFS 按后序处理节点,返回当前子树的 map<key, id>:

  1. 递归得到所有儿子的 map,选出其中最大的一张作为当前 cur,无需复制,直接复用。
  2. 枚举其他儿子的 map 中的每个 (key, id),按三种情况加入 cur。这些人第一次与其他子树的人汇合的位置正是当前节点 uu,所以在这里判定碰撞不会遗漏,也不会提前。
  3. 再以同样的规则加入 people[u]。从 uu 出发的人与子树中的人第一次可能相遇的位置也是 uu。
  4. blocked 只在处理 uu 时存在;它记录的是“已经在 uu 死亡的到达时刻”。处理完成后,这些人不会继续向父亲移动,因此父节点既不需要也不能继承这些键。

最终根返回的 map 中的所有人都没有在任何节点相遇,答案为 1;发生过碰撞、被从 map 删除或被 blocked 拦下的人,答案为 0。

复杂度: 每个人随所在的 map 被合并时,容器规模按 Small to Large 的方式增长;总迁移次数均摊为 O(mlog⁡m)O(m\log m)。std::map 的查询、插入和删除均为 O(log⁡m)O(\log m),故总时间复杂度为 O(mlog⁡2m)O(m\log^2 m),额外空间复杂度为 O(m)O(m)。


DSU on Tree#

DSU on Tree 同样优先复用重子树的信息,但不为每棵子树保存完整容器历史。它维护一份全局统计:轻子树完成自身答案后撤销,重子树的统计保留;处理父节点时,再把轻子树的节点加入。这是用重算换取更低的空间占用。

U41492 树上数颜色#

题目链接: 洛谷 U41492

题意: 给定一棵以 11 为根的树,每个节点有一种颜色。多次询问某个节点的子树中,一共出现了多少种不同颜色。

思路: 这是 DSU on Tree 的标准应用。维护一个全局计数器 color[col],表示当前保留的节点集合中颜色 col 出现了多少次;再维护 diff,表示计数非零的颜色种类数。

对一棵子树执行 add(u, delta) 时,遍历整棵子树:

  • delta = 1 时,将所有节点加入统计。某种颜色的计数从 00 变为 11,diff 加一。
  • delta = -1 时,将所有节点移出统计。某种颜色的计数从 11 变为 00,diff 减一。

先做一遍 DFS 求每个节点的子树大小,并找出最大儿子 heavy[u]。第二遍 DFS 按下面的顺序处理节点 uu:

  1. 先递归所有轻儿子,传入 keep = 0。每棵轻子树在算完自身答案后都会从全局统计中清空。
  2. 再递归重儿子,传入 keep = 1。重子树的统计会被保留,因此此时全局计数器恰好表示重子树。
  3. 将节点 uu 本身加入统计,再逐棵遍历轻子树,把它们的所有节点加入统计。此时统计器恰好覆盖整棵 uu 子树,直接令 ans[u] = diff。
  4. 若当前调用的 keep = 0,最后对整棵 uu 子树执行一次 add(u, -1),将它从全局统计器清空;否则保留它,交给父节点继续使用。

这里的 keep 不影响答案是否计算。无论 keep 是什么,都必须先在第 3 步得到 ans[u];keep 只决定答案求完后是否清空当前子树的颜色统计。

为什么这样做足够快?重儿子的统计直接复用,轻子树只会在作为“轻儿子”时被整棵重新加入。沿着任意节点到根的路径,每次经过轻边,所在子树大小至少翻倍,因此一名节点至多被重新加入 O(log⁡n)O(\log n) 次。这正是 DSU on Tree 中“保留重儿子、重加轻儿子”的摊还基础。

实现: 颜色编号范围不超过 nn,可以直接用数组维护 color。第一遍 DFS 填好 size 与 heavy;第二遍 DFS 使用全局 color 和 diff,在第 3 步记录每个节点的 ans。读入询问后直接输出对应的 ans[x]。

复杂度: 预处理和回答询问分别为 O(n)O(n)、O(m)O(m)。DSU on Tree 的加减总次数为 O(nlog⁡n)O(n\log n),因此总时间复杂度为 O(nlog⁡n+m)O(n\log n+m),额外空间复杂度为 O(n)O(n)。

对比与选择#

三种方法共享“优先复用大对象”的方向,但不能混用模板。最本质的区别是:Union by Size 只决定根的挂接方向;Small to Large 保留各子树的独立状态;DSU on Tree 则将暂时不用的轻子树状态撤销,之后按需重算。

维度Union by SizeSmall to LargeDSU on Tree
核心小连通块挂到大连通块存储并合并小容器撤销轻子树、按需重加
维护的状态并查集父亲与集合大小每个子树的一份独立容器一份全局统计器
关键操作merge 时按大小决定根遍历小容器并插入大容器轻儿子清空,重儿子保留
常见数据结构并查集map、set、可合并线段树计数数组、树状数组、线段树
常见复杂度单次操作均摊 O(α(n))O(\alpha(n))使用 STL 时通常为 O(nlog⁡2n)O(n\log^2 n)修改为 O(1)O(1) 时为 O(nlog⁡n)O(n\log n)
空间特点只维护连通块信息保留子树容器,常数通常较大不保留轻子树历史,空间更省
适用场景动态连通性或按块合并需要保留每棵子树/连通块的状态离线求各节点子树答案,且统计支持加减
分享

如果这篇文章对你有帮助,欢迎分享给更多人!

[启发式合并] 浅析 Union by Size、Small to Large 和 DSU on Tree
https://shannonkwan.cn/posts/heuristic-merging/
作者
Shannon Kwan
发布于
2026-08-23
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时

目录