莫队算法 —— 将暴力玩出花
莫队算法 —— 将暴力玩出花
一、 为什么需要莫队?—— 暴力法的瓶颈
我们已经学会了用分块处理一些在线的区间问题。现在,我们来看一类特殊的离线区间查询问题。
“离线”意味着我们可以把所有查询先读进来,再按我们喜欢的顺序去处理它们。
思考一个问题:
给定一个长度为
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]
,通过不断移动左右端点 L
和 R
,来回答所有的查询。
新的瓶颈:如果我们按照读入的顺序处理询问,[L, R]
指针可能会在整个数组上“疯狂横跳”。比如前一个询问是 [1, 10]
,后一个询问是 [99990, 100000]
,指针移动的距离是 O(N)
。M
次询问,总复杂度最坏还是 O(M * N)
。
莫队算法的核心,就是解决这个问题:通过对询问进行巧妙的排序,最小化指针移动的总距离。
二、 莫队的核心思想 —— 分块排序
莫队算法的精髓在于它独特的排序策略,它将分块思想运用到了对“询问”的排序上。
- 分块:将长度为
N
的原数组下标分成√N
个块,每块长度为s = √N
。 - 排序:对所有询问
(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)
。
标准排序(无优化)
-
分块:左端点
l
属于块1(1-4)的有(2, 5)
,(4, 9)
,(1, 18)
。左端点l
属于块2(5-8)的有(7, 8)
,(8, 12)
,(5, 20)
。 -
排序:
- 块1内:按
r
升序 ->(2, 5)
,(4, 9)
,(1, 18)
- 块2内:按
r
升序 ->(7, 8)
,(8, 12)
,(5, 20)
- 块1内:按
-
处理顺序与
R
指针移动:
处理顺序 | 查询 (l, r) | R 的移动 | R 当前位置 |
---|---|---|---|
1 | (2, 5) | 0 -> 5 | 5 |
2 | (4, 9) | 5 -> 9 | 9 |
3 | (1, 18) | 9 -> 18 | 18 |
— | —换块— | — | — |
4 | (7, 8) | 18 -> 8 | 8 |
5 | (8, 12) | 8 -> 12 | 12 |
6 | (5, 20) | 12 -> 20 | 20 |
总时间复杂度:
将两部分加起来,总复杂度为 O(N√N + M√N)
。如果 N
和 M
同阶,就是 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(奇数块):按
r
升序 ->(2, 5)
,(4, 9)
,(1, 18)
- 块2(偶数块):按
r
降序 ->(5, 20)
,(8, 12)
,(7, 8)
- 块1(奇数块):按
-
处理顺序与
R
指针移动:
处理顺序 | 查询 (l, r) | R 的移动 | R 当前位置 |
---|---|---|---|
1 | (2, 5) | 0 -> 5 | 5 |
2 | (4, 9) | 5 -> 9 | 9 |
3 | (1, 18) | 9 -> 18 | 18 |
— | —换块— | — | — |
4 | (5, 20) | 18 -> 20 | 20 |
5 | (8, 12) | 20 -> 12 | 12 |
6 | (7, 8) | 12 -> 8 | 8 |
在这个优化版本中,当从块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. 重新审视时间复杂度的构成
莫队算法的总时间开销主要来自两部分:
- 左指针
L
的移动开销 - 右指针
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
指针在处理这一个块的所有询问时,最多会把1
到N
的范围完整地扫一遍(或者来回扫一遍)。所以处理一个块的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)=M⋅s+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⋅(MN)+(MN)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=N⋅M+N2⋅NM
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;
}