浅谈 莫队 & 回滚莫队

本来是想在学完树上莫队和莫队二次离线后一起写的,但后面两个还没学会,就先把面前两个给写了吧。

莫队的简介:

莫队是一种简单的离线算法,可以相对高效的处理一些区间问题,时间复杂度一般为 mn

普通莫队:

我们以B3612为例子来介绍一下莫队。

首先,我们考虑暴力去计算区间和。
普通暴力肯定不用说,我们考虑将暴力稍微优化一下。
每次查询一个区间我们不一定要全部重新计算,因为区间之间可能会有重叠,而重叠的部分不需要再次计算,如图:

图中两端黑色的区间是查询的区间,而红色区间是两个区间的交集,不需要计算。

所以我们可以搞两个指针 lr,表示当前计算的区间位置。
每次更换区间时就移动 lr,并且在移动的过程中维护区间和,代码如下:

#include <iostream>

using namespace std;
using LL = long long;

const int kMaxN = 1e5 + 5;

LL a[kMaxN], n, m, l, r, x, y, s;

int main() {
  cin >> n;
  for (int i = 1; i <= n; i++) {  //  输入
    cin >> a[i];
  }
  for (cin >> m; m; m--) {
    cin >> x >> y;
    if (!l) {  //  特判第一次询问(其实不判断好像也行,但我怕出错。。。)
      for (int i = x; i <= y; i++) {
        s += a[i];
      }
      l = x, r = y;
    } else {
      for (; r < y; r++, s += a[r]) {  //  右指针没到当前查询区间右端点,向右边移动
      }
      for (; l > x; l--, s += a[l]) {  //  左指针没到当前查询区间左端点,向左移动
      }
      for (; r > y; s -= a[r], r--) {  //  右指针比当前查询区间右端点更右,向左边移动
      }
      for (; l < x; s -= a[l], l++) {  //  左指针比当前查询区间左端点更左,向右边移动
      }
    }
    cout << s << "\n";
  }
  return 0;
}

然后就。。。

我们考虑一下为什么会 TLE。
不难想到,如果两个区间,他们的距离很远,那么每次操作就又会变成 O(n) 的。

所以我们要把这些区间排成某种顺序,使得相邻的两个区间距离最小

然而如果要获得最短的代价就成了一个 NP-hard 问题(把一个区间抽象成一个 x 为左端点,y 为右端点的一个点就变成了哈密顿路径问题)。

考虑运用分块的思想。
我们把数列分成许多块,如下:

然后对每个区间按照左端点所在的块为第一关键字,右端点的位置为第二关键字排序,如图:

然后我们按照这个顺序进行上述的求解即可。

这里可能会问了,为什么排序后就不会 TLE 了呢?

首先,考虑左端点移动的复杂度。

如果左端点实在块内移动的,那么由于一个块的长度为 n,所以每次查询最多移动 n 次,有 m 次查询,所以总移动次数为 mn

如果是从一个块跨到了另一个块,那么每次查询要移动 n 次,但只有 n 个块,所以一共只会移动 nn 次。

然后考虑右端点的移动。

由于在每个块呢右端点是递增的,所以一个块内会移动 n 次,一共又有 n 个块,所以是 nn 的。

所以总时间复杂度是 mn+nn 的。

代码如下:

#include <bits/stdc++.h>

using namespace std;
using LL = long long;

const int kMaxN = 1e5 + 5;

LL a[kMaxN], ans[kMaxN], s, n, m, d, l, r;

struct L {
  LL l, r, id;
  bool operator<(const L &a) const {
    return l / d == a.l / d ? r < a.r : l / d < a.l / d;
  }
} q[kMaxN];

