莫队算法及各种莫队扩展

普通莫队

了解莫队之前先看一下这样一个问题
Q:有一个长为N序列,有M个询问:在区间[L,R]内,出现了多少个不同的数字。(数字范围为0到1000000之间的整数),N ≤ 50000,M ≤ 200000。

对于这个问题不难想到一个简单的暴力

int L=1,R=0;
void add(int x)
{
    cnt[x]++;
    if(cnt[x]==1) sum++;
}

void del(int x)
{
    cnt[x]--;
    if(cnt[x]==0) sum--;
}

for(int i=1;i<=m;++i)
{
    while(R<q[i].rr) add(a[++R]);
    while(R>q[i].rr) del(a[R--]);
    while(L<q[i].ll) del(a[L++]);
    while(L>q[i].ll) add(a[--L]);
    ans[q[i].num]=sum;
}

就是用一个cnt[]数组记录当前每个数字出现了多少次
然后用左L,右R指针不断移动来获得当前区间的cnt[]数组
增加时每当有一个cnt[i]==1就代表新增加了一个数字
删除时每当有一个cnt[i]==0就代表新减少了一个数字

但是既然都说了这是一个暴力,这样当然是过不了
不过这就是莫队算法的其中一个核心了

考虑到这样做会T的原因是左右指针移动次数无法确定
所以我们可以改变对询问的处理顺序,采用离线算法

具体怎么改变询问顺序呢
我们将整个序列按分块思想分成 N \sqrt{N} N 块,每块有 N \sqrt{N} N 个元素
然后将询问排序,具体方法是
询问左区间L所在块为第一关键字
询问右区间R为第二关键字排序

现在所有询问左区间所在块是单调递增的
所以左指针移动次数不超过 M N M \sqrt{N} MN

由于同一个块内所有询问右指针单调递增
一个块内的询问右指针移动最大为 N N N,总共 N \sqrt{N} N 个块
所以右指针移动次数不超过 N N N \sqrt{N} NN

整体复杂度 O ( ( N + M ) N ) O((N+M)\sqrt{N}) O((N+M)N )

果题链接及代码(P.S. 这题luogu加强了数据,就只能用离线树状数组了)
BZOJ1878 [SDOI2009]HH的项链

#include<iostream>
#include<cmath>
#include<algorithm>
#include<queue>
#include<cstring>
#include<cstdio>
using namespace std;
 
int read()
{
    int f=1,x=0;
    char ss=getchar();
    while(ss<'0'||ss>'9'){if(ss=='-')f=-1;ss=getchar();}
    while(ss>='0'&&ss<='9'){x=x*10+ss-'0';ss=getchar();}
    return x*f;
}
 
int t,n,m;
int L=1,R=0,a[500010];
int cnt[1000010];
struct node{int ll,rr,num;}q[200010];
bool cmp(node a,node b){return (a.ll/t)==(b.ll/t) ?a.rr<b.rr :(a.ll/t)<(b.ll/t);}
int ans[200010],sum;
 
void add(int x)
{
    cnt[x]++;
    if(cnt[x]==1) sum++;
}
 
void del(int x)
{
    cnt[x]--;
    if(cnt[x]==0) sum--;
}
 
int main()
{
    n=read();
    for(int i=1;i<=n;++i)
    a[i]=read();
     
    m=read();
    for(int i=1;i<=m;++i)
    q[i].ll=read(),q[i].rr=read(),q[i].num=i;
     
    t=sqrt(n);//块数
    sort(q+1,q+1+m,cmp);
    for(int i=1;i<=m;++i)
    {
        while(R<q[i].rr) add(a[++R]);
        while(R>q[i].rr) del(a[R--]);
        while(L<q[i].ll) del(a[L++]);
        while(L>q[i].ll) add(a[--L]);
        ans[q[i].num]=sum;
    }
    for(int i=1;i<=m;++i)
    printf("%d\n",ans[i]);
    return 0;
}

带修改的莫队

同样先看问题
Q:有一个长为N序列,有M个操作:
查询操作,查询在区间[L,R]内,出现了多少个不同的数字。
修改操作,修改第x个数字为y
(数字范围为0到1000000之间的整数),N ≤ 50000,M ≤ 50000。

带上了修改的思路依旧很暴力
对于每个询问记录在这个询问前修改了多少次
改多了或改少了都一个while改回来

