搜索

Part -1 一些修改

  • 24.7.5 初稿
  • 24.11.11 删去了一部分的内容,增加了几道例题
  • 24.11.12 增加 Part 4 双向搜索/折半搜索 部分,增加了若干例题,完善了格式。
  • 24.11.20 修改格式

Part 1 什么是搜索?

搜索,也就是对状态空间进行枚举,通过穷尽所有的可能来找到最优解,或者统计合法解的个数。

搜索有很多优化方式,如减小状态空间,更改搜索顺序,剪枝等。

搜索是一些高级算法的基础。在 OI 中,纯粹的搜索往往也是得到部分分的手段,但可以通过纯粹的搜索拿到满分的题目非常少。

在我们遇到的一些问题当中,有些问题不能够确切的建立数学模型,或即便有数学模型但该模型的准确方法也不一定能运用现成算法。在要求枚举方案时,常常会遇到这一类问题。

搜索一般是用于枚举,在方法不明确时作为一种不完美算法使用。

名词解释:

  • 状态:指当前所面临的具体问题。
  • 转移:指从一个状态到另一状态的一种决策。

状态和转移可能是题目中已经给出,也可能是需要自己分析出的。一道题的状态与决策可能有多种。

  • 产生式系统:把状态通过转移得到的一颗状态树,称作产生式系统。

Part 2 广度优先搜索 BFS

在搜索算法中,我们对每个节点进行拓展,深度越小的结点越先得到扩展,就是广度优先搜索法。

广度优先搜索是注重广度,一般用于解决最短步之类的问题。

一般来说,广度优先搜索是使用队列(queue)实现。

由于是按移动步数从少到多产生新布局的,所以找到的第一个目标一定是移动步数最少的一个,也就是最优解。

搜索的关键是转移,只要转移方法找到,剩下的就是套板子。

void bfs()
{
    q.push(初始状态);
    while(!q.empty())
    {
        int f=q.front();
        q.pop();
        if(f为目标)
        {
            输出
            return;
        }
        对f进行操作
        对f转移,入队
    }
}

  • 图 1:BFS 的图示

T1 P1135 奇怪的电梯

这里是原题。

题意简述

  • 给定起点和终点。
  • 对于第 \(i\) 层,如果有对应的层数,你可以向上走或向下走 \(K_i\) 层。
  • 求从起点到终点的最短步数或报告无解。

一道经典的 BFS 练习题。

考虑每次转移,设当前队头为 \(k\),那么可以从 \(k\)\(k+K_k\) 或是到 \(k-K_k\)

然后就是愉快的套板子时间,记得判重。

冷知识:这道题使用 Kitten 4 比使用 C++ 测样例快了整整一帧

AC 代码:

#include<bits/stdc++.h>
using namespace std;
queue<int> qu,stp;
bool isfind[1005];
int n,k[1005],st,en;
void bfs()
{
	while(!qu.empty())
	{
		if(qu.front()==en)//结束 
		{
			cout<<stp.front();
			return;
		}
		if(isfind[qu.front()])//已经搜索过了,跳过 
		{
			qu.pop();
			stp.pop();
			continue;
		}
		isfind[qu.front()]=1;//记得判重
		if(qu.front()+k[qu.front()]<=n)//向上 
		{
			qu.push(qu.front()+k[qu.front()]);
			stp.push(stp.front()+1);
		}
		if(qu.front()-k[qu.front()]>=1)//向下 
		{
			qu.push(qu.front()-k[qu.front()]);
			stp.push(stp.front()+1);
		}
		qu.pop();
		stp.pop();
	}
	cout<<"-1";//搜索完毕,无解 
 } 
int main()
{
	cin>>n>>st>>en;
	for(int i = 1; i <= n; i++) cin>>k[i];
	qu.push(st);
	stp.push(0);
	bfs();
}

T2 P1379 八数码难题

我知道这样直接上强度不好,但这是我很老以前的博客啊

八数码难题是一道经典的搜索题。

题意简述

  • 解 3*3 的华容道。

首先,题目提到了 “最少步骤的移动方法”,很明显使用广搜

但是,状态和转移方法没有找到,也做不了。

显然,状态目前华容道,可以考虑使用二维数组或者字符串存储。

转移不能对数码讨论,要对一个可以移动的“空格”进行讨论

