CDQ分治与整体二分

两种做法本质上都是划分子问题后进行解决,基于分治思想,但要求离线。

CDQ分治

偏序问题

有一个 count stars 的题,说有一片星星(不超过\(6e4\)),给出所有坐标(整数,大概\(3e4\)范围),求出对于每个星星,有多少个星星在它的左下方。

这个叫二维偏序,可以先对着横坐标\(x\)排序,然后依次扔进树状数组里(以纵坐标\(y\)为下标),每个星星扔进去之前在树状数组里求一下不超过\(y\)的数量和即可。在这个过程中,最开始的排序保证了\(x\)的顺序,树状数组保证了\(y\)的顺序。

那么三维偏序呢?

\(n\)点,每个点有\(u,v,w\)三种属性,点\(i\)的等级定义为所有满足\(u_j\leq u_i,v_j\leq v_i,w_j\leq w_i\)\(j\)点的个数,求从\(0\)\(n-1\)每个等级的点数。

这就需要用到CDQ陈丹琦分治了。

归并排序

我太依赖 sort 了,现在不得不再来说说归并排序,它是 CDQ 分治的重要组成部分。

归并排序本质上以分治思想为基础。先把区间\([L,R]\)从中间划分开,我们假装他的左右区间已经通过递归排好序了,现在只需要把左右区间混一块使整个区间有序。

考虑来两个指针\(l,r\),一开始\(l\)指着\(L\)\(r\)指着\(mid+1\),比较\(l,r\)所指的数,\(l\)指的数小就把这个数扔进答案数组,然后让\(l\)向后移一位,\(r\)指的数小就把这个数扔进答案数组,然后让\(r\)向后移一位。\(l\)一直到\(mid\)停止,\(r\)一直到\(R\)停止。

递归分治的复杂度为\(log\),合并左右区间的复杂度为\(O(n)\),因此总复杂度为\(O(nlogn)\)

void merge(int L, int R){
	if(L >= R) return;
	int mid = (L + R) >> 1;
	merge(L, mid);
	merge(mid + 1, R);
	int l = L, r = mid + 1, cnt = 0;
	for(; l <= mid || r <= R; ){
		if((r == R + 1) || (a[l] <= a[r] && l <= mid && r <= R)){
			b[++cnt] = a[l];
			l++;
		}else{
			b[++cnt] = a[r];
			r++;
		}
	}
	for(int i = 1; i <= cnt; i++) a[L + i - 1] = b[i];
}

merge(1, n);

CDQ分治的实现

拿上面那个三维偏序问题来说。

首先,对着\(u\)排序,\(u\)有序了。

然后进入归并排序的递归分治过程,用它来对着\(v\)排序。

现在我们来到一个区间\([L,R]\),假装\([L,mid]\)\([mid+1,R]\)内部的答案已经统计完了,现在只需要统计整个大区间的贡献。由于事先给\(u\)排过序了,所以左区间的\(u\)肯定都小于右区间的\(u\),因此只能是左区间给右区间做贡献。

现在分别给\([L,mid]\)\([mid+1,R]\)\(v\)升序排序。虽然此时\(u\)被打乱了,但\([L,mid]\)\(u\)全部小于\([mid+1,R]\)\(u\)不会变,这样就够了,因为左右两个区间内部的答案我们已经计算过了,不用再考虑了。

在拿着\(l,r\)两个指针扫的时候,如果要放一个左区间的数,不会有人给他贡献答案了,他只可能给后面右区间的数做贡献,因此把它扔进树状数组,以\(w\)为下标。如果要放一个右区间的数,现在树状数组里面存的都是左区间的数,\(u\)一定比他的小,我们归并时按\(v\)从小到大扫, 里面的\(v\)也一定比他的小,所以他能拿到的贡献就要加上树状数组里小于等于他的\(w\)的数的个数。

这一层做完以后清空树状数组,再进入下一层。最后就能求出答案。

当然还有一个细节问题,对于所有\(u,v,w\)三种属性都相等的数,它们是可以相互贡献的,然而我们在归并时只能计算左边对右边的贡献。于是要先把序列去重,算完以后再让\(res_i\)加上\(cnt_i-1\)即可。

这样,我们通过三种排序或统计手段的合理交错计算出了正确的答案。

例题在这里

int lowbit(int x){
	return x & -x;
}

bool s1(node a, node b){
	if(a.u != b.u) return a.u < b.u;
	if(a.v != b.v) return a.v < b.v;
	return a.w < b.w;
}

