莫队算法,用来处理一类 离线 区间查询问题,具有编程复杂度低,易于扩展的优点。

可以方便地扩展到树上,也可以以一定的代价支持修改。

序列上 + 无修改

BZOJ 2038 小 Z 的袜子

这是最普通的莫队问题。

如果序列上询问区间 $[l, r]$ 上的答案可以 $O(1)$ 地扩展到 $[l - 1, r], [l + 1, r], [l, r - 1], [l, r + 1]$ 上,也就是说,我们能够 $O(1)$ 的添加和删除一个位置的贡献,那么莫队可以在 $O(n \sqrt n)$ 的时间内处理所有询问。

我们先来考虑基于区间转移的暴力:按顺序处理询问,每次暴力(一直移动区间端点,添加或删除一个位置的贡献,直到与当前区间相同)从上一个区间转移到下一个即可。

我们大概可以写出以下代码:

struct Query{
    int l, r;
    int id;
} querys[MAXM]; // 询问区间 [l, r],id 代表在输入顺序中该询问的编号

int ans[MAXM]; // 每个询问的答案

bool in[MAXN]; // 每个元素的贡献是否在答案中
int l, r, currAns; // 维护当前区间 [l, r] 的答案是 currAns

void flip(int x){ // 将 x 的存在性取反
    in[x] ^= 1;
    
    if(in[x]){
        // add the contribution of x into currAns;
    } else{
        // del the contribution of x from currAns;
    }
}

void solve(){
    l = 0, r = 0, currAns = 0, flip(0);
    for(int i = 0; i < m; i++){
        Query *q = &querys[i];
        
        while(l > q->l) flip(--l);
        while(r < q->r) flip(++r);
        while(l < q->l) flip(l++);
        while(r > q->r) flip(r--);
        
        ans[q->id] = currAns;
    }
}

每次转移的复杂度是 $O(n)$ 的,有 $O(m)$ 次转移,那么复杂度是 $O(nm)$ 的,我们以 $m, n$ 同阶(以下同),则复杂度是 $O(n^2)$ 的。

这个算法看起来并不比直接暴力优越,但我们考虑其意义,将区间 $[l, r]$ 看做二维平面上的点 $(l, r)$,可以看出,区间之间的转移代价是他们的「曼哈顿距离」。

(曼哈顿距离:平面上两个点 $(a, b)$ 和 $(c, d)$ 的曼哈顿距离为 $|a - c| + |b - d|$,即横纵坐标差的绝对值之和)

那么处理所有的询问相当于按照某种顺序走过所有点,所用总时间即为走过的曼哈顿距离之和。

一种可行的思路是,求出所有点的「最小曼哈顿距离生成树」,然后在生成树上走。

然而一点都不好写辣...

接下来我们说「莫队」的做法。

设置一个块的大小 $S$,根据左端点将询问分成若干块,即左端点为 $l$ 的询问被分到块 $\left \lfloor \frac l S \right \rfloor$ 中,然后在每一块中根据 $r$ 排序,然后块内暴力转移,块间也暴力转移。

另一种更简明的描述方法:按将询问按 $(\left \lfloor \frac l S \right \rfloor, r)$ 二元组排序,然后按这个顺序从前往后暴力转移。

可是等等,这可不就是把询问序列调了个顺序然后暴力嘛,怎么复杂度就变成 $O(n\sqrt n)$ 了呢?

然而确实是 $O(n\sqrt n)$,下面讨论时间复杂度。

我们先考虑块内的转移,对于每个询问,块内左端点移动是 $O(S)$ 的,对于每个块,总体来计,右端点的移动是 $O(n)$ 的,块数是 $O(\frac n S)$ 的,所以块内转移的总复杂度是 $O(nS + \frac {n^2} S)$ 的。

然后考虑块间的转移,每次转移是 $O(n)$ 的,块数是 $O(\frac n S)$ 的,所以块间转移的总复杂度是 $O(\frac {n^2} S)$ 的。

把两种转移加起来,算法的总复杂度是 $O(nS + \frac {n^2} S) = O(n (S + \frac n S))$,根据基本不等式,当 $S$ 取 $\sqrt n$ 时最优,这时的复杂度为 $O(n \sqrt n)$。

至于实现上,就是先计算 $S$,然后把询问按以上规则排序,之后暴力转移即可,代码与上面给出的完全一致。

可以在这篇文章中 [BZOJ 2038] 小 Z 的袜子 - 莫队 看到本题的完整代码。

序列上 + 带修改

BZOJ 2120 数颜色

如果带修改怎么办?

