整体二分学习笔记
0.前言
整体二分算法在一定程度上推翻了本蒟蒻之前学习的一些内容、颠覆了本蒟蒻的认知、打开了全新世界的大门。故本蒟蒻认为有必要写个博客记录一下。
1.问题引入
1.1
有一道非常简单的题目:
例一、求区间内第 \(k\) 小的数
给出 \(a_1\sim a_n\),求 \(a_l\sim a_r\) 中第 \(k\) 小的数(即将 \(a_l\sim a_r\) 从小到大排序后排在第 \(k\) 位的数)。
\(1\le l\le r\le n\le 2\times 10^5\),\(1\le k\le r-l+1\),\(|a_i|\le 10^9\)。
当然,这题可以使用排序算法以及 STL 中的 sort
,priority_queue
或者 kth_element
等做。但是为了贴合主题,考虑使用二分答案。
单调性很显然,若第 \(k\) 小的数(用 \(p\) 表示) \(p\le q\,(q\in \mathbb{Z})\),则 \(p\le q+1\);若 \(p>q\),则 \(p>q-1\)。
所以二分一个 \(mid\),check
函数里判断是否满足 \(p\le mid\)(即存在不低于 \(k\) 个数 \(\le mid\))。是则记录答案,并往更小的值域找答案,否则往更大的值域找答案。
时间复杂度为 \(\mathcal{O}(n\log_2 n)\),空间复杂度为 \(\mathcal{O}(n)\),能够接受。
$\texttt{Code Click Here}$
#include<bits/stdc++.h>
using namespace std;
#define N 200005
int n,l,r,k,a[N],ql=-1e9,qr=1e9,ans;
bool check(int x){
int tot=0;
for(int i=l;i<=r;++i){
tot+=(a[i]<=x);
}
return tot>=k;
}
int main(){
cin.tie(0);
cout.tie(0);
ios::sync_with_stdio(0);
cin>>n>>l>>r>>k;
for(int i=1;i<=n;++i){
cin>>a[i];
}
while(ql<=qr){
int mid=ql+(qr-ql>>1);//由于有负数,取整、位运算可能会出奇奇怪怪的问题,所以通过取长度的一半来实现取中点
if(check(mid)){//查找更小的值域
ans=mid;//更新答案
qr=mid-1;
}else{//查找更大的值域
ql=mid+1;
}
}
cout<<ans;
}
1.2
现在,我们不妨将例一进行扩展:
给出 \(a_1\sim a_n\),共有 \(m\) 次询问,第 \(i\) 次询问 \(a_{l_i}\sim a_{r_i}\) 中第 \(k_i\) 小的数(第 \(k_i\) 小的数意义同 1.1)。
\(1\le n,m\le 2\times 10^5\),\(|a_i|\le 10^9\),\(1\le l_i\le r_i\le n\),\(1\le k_i\le r_i-l_i+1\)。
自然地,可以参照例一的思路,对每一次询问进行二分答案。
时间复杂度为 \(\mathcal{O}(mn\log_2 n)\),空间复杂度为 \(\mathcal{O}(n)\),无法接受。
于是,我们需要引进一种全新的、更高效的算法——整体二分。
2.算法介绍
2.1 算法思想
整体二分是一种离线的分治算法,其主要思想是将多个询问一起二分答案,从而得到提升效率的目的。
2.2 算法步骤(针对例二)
-
既然是分治算法,就可以递归求解。
-
设当前有询问集合 \(Q\) 和数集 \(A\)。为了方便,记询问集合中的元素为 \((l,r,k,id)\)(\(l,r,k\) 意义同题目,\(id\) 表示询问编号,即第几个输入),数集中的元素为 \((x,id)\)(\(x\) 表示数值,\(id\) 表示在数组中的下标)。令当前二分值域为 \([L,R]\),得到 \(mid=L+\dfrac{R-L}{2}\)(取数轴上长度的一半,在例一的代码中已经解释过)。
-
若 \(L=R\),则将当前询问集合 \(Q\) 中的所有询问 \((l,r,k,id)\) 的答案 \(ans_{id}\) 赋值为 \(L\) 并返回,否则继续执行后续的步骤。
-
将询问集合 \(Q\) 分成两个子集 \(M,N\),数集 \(A\) 分成两个子集 \(B,C\)。把区间中所有 \(\le mid\) 的数(数的意义为 \((x,id)\) 中的 \(x\),下同)插入数集 \(B\),\(>mid\) 的数插入数集 \(C\)。
-
对于询问集合 \(Q\) 中的每一个询问 \((l,r,k,id)\),查询在 \([l,r]\) 区间内是否有 \(\ge k\) 个 \(\le mid\) 的数。这一步可以使用树状数组做,在将 \(\le mid\) 的数 \((x,id)\) 插入 \(B\) 集合时,把树状数组上 \(id\) 的位置 \(+1\),这样一来,就维护了一个 \(\le mid\) 数在前缀 \([1,p](p\in \mathbb{Z})\) 中总共出现的个数 \(sum_p\)(算是一种个数的前缀和),\(sum_r-sum_{l-1}\) 就是 \([l,r]\) 内 \(\le mid\) 的数的个数 \(num\)。若 \(num\ge k\),则说明询问 \(id\) 的答案 \(ans_{id}\le mid\),将询问 \((l,r,k,id)\) 插入询问集合 \(M\),否则将询问 \((l,r,k-num,id)\) 插入询问集合 \(M\)。注意这里变成了第 \(k-num\) 小。因为容易得出 \(\forall i(i \in B) \le mid <\forall j(j\in C)\),即 \(B\) 集合中的任意数小于 \(C\) 集合中的任意数。当前询问已经在 \(B\) 集合中找到了 \(num\) 个数,在 \(C\) 集合中只要找到 \(\ge k-num\) 个 \(\le mid\) 的数(\(C\) 集合对应的递归函数中的 \(mid\),不是当前的 \(mid\)),就满足判定 \(ans_{id}\) 是否 \(\le mid\)(意义同上)的条件 \(num \ge k\)。
-
把树状数组清空(对于 \(B\) 集合中的数 \((x,id)\),在树状数组上 \(id\) 的位置 \(-1\)),因为在接下来的二分中,\(\le mid\)(是对应递归函数中的 \(mid\),不是当前的 \(mid\))的数的个数是要重新计数的。
-
现在已经将询问和数分成了二分答案值域为 \([L,mid]\)(询问集合 \(M\) 和数集 \(B\))和 \([mid+1,R]\)(询问集合 \(N\) 和数集 \(C\))的两部分,分别对这两部分递归、重复上述步骤进行整体二分求解。
2.3 算法复杂度分析(针对例二)
注:以下分析中时间复杂度和空间复杂度已经次数、层数等均为近似值。
设值域(上界减下界)为 \(V\),一共有 \(\log_2 V\) 层递归。每层递归中共有 \(n+m\) 次循环(对应 \(n\) 个数和 \(m\) 次询问),循环中要用到树状数组,每次循环中的复杂度为 \(\mathcal{O}(\log_2 n)\)。
通过上面的分析,容易得出整体二分算法的时间复杂度为 \(\mathcal{O}((n+m)\cdot \log_2 V\cdot \log_2 n)\),空间复杂度为 \(\mathcal{O}((n+m)\log_2 V)\)。
由于 \(n,m\) 同阶,可以认为时间复杂度为 \(\mathcal{O}(n\cdot \log_2 V\cdot\log_2 n)\),空间复杂度为 \(\mathcal{O}(n\log_2 V)\),能够接受。
离散化之后,可以将时间复杂度优化成 \(\mathcal{O}(n\log_2^2n)\),将空间复杂度优化成 \(\mathcal{O}(n\log_2 n)\)。
2.4 算法的代码实现(针对例二)
$\texttt{Code Click Here}$
#include<bits/stdc++.h>
using namespace std;
const int N=2e5+5;
int n,m,bit[N],ans[N];//bit 是树状数组。
struct query{//询问。
int l,r,k,id;//左端点、右端点、第 k 小、询问编号(即第几个输入)。
}Q[N];//询问集合。
struct num{//数。
int x,id;//数值、下标。
}A[N];//数集。
//树状数组。
void add(int x,int k){//单点加。
for(;x<=n;x+=x&-x){
bit[x]+=k;
}
}
int sum(int x){//前缀和。
int ret=0;
for(;x;x-=x&-x){
ret+=bit[x];
}
return ret;
}
//整体二分。
void solve(query q[],num a[],int l,int r,int lenq,int lena){//当前询问集合、当前数集、二分下界、二分上界、询问集合大小、数集大小。
if(!lenq){//如果没有询问的答案在 [l,r] 内,就返回不做了。
return;
}
if(l==r){//得到答案,赋值、返回。
for(int i=1;i<=lenq;++i){
ans[q[i].id]=l;
}
return;
}
int mid=l+(r-l)/2,lenq1=0,lenq2=0,lena1=0,lena2=0;//mid 取长度的一半,4 个 len 分别对应 4 个数组的长度。
query q1[lenq+5],q2[lenq+5];//q1 和 q2 分别对应上文说到的 M 和 N。
num a1[lena+5],a2[lena+5];//a1 和 a2 分别对应上文说到的 B 和 C。
for(int i=1;i<=lena;++i){
if(a[i].x<=mid){//如果 x<=mid。
add(a[i].id,1);//更新到树状数组上。
a1[++lena1]=a[i];//插入 a1(B)。
}else{
a2[++lena2]=a[i];//插入 a2(C)。
}
}
for(int i=1,p;i<=lenq;++i){
p=sum(q[i].r)-sum(q[i].l-1);//前缀和得到 num。
if(p>=q[i].k){//如果 num>=k。
q1[++lenq1]=q[i];//插入 q1(M)。
}else{
q[i].k-=p;//已经解释过,新的 k 要变成原来的 k 减去 p(num)。
q2[++lenq2]=q[i];//插入 q2(N)。
}
}
for(int i=1;i<=lena1;++i){//清空树状数组。
add(a1[i].id,-1);
}
solve(q1,a1,l,mid,lenq1,lena1);//整体二分值域为 [l,mid] 的部分。
solve(q2,a2,mid+1,r,lenq2,lena2);//整体二分值域为 [mid+1,r] 的部分。
}
int main(){
cin.tie(0);
cout.tie(0);
ios::sync_with_stdio(0);
cin>>n>>m;
for(int i=1,x;i<=n;++i){
cin>>x;
A[i]={x,i};//插入初始数集 A。
}
for(int i=1,l,r,k;i<=m;++i){
cin>>l>>r>>k;
Q[i]={l,r,k,i};//离线询问。
}
solve(Q,A,-1e9,1e9,m,n);//整体二分。
for(int i=1;i<=m;++i){//输出答案。
cout<<ans[i]<<'\n';
}
}
本蒟蒻的评测记录:link。
经过实测,在 solve
中 q(Q),a(A),q1(M),q2(N),a1(B),a2(C)
使用数组比使用 vector
效率高许多。
3 习题
3.1 CF484E Sign on Fence
注:以下内容均搬运与我的博客 CF484E Sign on Fence。
\(\texttt{Description}\)
给定一个长度为 \(n\) 的数列 \(A\),有 \(m\) 次询问,每次在给出 \(A_l\sim A_r\) 中选一个长度为 \(k\) 的子区间,使得选出区间的最小值最大。
\(1\le n,m\le 10^5\),\(1\le A_i\le 10^9\)。
\(\texttt{Solution}\)
首先看到最小值最大,不难想到二分。由于是多组询问,所以考虑整体二分。
在整体二分中,我们套路地将所有询问当前答案的值域 \([L,R]\) 分成 \([L,\text{mid}]\) 和 \((\text{mid},R]\) 两部分。 先二分一个 \(\text{mid}\),然后维护所有 \(> \text{mid}\) 的数的最长连续子区间长度。每次询问查询 \([l,r]\) 这一区间的答案。若结果 \(\ge k\),说明答案在 \((\text{mid},R]\) 中,否则在 \([L,\text{mid}]\) 中。然后继续对这两部分递归求解即可。若 \(L=R\),则更新当前递归内的所有询问的答案。
具体来讲,可以参照洛谷 P4513 的思路,使用线段树维护所有 \(> \text{mid}\) 的数的最长连续子区间长度,即把所有 \(>\text{mid}\) 的数的贡献统计到线段树上。考虑在线段树的每个节点中维护 \(\text{len},\text{llen},\text{rlen},\text{sz}\) 四个值,表示该节点的答案、左起最长连续段长度、右起最长连续段长度和该节点对应的区间长度。
令 \(x\) 为当前节点,\(\text{ls}\) 为左儿子,\(\text{rs}\) 为右儿子。则有:
-
若 \(\text{llen}_{\text{ls}}\ne \text{sz}_\text{ls}\),说明左起最长连续段不横跨左右儿子的区间,\(\text{llen}_x=\text{llen}_{\text{ls}}\);否则说明横跨左右儿子的区间,\(\text{llen}_x=\text{sz}_\text{ls}+\text{llen}_\text{rs}\)。\(\text{rlen}_x\) 类似维护即可。
-
\(\text{sz}_x=\text{sz}_\text{ls}+\text{sz}_\text{rs}\)。显而易见。
-
\(\text{len}_x=\max\{\text{len}_\text{ls},\text{len}_\text{rs},\text{rlen}_\text{ls}+\text{llen}_\text{rs}\}\)。表示分别考虑答案完全在左儿子区间内、完全在右儿子区间内和横跨左右儿子的区间。
此外,这题并不像模板题一样答案符合可加性,因为模板题只有数量的限制,可以加加减减。但是这题还有连续的限制,就不能像模板题那样,若查询结果为 \(p\) 且 \(p<k\),则在 \([L,\text{mid}]\) 内查找剩下的 \(k-p\) 个数。但是若暴力往两个区间内插入 \(>\text{mid}\) 的数,肯定会 TLE
。由于 \(>\text{mid}\) 的数在 \([L,\text{mid}]\) 仍有贡献(用它连接起两端的连续段,符合连续的限制),所以可以考虑先递归求解 \([L,\text{mid}]\) 的答案,再消除当前递归中的数在线段树中的贡献,再递归求解 \((\text{mid},R]\)。注意,在递归求解 \([L,\text{mid}]\) 的答案时,询问内的 \(k\) 值不能改成 \(k-p\),因为那 \(p\) 个值仍然在线段树上有贡献。这样就可以在保证效率的同时实现连续的限制了。
令 \(V\) 为值域(上界减下界),这样的方法时间复杂度和空间复杂度均为 \(\mathcal{O}((n+m)\cdot \log n\cdot \log V)\),可以接受。
\(\texttt{Code}\)
实现细节:
-
我是用
vector
储存整体二分中的数与询问的。 -
使用指令集或离散化(嫌麻烦没写 qwq)可以加快代码运行效率。
$\texttt{Click for Code}$
#pragma GCC optimize("Ofast")
#include<bits/stdc++.h>
using namespace std;
#define ls x*2
#define rs x*2+1
const int N=100005;
int n,m,ans[N];
struct queries{
int l,r,k,id;
};
vector<queries>Q;
struct num{
int x,p;
};
vector<num>A;
struct tree{
int len,llen,rlen,sz;
}sg[N*4];
void up(int x){
sg[x].sz=sg[ls].sz+sg[rs].sz;
sg[x].len=max({sg[ls].len,sg[rs].len,sg[ls].rlen+sg[rs].llen});
sg[x].llen=(sg[ls].len==sg[ls].sz?sg[ls].len+sg[rs].llen:sg[ls].llen);
sg[x].rlen=(sg[rs].len==sg[rs].sz?sg[rs].len+sg[ls].rlen:sg[rs].rlen);
}
void build(int x,int l,int r){
if(l==r){
sg[x]={0,0,0,1};
return;
}
int mid=l+r>>1;
build(ls,l,mid);
build(rs,mid+1,r);
up(x);
}
void change(int x,int l,int r,int k,int v){
if(l==r&&l==k){
sg[x]={v,v,v,1};
return;
}
int mid=l+r>>1;
if(k<=mid){
change(ls,l,mid,k,v);
}else{
change(rs,mid+1,r,k,v);
}
up(x);
}
tree query(int x,int l,int r,int ql,int qr){
if(ql<=l&&qr>=r){
return sg[x];
}
int mid=l+r>>1;
if(qr<=mid){
return query(ls,l,mid,ql,qr);
}else if(ql>mid){
return query(rs,mid+1,r,ql,qr);
}else{
tree t1=query(ls,l,mid,ql,qr),t2=query(rs,mid+1,r,ql,qr);
return {max({t1.len,t2.len,t1.rlen+t2.llen}),(t1.len==t1.sz?t1.len+t2.llen:t1.llen),(t2.len==t2.sz?t2.len+t1.rlen:t2.rlen),t1.sz+t2.sz};
}
}
void solve(vector<queries>q,vector<num>a,int l,int r){
if(!q.size()||!a.size()){
return;
}
if(l^r){
int mid=l+r>>1;
vector<queries>q1,q2;
vector<num>a1,a2;
for(auto i:a){
if(i.x>mid){
a1.push_back(i);
change(1,1,n,i.p,1);
}else{
a2.push_back(i);
}
}
for(auto i:q){
tree tmp=query(1,1,n,i.l,i.r);
if(tmp.len>=i.k){
q1.push_back(i);
}else{
q2.push_back(i);
}
}
solve(q2,a2,l,mid);
for(auto i:a){
if(i.x>mid){
change(1,1,n,i.p,0);
}
}
solve(q1,a1,mid+1,r);
}else{
for(auto i:q){
ans[i.id]=l;
}
}
}
signed main(){
cin>>n;
for(int i=1,x;i<=n;++i){
cin>>x;
A.push_back({x,i});
}
cin>>m;
for(int i=1,l,r,k;i<=m;++i){
cin>>l>>r>>k;
Q.push_back({l,r,k,i});
}
build(1,1,n);
solve(Q,A,1,1e9);
for(int i=1;i<=m;++i){
cout<<ans[i]<<'\n';
}
}