15分钟完全理解二维数点与三维数点(二维偏序/三维偏序)
夜雨_よさめ
编辑于 2024年01月13日 09:25
收录于文集
共1篇

2024.1.12

前置知识

树状数组:一种支持单点修改和区间求和的数组,两者时间复杂度都是O(%5Clog%20n)

离散化:把无限空间中有限的个体映射到有限的空间中

省流

二维数点:对一个维度排序,然后在这个维度上进行扫描线,同时动态地用树状数组维护扫描过的点在另一个维度上的值

三维数点:对一个维度排序,然后进行分治,保证了分治左右两侧相比较时,该维度满足题目要求,随后问题转化为二维数点问题


1. 二维数点(二维偏序)

以上三个问题是等价的,第一种描述最形象,第三种描述最一般化。在以下讨论中,我们以第一种描述作为探讨的背景,并且不妨假设坐标点均为整数。不是整数的情况可以通过离散化转换为整数。

容易想到,但不优秀

二维前缀和:维护一个二维数组s,其中s%5Bx_0%5D%5By_0%5D表示在x%5Cleq%20x_0%2C%5C%3By%5Cleq%20y_0范围内点的数量。这个数组很容易求出,我们假设a%5Bx%5D%5By%5D表示(x%2Cy)这个位置有没有点(1或0),根据简单的容斥原理可以得到s%5Bx%5D%5By%5D%3Ds%5Bx-1%5D%5By%5D%2Bs%5Bx%5D%5By-1%5D-s%5Bx-1%5D%5By-1%5D%2Ba%5Bx%5D%5By%5D

此时想要求一个矩形区域内包含多少个点,想法也是类似的。假设矩形左下角为(a,b),右上角为(c,d),则%5Cmathrm%7Bans%7D%3Ds%5Bc%5D%5Bd%5D-s%5Ba-1%5D%5Bd%5D-s%5Bc%5D%5Bb-1%5D%2Bs%5Ba-1%5D%5Bb-1%5D

通过容斥计算所求区域点的数量

假设平面上有n个点,坐标范围[0,l],共q次询问,由于求了二维前缀和,显然所需要的时空复杂度都是O(l%5E2),不可接受。就算用了离散化,复杂度也是O((n%2Bq)%5E2)

从简单的问题出发

思考一个更简单的情况:假设我们只给出y_1%2C%5C%3By_2,询问%5By_1%2Cy_2%5D范围内有多少个点?我们不需要关心点的横坐标了。也就是说,我们只需要根据点的纵坐标维护一个数组num,其中num%5By_0%5D代表y%3Dy_0这条线上有多少个点,那么询问的答案就是求和%5CSigma_%7Bi%3Dy_1%7D%5E%7By_2%7Dnum%5Bi%5D了。至于这个求和应该怎么高效处理,后文再谈。我们把上述询问的过程记作函数query(y_1%2Cy_2)

绿色部分为query(y1,y2)所求点的范围

我们实际要求的是一个矩形范围内点的数量,依旧假设矩形左下角为(a,b),右上角为(c,d),一个思路是在只考虑x%5Cleq%20c的情况下求一遍query(b%2Cd),然后在只考虑x%5Cleq%20a-1的情况下求一遍query(b%2Cd),两者结果相减就是所求的答案了。我们接下来思考如何做到只考虑x小于等于某值时的情况。

我们可以采用扫描线的思想,在离线状态下求出答案。首先将数组num初始化为0,随后让一条垂直于x轴的线从最左侧出发逐渐向右扫描:

  1. 如果这条扫描线触碰到了平面上存在的点,我们将其加入数组统计的结果。

  2. 设此时扫描线的位置是,由于数组当前只统计了范围内的点,执行完1后如果立刻执行一次操作,就相当于求出了范围内点的数量,这正是我们希望求的东西。

左图:在橙线左侧求一次query(b,d)减去在绿线左侧求一次query(b,d)得到的就是答案;右图:扫描线扫描的点和线段

根据该过程我们发现,对于数组num的操作包含了单点修改和区间求和两种操作,自然我们选择采用树状数组来实现这一点。所以根据以上过程,对于每个左下角为(a_0%2Cb_0),右上角为(c_0%2Cd_0)的询问矩形,在扫描线扫描到x%3Da_0-1x%3Dc_0的时候各进行一次query(b_0%2Cd_0),两者答案相减,就得到了矩形范围内的点的数量。

