整体二分学习笔记

整体二分学习笔记

谁说这二分老了,这二分太棒了!

概念

二分适用于答案具有单调性的题目,思路是令 \(\text{Solve}(l,r)\) 表示二分此问题的答案时,已经知道了 \(ans\in[l,r]\)。此时如果有一种手段 \(\text{check(x)}\) 判断 \(ans\ge x\) 时答案是否合法,那么问题就得到了解决:我们可以令 \(mid=\dfrac{l+r}{2}\)\(\text{check}(mid)\) 后执行 \(\text{Solve}(l,mid)\)\(\text{Solve}(mid+1,r)\),直到边界情况 \(l=r\),我们此时就得知了这个问题的答案。

但是对于多个询问的情况,这种方式的效率比较低下。我们考虑一种高端的方式:\(\text{Solve}(l,r,Q)\) 表示已经知道 \(Q\) 这个集合中的询问的答案均在 \([l,r]\) 之间,利用数据结构对 \(mid\) 进行复杂度与 \(|Q|,[l,r]\) 有关的处理后,对集合内所有询问执行 \(\text{check}(mid)\),将所有询问分为两组 \(Q_1,Q_2\),分别表示 \(ans\le mid\)\(ans>mid\) 的询问,再调用 \(\text{Solve}(l,mid,Q_1),\text{Solve}(mid+1,r,Q_2)\) 递归解决下去。

整体二分的好处在于常数小和线性空间。关于上面提到的将询问分成两部分,我们可以采用类似 CDQ 分治的做法,将每一次需要处理的询问排在 \([L,R]\) 之内,可以减小常数和代码难度。

经典例题

整体二分维护可加贡献

静态区间 \(k\) 小值

给出一个长度为 \(n\) 的序列,询问 \(q\) 次区间 \([L,R]\) 中第 \(k\) 小的元素。

\(\text{Solve}(l,r,Q)\) 表示已经知道集合 \(Q\) 内的询问的答案在 \([l,r]\) 中,考虑如何判断每个询问的答案和 \(mid\) 的关系,显然我们想要得知某个区间 中 \((-\infty,mid]\) 范围内的数有多少个,那么我们将这些范围内的数视作 \(1\),对每一个查询区间求一个区间和,并和 \(k\) 判断大小关系。但显然我们需要枚举区间内的每一个数,复杂度是无法承受的。

考虑区间内某范围中的数的个数的贡献是可加的,于是可以分别考虑区间 \((-\infty,l)\)\([l,mid]\) 的贡献。考虑到分治的过程,我们发现,每一个询问分治到当前情景下一定已经处理过 \((-\infty,l)\) 的贡献,于是我们只需要在当前状态下枚举 \([l,mid]\) 内的数,利用树状数组进行单点修改和区间查询即可。

这样做的复杂度在于,每一个数都只包含在 \(\log n\)\(\text{Solve}\) 的区间中,也就是只会被加入 \(\log n\) 次树状数组。而每一个询问会被划分 \(\log n\) 次,而每一次的判断的复杂度都是 \(\log n\)。于是得出这个算法的复杂度是 \(O((n+q)\log^2 n)\),空间复杂度 \(O(n+q)\)

然而这个题目有 \(O(n\log n)\) 的整体二分做法,在下文会提到。

code(P3834)
#include <bits/stdc++.h>
using namespace std;
#define il inline

const int N=2e5+5;

int n,m;
int ans[N],a[N],id[N];
struct Oper{
	int l,r,k,id;
}opt[N],tmp[N];
struct BIT{
	#define lowbit(x) (x&-x)
	int t[N];
	il void Modify(int x,int v){
		for(;x<=n;x+=lowbit(x))t[x]+=v;
		return ;
	}
	il int Query(int x){
		int res=0;
		for(;x;x-=lowbit(x))res+=t[x];
		return res;
	}
}bit;

