数列入门分块 做题记录

系列链接


前言

分块,一种优雅的暴力。我总是在一些弱智的地方犯错,一调就是半小时。

这些入门题的思路还是比较直接的,希望分块水平能有所提高,少犯小错误。


我的常用模板

基本预处理方式,在空间要求不严格时会用 leribel等数组增强可读性。

void make_group()
{
	block=sqrt(n);
	num=(n-1)/block+1;
	for(int i=1;i<=num;i++)
	{
		le[i]=(i-1)*block+1;
		ri[i]=min(i*block,n);
		for(int j=le[i];j<=ri[i];j++)
			bel[j]=i;
	}
}

注意事项:leri 是对于块来说的,bel 是对于序列来说的,因此空间大小需要警惕。在操作涉及插入时,空间要开为 n+m,否则越界。


关于本文中包含的数组含义

leri 为每块的左右边界。

mul 为乘法标记,add 为加法标记。

sum 有时是加法标记,有时是块中元素总和。

bel 为每个元素属于的块。


数列入门分块1

题目链接

操作:区间加法,单点查值。

对于散块直接进行暴力更新,对于整块对 add 数组进行更新。

void modify(int l,int r,int k)
{
	int st=bel[l],ed=bel[r];
	if(st==ed)
	{
		for(int i=l;i<=r;i++)
			a[i]=a[i]+k;
		return;
	}//如果首尾在同一块中,直接暴力更新
	for(int i=l;i<=ri[st];i++)
		a[i]=a[i]+k;
   // 对于前面散块进行暴力更新。注意范围为 l~ri[st]
	for(int i=le[ed];i<=r;i++)
		a[i]=a[i]+k;
   // 对于后面散块进行暴力更新。注意范围为 le[ed]~r
	for(int i=st+1;i<=ed-1;i++)
		add[i]=add[i]+k;
   // 对于整块,对add数组进行更新
	return;
}

在查询时,答案即为 \(a_i+add_{bel_i}\)


数列入门分块2

题目链接

操作:区间加法,询问区间内小于某个值的元素个数。

先考虑直接暴力。在查询时可以通过 \(O(n)\) 遍历查找,也可以先排序再二分来 \(O(n\log_2 n)\) 来计算。

然后考虑分块。如果先对每块内的元素进行排序,再对每块元素都加上一个值 \(c\),那么这个块内仍然是单调不降的。

先说修改。对于散块,还像之前一样直接暴力更新,但这样散块所对应的整块就不有序了。如果直接对 a 数组进行排序,那么就找不到原来位置所对应的元素了。所以这时我们需要再开一个数组 b,其中每块中的元素都是有序的。那么对于散块就在 a 数组中查找,对于整块就在 b 数组中查找,就不会冲突了。

修改部分代码如下:

void modify(int l,int r,int k)
{
	int st=bel[l],ed=bel[r];
	if(st==ed)
	{
		for(int i=l;i<=r;i++)a[i]+=k;// 暴力更新
		for(int i=le[st];i<=ri[st];i++)b[i]=a[i];// 赋值
		sort(b+le[st],b+ri[st]+1);// 保证有序
		return;
	}
	for(int i=l;i<=ri[st];i++)a[i]+=k;
	for(int i=le[st];i<=ri[st];i++)b[i]=a[i];
	sort(b+le[st],b+ri[st]+1);// 前散块
	
	for(int i=le[ed];i<=r;i++)a[i]+=k;
	for(int i=le[ed];i<=ri[ed];i++)b[i]=a[i];
	sort(b+le[ed],b+ri[ed]+1);// 后散块
	
	for(int i=st+1;i<=ed-1;i++)add[i]+=k;// 整块直接加
	return;
}

然后说查询。相似的,对于散块,直接遍历即可。对于整块,用二分查找,然后减去对应下标即可。