不过这里分块要变成
每块大小 N 2 3 N^{ \frac 2 3 } N32,总共 N 1 3 N^{ \frac 1 3 } N31
且要加入修改顺序为第三关键字排序
这样左右指针,修改指针移动次数都是 N ∗ N 3 2 N*N^{ \frac 3 2 } NN23

所以整个算法渐进复杂度 O ( N 5 3 ) O(N^{ \frac 5 3 }) O(N35)

依旧是果题链接及代码
BZOJ2120 || 洛谷P1903 [国家集训队]数颜色

#include<iostream>
#include<cmath>
#include<algorithm>
#include<queue>
#include<cstring>
#include<cstdio>
using namespace std;

int read()
{
    int f=1,x=0;
    char ss=getchar();
    while(ss<'0'||ss>'9'){if(ss=='-')f=-1;ss=getchar();}
    while(ss>='0'&&ss<='9'){x=x*10+ss-'0';ss=getchar();}
    return x*f;
}

const int maxn=50010;
int n,m,t;
int a[maxn],cnt[2000010];
int cntq,cntc;
int L=1,R=0,cur,sum;
struct node{int ll,rr,num,id,pre;}q[maxn];
struct node2{int pos,val;}c[maxn];
int ans[maxn];
bool cmp(node a,node b)
{
    if(a.ll/t!=b.ll/t) return a.ll/t<b.ll/t;//左端点按块
    if(a.rr/t!=b.rr/t) 
    {
    	if((a.ll/t)&1) return a.rr<b.rr;//右端点奇偶块排序优化
    	else return a.rr>b.rr;
    }
    return a.pre<b.pre;//修改顺序排
}

void add(int x)
{
    cnt[x]++;
    if(cnt[x]==1) sum++;
}

void del(int x)
{
    cnt[x]--;
    if(cnt[x]==0) sum--;
}

void update(int cur)
{
    if(c[cur].pos>=L&&c[cur].pos<=R)//修改在当前查询区间里才要更新
    {
        if(--cnt[ a[ c[cur].pos ] ]==0) sum--;
        if(++cnt[ c[cur].val ]==1) sum++;
    }
    swap(c[cur].val,a[c[cur].pos]);//因为可能多次更改,所以直接交换要改的数
}

int main()
{
    n=read();m=read(); t=pow(n,2.0/3);//**高亮**,注意分块大小
    for(int i=1;i<=n;++i) a[i]=read();
    for(int i=1;i<=m;++i)
    {
        char ss; scanf("%s",&ss);
        if(ss=='Q')
        {
            q[++cntq].ll=read(); q[cntq].rr=read();
            q[cntq].pre=cntc; //记录上一次修改编号
            q[cntq].num=cntq;
        }
        else if(ss=='R')
        c[++cntc].pos=read(),c[cntc].val=read();//记录修改
    }
    
    sort(q+1,q+1+cntq,cmp);
    for(int i=1;i<=cntq;++i)
    {
        while(R<q[i].rr) add(a[++R]);
        while(R>q[i].rr) del(a[R--]);
        while(L<q[i].ll) del(a[L++]);
        while(L>q[i].ll) add(a[--L]);
        while(cur<q[i].pre) update(++cur);//把改多/少的改回来
        while(cur>q[i].pre) update(cur--);
        ans[q[i].num]=sum;
    }
    for(int i=1;i<=cntq;++i)
    printf("%d\n",ans[i]);
    return 0;
}

回滚莫队

不删除莫队

一些莫队的题在左右指针移动时,增加和删除元素时都更新答案会很困难,但如果只有增加时才更新会很容易,比如下面这个
AtCoder - joisc2014_c 歴史の研究
题目大意是:给定一个长度为n的序列,有m个询问,每次回答区间内元素权值乘以元素出现次数的最大值

由此我们引入回滚莫队

和普通莫队一样先对询问按左端点所在块和右端点位置为第一、二关键字升序排序,依次处理询问

① 若当前询问的左右端点在同一个块内,则直接暴力扫描该区间回答

② 若当前询问的左右端点不在同一个块内

此时先检查当前询问的的左端点与上一个询问的左端点是否在同一个块内
不是则初始化莫队的左指针为当前块右端点+1右指针为当前块右端点
接下来将莫队左、右指针分别扩展到询问左、右端点,回答询问

回答后将莫队左指针移回当前块右端点+1,也就是所谓的回滚
这样可以保证下一次回答询问只在增加元素时更新答案

