「学习笔记」整体二分
整体二分
应用前提
- 询问的答案具有可二分性。
- 修改对判定答案的贡献互相独立,修改之间互不影响效果。
- 修改如果对判定答案有贡献,则贡献为一确定的与判定标准无关的值。
- 贡献满足交换律、结合律,具有可加性。
- 题目允许离线算法。
引入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。
- 区间求和。
用线段树可以轻松解决,也可以用树状数组+差分解决这一问题。下面给出用树状数组实现的全代码:
#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)
的操作。这是等效的,但后者常数更小。
值得注意的是,本题代码写出来一般常数较大,这里有几处优化:
- 用索引排序,即仅记录对应下标而不是整个复制结构体。由于结构体内含的参数过多,每个数字复制时都带有一定的常数,累加起来会较慢。而仅复制下标常数更小。
- 区间修改可以直接改为几次单点的修改操作。在更改为单点修改的前提下,不用树状数组维护这些统计,而用一个桶维护。去掉所有查询操作,将所有修改操作按照修改的端点排序,统计时直接根据索引找到对应的修改即可。不使用树状数组可以少个 \(\log\)。
- 读入输出挂。即 IO 优化。
在这里我直接贴别人代码了(
推荐习题
参考文献
- 许昊然《浅谈数据结构题几个非经典解法》(2013年信息学奥林匹克中国国家队候选队员论文)
- OI Wiki - 整体二分