【暴力美学!!!】莫队初步

同步发表在了我的公众号

0. 序章:为什么是莫队?

0.0 一些废话

记得半年前,做题的时候实在是不想写过于重的数据结构(如主席树),于是学习了莫队。

近期又复习了一下莫队,感觉莫队就是优雅的暴力美学。

注意,此篇文章对你的算法基础有较高的要求。

0.1 与常见数据结构的“分工”

面对静态数组的离线区间查询,若被维护的答案能在指针左右扩/缩一格时通过可加可减地更新,莫队就很强:它以“多查询、少变动”为假设,把“全局复杂区间问题”化为“相邻状态的小步子”。

这与“在线 + 可更新”的线段树/树状数组的思路不同:后者依赖结构化信息(如区间合并、幂等等)快速回答每个区间;前者则用排序减少区间端点的大跳跃。作为参照,线段树适合在线可改且能在节点上做可结合运算(如和/最值,或借助懒标记),而 RMQ 等幂等问题甚至用稀疏表一次性预处理后 O(1) 查询即可,根本不需要莫队。

0.2 离线 + 平方分块的直觉

莫队的核心做法是:把所有查询离线后,按左端点所属块再配合右端点排序,让滑动窗口 \([L,R]\) 在相邻查询间移动得尽可能短。块长通常取 \(\lfloor\sqrt{N}\rfloor\),于是平均每次查询只需 \(O(\sqrt N)\) 级别的指针移动(每步为一次 add/remove),总复杂度常见写作 \(O\big((N+Q)\sqrt N\big)\)(常数与维护代价相关)。

0.3 典型收益与边界

\(Q\)\(N\) 同阶或更大时,莫队往往从朴素 \(O(QN)\) 拉到 \(O((N+Q)\sqrt N)\) 的可接受区间;但如果答案维护代价很大(如“众数”一类需要多层桶/结构支撑),或者问题本身存在幂等/可加合并的强结构,改用稀疏表/线段树/分块算法更合算。

1. 莫队算法基本面

1.1 适用条件

若一个问题满足以下要点,往往适合莫队:

  • 查询可离线排序;
  • 不含或只含“可离线处理”的少量修改(带修改版本见后面);
  • 维护量可在 \([L,R]\) 伸缩一步时常数级更新(add/remove);
  • 你能为值域准备合理的计数/桶/状态,并从中 O(1) 或摊还近似 O(1) 地读出答案(get)。

1.2 标准排序与块长

最常用的排序规则:

  • 先按 \(\lfloor L / B \rfloor\)(左端点所在块)升序;
  • 同一块内按 \(R\) 升降序交替(或统一升序)。
    一般取 \(B \approx \sqrt{N}\)。交替排序能减少跨块切换时的“之字形”回退。也可统一升序以简化实现,代价是常数略大。

进阶替代:把查询点对映射到空间填充曲线(Hilbert/Peano)顺序,可进一步降低常数,尤其当 add/remove 很便宜而排序成本主导时。后续会展开代码与结论。

1.3 三件套:add / remove / answer

实现上保持三件套分工清晰:

  • add(pos): 把 a[pos] 纳入当前窗口并更新频次/贡献;
  • remove(pos): 把 a[pos] 移出并撤销贡献;
  • answer(): 读出当前答案。
    保持对称性可逆性是关键(例如“不同数个数”里,频次从 0→1 时答案 +1,1→0 时答案 −1)。

1.4 入门范例:区间不同数个数

问题:给定数组与若干区间 \([l,r]\),求每个区间中不同数的个数。这是莫队的经典练手题。

思路:维护一个值域频次数组 cnt[x] 与当前“活跃值”的计数 distinct

  • add(i): 若 cnt[a[i]] 从 0 变 1,则 distinct++;随后 cnt[a[i]]++
  • remove(i): 先 cnt[a[i]]--;若其从 1 变 0,则 distinct--
  • answer(): 直接返回 distinct

复杂度:排序 \(O(Q\log Q)\)(或 \(O(Q)\) 取决于实现),指针移动总步数摊还约 \(O(Q\sqrt N)\),整体常见为 \(O((N+Q)\sqrt N)\)。若引入 Hilbert/Peano 排序,可显著降低常数,数据偏大时尤为明显。

1.5 最小实现骨架

#include <bits/stdc++.h>
using namespace std;

struct Query
{
    int l, r, id, bl;
};
int main()
{
    ios::sync_with_stdio(false);
    cin.tie(0);
    int n;
    cin >> n;
    vector<int> a(n + 1);
    for (int i = 1; i <= n; ++i)
        cin >> a[i];

    // 离散化(若值域大/离散)
    vector<int> b(a.begin() + 1, a.end());
    sort(b.begin(), b.end());
    b.erase(unique(b.begin(), b.end()), b.end());
    for (int i = 1; i <= n; ++i)
        a[i] = int(lower_bound(b.begin(), b.end(), a[i]) - b.begin());

    int q;
    cin >> q;
    int B = max(1, int(sqrt(n)));
    vector<Query> qs(q);
    for (int i = 0; i < q; ++i)
    {
        int l, r;
        cin >> l >> r;
        qs[i] = {l, r, i, l / B};
    }
    sort(qs.begin(), qs.end(), [&](const Query &x, const Query &y) {
        if (x.bl != y.bl)
            return x.bl < y.bl;
        if (x.bl & 1)
            return x.r > y.r; // 交替次序,减常数的小技巧
        return x.r < y.r;
    });

    vector<int> cnt(b.size() + 1, 0);
    vector<int> ans(q);
    int curL = 1, curR = 0, distinct = 0;

    auto add = [&](int i) {
        int &c = cnt[a[i]];
        if (c == 0)
            ++distinct;
        ++c;
    };
    auto rem = [&](int i) {
        int &c = cnt[a[i]];
        --c;
        if (c == 0)
            --distinct;
    };

    for (auto qu : qs)
    {
        int L = qu.l, R = qu.r;
        while (curL > L)
            add(--curL);
        while (curR < R)
            add(++curR);
        while (curL < L)
            rem(curL++);
        while (curR > R)
            rem(curR--);
        ans[qu.id] = distinct;
    }
    for (int i = 0; i < q; ++i)
        cout << ans[i] << '\n';
    return 0;
}

这份骨架体现了莫队的三件套与排序规则;把 add/rem 的内容替换为你的问题的增删逻辑即可。

1.6 复杂度与块长的更细推导

  • 设块长 \(B\)。把查询按块排序后,跨块时左端点移动主导,在块内则右端点的变化主导。令每块大约覆盖 \(B\) 个起点,块数约 \(N/B\)。当 \(B \approx \sqrt N\) 时,移动步数的主导项约落在 \(Q\sqrt N\) 数量级。
  • \(Q\) 明显偏大或偏小,可以把 \(B\) 做小幅调参(如 \(\max(1,\lfloor N/\max(1,\sqrt Q)\rfloor)\) 的经验式),但一般 \(\sqrt N\) 够用。
  • add/remove 常数很高(如众数需要多层桶),Hilbert/Peano 排序可显著减少“远跳”,降低 cache miss 与移动总步数。

