算法思维/优化

目录

搜索

深度优先搜索

题目来源:小木棍

广度优先搜索

题目来源:棋盘

题目来源:引水入城

双向搜索/折半搜索

题目来源:世界冰球锦标赛

题目来源:Balanced Cow Subsets G

A*/迭代加深搜索/IDA*

题目来源:八数码难题

逆序对

题目来源:逆序对[模板]

题目来源:火柴排队

倍增

题目来源:Fountain

离散化

题目来源:程序自动分析

单调栈

题目来源:玉蟾宫

题目来源:长方形

单调队列

题目来源:琪露诺

贪心思维

推销员


搜索

一种暴力求解的方法,通过确定初始状态、下一步可能的行动进行状态转移;

搜索的核心在于记忆化和剪枝

深度优先搜索

每一次都选择一条路径搜到底

题目来源:小木棍

很有意思的一道题

背景:有一些同样长的小木棍,把这些木棍随意砍成几段,直到每段的长都不超过 50,现在想把小木棍拼接成原来的样子,但是却忘记了开始时有多少根木棍和它们的长度。给出每段小木棍的长度,求原始木棍的最小可能长度

思路:首先考虑如何知道原木棍最小长度的范围再考虑一个个枚举然后去爆搜,很明显的一点是小木棍中最长的一条的长度<=原木棍长度<=小木棍长度之和

确定了木棍范围后再考虑剪枝,考虑所有可行的剪枝:

  • 原木棍的长度必须能由小木棍拼接而来,这个是我们最终要求的,先不考虑
  • 小木棍长度之和一定是原木棍长度的整数倍:也就是这些小木棍能拼成x棵原木棍且x棵原木棍长度必须相等
  • 短木棍比长木棍在拼凑上更加灵活,有更多的可能:很明显凑成一颗相同长度的木棍原长度小的木棍比原长度长的木棍有更多的选择,反而言之就是长度长的木棍可选择较少,可以优先考虑(排序)
  • 放弃选择一根长度为x的木棍后就代表放弃所有长度为x的木棍:因为可能存在相同长度的木棍,所以这点很重要
  • ※当回溯时放弃的这条木棍正好能够和当前长度拼凑成目标长度时继续返回

这里重点解释一下上面这点:

首先如果放弃的这跟木棍正好跟当前拼凑的长度凑成目标长度,不用继续再往下搜,因为后面的结果也只可能是找几根小木棍来代替这跟木棍,这根木棍去代替这几根小木棍,没有造成任何长度上的改变(仔细考虑),而且根据放弃一条代表放弃全部长度相同的木棍的原则也不用继续往下搜长度相同的木棍了,只能再返回从倒数第二根上开始做出改变(思考为什么一根用几根替代没有改变而两根用几根替代就会可能产生变换,这里不多解释)

所有的剪枝都是必要的,即使只减去一个,因为深搜的复杂度是指数上涨,即使减去很少的分支也能带来很大的优化

如果把上面的剪枝搞懂了,那么这题应该就没有问题了,然后就是这题时间要求比较严格,能快读的快读以及各个小细节能优化的尽量优化(比如找到答案直接exit(0),省去递归返回的时间)

剩下的没什么好说的都在注释里的

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

int n,sum = 0,len = 0;//sum木棍总长度,len选择的原木棍的长度
int arr[70];
bool vis[70];
bool cmp(int a,int b)
{
	return a > b;//从大到小排序
}
void dfs(int cnt,int length,int last)//三个变量分别为拼成的第几根木棍、当前拼的木棍的长度、上一次选择的小木棍的下一个的位置
{
	if(length == len)
	{
		if(cnt == n)
		{
			cout << len;//枚举原木棍长度是从小到大枚举的,所以第一次的答案就是最终答案
			exit(0);
			return;
		}
        
		length = 0;
		last = 1;
	}
	if(length == 0)//如果长度是0就代表单开一个木棍搜索
	{
		int i = 1;
		while(vis[i])//找到第一个没有被搜到的
		{
			i++;
		}
		vis[i] = true;
		dfs(cnt+1,length+arr[i],i+1);
		vis[i] = false;
		return;
	}
	for(int i = last;i <= n;i++)//i从last开始,避免重复选择
	{
		if(!vis[i] && length + arr[i] <= len)
		{
			if(arr[i] == arr[i-1] && !vis[i-1])//剪枝:放弃一个代表放弃所有长度相同的
			{
				continue;
			}
			vis[i] = true;
			dfs(cnt+1,length+arr[i],i+1);//标准的深搜回溯
			vis[i] = false;
			if(length + arr[i] == len)//尾剪枝
			{
				return;
			}
		}
	}
}
int main()
{
	ios::sync_with_stdio(0),cin.tie(0),cout.tie(0);
	cin >> n;
	for(int i = 1;i <= n;i++)
	{
		cin >> arr[i];
		sum += arr[i];
	}
	sort(arr+1,arr+1+n,cmp);//从大到小排序,优先考虑可选择方案少的
	for(int i = n;i >= 1;i--)//len从小到大,保证第一次成功搜到的就是最优解
	{
		if(sum%i == 0 && sum/i >= arr[1])//保证sum是len的倍数而且要不小于小木棍中最大的长度
		{
			len = sum/i;
			dfs(0,0,1);
		}
	}
	return 0;
}

广度优先搜索

按照层次一层层遍历

题目来源:棋盘

背景:一个m*m的棋盘上有三种棋子,分为红、黄和无色,相同颜色的棋子之间可以无消耗移动,不同颜色的棋子(不包括无色)之间移动需要消耗一金币,有颜色的棋子向无颜色的棋子移动需要二金币,而且此时无色的棋子可以被染上任意的颜色,但不能从一个被染上色的棋子上走向另一个无色棋子,同时离开这个被染色的棋子后棋子颜色也会消失,求从原点到终点的最小代价

思路:这个其实有很多方法,这里介绍一下BFS的思路

这道题BFS也有很多种方法(单调队列、优先队列、双端优化等等)这里介绍一个直接上标记的记忆法:

首先有色的格子之间的移动很简单,只需要考虑1或0的花费,这里不再多说,最复杂的部分是从有色格子到无色格子和无色格子到有色格子

先讲从有色格子走到无色格子:

有色格子走到无色格子需要花费2金币,同时要给格子暂时染上一个颜色,这里考虑如何染色最优,其实很简单,就是染和当前格子相同的颜色就是最优,因为这样能保证从当前格子到无色格子的花费最小,再从无色格子走到有色格子时只有两种情况:和被染的颜色相同或不同,也就是花费1金币或无花费,可以计算出通过无色格子的路径最多的花费就是2+1(三个颜色各不相同),最少花费是2(左右两个格子颜色相同,中间无色格子也染成相同颜色),所以考虑贪心,每次要走到无色格子上时染成和当前格子相同的颜色

再来说从无色格子走到有色格子:

因为不能从被染色的无色格子上走到另一个无色格子上,所以在判断的时候直接跳过就好,然后就是根据被染的颜色按照题目要求进行判断是否有花费,最重要的是记得走到下一个格子时将该格子变回无色状态

这里提出一个思考:广搜是一层一层遍历,那就会遇到两种路径下有两个无色格子都被染色,到下一层的时候这两个格子也是可以互通的,这种情况该如何处理呢?

这里先给出预先处理的数据代码:

arr数组存放棋盘,res数组存放每个点的最小代价

for(int i = 1;i <= m;i++)
{
	for(int j = 1;j <= m;j++)
	{
		res[i][j] = -1;//为什么要初始化为-1而不是0请自行思考,最后讲解
	}
}
for(int i = 1;i <= n;i++)
{
	int x,y,c;
	scanf("%d %d %d",&x,&y,&c);//将c+1这样更好处理,让0为无色
    arr[x][y] = c+1;
}
res[m][m] = 0x3f3f3f3f + 1;
res[1][1] = 0;//初始格子无消耗

接下来就是核心步骤广搜的流程:

这里解释一下上面提出的思考,如果不考虑两个路径两个格子都被染色的情况那么很容易造成本该是2消耗的问题变为了1消耗,造成答案错误,那么这种情况该如何处理呢?这个时候就用到了我们的核心操作:上标记

  • 开一个布尔数组将被染色的格子的位置标记一下,如果当前格子是被染色的而且目标移动到的格子也是被染色的,那么就等于两个无色格子进行移动,这样是不允许的,直接跳过,如果一个格子没有被标记而且格子还有颜色的话那就说明当前格子的颜色是本来就有的
  • 格子被标记后要记得将颜色也暂时改变,当从一个被染色的格子移动到另一个格子上后要记得将颜色变回0并且将标记去掉