空格在棋盘上有上下左右四种移动方式,只需要先判断是否可行,再将空格与对应方向的数字进行转换操作即可。

但是这还不够快。

考虑到一个方法可以被计算多次,考虑判重优化

下面是使用字符串的方法。

为了实现判重,这需要你有 map、字符串转数字或者哈希算法的基础。

对于 map:考虑使用一个 string,bool 类型的 map 来记录字符串之前是否出现过,需要时直接调用即可。

字符串的移动操作有点难想。考虑下面四种操作:

  1. 向上移动,字符串位置 \(-3\)
  2. 向左移动,字符串位置 \(-1\)
  3. 向右移动,字符串位置 \(+1\)
  4. 向下移动,字符串位置 \(+3\)

总结出规律:设目前空格所在的位置为 \(p\),那么对于第 \(i\) 个操作,这个位置要和 \(p+2\times i-5\) 的位置转换位置。

使用上面这种方法有个小坑,例如你目前处在棋盘正右边的位置,你就不能执行方案 3,因为这样会将空格和左下角的方块调换位置,这显然是不合法的

考虑打 bool 类型的表解决上面的问题,手动判断当前位置能执行哪种操作。

综合以上信息可以写出以下 AC 代码:

#include<bits/stdc++.h>
using namespace std;
struct place
{
	string str;
	int p;
};
bool canw[5][9]={{0,0,0,0,0,0,0,0,0},{0,0,0,1,1,1,1,1,1},{0,1,1,0,1,1,0,1,1},{1,1,0,1,1,0,1,1,0},{1,1,1,1,1,1,0,0,0}};
string str,inp,nstr,f;
map<string,bool> mp;
queue<place> q;
int main()
{
	cin>>inp;
	//mp[inp]++;
	place pl;
	pl.str=inp;
	pl.p=0;
	q.push(pl);
	while(!q.empty())
	{
		place f=q.front();
		q.pop();
		if(f.str=="123804765")//终点
		{
			cout<<f.p<<endl;
			return 0;
		}
		if(mp[f.str]) continue;
		mp[f.str]=1;
		int spa;
		for(int i = 0; i < 9; i++)
		{
			if(f.str[i]=='0') spa=i;
		}
		for(int i = 1; i <= 4; i++)
		{
			place nn=f;
			if((spa+2*i-5)>=0&&(spa+2*i-5)<9&&canw[i][spa])//转移
			{
				nn.str[spa]=nn.str[spa+2*i-5];
				nn.str[spa+2*i-5]='0';
				nn.p=nn.p+1;
				q.push(nn);
			}
		}
	}
	cout<<"Failed";//这个玩意不会被执行
}

T3 P1432 倒水问题

here

题意简述

  • 给定两个容量为 \(C_a,C_b\) 的无刻度水桶,要求倒出 \(N\) 升水。
  • 每次可以互相转移水,或是倒满/倒空一桶水。
  • 求最小步数并输出方案。

这个题比上一个题简单一些,它只有六种转移。

现在我们令水池为 \(P\),那么可行的方案就是:

  • \(A \leftrightarrow B\)
  • \(A \leftrightarrow P\)
  • \(B \leftrightarrow P\)

仅需对上述转移方法进行可行性判断并进行转移即可。

需要注意,因为要输出方案,存储方法一定不能使用 STL 的 queue,自己手写。结构体中还要单独开一个 father,记录这个状态是由哪个状态转移而成。

对于判重优化:建议使用编码,令目前状态 \((a,b)\) 的编码值为 \(1001 \times a + b\),直接开一个 bool 类型的数组解决。

多测不清空,WA 两行泪

