偏序问题与 CDQ 分治

什么是偏序和拟序?

偏序关系
\(R\) 是集合 \(A\) 上的一个二元关系,若 \(R\) 满足:

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

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

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

前置知识分治

二维偏序

二维偏序,即在两个序列 \(a\)\(b\) 中,同时满足两种给定的偏序/拟序关系 \(R_1,\ R_2\) 的数对 \(\{i,j\}\) 的数量,即满足 \(a_i\ R_1\ a_j\)\(b_i\ R_2\ b_j\) 的方案总数。

例题

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

逆序对的定义如下:对于数列的第 \(i\) 个和第 \(j\) 个元素,如果满足 \(i<j\)\(a_i>a_j\),则其为一个逆序对;否则不是。

做法 1:数据结构维护

思路

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

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

总共 \(n\) 次修改,\(n\) 次查询,总的时间复杂度为 \(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;
}

做法 2:分治

思路

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

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

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

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

总体思想

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

三维偏序

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

例题

\(n\) 个元素,第 \(i\) 个元素有 \(a_i​\)\(b_i\)​、\(c_i\)​ 三个属性,设 \(f(i)\) 表示满足 \(a_j\leq a_i\)​ 且 \(b_j\leq b_i\)​ 且 \(c_j\leq c_i\)​ 的 \(j\) 的数量。

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

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

思路

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

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

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

警钟长鸣:一定要去重!

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

3 1
1 1 1
1 1 1
1 1 1

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

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

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

参考代码

#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;
}
posted @ 2023-09-27 23:15  abensyl  阅读(45)  评论(0)    收藏  举报  来源
//雪花飘落效果