CDQ分治和多维偏序问题

尝试一下写个专题吧。

目录

    • 1. CDQ分治介绍
    • 2. 逆序对问题
    • 3. 二维偏序问题
    • 4. 三维偏序问题
    • 5. CDQ分治的理解
    • 6. CDQ套CDQ解决三维偏序
    • 7. 例题

1. CDQ分治介绍

CDQ分治与其说是一种算法,不如说是一种思想。分治算法往往可以分成三步:

  1. 分解:将原问题分为若干个子问题
  2. 解决:递归处理分段后的子问题
  3. 合并:通过子问题的解求出原问题的解

在我看来,CDQ分治与一般的分治算法的区别关键在于合并这一步上,也就是merge操作。常见的分治算法(比如归并排序、线段树的merge操作等等)往往只关注合并后的结果,但CDQ分治则会同时关注结果和过程两方面,并在合并过程中计算出答案。

2. 逆序对问题

一个经典的问题是在归并排序过程中计算出逆序对的个数:

洛谷P1908 逆序对

按顺序给出n个数,求逆序对的数目(逆序对就是ai>aj且i

归并排序的合并操作过程中,假设左右两段当前头指针分别是j和k,那么需要不断比较a[j]和a[k]的大小关系,并将较小的那个数加入临时队列。

计算逆序数的方法是,每当加入了一个右段的数,计算左段有几个数能和当前的a[k]构成逆序对。即ans+=mid-j+1。

归并排序中,任意一对数“分别出现在两个子问题中”的次数恰好为1,于是可以保证所求的结果不重复不遗漏。(我觉得这点在所有CDQ分治的算法中都很重要,但是好像很少被提到)

逆序对参考代码:

#include
using namespace std;
typedef long long ll;

const int N=5e5+5;

int T,n,m,a[N],b[N];

ll ans;

void CDQ(int l,int r)
{
    if (l==r) return;
    int mid=(l+r)>>1;
    CDQ(l,mid);
    CDQ(mid+1,r);
    for (int i=l,j=l,k=mid+1;i<=r;i++)
    {
        if (k>r || j<=mid && a[j]<=a[k])
            b[i]=a[j++];
        else
        {
            b[i]=a[k++];
            //计算左段对a[k]的贡献
            ans+=mid-j+1;
        }
    }
    for (int i=l;i<=r;i++) a[i]=b[i];
}
int main()
{
    cin>>n;
    for (int i=1;i<=n;i++) scanf("%d",&a[i]);
    CDQ(1,n);
    printf("%lld\n",ans);
    return 0;
}

3. 二维偏序问题

说到CDQ分治就一定会讲到多维偏序的问题。这里先从一个比较简单的二维偏序问题入手。

hdu P1541.Stars

给出n个点(x,y),一个点的level为满足xi<=x且yi<=y的点(xi,yi)的个数(除自身之外),分别求出level为0~n-1的点的个数。

这道题当然可以用树状数组来解决:先把所有点根据x从小到大排序,然后从前往后扫一遍,通过求出区间[0,y]的和计算出符合条件的个数,并根据y的值修改树状数组。

不过重点还是讲CDQ分治,因为用CDQ分治的思想同样也可以解决这个问题。(CDQ分治的一个特点就是可以代替树状数组或者树套树之类的数据结构)

最简单的理解方法就是把二维偏序问题和求逆序对的问题放在一起比较一下。

逆序对的定义是满足iaj的一对数,其中就包含了两个约束条件:位置和权值。于是可以发现,逆序对问题本身就是一个二维偏序问题,只不过位置这一条件是根据输入的顺序隐性产生的,并且在输入时就保证了有序性。略微不同的是,上面的这道二维偏序问题直接显性输入了两个约束条件,我们也需要通过排序来维护第一维的有序性,而计算结果的方式也需要作相应的调整。

于是把逆序对的代码修改一下,就可以得到一个CDQ分治的的解法了。

hdu P1541.Stars

#include
using namespace std;
typedef long long ll;

const int N=2e5+5;

int T,n,m;

int ans[N],cnt[N];

struct Point{
    int x,y,id;
    bool operator <(Point &t)
    {
        if (x!=t.x)
            return x<t.x;
        else return y<t.y;
    }
}a[N],b[N];

void CDQ(int l,int r)
{
    if (l==r) return;
    int mid=(l+r)>>1;
    CDQ(l,mid);
    CDQ(mid+1,r);
    for (int i=l,j=l,k=mid+1;i<=r;i++)
    {
        if (k>r || j<=mid && a[j].y<=a[k].y)
            b[i]=a[j++];
        else
        {
            ans[a[k].id]+=j-l;
            b[i]=a[k++];
        }
    }
    for (int i=l;i<=r;i++) a[i]=b[i];
}
int main()
{
    //多组数据
    while(scanf("%d",&n)!=-1)
    {
        for (int i=1;i<=n;i++)
        {
            scanf("%d%d",&a[i].x,&a[i].y);
            a[i].id=i;
        }
        memset(cnt,0,sizeof(cnt));
        memset(ans,0,sizeof(ans));
        sort(a+1,a+1+n);
        CDQ(1,n);
        for (int i=1;i<=n;i++) cnt[ans[a[i].id]]++;
        for (int i=0;i<=n-1;i++) printf("%d\n",cnt[i]);
    }
    return 0;
}

值得注意的当某一维数据相同时(即a[i].x=a[j].x或a[i].y=a[j].y)的处理方式。需要根据题意决定该点是否能产生贡献。这道题不存在重合的点,但有时需要对相同的数据点做一些预处理来得到预期的结果(在后面的三维偏序题里会有体现)。

之所以要用CDQ来解决一道树状数组可以秒切的题主要还是为了理解CDQ分治的本质就是降维。多维偏序问题中的“维”可以抽象理解为一种约束条件,这种约束条件有时是显性的,有时是隐含在题意中的,无论排序、分治还是树状数组,其实都是在可以接受的复杂度下处理某一维数据的方式。

4. 三维偏序问题

luoguP3810三维偏序(陌上花开)

有n个元素,第i个元素有ai,bi,ci三个属性,f(i)表示满足aj<=ai且bj<=bi且cj<=ci且j!=i的j的数量
求f(i)=d的数量。

解决三维偏序问题应该是CDQ分治最经典的应用了。三维偏序问题解决方法很多,可以通过bitset、树套树等算法来解决,但是这些做法缺点就是难写难调,代码量巨大。(当然这些算法还是要尽量学一下)

所以我们通常使用CDQ分治+树状数组的解法,比较容易理解,而且能减少代码量,优化常数。

粗略地说来:

第一维:排序

第二维:CDQ分治

第三维:树状数组

三维偏序(陌上花开)

#include
using namespace std;
typedef long long ll;

const int N=1e5+5,SZ=2e5+5;

int n,k,ans[N],cnt[N];

struct BIT{
    int tr[SZ];
    int lowbit(int x){ return x&(-x); }
    void clr(int x){
        while(x<SZ)
        {
            tr[x]=0;
            x+=lowbit(x);
        }
    }
    void add(int x,int v){
        while(x<SZ)
        {
            tr[x]+=v;
            x+=lowbit(x);
        }
    }
    int sum(int x){
        int sum=0;
        while(x>0)
        {
            sum+=tr[x];
            x-=lowbit(x);
        }
        return sum;
    }
}bit;

struct Node{
    int id,x,y,z;
    bool operator==(Node &t){
        return x==t.x && y==t.y && z==t.z;
    }
}a[N],b[N],tmp[N];

bool cmp(Node &a,Node &b){
    if (a.x==b.x && a.y==b.y)
        return a.z<b.z;
    else if(a.x==b.x) return a.y<b.y;
    else return a.x<b.x;
}

void CDQ(int l,int r)
{
    if (l==r) return;
    int mid=(l+r)>>1;
    CDQ(l,mid);
    CDQ(mid+1,r);
    for (int i=l,j=l,k=mid+1;i<=r;i++)
    {
        if (k>r || j<=mid && a[j].y<=a[k].y)
        {
            bit.add(a[j].z,1);
            b[i]=a[j++];
        }
        else
        {
            ans[a[k].id]+=bit.sum(a[k].z);
            b[i]=a[k++];
        }
    }
    for (int i=l;i<=mid;i++) bit.clr(a[i].z);
    for (int i=l;i<=r;i++) a[i]=b[i];
}

int main()
{
    cin>>n>>k;
    for (int i=1;i<=n;i++)
    {
        scanf("%d%d%d",&a[i].x,&a[i].y,&a[i].z);
        a[i].id=i;
    }
    sort(a+1,a+1+n,cmp);
    int same=0;
    for (int i=n;i>=2;i--)
    {
        if (a[i]==a[i-1]) ans[a[i-1].id]+=++same;
        else same=0;
    }
    CDQ(1,n);
    for (int i=1;i<=n;i++) cnt[ans[i]]++;
    for (int i=0;i<n;i++) printf("%d\n",cnt[i]);
    return 0;
}

因为在处理相同数据点的时候CDQ其实不是非常灵活(分治过程中无法让两个元素互相产生贡献),所以需要预处理数据相同的情况。

我觉得CDQ分治的代码是比文字更容易理解的。与其先搞懂CDQ分治的思想,不如先看看现成的代码。

但是在自己写的时候会发现因为分治算法这种递归的写法,导致程序比较难调试,很可能因为一点细节要改很久。这就需要我们对CDQ分治有比较深刻的理解。

5. CDQ分治的理解

嗯。。这部分应该是重点,其实网上关于CDQ分治的代码很多,但是理解性的讨论并不是很多。我尽量把自己的理解讲清楚。

之前有说到,CDQ分治的核心思想仍然是在分治过程中,不断处理左段的元素对右段每一个元素的贡献,且因为任意两个元素“分别处于左段和右段”的次数只有一次,保证了统计结果能够不重复不遗漏。 但是这个过程具体是怎么实现,或者说具体展开的呢?

我们仍然以最朴素的想法为起点,我们完全可以通过一个O(n^2)的算法,暴力计算出“任意两个元素之间的贡献”。然后想想怎么优化呢?

首先明确一下,在分治过程中,我们把第一次递归的区间[l,mid]称为左段,把第二次递归区间[mid+1,r]称为右段。把x、y、z这三个属性分别称为第一维、第二维和第三维(实际上这三维的顺序是任意的)。

“分段”是基于对第一维的排序,通过分治算法,我们相当于已经对原问题做了第一次降维,更具体地说:

第一次降维:

原问题:“任意两个元素之间的贡献”

降维成的子问题:“左段元素对右段每一个元素的贡献”

那么假设当前左段和右段的元素个数都为k,我们要计算“左段元素对右段每个元素的贡献”。用朴素的思想解决这个子问题,发现仍然是O(k^2)的复杂度,时间复杂度并没有自动降下来。

但是既然已经用了分治的思想,那自然可以在分治的过程中对第二维排序。

于是此时的“左段”和“右段”具备了两个很好的性质:

  1. 在各自区间上对第二维保持有序(属性y有序)
  2. 分别从左段和右段取出任意两个元素,在第一维上同样保持有序(因为此时左右两段还是各自独立的,没有发生过任何两段之间的交换)

这是第二次降维,因为有了以上的性质后,产生的结果是:

1. 第二维在两段上各自有序

2. 完全不需要对第一维再作考虑

所以此时问题其实已经从三维偏序转化为另一个二维偏序问题了,唯一不同的是要记住只有“左段”的元素才能对“右段”产生贡献。

至于第三维,最方便理解的解决方法是树状数组。

从代码上来理解

void CDQ(int l,int r)
{
    if (l==r) return;
    int mid=(l+r)>>1;
    CDQ(l,mid);
    CDQ(mid+1,r);
    //此时左右两段已满足以上性质
    //下面所有的内容都是为了计算“左段元素对右段每一个元素的贡献”
    for (int i=l,j=l,k=mid+1;i<=r;i++)
    {
        if (k>r || j<=mid && a[j].y<=a[k].y)
        {
            bit.add(a[j].z,1);
            b[i]=a[j++];
        }
        else
        {
            ans[a[k].id]+=bit.sum(a[k].z);
            b[i]=a[k++];
        }
    }
    for (int i=l;i<=mid;i++) bit.clr(a[i].z);
    for (int i=l;i<=r;i++) a[i]=b[i];
}

6. CDQ套CDQ解决三维偏序

但其实CDQ分治是可以不断嵌套的。无论是树状数组还是再套一层CDQ,本质都是为了计算“左段元素对右段每一个元素的贡献”。

只需要在进入第二层CDQ前给每个元素加个tag,记录它属于左段还是右段即可(因为用树状数组时不需要再经历“打乱顺序”这一步,所以不需要用tag来记录,并没有本质上的区别)。理论上也可以用类似的方法一直这么嵌套下去解决更高维的问题。

CDQ套CDQ 三维偏序:

#include
using namespace std;
typedef long long ll;

const int N=1e5+5,SZ=2e5+5;

struct node{
    int id,x,y,z,tag;
    bool operator==(node &t){
        return x==t.x && y==t.y && z==t.z;
    }
}a[N],b[N],c[N];

bool cmp(node &a,node &b){
    if (a.x==b.x && a.y==b.y)
        return a.z<b.z;
    else if(a.x==b.x) return a.y<b.y;
    else return a.x<b.x;
}

int n,k,ans[N],cnt[N];

void CDQ2(int l,int r)
{
    if (l>=r) return;
    int mid=(l+r)>>1;
    CDQ2(l,mid);
    CDQ2(mid+1,r);
    for (int i=l,j=l,k=mid+1,cnt=0;i<=r;i++)
    {
        if ((k>r || b[j].z<=b[k].z) && j<=mid)
        {
            c[i]=b[j++];
            cnt+=c[i].tag;
        }
        else
        {
            if (b[k].tag==0) ans[b[k].id]+=cnt;
            c[i]=b[k++];
        }
    }
    for (int i=l;i<=r;i++) b[i]=c[i];
}

void CDQ(int l,int r)
{
    if (l>=r) return;
    int mid=(l+r)>>1;
    CDQ(l,mid);
    CDQ(mid+1,r);
    for (int i=l,j=l,k=mid+1;i<=r;i++)
    {
        if ((k>r || a[j].y<=a[k].y) && j<=mid)
            b[i]=a[j++],b[i].tag=1;
        else
            b[i]=a[k++],b[i].tag=0;
    }
    for (int i=l;i<=r;i++) a[i]=b[i];
    CDQ2(l,r);
}

int main()
{
    cin>>n>>k;
    for (int i=1;i<=n;i++)
    {
        scanf("%d%d%d",&a[i].x,&a[i].y,&a[i].z);
        a[i].id=i;
    }
    sort(a+1,a+1+n,cmp);
    int same=0;
    for (int i=n;i>=2;i--)
    {
        if (a[i]==a[i-1]) ans[a[i-1].id]+=++same;
        else same=0;
    }
    CDQ(1,n);
    for (int i=1;i<=n;i++) cnt[ans[i]]++;
    for (int i=0;i<n;i++) printf("%d\n",cnt[i]);
    return 0;
}

7. 例题

EOJ 2020"游族杯" C. Coronavirus Battle

虽然这题可以靠5s时限和随机数据用各种方法瞎搞过去,但回过头来看其实也是比较经典的三维偏序问题。只不过树状数组要维护的不是区间和而是区间最大值,而且对分治的顺序是有要求的。

#include
using namespace std;
typedef long long ll;
typedef unsigned long long ull;

const int N=1e5+5,SZ=1e5+5;

int n,k,maxans;

ull k1,k2;

struct BIT{
    int tr[SZ];
    BIT(){
        for (int i=0;i<SZ;i++) tr[i]=-1;
    }
    int lowbit(int x){ return x&(-x); }
    void clr(int x){
        while(x<SZ)
        {
            tr[x]=-1;
            x+=lowbit(x);
        }
    }
    void add(int v,int x){
        while(x<SZ)
        {
            tr[x]=max(tr[x],v);
            x+=lowbit(x);
        }
    }
    int getMax(int x){
        int mx=-1;
        while(x>0)
        {
            mx=max(mx,tr[x]);
            x-=lowbit(x);
        }
        return mx;
    }
}bit;

struct node{
    int id,ans;
    ull x,y,z;
    bool operator<(node &t){ return y<t.y; }
}A[N],tmp[N];
bool cmp(node &a,node &b){ return a.x<b.x; }
bool cmpx(node &a,node &b){ return a.x<b.x; }
bool cmpy(node &a,node &b){ return a.y<b.y; }
bool cmpz(node &a,node &b){ return a.z<b.z; }
bool cmp_id(node &a,node &b){ return a.id<b.id; }

void CDQ(int l,int r)
{
    if (l==r) return;
    int mid=(l+r)>>1;
    CDQ(l,mid);
    sort(A+mid+1,A+r+1,cmpy);
    int j=l;
    for (int i=mid+1;i<=r;i++)
    {
        while(j!=mid+1 && A[j].y<A[i].y)
        {
            bit.add(A[j].ans,A[j].z);
            j++;
        }
        A[i].ans=max(A[i].ans,bit.getMax(A[i].z)+1);
    }
    for (int i=l;i<=mid;i++) bit.clr(A[i].z);
    sort(A+mid+1,A+r+1,cmpx);
    CDQ(mid+1,r);
    merge(A+l,A+mid+1,A+mid+1,A+r+1,tmp+l);
    memcpy(A+l,tmp+l,sizeof(A[0])*(r-l+1));
}

ull CoronavirusBeats() {
    ull k3 = k1, k4 = k2;
    k1 = k4;
    k3 ^= k3 << 23;
    k2 = k3 ^ k4 ^ (k3 >> 17) ^ (k4 >> 26);
    return k2 + k4;
}

void dataGenerator()
{
    for (int i=1;i<=n;i++)
    {
        A[i].x=CoronavirusBeats();
        A[i].y=CoronavirusBeats();
        A[i].z=CoronavirusBeats();
        A[i].id=i;
    }
    sort(A+1,A+1+n,cmpx);
    for (int i=1;i<=n;i++) A[i].x=i;
    sort(A+1,A+1+n,cmpy);
    for (int i=1;i<=n;i++) A[i].y=i;
    sort(A+1,A+1+n,cmpz);
    for (int i=1;i<=n;i++) A[i].z=i;
}
int main()
{
    cin>>n>>k1>>k2;
    dataGenerator();
    sort(A+1,A+1+n,cmp);
    CDQ(1,n);
    sort(A+1,A+1+n,cmp_id);
    for (int i=1;i<=n;i++) maxans=max(maxans,A[i].ans);
    printf("%d\n",maxans+1);
    for (int i=1;i<=n;i++) printf("%d ",A[i].ans);
    return 0;
}

注意到CDQ分治部分的代码,简化一下大概是这样(calculate()表示计算左段对右段的贡献)

void CDQ(int l,int r)
{
    if (l==r) return;
    int mid=(l+r)>>1;
    CDQ(l,mid);
    calculate();
    CDQ(mid+1,r);
    merge();
}

也就是先递归左段,计算好贡献之后再开始递归右段,最后把两段合并。这种顺序可以确保答案是从左到右依次确定的(类比二叉树的中序遍历,对叶子结点的访问也是从左至右的),保证用于计算的左段元素先确定自身的值后才会对其他元素产生贡献。

你可能感兴趣的:(笔记)