【学习笔记】cdq分治
一. cdq分治的定义
cdq 分治是一种思想,由陈丹琦引入。
-
cdq 分治解决和点对有关的问题
-
cdq 分治优化 1D/1D 动态规划的转移
-
通过 cdq 分治,将一些动态问题转化为静态问题
仅支持离线操作。
一般来说,cdq 分治是通过如下结构进行分治:
分治一共分为四步:
-
找到当前区间 \([l,r]\) 中点 \(mid\)。
-
递归处理左子区间 \([l,mid]\)。
-
递归处理右子区间 \([mid+1,r]\)。
-
处理左区间对于右区间的影响,并对于右区间或者答案进行更改与修正。
二. 从二维偏序到三维偏序
求逆序对。可以当成二维偏序。
\(n\le 5\times 10^5\)。
二位偏序就是类似于逆序对对吧。
这个显然可以用树状数组做,我们考虑怎么用 cdq 分治做。
类似于归并排序的思想,考虑一直将序列二分下去,然后统计 \([l,mid]\) 区间对于 \([mid+1,r]\) 区间带来的贡献。
考虑双指针 \(i,j\) 分别表示当前走到的左区间的位置和右区间的位置。
因为我们是通过递归,所以显然 \([l,mid],[mid+1,r]\) 都已经是有序的了。
那么通过双指针的移动来确定贡献。具体操作如下:
- 若 \(a_i\le a_j\),则不是逆序对,不产生贡献。考虑将 \(a_i\) 加入一个序列 \(b\) 中。
- 若 \(a_i>a_j\),则产生逆序对,发生贡献,注意到 \([l,mid],[mid+1,r]\) 是单调的,所以如果 \(a_i>a_j\),那么对于 \(x\in [l,mid]\),必然有 \(a_x>a_j\),所以 \(ans\) 加上 \(mid-i+1\),考虑将 \(a_j\) 加入 \(b\)。
最后将 \(b\) 拷贝到 \(a\)。这样的操作必然能保证 \(b\) 是有序的。
如果还是不懂,参见下面的例子:
\([l,mid]=1,3,4,7;[mid+1,r]=2,3,5,6\)
初始时 \(i\) 指向 \(a_1=1(l)\);\(j\) 指向 \(a_5=2(mid+1)\)。
因为 \(1<2\),不产生贡献,所以 \(b_1=1\),\(i\) 指向 \(a_2=3\);
\(3>2\),所以产生贡献,\(b_2=2\),\(j\) 指向 \(a_6=3\);
\(3=3\),不产生贡献,\(b_3=3\),\(i\) 指向 \(a_3=4\);
\(4>3\),产生贡献,\(b_4=3\),\(j\) 指向 \(a_7=5\);
\(4<5\),不产生贡献,\(b_5=4\),\(i\) 指向 \(a_4=7\);
如此类推,最终易知 \(b\) 序列有序。
#include<bits/stdc++.h>
using namespace std;
#define int long long
const int N=5e5+5;
int n,a[N],b[N],ans;
inline void cdq(int l,int r){
if(l==r)return;
int mid=(l+r)>>1;
cdq(l,mid);//递归左区间
cdq(mid+1,r);//递归右区间
int i=l,j=mid+1,now=l;//i,j表示左右指针,now表示b数组的指针
while(i<=mid&&j<=r){
if(a[i]<=a[j])b[now++]=a[i++];//不产生贡献
else{//产生贡献
b[now++]=a[j++];
ans+=mid-i+1;//计算贡献
}
}
while(i<=mid)b[now++]=a[i++];
while(j<=r)b[now++]=a[j++];
for(int i=l;i<=r;i++)a[i]=b[i];
return;
}
signed main(){
scanf("%lld",&n);
for(int i=1;i<=n;i++)scanf("%lld",&a[i]);
cdq(1,n);
printf("%lld",ans);
return 0;
}
通过一道例题来引入 cdq 分治的具体实现。
有 $ n $ 个元素,第 $ i $ 个元素有 $ a_i,b_i,c_i $ 三个属性,设 $ f(i) $ 表示满足 $ a_j \leq a_i $ 且 $ b_j \leq b_i $ 且 $ c_j \leq c_i $ 且 $ j \ne i $ 的 \(j\) 的数量。
对于 $ d \in [0, n) $,求 $ f(i) = d $ 的数量。
考虑将第一维 \(a_i\) 通过 sort 解决。\(O(n\log n)\)。
那么还剩下 \(b,c\) 两维。
对于当前区间 \([l,r]\),我们通过递归算出了 \([l,mid]\) 和 \([mid+1,r]\) 的答案,考虑求 \([l,r]\)。
那么本质上就是算出有多少对 \((i,j)\) 满足 \(l\le i\le mid,mid+1\le j\le r\)。
接下来是两种思路。
- 用 sort 解决第二维 \(b\)。
考虑对于 \([l,mid],[mid+1,r]\) 以 \(b\) 为第二关键字再 sort 一遍。
那么第二维 \(b\) 也解决了。
考虑依次枚举 \(j\),对于每个 \(b_i<b_j\) 的点插入树状数组。
那么查询这个数据结构里有多少个点的 \(c\) 值是小于 \(c_j\) 的,我们就对于这个点 \(j\) 求出了有多少个 \(i\) 可以匹配了。
具体操作就是每插入一个 \(c_i=x\) 时,将 \(x\) 位上 \(+1\),那么查询有多少点 \(\le c_j\) 相当于是查询一个前缀和。
注意我们已经 sort 好了 \(b\) 这一维,所以在枚举 \(i,j\) 时考虑线性插入 \(O(n)\) 即可。
总时间复杂度 \(O(n\log^2 n)\)。
#include<bits/stdc++.h>
using namespace std;
const int N=2e5+6;
int n,m,k,tr[N],ans[N];
struct node{
int x,y,z,cnt,p;
}a[N],f[N];
inline bool cmp1(node a,node b){
if(a.x!=b.x)return a.x<b.x;
if(a.y!=b.y)return a.y<b.y;
return a.z<b.z;
}
inline bool cmp2(node a,node b){
if(a.y!=b.y)return a.y<b.y;
return a.z<b.z;
}
//树状数组模板
inline int lowbit(int x){
return x&(-x);
}
inline void update(int x,int val){
while(x<=k){
tr[x]+=val;
x+=lowbit(x);
}
}
inline int query(int x){
int res=0;
while(x){
res+=tr[x];
x-=lowbit(x);
}
return res;
}
inline void cdq(int l,int r){
if(l==r)return;
int mid=(l+r)>>1;
cdq(l,mid);//递归处理 [l,mid]
cdq(mid+1,r);//递归处理 [mid+1,r]
sort(f+l,f+mid+1,cmp2);
sort(f+mid+1,f+r+1,cmp2);
//对第二关键字排序
int now=l;//双指针
for(int i=mid+1;i<=r;i++){
while(f[now].y<=f[i].y&&now<=mid){
update(f[now].z,f[now].cnt);//插入树状数组
now++;
}
f[i].p+=query(f[i].z);
}
for(int i=l;i<now;i++)update(f[i].z,-f[i].cnt);//清空树状数组
return;
}
signed main(){
scanf("%d%d",&n,&k);
for(int i=1;i<=n;i++)scanf("%d%d%d",&a[i].x,&a[i].y,&a[i].z);
sort(a+1,a+n+1,cmp1);//对第一维关键字排序
int now=0;
for(int i=1;i<=n;i++){
now++;
if(a[i].x!=a[i+1].x||a[i].y!=a[i+1].y||a[i].z!=a[i+1].z){
m++;
f[m].x=a[i].x;
f[m].y=a[i].y;
f[m].z=a[i].z;
f[m].cnt=now;
now=0;
}
}
cdq(1,m);
for(int i=1;i<=m;i++)ans[f[i].p+f[i].cnt]+=f[i].cnt;
for(int i=1;i<=n;i++)printf("%d\n",ans[i]);
return 0;
}
- 归并排序思想解决第二维 \(b\)。
这种比上一种常数小很多。
考虑直接在统计 \(c\) 的贡献时解决 \(b\)。
比如当前是 \([l,mid],[mid+1,r]\) 而通过递归,两个区间的第二维 \(b\) 都已经有序。
考虑维护一个数组和指针 \(p=l\),当遇到 \(c_i>c_j\) 时,考虑将 \(p\to i-1\) 加入数组后再在后面加入 \(j\)。然后更新 \(p\) 为 \(i\)。
举个例子:比如上图,假设我们递归到 \([l,r]\)。而通过递归显然 \([l,mid],[mid+1,r]\) 的第二维 \(b\) 是有序的。
考虑如何将两者合并。
首先比较 \(1,2\)。\(1\) 比较小,继续比较。
\(3>2\),则将 \(1\) 加入维护的数组,然后加入 \(2\)。此时指针指向 \(3\)。
然后同理,\(3<4\),继续比较。
\(7>4\),则将 \(3,4\) 加入数组,指针指向 \(7\)。
\(7>6\),继续将 \(6\) 加入数组。指针不变。
\(7<10\),继续比较。
\(15>10\),将 \(7,10\) 加入。
\(15>14\),将 \(14\) 加入。
最后加入剩余的 \(15\)。
对于 \(c\) 的处理同第一种方法。
这样可以做到省略了 sort 部分。但由于树状数组查询和修改还带有一个 \(\log\),所以时间复杂度仍为 \(O(n\log ^2 n)\)。但是显然常数小很多。
#include<bits/stdc++.h>
using namespace std;
const int N=2e5+6;
int n,k,tr[N],sz[N],cnt,f[N],ans[N];
struct node{
int x,y,z,id;
}a[N],b[N];//a为输入,b即上文提到的用来维护的数组
inline bool cmp(node a,node b){//以第一关键字排序
if(a.x!=b.x)return a.x<b.x;
else if(a.y!=b.y)return a.y<b.y;
else return a.z<b.z;
}
//BIT树状数组板子
inline int lowbit(int x){
return x&(-x);
}
inline void update(int x,int val){
while(x<=k){
tr[x]+=val;
x+=lowbit(x);
}
}
inline int query(int x){
int res=0;
while(x){
res+=tr[x];
x-=lowbit(x);
}
return res;
}
inline void cdq(int l,int r){
if(l==r)return;
int mid=(l+r)>>1;
cdq(l,mid);
cdq(mid+1,r);
int i=l,j=mid+1,now=l;//i,j左右指针
while(i<=mid&&j<=r){//归并+统计答案
if(a[i].y<=a[j].y){
update(a[i].z,sz[a[i].id]);
b[now]=a[i];//将a[i]加入
i++;
now++;
}
else{
ans[a[j].id]+=query(a[j].z);
b[now]=a[j];//将a[j]加入
j++;
now++;
}
//注意因为在子区间中使用了归并,所以两个子区间中第二维肯定是升序的
}
while(j<=r){//将剩下的归并排序完
ans[a[j].id]+=query(a[j].z);
b[now]=a[j];
now++;
j++;
}
for(int I=l;I<i;I++)update(a[I].z,-sz[a[I].id]);//清除树状数组
while(i<=mid){//将剩下的归并排序完
b[now]=a[i];
now++;
i++;
}
for(int i=l;i<=r;i++)a[i]=b[i];//更新原数组a
return;
}
int main(){
scanf("%d%d",&n,&k);
for(int i=1;i<=n;i++)scanf("%d%d%d",&a[i].x,&a[i].y,&a[i].z);
sort(a+1,a+n+1,cmp);//对第一关键字排序
for(int i=1;i<=n;i++){//去重
int j=i-1;
if(a[i].x!=a[j].x||a[i].y!=a[j].y||a[i].z!=a[j].z)b[++cnt]=a[i];
sz[cnt]++;
}
for(int i=1;i<=cnt;i++){
a[i]=b[i];
a[i].id=i;
}
cdq(1,cnt);
for(int i=1;i<=cnt;i++){
int x=a[i].id;
f[ans[x]+sz[x]-1]+=sz[x];
}
for(int i=0;i<n;i++)printf("%d\n",f[i]);
return 0;
}
三. 从三维偏序到四维偏序
给定 \(n\) 个四维点,第 \(i\) 个点为 \((x_i,y_i,z_i,t_i)\),每个点都带有一个点权,第 \(i\) 个点的点权为 \(w_i\)。
对于一个合法的路径,满足经过的点的四个坐标全部单调不降;路径的权定义为该路径所经过点的权值和。
求合法路径权的最大值。
\(1≤n≤5\times 10^4\)。
四维偏序模板题。
这个要求维护四个量,而我们所熟知的 cdq 分治是维护三维偏序的。
那么考虑 cdq 套 cdq 来增加一维。
回顾如何解决用 cdq 分治做三维偏序。
本质就是考虑左边区间对右边区间的影响。
四维偏序同理。
比如我们令 \((x,y,z)\) 表示三维;那么解决四维偏序就是 \((x,0,z)\) 对 \((x,1,z)\) 的影响。这里 \(0/1\) 表示是当前这一维所讨论的是左区间还是右区间。因为第一维是用 sort
排序好了的,所以讨论的是第二维。
那么如果四维偏序用 \((x,y,z,w)\) 表示,我们所讨论的就是 \((x,0,0,w)\) 对 \((x,1,1,w)\) 的影响。
然后思路同三维偏序,先排序第一维,然后 cdq1
第二维、cdq2
第三、四维。
其中 cdq1,cdq2
是两个 cdq 分治函数。通过第一个函数 cdq1
中嵌套 cdq2
来解决多的一维。
如果理解以上就很简单了,其余的和普通三维偏序没有什么不同。
注意这题是最长上升序列,要在递归 \([l,mid]\) 后先计算 \([l,mid]\) 对 \([mid+1,r]\) 的贡献,再递归 \([mid+1,r]\)。
时间复杂度 \(O(n\log^3 n)\)。
如果是 \(k\) 维偏序,那么朴素的 cdq 分治就是 \(O(n\log ^k n)\) 的。当然这种复杂度渐进意义下优秀,但当 \(n\) 较小又 \(>3\) 时,显然 cdq 分治就不如暴力了。
当然这时候用分块对吧。时间复杂度 \(O(kn\sqrt n)\)。
//O2
#include<bits/stdc++.h>
using namespace std;
#define ll long long
const int N=5e5+5;
const ll inf=0x7f7f7f7f;
int n,m,lsh[N],pos[N],pos2[N],cnt;
ll ans=-inf;
struct node{
int x,y,z,w,id;
bool flag;
ll p,val;
bool operator ==(const node rhs)const{
return x==rhs.x&&y==rhs.y&&z==rhs.z&&w==rhs.w;
}
}a[N],b[N];
inline bool cmp1(node a,node b){
if(a.x!=b.x) return a.x<b.x;
if(a.y!=b.y) return a.y<b.y;
if(a.z!=b.z) return a.z<b.z;
return a.w<b.w;
}
inline bool cmp2(node a,node b){
if(a.y!=b.y) return a.y<b.y;
if(a.z!=b.z) return a.z<b.z;
if(a.w!=b.w) return a.w<b.w;
return a.x<b.x;
}
inline bool cmp3(node a,node b){
if(a.z!=b.z) return a.z<b.z;
if(a.w!=b.w) return a.w<b.w;
if(a.x!=b.x) return a.x<b.x;
return a.y<b.y;
}
//注意排序要彻底
struct BIT{//树状数组
ll tr[N];
inline int lowbit(int x){
return x&(-x);
}
inline void update(int x,ll val){
while(x<=n){
tr[x]=max(tr[x],val);
x+=lowbit(x);
}
return;
}
inline ll query(int x){
ll res=-inf;
while(x){
res=max(res,tr[x]);
x-=lowbit(x);
}
return res;
}
inline void clear(int x){
while(x&&x<=n){
tr[x]=0;
x+=lowbit(x);
}
return;
}
}tr;
inline void cdq2(int l,int r){//第二层的cdq
if(l==r)return;
int mid=(l+r)>>1;
cdq2(l,mid);
sort(a+l,a+mid+1,cmp3);
sort(a+mid+1,a+r+1,cmp3);
int i=l,j=mid+1;
while(j<=r){
while(i<=mid&&a[i].z<=a[j].z){
if(!a[i].flag)tr.update(a[i].w,a[i].p);
i++;
}
if(a[j].flag)a[j].p=max(a[j].p,tr.query(a[j].w)+a[j].val);
j++;
}
int now=i;
for(int i=l;i<now;i++)if(!a[i].flag)tr.clear(a[i].w);
for(int i=l;i<=r;i++)b[pos2[a[i].id]]=a[i];
for(int i=l;i<=r;i++)a[i]=b[i];
cdq2(mid+1,r);
return;
}
inline void cdq1(int l,int r){//第一层的cdq
if(l==r)return;
int mid=(l+r)>>1;
cdq1(l,mid);
for(int i=l;i<=mid;i++)a[i].flag=false;
for(int i=mid+1;i<=r;i++)a[i].flag=true;
sort(a+l,a+r+1,cmp2);
for(int i=l;i<=r;i++)pos2[a[i].id]=i;
cdq2(l,r);
for(int i=l;i<=r;i++)b[pos[a[i].id]]=a[i];
for(int i=l;i<=r;i++)a[i]=b[i];
cdq1(mid+1,r);
return;
}
int main(){
scanf("%d",&n);
for(int i=1;i<=n;++i){
scanf("%d%d%d%d%lld",&a[i].x,&a[i].y,&a[i].z,&a[i].w,&a[i].val);
lsh[i]=a[i].w;
}
sort(lsh+1,lsh+n+1);
m=unique(lsh+1,lsh+n+1)-lsh-1;//离散化
for(int i=1;i<=n;i++)a[i].w=lower_bound(lsh+1,lsh+m+1,a[i].w)-lsh;
sort(a+1,a+n+1,cmp1);
for(int i=1;i<=n;i++){
if(a[i]==a[i+1])a[cnt].val+=max(0ll,a[i].val);
else a[++cnt]=a[i];
}
for(int i=1;i<=n;i++){
a[i].p=a[i].val;
a[i].id=i;
pos[i]=i;
}
cdq1(1,n);
for(int i=1;i<=n;i++)ans=max(ans,a[i].p);
printf("%lld\n",ans);
return 0;
}
四. cdq 分治优化1D/1D动态规划
1D 就是一维 dp,转移 \(O(n)\) 的一类dp。
有时可以通过 cdq 分治优化。
但是这里 cdq 分治的顺序应该改变:
-
递归 \([l,mid]\)
-
处理左区间对于右区间的影响,并对于右区间或者答案进行更改与修正。
-
递归 \([mid+1,r]\)
考虑第二步的做法,和普通的 cdq 分治一样的做法:双指针和树状数组查询前缀和一类的操作。
与普通的 cdq 分治操作顺序不同的是,这种情况要先处理左区间对右区间的影响再递归 \([mid+1,r]\)。
考虑为什么要这样。
普通 cdq 的写法是,当递归到 \([l,mid],[mid+1,r]\) 时,会计算它内部的相互贡献,然后通过 \([l,mid]\) 计算对 \([mid+1,r]\) 的贡献。
但是有可能出现计算左边部分对右边部分的贡献的结果会影响到右边部分内部的情况,所以不能先计算左右区间内部贡献。
因为这种情况需要保证当递归 \([l,r]\) 时,\(dp_l\sim dp_r\) 的值必须全部计算好。所以在递归 \([mid+1,r]\) 时要保证左边区间不再对右边做出贡献。
五. cdq 将动态问题转化为静态问题
六. 习题
二维偏序:
-
\(\text{P2717}\) 寒假作业 简单二维偏序
-
\(\text{SHOI2007}\) 园丁的烦恼 前缀和+简单二维偏序
-
\(\text{USACO04OPENMooFest G}\) 较难的二维偏序(指用cdq的做法,显然树状数组更简单)
常规三维偏序:
-
\(\text{CF1045G AI robots}\) 简单三维偏序
-
\(\text{CQOI2011}\) 动态逆序对 稍加转换就是模板三维偏序