【学习笔记】莫队

发布时间 2023-10-23 17:22:48作者: KingPowers

0 前言

前置知识:

  • 会打暴力。

  • 简单的分块或根号思想。

二次离线莫队不会,就不写了。

1 普通莫队

不妨直接上一个例题来看看,通过题目来直接引入莫队:题目链接

题意:给定一个长度为 \(n\) 的数列 \(n\)\(m\) 次询问区间 \([l,r]\) 中的不同数字数。

我们不妨设置两个指针 \(l,r\),表示询问区间的左右端点,初始令 \(l=1,r=0\)

对于每次询问,暴力做法就是直接开一个桶,然后每次把 \(l,r\) 指针移动到对应的区间,顺便更新下桶,期间把答案统计出来就可以了。但是这样的时间复杂度是 \(O(nm)\) 的,无法通过。

不过这一步十分重要,先放个代码大家理解下:

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]);
}

考虑上述做法复杂度高的原因:我们的 \(l,r\) 指针每次询问的移动次数最高是可以到达 \(O(n)\) 的(比如上次 \(l\)\(1\),这次 \(l\)\(n\))。而莫队的核心思想就在于,通过分块和排序的方式来降低指针的移动次数

具体怎么做呢?我们考虑对原序列进行分块:将原序列分为 \(\sqrt n\) 块,每块的长度为 \(\sqrt n\)(理论上块长取 \(\frac{n}{\sqrt m}\) 最优,但是差别不大,见下文的复杂度证明)。然后,将所有的询问离线下来,并按照如下方式排序:对于每个询问,优先按照左端点所在的块的编号进行排序,如果左端点所在块的编号相同就按照右端点排序

然后再套用我们上面的暴力方法做即可。

这样复杂度变了多少呢?直接省掉了一个根号!

接下来我们不妨对其复杂度进行证明(下文中记块长为 \(B\)):

update on 2023/5/1:在省集听了 lxl 的讲解之后,才知道我莫队的复杂度一直是记错的。

左指针的移动:假如下一个左端点在当前块内,由于块的大小为 \(B\),那么此时左指针移动的复杂度就是 \(O(B)\);假如下一个左端点和当前不在同一个块内,那么左指针最多只会跑到下一个块内,复杂度也是 \(O(B)\)。综上,左指针每次移动的复杂度不会超过 \(O(B)\),总复杂度不会超过 \(O(mB)\)

右指针的移动:对于每一个块,右指针单调递增,因此每一个块内右指针移动的复杂度为 \(O(n)\),而总共有 \(\frac{n}{B}\) 个块,因此右指针移动总复杂度不会超过 \(O(\frac{n^2}{B})\)

总复杂度为 \(O(mB+\frac{n^2}{B})\),根据基本不等式,令 \(mB=\frac{n^2}{B}\),可以解得 \(B=\frac{n}{\sqrt m}\),代入可得总复杂度为 \(O(m\sqrt n)\)

说了这么多,代码终于能端上来了。

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,l++,r-- 的顺序来写,否则可能会因为奇怪的原因 RE。

具体的原因大家可以取 OI-Wiki 上看,有相对详细的介绍。

2 带修改的莫队

我们同样还是以例题的方式来引入:题目链接

我们之前是引入了两个指针 \(l,r\),现在我们考虑再引入一个指针 \(t\),代表当前进行的修改操作。对于每个询问,我们记录它是在第几次操作之后(称其为“时间戳”),移动完 \(l,r\) 指针之后,我们再比较一下指针 \(t\) 和当前询问的时间戳关系:如果当前询问是在第 \(t\) 次修改之前的,那么就将多的几次修改改回去;如果当前询问是在第 \(t\) 次修改之后的,我们就要多进行几次修改。

排序方式:按照询问左端点所在块的编号排序,左端点所在块相同时按右端点所在块的编号排序,右端点所在块相同时按照时间戳排序。当块长取 \(n^{\frac{2}{3}}\) 时取得最优复杂度 \(O(n^\frac{5}{3})\),具体证明过程不再展示。

直接说有点抽象,结合代码理解:

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 子树统计相关

