英文 | https://bytefish.medium.com/use-depth-first-search-algorithm-to-solve-a-maze-ae47758d48e7

翻译 | 杨小爱

下面是一个简单的小迷宫,其中蓝色方块代表墙壁,白色方块代表道路,你能找到从这个迷宫的入口到出口的路径吗?

你可能会说:这太简单了,迷宫的解法我一眼就能搞定。

确实,上面的迷宫很简单。但是,如果给你这样一个迷宫,你还能找到它的解决方案吗?

面对这样的迷宫,你可能无法一眼就得到迷宫的解法,对你来说可能还需要考虑一段时间。

现在,如果您要编写一个程序来自动解决迷宫,您将如何编写代码?

分析

事实上,当我们解决这类问题时,我们不能想当然。我们应该使用标准化的语言来描述我们的想法,然后将这个想法编程为代码。

对于所有的迷宫问题,可以用一个很简单的思路来解题,那就是遍历。

我们可以从起点开始:

  1. 首先,判断当前点是否为已有点。如果是,就意味着我们已经找到了终点;如果不是,那么我们需要继续遍历。

  2. 然后去正确的地方,并重复步骤 1。

  3. 然后转到它的下面点,并重复步骤 1。

  4. 然后去它的左边点,并重复步骤 1。

  5. 然后转到上面的点,并重复步骤 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]]

截图如下:

现在它是:

如您所见,算法求解的顺序完全不同。

深度优先搜索

在这种遍历算法中,它会先尝试一条路径,然后继续深入,直到到达死胡同或找到最终解决方案。如果遇到死胡同,将退回到上一点。这种遍历方法称为深度优先搜索,对应的还有广度优先搜索,后面的文章会讲到。

结论

好了,今天的内容迷宫,我们就先成功解到这里,我也把整个过程可视化了,希望这个对你学习算法内容有帮助,同时也希望通过这个案例,让你对深度优先搜索有了更直观的认识。

最后,如果你觉得今天的内容有用的话,请记得点赞我,关注我,并将它分享给你身边做开发的朋友,也许能够帮助到他。

感谢你的阅读,祝编程愉快!

学习更多技能

请点击下方公众号

使用深度优先搜索算法解决迷宫问题相关推荐

  1. 求素数mdp c语言问题,C语言使用深度优先搜索算法解决迷宫问题(堆栈)

    本文实例讲述了C语言使用深度优先搜索算法解决迷宫问题.分享给大家供大家参考,具体如下: 深度优先搜索 伪代码 (Pseudocode)如下: 将起点标记为已走过并压栈; while (栈非空) { 从 ...

  2. 利用广度优先和深度优先算法解决迷宫问题【算法设计与分析】<图搜索问题>

    实验问题: 给定一个n*n的迷宫,要求利用广度优先和深度优先算法解决迷宫问题 问题分析: 对于此问题首先要明确搜索顺序 可以分为两种方法求解问题:1.广度优先算法2.深度优先算法 (1)对于深度优先算 ...

  3. 采用矩阵+深度优先算法解决迷宫问题

    所谓深度优先算法,百科的解答是这样的 深度优先搜索算法(Depth-First-Search),简称DFS,是搜索算法的一种.是沿着树的深度遍历树的节点,尽可能深的搜索树的分支.当节点v的所有边都己被 ...

  4. 教小学妹学算法:搜索算法解决迷宫问题

    Hello,我是 Alex 007,一个热爱计算机编程和硬件设计的小白,为啥是007呢?因为叫 Alex 的人太多了,再加上每天007的生活,Alex 007就诞生了. 最近有一位小学妹 Coco 入 ...

  5. 利用深度优先搜索算法解决老鼠吃奶酪问题(python)

    问题描述 一只老鼠位于迷宫左下角(0,0),迷宫中的数字9处有块大奶酪.0表示墙,1表示可通过路径.试给出一条可行的吃到奶酪的路径:若没有返回空. 假定迷宫是4连通的,即:老鼠只能上下左右走,不能斜着 ...

  6. 有界深度优先搜索算法解决八数码问题

    <人工智能导论>(第四版) 课后题5.3 C++实现 题目: 运行结果: C++代码: 思想:Octal结构体表示一个状态,其中parent和current为父状态和当前状态的值,这个值可 ...

  7. 黑白迷宫问题——深度优先搜索算法

    2021年,九月,小w发现自己位于一个巨大的由黑格和白格组成的n行m列的迷宫中. 小w只能从白格走到黑格或从黑格走到白格, 小w找到了ljf,她想知道自己从每一个格子出发不回头可以走多少个格子. 但是 ...

  8. 深度优先搜索找迷宫的出路

    3. 深度优先搜索 现在我们用堆栈解决一个有意思的问题,定义一个二维数组: int maze[5][5] = {0, 1, 0, 0, 0,0, 1, 0, 1, 0,0, 0, 0, 0, 0,0, ...

  9. 深度优先搜索算法的通用解法

    一.深度优先搜索 深度优先搜索算法(Depth First Search),是图论中的经典算法. 深度优先搜索算法是沿着树的深度遍历树的节点,尽可能深的搜索树的分支.当结点所有子结点那一层都被搜索过, ...

最新文章

  1. 在一个大项目中,我选择了另一种JDK实现AdoptOpenJDK
  2. 在Delphi中根据SQL Server表名和表描述生成SQL语句
  3. 物联网平台构架系列 (四):Amazon, Microsoft, IBM IoT 解决方案导论 之 平台
  4. 构建DRM系统的重要基石——EME、CDM、AES、CENC和密钥
  5. Hadoop宿醉:使用Apache Whirr启动hadoop集群CDH4
  6. 虚函数和纯虚函数详解
  7. Prim和Kruskal求最小生成树
  8. 如何成为一名Web前端开发人员?入行学习完整指南
  9. 说说id获取器手机版_怎么提取自己空间说说的ID
  10. 如何自动生成PDF书签
  11. gmail如何设置邮箱别名
  12. 夜神模拟器卡在android,夜神安卓模拟器很卡怎么办?模拟器卡顿解决方法分享...
  13. FFmpeg 使用命令整理 – 提取音频或视频、提取图片、格式转换等
  14. 如何系统得对目标检测模型的误差分析?
  15. 快充伤电池?我来帮何同学做个假设检验
  16. 数字孪生十问:分析与思考
  17. C语言 #include指令
  18. 在flutter使用地图插件进行定位
  19. Windows下查找各类游戏存档路径
  20. Windows Server 2008简体中文语言包体验

热门文章

  1. 封装建立-SMD封装
  2. 物联网技术在智慧电网中的应用
  3. TCL雷鸟电视卸载内置应用
  4. EasyUI之树形菜单选项卡前端应用
  5. 如何查看文件字节大小
  6. python想要保存QQ邮箱富途的附件
  7. [战略]武科大IT交流群-群目标和具体措施-V1.0
  8. 基于Segment anything的实例分割半自动标注
  9. 一中模拟赛3.15——树上gcd
  10. 《大学章句集注》-读书笔记之二