# 莫队 莫队算法是由莫涛提出的算法。在莫涛提出莫队算法之前,莫队算法已经在 Codeforces 的高手圈里小范围流传,但是莫涛是第一个对莫队算法进行详细归纳总结的人。莫涛提出莫队算法时,只分析了普通莫队算法,但是经过 OIer 和 ACMer 的集体智慧改造,莫队有了多种扩展版本。 莫队算法可以解决一类离线区间询问问题,适用性极为广泛。同时将其加以扩展,便能轻松处理树上路径询问以及支持修改操作。 --- ## 形式 假设 $n=m$,那么对于序列上的区间询问问题,如果从 $[l,r]$ 的答案能够 $O(1)$ 扩展到 $[l-1,r],[l+1,r],[l,r+1],[l,r-1]$(即与 $[l,r]$ 相邻的区间)的答案,那么可以在 $O(n\sqrt{n})$ 的复杂度内求出所有询问的答案。 --- ## 解释 离线后排序,顺序处理每个询问,暴力从上一个区间的答案转移到下一个区间答案(一步一步移动即可)。 --- ## 排序方法 对于区间 $[l,r]$, 以 $l$ 所在块的编号为第一关键字,$r$ 为第二关键字从小到大排序。 --- ## 实现 ```cpp void move(int pos, int sign) { // update nowAns } void solve() { BLOCK_SIZE = int(ceil(pow(n, 0.5))); sort(querys, querys + m); for (int i = 0; i < m; ++i) { const query &q = querys[i]; while (l > q.l) move(--l, 1); while (r < q.r) move(++r, 1); while (l < q.l) move(l++, -1); while (r > q.r) move(r--, -1); ans[q.id] = nowAns; } } ``` --- ## 复杂度分析 以下的情况在 $n$ 和 $m$ 同阶的前提下讨论。 首先是分块这一步,这一步的时间复杂度是 $O(\sqrt{n}\cdot\sqrt{n}\log\sqrt{n}+n\log n)=O(n\log n)$; 接着就到了莫队算法的精髓了,下面我们用通俗易懂的初中方法来证明它的时间复杂度是 $O(n\sqrt{n})$; --- 证:令每一块中 $L$ 的最大值为 $\max_1,\max_2,\max_3, \cdots , \max_{\lceil\sqrt{n}\rceil}$。 由第一次排序可知,$\max_1 \le \max_2 \le \cdots \le \max_{\lceil\sqrt{n}\rceil}$。 显然,对于每一块暴力求出第一个询问的时间复杂度为 $O(n)$。 考虑最坏的情况,在每一块中,$R$ 的最大值均为 $n$,每次修改操作均要将 $L$ 由 $\max_{i - 1}$ 修改至 $\max_i$ 或由 $\max_i$ 修改至 $\max_{i - 1}$。 考虑 $R$:因为 $R$ 在块中已经排好序,所以在同一块修改完它的时间复杂度为 $O(n)$。对于所有块就是 $O(n\sqrt{n})$。 重点分析 $L$:因为每一次改变的时间复杂度都是 $O(\max_i-\max_{i-1})$ 的,所以在同一块中时间复杂度为 $O(\sqrt{n}\cdot(\max_i-\max_{i-1}))$。 --- 将每一块 $L$ 的时间复杂度合在一起,可以得到: 对于 $L$ 的总时间复杂度为 $$ \begin{aligned} & O(\sqrt\{n\}(\max\{\}_1-1)+\sqrt\{n\}(\max\{\}_2-\max\{\}_1)+\sqrt\{n\}(\max\{\}_3-\max\{\}_2)+\cdots+\sqrt\{n\}(\max\{\}_\{\lceil\sqrt\{n\}\rceil\}-\max\{\}_\{\lceil\sqrt\{n\}\rceil-1))\} \\\\ = \phantom\{\} & O(\sqrt\{n\}\cdot(\max\{\}_1-1+\max\{\}_2-\max\{\}_1+\max\{\}_3-\max\{\}_2+\cdots+\max\{\}_\{\lceil\sqrt\{n\}\rceil-1\}-\max\{\}_\{\lceil\sqrt\{n\}\rceil-2\}+\max\{\}_\{\lceil\sqrt\{n\}\rceil\}-\max\{\}_\{\lceil\sqrt\{n\}\rceil-1)\}) \\\\ = \phantom\{\} & O(\sqrt\{n\}\cdot(\max\{\}_\{\lceil\sqrt\{n\}\rceil-1\}))\\\\ \end\{aligned\} $$ (裂项求和) 由题可知 $\max_{\lceil\sqrt{n}\rceil}$ 最大为 $n$,所以 $L$ 的总时间复杂度最坏情况下为 $O(n\sqrt{n})$。 综上所述,莫队算法的时间复杂度为 $O(n\sqrt{n})$; --- 但是对于 $m$ 的其他取值,如 $m
r+1$ 的情况,那么 $[r+1,l-1]$(这个区间非空)的元素被删除了一次但没有被加入,因此这个元素被加入的次数是负数。 因此,如果某时刻出现 $l>r+1$ 的情况,那么会存在一个元素,它的加入次数是负数。这在某些题目会出现问题,例如我们如果用一个 `set` 维护区间中的所有数,就会出现「需要删除 `set` 中不存在的元素」的问题。 --- 代码中的四个 while 循环一共有 $4!=24$ 种排列顺序。不妨设第一个循环用于操作左端点,就有以下 $12$ 种排列(另外 $12$ 种是对称的)。下表列出了这 12 种写法的正确性,还给出了错误写法的反例。 --- | 循环顺序 | 正确性 | 反例或注释 | | ---- | --- | ---- | | `l--,l++,r--,r++` | 错误 | $l
#include
#include
using namespace std; constexpr int N = 50005; int n, m, maxn; int c[N]; long long sum; int cnt[N]; long long ans1[N], ans2[N]; struct query { int l, r, id; bool operator<(const query &x) const { // 重载<运算符 if (l / maxn != x.l / maxn) return l < x.l; return (l / maxn) & 1 ? r < x.r : r > x.r; } } a[N]; void add(int i) { sum += cnt[i]; cnt[i]++; } void del(int i) { cnt[i]--; sum -= cnt[i]; } long long gcd(long long a, long long b) { return b ? gcd(b, a % b) : a; } int main() { cin.tie(nullptr)->sync_with_stdio(false); cin >> n >> m; maxn = sqrt(n); for (int i = 1; i <= n; i++) cin >> c[i]; for (int i = 0; i < m; i++) cin >> a[i].l >> a[i].r, a[i].id = i; sot(a, a + m); for (int i = 0, l = 1, r = 0; i < m; i++) { // 具体实现 if (a[i].l == a[i].r) { ans1[a[i].id] = 0, ans2[a[i].id] = 1; continue; } while (l > a[i].l) add(c[--l]); while (r < a[i].r) add(c[++r]); while (l < a[i].l) del(c[l++]); while (r > a[i].r) del(c[r--]); ans1[a[i].id] = sum; ans2[a[i].id] = (long long)(r - l + 1) * (r - l) / 2; } for (int i = 0; i < m; i++) { if (ans1[i] != 0) { long long g = gcd(ans1[i], ans2[i]); ans1[i] /= g, ans2[i] /= g; } else ans2[i] = 1; cout << ans1[i] << '/' << ans2[i] << '\n'; } return 0; } ``` --- ## 普通莫队的优化 ### 过程 我们看一下下面这组数据 ```text // 设块的大小为 2 (假设) 1 1 2 100 3 1 4 100 ``` 手动模拟一下可以发现,r 指针的移动次数大概为 300 次,我们处理完第一个块之后,$l = 2, r = 100$,此时只需要移动两次 l 指针就可以得到第四个询问的答案,但是我们却将 r 指针移动到 1 来获取第三个询问的答案,再移动到 100 获取第四个询问的答案,这样多了九十几次的指针移动。我们怎么优化这个地方呢?这里我们就要用到奇偶化排序。 --- 什么是奇偶化排序?奇偶化排序即对于属于奇数块的询问,r 按从小到大排序,对于属于偶数块的排序,r 从大到小排序,这样我们的 r 指针在处理完这个奇数块的问题后,将在返回的途中处理偶数块的问题,再向 n 移动处理下一个奇数块的问题,优化了 r 指针的移动次数,一般情况下,这种优化能让程序快 30% 左右。 --- ### 实现 "压行" ```cpp // 这里有个小细节等下会讲 int unit; // 块的大小 struct node { int l, r, id; bool operator<(const node &x) const { return l / unit == x.l / unit ? (r == x.r ? 0 : ((l / unit) & 1) ^ (r < x.r)) : l < x.l; } }; ``` 不压行 ```cpp struct node { int l, r, id; bool operator<(const node &x) const { if (l / unit != x.l / unit) return l < x.l; // 注意下面两行不能写小于(大于)等于,否则会出错(详见下面的小细节) if ((l / unit) & 1) return r < x.r; return r > x.r; } }; ``` --- 小细节 如果使用 `sort` 比较两个结构体,不能出现 $a < b$ 和 $b < a$ 同时为真的情况,否则会运行错误,详见 [常见错误](../contest/common-mistakes.md#会导致-re)。 对于压行版,如果没有 `r == x.r` 的特判,当 l 属于同一奇数块且 r 相等时,会出现上面小细节中的问题(自己手动模拟一下),对于不压行版,如果写成小于(大于)等于,则也会出现同样的问题。