【8*】CDQ分治学习笔记

前言

一直感觉 CDQ 分治是一个很高级的算法,但其实 CDQ 分治的思想早就接触过了。其实,也不是很困难嘛。

此类知识点大纲中并未涉及,所以【8】是我自己的估计,后带星号表示估计,仅供参考。

CDQ 分治

我没有找到 CDQ 分治的严格定义,所以一下只是我的理解,并不一定非常准确。

先从一般分治算法入手。分治的思想是把问题划分为若干个独立的子问题,通过递归来求解每个子问题。

但如果子问题之间相互有贡献,那我们还需要处理子问题之间的贡献,这种分治就叫做 CDQ 分治。

CDQ 分治最常见的情况就是将问题划分为两个子问题,然后先递归求解第一个问题,再处理第一个问题对第二个问题的贡献,最后再递归第二个问题。在序列上,我们一般通过二分取中间点的方式来划分子问题。不计其余复杂度,假设每个子问题时间复杂度为 \(O(\text{区间长度})\),总时间复杂度为 \(O(n\log n)\)

CDQ 分治有几个经典应用。一是处理三维偏序问题(其实也可以扩展到更高维),二是将数据结构离线下来通过 CDQ 处理贡献(其实就是转化为三维偏序问题),三是利用 CDQ 分治来优化 DP(其实依旧是三位偏序问题)。分别对应三道例题。

例题

例题 \(1\)

P3810 【模板】三维偏序(陌上花开)

我们使用使用 CDQ 分治,每次将区间折半,递归子问题,之后考虑左子区间对右子区间的贡献。

对初始序列按 \(a_i\) 排序。这样,左子区间的所有数的 \(a_i\) 一定比右子区间的所有数的 \(a_i\) 要小,消掉一维。我们把左、右子区间的所有数分别按照 \(b_i\) 排序,这样,我们就可以遍历右子区间的所有元素,通过双指针找出左子区间中 \(b_i\) 小于当前右子区间的元素 \(b_i\) 的元素,又消掉了一维。最后,我们通过树状数组以 \(c_i\) 为下标维护满足 \(b_i\) 范围内 \(c_i\) 小于等于某个值的元素的数量,就可以通过右子区间的元素的 \(c_i\) 统计题目要求的 \(f(i)\) 了。

注意如果有重复元素的话,右边元素可能会对左边元素有贡献,这是 CDQ 无法处理的,所以我们还需要去重。

时间复杂度 \(O(n\log^2n)\)

#include <bits/stdc++.h>
using namespace std;
struct node
{
	int a,b,c,v,ans;
}a[300000];
int n,m,d,c[300000],ans[300000],sum=0;
bool cmp1(struct node a,struct node b)
{
	return (a.a==b.a)?((a.b==b.b)?(a.c<b.c):(a.b<b.b)):(a.a<b.a);
}

bool cmp2(struct node a,struct node b)
{
	return (a.b==b.b)?(a.c<b.c):(a.b<b.b);
}

int lowbit(int x)
{
	return x&(-x);
}

void add(int x,int k)
{
	while(x<=d)c[x]+=k,x+=lowbit(x);
}

int getsum(int x)
{
	int ans=0;
	while(x>0)ans+=c[x],x-=lowbit(x);
	return ans;
}

void cdq(int l,int r)
{
	if(l==r)return;
	int mid=(l+r)/2,i=l,j=mid+1;
	cdq(l,mid);
	cdq(mid+1,r);
	sort(a+l,a+mid+1,cmp2);
	sort(a+mid+1,a+r+1,cmp2);
	for(int j=mid+1;j<=r;j++)
	    {
	    	while(a[i].b<=a[j].b&&i<=mid)add(a[i].c,a[i].v),i++;
	    	a[j].ans+=getsum(a[j].c);
		}
	for(int j=l;j<i;j++)add(a[j].c,-a[j].v);
}

