CDQ 分治 (CDQ Divide and Conquer)

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 分治的标准模板。

  1. 第一维排序 (aaa)

    • 将所有元素按 aaa 属性从小到大排序。
    • 如果 aaa 相同,按 bbb 排;bbb 相同按 ccc 排。
    • 去重:如果有完全相同的元素,合并为一个,记录数量(cnt)。
    • 此时,对于任意 i<ji < ji<j,必然满足 ai≤aja_i \le a_jai≤aj。 第一维 aaa 被消除了。
  2. 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 复杂度分析

  • 层数 :log⁡N\log NlogN。
  • 每一层
    • 排序:O(N)O(N)O(N) (归并排序)。
    • 扫描与树状数组:O(Nlog⁡N)O(N \log N)O(NlogN) (树状数组操作)。
  • 总复杂度 :O(Nlog⁡2N)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 分治的本质是剥洋葱:

  1. 最外层 (Sort) :用排序消灭第一维 aaa。
  2. 中间层 (Divide) :用分治消灭第二维 bbb。(把 bbb 的有序性局限在递归的子过程中)
  3. 最内层 (Data Structure) :用树状数组/线段树消灭第三维 ccc。

如果你有 4 维怎么办?
CDQ 套 CDQ 。外层 CDQ 消灭 bbb,内层 CDQ 消灭 ccc,树状数组消灭 ddd。

4.2 练习路径

  1. 入门:逆序对

    • 题目:求数组中满足 i<ji < ji<j 且 ai>aja_i > a_jai>aj 的对数。
    • 理解:这是二维偏序(iii 是一维,aaa 是一维)。归并排序求逆序对其实就是 CDQ 分治的雏形。
    • 思考:在归并时,左边 aia_iai 大于右边 aja_jaj,由于 i<ji < ji<j 天然满足(分治结构保证),所以构成了逆序对。
  2. 进阶:三维偏序 (陌上花开)

    • 理解:在 aaa 有序的前提下,用归并排序处理 bbb,用树状数组处理 ccc。
    • 关键点 :理解为什么只能算"左对右"的贡献?因为我们已经排好序了,只有下标小的(原 aaa 小)才可能贡献下标大的。
  3. 高级:动态逆序对 / 动态二维偏序

    • 题目:在一个平面上动态加点,询问某矩形内的点数。
    • 转化:将"动态"看作时间维 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 避坑指南

  1. 去重 :在三维偏序中,如果两个元素 (a,b,c)(a,b,c)(a,b,c) 完全相同,普通的 < 比较会导致它们互不贡献。必须去重,把数量记在 cnt 里,或者在比较时小心处理 = 的情况。
  2. 树状数组清空 :千万不要 memset!每次只撤销你修改过的那些节点。否则 O(N2)O(N^2)O(N2) 会超时。
  3. 递归顺序 :先递归子区间,还是先处理贡献?
    • 如果是 偏序问题:顺序无所谓(因为是离线的),通常先递归再归并(类似后序遍历)。
    • 如果是 CDQ 优化 DP :必须 先左,再中,再右(中序遍历)。因为右边的 DP 值依赖左边计算出的结果。

相关推荐
笨蛋不要掉眼泪2 小时前
OpenFeign远程调用详解:声明式实现、第三方API集成与负载均衡对比
java·运维·负载均衡
洛豳枭薰2 小时前
Redis 基础数据结构
数据结构·redis
yaoxin5211232 小时前
326. Java Stream API - 实现自定义的 toList() 与 toSet() 收集器
java·开发语言
追随者永远是胜利者2 小时前
(LeetCode-Hot100)31. 下一个排列
java·算法·leetcode·职场和发展·go
Cosmoshhhyyy2 小时前
《Effective Java》解读第40条:坚持使用Override注解
java·开发语言
ValhallaCoder2 小时前
hot100-二分查找
数据结构·python·算法·二分查找
0 0 02 小时前
【C++】矩阵翻转/n*n的矩阵旋转
c++·线性代数·算法·矩阵
m0_531237172 小时前
C语言-指针,结构体
c语言·数据结构·算法
癫狂的兔子2 小时前
【Python】【机器学习】十大算法简介与应用
python·算法·机器学习