JavaScript 迷宫寻路(广度优先搜索详解)

1,433 阅读7分钟

前言

本篇文章是随着我对 JavaScript 数据结构与算法 这本书学习后的一个示例文章,其中有许多地方讲的不是很好,还请多多包涵。


一、实例截图

image.png

image.png

image.png

二、准备工作

1、HTML

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>BFS Maze</title>

    <link rel="stylesheet" href="style.css">
</head>
<body>
    <main>
        <h2>迷宫寻路算法实现</h2>

		<!-- 这里是控制台区域的开始 -->
        <div class="mtb-30 flex">
            <div class="mr-10">
                行数 <input type="number" class="row" value="10" min="1">
            </div>
            <div class="mr-10">
                列数 <input type="number" class="col" value="10" min="1">
            </div>
            <div>
                <button class="mr-10 reset">重置</button>
                <button class="mr-10 calc">计算</button>
                <button class="random">随机障碍</button>
            </div>
        </div>
		<!-- 这里是控制台区域的结束 -->

        <div class="table">
        	<!-- 这里存放迷宫格子 -->
            <div class="grid"></div>
        </div>
    </main>
    
    <!-- index 文件里是我们的重头戏 -->
    <script src="index.js"></script>
    <script>
        const maze = new Maze();// 创建一个 迷宫对象
        const rowInput = document.querySelector(".row");
        const colInput = document.querySelector(".col");

        /**
         * dom 事件监听
         */
        rowInput.onchange = function() {
            maze.init(rowInput.value,colInput.value);// 初始化地图
        }

        colInput.onchange = function() {
            maze.init(rowInput.value,colInput.value);// 初始化地图
        }

        document.querySelector(".reset").onclick = function() {
            maze.init(rowInput.value,colInput.value);// 初始化地图
        }

        document.querySelector(".random").onclick = function() { 
            maze.randomObstacle();// 随机障碍物
        }

        document.querySelector(".calc").onclick = function() { 
            maze.defaultSearchPath();// 寻路
        }
    </script>
</body>
</html>

2、CSS

* {
    padding: 0;
    margin: 0;
    box-sizing: border-box;
}

body {
    display: flex;
    justify-content: center;
    align-items: center;
    width: 100%;
    height: 100vh;
}

main {
    width: 600px;
    height: 700px;
}

.mtb-30 { margin: 24px 0; }
.mr-10 { margin-right: 10px; }

.flex { display: flex; }

.table {
    width: 600px;
    height: 600px;
    border: 1px solid #000;
}

.grid { 
    display: grid; 
    width: 100%;
    height: 100%;
}

.grid div {
    position: relative;
    border: 1px solid #000;
}

.grid div.start::after,
.grid div.end::after,
.grid div.obstacle::after {
    position: absolute;
    width: 100%;
    height: 100%;
    background-size: cover;
    background-position: center;
    content: "";
}

.grid div.start::after {
    background-image: url(start.png);
}

.grid div.end::after {
    background-image: url(end.png);
}

.grid div.obstacle::after {
    background-image: url(obstacle.png);
}

.grid div.active {
    background: yellow;
}

3、图片素材

路障:obstacle.png

image.png

起点:start.png

image.png

终点:end.png

image.png

三、功能分析

在实现功能前,我们首先需要明白我们要做什么,为了做这些我们又需要那些东西。

这里让我们来一步一步分析吧!

1、所需功能

第一步,我们来分析分析,做一个迷宫寻路,我们需要哪些方法。

(1)、迷宫生成

身为一个迷宫寻路的例子,那么当然少不了迷宫生成这个方法。

没有迷宫生成这个方法,就好比巧妇难为无米之炊


(2)、随机障碍物

由于在本例中,障碍物一开始并不会被初始化,所以我们需要实现一个随机障碍物的方法来帮助我们。

当然了,我们也可以在初始化迷宫的时候为每个格子绑定点击事件(除起点与终点外)。

当点击该格子的时候,判定当前格子是否存在障碍物,如果存在,那么我们就将该位置上的障碍物移除。

如果不存在,那么我们就在该位置上添加障碍物。


(3)、路径搜索

当我们可以初始化地图与随机障碍物的时候,本例就已经完成了一半了。

该路径搜索如同字面意思,搜索从起点到终点的可走的最短路径。

该方法存在三种情况。

第一种:某个节点的下一个节点不是终点位置

遇到这种情况,我们只需要递归即可。

第二种:某个节点的下一个节点是终点位置