对于第一类询问,单次回答复杂度为 O ( n ) O(\sqrt n) O(n )
对于第二类询问,左指针单次移动复杂度为 O ( n ) O(\sqrt n) O(n ),总移动复杂度为 O ( m n ) O(m\sqrt n) O(mn )
而当询问左端点所在块未发生变化时,右指针位置单调递增
询问左端点所在块最多发生 O ( n ) O(\sqrt n) O(n )次变化,故右指针总移动复杂度为 O ( n n ) O(n\sqrt n) O(nn )

综上回滚莫队复杂度为 O ( n n ) O(n\sqrt n) O(nn )

下面是上述题目的代码

#include<iostream>
#include<cstdio>
#include<cstdlib>
#include<cstring>
#include<algorithm>
#include<cmath>
using namespace std;
typedef long long lt;
typedef double dd;
#define sqr(x) ((x)*(x)) 

int read()
{
    int x=0,f=1;
    char ss=getchar();
    while(ss<'0'||ss>'9'){if(ss=='-')f=-1;ss=getchar();}
    while(ss>='0'&&ss<='9'){x=x*10+ss-'0';ss=getchar();}
    return x*f;
}

const int maxn=200010;
int n,Q;
struct node{int ll,rr,id;}q[maxn];
lt a[maxn],b[maxn],val[maxn];
lt pos[maxn],tot;
lt sz,cnt[maxn],_cnt[maxn];
lt ans[maxn];

int block(int idx){ return (idx-1)/sz+1;} // 位置idx所属的块编号
int ed(int k){ return k*sz>n ?n :k*sz;} // 第k个块的右端点

bool cmp(node a, node b){ 
	return block(a.ll)==block(b.ll) ?a.rr<b.rr :block(a.ll)<block(b.ll);
}

void del(int idx){ --cnt[val[idx]];}
void add(int idx){ ++cnt[val[idx]];} // 用了cpp的函数重载
void add(int idx, lt &mx)
{
	++cnt[val[idx]];
	mx=max(mx,cnt[val[idx]]*a[idx]);
}

int main()
{
	n=read(); Q=read();
	sz=sqrt(n);
	
	for(int i=1;i<=n;++i){
		a[i]=b[i]=read();
	}
	
	for(int i=1;i<=Q;++i)
	{
		q[i].ll=read();
		q[i].rr=read();
		q[i].id=i;
	}
	
	// 离散化 
	sort(b+1,b+1+n);
	for(int i=1;i<=n;++i)
	if(i==1||b[i]!=b[i-1])
	pos[++tot]=b[i];
	
	for(int i=1;i<=n;++i){
		val[i]=lower_bound(pos+1,pos+1+tot,a[i])-pos;
	}
	
	sort(q+1, q+1+Q, cmp);
	
	int L=1, R=0, last=0; lt mx=0;
	for(int i=1;i<=Q;++i)
	{
		if(block(q[i].ll)==block(q[i].rr)) // 询问左右端点在同一个块则暴力扫描
		{
			for(int j=q[i].ll;j<=q[i].rr;++j)  // 注意这里用的是一个新数组 _cnt 存的数据
			_cnt[val[j]]++;
			
			for(int j=q[i].ll;j<=q[i].rr;++j)
			ans[q[i].id]=max(ans[q[i].id], _cnt[val[j]]*a[j]);
			
			for(int j=q[i].ll;j<=q[i].rr;++j)
			_cnt[val[j]]--;
			
			continue;
		}
		
		if(last!=block(q[i].ll))
		{
			while(R < ed( block(q[i].ll) ) ) add(++R); // 初始化右指针为块右端点,注意这里add不用更新答案
			while(R > ed( block(q[i].ll) ) ) del(R--); 
			while(L < ed( block(q[i].ll) ) +1) del(L++); // 初始化左指针为块右端点+1
			
			last=block(q[i].ll);
			mx=0;
		}
		
		while(R<q[i].rr) add(++R, mx);
		
		int _L=L; lt tmp=mx;
		while(_L>q[i].ll) add(--_L, tmp);
		while(_L<L) del(_L++); // 回滚
		
		ans[q[i].id]=tmp;
	}
	
	for(int i=1;i<=Q;++i)
	printf("%lld\n",ans[i]);
	
	return 0;
} 

不增加莫队

