偏序问题与 CDQ 分治

什么是偏序和拟序?

偏序关系
RR 是集合 AA 上的一个二元关系,若 RR 满足:

  1. 自反性:对任意 xAx∈A,有 x R xx\ R\ x
  2. 反对称性(即反对称关系):对任意 x,yAx,y\in A,若 x R yx\ R\ y,且 y R xy\ R\ x,则 x=yx=y
  3. 传递性:对任意 x,y,zAx,y,z\in A,若 x R yx\ R\ y,且 y R zy\ R\ z,则 x R zx\ R\ z
    那么,关系 RR 就是一种偏序关系。

拟序关系:指没有自反性的偏序关系。

举个例子:“\leq”就是一种偏序关系,因为它具有自反性(xxx\leq x),具有反对称性(xy, yx, x=yx\leq y,\ y\leq x,\ \Rightarrow x=y),也具有传递性(xy, yz, xzx\leq y,\ y\leq z,\ \Rightarrow x\leq z),而 << 则不是偏序关系,它是一种拟序关系。

前置知识分治

二维偏序

二维偏序,即在两个序列 aabb 中,同时满足两种给定的偏序/拟序关系 R1, R2R_1,\ R_2 的数对 {i,j}\{i,j\} 的数量,即满足 ai R1 aja_i\ R_1\ a_jbi R2 bjb_i\ R_2\ b_j 的方案总数。

例题

给定一个长度为 nn 的整数数列,请你计算数列中的逆序对的数量。

逆序对的定义如下:对于数列的第 ii 个和第 jj 个元素,如果满足 i<ji<jai>aja_i>a_j,则其为一个逆序对;否则不是。

做法 11:数据结构维护

思路

求解方式非常简单,即从后往前遍历 aia_i 中的每个元素,求其后面(i<ji<j)共有多少个 jj 满足 ai>aja_i>a_j

实际上,求 <ai<a_i 的数字总数,那么就考虑采用正确的更新顺序,先保证一维满足偏序/拟序关系,再通过数据结构查询另一位偏序/拟序关系,发现数组下标的偏序/拟序关系很容易维护,那么,考虑从前向后遍历,保证所有数组下标都是逆序的,然后另一位权值 aa 的关系,只要靠树状数组求 <ai<a_i 的元素的前缀和就可以了,或者开值域线段树每次求 [0,ai1][0,{a_i}-1] 的权值和即可。

总共 nn 次修改,nn 次查询,总的时间复杂度为 O(nlogn)O(n\log n)

注意,如果数据范围较大,需要先离散化再进行计算,因为有可能开不下这么大的数组,下面的代码以树状数组为例:

参考代码

#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;
}

做法 22:分治

思路

这里就要用到分治的思想了,不了解分治的同学请先学习一下分治,学习前置知识。

对区间进行分治,然后进行归并排序,在合并区间时统计逆序对数量。

每次先取区间中点 mid=l+r2mid=\lfloor{{l+r}\over 2}\rfloor,然后分别对区间 [l,mid][l,mid] 和区间 (mid,r](mid,r] 分别进行归并排序,进行“分”的操作。

排序结束后,就要开始进行“治”的操作了,由于左边区间和右边区间的数组下标的偏序/拟序关系很容易确定,左边区间的数组下标 yy 都在 [l,mid][l,mid] 之间,右区间的数组下标 xx 都在 (mid,r](mid,r] 之间,所以一位/拟序关系已经解决,即 x>yx>y,那么另一位偏序/拟序关系可以在双指针合并时进行统计,即在右区间的某个元素 ax (x(mid,r])a_x\ (x\in (mid,r]) 被归并进区间时,查询左边还有多少个 ay (y[l,mid])a_y\ (y\in [l,mid]) 没有被归并进区间,由于归并排序按照从小到大依次放入新区间,所以可以保证 ax<aya_x<a_y,那么,我们就可以保证 x>yx>yax<aya_x<a_y 的所求偏序/拟序关系的数对数量了。

