数列入门分块 做题记录
前言
分块,一种优雅的暴力。我总是在一些弱智的地方犯错,一调就是半小时。
这些入门题的思路还是比较直接的,希望分块水平能有所提高,少犯小错误。
我的常用模板
基本预处理方式,在空间要求不严格时会用 le
、ri
、bel
等数组增强可读性。
void make_group()
{
block=sqrt(n);
num=(n-1)/block+1;
for(int i=1;i<=num;i++)
{
le[i]=(i-1)*block+1;
ri[i]=min(i*block,n);
for(int j=le[i];j<=ri[i];j++)
bel[j]=i;
}
}
注意事项:le
、ri
是对于块来说的,bel
是对于序列来说的,因此空间大小需要警惕。在操作涉及插入时,空间要开为 n+m
,否则越界。
关于本文中包含的数组含义
le
和 ri
为每块的左右边界。
mul
为乘法标记,add
为加法标记。
sum
有时是加法标记,有时是块中元素总和。
bel
为每个元素属于的块。
数列入门分块1
操作:区间加法,单点查值。
对于散块直接进行暴力更新,对于整块对 add
数组进行更新。
void modify(int l,int r,int k)
{
int st=bel[l],ed=bel[r];
if(st==ed)
{
for(int i=l;i<=r;i++)
a[i]=a[i]+k;
return;
}//如果首尾在同一块中,直接暴力更新
for(int i=l;i<=ri[st];i++)
a[i]=a[i]+k;
// 对于前面散块进行暴力更新。注意范围为 l~ri[st]
for(int i=le[ed];i<=r;i++)
a[i]=a[i]+k;
// 对于后面散块进行暴力更新。注意范围为 le[ed]~r
for(int i=st+1;i<=ed-1;i++)
add[i]=add[i]+k;
// 对于整块,对add数组进行更新
return;
}
在查询时,答案即为 \(a_i+add_{bel_i}\)。
数列入门分块2
操作:区间加法,询问区间内小于某个值的元素个数。
先考虑直接暴力。在查询时可以通过 \(O(n)\) 遍历查找,也可以先排序再二分来 \(O(n\log_2 n)\) 来计算。
然后考虑分块。如果先对每块内的元素进行排序,再对每块元素都加上一个值 \(c\),那么这个块内仍然是单调不降的。
先说修改。对于散块,还像之前一样直接暴力更新,但这样散块所对应的整块就不有序了。如果直接对 a
数组进行排序,那么就找不到原来位置所对应的元素了。所以这时我们需要再开一个数组 b
,其中每块中的元素都是有序的。那么对于散块就在 a
数组中查找,对于整块就在 b
数组中查找,就不会冲突了。
修改部分代码如下:
void modify(int l,int r,int k)
{
int st=bel[l],ed=bel[r];
if(st==ed)
{
for(int i=l;i<=r;i++)a[i]+=k;// 暴力更新
for(int i=le[st];i<=ri[st];i++)b[i]=a[i];// 赋值
sort(b+le[st],b+ri[st]+1);// 保证有序
return;
}
for(int i=l;i<=ri[st];i++)a[i]+=k;
for(int i=le[st];i<=ri[st];i++)b[i]=a[i];
sort(b+le[st],b+ri[st]+1);// 前散块
for(int i=le[ed];i<=r;i++)a[i]+=k;
for(int i=le[ed];i<=ri[ed];i++)b[i]=a[i];
sort(b+le[ed],b+ri[ed]+1);// 后散块
for(int i=st+1;i<=ed-1;i++)add[i]+=k;// 整块直接加
return;
}
然后说查询。相似的,对于散块,直接遍历即可。对于整块,用二分查找,然后减去对应下标即可。
int ask(int l,int r,int k)
{
int st=bel[l],ed=bel[r],ans=0;
if(st==ed)
{
for(int i=l;i<=r;i++)
if(a[i]+add[st]<k)ans++;// 注意是 a[i]+add[st]
return ans;
}
for(int i=l;i<=ri[st];i++)
if(a[i]+add[st]<k)ans++;//前散块
for(int i=le[ed];i<=r;i++)
if(a[i]+add[ed]<k)ans++;//后散块
for(int i=st+1;i<=ed-1;i++)
{
int p=lower_bound(b+le[i],b+ri[i]+1,k-add[i])-b;
// 注意找的值是 k-add[i],这实际上是互逆的一个过程
ans+=p-le[i];// 其实应为 (p-1)-le[i]+1
}
return ans;
}
数列入门分块3
操作:区间加法,询问区间内小于某个值的前驱(比其小的最大元素)。
本题与分块2类似,还是采用相同的方式进行修改,就没有必要再贴一遍了。
在询问时,对于散块,直接遍历找出小于 \(k\) 的最大值,没有就是 -1
。对于整块,用二分找到第一个小于 \(k\) 的位置 \(x\),如果 \(x<le[i]\),那么对答案没有贡献。
int ask(int l,int r,int k)
{
int st=bel[l],ed=bel[r],ans=-1;// 注意ans的初始值
if(st==ed)
{
for(int i=l;i<=r;i++)
if(a[i]+add[st]<k)
ans=max(ans,a[i]+add[st]);
return ans;
}
for(int i=l;i<=ri[st];i++)
if(a[i]+add[st]<k)
ans=max(ans,a[i]+add[st]);//前散块更新
for(int i=le[ed];i<=r;i++)
if(a[i]+add[ed]<k)
ans=max(ans,a[i]+add[ed]);//后散块更新
for(int i=st+1;i<=ed-1;i++)
{
int p=lower_bound(b+le[i],b+ri[i]+1,k-add[i])-b-1;
// lower_bound 查找到的是第一个大于等于某个数的位置,故第一个小于的需要 -1
if(p>=le[i])ans=max(ans,b[p]+add[i]);//满足条件的取max
}
return ans;
}
数列入门分块4
操作:区间加法,区间求和。
本题与分块1类似,我们还需要维护一个区间和。
在预处理时,先对每块的元素和进行统计。
在修改时,对于散块,直接暴力更新元素值,同时更新总和。对于整块,直接更新 add
数组。
void modify(int l,int r,ll k)
{
int st=bel[l],ed=bel[r];
if(st==ed)
{
for(int i=l;i<=r;i++)a[i]+=k;
sum[st]+=k*(r-l+1);
return;
}
for(int i=l;i<=ri[st];i++)
a[i]+=k;
sum[st]+=k*(ri[st]-l+1);// 前散块
for(int i=le[ed];i<=r;i++)
a[i]+=k;
sum[ed]+=k*(r-le[ed]+1);// 后散块
for(int i=st+1;i<ed;i++)
add[i]+=k;// 整块
return;
}
在查询时,对于散块,直接暴力加上 \(a_i+add_{bel_i}\)。对于整块,直接加上区间和和 add
的值。
ll query(int l,int r)
{
int st=bel[l],ed=bel[r];
ll ans=0;
if(st==ed)
{
for(int i=l;i<=r;i++)
ans+=a[i];
ans+=add[st]*(r-l+1);
return ans;
}
for(int i=l;i<=ri[st];i++)// 前散块
ans+=a[i];
ans+=add[st]*(ri[st]-l+1);
for(int i=le[ed];i<=r;i++)// 后散块
ans+=a[i];
ans+=add[ed]*(r-le[ed]+1);
for(int i=st+1;i<ed;i++)// 整块
ans+=sum[i]+add[i]*(ri[i]-le[i]+1);
return ans;
}
数列入门分块5
操作:区间开方,区间求和。
乍一看,开方后没有可加性,似乎不可做。
想一下,一个 int
范围内的数最多不超过 \(6\) 次就会变为 \(1\)。
那么增加一个数组 die
来表示当前整块是否全部为 \(1\)。如果是,直接跳过即可。否则就同散块一样暴力更新,这样就有效防止了同个区间更新多次而导致暴力复杂度过大的后果。
void modify(int l,int r)
{
int st=bel[l],ed=bel[r];
if(st==ed)
{
for(int i=l;i<=r;i++)a[i]=sqrt(a[i]);
sum[st]=0,die[st]=true;//开过根号后需要整块重新统计
for(int i=le[st];i<=ri[st];i++)
{
sum[st]+=a[i];
if(a[i]>1)die[st]=false;//存在一个就为false
}
return;
}
for(int i=l;i<=ri[st];i++)a[i]=sqrt(a[i]);
sum[st]=0,die[st]=true;
for(int i=le[st];i<=ri[st];i++)
{
sum[st]+=a[i];
if(a[i]>1)die[st]=false;
}// 前散块
for(int i=le[ed];i<=r;i++)a[i]=sqrt(a[i]);
sum[ed]=0,die[ed]=true;
for(int i=le[ed];i<=ri[ed];i++)
{
sum[ed]+=a[i];
if(a[i]>1)die[ed]=false;
}// 后散块
for(int i=st+1;i<=ed-1;i++)
{
if(die[i])continue;// 如果全为1就直接跳过
for(int j=le[i];j<=ri[i];j++)a[j]=sqrt(a[j]);
sum[i]=0,die[i]=true;
for(int j=le[i];j<=ri[i];j++)
{
sum[i]+=a[j];
if(a[j]>1)die[i]=false;
}//否则就同散块一般暴力更新
}
return;
}
查询就很入门了,散块加 a
,整块加 sum
,不贴代码了。
数列入门分块6
操作:单点插入,单点询问。
对于本题,我采用了 vector
+ 分块。
先用常规思路进行思考:因为有多个插入操作,所以块的大小是会更改的。这时用 le
和 ri
记录不合适,因此采用更为方便的 vector
。如何找到当前第 \(x\) 个数所属的块?很暴力。直接从前往后一路加上块长,直到再加会超过 \(x\) 时停止。为了方便,采用结构体返回所属的块和块中的位置,也可以用 pair
。
struct node{int x,k;};
node query(int x)
{
int i=1;
while(x>(int)(e[i].size()))
x-=(int)(e[i++].size());
return (node){i,x-1};// 注意 vector 中的元素下标从0开始,所以要-1
}
修改也很简单,直接用 vector
插入即可。
void insert(int x,int k)
{
node pos=query(x);
int bel=pos.x,t=pos.k;
e[bel].insert(e[bel].begin()+t,k);
}
但是问题来了:对于 vector
来说,插入这个操作是 \(O(n)\) 的。假如数据不随机,在一个点大量插入,那么这个块的块长远超 \(\sqrt{n}\),会使效率大大降低。
于是我们就引进了一个方法:重构。当一个块块长过大时,对所有的块进行清空,并且重新进行分块。这样对块长重新平摊,能够提高分块效率。
重构部分:
void rebuild()
{
int cnt=0;
for(int i=1;i<=num;i++)
{
for(int j=0;j<e[i].size();j++)
v[++cnt]=e[i][j];// 临时数组v存放所有元素
e[i].clear();
}
block=sqrt(cnt);
num=(cnt-1)/block+1;
for(int i=1;i<=cnt;i++)
{
int bel=(i-1)/block+1;
e[bel].push_back(v[i]);
}//重新分块
}
在函数中调用时:
if((int)(e[bel].size())>20*block)
rebuild();
这个 \(20\) 视情况而定,其实我也不太明白。
数列入门分块7
操作:区间乘法,区间加法,单点询问。
本题与分块4不同之处在于增加了一个区间乘法操作。
不是很想写,先咕着。
数列入门分块8
操作:区间询问等于一个数 \(c\) 的元素个数,并将这个区间的所有元素改为 \(c\)。
不是很想写,先咕着。
数列入门分块9
操作:询问区间的最小众数。
本题没有修改操作,但是仍然有难度。
先考虑暴力:
遍历 \(l\) 到 \(r\),对每个数字开个桶记录出现次数,然后更新答案。显然这样的时间复杂度是 \(O(m\times n)\) 的。空间如何解决?你可以用 map
,但是我更推荐用离散化,这样将桶的空间复杂度降到了 \(O(n)\)。
如果想要将查询操作降为 \(O(1)\),理想情况是不是直接开一个数组 \(C[i][j]\) 来记 \(l\) 到 \(r\) 中的众数是什么?但是这样既难预处理还会爆炸时空复杂度。
考虑分块:
首先要想一个问题:如果将一段区间分为左右散块和中间的整块,那么这段区间的答案会来自哪里?
简化问题,设 \(\text{mode(A)}\) 表示集合 \(\text{A}\) 中的众数,且存在一个集合 \(\text{B}\)。那么集合 \(\text{A}\cup\text{B}\) 的众数会是什么?是不是要么是 \(\text{mode(A)}\),要么就是集合 \(\text{B}\) 中的某个元素?假如 \(\text{A}\) 中的某个元素 \(x\ne \text{mode(A)}\),但是 \(x=\text{mode(A}\cup\text{B)}\),那么肯定有 \(x\in \text{B}\)。
用一个式子概括,就是 \(\text{mode(A}\cup\text{B)}\in \text{mode(A)}\cup \text{B}\)。
所以一段区间的众数,要么来自左散块,要么来自右散块,要么就是中间所有整块的众数。
设 \(block\) 为每块块长,\(num\) 为总块数,那么我们直接用 \(O(num^2\times block)\) 的时间复杂度预处理所有第 \(i\) 块到第 \(j\) 块的众数。取 \(block=\sqrt{n}\) 时复杂度约为 \(O(n\sqrt{n})\)。
void init()
{
for(int i=1;i<=num;i++)// i为起始块编号
{
memset(cnt,0,sizeof(cnt));// 每次做完需要清空
int wh=inf,ti=0;
for(int j=i;j<=num;j++)// j为结束块编号
{
for(int k=le[j];k<=ri[j];k++)// 遍历整个块
{
cnt[a[k]]++;
if(cnt[a[k]]>ti)
ti=cnt[a[k]],wh=a[k];
else if(cnt[a[k]]==ti)
wh=min(wh,a[k]);
// 注意找的是所有众数中最小的
}
zs[i][j]=wh;
}
}
}
然后就是查询操作了。按照之前说的思想,对于散块中的每个元素和整块的众数,查询这些数在 \([l,r]\) 中出现的次数,然后进行比较即可。
问题又来了,如何查询出现次数?
不难想到使用二分。对于每个数,开一个桶顺序记录它们在序列中出现的位置。在查询出现次数时,我们只需找到最后一个 \(\le r\) 的位置在桶中的编号 \(p\),以及第一个 \(\ge l\) 的位置在桶中的编号 \(q\),那么就出现了 \(p-q+1\) 次。
inline int calc(int x,int l,int r)
{
int p=upper_bound(c[x].begin(),c[x].end(),r)-c[x].begin()-1;
// upper_bound 查找的是第一个大于的,-1就是最后一个小于等于的了
int q=lower_bound(c[x].begin(),c[x].end(),l)-c[x].begin();
return p-q+1;
}
经过上述讲解,相信查询操作就很轻松了,详见代码注释:
int query(int l,int r)
{
int st=bel[l],ed=bel[r];
int wh=inf,ti=0;
if(st==ed)
{
memset(cnt,0,sizeof(cnt));
for(int i=l;i<=r;i++)
{
cnt[a[i]]++;
if(cnt[a[i]]>ti)
ti=cnt[a[i]],wh=a[i];
else if(cnt[a[i]]==ti)
wh=min(wh,a[i]);
}
return wh;
}// 首尾在同一块中,直接统计即可
for(int i=l;i<=ri[st];i++)
{
int sum=calc(a[i],l,r);
if(sum>ti)
ti=sum,wh=a[i];
else if(sum==ti)
wh=min(wh,a[i]);
}// 对于前散块进行处理
for(int i=le[ed];i<=r;i++)
{
int sum=calc(a[i],l,r);
if(sum>ti)
ti=sum,wh=a[i];
else if(sum==ti)
wh=min(wh,a[i]);
}// 对于后散块进行处理
if(st+1<=ed-1)
{
int now=zs[st+1][ed-1];
int sum=calc(now,l,r);
if(sum>ti)
ti=sum,wh=now;
else if(sum==ti)
wh=min(wh,now);
}// 如果中间有整块,再计算整块众数是否为区间众数
return wh;
}
将块长调为 \(80\) 就可以通过本题了。
但是问题来了:当块长为 \(\sqrt n\) 时单次查询的复杂度为 \(O\sqrt n\log_2n)\),总时间复杂度为 \((n+m)\sqrt n\log_2n\)。当时间限制更小时,这显然无法通过。
怎么办呢?分块的 \(\sqrt n\) 的系数肯定是去不掉了,那我们就需要考虑优化查询操作,用 \(O(1)\) 的时间查询 \(x\) 在 \([l,r]\) 中出现的次数。
采用前缀和思想,用数组 c
来记录前 \(i\) 块中元素 \(j\) 出现的总次数,那么第 \(l+1\) 到第 \(r-1\) 块中 \(j\) 出现的次数就为 \(c[r-1][j]-c[l][j]\)。
inline int calc(int x,int l,int r)
{
int p=c[r][x];
int q=c[l-1][x];
return p-q;
}
那么在计算左右散块中元素在区间 \([l,r]\) 中出现的次数时,只需累加当前元素在散块中出现次数,并加上中间整块的出现次数即可。
for(int i=l;i<=ri[st];i++)
cnt[a[i]]=0;
for(int i=le[ed];i<=r;i++)
cnt[a[i]]=0;
for(int i=l;i<=ri[st];i++)
{
cnt[a[i]]++;
int sum=cnt[a[i]]+calc(a[i],st+1,ed-1);
if(sum>ti)
ti=sum,wh=a[i];
else if(sum==ti)
wh=min(wh,a[i]);
}
for(int i=le[ed];i<=r;i++)
{
cnt[a[i]]++;
int sum=cnt[a[i]]+calc(a[i],st+1,ed-1);
if(sum>ti)
ti=sum,wh=a[i];
else if(sum==ti)
wh=min(wh,a[i]);
}
if(st+1<=ed-1)
{
int now=zs[st+1][ed-1];
int sum=calc(now,st+1,ed-1);
if(sum>ti)
ti=sum,wh=now;
else if(sum==ti)
wh=min(wh,now);
}
return wh;
这时总时间复杂度就降为 \(O((n+m)\sqrt n)\)。