打打打打打字机|

realFish

园龄:3年1个月粉丝:3关注:0

莫队算法学习笔记

普通莫队

"莫队算法"是用于一类离线区间询问问题的常用算法,以适用性广、代码量短、实际运行速度快、适合骗分等优点著称。           ——莫涛

莫队的基本操作基于暴力实现,其降低复杂度的突破口在于处理“询问”。通过对询问合理的排序,使得之后的询问充分利用先前询问得到的信息,可以将 O(NM) 的复杂度显著降低至 O(NM) 。确实是适合骗分的好算法。

以下题为例:

Acwing2492 HH的项链
长度为 N 的序列,M 次询问,每次询问一段闭区间内有多少个不同的数。1N500001M2×105

先考虑暴力做法。对于每次操作,从 LR 扫一遍,统计个数。但我们想,对于一些左右端点都相近区间,这样的做法显然浪费了很多可以利用的数据。
于是再考虑另一种暴力做法。用两个指针 i,j 标记左右区间,对于一个新的查询,只需移动这两个指针到新的位置即可。这样保留的可以利用的数据,不用重新扫描。但又很容易发现新的问题:对于两个相距很远的区间,移动指针仍然需要 O(N)。只需稍加构造,复杂度仍为 O(NM)。此时,先前区间维护的信息也不能很好地传递给之后相近的区间,而是被中间的其他区间浪费掉了。
若我们将所有区间按右端点排序,则右端点指针仅会移动至多 N 次。这种单调性给我们启发。如果能再将所有相近的左端点维护在一起,那么不就解决了以上问题吗?
莫队维护相近左端点的方法是分块。设每块长度为 S,共 NS 块。将所有区间按照 L 所属块排序,L所属块相同时再按 R 递增排序。在这样的顺序下,执行上述暴力做法。然后我们再来分析时间复杂度:

  • 左端点指针:块内移动每次为 O(S),移动 M 次;块间移动每次为 O(S),移动 NS 次。共 O(SM+N)
  • 右端点指针:对于左端点所属的每个块,每次 O(N),移动 NS 次。共 O(N2S)

总时间复杂度为 O(SM+N+N2S)。其中 N 一定小于 N2S,可以忽略。利用基本不等式,SM+N2SN2M,其中 N,M是常数,故最小值在 SMN2S 相等时取得。于是得到 S=N2M 时,复杂度取最小值,值为 O(NM)

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 数颜色
题意:就是上题+修改操作。
普通的莫队,处理的是一维问题(序列)。我们在这里增加一个维度表示时间,时间轴的单位为每次修改操作。即每次修改之后,都有一个新的序列与之对应。此时查询操作为查询特定时间时的区间信息,显然可以离线。
此时莫队由一维变成了二维,我们也可以用相似的方法处理。先对序列分块,每块大小为 S。此时询问有三元:(L,R,t)。先将所有询问按照 L 所属块排序,相同时按照 R 所属块排序,最后按照 t 递增排序。此时设三个指针:右指针 i,左指针 j,时间指针 t。指针移动一单位均为 O(1)。分析复杂度:

  • j:与普通莫队相似,O(SM+N)
  • i:块内移动 O(S)M 次;块间移动 O(N)NS 次。共 O(SM+N2S)
  • t:设共修改 T 次。i 块间移动 NS 次,j 块间移动 NS 次,每次 t 移动为 O(T)。共 O(N2TS2)

相加,忽略 N 及常数,则为 O(MS+N2S1+N2TS2)。数学不好,过程推不来……结论是,当 MN 处于同一个数量级时,最小值为 O(N4T3),当 S=NT3 时取得。
另外,t 指针会上下移动,故要维护好修改操作,并支持向下移动。小技巧详见代码。

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;
}

回滚莫队

普通莫队在实现的时候,两个指针的移动代表的实际意义是在区间内加入或删除一个元素,时间复杂度均为 O(1)。但是,如果我们难以在 O(1) 的时间内进行移动,那么莫队的复杂度就会增加。但是,对于一些加入操作为 O(1),删除操作大于 O(1) 的普通莫队,我们可以使用回滚的方式,使得莫队的时间复杂度仍然保持 O(NM)

例:Acwing2523 历史研究
给定一个长度为 N 的序列,以及 Q 次查询。每次查询为区间 [L,R],计算方法如下:
对于任意一个数字 t,计算区间内其出现的次数 s,则其重要度为 t×s。要求输出区间内所有数字的重要度的最大值。
1N1051Q105,序列中的数为不超过 109 的正整数。

显然,我们可以用普通莫队的方法处理。当区间加入一个数 x时,cnt[x]++res=max(res,x×cnt[x]),复杂度 O(1)。但是删除一个数时,最大值的维护就不能再 O(1) 实现,只能够用堆或线段树、平衡树等数据结构 O(logN) 实现。然而这样的莫队复杂度就变成了 O(NlogN·M),无法通过此题。
回滚莫队巧妙地把删除操作转化为加入操作,维护了 O(NM) 的复杂度。
来看几个左端点在同一块内的询问:

首先对于Q123,长度不超过 O(N),暴力处理;
对于Q456,它们的右端点是递增的,而左端点不保证。若用普通莫队做法,需要维护删除操作。但是我们发现,左端点离块12的分界线R距离不超过 N。那么我们将这些区间分成左右两部分,以R为分界;此时右部的左端点始终不变,右端点只会增加;左部长度不超过 N,可以暴力处理。此时复杂度与普通莫队相同,为 O(NM)
至于这种方法为什么叫做“回滚”呢?这体现在代码实现上。设左指针为 i,右指针为 j。在处理Q456时,将 i 置于R,j 随区间右端点不断右移。当 j 移动到区间右端点时,我们将维护的 res 备份。然后将 i 左移至左端点,更新答案,再将 i 重新移回R,将 res 恢复备份。

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序),并记录每个点第一次和第二次出现的位置(设为 L[x]R[x])。考虑树上的两点 x,y,满足 L[x]<L[y],即 xy 先出现:

  • xy 的父节点,即 LCA(x,y)=x,那么欧拉序中 L[x]L[y] 区间内路径上的点出现一次,不在路径上的点出现2或0次。易证。
  • LCA(x,y)x,那么欧拉序中 R[x]L[y] 区间内路径上的点(LCA(x,y) 除外)出现一次,不在路径上的点出现2或0次。也易证。

那么树上的路径就转化为欧拉序中的区间。问题转化为求一段区间内出现1次的不同的数的个数。用普通莫队可以处理。LCA(x,y) 特判即可。

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 中国大陆许可协议进行许可。

posted @   realFish  阅读(135)  评论(0编辑  收藏  举报
点击右上角即可分享
微信分享提示
评论
收藏
关注
推荐
深色
回顶
收起