
同學們好,我是來自 《技術銀河》的 💎 三鉆 ,
尋路演算法練習
學習尋路演算法有什么好處?
- 尋路是廣度優先搜索演算法
- 所有的搜索的演算法的思路的非常相似
- 所以在講廣度優先的演算法的程序中也可以把深度優先搜索類的都講一遍
- 搜索是演算法里面特別重要,通用型也是特別好的一類演算法
- 這里可以幫助大家在演算法方面有一定的提升
通過可視化來理解演算法
- 演算法里面也是有 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 個資料的陣列,并且給里面都放入
0 - 從左到右,從上到下,回圈遍歷所有底盤的格子
- 在遍歷的同時在創建
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>
最后呈現的結果就是這樣的:

查看效果 | 查看代碼 | 喜歡的同學 🌟star 一下謝謝!
實作廣度優先搜索
現在我們來深入的解決尋路的問題,上面我們已經定義過尋路問題,就是 “找到一個起點和終點,然后我們需要找一條路徑,可以從起點到達終點,并且不能越過我們的邊界和墻”,
我們一眼看過去這個 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結合使用那么,陣列就是一個堆疊 (Stack)(當然 shif 和 unshif 也是可以的,但是我們一般不會用這個組合來做堆疊,因為我們考慮到 JavaScript 的陣列的實作,這樣使用性能會變低,)
這些理論知識都搞懂了,我們就可以開始整理一下撰寫代碼的思路了:
- 我們需要宣告一個佇列,也就是宣告一個
queue,并且把我們的起點默認放入佇列中,(JavaScript 中我們使用陣列即可) - 撰寫一個 “入隊” 的方法,條件是如果遇到邊緣或者障礙就直接跳出方法,這些都是不可走的格子所以不會加入我們的佇列,
- 如果沒有遇到以上情況,我們就可以先把可以走的格子在我們的
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 的時候就會停止運行了,
查看效果 | 查看代碼 | 喜歡的同學 🌟star 一下謝謝!
加入 Async 和 Await 來方便除錯和展示
上一個代碼中,其實已經實作了尋路演算法的主體部分了,但是里面還是有一些問題的:
- 演算法雖然最侄訓傳了我們終點的位置,并且回傳了
true,看起來是符合了我們的預期的,但是它的正確性我們不太好保證,所以我們希望有一個可視化的效果來觀察這個尋路演算法的程序, - 我們是找到一條路徑,這個最終的路徑我們還沒有把它找出來,
這些問題我們下來幾個步驟中會一步一步來解決,
如何有看我們的 《TicTacToe 三子棋》的編程與演算法練習的文章的話,我們里面有講到使用
async和await,來讓函式中間可插入一些異步的操作,
這部分的代碼我們做了一些代碼的改造:
- 把
path()函式改為async函式 - 把
insert()函式改為async函式 - 因為
insert()編程了異步函式,所以我們while()回圈中的 insert 呼叫都需要在前面加入await - 同時我們需要加入一個等待函式
sleep(),它必須回傳一個promise - 在我們入佇列之后,在改變當前格子狀態為
2之前,我們會對 DOM 元素中的格子的背景顏色進行改變,這樣我們就可以看到尋路的程序 - 因為我們需要看到這個程序,所以每一次入佇列的時候我們需要給一個 1 秒的等待時間,這個就是使用
async和await的好處,在加入這個背景顏色之前,我們就可以加入一個await sleep(1),這樣入佇列和改變格子背景顏色之前就會有 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;
}
最后我們執行后的結果:

查看效果 | 查看代碼 | 喜歡的同學 🌟star 一下謝謝!
處理路徑問題
上一步我們用一個影片,讓我們特別清晰的去理解整個尋路演算法的程序,但是上一步提到的第二個問題,我們目前是還沒有解決的,所以這里我們就是要找到最終的路徑是什么,我們最終通過尋路演算法之后,我們怎么獲得一個路徑是可以從起點到達終點的呢?
其實處理這個路徑的問題也是非常的簡單的,我們先看看看我們之前講過的尋路的思路:

通過上面這個圖,我們已經都還記得,在我們訪問每一個格子的時候,我們都往外擴展找到可走的格子有哪些,這個程序當中,我們都是知道每一個被擴展到的格子都是由前一個給自擴展過來的,換句話說,每一個格子都是知道我們是從那個給自擴展過來的,
比如說 5,6,7 這三個格子都是從 1 這個格子擴展過來的,既然我們知道每個給自上一步的來源,是不是我們可以在每一個給自記錄上一個來源的格子呢?所以在代碼中我們是不是可以在入隊的時候,就記錄上一個給自的 x,y軸呢?
那么我們就產生一個想法,通過這個記錄,我們怎么尋找最終的路徑呢?
我們假設終點是在 8 這個位置,我們過來 8 這個點的時候是從我們的起點一步一步擴展出來的,那我們反過來通過記錄了每一個格子的前驅格子,就可以一步一步收碩訓到起點呢?
那就是說,從 8 開始收,8 是從 2 走過來的,而 2 就是從起點 擴展過來的,最終我們就找到起點了,如果我們在收縮的程序記錄著收縮時候訪問到的格子,最終這些格子就是從起點到終點的整條路徑了!是不是很神奇?!
其實也可以理解為一個 “原路回傳” 的效果!

先不要雞凍,穩住!我們接下來看看代碼是如何處理的:
- 其實基本上我們的代碼沒有太多的改變
- 首先就是在
while回圈當中的insert()呼叫的時候添加了上一個坐標的傳參 - 這里我們順便也把橫向的可走的格子也加入到佇列中
- 這里因為我們需要記錄所有格子的前驅坐標,所以我們需要宣告一個
table的變數存放這個資料 - 在我們進入佇列之前,我們就把當前入佇列的格子的值存為上一個格子的坐標(這個為了我們后面方便收縮是找到整個路徑)
- 最后在
while回圈中,當我們遇到終點的 x 和 y 的時候,我們加入一段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>
運行最終效果如下:

查看效果 | 查看代碼 | 喜歡的同學 🌟star 一下謝謝!
啟發式尋路(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;
}
這個最終的運行效果如下:

查看效果 | 查看代碼 | 喜歡的同學 🌟star 一下謝謝!
我是來自《技術銀河》的三鉆:“學習是為了成長,成長是為了不退步,堅持才能成功,失敗只是因為沒有堅持,同學們加油哦!下期見!”

推薦專欄
小伙伴們可以查看或者訂閱相關的專欄,從而集中閱讀相關知識的文章哦,
-
📖 《前端進階》 — 這里包含的文章學習內容需要我們擁有 1-2 年前端開發經驗后,選擇讓自己升級到高級前端工程師的學習內容(這里學習的內容是對應阿里 P6 級別的內容),
-
📖 《資料結構與演算法》 — 到了如今,如果想成為一個高級開發工程師或者進入大廠,不論崗位是前端、后端還是AI,演算法都是重中之重,也無論我們需要進入的公司的崗位是否最后是做演算法工程師,前提面試就需要考演算法,
-
📖 《FCC前端集訓營》 — 根據FreeCodeCamp的學習課程,一起深入淺出學習前端,穩固前端知識,一起在FreeCodeCamp獲得證書
-
📖 《前端星球》 — 以實戰為線索,深入淺出前端多維度的知識點,內含有多方面的前端知識文章,帶領不懂前端的童鞋一起學習前端,在前端開發路上童鞋一起燃起心中那團火🔥

CSDN認證博客專家
前端
Vue
React
轉載請註明出處,本文鏈接:https://www.uj5u.com/qita/204187.html
標籤:其他
上一篇:摸魚嗎?不會我教你啊
下一篇:經典三子棋小游戲(C語言版)
