[C++]洛谷 机器人搬重物 dfs+记忆化剪枝+回溯详解

[原题]

机器人移动学会(RMI)现在正尝试用机器人搬运物品。机器人的形状是一个直径1.6米的球。在试验阶段,机器人被用于在一个储藏室中搬运货物。储藏室是一个N×M 的网格,有些格子为不可移动的障碍。机器人的中心总是在格点上,当然,机器人必须在最短的时间内把物品搬运到指定的地方。机器人接受的指令有:向前移动1步(Creep);向前移动2步(Walk);向前移动3 步(Run);向左转(Left);向右转(Right)。每个指令所需要的时间为1 秒。请你计算一下机器人完成任务所需的最少时间。

[输入格式]

第一行为两个正整数N,M(N,M≤50),下面NN行是储藏室的构造,0表示无障碍,1表示有障碍,数字之间用一个空格隔开。接着一行有4个整数和1个大写字母,分别为起始点和目标点左上角网格的行与列,起始时的面对方向(东E,南S,西W,北N),数与数,数与字母之间均用一个空格隔开。终点的面向方向是任意的。

[输出格式]

一个整数,表示机器人完成任务所需的最少时间。如果无法到达,输出−1。

[C++]洛谷 机器人搬重物 dfs+记忆化剪枝+回溯详解_第1张图片

[输入样例]

9 10
0 0 0 0 0 0 1 0 0 0
0 0 0 0 0 0 0 0 1 0
0 0 0 1 0 0 0 0 0 0
0 0 1 0 0 0 0 0 0 0
0 0 0 0 0 0 1 0 0 0
0 0 0 0 0 1 0 0 0 0
0 0 0 1 1 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0
1 0 0 0 0 0 0 0 1 0
7 2 2 7 S

[输出样例]

12

[解题思路]

给出一张网格图和若干障碍物,一个机器人从起点通过若干操作走到终点,求最短时间。

这道题其中一个难点在于机器人是位于一个格点上并占四个单元格的,而题目给出的所有数据都是以一个单元格为单位的。因此,我们要将格坐标处理为点坐标。

此外,机器人在一秒内可以执行五个动作中的一种,而这五个动作可以分为两类,一类是转向(左转、右转),另一类是前进(爬、走、跑)。虽然他们同属于动作,但是由于他们性质并不相同,我们完全可以对他们进行不同的处理。

看了眼洛谷上大部分题解都是bfs,那我今天就来用dfs的方法实现一下吧。当然,暴力dfs肯定是过不了用例的,因此,我们需要亿点点技巧

-深度优先搜索+记忆化剪枝+回溯-

先来看一下怎么处理这五个动作吧:

首先,为了使代码更加直观和清楚,我们先定义一个方向枚举类型Direction。在这里,我们将相反的方向设置为相反数,这样我们就可以直接用负号表示两个相反方向的关系了。相关代码如下:

//设计方向枚举类型:将相反的方向设置为相反数,这样就可以直接用负号表示两个方向相反
enum Direction
{
	East = 1,
	South = 2,
	West = -1,
	North = -2
};

如果说以上是我对转向操作的处理,那么如何处理前进操作呢?这里,我选择用dx和dy分别对四个搜索方向和三种步长进行存储(共12项),同时用一个D数组存储四个方向,满足这四个方向与dx和dy中存储的搜索方向位置相对应:对于dx[i]和dy[i],D[i%4]即为对应的搜索方向。

int dx[12] = { 0,1,0,-1,0,2,0,-2,0,3,0,-3 }, dy[12] = { 1,0,-1,0,2,0,-2,0,3,0,-3,0 };//dx和dy表示4个搜索方向及对应的3种搜索步长
Direction D[4] = { East,South,West,North };//D表示四个搜索方向,注意D与上方的dx和dy方向对应,对4取余就能得到对应的搜索方向

另外,我们再定义一个Direction dir,用于存储机器人的面朝方向(注意区别于“搜索方向”)。

下面我们梳理一下面朝方向dir与搜索方向D[i]之间的关系:

(1)若 dir == D[i] ,则说明机器人在本次前进时不需要转向。

(2)若 dir == -D[i] (这里就可以体现出将相反方向设置为相反数的便利了),则说明机器人在前进前需要两次转向操作。

(3)其余情况,机器人都只需要一次转向操作即可。

