莫队算法学习总结

本文介绍了分块思想在数组处理中的应用,详细阐述了块状数组的构建、区间修改与查询操作,以及如何通过预处理优化效率。随后,文章深入探讨了莫队算法,通过移动双指针处理区间查询,实现线性时间复杂度的优化。文中还提到了莫队算法的进一步优化——分块和排序,以及如何通过结构体排序策略提升性能。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

前置知识:分块

分块是一种思想,把一个整体划分为若干个小块,对整块整体处理,零散块单独处理。本文主要介绍块状数组——利用分块思想处理区间问题的一种数据结构。

块状数组把一个长度为 n 的数组划分为 a 块,每块长度为 n/a 。对于一次区间操作,对区间內部的整块进行整体的操作,对区间边缘的零散块单独暴力处理。(所以分块被称为“优雅的暴力”)

这里,块数既不能太少也不能太多。如果太少,区间中整块的数量会很少,我们要花费大量时间处理零散块;如果太多,又会让块的长度太短,失去整体处理的意义。一般来说,我们取块数为 根号n,这样在最坏情况下,我们要处理接近 根号n个整块,还要对长度为 2*根号n 的零散块单独处理,总时间复杂度为 O(根号n)。这是一种根号算法。

显然,分块的时间复杂度比不上线段树和树状数组这些对数级算法。但由此换来的,是更高的灵活性。与线段树不同,块状数组并不要求所维护信息满足结合律,也不需要一层层地传递标记。但它们又有相似之处,线段树是一棵高度约为 的树,而块状数组则可被看成一棵高度为3的树:
在这里插入图片描述
只不过,块状数组最顶层的信息不用维护。

预处理
具体地使用块状数组,我们要先划定出每个块所占据的范围:

int sq = sqrt(n);
for (int i = 1; i <= sq; ++i)
{
    st[i] = n / sq * (i - 1) + 1; // st[i]表示i号块的第一个元素的下标
    ed[i] = n / sq * i; // ed[i]表示i号块的最后一个元素的下标
}

但是,数组的长度并不一定是一个完全平方数,所以这样下来很可能会漏掉一小块,我们把它们纳入最后一块中:

ed[sq] = n;

正如上面所述,sq就是每个块有几个数,然后我们根据这个算出总共有几块,举个例子,假如10个数字,那么sqrt处理后,得到的结果省略小数点后面就是3,但是如果每块3个,那么会多出来一个数字,我们在算块数的时候,因为根号算出的,所以块数是接近3的,但是我们需要4块,因为多出了一个数,我们这时候可以直接向上取整,假如有9个,则每块有3个,共3块,假如有十个,则每块还是三个,但是共四块,处理方法如下

ceil((double)n / size);

然后,我们为每个元素确定它所归属的块:

for (int i = 1; i <= sq; ++i)
    for (int j = st[i]; j <= ed[i]; ++j)
        bel[j] = i; // 表示j号元素归属于i块

最后,如果必要,我们再预处理每个块的大小:

for (int i = 1; i <= sq; ++i)
    size[i] = ed[i] - st[i] + 1;
int read() {
    int res = 0;
    char c = getchar();
    while(!isdigit(c)) c = getchar();
    while(isdigit(c)) res = (res << 1) + (res << 3) + c - 48, c = getchar();
    return res;
}
for (int i = 1; i <= n; ++i)
    A[i] = read();//快读
for (int i = 1; i <= sq; ++i)
    for (int j = st[i]; j <= ed[i]; ++j)
        sum[i] += A[j];//保存每一块的和
        

区间修改
首先是区间修改,当x与y在同一块内时,直接暴力修改原数组和sum数组:

if (bel[x] == bel[y])
    for (int i = x; i <= y; ++i)
    {
        A[i] += k;
        sum[bel[i]] += k;
    }

否则,先暴力修改左右两边的零散区间:

for (int i = x; i <= ed[bel[x]]; ++i)
{
    A[i] += k;
    sum[bel[i]] += k;
}
for (int i = st[bel[y]]; i <= y; ++i)
{
    A[i] += k;
    sum[bel[i]] += k;
}

然后对中间的整块打上标记:

for (int i = bel[x] + 1; i < bel[y]; ++i)
    mark[i] += k;