il void Solve(int l,int r,int L,int R){
	if(L>R)return ;
	if(l==r){
		for(int i=L;i<=R;i++)ans[opt[i].id]=a[id[l]];
		return ;
	}
	int mid=(l+r)>>1,p=L-1,q=R+1;
	for(int i=l;i<=mid;i++)bit.Modify(id[i],1);
	for(int i=L;i<=R;i++){
		int sum=bit.Query(opt[i].r)-bit.Query(opt[i].l-1);
		if(sum>=opt[i].k)tmp[++p]=opt[i];
		else opt[i].k-=sum,tmp[--q]=opt[i];
	}
	for(int i=l;i<=mid;i++)bit.Modify(id[i],-1); 
	for(int i=L;i<=p;i++)opt[i]=tmp[i];
	for(int i=q;i<=R;i++)opt[R+q-i]=tmp[i];
	Solve(l,mid,L,p),Solve(mid+1,r,q,R);
	return ;
}

int main(){
	ios::sync_with_stdio(0);
	cin.tie(0),cout.tie(0);
	cin>>n>>m;
	for(int i=1;i<=n;i++)cin>>a[i];
	for(int i=1;i<=n;i++)id[i]=i;
	sort(id+1,id+1+n,[](int x,int y){return a[x]<a[y];});
	for(int i=1;i<=m;i++){
		int l,r,k;cin>>l>>r>>k;
		opt[i]={l,r,k,i}; 
	}
	Solve(1,n,1,m);
	for(int i=1;i<=m;i++)cout<<ans[i]<<"\n";
	return 0;
}

动态区间第 \(k\)

考虑在上面的题目基础上添加单点修改,即每一次可以将 \(a_x\) 改为 \(p\)

看似加入动态修改后静态二分是困难的,但使用整体二分实际很简单。考虑我们二分值域,对判断的时间顺序是没有要求的。于是我们可以将所有的询问按时间顺序排序,如果我们可以将修改也按照时间顺序维护的话,那么对于每一个询问的查询一定是正确的。

具体而言,令 \(\text{Solve}(l,r,Q)\)\(Q\) 表示所有修改和询问的集合,已经按照时间排序。因为我们此时只在意 \([l,mid]\) 之间的数字的插入,因此,修改操作我们只关心值在 \([l,mid]\) 之间的操作,也只会将这样的操作放入 \(Q\) 中。考虑一个修改操作本质上是删除 \(a_x\) 后添加 \(p\),从而两个操作可以分开考虑。然后我们按照时间顺序执行 \(Q\) 内的操作,然后对于询问按照区间和划分,修改按照值划分。时间复杂度不变,依然是 \(O((n+q)\log^2n)\)

code(P2617)
#include <bits/stdc++.h>
using namespace std;
#define il inline

const int N=1e5+5;

int n,m,tot,q,cnt;
int ans[N],a[N],b[N<<1];
struct Oper{
	int o,l,r,k,id;
}opt[N<<2],tmp[N<<2];
struct BIT{
	#define lowbit(x) (x&-x)
	int t[N];
	il void Modify(int x,int v){
		for(;x<=n;x+=lowbit(x))t[x]+=v;
		return ;
	}
	il int Query(int x){
		int res=0;
		for(;x;x-=lowbit(x))res+=t[x];
		return res;
	}
}bit;

il void Solve(int l,int r,int L,int R){
	if(L>R)return ;
	if(l==r){
		for(int i=L;i<=R;i++){
			if(opt[i].o)ans[opt[i].id]=b[l];
		}
		return ;
	}
	int mid=(l+r)>>1,p=L-1,q=R+1;
	for(int i=L;i<=R;i++){
		if(opt[i].o){
			int sum=bit.Query(opt[i].r)-bit.Query(opt[i].l-1);
			if(sum>=opt[i].k)tmp[++p]=opt[i];
			else opt[i].k-=sum,tmp[--q]=opt[i];
		}else{
			if(opt[i].k<=b[mid]){
				bit.Modify(opt[i].l,opt[i].id);
				tmp[++p]=opt[i];
			}else tmp[--q]=opt[i]; 
		}
	}
	for(int i=L;i<=p;i++){
		if(!tmp[i].o)bit.Modify(tmp[i].l,-tmp[i].id);
	}
	for(int i=L;i<=p;i++)opt[i]=tmp[i];
	for(int i=q;i<=R;i++)opt[i]=tmp[R+q-i];
	Solve(l,mid,L,p),Solve(mid+1,r,q,R);
	return ;
}

