数据结构学习笔记 - 分块和莫队

分块

前言

我们可能已经了解了树状数组是基于二进制划分与倍增思想,线段树基于分治思想。它们之所以能够高效地在一个序列上执行指令并统计信息,就是因为它们把序列中的元素聚合成大大小小的“段”,花费额外的代价对这些“段”进行维护,从而使得每个区间的信息可以快速由几个已有的“段”结合而成。

当然,树状数组与线段树也有其缺点。比如在维护较为复杂的信息(尤其是不满足区间可加,可减性的信息)时显得吃力,代码实现也不是那么简单、直观,需要深入理解并注意许多细节。在此,我们将介绍分块算法。

基本概念

基本思想是通过适当的划分,预处理一部分信息并保存下来,用空间换取时间,达到时空平衡。事实上,分块更接近于“朴素”,效率往往比不上树状数组与线段树,但是它更加通用、容易实现。

下面通过一个例题来探讨一下分块算法

题目链接

题目描述

给定长度为 N(N≤10^5) 的数列A,然后输入 Q(Q≤10^5) 行操作指令。

第一类指令形如"1 l r d",表示把数列中第 l~r 个数都加 d。

第二类指令形如"2 l r",表示询问数列中第 l~r 个数的和。

我们用了树状数组和线段树在 O((N + Q)logN) 的时间内解决过该问题。现在我们用分块来求解。

把数列A分成若干个长度不超过\sqrt{N}的段,其中第 i 段左端点为 (i-1)\sqrt{N}+1,右端点为 min(i\sqrt{N},N) 。另外,预处理出数组 sum,其中 sum[i] 表示第 i 段的区间和。设 add[i] 表示第 i 段的“增量标记”,起初 add[i] = 0。

对于指令“1 l r d”:

1. 若 l 与 r 同时处于第 i 段内,则直接把 A[l],A[l+1],...,A[r] 都加 d,同时令 sum[i]+=d*(r-l+1)。

2. 否则,设 l 处于第 p 段,r 处于第 q 段。

        (1) 对于 i∈[p+1,q-1],令 add[i]+=d。

        (2) 对于开头、结尾不足一整段的两部分,按照与第 1 种情况相同的方法朴素地更新。

 对于指令"2 l r":

1. 若 l 与 r 同时处于第 i 段内,则(A[l] + A[l+1] +...+ A[r])+ (r - l + 1) * add[i] 就是答案。

2. 否则,设 l 处于第 p 段,r 处于第 q 段,初始化 ans=0。

        (1) 对于 i∈[p+1,q-1],令 ans += sum[i] + add[i] * len[i],其中 len[i] 表示第 i 段的长度

        (2) 对于开头、结尾不足一整段的两部分,按照与第1种情况相同的方法朴素地累加。

这种分块算法对于整段的修改用标记 add 记录,对于不足整段的修改采取朴素算法。因为段数和段长都是O(\sqrt{N}),所以整个算法的时间复杂度为 O((N+Q)*\sqrt{N})。

大部分常见的分块思想都可以用“大段维护、局部朴素”来形容。

代码示例

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

const int maxn=110000;
long long a[maxn],sum[maxn],add[maxn];
int L[maxn],R[maxn];
int pos[maxn];
int n,m,t;

void change(int l,int r,long long d){
	int p=pos[l],q=pos[r];
	if(p==q){
		for(int i=l;i<=r;i++) a[i]+=d;
		sum[p]+=d*(r-l+1);
	}
	else{
		for(int i=p+1;i<=q-1;i++) add[i]+=d;
		for(int i=l;i<=R[p];i++) a[i]+=d;
		sum[p]+=d*(R[p]-l+1);
		for(int i=L[q];i<=r;i++) a[i]+=d;
		sum[q]+=d*(r-L[q]+1);
	}
}

long long ask(int l,int r){
	int p=pos[l],q=pos[r];
	long long ans=0;
	if(p==q){
		for(int i=l;i<=r;i++) ans+=a[i];
		ans+=add[p]*(r-l+1);
	}
	else{
		for(int i=p+1;i<=q-1;i++) ans+=sum[i]+add[i]*(R[i]-L[i]+1);
		for(int i=l;i<=R[p];i++) ans+=a[i];
		ans+=add[p]*(R[p]-l+1);
		for(int i=L[q];i<=r;i++) ans+=a[i];
		ans+=add[q]*(r-L[q]+1);
	}
	return ans;
}

int main(){
	cin>>n>>m;
	for(int i=1;i<=n;i++) scanf("%lld",&a[i]);
	t=sqrt(n);
	for(int i=1;i<=t;i++){
		L[i]=(i-1)*sqrt(n)+1;
		R[i]=i*sqrt(n);
	}
	if(R[t]<n){
		t++;
		L[t]=R[t-1]+1;
		R[t]=n;
	}
	for(int i=1;i<=t;i++){
		for(int j=L[i];j<=R[i];j++){
			pos[j]=i;
			sum[i]+=a[j];
		}
	}
	while(m--){
		char op[3];
		int x;
		int l,r,d;
		scanf("%d%d%d",&x,&l,&r);
		if(x==1){
			scanf("%d",&d);
			change(l,r,d);
		}
		else printf("%lld\n",ask(l,r));
	}
}

