初见 | 数据结构 | 莫队入门
前言
因为很好奇莫队是个啥于是昨天就去学了一下,不过只学到了普通莫队和带修莫队,剩下的可能以后再补。
缺省源
算是老套路了(
可能是因为中间隔了一晚上所以所给出例题的代码实现略有不同,不过码风是一样的(
#include <iostream>
#include <stdio.h>
#include <math.h>
#include <algorithm>
#include <string.h>
#define Heriko return
#define Deltana 0
#define Romanno 1
#define S signed
#define LL long long
#define R register
#define I inline
#define CI const int
#define mst(a, b) memset(a, b, sizeof(a))
#define ON std::ios::sync_with_stdio(false);cin.tie(0)
using namespace std;
template<typename J>
I void fr(J &x)
{
short f(1);
char c=getchar();
x=0;
while(c<'0' or c>'9')
{
if(c=='-') f=-1;
c=getchar();
}
while (c>='0' and c<='9')
{
x=(x<<3)+(x<<1)+c-'0';
c=getchar();
}
x*=f;
}
template<typename J>
I void fw(J x,bool k)
{
if(x<0) putchar('-'),x=-x;
static short stak[35];
short top(0);
do
{
stak[top++]=x%10;
x/=10;
}
while(x);
while(top) putchar(stak[--top]+'0');
if(k) putchar('\n');
else putchar(' ');
}
引入
先来点轻松的,虽然目前我学到的莫队都很轻松愉快就是了。
何为莫队?
莫队算法是由莫涛提出的算法。在莫涛提出莫队算法之前,莫队算法已经在 Codeforces 的高手圈里小范围流传,但是莫涛是第一个对莫队算法进行详细归纳总结的人。莫涛提出莫队算法时,只分析了普通莫队算法,但是经过 OIer 和 ACMer 的集体智慧改造,莫队有了多种扩展版本。[1]
其实简单来说,莫队是一种优雅的暴力,是一种令人快乐的分块暴力(
众所周知,莫队是由莫涛大神提出的,一种
玄学毒瘤暴力骗分区间操作算法,它以简短的框架、简单易记的板子和优秀的复杂度闻名于世。[2]
而本篇又是讲的相对入门的东西,于是就更加轻松愉悦。
莫队
根据前面的引入我们也能知道莫队是一种区间操作算法,于是我们就来看一看它是如何区间操作的(
由于个人觉得莫队的讲解应当和例题相结合,于是下面我先围绕一道例题开讲 —— SP3267 DQUERY - D-query
普通莫队
以 SP3267 开讲(不过数据范围扩大一点)
题目简述
给出一个长度为 \(n\) 的数列 \(a\) 和 \(q\) 个询问。对于每个询问给出一个区间 \([l,r]\),要求这个区间里有多少不同的数字。
数据范围 \(1\le n,q,a_i\le 5\times10^5\)。
思路分析
因为前面说到莫队是一种优雅的暴力,所以我们先来看看暴力怎么做。
那么我们在不考虑时空复杂度的情况下,我们开一个桶 \(cnt\) 来记录数字是否出现,然后对于每一个询问从 \(l\) 到 \(r\) 暴力跑一边统计一下。
显然这个时间复杂度我们是跑不起的啊,于是下面来介绍优化一下怎么做。
我们考虑去枚举两个位置指针 \(l\) 和 \(r\),每次向询问的区间去移动,直到 \(l\) 和 \(r\) 的和要查询的区间完全重合。
当我们移动这两个指针的时候,对于每一个新位置 \(x\) ,判断一下 \(cnt_{a_x}\) 是否为 0,若是,说明这个数字之前还没出现过,记录的数的个数 \(sum\) 加一;同样,我们在移动的时候显然是要舍弃一些位置,对于这些从 \([l,r]\) 被删除的位置 \(y\),同样去判断她的 \(cnt_{a_y}\) 再减一之后是否为 0,若是的话,\(sum\) 也同样减一。
可以发现我们的复杂度已经小了很多,现在我们已经可以相对快速的统计相邻询问区间的答案了!
但是发现没有上面我实际上加粗了是相邻的区间才可以,因为我们一位一位的移动也需要时间啊QwQ
但是题目中给出的询问区间并不一定标号和区间都是相连的,所以可有可能会遇到如下图一样极限的情况:出题人很喜欢造这种数据,而且还很好造(
但是我们发现上面那道题并不是强制在线,也就是说我们可以把询问离线下来进行排序,但是如果只是按照左端点或右端点为关键字进行排序的话,我们只能优化一个指针的移动,另一个还是原来的样子。看上去这个复杂度已经无法优化了(
在这个时候,莫队算法的出现,给无数OIer带来了光明(雾)。[2]
下面就来说说如何用莫队进一步优化。
优化
实际上莫队在进行查询区间转移的时候采取的思路和上面所述的基本一直,简单来说就是,假如我们能够在移动指针的时候 \(O(1)\) 的去维护信息,(也就是刚才所说的添加和删除操作要求是 \(O(1)\) 的)那么这道题就能够用莫队来做,否则复杂度会炸掉的QxQ
预处理
莫队的第一步就是将询问区间离线下来进行预处理,以避免出现我们在上面出现的指针移动距离过长的问题。
莫队预处理的核心就是分块 + 排序。我们先把序列分为 \(\sqrt n\) 个块,并且将其用 \(1\) 到 \(\sqrt n\) 编号,然后将询问区间按照这个编号进行排序。
具体来说就是把在进行比较的时候,先按照左端点所在的块排序,然后把属于相同块的按照右端点排序。
不过这里有一个玄学的奇偶性排序,具体就是说左端点同在编号为奇数的块里面的询问区间,按照右端点升序排列,反之按照降序排列。
这样做的原理貌似就是在右指针跳完奇数块后在回跳的时候能够顺便把偶数快跳完,先跳偶数块也一样。
理论上主算法运行时间减半,实际情况有所偏差。(不过能优化得很爽就对了)[2]
于是我们就有了这些东西:
struct questions
{
int l,r,id;
bool operator < (const questions &x) const
{
if(l/sn!=x.l/sn) Heriko l<x.l;
if((l/sn)&1) Heriko r<x.r;
Heriko r>x.r;
}
}
(不过我个人习惯于写 \(cmp\) 函数而不是重载运算符)
然后我们就可以直接轻松愉悦的实现上面那个题了(
Code
CI NXX=3e4+5,QXX=2e5+5,MXX=1e6+5;
int q,n,sn,a[NXX],ans[QXX],cnt[MXX],now,l(1),r;
struct questions
{
int l,r,id;
bool operator < (const questions &x) const
{
if(l/sn!=x.l/sn) Heriko l<x.l;
if((l/sn)&1) Heriko r<x.r;
Heriko r>x.r;
}
}
ques[QXX];
I void Add(const int &x) {if(!(cnt[a[x]]++)) ++now;}
I void Del(const int &x) {if(!(--cnt[a[x]])) --now;}
S main()
{
fr(n);sn=sqrt(n);
for(R int i(1);i<=n;++i) fr(a[i]);
fr(q);
for(R int i(1);i<=q;++i) fr(ques[i].l),fr(ques[i].r),ques[i].id=i;
sort(ques+1,ques+1+q);
for(R int i(1);i<=q;++i)
{
while(l>ques[i].l) Add(--l);
while(l<ques[i].l) Del(l++);
while(r<ques[i].r) Add(++r);
while(r>ques[i].r) Del(r--);
ans[ques[i].id]=now;
}
for(R int i(1);i<=q;++i) fw(ans[i],1);
Heriko Deltana;
}
在切了这道题之后,小结一下普通莫队是如何运用和实现的。
预处理
利用分块和排序把指针跳询问区间的时间损耗有效降低,不过分块的大小实际上可以根据题目来定。
定策略
也就是说我们要根据题目所求的部分来定下来我们在添加和删除时需要维护的信息。
下面给几道题来简单介绍如何去定策略。
莉题一
思路简述
我们去开一个桶 \(cnt\) 记录当前颜色 \(i\) 出现了几次,然后用 \(sum\) 去记录有多少可行的配对方案。
那么显然我们添加元素就相当于是让 \(sum+(C_{cnt_i+1}^{2}-C_{cnt_i}^{2})\),删除元素就是 \(sum+(C_{cnt_i}^{2}-C_{cnt_i-1}^{2})\),然后本次询问答案即为 \(\dfrac{sum}{C_{r-l+1}^{2}}\)。
下面来把这个东西化简一下:
于是我们就有如下代码:
Code
CI MXX=50005;
int n,m,mx,co[MXX],cnt[MXX];
LL ann[MXX],ass[MXX],sum;//请不要误会,ann 和 ass 只是单纯的 ans 的变体,并没有其他意思(
struct Query
{
int l,r,id;
}
q[MXX];
I bool cmp(const Query &x,const Query &y)
{
if(x.l/mx != y.l/mx) Heriko x.l<y.l;
if((x.l/mx)&1) Heriko x.r<y.r;
Heriko x.r>y.r;
}
I void Add(const int &x) {sum+=(cnt[x]++);}
I void Del(const int &x) {sum-=(--cnt[x]);}
I LL Gcd(LL x,LL y) {Heriko !y?x:Gcd(y,x%y);}
I void Pre()
{
fr(n),fr(m);
mx=sqrt(n);
for(R int i(1);i<=n;++i) fr(co[i]);
for(R int i(1);i<=m;++i) fr(q[i].l),fr(q[i].r),q[i].id=i;
sort(q+1,q+1+m,cmp);
}
S main()
{
Pre();
for(R int i(1),l(1),r(0);i<=m;++i)
{
if(q[i].l==q[i].r) {ann[q[i].id]=0;ass[q[i].id]=1;continue;}
while(l>q[i].l) Add(co[--l]);
while(r<q[i].r) Add(co[++r]);
while(l<q[i].l) Del(co[l++]);
while(r>q[i].r) Del(co[r--]);
ann[q[i].id]=sum;
ass[q[i].id]=(LL)(r-l+1)*(r-l)/2;
}
LL G;
for(R int i(1);i<=m;++i)
{
if(ann[i])
{
G=Gcd(ann[i],ass[i]);
ann[i]/=G,ass[i]/=G;
}
else ass[i]=1;
fw(ann[i],0),putchar('/'),fw(ass[i],1);
}
Heriko Deltana;
}
莉题二
思路简述
因为要统计一个数字在当前询问区间中出现的次数,所以我们还是要开一个桶 co
。
不知道我怎么想的这几个题命名一样的数组和变量作用完全不一样(
因为我们在添加或者删除元素的时候只会对一个数的平方有影响,于是我们考虑 \((x+1)^2,(x-1)^2\) 和 \(x^2\) 的关系。
我们可以用初中知识得到:
于是我们就能知道,在每次添加一个 \(x\) 的时候答案 \(cnt\) 增加 \(2x+1\),删除的时候减少 \(2x-1\)。
于是我们就有如下代码:
Code
CI MXX=50005;
int n,m,a[MXX],mx,k;
LL ans[MXX],co[MXX],cnt;//请不要搞混了,这里的 co 和 cnt 都与莉题一的定义不一样
struct Query
{
int l,r,id;
}
q[MXX];
I bool cmp(const Query &x,const Query &y)
{
if(x.l/mx!=y.l/mx) Heriko x.l<y.l;
if((x.l/mx)&1) Heriko x.r<y.r;
Heriko x.r>y.r;
}
I void Add(const int &x) {cnt+=(co[x])*2+1;++co[x];}
I void Del(const int &x) {cnt-=(co[x])*2-1;--co[x];}
I void Pre()
{
fr(n),fr(m),fr(k);mx=sqrt(n);
for(R int i(1);i<=n;++i) fr(a[i]);
for(R int i(1);i<=m;++i) fr(q[i].l),fr(q[i].r),q[i].id=i;
sort(q+1,q+1+m,cmp);
}
S main()
{
Pre();
int l(1),r(0);
for(R int i(1);i<=m;++i)
{
while(l<q[i].l) Del(a[l++]);
while(l>q[i].l) Add(a[--l]);
while(r<q[i].r) Add(a[++r]);
while(r>q[i].r) Del(a[r--]);
ans[q[i].id]=cnt;
}
for(R int i(1);i<=m;++i) fw(ans[i],1);
Heriko Deltana;
}
带修莫队
还是老传统,我随着一个莉题来讲一讲(
题意简述
给你一个长度为 \(n\) 的序列 \(a\),有 \(m\) 个操作。
共用有两种操作:
-
\(\texttt{Q}\ l\ r\) 操作,表示询问区间 \([l,r]\) 中有多少不相同的数。
-
\(\texttt{R}\ pos\ col\) 操作,把位置为 \(pos\) 的数字改为 \(col\)。
思路简述
前面提到了,莫队能用的前提是你得能离线,强制在线莫队一点法没有(
但是这道题不强制在线,虽然是有修改,但莫队也是可以稍作修改来大展拳脚的。我们在基础的普通莫队上加上一维表示时间。
也就是说我们又加了个指针 \(t\) ,在来回的跳动修改,于是我们的移动方向从 \([l,r+1],[l,r-1],[l-1,r],[l+1,r]\) 这四个方向扩展到了 \([l,t+1,r],[l,t-1,r],[l+1,t,r],[l-1,t,r],[l,t,r-1],[l,t,r+1]\) 这六个方向(
于是我们的 \(cmp\) 就变成了这个样子:
I bool cmp(const Query &a,const Query &b)
{
Heriko (belong[a.l]^belong[b.l]) ? belong[a.l]<belong[b.l]:((belong[a.r]^belong[b.r]) ? belong[a.r]<belong[b.r]:a.ti<b.ti);
}
形象一点就是(排序之前):
(我也不知道画的能不能看懂,但是大约就是把询问区间离线成了这样的东西QxQ)
其实修改操作基本是一致的,不过有个小优化,即把原值暂存一下备用,由于本人没有很搞懂,于是就引用一下 WAMonster 的描述罢(
但其实我们也可以不存,只要在修改后把修改操作的值和原值 swap 一下,那么改回来时也只要 swap 一下, swap 两次相当于没搞,就改回来了 qwq [2]
前面还说到了一点,分块的大小其实是不一定为 \(\sqrt n\) 的,比如这道题就是,这道题的块的大小应取 \(n^{\frac{2}{3}}\) 以得到总体 \(O(n^\frac{5}{3})\) ,本人不会证明,但是能够得到这样是要比总体复杂度为 \(O(n^2)\) 的 \(\sqrt n\) 要优的。
Code
CI QXX=1e6+5,MXX=5e5+5;
int a[MXX],cnt[QXX],ans[MXX],belong[MXX];
struct Query
{
int l,r,id,ti;
}
q[MXX];
struct Modify
{
int pos,co,lst;
}
c[MXX];
int cnq,cnc,n,m,sz,blockn,l(1),r,tim,now;
I bool cmp(const Query &a,const Query &b)
{
Heriko (belong[a.l]^belong[b.l]) ? belong[a.l]<belong[b.l]:((belong[a.r]^belong[b.r]) ? belong[a.r]<belong[b.r]:a.ti<b.ti);
}
I void pre()
{
fr(n),fr(m);
sz=pow(n,2.0/3.0);
blockn=ceil((double)n/sz);
for(R int i(1);i<=blockn;++i)
for(R int j=(i-1)*sz+1;j<=i*sz;++j)
belong[j]=i;
for(R int i(1);i<=n;++i) fr(a[i]);
char opts[5];
for(R int i(1);i<=m;++i)
{
scanf("%s",opts);
if(opts[0]=='Q') fr(q[++cnq].l),fr(q[cnq].r),q[cnq].id=cnq,q[cnq].ti=cnc;
else if(opts[0]=='R') fr(c[++cnc].pos),fr(c[cnc].co);
}
sort(q+1,q+1+cnq,cmp);
}
S main()
{
pre();
for(R int i(1);i<=cnq;++i)
{
while(l<q[i].l) now-=!--cnt[a[l++]];
while(l>q[i].l) now+=!cnt[a[--l]]++;
while(r<q[i].r) now+=!cnt[a[++r]]++;
while(r>q[i].r) now-=!--cnt[a[r--]];
while(tim<q[i].ti)
{
++tim;
if(q[i].l<=c[tim].pos and c[tim].pos<=q[i].r) now-=!(--cnt[a[c[tim].pos]])-!(cnt[c[tim].co]++);
swap(a[c[tim].pos],c[tim].co);
}
while(tim>q[i].ti)
{
if(q[i].l<=c[tim].pos and c[tim].pos<=q[i].r) now-=!(--cnt[a[c[tim].pos]])-!(cnt[c[tim].co]++);
swap(a[c[tim].pos],c[tim].co);
--tim;
}
ans[q[i].id]=now;
}
for(R int i(1);i<=cnq;++i) fw(ans[i],1);
Heriko Deltana;
}
因为我光把洛谷上紫以下的莫队做了,于是带修莫队就没什么莉题了(
尾声
于是就差不多结束了罢,好久没有在一篇文章里写过这么多莉题了(
参考资料
-
[1] 莫队算法简介 —— OI-Wiki
-
[2] 莫队算法——从入门到黑题 —— WAMonster
-
[3] 普通莫队算法 —— OI-Wiki