整体二分——二分的进阶版!


Idea

整体二分,就是对所有的操作进行一个整体的二分答案,需要数据结构题满足以下性质:

  1. 询问的答案具有可二分性。
  2. 修改对判定答案的贡献相对独立,修改之间互不影响效果。
  3. 修改如果对判定答案有贡献,则贡献为一确定的与判定标准无关的值。
  4. 贡献满足交换律、结合律,具有可加性。
  5. 题目允许离线操作。

(来自《浅谈数据结构题的几个非经典解法》)

这些性质现在看不懂没有关系,等学会了整体二分,再回来看这些性质,就会发现无比在理(

或者我们用一句话总结——如果询问的答案具有可二分性,那么我们就可以尝试使用整体二分。

让我们结合例题来具体讲解这个高级的二分算法。


Example

1. P3332 [ZJOI2013]K大数查询

询问第 \(k\) 大?这不经典二分答案?

但是我们还有处理区间加元素的操作,而这个操作显然并不好处理。

于是我们考虑整体二分,将所有的操作序列打包,一起进行二分。

具体来说,我们可以用一个函数 \(\text{solve}(l,r,L,R)\),代表操作 \([l,r]\) 的答案在区间 \([L,R]\) 当中。对于该函数的处理,我们有如下流程:

  • 二分一个答案 \(mid\),并扫一遍 \([l,r]\) 之间的所有操作。考虑用一个树状数组维护每一个集合中大于 \(mid\) 的数的个数。
  • 对于修改操作,若所添加的元素 \(c>mid\),则将该操作对应的区间作一个区间加 \(1\),并将该操作划分到右侧集合当中;否则直接将该操作划分到左侧集合当中。
  • 对于查询操作,直接在树状数组上查询询问区间的区间和,若小于 \(c\),则说明当前答案 \(mid\) 较大,可以将 \(c\) 减去询问区间的区间和后,把该询问划分到左侧集合当中;否则则说明 \(mid\) 较小,可以将该询问划分到右侧集合中。
  • 然后我们将操作 \([l,r]\) 重新排序,将左侧集合的操作放到前面,右侧集合的操作放在后面,然后分别向两边递归即可。
  • \(L=R\) 时,当前操作区间 \([l,r]\) 中的所有询问操作的答案即为 \(L\) 的值。

所以说,我们的整体二分其实就起到了一种二分答案+划分操作的效果。

对于每一个操作,我们都递归了 \(\log V\) 层,所以复杂度为 \(O(n \log V\log n)\),其中 \(n\) 为操作个数,\(V\) 为答案的值域。

那么问题来了,为什么整体二分会优化时间复杂度呢?

回想一下如果是对于询问单独二分,我们会怎么二分?我们会对于每一个询问都二分出一个 \(mid\),并且将整个序列大于 \(mid\) 的数设为 \(1\),小于 \(mid\) 的数设为 \(0\),然后扫一遍进行 \(check\),时间复杂度为 \(O(V\log V)\)。但是整体二分通过将所有操作序列进行划分,使得分治树上的每一层的操作总数都是 \(O(n)\) 的,且我们在 \(check\) 划分操作的时候对于每一个操作都做到了 \(O(\log n)\) 修改或查询,相当于我们用 \(check\) 一个询问的时间 \(check\) 了本层的所有操作,这就是整体二分的魅力所在。

核心代码如下:

void solve(int l,int r,int L,int R){
	if (l>r||L>R) return;
	if (L==R){
		for (int i=l;i<=r;i++) 
			if (qu[i].op==2) ans[qu[i].id]=L;
		return;
	}
	int mid=L+R>>1,nowl=0,nowr=0;
	for (int i=l;i<=r;i++){
		if (qu[i].op==1){
			if (qu[i].c>mid) add(qu[i].l,1),add(qu[i].r+1,-1),q2[++nowr]=qu[i];
			else q1[++nowl]=qu[i];
		}else{
			int x=query(qu[i].r)-query(qu[i].l-1);
			if (x<qu[i].c) qu[i].c-=x,q1[++nowl]=qu[i];
			else q2[++nowr]=qu[i];
		}
	}
	for (int i=1;i<=nowr;i++)
		if (q2[i].op==1) add(q2[i].l,-1),add(q2[i].r+1,1);
	for (int i=1;i<=nowl;i++) qu[i+l-1]=q1[i];
	for (int i=1;i<=nowr;i++) qu[i+l+nowl-1]=q2[i];
	solve(l,l+nowl-1,L,mid),solve(l+nowl,r,mid+1,R);
}

记得清空的时候复杂度一定要跟当前的区间长度有关,而不是用 memset!


2. P3527 [POI2011]MET-Meteors

在第几次陨石雨之后,才能收集到足够的陨石。

对于这个“第几次”的询问,我们便可以考虑使用整体二分。

考虑每次二分找到一个 \(mid\),表示前 \(mid\) 次陨石雨之后的状态。可以将每一次流星雨当做一次修改操作,将每一段轨道当作一次查询操作放在所以修改操作的后面。

然后我们遍历当前的操作序列区间。对于一个修改操作,是前 \(mid\) 次之一则作一个区间加,并分给左边;否则分给右边。对于一次查询操作,我们将其对于国家的贡献做个记录,然后扫一遍所有国家,判断出哪些国家达标,哪些国家未达标,分别分给左右两边即可。

(双倍经验 SP10264()


3. P4175 [CTSC2008]网络管理

简化一下题意就是,需要支持树上单点修改以及查询路径第 \(k\) 大。

乍一看很没有头绪啊,但是看到了我们熟悉的查询第 \(k\) 大,自然下意识地想到了主席树二分答案。

考虑将树上结点的初始延迟时间也作为一次修改操作加入操作序列的最前面,并作一个树上差分。

对于当前二分到的键值 \(mid\),如果是修改操作,且修改的值大于 \(mid\),我们就将其子树内的所有结点作一个区间加 \(1\);若是查询操作,则将路径两端结点 \(u\)\(v\) 的值减去他们的 lca 以及 lca 的父亲的值,即为当前路径上延迟时间大于 \(mid\) 的结点个数。用一个数据结构维护子树加以及单点查询的操作,并在每次二分时将操作分给左右两边即可。

好奇怪,第 \(k\) 大这种询问明明是不满足可减性的啊,为什么最后反而可以转化为树上差分了呢……?

这个其实也就是整体二分到魔力了。我们相当于是将【查询第 \(k\) 大】的询问转化为了【有多少个数大于 \(mid\)】的计数类询问,而计数显然是满足可合并性与可减性的。

所以遇到第 \(k\) 大,一定要考虑二分a!


4. CF1100F Ivan and Burgers

这题要询问的东西似乎跟二分牛马不相及(

但是这玩意确实可以整体二分。大概有点类似分治(?

我们考虑对于将序列进行二分,对于二分到的一个 \(mid\),显然可以将所有的询问分为三类:

  • 整个询问区间都在 \(mid\) 左侧的。
  • 整个询问区间都在 \(mid\) 右侧的。
  • 询问区间横跨 \(mid\) 的。

前两者显然可以通过递归左右子区间进行解决,于是重点就放在了第三种区间的解决上。

对于 【任选若干数使其异或和最大】的询问,我们显然可以想到线性基。

于是对于第三种区间,我们可以求出以 \(mid\) 为右端点的所有后缀线性基,以及以 \(mid+1\) 为左端点的前缀线性基,然后对于每个询问将两个线性基合并即可。而合并线性基其实就是将一个线性基当中的数插入另一个线性基,单次合并的时间复杂度为 \(O(\log^2 V)\)

所以总时间复杂度为 \(O(n\log n\log V+O(q\log^2V))\)


Problem

1. P7424 [THUPC2017] 天天爱射击

考虑将木板和子弹作一个简单的“反演”,将【每个子弹射出之后有多少个木板会碎掉】转化为【每个木板会在第几次子弹射击之后碎掉】,而这个熟悉的“第几次”的询问,就可以二分答案了。

发现这道题其实和例2很相似,例2是区间修改+单点查询,而这道题就相当于是单点修改,区间查询。

考虑将子弹的单点修改作为修改操作,木板的区间查询作为询问操作放在修改操作的后面,将“第几次子弹射击后”做整体二分。

具体的整体二分过程直接类比例2即可。


2. P3250 [HNOI2016] 网络

考虑对于【重要度的最大值】进行整体二分,将【不经过 \(x\) 的请求的最大值】的最值问题转化为 【有多少个请求经过 \(x\) 】的计数问题。

设当前二分到的答案为 \(mid\),那么对于查询操作,我们需要知道有多少条大于 \(mid\) 的路径经过了 \(x\),若个数等于总数,说明不经过 \(x\) 的请求的最大值一定小于等于 \(mid\),否则相反。

对于修改操作,我们与查询操作相对应,若当前请求的重要度大于 \(mid\) 则作一个链加即可。

于是二分之后的题目就被我们转化为了链加与单点查询,用树上差分配合一个数据结构即可解决。


Exercise

  1. [HNOI2015] 接水果

  2. P1527 [国家集训队]矩阵乘法

posted @ 2022-08-22 08:43  ydtz  阅读(493)  评论(0编辑  收藏  举报