什么是偏序和拟序?
偏序关系:
设 是集合 上的一个二元关系,若 满足:
- 自反性:对任意 ,有 ;
- 反对称性(即反对称关系):对任意 ,若 ,且 ,则 ;
- 传递性:对任意 ,若 ,且 ,则 。
那么,关系 就是一种偏序关系。
拟序关系:指没有自反性的偏序关系。
举个例子:“”就是一种偏序关系,因为它具有自反性(),具有反对称性(),也具有传递性(),而 则不是偏序关系,它是一种拟序关系。
前置知识:分治。
二维偏序
二维偏序,即在两个序列 和 中,同时满足两种给定的偏序/拟序关系 的数对 的数量,即满足 , 的方案总数。
例题
给定一个长度为 的整数数列,请你计算数列中的逆序对的数量。
逆序对的定义如下:对于数列的第 个和第 个元素,如果满足 且 ,则其为一个逆序对;否则不是。
做法 :数据结构维护
思路
求解方式非常简单,即从后往前遍历 中的每个元素,求其后面()共有多少个 满足 。
实际上,求 的数字总数,那么就考虑采用正确的更新顺序,先保证一维满足偏序/拟序关系,再通过数据结构查询另一位偏序/拟序关系,发现数组下标的偏序/拟序关系很容易维护,那么,考虑从前向后遍历,保证所有数组下标都是逆序的,然后另一位权值 的关系,只要靠树状数组求 的元素的前缀和就可以了,或者开值域线段树每次求 的权值和即可。
总共 次修改, 次查询,总的时间复杂度为 。
注意,如果数据范围较大,需要先离散化再进行计算,因为有可能开不下这么大的数组,下面的代码以树状数组为例:
参考代码
#include <bits/stdc++.h>
#define int long long
#define lowbit(i) (i&-i)
using namespace std;
const int N = 2e5+7;
struct node {
int tr[N];
void update(int x,int y) {
for(int i=x;i<N;i+=lowbit(i))
tr[i]+=y;
}
int query(int x) {
int res=0;
for(int i=x;i>0;i-=lowbit(i))
res+=tr[i];
return res;
}
} tr;
int a[N];
signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr),
cout.tie(nullptr);
int n,res=0;
cin>>n;
vector<int> vc;
for(int i=1;i<=n;++i) cin>>a[i],vc.push_back(a[i]);
sort(vc.begin(),vc.end()); //先离散化,防止值域太大
vc.erase(unique(vc.begin(),vc.end()),vc.end());
for(int i=1;i<=n;++i) a[i]=lower_bound(vc.begin(),vc.end(),a[i])-vc.begin()+1;
for(int i=n;i>=1;--i) {
tr.update(a[i],1); //树状数组的查询和更新
res+=tr.query(a[i]-1);
} cout<<res<<'\n';
return 0;
}
做法 :分治
思路
这里就要用到分治的思想了,不了解分治的同学请先学习一下分治,学习前置知识。
对区间进行分治,然后进行归并排序,在合并区间时统计逆序对数量。
每次先取区间中点 ,然后分别对区间 和区间 分别进行归并排序,进行“分”的操作。
排序结束后,就要开始进行“治”的操作了,由于左边区间和右边区间的数组下标的偏序/拟序关系很容易确定,左边区间的数组下标 都在 之间,右区间的数组下标 都在 之间,所以一位/拟序关系已经解决,即 ,那么另一位偏序/拟序关系可以在双指针合并时进行统计,即在右区间的某个元素 被归并进区间时,查询左边还有多少个 没有被归并进区间,由于归并排序按照从小到大依次放入新区间,所以可以保证 ,那么,我们就可以保证 且 的所求偏序/拟序关系的数对数量了。
在这种做法当中,不需要进行离散化。
参考代码
#include <bits/stdc++.h>
#define int long long
#define lowbit(i) (i&-i)
using namespace std;
const int N = 2e5+7;
int n;
int a[N],b[N];
int merge_sort(int a[],int l,int r) {
if(l==r) return 0;
int mid=l+r>>1,zc=l;
int ans=merge_sort(a,l,mid)+merge_sort(a,mid+1,r);
//分治归并做右区间,先将答案设为左右区间各自产生的答案
int i=l,j=mid+1;
for(;j<=r;++j) {
while(a[i]<=a[j]&&i<=mid) b[zc++]=a[i++];
ans+=mid-i+1,b[zc++]=a[j];
//在合并区间时统计答案,保证另一维的偏序关系
} while(i<=mid) b[zc++]=a[i++];
//双指针合并左右区间,保证区间有序,方便合并
for(int i=l;i<=r;++i) a[i]=b[i];
return ans;
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr),
cout.tie(nullptr);
cin>>n;
for(int i=1;i<=n;++i) cin>>a[i];
int res=merge_sort(a,1,n); //进行归并排序
cout<<res<<'\n';
return 0;
}
总体思想
由上述分治和数据结构维护两种方法不难看出,解决多维偏序/拟序问题的方式都是先保证一维有序,然后在有序的这一维上按照顺序对于第二维进行更新,再对第二维进行查询,从而求出答案。
三维偏序
三维偏序是基于二维偏序,用给定另一个偏序/拟序关系 ,求解满足三个条件的序列方案总数,它的别称非常具有诗意——陌上花开(关于为什么叫这个名字,我查了好久,都没有得到结果)。
例题
有 个元素,第 个元素有 、、 三个属性,设 表示满足 且 且 的 的数量。
对于 ,求 的 的数量。
原题 & 提交链接:洛谷 P3810 【模板】三维偏序(陌上花开) 。
思路
刚才解决二维偏序的时候,我讲解了两种做法,分治和数据结构维护两种,那么,当维数达到三维时,我们可以考虑两者同时使用,我们中国一位伟大的 OIer 陈丹琦同学基于这个想法发明了一种分治方法,即 cdq 分治,可以用于偏序/拟序问题降维,其核心思路为:通过先排序,再分治,消掉一位,然后再对分治后的左后区间再次进行排序,然后进行独立的二维偏序求解即可。
比如在这题中,我们要求 且 且 的数对数量,那么考虑先按照 进行排序,排序过后,对一个区间 ,进行以下操作:
- 取区间中点 ,先将区间拆成 和 ,由于 数组有序,假设 ,,那么不难知道 ,通过分治保障了第一维是有序的。
- 对于左右区间 和 进行分别的求解计算。
- 归并左右区间,合并时,如果我们直接进行归并式的合并,那么会导致 与 的关系很难确定,所以,左右区间是不能直接合并的,考虑 和 的关系,可以在将左、右区间分别进行排序,在两区间中使用双指针 和 进行滑动(假设 是左区间中的指针, 是右区间中的指针),那么可以通过移动指针 和 ,使得 和 恒满足偏序/拟序关系,即 ,那么,对于 和 的偏序/拟序关系,可以使用数据结构来维护。
警钟长鸣:一定要去重!
对于下面这个样例,可以在不去重的情况手算答案:
3 1
1 1 1
1 1 1
1 1 1
可以发现,在不去充的情况下算出的结果是 1 1 1
,而正确结果是 0 0 3
(这里为了节省空间,将换行改为了空格),这是为什么呢?
可以发现,我们的 cdq 分治对于左右区间合并的时候,左区间的值可以对右区间产生贡献,但是右区间永远不能对左边产生贡献,也就是说,如果 ,那么如果 和 的所有值都相同,那么根据偏序的反对称性,不论是 对 ,还是 对 ,都满足偏序关系,所以都应对对方产生贡献,而由于 ,所以只有 对 产生了贡献, 对 并没有产生贡献,为了解决这个问题,只能通过去重,将相同元素合并成一个,这样所有元素之间都不存在“等于”的关系,偏序就变成了拟序,于是就可以正常求解了。
但是,如果是拟序关系,那么就不需要去重了,但是偏序关系必须去重。
参考代码
#include <bits/stdc++.h>
#define int long long
#define lowbit(i) (i&-i)
using namespace std;
const int N = 2e5+7;
int a[N],res[N],cnt[N];
struct node {
int a,b,c,id,ct=0;
} x[N],zc[N]; //结构体存储
bool cmp(node a,node b) {
if(a.a!=b.a) return a.a<b.a;
if(a.b!=b.b) return a.b<b.b;
return a.c<b.c;
//先按照关键字 a 进行排序
}
struct trnode {
int tr[N];
void update(int a,int b) {
for(int i=a;i<N;i+=lowbit(i))
tr[i]+=b;
}
int query(int a) {
int res=0;
for(int i=a;i>0;i-=lowbit(i))
res+=tr[i];
return res;
}
} tr; //树状数组
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 i=l,j=mid+1;
for(;j<=r;++j) {
while(x[i].b<=x[j].b&&i<=mid)
tr.update(x[i++].c,x[i].ct);
res[x[j].id]+=tr.query(x[j].c);
//区间合并时,进行树状数组更新和查询
} --i;
for(;i>=l;--i) //还原树状数组形态
tr.update(x[i].c,-x[i].ct);
int pos=l;
i=l,j=mid+1;
for(;i<=mid&&j<=r;) {
while(x[i].b<x[j].b&&i<=mid) zc[pos++]=x[i++];
zc[pos++]=x[j++];
} while(i<=mid) zc[pos++]=x[i++];
for(int i=l;i<=r;++i) x[i]=zc[i];
//归并左右区间,并按照 b 关键字进行排序
}
signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr),
cout.tie(nullptr);
int n,k;
cin>>n>>k;
for(int i=1;i<=n;++i) cin>>zc[i].a>>zc[i].b>>zc[i].c;
sort(zc+1,zc+n+1,cmp);
int idx=0;
for(int i=1;i<=n;++i)
if(zc[i].a==zc[i-1].a&&zc[i].b==zc[i-1].b&&zc[i].c==zc[i-1].c) x[idx].ct++;
else x[++idx]={zc[i].a,zc[i].b,zc[i].c,idx,1}; //去重并计数
//for(int i=1;i<=idx;++i) cout<<x[i].a<<' '<<x[i].b<<' '<<x[i].c<<' '<<x[i].id<<' '<<x[i].ct<<'\n';
merge_sort(1,idx); //归并求答案
for(int i=1;i<=idx;++i) cnt[res[x[i].id]+zc[i].ct-1]+=zc[i].ct;
//之所以 +zc[i].ct-1,是因为一个数与其自身也是满足偏序关系的
for(int i=0;i<n;++i) cout<<cnt[i]<<'\n';
return 0;
}