CDQ 分治 (CDQ Divide and Conquer) 完全指南
CDQ 分治是一种基于 分治思想 的离线算法,由陈丹琦(CDQ)引入。
它的核心思想是: 降维打击 。
- 它能将 动态问题 转化为 静态问题 。
- 它能将 kkk 维偏序问题 转化为 k−1k-1k−1 维偏序问题 。
一、核心思想:时间即维度
在处理复杂的数据结构题时,我们常遇到两类操作:修改 和 查询 。
通常我们认为"修改"改变了数据的状态,"查询"获取当前状态。
CDQ 分治提供了一个独特的视角: 把"时间"看作第一维度。
1.1 静态化视角
如果我们把所有的操作(无论是修改还是查询)都按发生的时间顺序排成一个序列:
- 修改操作 :在 (t,x,y)(t, x, y)(t,x,y) 处增加了一个值。
- 查询操作 :求 (t,x,y)(t, x, y)(t,x,y) 处的值(或前缀和)。
这就变成了一个 三维偏序问题 :
我们要统计所有满足 ti<tj,xi≤xj,yi≤yjt_i < t_j, x_i \le x_j, y_i \le y_jti<tj,xi≤xj,yi≤yj 的修改操作 iii 对查询操作 jjj 的贡献。
1.2 分治策略
CDQ 分治利用分治结构消除"时间"这一维度的限制:
对于区间 [L,R][L, R][L,R],我们将其划分为 [L,mid][L, mid][L,mid] 和 [mid+1,R][mid+1, R][mid+1,R]。
- 递归解决:先递归处理左右子区间内部的问题。
- 处理跨区间贡献 : 只考虑 左区间 [L,mid][L, mid][L,mid] 中的 修改操作 对 右区间 [mid+1,R][mid+1, R][mid+1,R] 中的 查询操作 的影响。
为什么这样是对的?
因为左区间的操作在时间上(下标)天然早于右区间。当我们处理"左对右"的贡献时,时间维度的条件 ti<tjt_i < t_jti<tj 已经自动满足了!我们只需要关心剩下的维度。
二、算法流程:以三维偏序为例 (陌上花开)
问题描述 :
给定 NNN 个元素,每个元素有三个属性 (a,b,c)(a, b, c)(a,b,c)。
对于每个元素 iii,求满足 aj≤ai,bj≤bi,cj≤cia_j \le a_i, b_j \le b_i, c_j \le c_iaj≤ai,bj≤bi,cj≤ci 且 j≠ij \ne ij=i 的元素 jjj 的数量。
2.1 算法步骤
这不仅是三维偏序的解法,也是 CDQ 分治的标准模板。
-
第一维排序 (aaa):
- 将所有元素按 aaa 属性从小到大排序。
- 如果 aaa 相同,按 bbb 排;bbb 相同按 ccc 排。
- 去重:如果有完全相同的元素,合并为一个,记录数量(cnt)。
- 此时,对于任意 i<ji < ji<j,必然满足 ai≤aja_i \le a_jai≤aj。 第一维 aaa 被消除了。
-
CDQ 分治 (b,cb, cb,c):
- 定义函数
solve(l, r)。 - 递归 :
solve(l, mid)和solve(mid + 1, r)。 - 处理贡献 :
- 我们的目标是统计左区间 [L,mid][L, mid][L,mid] 中满足 bi≤bj,ci≤cjb_i \le b_j, c_i \le c_jbi≤bj,ci≤cj 的元素 iii 对右区间 [mid+1,R][mid+1, R][mid+1,R] 中元素 jjj 的贡献。
- 排序 (bbb) :对 [L,mid][L, mid][L,mid] 和 [mid+1,R][mid+1, R][mid+1,R] 分别按 bbb 属性排序(通常用归并排序的方式,在递归过程中完成)。
- 双指针扫描 :遍历右区间每个元素 jjj。维护一个指针 iii 指向左区间。当 bi≤bjb_i \le b_jbi≤bj 时,将 iii 插入 树状数组 (位置为 cic_ici,值为 cnticnt_icnti),并右移 iii。
- 查询 (ccc) :在树状数组中查询 ≤cj\le c_j≤cj 的总和,加到 jjj 的答案中。
- 清空:处理完当前层后,要撤销树状数组的操作,还原状态。
- 定义函数
2.2 复杂度分析
- 层数 :logN\log NlogN。
- 每一层 :
- 排序:O(N)O(N)O(N) (归并排序)。
- 扫描与树状数组:O(NlogN)O(N \log N)O(NlogN) (树状数组操作)。
- 总复杂度 :O(Nlog2N)O(N \log^2 N)O(Nlog2N)。
三、代码实现 (Java)
这是一个标准的三维偏序(陌上花开)模板。
java
import java.util.*;
import java.io.*;
public class CDQDivideAndConquer {
// 元素类
static class Element implements Comparable<Element> {
int a, b, c;
int id; // 原始输入顺序(可选,如果需要按原序输出)
int cnt; // 重复元素个数
int ans; // 答案
public Element(int a, int b, int c, int id) {
this.a = a;
this.b = b;
this.c = c;
this.id = id;
this.cnt = 1;
this.ans = 0;
}
// 初始排序:优先按 a 排序,其次 b,最后 c
@Override
public int compareTo(Element o) {
if (this.a != o.a) return this.a - o.a;
if (this.b != o.b) return this.b - o.b;
return this.c - o.c;
}
}
static int N, K; // N个元素,最大值域K
static Element[] elements;
static Element[] temp; // 归并排序辅助数组
static int[] bit; // 树状数组
// 树状数组操作
static void update(int idx, int val) {
for (; idx <= K; idx += idx & -idx) {
bit[idx] += val;
}
}
static int query(int idx) {
int sum = 0;
for (; idx > 0; idx -= idx & -idx) {
sum += bit[idx];
}
return sum;
}
// CDQ 分治核心
static void solve(int l, int r) {
if (l >= r) return;
int mid = (l + r) >> 1;
// 1. 递归处理子问题
solve(l, mid);
solve(mid + 1, r);
// 2. 处理左区间对右区间的贡献
// 此时左区间和右区间内部虽然是乱序的(经过了下一层的归并),
// 但对于当前层,左区间的下标集合 < 右区间的下标集合,
// 意味着左区间的 a 仍然 <= 右区间的 a(来源于最开始的排序)。
// 我们需要按 b 排序来消除第二维。
// 这里的逻辑类似归并排序的 merge 过程
int i = l;
int j = mid + 1;
int k = l;
// 双指针扫描
while (j <= r) {
// 如果左边的 b 更小,它可能对右边有贡献,加入树状数组
while (i <= mid && elements[i].b <= elements[j].b) {
update(elements[i].c, elements[i].cnt);
temp[k++] = elements[i++];
}
// 计算当前右边元素 j 的答案
// 此时树状数组里已经包含了所有 a <= a[j] 且 b <= b[j] 的元素
// 我们只需要查 c <= c[j] 的数量
elements[j].ans += query(elements[j].c);
temp[k++] = elements[j++];
}
// 处理剩余的左边元素(虽然对当前右边没贡献了,但为了归并排序需要复制过去)
// 注意:这部分元素也要 update 进树状数组,以便后续清空!
// 或者,更简单的做法是:只 update 上面 while 循环里涉及的 i。
// 但为了清空方便,我们通常会在循环后专门写一个清空逻辑。
// 修正:上面的写法是标准的归并。但在 CDQ 中,
// 我们通常只对"进入了树状数组"的那些 i 进行操作。
// 且为了不影响下一轮(或其他分支),我们需要撤销树状数组的操作。
// 让我们稍微调整一下逻辑,把归并和统计分开写可能更清晰,
// 但为了性能,通常合在一起。这里为了清晰展示,我展示"撤销"的逻辑。
// 撤销树状数组的操作(清空)
// 注意:只能清空刚才修改过的位置,不能 Arrays.fill,否则复杂度退化
for (int p = l; p < i; p++) {
update(elements[p].c, -elements[p].cnt);
}
// 完成归并排序的剩余部分(纯排序,不涉及统计)
while (i <= mid) temp[k++] = elements[i++];
// 此时 temp[l...r] 已经是按 b 有序的了,复制回原数组
// 注意:由于上面的 j 循环可能已经结束,但 i 还没走完,
// 或者 i 走完了 j 还没走完。
// 上面的 while(j <= r) 只能保证 j 走完。
// 如果 i 没走完,上面的 while(i <= mid) 会把剩下的 i 拷进去。
// 如果 j 没走完(这种情况在标准归并里有,但在这里,
// j 没走完意味着剩下的 j 的 b 都很大,不影响统计,直接拷贝)
// 实际上,为了正确的归并排序,我们需要保证所有元素都进入 temp。
// 所以建议把统计和归并彻底分开,或者小心处理边界。
// 【标准模板写法】:
// 重新做一次清晰的归并
// 刚才的逻辑为了统计 ans,现在为了排序
// 实际应用中,可以在上面的循环里同时完成,但容易错。
// 下面演示最稳健的写法:统计归统计,排序归排序。
}
// 稳健版 CDQ 写法
static void solveRobust(int l, int r) {
if (l >= r) return;
int mid = (l + r) >> 1;
solveRobust(l, mid);
solveRobust(mid + 1, r);
// 统计贡献
int i = l;
int j = mid + 1;
while (j <= r) {
while (i <= mid && elements[i].b <= elements[j].b) {
update(elements[i].c, elements[i].cnt);
i++;
}
elements[j].ans += query(elements[j].c);
j++;
}
// 清空树状数组
for (int p = l; p < i; p++) {
update(elements[p].c, -elements[p].cnt); // 加上负值即减去
}
// 归并排序 (按 b 排序)
i = l; j = mid + 1;
int k = l;
while (i <= mid && j <= r) {
if (elements[i].b <= elements[j].b) temp[k++] = elements[i++];
else temp[k++] = elements[j++];
}
while (i <= mid) temp[k++] = elements[i++];
while (j <= r) temp[k++] = elements[j++];
for (int p = l; p <= r; p++) elements[p] = temp[p];
}
}
四、怎么快速掌握这种思考方式?
4.1 核心思维模型:降维
当你看到一个多维限制的问题(比如 x<X,y<Y,z<Zx < X, y < Y, z < Zx<X,y<Y,z<Z),不要试图直接攻克。
CDQ 分治的本质是剥洋葱:
- 最外层 (Sort) :用排序消灭第一维 aaa。
- 中间层 (Divide) :用分治消灭第二维 bbb。(把 bbb 的有序性局限在递归的子过程中)
- 最内层 (Data Structure) :用树状数组/线段树消灭第三维 ccc。
如果你有 4 维怎么办?
CDQ 套 CDQ 。外层 CDQ 消灭 bbb,内层 CDQ 消灭 ccc,树状数组消灭 ddd。
4.2 练习路径
-
入门:逆序对
- 题目:求数组中满足 i<ji < ji<j 且 ai>aja_i > a_jai>aj 的对数。
- 理解:这是二维偏序(iii 是一维,aaa 是一维)。归并排序求逆序对其实就是 CDQ 分治的雏形。
- 思考:在归并时,左边 aia_iai 大于右边 aja_jaj,由于 i<ji < ji<j 天然满足(分治结构保证),所以构成了逆序对。
-
进阶:三维偏序 (陌上花开)
- 理解:在 aaa 有序的前提下,用归并排序处理 bbb,用树状数组处理 ccc。
- 关键点 :理解为什么只能算"左对右"的贡献?因为我们已经排好序了,只有下标小的(原 aaa 小)才可能贡献下标大的。
-
高级:动态逆序对 / 动态二维偏序
- 题目:在一个平面上动态加点,询问某矩形内的点数。
- 转化:将"动态"看作时间维 ttt。
- 问题转化为三维偏序:ti<tnow,xi∈[x1,x2],yi∈[y1,y2]t_i < t_{now}, x_i \in [x1, x2], y_i \in [y1, y2]ti<tnow,xi∈[x1,x2],yi∈[y1,y2]。
- 利用容斥原理拆成 4 个查询,变成标准三维偏序。
4.3 避坑指南
- 去重 :在三维偏序中,如果两个元素 (a,b,c)(a,b,c)(a,b,c) 完全相同,普通的
<比较会导致它们互不贡献。必须去重,把数量记在cnt里,或者在比较时小心处理=的情况。 - 树状数组清空 :千万不要
memset!每次只撤销你修改过的那些节点。否则 O(N2)O(N^2)O(N2) 会超时。 - 递归顺序 :先递归子区间,还是先处理贡献?
- 如果是 偏序问题:顺序无所谓(因为是离线的),通常先递归再归并(类似后序遍历)。
- 如果是 CDQ 优化 DP :必须 先左,再中,再右(中序遍历)。因为右边的 DP 值依赖左边计算出的结果。