浅谈《双点扩展的九种做法》

· · 个人记录

曹神在校内讲了这个题,并给出了九种做法,标题中的文章也是曹神写的,已严肃学习。

题意

给定 n 个点 m 条边的连通无向图,q 次询问给出 x,y,z,求从 x,y 两点分别开始走,共经过 z 个不同点时,经过边的最大编号最小值。

转化后题意:向 n 个点的图中依次加入 m 条边,q 次询问 x,y 两点所在连通块并集大小在哪次加边后超过 z

数据范围:n,m,q\le {10}^5,下文均认为 n,m,q 同级。由于关心连通块大小,并查集均使用启发式合并。

概要

提交记录仅供参考,可能实现的比较粗糙。各解法按表格顺序标号。

做法 时间复杂度 空间复杂度 提交记录 是否在线
二分 + 可持久化并查集 O(n\log ^2 n) O(n) 243ms 在线
整体二分 + 可撤销并查集 O(n\log^2 n) O(n) 83ms 离线
按层整体二分 + 并查集 O(n\log n\alpha(n)) O(n) 83ms 离线
整体二分 + DFS O(n\log n) O(n) 97ms 离线
Kruskal 重构树 + 二分 + 倍增 O(n\log^2 n) O(n\log n) 357ms 在线
Kruskal 重构树 + 异步倍增 O(n\log n) O(n\log n) 110ms 在线
Kruskal 重构树 + 主席树二分 O(n\log n) O(n\log n) 173ms 在线
折半警报器 + 可删堆 O(n\log ^2n) O(n\log n) 307ms 离线
二进制警报器 + 链表 O(n\log n) O(n\log n) 188ms 离线

Sol.1

直接考虑二分,需求出 mid 时刻两点所在连通块及大小。可直接使用可持久化并查集预处理出每个时刻的状态,二分时在上面查询即可。这里给每个点记录一个时间戳 t_i,表示该点作为根在 t_i 时刻合并到了别的点上。另外在每个点上开一个二元组序列,记录其为根的连通块点数增加的时间和增加后的大小。由于合并次数是线性的,空间复杂度得到保证。查询 mid 时刻 x 点时先跳父亲直到 t_x>mid,再在该点的二元组序列上二分出连通块大小即可。该过程难以路径压缩,只能做到单次 O(\log n)

Sol.2-4

多组询问考虑整体二分,要注意每个元素在每层只能访问线性次,这样才能保证复杂度。此时需求出 mid 时刻每个点所在连通块及大小,有以下三种做法。

Sol.2

直接使用可撤销并查集,用栈维护已进行的操作,并维护当前时刻的指针。处理某个答案区间时将指针移动到 mid,过程中进行加边和撤销操作即可,显然指针在每层移动次数是线性的。由于可撤销并查集不能路径压缩,只能做到单次 O(\log n),但该做法常数很小。

Sol.3

考虑按层进行整体二分。具体地,对每层记录所有答案区间,显然其两两不交。从小到大依次处理每个区间,则 mid 一定递增。因此先加入两个 mid 之间的边,再用当前并查集查询即可。由于不需要撤销,可以使用路径压缩做到均摊单次 O(\alpha(n))

Sol.4

据说该做法是青岛地区信息学奠基人 yyc 提出的,因此又称 “yyc trick”。

初始处理答案区间 [1,n] 时,需要知道 [1,mid] 内边连成的连通块情况,这个一遍 DFS 即可求出,左区间 [1,mid] 也可直接递归下去做。问题在于处理右区间 [mid+1,n] 时不能再访问 [1,mid] 内的边,否则递归后复杂度无法保证,因此右区间只能使用 [mid+1,r] 内的边处理。考虑处理完左区间后用 [1,mid] 内的边 DFS 出连通块,并将连通块缩成一个点,带上总点数的权值。之后枚举所有右区间的边和询问,将其 u,v,x,y 均重标号,再递归下去处理。

