> 文章列表 > 详谈莫队算法

详谈莫队算法

详谈莫队算法

一定更好的阅读体验:

Here


0、来历

莫队算法是由莫涛提出的算法。

在莫涛提出莫队算法之前,莫队算法已经在 Codeforces 的高手圈里小范围流传,但是莫涛是第一个对莫队算法进行详细归纳总结的人。

莫涛提出莫队算法时,只分析了普通莫队算法,但是经过 OIer 和 ACMer 的集体智慧改造,莫队有了多种扩展版本。

莫队算法可以解决一类离线区间询问问题,适用性极为广泛。同时将其加以扩展,便能轻松处理树上路径询问以及支持修改操作。

—— OI-Wiki

本文分别在1、2、3章介绍了普通莫队、带修莫队和回滚莫队算法并详细地分析了最优块长和相对应的时间复杂度

同时各给出了一道例题的实现。

本文参考了诸多资料,统一置于文末,读者可自行查阅。


1、普通莫队算法

a:算法引入

对于 qqq 次询问序列的某一区间特征这一类题,我们很容易有一个不成熟的想法:

刚开始我们什么也没有存,即存了 i∈[L=1,R=0]i\\in[L=1,R=0]i[L=1,R=0] 的信息。

对于第一个询问 [l1,r1][l_1,r_1][l1,r1] ,我们直接 L++,R++L++,R++L++,R++ 地移到这里,对于每一个加入的元素,我们把它计入贡献。

然后用类似的方法,从询问 [li−1,ri−1][l_{i-1},r_{i-1}][li1,ri1] 移动到 [li,ri][l_i,r_i][li,ri] ,对于每一个删除的元素,我们把它去除贡献。

也就是说,我们存储区间 [L,R][L,R][L,R] 的信息,不断移动区间的同时更新答案。直到 L=li,R=riL=l_i,R=r_iL=li,R=ri ,我们把答案输出。

比如这一道题:LuoguP3901。

现有数列 A1,A2,…,ANA_1,A_2,\\ldots,A_NA1,A2,,ANQQQ 个询问 (Li,Ri)(L_i,R_i)(Li,Ri) ,询问 ALi,ALi+1,…,ARiA_{L_i} ,A_{L_i+1},\\ldots,A_{R_i}ALi,ALi+1,,ARi 是否互不相同。

互不相同?就是最多的出现次数不大于1嘛。

我们用一个桶bz来存每个数的出现次数,用 sss 记下有多少个数的出现次数大于1。

我们可以写出如下代码:

//加入一个数x
void add(int x) {bz[x]++;if(bz[x]==2) s++;
}
//删除一个数x
void inc(int x) {bz[x]--;if(bz[x]==1) s--;
}
//核心代码
int l=1,r=0;
for(int i=1;i<=Q;i++) {while(l>q[i].l) add(a[--l]);while(r<q[i].r) add(a[++r]);while(l<q[i].l) inc(a[l++]);while(r>q[i].r) inc(a[r--]);if(s==0) printf("Yes\\n");else printf("No\\n");
}

但是!这样一来要移动 QQQ 次,每次最多移动 nnn 个位置,时间复杂度为 O(Qn)O(Qn)O(Qn)

跟暴力一样……

需要优化!

b:算法形成

我们发现有许多重复的移动:比如一个最坏的数据就是 [1,1],[n,n],[1,1],[n,n]......[1,1],[n,n],[1,1],[n,n]......[1,1],[n,n],[1,1],[n,n]......

按照上述算法的话,我们就会从 1→n1\\to n1n ,再从 n→1n\\to1n1 ,再从 1→n......1\\to n......1n...... ,一共 QnQnQn 次。

但如果我们把询问排序,也许就能少很多的移动次数!上面的排完序就是 [1,1],[1,1]......[n,n],[n,n][1,1],[1,1]......[n,n],[n,n][1,1],[1,1]......[n,n],[n,n]

这样我们一共只用移动 2n2n2n 次。

