莫队学习笔记
普通莫队
初遇——从暴力谈起
我们通过一道例题来讨论普通莫队。
题目链接。
观察数据范围,一个很直接的想法是:开一个数组 \(cnt\),\(cnt_i\) 表示在询问的区间内数字 \(i\) 出现的次数。对于每一个询问,记询问区间左端点为 \(lt\),询问区间右端点为 \(rt\),对于 \(i\in [lt,rt],cnt_{a_{i}}\leftarrow cnt_{a_{i}}+1\),最后扫描一遍 \(cnt\) 数组统计 \(cnt\) 数组中有几个位置不为 \(0\),统计的结果即为这次询问的答案。记给定的 \(a\) 数组中最大的数为 \(K\),这个算法的时间复杂度在最坏的情况(即每个询问的左端点都是 \(1\),右端点都是 \(N\))下为 \(O(NQK)\),无法通过本题。
接下来我们考虑如何优化这个算法。
首先,对于 \(i\in [lt,rt]\),如果 \(cnt_{a_{i}}=0\),则说明 \(a_{i}\) 在询问的区间中第一次出现,答案加一,这样省去了扫描 \(cnt\) 数组的过程,在最坏的情况下时间复杂度降为 \(O(NQ)\),但是效率还是不够高。
接下来我们看几个询问:
1 5
2 6
5 7
...
注意到,这几个询问的区间有重复部分,解决每个询问时都会重复扫描重复部分,很浪费时间。于是,我们可以使用两个指针 \(l\) 和 \(r\),对于一个询问,我们不再直接扫描对应的区间,而是将 \(l\) 指针移动到对应的左端点,将 \(r\) 指针移动到对应的右端点,并且仅在 \(l\) 指针或 \(r\) 指针移动时对 \(cnt\) 数组和答案进行修改。
于是可以写出如下代码:
点击查看代码
#include<bits/stdc++.h>
#define endl '\n'
#define int long long
using namespace std;
int n,a[30005],m,cnt[1000005],now,l=1,r=0,lt,rt;
void add(int x){
if(!cnt[a[x]]) now++;
cnt[a[x]]++;
}
void del(int x){
cnt[a[x]]--;
if(!cnt[a[x]]) now--;
}
signed main(){
cin>>n;
for(int i=1;i<=n;i++) cin>>a[i];
cin>>m;
for(int i=1;i<=m;i++){
cin>>lt>>rt;
while(l>lt) add(--l);
while(r<rt) add(++r);
while(l<lt) del(l++);
while(r>rt) del(r--);
cout<<now<<endl;
}
return 0;
}
困境——乱跑的指针
当一个询问的左端点和右端点与上一个询问的左端点与右端点差距过大时,就会出现 \(l\) 指针和 \(r\) 指针在数列上乱跑的情况,无法保证效率。
例如当 \(N=30000\) 时的这几个询问:
1 2
29999 30000
3 4
29997 29998
...
这时, \(l\) 指针和 \(r\) 指针先从序列开头跑到了序列末尾,又从序列末尾跑到了接近序列开头的位置,然后又跑到了接近序列末尾的位置。
那么如何提高指针移动的效率呢?
观察到,指针的低效移动是由询问左右端点的无序性引起的,所以我们可以把询问离线,并将数列分块。按照左端点所处的块的位置升序排序,如果左端的所处的块位置相同,则按照右端点升序排序。这样,就在保证了左端点比较有序也保证了右端点比较有序,从而提高了指针的移动效率。
代码如下:
点击查看代码
#include<bits/stdc++.h>
#define endl '\n'
#define int long long
using namespace std;
int n,a[30005],m,cnt[1000005],now,l=1,r=0,L[30005],R[30005],pos[30005],t,ans[200005];
struct node{
int l,r,id;
}q[200005];
inline bool cmp(node x,node y){
if(pos[x.l]==pos[y.l]) return x.r<y.r;
return pos[x.l]<pos[y.l];
}
inline void add(int x){
if(!cnt[a[x]]) now++;
cnt[a[x]]++;
}
inline void del(int x){
cnt[a[x]]--;
if(!cnt[a[x]]) now--;
}
signed main(){
std::ios::sync_with_stdio(false);
cin>>n;
for(int i=1;i<=n;i++) cin>>a[i];
t=sqrt(n);
for(int i=1;i<=t;i++){
L[i]=(i-1)*t+1;
R[i]=i*t;
}
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;
}
}
cin>>m;
for(int i=1;i<=m;i++){
cin>>q[i].l>>q[i].r;
q[i].id=i;
}
sort(q+1,q+1+m,cmp);
for(int i=1;i<=m;i++){
while(l>q[i].l) add(--l);
while(r<q[i].r) add(++r);
while(l<q[i].l) del(l++);
while(r>q[i].r) del(r--);
ans[q[i].id]=now;//注意,排序只有询问的顺序可能会改变,所以应该将答案存在ans[q[i].id]而不是ans[i]
//4个while循环的位置有一定的要求,详见OI-Wiki
}
for(int i=1;i<=m;i++) cout<<ans[i]<<endl;
return 0;
}
优化——顺路而为之
考虑一种排序方式:
对于左端点所处的块不同的询问,按左端点升序排序。对于左端点所处的块相同的询问,若这个块是的编号是奇数,则按右端点升序排序,否则按右端点降序排序。
按照这样的方式排序后,\(r\) 指针能在处理完奇数块的询问后,可以在返回的途中顺路处理偶数块的询问,减少 \(r\) 指针的移动次数,从而提高效率。
代码如下:
bool cmp(node x,node y){
if(x.l/t!=y.l/t) return x.l<y.l;
if((x.l/t)&1) return x.r<y.r;
return x.r>y.r;
}
一些习题
题意简述
求区间众数的出现次数。
解法分析
本题可以离线,于是可以考虑使用莫队解决。
add
操作比较好写,下面我们主要考虑 del
操作。
记删掉的数为 \(x\),当前答案为 \(y\),分 \(3\) 种情况讨论:
-
\(x\) 的出现次数小于 \(y\),对 \(y\) 没有影响。
-
\(x\) 的出现次数等于 \(y\),但不止 \(x\) 一个数出现了 \(y\) 次,对 \(y\) 没有影响。
-
\(x\) 的出现次数等于 \(y\),并且只有 \(x\) 一个数出现了 \(y\) 次,\(y\leftarrow y-1\)。
综上,我们需要开两个数组 \(cnt1,cnt2\),\(cnt1_i\) 记录 \(i\) 出现的次数, \(cnt2_i\) 记录出现次数为 \(i\) 的数的个数。
代码
点击查看代码
#include<bits/stdc++.h>
using namespace std;
inline int read(){
...
}
inline void write(int x){
...
}
// read函数与write函数为快读与快写
int n,m,a[100005],b[100005],t,l=1,r,cnt1[200005],cnt2[200005],now,ans[200005];
struct node{
int l,r,id;
}q[200005];
bool cmp(node x,node y){
if(x.l/t!=y.l/t) return x.l<y.l;
if((x.l/t)&1) return x.r<y.r;
return x.r>y.r;
}//对排序策略的优化
void add(int x){
cnt2[cnt1[x]]--;
cnt1[x]++;
cnt2[cnt1[x]]++;
now=max(now,cnt1[x]);
}
void del(int x){
cnt2[cnt1[x]]--;
if(!cnt2[cnt1[x]] && now==cnt1[x]) now--;
cnt1[x]--;
cnt2[cnt1[x]]++;
}
int main(){
n=read();
m=read();
for(int i=1;i<=n;i++){
a[i]=read();
b[i]=a[i];
}
sort(b+1,b+1+n);
t=unique(b+1,b+1+n)-b-1;
for(int i=1;i<=n;i++) a[i]=lower_bound(b+1,b+1+t,a[i]-b;//离散化
t=sqrt(n);
for(int i=1;i<=m;i++){
q[i].l=read();
q[i].r=read();
q[i].id=i;
}
sort(q+1,q+1+m,cmp);
for(int i=1;i<=m;i++){
while(l>q[i].l) add(a[--l]);
while(r<q[i].r) add(a[++r]);
while(l<q[i].l) del(a[l++]);
while(r>q[i].r) del(a[r--]);
ans[q[i].id]=now;
}
for(int i=1;i<=m;i++){
write(ans[i]);
putchar('\n');
}
return 0;
}
带修莫队
显然,想小标题是困难的QAQ
修改——与时间旅行
我们同样通过一道例题来探究带修莫队。
题目链接。
本题要求支持修改操作,我们可以在修改操作上加上一维时间维度,它的取值是最近一次修改操作的时间,即最近一次修改操作是第几次修改操作。同样,在主函数记录一个当前时间,如果当前时间早于查询操作的时间,就说明在回答这个询问之前需要按照输入数据进行修改操作;如果当前时间晚于查询操作的时间,就说明在回答这个查询之前需要按照输入数据对修改操作进行撤销,也就是尝试向以下6个方向扩展或缩小。
-
\([l-1,r,time]\)
-
\([l+1,r,time]\)
-
\([l,r-1,time]\)
-
\([l,r+1,time]\)
-
\([l,r,time-1]\)
-
\([l,r,time+1]\)
另外,由于加上了时间这一维,所以我们需要改变排序策略,具体的代码如下:
点击查看代码
#include<bits/stdc++.h>
#define endl '\n'
#define int long long
using namespace std;
int n,m,a[200005],t,cnt_c,cnt_q,cnt[1000005],now,l=1,r,last,ans[200005];
struct que{
int id,ti,l,r;
}q[200005];
struct cha{
int x,dat;
}c[200005];
bool cmp(que x,que y){
if(x.l/t!=y.l/t) return x.l/t<y.l/t;
else if(x.r/t!=y.r/t) return x.r/t<y.r/t;
else return x.ti<y.ti;//在排序时需要考虑时间
}
void add(int x){
if(!cnt[x]) now++;
cnt[x]++;
}
void del(int x){
cnt[x]--;
if(!cnt[x]) now--;
}
signed main(){
std::ios::sync_with_stdio(false);
cin>>n>>m;
for(int i=1;i<=n;i++){
cin>>a[i];
}
for(int i=1;i<=m;i++){
char t1;
int t2,t3;
cin>>t1>>t2>>t3;
if(t1=='Q') q[++cnt_q]=(que){cnt_q,cnt_c,t2,t3};
else if(t1=='R') c[++cnt_c]=(cha){t2,t3};
}
t=pow(n,2.0/3.0);
sort(q+1,q+1+cnt_q,cmp);
for(int i=1;i<=cnt_q;i++){
while(l>q[i].l) add(a[--l]);
while(r<q[i].r) add(a[++r]);
while(l<q[i].l) del(a[l++]);
while(r>q[i].r) del(a[r--]);
while(last<q[i].ti){
last++;
if(c[last].x<=r && c[last].x>=l){
add(c[last].dat);
del(a[c[last].x]);
}
swap(a[c[last].x],c[last].dat);//在下面的撤销修改中再次swap即可撤销修改,减少码量
}
while(last>q[i].ti){
if(c[last].x<=r && c[last].x>=l){
add(c[last].dat);
del(a[c[last].x]);
}
swap(a[c[last].x],c[last].dat);
last--;
}
ans[q[i].id]=now;
}
for(int i=1;i<=cnt_q;i++) cout<<ans[i]<<endl;
return 0;
}
回滚莫队
但是我还是决定想小标题。
回滚——我是小标题
我们依然从一道例题开始讲起。
题目链接。
在这道题目中,在莫队中插入元素比较简单,但是从莫队中删除元素较为困难,于是,我们可以只使用插入这一个操作,剩下的交给回滚解决,这就是回滚莫队的核心思想。
假设块长为 \(t\)。
首先,我们将询问离线下来,并且将询问按照左端点所处的块为第一关键字,右端点为第二关键字升序排序,这样左端点在同一块的询问就会变得连续,而它们的右端点将递增。按排序后的顺序处理询问,若该询问的左右端点在同一个块内,可以在 \(O(t)\) 的时间复杂度内暴力的解决这个询问。若它们不在同一个块内,则考虑该询问的左端点是否与上一个询问的左端点在同一块内,若否,则消去之前询问对答案的影响,将莫队的左端点设置为当前询问所处块的右端点 \(+1\),将莫队的右端点设置为当前询问所处块的右端点,接着,不断扩展莫队的右端点直到它与询问的右端点重合,再不断扩展莫队的左端点直到它与莫队的左端点重合,然后回答该询问,最后撤销莫队左端点的改动。
具体代码如下:
点击查看代码
#include<bits/stdc++.h>
#define int long long
using namespace std;
inline int read(){register int t1=0,t2=0;register char x=getchar();while(x<'0' ||x>'9'){if(x=='-') t2|=1;x=getchar();}while(x>='0' && x<='9'){t1=(t1<<1)+(t1<<3)+(x^48),x=getchar();}return t2?-t1:t1;}
inline void write(int x){register int sta[105],top=0;if(x<0) putchar('-'),x=-x;do{sta[top++]=x%10,x/=10;}while(x);while(top) putchar(sta[--top]+48);}
int n,m,a[100005],b[100005],pos[100005],R[100005],L[100005],t,temp,l=1,r=0,now,cnt[100005],temp_cnt[100005],ans[100005],last,temp_l;
struct que{
int l,r,id;
}q[100005];
void build(){
t=sqrt(n);
for(int i=1;i<=t;i++){
L[i]=(i-1)*t+1;
R[i]=i*t;
}
if(R[t]<n){
t++;
R[t]=n;
L[t]=R[t-1]+1;
}
for(int i=1;i<=t;i++) for(int j=L[i];j<=R[i];j++) pos[j]=i;
}
bool cmp(que x,que y){
if(pos[x.l]!=pos[y.l]) return pos[x.l]<pos[y.l];
return x.r<y.r;
}
inline void add(int x,int& y){
cnt[a[x]]++;
y=max(y,cnt[a[x]]*b[a[x]]);
}
inline void del(int x){cnt[a[x]]--;}
signed main(){
n=read();
m=read();
build();
for(int i=1;i<=n;i++){
b[i]=read();
a[i]=b[i];
}
for(int i=1;i<=m;i++){
q[i].l=read();
q[i].r=read();
q[i].id=i;
}
sort(q+1,q+1+m,cmp);
sort(b+1,b+1+n);
temp=unique(b+1,b+1+n)-b-1;
for(int i=1;i<=n;i++) a[i]=lower_bound(b+1,b+1+temp,a[i])-b;
for(int i=1;i<=m;i++){
if(pos[q[i].l]==pos[q[i].r]){
for(int j=q[i].l;j<=q[i].r;j++){
temp_cnt[a[j]]++;
ans[q[i].id]=max(ans[q[i].id],temp_cnt[a[j]]*b[a[j]]);
}
for(int j=q[i].l;j<=q[i].r;j++) temp_cnt[a[j]]--;
}
else{
if(pos[q[i].l]!=last){
while(l<R[pos[q[i].l]]+1) del(l++);
while(r>R[pos[q[i].l]]) del(r--);
now=0;
last=pos[q[i].l];
}
while(r<q[i].r) add(++r,now);
temp_l=l;
temp=now;
while(temp_l>q[i].l) add(--temp_l,temp);
ans[q[i].id]=temp;
while(temp_l<l) del(temp_l++);
}
}
for(int i=1;i<=m;i++){
write(ans[i]);
putchar('\n');
}
return 0;
}