1.7 常见坑与对拍要点(基础版)

  • 区间闭包一致性:模板中统一使用 \([L,R]\) 左闭右闭;add/rem 的位置与 while 的顺序要与之匹配。
  • 离散化:若值域很大必须压缩到连续小整数,避免超大桶。
  • 多测试组:频次数组与全局状态务必清零;建议写一个朴素 \(O(QN)\) 的小 checker 做对拍。
  • 排序比较器:交替排序要写对(块内奇偶分支),防止指针“回摆过度”。
  • IO 与常数:大数据集可换更快的读入,或使用 Hilbert/Peano 排序作为替代(下一卷详述)。

1.8 何时不该用莫队(基础版)

  • 幂等/可表合并的区间问题(如 RMQ、GCD),稀疏表/线段树一般更优;
  • 需要在线强更新且合并结构清晰的场景(如区间加、区间和),线段树/树状数组更简单直接;
  • 强相关跨区间但难以“可加可减”维护的函数(例如众数的严格在线维护)——如果代价太大,需评估是否转向其他结构。

2. 块长选取与复杂度的更细推导

2.1 两种常见的复杂度

  • 标准写法:将查询按 \(\lfloor L/B\rfloor\) 分块、块内按 \(R\) 排序,整体步数约为 \(\mathcal O\big((N+Q)\sqrt N\big)\)add/remove 的单位代价计入常数项 \(F\))。
  • “块数 K” 视角:把序列分成 \(K\) 个块,可得到 \(\mathcal O\!\left(Q\cdot\frac{N}{K}+K\cdot N\right)\) 的移动上界。选 \(K=\sqrt N\) 时回到 \(\mathcal O\big((N+Q)\sqrt N\big)\)

小结:无论从哪个视角,默认选 \(B\approx\sqrt N\) 既简洁又可靠。把 \(F\) 控制在常数级,是莫队可行的第一要务。

2.2 何时考虑“按 \(Q\) 调参”的选择

在一些极端任务里(比如 \(Q\ll N\)\(Q\gg N\)),会有人用经验公式把块长调到 \(B\approx\max\!\big(1,\lfloor N/\sqrt{\max(1,Q)}\rfloor\big)\) 来降低常数。它不改变“量级上界”的经典结论,但实践里确实可能更快或更稳。你可以当作一条经验,而不是需要严格背诵的结论。

2.3 什么时候“换排序”能显著提速

如果你的 add/remove 已经非常便宜,而移动指针的 cache 友好性与回拉距离成为瓶颈,那么用空间填充曲线(Hilbert/Peano)排序通常能以更低的常数跑过“分块排序”。

3 实现细节总览(离散化 / 计数桶布局 / (I/O) / 对拍)

3.1 离散化与值域压缩

  • 何时需要:当输入值域很大(例如值达到 \(10^9\))或稀疏时,用离散化把它们映射到 \([0..M-1]\) 的连续小整数,令计数桶 cnt[val] 可用数组实现,保证增删为 O(1)。
  • 做法简述:复制数组、排序去重,再 lower_bound 回写原数组。遇到多组数据时,要么复用缓冲并在每组结束后清零,要么用时间戳技巧。

3.2 计数桶布局与缓存友好

  • 频次数组:最常见是 cnt[value] 与某些“派生桶”(如 freqCount[f] 统计“出现次数恰为 f 的值的个数”,用于“最大频次/众数系”)。

  • 布局建议

    • 将大数组开成全局(或 static),避免反复分配;
    • 尽量连续访问(减小跨页跨度),减少 cache miss。
  • 曲线排序与 locality:当 add/remove 很便宜、而指针“折返”开销占主导时,采用 Hilbert/Peano 排序可显著改善访问局部性,常被用来压常数。

3.3 快速 I/O 与分支最小化

  • 大数据下建议使用 ios::sync_with_stdio(false); cin.tie(nullptr);
  • add/remove 内减少分支:例如将“计数从 0→1 时答案 +1;1→0 时 −1”的分支写在最外侧,内部只做自增自减;
  • 块长调参:虽然“\(\sqrt N\)”是可靠默认,但在极端 \(Q\)\(N\) 比例下,工程上有人按 \(B\approx N/\sqrt{\max(1,Q)}\) 微调,实践中能小降常数。

4 入门范例一:区间不同数个数

这是莫队最经典的问题。核心是维护 cnt[val]distinct

4.1 思路回顾

  • add(i):若 cnt[a[i]] 由 0 变 1,则 distinct++,随后 cnt++
  • remove(i):先 cnt--,若由 1 变 0,则 distinct--
  • answer():返回 distinct

4.2 参考实现

#include <bits/stdc++.h>
using namespace std;

struct Query
{
    int l, r, id, bl;
};
int main()
{
    ios::sync_with_stdio(false);
    cin.tie(0);
    int n;
    cin >> n;
    vector<int> a(n + 1);
    for (int i = 1; i <= n; i++)
        cin >> a[i];
    // 离散化
    vector<int> b(a.begin() + 1, a.end());
    sort(b.begin(), b.end());
    b.erase(unique(b.begin(), b.end()), b.end());
    for (int i = 1; i <= n; i++)
        a[i] = int(lower_bound(b.begin(), b.end(), a[i]) - b.begin());
    int q;
    cin >> q;
    int B = max(1, int(sqrt(n)));
    vector<Query> qs(q);
    for (int i = 0; i < q; i++)
    {
        int l, r;
        cin >> l >> r;
        qs[i] = {l, r, i, l / B};
    }
    sort(qs.begin(), qs.end(), [&](const Query &x, const Query &y) {
        if (x.bl != y.bl)
            return x.bl < y.bl;
        return (x.bl & 1) ? x.r > y.r : x.r < y.r; // 交替
    });

    vector<int> cnt(b.size() + 1);
    cnt.assign(b.size() + 1, 0);
    vector<int> ans(q);
    int curL = 1, curR = 0, distinct = 0;
    auto add = [&](int i) {
        int &c = cnt[a[i]];
        if (c == 0)
            ++distinct;
        ++c;
    };
    auto rem = [&](int i) {
        int &c = cnt[a[i]];
        --c;
        if (c == 0)
            --distinct;
    };

    for (auto qu : qs)
    {
        int L = qu.l, R = qu.r;
        while (curL > L)
            add(--curL);
        while (curR < R)
            add(++curR);
        while (curL < L)
            rem(curL++);
        while (curR > R)
            rem(curR--);
        ans[qu.id] = distinct;
    }
    for (int i = 0; i < q; i++)
        cout << ans[i] << "\n";
    return 0;
}

