啟發函數 (Heuristic Function)
盲目搜索會浪費很多時間和空間, 所以我們在路徑搜索時, 會首先選擇最有希望的節點, 這種搜索稱之為 "啟發式搜索 (Heuristic Search)"
如何來界定"最有希望"? 我們需要通過 啟發函數 (Heuristic Function) 計算得到.
對於網格地圖來說, 如果只能四方向(上下左右)移動, 曼哈頓距離(Manhattan distance) 是最合適的啟發函數.
function Manhattan(node) = dx = abs(node.x - goal.x) dy = abs(node.y - goal.y) // 在最簡單的情況下, D 可以取 1, 返回值即 dx + dy return D * (dx + dy)
如果網格地圖可以八方向(包括斜對角)移動, 使用 切比雪夫距離(Chebyshev distance) 作為啟發函數比較合適.
function Chebyshev(node) = dx = abs(node.x - goal.x) dy = abs(node.y - goal.y) // max(dx, dy) 保證了斜對角的距離計算 return D * max(dx, dy)
如果地圖中允許任意方向移動, 不太建議使用網格 (Grid) 來描述地圖, 可以考慮使用 路點 (Way Points) 或者 導航網格 (Navigation Meshes) , 此時使用 歐式距離(Euclidean distance) 來作為啟發函數比較合適.
function heuristic(node) = dx = abs(node.x - goal.x) dy = abs(node.y - goal.y) // 在最簡單的情況下, D 可以取 1, 返回值即 sqrt(dx * dx + dy * dy) return D * sqrt(dx * dx + dy * dy)
歐式距離因為有一個 sqrt() 運算, 計算開銷會增大, 所以可以使用 Octile 距離 來優化(不知道怎么翻譯), Octile 的核心思想就是假定只能做 45 度角轉彎.
function heuristic(node) =
dx = abs(node.x - goal.x) dy = abs(node.y - goal.y) k = sqrt(2) - 1 return max(dx, dy) + k * min(dx, dy)
下圖是一個啟發函數的簡單示意
Dijkstra
Dijkstra 算法 是由計算機大牛 Dijkstra 在1956年提出來的, 它是一個"精確"的尋路算法, 能保證發現節點之間的最短路徑, 本質是一個 廣度優先搜索 .
- g(n): 從起始點到當前點 n 的開銷, 在網格地圖中一般就是步長.
- 將起始點 S 加入 open-list.
- 從當前的 open-list 中選取 g(n) 最優(最小)的節點 n, 加入 closed-list
- 遍歷 n 的后繼節點 ns
- 如果 ns 是新節點, 加入 open-list
- 如果 ns 已經在 open-list 中, 並且當前 h(ns) 更優, 則更新 ns 並修改 parent
- 迭代, 直到找打目標節點 D, 回溯得到路徑
Dijkstra 算法的效率並不高, 時間復雜度為 O(n^2), 但是它保證了尋路結果是最短距離.
Best-First-Search
Best-first Search 最佳優先搜索, 簡稱為 BFS.
BFS 根據啟發式函數的推斷, 每次迭代最優的節點, 直到尋找到目標節點. 一個簡單的算法流程如下:
- h(n) 代表從當前點 n 到目標點 S 的估算開銷, 即啟發函數.
- 將起始點 S 加入 open-list.
- 從當前的 open-list 中選取 h(n) 最優(最小)的節點 n, 加入 closed-list
- 遍歷 n 的后繼節點 ns
- 如果 ns 是新節點, 加入 open-list
- 如果 ns 已經在 open-list 中, 並且當前 h(ns) 更優, 則更新 ns 並修改 parent
- 迭代, 直到找打目標節點 D, 回溯得到路徑
如果不使用 open-list, 直接每次都尋找 n 的后繼節點中最優的節點, BFS 則退化為貪心最佳優先搜索 (Greedy BFS).
不管是 BFS 還是 Greedy-BFS, 本質上都還是尋找 局部最優 , 雖然效率會比 Dijkstra 高很多, 但最終得到的解並不一定是全局最優(最短距離), 在地圖中有障礙物時尤為明顯.
A-Star
A-Star 俗稱 A* , 是應用最廣的尋路算法, 在很多場景下都適用. A* 兼顧了 Dijkstra 的准確度和 BFS 的效率.
- f(n) = g(n) + h(n) , g(n) 和 h(n) 的定義同上
- 當 g(n) 退化為 0, 只計算 h(n) 時, A* 即退化為 BFS.
- 當 h(n) 退化為 0, 只計算 g(n) 時, A* 即退化為 Dijkstra 算法.
- 將起始點 S 加入 open-list.
- 從當前的 open-list 中選取 f(n) 最優(最小)的節點 n, 加入 close-list
- 遍歷 n 的后繼節點 ns
- 如果 ns 是新節點, 加入 open-list
- 如果 ns 已經在 open-list 中, 並且當前 f(ns) 更優, 則更新 ns 並修改 parent
- 迭代, 直到找打目標節點 D.
A* 算法在從起始點到目標點的過程中, 嘗試平衡 g(n) 和 h(n), 目的是找到最小(最優)的 f(n) .
A-Star 算法衍生
如果在每一步迭代擴展中, 都去除掉質量比較差的節點, 即選取 有限數量的后繼節點 , 這樣減少了空間開銷, 並提高了時間效率. 但是缺點可能會丟棄潛在的最佳方案. 這就是 集束搜索(Beam Search) , 本質上還是局部最優的貪心算法.
當距離起點越近, 快速前行更重要; 當距離目標點越近, 到達目標更重要. 基於這個原則, 可以引入權重因子, A* 可以演進為選取 f(n) = g(n) + w(n) * h(n) 最優的點, w(n) 表示在點 n 的權重. w(n) 隨着當距離目標點越近而減小. 這是 動態加權 A* (Dynamic Weighting A*) .
當從出發點和目標點同時開始做雙向搜索, 可以利用並行計算能力, 更快的獲得計算結果, 這是 雙向搜索 (Bidirectional Search) . 此時, 向前搜索 f(n) = g(start, n) + h(n, goal), 向后搜索 f(m) = g(m, goal) + h(start, m), 所以雙向搜索最終可以歸結為選取 g(start, n) + h(m, n) + g(m, goal) 最優的一對點(m, n).
A* 在靜態地圖中表現很棒, 但是在動態環境中(例如隨機的障礙物), 卻不太合適, 一個基於 A* 算法的 D* 算法(D-Star, Dynamic A*) 能很好的適應這樣的動態環境. D* 算法中, 沒有 g(n), f(n) 退化為 h(n), 但是每一步 h(n) 的計算有所不同: h(n) = h(n-1) + h(n-1, n) , c(n-1, n) 表示節點 n-1 到節點 n 的開銷.
示例代碼
下面是來自 PathFinding.js 的一份 JS 示例代碼:
// A* 尋路
AStarFinder.prototype.findPath = function(startX, startY, endX, endY, grid) { // heap 做容器(有序) var openList = new Heap(function(nodeA, nodeB) { return nodeA.f - nodeB.f; }), startNode = grid.getNodeAt(startX, startY), endNode = grid.getNodeAt(endX, endY), heuristic = this.heuristic, diagonalMovement = this.diagonalMovement, weight = this.weight, abs = Math.abs, SQRT2 = Math.SQRT2, node, neighbors, neighbor, i, l, x, y, ng; // 分別代表 g(n) 和 f(n) startNode.g = 0; startNode.f = 0; // 從起始點開始 openList.push(startNode); startNode.opened = true; // while the open list is not empty while (!openList.empty()) { // 找到當前隊列中最小 f(n) node = openList.pop(); // closed 標簽表明已經計算過 node.closed = true; // 結束, 並回溯最佳路線 if (node === endNode) { return Util.backtrace(endNode); } // 鄰居節點(四方向或者八方向有區別的) neighbors = grid.getNeighbors(node, diagonalMovement); for (i = 0, l = neighbors.length; i < l; ++i) { neighbor = neighbors[i]; // 已經結算過的就忽略了 if (neighbor.closed) { continue; } x = neighbor.x; y = neighbor.y; // g(n), 實際距離, 實際上是 Euclidean distance ng = node.g + ((x - node.x === 0 || y - node.y === 0) ? 1 : SQRT2); // 兩種情況需要計算: // 1. 這是一個新的節點 // 2. 這個節點當前計算的 g(n) 更優 if (!neighbor.opened || ng < neighbor.g) { neighbor.g = ng; // h = 權重 * 啟發函數的計算結果 neighbor.h = neighbor.h || weight * heuristic(abs(x - endX), abs(y - endY)); neighbor.f = neighbor.g + neighbor.h; // 到父節點的鏈接, 方便結果回溯 neighbor.parent = node; // 更新到結果集 if (!neighbor.opened) { openList.push(neighbor); neighbor.opened = true; } else { // the neighbor can be reached with smaller cost. // Since its f value has been updated, we have to // update its position in the open list openList.updateItem(neighbor); } } } // end for each neighbor } // end while not open list empty // 沒找大, 失敗 return []; }; // Dijkstra 弱化 A* 的 h(n) = 0 function DijkstraFinder(opt) { AStarFinder.call(this, opt); this.heuristic = function(dx, dy) { return 0; }; } DijkstraFinder.prototype = new AStarFinder(); DijkstraFinder.prototype.constructor = DijkstraFinder; // BFS 強化 A* 的 h(n), 相當於弱化 g(n) function BestFirstFinder(opt) { AStarFinder.call(this, opt); var orig = this.heuristic; this.heuristic = function(dx, dy) { return orig(dx, dy) * 1000000; }; } BestFirstFinder.prototype = new AStarFinder(); BestFirstFinder.prototype.constructor = BestFirstFinder;
無障礙尋路對比
默認使用曼哈頓距離來做啟發函數.
無障礙 BFS
無障礙 Dijkstra
無障礙 A*
典型障礙尋路對比
默認使用曼哈頓距離來做啟發函數.
有障礙 BFS
有障礙 Dijkstra
有障礙 A*
啟發函數尋路對比
以 A* 算法為例, 除了 曼哈頓距離 之外, 其他的啟發函數會增加一些計算開銷.
A* Manhattan Heuristic
A* Chebyshev Heuristic
A* Euclidean Heuristic
A* Octile Heuristic
參考文章與代碼