主頁 >  其他 > 用 JavaScript 實作尋路演算法 —— 編程訓練

用 JavaScript 實作尋路演算法 —— 編程訓練

2020-11-06 08:41:10 其他

同學們好,我是來自 《技術銀河》的 💎 三鉆

尋路演算法練習

學習尋路演算法有什么好處?

  • 尋路是廣度優先搜索演算法
  • 所有的搜索的演算法的思路的非常相似
  • 所以在講廣度優先的演算法的程序中也可以把深度優先搜索類的都講一遍
  • 搜索是演算法里面特別重要,通用型也是特別好的一類演算法
  • 這里可以幫助大家在演算法方面有一定的提升

通過可視化來理解演算法

  • 演算法里面也是有 UI 相關的部分的
  • 并且有一些 JavaScript 特有的部分
  • 學習這部分可以讓大家對 JavaScript 語言提高熟悉程度
  • 并且對語言里面的應用方式獲得一個更深的了解
  • 還有最重要的是通過異步編程的特性,來講解一些可視化相關的知識
  • 通過把演算法的步驟可視化后,我們就可以非常直觀地看到演算法的運轉狀況

尋路問題的定義

尋路的問題 —— 就是在一張地圖上指定一個起點和一個終點,從起點通過橫豎斜各個方向去找到它通往終點的一個路徑,

在我們的這個練習里面我們會制造一張 100 x 100 個格子的地圖,并且在上面繪制我們的從起點到終點的路徑,

在一開始我們會先用簡單的橫豎兩個方向來尋找我們的終點,因為斜向的路徑會有一定的差異,尤其是在地圖比較空曠的情況下,后面我們會逐漸地增加各種各樣的功能,直到我們把搜素全部解決完,

地圖編輯器

在這么大規模的尋路問題當中,我們首選需要做一個地圖編輯器,它的功能如下:

  1. 可以通過滑鼠左鍵點擊,或者點擊并拖動就可以繪制地圖
  2. 可以通過滑鼠右鍵點擊,或者點擊并拖動就可以清楚地圖
  3. 可以保存地圖的資料,重繪地圖后,還可以重新繪制出來

首先我們需要繪制我們地圖的底盤,在繪制之前我們就需要給我們的 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>

實作思路

  1. 創建一個 10000 個資料的陣列,并且給里面都放入 0
  2. 從左到右,從上到下,回圈遍歷所有底盤的格子
  3. 在遍歷的同時在創建 div 元素,classcell
  4. 遍歷的程序中遇到值為 1 的就給予背景顏色 #7ceefc
  5. 添加 mousemove (滑鼠移動) 監聽
  6. 滑鼠移動監聽中有兩種情況,如果是滑鼠左鍵點擊狀態下就加入背景顏色,如果是右鍵點擊的話就是清楚當前背景顏色
  7. 最后把使用 appendChildcell 加入到 container 之中
  8. 使用 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 個格子,如果我們逆時針的給這些位置標記一下就會得出一下的路徑標記,

就是這樣,我們就完成了尋路的第一本步驟了,接下來我們看看下一步我們又能走到哪里,如果上面標記的1234 都是可以走的,按照我們剛剛的思路,我們現在看 1 號格子又能走哪里呢?

沒有錯,按照逆時針的方式,我們可以走的格子有 567 三個格子,所以我們可以按照這樣的走法,把234 都走一遍,最后我們就會發現下面這樣的路徑:

最后我們看看下面的影片效果,看看整個程序是怎么樣的,

所以我們就是這樣,一直往外尋找我們可以走到哪些格子,直到我們找到我們的終點,這樣即可幫助我們找到從起點到終點的路線,當然在尋找的時候如果我們遇到邊界或者障礙的時候就會跳過,因為這些都是走不過去的,

這個思路的問題,很容易讓大家想到遞回,但是這個方法并不適合使用遞回來表達,如果我們用遞回來表達,我們肯定是從找到 1 這個格子之后,就會開始展開找 1 周圍的格子了,那么 5,6,7 就會在 2,3,4 之前被執行了(因為是遞回,我們會逐層展開的),

所以說,如果我們默認用遞回的方式來表達,這個尋路的方式就會變成 “深度優先搜索”,但是對尋路問題來說深度優先搜索是不好的,“廣度優先搜索”才是好的,

