莫队——一个优雅又不失效率的算法
前言
今天突然想写一下关于莫队的那些事,原因无非有两点:
-
最近在写一些莫队的题
-
被莫队这个算法深深的吸引了
于是决定在今天写一篇简短但是不失莫队精髓的学习笔记。
莫队的引出
我们在刚开始做区间问题的时候很多时候会想到用两个指针去模拟当前区间。在需要回答下一个问题的时候,会去用当前的区间去更新即将回答的区间。
当时还不怎么会算复杂度的我兴冲冲的提交了代码,结果一片紫黑,我便思考,直接傻乎乎的去做区间似乎太蠢了。当时想了很多很多稀奇古怪的算法,结果无一 \(AC\) (废话,不然发明莫队的就有可能就是我了……)。
不过,现在终于有一种算法能够完成我当年的愿望了——莫队!!!
\(P.S.\) 其实莫队应该发明得挺早的,只不过我了解的比较晚。
莫队的基本操作
从刚刚的引入就可以大致猜到莫队的实现方式就是通过两个指针的跳跃来维护区间的信息。但是显然,像前文中讲的直接去移动指针肯定是不行的,我们需要合理地规划移动顺序,然后使得移动的次数尽量少,就可以了。
莫队的发明者是这样考虑的。我们先将询问的左端点进行分块处理,块长为 \(\sqrt n\) ,然后对于每个块内部的点,进行右端点的排序。然后以这样的顺序来移动指针的时候,我们可以发现:
左指针在块内最大移动 \(\sqrt n\) 的距离,最多移动 \(n\) 次,复杂度为 \(O(n~\sqrt n)\) ;右指针最大移动 \(n\) 的距离,最多移动 \(\sqrt n\) 次,复杂度为 \(O(n~\sqrt n)\) 。总复杂度为 \(O(n~\sqrt n)\) 。
这样的复杂度是可以通过很多区间题目的数据范围的。
其实复杂度最小的移动指针的做法是利用曼哈顿距离最小生成树,因为我们可以将指针的移动转化为笛卡尔坐标系上点的水平与竖直的移动,就是曼哈顿距离,利用曼哈顿距离最小生成树的构建就可以得到最小的移动次数,但是由于代码实现(相比于分块的简洁,略微)复杂,所以不考虑使用。
例题一:P2709 小B的询问
这是一道非常基础的莫队题,我们可以利用桶和二次项定理来维护区间的答案,适合练手。
代码如下:
#include<bits/stdc++.h>
using namespace std;
const int N=5e4+5,M=5e4+5,K=5e4+5;
int n,m,k,size;
int a[N],cnt[K],bel[N];
struct Query{int l,r,id;}s[M];
bool cmp(Query a,Query b)
{
if(bel[a.l]!=bel[b.l]) return a.l<b.l;
if(bel[a.l]&1) return a.r<b.r;
return a.r>b.r;
}
int ans[M];
int main()
{
cin>>n>>m>>k;
for(int i=1;i<=n;++i) scanf("%d",&a[i]);
for(int i=1;i<=m;++i) scanf("%d%d",&s[i].l,&s[i].r),s[i].id=i;
size=sqrt(n);
for(int i=1,cnt=0;i<=n;i+=size,cnt++)
{
for(int j=i;j<=min(i+size-1,n);++j)
bel[j]=cnt;
}
sort(s+1,s+1+m,cmp);
int l=1,r=0,sum=0;
for(int i=1;i<=m;++i)
{
while(s[i].l>l) sum-=2*cnt[a[l]]-1,cnt[a[l]]--,l++;
while(s[i].l<l) l--,sum+=2*cnt[a[l]]+1,cnt[a[l]]++;
while(s[i].r>r) r++,sum+=2*cnt[a[r]]+1,cnt[a[r]]++;
while(s[i].r<r) sum-=2*cnt[a[r]]-1,cnt[a[r]]--,r--;
ans[s[i].id]=sum;
}
for(int i=1;i<=m;++i) printf("%d\n",ans[i]);
return 0;
}
莫队的进阶一
可以发现,莫队算法可以使用的必备条件是区间之间的转移必须是 \(O(1)\) 的,但是在获得当前区间的答案的时候可以偷偷的加一点东西……
例题二:CF1000F One Occurrence
你会发现,这道题目跟莫队的大体思路很像,只出现一次的数似乎用莫队很好维护,但是询问的内容是输出任意一个只出现一次的数,很难用 \(O(1)\) 去转移。
我们可以考虑引入一个懒标记,将转移区间的复杂度均摊到查询答案里,就是在获得答案的时候加一点东西,使得最终复杂度正确。
详情见我的另一篇博客
例题三:P3674 小清新人渣的本愿
这是一道很不错的题目。我们可以发现,如果你直接上 \(bool\) 数组来统计莫队的转移信息的话,获取答案的时候是肯定是会 \(TLE\) 的,所以我们这里就需要考虑一个优秀的东西—— \(bitset\) ,可以让你的代码减去一个较大的常数,效率还是很可观的。
代码如下:
#include<bits/stdc++.h>
using namespace std;
const int N=1e5+5,M=1e5+5,C=1e5+5;
inline int read()
{
char c=getchar();int x=0;
while(c<'0'||'9'<c) c=getchar();
while('0'<=c&&c<='9') x=x*10+c-'0',c=getchar();
return x;
}
int n,m,size;
int a[N],bel[N];
struct Query{int opt,l,r,x,id;}s[M];
bool cmp(Query a,Query b)
{
if(bel[a.l]!=bel[b.l]) return a.l<b.l;
if(bel[a.l]&1) return a.r<b.r;
return a.r>b.r;
}
bitset<C> tag,rtag,now;
int cnt[C];
int l,r,ans[M];
int main()
{
n=read(),m=read(),size=sqrt(n);
for(int i=1;i<=n;++i) a[i]=read();
for(int i=1;i<=m;++i) s[i].opt=read(),s[i].l=read(),s[i].r=read(),s[i].x=read(),s[i].id=i;
for(int i=1,cnt=1;i<=n;i+=size,cnt++)
{
for(int j=i;j<=min(i+size-1,n);++j)
bel[j]=cnt;
}
sort(s+1,s+1+m,cmp);
l=1,r=0;
for(int i=1;i<=m;++i)
{
while(l<s[i].l) cnt[a[l]]--,tag[a[l]]=cnt[a[l]],rtag[1e5-a[l]+1]=cnt[a[l]],l++;
while(s[i].l<l) l--,cnt[a[l]]++,tag[a[l]]=cnt[a[l]],rtag[1e5-a[l]+1]=cnt[a[l]];
while(r<s[i].r) r++,cnt[a[r]]++,tag[a[r]]=cnt[a[r]],rtag[1e5-a[r]+1]=cnt[a[r]];
while(s[i].r<r) cnt[a[r]]--,tag[a[r]]=cnt[a[r]],rtag[1e5-a[r]+1]=cnt[a[r]],r--;
if(s[i].opt==1) now=(tag&(tag>>s[i].x)),ans[s[i].id]=now.any();
if(s[i].opt==2) now=(tag&(rtag>>(1e5+1-s[i].x))),ans[s[i].id]=now.any();
if(s[i].opt==3)
{
for(int j=1;j*j<=s[i].x;++j)
if(s[i].x%j==0&&tag[j]&&tag[s[i].x/j]) ans[s[i].id]=true;
}
}
for(int i=1;i<=m;++i)
if(ans[i]) printf("hana\n");
else printf("bi\n");
return 0;
}
莫队的进阶二
说到造题的好方法之一,就是让区间问题变为树上问题,所以莫队可以处理树上问题吗?当然是可以的。
例题四:P5838 [USACO19DEC]Milk Visits G
非常裸的一道树上莫队。相比于普通的莫队,我们只需要引入一个叫做欧拉序的东西就可以做了。
详情请见我的另一篇博客
莫队的进阶三
离线算法一定不能带修吗?\(No,No,No\) ,莫队是可以带修的。
例题五:P1903 [国家集训队]数颜色 / 维护队列
我们发现除了带修之外,他的操作跟普通的莫队是很像的,我们该如何考虑修改呢?
我们当然不能放弃莫队指针转移的灵魂,所以我们能否考虑像枚举左右端点一样考虑枚举时间戳呢?
当然也是可以的,我们可以考虑同时将左右端点都分块,最后在相同的左右端点块里将 \(t\) 排序即可。当然了,此时块的大小需要调整,经大佬的证明,块长取 \(O(n^{\frac{2}{3}})\) 最佳。(蒟蒻不会证明……)
代码如下:
#include<bits/stdc++.h>
using namespace std;
const int N=133345,M=133345,MAXN=1e6+5;
inline int read()
{
char c=getchar();int x=0;
while(c<'0'||c>'9') c=getchar();
while('0'<=c&&c<='9') x=x*10+c-'0',c=getchar();
return x;
}
inline void write(int x)
{
if(x>9) write(x/10);
putchar(x%10+'0');
}
int n,m,size;
int a[N],bel[N];
struct Query{int l,r,t,id;}q[M];int lq;
struct Change{int x,u,v,t;}s[M];int ls;
bool cmp(Query a,Query b)
{
if(bel[a.l]!=bel[b.l]) return a.l<b.l;
if(bel[a.r]!=bel[b.r])
{
if(bel[a.l]&1) return a.r<b.r;
return a.r>b.r;
}
if(bel[a.r]&1) return a.t>b.t;
return a.t<b.t;
}
int l,r,t,sum=0;
int cnt[MAXN],ans[M];
int main()
{
n=read(),m=read(),size=pow(n,2.0/3);
for(int i=1;i<=n;++i) a[i]=read();
for(int i=1,cnt=0;i<=n;i+=size,cnt++)
{
for(int j=i;j<=min(i+size-1,n);++j)
bel[j]=cnt;
}
for(int i=1;i<=m;++i)
{
char c=getchar();int x,y;
while(c!='Q'&&c!='R') c=getchar();
x=read(),y=read();
if(c=='Q') q[++lq].l=x,q[lq].r=y,q[lq].t=t,q[lq].id=lq;
else s[++ls].t=++t,s[ls].x=x,s[ls].u=a[x],s[ls].v=a[x]=y;
}
sort(q+1,q+1+lq,cmp);
l=1,r=0;
for(int i=1;i<=lq;++i)
{
while(q[i].l>l) cnt[a[l]]--,sum-=(!cnt[a[l]]),l++;
while(q[i].l<l) l--,cnt[a[l]]++,sum+=(cnt[a[l]]==1);
while(q[i].r<r) cnt[a[r]]--,sum-=(!cnt[a[r]]),r--;
while(q[i].r>r) r++,cnt[a[r]]++,sum+=(cnt[a[r]]==1);
while(q[i].t>t)
{
t++;
if(l<=s[t].x&&s[t].x<=r) cnt[a[s[t].x]]--,sum-=(!cnt[a[s[t].x]]);
a[s[t].x]=s[t].v;
if(l<=s[t].x&&s[t].x<=r) cnt[a[s[t].x]]++,sum+=(cnt[a[s[t].x]]==1);
}
while(q[i].t<t)
{
if(l<=s[t].x&&s[t].x<=r) cnt[a[s[t].x]]--,sum-=(!cnt[a[s[t].x]]);
a[s[t].x]=s[t].u;
if(l<=s[t].x&&s[t].x<=r) cnt[a[s[t].x]]++,sum+=(cnt[a[s[t].x]]==1);
t--;
}
ans[q[i].id]=sum;
}
for(int i=1;i<=lq;++i) write(ans[i]),putchar('\n');
return 0;
}
莫队的小技巧
当然,莫队作为一种比较暴力的算法,很多时候是很难完全 \(AC\) 题目的,所以需要一些奇技淫巧来帮助过题:
- 可以在交题前洗一把脸
- 可以使用一些奇怪的优化
- 可以将排序的函数奇偶分开排序,奇数正序,偶数倒序,因为你做完奇数后右指针回来的时候正好做偶数。(我前面的题的排序都是这么写的,可以看看)
后记
希望大家莫队写的开心,题题 \(AC\) 。