人类分块精华(一)

优雅,永不过时。

暴力的艺术,艺术的暴力——分块。

Part 1 分块原理

众所周知,线段树和树状数组都可以在 (log(n)) 的时间里解决序列区间求和的问题。但是使用线段树或者树状数组有一个前提:必须满足区间可加性原则。有一些不满足区间可加原则的值(譬如众数),很难使用线段树或者树状数组进行维护。并且有时候,线段树和树状数组的代码并不直观,导致在调试的时候比较艰难……

那么有没有一种扩展性强,简单易懂,可以维护区间信息的数据结构呢?

答案是:分块。

与线段树和树状数组相比,分块没有选择把区间层层分段到只有一个元素,而是分成若干个小段(常用的小段长是 (sqrt n) ),对每一个小段进行整体维护。至于不足段或者单个元素的修改……emmmm暴力它不香吗?

比如这样,对于一个数组 A :

我们可以把 A 分成 3 段(蓝色线隔开)。

利用 sum[i] 记录第 i 段的区间和,add[i] 表示第 i 段的增量标记(具体作用之后会说),sum 数组需要提前预处理,add 数组初始为 0 。

假设要把 A[2] ~ A[7] 这 7 个元素加上 d 。

这个修改包含了 2 段的所有元素,利用增量数组 add 进行整段维护,直接更新整段的 sum 值。add[2]+=d;sum[2]+=len*d;( len 是段长,这里为 3 )。

2 3 7 不是整段,直接暴力的在原数组 A[i] 上加 d ,循环的时候顺便更新 sum 数组。

for(2 to 3) A[i]+=d,sum[i]+=d; for(7 to 7) A[i]+=d,sum[i]+=d;

假设要查询 A[2]~A[7] 这 7 个元素的和。

查询包含了 2 段的所有元素,那么

ans+=sum[2];

答案还包括 2 3 7,直接暴力加进去。不过这里记得,整段修改的时候,我们不对段内的单个元素更新,而是寄存在 add 数组里,所以 A 中的元素 A[i] 一般不是真实值,A[i]+add[j] ( j 表示元素 A[i] 所处的段)才是真实值。

for(2 to 3) ans+=(A[i]+add[1]); for(7 to 7)ans+=(A[i]+add[3]);

最后 ans 就是答案。

Part 2 分块优势/应用

与线段树等相比,分块的做法更接近于“暴力”,所以它的效率通常没有线段树这些精致的数据结构高。但是因为分块在维护的时候,并不需要满足区间可加、可减性原则,因而可以更为广泛的应用到序列问题中。因为分块带有暴力的性质,对于一些题目可以大大减少其思维量。另外,分块算法有时可以替代一些复杂的数据结构,比如二逼平衡树、文艺平衡树等等。

光说不练假把式,下面来用几道例题,感受一下分块的暴力美学。

例1 区间加、区间和

给定一个长度为 (n(nleq 1e5)) 的序列 (A) ,有 (T) 次操作,每次给定一个操作参数 (op)

1、若 (op=1) ,再给定三个整数 (i,j,m) ,使 (A_k(ileq k leq j))(m)

2、若 (op=2) ,再给定两个整数 (i,j) ,求 (sum^{j}_{k=i} A_k) 的值。

这个问题可以使用线段树在 (O(nlogn)) 的时间内解决,现在看看分块解法。

和前面讲的原理差不多,判断区间加属于哪几个段,维护 sum、add 数组,不足一段的暴力处理;

统计的时候,答案在整段直接加 sum ,不足整段的加 (A_i+add_j) ;(其中 (j) 是值 (A_i) 所处的段)

总时间复杂度 (O(nsqrt n))

代码:

#include<cstdio>
#include<algorithm>
#include<cmath>
#include<iostream>
#define ll long long
using namespace std;

inline ll read(){
    ll x=0,fh=1;
    char ch=getchar();
    while(!isdigit(ch)){
        if(ch=='-')
            fh=-1;
        ch=getchar();
    }
    while(isdigit(ch)){
        x=(x<<3)+(x<<1)+ch-'0';
        ch=getchar();
    }
    return fh*x;
}

