我的暴力有救了哈哈哈。
是什么
莫队是一种离线区间查询的算法,是前国家队队长莫涛 提出并推广的。由离线+分块(排序)+暴力移动指针组成,简直就是优雅的暴力。
关于莫对的分类 :目前有普通的只查询莫队、带修莫队(带修改的)、回滚莫队(用撤销操作来代替删除等修改)、以及树上莫队(树剖+其他的(带修\(\dots\)))和二次离线。
怎么实现
想要写代码,就要先知道这个算法的原理。
核心原理
莫队解决的问题通常有这样的特征 :如果已知区间 \(L, R\) 的答案,可以在很快(通常是 \(O(1)\))的时间内,算出相邻区间(如 \(L-1, R\) 或 \(L, R+1\))的答案。
其实就是区间查询,只是莫队算法的核心做法就是:把所有询问离线,按某种顺序排序,然后暴力移动左右指针,让指针总移动距离尽可能小。
代码思路
那我们就按照 离线+排序+暴力移指针 的顺序写。
离线很简单,就是把所有询问按照 \(l,r,id\) 的方式存起来,至于为什么 \(id\),因为后面的排序会把顺序打乱,需要存一下顺序,不然输出会 WA。
那具体是哪种排序方式呢?
当然不是只按左/右中一个,如果题目要求问的跨度很大,那不就又退化成 \(O(n^2)\) 了吗。
那么就要请到分块 出场了。
分块的作用就是控制左右指针的移动总量。具体做法是:
- 把序列下标 \(1\sim n\) 分成一些长度为 \(\sqrt{n}\)(或 \(n^{\frac{2}{3}}\)) 的块
- 对询问排序时,先按左端点所在的块编号升序 ,同一块内再按右端点排序(右端点可以配合奇偶性优化)。
这时的时间复杂度大概就是 \(O(n\sqrt{n})\),比暴力快多了 (难怪叫优雅的暴力)。
排序 时可以加上同块内的右指针奇偶性的优化。就是对于同一个块内的奇数右指针按升序排,偶数右指针按降序排,这样右指针的移动就是"Z"字形的。
这有什么好处
这样就可以使右指针的移动具有单调性,效率更高。
如图:

最后是暴力移动指针。
其实就是维护一个当前区间,通过一步步移动左右指针,让它变成下一个要查询的区间,并在移动过程中更新答案。
由于排过序,指针移动的总距离被大大缩短了。
对于转移到下一个区间,一共有四种情况(左右节点总共)。
- 当前的 \(l>\) 下一个的 \(l\),那么缩小(当前的区间更小)\(l\);
- 当前的 \(r<\) 下一个的 \(r\),那么扩大(同上)\(r\);
- 当前的 \(l>\) 下一个的 \(l\),那么扩大(当前的区间更大)\(l\);
- 当前的 \(r>\) 下一个的 \(r\),那个缩小(同上)\(r\)。
有点难理解,我当时都记混了。
图片帮助理解

