莫队算法小结

莫队是一种美妙的处理区间操作的离线算法,与之前我们提到的代码量极大的\(LCT\)相比,它的板子简单易记,可以说是十分清新了, 二次离线之类的毒瘤另说

一、概述

首先我们来看一道经典例题,题目给出了一个序列,多次询问区间\([l,r]\)中不同数值的个数。

显然我们可以暴力,对于每个询问都暴力计算。

但这样的复杂度太高,我们发现其实我们可以\(O(1)\)进行单个元素插入与删除,也就是说如果我们维护了一个区间\([l,r]\)的答案,那么我们就可以\(O(1)\)转移到区间\([l,r+1]\)\([l,r-1]\)\([l-1,r]\)\([l+1,r]\)

那么我们考虑维护一个区间\([l,r]\),每处理到一个新的询问,就移动\([l,r]\)这两个指针移动到询问的区间。

但这样很容易被卡,只需要先查\([1,2]\),再查\([99999,100000]\),再查\([1,2]\),再查\([99999,100000]\)\(\dots\),我们的程序就又变成了\(O(nm)\)

但我们还有应对方法,注意到在这样的情况下,我们其实可以先处理完所有\([1,2]\)的询问,再处理\([99999,100000]\)的询问,也就是说,我们可以将询问离线下来,进行排序,以特殊的顺序进行处理,这就是莫队算法了。

二、实现

一般来说莫队的排序方式是:现将长度为\(n\)的序列分为若干个块,每个块的大小为\(B\),然后在排序时,先按照左端点所在块的序号排序,如果所在块相同,再按右端点排序,可以证明这样做,我们的复杂度是\(\mathcal O(n\sqrt{m})\)的:

\(1.\)\(l\)的移动:块内移动时单次移动不超过\(\mathcal O(B)\),跨越块移动时复杂度也是\(\mathcal O(B)\)的,总复杂度为\(\mathcal O(mB)\),(\(m\)是询问个数)

\(2.r\)的移动:因为每个块内的询问的右端点有序,所以块内的所有询问最坏情况下也不过是跳完整个序列,于是总复杂度就是块数\(*\)序列长度,即\(\mathcal O(\frac{n^2}{B})\)

于是总复杂度为:\(\mathcal O(mB+\frac{n^2}{B})\),当\(B=\sqrt{\frac{n^2}{m}}=\frac{n}{\sqrt{m}}\)时复杂度最优为\(\mathcal O(n\sqrt{m})\)

于是在经过一个看似简单的排序后,我们大大优化了复杂度!

莫队还有一些优化:

\(1.\)奇偶性排序,就是在排序中额外操作一下,从

struct cmp{
	bool operator()(query x,query y){
		return (bel[x.l]^bel[y.l])?bel[x.l]<bel[y.l]:x.r<y.r;
	}
};

更换为

struct cmp{
	bool operator()(query x,query y){
		return (bel[x.l]^bel[y.l])?bel[x.l]<bel[y.l]:bel[x.l]&1?x.r<y.r:x.r>y.r;
	}
};

这样做大概思路就是做奇数块是将右指针从左移到右,做偶数块时再从右到左,可以优化不少常数,

并且在定义块大小时考虑到常数的问题,我们用块大小为

\[\frac{n}{\sqrt{\frac{2}{3}m}} \]

是最优的;

于是刚才那道题代码如下:

#include<bits/stdc++.h>
using namespace std;
const int N=30010;
int n,a[N],cnt[1000010],l=1,r=0,sq,sum,bel[N],bl,ans[200010];
inline int read(){
	int x=0,f=1;char ch=getchar();
	while(!isdigit(ch)){if(ch=='-') f=-1;ch=getchar();}
	while(isdigit(ch)){x=(x<<3)+(x<<1)+(ch^48);ch=getchar();}
	return x*f; 
}
struct query{
	int l,r,id;
}q[200010];
struct cmp{
	bool operator()(query x,query y){
		return (bel[x.l]^bel[y.l])?bel[x.l]<bel[y.l]:bel[x.l]&1?x.r<y.r:x.r>y.r;
	}
};
int main(){
	n=read();bl=n/sqrt(m*2/3);
	for(int i=1;i<=n;++i){
		a[i]=read();
		bel[i]=(i-1)/bl+1; 
	} 
	sq=read();
	for(int i=1;i<=sq;++i) q[i].l=read(),q[i].r=read(),q[i].id=i;
	sort(q+1,q+sq+1,cmp());
	for(int i=1;i<=sq;++i){
		while(l<q[i].l) sum-=!--cnt[a[l++]];
		while(l>q[i].l) sum+=!cnt[a[--l]]++;
		while(r<q[i].r) sum+=!cnt[a[++r]]++;
		while(r>q[i].r) sum-=!--cnt[a[r--]];//这里是为了减少常数做的一些操作,你也可以单独写add函数与dec函数
		ans[q[i].id]=sum; 
	}
	for(int i=1;i<=sq;++i) printf("%d\n",ans[i]);
	return 0;
}

