【Coel.学习笔记】莫队(上)-引入和带修、回滚莫队

终于到莫队啦~听说 WC2022 就考了一道回滚莫队题,有空看一看w

引入

莫队是一种基于分块的算法,能够解决离线区间询问的问题。这个算法曾在 Codeforces 小范围流传,后来由国家队选手莫涛总结提出并成为一个广为人知的算法。经过几代 OIer 的发展,莫队演变出了许多种扩展版本,在算法竞赛中的使用也越来越广泛。

基础莫队

先来看一道简单的例题。

SP3267 DQUERY - D-query

洛谷传送门
给定一个数列,静态询问每个区间中不同数字的个数。

解析:这题可以用树状数组或者主席树等其他数据解决,但由于静态查询,也可以使用莫队。

从暴力算法开始想。对于每个区间使用一个桶暴力枚举所有数字,做一遍计数就可以得到答案了。显然暴力的单次询问时间复杂度是 \(O(n)\)

有一个不太能想到的优化方式——从上一个查询答案推到下一个查询答案。我们移动区间的左右端点,先把右端点移动到新区间的右端点,每次给桶增加上出现的数字;再把左端点移动到新区间的左端点,同时更新桶。在这个过程中,我们维护答案只需要根据移动情况操作就可以了。


虽然这么做没有让时间复杂度得到改观,但是为莫队算法的使用提供了契机。

首先,我们可以给询问按照右端点做一个排序,这样右端点就有了单调性。这时右端点不会来回移动,类似双指针法,这样操作的总复杂度是 \(O(n)\)

虽然左端点不具有单调性,但我们可以借用分块思想加速端点移动。在排序时,我们进一步细化,先让左端点按照分块编号排序,分块编号相同时再排序右端点下标。这样在移动左端点时,我们就可以按照分块的操作,同一块内则暴力移动,在不同的块则每次跳一块。

此时,单次询问的时间复杂度就变成了 \(O(\sqrt n)\),总时间复杂度为 \(O(m\log m+m\sqrt n)\)。这里的 \(O(m\log m)\) 是排序的时间复杂度。

由于询问次数 \(m\) 和数列长度 \(n\) 不一定同阶,所以我们在分配块长的时候需要更好的方式。一种常见的方法是把块长设置为 \(\sqrt {\dfrac{n^2}{m}}\)。这时 \(\dfrac{n^2}{m}\) 可能为零,我们可以人工地加上 \(1\) 规避浮点数错误。

代码如下:

#include <algorithm>
#include <cmath>
#include <iostream>

#define get(x) (x - 1) / len

using namespace std;

const int maxn = 2e5 + 10, mlen = 1e6 + 10;

int n, m, len;
int a[maxn], ans[maxn];
int cnt[mlen];

struct node {
    int l, r, pos;
    inline bool operator<(const node &x) const {
        int i = get(l), j = get(x.l);
        if (i != j) return i < j;
        return r < x.r;
    }
} q[maxn];

inline void add(int x, int &res) {
    if (!cnt[x]) res++;
    cnt[x]++;
}

inline void del(int x, int &res) {
    cnt[x]--;
    if (!cnt[x]) res--;
}

int main(void) {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    cin >> n;
    for (int i = 1; i <= n; i++) cin >> a[i];
    cin >> m;
    len = sqrt((double)n * n / m) + 1;
    for (int i = 0; i < m; i++) {
        cin >> q[i].l >> q[i].r;
        q[i].pos = i;
    }
    sort(q, q + m);
    for (int k = 0, i = 0, j = 1, res = 0; k < m; k++) {
        int pos = q[k].pos, l = q[k].l, r = q[k].r;
        while (i < r) add(a[++i], res);
        while (i > r) del(a[i--], res);
        while (j < l) del(a[j++], res);
        while (j > l) add(a[--j], res);
        ans[pos] = res;
    }
    for (int i = 0; i < m; i++) cout << ans[i] << '\n';
    return 0;
}

顺带一提,莫队的空间复杂度为 \(O(n)\),时间复杂度为 \(O(m\sqrt n)\)(不计排序);主席树的空间复杂度为 \(O(n\log n)\),时间复杂度为 \(O(m\log n)\)。在实际运用中,我们要注意题目的时空限制,采用适当的算法解决问题。

带修莫队

上一道题没有修改要求,可以很轻松地用莫队得到答案。但如果在询问过程中有修改(没有强制在线要求),莫队还能做吗?
答案是可以的。我们再看一道例题。