与不删除莫队相对应,也有一些问题在删除的时候维护答案才会比较容易
比如求区间mex,即一个区间内没有出现过的最小正整数

对不删除莫队的细节稍作修改我们就可以得到不增加的回滚莫队
首先对询问按左端点所在块为第一关键字升序排序,而右端点为第二关键字降序排序

对左右端点在同一块内的询问同样暴力处理
不在同一块内时,先检查当前询问的的左端点与上一个询问的左端点是否在同一个块内

注意此处,若不是则初始化莫队的左指针为当前块左端点右指针为当前n
然后缩小莫队区间回答询问
回答之后令莫队左指针移回当前块左端点

贴一段求区间mex的主要代码

// 求mex的函数
void add(int idx){ ++cnt[a[idx]];}
void del(int idx, int &mex){
	--cnt[a[idx]];
	if(!cnt[a[idx]]) mex=min(mex,a[idx]);
}

int qmex(int ll, int rr)
{
	int res=1e9;
	for(int i=ll;i<=rr;++i)
	_cnt[a[i]]++;
			
	if(_cnt[0]==0) res=0;
	else
	{
		for(int i=ll;i<=rr;++i)
		if(_cnt[a[i]+1]==0) res=min(res,a[i]+1);
	}
			
	for(int i=ll;i<=rr;++i)
	_cnt[a[i]]--;

	return res;
}

回滚莫队主循环

int L=1, R=0, last=0, res=1e9;
for(int i=1;i<=m;++i)
{
	if(block(q[i].ll)==block(q[i].rr))
	{
		ans[q[i].id]=qmex(q[i].ll,q[i].rr);
		continue;
	}
		
	if(last!=block(q[i].ll))
	{
		res=qmex(L, n);
		while(R < n) add(++R);
		while( L < st( block(q[i].ll) ) ) del(L++, res);
		last=block(q[i].ll);
	}
		
	while(R>q[i].rr) del(R--, res);
		
	int _L=L,tmp=res;
	while(_L<q[i].ll) del(_L++, tmp);
	while(_L>L) add(--_L);
		
	ans[q[i].id]=tmp;
}

树上莫队

Q:一棵n个节点的树,每个节点有一个颜色,求u到v的路径上共有多少个不同的颜色
N ≤ 40000

想要把树上问题转化成序列操作,无非就是dfs序/欧拉序/树剖什么的
这里我们采用欧拉序来解决

一下图为例,我们给每个节点标上其欧拉序
(以下 s t [ u ] st[u] st[u]表示结点 u u u第一次加入欧拉序的编号, e d [ u ] ed[u] ed[u]表示回溯时的编号)

在这里插入图片描述
那么查询分为两种情况

1. u , v u,v u,v在从根出发的同一条链上(即 L C A ( u , v ) = = u LCA(u,v)==u LCA(u,v)==u v v v),这时查询区间就是 [ s t [ u ] , s t [ v ] ] [st[u],st[v]] [st[u],st[v]]
例如图中 1 , 6 1,6 1,6,那么我们查询区间为 [ 1 , 7 ] [1,7] [1,7]

2. u , v u,v u,v不在从根出发的同一条链上,我们假设有 s t [ u ] < s t [ v ] st[u]<st[v] st[u]<st[v],那么查询区间为 [ e d [ u ] , s t [ v ] ] [ed[u],st[v]] [ed[u],st[v]],以及你会发现这个区间没有包含 L C A ( u , v ) LCA(u,v) LCA(u,v),所以 L C A ( u , v ) LCA(u,v) LCA(u,v)要单独计算
例如图中 7 , 8 7,8 7,8,那么查询区间为 [ 10 , 13 ] [10,13] [10,13],然后再单独计算1

等等,按照这个区间计算还想好像还经过了别的点???
例如图中 5 , 8 5,8 5,8,查询区间为 [ 6 , 13 ] [6,13] [6,13]
注意到实际需要查询的点的欧拉序在上述查询区间中都只出现过一次
所以只要把查询区间中那些欧拉序出现过两次的点剔除就好了

具体点说就是用 r e m [ u ] rem[u] rem[u]记录当前结点 u u u是否已计算过
r e m [ u ] = = 1 rem[u]==1 rem[u]==1,那么对节点 u u u的数值记录进行删除,否则增加

果题链接和完整代码
SPOJ - COT2 Count on a tree II