以上就是我对这五个动作进行的预处理,具体的部分我们稍后再进行讲解。

接下来,我们来看一下如何将格坐标转化成格点坐标吧:

对于一个N*M的网格图,共有(N+1)*(M+1)个格点,但是由于边缘处的格点机器人是无法到达的,我们可以将这种无效格点忽略。即,我们只需要统计处于网格图内部的格点,共(N-1)*(M-1)个。

现在我们取两个特殊的单元格来观察一下。

[C++]洛谷 机器人搬重物 dfs+记忆化剪枝+回溯详解_第2张图片

假设(0,0)处的单元格为障碍物(我们用白色坐标表示格坐标,用灰色坐标表示点坐标),可以看见,一个单元格障碍物会导致四个角上的格点无法到达。从这张图上可以看出,若单元格坐标为(i, j),则对应的四个角格点坐标分别是(i-1, j-1),(i-1, j),(i, j-1),(i, j)。由于(0,0)的特殊性,我们需要挖除网格边缘的格点,因此,我们可以得出i>0和j>0为一个边界条件

[C++]洛谷 机器人搬重物 dfs+记忆化剪枝+回溯详解_第3张图片

同样地,取单元格(N-1,M-1),我们就可以得出i

另外,对于题中给出的起点和终点坐标,我们也需要进行转换,这里减去1即可。

下面就是深度优先搜索的部分了:

首先,我们先写出暴搜的框架,分别传参搜索的当前坐标x,y,格点数组grid,当前消耗的时间time(为什么这么设计后面会进一步解释),在x,y等于终点坐标时终止搜索并更新答案。我们来看一下这段代码:

#define INF 0x3f3f3f3f
#include
#include
using namespace std;

enum Direction
{
	East = 1,
	South = 2,
	West = -1,
	North = -2
};

int dx[12] = { 0,1,0,-1,0,2,0,-2,0,3,0,-3 }, dy[12] = { 1,0,-1,0,2,0,-2,0,3,0,-3,0 };//dx和dy表示4个搜索方向及对应的3种搜索步长
Direction D[4] = { East,South,West,North };//D表示四个搜索方向,注意D与上方的dx和dy方向对应,对4取余就能得到对应的搜索方向
Direction dir;//表示当前面朝方向
int ans = INF;
int N, M;
int s_x, s_y, d_x, d_y;//起点和终点坐标

void dfs(int x, int y, vector>& grid, int time)
{
	//到终点时递归结束并更新ans
	if (x == d_x && y == d_y)
	{
		ans = min(ans, time);
		return;
	}

	for (int i = 0; i < 12; i++)
	{
		if (x + dx[i] < 0 || x + dx[i] >= N - 1 || y + dy[i] < 0 || y + dy[i] >= M - 1 || grid[x + dx[i]][y + dy[i]] == 1) continue;

		//当前方向与搜索方向相同时,不用转向,因此time+1
		if (dir == D[i % 4])
			dfs(x + dx[i], y + dy[i], grid, time + 1);
		//当前方向与搜索方向反向时,需要转两次,因此time+3
		else if (dir == -D[i % 4])
			dfs(x + dx[i], y + dy[i], grid, time + 3);
		//其他情况只需time+2
		else
			dfs(x + dx[i], y + dy[i], grid, time + 2);
	}
	return;
}

int main()
{
	//数据的读取与数组声明
	cin >> N >> M;
	vector> grid(N - 1, vector(M - 1, 0));//注意这里存储的不是单元格,而是各个格点(不包括边缘上的格点)
	for (int i = 0; i < N; i++)
	{
		for (int j = 0; j < M; j++)
		{
			int t;
			cin >> t;
			if (t)
			{
				//每一个障碍物对应四个格点位置,我们只需做好越界判断并把界内的格点赋值为1(表示不可到达)
				if (i < N - 1 && j < M - 1)grid[i][j] = 1;
				if (i > 0 && j < M - 1) grid[i - 1][j] = 1;
				if (j > 0 && i < N - 1) grid[i][j - 1] = 1;
				if (i > 0 && j > 0) grid[i - 1][j - 1] = 1;
			}
		}
	}
	cin >> s_x >> s_y >> d_x >> d_y;

	//初始方向判断
	char c;
	cin >> c;
	switch (c)
	{
	case 'E':
		dir = East;
		break;
	case 'S':
		dir = South;
		break;
	case 'W':
		dir = West;
		break;
	case 'N':
		dir = North;
		break;
	}

	//因为我们算的是格点,因此两个坐标都要-1后再使用
	d_x--; d_y--;

	//调用dfs
	dfs(s_x - 1, s_y - 1, grid, 0);

	//对结果的输出:若ans仍为INF,则说明题目无解,否则输出ans
	if (ans >= INF) cout << -1;
	else cout << ans;
	return 0;
}

