「学习笔记」整体二分

整体二分

应用前提

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

引入1

在一个数列中查询第 \(k\) 小的数。

法1:简单粗暴,直接 sort,或者用 nth_element。

法2:考虑值域上的二分。用数据结构记录每个大小范围中有多少数,二分查找到位置。

引入2

在一个数列中多次查询第 \(k\) 小的数。

法1:简单粗暴,直接 sort。因为这既是静态,查询区间也不变。

法2:对每个询问采用上题的法2,分别进行二分。

法3:采用整体二分的思想。我们可以将所有的询问放在一起二分。

我们可以猜测当前所有询问的答案为 \(mid\),然后依次检验每个询问的答案,并依此分为小于等于和大于 \(mid\) 两部分,对于两部分继续二分。这其实是一个分治的过程。

若询问的答案 \(\le mid\),则说明 \(mid\) 是第 \(\ge k\) 小的数,因此第 \(k\) 小的数在 \([l,mid]\)

若询问的答案 \(>mid\),则说明 \(mid\) 是第 \(<k\) 小的数,因此第 \(k\) 小的数在 \([mid+1,r]\)。设询问得到 \(\le mid\) 的数有 \(x\) 个,那么问题转化为求出现在值域 \([mid+1,r]\) 中第 \(k-x\) 小的数。

\(l=r\) 时,我们结束该部分的二分,并给出该部分询问对应的答案。

下面贴个代码:

void solve(int l,int r,int L,int R)
{
	if(l==r)
	{
		for(int i=L;i<=R;i++)
			Ans[q[i].id]=l;
		/*值为l答案统计现场*/
		return;
	}
	int mid=(l+r)>>1;
	int cnt1=0,cnt2=0;
	for(int i=L;i<=R;i++)
	{
		int sum=query(mid);
		/*值域内查找<=mid的个数,一般用树状数组统计*/
		if(q[i].k<=sum)
			ql[++cnt1]=q[i];
		/*若<=说明在[l,mid]*/ 
		else
		{
			q[i].k-=sum;
			qr[++cnt2]=q[i];
		}
		/*否则说明在[mid+1,r]*/
	}
	/*由于接下来的二分我们还需要用到q数组
	所以需要将新处理的序列复制回去 
	*/
	for(int i=1;i<=cnt1;i++)
		q[i+L-1]=ql[i];
	/*复制左序列*/
	for(int i=1;i<=cnt2;i++)
		q[i+L+cnt1-1]=qr[i];
	/*复制右序列*/
	solve(l,mid,L,L+cnt1-1);
	solve(mid+1,r,L+cnt1,R);
}

引入3

静态区间第 \(k\)​ 小。

题目链接

我们曾经用主席树的写法解决了这个问题,现在用整体二分的想法去思考一下。

用类似于上题的写法,设当前询问为 \(q[i]\),我们需要统计出在 \([q[i].l,q[i].r]\) 区间内 \(\le m\) 的个数。单点加,区间询问,用树状数组即可轻松解决。