核心代码:

//a:当前格子的颜色,x,y:目标格子的位置,sum:走到当前格子花费的最小代价,flag:当前格子是否是被标记的格子
void in(int a,int x,int y,int sum,bool flag)
{
	if(x < 1 || x > m || y < 1 || y > m)//判断边界
	{
		return;
	}
	if(change[x][y])//核心部分1:判断目标格子是否是被染色的,如果是的话就当做无色的处理
	{
		if(flag)//两个格子都被染色即两个无色格子,不能互通
		{
			return;
		}
        //初始1,1的位置设置成了0消耗,这里初始化-1的目的就是判断是否是目标格子第一次被走到,也可以用一个vis数组代替
		if(sum+2 < res[x][y] || res[x][y] == -1)
		{
			q.push((node){x,y});
			change[x][y] = true;
			arr[x][y] = a;
			res[x][y] = sum+2;
		}
		return;
	}
	if(arr[x][y] == 0)
	{
		if(flag)
		{
			return;
		}
        //这里的if语句都是判断当前格子到目标格子的总消耗是否小于其他路径到目标格子的总消耗
		if(sum+2 < res[x][y] || res[x][y] == -1)
		{
			q.push((node){x,y});
			change[x][y] = true;
			arr[x][y] = a;
			res[x][y] = sum+2;
		}
	}
	else
	{
		if(arr[x][y] == a)
		{
			if(sum < res[x][y] || res[x][y] == -1)
			{
				q.push((node){x,y});
				res[x][y] = sum;
			}
		}
		else
		{
			if(sum+1 < res[x][y] || res[x][y] == -1)
			{
				q.push((node){x,y});
				res[x][y] = sum+1;
			}
		}
	}
}
void bfs()
{
	q.push((node){1,1});
	while(!q.empty())
	{
		int len = q.size();
		for(int i = 1;i <= len;i++)
		{
			node p = q.front();
            //上下左右四个方向都走一遍
			in(arr[p.x][p.y],p.x-1,p.y,res[p.x][p.y],change[p.x][p.y]);
			in(arr[p.x][p.y],p.x+1,p.y,res[p.x][p.y],change[p.x][p.y]);
			in(arr[p.x][p.y],p.x,p.y-1,res[p.x][p.y],change[p.x][p.y]);
			in(arr[p.x][p.y],p.x,p.y+1,res[p.x][p.y],change[p.x][p.y]);
            //核心部分2:当前格子遍历完上下左右四个方向后已经离开该位置,同时取消标记和颜色
			if(change[p.x][p.y])
			{
				change[p.x][p.y] = false;
				arr[p.x][p.y] = 0;
			}
			q.pop();
		}
	}
}

思路基本讲完,还是比较简单的(个人感觉相对其他的某些方法来说),但很容易被某些小问题卡住(比如同层格子同时被染色,遍历最优解更新res数组时处理数据失误造成一条回路来回更新无线循环等等)

下面是总体代码:

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

typedef struct node{
	int x,y;
}node;
int m,n;
queue<node> q;
int arr[110][110];
int res[110][110];
bool change[110][110];
void in(int a,int x,int y,int sum,bool flag)
{
	if(x < 1 || x > m || y < 1 || y > m)
	{
		return;
	}
	if(change[x][y])
	{
		if(flag)
		{
			return;
		}
		if(sum+2 < res[x][y] || res[x][y] == -1)
		{
			q.push((node){x,y});
			change[x][y] = true;
			arr[x][y] = a;
			res[x][y] = sum+2;
		}
		return;
	}
	if(arr[x][y] == 0)
	{
		if(flag)
		{
			return;
		}
		if(sum+2 < res[x][y] || res[x][y] == -1)
		{
			q.push((node){x,y});
			change[x][y] = true;
			arr[x][y] = a;
			res[x][y] = sum+2;
		}
	}
	else
	{
		if(arr[x][y] == a)
		{
			if(sum < res[x][y] || res[x][y] == -1)
			{
				q.push((node){x,y});
				res[x][y] = sum;
			}
		}
		else
		{
			if(sum+1 < res[x][y] || res[x][y] == -1)
			{
				q.push((node){x,y});
				res[x][y] = sum+1;
			}
		}
	}
}
void bfs()
{
	q.push((node){1,1});
	while(!q.empty())
	{
		int len = q.size();
		for(int i = 1;i <= len;i++)
		{
			node p = q.front();
			in(arr[p.x][p.y],p.x-1,p.y,res[p.x][p.y],change[p.x][p.y]);
			in(arr[p.x][p.y],p.x+1,p.y,res[p.x][p.y],change[p.x][p.y]);
			in(arr[p.x][p.y],p.x,p.y-1,res[p.x][p.y],change[p.x][p.y]);
			in(arr[p.x][p.y],p.x,p.y+1,res[p.x][p.y],change[p.x][p.y]);
			if(change[p.x][p.y])
			{
				change[p.x][p.y] = false;
				arr[p.x][p.y] = 0;
			}
			q.pop();
		}
	}
}

int main()
{
	scanf("%d %d",&m,&n);
	for(int i = 1;i <= m;i++)
	{
		for(int j = 1;j <= m;j++)
		{
			res[i][j] = -1;
		}
	}
	for(int i = 1;i <= n;i++)
	{
		int x,y,c;
		scanf("%d %d %d",&x,&y,&c);
		arr[x][y] = c+1;
	}
	res[m][m] = 0x3f3f3f3f + 1;
	res[1][1] = 0;
	bfs();
	cout << (res[m][m] > 0x3f3f3f3f ? -1 : res[m][m]);
}

题目来源:引水入城

背景:有N*M的矩形,每个格子上都有一个数字表示海拔,规定水能从高海拔处流向低海拔处;现准备在第一行修建蓄水厂,想要将水引入最后一行,求最少需要建多少个蓄水厂才能使最后一行全部有水,不能的话输出最少有多少个格子不能被引入水

思路:读完题基本都能看出来是用广搜,将第一层的点依次作为起点广搜一次就能知道最后一行是否能满足要求,关键在于第二问:能满足的话最少要建几个/不能满足的话最少有几个格子不能满足

首先第二种情况很好做,就是广搜之后看有几个点没有被访问到直接输出就好了;重点放在第一种情况

既然要知道最少需要几个蓄水厂才能使最后一行全部满足条件,那么就需要知道第一行每个点所能带来的利益:能到达哪几个格子

到这里我们可以想到一种思路:建立M个集合,每个集合存储第一行第i个点广搜后最后一行能满足条件的位置,M次广搜后就能得到第一行所有的点分别能带来的利益,(这里有个小技巧,既然要每个点分别带来的利益,那么肯定不能用一个bool类型vis数组连续使用,因为会包含上一次的答案,还要每次广搜前初始化vis数组,这样太浪费时间,可以建立一个int类型的vis数组,第i个点作为起点时将该点走过的点在vis中的位置改为i,这样一个数组可以循环使用并且不会造成重复也不用每次都初始化,每次判断有哪些点满足时只需要判断vis中的值是否等于i即可)随后枚举这些点的的任意组合,若他们的集合的并集长度等于M,则表明满足条件,最后找出满足条件的点的个数的最小值,看起来似乎可行,但是当M等于500的时候枚举500个点的任意组合......不用想肯定超时了,所以考虑能不能取巧

我们考虑一种情况:

 9 8 7 6 

 5 9 3 2

 1 2 1 1

这样的一组数据,我们发现以第一个点9作为起点深搜之后能到达最后一行的1.3.4的位置,2的位置不能到达,思考一个问题:如果能到达的点不是连续的那是否还存在其他的点作为起点广搜能到达未到达的那个点的可能

很明显:不能,因为既然跳过了一个点到达了下一个点就说明至少走了一条路径完全包围了到达被跳过的点的必经之路(即该点的上、左、右三个点的位置),所有要到达该点的路径必须经过这三点之一,现有一条将该三点包围的路径且不能向这三点延伸,则说明该点永远不可到达