因此整个过程为:用 [l,mid] 内的边 DFS,处理询问并分到两侧;递归到左区间处理;再次用 [l,mid] 内的边 DFS,用连通块更新权值,并对右区间的元素重标号;递归到右区间处理;将权值还原为更新前的状态。注意缩点时新权值为原权值和而非原点数,还原权值是因为更高层预处理时还会用到原权值。另外为保证复杂度,只能访问 m 条边的 2m 个端点 DFS,其他点默认为单点即可。还要注意每次 DFS 都要清空,总之细节较多,比较难写。感觉实现得确实很粗糙,但还是跑挺快的。

以上四种做法均容易特判 x,y 在同一连通块内的情况,故不再赘述。

Sol.5-7

注意到最优解一定会走最小(瓶颈)生成树上的边,因此考虑建出合并树(Kruskal 重构树)求解。该树从根到叶子权值(原图边编号)w_i 递减,子树内叶子(原图点)数 s_i 也递减。所求即最小 t 使得 x,y 两点跳到 w\le t 的最远祖先后,两子树的并集内叶子数量达到 z,有以下三种做法。

Sol.5

直接二分 t 的取值,再用倍增将两点跳到 w\le mid 的最远祖先,求一下当前叶子数量即可。这里若 x,y 在同一连通块内则一定会跳到同一点,容易特判。由于二分和倍增都很满,该做法在所有做法中跑得最慢。

Sol.6

据说该做法是提出了无数 trick 的 gqh 提出的,因此又称 "gqh trick"。

上个做法无法直接倍增是因为两点跳 2^k 步时,所到点的 w 值没有任何规律,难以进行。考虑转为倍增到权值最大的一组不满足 z 限制的点,方法为对 x,y 分别维护倍增位置 kx,ky,每次跳到 x2^{kx} 级祖先和 y2^{ky} 级祖先,并统计两子树并集内叶子数量。若已满足限制,则至少有一个点跳多了,因此要求权值较大的点不能跳到,其 k 值减一;若不满足限制,则至少有一个点跳少了或恰好够,因此将权值较小的点跳过去,并将 k 值减一。

这样最终确定 x,y 后,\min(w_{fa_x},w_{fa_y}) 即为答案。任意一点往上跳一步就合法是显然的,否则可以跳得更高。然而有时会出现 w_x>w_{fa_y} 这样的情况,但此时答案只取 w_{fa_y} 仍正确。这是因为若 y 没有成功跳到 fa_y,一定是因为在 fa_y 时跟另一边已满足限制,且 w_{fa_u} 比另一边大,这样才会强制 y 不能跳上去。因此一定存在以 w_{fa_y} 为较大值的合法方案,w_{fa_x} 也同理,因此这个答案是对的。

过程中有一些细节,比如求两子树并集内叶子数量时,需要特判某点是另一点祖先的情况,此时应只计算祖先点。判断可以预先 DFS 一遍,用 DFS 序判断是否在子树内。另外若某个点已确定,即可只对另一个点倍增完剩下的。这个做法比较抽象,但确实很妙。

Sol.7

与上个做法截然相反,本做法比较暴力。考虑将 Sol.5 中的 s 全放到对应的 w 处,也就是把每个点到根路径上的所有 (w,s) 放到 w 处,并直接对 w 二分,找到两边前缀最大值之和满足限制的最小 w 即为答案。处理方式是维护主席树,每个点从其父亲继承过来,再加入自己的信息即可。二分时由于主席树结构相同,可直接在两棵树上同时二分。

注意特判两个点在同一连通块内的情况,方式是在线段树上维护每个区间对应的树上节点,在上一步二分出结果后取出对应的两个点,判断其是否有祖先关系,没有则答案正确,有则需继续二分出单个值不低于 z 的最小值作为答案。这里从头开始二分即可,前面部分对答案没有影响。

Sol.8-9