int main()
{
	scanf("%d%d",&n,&d);
	for(int i=1;i<=n;i++)
	    scanf("%d%d%d",&a[i].a,&a[i].b,&a[i].c);
	sort(a+1,a+n+1,cmp1);
	for(int i=1;i<=n;i++)
	    {
	    	sum++;
	    	if(a[i].a!=a[i+1].a||a[i].b!=a[i+1].b||a[i].c!=a[i+1].c)
	    	   {
	    	   	a[++m].a=a[i].a;
	    	   	a[m].b=a[i].b;
	    	   	a[m].c=a[i].c;
	    	   	a[m].v=sum;
	    	   	sum=0;
			   }
		}
	cdq(1,m);
	for(int i=1;i<=m;i++)ans[a[i].ans+a[i].v-1]+=a[i].v;
	for(int i=0;i<n;i++)printf("%d\n",ans[i]);
	return 0;
}

例题 \(2\)

P4169 [Violet] 天使玩偶/SJY摆棋子

首先可以离线,这是我们可以把时间作为一个维度在操作间形成偏序关系,从而转化为三位偏序问题。这也是离线处理这种带修改的问题的经典方式之一。

对于这种带绝对值的问题,我们考虑按绝对值的正负性分类讨论。例如,本题中根据 \(x,y\) 两维的绝对值分类讨论,可以把原平面分成询问点左上,右上,右下,左下四个部分。四个部分做法大同小异,以左上为例。

考虑什么样的插入 \(j\) 会对一个询问 \(i\) 有贡献。首先操作时间必须满足 \(t_j\le t_i\),且因为是左上所以 \(x_j\ge x_i,y_j\ge y_i\)。发现这个偏序关系有三维,考虑 CDQ 分治。

然后考虑贡献是什么。由于位置确定,直接拆掉绝对值有 \(x_j+y_j-x_i-y_i\)。因此,我们想让距离最小,就让 \(x_j+y_j\) 最小,之后减掉 \(x_i+y_i\)

这样思路就清晰了。操作已经按照时间 \(t_i\) 排好了序,那么我们直接分治。左右分别按 \(x_i\) 排序,双指针求出来 \(j\) 的区间。用单点 \(\text{chkmax}\) 前缀 \(\max\) 维护 \(y_i\) 的偏序关系,插入 \(j\) 时在 \(y_j\)\(\text{chkmax}\;x_j+y_j\)

记得清空。时间复杂度 \(O(n\log^2n)\)

#include <bits/stdc++.h>
using namespace std;
struct ask
{
	int t,x,y,p;
}q[800000],qp[800000];
int n,m,c[1200000],ans[800000];
bool cmp1(struct ask a,struct ask b)
{
	return a.x>b.x;
}

bool cmp2(struct ask a,struct ask b)
{
	return a.x<b.x;
}

int lowbit(int x)
{
	return x&(-x);
}

void clear(int x)
{
    x++;
	while(x<=1e6+1)c[x]=1e8,x+=lowbit(x);
}

void add(int x,int k)
{
    x++;
	while(x<=1e6+1)c[x]=min(c[x],k),x+=lowbit(x);
}

int query(int x)
{
    x++;
	int ans=1e8;
	while(x>0)ans=min(ans,c[x]),x-=lowbit(x);
	return ans;
}

void cdq1(int l,int r)
{
    if(l==r)return;
	int mid=(l+r)>>1;
	cdq1(l,mid),cdq1(mid+1,r);
	sort(q+l,q+mid+1,cmp1),sort(q+mid+1,q+r+1,cmp1);
	int j=l;
	for(int i=mid+1;i<=r;i++)
	    {
	    	while(q[j].x>=q[i].x&&j<=mid)
	    	   {
	    	   if(q[j].t==1)add(1e6-q[j].y+1,q[j].x+q[j].y);
			   j++;
		       }
		    if(q[i].t==2)ans[q[i].p]=min(ans[q[i].p],query(1e6-q[i].y+1)-q[i].x-q[i].y);
		}
	for(int i=l;i<=mid;i++)
	    if(q[i].t==1)clear(1e6-q[i].y+1);
}

