数据结构专题-学习笔记:莫队#1(普通莫队)

1.概述

莫队算法,是由莫涛队长提出的一种,能够以玄学的复杂度来处理区间查询类的问题。

甲:区间查询类的问题不是可以用线段树等数据结构解决的吗?
乙:那如果要求某个区间的区间众数要怎么办呢?不准使用分块。
甲:啊这。。。。。。

所以,莫队算法就是用来解决这种线段树等数据结构不好维护的区间查询问题。

在接触莫队之前,请先确保已经掌握分块的基本思想。

我的博客:数据结构专题-学习笔记:分块

@Isaunoya 大佬的博客:link

开始讲解之前先安利几个博客,写的非常好,建议各位读者可以看一看,写的比我好多了:

考虑到莫队算法有很多的扩展 ,因此莫队分成三篇文章来讲述:

数据结构专题-学习笔记:莫队#1(普通莫队):基础的莫队讲解,普通莫队。

数据结构专题-学习笔记:莫队#2(带修莫队,树上莫队):更进一步,讲解 带修莫队、树上莫队、树上带修莫队。

数据结构专题-学习笔记:莫队#3(回滚莫队,莫队二次离线):最后两种莫队,回滚莫队/不删除莫队、莫队二次离线。

接下来通过一道题目,来讲述莫队算法的原理。

2.套路

实际上,莫队算法并没有固定的模板,却有一个非常好记的代码套路,比分块好学一点。

抛出例题->link1&link2

link1&2 是一个题目,不过:link2被数据加强狂魔 chen_zhe 加强了数据,莫队过不去了。

So,这道题怎么做呢?

直接暴力时间复杂度 \(O(m(n+s))\) ~ \(O(n^2)\)\(n\) 是序列长度, \(m\) 是询问个数, \(s\) 是值域。但是,这简直就是要 T 上天啊,因此我们需要考虑一定的优化。

优化1:

我们考虑记录 \(cnt\) 数组表示每一个数出现了几次。

在暴力 for 循环中,每一次枚举时我们都让 \(cnt_{a_i}++\) ,每一次加完之后我们看一眼,如果 \(cnt_{a_i}=1\) 那么记录答案 +1。在后面的操作中,假如我们需要删除 \(a_i\) ,那么让 \(cnt_{a_i}--\) ,如果变成 0 了,那么让答案 -1。这样,我们可以将时间复杂度从 \(O(m(n+s))\) 优化到 \(O(mn)\) ,删掉了值域的影响。但是还是 TLE 啊!于是我们需要再次优化。

优化2:

接下来这个优化,将是莫队算法的一个重点!

还记得尺取法吗?没错,就是那个两个指针在两个数组上移来移去的东西。通过尺取法,我们可以将 \(O(n^2)\) 的代码优化到 \(O(n)\) ,那么我们可不可以使用同样的办法来优化暴力呢?

答案是肯定的。只不过,由于只剩了一个数组,所以两个指针需要在同一个数组上移动。

具体做法如下:

假设当前序列为 5 6 8 2 5 2 1 3 6 9 7 5 2 1,指定询问区间是 \([2,6],[5,7]\)

首先,我们规定两个指针 \(l=1,r=0\) (为什么?请见下文),一开始处于如下位置:

_ 5 6 8 2 5 2 1 3 6 9 7 5 2 1
r l

接下来,看到第一个区间左端点为 2,那么我们将左端点右移一位,同时根据优化 1 ,删除 5 ,记录 \(cnt_5--\)

_ 5 6 8 2 5 2 1 3 6 9 7 5 2 1
r   l
cnt[5]=-1

接下来,发现右指针 \(r\) 太远了,于是一步一步向右移动。

接下来放上移动的全过程:
r++,右移一位
_ 5 6 8 2 5 2 1 3 6 9 7 5 2 1
  r l
记录cnt[5]++,此时 cnt[5]=0;
接下来记录 total 为答案。total=0;
r++,右移一位
_ 5 6 8 2 5 2 1 3 6 9 7 5 2 1
    l
    r
记录cnt[6]++,此时 cnt[6]=1;由优化一,total++;(total=1)
r++,右移一位
_ 5 6 8 2 5 2 1 3 6 9 7 5 2 1
	l r
记录cnt[8]++,此时 cnt[6]=1,cnt[8]=1;由优化一,total++;(total=2)
r++,右移一位
_ 5 6 8 2 5 2 1 3 6 9 7 5 2 1
	l	r
记录cnt[2]++,此时 cnt[6]=cnt[8]=cnt[2]=1;由优化一,total++;(total=3)
r++,右移一位
_ 5 6 8 2 5 2 1 3 6 9 7 5 2 1
	l	  r
记录cnt[5]++,此时 cnt[6]=cnt[8]=cnt[2]=cnt[5]=1;由优化一,total++;(total=4)
r++,右移一位
_ 5 6 8 2 5 2 1 3 6 9 7 5 2 1
	l		r
记录cnt[2]++,此时 cnt[6]=cnt[8]=cnt[5]=1,cnt[2]=2;