这个结论从侧面说明如果想要满足最后一行全被引入水这个条件,那么所有点能到达的最后一行的位置必须是连续的,所以我们可以只记录每个点能到达的起点和终点

到这里已经成功了一大半了,先把目前操作的代码给出:

typedef struct node{
	int x,y;
}node;
struct re{
	int fir,las;//fir(st)起点,las(t)终点
	bool operator < (const re& a) const
	{
		return fir < a.fir;//根据起点从小到大排序,后面会说到
	}
}res[510];
int n,m;
int arr[510][510];
int vis[510][510];
bool use[510];
queue<node> q;

void in(int x,int y,int tag,int num)//四个参数,目标位置的x,y,本次广搜的起点tag,当前位置的海拔
{
    //这里判断是否越界、是否被当前点访问过、是否符合条件(海拔小于)
	if(x > n || x < 1 || y > m || y < 1 || vis[x][y] == tag || arr[x][y] >= num)
	{
		return;
	}
	vis[x][y] = tag;//满足则标记当前点,代表来过,同时将该点入队
	q.push((node){x,y});
}
void bfs(int tag)
{
	while(!q.empty())
	{
		int len = q.size();
		for(int i = 1;i <= len;i++)
		{
			node p = q.front();
			in(p.x-1,p.y,tag,arr[p.x][p.y]);
			in(p.x+1,p.y,tag,arr[p.x][p.y]);
			in(p.x,p.y-1,tag,arr[p.x][p.y]);
			in(p.x,p.y+1,tag,arr[p.x][p.y]);
			q.pop();
		}
	}
    //此处为记录起点和终点的代码,很容易看懂
	bool first = false;
	for(int i = 1;i <= m;i++)
	{
		if(vis[n][i] == tag)
		{
			if(!first)
			{
				res[tag].fir = res[tag].las = i;
				first = true;
			}
			else
			{
				res[tag].las = i;
			}
		}
	}
}

枚举每个起点广搜之后只需要枚举判断最后一行每个点是否都不为0,如果有为0的则记录0的个数,最后输出即可,如果全不为0则表明存在符合条件的答案

接下来就是找出最少个点满足最后一行全部被引入水

上面说过一个个枚举肯定是不行的,而且我们现在存的是每个点能到达的第一个点和最后一个点的位置,可以看作是一条线段,

  • 那么就可以把这个问题转化为:给出m个区间(起点和终点),找出a个区间拼接后满足区间连续且长度为M,也就是区间覆盖问题
  • 这个问题只需要按照起点的位置将所有区间先排个序,然后对区间左端点不小于起点(起点规定为初始点或者选出一个区间后的最右端点)的区间中找出右端点的最大值R,将R更新为新的起点,然后从该点继续遍历,复杂度为O(n),使用的是贪心的思想

最后所有问题都解决完了,记得要先判断不能满足的情况在考虑满足的情况

main函数如下(跟上面的连起来)

int main()
{
	scanf("%d %d",&n,&m);
	for(int i = 1;i <= n;i++)
	{
		for(int j = 1;j <= m;j++)
		{
			scanf("%d",&arr[i][j]);
		}
	}
    //广搜以第一行每个点为起点
	for(int i = 1;i <= m;i++)
	{
		q.push((node){1,i});
		vis[1][i] = i;//将第i个点标记为i
		bfs(i);
	}
	int a = 0;
	for(int i = 1;i <= m;i++)
	{
		if(vis[n][i] == 0)
		{
			a++;
		}
	}
	if(a != 0)
	{
		cout << "0\n" << a;
	}
    //以上为判断不满足条件的情况
    //以下为满足条件的情况
	else
	{
		sort(res+1,res+1+m);
		int i = 1;
		while(res[i].fir == 0)//*易错点:记得跳过不能到达任何点的起点(即fir为0的点)
		{
			i++;
		}
		int ans = 0;
		int st = 0,ed = m;
		for(i;i <= m;i++)
		{
			int j = i,r = 0;
			while(j <= m && res[j].fir <= st+1)//*易错点:区间是拼接的,即可以等于左区间的值+1
			{
				r = max(r,res[j].las);
				j++;
			}
			ans++;
			if(r == ed)
			{
				cout << "1\n" << ans;
				break;
			}
			st = r;
			i = j-1;//因为执行了j++之和才跳出的while循环,所以j是不满足的,j-1才是满足条件的最后一个点
		}
	}
	return 0;
}


双向搜索/折半搜索

·从起点和终点同时搜索,直到两点相遇

·将搜索过程分成两半,分别搜索,最后将结果合并

题目来源:世界冰球锦标赛

背景:有n场比赛,每场的门票花费为Ai,现有m的财产,求有多少种花费不超过m的不同观赛方案

思路:首先最暴力的思路就是深搜+回溯在每场比赛都选择看或不看之后总共有多少种可行方案,但这题的n范围在[0,40],2^40很明显超时

想到每场比赛都可以选择看或不看,我们可以很自然的联想到背包问题,只需要把背包问题的收益改为方案数量即可,背包问题的复杂度是O(n*m),看一眼数据范围n <= 40,只有在m <= 10^6的时候可行,还是有一部分会超时

这时候就用到了本题的核心思路:折半搜索,将搜索的过程分为两部分[1,mid]和[mid,n],这样最坏的情况下复杂度是O(2^(n/2)),是可以安全通过的,最后将两次搜索分别得到的两个方案数组A,B答案依次进行合并,先对任意一个数组进行排序(这里用A),若Ai+Bj的数据符合<=m,则i之前的所有数据都能和Bj组成方案,最后将方案个数之和加起来就是最终答案

为什么折半搜索是正确的:核心就在于这些选择互不影响,即每一个选择或不选择都不会影响下一个的选择

  • 接下来的操作就很简单了,分别搜索两次用两个数组分别记录下两次搜索的每次结果的花费(注意选1,2和选2,1是一种选择,所有不要按照全排列的方式去搜,每次只向后面搜索就好)

这里搜索有两种写法,一种是正常的for循环搜索方式,另一种是结合背包思想每次可以选或不选分成两种方式再分别去搜,根据自己喜好来选择:

void dfs1(int pos,int end,long long sum)
{
	arr1[++cnt1] = sum;
	for(int i = pos;i <= end;i++)
	{
		if(sum + arr[i] <= m)
		{
            //每次搜到一个点就可以记录当前的值即为选择i不选择i后面的方案
			dfs1(i+1,end,sum+arr[i]);
            //返回到此的时候循环执行i++即为不选择i的方案,继续搜索后面的方案
		}
	}
}
void dfs2(int pos,int end,long long sum)
{
	arr2[++cnt2] = sum;
	for(int i = pos;i <= end;i++)
	{
		if(sum + arr[i] <= m)
		{
			//每次搜到一个点就可以记录当前的值即为选择i不选择i后面的方案
			dfs2(i+1,end,sum+arr[i]);
            //返回到此的时候循环执行i++即为不选择i的方案,继续搜索后面的方案
		}
	}
}
void dfs1(int l,int r,long long sum)
{
	if(sum > m)//若当前值大于m则返回
	{
		return;
	}
	if(l > r)//l > r则表明一条有r次选择的可选方案,记录即可
	{
		arr1[++cnt1] = sum;
		return;
	}
	dfs1(l+1,r,sum+arr[l]);//选择观看当前比赛
	dfs1(l+1,r,sum);//选择不看当前比赛
}
void dfs2(int l,int r,long long sum)
{
	if(sum > m)//若当前值大于m则返回
	{
		return;
	}
	if(l > r)//l > r则表明一条有r次选择的可选方案,记录即可
	{
		arr2[++cnt2] = sum;
		return;
	}
	dfs2(l+1,r,sum+arr[l]);//选择观看当前比赛
	dfs2(l+1,r,sum);//选择不看当前比赛
}

下面是整体代码:

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

long long n,m,cnt1 = 0,cnt2 = 0,ans = 0;
long long arr[60];
long long arr1[1100010];
long long arr2[1100010];