#include<iostream>
#include<stack>
#include<algorithm> 
#include<cstring>
using namespace std;
struct p
{
	int fa,x,y,p;
};
p a[100005];
int t,cy,cx,cn,fst,lst,pp,opt[100005];
bool isf[5000005];
stack<int> qq;
int f(int x,int y)
{
	return 1001*x+y;
}
int main() {
    cin>>t;
    while(t--)
    {
    	memset(isf,0,sizeof(isf));
    	cin>>cx>>cy>>cn;
    	a[1]={0,0,0,0};
    	fst=lst=1;
    	//mp=emp;
    	while(fst<=lst)
    	{
    		if(a[fst].y==cn)
    		{
    			pp=fst;
    			cout<<a[fst].p<<" ";
    			break;
			}
			p now=a[fst];//记录临时变量
			if(a[fst].x>0)//x空的转移
			{
				now.x=0;//进行转移
				if(!isf[f(now.x,now.y)])//没有搜索过的话
				{
					isf[f(now.x,now.y)]=1;//就去搜索
					now.fa=fst;//记录父亲(什么转移而来)
					now.p=a[fst].p+1;//记录步数
					lst++;//队列末尾++
					opt[lst]=3;//记录操作
					a[lst]=now;//保存
				}
			}
			now=a[fst];//重置
			if(a[fst].y>0)//y空的转移,下面同理
			{
				now.y=0;
				if(!isf[f(now.x,now.y)])
				{
					isf[f(now.x,now.y)]=1;
					now.fa=fst;
					now.p=a[fst].p+1;
					lst++;
					opt[lst]=4;
					a[lst]=now;
				}
			}
			now=a[fst];
			if(a[fst].x<cx)//x装满的转移
			{
				now.x=cx;
				if(!isf[f(now.x,now.y)])
				{
					isf[f(now.x,now.y)]=1;
					now.fa=fst;
					now.p=a[fst].p+1;
					lst++;
					opt[lst]=1;
					a[lst]=now;
				}
			}
			now=a[fst];
			if(a[fst].y<cy)//y装满的转移
			{
				now.y=cy;
				if(!isf[f(now.x,now.y)])
				{
					isf[f(now.x,now.y)]=1;
					now.fa=fst;
					now.p=a[fst].p+1;
					lst++;
					opt[lst]=2;
					a[lst]=now;
				}
			}
			now=a[fst];
			if(a[fst].x>0&&a[fst].y<cy)//x到y的转移
			{
				now.x=a[fst].x-min(a[fst].x,cy-a[fst].y);
				now.y=a[fst].y+min(a[fst].x,cy-a[fst].y);
				if(!isf[f(now.x,now.y)])
				{
					isf[f(now.x,now.y)]=1;
					now.fa=fst;
					now.p=a[fst].p+1;
					lst++;
					opt[lst]=6;
					a[lst]=now;
				}
			}
			now=a[fst];
			if(a[fst].y>0&&a[fst].x<cx)//y到x的转移
			{
				now.x=a[fst].x+min(a[fst].y,cx-a[fst].x);
				now.y=a[fst].y-min(a[fst].y,cx-a[fst].x);
				if(!isf[f(now.x,now.y)])
				{
					isf[f(now.x,now.y)]=1;
					now.fa=fst;
					now.p=a[fst].p+1;
					lst++;
					opt[lst]=5;
					a[lst]=now;
				}
			}
			fst++;
		}
		while(pp!=1)//倒序输出
		{
			qq.push(pp);
			pp=a[pp].fa;
		}
		while(!qq.empty())//输出
		{
			cout<<opt[qq.top()]<<" ";
			qq.pop();
		}
		cout<<endl;
	}
    return 0;//华丽收尾
}

上面只是转移的一小部分例子,转移公式还需要通过多练习积累。

Part 3 深度优先搜索 DFS

深度优先搜索优先拓展后拓展出的节点,这种算法也叫做回溯法,是搜索算法中的一种控制策略,也是求解特殊型计数题或较复杂的枚举题中使用频率最高的一种算法。

深度优先搜索是注重深度,是“不撞南墙不回头”的搜索。一般用于解决枚举或计数类的问题。

一般来说,广度优先搜索是使用栈(stack)或递归实现。

深度优先搜索的板子如下:

void dfs(int layer,int 参数1,int 参数2......)
{
    if(layer>目标层数)
    {
        输出/记录答案
        return;
    }
    进行遍历
    {
        if(当前遍历的方法可行)
        {
            dfs(layer+1,新参数...);
        }
    }
}

深度优先搜索的一般剪枝:

if(已经不满足条件) return;

深度优先搜索主要考的就是一个剪枝,及时地去除无效的答案,可以大大提升程序运行的速度。

  • 图 2:DFS 的图示和剪枝(被橙色选中的部分是不可能的,最大深度只有 3)

T1 P1135 奇怪的电梯

