分块
前言
我们可能已经了解了树状数组是基于二进制划分与倍增思想,线段树基于分治思想。它们之所以能够高效地在一个序列上执行指令并统计信息,就是因为它们把序列中的元素聚合成大大小小的“段”,花费额外的代价对这些“段”进行维护,从而使得每个区间的信息可以快速由几个已有的“段”结合而成。
当然,树状数组与线段树也有其缺点。比如在维护较为复杂的信息(尤其是不满足区间可加,可减性的信息)时显得吃力,代码实现也不是那么简单、直观,需要深入理解并注意许多细节。在此,我们将介绍分块算法。
基本概念
基本思想是通过适当的划分,预处理一部分信息并保存下来,用空间换取时间,达到时空平衡。事实上,分块更接近于“朴素”,效率往往比不上树状数组与线段树,但是它更加通用、容易实现。
下面通过一个例题来探讨一下分块算法
题目描述
给定长度为 N(N≤10^5) 的数列A,然后输入 Q(Q≤10^5) 行操作指令。
第一类指令形如"1 l r d",表示把数列中第 l~r 个数都加 d。
第二类指令形如"2 l r",表示询问数列中第 l~r 个数的和。
我们用了树状数组和线段树在 O((N + Q)logN) 的时间内解决过该问题。现在我们用分块来求解。
把数列A分成若干个长度不超过的段,其中第 i 段左端点为 (i-1)
+1,右端点为 min(i
,N) 。另外,预处理出数组 sum,其中 sum[i] 表示第 i 段的区间和。设 add[i] 表示第 i 段的“增量标记”,起初 add[i] = 0。
对于指令“1 l r d”:
1. 若 l 与 r 同时处于第 i 段内,则直接把 A[l],A[l+1],...,A[r] 都加 d,同时令 sum[i]+=d*(r-l+1)。
2. 否则,设 l 处于第 p 段,r 处于第 q 段。
(1) 对于 i∈[p+1,q-1],令 add[i]+=d。
(2) 对于开头、结尾不足一整段的两部分,按照与第 1 种情况相同的方法朴素地更新。
对于指令"2 l r":
1. 若 l 与 r 同时处于第 i 段内,则(A[l] + A[l+1] +...+ A[r])+ (r - l + 1) * add[i] 就是答案。
2. 否则,设 l 处于第 p 段,r 处于第 q 段,初始化 ans=0。
(1) 对于 i∈[p+1,q-1],令 ans += sum[i] + add[i] * len[i],其中 len[i] 表示第 i 段的长度
(2) 对于开头、结尾不足一整段的两部分,按照与第1种情况相同的方法朴素地累加。
这种分块算法对于整段的修改用标记 add 记录,对于不足整段的修改采取朴素算法。因为段数和段长都是O(),所以整个算法的时间复杂度为 O((N+Q)*
)。
大部分常见的分块思想都可以用“大段维护、局部朴素”来形容。
代码示例
#include<iostream>
#include<cmath>
#include<cstdio>
#include<algorithm>
#include<cstring>
using namespace std;
const int maxn=110000;
long long a[maxn],sum[maxn],add[maxn];
int L[maxn],R[maxn];
int pos[maxn];
int n,m,t;
void change(int l,int r,long long d){
int p=pos[l],q=pos[r];
if(p==q){
for(int i=l;i<=r;i++) a[i]+=d;
sum[p]+=d*(r-l+1);
}
else{
for(int i=p+1;i<=q-1;i++) add[i]+=d;
for(int i=l;i<=R[p];i++) a[i]+=d;
sum[p]+=d*(R[p]-l+1);
for(int i=L[q];i<=r;i++) a[i]+=d;
sum[q]+=d*(r-L[q]+1);
}
}
long long ask(int l,int r){
int p=pos[l],q=pos[r];
long long ans=0;
if(p==q){
for(int i=l;i<=r;i++) ans+=a[i];
ans+=add[p]*(r-l+1);
}
else{
for(int i=p+1;i<=q-1;i++) ans+=sum[i]+add[i]*(R[i]-L[i]+1);
for(int i=l;i<=R[p];i++) ans+=a[i];
ans+=add[p]*(R[p]-l+1);
for(int i=L[q];i<=r;i++) ans+=a[i];
ans+=add[q]*(r-L[q]+1);
}
return ans;
}
int main(){
cin>>n>>m;
for(int i=1;i<=n;i++) scanf("%lld",&a[i]);
t=sqrt(n);
for(int i=1;i<=t;i++){
L[i]=(i-1)*sqrt(n)+1;
R[i]=i*sqrt(n);
}
if(R[t]<n){
t++;
L[t]=R[t-1]+1;
R[t]=n;
}
for(int i=1;i<=t;i++){
for(int j=L[i];j<=R[i];j++){
pos[j]=i;
sum[i]+=a[j];
}
}
while(m--){
char op[3];
int x;
int l,r,d;
scanf("%d%d%d",&x,&l,&r);
if(x==1){
scanf("%d",&d);
change(l,r,d);
}
else printf("%lld\n",ask(l,r));
}
}
莫队
基础莫队
莫队=离线+暴力+分块
“离线”和“在线”的概念:在线是交互式的,一问一答;如果前面的答案用于后面的提问,称为“强制在线”。离线是非交互的,一次性读取所有问题,然后一起回答,"记录所有步,回头再做”。
基础的莫队算法是一种离线算法,它通常用于不修改只查询的一类区间问题,复杂度,没有在线算法线段树或树状数组好,但是编码很简单。下面是一道莫队模板题。
莫队算法的排序:把数组分块(分成块),然后把查询的区间按左端点所在块的序号排序,如果左端点的块相同,再按右端点排序。
下面分析多种情况下莫队算法的复杂度。
(1)简单情况:区间交错,设区间[P1,y1]、[P2,y2]的关系是P1<P2,y1≤y2,其中P1、P2是左端点所在的块。L,R只需要从左到右扫一次,m次查询的总复杂度是O(N)。
(2)复杂情况:区间包含,设两个区间查询[P1,y1]、[P1,y2]的关系是P1=P2,y2≤y1。
此时小区间[P2,y2]排在大区间[P1,y1]的前面,与暴力法正好相反右指针R从左到右单向移动,不再往复移动。而左指针L发生了回退移动,但是被限制在一个长为 的块内,每次移动的复杂度是的。m次查询,每次查询左端点只需要移动
次,右端点R共单向移动O(n)次,总复杂度
(3)特殊情况:m个询问,端点都在不同的块上,此时莫队算法和暴力法是一样的,但总复杂度小
编码时,还可以对排序做一个小优化:奇偶性排序,让奇数块和偶数块的排序相反。例如左端点L都在奇数块,则对R从大到小排序;若L在偶数块,则对R从小到大排序(反过来也可以:奇数块从小到大,偶数块从大到小)。
#include<bits/stdc++.h>
using namespace std;
const int maxn = 1e6;
struct node{ //离线记录查询操作
int L, R, k; //k:查询操作的原始顺序
}q[maxn];
int pos[maxn];
int ans[maxn];
int cnt[maxn]; //cnt[i]: 统计数字i出现了多少次
int a[maxn];
bool cmp(node a, node b){
//按块排序,就是莫队算法:
if(pos[a.L] != pos[b.L]) //按L所在的块排序,如果块相等,再按R排序
return pos[a.L] < pos[b.L];
if(pos[a.L] & 1) return a.R > b.R; //奇偶性优化,如果删除这一句,性能差一点
return a.R < b.R;
/*如果不按块排序,而是直接L、R排序,就是普通暴力法:
if(a.L==b.L) return a.R < b.R;
return a.L < b.L; */
}
int ANS = 0;
void add(int x){ //扩大区间时(L左移或R右移),增加数x出现的次数
cnt[a[x]]++;
if(cnt[a[x]]==1) ANS++; //这个元素第1次出现
}
void del(int x){ //缩小区间时(L右移或R左移),减少数x出现的次数
cnt[a[x]]--;
if(cnt[a[x]]==0) ANS--; //这个元素消失了
}
int main(){
int n; scanf("%d",&n);
int block = sqrt(n); //每块的大小
for(int i=1;i<=n;i++){
scanf("%d",&a[i]); //读第i个元素
pos[i]=(i-1)/block + 1; //第i个元素所在的块
}
int m; scanf("%d",&m);
for(int i=1;i<=m;i++){ //读取所有m个查询,离线处理
scanf("%d%d",&q[i].L, &q[i].R);
q[i].k = i; //记录查询的原始顺序
}
sort(q+1, q+1+m, cmp); //对所有查询排序
int L=1, R=0; //左右指针的初始值。思考为什么?
for(int i=1;i<=m;i++){
while(L<q[i].L) del(L++); //{del(L); L++;} //缩小区间:L右移
while(R>q[i].R) del(R--); //{del(R); R--;} //缩小区间:R左移
while(L>q[i].L) add(--L); //{L--; add(L);} //扩大区间:L左移
while(R<q[i].R) add(++R); //{R++; add(R);} //扩大区间:R右移
ans[q[i].k] = ANS;
}
for(int i=1;i<=m;i++) printf("%d\n",ans[i]); //按原顺序打印结果
return 0;
}