此时,由优化一, \(total\) 需要加 1 吗?

不需要!根据优化一的理论,此时 2 已经出现过,所以不能 +1。

此时 \(l=2,r=6\) ,询问结束,答案为 4。

接下来看询问 \([5,7]\)

初始序列:_ 5 6 8 2 5 2 1 3 6 9 7 5 2 1
发现 l<5 ,于是把 l 右移到 5
_ 5 6 8 2 5 2 1 3 6 9 7 5 2 1
		  l r
其中,cnt[6]=cnt[8]=0,cnt[5]=cnt[2]=1;
然后将 r 右移到 7
_ 5 6 8 2 5 2 1 3 6 9 7 5 2 1
		  l   r
此时,cnt[5]=cnt[2]=cnt[1]=1,total=3,模仿上述过程不难想出。

理解优化二了吗?有尺取法的感觉。

接下来说一下为什么要初始化 \(l=1,r=0\)

是这样的,因为在处理第一个询问的时候,我们需要将 \(l,r\) 右移,而 \(l\) 右移的时候认为这个数是需要被删除的(删除操作),而 \(r\) 右移的时候认为这个数是需要被加入的(增加操作),所以初始化 \(l=1,r=0\) 就可以完美解决这个问题——删除一次+增加一次=啥都没干

那么如果此时又需要询问区间 \([3,5]\) 呢?我们只需要将 \(l,r\) 往回移即可。

所以,在加入优化二之后,程序的效率被大大提升,在某些测试点上取得了不错的效果。

P.S. 通过在上面的操作我们可以发现,双指针处理答案时先加一个数再删一个数是对答案没有影响的。(这点性质在后面的讲解中会用到)

但是!这还不是莫队!

优化3:

刚才的优化二,在随机数据上取得了很好的效果,但是如果询问区间是这样的:

[1,2][1e5-1,1e5][2,3][1e5-2,1e5-1]......

此时,优化二直接就没了,因为此时 \(l,r\) 从头到尾,又从尾到头移来移去,直接使得时间复杂度飙升至 \(O(nm)\) ,甚至跑的比暴力还慢。

看起来似乎不能再优化了,但是我们还有最后一招——排序!

如果我们能够通过合理的排序,使得 \(l,r\) 的移动次数尽量少,那么就可以解决 TLE 的问题。

此时,莫涛队长想出了一种优化的方法:分块!

将左端点分成若干块,排序的时候按照左端点所在的块为第一关键字,右端点为第二关键字排序,取块长为 \(\sqrt{n}\) ,那么可以证明时间复杂度为 \(O(n\sqrt{n})\)

证明过程:

  1. 首先,对于每一块内的元素而言,左端点至多移动 \(\sqrt{n}\) ,右端点移动 \(n\) ,复杂度 \(n\sqrt{n}\)
  2. 而后,由于块与块的转移是 \(O(n)\) 的,因此可以做到时间复杂度 \(O(n\sqrt{n}+n)\) (注意不是相乘),即为 \(O(n\sqrt{n})\)

于是,我们就证完了~~~

不过由于莫队需要对询问进行排序,因此莫队就变成了离线算法。因此我们需要事先存下询问的顺序 \(id\) ,最后根据 \(id\) 输出。

update 2020/12/13 这里需要强调一下:莫队排序时是以左端点为第一关键字!而不是以它的 \(id\) 为第一关键字!千万不能在这里 TLE 了!!!

代码实现:

  • 删除&增加操作:
    仿照优化一敲代码即可。
    void Delete(int x)
    {
    	cnt[a[x]]--;
    	if(cnt[a[x]]==0) sum--;
    }
    void Add(int x)
    {
    	if(cnt[a[x]]==0) sum++;
    	cnt[a[x]]++;
    }
    
  • 排序实现
    bool cmp(const node &fir,const node &sec)
    {
    	if(fir.b!=sec.b) return fir.b<sec.b;
    	return fir.r<sec.r;
    }
    
  • 询问实现
    for(int i=1;i<=m;i++) {q[i].l=read();q[i].r=read();q[i].id=i;q[i].b=(q[i].l-1)/block+1;}//由于我们进行了排序,所以需要存一个 id ,方便输答案使用
    sort(q+1,q+m+1,cmp);
    int l=1,r=0;
    for(int i=1;i<=m;i++)
    {
    	while(l<q[i].l) Delete(l++);
    	while(l>q[i].l) Add(--l);
    	while(r>q[i].r) Delete(r--);
    	while(r<q[i].r) Add(++r);
    	ans[q[i].id]=sum;
    }
    

update 2020/12/11:这里的写法实际上是有问题的,因为我们先动 \(l\) 可能会导致出现 \(l>r\) 的情况,这样在某些题目当中会导致一些神奇的错误。具体详见 关于莫队的区间端点移动顺序 这篇文章,有详细的解说,不过本文里面给出的题单与例题没有这个问题,各位读者注意一下。

update 2020/12/11:推荐的写法是 l--,r--,r++,l++

讲到这里,代码已经不难写出,下面给出代码:

#include<bits/stdc++.h>
using namespace std;

