1 概念
cdq 分治,是一种分治思想而非具体算法。它是基于分治思想,将复杂的问题拆分求解。
与一般分治算法不同的是,一般分治所拆分的子问题互相独立、互不干扰、形式与原问题一致。而在 cdq 分治中,每次划分出的两个子问题,是利用前面的子问题解决后面的子问题。也就是说,对于序列 \([l,r]\),我们先解决 \([l,mid)\) 的子问题,然后处理 \([l,mid)\) 对于 \([mid,r]\) 的贡献、影响,接着递归处理 \([mid,r]\)。
cdq 分治是一种离线算法,做题时需要注意这点。
2 基础例题
2.1 偏序问题
偏序问题是利用 cdq 分治求解的经典问题。
在这之前我们先需要看一个分治的经典应用:归并排序。
2.1.1 归并排序
void merge_sort(int l, int r) {
if(l == r) return ;
int mid = (l + r) >> 1;
merge_sort(l, mid);
merge_sort(mid + 1, r);
int pl = l, pr = mid + 1;
for(int i = l; i <= r; i++) {
if((a[pl] < a[pr] && pl <= mid) || pr > r) {
b[i] = a[pl++];
}
else {
b[i] = a[pr++];
}
}
for(int i = l; i <= r; i++) {
a[i] = b[i];
}
}
这是经典的归并排序代码,看上去就是一个普通分治。但是我们知道,归并排序的一个经典用途就是求逆序对。
我们将核心部分修改为这样:
int ans = 0;
void merge_sort(int l, int r) {
if(l == r) return ;
int mid = (l + r) >> 1;
merge_sort(l, mid);
merge_sort(mid + 1, r);
int pl = l, pr = mid + 1;
for(int i = l; i <= r; i++) {
if((a[pl] < a[pr] && pl <= mid) || pr > r) {
b[i] = a[pl++];
}
else {
b[i] = a[pr++];
ans += mid - pl + 1;
}
}
for(int i = l; i <= r; i++) {
a[i] = b[i];
}
}
现在我们重新审视一下这个归并排序。它首先将整个区间 \([l,r]\) 分成两部分求解。而在合并的过程中,当右半部分的某一个值小于当前左半部分时,左半部分剩下的所有数字一定都和右半部分的这个值构成逆序对。
也就是说,归并排序求逆序对实际上已经运用了 “用左边信息计算右边信息” 的思想了。这也是 cdq 分治一个简单的应用。而本质上,这就是一个二维偏序问题。
2.1.2 二维偏序
题意:有 \(n\) 个二元组 \((a_i,b_i)\),求出满足 \(a_j< a_i,b_j< b_i\) 且 \(i\ne j\) 的 \((i,j)\) 的数量。
考虑我们刚刚求逆序对的过程,其实是给出二元组 \((i,a_i)\),求 \(i< j,a_i> a_j\) 的数量。这本质上就是一个二维偏序问题。
不同的地方在于,逆序对已经自动将 \(i\) 排好序了。那我们也可以仿照,在二维偏序中首先将 \(a_i\) 排序,然后求解的就是 \(b\) 上的 “顺序对” 问题了。
也就是说,我们求解二维偏序的时候其实是通过一次排序降掉了一维 \(a_i\),然后利用 cdq 处理第二维。这也是求解高维偏序的基本思路:降维。
(当然我们发现 \(b\) 上的问题也可以用树状数组解)
那我们看一下二维偏序的模板:「一本通 4.1 例 2」数星星 Stars。代码如下:
#include <bits/stdc++.h>
using namespace std;
typedef long long LL;
const int Maxn = 2e5 + 5;
int n;
struct node {
int x, y;
int num;
bool operator < (const node &b) const {
if(x == b.x) return y < b.y;
return x < b.x;
}
}p[Maxn], b[Maxn];
int ans[Maxn];
void cdq(int l, int r) {
if(l == r) return ;
int mid = (l + r) >> 1;
cdq(l, mid);
cdq(mid + 1, r);
int pl = l, pr = mid + 1;
for(int i = l; i <= r; i++) {
if((p[pl].y <= p[pr].y && pl <= mid) || (pr > r)) {
b[i] = p[pl++];
}
else {
p[pr].num += pl - l;
b[i] = p[pr++];
}
}
for(int i = l; i <= r; i++) {
p[i] = b[i];
}
}
int main() {
ios::sync_with_stdio(0);
cin.tie(0), cout.tie(0);
cin >> n;
for(int i = 1; i <= n; i++) {
cin >> p[i].x >> p[i].y;
}
sort(p + 1, p + n + 1);
cdq(1, n);
for(int i = 1; i <= n; i++) {
ans[p[i].num]++;
}
for(int i = 0; i < n; i++) {
cout << ans[i] << '\n';
}
return 0;
}
2.1.3 三维偏序
这才是 cdq 的模板:【模板】三维偏序(陌上花开)。
考虑到三维偏序就是在二维偏序之上再加一维,那么继续使用降维思想。
首先第一维 \(a_i\) 可以简单做掉,第二维我们考虑使用 cdq 分治。在上面二维偏序中,我们统计答案直接加上了 \(pl-l\),但在三维偏序中我们还要判断第三维的关系才能修改答案。因此第三维可以用树状数组维护。
也就是说,三维偏序我们可以使用 cdq 分治 + 树状数组解决。时间复杂度 \(O(n\log^2 n)\)。
代码:
#include <bits/stdc++.h>
using namespace std;
typedef long long LL;
const int Maxn = 2e5 + 5;
int n, d, m;
struct node {
int x, y, z, val;
int num;
bool operator < (const node &b) const {
if(x != b.x) return x < b.x;
if(y != b.y) return y < b.y;
return z < b.z;
}
}p[Maxn], tmp[Maxn], b[Maxn];
int mx;
struct BIT {//第三维
int c[Maxn];
void init() {
memset(c, 0, sizeof c);
}
int lowbit(int x) {
return x & (-x);
}
void mdf(int x, int val) {
for(int i = x; i <= mx; i += lowbit(i)) {
c[i] += val;
}
}
int query(int x) {
int sum = 0;
for(int i = x; i; i -= lowbit(i)) {
sum += c[i];
}
return sum;
}
}B;
struct Cdq {//第二维
void cdq(int l, int r) {
if(l == r) return;
int mid = (l + r) >> 1;
cdq(l, mid);
cdq(mid + 1, r);
int pl = l, pr = mid + 1;
for(int i = l; i <= r; i++) {
if((p[pl].y <= p[pr].y && pl <= mid) || (pr > r)) {
B.mdf(p[pl].z, p[pl].val);
b[i] = p[pl++];
}
else {
p[pr].num += B.query(p[pr].z);
b[i] = p[pr++];
}
}
for(int i = l; i < pl; i++) {
B.mdf(p[i].z, -p[i].val);
}
//树状数组清空不能用 memset,会 TLE
for(int i = l; i <= r; i++) {
p[i] = b[i];
}
}
}C;
int ans[Maxn];
int main() {
ios::sync_with_stdio(0);
cin.tie(0), cout.tie(0);
cin >> n >> mx;
for(int i = 1; i <= n; i++) {
cin >> tmp[i].x >> tmp[i].y >> tmp[i].z;
}
sort(tmp + 1, tmp + n + 1);
int num = 0;
for(int i = 1; i <= n; i++) {//去重
num++;
if(tmp[i].x != tmp[i + 1].x || tmp[i].y != tmp[i + 1].y || tmp[i].z != tmp[i + 1].z) {
p[++m] = tmp[i];
p[m].val = num;
num = 0;
}
}
//数据中可能存在完全一样的三元组,这些三元组之间都有贡献。但是 cdq 分治只能解决左区间对右区间的贡献。
C.cdq(1, m);
for(int i = 1; i <= m; i++) {
ans[p[i].num + p[i].val - 1] += p[i].val;
}
for(int i = 0; i < n; i++) {
cout << ans[i] << '\n';
}
return 0;
}
考虑到上面我们采取降维的思想,将前两维降掉后第三维采用的是树状数组。其实,我们第三维也可以使用 cdq 分治。因此三维偏序的另一种解法就是 cdq 套 cdq。时间复杂度 \(O(n\log^2n)\),但是常数相较于上一种做法要高。
当然 cdq 套 cdq 是解决偏序问题的通法,对于 \(k\) 维偏序,时间复杂度为 \(O(n\log ^{k-1}n)\)。
代码:
#include <bits/stdc++.h>
using namespace std;
typedef long long LL;
const int Maxn = 2e5 + 5;
int n, mx, m;
struct node {
int x, y, z;
int num, ans, id;
bool op;
bool operator < (const node &b) const {
if(x != b.x) return x < b.x;
if(y != b.y) return y < b.y;
return z < b.z;
}
}tmp[Maxn], a[Maxn], b[Maxn], c[Maxn];
struct Cdq {
void cdq1(int l, int r) {//第二层 cdq
if(l == r) {
return ;
}
int mid = (l + r) >> 1;
cdq1(l, mid);
cdq1(mid + 1, r);
int pl = l, pr = mid + 1, cnt = 0;
for(int i = l; i <= r; i++) {
if((b[pl].z <= b[pr].z && pl <= mid) || pr > r) {
c[i] = b[pl++];
if(c[i].op == 0) {//只有在左边的点才会对右边的点产生贡献
cnt += c[i].num;
}
}
else {
if(b[pr].op == 1) {//只有右边的点才能接受贡献
a[b[pr].id].ans += cnt;//累加答案
}
c[i] = b[pr++];
}
}
for(int i = l; i <= r; i++) {
b[i] = c[i];
}
}
void cdq(int l, int r) {//第一层 cdq
if(l == r) return ;
int mid = (l + r) >> 1;
cdq(l, mid);
cdq(mid + 1, r);
int pl = l, pr = mid + 1;
for(int i = l; i <= r; i++) {
if((a[pl].y <= a[pr].y && pl <= mid) || pr > r) {
b[i] = a[pl++];
b[i].op = 0;
}
else {
b[i] = a[pr++];
b[i].op = 1;//b 中记录这个节点在放在左边还是右边
}
}
for(int i = l; i <= r; i++) {
a[i] = b[i];
b[i].id = i;
}
cdq1(l, r);
}
}C;
int ans[Maxn];
int main() {
ios::sync_with_stdio(0);
cin.tie(0), cout.tie(0);
cin >> n >> mx;
for(int i = 1; i <= n; i++) {
cin >> tmp[i].x >> tmp[i].y >> tmp[i].z;
}
sort(tmp + 1, tmp + n + 1);
int num = 0;
for(int i = 1; i <= n; i++) {
num++;
if(tmp[i].x != tmp[i + 1].x || tmp[i].y != tmp[i + 1].y || tmp[i].z != tmp[i + 1].z) {
a[++m] = tmp[i];
a[m].num = num;
num = 0;
}
}
C.cdq(1, m);
for(int i = 1; i <= m; i++) {
ans[a[i].ans + a[i].num - 1] += a[i].num;
}
for(int i = 0; i < n; i++) {
cout << ans[i] << '\n';
}
return 0;
}
2.2 动态变静态
这也是 cdq 分治的一个基础应用。所谓动态变静态,就是将动态解决的问题化为静态问题。而一个动态问题则会有修改和查询操作,我们将他们离线下来,按照时间顺序排开,接着我们在时间轴上进行 cdq 分治。
具体的,我们先递归处理左区间,然后处理左区间的修改对于右区间的查询的影响,最后处理右区间。
例如这道题:【模板】树状数组 1,用树状数组十分简单,但是 cdq 分治也可以动态变静态完成。这里我们将查询操作拆成两个前缀和求解。
代码:
#include <bits/stdc++.h>
using namespace std;
typedef long long LL;
const int Maxn = 2e6 + 5;
int n, m;
struct node {
int opt, id, val;
//opt=1 表示修改,opt=2 表示查询左端点,opt=3 表示查询右端点
//id 表示查询或修改的下标
//当修改时,val 表示修改的值;当查询时,val 表示查询是第几个查询
bool operator < (const node &b) const {
if(id != b.id) return id < b.id;
return opt < b.opt;
}
}q[Maxn], b[Maxn];
int tot, cnt;
int ans[Maxn];
void cdq(int l, int r) {
if(l == r) return ;
int mid = (l + r) >> 1;
cdq(l, mid);
cdq(mid + 1, r);
int pl = l, pr = mid + 1, sum = 0;
for(int i = l; i <= r; i++) {
if((q[pl] < q[pr] && pl <= mid) || pr > r) {
if(q[pl].opt == 1) sum += q[pl].val;
//记录左区间的修改
b[i] = q[pl++];
}
else {
if(q[pr].opt == 2) ans[q[pr].val] -= sum;
if(q[pr].opt == 3) ans[q[pr].val] += sum;
//计算右区间的查询
b[i] = q[pr++];
}
}
for(int i = l; i <= r; i++) {
q[i] = b[i];
}
}
signed main() {
ios::sync_with_stdio(0);
cin.tie(0), cout.tie(0);
cin >> n >> m;
for(int i = 1; i <= n; i++) {
int p;
cin >> p;
q[++tot] = {1, i, p};//将初始操作也看做查询
}
while(m--) {
int opt, x, y;
cin >> opt >> x >> y;
if(opt == 1) {
q[++tot] = {1, x, y};
}
else {
q[++tot] = {2, x - 1, ++cnt};
q[++tot] = {3, y, cnt};
}
}
cdq(1, tot);
for(int i = 1; i <= cnt; i++) {
cout << ans[i] << '\n';
}
return 0;
}
2.3 1D/1D 动态规划
1D/1D 动态规划是指这样一类动态规划:dp
数组为 \(1\) 维,单次转移复杂度 \(O(n)\) 的动态规划。经典的 1D/1D 动态规划模型就是最长上升子序列。
1D/1D 动态规划的优化方式有很多,例如单调队列优化、斜率优化、四边形不等式优化等等。但是 cdq 分治同样可以用于优化 1D/1D 动态规划问题。
我们以最长上升子序列为例,观察它的转移方程:
\[dp_i=\max_{j<i,a_j<a_i}\{dp_j\}+1 \]我们发现 \(\max\) 底下那一坨是 \(j<i,a_j<a_i\),就是一个二维偏序。因此我们在 cdq 分治的时候利用二维偏序关系更新 \(dp\) 数组即可。
但是此时要注意,我们的流程是先递归左半部分,处理左半部分对于右半部分的贡献,最后再处理右半部分。因为 \(dp\) 数组是需要更新后才能继续计算的。
代码:
void cdq(int l, int r) {
if(l == r) {
return ;
}
int mid = (l + r) >> 1;
cdq(l, mid);
sort(p + l, p + mid + 1, cmp);
sort(p + mid + 1, p + r + 1, cmp);//先对左右两部分排序,满足单调性后才可以求解
int pl = l, pr = mid + 1, mx = 0;
for(int i = l; i <= r; i++) {
if((p[pl].val < p[pr].val && pl <= mid) || pr > r) {
mx = max(mx, dp[p[pl].id]);//左边记录最大值
pl++;
}
else {
dp[p[pr].id] = max(dp[p[pr].id], mx + 1);//右边直接修改
pr++;
}
}
sort(p + mid + 1, p + r + 1, cmp2);
//按照下标排序,也就是要在递归前将下标这一维降去
cdq(mid + 1, r);
}
时间复杂度是 \(O(n\log^2 n)\) 的,只比暴力 \(O(n^2)\) 要好一点,比不上二分贪心的 \(O(n\log n)\)。不过如果限制条件变为 \(j<i,a_j<a_i,b_j<b_i\),,也就是二维最长上升子序列的时候,cdq 分治就是一种不错的选择了。
标签:pr,偏序,int,分治,mid,cdq,pl From: https://www.cnblogs.com/dzbblog/p/18172458