分治:序列分治(CDQ)与点分治
分治:序列分治(CDQ)与点分治
一、分治思想概述
分治(Divide and Conquer)是算法设计中最核心的思想之一。它的基本策略是:
- 分(Divide):将原问题划分为规模更小的子问题。
- 治(Conquer):递归地求解子问题(若子问题足够小则直接求解)。
- 合(Combine):将子问题的解合并为原问题的解。
分治的威力在于:如果每次将问题规模缩小为原来的一半,那么递归深度仅为 \(O(\log n)\),配合高效的合并策略,往往能得到 \(O(n \log n)\) 级别的算法。
经典的分治算法包括:归并排序、快速排序、线段树等。而本文要讨论的 CDQ 分治和点分治,是分治思想在竞赛中的两大高级应用,分别处理序列上的偏序 /动态问题和树上的路径问题。
二、序列分治——CDQ 分治
2.1 基本概念与核心思想
CDQ 分治是一种基于时间(序列顺序)的分治思想,由 IOI2008 金牌得主陈丹琦在高中时整理总结,因而得名。它不是某个具体算法,而是一种处理问题的范式,与动态规划类似。
CDQ 分治主要应用于以下三类问题:
| 类别 | 问题描述 | 核心做法 |
|---|---|---|
| 点对统计 | 统计序列中满足某些偏序关系的点对数量 | 分治 + 数据结构合并 |
| 1D/1D DP 优化 | 一维 DP,转移 \(O(n)\) 降为 \(O(\log n)\) | 分治区间转移 |
| 动态转静态 | 带修改的操作序列转化为离线静态问题 | 以时间为分治中点 |
核心流程
对于序列上的点对问题,CDQ 分治的 solve(l, r) 处理区间 \([l, r]\) 内所有点对的贡献。算法步骤为:
- 取中点 \(mid = \lfloor (l+r)/2 \rfloor\)。
- 划分点对:将 \([l, r]\) 内的所有点对 \((i, j)\)(\(i < j\))分为三类:
- 第一类:\(i, j \in [l, mid]\)(都在左半)
- 第二类:\(i \in [l, mid],\; j \in [mid+1, r]\)(跨左右)
- 第三类:\(i, j \in [mid+1, r]\)(都在右半)
- 递归处理第一类
solve(l, mid)和第三类solve(mid+1, r)。 - 合并处理第二类——计算左半对右半的贡献。
关键洞察:递归处理完左右两半后,只剩"跨左右"的点对需要额外处理。而此时左半的下标 \(i \le mid < j\),天然保证了 \(i < j\) 的偏序关系(即"时间维"已被分治结构处理)。
这正是 CDQ 分治的精髓——用分治的中点天然消除一个维度。
2.2 经典应用:三维偏序
给定 \(n\) 个元素,每个元素有三个属性 \((a, b, c)\)。统计满足 \(a_i \le a_j,\; b_i \le b_j,\; c_i \le c_j\) 且 \(i < j\) 的点对数量。
分析
三个维度需要分别处理。我们对每一维使用不同的工具:
| 维度 | 处理方式 | 原因 |
|---|---|---|
| 第一维 \(a\) | 排序 | 全局排序保证 \(a\) 的偏序关系 |
| 第二维 \(b\) | CDQ 分治(归并排序) | 分治中点消除下标偏序,归并按 \(b\) 排序 |
| 第三维 \(c\) | 树状数组 | 在合并阶段动态查询 \(c\) 的前缀和 |
算法步骤
-
预处理:将序列按 \(a\) 从小到大排序(若 \(a\) 相同则按 \(b\),再按 \(c\))。这样保证了在序列中 \(i\) 排在 \(j\) 前面时 \(a_i \le a_j\)。同时,将完全相同的元素合并,记录出现次数 \(cnt\)。
-
CDQ 分治
solve(l, r):- 递归
solve(l, mid)和solve(mid+1, r)。 - 此时左半 \([l, mid]\) 的元素 \(a\) 值 \(\le\) 右半 \([mid+1, r]\) 的元素 \(a\) 值(因为预先按 \(a\) 排序,分治不会打乱这个关系),第一维已满足。
- 将左右两半分别按 \(b\) 排序(用归并或直接
sort)。 - 用双指针:遍历右半每个元素 \(j\),将左半所有 \(b_i \le b_j\) 的元素 \(i\) 的 \(c\) 值加入树状数组。
- 查询树状数组中 \(\le c_j\) 的元素个数,即为左半对 \(j\) 的贡献。
- 递归
-
统计答案:对于去重后的第 \(i\) 个元素,其满足条件的点对数为 \(res_i + cnt_i - 1\)(包括自身)。
2.3 代码实现与复杂度分析
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const int N = 1e5 + 5;
const int K = 2e5 + 5;
ll n, k;
struct node {
ll a, b, c;
ll cnt, res;
};
node x[N];
node nx[N];
ll m;
ll ans[N], bit[N];
bool cmpA(node g, node h) {
if (g.a != h.a) return g.a < h.a;
if (g.b != h.b) return g.b < h.b;
return g.c < h.c;
}
bool cmpB(node g, node h) {
if (g.b != h.b) return g.b < h.b;
return g.c < h.c;
}
ll lowbit(ll g) {
return g & (-g);
}
void add(ll g, ll h) {
for (; g <= k; g += lowbit(g)) {
bit[g] += h;
}
}
ll query(ll g) {
ll ans = 0;
for (; g; g -= lowbit(g)) ans += bit[g];
return ans;
}
void CDQ(ll l, ll r) {
if (l == r) return;
ll mid = (l + r) / 2;
CDQ(l, mid);
CDQ(mid + 1, r);
sort(nx + l, nx + mid + 1, cmpB);
sort(nx + mid + 1, nx + r + 1, cmpB);
ll i = l, j = mid + 1;
while (j <= r) {
while (i <= mid && nx[i].b <= nx[j].b) {
add(nx[i].c, nx[i].cnt);
i ++;
}
nx[j].res += query(nx[j].c);
j ++;
}
for (ll p = l; p < i; p ++) add(nx[p].c, -nx[p].cnt);
}
int main () {
// freopen("", "r", stdin);
// freopen("", "w", stdout);
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n >> k;
for (ll i = 1; i <= n; i ++) {
cin >> x[i].a >> x[i].b >> x[i].c;
}
sort(x + 1, x + n + 1, cmpA);
ll t = 0;
for (ll i = 1; i <= n; i ++) {
t ++;
if (i == n || !(x[i].a == x[i + 1].a && x[i].b == x[i + 1].b && x[i].c == x[i + 1].c)) {
nx[++ m].a = x[i].a;
nx[m].b = x[i].b;
nx[m].c = x[i].c;
nx[m].cnt = t;
t = 0;
}
}
CDQ(1, m);
for (ll i = 1; i <= m; i ++) {
ans[nx[i].res + nx[i].cnt - 1] += nx[i].cnt;
}
for (ll i = 0; i < n; i ++) {
cout << ans[i] << '\n';
}
return 0;
}
复杂度分析
- 时间复杂度:CDQ 分治共 \(O(\log n)\) 层,每层中排序和双指针扫描均为 \(O(n \log n)\)(树状数组操作 \(O(\log k)\)),总计 \(O(n \log^2 n)\)。
- 空间复杂度:\(O(n + k)\)。
代码细节要点
-
去重:完全相同的 \((a, b, c)\) 元素必须合并,否则会漏算。去重后记录 \(cnt\),最终答案为 \(res_i + cnt_i - 1\)。
-
清空树状数组:不能用
memset(会超时),只能将用过的位置逐个减回去。这是保证复杂度的关键。 -
排序的等价性:在合并阶段对左右两半分别按 \(b\) 排序,不会破坏第一维 \(a\) 的大小关系(因为左半的 \(a\) 普遍 \(\le\) 右半),这是 CDQ 分治正确性的基础。
2.4 CDQ 套 CDQ:四维偏序
四维偏序:给定 \(n\) 个四元组 \((a, b, c, d)\),统计满足四维均 \(\le\) 的点对数。
处理思路与三维偏序类似,逐维降低:
| 维度 | 处理方式 |
|---|---|
| 第一维 \(a\) | 排序 |
| 第二维 \(b\) | 外层 CDQ 分治 |
| 第三维 \(c\) | 内层 CDQ 分治(或归并排序) |
| 第四维 \(d\) | 树状数组 |
外层 CDQ 负责按 \(b\) 的偏序关系分治,同时给左半元素打标记 tag=1,右半元素 tag=0。合并时按 \(b\) 排序后,将所有元素送入内层。
内层 CDQ 对 tag 数组按 \(c\) 分治归并,合并时用双指针按 \(c\) 排序,将 tag=1 的元素加入树状数组,tag=0 的元素查询。
// 外层 CDQ:处理 b 维
void cdq2(int l, int r) {
if (l == r) return;
int mid = (l + r) >> 1;
cdq2(l, mid);
cdq2(mid + 1, r);
// 给左半打标记
for (int i = l; i <= mid; i++) tmp[i].tag = 1;
for (int i = mid + 1; i <= r; i++) tmp[i].tag = 0;
// 按 b 归并,送入内层 CDQ 处理 c, d 两维
merge_by_b(l, mid, mid + 1, r); // 归并到 tmp2[l..r]
cdq_inner(l, r); // 内层处理 c, d
// 恢复
for (int i = l; i <= r; i++) /* restore order */;
}
// 内层 CDQ:处理 c 维,用树状数组处理 d 维
void cdq_inner(int l, int r) {
if (l == r) return;
int mid = (l + r) >> 1;
cdq_inner(l, mid);
cdq_inner(mid + 1, r);
sort(tmp2 + l, tmp2 + mid + 1, cmpC); // 按 c 排序
sort(tmp2 + mid + 1, tmp2 + r + 1, cmpC);
int i = l, j = mid + 1;
while (j <= r) {
while (i <= mid && tmp2[i].c <= tmp2[j].c) {
if (tmp2[i].tag) bit.add(tmp2[i].d, 1);
i++;
}
if (!tmp2[j].tag)
tmp2[j].ans += bit.query(tmp2[j].d);
j++;
}
for (int p = l; p < i; p++)
if (tmp2[p].tag) bit.add(tmp2[p].d, -1);
}
复杂度:\(O(n \log^3 n)\)。实际上常数较大,\(n\) 不太大时可用。
规律总结:\(k\) 维偏序可以用 \(k-2\) 层 CDQ 套 CDQ + 1 次排序 + 1 个树状数组解决,复杂度 \(O(n \log^k n)\)。
2.5 动态问题转静态
CDQ 分治还有一个强大应用:将带修改的动态问题转化为离线静态问题。
典型例题:CQOI2011 动态逆序对
给定 \(1 \sim n\) 的排列,按给定顺序依次删除 \(m\) 个元素,每次删除前求当前逆序对数。
分析:
- 每个元素有两个属性:值 \(val\) 和删除时间 \(del\)(未删除的设为 \(m+1\))。
- 对于元素 \(i\),删除它时损失的逆序对数 = 在它之前删除且值比它大的元素数 + 在它之后删除且值比它小的元素数。
- 即统计满足 \(del_j < del_i\) 且 \(val_j > val_i\) 的点对——这又是一个三维偏序问题!
三维为:时间维(删除顺序 \(del\))、值维 \(val\)、贡献方向。
用 CDQ 分治处理时间维,归并处理 \(val\) 维,树状数组统计贡献。CDQ 分治的"动态转静态"能力,使得许多在线问题可以离线高效解决。
三、点分治
3.1 树的重心
点分治的核心在于选择一个合适的"分治中心"——树的重心。
定义:对于树中的某个节点 \(u\),删除 \(u\) 后树分裂为若干子树。记其中最大子树的大小为 \(max\_subtree(u)\)。使 \(max\_subtree(u)\) 最小的节点 \(u\) 称为树的重心(centroid)。
重要性质
-
子树大小不超过一半:以重心为根时,所有子树的大小不超过 \(n/2\)。这是保证分治深度为 \(O(\log n)\) 的关键。
-
重心的唯一性:一棵树至少有 1 个重心,至多有 2 个重心。若有两个重心,则它们相邻,且树有偶数个节点。
-
距离和最小:树中所有点到某个点的距离和,以重心处最小。
求重心的方法
两次 DFS :
int siz[N], maxS[N], root, maxPart;
// 第一次 DFS:计算子树大小
void dfs1(int u, int f) {
siz[u] = 1;
for (int i = head[u]; i; i = nxt[i]) {
int v = to[i];
if (v == f || vis[v]) continue;
dfs1(v, u);
siz[u] += siz[v];
}
}
// 第二次 DFS:计算最大子树大小并找重心
void dfs2(int u, int f, int total) {
maxS[u] = total - siz[u]; // 向上的子树
for (int i = head[u]; i; i = nxt[i]) {
int v = to[i];
if (v == f || vis[v]) continue;
maxS[u] = max(maxS[u], siz[v]);
dfs2(v, u, total);
}
if (maxS[u] < maxPart) {
maxPart = maxS[u];
root = u;
}
}
// 使用示例(以 1 为根,整棵树大小 n)
maxPart = INF;
dfs1(1, 0);
dfs2(1, 0, n);
// root 即为重心
3.2 基本思想与算法流程
点分治适合处理大规模的树上路径信息问题。
核心思想
树上任意一条路径,必然经过某个"中心节点"。我们将所有路径分为两类:
- 经过当前根节点的路径。
- 不经过当前根节点的路径(完全在某个子树内部)。
对于第一类路径,可以在当前层直接统计;对于第二类路径,递归到子树中处理。
算法流程
divide(root):
1. 找到当前连通块的重心 root
2. 统计所有经过 root 的路径的贡献
3. 标记 root 为已处理(vis[root] = true)
4. 对 root 的每个子节点所在连通块,递归执行 divide
路径分类细化
对于经过根节点 \(rt\) 的路径,进一步细分:
- 以 \(rt\) 为一个端点的路径:只需统计 \(rt\) 到子树中每个节点的距离信息。
- 两个端点都不为 \(rt\) 的路径:可以由两条"以 \(rt\) 为端点"的路径合并得到(路径 \(u \to v\) = 路径 \(u \to rt\) + 路径 \(rt \to v\))。
因此处理过程为:
- 遍历 \(rt\) 的每个子节点 \(ch\),计算 \(ch\) 子树中所有节点到 \(rt\) 的距离。
- 对于每条新计算的路径,检查能否与已处理的子树中路径拼接成答案。
- 将新路径信息加入总信息中。
注意:清空信息时不能直接
memset,应记录用过的位置然后逐个清空,保证复杂度正确。
3.3 经典应用:树上距离为 k 的路径
给定一棵 \(n\) 个节点的带边权树,\(m\) 次询问,每次给出 \(k\),问树上是否存在距离为 \(k\) 的点对。
分析
- 对于每个询问 \(k\),检查是否存在两点距离恰为 \(k\)。
- 使用点分治:对每个分治层,统计经过当前重心的路径。
- 用桶
tf[d]记录之前处理过的子树中是否存在距离重心为 \(d\) 的节点。 - 对当前子树中的每个节点 \(i\)(到重心距离为 \(dist_i\)),检查 \(tf[k - dist_i]\) 是否为
true。
处理顺序(关键!)
对于重心的每个子树 ch:
1. 先计算 ch 子树中所有节点到重心的距离
2. 检查这些路径能否与"之前处理过的子树"中的路径组合成答案
3. 将这些路径信息加入桶中
为什么要"先查询再加入"?因为这样可以避免同一子树内的两个节点被错误地配对(它们的路径不经过重心,而是经过子树内的某个节点,会在递归处理该子树时被统计到)。
3.4 AC code
#include <bits/stdc++.h>
using namespace std;
const int N = 1e4 + 5;
const int M = 105;
const int Q = 1e7 + 7;
int n, m;
int head[N], ver[N * 2], nxt[N * 2], edg[N * 2], tot;
void add(int u, int v, int w) {
ver[++tot] = v;
nxt[tot] = head[u];
head[u] = tot;
edg[tot] = w;
}
int siz[N], maxS[N], root, maxp;
bool vis[N];
void dfs1(int u, int fa) {
siz[u] = 1;
for (int i = head[u]; i; i = nxt[i]) {
int v = ver[i];
if (v == fa || vis[v]) continue;
dfs1(v, u);
siz[u] += siz[v];
}
}
void dfs2(int u, int fa, int total) {
maxS[u] = total - siz[u];
for (int i = head[u]; i; i = nxt[i]) {
int v = ver[i];
if (v == fa || vis[v]) continue;
maxS[u] = max(maxS[u], siz[v]);
dfs2(v, u, total);
}
if (maxS[u] < maxp) {
maxp = maxS[u];
root = u;
}
}
int dist[N], cnt;
int ask[M];
bool ans[M];
bool bucket[Q];
vector<int> used;
void dis(int u, int fa, int d) {
dist[++ cnt] = d;
for (int i = head[u]; i; i = nxt[i]) {
int v = ver[i];
if (v == fa || vis[v]) continue;
dis(v, u, d + edg[i]);
}
}
void divide(int u) {
dfs1(u, 0);
int total = siz[u];
maxp = INT_MAX;
dfs2(u, 0, total);
u = root;
vis[u] = true;
bucket[0] = true;
used.push_back(0);
for (int i = head[u]; i; i = nxt[i]) {
int v = ver[i];
if (vis[v]) continue;
cnt = 0;
dis(v, u, edg[i]);
for (int j = 1; j <= cnt; j ++) {
for (int q = 0; q < m; q ++) {
if (ans[q]) continue;
if (ask[q] >= dist[j] && bucket[ask[q] - dist[j]]) {
ans[q] = true;
}
}
}
for (int j = 1; j <= cnt; j ++) {
if (dist[j] < Q) {
bucket[dist[j]] = true;
used.push_back(dist[j]);
}
}
}
for (int d : used) {
if (d < Q) bucket[d] = false;
}
used.clear();
for (int i = head[u]; i; i = nxt[i]) {
int v = ver[i];
if (vis[v]) continue;
divide(v);
}
}
int main() {
ios::sync_with_stdio(false);
cin.tie(0);
cin >> n >> m;
for (int i = 1; i < n; i ++) {
int u, v, w; cin >> u >> v >> w;
add(u, v, w);
add(v, u, w);
}
for (int i = 0; i < m; i ++) cin >> ask[i];
divide(1);
for (int i = 0; i < m; i ++) {
if (ans[i]) cout << "AYE\n";
else cout << "NAY\n";
}
return 0;
}
复杂度分析
- 找重心:每层 \(O(n)\)(DFS 遍历整个连通块)。
- 统计路径:每层 \(O(n)\)(遍历所有节点计算距离 + 查询)。
- 递归深度:由于每次选重心,子树大小不超过 \(n/2\),递归深度为 \(O(\log n)\)。
- 总复杂度:\(O(n \log n)\)(对于本题路径存在性查询),或 \(O(n \log^2 n)\)(若需要排序或使用数据结构)。
关键点:选择重心保证了递归深度为 \(O(\log n)\),这是点分治效率的保障。如果随意选根(如总是选 1 号节点),在最坏情况(链状树)下递归深度退化为 \(O(n)\),总复杂度退化为 \(O(n^2)\)。
代码细节要点
-
两次 DFS 找重心:第一次计算
total(当前连通块大小),第二次根据total找重心。注意每次递归到子树时,必须重新计算子树大小作为新的total。 -
清空桶的方式:不能使用
memset(会导致 \(O(\text{桶大小})\) 的清空开销)。正确做法是记录所有修改过的桶位置,然后逐个清零。 -
查询先于加入:处理每个子树时,先查询该子树路径能否与之前子树的路径组合成答案,再将该子树路径加入桶。这保证了统计的路径一定经过重心。
-
vis数组:标记已处理的重心节点,确保递归时不会回到上层。
3.5 扩展:点分治统计距离不超过 k 的点对数
给定带权树,求距离 \(\le k\) 的点对数量。
这与上面的"存在性查询"不同,需要计数。处理方式:
- 对每个子树,收集所有节点到重心的距离。
- 对距离数组排序,用双指针统计满足 \(dist_i + dist_j \le k\) 的对数。
- 减去同一子树内的重复计数(容斥)。
int countPairs(int dist[], int top, int k) {
sort(dist + 1, dist + top + 1);
int cnt = 0, r = top;
for (int l = 1; l < r; ) {
if (dist[l] + dist[r] <= k) {
cnt += r - l;
l++;
} else {
r--;
}
}
return cnt;
}
复杂度:排序 \(O(n \log n)\),每层 \(O(n \log n)\),总计 \(O(n \log^2 n)\)。
四、对比与总结
CDQ 分治 vs 点分治
| 对比项 | CDQ 分治(序列分治) | 点分治(树分治) |
|---|---|---|
| 适用场景 | 序列上的偏序问题、DP 优化、动态转静态 | 树上路径统计 |
| 分治对象 | 序列的下标区间 \([l, r]\) | 树的连通块(以重心为根) |
| 消除的维度 | 时间维(下标顺序 \(i < j\)) | 空间维(路径必经过重心) |
| 分治中心 | 区间中点 \(mid\) | 树的重心 |
| 保证深度的机制 | 区间自然二分 | 重心保证子树 \(\le n/2\) |
| 合并方式 | 排序 + 双指针 + 数据结构 | 桶 / 排序 + 双指针 + 数据结构 |
| 典型复杂度 | \(O(n \log^2 n)\) | \(O(n \log n)\) 或 \(O(n \log^2 n)\) |
共同核心思想
两者的本质都是通过分治的结构天然消除一个维度:
- CDQ 分治:分治中点 \(mid\) 保证了左半下标 \(<\) 右半下标,天然满足 \(i < j\) 的偏序。
- 点分治:选择重心作为根,所有经过重心的路径可以拆分为两条子路径,天然保证路径经过当前层处理的节点。
分治三步法总结
无论是 CDQ 分治还是点分治,都遵循分治的三步范式:
- 分:将问题划分为独立子问题(CDQ 按区间中点分;点分治按重心分子树)。
- 治:递归处理子问题(处理左/右区间;处理各子树)。
- 合:合并子问题解(处理跨区间点对;处理经过重心的路径)。
学习建议
- 先掌握基础:归并排序、树状数组、树的 DFS 是前置知识,务必扎实。
- 理解"消除维度":这是 CDQ 分治和点分治最核心的直觉——分治结构本身就是一个维度被处理掉了。
- 注意清空方式:无论是 CDQ 的树状数组还是点分治的桶,都不能用
memset暴力清空,要记录修改过的位置逐个还原。 - 注意处理顺序:CDQ 中"先递归再合并",点分治中"先查询再加入桶",顺序的正确性是算法正确的基础。
- 多写模板题:三维偏序(P3810)和点分治模板(P3806)是入门必刷题,务必独立 AC。
原创·转载请注明出处
✦
浙公网安备 33010602011771号