int ask(int l,int r,int k)
{
	int st=bel[l],ed=bel[r],ans=0;
	if(st==ed)
	{
		for(int i=l;i<=r;i++)
			if(a[i]+add[st]<k)ans++;// 注意是 a[i]+add[st]
		return ans;
	}
	for(int i=l;i<=ri[st];i++)
		if(a[i]+add[st]<k)ans++;//前散块
	for(int i=le[ed];i<=r;i++)
		if(a[i]+add[ed]<k)ans++;//后散块
	for(int i=st+1;i<=ed-1;i++)
	{
		int p=lower_bound(b+le[i],b+ri[i]+1,k-add[i])-b;
        // 注意找的值是 k-add[i],这实际上是互逆的一个过程
		ans+=p-le[i];// 其实应为 (p-1)-le[i]+1
	}
	return ans;
}

数列入门分块3

题目链接

操作:区间加法,询问区间内小于某个值的前驱(比其小的最大元素)。

本题与分块2类似,还是采用相同的方式进行修改,就没有必要再贴一遍了。

在询问时,对于散块,直接遍历找出小于 \(k\) 的最大值,没有就是 -1。对于整块,用二分找到第一个小于 \(k\) 的位置 \(x\),如果 \(x<le[i]\),那么对答案没有贡献。

int ask(int l,int r,int k)
{
	int st=bel[l],ed=bel[r],ans=-1;// 注意ans的初始值
	if(st==ed)
	{
		for(int i=l;i<=r;i++)
			if(a[i]+add[st]<k)
				ans=max(ans,a[i]+add[st]);
		return ans;
	}
	for(int i=l;i<=ri[st];i++)
		if(a[i]+add[st]<k)
			ans=max(ans,a[i]+add[st]);//前散块更新
	for(int i=le[ed];i<=r;i++)
		if(a[i]+add[ed]<k)
			ans=max(ans,a[i]+add[ed]);//后散块更新
	for(int i=st+1;i<=ed-1;i++)
	{
		int p=lower_bound(b+le[i],b+ri[i]+1,k-add[i])-b-1;
        // lower_bound 查找到的是第一个大于等于某个数的位置,故第一个小于的需要 -1
		if(p>=le[i])ans=max(ans,b[p]+add[i]);//满足条件的取max
	}
	return ans;
}

数列入门分块4

题目链接

操作:区间加法,区间求和。

本题与分块1类似,我们还需要维护一个区间和。

在预处理时,先对每块的元素和进行统计。

在修改时,对于散块,直接暴力更新元素值,同时更新总和。对于整块,直接更新 add 数组。

void modify(int l,int r,ll k)
{
	int st=bel[l],ed=bel[r];
	if(st==ed)
	{
		for(int i=l;i<=r;i++)a[i]+=k;
		sum[st]+=k*(r-l+1);
		return;
	}
	for(int i=l;i<=ri[st];i++)
		a[i]+=k;
	sum[st]+=k*(ri[st]-l+1);// 前散块
	
	for(int i=le[ed];i<=r;i++)
		a[i]+=k;
	sum[ed]+=k*(r-le[ed]+1);// 后散块
	
	for(int i=st+1;i<ed;i++)
		add[i]+=k;// 整块
	return;
}

在查询时,对于散块,直接暴力加上 \(a_i+add_{bel_i}\)。对于整块,直接加上区间和和 add 的值。

ll query(int l,int r)
{
	int st=bel[l],ed=bel[r];
	ll ans=0;
	if(st==ed)
	{
		for(int i=l;i<=r;i++)
			ans+=a[i];
		ans+=add[st]*(r-l+1);
		return ans;
	}
	for(int i=l;i<=ri[st];i++)// 前散块
		ans+=a[i];
	ans+=add[st]*(ri[st]-l+1);
	
	for(int i=le[ed];i<=r;i++)// 后散块
		ans+=a[i];
	ans+=add[ed]*(r-le[ed]+1);
	
	for(int i=st+1;i<ed;i++)// 整块
		ans+=sum[i]+add[i]*(ri[i]-le[i]+1);
	return ans;
}