如果我们能够 $O(1)$ 地添加或删除一个修改的贡献,那么莫队算法可以在 $O(n^{\frac 5 3})$ 的时间内处理所有询问。

我们可以把考虑修改操作看成是添加一维,这一维是时间。

即把询问表示成 $(l, r, t)$ 的形式,其中 $t$ 表示在这之间有多少个询问。

可以把这看成三维空间上的点,然后暴力移动三个坐标以转移。

我们大概可以写出这样的代码:

struct Query{
    int l, r, time;
    int id;
} querys[MAXM]; // 询问区间 [l, r],time 代表询问时间,或者说是在这之前的修改次数,id 代表在输入顺序中该询问的编号

int ans[MAXM]; // 每个询问的答案

bool in[MAXN]; // 每个元素的贡献是否在答案中
int l, r, currTime, currAns; // 维护当前询问 (l, r, currTime) 的答案是 currAns

void flip(int x){ // 将 x 的存在性取反
    in[x] ^= 1;
    
    if(in[x]){
        // add the contribution of x into currAns;
    } else{
        // del the contribution of x from currAns;
    }
}

void flipModification(int x){
    // apply modification or invoke modification;
    // if necessary, update currAns;
}

void solve(){
    l = 0, r = 0, currAns = 0, currTime = 0, flip(0);
    for(int i = 0; i < m; i++){
        Query *q = &querys[i];
        
        while(currTime < q->time) flipModification(currTime++);
        while(currTime > q->time) flipModification(--currTime);

        while(l > q->l) flip(--l);
        while(r < q->r) flip(++r);
        while(l < q->l) flip(l++);
        while(r > q->r) flip(r--);
        
        ans[q->id] = currAns;
    }
}

保证时间复杂度的方法是,设定块的大小 $S_1, S_2$,将询问按 $(\left \lfloor \frac l S_1 \right \rfloor, \left \lfloor \frac r S_2 \right \rfloor, t)$ 三元组排序。

讨论下时间复杂度,以修改数、询问数均与 $n$ 同阶。

考虑这样的小块,其中的询问第一第二关键字相同,那么在块内,$t$ 的移动是 $O(n)$ 的,一共有 $O(\frac {n^2} {S_1S_2})$ 个块,所以转移 $t$ 的复杂度是 $O(\frac {n^3} {S_1S_2})$。

对于每个询问,$l$ 的移动是 $O(S_1)$ 的,$r$ 的移动是 $O(S_2)$ 的。

总的复杂度 $O(\frac {n^3} {S_1S_2} + nS_1 + nS_2) = O(n(\frac {n^2} {S_1S_2} + S_1 + S_2))$,根据(某种我不知道的数学技巧),当 $S_1 = S_2 = n^{\frac 2 3}$ 时最优,此时复杂度为 $O(n^{\frac 5 3})$

代码实现上,仍然是先计算 $S$,然后把询问按以上规则排序,然后像如上代码一样暴力转移即可。

可以在这篇文章中 [BZOJ 2120] 数颜色 - 带修改莫队 看到本题的完整代码。

树上 + 无修改

SPOJ COT2 Count on a tree II

接下来我们把莫队弄到树上去。~~(当然不是加个树链剖分)~~

原来的区间询问变成了树上路径询问。

如果我们能 $O(1)$ 的添加或删除一个点的贡献,那么莫队可以在 $O(n \sqrt n)$ 的时间内处理所有询问。

我们仍然先考虑暴力转移,这次我们转移的对象变成了路径,怎么办呢。

我们设 $R_i$ 表示从 $i$ 到根路径上的点集(包括 $i$),$(u, v)$ 表示从 $u$ 到 $v$ 路径上的点集,那么有:

$$ (u, v) = R_u \otimes R_v \otimes \{lca(u, v)\} $$

(其中 $\otimes$ 表示对称差运算,通俗地说就是出现两次的元素会被消掉)

我们要转移到 $u', v'$,也就是:

$$ (u', v') = R_{u'} \otimes R_{v'} \otimes \{lca(u', v')\} $$

根据对称差的性质,空集是单位元,任何集合都是其自身的逆元,以及交换律和结合律,有:

$$ \begin{align*} (u', v') & = (u, v) \otimes (\{lca(u, v)\} \otimes \{lca(u', v')\}) \otimes (R_u \otimes R_{u'}) \otimes (R_v \otimes R_{v'}) \\ & = (u, v) \otimes ({lca(u, v)} \otimes \{lca(u', v')\}) \otimes ((u, u') \otimes \{lca(u, u')\}) \otimes ((v, v') \otimes \{lca(v, v')\}) \end{align*} $$

