一篇自己都看不懂的CDQ分治&整体二分学习笔记

时间:2021-10-19 06:50:43

作为一个永不咕咕咕的博主,我来更笔记辣qaq


CDQ分治

CDQ分治的思想还是比较简单的。它的基本流程是:

\(1.\)将所有修改操作和查询操作按照时间顺序并在一起,形成一段序列。显然,会影响查询操作结果的修改操作在序列中一定会在这一个查询操作前面

\(2.\)将这一段序列分为左右两半,递归解决左右两半的子问题

\(3.\)考虑左半部分的修改操作对右半部分的查询操作的贡献

CDQ分治的基本思想就是在分治的过程中统计左半部分对右半部分的影响

上面的过程可能比较抽象,举个栗子:归并排序求逆序对

别告诉我你只会树状数组求逆序对

回顾一下归并排序求逆序对的过程:

\(1.\)将当前待排序的序列分为左右两个区间,计算左右区间内部的逆序对数量并将左右区间分别排好序

\(2.\)通过双指针计算一个数在左区间、另一个数在右区间的逆序对数量并将原序列排好序

上面加粗的部分与CDQ分治是异曲同工的。

还有些模糊其实是很正常的,下面来看几道例题感受一下:

三维偏序

二维偏序就是逆序对统计,那么三维偏序应该怎么做呢?我们设三个维度为\((a,b,c)\),那么方法如下:

\(1.\)对所有点的\(a\)维排序

\(2.\)对\(b\)维归并排序

然后我们考虑在分治过程中如何统计左半边区间对右半边区间的贡献。

可以发现,在当前分治区间合并\(b\)维的时候,\(a\)维是无序的,但是一定会有左半区间的点的\(a\)小于右半区间的点的\(a\),所以我们不需要考虑\(a\),只需要统计对于每一个右半区间的点,有多少个\(b,c\)维同时小于它即可。

这个可以在对\(b\)维进行归并排序的时候使用树状数组对\(c\)维进行维护。

然后就做完了QuQ复杂度\(O(nlog^2n)\)

注意一些小细节:

①在合并左右两个区间时可以直接快速排序,因为分治+树状数组已经有\(log^2n\)的复杂度了,快排不会影响最后的复杂度,而且似乎比直接归并要快一些

②每一次树状数组清空的时候不要暴力\(memset\)(这样子复杂度就会退化为\(O(n^2)\)),应该是将左半区间的贡献在树状数组中清除,这样才能保证复杂度。

当然了以上的方法是可以拓展到多维的(使用CDQ+CDQ+CDQ+...+树状数组解决),但是一般当维度大于\(4\)维时使用\(bitset\)会有更优的复杂度。

这里是代码菌

动态逆序对

考虑每一次删除实际上会减掉什么

首先考虑会减掉这一个数组成的逆序对数量,然后能够发现会减多一部分。

减多了什么呢?如果两个数能够产生逆序对,那么在这两个数都被删除的时候,这一个逆序对会被算两次。

那么我们考虑在后面被删除的数被删除的时候将这一个贡献加回来

对于每一次删除操作,设其为\((time,pos,val)\),那么我们需要对于每一个\(j\)求满足\(time_i<time_j,pos_i>pos_j,val_i<val_j\)或者\(time_i<time_j,pos_i<pos_j,val_i>val_j\)的\(i\)的数量。可以发现这是一个三维偏序,然后直接上板子即可。

代码

NOI2007 货币兑换

这一道题是CDQ论文里面的题目

做这道题你需要的前置芝士是斜率优化,顺便给我的斜率优化笔记打个广告QAQ

我们来愉快地推\(DP\)

设\(f_i\)表示在第\(i\)天能够获得的最大收益,设\(g_i\)为第\(i\)天最多能够兑换的\(B\)券数量

那么有\(g_i \times B_i + g_i \times Rate_i \times A_i = f_i\),即\(g_i = \frac{f_i}{B_i + Rate_i \times A_i}\)

而我们的\(DP\)转移为:\(f_i = \max\{A_i\max\limits_{j=1}^{i-1}\{g_j\frac{B_i}{A_i} + g_jRate_j\},f_{i-1}\}\)

可以发现我们的转移是一个斜率式子,可以使用斜率优化解决。但是\(g_i\)与\(i\)并不呈正相关,意味着我们不能像传统的斜率优化一样使用单调队列解决凸包的维护问题

这个时候有两种做法:使用平衡树维护凸包和使用分治维护凸包。第一种不是这篇文章的重点,所以我们直接看第二种

考虑当前正在求\([l,r]\)的\(f\)值,首先我们先递归进入左区间,将左区间的\(f\)和\(g\)的值求出来,然后先在当前层处理左区间对右区间的影响,然后递归进入右区间求解