数列入门分块5

题目链接

操作:区间开方,区间求和。

乍一看,开方后没有可加性,似乎不可做。

想一下,一个 int 范围内的数最多不超过 \(6\) 次就会变为 \(1\)

那么增加一个数组 die 来表示当前整块是否全部为 \(1\)。如果是,直接跳过即可。否则就同散块一样暴力更新,这样就有效防止了同个区间更新多次而导致暴力复杂度过大的后果。

void modify(int l,int r)
{
	int st=bel[l],ed=bel[r];
	if(st==ed)
	{
		for(int i=l;i<=r;i++)a[i]=sqrt(a[i]);
		sum[st]=0,die[st]=true;//开过根号后需要整块重新统计
		for(int i=le[st];i<=ri[st];i++)
		{
			sum[st]+=a[i];
			if(a[i]>1)die[st]=false;//存在一个就为false
		}
		return;
	}
	for(int i=l;i<=ri[st];i++)a[i]=sqrt(a[i]);
	sum[st]=0,die[st]=true;
	for(int i=le[st];i<=ri[st];i++)
	{
		sum[st]+=a[i];
		if(a[i]>1)die[st]=false;
	}// 前散块
	
	for(int i=le[ed];i<=r;i++)a[i]=sqrt(a[i]);
	sum[ed]=0,die[ed]=true;
	for(int i=le[ed];i<=ri[ed];i++)
	{
		sum[ed]+=a[i];
		if(a[i]>1)die[ed]=false;
	}// 后散块
	
	for(int i=st+1;i<=ed-1;i++)
	{
		if(die[i])continue;// 如果全为1就直接跳过
		for(int j=le[i];j<=ri[i];j++)a[j]=sqrt(a[j]);
		sum[i]=0,die[i]=true;
		for(int j=le[i];j<=ri[i];j++)
		{
			sum[i]+=a[j];
			if(a[j]>1)die[i]=false;
		}//否则就同散块一般暴力更新
	}
	return;
}

查询就很入门了,散块加 a,整块加 sum,不贴代码了。


数列入门分块6

题目链接

操作:单点插入,单点询问。

对于本题,我采用了 vector + 分块。

先用常规思路进行思考:因为有多个插入操作,所以块的大小是会更改的。这时用 leri 记录不合适,因此采用更为方便的 vector。如何找到当前第 \(x\) 个数所属的块?很暴力。直接从前往后一路加上块长,直到再加会超过 \(x\) 时停止。为了方便,采用结构体返回所属的块和块中的位置,也可以用 pair

struct node{int x,k;};

node query(int x)
{
	int i=1;
	while(x>(int)(e[i].size()))
		x-=(int)(e[i++].size());
	return (node){i,x-1};// 注意 vector 中的元素下标从0开始,所以要-1
}

修改也很简单,直接用 vector 插入即可。

void insert(int x,int k)
{
	node pos=query(x);
	int bel=pos.x,t=pos.k;
	e[bel].insert(e[bel].begin()+t,k);
}

但是问题来了:对于 vector 来说,插入这个操作是 \(O(n)\) 的。假如数据不随机,在一个点大量插入,那么这个块的块长远超 \(\sqrt{n}\),会使效率大大降低。

于是我们就引进了一个方法:重构。当一个块块长过大时,对所有的块进行清空,并且重新进行分块。这样对块长重新平摊,能够提高分块效率。

重构部分:

void rebuild()
{
	int cnt=0;
	for(int i=1;i<=num;i++)
	{
		for(int j=0;j<e[i].size();j++)
			v[++cnt]=e[i][j];// 临时数组v存放所有元素
		e[i].clear();
	}
	block=sqrt(cnt);
	num=(cnt-1)/block+1;
	for(int i=1;i<=cnt;i++)
	{
		int bel=(i-1)/block+1;
		e[bel].push_back(v[i]);
	}//重新分块
}