当然,如果坐标范围很大,确实也是需要离散化的。

流程总结与时间复杂度分析

假设平面上有n个点,坐标范围[0,l],共q次询问

  1. [可选] 对坐标进行离散化,时间复杂度

  2. 把平面上的点按横坐标排序,时间复杂度

  3. 取出每个矩形的左右两边,并记录与这些边所对应的底部和顶部的纵坐标,这对应了在扫描线经过哪些横坐标时需要调用,随后按横坐标给取出的这些边排序,时间复杂度

  4. 扫描线从左向右遍历,如果触碰到了平面上存在的点,就操作一次树状数组单点加1。如果触碰到了矩形的左右两侧的边,就进行一次,本质上是树状数组区间求和。所以如果没有进行离散化,树状数组范围是[0,l],单次操作复杂度为,否则复杂度为,该过程一共进行

  5. 输出原询问的答案

综上所述,如果没有进行离散化,复杂度为O((n%2Bq)%5Clog%20l),如果坐标范围很大,需要离散化,复杂度为O((n%2Bq)%5Clog%20(n%2Bq))。如果问题是求有多少个点对(i%2Cj)满足所给的偏序关系x_i%5Cprec%20x_j,那么O(n)%3DO(q),复杂度可以化简为O(n%5Clog%20l)或者O(n%5Clog%20n)

模板代码

代码块
C++
自动换行
复制代码
#include<iostream>
#include<algorithm>
#define maxn 1000010
#define maxl 10000010
#define offset 1
using namespace std;

struct Point{
    int x,y;
}points[maxn];

struct Rect{
    int a,b,c,d;
}rects[maxn];

struct Segment{
    int x,y1,y2;
    int qid; /* 对应的矩形id */
    bool left; /* 矩形的左侧边 */
}seg[maxn];

struct BIT{ // 树状数组
    int a[maxl];
    int lowbit(int x) {
        return x&-x;
    }
    int psum(int x){
        x += offset; // offset 用于避免纵坐标为0或负的情况
        int ans=0;
        while(x>0){
            ans+=a[x];
            x-=lowbit(x);
        }
        return ans;
    }
    int query(int l, int r){
        if(l>r)return 0;
        return psum(r)-psum(l-1);
    }
    void add(int x, int val){
        x += offset;
        while(x<=maxl){
            a[x]+=val;
            x+=lowbit(x);
        }
    }
}tr;

int ans[maxn];
void solve(int n,int q){
    // Step 1: 把平面上的点按横坐标排序
    sort(points+1,points+n+1,[](Point &a,Point &b){
        return a.x<b.x;
    });

    // Step 2: 把询问的矩形转换为需要query的线段
    int segptr=0;
    for(int i=1;i<=q;++i){
        seg[++segptr]={rects[i].a-1, rects[i].b, rects[i].d, i, true};
        seg[++segptr]={rects[i].c  , rects[i].b, rects[i].d, i, false};
    }
    sort(seg+1,seg+segptr+1,[](Segment &a,Segment &b){
        return a.x<b.x;
    });

    // Step 3: 扫描线遍历
    int pp=1,sp=1;
    while(pp<=n||sp<=segptr){
        if(sp>segptr || (pp<=n && points[pp].x<=seg[sp].x)){
            tr.add(points[pp].y,1);
            ++pp;
        }
        else{
            int num = tr.query(seg[sp].y1,seg[sp].y2);
            if(seg[sp].left) ans[seg[sp].qid]-=num; // 当前矩形的左侧边:排除矩形范围外的点
            else ans[seg[sp].qid]+=num; // 当前边是矩形的右侧边
            ++sp;
        }
    }

    // Step 4: 输出
    for(int i=1;i<=q;++i)cout<<ans[i]<<'\n';
}
复制成功

例题

模板题:洛谷P2163 [SHOI2007] 园丁的烦恼

练习:需要自行思考题目中的什么特征适合建立关系

洛谷P1972 [SDOI2009] HH 的项链

