整体二分 学习笔记
整体二分
本文通过介绍几道例题的解法,带你深入了解整体二分的精髓。
例题
大致按难度排序,其中,中间的三道题都是类似的。
- P3527 [POI2011] MET-Meteors
- P3332 [ZJOI2013] K大数查询
- P2617 Dynamic Rankings
- P1527 [国家集训队] 矩阵乘法
- P5163 WD与地图
简介
给你 \(Q\) 个询问,每个询问形如第 \(k\) 小是什么或某个点什么时候符合所给条件(达到一定数量),中间可能带有修改,并且不强制在线。
一般来说,如果 \(Q\) 个询问可以分别每次用二分的方式求答案,那么我们可以把这 \(Q\) 次询问合在一起做一个分治递归,这就是整体二分。
多说无用,直接看例题。
P3527 [POI2011] MET-Meteors
我们要求每个国家在什么时间满足条件,所以二分时间。
我们设 \(solve(l,r,S)\),表示已知 \(S\) 这个集合中每个国家的答案都在 \([l,r]\) 的区间内,然后求出它们具体的时间。
设 \(mid=\lceil{\dfrac{l+r}{2}\rceil}\)。
我们可以把时间在 \([l,mid]\) 的修改执行。
之后判断 \(S\) 中的每个国家,是否已满足条件,是则说明它的答案在 \([l,mid]\) 内,否则在 \([mid+1,r]\) 内。
我们可以新建两个 vector
把 \(S\) 分掉,然后往两边递归。
考虑右区间 \([mid+1,r]\) 需要用到 \([l,mid]\) 修改的贡献,所以可以这样:
- 先递归右区间 \([mid+1,r]\)。
- 清除 \([l,mid]\) 修改的贡献。
- 递归左区间 \([l,mid]\)。
我们可以用树状数组处理修改带来的贡献。
考虑复杂度,默认各数据同阶。
最多往下递归 \(O(\log n)\) 层。
对于每个时间的修改,它在每层都出现,而在树状数组上修改贡献是 \(O(\log n)\) 的。每个时间的修改的总数是 \(O(n)\) 的,于是修改是 \(O(n\log ^2n)\) 的。
对于查询,一个国家在每层出现一次,每次 \(O(\log n)\) 查询,于是也是 \(O(n\log^2n)\) 的。
总复杂度 \(O(n\log ^2n)\)。
参考代码:
ull s[N];
int n,m,K;
void update(int x,int y){
while(x<=m)s[x]+=y,x+=lowbit(x);
}
ull query(int x){
ull sum=0;
while(x)sum+=s[x],x-=lowbit(x);
return sum;
}
int p[N];
vector<int> h[N];
int ql[N],qr[N],qa[N];
int ans[N];
void add(int l,int r,int a){
if(l<=r)update(l,a),update(r+1,-a);
else update(l,a),update(1,a),update(r+1,-a);
}
void solve(int l,int r,vector<int> &s){
if(l==r){
for(int i:s)ans[i]=l;
return;
}
int mid=(l+r)>>1;
fo(i,l,mid){
add(ql[i],qr[i],qa[i]);
}
vector<int> t1,t2;
for(auto i:s){
ull sum=0;
for(int v:h[i]){
sum+=query(v);
}
if(sum>=p[i])t1.push_back(i);
else t2.push_back(i);
}
solve(mid+1,r,t2);
fo(i,l,mid){
add(ql[i],qr[i],-qa[i]);
}
solve(l,mid,t1);
}
signed main(){
IOS;
cin>>n>>m;
fo(i,1,m){
int a; cin>>a;
h[a].push_back(i);
}
vector<int> S;
fo(i,1,n){
cin>>p[i];
S.push_back(i);
}
int Q;
cin>>Q;
fo(i,1,Q)cin>>ql[i]>>qr[i]>>qa[i];
ql[Q+1]=qr[Q+1]=1;
solve(1,Q+1,S);
fo(i,1,n){
if(ans[i]==Q+1)cout<<"NIE\n";
else cout<<ans[i]<<'\n';
}
return 0;
}
P3332 [ZJOI2013] K大数查询
这题相比第一题的一个限制,有两个限制:区间 \([l,r]\) 和时间戳的限制。
由于求第 \(k\) 大,所以二分第 \(k\) 大,要离散化。
所以考虑 \(solve(l,r,S)\) 内怎么写。
我们可以每次把值为 \([l,mid]\) 的修改拿下来,按时间戳排序。
同时我们保证 \(S\) 也是按时间戳排好序的。
那么此时可以用双指针,即可更新 \([l,mid]\) 的修改对 \(S\) 内的询问的影响,可用线段树解决区间加以及区间求和。
然后就可以知道,当前每个 \(S\) 的询问区间中,小于等于 \(mid\) 的数有多少个,若大于等于 \(k\),那么往左区间递归,否则往右区间递归。
我们可以用 \(cnt\) 表示每个询问当前的贡献,用 \(dcnt\) 表示每个询问在执行修改前的贡献,这样就可以在递归左区间时清除贡献,而递归右区间时不用清除贡献,线段树也可以每次清空了。
考虑时间复杂度,递归 \(O(\log n)\) 层,因此每个修改执行 \(O(\log n)\) 次,每次 \(O(\log n)\)。而对于每个询问,在每层都用 \(O(\log n)\) 查询。
因此总复杂度是 \(O(n\log^2n)\)。
代码
const int N=5e4+5;
ll s[N*4],tag[N*4];
#define ls (x<<1)
#define rs (ls|1)
#define mid ((l+r)>>1)
void down(int x,int l,int r){
if(tag[x]){
s[x]+=(ll)tag[x]*(r-l+1);
if(l<r)tag[ls]+=tag[x],tag[rs]+=tag[x];
tag[x]=0;
}
}
void update(int x,int l,int r,int L,int R,int y){
if(L<=l&&r<=R){
tag[x]+=y;
down(x,l,r);
return;
}
down(x,l,r);
if(R<l||r<L)return ;
update(ls,l,mid,L,R,y),update(rs,mid+1,r,L,R,y);
s[x]=s[ls]+s[rs];
}
ll query(int x,int l,int r,int L,int R){
if(R<l||r<L)return 0;
down(x,l,r);
if(L<=l&&r<=R)return s[x];
return query(ls,l,mid,L,R)+query(rs,mid+1,r,L,R);
}
struct arr{
int l,r,id; ll c;
};
int n,m;
vector<arr> h[N*2];
vector<int> qout;
int ans[N];
ll cnt[N],dcnt[N];
void solve(int l,int r,vector<arr> &S){
if(l==r){
for(auto i:S)ans[i.id]=l-N;
return;
}
vector<arr> T;
fo(i,mid+1,r){
for(auto j:h[i])T.push_back(j);
}
sort(T.begin(),T.end(),[](arr x,arr y){
return x.id<y.id;
});
int j=0;
for(auto i:S)dcnt[i.id]=cnt[i.id];
vector<arr> S1,S2;
fo(i,0,(int)T.size()-1){
while(j<S.size()&&S[j].id<T[i].id)
cnt[S[j].id]+=query(1,1,n,S[j].l,S[j].r),++j;
update(1,1,n,T[i].l,T[i].r,1);
}
while(j<S.size())cnt[S[j].id]+=query(1,1,n,S[j].l,S[j].r),++j;
for(auto i:S){
if(cnt[i.id]>=i.c)S1.push_back(i);
else S2.push_back(i);
}
for(auto i:T){
update(1,1,n,i.l,i.r,-1);
}
solve(l,mid,S2);
for(auto i:S1)cnt[i.id]=dcnt[i.id];
solve(mid+1,r,S1);
}
signed main(){
IOS;
cin>>n>>m;
vector<arr> S;
fo(i,1,m){
int opt; cin>>opt;
int l,r; ll c; cin>>l>>r>>c;
if(opt==1){
h[c+N].push_back({l,r,i,0});
}else{
S.push_back((arr){l,r,i,c});
qout.push_back(i);
}
}
solve(-n+N,n+N,S);
for(int i:qout)cout<<ans[i]<<"\n";
return 0;
}
P2617 Dynamic Rankings
这题和上一题是类似的。
把 \(a_x\) 改成 \(y\),就相当于删掉原来的 \(a_x\),然后加上一个 \(y\)。
另外由于变成了单点加区间求和,于是可以选用树状数组。
然后就和上一题一样了,不再过多阐述,时间复杂度 \(O(n\log ^2n)\)。
代码
int lowbit(int x){
return x&(-x);
}
void update(int x,int y){
while(x<N)s[x]+=y,x += lowbit(x);
}
int query(int x){
int sum=0;
while(x) sum += s[x], x-=lowbit(x);
return sum;
}
int n,m,a[N];
char opt[N];
int q1[N],q2[N],q3[N];
struct arr{
int l,r,k,id;
};
struct upd{
int x,y,id;
};
vector<upd> q[N];
int ans[N];
int cnt[N],dcnt[N];
void solve(int l,int r,vector<arr> &S){
if(!S.size())return;
if(l==r){
for(auto i:S)ans[i.id]=l;
return;
}
int mid=(l+r)>>1;
vector<upd> Q;
fo(i,l,mid)for(auto j:q[i])Q.push_back(j);
sort(Q.begin(),Q.end(),[](auto x,auto y){
return x.id<y.id;
});
for(auto i:S)dcnt[i.id]=cnt[i.id];
int j=0;
for(auto i:Q){
while(j<S.size()&&S[j].id<i.id){
cnt[S[j].id]+=query(S[j].r)-query(S[j].l-1);
++j;
}
update(i.x,i.y);
}
while(j<S.size()){
cnt[S[j].id]+=query(S[j].r)-query(S[j].l-1);
++j;
}
vector<arr> S1,S2;
for(auto i:S){
if(i.k<=cnt[i.id])S1.push_back(i),cnt[i.id]=dcnt[i.id];
else S2.push_back(i);
}
for(auto i:Q)update(i.x,-i.y);
solve(l,mid,S1);
solve(mid+1,r,S2);
}
int main(){
ios::sync_with_stdio(0);
vector<int> b;
cin>>n>>m;
fo(i,1,n){
cin>>a[i];
b.push_back(a[i]);
}
fo(i,1,m){
cin>>opt[i];
if(opt[i]=='C')cin>>q1[i]>>q2[i],b.push_back(q2[i]);
else cin>>q1[i]>>q2[i]>>q3[i];
}
sort(b.begin(),b.end());
auto ed=unique(b.begin(),b.end());
fo(i,1,n){
a[i]=lower_bound(b.begin(),ed,a[i])-b.begin();
q[a[i]].push_back({i,1,0});
}
vector<arr> S;
fo(i,1,m){
if(opt[i]=='C'){
q2[i]=lower_bound(b.begin(),ed,q2[i])-b.begin();
q[a[q1[i]]].push_back({q1[i],-1,i});
a[q1[i]]=q2[i];
q[a[q1[i]]].push_back({q1[i],1,i});
}
else{
S.push_back({q1[i],q2[i],q3[i],i});
}
}
solve(0,ed-b.begin()-1,S);
fo(i,1,m){
if(opt[i]=='Q')cout<<b[ans[i]]<<"\n";
}
}
P1527 [国家集训队] 矩阵乘法
现在变成了矩阵求第 \(k\) 小。
还是和上两题类似的,区别就是要考虑怎么得到一个矩阵内的和。
我们可以把修改按照 \(x\) 为第一关键字,\(y\) 为第二关键字排序,然后用树状数组就能对于每个 \(j\),实时维护前 \(j\) 列的和。
而我们只要在第 \(i\) 行修改做完时,查询前 \(j\) 列的和,就能得到 \((1,1)\) 为左上角,\((i,j)\) 为右下角的矩阵和,记为 \(s_{i,j}\)。
而对于以 \((x_1,y_1)\) 为左上角,\((x_2,y_2)\) 为右下角的矩阵和,它就等于 \(s_{x2,y2}-s_{x1-1,y2}-s_{x2,y1-1}+s_{x1-1,y1-1}\)。
我们只需要求出所需的 \(s\) 即可,还是运用双指针的方法。
考虑时间复杂度,对于矩阵中每个位置的值,每层执行一次在树状数组上 \(O(\log n)\) 的修改,共有 \(O(\log n^2)\) 个递归层,这部分是 \(O(n^2 \log n^2\log n)\)。
而对于每个询问,在 \(O(\log n^2)\) 个递归层里,每层进行 \(O(\log n)\) 的查询,这部分是 \(O(Q \log n^2 \log n)\)。
所以总时间复杂度是 \(O((Q+n^2)\log n^2\log n)\)。
代码
const int N=505;
const int M=6e4+5;
int lowbit(int x){
return x&(-x);
}
int s[N];
void update(int x,int y){
while(x<N)s[x]+=y,x+=lowbit(x);
}
int query(int x){
int sum=0;
while(x)sum+=s[x],x-=lowbit(x);
return sum;
}
#define x1 x_1
#define y1 y_1
#define x2 x_2
#define y2 y_2
struct arr{
int x1,y1,x2,y2,id;
int k;
};
struct upd{
int x,y,z,id;
bool operator<(const upd t){
if(x==t.x)return y<t.y;
return x<t.x;
}
};
int ans[M];
int cnt[M],dcnt[M];
vector<upd> c[N*N];
void solve(int l,int r,vector<arr> S){
if(l==r){
for(auto i:S)ans[i.id]=l;
return;
}
int mid=(l+r)>>1;
vector<upd> T;
for(auto i:S){
dcnt[i.id]=cnt[i.id];
T.push_back({i.x1-1,i.y1-1,1,i.id});
T.push_back({i.x2,i.y1-1,-1,i.id});
T.push_back({i.x1-1,i.y2,-1,i.id});
T.push_back({i.x2,i.y2,1,i.id});
}
sort(T.begin(),T.end());
vector<upd> p;
fo(i,l,mid)for(auto j:c[i])p.push_back(j);
sort(p.begin(),p.end());
int j=0;
for(auto i:p){
while(j<T.size()&&T[j]<i){
cnt[T[j].id]+=query(T[j].y)*T[j].z;
++j;
}
update(i.y,1);
}
while(j<T.size()){
cnt[T[j].id]+=query(T[j].y)*T[j].z;
++j;
}
vector<arr> S1,S2;
for(auto i:S){
if(i.k<=cnt[i.id])S1.push_back(i),cnt[i.id]=dcnt[i.id];
else S2.push_back(i);
}
for(auto i:p){
update(i.y,-1);
}
if(S1.size())solve(l,mid,S1);
if(S2.size())solve(mid+1,r,S2);
}
int n,Q;
int a[N][N];
vector<int> b;
int main(){
ios::sync_with_stdio(0); cin.tie(0);
cin>>n>>Q;
fo(i,1,n){
fo(j,1,n){
cin>>a[i][j];
b.push_back(a[i][j]);
}
}
sort(b.begin(),b.end());
auto ed=unique(b.begin(),b.end());
fo(i,1,n){
fo(j,1,n){
a[i][j]=lower_bound(b.begin(),ed,a[i][j])-b.begin();
c[a[i][j]].push_back({i,j,0,0});
}
}
vector<arr> S;
fo(i,1,Q){
int x1,y1,x2,y2,k;
cin>>x1>>y1>>x2>>y2>>k;
S.push_back({x1,y1,x2,y2,i,k});
}
solve(0,ed-b.begin()-1,S);
fo(i,1,Q)cout<<b[ans[i]]<<'\n';
}
P5163 WD与地图
动态图强联通性如果在线做是不可做的,考虑离线。
加边比删边简单,所以考虑把时间倒过来做,以下所提时间均为倒过来以后的。
考虑一个简化的题目:每条边为无向边,每次考虑查询一个连通块。
那么显然可以用并查集来做。
那么变成有向边,查询强联通分量该怎么做呢?
我们考虑整体二分每条有向边连接的两个点在什么时间开始属于同一个分量。
那么每次 \(solve(l,r,S)\),将时间在 \(mid\) 以前的边加入,跑 \(Tarjan\),然后对于 \(S\) 中的每条边,看两边是否缩在一起,然后往两边递归。
但是这里发现我们的查询和修改都是同一个东西,即若干有向边。所以我们的 \(S\) 既是询问又是修改。
于是考虑这样:
-
每次把 \(S\) 中在 \(mid\) 及之前的边加入,跑 \(Tarjan\),记缩点后点 \(i\) 在包含 \(bel_i\) 的分量中,即缩点后的编号。
-
之后遍历 \(S\),对于一条边 \((u,v)\) 若 \(bel_u=bel_v\)(且 \(bel\ne 0\))则往左边插入 \((u,v)\)。
-
否则在右边插入 \((bel_u,bel_v)\)。
-
每次把 \(bel\) 清空,然后往两边递归。
由于我们每次往右边插入的是 \((bel_u,bel_v)\),所以不需要使用并查集。
而我们如果使用并查集的话,那么必须使用可撤销并查集,然而可撤销并查集会多一个 \(\log\),且不方便。
(可撤销并查集的做法建议看洛谷第一篇题解)。
这样我们就在 \(O(n\log n)\) 内得到了每条边的时间。
我们把每条边重新放在它所在的时间处,并把它当做双向边。
然后用并查集维护连通块即可,而对于查询可以选择使用平衡树(如无旋 Treap)并启发式合并,这样是 \(O(n\log ^2n)\) 的。
也可以用线段树合并,最优复杂度是 \(O(n\log n)\)。