5 入门范例二:区间“最大频次 / 众数系”

5.1 难点与典型权衡

  • “最大频次”\(\max_f\))相对“众数本身”要容易:只需要知道当前窗口内“最大的出现次数是多少”。

  • 思路:维护两层结构

    • cnt[val]:每个值的出现次数;
    • bucket[f]:出现次数恰为 \(f\) 的值的个数。
      窗口变化时:某个值的频次从 \(f\to f+1\)\(f\to f-1\),相应更新 bucket;记录 curMaxF 并在需要时向下修正(当 bucket[curMaxF]==0 时递减)。
  • 复杂度add/remove 仍是 O(1),求最大频次只在个别时刻递减 curMaxF,总体摊还是 O(1)。若要求众数的值,通常还需在各频次里维持候选(成本更高、实现更重),很多人会改为“求最大频次”或“出现次数 ≥ K 的计数”等替代表述。

5.2 参考实现(返回“最大频次”)

#include <bits/stdc++.h>
using namespace std;

struct Query
{
    int l, r, id, bl;
};
int main()
{
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    int n;
    cin >> n;
    vector<int> a(n + 1);
    for (int i = 1; i <= n; i++)
        cin >> a[i];
    // 离散化
    vector<int> b(a.begin() + 1, a.end());
    sort(b.begin(), b.end());
    b.erase(unique(b.begin(), b.end()), b.end());
    for (int i = 1; i <= n; i++)
        a[i] = int(lower_bound(b.begin(), b.end(), a[i]) - b.begin());
    int q;
    cin >> q;
    int B = max(1, int(sqrt(n)));
    vector<Query> qs(q);
    for (int i = 0; i < q; i++)
    {
        int l, r;
        cin >> l >> r;
        qs[i] = {l, r, i, l / B};
    }
    sort(qs.begin(), qs.end(), [&](const Query &x, const Query &y) {
        if (x.bl != y.bl)
            return x.bl < y.bl;
        return (x.bl & 1) ? x.r > y.r : x.r < y.r;
    });

    int M = b.size();
    vector<int> cnt(M + 1, 0);
    // 最大频次理论上不超过区间长度,保守可开到 n
    vector<int> bucket(n + 1, 0);
    vector<int> ans(q);
    int curL = 1, curR = 0, curMaxF = 0;

    auto add = [&](int i) {
        int v = a[i], f = cnt[v];
        if (f > 0)
            --bucket[f];
        ++cnt[v];
        ++bucket[f + 1];
        if (f + 1 > curMaxF)
            curMaxF = f + 1;
    };
    auto rem = [&](int i) {
        int v = a[i], f = cnt[v];
        --bucket[f];
        --cnt[v];
        if (f - 1 > 0)
            ++bucket[f - 1];
        while (curMaxF > 0 && bucket[curMaxF] == 0)
            --curMaxF;
    };

    for (auto qu : qs)
    {
        int L = qu.l, R = qu.r;
        while (curL > L)
            add(--curL);
        while (curR < R)
            add(++curR);
        while (curL < L)
            rem(curL++);
        while (curR > R)
            rem(curR--);
        ans[qu.id] = curMaxF;
    }
    for (int i = 0; i < q; i++)
        cout << ans[i] << "\n";
    return 0;
}

若题目需要“\(\ge K\) 的频次的元素个数”,只需把 bucket[f] 的前缀和(或分块维护)变成查询对象即可。

5.3 何时应放弃“精确众数值”

精确众数(返回某个出现次数最高的值)在莫队下往往需要更重的结构,实现复杂度与常数都很高;若数据范围允许,很多选手会转向线段树+分治。

6 入门范例三:区间 MEX(最小缺失值)

6.1 维护结构

  • 仍用 cnt[val] 统计出现次数;
  • 维护一个块分解的“零计数桶”:把值域分成若干块,记录每块中“cnt==0 的位置个数”;
  • “求 MEX”时:先找到首个“零个数>0”的块,再在线性扫描该块内找到第一个 cnt==0 的位置。这样能在 O(值域块数 + 块长) 的复杂度内得到 MEX。

6.2 参考实现(返回当前窗口 MEX)

#include <bits/stdc++.h>
using namespace std;

struct Query
{
    int l, r, id, bl;
};

int main()
{
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    int n;
    cin >> n;
    vector<int> a(n + 1);
    for (int i = 1; i <= n; i++)
        cin >> a[i];

    int q;
    cin >> q;
    int B = max(1, int(sqrt(n)));
    vector<Query> qs(q);
    for (int i = 0; i < q; i++)
    {
        int l, r;
        cin >> l >> r;
        if (l > r)
            swap(l, r); // 若题面不保证,可保底
        qs[i] = {l, r, i, l / B};
    }
    sort(qs.begin(), qs.end(), [&](const Query &x, const Query &y) {
        if (x.bl != y.bl)
            return x.bl < y.bl;
        return (x.bl & 1) ? x.r > y.r : x.r < y.r;
    });

    // 关键点:不做离散化!只维护 0..V-1 的计数
    int V = n + 2; // MEX ≤ n,留一点余量
    int S = max(1, int(sqrt(V)));
    int NB = (V + S - 1) / S;

    vector<int> cnt(V, 0), zero(NB, 0);
    for (int i = 0; i < V; i++)
        zero[i / S]++; // 初始全为“没出现”

    auto inc = [&](int v) {
        if (0 <= v && v < V)
        {
            if (cnt[v] == 0)
                zero[v / S]--;
            ++cnt[v];
        }
    };
    auto dec = [&](int v) {
        if (0 <= v && v < V)
        {
            --cnt[v];
            if (cnt[v] == 0)
                zero[v / S]++;
        }
    };
    auto get_mex = [&]() {
        int blk = 0;
        while (blk < NB && zero[blk] == 0)
            ++blk;
        if (blk == NB)
            return V; // 理论上到不了
        int L = blk * S, R = min(V - 1, (blk + 1) * S - 1);
        for (int x = L; x <= R; x++)
            if (cnt[x] == 0)
                return x;
        return V;
    };

    vector<int> ans(q);
    int curL = 1, curR = 0;
    auto add = [&](int i) { inc(a[i]); };
    auto rem = [&](int i) { dec(a[i]); };

    for (auto qu : qs)
    {
        int L = qu.l, R = qu.r;
        while (curL > L)
            add(--curL);
        while (curR < R)
            add(++curR);
        while (curL < L)
            rem(curL++);
        while (curR > R)
            rem(curR--);
        ans[qu.id] = get_mex();
    }
    for (int i = 0; i < q; i++)
        cout << ans[i] << "\n";
    return 0;
}