void dfs1(int l,int r,long long sum)
{
	if(sum > m)
	{
		return;
	}
	if(l > r)
	{
		arr1[++cnt1] = sum;
		return;
	}
	dfs1(l+1,r,sum+arr[l]);
	dfs1(l+1,r,sum);
}
void dfs2(int l,int r,long long sum)
{
	if(sum > m)
	{
		return;
	}
	if(l > r)
	{
		arr2[++cnt2] = sum;
		return;
	}
	dfs2(l+1,r,sum+arr[l]);
	dfs2(l+1,r,sum);
}
int main()
{
	scanf("%lld %lld",&n,&m);
	for(int i = 1;i <= n;i++)
	{
		scanf("%lld",&arr[i]);
	}
	long long mid = n>>1;
	dfs1(1,mid,0);
	dfs2(mid+1,n,0);
	sort(arr1+1,arr1+1+cnt1);//对其中一个数组进行排序,方便方便找到一个元素前面有多少元素
	for(int i = 1;i <= cnt2;i++)
	{
        //因为已经排好序了所以这里直接二分查找第一个大于m的数据
        //即该数据之前的数据都能和arr2[i]匹配对答案做出贡献
		int l = 0,r = cnt1;
		while(l <= r)
		{
			int midd = l+r>>1;
			if(arr1[midd] <= m-arr2[i])
			{
				l = midd+1;
			}
			else
			{
				r = midd-1;
			}
		}
		ans += l-1;
	}
	cout << ans;
}

如果对自己的手写二分没有信心的话可以使用算法库自带的lower_bound( )和upper_bound( )函数,都是利用二分的方法在一个排好序的数组中进行查找。


题目来源:Balanced Cow Subsets G

背景:定义一个集合是平衡的当且仅当集合非空且该集合S可划分成两个集合A,B且A\bigcap B = S,A\bigcup B=\varnothing,现给出一个集合,求该集合有多少子集是平衡的(集合元素可以重复)

这题也是一道折半搜索的好题

思路:先看数据范围:n<=20,可以爆搜,但这题要注意,平常的搜索都是选择要或不要,这题是要求平衡子集,子集不一定是要把全部元素都带上的,也就是说这题的搜索要分成三个分支:不选、选择成为子集的左集合、选择成为子集的右集合,也就是如果这题单纯用搜索的话复杂度会是O(3^n),肯定会超时,所以我们考虑折半搜索

  • 首先我们先确定搜索要得到什么:子集的左集合和子集的右集合,因为只有当子集的左右集合相等时才符合条件
  • 我们暂定放入左集合为+arr[i],放入右集合为-arr[i],所以深搜的三个分支就很好写了,分别为sum+arr[i],sum-arr[i]和sum

我们把两次折半搜索的所有可能的sum数存起来,但是有一个问题还没有解决:相同的和可以由不同的选择得来,为了解决这个问题,我们引入一个变量state来用二进制状态压缩表示选择的方案

  • 把能得到同一sum的不同选择放到一起,这里二进制0表示没有选择,1表示选择放进了左区间或右区间,这样我们两次搜索的状态合并一下就是整个集合中选择的状态,然后考虑去重问题,因为题目要求的是子集的方案数,而不是子集能分成左右集合的方案数,所以同一个集合例如{1,2,3}虽然可以分成{1,2},{3}或{3},{1,2},但实际上还是一个可行集合{1,2,3},所以我们把最终状态相同的只计算一次即可

到这里,基本上思路已经很清晰了,直接放代码:

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

int n,ans = 0;
int arr[30];
//vis数组去重,只有true和false,表示当前状态是否出现过,记得数组最少开到1<<20
bool vis[1100000];
//建立一个map,key存放子集和的sum,val存放一个vector来存放sum所有可能的状态
map<int,vector<int>> mp;

void dfs1(int l,int r,int sum,int state)
{
	if(l > r)
	{
		mp[sum].push_back(state);//记录状态
		return;
	}
    //state|1<<l-1:状态转移,即当前的状态和只选择arr[l]的并集
	dfs1(l+1,r,sum+arr[l],state|1<<l-1);//选择为左集合
	dfs1(l+1,r,sum-arr[l],state|1<<l-1);//选择为右集合
	dfs1(l+1,r,sum,state);//不选当前元素为子集
}
void dfs2(int l,int r,int sum,int state)
{
	if(l > r)
	{    
        //每次确定一个sum的状态后即可对上次搜索的结果进行匹配
		if(!mp[sum].empty())
		{
			int len = mp[sum].size();
			for(int i = 0;i < len;i++)
			{
				vis[state|mp[sum][i]] = true;//简单粗暴的去重方式,true为1,false为0
			}
		}
		return;
	}
	dfs2(l+1,r,sum+arr[l],state|1<<l-1);
	dfs2(l+1,r,sum-arr[l],state|1<<l-1);
	dfs2(l+1,r,sum,state);
}
int main()
{
	scanf("%d",&n);
	for(int i = 1;i <= n;i++)
	{
		scanf("%d",&arr[i]);
	}
	int mid = n>>1;
	dfs1(1,mid,0,0);
	dfs2(mid+1,n,0,0);
    //遍历整个数组记录所有答案之和
	for(int i = 1;i < (1<<n);i++)
	{
		ans += vis[i];
	}
	cout << ans;
}

最后留给大家一个思考:上述代码有没有记录只在前一半中可以选择的子集和只在后一半集合中可以选择的子集作为答案呢?(仔细思考 ‘|’ 的使用)


A*/迭代加深搜索/IDA*

  • A*算法(启发式搜索):某种方面上像是BFS的改进,每次优先找出BFS已经遍历到的可能为距终点最优的一个或几个点进行搜索,通常有一个估价函数f(x) = g(x)+h(x),g(x)是起点到x的成本,h(x)是启发函数,用于估计x点到终点的最少成本
  • 迭代加深搜索:每次限制搜索的长度,一般用于寻找最优解,和BFS不同,BFS在分支选择很多的时候在队列中会浪费大量空间,甚至可能会爆掉,所以这时候限制搜索长度来进行搜索,每次长度+1第一次能搜到的答案就是最优解,可以看作是DFS方式实现的BFS
  • IDA*算法:即采用了迭代加深的A*算法,集合了两者的优点,自带方向的DFS,空间比BFS要少很多,而且限制搜索长度,大多情况下都不需要判重和排序等优化

题目来源:八数码难题

背景:给出一个3*3的方阵,空格(0)周围的数字可以移到空格位置,要求最终达到如下状态:\begin{pmatrix} 1&2 & 3\\ 8& 0 & 4\\ 7& 6 & 5 \end{pmatrix}

求最少交换次数

思路:经典题目,本文介绍的是IDA*的做法,还有很多其他方法例如BFS+哈希、双端队列BFS、A*等做法可以上网自行查阅

  • 首先对于每个可移动的数字都可以选择移动或者不移动,但这样太复杂每次还需要考虑移动前和移动后都有那些点是可以移动的;换个思路我们把空格作为移动对象,每次有上下左右四种移动方式,每次移动后判断一下是否满足条件即可,这是传统的BFS思路。这种方式下我们会发现会有很多的重复方案进队,因为BFS是漫无目的的搜,我们要找到的答案即使搜索很少次就能找到时间也会被大部分的无效方案浪费掉,所以这里我们限制一下搜索的长度同时加上一个估价函数来判断当前方案是否在限定的长度下有能得到结果的可能,也就是IDA*算法

在估价函数中我们需要判断限制的搜索长度减去当前的搜索长度剩下的搜索长度是否有解的可能,也就是说

  • 最理想的状态下假设当前状态下的矩阵与目标状态的矩阵有a个数字不同,最少需要移动a次才能到达目标状态,也就是当前已经搜索的长度step+最少移动次数a-1要小于等于限制的长度时我们才认为此分支是一个可行方案,否则直接剪枝

还有一个很重要的剪枝就是避免两个格子来回移动,也就是避免从1走到2再从2走回1

剩下的都在注释里,直接上代码:

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

