浅谈莫队算法
首先来看一道例题:
Description
有n个数字,以及m个查询。每次查询的格式是L,r,求L~r(左右包含)这个区间内有多少个不同的数?
1< n,m<=50000,1<=L<r<=n,数列中元素大小<=n 。
思路1:
自然而然想到暴力,对每次询问L~r枚举区间,如果数列中元素取值范围很大,先要进行离散化处理。
时间复杂度:O(nm)
代码略
思路2:
线段树或树状数组处理一下。(代码复杂度相对而言较高)。
#include<cstdio>
#include<algorithm>
#define lowbit(x) ((x)&(-(x)))
using namespace std;
int n,m;
int b[500010],c[500010],last[1000010],ans[500010];
struct node{int x,y,id;} a[500010];
bool cmp(node x,node y)
{
return x.y==y.y?x.x<y.x:x.y<y.y;
}
void add(int x,int y)
{
while(x<=n)
{
c[x]+=y;
x+=lowbit(x);
}
}
int getsum(int x)
{
int sum=0;
while(x)
{
sum+=c[x];
x-=lowbit(x);
}
return sum;
}
int main()
{
scanf("%d",&n);
for(int i=1;i<=n;i++)
scanf("%d",&b[i]);
scanf("%d",&m);
for(int i=1;i<=m;i++)
{
scanf("%d %d",&a[i].x,&a[i].y);
a[i].id=i;
}
sort(a+1,a+m+1,cmp);
int now=1;
for(int i=1;i<=n;i++)
{
if(last[b[i]]) add(last[b[i]],-1);
last[b[i]]=i;
add(i,1);
while(i==a[now].y&&now<=m)
{
ans[a[now].id]=getsum(a[now].y)-getsum(a[now].x-1);
now++;
}
if(now==m+1) break;
}
for(int i=1;i<=m;i++)
printf("%d\n",ans[i]);
return 0;
}
思路3
回到思路1,方法1这样的暴力是没有前途的!我们来考虑一下新的暴力:
一开始指针区间0->0,然后对于一个查询,我们将Left指针逐步更新成新的L,Right同理。
比如一开始Left=2,Right=3,而当前查询 L=1,r=5。
那么我们Left-1,并且把Left位置上的数字出现次数+1.
Right+1,把Right位置上的数字出现次数+1,直到Right=5为止。
add(x){ //把x位置的数字加入进来
cnt[x]++;
if (cnt[x]==0) ans++;
}
remove(x){ //把x位置的数字移出去
cnt[x]--;
if (cnt[x]!=0) ans--;
}
以上面题目为例;这种方法需要离线处理,我们同理来看一下解法:
Left=Right=1; add(1);
ans=0;
for u=1 to m{
while (Left<L[u]){ remove(Left); Left++;}
while (Left>L[u]){ Left--; add(Left);}
while (Right<r[u]){ Right++; add(Right};}
while (Right>r[u]){ remove(Right); Right--;}
output ans;
}
这里说明一下,其实remove(Left); Left--; 等等可以直接写成remove(Left--)等等,这么写是为了理解。
分析一下时间复杂度,我们可以从Left和Right的移动量来分析:
每一个新的询问,Left和Right的移动量最大都会是O(N)。所以这样子的方法时间复杂度仍然是O(NM),而且可能比上面的暴力更慢。
但是莫队算法的核心,就是从这么一个算法转变过来的。
现在来介绍一下莫队算法解决这道题:
对询问进行分块,我们知道m个询问,L和r的范围都在n以内,我们根据L和r的大小来对询问分块。
比如n=9,有以下的询问:
2 3
1 4
4 5
1 6
7 9
8 9
5 8
6 8
对于n=9,我们以根号n为每个块block的大小,这里block=3.
那么我们把13分成一组,46,7~9.
对于每一个询问(L,r),我们以L的范围来决定这个询问在哪一个块。
然后每一个独自的块内,我们让询问r更小的排在更前面。
那么上面的询问就可以分组成:
(2,3)/(1,4)/(1,6)和
(4,5)/(5,8)/(6,8)和
(7,9)/(8,9)
这一步的排序操作,我们可以在排序的时候加入判断条件cmp:
bool cmp(node x,node y)
{
if (block[x.x]==block[y.x])
return x.r<y.r; //同一块的时候
return x.L<y.L; //不同一块的时候
}
排序之后,我们再来分析一下时间复杂度;接下来我们会看到神奇的事情!!
刚才分析此方法的时候,我们是从L和R的偏移量分析的;我们仍然用这种方法来分析。
考虑一下在同一个块的时候。由于L的范围是确定的,所以每次L的偏移量是O(√N)
但是r的范围没有确定;r的偏移量是O(N)。
那么从一个块到另一个块呢?
明显地,r我们不需要作考虑,仍然是O(N)。
而L明显最多也是2√N,而且这种情况下,很快就会到下下一块。所以也是O(√N)
由于有√N(根号N)个块,所以r的总偏移量是O(N√N)
而M个询问,每个询问都可以让L偏移O(√N),所以L的总偏移量O(M√N)
注意了,时间复杂度分析的时候一定要注意,r的偏移量和询问数目是没有直接关系的。
而L则恰恰相反;L的偏移量我们刚才也说明了,它和块的个数没有直接关系。
所以总的时间复杂度是:O((N+M)√N)
很神奇地看到了,我们仅仅改变了一下问题求解的次序,就让时间复杂度大幅度下降!
当然在这个说明过程中我们也看到了,事实上,莫队是一个必须离线的算法。
意味着一些题目如果强制在线,那么莫队就无能为力了。
实现代码:
#include<cstdio>
#include<cmath>
#include<algorithm>
using namespace std;
int n,m,now;
int p[500010],block[500010],cnt[500010],ans[500010];
struct node{int x,y,id;} a[500010];
bool cmp(node x,node y)
{
return block[x.x]==block[y.x] ? x.y<y.y : x.x<y.x;
}
void init()
{
int u=sqrt(n);
for(int i=1;i<=n;i++)
block[i]=(i-1)/u+1;
}
void move(int x,int d)
{
if(d)
{
if(!cnt[p[x]]) now++;
cnt[p[x]]++;
}
else
{
cnt[p[x]]--;
if(!cnt[p[x]]) now--;
}
}
int main()
{
scanf("%d",&n);
for(int i=1;i<=n;i++)
scanf("%d",&p[i]);
scanf("%d",&m);
init();
for(int i=1;i<=m;i++)
{
scanf("%d%d",&a[i].x,&a[i].y);
a[i].id=i;
}
sort(a+1,a+m+1,cmp);
int l=0,r=0;
for(int i=1;i<=m;i++)
{
while(l<a[i].x) move(l++,0);
while(l>a[i].x) move(--l,1);
while(r<a[i].y) move(++r,1);
while(r>a[i].y) move(r--,0);
ans[a[i].id]=now;
}
for(int i=1;i<=m;i++)
printf("%d\n",ans[i]);
return 0;
}
莫队算法小结
莫队算法的条件
1、不包含修改操作。
2、题目允许离线,也就是允许在所有询问全部读入完之后回答所有询问。
3、不同区间的结果可以互相计算得出。怎么理解这个条件呢?
就上面的问题而言,如果上一次已经回答了[l,r]区间的答案,并且已经存下了[l,r]区间里的所有数值的出现次数,那么如果下面要询问[l,r+1]的结果,就只要把右指针r向右移一个单位,并将序列的第r+1个数的出现次数++,同时维护当前的答案,也就是说,如果第r+1个数在[l,r]区间内没有出现,则当前答案++。同样,对于[l,r−1],[l−1,r][l+1,r]以及其他任何的区间都可以在上一个询问的基础上,通过l和r移动指针来求得下一个询问的答案。如果满足这样的条件,就是说不同区间的结果可以互相计算得出。
莫队算法的流程
可以看出,一次移动指针是O(1)的。于是想到可以回答第1个询问之后,不断地移动指针,一个一个移动到后面将要回答的所有区间。
莫队算法的主要思想就是这样。同时,莫队算法利用了可以离线的条件,将询问按照合理的顺序进行求解,实现了O(N√N)的复杂度。
首先,将序列分块,即分成√n块,每个块的大小为√n。
然后,就将询问按照左端点所在的块为第一关键字,右端点的位置为第二关键字进行从小到大排序,这样,就能像上面那样不断移动指针,可以达到O(N√N)的复杂度。
复杂度证明
不妨把左端点在同一个块内的询问分成一组。
先考虑右端点的移动次数。由于在同一组询问内的右端点是递增的,所以在同一组内,右端点移动了O(n)次。同时在跨越两个组时,右端点的移动次数也是O(n),即右端点一共移动了O(N√N)次。
再考虑左端点的移动次数。可以看出,在同一组询问内,左端点一次移动的次数为O(N√N)次。
再加上在跨越两个组时,左端点的移动次数也是O(N√N),因此左端点一共移动了O(N√N)次。复杂度得证。
巩固练习
[BZOJ1878][SDOI2009]HH的项链
[BZOJ2038][2009国家集训队]小z的袜子
[BZOJ3236][AHOI2013]作业
[BZOJ4540][HNOI2016]序列
[BZOJ4542][HNOI2016]大数