為什么尋路廣度優先搜索比深度優先搜索好呢?

我們來舉個例子,就更好理解了!

假設我們現在走入了一個迷宮,前方有成千上萬個分叉口,這個時候我們有兩種搜索出路的方法

方案一

獨自一人,選擇左邊或者右邊一直走那邊的每一條分支,并且都一直走到底直到走到有死胡同了,然后就回頭走另外那邊的分支,就這樣一直走一直切換分支,直到我們找到出口的分支為止,運氣好的話,很快就找到出口了,運氣不好的話,所有分支都基本走個遍才能找到出口,

方案二

我們是獨自一個人,但是我們有 “分身術”,當我們遇到分叉口的時候我們是可以每一個分叉口都去嘗試一遍,比如 A,B,C 三個分叉口,我們可以 A 路線走一步,然后 B 路線也走一步,最后 C 路線也走一步,這里我們步伐整齊統一,就有點像我們上面的動態圖中一樣,一直往所有分叉口往外擴展尋找路線,這樣我們就可以在 N 個分叉口依次一步一步往前走,直到找到出口,這樣的方式等同于我們依次的在尋找每一個分叉口,這樣的搜索顯然是比第一個好的,(我不知道你們,但是就有分身術這個技能我就覺得已經在尋路這個問題中贏了!

回歸到演算法的話,方案一其實就是“深度優先搜索”,而方案二就是"廣度優先搜索"!顯然在尋路這種問題是廣度優先搜索更為高效!

實作廣度優先搜索代碼

玩過走迷宮的同學肯定都會想到,在走迷宮的時候,我們都會給我們走過的路徑標記,這樣我們才知道我們走過哪里,最后通過這些記錄找到可以到達終點的路徑,我們這個尋路的問題也不例外,根據我們上面的分析,我們從起點就開始往外擴展尋找可以走的格子,每當我們找到一個可走的格子,我們都需要記錄起來,

在演算法當中我們都會把資料加入一個 “集合” 里面,而這個集合就是所有搜索演算法的 “靈魂”,所有搜索演算法的差異,完全就是在于這個 “集合 (queue)” 里面,

queue 是一種資料結構,我們也叫它為 “佇列”,它的特性就是 先進先出一邊進另外一邊出,實際效果與下圖:

那么 JavaScript 中有沒有佇列這樣的資料結構呢?有!JavaScript 中的陣列就是天然的佇列 (Queue),同時 JavaScript 中的陣列也是天然的堆疊 (Stack)

JavaScript 的陣列有 2 組常用的處理方法 shifunshift,以及 pushpop, 但是如果我們混搭來使用他們的話,就會讓我們的陣列變成不一樣的資料結構,

  • pushshift 或者 popunshift 結合使用,那么陣列就是一個佇列 (Queue)
  • pushpop 結合使用那么,陣列就是一個堆疊 (Stack) (當然 shif 和 unshif 也是可以的,但是我們一般不會用這個組合來做堆疊,因為我們考慮到 JavaScript 的陣列的實作,這樣使用性能會變低,)

這些理論知識都搞懂了,我們就可以開始整理一下撰寫代碼的思路了:

  1. 我們需要宣告一個佇列,也就是宣告一個 queue,并且把我們的起點默認放入佇列中,(JavaScript 中我們使用陣列即可)
  2. 撰寫一個 “入隊” 的方法,條件是如果遇到邊緣或者障礙就直接跳出方法,這些都是不可走的格子所以不會加入我們的佇列,
  3. 如果沒有遇到以上情況,我們就可以先把可以走的格子在我們的 map(在實作我們的地圖資料的時候宣告的一個陣列)中記錄一個狀態,這里我們可以使用 2, 代表這個格子我們已經走過了,這里我們加入一個標記,
  4. 回圈我們佇列中可以走的格子,這里的主要目標就是把所有記錄了可以走的格子都找到它的 ,并且把這些可走的格子都入佇列,然后進入下一個回圈時就會去找這些新入佇列的格子可以走到哪里,然后把后面找到的格子再次入佇列,以此類推,
  5. 這個回圈有一個截止條件,那就是如果我們在回圈每個格子的時候,找到了終點的格子,我們就可以直接回傳 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 來方便除錯和展示

上一個代碼中,其實已經實作了尋路演算法的主體部分了,但是里面還是有一些問題的:

  1. 演算法雖然最侄訓傳了我們終點的位置,并且回傳了 true,看起來是符合了我們的預期的,但是它的正確性我們不太好保證,所以我們希望有一個可視化的效果來觀察這個尋路演算法的程序
  2. 我們是找到一條路徑,這個最終的路徑我們還沒有把它找出來

這些問題我們下來幾個步驟中會一步一步來解決,

如何有看我們的 《TicTacToe 三子棋》的編程與演算法練習的文章的話,我們里面有講到使用 asyncawait ,來讓函式中間可插入一些異步的操作,

這部分的代碼我們做了一些代碼的改造:

  1. path() 函式改為 async 函式
  2. insert() 函式改為 async 函式
  3. 因為 insert() 編程了異步函式,所以我們 while() 回圈中的 insert 呼叫都需要在前面加入 await
  4. 同時我們需要加入一個等待函式sleep(),它必須回傳一個 promise
  5. 在我們入佇列之后,在改變當前格子狀態為 2 之前,我們會對 DOM 元素中的格子的背景顏色進行改變,這樣我們就可以看到尋路的程序
  6. 因為我們需要看到這個程序,所以每一次入佇列的時候我們需要給一個 1 秒的等待時間,這個就是使用 asyncawait 的好處,在加入這個背景顏色之前,我們就可以加入一個 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 一下謝謝!

處理路徑問題

上一步我們用一個影片,讓我們特別清晰的去理解整個尋路演算法的程序,但是上一步提到的第二個問題,我們目前是還沒有解決的,所以這里我們就是要找到最終的路徑是什么,我們最終通過尋路演算法之后,我們怎么獲得一個路徑是可以從起點到達終點的呢?

其實處理這個路徑的問題也是非常的簡單的,我們先看看看我們之前講過的尋路的思路:

通過上面這個圖,我們已經都還記得,在我們訪問每一個格子的時候,我們都往外擴展找到可走的格子有哪些,這個程序當中,我們都是知道每一個被擴展到的格子都是由前一個給自擴展過來的,換句話說,每一個格子都是知道我們是從那個給自擴展過來的,

比如說 567 這三個格子都是從 1 這個格子擴展過來的,既然我們知道每個給自上一步的來源,是不是我們可以在每一個給自記錄上一個來源的格子呢?所以在代碼中我們是不是可以在入隊的時候,就記錄上一個給自的 x,y軸呢?

那么我們就產生一個想法,通過這個記錄,我們怎么尋找最終的路徑呢?

我們假設終點是在 8 這個位置,我們過來 8 這個點的時候是從我們的起點一步一步擴展出來的,那我們反過來通過記錄了每一個格子的前驅格子,就可以一步一步收碩訓到起點呢?

那就是說,從 8 開始收,8 是從 2 走過來的,而 2 就是從起點 擴展過來的,最終我們就找到起點了,如果我們在收縮的程序記錄著收縮時候訪問到的格子,最終這些格子就是從起點到終點的整條路徑了!是不是很神奇?!

其實也可以理解為一個 “原路回傳” 的效果!

先不要雞凍,穩住!我們接下來看看代碼是如何處理的:

  1. 其實基本上我們的代碼沒有太多的改變
  2. 首先就是在 while 回圈當中的 insert() 呼叫的時候添加了上一個坐標的傳參
  3. 這里我們順便也把橫向的可走的格子也加入到佇列中
  4. 這里因為我們需要記錄所有格子的前驅坐標,所以我們需要宣告一個 table 的變數存放這個資料
  5. 在我們進入佇列之前,我們就把當前入佇列的格子的值存為上一個格子的坐標(這個為了我們后面方便收縮是找到整個路徑)
  6. 最后在 while 回圈中,當我們遇到終點的 x 和 y 的時候,我們加入一段 while 回圈
  7. 這個 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 類,這個類有幾個作業:

  1. 這個類可以存盤我們之前 queue 佇列里面的資料
  2. 可以支持傳入排序函式(也就是和我們 array sort 函式一樣的功能,可以傳入一個排序規則函式,也叫 compare 函式)
  3. 在我們通過這個類獲取值的時候給我們資料里面的最小值
  4. 在我們插入資料的時候不需要排序,只是單純的保存
  5. 每次去除資料的時候,可以把輸出的資料在類的資料里面洗掉掉
  6. 這樣我們就可以一直在里面獲取資料,知道沒有資料為止
  7. 最后加入一個可以獲取當前資料的長度(這個在我們的尋路演算法中需要用到)

這里我們用一個非常 “土鱉” 的陣列來實作這個 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 的資料結構之后,我們就可以用來解決我們的尋路問題,讓我們可以找到最佳的路徑了,

加入這個最佳路徑的邏輯,我們需要改到一下幾個點:

  1. 首先改寫我們的存盤資料的 queue,使用我們 Sorted 的排序資料結構
  2. 撰寫一個 distance() 函式來運算任何一個格子與終點格子的直線距離
  3. 把所有入佇列和出佇列的呼叫改為使用 Sorted 類里面的 take 取值 和 give 插入值函式
  4. 其他地方基本就沒有什么改變了

接下來我們來看看代碼是怎么實作的:

// 上一部分的代碼,這里就忽略了...
// 只要在上部分的代碼后面改造這部分即可

/**
     * 尋路方法 (異步)
     * @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
—— 起步于PHP,一入前端深似海,最后愛上了前端,Vue、React使用者,專于Web、移動端開發,特別關注產品和UI設計,專心、專注、專研,與同學們一起終身學習,關注我的微信公眾號《技術銀河》有更多最新知識文章與同學們分享,

轉載請註明出處,本文鏈接:https://www.uj5u.com/qita/204187.html

標籤:其他

上一篇:摸魚嗎?不會我教你啊

下一篇:經典三子棋小游戲(C語言版)

標籤雲
其他(157675) Python(38076) JavaScript(25376) Java(17977) C(15215) 區塊鏈(8255) C#(7972) AI(7469) 爪哇(7425) MySQL(7132) html(6777) 基礎類(6313) sql(6102) 熊猫(6058) PHP(5869) 数组(5741) R(5409) Linux(5327) 反应(5209) 腳本語言(PerlPython)(5129) 非技術區(4971) Android(4554) 数据框(4311) css(4259) 节点.js(4032) C語言(3288) json(3245) 列表(3129) 扑(3119) C++語言(3117) 安卓(2998) 打字稿(2995) VBA(2789) Java相關(2746) 疑難問題(2699) 细绳(2522) 單片機工控(2479) iOS(2429) ASP.NET(2402) MongoDB(2323) 麻木的(2285) 正则表达式(2254) 字典(2211) 循环(2198) 迅速(2185) 擅长(2169) 镖(2155) 功能(1967) .NET技术(1958) Web開發(1951) python-3.x(1918) HtmlCss(1915) 弹簧靴(1913) C++(1909) xml(1889) PostgreSQL(1872) .NETCore(1853) 谷歌表格(1846) Unity3D(1843) for循环(1842)

熱門瀏覽
  • 網閘典型架構簡述

    網閘架構一般分為兩種:三主機的三系統架構網閘和雙主機的2+1架構網閘。 三主機架構分別為內端機、外端機和仲裁機。三機無論從軟體和硬體上均各自獨立。首先從硬體上來看,三機都用各自獨立的主板、記憶體及存盤設備。從軟體上來看,三機有各自獨立的作業系統。這樣能達到完全的三機獨立。對于“2+1”系統,“2”分為 ......

    uj5u.com 2020-09-10 02:00:44 more
  • 如何從xshell上傳檔案到centos linux虛擬機里

    如何從xshell上傳檔案到centos linux虛擬機里及:虛擬機CentOs下執行 yum -y install lrzsz命令,出現錯誤:鏡像無法找到軟體包 前言 一、安裝lrzsz步驟 二、上傳檔案 三、遇到的問題及解決方案 總結 前言 提示:其實很簡單,往虛擬機上安裝一個上傳檔案的工具 ......

    uj5u.com 2020-09-10 02:00:47 more
  • 一、SQLMAP入門

    一、SQLMAP入門 1、判斷是否存在注入 sqlmap.py -u 網址/id=1 id=1不可缺少。當注入點后面的引數大于兩個時。需要加雙引號, sqlmap.py -u "網址/id=1&uid=1" 2、判斷文本中的請求是否存在注入 從文本中加載http請求,SQLMAP可以從一個文本檔案中 ......

    uj5u.com 2020-09-10 02:00:50 more
  • Metasploit 簡單使用教程

    metasploit 簡單使用教程 浩先生, 2020-08-28 16:18:25 分類專欄: kail 網路安全 linux 文章標簽: linux資訊安全 編輯 著作權 metasploit 使用教程 前言 一、Metasploit是什么? 二、準備作業 三、具體步驟 前言 Msfconsole ......

    uj5u.com 2020-09-10 02:00:53 more
  • 游戲逆向之驅動層與用戶層通訊

    驅動層代碼: #pragma once #include <ntifs.h> #define add_code CTL_CODE(FILE_DEVICE_UNKNOWN,0x800,METHOD_BUFFERED,FILE_ANY_ACCESS) /* 更多游戲逆向視頻www.yxfzedu.com ......

    uj5u.com 2020-09-10 02:00:56 more
  • 北斗電力時鐘(北斗授時服務器)讓網路資料更精準

    北斗電力時鐘(北斗授時服務器)讓網路資料更精準 北斗電力時鐘(北斗授時服務器)讓網路資料更精準 京準電子科技官微——ahjzsz 近幾年,資訊技術的得了快速發展,互聯網在逐漸普及,其在人們生活和生產中都得到了廣泛應用,并且取得了不錯的應用效果。計算機網路資訊在電力系統中的應用,一方面使電力系統的運行 ......

    uj5u.com 2020-09-10 02:01:03 more
  • 【CTF】CTFHub 技能樹 彩蛋 writeup

    ?碎碎念 CTFHub:https://www.ctfhub.com/ 筆者入門CTF時時剛開始刷的是bugku的舊平臺,后來才有了CTFHub。 感覺不論是網頁UI設計,還是題目質量,賽事跟蹤,工具軟體都做得很不錯。 而且因為獨到的金幣制度的確讓人有一種想去刷題賺金幣的感覺。 個人還是非常喜歡這個 ......

    uj5u.com 2020-09-10 02:04:05 more
  • 02windows基礎操作

    我學到了一下幾點 Windows系統目錄結構與滲透的作用 常見Windows的服務詳解 Windows埠詳解 常用的Windows注冊表詳解 hacker DOS命令詳解(net user / type /md /rd/ dir /cd /net use copy、批處理 等) 利用dos命令制作 ......

    uj5u.com 2020-09-10 02:04:18 more
  • 03.Linux基礎操作

    我學到了以下幾點 01Linux系統介紹02系統安裝,密碼啊破解03Linux常用命令04LAMP 01LINUX windows: win03 8 12 16 19 配置不繁瑣 Linux:redhat,centos(紅帽社區版),Ubuntu server,suse unix:金融機構,證券,銀 ......

    uj5u.com 2020-09-10 02:04:30 more
  • 05HTML

    01HTML介紹 02頭部標簽講解03基礎標簽講解04表單標簽講解 HTML前段語言 js1.了解代碼2.根據代碼 懂得挖掘漏洞 (POST注入/XSS漏洞上傳)3.黑帽seo 白帽seo 客戶網站被黑帽植入劫持代碼如何處理4.熟悉html表單 <html><head><title>TDK標題,描述 ......

    uj5u.com 2020-09-10 02:04:36 more
最新发布
  • 2023年最新微信小程式抓包教程

    01 開門見山 隔一個月發一篇文章,不過分。 首先回顧一下《微信系結手機號資料庫被脫庫事件》,我也是第一時間得知了這個訊息,然后跟蹤了整件事情的經過。下面是這起事件的相關截圖以及近日流出的一萬條資料樣本: 個人認為這件事也沒什么,還不如關注一下之前45億快遞資料查詢渠道疑似在近日復活的訊息。 訊息是 ......

    uj5u.com 2023-04-20 08:48:24 more
  • web3 產品介紹:metamask 錢包 使用最多的瀏覽器插件錢包

    Metamask錢包是一種基于區塊鏈技術的數字貨幣錢包,它允許用戶在安全、便捷的環境下管理自己的加密資產。Metamask錢包是以太坊生態系統中最流行的錢包之一,它具有易于使用、安全性高和功能強大等優點。 本文將詳細介紹Metamask錢包的功能和使用方法。 一、 Metamask錢包的功能 數字資 ......

    uj5u.com 2023-04-20 08:47:46 more
  • vulnhub_Earth

    前言 靶機地址->>>vulnhub_Earth 攻擊機ip:192.168.20.121 靶機ip:192.168.20.122 參考文章 https://www.cnblogs.com/Jing-X/archive/2022/04/03/16097695.html https://www.cnb ......

    uj5u.com 2023-04-20 07:46:20 more
  • 從4k到42k,軟體測驗工程師的漲薪史,給我看哭了

    清明節一過,盲猜大家已經無心上班,在數著日子準備過五一,但一想到銀行卡里的余額……瞬間心情就不美麗了。最近,2023年高校畢業生就業調查顯示,本科畢業月平均起薪為5825元。調查一出,便有很多同學表示自己又被平均了。看著這一資料,不免讓人想到前不久中國青年報的一項調查:近六成大學生認為畢業10年內會 ......

    uj5u.com 2023-04-20 07:44:00 more
  • 最新版本 Stable Diffusion 開源 AI 繪畫工具之中文自動提詞篇

    🎈 標簽生成器 由于輸入正向提示詞 prompt 和反向提示詞 negative prompt 都是使用英文,所以對學習母語的我們非常不友好 使用網址:https://tinygeeker.github.io/p/ai-prompt-generator 這個網址是為了讓大家在使用 AI 繪畫的時候 ......

    uj5u.com 2023-04-20 07:43:36 more
  • 漫談前端自動化測驗演進之路及測驗工具分析

    隨著前端技術的不斷發展和應用程式的日益復雜,前端自動化測驗也在不斷演進。隨著 Web 應用程式變得越來越復雜,自動化測驗的需求也越來越高。如今,自動化測驗已經成為 Web 應用程式開發程序中不可或缺的一部分,它們可以幫助開發人員更快地發現和修復錯誤,提高應用程式的性能和可靠性。 ......

    uj5u.com 2023-04-20 07:43:16 more
  • CANN開發實踐:4個DVPP記憶體問題的典型案例解讀

    摘要:由于DVPP媒體資料處理功能對存放輸入、輸出資料的記憶體有更高的要求(例如,記憶體首地址128位元組對齊),因此需呼叫專用的記憶體申請介面,那么本期就分享幾個關于DVPP記憶體問題的典型案例,并給出原因分析及解決方法。 本文分享自華為云社區《FAQ_DVPP記憶體問題案例》,作者:昇騰CANN。 DVPP ......

    uj5u.com 2023-04-20 07:43:03 more
  • msf學習

    msf學習 以kali自帶的msf為例 一、msf核心模塊與功能 msf模塊都放在/usr/share/metasploit-framework/modules目錄下 1、auxiliary 輔助模塊,輔助滲透(埠掃描、登錄密碼爆破、漏洞驗證等) 2、encoders 編碼器模塊,主要包含各種編碼 ......

    uj5u.com 2023-04-20 07:42:59 more
  • Halcon軟體安裝與界面簡介

    1. 下載Halcon17版本到到本地 2. 雙擊安裝包后 3. 步驟如下 1.2 Halcon軟體安裝 界面分為四大塊 1. Halcon的五個助手 1) 影像采集助手:與相機連接,設定相機引數,采集影像 2) 標定助手:九點標定或是其它的標定,生成標定檔案及內參外參,可以將像素單位轉換為長度單位 ......

    uj5u.com 2023-04-20 07:42:17 more
  • 在MacOS下使用Unity3D開發游戲

    第一次發博客,先發一下我的游戲開發環境吧。 去年2月份買了一臺MacBookPro2021 M1pro(以下簡稱mbp),這一年來一直在用mbp開發游戲。我大致分享一下我的開發工具以及使用體驗。 1、Unity 官網鏈接: https://unity.cn/releases 我一般使用的Apple ......

    uj5u.com 2023-04-20 07:40:19 more