int main(){
	ios::sync_with_stdio(0);
	cin.tie(0),cout.tie(0);
	cin>>n>>m;
	for(int i=1;i<=n;i++){
		cin>>a[i];
		b[++cnt]=a[i];
		opt[++tot]={0,i,0,a[i],1};
	}
	for(int i=1;i<=m;i++){
		char o;int l,r,k;cin>>o>>l>>r;
		if(o=='Q'){
			cin>>k;
			opt[++tot]={1,l,r,k,++q};
		}else{
			b[++cnt]=r;
			opt[++tot]={0,l,0,a[l],-1},opt[++tot]={0,l,0,r,1};
			a[l]=r;
		}
	}
	sort(b+1,b+1+cnt),cnt=unique(b+1,b+1+cnt)-(b+1);
	Solve(1,cnt,1,tot);
	for(int i=1;i<=q;i++)cout<<ans[i]<<"\n";
	return 0; 
}

练习(P3250)

在一棵 \(n\) 个点的树上,有 \(m\) 个操作:

  • 加入/删除一条 \(x\to y\),权值为 \(w\) 的路径。
  • 询问一条不经过 \(x\) 的路径的最大权值。

\(\text{Hint}\):二分的显然是答案,只需要考虑如何在分治时检查答案与 \(mid\) 的大小关系。

部分题目的优化

差分优化树状数组

给定 \(n\) 个国家和一个 \(m\) 个点的环。有 \(k\) 个操作,每个操作形如给 \([l,r]\) 中的每个点所属的国家的值增加 \(a\)。对第 \(i\) 个国家求解哪一次操作后的值 \(\ge p_i\)

此题显然需要二分每个国家的合法时间,执行 \([1,mid]\) 的操作后判断每一个国家的点权是否到达上限即可。

同样的利用整体二分,令 \(\text{Solve}(l,r,Q)\) 已知 \(Q\) 内的国家的答案在 \([l,r]\),考虑继续分治下去。类似上面的思路,由于贡献的可加性,可以考虑暴力执行 \([l,mid]\) 内的所有操作,对于每一个国家,统计答案是枚举其拥有的点权,单点求值后暴力求和判断,可以简单用树状数组维护。不难看出,时间复杂度是 \(O((n+m)\log^2n)\) 的。

然而这个做法不够优秀,考虑继续优化。不难考虑到在处理 \([l,mid]\) 的修改时,树状数组的部分是不必要的。有一个做法是维护差分数组,最后扫一遍序列做前缀和,这样是 \(O(n)\) 的,但我们只关心一些关键点的点权(即 \(Q\) 中国家拥有的点)。我们将区间加差分成单点修改,将这些单点修改和单点询问按照其修改的位置排序,就能 \(O(|Q|)\) 得到所有关键点的值。排序这一步,我们只需要保证在最开始的情况下有序,那么乡下的两部分也自然有序。于是我们得到了一个 \(O(n\log n)\) 的做法。

显然上文提到的静态区间第 \(k\) 小也可以用这种方法做到 \(O(n\log n)\),留作读者练习。

code(P3527)
#include <bits/stdc++.h>
using namespace std;
#define il inline
#define ll long long
#define LL __int128

const int N=3e5+5;

int n,m,k,tot;
int a[N],ans[N];
LL val[N];
struct Oper{
	int o,x,v,id;
}opt[N<<2],tmp[N<<2];