但是,假如你将这段代码拷贝到编译器中并尝试运行,会发现程序直接产生报错。到底是哪里出问题了呢?

下面,我们分四步对程序进行完善和优化:

(注:建议结合最后的代码解读相应内容)

(1)避免“跨越式前进”同时搜索剪枝

我们来看下面这种情形(红色代表终点位置,灰色标出的格点为不可到达部分)

[C++]洛谷 机器人搬重物 dfs+记忆化剪枝+回溯详解_第4张图片

显然,这种情形应当输出-1。但是我们模拟一下上述程序的运行过程会发现,倘若我们向右搜索一格、两格时,机器人会落在灰点上,但当我们搜索三格时,机器人可以直接越过障碍物到达终点位置。

这种跨越式的前进并非是我们所期望的,在这里,我声明一个bool isAccess[4]且初值为true,用来判断某个方向是否为通路,设计思路如下:

由于我们的搜索方式在每个方向上是按前进格数递增的顺序进行的,如果在一个方向上前进1格时就遇到了障碍物,那么就没必要对前进2、3格的情况进行判断了,因此我们只需要将isAccess[i % 4]的部分设置为false,后续搜索时就可以直接进行剪枝,也同时避免了“跨越式前进”发生的可能。

(2)对已经过路径进行改值与回溯

思考下面这种最简单的情形(即没有任何障碍物):

[C++]洛谷 机器人搬重物 dfs+记忆化剪枝+回溯详解_第5张图片

假如我们不进行任何处理,那么总有这样一种搜索方式,使得路径搜索进入了无限死循环(如下图):

[C++]洛谷 机器人搬重物 dfs+记忆化剪枝+回溯详解_第6张图片

这也是让程序无法运行的根本原因(因为如此搜索下去是没有终止条件的)。

在这里,我选择这样处理:

将每一个经过的格子赋值为1,避免重复搜索。当然,这种方式会改变grid数组本身的值,因此在这么做之前,我们需要将当前grid的值保存下来,并在本层递归结束之前将值还原,我们将这一步称为回溯

(3)变向与方向回溯

在之前的代码中,我们有一个待处理的地方,即我们只判断了 dir D[i] 是否相等,但事实上dir是处于不断变化中的。每经过一次搜索,dir都会变成与上一次搜索方向一致(说白了就是转向)。这里又涉及到与(2)中相同的问题:我们需要在下一次递归中改变dir的值,但又不能改变dir在本层递归中的值,因此,我们需要对dir进行类似的回溯操作。

(4)记忆化剪枝

为了优化时间复杂度,我们对每一个格点进行记忆化搜索(对记忆化搜索还不了解的可以看一下文章末的相关题解),保存每个格点最小的{time}数据(我们用大括号括出表示存在记忆化数组中的time数据)。对于下一次执行到本格点的搜索,如果有time >= {time},则说明本次搜索不可能找到更小的时间值,因此没必要重复进行递归搜索。经过这种记忆化剪枝的过程,可以大大提升程序的搜索效率。

另外,在此可以解释一个细节上的问题:为什么dfs函数不选择int类型作为返回值,返回需要的时间,而是选择void类型作为返回值,将时间作为参数传入函数。

前者,返回的结果是从内向外逐步递归得到的,也就是说,如果内层递归不执行完毕,我们是无法得到外层递归的返回值的。这样做强制性要求我们完成整个递归才能得到结果,是不利于我们进行记忆化剪枝的。

而后者则十分友好:每一步的time值都以传参的方式直接获得,方便我们进行剪枝

经过以上四步的优化,让我们看一下最终的代码吧:

#include
#include
#define INF 0x3f3f3f3f
using namespace std;

//设计方向枚举类型:将相反的方向设置为相反数,这样就可以直接用负号表示两个方向相反
enum Direction
{
	East = 1,
	South = 2,
	West = -1,
	North = -2
};

