当前位置: 首页 > news >正文

莫队算法 —— 将暴力玩出花

莫队算法 —— 将暴力玩出花

一、 为什么需要莫队?—— 暴力法的瓶颈

我们已经学会了用分块处理一些在线的区间问题。现在,我们来看一类特殊的离线区间查询问题。

“离线”意味着我们可以把所有查询先读进来,再按我们喜欢的顺序去处理它们。

思考一个问题:

给定一个长度为 N 的数组,M 次询问。每次询问一个区间 [l, r],问区间内有多少种数字至少出现了2次?

那我们回到最朴素的暴力。

  • 纯暴力:对于每个询问 (l, r),都for一遍,用数组统计词频。复杂度 O(M * N),无法接受。

  • 聪明的暴力:我们发现,如果已经知道了区间 [l, r] 的答案,我们似乎可以很快地算出相邻区间的答案。

    • [l, r+1] 的答案:在 [l, r] 的基础上,加入 a[r+1] 这个元素。
    • [l-1, r] 的答案:在 [l, r] 的基础上,加入 a[l-1] 这个元素。
    • [l+1, r] 的答案:在 [l, r] 的基础上,删去 a[l] 这个元素。
    • [l, r-1] 的答案:在 [l, r] 的基础上,删去 a[r] 这个元素。

这种“加入/删除一个元素”的操作,通常可以在 O(1)O(log N) 的时间内完成。这给了我们一个启发:我们可以维护一个当前区间 [L, R],通过不断移动左右端点 LR,来回答所有的查询。

新的瓶颈:如果我们按照读入的顺序处理询问,[L, R] 指针可能会在整个数组上“疯狂横跳”。比如前一个询问是 [1, 10],后一个询问是 [99990, 100000],指针移动的距离是 O(N)M 次询问,总复杂度最坏还是 O(M * N)

莫队算法的核心,就是解决这个问题:通过对询问进行巧妙的排序,最小化指针移动的总距离。

二、 莫队的核心思想 —— 分块排序

莫队算法的精髓在于它独特的排序策略,它将分块思想运用到了对“询问”的排序上。

  1. 分块:将长度为 N原数组下标分成 √N 个块,每块长度为 s = √N
  2. 排序:对所有询问 (l, r) 进行排序,规则如下:
    • 第一关键字:以询问的左端点 l 所在的块的编号为第一关键字,升序排列。
    • 第二关键字:如果 l 在同一个块内,则以询问的右端点 r 为第二关键字,升序排列。
