莫队算法详解
简介
莫队算法是由前国家队队长莫涛总结的一种可以解决一类离线区间询问问题,适用性极为广泛的算法。
这篇文章会讲解目前 OI 竞赛中比较常见的莫队算法类型。
大体思路
从一道例题讲起:
Luogu P3901 数列找不同
给定长度为 \(n\) 的序列 \(a\),\(m\) 次询问 \(l,r\),询问区间里的数是否互不相同。
\(1 \le n,m,a_i \le 10^5\)
首先我们思考暴力怎么搞:每次暴力枚举 \([l,r]\),看是否有两个数相同。
然后我们考虑优化这个算法:假设我们已经有了 \([l,r]\) 的答案,那我们是否可以在 \(\Theta(1)\) 的复杂度里得到 \([l,r+1],[l+1,r],[l,r-1],[l-1,r]\) 的答案呢?
答:显然可以。
所以我们有了一个看似比较优秀的做法,对于每次询问,我们维护两个指针 \(l,r\),每次的答案由上一次的答案通过加入或者删除某些元素得到。
也就是这样子:
while(l < L) del(l++);
while(l > L) add(--l);
while(r < R) add(++r);
while(r > R) del(r--);
//L,R表示询问区间,l,r表示指针所在的区间
但其实这个算法本质上仍然是 \(\Theta(n^2)\) 的,比如如下数据:
\([1,2],[99999,100000],[1,2],[99999,100000]\cdots\)
这个算法的缺陷是什么呢?
我们会发现它出现了一个指针反复横跳的情况。
所以我们接下来的思路就是通过 合理安排询问顺序 ,来避免这样的情况。
怎样的情况是最优的呢?
我们把每个询问抽象成平面直角坐标系上的一个点(横坐标为 \(l\),纵坐标为 \(r\)),则指针移动的最短距离就是曼哈顿距离最短路径。
如图所示:
但是在实际操作中,求出曼哈顿距离最短路径难度较大。所以一些高人想出了一个折中的方法:
将序列分成 \(S\) 块,对于每个询问,按照其左端点所在块为第一关键字,右端点为第二关键字升序排序。
我们来分析一下复杂度:对于每一块内的询问,设其个数为 \(q\),则 \(l\) 指针在块内最多移动 \(q \dfrac n S\) 次,右端点最多移动 \(n\) 次,所以总复杂度就是 \(\sum q \dfrac n S + nS = \dfrac {mn} S + nS\)。
即当 \(S = \sqrt m\) 时有最优解,则单块块长为 \(\dfrac n S = \dfrac n {\sqrt m}\)。
时间复杂度为 \(\Theta(n\sqrt m)\)
可以通过此题。
接下来给出代码:
int a[N],cnt[N],Ans[N],bl[N],n,m,ans,siz,l = 1,r = 0;
struct Q{int l,r,id;}q[N];
inline void add(int x){
if(++cnt[a[x]] == 1) ans++;
}
inline void del(int x){
if(--cnt[a[x]] == 0) ans--;
}
int main(int argc,const char *argv[]){
//freopen("1.in","r",stdin);
//freopen("2.out","w",stdout);
n = read(),m = read(),siz = sqrt(n);
for(ri i = 1;i <= n;++i) a[i] = read(),bl[i] = (i - 1)/siz + 1;
for(ri i = 1;i <= m;++i) q[i].l = read(),q[i].r = read(),q[i].id = i;
sort(q + 1,q + 1 + m,[](Q a,Q b){return bl[a.l] ^ bl[b.l] ? a.l < b.l : bl[a.l] & 1 ? a.r < b.r : a.r > b.r;});//排序方法,这里是奇偶性排序,一会介绍。
for(ri i = 1;i <= m;++i){
while(l < q[i].l) del(l++);
while(l > q[i].l) add(--l);
while(r < q[i].r) add(++r);
while(r > q[i].r) del(r--);
Ans[q[i].id] = ans == (r - l + 1);
}
for(ri i = 1;i <= m;++i){
if(Ans[i]) write("Yes\n");
else write("No\n");
}
return flush(),0;
}
这道题有复杂度为 \(\Theta(n)\) 的算法,这里不再赘述。
接下来介绍奇偶性排序:
我们考虑莫队的端点在跨块时的移动:在前一块的时候我们的 \(r\) 端点在一个比较靠右的位置,而在下一块时 \(r\) 端点又要跳到一个相对靠左的位置,产生了一些无效移动,所以可以考虑用上述代码所示的排序方法,可以自己手模一下。当然这只会减小常数,并不会改变复杂度。
Luogu P2709 小B的询问
有一个长为 \(n\) 的整数序列 \(a\),值域为 \([1,k]\)。
他一共有 \(m\) 个询问,每个询问给定一个区间 \([l,r]\),求:
其中 \(c_i\) 表示数字 \(i\) 在 \([l,r]\) 中的出现次数。
维护区间每个数的出现次数,每次转移的时候考虑答案的变化量即可,建议自己手推一下,答案在代码里。
struct query{
int l,r,id,pos;
friend bool operator < (query a,query b){return (a.pos == b.pos) ? a.r < b.r : a.l < b.l;}
}s[N];
int a[N],n,m,k;
ll cnt[N],Ans[N];
int main(int argc,const char *argv[]){
ios::sync_with_stdio(false);
n = read(),m = read(),k = read();
int siz = sqrt(n);
for(ri i = 1;i <= n;++i) a[i] = read();
for(ri i = 1;i <= m;++i){
s[i].l = read(),s[i].r = read(),s[i].id = i;
s[i].pos = (s[i].l-1) / siz + 1;
}
sort(s+1,s+1+m);
int l = 1,r = 0,ans = 0;
for(ri i = 1;i <= m;++i){
while(l > s[i].l) --l,cnt[a[l]]++,ans += 2*cnt[a[l]] - 1;//每次加入的时候答案会变化 (x+1)^2 - x^2
while(r < s[i].r) ++r,cnt[a[r]]++,ans += 2*cnt[a[r]] - 1;
while(l < s[i].l) cnt[a[l]]--,ans -= 2*cnt[a[l]] + 1,++l;//每次删除的时候答案会变化 x^2-(x-1)^2
while(r > s[i].r) cnt[a[r]]--,ans -= 2*cnt[a[r]] + 1,--r;
Ans[s[i].id] = ans;
}
for(ri i = 1;i <= m;++i) print(Ans[i]),putc('\n');
return flush(),0;
}
顺便说一句,这种奇奇怪怪的区间询问大多都可以莫队来搞。
莫队的核心在于快速的加入和删除一个元素
例题:
简单的容斥技巧即可。
带修莫队
莫队也可以支持简单的修改操作,例如单点修改。
实现是通过加一维时间轴,维护在这次询问之前经历了多少次修改即可,对于每次的指针移动,我们先移动时间轴,移动到它所对应的位置。
排序方法则变为:按左端点所在块为第一关键字,右端点所在块为第二关键字,时间轴为第三关键字排序。
时间复杂度:块长取 \(n^{\frac 2 3}\) 最优,为 \(\Theta(n^{\frac 5 3})\)。
Luogu P1903 数颜色/维护队列
给定序列,支持单点修改,查区间不同颜色数。
就跟上面讲的一样,我们建立一维时间轴,细节见代码。
int a[N],pos[N],Ans[N],cnt[A],lst[N],n,m,cq,cm,l,r,K,ans,siz;
struct Q{int l,r,id,k;}q[N];
struct M{int num,bf,to;}mo[N];
int main(int argc,const char *argv[]){
ios::sync_with_stdio(false);
n = read(),m = read(),siz = pow(n,2.0/3);
for(ri i = 1;i <= n;++i) lst[i] = a[i] = read(),pos[i] = (i - 1)/siz + 1;
for(ri i = 1;i <= m;++i){
char opt = getc();
while(!isalnum(opt)) opt = getc();
if(opt ^ 'R') q[++cq].l = read(),q[cq].r = read(),q[cq].k = cm,q[cq].id = cq;
else mo[++cm].num = read(),mo[cm].to = read(),mo[cm].bf = lst[mo[cm].num],lst[mo[cm].num] = mo[cm].to;
}
sort(q + 1,q + 1 + cq,[](Q a,Q b){return (pos[a.l] == pos[b.l]) ? (pos[a.r] == pos[b.r]) ? a.k < b.k : pos[a.r] < pos[b.r] : pos[a.l] < pos[b.l];});//排序
for(ri i = 1;i <= cq;++i){
while(K < q[i].k){
++K;
if(l <= mo[K].num && mo[K].num <= r){
if(!--cnt[a[mo[K].num]]) --ans;
if(!cnt[mo[K].to]++) ++ans;
}
a[mo[K].num] = mo[K].to;
}
while(K > q[i].k){
if(l <= mo[K].num && mo[K].num <= r){
if(!--cnt[a[mo[K].num]]) --ans;
if(!cnt[mo[K].bf]++) ++ans;//修改对当前区间答案产生影响则修改答案。
}
a[mo[K].num] = mo[K].bf,--K;
}//先移动时间轴,将其移动到询问所对应的时间上。
while(r<q[i].r) if(!cnt[a[++r]]++) ++ans;
while(l>q[i].l) if(!cnt[a[--l]]++) ++ans;
while(r>q[i].r) if(!--cnt[a[r--]]) --ans;
while(l<q[i].l) if(!--cnt[a[l++]]) --ans;
Ans[q[i].id]=ans;
}
for(ri i = 1;i <= cq;++i) print(Ans[i]),putc('\n');
return flush(),0;
}
莫队套bitset
莫队的可扩展性是非常强的,这里仅以莫队套 bitset
举例。
什么是 bitset
呢?
bitset
类似于数组,但是每一位的取值只有 \(0,1\),同时每一位仅占用 \(1 \text{bit}\) 空间,故而常数也比较小,时间复杂度为 \(\Theta(\frac n w)\)(其中 \(w\) 为计算机位数)。
bitset
的使用可以参照 OI Wiki 对 bitset
的介绍
Luogu P3674 小清新人渣的本愿
给你一个序列 \(a\),长度为 \(n\),有 \(m\) 次操作,每次询问一个区间是否可以选出两个数它们的差为 \(x\),或者询问一个区间是否可以选出两个数它们的和为 \(x\),或者询问一个区间是否可以选出两个数它们的乘积为 \(x\) ,这三个操作分别为操作 \(1,2,3\)。
选出的这两个数可以是同一个位置的数。
\(1 \leq n,m,a_i \le 10^5\)
先考虑 \(1\) 操作
一个很朴素的思路是维护每一个数的出现次数,然后枚举减数 \(z\),查看 \(z\) 和 \(z+x\) 是否都出现。
可惜这样子是 \(\Theta(n)\) 的。
所以我们可以考虑用 bitset
优化:\(now_x\) 表示 \(x\) 是否出现,则检验是否有两个数差为 \(x\) 只需要检验 now&(now<<x)
是否有 \(1\) 即可。
对于 \(2\) 操作,我们可以将其转化为减法,这里不再赘述。
对于 $3 $ 操作,我们可以暴力枚举因数,时间复杂度不超过 \(\Theta(\sqrt x)\)。
这样子这个问题就解决了:时间复杂度 \(\Theta(m\sqrt n+\frac {mn} w)\)
代码如下:
constexpr int inf = 0x3f3f3f3f,N = 1e5 + 10,siz = 450;
int bl[N],a[N],n,m,cnt[N];
bool Ans[N];
struct Q {
int op,l,r,x,id;
Q(){op=l=r=x=id=0;}
Q(int _op,int _l,int _r,int _x,int _id){op=_op,l=_l,r=_r,x=_x,id=_id;}
friend bool operator < (Q a,Q b){return bl[a.l] ^bl[b.l] ? a.l < b.l : bl[a.l]&1 ? a.r < b.r:a.r > b.r;}
}q[N];
std::bitset <N> rev,now;
inline void add(const int x) {
int tmp = a[x];
cnt[tmp]++,cnt[tmp] == 1 ? now[tmp] = 1,rev[n-tmp] = 1 : 0;
}
inline void del(const int x) {
int tmp = a[x];
cnt[tmp]--,!cnt[tmp] ? now[tmp] = 0,rev[n-tmp] = 0:0;
}
int main() {
read(n,m);
for(ri i = 1;i <= n;++i) read(a[i]),bl[i] = (i-1)/siz + 1;
for(ri i = 1;i <= m;++i) {
int opt,l,r,x;
read(opt,l,r,x);
q[i] = Q(opt,l,r,x,i);
}
int l = 1,r = 0;
std::sort(q+1,q+1+m);
for(ri i = 1;i <= m;++i) {
while(l < q[i].l) del(l++);
while(l > q[i].l) add(--l);
while(r < q[i].r) add(++r);
while(r > q[i].r) del(r--);
int op = q[i].op,x = q[i].x;
if(op == 1) {
if((now&(now<<x)).any()) Ans[q[i].id] = 1;//any函数:若 bitset 内有 1,则为 1,否则为 0
} else if(op == 2) {
if((now&(rev>>n-x)).any()) Ans[q[i].id] = 1;
} else {
for(ri j = 1;j <= sqrt(x);++j) {
if(x%j == 0 && now[j] && now[x/j]) {
Ans[q[i].id] = 1;
break;
}
}
}
}
for(ri i = 1;i <= m;++i){
if(Ans[i]) putc('h'),putc('a'),putc('n'),putc('a');
else putc('b'),putc('i');
putc('\n');
}
return IO::flush(),0;
}