在函数中调用时:

if((int)(e[bel].size())>20*block)
	rebuild();

这个 \(20\) 视情况而定,其实我也不太明白。


数列入门分块7

题目链接

操作:区间乘法,区间加法,单点询问。

本题与分块4不同之处在于增加了一个区间乘法操作。

不是很想写,先咕着。


数列入门分块8

题目链接

操作:区间询问等于一个数 \(c\) 的元素个数,并将这个区间的所有元素改为 \(c\)

不是很想写,先咕着。


数列入门分块9

题目链接

操作:询问区间的最小众数。

本题没有修改操作,但是仍然有难度。

先考虑暴力:

遍历 \(l\)\(r\),对每个数字开个桶记录出现次数,然后更新答案。显然这样的时间复杂度是 \(O(m\times n)\) 的。空间如何解决?你可以用 map,但是我更推荐用离散化,这样将桶的空间复杂度降到了 \(O(n)\)

如果想要将查询操作降为 \(O(1)\),理想情况是不是直接开一个数组 \(C[i][j]\) 来记 \(l\)\(r\) 中的众数是什么?但是这样既难预处理还会爆炸时空复杂度。

考虑分块:

首先要想一个问题:如果将一段区间分为左右散块和中间的整块,那么这段区间的答案会来自哪里?

简化问题,设 \(\text{mode(A)}\) 表示集合 \(\text{A}\) 中的众数,且存在一个集合 \(\text{B}\)。那么集合 \(\text{A}\cup\text{B}\) 的众数会是什么?是不是要么是 \(\text{mode(A)}\),要么就是集合 \(\text{B}\) 中的某个元素?假如 \(\text{A}\) 中的某个元素 \(x\ne \text{mode(A)}\),但是 \(x=\text{mode(A}\cup\text{B)}\),那么肯定有 \(x\in \text{B}\)

用一个式子概括,就是 \(\text{mode(A}\cup\text{B)}\in \text{mode(A)}\cup \text{B}\)

所以一段区间的众数,要么来自左散块,要么来自右散块,要么就是中间所有整块的众数。

\(block\) 为每块块长,\(num\) 为总块数,那么我们直接用 \(O(num^2\times block)\) 的时间复杂度预处理所有第 \(i\) 块到第 \(j\) 块的众数。取 \(block=\sqrt{n}\) 时复杂度约为 \(O(n\sqrt{n})\)

void init()
{
    for(int i=1;i<=num;i++)// i为起始块编号
    {
        memset(cnt,0,sizeof(cnt));// 每次做完需要清空
        int wh=inf,ti=0;
        for(int j=i;j<=num;j++)// j为结束块编号
        {
            for(int k=le[j];k<=ri[j];k++)// 遍历整个块
            {
                cnt[a[k]]++;
                if(cnt[a[k]]>ti)
                    ti=cnt[a[k]],wh=a[k];
                else if(cnt[a[k]]==ti)
                    wh=min(wh,a[k]);
                    // 注意找的是所有众数中最小的
            }
            zs[i][j]=wh;
        }
    }
}

然后就是查询操作了。按照之前说的思想,对于散块中的每个元素和整块的众数,查询这些数在 \([l,r]\) 中出现的次数,然后进行比较即可。

问题又来了,如何查询出现次数?

不难想到使用二分。对于每个数,开一个桶顺序记录它们在序列中出现的位置。在查询出现次数时,我们只需找到最后一个 \(\le r\) 的位置在桶中的编号 \(p\),以及第一个 \(\ge l\) 的位置在桶中的编号 \(q\),那么就出现了 \(p-q+1\) 次。

inline int calc(int x,int l,int r)
{
    int p=upper_bound(c[x].begin(),c[x].end(),r)-c[x].begin()-1;
    // upper_bound 查找的是第一个大于的,-1就是最后一个小于等于的了
    int q=lower_bound(c[x].begin(),c[x].end(),l)-c[x].begin();
    return p-q+1;
}