具体实现
主要框架就是这样,其余要添加的操作有需要看题了。
普通莫队
PID:P2709
思路就是把询问按左端点分块、块内按右端点排序(普通莫队) ,双指针移动时 \(O(1)\) 增量维护每种颜色计数的平方和 sum(add/del 先减旧平方再加新平方),离线存答案按原编号输出。
\\color{Cyan}Code
cpp
#include<bits/stdc++.h>
#define int long long
using namespace std;
const int N=1e5+10;
int n,m,k,a[N],b,ans[N],sum,c[N];
struct block{
int l,r,op;
bool operator<(const block &k)const{//排序优化
if(l/b!=k.l/b) return l<k.l;
if((l/b)&1) return r<k.r;
return r>k.r;
}
}q[N];
void add(int x){sum-=c[x]*c[x],c[x]++,sum+=c[x]*c[x];}
void del(int x){sum-=c[x]*c[x],c[x]--,sum+=c[x]*c[x];}
signed main(){
ios::sync_with_stdio(0);
cin.tie(0),cout.tie(0);
cin>>n>>m>>k;
b=sqrt(n);
for(int i=1;i<=n;i++) cin>>a[i];
for(int i=1,l,r;i<=m;i++){
cin>>l>>r;
q[i]={l,r,i};
}
sort(q+1,q+m+1);
for(int i=1,l=1,r=0;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].op]=sum;
}
for(int i=1;i<=m;i++) cout<<ans[i]<<"\n";
return 0;
}
带修莫队
PID:P1903
这就是带修改的莫队。
1. 三维莫队:区间 + 时间
普通莫队只有 \(l,r\) 两个指针;这题多了修改,就引入第三个指针 \(t\) 记录已执行的修改次数。每个询问记录它发生时的 \(t\),状态变成 \((l,r,t)\)。按 l 所在块、r 所在块、t 三维排序,块长取 \(n^{\frac23}\),复杂度 \(O(n^{\frac53})\)。
2. 时间指针的前进与回退
移动 \(l,r\) 时照常 add/del 维护颜色计数和种数;移动 \(t\) 时执行/撤销修改:若修改位置 p 在当前区间内,就先 del 旧色再 add 新色,否则不动答案。前进用 ++x,回退用 x--,两段 while 对称写。
3. 用 swap 让修改可撤销
swap(a[p], re[x].c) 应用修改的同时把旧颜色存回 re[x].c,回退时再 swap 一次就还原。区间外的位置也要 swap(只是不更新答案),否则状态会错乱。这是带修莫队最核心的技巧。
\\color{Cyan}Code
cpp
#include<bits/stdc++.h>
#define int long long
using namespace std;
const int N=1e6+10;
int n,m,cntq,cntr,a[N],b,ans[N],sum,c[N];
struct block{
int l,r,t,op;
bool operator<(const block &k)const{
if(l/b!=k.l/b) return l<k.l;
if(r/b!=k.r/b) return r<k.r;
return t<k.t;
}
}q[N];
struct replace{int p,c;}re[N];//存修改的元素,记录要修改的位置和新颜色
void add(int x){sum+=(!c[x]),c[x]++;}
void del(int x){c[x]--,sum-=(!c[x]);}
signed main(){
ios::sync_with_stdio(0);
cin.tie(0),cout.tie(0);
cin>>n>>m;
b=pow(n,0.66);
for(int i=1;i<=n;i++) cin>>a[i];
for(int i=1,l,r;i<=m;i++){
char s;
cin>>s>>l>>r;
if(s=='Q') q[++cntq]={l,r,cntr,cntq};
else re[++cntr]={l,r};
}
sort(q+1,q+cntq+1);
for(int i=1,l=1,r=0,x=0;i<=cntq;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(x<q[i].t){//时间戳变大,修改
int p=re[++x].p;//时间走一步,找到这次的操作,到要改的位置p
//只当在当前要查询的区间里才修改统计,先删再添
if(l<=p&&p<=r) del(a[p]),add(re[x].c);
swap(a[p],re[x].c);//若覆盖了,那就无法做还原操作了
}
while(x>q[i].t){//时间戳变小,还原,其余同上
int p=re[x].p;//找到当前时间x对应的修改操作,到要改的位置p
if(l<=p&&p<=r) del(a[p]),add(re[x].c);
swap(a[p],re[x--].c);//x--是让时间倒流一步。
}
ans[q[i].op]=sum;
}
for(int i=1;i<=cntq;i++) cout<<ans[i]<<"\n";
return 0;
}
回滚莫队
PID:P14420
1. 矛盾:增加容易,删除难
维护 事件类型 × 出现次数 的最大值。加入一个元素时,只需拿 \(c _x\times k_x\) 更新答案,很容易;但删除时无法快速知道最大值是否变化,所以不能用普通莫队。
2. 回滚莫队:只加不减
按左端点分块,块内询问按右端点升序。对每块固定一个基准右边界 rl,右指针只向右扩展(只加);左指针临时向左扩展来覆盖询问,算完答案后把左指针移回 rl 并撤销计数(回滚),从而避免任何删除操作。
3. 块内暴力 + 保存/恢复 res
\(l、r\) 同块的询问直接暴力算(块长 \(\sqrt n\));跨块询问先用 \(lst\) 保存右扩展后的答案,临时扩展左指针得到 \(res\) 输出,回滚时 \(res=lst\) 恢复,保证下一询问从干净状态开始。
\\color{Cyan}Code
cpp
#include<bits/stdc++.h>
#define int long long
using namespace std;
const int N=1e5+10;
int n,m,a[N],c[N],lst,res,ans[N],b,bl[N],k[N],t[N];
struct block{
int l,r,op;
bool operator<(const block &b)const{
if(bl[l]!=bl[b.l]) return l<b.l;
return r<b.r;
}
}q[N];
void add(int x){++c[x],res=max(res,c[x]*k[x]);}
int calc(int l,int r){
int mx=0;
for(int i=l;i<=r;i++) t[a[i]]=0;
for(int i=l;i<=r;i++){
t[a[i]]++;
mx=max(mx,t[a[i]]*k[a[i]]);
}
return mx;
}
signed main(){
ios::sync_with_stdio(0);
cin.tie(0),cout.tie(0);
cin>>n>>m;
b=sqrt(n);
for(int i=1;i<=n;i++){
cin>>a[i],k[i]=a[i];
bl[i]=(i-1)/b+1;
}
int num=bl[n];
sort(k+1,k+n+1);
for(int i=1;i<=n;i++) a[i]=lower_bound(k+1,k+n+1,a[i])-k;
for(int i=1,l,r;i<=m;i++) cin>>l>>r,q[i]={l,r,i};
sort(q+1,q+m+1);
for(int i=1,x=1;i<=num;i++){
res=lst=0;
for(int j=1;j<=n;j++) c[j]=0;
int rl=min(b*i,n),l=rl+1,r=rl;
for(;bl[q[x].l]==i;++x){
if(bl[q[x].l]==bl[q[x].r]){
ans[q[x].op]=calc(q[x].l,q[x].r);
continue;
}
while(r<q[x].r) add(a[++r]);//操作
lst=res;
while(l>q[x].l) add(a[--l]);
ans[q[x].op]=res;
while(l<=rl) --c[a[l++]];//回滚
res=lst;
}
}
for(int i=1;i<=m;i++) cout<<ans[i]<<"\n";
return 0;
}
为什么要回滚 l?
为了"撤掉临时加的东西"。
只有右指针扩展出来的部分是真正属于莫队当前状态的。
左指针向左扩展只是为了算当前这一个询问的答案,它是"临时借用的"。
树上莫队
PID:P4074
实质上就是树+带修莫队,所以只需要写个树剖+带修莫队就行了。
对于树剖后的操作,需要判断这是的查询是否是在同一条直的链上,因为在同一条链上好处理;而不再同一条链上就不好处理了,因为这是一条"折链",还需要计入两个点的 LCA 的。
- 欧拉序(括号序)代替DFS序 :这里求树剖的
in和out是为了构建长度为2n的欧拉序(每个点进树记一次,出树记一次),而不是求普通的子树连续区间。利用flg[x] ^= 1的异或性质:出现两次代表该点不在路径上(进+出抵消),出现一次代表该点在路径上。 - 直链与折链的处理 :
- 直链 (
lca(x,y)==x):对应的莫队区间就是[in[x], in[y]]。 - 折链 (
lca(x,y)!=x):对应的莫队区间是[out[x], in[y]]。注意这里,此时 LCA 并没有被包含在区间内 ,所以要把 LCA 记录下来(代码里的q[i].f = lca),在莫队统计完该区间的答案后,单独把 LCA 的贡献加进去。
- 直链 (
- 时间指针的移动(带修部分) :遇到修改操作时,需判断被修改的点是否在当前莫队区间内(
flg[pos] == 1)。如果在,就先删除旧颜色的贡献,用swap换上新颜色,再加上新颜色的贡献;如果不在,直接swap换色即可,不影响当前答案。 - 贡献计算与排序 :每次加入/删除点,更新该颜色的出现次数
cnt,贡献由w[cnt[c]] * v[c]动态维护。排序用三维关键字(l所在块, r所在块, t),块长取(2n)^{2/3}。
一句话:用树剖欧拉序把树上路径映射成序列区间(折链单独补 LCA 贡献),然后套用带修莫队,时间指针移动时判断点是否在区间内来变更颜色。
\\color{Cyan}Code(有点长,慎入)
cpp
#include<bits/stdc++.h>
#define int long long
using namespace std;
const int N=2e5+10;
int n,m,k,b,t,tot,sum,v[N],w[N],c[N],pos[N],nc[N],cnt[N],ans[N];
struct block{
int l,r,t,op,f;
bool operator<(const block &k)const{
if(l/b!=k.l/b) return l<k.l;
if(r/b!=k.r/b) return r<k.r;
return t<k.t;
}
}q[N];
int fa[N],dep[N],top[N],siz[N],son[N],sfn,in[N],out[N],a[N<<1];
bool flg[N];
vector<int> g[N];
void dfs1(int u,int f){
fa[u]=f;
siz[u]=1;
dep[u]=dep[f]+1;
for(auto v:g[u]){
if(v==f) continue;
dfs1(v,u);
siz[u]+=siz[v];
if(siz[son[u]]<siz[v]) son[u]=v;
}
}
void dfs2(int u,int t){
top[u]=t;
in[u]=++sfn;
a[sfn]=u;
if(son[u]) dfs2(son[u],t);
for(auto v:g[u]) if(v!=fa[u]&&v!=son[u]) dfs2(v,v);
out[u]=++sfn;
a[sfn]=u;
}
int lca(int u,int v){
while(top[u]!=top[v]){
if(dep[top[u]]<dep[top[v]]) swap(u,v);
u=fa[top[u]];
}
return dep[u]<dep[v]?u:v;
}
void add(int x){
flg[x]^=1;//访问x次数
if(flg[x]) sum+=w[++cnt[c[x]]]*v[c[x]];//一次,加
else sum-=w[cnt[c[x]]--]*v[c[x]];//两次,减
}
signed main(){
ios::sync_with_stdio(0);
cin.tie(0),cout.tie(0);
cin>>n>>m>>k;
for(int i=1;i<=m;i++) cin>>v[i];
for(int i=1;i<=n;i++) cin>>w[i];
for(int i=1,u,v;i<n;i++){
cin>>u>>v;
g[u].push_back(v);
g[v].push_back(u);
}
for(int i=1;i<=n;i++) cin>>c[i];
dfs1(1,0);
dfs2(1,1);
for(int i=1,t=0,op,x,y;i<=k;i++){
cin>>op>>x>>y;
if(op==1){
int f=lca(x,y);
q[++tot].t=t,q[tot].op=tot;
if(in[x]>in[y]) swap(x,y);
if(f==x) q[tot].l=in[x],q[tot].r=in[y];//直链
else q[tot].l=out[x],q[tot].r=in[y],q[tot].f=f;//折链
}
else pos[++t]=x,nc[t]=y;
}
b=pow(n<<1,0.66);
sort(q+1,q+tot+1);
for(int i=1,l=1,r=0,x=0;i<=tot;i++){
while(l>q[i].l) add(a[--l]);
while(r<q[i].r) add(a[++r]);
while(l<q[i].l) add(a[l++]);
while(r>q[i].r) add(a[r--]);
while(x<q[i].t){
x++;
if(flg[pos[x]]){
add(pos[x]);
swap(c[pos[x]],nc[x]);
add(pos[x]);
}
else swap(c[pos[x]],nc[x]);
}
while(x>q[i].t){
if(flg[pos[x]]){
add(pos[x]);
swap(c[pos[x]],nc[x]);
add(pos[x]);
}
else swap(c[pos[x]],nc[x]);
x--;
}
ans[q[i].op]=sum;
if(q[i].f) ans[q[i].op]+=1*w[cnt[c[q[i].f]]+1]*v[c[q[i].f]];
}
for(int i=1;i<=tot;i++) cout<<ans[i]<<"\n";
return 0;
}
小TIPS
说了这么多,还是得说说细节,不然很容易写炸。
保留一~六,已将所有数学公式统一改为 $ 包裹,分数改为 \frac{}{} 格式:
一、 判断能不能用莫队
- 必须能离线:莫队本质是调整询问顺序,强制在线(如异或加密查询)不能用。
- 必须能 \(O(1)\) 或 \(O(\log n)\) 增量更新 :区间向外扩或向内缩一步时,答案必须能快速更新。如果"加"容易"删"难,想回滚莫队 (如 P14420);如果"加"难"删"容易,想只删莫队。
二、 指针移动的"玄学"与细节
-
初始区间设为空 :通常写
l = 1, r = 0(或者l = rl+1, r = rl),保证一开始区间为空,避免多算。 -
四个
while的顺序无所谓,但别写错边界 :cppwhile (l > ql) add(a[--l]); // 左指针外扩,先移位再加 while (r < qr) add(a[++r]); // 右指针外扩,先移位再加 while (l < ql) del(a[l++]); // 左指针内缩,先删再移位 while (r > qr) del(a[r--]); // 右指针内缩,先删再移位
三、 排序与块长的玄学
-
块长不要死记 \(\sqrt{n}\) :
- 普通莫队:大多都是 \(\sqrt{n}\)。
- 带修莫队 :\(n^{\frac{2}{3}}\)(
pow(n, 0.66)),因为多了一维时间。 - 树上莫队 :序列长度是 \(2n\),所以块长是 \(\sqrt{2n}\) 或 \((2n)^{\frac23}\)。
-
奇偶化排序(常数优化) :对于普通莫队,右端点可以写成"奇数块升序,偶数块降序":
cppif((l/b)&1) return r<k.r; return r>k.r;能让右指针少跑很多冤枉路。
-
带修莫队的排序一定要多判一层 :
cppif(l/b!=k.l/b) return l<k.l; if(r/b!=k.r/b) return r<k.r; return t < k.t;
四、 修改与撤销(带修莫队专属)
- 修改用
swap实现可逆 :swap(a[pos], mod[x].c)是带修莫队的灵魂。走时间前进时swap存下旧值,走时间回退时再swap还原。 - 判断位置是否在区间内 :时间指针移动时,一定要判断
l <= pos && pos <= r(或树上flg[pos] == 1)。不在区间里的修改不影响答案,但依然要swap,不然回退时状态全乱。
五、 树上的特殊处理
- 欧拉序中一个点出现两次 :利用
flg ^= 1,进和出相遇抵消,剩下的就是路径上的点。 - 折链必补 LCA :对于
[out[x], in[y]]这种区间,LCA 绝对不在区间内。最后算完莫队答案,记得把 LCA 的贡献单独加一次 (加的时候要考虑cnt[颜色]+1的权值)。
六、 回滚莫队专属坑点
- 左指针的"家"是固定的 :每一块处理时,左指针必须从块右边界
R+1出发,临时往左扩,算完必须滚回R+1,并用res = last强行恢复答案。 - 同块询问直接暴力 :如果询问的左右端点在同一块里(
bl[l] == bl[r]),区间长度不到 \(\sqrt{n}\),直接 \(O(\sqrt{n})\) 暴力扫,写起来比强行回滚简单得多。