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

75f77729bfd19a22d474e3b3b3ce9987.jpeg

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

翻译 | 杨小爱

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

94863a8b2f646f545768b9267dad3059.png

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

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

4c3ee2b69e456f9430ab204dc93984b9.png

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

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

分析

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

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

我们可以从起点开始:

  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)
}

我们可以发现,这个过程实际上包含了一个递归过程。

当然,上述步骤中没有考虑边界条件和重复访问。但这是次要的,我们可以稍后添加。

但不管怎样,通过以上步骤,我们可以遍历整个迷宫,找到出口的位置。

这样的步骤看似繁琐,其实是最有效的方法。而对于计算来说,穿越迷宫的任务非常简单,可以很快完成。

解决小迷宫

我们可以将上述想法应用到小迷宫中。

首先,我们从起点出发,发现这个点不是现有的点,所以我们需要在这个点的上下左右上下左右。然后我们发现起点的上、下、左都不是合法道路,所以只能往右走。

9ed88700d705f4080e9c01556aaebea4.png

然后我们需要往下走:

72aecc59fdcb149027749fb251bdab9e.png

这时候,我们发现其实有两个选择:可以向右走,也可以向下走。而只要我们尝试所有可能的路径,最终我们一定会找到解决方案。

bb4710831ec593c9696843168e5a0d47.png

代码实现

以上是我们的想法,很简单。一句话总结就是:按照一定的顺序遍历整个迷宫的所有可能路径,你一定会到达存在点并找到解决方案。

通过这个算法解迷宫的代码放在目录solution/DFS-Recursive中:https://github.com/BytefishMedium/MazeGame

52715a526b4c6db5a576b64c2488fc57.png

然后,为了方便读者更好的理解相关代码,我将代码分为v1到v7版本。v1是基本代码框架,v7是最终完整代码。

在本文中,我介绍了如何使用数组来表示迷宫,以及如何使用 Canvas 来渲染迷宫。

v1

首先,我们要明确一件事,那就是我们的终极目标。

在我们的迷宫求解问题中,我们需要以下数据作为输入:迷宫数组、入口坐标和出口坐标。那么,我们的算法应该返回一个点数组,这个数组表示从入口到出口的路径:

2dcf0b9049a75ecbbeb9f2aba3019ad7.png

正如我之前所说,我们使用二维数组来表示迷宫。既然我们的目标是解迷宫,那么我们肯定需要一个迷宫数组作为参数。

然后我们需要知道入口坐标。为方便起见,本项目始终使用第二行第一列的点,即坐标为(0, 1)的点作为迷宫的入口。

我们还需要知道现有点的坐标。为方便起见,本项目始终使用倒数第二行和最后一列中的点作为出口。即坐标为{mazeArray[0].length — 1, mazeArray.length — 2}的点。

最后,我们使用编程语言 JavaScript。JavaScript 支持函数式编程和面向对象的编程。在我的实践中,我发现这个项目的代码会涉及到很多变量和实用函数,所以我认为使用类表示法更方便。

好的,基本的代码框架是:

622661c4ad489ca20960c60016fa3afc.png

class MazeSolver {


