前言
本篇文章是随着我对 JavaScript 数据结构与算法 这本书学习后的一个示例文章,其中有许多地方讲的不是很好,还请多多包涵。
一、实例截图
二、准备工作
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
起点:start.png
终点:end.png
三、功能分析
在实现功能前,我们首先需要明白我们要做什么,为了做这些我们又需要那些东西。
这里让我们来一步一步分析吧!
1、所需功能
第一步,我们来分析分析,做一个迷宫寻路,我们需要哪些方法。
(1)、迷宫生成
身为一个迷宫寻路的例子,那么当然少不了迷宫生成这个方法。
没有迷宫生成这个方法,就好比巧妇难为无米之炊。
(2)、随机障碍物
由于在本例中,障碍物一开始并不会被初始化,所以我们需要实现一个随机障碍物的方法来帮助我们。
当然了,我们也可以在初始化迷宫的时候为每个格子绑定点击事件(除起点与终点外)。
当点击该格子的时候,判定当前格子是否存在障碍物,如果存在,那么我们就将该位置上的障碍物移除。
如果不存在,那么我们就在该位置上添加障碍物。
(3)、路径搜索
当我们可以初始化地图与随机障碍物的时候,本例就已经完成了一半了。
该路径搜索如同字面意思,搜索从起点到终点的可走的最短路径。
该方法存在三种情况。
第一种:某个节点的下一个节点不是终点位置
遇到这种情况,我们只需要递归即可。
第二种:某个节点的下一个节点是终点位置
这种情况是我们递归结束的基线条件之一。
第三种:无路可走
这种情况是递归基线条件的另外一个条件。
这里为什么会说是寻到可走的最短路径呢? 原因很简单,因为当你第一时间找到终点的时候,该递归就停止了。那么返回过来的自然是第一个找到终点的位置。 既然是第一个找到终点的位置,那么意味这这条路径消耗时间最短,而消耗时间最短,也意味着这条路径是最佳路径(最短路径) (不过也有可能存在多条最短路径,但该算法只会取一个)
到这,我们为完成这个例子所需要实现的方法差不多已经罗列出来了。
但我们为了将这些逻辑用更简单的方法来实现,所以我们还需要借助 链表 与 队列 这两个数据结构。
这里我们将这两个数据结构分别创建对应的类(包括我们三个方法实现的类)。
2、所需类
(1)、Maze 类(迷宫类)
该类是主要类。
其负责:
- 迷宫初始化
- 随机障碍物
- 路径搜索
等上述所罗列出来的功能。
(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;
}
}