经过上述讲解,相信查询操作就很轻松了,详见代码注释:

int query(int l,int r)
{
    int st=bel[l],ed=bel[r];
    int wh=inf,ti=0;
    if(st==ed)
    {
        memset(cnt,0,sizeof(cnt));
        for(int i=l;i<=r;i++)
        {
            cnt[a[i]]++;
            if(cnt[a[i]]>ti)
                ti=cnt[a[i]],wh=a[i];
            else if(cnt[a[i]]==ti)
                wh=min(wh,a[i]);
        }
        return wh;
    }// 首尾在同一块中,直接统计即可
    for(int i=l;i<=ri[st];i++)
    {
        int sum=calc(a[i],l,r);
        if(sum>ti)
            ti=sum,wh=a[i];
        else if(sum==ti)
            wh=min(wh,a[i]);
    }// 对于前散块进行处理
    for(int i=le[ed];i<=r;i++)
    {
        int sum=calc(a[i],l,r);
        if(sum>ti)
            ti=sum,wh=a[i];
        else if(sum==ti)
            wh=min(wh,a[i]);
    }// 对于后散块进行处理
    if(st+1<=ed-1)
    {
        int now=zs[st+1][ed-1];
        int sum=calc(now,l,r);
        if(sum>ti)
            ti=sum,wh=now;
        else if(sum==ti)
            wh=min(wh,now);
    }// 如果中间有整块,再计算整块众数是否为区间众数
    return wh;
}

将块长调为 \(80\) 就可以通过本题了。

但是问题来了:当块长为 \(\sqrt n\) 时单次查询的复杂度为 \(O\sqrt n\log_2n)\),总时间复杂度为 \((n+m)\sqrt n\log_2n\)。当时间限制更小时,这显然无法通过。

怎么办呢?分块的 \(\sqrt n\) 的系数肯定是去不掉了,那我们就需要考虑优化查询操作,用 \(O(1)\) 的时间查询 \(x\)\([l,r]\) 中出现的次数。

采用前缀和思想,用数组 c 来记录前 \(i\) 块中元素 \(j\) 出现的总次数,那么第 \(l+1\) 到第 \(r-1\) 块中 \(j\) 出现的次数就为 \(c[r-1][j]-c[l][j]\)

inline int calc(int x,int l,int r)
{
    int p=c[r][x];
    int q=c[l-1][x];
    return p-q;
}

那么在计算左右散块中元素在区间 \([l,r]\) 中出现的次数时,只需累加当前元素在散块中出现次数,并加上中间整块的出现次数即可。

for(int i=l;i<=ri[st];i++)
	cnt[a[i]]=0;
for(int i=le[ed];i<=r;i++)
	cnt[a[i]]=0;
for(int i=l;i<=ri[st];i++)
{
	cnt[a[i]]++;
    int sum=cnt[a[i]]+calc(a[i],st+1,ed-1);
    if(sum>ti)
        ti=sum,wh=a[i];
    else if(sum==ti)
        wh=min(wh,a[i]);
}
for(int i=le[ed];i<=r;i++)
{
	cnt[a[i]]++;
    int sum=cnt[a[i]]+calc(a[i],st+1,ed-1);
    if(sum>ti)
        ti=sum,wh=a[i];
    else if(sum==ti)
        wh=min(wh,a[i]);
}
if(st+1<=ed-1)
{
    int now=zs[st+1][ed-1];
    int sum=calc(now,st+1,ed-1);
    if(sum>ti)
        ti=sum,wh=now;
    else if(sum==ti)
        wh=min(wh,now);
}
return wh;

这时总时间复杂度就降为 \(O((n+m)\sqrt n)\)


posted @ 2021-05-03 19:15  cyl06  阅读(131)  评论(0编辑  收藏  举报