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

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

序列上 + 无修改

BZOJ 2038 小 Z 的袜子

这是最普通的莫队问题。

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

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

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

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(n2) 的。

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

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

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

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

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

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

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

另一种更简明的描述方法:按将询问按 (lS,r) 二元组排序,然后按这个顺序从前往后暴力转移。

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

然而确实是 O(nn),下面讨论时间复杂度。

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

然后考虑块间的转移,每次转移是 O(n) 的,块数是 O(nS) 的,所以块间转移的总复杂度是 O(n2S) 的。

把两种转移加起来,算法的总复杂度是 O(nS+n2S)=O(n(S+nS)),根据基本不等式,当 Sn 时最优,这时的复杂度为 O(nn)

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

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

序列上 + 带修改

BZOJ 2120 数颜色

如果带修改怎么办?

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

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

即把询问表示成 (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;
    }
}

保证时间复杂度的方法是,设定块的大小 S1,S2,将询问按 (lS1,rS2,t) 三元组排序。

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

考虑这样的小块,其中的询问第一第二关键字相同,那么在块内,t 的移动是 O(n) 的,一共有 O(n2S1S2) 个块,所以转移 t 的复杂度是 O(n3S1S2)

对于每个询问,l 的移动是 O(S1) 的,r 的移动是 O(S2) 的。

总的复杂度 O(n3S1S2+nS1+nS2)=O(n(n2S1S2+S1+S2)),根据(某种我不知道的数学技巧),当 S1=S2=n23 时最优,此时复杂度为 O(n53)

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

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

树上 + 无修改

SPOJ COT2 Count on a tree II

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

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

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

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

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

(u,v)=RuRv{lca(u,v)}

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

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

(u,v)=RuRv{lca(u,v)}

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

(u,v)=(u,v)({lca(u,v)}{lca(u,v)})(RuRu)(RvRv)=(u,v)(lca(u,v){lca(u,v)})((u,u){lca(u,u)})((v,v){lca(v,v)})

在程序中实现对称差运算,就是对存在性取反,类似上面的 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=n 时,复杂度为 O(nn)

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

树上 + 带修改

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

~~(卡评测好题)~~

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

排序依据是三元组 (blockId[u],blockId[v],t),时间复杂度为 O(n53)

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

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