il void Solve(int l,int r,int L,int R){
	if(L>R)return ;
	if(l==r){
		for(int i=L;i<=R;i++){
			if(opt[i].o==1)ans[opt[i].id]=l;
		}
		return ;
	}
	int mid=(l+r)>>1,p=L-1,q=R+1;
	ll s=0;
	for(int i=L;i<=R;i++){
		if(opt[i].o==1)val[opt[i].id]+=s;
		else if(opt[i].id<=mid)s+=opt[i].v;
	}
	for(int i=L;i<=R;i++){
		if(opt[i].o==1){
			int x=opt[i].id;
			if(val[x]>=a[x])tmp[++p]=opt[i];
			else a[x]-=val[x],val[x]=0,tmp[--q]=opt[i];
		}else (opt[i].id<=mid?tmp[++p]:tmp[--q])=opt[i]; 
	}
	for(int i=L;i<=R;i++){
		if(opt[i].o==1)val[opt[i].id]=0;
	}
	for(int i=L;i<=p;i++)opt[i]=tmp[i];
	for(int i=q;i<=R;i++)opt[i]=tmp[R+q-i];
	Solve(l,mid,L,p),Solve(mid+1,r,q,R);
	return ;
}

int main(){
	ios::sync_with_stdio(0);
	cin.tie(0),cout.tie(0);
	cin>>n>>m;
	for(int i=1;i<=m;i++){
		int o;cin>>o;
		opt[++tot]={1,i,0,o};
	}
	for(int i=1;i<=n;i++)cin>>a[i];
	cin>>k;
	for(int i=1;i<=k;i++){
		int l,r,v;cin>>l>>r>>v;
		opt[++tot]={0,l,v,i},opt[++tot]={0,r+1,-v,i};
		if(l>r)opt[++tot]={0,1,v,i};
	}
	sort(opt+1,opt+1+tot,[](Oper a,Oper b){return (a.x^b.x)?a.x<b.x:a.o<b.o;});
	Solve(1,k+1,1,tot);
	for(int i=1;i<=n;i++){
		if(!ans[i]||ans[i]==k+1)cout<<"NIE\n";
		else cout<<ans[i]<<"\n";
	}
	return 0;
}

练习(P7560)

\(n\) 家店,\(m\) 个时间:

  • 编号 \(\in[L,R]\) 的所有食品店的队伍末尾加入了 \(K\) 个编号为 \(C\) 的人。
  • 编号 \(\in[L,R]\) 的所有食品店的队伍出队了 \(K\) 个人(不足 \(K\) 个人则全体离开队伍)。
  • 查询编号为 \(A\) 的食品店的队伍中第 \(B\) 个人的编号。

\(\text{Hint}\):考虑忽略掉出队操作,而是维护出队了多少人,这样可以得知查询的人是第几个进店的。先利用数据结构,后可以利用上述方法整体二分做到 \(O((n+m)\log n)\) 的复杂度。

整体二分求解单调序列

有一个长度为 \(n\) 的序列,想要将其分为 \(k\) 段,每个子段的费用是其中相同元素的对数,求所有字段的费用之和的最小值。

容易写出一个朴素的转移:令 \(f_{i,j}\) 表示前 \(j\) 个点划分成 \(i\) 段的最小值,\(w(i,j)\) 为将 \([i,j]\) 划分到一起的代价,那么有 \(f_{k,i}=\min\{f_{k-1,j-1}+w(j,i)\}\)。众所周知的是,如果 \(w(i,j)\) 满足四边形不等式,那么说明 \(f\) 具有决策单调性,也就是说对于相同的 \(i\),记 \(f_{i,j}\) 的决策点为 \(g_j\),那么 \(g_j\) 单调不减。而对于这道题,显然有 \(w(i,j+1)+w(i+1,j)\ge w(i,j)+w(i+1,j+1)\),满足四边形不等式(读者可以自己思考为什么)。

考虑用整体二分去求解这个最优的决策序列,我们可以采取这样的做法:令 \(\text{Solve}(l,r,L,R)\) 表示 \(g_{[L,R]}\) 的值都在 \([l,r]\) 中,取 \(mid=\dfrac{L+R}{2}\)。如果我们能在合理的复杂度内得到 \(g_{mid}\) 的值 \(m\),那么我们可以递归执行 \(\text{Solve}(l,m,L,mid-1),\text{Solve}(m,r,mid+1,R)\),这样可以求得序列的所有位置的值。