const int MAXN=1e6+10;
int n,a[MAXN],ans[MAXN],m,cnt[MAXN],sum,block;
struct node
{
	int l,r,id,b;
}q[MAXN];

bool cmp(const node &fir,const node &sec)
{
	if(fir.b!=sec.b) return fir.b<sec.b;
	return fir.r<sec.r;
}

int read()
{
	int sum=0,fh=1;char ch=getchar();
	while(ch<'0'&&ch>'9') {if(ch=='-') fh=-1;ch=getchar();}
	while(ch>='0'&&ch<='9') {sum=(sum<<3)+(sum<<1)+(ch^48);ch=getchar();}
	return sum*fh;
}
void print(int x,char tail=0)
{
	if(x<0) {putchar('-');x=-x;}
	if(x>9) {print(x/10);x%=10;}
	putchar(x|48);
	if(tail) putchar(tail);
}

void Delete(int x)
{
	cnt[a[x]]--;
	if(cnt[a[x]]==0) sum--;
}
void Add(int x)
{
	if(cnt[a[x]]==0) sum++;
	cnt[a[x]]++;
}

int main()
{
	n=read();block=2135;
	for(int i=1;i<=n;i++) a[i]=read();
	m=read();
	for(int i=1;i<=m;i++) {q[i].l=read();q[i].r=read();q[i].id=i;q[i].b=(q[i].l-1)/block+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) Delete(l++);
		while(l>q[i].l) Add(--l);
		while(r>q[i].r) Delete(r--);
		while(r<q[i].r) Add(++r);
		ans[q[i].id]=sum;
	}
	for(int i=1;i<=m;i++) print(ans[i],'\n');
	return 0;
}

然而,莫队因为时间复杂度带了一个根号,因此很容易被卡常 (比如 chen_zhe 就到处卡莫队) ,所以接下来讲几个卡常技巧。

卡常技巧:

1.#pragma GCC optimize系列

包括 O2,O3,Ofast。

实际上,通过测试可以发现,吸了氧气的莫队跑的非常之快,甚至 1e6 都能无压力碾过。因此,只要比赛不禁 O2 ,那么写莫队时就最好开着。

2.奇偶性排序

这个是比较强力的一个排序,而代码只需要这么改动(规定初始块为 1):

bool cmp(const node &fir,const node &sec)
{
	if(fir.b!=sec.b) return fir.b<sec.b;
	if(fir.b&1) return fir.r<sec.r;
	return fir.r>sec.r;//请注意这两行
}

这两行的主要作用就是奇数块右端点从小到大排序,偶数块右端点从大到小排序,可以优化将近 200ms,那么原理是什么呢?

原理就是一开始 \(r\) 往右移时,能够将 1 号块全部做完,移回去时又能顺便把 2 号块给解决了,然后往右移时又能做完 3 号块······就这样,优化了大量的常数。

3.压缩代码:

啥意思?

我们可以将这段代码:

void Delete(int x)
{
	cnt[a[x]]--;
	if(cnt[a[x]]==0) sum--;
}
void Add(int x)
{
	if(cnt[a[x]]==0) sum++;
	cnt[a[x]]++;
}

和这一段代码:

while(l<q[i].l) del(l++);
while(l>q[i].l) add(--l);
while(r>q[i].r) del(r--);
while(r<q[i].r) add(++r);
//del=Delete函数,add=Add函数

活生生的压缩成这一段:

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]]++;

这样,又能优化 200ms,并且非常有用。不过使用时一定要建立在熟练的基础上,不然会大大增加调试难度。

4.手打快读/快输

其实莫队题目的输入输出量还是非常大的,因此建议手打快读/快输,能够省下不少时间。(但是为什么我的快输比 printf 还慢啊?)

总结:

莫队的一般套路:

void del(int x){/*do sth.*/}
void add(int x){/*do sth.*/}

sort(q+1,q+m+1,cmp);
int l=1,r=0;
for(int i=1;i<=m;i++)
{
	while(l<q[i].l) del(l++);
	while(l>q[i].l) add(--l);
	while(r>q[i].r) del(r--);
	while(r<q[i].r) add(++r);
	ans[q[i].id]=sum;
}

其实也还是很好 理解的。

如果你理解了上述代码,那么恭喜你,学会了普通莫队!

不过要注意:莫队只适合离线,如果要在线基本上就没有用了,需要另寻他法(洛谷日报 #183 期除外)。

3.练习题

接下来,你将见到各路莫队用法(包括带修莫队,树上莫队,树上带修莫队(前两者的结合体),回滚莫队/不删除莫队,莫队二次分块/第十四分块(前体)),共 9 题。

题单:

篇幅有限,这里无法写出题解(其实是因为还有好几种莫队没有讲),因此莫队练习题与莫队的总结请见这两篇文章->数据结构专题-学习笔记:莫队#2(带修莫队,树上莫队)数据结构专题-学习笔记:莫队#3(回滚莫队,莫队二次离线)

posted @ 2022-04-13 21:32  Plozia  阅读(214)  评论(0编辑  收藏  举报