三种启发式合并
“启发式合并”并不是一个严格单一的算法名,而是一类按规模决定处理方向的思路:尽量让小对象并入大对象,避免反复处理同一批元素。Union by Size、Small to Large 和 DSU on Tree 都利用了这个想法,但它们保存的信息与具体操作并不相同。
它们共同的复杂度直觉是:当一个元素从小对象进入大对象后,它所属对象的规模至少翻倍。因此同一个元素不可能一直作为“小的一方”被处理,至多经历 次这样的变化。这里的“对象”在并查集中是所在连通块,在 Small to Large 中是所在容器;DSU on Tree 则等价地表现为,一个节点到根路径上的轻边数量至多为 。
| 方法 | 合并或复用的对象 | 核心操作 |
|---|---|---|
| Union by Size | 并查集连通块 | 将小连通块的根挂到大连通块的根上 |
| Small to Large | 子树维护的独立容器 | 保留大容器,将小容器中的元素逐个加入 |
| DSU on Tree | 全局统计器中的子树贡献 | 保留重子树统计,轻子树先撤销、需要时再加入 |
下面按从基础到树上应用的顺序展开。
Union by Size
在并查集中,每个集合只保存一个代表元和集合大小。合并两个集合时,不直接关心集合内部元素,而是让较小集合的根指向较大集合的根。对小集合中的任意节点而言,它所在集合的大小都会至少翻倍,因此至多经历 次向上挂接,避免父指针形成很深的链。
牛客暑期多校训练营 6 A
题目链接: Nowcoder A
题意: 给定一个排列 。每次可以选择一个非端点位置 ,满足 同时大于左右相邻元素,然后交换 和 。求经过任意次操作后,能够得到多少个不同排列。
思路: 可以从大到小处理数值,并查集维护“当前数还能去到的未知位置”。在某一步中,同一个连通块内的未知位置对当前数是等价的;选择其中任意一个位置,都不会影响之后的选择数。
当前处理数值 ,设它在原排列中的位置为 。执行三步:
find(j)所在连通块中的所有未知位置,都是 的候选位置。因此将该连通块大小sz[find(j)]乘入答案。- 选定 的一个位置后,这个位置不再是未知位置,令该连通块大小减一。
- 位置 已经处理完毕,它左右两边的未知位置可以通过 互相到达。因此尝试合并 与 所在的两个连通块。
从 一直处理到 ,每一步的候选数相互独立,乘法原理给出的答案就是所有连通块大小的乘积。越界的一侧或候选数已经耗尽的连通块不参与合并。
并查集维护这些候选集合。sz 同时表示集合内尚可选择的位置数;合并时让较小集合的根挂到较大集合的根,避免合并方向导致并查集退化。
实现: 先记录每个数值的位置 pos。倒序枚举 i,找到 pos[i] 的并查集根,将该根的 sz 乘入答案并减一;若位置左右均存在,再尝试合并左右位置所属的连通块。合并前需要判断两个集合是否相同,以及是否仍有可用候选位置。
复杂度: 每个数只进行常数次查找、合并和一次乘法。配合路径压缩与按大小合并,单个测试用例的时间复杂度为 ,额外空间复杂度为 。
另外笛卡尔树可以解释这里的候选集合为何等价:题目的操作不改变大根笛卡尔树的形态,只会交换同一结构中较小数的位置。本文不需要显式建树,并查集已经直接维护了从大到小处理时的可选位置集合。
Small to Large
Small to Large 会为每个子树保留一份独立容器,例如 set、map 或计数表。后序处理节点时,直接复用最大的儿子容器,再枚举其他小容器中的元素并加入。与并查集按大小合并相比,这里真正发生了元素迁移。
牛客暑期多校训练营 9 D
题目链接: Nowcoder D
题意: 给定一棵以 为根的树。有 个人,第 个人从节点 在时刻 出发,每单位时间沿父边向根走一步。两人只要在同一时刻到达同一节点,就都会死亡;否则到达根即逃脱。求每个人能否逃脱。
思路:
先将树根定为 ,求出每个节点的深度 depth。对于从 在时刻 出发的人,记:
若他能活着走到祖先 ,到达 的时刻为:
因此,在同一个节点 汇合的两个人会相遇,当且仅当他们的 key 相同。这个 key 也可以理解为该人不受阻碍时到达根的时刻。
后序遍历整棵树。令节点 返回一张 map<key, id>,表示 的子树中仍然活着、且正在向上走的人;key 对应唯一的人编号。子树内部的相遇已经处理完毕,所以一张返回的 map 内不会有重复的 key。
处理 时,先找出 map 最大的儿子并直接复用它作为 cur,再把其余儿子的 map 中的元素逐个加入 cur。最后再加入所有从 出发的人。这就是这里的 Small to Large:始终保留最大的容器,只枚举较小容器中的元素。
加入一名 (key, id) 时分三种情况:
cur中没有key:此人暂时存活,直接记录。cur中已有key:两人恰好在 相遇,两人的答案都标记为死亡,并从cur删除这个key。- 这个
key已经发生过碰撞:之后再加入同一key的人也会在 相遇并死亡。
第三种情况不能只依赖 cur。例如三个不同儿子各有一人以相同 key 到达 :前两人碰撞后会从 cur 删除该键,第三人若直接插入就会被误判为存活。因此用 blocked 记录本节点已经发生过碰撞的 key;它不需要向父节点传递,因为对应的人已经全部死亡。
实现: 第一遍 DFS 计算根深度,并把每个人按起点分到 people[x] 中,记录其 key。第二遍 DFS 按后序处理节点,返回当前子树的 map<key, id>:
- 递归得到所有儿子的 map,选出其中最大的一张作为当前
cur,无需复制,直接复用。 - 枚举其他儿子的 map 中的每个
(key, id),按三种情况加入cur。这些人第一次与其他子树的人汇合的位置正是当前节点 ,所以在这里判定碰撞不会遗漏,也不会提前。 - 再以同样的规则加入
people[u]。从 出发的人与子树中的人第一次可能相遇的位置也是 。 blocked只在处理 时存在;它记录的是“已经在 死亡的到达时刻”。处理完成后,这些人不会继续向父亲移动,因此父节点既不需要也不能继承这些键。
最终根返回的 map 中的所有人都没有在任何节点相遇,答案为 1;发生过碰撞、被从 map 删除或被 blocked 拦下的人,答案为 0。
复杂度: 每个人随所在的 map 被合并时,容器规模按 Small to Large 的方式增长;总迁移次数均摊为 。std::map 的查询、插入和删除均为 ,故总时间复杂度为 ,额外空间复杂度为 。
DSU on Tree
DSU on Tree 同样优先复用重子树的信息,但不为每棵子树保存完整容器历史。它维护一份全局统计:轻子树完成自身答案后撤销,重子树的统计保留;处理父节点时,再把轻子树的节点加入。这是用重算换取更低的空间占用。
U41492 树上数颜色
题目链接: 洛谷 U41492
题意: 给定一棵以 为根的树,每个节点有一种颜色。多次询问某个节点的子树中,一共出现了多少种不同颜色。
思路: 这是 DSU on Tree 的标准应用。维护一个全局计数器 color[col],表示当前保留的节点集合中颜色 col 出现了多少次;再维护 diff,表示计数非零的颜色种类数。
对一棵子树执行 add(u, delta) 时,遍历整棵子树:
delta = 1时,将所有节点加入统计。某种颜色的计数从 变为 ,diff加一。delta = -1时,将所有节点移出统计。某种颜色的计数从 变为 ,diff减一。
先做一遍 DFS 求每个节点的子树大小,并找出最大儿子 heavy[u]。第二遍 DFS 按下面的顺序处理节点 :
- 先递归所有轻儿子,传入
keep = 0。每棵轻子树在算完自身答案后都会从全局统计中清空。 - 再递归重儿子,传入
keep = 1。重子树的统计会被保留,因此此时全局计数器恰好表示重子树。 - 将节点 本身加入统计,再逐棵遍历轻子树,把它们的所有节点加入统计。此时统计器恰好覆盖整棵 子树,直接令
ans[u] = diff。 - 若当前调用的
keep = 0,最后对整棵 子树执行一次add(u, -1),将它从全局统计器清空;否则保留它,交给父节点继续使用。
这里的 keep 不影响答案是否计算。无论 keep 是什么,都必须先在第 3 步得到 ans[u];keep 只决定答案求完后是否清空当前子树的颜色统计。
为什么这样做足够快?重儿子的统计直接复用,轻子树只会在作为“轻儿子”时被整棵重新加入。沿着任意节点到根的路径,每次经过轻边,所在子树大小至少翻倍,因此一名节点至多被重新加入 次。这正是 DSU on Tree 中“保留重儿子、重加轻儿子”的摊还基础。
实现: 颜色编号范围不超过 ,可以直接用数组维护 color。第一遍 DFS 填好 size 与 heavy;第二遍 DFS 使用全局 color 和 diff,在第 3 步记录每个节点的 ans。读入询问后直接输出对应的 ans[x]。
复杂度: 预处理和回答询问分别为 、。DSU on Tree 的加减总次数为 ,因此总时间复杂度为 ,额外空间复杂度为 。
对比与选择
三种方法共享“优先复用大对象”的方向,但不能混用模板。最本质的区别是:Union by Size 只决定根的挂接方向;Small to Large 保留各子树的独立状态;DSU on Tree 则将暂时不用的轻子树状态撤销,之后按需重算。
| 维度 | Union by Size | Small to Large | DSU on Tree |
|---|---|---|---|
| 核心 | 小连通块挂到大连通块 | 存储并合并小容器 | 撤销轻子树、按需重加 |
| 维护的状态 | 并查集父亲与集合大小 | 每个子树的一份独立容器 | 一份全局统计器 |
| 关键操作 | merge 时按大小决定根 | 遍历小容器并插入大容器 | 轻儿子清空,重儿子保留 |
| 常见数据结构 | 并查集 | map、set、可合并线段树 | 计数数组、树状数组、线段树 |
| 常见复杂度 | 单次操作均摊 | 使用 STL 时通常为 | 修改为 时为 |
| 空间特点 | 只维护连通块信息 | 保留子树容器,常数通常较大 | 不保留轻子树历史,空间更省 |
| 适用场景 | 动态连通性或按块合并 | 需要保留每棵子树/连通块的状态 | 离线求各节点子树答案,且统计支持加减 |
如果这篇文章对你有帮助,欢迎分享给更多人!
部分信息可能已经过时
