在C++算法竞赛中,有一类问题让不少选手头疼:给定一个数组,多次询问某个区间内数字的出现次数、不同元素的个数、众数等信息。这类“区间离线查询”问题,如果直接暴力求解,每次查询 O(n),总复杂度 O(n·q),对于 n,q 在 1e5 的量级几乎不可行。而线段树或树状数组在面对“不同元素个数”这类不可减的信息时,又显得力不从心。直到莫队算法(Mo’s algorithm)的出现,才为这些问题提供了一款“优雅的暴力”——它用 O((n+q)·√n) 的时间,将看似棘手的区间统计问题变得简单而高效。
一、从暴力到“聪明”的暴力
假设我们有一个长度为 n 的数组 a,需要回答 q 个区间查询 [L, R] 中不同数值的个数。朴素做法是每个查询独立地用哈希表统计,每次 O(R-L+1),最坏 O(nq)。但观察发现:相邻查询之间往往有重叠。如果我们能利用上一个查询的中间结果,只对区间端点进行微调,就能大幅减少重复计算。
莫队算法的核心思想就是:离线处理所有查询,通过排序和双指针移动,将总移动次数控制在 O(n√q) 级别。具体来说:
-
将数组分块,块大小一般取 B = n / sqrt(q) 或简单取 sqrt(n)。
-
对查询按照 左端点所在块 为第一关键字,右端点 为第二关键字排序(右端点在块内奇偶交替排序可以进一步优化)。
-
维护当前的区间 [curL, curR] 以及该区间内的统计信息(如 cnt 数组、答案变量)。
-
对于每个查询,将 curL 和 curR 一步步移动到目标区间,移动过程中更新统计信息。
整个过程看似还是“暴力移动”,但通过排序,所有查询的 curL 和 curR 移动总次数被控制在 O((n+q)·√n) 内,从而成为实用的算法。
二、算法详解与 C++ 实现
2.1 分块与排序
int n, q;
int a[N];
int pos[N];
struct Query {
int l, r, id;
} Q[N];
bool cmp(const Query &a, const Query &b) {
if (pos[a.l] != pos[b.l]) return pos[a.l] < pos[b.l];
if (pos[a.l] & 1) return a.r > b.r;
else return a.r < b.r;
}
块大小通常取 block = sqrt(n) 或 n / sqrt(q),当然也可以直接取 300~500 的经验值。
2.2 区间移动与统计
我们维护一个 cnt[val] 数组,记录当前区间内每个值出现的次数,以及一个 curAns 表示当前区间内不同值的个数。
添加一个位置 x 时:
cnt[a[x]]++;
if (cnt[a[x]] == 1) curAns++;
删除一个位置 x 时:
cnt[a[x]]--;
if (cnt[a[x]] == 0) curAns--;
2.3 完整代码框架
#include <bits/stdc++.h>
using namespace std;
const int N = 1e5 + 5;
int a[N], cnt[N], ans[N], curAns;
int n, q, block;
struct Query {
int l, r, id;
} Q[N];
bool cmp(const Query &x, const Query &y) {
if (x.l / block != y.l / block) return x.l / block < y.l / block;
return ((x.l / block) & 1) ? (x.r > y.r) : (x.r < y.r);
}
void add(int pos) {
if (cnt[a[pos]] == 0) curAns++;
cnt[a[pos]]++;
}
void remove(int pos) {
cnt[a[pos]]--;
if (cnt[a[pos]] == 0) curAns--;
}
int main() {
scanf("%d", &n);
block = sqrt(n);
for (int i = 1; i <= n; i++) scanf("%d", &a[i]);
scanf("%d", &q);
for (int i = 1; i <= q; i++) {
scanf("%d%d", &Q[i].l, &Q[i].r);
Q[i].id = i;
}
sort(Q + 1, Q + q + 1, cmp);
int curL = 1, curR = 0;
for (int i = 1; i <= q; i++) {
int L = Q[i].l, R = Q[i].r;
while (curL > L) add(--curL);
while (curR < R) add(++curR);
while (curL < L) remove(curL++);
while (curR > R) remove(curR--);
ans[Q[i].id] = curAns;
}
for (int i = 1; i <= q; i++) printf("%d\n", ans[i]);
return 0;
}
三、复杂度分析
每个查询的移动量分为两部分:
事实上,标准莫队算法的复杂度为 O((n+q)·√n·f),其中 f 是单次 add/remove 的复杂度(通常 O(1))。
四、优化技巧
4.1 奇偶排序优化
前面已经给出:同块内,奇数块右端点降序,偶数块右端点升序。这可以减少右端点在块之间来回移动的开销。
4.2 块大小调整
理论最优块大小为 n / sqrt(q),但实践中常取 int(sqrt(n)) 或 n / sqrt(q) 并微调。
4.3 使用更快的 I/O
莫队算法常有大量移动操作,用 scanf/printf 或关闭同步的 cin/cout。
4.4 常数优化
将 add/remove 内联,使用局部变量引用数组等。
五、莫队算法的扩展
5.1 带修改的莫队(莫队 + 时间轴)
如果查询之间还夹杂着单点修改,可以引入第三维 t 表示时间,变成“三维偏序”询问。分块大小取 n^(2/3),复杂度 O(n^(5/3))。
5.2 树上莫队
将树上的路径查询转化为欧拉序区间(首次出现和最后一次出现之间的区间特殊处理),即可用普通莫队解决。
5.3 回滚莫队
当 add 容易但 remove 困难时(例如维护最大值、众数),可以用回滚莫队——通过每次左端点回滚到块首来避免删除操作。
六、典型例题
-
洛谷 P1972 [SDOI2009] HH的项链(区间不同数个数,莫队模板)
-
洛谷 P2709 小B的询问(区间内每个数出现次数的平方和)
-
Codeforces 617E XOR and Favorite Number(区间异或值为 k 的子区间个数,需维护前缀异或)
七、常见错误与调试
-
块的边界:确保 pos[i] 计算正确,避免负索引。
-
add 和 remove 的顺序:先扩展再收缩,防止区间为空时访问非法下标。
-
整数溢出:如果统计的是平方和等,注意 long long。
-
排序的比较函数:必须严格弱序,否则可能 RE 或死循环。奇偶优化中注意同块相等时的处理。
八、总结
莫队算法是一种“优雅的暴力”,它将朴素思想与分块、离线处理、排序、双指针移动等技巧巧妙结合,解决了大量难以用传统数据结构处理的区间统计问题。它的思想简单,代码量不大,但需要对其复杂度有信心。对于竞赛选手来说,莫队算法是必备的武器之一。
当你再次遇到“区间查询,信息不可合并,但可以暴力扩展收缩”的题目时,请想起莫队。你会发现,原来暴力也可以如此优雅。 |