本篇将尝使用canvas + wasm画一个迷宫,生成算法主要用到连通集算法,使用wasm主要是为了提高运行效率。而后再用一个最短路径算法找到迷宫的出路,最后的效果以下:javascript
生成迷宫的算法其实很简单,假设迷宫的大小是10 * 10,即这个迷宫有100个格子,经过不断地随机拆掉这100个格子中间的墙,直到能够从第一个格子走到最后一个格子,也就是说第一个格子和最后一个格子处于同一个连通集。具体以下操做:html
(1)生成100个格子,每一个格子都不相通前端
(2)随机选取相邻的两个格子,能够是左右相邻或者是上下相邻,判断这两个格子是否是处于同一个连通集,即可否从其中一个格子走到另一个格子,若是不能,就拆掉它们中间的墙,让它们相连,处于同一个连通集。html5
(3)重复第二步,直到第一个格子和最后一个格子相连。java
那这个连通集应该怎么表示呢?咱们用一个一维数组来表示不一样的已连通的集合,初始化的时候每一个格子的值都为-1,以下图所示,假设迷宫为3 * 3,即有9个格子:web
每一个索引在迷宫的位置:算法
负数表示它们是不一样的连通集,由于咱们还没开始拆墙,因此一开始它们都是独立的。canvas
如今把三、4中间的墙拆掉,也就是说让3和4连通,把4的值置成3,表示4在3这个连通集,3是它们的根,以下图所示:api
再把五、8给拆了:数组
再把四、5给拆了:
这个时候三、四、五、8就处于同一个连通集了,可是0和8依旧是两个不一样的连通集,这个时候再把3和0中间的墙给拆了:
因为0的连通集是3,而8的连通集也是3,即它们处于同一个连通集,所以这个时候从第一个格子到最后一个格子的路是相通的,就生成了一个迷宫。
咱们用UnionSet的类表示连通集,以下代码所示:
class UnionSet{ constructor(size){ this.set = new Array(size); for(var i = this.set.length - 1; i >= 0; i--){ this.set[i] = -1; } } union(root1, root2){ this.set[root1] = root2; } findSet(x){ while(this.set[x] >= 0){ x = this.set[x]; } return x; } sameSet(x, y){ return this.findSet(x) === this.findSet(y); } unionElement(x, y){ this.union(this.findSet(x), this.findSet(y)); } }复制代码
咱们总共用了22行代码就实现了一个连通集。上面的代码应该比较好理解,对照上面的示意图。如findSet函数获得某个元素所在的set的根元素,而根元素存放的是负数,只要存放的值是正数那么它就是指向另外一个结点,经过while循环一层层的往上找直到负数。unionElement能够连通两个元素,先找到它们所在的set,而后把它们的set union一下变成同一个连通集。
如今写一个Maze,用来控制画迷宫的操做,它组合一个UnionSet的实例,以下代码所示:
class Maze{ constructor(columns, rows, cavans){ this.columns = columns; this.rows = rows; this.cells = columns * rows; //存放是连通的格子,{1: [2, 11]}表示第1个格子和第二、11个格子是相通的 this.linkedMap = {}; this.unionSets = new UnionSet(this.cells); this.canvas = canvas; } }复制代码
Maze构造函数传三个参数,前两个是迷宫的列数和行数,最后一个是canvas元素。在构造函数里面初始化一个连通集,做为这个Maze的核心模型,还初始化了一个linkedMap,用来存放拆掉的墙,进而提供给canvas绘图。
Maze类再添加一个生成迷宫的函数,以下代码所示:
//生成迷宫 generate(){ //每次任意取两个相邻的格子,若是它们不在同一个连通集, //则拆掉中间的墙,让它们连在一块儿成为一个连通集 while(!this.firstLastLinked()){ var cellPairs = this.pickRandomCellPairs(); if(!this.unionSets.sameSet(cellPairs[0], cellPairs[1])){ this.unionSets.unionElement(cellPairs[0], cellPairs[1]); this.addLinkedMap(cellPairs[0], cellPairs[1]); } } }复制代码
生成迷宫的核心逻辑很简单,在while循环里面判断第一个是否与最后一个格子连通,若是不是的话,则每次随机选取两个相邻的格子,若是它们不在同一个连通集,则把它们连通一下,同时记录一下拆掉的墙到linkedMap里面。
怎么随机选取两个相邻的格子呢?这个虽然没什么技术难点,可是实现起来须要动一番脑筋,由于在边上的格子没有完整的上下左右四个相邻格子,有些只有两个,有些有三个。笔者是这么实现的,相对来讲比较简单:
//取出随机的两个挨着的格子 pickRandomCellPairs(){ var cell = (Math.random() * this.cells) >> 0; //再取一个相邻格子,0 = 上,1 = 右,2 = 下,3 = 左 var neiborCells = []; var row = (cell / this.columns) >> 0, column = cell % this.rows; //不是第一排的有上方的相邻元素 if(row !== 0){ neiborCells.push(cell - this.columns); } //不是最后一排的有下面的相邻元素 if(row !== this.rows - 1){ neiborCells.push(cell + this.columns); } if(column !== 0){ neiborCells.push(cell - 1); } if(column !== this.columns - 1){ neiborCells.push(cell + 1); } var index = (Math.random() * neiborCells.length) >> 0; return [cell, neiborCells[index]]; }复制代码
首先随机选一个格子,而后获得它的行数和列数,接着依次判断它的边界状况。若是它不是处于第一排,那么它就有上面一排的相邻格子,若是不是最后一排则有下面一排的相邻格子,同理,若是不是在第一列则有左边的,不是最后一列则有右边的。把符合条件的格子放到一个数组里面,而后再随机取这个数组里的一个元素。这样就获得了两个随机的相邻元素。
另外一个addLinkedMap函数的实现较为简单,以下代码所示:
addLinkedMap(x, y){ if(!this.linkedMap[x]) this.linkedMap[x] = []; if(!this.linkedMap[y]) this.linkedMap[y] = []; if(this.linkedMap[x].indexOf(y) < 0){ this.linkedMap[x].push(y); } if(this.linkedMap[y].indexOf(x) < 0){ this.linkedMap[y].push(x); } }复制代码
这样生成迷宫的核心逻辑基本完成,可是上面连通集的代码能够优化, 一个是findSet函数,能够在findSet的时候把当前连通集里的元素的存放值直接改为根元素,这样就不用造成一条很长的查找链,或者说造成一棵很高的树,可直接一步到位,以下代码所示:
findSet(x){ if(this.set[x] < 0) return x; return this.set[x] = this.findSet(this.set[x]); }复制代码
这段代码使用了一个递归,在查找的同时改变值。
union函数也能够作一个优化,findSet能够把树的高度改小,可是在没有改小前的union操做也能够作优化,以下代码所示:
union(root1, root2){ if(this.set[root1] < this.set[root2]){ this.set[root2] = root1; } else { if(this.set[root1] === this.set[root2]){ this.set[root2]--; } this.set[root1] = root2; } }复制代码
这段代码的目的也是为了减小查找链的长度或者说减小树的高度,方法是把一棵比较矮的连通集成为另一棵比较高的连通集的子树,这样两个连通集,合并起来的高度仍是那棵高的。若是两个连通集的高度同样,则选取其中一个做为根,另一棵树的结点在查找的时候无疑这些结点的查找长度要加上1 ,由于多了一个新的root,也就是说树的高度要加1,因为存放的是负数,因此进行减减操做。在判断树高度的时候也是同样的,越小就说明越高。
经验证,这样改过以后,代码执行效率快了一半以上。
迷宫生成好以后,如今开始来画。
先写一个canvas的html元素,以下代码所示:
<canvas id="maze" width="800" height="600"></canvas>复制代码
注意canvas的宽高要用width和height的属性写,若是用style的话就是拉伸了,会出现模糊的状况。
怎么用canvas画线呢?以下代码所示:
var canvas = document.getElementById("maze"); var ctx = canvas.getContext("2d"); ctx.moveTo(0, 0); ctx.lineTo(100, 100); ctx.stroke();复制代码
这段代码画了一条线,从(0, 0)到(100, 100),这也是本篇将用到的canvas的3个基础的api。
上面已经获得了一个linkedMap,对于一个3 * 3的表格,把linkedMap打印一下,可获得如下表格。
经过上面的表格可知道,0和3中间是没有墙,因此在画的时候0和3中间就不要画横线了,3和4也是相连的,它们中间就不要画竖线了。对每一个普通的格子都画它右边的竖线和下面的横线,而对于被拆掉的就不要画,因此获得如下代码:
draw(){ var linkedMap = this.linkedMap; var cellWidth = this.canvas.width / this.columns, cellHeight = this.canvas.height / this.rows; var ctx = this.canvas.getContext("2d"); //translate 0.5个像素,避免模糊 ctx.translate(0.5, 0.5); for(var i = 0; i < this.cells; i++){ var row = i / this.columns >> 0, column = i % this.columns; //画右边的竖线 if(column !== this.columns - 1 && (!linkedMap[i] || linkedMap[i].indexOf(i + 1) < 0)){ ctx.moveTo((column + 1) * cellWidth >> 0, row * cellHeight >> 0); ctx.lineTo((column + 1) * cellWidth >> 0, (row + 1) * cellHeight >> 0); } //画下面的横线 if(row !== this.rows - 1 && (!linkedMap[i] || linkedMap[i].indexOf(i + this.columns) < 0)){ ctx.moveTo(column * cellWidth >> 0, (row + 1) * cellHeight >> 0); ctx.lineTo((column + 1) * cellWidth >> 0, (row + 1) * cellHeight >> 0); } } //最后再一次性stroke,提升性能 ctx.stroke(); //画迷宫的四条边框 this.drawBorder(ctx, cellWidth, cellHeight); }复制代码
上面的代码也比较好理解,在画右边的竖线的时候,先判断它和右边的格子是否相通,即linkMap[i]里面有没有i + 1元素,若是没有,而且它不是最后一列,就画右边的竖线。由于迷宫的边框放到后面再画,它比较特殊,最后一个格子的竖线是不要画的,由于它是迷宫的出口。每次moveTo和lineTo的位置须要计算一下。
注意上面的代码作了两个优化,一个是先translate 0.5个像素,为了让canvas画线的位置恰好在像素上面,由于咱们的lineWidth是1,若是不translate,那么它画的位置以下图中间所示,相邻两个像素占了半个像素,显示器显示的时候变会变虚,而translate 0.5个像素以后,它就会恰好画在像在像素点上。详见:HTML5 Canvas – Crisp lines every time。
第二个优化是全部的moveTo和lineTo都完成以后再stroke,这样它就是一条线,能够极大地提升画图的效率。这个很重要,刚开始的时候没这么作,致使格子数稍多的时候就画不了了,改为这样以后,绘制的效率提高不少。
咱们还能够再作一个优化,就是使用双缓存技术,在画的时候别直接画到屏幕上,而是先在内存的画布里面完成绘制,最后再一次性地Paint绘制到屏幕上,这样也能够提升性能。以下代码所示:
draw(){ var canvasBuffer = document.createElement("canvas"); canvasBuffer.width = this.canvas.width; canvasBuffer.height = this.canvas.height; var ctx = canvasBuffer.getContext("2d"); ctx.translate(0.5, 0.5); for(var i = 0; i < this.cells; i++){ } ctx.stroke(); this.drawBorder(ctx, cellWidth, cellHeight); console.log("draw"); this.canvas.getContext("2d").drawImage(canvasBuffer, 0, 0); }复制代码
先动态建立一个canvas节点,获取它的context,在上面画图,画好以后再用原先的canvas的context的drawImage把它画到屏幕上去。
而后就能够写驱动代码了,以下画一个50 * 50的迷宫,并统计一下时间:
const column = 50, row = 50; var canvas = document.getElementById("maze"); var maze = new Maze(column, row, canvas); console.time("generate maze"); maze.generate(); console.timeEnd("generate maze"); console.time("draw maze"); maze.draw(); console.timeEnd("draw maze");复制代码
画出的迷宫:
运行时间:
能够看到画一个2500规模的迷宫,draw的时间仍是不多的,而生成的时间也不长,可是咱们发现一个问题,就是迷宫的有些格子是封闭的:
这些不可以进去的格子就没用了,这不太符合迷宫的特色。因此不能存在自我封闭的格子,因为生成的时候是判断第一个格子有没有和最后一个连通,如今改为第一个格子和全部的格子都是连通的,也就是说能够从第一个格子到达任意一个格子,这样迷宫的误导性才比较强,以下代码所示:
linkedToFirstCell(){ for(var i = 1; i < this.cells; i++){ if(!this.unionSets.sameSet(0, i)) return false; } return true; }复制代码
把while的判断也改一下,这样改完以后,生成的迷宫变成了这样:
这样生成的迷宫看起来就正常多了,生成迷宫的时间也相应地变长:
可是咱们发现仍是有一些比较奇怪的格子布局,以下图所示:
由于这样布局的其实没太大的意义,若是让你手动设计一个迷宫,你确定也不会设计这样的布局。因此咱们的算法还能够再改进,因为上面是随机选取两个相邻格子,能够把它改为随机选取4个相邻的格子,这样生成的迷宫通道就会比较长,像上图这种比较奇芭的状况就会比较少。读者能够亲自动手试验一下。
这个模型更为常见的场景是,如今我在A城镇,准备去Z城镇,中间要绕道B、C、D等城镇,而且有多条路线可选,而且知道每一个城镇和它连通的城镇以及两两之间距离,如今要求解一条A到Z的最短的路,以下图所示:
在迷宫的模型里面也是相似的,要求解从第一个格子到最后一个格子的最短路径,而且已经知道格子之间的连通状况。不同的是相邻格子之间的距离是无权的,都为1,因此这个处理起来会更加简单。
用一个贪婪算法能够解决这个问题,假设从A到Z的最短路径为A->C->G->Z,那么这条路径也是A到G、A到C的最短路径,由于若是A到G还有更短的路径,那么A到Z的距离就还能够更短了,即这条路径不是最短的。所以咱们从A开始延伸,一步步地肯定A到其它地点的最短路径,直到扩散到Z。
在无权的状况下,如上面任意相邻城镇的距离相等,和A直接相连的节点一定是A到这个节点的最短路径,如上图A到B、C、F的最短路径为A->B、A->C、A->F,这三个点的最短路径可标记为已知。和C直接相邻的是G和D,C是最短的,因此A->C-G和A->C->D也是最短的,再往下一层,和G、D直接相连的分别是E和Z,因此A->C->G->Z和A->C->D->E是到Z和E的一条最短路径,到此就找到了A->Z的最短路线。E也能够到Z,可是因为Z已经被标为已知最短了,因此经过E的这条路径就被放弃了。
和A直接相连的作为第一层,而和第一层直接相连的作为第二层,由第一层到第二层一直延伸目标结点,先被找到的节点就会被标记为已知。这是一个广度优先搜索。
而在有权的状况下,刚开始的时候A被标记为已知,因为A和C是最短的,因此C也被标记为已知,B和F不会标记,可是它们和A的距离会受到更新,由初始化的无穷大更新为A->B和A->F的距离。在已查找到但未标记的两个点里面,A->F的距离是最短的,因此F被标记为已知,这是由于若是存在另一条更短的未知的路到F,它一定得先通过已经查找到的点(由于已经查找过的点是A的必经之路),这里面已是最短的了,因此不可能还有更短的了。F被标记为已知以后和F直接相连的E的距离获得更新,一样地,在已查找到但未标记的点里面B的距离最短,因此B被标记为已知,而后再更新和B相连的点的距离。重复这个过程,直到Z被标记为已知。
标记起始点为已知,更新表的距离,再标记表里最短的距离为已知,再更新表的距离,重复直到目的点被标记,这个算法也叫Dijkstra算法。
如今来实现一个无权的最短路径,以下代码所示:
calPath(){ var pathTable = new Array(this.cells); for(var i = 0; i < pathTable.length; i++){ pathTable[i] = {known: false, prevCell: -1}; } pathTable[0].known = true; var map = this.linkedMap; //用一个队列存储当前层的节点,先进队列的结点优先处理 var unSearchCells = [0]; var j = 0; while(!pathTable[pathTable.length - 1].known){ while(unSearchCells.length){ var cell = unSearchCells.pop(); for(var i = 0; i < map[cell].length; i++){ if(pathTable[map[cell][i]].known) continue; pathTable[map[cell][i]].known = true; pathTable[map[cell][i]].prevCell = cell; unSearchCells.unshift(map[cell][i]); if(pathTable[pathTable.length - 1].known) break; } } } var cell = this.cells - 1; var path = [cell]; while(cell !== 0){ var cell = pathTable[cell].prevCell; path.push(cell); } return path; }复制代码
这个算法实现的关键在于用一个队列存储未处理的结点,每处理一个结点时,就把和这个结点相连的点入队,这样新入队的结点就会排到当前层的结点的后面,当把第一层的结点处理完了,就会把第二层的结点都push到队尾,同理当把第二层的结点都出队了,就会把第三层的结点推到队尾。这样就实现了一个广度优先搜索。
在处理每一个结点须要须要先判断一下当前结点是否已被标记为known,若是是的话就不用处理了。
在pathTable表格里面用一个prevCell记录到这个结点的上一个结点是哪一个,为了可以从目的结点一直往前找到到达第一个结点的路径。最后找到这个path返回。
只要有这个path,就可以计算位置画出路径的图,以下图所示:
这个算法的速度仍是很快的,以下图所示:
当把迷宫的规模提升到200 * 200时:
生成迷宫的时间就很耗时了,花费了10秒:
因而想着用WASM提升生成迷宫的效率,看看能提高多少。我在《WebAssembly与程序编译》这篇里已经介绍了WASM的一些基础知识,本篇我将用它来生成迷宫。
我在《WebAssembly与程序编译》提过用JS写很难编译,因此本篇也直接用C来写。上面是用的class,可是WASM用C写没有class的类型,只支持基本的操做。可是能够用一个struct存放数据,函数名也相应地作修改,以下代码所示:
struct Data{ int *set; int columns; int rows; int cells; int **linkedMap; } data; void Set_union(int root1, int root2){ int *set = data.set; if(set[root1] < set[root2]){ set[root2] = root1; } else { if(set[root1] == set[root2]){ set[root2]--; } set[root1] = root2; } } int Set_findSet(int x){ if(data.set[x] < 0) return x; else return data.set[x] = Set_findSet(data.set[x]); }复制代码
数据类型都是强类型的,函数名以类名Set_开头,类的数据放在一个struct结构里面。主要导出函数为:
#include <emscripten.h> EMSCRIPTEN_KEEPALIVE //这个宏表示这个函数要做为导出的函数 int **Maze_generate(int columns, int rows){ Maze_init(columns, rows); Maze_doGenerate(); return data.linkedMap; //return Maze_getJSONStr(); }复制代码
传进来列数和行数,返回一个二维数组。其它代码相应地改为C代码,这里再也不放出来。须要注意的是,因为这里用到了一些C内置的库,如使用随机数函数rand(),因此不能用上文提到的生成wasm的方法,否则会报rand等库函数没有定义。
把生成wasm的命令改为:
emcc maze.c -Os -s WASM=1 -o maze-wasm.html
这样它会生成一个maze-wasm.js和maze-wasm.wasm(生成的html文件不须要用到),生成的JS文件是用来自动加载和导入wasm文件的,在html里面引入这个JS:
<script src="maze-wasm.js"></script> <script src="maze.js"></script>复制代码
它就会自动去加载maze-wasm.wasm文件,同时会定义一个全局的Module对象,在wasm文件加载好以后会触发onInit,因此调它的api添加一个监听函数,以下代码所示:
var maze = new Maze(column, row, canvas); Module.addOnInit(function(){ var ptr = Module._Maze_generate(column, row); maze.linkedMap = readInt32Array(ptr, column * row); maze.draw(); });复制代码
有两种方法能够获得导出的函数,一种是在函数名前面加_,如Module._Maze_generate,第二种是使用它提供的ccall或cwrap函数,如ccall:
var linkedMapPtr = Module.ccall("Maze_generate", "number", ["number", "number"], [column, row]);复制代码
第一个参数表示函数名,第二个返回类型,第三个参数类型,第四个传参,或者用cwrap:
var mazeGenerate = Module.cwrap("Maze_generate", "number", ["number", "number"]); var linkedMapPtr = mazeGenerate(column, row);复制代码
三种方法都会返回linkedMap的指针地址,可经过Module.get获得地址里面的值,以下代码所示:
function readInt32Array(ptr, length) { var linkedMap = new Array(length); for(var i = 0; i < length; i++) { var subptr = Module.getValue(ptr + (i * 4), 'i32'); var neiborcells = []; for(var j = 0; j < 4; j++){ var value = Module.getValue(subptr + (j * 4), 'i32'); if(value !== -1){ neiborcells.push(value, 'i32'); } } linkedMap[i] = neiborcells; } return linkedMap; }复制代码
因为它是一个二维数组,因此数组里面存放的是指向数组的指针,所以须要再对这些指针再作一次get操做,就能够拿到具体的值了。若是取出的值是-1则表示不是有效的相邻元素,由于C里面数组的长度是固定的,没法随便动态push,所以我在C里面都初始化了4个,由于相邻元素最多只有4个,初始时用-1填充。取出非-1的值push到JS的数组里面,获得一个用WASM计算的linkedMap. 而后再用一样的方法去画地图。
最后再比较一下WASM和JS生成迷宫的时间。以下代码所示,运行50次:
var count = 50; console.time("JS generate maze"); for(var i = 0; i < count; i++){ var maze = new Maze(column, row, canvas); maze.generate(); } console.timeEnd("JS generate maze"); Module.addOnInit(function(){ console.time("WASM generate maze"); for(var i = 0; i < count; i++){ var maze = new Maze(column, row, canvas); var ptr = Module._Maze_generate(column, row); var linkedMap = readInt32Array(ptr, column * row); } console.timeEnd("WASM generate maze"); })复制代码
迷宫的规模为50 * 50,结果以下:
能够看到,WASM的时间大概快了25%,而且有时候会观察到WASM的时间甚至要比JS的时间要长,这时由于算法是随机的,有时候拆掉的墙可能会比较多,因此误差会比较大。可是大部份状况下的25%仍是可信的,由于若是把随机选取的墙保存起来,而后让JS和WASM用一样的数据,这个时间差就会固定在25%,以下图所示:
这个时间要比上面的大,由于保存了一个须要拆的墙比较多的数组。理论上不用产生随机数,时间会更少,不过咱们的重点是比较它们的时间差,结果是无论运行多少次,时间差都比较稳定。
因此在这个例子里面WASM节省了25%的时间,虽然提高不是很明显,但仍是有效果,不少个25%累积起来仍是挺长的。
综上,本文用JS和WASM使用连通集算法生成迷宫,并用最短路径算法求解迷宫的路径。使用WASM在生成迷宫的例子里面能够提高25%的速度。
虽然迷宫小时候就已经在玩了,不是什么高大上的东西,可是经过这个例子讨论到了一些算法,还用到了很出名的最短路径算法,还把WASM实际地应用了一遍,做为学习的的模型仍是挺好的。更多的算法可参考这篇《我接触过的前端数据结构与算法》。