int ans;
int arr[4][4];
//目标数组,方便对答案进行确认
int res[4][4] = {{0,0,0,0},{0,1,2,3},{0,8,0,4},{0,7,6,5}};
//x,y上下左右移动四个方向
int nex[4] = {0,1,-1,0};
int ney[4] = {1,0,0,-1};
bool ok()
{
	for(int i = 1;i <= 3;i++)
	{
		for(int j = 1;j <= 3;j++)
		{
			if(arr[i][j] != res[i][j])
			{
				return false;
			}
		}
	}
	return true;
}
bool check(int cnt)//估价函数,判断是否可行
{
	int now = 0;
	for(int i = 1;i <= 3;i++)
	{
		for(int j = 1;j <= 3;j++)
		{
			if(res[i][j] != arr[i][j])
			{
				now++;
                //这里本来应该传来的参数是cnt+1(因为先移动在判断的),判断是cnt+now-1 <= ans
                //但是这里方便一下传来的是cnt,所以cnt+now > ans和上面的等价
				if(now+cnt > ans)
				{
					return false;
				}
			}
		}
	}
	return true;
}
void ida(int cnt,int x,int y,int last)//当前搜索已走的长度、空格下标、上次移动的方向
{
	if(cnt == ans)
	{
		if(ok())
		{
			cout << ans;//输出完直接退出,方便
			exit(0);
		}
		return;
	}
	for(int i = 0;i < 4;i++)
	{
		int nx = x+nex[i];
		int ny = y+ney[i];
        //这里判断重复用的是上次走的方向和当前要走的方向下标之和等于3(nex和ney数组相加得0的下标)
		if(nx > 3 || nx < 1 || ny > 3 || ny < 1 || last+i == 3)
		{
			continue;
		}
		swap(arr[x][y],arr[nx][ny]);
		if(check(cnt))
		{
			ida(cnt+1,nx,ny,i);
		}
		swap(arr[x][y],arr[nx][ny]);//回溯就是交换回来
	}
}
int main()
{
	string str;
	cin >> str;
	int n = 0;
	int x,y;
	for(int i = 1;i <= 3;i++)
	{
		for(int j = 1;j <= 3;j++)
		{
			arr[i][j] = str[n++]-'0';
			if(arr[i][j] == 0)
			{
				x = i;
				y = j;
			}
		}
	}
	if(ok())//先判断一下是否不需要移动
	{
		cout << "0";
	}
	else
	{
		while(++ans)
		{
			ida(0,x,y,-1);
		}
	}
	return 0;
}

题目来源:骑士精神

可以拿来练练手,和上面思路差不多,注意一下移动问题,这里不放代码了


逆序对

对于给定的一段正整数序列,逆序对就是序列中ai​>aj​ 且 i<j 的有序对,即在一个单调序列中出现了一对违反单调性的数,将这一对数称为逆序对

题目来源:逆序对[模板]

背景:求逆序对

思路:考虑到逆序对是违反了单调性的一对数,所以在一个单调递增的序列上一对逆序对的出现只可能是一个数出现在了排序后应出现在的位置的前面(当前位置比实际小),另一个数则是出现在的排序后应出现在的位置的后面(当前位置比实际大)

  • 很明显的,可以使用归并排序来解决,归并排序的思想是分治,把一个序列分成若干给有序的子序列,当子序列长度为1时保证序列是有序的,然后再将子序列合并,合并时按照顺序进行交换,复杂度为nlogn。
  • 利用归并排序求逆序对的思路是合并两个有序子序列时考虑右边的子序列的数的数能与左边子序列中的数组成多少对逆序对,即对右边每个r[i]找出左边有多少个l[i]比r[i]大,每次求得的答案之和即为总逆序对个数。

举例:两个序列 [1,3,5,7],[2,4,6,8],两边都按照归并排序的思想从各自起始下标开始,对于右边第一个数2,左边第一个数1比2小,没有组成逆序对,放入归并排序的备用数组中;随后左边下标+1,3比2大,组成逆序对,由于两个序列都是有序的,所以3后面的数组也一定能和2组成逆序对,ans+3;将2放入归并排序的备用数组中,重复该操作。

可以看出,求逆序对的操作对归并排序没有任何影响,只是当r[i] < l[j]的时候记录了一次答案。

直接上代码:

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

long long n,ans;
long long arr[500010];
long long use[500010];

//归并排序板子
void M(long long l,long long mid,long long r)
{
	long long i = l,j = mid+1,num = l;
	while(i <= mid && j <= r)
	{
		if(arr[i] > arr[j])
		{
			use[num++] = arr[j++];
			ans += mid-i+1;//记录答案,注意下标为i的数据也符合条件,不要忘记+1
		}
		else
		{
			use[num++] = arr[i++];
		}
	}
	while(i <= mid)
	{
		use[num++] = arr[i++];
	}
	while(j <= r)
	{
		use[num++] = arr[j++];
	}
	for(i = l;i <= r;i++)
	{
		arr[i] = use[i];
	}
}

void Ms(long long l,long long r)
{
	if(l < r)
	{
		long long mid = (l+r)/2;
		Ms(l,mid);
		Ms(mid+1,r);
		M(l,mid,r);
	}
}
int main()
{
    cin >> n;
	for(int i = 1;i <= n;i++)
	{
		scanf("%lld",&arr[i]);
	}
	Ms(1,n);
	cout << ans;
	return 0;
}

题目来源:火柴排队

逆序对的题难点从来不是求逆序对,而是如何发现他是逆序对 ——布什沃·硕德

背景:给出两个序列An,Bn,每个序列中任意相邻两个数可以交换,不限次数,求使得\sum_{1}^{n}(a-b)^2最小的最少交换次数

思路:

首先分析公式,要使该公式最小,则有两种思路:

  • 1.让每个a-b的绝对值之和最小
  • 2.将公式展开,发现公式为\sum_{1}^{n}a^2-2ab+b^2等价于\sum_{1}^{n}a^2 +\sum_{1}^{n}b^2 - \sum_{1}^{n}2ab,由于a^2和b^2是固定不变的,所以等价于\sum_{1}^{n}ab最大

两种思路的最终思想都是一样的,因为:

  • 两个序列对应相减,绝对值之和为:有序情况<=无序情况;
  • 两个序列对应相乘,乘积之和为:有序情况 >= 无序情况;这里不予证明

最终又回到了求两个序列排序上,但这里并不一定要求两个序列都是单调的,只要保持两个序列相对有序即可

  • 即ai在A数组中的排名和对应位置的bi在B数组中的排名相等

回到题目,要求我们只能交换相邻的两个数,这让我们想起了冒泡排序,同样也是每次交换相邻的两个数,而且在给定一个序列的完整冒泡排序中,冒泡排序的交换次数等于逆序对数

这样我们又把问题转移到了求逆序对上,但上面已经知道最优解并不是一定要按照单调性来排序,所以这里我们先思考要按照怎样的一个顺序对什么样的序列求逆序对数。

上面已经证明出了最终目的是使ai在A数组中的排名和对应位置的bi在B数组中的排名相等

  • 那么我们可以先求出数组A和B的元素在原数组中的排名rankA[ai...an]和rankB[bi...bn],接下来对这两个数组一一对应进行映射,即map[rankA[i]] = rankB[i],可知在理想状态下map[i] = i;

该操作是先找两个序列中的任意一个,求出该序列元素所在序列中的排序,按照这个顺序来对另一个序列进行排序。

typedef struct node{
	int num,id;//num存元素实际值,id存该元素开始所在序列中的位置
    //将元素按照升序排列
	bool operator < (const node& n) const{
		return num < n.num;	
	}
}node;
node a[100010];
node b[100010];

最后对map数组求逆序对,得到的答案即为最少交换次数。

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

typedef struct node{
	int num,id;
	bool operator < (const node& n) const{
		return num < n.num;	
	}
}node;

int n;
long long ans;
node a[100010];
node b[100010];
int arr[100010];
int use[100010];

int main()
{
    scanf("%d",&n);
    for(int i = 1;i <= n;i++)
    {
    	scanf("%d",&a[i]);
    	a[i].id = i;
	}
	for(int i = 1;i <= n;i++)
	{
		scanf("%d",&b[i]);
		b[i].id = i;
	}
	sort(a+1,a+n+1);
	sort(b+1,b+n+1);
	for(int i = 1;i <= n;i++)
	{
		arr[a[i].id] = b[i].id;//将两个数组元素排名一一映射
	}
	Ms(1,n);//归并排序板子不再列出,注意ans要边算边取模
	printf("%lld",ans);
	return 0;
}

倍增

顾名思义,倍增就是不断地翻倍,使线性处理转化为对数级的处理

例:从1跳到2^n可以先跳到2^{n-1},再从该位置再跳2^{n-1},即pos[i][j] = pos[pos[i][j-1]][j-1],该式表示第i个点跳2^j个距离到达的地方可以从i跳到2^{j-1}个距离所到达的位置再跳2^{j-1}个距离转移过来