// 排序规则
bool operator < (const Query& a, const Query& b) {if (belong[a.l] != belong[b.l]) {return belong[a.l] < belong[b.l]; // l 不在同块,按块编号排}return a.r < b.r; // l 在同块,按 r 排
}

为什么这样排序是高效的?
我们来直观地感受一下指针的移动:

  • 左指针 L

    • 当处理同一块内的所有询问时,l 的变化范围不会超过块长 √N。因此 L 每次也会在一个 √N 的范围内移动。
    • 当处理完一个块,换到下一个块时,L 才可能发生一次大的跳跃。
  • 右指针 R

    • 当处理同一块内的所有询问时,由于这些询问的 r 是升序的,所以 R 指针会单调向右移动,从左到右扫一遍。
    • 当处理完一个块,换到下一个块时,R 的位置是无序的,可能会从数组末尾跳回开头。

现在,我们来严格分析一下它的时间复杂度。

三、 复杂度分析

假设我们有 M 个询问,数组长度 N,块长 s = √N,块数n = √N。每次指针移动后更新答案的代价是 O(1)

1. 右指针 R 的移动

  • 块内移动:对于左端点在同一个块 i 的所有询问,它们的右端点 r 是递增的。所以,R 指针在处理这整个块的询问时,最多从 1 移动到 N,总移动距离是 O(N)。因为有 √N 个块,所以这部分总移动距离是 O(N * √N)
  • 块间移动:当左端点从一个块 i 换到下一个块 i+1 时,R 指针可能会从 N 跳回 1。这个过程最多发生 √N - 1 次。每次跳跃的成本是 O(N)。所以这部分总移动距离是 O(N * √N)

综合来看,右指针 R 的总移动次数是 O(N√N)

2. 左指针 L 的移动

  • 块内移动:对于任意一个询问,L 从上一个询问的左端点 l_{prev} 移动到当前询问的左端点 l_{cur}。由于 l_{prev}l_{cur} 在同一个块,它们之间的距离最多是块长 s,即 √N。所以每次移动距离是 O(√N)
  • 块间移动:当左端点从一个块 i 移动到下一个块 i+1 的时,移动距离最多是 2s,即 O(√N)

L 的每一次移动,无论是块内还是块间,距离都不会超过 O(√N)。总共有 M 个询问,所以左指针 L 的总移动次数是 O(M√N)


让我们用一个正确且有效的例子来演示这个过程

假设我们的查询是这些:(2, 5), (4, 9), (1, 18), (7, 8), (8, 12), (5, 20)

标准排序(无优化)
  1. 分块:左端点 l 属于块1(1-4)的有 (2, 5), (4, 9), (1, 18)。左端点 l 属于块2(5-8)的有 (7, 8), (8, 12), (5, 20)

  2. 排序

    • 块1内:按 r 升序 -> (2, 5), (4, 9), (1, 18)
    • 块2内:按 r 升序 -> (7, 8), (8, 12), (5, 20)
  3. 处理顺序与 R 指针移动

处理顺序查询 (l, r)R 的移动R 当前位置
1(2, 5)0 -> 55
2(4, 9)5 -> 99
3(1, 18)9 -> 1818
—换块—
4(7, 8)18 -> 88
5(8, 12)8 -> 1212
6(5, 20)12 -> 2020

总时间复杂度
将两部分加起来,总复杂度为 O(N√N + M√N)。如果 NM 同阶,就是 O(N√N)

**3. 奇偶性排序优化 **
我们发现,R 指针在换块时的大幅回跳是性能瓶颈之一。可以这样优化:/

  • 如果左端点 l 所在的块编号是奇数,则按 r 升序排。
  • 如果左端点 l 所在的块编号是偶数,则按 r 降序排。
// 奇偶性排序
bool operator < (const Query& a, const Query& b) {if (belong[a.l] != belong[b.l]) {return belong[a.l] < belong[b.l];}// 如果 belong[a.l] 是偶数,则 r 降序if (belong[a.l] % 2 == 0) {return a.r > b.r;}// 如果 belong[a.l] 是奇数,则 r 升序return a.r < b.r;
}

这样,R 指针在处理完一个块后,换到下一个块时,就无需从 N 回跳到 1,而是从当前位置继续“回头”扫描,如同耕地一样。这能省掉 R 指针换块时的 O(N) 的开销,总复杂度依然是 O(N√N + M√N),但常数会小很多。

假设我们的查询是这些:(2, 5), (4, 9), (1, 18), (7, 8), (8, 12), (5, 20)


场景二:奇偶性排序优化
  1. 排序

    • 块1(奇数块):按 r 升序 -> (2, 5), (4, 9), (1, 18)
    • 块2(偶数块):按 r 降序 -> (5, 20), (8, 12), (7, 8)
  2. 处理顺序与 R 指针移动

处理顺序查询 (l, r)R 的移动R 当前位置
1(2, 5)0 -> 55
2(4, 9)5 -> 99
3(1, 18)9 -> 1818
—换块—
4(5, 20)18 -> 2020
5(8, 12)20 -> 1212
6(7, 8)12 -> 88

在这个优化版本中,当从块1换到块2时,R 指针从 18 平滑地移动到了 20完全避免了大幅回跳。然后,在处理整个块2的过程中,R 指针再从右向左“扫描”回来。

四、 经典例题:小Z的袜子

我们来看知乎文章里的经典例题 P1494 [国家集训队]小Z的袜子。

题意:区间 [l, r] 内,随机取两只袜子,颜色相同的概率是多少?化为最简分数。

分析

  • 设区间长度为 len = r - l + 1
  • len 只袜子中取两只,总方案数是 C(len, 2) = len * (len-1) / 2
  • 设颜色 i 出现了 cnt[i] 次。取出两只颜色为 i 的方案数是 C(cnt[i], 2) = cnt[i] * (cnt[i]-1) / 2
  • 所有颜色都算上,取出两只相同颜色的总方案数是 Σ C(cnt[i], 2)
  • 概率 P = (Σ C(cnt[i], 2)) / C(len, 2) = (Σ (cnt[i]² - cnt[i])) / (len * (len-1))

核心任务
推导最优块长

我们来一步步推导。

1. 重新审视时间复杂度的构成

莫队算法的总时间开销主要来自两部分:

  1. 左指针 L 的移动开销
  2. 右指针 R 的移动开销

(排序的开销 O(M log M) 通常被指针移动的开销覆盖,我们在此暂不考虑。)

我们的目标是:选择一个合适的块长 s,使得这两部分开销之和最小。

2. 用块长 s 来表示开销

设数组长度为 N,询问数量为 M,块长为 s
那么,数组被分成的块数就是 ceil(N/s),我们近似看作 N/s

  • 左指针 L 的总移动距离:

    • 块内移动:当处理同一块内的询问时,左端点 l 的变化范围不会超过块长 s
    • 块间移动:当从一个块换到下一个块时,左指针 L 最多移动 2s 的距离。
    • 对于 M 次询问,每次询问 L 最多移动 O(s) 的距离。所以,L 的总移动次数是 O(M * s)
  • 右指针 R 的总移动距离:

    • 块内移动:对于左端点在同一个块的所有询问,由于我们对右端点 r 进行了排序(无论是单调递增还是奇偶性优化),R 指针在处理这一个块的所有询问时,最多会把 1N 的范围完整地扫一遍(或者来回扫一遍)。所以处理一个块的 R 指针移动开销是 O(N)
    • 总移动:我们总共有 N/s 个块。因此,R 指针的总移动次数就是 (块数) * (处理每块的开销),即 O((N/s) * N) = O(N^2 / s)

3. 使用均值不等式求解最优 s

现在,我们得到了总的指针移动次数(时间复杂度的主要部分):
T ( s ) = M ⋅ s + N 2 s T(s) = M \cdot s + \frac{N^2}{s} T(s)=Ms+sN2
我们的任务是找到一个 s,使得 T(s) 最小。

4. 计算最低时间复杂度

我们将最优块长 s = N / √M 代入到总复杂度 T(s) 中:
T m i n = M ⋅ ( N M ) + N 2 ( N M ) T_{min} = M \cdot \left(\frac{N}{\sqrt{M}}\right) + \frac{N^2}{\left(\frac{N}{\sqrt{M}}\right)} Tmin=M(M N)+(M N)N2

T m i n = N ⋅ M + N 2 ⋅ M N T_{min} = N \cdot \sqrt{M} + N^2 \cdot \frac{\sqrt{M}}{N} Tmin=NM +N2NM

T m i n = N M + N M = 2 N M T_{min} = N\sqrt{M} + N\sqrt{M} = 2N\sqrt{M} Tmin=NM +NM =2NM

所以,总时间复杂度为 O(N√M)

#include <bits/stdc++.h>
using namespace std;
#define int long long
#define endl '\n'
#define close ios::sync_with_stdio(false), cin.tie(0), cout.tie(0)
int n, m;
int B;
int tot;
const int N = 5e4 + 10;
int belong[N];
int a[N];
int ans = 0;
int cnt[N];
struct ss
{int l;int r;int index;int fz;int fm;
};
void build()
{for (int i = 1; i <= n; i++){belong[i] = (i - 1) / B + 1;}
}
void add(int x)
{if (cnt[a[x]] >= 2){ans -= (cnt[a[x]]) * (cnt[a[x]] - 1);}++cnt[a[x]];if (cnt[a[x]] >= 2){ans += (cnt[a[x]]) * (cnt[a[x]] - 1);}
}
void del(int x)
{if (cnt[a[x]] >= 2){ans -= (cnt[a[x]]) * (cnt[a[x]] - 1);}--cnt[a[x]];if (cnt[a[x]] >= 2){ans += (cnt[a[x]]) * (cnt[a[x]] - 1);}
}
void solve()
{cin >> n >> m;B = n / sqrt(m);tot = (n + B - 1) / B;for (int i = 1; i <= n; i++){cin >> a[i];}build();vector<ss> q;for (int i = 1; i <= m; i++){int l, r;cin >> l >> r;q.push_back({l, r, i, 0, 0});}sort(q.begin(), q.end(), [&](const ss &x, const ss &y){if(belong[x.l] != belong[y.l]){return belong[x.l] < belong[y.l];}if(belong[x.l] % 2 == 0){return x.r < y.r;}else{return x.r > y.r;} });int L = 1, R = 0;for (int i = 0; i < m; i++){int cntLen = q[i].r - q[i].l + 1;q[i].fm = cntLen * (cntLen - 1);while (L > q[i].l){add(--L);}while (L < q[i].l){del(L++);}while (R < q[i].r){add(++R);}while (R > q[i].r){del(R--);}if (ans == 0 or q[i].l == q[i].r){q[i].fz = 0;q[i].fm = 1;}else{int com = __gcd(ans, q[i].fm);q[i].fz = ans / com;q[i].fm = q[i].fm / com;}}sort(q.begin(), q.end(), [&](const ss &x, const ss &y){ return x.index < y.index; });for (int i = 0; i < m; i++){cout << q[i].fz << "/" << q[i].fm << endl;}
}
signed main()
{close;solve();return 0;
}

相关文章:

  • 配置文件application.yml使用指南
  • Conformal LEC:官方学习教程
  • 大语言模型推理速度优化之模型量化实践
  • 【Elasticsearch】全文检索 组合检索
  • 正交视图三维重建 笔记 2d线到3d线
  • red-black-tree
  • 《Go语言高级编程》玩转RPC
  • axure基础操作
  • Rust高效编程实战指南
  • c++学习(五、函数高级)
  • GEO(生成式引擎优化)—— 内容创作者与企业的生死新战场
  • 掌握 MySQL 的基石:全面解读数据类型及其影响
  • HarmonyOS NEXT仓颉开发语言实战案例:动态广场
  • 单调栈一文深度解析
  • Flutter基础(路由页面跳转)
  • 【Cursor黑科技AI编程实战】
  • PMO 与IPD、CMMI、项目管理什么区别和联系
  • C++扩展 - 关键字应用 - decltype
  • 中级统计师-经济学基础知识-第三章 市场失灵与分配不公及其公共治理
  • Uni-App 小程序面试题高频问答汇总