cdq分治——处理多维偏序问题的利器

Idea

专门用来处理偏序问题的离线算法

与归并排序非常类似。

主要思想将通过例题讲解。


Example

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

这是一道三维偏序的模板题目,体现了三维偏序在解决点对问题时的强大实力。

首先我们可以考虑两维偏序应该怎么做。

我们发现其实逆序对就是一个二维偏序,对于一对逆序对 \(<i,j>\),需要满足 \(j<i\)\(a_j>a_i\),而类似于这样的大小关系其实就是偏序

回忆一下逆序对是怎么求的。常见的解法有两种,树状数组求法和归并排序求法。

对于树状数组求法,当处理到第 \(i\) 项时,我们可以将 \(a_{1}\)\(a_{i-1}\) 加入树状数组,然后查询树状数组中有多少大于 \(a_i\) 的数即可。

对于归并排序求法,我们会利用递归,分别排序左右半边,然后将左右半边合并,合并过程中计算逆序对的个数即可。

我们发现这两种求法在解决二维偏序问题之前,其实就已经隐式地将 \(a\) 数组按照编号进行排序了——不信,可以将原数组随机打乱之后,再用上面两种求法求逆序对试试。

那么我们隐式排序地过程,实际上就已经将 \(j<i\) 的条件消去了。例如树状数组,我们默认当前在树状数组中的元素编号一定小于 \(i\);再例如归并排序,我们也已经默认了左半边元素的编号一定小于右半边。

所以消去一维偏序之后,无论是树状数组还是归并排序,实际上只起到了解决一维偏序的作用。

一般的,对于二维偏序问题,我们都可以先将所有元素按照一维进行排序,然后再用树状数组或是归并排序解决另一维偏序即可。

那么三维偏序应该怎么解决呢?

我们显然可以使用树套树,用一个树状数组套一个线段树——实际上跟二维偏序一样,排序解决了一维偏序,树状数组解决了一维,线段树解决了一维——不过除此之外,我们能否将求逆序对的两种方法,树状数组和归并排序,结合起来呢?

于是我们就得到了升级版的归并排序——cdq分治!

cdq分治的主要思想就是,我们先按照一维进行排序,然后用归并排序排序第二维,同时将排序过程中的数插入树状数组,解决第三维偏序。

具体来说,分治过程中,考虑将当前分治区间劈开,由于三维偏序数对要不在数组左边,要不在数组右边,要不横跨数组左右,所以我们可以先递归解决左侧,再递归解决右侧,然后考虑怎么计算一个在数组左侧,一个在数组右侧的三维偏序对,也就是怎么处理当前区间左半边对于右半边的贡献

跟归并排序类似,我们可以考虑使用两个指针,分别指向当前区间左半边和右半边的最前面,然后比较所指元素第二维的大小关系,如果左半边较小,则将左半边的第三维插入树状数组;如果右半边更小,就说明我们已经将当前区间左半边所有第二维小于 右半边当前指向元素 的元素加入了树状数组(因为我们在对于子区间归并的过程中,已经将左右半边按照第二维分别进行了排序),此时我们只需要查询树状数组(第三维)中小于 右半边当前指向元素 的个数即可。

归并进行完毕后,我们将树状数组清空,并将当前区间的元素变成我们在归并过程中按照第二维排好之后的顺序即可。

不过需要注意,对于本题,可能会存在某些元素三个维度都相同的情况,此时我们显然只会计算到较前者对于较后者的贡献。所以我们需要将所有元素去重,并记录每种元素的个数,特殊处理一下即可。

int lowbit(int i){
	return i&(-i);
}
void add(int x,int k){
	for (int i=x;i<=t;i+=lowbit(i))
		c[i]+=k;
}
int query(int x){
	int sum=0;
	for (int i=x;i;i-=lowbit(i))
		sum+=c[i];
	return sum;
}
void solve(int l,int r){
	if (l==r) return;
	int mid=l+r>>1;
	solve(l,mid);
	solve(mid+1,r);
	int now1=l,now2=mid+1,now=l;
	while (now1<=mid&&now2<=r){
		if (p[now1].b<=p[now2].b) add(p[now1].c,g[p[now1].id]),zc[now]=p[now1],++now1;
		else ans[p[now2].id]+=query(p[now2].c),zc[now]=p[now2],++now2;
		++now;
	}
	while (now2<=r){
		ans[p[now2].id]+=query(p[now2].c);
		zc[now]=p[now2];
		++now2,++now;
	}
	for (int i=l;i<now1;i++) add(p[i].c,-g[p[i].id]);
	while (now1<=mid){
		zc[now]=p[now1];
		++now1,++now;
	}
	for (int i=l;i<=r;i++) p[i]=zc[i];
}

