4

C++不知算法系列之迷宫问题中的“见山不是山”

 1 year ago
source link: https://blog.51cto.com/gkcode/5989492
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

迷宫问题是一类常见的问题。

初识此类问题,应该是“见山是山”,理解问题的原始要求,便是查找从起点到终点的可行之路。

有了广泛的知识体系之后,应该是"见山不是山"。会发现迷宫就是邻接矩阵,树和图中顶点的关系常用邻接矩阵描述,所以,迷宫问题可以转化为树、图的搜索问题。或帮助理解树和图,反之也可在迷宫问题中用树、图中的理论。

最后便是“见山还是山”,能透过问题的表象,深化问题的本质,识破披着各色外衣的迷宫问题。

本文从不同的角度、全方位讲透迷宫问题中的“见山不是山”,让大家对迷宫问题有实质性的理解。

2. 迷宫问题

问题描述:

如下图迷宫地图中,1表示障碍物,0表示可通行。要求从起始点(0,0)出发,检查是否有行之有效的通路,可以一直走到终点(8,8)。迷宫问题的本质就是邻接矩阵的路径搜索问题。

常用的是广度优先深度优先搜索算法。

C++不知算法系列之迷宫问题中的“见山不是山”_邻接矩阵

2.1 设计数据类型

首先分析迷宫问题中的数据类型。

  • 坐标类型:用来描述迷宫中每个单元格的位置。
/*
*坐标类型
*/
struct Position {
	//x坐标
	int x;
	//y坐标
	int y;
	Position() {}
	//构造函数
	Position(int x,int y) {
		this->x=x;
		this->y=y;
	}
	//重载 == 运算符
	bool operator==(Position pos) {
		return this->x==pos.x && this->y==pos.y;
	}
    //输出
	void desc() {
		cout<<"(x:"<<x<<",y:"<<y<<")"<<"->";
	}
};
  • 方向类型:描述与每个单元格相邻的上、下、左、右 4 个单元格的关系。
/*
* 方向增量
*/
struct Direction {
	//x 方向增量
	int xOffset;
	//y 方向增量
	int yOffset;
};
  • 迷宫类:描述迷宫本身以及迷宫相对应的操作函数。
class Maze {
	private:
		//一般用二维数组存储迷宫信息
		int maze[10][10]= {
			{1,1,1,1,1,1,1,1,1,1},
			{1,0,0,1,0,1,1,1,1,1},
			{1,1,0,1,0,0,1,1,1,1},
			{1,1,0,1,0,0,0,0,1,1},
			{1,1,0,0,0,0,1,0,1,1},
			{1,1,1,1,0,0,1,0,1,1},
			{1,1,0,0,0,0,1,0,1,1},
			{1,1,1,1,0,0,1,0,1,1},
			{1,1,0,0,0,1,1,0,0,1},
			{1,1,1,1,1,1,1,1,1,1},
		};
		//地图中非障碍点,即值为 0 位置的个数
		int count=32;
		//当前坐标与相邻(右、下、左、上)坐标的增量关系
		Direction dirs[4]= { {0,1},{1,0},{0,-1},{-1,0} };
		//栈,用于深度搜索
		stack<Position> mazeStack;
		//队列,用于广度搜索
		queue<Position>  mazeQueue;
		//总路径数
		int totalCount=0;
	public:
        //构造函数
		Maze() {}
		/*
		*洪水填充算法检查迷宫的连通性
		*/
		void floodfill(Position start,Position end);
		/*
		* 是否连通
		*/
		void isConnection();
		/*
		* 非递归实现路径的查找
		* 只保证查找到路径
		* 需要借助栈
		*/
		void searchPathByStack(Position start,Position end);
		/*
		*  显示到深度搜索到的路径
		*/
		void showPath();
		/*
		*递归深度搜索
		*/
		bool searchPathByRecursion(Position start,Position end,int deep);
		/*
		*广度搜索
		*/
		void searchPathByQueue(Position start,Position end);
		/*
		* 显示地图
		*/
		void showMap() {
			cout<<"\n\t----------------------地图----------------------\n"<<endl;
			for(int i=0; i<10; i++) {
				for( int j=0; j<10; j++ )
					cout<<this->maze[i][j]<<"\t";
				cout<<endl;
			}
			cout<<"\n\t--------------------------------------------"<<endl;
		}
};