int main() {
  cin >> n;
  d = sqrt(n);
  for (int i = 1; i <= n; i++) {
    cin >> a[i];
  }
  cin >> m;
  for (int i = 1; i <= m; i++) {
    cin >> q[i].l >> q[i].r;
    q[i].id = i;  //  离线
  }
  sort(q + 1, q + m + 1);  //  最关键的排序
  for (int i = q[1].l; i <= q[1].r; i++) {
    s += a[i];
  }
  ans[q[1].id] = s;
  l = q[1].l, r = q[1].r;
  for (int i = 2; i <= m; i++) {
    for (; r < q[i].r; r++, s += a[r]) {  //  右指针没到当前查询区间右端点,向右边移动
    }
    for (; l > q[i].l; l--, s += a[l]) {  //  左指针没到当前查询区间左端点,向左移动
    }
    for (; r > q[i].r; s -= a[r], r--) {  //  右指针比当前查询区间右端点更右,向左边移动
    }
    for (; l < q[i].l; s -= a[l], l++) {  //  左指针比当前查询区间左端点更左,向右边移动
    }
    ans[q[i].id] = s;  //  因为离线下来了,所以要把答案顺序还原
  }
  for (int i = 1; i <= m; i++) {
    cout << ans[i] << "\n";
  }
  return 0;
}

跑的还挺快的。。。

PS:这里要注意一个细节,在移动端点时要先添加再删除,避免产生空区间。

再来看一道真正意义上的莫队板子。

题目

考虑用莫队进行求解。

首先,如果想利用莫队进行求解,肯定要能在添加元素时快速的维护当前的答案。
而求数字出现个数的平方和很明显可以快速维护(可以减去原来的贡献再加上现在的贡献或直接完全平方)。

那么,我们只要额外开一个数组记录目前区间内某一个数的出现个数就好了。

代码如下:

#include <bits/stdc++.h>

using namespace std;
using LL = long long;

const int kMaxN = 5e4 + 5;

LL a[kMaxN], ans[kMaxN], cnt[kMaxN], n, m, k, d, l, r;

struct L {
  LL l, r, id;
  bool operator< (const L &a) const {
    return l / d == a.l / d ? r < a.r : l / d < a.l / d;
  }
} q[kMaxN];

int main() {
  cin >> n >> m >> k;
  d = sqrt(n);
  for (int i = 1; i <= n; i++) {
    cin >> a[i];
  }
  for (int i = 1; i <= m; i++) {
    cin >> q[i].l >> q[i].r;
    q[i].id = i;
  }
  sort(q + 1, q + m + 1);
  for (int i = q[1].l; i <= q[1].r; i++) {
    cnt[a[i]]++;
  }
  for (int i = 1; i <= k; i++) {
    ans[q[1].id] += cnt[i] * cnt[i];
  }
  l = q[1].l, r = q[1].r;
  for (int i = 2; i <= m; i++) {
    int s = ans[q[i - 1].id];
    for (; l < q[i].l; s -= 2 * cnt[a[l]] - 1, cnt[a[l]]--, l++) {
    }
    for (; l > q[i].l; l--, s += 2 * cnt[a[l]] + 1, cnt[a[l]]++) {
    }
    for (; r < q[i].r; r++, s += 2 * cnt[a[r]] + 1, cnt[a[r]]++) {
    }
    for (; r > q[i].r; s -= 2 * cnt[a[r]] - 1, cnt[a[r]]--, r--) {
    }
    ans[q[i].id] = s;
  }
  for (int i = 1; i <= m; i++) {
    cout << ans[i] << "\n";
  }
  return 0;
}

回滚莫队:

模板题题面

题目让我们查询相同的两个数的位置差最大,那么我们只要维护每个值的最左位置和最右位置就可以了。

但是这样会有个问题,在左指针进行删除时我们需要把当前删除的数的最左位置给替换成第二小的位置,而我们需要快速找到第二小的位置。

在这里有两种方法:

  1. n 个 set 去维护每个数的所有出现位置,但这样时间复杂度会乘上一个 logn,在常数大时可能会超时

  2. 我们考虑如何避免删除。不难想到,我们可以直接让左指针回到当前块的末尾,然后重新添加字符。
    但如果每次计算末尾到右指针之间的数,那么也会超时。
    但是我们可以发现在删除时我们都只要知道块末尾到右指针之间的数,所以可以搞一个数组记录这个值,并且在右指针移动时不会对这个值造成影响,所以正常更新就好了。
    这就是莫队的 回滚操作

这是有人会问了:如果右指针要删除该怎么办呢?

由于左端点在一个块内的区间都按照右端点排序了,所以右端点只有在左端点从一个块到了另一个块时会删除。
但由于整个数列只被分成了 n 块,所以最多只会出现 n 次这种情况,直接暴力求解即可。

还有一点要注意:如果一个区间的左端点和端点在同一个个块内,那么就不能把块末尾到右端点的值进行计算,所以不能把其作为新块的第一个区间,要把它特殊处理后忽略掉。