不过到后期可能会牵扯到较多个数的数据结构,此时我们最好将每个数据结构都用一个结构体存储,使得我们的程序更加便于调试。

归并排序一个 log,树状数组一个 log,时间复杂度为 \(O(n\log^2n)\).


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

这也是一道三维数点问题,只不过将 数个数 改为了 数最近的点。

同时,本题也体现了 cdq 分治将动态问题转化为静态问题的能力。

看到题目中的绝对值,凭着OI的本能,我们很容易下意识地将它拆开。于是本题就变成了,对于每个询问,我们要连续四次旋转坐标系并求出 \(x,y\) 均小于当前询问点的距离当前询问点最近的点。

然后不妨分析一下题目中的三个维度。

第一个维度是每一个操作的顺序,也就是时间维度;第二个维度是 \(x\) 坐标,而第三个维度就是 \(y\) 坐标。

其中第一个维度已经被默认排序好了,现在我们要考虑的就是后两个维度。

于是考虑使用 cdq 分治,对于第二维进行归并排序,排序时遵循归并排序的原则,只考虑左侧修改对于右侧询问的贡献;第三维则对于每一个 \(y\) 坐标维护 \(x+y\) 坐标的最大值即可。询问时相当于就是在查询一个前缀最大值。

void solve(int l,int r){
	if (l==r) return;
	int mid=l+r>>1;
	solve(l,mid),solve(mid+1,r);
	int now1=l,now2=mid+1;
	for (int now=l;now<=r;now++){
		if ((now1<=mid&&p[now1].x<=p[now2].x)||now2>r){
			if (p[now1].id==0) modify(p[now1].y,p[now1].x+p[now1].y);
			zc[now]=p[now1++];
		}else{
			if (p[now2].id!=0) ans[p[now2].id]=min(ans[p[now2].id],p[now2].x+p[now2].y-query(p[now2].y));
			zc[now]=p[now2++];
		}
	}
	for (int now=l;now<=r;now++) 
		if (p[now].id==0) cle(p[now].y);
	for (int i=l;i<=r;i++) p[i]=zc[i];
}

以及推荐一下这种归并排序的写法,简洁好写。

一定要注意,我们只考虑左侧修改对于右侧询问的最大值!!!还有记得清空树状数组和进行归并!!

再次感谢来自spx的工业化调试法拯救了我的下午[合十]。


3. P2487 [SDOI2011]拦截导弹

其实就是著名题目《导弹拦截》的升维版。

很容易想到设 \(dp_i\) 表示拦截前 \(i\) 颗导弹,且强制拦截第 \(i\) 颗导弹时最多能拦截的导弹数量,则有状态转移方程:

\[dp_i=\max(dp_j[j\le i][h_j\ge h_i][v_j\ge v_i])+1 \]

很容易发现,其实这个状态转移方程也是一个三维偏序的形式。

于是这就可以用到 cdq 分治的第三种用途——优化 1D 动态规划问题

考虑将 cdq 分治的过程看作是动态规划转移的过程,那么其实我们就是在解决一个三维偏序问题。

同样的,我们初始的数组已经按照编号排好了顺序,消掉了一个维度。对于后两个维度,考虑归并排序解决掉高度,然后用数据结构维护第三个维度速度,并在每次查询时查询大于等于当前速度的最大 dp 值,然后加 \(1\) 即可。(注意 \(v_i\) 的范围,需要提前离散化)。