暴力修改左右两边的零散区间
ed[bel[x]]表示x所在块的最后一个数字下标
st[bel[y]]表示y所在块的第一个数字下标
这样就把x,y所在的块通过每个数都加上k,并且块的总和也加上,数字加的k的总和,就是你每个数加了几个k,那块的总和也就加几个k,
然后中间打标记
bel[x]表示x所在块的下一个块,i < bel[y]表示y所在块的前一个块,
mark[i] += k;就表示把除了x,y所在块的所有块都标记这块所有数都加了k,到时候想要加回来就用k也就是mark[i]*(块内的元素个数)

区间查询不解释了
同样地,如果左右两边在同一块,直接暴力计算区间和。

if (bel[x] == bel[y])
for (int i = x; i <= y; ++i)
s += A[i] + mark[bel[i]]; // 注意要加上标记
否则,暴力计算零碎块:

for (int i = x; i <= ed[bel[x]]; ++i)
s += A[i] + mark[bel[i]];
for (int i = st[bel[y]]; i <= y; ++i)
s += A[i] + mark[bel[i]];
再处理整块:

for (int i = bel[x] + 1; i < bel[y]; ++i)
s += sum[i] + mark[i] * size[i]; // 注意标记要乘上块长
那么为什么在区间不在一个块内的就单独处理前后,就要单独的每个数都加呢?,因为不在一个块内,假如有1到9个数,分成三个块,假如是2,8,那么你就原本1到3是一个块,但是现在不包括1,所以你不能用sum进行区间修改,因为sum代表的是整个块,然后7,9是一个块,这里只有8,所以同理,那个查询更明确了,前后都不能用sum,中间可以,因为中间肯定是一个完整的块

再啰嗦一点,只有你在当时要求的范围包括l完完整整的那几个块区间,
那几个块区间才可以用sum,预先处理的结果来计算并更新,因为sum里面
存储的都是一整块,并不是分开的

正式知识:莫队算法

这里不讲理论,直接讲题和代码,把代码读懂,然后就可以发现不变和变的地方,将不变的地方抽象的理解每一步骤的目的,具体的思维,写在变的里面
看一下这道题SP3267 DQUERY - D-query

在这里插入图片描述
在这里插入图片描述
题目到手,我们开始分析本题的算法。这题最简单做法无非暴力——用一个cnt数组记录每个数值出现的次数,再暴力枚举l到r统计次数,最后再扫一遍cnt数组,统计cnt不为零的数值个数,输出答案即可。设最大数值为s,那么这样做的复杂度为O(m(n+s))∽O(n2),对于本题实在跑不起。

我们可以尝试优化一下:
优化1:每次枚举到一个数值num,增加出现次数时判断一下cntnum是否为0,如果为0,则这个数值之前没有出现过,现在出现了,数值数当然要+1。反之在从区间中删除num后也判断一下cntnum是否为0,如果为0数值总数-1。这样我们优化掉了一个O(ms),但还是跑不起。
优化2:我们弄两个指针 l 、r ,每次询问不直接枚举,而是移动 l 、r 指针到询问的区间,直到[l,r]与询问区间重合。在统计答案时,我们也只在两个指针处加减cnt,然后我们就可以用优化1中的方法快速地统计答案啦

优化2就是莫队算法的铺垫,莫队算法的基本思想就是用两个指针不断的移动
因为有n次区间询问,每次就从开始的位置一个一个的移动,就是++的移动
,移动一次,就处理当前的数,比如,我们要查2,5区间,现在两个指针在2,3区间,那右指针就要移动从3移动到5,所以一步一步处理,3移动到4,4这个位置上的数,更新一下aa[4]这个数的个数,用桶去记录cnt[aa[4]]++。为什么要更新,因为你区间把这个数字包括进去了,等从4走到5的时候,看看aa[5],并统计一下更新cnt数组,然后去看看这个数字是不是新出现的,如果是就更新当前结果now++,如果这个数组在挪动之前就统计了出现了一次,那么就不++,这种两个指针越来越大的是好处理的,有些区别的是范围在缩小的。比如查2,5但是目前在2,7,那你右指针是不是要从7挪动到5,所以7先挪动到6,处理之前先看看7这个位置上的数字是不是变成0了,变成0了,就说明这个数字出现次数为0了。就去改当前的结果now–。大概讲了一下思路,说核心代码

