同學們好,我是來自 《技術銀河》的 三鑽 。
尋路算法練習
學習尋路算法有什麼好處?
- 尋路是廣度優先搜尋算法
- 所有的搜尋的算法的思路的非常相似
- 是以在講廣度優先的算法的過程中也可以把深度優先搜尋類的都講一遍
- 搜尋是算法裡面特别重要,通用型也是特别好的一類算法
- 這裡可以幫助大家在算法方面有一定的提升
通過可視化來了解算法
- 算法裡面也是有 UI 相關的部分的
- 并且有一些 JavaScript 特有的部分
- 學習這部分可以讓大家對 JavaScript 語言提高熟悉程度
- 并且對語言裡面的應用方式獲得一個更深的了解
- 還有最重要的是通過異步程式設計的特性,來講解一些可視化相關的知識
- 通過把算法的步驟可視化後,我們就可以非常直覺地看到算法的運轉狀況
尋路問題的定義
!! 尋路的問題 —— 就是在一張地圖上指定一個起點和一個終點,從起點通過橫豎斜各個方向去找到它通往終點的一個路徑。
在我們的這個練習裡面我們會制造一張 100 x 100 個格子的地圖,并且在上面繪制我們的從起點到終點的路徑。
!! 在一開始我們會先用簡單的橫豎兩個方向來尋找我們的終點,因為斜向的路徑會有一定的差異,尤其是在地圖比較空曠的情況下。後面我們會逐漸地增加各種各樣的功能,直到我們把搜素全部解決完。
地圖編輯器
在這麼大規模的尋路問題當中,我們首選需要做一個地圖編輯器。它的功能如下:
- 可以通過滑鼠左鍵點選,或者點選并拖動就可以繪制地圖
- 可以通過滑鼠右鍵點選,或者點選并拖動就可以清楚地圖
- 可以儲存地圖的資料,重新整理地圖後,還可以重新繪制出來
首先我們需要繪制我們地圖的底盤,在繪制之前我們就需要給我們的 HTML 加入 CSS。
我們的底盤是一個 100 x 100 格的,是以我們需要給每個格子一個樣式。這裡我們隻需要用
flex
來布局即可。假設我們每個格子都是
6px
寬高,然後依次每行排列 100 個。是以我們的 HTML 布局代碼就是如下:
<div id="container">
<div class="cell"></div>
<div class="cell"></div>
<div class="cell"></div>
<div class="cell"></div>
...
</div>
複制
-
就是外包框container
-
就是裡面的格子cell
布局的 CSS 就是如下:
body {
background: #0f0e18;
/* 讓整個内容居中 */
display: flex;
flex-direction: column;
align-items: center;
}
.cell {
display: inline-block;
line-height: 7px;
width: 6px;
height: 6px;
background-color: #2d2f42;
border-bottom: solid 1px #0f0e18;
border-right: solid 1px #0f0e18;
vertical-align: bottom;
transition: all 400ms ease;
}
#container {
padding-bottom: 1rem;
display: flex;
flex-wrap: wrap;
width: 701px;
}
/* 為了更好看,加入了 button 的樣式,可有可無!*/
button {
background: transparent;
/* border: none; */
color: var(--blue-color);
border: 1px solid aqua;
padding: 0.5rem 1rem;
cursor: pointer;
transition: all 400ms ease;
}
button:hover {
background: var(--blue-color);
color: #333;
}
複制
以上是布局的 CSS,但是整個底盤是需要我們用資料來建構的,這樣我們後面才能使用它來做我們尋路的問題。是以這裡我們需要加入 JavaScript 來做整個渲染的過程。
HTML 的部分我們隻需要加入一個
div
,并且擁有一個 ID 為
container
即可:
<div id="container"></div>
複制
實作思路:
- 建立一個 10000 個資料的數組,并且給裡面都放入
- 從左到右,從上到下,循環周遊所有底盤的格子
- 在周遊的同時在建立
元素,div
為class
cell
- 周遊的過程中遇到值為
的就給予背景顔色1
#7ceefc
- 添加
(滑鼠移動) 監聽mousemove
- 滑鼠移動監聽中有兩種情況,如果是滑鼠左鍵點選狀态下就加入背景顔色,如果是右鍵點選的話就是清楚目前背景顔色
- 最後把使用
把appendChild
加入到cell
之中container
- 使用 localStorage 記錄我們的底盤資料
代碼實作:
// 定義 100 x 100 的底盤資料
// 使用 localStorage 擷取,如果沒有就建立一個
let map = localStorage['map'] ? JSON.parse(localStorage['map']) : Array(10000).fill(0);
// 擷取 container 元素對象
let container = document.getElementById('container');
// 周遊所有格子
for (let y = 0; y < 100; y++) {
for (let x = 0; x < 100; x++) {
// 建立地圖方格
let cell = document.createElement('div');
cell.classList.add('cell');
// 遇到格子的狀态是 1 的,就賦予背景顔色
if (map[100 * y + x] == 1) cell.style.backgroundColor = 'aqua';
// 添加滑鼠移動監聽事件
cell.addEventListener('mousemove', () => {
// 隻有在滑鼠點選狀态下執行
if (mousedown) {
if (clear) {
// 1. 右鍵點選時,就是清楚格子的狀态
cell.style.backgroundColor = '';
map[100 * y + x] = 0;
} else {
// 2. 左鍵點選時,就是畫入格子的狀态
cell.style.backgroundColor = 'aqua';
map[100 * y + x] = 1;
}
}
});
// 加入到 container 之中
container.appendChild(cell);
}
}
let mousedown = false;
let clear = false;
// 滑鼠按鍵點選時,把滑鼠點選狀态變為 true
document.addEventListener('mousedown', e => {
mousedown = true;
clear = e.which === 3;
});
// 離開點選滑鼠按鍵後,把狀态更變成 false
document.addEventListener('mouseup', () => (mousedown = false));
// 因為我們需要使用右鍵,是以要把右鍵預設打開菜單禁用
document.addEventListener('contextmenu', e => e.preventDefault());
複制
最後我們這裡添加一個儲存按鈕,讓我們重新整理頁面的時候也會儲存着我們編輯過的地圖:
<div id="container"></div>
<!-- 儲存地圖資料到 localStorage -->
<button onclick="localStorage['map'] = JSON.stringify(map)">save</button>
複制
最後呈現的結果就是這樣的:
實作廣度優先搜尋
現在我們來深入的解決尋路的問題,上面我們已經定義過尋路問題,就是 “
找到一個起點和終點,然後我們需要找一條路徑,可以從起點到達終點,并且不能越過我們的邊界和牆
”。
我們一眼看過去這個 100 x 100 的網格,确實會覺得這個問題不是特别地好解決。在算這個路徑的過程,中間有大量的資料需要我們去運算。但是我們可以把這個問題變得更簡單一點。
我們會到起點,從起點這個位置開展我們的思考。問一下我們自己一個問題:”從這裡我們可以走到哪裡呢?“
這裡我們問題還是不簡單,可以走的地方可多了。因為起點的位置可能沒有任何障礙物,也不在任何邊緣上。那就是說哪裡都可以呀?
好吧,那麼我們再把問題的範圍再縮窄一點,“我們從起點開始,
第一步
能走到哪裡呢?”。好這個問題的關鍵就是
第一步
。
假設我們忽略斜着走的情況(這個我們後面再考慮進去,這裡的終點是簡化問題),我們可以走往
上
,
下
,
左
,
右
4 個格子。如果我們逆時針的給這些位置标記一下,就會得出以下的路徑标記。
就是這樣,我們就完成了尋路的第一本步驟了。接下來我們看看下一步我們又能走到哪裡。如果上面标記的
1
,
2
,
3
,
4
都是可以走的,按照我們剛剛的思路,我們現在看
1
号格子又能走哪裡呢?
沒有錯,按照逆時針的方式,我們可以走的格子有
5
,
6
,
7
三個格子。是以我們可以按照這樣的走法,把
2
,
3
,
4
都走一遍。最後我們就會發現下面這樣的路徑:
最後我們看看下面的動畫效果,看看整個過程是怎麼樣的。
是以我們就是這樣,一直往外尋找我們可以走到哪些格子,直到我們找到我們的終點。這樣即可幫助我們找到從起點到終點的路線。當然在尋找的時候如果我們遇到邊界或者障礙的時候就會跳過,因為這些都是走不過去的。
!! 這個思路的問題,很容易讓大家想到遞歸,但是這個方法并不适合使用遞歸來表達。如果我們用遞歸來表達,我們肯定是從找到這個格子之後,就會開始展開找
1
1
周圍的格子了。那麼 5,6,7 就會在 2,3,4 之前被執行了(因為是遞歸,我們會逐層展開的)。
!! 是以說,如果我們預設用遞歸的方式來表達,這個尋路的方式就會變成 “
”,但是對尋路問題來說深度優先搜尋是不好的,“
深度優先搜尋
”才是好的。
廣度優先搜尋
為什麼尋路廣度優先搜尋比深度優先搜尋好呢?
我們來舉個例子,就更好了解了!
!! 假設我們現在走入了一個迷宮,前方有成千上萬個分叉口,這個時候我們有兩種搜尋出路的方法
方案一:
獨自一人,選擇左邊或者右邊一直走那邊的每一條分支,并且都一直走到底直到走到有死胡同了,然後就回頭走另外那邊的分支。就這樣一直走一直切換分支,直到我們找到出口的分支為止。運氣好的話,很快就找到出口了,運氣不好的話,所有分支都基本走個遍才能找到出口。
方案二:
我們是獨自一個人,但是我們有 “分身術”,當我們遇到分叉口的時候我們是可以每一個分叉口都去嘗試一遍。比如 A,B,C 三個分叉口,我們可以 A 路線走一步,然後 B 路線也走一步,最後 C 路線也走一步,這裡我們步伐整齊統一,就有點像我們上面的動态圖中一樣,一直往所有分叉口往外擴充尋找路線。這樣我們就可以在 N 個分叉口依次一步一步往前走,直到找到出口。這樣的方式等同于我們依次的在尋找每一個分叉口,這樣的搜尋顯然是比第一個好的。(我不知道你們,但是就有分身術這個技能我就覺得已經在尋路這個問題中赢了!)
!! 回歸到算法的話,方案一其實就是“”,而方案二就是"
深度優先搜尋
"!顯然在尋路這種問題是廣度優先搜尋更為高效!
廣度優先搜尋
實作廣度優先搜尋代碼
玩過走迷宮的同學肯定都會想到,在走迷宮的時候,我們都會給我們走過的路徑标記,這樣我們才知道我們走過哪裡,最後通過這些記錄找到可以到達終點的路徑。我們這個尋路的問題也不例外,根據我們上面的分析,我們從起點就開始往外擴充尋找可以走的格子,每當我們找到一個可走的格子,我們都需要記錄起來。
在算法當中我們都會把資料加入一個 “
集合
” 裡面,而這個集合就是所有搜尋算法的 “
靈魂
”。所有搜尋算法的差異,完全就是在于這個 “
集合 (queue)
” 裡面。
而
queue
是一種資料結構,我們也叫它為 “
隊列
”,它的特性就是
先進先出
,
一邊進另外一邊出
。實際效果與下圖:
那麼 JavaScript 中有沒有隊列這樣的資料結構呢?有!
JavaScript 中的數組就是天然的隊列 (Queue)
,同時
JavaScript 中的數組也是天然的棧 (Stack)
。
JavaScript 的數組有 2 組常用的處理方法
shif
和
unshift
,以及
push
和
pop
。但是如果我們混搭來使用他們的話,就會讓我們的數組變成不一樣的資料結構。
-
與push
或者shift
與pop
結合使用,那麼數組就是一個unshift
隊列 (Queue)
-
和push
結合使用那麼,數組就是一個pop
(當然 shif 和 unshif 也是可以的,但是我們一般不會用這個組合來做棧,因為我們考慮到 JavaScript 的數組的實作,這樣使用性能會變低。)棧 (Stack)
這些理論知識都搞懂了,我們就可以開始整理一下編寫代碼的思路了:
- 我們需要聲明一個隊列,也就是聲明一個
,并且把我們的起點預設放入隊列中。(JavaScript 中我們使用數組即可)queue
- 編寫一個 “入隊” 的方法,條件是如果遇到邊緣或者障礙就直接跳出方法,這些都是不可走的格子是以不會加入我們的隊列。
- 如果沒有遇到以上情況,我們就可以先把可以走的格子在我們的
(在實作我們的地圖資料的時候聲明的一個數組)中記錄一個狀态,這裡我們可以使用map
, 代表這個格子我們已經走過了,這裡我們加入一個标記。2
- 循環我們隊列中可以走的格子,這裡的主要目标就是把所有記錄了可以走的格子都找到它的
,上
,下
,左
,并且把這些可走的格子都入隊列,然後進入下一個循環時就會去找這些新入隊列的格子可以走到哪裡,然後把後面找到的格子再次入隊列,以此類推。右
- 這個循環有一個截止條件,那就是如果我們在循環每個格子的時候,找到了終點的格子,我們就可以直接傳回
,代表我們已經找到終點了。true
好思路有了,我們馬上來看看代碼實作:
// 上一部分的代碼,這裡就忽略了...
// 隻要在上部分的代碼後面改造這部分即可
// 離開點選滑鼠按鍵後,把狀态更變成 false
document.addEventListener('mouseup', () => (mousedown = false));
// 因為我們需要使用右鍵,是以要把右鍵預設打開菜單禁用
document.addEventListener('contextmenu', e => e.preventDefault());
/**
* 尋路方法
* @param {Array} map 地圖資料
* @param {Array} start 起點 例如:[0, 0]
* @param {Array} end 終點 例如:[50, 50]
* @return Boolean
*/
function path(map, start, end) {
var queue = [start];
function insert(x, y) {
// 到達底盤邊緣,直接停止
if (x < 0 || x >= 100 || y < 0 || y >= 100) return;
// 遇到地圖的牆,也停止
if (map[y * 100 + x]) return;
// 标記可以走的路的格子的狀态為 2
map[y * 100 + x] = 2;
// 把可走的路推入隊列
queue.push([x, y]);
}
// 循環格子 4 邊的格子
while (queue.length) {
let [x, y] = queue.shift();
console.log(x, y);
// 遇到了終點位置就可以傳回了
if (x === end[0] && y === end[1]) return true;
// 把上下左右推入隊列
insert(x - 1, y);
insert(x, y - 1);
insert(x + 1, y);
insert(x, y + 1);
}
return false;
}
複制
為了可以調試看看,我們的代碼是否正确,我們來加一個按鈕,并且讓它可以執行我們這個尋路的方法
path
。
<div id="container"></div>
<!-- 儲存地圖資料到 localStorage -->
<div>
<button onclick="localStorage['map'] = JSON.stringify(map)">save</button>
<button onclick="path(map, [0,0], [50,50])">find</button>
</div>
複制
!! 這裡我們的按鈕會執行一個尋路方法,設定了起點是 x = 0,y = 0 的位置,終點是 x = 50,y = 50 的位置。點選這個按鈕之後,我們就可以在浏覽器的調試工具中的 console
之中看到尋路過程中走過的 x 和 y。如果我們的代碼是對的話,最後我們會看到在 50, 50 的時候就會停止運作了。
加入 Async 和 Await 來友善調試和展示
上一個代碼中,其實已經實作了尋路算法的主體部分了。但是裡面還是有一些問題的:
- 算法雖然最終傳回了我們終點的位置,并且傳回了
,看起來是符合了我們的預期的。但是它的正确性我們不太好保證。是以我們希望有一個可視化的效果來觀察這個尋路算法的過程。true
- 我們是找到一條路徑,這個最終的路徑我們還沒有把它找出來。
這些問題我們下來幾個步驟中會一步一步來解決。
!! 如何有看我們的 《TicTacToe 三子棋》的程式設計與算法練習的文章的話,我們裡面有講到使用和
async
,來讓函數中間可插入一些異步的操作。
await
這部分的代碼我們做了一些代碼的改造:
- 把
函數改為path()
函數async
- 把
函數改為insert()
函數async
- 因為
程式設計了異步函數,是以我們insert()
循環中的 insert 調用都需要在前面加入while()
await
- 同時我們需要加入一個等待函數
,它必須傳回一個sleep()
promise
- 在我們入隊列之後,在改變目前格子狀态為
之前,我們會對 DOM 元素中的格子的背景顔色進行改變,這樣我們就可以看到尋路的過程2
- 因為我們需要看到這個過程,是以每一次入隊列的時候我們需要給一個 1 秒的等待時間,這個就是使用
和async
的好處。在加入這個背景顔色之前,我們就可以加入一個await
,這樣入隊列和改變格子背景顔色之前就會有 1 秒的延遲。await sleep(1)
好廢話少說,我們來看看代碼有什麼變化:
// 上一部分的代碼,這裡就忽略了...
// 隻要在上部分的代碼後面改造這部分即可
// 離開點選滑鼠按鍵後,把狀态更變成 false
document.addEventListener('mouseup', () => (mousedown = false));
// 因為我們需要使用右鍵,是以要把右鍵預設打開菜單禁用
document.addEventListener('contextmenu', e => e.preventDefault());
/**
* 等待函數
* @param {Integer} t 時間 (秒)
* @return Promise
*/
function sleep(t) {
return new Promise(function (resolve) {
setTimeout(resolve, t);
});
}
/**
* 尋路方法 (異步)
* @param {Array} map 地圖資料
* @param {Array} start 起點 例如:[0, 0]
* @param {Array} end 終點 例如:[50, 50]
* @return Boolean
*/
async function path(map, start, end) {
var queue = [start];
/**
* 入隊方法 (異步)
* @param {Integer} x
* @param {Integer} y
*/
async function insert(x, y) {
// 到達底盤邊緣,直接停止
if (x < 0 || x >= 100 || y < 0 || y >= 100) return;
// 遇到地圖的牆,也停止
if (map[y * 100 + x]) return;
// 加入 30 毫秒的停頓,讓我們可以看到 UI 上面的變化
await sleep(1);
// 給搜尋到的路徑的格子加上背景顔色
container.children[y * 100 + x].style.backgroundColor = 'DARKSLATEBLUE';
// 标記可以走的路的格子的狀态為 2
map[y * 100 + x] = 2;
// 把可走的路推入隊列
queue.push([x, y]);
}
// 循環格子 4 邊的格子
while (queue.length) {
let [x, y] = queue.shift();
// console.log(x, y);
// 遇到了終點位置就可以傳回了
if (x === end[0] && y === end[1]) return true;
// 把上下左右推入隊列
await insert(x - 1, y);
await insert(x, y - 1);
await insert(x + 1, y);
await insert(x, y + 1);
}
return false;
}
複制
最後我們執行後的結果:
處理路徑問題
上一步我們用一個動畫,讓我們特别清晰的去了解整個尋路算法的過程。但是上一步提到的第二個問題,我們目前是還沒有解決的。是以這裡我們就是要找到最終的路徑是什麼,我們最終通過尋路算法之後,我們怎麼獲得一個路徑是可以從起點到達終點的呢?
其實處理這個路徑的問題也是非常的簡單的,我們先看看看我們之前講過的尋路的思路:
通過上面這個圖,我們已經都還記得,在我們通路每一個格子的時候,我們都往外擴充找到可走的格子有哪些。這個過程當中,我們都是知道每一個被擴充到的格子都是由前一個格子擴充過來的。換句話說,每一個格子都是知道我們是從那個格子擴充過來的。
比如說
5
,
6
,
7
這三個格子都是從
1
這個格子擴充過來的。既然我們知道每個格子上一步的來源,是不是我們可以在每一個格子記錄上一個來源的格子呢?是以在代碼中我們是不是可以在入隊的時候,就記錄上一個格子的 x,y軸呢?
!! 那麼我們就産生一個想法,通過這個記錄,我們怎麼尋找最終的路徑呢?
我們假設終點是在
8
這個位置,我們過來
8
這個點的時候是從我們的起點一步一步擴充出來的,那我們反過來通過記錄了每一個格子的前驅格子,就可以一步一步收縮回到起點呢?
那就是說,從
8
開始收,
8
是從
2
走過來的,而
2
就是從
起點
擴充過來的,最終我們就找到起點了。如果我們在收縮的過程記錄着收縮時候通路到的格子,最終這些格子就是從起點到終點的整條路徑了!是不是很神奇?!
!! 其實也可以了解為一個 “原路傳回” 的效果!
先不要雞凍,穩住!我們接下來看看代碼是如何處理的:
- 其實基本上我們的代碼沒有太多的改變
- 首先就是在
循環當中的while
調用的時候添加了上一個坐标的傳參insert()
- 這裡我們順便也把橫向的可走的格子也加入到隊列中
- 這裡因為我們需要記錄所有格子的前驅坐标,是以我們需要聲明一個
的變量存放這個資料table
- 在我們進入隊列之前,我們就把目前入隊列的格子的值存為上一個格子的坐标(這個為了我們後面友善收縮是找到整個路徑)
- 最後在
循環中,當我們遇到終點的 x 和 y 的時候,我們加入一段while
循環while
- 這個
就是往回一直走,知道我們找到起點位置,在往回走的同時,把每一個經過的格子的背景改為另外一個背景顔色,這樣就可以在我們的地圖上畫出一個路徑了!while
好思路清晰,我們來上一波代碼吧!
// 上一部分的代碼,這裡就忽略了...
// 隻要在上部分的代碼後面改造這部分即可
/**
* 尋路方法 (異步)
* @param {Array} map 地圖資料
* @param {Array} start 起點 例如:[0, 0]
* @param {Array} end 終點 例如:[50, 50]
* @return Boolean
*/
function sleep(t) {
return new Promise(function (resolve) {
setTimeout(resolve, t);
});
}
/**
* 入隊方法 (異步)
* @param {Integer} x
* @param {Integer} y
*/
async function findPath(map, start, end) {
// 建立一個記錄表格
let table = Object.create(map);
let queue = [start];
/**
* 入隊方法 (異步)
* @param {Integer} x
* @param {Integer} y
* @param {Array} pre 上一個格子的坐标:[x,y]
*/
async function insert(x, y, pre) {
// 到達底盤邊緣,直接停止
if (x < 0 || x >= 100 || y < 0 || y >= 100) return;
// 遇到地圖的牆,也停止
if (table[y * 100 + x]) return;
// 加入 30 毫秒的停頓,讓我們可以看到 UI 上面的變化
// await sleep(1);
// 給搜尋到的路徑的格子加上背景顔色
container.children[y * 100 + x].style.backgroundColor = 'DARKSLATEBLUE';
// 标記走過的格子的值,标記為上一個格子的 x,y 位置
table[y * 100 + x] = pre;
// 把可走的路推入隊列
queue.push([x, y]);
}
// 循環格子 4 邊的格子
while (queue.length) {
let [x, y] = queue.shift();
// console.log(x, y);
// 遇到了終點位置就可以傳回了
if (x === end[0] && y === end[1]) {
let path = [];
// 往回走,直到走到起點
// 這樣就能畫出最佳路徑了
while (x != start[0] || y != start[1]) {
path.push(map[y * 100 + x]);
[x, y] = table[y * 100 + x];
await sleep(1);
container.children[y * 100 + x].style.backgroundColor = 'fuchsia';
}
return path;
}
// 把上下左右推入隊列
await insert(x - 1, y, [x, y]);
await insert(x, y - 1, [x, y]);
await insert(x + 1, y, [x, y]);
await insert(x, y + 1, [x, y]);
// 把 4 個 斜邊推入隊列
await insert(x - 1, y - 1, [x, y]);
await insert(x + 1, y - 1, [x, y]);
await insert(x - 1, y + 1, [x, y]);
await insert(x + 1, y + 1, [x, y]);
}
return null;
}
複制
!! 注意:這裡我們把原來的函數名改為了
path
,因為在尋找路徑的
findPath
循環裡面我們用了
while
做為記錄路徑的變量。這裡還需要注意的就是,我們的
path
按鈕裡面的函數調用也需要一并修改哦。
find
<!-- 儲存地圖資料到 localStorage -->
<div>
<button onclick="localStorage['map'] = JSON.stringify(map)">save</button>
<button onclick="findPath(map, [0,0], [50,50])">find</button>
</div>
複制
運作最終效果如下:
啟發式尋路(A*)
到這裡我們已經完成了整個廣度優先尋路的算法。但是廣搜式尋路是不是最好的尋路方案呢?
其實并不是的
!
通過各位數學科學家的努力下,他們證明了一件事情。我們是可以有一種方法能夠加速尋路的,通過用這個方法我們不需要使用一個非常傻的方式來挨個去找。
!! 這種尋路的方式叫做 “
啟發式尋路
”
!! 啟發式尋路就是用一個函數去判斷這些點擴充的優先級。隻要我們判斷好了優先級,我們就可以有目的的去驗證格子的方向做優先找路。
“但是這個找到的路徑是不是最佳路徑呀?說的那麼神奇的。“ 說實話,這個我們普通人的思路就排不上用場了。但是數學家證明了一件事情,隻要我們使用啟發式函數來估計值,并且這些值能夠一定小于目前點到終點的路徑的長度,這樣就一定能找到最優路徑。
!! 這種能找到最優路徑的啟發式尋路,在計算機裡面我們叫它做 “”。這裡面的
A*
代表着一種不一定能找到最優路徑的啟發式尋路。是以
A
就是 A 尋路的一個特例,是一種可以找到最佳路徑的一種算法。
A*
要實作這個啟發式尋路,其實我們是不需要改過多我們
findPath()
函數裡面的代碼的。我們要改的是我們存儲的資料結構,也就是我們的
queue
隊列。
我們要把
queue
的先進先出變成一個
優先隊列 (Prioritized Queue)
。
是以我們需要建構一個
Sorted
類,這個類有幾個工作:
- 這個類可以存儲我們之前
隊列裡面的資料queue
- 可以支援傳入排序函數(也就是和我們
函數一樣的功能,可以傳入一個排序規則函數,也叫array sort
函數)compare
- 在我們通過這個類擷取值的時候給我們資料裡面的最小值
- 在我們插入資料的時候不需要排序,隻是單純的儲存
- 每次去除資料的時候,可以把輸出的資料在類的資料裡面删除掉
- 這樣我們就可以一直在裡面擷取資料,知道沒有資料為止
- 最後加入一個可以擷取目前資料的長度(這個在我們的尋路算法中需要用到)
!! 這裡我們用一個非常 “土鼈” 的數組來實作這個 Sorted 類,但是在計算機當中,我們還有很多其他方式可以實作這一種類。比如說,
winner tree
,
堆 (heap)
等很多的不同思路來實作有序的資料結構。
排序二叉樹
好廢話少說,我們來看看怎麼實作這個資料結構:
/** 排序資料類 */
class Sorted {
constructor(data, compare) {
this.data = data.slice();
this.compare = compare || ((a, b) => a - b);
}
take() {
// 考慮到 null 也是可以參與比較的,是以這裡傳回 null 是不合适的
if (!this.data.length) return;
// 記錄最小的值
// 預設第一個位置為最小值
let min = this.data[0];
// 記錄最小值的位置
let minIndex = 0;
// 開始比較數組裡面的所有值,找到更小的值,就記錄為 min
// 同時記錄最小值,和最小值的位置
for (let i = 1; i < this.data.length; i++) {
if (this.compare(this.data[i], min) < 0) {
min = this.data[i];
minIndex = i;
}
}
// 現在我們要把最小值拿出去了,是以要在我們目前的資料中移除
// 這裡我們不考慮使用 splice,因為 splice 移除會涉及數組内的元素都要往前挪動
// 這樣 splice 就會有一個 O(N) 的時間複雜度
// 這裡我們用一個小技巧,把數組裡面最後一位的值挪動到目前發現最小值的位置
// 最後使用 pop 把最後一位資料移除
this.data[minIndex] = this.data[this.data.length - 1];
this.data.pop();
// 最後把最小值輸出
return min;
}
give(value) {
this.data.push(value);
}
get length() {
return this.data.length;
}
}
複制
有了這個
Sorted
的資料結構之後,我們就可以用來解決我們的尋路問題,讓我們可以找到最佳的路徑了。
加入這個最佳路徑的邏輯,我們需要改造以下幾個點:
- 首先改寫我們的存儲資料的
,使用我們queue
的排序資料結構Sorted
- 編寫一個
函數來運算任何一個格子與終點格子的直線距離distance()
- 把所有入隊列和出隊列的調用改為使用
類裡面的Sorted
取值 和take
插入值函數give
- 其他地方基本就沒有什麼改變了
接下來我們來看看代碼是怎麼實作的:
// 上一部分的代碼,這裡就忽略了...
// 隻要在上部分的代碼後面改造這部分即可
/**
* 尋路方法 (異步)
* @param {Array} map 地圖資料
* @param {Array} start 起點 例如:[0, 0]
* @param {Array} end 終點 例如:[50, 50]
* @return Boolean
*/
async function findPath(map, start, end) {
// 建立一個記錄表格
let table = Object.create(map);
let queue = new Sorted([start], (a, b) => distance(a) - distance(b));
async function insert(x, y, pre) {
// 到達底盤邊緣,直接停止
if (x < 0 || x >= 100 || y < 0 || y >= 100) return;
// 遇到地圖的牆,也停止
if (table[y * 100 + x]) return;
// 加入 30 毫秒的停頓,讓我們可以看到 UI 上面的變化
await sleep(1);
// 給搜尋到的路徑的格子加上背景顔色
container.children[y * 100 + x].style.backgroundColor = 'DARKSLATEBLUE';
// 标記走過的格子的值,标記為上一個格子的 x,y 位置
table[y * 100 + x] = pre;
// 把可走的路推入隊列
queue.give([x, y]);
}
/**
* 擷取格與格之前的距離
* @param {Array} point 目前格子的坐标:[x,y]
*/
function distance(point) {
// 使用三角形 x^2 + y^2 = z^2 的方式來計算距離
return (point[0] - end[0]) ** 2 + (point[1] - end[1]) ** 2;
}
// 循環格子 4 邊的格子
while (queue.length) {
let [x, y] = queue.take();
// console.log(x, y);
// 遇到了終點位置就可以傳回了
if (x === end[0] && y === end[1]) {
let path = [];
// 往回走,直到走到起點
// 這樣就能畫出最佳路徑了
while (x != start[0] || y != start[1]) {
path.push(map[y * 100 + x]);
[x, y] = table[y * 100 + x];
container.children[y * 100 + x].style.backgroundColor = 'fuchsia';
}
return path;
}
// 把上下左右推入隊列
await insert(x - 1, y, [x, y]);
await insert(x, y - 1, [x, y]);
await insert(x + 1, y, [x, y]);
await insert(x, y + 1, [x, y]);
// 把 4 個 斜邊推入隊列
await insert(x - 1, y - 1, [x, y]);
await insert(x + 1, y - 1, [x, y]);
await insert(x - 1, y + 1, [x, y]);
await insert(x + 1, y + 1, [x, y]);
}
return null;
}
複制
這個最終的運作效果如下:
我是來自《技術銀河》的三鑽:"學習是為了成長,成長是為了不退步。堅持才能成功,失敗隻是因為沒有堅持。同學們加油哦!下期見!"