这就是莫队算法的大致框架了,我们再来讲一些莫队算法的扩展

三、带修莫队

就是增加一个单点修改,考虑在给询问与修改增加一维\(t\)时间戳,在\(t\)上的操作,就是\(t+1\)就将\(t+1\)时间的修改加上,\(t-1\)就还原\(t\)时间的修改

于是排序时再增加一个关键字,主函数移动增加为\(6\)个方向就可以了,并没有什么高论。

这种情况下,当块大小为\(n^{\frac 23}\)时,复杂度大致达到最优,是\(\mathcal O(n^{\frac 53})\)

这里给出一道例题的参考代码:

#include<bits/stdc++.h>
using namespace std;
const int N=5e4+10;
inline int read(){
	char c;int f=0,x=0;
	while(!isdigit(c=getchar()))if(c=='-')f=1;x=c^48;
	while(isdigit(c=getchar()))x=(x+(x<<2)<<1)+(c^48);
	return f?-x:x;
}
int n,m,a[N],ans[N],cntq,cntc,k,ll,rr,tt,ret,cnt[N*20];
struct query{
	int l,r,t,id;
}q[N];
struct change{
	int pos,u,v;
}c[N];
inline int cmp(query x,query y){
	if(x.l/k!=y.l/k) return x.l/k<y.l/k;
	if(x.r/k!=y.r/k) return x.r/k<y.r/k;
	return x.t<y.t;
}//对l,r都分块处理
signed main(){
	n=read();m=read();
	for(int i=1;i<=n;i++) a[i]=read();
	for(int i=1;i<=m;i++){
		 char op[10];int x,y;
		 scanf("%s",op);
		 x=read();y=read();
		 if(op[0]=='Q'){
		 	q[++cntq].l=x;q[cntq].r=y;
			q[cntq].id=cntq;q[cntq].t=cntc;
		 }
		 else{
		 	c[++cntc].pos=x;c[cntc].v=y;c[cntc].u=a[x];
		 	a[x]=y;
		 }
	}
	k=ceil(exp((log(n)+log(cntc))/3));//貌似比较优的块大小
	sort(q+1,q+cntq+1,cmp);
	for(int i=cntc;i>=1;i--) a[c[i].pos]=c[i].u;
	for(int i=1;i<=cntq;i++){
		int l=q[i].l,r=q[i].r;
		while(ll<l){
			cnt[a[ll]]--;if(!cnt[a[ll]]) ret--;
			ll++;
		} 
		while(ll>l){
			ll--;
			cnt[a[ll]]++;if(cnt[a[ll]]==1) ret++;
		}
		while(rr<r){
			rr++;
			cnt[a[rr]]++;if(cnt[a[rr]]==1) ret++;
		}
		while(rr>r){
			cnt[a[rr]]--;if(!cnt[a[rr]]) ret--;
			rr--;
		}
		while(tt>q[i].t){
			int p=c[tt].pos;
			if(ll<=p&&p<=rr){
				cnt[a[p]]--;
				if(!cnt[a[p]]) ret--;
			}
			a[p]=c[tt].u;
			if(ll<=p&&p<=rr){
				cnt[a[p]]++;
				if(cnt[a[p]]==1) ret++;
			}
			tt--;
		}
		while(tt<q[i].t){
			tt++;
			int p=c[tt].pos;
			if(ll<=p&&p<=rr){
				cnt[a[p]]--;
				if(!cnt[a[p]]) ret--;
			}
			a[p]=c[tt].v;
			if(ll<=p&&p<=rr){
				cnt[a[p]]++;
				if(cnt[a[p]]==1) ret++;
			}
		}//6个方向的修改
		ans[q[i].id]=ret;
	}
	for(int i=1;i<=cntq;i++)
		printf("%d\n",ans[i]);
	return 0;
}

四、树上莫队

莫队处理的一般都是一维的序列,至于树上的问题,我们就考虑在欧拉序上做即可

对于子树上的问题,它们在欧拉序中一定是连续的一段,于是和普通莫队就没什么区别了

对于路径上的问题,我们假设询问的是节点\(u,v\),欧拉序中每一个点入栈时的时间戳是\(in[u]\),出栈时是\(out[u]\),分类讨论一下:

\(1.\)\(u,v\)是祖先关系,不妨设\(u\)\(v\)的祖先,那么我们直接用\((in[u],in[v])\)作为序列,不在\(u-v\)这条链上的点都会出现\(2\)次或者不出现,在链上的都只会出现\(1\)次了。

\(2,\)\(u,v\)不是子树关系,同样的我们拿出\((out[u],in[v])\)这段序列来做,也是只考虑序列上出现\(1\)次的点的权值,但这样会漏算\(lca\)的权值,需要额外考虑一下。

读者可以手画几幅图理解一下,确定序列之后也就和普通莫队没有什么区别了。

例题的代码:

#include<bits/stdc++.h>
using namespace std;
const int N=2e5+10;
const int M=2e5+10;
int n,m,col[N],a[N],tot,ru[N],chu[N],first[N],cnt,dfn,dep[N],fa[N][20],ans[M],bl,bel[N<<1],df[N];
int sum,vis[N],s[N];
struct node{
	int v,nxt;
}e[N<<1];
inline void add(int u,int v){e[++cnt].v=v;e[cnt].nxt=first[u];first[u]=cnt;}
inline void dfs(int u,int f){
	fa[u][0]=f;
	for(int i=1;i<=19;++i) fa[u][i]=fa[fa[u][i-1]][i-1];
	ru[u]=++dfn;df[dfn]=u;
	for(int i=first[u];i;i=e[i].nxt){
		int v=e[i].v;
		if(v==f) continue;
		dep[v]=dep[u]+1;
		dfs(v,u);
	}
	chu[u]=++dfn;df[dfn]=u;
}
inline int getlca(int x,int y){
	if(dep[x]<dep[y]) swap(x,y);
	int t=dep[x]-dep[y];
	for(int i=19;i>=0;--i) if(t&(1<<i)) x=fa[x][i];
	if(x==y) return x;
	for(int i=19;i>=0;--i) if(fa[x][i]!=fa[y][i]) x=fa[x][i],y=fa[y][i];
	return fa[x][0]; 
}
struct query{
	int l,r,lca,id;
}q[M];
struct cmp{bool operator ()(query a,query b){return (bel[a.l]^bel[b.l])?bel[a.l]<bel[b.l]:bel[a.l]&1?a.r<b.r:a.r>b.r;}};
inline void work(int x){
	if(vis[x]) vis[x]=0,sum-=!--s[col[x]];
	else vis[x]=1,sum+=!s[col[x]]++;
}
int main(){
	scanf("%d%d",&n,&m);
	for(int i=1;i<=n;++i) scanf("%d",&col[i]),a[i]=col[i];
	sort(a+1,a+n+1);
	tot=unique(a+1,a+n+1)-a-1;
	for(int i=1;i<=n;++i) col[i]=lower_bound(a+1,a+tot+1,col[i])-a;
	for(int i=1,u,v;i<n;++i){
		scanf("%d%d",&u,&v);
		add(u,v);add(v,u);
	}
	dfs(1,0);
	for(int i=1,x,y;i<=m;++i){
		scanf("%d%d",&x,&y);
		if(ru[x]>ru[y]) swap(x,y);
		q[i].id=i;
		if(getlca(x,y)==x) q[i].l=ru[x],q[i].r=ru[y];
		else q[i].l=chu[x],q[i].r=ru[y],q[i].lca=getlca(x,y);		
	}
	bl=ceil(sqrt(n+0.5));
	for(int i=1;i<=dfn;++i) bel[i]=(i-1)/bl+1;
	sort(q+1,q+m+1,cmp());
	int l=1,r=0;
	for(int i=1;i<=m;++i){
		while(l<q[i].l) work(df[l++]);
		while(l>q[i].l) work(df[--l]);
		while(r<q[i].r) work(df[++r]);
		while(r>q[i].r) work(df[r--]);
		if(q[i].lca) work(q[i].lca);
		ans[q[i].id]=sum;
		if(q[i].lca) work(q[i].lca);
	}
	for(int i=1;i<=m;++i) printf("%d\n",ans[i]);
	return 0;
}

五、回滚莫队

我们都知道,莫队的维护基础在于它能够\(O(1)\)进行快速插入与删除,但如果是这样的一道题呢:

例题:给定一个序列,多次询问一段区间\([l,r]\),求区间中相同的数的最远间隔距离。序列中两个元素的间隔距离指的是两个元素下标差的绝对值

显然我们可以离散化后维护每一个权值对应的元素中下标的最大值与最小值,但是我们会发现:加入很好做,直接修改进行了,删除怎么做呢?唯一的办法是如果当前记录的最大位置被更改,就暴力寻找一个,但这样的复杂度达到了\(\mathcal O(n^2\sqrt{n})\),是我们无法接受的。

