【学习笔记】莫队
0 前言
前置知识:
-
会打暴力。 -
简单的分块或根号思想。
二次离线莫队不会,就不写了。
1 普通莫队
不妨直接上一个例题来看看,通过题目来直接引入莫队:题目链接。
题意:给定一个长度为
我们不妨设置两个指针
对于每次询问,暴力做法就是直接开一个桶,然后每次把
不过这一步十分重要,先放个代码大家理解下:
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]);
}
考虑上述做法复杂度高的原因:我们的
具体怎么做呢?我们考虑对原序列进行分块:将原序列分为
然后再套用我们上面的暴力方法做即可。
这样复杂度变了多少呢?直接省掉了一个根号!
接下来我们不妨对其复杂度进行证明(下文中记块长为
update on 2023/5/1:在省集听了 lxl 的讲解之后,才知道我莫队的复杂度一直是记错的。
左指针的移动:假如下一个左端点在当前块内,由于块的大小为
右指针的移动:对于每一个块,右指针单调递增,因此每一个块内右指针移动的复杂度为
总复杂度为
说了这么多,代码终于能端上来了。
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--
的顺序来写,否则可能会因为奇怪的原因 RE。
具体的原因大家可以取 OI-Wiki 上看,有相对详细的介绍。
2 带修改的莫队
我们同样还是以例题的方式来引入:题目链接。
我们之前是引入了两个指针
排序方式:按照询问左端点所在块的编号排序,左端点所在块相同时按右端点所在块的编号排序,右端点所在块相同时按照时间戳排序。当块长取
直接说有点抽象,结合代码理解:
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 子树统计相关#
图太丑了呜呜呜……
这个其实大家应该都比较熟悉了,子树问题可以通过预处理 dfs 序和子树大小,来转换成区间问题,以 [dfn[x],dfn[x]+siz[x]-1]
。
例题没找到合适的,有人有的话戳我一下。
3.2 路径统计相关#
不难发现,路径问题是无法用普通 dfs 序来解决的。
但是,我们有一种新的序列——欧拉序,也叫括号序。
它的求法十分简单,让每个点入栈两次即可,先上一张图(图片来自网络,如果侵权立马删除):
不妨观察下它有什么性质:以
我们不妨记点
我们分两种情况来考虑。
不妨设
第二种情况即
至于这么做的具体证明就不再给了,结合上面的图其实应该很好理解。
求欧拉序的代码放在这里:
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 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 回滚莫队
有的时候,我们可能发现,对于某些题目,添加操作十分好实现,但是删除操作却异常困难(或者反过来)。这个时候,我们可能就需要用到回滚莫队。
就比如这道题:题目链接。
我们发现,如果单纯只是加入一个数的话,题目要求的东西其实是非常好维护的。但是删除呢?次大值我们是不可能快速地直接维护的。
我们仍然从莫队排序后的性质出发,看看能不能找到解决问题的方法。既然我们实现不了删除,那么我们直接不删除不就行了吗?
同样还是对序列分好块,这次我们需要预处理出第
-
如果这次询问的左右端点都在同一个块内的话,那么我们不妨直接暴力从前往后扫一遍,复杂度只有
。 -
否则,如果当前询问的左端点和上一个询问左端点不在同一块内,我们直接将答案和贡献清零,并把
指针移动到当前块的右端点 , 指针移动到当前块的右端点。 -
每次移动,先把
指针移动到对应位置,对于左端点同属一块的询问,右端点是单调向右的,因此 指针的贡献不需要消除;随后,把 指针移动到对应的端点,统计完答案后,将 指针归回到原位,因为 指针无法保持单调,对答案带来的贡献不一定能留下去。
时间复杂度分析:对于
综上所述,回滚莫队的复杂度也只有
具体实现的细节直接看代码。
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]);
}
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 从 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)