还有就是数值比较大,如果用 map/set 容易 TLE,所以最好是进行离散化。

代码如下:

#include <bits/stdc++.h>

using namespace std;

const int kMaxN = 2e5 + 5;

struct Q {
  int l, r, id, p;
  bool operator<(const Q a) {
    return p == a.p ? r < a.r : p < a.p;
  }
} q[kMaxN];

int a[kMaxN], b[kMaxN], l[kMaxN], r[kMaxN], pl[kMaxN], ans[kMaxN], pres, s, lx, rx, tot, n, m;
//  l: 记录每个值最左的位置(当前情况), r:记录每个值最右的位置,pl:每个值最左的位置(备份)

int main() {
  cin >> n;
  for (int i = 1; i <= n; i++) {
    cin >> a[i];
    b[i] = a[i];
  }
  int d = sqrt(n);
  sort(b + 1, b + n + 1);
  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;
  }
  cin >> m;
  for (int i = 1; i <= m; i++) {
    cin >> q[i].l >> q[i].r;
    q[i].id = i, q[i].p = q[i].l / d + bool(q[i].l % d);
  }
  sort(q + 1, q + m + 1);
  for (int i = 1, pre = 0; i <= m; i++) {
    if (q[i].r / d + bool(q[i].r % d) == q[i].p) {
      for (int j = lx; j <= rx; j++) {
        l[a[j]] = r[a[j]] = pl[a[j]] = 0;
      }
      s = pres = 0;
      for (int j = q[i].l; j <= q[i].r; j++) {
        !l[a[j]] && (l[a[j]] = j);
        r[a[j]] = j;
      }
      for (int j = q[i].l; j <= q[i].r; j++) {
        s = max(s, r[a[j]] - l[a[j]]);
      }
      lx = q[i].l, rx = q[i].r;
    } else {
      if (q[i].p != pre) {
        s = pres = 0;
        for (int j = lx; j <= rx; j++) {
          l[a[j]] = r[a[j]] = pl[a[j]] = 0;
        }
        for (int j = q[i].p * d + 1; j <= q[i].r; j++) {
          r[a[j]] = j;
          !pl[a[j]] && (pl[a[j]] = j);
          !l[a[j]] && (l[a[j]] = j);
        }
        for (int j = q[i].p * d + 1; j <= q[i].r; j++) {
          s = max(s, r[a[j]] - l[a[j]]);
        }
        pres = s;
        for (int j = q[i].l; j <= q[i].p * d; j++) {
          (!l[a[j]] || l[a[j]] > j) && (l[a[j]] = j);
          r[a[j]] = max(r[a[j]], j);
        }
        for (int j = q[i].l; j <= q[i].r; j++) {
          s = max(s, r[a[j]] - l[a[j]]);
        }
        lx = q[i].l, rx = q[i].r;
      } else {
        for (; rx < q[i].r; rx++, !pl[a[rx]] && (pl[a[rx]] = rx), !l[a[rx]] && (l[a[rx]] = rx), r[a[rx]] = rx, s = max(s, rx - l[a[rx]]), pres = max(pres, rx -  pl[a[rx]])) {
        }
        if (q[i].l > lx) {
          s = pres;
          for (int j = lx; j <= q[i].p * d; j++) {
            l[a[j]] = pl[a[j]];
            (r[a[j]] == j) && (r[a[j]] = 0);
          }
          lx = q[i].p * d + 1;
        }
        for (; lx > q[i].l; lx--, r[a[lx]] = max(r[a[lx]], lx), l[a[lx]] = lx, s = max(s, r[a[lx]] - lx)) {
        }
      }
      pre = q[i].p;
    }
    ans[q[i].id] = s;
  }
  for (int i = 1; i <= m; i++) {
    cout << ans[i] << "\n";
  }
  return 0;
}
posted @   caoshurui  阅读(89)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· winform 绘制太阳,地球,月球 运作规律
· AI与.NET技术实操系列(五):向量存储与相似性搜索在 .NET 中的实现
· 超详细:普通电脑也行Windows部署deepseek R1训练数据并当服务器共享给他人
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理
· 上周热点回顾(3.3-3.9)
点击右上角即可分享
微信分享提示