6.3 复杂度与实战提示

  • 单次 get_mex() 的成本约为 \(O(\text{块数} + \text{块长})\approx O(\sqrt{V})\),在莫队的总框架下仍然可接受;
  • 若值域极大(比如原值域 \(10^9\)),离散化(确保 V 比“窗口里可能出现的最大 MEX”略大)是关键,否则 MEX 可能“撞上边界”;

7. 扩展阅读:空间填充曲线排序(Hilbert / Peano)

7.1 为什么它们能“更快”

Hilbert / Peano 都把二维点 \((L,R)\) 映射为一条连续穿过所有格点的曲线序,使得相邻查询在二维平面上彼此接近,从而减少指针大幅回摆。理论量级仍是莫队的那一档,但常数往往更小。

7.2 Hilbert 排序:实现要点与模板

要点

  • 选择 \(P\) 使 \(2^P\) 覆盖到 \(\max(L,R)\) 的范围(例如 \(P=21 \sim 22\) 足以覆盖 \(2\times10^6\) 级别坐标)。
  • 为每个查询计算 ord = hilbertOrder(L, R, P, 0),再按 ord 从小到大排序即可。

参考实现(可直接替换比较器)
下面给出一个常用且稳定的 Hilbert 编码函数与最小骨架。你只需把上一卷的 Query 加个 ord 字段,或在排序时临时计算并比较即可。

#include <bits/stdc++.h>
using namespace std;

// 计算 (x,y) 在 2^pow × 2^pow 网格上的 Hilbert 次序
static inline long long hilbertOrder(int x, int y, int pow, int rot)
{
    if (pow == 0)
        return 0;
    int h = 1 << (pow - 1);
    int seg = (x < h) ? ((y < h) ? 0 : 3) : ((y < h) ? 1 : 2);
    seg = (seg + rot) & 3;
    static const int nxt[4] = {3, 0, 0, 1};
    int nx = x & (h - 1), ny = y & (h - 1);
    int nrot = (rot + nxt[seg]) & 3;
    long long add = hilbertOrder(nx, ny, pow - 1, nrot);
    long long base = 1LL << (2 * pow - 2);
    if (seg == 1 || seg == 2)
        return 1LL * seg * base + add;
    else
        return 1LL * seg * base + (base - add - 1);
}

struct Query
{
    int l, r, id;
    long long ord;
};

int main()
{
    ios::sync_with_stdio(false);
    cin.tie(0);
    int n;
    cin >> n;
    vector<int> a(n + 1);
    for (int i = 1; i <= n; ++i)
        cin >> a[i];

    int q;
    cin >> q;
    vector<Query> qs(q);
    for (int i = 0; i < q; ++i)
    {
        int L, R;
        cin >> L >> R;
        qs[i] = {L, R, i, 0};
    }
    int P = 0;
    while ((1 << P) <= max(n, 1))
        ++P; // 估一个 pow
    for (auto &x : qs)
        x.ord = hilbertOrder(x.l, x.r, max(P, 1), 0);
    sort(qs.begin(), qs.end(), [&](const Query &A, const Query &B) { return A.ord < B.ord; });

    vector<int> cnt(1 << 20, 0); // 按需换成离散化后的大小
    vector<int> ans(q);
    int curL = 1, curR = 0, cur = 0;

    auto add = [&](int i) {
        int &c = cnt[a[i]];
        if (c == 0)
            ++cur;
        ++c;
    };
    auto rem = [&](int i) {
        int &c = cnt[a[i]];
        --c;
        if (c == 0)
            --cur;
    };

    for (auto qu : qs)
    {
        int L = qu.l, R = qu.r;
        while (curL > L)
            add(--curL);
        while (curR < R)
            add(++curR);
        while (curL < L)
            rem(curL++);
        while (curR > R)
            rem(curR--);
        ans[qu.id] = cur;
    }
    for (int i = 0; i < q; ++i)
        cout << ans[i] << '\n';
    return 0;
}

7.3 Peano 排序:另一条曲线的思路

Peano 是把平面划成 \(3^k\times 3^k\) 的九宫格递归,访问顺序呈“Z-蛇形”与旋转组合。相较 Hilbert,它在某些实现里可获得更规整的分块递归,常数也相当可观。若你有兴趣做进一步微调或三维扩展(带修改时把“时间”也纳入曲线),Peano 是值得一试的替代项。

何时选 Peano?

  • 当你的平台/数据分布下,Hilbert 的旋转规则带来的分支开销稍重,或你准备做三维曲线(把 \(T\) 一起映射)时,Peano会有不错的工程手感。

7.4 与“按块排序”的取舍

  • 优点:空间曲线更“平滑”,相邻查询平均改动更小,cache 友好;代码里不再出现块奇偶交替的分支。
  • 缺点/注意:编码函数的递归/位运算有少量开销;若查询本就稀疏且 \(Q\) 不大,收益可能不明显。极端题目中,还要注意你的桶/结构是否因为“访问轨迹”而膨胀(有讨论指出在特定题目里 Hilbert 写法出现了 MLE,需要你回到离散化与桶布局本身找原因,而不是曲线本身的锅)。

8. 带修改莫队(三维 / 时间维 T)

8.1 模型

当查询与“点修改”穿插出现时,我们把时间也当成一维:

  • 对每条查询 \(q_i=(L_i,R_i,t_i)\),其中 \(t_i\) 表示这条查询之前已经发生的修改次数;
  • 维护三指针 \((L,R,T)\) 并提供以下操作:add_left / add_right / remove_left / remove_rightapply_update / rollback_update,使得我们可以在移动到下一条查询时,把窗口与时间都调整到目标状态。

8.2 排序与复杂度直觉

  • 经典排序做法:对 \((L,R,T)\) 使用分块排序(例如 \(L\) 按块、块内按 \(R\)、再按 \(T\)),使相邻查询的总体位移尽量小;
  • 摊还复杂度仍可写成 \(O\left((N+Q)\sqrt N\right)\) 级别乘上一个与“时间维切换次数”相关的常数,具体要看你的 apply/rollback 代价;

8.3 维护策略:两套增删 + 改动“命中窗口”的判定

设修改集为 \(\{(p_k, \text{old}_k, \text{new}_k)\}\)。当 \(T\) 前移(应用第 \(T+1\) 次修改)或后退(回滚第 \(T\) 次修改)时:

  • 如果修改位置 \(p_k\) 在当前 \([L,R]\) 中,就要把旧值从计数结构里“remove”,再把新值“add”(或回滚时相反);
  • 若不在窗口内,只需更新数组本身,不触碰计数结构。
    这保证“时间维移动”的代价与普通 add/remove 同阶,是带修改莫队能跑起来的关键。