  constructor (mazeArray,
               entrancePosition = {x: 0, y: 1},
               exitPosition = {x: mazeArray[0].length - 1, y: mazeArray.length - 2}
  ) {
    this.mazeArray = mazeArray
    this.path = []


    this.entrancePosition = entrancePosition
    this.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 = mazeArray
    this.path = []


    this.entrancePosition = entrancePosition
    this.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

这种遍历的终止条件是到达出口。所以我们只需要简单的添加一个判断来判断当前点是否是现有点。

d964c3f177ec8eac669b1f8e22aeb680.png

class MazeSolver {


  constructor (mazeArray,
               entrancePosition = {x: 0, y: 1},
               exitPosition = {x: mazeArray[0].length - 1, y: mazeArray.length - 2}
  ) {
    this.mazeArray = mazeArray
    this.path = []


    this.entrancePosition = entrancePosition
    this.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

但是一个点周围的四个点不一定都是合法的道路,有可能是越界,或者这个点是一堵墙。所以我们在递归的时候,还是需要判断一个点是否是合法的路。

952d6f0ee073dc7c6b9c54e403c96797.png

判断条件很简单:

  • 横坐标是否越界

  • 纵坐标是否超出范围

  • 这个点是不是墙

然后在递归之前,判断下一点是否合法:

class MazeSolver {


  constructor (mazeArray,
               entrancePosition = {x: 0, y: 1},
               exitPosition = {x: mazeArray[0].length - 1, y: mazeArray.length - 2}
  ) {
    this.mazeArray = mazeArray
    this.path = []


    this.entrancePosition = entrancePosition
    this.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

除了判断点是否合法外,我们还要处理重复访问的问题。在上面的代码中,我们没有考虑重复访问某个点的问题,所以在递归过程中可能会导致重复访问某个点,导致死循环。

为了解决这个问题,我们必须记录访问过的点,然后在递归到下一个点之前进行判断。

1efee3f00d74e85a64d77df9d7d81939.png

visitedPosition 是一个与迷宫数组中的点对应的二维数组,它的值是一个布尔值。如果为false,则表示迷宫中的对应点没有被访问过;如果为真,则说明迷宫中的对应点已经被访问过。

稍后,当我们遍历下一个点时,我们可以做一个额外的判断:

5df4111ebc6e61ac1f9507aa3b5cb021.png

在这里,我们正在讨论一种用于优化代码的附加技术。我们之前的代码是这样的:

45c990cad5042a50de59d78d06b3ef3b.png

如果直接修改这段代码,需要修改4个地方。

但是我们可以发现,这四个点的坐标变化非常规律。

  • 横坐标不变,纵坐标加1

  • 横坐标加1,纵坐标不变

  • 横坐标不变,纵坐标减1

  • 横坐标减1,纵坐标不变

就数字而言,它可能如下所示:

919d2637ecc3bd68dad115acda1b9bb4.png

然后我们可以像这样优化代码:

144c012263431cac470446d1b56e8250.png

因为后面我们对里面的代码还有很多的优化,这个小技巧可以方便我们以后改代码。

完整代码可以查看相关源文件:

c921a23c18d5e5cafd797703ac1aa516.png

class MazeSolver {


  constructor (mazeArray,
               entrancePosition = {x: 0, y: 1},
               exitPosition = {x: mazeArray[0].length - 1, y: mazeArray.length - 2}
  ) {
    this.mazeArray = mazeArray
    this.path = []


    this.entrancePosition = entrancePosition
    this.exitPosition = exitPosition


    this.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] = true


    if (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数组中。

12cfcfc3b5ce46ab162977b21abccd58.png

思路也很简单,就是开始求解某个点的时候,把这个点压进this.path,记录下这个位置。

但同时,每个人都需要意识到,有些道路是死胡同。比如上面的小迷宫,在第三个点,它会先向右走,然后发现是死路,然后又往回走。

196368a917c58cafdc0e61a16b4b83b7.png

所以会有一个回滚过程。回滚时,我们需要通过 this.path.pop() 从路径数组中移除这个点。

最后,如果我们的程序到了出口,就不需要继续往下遍历了,我们还要给我们的递归加上一个终止条件。

3e8a93d43fbeada1d5ef1abb9a17f886.png

好的,这些是我们最终的递归代码:

  • 有终止条件

  • 有边界判断

  • 然后返回一个路径

class MazeSolver {


  constructor (mazeArray,
               entrancePosition = {x: 0, y: 1},
               exitPosition = {x: mazeArray[0].length - 1, y: mazeArray.length - 2}
  ) {
    this.mazeArray = mazeArray
    this.path = []


    this.entrancePosition = entrancePosition
    this.exitPosition = exitPosition


    this.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] = true
    this.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

现在,我们可以绘制迷宫和路径:

613b4d977a2e81d194a765a38ea941b9.png

3cb80fa4f2b97b412f7c693ef5022dfe.png

v7

但是上面的代码只展示了静态的结果,并没有展示动态的过程。

我们还想知道的是:算法访问了哪些点,经过了哪些死胡同,最后是如何回归的。

为了解决这个问题,我们需要继续添加代码。我们添加一个数组:

//[{type:'enter' , position:{x:0 , y:1}} , {type: 'exit' , position:{x:0 , y:1}}]
this.detailedPath = []

截图如下:

075ac3fa7f6f5738b4c92d8b4d07e807.png

这个数组不仅记录了访问过哪些点,还记录了是进入还是离开这些点。enter 表示进入,exit 表示离开。

然后,我们在solvePoint方法开始执行后标记进入该点,并在结束前标记离开该点。最终代码如下:

3972eb768638c640b68e6750f7e5c18c.png

现在,detaillePath 可以记录算法的具体执行过程。

让我们渲染它:

caf5c105ffaee93bd22c4c7ec8f3083c.png

不是很酷吗?

f329f4331cdccc1df1a028ef878d0fd2.gif

v8

那么,你有没有注意到算法总是先向左尝试?

这实际上符合我们之前提到的代码:

9e9eaf4c6ec407e6ef80d0372ebcc698.png

而我们的坐标系是这样的:

718bdab1c0319971d2030b5b93698dd5.png

d2366c0e1a28dc57838365af4dd3a2a1.png

所以在之前的搜索路径中,我们的算法总是优先向右走。

如果要调整搜索顺序,只需要改变以上几行代码的顺序即可。但是正如我们之前提到的,我们用 this.directions 修改了代码,所以我们现在可以像这样修改它:

this.directions = [[-1,0],[0,1],[1,0],[0,-1]]

截图如下:

6829978adfe9d14325d59fdc5c6c97da.png

现在它是:

4ffb1e16759fb3b1abdfddde5302b368.gif

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

深度优先搜索

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

结论

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

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

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

学习更多技能

请点击下方公众号

956cc1f0541a5f00d5feab7a6ac50a91.gif

4933f05d9c5d93cb5f43f66f4d3f6d57.jpeg

d9a902a9bcff658f1d429e8ebd07bde7.png

  • 2
    点赞
  • 21
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值