这里是原题。

题意简述

  • 给定起点和终点。
  • 对于第 \(i\) 层,如果有对应的层数,你可以向上走或向下走 \(K_i\) 层。
  • 求从起点到终点的最短步数或报告无解。

一题多解

一道经典的 DFS 练习题。

考虑每次转移,设当前搜索的楼层为 \(k\),那么可以从 \(k\) 转移到到 \(k+K_k\) 或是到 \(k-K_k\)

然后就是愉快的套板子时间。

一个显然的优化是考虑当前的转移方案是否比目前的最优解优秀,只有更优秀才去搜索。因为这道题的转移满足无后效性(已经求解的子问题,不会再受到后续决策的影响),所以我们才能去这样剪枝。

冷知识:这道题使用 Kitten 4 比使用 C++ 测样例没法比,因为 Kitten 4 MLE 了(悲)

AC 代码:

#include<cstring>
#include<iostream>
#include<algorithm>
using namespace std;
int n,k[205],a,b;
int isFind[205];
void dfs(int now,int step)
{
	isFind[now]=step;//先保存
	if(now+k[now]<=n&&(step+1)<isFind[now+k[now]])//如果解更优就去更新
		dfs(now+k[now],step+1);
	if(now-k[now]>=1&&(step+1)<isFind[now-k[now]])
		dfs(now-k[now],step+1);
	return;
}
int main(){
	cin>>n>>a>>b;
	memset(isFind,0x3f,sizeof(isFind));//重置访问数组
	for(int i = 1; i <= n; i++)
	{
		cin>>k[i];
	}
	dfs(a,0);//搜索,启动!
	if(isFind[b]>0x1f1f1f1f)//不可能到达
	{
		cout<<-1;
	}
	else
	{
		cout<<isFind[b];
	}
	return 0;
}

好,如果基础减枝都会了怎么办?试试下面的数学剪枝:

T2 P1731 [NOI1999] 生日蛋糕

本题就是一道练习剪枝的好题。

题意简述

  • 给定若干层圆柱形蛋糕的总体积 \(N\pi\) 和总层数 \(M\)
  • 要求每一层蛋糕的半径和高度比下一层蛋糕要低。
  • 求出蛋糕的最小外表面积 \(S\pi\)

首先写出标准的做法。考虑以下四个剪枝:

定义 \(s_i\) 为目前蛋糕的表面积,\(v_i\) 为目前蛋糕的体积,\(ans\) 为目前的最好答案,\(r_i\) 为目前枚举的半径,\(h_i\) 为目前枚举的高,\(l\) 为目前枚举到的层数。

  • \(s_i > ans\) 返回。(没有搜索的必要,无法打破记录。)
  • \(s_i + \frac{2\times v_{i-1}}{r_i}\) 则跳过。

为什么?

因为:

\[\begin{aligned}2(n-v) & = \sum\limits_{j=l+1}^mr_j^2\times h_j\\ & = \sum\limits_{j=l+1}^mr_j \times s_j\\ & \leq r \times \sum\limits_{j=l+1}^ms_j \end{aligned}\]

所以:

\[r \times \sum\limits_{i=l+1}^ms_i\geq \frac{2\times v_l}{r_{l+1}} \]

就能得到这个条件。

  • \(t < \sum\limits_{j=1}^{m-l}j^3\) 则返回,前面的函数使用预处理来节省时间。(后面的蛋糕最少用这些,这些都能欠材料肯定不行。)
  • \(t > \sum\limits_{j=l+1}^m(r_l-(j-l))^2\times (h_l-(j-l))\) 则返回。(后面的蛋糕最多用这些,这些都用不完肯定不行。)

用预处理完成优化 3,然后就是套板子。