2.2 检查连通性

使用洪水填充算法检查迷宫的连通性。

洪水填充算法类似于古时候的"连坐法",或说星星之火可以燎原也,从最初给定的位置开始,以蔓延之势,用-1填充与之相邻且值为 0的单元格。 本文中, -10都用于表示迷宫中的非障碍物区间。

洪水填充算法和后面的递归搜索算法相似,不同地方之处,洪水填充会蔓延至所有满足条件的位置,搜索则是强调到通向目标的路径。

/*
* 洪水填充算法检查迷宫的连通性
*/
void Maze::floodfill(Position start,Position end) {
	Position tmpPos;
	//检查起始点周边的点是否存在
	for(int i=0; i<4; i++ ) {
		//当前点上、下、左、右的相邻点
		tmpPos.x=start.x+ dirs[i].xOffset;
		tmpPos.y=start.y+dirs[i].yOffset;
		if( maze[tmpPos.x][tmpPos.y]==0 ) {
			//可通,则填充为 -1
			maze[tmpPos.x][tmpPos.y]=-1;
			//计数
			count--;
             //递归调用
			Maze::floodfill(tmpPos,end);
		}
	}
}

连通性结论:

/*
* 如果洪水填充算法所填充的单元格数量和初始时值为 0 的单元格的数量一样,则连通
*/
void Maze::isConnection() {
	if(Maze::count==0)
		cout<<"地图是连通!"<<endl;
	else
		cout<<"地图不是连通的!"<<endl;
}

测试:

//需要的所有头文件
#include <iostream>
#include <stack>
#include <vector>
#include <queue>
using namespace std;
int main(int argc, char** argv) {
	Maze maze;
	//起点位置
	Position startPos(1,1);
	//终点位置
	Position  endPos(8,8);
	cout<<"洪水填充算法前的地图"<<endl;
	maze.showMap();
	//洪水填充算法
	maze.floodfill(startPos,endPos);
	cout<<"洪水填充算法后的地图"<<endl;
	maze.showMap();
	//结论
	maze.isConnection();
	return 0;
}

输出结果: 迷宫中值为0的位置全部被-1填充。

C++不知算法系列之迷宫问题中的“见山不是山”_邻接矩阵_02

2.3 深度搜索

深度搜索可以使用非递归和递归 2 种方案实现。

2.3.1 非递归的思想

非递归深度搜索需要借助栈。

  • 初始,把起始点的坐标值压入栈中。
C++不知算法系列之迷宫问题中的“见山不是山”_数据类型_03
  • 获取栈顶的坐标,检查此坐标的上、下、左、右 4 个相邻的坐标是否可通行。如果可通行,则压入栈中,且标识此坐标已经访问过。如下图使用绿色表示已经访问过。
C++不知算法系列之迷宫问题中的“见山不是山”_数据类型_04
  • 重复上述的的逻辑,如下图当是添加到(4,4)单元格时栈中的内容。
C++不知算法系列之迷宫问题中的“见山不是山”_邻接矩阵_05
  • (4,4)相邻的坐标分别是(4,5)、(5,4)、(4,3)、(3,4)。其中(4,3)已经访问过,则不需要再压入。栈中的坐标都是一路搜索下来可通行的位置。
C++不知算法系列之迷宫问题中的“见山不是山”_邻接矩阵_06
  • 继续获取栈顶坐标(3,4)。随后找到与之相邻的(3,5)、(2,4),压入栈中;再得到栈顶的(2,4)坐标,并找到与之相邻的(2,5)、(1,4)

    Tips: 本文查找与栈顶坐标相邻的坐标是按右、下、左、上的顺序。如果顺序不同,则会导致搜索过程不一样。

