整体二分学习笔记

发布时间 2023-11-06 16:03:32作者: 蒟蒻·廖子阳

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 中的 sortpriority_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

现在,我们不妨将例一进行扩展:

例二、洛谷 P3834 【模板】可持久化线段树 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 算法步骤(针对例二)

  1. 既然是分治算法,就可以递归求解。

  2. 设当前有询问集合 \(Q\) 和数集 \(A\)。为了方便,记询问集合中的元素为 \((l,r,k,id)\)\(l,r,k\) 意义同题目,\(id\) 表示询问编号,即第几个输入),数集中的元素为 \((x,id)\)\(x\) 表示数值,\(id\) 表示在数组中的下标)。令当前二分值域为 \([L,R]\),得到 \(mid=L+\dfrac{R-L}{2}\)(取数轴上长度的一半,在例一的代码中已经解释过)。

  3. \(L=R\),则将当前询问集合 \(Q\) 中的所有询问 \((l,r,k,id)\) 的答案 \(ans_{id}\) 赋值为 \(L\) 并返回,否则继续执行后续的步骤。

  4. 将询问集合 \(Q\) 分成两个子集 \(M,N\),数集 \(A\) 分成两个子集 \(B,C\)。把区间中所有 \(\le mid\) 的数(数的意义为 \((x,id)\) 中的 \(x\),下同)插入数集 \(B\)\(>mid\) 的数插入数集 \(C\)

  5. 对于询问集合 \(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\)

  6. 把树状数组清空(对于 \(B\) 集合中的数 \((x,id)\),在树状数组上 \(id\) 的位置 \(-1\)),因为在接下来的二分中,\(\le mid\)(是对应递归函数中的 \(mid\),不是当前的 \(mid\))的数的个数是要重新计数的。

  7. 现在已经将询问和数分成了二分答案值域为 \([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

经过实测,在 solveq(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}\)

CodeForces 链接

洛谷链接

  • 给定一个长度为 \(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';
    }
}