int dx[12] = { 0,1,0,-1,0,2,0,-2,0,3,0,-3 }, dy[12] = { 1,0,-1,0,2,0,-2,0,3,0,-3,0 };//dx和dy表示4个搜索方向及对应的3种搜索步长
Direction D[4] = { East,South,West,North };//D表示四个搜索方向,注意D与上方的dx和dy方向对应,对4取余就能得到对应的搜索方向
Direction dir;//表示当前面朝方向
int ans = INF;
int N, M;
int s_x, s_y, d_x, d_y;//起点和终点坐标
vector> mem;//记忆化数组

void dfs(int x, int y, vector>& grid, int time)
{
	//到终点时递归结束并更新ans
	if (x == d_x && y == d_y)
	{
		ans = min(ans, time);
		return;
	}

	//记忆化剪枝:当此处的时间损耗大于等于mem中该点的最小时间损耗,则无需重复搜索
	if (time >= mem[x][y])
		return;
	mem[x][y] = min(time, mem[x][y]);

	//存储本格数据,并赋值本格为1(用来代表本格已经过搜索,避免重复搜索)->说直白一点就是把来时的路封上
	int tmp = grid[x][y];
	grid[x][y] = 1;

	//用isAccess数组来判断某个方向是否为通路(若向前1步不为通路,则向前2、3步同样不为通路,则后面的情况无需判断)
	bool isAccess[4] = { true,true,true,true };
	for (int i = 0; i < 12; i++)
	{
		if (!isAccess[i % 4]) continue;//用i%4就可表示某个方向的三种情况
		if (x + dx[i] < 0 || x + dx[i] >= N - 1 || y + dy[i] < 0 || y + dy[i] >= M - 1 || grid[x + dx[i]][y + dy[i]] == 1)//若越界或下一格为障碍物,则不为通路
		{
			isAccess[i % 4] = false;
			continue;
		}

		//存储当前的方向,改变当前方向为搜索方向,进行下一步搜索
		Direction dtmp = dir;
		dir = D[i % 4];

		//当前方向与搜索方向相同时,不用转向,因此time+1
		if (dtmp == D[i % 4])
			dfs(x + dx[i], y + dy[i], grid, time + 1);
		//当前方向与搜索方向反向时,需要转两次,因此time+3
		else if (dtmp == -D[i % 4])
			dfs(x + dx[i], y + dy[i], grid, time + 3);
		//其他情况只需time+2
		else
			dfs(x + dx[i], y + dy[i], grid, time + 2);

		//回溯
		dir = dtmp;
	}
	//回溯
	grid[x][y] = tmp;
	return;
}

int main()
{
	//数据的读取与数组声明
	cin >> N >> M;
	vector> grid(N - 1, vector(M - 1, 0));//注意这里存储的不是单元格,而是各个格点(不包括边缘上的格点)
	mem = vector>(N - 1, vector(M - 1, INF));
	for (int i = 0; i < N; i++)
	{
		for (int j = 0; j < M; j++)
		{
			int t;
			cin >> t;
			if (t)
			{
				//每一个障碍物对应四个格点位置,我们只需做好越界判断并把界内的格点赋值为1(表示不可到达)
				if (i < N - 1 && j < M - 1)grid[i][j] = 1;
				if (i > 0 && j < M - 1) grid[i - 1][j] = 1;
				if (j > 0 && i < N - 1) grid[i][j - 1] = 1;
				if (i > 0 && j > 0) grid[i - 1][j - 1] = 1;
			}
		}
	}
	cin >> s_x >> s_y >> d_x >> d_y;

	//初始方向判断
	char c;
	cin >> c;
	switch (c)
	{
	case 'E':
		dir = East;
		break;
	case 'S':
		dir = South;
		break;
	case 'W':
		dir = West;
		break;
	case 'N':
		dir = North;
		break;
	}

	//因为我们算的是格点,因此两个坐标都要-1后再使用
	d_x--; d_y--;

	//调用dfs
	dfs(s_x - 1, s_y - 1, grid, 0);

	//对结果的输出:若ans仍为INF,则说明题目无解,否则输出ans
	if (ans >= INF) cout << -1;
	else cout << ans;
	return 0;
}

相关链接:

dfs+记忆化搜索详解:滑雪

你可能感兴趣的:(CPP题集,深度优先,算法,c++,剪枝)