洛谷P8844 [传智杯 #4 初赛] 小卡与落叶

CF1311F Moving Points


2. 三维数点

我们以如下的问题作为以下探讨的背景: 给出三维空间中的n个点p_1%2C%5C%3Bp_2%2C%5C%3B%5Ccdots%2C%5C%3Bp_n,如果点p_i的三个维度的坐标都不大于p_j,我们将其称为(容易证明,这是一种偏序关系),请求出一共有多少组点对(p_i%2Cp_j)满足小。同样假定每个点的属性都是整数。

显然,暴力地将每对点进行比较需要的时间复杂度是O(n%5E2)的,不可接受。

分治法降维打击

能否把三维数点问题变换为二维数点问题,之后我们就可以用二维数点的方法解决?我们把该序列按x坐标从小到大排序之后,y坐标的顺序是乱的;如果按y坐标排序,x坐标的顺序又乱了。如何才能让x和y坐标同时“有序”呢?

采用分治法的思想,把该序列分成前后两部分,假设该方法能够求出一个序列中满足要求的点对(p_i%2Cp_j)的数量,可以对前后两部分分别递归地使用该方法,求出前半部分和后半部分中所求点对的数量。现在我们需要解决的问题就只剩下:如何求出ij一个在前半部分,另一个在后半部分时,满足要求的点对(p_i%2Cp_j)的数量?

我们可以做到的是,先对x坐标进行排序,再进行分治,这样我们就保证了序列被分成前后两部分之后,从前半部分任选一个点,它的x坐标都不大于后半部分的任何一个点,这就相当于从前半部分和后半部分分别选出一个点的时候,已经保证了一个维度的大小关系。我们再将前后两部分分别按y坐标排序,然后类似归并排序的方式,用两个指针去遍历[l,r]的所有点:

  1. 初始化指针指向l,指向mid+1,分别是前后两段序列的开头。初始化一个空的树状数组

  2. 每次比较两指针指向的点的y坐标,取更小的一个,执行如下操作后对应指针自增1:a. 如果这个点来自左半边的序列,树状数组中下标为当前点z坐标的位置单点加1,代表有一个坐标为z的点被加进了树状数组b. 如果这个点来自右半边的序列,查询树状数组中记录了多少个不大于当前点的z坐标,即在树状数组中进行一次[1,z]的区间求和。想一想,只有序列前半部分的点才会被加入树状数组,所以我们保证了查询到的点的x坐标不大于当前点;我们是按y坐标的顺序来遍历的,所以也保证了查询到的点的y坐标不大于当前点。所以区间求和的结果就是我们所统计的答案的一部分

  3. 按照上述流程遍历完后,我们就求出了i在序列前半部分,j在序列后半部分时,满足要求的点对的数量

相信你已经看出来了,上面的流程其实和二维数点问题很像。我们通过分治的方法,把三个维度其中一维的限制抹除了,简化了问题。换句话说,我们根据其中一个维度的值把序列分成两部分,保证了第一个部分中该维度的任意一个值都比第二个部分中的小。如果我们的方法是正确的,那么这两个部分内部的答案可以递归地用相同的方法求出,所以我们就只需要解决两部分之间的答案怎么求,而根据划分序列的方法,我们已经保证了一个部分在其中一个维度上完全不大于另外一个部分,相当于这个维度我们就不需要再管了,成功地把三维问题转变为了二维问题。这种方法其实是被称作CDQ分治的方法的应用之一。

其实上述步骤略微有一点点不严谨的地方,我们将在下面代码部分进行更详细的说明。

流程总结与时间复杂度分析

假设序列中有n个点,坐标范围[0,l]

  1. [可选] 对坐标进行离散化,时间复杂度

  2. 将所有点根据第一个维度进行排序,时间复杂度

  3. 进行分治,一共会有log n层递归:a. 当前分治区间的前半段和后半段各自按第二个维度的值排序。每一层的所有点会参与到第二个维度的排序,时间复杂度b. 一层中的每个点,如果属于分治过程中当前序列左半部分的点,根据第三维度的坐标单点修改树状数组,时间为(取决于是否离散化);如果属于右半部分,根据第三维度的坐标在树状数组中查询坐标不大于自身的点的数量,时间复杂度同上,所以处理一层所有点的总时间复杂度就是在此基础上乘以nc. 一层分治结束后,清空树状数组,时间复杂度所以当前这一步的总时间复杂度为

  4. 输出答案

模板代码与思考

前面的过程中有一些我们没有提及的代码细节,需要进行思考:

  1. 在以下代码中,新开了一个数组,把坐标相同的点放在一起并记录该坐标一共出现了多少次,而只把坐标不同的点交给分治算法去处理。这样做的好处是什么?

  2. 为什么在排序第一个维度的时候,也需要在第一个维度的值相同时,把第二、第三个维度上值更小的点排在前面

代码块
C++
自动换行
复制代码
#include<iostream>
#include<algorithm>
#include<stack>
#define maxn 100010
#define maxl 100010
#define offset 0
using namespace std;

struct Point{
    int a,b,c,num;
}in[maxn],points[maxn];

struct BIT{ // 树状数组
    int bit[maxl];
    stack<pair<int,int>>hist;
    int lowbit(int x) {
        return x&-x;
    }
    int psum(int x){
        x += offset;
        int ans=0;
        while(x>0){
            ans+=bit[x];
            x-=lowbit(x);
        }
        return ans;
    }
    int query(int l, int r){
        if(l>r)return 0;
        return psum(r)-psum(l-1);
    }
    void add(int x, int val, bool record){
        if(record)hist.push({x,val});
        x += offset;
        while(x<maxl){
            bit[x]+=val;
            x+=lowbit(x);
        }
    }
    void clear(){
        while(!hist.empty()){
            add(hist.top().first,-hist.top().second,false);
            hist.pop();
        }
    }
}tr;

int ans=0;
void divide(int l,int r){
    if(l>=r)return;
    int mid=(l+r)>>1;
    divide(l,mid);
    divide(mid+1,r);

    // 给分成两段的序列分别在第二维上排序
    sort(points+l, points+mid+1, [](Point &a, Point &b){
        if(a.b==b.b)return a.c<b.c;
        return a.b<b.b;
    });
    sort(points+mid+1, points+r+1, [](Point &a, Point &b){
        if(a.b==b.b)return a.c<b.c;
        return a.b<b.b;
    });

    // 设现在[l,mid]范围内的点为a,[mid+1,r]范围内的点为b,则经过排序后所有a都满足a.x<=b.x
    // 现在我们要找出满足题意的点对(i,j),由于[l,mid]范围和[mid+1,r]范围内的(i,j)都在分治过程中找完了
    // 所以现在我们只关心i在[l,mid],j在[mid+1,r]范围内的点对,我们需要按y从小到大来遍历这些点
    int i=l,j=mid+1;
    while(j<=r){
        if(i<=mid&&points[i].b<=points[j].b){
            tr.add(points[i].c, points[i].num, true);
            ++i;
        }
        else{
            ans+=tr.query(1,points[j].c)*points[j].num;
            ++j;
        }
    }
    tr.clear();
}

void solve(int n){
    // 给第一个维度进行排序
    sort(in+1, in+n+1, [](Point &a, Point &b){
        if(a.a==b.a){
            if(a.b==b.b)return a.c<b.c;
            return a.b<b.b;
        }
        return a.a<b.a;
    });
    int ptr=0;

    // 仅筛选出坐标不同的点,并求出坐标相同的点对答案的贡献
    for(int i=1;i<=n;++i){
        if(in[i].a!=points[ptr].a||in[i].b!=points[ptr].b||in[i].c!=points[ptr].c){
            ans+=points[ptr].num*(points[ptr].num-1);
            points[++ptr]=in[i];
            points[ptr].num=1;
        }
        else ++points[ptr].num;
    }
    ans+=points[ptr].num*(points[ptr].num-1);
    divide(1,ptr);
}
复制成功

问题解答:

  1. 假设有两个坐标相同的点A, B,如果不把他们放在一起,分治时可能会出现A在序列前半段,B在序列后半段的情况。本来由于应该统计2次答案,结果按照分治的方法,前半段的点只被放入树状数组而不查询,后半段的点只会查询而不放入数组,导致答案只统计了一次

  2. 和上一个问题的答案类似,假设有两个x坐标相同而y坐标不同的点A, B,设,此时如果不对第二维进行排序,可能会导致A被放在序列前半段。由于,所以点对(B,A)有可能是答案之一,而(A,B)绝无可能是答案,把A放在序列前半段,就不会把(B,A)的情况统计进答案中

例题

模板题:洛谷P3810 【模板】三维偏序(陌上花开)

练习:洛谷P3157 [CQOI2011] 动态逆序对

为什么单个变量和部分公式没有用LaTeX表示?

——“你已经插入159张图片了,目前最多支持插入100张哦~”