8.4 实战细节与坑

  • 离散化 依旧重要:值域大时先压缩到 \([0..M)\),便于 O(1) 桶;
  • 排序块长 可按经验用 \(B \approx N^{2/3}\)(或把 \(L\)\(R\) 用同一块长、\(T\) 用单独块长)以平衡三维移动的常数。
  • 缓存友好:若 apply/rollback 很频繁,考虑把修改按时间块聚类或尝试三维曲线排序(Peano/Hilbert);

8.5 可抄骨架

示意骨架强调流程;你只需把 add/remove/apply/rollback/get 改成你的题意即可。

#include <bits/stdc++.h>
using namespace std;

// 离散化工具
static inline void compress(vector<int> &a)
{
    vector<int> b = a;
    sort(b.begin(), b.end());
    b.erase(unique(b.begin(), b.end()), b.end());
    for (auto &x : a)
        x = int(lower_bound(b.begin(), b.end(), x) - b.begin());
}

struct Q
{
    int l, r, t, id;
}; // 查询:带时间戳 t(之前的修改数)
struct U
{
    int p, prev, nxt;
}; // 修改:位置、旧值、新值

int main()
{
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    int n;
    cin >> n;
    vector<int> a(n + 1);
    for (int i = 1; i <= n; i++)
        cin >> a[i];
    int m;
    cin >> m;

    vector<Q> qs;
    qs.reserve(m);
    vector<U> us;
    us.reserve(m);

    vector<tuple<int, int, int>> raw; // 读入操作:type, x, y
    for (int i = 0; i < m; i++)
    {
        int tp, x, y;
        cin >> tp >> x >> y;
        raw.emplace_back(tp, x, y);
    }

    // 为了离散化,把出现过的值先收集:初值 + 所有修改的新值
    vector<int> pool;
    pool.reserve(n + m + 5);
    for (int i = 1; i <= n; i++)
        pool.push_back(a[i]);
    for (auto [tp, x, y] : raw)
        if (tp == 2)
            pool.push_back(y);
    sort(pool.begin(), pool.end());
    pool.erase(unique(pool.begin(), pool.end()), pool.end());
    auto enc = [&](int v) { return int(lower_bound(pool.begin(), pool.end(), v) - pool.begin()); };
    for (int i = 1; i <= n; i++)
        a[i] = enc(a[i]);

    int T = 0; // 已应用修改数
    for (int i = 0; i < m; i++)
    {
        int tp, x, y;
        tie(tp, x, y) = raw[i];
        if (tp == 1)
        { // query
            qs.push_back({x, y, T, (int)qs.size()});
        }
        else
        { // update
            int p = x, nv = enc(y);
            us.push_back({p, a[p], nv}); // 记录修改时的“旧值”
            a[p] = nv;                   // 把数组推进到最新,稍后执行离线回放
            T++;
        }
    }

    // 还原 a 到最初状态,准备离线回放
    for (int i = (int)us.size() - 1; i >= 0; i--)
        a[us[i].p] = us[i].prev;

    // 选择块长(经验值:三维时可用 n^(2/3) 级别)
    int nblk = max(1, (int)pow(n, 2.0 / 3.0));
    sort(qs.begin(), qs.end(), [&](const Q &A, const Q &B) {
        int b1 = A.l / nblk, b2 = B.l / nblk;
        if (b1 != b2)
            return b1 < b2;
        int b3 = A.r / nblk, b4 = B.r / nblk;
        if (b3 != b4)
            return b3 < b4;
        return A.t < B.t;
    });

    // 计数结构(示例:维护“不同数个数”)
    int V = (int)pool.size();
    vector<int> cnt(V, 0);
    int cur = 0;

    auto add = [&](int pos, vector<int> &arr) {
        int &c = cnt[arr[pos]];
        if (c == 0)
            ++cur;
        ++c;
    };
    auto rem = [&](int pos, vector<int> &arr) {
        int &c = cnt[arr[pos]];
        --c;
        if (c == 0)
            --cur;
    };

    // 时间维应用/回滚
    auto apply = [&](int k, int L, int R, vector<int> &arr) {
        int p = us[k].p, nv = us[k].nxt, ov = arr[p];
        if (L <= p && p <= R)
        {
            // 先把旧值移出,再放新值
            --cnt[ov];
            if (cnt[ov] == 0)
                --cur;
            if (cnt[nv] == 0)
                ++cur;
            ++cnt[nv];
        }
        arr[p] = nv;
    };
    auto rollb = [&](int k, int L, int R, vector<int> &arr) {
        int p = us[k].p, ov = us[k].prev, nv = arr[p];
        if (L <= p && p <= R)
        {
            --cnt[nv];
            if (cnt[nv] == 0)
                --cur;
            if (cnt[ov] == 0)
                ++cur;
            ++cnt[ov];
        }
        arr[p] = ov;
    };

    vector<int> ans(qs.size());
    int L = 1, R = 0, t = 0; // 当前区间与时间
    // 把 a 作为“活动数组”,随时间维变更
    vector<int> arr = a;

    for (auto q : qs)
    {
        while (t < q.t)
            apply(t++, L, R, arr);
        while (t > q.t)
            rollb(--t, L, R, arr);
        while (L > q.l)
            add(--L, arr);
        while (R < q.r)
            add(++R, arr);
        while (L < q.l)
            rem(L++, arr);
        while (R > q.r)
            rem(R--, arr);
        ans[q.id] = cur; // 把“当前答案”改成你的问题的 get()
    }
    for (int x : ans)
        cout << x << "\n";
    return 0;
}

提示

  • 上面骨架用“不同数个数”示范,替换 cur/cnt 逻辑即可实现“最大频次”“阈值计数”等可加可减目标;

9. 树上莫队(路径查询)

9.1 目标与套路总览

树上路径 \((u,v)\) 的查询可通过 Euler Tour + LCA 化到序列问题,然后在序列上做莫队。关键点:

  • 对树做 DFS 得到 dfn[u] 与“进入/离开”的欧拉序列;
  • 每个节点出现两次(进与出),配合“存在性翻转”技巧:当指针经过某个结点的任一出现位置时,就“翻转”该节点在当前集合中的存在状态;
  • 对于路径 \((u,v)\),若 lca(u,v)=w,则在区间答案上补偿 LCA \(w\) 一次(因为欧拉映射带来“端点计数少一次”的现象)。

9.2 Euler Tour 与 LCA 预处理

  • LCA 可用 倍增Euler Tour + RMQ
  • Euler Tour 具体做法:DFS 记录访问序列与深度,或只记录首次出现 dfn[u] 与节点的进出时间 tin/tout,供判定祖先关系使用。

9.3 查询映射

