使用深度优先搜索算法解决迷宫问题
英文 | https://bytefish.medium.com/use-depth-first-search-algorithm-to-solve-a-maze-ae47758d48e7
翻译 | 杨小爱
下面是一个简单的小迷宫,其中蓝色方块代表墙壁,白色方块代表道路,你能找到从这个迷宫的入口到出口的路径吗?
你可能会说:这太简单了,迷宫的解法我一眼就能搞定。
确实,上面的迷宫很简单。但是,如果给你这样一个迷宫,你还能找到它的解决方案吗?
面对这样的迷宫,你可能无法一眼就得到迷宫的解法,对你来说可能还需要考虑一段时间。
现在,如果您要编写一个程序来自动解决迷宫,您将如何编写代码?
分析
事实上,当我们解决这类问题时,我们不能想当然。我们应该使用标准化的语言来描述我们的想法,然后将这个想法编程为代码。
对于所有的迷宫问题,可以用一个很简单的思路来解题,那就是遍历。
我们可以从起点开始:
首先,判断当前点是否为已有点。如果是,就意味着我们已经找到了终点;如果不是,那么我们需要继续遍历。
然后去正确的地方,并重复步骤 1。
然后转到它的下面点,并重复步骤 1。
然后去它的左边点,并重复步骤 1。
然后转到上面的点,并重复步骤 1。
如果这个过程是用伪代码描述的,它看起来像这样
function solvePoint(x, y) {if Point(x, y) is the existing point:we get the existing point.else:solvePoint(x + 1, y)solvePoint(x, y - 1)solvePoint(x - 1, y)solvePoint(x, y + 1)
}
我们可以发现,这个过程实际上包含了一个递归过程。
当然,上述步骤中没有考虑边界条件和重复访问。但这是次要的,我们可以稍后添加。
但不管怎样,通过以上步骤,我们可以遍历整个迷宫,找到出口的位置。
这样的步骤看似繁琐,其实是最有效的方法。而对于计算来说,穿越迷宫的任务非常简单,可以很快完成。
解决小迷宫
我们可以将上述想法应用到小迷宫中。
首先,我们从起点出发,发现这个点不是现有的点,所以我们需要在这个点的上下左右上下左右。然后我们发现起点的上、下、左都不是合法道路,所以只能往右走。
然后我们需要往下走:
这时候,我们发现其实有两个选择:可以向右走,也可以向下走。而只要我们尝试所有可能的路径,最终我们一定会找到解决方案。
代码实现
以上是我们的想法,很简单。一句话总结就是:按照一定的顺序遍历整个迷宫的所有可能路径,你一定会到达存在点并找到解决方案。
通过这个算法解迷宫的代码放在目录solution/DFS-Recursive中:https://github.com/BytefishMedium/MazeGame
然后,为了方便读者更好的理解相关代码,我将代码分为v1到v7版本。v1是基本代码框架,v7是最终完整代码。
在本文中,我介绍了如何使用数组来表示迷宫,以及如何使用 Canvas 来渲染迷宫。
v1
首先,我们要明确一件事,那就是我们的终极目标。
在我们的迷宫求解问题中,我们需要以下数据作为输入:迷宫数组、入口坐标和出口坐标。那么,我们的算法应该返回一个点数组,这个数组表示从入口到出口的路径:
正如我之前所说,我们使用二维数组来表示迷宫。既然我们的目标是解迷宫,那么我们肯定需要一个迷宫数组作为参数。
然后我们需要知道入口坐标。为方便起见,本项目始终使用第二行第一列的点,即坐标为(0, 1)的点作为迷宫的入口。
我们还需要知道现有点的坐标。为方便起见,本项目始终使用倒数第二行和最后一列中的点作为出口。即坐标为{mazeArray[0].length — 1, mazeArray.length — 2}的点。
最后,我们使用编程语言 JavaScript。JavaScript 支持函数式编程和面向对象的编程。在我的实践中,我发现这个项目的代码会涉及到很多变量和实用函数,所以我认为使用类表示法更方便。
好的,基本的代码框架是:
class MazeSolver {constructor (mazeArray,entrancePosition = {x: 0, y: 1},exitPosition = {x: mazeArray[0].length - 1, y: mazeArray.length - 2}) {this.mazeArray = mazeArraythis.path = []this.entrancePosition = entrancePositionthis.exitPosition = exitPosition}solveMaze () {return this.path}
}
因为我们的目的是解决迷宫问题,所以我们定义了一个名为 MazeSolver 的类。在构造函数中,我们要求调用者传入迷宫数组、入口坐标、出口坐标等信息。由于我们之前约定一般使用固定的出入口,所以我们会给这两个参数一个默认值。
然后我们用this.path来存储迷宫的最终解法,然后solveMaze就是解迷宫的方法。
v2
然后我们在之前的伪代码中说过,解迷宫的本质就是遍历迷宫。如果某个点不是现有点,则尝试访问该点周围的点:上、下、左、右。
所以当我们的代码的v2版本是用递归来解决它的上、下、左、右点时:
class MazeSolver {constructor (mazeArray,entrancePosition = {x: 0, y: 1},exitPosition = {x: mazeArray[0].length - 1, y: mazeArray.length - 2}) {this.mazeArray = mazeArraythis.path = []this.entrancePosition = entrancePositionthis.exitPosition = exitPosition}solvePoint(x, y){this.solvePoint(x + 1, y)this.solvePoint(x, y + 1)this.solvePoint(x - 1, y)this.solvePoint(x, y - 1)}solveMaze () {this.solvePoint(0, 1)return this.path}
}
在这里,我们从起点solvePoint(0,1)开始,然后将四个点向左、上、右、下循环。
其实这里有个小问题,就是我们遍历周围四个点的时候,是按什么顺序来的?是上、下、左、右,还是下、左、右、上?这不影响最终的结果,但确实影响了具体的过程。我们将在可视化部分讨论差异,并在此处留下悬念。
那么上面的代码中没有考虑边界问题和终止条件,我们现在继续。
v3
这种遍历的终止条件是到达出口。所以我们只需要简单的添加一个判断来判断当前点是否是现有点。
class MazeSolver {constructor (mazeArray,entrancePosition = {x: 0, y: 1},exitPosition = {x: mazeArray[0].length - 1, y: mazeArray.length - 2}) {this.mazeArray = mazeArraythis.path = []this.entrancePosition = entrancePositionthis.exitPosition = exitPosition}solvePoint(x, y){if (x === this.exitPosition.x && y === this.exitPosition.y) {console.log('you win')return}this.solvePoint(x + 1, y)this.solvePoint(x, y + 1)this.solvePoint(x - 1, y)this.solvePoint(x, y - 1)}solveMaze () {this.solvePoint(0, 1)return this.path}
}
v4
但是一个点周围的四个点不一定都是合法的道路,有可能是越界,或者这个点是一堵墙。所以我们在递归的时候,还是需要判断一个点是否是合法的路。
判断条件很简单:
横坐标是否越界
纵坐标是否超出范围
这个点是不是墙
然后在递归之前,判断下一点是否合法:
class MazeSolver {constructor (mazeArray,entrancePosition = {x: 0, y: 1},exitPosition = {x: mazeArray[0].length - 1, y: mazeArray.length - 2}) {this.mazeArray = mazeArraythis.path = []this.entrancePosition = entrancePositionthis.exitPosition = exitPosition}isLegalRoad (x, y) {if (x < 0 || x >= this.mazeArray[0].length) {return false}if (y < 0 || y >= this.mazeArray.length) {return false}if (this.mazeArray[y][x] === 1) {return false}return true}solvePoint(x, y){if (x === this.exitPosition.x && y === this.exitPosition.y) {console.log('you win')return}if(this.isLegalRoad(x + 1, y)){this.solvePoint(x + 1, y)}if(this.isLegalRoad(x, y + 1)){this.solvePoint(x, y + 1)}if(this.isLegalRoad(x - 1, y)){this.solvePoint(x - 1, y)}if(this.isLegalRoad(x, y - 1)){this.solvePoint(x, y - 1)}}solveMaze () {this.solvePoint(0, 1)return this.path}
}
v5
除了判断点是否合法外,我们还要处理重复访问的问题。在上面的代码中,我们没有考虑重复访问某个点的问题,所以在递归过程中可能会导致重复访问某个点,导致死循环。
为了解决这个问题,我们必须记录访问过的点,然后在递归到下一个点之前进行判断。
visitedPosition 是一个与迷宫数组中的点对应的二维数组,它的值是一个布尔值。如果为false,则表示迷宫中的对应点没有被访问过;如果为真,则说明迷宫中的对应点已经被访问过。
稍后,当我们遍历下一个点时,我们可以做一个额外的判断:
在这里,我们正在讨论一种用于优化代码的附加技术。我们之前的代码是这样的:
如果直接修改这段代码,需要修改4个地方。
但是我们可以发现,这四个点的坐标变化非常规律。
横坐标不变,纵坐标加1
横坐标加1,纵坐标不变
横坐标不变,纵坐标减1
横坐标减1,纵坐标不变
就数字而言,它可能如下所示:
然后我们可以像这样优化代码:
因为后面我们对里面的代码还有很多的优化,这个小技巧可以方便我们以后改代码。
完整代码可以查看相关源文件:
class MazeSolver {constructor (mazeArray,entrancePosition = {x: 0, y: 1},exitPosition = {x: mazeArray[0].length - 1, y: mazeArray.length - 2}) {this.mazeArray = mazeArraythis.path = []this.entrancePosition = entrancePositionthis.exitPosition = exitPositionthis.directions = [[1, 0], [0, 1], [-1, 0], [0, -1]]this.visitedPositions = new Array(mazeArray.length)for (let index = 0; index < this.visitedPositions.length; index++) {this.visitedPositions[index] = new Array(mazeArray[index].length).fill(false)}}isLegalRoad (x, y) {if (x < 0 || x >= this.mazeArray[0].length) {return false}if (y < 0 || y >= this.mazeArray.length) {return false}if (this.mazeArray[y][x] === 1) {return false}return true}solvePoint(x, y){this.visitedPositions[y][x] = trueif (x === this.exitPosition.x && y === this.exitPosition.y) {console.log('you win')return}for (const direction of this.directions) {let newX = x + direction[0]let newY = y + direction[1]if (this.isLegalRoad(newX, newY) && !this.visitedPositions[newY][newX]){this.solvePoint(newX, newY)}}}/**** @return {*}*/solveMaze () {this.solvePoint(0, 1)return this.path}
}module.exports = MazeSolver
v5.test
上面的代码已经可以找到迷宫的解法了,我们可以测试一下。具体代码在 MazeSolver.v5.test.js
const MazeSolver = require('./MazeSolver.v5')function test () {let mazeArray = [[1, 1, 1, 1, 1, 1],[0, 0, 1, 0, 1, 1],[1, 0, 0, 0, 0, 1],[1, 0, 1, 1, 1, 1],[1, 0, 0, 0, 0, 0],[1, 1, 1, 1, 1, 1],]let mazeSolver = new MazeSolver(mazeArray)mazeSolver.solveMaze()
}
test()function test2 () {let mazeArray2 = require('../../maze_files/mazeArray101.node.js')let mazeSolver = new MazeSolver(mazeArray2)mazeSolver.solveMaze()
}test2()
我们有两个测试用例,一个是解决小迷宫,另一个是maze_files中的大迷宫。
然后,我们新建一个 MazeSolver 实例,然后调用它的solveMaze 方法来解迷宫。
最后,我们执行完代码后,在控制台看到你win打印了两次,说明我们的程序确实到了结尾。
v6
虽然已经走到了尽头,但具体的过程我们还是不知道。这时候我们需要添加一些代码:在遍历的过程中,记录解路径中的点,然后记录到this.path数组中。
思路也很简单,就是开始求解某个点的时候,把这个点压进this.path,记录下这个位置。
但同时,每个人都需要意识到,有些道路是死胡同。比如上面的小迷宫,在第三个点,它会先向右走,然后发现是死路,然后又往回走。
所以会有一个回滚过程。回滚时,我们需要通过 this.path.pop() 从路径数组中移除这个点。
最后,如果我们的程序到了出口,就不需要继续往下遍历了,我们还要给我们的递归加上一个终止条件。
好的,这些是我们最终的递归代码:
有终止条件
有边界判断
然后返回一个路径
class MazeSolver {constructor (mazeArray,entrancePosition = {x: 0, y: 1},exitPosition = {x: mazeArray[0].length - 1, y: mazeArray.length - 2}) {this.mazeArray = mazeArraythis.path = []this.entrancePosition = entrancePositionthis.exitPosition = exitPositionthis.directions = [[1, 0], [0, 1], [-1, 0], [0, -1]]this.visitedPositions = new Array(mazeArray.length)for (let index = 0; index < this.visitedPositions.length; index++) {this.visitedPositions[index] = new Array(mazeArray[index].length).fill(false)}}isLegalRoad (x, y) {if (x < 0 || x >= this.mazeArray[0].length) {return false}if (y < 0 || y >= this.mazeArray.length) {return false}if (this.mazeArray[y][x] === 1) {return false}return true}solvePoint(x, y){this.visitedPositions[y][x] = truethis.path.push({x, y})if (x === this.exitPosition.x && y === this.exitPosition.y) {console.log('you win')return true}for (const direction of this.directions) {let newX = x + direction[0]let newY = y + direction[1]if (this.isLegalRoad(newX, newY) && !this.visitedPositions[newY][newX]){let isDone = this.solvePoint(newX, newY)if(isDone) return true}}this.path.pop()return false}/**** @return {*}*/solveMaze () {let result = this.solvePoint(0, 1)if (result === false) {console.log('no solution for this maze')}return this.path}
}function test () {let mazeArray = [[1, 1, 1, 1, 1, 1],[0, 0, 1, 0, 1, 1],[1, 0, 0, 0, 0, 1],[1, 0, 1, 1, 1, 1],[1, 0, 0, 0, 0, 0],[1, 1, 1, 1, 1, 1],]let mazeSolver = new MazeSolver(mazeArray)let path = mazeSolver.solveMaze()console.log(path)
}
// test()function test2 () {let mazeArray2 = require('../../maze_files/mazeArray101.node.js')let mazeSolver = new MazeSolver(mazeArray2)let path = mazeSolver.solveMaze()console.log(path)
}// test2()
v6.html
现在,我们可以绘制迷宫和路径:
v7
但是上面的代码只展示了静态的结果,并没有展示动态的过程。
我们还想知道的是:算法访问了哪些点,经过了哪些死胡同,最后是如何回归的。
为了解决这个问题,我们需要继续添加代码。我们添加一个数组:
//[{type:'enter' , position:{x:0 , y:1}} , {type: 'exit' , position:{x:0 , y:1}}]
this.detailedPath = []
截图如下:
这个数组不仅记录了访问过哪些点,还记录了是进入还是离开这些点。enter 表示进入,exit 表示离开。
然后,我们在solvePoint方法开始执行后标记进入该点,并在结束前标记离开该点。最终代码如下:
现在,detaillePath 可以记录算法的具体执行过程。
让我们渲染它:
不是很酷吗?
v8
那么,你有没有注意到算法总是先向左尝试?
这实际上符合我们之前提到的代码:
而我们的坐标系是这样的:
所以在之前的搜索路径中,我们的算法总是优先向右走。
如果要调整搜索顺序,只需要改变以上几行代码的顺序即可。但是正如我们之前提到的,我们用 this.directions 修改了代码,所以我们现在可以像这样修改它:
this.directions = [[-1,0],[0,1],[1,0],[0,-1]]
截图如下:
现在它是:
如您所见,算法求解的顺序完全不同。
深度优先搜索
在这种遍历算法中,它会先尝试一条路径,然后继续深入,直到到达死胡同或找到最终解决方案。如果遇到死胡同,将退回到上一点。这种遍历方法称为深度优先搜索,对应的还有广度优先搜索,后面的文章会讲到。
结论
好了,今天的内容迷宫,我们就先成功解到这里,我也把整个过程可视化了,希望这个对你学习算法内容有帮助,同时也希望通过这个案例,让你对深度优先搜索有了更直观的认识。
最后,如果你觉得今天的内容有用的话,请记得点赞我,关注我,并将它分享给你身边做开发的朋友,也许能够帮助到他。
感谢你的阅读,祝编程愉快!
学习更多技能
请点击下方公众号
使用深度优先搜索算法解决迷宫问题相关推荐
- 求素数mdp c语言问题,C语言使用深度优先搜索算法解决迷宫问题(堆栈)
本文实例讲述了C语言使用深度优先搜索算法解决迷宫问题.分享给大家供大家参考,具体如下: 深度优先搜索 伪代码 (Pseudocode)如下: 将起点标记为已走过并压栈; while (栈非空) { 从 ...
- 利用广度优先和深度优先算法解决迷宫问题【算法设计与分析】<图搜索问题>
实验问题: 给定一个n*n的迷宫,要求利用广度优先和深度优先算法解决迷宫问题 问题分析: 对于此问题首先要明确搜索顺序 可以分为两种方法求解问题:1.广度优先算法2.深度优先算法 (1)对于深度优先算 ...
- 采用矩阵+深度优先算法解决迷宫问题
所谓深度优先算法,百科的解答是这样的 深度优先搜索算法(Depth-First-Search),简称DFS,是搜索算法的一种.是沿着树的深度遍历树的节点,尽可能深的搜索树的分支.当节点v的所有边都己被 ...
- 教小学妹学算法:搜索算法解决迷宫问题
Hello,我是 Alex 007,一个热爱计算机编程和硬件设计的小白,为啥是007呢?因为叫 Alex 的人太多了,再加上每天007的生活,Alex 007就诞生了. 最近有一位小学妹 Coco 入 ...
- 利用深度优先搜索算法解决老鼠吃奶酪问题(python)
问题描述 一只老鼠位于迷宫左下角(0,0),迷宫中的数字9处有块大奶酪.0表示墙,1表示可通过路径.试给出一条可行的吃到奶酪的路径:若没有返回空. 假定迷宫是4连通的,即:老鼠只能上下左右走,不能斜着 ...
- 有界深度优先搜索算法解决八数码问题
<人工智能导论>(第四版) 课后题5.3 C++实现 题目: 运行结果: C++代码: 思想:Octal结构体表示一个状态,其中parent和current为父状态和当前状态的值,这个值可 ...
- 黑白迷宫问题——深度优先搜索算法
2021年,九月,小w发现自己位于一个巨大的由黑格和白格组成的n行m列的迷宫中. 小w只能从白格走到黑格或从黑格走到白格, 小w找到了ljf,她想知道自己从每一个格子出发不回头可以走多少个格子. 但是 ...
- 深度优先搜索找迷宫的出路
3. 深度优先搜索 现在我们用堆栈解决一个有意思的问题,定义一个二维数组: int maze[5][5] = {0, 1, 0, 0, 0,0, 1, 0, 1, 0,0, 0, 0, 0, 0,0, ...
- 深度优先搜索算法的通用解法
一.深度优先搜索 深度优先搜索算法(Depth First Search),是图论中的经典算法. 深度优先搜索算法是沿着树的深度遍历树的节点,尽可能深的搜索树的分支.当结点所有子结点那一层都被搜索过, ...
最新文章
- 在一个大项目中,我选择了另一种JDK实现AdoptOpenJDK
- 在Delphi中根据SQL Server表名和表描述生成SQL语句
- 物联网平台构架系列 (四):Amazon, Microsoft, IBM IoT 解决方案导论 之 平台
- 构建DRM系统的重要基石——EME、CDM、AES、CENC和密钥
- Hadoop宿醉:使用Apache Whirr启动hadoop集群CDH4
- 虚函数和纯虚函数详解
- Prim和Kruskal求最小生成树
- 如何成为一名Web前端开发人员?入行学习完整指南
- 说说id获取器手机版_怎么提取自己空间说说的ID
- 如何自动生成PDF书签
- gmail如何设置邮箱别名
- 夜神模拟器卡在android,夜神安卓模拟器很卡怎么办?模拟器卡顿解决方法分享...
- FFmpeg 使用命令整理 – 提取音频或视频、提取图片、格式转换等
- 如何系统得对目标检测模型的误差分析?
- 快充伤电池?我来帮何同学做个假设检验
- 数字孪生十问:分析与思考
- C语言 #include指令
- 在flutter使用地图插件进行定位
- Windows下查找各类游戏存档路径
- Windows Server 2008简体中文语言包体验