优雅暴力算法——莫队

众所周知,莫队是莫涛大神发明的一种玄学优雅暴力算法,鉴定为:区间查询专业对口,拓展应用十分毒瘤。

莫队的模板特别方便记忆,其实只要领悟了莫队的核心思想,可谓是非常简单。

\(\texttt{0x00:}\) 前置芝士

  1. 分块基础思想
  2. \(\operatorname{sort}\) 排序和自定义 \(\operatorname{cmp}\)

\(\texttt{0x01:}\) 引入

来看一道例题:P1972 [SDOI2009] HH的项链

题目大意:给定一个长度为 \(n\) 的序列 \(a\),有 \(m\) 询问,每次询问区间 \([l,r]\) 中有多少个不同的数值。

首先考虑朴素做法:开一个桶记录区间内每一个数出现的次数,扫一遍 \([l,r]\) 暴力统计,最后扫描一遍桶,时间复杂度最坏为 \(O(m(n + s))\)\(s\) 为值域)。就算我们离散化一下也大概是 \(O(nm)\) 的复杂度。

如何优化?

优化1:

在统计时如果当前数出现次数为 \(0\),则答案加一,否则不管,这样可以优化一点时间,但还远远不够。

优化2:

不难发现,如果每次都暴力扫描每个区间,会浪费浪费掉很多有用的信息。

就比如:首先询问区间 \([l,r]\),再询问区间 \([l + 1,r]\),暴力做法就要把它们的交集扫描两遍,非常低效。

其实我们可以将前面的查询作为基础来求出后面的询问。

我们用两个指针 \(j\)\(i\) 分别表示查询区间的左右端点,如果 \(i\) 指针小于要查询区间的右端点,就将其右移一位,并将 \(a[i]\) 加到桶中去;若大于查询区间的右端点,就将其左移一位,并将 \(a[i]\) 从桶中删去。\(j\) 指针也是同理。最终两个指针都会和当前查询区间重合,答案即为所求。

就比如上面这个例子,只需要在桶中将 \(a[j]\) 减去并将左指针 \(j\) 右移一位就行了。

这样一看似乎能优化很多,但如果询问的区间为 \([1,2], [n - 1,n], [2,3], [n - 2,n - 1]\cdots\) 像这样一头一尾地移动,那么两个指针最坏情况下还是会移动 \(O(nm)\) 次。

那么又该怎么优化呢?

考虑将所有操作离线下来,按左端点从小到大的顺序将询问排序,这样左指针的移动次数就降为 \(O(n)\),但是右端点依然是非单调的,复杂度仍旧很高。

还能优化吗?

很遗憾,到目前为止,基础的方法已经无法再进行任何的优化了。但这个时候,莫队的出现,让无数人看到了希望的曙光。

\(\texttt{0x02:}\) 莫队基本思想

莫队说:“我们先对整个序列进行分块,按照左端点所在块的编号从小到大为第一关键字排序,再按照右端点从小到大为第二关键字排序。”然后他以 \(\sqrt n\) 为块长,极大地优化了时间复杂度。

这样做为什么是对的?

我们来分析一下这样做的时间复杂度:

  1. \(\operatorname{sort}\) 排序一遍,时间复杂度 \(O(n\log n)\)
  2. 设每个块中有 \(c_i\) 个左端点。在块中,由于左端点不单调,所以最坏情况下要移动 \(O(c_i\sqrt n)\) 次。在块外,由于左端点单调,所以最多跨越 \(O(\sqrt n)\) 个块。综上所述,总共会移动 \(O(\sqrt n \sum\limits _{i = 1} ^ {\sqrt n} c_i) = O(m\sqrt n)\) 次。
  3. 每个块中有 \(c_i\) 个左端点。由于左端点同块的右端点单调,所以最坏情况下会移动 \(O(n)\) 次(相当于移动完整个序列),每个块都是如此,所以右端点总共会移动 \(O(n\sqrt n)\) 次。

综上所述,总时间复杂度为 \(O(n\log n) + O(m\sqrt n) + O(n\sqrt n) = O(m\sqrt n) + O(n\sqrt n)\)

复杂度降了一个根号!

\(\texttt{Code:}\)

#include <iostream>
#include <cmath>
#include <algorithm>
using namespace std;
const int N = 1000010;
int n, m;
int a[N], ans[N];
int cnt[N];
int t;
struct node{
    int id, l, r;
}q[N];
inline int read() {
	int x = 0;
	char ch = getchar();
	while(ch < '0' || ch > '9') ch = getchar();
	while(ch >= '0' && ch <= '9') {x = (x << 3) + (x << 1) + ch - 48; ch = getchar();}
	return x;
}
inline int get(int x) {return x / t;}
inline bool cmp(node x, node y) {
    if(get(x.l) == get(y.l)) {
    	if(get(x.l) & 1) return x.r < y.r;
    	return x.r > y.r;
	}
	return x.l < y.l;
}
int main() {
    n = read();
    for(int i = 1; i <= n; i++) a[i] = read();
    m = read();
    t = (int)sqrt(n);
    int l, r;
    for(int i = 1; i <= m; i++) {
        l = read(), r = read();
        q[i] = {i, l, r};
    }
    sort(q + 1, q + m + 1, cmp);
    for(int i = 0, j = 1, k = 1, res = 0; k <= m; k++) {
        l = q[k].l, r = q[k].r;
        int id = q[k].id;
        while(i < r) res += !cnt[a[++i]]++;
        while(i > r) res -= !--cnt[a[i--]];
        while(j < l) res -= !--cnt[a[j++]];
        while(j > l) res += !cnt[a[--j]]++;
        ans[id] = res;
    }
    for(int i = 1; i <= m; i++) printf("%d\n", ans[i]);
    return 0;
}

