浅谈莫队,带修莫队,高维莫队。树上莫队,莫队二次离线(暂无),回滚莫队及其应用 || 爆肝万余字 || 这里有 luogu 最全的莫队讲解!
__biu_biu_biu__ · · 算法·理论
我不会滥用标题行了吧。
本专栏讲解了前人都没怎么讲解的莫队算法的拓展与进阶,对朴素莫队仍然有详细的讲解,并对时间复杂度和最优块长有了一个系统的整理与分析,是一个大集合。
一、普通莫队
莫队算法可以以
莫队算法的本质是在已知某区间的情况下,能逐步推出未知区间,我们以下面的 Simple Question I 作为例题。
:::info[Simple Question I]
维护一个长度为
如果一个区间
如果
反之,说明
如果要推出
如果
反之,说明
通过逐步递推,我们就可以推出所有的区间了。
#include <bits/stdc++.h>
using namespace std;
const int N=1e6+5;
int a[N],b[N],n,m,ans;
int lst=1,rst=0;
int main(){
cin>>n>>m;
for(int i=1;i<=n;i++)cin>>a[i];
for(int i=1;i<=m;i++){
int l,r;
cin>>l>>r;
while(l<lst){
if(!b[a[lst-1]])ans++;
b[a[lst-1]]++;
lst--;
}//递推[l,r]->[l-1,r]
while(r>rst){
if(!b[a[rst+1]])ans++;
b[a[rst+1]]++;
rst++;
}//递推[l,r]->[l,r+1]
while(l>lst){
if(b[a[lst]]==1)ans--;
b[a[lst]]--;
lst++;
}//递推[l,r]->[l+1,r]
while(r<rst){
if(b[a[rst]]==1)ans--;
b[a[rst]]--;
rst--;
}//递推[l,r]->[l,r-1]
cout<<ans<<'\n';
}
}
时间复杂度
:::info[hack]
1 n
n/2 n/2
2 n-1
n/2-1 n/2+1
3 n-2
n/2-2 n/2+2
...
此时我们的递推就会出现来回跑的情况。 :::
二、排序优化
由于我们傻乎乎的挨个操作跑特别笨,由于每个操作不会有后效性,所以我们可以不按题目给的顺序进行递推。
此时我们的问题相当于:网格图上有
首先,这是一个 NP-hard 问题,我们无法求出严格最短的路径,只需要设计一个较为优秀的方案即可。
一个明显的贪心策略是,我们以
:::info[hack]
1 1
1 n
2 2
2 n
...
此时虽然
这个策略其实有很多值得借鉴的地方,因为在
所以我们想到,把很多个
我们来分析组内的元素个数
- 左端点
l 在组内的移动:由于我们需要保证r 是有序跑的,所以l 的移动可能非常复杂,第i 组内最大的移动次数为O(x·p_i) ,其中p_i 表示操作左端点在块i 内的个数,总计O(qx) 。 - 左端点不同组的跨越:最多会跨越两个块长,共跨越
\Theta(\frac{n}{x}) ,总计\Theta(\frac{n}{x}·2x)=\Theta(n) 。 - 右端点的移动:在每个组里会跨越
O(n) ,共\frac{n}{x} ,总计\frac{n^2}{x} 。
总的移动次数为
下面给出 Simple Question I 的正确莫队代码 [
#include <bits/stdc++.h>
using namespace std;
const int N = 1e6 + 5;
int a[N], cnt[N], ans, block;
int n, m;
struct Query {
int l, r, id;
} q[N];
int res[N];
bool cmp(const Query &x, const Query &y) {
if (x.l / block != y.l / block) return x.l / block < y.l / block;
return x.r < y.r;
}
void add(int pos) {
cnt[a[pos]]++;
if (cnt[a[pos]] == 1) ans++;
}
void remove(int pos) {
cnt[a[pos]]--;
if (cnt[a[pos]] == 0) ans--;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
cin >> n >> m;
block = n / sqrt(m);
for (int i = 1; i <= n; i++) cin >> a[i];
for (int i = 1; i <= m; i++) {
cin >> q[i].l >> q[i].r;
q[i].id = i;
}
sort(q + 1, q + m + 1, cmp);
int curL = 1, curR = 0;
ans = 0;
for (int i = 1; i <= m; i++) {
int l = q[i].l, r = q[i].r;
while (curL > l) add(--curL);
while (curR < r) add(++curR);
while (curL < l) remove(curL++);
while (curR > r) remove(curR--);
res[q[i].id] = ans;
}
for (int i = 1; i <= m; i++) {
cout << res[i] << '\n';
}
return 0;
}
总结:莫队的操作其实和线段树的合并类似,但它没有多次合并的过程,且是单点合并,所以可以处理一些线段树无法区间合并的问题,但是在有修改时,莫队明显劣于线段树,操作空间受限。
三、基础莫队例题
ABC448F
这是我们提到的莫队的定义式,直接使用莫队排序即可通过。
P2709
此题与 Simple Question I 类似,但改为了求次数的平方。
很简单的推式子,我们只需要考虑
从
P4137
莫队和 bitset 的巧妙结合。
我们发现区间
bitset 中的函数 _Find_first() 可以返回第一个
由于返回的是第一个 1,所以我们要将未出现的记为
区间内的数为 0 2 1 4,则 bitset 内存的东西为 0010,_Find_first 的值为
P4462
区间
此时
P5268
本题是莫队问题的变种。
注意到
将
我们的莫队维护的就是两个端点
P11659
由于需要满足
对于范围扩大,我们按开头的元素的新增来增加答案:
-
对于
x 为4 的倍数,可以成为f_{1,\frac{x}{4}} 的开头和f_{2,\frac{x}{2}} 的开头。 -
对于
x 为2 的倍数(但不为4 的倍数),可以成为f_{2, \frac{x}{2}} 的开头。
对于缩小,我们按结尾的元素的消失进行减小答案。
- 对于
x 为2 的倍数,则f_{1,\frac{x}{2}} 少了一个元素结尾。 - 对于
x 为3 的倍数,则f_{2,\frac{x}{3}} 少了一个元素结尾。
四、奇偶排序
我们在排序的时候会发现一个问题,在每次跨越块的时候右端点都会回到起点重新跑,于是我们可以优化一下,在每次到终点后从终点跑回起点,这样就减小了
bool cmp(node a, node b) {
return block[a.l]^block[b.l] ? a.l<b.l : (block[a.l]&1 ? a.r<b.r : a.r>b.r);
}//奇数块内右边界升序,偶数块内右边界降序
五、带修莫队
温馨提示:带修莫队只用于单点修改,对于区间修改的情况,需要用前缀和,差分等维护。
我们可以增加一维操作时间维度,在操作区间符合后,我们可以暴力维护操作,对于每一个操作都直接进行修改,共有
我们在排序的时候需要先按左端点的块的编号排序,再按右端点的块的编号排序,最后再按时间排序。
我们以 P1903 为例,展示代码 [
inline void upd(int x, int t)//upd是对于时间上的变化所造成变化的维护
{
if (qq[x].l <= qr[t].l && qr[t].l <= qq[x].r)del(a[qr[t].l]),add(qr[t].r); //如果这个修改的值在[l,r]区间内,则其变化将对答案造成影响
swap(a[qr[t].l], qr[t].r);//因为修改后的下一次操作一定相反(即修改该位置->还原该位置->修改该位置...如此循环),所以只需交换即可,而不需要写两个函数
}
bool cmp (const ques &a, const ques &b)
{
return a.l / sz == b.l / sz ? a.r / sz == b.r / sz ? a.t < b.t : a.r < b.r : a.l < b.l;
}
int main()
{
cin >> n >> m; sz = pow(n, 0.666);//设置块的大小
for (int i = 1; i <= m; i++)
{
char op[5];
int l, r;
scanf("%s%d%d", op, &l, &r);
if (op[0] == 'Q') qq[++cntq].id = {cntq,l,r,cntr};//时间轴是对只关于操作,所以可以类似一种离散化的方法,直接将时间改为上一次修改的时间
else qr[++cntr].l = l, qr[cntr].r = r;
}
sort(qq + 1, qq + cntq + 1, cmp);
while (lcur > qq[i].l) add(a[--lcur]);
while (lcur < qq[i].l) del(a[lcur++]);
while (rcur > qq[i].r) del(a[rcur--]);
while (rcur < qq[i].r) add(a[++rcur]);
while (tcur < qq[i].t) upd(i, ++tcur);
while (tcur > qq[i].t) upd(i, tcur--);
复杂度分析:假设
一个简略的证明是,对于每一个询问的端点移动复杂度为
总复杂度为
六、带修莫队例题:CF940F Machine Learning
可以说是 P4137 和带修莫队的结合,将 bitset 的操作照搬过来即可,这里不再赘述。
七、高维莫队
高维莫队通常是
注意在排序时是将前
小提示:交换两个维度的顺序在一些时候可以有效改善代码复杂度。
在修改时,按先将所有维度扩大,再将所有维度缩小的写法去写,不会有问题。
下面给出例题:
八、高维莫队例题
P5268
本题也可以不用容斥转正常
在
九、树上莫队
在处理树上路径的问题中,莫队是不能直接在树上求解的,对此,我们可以使用欧拉序的方式,将树上路径问题转为序列问题。
欧拉序是从根结点出发,按深度遍历整棵树,在每个点第一次被遍历到时记录,在第二次被遍历到时再次记录,例如:
它的欧拉序为 1 2 2 3 5 5 6 6 7 7 3 4 8 8 4 1。
欧拉序有一个重要的性质,可以帮助我们将树上问题转为序列问题(我们将
- 如果
u 为v 的祖先节点,则树上u,v 的路径等于欧拉序中[f_u,f_v] 的节点。 - 反之
u ,则u,v 的路径等于欧拉序中[s_u,f_v] ,并加上(u,v) 的最近公共祖先。 - 如果
[x,y] 中点a 出现了2 次,则a 一定不在树上x 到y 的路径。
例如:树上 1 2 2 3 5,再减去 1 3 5,为树上
再比如:树上 7 3 4 8,再加上最近公共祖先 7 3 4 8 1,也是树上
有了欧拉序,我们就可以将树上问题转为序列问题了。
下面以 SP10707 举例,给出代码。
#include <iostream>
#include <algorithm>
#include <cstdio>
#include <cmath>
#include <vector>
#define N 200000
using namespace std;
struct node
{
int l,r,ll,rr,id,lca;
}q[N+5];
int n,m,a[N+5],st[N+5],ed[N+5],dfn[N+5],f[N+5],num,size[N+5],his[N+5],dep[N+5],son[N+5],top[N+5],c[N+5],tmp,blo,l=1,r,use[N+5],ans[N+5],data[N+5];
vector <int> d[N+5];
void dfs1(int u,int fa) //树剖第一次深搜
{
f[u]=fa;st[u]=++num;
size[u]=1;his[num]=u;
dep[u]=dep[fa]+1;
vector <int>::iterator it;
for (it=d[u].begin();it!=d[u].end();it++)
{
int v=(*it);
if (v==fa)continue;
dfs1(v,u);
size[u]+=size[v];
if (size[v]>size[son[u]])son[u]=v;
}
ed[u]=++num;his[num]=u;
}
void dfs2(int u,int to) //树剖第二次深搜
{
top[u]=to;
if (son[u])dfs2(son[u],to);
vector <int>::iterator it;
for (it=d[u].begin();it!=d[u].end();it++)
{
int v=(*it);
if (v!=son[u]&&v!=f[u])dfs2(v,v);
}
}
int Lca(int x,int y) //树剖求lca
{
while (top[x]!=top[y])
{
if (dep[top[x]]<dep[top[y]])swap(x,y);
x=f[top[x]];
}
if (dep[x]>dep[y])swap(x,y);
return x;
}
void add(int x)
{
tmp+=(++c[a[x]]==1);
}
void del(int x)
{
tmp-=(--c[a[x]]==0);
}
void calc(int x) //对点进行加入或删除
{
(!use[x])?add(x):del(x);
use[x]^=1;
}
int cmp(node x,node y) //排序
{
return (x.ll==y.ll)?(x.ll%2==1?x.r<y.r:x.r>y.r):x.l<y.l;
}
int main()
{
scanf("%d%d",&n,&m);
for (int i=1;i<=n;i++)
scanf("%d",&a[i]),data[i]=a[i];
sort(data+1,data+n+1);
for(int i=1;i<=n;i++)a[i]=lower_bound(data+1,data+n+1,a[i])-data; //离散化
int x,y;
for (int i=1;i<n;i++)
{
scanf("%d%d",&x,&y);
d[x].push_back(y);
d[y].push_back(x);
}
dfs1(1,0);
dfs2(1,1);
blo=n*2/sqrt(m*2/3);
for (int i=1;i<=m;i++)
{
scanf("%d%d",&x,&y);
if (st[x]>st[y])swap(x,y); //保证stx<sty
q[i].id=i;
q[i].lca=Lca(x,y);
if (q[i].lca==x) //x,y在以x为根的子树中
{
q[i].l=st[x];
q[i].r=st[y];
q[i].ll=st[x]/blo;
q[i].rr=st[y]/blo;
q[i].lca=0;
}
else
{
q[i].l=ed[x];
q[i].r=st[y];
q[i].ll=ed[x]/blo;
q[i].rr=st[y]/blo;
}
}
sort(q+1,q+m+1,cmp);
for (int i=1;i<=m;i++)
{
while (l>q[i].l)calc(his[--l]);
while (r<q[i].r)calc(his[++r]);
while (l<q[i].l)calc(his[l++]);
while (r>q[i].r)calc(his[r--]);
if (q[i].lca)calc(q[i].lca);//单独计算 LCA
ans[q[i].id]=tmp;
if (q[i].lca)calc(q[i].lca);//将 LCA 删去,避免多算
}
for (int i=1;i<=m;i++)
printf("%d\n",ans[i]);
return 0;
}
十、莫队二次离线
太难了,先咕咕咕,预计 2026 年能写完,先别急。
十一、回滚莫队 / 不删除莫队
在一些较难处理区间缩小操作,但区间扩大好做的情况下,就可以使用回滚莫队。
:::warning 回滚莫队无法使用奇偶排序!!! :::
每一个块
首先将所有左右端点在同一个块内的查询全部暴力搜掉,接着我们初始化左右端点为
对于每一次要缩小左端点的情况,我们不妨暴力一点,将左端点**直接回退到初始
:::warning
由于我们要记录
然后就做完了,时间复杂度为
- [
^{1} ]():该代码使用 DeepSeek 编写。 - [
^{2} ]():该代码参考了 Gu_Pigeon 的题解。