[国家集训队] 数颜色 / 维护队列

洛谷传送门
给定一个数列,进行两种操作:查询区间内不同数字的个数,或者单点修改某一个数。

解析:上一题的查询可以看作是在一个数轴上进行的,这里多了查询操作,我们可以为时间建立一个维度,变成二维。这样每个操作都有三个值:左端点,右端点,修改时间。

这时排序又多了一维,这也意味着时间维度不递增,所以修改时还得再考虑考虑。这里可以利用一个小技巧,如果时间为逆就交换回去,时间为顺就交换过来。

还有一个更麻烦的问题:块长怎么定?由于这题三个关键字,直接用根号不是最优解。我们分别统计一下三个端点的移动次数来推算一下。假设块长为 \(s\),则有 \(\dfrac{n}{s}\) 个块。这时左端点移动为 \(O(sn)\),右端点移动为 \(O(sn)\),时间维度移动为 \(O(\dfrac{n^2}{s^2}t)\),其中 \(t\) 为修改次数,即时间维度的最大值。还是根据均值不等式的原理,当 \(sn=\dfrac{n^2}{s^2}t\),即 \(s=(nt)^\frac{1}{3}\) 时,可以得到最优复杂度 \(O(n^\frac{4}{3}t^\frac{1}{3})\)。当 \(n\)\(t\) 同阶时,\(s=n^\frac{2}{3}\),时间复杂度就是 \(O(n^\frac{5}{3})\)

这题同样可以用树状数组/主席树等数据结构做,留给读者思考。

#include <algorithm>
#include <cmath>
#include <iostream>

#define get(x) (x - 1) / len

using namespace std;

const int maxn = 2e5 + 10, mlen = 1e6 + 10;

int n, m, len, mq, mc, res; //mq 是查询次数, mc 是修改次数
int a[maxn], ans[maxn], cnt[mlen];

struct node {
    int id, l, r, t;
    inline bool operator<(const node &x) const {
        int al = get(l), ar = get(r), bl = get(x.l), br = get(x.r);
        if (al != bl) return al < bl;
        if (ar != br) return ar < br;
        return t < x.t;
    }
} q[maxn];

struct cord {
    int p, c;
} c[maxn];

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(void) {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    cin >> n >> m;
    for (int i = 1; i <= n; i++) cin >> a[i];
    for (int i = 0; i < m; i++) {
        char op
        cin >> op;
        if (op == 'Q') {
            mq++;
            q[mq].id = mq, q[mq].t = mc;
            cin >> q[mq].l >> q[mq].r;
        } else
            mc++, cin >> c[mc].p >> c[mc].c;
    }
    len = pow(n, 2.0 / 3.0);
    sort(q + 1, q + mq + 1);
    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(a[++i], res);
        while (i > r) del(a[i--], res);
        while (j < l) del(a[j++], res);
        while (j > l) add(a[--j], res);//前面四个操作和普通莫队一样
        while (t < tm) {
            t++;
            if (c[t].p >= j && c[t].p <= i) {
                del(a[c[t].p], res);
                add(c[t].c, res);
            }
            swap(a[c[t].p], c[t].c);
        }
        while (t > tm) {
            if (c[t].p >= j && c[t].p <= i) {
                del(a[c[t].p], res);
                add(c[t].c, res);
            }
            swap(a[c[t].p], c[t].c);
            t--;
        }
        ans[id] = res;
    }
    for (int i = 1; i <= mq; i++) cout << ans[i] << '\n';
    return 0;
}

回滚莫队

莫队算法的时间复杂度优势不仅在于分块,还在于加入和删除(即上面代码中的 adddel)操作都是 \(O(1)\) 的。但对于一些特殊的题目,加入或删除操作难以维护而另一个操作比较容易维护的时候,我们就要用回滚莫队。

AT1219 歴史の研究 (不删除莫队)

洛谷传送门
给定一个数列,定义一个数的重要程度为这个数字在区间内的出现次数乘上该数字本身。静态查询区间重要程度的最大值。

解析:对于这道题而言,维护插入操作是很容易的,因为如果增加会影响答案,那么新的答案一定是刚刚增加的数字,可以唯一确定下来;而删除操作并不容易维护,因为如果删除过后答案改变,那么重要度最大的数字就难以找到了。这时,就要用回滚莫队。