然后你会发现过不了这道题。

实际上洛谷上的这道题卡了莫队,但是运用上地址连续还是可以卡过的,实在不行还是去 ACwing 上提交吧。

注意:若 \(n,m\) 不同级,则最优块长应该为 \(\frac{n}{\sqrt m}\)

\(\texttt{0x03:}\) 优化

1. \(O_2\)

\(O_2\) 对莫队的优化巨大,大概能优化 \(2\sim 3\) 倍。

2. 奇偶排序

这是一个特别玄学的优化。

它的主要原理便是右指针跳完奇数块往回跳时在同一个方向能顺路把偶数块跳完,然后跳完这个偶数块又能顺带把下一个奇数块跳完。理论上主算法运行时间减半,实际情况有所偏差。(不过能优化得很爽就对了)

也就是说,对于左端点在同一奇数块的区间,右端点按升序排列,反之降序。这个东西也是看着没用,但实际效果显著。

inline bool cmp(node x, node y) {
    if(get(x.l) == get(y.l)) {
    	if(get(x.l) & 1) return x.r < y.r;
    	return x.r > y.r;
	}
	return x.l < y.l;
}
  1. 快读快写

\(\texttt{0x04:}\) 莫队进阶应用:带修莫队

如果不光有查询操作,还有修改操作,该怎么办?

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

单点修改加区间查询,由于莫队是离线算法,所以遇到强制在线就直接去世了

不过这道题并没有强制在线,我们也可以把所有的操作全部离线下来排序。

但是修改操作怎么办?

因为修改是有顺序的,每次修改只会会对它之后的查询操作有变动,而对它之前的查询不影响,所以我们在普通莫队的基础上再加上一个变量:时间。

令当前时间为 \(t\),若当前时间小于所处理的查询操作的时间,就将时间往后推进,增添几个修改,反之时间回溯,减少几个修改,直到当前的所有变量与所查询区间重合。

通俗地讲,就是再弄一指针,在修改操作上跳来跳去,如果当前修改多了就改回来,改少了就改过去,直到次数恰当为止。

排序也需要加上时间这一关键字。

注意:带修莫队的块长不是 \(\sqrt n\)了,而是 \(\sqrt [4]{n^3t}\)\(t\) 为修改操作的个数),
而且不能使用奇偶排序了

\(\texttt{Code:}\)

#include <algorithm> 
#include <iostream>
#include <cmath>
using namespace std;
const int N = 140010;
int n, m;
int a[N];
int ans[N], cnt[N << 3];
struct node{
	int id, l, r, t;
}q[N]; //查询操作
int ttq;
struct Node{
	int p, c;
}cha[N]; //修改操作
int ttc;
int len;
inline int read() {
	register int x = 0;
	register char ch = getchar();
	while(ch < '0' || ch > '9') ch = getchar();
	while(ch >= '0' && ch <= '9') {x = (x << 3) + (x << 1) + ch - '0'; ch = getchar();}
	return x;
}
inline int get(int x) {return x / len;}
bool cmp(node x, node y) {
	if(get(x.l) != get(y.l)) return x.l < y.l;
	if(get(x.r) != get(y.r)) return x.r < y.r;
	return x.t < y.t; 
}
inline void add(int x, int &res) {
	cnt[x]++;
	if(cnt[x] == 1) res++;
}
inline void del(int x, int &res) {
	cnt[x]--;
	if(!cnt[x]) res--;
}
int main() {
	n = read(), m = read();
	for(int i = 1; i <= n; i++) a[i] = read();
	char op[2];
	int x, y;
	for(int i = 1; i <= m; i++) {
		scanf("%s", op);
		x = read(), y = read();
		if(*op == 'Q') {
			++ttq;
			q[ttq] = {ttq, x, y, ttc};
		}
		else cha[++ttc] = {x, y};
	}
	len = cbrt((double)n * n * ttc / m + 1);
	if(ttc == 0) len = sqrt((double)n * n / m);
	sort(q + 1, q + ttq + 1, cmp);
	int id, l, r, tg;
	for(int i = 0, j = 1, t = 0, k = 1, res = 0; k <= m; k++) {
		id = q[k].id, l = q[k].l, r = q[k].r, tg = 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 < tg) {
			++t;
			if(cha[t].p >= j && cha[t].p <= i) { //如果修改的位置处于这个区间内,修改才有效
				del(a[cha[t].p], res);
				add(cha[t].c, res);
			}
			swap(a[cha[t].p], cha[t].c); //将修改加入
		}
		while(t > tg) {
			if(cha[t].p >= j && cha[t].p <= i) {
				del(a[cha[t].p], res);
				add(cha[t].c, res);
			}
			swap(a[cha[t].p], cha[t].c);
			--t;
		}
		ans[id] = res;
	}
	for(int i = 1; i <= ttq; i++) printf("%d\n", ans[i]);
	return 0;
}

\(\texttt{0x05:}\) 莫队进阶应用:树上莫队

posted @ 2024-07-23 14:38  Brilliant11001  阅读(15)  评论(0编辑  收藏  举报