#include<iostream>
#include<cmath>
#include<algorithm>
#include<queue>
#include<cstring>
#include<cstdio>
using namespace std;
typedef long long lt;
  
int read()
{
    int f=1,x=0;
    char ss=getchar();
    while(ss<'0'||ss>'9'){if(ss=='-')f=-1;ss=getchar();}
    while(ss>='0'&&ss<='9'){x=x*10+ss-'0';ss=getchar();}
    return f*x;
}
  
const int maxn=100010;
int n,m,t;
struct edge{int v,nxt;}E[maxn<<1];
int head[maxn],tot;
int a[maxn],b[maxn],pot[maxn],num;
int pos[maxn],cnt[maxn],L=1,R;
int fa[maxn],son[maxn],size[maxn];
int dep[maxn],top[maxn],st[maxn],ed[maxn];
struct node{int ll,rr,lca,num;}q[maxn];
bool cmp(node a,node b){return (a.ll/t==b.ll/t) ?a.rr<b.rr :(a.ll/t<b.ll/t);}
int rem[maxn],ans[maxn],res;

void add(int u,int v)
{
	E[++tot].nxt=head[u];
	E[tot].v=v;
	head[u]=tot; 
}

void dfs1(int u,int pa)
{
	size[u]=1; st[u]=++num; pot[num]=u;
	for(int i=head[u];i;i=E[i].nxt)
	{
		int v=E[i].v;
		if(v==pa) continue;
		fa[v]=u; dep[v]=dep[u]+1;
		dfs1(v,u);
		size[u]+=size[v];
		if(size[v]>size[son[u]]) son[u]=v;
	}
	ed[u]=++num; pot[num]=u;
}

void dfs2(int u,int tp)
{
	top[u]=tp;
	if(son[u]) dfs2(son[u],tp);
	for(int i=head[u];i;i=E[i].nxt)
	{
		int v=E[i].v;
		if(v==fa[u]||v==son[u]) continue;
		dfs2(v,v);
	}
}

int LCA(int u,int v)
{
	while(top[u]!=top[v])
	{
		if(dep[top[u]]>dep[top[v]]) u=fa[top[u]];
		else v=fa[top[v]];
	}
	return dep[u]<dep[v]?u:v;
}

void add(int x){ if(++cnt[x]==1)res++;}
void del(int x){ if(--cnt[x]==0)res--;}

void cal(int u)
{
	if(rem[u]) del(a[u]);//注意判断欧拉序在查询区间中出现两次的不计入答案
	else add(a[u]); rem[u]^=1;
}

int main()
{
    n=read();m=read();
    for(int i=1;i<=n;++i) 
	a[i]=b[i]=read();
	
	for(int i=1;i<n;++i)
	{
		int u=read(),v=read();
		add(u,v); add(v,u)
	}
    
    sort(b+1,b+1+n);
    for(int i=1;i<=n;++i)
    if(i==1||b[i]!=b[i-1])
    pos[++pos[0]]=b[i];
    
    for(int i=1;i<=n;++i)
    a[i]=lower_bound(pos+1,pos+1+pos[0],a[i])-pos;
    
    dep[1]=1;
	dfs1(1,0); dfs2(1,1);
	
	for(int i=1;i<=m;++i)
	{
		int u=read(),v=read();
		if(st[u]>st[v]) swap(u,v);
		int lca=LCA(u,v); q[i].num=i;
		if(lca==u){ q[i].ll=st[u]; q[i].rr=st[v]; q[i].lca=0;}//在从根出发的同一条链上,查询区间[ st[u], st[v] ]
		else{ q[i].ll=ed[u]; q[i].rr=st[v]; q[i].lca=lca;}//不在从根出发的同一条链上,查询区间[ ed[u], st[v] ]
	}
	
	t=sqrt(n*2);//欧拉序长度是原节点数两倍,所以n*2
	sort(q+1,q+1+m,cmp);
	for(int i=1;i<=m;++i)
	{
		while(R<q[i].rr) cal(pot[++R]);
		while(R>q[i].rr) cal(pot[R--]);
		while(L<q[i].ll) cal(pot[L++]);
		while(L>q[i].ll) cal(pot[--L]);
		if(q[i].lca!=0) cal(q[i].lca);
		ans[q[i].num]=res;
		if(q[i].lca!=0) cal(q[i].lca);
	}
	for(int i=1;i<=m;++i)
	printf("%d\n",ans[i]);
	return 0;
}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值