考虑如何计算 \(g_{mid}\) 的值,可以考虑暴力枚举 \([l,r]\) 中的每一个位置,由于每一个点只会被分治区间包含 \(\log n\) 次,所以复杂度是 \(O(n\log n)\) 的,然而 \(w(i,j)\) 的计算较为棘手。考虑用一个类似于莫队的思路维护左端点右端点两个指针,并在移动时计算内部的答案。$mid $ 一定是右端点的目标,而相邻两次分治 \(mid\) 的改变量与 \(R-L\) 同级,故右端点移动次数为 \(O(n\log n)\);左端点一定从上次分治的某个端点移动来,因此在此次分治内移动次数与 \(r-l\) 同级,移动次数也是 \(O(n\log n)\) 的。于是一层的转移求解复杂度是 \(O(n\log n)\) 的,总复杂度为 \(O(nk\log n)\)

code(CF868F)

#include <bits/stdc++.h>
using namespace std;
#define il inline
#define ll long long

const int N=1e5+5,K=25;

int n,k,_l,_r;
int a[N],cnt[N];
ll ans;
ll f[K][N];

il ll w(int l,int r){
	while(_l<l)ans-=--cnt[a[_l++]];
	while(_l>l)ans+=cnt[a[--_l]]++;
	while(_r<r)ans+=cnt[a[++_r]]++;
	while(_r>r)ans-=--cnt[a[_r--]];
	return ans;
}

il void Solve(int d,int l,int r,int L,int R){
	if(L>R)return ;
	if(l==r){
		for(int i=L;i<=R;i++)f[d][i]=f[d-1][l-1]+w(l,i);
		return ;
	}
	int mid=(L+R)>>1,p=l;
	f[d][mid]=f[d-1][l-1]+w(l,mid);
	for(int i=l+1;i<=min(r,mid);i++){
		ll val=f[d-1][i-1]+w(i,mid);
		if(val<f[d][mid])f[d][mid]=val,p=i; 
	}
	Solve(d,l,p,L,mid-1),Solve(d,p,r,mid+1,R);
	return ;
}

int main(){
	ios::sync_with_stdio(0);
	cin.tie(0),cout.tie(0);
	cin>>n>>k;
	for(int i=1;i<=n;i++)cin>>a[i];
	_l=1,_r=0;
	for(int i=1;i<=n;i++)f[1][i]=w(1,i);
	for(int i=2;i<=k;i++)Solve(i,1,n,1,n);
	cout<<f[k][n];
	return 0;
}

整体二分维护不可加贡献

给定一张 \(n\) 个点的无向图,初始没有边。依次加入 \(m\) 条带权的边,每次加入后询问是否存在一个边集使得图中所有点的度数为奇数。若存在则输出边集中最大边权的最小值。

首先利用人类智慧得到结论:存在合法边集当且仅当所有联通块大小均为偶数。

  • 必要性:联通块大小为奇数时若存在方案,则保留合法边集后次联通块度数之和为奇数,矛盾。
  • 充分性:每个连通块仅保留 DFS 树,自底向上 DP,必然可以构造方案。

然后考虑无修改的情况:联通块大小均为偶数时,添加边后依然满足条件,所以按边权从小到大排序,有用的边一定是一个前缀,并且具有单调性。并且答案序列也是单调不增的,可以考虑整体二分。

我们可以用可撤销并查集维护联通块,注意到答案序列是单调不增的,考虑使用上文求解单调序列的方式,令 \(\text{Solve}(l,r,L,R)\) 表示 \(ans_{[L,R]}\) 的值在 \([l,r]\) 中,考虑如何求得 \(ans_{mid}\) 的值。最为朴素的做法就是将时间 \(\le mid\) 的边按照权值从小到大加入到并查集中,第一个联通块大小均为偶数的时刻即为 \(ans_{mid}\)。然而此时有一个丑陋的事情:贡献没有可加性,我们无法用之前求得的内容帮助求解。考虑换一种思路,我们可以不用之前的内容帮助,而用当前的内容取帮助下面的二分。更具体的,我们可以每一次操作完后不清空,这样 \([l,mid]\) 的操作可以帮助 \(\text{Solve}(mid+1,r,Q_2)\) 求解,接着撤销掉这部分的贡献后又可以求解 \(\text{Solve}(l,mid,Q_1)\),于是我们发现对于一个分治区间,操作的次数与 \(r-l\) 同级,于是复杂度是 \(O(n\log n)\) 的。