这种情况是我们递归结束的基线条件之一。

第三种:无路可走

这种情况是递归基线条件的另外一个条件。

这里为什么会说是寻到可走的最短路径呢? 原因很简单,因为当你第一时间找到终点的时候,该递归就停止了。那么返回过来的自然是第一个找到终点的位置。 既然是第一个找到终点的位置,那么意味这这条路径消耗时间最短,而消耗时间最短,也意味着这条路径是最佳路径(最短路径) (不过也有可能存在多条最短路径,但该算法只会取一个)

到这,我们为完成这个例子所需要实现的方法差不多已经罗列出来了。

但我们为了将这些逻辑用更简单的方法来实现,所以我们还需要借助 链表 与 队列 这两个数据结构。

这里我们将这两个数据结构分别创建对应的类(包括我们三个方法实现的类)。

2、所需类

(1)、Maze 类(迷宫类)

该类是主要类。

其负责:

  1. 迷宫初始化
  2. 随机障碍物
  3. 路径搜索

等上述所罗列出来的功能。

(2)、Node 类(链表类)

该类用于存储一个节点与其上一个节点。

通过该类我们可以在找到终点的时候回溯。

(3)、Queue 类(队列类)

该类用于暂时存储待访问节点。

当访问过一个节点后会将该节点移除。

四、创建类

1、Maze 类

class Maze {
	constructor() {
		this.row = 10;
		this.col = 10;
		this.graph = [];
		this.visited = [];
		this.init(this.col,this.row);
	}
}

这里我们创建好了 Maze 类及其构造函数。

并在构造函数声明了 row ,col ,graph 与 visited 属性。

其中 row 代表迷宫的 ,col 代表迷宫的

graph 用来存储整个迷宫格子,visited 用来存储每个格子的状态(是否被访问)。

声明完四个属性后,我们调用 init 方法来初始化地图。(Maze 类的方法待会在下面实现)

2、Node 类

class Node {
    constructor(x,y,parent) {
        this.x = x;
        this.y = y;
        this.parent = parent;
    }
}

同 Maze 类一样,我们创建好 Node 类及其构造函数后,为其添加三个属性。

x 代表该节点所在的 x 位置,y 代表所在的 y 位置。

parent 则指向其上一个节点。

3、Queue 队列

class Queue {
    constructor() {
        this.arr = [];
    }

    push(element) {
        this.arr.push(element);
        return true;
    }

    shift() {
        return this.arr.shift();
    }

    size() {
        return this.arr.length;
    }
}

由于队列遵循 FIFO 规则,所以我们需要用到 push 方法放入元素, shift 方法移除元素。

五、实现方法

待到我们创建完三个类后,我们便可以开始动手来实现 Maze 类里的方法了。

1、init 方法

正如 init 的意思一样,我们用其来初始化迷宫与每个格子的状态。

该方法接收两个参数,col 与 row。

init(col,row) {
    this.row = row;
    this.col = col;

    this.graph = [];
    this.visited = [];

    for ( let r = 0; r < row; r++ ) {
        this.graph[r] = [];
        this.visited[r] = [];
        for ( let c = 0; c < col; c++ ) {
            this.graph[r][c] = 1;// 1 代表路可走,0代表路不可走(即有路障)
            this.visited[r][c] = 0;// 0 代表未访问,1代表已访问
        }
    }

    this.buildDom();
}

2、buildDom 方法

该方法根据 graph 数组生成对应的 Dom 元素并为其绑定点击事件。

buildDom() {
	// 获取存放迷宫格子的父级Dom
    const grid = document.querySelector(".grid");

	// 清空存放迷宫格子的父级Dom里的所有元素并未其设置样式
    grid.innerHTML = "";
    grid.style.cssText = `grid-template-columns: repeat(${this.col},1fr);grid-template-rows: repeat(${this.row},1fr);`;

    for ( let row in this.graph ) {
        for ( let col in this.graph[row] ) {
			// 创建对应格子的 Dom 元素
            let div = document.createElement("div");

            div.setAttribute("data-key",`${row}-${col}`);// 为其设置对应属性

			// 为其绑定事件
            div.onclick = () => {
            	// 如果该 Dom 是起点或者终点那么就终止此次执行
                if ( row == 0 && col == 0 || row == this.row - 1 && col == this.col - 1 ) return;

                if ( div.classList.contains("obstacle") ) {// 如过当前元素存在路障
                    div.classList.remove("obstacle");
                    this.graph[row][col] = 1;
                } else {// 如果当前元素不存在路障
                    div.classList.add("obstacle");
                    this.graph[row][col] = 0;
                }
            }

            grid.append(div);// 添加 Dom 元素
        }
    }

    
    document.querySelector(`.grid div[data-key="0-0"]`).classList.add("start");// 起点
    document.querySelector(`.grid div[data-key="${this.row - 1}-${this.col - 1}"]`).classList.add("end");// 终点
}

