莫队算法学习笔记
【前言】
莫队算法是基于对询问进行分块的离线算法,由提出者的名字命名。
都说分块是优雅的暴力,莫队显然也不是例外。
如果一个区间问题能在 \(O(1)\sim O(\log n)\) 的时间内扩展 \(1\) 个长度,且不强制在线,那么可以尝试莫队。
【前置芝士】
- 基础分块思想。(或许你可以看看这篇文章)
- 模拟。
各种卡常。
可以看出莫队算法还是相对比较独立的算法,不需要过多的知识基础。
【普通莫队】
【梦开始的地方】
莫队算法的起源就是 这道题。
给定一个长度为 \(n\) 的序列,每次查询 \([l,r]\) 中的数 两两组队时 数字相等的对 的数量。
考虑暴力做法:
- 每次直接遍历一遍区间,\(O(n^2)\),没什么可优化的了。
- 首先暴力处理第一个区间询问,之后每次询问相应的移动左右端点,在移动时顺便 \(O(1)\) 修改答案。
第二个优化看上去很厉害的样子,可惜如果区间跨度很大,时间还是 \(O(n^2)\) 甚至更大的。
那么能否把询问离线下来,然后以某种特定的顺序进行查询,保证左右端点的跳度没那么大呢。
于是莫队算法诞生了。
【主要思想】
我们可以将左端点升序排序,分成 \(\sqrt n\) 块,块内再按照右端点排序。
这有什么用呢,看看时间复杂度变化:(假设询问次数与序列长度同级)
首先左端点在同一块中,每次最大移动长度为 \(\sqrt n\),\(n\) 次询问最多移动 \(n\sqrt n\) 次。
然后对于每个块,右端点递增排序,最多移动 \(n\) 次,共有 \(\sqrt n\) 个块,总共移动次数也最多是 \(n\sqrt n\)。
本题中每次移动都是 \(O(1)\) 的,所以总时间复杂度为 \(O(n\sqrt n)\)。
这样就保证了莫队算法的时间复杂度为 \(O(n\sqrt n)\)。
关键主要思想它就这么多。
【代码实现】
首先我们要对询问进行分块和排序,其实非常简单,基本代码结构长这样:
bool cmp(Query a, Query b){
return (bl[a.l] ^ bl[b.l]) ? bl[a.l] < bl[b.l] : a.r < b.r;
}
n = read(), m = read(), block = sqrt(n);
for(int i=1; i<=n; i++){
a[i] = read();
bl[i] = (i-1) / block + 1;
}
for(int i=1; i<=m; i++){
q[i].l = read(), q[i].r = read();
q[i].id = i;
}
sort(q+1, q+m+1, cmp);
然后就是每次暴力移动 l,r
两端点。
int l = 1, r = 0;
for(int i=1; i<=m; i++){
int ql = q[i].l, qr = q[i].r, id = q[i].id;
while(l < ql) Del(a[l++]);
while(l > ql) Add(a[--l]);
while(r < qr) Add(a[++r]);
while(r > qr) Del(a[r--]);
// bla,bla,bla...
}
值得注意的是,我们初始化时令 l = 1, r = 0
,这是为什么呢。
在描述暴力做法 \(2\) 时我们提到过
首先暴力处理第一个区间询问
。但是如果真的单独写一个预处理貌似很麻烦,那么我们就利用初始化来避免这一点。
我们的目标是:特别构造初始化,使首次移动后就得到第一个区间的答案。
首先考虑令 \(r=0\),这样 \(r\) 就会一直扫过去,遍历区间 \(1\sim qr\) 并增添答案。
然后显然我们需要删去区间 \(1\sim ql-1\) 的答案,于是令 \(l=1\) 即可,结合上方代码不难看出其正确性。
然后给出本题的全部代码(之后的题目就只给出部分主要代码):
#include<cstdio>
#include<cstring>
#include<algorithm>
#include<cmath>
using namespace std;
typedef long long LL;
const int N = 50010;
int n, m, block;
int a[N], cnt[N], bl[N];
LL sum, ans1[N], ans2[N];
struct Query{int l, r, id;} q[N];
int read(){
int x=0,f=1;char c=getchar();
while(c<'0' || c>'9') f=(c=='-')?-1:1,c=getchar();
while(c>='0' && c<='9') x=x*10+c-48,c=getchar();
return x*f;
}
bool cmp(Query a, Query b){
return (bl[a.l] ^ bl[b.l]) ? bl[a.l] < bl[b.l] : (bl[a.l] & 1) ? a.r < b.r : a.r > b.r;
}
void Add(int x){
cnt[x] ++;
if(cnt[x] > 1){
sum += 1LL * cnt[x] * (cnt[x] - 1);
sum -= 1LL * (cnt[x] - 1) * (cnt[x] - 2);
}
}
void Del(int x){
cnt[x] --;
if(cnt[x] > 0){
sum += 1LL * cnt[x] * (cnt[x] - 1);
sum -= 1LL * cnt[x] * (cnt[x] + 1);
}
}
LL Gcd(LL x, LL y){
while(y) {LL z = x; x = y; y = z % y;}
return x;
}
int main(){
n = read(), m = read(), block = sqrt(n);
for(int i=1; i<=n; i++){
a[i] = read();
bl[i] = (i-1) / block + 1;
}
for(int i=1; i<=m; i++){
q[i].l = read(), q[i].r = read();
q[i].id = i;
}
sort(q+1, q+m+1, cmp);
int l = 1, r = 0;
for(int i=1; i<=m; i++){
int ql = q[i].l, qr = q[i].r, id = q[i].id;
while(l < ql) Del(a[l++]);
while(l > ql) Add(a[--l]);
while(r < qr) Add(a[++r]);
while(r > qr) Del(a[r--]);
LL now = 1LL * (qr - ql + 1) * (qr - ql);
if(!now) {ans1[id] = 0; ans2[id] = 1; continue;}
LL G = Gcd(now, sum);
ans1[id] = sum / G;
ans2[id] = now / G;
}
for(int i=1; i<=m; i++)
printf("%lld/%lld\n", ans1[i], ans2[i]);
return 0;
}
【小结】
前面解释过什么情况下可以使用莫队算法,那么莫队算法在实现时的主要难点是什么呢。
其实看出是莫队的题之后往往变得十分套路,唯一需要考虑的是如何 \(O(1)\) 转移。(也就是上面的 Add
和 Del
)
所以在尝试实现代码之前,一定要将转移的所有细节想清楚,借此判断这道题是否适合莫队。
【独特的优化】
其实就是卡常。
-
奇偶性排序。
主要可以参考上面的代码的
cmp
函数写法。就是
r
在奇数块递增排序,偶数块递减排序,从而优化复杂度。原理是奇数块递增之后
r
会很大,如果偶数快递减的话正好从大变小,到了下一个奇数块又从小到大。这样保证了每一步都是单调的,理论上可以时间减半,实际有所偏差,但是优化很大。
值得注意的是这个优化会破坏全局
r
的单调性,所以有时可能不太能用。主要用在普通莫队和树上莫队中吧。
-
分块大小。
有的时候 \(\sqrt n\) 的分块大小可能并不是最优的,可以证明 \(n^{\frac{2}{3}}\) 有时更快。(
部分基于评测机心情)具体方法就是将
block = sqrt(n)
变为block = pow(n, 2.0 / 3.0)
。主要用于带修莫队的优化。
【带修莫队】
每次询问一个区间中有多少个不同的数,支持单点修改。
【主要思想】
莫队似乎不太能支持修改呢。
其实可以引入另一个概念——时间轴。
我们以这次询问之前最后一次修改的编号作为这次询问的时间。
那么每次除了左右端点的移动外,还要考虑时间的移动,实际并不麻烦多少。
本质上就是:增添一维的莫队。
【代码实现】
注意块的大小最好取 \(n^{\frac{2}{3}}\),那么总时间复杂度 \(O(n^{\frac{5}{3}})\)。
或许你发现块取 \(\sqrt n\) 时的 \(O(n^{\frac{3}{2}})\) 更优,可惜它有可能被卡成 \(O(n^2)\),证明我也不会。
而且注意排序时三个关键字的顺序,然后最好不要用奇偶性排序。
还有一个重要的小技巧:
每次修改时将修改的颜色与当前颜色
swap
一下,这样避免了不必要的讨论,减低代码实现复杂度。
const int N = 2000000;
int n, m, sum, block;
int cntq, cntc;
int a[N], bl[N], ans[N], cnt[N];
struct Query{int l, r, id, tim;} q[N];
struct Modify{int pos, col;} c[N];
void Add(int x){sum += !cnt[x]++;}
void Del(int x){sum -= !--cnt[x];}
bool cmp(Query a, Query b){
return (bl[a.l] ^ bl[b.l]) ? bl[a.l] < bl[b.l] : ((bl[a.r] ^ bl[b.r]) ? bl[a.r] < bl[b.r] : a.tim < b.tim);
}
int main(){
n = read(), m = read();
block = pow(n, 2.0 / 3.0);
for(int i=1; i<=n; i++){
a[i] = read();
bl[i] = (i-1) / block + 1;
}
cntq = cntc = 0;
char str[5];
for(int i=1; i<=m; i++){
scanf("%s", str);
if(str[0] == 'Q'){
q[++cntq].l = read();
q[cntq].r = read();
q[cntq].tim = cntc;
q[cntq].id = cntq;
}else{
c[++cntc].pos = read();
c[cntc].col = read();
}
}
sort(q+1, q+cntq+1, cmp);
int l = 1, r = 0, tim = 0;
sum = 0;
for(int i=1; i<=cntq; i++){
int ql = q[i].l, qr = q[i].r;
int qt = q[i].tim, id = q[i].id;
while(l < ql) Del(a[l++]);
while(l > ql) Add(a[--l]);
while(r < qr) Add(a[++r]);
while(r > qr) Del(a[r--]);
while(tim < qt){
tim ++;
if(ql <= c[tim].pos && c[tim].pos <= qr) Del(a[c[tim].pos]), Add(c[tim].col);
swap(c[tim].col, a[c[tim].pos]);
}
while(tim > qt){
if(ql <= c[tim].pos && c[tim].pos <= qr) Del(a[c[tim].pos]), Add(c[tim].col);
swap(c[tim].col, a[c[tim].pos]);
tim --;
}
ans[id] = sum;
}
for(int i=1; i<=cntq; i++) printf("%d\n", ans[i]);
return 0;
}
【树上莫队】
给定 \(n\) 个结点的树,每个结点有一种颜色。
\(m\) 次询问,每次询问给出 \(u,v\),回答 \(u,v\) 之间的路径上的结点的不同颜色数。
【主要思想】
其实和树剖的思想差不多,我们都需要将树上问题转化为序列问题。
(其实还有一种真的在树上跑莫队的 真·树上莫队 算法,太过神仙,可以看这里)
可惜单纯的 DFS 序不太能满足我们的要求(或许你可以尝试一下在 DFS 序上标记处任意两点之间的路径)
所以引入欧拉序(括号序)这个概念:
DFS 遍历一棵树,遍历到一个节点时将其加入序列,回溯时再次加入序列,就得到了这棵树的欧拉序。
不难发现欧拉序列具有以下性质:
树的欧拉序上两个相同编号(设为 \(x\))之间的所有编号都出现两次,且都位于 \(x\) 的子树上。
这样,如果得到了欧拉序,我们可以将树上路径转化为序列上的一段区间,方法如下:
假设当前树上两节点为 \((u,v)\),得到他们在欧拉序中第一次和第二次出现的下标。
本别计为 \(\rm st(u),ed(u),st(v),ed(v)\)(如果 \(\rm st(u) > st(v)\) 就交换一下 \(\rm u,v\))
同时假设 \(\rm lca = lca(u,v)\),我们分类讨论一下:
若 \(\rm lca = u\),那么 \(\rm v\) 是 \(\rm u\) 的直接子节点,\(\rm st(u)\sim st(v)\) 即构成路径。
若 \(\rm lca\neq u\),那么两点的路径可以分为 \(\rm u\sim lca\) 和 \(\rm lca \sim v\) 两段,在序列上的表现为 \(\rm ed(u)\sim st(v)\)。
同时这一段是不包含 \(\rm lca\) 的,还要特别处理 \(\rm lca\) 的贡献。
在上述路径中,出现两次的节点需要忽略。(它们相当于其它子树的节点,并不构成路径)
这样我们就将树上问题转化为了区间问题。
【代码实现】
如果出现两次就要忽略那个节点,具体实现方式可以利用一个数组不断异或,为 \(1\) 就加,为 \(0\) 就减。
这样就完美避免了奇偶性的讨论,简化代码实现。
这里用的是树剖求 \(\rm lca\),其它实现方式也是 OK 的。
const int N = 100010;
int n, m, tot, sum;
int a[N], b[N], bl[N], ans[N], num[N];
int St[N], Ed[N], pos[N];
int head[N], cnt;
bool vis[N];
struct Edge{int nxt, to;} ed[N];
struct Query{int l, r, lca, id;} q[N];
struct LCA{
int fa[N], dep[N], sz[N], son[N];
void dfs1(int u, int Fa){
fa[u] = Fa; dep[u] = dep[Fa] + 1;
pos[++tot] = u, St[u] = tot;
sz[u] = 1;
for(int i=head[u]; i; i=ed[i].nxt){
int v = ed[i].to;
if(v == Fa) continue;
dfs1(v, u);
sz[u] += sz[v];
if(sz[v] > sz[son[u]]) son[u] = v;
}
pos[++tot] = u, Ed[u] = tot;
}
int top[N];
void dfs2(int u, int Top){
top[u] = Top;
if(!son[u]) return;
dfs2(son[u], Top);
for(int i=head[u]; i; i=ed[i].nxt){
int v = ed[i].to;
if(v == fa[u] || v == son[u]) continue;
dfs2(v, v);
}
}
int Lca(int x, int y){
while(top[x] != top[y]){
if(dep[top[x]] < dep[top[y]]) swap(x, y);
x = fa[top[x]];
}
if(dep[x] > dep[y]) swap(x, y);
return x;
}
void build(){dfs1(1, 0); dfs2(1, 1);}
} T;
void add(int u, int v){
ed[++cnt] = (Edge){head[u], v};
head[u] = cnt;
}
void Init(){
n = read(), m = read();
for(int i=1; i<=n; i++) a[i] = b[i] = read();
sort(b+1, b+n+1);
int k = unique(b+1, b+n+1) - (b+1);
for(int i=1; i<=n; i++)
a[i] = lower_bound(b+1, b+k+1, a[i]) - b;
for(int i=1; i<n; i++){
int u = read(), v = read();
add(u, v), add(v, u);
}
T.build();
}
bool cmp(Query a, Query b){
return (bl[a.l] ^ bl[b.l]) ? bl[a.l] < bl[b.l] : (bl[a.l] & 1) ? a.r < b.r : a.r > b.r;
}
void Modify(int x){
if(vis[x]) sum -= !--num[a[x]];
else sum += !num[a[x]]++;
vis[x] ^= 1;
}
int main(){
Init();
int block = sqrt(n);
for(int i=1; i<=tot; i++)
bl[i] = (i-1) / block + 1;
for(int i=1; i<=m; i++){
int u = read(), v = read();
int Lca = T.Lca(u, v);
if(St[u] > St[v]) swap(u, v);
if(u == Lca) q[i].l = St[u], q[i].r = St[v];
else q[i].l = Ed[u], q[i].r = St[v], q[i].lca = Lca;
q[i].id = i;
}
sort(q+1, q+m+1, cmp);
int l = 1, r = 0; sum = 0;
for(int i=1; i<=m; i++){
int ql = q[i].l, qr = q[i].r;
int lca = q[i].lca, id = q[i].id;
while(l < ql) Modify(pos[l++]);
while(l > ql) Modify(pos[--l]);
while(r < qr) Modify(pos[++r]);
while(r > qr) Modify(pos[r--]);
if(lca) Modify(lca);
ans[id] = sum;
if(lca) Modify(lca);
}
for(int i=1; i<=m; i++) printf("%d\n", ans[i]);
return 0;
}
【回滚莫队】
【主要思想】
莫队算法的关键就在于如何进行区间的转移,这就可能涉及到很多的细节。
有一类普通莫队不可解的问题就是在转移区间过程中,可能出现删点或加点操作其中之一无法实现的问题。
那么我们就来探讨如何利用特殊的莫队算法来解决这类问题,而这种莫队算法就称之为回滚莫队算法。
【只加不减的回滚莫队】
某些区间问题适合增加点,但不容易删除,那么我们就只增加不删除,例如这道题。
区间询问 \({\rm Max}_{i=l}^r cnt_i\times a_i\),其中 \(cnt_i\) 表示数字 \(a_i\) 的出现次数。
不难想到加点方式,但是删点似乎只能暴力扫一遍值域找到次大值更新答案,莫队就显得很鸡肋。
或许可以有回滚莫队:
- 对原序列进行分块,并对询问按照如下的方式排序:以左端点所在的块升序为第一关键字,以右端点升序为第二关键字。
- 对于处理所有左端点在块 \(T\) 内的询问,我们先将莫队区间左端点初始化为 \(R[T]+1\),右端点初始化为\(R[T]\),原因上面提到过。
- 对于左右端点在同一个块中的询问,我们直接暴力扫描回答即可。
- 对于左右端点不在同一个块中的所有询问,由于其右端点升序,我们对右端点只做加点操作,总共最多加点 \(n\) 次。
- 对于左右端点不在同一个块中的所有询问,其左端点是可能乱序的,我们每一次从 \(R[T]+1\) 的位置出发,只做加点操作,到达询问位置即可,每一个询问最多加 \(\sqrt n\) 次。
- 回答完询问后,我们撤销本次移动左端点的所有改动,使左端点回到 \(R[T]+1\) 的位置。
- 按照相同的方式处理下一块。
【只减不加的回滚莫队】
某些问题适合删点但不适合加点,如 这道题。
\(m\) 次询问,每次询问一个区间内最小没有出现过的自然数。
删除很简单,顺便更新答案,但是增加似乎很麻烦。
或许可以有回滚莫队:
- 对原序列进行分块,并对询问按照如下的方式排序:以左端点所在的块升序为第一关键字,以右端点降序为第二关键字。
- 对于处理所有左端点在块 \(T\) 内的询问,我们先将莫队区间左端点初始化为 \(L[T]\),右端点初始化为 \(n\),然后将其中的点全部增加到统计数组中去,并暴力求一次答案。
- 对于左右端点在同一个块中的询问,我们直接暴力扫描回答即可。(这时要新开一个数组,不破坏原数组)
- 对于左右端点不在同一个块中的所有询问,由于其右端点升序,我们对右端点只做加点操作,总共最多加点 \(n\) 次。
- 对于左右端点不在同一个块中的所有询问,其左端点是可能乱序的,我们每一次从 \(R[T]+1\) 的位置出发,只做加点操作,到达询问位置即可,每一个询问最多加 \(\sqrt n\) 次。
- 回答完询问后,我们撤销本次移动左端点的所有改动,使左端点回到 \(L[T]\) 的位置。
- 按照相同的方式处理下一块。
【小结】
不论怎样,思考出莫队实现的细节之后,回滚莫队的难点是上述算法中的第六步,撤销改动。
因为使用回滚莫队本身的算法就是很难撤销改动的(不然用回滚莫队干什么),所以要格外注意。
同时要注意不能滥用 memset
,撤销改动还是要有些技巧。
【代码实现】
还算好写。
例题一:
const int N = 100010;
typedef long long LL;
int n, m, block;
int a[N], val[N], cnt[N], bl[N];
LL ans[N];
struct Query{int l, r, id;} q[N];
bool cmp(Query a, Query b){
return (bl[a.l] ^ bl[b.l]) ? bl[a.l] < bl[b.l] : a.r < b.r;
}
void Add(int x, LL &Mx){
cnt[a[x]] ++;
Mx = max(Mx, 1LL * cnt[a[x]] * val[a[x]]);
}
int main(){
n = read(), m = read();
block = sqrt(n);
for(int i=1; i<=n; i++){
a[i] = val[i] = read();
bl[i] = (i-1) / block + 1;
}
sort(val+1, val+n+1);
int k = unique(val+1, val+n+1) - (val+1);
for(int i=1; i<=n; i++)
a[i] = lower_bound(val+1, val+k+1, a[i]) - val;
for(int i=1; i<=m; i++){
q[i].l = read(), q[i].r = read();
q[i].id = i;
}
sort(q+1, q+m+1, cmp);
int i = 1;
for(int j=1; j<=bl[n]; j++){
memset(cnt, 0, sizeof(cnt));
int l = j * block + 1, r = l - 1;
LL sum = 0;
for(; bl[q[i].l] == j; i ++){
int ql = q[i].l, qr = q[i].r, id = q[i].id;
LL now = 0;
if(bl[ql] == bl[qr]){
for(int p=ql; p<=qr; p++) cnt[a[p]] = 0;
for(int p=ql; p<=qr; p++){
cnt[a[p]] ++;
now = max(now, 1LL * cnt[a[p]] * val[a[p]]);
}
for(int p=ql; p<=qr; p++) cnt[a[p]] = 0;
ans[id] = now;
continue;
}
while(r < qr) Add(++ r, sum);
now = sum;
while(l > ql) Add(-- l, now);
ans[id] = now;
while(l < j * block + 1) cnt[a[l ++]] --;
}
}
for(int i=1; i<=m; i++) printf("%lld\n", ans[i]);
return 0;
}
例题二:
const int N = 200010;
int n, m, block;
int a[N], bl[N], ans[N];
int cnt[N], cnt_[N];
struct Query{int l, r, id;} q[N];
bool cmp(Query a, Query b){
return (bl[a.l] ^ bl[b.l]) ? bl[a.l] < bl[b.l] : a.r > b.r;
}
int main(){
n = read(), m = read();
block = sqrt(n);
for(int i=1; i<=n; i++){
a[i] = read();
bl[i] = (i-1) / block + 1;
}
for(int i=1; i<=m; i++){
q[i].l = read(), q[i].r = read();
q[i].id = i;
}
sort(q+1, q+m+1, cmp);
int i = 1;
for(int k=1; k<=bl[n]; k++){
int l = (k-1) * block + 1, r = n;
memset(cnt, 0, sizeof(cnt));
for(int j=l; j<=r; j++) cnt[a[j]] ++;
int sum = 0;
while(cnt[sum]) sum ++;
for(; bl[q[i].l] == k; i ++){
int ql = q[i].l, qr = q[i].r, id = q[i].id;
int tmp = 0;
if(bl[ql] == bl[qr]){
for(int j=ql; j<=qr; j++) cnt_[a[j]] ++;
while(cnt[tmp]) tmp ++;
ans[id] = tmp;
for(int j=ql; j<=qr; j++) cnt_[a[j]] = 0;
}
while(r > qr){
cnt[a[r]] --;
if(!cnt[a[r]]) sum = min(sum, a[r]);
r --;
}
tmp = sum;
while(l < ql){
cnt[a[l]] --;
if(!cnt[a[l]]) tmp = min(tmp, a[l]);
l ++;
}
ans[id] = tmp;
while(l > (k-1) * block + 1) cnt[a[--l]] ++;
}
}
for(int i=1; i<=m; i++) printf("%d\n", ans[i]);
return 0;
}
【简单习题】
-
套路题,直接上普通莫队即可。
-
一致认为出题人语文不太行(dllxl)。
求区间众数出现的次数,简单搞几个数组维护一下就好了。
-
求将区间变为升序至少要交换多少次,只能邻项交换。
树状数组 + 普通莫队维护,需要一点思维和细节。(思考:相同数字怎么处理)
-
既然都是模板了...
-
树上带修莫队,细节上注意一下就好了,也蛮简单的。
然后推荐一个题单,Dalao 总结的比蒟蒻强多了。
【总结】
莫队算法就是这么点东西,思想简单,代码简短,是不可多得的骗分神器。
(偷偷告诉你,其实还有二次离线莫队哦)
引用资料&特别鸣谢:
完结撒花