考虑每个块内的做法。先处理第一个块的所有询问,那么剩下的询问右端点一定不在这个块内。将维护区间的左端点固定在下一个块上,这样询问区间就分成了两部分:在右边块和在左边块。对于在右边块的部分,可以直接移动右端点扫描;对于左边块的端点,由于长度很小(不多于一个块的长度),所以可以暴力修改。

那么,左端点的单次询问移动次数为 \(O(\sqrt n)\),右端点的移动次数和为 \(O(n)\),总复杂度仍为 \(O(m\sqrt n)\)。这题的块长不像上一题那么难算,直接用 \(\sqrt n\) 就行了。

另外本题的值域很大 (\(x\leq 10^9\)),需要做一遍离散化。

#include <algorithm>
#include <cmath>
#include <cstring>
#include <iostream>
#include <vector>

#define get(x) (x - 1) / len

typedef long long ll;

using namespace std;

const int maxn = 1e6 + 10;

int n, m, len;
int a[maxn], cnt[maxn];
int x, y;
ll ans[maxn];

struct node {
    int l, r, pos;
    inline bool operator<(const node &x) const {
        int i = get(l), j = get(x.l);
        if (i != j) return i < j;
        return r < x.r;
    }
} q[maxn];
vector<int> rec;

inline void init_hash() {
    sort(rec.begin(), rec.end());
    rec.erase(unique(rec.begin(), rec.end()), rec.end());
    for (int i = 1; i <= n; i++)
        a[i] = lower_bound(rec.begin(), rec.end(), a[i]) - rec.begin();
}

void add(int x, ll &res) {
    cnt[x]++;
    res = max(res, 1LL * cnt[x] * rec[x]);
}

int main(void) {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    cin >> n >> m;
    len = sqrt(n);
    for (int i = 1; i <= n; i++) {
        cin >> a[i];
        rec.push_back(a[i]);
    }
    init_hash();
    for (int i = 0; i < m; i++) {
        cin >> q[i].l >> q[i].r;
        q[i].pos = i;
    }
    sort(q, q + m);
    while (x < m) {
        y = x;
        while (y < m && get(q[y].l) == get(q[x].l)) y++;
        int tail = get(q[x].l) * len + len - 1;
        /*暴力处理第一个块*/
        while (x < y && q[x].r <= tail) {
            ll res = 0;
            int pos = q[x].pos, l = q[x].l, r = q[x].r;
            for (int k = l; k <= r; k++) add(a[k], res);
            ans[pos] = res;
            for (int k = l; k <= r; k++) cnt[a[k]]--;//回滚操作
            x++;
        }
        /*进行后续操作*/
        ll res = 0, tem;
        int i = tail, j = tail + 1;
        while (x < y) {
            int pos = q[x].pos, l = q[x].l, r = q[x].r;
            while (i < r) add(a[++i], res);
            tem = res;
            while (j > l) add(a[--j], res);
            ans[pos] = res;
            while (j < tail + 1) cnt[a[j++]]--; //回滚操作
            res = tem;
            x++;
        }

        memset(cnt, 0, sizeof(cnt));//每次循环都要清空桶
    }
    for (int i = 0; i < m; i++) cout << ans[i] << '\n';
    return 0;
}

上面这道题中回滚莫队维护了插入操作,而让删除回滚;同样地,在某些题目中,也可以维护删除操作,让插入操作回滚。

[WC2022] 秃子酋长 (不插入莫队)

洛谷传送门
全网好像没几个 OJ 放了这题,都半年多了喂!
给定一个排列,静态询问区间 \([l, r]\) 内排序后相邻的数在原序列中的下标的差的绝对值之和。

题意有一点绕,用样例解释一下:
一个排列 \(5,4,2,3,1\),询问 \([2,5]\),区间内的数字是 \(4,2,3,1\),对应下标为 \(2,3,4,5\)。排序后数字变成 \(1,2,3,4\),对应的下标为 \(5,3,4,2\),则答案为 \(|5 - 3| + |3 - 4| + |4 - 2| = 5\)

解析:我们可以想到一个普通莫队的做法:直接维护左右端点,然后用 set 或是平衡树(谁脑子有问题会在赛场上不用 set 打平衡树)维护插入和删除的数字。修改答案时,直接从 set 中查询。这时时间复杂度多了一个平衡树的 \(\log n\),总复杂度就是 \(O(m\sqrt n \log n)\)。这个方法足以通过 \(n,m\leq 2\times 10^5\) 的数据,并拿到 \(50\) 分,在冬令营中得到铜牌。