考虑只询问单点连通块大小何时不低于 z,可以使用警报器。把询问挂在点上,并查集合并时也把所有警报器启发式合并,然后按 z 从小到大检查每个警报是否触发,若触发则该询问答案为当前边边权,删除该警报器;否则后面也不会触发,直接停止检查。这样总检查次数就是线性的了。为了维护从小到大的顺序,需要用 set 或堆维护所有警报,总时间复杂度是 O(n\log^2n) 的。本题需要维护两个点的警报,有以下两种做法。

Sol.8

由于 s_x+s_y\ge z 需要 s_x,s_y 中至少一个不小于 \lceil \frac z2\rceil,因此考虑折半,在 x,y 上各放一个 \lceil \frac z2\rceil 的警报,同上进行启发式合并。当两个警报中的一个触发时,将两个警报都删掉并检查,若不合法则令 z'=z-(s_x+s_y),并更新 x 的警报为 s_x+\lceil \frac {z'}2\rceily 的警报为 s_y+\lceil \frac {z'}2\rceil,再放回原位置。由于每次 z' 至多为 z 的一半,至多误报 O(\log n) 次即可得到答案。

注意特判两个点在同一连通块内的情况,方式是在警报触发时先判断目前是否在同一连通块,若已在则转而加入上面说的单点警报器,注意与两点警报器区分。由于每个询问最多同时存在两个警报器,每个时刻警报器总数都是线性的,启发式合并的复杂度不变。该过程可使用 set 维护,由于堆不支持删除任意元素,只能用可删堆代替 set 减小常数,上面表格中的代码使用了可删堆。

Sol.9

考虑优化折半警报器,发现其触发的条件比较严苛,导致必须按值从小到大维护。因此考虑将限制放宽,对 s_x,s_y 找到最大的 p 使得 f(p,s_x)+f(p,s_y)<z,其中 f(p,i) 表示大于 i 的数中最小的 2^p 的倍数。将 p 的警报放在 x,y 目前连通块上,在该连通块大小增大过程中经过 2^p 的倍数时触发。增大过程中经过是指 s 增大到 s' 的过程中,存在 2^p\mid t 满足 s<t\le s'。警报触发后同样删除、检查,再计算出新的 p 放回去。由于 p 不增,直接不断减小并检查是否合法即可。

现在需要证明误报次数是 O(\log n) 级别的。考虑转而证明一个 p 误报 O(1) 次就会变小,由于 f(p,s_x)+f(p,s_y)<zf(p+1,s_x)+f(p+1,s_y)\ge z,因此 z-(s_x+s_y)\le 2\times 2^{p+1}= 2^{p+2},又由于 s_x,s_y 第二次及之后触发警报 p 时均增大了至少 2^p,因此同一个 p 触发 6 次时一定求出了答案,即最多误报 5 次。好像可以证明到不超过 3 次,不会。

实现上注意到每次 s\rightarrow s' 时触发的 p 是一段前缀,即 s,s' 不同的最高位及更低的位均会触发。因此在每个点上维护 O(\log n) 组警报,每次触发前缀内的所有警报即可。另外还要求可以删除任意元素,并能以较低复杂度合并。vector 维护启发式合并不能快速删除,因此只能使用链表维护。具体地,每个点的每个组维护一个链表,两点合并时把 O(\log n) 对链表接起来即可,接一对复杂度 O(1),总复杂度 O(\log n)。删除也是容易的,只需要记录每个询问的两个警报编号,即可在链表中 O(1) 删除,于是做到了 O(n\log n) 的总复杂度。

注意特判两个点在同一连通块内的情况,同样在触发时先看是否已在同一连通块,若已在则之后的警报都用单个元素的 f(p,s_x) 处理,误报次数同样有保证。细节上要注意在某点上删除后,在同一个点新加入警报不能直接加,而是应该记录下来,等全删完后统一加入,否则可能会出问题。