莫队入门
前言
本人DS就是个傻逼,博客几乎没有任何技术含量,各位想提升自己熟练度的dalao请移步以上任意一位的博客。本文试着讲清楚各种莫队的基本思路,有对各种莫队复杂度的口胡证明,同时会不定期放一些例题。
莫队,是一种优雅的暴力,有着优秀的根号复杂度和不算大的常数,很多正解非莫队的静态区间查询和简单的动态区间查询题目都可以用莫队这种好写好调的算法爆切甚至踩标算。
在前国家队队长莫涛提出该算法后,各路神仙纷纷前来探索,将莫队拓展到了多种形式,只要题目允许离线,莫队能以较低的思维量解决各种各样的问题。
普通莫队
适用范围:可以较快地单点插入、单点删除
考虑这样一种算法:用两个指针维护一个区间 \([l,r]\),对于每一个询问区间 \([l_i,r_i]\),通过一次一次地单点插入、删除让 \(l\) 指针暴力移动到 \(l_i\) 处、\(r\) 指针暴力移动到 \(r_i\) 处。比如这样一个操作序列:
1 6
3 5
2 4
一开始 \((l,r)=(1,0)\)。
对于第一个询问,我们往序列里插入 \(1,2,3,4,5,6\),将 \(r\) 挪到 \(6\) 处,\((l,r)=(1,6)\);
对于第二个询问,我们删除 \(1,2\),再删除 \(6\),将 \(l\) 挪到 \(3\) 处,\(r\) 挪到 \(5\) 处,\((l,r)=(3,5)\);
对于第三个询问,我们插入 \(2\),删除 \(5\),将 \(l\) 挪到 \(2\) 处,\(r\) 挪到 \(4\) 处,\((l,r)=(2,4)\)。
我们在插入和删除一个位置的时候更新信息,在 \(l\) 和 \(r\) 到达询问位置的时候回答询问,不难打出这段代码:
int l=1,r=0;
for(int i=1;i<=m;++i){
while(l>q[i].l) ins(--l);
while(r<q[i].r) ins(++r);
while(l<q[i].l) del(l++);
while(r>q[i].r) del(r--);
printf("%d\n",ans);
}
肯定有人问,这和暴力有区别吗?没有区别,比如这么一组数据:
1 114514
2 2
3 114514
4 4
...
114514 114514
\(r\) 指针会进行非常多不必要的左右横跳,复杂度为 \(\Theta(nm)\)。
考虑把询问离线下来,按某种规则排个序后在处理,减少不必要的指针移动,这就是莫队的基本思路。
我们把序列分成 \(\Theta(\sqrt{n})\) 个块,每块的长度为 \(\Theta(\sqrt{n})\),对所有询问按照下述规则排序:
-
优先按照 \(l_i\) 所在的块从小到大排;
-
\(l_i\) 所在块相同的,按照 \(r_i\) 从小到大排。
然后再按照上述方法对排序后询问一个一个地处理。
栗子:对于 \(n=20\),分成 \(4\) 块,每块 \(5\) 个元素,对于询问:
7 8
11 19
13 15
4 10
2 9
排序后变成:
2 9
4 10
7 8
13 15
11 19
如此这般:
struct qry{
int l,r,id;
bool operator<(const qry &x)const{
return k[l]==k[x.l]?r<x.r:l<x.l;
}
}q[N+1];
sort(q+1,q+m+1);
这样处理大大减少了不必要的反复横跳:形象化地,\(l\) 指针左右横跳的幅度很小,\(r\) 指针则大多时候是在单调地从左向右移。我们来对复杂度进行口胡。
称排序后每个 \(l_i\) 在一个块里的极长子序列为一个大块,则这样的大块共有 \(O(\sqrt{n})\) 个。
我们把总复杂度拆成四个部分:
-
\(1.l\) 在处理单个大块时移动。
大块内部 \(l_i\) 在一个块里,所以单次转移 \(l\) 花费 \(O(\sqrt{n})\),总共最多花费 \(O(m\sqrt{n})\)。
-
\(2.l\) 在大块与大块间转移时移动。
我们按 \(l_i\) 所在块从小到大排序,所以这时 \(l\) 总是单调往上走,从一个块走到另一个块,每个块中每个元素最多被跑 \(2\) 次(从前面的块移进来一次,移动到后面的块一次),所以总复杂度 \(O(n)\)。
-
\(3.r\) 在处理单个大块时移动。
大块内部按 \(r_i\) 从小到大排序,所以 \(r\) 只会从左到右移动,处理单个大块最多花费 \(O(n)\),\(O(\sqrt{n})\) 个大块,总复杂度 \(O(n\sqrt{n})\)。
-
\(4.r\) 在大块与大块间转移时移动。
这样的转移最多发生 \(O(\sqrt{n})\) 次,单次移动不会超过 \(O(n)\),总复杂度 \(O(n\sqrt{n})\)。
综上,在可以 \(O(1)\) 插入删除的情况下,我们的最终复杂度为
在 \(n,m\) 同阶时复杂度 \(O(n\sqrt{n})\),插入删除做不到 \(O(1)\) 时乘上它们的复杂度即可。
奇偶排序优化
我们的算法仍有一个 bug
:大块内部 \(r\) 从左到右移动,转移到下一个大块时要先暴力向左跳回来,再在下一个大块在处理询问时跳回去,造成不必要的时间浪费。可否优化?
答案是肯定的:我们把大块按照奇偶分开,奇数大块按 \(r_i\) 从小到大排序,偶数大块按 \(r_i\) 从大到小排序,就能实现 \(r\) 指针从小到大跑完奇数大块后,无需跑回来而可以直接从大到小处理完接下来的偶数大块。
理论上常数优化一半,实测常数为原来的 \(\dfrac{3}{4}\sim\dfrac{4}{5}\) 左右。本人口胡出来的代码如下:
struct qry{
int l,r,id;
bool operator<(const qry &x)const{
return k[l]<k[x.l];
}
}q[N+1];
bool cmp1(const qry &x,const qry &y){return x.r<y.r;}
bool cmp2(const qry &x,const qry &y){return x.r>y.r;}
sort(q+1,q+m+1);int nowk=0,pos=0;bool st=0;
for(int i=1;i<=m;++i) if(k[q[i].l]!=nowk){
if(st) sort(q+pos,q+i,cmp1);
else sort(q+pos,q+i,cmp2);
nowk=k[q[i].l];pos=i;st^=1;
}
if(st) sort(q+pos,q+m+1,cmp1);
else sort(q+pos,q+m+1,cmp2);
思路是先按 \(l_i\) 所在块排序,排好后再枚举每个大块,对其内部视奇偶对 \(r_i\) 排序。
更加通用的写法:
struct qry{
int l,r,id;
bool operator<(const qry &x)const{
return k[l]==k[x.l]?((k[l]&1)?r<x.r:r>x.r):l<x.l;
}
}q[N+1];
直接修改 operator<
,判断的是 \(l_i\) 所在块的奇偶性而不是大块的奇偶性,有点不符合我们的定义,但是随机数据即 \(l_i\) 均匀分布情况下跑出的时间和我口胡的代码相差无几。
回滚(不删除)莫队
适用范围:可以较快地单点插入
有些时候我们可以很方便地插入数据,却很难较快地删除(比如 RMQ
),回滚莫队应运而生。
考虑将询问按普通莫队的方法排序,在普通莫队的基础上把删除操作去掉。发现删除操作即 \(l\) 指针向右移和 \(r\) 指针向左移,我们分别试着取消掉这两个操作。
右移 \(l\):我们让 \(l\) 只能左移就行了。每次询问,我们把 \(l\) 置于 \(l_i\) 所在块的右端点 \(+1\) 处,让它暴力向左移。记录上一次询问 \(r\) 到位而 \(l\) 仍在 \(l_{i-1}\) 块的右端点 \(+1\) 处时的答案,就可以很方便地进行重置。
左移 \(r\):发现按原来的方法,\(r\) 只有在大块与大块间转移时才会向左移,此时我们直接不让它移了,将其重置到 \(l_i\) 所在块的右端点处。
这样一来,对于大块内部,\(l\) 每次重置后从右往左单调插入,\(r\) 仍然是从左往右;大块间的转移则通过 \(l\) 指针和 \(r\) 指针分别重置实现,\(l\) 重置到 \(l_i\) 所在块末尾 \(+1\),\(r\) 重置到 \(l_i\) 所在块末尾。\(l_i\) 与 \(r_i\) 在一个块里面时可能会出问题,我们提前暴力回答即可。
虽然是不删除莫队,但是在重置指针时,我们仍然要撤销掉插入所带来的一些影响(暴力 memset
复杂度会出问题)。口胡复杂度:
-
\(l_i\) 与 \(r_i\) 在同一个块里的询问先行暴力,总复杂度 \(O(m\sqrt{n})\);
-
每次询问 \(l\) 左移并重置复杂度 \(O(\sqrt{n})\),总复杂度 \(O(m\sqrt{n})\);
-
\(r\) 在每个大块内部仍从左到右移,复杂度 \(O(n)\),总复杂度仍然是 \(O(n\sqrt{n})\);
-
大块间转移重置两个指针直接 \(O(n)\) 暴力清空信息,转移 \(O(\sqrt{n})\) 次,总复杂度 \(O(n\sqrt{n})\)。
综上,总复杂度为
\(n,m\) 同阶则复杂度 \(O(n\sqrt{n})\)。这里以 板子题 为例放出代码:
int a[],num[],c[];//离散化后的序列,离散化后序列对应的原序列的数字,离散化后数字出现次数
for(int i=1;i<=m;++i) if(k[q[i].l]==k[q[i].r]){
//暴力
}
sort(q+1,q+m+1);
int l,r,nowk=0;long long lres;
//记录lastres便于快速重置指针回到原来状态
for(int i=1;i<=m;++i) if(k[q[i].l]!=k[q[i].r]){
if(k[q[i].l]!=nowk){
memset(c,0,sizeof(c));//暴力清空
r=ed[q[i].l];l=r+1;
nowk=k[q[i].l];lres=0;
}
long long res=lres;//在上一次的基础上(l在li的块的末尾)l向左拓展,r向右拓展
int rp=r;//当前位置
while(r<q[i].r) ++c[a[++r]];
for(int j=rp+1;j<=r;++j) res=max(res,1ll*num[a[j]]*c[a[j]]);
lres=res;//r拓展完后记录此时答案
int lp=l;while(l>q[i].l) ++c[a[--l]];
for(int j=l;j<lp;++j) res=max(res,1ll*num[a[j]]*c[a[j]]);
for(int j=rp+1;j<=r;++j) res=max(res,1ll*num[a[j]]*c[a[j]]);
ans[q[i].id]=res;//l拓展完后得到答案
while(l<lp) --c[a[l++]];//重置时清除影响
}
莫队套DS
以下默认 \(n,m\) 同阶。
有些时候询问除了端点 \((l,r)\) 两者外有着其它的参数,这意味着我们在 ins
或 del
时无法马上得出答案。对于之前的普通莫队,其复杂度全部在于指针的转移上(这部分经我们的分析是 \(O(n\sqrt n)\) 的),回答排序后的第 \(i\) 个询问时只需把指针转移到相应位置即可 \(O(1)\) 获取答案(ans[q[i].id]=res
)。因此原来的莫队实际上可以看成是一个总共花费 \(O(n\sqrt n)\) 修改,\(O(n)\) 查询的DS。
为了引出接下来为莫队做的延伸,煮个栗子,这一题的询问参数除了序列上的区间左右端点还有值域上的限制。我们的莫队仍然是按照序列的 \(l_i,r_i\) 排序。考虑这样一个数据结构,它维护的是莫队当前维护区间 \((l,r)\) 的状态,可以在莫队插入/删除的时候快速修改,在莫队查询的时候以不那么快的速度得到答案。对于这题,所求的DS应该是值域分块,只需要支持单点修改、求区间和与区间点值 \(>0\) 的数字个数即可。
为什么是值域分块而非树状数组、线段树等等呢?我们发现,对于所求DS需要支持的操作(单点修改、求区间和与区间点值 \(>0\) 的数字个数),值域分块可以做到 \(O(1)-O(\sqrt n)\)(\(O(1)\) 修改,\(O(\sqrt n)\) 查询),套到莫队上后总的复杂度就是 \(O(n\sqrt n)\times O(1)+O(n)\times O(\sqrt n)=O(n\sqrt n)\),是众多待选DS中的佼佼者(比如树状数组,对于所需操作它的复杂度是 \(O(\log n)-O(\log n)\),最终复杂度 \(O(n\sqrt n\log n)+O(n\log n)=O(n\sqrt n\log n)\),显然劣于值域分块)。通过观察我们发现,设套到莫队上的DS的复杂度为 \(S-T\),则最终复杂度为
相当于是一个加权求 \(\max\),解决这类问题应优先选取修改较快,查询较慢的DS。
事实上这类询问带其它参数的问题才算是比较常见的,各路难题中莫队也正是如此充当板子的角色。
代码不过是把普通莫队的插入/删除/查询稍微改了一下,就不放了。
树上莫队
适用范围:树链操作
其实就是把树拍扁转化成序列上的操作。
考虑这么一个东西,它叫做括号序,属于欧拉序的一种,确切定义是从根节点开始遍历一棵树,过程中每开始访问 \(u\) 的子树以及结束访问 \(u\) 的子树时各将 \(u\) 添加至序列末尾所最终形成的序列。比如这么一棵以 \(1\) 为根的树:
其一种可能的括号序列为:1 2 3 3 4 4 2 5 5 1
。
设开始访问 \(u\) 子树的时间戳为 \(bg(u)\)、结束访问的时间戳为 \(ed(u)\),最终构成的括号序列为 \(a\)。对于一次树链操作 \((x,y)\),特判掉 \(x=y\) 的情况后令 \(bg(x)<bg(y)\),\(p=\operatorname{lca}(x,y)\),考虑如何将其转移到括号序上。
Ⅰ.\(x\) 是 \(y\) 的祖先,即 \(p=x\)。
则
也就是括号序列中下标在 \(bg(x)\) 到 \(bg(y)\) 间且在该区间内出现次数恰好为 \(1\) 的所有结点,构成了 \(x\) 到 \(y\) 的简单路径。
如何理解?上图(纯手绘,造成视觉障碍概不负责):
可以看到 \(x,y\) 以及 \(x\) 到 \(y\) 路径上的其它结点 \(c_1,c_2,\cdots\) 的子树在 \([bg(x),bg(y)]\) 间都没有访问完(即各在序列中只出现了一个 \(bg\)),而 \(x,c_1,c_2,\cdots\) 的与 \(x\) 到 \(y\) 路径无关的儿子的子树要么已经访问完毕(\(bg\) 和 \(ed\) 各出现一遍,图中 \(s\)),要么还没开始访问(\(bg\) 和 \(ed\) 都没有,图中 \(rs\));\(y\) 的儿子们的子树也都处于未开拓的状态。我们的结论显然是正确的。
Ⅱ.\(p\ne x\) 且 \(p\ne y\)。
则
也就是括号序列中下标在 \([ed(x),bg(y)]\) 间且在该区间内出现次数恰好为 \(1\) 的所有结点以及 \(p\) ,构成了 \(x\) 到 \(y\) 的简单路径。
设 \(u_x\) 为 \(p\) 包含 \(x\) 的子树的根,\(u_y\) 为 \(p\) 包含 \(y\) 的子树的根,接着上图(稍微能看点了):
把 \([ed(x),bg(y)]\) 拆成 \([ed(x),ed(u_x)],(ed(u_x),bg(u_y)),[bg(u_y),bg(y)]\) 三块,第三块套上情况Ⅰ就知道是 \(u_y\) 到 \(y\) 的路径,第一块实际上和第三块一模一样是 \(u_x\) 到 \(x\) 的路径,第二块就是 \(p\) 的某些夹在 \(u_x,u_y\) 间的子树,它们全部被访问完毕、\(bg\) 和 \(ed\) 各出现一次不被计入答案。此时唯一漏掉的就是 \(p\) 这个 \(\operatorname{lca}\) 了,我们的结论仍然成立。
Ⅲ.\(y\) 是 \(x\) 的祖先,即 \(p=y\)。
开头保证了 \(bg(x)<bg(y)\),显然这种情况不存在。
于是我们在加上对 \(\operatorname{lca}\) 的特判后用各种普通莫队处理区间操作即可。另外树上莫队可以把 ins
和 del
写进一个函数,像这样(\(ext_i\) 表示当前维护的区间中结点 \(i\) 出现次数的奇偶性):
void cxk(int i){
(ext[nod[i]]^=1)?INS:DEL;
}
参考实现:
struct que{
int l,r,id,ac;//区间端点,原来操作的位置,lca
bool operator<(const que &x)const{
return k[l]==k[x.l]?(k[l]&1?r<x.r:r>x.r):l<x.l;
}
}q[M];
int qcnt=0;for(int i=1;i<=m;++i){
scanf("%d%d",&x,&y);
if(x==y){ans[i]=...;continue;}
if(bg[x]>bg[y]) swap(x,y);
int ac=RMQ_LCA::lca(x,y);
if(x==ac) q[++qcnt]=(que){bg[x],bg[y],i,0};
else q[++qcnt]=(que){ed[x],bg[y],i,ac};
}
sort(q+1,q+qcnt+1);int l=1,r=0;
for(int i=1;i<=qcnt;++i){
while(l>q[i].l) cxk(--l);
while(r<q[i].r) cxk(++r);
while(l<q[i].l) cxk(l++);
while(r>q[i].r) cxk(r--);
ans[q[i].id]=res;
if(q[i].ac&&...) ...;//特判lca
}
for(int i=1;i<=m;++i) printf("%d\n",ans[i]);
例题
普通莫队:
回滚莫队:
莫队套DS:
以及各种神仙题目
树上莫队:
……待更新