题目来源:Fountain

背景:一个喷泉从上到下由n个圆盘构成,当圆盘水接满时水就会溢出流到第一个比该圆盘大的圆盘中,现给出n个圆盘的直径和容量,q次询问在第x个圆盘注入v体积水,求水最终的落点

思路:按照正常思维,枚举每个圆盘,依次找到每个圆盘向下对应的第一个直径比他大的圆盘,找出这样的n条递增序列,对于每次询问从第x个圆盘依次判断,时间复杂度O(n^2),超时

想一想如何优化,创建一个数组r,对于每个圆盘,可以先找出下面第一个直径比他大的圆盘的位置,不用找出每条序列,可以在用的时候一层一层推过去,那么这个数组如何去求呢?如果从每个点i开始遍历,一次次向下寻找,这样最差的情况可能会达到O(n^2),还是不行

  • 所以考虑使用单调栈来优化,复杂度为线性:
stack<int> st;
st.push(1);
for(int i = 2;i <= n+1;i++)
{
	while(!st.empty() && d[i] > d[st.top()])//维护一个严格单调递减的栈
	{
		r[st.top()] = i;
		st.pop();
	}
	st.push(i);
}
while(!st.empty())
{
	r[st.top()] = 0;//将没有下界的圆盘下届设为0,即地底
	st.pop();
}

然后考虑如何去快速的来找出水池注水后会溢出到的地方,这里就用到了处理这道题的核心思想:倍增

  • 从第一个圆盘开始溢出流到第n个圆盘停止,按之前的思想需要进行n次判断,而用倍增后只需要判断[1,logn](取决于这个数和2^n的相对差)次,提升非常大,可以将n^2的算法优化为nlogn;
  • 同时开两个二维数组r[n][logn],sum[n][logn],分别记录第i个圆盘向下找2^logn个严格递增的圆盘所到达的圆盘位置和所经过的圆盘的总容量。

同时更新一下单调栈的代码:

memset(sum,0x3f,sizeof(sum));
st.push(1);
for(int i = 2;i <= n+1;i++)
{
	while(!st.empty() && d[i] > d[st.top()])
	{
		r[st.top()][0] = i;
		sum[st.top()][0] = c[i];
		st.pop();
	}
	st.push(i);
}
while(!st.empty())
{
	r[st.top()][0] = 0;
	st.pop();
}
for(int j = 1;(1<<j) <= n;j++)
{
	for(int i = 1;i+(1<<j) <= n;i++)
	{
        //i走2^j个位置到达的地方为i走2^(j-1)个位置到达的地方再走2^(j-1)个
		r[i][j] = r[r[i][j-1]][j-1];
        //i点走2^j个位置经过的总容量同理
		sum[i][j] = sum[i][j-1] + sum[r[i][j-1]][j-1];
	}
}

接下来就是如何去查询,最简单的一个结果:

  • v比c[i]要小,直接输出i就可以
  • 否则的话就要大致估测一个数保证2^i大于等于n,保证r[x][i]不为0的同时将sum[x][i]和当前的v进行判断
  • 如果大于的话就去找2^(i-1)的位置继续判断,然后将v减少sum[x][i],每次减少的同时要把x更新为x向下走2^i个距离的位置
while(q--)
{
	int x,v;
	scanf("%d %d",&x,&v);
	if(c[x] >= v)
	{
		printf("%d\n",x);//可以装下则直接输出
		continue;
	}
	v -= c[x];
	for(int i = 20;i >= 0;i--)//保证2^i最大值大于等于n
	{
		if(r[x][i] != 0 && v > sum[x][i])//r[x][i]等于0代表流向地面
		{
			v -= sum[x][i];//v减少当前容量之和,更新x之后继续判断
			x = r[x][i];//x更新为x下2^i个圆盘的位置
		}
	}
	printf("%d\n",r[x][0]);
}

离散化

题目来源:程序自动分析

给定 n 个形如 Xi = Xj 或 Xi​ !=Xj​ 的变量相等/不等的约束条件,判断是否所有约束条件可同时被满足

思路:将所有相等的看作一个集合,用并查集来维护,因为去任意值都能满足条件,然后处理不相等的数据,保证两者不相等等价于两者不存在于同一集合。

本体并查集的思路很简单,但是数据范围在0-1e9,并查集并不能开这么大,所有要对数据进行处理——离散化

  • 先记录每个数据在输入时所在的位置id,也就是第输入的第i个数据的id为i,然后按照每个数据的实际值num进行排序,得到离散化后的数组
typedef struct node{
	int num;
	int id;
	bool operator < (const node& n) const{
		if(num == n.num)
		{
			return id < n.id;//保持原来顺序
		}
		return num < n.num;
	}
}node;

注意数组要开两倍大小,因为一次要输入两个数据,注意如果开两个数组分别来存的话会造成离散化后必有数组1中的数据等于数组2中的数据的可能,会给并查集的合并带来很大的麻烦;

输入两组数据可以有很多种方式来存储,比如第一个存在i位置,第二个存在i+n位置,或者第一个存在i位置,第二个存在i+1位置,i每次+2等等可根据自己风格来写,这里使用的是第一种

int n;
scanf("%d",&n);
for(int i = 1;i <= n*2;i++)
{
	fa[i] = i;//并查集初始化
}
for(int i = 1;i <= n;i++)
{
	scanf("%d %d %d",&x[i].num,&x[i+n].num,&cm[i]);
	x[i].id = i;//设置初始id
	x[i+n].id = i+n;
}
sort(x+1,x+1+n*2);//排序进行离散化

排序后我们将一些可能很大且不连续的数转化为了1-n且连续的数,即各数在序列中对应的排名,后面将使用数据的排名来代表这个数据;

离散化后本应有一步去重操作,即删掉重复的数据,因为有相同的数据但排名之后他们的排名却不相同会对答案造成影响;这里懒得去重,所以直接预处理将数据相同的元素放入同一集合,因为时间复杂度为线性,不会造成什么影响;

同时开一个数组arr来表示排序后数组原id所对应的排名,即在arr[x.id]的位置上存x的排名,形成一种映射,那么找到排序前数组的第i个元素也就是输入顺序的第i个元素的位置的方法就是arr[x[i].id]

for(int i = 1;i <= n*2;i++)
{
	arr[x[i].id] = i;
}
for(int i = 2;i <= n*2;i++)
{
	if(x[i].num == x[i-1].num)
	{
		Merg(arr[x[i].id],arr[x[i-1].id]);//预处理将相同的元素先放入同一集合,代替了去重操作
	}
}

随后就是并查集的合并以及查询了,先处理1的情况,在处理0的情况,即先合并再查询是否有冲突

for(int i = 1;i <= n;i++)
{
	if(cm[i] == 1)
	{
        //合并处理
		Merg(arr[i],arr[i+n]);
	}
}
bool flag = true;
for(int i = 1;i <= n;i++)
{
	if(cm[i] == 0)
	{
        //如果两个元素在同一集合,那么就违反了约束条件
		if(get_f(arr[i]) == get_f(arr[i+n]))
		{
			flag = false;
			break;
		}
	}
}
if(flag)
{
	puts("YES");
}
else
{
	puts("NO");
}

完整代码

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

typedef struct node{
	int num;
	int id;
	bool operator < (const node& n) const{
		if(num == n.num)
		{
			return id < n.id;
		}
		return num < n.num;
	}
}node;
int fa[100010*2];
node x[100010*2];
int arr[100010*2];
int cm[100010];
int get_f(int x)
{
	if(fa[x] == x)
	{
		return x;
	}
	return fa[x] = get_f(fa[x]);
}

