基础莫队学习笔记

· · 算法·理论

引入

上文提到,分块是一种优雅的暴力,能以较优秀的时间复杂度处理各种(或许是线段树做不到的)在线询问。

但涉及到 区间计数 等问题,分块也就无法实现了,如果不强制在线,有没有什么能在较优秀的时间复杂度下,能处理这个问题的算法呢?

简介

莫队是一种能处理多种离线询问的算法,经过拓展可以支持带修,支持树上操作等,同时时间复杂度比较优秀。是另一种优雅的暴力。第一种见 分块学习笔记。

例题

例题 1:P1972 [SDOI2009] HH的项链

Description

由于本篇主要讲莫队,所以本题的数据范围会被缩小到原来的 40\% 数据级别。

给定一个长为 n 的序列 aq 次询问,每次询问给定两个数 l,r,询问序列 a 中范围 [l,r] 有多少种不同的数。

#### Solution 直入本篇主题:莫队。 莫队的算法流程如下: 首先初始化两个指针 $L=1,R=0$。对于每个询问,尝试将当前指针缩小 / 放大范围到询问区间。 其中,当指针区间缩小时,说明答案缩小了,应减去当前位置答案,随后指针靠近询问区间一个下标。同理,当指针区间放大时,说明答案增多了,应当先靠近询问区间一个下标,再增加当前位置答案。(想一想,区间扩大为什么是先移后增?) 当左右指针都到达询问区间时,答案处理完成。 思考,时间复杂度是多少? 显然,复杂度是可以卡到 $O(nq)$ 的,这种做法并不优。 再思考,再暴力算法中,能优化的只有指针移动,那么是否可以优化处理顺序,使指针移动次数大幅减少? 把指针移动的路线转化到平面图上,大致如下: ![](https://cdn.luogu.com.cn/upload/image_hosting/kn1w9he2.png) 于是指针的移动长就转化成了一个求平面最短连接问题。求最短是暂时没有多项式解法的,但是我们可以优化至较短的路径。 考虑以下排序方式: 首先,把 $a$ 分成 $\sqrt{n}$ 块(大多数时)。 令 $id_i$ 为 $i$ 所在块编号,然后将询问离线下来,按 $id_l$ 为第一关键字排序,按 $r$ 为第二关键字排序。 将排序后的 $[l,r]$ 放在平面图上顺次连接后大概长这样: ![](https://cdn.luogu.com.cn/upload/image_hosting/1hx34nh6.png) 看上去指针移动的距离优化了很多,至少没有打结了。 事实上,还可以按照 $id_l$ 奇偶性进行排序,即当两个块相邻时,分别按 `a.r < b.r` 和 `a.r > b.r`(或相反)排序,这样路线会更短更平滑,请读者尝试自行理解。 可以看出,每次移动距离不会超过 $2\sqrt{n}$,同时指针肯定不走“回头路”,共 $q$ 次询问,所以总时间复杂度是 $O(n\sqrt{n})$ 的。 对于增加操作,如果增加前计数桶为 $0$,种类数加 $1$。 对于减少操作,如果减少后计数桶为 $0$,种类数减 $1$。 至此,本题的 $40\%$ 做法完毕,通过奇偶优化加卡常加调块长等等操作能卡到 $56pts$。 #### Code ```cpp // PikachuQAQ 2024/08/20 #include <bits/stdc++.h> using namespace std; const int kMaxN = 1e6 + 2; int id[kMaxN]; int read() { int x = 0, w = 1; char ch = 0; while (ch < '0' || ch > '9') { if (ch == '-') w = -1; ch = getchar(); } while (ch >= '0' && ch <= '9') { x = (x << 3) + (x << 1) + (ch - 48); ch = getchar(); } return x * w; } void write(int x) { if (x > 9) write(x / 10); putchar(x % 10 + 48); } struct Q { int l, r, k; friend bool operator < (Q a, Q b) { return (id[a.l] == id[b.l]) ? ((id[a.l] & 1) ? (a.r < b.r) : (a.r > b.r)) : (id[a.l] < id[b.l]); } } b[kMaxN]; int n, q, a[kMaxN], len, res, cnt[kMaxN], ans[kMaxN]; void del(int x) { res -= (--cnt[a[x]] == 0); } void add(int x) { res += (cnt[a[x]]++ == 0); } int main() { n = read(); const int len = sqrt(n) + 750; for (int i = 1; i <= n; i++) { a[i] = read(); id[i] = (i - 1) / len + 1; } q = read(); for (int i = 1, l, r; i <= q; i++) { l = read(), r = read(); b[i] = {l, r, i}; } sort(b + 1, b + q + 1); for (int i = 1, l = 1, r = 0; i <= q; i++) { while (l < b[i].l) del(l++); while (r > b[i].r) del(r--); while (l > b[i].l) add(--l); while (r < b[i].r) add(++r); ans[b[i].k] = res; } for (int i = 1; i <= q; i++) { write(ans[i]); putchar('\n'); } return 0; } ``` ### 例题 2:[P1494 [国家集训队] 小 Z 的袜子](https://www.luogu.com.cn/problem/P1494) #### Description 给定一个长为 $n$ 的序列 $a$,$q$ 次询问,每次询问给定两个数 $l,r$,在询问区间中随机选取两个数,这两个数相同的概率,用分数表示。如果 $l=r$,输出 `0/1`。 $1 \le n,q,a_i \le 5 \times 10^4$,$1 \le l \le r \le n$。 #### Solution 同上一题的思路。先考虑抽取到相同数的方案数。 - 每次当前颜色增加 $1$ 时,贡献增多了原本的相同颜色的个数,方案数增加它。 - 每次当前颜色减少 $1$ 时,贡献减少了减后的相同颜色的个数,方案数减去它。 令 $f=r-l+1$。 显然,每次询问的总方案数是 $\binom{f}{2}=\dfrac{f(f-1)}{2}$。最后两数约分输出即可,注意 $l=r$ 特判。 #### Code ```cpp // PikachuQAQ 2024/08/21 #include <bits/stdc++.h> using namespace std; using PLL = pair<long long, long long>; const int kMaxN = 5e4 + 2; int id[kMaxN]; struct Q { int l, r, k; friend bool operator < (Q a, Q b) { return (id[a.l] == id[b.l]) ? ((id[a.l] & 1) ? (a.r < b.r) : (a.r > b.r)) : (id[a.l] < id[b.l]); } } b[kMaxN]; int n, q, k, a[kMaxN], len, cnt[kMaxN]; long long res; PLL ans[kMaxN]; void del(int x) { res -= (--cnt[a[x]]); } void add(int x) { res += (cnt[a[x]]++); } PLL frac(int a, int b) { if (a == 0) return {0, 1}; return {a / __gcd(a, b), b / __gcd(a, b)}; } int main() { ios::sync_with_stdio(0), cin.tie(0), cout.tie(0); cin >> n >> q; len = sqrt(n); for (int i = 1; i <= n; i++) { cin >> a[i]; id[i] = (i - 1) / len + 1; } for (int i = 1, l, r; i <= q; i++) { cin >> l >> r; b[i] = {l, r, i}; } sort(b + 1, b + q + 1); for (int i = 1, l = 1, r = 0; i <= q; i++) { while (l < b[i].l) del(l++); while (r > b[i].r) del(r--); while (l > b[i].l) add(--l); while (r < b[i].r) add(++r); int f = r - l + 1; ans[b[i].k] = frac(res, 1ll * f * (f - 1) >> 1ll); } for_each(ans + 1, ans + q + 1, [](pair<int, int> x){ cout << x.first << '/' << x.second << '\n'; }); return 0; } ``` ### 例题 3:[P1903 [国家集训队] 数颜色 / 维护队列](https://www.luogu.com.cn/problem/P1903) #### Description 给定一个长为 $n$ 的序列 $a$,$q$ 次操作,有两种操作: - `Q l r` 表示询问 $[l,r]$ 区间内有几种不同的数。 - `R p c` 表示将第 $p$ 个数改成 $c$。 $1 \le n,q\le 1.5\times 10^5$,$1 \le l \le r \le n$,$1 \le c,a_i \le 10^6$,$1 \le p \le n$。 #### Solution 这个问题有修改了,普通的莫队肯定完不成。 于是请出主角:带修莫队!支持单点修改,区间查询。 可以想到,本题答案的值是按时间的上升而变化的,因此要将询问和修改分开处理,询问需要多一个参数:$t$,来分别处理每个时间的答案。 具体来讲,令时间指针 $T=0$,当当前处理的操作编号小了,就要往大靠拢,再修改,反之先修改,再往小靠拢。 对于每次移动的修改操作,如果当前操作再第 $i$ 个询问范围内,就说明这个修改对答案产生影响。删去原本的数,换成修改的书即可。 那么如何处理交换后的 $a$ 呢? 其实并不麻烦。只需要交换 被替代的数 和 替代原数的数即可,因为修改一个位置多次一定是修改再还原,修改再还原,因此仅需交换。 对于询问排序比较,需要以 $id_l$ 为第一关键字,$id_r$ 为第二关键字,$t$ 为第三关键字进行排序。 但是,块长不再是 $\sqrt{n}$ 了, 因为多一个时间轴时,块长应该是 $n^{\frac{2}{3}}$,时间复杂度为 $O(n^\frac{5}{3})$。[证明见此](https://oi-wiki.org/misc/modifiable-mo-algo/#%E7%89%B9%E7%82%B9)。 至此,本题得解。 #### Code ```cpp // PikachuQAQ 2024/08/22 #include <bits/stdc++.h> using namespace std; const int kMaxN = 1e6 + 2; int id[kMaxN]; char op; struct Q { int l, r, t, k; friend bool operator < (Q a, Q b) { return (id[a.l] == id[b.l]) ? ((id[a.r] == id[b.r]) ? (a.t < b.t) : (id[a.r] < id[b.r])) : (id[a.l] < id[b.l]); } } bq[kMaxN]; struct R { int p, d; } br[kMaxN]; int n, q, k, qt, rt, a[kMaxN], len, cnt[kMaxN], res, ans[kMaxN]; void del(int x) { res -= (--cnt[x] == 0); } void add(int x) { res += (cnt[x]++ == 0); } void update(int x, int t) { if (bq[x].l <= br[t].p && br[t].p <= bq[x].r) { del(a[br[t].p]); add(br[t].d); } swap(a[br[t].p], br[t].d); } int main() { ios::sync_with_stdio(0), cin.tie(0), cout.tie(0); cin >> n >> q; len = pow(n, 2. / 3.); for (int i = 1; i <= n; i++) { cin >> a[i]; id[i] = (i - 1) / len + 1; } for (int i = 1, l, r; i <= q; i++) { cin >> op >> l >> r; if (op == 'Q') { bq[++qt] = {l, r, rt, qt}; } else { br[++rt] = {l, r}; } } sort(bq + 1, bq + qt + 1); for (int i = 1, l = 1, r = 0, t = 0; i <= qt; i++) { while (l < bq[i].l) del(a[l++]); while (r > bq[i].r) del(a[r--]); while (l > bq[i].l) add(a[--l]); while (r < bq[i].r) add(a[++r]); while (t < bq[i].t) update(i, ++t); while (t > bq[i].t) update(i, t--); ans[bq[i].k] = res; } for (int i = 1; i <= qt; i++) { cout << ans[i] << '\n'; } return 0; } ``` ### 例题 4:[SP10707 COT2 - Count on a tree II](https://www.luogu.com.cn/problem/SP10707) #### Description 给定一棵 $n$ 个节点的树,每个节点的权值为 $a_i$。$q$ 次询问,每次询问给定两个节点,问包含 $u,v