void cdq2(int l,int r)
{
	if(l==r)return;
	int mid=(l+r)>>1;
	cdq2(l,mid),cdq2(mid+1,r);
	sort(q+l,q+mid+1,cmp2),sort(q+mid+1,q+r+1,cmp2);
	int j=l;
	for(int i=mid+1;i<=r;i++)
	    {
	    	while(q[j].x<=q[i].x&&j<=mid)
	    	   {
	    	   if(q[j].t==1)add(1e6-q[j].y+1,q[j].y-q[j].x);
			   j++;
		       }
		    if(q[i].t==2)ans[q[i].p]=min(ans[q[i].p],query(1e6-q[i].y+1)+q[i].x-q[i].y);
		}
	for(int i=l;i<=mid;i++)
	    if(q[i].t==1)clear(1e6-q[i].y+1);
}

void cdq3(int l,int r)
{
	if(l==r)return;
	int mid=(l+r)>>1;
	cdq3(l,mid),cdq3(mid+1,r);
	sort(q+l,q+mid+1,cmp2),sort(q+mid+1,q+r+1,cmp2);
	int j=l;
	for(int i=mid+1;i<=r;i++)
	    {
	    	while(q[j].x<=q[i].x&&j<=mid)
	    	   {
	    	   if(q[j].t==1)add(q[j].y,-q[j].x-q[j].y);
			   j++;
		       }
		    if(q[i].t==2)ans[q[i].p]=min(ans[q[i].p],query(q[i].y)+q[i].x+q[i].y);
		}
	for(int i=l;i<=mid;i++)
	    if(q[i].t==1)clear(q[i].y);
}

void cdq4(int l,int r)
{
	if(l==r)return;
	int mid=(l+r)>>1;
	cdq4(l,mid),cdq4(mid+1,r);
	sort(q+l,q+mid+1,cmp1),sort(q+mid+1,q+r+1,cmp1);
	int j=l;
	for(int i=mid+1;i<=r;i++)
	    {
	    	while(q[j].x>=q[i].x&&j<=mid)
	    	   {
	    	   if(q[j].t==1)add(q[j].y,q[j].x-q[j].y);
			   j++;
		       }
		    if(q[i].t==2)ans[q[i].p]=min(ans[q[i].p],query(q[i].y)-q[i].x+q[i].y);
		}
	for(int i=l;i<=mid;i++)
	    if(q[i].t==1)clear(q[i].y);
}

int main()
{
	scanf("%d%d",&n,&m);
	for(int i=1;i<=n;i++)scanf("%d%d",&q[i].x,&q[i].y),q[i].t=1;
	for(int i=n+1;i<=n+m;i++)
	    {
	    	scanf("%d",&q[i].t);
	    	if(q[i].t==1)scanf("%d%d",&q[i].x,&q[i].y);
	    	else scanf("%d%d",&q[i].x,&q[i].y),q[i].p=i;
		}
	for(int i=1;i<=n+m;i++)qp[i]=q[i],ans[i]=1e8;
	for(int i=1;i<=1e6+1;i++)c[i]=1e8;
	cdq1(1,n+m);
	for(int i=1;i<=n+m;i++)q[i]=qp[i];
	cdq2(1,n+m);
	for(int i=1;i<=n+m;i++)q[i]=qp[i];
	cdq3(1,n+m);
	for(int i=1;i<=n+m;i++)q[i]=qp[i];
	cdq4(1,n+m);
	for(int i=n+1;i<=n+m;i++)
	    if(qp[i].t==2)printf("%d\n",ans[i]);
	return 0;
}

例题 \(3\)