在程序中实现对称差运算,就是对存在性取反,类似上面的 flip 函数。

那么实现这个转移的完整步骤就是这样的:

一、先对原来的 $lca(u, v)$ 取反,再对新的 $lca(u', v')$ 取反,其实这样就把原来的 $lca$ 替换成现在的了。

二、然后对路径 $(u, u')$ 上除 $lca(u, u')$ 以外的点取反,对路径 $(v, v')$ 上除 $lca(v, v')$ 以外的点取反,直接暴力遍历路径上的点即可。

我们大概可以写出如下代码(Node 类型是树上节点):

struct Query{
    Node *u, *v;
    int time;
    int id;
} querys[MAXM];

int currAns;

inline void flip(Node *v){ // 对 v 的存在性取反
    v->in ^= 1;
    
    if(v->in){
        // add the contribution of v into currAns;
    } else{
        // del the contribution of v from currAns;
    }
}

inline void flip(Node *u, Node *v){ // 对 (u, v) 路径上除 lca(u, v) 以外的点的存在性取反
    Node *lca = queryLCA(u, v);
    for(Node *p = u; p != lca; p = p->fa) flip(p);
    for(Node *p = v; p != lca; p = p->fa) flip(p);
}

inline void solve(){
    Node *u = nodes, *v = nodes, *lca = nodes;
    currAns = 0, flip(nodes);
    for(int i = 0; i < m; i++){
        Query *q = &querys[i];
        
        // 步骤一
        flip(lca);
        lca = queryLCA(q->u, q->v);
        flip(lca);

        // 步骤二
        flip(u, q->u), u = q->u;
        flip(v, q->v), v = q->v;

        ans[q->id] = currAns;
    }
}

下面讨论如何排序以保证时间复杂度。

首先你要会对树分块,所以请参见这道题目 [BZOJ 1086] 王室联邦 - 树分块

这种分块的特点是块内任意两点间距离是 $O(S)$ 的。

排序方法是:将询问按照 $(blockId[u], dfn[v])$ 二元组排序。

和普通莫队的分析类似的,我们可以得到当 $S = \sqrt n$ 时,复杂度为 $O(n \sqrt n)$

可以在这篇文章中 Count on a tree II - 树上莫队 看到本题的完整代码。

树上 + 带修改

BZOJ 3052 糖果公园(在 UOJ 58 上也有此题)

~~(卡评测好题)~~

相信您看到这里,应该已经掌握了莫队的套路啦,对于这种带修改的情形,我们加一维时间就是咯。

排序依据是三元组 $(blockId[u], blockId[v], t)$,时间复杂度为 $O(n^{\frac 5 3})$

我还是把转移的大致代码给一下吧,作为参考:

struct Query{
    Node *u, *v;
    int id;
} querys[MAXM];

int currAns, currTime;

inline void flip(Node *v){ // 对 v 的存在性取反
    v->in ^= 1;
    
    if(v->in){
        // add the contribution of v into currAns;
    } else{
        // del the contribution of v from currAns;
    }
}

inline void flip(Node *u, Node *v){ // 对 (u, v) 路径上除 lca(u, v) 以外的点的存在性取反
    Node *lca = queryLCA(u, v);
    for(Node *p = u; p != lca; p = p->fa) flip(p);
    for(Node *p = v; p != lca; p = p->fa) flip(p);
}

void flipModification(int x){
    // apply modification or invoke modification;
    // if necessary, update currAns;
}

inline void solve(){
    Node *u = nodes, *v = nodes, *lca = nodes;
    currAns = 0, currTime = 0, flip(nodes);
    for(int i = 0; i < m; i++){
        Query *q = &querys[i];
        
        while(currTime < q->time) flipModification(currTime++);
        while(currTime > q->time) flipModification(--currTime);
        
        // 步骤一
        flip(lca);
        lca = queryLCA(q->u, q->v);
        flip(lca);

        // 步骤二
        flip(u, q->u), u = q->u;
        flip(v, q->v), v = q->v;

        ans[q->id] = currAns;
    }
 }

可以在这篇文章中 [BZOJ 3052] 糖果公园 - 树上带修改莫队 看到本题的完整代码。

总结

总的来说,莫队算法还是比较好懂的,也十分好写,因为其本质是优化的暴力。

对莫队算法的扩展思路可能会给我们一些启示。

  • 加一维支持修改
  • 将序列问题扩展到树上 ~~或许也可以扩展到仙人掌上(雾~~

参考资料

莫队算法学习笔记, Sengxian

清橙A1206 小Z的袜子(莫队算法), 疯狂的橡树

WC 2013 糖果公园 park 题解, vfleaking