于是我们就这么提交了代码,然后发现 WA 了(

为什么啊?难道 cdq 分治不靠谱??

显然不是,而是你的转移顺序靠不住(

再仔细想想我们 cdq 分治的分治顺序——先递归解决左半边,再递归解决右半边,最后再考虑左半边对于右半边的转移。

哪里有问题呢?

问题大了!我们在处理右半边时,先将右半边的左半边对于右半边的右半边进行了转移,可是此时右半边的左半边还没有被当前区间的左半边更新,导致我们此时的右半边的左半边的答案可能不是最优解——那么 dp 显然就错了嘛!

怎么修改呢?很简单,我们将转移顺序更改一下就行了——先处理左半边,然后处理左半边对于右半边的贡献,再处理右半边——这样就可以啦!不过这样以来归并排序就只能进行一半了,所以排序第二维的部分可以考虑另一半用 sort 排序,也可以考虑干脆直接舍弃归并排序,全部用 sort 就好了。除此之外,为了保证解决右半边时第一维的单调性,解决完当前区间后还需要将数组还原。

时间复杂度即为三维偏序的复杂度。


4. P5621 [DBOI2019]德丽莎世界第一可爱

cdq 分治优化四维偏序 dp 题目。

多了一维偏序,我们的 cdq 分治应该怎么处理呢?

我们可以再回顾一下 cdq 分治的基本原理——单次解决问题时考虑左半边对于右半边的贡献。

形象化地来讲,这就类似于我们在排序解决掉第一维之后,沿着第二维的中间向下切了一刀,并用数据结构考虑了左半边对于右半边在第三维上的贡献。

所以如果多了一维的话……我们完全可以沿着另一维再切一刀啊!

考虑将原先切刀的维度由一条线变成一个面,那么相当于就是让我们横着切一刀,竖着切一刀,并求出左上角对于右下角的贡献。

换句话说,我们可以先对第二个维度进行“切刀”,记录下按照第一维排序后,哪些元素是左半边,哪些元素在右半边,然后按照第二维排序,并进行下一层 cdq 分治。而第二层 cdq 分治,就相当于是在切下第二刀,对比三维偏序的分治过程,其实就是对于左半边的修改操作判断一下是否在第二层也在左半边,对于右半边的查询操作,判断一下是否在第二层也在右半边,如果满足则继续操作即可。

与三维偏序类似,优化 dp 时仍需考虑转移顺序。

除此之外,还需要注意排序时比较函数的设置,由于 sort 并没有 stable_sort 对于原序列的稳定性,所以需要将比较函数设置全面。

核心代码如下:

void cdq2(int l,int r){
	if (l==r) return;
	int mid=l+r>>1,nowl=l,nowr=mid+1;
	cdq2(l,mid);
	for (int i=l;i<=r;i++) p2[i]=p[i];
	sort(p+l,p+mid+1,cmp3),sort(p+mid+1,p+r+1,cmp3);
	for (int i=l;i<=r;i++){
		if ((nowl<=mid&&p[nowl].c<=p[nowr].c)||nowr>r){
			if (pd[p[nowl].id]==0) S.add(p[nowl].d,dp[p[nowl].id]);
			++nowl;
		}else{
			if (pd[p[nowr].id]==1) dp[p[nowr].id]=max(dp[p[nowr].id],S.query(p[nowr].d)+p[nowr].v);
			++nowr;
		}
	}
	for (int i=l;i<=r;i++)
		if (pd[p[i].id]==0) S.clear(p[i].d);
	for (int i=l;i<=r;i++) p[i]=p2[i];
	cdq2(mid+1,r);
}
void cdq1(int l,int r){
	if (l==r) return;
	int mid=l+r>>1;
	cdq1(l,mid);
	for (int i=l;i<=r;i++) p1[i]=p[i];
	for (int i=l;i<=mid;i++) pd[p[i].id]=0;
	for (int i=mid+1;i<=r;i++) pd[p[i].id]=1;
	sort(p+l,p+r+1,cmp2);
	cdq2(l,r);
	for (int i=l;i<=r;i++) p[i]=p1[i];
	cdq1(mid+1,r);
}

第三维排序函数如下:

bool cmp3(node x,node y){
	if (x.c^y.c) return x.c<y.c;
	else if (x.d^y.d) return x.d<y.d;
	else if (x.a^y.a) return x.a<y.a;
	else if (x.b^y.b) return x.b<y.b;
	return x.id<y.id;
}

其他维度的排序函数与之类似。


Problem

1. P3364 Cool loves touli

表面上是四个属性,其实还是一道三维偏序问题。

还是考虑设 \(dp_i\) 为选择前 \(i\) 个英雄,且强制选择第 \(i\) 个英雄时最多能选出的英雄个数。

则有状态转移方程:

\[dp_{i}=\max(dp_j[l_j\le l_i][a_j\le s_i][w_j\le a_i])+1 \]

老套路,将所有英雄按照 \(l\) 排序后消掉一维,并考虑使用 cdq 分治,将当前区间 \([l,r]\) 左侧按照 \(a\) 排序,右侧按照 \(s\) 排序(用 sort 即可),归并过程中,对于左半边的元素,考虑将其 \(w\) 值加入树状数组,对于右半边的元素,考虑查询树状数组中小于等于 \(a_i\) 的最大值用来转移。

仍需考虑转移顺序,先左边再中间后右边,处理完当前区间答案后将原数组还原以保持等级一维的单调性。还有就是后三个属性是关联的,所以需要一次性离散化三个属性。


2. P4093 [HEOI2016/TJOI2016]序列

注意这道题变化完某一个值后在下一个时刻是会还原的!!!我说怎么这么不可做呢(

仍然考虑设 \(dp_i\) 表示以第 \(i\) 项结尾,且强制选择第 \(i\) 项时子序列的最长长度。

考虑设计状态转移方程。满足什么条件时,\(dp_j\) 可以转移到 \(dp_i\) 呢?

由于同一时刻只有一个值发生变化,所以第 \(j\) 项能够变化到的最大值一定小于等于 \(a_i\)\(j\) 变化了 \(i\) 就不可能变化);同理,第 \(i\) 项能够变化到的最小值也一定大于等于 \(a_j\)

所以就有状态转移方程:

\[dp_i=\max(dp_j[j\le i][maxn_j\le a_i][a_j\le minn_i]) \]

其中 \(maxn_j\)\(j\) 在左右时刻能够变化得到的最大值,\(minn_i\) 表示 \(i\) 在左右时刻能够变化得到的最小值。

推出式子后,我们就可以很容易地使用 cdq 分治进行维护啦!


3. P3658 [USACO17FEB]Why Did the Cow Cross the Road III P

我们可以将题目进一步地转化,通过【排列对应线交叉】这一条件,我们可以形式化地归纳出数对 \((i,j)\) 需要满足的条件:

  • \(|i-j|>k\)
  • \(a_i\le a_j\)
  • \(b_i\ge b_j\)

所以我们可以初始将原数组按照 \(a\) 排序,然后分治时将 \(b\) 归并,并在树状数组中查询所有 \(|i-j|>k\)\(j\) 即可。


Exersize

  1. P4390 [BOI2007]Mokia 摩基亚
  2. P2163 [SHOI2007]园丁的烦恼
  3. P3769 [CH弱省胡策R2]TATT
  4. P4849 寻找宝藏

Ending

看了这么多,你的心里是否会产生一个疑问——凭什么 cdq 分治就可以优化复杂度了?

这其实还是因为在归并的过程中,我们利用了某一维度的单调性。

想想我们是怎么进行归并排序的?用两个指针变量分别指向左右半边,并利用左右半边内部都已经有序了的单调性,使得我们在处理右半边询问的过程中,能够保证左半边所有满足第二个维度限制的元素都已经被加进了我们的数据结构。如此以来,我们便可以在分治 \(O(\log)\) 的时间复杂度内使得题目中其中的一维变得有序。

这其实也是归并排序的本质。而这种单调性也使得我们用数据结构维护第三个维度变得更加方便,从而能够高效地处理多维偏序问题。

而 cdq 分治的结构,其实和树套树类似,都是一种【x套y】的形式,每多套一个就多解决一维偏序,只不过是将两个树相嵌套改为了归并套数据结构而已。

而由于 cdq 分治优秀的性质以及常数,在解决多维偏序问题的时候,如果题目不要求强制在线,为了追求更高的程序效率以及更简洁的代码,我们一般都会选择 cdq 分治。

posted @ 2022-08-19 22:16  ydtz  阅读(202)  评论(1编辑  收藏  举报