本篇將嘗使用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實際地應用了一遍,做爲學習的的模型仍是挺好的。更多的算法可參考這篇《我接觸過的前端數據結構與算法》。