搜索选讲、分块初步、莫队简介

soapprice / 2024-08-24 / 原文

省流:本篇专供冲击NOIP一等的人使用,坐标HN

本文的洛谷链接

1:搜索选讲

因为纯靠搜索能AC的题目少得离谱,所以初学阶段,我们不讲很多高深的搜索,以下四个算法应该足以应付我们的目标了。

1.1 深度优先搜索DFS

深度优先搜索指的是用递归完成的遍历枚举,遍历请转移至图论

其思想很简单,就是一条路走到~~
WA~~
\(\color{#52C41A}{黑}\),沿着起始位置一直往下走,不撞南墙不回头

DFS枚举一般使用递归实现,至于图论里的非递归+栈,到时候再讲

它有一个小优化,就是当枚举的位置/元素不符合要求的时候,可以通过撤销操作往回跑,叫恢复现场也可以。其实,它的真正名字叫\(\color{#EE0000}{回溯}\)

回溯怎么实现呢?你赋了哪些值、改变了哪些变量,改回去不就是了

就这样,你已经了解了这个相当简洁的算法

试一试!

习题1.1.1

P1219 八皇后

明明是n皇后

对于这道题,我们可以学习图的行-列-对角线标记法

即对于行、列、左/右对角线,我们分别开一个数组标记

对于对角线存储的原理,我们观察可得:
\(对于每一条左对角线上的格子,有x+y为定值\)

\(对于每一条右对角线上的格子,有y-x为定值\)

\(其中y为列数,x为行数\)

但右对角线的定值可能\(\le 0\),考虑极端情况:

\[y=1,x=n \]

此时定值为\(1-n\),取得最小值

故我们加一个\(n\),就可以保证坐标\(>0\)

然后是核心的dfs部分,

我们将dfs的参数设为行数,及对每一行进行dfs,

一旦这个格子可以放,就将这个格子所在的行、列、左/右对角线全部打上标记(示例代码记为1)

显然回溯时全部定为0就可以了

如何判断可不可以放呢?当且仅当格子所在的行、列、左/右对角线都未标记时,这里是可以放的。

然后输出就可以了

AC Code:

#include<bits/stdc++.h>
using namespace std;
const int maxn=30;

//int line[maxn]; legacy
int column[maxn];
int lefts[maxn];
int rights[maxn];
int way[maxn];//三种方案
int ans=0;//最终输出的次数
int cnt=1;//为了让打印答案更直观,添加一个辅助变量

void printarr(int len)
{
	if(cnt<=3)
	{
		for(int i=1;i<=len;i++)
		{
			cout<<way[i];
			if(i<len)cout<<" ";
		}
		cout<<endl;
	}
	ans++;
	cnt++;
}

void dfs(int k,int n)//这里的k是列数,如果行和列都枚一遍,那就循环了,还递归什么
{
	if(k>n)//行数枚完了
	{
		printarr(n);
		return;
	}
	for(int i=1;i<=n;i++)
	{
		if(column[i]==0&&lefts[i+k]==0&&rights[i-k+n]==0)
		{
			way[k]=i;//别忘了保存答案,第k行的答案(列数)是i
			column[i]=1;
			lefts[i+k]=1;
			rights[i-k+n]=1;
			dfs(k+1,n);//下一行,启动!
			//if 要不得,回溯,撤!
			column[i]=0;
			lefts[i+k]=0;
			rights[i-k+n]=0;
		}
	}
}

int main()
{
	int n=0;
	cin>>n;
	dfs(1,n);
	cout<<ans;
	return 0;
}

习题1.1.2

P1135 奇怪的电梯

我一看,哦,很简单的题目,dfs的二叉搜索就够了

递归时先判断合不合法,然后再调用

然后你就收获了\(\color{#E74C3C}{Unaccepted}\)

#include<bits/stdc++.h>
using namespace std;
const int maxn=205;

int arr[maxn];
int cnt=0;

void dfs(int s,int t,int n)
{
	if(s==t)
	{
		return;
	}
	else
	{
		if(s+arr[s]<=n)
		{
			cnt++;
			dfs(s+arr[s],t,n);
		}
		else if(s-arr[s]>=1)
		{
			cnt++;
			dfs(s-arr[s],t,n);
		}
		else
		{
			cout<<"No roads"<<endl;
			return;
		}
	}
}

int main()
{
	int n=0,a=0,b=0;
	cin>>n>>a>>b;
	for(int i=1;i<=n;i++)
	{
		cin>>arr[i];
	}
	dfs(a,b,n);
	cout<<cnt;
	return 0;
}

值得一提的是,dfs不做任何优化的情况下时间复杂度为\(O(2^n)\)。虽然优化后可降至\(O(n^2)\),但它想卡还是完全可以卡你的。面对这种情况,我们可以使用下面这种算法↓

1.2 广度优先搜索BFS

这个算法基本是为图论服务的,网上单独对它进行讲述的资料很少,有的甚至只有两行,点名批评某wiki

但以后的图论最短路算法都是从它的基础上衍生出去的,写法看上去还差不多,所以我们讲讲。

广度优先搜索基本上以循环实现,用队列来储存所有与当前元素相邻的元素,再一个个从队列中取出,以它为当前元素,再入队相邻元素,如此往复。

值得一提的是,虽然裸的不加优化的bfs似乎也是\(O(2^n)\),但在一些具体题目中,它很可能达到\(O(n+m)\)的优秀时间复杂度,其中\(n\)为元素数,\(m\)为元素之间的关系数

习题1.2.1

P1135 奇怪的电梯 again

好,我们用bfs来处理一下这个题目

我们使用\(vis\)数组来记录每个点有没有被访问过,如果是,那就跳过它,这属于\(“剪枝”优化\)的一部分(其实这里挺谜的,为什么一个楼层不能搭两次电梯?)

其余的写法如上文所述。

AC Code:

#include<bits/stdc++.h>
using namespace std;
const int maxn=205;

int arr[maxn];
int vis[maxn];
int dis[maxn];
int cnt=0;

void bfs(int s,int t,int n)
{
	queue<int> q;
	memset(vis,0,sizeof(vis));
	memset(dis,0x3f,sizeof(dis));
	vis[s]=1;
	dis[s]=0;
	if(s+arr[s]<=n&&vis[s+arr[s]]==0)
	{
		q.push(s+arr[s]);
		vis[s+arr[s]]=1;
		dis[s+arr[s]]=min(dis[s+arr[s]],dis[s]+1);
	}
	if(s-arr[s]>=1&&vis[s-arr[s]]==0)
	{
		q.push(s-arr[s]);
		vis[s-arr[s]]=1;
		dis[s-arr[s]]=min(dis[s-arr[s]],dis[s]+1);
	}
	while(!q.empty())
	{
		int u=q.front();
		q.pop();
		if(u==t)return;
		if(u+arr[u]<=n&&vis[u+arr[u]]==0)
		{
			q.push(u+arr[u]);
			vis[u+arr[u]]=1;
			dis[u+arr[u]]=min(dis[u+arr[u]],dis[u]+1);
		}
		if(u-arr[u]>=1&&vis[u-arr[u]]==0)
		{
			q.push(u-arr[u]);
			vis[u-arr[u]]=1;
			dis[u-arr[u]]=min(dis[u-arr[u]],dis[u]+1);
		}
	}
}

int main()
{
	int n=0,a=0,b=0;
	cin>>n>>a>>b;
	for(int i=1;i<=n;i++)
	{
		cin>>arr[i];
	}
	bfs(a,b,n);
	if(dis[b]==0x3f3f3f3f)
	{
		cout<<-1;
		return 0;
	}
	cout<<dis[b];
	return 0;
}

其实这题的搜索更适合用图论建模的思想,把能到的楼层都连上边,然后跑图论版的\(dfs\)\(bfs\),留作思考题。

习题1.2.2

P1443 马的遍历

这个就是bfs的板子题了,一点技术含量都没有

我们直接把马的八个运动方向分解到棋盘的\(x\)\(y\)轴上(\(\color{#66CCFF}{梦回物理whk}\)),然后把八个方向存在一个所谓的常量数组里,这样调用时可以写在一个循环里面,防止一大坨的bfs

本题bfs的入队条件是运动到目的地后不越界,即

\[1\le x_{移动后}\le n \]

\[1\le y_{移动后}\le m \]

为了避免一个格子被覆盖多个路径,我们使用二维数组\(vis\)记录,\(1\)已访问\(0\)未访问。这样,在满足第三个条件后,才可以入队:

\[vis[x_{移动后}][y_{移动后}]==0 \]

其余照常bfs即可,注意本题输出数组好像是用水平制表符分隔的,不知道空格会不会错。

AC Code:

#include<bits/stdc++.h>
using namespace std;
const int maxn=405;

int dis[maxn][maxn];
int vis[maxn][maxn];

int xs[]={0,-1,-2,-2,-1,1,2,2,1};
int ys[]={0,2,1,-1,-2,-2,-1,1,2};

struct xy
{
	int x;
	int y;
};

void bfs(int n,int m,int x,int y)
{
	memset(dis,0x3f,sizeof(dis));
	memset(vis,0,sizeof(vis));
	queue<xy> q;
	q.push({x,y});
	dis[x][y]=0;
	vis[x][y]=1;
	while(!q.empty())
	{
		xy u=q.front();
		q.pop();
		for(int i=1;i<=8;i++)
		{
			if(u.x+xs[i]<=n&&u.x+xs[i]>=1&&u.y+ys[i]<=m&&u.y+ys[i]>=1&&vis[u.x+xs[i]][u.y+ys[i]]==0)
			{
				dis[u.x+xs[i]][u.y+ys[i]]=dis[u.x][u.y]+1;
				vis[u.x+xs[i]][u.y+ys[i]]=1;
				q.push({u.x+xs[i],u.y+ys[i]});
			}
		}
	}
}

int main()
{
	int n=0,m=0,x=0,y=0;
	cin>>n>>m>>x>>y;
	bfs(n,m,x,y);
	for(int i=1;i<=n;i++)
	{
		for(int j=1;j<=m;j++)
		{
			
			if(dis[i][j]==0x3f3f3f3f)cout<<-1;
			else cout<<dis[i][j];
			if(j<m)cout<<'\t';
		}
		if(i<n)cout<<endl;
	}
	return 0;
}

1.3 双向搜索/meeting in the middle

很快啊,这里已经只剩\(\color{#52C41A}{普及+/提高及以上}\)

这个译名真的很乱啊,而且这两个真不是一个算法!

双向搜索就是同时从起点\(s\)和终点\(t\)同时dfs或bfs,那么问题有可行解的充要条件是两条搜索路径能够相遇

感觉写起来的话bfs实现比较方便,dfs可能头绪会有点乱。

meeting in the middle 常译作“折半搜索”,适用于以下情况:

\[O(n^2)<<O(数据规模)<<O(2^n) \]

数据规模较小,但不至于直接dfs或bfs

其核心思想是将搜索路径分成两部分进行,最终使用时再合并。

分治了属于是

非常抽象是吧,看两道题吧

习题1.3.1

P4799 世界冰球锦标赛

经典meeting in the middle,这里最多有40场比赛,暴力枚举为\(O(2^{40})\),请你算算,\(2^{40}\)\(10^9\)哪个大?\(2^{40}\)\(10^{18}\)呢?

所以显然不能朴素dfs。而所谓“背包”算法也无法\(\color{52C41A}{accepted}\),所以这里采用折半搜索。

事实上,折半搜索常用于\(O(2^{30})\)\(O(2^{40})\)的搜索问题

我们考虑将比赛分成两半,分开dfs求出可行方案数,并分别存在\(a,b\)两个\(vector\)中。

分别进行两次dfs后,我们就应该考虑合并答案,那如何合并呢?

首先,我们必须让一个数组有序(这里选择\(a\)),因为方便后面统计时直接跳出。

然后对于\(b\)中的第\(j\)个元素,所有满足下列关系的\(a_{i}\)都是合法答案。我们将其累加到\(ans\)中:

\[a_{i}+b_{j}\le m \]

\[其中i \in [1,\frac{mid}{2}],j \in [\frac{mid}{2}+1,n] \]

这里对于\(a\)\(b\)稍作解释。\(a\)\(b\)中的每一个元素代表一种可行的方案的钱数(\(sum\)),比如样例中我们将\(1、2\)\(3、4、5\)分开,对于前组,很明显有两种方案:不买买第一场,那么,我们就往\(a\)\(pushback\)两个答案,很明显,\(a\)\(b\)组合时,答案遵循乘法原理,所以如此合并。

这真是道麻烦至极的\(\color{#52C41A}绿\)题,isn't it?

它是从\(\color{#9D3DCF}{紫}\)掉下来的

千万要认真吸收,吃透

AC Code:

#include<bits/stdc++.h>
using namespace std;
const int maxn=50;
#define ll long long

ll price[maxn];//每场价钱
vector<ll> a;//前半段的搜索结果
vector<ll> b;//后半段的搜索结果
ll ans;//最终答案

void dfs(ll l,ll r,ll sum,vector<ll> &q,ll m)
{
	if(sum>m)return;//买不起了,撤!
	if(l>r)//到达目的地了,撤!
	{
		q.push_back(sum);//把答案扔进去
		return;
	}
	dfs(l+1,r,sum+price[l],q,m);//这一场买了,处理下一场比赛
	dfs(l+1,r,sum,q,m);//这一场没买,处理下一场比赛
}

int main()
{
	ll n=0,m=0;
	cin>>n>>m;
	for(int i=1;i<=n;i++)
	{
		cin>>price[i];
	}
	ll mid=n/2;//折半边界
	dfs(1,mid,0,a,m);//前半段的搜索
	dfs(mid+1,n,0,b,m);//后半段的搜索
	sort(a.begin(),a.end());//我们选择使前半段有序
	for(int i=0;i<b.size();i++)
	{
		ans+=upper_bound(a.begin(),a.end(),m-b[i])-a.begin();//对于后半段结果中的每一个,它对ans的贡献为前半段中与它相加小于m的数个数
	}
	cout<<ans;
	return 0;
}

习题1.3.2

P1379 八数码难题

显然每个九位数的数列都是个状态,我们只要简单地写个单向bfs就可以了。

AC Code:

#include<bits/stdc++.h>
using namespace std;

int matrix[4][4];//模拟的地图
int xs[]={0,0,0,-1,1};//x方向的移动的常量数组
int ys[]={0,1,-1,0,0};//y方向的移动的常量数组
int res=123804765;//目的状态
int sx=0,sy=0;//0在地图中的位置
queue<int> q;//bfs队列
map<int,int> ans;//每个状态(一个九位数)的答案

void inttoma(int n)//数列转矩阵
{
	for(int i=3;i>=1;i--)
	{
		for(int j=3;j>=1;j--)
		{
			matrix[i][j]=n%10;
			n/=10;
			if(matrix[i][j]==0)
			{
				sx=i;
				sy=j;
			}
		}
	}
}

int matoint()//矩阵转数列
{
	int res=0;
	int cnt=1;
	for(int i=3;i>=1;i--)
	{
		for(int j=3;j>=1;j--)
		{
			res+=matrix[i][j]*cnt;
			cnt*=10;
		}
	}
	return res;
}

void dbfs(int s)//双向搜索
{
	q.push(s);//起点入队
	ans[s]=0;//起点距离为0
	while(!q.empty())
	{
		int u=q.front();
		q.pop();
		if(u==res)break;//到达目的状态直接退出
		inttoma(u);//数字转矩阵
		for(int i=1;i<=4;i++)
		{
			int ux=sx+xs[i];//尝试挪动x
			int uy=sy+ys[i];//尝试挪动y
			int n=0;
			if(ux<1||ux>3||uy<1||uy>3)continue;//越界就不动
			swap(matrix[ux][uy],matrix[sx][sy]);//尝试挪动
			n=matoint();//挪动结果转化成数列
			if(!ans.count(n))//在这里,ans[n]==0可以起到vis[n]==0的作用,所以不需要额外的vis数组
			{
				ans[n]=ans[u]+1;//更新距离
				q.push(n);//入队吧
			}
			swap(matrix[ux][uy],matrix[sx][sy]);//恢复现场,以便下次挪动
		}
	}
}

int main()
{
	int s=0;
	cin>>s;
	dbfs(s);
	cout<<ans[res];//res的距离
	return 0;
}

但很明显它出在了双向bfs里面,事实上,这是我能找到的双向bfs的几乎唯一一道例题。还有一道是\(双向A^{*}\),那太难了,有缘再讲。

现在让我们思考本题的双向bfs写法……要不我留作思考题吧

鉴于维护两个bfs队列较为复杂,我们考虑还是只维护一个队列\(q\)和一个\(vis\)数组。你一开始可能会觉得这也差不多,但是 巧妙的处理 在后面。

我们在\(dbfs()\)的开头将两点分别入队,初始化距离,并打标记(本处\(1\)为正向,\(2\)为反向)。然后进入\(while(!q.empty())\)部分。大的要来了。

我们先取队头\(u\),然后出个队,然后——我们设置一个\(buf\),并赋值为\(u\),显然,\(buf\)\(u\)备份

然后,我们将\(u\)转化为矩阵(在此获得\(0\)的位置),尝试一次挪动,并换回来(用于调用标记),此时,\(u\)\(buf\)的关系有且只有三种:

  1. \[u已访问,且与buf同向:vis[u]==vis[buf] \]

  2. \[u已访问,且与buf反向:vis[u]+vis[buf]==3 \]

  3. \[u未访问 \]

对于Case 1,我们搜索下去完全没有意义了(因为有可能继续搜到同向访问过的点),所以恢复现场(方便下次移动),然后直接跳过其他情况。

对于Case 2,显然再走一步,两条搜索路径就会相交,此时起点和终点就有了一条通路,易证此时是符合题意的答案。

对于Case 3,是缺省答案,,既然\(u\)未访问,那就让它的\(vis\)\(buf\)保持一致(表示是\(buf\)的方向搜过来的),距离自然是\(buf\)的加1,最后恢复现场,the end。

另外,如果开始就碰到了终点,记得开头就特判,不然\(testcase 31\)就炸了。

AC Code:

#include<bits/stdc++.h>
using namespace std;

int matrix[4][4];//模拟的地图
int xs[]={0,0,0,-1,1};//x方向的移动的常量数组
int ys[]={0,1,-1,0,0};//y方向的移动的常量数组
int res=123804765;//目的状态
int sx=0,sy=0;//0在地图中的位置
queue<int> q;//bfs队列
map<int,int> ans;//每个状态(一个九位数)的答案
map<int,int> vis;//标记数组,正向碰到了打1,逆向碰到了打2

void inttoma(int n)//数列转矩阵
{
	for(int i=3;i>=1;i--)
	{
		for(int j=3;j>=1;j--)
		{
			matrix[i][j]=n%10;
			n/=10;
			if(matrix[i][j]==0)
			{
				sx=i;
				sy=j;
			}
		}
	}
}

int matoint()//矩阵转数列
{
	int res=0;
	int cnt=1;
	for(int i=3;i>=1;i--)
	{
		for(int j=3;j>=1;j--)
		{
			res+=matrix[i][j]*cnt;
			cnt*=10;
		}
	}
	return res;
}

void dbfs(int s)//双向搜索
{
	if(s==res)//很快啊,直接就到达目的地了
	{
		cout<<0;
		return;
	}
	q.push(s);//起点入队
	q.push(res);//终点入队
	ans[s]=0;//起点距离为0
	ans[res]=0;//终点距离也为0
	vis[s]=1;//起点方向搜索打1标记
	vis[res]=2;//终点方向搜索打2标记
	while(!q.empty())
	{
		int u=q.front();
		int buf=u;//存一份副本,buf是“上一步操作”
		q.pop();
		inttoma(u);//数字转矩阵
		for(int i=1;i<=4;i++)
		{
			int ux=sx+xs[i];//尝试挪动x
			int uy=sy+ys[i];//尝试挪动y
			if(ux<1||ux>3||uy<1||uy>3)continue;//越界就不动
			swap(matrix[ux][uy],matrix[sx][sy]);//尝试挪动
			u=matoint();//挪动结果转化成数列
			if(vis[u]==vis[buf])//如果u和buf已经是一个方向搜索到的,那就算了
			{
				swap(matrix[ux][uy],matrix[sx][sy]);//挪回来
				continue;//下一位!!
			}
			else if(vis[u]+vis[buf]==3)//因为一个打了1标记,一个打了2标记,如果它们相加为3,下一步必然相遇,再加1就可以输出了
			{
				cout<<ans[u]+ans[buf]+1;//因为二者是相反方向搜索过来的,所以二者到各自起始节点的距离相加,再加最后交融的1步,就是起点到终点的路径
				return;
			}
			else
			{
				ans[u]=ans[buf]+1;//默认情况,u是新开发地带,buf已被访问,自然加1
				vis[u]=vis[buf];//保证u和buf是一个方向搜索的
				q.push(u);//vis都打了,入队吧
				swap(matrix[ux][uy],matrix[sx][sy]);//恢复现场,以便下次挪动
			}
		}
	}
}

int main()
{
	int s=0;
	cin>>s;
	dbfs(s);
	return 0;
}

这道题同时是单向bfs双向bfs的经典例题,且数字/字符串←→二维数组的思想是状态压缩类型动态规划的基础,一定要掌握牢实。

关于双队列\(version\),那就是真正的思考题了(逃)

2.分块初步

网上面将分块称之为“优雅的暴力”优雅个鬼

常用来解决\(10^5\)这一级数据规模的区间查找与修改问题,所以是线段树树套树的同类算法

分块码量跟线段树差不多,其实思维也不比线段树容易理解多少,但是奇葩的事实是,分块作为平均\(O(n\sqrt{n})\)的算法,居然能够通过卡平均\(O(nlogn)\)的线段树的数据。这就是我们学它的意义吧。

有一个重要前提: 分块的具体操作(查询题目需要的数据)必须是\(O(1)\)的,如果不是,莫队可以二次离线,分块暂时没辙

分块就三个操作,初始化、区间修改、区间查询

初始化:我们一般将块的大小设计为\(\sqrt{n}\)\(n\)无法整除\(n\),则统计块的数目时记得加1。 我们设数组\(l、r、belong\)分别表示每个块的左右端点号和每个数组元素归属块的编号,我们在此不加证明的给出三个数组元素的初始化公式。

\[l[i]=(i-1)*块的长度+1 \]

\[r[i]=i*块的长度 \]

\[block[i]=\frac{i-1}{块的长度}+1 \]

最后,如果有需要,将每个块的内部进行排序。

我们再设原数据储存在\(arr\)数组,\(cpy\)数组用于备份,在 将数据读入到\(arr\)初始化 之间,应将\(arr\)的内容转移到\(cpy\)里,使用 \(for\)逐个赋值和\(memcpy()\)均可。

区间修改:我们将待修改区间\([l,r]\)分为三个部分

  1. \[[x,r[belong[x]] \]

  2. \[[r[belong[x]+1,l[belong[y]]-1 \]

  3. \[l[belong[y]],y] \]

对于Case 1&&3,我们直接暴力修改即可。

对于Case 2,我们显然可以偷懒,因为这是整块整块的区间,显然设个\(lazy\)数组存放懒标记就可以了。梦回线段树

在每种修改后,你都需要\(arr\)的内容及时更新到\(cpy\),并且\(x\)\(y\)所在的块进行排序。(中间的块是整体改的,还在\(lazy\)里当懒标记,没必要重新整一遍)

区间查询:类似的,我们可以将区间\([x,y]\)分作相同的三部分。Case 1&&3还是一样的暴力,对于Case2,我们可以在\(belong[x]+1\)\(belong[y]-1\)两块之间内部查询。内部查询的实现依赖于具体问题,本处略去。三部分的答案相加即为查询解。

设询问数\(q\),数据大小\(n\),由于暴力修改的时间基本为常数,我们可以考虑忽略。(这是基本前提)所以在块大小为\(\sqrt{n}\)的情况下,区间修改的时间复杂度为\(O(1)\)区间查询的时间复杂度为\(O(q\sqrt{n})\)

分块将大小设为\(\sqrt{n}\)被卡的情况,你设成\(\sqrt{n}\pm 1\)差不多就得了,设成\(\sqrt{ \frac{n}{lgn}}\)的极为少见

习题2.1.1

P2801 教主的魔法

窃以为这才是真的板子题

请留意讨论区提示,不要尝试使用线段树通过此题

AC Code:

#include<bits/stdc++.h>
using namespace std;
const int maxn=1e6+5;

int arr[maxn];
int block=0;
int tot=0;
int l[maxn];
int r[maxn];
int belong[maxn];
int lazy[maxn];
int cpy[maxn];

void init(int n)
{
	block=(int)sqrt(n);
	tot=n/block;
	if(n%block!=0)tot++;
	for(int i=1;i<=tot;i++)
	{
		l[i]=(i-1)*block+1;
		r[i]=i*block;
	}
	r[tot]=n;
	for(int i=1;i<=n;i++)
	{
		belong[i]=(i-1)/block+1;
	}
	for(int i=1;i<=tot;i++)
	{
		sort(cpy+l[i],cpy+r[i]+1);
	}
}

void add(int x,int y,int k)
{
	if(belong[x]==belong[y])
	{
		for(int i=x;i<=y;i++)
		{
			arr[i]+=k;
		}
		for(int i=l[belong[x]];i<=r[belong[y]];i++)
		{
			cpy[i]=arr[i];
		}
		sort(cpy+l[belong[x]],cpy+r[belong[x]]+1);
		return;
	}
	else
	{
		for(int i=x;i<=r[belong[x]];i++)
		{
			arr[i]+=k;
		}
		for(int i=l[belong[x]];i<=r[belong[x]];i++)
		{
			cpy[i]=arr[i];
		}
		sort(cpy+l[belong[x]],cpy+r[belong[x]]+1);
		for(int i=l[belong[y]];i<=y;i++)
		{
			arr[i]+=k;
		}
		for(int i=l[belong[y]];i<=r[belong[y]];i++)
		{
			cpy[i]=arr[i];
		}
		sort(cpy+l[belong[y]],cpy+r[belong[y]]+1);
		for(int i=belong[x]+1;i<=belong[y]-1;i++)
		{
			lazy[i]+=k;
		}
	}
}

void query(int x,int y,int k)
{
	int ans=0;
	if(belong[x]==belong[y])
	{
		for(int i=x;i<=y;i++)
		{
			if(lazy[belong[i]]+arr[i]>=k)ans++;
		}
		cout<<ans<<endl;
		return;
	}
	for(int i=x;i<=r[belong[x]];i++)
	{
		if(lazy[belong[x]]+arr[i]>=k)ans++;
	}
	for(int i=l[belong[y]];i<=y;i++)
	{
		if(lazy[belong[y]]+arr[i]>=k)ans++;
	}
	for(int i=belong[x]+1;i<=belong[y]-1;i++)
	{
		int ll=l[i],rr=r[i],mid=0,res=0;
		while(ll<=rr)
		{
			mid=(ll+rr)/2;
			if(cpy[mid]+lazy[i]>=k)
			{
				rr=mid-1;
				res=r[i]-mid+1;
			}
			else
			{
				ll=mid+1;
			}
		}
		ans+=res;
	}
	cout<<ans<<endl;
}

int main()
{
	int n=0,q=0;
	cin>>n>>q;
	for(int i=1;i<=n;i++)
	{
		cin>>arr[i];
	}
	memcpy(cpy,arr,sizeof(arr));
	init(n);
	char op;
	int a,b,c;
	for(int i=1;i<=q;i++)
	{
		cin>>op>>a>>b>>c;
		if(op=='M')add(a,b,c);
		if(op=='A')query2(a,b,c);
	}
	return 0;
}

对于分块预处理数组的构建,我觉得还是很难的,要多练一下。

分块的模板好打的锂谱,但有很多时候,你会卡在不知道维护什么数组上

但是那种题我也不会做,所以再来做道简单题吧

习题2.1.2

P2357 守墓人

模板题\(See\) \(you\) \(again\),相信你不看代码就能\(\color{#52C41A}{Accepted!!!}\)

但是呢,这题有所提示,输入不超过64位整数

十年OI一场空,不开\(longlong\)见祖宗

rp++

而且这题十分玄学,你甚至可以不初始化,直接就处理询问,只要\(update()\)里不给区间和加上应有的\(k\),你甚至可以拿\(\color{#52C41A}{80分的高分!!!}\) \(\color{#052242}{with}\) \(\color{#052242}{testcase21}\) \(\color{#052242}{TLE}\),可能是数据出锅了 😦

你可以观察到,我们在读入每个数后立马处理区间和,这样可以在\(init()\)内省去二重循环,卡常小trick get

AC Code:

#include<bits/stdc++.h>
using namespace std;
const int maxn=2e5+7;
#define int long long

int arr[maxn];
int l[maxn];
int r[maxn];
int belong[maxn];
int cntb[maxn];
int lazy[maxn];
int block=0;
int tot=0;

void init(int n)
{
	block=sqrt(n);
	tot=n/block;
	if(n%block!=0)tot++;
	for(int i=1;i<=tot;i++)
	{
		l[i]=(i-1)*block+1;
		r[i]=i*block;
	}
	r[tot]=n;
	for(int i=1;i<=n;i++)
	{
		belong[i]=(i-1)/block+1;
	}
}

void update(int x,int y,int k)
{
	if(belong[x]==belong[y])
	{
		for(int i=x;i<=y;i++)
		{
			arr[i]+=k;
			cntb[belong[i]]+=k;
		}
		return;
	}
	for(int i=x;i<=r[belong[x]];i++)
	{
		arr[i]+=k;
		cntb[belong[i]]+=k;
	}
	for(int i=l[belong[y]];i<=y;i++)
	{
		arr[i]+=k;
		cntb[belong[i]]+=k;
	}
	for(int i=belong[x]+1;i<=belong[y]-1;i++)
	{
		lazy[i]+=k;
		cntb[i]+=k*(r[i]-l[i]+1);
	}
}

int query(int x,int y)
{
	int res=0;
	if(belong[x]==belong[y])
	{
		for(int i=x;i<=y;i++)
		{
			res+=lazy[belong[i]]+arr[i];
		}
		return res;
	}
	for(int i=x;i<=r[belong[x]];i++)
	{
		res+=lazy[belong[i]]+arr[i];
	}
	for(int i=l[belong[y]];i<=y;i++)
	{
		res+=lazy[belong[i]]+arr[i];
	}
	for(int i=belong[x]+1;i<=belong[y]-1;i++)
	{
		res+=cntb[i];
	}
	return res;
}

signed main()
{
	ios::sync_with_stdio(false);
	cin.tie(0);
	cout.tie(0);
	int n=0,m=0,op=0,x=0,y=0,k=0;
	cin>>n>>m;
	init(n);
	for(int i=1;i<=n;i++)
	{
		cin>>arr[i];
		cntb[belong[i]]+=arr[i];
	}
	for(int i=1;i<=m;i++)
	{
		cin>>op;
		if(op==1)
		{
			cin>>x>>y>>k;
			update(x,y,k);
		}
		else if(op==2)
		{
			cin>>k;
			update(1,1,k);
		}
		else if(op==3)
		{
			cin>>k;
			update(1,1,-k);
		}
		else if(op==4)
		{
			cin>>x>>y;
			cout<<query(x,y);
			if(i<m)cout<<endl;
		}
		else if(op==5)
		{
			cout<<query(1,1);
			if(i<m)cout<<endl;
		}
	}
	return 0;
}

但是我们不能将思维停留在这种简单题上面,接下来来看一道之前说过的,怎么建立数组分块的题目。

习题2.1.3

P4135 作诗

这道题真的不是分块模板题!就算是,也有点难度,起码对你的前缀和思想考查要求很高。

一个很麻烦的事情是本题要维护的信息(询问区间内出现次数为偶数的数的个数)并不满足区间可加性,举个例子:

至于本题,对于一个长度为\(10\)的原数组,我们查询\([4,9]\)的答案,按\(\sqrt n\)分块的话,是两个整区间\([4,6]\)\([7,9]\),没有散块,最简单的情况。

假如在\([4,6]\)里,\(3\)出现了\(1\)次,在\([7,9]\)里则出现了\(3\)次,合到一起就是\(4\)次,对答案产生了贡献,加进去是吧?模板分块下,你根本预判不到这种情况,除非愿意退化为暴力扫。

这就叫做不满足区间可加性,那让我们想想办法,把分块改造一下。

首先把整块的情况处理出来,开一个二维数组\(ans[i][j]\),表示第\(i\)块到第\(j\)块的答案。这个部分在初始化里就可以处理出来,设个\(bufcnt[maxn]\),表示一个特定区间内\(i\)的出现次数。

  • 首先搞个\(i\)\(j\)的二重循环,因为设定原因,\(j\)直接从\(i\)开始就可以了,没问题吧。

  • 然后显然初始时\(ans[i][j]=ans[i][j-1]\)

  • 然后再开始统计这个块的答案,直接在\(ans[i][j]\)上修改,就根据\(bufcnt\)的结果修改就可以。

  • 为什么设个\(bufcnt\)就可以了呢,我们主要是初始化答案时要做到块内数据可合并,只要\(bufcnt\)不清空,我们就可以实现这个目的。你搞过工程的话,你可能会觉得这是一个给块统计答案的数据池,我觉得也是的。

  • 由于我们为了共享数据,又不是多测\(bufcnt\)\(j\)循环内不用清空,只要在\(i\)循环内清空就行了,起点/左边界变了嘛,难道你又从最左边删起?编码复杂度角度考虑肯定不会这样的。

然后查询\([x,y]\)时初值就是

\[ans[belong[x]+1][belong[y]-1] \]

现在再来考虑散块,我们之前的一个痛点就是区间合并时无法预测答案是否受到额外贡献,为此,我们再设立一个\(cnt[i][j]\)数组,用来统计\(1到i\)块中\(j\)的出现个数,那么,我们扫描散块时,每个数的出现次数就从循环内统计的次数变成还要加上整块内的次数,剩余的技巧就和初始化\(ans\)差不多了。同理可得两个散块的统计之间也不需要清空。

容易发现其实\(ans\)(如果你改成一维的话,可能写个inline查询函数可以改善马蜂,就像下面的\(cnt\)一样)和\(cnt\)都是可差分的,所以整块内的数据就很好查询了,最后,输入输出是加密的,小心一点。

这就是不好直接分块的题目的一个常用办法:将每块答案用前缀和和差分处理。再用数据共享数组辅助更新答案。很牛逼吧。

但是线段树的话,这个技巧的实现就有点复杂了,这就是为什么讨论区里没有人喊线段树怎么做的原因吧(如果线段树能打破我们的痛点,那这题就太板了,不能评紫了QAQ

AC Code:

#include<bits/stdc++.h>
using namespace std;
const int maxn=1e5+7;
const int sqrtn=5e3+5;

int arr[maxn];//原数组
int cnt[sqrtn][maxn];//区间1到i中x出现次数
int ans[sqrtn][sqrtn];//区间i到j的答案
int l[sqrtn];
int r[sqrtn];
int belong[maxn];
int block=0;
int tot=0;
int bufcnt[maxn];//暴力扫辅助

inline int getnum(int l,int r,int x)//差分求出块l到块r的x个数
{
	return cnt[r][x]-cnt[l-1][x];
}

void init(int n,int c)
{
	block=sqrt(n);
	tot=n/block;
	if(n%block!=0)tot++;
	for(int i=1;i<=tot;i++)
	{
		l[i]=(i-1)*block+1;
		r[i]=(i==tot)?n:(i*block);
	}
	for(int i=1;i<=tot;i++)
	{
		for(int j=l[i];j<=r[i];j++)
		{
			belong[j]=i;
			cnt[i][arr[j]]++;
		}
		for(int j=0;j<=c;j++)
		{
			cnt[i][j]+=cnt[i-1][j];
		}
	}
	for(int i=1;i<=tot;i++)
	{
		for(int j=i;j<=tot;j++)
		{
			ans[i][j]=ans[i][j-1];
			for(int k=l[j];k<=r[j];k++)
			{
				bufcnt[arr[k]]++;
				int buf=bufcnt[arr[k]];
				if(buf%2==0)ans[i][j]++;
				else if(buf!=1)ans[i][j]--;
			}
		}
		for(int j=0;j<=c;j++)
		{
			bufcnt[j]=0;
		}
	}
}

int query(int x,int y)
{
	int res=0;
	if(belong[y]-belong[x]<=1)
	{
		for(int i=x;i<=y;i++)
		{
			bufcnt[arr[i]]++;
			int buf=bufcnt[arr[i]];
			if(buf%2==0)res++;
			else if(buf!=1)res--;
		}
		for(int i=x;i<=y;i++)
		{
			bufcnt[arr[i]]--;
		}
		return res;
	}
	else
	{
		res=ans[belong[x]+1][belong[y]-1];
		for(int i=x;i<=r[belong[x]];i++)
		{
			bufcnt[arr[i]]++;
			int buf=bufcnt[arr[i]]+getnum(belong[x]+1,belong[y]-1,arr[i]);
			if(buf%2==0)res++;
			else if(buf!=1)res--;
		}
		for(int i=l[belong[y]];i<=y;i++)
		{
			bufcnt[arr[i]]++;
			int buf=bufcnt[arr[i]]+getnum(belong[x]+1,belong[y]-1,arr[i]);
			if(buf%2==0)res++;
			else if(buf!=1)res--;
		}
		for(int i=x;i<=r[belong[x]];i++)
		{
			bufcnt[arr[i]]--;
		}
		for(int i=l[belong[y]];i<=y;i++)
		{
			bufcnt[arr[i]]--;
		}
		return res;
	}
}

int main()
{
	int n=0,c=0,m=0,last=0,ll=0,rr=0;
	cin>>n>>c>>m;
	for(int i=1;i<=n;i++)
	{
		cin>>arr[i];
	}
	init(n,c);
	for(int i=1;i<=m;i++)
	{
		cin>>ll>>rr;
		ll=(ll+last)%n+1;
		rr=(rr+last)%n+1;
		if(ll>rr)swap(ll,rr);
		//cout<<ll<<' '<<rr<<endl;
		last=query(ll,rr);
		cout<<last;
		if(i<m)cout<<endl;
	}
	return 0;
}

3.莫队简介

本篇不涉及莫队的在线化改造

莫队算法是一位叫莫涛的聚铑在\(2009\)年以\(\color{#52C41A}{577的高分}\)斩获\(NOI\) \(\color{#FFD700}{Au}\)后在国家集训队作业论文中提出的。他本人也承认当时其实\(Codefoces\)上有很多神犇早就会写了,但莫涛确实是第一个对该算法进行系统整理的人,这在科学上极为重要。为了表彰他的卓越贡献,我们现在都将其称为莫队算法。

顺便大声喊一声:莫涛聚铑高中就读于长沙市长郡中学!

不喜欢郡系,但喜欢莫涛

我的名言

值得一提的是,还有一位聚铑,名叫范浩强,是中国人民大学附属中学的选手,他在同年\(NOI\)斩获二等,于次年斩获一等,一路打到\(NOI2012\)都是\(\color{#FFD700}{Au}\)大满贯。他是\(fhq-treap/无旋treap\)的发明者,这个数据结构有缘再讲。

更值得一提的是,我们雅礼集团早在\(2007\)年就已经有\(插头DP\)\(cdq分治\)的发明者陈丹琦学姐(长沙市雅礼中学)进队了虽然Ag,郡系还是慢了一步啊。乐死了。

其实给莫队算法起个名字还是很重要的,你说他是暴力&&剪枝,它比\(dfs/bfs\)抽象多了;说它是双指针、滑动窗口,都有点影子。所以干脆叫莫队,一了百了。

另外,虽然很奇葩,但:

莫队算法是主席树(可持久化线段树)的替代算法

这句话很重要,它几乎成为莫队各种改进的指导思想。

3.1 普通莫队

我插一句,只有这个version是莫队本队提出的

现在我们进入正题,莫队怎么写?

一个典型的母题是:

  • 有个长度为\(n\)的数组\(arr\),一般\(1 \le n\le 2\times10^{5}\)

  • 要你处理\(q\)\([l_{i},r_{i}]\)区间的查询,范围一般同\(n\)

  • 可以离线(提前获取所有数据)。

我们当然可以写暴力骗分对拍,时间复杂度显然是\(O(q(l+r))\),最坏情况下直接打到\(O(nq)\),约\(4\times 10^{10}\),显然\(\color{#E74C3C}{Unaccepted}\)

我们大多数情况下也可以写主席树,但我们假装不行

我们此时可以构想,有两个“指针”指着数组下标,每次获取一个查询区间,然后不断将“指针”一个位置一个位置挪至对应的左右端点,每挪动一个位置就计算该位置对答案的贡献并累加或删除。这里的位置指数组的“格子”。 我们可以将累加或删除抽象到\(add()\)\(del()\)两个函数里。

设左/右指针为\(l,r\),左右端点为\(l_{i},r_{i}\)

我们在这里对指针与区间端点的位置关系及Solution进行讨论,这四个情况的判断代码的顺序似乎关系不大……有点绕,请大家认真体会:

  1. 左指针在左端点左边:\(l < l_{i}\)

    此时,左边显然对答案做了过多的贡献,所以要减去多余部分,可使用\(while\)来控制\(del(l++)\)

  2. 左指针在左端点右边:\(l > l_{i}\)

    此时,左边对答案的贡献还有一部分没被统计到,要加上这一部分:\(del(l++)\)

  3. 右指针在右端点右边:\(r > r_{i}\)

    此时,右边对答案造成了多余贡献,减去:\(del(r--)\)

  4. 右指针在右端点左边:\(r < r_{i}\)

    此时,右边的贡献少了,加上:\(add(r++)\)

答案的输出需要依照原顺序,所以对于区间,我们建个结构体维护,其中还要记录原始位置,存进答案数组时按照原数组的位置存。

你说,是不是很像滑动窗口或双指针?

在这种算法中,看不出对时间复杂度有什么优化,但我们可以将前面分块的\(init()\)搬过来,然后先按左端点大小,后按右端点大小来将查询区间排序,这样就可以一个块一个块的处理,减少“指针”挪动距离。

假设我们以\(\sqrt{n}\)的大小建块,显然此时单次查询已经降到了最坏\(O(\sqrt{n})\)的地步,总时间复杂度为\(O(n\sqrt{n})\),关于排序的时间,因为\(O(nlogn)<<O(n\sqrt{n})\),所以可以忽略不计。

此处的排序必须要获取所有数据,所以莫队算法是离线的。

关于奇偶化排序这一小trick的证明,我太菜,讲不了一点,看普通莫队的代码吧。

习题3.1.1

P2709 小B的询问

应该算是现存的模板题,注意累加的是\(c_{i}^{2}\),不是整体累加再平方。

还有一个有趣的地方,关于答案输出的部分,可以写完后和前面分块的代码对照一下,思考一下:为什么莫队只能离线,而分块可以在线?

AC Code:

#include<bits/stdc++.h>
using namespace std;
const int maxn=5e4+5;

int arr[maxn];
int cnt[maxn];
int belong[maxn];
int ans[maxn];
int block=0;
int tot=0;
int l=1;
int r=0;
int res=0;

struct interval
{
	int l;
	int r;
	int id;
};
interval querys[maxn];

bool operator <(interval a,interval 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;
}

void init(int n,int k)
{
	block=sqrt(n);
	tot=n/block;
	if(n%block!=0)tot++;
	for(int i=1;i<=n;i++)
	{
		belong[i]=(i-1)/block+1;
	}
	sort(querys+1,querys+k+1);
}

void add(int pos)
{
	res-=cnt[arr[pos]]*cnt[arr[pos]];
	cnt[arr[pos]]++;
	res+=cnt[arr[pos]]*cnt[arr[pos]];
}

void del(int pos)
{
	res-=cnt[arr[pos]]*cnt[arr[pos]];
	cnt[arr[pos]]--;
	res+=cnt[arr[pos]]*cnt[arr[pos]];
}

int main()
{
	int n=0,m=0,k=0;
	cin>>n>>m>>k;
	for(int i=1;i<=n;i++)
	{
		cin>>arr[i];
	}
	for(int i=1;i<=m;i++)
	{
		cin>>querys[i].l>>querys[i].r;
		querys[i].id=i;
	}
	init(n,m);
	for(int i=1;i<=m;i++)
	{
		while(l<querys[i].l)del(l++);
		while(l>querys[i].l)add(--l);
		while(r<querys[i].r)add(++r);
		while(r>querys[i].r)del(r--);
		ans[querys[i].id]=res;
	}
	for(int i=1;i<=m;i++)
	{
		cout<<ans[i];
		if(i<m)cout<<endl;
	}
	return 0;
}

简短好记,起码比主席树好写多了。

这基本上就最简短了,再压行会不便于调试的。

3.2 带修莫队

我们发现,莫队将输入的区间排好序后,无法再更改,事实上,也不好更改。因为莫队本身只支持查询。为什么呢?你想,我们事先对所有查询进行了\(sort()\),本来查询和修改就是乱的,你还在这里改数据就是添乱! 其实你当初写\(add()\)\(del()\)时改的也是\(cnt\),并未“动真格”。

但人家主席树可是带修的,莫队气不过,所以也勉勉强强,支持一下单点修改。

你别说还真的可以

我们在\(l,r\)两个指针的基础上,再添加一个指针\(t\),它指向谁呢?就是储存的查询操作。

现在你可以将整个题目数据想象成一个二维坐标系,数据沿横轴展开,而纵轴是修改操作。

如果像其他文章里写的那样,硬是把\(l\)\(r\)两个指针看成一个二维坐标系的话,那你加上\(t\)时间指针,就相当于是个三维坐标系就行了。没什么大不了的

我们着重理解一下修改操作。为了保证形式一致,我们也把它抽象到一个\(travel()\)函数里,因为它有种说法叫“时间漫游”。

我们首先考虑当前修改次数\(t\)在不在区间\([l,r]\)里,如果在的话,那就减去要修改位置本来的值,再加上修改的目标值,这里的增删可使用\(add()、del()\).

然后,我们就交换一下数组里的要修改位置的值和修改操作里的目标值。最后,别忘了移动\(t\),我们的时间显然从\(t=0\)为起点,向未来是\(t++\),回忆往昔是\(t--\)。大功告成。

这里为什么要执行交换操作呢?注意到前面的\(add()\)\(del()\)都只修改了(也只能修改)\(cnt\)数组,而没有对\(arr\)数组进行实质性的修改。这样的话,如果不交换一下,若要撤回修改操作(当\(t\)所在的时间在本次查询之前,别笑,你对查询进行了排序就肯定有这样的情况),你模拟一遍,是不是啥也没做?

交换操作的用处就在这里,它动了一下“真格”,看似打破了莫队的理论基石,其实是保护了\(arr\)原来的值,以防止为效率对查询进行排序使修改和查询的时间顺序错乱,从而\(WA\),而添加交换以后,我们在前面一遍的啥也没做之后,就可以安心把数据恢复回来。妙啊!!!

所以你会发现为什么带修莫队只支持单点修改?因为它只有一个\(t\)指针在“时间”维度上跑来跑去,而区间修改别无他法,只能暴力扫一遍,直接给你的复杂度再套一个\(n\)变为\(O(n^2 \sqrt {n)}\)你比拿\(O(n^2)\)的暴力尝试跑\(10^7\)更勇啊

习题3.2.1

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

上面讲解的差不多了,这里注意一些细节,比如前四个\(while()\)里传的参数和普通莫队的有些区别,主要是为了适配\(travel()\)

如果你发现前四个\(while()\)和普通莫队不同时,It doesn't matter,是我调不过时看资料乱改的,现在懒得改回来了,汗。

AC Code:

#include<bits/stdc++.h>
using namespace std;
const int maxn=2e6;

int arr[maxn];
int cnt[maxn];
int belong[maxn];
int ans[maxn];
int block=0;
int tot=0;
int l=1;
int r=0;
int t=0;
int res=0;

struct interval
{
	int l;
	int r;
	int time;
	int id;
};
interval querys[maxn];

struct modify
{
	int pos;
	int val;
};
modify ops[maxn];

bool operator <(interval a,interval b)
{
	if(belong[a.l]!=belong[b.l])return belong[a.l]<belong[b.l];
	else if(belong[a.r]!=belong[b.r])return belong[a.r]<belong[b.r];
	else return a.time<b.time;
}

void add(int pos)
{
	if(cnt[pos]==0)res++;
	cnt[pos]++;
}

void del(int pos)
{
	cnt[pos]--;
	if(cnt[pos]==0)res--;
}

void travel(int poss,int i)
{
	if(ops[poss].pos>=querys[i].l&&ops[poss].pos<=querys[i].r)
	{
		del(arr[ops[poss].pos]);
		add(ops[poss].val);
	}
	swap(ops[poss].val,arr[ops[poss].pos]);
}

int main()
{
	ios::sync_with_stdio(false);
	cin.tie(0);
	cout.tie(0);
	int n=0,m=0,often=0,qs=0;
	char op;
	cin>>n>>m;
	block=pow(n,2.0/3.0);
	for(int i=1;i<=n;i++)
	{
		cin>>arr[i];
		belong[i]=(i-1)/block+1;
	}
	for(int i=1;i<=m;i++)
	{
		cin>>op;
		if(op=='Q')
		{
			qs++;
			cin>>querys[qs].l>>querys[qs].r;
			querys[qs].id=qs;
			querys[qs].time=often;
		}
		if(op=='R')
		{
			often++;
			cin>>ops[often].pos>>ops[often].val;
		}
	}
	sort(querys+1,querys+qs+1);
	for(int i=1;i<=qs;i++)
	{
		while(l>querys[i].l)add(arr[--l]);
		while(r>querys[i].r)del(arr[r--]);
		while(r<querys[i].r)add(arr[++r]);
		while(l<querys[i].l)del(arr[l++]);
		while(t<querys[i].time)travel(++t,i);
		while(t>querys[i].time)travel(t--,i);
		ans[querys[i].id]=res;
	}
	for(int i=1;i<=qs;i++)
	{
		cout<<ans[i];
		if(i<qs)cout<<endl;
	}
	return 0;
}

关于这道题的花式问题,This may help

国集的题能给点面子嘛,就一个蓝,砹

我想水紫题

3.3 回滚/不删除/不增加莫队

一般来说,回滚莫队是针对如下情况的莫队改进的:

\(add()\)\(del()\)函数中,有一个实现的复杂度非常大

有一个母题:求查询区间的众数

如果要用莫队做这个题,\(add()\)非常好编写,只要\(cnt++\),然后和当前的最大值打擂台,就可以了

\(del()\)函数中,我们删除了一个数后,与谁打擂台显然不清楚。要不然就暴力扫一遍\(cnt\),那显然不现实,搞不好\(O(n)\)

在这种情况下,我们就使用回滚莫队来回避\(del()\)的编写,其实还是和带修莫队一样“打假拳”

我们思考一下,怎么做能够最大程度减少复杂度大的那一个操作?

对于不删除的情况,你想啊,如果我们保证同一个块内,左端点不管,右端点是单调递增的,那我们对于右指针的删除操作是不是就可以免了?不错啊,成功了一半。

接下来是左指针,我们想办法让询问的左端点按分块的顺序排序(你应该熟悉分块顺序),使左端点所在块编号小的就排在前面处理,那左端点在大部分情况下是不是也可以不用删除操作了?好,基本成功了。所以这里不能使用奇偶化排序的卡常技巧,因为有可能右端点会因为依照奇偶排序而不能单调递增,让你\(\color{#E74C3C}{WA}\)掉。

既然分了块,那当我们移动指针时,就可以考虑一块一块的处理询问(好像讲过了qwq),然后新到每个块时,就将\(l\)放在这个块的右端点,而\(r\)是莫队老惯例了,放在\(l-1\)的位置哦。

然后我们分三个阶段搜索:

  1. \(r\)向右搜索到询问右端点
  2. \(l\)向左搜索到询问左端点
  3. \(l\)回到原来初始化的位置,为下组同块的询问做准备(回滚)

琢磨一下,是不是只有3中涉及到删除操作了?而且告诉你吧,这个操作只要清空一个单独开的数组,删一次是\(O(1)\)的,满足莫队聚铑的心意

我们开\(first、last、last2\)三个数组维护,它们分别代表:

  • 每个元素出现的第一个位置
  • 每个元素出现的最后一个位置
  • 在询问左端点到\(l\)初始化位置的\(last\)功能

对于Case 1,显然我们移动时,对于每个位置的元素\(first\)只用修改一次(第一次嘛),但\(last\)要实时更新(最后一个位置),然后根据题目处理。

对于Case 2,虽然我们更改的也是\(last2\)数组,但是我们必须和\(last\)里相同位置的数据比较一下,根据题意取舍。因为同块内的查询数据显然是可以共享的,所以懒得清空\(first\)\(last\),但这样的话\(l\)走过的\(last2\)\(r\)也可能记录在\(last\)里了,这就不能不比较一下。

对于Case 3,\(l\)每滚一个位置,就把\(last2\)的该位置清空,因为同块内查找,\(last2\)的数据也有可能不同,所以不能等到换块了再清。显然这个“删除”是\(O(1)\)的,good

对于询问端点同块的情况,那你还是暴力吧,\(l\)不用动,只动\(r\)\(first\)就行。\(last\)不动吗,最后一个位置显然是\(r\)所在的地方嘛。

注意\(r\)移动前后\(first\)都要清空,因为都在块内,显然不是整块,不能再使用整块的数据了。

不增加的题目也有,但构造增加不为\(O(1)\)且比删除困难的情况太难了,所以现阶段只要掌握不删除的就行了,不增加的,除了使每个询问右端点单调递减以外,区别几乎没有。

习题3.3.1

P5906 【模板】回滚莫队&不删除莫队

虽然你们很喜欢把另一道题作为回滚莫队模板题,但我不喜欢RemoteJudge的题,它们编号不一样,打乱了通过题号的队形,所以用这道吧。qwq

正经模板题就是不一样,实现细节我都没什么好讲的了\((doge)\)

哦,你看这变量多的,给算法发明者点赞!

AC Code:

#include<bits/stdc++.h>
using namespace std;
const int maxn=5e5+5;

int arr[maxn];//原数组、离散化后数组
int belong[maxn];//每个元素储存的块
int rs[maxn];//每个块的右端点
int ans[maxn];//每个询问的答案
int first[maxn];//每个元素在每个询问区间第一个出现的位置
int last[maxn];//每个元素在每个询问区间出现的最后一个位置
int last2[maxn];//每个元素在(询问区间-当前块)范围最后一个出现的值
int cpy[maxn];//离散化辅助数组
int lastblock=0;//上一个块编号
int block=0;//块大小
int tot=0;//块数量
int l=0;//左指针
int r=0;//右指针
int res=0;//每次询问的结果

struct interval//询问区间
{
	int l;
	int r;
	int id;
};
interval querys[maxn];//询问区间数组

bool operator <(interval a,interval b)
{
	if(belong[a.l]!=belong[b.l])return a.l<b.l;
	else return a.r<b.r;
}

int main()
{
	int n=0,m=0;
	cin>>n;
	for(int i=1;i<=n;i++)
	{
		cin>>arr[i];
		cpy[i]=arr[i];//离散化
	}
	sort(cpy+1,cpy+n+1);
	int len=unique(cpy+1,cpy+n+1)-(cpy+1);
	for(int i=1;i<=n;i++)
	{
		arr[i]=lower_bound(cpy+1,cpy+len+1,arr[i])-cpy;
	}//离散化end
	block=sqrt(n);//分块
	tot=n/block;
	if(n%block!=0)tot++;
	for(int i=1;i<=n;i++)
	{
		belong[i]=(i-1)/block+1;
	}
	for(int i=1;i<=tot;i++)
	{
		rs[i]=i*block;
	}
	rs[tot]=n;//分块end
	cin>>m;
	for(int i=1;i<=m;i++)
	{
		cin>>querys[i].l>>querys[i].r;
		querys[i].id=i;
	}
	sort(querys+1,querys+m+1);
	for(int i=1;i<=m;i++)
	{
		if(belong[querys[i].l]==belong[querys[i].r])
		{
			res=0;
			for(int j=querys[i].l;j<=querys[i].r;j++)
			{
				first[arr[j]]=0;
			}
			for(int j=querys[i].l;j<=querys[i].r;j++)
			{
				if(first[arr[j]]==0)first[arr[j]]=j;
				else
				{
					res=max(res,j-first[arr[j]]);
				}
			}
			for(int j=querys[i].l;j<=querys[i].r;j++)
			{
				first[arr[j]]=0;
			}
			ans[querys[i].id]=res;
			continue;
		}
		int nowblock=belong[querys[i].l];
		if(lastblock!=nowblock)
		{
			res=0;
			for(int j=l;j<=r;j++)
			{
				first[arr[j]]=last[arr[j]]=0;
			}
			l=rs[nowblock];
			r=l-1;
			lastblock=nowblock;
		}
		while(r<querys[i].r)
		{
			r++;
			if(first[arr[r]]==0)
			{
				first[arr[r]]=r;
			}
			last[arr[r]]=r;
			res=max(res,r-first[arr[r]]);
		}
		int p=l,tmp=0;
		while(p>querys[i].l)
		{
			p--;
			if(last2[arr[p]]==0)last2[arr[p]]=p;
			tmp=max(tmp,max(last[arr[p]],last2[arr[p]])-p);
		}
		while(p<l)
		{
			last2[arr[p]]=0;
			p++;
		}
		ans[querys[i].id]=max(res,tmp);
	}
	for(int i=1;i<=m;i++)
	{
		cout<<ans[i];
		if(i<m)cout<<endl;
	}
	return 0;
}

想水的紫题终于到手了

请读者注意:莫队算法一般来说掌握普通普队就够了,如果本文3.2和3.3的知识学的吃力的话,先去学线段树和平衡树等同样热门的知识点可能有所帮助。因为莫队是它们的替代算法。

2024/8/20 01:00:00 初稿!完结撒花!!!(发布在洛谷)