一般来讲,我们把 nnn 个下标分块。以询问左端点所在块编号为第一关键字,以询问右端点为第二关键字排序。

块长最优是 n\\sqrt nn

具体证明在d节给出。

分析一下时间复杂度。

我们有 n\\sqrt nn 个块,时间复杂度是 O(n×处理一个块内的询问的时间)O(\\sqrt n\\times处理一个块内的询问的时间)O(n×处理一个块内的询问的时间)

(如果 l,rl,rl,r 在一个块内就暴力,反正也是 n\\sqrt nn 。)

这里说的「一个块内的询问」是「左端点位于该块的询问」。

对于同一个块里的询问,左边界移动次数显然不超过 n\\sqrt nn 。同时右边界的移动次数不超过 nnn

这样的时间复杂度是 O(n+n)O(n+\\sqrt n)O(n+n)

总的时间复杂度就是 O(n(n+n))=O(nn+n)O(\\sqrt n(n+\\sqrt n))=O(n\\sqrt n+n)O(n(n+n))=O(nn+n)

右边的 nnn 可以省略。至此,我们已经可以通过 10510^5105 的数据了!

至于更快的时间?不是很可能。

c:归纳与实现

上述的例题从 [L,R][L,R][L,R] 移动一个位置是 O(1)O(1)O(1) 的。当它是 O(k)O(k)O(k) 会是什么结果呢?

处理一个块内的询问的时间:(注意,「对于每一个块分析」这样的思路只是方便我们分析时间复杂度,并不是真的这么实现。)

左边界移动次数不超过 n\\sqrt nn ,时间是 O(kn)O(k\\sqrt n)O(kn)

右边界移动次数不超过 nnn ,时间是 O(nk)O(nk)O(nk)

总的时间复杂度就是 O(n(kn+nk))=O(k(nn+n))O(\\sqrt n(k\\sqrt n+nk))=O(k(n\\sqrt n+n))O(n(kn+nk))=O(k(nn+n))

当它不是 O(1)O(1)O(1) 的,总的时间乘上了一个 kkk ,非常之劣。当 k≥nk\\geq \\sqrt nkn 时,劣于直接暴力。

k<nk\\lt\\sqrt nk<n ,也没什么实际效果啊。

所以,普通莫队算法一般适用于:

  • 允许离线。
  • 可以 O(1)O(1)O(1) 移动一次区间。

这一类问题。

上述例题的实现:

#include<cstdio>
#include<algorithm>
#include<cmath>
using namespace std;
const int N=1e5+5;
int n,m,s,a[N],id[N],bz[N],ans[N];
struct pr { int l,r,i; }q[N];
void add(int x) {bz[a[x]]++;if(bz[a[x]]==2) s++;
}
void inc(int x) {bz[a[x]]--;if(bz[a[x]]==1) s--;
}
bool cmp(pr a,pr b) {if(id[a.l]==id[b.l]) return id[a.r]==id[b.r]?(a.r<b.r):(id[a.r]<id[b.r]);return id[a.l]<id[b.l];
} 
int main() {scanf("%d%d",&n,&m);int g=sqrt(n);for(int i=1;i<=n;i++)scanf("%d",&a[i]),id[i]=(i-1)/g+1;for(int i=1;i<=m;i++) scanf("%d%d",&q[i].l,&q[i].r),q[i].i=i;sort(q+1,q+m+1,cmp);int l=1,r=0;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) inc(l++);while(r>q[i].r) inc(r--);ans[q[i].i]=s;}for(int i=1;i<=m;i++) printf(ans[i]?"No\\n":"Yes\\n");
}

还有一些题目大家可以自行上网络寻找。

d:关于块长的讨论

细心的读者可能会发现,我们常说的莫队的时间复杂度 nnn\\sqrt nnn 跟询问的个数 QQQ 是没有关系的。

其实有关系。(废话)

这里给出最优块长的证明:

设块长为 sss ,则有 ns\\frac{n}{s}sn 个块。记数列长度为 nnn ,有 mmm 个询问,第 iii 个块的询问数量为 qiq_iqi

那么根据b节的分析,最劣时间复杂度应该是:
∑i=1nsqi⋅s+n=ms+n⋅ns=ms+n2s\\begin{aligned} &\\sum_{i=1}^\\frac{n}{s}q_i\\cdot s+n\\\\ =&ms+n\\cdot\\frac{n}{s}\\\\\\\\ =&ms+\\frac{n^2}{s} \\end{aligned} ==i=1snqis+nms+nsnms+sn2
问题就变成了求上述式子的最小值。

根据基本不等式,设 a=ms,b=n2sa=ms,b=\\frac{n^2}{s}a=ms,b=sn2 ,那么有:
ab≤a+b22nsm≤ms2+n20≤ms2−2nsm+n2\\begin{aligned} \\sqrt{ab}&\\leq\\frac{a+b}{2}\\\\ 2ns\\sqrt m&\\leq ms^2+n^2\\\\ 0&\\leq ms^2-2ns\\sqrt m+n^2 \\end{aligned} ab2nsm02a+bms2+n2ms22nsm+n2
当这个式子取等号, sss 有最小值。
ms2−2nsm+n2=0∵Δ=4n2s2m−4n2s2m=0∴s=2nm2m=nmms^2-2ns\\sqrt m+n^2=0\\\\ \\begin{aligned} \\because&\\Delta=4n^2s^2m-4n^2s^2m=0\\\\ \\therefore&s=\\frac{2n\\sqrt m}{2m}=\\frac{n}{\\sqrt m} \\end{aligned} ms22nsm+n2=0Δ=4n2s2m4n2s2m=0s=2m2nm=mn
所以当块长取 nm\\frac{n}{\\sqrt m}mn 时,算法效率最高,是 m⋅nm+n2nm=2nmm\\cdot\\frac{n}{\\sqrt m}+\\frac{n^2}{\\frac{n}{\\sqrt m}}=2n\\sqrt mmmn+mnn2=2nm

所以莫队算法严格的时间复杂度是 O(nm)O(n\\sqrt m)O(nm)

至于我们常说的 O(nn)O(n\\sqrt n)O(nn) ,是把 n,mn,mn,m 当做等数量级的时间。

我发誓这是信息学而不是数学课……

至于另一种方式的时间复杂度分析和一些优化,可以参见 OI-Wiki 中对于莫队的描述。


2、带修改莫队

a:算法形成

我们已经说过,莫队是一类离线算法。

要是有修改呢?我们可以把一个询问加上一个时间维,变成 [li,ri,ti][l_i,r_i,t_i][li,ri,ti] 表示询问已经完成了前 iii 个修改的 [l,r][l,r][l,r] 的答案。

我们同样地在时间上移动!

也就是我们可以从 [L,R,T][L,R,T][L,R,T] 移动到:

  • [L+1,R,T][L+1,R,T][L+1,R,T]
  • [L,R+1,T][L,R+1,T][L,R+1,T]
  • [L−1,R,T][L-1,R,T][L1,R,T]
  • [L,R−1,T][L,R-1,T][L,R1,T]
  • [L,R,T+1][L,R,T+1][L,R,T+1]
  • [L,R,T−1][L,R,T-1][L,R,T1]

排序的时候以左端点所属块第一关键字,右端点所属块为第二关键字,时间大小为第三关键字。

于是有一个类似的算法流程:

L,RL,RL,R 移动到查询区间,再把 TTT 移动到查询时间。

此时,我们左右端点分别在一个块内左右横跳,时间单调递增,直到左右端点到下一个块。

b:时间复杂度以及最优块长

接下来的证明看似冗长,其实不算很复杂。

我们设序列长为 nnnmmm 个询问,ttt 个修改。

这里排序的第二关键字是右端点所在块编号,不同于普通莫队。

