前言
莫队算法是对于区间(高级的有树上莫队等)离线维护的暴力优化算法,主要是采用双指针和分块的思想优化了暴力
问题引入
给出一个长度为 n n n序列以及 m m m次查询,每次查询区间 [ l , r ] [l,r] [l,r]内数字种类的个数并输出
暴力做法显然是对于每次询问直接遍历这个区间统计种类数然后输出,时间复杂度妥妥的 O ( n m ) O(nm) O(nm),一定 T L E TLE TLE
莫队
暴力形式的转换
首先做一个事情,因为区间是不断输入的,那么我们维护两个指针代表区间。一开始指针维护的是空区间,在不断输入区间的过程中不断移动双指针,移动的过程中类似尺取那样动态维护区间内数的个数,这个过程并不难懂,简洁规范的代码如下:
int a[maxn], cnt[maxn];
int n, m, tot = 0;
inline void add(int pos) {
if (!cnt[a[pos]]) tot++;
++cnt[a[pos]];
}
inline void del(int pos) {
cnt[a[pos]]--;
if (!cnt[a[pos]]) tot--;
}
void solve() {
int l = 1, r = 0;
for (int i = 1, L, R; i <= m; i++) {
scanf("%d%d", &L, &R);
while (l < L) del(l++);
while (l > L) add(--l);
while (r < R) add(++r);
while (r > R) del(r--);
printf("%d\n", tot);
}
}
但是上述代码时间复杂度仍是 O ( n m ) O(nm) O(nm),只是将区间暴力统计变样为双指针移动,在较坏的情况下时间复杂度有可能还不如暴力统计
分块优化
将大小为 n n n的序列分为 n \sqrt{n} n个块,从 1 1 1到 n \sqrt{n} n编号,然后根据这个对查询的区间排序,那么显然要先保存区间然后离线处理
经典的排序标准是先按左端点升序,左端点相同的按右端点升序。之后再进行上述双指针大法,这样时间复杂度可以优化为 O ( n n ) O(n\sqrt{n}) O(nn)
时间复杂度的证明:
- 对于左指针来说,每个块中假设分布着 x i x_i xi个左端点,处理一个块的时间复杂度为 O ( x i n ) O(x_i\sqrt{n}) O(xin),最多 n n n个块需要处理,那么也就是操作所有块的总时间复杂度 O ( ∑ x i n ) = O ( n n ) O(\sum x_i\sqrt{n})=O(n\sqrt{n}) O(∑xin)=O(nn)。此外还需要考虑到不同块之间的跨越,显然这部分时间复杂度也为 O ( n n ) O(n\sqrt{n}) O(nn),对于左端点总的时间复杂度为 O ( n n ) O(n\sqrt{n}) O(nn)
- 对于右端点来说,由于左端点的同一块的区间右端点是有序的,单独考虑所有的块的累积右端点总共需要 O ( n ) O(n) O(n)的时间复杂度,还是考虑到在不同的块之间的跨越,那么时间复杂度最坏为 O ( n n ) O(n\sqrt{n}) O(nn)
总的来说,时间复杂度为 O ( n n ) O(n\sqrt{n}) O(nn)
int a[maxn], cnt[maxn], ans[maxn];
int n, m, tot = 0;
struct node {
int l, r, id, pos;
bool operator < (const node &p) const{
return pos == p.pos ? r < p.r : pos < p.pos;
}
}q[maxn];
void init() {
int sz = sqrt(n);
for (int i = 1; i <= m; i++) {
cin >> q[i].l >> q[i].r;
q[i].id = i;
q[i].pos = (q[i].l - 1) / sz + 1; //向上取整
}
}
其他优化
-
奇偶性排序
玄学优化,在奇偶数不同的块跳时顺便将另外一块跳完,理论运行时间减半
bool operator < (const node &p) const{ return (pos ^ p.pos) ? pos < p.pos : ((pos & 1) ? r < p.r : r > p.r); }
-
移动指针的常数压缩
将 s o l v e solve solve函数写成这个形式也能优化常数,但是容易错误
void solve() { int l = 1, r = 0; for(int i = 1; i <= m; ++i) { int ql = p[i].l, qr = p[i].r; while(l < ql) tot -= !--cnt[a[l++]]; while(l > ql) tot += !cnt[a[--l]]++; while(r < qr) tot += !cnt[a[++r]]++; while(r > qr) tot -= !--cnt[a[r--]]; ans[p[i].id] = tot; } }
模板
int a[maxn], cnt[maxn], ans[maxn];
int n, m, tot = 0;
struct node {
int l, r, id, pos;
bool operator < (const node &p) const{
return pos == p.pos ? r < p.r : pos < p.pos;
}
}q[maxn];
void init() {
int sz = sqrt(n);
for (int i = 1; i <= m; i++) {
cin >> q[i].l >> q[i].r;
q[i].id = i;
q[i].pos = (q[i].l - 1) / sz + 1; //向上取整
}
}
inline void add(int pos) {
if (!cnt[a[pos]]) tot++;
++cnt[a[pos]];
}
inline void del(int pos) {
cnt[a[pos]]--;
if (!cnt[a[pos]]) tot--;
}
void solve() {
int l = 1, r = 0;
for (int i = 1; i <= m; i++) {
int L = q[i].l, R = q[i].r;
while (l < L) del(l++);
while (l > L) add(--l);
while (r < R) add(++r);
while (r > R) del(r--);
ans[q[i].id] = tot;
}
for(int i = 1;i <= m; i++) cout<<ans[i]<<"\n";
}
带修改的莫队
莫队本是不支持修改的离线算法,但是可以增加排序关键字使其变成支持单点修改的离线算法(复杂度很玄学)
问题引入
给出一个长度为 n n n序列以及 m m m次查询,每次查询输入 Q l r Q~~l~~r Q l r需要输出区间 [ l , r ] [l,r] [l,r]内数字种类的个数,输入 R p o s x R~~pos~~x R pos x需要将原序列 p o s pos pos位置的数改为 x x x
分析
首先对每次修改记录修改的时间(或者说是第几次修改),那么在分块过程中,我们需要记录的不只是左右边界,还有本次查询之前最近的一次修改的时间。之所以记录这个时间,查询时,如果当前修改数比询问的修改数少就把没修改的进行修改,反之回退。
因此,排序的过程为:以左端点所在块为第一关键字,以右端点所在块为第二关键字,以时间为第三关键字进行排序。
bool operator<(const node &p) const {
return block[l] == block[p.l] ? (block[r] == block[p.r] ? t < p.t : block[r] < block[p.r]) : block[l] < block[p.l];
}
//排序使用位运算减小常数
bool operator<(const node &p) const {
return (belong[a.l] ^ belong[b.l]) ? belong[a.l] < belong[b.l] : ((belong[a.r] ^ belong[b.r]) ? belong[a.r] < belong[b.r] : a.time < b.time);
}
需要注意的是,修改分为两部分:
- 若修改的位置只有在当前区间内,才需要更新答案
- 每个时间前后被修改位置只可能有两种取值,因此直接交换即完成修改
void modify(int now, int i) {
if (p[now].pos >= q[i].l && p[now].pos <= q[i].r) {
if (--cnt[a[p[now].pos]] == 0) tot--;
if (++cnt[p[now].val] == 1) tot++;
}
swap(p[now].val, a[p[now].pos]); //每个时间前后被修改位置只可能有两种取值因此直接交换即完成修改
}
值得注意的一点是,本问题需要分成块的大小为 n 2 3 n^{\frac{2}{3}} n32复杂度优于 n \sqrt{n} n。上述问题的代码大体如下:
const int maxn = 1e6 + 10;
int a[maxn], cnt[maxn], ans[maxn], block[maxn];
struct node {
int l, r, id, t;
bool operator<(const node &p) const {
return block[l] == block[p.l] ? (block[r] == block[p.r] ? t < p.t : block[r] < block[p.r]) : block[l] < block[p.l];
}
} q[maxn];
struct Node {
int pos, val;
} p[maxn];
int cntq, cntp, n, m, tot, sz, up;
void add(int pos) {
if (++cnt[a[pos]] == 1) tot++;
}
void del(int pos) {
if (--cnt[a[pos]] == 0) tot--;
}
void modify(int now, int i) {
if (p[now].pos >= q[i].l && p[now].pos <= q[i].r) {
if (--cnt[a[p[now].pos]] == 0) tot--;
if (++cnt[p[now].val] == 1) tot++;
}
swap(p[now].val, a[p[now].pos]); //每个时间前后被修改位置只可能有两种取值
}
void solve() {
int l = 1, r = 0, now = 0;
for (int i = 1; i <= cntq; i++) {
int ql = q[i].l, qr = q[i].r;
while (l < ql) del(l++);
while (l > ql) add(--l);
while (r < qr) add(++r);
while (r > qr) del(r--);
while (now < q[i].t) modify(++now, i); //少了就多修改
while (now > q[i].t) modify(now--, i); //多了就少修改
ans[q[i].id] = tot;
}
for (int i = 1; i <= cntq; i++) cout << ans[i] << "\n";
}
int main() {
//freopen("in.txt","r",stdin);
//freopen("out.txt","w",stdout);
ios_base::sync_with_stdio(0), cin.tie(0), cout.tie(0);
char op;
cin >> n >> m;
sz = pow(n,2.0/3);
for (int i = 1; i <= n; i++) {
cin >> a[i];
block[i] = (i - 1) / sz + 1; //区间分块记录每个位置的块
}
while (m--) {
cin >> op;
if (op == 'Q') {
++cntq;
cin >> q[cntq].l >> q[cntq].r;
q[cntq].id = cntq, q[cntq].t = cntp;
} else {
++cntp; //记录修改的时间
cin >> p[++cntp].pos >> p[cntp].val;
}
}
sort(q + 1, q + 1 + cntq);
solve();
return 0;
}