分块入门
分块是一种优美的暴力算法。
其思想为将数组分为若干块,修改、查询时将整块一起处理,而对剩余的元素,即散点进行暴力处理,以优化时间复杂度。
同时,分块的适用性更加广泛,可以解决插入元素,区间众数等线段树所不能解决的操作。
假设数组长度为 \(n\),而分出的块大小为 \(B\),则一共有 \(\left\lfloor\dfrac{n}{B}\right\rfloor\) 个整块。
假设对单点和整块的操作单次时间复杂度是 \(O(1)\),则单次操作时间复杂度为 \(O\left(\left\lfloor\dfrac{n}{B}\right\rfloor+B\right)\),当 \(B=\lfloor\sqrt{n}\rfloor\) 时取得最优时间复杂度 \(O(\sqrt{n})\)。
则总时间复杂度为 \(O(m\sqrt{n})\),其中 \(m\) 为询问次数。若 \(n,m\) 同级,则最终时间复杂度为 \(O(n\sqrt{n})\)。
如果单点和整块操作的时间复杂度有变化,那么上述分析也会有相应的变化。
接下来,我们通过若干道例题来学习分块的一些操作。
0. 分块的大致实现
在做题之前,我们首先要了解分块的大致实现。
首先要处理出每个下标所在的块的编号。
一般来说,我们希望 \(a_1, a_2, \cdots, a_B\)在一个块,\(a_{B+1}, a_{B+2}, \cdots, a_{2B}\)在一个块,以此类推。
这样的分块可以用下面的代码实现:
for(int i=1;i<=n;i++)bel[i]=(i-1)/B+1;
其中 \(bel\) 是 \(belong\) 的缩写,即这个元素是属于哪个块的。
有时候我们要查询所在块的左右端点,这时我们可以这样操作:
int lp=(pos-1)*B+1,rp=min(pos*B,n);
其中 \(pos\) 是所在块的编号。注意查询的可能是最后的不完整块,这里右指针中的取 \(\min\) 操作保证了不会越界。
有了这些最基础的,我们就能大致写出分块“整块处理,剩余暴力”的代码了。(以查询为例)
int query(int l,int r,...)
{
int lpos=bel[l],rpos=bel[r];
int ans=0;//查询操作需要返回答案
if(lpos==rpos)for(int i=l;i<=r;i++);//询问区间在同一块内直接暴力即可
else
{
for(int i=lpos+1;i<=rpos-1;i++);//整块的一起处理
for(int i=l;bel[i]==lpos;i++);//剩余的同样暴力即可
for(int i=r;bel[i]==rpos;i--);
}
return ans;
}
1. 基础操作
1.1 数列分块入门1:区间加,单点查询
基础中的基础。
我们给每一个块打上一个加法标记,区间内数字的实际值就是原数组中的值加上对应块的加法标记。
整块修改加到加法标记上,剩余的单点修改加到原数组上就行了。
时间复杂度 \(O(n\sqrt{n})\)。
说句闲话:块的大小也不一定要是严格的 \(\sqrt{n}\)。
可以定一个常数,并根据实际的运行速度三分调整;也可以依照时间复杂度分析定成其他的形式,根据实际运行速度进行调整。
总之能卡过就行(
代码:
const int maxn=50010,B=220;
int n,a[maxn],bel[maxn],add[500];//add是加法标记
void modify(int l,int r,int k)
{
int lpos=bel[l],rpos=bel[r];
if(lpos==rpos)for(int i=l;i<=r;i++)a[i]+=k;//单点修改加到原数组上
else
{
for(int i=lpos+1;i<=rpos-1;i++)add[i]+=k;//整块修改加到加法标记上
for(int i=l;bel[i]==lpos;i++)a[i]+=k;
for(int i=r;bel[i]==rpos;i--)a[i]+=k;
}
}
inline int query(int k){return a[k]+add[bel[k]];}//实际的值是原数组中的值加上对应块的加法标记
int main()
{
n=read();
for(int i=1;i<=n;i++)a[i]=read();
for(int i=1;i<=n;i++)bel[i]=(i-1)/B+1;
//...
return 0;
}
1.2 数列分块入门4:区间加,区间求和
要高效地处理区间求和,需要效仿加法标记,引入 \(sum\) 数组来表示块内的所有元素之和。
注意 \(sum\) 数组和 \(add\) 标记是相互独立的,互不影响。
时间复杂度 \(O(n\sqrt{n})\)。详细内容见代码注释。
#define int long long//不开long long见祖宗
const int maxn=50010,B=220;
int n,mod,a[maxn],bel[maxn],add[500],sum[500];
void modify(int l,int r,int k)
{
int lpos=bel[l],rpos=bel[r];
if(lpos==rpos)
for(int i=l;i<=r;i++)
{
a[i]+=k;
sum[lpos]+=k;//修改的时候要记得更新sum数组
}
else
{
for(int i=lpos+1;i<=rpos-1;i++)
{
add[i]+=k;
sum[i]+=B*k;//需要注意修改整个块对sum的贡献是B*k
}
for(int i=l;bel[i]==lpos;i++)
{
a[i]+=k;
sum[lpos]+=k;
}
for(int i=r;bel[i]==rpos;i--)
{
a[i]+=k;
sum[rpos]+=k;
}
}
}
int query(int l,int r)
{
int lpos=bel[l],rpos=bel[r],ans=0;
if(lpos==rpos)
for(int i=l;i<=r;i++)
{
ans+=a[i]+add[lpos];//别忘了加上加法标记
ans%=mod;
}
else
{
for(int i=lpos+1;i<=rpos-1;i++)
{
ans+=sum[i];//维护sum数组让我们轻松询问每一块中的元素和
ans%=mod;
}
for(int i=l;bel[i]==lpos;i++)
{
ans+=a[i]+add[lpos];
ans%=mod;
}
for(int i=r;bel[i]==rpos;i--)
{
ans+=a[i]+add[rpos];
ans%=mod;
}
}
return ans;
}
signed main()
{
n=read();
for(int i=1;i<=n;i++)
{
a[i]=read();
bel[i]=(i-1)/B+1;
sum[bel[i]]+=a[i];//一定要预处理sum数组!!!
}
//...
return 0;
}
1.3 多标记处理
1.3.1 数列分块入门7:区间加,区间乘,单点查询
方法同线段树。标记优先级先乘后加。
判断标记优先级的方法:一个个试(
先加后乘:
\((x+add)\cdot mul+k=\left(x+\left(add+\dfrac{k}{mul}\right)\right)\cdot mul\),损失精度。
先乘后加:
\((x\cdot mul+add)+k=x\cdot mul+(add+k)\);
\((x\cdot mul+add)\cdot k=x\cdot(mul\cdot k)+(add\cdot k)\),完全没有问题。
同时这也说明,在区间加的时候修改标记是要让 \(add\) 加上 \(k\),而在区间乘的时候 \(mul\) 和 \(add\) 都要乘上 \(k\)。
时间复杂度 \(O(n\sqrt{n})\)。
const int maxn=100010,B=320,mod=10007;
int n,a[maxn],bel[maxn],add[500],mul[500];
void release(int pos)//将块的标记释放掉,这样做的解释在之后
{
int lp=(pos-1)*B+1,rp=min(pos*B,n);
for(int i=lp;i<=rp;i++)a[i]=(a[i]*mul[pos]%mod+add[pos])%mod;
mul[pos]=1;add[pos]=0;
}
void modify_add(int l,int r,int k)
{
int lpos=bel[l],rpos=bel[r];
if(lpos==rpos)
{
//我们发现多标记的时候单点修改会很尴尬。我们不能因为个别的块而直接改标记,这样其他的块也会受影响。
//但只要将单点所在的块的标记释放掉,我们就可以尽情在原数组上修改了,是不是很暴力呢(
release(lpos);
for(int i=l;i<=r;i++)a[i]=(a[i]+k)%mod;
}
else
{
for(int i=lpos+1;i<=rpos-1;i++)add[i]=(add[i]+k)%mod;//加法操作更新标记的方式在上面已经进行解释了
release(lpos);
for(int i=l;bel[i]==lpos;i++)a[i]=(a[i]+k)%mod;
release(rpos);
for(int i=r;bel[i]==rpos;i--)a[i]=(a[i]+k)%mod;
}
}
void modify_mul(int l,int r,int k)//同上,解释略
{
int lpos=bel[l],rpos=bel[r];
if(lpos==rpos)
{
release(lpos);
for(int i=l;i<=r;i++)a[i]=a[i]*k%mod;
}
else
{
for(int i=lpos+1;i<=rpos-1;i++)
{
add[i]=add[i]*k%mod;
mul[i]=mul[i]*k%mod;
}
release(lpos);
for(int i=l;bel[i]==lpos;i++)a[i]=a[i]*k%mod;
release(rpos);
for(int i=r;bel[i]==rpos;i--)a[i]=a[i]*k%mod;
}
}
int query(int x){return (a[x]*mul[bel[x]]%mod+add[bel[x]])%mod;}//先乘后加
int main()
{
n=read();
for(int i=1;i<=n;i++)
{
a[i]=read()%mod;
bel[i]=(i-1)/B+1;
mul[bel[i]]=1;//注意要预处理好乘法标记
}
//...
return 0;
}
1.3.2 区间加,区间乘,区间求和
把1.2和1.3.2的代码合起来改改再卡卡常就过了。
注意 \(sum\) 数组相对于加法和乘法标记的独立性。时间复杂度 \(O(n\sqrt{n})\)。
据本人实测,分块可以不开O2过掉,我的代码最坏的点跑了 \(994\text{ms}\)。
附上卡常后的部分代码(其实也没影响多少阅读性)
#define int long long//不开long long见祖宗
const int maxn=100010,B=220;
int n,m,mod,a[maxn],bel[maxn],add[500],mul[500],sum[500];
inline void release(int pos)
{
int lp=(pos-1)*B+1,rp=min(pos*B,n);
for(register int i=lp;i<=rp;++i)a[i]=(a[i]*mul[pos]+add[pos])%mod;//使用long long要少取模
mul[pos]=1;add[pos]=0;
}
void modify_add(int l,int r,int k)
{
int lpos=bel[l],rpos=bel[r];
if(lpos==rpos)
{
release(lpos);
for(register int i=l;i<=r;++i)
{
a[i]=(a[i]+k)%mod;
sum[lpos]=(sum[lpos]+k)%mod;//单点加的时候也要维护sum值
}
}
else
{
for(register int i=lpos+1;i<=rpos-1;++i)
{
add[i]=(add[i]+k)%mod;
sum[i]=(sum[i]+B*k)%mod;//这里的贡献同1.2
}
release(lpos);
for(register int i=l;bel[i]==lpos;++i)
{
a[i]=(a[i]+k)%mod;
sum[lpos]=(sum[lpos]+k)%mod;
}
release(rpos);
for(register int i=r;bel[i]==rpos;--i)
{
a[i]=(a[i]+k)%mod;
sum[rpos]=(sum[rpos]+k)%mod;
}
}
}
void modify_mul(int l,int r,int k)
{
int lpos=bel[l],rpos=bel[r];
if(lpos==rpos)
{
release(lpos);
for(register int i=l;i<=r;++i)
{
sum[lpos]=(sum[lpos]+a[i]*(k-1))%mod;//乘法操作的贡献是(k-1)倍的原数
a[i]=a[i]*k%mod;
}
}
else
{
for(register int i=lpos+1;i<=rpos-1;++i)
{
add[i]=add[i]*k%mod;
mul[i]=mul[i]*k%mod;
sum[i]=sum[i]*k%mod;//显然,块内每个数都扩大到原来的k倍,则和也扩大到原来的k倍
}
release(lpos);
for(register int i=l;bel[i]==lpos;++i)
{
sum[lpos]=(sum[lpos]+a[i]*(k-1))%mod;
a[i]=a[i]*k%mod;
}
release(rpos);
for(register int i=r;bel[i]==rpos;--i)
{
sum[rpos]=(sum[rpos]+a[i]*(k-1))%mod;
a[i]=a[i]*k%mod;
}
}
}
int query(int l,int r)
{
int lpos=bel[l],rpos=bel[r],ans=0;
if(lpos==rpos)
for(register int i=l;i<=r;++i)
{
ans+=a[i]*mul[lpos]+add[lpos];
ans%=mod;
}
else
{
for(register int i=lpos+1;i<=rpos-1;++i)
{
ans+=sum[i];
ans%=mod;
}
for(register int i=l;bel[i]==lpos;++i)
{
ans+=a[i]*mul[lpos]+add[lpos];
ans%=mod;
}
for(register int i=r;bel[i]==rpos;--i)
{
ans+=a[i]*mul[rpos]+add[rpos];
ans%=mod;
}
}
return ans;
}
signed main()
{
n=read();m=read();mod=read();
for(register int i=1;i<=n;++i)
{
a[i]=read();
bel[i]=(i-1)/B+1;
mul[bel[i]]=1;//mul和sum都需要初始化
sum[bel[i]]+=a[i];
}
//...
return 0;
}
2. 均摊性质
2.1 数列分块入门5:区间开根,区间求和
经典题目。
一个数开不了几次根号就收敛到 \(0\) 或 \(1\) 了。(据说大概需要 \(O(\log\log n)\) 次)
所以我们可以记录整个块内的元素是否都是 \(0\) 或 \(1\),如果是就跳过不作处理。
时间复杂度 \(O(n\sqrt{n}\log\log n)\)。
const int maxn=50010,B=220;
int n,a[maxn],bel[maxn],sum[500];
bool flag[500];//判断块内是否全为0/1
void modify(int l,int r)
{
int lpos=bel[l],rpos=bel[r];
if(lpos==rpos)
for(int i=l;i<=r;i++)
{
sum[lpos]-=a[i];
a[i]=(int)sqrt(a[i]);//散点暴力!
sum[lpos]+=a[i];
}
else
{
for(int i=lpos+1;i<=rpos-1;i++)
if(!flag[i])//如果已经全为0/1就不做任何操作
{
int lp=(i-1)*B+1,rp=min(i*B,n);
bool nowflag=1;
for(int j=lp;j<=rp;j++)
{
sum[i]-=a[j];
a[j]=(int)sqrt(a[j]);
sum[i]+=a[j];
if(a[j]!=0&&a[j]!=1)nowflag=0;//本轮修改后存在某个数大于1,则不能打标记
}
if(nowflag)flag[i]=1;//本轮修改后所有的数都为0/1,打上标记
}
for(int i=l;bel[i]==lpos;i++)
{
sum[lpos]-=a[i];
a[i]=(int)sqrt(a[i]);
sum[lpos]+=a[i];
}
for(int i=r;bel[i]==rpos;i--)
{
sum[rpos]-=a[i];
a[i]=(int)sqrt(a[i]);
sum[rpos]+=a[i];
}
}
}
int query(int l,int r)
{
int lpos=bel[l],rpos=bel[r],ans=0;
if(lpos==rpos)
for(int i=l;i<=r;i++)
ans+=a[i];
else
{
for(int i=lpos+1;i<=rpos-1;i++)ans+=sum[i];
for(int i=l;bel[i]==lpos;i++)ans+=a[i];
for(int i=r;bel[i]==rpos;i--)ans+=a[i];
}
return ans;
}
signed main()
{
n=read();
for(int i=1;i<=n;i++)
{
a[i]=read();
bel[i]=(i-1)/B+1;
sum[bel[i]]+=a[i];
}
//...
return 0;
}
2.2 数列分块入门8:区间赋值,查询区间内某值的个数
容易看出,大段的区间赋值只会使数列快速变成几大段相同数字的形式。
所以我们可以维护每一个块内的数是否都相同,如果都相同那就可以整块一起处理了。
时间复杂度\(O(能过)\)
const int maxn=100010,B=220;
int n,a[maxn],bel[maxn],cover[500];//cover表示如果该块中所有的数都相同,那么这个数是什么
bool flag[500];//标记该块中所有的数是否都相同
inline void release(int pos)//当块内的修改破坏了块内数都相同的性质,我们需要暴力撤掉标记
{
int lp=(pos-1)*B+1,rp=min(pos*B,n);
for(int i=lp;i<=rp;i++)a[i]=cover[pos];
flag[pos]=0;
}
int query(int l,int r,int k)
{
int lpos=bel[l],rpos=bel[r],ans=0;
if(lpos==rpos)
{
if(flag[lpos])
{
if(cover[lpos]==k)ans+=r-l+1;
release(lpos);
}
else for(int i=l;i<=r;i++)if(a[i]==k)ans++;
for(int i=l;i<=r;i++)a[i]=k;//注意要先撤标记再把新的修改上
}
else
{
for(int i=lpos+1;i<=rpos-1;i++)
if(flag[i])
{
if(cover[i]==k)ans+=B;//这里可以看出块内标记对时间复杂度的优化作用
cover[i]=k;
}
else
{
int lp=(i-1)*B+1,rp=min(i*B,n);
for(int j=lp;j<=rp;j++)if(a[j]==k)ans++;
flag[i]=1;
cover[i]=k;
}
int rp=min(lpos*B,n);
if(flag[lpos])
{
if(cover[lpos]==k)ans+=rp-l+1;
release(lpos);
}
else for(int i=l;bel[i]==lpos;i++)if(a[i]==k)ans++;
for(int i=l;bel[i]==lpos;i++)a[i]=k;
int lp=(rpos-1)*B+1;
if(flag[rpos])
{
if(cover[rpos]==k)ans+=r-lp+1;
release(rpos);
}
else for(int i=r;bel[i]==rpos;i--)if(a[i]==k)ans++;
for(int i=r;bel[i]==rpos;i--)a[i]=k;
}
return ans;
}
int main()
{
n=read();
for(int i=1;i<=n;i++)
{
a[i]=read();
bel[i]=(i-1)/B+1;
}
//...
return 0;
}
3. 分块+二分
其实分块和二分并不兼容,强行二分会让复杂度多一个log。
更强的做法是值域分块,具体见Ynoi最初分块,但是我并不会qaq
3.1 数列分块入门2:区间加法,查询区间内小于某值的个数
考虑将每一个块进行排序,以便于二分。
但是这样我们还需要记录一下原来数组的下标,因为排序后元素的相对顺序发生了变化。当然,是只有块内的相对顺序发生了变化,块外的相对顺序还是相同的。
对于区间加法,整个块内依然有序,但是左右的散点所在块可能会发生变化,因此我们需要再次进行排序。
对于询问,需要对每个整块进行二分查找;散点暴力统计即可。
时间复杂度 \(O(n\sqrt{n}\log n)\)。
#define int long long
const int maxn=50010,B=220;
struct node{int val,pos,bel;}a[maxn];
bool cmp(node x,node y){return x.val<y.val;}
int n,cnt,add[500];
void modify(int l,int r,int k)
{
int lpos=a[l].bel,rpos=a[r].bel;
if(lpos==rpos)
{
int lp=(lpos-1)*B+1,rp=min(lpos*B,n);
for(int i=lp;i<=rp;i++)//注意散点在现在的坐标上不一定在[l,r]的范围内,需要对整个块进行查找
if(a[i].pos>=l&&a[i].pos<=r)
a[i].val+=k;
sort(a+lp,a+rp+1,cmp);//重新排序
}
else
{
for(int i=lpos+1;i<=rpos-1;i++)add[i]+=k;//加法标记还是要有的
int lp=(lpos-1)*B+1,rp=min(lpos*B,n);
for(int i=lp;i<=rp;i++)if(a[i].pos>=l)a[i].val+=k;//散点,同上
sort(a+lp,a+rp+1,cmp);
lp=(rpos-1)*B+1;rp=min(rpos*B,n);
for(int i=lp;i<=rp;i++)if(a[i].pos<=r)a[i].val+=k;
sort(a+lp,a+rp+1,cmp);
}
}
int query(int l,int r,int k)
{
int ans=0;
int lpos=a[l].bel,rpos=a[r].bel;
if(lpos==rpos)
{
int lp=(lpos-1)*B+1,rp=min(lpos*B,n);
for(int i=lp;i<=rp;i++)
if(a[i].pos>=l&&a[i].pos<=r&&a[i].val+add[lpos]<k)//统计的时候别忘了加上加法标记
ans++;
}
else
{
for(int i=lpos+1;i<=rpos-1;i++)
{
int lp=(i-1)*B+1,rp=min(i*B,n);
int ll=lp,rr=rp,now=0;
while(ll<=rr)//对于整个块进行二分,找到比k小的最大元素位置(不怎么会用lower_bound
{
int mid=(ll+rr)>>1;
if(a[mid].val+add[i]<k)
{
now=mid;
ll=mid+1;
}
else rr=mid-1;
}
if(now)ans+=now-lp+1;//如果存在比k小的元素,则它们都在[lp,now]区间内且有序,于是答案显然为此
}
int lp=(lpos-1)*B+1,rp=min(lpos*B,n);
for(int i=lp;i<=rp;i++)
if(a[i].pos>=l&&a[i].val+add[lpos]<k)
ans++;
lp=(rpos-1)*B+1;rp=min(rpos*B,n);
for(int i=lp;i<=rp;i++)
if(a[i].pos<=r&&a[i].val+add[rpos]<k)
ans++;
}
return ans;
}
signed main()
{
n=read();
for(int i=1;i<=n;i++)
{
a[i].val=read();
a[i].pos=i;
a[i].bel=(i-1)/B+1;
}
cnt=(int)ceil(n*1.0/B);//其实直接用a[n].bel就行(
for(int i=1;i<=cnt;i++)//对每个块进行排序
{
int lp=(i-1)*B+1,rp=min(i*B,n);
sort(a+lp,a+rp+1,cmp);
}
//...
return 0;
}
3.2 数列分块入门3:区间加法,查询前驱
和上面一道题差不多,改一改询问的处理就行了。
时间复杂度 \(O(n\sqrt{n}\log n)\)。
const int maxn=100010,B=320;
struct node{int val,pos,bel;}a[maxn];
bool cmp(node x,node y){return x.val<y.val;}
int n,cnt,add[500];
void modify(int l,int r,int k)//modify函数同上一题
{
int lpos=a[l].bel,rpos=a[r].bel;
if(lpos==rpos)
{
int lp=(lpos-1)*B+1,rp=min(lpos*B,n);
for(int i=lp;i<=rp;i++)
if(a[i].pos>=l&&a[i].pos<=r)
a[i].val+=k;
sort(a+lp,a+rp+1,cmp);
}
else
{
for(int i=lpos+1;i<=rpos-1;i++)add[i]+=k;
int lp=(lpos-1)*B+1,rp=min(lpos*B,n);
for(int i=lp;i<=rp;i++)if(a[i].pos>=l)a[i].val+=k;
sort(a+lp,a+rp+1,cmp);
lp=(rpos-1)*B+1;rp=min(rpos*B,n);
for(int i=lp;i<=rp;i++)if(a[i].pos<=r)a[i].val+=k;
sort(a+lp,a+rp+1,cmp);
}
}
int query(int l,int r,int k)
{
int ans=-1;//题目要求不存在时返回-1
int lpos=a[l].bel,rpos=a[r].bel;
if(lpos==rpos)
{
int lp=(lpos-1)*B+1,rp=min(lpos*B,n);
for(int i=lp;i<=rp;i++)
if(a[i].pos>=l&&a[i].pos<=r&&a[i].val+add[lpos]<k)
ans=max(ans,a[i].val+add[lpos]);//因为是求前驱,即比k小的最大元素,所以要取max
}
else
{
for(int i=lpos+1;i<=rpos-1;i++)
{
int lp=(i-1)*B+1,rp=min(i*B,n);
int ll=lp,rr=rp,now=0;
while(ll<=rr)
{
int mid=(ll+rr)>>1;
if(a[mid].val+add[i]<k)
{
now=mid;
ll=mid+1;
}
else rr=mid-1;
}
if(now)ans=max(ans,a[now].val+add[i]);//同样取max,别忘了加法标记
}
int lp=(lpos-1)*B+1,rp=min(lpos*B,n);
for(int i=lp;i<=rp;i++)
if(a[i].pos>=l&&a[i].val+add[lpos]<k)
ans=max(ans,a[i].val+add[lpos]);
lp=(rpos-1)*B+1;rp=min(rpos*B,n);
for(int i=lp;i<=rp;i++)
if(a[i].pos<=r&&a[i].val+add[rpos]<k)
ans=max(ans,a[i].val+add[rpos]);
}
return ans;
}
int main()
{
n=read();
for(int i=1;i<=n;i++)
{
a[i].val=read();
a[i].pos=i;
a[i].bel=(i-1)/B+1;
}
cnt=(int)ceil(n*1.0/B);
for(int i=1;i<=cnt;i++)
{
int lp=(i-1)*B+1,rp=min(i*B,n);
sort(a+lp,a+rp+1,cmp);
}
//...
return 0;
}
咕咕咕