对于这个题目来说,我们事先保证在处理 \(\text{Solve}(l,r,L,R)\) 时,权值 \(<l\) 并且时间 \(<L\) 的边已经全部加入并查集中,接下来对答案有贡献的边分成两部分:

  1. 时间 \(\le mid\) 并且权值 \(<l\) 的边。这些边显然不影响答案,先直接加入这些边。
  2. 时间 \(\le mid\) 并且权值 \(\in[l,r]\) 的边。按顺序加入这些边,直到图合法为止。

求出 \(ans_{mid}=m\) 后,发现接下来应当调用 \(\text{Solve}(l,m,mid+1,R),\text{Solve}(m,r,L,mid-1)\),对于一个函数的调用,显然只需要保留 \(1\) 中的边,于是撤回所有 \(2\) 的贡献即可;接着撤回 \(1\) 的贡献,单独加入递归左区间需要的边集后求解即可。复杂度是 \(O(m\log^2n)\) 的。

code(CF603E)

#include <bits/stdc++.h>
using namespace std;
#define il inline

const int N=1e5+5,M=3e5+5;

int n,m;
int ans[M];
struct Oper{
	int u,v,w,id;
}p[M],q[M];
struct DSU{
	int top,tot;
	int faz[N],siz[N];
	struct Node{
		int x,y,f;
	}stk[M];
	il void Init(){
		tot=n;
		for(int i=1;i<=n;i++)faz[i]=i,siz[i]=1;
		return ;
	}
	il int Find(int x){
		while(x^faz[x])x=faz[x];
		return x;
	}
	il void Link(int x,int y){
		int a=Find(x),b=Find(y);
		if(a==b)return ;
		if(siz[a]<siz[b])swap(a,b);
		stk[++top]={a,b,faz[b]};
		tot-=(siz[a]&1)+(siz[b]&1);
		faz[b]=a,siz[a]+=siz[b];
		tot+=(siz[a]&1);
		return ;
	}
	il void Remove(int tmp){
		while(top>tmp){
			int a=stk[top].x,b=stk[top].y,f=stk[top].f;top--;
			tot-=(siz[a]&1);
			faz[b]=f,siz[a]-=siz[b];
			tot+=(siz[a]&1)+(siz[b]&1);
		}
		return ;
	}
}dsu;

il void Solve(int l,int r,int L,int R){
	if(L>R)return ;
	int mid=(L+R)>>1,pos=0,t1=dsu.top,t2;
	for(int i=L;i<=mid;i++){
		if(p[i].w<l)dsu.Link(p[i].u,p[i].v);
	}
	t2=dsu.top;
	for(pos=l;pos<=r;pos++){
		if(q[pos].id<=mid){
			dsu.Link(q[pos].u,q[pos].v);
			if(!dsu.tot)break;
		}
	}
	if(pos<=r)ans[mid]=q[pos].w;
	dsu.Remove(t2);
	Solve(l,pos,mid+1,R);
	dsu.Remove(t1);
	for(int i=l;i<pos;i++){
		if(q[i].id<L)dsu.Link(q[i].u,q[i].v);
	}
	Solve(pos,r,L,mid-1);
	dsu.Remove(t1);
	return ;
}

int main(){
	ios::sync_with_stdio(0);
	cin.tie(0),cout.tie(0);
	cin>>n>>m;
	dsu.Init();
	for(int i=1;i<=m;i++){
		int u,v,w;cin>>u>>v>>w;
		p[i]=q[i]={u,v,w,i},ans[i]=-1;
	}
	sort(q+1,q+1+m,[](Oper a,Oper b){return a.w<b.w;});
	for(int i=1;i<=m;i++)p[q[i].id].w=i;
	Solve(1,m,1,m);
	for(int i=1;i<=m;i++)cout<<ans[i]<<"\n";
	return 0;
}
posted @ 2024-12-18 16:42  DycIsMyName  阅读(11)  评论(0编辑  收藏  举报