引言:
什么是CDQ分治?其实这是一种思想而不是具体算法,因此CDQ分治覆盖的范围相当广泛,在 OI 界初见于陈丹琦 2008 年的集训队作业中,故被称为CDQ分治。
大致分为三类:
- cdq分治解决与点对有关的问题
- cdq分治优化1D/1D 动态规划的转移
- 通过 cdq 分治,将一些动态问题转化为静态问题
先总体说一下CDQ分治:
通常用来解决一类“修改独立,允许离线”的数据结构题。实际上它的本质是按时间分治,即若要处理时间([l,r])上的修改与询问操作,就先处理([l,mid])上的修改对([mid + 1,r])上的询问的影响,之后再递归处理([l,mid])与([mid + 1,r]),根据问题的不同,这几个步骤的顺序有时也会不一样。
CDQ 分治会使得我们考虑的问题的思维难度与代码难度大大减小,通常利用 CDQ 分治能使得一个树套树实现的题目,能够去掉外层的树,改为用分治来进行求解。
算法描述
CDQ分治适用于满足一下两个条件的数据结构题:
- 修改操作对询问的贡献独立,修改操作之间互不影响效果
- 题目允许使用离线算法
我们不妨假设我们需要(按顺序)完成的操作序列称为S,考虑将整个操作序列等分为前后两个部分,那么我们可以发下以下两个性质:
- 显然,后一半操作序列中的修改操作对前一半操作序列中的询问结果不会产生任何影响。
- 后一半操作序列中的询问操作只受两方面影响:一是前一半操作序列中的所有修改操作;二是后一半操作序列中,在该询问操作之前的修改操作。
容易发现,因为后一半操作序列的修改操作完全不会影响前一半操作序列中的询问结果,因此前一半操作序列的查询实际是与后一半操作序列完全独立的,是与原问题完全相同的子问题,可以递归处理。
接下来我们来考虑后一半操作序列中的询问操作。我们发现,影响后一半操作序列询问的答案的因素中,第二部分“后一半操作序列中,在该询问操作之前的修改操作”也是与前一半序列完全无关的(因为我们前面已经假定题目中的修改操作互相独立互不影响,而询问操作更不会影响修改操作了)因此,这部分因素也是与原问题完全相同的完全独立的子问题,可以递归处理。
我们还可以发现,影响后一半操作序列询问答案案的因素的第一部分“前一半操作序列中的所有修改操作”虽然与前一半序列密切相关,但它有一个非常好的性质,就是现在后一半操作序列的询问都是在前一半操作序列的所有修改完成之后执行的,那么对于影响后半部分询问的前半部分修改,他们对于任意一个后半部分询问的影响都是一模一样的。这时,原问题的动态修改操作便不再存在了,而被转化为了离线的、与原问题同样规模的“一开始给出所有修改”然后“回答若干询问”的更简单的问题,从而简化算法。
不妨设“解决无动态修改操作的原问题”的复杂度为(O(f(n))),那么由主定理,我们知道这样分治的时间复杂度是(O(f(n)logn))
因此,只要数据结构题满足我们上文假定的两个要求:修改独立,允许离线,就可以以一个(log)的代价,将原问题中的动态修改去掉,变为没有动态修改的简化版问题,极大简化我们的思维与代码难度。
这种对时间分治的方法要求操作之间是互相独立的,因此如果有形如“撤销某次操作”这样的操作,就不能直接按上面的方法来解决问题,因为现在的插入操作可能会被后面的删除操作撤销掉,修改操作并不是完全独立的。
我们依然把操作序列等分为前后凉拌。我们考虑前一半操作中的询问操作,它们的答案显然与后半部分的插入与删除操作无关,因此他们仍然可以直接递归。
考虑后一半操作中的询问操作,它们的答案只与两部分内容有关:
- 前一半操作序列中的所有插入操作中在该询问之前未被删除的部分。
- 后一半操作序列中,在该询问操作之前的且未被删除的插入操作。
我们发现,因为后一半序列中的插入操作显然不会在前一半序列中被删除,因此“后一半操作序列中,在该询问操作之前的且未被删除的插入操作“这一部分与前一半操作序列完全无关,也就是与原问题相同的子问题,可以递归求解。
因此现在我们实际要处理的内容是:
“前一半操作序列中的所有插入操作中未被删除的部分”对“后一半操作序列的询问操作”的贡献。
因此,我们的实际任务是初始时给定一些插入操作,然后现在有个操作序列,每个操作会是删除一个插入操作,或是一个询问操作。
这个问题直接解决还是非常困难,但我们发现现在问题中只有删除没有插入,于是一个经典的解决办法就可以派上用场了,那就是“时光倒流”。
因为我们使用的是离线算法,我们完全可以预知转化后的问题中“初始的插入操作”,“改定的删除操作与询问操作”等所有需要的信息,那么我们能实现求出到最后都没有被删除掉的那些初始的插入操作,接着我们逆序处理操作序列,这样询问操作没有变,而删除操作变为了插入操作。利用时光倒流,我们再次把文义转化为了与之前相同的问题。
现在,只要插入操作贡献独立,插入操作之间互不影响,且题目允许离线,即使有身处或者变更操作(变更操作实际等价于西安深处源操作,再插入新操作),我们也可以利用CDQ分治与时光倒流,以2个log的时间复杂度为代价,把题目假话为没有动态插入,没有动态身处,没有动态变更的完全静态版问题。
按照开篇说的三类问题来具体分析的话
CDQ分治解决与点对有关的问题
这类问题一般是给你一个长度为(n)的序列,然后当你统计有一些特性的点对((i,j))有多少个,又或者说是找到一对点((i,j))使得一些函数的最大之类的问题
那么CDQ分治机遇这样一个算法流程解决这类问题:
-
找到这个序列的重点(mid)
-
讲所有点对((i,j))划分为3类
- 第一种是$ 1 leq i leq mid,1 leq j leq mid$的点对
- 第二种是(1 leq i leq mid,mid +1 leq j leq n)的点对
- 第三种是(mid +1 leq i leq n,mid + 1 leq j leq n)
-
讲((1,n))这个序列祭城两个序列((1,mid))和((mid + 1,n))会发现第一类点对和第三类点对都在这两个序列之中,递归的去解决这两类点对
-
想方设法处理一下第二类点对的信息
实际应用时候我们通常都是写一个函数(solve(l,r))表示我们在处理(l leq i leq r,l leq j leq r)的点对
所以刚才的算法流程中的递归部分我们就是通过(solve(l,mid),solve(mid,r))来实现的
所以说cdq分治知识一种十分模糊的思想,可以看大这种思想就是不断的把点对通多递归的方式分给左右两个区间
至于我们设计出来的算法真正干活的部分就是第四部分需要我们想方设法解决的部分
所以我们上几道例题看一下第四部分一般该怎么写
比如说我们来一个cdq分治的经典问题——三维偏序
三维偏序
给定一个序列,每个点有两个属性((a,b)),试求:这个序列里有多少对点对((i,j))满足(i < j,a_i < a_j,b_i<b_j)
统计序列里点对的个数?我们套一个cdq
假设我们现在正在(solve(l,r))并且通过某些奥妙重重的手段搞定了(solve(l,mid))和(solve(mid + 1,r))
那么我么现在就是统计满足(l leq i leq mid,mid +1 leq j leq r)的点对((i,j))中,有多少个点对还满足(i < j,a_i < a_j,b_i < b_j)的限制条件
然后可以发现(i < j)的限制条件没有用,既然(i)比(mid)小,(j)比(mid)大,那(i)一定比(j)小_
还可以发现还剩下两个限制条件(a_i<a_j,b_i<b_j),根据这个限制条件我们就可以枚举(j),求出有多少个满足条件的(i)
为了方便枚举,我们把((l,mid))和((mid + 1,r))中的点全部按照(a)值从小到大排个序,之后我们依次枚举每一个(j),把所有(a_i<a_j)的点(i)全部插入一个数据结构里。此时我们只要对这个数据结构进行询问:里面有多少个点的(b)值是小雨(b_j)的,我们就对于这个点(j)求出了有多少个(i)可以和他合法的匹配了
这个数据结构用树状数组就可了
当我们插入一个(b)值等于(x)的点时,我们就令树状数组(x)这个位置单点+1,而查询数据结构里有多少个点小于(x)的操作实际上就是在求前缀和,只要我们实现对于所有的(b)值做了离散化我们的复杂度就是对的
文义又来了,钓鱼台有每一个(j)我们都需要将所有(a_i < a_j)的点插入树状数组中,这样的话我们总共要对树状数组做(O(n^2))次操作。
由于我们把所有的(i)和(j)都事先按照(a)值排好序了,我们以双指针的方式在树状数组里插入点,这样的话我们就只需要做(O(n))次插入操作就行了
所以通过这样一个算法流程我们就用(O(nlogn))的时间处理完了关于第2类点对的信息了
这样的话算法复杂度就是
例题[CQOI2011]动态逆序对
和三维偏序差不多,差不多是一个板子题
#define B cout << "BreakPoint" << endl;
#define O(x) cout << #x << " " << x << endl;
#define O_(x) cout << #x << " " << x << " ";
#define Msz(x) cout << "Sizeof " << #x << " " << sizeof(x)/1024/1024 << " MB" << endl;
#include<cstdio>
#include<cmath>
#include<iostream>
#include<cstring>
#include<algorithm>
#include<queue>
#include<stack>
#define LL long long
#define inf 1000000009
const int N = 1e5 + 3,M = 2e5 + 3;
using namespace std;
inline int read() {
int s = 0,w = 1;
char ch = getchar();
while(ch < '0' || ch > '9') {
if(ch == '-')
w = -1;
ch = getchar();
}
while(ch >= '0' && ch <= '9') {
s = s * 10 + ch - '0';
ch = getchar();
}
return s * w;
}
int n,m,c[N],p;
struct treearray {
int ta[N];
void ub(int& x) { x += x & (-x); }
void db(int& x) { x -= x & (-x); }
void c(int x, int t) {
for (; x <= n + 1; ub(x)) ta[x] += t;
}
inline int sum(int x) {
int r = 0;
for (; x > 0; db(x)) r += ta[x];
return r;
}
} ta;
struct data {
int val,del,ans;
} a[N];
LL res;
bool cmp1(const data& a, const data& b) { return a.val < b.val; }
bool cmp2(const data& a, const data& b) { return a.del < b.del; }
void solve(int l, int r) {
if (r - l == 1) return;
int mid = (l + r) / 2;
solve(l, mid), solve(mid, r);
int i = l + 1,j = mid + 1;
while(i <= mid) {
while (a[i].val > a[j].val && j <= r) ta.c(a[j].del, 1),j++;
a[i].ans += ta.sum(m + 1) - ta.sum(a[i].del);
i++;
}
i = l + 1,j = mid + 1;
while(i <= mid) {
while(a[i].val > a[j].val && j <= r) ta.c(a[j].del, -1),j++;
i++;
}
i = mid,j = r;
while(j > mid) {
while(a[j].val < a[i].val && i > l) ta.c(a[i].del,1),i--;
a[j].ans += ta.sum(m + 1) - ta.sum(a[j].del);
j--;
}
i = mid,j = r;
while(j > mid) {
while(a[j].val < a[i].val && i > l) ta.c(a[i].del,-1),i--;
j--;
}
sort(a + l + 1, a + r + 1, cmp1);
return;
}
int main() {
n = read(),m = read();
for(int i = 1; i <= n; i++) a[i].val = read(),c[a[i].val] = i;
for(int i = 1; i <= m; i++) p = read(),a[c[p]].del = i;
for(int i = 1; i <= n; i++) if(a[i].del == 0) a[i].del = m + 1;
for(int i = 1; i <= n; i++) res += ta.sum(n + 1) - ta.sum(a[i].val),ta.c(a[i].val, 1);
for(int i = 1; i <= n; i++) ta.c(a[i].val, -1);
solve(0, n);
sort(a + 1, a + n + 1, cmp2);
for(int i = 1; i <= m; i++) printf("%lld
", res),res -= a[i].ans;
return 0;
}