莫队入门(咕咕咕)


前言

感谢 yzhwjylscqyb 等一众大奆的大力支持

本人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)/) 插入删除的情况下,我们的最终复杂度为

/[O(m/sqrt{n})+O(n)+O(n/sqrt{n})+O(n/sqrt{n})=O((n+m)/sqrt{n})
/]

在 /(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})/)。

综上,总复杂度为

/[O(m/sqrt{n})+O(m/sqrt{n})+O(n/sqrt{n})+O(n/sqrt{n})=O((n+m)/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++]];//重置时清除影响 
}

……(待更新)

原创文章,作者:kepupublish,如若转载,请注明出处:https://blog.ytso.com/275888.html

(0)
上一篇 2022年7月21日
下一篇 2022年7月21日

相关推荐

发表回复

登录后才能评论