P4093 [HEOI2016/TJOI2016] 序列

本题是 CDQ 优化 DP 的经典应用。

我们先写出朴素 DP。设 \(f[i]\) 表示以 \(i\) 结尾最长满足条件的序列的长度,\(v[i]\) 为位置 \(i\) 初始的值,\(mx[i]\) 为位置 \(i\) 的最大取值,\(mi[i]\) 为位置 \(i\) 的最小取值。不难推出如下转移方程。

\[f[i]=\max\{f[j]\}+1(j\lt i,mx[j]\le v[i],v[j]\le mi[i]) \]

这是一个三位偏序问题,我们考虑在维护 CDQ 分治的同时通过处理贡献转移 DP。具体的,我们按照位置排序,分成左右两个子区间,递归求解。由于左边对右边有影响,所以先递归左边,再处理左边对右边的贡献,最后递归右边。

把左边按照 \(v\) 升序排序,右边按照 \(mi\) 升序排序,这样在枚举右边的位置时左边的可选的位置可以通过双指针求出。然后用树状数组维护以 \(mx\) 为下标,\(f\) 为值的单点 \(\text{chkmax}\) 前缀 \(\max\),对于右边的 \(i\) 查询小于等于 \(v[i]\) 的前缀 \(\max\) 转移 DP。

时间复杂度 \(O(n\log^2n)\)

#include <bits/stdc++.h>
using namespace std;
struct val
{
	int v,mx,mi,f,p;
}a[200000];
int n,m,x,y,c[200000],ans=0;
int lowbit(int x)
{
	return x&(-x);
}

void clear(int x)
{
	x++;
	while(x<=1e5+1)c[x]=0,x+=lowbit(x); 
}

void add(int x,int k)
{
	x++;
	while(x<=1e5+1)c[x]=max(c[x],k),x+=lowbit(x); 
}

int query(int x)
{
	x++;
	int ans=0;
	while(x>0)ans=max(ans,c[x]),x-=lowbit(x);
	return ans;
}

bool cmp1(struct val a,struct val b)
{
	return a.v<b.v;
}

bool cmp2(struct val a,struct val b)
{
	return a.mi<b.mi;
}

bool cmp3(struct val a,struct val b)
{
	return a.p<b.p;
}

void cdq(int l,int r)
{
	if(l==r)return;
	int mid=(l+r)>>1;
	cdq(l,mid),sort(a+l,a+mid+1,cmp1),sort(a+mid+1,a+r+1,cmp2);
	int j=l;
	for(int i=mid+1;i<=r;i++)
	    {
	    	while(a[j].v<=a[i].mi&&j<=mid)add(a[j].mx,a[j].f),j++;
	    	a[i].f=max(a[i].f,query(a[i].v)+1);
		}
	for(int i=l;i<=mid;i++)clear(a[i].mx);
	sort(a+mid+1,a+r+1,cmp3);
	cdq(mid+1,r);
}

int main()
{
	scanf("%d%d",&n,&m);
	for(int i=1;i<=n;i++)scanf("%d",&a[i].v),a[i].mx=a[i].mi=a[i].v,a[i].p=i;
	for(int i=1;i<=m;i++)scanf("%d%d",&x,&y),a[x].mx=max(a[x].mx,y),a[x].mi=min(a[x].mi,y);
	cdq(0,n);
	for(int i=1;i<=n;i++)ans=max(ans,a[i].f);
	printf("%d\n",ans);
	return 0;
}

后记

CDQ 分治似乎大多都可以被高维数据结构替代,但 CDQ 码量小,常数小,还是很有优势的。

三千 铁衣披霜 万籁绝响 举目是残阳

回首 剑拔弩张 箭已经在弦上

耳畔 江海有声 山河无量 呼万寿无疆

却无人共看 这人间多荒唐

posted @ 2025-07-07 10:03  w9095  阅读(23)  评论(0)    收藏  举报