const int maxn=100005;
ll a[maxn],n,m;
ll L[maxn],R[maxn],belong[maxn],add[maxn],sum[maxn];
//L[i]/R[i]表示第i段的左端点,右端点,belong[i]表示第i个元素属于第几个块
//add表示增量标记,sum[i]表示第i段的和

signed main(){
    n=read(),m=read();
    for(int i=1;i<=n;i++)
        a[i]=read();
    int k=sqrt(n);
    for(int i=1;i<= (n%k==0 ? n/k :n/k+1) ;i++){//预处理分块
        L[i]=1+(i-1)*k;
        R[i]=min(n,L[i]+k-1);
        for(int j=L[i];j<=R[i];j++){
            sum[i]+=a[j];//预处理出每一段的和
            belong[j]=i;//第j个元素属于第i块
        }
    }
    for(ll i=0,op,l,r,k,p,q;i<m;i++){
        op=read();
        if(op==1){
            l=read(),r=read(),k=read();
            if(belong[l]==belong[r]){//属于同一块,直接暴力
                for(int j=l;j<=r;j++){
                    a[j]+=k;sum[belong[l]]+=k;//虽然暴力很快乐,但别忘更新sum
                }      
            }else{
                p=belong[l],q=belong[r];
                //记下来第l个元素属于第p段,而第r个属于第q段
                for(int j=p+1;j<=q-1;j++){
                	//那么p+1到q-1段都在这两段之间,整段更新
                    add[j]+=k;sum[j]+=(R[j]-L[j]+1)*k;
                }     
                for(int j=l;j<=R[p];j++){
                    a[j]+=k;sum[p]+=k;//单独更新最左边一段
                }                 
                for(int j=L[q];j<=r;j++){
                    a[j]+=k;sum[q]+=k;//单独更新最右边一段
                }   
            }
        }else{
            l=read(),r=read();
            ll ans=0;
            if(belong[l]==belong[r]){//类似修改,属于同一段的直接暴力查询             
                for(int j=l;j<=r;j++)
                    ans+=(a[j]+add[belong[l]]);
                printf("%lld
",ans);
            }else{
                p=belong[l],q=belong[r];
                for(int j=p+1;j<=q-1;j++)
                	//中间段整段累加
                    ans+=sum[j];
                for(int j=l;j<=R[p];j++)
                    ans+=(a[j]+add[p]);//单独暴力累加最左段
                for(int j=L[q];j<=r;j++)
                    ans+=(a[j]+add[q]);//单独暴力累加最右段
                printf("%lld
",ans);
            }
        }
    }
    return 0;
}

例2 区间加、区间乘、区间和

给定一个长度为 (n(nleq 1e5)) 的序列 (A) ,给定一个模数 (p) ,有 (T) 次操作,每次给定一个操作参数 (op)

1、若 (op=1) ,再给定三个整数 (i,j,m) ,使 (A_k(ileq k leq j))(m)

2、若 (op=2) ,再给定三个整数 (i,j,m) ,使 (A_k(ileq k leq j))(m)

3、若 (op=3) ,再给定两个整数 (i,j) ,求 (sum^{j}_{k=i} A_k)(p) 取模的值。

这个题同样可以使用线段树在 (O(Tlogn)) 时间内解决,但是对于懒标记的设计相对于例1有着更高的要求,因为这里涉及到先加后乘还是先乘后加的问题。我们不妨在这里讨论一下线段树的做法,以此与分块形成对比。

解法1:线段树

涉及到两种操作:加和乘。这里很显然有 ((a+b) imes c e a imes c +b) ,什么意思呢,就是说这里涉及到顺序问题。加操作和乘操作必须要分开计算,不可以混在一起,否则顺序不对会导致答案错误。

一种解决办法就是当有乘操作的时候,直接把涉及到的节点的加标记下传到叶子节点。这样解决了顺序问题,但是如果每次都暴力下传的话,很容易导致时间复杂度退化为 (O(n))

那怎么办呢?既然要分开计算,那不妨搞两个懒标记,一个用来记加 ( add ),一个用来记乘 ( mul )。

1、更新当前区间和

假设有一段区间是 (A_i)(A_j) 。从最简单的情况考虑,假设当前区间的 mul 标记的值为 1 (没乘过),而现在要给这段区间的每一个元素都乘上 (b)

那么设现在的区间和是 (v=sum^j_{k=i} (A_k imes b)=bsum^j_{k=i} A_k) 。发现直接把原来的区间和乘 (b) 就行了。

2、下传时更新子区间和

推理一下,假设某个区间的有子区间,且其中一个元素的值是 (a)

现在有 4 次操作:加 (b) 、乘 (c) 、加 (d) 、乘 (e)

那么现在的和应该是 (left[(a+b)c+d ight]e=ace+(bc+d)e)

观察式子,发现 (ce) 是两次乘法操作的积,这里我们的 mul 标记记录的正好就是两次操作的积,下传时直接用 (a) 乘标记的值就完事了。看看剩下的式子,好像不太好计算?没关系,这里是数学多项式迷惑了我们的思路,我们不妨换一换思维方式,用程序设计的思维来想。

如果给你 (b,c,d,e) 四个整数,让你求 ((bc+d)e) 的值,你会怎么做?

你肯定会不假思索的写出这样的代码:

#include<iostream>
using namespace std;
int main(){
	int ans=0;
	int b,c,d,e;
	cin>>b>>c>>d>>e;
	ans+=b;ans*=c;ans+=d;ans*=e;
	cout<<ans;
	return 0;
}

其实我们的 add 标记就是上面的 ans ,它在记录加的同时,也要记录乘。

打加法标记?那我 add+=x;打乘法标记?那我 add*=x

这样记录下的 add 就是上面所说的 ((bc+d)e) 的值啦!

总体来说,我们下传标记的时候,先下传乘法标记,然后下传加法标记,注意上面讲到的对加法标记的特殊处理就可以了。子区间的和就是 (v imes mul+len imes add) (其中 len 为区间长度,v 为原区间和)。

3、下传时更新子区间的两个标记

下传的时候,除了子区间和要更新,子区间的标记也要更新。

与统计区间和类似,下传的时候,也要先下传乘法标记,左右儿子的乘法标记乘上 (mul) 。注意此时乘了 (mul) 之后,加法标记也要乘上 (mul) (具体原因上面已经分析过),然后再加上父节点的 (add)

写出代码:

#include<cstdio>
using namespace std;
typedef long long LL;
const int maxn=100005;
int n,q,mod;
LL a[maxn];
struct Node{
	LL add,mul,v;
	int l,r;
	Node *ls,*rs;
	Node(const int L,const int R){//建树
		l=L,r=R;
		if(l==r){
			add=0;
			mul=1;
			v=a[l];
			ls=rs=NULL;
		}else{
			add=0;
			mul=1;
			int Mid=(L+R)/2;
			ls=new Node(L,Mid);
			rs=new Node(Mid+1,R);
			push_up();
		}
	}
	inline void make_add(const LL w){//打加法标记
		v+=(r-l+1)*w;
		add+=w;
	}
	inline void make_mul(const LL w){//打乘法标记 
		add=(add*w)%mod;//上面分析过,乘法的时候,add也乘w
		mul=(mul*w)%mod;
		v=(v*w)%mod;
	}
	inline void push_up() { v=(ls->v+rs->v)%mod; }//更新区间和
	inline void push_down(){//按照上面分析过的方法,向下传递标记
		ls->v=(LL)((mul*ls->v)+((ls->r-ls->l+1)*add)%mod)%mod;
		rs->v=(LL)((mul*rs->v)+((rs->r-rs->l+1)*add)%mod)%mod;
		//更新子区间的和
		ls->mul=(LL)(ls->mul*mul)%mod;
		rs->mul=(LL)(rs->mul*mul)%mod;	
		ls->add=(LL)(ls->add*mul+add)%mod;
		rs->add=(LL)(rs->add*mul+add)%mod;
		//更新子区间的标记
		mul=1;add=0;//注意这里mul是1,如果是0的话那么乘法会爆0
	}
	inline bool In_Range(const int L,const int R) { return (L<=l)&&(r<=R); }
	inline bool Outof_Range(const int L,const int R) { return (l>R)||(r<L); }
	//这两个函数是判断当前区间是不是完整包含于修改区间
	void up_add(const int L,const int R,const LL w){
		if(In_Range(L,R)==true) make_add(w);
		else if(!Outof_Range(L,R)){
			push_down();
			ls->up_add(L,R,w);
			rs->up_add(L,R,w);
			push_up();
		}
	}//加法操作
	void up_mul(const int L,const int R,const LL w){
		if(In_Range(L,R)==true) make_mul(w);
		else if(!Outof_Range(L,R)){
			push_down();
			ls->up_mul(L,R,w);
			rs->up_mul(L,R,w);
			push_up();
		}
	}//乘法操作
	LL query(const int L,const int R){
		if(In_Range(L,R)==true) return v;
		if(Outof_Range(L,R)==true) return 0;
		push_down();
		return ((ls->query(L,R))%mod+(rs->query(L,R))%mod)%mod;
	}//查询操作
};
int main(){
	scanf("%d%d%d",&n,&q,&mod);
	for(int i=1;i<=n;i++)
		scanf("%lld",a+i);
	Node *rot=new Node(1,n);
	for(LL o,l,r,x;q>0;q--){
		scanf("%lld%lld%lld",&o,&l,&r);
		if(o==1){
			scanf("%lld",&x);
			rot->up_mul(l,r,x);
		}
		if(o==2){
			scanf("%lld",&x);
			rot->up_add(l,r,x); 
		}
		if(o==3) printf("%lld
",rot->query(l,r));
	}
	return 0;
}

解法2 分块

和线段树解法相似的,分块也需要搞两个标记数组 add 和 mul 。

加法修改的时候,和例1一样,整段打标记,零散段直接暴力。乘法修改的时候,整段打标记,同时别忘了把 add 数组也乘一下(原理同上),零散段暴力处理。

但出现了两个问题:

1、假设现在有一个零散段(假设它完全被包含于第 (k) 段) ([l,r]) 要乘上 (m) ,我们的做法是直接暴力把 (A_l)(A_r) 的所有元素直接乘上 (m) 。然而实际上这样的做法并不对,这个段里的元素 (A_i)(lleq ileq r))的真实值是 (A_i imes mul_k+add_k) 。如果暴力,那么答案就变成了 (A_i imes mul_k imes m+add_k) ,然而真实的答案是$ m(A_i imes mul_k+add_k) $。这样一来一回,两个答案差了 (add_k(m-1)) 。按道理来说,如果想要暴力修改,得把 (add_k) 也乘 (m) 。但是 (add_k) 表示的是一整段的加法标记,不可能因为某几个元素的修改就跟着修改。

2、假设现在有一个零散段(假设它完全被包含于第 (k) 段) ([l,r]) 要加上 (m) ,我们的做法是直接暴力把 (A_l)(A_r) 的所有元素直接加上 (m) 。然而实际上这样的做法并不对,这个段里的元素 (A_i)(lleq ileq r))的真实值是 (A_i imes mul_k+add_k) 。如果暴力,那么答案就变成了 ((A_i+m) imes mul_k +add_k) ,然而真实的答案是$ A_i imes mul_k+add_k +m$。这样一来一回,两个答案差了 (m(mul_k-1)) 。按道理来说,如果想要暴力修改,得把 加数 (m) 变成 (frac{m}{mul_k}) 。但是运算中加入除法会导致加数变成奇奇怪怪的分数小数爆精度,我们的内心是拒绝的。

怎么办?暴力啊!直接把这一整段的 (mul_k)(add_k) 下传到 (A_i) 上,(A_i) 里存的就是真实值了!然后再暴力把每一个元素都乘上 (m) ,或者加上 (m) ,不就完成了修改么?

因为是零散段,每一个零散段的长度不超过 (sqrt n) ,所以对零散段的暴力下传标记的复杂度应该是 (sqrt n) ,这样的做法看似暴力,但是也是符合分块的时间复杂度的(最多算带个 4 倍常数)。这样总复杂度 (O(Tsqrt n))

这里分块的做法不用考虑如何更新儿子节点的标记的问题,节约了思维量。

写出代码:

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

#define ll long long
const ll maxn=100005;
ll mod;

inline ll read(){
    ll x=0,fh=1;
    char ch=getchar();
    while(!isdigit(ch)){
        if(ch=='-')
            fh=-1;
        ch=getchar();
    }
    while(isdigit(ch)){
        x=(x<<3)+(x<<1)+ch-'0';
        ch=getchar();
    }
    return x*fh;
}

ll A[maxn],add[maxn],tme[maxn],L[maxn],R[maxn],belong[maxn],sum[maxn];
//这里乘法标记我用的是tme数组,其他同例1
ll n,m;

inline void reset(const int x){
    for(int i=L[x];i<=R[x];i++)
        A[i]=( A[i]*tme[x] + add[x] )%mod;
    tme[x]=1,add[x]=0;
}//暴力下传标记

signed main(){
    n=read(),m=read(),mod=read();
    for(int i=1;i<=n;i++)
        A[i]=read();
    ll len=sqrt(n);
    for(int i=1;i<=(n%len==0?n/len:n/len+1);i++){
        L[i]=1+(i-1)*len;
        R[i]=min(n,L[i]+len-1);
        tme[i]=1;
        for(int j=L[i];j<=R[i];j++){
            sum[i]=( sum[i]%mod + A[j]%mod )%mod;
            belong[j]=i;
        } 
    }
    for(ll i=0,op,l,r,k;i<m;i++){
        op=read(),l=read(),r=read();
        if(op==1){//乘k
            k=read();
            int p=belong[l],q=belong[r];
            if(p==q){
                reset(p);//零散段下传
                for(int j=l;j<=r;j++){//零散段暴力
                    sum[p]=( sum[p] + A[j] * (k-1) )%mod;
                    A[j]=( A[j] * k ) %mod;                 
                }    
            }else{
                for(int j=p+1;j<=q-1;j++){//整段更新
                    add[j]=( add[j] * k )%mod;
                    tme[j]=( tme[j] * k )%mod;
                    sum[j]=( sum[j] * k )%mod;   
                }
                reset(p);reset(q);//零散段下传
                for(int j=l;j<=R[p];j++){//零散段暴力
                    sum[p]=( sum[p] + A[j] * (k-1) )%mod;
                    A[j]=( A[j] * k ) %mod; 
                }
                for(int j=L[q];j<=r;j++){
                    sum[q]=( sum[q] + A[j] * (k-1) )%mod;
                    A[j]=( A[j] * k ) %mod;                     
                }
            }
        }else if(op==2){
            k=read();//这一堆都同上
            int p=belong[l],q=belong[r];
            if(p==q){
                reset(p);
                for(int j=l;j<=r;j++){
                    A[j]=( A[j] + k )%mod;
                    sum[p]=( sum[p] + k )%mod;
                }
            }else{ 
                for(int j=p+1;j<=q-1;j++){
                    sum[j]=( sum[j] + (len*k) )%mod;
                    add[j]=( add[j] + k )%mod;
                }
                reset(p);reset(q);
                for(int j=l;j<=R[p];j++){
                    A[j]=( A[j] + k )%mod;
                    sum[p]=( sum[p] + k )%mod;
                }
                for(int j=L[q];j<=r;j++){
                    A[j]=( A[j] + k )%mod;
                    sum[q]=( sum[q] + k )%mod;
                }
            }
        }else{
            ll ans=0;
            int p=belong[l],q=belong[r];
            if(p==q){//和线段树的查询差不多,先乘后加
                for(int j=l;j<=r;j++)
                    ans=( ans + add[p] + A[j] * tme[p] )%mod;  
                printf("%lld
",ans);
            }else{
                for(int j=l;j<=R[p];j++)
                    ans=( ans + add[p] + A[j] * tme[p] )%mod;
                for(int j=p+1;j<=q-1;j++)
                    ans=( ans + sum[j] )%mod;
                for(int j=L[q];j<=r;j++)
                    ans=( ans + add[q] + A[j] * tme[q] )%mod;
                printf("%lld
",ans);
            }
        }
    }
    return 0;
}

今天的分享先到这里,以后还会有别的分块博客,敬请期待

繁华尽处, 寻一静谧山谷, 筑一木制小屋, 砌一青石小路, 与你晨钟暮鼓, 安之若素。
原文地址:https://www.cnblogs.com/zaza-zt/p/14899428.html