想一想,如果不把右端点分块:

  • 乱序的右端点对于每个询问会移动 nnn 次。
  • 有序的右端点会带来乱序的时间,每次询问会移动 ttt 次。

无论哪一种情况,带来的时间开销都无法接受。

接下来分析最优块长:

设左端点在第 iii 个块的询问数量是 qiq_iqi,块长为 sss,则有 ns\\frac{n}{s}sn 个块。

每“组”左右端点不换块的询问 (i,j)(i,j)(i,j),端点共会移动 O(s)O(s)O(s) 次,时间单调递增,O(t)O(t)O(t)

左右端点换块的时间忽略不计。

表示一下就是:

∑i=1ns∑j=i+1ns(qi,j⋅s+t)=ms+(ns)2t=ms+n2ts2\\begin{aligned} &\\sum_{i=1}^{\\frac{n}{s}}\\sum_{j=i+1}^{\\frac{n}{s}}(q_{i,j}\\cdot s+t)\\\\ =&ms+(\\frac{n}{s})^2t\\\\ =&ms+\\frac{n^2t}{s^2} \\end{aligned} ==i=1snj=i+1sn(qi,js+t)ms+(sn)2tms+s2n2t

x=ms,y=n2ts2x=ms,y=\\frac{n^2t}{s^2}x=ms,y=s2n2t,根据基本不等式 xy≤x+y2\\sqrt{xy}\\leq\\frac{x+y}{2}xy2x+y,可以得到:

2n2mts≤ms+n2ts22\\sqrt{\\frac{n^2mt}{s}}\\leq ms+\\frac{n^2t}{s^2} 2sn2mtms+s2n2t

两侧同乘 s2s^2s2 得: 2ssn2mt≤ms3+n2t2s\\sqrt{sn^2mt}\\leq ms^3+n^2t2ssn2mtms3+n2t

为了让式子美观一点,设 a=ssa=s\\sqrt sa=ss 并移项:
0≤ma2−(2n2mt)a+n2t0\\leq ma^2-(2\\sqrt{n^2mt})a+n^2t 0ma2(2n2mt)a+n2t
当这个式子取等号, aaa 有最小值,也就是 sss 有最小值。
∵Δ=4n2mt−4n2mt=0∴a=2n2mt2m=nmtm∵a=ss∴ss=nmtms=n23t13m13\\begin{aligned} \\because\\Delta=&4n^2mt-4n^2mt=0\\\\ \\therefore a=&\\frac{2\\sqrt{n^2mt}}{2m}=\\frac{n\\sqrt{mt}}{m}\\\\ \\because a=&s\\sqrt s\\\\ \\therefore s\\sqrt s=&\\frac{n\\sqrt{mt}}{m}\\\\ s=&\\frac{n^\\frac23t^\\frac13}{m^\\frac13} \\end{aligned} Δ=a=a=ss=s=4n2mt4n2mt=02m2n2mt=mnmtssmnmtm31n32t31

所以当块长取 n23t13m13\\frac{n^\\frac23t^\\frac13}{m^\\frac13}m31n32t31 时有最优时间复杂度,是 O(n23m23t13)O(n^\\frac23m^\\frac23t^\\frac13)O(n32m32t31)

常说的 O(n35)O(n^\\frac35)O(n53) 便是把 n,m,tn,m,tn,m,t 当做同数量级的时间复杂度。

c:例题

数颜色 / 维护队列

给定长度为 nnn 的序列 aaa ,两种操作:

  • Q,L,RQ,L,RQ,L,R 询问 aL→Ra_{L\\to R}aLR 有多少种数字。
  • R,P,ColR,P,ColR,P,ColaPa_PaP 改成 ColColCol

n,m≤133333,1≤ai≤106n,m\\leq 133333,1\\leq a_i\\leq10^6n,m133333,1ai106

我们把每个询问增加一维时间,表示此询问是建立在多少次修改之后的。

记下每个修改之前的数应该是什么,用来还原一次修改。

