莫队 学习笔记
2026.7.6 终于把各种莫队全部学完了,特此来写个学习笔记。
莫队
高级的暴力算法
莫队是一种根号复杂度的离线算法,适用于区间 \([l,r]\) 的答案可以向 \([l + 1,r]\),\([l - 1,r]\),\([l,r-1]\),\([l,r+1]\) 转移的情况。一般来说这个转移的复杂度不会太高,但是如果很高也有办法——使用莫队二次离线(这个我们之后会讲)。
普通莫队
莫队的模板题是这样的:
例1-1
P2709 给定一个长度为 \(n\) 的序列 \(a\),\(m\) 次询问,每次询问给出两个整数 \(l,r\),求 \(\sum\limits_{i=1}^k c_i^2\) 的值,其中 \(c_i\) 表示数字 \(i\) 在 \([l,r]\) 中的出现次数。
\(n,m \le 5 \times 10^4\)(后面数据加强到 \(10^5\) 以防止暴力卡过)
我们有常规暴力算法:
对于每次询问,直接用桶来存储每个数出现的次数,然后遍历整个桶来求出出现次数的平方,复杂度 \(\mathcal{O}(nm + nV)\),其中 \(V\) 为数字的值域。
使用离散化可以优化到 \(\mathcal{O}(nm)\),但是也有一种不使用离散化也是这个复杂度的做法:我们往桶里存数的时候直接将原有的贡献删除,加上新贡献就行了,不需要后面再遍历一遍桶。
但无论哪种暴力都最多只能 \(\mathcal{O}(nm)\) 了。有没有一种更加优秀的做法呢?
你发现我们的暴力做法都是在线的,而本题支持离线。这说明,我们不按照询问给定的顺序来输出答案,将询问排个序,算出答案后再排回来也是可行的。排序有什么作用呢?
对于当前枚举的区间 \([l_{now},r_{now}]\),设上一个枚举的区间是 \([l_{last},r_{last}]\),我们只需要按照上面暴力的方法,移动 \(l\) 指针和 \(r\) 指针,删除旧贡献,增加新贡献,就能得到答案了。这样,我们只需要 \(|l_{now} - l_{last}| + |r_{now} - r_{last}|\) 次操作就完成了转移。
但是如果不排序还是会超时的,我们需要在排序这一方面动点小心思,使得两个指针的移动总次数在可接受范围之内。
莫涛给出了一个很好的方法:使用分块!
如何实现呢?他的做法是:设置一个常数 \(B\),按照每个区间 \(\left\lfloor\dfrac{l}{B}\right\rfloor\) 的大小从小到大排序,如果相同,则按 \(r\) 的大小从小到大排序。
什么意思呢?就是下面的这张图:

每个颜色的 \(\left\lfloor\dfrac{l}{B}\right\rfloor\) 大小相同。这里,颜色数叫做块数,而 \(B\) 就叫做块长。
那么在一个颜色内,移动左端点最多 \(B\) 次操作,又左端点总体大体呈升序排序,所以总共大约移动 \(mB\) 次操作;在相同颜色内移动右端点总共不超过 $n \times $ 颜色数 次操作(因为一个颜色内右端点已经有序)。因为颜色数最多为 \(\dfrac{n}{B}\) 个,所以右指针移动次数移动次数最多 \(2 \times \dfrac{n^2}{B}\) 次。注意带 \(2\) 的常数的原因是右端点从一个颜色移动到另一个颜色时最环需要移动 \(n\) 次,乘上颜色数同样为 \(\dfrac{n^2}{B}\)。但由于 \(2\) 是常数,后面分析复杂度时我们将它舍去。
于是总和为 \(mB + \dfrac{n^2}{B}\)。这就是我们高中经常会用到的基本不等式了,\(mB + \dfrac{n^2}{B} \ge 2 \sqrt{mB \times \dfrac{n^2}{B}} = 2n\sqrt{m}\),当且仅当 \(mB = \dfrac{n^2}{B}\) 时取等号。所以理论估计,当 \(B = \dfrac{n}{\sqrt{m}}\) 时,取得最优复杂度 \(\mathcal{O}(n \sqrt{m})\)。
但其实上面的过程大家不需要记,只需要知道 \(B\) 近似取 \(\sqrt{n}\) 即可,因为绝大部分情况 \(n,m\) 同阶。虽然会有常数因子影响使得最优的 \(B\) 会左右波动,但是只要出题人不卡是可以过的。
那么接下来就是代码细节了,但其实这道题很板没有什么要说的,主要看两个函数:
其实一个函数就是加上位置 \(x\) 的贡献,还有一个函数就是减去位置 \(x\) 的贡献。
但注意加上贡献时需要先移动到产生贡献的下标再进行 \(\text{add(x)}\) 操作,减去贡献时需要先减去该下标的贡献再移动指针。其实就是 \(\text{add(}\small{+\!+}\)\(\text{x)}\) 和 \(\text{del(x}\small{+\!+}\)\(\text{)}\) 的区别。大家都知道为什么吧。因为增加的贡献不在以前的区间内,而减去的贡献在以前的区间内。
下面是这道题的代码。
#include <iostream>
#include <cmath>
#include <algorithm>
#define int long long
using namespace std;
int n,m,k,B,a[100005],l = 1,r,cnt[100005],ans[100005],sum;//cnt:桶
struct node
{
int x,y,id;
}b[100005];
bool cmp(node e,node f)//按x/B从小到大为第一关键字,y从小到大为第二关键字排序
{
if(e.x / B != f.x / B)return e.x < f.x;
return e.y < f.y;
}
void add(int x)
{//加上新贡献,减去旧贡献(cnt[a[x]] - 1 的平方)
cnt[a[x]] ++,sum += cnt[a[x]] * cnt[a[x]] - (cnt[a[x]] - 1) * (cnt[a[x]] - 1);
}
void del(int x)
{
cnt[a[x]] --,sum -= (cnt[a[x]] + 1) * (cnt[a[x]] + 1) - cnt[a[x]] * cnt[a[x]];
}
signed main()
{
cin >> n >> m >> k;B = sqrt(n);
for(int i = 1;i <= n;i ++)cin >> a[i];
for(int i = 1;i <= m;i ++)
{
cin >> b[i].x >> b[i].y;
b[i].id = i;//记录询问编号
}
sort(b + 1,b + m + 1,cmp);
for(int i = 1;i <= m;i ++)
{//l,r:上一个询问的区间。初始时该区间为空,可设置为[1,0]
while(r < b[i].y)add(++r);//加入贡献
while(l > b[i].x)add(--l);
while(r > b[i].y)del(r--);//删除贡献
while(l < b[i].x)del(l++);
ans[b[i].id] = sum;//顺序输出答案
}
for(int i = 1;i <= m;i ++)cout << ans[i] << '\n';
}
其实排序不一定非得是这个排序方法,还有一种奇偶交替排序法,会比这种排序端点移动次数更少,但少的不多,为了理解还是推荐大家按照上面的排序方法排序。
虽然说这只是最基础的莫队,但是也能做很多偏难的题了。接下来我们来看几道例题。
例1-2
P1494 给定一个长度为 \(n\) 的序列 \(a\),\(m\) 次询问,每次询问给出两个整数 \(l,r\),求从这段区间内随机选两个数相同的概率。
\(n,m \le 5 \times 10^4,a_i \le n\)
这道题其实就是原模板的变形。因为选两个数的总方案数是容易求的,所以只需要求设我们新增加了一个点 \(x\),那么它造成的贡献和桶中有多少个数有关。根据等差数列求和可知,若桶中有 \(c\) 个相同的数,那么选两个数均为这个数的方案数为 \(\dfrac{c \times (c - 1)}{2}\)。直接维护即可。
由于就是之前模板的变形,就不贴代码了。实际上是我原来的码风超级丑
例1-3
P4462 给定一个长度为 \(n\) 的序列 \(a\),\(m\) 次询问,每次询问给出两个整数 \(l,r\),求选出两个数 \(x,y\) 满足 \(l \le x \le y \le r\) 的方案数,满足 \(a_x \oplus a_{x+1} \oplus \dots \oplus a_y = k\)。
\(n,m,k,a_i \le 10^5\)
这道题需要一个变形:大量求区间的异或和显然是会超时的,可以类比前缀和的思想,令 \(qian_i = a_1 \oplus a_{2} \oplus \dots \oplus a_i\),那么 \(a_x \oplus a_{x+1} \oplus \dots \oplus a_y = qian_y \oplus qian_{x - 1}\)。那么这道题的询问就可以变成:求选出两个数 \(x,y\) 满足 \(l\,{\color{red}{-1}} \le x \le y \le r\) 的方案数,满足 \(qian_x \oplus qian_y = k\)。
那这道题不就特别水了吗,用一个桶来存储区间内出现的数,每新加一个点 \(x\) 时,\(k \oplus x\) 在桶里出现的次数即为 \(x\) 对答案的贡献。删除同理。
提醒:此题需要开 long long。
#include <iostream>
#include <algorithm>
#include <cmath>
#define int long long
using namespace std;
int n,m,p[200005],block[100006],ans[100006],sum,k;
int a[671089];
struct node
{
int u,v,id;
}q[100005];
bool cmp(node e,node f)
{
if(block[e.u] != block[f.u])return block[e.u] < block[f.u];
return e.v < f.v;
}
void add(int x)
{
sum += a[k ^ p[x]];
a[p[x]] ++;
}
void del(int x)
{
a[p[x]] --;
sum -= a[k ^ p[x]];
}
signed main()
{
cin >> n >> m >> k;
int blocks = sqrt(n + 1);
for(int i = 0;i <= n;i ++)block[i] = i / blocks + 1;
for(int i = 1;i <= n;i ++)cin >> p[i],p[i] ^= p[i - 1];
for(int i = 1;i <= m;i ++)
{
cin >> q[i].u >> q[i].v;
q[i].u --;
q[i].id = i;
}
sort(q + 1,q + m + 1,cmp);
int l = 1,r = 0;
//cout << q[1].u << " " << q[1].v << '\n';
for(int i = 1;i <= m;i ++)
{
while(r < q[i].v)add(++r);
while(l > q[i].u)add(--l);
while(r > q[i].v)del(r--);
while(l < q[i].u)del(l++);
ans[q[i].id] = sum;
}
for(int i = 1;i <= m;i ++)cout << ans[i] << '\n';
}
例1-4
P3604 给定一个长度为 \(n\) 的字符串 \(a\),\(m\) 次询问,每次询问给出两个整数 \(l,r\),求选出两个数 \(x,y\) 满足 \(l \le x \le y \le r\) 的方案数,满足 \(a_x,a_{x+1},\dots,a_y\) 经重排后可以形成一个回文串。
\(n,m \le 60000,a\) 由小写字母构成
已经是紫题了哦!!!要上难度了。
这道题目有我们之后要讲的莫队二次离线做法,复杂度可以压到 \(\mathcal{O}(n\sqrt{m})\),但是我们假装不知道, 只需要会 \(\mathcal{O}(wn\sqrt{m})\) 的方法就行了,其中 \(w\) 为字符串字符集大小,本题中为 \(26\)。
我们来考虑能够通过重排形成回文串的字符串的性质:除了中间的字符,其余字符的出现次数均为偶数。当然所有字符出现次数都为偶数也是可以的,这就等价与出现次数为奇数的字符数量 \(\le 1\)。
但那些出现次数为偶数的字符可能很烦。于是我们想到了一个东西:异或!一个数被一个相同的数异或偶数次时,还是等于原来的那个数。
那么我们怎么将字符转换成数呢?很简单,a 对应 \(2^0\),b 对应 \(2^1\),c 对应 \(2^2\),...,z 对应 \(2^{25}\) 即可。
那么问题又变得很简单了:原题等价于:求选出两个数 \(x,y\) 满足 \(l\,{\color{red}{-1}} \le x \le y \le r\) 的方案数,满足 \(qian_x \oplus qian_y\) 的二进制位最多一个 \(1\)。
那么问题就和例1-3差不多了。我们直接可以枚举 \(1\) 的位置,累加 \(2^{i} \oplus x\) 在桶里出现的次数即可。
这里有个细节:桶需要开到 \(2^{26}\),占用空间很大,但不难发现 \(n,m\) 在 short 范围,因此桶可以用 short 来存储。
//位运算,不难发现异或区间出现最多一个1即可构造回文串
#include <iostream>
#include <algorithm>
#include <cmath>
using namespace std;
int n,m,p[200005],block[60006],ans[60006],sum;
short a[67108868];
string s;
struct node
{
int u,v,id;
}q[60005];
bool cmp(node e,node f)
{
if(block[e.u] != block[f.u])return block[e.u] < block[f.u];
return e.v < f.v;
}
void add(int x)
{
a[p[x]] ++;
for(int i = 0;i < 26;i ++)sum += a[p[x] ^ (1 << i)];
sum += a[p[x]] - 1;//这里应该是异或为0的
}
void del(int x)
{
sum -= a[p[x]] - 1,a[p[x]] --;//这里只有a[p[x]] - 1个与之匹配
for(int i = 0;i < 26;i ++)sum -= a[p[x] ^ (1 << i)];
}
int main()
{
cin >> n >> m >> s,s = " " + s;
int blocks = sqrt(n + 1);
for(int i = 0;i <= n;i ++)block[i] = i / blocks + 1;
for(int i = 1;i <= n;i ++)
{
p[i] = (1 << (s[i] - 97));//转化为整数
p[i] ^= p[i - 1];//异或前缀和
}
for(int i = 1;i <= m;i ++)
{
cin >> q[i].u >> q[i].v;
q[i].u --;
q[i].id = i;
}
sort(q + 1,q + m + 1,cmp);
int l = 1,r = 0;
for(int i = 1;i <= m;i ++)
{
while(r < q[i].v)add(++r);
while(r > q[i].v)del(r--);
while(l > q[i].u)add(--l);
while(l < q[i].u)del(l++);
ans[q[i].id] = sum;
}
for(int i = 1;i <= n;i ++)cout << ans[i] << '\n';
}
例1-5
P3245 给定一个长度为 \(n\) 的由数字构成的字符串 \(a\),\(m\) 次询问,每次询问给出两个整数 \(l,r\),求选出两个数 \(x,y\) 满足 \(l \le x \le y \le r\) 的方案数,满足 \(a_x,a_{x+1},\dots,a_y\) 顺序拼接组成的数可以被素数 \(p\) 整除。
\(n,m \le 2 \times 10^5,p \le 10^9\)
也是一道紫题,但其实也没那么难。
首先是怎么得到子串所构成的数的问题,我们采用前缀和思路:设 \(pre_i\) 表示前 \(i\) 个数字拼成的数对 \(p\) 取模的值,那么 \([l,r]\) 所构成的数就等于 \(pre_r - pre_{l - 1} \times 10^{r - l}\),题目要求这个数在模 \(p\) 意义下等于 \(0\),于是 \(pre_r\equiv pre_{l - 1} \times 10^{r - l}\pmod{p}\)。但我们发现难以化简消除 \(10^{r - l}\),所以我们移项,得到 \(pre_r \times 10^{-r} \equiv pre_{l - 1} \times 10^{- l}\pmod{p}\)。我们发现左边和 \(l\) 没关系,右边和 \(r\) 没关系,所以左边的 \(pre_r\) 可以直接乘以 \(10^{-r}\),右边同理,注意对 \(p\) 取模。
我们预处理出 \(10^i\) 模 \(p\) 的逆元,于是题目就变成了:求选出两个数 \(x,y\) 满足 \(l\,{\color{red}{-1}} \le x \le y \le r\) 的方案数,满足 \(pre_y\) 和 \(pre_x\) 相等。然后就变成了简单算贡献问题。
但我们发现一个问题:\(10^i\) 可能与 \(p\) 不互质导致 \(10^i\) 不存在逆元!但我们发现 \(p\) 要是质数且与 \(10^i\) 不互质只可能等于 \(2\) 或 \(5\),所以只需要看最后一位是否为 \(p\) 的倍数即可。可以前缀和来求出。
注意: \(p\) 很大,所以 \(pre\) 数组后面需要离散化。
#include <iostream>
#include <algorithm>
#include <cmath>
#define int long long
using namespace std;
int p,m,n,pre[200005],blocks,block[200005],b[200005],ans[200005];
string s;
struct node
{
int l,r,id;
}q[200005];
int fpow(int x,int y,int mod)
{
int v = 1;
while(y > 0)
{
if(y & 1)v = v * x % mod;
x = x * x % mod;
y >>= 1;
}
return v;
}
bool cmp(node e,node f)
{
int ee = block[e.l],ef = block[f.l];
if(ee != ef)return ee < ef;
return e.r < f.r;
}
int l = 1,r,cnt[200005],sum;
void add(int x)//相同数
{
cnt[pre[x]] ++;
sum += (cnt[pre[x]] - 1);
}
void del(int x)
{
cnt[pre[x]] --;
sum -= cnt[pre[x]];
}
int qian[200005],dot[200005];
signed main()
{
ios::sync_with_stdio(0);cin.tie(0),cout.tie(0);
cin >> p >> s >> m;
n = s.size(),s = " " + s;
if(p == 2 || p == 5)
{
for(int i = 1;i <= n;i ++)
{
if((s[i] - 48) % p == 0)qian[i] = qian[i - 1] + 1,dot[i] = dot[i - 1] + i;
else qian[i] = qian[i - 1],dot[i] = dot[i - 1];//dot_i:1~i的答案
}
while(m --)
{
cin >> l >> r;
cout << (dot[r] - dot[l - 1]) - (qian[r] - qian[l - 1]) * (l - 1) << '\n';//(1~r)-(l-1~r),注意因为还截去了l-1个长度后面还要减去 (qian[r] - qian[l - 1]) * (l - 1)
}
return 0;
}
blocks = sqrt(n);
for(int i = 1;i <= n;i ++)pre[i] = (pre[i - 1] * 10 + (s[i] - 48)) % p;
for(int i = 1;i <= n;i ++)pre[i] = pre[i] * fpow(fpow(10,i,p),p - 2,p) % p;//乘10^i的逆元
for(int i = 1;i <= m;i ++)
cin >> q[i].l >> q[i].r,q[i].l --,q[i].id = i;
for(int i = 1;i <= n;i ++)block[i] = (i - 1) / blocks + 1;
sort(q + 1,q + m + 1,cmp);
for(int i = 1;i <= n;i ++)b[i] = pre[i];
sort(b,b + n + 1);
int tot = unique(b,b + n + 1) - b - 1;//离散化
for(int i = 1;i <= n;i ++)pre[i] = lower_bound(b,b + tot + 1,pre[i]) - b;
for(int i = 1;i <= m;i ++)
{
while(l > q[i].l)add(--l);
while(l < q[i].l)del(l++);
while(r < q[i].r)add(++r);
while(r > q[i].r)del(r--);
ans[q[i].id] = sum;
}
for(int i = 1;i <= m;i ++)cout << ans[i] << '\n';
}
其他习题:P1997,P3709,P3674(bitset优化),P4396(套了一个分块)。
带修莫队
普通的莫队模板题不支持在线。于是有些出题人会往里面操作加入修改操作企图想让你强制在线(笑点:真正的强制在线其实是异或上一次查询的答案),但是莫队其实可以支持修改——且复杂度不退化到 \(\mathcal{O}(n^2)\)。而且在某些时候,甚至支持区间修改,不过那种东西太难了我们永不上(((
我们来看一道例题。
例2-1
P1903 给定一个长度为 \(n\) 的序列 \(a\),\(m\) 次询问,每次询问给出三个整数 \(opt,l,r\),当 \(opt = R\) 时将位置 \(l\) 的数变成 \(r\),\(opt = Q\) 时输出 \(a_l \sim a_r\) 中不同的数有多少个。
\(n,m \le 133333,a_i \le 10^6\)
要是没有修改那这道题目就是裸题了,但是我们怎么修改呢。
思路就是————把时间这一维放在第三维里!
就是说,我们在移动 \(l,r\) 指针的同时,还会移动指针 \(tim\),用来应对单点修改。当 \(tim + 1\),就说明当前操作是将执行 \(tim + 1\) 次修改,当 \(tim - 1\),就说明将第 \(tim\) 次修改撤回 ———— 至于怎么撤回吗,留个悬念,之后会讲。
首先这道题绝大部分还是和原莫队模板相同的。注意这里块长 \(B\) 不能为 \(\sqrt{n}\),而应该为 \(n^\frac{2}{3}\),同样是根据基本不等式。因为篇幅有限,在这里我就不证了。但其实理性分析也知道:要同时照顾三个维度,块长自然也要放大些。而带修莫队的总复杂度就是 \(\mathcal{O}(n^\frac{5}{3})\)。
如何排序?按 \(\left\lfloor\dfrac{l}{B}\right\rfloor\) 的大小从小到大排序,如果相同,则按 \(\left\lfloor\dfrac{r}{B}\right\rfloor\) 的大小从小到大排序,如果还相同,则按 \(tim\) 从小到大排序。
接下来就是更新 \(tim\) 值了。首先我们要知道只有出现修改时时间戳才会 \(+1\),而查询时不会。而且因为要离线,修改操作也必须得用数组存储。
for(int i = 1;i <= m;i ++)
{
cin >> c >> ww >> wx;
if(c == 'Q')b[++ge].x = ww,b[ge].y = wx,b[ge].tim = shi,b[ge].id = ge;
else cr[++shi].x = ww,cr[shi].y = wx;
}
初始的时间戳为 \(0\)。在增加时间戳时,需要看那次修改的点是否在区间内,如果是则更新贡献,但是即使点不在区间内也要将 \(a\) 数组更新——因为后面还有询问。
但是撤销怎么办?用一个看上去不起眼的函数——\(\text{swap}\)。
在增加时间戳时,设时间戳为 \(xian\),我们将 \(a_{cr_{xian}.x}\) 和 \(cr_{xian}.y\) 互换,而撤销时再换回来。于是你就完美的解决了这个问题。
但是同样,时间戳和其他维都是增加时先加后操作,减小时先操作后减。
#include <iostream>
#include <cmath>
#include <algorithm>
using namespace std;
int n,m,a[133355],br[133355],l = 1,r,cnt[1083355],ans[133335],sum,shi,ge,xian,ww,wx;
char c;
double k;
struct query
{
int x,y,zhi,id,tim;
}b[133355],cr[133355];
bool cmp(query f,query ff)
{
if(br[f.x] != br[ff.x])return f.x < ff.x;
if(br[f.y] != br[ff.y])return f.y < ff.y;
return f.tim < ff.tim;
}
bool cmp3(query f,query ff)
{
return f.id < ff.id;
}
void add(int r)
{
cnt[a[r]] ++,sum += (cnt[a[r]] == 1);
}
void del(int l)
{
cnt[a[l]] --,sum -= (cnt[a[l]] == 0);
}
signed main()
{
cin >> n >> m;
for(int i = 1;i <= n;i ++)cin >> a[i];
for(int i = 1;i <= m;i ++)
{
cin >> c >> ww >> wx;
if(c == 'Q')b[++ge].x = ww,b[ge].y = wx,b[ge].tim = shi,b[ge].id = ge;
else cr[++shi].x = ww,cr[shi].y = wx;
}
k = pow(n,0.72);//实测0.72效率更快
for(int i = 1;i <= n;i ++)br[i] = (i - 1) / k + 1;
sort(b + 1,b + ge + 1,cmp);
for(int i = 1;i <= ge;i ++)
{
while(r < b[i].y)add(++r);
while(r > b[i].y)del(r--);
while(l < b[i].x)del(l++);
while(l > b[i].x)add(--l);
while(xian < b[i].tim)
{
int al = cr[++xian].x;
if(l <= al && al <= r)
{
cnt[a[al]] --,sum -= (cnt[a[al]] == 0);
cnt[cr[xian].y] ++,sum += (cnt[cr[xian].y] == 1);
}
swap(a[al],cr[xian].y);
}
while(xian > b[i].tim)
{
if(l <= cr[xian].x && cr[xian].x <= r)
{
cnt[a[cr[xian].x]] --,sum -= (cnt[a[cr[xian].x]] == 0);//减去旧贡献
cnt[cr[xian].y] ++,sum += (cnt[cr[xian].y] == 1);//添加新贡献
}
swap(a[cr[xian].x],cr[xian].y);
xian --;
}
ans[b[i].id] = sum;
}
for(int i = 1;i <= ge;i ++)cout << ans[i] << '\n';
}
例2-2
P2464 给定一个长度为 \(n\) 的序列 \(a\),\(m\) 次询问,每次询问先给出一个字符 \(opt\),若 \(opt = C\),则给出两个整数 \(A,P\),将 \(a_A\) 替换为 \(P\);当 \(opt = Q\) 时给出三个整数 \(A,B,K\),询问 \(a_A,a_{A+1},...,a_B\) 当中有多少数等于 \(K\)。
\(n,m \le 10^5,a_i < 2^{31}\)
这道题是青的原因是有可持久树做法。但是用带修莫队也是可以做的,而且是很板的题。
首先进行离散化,如果 \(K\) 不在离散化后的数组中直接输出 \(0\)。
然后就开始算贡献了,用桶来存储,并且算贡献只需要 \(+1\) 或 \(-1\) 即可,非常好求。
代码就不给了其实我没有这道题的带修莫队AC代码
例2-3
CF940F 给定一个长度为 \(n\) 的序列 \(a\),\(m\) 次询问,每次询问先给出一个整数 \(t\),若 \(t = 2\),则给出两个整数 \(p,x\),将 \(a_p\) 替换为 \(x\);当 \(opt = 1\) 时给出两个整数 \(l,r\),询问最小的非负整数 \(x\),使得没有一个数在 \(a_l,a_{l+1},...,a_r\) 的出现次数为 \(x\)。
\(n,m \le 10^5,a_i \le 10^9\)
相信你能一眼秒了这道 *2600 的题。
首先肯定是用桶来存储每个点的出现次数,再用一个桶来存储第一个桶内每个数的出现次数(有点绕啊理解一下)
那么我们发现什么端倪没有?
答案最多只会到 \(\mathcal{O}(\sqrt{n})\)!
因为仔细一想,如果答案很大的话,那么肯定至少一个数出现 \(1\) 次,至少一个数出现 \(2\) 次,至少一个数出现 \(3\) 次......于是到了 \(\sqrt{n}\) 左右总个数就会超过 \(n\) 了。
所以直接暴力判断答案即可,复杂度 \(\mathcal{O}(n^\frac{5}{3})\)(别忘了带修改啊)。
因为这题也很水,代码就不放了。
回滚莫队/不删除莫队
有的时候用 \([l,r]\) 来更新 \([l,r+1]\) 和 \([l - 1,r]\) 是容易的,但是更新 \([l+1,r]\) 和 \([l,r-1]\) 却非常困难(这种题一般是求最大值),于是莫队进行了一次升级———— 回滚莫队。
顾名思义,回滚莫队不会使用删除操作,仅凭加入操作就能解决所有问题,且复杂度依然为 \(\mathcal{O}(n \sqrt{m})\)。
例3-1
P5906 给定长度为 \(n\) 的序列 \(a\),\(m\) 次询问一段区间 \([l,r]\),求区间中相同的数的最远间隔距离。
\(n,m \le 2 \times 10^5,a_i \le 2 \times 10^{9}\)
如果我们考虑增,那么这会相当容易:用桶来记录每个数出现位置下标的最小值和最大值,增的时候顺便对他们两个绝对值取 \(\max\) 即可。
但是减就不行了。如果一个最大距离中的其中一个数被删除了,那么次大距离是什么呢?谁也说不准,所以这条路不可行。
有没有什么方法能避开减的操作呢?
诶,有的兄弟,有的。
我们再来看一下那张图片:

重排后,我们发现第 \(i\) 个颜色的左区间有一个特别优美的性质:端点落在 \((i \times (B - 1),i \times B]\) 之间。
如果我们对每一个颜色分开讨论的话,那么左区间的期望平均移动次数就是 \(B\)。(这个之前的莫队已经讲到)
而这其中还有一个优美的性质:右端点是单调不降的。(话说这不就是排序方法吗)
所以说,对于一个颜色的所有线段,右端点只会增,不会减,所以右端点的 \(\text{del}\) 操作我们可以不用考虑,因为不会用到。
但是左端点不是有序的啊!这就要说到左区间的期望移动次数了——我们考虑把初始左指针设成 \(i \times B + 1\),然后每次移动左指针时都只能向左移动,因为 \(i \times B + 1\) 已经大于了任意一个左端点的位置。
但移动到左指针完了之后不能撤回。怎么办?很简单,我们先移动右指针 \(r\),然后记录 \([i \times B + 1,r]\) 的答案。这样,我们将 \(l\) 指针向左移动并记录到答案了之后,直接将 \(l\) 指针调到 \(i \times B + 1\) 即可,此时答案已经记录,故我们就成功完成了撤回操作。
但是此时的桶(每个数下标的最大值和最小值)还没有被撤回,而因为是最值问题我们也无法朴素更新。其实这个也很简单,我们用 \(\text{vector}\) 来把移动左指针移动前的最大值数组和最小值数组记录下来即可。
但数的个数太多,其实我们也没必要记这么多,只需要记在移动左指针时被更新的数就行了。具体而言,我们设 \(in_i\) 表示左指针 \(l\) 到 \(i \times B\) 之间(其实就是左指针的移动区间)是否包含权值为 \(i\) 的点。如果一次移动左端点时 \(in_{a_x} = 0\),说明这个点的最大(最小)下标将要被更新,用 \(\text{vector}\) 存储即可,然后将 \(in_{a_x}\) 设为 \(1\)。如果它已经不为 \(0\) 了,说明它已经被存储初始状态了,无需继续更新。
移动左指针的代码如下所示。
void add2(int w)
{//cnt:最大值,cnt2:最小值(原谅我把当时我的变量名都是乱取的
if(!in[a[w]])//将要被更新
{
in[a[w]] = 1;
ve.push_back(a[w]),ve2.push_back(cnt[a[w]]),ve3.push_back(cnt2[a[w]]);//存储当前权值,最大值,最小值
}
cnt2[a[w]] = w;
if(!cnt[a[w]])cnt[a[w]] = w;//注意区间内只有一个权值为a_w的情况,需要同时更新最大位置下标
res = max(res,cnt[a[w]] - cnt2[a[w]]);
}
于是似乎就大功告成了。
但注意此时还有一个细节:初始我们设的空区间是 \([i \times B + 1,i \times B]\)。这意味着右指针增加时,询问的右端点必须不小于 \(i \times B\)。那么右端点小于 \(i \times B\) 怎么办呢?
不难发现出现这种情况 \(l\) 和 \(r\) 的距离不超过块长 \(B\)。所以我们暴力枚举即可。
另外因为 \(a_i \le 2 \times 10^9\),需要事先对其离散化。
接下来贴上总代码。
#include <iostream>
#include <algorithm>
#include <cmath>
#include <vector>
#define int long long
using namespace std;
int n,m,a[200005],blocks,maxx,aa[200005],bb[200005],block[200005];
int ans[200005],tong[200005],cnt[200005],in[200005],cnt2[200005];
vector<int>ve,ve2,ve3;
struct node
{
int x,y,id;
}d[200005];
bool cmp(node ema,node emb)
{
int ss = block[ema.x],ss2 = block[emb.x];
if(ss != ss2)return ss < ss2;
return ema.y < emb.y;
}
int init(int o,int p)//暴力部分
{
int ans = 0;
for(int i = o;i <= p;i ++)tong[a[i]] = 0;//临时数组
for(int i = o;i <= p;i ++)
{
if(!tong[a[i]])tong[a[i]] = i;
ans = max(ans,i - tong[a[i]]);
}
return ans;
}
int res;
void add(int w)//扩展右端点
{
cnt[a[w]] = w;
if(!cnt2[a[w]])cnt2[a[w]] = w;
res = max(res,cnt[a[w]] - cnt2[a[w]]);
}
void add2(int w)
{
if(!in[a[w]])
{
in[a[w]] = 1;
ve.push_back(a[w]),ve2.push_back(cnt[a[w]]),ve3.push_back(cnt2[a[w]]);
}
cnt2[a[w]] = w;
if(!cnt[a[w]])cnt[a[w]] = w;
res = max(res,cnt[a[w]] - cnt2[a[w]]);
}
signed main()
{
ios::sync_with_stdio(0);
cin.tie(0),cout.tie(0);
cin >> n;
blocks = sqrt(n);
for(int i = 1;i <= n;i ++)block[i] = (i - 1) / blocks + 1;
for(int i = 1;i <= n;i ++)cin >> a[i],aa[i] = bb[i] = a[i];
sort(bb + 1,bb + n + 1);//离散化
int tot = unique(bb + 1,bb + n + 1) - bb - 1;
for(int i = 1;i <= n;i ++)a[i] = lower_bound(bb + 1,bb + tot + 1,a[i]) - bb;
cin >> m;
for(int i = 1;i <= m;i ++)cin >> d[i].x >> d[i].y,d[i].id = i;
sort(d + 1,d + m + 1,cmp);
for(int i = 1,uns = 1;i <= block[n];i ++)//拆成若干个颜色,分别求出答案
{
int R = min(blocks * i,n);
int l = R + 1,r = R,last = 0;//初始区间
res = 0;
for(;block[d[uns].x] == i;uns ++)//保证是第i个颜色
{
if(block[d[uns].x] == block[d[uns].y])//在一个块内可以直接暴力
{
ans[d[uns].id] = init(d[uns].x,d[uns].y);
continue ;
}
while(r < d[uns].y)add(++r);//先移动右指针
last = res;//先记录答案,询问完后指针回滚
while(l > d[uns].x)add2(--l);
ans[d[uns].id] = res;
res = last;
for(int j = 0;j < ve.size();j ++)in[ve[j]] = 0,cnt[ve[j]] = ve2[j],cnt2[ve[j]] = ve3[j];//回溯最大值数组和最小值数组
ve.clear(),ve2.clear(),ve3.clear(),l = R + 1;//端点回溯
}
for(int j = 1;j <= n;j ++)cnt[j] = cnt2[j] = 0;//因为每个颜色相互独立,记得清空
}
for(int i = 1;i <= m;i ++)cout << ans[i] << '\n';
}
Tips:用普通莫队能做的题其实都能用滚动莫队来做,但是常数和代码量大约是普通莫队的 \(1.5\) 倍。
再看一道例题。
例3-2
AT_joisc2014_c 给定长度为 \(n\) 的序列 \(a\),\(m\) 次询问一段区间 \([l,r]\),选择一个数 \(x\),设它在区间内的出现次数为 \(y\),则输出 \(x \times y\) 的最大值。
\(n,m \le 10^5,a_i \le 10^{9}\)
其实这道题比模板题还要简单一点。但为什么我把这题放在例3-2的原因是想增大你们的自信心
首先因为没有最大下标和最小下标,所以扩展左指针和扩展右指针的函数都是一样的,都是直接往桶里加一,然后答案顺便求桶里的个数和它自己乘积的最大值就行了。
其次因为更新桶特别容易,所以也不需要 \(\text{vector}\) 存储,直接向右移动 \(l\) 指针的同时减去桶内的个数就行了。
当然,因为这题的数很大,因此还是需要离散化。
#include <iostream>
#include <algorithm>
#include <cmath>
#define int long long
using namespace std;
int n,m,a[100005],blocks,maxx,aa[100005],bb[100005],block[100005];
int ans[100005],tong[100005],cnt[100005];
struct node
{
int x,y,id;
}d[100005];
bool cmp(node ema,node emb)
{
int ss = block[ema.x],ss2 = block[emb.x];
if(ss != ss2)return ss < ss2;
return ema.y < emb.y;
}
int init(int o,int p)
{
int ans = 0;
for(int i = o;i <= p;i ++)tong[a[i]] = 0;
for(int i = o;i <= p;i ++)
{
tong[a[i]] ++;
ans = max(ans,tong[a[i]] * aa[i]);
}
return ans;
}
int res;
void add(int w)
{
cnt[a[w]] ++;
res = max(res,cnt[a[w]] * aa[w]);
}
signed main()
{
cin >> n >> m;
blocks = sqrt(n);
for(int i = 1;i <= n;i ++)block[i] = (i - 1) / blocks + 1;
for(int i = 1;i <= n;i ++)cin >> a[i],aa[i] = bb[i] = a[i];
sort(bb + 1,bb + n + 1);
int tot = unique(bb + 1,bb + n + 1) - bb - 1;
for(int i = 1;i <= n;i ++)a[i] = lower_bound(bb + 1,bb + tot + 1,a[i]) - bb;
for(int i = 1;i <= m;i ++)cin >> d[i].x >> d[i].y,d[i].id = i;
sort(d + 1,d + m + 1,cmp);
for(int i = 1,uns = 1;i <= block[n];i ++)
{
int R = min(blocks * i,n);
int l = R + 1,r = R,last = 0;
res = 0;
for(;block[d[uns].x] == i;uns ++)
{
if(block[d[uns].x] == block[d[uns].y])
{
ans[d[uns].id] = init(d[uns].x,d[uns].y);
continue ;
}
while(r < d[uns].y)add(++r);
last = res;
while(l > d[uns].x)add(--l);
ans[d[uns].id] = res;
while(l <= R)cnt[a[l ++]] --;
res = last;
}
while(l <= r)cnt[a[l ++]] --;
}
for(int i = 1;i <= m;i ++)cout << ans[i] << '\n';
}
当然有不删除莫队还有不插入莫队。
例3-3
P4137 给定长度为 \(n\) 的序列 \(a\),\(m\) 次询问一段区间 \([l,r]\),求该区间内最小未出现的数是多少。
\(n,m \le 2 \times 10^5,a_i \le 2 \times 10^5\)
这道题目其实普通莫队也能做,这里说以下回滚莫队的做法。
这道题不同于之前的回滚莫队————扩展困难,因为不知道最小的未出现自然数出现了之后,第二小的未出现自然数是多少;但是删除很简单,只需要取 \(\min\) 即可。所以我们需要反者来,第二关键字应为 \(r\) 从大到小来排序,这样就能确保右指针只会往左做删除操作了。
另外左指针的初始位置也要发生变化,不应该为 \(i \times B\) 而应为 \((i - 1) \times B + 1\),以包含所有的左端点。
初始直接暴力计算 \([l,r]\) 的答案就行了。
这里有个显著降低码量的地方:因为执行的是删除操作,且左指针在最左端,所以无需特判查询右端点 \(\le i \times B\) 的情况,\(a_i \le 2 \times 10^5\) 还不用离散化。
#include <iostream>
#include <algorithm>
#include <cmath>
#include <vector>
#define int long long
using namespace std;
int n,m,a[200005],blocks,block[200005];
int ans[200005],cnt[200005];
struct node
{
int x,y,id;
}d[200005];
bool cmp(node ema,node emb)
{
int ss = block[ema.x],ss2 = block[emb.x];
if(ss != ss2)return ss < ss2;
return ema.y > emb.y;
}
int init(int o,int p)
{
for(int i = o;i <= p;i ++)cnt[a[i]] ++;
for(int i = 0;;i ++)
if(!cnt[i])return i;
}
int res;
void del(int w)
{
cnt[a[w]] --;
if(cnt[a[w]] == 0)res = min(res,a[w]);
}
signed main()
{
ios::sync_with_stdio(0);cin.tie(0),cout.tie(0);
cin >> n >> m;
blocks = min(n,(int)sqrt(n) * 2);
for(int i = 1;i <= n;i ++)block[i] = (i - 1) / blocks + 1;
for(int i = 1;i <= n;i ++)cin >> a[i];
for(int i = 1;i <= m;i ++)cin >> d[i].x >> d[i].y,d[i].id = i;
sort(d + 1,d + m + 1,cmp);
for(int i = 1,uns = 1;i <= block[n];i ++)
{
int R = blocks * (i - 1) + 1;
int l = R,r = d[uns].y;//最大的右端点
res = init(l,r);//暴力计算答案
int last = res;
for(;block[d[uns].x] == i;uns ++)
{
while(r > d[uns].y)del(r--);
last = res;
while(l < d[uns].x)del(l++);
ans[d[uns].id] = res;
while(l > R)cnt[a[-- l]] ++;//回滚
res = last;
}
while(l <= r)cnt[a[l ++]] --;//清空
}
for(int i = 1;i <= m;i ++)cout << ans[i] << '\n';
}
树上莫队
树上莫队一般是多次求树上某一段路径的权值和/最大权值,但众所周知我们并不会直接树上维护,所以需要把它压平成一个序列。一般的方法是求欧拉序。这里给还不会的人科普以下:欧拉序就是每个点在进去的时候(开始遍历)记录时间戳,出去(结束遍历)的时候再记录一次时间戳,存储在序列里面。显然这个序列的长度是 \(2n\)。
举个例子:

这颗树假定以 \(1\) 为根,欧拉序就为 \(1\,4\,6\,6\,7\,3\,3\,7\,4\,5\,2\,2\,5\,1\)(假定输入边的顺序从左到右)。
这里我们约定,一个结点 \(x\) 在欧拉序中第一次出现的位置为 \(s_x\),第二次出现的位置为 \(r_x\)。
那么它对维护一段路径有什么作用呢?
我们设这两个结点为 \(x\) 和 \(y\),它们的最近公共祖先为 \(z\)。
首先为了让 \(x\) 的欧拉序在 \(y\) 前面,所以如果 \(s_x > s_y\),则交换 \(x\) 和 \(y\)(可能存在 \(s_x < s_y < r_y < s_x\) 的情况,这个我们之后会说)。
我们分两种情况讨论。
第一种情况:\(z \neq x\) 且 \(z \neq y\)。然后我们发现 \(r_x\) 到 \(s_y\) 之间的区间内,如果不考虑出现两次的数,那么正好包含了 \(x\) 到 \(y\) 的路径除 \(z\) 外所有的数!
其实证明也很简单。首先 \(x\) 遍历完后会回溯到他的父结点,即 \(fa_x\)。所以 \(r_{fa_x}\) 肯定在该区间内。然后 \(fa_x\) 遍历完之后又会到 \(fa_{fa_x}\),以此类推,所以 \(x\) 到 \(z\)(不含)的路径一定都只会出现一次。请注意 \(s_{fa_x}\) 是不会在区间内的,因为 \(fa_x\) 在 \(x\) 之前就被遍历到了。
到了 \(z\) 后,又开始遍历到 \(y\) 所在的子树,那么到 \(y\) 时它的父结点肯定全部已经遍历过,而 \(y\) 又没遍历完,所以那些父结点也不会回溯,所以也只会出现一次。
至于那些出现两次的,是遍历了一个与 \(x,y\) 无关的子树往返遍历一遍,因此不会在 \(x\) 到 \(y\) 的路径上。
但是要注意的是 \(z\) 是不会出现在序列里面的(\(s_z < s_x\) 且 \(r_z > r_y\)),所以对于结点 \(z\) 需要特判。
第二种情况:\(z = x\) 或 \(z = y\)。因为 \(s_x < s_y\),所以 \(x\) 一定是 \(y\) 的祖先。我们需要遍历到结点 \(x\),所以只需要判断 \([s_x,s_y]\) 中出现了一次的数即可。对于不在其路径的子树的结点,如果被遍历到一定会出现两次,因为会一进一出。不过这种情况不需要考虑 \(z\) 的贡献,因为已经在序列里面了。
于是我们就成功转换为了序列上的莫队问题。
还有一个难点:如何统计只出现一次的数呢?
异或不可取,因为数可能很多且很大。所以可以考虑使用数组 \(cnt\) 数组,若 \(cnt_x = 0\),则进行插入操作;否则进行删除操作。操作后再将 \(cnt_x\) 异或 \(1\)。
还有一个问题:如何快速求最近公共祖先呢?额,那 recall 一下模板 P3379 吧。
例4-1
SP10707 给定一个包含 \(n\) 个结点的树,结点有点权,\(m\) 次询问一段路径中不同的点权数量。
\(n \le 4 \times 10^4,m \le 10^5\)(点权的数据范围没有给,故且认为是 \(10^9\) 吧)
这道题的方法已经在刚才讲了(((
但给几个警示后人:
-
可能需要离散化
-
欧拉序的长度为 \(2n\),所以算 \(block_i\) 的时候记得算到 \(2n\)
-
不要到了莫队的时候再判断最近公共祖先是不是两个之一,因为左右端点已经变了,可以在输入时就把它存起来。
#include <iostream>
#include <vector>
#include <algorithm>
#include <cmath>
using namespace std;
int n,m,a[200005],b[200005],s[200005],r[200005],idx,gg,blocks,block[200005],tong[200005];
int dp[100005][20],shen[100005];
vector<int>ve[200005];
void dfs(int x,int fa)
{
s[x] = ++idx,tong[idx] = x,dp[x][0] = fa,shen[x] = shen[fa] + 1;
for(int i = 1;i <= 18;i ++)dp[x][i] = dp[dp[x][i - 1]][i - 1];
for(int i = 0;i < ve[x].size();i ++)
if(ve[x][i] != fa)dfs(ve[x][i],x);
r[x] = ++idx,tong[idx] = x;
}
int lca(int x,int y)//倍增法求最近公共祖先
{
if(shen[x] < shen[y])swap(x,y);
for(int i = 18;i >= 0;i --)
{
if(shen[dp[x][i]] >= shen[y])x = dp[x][i];
}
if(x == y)return x;
for(int i = 18;i >= 0;i --)
{
if(dp[x][i] != dp[y][i])x = dp[x][i],y = dp[y][i];
}
return dp[x][0];
}
struct node
{
int l,r,id,lc;
}q[200005];
bool cmp(node e,node f)
{
int eg = block[e.l],ek = block[f.l];
if(eg != ek)return eg < ek;
return e.r < f.r;
}
int ls = 1,rs,sum,ans[200005],cnts[200005],cnts2[200005];
void add(int x)
{
if(cnts[tong[x]])cnts[tong[x]] = 0,cnts2[a[tong[x]]] --,sum -= (cnts2[a[tong[x]]] == 0);
else cnts[tong[x]] = 1,cnts2[a[tong[x]]] ++,sum += (cnts2[a[tong[x]]] == 1);
}
void del(int x)//插入和删除相同。
{
add(x);
}
int main()
{
cin >> n >> m;
for(int i = 1;i <= n;i ++)cin >> a[i],b[i] = a[i];
sort(b + 1,b + n + 1);
int tot = unique(b + 1,b + n + 1) - b - 1;
for(int i = 1;i <= n;i ++)a[i] = lower_bound(b + 1,b + tot + 1,a[i]) - b;
for(int i = 1,u,v;i < n;i ++)
{
cin >> u >> v;
ve[u].push_back(v),ve[v].push_back(u);
}
dfs(1,0);
gg = idx,blocks = sqrt(idx);
for(int i = 1;i <= idx;i ++)block[i] = (i - 1) / blocks + 1;
for(int i = 1;i <= m;i ++)
{
cin >> q[i].l >> q[i].r;
if(s[q[i].l] > s[q[i].r])swap(q[i].l,q[i].r);
q[i].id = i,q[i].lc = lca(q[i].l,q[i].r);
int fm1 = s[q[i].l],fm2 = r[q[i].l],fm3 = s[q[i].r];
if(q[i].lc == q[i].l)q[i].l = fm1,q[i].r = fm3;
else q[i].l = fm2,q[i].r = fm3;
}
sort(q + 1,q + m + 1,cmp);
for(int i = 1;i <= m;i ++)
{
while(rs < q[i].r)add(++rs);
while(rs > q[i].r)del(rs--);
while(ls > q[i].l)add(--ls);
while(ls < q[i].l)del(ls++);
int flag = 0;
if(q[i].lc != tong[q[i].l] && q[i].lc != tong[q[i].r])//对lca特判
if(!cnts2[a[q[i].lc]])flag ++;
ans[q[i].id] = sum + flag;
}
for(int i = 1;i <= m;i ++)cout << ans[i] << '\n';
}
例4-2
给你一棵 \(n\) 个结点的树,每个点有颜色 \(c_i\) 颜色在 \(1 \sim m\) 之间,\(q\) 次询问,要么会将 \(c_i\) 修改为 \(y\),要么询问一条路径,求\(\sum_{c}V_c\sum_{i=1}^{cnt_c}W_i\),其中 \(cnt_c\) 为 \(c\) 在路径中的出现次数
\(n,m,q \le 10^5,V_i,W_i \le 10^6\)
这道题看起来就很毒瘤哈,树上莫队还带修。(事实上它本来是一道黑题,后面降紫了,因为思维难度不大)
首先是这道题带修,时间戳肯定是需要的了。
但是对于扩展左右指针却非常容易,只需要多加一个 \(V_x \times W_{cnt_x}\) 就行了,其中 \(cnt_x\) 为 \(x\) 的出现次数。
这题唯一的一个难点是时间戳的维护。我们判断一个结点是否对路径有贡献,是判断它是否在欧拉序区间中只出现了一次。出现了两次我们可以完全不用管。
因为绝大部分代码就是 P1903 和 SP10707 的综合,且代码很长,这里就不放了。
莫队二次离线
回到正文第一句话给的问题。如果 \([l,r]\) 到 \([l,r+1]\),\([l,r-1]\) 等区间确实能转,但是复杂度达到 \(\mathcal{O}(\log n)\) 甚至 \(\mathcal{O}(\sqrt{n})\),导致 TLE,该怎么办呢?
回滚莫队是实现不了的,因为无论你删除还是插入复杂度都很高。
于是就有了莫队二次离线——把那些难操作的先记录下来,到后面再统一解决。
我们来看一道例题:
例5-1
P4887 给定长度为 \(n\) 的序列 \(a\),\(m\) 次询问一段区间 \([l,r]\),求该区间内选出两个不等的数 \(x,y\) 满足 \(a_x \oplus a_y\) 在二进制位含 \(k\) 个 \(1\) 的方案数。
\(n,m \le 10^5,0 \le a_i,k \le 2^{14}\)
莫队确实能做的,但是要枚举含 \(k\) 个 \(1\) 的数,计算器知道这个数最大达到了 \(3432\),那么最多需要甚至 \(3432n \sqrt{n}\) 次操作,甚至没有 \(\mathcal{O}(n^2)\) 快。
但是虽然 \(3432n \sqrt{n}\) 不能接受,但是 \(3432n + n \sqrt{n}\) 我们是能接受。也就是说,我们可以先花 \(\mathcal{O}(n \sqrt{n})\) 的时间完成部分答案查询,再用 \(3432n\) 次操作完成另一部分答案查询。
这种骚操作叫做莫队二次离线。
我们要做一个变形:
假设我们右指针从 \(r\) 扩展到了 \(r + 1\),那么新增的贡献应为 \(a_l \sim a_r\) 中和 \(a_{r + 1}\) 异或二进制位含 \(k\) 个 \(1\) 的个数,为方便起见我们把这个答案记作 \(f(l,r,r + 1)\)。
但由于这个太难算了,我们试着拆一下:\(f(l,r,r + 1) = f(1,r,r + 1) - f(1,l - 1,r + 1)\)
我们发现 \(f(1,r,r + 1)\)这个函数总共只能找到 \(n\) 个,可以直接预处理求出。
这个怎么预处理呢?还是用桶的方法。对于每个数 \(a_i\),把所有 \(a_i \oplus s_i\) 都放入桶,其中 \(s_i\) 为第 \(i\) 个二进制位含 \(k\) 个 \(1\) 的数。于是对于每个 \(a_i\) 桶内它的个数即为 \(f(1,i - 1,i)\)。下面我们用 \(pre_i\) 来表示预处理的数组。
for(int i = 0;i < (1 << 14);i ++)
if(__builtin_popcount(i) == k)us[++cnt] = i;//所有满足条件的数
for(int i = 1;i <= n;i ++)
{
pre[i] = tng[a[i]];//记录答案
for(int j = 1;j <= cnt;j ++)tng[a[i] ^ us[j]] ++;//加入桶
}
那么 \(f(1,r,r + 1)\) 解决完了,\(f(1,l - 1,r+1)\) 呢?
我们不立即求出来,而是在后面统一求。
但是实际上 \(f(1,l - 1,r+1),f(1,l - 1,r+2),...,f(1,l - 1,r+q_i.r)\) 也一起求不出来,我们可以用 \(\text{vector}\) 来记录所有这些待处理的值,如往 \(ve_{l - 1}\) 尾部加入 \(\{q_i.id,r+1,q_i.r,-1\}\)。这里,在 \(l - 1\) 加入表示与 \(a_1 \sim a_{l - 1}\) 做结合,第一个数表示累加到的查询编号,第二个数和第三个数表示待处理值的区间,第四个数表示贡献是减去还是加上。
接下来我们做一下收缩右端点操作:\(- f(l,r - 1,r) = - f(1,r - 1,r) + f(1,l - 1,r)\),前面预处理算出,后面往 \(ve_{l - 1}\) 尾部加入 \(\{q_i.id,q_i.r+1,r,1\}\)。
扩展左端点操作:\(f(l,r,l - 1) = f(1,r,l - 1) - f(1,l - 1,l - 1)\),后面预处理算出,前面往 \(ve_{r}\) 尾部加入 \(\{q_i.id,q_i.l,l-1,1\}\)。
注意:\(f(1,l - 1,l - 1)\) 对比 \(f(1,l - 2,l - 1)\) 其实本质上是相同的,因为自己不能对自己造成贡献。
收缩左端点操作:\(- f(l+1,r,l) = - f(1,r,l) + f(1,l,l)\),后面预处理算出,前面往 \(ve_r\) 尾部加入 \(\{q_i.id,l,q_i.l-1,-1\}\)。
然后我们就要进行第二次离线,求出第二部分的贡献了。实际上很简单:
首先从 \(1\) 开始,将 \(a_i \oplus s_i\) 加入桶,查询时设要查询的数为 \(x\),则贡献就是桶内存的 \(x\) 的个数。
for(int i = 1;i <= n;i ++)
{
for(int j = 1;j <= cnt;j ++)tng[a[i] ^ us[j]] ++;//和a_i能匹配的数
for(int j = 0;j < ve[i].size();j ++)
for(int k = ve[i][j].l;k <= ve[i][j].r;k ++)//算第k个数的贡献
ans[ve[i][j].id] += ve[i][j].opt * tng[a[k]];//将贡献加(减)到答案中
}
需要注意的是, \(ans\) 数组是对上一个询问的增量。于是你还需要一个前缀和来求出真正的 \(ans\)。
所以接下来就是全部代码了。
#include <iostream>
#include <cmath>
#include <algorithm>
#include <vector>
#define int long long
using namespace std;
int n,block,m,k,pre[200005],a[200005],us[200005],tng[200005],cnt,l = 1,r,ans[200005];
struct node
{
int x,y,id;
}q[200005];
struct nodes
{
int id,l,r,opt;//记录到第id次查询:l~r中,与前i项配对的有多少
};
bool cmp(node e,node f)
{
if(e.x / block != f.x / block)return e.x / block < f.x / block;
return e.y < f.y;
}
vector<nodes>ve[200005];
signed main()
{
cin >> n >> m >> k;
block = ceil(sqrt(n));
for(int i = 1;i <= n;i ++)cin >> a[i];
for(int i = 0;i < (1 << 14);i ++)
if(__builtin_popcount(i) == k)us[++cnt] = i;
for(int i = 1;i <= n;i ++)
{
pre[i] = tng[a[i]];
for(int j = 1;j <= cnt;j ++)tng[a[i] ^ us[j]] ++;
}
for(int i = 0;i < (1 << 14);i ++)tng[i] = 0;
for(int i = 1;i <= m;i ++)
cin >> q[i].x >> q[i].y,q[i].id = i;
sort(q + 1,q + m + 1,cmp);
for(int i = 1;i <= m;i ++)
{
//f(1,r,i) - f(1,l - 1,i)
if(r < q[i].y)ve[l - 1].push_back({q[i].id,r + 1,q[i].y,-1});
while(r < q[i].y)ans[q[i].id] += pre[++ r];
if(r > q[i].y)ve[l - 1].push_back({q[i].id,q[i].y + 1,r,1});
while(r > q[i].y)ans[q[i].id] -= pre[r --];
if(l < q[i].x)ve[r].push_back({q[i].id,l,q[i].x - 1,-1});
while(l < q[i].x)ans[q[i].id] += pre[l ++];
if(l > q[i].x)ve[r].push_back({q[i].id,q[i].x,l - 1,1});
while(l > q[i].x)ans[q[i].id] -= pre[-- l];
}
for(int i = 1;i <= n;i ++)
{
for(int j = 1;j <= cnt;j ++)tng[a[i] ^ us[j]] ++;
for(int j = 0;j < ve[i].size();j ++)
for(int k = ve[i][j].l;k <= ve[i][j].r;k ++)
ans[ve[i][j].id] += ve[i][j].opt * tng[a[k]];
}
for(int i = 1;i <= m;i ++)ans[q[i].id] += ans[q[i - 1].id];
for(int i = 1;i <= m;i ++)cout << ans[i] << '\n';
}
于是我们应该就可以AC......
了吗?
事实上,上述代码只能获得 \(30\) 分,会 WA 掉 \(4\) 个点。
实际上这份代码的二次离线部分确实存在一个错误———没有特判 \(k = 0\)!
回到前面,我们说扩展左端点操作往 \(ve_{r}\) 尾部加入 \(\{q_i.id,q_i.l,l-1,1\}\),收缩左端点操作往 \(ve_r\) 尾部加入 \(\{q_i.id,l,q_i.l-1,-1\}\)。但容易发现 \(r\) 显然是大于 \(q_i.l\) 和 \(l\) 的。这说明,我们在算贡献时,把自己的贡献也算了进去,而当 \(k = 0\) 时就会出错,所以每个点的贡献我们都多加了 \(1\)。
为了输出正确答案,可以往 while(l < q[i].x)ans[q[i].id] += pre[l ++]; 和 while(l > q[i].x)ans[q[i].id] -= pre[-- l]; 后面加上 + (!k)。这样 \(+1 - (+1) = 0\),即可成功抵消错误。
莫队二次离线有一道练习题:P5047。但是这道题难度太高,不要求做出。这题必须得用 \(\mathcal{O}(n\sqrt{n}+n\log n)\) 做法,否则就会超时。注意由于是求逆序对,移动左指针和移动右指针所带来的贡献是不一样的。感兴趣的人可以去尝试一下。

浙公网安备 33010602011771号