3、randomObstacle 方法

/**
* 获取随机位置的元素
*/
randomObstacle() {
    let { row , col } = this.getRandom();
    document.querySelector(`.grid div[data-key="${row}-${col}"]`).click();
}

getRandom() {
    let row = Math.floor(Math.random() * this.row),
        col = Math.floor(Math.random() * this.col);

    if ( row === 0 ) row = 1;
    if ( col === 0 ) col = 1;
    if ( row === this.row ) row = this.row - 1;
    if ( col === this.col ) col = this.col - 1;

    if ( this.graph[row][col] === 0 ) {
        let random = this.getRandom();
        row = random.row;
        col = random.col;
    }

    return { row,col };
}

4、defaultSearchPath 方法

该方法是默认搜索,即从迷宫最左上角到最右下角,接下来的才是重头戏。

defaultSearchPath() {
    this.BFS([0,0],[this.row - 1,this.col - 1]);
}

5、BFS 方法(广度优先搜索算法)

BFS(from,to) {
    let flag = false,current,
        queue = new Queue();

    const move = [
        [0,1],
        [1,0],
        [0,-1],
        [-1,0]
    ];

    queue.push(new Node(from[0],from[1],null));

    const bfs = () => {
        current = queue.shift();

        if ( current.y == to[0] && current.x == to[1] ) {
            let parent = current;
            flag = true;

            while ( parent != null ) {
                document.querySelector(`.grid div[data-key="${parent.y}-${parent.x}"]`).style.background = "yellow";
                parent = parent.parent;
            }

            return console.log("已匹配到终点");
        }

        for ( let v = 0; v <= 3; v++ ) {
            let y = Math.min(current.y + move[v][0],this.row - 1),
                x = Math.min(current.x + move[v][1],this.col - 1);

            if (this.graph[y][x] === 1 && this.visited[y][x] == 0 ) {
                queue.push(new Node(x,y,current));
                this.visited[y][x] = 1;
            }
        }

        if ( queue.size() <= 0 && !flag ) return console.log("未找到终点");

        bfs();
    }
    
    bfs();
}

首先我们来看看声明的变量。

flag,该变量用于确定我们是否找到终点。

current,该变量用于保存我们当前走到的节点。

queue,该变量是一个队列,用于存储我们待访问的节点。

move,该常量用于我们是往上走,往下走等操作,帮助我们访问附近的兄弟节点是否被访问过等作用。

待我们声明完这几个变量/常量后,我们将起点的位置存入待访问队列中。

然后调用 bfs 方法(此方法用于递归查路)。


在 bfs 方法里,我们首先将队列里第一个元素移除并赋予 current。

current = queue.shift();

之后我们进行判定,判定当前节点是否是我们的目的地。

如果是,那么我们将 flag 变量变为 true,表示我们找到终点了 {*2}

并且声明一个变量保存当前节点 {*1}

之后我们再通过 while 循环来回溯该节点的上一个节点,并将该节点对应的 Dom 元素进行一个背景颜色为黄色的标记,直到回溯到起点 {*3}

完成这一系列操作后,我们通过 return 来跳出递归。

if ( current.y == to[0] && current.x == to[1] ) {
    let parent = current;// {1}
    flag = true;// {2}

    while ( parent != null ) {// {3}
        document.querySelector(`.grid div[data-key="${parent.y}-${parent.x}"]`).style.background = "yellow";
        parent = parent.parent;
    }

    return console.log("已匹配到终点");
}

如果我们判定到当前节点不是终点后,那么我们执行如下方法。

for ( let v = 1; v < 4; v++ ) {// {1}
    let y = Math.min(current.y + move[v][0],this.row - 1),
        x = Math.min(current.x + move[v][1],this.col - 1);

    if (this.graph[y][x] === 1 && this.visited[y][x] == 0 ) {// {2}
        queue.push(new Node(x,y,current));
        this.visited[y][x] = 1;
    }
}

