引言
CDQ 分治是我国选手陈丹琦总结的一类算法思想,OI Wiki上这个算法用来解决三种问题,分别是多维点对问题、DP转移类问题和动态转静态问题。如果有未涉及到的部分,可以自行查阅学习。本文会介绍这种算法,并给出几道例题的题解。
以经典的三维偏序为例,可以先通过排序处理第一维,再用 CDQ 分治组织第二维的计算顺序,最后借助树状数组完成第三维的前缀累加。这样一来,三维条件被依次分摊到排序、分治和数据结构中,原本的高维统计就获得了清晰的处理路径。
很久以前学习树状数组时,逆序对问题是最常见的练习之一:它既可以用树状数组统计,也可以用归并分治完成。我认为CDQ分治可能是对这种分治方法的拓展总结。
算法思路
我对 CDQ 分治的理解是:每次递归只负责计算跨越左右区间的贡献。当前区间被划分为 和 后,先分别递归处理左右两个子区间,再统计左侧元素对右侧元素产生的答案贡献。同一个子区间内部的关系,会在更深的递归层中继续被划分;因此等当前层处理到这个区间时,区间内部的贡献已经在之前的合并过程中统计过了,不需要重复计算。
一层 CDQ 通常可以按下面的顺序组织:
- 递归处理左区间和右区间。
- 根据第二维对左右区间进行有序扫描,用双指针维护已经满足第二维条件的左侧元素。
- 将这些左侧元素加入树状数组等数据结构,并查询右侧元素在第三维上的前缀贡献。
- 当前层处理结束后,将整个 区间按第二维重新排好序,交给上一层继续使用。
最后一步很重要。递归返回时,不能只保证左右区间各自有序,而要保证合并后的整个区间有序。这样上一层在扫描自己的左右区间时,才能继续使用双指针推进,而不必重新处理区间内部的顺序关系。
如果每一层都直接调用快速排序(例如 C++ 的 sort),代码会比较简洁。此时每层排序需要 ,递归共有 层,排序部分的复杂度为 。也可以手写归并排序,在合并两个已经按第二维排好序的区间时直接得到整体有序序列,把排序部分降到 。不过如果第三维还要通过树状数组完成单次 的修改和查询,那么整套算法通常仍为 ;归并排序主要用于优化排序部分的复杂度和常数。
因此,CDQ 的通用模板可以概括为:递归、处理跨区间贡献、维护扫描顺序、合并成下一层需要的有序区间。具体的“贡献”可以是计数、最大值、最小值或动态规划转移,双指针维护的条件和树状数组保存的信息则随题目要求变化。
例题
P3810 【模板】三维偏序 / 陌上花开
题目链接: P3810 三维偏序
这是最经典的三维偏序问题。给定 个三元组 ,对于每个点,统计三个维度都不超过它的点的数量,最后再统计每种数量分别对应多少个点。
这道题的整体思路正如前面所说:先排序处理第一维,再用 CDQ 分治处理第二维,最后用树状数组维护第三维的前缀和。排序后,第一维的条件已经由点的顺序保证;在 CDQ 的合并过程中,左右区间分别按照第二维扫描;当左侧点满足第二维条件时,将它加入树状数组,查询右侧点的第三维前缀和,就得到了三维条件同时满足时的贡献。
实现时首先需要处理完全相同的点。相同的三元组在统计时也要互相贡献,如果直接把它们当成独立点处理,会产生重复计算。因此先按照 排序,将连续出现的相同点合并,记录这一组点的数量 cnt。之后只对合并后的点进行 CDQ 分治,最后再把这一组点的贡献还原给原来的 cnt 个点。
CDQ 的递归部分只计算跨越左右区间的贡献。递归处理完左右区间后,枚举右区间的点,并用双指针扫描左区间。对于当前右侧点 ,只要左侧点 满足 ,就把它的 加入树状数组;随后查询 query(c_j),即可得到左区间中同时满足第二维和第三维条件的点数。加入树状数组的增量不是 ,而是该点的 cnt,因为一个合并点代表了多个完全相同的原始点。
扫描结束后,要把本轮加入树状数组的左侧点全部撤销,避免它们影响其他递归区间。最后再将整个 区间按照第二维排序,使当前区间在返回上一层时保持整体有序。这里可以直接使用 sort 简化代码;如果改成归并排序,就可以在合并两个已有序区间的同时完成排序。
需要注意比较符号和题目的定义保持一致。这里树状数组加入条件使用 ,查询使用 ,对应题目中“每一维不超过”的偏序关系。如果题目要求严格小于,就需要同步修改排序、双指针和树状数组查询的边界。
相同点合并后,设某个点的 CDQ 贡献为 ans,那么它对应的统计数量为 ans + cnt - 1:ans 来自其他点,cnt - 1 来自同组中除自身以外的重复点。最后将这个数量对应的答案计数增加 cnt,即可恢复所有原始点的结果。
复杂度: 初始排序和相同点合并需要 。CDQ 分治有 层,每层树状数组的修改和查询为 ,因此总时间复杂度为 ,额外空间复杂度为 。
Grand Swap Master
题目链接: Grand Swap Master
这道题的关键不在于直接模拟操作,而是先对题目中的式子进行数学化简。化简之后,可以把一个位置表示成二维点 ,原问题转化为统计满足特定偏序关系的点对:一维满足严格大小关系,另一维满足对应的大小关系。除此之外, 和 相邻,以及 或 位于 、 等边界位置时,原来的推导不再适用,需要单独讨论。
因此,算法分成两部分。首先把相邻位置和边界位置产生的贡献直接用公式计算出来,记入 base;然后只处理满足一般情况的点对。这样可以避免在 CDQ 中混入边界条件,让分治部分只负责统一的二维偏序统计。
对于一般情况,令
将所有点按照 从小到大排序,之后第一维的关系就由数组顺序保证。接下来只需要使用 CDQ 分治处理第二维 。每次递归仍然只统计跨越左右区间的贡献:右区间的点依次扫描,左区间的点通过双指针加入统计范围,再根据 的关系累加答案。
代码中排序时对 相同的点按照 从大到小排列。这个 tie-break 是为了配合题目要求的严格关系:当第一维相同时,利用第二维的反向顺序,避免相等的第一维被错误地当成合法的先后关系。进入 CDQ 合并后,区间会按照 排序;双指针通过跳过不满足条件的左侧点,直接统计当前右侧点对应的左侧数量。
这部分代码不需要树状数组,因为当前只需要知道满足第二维条件的左侧点个数,不需要再对第三维进行前缀查询。双指针移动到位置 i 时,i - l 就是已经满足条件的左侧点数量,将它累加到 add 即可。
这里的判断只写成 p[i].y < p[j].y,并不是只统计两维都变小的情况。数学化简后,合法点对满足
也就是两维同时变小,或者两维同时变大。由于统计的是点对而不是有向关系,后一种情况交换 的顺序后,就变成了前一种情况。将点按照 从小到大排序后,每个合法点对都会唯一地表现为左侧点的 、 都小于右侧点,因此 CDQ 只需要统计 x_left < x_right 且 y_left < y_right。两维都大于的情况已经以相反的下标顺序被包含在其中了。
最后还要处理相邻位置的重复统计。相邻点对在一般二维偏序中可能已经被 CDQ 统计过,但它们在原题中属于前面单独讨论的特殊情况,所以用 adj 记录这些被重复计算的数量,最终答案为:
其中 base 是边界和特殊位置直接计算出的贡献,add 是 CDQ 对一般点对统计出的贡献,adj 则是需要从中扣除的相邻点对。
复杂度: 特殊情况的处理是线性的。对一般点按照 排序后,CDQ 分治每层进行线性双指针扫描,若每层使用 sort 重新按照 排序,则总时间复杂度为 ,额外空间复杂度为 。如果将最后的排序改为归并,可以把排序部分优化为 。
病毒片段
题目链接: 病毒片段
这道题在赛后补题时,常见的做法是扫描线加线段树。这里换一种更直接的方式,只使用 CDQ 分治完成同样的二维偏序查询。
一个病毒片段可以表示为区间 ,它的长度是
对于询问区间 ,需要在满足
的所有病毒片段中取最大长度。原始条件的一维是“向右”,不符合标准的左下偏序,因此将左端点取反:
询问点同样取为
于是条件变成
这就是标准的二维偏序。点的权值也可以写成
因此只需要查询左下矩形中的最大点权。
把病毒片段作为 type = 0 的点,把询问作为 type = 1 的点,所有点按照第一维 排序后放入同一个数组。CDQ 递归处理左右区间,合并时只让左侧的病毒点对右侧的询问产生贡献:左右区间按第二维 扫描,当左侧点满足 时,将它的权值加入当前最大值;右侧询问直接用这个最大值更新答案。
第一维相等时,排序让病毒点排在询问之前,这样 的边界点可以被查询包含。第二维同样使用 <=,对应题目中的 。
CDQ 的每一层处理完贡献后,还要将整个区间按照 排序,保证返回上一层时左右区间已经具备归并扫描所需的顺序。代码使用 sort 可以简化实现;如果改为归并排序,则可以在线性时间内完成这一层的整体排序。
当某个询问没有匹配的病毒片段时,维护的答案仍是负无穷,输出时再与 取最大值即可。这样既能正确表示“没有可选区间”,也不会因为把初值设成 而掩盖点权可能为负的情况。
复杂度: 将所有病毒片段和询问一起排序后,使用 sort 完成每层按 的重排,时间复杂度为 ,额外空间复杂度为 。若使用归并维护 的有序性,排序部分可以优化到 。
如果这篇文章对你有帮助,欢迎分享给更多人!
部分信息可能已经过时