C++不知算法系列之迷宫问题中的“见山不是山”_邻接矩阵_07
  • 从栈顶得到(1,4),因此坐标位于死胡同。于是,从栈顶把此坐标删除。可得到一个结论,不是所有进入栈中的坐标都有机会成为有效路径中的一份子。本文称行之此处不通的坐标为死胡同坐标,也做相应的标记。也就是说,当坐标为死胡同坐标时,则从栈顶删除。
C++不知算法系列之迷宫问题中的“见山不是山”_数据类型_08
  • 栈中的(2,5)、(2,4)死胡同坐标,删除。
C++不知算法系列之迷宫问题中的“见山不是山”_数据类型_09
  • (3,5)坐标开始,可以一路走到终点。把栈中的坐标全部输出便得到起点终点的有效路径。
C++不知算法系列之迷宫问题中的“见山不是山”_数据类型_10

这里有一个问题要思考,栈中的值真的全是有效路径上的坐标?

其实不然,也许有些坐标进入栈中,只为备用。如到(4,4)位置时,有 3 个可行方向(4,5)、(5,4)、(3,4)。因(3,4)这个方向可行,最终(4,5)、(5,4)备用点没用上。所以,这些坐标也需要标记一下,本文称为备用坐标

C++不知算法系列之迷宫问题中的“见山不是山”_邻接矩阵_11

对坐标进行不同颜色标记后,上图中的绿色坐标为最终有效路径。

2.3.2 编码实现

/*
* 非递归实现路径的查找
* 只保证查找到路径
* 需要借助栈
*/
void Maze::searchPathByStack(Position start,Position end) {
	//把起始点压入栈
	Maze::mazeStack.push(start);
	//置 0,表示已经访问过
	Maze::maze[start.x][start.y]=0;
	Position top;
	Position tmpPos;
	while(1) {
		//得到栈顶的元素
		top=Maze::mazeStack.top();
		// -2 表示此坐标是路径中的一部分
		Maze::maze[top.x ][top.y ]=-2;
		if(top==end) {
			return;
		}
		bool isConnection=false;
		//检查栈顶元素的周边点
		for(int i=0; i<4; i++) {
			tmpPos= { top.x+dirs[i].xOffset,top.y+dirs[i].yOffset  };
			//检查是否可通,-1 表示可通
			if( Maze::maze[tmpPos.x ][tmpPos.y ]==-1 ) {
				isConnection=true;
				//压入栈
				Maze::mazeStack.push(tmpPos);
				//经访问过
				Maze::maze[tmpPos.x][tmpPos.y]=0;
			}
		}
		if(!isConnection) {
			//此点周边不能通用,从栈顶删除
			Maze::mazeStack.pop();
			//-3 表示死胡同坐标
			this->maze[top.x ][top.y ]=-3;
		}
	}
}

/*
*输出路径中的坐标
*
*/
void Maze::showPath() {
	cout<<"非递归查找到的路径:"<<endl;
	stack<Position> paths;
	Position pos;
	while(!Maze::mazeStack.empty()) {
		pos=Maze::mazeStack.top();
		Maze::mazeStack.pop();
		if( this->maze[pos.x][pos.y]==-2 )
			paths.push(pos);
	}
    //正序输出
	while(!paths.empty()) {
		pos=paths.top();
		paths.pop();
		pos.desc();
	}
	cout<<endl;
}

测试:

int main(int argc, char** argv) {
	Maze maze;
	//起点位置
	Position startPos(1,1);
	//终点位置
	Position  endPos(8,8);
    //省略…… 
	//非递归搜索路径
	maze.searchPathByStack(startPos,endPos) ;
	maze.showPath();
	maze.showMap();
	return 0;
}

输出结果: 下图中的-2所示的坐标为有效路径描述。-3曾经进入过栈,但是死胡同坐标。0 表示没有用上的备用坐标。

C++不知算法系列之迷宫问题中的“见山不是山”_数据类型_12

2.4 递归实现

可以使用递归回溯,找出所有的路径。