在这个循环里,我们会访问当前所在节点的 上、下、左、右 等四个相邻的节点 {*1}

并且如果我们附近的节点没有被访问过,且不存在路障,则将该节点存入 queue 队列,并将该节点的状态更变为已访问 {*2}

if ( queue.size() <= 0 && !flag ) return console.log("未找到终点");// {1}

bfs();// {2}

最后如果我们的待访问队列里的元素已为空且 flag 为 false,则证明我们没有找到终点。那么我们同样跳出递归 {*1}

否则继续执行递归 {*2}

至此,我们的迷宫寻路已经完成。

完整的 index 代码如下。

class Maze {
    constructor() {
        this.row = 10;
        this.col = 10;
        this.graph = [];
        this.visited = [];
        this.init(this.col,this.row);
    }

    init(col,row) {
        this.row = row;
        this.col = col;

		this.visited = [];
        this.graph = [];

        for ( let r = 0; r < row; r++ ) {
            this.graph[r] = [];
            this.visited[r] = [];
            for ( let c = 0; c < col; c++ ) {
                this.graph[r][c] = 1;
                this.visited[r][c] = 0;
            }
        }

        this.buildDom();
    }

    buildDom() {
        const grid = document.querySelector(".grid");

        grid.innerHTML = "";
        grid.style.cssText = `grid-template-columns: repeat(${this.col},1fr);grid-template-rows: repeat(${this.row},1fr);`;

        for ( let row in this.graph ) {
            for ( let col in this.graph[row] ) {

                let div = document.createElement("div");

                div.setAttribute("data-key",`${row}-${col}`);

                div.onclick = () => {
                    if ( row == 0 && col == 0 || row == this.row - 1 && col == this.col - 1 ) return;

                    if ( div.classList.contains("obstacle") ) {
                        div.classList.remove("obstacle");
                        this.graph[row][col] = 1;
                    } else {
                        div.classList.add("obstacle");
                        this.graph[row][col] = 0;
                    }
                }

                grid.append(div);
            }
        }

        
        document.querySelector(`.grid div[data-key="0-0"]`).classList.add("start");
        document.querySelector(`.grid div[data-key="${this.row - 1}-${this.col - 1}"]`).classList.add("end");
    }

    randomObstacle() {
        let { row , col } = this.getRandom();
        document.querySelector(`.grid div[data-key="${row}-${col}"]`).click();
    }

    getRandom() {
        let row = Math.floor(Math.random() * this.row),
            col = Math.floor(Math.random() * this.col);

        if ( row === 0 ) row = 1;
        if ( col === 0 ) col = 1;
        if ( row === this.row ) row = this.row - 1;
        if ( col === this.col ) col = this.col - 1;

        if ( this.graph[row][col] === 0 ) {
            let random = this.getRandom();
            row = random.row;
            col = random.col;
        }

        return { row,col };
    }

    defaultSearchPath() {
        this.BFS([0,0],[this.row - 1,this.col - 1]);
    }

    /**
     * 
     * @param {array} from 
     * @param {array} to 
     */
    BFS(from,to) {
        let flag = false,current,
            queue = new Queue();

        const move = [
            [0,1],
            [1,0],
            [0,-1],
            [-1,0]
        ];

        queue.push(new Node(from[0],from[1],null));

        const bfs = () => {
            current = queue.shift();

            if ( current.y == to[0] && current.x == to[1] ) {
                let parent = current;
                flag = true;

                while ( parent != null ) {
                    document.querySelector(`.grid div[data-key="${parent.y}-${parent.x}"]`).style.background = "yellow";
                    parent = parent.parent;
                }

                return true;
            }

            for ( let v = 0; v <= 3; v++ ) {
                let y = Math.min(current.y + move[v][0],this.row - 1),
                    x = Math.min(current.x + move[v][1],this.col - 1);

                if (this.graph[y][x] === 1 && this.visited[y][x] == 0 ) {
                    queue.push(new Node(x,y,current));
                    this.visited[y][x] = 1;
                }
            }

            if ( queue.size() <= 0 && !flag ) return console.log("未找到终点");

            bfs();
        }
        
        bfs();
    }
}

class Node {
    constructor(x,y,parent) {
        this.x = x;
        this.y = y;
        this.parent = parent;
    }
}

class Queue {
    constructor() {
        this.arr = [];
    }

    push(element) {
        this.arr.push(element);
        return true;
    }

    shift() {
        return this.arr.shift();
    }

    size() {
        return this.arr.length;
    }
}