注意到莫队有一个性质:左端点在同一块内的所有询问右端点单调递增,那么事实上,我们可以在处理每一个块的询问时,都将维护的东西清零,然后将\(r\)从头开始依次移动,这部分的复杂度仍然是\(\mathcal O(n\sqrt{n})\)

对于左端点,由于最多也就会移动块大小的距离,那么我们每次操作时都暴力移动\(l\)然后,用栈记录下来并复原即可,这样做的复杂度也是\(\mathcal O(n\sqrt{n})\)

于是,我们可以将二者合并起来,一开始将\(r\)指针置于块的尾部,每个询问依次移动,那么每一个询问中我们就只会漏算块内的部分,这部分暴力算即可

于是整个过程中,我们没有用到删除操作,但复杂度依然是\(\mathcal O(n\sqrt{n})\),符合我们的要求

代码如下:

#include<bits/stdc++.h>
using namespace std;
const int N=2e5+10;
int n,m,k,a[N],mx[N],r,mn[N],bel[N],ans,R,ret[N];
struct query{
	int l,r,id;
}q[N];
struct cmp{
	bool operator ()(query x,query y){
		return (bel[x.l]^bel[y.l])?bel[x.l]<bel[y.l]:x.r<y.r;
	}//现在可不能再奇偶性排序了
};
inline void add_r(int i){
	int x=a[i];
	mn[x]=min(mn[x],i);mx[x]=max(mx[x],i);
	ans=max(ans,mx[x]-mn[x]);
} 
int top,stk[N][3];
inline void add_l(int i){
	int x=a[i];
	stk[++top][0]=x;stk[top][1]=mn[x];stk[top][2]=mx[x];
	mn[x]=min(mn[x],i);mx[x]=max(mx[x],i);
	ans=max(ans,mx[x]-mn[x]);
}//这是需要记录并回溯的一些加入,实际上可以将两个add合并起来
int b[N]; 
int main(){
	scanf("%d",&n);
	int sq=pow(n,0.5);
	for(int i=1;i<=n;++i) scanf("%d",&a[i]),bel[i]=(i-1)/sq+1,b[i]=a[i];
	sort(b+1,b+n+1);m=unique(b+1,b+n+1)-b-1;
	for(int i=1;i<=n;++i) a[i]=lower_bound(b+1,b+m+1,a[i])-b;
	scanf("%d",&k);
	for(int i=1;i<=k;++i){
		scanf("%d%d",&q[i].l,&q[i].r);
		q[i].id=i;
	}
	sort(q+1,q+k+1,cmp());
	for(int i=1;i<=k;++i){
		if(bel[q[i].l]!=bel[q[i-1].l]||i==1){
			for(int i=1;i<=m;++i) mx[i]=-0x3f3f3f3f,mn[i]=0x3f3f3f3f;
			ans=0;R=bel[q[i].l]*sq;r=min(R,n);top=0;
		}
		while(r<q[i].r) add_r(++r);
		int rec=ans;
		for(int l=q[i].l;l<=min(q[i].r,R);++l) add_l(l);//暴力计算块内的部分
		ret[q[i].id]=ans;ans=rec;
		while(top){
			mn[stk[top][0]]=stk[top][1];
			mx[stk[top][0]]=stk[top][2];
			--top;
		}
	}
	for(int i=1;i<=k;++i) printf("%d\n",ret[i]);
	return 0;
}

六、二次离线莫队

对于很多问题,在莫队加入一个点时会同时进行修改与查询,修改与查询的次数都是 \(\mathcal O(n\sqrt{q})\) 的。

很多时候,查询与修改的复杂度并不是 \(\mathcal O(1)\) 的,例如对于单点修改区间查询,可以树状数组维护使得两个操作复杂度均为 \(\mathcal O(\log n)\),但事实上我们可以分块平衡复杂度做到 \(\mathcal O(1)\) 修改 \(\mathcal O(\sqrt{n})\) 查询或者 \(\mathcal O(\sqrt{n})\) 修改 \(\mathcal O(1)\) 查询。能不能利用这一点呢?

考虑莫队时区间端点的变化,设 \(f(x,i)\) 表示点 \(x\) 对点 \(i\) 的贡献,\(f(x,[l,r])\) 表示点 \(x\) 对区间 \([l,r]\) 的贡献,\(F(x,i)=f(x,[1,i])\)

\((l,r)\rightarrow (l,r')(r'>r)\) 为例,此时新增的贡献为:

\[\sum_{i=r+1}^{r'}f(i,[l,i-1])=\sum_{i=r+1}^{r'}[F(i,i-1)-F(i,l-1)] \]

注意到 \(F(i,i-1)\) 是可以预处理的,因此事实上只需要求出 \(\sum_{i=r+1}^{r'}F(i,l-1)\),更一般的说也就是要 \(\mathcal O(n)\) 的求 \(\sum_{i=l}^{r}F(i,x)\),对其他情况也是同理。

那么可以先把所有要求的 \(\sum_{i=l}^{r}F(i,x)\) 预处理出来,然后对于这些询问再次离线,按 \(x\) 排序,从小到大扫过去,\(x\) 增大时就只用进行一次修改,然后对存在 \(x\) 上的所有区间,可以暴力遍历 \([l,r]\) 进行查询。这样一来修改次数被降到了 \(\mathcal O(n)\),查询次数依然是 \(\mathcal O(n\sqrt{q})\),此时就可以用根号平衡等方法平衡修改与查询的复杂度了。比如对于单点修改区间查询的问题即可优化到 \(\mathcal O(n\sqrt{q})\),整个过程中在莫队的基础上再次离线,因此被称为二次离线莫队。

例题,本题修改查询的复杂度只能为 \(\mathcal O(k)—O(1)\) 或者 \(\mathcal O(1)—O(k)\),其中 \(k=\binom{14}{7}\),直接二次离线即可做到 \(\mathcal O(nk+n\sqrt{q})\)

#include<bits/stdc++.h>
using namespace std;
typedef long long ll;
const int N=1e5+10;
struct query{
	int l,r,id;
}q[N];
struct node{
	int tp,l,r,id;
};
int n,m,k,a[N],b[N],cnt,bel[N],ct[N],ans[N];
ll ret[N],p1[N],p2[N];
vector<node> ve[N];
int main(){
	scanf("%d%d%d",&n,&m,&k);
	for(int i=1;i<=n;++i) scanf("%d",&a[i]);
	for(int i=0;i<16384;++i) if(__builtin_popcount(i)==k) b[++cnt]=i;
	for(int i=1;i<=m;++i) scanf("%d%d",&q[i].l,&q[i].r),q[i].id=i;
	int B=n/sqrt(m)+1;
	for(int i=1;i<=n;++i) bel[i]=(i-1)/B+1;
	sort(q+1,q+m+1,[&](const query &x,const query &y){
		return (bel[x.l]^bel[y.l])?bel[x.l]<bel[y.l]:((bel[x.l]&1)?x.r<y.r:x.r>y.r);	
	;});
	for(int i=1;i<=n;++i){
		for(int j=1;j<=cnt;++j) p1[i]+=ct[a[i]^b[j]];
		p2[i]=p1[i]+(k==0);
		ct[a[i]]++;
		p1[i]+=p1[i-1];p2[i]+=p2[i-1];
	}
	int ql=1,qr=0;
	for(int i=1;i<=m;++i){
		if(ql<q[i].l) ret[q[i].id]+=p2[q[i].l-1]-p2[ql-1],ve[qr].push_back((node){-1,ql,q[i].l-1,q[i].id});
		while(ql<q[i].l) ql++;
		if(ql>q[i].l) ret[q[i].id]-=p2[ql-1]-p2[q[i].l-1],ve[qr].push_back((node){1,q[i].l,ql-1,q[i].id});
		while(ql>q[i].l) ql--;
		
		if(qr<q[i].r) ret[q[i].id]+=p1[q[i].r]-p1[qr],ve[ql-1].push_back((node){-1,qr+1,q[i].r,q[i].id});
		while(qr<q[i].r) qr++;
		if(qr>q[i].r) ret[q[i].id]-=p1[qr]-p1[q[i].r],ve[ql-1].push_back((node){1,q[i].r+1,qr,q[i].id});
		while(qr>q[i].r) qr--;
	}
	for(int i=1;i<=n;++i){
		for(int j=1;j<=cnt;++j) ans[a[i]^b[j]]++;
		for(const node& p:ve[i]){
			ll tmp=0;
			for(int j=p.l;j<=p.r;++j) tmp+=ans[a[j]];
			ret[p.id]+=p.tp*tmp;
		}
	}
	for(int i=2;i<=m;++i) ret[q[i].id]+=ret[q[i-1].id];
	for(int i=1;i<=m;++i) printf("%lld\n",ret[i]);
	return 0;
}

莫队的好题有很多,大家可以自己在网上查找,未来我也可能会写其中一些的题解

完结撒花...

posted @ 2021-01-08 09:54  cjTQX  阅读(150)  评论(0)    收藏  举报