注意,如果直接 memset 清空树状数组,会清空很多根本就没有操作过的位置。相反,我们只需将修改过的地方撤销操作即可。(即加上 -1

由于序列中原来已有初始值,为了能够顺利使用整体二分,我们将原序列的 \(n\)​ 个数看做是 \(n\)​ 次单点插入,将这些插入操作先于询问操作加入操作队列,这样就可以保证在查询前,对应区间内所需的所有插入已插入完毕。

具体时间复杂度分析及证明可以看这篇博客:https://blog.csdn.net/lwt36/article/details/50669972

void solve(int l,int r,int L,int R)
{
	if(l==r)
	{
		for(int i=L;i<=R;i++)
			if(q[i].op==2)Ans[q[i].id]=l;
		/*值为l答案统计现场*/
		return;
	}
	int mid=(l+r)>>1;
	int cnt1=0,cnt2=0;
	for(int i=L;i<=R;i++)
	{
		/*注意此处:
		如果在主函数内先添加修改操作,再添加查询操作
		那么修改操作一定先于其后的查询操作 
		因此两个操作可以合在一起写:
		*/
		if(q[i].op==1)
		{
			if(q[i].k<=mid)
			{
				ql[++cnt1]=q[i];
				add(q[i].id,1);
				/*在k这个值出现的位置id进行修改*/
			}
			else qr[++cnt2]=q[i]; 
		}
		else
		{
			int sum=query(q[i].r)-query(q[i].l-1);
			/*区间内查找<=mid的个数*/
			if(q[i].k<=sum)
				ql[++cnt1]=q[i];
			/*若<=说明在[l,mid]*/ 
			else
			{
				q[i].k-=sum;
				qr[++cnt2]=q[i];
			}
			/*否则说明在[mid+1,r]*/
		}
	}
	/*
	注意到如果即是修改又在ql数组内
	必然是添加操作
	直接用add(-1)的方法撤销即可 
	*/ 
	for(int i=1;i<=cnt1;i++)
		if(ql[i].op==1)
			add(ql[i].id,-1);
	/*由于接下来的二分我们还需要用到q数组
	所以需要将新处理的序列复制回去 
	*/
	for(int i=1;i<=cnt1;i++)
		q[i+L-1]=ql[i];
	/*复制左序列*/
	for(int i=1;i<=cnt2;i++)
		q[i+L+cnt1-1]=qr[i];
	/*复制右序列*/
	solve(l,mid,L,L+cnt1-1);
	solve(mid+1,r,L+cnt1,R);
}
/*
一组数据供模拟 
input:
5 5
3 1 2 4 5
2 2 1
3 4 1
4 5 1
1 2 2
4 4 1
output:
1
2
4
3
4
*/

引入4

给定数列,支持单点修改,区间查询第 \(k\)​ 小。

题目链接

将单点修改改为:删去原数,在原位置插入新数。将一个操作拆为两个,几乎与引入3代码相同地套上整体二分即可。

具体代码实现

例题 1

【ZJOI2013】K大数查询

你需要维护 \(n\) 个可重整数集,集合的编号从 \(1\)\(n\)
这些集合初始都是空集,有 \(m\) 个操作:

  • 1 l r k:表示将 \(k\) 加入到编号在 \([l,r]\) 内的集合中。\((1\le k\le n)\)
  • 2 l r k:表示查询编号在 \([l,r]\) 内的集合的并集中,第 \(k\) 大的数是多少。\((1\le k\le 2^{31}-1)\)

注意可重集的并是不去除重复元素的,如 \(\{1,1,4\}\cup\{5,1,4\}=\{1,1,4,5,1,4\}\)​。

题目链接

如果运用整体二分算法,我们相当于将问题转化为:

有一序列支持:

  1. 区间加 1。
  2. 区间求和。

用线段树可以轻松解决,也可以用树状数组+差分解决这一问题。下面给出用树状数组实现的全代码:

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

typedef long long ll;
const int M=5e4+5;

inline ll read()
{
	ll x=0,f=1;static char ch;
	while(ch=getchar(),ch<48)if(ch==45)f=0;
	do x=(x<<1ll)+(x<<3ll)+(ch^48);
	while(ch=getchar(),ch>=48);
	return f?x:-x;
}

int n,m,Ans[M];
bool out[M];

struct cpp
{
	int op,l,r,id;ll c;
	void init(int i)
	{
		op=read(),l=read(),r=read();
		c=read(),id=i;
		if(op==2)out[i]=true;
	}
}q[M],ql[M],qr[M];

struct BIT
{
	ll c1[M],c2[M];
	void init()
	{
		memset(c1,0,sizeof(c1));
		memset(c2,0,sizeof(c2));
	}
	#define lowbit(x) x&(-x)
	void add(int i,ll x)
	{
		int p=i;
		while(i<=n)c1[i]+=x,c2[i]+=x*(p-1),i+=lowbit(i);
	}
	ll query(int i)
	{
		int p=i;ll res=0;
		while(i>0)res+=c1[i]*p-c2[i],i-=lowbit(i);
		return res;
	}
}T;
/*支持区间+1区间求和的树状数组*/
void solve(int l,int r,int L,int R)
{
	if(l==r)
	{
		for(int i=L;i<=R;i++)
			if(q[i].op==2)Ans[q[i].id]=l;
		 /*值为l答案统计现场*/
		return;
	}
	int mid=(l+r)>>1;
	int cnt1=0,cnt2=0;
	/*
	由于是求第k大,与上述代码需要相反
	即寻找左右区间的相反(ql和qr数组) 
	*/
	for(int i=L;i<=R;i++)
	{
		if(q[i].op==1)
		{
			if(mid<q[i].c)
			{
				T.add(q[i].l,1);
				T.add(q[i].r+1,-1);
				/*区间修改,含差分思想*/
				qr[++cnt2]=q[i];
			}
			else ql[++cnt1]=q[i];
		}
		else
		{
			ll num=T.query(q[i].r)-T.query(q[i].l-1);
			/*区间求和*/
			if(num>=q[i].c)qr[++cnt2]=q[i];
			else q[i].c-=num,ql[++cnt1]=q[i];
		}
	}
	/*注意到如果即是修改又在qr数组内
	必然是区间修改操作
	直接用add(-1)的方法撤销即可
	*/
	for(int i=1;i<=cnt2;i++)
		if(qr[i].op==1)
		{
			T.add(qr[i].l,-1);
			T.add(qr[i].r+1,1);
		}
	/*还是一样的复制操作*/
	for(int i=1;i<=cnt1;i++)
		q[i+L-1]=ql[i];
	for(int i=1;i<=cnt2;i++)
		q[i+L+cnt1-1]=qr[i];
	solve(l,mid,L,L+cnt1-1);
	solve(mid+1,r,L+cnt1,R);
}
int main()
{
	T.init();
	n=read(),m=read();
	for(int i=1;i<=m;i++)
		q[i].init(i);
	
	solve(1,n,1,m);
	/*
	值域:1-n
	操作:1-m 
	*/
	for(int i=1;i<=m;i++)
		if(out[i])printf("%d\n",Ans[i]);
	return 0;
}

但如果复杂度只能与操作个数相关,不能与序列长度线性相关呢?(即序列长度达到 \(10^9\) 级别,此时线段树或树状数组难以支持上述操作)

注意到这些修改操作满足”修改独立“这一性质。因此我们采用对时间分治的方法,将问题转化为:

给出若干区间,求该区间与之前所有区间的交集的长度之和。

直接排序后扫一遍即可解决,时间复杂度为 \(O(n\log^3 n)\),用归并排序可优化至 \(O(n\log^2 n)\)

代码实现?全网都没有为什么我会写

例题2

矩阵乘法

给定 \(n\times m\) 的矩阵,查询某个子矩阵的第 \(k\)​ 大值。

题目链接

一维 \(\rightarrow\)​ 二维。仍然是一样的套路,只不过变为统计当前询问子矩阵中 \(\le mid\) 数的个数。这个东西可以用二维树状数组去维护,实现起来也比较简单。

具体代码实现

例题3

[POI2011]MET-Meteors

Byteotian Interstellar Union 有 \(n\)​ 个成员国。现在它发现了一颗新的星球,这颗星球的轨道被分为 \(m\) 份(第 \(m\) 份和第 \(1\) 份相邻),第 \(i\) 份上有第 \(a_i\) 个国家的太空站。

这个星球经常会下陨石雨。BIU 已经预测了接下来 \(k\) 场陨石雨的情况。

BIU 的第 \(i\) 个成员国希望能够收集 \(p_i\) 单位的陨石样本。你的任务是判断对于每个国家,它需要在第几次陨石雨之后,才能收集足够的陨石。

题目链接

前面所有的题目几乎都是一个板子套来套去,接下来我们看看这道整体二分的经典例题。

容易发现答案具有单调性:设 \(t\) 为答案,那么 \(<t\) 的时刻都不满足条件,\(\ge t\)​ 的时刻都满足。每个国家都二分一次的时间复杂度显然是无法承受的,因此我们考虑整体二分。

我们将时间在 \([l,mid]\) 间的陨石雨全部降下,统计得到该国家获得的陨石数。如果陨石数 \(\ge p_i\),则说明 \(t\in [l,mid]\),否则 \(t\in [mid+1,r]\)​。陨石数可以用树状数组维护,区间修改单点查询。由于是一个环,我们可以破环成链,但也可以增加一个 if(l>r)add(1,k) 的操作。这是等效的,但后者常数更小。

具体代码实现

值得注意的是,本题代码写出来一般常数较大,这里有几处优化:

  1. 用索引排序,即仅记录对应下标而不是整个复制结构体。由于结构体内含的参数过多,每个数字复制时都带有一定的常数,累加起来会较慢。而仅复制下标常数更小。
  2. 区间修改可以直接改为几次单点的修改操作。在更改为单点修改的前提下,不用树状数组维护这些统计,而用一个桶维护。去掉所有查询操作,将所有修改操作按照修改的端点排序,统计时直接根据索引找到对应的修改即可。不使用树状数组可以少个 \(\log\)
  3. 读入输出挂。即 IO 优化。

在这里我直接贴别人代码了(

具体代码实现

推荐习题


参考文献

  • 许昊然《浅谈数据结构题几个非经典解法》(2013年信息学奥林匹克中国国家队候选队员论文)
  • OI Wiki - 整体二分
posted @ 2021-08-09 22:12  cyl06  阅读(139)  评论(0编辑  收藏  举报