bool s2(node a, node b){
	if(a.v != b.v) return a.v < b.v;
	return a.w < b.w;
}

void add(int x, int kk){
	if(x <= 0) return c[0] = 0, void();
	for(; x <= k; x += lowbit(x)) c[x] += kk;
	return;
}

int ask(int x){
	if(x <= 0) return 0;
	int ans = 0;
	for(; x; x -= lowbit(x)) ans += c[x];
	return ans;
}

void calc(int L, int R){
	if(L >= R) return;
	int mid = (L + R) / 2;
	calc(L, mid);
	calc(mid + 1, R);
	int l = L, r = mid + 1, cnt = 0;
	sort(p + L, p + mid + 1, s2);
	sort(p + mid + 1, p + R + 1, s2);
	for(;l <= mid || r <= R; ){
		if((r == R + 1) || (p[l].v <= p[r].v && l <= mid && r <= R)){
			a[++cnt] = l;
			add(p[l].w, p[l].jud);
			l++;
		}else{
			a[++cnt] = r;
			p[r].res += ask(p[r].w);
			r++;
		}
	}
	for(int i = L; i <= mid; i++) add(p[i].w, -p[i].jud);
}

signed main(){
	n = read();
	k = read();
	for(int i = 1; i <= n; i++) s[i].u = read(), s[i].v = read(), s[i].w = read();
	sort(s + 1, s + n + 1, s1);
	tot = 0;
	for(int i = 1; i <= n; i++){
		if(s[i].u == p[tot].u && s[i].v == p[tot].v && s[i].w == p[tot].w){
			p[tot].jud += 1;
			continue;
		}
		p[++tot] = s[i];
		p[tot].jud = 1;
	}
	sort(p + 1, p + tot + 1, s1);
	calc(1, tot);
	for(int i = 1; i <= n; i++) p[i].res += (p[i].jud - 1);
	for(int i = 1; i <= n; i++) Res[p[i].res] += p[i].jud;
	for(int i = 0; i < n; i++) cout << Res[i] << '\n';
	return 0;
}

CDQ分治的简单应用

一些较难处理的动态问题,或许可以通过CDQ分治转化为静态问题。

比如这个

对于每个元素\(i\),我们记录三样东西:位置\(p_i\),值\(v_i\),删除时间\(d_i\)(如果不删就令其\(d_i=m+1\),表示一直都在)。删掉\(i\)以后,逆序对减少的数量就是所有原本与\(i\)构成逆序对的元素\(j\)的数量。

这样的\(j\)到底有多少个?不难发现它们都满足以下两个条件之一

\(1.p_j<p_i,v_j>v_i,d_j>d_i.\)
\(2.p_j>p_i,v_j<v_i,d_j>d_i.\)

那这不就是裸的三维偏序吗。

还有一个小trick。为了代码好写,只用写一个CDQ分治,可以考虑比如令\(p_i=n+1-i\),就可以把\(p_j<p_i\)转化为\(p_j>p_i\),不过别忘了做完以后再换回来。

其它扩展应用静待\(update\)

整体二分

经典应用与模版实现

比较经典的一个应用是查询区间第\(k\)大的问题,当然在权值线段树上二分也可以解决此类问题,不过如果询问可以离线的话,就可以拿整体二分来解决。

为了方便,先从不带修改的静态区间第\(k\)大问题入手。参见例题

首先,把所有操作存起来。把输入序列看做是加数操作,记为操作类型一,记录下标、值和操作类型。把查询操作记为操作类型二,记录查询的左右端点,\(k\)值和操作类型。这些操作都存在同一个结构体数组里,类型一在类型二前,二者都按序排列。

然后开始二分。整体二分的过程类似于分治,也就是子问题划分的过程。

二分两个东西:当前的操作区间\([l,r]\),即将要处理的一段操作,和答案所在区间\([L,R]\)。令\(mid=(L+R)/2\)。先处理当前操作区间内的类型一操作,如果一个元素的值大于\(mid\),让它准备滚到右边去,如果它的值小于等于\(mid\),就把它扔进树状数组,并准备去左边。这样处理完以后,树状数组上\([1,x]\)的前缀就表示原序列中下标在\([1,x]\)且值小于等于\(mid\)的元素数量。然后拿着类型二的操作,假如它的询问区间是\([ql,qr]\),那么看看这个区间内小于等于\(mid\)的数有多少个,假如有\(sum\)个,要是\(sum>k\),说明它的答案一定在\([mid+1,R]\)内,让它的\(k\)减去\(sum\)然后准备滚到右边去,否则就直接准备去左边。