然后就可以快乐地实现查询了!

不过需要注意的是,我们存的是 [L,R][L,R][L,R] 的答案,所以不在 [L,R][L,R][L,R] 内的修改不要计入贡献。

这就是为什么要先移区间再移时间会方便一些。

实现如下:

#include<cstdio>
#include<cmath>
#include<algorithm>
using namespace std;
const int N=133335;
const int T=1e6+5;
int n,m,cn,qn,s,a[N],b[N],ans[N],bz[T];
int qs,id[N];
struct pr {int l,r,t,i;
}q[N],c[N];
bool cmp(pr a,pr b) {if(id[a.l]==id[b.l]) {if(id[a.r]==id[b.r]) return a.t<b.t;return a.r<b.r;}return a.l<b.l;
}
void add(int x) {bz[x]++;if(bz[x]==1) s++;
}
void inc(int x) {bz[x]--;if(bz[x]==0) s--;
}
int main() {scanf("%d%d",&n,&m);qs=pow(n,2.0/3);for(int i=1;i<=n;i++) {scanf("%d",&a[i]);b[i]=a[i];id[i]=(i-1)/qs+1;}while(m--) {char op[5];int l,r;scanf("%s%d%d",&op,&l,&r);if(op[0]=='Q') {q[++qn].l=l;q[qn].r=r;q[qn].t=cn;q[qn].i=qn;} else {c[++cn].t=b[l];b[l]=r;c[cn].l=l;c[cn].r=r;//把a[c[cn].l]改成c[cn].r,a[c[cn].l]在这次修改前是c[cn].t}}sort(q+1,q+qn+1,cmp);int l=1,r=0,t=0;for(int i=1;i<=qn;i++) {while(l>q[i].l) add(a[--l]);while(r<q[i].r) add(a[++r]);while(l<q[i].l) inc(a[l++]);while(r>q[i].r) inc(a[r--]);while(t<q[i].t) {t++;if(l<=c[t].l&&c[t].l<=r) {inc(a[c[t].l]);add(c[t].r);}a[c[t].l]=c[t].r;}while(t>q[i].t) {if(l<=c[t].l&&c[t].l<=r) {inc(a[c[t].l]);add(c[t].t);}a[c[t].l]=c[t].t;t--;}ans[q[i].i]=s;}for(int i=1;i<=qn;i++) printf("%d\\n",ans[i]);
}

3、回滚莫队

a:算法形成

在普通莫队算法中,我们通过加入/删除一个数的贡献以移动区间。但是这个方法并不通用。

例如:AT_joisc2014_c。

一个序列长度为 nnn 的序列 aaaqqq 次询问一段区间 [l,r][l,r][l,r]TiT_iTi 表示数 iii[l,r][l,r][l,r] 内的出现次数。

max⁡(ai×Tai)\\max(a_i\\times T_{a_i})max(ai×Tai)

1≤n,q≤105,1≤ai≤1091\\leq n,q\\leq 10^5,1\\leq a_i\\leq10^91n,q105,1ai109

首先要离散化。

莫队区间是 [L,R][L,R][L,R]L−−L--LR++R++R++ 很简单。我们只需要记录 TTT 数组即可。

另外两种移动的话,如果我们删除的数是最大值的贡献者,我们无法更新答案!

所以:不 要 删 除!不 要 删 除!不 要 删 除!

b:不要删除

首先,我们对原序列进行分块。以询问左端点所属块为第一关键字,询问右端点大小为第二关键字排序。

记块 BBB 的左右端点为 Bl,BrB_l,B_rBl,Br

按顺序处理询问 [l,r][l,r][l,r]

  • 如果 lll 所属的块 BBB 和上一个询问的 lll 所属块不同,那么将 LLL 初始化为 Br+1B_r+1Br+1 ,将 RRR 初始化为 BrB_rBr ,并清空一切 ans1ans1ans1 这样的数据。

  • 如果 l,rl,rl,r 所属块相同,那么直接扫描区间回答询问。

  • 如果 l,rl,rl,r 所属的块不同:

    • 我们设 ans1ans1ans1[Br+1,R][B_r+1,R][Br+1,R] 的答案, ans2ans2ans2[L,R][L,R][L,R] 的答案。

    • 如果 R<rR\\lt rR<r ,那么不断 R++R++R++ 直至 R=rR=rR=r ,同时更新 ans1ans1ans1

    • ans2ans2ans2 赋为 ans1ans1ans1 ,不断 L−−L--L 直至 L=lL=lL=l ,同时更新 ans2ans2ans2

    • ans2ans2ans2 回答询问。

    • L++L++L++ ,直到 Br+1B_r+1Br+1

也就是我们每次重新做一遍左边,右边单调。

很好理解。

c:最优块长&时间复杂度

这些分析稍加思考就会发现式子跟普通莫队一模一样。

就不再赘述了。

上述例题的代码如下:

#include<cstdio>
#include<algorithm>
#include<cstring> 
#include<cmath>
#define ll long long
using namespace std;
const int N=1e5+5;
//v[i]是第i个数的实际值,a[i]是离散化以后的值。ls和bz都是桶。 
int n,m,v[N],a[N],bz[N],ls[N],id[N],L[N],R[N];
ll ans[N];
//b是用来离散化的。 
struct pr {int l,r,i;
}q[N],b[N];
bool cmp(pr a,pr b) {//对询问的排序 if(id[a.l]==id[b.l]) return a.r<b.r;return a.l<b.l;
}
bool cmp1(pr a,pr b) {//离散化的排序 return a.l<b.l;
}
//离散化 
void lsh() {int sh=0;sort(b+1,b+n+1,cmp1);for(int i=1;i<=n;i++) {if(b[i].l!=b[i-1].l) sh++;a[b[i].r]=sh;}
}
ll js(int l,int r) {//计算[l,r]的答案 ll s=0;for(int i=l;i<=r;i++) ls[a[i]]=0;for(int i=l;i<=r;i++) {ls[a[i]]++;s=max(s,(ll)ls[a[i]]*v[i]);}return s;
}
int main() {scanf("%d%d",&n,&m);int size=sqrt(n);for(int i=1;i<=n;i++) {scanf("%d",&v[i]);b[i].l=v[i];b[i].r=i;id[i]=(i-1)/size+1;//处理每个块的端点 if(L[id[i]]==0) L[id[i]]=i;R[id[i]]=i;}for(int i=1;i<=m;i++) {scanf("%d%d",&q[i].l,&q[i].r);q[i].i=i;}lsh();sort(q+1,q+m+1,cmp);int l,r;ll ans1,ans2;for(int i=1;i<=m;i++) {int Br=R[id[q[i].l]];if(i==1||id[q[i].l]!=id[q[i-1].l]) {//新处理  l=Br+1,r=Br,ans1=0;memset(bz,0,sizeof bz); }if(id[q[i].l]==id[q[i].r]) {//同一块 ans[q[i].i]=js(q[i].l,q[i].r);continue;}while(r<q[i].r) {bz[a[++r]]++;ans1=max(ans1,(ll)bz[a[r]]*v[r]);}ans2=ans1;while(l>q[i].l) {bz[a[--l]]++;ans2=max(ans2,(ll)bz[a[l]]*v[l]);}ans[q[i].i]=ans2;while(l<Br+1) bz[a[l++]]--;}for(int i=1;i<=m;i++) printf("%lld\\n",ans[i]);
}

d:一句杂话

回滚莫队的关键就在于“撤销 LLL ”。

对于这个撤销操作有易有难。上述的例题是比较简单的一种。

洛谷的模板题P5906是一道稍难处理的题目。

莫队相关的题大多套路,但是具体细节还是要随机应变。


最后

参考资料:

浅谈莫队算法分块大小 - Pycr - 博客园

莫队算法 - OI Wiki