我们已经学会了用分块处理一些在线的区间问题。现在,我们来看一类特殊的离线区间查询问题。
“离线”意味着我们可以把所有查询先读进来,再按我们喜欢的顺序去处理它们。
思考一个问题:
给定一个长度为
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)
。
排序:
r
升序 -> (2, 5)
, (4, 9)
, (1, 18)
r
升序 -> (7, 8)
, (8, 12)
, (5, 20)
处理顺序与 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)
。
排序:
r
升序 -> (2, 5)
, (4, 9)
, (1, 18)
r
降序 -> (5, 20)
, (8, 12)
, (7, 8)
处理顺序与 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
指针再从右向左“扫描”回来。
我们来看知乎文章里的经典例题 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))
。核心任务:
推导最优块长。
我们来一步步推导。
莫队算法的总时间开销主要来自两部分:
L
的移动开销R
的移动开销(排序的开销 O(M log M)
通常被指针移动的开销覆盖,我们在此暂不考虑。)
我们的目标是:选择一个合适的块长 s
,使得这两部分开销之和最小。
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)
。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)
最小。
我们将最优块长 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
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 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;
}