#include<iostream>
#include<algorithm>
#include<cmath>
using namespace std;
int maxx[20][20][20005],n,m,s,t,nowr[20],nowh[20],minans=1145141919,minx[20];
void dfs(int layer,int nowv,int nows)
{
	//1cout<<"nowdfs:("<<layer<<","<<nowv<<","<<nows<<")"<<endl;
	if(layer>m&&nowv==0)
	{
		minans=min(minans,nows);
		//cout<<minans<<endl;
		return;
	}
	if(layer>m) return;
	if(nows>minans) return;
	if(nowv<minx[m-layer]) return;
	
	if(layer>1)
	{
		int tmp=0;
		for(int j = 1; j <= m-layer+1; j++)
		{
			tmp+=(nowr[layer-1]-j)*(nowr[layer-1]-j)*(nowh[layer-1]-j);
		} 
		if(nowv>tmp)
		{
			return;
		}	
	}
	
	if(layer==1)
	{
		//cout<<123<<endl;
		int nowmaxi=(int)sqrt(n);
		for(int i = m; i <= nowmaxi; i++)
		{
			int nowmaxj=n/(i*i);
			for(int j = m; j <= nowmaxj; j++)
			{
				nowr[1]=i;
				nowh[1]=j;//if(v-(r[x-1])*(r[x-1])*(h[x-1])*z>0) return;
				dfs(2,nowv-(i*i*j),nows+(i*i+2*i*j));
			}
		}
	}
	else
	{
		//cout<<123<<endl;
		int nowmaxi=min((int)sqrt(nowv),nowr[layer-1]-1);
		for(int i = nowmaxi; i >= (m-layer+1); i--)
		{
			int nowmaxj=min(nowv/(i*i),nowh[layer-1]-1);
			if(nows+2*nowv/i > minans) break;
			for(int j = (m-layer+1); j <= nowmaxj; j++)
			{
				//cout<<123<<endl;
				nowr[layer]=i;
				nowh[layer]=j;
				//if((nowv-(i*i*j))<=maxx[layer][i][j]) dfs(layer+1,nowv-(i*i*j),nows+(2*i*j));
				dfs(layer+1,nowv-(i*i*j),nows+(2*i*j));
			}
		}
	}
}
int main()
{
	cin>>n>>m;
	for(int i = 1; i <= m; i++)
	{
		minx[i]=minx[i-1]+i*i*i;
	}
//	for(int i = 1; (i-1) * (i-1) <= n; i++)
//	{
//		for(int j = 1; j <= (n+i*i-1)/(i*i); j++)
//		{
//			int ss=0;
//			for(int k = m; k >= 1; k--)
//			{
//				ss+=(i-k)*(i-k)*(j-k);
//				maxx[k][i][j]=ss;
//			}
//		}
//	}
	//cout<<23;
	dfs(1,n,0);
	if(minans==1145141919) cout<<0;
	else cout<<minans;
	
 } 

Part 4 双向搜索

双向同时搜索的基本思路是从状态图上的起点和终点同时开始进行 广搜 或 深搜。

如果发现搜索的两端相遇了,那么可以认为是获得了可行解。

一个很简单的例子,就是有的时候有些枝删不掉又太远了,这个时候就可以考虑从起点和终点同时进行搜索,并求它们的交点。

  • 图 3:普通搜索 vs. 双向搜索

T1 P2324 [SCOI2005] 骑士精神

here

题意简述

  • 给定一个 \(5\times5\) 大小的国际棋盘,上面有 \(24\) 头马。
  • 马走日。
  • 求出把初始状态变为最终状态的最小步数,若步数大于 \(15\) 步或无解输出 -1

双向搜索之经典。

考虑从初始状态和最终状态搜索。

初始状态向下搜索 \(11\) 层,最终状态向下搜索 \(5\) 层。

如果这两次搜索搜到的点有交集,则说明有方案,反之则没有。

最后输出步数(初始搜索的步数+最终搜索的步数)即可。

关于实现细节:推荐使用 unordered_map 存储最小步数,并在最终搜索时记录答案。

参考代码:

#include<bits/stdc++.h>
using namespace std;
int dx[8]={2,2,1,1,-1,-1,-2,-2};
int dy[8]={1,-1,2,-2,2,-2,1,-1};
//string board2str(board pp)
//{
//	string str="";
//	for(int i = 1; i <= 5; i++)
//	{
//		for(int j = 1; j <= 5; j++)
//		{
//			str+=(pp.b[i][j]+'0');
//		}
//	}
//	return str;
// } 
//
//void printBoard(board pp)
//{
//	cout<<"Print:"<<endl;
//	for(int i = 1; i <= 5; i++)
//	{
//		for(int j = 1; j <= 5; j++)
//		{
//			cout<<pp.b[i][j]<<" ";
//		}
//		cout<<endl;
//	}
//	cout<<endl;
//	return;
//}