p9pvho4.png

图太丑了呜呜呜……

这个其实大家应该都比较熟悉了,子树问题可以通过预处理 dfs 序和子树大小,来转换成区间问题,以 \(x\) 为根的子树所对应的区间就可以表示为 [dfn[x],dfn[x]+siz[x]-1]

例题没找到合适的,有人有的话戳我一下。

3.2 路径统计相关

不难发现,路径问题是无法用普通 dfs 序来解决的。

但是,我们有一种新的序列——欧拉序,也叫括号序。

它的求法十分简单,让每个点入栈两次即可,先上一张图(图片来自网络,如果侵权立马删除):

p99JWnI.png

不妨观察下它有什么性质:以 \(3\) 为例,\(3\) 在欧拉序中一共出现了两次,且两个 \(3\) 中间出现的点有 \(6,8,9,10,11\),不难发现它们其实都是 \(3\) 子树内的点,这种优美的性质就可以很好地帮我们解决树上莫队问题。

我们不妨记点 \(i\) 在欧拉序中第一个出现位置为 \(st[i]\),第二个出现位置为 \(ed[i]\),现在有两个点 \((u,v)\),如何将它们路径上的点转化为对应区间呢?
我们分两种情况来考虑。

不妨设 \(st[u]<st[v]\),第一种情况就是 \(lca(u,v)=u\),即 \(u\)\(v\) 的直接祖先,此时我们可以直接将欧拉序的区间 \([st[u],st[v]]\) 拉出来使用,并且将区间中出现过两次的点不加入答案的计算。根据性质,路径上的点一定都会只出现一次。

第二种情况即 \(u,v\) 分属不同的子树中,这个时候我们直接把区间 \([ed[u],st[v]]\) 拉出来使用,同样区间中出现过两次的点忽略不计,理由同上。当然这么做不难发现 \(lca(u,v)\) 也被忽略掉了,特判一下加上就可以了。

至于这么做的具体证明就不再给了,结合上面的图其实应该很好理解。

求欧拉序的代码放在这里:

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 回滚莫队

有的时候,我们可能发现,对于某些题目,添加操作十分好实现,但是删除操作却异常困难(或者反过来)。这个时候,我们可能就需要用到回滚莫队。

就比如这道题:题目链接

我们发现,如果单纯只是加入一个数的话,题目要求的东西其实是非常好维护的。但是删除呢?次大值我们是不可能快速地直接维护的。

我们仍然从莫队排序后的性质出发,看看能不能找到解决问题的方法。既然我们实现不了删除,那么我们直接不删除不就行了吗?

同样还是对序列分好块,这次我们需要预处理出第 \(i\) 号块的左端点 \(L[i]\) 和第 \(i\) 号块的右端点 \(R[i]\),对于每次询问,考虑如下情况:

  • 如果这次询问的左右端点都在同一个块内的话,那么我们不妨直接暴力从前往后扫一遍,复杂度只有 \(O(\sqrt n)\)

  • 否则,如果当前询问的左端点和上一个询问左端点不在同一块内,我们直接将答案和贡献清零,并把 \(l\) 指针移动到当前块的右端点 \(+1\)\(r\) 指针移动到当前块的右端点。

  • 每次移动,先把 \(r\) 指针移动到对应位置,对于左端点同属一块的询问,右端点是单调向右的,因此 \(r\) 指针的贡献不需要消除;随后,把 \(l\) 指针移动到对应的端点,统计完答案后,\(l\) 指针归回到原位,因为 \(l\) 指针无法保持单调,对答案带来的贡献不一定能留下去。

时间复杂度分析:对于 \(r\) 指针,每个块最多移动 \(O(n)\) 次,总共 \(\sqrt n\) 个块,时间复杂度 \(O(n\sqrt n)\);对于 \(l\) 指针,每次最多移动 \(O(\sqrt n)\) 次,总共有 \(n\) 次询问,复杂度 \(O(n\sqrt n)\)

综上所述,回滚莫队的复杂度也只有 \(O(\sqrt n)\),十分优秀。

具体实现的细节直接看代码。

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]);
}