/*
*递归查找路径
*/
bool Maze::searchPathByRecursion(Position start,Position end,int deep) {
	Maze::maze[start.x][start.y]=deep;
	//是否是死胡同坐标
	bool isConnection=false;
	Position tmpPos;
	//查找相邻坐标
	for(int i=0; i<4; i++ ) {
		//相邻坐标
		tmpPos= { start.x+dirs[i].xOffset,start.y+dirs[i].yOffset };
		if( Maze::maze[tmpPos.x][tmpPos.y]==-1  ) {
			//可访问
			isConnection=true;
			Maze::maze[tmpPos.x][tmpPos.y]=deep;
			if(tmpPos==end ) {
				Maze::totalCount++;
				cout<<"第"<<Maze::totalCount<<"种"<<endl;
				//输出
				Maze::showMap();
			} else {
				//递归,如果相邻坐标是死胡同,则当前坐标也是
				isConnection=Maze::searchPathByRecursion(tmpPos,end,deep+1);
			}
			//回溯时恢复状态
			//Maze::maze[tmpPos.x][tmpPos.y]=-1;
		}
	}
	if(!isConnection)
		//死胡同坐标,回溯不用判断是否死胡同
		Maze::maze[start.x][start.y]=-3;
	return isConnection;
}

测试代码:

int main(int argc, char** argv) {
	//省略……
	//递归搜索路径
	maze.searchPathByRecursion(startPos,endPos,2) ;
	return 0;
}

注释如下代码:

//Maze::maze[tmpPos.x][tmpPos.y]=-1;

会显示一条路径。如下图所示,-3表示递归过,但是死胡同的坐标。标记为大于等于 2 的位置为可正常通行。

C不知算法系列之迷宫问题中的“见山不是山”_c_13

如果打开如下代码。

//回溯时恢复原来状态
Maze::maze[tmpPos.x][tmpPos.y]=-1;

显示所有路径,回溯不用判断死胡同坐标,回溯时会自动恢复原来的状态。

C不知算法系列之迷宫问题中的“见山不是山”_c_14

2.4 广度搜索

广度优先类似于一石激起千层浪,一层层向外推动。

/*
*广度搜索
*/
void Maze::searchPathByQueue(Position start,Position end) {
    //所有路径
	vector<vector<Position>>  paths;
	//把起始点放入队列
	Maze::mazeQueue.push(start);
	vector<Position> path;
	path.push_back(start);
	paths.push_back(path);
	Position pos;
	bool isConnection;
	Position tmpPos;
	while(true ) {
		//从队列中得到队头数据
		pos=Maze::mazeQueue.front();
		Maze::mazeQueue.pop();
		for(int i=0; i<paths.size(); i++) {
			//得到所有路径
			path=paths[i];
			Position lastPos=path.back();
			for(int j=0; j<4; j++) {
				Position tmp= {lastPos.x+dirs[j].xOffset,lastPos.y+dirs[j].yOffset };
				if(tmp==pos) {
					path.push_back(pos);
					paths.push_back(path);
					break;
				}
			}
		}
		//到达终点
		if(pos==end) {
			for(int i=0; i<paths.size(); i++) {
				if( paths[i].back()==end ) {
					for(int j=0; j<paths[i].size(); j++) {
						Maze::maze[ paths[i][j].x ][paths[i][j].y]=-2;
						paths[i][j].desc();
					}
					break;
				}
			}
			return;
		}
		//查找相邻的坐标
		for(int i=0; i<4; i++) {
			tmpPos= { pos.x+dirs[i].xOffset,pos.y+dirs[i].yOffset };
			if( Maze::maze[tmpPos.x][tmpPos.y]==-1 ) {
				//可通加入队列
				Maze::mazeQueue.push(tmpPos);
				Maze::maze[tmpPos.x][tmpPos.y]=0;
			}
		}

	}
}

输出结果: 迷宫中的广度优先搜索相当于在无向图中查找路径,可以找到任何 2 个可通行位置的最短路径。这里只显示起点到终点的最短路径,如下-2所标记的位置连接起来的路径。

C不知算法系列之迷宫问题中的“见山不是山”_c_15

迷宫本质是矩阵,在解决迷宫问题时,可以考虑运用矩阵相关的操作理论。另,树、图的关系也常用矩阵描述,迷宫问题也可以当成树、图论算法中的子问题。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK