莫队算法学习笔记
普通莫队
"莫队算法"是用于一类离线区间询问问题的常用算法,以适用性广、代码量短、实际运行速度快、适合骗分等优点著称。 ——莫涛
莫队的基本操作基于暴力实现,其降低复杂度的突破口在于处理“询问”。通过对询问合理的排序,使得之后的询问充分利用先前询问得到的信息,可以将 的复杂度显著降低至 。确实是适合骗分的好算法。
以下题为例:
Acwing2492 HH的项链
长度为 的序列, 次询问,每次询问一段闭区间内有多少个不同的数。,。
先考虑暴力做法。对于每次操作,从 到 扫一遍,统计个数。但我们想,对于一些左右端点都相近区间,这样的做法显然浪费了很多可以利用的数据。
于是再考虑另一种暴力做法。用两个指针 , 标记左右区间,对于一个新的查询,只需移动这两个指针到新的位置即可。这样保留的可以利用的数据,不用重新扫描。但又很容易发现新的问题:对于两个相距很远的区间,移动指针仍然需要 。只需稍加构造,复杂度仍为 。此时,先前区间维护的信息也不能很好地传递给之后相近的区间,而是被中间的其他区间浪费掉了。
若我们将所有区间按右端点排序,则右端点指针仅会移动至多 次。这种单调性给我们启发。如果能再将所有相近的左端点维护在一起,那么不就解决了以上问题吗?
莫队维护相近左端点的方法是分块。设每块长度为 ,共 块。将所有区间按照 所属块排序,所属块相同时再按 递增排序。在这样的顺序下,执行上述暴力做法。然后我们再来分析时间复杂度:
- 左端点指针:块内移动每次为 ,移动 次;块间移动每次为 ,移动 次。共 。
- 右端点指针:对于左端点所属的每个块,每次 ,移动 次。共 。
总时间复杂度为 。其中 一定小于 ,可以忽略。利用基本不等式,,其中 ,是常数,故最小值在 与 相等时取得。于是得到 时,复杂度取最小值,值为 。
Code
#include<cstdio>
#include<cmath>
#include<algorithm>
using namespace std;
const int N = 5e4 + 5;
const int M = 2e5 + 5;
const int L = 1e6 + 5;
int n, a[N], m, len, pos[N];
int cnt[L], res, ans[M];
struct Query {
int id, l, r;
bool operator <(const Query &oth) const {
return pos[l] == pos[oth.l] ? r < oth.r : pos[l] < pos[oth.l];
}
} q[M];
void add(int x) {
if (!cnt[a[x]]) res++; cnt[a[x]]++;
}
void del(int x) {
cnt[a[x]]--; if (!cnt[a[x]]) res--;
}
int main() {
scanf("%d", &n);
for (int i = 1; i <= n; i++) scanf("%d", &a[i]);
scanf("%d", &m);
for (int i = 1; i <= m; i++) {
int l, r;
scanf("%d%d", &l, &r);
q[i] = (Query){i, l, r};
}
len = max(1, (int)sqrt((double)n * n / m));
for (int i = 1; i <= n; i++) pos[i] = (i - 1) / len + 1;
sort(q + 1, q + m + 1);
for (int k = 1, i = 0, j = 1; k <= m; k++) { //i右j左
while (i < q[k].r) add(++i);
while (i > q[k].r) del(i--);
while (j < q[k].l) del(j++);
while (j > q[k].l) add(--j); //这四个while十分浓缩,建议纸上推一遍
ans[q[k].id] = res;
}
for (int i = 1; i <= m; i++) printf("%d\n", ans[i]);
return 0;
}
带修莫队
顾名思义,就是可以修改序列数值的莫队。
例:Acwing2521 数颜色
题意:就是上题+修改操作。
普通的莫队,处理的是一维问题(序列)。我们在这里增加一个维度表示时间,时间轴的单位为每次修改操作。即每次修改之后,都有一个新的序列与之对应。此时查询操作为查询特定时间时的区间信息,显然可以离线。
此时莫队由一维变成了二维,我们也可以用相似的方法处理。先对序列分块,每块大小为 。此时询问有三元:()。先将所有询问按照 所属块排序,相同时按照 所属块排序,最后按照 递增排序。此时设三个指针:右指针 ,左指针 ,时间指针 。指针移动一单位均为 。分析复杂度:
- :与普通莫队相似,。
- :块内移动 , 次;块间移动 , 次。共 。
- :设共修改 次。 块间移动 次, 块间移动 次,每次 移动为 。共 。
相加,忽略 及常数,则为 。数学不好,过程推不来……结论是,当 与 处于同一个数量级时,最小值为 ,当 时取得。
另外, 指针会上下移动,故要维护好修改操作,并支持向下移动。小技巧详见代码。
Code
#include<cstdio>
#include<algorithm>
#include<cmath>
using namespace std;
const int N = 1e4 + 5;
const int L = 1e6 + 5;
int n, m, w[N], mc = 1, mq, pos[N];
int p[N], c[N], cnt[L], res, ans[N];
struct Query {
int id, l, r, t;
bool operator <(const Query &oth) const {
return pos[l] != pos[oth.l] ? pos[l] < pos[oth.l] : (pos[r] != pos[oth.r] ? pos[r] < pos[oth.r] : t < oth.t);
}
} q[N];
void add(int x) {
if (!cnt[x]) res++; cnt[x]++;
}
void del(int x) {
cnt[x]--; if (!cnt[x]) res--;
}
int main() {
scanf("%d%d", &n, &m);
for (int i = 1; i <= n; i++) scanf("%d", &w[i]);
while (m--) {
char opt[5];
int a, b;
scanf("%s%d%d", opt, &a, &b);
if (opt[0] == 'Q') q[++mq] = (Query){mq, a, b, mc};
else p[++mc] = a, c[mc] = b;
}
int len = max(1, (int)cbrt((double)n * mc)); //cbrt开三次根号。也可用pow(值,1.0/3)
for (int i = 1; i <= n; i++) pos[i] = (i - 1) / len + 1;
sort(q + 1, q + mq + 1);
for (int k = 1, i = 0, j = 1, t = 1; k <= mq; k++) {
int l = q[k].l, r = q[k].r, tt = q[k].t;
while (i < r) add(w[++i]);
while (i > r) del(w[i--]);
while (j < l) del(w[j++]);
while (j > l) add(w[--j]);
while (t < tt) {
++t;
if (j <= p[t] && p[t] <= i) {
del(w[p[t]]); add(c[t]);
}
swap(w[p[t]], c[t]); //小技巧来了
}
while (t > tt) {
if (j <= p[t] && p[t] <= i) {
del(w[p[t]]); add(c[t]);
}
swap(w[p[t]], c[t]);
--t;
}
ans[q[k].id] = res;
}
for (int i = 1; i <= mq; i++) printf("%d\n", ans[i]);
return 0;
}
回滚莫队
普通莫队在实现的时候,两个指针的移动代表的实际意义是在区间内加入或删除一个元素,时间复杂度均为 。但是,如果我们难以在 的时间内进行移动,那么莫队的复杂度就会增加。但是,对于一些加入操作为 ,删除操作大于 的普通莫队,我们可以使用回滚的方式,使得莫队的时间复杂度仍然保持 。
例:Acwing2523 历史研究
给定一个长度为 的序列,以及 次查询。每次查询为区间 ,计算方法如下:
对于任意一个数字 ,计算区间内其出现的次数 ,则其重要度为 。要求输出区间内所有数字的重要度的最大值。
,,序列中的数为不超过 的正整数。
显然,我们可以用普通莫队的方法处理。当区间加入一个数 时,,,复杂度 。但是删除一个数时,最大值的维护就不能再 实现,只能够用堆或线段树、平衡树等数据结构 实现。然而这样的莫队复杂度就变成了 ,无法通过此题。
回滚莫队巧妙地把删除操作转化为加入操作,维护了 的复杂度。
来看几个左端点在同一块内的询问:
首先对于Q123,长度不超过 ,暴力处理;
对于Q456,它们的右端点是递增的,而左端点不保证。若用普通莫队做法,需要维护删除操作。但是我们发现,左端点离块12的分界线R距离不超过 。那么我们将这些区间分成左右两部分,以R为分界;此时右部的左端点始终不变,右端点只会增加;左部长度不超过 ,可以暴力处理。此时复杂度与普通莫队相同,为 。
至于这种方法为什么叫做“回滚”呢?这体现在代码实现上。设左指针为 ,右指针为 。在处理Q456时,将 置于R, 随区间右端点不断右移。当 移动到区间右端点时,我们将维护的 备份。然后将 左移至左端点,更新答案,再将 重新移回R,将 恢复备份。
Code
#include<cstdio>
#include<cstring>
#include<cmath>
#include<algorithm>
#define ll long long
using namespace std;
const int N = 1e5 + 5;
int n, m, w[N], pos[N], cnt[N];
int mp[N], mpt;
ll res, ans[N];
struct Query {
int id, l, r;
bool operator <(const Query &oth) const {
return pos[l] != pos[oth.l] ? pos[l] < pos[oth.l] : r < oth.r;
}
} q[N];
int read() {
int x = 0; char c = getchar();
while (c < '0' || c > '9') c = getchar();
while (c >= '0' && c <= '9') {x = (x << 3) + (x << 1) + (c ^ 48); c = getchar();}
return x;
}
void add(int x) {
cnt[x]++; res = max(res, (ll)mp[x] * cnt[x]);
}
int main() {
n = read(); m = read();
for (int i = 1; i <= n; i++) mp[i] = w[i] = read();
sort(mp + 1, mp + n + 1);
mpt = unique(mp + 1, mp + n + 1) - (mp + 1);
for (int i = 1; i <= n; i++) w[i] = lower_bound(mp + 1, mp + mpt + 1, w[i]) - mp;
int len = max(1, (int)sqrt((double)n * n / m));
for (int i = 1; i <= n; i++) pos[i] = (i - 1) / len + 1;
for (int i = 1; i <= m; i++) q[i] = (Query){i, read(), read()};
sort(q + 1, q + m + 1);
for (int x = 1, y; x <= m; x = y) {
y = x;
int cur = pos[q[x].l];
memset(cnt, 0, sizeof (cnt));
for (; y <= m && pos[q[y].r] == cur; y++) {
res = 0;
for (int i = q[y].l; i <= q[y].r; i++) add(w[i]);
ans[q[y].id] = res;
for (int i = q[y].l; i <= q[y].r; i++) cnt[w[i]]--;
//数组大时不要频繁地使用memset!减回去更快!
//我以前还以为memset几乎没有时间复杂度……好吧,是我大意了……
}
memset(cnt, 0, sizeof (cnt)); res = 0;
int R = cur * len, i = R + 1, j = R;
for (; y <= m && pos[q[y].l] == cur; y++) {
while (j < q[y].r) add(w[++j]);
ll buckup = res;
while (i > q[y].l) add(w[--i]);
ans[q[y].id] = res;
while (i <= R) cnt[w[i++]]--;
res = buckup;
}
}
for (int i = 1; i <= m; i++) printf("%lld\n", ans[i]);
return 0;
}
树上莫队
就是把普通莫队的区间改成了树上的路径。解决方法也很简单。
例:Acwing2534 树上计数2
对这棵树求欧拉序(DFS序),并记录每个点第一次和第二次出现的位置(设为 和 )。考虑树上的两点 ,,满足 ,即 比 先出现:
- 若 是 的父节点,即 ,那么欧拉序中 到 区间内路径上的点出现一次,不在路径上的点出现2或0次。易证。
- 若 ,那么欧拉序中 到 区间内路径上的点( 除外)出现一次,不在路径上的点出现2或0次。也易证。
那么树上的路径就转化为欧拉序中的区间。问题转化为求一段区间内出现1次的不同的数的个数。用普通莫队可以处理。 特判即可。
Code
#include<cstdio>
#include<cmath>
#include<algorithm>
using namespace std;
const int N = 4e4 + 5, M = 1e5 + 5, H = 17;
int n, m, h, w[N], pos[N << 1];
int head[N], nxt[N << 1], ver[N << 1], tot;
int d[N], f[N][H], idx, dfn[N << 1], l[N], r[N];
int mp[N], mpt, cnt[N], st[N], res, ans[M];
struct Query {
int id, l, r, p;
bool operator <(const Query &oth) const {
return pos[l] != pos[oth.l] ? pos[l] < pos[oth.l] : r < oth.r;
}
} q[M];
void Add(int x, int y) {
nxt[++tot] = head[x]; head[x] = tot; ver[tot] = y;
}
void dfs(int x) {
dfn[++idx] = x; l[x] = idx;
for (int i = head[x]; i; i = nxt[i]) {
int y = ver[i];
if (d[y]) continue;
d[y] = d[x] + 1; f[y][0] = x;
for (int j = 1; j <= h; j++) f[y][j] = f[f[y][j - 1]][j - 1];
dfs(y);
}
dfn[++idx] = x; r[x] = idx;
}
int lca(int x, int y) {
if (d[x] < d[y]) swap(x, y);
for (int i = h; i >= 0; i--)
if (d[f[x][i]] >= d[y]) x = f[x][i];
if (x == y) return x;
for (int i = h; i >= 0; i--)
if (f[x][i] != f[y][i]) {
x = f[x][i]; y = f[y][i];
}
return f[x][0];
}
void oprt(int x) { //演得增减之法诸多相似,遂并之
st[x] ^= 1;
if (st[x]) {
if (!cnt[w[x]]) res++; cnt[w[x]]++;
}
else {
cnt[w[x]]--; if (!cnt[w[x]]) res--;
}
}
int main() {
int x, y;
scanf("%d%d", &n, &m); h = (int)log2(n) + 1;
for (int i = 1; i <= n; i++) {
scanf("%d", &w[i]); mp[i] = w[i];
}
sort(mp + 1, mp + n + 1);
mpt = unique(mp + 1, mp + n + 1) - (mp + 1);
for (int i = 1; i <= n; i++) w[i] = lower_bound(mp + 1, mp + mpt + 1, w[i]) - mp;
for (int i = 1; i < n; i++) {
scanf("%d%d", &x, &y);
Add(x, y); Add(y, x);
}
d[1] = 1; dfs(1); n <<= 1;
int len = max(1, (int)sqrt((double)n * n / m));
for (int i = 1; i <= n; i++) pos[i] = (i - 1) / len + 1;
for (int i = 1; i <= m; i++) {
scanf("%d%d", &x, &y);
if (l[x] > l[y]) swap(x, y);
int z = lca(x, y);
if (z == x) q[i] = (Query){i, l[x], l[y], 0};
else q[i] = (Query){i, r[x], l[y], z};
}
sort(q + 1, q + m + 1);
for (int k = 1, i = 1, j = 0; k <= m; k++) {
while (j < q[k].r) oprt(dfn[++j]);
while (j > q[k].r) oprt(dfn[j--]);
while (i < q[k].l) oprt(dfn[i++]);
while (i > q[k].l) oprt(dfn[--i]);
ans[q[k].id] = res + (q[k].p && !cnt[w[q[k].p]]);
}
for (int i = 1; i <= m; i++) printf("%d\n", ans[i]);
return 0;
}
本文作者:realFish的博客
本文链接:https://www.cnblogs.com/fish07/p/16084352.html
版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 2.5 中国大陆许可协议进行许可。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步