跳转至

普通莫队算法

形式

假设 n=m,那么对于序列上的区间询问问题,如果从 [l,r] 的答案能够 O(1) 扩展到 [l1,r],[l+1,r],[l,r+1],[l,r1](即与 [l,r] 相邻的区间)的答案,那么可以在 O(nn) 的复杂度内求出所有询问的答案。

解释

离线后排序,顺序处理每个询问,暴力从上一个区间的答案转移到下一个区间答案(一步一步移动即可)。

排序方法

对于区间 [l,r], 以 l 所在块的编号为第一关键字,r 为第二关键字从小到大排序。

实现

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
void move(int pos, int sign) {
  // update nowAns
}

void solve() {
  BLOCK_SIZE = int(ceil(pow(n, 0.5)));
  sort(querys, querys + m);
  for (int i = 0; i < m; ++i) {
    const query &q = querys[i];
    while (l > q.l) move(--l, 1);
    while (r < q.r) move(++r, 1);
    while (l < q.l) move(l++, -1);
    while (r > q.r) move(r--, -1);
    ans[q.id] = nowAns;
  }
}

复杂度分析

以下的情况在 nm 同阶的前提下讨论。

首先是分块这一步,这一步的时间复杂度是 O(nnlogn+nlogn)=O(nlogn)

接着就到了莫队算法的精髓了,下面我们用通俗易懂的初中方法来证明它的时间复杂度是 O(nn)

证明

证:令每一块中 L 的最大值为 max1,max2,max3,,maxn

由第一次排序可知,max1max2maxn

显然,对于每一块暴力求出第一个询问的时间复杂度为 O(n)

考虑最坏的情况,在每一块中,R 的最大值均为 n,每次修改操作均要将 Lmaxi1 修改至 maxi 或由 maxi 修改至 maxi1

考虑 R:因为 R 在块中已经排好序,所以在同一块修改完它的时间复杂度为 O(n)。对于所有块就是 O(nn)

重点分析 L:因为每一次改变的时间复杂度都是 O(maximaxi1) 的,所以在同一块中时间复杂度为 O(n(maximaxi1))

将每一块 L 的时间复杂度合在一起,可以得到:

对于 L 的总时间复杂度为

O(n(max11)+n(max2max1)+n(max3max2)++n(maxnmaxn1))=O(n(max11+max2max1+max3max2++maxn1maxn2+maxnmaxn1))=O(n(maxn1))

(裂项求和)

由题可知 maxn 最大为 n,所以 L 的总时间复杂度最坏情况下为 O(nn)

综上所述,莫队算法的时间复杂度为 O(nn)

但是对于 m 的其他取值,如 m<n,分块方式需要改变才能变的更优。

怎么分块呢?

我们设块长度为 S,那么对于任意多个在同一块内的询问,挪动的距离就是 n,一共 nS 个块,移动的总次数就是 n2S,移动可能跨越块,所以还要加上一个 mS 的复杂度,总复杂度为 O(n2S+mS),我们要让这个值尽量小,那么就要将这两个项尽量相等,发现 Snm 是最优的,此时复杂度为 O(n2nm+m(nm))=O(nm)

事实上,如果块长度的设定不准确,则莫队的时间复杂度会受到很大影响。例如,如果 mn 同阶,并且块长误设为 n,则可以很容易构造出一组数据使其时间复杂度为 O(nn) 而不是正确的 O(n5/4)

莫队算法看起来十分暴力,很大程度上是因为莫队算法的分块排序方法看起来很粗糙。我们会想到通过看上去更精细的排序方法对所有区间排序。一种方法是把所有区间 [l,r] 看成平面上的点 (l,r),并对所有点建立曼哈顿最小生成树,每次沿着曼哈顿最小生成树的边在询问之间转移答案。这样看起来可以改善莫队算法的时间复杂度,但是实际上对询问分块排序的方法的时间复杂度上界已经是最优的了。

假设 n,m 同阶且 n 是完全平方数。我们考虑形如 [an,bn](1a,bn) 的区间,这样的区间一共有 n 个。如果把所有的区间看成平面上的点,则两点之间的曼哈顿距离恰好为两区间的转移代价,并且任意两个区间之间的最小曼哈顿距离为 n,所以处理所有询问的时间复杂度最小为 O(nn)。其它情况的数据构造方法与之类似。

莫队算法还有一个特点:当 n 不变时,m 越大,处理每次询问的平均转移代价就越小。一些其他的离线算法也具有同样的特点(如求 LCA 的 Tarjan 算法),但是莫队算法的平均转移代价随 m 的变化最明显。

例题 & 代码

例题 「国家集训队」小 Z 的袜子

题目大意:

有一个长度为 n 的序列 {ci}。现在给出 m 个询问,每次给出两个数 l,r,从编号在 lr 之间的数中随机选出两个不同的数,求两个数相等的概率。

过程

思路:莫队算法模板题。

对于区间 [l,r],以 l 所在块的编号为第一关键字,r 为第二关键字从小到大排序。

然后从序列的第一个询问开始计算答案,第一个询问通过直接暴力算出,复杂度为 O(n),后面的询问在前一个询问的基础上得到答案。

具体做法:

对于区间 [i,i],由于区间只有一个元素,我们很容易就能知道答案。然后一步一步从当前区间(已知答案)向下一个区间靠近。

我们设 col[i] 表示当前颜色 i 出现了多少次,ans 表示当前共有多少种可行的配对方案(有多少种可以选到一双颜色相同的袜子)。然后每次移动的时候更新答案:设当前颜色为 k,如果是增长区间就是 ans 加上 (col[k]+12)(col[k]2);如果是缩短就是 ans 减去 (col[k]2)(col[k]12)。这个询问的答案就是 ans(rl+12)

这里有个优化:(a2)=a(a1)2

所以 (a+12)(a2)=(a+1)a2a(a1)2=a2(a+1a+1)=a22=a

所以 (col[k]+12)(col[k]2)=col[k]

算法总复杂度:O(nn)

下面的代码中 deno 表示答案的分母 (denominator),nume 表示分子 (numerator),sqn 表示块的大小:narr 是输入的数组,node 是存储询问的结构体,tab 是询问序列(排序后的),col 同上所述。

注意:由于 ++l--r 的存在,下面代码中的移动区间的 4 个 while 循环的位置很关键,不能随意改变它们之间的位置关系。

关于四个循环位置的讨论

莫队区间的移动过程,就相当于加入了 [1,r] 的元素,并删除了 [1,l1] 的元素。因此,

  • 对于 lr 的情况,[1,l1] 的元素相当于被加入了一次又被删除了一次,[l,r] 的元素被加入一次,[r+1,+) 的元素没有被加入。这个区间是合法区间。
  • 对于 l=r+1 的情况,[1,r] 的元素相当于被加入了一次又被删除了一次,[r+1,+) 的元素没有被加入。这时这个区间表示空区间。
  • 对于 l>r+1 的情况,那么 [r+1,l1](这个区间非空)的元素被删除了一次但没有被加入,因此这个元素被加入的次数是负数。

因此,如果某时刻出现 l>r+1 的情况,那么会存在一个元素,它的加入次数是负数。这在某些题目会出现问题,例如我们如果用一个 set 维护区间中的所有数,就会出现「需要删除 set 中不存在的元素」的问题。

代码中的四个 while 循环一共有 4!=24 种排列顺序。不妨设第一个循环用于操作左端点,就有以下 12 种排列(另外 12 种是对称的)。下表列出了这 12 种写法的正确性,还给出了错误写法的反例。

循环顺序 正确性 反例或注释
l--,l++,r--,r++ 错误 $l