莫队

基础莫队

莫队=离线+暴力+分块

“离线”和“在线”的概念:在线是交互式的,一问一答;如果前面的答案用于后面的提问,称为“强制在线”。离线是非交互的,一次性读取所有问题,然后一起回答,"记录所有步,回头再做”。

基础的莫队算法是一种离线算法,它通常用于不修改只查询的一类区间问题,复杂度O({N\sqrt{N}}),没有在线算法线段树或树状数组好,但是编码很简单。下面是一道莫队模板题。

题目链接

莫队算法的排序:把数组分块(分成\sqrt{N}块),然后把查询的区间按左端点所在块的序号排序,如果左端点的块相同,再按右端点排序。

下面分析多种情况下莫队算法的复杂度。

(1)简单情况:区间交错,设区间[P1,y1]、[P2,y2]的关系是P1<P2,y1≤y2,其中P1、P2是左端点所在的块。L,R只需要从左到右扫一次,m次查询的总复杂度是O(N)。

(2)复杂情况:区间包含,设两个区间查询[P1,y1]、[P1,y2]的关系是P1=P2,y2≤y1。

此时小区间[P2,y2]排在大区间[P1,y1]的前面,与暴力法正好相反右指针R从左到右单向移动,不再往复移动。而左指针L发生了回退移动,但是被限制在一个长为 的块内,每次移动的复杂度是O(\sqrt{N})的。m次查询,每次查询左端点只需要移动O(\sqrt{N})次,右端点R共单向移动O(n)次,总复杂度O({N\sqrt{N}})

 (3)特殊情况:m个询问,端点都在不同的块上,此时莫队算法和暴力法是一样的,但总复杂度小

编码时,还可以对排序做一个小优化:奇偶性排序,让奇数块和偶数块的排序相反。例如左端点L都在奇数块,则对R从大到小排序;若L在偶数块,则对R从小到大排序(反过来也可以:奇数块从小到大,偶数块从大到小)。

#include<bits/stdc++.h>
using namespace std;
const int maxn = 1e6;
struct node{         //离线记录查询操作
  int L, R, k;       //k:查询操作的原始顺序
}q[maxn];
int pos[maxn];
int ans[maxn];
int cnt[maxn];       //cnt[i]: 统计数字i出现了多少次
int a[maxn];
bool cmp(node a, node b){
	//按块排序,就是莫队算法:
	if(pos[a.L] != pos[b.L])        //按L所在的块排序,如果块相等,再按R排序
		return pos[a.L] < pos[b.L];
	if(pos[a.L] & 1)   return a.R > b.R; //奇偶性优化,如果删除这一句,性能差一点
	return a.R < b.R;     
		/*如果不按块排序,而是直接L、R排序,就是普通暴力法:
		if(a.L==b.L)  return a.R < b.R;
    	return a.L < b.L;   */
}
int ANS = 0;
void add(int x){     //扩大区间时(L左移或R右移),增加数x出现的次数
    cnt[a[x]]++;
    if(cnt[a[x]]==1)  ANS++;   //这个元素第1次出现
}
void del(int x){     //缩小区间时(L右移或R左移),减少数x出现的次数
    cnt[a[x]]--;
    if(cnt[a[x]]==0)  ANS--;   //这个元素消失了
}
int main(){	
    int n; scanf("%d",&n);
    int block = sqrt(n);         //每块的大小
    for(int i=1;i<=n;i++){
        scanf("%d",&a[i]);       //读第i个元素
		pos[i]=(i-1)/block + 1;  //第i个元素所在的块
    }
    int m; scanf("%d",&m);          
    for(int i=1;i<=m;i++){       //读取所有m个查询,离线处理
        scanf("%d%d",&q[i].L, &q[i].R);
        q[i].k = i;              //记录查询的原始顺序
    }
    sort(q+1, q+1+m, cmp);       //对所有查询排序
	int L=1, R=0;                //左右指针的初始值。思考为什么?
    for(int i=1;i<=m;i++){
       	while(L<q[i].L)  del(L++);    //{del(L); L++;}  //缩小区间:L右移
        while(R>q[i].R)  del(R--);    //{del(R); R--;}  //缩小区间:R左移
		while(L>q[i].L)  add(--L);    //{L--; add(L);}  //扩大区间:L左移
        while(R<q[i].R)  add(++R);    //{R++; add(R);}  //扩大区间:R右移
        ans[q[i].k] = ANS;
    }
    for(int i=1;i<=m;i++)   printf("%d\n",ans[i]);  //按原顺序打印结果
    return 0;
}

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值