int aa[maxn], cnt[maxn], l = 1, r = 0, now = 0; //每个位置的数值、每个数值的计数器、左指针、右指针、当前统计结果(总数)
void add(int pos) {//添加一个数
    if(!cnt[aa[pos]]) ++now;//在区间中新出现,总数要+1
    ++cnt[aa[pos]];
}
void del(int pos) {//删除一个数
    --cnt[aa[pos]];
    if(!cnt[aa[pos]]) --now;//在区间中不再出现,总数要-1
}
void work() {//优化2主过程
    for(int i = 1; i <= q; ++i) {//对于每次询问
        int ql, qr;
        scanf("%d%d", &ql, &qr);//输入询问的区间
        while(l < ql) del(l++);//如左指针在查询区间左方,左指针向右移直到与查询区间左端点重合
        while(l > ql) add(--l);//如左指针在查询区间左端点右方,左指针左移
        while(r < qr) add(++r);//右指针在查询区间右端点左方,右指针右移
        while(r > qr) del(r--);//否则左移
        printf("%d\n", now);//输出统计结果
    }
}

执行步骤见下面链接的这篇博客的图片
莫队
然后你会发现,哪里是变得,add和del函数是变得,其他不变,但是到这核心思想还没有讲完,莫队为什么可以离线处理,因为他讲每一次的结果保存了下来,并且给这个结果加上一个id,也就是序号,让我们知道他第几次问的问题的结果是什么,那为什么加序号,如果优化2遇到这样的区间,如下图
在这里插入图片描述
此时l、r指针在整个序列中移来移去,从头到尾,又从尾到头。我们发现左右指针最坏情况下均移动了O(nm)次,O(1)更新答案,总时间复杂度仍然是O(nm),在最坏情况下跑得比慢的一批的优化1还慢。尽管如此,我们还是可以继续优化。怎么优化,这就跟序号有关了
莫队算法优化的核心是分块和排序。我们将大小为n的序列分为√n 个块,从1到√n 编号,然后根据这个对查询区间进行排序。一种方法是把查询区间按照左端点所在块的序号排个序,如果左端点所在块相同,再按右端点排序。排完序后我们再进行左右指针跳来跳去的操作,虽然看似没多大用,但带来的优化实际上极大。排序根据区间左右端点排,而保证他们的顺序则是上面的序号来保证的,如何带序号,结构体呗

在这里插入图片描述
结构体排序策略如下

int cmp(query a, query b) {
    return belong[a.l] == belong[b.l] ? a.r < b.r : belong[a.l] < belong[b.l];
}

然后有一部分代码可以通过更换减少时间复杂度,下面有例题讲解并标识出减少时间复杂度的部分

#include <cstdio>
#include <cstring>
#include <cmath>
#include <algorithm>
using namespace std;

#define maxn 1010000
#define maxb 1010
int aa[maxn], cnt[maxn], belong[maxn];
int n, m, size, bnum, now, ans[maxn];
struct query {
	int l, r, id;
} q[maxn];

int cmp(query a, query b) {
//相对于上面的排序策略,这个快很多
	return (belong[a.l] ^ belong[b.l]) ? belong[a.l] < belong[b.l] : ((belong[a.l] & 1) ? a.r < b.r : a.r > b.r);
}
#define isdigit(x) ((x) >= '0' && (x) <= '9')
int read() {
//优化IO输入,快读,上面讲过
	int res = 0;
	char c = getchar();
	while(!isdigit(c)) c = getchar();
	while(isdigit(c)) res = (res << 1) + (res << 3) + c - 48, c = getchar();
	return res;
}
void printi(int x) {
//优化IO输出
	if(x / 10) printi(x / 10);
	putchar(x % 10 + '0');
}

int main() {
	scanf("%d", &n);
	size = sqrt(n);
	bnum = ceil((double)n / size);
	for(int i = 1; i <= bnum; ++i) 
		for(int j = (i - 1) * size + 1; j <= i * size; ++j) {
			belong[j] = i;
		}
	for(int i = 1; i <= n; ++i) aa[i] = read(); 
	m = read();
	for(int i = 1; i <= m; ++i) {
		q[i].l = read(), q[i].r = read();
		q[i].id = i;
	}
	sort(q + 1, q + m + 1, cmp);
	int l = 1, r = 0;
	for(int i = 1; i <= m; ++i) {
		int ql = q[i].l, qr = q[i].r;
		while(l < ql) now -= !--cnt[aa[l++]];//1
		while(l > ql) now += !cnt[aa[--l]]++;//2
		while(r < qr) now += !cnt[aa[++r]]++;//3
		while(r > qr) now -= !--cnt[aa[r--]];//4
		ans[q[i].id] = now;
	}
	for(int i = 1; i <= m; ++i) printi(ans[i]), putchar('\n');
	return 0;
}

可修改的地方标记在了1,2,3,4,可以仍然保持,del和add函数的写法,更明确

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值