【学习笔记】莫队

0 前言

前置知识:

  • 会打暴力。

  • 简单的分块或根号思想。

二次离线莫队不会,就不写了。

1 普通莫队

不妨直接上一个例题来看看,通过题目来直接引入莫队:题目链接

题意:给定一个长度为 n 的数列 nm 次询问区间 [l,r] 中的不同数字数。

我们不妨设置两个指针 l,r,表示询问区间的左右端点,初始令 l=1,r=0

对于每次询问,暴力做法就是直接开一个桶,然后每次把 l,r 指针移动到对应的区间,顺便更新下桶,期间把答案统计出来就可以了。但是这样的时间复杂度是 O(nm) 的,无法通过。

不过这一步十分重要,先放个代码大家理解下:

Copy
void Add(int x){ //将数加入桶中 if(!cnt[a[x]]) now++; cnt[a[x]]++; } void Del(int x){ //将数从桶中删除 cnt[a[x]]--; if(!cnt[a[x]]) now--; } void Main(){ read(n);For(i,1,n) read(a[i]); read(m);For(i,1,m) read(q[i].l,q[i].r),q[i].id=i; int l=1,r=0; For(i,1,m){ while(l>q[i].l) Add(--l); //移动l,r指针到对应区间 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; } For(i,1,m) printf("%d\n",ans[i]); }

考虑上述做法复杂度高的原因:我们的 l,r 指针每次询问的移动次数最高是可以到达 O(n) 的(比如上次 l1,这次 ln)。而莫队的核心思想就在于,通过分块和排序的方式来降低指针的移动次数

具体怎么做呢?我们考虑对原序列进行分块:将原序列分为 n 块,每块的长度为 n(理论上块长取 nm 最优,但是差别不大,见下文的复杂度证明)。然后,将所有的询问离线下来,并按照如下方式排序:对于每个询问,优先按照左端点所在的块的编号进行排序,如果左端点所在块的编号相同就按照右端点排序

然后再套用我们上面的暴力方法做即可。

这样复杂度变了多少呢?直接省掉了一个根号!

接下来我们不妨对其复杂度进行证明(下文中记块长为 B):

update on 2023/5/1:在省集听了 lxl 的讲解之后,才知道我莫队的复杂度一直是记错的。

左指针的移动:假如下一个左端点在当前块内,由于块的大小为 B,那么此时左指针移动的复杂度就是 O(B);假如下一个左端点和当前不在同一个块内,那么左指针最多只会跑到下一个块内,复杂度也是 O(B)。综上,左指针每次移动的复杂度不会超过 O(B),总复杂度不会超过 O(mB)

右指针的移动:对于每一个块,右指针单调递增,因此每一个块内右指针移动的复杂度为 O(n),而总共有 nB 个块,因此右指针移动总复杂度不会超过 O(n2B)

总复杂度为 O(mB+n2B),根据基本不等式,令 mB=n2B,可以解得 B=nm,代入可得总复杂度为 O(mn)

说了这么多,代码终于能端上来了。

Copy
struct Query{ int id,l,r; }q[N]; int n,m,now,a[N],bl[N],cnt[N],ans[N]; bool cmp(const Query &a,const Query &b){ if(bl[a.l]==bl[b.l]) return a.r<b.r; //分块后排序 return bl[a.l]<bl[b.l]; } void Add(int x){ //将数加入桶 if(!cnt[a[x]]) now++; cnt[a[x]]++; } void Del(int x){ //将数从桶中删除 cnt[a[x]]--; if(!cnt[a[x]]) now--; } void Main(){ read(n);For(i,1,n) read(a[i]); read(m);For(i,1,m) read(q[i].l,q[i].r),q[i].id=i; int siz=sqrt(n+1); For(i,1,n) bl[i]=(i-1)/siz+1; //分块 sort(q+1,q+m+1,cmp); int l=1,r=0; For(i,1,m){ 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; //离线答案别统计错了 } For(i,1,m) printf("%d\n",ans[i]); }

至此,我们大概可以总结出来莫队适用的问题:

  • 问题可以离线。

  • 问题的询问是以区间的形式给出。

  • 插入或删除一个数的操作能以低复杂度快速实现。

补充:l,r 指针的移动顺序。

这里我建议大家莫队统一采用我上面给出的顺序来写,即 --l,++r,l++,r-- 的顺序来写,否则可能会因为奇怪的原因 RE。

具体的原因大家可以取 OI-Wiki 上看,有相对详细的介绍。

2 带修改的莫队

我们同样还是以例题的方式来引入:题目链接

我们之前是引入了两个指针 l,r,现在我们考虑再引入一个指针 t,代表当前进行的修改操作。对于每个询问,我们记录它是在第几次操作之后(称其为“时间戳”),移动完 l,r 指针之后,我们再比较一下指针 t 和当前询问的时间戳关系:如果当前询问是在第 t 次修改之前的,那么就将多的几次修改改回去;如果当前询问是在第 t 次修改之后的,我们就要多进行几次修改。

排序方式:按照询问左端点所在块的编号排序,左端点所在块相同时按右端点所在块的编号排序,右端点所在块相同时按照时间戳排序。当块长取 n23 时取得最优复杂度 O(n53),具体证明过程不再展示。

直接说有点抽象,结合代码理解:

Copy
using Query=struct{int l,r,tim,id;}; using Modify=struct{int pos,col;}; int n,m,a[N]; int block,now,bl[N],cnt[N],ans[N]; int tot1,tot2;Query q[N];Modify c[N]; bool cmp(const Query &a,const Query &b){ //新的排序方式 if(bl[a.l]!=bl[b.l]) return bl[a.l]<bl[b.l]; if(bl[a.r]!=bl[b.r]) return bl[a.r]<bl[b.r]; return a.tim<b.tim; } namespace MoOrz{ void Add(int pos){ cnt[a[pos]]++; if(cnt[a[pos]]==1) now++; } void Del(int pos){ cnt[a[pos]]--; if(!cnt[a[pos]]) now--; } void Change(int pos,int i){ if(c[pos].pos>=q[i].l&&c[pos].pos<=q[i].r){ //要注意的一点,只有修改的位置在当前询问的区间内的时候才会对答案产生影响 if(--cnt[a[c[pos].pos]]==0) now--; if(++cnt[c[pos].col]==1) now++; } swap(c[pos].col,a[c[pos].pos]); //这里是非常妙的一点,解释一下 //假如某次修改是把a[3]的颜色3改为7,那么下次再改回来的时候就相当于把a[3]的7再改回3 // 不难发现其实就是交换了一下,非常方便 } void Solve(){ int l=1,r=0,t=0; For(i,1,tot1){ 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--); // printf("q[i].l:%d q[i].r:%d now:%d t:%d q[i].tim:%d\n",q[i].l,q[i].r,now,t,q[i].tim); while(t<q[i].tim) Change(++t,i); while(t>q[i].tim) Change(t--,i); ans[q[i].id]=now; } } } void Main(){ read(n,m);For(i,1,n) read(a[i]); For(i,1,m){ char opt;cin>>opt; if(opt=='Q') q[++tot1].l=read(),q[tot1].r=read(),q[tot1].tim=tot2,q[tot1].id=tot1; else c[++tot2].pos=read(),c[tot2].col=read(); //修改要单独拎出来 } block=pow(n,(db)2.0/3.0);For(i,1,n) bl[i]=(i-1)/block+1; //注意块长的大小 sort(q+1,q+tot1+1,cmp);MoOrz::Solve(); For(i,1,tot1) printf("%d\n",ans[i]); }

3 树上莫队

3.1 子树统计相关#

p9pvho4.png

图太丑了呜呜呜……

这个其实大家应该都比较熟悉了,子树问题可以通过预处理 dfs 序和子树大小,来转换成区间问题,以 x 为根的子树所对应的区间就可以表示为 [dfn[x],dfn[x]+siz[x]-1]

例题没找到合适的,有人有的话戳我一下。

3.2 路径统计相关#

不难发现,路径问题是无法用普通 dfs 序来解决的。

但是,我们有一种新的序列——欧拉序,也叫括号序。

它的求法十分简单,让每个点入栈两次即可,先上一张图(图片来自网络,如果侵权立马删除):

p99JWnI.png

不妨观察下它有什么性质:以 3 为例,3 在欧拉序中一共出现了两次,且两个 3 中间出现的点有 6,8,9,10,11,不难发现它们其实都是 3 子树内的点,这种优美的性质就可以很好地帮我们解决树上莫队问题。

我们不妨记点 i 在欧拉序中第一个出现位置为 st[i],第二个出现位置为 ed[i],现在有两个点 (u,v),如何将它们路径上的点转化为对应区间呢?
我们分两种情况来考虑。

不妨设 st[u]<st[v],第一种情况就是 lca(u,v)=u,即 uv 的直接祖先,此时我们可以直接将欧拉序的区间 [st[u],st[v]] 拉出来使用,并且将区间中出现过两次的点不加入答案的计算。根据性质,路径上的点一定都会只出现一次。

第二种情况即 u,v 分属不同的子树中,这个时候我们直接把区间 [ed[u],st[v]] 拉出来使用,同样区间中出现过两次的点忽略不计,理由同上。当然这么做不难发现 lca(u,v) 也被忽略掉了,特判一下加上就可以了。

至于这么做的具体证明就不再给了,结合上面的图其实应该很好理解。

求欧拉序的代码放在这里:

Copy
void dfs1(int now,int fath){ fa[now]=fath;dep[now]=dep[fath]+1;siz[now]=1; st[now]=++clk;pts[clk]=now; for(int to:G[now]){ if(to==fath) continue; dfs1(to,now); siz[now]+=siz[to]; if(siz[to]>siz[son[now]]) son[now]=to; } ed[now]=++clk;pts[clk]=now; }

例题:题目链接

套用欧拉序其实就成了区间不同颜色数的问题,具体的实现及细节直接看我的代码吧。

Copy
void dfs1(int now,int fath){ fa[now]=fath;dep[now]=dep[fath]+1;siz[now]=1; st[now]=++clk;pts[clk]=now; for(int to:G[now]){ if(to==fath) continue; dfs1(to,now); siz[now]+=siz[to]; if(siz[to]>siz[son[now]]) son[now]=to; } ed[now]=++clk;pts[clk]=now; } void dfs2(int now,int lmt){ top[now]=lmt; if(!son[now]) return; dfs2(son[now],lmt); for(int to:G[now]){ if(top[to]) continue; dfs2(to,to); } } int LCA(int x,int y){ while(top[x]!=top[y]){ if(dep[top[x]]<dep[top[y]]) swap(x,y); x=fa[top[x]]; } return dep[x]<dep[y]?x:y; } void Lisanhua(){ sort(b+1,b+n+1); int len=unique(b+1,b+n+1)-b-1; For(i,1,n) a[i]=lower_bound(b+1,b+len+1,a[i])-b; } void prework(){ For(i,1,m){ int x=read(),y=read(),lca=LCA(x,y); if(st[x]>st[y]) swap(x,y); if(lca==x) q[i].l=st[x],q[i].r=st[y]; else q[i].l=ed[x],q[i].r=st[y],q[i].lca=lca; q[i].id=i; } } void Add(int pos){ cnt[a[pos]]++; if(cnt[a[pos]]==1) Ans++; } void Del(int pos){ cnt[a[pos]]--; if(!cnt[a[pos]]) Ans--; } void Change(int pos){ used[pos]?Del(pos):Add(pos); used[pos]^=1; } void Main(){ read(n,m);block=sqrt(n); For(i,1,n) a[i]=b[i]=read(); For(i,1,n<<1) bl[i]=(i-1)/block+1; Lisanhua(); For(i,1,n-1){ int u=read(),v=read(); G[u].pb(v);G[v].pb(u); } dfs1(1,0);dfs2(1,1);prework(); sort(q+1,q+m+1,cmp); int l=1,r=0; For(i,1,m){ while(l>q[i].l) Change(pts[--l]); while(r<q[i].r) Change(pts[++r]); while(l<q[i].l) Change(pts[l++]); while(r>q[i].r) Change(pts[r--]); if(q[i].lca) Change(q[i].lca); ans[q[i].id]=Ans; if(q[i].lca) Change(q[i].lca); } For(i,1,m) printf("%d\n",ans[i]); }

4 回滚莫队

有的时候,我们可能发现,对于某些题目,添加操作十分好实现,但是删除操作却异常困难(或者反过来)。这个时候,我们可能就需要用到回滚莫队。

就比如这道题:题目链接

我们发现,如果单纯只是加入一个数的话,题目要求的东西其实是非常好维护的。但是删除呢?次大值我们是不可能快速地直接维护的。

我们仍然从莫队排序后的性质出发,看看能不能找到解决问题的方法。既然我们实现不了删除,那么我们直接不删除不就行了吗?

同样还是对序列分好块,这次我们需要预处理出第 i 号块的左端点 L[i] 和第 i 号块的右端点 R[i],对于每次询问,考虑如下情况:

  • 如果这次询问的左右端点都在同一个块内的话,那么我们不妨直接暴力从前往后扫一遍,复杂度只有 O(n)

  • 否则,如果当前询问的左端点和上一个询问左端点不在同一块内,我们直接将答案和贡献清零,并把 l 指针移动到当前块的右端点 +1r 指针移动到当前块的右端点。

  • 每次移动,先把 r 指针移动到对应位置,对于左端点同属一块的询问,右端点是单调向右的,因此 r 指针的贡献不需要消除;随后,把 l 指针移动到对应的端点,统计完答案后,l 指针归回到原位,因为 l 指针无法保持单调,对答案带来的贡献不一定能留下去。

时间复杂度分析:对于 r 指针,每个块最多移动 O(n) 次,总共 n 个块,时间复杂度 O(nn);对于 l 指针,每次最多移动 O(n) 次,总共有 n 次询问,复杂度 O(nn)

综上所述,回滚莫队的复杂度也只有 O(n),十分优秀。

具体实现的细节直接看代码。

Copy
using Query=struct{int l,r,id;}; int n,m,q,a[N],b[N],cnt1[N],cnt2[N]; int block,tot,bl[N],L[N],R[N]; int now_ans,ans[N]; Query qry[N]; bool cmp(const Query &a,const Query &b){return (bl[a.l]^bl[b.l])?bl[a.l]<bl[b.l]:a.r<b.r;} void build(){ //建块过程 block=sqrt(n); For(i,1,n){ bl[i]=(i-1)/block+1; if(bl[i]!=bl[i-1]) L[bl[i]]=i,tot++; R[bl[i]]=i; //记录每个块的左右端点 } } void Add(int v,int &now_ans){ cnt1[v]++; now_ans=max(now_ans,cnt1[v]*b[v]); } void Del(int v){cnt1[v]--;} void Main(){ read(n,q); For(i,1,n) read(a[i]),b[++m]=a[i]; sort(b+1,b+m+1);m=unique(b+1,b+m+1)-b-1; //离散化一下 for(int i=1;i<=n;i++) a[i]=lower_bound(b+1,b+m+1,a[i])-b; For(i,1,q) read(qry[i].l,qry[i].r),qry[i].id=i; build();sort(qry+1,qry+q+1,cmp); //对询问排序 int l=1,r=0,last=0; For(i,1,q){ if(bl[qry[i].l]==bl[qry[i].r]){ //如果左右端点在一个块内,直接暴力也是没问题的 For(j,qry[i].l,qry[i].r) cnt2[a[j]]++; For(j,qry[i].l,qry[i].r) ans[qry[i].id]=max(ans[qry[i].id],cnt2[a[j]]*b[a[j]]); For(j,qry[i].l,qry[i].r) cnt2[a[j]]--; continue; } if(bl[qry[i].l]!=last){ //如果和左端点上次询问不在一个块,重置指针 while(r>R[bl[qry[i].l]]) Del(a[r--]); while(l<=R[bl[qry[i].l]]) Del(a[l++]); now_ans=0;last=bl[qry[i].l]; } while(r<qry[i].r) Add(a[++r],now_ans); //右端点直接移动 int l2=l,tmp=now_ans; //答案要记一下,因为右端点的贡献可以延续下去,左端点不行 while(l2>qry[i].l) Add(a[--l2],tmp); ans[qry[i].id]=tmp; while(l2<l) Del(a[l2++]); //回滚回去 } For(i,1,q) printf("%lld\n",ans[i]); }
posted @   KingPowers  阅读(7)  评论(0编辑  收藏  举报
编辑推荐:
· 从 HTTP 原因短语缺失研究 HTTP/2 和 HTTP/3 的设计差异
· AI与.NET技术实操系列:向量存储与相似性搜索在 .NET 中的实现
· 基于Microsoft.Extensions.AI核心库实现RAG应用
· Linux系列:如何用heaptrack跟踪.NET程序的非托管内存泄露
· 开发者必知的日志记录最佳实践
阅读排行:
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· Manus的开源复刻OpenManus初探
· 写一个简单的SQL生成工具
· AI 智能体引爆开源社区「GitHub 热点速览」
· C#/.NET/.NET Core技术前沿周刊 | 第 29 期(2025年3.1-3.9)
点击右上角即可分享
微信分享提示
CONTENTS