在这种做法当中,不需要进行离散化

参考代码

#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;
}

总体思想

由上述分治和数据结构维护两种方法不难看出,解决多维偏序/拟序问题的方式都是先保证一维有序,然后在有序的这一维上按照顺序对于第二维进行更新,再对第二维进行查询,从而求出答案。

三维偏序

三维偏序是基于二维偏序,用给定另一个偏序/拟序关系 R3R_3,求解满足三个条件的序列方案总数,它的别称非常具有诗意——陌上花开(关于为什么叫这个名字,我查了好久,都没有得到结果)。

例题

nn 个元素,第 ii 个元素有 aia_i​bib_i​、cic_i​ 三个属性,设 f(i)f(i) 表示满足 ajaia_j\leq a_i​ 且 bjbib_j\leq b_i​ 且 cjcic_j\leq c_i​ 的 jj 的数量。

对于 d[0,n)d\in[0,n),求 f(i)=df(i)=dii 的数量。

原题 & 提交链接:洛谷 P3810 【模板】三维偏序(陌上花开)

思路

刚才解决二维偏序的时候,我讲解了两种做法,分治和数据结构维护两种,那么,当维数达到三维时,我们可以考虑两者同时使用,我们中国一位伟大的 OIer 陈丹琦同学基于这个想法发明了一种分治方法,即 cdq 分治,可以用于偏序/拟序问题降维,其核心思路为:通过先排序,再分治,消掉一位,然后再对分治后的左后区间再次进行排序,然后进行独立的二维偏序求解即可。

比如在这题中,我们要求ajaia_j\leq a_i​ 且 bjbib_j\leq b_i​ 且 cjcic_j\leq c_i 的数对数量,那么考虑先按照 aa 进行排序,排序过后,对一个区间 [l,r][l,r],进行以下操作:

  1. 取区间中点 midmid,先将区间拆成 [l,mid][l,mid](mid,r](mid,r],由于 aa 数组有序,假设 x[l,mid]x\in [l,mid]y(mid,r]y\in (mid,r],那么不难知道 axaya_x\leq a_y,通过分治保障了第一维是有序的。
  2. 对于左右区间 [l,mid][l,mid](mid,r(mid,r 进行分别的求解计算。
  3. 归并左右区间,合并时,如果我们直接进行归并式的合并,那么会导致 axa_xaya_y 的关系很难确定,所以,左右区间是不能直接合并的,考虑 bbcc 的关系,可以在将左、右区间分别进行排序,在两区间中使用双指针 iijj 进行滑动(假设 ii 是左区间中的指针,jj 是右区间中的指针),那么可以通过移动指针 iijj,使得 bib_ibjb_j 恒满足偏序/拟序关系,即 bibjb_i\leq b_j,那么,对于 cic_icjc_j 的偏序/拟序关系,可以使用数据结构来维护。

警钟长鸣:一定要去重!

对于下面这个样例,可以在不去重的情况手算答案:

3 1
1 1 1
1 1 1
1 1 1

可以发现,在不去充的情况下算出的结果是 1 1 1,而正确结果是 0 0 3(这里为了节省空间,将换行改为了空格),这是为什么呢?

可以发现,我们的 cdq 分治对于左右区间合并的时候,左区间的值可以对右区间产生贡献,但是右区间永远不能对左边产生贡献,也就是说,如果 i<ji<j,那么如果 xix_ixjx_j 的所有值都相同,那么根据偏序的反对称性,不论是 xix_ixjx_j,还是 xjx_jxix_i,都满足偏序关系,所以都应对对方产生贡献,而由于 i<ji<j,所以只有 iijj 产生了贡献,jjii 并没有产生贡献,为了解决这个问题,只能通过去重,将相同元素合并成一个,这样所有元素之间都不存在“等于”的关系,偏序就变成了拟序,于是就可以正常求解了。

但是,如果是拟序关系,那么就不需要去重了,但是偏序关系必须去重

参考代码

#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;
}
赞赏