分治法的关键特征_算法学习笔记(61): cdq分治

cdq分治与其说是一种算法,不如说是一种思想,它是由陈丹琦最早引入国内算法竞赛界的,所以称为cdq分治。它的思想主要是,对于一个序列

,递归地解决
的子问题,然后处理
的贡献、影响(此处利用排序制造单调性,从而降低复杂度),再递归地解决
的子问题。

二维偏序

比如,我们用cdq分治解决二维偏序问题。我们先在外部把第一维排序好。对于左右区间内部的点对,我们递归地求解。而那些一个在左区间,一个在右区间的点对,我们已经确定左边所有点和右边所有点的第一维都满足偏序要求的大小关系(因为已经排过序了)。所以可以将左右区间分别按第二维排序,然后对于右区间中每个点,统计左区间中符合偏序关系的点的个数,这可以用双指针

地求。以统计
逆序对为例:
ll cdq(int A[], int n)
{
    if (n == 1)
        return 0;
    int mid = n / 2, i = 0, j = mid;
    ll ans = cdq(A, mid) + cdq(A + mid, n - mid);
    sort(A, A + mid, greater<int>()), sort(A + mid, A + n, greater<int>());
    for (; j < n; ++j)
    {
        while (i < mid && A[i] > A[j])
            i++;
        ans += i;
    }
    return ans;
}

这样的话,每层递归的复杂度是

(排序的复杂度),一共
层,所以总复杂度是
。这个复杂度是不如普通的树状数组法的,但是我们注意到,我们其实可以在双指针移动过程中顺便进行排序,那么上层递归就不用再
sort了,复杂度降到
ll cdq(int A[], int n)
{
    static int B[MAXN]; // 暂存归并排序的结果
    if (n == 1)
        return 0;
    int mid = n / 2, i = 0, j = mid, k = 0;
    ll ans = cdq(A, mid) + cdq(A + mid, n - mid);
    for (; j < n; ++j)
    {
        while (i < mid && A[i] > A[j])
            B[k++] = A[i++];
        ans += i, B[k++] = A[j];
    }
    while (i < mid)
        B[k++] = A[i++];
    memcpy(A, B, sizeof(int) * k);
    return ans;
}

可以发现这就是一个归并排序的过程,所以有人说,cdq分治就是拓展了的归并排序,这句话也可以反过来讲,即归并排序是cdq分治的一种简单应用。不过,稍后我们会看到,并不是任何情况下都能省掉sort

三维偏序

求二维偏序的方法已经介绍了很多,但是如果是三维偏序呢?cdq分治同样能解决。我们还是先在外部按第一维排好序。在函数体内,递归地解决左区间和右区间的子问题,然后对于一个在左区间,一个在右区间的点对,我们分别将左右区间按第二维排序,再用双指针+树状数组的方法解决问题。具体地,对于右区间的每个点,我们遍历左区间,把第二维符合要求的点的第三维塞入树状数组,然后查询第三维也符合要求的点的数目。

但是需要注意,如果偏序关系带等号,对于完全相等的元素,需要捆绑起来一起处理(包括二维偏序也要注意这个问题),不然可能会把相等的元素划到左右两个区间去,从而把相等元素的相互贡献漏掉一部分。

以下是LOJ模板题的AC代码片段:

struct Node
{
    int a, b, c, n, id;
    bool operator<(const Node &o) const
    {
        if (a != o.a)
            return a < o.a;
        if (b != o.b)
            return b < o.b;
        return c < o.c;
    }
} A[MAXN];
bool cmp(const Node &x, const Node &y) { return x.b < y.b; }
void cdq(Node A[], int n) // 在外部对A以a为关键词排序
{
    if (n == 1)
    {
        ans[A[0].id] += A[0].n - 1;
        return;
    }
    int mid = n / 2, k = 0, i = 0, j = mid;
    cdq(A, mid), cdq(A + mid, n - mid);
    sort(A, A + mid, cmp), sort(A + mid, A + n, cmp);
    for (; j < n; ++j)
    {
        while (i < mid && A[i].b <= A[j].b)
            update(A[i].c, A[i].n), i++;
        ans[A[j].id] += query(A[j].c);
    }
    for (int k = 0; k < i; ++k)
        update(A[k].c, -A[k].n);
}

