引入
偏序问题: 这种类型的题目通常会告诉我们n个数组,其中每个元素有不同属性,询问通常是回答有多少元素A满足元素B的属性条件。
如一个元素有三种属性:a,b,c,对于元素B,求满足条件的元素A(A.a<B.a,A.b>B.b,A.c<B.c)的个数,这就是一道典型的三维偏序问题。
而解决偏序问题通常有以下方法:排序,数据结构(树状数组,线段树,平衡树),cdq分治。
在处理这类问题时我们通常会通过排序来处理其中一个属性(最简易),那剩下几个属性呢?容易想到,每一个都可以用一层数据结构维护。那么只有一个属性时我们用一颗树维护;两个属性,树套树?;三个属性,树套树套树?四个属性......当大量数据结构堆在一起,代码量就十分爆炸,而且bug将无处不在。这时我们就要用到CDQ分治,它是我们处理多维偏序问题的重要武器。它的优势在于可以顶替复杂的高级数据结构,而且常数比较小;缺点在于必须离线操作。
基本思想
CDQ分治的基本思想十分简单。如下:
- 我们要解决一系列问题,这些问题一般包含修改和查询操作,可以把这些问题排成一个序列,用一个区间[L,R]表示。
- 分。递归处理左边区间[L,M]和右边区间[M+1,R]的问题。
- 治。合并两个子问题,同时考虑到[L,M]内的修改对[M+1,R]内的查询产生的影响。即,用左边的子问题帮助解决右边的子问题。
这就是CDQ分治的基本思想。和普通分治不同的地方在于,普通分治在合并两个子问题的过程中,[L,M]内的问题不会对[M+1,R]内的问题产生影响。(转载__stdcall的博客)
二维偏序问题
给定N个有序对(a,b),求对于每个(a,b),满足a2<a且b2<b的有序对(a2,b2)有多少个(条件随题目而变)。
模板题:普通逆序对
这道题大家应该非常熟悉,是归并排序的应用(不会的请先学习),但这道题也是CDQ分治最基础的题。所以说,我们其实很早就已经学会了最简单的CDQ分治,只是我们不理解这个概念。
重新分析这道题,为什么说求逆序对是偏序问题。对于一个数A,和它组成逆序对的数B满足:B小于A,B在A后面。我们发现,这两句话不就是描述了两个属性条件吗。属性一:数值大小。属性二:在整个序列里的位置(下标)。第二个属性十分隐蔽,因此初学者很难发现它。
回忆一下归并排序求逆序对的过程,我们在合并两个子区间的时候,要考虑到左边区间的对右边区间的影响。即,我们每次从右边区间的有序序列中取出一个元素的时候,要把“以这个元素结尾的逆序对的个数”加上“左边区间有多少个元素比他大”。这是一个典型的CDQ分治的过程。那么对于二维偏序问题,我们在拿到所有有序对(a,b)的时候,先把a元素从小到大排序。这时候问题就变成了“求b元素的顺/逆序对”(具体求什么看题目),因为a元素已经有序,可以忽略a元素带来的影响,和“求逆序对”的问题是一样的。
代码:
#include<cstdio>
int s[100005],t[100005],n;
long long tot;
void merge(int l,int m,int r)
{
int p=l,q=m+1,k=0;
while(p<=m&&q<=r)
{
if(s[p]<=s[q])
t[k++]=s[p++];
else
{
t[k++]=s[q++];//因为左右区间分别有序,并且。p永远大于q
tot+=m-p+1;//所以当s[p]>s[q]时,s[p]后面所有左区间的数>s[q],都为逆序对
}
}
while(p<=m)
t[k++]=s[p++];
while(q<=r)
t[k++]=s[q++];
for(int i=l;i<=r;i++)
s[i]=t[i-l];
}
void msort(int l,int r)
{
int mid;
if(l<r)
{
mid=(l+r)/2;
msort(l,mid);//处理左区间
msort(mid+1,r);//处理右区间
merge(l,mid,r);
}
}
int main()
{
scanf("%d",&n);
for(int i=0;i<n;i++)
scanf("%d",&s[i]);
msort(0,n-1);
printf("%lld",tot);
}
三维偏序问题(重点)
给定N个有序三元组(a,b,c),求对于每个三元组(a,b,c),有多少个三元组(a2,b2,c2)满足a2<a且b2<b且c2<c(条件随题目而变)。
类似二维偏序问题,先按照a元素从小到大排序,忽略a元素的影响。然后CDQ分治,按照b元素从小到大的顺序进行归并操作。但是这时候没办法像 求逆序对 一样简单地统计个数了,c元素如何处理呢?
这时候比较好的方案就是借助树状数组维护c元素(一层数据结构可以接受)。每次从右边的序列中取出三元组(a,b,c)时,对树状数组查询c值小于(a,b,c)的三元组有多少个;每次从左边序列取出三元组(a,b,c)的时候,根据c值在树状数组中进行修改。
模板题:动态逆序对
题意:给1到n的一个排列,按照某种顺序依次删除m个元素,你的任务是在每次删除一个元素之前统计整个序列的逆序对数。
同样的题,怎么就三维偏序了呢?由于序列是动态的,所以每个数就多了一个加入时间(题目是删除,我们就反过来离线处理),这就是它的第三维元素。于是对于每一个数A有三个元素(下标x,数值y,加入时间z)。这道题就变成了:对于每一个数A,求满足(B.x>A.x,B.y<A.y,B.z>A.z)的数B的个数。这是一个典型的三维偏序问题。
代码:
#include<cstdio>
#include<cstring>
#include<algorithm>
using namespace std;
#define N 3000005
struct mzls
{
int x,y,z;//下标,数值,加入时间。
}d[N];
int t[N],n,k,tot,num[N],a[N],a1[N],a2[N];
long long ans[N];
bool f1[N];
inline bool cmp(mzls a,mzls b)
{
return a.z<b.z;
}
inline bool cmp1(mzls a,mzls b)
{
return a.y>b.y;
}
inline bool cmp2(mzls a,mzls b)
{
return a.y<b.y;
}
inline void modify(int x,int v)
{
for(int i=x;i<=n;i+=i&(-i))
t[i]+=v;
}
inline int query(int x)
{
int sum=0;
for(int i=x;i>0;i-=i&(-i))
sum+=t[i];
return sum;
}
inline void cdq(int l,int r)
{
if(l==r)
return;
int mid=(l+r)>>1;
cdq(l,mid);
cdq(mid+1,r);
sort(d+l,d+mid+1,cmp1);//手动排序
sort(d+mid+1,d+r+1,cmp1);
int j=l;
for(int i=mid+1;i<=r;i++)//记录数值大的,下标小的
{
while(j<=mid&&d[j].y>d[i].y)
modify(d[j].x,1),j++;
ans[d[i].z]+=query(d[i].x);
}
for(int i=l;i<j;i++)//树状数组恢复
modify(d[i].x,-1);
sort(d+l,d+mid+1,cmp2);//注意排序的不同
sort(d+mid+1,d+r+1,cmp2);
j=l;
for(int i=mid+1;i<=r;i++)//记录数值小的,下标大的
{
while(j<=mid&&d[j].y<d[i].y)
modify(d[j].x,1),j++;
ans[d[i].z]+=query(n)-query(d[i].x-1);
}
for(int i=l;i<j;i++)
modify(d[i].x,-1);
}
int main()//主函数注意离线反向操作
{
scanf("%d%d",&n,&k);
for(int i=1;i<=n;i++)
{
scanf("%d",&a[i]);
f1[a[i]]=1;//是否被删除
a1[a[i]]=i;//记录下标
}
for(int i=1;i<=k;i++)
{
scanf("%d",&a2[i]);
f1[a2[i]]=0;
}
for(int i=1;i<=n;i++)
if(f1[a[i]])//没有被删除的优先加入
{
tot++;
d[tot].x=a1[a[i]];
d[tot].y=a[i];
d[tot].z=tot;
}
for(int i=k;i>=1;i--)//删除=反过来加入
{
tot++;
d[tot].x=a1[a2[i]];
d[tot].y=a2[i];
d[tot].z=tot;
}
sort(d+1,d+n+1,cmp);
cdq(1,tot);
sort(d+1,d+n+1,cmp);
for(int i=2;i<=tot;i++)//之前的答案需要累加
ans[i]+=ans[i-1];
for(int i=n;i>=n-k+1;i--)//注意不能到1,因为可能没有删除完
printf("%lld\n",ans[i]);
}
简单说几句:
- 以上方法的效率基本是每上一维多一个log,但是空间基本都是nlog(n)的数据大的话到五维可能就超时了。
- 第二句我想想。
- 关于不同方案间的优劣的话,排序是必须的,直接调用c++的快排即可,数据结构之间树状数组是最值得使用的,常数小而且代码短,cdq分治在时间方面要慢于树状数组,但是数据结构通常只能维护一维(大佬绕道),所以通常考虑使用排序+cdq分治+树状数组的方案,简单而且高效。