「学习笔记」CDQ 分治
CDQ 分治
应用范围
- 解决与点对相关的问题
- 优化 1D/1D 动态规划的转移
- 将一些动态问题转化为静态问题
Part 1 解决点对相关问题
算法流程
- 找到序列 \([l,r]\) 的中点 \(mid\)
- 将位于序列中的所有点对 \((i,j)\) 进行分类:
- \(l\le i\le mid\),\(l \le j\le mid\)
- \(mid+1\le i\le r\),\(mid+1\le j\le r\)
- \(l\le i\le mid\),\(mid+1\le j\le r\)
- 分别递归 \([l,mid]\) 和 \([mid+1,r]\) 解决前两类点对。
- 用某些方法解决最后一类点对。
整个算法流程其实就是一种分而治之的思想,而第 4 部分的信息处理则是我们应该去设计的算法。
例题1
三维偏序
给定 \(n\) 个点 \((a_i,b_i)\),求满足 \(i< j,a_i < a_j,b_i < b_j\) 的点对 \((i,j)\) 数。
我们尝试解决第 4 部分的问题。发现由于 \(l\le i\le mid\),\(mid+1\le j\le r\),已经满足 \(i<j\) 的条件,只需再统计满足 \(a_i< a_j,b_i<b_j\) 的点对数即可,将问题转化为了二维偏序问题。只需将 \([l,mid]\) 和 \([mid+1,r]\) 部分都按 \(a\) 的值从小到大排序,然后枚举 \(j\),将满足 \(a_i<a_j\) 的 \(b_i\) 全部加入权值树状数组,然后统计 \(<b_j\) 的值的个数即可。
由于已经按 \(a\) 的值排序,在 \(j\) 逐渐增大的同时,\(i\) 必然也在逐渐增大。(可能不变)因此只需要双指针即可解决。由于当前序列 \([l,r]\) 的统计问题与其他序列无关,我们需要完成操作后清空树状数组。直接用 memset
显然是不现实的,我们可以直接在修改的地方重新改回来即可,即撤销操作。
因此解决第 4 部分问题的复杂度为 \(O(n\log n)\),总时间复杂度就为 \(O(n\log^2 n)\)。
void CDQ(int l,int r)
{
if(l==r)return;
/*递归边界返回*/
int mid=(l+r)>>1;
/*找到序列中点mid*/
CDQ(l,mid),CDQ(mid+1,r);
/*递归解决前两类点对*/
sort(P+l,P+mid+1);
/*将[l,mid]间的点对按照 a 值排序*/
sort(P+mid+1,P+r+1);
/*将[mid+1,r]间的点对按照 a 值排序*/
int i=l,j=mid+1;
/*
双指针:i为[l,mid]部分的,j为[mid+1,r]部分的
*/
while(j<=r)
{
while(i<=mid&&P[i].a<P[j].a)
T.add(P[i].b,1),i++;
/*
i 移动的前提是得在 [l,mid] 内,不能跑出去了
如果满足 a_i<a_j 就加入 b_j,并将j向右移动
*/
P[j].sum+=T.query(P[j].b-1),j++;
/*
树状数组统计的是<=的个数,
因此 -1 统计的是< 的个数。
注意要将当前指针 j 移动
*/
}
for(int d=l;d<i;d++)
T.add(P[d].b,-1);
/*
i在符合条件时先加入再移动
因此 [l,i-1] 即为修改的点
*/
}
例题2
陌上花开
给定 \(n\) 个元素,第 \(i\) 个元素的属性为 \((a_i,b_i,c_i)\),设 \(f(i)\) 表示满足 \(j\ne i,a_j \le a_i,b_j \le b_i,c_j\le c_i\) 的 \(j\) 的个数。
对于 \(d\in [0,n)\),求 \(f(i)=d\) 的数量。
同样是偏序问题,上题是严格偏序,本题是非严格偏序。看似只需要简单转化:在主函数中先按 \(c\) 排序去掉一维,然后再套路地套上 CDQ 分治,将 P[i].a<P[j].a
更改为 P[i].a<=P[j].a
,将查询从 T.query(P[j].b-1)
更改为 T.query(P[j].b)
。
其实不然。若存在点对 \((i,j)\) 满足 \(i\ne j,a_i=a_j,b_i=b_j,c_i=c_j\),在 CDQ 分治中由于排序被固定了左右顺序,只能由一者贡献给另一者,而事实上它们之间是相互贡献的。如何解决这一问题?
将序列去重即可。同时记录和去重后的元素 \(i\) 相等的元素数目 \(v\)(包括 \(i\)),将 \(v\) 作为元素 \(i\) 的权值,修改时改为 T.add(P[i].b,P[i].v)
,撤销时也相应改为 T.add(P[i].b,-P[i].v)
即可。在最后统计答案时,再加上一个 \(v-1\),即相同元素间的贡献即可。
例题3
动态逆序对
现在给出 \(1\sim n\) 的一个排列,按照某种顺序依次删除 \(m\) 个元素,你的任务是在每次删除一个元素之前统计整个序列的逆序对数。
本题的在线算法:树套树。可以用树状数组套权值线段树,或者用分块套树状数组都可以实现。
现在我们来看看本题的离线算法。虽然没有强制离线这种玩意儿,但总还是要学的。
正序删除难以操作,怎么办?变为倒序插入呀!将删除时间 \(t\) 作为一维,统计满足 \(t_i\ge t_j\) 的逆序对数。CDQ 分治是以一个元素为根本,然后寻找满足条件的另一个元素。因此逆序对数需要由两部分构成:\(i<j\) 且 \(v_i>v_j\) 或是 \(i>j\) 且 \(v_i<v_j\)。我们需要做两次 CDQ 分治。在这里为了更加顺眼,我将每个元素的删除时间改为 \(m-t+1\),将不动的元素时间设置为 \(0\),这样就变为统计满足 \(t_i\le t_j\) 的逆序对数了。
这里就有两种写法了。一种是两次合为一次(因为大体框架相同),另一种是做两次。而做两次的时候有一个坑点需要注意:如果用 \(ans[t]\) 记录 \(t\) 时刻插入元素后新增的逆序对,那么初始的逆序对数 \(ans[0]\) 会被统计两次,因此需要除以 \(2\)。我们统计的是每一时刻的总逆序对数,因此需要累加。
例题4
Radio stations
给定 \(n\) 和 \(k\),以及 \(n\) 个元素,第 \(i\) 个元素的属性为 \((x_i,r_i,f_i)\)。求满足 \(i\ne j\) 且 \(\min(r_i,r_j)\ge |x_i-x_j|\) 且 \(|f_i-f_j|\le k\) 的点对 \((i,j)\) 数。注:\((i,j)\) 与 \((j,i)\) 相同,不重复计数。数据保证不存在 \(i\ne j\) 且 \(x_i=x_j\) 的点对。
拆绝对值成为四维偏序?不不不,时间复杂度多个 \(\log\) 承受不起。换个方法,我们拆 \(\min\)。
将元素按照 \(r\) 从大到小排序,这样就可以保证区间 \([mid+1,r]\) 内的元素 \(i\) 在统计时,对于每个 \([l,mid]\) 间的元素 \(j\) 都满足 \(\min(r_i,r_j)=r_i\),从而将原题的条件变为:
- \(r_i\le r_j\)
- \(r_i-x_i\le x_j\le r_i+x_i\)
- \(f_i-k\le f_j\le f_i+k\)
第一维,排序解决。
第二维,树状数组维护。
第三维,单边双指针实现。
看到这里或许有些读者会有问题:二、三两维是否可以交换实现方式?答案是否定的。
如果第二维用单边双指针维护,我们会发现难以维护:对于每个 \(i\),\(r_i-x_i\) 和 \(r_i+x_i\) 的范围太大,移动起来复杂度太高。反观 \(1\le f_i\le 10^4,0\le k\le 10\),移动起来复杂度较小。因此我们用树状数组维护第二维,用单边双指针维护第三维。
推荐习题
Part 2 优化 1D/1D 动态规划的转移
1D/1D 动态规划是指:状态一维,转移 \(O(n)\) 的一类 dp 问题。有的问题可以用单调队列、斜率进行优化,而还有的问题可以使用 cdq 分治来降低复杂度。
我们来尝试优化这个式子:\(dp_i= 1 + \max\limits_{j=1}^{i-1} dp_j[a_j<a_i][b_j<b_i]\)
直接转移,时间复杂度为 \(O(n^2)\)。我们尝试用 cdq 去优化转移过程。发现 \(dp_j\) 转移到 \(dp_i\) 也是一种点对间的关系,因此会有如下算法流程:
算法流程
- \(l=r\) 时,说明 \(dp_r\) 已经计算完毕,只需 \(+1\) 即可。
- 找到区间 \([l,mid]\) 的中点 \(mid\)。
- 先递归计算 \(solve(l,mid)\)
- 处理 \(l\le j\le mid\),\(mid+1\le i\le r\) 一类点对的转移关系
- 再递归计算 \(solve(mid+1,r)\)
注意点:与偏序问题不同的是,点对间的关系处理需要放在两次递归操作之间。为什么?因为 \(dp\) 的转移时有序的、从前往后的。因此在 \([mid+1,r]\) 对 \([l,mid]\) 进行转移时,首先要保证 \([l,mid]\) 间的 \(dp\) 都已经转移完毕。而在进行 \([mid+1,r]\) 内部的转移时,首先要保证之前的状态都已经转移完毕。因此点对间的关系处理需要放在两次递归操作之间。如果我们将 cdq 分治的递归树的递归过程拉出来看,会发现刚好是进行了中序遍历,因此是按照顺序处理了所有的 dp 值。
例题
拦截导弹
有 \(n\) 个导弹,每个导弹有三个参数 \(t\),\(a\) 和 \(b\)。你需要求出一个最长的序列 \({s}\),满足对于序列中所有的 \(i\),均有 \(t_ i\le t_{i+1}\) 且 \(a_i\ge a_{i+1}\) 且 \(b_i\ge b_{i+1}\)。输出最长的序列的长度。由于可能有多种最长的序列的方案,每次随机选一种,你需要求出对于每个导弹,其成为最长序列中的一项的概率。
数据范围:\(n\le 5\times 10^4\),\(1\le a_i,b_i\le 10^9\),\(1\le t_i\le n\)。
第一问:二维最长不升子序列长度。第二问:每个位置在方案中出现的概率。(即出现的方案数除以总方案数)
第一问正序枚举 \(i,j\) 进行转移求得。设 \(f1_i\) 表示以 \(i\) 为结尾的二维最长不升子序列的长度,\(g1_i\) 表示以 \(i\) 为结尾的长度等于 \(f1_i\) 的子序列数。有如下转移:
\(f1_i= 1 + \max\limits_{j=1}^{i-1} f1_j[a_j\ge a_i][b_j\ge b_i]\)
\(g1_i=\sum\limits_{j=1}^{i-1} g1_j[a_j\ge a_i][b_j\ge b_i][f1_i=f1_j+1]\)
则长度为 \(ans=\max\limits_{i=1}^n f1_i\),总方案数为 \(sum=\sum\limits_{i=1}^n g1_i[f1_i=ans]\)。
第二问倒序枚举 \(i,j\) 进行转移求得。设 \(f2_i\) 表示以 \(i\) 为开头的二维最长不升子序列的长度,\(g2_i\) 表示以 \(i\) 为开头的长度等于 \(f2_i\) 的子序列数。有如下转移:
\(f2_i= 1 + \max\limits_{j=i+1}^{n} f2_j[a_j\ge a_i][b_j\ge b_i]\)
\(g2_i=\sum\limits_{j=i+1}^{n} g2_j[a_j\ge a_i][b_j\ge b_i][f2_i=f2_j+1]\)
当一个元素位于某个二维最长不升子序列中时,则必然满足 \(f1_i+f2_i-1=ans\)。因此第二问的答案 \(P(i)=\dfrac{g1_i\times g2_i}{sum}[f1_i+f2_i-1=ans]\)。
从转移方程来看,都可以用 cdq 分治统计其中的偏序关系来进行转移。如第一问中,先将每个元素按照位置排序,解决掉第一维。在点对间的统计中,我们对 \([l,mid]\) 和 \([mid+1,r]\) 部分的元素分别按照第二维 \(a\) 从大到小排序,通过某种数据结构得到 \(\max f1_j\) 和对应方案 \(g1_j\)。能单点修改,区间查询最大值及对应方案和的数据结构是什么?可以用线段树,但也可以用树状数组。重载加号运算符可以使代码的可读性更高。
第二问由于是从后往前统计,为了方便使用同一个 cdq 进行统计,我们将参数 \(t\) 变为 \(n-t+1\),\(a\) 和 \(b\) 也取相反数,在离散化之后就可以轻松地使用同一个 cdq 了。
没那么好写,恶心死我了。
推荐习题
Part 3 将一些动态问题转化为静态问题
尚未完工qwq。