前置知识:双指针、分块
简单概括而言,莫队是一个离线算法,核心思想是将所有的询问按照分块进行排序后,对于每个询问可以通过双指针增删数据来达到总体的复杂度。
莫队通常用来解决求一段区间内不同数的个数相关的问题。
目录
1、莫队基础
来看一道题目:(洛谷P1972)
HH 有一串由各种漂亮的贝壳组成的项链。HH 相信不同的贝壳会带来好运,所以每次散步完后,他都会随意取出一段贝壳,思考它们所表达的含义。HH 不断地收集新的贝壳,因此,他的项链变得越来越长。
有一天,他突然提出了一个问题:某一段贝壳中,包含了多少种不同的贝壳?这个问题很难回答…… 因为项链实在是太长了。于是,他只好求助睿智的你,来解决这个问题。
观察题意可知,这是一个区间查询问题,查询的是区间内不同颜色的数量。如果用暴力算法将一个区间的颜色出现的次数存在一个数组cnt里,每次判断该节点是否是第一次出现,最后统计总结果的复杂度为
考虑优化,将询问的区间按结束的点进行排序,用双指针i,j分别表示左端点和右端点
设上一次查询的区间为,本次查询的区间为, 那么当我们将点移动到点的时候,本质上是删掉一些节点,那么节点颜色出现次数cnt和颜色总数res有以下关系
而当我们将移动到时,本质上是增加一些节点,那么cnt与res的关系是
当然这是显而易见的,和纯暴力没什么两样,这样的时间复杂度依旧是,如何进行进一步的优化呢?
可以利用我们学过的分块思想,如果我们把所有的询问区间存下来,然后对左端点按块排序,当两个询问左端点在同一块内时再看右端点的先后顺序。排序的复杂度为
如图,如此一来,每两个询问区间的转移复杂度为
1、左端点的块内查询复杂度为
2、左端点的块间复杂度最大为
3、右端点的复杂度最大为
最后的总时间复杂度为,如果m和n近似相等,那么可以简化为
这个方法就是莫队。
附:简单优化
(注意,这两种优化实际复杂度提升并不明显,只适合对卡常的题目进行优化)
(1)、奇偶块排序
根据莫队指针移动的顺序我们可以观察到,当本次查询与上次查询的左端点在不同块内时,右指针需要先移动到左指针的位置,然后重新开始向右移动,那么我们可以对下标为奇数的块正序排列右端点,下标为偶数的块倒序排列右端点,这样一来右指针就可以少走一次区间,稍微提高一点速度。
(2)、分块的长度
由于我们推理复杂度的时候默认将询问次数和区间长度的数量级统一为n,但如果询问次数m远大于区间总长度n,那么莫队的复杂度将不再近似为,设分块长度为len,那么复杂度为,为了使这个式子最小,可以求得,那么我们将区间改为该式即可。
附上代码:
#include <iostream>
#include <cstring>
#include <cstdio>
#include <algorithm>
#include <cmath>
using namespace std;
const int N = 50010, M = 200010, S = 1000010;
int n, m, len;
int w[N], ans[M];
struct Query
{
int id, l, r;
}q[M];
int cnt[S];
int get(int x)
{
return x / len;
}
bool cmp(const Query& a, const Query& b)
{
int i = get(a.l), j = get(b.l);
if (i != j) return i < j;
return a.r < b.r;
}
void add(int x, int& res)
{
if (!cnt[x]) res ++ ;
cnt[x] ++ ;
}
void del(int x, int& res)
{
cnt[x] -- ;
if (!cnt[x]) res -- ;
}
int main()
{
scanf("%d", &n);
for (int i = 1; i <= n; i ++ ) scanf("%d", &w[i]);
scanf("%d", &m);
len = max(1, (int)sqrt((double)n * n / m));
for (int i = 0; i < m; i ++ )
{
int l, r;
scanf("%d%d", &l, &r);
q[i] = {i, l, r};
}
sort(q, q + m, cmp);
for (int k = 0, i = 0, j = 1, res = 0; k < m; k ++ )
{
int id = q[k].id, l = q[k].l, r = q[k].r;
while (i < r) add(w[ ++ i], res);
while (i > r) del(w[i -- ], res);
while (j < l) del(w[j ++ ], res);
while (j > l) add(w[ -- j], res);
ans[id] = res;
}
for (int i = 0; i < m; i ++ ) printf("%d\n", ans[i]);
return 0;
}
2、莫队的修改
来看一条题目(洛谷P1903):
观察到普通的莫队是无法进行修改的,因为修改存在先后顺序,但是我们可以通过在数组上建立一个新的时间轴,按照时间顺序存储每一次修改之后的值,如图所示
观察到如果时间戳1的下一个操作是2,就要判断修改节点(红色)是否在指针移动区间内,如果不在,就不用管,如果在,就需要更改这些操作。
注意,询问排序并不严格按时间戳排序,时间戳的作用仅仅是存储修改节点的先后顺序,严格来说,询问应当按照左端点所在块进行排序,同块时按照右端点所在块排序,如果还是一样才看时间戳的排序,所以会经常在时间戳上来回移动,可能一会上去了,一会又下来了,上去的时候把值改过去,下来的时候又要改回来。因此如果直接更改询问区间内修改的值,那么在回来的时候就找不到原来的数是什么了。
这里可以用一个小技巧,我们可以把所有的修改操作存在一个数组里,当修改进行时,只需要将当前的数和修改数组里的数进行交换即可,那么在改回来的时候再次交换就又换回了原来的数。
最后再计算一下时间复杂度方便计算分块的最优大小
设数组长度为n,询问次数m,分块大小为a,修改次数t
左端点移动:块内,块间,总
右端点移动:块内,块间,总
时间戳t移动:次数t * 左端点n / a * 右端点n / a ,
分情况讨论a:
①
总复杂度约为
②
总复杂度约为
③
总复杂度约为
很明显a大概在左右
观察题目所给复杂度
n,m复杂度近似,可以都用n代替,那么左右端点的复杂度约为
t的复杂度大约为
为使复杂度平均,将两式相等
解得
所以我们的区间长度 len 取 cbrt(n * t) 即可。
最后附上代码:
include <iostream>
#include <cstring>
#include <algorithm>
#include <cmath>
using namespace std;
const int N = 2e6, M = 1e6 + 10;
struct Query {
int id, l, r, t;
}q[N];
struct Modify {
int p, c;
}c[N];
int n, m, w[N], cnt[M];
int mq, mc, len;
int ans[N];
int get(int x) {
return x / len;
}
bool cmp(const Query& a, const Query& b) {
int al = get(a.l), bl = get(b.l);
int ar = get(a.r), br = get(b.r);
if (al != bl) return al < bl;
if (ar != br) return ar < br;
return a.t < b.t;
}
void add(int x, int& res) {
if (!cnt[x]) res++;
cnt[x]++;
}
void del(int x, int& res) {
cnt[x]--;
if (!cnt[x]) res--;
}
int main() {
scanf("%d%d", &n, &m);
for (int i = 1; i <= n; i++) scanf("%d", &w[i]);
for (int i = 0; i < m; i++) {
char op[2];
int a, b;
scanf("%s%d%d", op, &a, &b);
if (*op == 'Q') mq++, q[mq] = {mq, a, b, mc};
else c[++mc] = {a, b};
}
len = cbrt((double)n * max(1, mc)) + 1;
sort(q + 1, q + mq + 1, cmp);
int res = 0;
for (int i = 0, j = 1, t = 0, k = 1; k <= mq; k++) {
int id = q[k].id, l = q[k].l, r = q[k].r, tm = q[k].t;
while (i < r) add(w[++i], res);
while (i > r) del(w[i--], res);
while (j < l) del(w[j++], res);
while (j > l) add(w[--j], res);
while (t < tm) {
t++;
int p = c[t].p;
if (p >= j && p <= i)
{
del(w[p], res);
add(c[t].c, res);
}
swap(w[p], c[t].c);
}
while (t > tm) {
int p = c[t].p;
if (p >= j && p <= i)
{
del(w[p], res);
add(c[t].c, res);
}
swap(w[p], c[t].c);
t--;
}
ans[id] = res;
}
for (int i = 1; i <= mq; i++) cout << ans[i] << endl;
return 0;
}
3、莫队回滚
前两问可以发现莫队很容易维护出现次数,即桶数组cnt,但是如果碰到这题:(JOISC2014) 同样使用双指针的移动来更新询问区间,要维护一个区间内的最大值,加入很容易考虑,假设加入的数为 x,维护 res = max(res, x) 即可,但是如何删除一个数来更新res?
回滚莫队的核心思想就是只进行添加操作,不进行删除操作,用回滚的方法代替删除一段区间来转移指针。
我们用几张图来演示莫队是如何回滚的:
首先将询问区间按照左端点所在块进行排序
(如图,设红色为上一次询问,蓝色线段为当前询问)
设当前左端点所在分块为B
1、如果 B 与上一个询问的 B' 不同,就将莫队左指针 j 设为 R[B] + 1,右指针 i 设为 R[B],此时区间为空
2、如果询问区间的左右端点在同一分块内,那么直接暴力,时间在 ;
否则先将 i 扩展到右端点(由于在同一分块内,右指针按升序排列,所以 i 只会向一个方向走)直接加点即可,然后将 j 暴力加点到左端点,得到的结果保存;
4、减去第3步的所有点,为下一次询问做准备。
核心操作是第四步,根据询问区间排序的规则我们知道:在同一分块内,右指针按升序排列,所以 i 只会向一个方向移动,不用考虑删除点的操作,但是左端点是乱序的,我们并不知道上一次询问的左端点在当前询问左端点的左边还是右边,所以干脆直接暴力加上这一段数,反正区间长度最长也就。
这样一来,我们只需要每次添加询问区间左端点即可,用回滚的方式代替了删除。
附上代码:
#include <iostream>
#include <cstring>
#include <cstdio>
#include <algorithm>
#include <cmath>
#include <vector>
using namespace std;
typedef long long LL;
const int N = 100010;
int n, m, len;
int w[N], cnt[N];
LL ans[N];
struct Query
{
int id, l, r;
}q[N];
vector<int> nums;
int get(int x)
{
return x / len;
}
bool cmp(const Query& a, const Query& b)
{
int i = get(a.l), j = get(b.l);
if (i != j) return i < j;
return a.r < b.r;
}
void add(int x, LL& res)
{
cnt[x] ++ ;
res = max(res, (LL)cnt[x] * nums[x]);
}
int main()
{
scanf("%d%d", &n, &m);
len = sqrt(n);
for (int i = 1; i <= n; i ++ ) scanf("%d", &w[i]), nums.push_back(w[i]);
sort(nums.begin(), nums.end());
nums.erase(unique(nums.begin(), nums.end()), nums.end());
for (int i = 1; i <= n; i ++ )
w[i] = lower_bound(nums.begin(), nums.end(), w[i]) - nums.begin();
for (int i = 0; i < m; i ++ )
{
int l, r;
scanf("%d%d", &l, &r);
q[i] = {i, l, r};
}
sort(q, q + m, cmp);
for (int x = 0; x < m;)
{
int y = x;
while (y < m && get(q[y].l) == get(q[x].l)) y ++ ;
int right = get(q[x].l) * len + len - 1;
// 暴力求块内的询问
while (x < y && q[x].r <= right)
{
LL res = 0;
int id = q[x].id, l = q[x].l, r = q[x].r;
for (int k = l; k <= r; k ++ ) add(w[k], res);
ans[id] = res;
for (int k = l; k <= r; k ++ ) cnt[w[k]] -- ;
x ++ ;
}
// 求块外的询问
LL res = 0;
int i = right, j = right + 1;
while (x < y)
{
int id = q[x].id, l = q[x].l, r = q[x].r;
while (i < r) add(w[ ++ i], res);
LL backup = res;
while (j > l) add(w[ -- j], res);
ans[id] = res;
while (j < right + 1) cnt[w[j ++ ]] -- ;
res = backup;
x ++ ;
}
memset(cnt, 0, sizeof cnt);
}
for (int i = 0; i < m; i ++ ) printf("%lld\n", ans[i]);
return 0;
}
4、树上莫队
前三问都是一解决的一维数组,那么如果题目给了一棵树该怎么办:(洛谷SP10707)
样例给出的树是这样的 这里介绍一个能够将一棵树转化成序列的方法:欧拉序列
欧拉序列就是从根节点开始进行一次dfs遍历,当第一次到达某个点时,将它记下来,当最后一次到达这个点离开时,再记录一次这个点,也就是将树上的所有点在按前序遍历和后序遍历分别存下来
void dfs(int u, int father)
{
seq[ ++ top] = u;
first[u] = top;
for (int i = h[u]; ~i; i = ne[i])
{
int j = e[i];
if (j != father) dfs(j, u);
}
seq[ ++ top] = u;
last[u] = top;
}
对于样例的树来说,它的欧拉序列为:
1 2 2 3 5 5 6 6 7 7 3 4 8 8 4 1
我们可以将某个数 i 第一次出现的位置记为 ,最后一次出现的位置记录为,那么对于两个询问的点x, y,假设 ,
(1)、LCA(x, y) = x
意为x是y的父节点,此时我们只需要取出欧拉序列中的[ fst[x], fst[y] ]这一段,不难看出,在x到y路径上的点只会在这一段中出现一次,其余的点要么出现两次,要么一次也不出现。
(2)、LCA(x, y) != x
我们可以取出[ lst[x], fst[y] ]这一段,那么同样的,在路径上的点只会出现一次。注意到x,y的最近公共祖先并不在这个序列里,因为搜索的顺序是lca(x,y) -> x -> y -> lca(x,y),所以我们要额外把lca(x, y)添加一遍
那么对于每次询问,我们可以用两个数组来维护信息,cnt依旧是结果出现的次数,st数组用来维护某个权值出现的奇偶性,因为0和2都是偶数,出现2次和出现一次一致,所以我们只需要在添加操作里将st的奇偶性取反,这样删除一个数和添加一个数就是等价操作了。
add函数:
void add(int x, int& res)
{
st[x] ^= 1;
if (st[x] == 0)
{
cnt[w[x]] -- ;
if (!cnt[w[x]]) res -- ;
}
else
{
if (!cnt[w[x]]) res ++ ;
cnt[w[x]] ++ ;
}
}
最后默写一遍LCA函数的模板即可,附上完整代码:
#include <iostream>
#include <cstring>
#include <algorithm>
#include <cmath>
#include <vector>
using namespace std;
const int N = 100040, M = 1e5 + 10;
struct Query {
int id, l, r, p;
}q[N];
int n, m, w[N], cnt[M], st[N], ans[M], len;
int e[M], ne[M], h[N], idx;
int seq[N], top, que[N], fst[N], lst[N];
int depth[N], fa[N][16];
vector<int> nums;
void add_edge(int a, int b) {
e[idx] = b; ne[idx] = h[a]; h[a] = idx++;
e[idx] = a; ne[idx] = h[b]; h[b] = idx++;
}
int get(int x) {
return x / len;
}
void dfs(int u, int father) {
seq[++top] = u;
fst[u] = top;
for (int i = h[u]; ~i; i = ne[i]) {
int j = e[i];
if (j == father) continue;
dfs(j, u);
}
seq[++top] = u;
lst[u] = top;
}
void bfs() {
memset(depth, 0x3d, sizeof depth);
int hh = 0, tt = 0;
que[0] = 1; depth[0] = 0; depth[1] = 1;
while (hh <= tt) {
int t = que[hh++];
for (int i = h[t]; ~i; i = ne[i]) {
int j = e[i];
if (depth[j] > depth[t] + 1) {
depth[j] = depth[t] + 1;
fa[j][0] = t;
for (int k = 1; k <= 15; k++)
fa[j][k] = fa[fa[j][k - 1]][k - 1];
que[++tt] = j;
}
}
}
}
void add(int x, int &res) {
st[x] ^= 1;
if (st[x]) {
if (!cnt[w[x]]) res++;
cnt[w[x]]++;
}
else {
cnt[w[x]]--;
if (!cnt[w[x]]) res--;
}
}
int lca(int a, int b) {
if (depth[a] < depth[b]) swap(a, b);
for (int i = 15; i >= 0; i--) {
if (depth[fa[a][i]] >= depth[b])
a = fa[a][i];
}
if (a == b) return a;
for (int i = 15; i >= 0; i--) {
if (fa[a][i] != fa[b][i]) {
a = fa[a][i];
b = fa[b][i];
}
}
return fa[a][0];
}
bool cmp(const Query& a, const Query& b) {
int i = get(a.l), j = get(b.l);
if (i != j) return i < j;
return a.r < b.r;
}
int main() {
scanf("%d%d", &n, &m);
memset(h, -1, sizeof h);
for (int i = 1; i <= n; i++) scanf("%d", &w[i]), nums.push_back(w[i]);
sort(nums.begin(), nums.end());
nums.erase(unique(nums.begin(), nums.end()), nums.end());
for (int i = 1; i <= n; i++) w[i] = lower_bound(nums.begin(), nums.end(), w[i]) - nums.begin();
for (int i = 0; i < n - 1; i++) {
int a, b;
scanf("%d%d", &a, &b);
add_edge(a, b);
}
dfs(1, -1);
bfs();
for (int i = 0; i < m; i++) {
int a, b;
scanf("%d%d", &a, &b);
if (fst[a] > fst[b]) swap(a, b);
int p = lca(a, b);
if (p == a) q[i] = {i, fst[a], fst[b]};
else q[i] = {i, lst[a], fst[b], p};
}
len = sqrt(top);
sort(q, q + m, cmp);
for (int k = 0, res = 0, i = 0, j = 1; k < m; k++) {
int id = q[k].id, l = q[k].l, r = q[k].r, p = q[k].p;
while (i < r) add(seq[++i], res);
while (i > r) add(seq[i--], res);
while (j < l) add(seq[j++], res);
while (j > l) add(seq[--j], res);
if (p) add(p, res);
ans[id] = res;
if (p) add(p, res);
}
for (int i = 0; i < m; i++) printf("%d\n", ans[i]);
return 0;
}
5、莫队二次离线
在前四章总我们每次移动莫队的左右指针的复杂度为O(1),因为只需要加入/删除即可,那么对于那些每次移动一格需要对整个序列重新维护的题目,我们不妨设每次移动的复杂度为f(i),那么莫队的总复杂度为,很明显是无法接受的,比如这一题:(洛谷P4887)
我们可以设想当区间从移动到时,不管哪个指针向什么方向移动,都要统计区间内有多少数和它匹配,然后加上或删掉,两个指针移动的复杂度将会超过O(n),明显是无法接受的。
莫队二次离线的思路就是将两个指针拆开操作,每次将其中一个指针看作定点,然后处理另一个指针移动的贡献值,最后合并即可。这样一来的时间复杂度会降为。
方法1:
可以假设:
1、 表示x在区间[l, r]内匹配数的数量
2、 表示区间1中的每个数在区间2中匹配的数量总和
3、 表示对于当前的询问,x在1到k - 1的区间中匹配的数量
4、由前缀和的知识可以知道
由于莫队左右指针移动有多种情况,我们这里先举一种情况进行分析,如图
我们将右指针固定,将移动到位置,那么此时res应该
用4式变换得:
将移动到位置的总res变化为
对于该式, 可以直接用预处理差分数组后用O(1)算出 ;
部分可以将这个信息先记录下来,最后用扫描线扫一遍求出后在加进res。
随后对于左端点,右指针固定的方法与其一致,可以再推导一次式子:
总res变化:
同样的,第二个式子保存,之后通过扫描线求出后加入res,第一个式子直接求解即可。
方法二:
设表示前 i 项中与当前新加的数匹配的数量
设表示前 i 项中与 匹配的数量
设表示当前(统计到i)能和x匹配的数量
则
假设莫队右指针R想要向右移动一格,即R -> R + 1,那么结果应该增加
计算g数组
对于每一个数w[i],设与之匹配的数为x
则 x ^ w[i] = y,y是二进制有k个1的数。
移项得 x = y ^ w[i]
那么我们只需要把所有y找出来,存在一个nums数组里,每次计算g数组的时候直接暴力枚举即可。
总结:二次离线莫队代码不长,难于指针移动的推理,要仔细把握每一个移动的方向和符号,稍有不慎就要debug半天。。。。别问我为什么。。。这题是我夜里两点写完的。。。
最后附上代码:
#include <iostream>
#include <cstring>
#include <cstdio>
#include <algorithm>
#include <cmath>
#include <vector>
using namespace std;
typedef long long LL;
const int N = 100010;
int n, m, k, len;
int w[N];
LL ans[N];
struct Query
{
int id, l, r;
LL res;
}q[N];
struct Range
{
int id, l, r, t;
};
vector<Range> range[N];
int f[N], g[N];
inline int get_count(int x)
{
int res = 0;
while (x) res += x & 1, x >>= 1;
return res;
}
inline int get(int x)
{
return x / len;
}
bool cmp(const Query& a, const Query& b)
{
int i = get(a.l), j = get(b.l);
if (i != j) return i < j;
return a.r < b.r;
}
int main()
{
scanf("%d%d%d", &n, &m, &k);
for (int i = 1; i <= n; i ++ ) scanf("%d", &w[i]);
vector<int> nums;
for (int i = 0; i < 1 << 14; i ++ )
if (get_count(i) == k)
nums.push_back(i);
for (int i = 1; i <= n; i ++ )
{
for (auto y: nums) ++ g[w[i] ^ y];
f[i] = g[w[i + 1]];
}
for (int i = 0; i < m; i ++ )
{
int l, r;
scanf("%d%d", &l, &r);
q[i] = {i, l, r};
}
len = sqrt(n);
sort(q, q + m, cmp);
for (int i = 0, L = 1, R = 0; i < m; i ++ )
{
int l = q[i].l, r = q[i].r;
if (R < r) range[L - 1].push_back({i, R + 1, r, -1});
while (R < r) q[i].res += f[R ++ ];
if (R > r) range[L - 1].push_back({i, r + 1, R, 1});
while (R > r) q[i].res -= f[ -- R];
if (L < l) range[R].push_back({i, L, l - 1, -1});
while (L < l) q[i].res += f[L - 1] + !k, L ++ ;
if (L > l) range[R].push_back({i, l, L - 1, 1});
while (L > l) q[i].res -= f[L - 2] + !k, L -- ;
}
memset(g, 0, sizeof g);
for (int i = 1; i <= n; i ++ )
{
for (auto y: nums) ++ g[w[i] ^ y];
for (auto& rg: range[i])
{
int id = rg.id, l = rg.l, r = rg.r, t = rg.t;
for (int x = l; x <= r; x ++ )
q[id].res += g[w[x]] * t;
}
}
for (int i = 1; i < m; i ++ ) q[i].res += q[i - 1].res;
for (int i = 0; i < m; i ++ ) ans[q[i].id] = q[i].res;
for (int i = 0; i < m; i ++ ) printf("%lld\n", ans[i]);
return 0;
}
6、bitset套莫队
暂时还没学,等我学会再更新(逃
写在最后:莫队小公式
莫队 = 双指针 + 分块
莫队修改 = 莫队 + 时间戳
回滚莫队 = 莫队 + 备份暴力
树上莫队 = 莫队 + 最近公共祖先
莫队二次离线 = 莫队 + 差分
你学费了吗?