对于 \(n,m\leq 5\times 10^5\) 的数据,我们要设计一个 \(O(m\sqrt n)\) 的算法,也就是把 set 这一层给去掉。(或者也可以使用压位 01 字典树优化,但过于黑科技,这里不展开讲述)

想一想:普通莫队的做法里,set 做了什么?插入,删除,求前驱,求后继。求前驱和后继可以用什么做到 \(O(1)\) 的时间复杂度?双向链表。由于在莫队处理询问的过程中插入数据难以实现,考虑使用回滚莫队。

具体地,我们维护一个带前驱和后继的双向链表,在读入数据的过程中把整张链表处理完毕,并处理出所有下标位置差的和。接下来进行回滚莫队就好了,操作和上一道题类似,只不过要在回滚和删除的同时维护链表。
代码如下:

#include <algorithm>
#include <cctype>
#include <cmath>
#include <cstdio>
#include <iostream>

#define get(x) (x - 1) / len

typedef long long ll;

using namespace std;

const int maxn = 1e6 + 10;

int n, m, len, a[maxn];
int pre[maxn], nxt[maxn], pos[maxn];
ll ans[maxn], idx;

struct node {
    int l, r, pos;
    inline bool operator<(const node &x) const {
        int i = get(l), j = get(x.l);
        if (i != j) return i < j;
        return x.r < r;
    }
} q[maxn];

inline int read() {
    int x = 0, f = 1;
    char ch = getchar();
    while (!isdigit(ch)) {
        if (ch == '-') f = -1;
        ch = getchar();
    }
    while (isdigit(ch)) x = x * 10 + ch - '0', ch = getchar();
    return x * f;
}

inline void write(long long x, char Ctr_sign) {
    static int buf[30];
    int top = 0;
    if (x < 0) x = -x, putchar('-');
    do {
        buf[top++] = x % 10;
        x /= 10;
    } while (x);
    while (top) putchar(buf[--top] + '0');
    putchar(Ctr_sign);
}

inline void init_list() {
    /*这个 lambda 表达式写得有点奇特w*/
    sort(pos + 1, pos + n + 1, [](int _, int __) { return a[_] < a[__]; });
    for (int i = 1; i <= n; i++) {
        pre[pos[i]] = pos[i - 1];
        nxt[pos[i]] = pos[i + 1];
    }
    pre[n + 1] = pos[n], nxt[0] = pos[1];
}

void undo(int x, ll &res) {  //回滚
    int pr = pre[x], nx = nxt[x];
    if (pr && nx <= n) res -= abs(pr - nx);
    if (pr) res += abs(pr - x);
    if (nx <= n) res += abs(x - nx);
    nxt[pr] = x, pre[nx] = x;
}

void del(int x, ll &res) {  //删除(其实回滚和删除是对偶的)
    int pr = pre[x], nx = nxt[x];
    if (pr && nx <= n) res += abs(pr - nx);
    if (pr) res -= abs(pr - x);
    if (nx <= n) res -= abs(x - nx);
    nxt[pr] = nx, pre[nx] = pr;
}

int main(void) {
    n = read(), m = read();
    len = sqrt(n);
    pos[0] = 0, pos[n + 1] = n + 1;
    for (int i = 1; i <= n; i++) a[i] = read(), pos[i] = i;
    init_list();
    for (int i = 1; i < n; i++) idx += abs(pos[i] - pos[i + 1]);
    for (int i = 0; i < m; i++) q[i].l = read(), q[i].r = read(), q[i].pos = i;
    sort(q, q + m);
    for (int i = 1, j = n, t = 1, k = 0; k < m; k++) {
        if (t < get(q[k].l) * len) {  //移动端点到查询位置
            t = get(q[k].l) * len;
            while (j < n) undo(++j, idx);
            while (i < t) del(i++, idx);
        }
        while (q[k].r < j) del(j--, idx);
        while (i < q[k].l) del(i++, idx);
        ans[q[k].pos] = idx;
        while (t < i) undo(--i, idx);
    }
    for (int i = 0; i < m; i++) write(ans[i], '\n');
    return 0;
}
posted @ 2022-07-29 22:05  秋泉こあい  阅读(51)  评论(0编辑  收藏  举报