这个复杂度是

,也可以用归并排序,但是只能减小常数,无法减少复杂度(因为树状数组提供了一个
)。为了节约时间,清空树状数组不使用
memset,而是原路删除。

cdq套cdq

三维偏序也可以不用树状数组,而是用cdq套cdq的方法解决。在第一层cdq中,我们递归计算左区间和右区间内部的贡献,然后对第一维排序,把排序后左区间内的点打上标记L,右区间的点打上标记R,现在我们可以无视第一维,只需要统计(L,b,c)这样的点对(R,b,c)这样的点的贡献。把数组按第二维排序,然后用cdq计算二维偏序的方法计算,但是只统计左边带有L标记的点对右边带有R标记的点的贡献。

struct Node
{
    int a, b, c, n, id;
    char mark;
    bool operator<(const Node &o) const
    {
        if (a != o.a)
            return a < o.a;
        if (b != o.b)
            return b < o.b;
        return c < o.c;
    }
} A[MAXN];
int ans[MAXN], res[MAXN];
bool cmp(const Node &x, const Node &y) { return x.b < y.b; }
void cdq2(Node A[], int n)
{
    static Node B[MAXN]; // 暂存归并排序的结果
    if (n == 1)
        return;
    int mid = n / 2, i = 0, j = mid, k = 0, cnt = 0;
    cdq2(A, mid), cdq2(A + mid, n - mid);
    for (; j < n; ++j)
    {
        while (i < mid && A[i].c <= A[j].c)
            cnt += A[i].n * (A[i].mark == 'L'), B[k++] = A[i++];
        ans[A[j].id] += cnt * (A[j].mark == 'R'), B[k++] = A[j];
    }
    while (i < mid)
        B[k++] = A[i++];
    memcpy(A, B, sizeof(Node) * k);
}
void cdq(Node A[], int n)
{
    if (n == 1)
        return;
    int mid = n / 2;
    cdq(A, mid), cdq(A + mid, n - mid);
    sort(A, A + n);
    for (int i = 0; i < mid; ++i)
        A[i].mark = 'L';
    for (int i = mid; i < n; ++i)
        A[i].mark = 'R';
    stable_sort(A, A + n, cmp);
    cdq2(A, n);
}

两个细节需要注意:首先,因为cdq2会多次被调用到底,所以不能在递归出口处处理完全相同的点的内部贡献,而要在函数外单独处理;此外,cdq最后用了stable_sort,否则当b1==b2 && a1<=a2时,可能出现标记了L(a1,b1,c1)跑到标记了R(a2,b2,c2)右边的情况,在这里保持稳定是必要的。

这个方法的好处在于很容易推广到四维乃至d维,复杂度为

1D/1D动态规划

所谓1D/1D动态规划,就是dp数组为一维,单次转移为

的一类动态规划问题,朴素的方法复杂度为
,cdq分治可以把某些1D/1D动态规划的复杂度优化到

最经典的1D/1D就是最长上升子序列了,我们来用cdq分治解决这个问题(跟前面类似的思路类似,复杂度也为

):
int dp[MAXN]; // 初始化时用1填满;也可以作为Node的属性,但那样会影响排序时交换的效率
struct Node
{
    int a, id;
} A[MAXN];
bool cmp_a(const Node &a, const Node &b) { return a.a < b.a; }
bool cmp_id(const Node &a, const Node &b) { return a.id < b.id; }
void cdq(Node A[], int n) // 在外部把A对id排序
{
    if (n == 1)
        return;
    int mid = n / 2, i = 0, j = mid, ma = 0;
    cdq(A, mid);
    sort(A, A + mid, cmp_a), sort(A + mid, A + n, cmp_a);
    for (; j < n; ++j)
    {
        while (i < mid && A[i].a < A[j].a)
            ma = max(ma, dp[A[i++].id]);
        dp[A[j].id] = max(dp[A[j].id], ma + 1);
    }
    sort(A + mid, A + n, cmp_id);
    cdq(A + mid, n - mid);
}

