前言

莫队是一种十分巧妙的数据结构,它和 分块 有着异曲同工之妙。莫队算法由前国家队队长莫涛发明,是一种 优雅的暴力 。莫队算法的主要思想是对询问进行合理的排序和处理,从而优化暴力算法的时间复杂度。通常情况下来说,莫队算法的时间复杂度大约是 \(O(n\sqrt{n})\) 。能够使用莫队算法的题目基本上都需要将询问强制离线处理,因此,如果题目强制要求在线,莫队算法就很难处理。

有些区间的数据结构题目,莫队算法是正解,如果数据强度不高可以通过。但更多的情况下,莫队算法作为优雅的暴力,更多地用来获取部分分。莫队算法的常见类型有:普通莫队带修莫队回滚莫队树上莫队 等。

普通莫队

基本思想

普通莫队算法可以解决不强制要求在线的部分区间问题。普通股莫队算法的主要优化就是对询问的排序。假设我们有两个指针 \(l\) 和 \(r\) ,第 \(i\) 个需要修改的区间为 \([L, R]\) 。按照最暴力的思想来算,我们会把 \(l\) 指针移动到第 \(L\) 个位置,把 \(r\) 指针移动到第 \(R\) 个位置,同时统计指针移动带来的影响。这样做的转移复杂度可以暂时先看作 \(O(1)\) ,但是指针最坏情况下需要遍历一次整个区间,时间复杂度为 \(O(nm)\) 。

考虑对暴力算法的优化。我们发现,暴力算法的时间复杂度瓶颈在于指针移动的次数。假设单次转移的时间复杂度不变,如何将移动次数减少呢?合理安排处理询问的次序。

我们可以先将所有询问离线保存,然后按某种顺序离线处理。考虑下面这个例子:假设我们需要处理的区间为 \([1, 998], [1000, 2], [3, 1000]\) 。如果按照读入的顺序处理,两个指针一共要移动 \(999 + 997 + 996 + 998 = 3990\) 次。但是如果按照 \([1, 998], [3, 1000], [1000, 2]\) 的顺序来处理,我们就只需要移动 \(2 + 997 + 2 + 998 = 1999\) 次,并且优化幅度还会随着数据规模的增加而增加!

从上面的例子中,我们可以发现一些规律。我们希望左指针尽量只往右边移动,而右指针在满足第一个条件的情况下要尽量少向左移动。因此,我们可以考虑以左端点为第一关键字,右端点为第二关键字从小到大排序的顺序来处理。这样做似乎是对的,但是事实证明这样做仍然还不是最优的——右端点的移动次数可能很大。

上面的算法不足之处在于,我们如果直接这样排序,右端点可能会来回扫描很大的子区间。因此,是否可以考虑限制住右端点的最大扫描长度,以此来优化时间复杂度呢?这里可以使用 分块 的思想。我们将整个数组分成若干个长度都为 \(\sqrt{n}\) 的块,把这些询问按左端点所属块大的编号为第一关键字,以右端点为第二关键字升序排序。这样做的平均时间复杂度较优,为 \(O(n\sqrt{n})\) 。

因为左端点按照所属块的编号升序排列了,因此,假设两个询问之间距离 \(k\) 个整块,则 \(l\) 指针每次的扫描次数最多不超过 \(k\sqrt{n}\) 。当 \(k\) 较大时,其他方法可能会重复扫描这一段区间,而莫队算法扫描的次数较少,整体的时间复杂度仍然较优。均摊下来,总时间复杂度大约为 \(O(n\sqrt{n})\) 。具体的时间复杂度取决于数据强度和选用的块长,可以证明 \(\sqrt{n}\) 不一定是最优的块长。

需要注意的是修改的边界。不妨设 ​​add​​ 函数为增加函数,​​del​​ 函数为删除函数。当 \(l < L\) 时,我们需要删去 \(l\) 的影响,所以调用 ​​del(l++)​​ 。当 \(l > L\) 时,\(l\) 的影响已经被统计,所以调用 ​​add(--l)​​。\(r < R\) 同理,调用 ​​add(++r)​​ ,反之若 \(r > R\) ,调用 ​​del(r--)​​ 。

最后,莫队算法的转移时间复杂度一定要优,最好是 \(O(1)\) 转移,否则扫描的长度乘以转移的时间复杂度很有可能会原地爆炸。如果区间拓展和缩减的操作都难以转移,建议不要使用莫队算法。至于有一个操作可以较为轻松转移的情况,请见下文的 回滚莫队 部分。

下面结合一道例题来说明。

例题讲解

一道非常经典的莫队题目。这种莫队的题目通常都需要配合 离散化 来使用。因为 \(1 \leq a_i \leq n \leq 10^5\),所以我们可以开一个大小为 \(10^5\) 的桶 \(t\) 来存下每个数字出现的次数。当我们的左指针 \(l\) 比当前询问区间的左端点 \(L\) 要小时,我们把左指针右移到 \(L\) 。当左指针从第 \(i - 1\) 个位置移动到第 \(i\) 个位置的时候,区间长度减少,相当于第 \(i - 1\) 个位置的数字少出现了一次。所以 \(t_{a_{i - 1}} = t_{a_{i - 1}} - 1\) 。当 \(l > L\) 时,我们需要把左指针左移,此时区间长度增加,第 \(i - 1\) 个位置的数多出现了一次。

右指针 \(r\) 小于或大于询问区间的右端点 \(R\) 的情况同理。若当前位置为 \(i\) ,小于时右移且第 \(i + 1\) 个位置的数出现次数 \(+ 1\) ,大于时左移且第 \(i\) 个位置的数出现次数 \(- 1\)。每次转移顺便判断,如果原本数 \(i\) 的出现为 \(0\) 且现在被 \(+ 1\),说明新出现了一个数,数的个数 \(+ 1\);反之,如果第 \(i\) 个数的个数 \(- 1\) 后 \(= 0\),说明有一个数灭绝了,数的个数 \(- 1\) 。最终对于每个询问判断数的个数是否等于区间长度即可。

另外,值得一提的是,莫队左指针的初始值最好赋值为 \(1\) 而非 \(0\) ,右指针直接赋值为 \(0\) 即可。原因是如果左指针初始值为 \(0\),可能会导致多计算数值为 \(0\) 的情况。

参考代码

#include <cstdio>
#include <cmath>
#include <algorithm>
using namespace std;

const int maxn = 1e5 + 5;
const int maxq = 1e5 + 5;

int n, m, tot;
int a[maxn], bel[maxn], cnt[maxn];
bool ans[maxn];

struct ques
{
int l, r, id;
bool operator < (const ques& rhs) const
{
if (bel[l] != bel[rhs.l])
return bel[l] < bel[rhs.l];
return r < rhs.r;
}
} q[maxn];

inline int read()
{
int res = 0, flag = 1;
char ch = getchar();
while (ch < '0' || ch > '9')
{
if (ch == '-')
flag = -1;
ch = getchar();

}
while (ch >= '0' && ch <= '9')
{
res = res * 10 + ch - '0';
ch = getchar();
}
return res * flag;
}

void add(int x)
{
if (!cnt[a[x]])
tot++;
cnt[a[x]]++;
}

void del(int x)
{
cnt[a[x]]--;
if (!cnt[a[x]])
tot--;
}

int main()
{
int l = 0, r = 0;
n = read();
m = read();
int block = sqrt(n);
for (int i = 1; i <= n; i++)
{
a[i] = read();
bel[i] = (i - 1) / block + 1;
}
for (int i = 1; i <= m; i++)
{
q[i].l = read();
q[i].r = read();
q[i].id = i;
}
sort(q + 1, q + m + 1);
for (int i = 1; i <= m; i++)
{
while (l < q[i].l)
del(l++);
while (l > q[i].l)
add(--l);
while (r < q[i].r)
add(++r);
while (r > q[i].r)
del(r--);
if (tot == (r - l + 1))
ans[q[i].id] = true;
}
for (int i = 1; i <= m; i++)
{
if (ans[i])
puts("Yes");
else
puts("No");
}
return 0;
}


带修莫队

基本思想

我们知道普通的莫队算法因为需要离线处理询问所以很难维护修改操作,因此我们需要给莫队加上支持修改的特性,从而在更多的题目中拿到部分分。 同样地,带修莫队也需要离线处理询问,但是同时它还会将修改也一起离线处理,实现带修的效果。

我们考虑在普通的莫队算法上增加一个指针 \(k\) ,表示当前的区间是经过了前 \(k\) 次修改的情况,显然 \(k\) 的初始值为 \(0\) 。我们给每次询问加上一个处理好的值,表示在这个询问前修改操作的个数。每次处理离线的询问,我们不仅要移动 \(l, r\) 指针,我们还需要移动 \(k\) 指针。当 \(l, r, k\) 都与当前询问的区间重合时,我们才认为此时的值是询问的合法解。

优化思路

显然直接多维护一个指针,莫队算法时间复杂度会严重退化。因此我们需要优化它,最好能控制在 \(O(n\sqrt{n})\) 左右。考虑到代码的易维护性和简洁性,我们没有合适的思路能简单地优化带修莫队的时间复杂度。因此,我们只能考虑从每一块的 块长 入手来 玄学 数学优化。

假设我们取块长 \(= n^x\) ,其中 \(x\) 满足 \(0 < x < 1\),假设操作次数 \(m\) 与 \(n\) 同阶且非常接近 \(n\)。我们考虑当 \(l\) 指针移动时,最坏的时间复杂度(注意 时间复杂度总时间复杂度 的区别):

  • 当 \(l, r\) 指针全部都在块内移动时,最坏要遍历一个整块,单次时间复杂度为 \(O(n^x)\) 。一共有近似 \(n\) 次询问,总时间复杂度为 \(O(n^{x + 1})\) 。
  • 当 \(l\) 指针移动到后面的若干块时,每移动一个整块的时间复杂度为 \(O(n^x)\) 。可以作为终点的块共有 \(O(\frac{n}{n^x})\) 个,所以时间复杂度最坏为 \(O(n)\) 。

\(\therefore\) 综上,\(l\) 指针移动的最坏时间复杂度为 \(O(x + 1)\) 。

接下来考虑移动 \(r\) 指针的最坏复杂度,我们叠加上 \(l\) 指针的影响,不会干扰到总时间复杂度的分析:

  • 当 \(l\) 和 \(r\) 指针都在块内移动时,两个指针可以移动的位置分别有 \(O(n^x)\) 个。一共有 \(\frac{n}{n^x}\) 个块,所以总时间复杂度为 \(O(n^{2x} \times \frac{n}{n^x})\) ,化简得 \(O(n^{2 - x})\) 。
  • 当 \(l\) 指针在块内移动,\(r\) 指针移动到后面的若干块时,\(r\) 指针每移动一个整块的单次时间复杂度为 \(O(n^x)\)。最坏情况下需要遍历 \(\frac{n}{n^x}\) 个块。由于右端点按照升序排列,假设每一个块需要付出 \(O(n)\) 的时间复杂度,则最坏复杂度接近于 \(O(\frac{n}{n^x} \times n)\),化简得 \(O(n^{2 - x})\) 。
  • 当 \(l\) 指针移动到下一块时,最坏单次需要 \(O(n)\) 的时间复杂度。向大估算,每个块都需要左指针遍历一次整个区间。共有 \(O(\frac{n}{n^x })\) 个块,每次需要 \(O(n)\) 遍历一次整个区间,则最坏复杂度接近于 \(O(n^{2 - x})\) 。

\(\therefore\) 综上,\(r\) 指针移动得时间复杂度最坏也接近于 \(O(n^{2 - x})\) 。

最后考虑 \(k\) 指针移动的时间复杂度:

  • \(l, r\) 指针都在块内移动时,\(k\) 指针按照排序规则递增,最坏时间复杂度为 \(O(n)\) 。
  • \(l\) 指针在块内移动,\(r\) 指针移动到后面的若干块时,每一次移动的复杂度最坏也是 \(O(n)\)。根据上面的估算,每次移动 \(l, r\) 指针最坏需要 \(O(n^{2 - 2x})\) ,每次移动右指针的同时还要最坏 \(O(n)\) 地更新 \(k\) 指针,所以总时间复杂度为 \(O(n^{3 - 2x})\) 。
  • \(l\) 指针移动到后面的若干块时,每次最坏时间复杂度为 \(O(n)\) 。假设每个块贡献一次移动的复杂度,则最坏复杂度不超过 \(O(n^{1 - x})\) ,考虑进 \(k\) 指针的影响,总时间复杂度不超过 \(O(n^{2 - x})\) 。

\(\therefore\) \(k\) 指针移动的总时间复杂度不超过 \(O(n^{\max(3 - 2x, 2 - x)})\)

三个指针移动的时间复杂度最坏为 \(O(n^{max(1, 3 - 2x, 2 - x)})\) ,进行 简单 数学分析后易知 \(x = \frac{2}{3}\) 时时间复杂度最优,为 \(O(n^{\frac{5}{3}})\) 。

带修莫队还有另外一种块长 \(= \sqrt[3]{nt}\) 的写法,理论上可以达到理论最优复杂度 \(O(\sqrt[3]{n^4t})\) 。具体提证明不再赘述,视数据范围决定使用的块长。

例题讲解

​例题链接​

经典的带修莫队问题,不再赘述,详见代码。

#include <cstdio>
#include <cmath>
#include <iostream>
#include <algorithm>
using namespace std;

const int maxn = 1e5 + 5e4;
const int maxq = 1e5 + 5e4;
const int maxv = 1e6 + 5;

int n, m, cur;
int l, r, k;
int qlen, clen;
int a[maxn], bel[maxn];
int cnt[maxv], ans[maxq];

struct ques
{
int l, r, k, id;
bool operator < (const ques& rhs) const
{
if (bel[l] ^ bel[rhs.l])
return bel[l] < bel[rhs.l];
if (bel[r] ^ bel[rhs.r])
return (bel[l] & 1 ? r < rhs.r : r > rhs.r);
return k < rhs.k;
}
} q[maxq];

struct node
{
int pos, color;
} c[maxq];

inline int read()
{
int res = 0, flag = 1;
char ch = getchar();
while (ch < '0' || ch > '9')
{
if (ch == '-')
flag = -1;
ch = getchar();

}
while (ch >= '0' && ch <= '9')
{
res = res * 10 + ch - '0';
ch = getchar();
}
return res * flag;
}

inline void write(int x)
{
if (x < 0)
{
putchar('-');
x = -x;
}
if (x > 9)
write(x / 10);
putchar(x % 10 + '0');
}

inline void add(int x)
{
if (!cnt[a[x]])
cur++;
cnt[a[x]]++;
}

inline void del(int x)
{
cnt[a[x]]--;
if (!cnt[a[x]])
cur--;
}

inline void update(int x)
{
if (c[x].pos >= l && c[x].pos <= r)
{
if (!cnt[c[x].color])
cur++;
if (cnt[a[c[x].pos]] == 1)
cur--;
cnt[c[x].color]++;
cnt[a[c[x].pos]]--;
}
swap(c[x].color, a[c[x].pos]);
}

int main()
{
char ch;
n = read();
m = read();
int block = pow(n, 0.666);
for (register int i = 1; i <= n; i++)
{
a[i] = read();
bel[i] = (i - 1) / block + 1;
}
for (register int i = 1; i <= m; i++)
{
scanf(" %c ", &ch);
if (ch == 'Q')
{
qlen++;
q[qlen].l = read();
q[qlen].r = read();
q[qlen].id = qlen;
q[qlen].k = clen;
}
else
{
clen++;
c[clen].pos = read();
c[clen].color = read();
}
}
sort(q + 1, q + qlen + 1);
for (register int i = 1; i <= qlen; i++)
{
while (l < q[i].l)
del(l++);
while (l > q[i].l)
add(--l);
while (r < q[i].r)
add(++r);
while (r > q[i].r)
del(r--);
while (k < q[i].k)
update(++k);
while (k > q[i].k)
update(k--);
ans[q[i].id] = cur;
}
for (register int i = 1; i <= qlen; i++)
write(ans[i]), puts("");
return 0;
}


回滚莫队

基本思想

假如存在这样一道题目,它的正解很难,像是奇 ♂ 怪的莫队算法,它的修改或者删除操作中有一个很难实现,另一个则非常简单。这时我们可以考虑使用回滚莫队。回滚莫队最大的特点是只需要维护一个操作即可,我们通过更加合理的排序,来暴力地撤销每一个操作的影响。

由于我无法也没试过在不会回滚莫队算法的情况下独立推出回滚莫队算法,因此我无法挂出回滚莫队思考的切入点。所以直接在这篇博文里挂出回滚莫队的算法流程,思路等想出来了再(wo)补(yao)充(gu)。

假设题目给出的增加操作非常简单,而删除操作难以实现。我们考虑把删除操作去掉,用增加操作来维护答案。本文仅讨论这种情况,只考虑删除的回滚莫队只是实现上有所差别,感兴趣可以浏览 这篇博客(外链) 。

首先,我们把询问按照左端点所属块的编号为第一关键字,以右端点为第二关键字升序排序。我们把询问按照左端点所属块的编号分类。对于同一类的询问,我们可以将 \(r\) 指针初始化为这个块的结尾,\(l\) 指针初始化为下一个块的开头。每次对于当前询问,如果 \(r\) 指针在当前询问右端点的左端,我们就把右端点右移。由于这些询问的左端点都在同一个块内,所以它们是按右端点升序排列的,因而可以沿用之前的 \(r\) 指针更新的信息,也就是这个块的末尾以后的信息。完成更新以后保存答案,设其为 \(k\) 。

特殊情况是某个询问的左端点和右端点在同一个块内,此时暴力做法的时间复杂度不超过 \(O(\sqrt{n})\) ,直接暴力即可。注意莫队和暴力的信息需要分开存储。

如果 \(l\) 指针在当前询问的左端点的右端,那么我们把 \(l\) 指针左移到当前询问的左端点并更新答案。此时这个询问的答案已经被求出。我们还需要把左端点的影响删除,并且把左端点还原到下一个块的开头。原因是:同一个块内的左端点不一定按照升序排列,如果我们沿用之前的信息,很有可能会处理到不在询问范围内的位置。例如我们先处理了区间 \([3, 5]\),再处理了区间 \([4, 6]\) 。此时若沿用区间 \([3, 5]\) 的信息,我们就会考虑到位置 \(3\) 的影响,而 \(3\) 并不在我们查询的区间 \([4, 6]\) 内。

每次对于在同一个块内的询问重复以上操作,注意清除左指针带来的影响即可。总的时间复杂度不超过 \(O(n\sqrt{n} + m\sqrt{n})\)。

复杂度证明

证明如下:我们把所有询问分成两类,设左右端点在同一块内的询问数量为 \(a\) ,左右端点不在同一块内的询问数量为 \(b\),所有询问的总数量为 \(m\)。

对于第一类询问,左右指针每次最多扫描 \(O(\sqrt{n})\) 个位置,所以处理第一类询问的总时间复杂度为 \(O(a\sqrt{n})\) 。对于第二类询问,我们再次分类,设左端点在第 \(i\) 个块内的询问为第 \(i\) 类询问。显然,对于每一个询问左指针最多扫描 \(\sqrt{n}\) 个位置。由于每个块内的右端点按升序排列,每一类询问右指针最多扫描 \(n\) 个位置。最多有 \(\sqrt{n}\) 类询问,左指针需要扫描的总长度最长为 \(b \times \sqrt{n}\) ,右指针需要扫描的总长度最长为 \(\sqrt{n} \times n\) 。求和可知最长需要扫描 \(b \times \sqrt{n} + n \times \sqrt{n}\) 个位置。

两类询问求和后为 \(a\sqrt{n} + b\sqrt{n} + n\sqrt{n}\),整理可得总时间复杂度为 \(O(n\sqrt{n} + m\sqrt{n})\) 。证毕。

例题讲解

​例题链接​

还是经典的回滚莫队问题,难点在于值域太大。我们发现我们处理出现次数时只需要数的相对大小,也就是第 \(1\) 大的数出现的次数,第 \(2\) 大的数出现的次数,第 \(3\) 大的数出现的次数……所以我们可以考虑莫队时使用离散化后的数组,求值时再调用原本数组里的值。离散化后维护每一个值的出现次数,出现次数增加时顺带修改答案即可。详见代码。

#include <cstdio>
#include <cstring>
#include <cmath>
#include <algorithm>
using namespace std;

const int maxn = 1e5 + 5;
const int maxq = 1e5 + 5;
const int maxv = 1e5 + 5;
const int maxb = 1e3 + 5;

int n, m;
int l, r;
int st[maxb], ed[maxb];
int bel[maxn], cnt[maxv];
int a[maxn], b[maxn], c[maxn];
long long vis, cur;
long long ans[maxq];

struct ques
{
int l, r, id;
bool operator < (const ques& rhs) const
{
if (bel[l] ^ bel[rhs.l])
return bel[l] < bel[rhs.l];
return r < rhs.r;
}
} q[maxq];

inline int read()
{
int res = 0, flag = 1;
char ch = getchar();
while (ch < '0' || ch > '9')
{
if (ch == '-')
flag = -1;
ch = getchar();

}
while (ch >= '0' && ch <= '9')
{
res = res * 10 + ch - '0';
ch = getchar();
}
return res * flag;
}

inline void write(long long x)
{
if (x < 0)
{
x = -x;
putchar('-');
}
if (x > 9)
write(x / 10);
putchar(x % 10 + '0');
}

inline void add(int x)
{
cnt[a[x]]++;
cur = max(cur, (long long)cnt[a[x]] * b[a[x]]);
}

inline long long solve(int l, int r)
{
long long res = 0;
for (register int i = l; i <= r; i++)
c[a[i]] = 0;
for (register int i = l; i <= r; i++)
{
c[a[i]]++;
res = max(res, (long long)c[a[i]] * b[a[i]]);
}
return res;
}

int main()
{
int idx = 1;
n = read();
m = read();
int block = sqrt(n);
int size = ceil(n * 1.0 / block);
for (register int i = 1; i <= size; i++)
{
st[i] = (i - 1) * block + 1;
ed[i] = i * block;
}
for (register int i = 1; i <= n; i++)
{
a[i] = b[i] = read();
bel[i] = (i - 1) / block + 1;
}
for (register int i = 1; i <= m; i++)
{
q[i].l = read();
q[i].r = read();
q[i].id = i;
}
sort(q + 1, q + m + 1);
sort(b + 1, b + n + 1);
int len = unique(b + 1, b + n + 1) - b - 1;
for (register int i = 1; i <= n; i++)
a[i] = lower_bound(b + 1, b + len + 1, a[i]) - b;
for (register int i = 1; i <= size; i++)
{
memset(cnt, 0, (n + 1) * sizeof(int));
cur = 0;
r = ed[i];
while (bel[q[idx].l] == i)
{
l = ed[i] + 1;
if (bel[q[idx].l] == bel[q[idx].r])
{
ans[q[idx].id] = solve(q[idx].l, q[idx].r);
idx++;
continue;
}
while (r < q[idx].r)
add(++r);
vis = cur;
while (l > q[idx].l)
add(--l);
ans[q[idx].id] = cur;
cur = vis;
while (l <= ed[i])
cnt[a[l++]]--;
idx++;
}
}
for (register int i = 1; i <= m; i++)
write(ans[i]), putchar('\n');
return 0;
}


树上莫队

基本思想

树上莫队是最毒瘤的莫队类型之一。它的难点在于如何把二维的树上问题降维打击成一维的区间问题,再用莫队维护区间答案。通常情况下我们可以把树上的所有结点按某种顺序排成一个序列,例如 \(\textbf{dfs}\) 序 或者 欧拉序 。根据题目的性质选择合适的顺序即可,这里挂出一道例题的做法,供各位感性理解树上莫队用。

例题讲解

​例题链接​

我们看到题目首先考虑用 \(dfs\) 序来把树转化成序列。但是经过画图分析,我们发现一条树上的路径并不总能直接转化成一段对应的区间。所以我们考虑使用另一个常用的 欧拉序

欧拉序 是指对于一棵树 \(t\) 的某个结点 \(u\) ,我们在第一次深度优先遍历到 \(u\) 时将 \(u\) 加入当前欧拉序的末尾,当深度优先遍历完 \(u\) 的子树时再加入当前欧拉序的末尾,最终由 \(2 \times n\) 个元素构成的序列。结合图来理解:

莫队_数据结构

如图,图中的树对应着例题的样例。这棵树的欧拉序为 \(\{1, 2, 2, 3, 5, 5, 6, 6, 7, 7, 3, 4, 8, 8, 4, 1\}\) 。我们容易发现设结点 \(i\) 在欧拉序中第一次出现的位置为 \(s_i\) ,最后一次出现的位置为 \(t_i\)。假设有两个结点 \(x, y\) 满足 \(s_i < s_j\) ,此时若 \(x, y\) 的 \(lca = x\) ,说明 \(y\) 是 \(x\) 的后代。对于欧拉序中的区间 \([s_x, s_y]\) ,我们可以证明其中出现且仅出现 \(1\) 次的结点一定在 \(x\) 到 \(y\) 的路径上。因为这个区间中不包含 \(y\) 的子树,且由于路径唯一,\(x, y\) 路径上的点因为 \(y\) 的子树没有被遍历完所以无法加入欧拉序第 \(2\) 次,所以结论成立。如果 \(lca(x, y)\neq x\) ,说明 \(x\) 和 \(y\) 在两棵不同的子树内,此时我们需要取区间 \(t_x, s_y\) ,这样 \(x, y\) 的子树都不会被处理。

上图中的 \(7, 8\) 满足第二种情况。我们找到欧拉序中对应的区间为 \(\{7, 3, 4, 8\}\),发现缺少 \(7\) 和 \(8\) 的最近公共祖先 \(1\) 。所以对于第二种情况,我们在维护莫队的时候还需要更新 \(x, y\) 的 \(lca\) 。

我们将读入的两个结点之间的路径转化成一个连续的区间,再用莫队来维护答案。具体映射关系上文已经解释。

在实现代码的时候有一个小技巧:对于同一个结点 \(i\),我们可以用一个值 ​​vis[i]​​ 表示结点 \(i\) 是否被更新过,是为 ​​true​​,否为 ​​false​​ 。每次处理的时候我们只需要根据 ​​vis[i]​​ 分类讨论,再将 ​​vis[i]​​ 取反即可。详见代码。

注意树上莫队因为对欧拉序进行处理,所以 ​​bel​​ 数组要处理 \(2 \times n\) 个位置。

参考代码

#include <cstdio>
#include <cmath>
#include <iostream>
#include <algorithm>
using namespace std;

const int maxn = 4e4 + 5;
const int maxm = 1e5 + 5;

int n, m;
int tot, Cnt, cur;
int f[maxn][20], st[maxn], ed[maxn];
int cnt[maxn], ans[maxm], p[maxn * 2];
int head[maxn], dep[maxn], a[maxn], b[maxn], bel[maxn * 2];
bool vis[maxn * 2];

struct edge
{
int to, nxt;
} edge[2 * maxn];

struct ques
{
int l, r, lca, id;
bool operator < (const ques& rhs) const
{
if (bel[l] ^ bel[rhs.l])
return bel[l] < bel[rhs.l];
return (bel[l] & 1 ? r < rhs.r : r > rhs.r);
}
} q[maxm];

inline int read()
{
int res = 0, flag = 1;
char ch = getchar();
while (ch < '0' || ch > '9')
{
if (ch == '-')
flag = -1;
ch = getchar();
}
while (ch >= '0' && ch <= '9')
{
res = res * 10 + ch - '0';
ch = getchar();
}
return res * flag;
}

void add_edge(int u, int v)
{
Cnt++;
edge[Cnt].to = v;
edge[Cnt].nxt = head[u];
head[u] = Cnt;
}

void dfs(int u)
{
p[++tot] = u;
st[u] = tot;
dep[u] = dep[f[u][0]] + 1;
for (int i = head[u]; i; i = edge[i].nxt)
{
int v = edge[i].to;
if (v != f[u][0])
{
f[v][0] = u;
dfs(v);
}
}
p[++tot] = u;
ed[u] = tot;
}

void add(int x)
{
if (!vis[x])
{
if (!cnt[a[x]])
cur++;
cnt[a[x]]++;
}
else
{
cnt[a[x]]--;
if (!cnt[a[x]])
cur--;
}
vis[x] ^= 1;
}

int lca(int u, int v)
{
if (dep[u] < dep[v])
swap(u, v);
int k = 0;
while ((1 << (k + 1)) <= n)
k++;
for (int i = k; i >= 0; i--)
if (dep[f[u][i]] >= dep[v])
u = f[u][i];
if (u == v)
return u;
for (int i = k; i >= 0; i--)
{
if (f[u][i] != f[v][i])
{
u = f[u][i];
v = f[v][i];
}
}
return f[u][0];
}

int main()
{
int u, v, x;
int l = 1, r = 0;
n = read();
m = read();
for (int i = 1; i <= n; i++)
a[i] = b[i] = read();
for (int i = 1; i <= n - 1; i++)
{
u = read();
v = read();
add_edge(u, v);
add_edge(v, u);
}
sort(b + 1, b + n + 1);
int len = unique(b + 1, b + n + 1) - b - 1;
for (int i = 1; i <= n; i++)
a[i] = lower_bound(b + 1, b + len + 1, a[i]) - b;
int block = sqrt(n);
for (int i = 1; i <= 2 * n; i++)
bel[i] = (i - 1) / block + 1;
dfs(1);
for (int j = 1; (1 << j) <= n; j++)
for (int i = 1; i <= n; i++)
f[i][j] = f[f[i][j - 1]][j - 1];
for (int i = 1; i <= m; i++)
{
u = read();
v = read();
if (st[u] >= st[v])
swap(u, v);
x = lca(u, v);
q[i].id = i;
if (x == u)
{
q[i].l = st[u];
q[i].r = st[v];
}
else
{
q[i].l = ed[u];
q[i].r = st[v];
q[i].lca = x;
}
}
sort(q + 1, q + m + 1);
for (int i = 1; i <= m; i++)
{
while (l < q[i].l)
add(p[l++]);
while (l > q[i].l)
add(p[--l]);
while (r < q[i].r)
add(p[++r]);
while (r > q[i].r)
add(p[r--]);
if (q[i].lca)
add(q[i].lca);
ans[q[i].id] = cur;
if (q[i].lca)
add(q[i].lca);
}
for (int i = 1; i <= m; i++)
printf("%d\n", ans[i]);
return 0;
}


常数优化

经过笔者的测试,在相同的评测环境下,使用优化的代码比不使用优化的代码平均快 \(1\) 到 \(1.2\) 秒。

奇偶性优化

更改莫队排序询问的规则。若左端点所属的块不同,我们按左端点所属块的编号排序,否则若左端点所属块的编号为奇数,我们按右端点升序排序,否则按右端点降序排序。理论最多可以优化 \(\frac{1}{2}\) 的常数。

bool operator < (const ques& rhs) const
{
if (bel[l] ^ bel[rhs.l])
return bel[l] < bel[rhs.l];
return (bel[l] & 1 ? r < rhs.r : r > rhs.r);
}


卡常

由于莫队的题目输入输出量较大,可以加上快读和快写。另外,开启 ​​O2​​ 优化的莫队代码最多可以比普通莫队代码快 \(4\) 到 \(5\) 倍,但是大多数情况下不会变快,甚至还会负优化。