常用两种映射方式:

  1. 成对出现 + 翻转法(最流行):构造长度 \(2N\) 的欧拉序列 euler[1..2N],每个节点出现两次;把每条路径 \((u,v)\) 转成对欧拉序列上的一个区间 \([L,R]\),指针移动时对访问到的欧拉位置 \(pos\) 的节点 x 执行 toggle(x)

    • x 当前不在集合中,则 add(x);否则 remove(x)
    • 当区间覆盖完整的路径时,集合中的节点刚好是路径上的“每个节点的奇偶出现”之和为 1 的那些,再对 LCA 做一次补偿(若 LCA 不在集合或需要权值计入)。
  2. 基于 dfs 序的单出现映射:维护每个节点的首次出现 dfn[u],把路径拆解为两段并搭配 LCA 修正(实现细节略多,本文不展开)。

9.4 可加可减目标与典型维护

  • 不同数个数 / 频次类:对节点权值做离散化与计数桶;
  • 阈值问题(如“≥K 次的值有多少个”)可沿用数组上莫队的“双层桶”技巧;
  • 边权/边信息:建双倍节点或在欧拉序上记“进入边”,即可把边的属性转成在节点“进入时添加”的属性。

9.5 可抄骨架

省略若干细节。把 add/remove/toggle 改成你的题意即可。

#include <bits/stdc++.h>
using namespace std;

struct Q
{
    int l, r, id, u, v, w;
};
const int MAXN = 200000 + 5;

int n, q;
vector<int> g[MAXN];
int val[MAXN];
int up[20][MAXN], dep[MAXN], tin[MAXN], tout[MAXN], timer_ = 0;
int eul[2 * MAXN];
int firstpos[MAXN];

void dfs(int u, int p)
{
    up[0][u] = p;
    dep[u] = (p == 0 ? 0 : dep[p] + 1);
    tin[u] = ++timer_;
    eul[timer_] = u;
    for (int k = 1; k < 20; k++)
        up[k][u] = up[k - 1][u] ? up[k - 1][up[k - 1][u]] : 0;
    for (int v : g[u])
        if (v != p)
        {
            dfs(v, u);
        }
    tout[u] = ++timer_;
    eul[timer_] = u;
}
int lca(int a, int b)
{
    if (dep[a] < dep[b])
        swap(a, b);
    int d = dep[a] - dep[b];
    for (int k = 19; k >= 0; k--)
        if (d >> k & 1)
            a = up[k][a];
    if (a == b)
        return a;
    for (int k = 19; k >= 0; k--)
        if (up[k][a] != up[k][b])
        {
            a = up[k][a];
            b = up[k][b];
        }
    return up[0][a];
}

int main()
{
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    cin >> n >> q;
    for (int i = 1; i <= n; i++)
        cin >> val[i];
    for (int i = 1; i < n; i++)
    {
        int u, v;
        cin >> u >> v;
        g[u].push_back(v);
        g[v].push_back(u);
    }
    dfs(1, 0);

    vector<int> pool(n);
    for (int i = 1; i <= n; i++)
        pool[i - 1] = val[i];
    sort(pool.begin(), pool.end());
    pool.erase(unique(pool.begin(), pool.end()), pool.end());
    for (int i = 1; i <= n; i++)
        val[i] = int(lower_bound(pool.begin(), pool.end(), val[i]) - pool.begin());

    vector<Q> qs;
    qs.reserve(q);
    for (int i = 0; i < q; i++)
    {
        int u, v;
        cin >> u >> v;
        if (tin[u] > tin[v])
            swap(u, v);
        int w = lca(u, v);
        int L, R;
        if (w == u)
        {
            L = tin[u];
            R = tin[v];
        }
        else
        {
            L = tout[u];
            R = tin[v];
        }
        qs.push_back({L, R, i, u, v, w});
    }

    int B = max(1, int(sqrt(2 * n)));
    sort(qs.begin(), qs.end(), [&](const Q &A, const Q &BQ) {
        int blA = A.l / B, blB = BQ.l / B;
        if (blA != blB)
            return blA < blB;
        return (blA & 1) ? A.r > BQ.r : A.r < BQ.r;
    });

    vector<int> cnt(pool.size() + 1, 0), in(n + 1, 0), ans(q);
    int cur = 0;

    auto addnode = [&](int x) {
        int &c = cnt[val[x]];
        if (c == 0)
            ++cur;
        ++c;
    };
    auto remnode = [&](int x) {
        int &c = cnt[val[x]];
        --c;
        if (c == 0)
            --cur;
    };
    auto toggle = [&](int pos) {
        int x = eul[pos];
        if (in[x])
        {
            remnode(x);
            in[x] = 0;
        }
        else
        {
            addnode(x);
            in[x] = 1;
        }
    };

    int L = 1, R = 0;
    for (auto qu : qs)
    {
        while (L > qu.l)
            toggle(--L);
        while (R < qu.r)
            toggle(++R);
        while (L < qu.l)
            toggle(L++);
        while (R > qu.r)
            toggle(R--);

        int w = qu.w;
        if (!in[w])
            addnode(w);
        ans[qu.id] = cur;
        if (!in[w])
            remnode(w);
    }
    for (int i = 0; i < q; i++)
        cout << ans[i] << "\n";
    return 0;
}

10. 与平方分块 / 稀疏表 / 分治的边界与取舍

一句话版总览

  • 问题“可离线 + 可加可减”→ 往莫队靠;
  • 幂等可合并(min/max/gcd/RMQ)→ 稀疏表一招制胜;
  • 在线强更新、结构化合并(区间加/和/最值)→ 线段树/树状数组/分块;
  • 树上:路径类倾向“树上莫队 + LCA”,子树/整棵并集统计倾向“DSU on tree(small-to-large)”。

10.1 莫队 vs. 平方分块(Sqrt Decomposition)

两者都源自“按块组织工作量”,但切入点不同:

  • 莫队:把查询离线排序,尽量缩短相邻查询间 \([L,R]\)指针移动,靠 add/remove 的常数级更新取胜;
  • 平方分块:把数据分块,块内与块间分别维护可合并信息(如块内和、块内最大值),查询在“整块 + 两个散块”中合并答案,更新在“块内”处理。
    当你需要在线地(不可离线)回答许多“区间和/最值/计数”且更新操作不复杂时,分块/树状数组/线段树会比莫队自然;当所有查询可离线、且 add/remove 足够轻时,莫队通常常数更稳。

10.2 幂等可合并问题:稀疏表 > 莫队

如果目标运算是幂等且可合并(如 RMQ 的 min/max、gcd),直接用稀疏表:预处理 \(O(n\log n)\),查询 \(O(1)\),而且实现极短;这类问题使用莫队往往画蛇添足。

10.3 树上问题:树上莫队 vs. DSU on tree

  • 树上莫队(路径类):把路径 \((u,v)\) 通过 Euler Tour + LCA 映射到序列区间,配合“翻转存在性”维护路径上的点/权统计;适合路径上不同数/频率函数等“可加可减”的答案。
  • DSU on tree / small-to-large(子树类):把每个子树的一堆信息“小并大”累加,摊还 \(O(n\log n)\) 甚至近线性;适合子树并集这类天然“增而不减”的统计(不同色数、出现次数≥K等)。