做完以后,把操作区间\([l,r]\)重排一下,将要去左边的操作放在左边,将要去右边的操作放在右边,然后左右分开,各自进入下一个子问题。在这之前别忘了清空树状数组。这样一直做下去,直到\(L=R\),也就是当前\([l,r]\)操作区间内的询问操作的答案都已经确定为\(L\)了,统计完以后往回走即可。

不难发现,与普通二分不同,整体二分始终在整个元素序列上操作,它二分的是答案的值域,并以此来划分操作序列。也正是因为要对着操作序列统一处理,所以要求必须离线。

因为涉及到树状数组,所以复杂度是\(O(nlog^2n)\)的。

struct node{
	int li, ri, k, type, id;
}q[N];
int res[N], c[N];

void add(int x, int k){
	if(! x) return;
	for(; x <= n; x += lowbit(x)) c[x] += k;
}

int ask(int x){
	if(! x) return 0;
	int ans = 0;
	for(; x; x -= lowbit(x)) ans += c[x];
	return ans;
}

void solve(int ql, int qr, int l, int r){//ql,qr表示操作区间,l,r表示答案所在区间(值域)
	if(ql > qr) return;
	if(l == r){
		for(int i = ql; i <= qr; i++){
			if(q[i].type == 2) res[q[i].id] = l;
		}
		return;
	}
	int mid = (l + r) >> 1;
	vector<node>q1, q2;//q1表示将要去左边的,q2表示将要去右边的
	for(int i = ql; i <= qr; i++){
		if(q[i].type == 1){
			if(q[i].k <= mid){
				add(q[i].id, 1);
				q1.pb(q[i]);
			}
			else q2.pb(q[i]);
		}else{
			int sum = ask(q[i].ri) - ask(q[i].li - 1);
			if(sum >= q[i].k) q1.pb(q[i]);
			else{
				q[i].k -= sum;
				q2.pb(q[i]);
			} 
		}//能这么写是因为不管怎么分类型一定排在类型二前面
	}
	for(int i = ql; i <= qr; i++){
		if(q[i].type == 1 && q[i].k <= mid) add(q[i].id, -1);
		else if(q[i].type == 2) break;
	}
	int tt = -1, Mid = 0;
	for(auto x : q1) q[++tt + ql] = x;//操作序列重排
	Mid = tt + ql;//操作区间的左右分界点
	for(auto x : q2) q[++tt + ql] = x;
	solve(ql, Mid, l, mid);//递归子问题
	solve(Mid + 1, qr, mid + 1, r);
}

signed main(){
	n = read();
	m = read();
	for(int i = 1; i <= n; i++) q[i] = {0, 0, read(), 1, i};
	for(int i = 1; i <= m; i++) q[n + i] = {read(), read(), read(), 2, i};
	solve(1, n + m, 0, 1e9);
	for(int i = 1; i <= m; i++) cout << res[i] << '\n';
	return 0;
}

那要是动态带修改呢?把这个操作分成两个:先减去原来的,再加上后来的就可以了。

其他应用

等待\(update\)

警钟撅烂

一种错误写法:

int Mid = s1.size(), t = 0;
for(auto x : s1) q[ql + t] = x, t++;
for(auto x : s2) q[ql + t] = x, t++;
for(int i = ql; i <= qr; i++){
	if(q[i].type == 1){
		if(q[i].id <= mid) add(q[i].x, -1);
	} else break;
}

正确写法:

	for(int i = ql; i <= qr; i++){
		if(q[i].type == 1){
			if(q[i].id <= mid) add(q[i].x, -1);
		} else break;
	}
	int Mid = s1.size(), t = 0;
	for(auto x : s1) q[ql + t] = x, t++;
	for(auto x : s2) q[ql + t] = x, t++;

一种错误写法:

Mid = s1.size();
solve(ql, Mid, l, mid);
solve(Mid + 1, qr, mid + 1, r);

正确写法:

    Mid = s1.size();
	solve(ql, ql + Mid - 1, l, mid);
	solve(ql + Mid, qr, mid + 1, r);
posted @ 2025-07-17 12:39  Lordreamland  阅读(13)  评论(0)    收藏  举报