这里跟前面有一个不同在于,我们要先处理左区间,再处理左区间对右区间的贡献(这需要通过排序制造单调性),最后(恢复原来的顺序后)处理右区间。因为在前面统计点对的问题中,顺序是不重要的,但在动态规划中,顺序就很重要了——我们在处理右区间内部贡献时,必须要保证左区间对其的贡献已经处理完毕了。因此我们采用这种类似中序遍历的顺序。

对于单纯的LIS来说,这样写是不如二分或者树状数组的做法的。但如果是二维LIS(找最长的满足给定二维偏序关系的子序列),比如这道题,那cdq分治就是个好方法了。我们模仿三维偏序的写法,维护一个树状数组即可。(不过链接那道题相当毒瘤,要同时进行两个dp并且要正着倒着跑两次cdq,甚至还卡long long……)

动态变静态

cdq分治还有一个重要的应用,就是化动态问题为静态问题。一个动态问题,具有修改和查询两种操作,按照时间顺序形成一个序列。于是我们可以把它离线下来,然后在时间轴上cdq分治——递归处理左区间,然后处理左区间的修改对右区间的查询影响,再递归处理右区间。

例如单点修改,区间查询和。这当然可以用树状数组轻松解决,但也可以用cdq分治解决。离线下来,把每个区间查询拆成两个前缀查询,然后就可以跑cdq分治了(以下是代码片段):

struct Node
{
    int op, id, q = INF, ans, x = INF, k; // op:1-修改,2-查询较大的前缀,3-查询较小的前缀
} N[MAXN * 2];
bool cmp_x(const Node &a, const Node &b) { return a.op > b.op || a.op == b.op && a.x < b.x; }
bool cmp_q(const Node &a, const Node &b) { return a.op < b.op || a.op == b.op && a.q < b.q; }
void cdq(Node N[], int n)
{
    if (n == 1)
        return;
    int mid = n / 2;
    cdq(N, mid), cdq(N + mid, n - mid);
    sort(N, N + mid, cmp_x), sort(N + mid, N + n, cmp_q);
    int i = upper_bound(N, N + mid, Node{2}, cmp_x) - N, j = upper_bound(N + mid, N + n, Node{1}, cmp_q) - N, sum = 0;
    for (; j < n; ++j)
    {
        while (i < mid && N[i].x <= N[j].q)
            sum += N[i].k, i++;
        N[j].ans += sum;
    }
}

// 在main函数中...
int cnt = 0;
for (int i = 0; i < m; ++i)
{
    int op = read();
    if (op == 1)
        N[cnt++] = {op, cnt, 0, 0, read(), read()};
    else
    {
        int x = read(), y = read(), id = cnt + 1;
        N[cnt++] = {2, id, x - 1, S[x - 1]};
        N[cnt++] = {3, id, y, S[y]};
    }
}
cdq(N, cnt);
for (int i = 0; i < cnt; ++i)
    if (N[i].op == 3)
        ans[N[i].id] += N[i].ans;
    else if (N[i].op == 2)
        ans[N[i].id] -= N[i].ans;

这里虽然没有什么优势,但在某些更复杂的问题上cdq分治是一种有效的方法。


(总结:本文主要用更差的时间复杂度和代码复杂度解决了二维偏序、LIS、树状数组问题(雾))

Pecco:算法学习笔记(目录)​zhuanlan.zhihu.com
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值