之间的路径有多少不同权值。

#### Solution 通过 **欧拉序**,莫队可以升级成树上莫队,通过树链剖分、LCA 等可以维护一些两个节点间的询问。 由于本题权值非常大,需要离散化。 离散化后的样例的图长这样:(花括号里的是权值) ![](https://cdn.luogu.com.cn/upload/image_hosting/wh11k8t1.png) 考虑把这个图的欧拉序取出来: `1 2 2 3 5 5 6 6 7 7 3 4 8 8 4` 我们考虑在欧拉序上操作。 先考虑有了询问区间,如何在欧拉序上操作。 具体来讲,给一个欧拉序区间,比如 $[l,r] = [1,5]$,当一个节点在区间里出现两次时,说明其不在询问路径上,当一个节点出现一次时,说明其在询问路径上。 因此,对于莫队的修改,当一个节点出现一次,且其权值没有出现过,答案加一,否则当一个节点出现过两次,且它没有相同的权值,答案减一。 然后考虑要怎么求出在欧拉序上询问的区间。 令 $s_u$ 为 $u$ 进入的时间,$t_u$ 为 $u$ 出来的时间,默认 $s_u<s_v$。询问分两种情况: - $u$ 为 $v$ 的 LCA。比如 $u=1,v=7$,如果要让询问包括 $u,v$,应取出来 `1 2 2 3 5 5 6 6 7`,根据上文,被影响的节点是 $\{1,3,7\}$,正好是路径 $[1,7]$ 的所有节点,可观察出询问区间应该是 $[s_u,s_v]$。 - $u$ 不为 $v$ 的 LCA。比如 $u=4,v=5$。如果要让询问包括 $u,v$,应取出来 `5 6 6 7 7 3 4`,被影响的节点是 $\{3,4,5\}$,观察到 $1$ 在路径上但没有被算进去,因为两点的 LCA 会被算两遍,所以在计算答案时也要算 LCA。可以观察出询问区间是 $[t_u,s_v]$。 因此,询问区间就分讨完了。 最后,将询问按 $\sqrt{q}$ 分块,排序后处理答案即可。 #### Code ```cpp // PikachuQAQ 2024/08/30 #include <bits/stdc++.h> using namespace std; const int kMaxN = 4e4 + 2, Lg = 24, kMaxQ = 1e5 + 2; struct Q { int l, r, k, lca, p; friend bool operator < (Q a, Q b) { return (a.p == b.p) ? ((a.p & 1) ? (a.r < b.r) : (a.r > b.r)) : (a.p < b.p); } } b[kMaxQ]; int n, q; int m, a[kMaxN], c[kMaxN], df; int s[kMaxN], t[kMaxN], edfn[kMaxN << 1], dfn[kMaxN]; int ts, p[Lg][kMaxN], dep[kMaxN]; int res, cnt[kMaxN]; vector<int> g[kMaxN]; bool vis[kMaxN]; int ans[kMaxQ]; void disc() { for (int i = 1; i <= n; i++) c[i] = a[i]; sort(c + 1, c + n + 1); m = unique(c + 1, c + n + 1) - c - 1; for (int i = 1; i <= n; i++) a[i] = lower_bound(c + 1, c + m + 1, a[i]) - c; } void update(int x) { vis[x] ^= 1; res -= (vis[x] == 0 && (--cnt[a[x]] == 0)); res += (vis[x] && (++cnt[a[x]] == 1)); } int get(int u, int v) { return dfn[u] < dfn[v] ? u : v; } void DFS(int u, int fa) { p[0][dfn[u] = ++ts] = fa, edfn[s[u] = ++df] = u; for (int v : g[u]) if (v ^ fa) DFS(v, u); edfn[t[u] = ++df] = u; } int LCA(int u, int v) { if (u == v) return u; if ((u = dfn[u]) > (v = dfn[v])) swap(u, v); int d = __lg(v - u++); return get(p[d][u], p[d][v - (1 << d) + 1]); } void init() { DFS(1, 0); for (int i = 1; i <= __lg(n); i++) for (int j = 1; j + (1 << i) - 1 <= n; j++) p[i][j] = get(p[i - 1][j], p[i - 1][j + (1 << i - 1)]); } int main() { ios::sync_with_stdio(0), cin.tie(0), cout.tie(0); cin >> n >> q; static const int len = sqrt(q); for (int i = 1; i <= n; i++) cin >> a[i]; for (int i = 1, u, v; i < n; i++) { cin >> u >> v; g[u].push_back(v); g[v].push_back(u); } disc(), init(); for (int i = 1, u, v, lca; i <= q; i++) { cin >> u >> v, lca = LCA(u, v); if (lca == u || lca == v) { if (s[u] > s[v]) swap(u, v); b[i] = {s[u], s[v], i, 0, s[u] / len}; } else { if (s[v] > t[u]) swap(u, v); b[i] = {t[u], s[v], i, lca, t[u] / len}; } } sort(b + 1, b + q + 1); for (int i = 1, l = 1, r = 0; i <= q; i++) { while (l < b[i].l) update(edfn[l++]); while (r > b[i].r) update(edfn[r--]); while (l > b[i].l) update(edfn[--l]); while (r < b[i].r) update(edfn[++r]); if (b[i].lca) update(b[i].lca); ans[b[i].k] = res; if (b[i].lca) update(b[i].lca); } for (int i = 1; i <= q; i++) { cout << ans[i] << '\n'; } return 0; } ``` ## 后记 莫队真挺好玩的,是考场上针对相邻区间转移答案复杂度非常小 (小于等于 $\log n$),询问离线的骗分利器。