void Merg(int x,int y)
{
	fa[get_f(x)] = get_f(y);
}
int main()
{
	int t;
	scanf("%d",&t);
	while(t--)
	{
		int n;
		scanf("%d",&n);
		for(int i = 1;i <= n*2;i++)
		{
			fa[i] = i;
		}
		for(int i = 1;i <= n;i++)
		{
			scanf("%d %d %d",&x[i].num,&x[i+n].num,&cm[i]);
			x[i].id = i;
			x[i+n].id = i+n;
		}
		sort(x+1,x+1+n*2);
		for(int i = 1;i <= n*2;i++)
		{
			arr[x[i].id] = i;
		}
		for(int i = 2;i <= n*2;i++)
		{
			if(x[i].num == x[i-1].num)
			{
				Merg(arr[x[i].id],arr[x[i-1].id]);
			}
		}
		for(int i = 1;i <= n;i++)
		{
			if(cm[i] == 1)
			{
				Merg(arr[i],arr[i+n]);
			}
		}
		bool flag = true;
		for(int i = 1;i <= n;i++)
		{
			if(cm[i] == 0)
			{
				if(get_f(arr[i]) == get_f(arr[i+n]))
				{
					flag = false;
					break;
				}
			}
		}
		if(flag)
		{
			puts("YES");
		}
		else
		{
			puts("NO");
		}
	}
	return 0;
}

单调栈

保持栈中元素是单调递减/递增的,入栈元素违反单调性时将栈内元素出栈直至符合单调性

出栈时对出栈元素进行操作记录答案,同时对入栈元素属性进行相应处理

题目来源:玉蟾宫

背景:给出一个只包含01的矩阵,求包含1的矩形的最大面积

思路:枚举每一行,求出包含i,j点的高度为h的矩形向左向右延伸的最长距离

维护一个单调递增的栈,栈中每个元素存储各自的高和宽

typedef struct node{
	int h,len;
}node;

枚举每一行元素,以高为标准维护一个严格单调递增的栈(若不严格递增则会造成重复计算)

每次入栈时若不违反单调性则将宽度设置为1,表示有一个宽为1,高为h的矩形进栈

  • 当入栈元素高度小于栈顶时,所有违反单调性的栈顶元素依次出栈,设置一个变量sum记录该次出栈元素的宽度之和,每个元素出栈时记录答案,即高为h宽为sum的矩形的面积;
  • 因为栈中元素是严格单调的,所以依次出栈的元素高度严格递减,该高度为h的矩形的面积为h*(自身宽度+本次出栈在该元素之前的元素的总宽度)

但栈为空或栈顶元素小于即将入栈的元素时停止出栈,入栈元素的宽度为1+sum(即出栈元素宽度之和)

for(int i = 1;i <= n;i++)
{
	g[1].h = arr[i][1];
	g[1].len = 1;
	st.push(g[1]);//将第一个元素入栈
	for(int j = 2;j <= m;j++)
	{
		int sum = 0;
		while(!st.empty() && arr[i][j] <= st.top().h)//确保栈严格单调递增
		{
			sum += st.top().len;//记录宽度为出栈元素宽度和
			ans = max(ans,st.top().h*sum);//面积为长乘宽
			st.pop();
		}
		g[j].h = arr[i][j];//设置入栈元素高度
		g[j].len = sum + 1;//设置入栈元素宽度
		st.push(g[j]);
	}
    //当结束时将所有为出栈的元素出栈,记录答案
	int sum = 0;
	while(!st.empty())
	{
		sum += st.top().len;
		ans = max(ans,st.top().h*sum);
		st.pop();
	}
}

题目来源:长方形

背景:给出一个01矩阵,求能得到只包含1的矩形有多少个

思路:首先知道一点,一个高为h宽为m的矩形能切出宽为m的矩形数量为1+2+3+...+n,即高度/宽度每增加1,之前所有同宽度/同宽度的矩形可组成不同矩形的数量+1。

考虑使用单调栈来维护以i,j为底的矩形能向两边延伸多少高度相同的长度

  • 对于每个i,j分别向两边进行延伸,以高h为标准,一边要严格单调,一边非严格单调(可以等于),避免重复计算一个矩形,记录为R[j],L[j]
  • 找到左边严格递增的数量,右边非严格递增的数量(保证当前矩形高度最小),这里通过记录下标来计算左右延伸距离,即R[i],L[i]表示i点左右所能延伸到的最远点
//利用一次遍历反向求出某点单调递减所能延伸的最远距离,该点即为目标点单调递增所能延伸的最远点
for(int j = m;j >= 1;j--)
{
	while(!st.empty() && h[i][j] < h[i][st.top()])//向左延伸严格单调递减
	{
		l[st.top()] = j;//记录栈顶元素所能延伸的最远位置
		st.pop();
	}
	st.push(j);
}
while(!st.empty())//剩下的元素都可以延伸到左边界
{
	l[st.top()] = 0;
	st.pop();
}
for(int j = 1;j <= m;j++)
{
	while(!st.empty() && h[i][j] <= h[i][st.top()])//向右延伸非严格单调递减
	{
		r[st.top()] = j;//记录栈顶元素所能延伸的最远位置
		st.pop();
	}
	st.push(j);
}
while(!st.empty())//剩下的元素都可以延伸到右边界
{
	r[st.top()] = m+1;
	st.pop();
}

计算答案:向左延伸到L[j],向右延伸到R[j],则任意不同两点所在的矩形配合都可使包含当前矩形的数量增加h,则当前矩形可获得的收益为(j - L[j]) * (R[j] - j) * h;枚举所有行计算出所有单列矩形的收益之和即为答案

for(int j = 1;j <= m;j++)//注意放在循环中每行都要计算
{
	ans += (j-l[j]) * (r[j]-j) * h[i][j];
}

简单证明:

一个矩形向左延伸任意长度距离(无延伸时长度为自身1)并且向右延伸任意长度距离(无延伸时长度为自身1)都可使当前矩形的每一行子矩形变成一个新的矩形,即总增加个数为h,设向左最大延伸长度为x1,向右最大延伸长度为x2(不能延伸时记为只有一种方案,即为自身长度1),则共有x1*x2种左右同时延伸的可能方案

核心代码:

for(int i = 1;i <= n;i++)
{
	for(int j = m;j >= 1;j--)
	{
		while(!st.empty() && h[i][j] < h[i][st.top()])
		{
			l[st.top()] = j;
			st.pop();
		}
		st.push(j);
	}
	while(!st.empty())
	{
		l[st.top()] = 0;
		st.pop();
	}
	for(int j = 1;j <= m;j++)
	{
		while(!st.empty() && h[i][j] <= h[i][st.top()])
		{
			r[st.top()] = j;
			st.pop();
		}
		st.push(j);
	}
	while(!st.empty())
	{
		r[st.top()] = m+1;
		st.pop();
	}
	for(int j = 1;j <= m;j++)
	{
		ans += (j-l[j]) * (r[j]-j) * h[i][j];
	}
}

单调队列

主要应用:求每一个连续长度为k区间的最大(最小)值、优化dp

题目来源:琪露诺

背景:⑨初始在编号为0的格子,每次能从当前位置i跳到[i+L,i+R]中的一个位置,每个位置有一个冰冻指数,跳到对岸后能获得的最大冰冻指数

思路:不难看出这是一道dp题,每次能从i跳到[i+L,i+R]中的一个位置,也就是说当前位置是由[i-R,i-L]中的一个点跳跃而来,跳到当前位置时获得的总冰冻指数也就是[i-R,i-L]其中一个位置中已获得的总冰冻指数的值加上当前格子的值,不难推出

  • 状态转移方程:dp[i] = max(dp[i-R,i-L]) + arr[i]

现在的核心在于如何求得[i-R,i-L]中的最大值,这里有很多方法,例如线段树单点修改、优先队列、双端队列、单调队列等,这里介绍单调队列的方法

理解了单调栈的思路后很容易理解单调队列:

  • 队列中的元素保持严格单调,由于每次只能取头部元素,所以单调递增的队列是尾部向头部递增,而每次只能从尾部插入,所以在判断的时候要判断当前元素是否比尾部元素小,若比尾部元素大则违反了单调性,就要从尾部依次删除元素(尾指针移动),直到符合要求。

什么时候动头指针呢?很显然,要求一个区间为k的区间中的最大值,那么队列的长度最大应为k,当长度大于k的时候,说明当前头元素已经不再这个区间内,所以弹出头元素(头指针移动);新的头元素即为当前区间的最大值。

下面给出代码:

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