对于当前区间的贡献的计算,首先将左区间的所有直线按照斜率排序,使用单调栈建立凸包,然后将右区间的所有询问按照\(x\)递增排序,因为\(x\)递增,所以可以直接在凸包上扫一遍得到答案。当然这一部分也可以不排序,通过在凸包上二分解决。

注意到上面的递归顺序与我们之前见到的大部分分治的递归顺序是不同的,这是因为:如果我们需要正确地得到\(f_i\),意味着对于所有的\(j<i\),\(f_j\)必须要在\(f_i\)之前算好并将贡献给\(f_i\),\(f_i\)才能够正确地给其他值进行贡献。如果我们在当前区间计算贡献之前计算右区间,意味着右区间的所有\(f_i\)都在没有正确地计算出值得情况下对其他值进行了贡献,这会导致答案的错误。

代码

练习题:BZOJ4237 稻草人

题解在这里

整体二分

整体二分与CDQ分治的做法有些像

整体二分的基本思路是将一堆操作和询问放在一起进行二分,一般可以做到\(O(nlog^2n)\)的复杂度

假设我们现在正在解决\(solve(ql,qr,l,r)\),这表示我们正在解决\([ql,qr]\)区间内的操作和询问,而且满足:区间内所有修改操作的权值范围在\([l,r]\)内,并且已经确定区间内所有询问的答案在\([l,r]\)区间内

接下来是整体二分最重要的\(check\)部分:

取\(mid=\frac{l+r}{2}\),从左往右执行所有修改和查询操作:

如果某个修改操作的权值范围在\([l,mid]\)内,执行它,否则忽略掉它

如果到了一个查询操作,进行查询,如果满足答案在\([l,mid]\)内的条件,那么它的答案就在\([l,mid]\)范围内,否则在\([mid+1,r]\)范围内

最后把所有权值范围在\([l,mid]\)的修改操作和答案在\([l,mid]\)的查询放在一起,其他的修改和查询放在一起,两边分别递归求解。递归边界为\(l=r\),就可以确定\([ql,qr]\)的所有询问的答案为\(l\)。

当然,可能在实际的\(check\)过程中需要执行的是\([mid+1,r]\)部分的修改操作。

可能理解起来比较抽象(分治理解起来都比较抽象)来看两道题感受一下:

ZJOI2010 K大数查询

这道题曾经树套树时空双卡,但整体二分表示很轻松;现在空间开大了,但是树套树还是因为超大的常数容易被卡……

考虑\(solve\)函数的\(check\)过程,不妨这么做:

使用一个权值线段树维护每一个位置中,数字范围在\([mid+1,r]\)的数的个数。

对于在\([mid+1,r]\)范围内的修改,也就是加入的数在\([mid+1,r]\)范围内的操作,在它对应的操作区间上\(+1\);

对于一个查询操作,如果这个查询操作对应的区间上数字范围在\([mid+1,r]\)的数的个数大于等于这一次询问的\(K\),则表示这一个询问的答案在\([mid+1,r]\)范围内,否则在\([l,mid]\)范围内。

值得注意的是,要清除\([mid+1,r]\)的修改操作对答案在\([l,mid]\)范围内的询问的贡献。

代码

HNOI2016 网络

这道题数据比较水,以至于树链剖分+线段树+堆的\(O(nlog^3n)\)算法都怼过去了

仍然考虑\(check\)函数,我们只对权值在\([mid+1,r]\)的范围内的修改进行操作,操作的方式考虑差分:预处理树的\(dfs\)序,对于一个加入操作\(a,b\),在\(dfn_a,dfn_b\)处\(+1\),然后在\(dfn_{LCA(a,b)},dfn_{fa_{LCA(a,b)}}\)处\(-1\),删除操作反过来即可。

对于一个查询操作,我们需要查询是否当前加入的所有链都经过了当前点,我们直接查询当前点对应的子树中的权值和,如果它与当前加入的链数相等就表示所有链都经过了,答案在\([l,mid]\)范围内,否则在\([mid+1,r]\)范围内。正确性显然分类讨论一下就行了

代码来这里看啦啦啦

练习题:POI2011 Meteors

题解在这里

总结

CDQ分治与整体二分是两种比较优秀的分治算法,一般适用于以下情形:

\(1.\)不强制在线

\(2.\)对时间限制和空间限制要求比较高,使用高级数据结构(点名树套树!)容易被卡

虽然离线是这两种分治算法最大的软肋,出题人只需要加上异或\(lastans\)分治就GG了,但仍然不能改变这两种分治算法的重要地位。