经验分界:路径就用树上莫队,子树就用 DSU on tree(非绝对)。如果问题两者都能做,谁的 add/remove/合并常数更低,就选谁。

10.4 分治/离线技巧何时更优

像“区间逆序对/小于等于 K 的对数/二维偏序”之类,本质是可排序维度多、答案可拆为前缀贡献的题,常选 CDQ/归并分治 + 树状数组,而不是莫队。

它们利用“按一个维度排序,另一个维度用数据结构”的套路自然离线,少指针折返、更 Cache-friendly。莫队当然也能硬上,但往往常数更大、实现更绕。

10.5 空间填充曲线排序与卡常地带

当你确认莫队是正解但常数吃紧(尤其 add/remove 很便宜时),用 HilbertPeano 排序通常显著提速。

11. 可维护函数族与“依赖频次向量”的问题

核心判据
把当前窗口的内容抽象成频次向量 \(c_x=\#\{a_i=x\}\)。若答案 \(F\) 能写成对 \(\{c_x\}\)可逆增删(把某个 \(c_x\) 改为 \(c_x\pm1\) 时能 \(O(1)\) 更新 \(F\)),就天然适合莫队。

11.1 经典“依赖频次”的可维护目标

  • 不同数个数:维护 cnt[x] 与“活跃值计数”;0→1 加一,1→0 减一。
  • 等值对/三元组计数:维护 \(\sum_x \binom{c_x}{2}\)\(\sum_x \binom{c_x}{3}\) 即可;增删时只改动涉及的单个 \(x\) 的项。
  • 最大频次 / 至少 K 次的值的个数:双层桶 bucket[f]=#{x: c_x=f},再维护 curMaxF 或“\(\ge K\)”的计数。
  • MEX:本质是找最小的 \(x\) 满足 \(c_x=0\)。用“值域分块的零计数桶”或位集/树结构即可摊还 \(O(\sqrt V)\)\(O(\log V)\) 维护。
  • “前缀哈希/前缀异或”派生类:典型如 CF 617E “XOR and Favorite Number”。把原数组转为前缀异或 \(p[i]\),区间询问化为“统计区间内有多少对 \((i,j)\) 使 \(p[i]\oplus p[j]=K\)”;维护“前缀值频次”即可用莫队在 \(O(1)\) 增删中更新答案。

11.2 需要小心或不推荐的目标

  • 严格“众数的值”:在莫队下维护“最大频次对应的某个值”通常要更重的结构(多层桶/有序结构),代码与常数显著上升;若赛题允许,常改为“最大频次/≥K 的个数/是否≥K”。
  • 非可逆、强顺序依赖:例如“把答案写成区间有序统计第 k 小且频繁变化”的需求,add/remove 往往需要平衡树维护,常数、实现复杂度双高;若题目允许在线结构,更宜直接线段树/主席树。
  • 跨元素强相关的复杂代价式:一旦 add/remove 需要“全局重算”或扫描多个桶,莫队意义不大。

11.3 快速检查清单(给出就能上莫队)

  1. 离线可行吗?
  2. 你能把答案写成 \(\{c_x\}\) 上的函数吗?
  3. 当某个 \(c_x\) 改为 \(c_x\pm1\) 时,是否能 \(O(1)\) 更新答案?
  4. 值域是否可离散化到可承受的桶大小?
  5. add/remove 很快但 TLE,是否考虑 Hilbert/Peano 排序降常?

12. 常见坑与 Debug 清单

12.1 区间闭包与指针顺序

  • 全文统一用左闭右闭 [L, R]。一旦写成半开区间或把 add/remove 的顺序写反,答案会在端点处“抖动”。建议把四条指针移动写成固定模板:
while (curL > L)
    add(--curL);
while (curR < R)
    add(++curR);
while (curL < L)
    rem(curL++);
while (curR > R)
    rem(curR--);

并在小数据上做对拍。

12.2 值域离散化与桶大小

  • 值域过大时必须压缩为连续小整数,否则 cnt[] 会炸内存或 TLE。把“初值 + 所有可能写入的新值(带修改题)”一起收集再离散化,避免修改时落到“未分配”区间。

12.3 计数溢出与类型选择

  • 诸如 CF 617E“统计等值对/异或对”的题,答案可能达 \(O(n^2)\)ans、中间乘法、组合数累加都要用 64 位(long long)或更大。

12.4 排序比较器与稳定性

  • 经典块排序:按 L/B 分块,块内 R 交替升降以减小“回拉”。比较器写错(比如奇偶判断反了)会导致频繁“折返”。若改用 Hilbert/Peano,给每个查询计算一个 64 位序值 ord 后直接按 ord 排即可。

12.5 多测试组与清零

  • 评测常有多组数据,cnt[]in[](树上莫队的“是否在集合”标记)、答案累加器都要在每组结束后完整复位。若值域很大,考虑“时间戳数组”替代全清零(以 vis[val] == stamp 判断是否“被使用过”)。

12.6 树上莫队的 LCA 补偿

  • 路径 \((u,v)\) 做成欧拉序区间后,集合中节点是“出现奇偶为 \(1\) 的那些”,LCA 需单独补一次(如果题意需要计入 LCA 的权)。忘记补偿或重复补偿都会 WA。

12.7 带修改(三维)中的时间维操作

  • apply_update/rollback_update 只有在修改位置落在当前 [L,R] 内时,才对计数结构做“先移出旧值、再加入新值”(回滚反之);否则只更新底层数组。把这点写反会出现“幽灵贡献”。

12.8 “玄学 RE/MLE”与内存布局

  • 遇到这种案例,优先自查越界、初始化、下标与闭包,再考虑更换编译参数或局部调整内存布局。

12.9 Hilbert/Peano 的参数与溢出

  • Hilbert 的 pow 要覆盖坐标最大值(如 \(2^{21}\) 能盖到约两百万的坐标),序值建议用 64 位;Peano 的 3 叉递归也要避免中间溢出。基准与教程会给出安全的模板与参数范围。

12.10 树与欧拉序的前置

  • 树上莫队前需要可靠的 LCA(倍增或欧拉+RMQ)。把 tin/touteuler[] 写乱会直接影响“翻转存在性”正确性。

13. 毒瘤卡常:让莫队飞起来!

警告:本部分为拓展阅读,对莫队算法还不是很熟悉的同学请尽量跳过,以防误导。

13.1 排序:从块排序切到空间曲线

  • add/remove 已经 O(1) 的前提下,排序序列的“局部性”显著影响常数。把 [L,R] 映射到 Hilbert 序通常能明显提速;Peano 作为另一条空间曲线,在一些实现里也有良好常数。

13.2 三维(带修改)时的块长选择

  • 三维莫队常用经验:把 L、R、T 都分块或让 T 用更大的块;不少实现用 \(B\approx n^{2/3}\) 平衡三维移动。也有人采用“(L 块, R 块, T)”三层键按字典序。

13.3 计数结构的局部性与层次化桶

  • “最大频次/≥K 次个数”之类用双层桶cnt[val] + bucket[freq],并缓存当前 maxFreq 或“≥K 的总数”。这种设计把每次更新压到常数,避免全局扫描。

13.4 分支优化

  • add/remove 内尽量减少分支、合并相邻自增自减;
  • 若你用块排序,预先把每个位置的块号存好,比较器里只读整型字段,避免反复 i/B

13.5 树上莫队:翻转快路径

  • toggle(pos) 写成“命中节点 x:若 in[x] 为 1 就移除,否则加入”,并让 addnode/remnode 极简;权值离散化后 cnt[val[x]] 连续存放、命中率更高。

13.6 题型边界上的更优替代

  • 若运算幂等且可合并(min/max/gcd/RMQ),优先上 稀疏表:预处理 \(O(n\log n)\)、查询 \(O(1)\),代码短常数小;
  • 若需要在线强更新(区间加/和/最值),分块/线段树/树状数组通常更适合;
  • 子树并集类在树上常用 DSU on tree

14. FAQ

Q1:我的区间问题到底适不适合用莫队?

A:三步快检:① 查询可离线;② 答案能写成“只依赖频次数组”的函数;③ add/remove 在单点进出时可 \(O(1)\) 或摊还 \(O(1)\) 更新。三步都过,基本就合适。若是 RMQ/GCD 这类幂等可合并问题,优先稀疏表;若需要大量在线更新(区间加/和/最值),优先树状数组/线段树/分块。

Q2:块长一定取 \(\sqrt{N}\) 吗?

A:默认取 \(\lfloor\sqrt N\rfloor\) 就很稳;在 \(Q\) 极大或极小时,工程上有人用 \(B\approx N/\sqrt{\max(1,Q)}\) 做微调,常数会更舒服,但量级不变。带修改(三维)常用 \(B\sim N^{2/3}\) 一类的均衡选择。

Q3:为什么很多人把排序从“分块排序”换成 Hilbert/Peano?

A:为降低常数。空间填充曲线能让相邻查询在 \((L,R)\) 平面上更“邻近”,减少指针大回摆与 cache miss;当 add/remove 很便宜时,曲线排序往往显著提速。

Q4:带修改的“三维莫队”怎么判定一次修改是否影响当前答案?

A:维护时间指针 \(T\)。当应用/回滚第 \(k\) 次修改 \((p,\,\text{old}\to\text{new})\) 时,只有当 \(p\in[L,R]\) 才对计数结构执行“先移出旧值、再加入新值”(或相反)。否则只改底层数组,不动计数桶。

Q5:树上路径怎么做莫队?

A:Euler Tour + LCA。把树转欧拉序,每个点出现两次;指针移动时对经过的位置做“存在性翻转”(在/不在当前集合)。路径 \((u,v)\) 的答案需要对 LCA 做一次补偿。

Q6:众数为什么在莫队里常常“很难写”?

A:因为“最大频次对应的值”要在频次层里再维护候选,结构复杂、常数大。实战里常把需求改成“最大频次”“出现次数≥K 的值的个数”等,配双层桶就很稳。

Q7:MEX 如何高效维护?

A:值域分块。用 cnt[val] 统计频次,再为值域分块维护“该块中 cnt==0 的位置个数”。查询 MEX 时先定位首个非满块,再在块内线性找第一个 cnt==0

Q8:前缀异或/哈希的题能用莫队吗?

A:能,且常见。把原问题改写成“统计子区间内满足某性质的前缀对数”,add/remove 只在计数桶里做常数增删,是莫队的高质量题型(如区间异或等于 K)。

Q9:多组数据如何避免频繁清空大数组?

A:常见办法是“时间戳数组”:用 vis[val]==stamp 判断是否使用过,换测试组直接 ++stamp,省去全清零。

Q10:为什么我把比较器写好了还是 TLE?

A:优先排查三点:① add/remove 是否含重分支或隐性 \(O(\log V)\) 操作;② 值域是否离散化充分;③ 排序是否产生了过多的“折返”。常数吃紧时改用 Hilbert/Peano。

Q11:答案溢出怎么办?

A:像“等值对数”“异或对数”上界近 \(O(n^2)\),答案必须用 64 位整型(或更大)。中间乘法也要注意提升类型。

Q12:树上莫队与 DSU on tree(小并大)怎么取舍?

A:路径类通常树上莫队更自然;子树并集类通常 DSU on tree 更顺手。若两者都能做,看谁的增删/合并常数更低。

Q13:C++ 实现还有哪些卡常细节?

A:把块号预存为整型字段;add/remove 合并分支、少做条件判断;频次与派生桶放连续大数组,尽量走顺序访问路径。

Q14:Hilbert/Peano 的参数怎么选才不炸?
A:Hilbert 的阶数覆盖最大坐标即可(常见用 64 位序值);Peano 是 \(3^k\) 网格,注意中间乘加别溢出。两者在实现上都可直接套标准模板。

Q15:什么时候该果断放弃莫队?

A:当 add/remove 无法设计成 \(O(1)\),或问题有更直接的“可合并结构”(稀疏表/线段树/分治),就别硬上了。选对工具,收益最大。

15. 训练题单(分阶进阶)

入门(把三件套写顺手)

  • SPOJ:DQUERY
  • Codeforces:220B Little Elephant and Array

经典

  • Codeforces:86D Powerful Array (莫队名题)
  • Codeforces:617E XOR and Favorite Number
  • AtCoder ABC174 F:Range Set Query

带修改(三维莫队)

  • Codeforces:940F Machine Learning

树上莫队(路径/子树场景)

  • Codeforces:375D Tree and Queries

那些我做过的莫队好题

  • Luogu P1972
  • Luogu P1903
  • Luogu P1494
  • Luogu P3709
  • Luogu P4074

16. 推荐阅读

  • Codeforces 博客:

    • 《Everything on Mo's Algorithm》
    • 《Mo’s Algorithm on Trees [Tutorial]》
    • 《Mo's algorithm and 3D Mo》
    • 《An alternative sorting order for Mo’s algorithm》(Hilbert 曲线排序)
    • 《Space-Filling Curves for Mo’s Algorithm》(Peano 比较器)
  • USACO Guide:

    • Square Root Decomposition(含 Mo’s Algorithm 小节与题单)
posted @ 2025-08-26 22:18  薛儒浩  阅读(29)  评论(0)    收藏  举报