unordered_map<string,int> mp,emp;
queue<string> q,eq;
queue<int> l,ell;
queue<int> ex;
queue<int> ey;
int t,nans=114514;
int nnx,nny,nnl,nx,ny,fl,el,iii;
void bfs()//初始bfs
{
	q.push("1111101111002110000100000");//1表示黑,0表示白,2表示空格
	l.push(0);
	ex.push(3);
	ey.push(3);
	while(!q.empty())
	{
		string f=q.front();
		q.pop();
		fl=l.front();
		l.pop();
		nx=ex.front();
		ny=ey.front();
		ex.pop();
		ey.pop();
		if(fl>11) return;
		if(mp[f]!=0) continue;
		mp[f]=fl;
		//cout<<fl<<endl;
		el=(nx<<2)+nx+ny-6;
		for(iii = 0; iii < 8; iii++)
		{
			nnx=nx+dx[iii];
			nny=ny+dy[iii];
			//cout<<nx<<ny;
			if(nnx>=1&&nnx<=5&&nny>=1&&nny<=5)
			{
				nnl=(nnx<<2)+nnx+nny-6;
				f[el]=f[nnl];
				f[nnl]='2';
				q.push(f);
				l.push(fl+1);
				ex.push(nnx);
				ey.push(nny);
				f[nnl]=f[el];
				f[el]='2';
			}
			//cout<<" ";
		}
		//cout<<endl;
	}
 } 
void rbfs()//最终bfs
{
	while(!q.empty())
	{
		string f=q.front();
		q.pop();
		fl=l.front();
		l.pop();
		nx=ex.front();
		ny=ey.front();
		ex.pop();
		ey.pop();
		if(mp[f]!=0) nans=min(nans,mp[f]+fl);
		if(fl>5) return;
		el=(nx<<2)+nx+ny-6;
		for(iii = 0; iii < 8; iii++)
		{
			nnx=nx+dx[iii];
			nny=ny+dy[iii];
			if(nnx>=1&&nnx<=5&&nny>=1&&nny<=5)
			{
				nnl=(nnx<<2)+nnx+nny-6;
				f[el]=f[nnl];
				f[nnl]='2';
				q.push(f);
				l.push(fl+1);
				ex.push(nnx);
				ey.push(nny);
				f[nnl]=f[el];
				f[el]='2';
			}
		}
	}
 } 
int main(){
	bfs();//初始搜索
	cin>>t;
	while(t--)
	{
		nans=114514;
		q=eq;
		l=ell;
		ex=ell;
		ey=ell;
		string ns="";
		for(int i = 1; i <= 5; i++)
		{
			for(int j = 1; j <= 5; j++)
			{
				char ch;
				cin>>ch;
				if(ch=='*')
				{
					ns+='2';
					ex.push(i);
					ey.push(j);
				}
				else ns+=ch;
			}
		}
		q.push(ns);
		l.push(0);
		rbfs();
		if(ns=="1111101111002110000100000") cout<<0<<endl;
		else if(nans>15) cout<<-1<<endl;
		else cout<<nans<<endl;
	}
	return 0;
}

扩展:Meet in the middle(折半搜索)

和前者类似,感性化理解一下:

把两边搜索的层数都调到了总层数的一半,这样子(假设处理的问题是双选择 \(n\) 层)时间复杂度就从 \(O(2^n)\) 变成了 \(O(n2^{\frac n2})\) 啦(多出来的那个 \(n\) 是处理交集)!

Part 5 总结

广搜码量大,但是适合解决最短类型的题目;
深搜码量适中,适合解决全枚举类型的题目。
而双向搜索,是介于两种方法之间,优化无果后的杀手锏。

无论哪种方法,状态与转移都是需要认真思考的问题。

这个文章算是抛砖引玉了,希望更多人来催更补充。

Part 6 参考与引用资料

在此对以上参考资料表示感谢!


本文章按照 CC BY-NC-SA 署名-非商业性使用-相同方式共享 协议共享。

\[\text{The end.}\\ \text{By \color{red}{ToastBread} }\\2024.11.13 \]

posted @ 2026-07-20 10:55  ToastBread  阅读(10)  评论(0)    收藏  举报