int arr[200010];
int dp[200010];
int que[200010];//单调队列中存储元素所在的下标
int tail = 1,head = 1;//头尾下标
int main()
{
	int n,l,r,ans = -0x3f3f3f3f;
	scanf("%d %d %d",&n,&l,&r);
	for(int i = 0;i <= n;i++)
	{
		scanf("%d",&arr[i]);
	}
	memset(dp,128,sizeof(dp));//初始化dp数组值为最小值
	dp[0] = 0;//0位置为起始位置
	for(int i = l;i <= n;i++)//i从l开始,为从0起跳最近能到达的位置
	{
        //特别注意尾指针不能小于头指针
        //当当前获得的总值的值要比尾部所存的大的时候违反了单调性,尾指针左移
		while(dp[i-l] >= dp[que[tail]] && tail >= head)
		{
			tail--;
		}
		que[++tail] = i-l;//当前元素插入
        //当头元素所在的位置已经不能到达当前位置,头指针右移
		while(que[head]+r < i && tail >= head)
		{
			head++;
		}
		int pos = que[head];//获取头元素(最大元素)的位置
		dp[i] = dp[pos] + arr[i];
		if(i+r > n)//当下一步能够跳到对岸的时候开始记录答案
		{
			ans = max(ans,dp[i]);
		}
	}
	printf("%d",ans);
	return 0;
}

贪心思维

核心思想:每一步走选择当前最优解,最后得到的结果就是总的最优解(受限)

推销员

题目来源:推销员

背景:一条死胡同有n家住户,出口入口为同一个地方,第i家住户离入口的距离为Si,小明现要给x家住户依次推销产品,每走1距离就会产生1疲惫感,同时向第i家住户推销产品也会产生Ai疲惫感,现求在不走多余的路的前提下对于不同的x所积累的疲惫感的最高值

思路:先理解一下题意,题意要求是求推销x家所能积累的最高值,而且不能走多余的路,假设a,b,c,d四家按照离原点的距离增序排列,如果只推销a,b,c,则不能走到d什么都不干然后返回去a,b,而且也不能先走到b然后再走到a再走到c,但是可以走到c然后走到b再走到a直接回原点,一旦回头就不能再回头了,因为这样一定会造成重走的部分,这点不难理解。

然后分析题目,走到一个点去推销可能有两重可能:

  • 1.从原点开始走过来,产生的收益为Si + Ai(暂时不算返回)
  • 2.从上一个点走来,产生的收益为当前收益 + 两点间的距离 + Ai

此时我们发现:

  • 走路所产生的收益完全取决于推销到的离原点最远的一家,来回收益为2*Si + Ai
  • 距离比最远距离小的住户所带来的收益为Ai;
  • 产生的总利益为:max(Si)*2 + \sum_{i =1}^{x}Ai

要求我们求最大值,所以每一次都选择能带来收益最大的一个位置就好了

假设当前位置为now,now左边带来的收益为A(i),右边带来的收益为S(j) - S(now) + A(j);

由此可知只需要每次比较左边一段区间里没到达过的的最大值和右边一段区间里没到达过的最大值的最大值即可得出当前能得到的最大利益,即:

  • val = max(max(val[1...l]),max(val[r...n]))

开两个结构体类型l,r,分别用来存储左边元素和右边元素的信息,左边只需要存id(表示是第几个住户)和cast(表示对该用户推销产生的疲惫),而右边需要存id,cast和dis(表示距原点的距离),因为选择右边需要更新最大距离;

typedef struct r{
	int id,dis,cast;
	bool operator < (const r& a) const
	{
		return (dis+cast) < (a.dis+a.cast);
	}
}r;
typedef struct l{
	int id,cast;
	bool operator < (const l& a) const
	{
		return cast < a.cast;
	}
}l;
priority_queue<l> ql;
priority_queue<r> qr;

可以看到代码中加入了一个判断,左区间按照疲惫值排序,右区间还要加上距离产生的疲惫值

分别开两个优先队列,这样可以每次以logn的时间来找到最大值

  • 第一步先找出只推销一家时的最大疲惫,这个很好求,只需要考虑来回路径和单次产生的疲惫即可
int n;
scanf("%d",&n);
int now = 0,mn = 0;//mn表示最大收益,now表示选取的位置
for(int i = 1;i <= n;i++)
{
	scanf("%d",&arr[i].s);
}
for(int i = 1;i <= n;i++)
{
	scanf("%d",&arr[i].a);
	int num = arr[i].s *2 + arr[i].a;
	if(num > mn)
	{
		mn = num;
		now = i;
	}
}

选完第一个之后就可以开始分左右区间了,记得每次都要输出

for(int i = 1;i < now;i++)
{
	ql.push((l){i,arr[i].a});//小于now的为左区间
}
for(int i = now+1;i <= n;i++)
{
	qr.push((r){i,arr[i].s,arr[i].a});//大于now的为右区间
}
cout << mn << endl;

每次选取之和如何更新队列中的元素呢?

我们知道如果选取了右边的元素的话,那么这个元素左边的都变为了左区间,也就是将从now到右边某元素的id位置全都放进ql队列,同时这一区间的元素要从右边队列出队(重点)

右边队列都是按照总收益来排序的,如何按照id从小到大来出队呢?好像并不能这样操作

既然没办法删去,那我们索性就不删了,因为实际上并不需要这样操作

我们知道如果选了右边元素的话now的值也会更新为新的id,所以

  • 当下一次查询右区间的时候先比较队头元素的id和当前的now,大于(不可能会出现等于)则没问题,符合要求,小于的话先将该元素出列,然后再查询,直到大于位置

对应的,左区间就很好办了,只需要将答案加上Ai,并不需要更改其他元素,因为不影响最大距离

for(int i = 2;i <= n;i++)
{
	while(!qr.empty() && qr.top().id <= now)//当队头元素id小于now时出队
	{
		qr.pop();
	}
	int nl = 0;
	int nr = 0;
    //!!!特别注意要保证队列不为空的时候才能取队头元素,不然就是0,不判断的话不会报错但运行会越界
	if(!ql.empty())
	{
		nl = mn + ql.top().cast;
	}
	if(!qr.empty())
	{
		nr = qr.top().dis*2 + qr.top().cast + mn - arr[now].s*2;
	}
	if(nr >= nl)
	{	
        for(int i = now+1;i < qr.top().id;i++)//选择右区间元素则将多出来的一段区间转移到左区间
		{
			ql.push((l){i,arr[i].a});
		}
		now = qr.top().id;//更新now
		mn = nr;//更新答案
		qr.pop();//记得出队
	}
	else
	{
		mn = nl;//选择左区间元素只需要更新答案
		ql.pop();
	}
	cout << mn << endl;
}

这道题还是很容易的,主要就是要敢想的同时也敢写,除了队列判空基本没什么雷点

总代码:

#include<iostream>
#include<queue>
#include<algorithm>
using namespace std;

typedef struct r{
	int id,dis,cast;
	bool operator < (const r& a) const
	{
		return (dis+cast) < (a.dis+a.cast);
	}
}r;
typedef struct l{
	int id,cast;
	bool operator < (const l& a) const
	{
		return cast < a.cast;
	}
}l;

struct node{int s,a;} arr[100010];
priority_queue<l> ql;
priority_queue<r> qr;
int main()
{
	int n;
	scanf("%d",&n);
	int now = 0,mn = 0;
	for(int i = 1;i <= n;i++)
	{
		scanf("%d",&arr[i].s);
	}
	for(int i = 1;i <= n;i++)
	{
		scanf("%d",&arr[i].a);
		int num = arr[i].s *2 + arr[i].a;
		if(num > mn)
		{
			mn = num;
			now = i;
		}
	}
	for(int i = 1;i < now;i++)
	{
		ql.push((l){i,arr[i].a});
	}
	for(int i = now+1;i <= n;i++)
	{
		qr.push((r){i,arr[i].s,arr[i].a});
	}
	cout << mn << endl;
	for(int i = 2;i <= n;i++)
	{
		while(!qr.empty() && qr.top().id <= now)
		{
			qr.pop();
		}
		int nl = 0;
		int nr = 0;
		if(!ql.empty())
		{
			nl = mn + ql.top().cast;
		}
		if(!qr.empty())
		{
			nr = qr.top().dis*2 + qr.top().cast + mn - arr[now].s*2;
		}
		if(nr >= nl)
		{	for(int i = now+1;i < qr.top().id;i++)
			{
				ql.push((l){i,arr[i].a});
			}
			now = qr.top().id;
			mn = nr;
			qr.pop();
		}
		else
		{
			mn = nl;
			ql.pop();
		}
		cout << mn << endl;
	}
	return 0;
}
  • 21
    点赞
  • 20
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值