cv.cm

編碼 · 演算法

A* 搜尋演算法教學:原理、程式碼與時間複雜度

閱讀約 6 分鐘

簡答

A* 搜尋結合已付出的成本 g(n) 與剩餘成本的估計 h(n),找出從起點到單一目標節點的最短路徑。它總是展開 f(n) = g(n) + h(n) 最小的節點。只要啟發函式不會高估真實的剩餘成本,A* 就保證回傳最佳路徑,而好的啟發函式能讓它探索的節點遠少於 Dijkstra 演算法。

A* 解決什麼問題

Dijkstra 演算法會朝各個方向以距離遞增的一圈圈範圍展開節點,連遠離目標的方向也不例外。當你知道目標在哪裡時,這些都是白費的工夫。A* 加入了啟發函式,也就是對剩餘距離的估計,讓搜尋被拉向目標。它是遊戲、機器人和地圖中尋路的標準演算法。

A* 由 Peter Hart、Nils Nilsson 和 Bertram Raphael 於 1968 年在「A Formal Basis for the Heuristic Determination of Minimum Cost Paths」中提出。

用白話說明原理

符號意義
g(n)從起點到 n 的已知最佳路徑成本。
h(n)從 n 到目標的成本的啟發式估計。
f(n) = g(n) + h(n)經過 n 的最便宜完整路徑的估計成本。開放集合依 f 排序。
  1. 將起點放入優先佇列(開放集合),g = 0,f = h(start)。
  2. 彈出 f 最小的節點。如果它是目標,就重建路徑並停止。
  3. 對每個鄰居,計算暫定的 g = g(current) + 邊成本。如果比鄰居目前已知的 g 更好,就記錄下來、設定父節點,並以 f = g + h 把鄰居推入佇列。
  4. 如果開放集合變空,就代表沒有路徑。

若所有節點的 h(n) = 0,A* 就完全等同於 Dijkstra 演算法。若啟發函式完美,它會沿著最佳路徑直線前進。

選擇啟發函式

如果啟發函式不會高估真實的剩餘成本,它就是可容許的(admissible)。如果對每條邊 n → m 都滿足 h(n) <= cost(n, m) + h(m),它就是一致的(consistent,或稱單調)。一致的啟發函式一定也是可容許的,而且每個節點只需展開一次。

移動方式啟發函式公式
網格上 4 個方向,成本為 1曼哈頓距離abs(dx) + abs(dy)
8 個方向,斜向成本為 1切比雪夫距離max(abs(dx), abs(dy))
8 個方向,斜向成本為 √2八分位距離(octile distance)max(dx, dy) + (sqrt(2) - 1) * min(dx, dy),其中 dx、dy 取絕對值
任意方向,直線移動歐幾里得距離sqrt(dx * dx + dy * dy)

網格上的 Python 實作

下面的網格有 5 列 7 欄;# 代表第 5 欄的牆。格子以 (row, column) 表示。可以向上、下、左、右移動,成本為 1,所以曼哈頓距離是可容許且一致的。

import heapq
def a_star(grid, start, goal):
    rows, cols = len(grid), len(grid[0])
    h = lambda p: abs(p[0] - goal[0]) + abs(p[1] - goal[1])
    g = {start: 0}
    parent = {start: None}
    open_heap = [(h(start), start)]
    while open_heap:
        f, cur = heapq.heappop(open_heap)
        if cur == goal:
            path = []
            while cur is not None:
                path.append(cur)
                cur = parent[cur]
            return path[::-1]
        if f > g[cur] + h(cur):
            continue                      # outdated entry
        r, c = cur
        for nb in ((r + 1, c), (r - 1, c), (r, c + 1), (r, c - 1)):
            if 0 <= nb[0] < rows and 0 <= nb[1] < cols and grid[nb[0]][nb[1]] != "#":
                ng = g[cur] + 1
                if ng < g.get(nb, float("inf")):
                    g[nb] = ng
                    parent[nb] = cur
                    heapq.heappush(open_heap, (ng + h(nb), nb))
    return None
grid = [".......",
        ".....#.",
        ".....#.",
        ".....#.",
        "......."]
path = a_star(grid, (2, 3), (2, 6))
print(len(path) - 1, path)
# 7 [(2, 3), (2, 4), (1, 4), (0, 4), (0, 5), (0, 6), (1, 6), (2, 6)]

逐步範例

在上面的網格中,起點 (2, 3) 沿直線到目標 (2, 6) 只有 3 步,所以第一個 f 是 0 + 3 = 3。牆擋住了這條直線,真正的最短路徑有 7 步。A* 分階段發現這件事:它先展開 f 最小的所有格子,用完之後再展開下一個最小的 f。

f 值展開的格子(依序)發生了什麼
3(2, 3), (2, 4)直接朝目標前進,直到遇到牆。
5(1, 3), (1, 4), (2, 2), (3, 3), (3, 4)往旁邊或後退一步要多付 2;仍被擋住。
7(0, 3), (0, 4), (0, 5), (0, 6), (1, 2), (1, 6), (2, 1), (2, 6)繞過牆的上方;目標在 f = 7 時被彈出。

A* 在 32 個可通行格子中展開了 15 個。Dijkstra 演算法(即 h = 0 的 A*)則會展開全部 32 個,因為每個可通行格子離起點都不超過 7 步。在大型開闊的地圖上,節省的幅度會大得多。

時間與空間複雜度

項目成本原因
最差時間O((V + E) log V)與 Dijkstra 相同:像 h = 0 這種無用的啟發函式提供不了任何引導。
一般時間遠少於 Dijkstra完全取決於啟發函式的品質;完美的 h 只會展開路徑上的節點。
空間O(V)開放堆積、g 值與父節點。在超大地圖上,記憶體往往才是真正的限制。

節省記憶體的變化版本包括 IDA*(迭代加深 A*),它只保留目前的路徑,代價是會重複拜訪節點。

何時使用 A* 與實際應用

  • 當只有一個目標,而且有便宜又可容許的剩餘成本估計(例如地圖上的幾何距離)時,請使用 A*。
  • 需要到許多或所有節點的距離,或找不到有用的啟發函式時,請用 Dijkstra。無權重圖且啟發函式幫助不大時,請用 BFS。
  • 電玩遊戲:在格狀地圖和導航網格(navigation mesh)上移動單位。
  • 機器人與路線規劃,常結合預先計算的地標或階層結構。
  • 解謎:8 數字謎題與 15 數字謎題,以方塊的曼哈頓距離作為啟發函式。

常見問題

A* 一定是最佳的嗎?

當啟發函式是可容許的(也就是不會高估剩餘成本)時,A* 會回傳最佳路徑。若啟發函式高估,它可能回傳較長的路徑。

A* 和 Dijkstra 演算法有什麼差別?

Dijkstra 依已付出的成本 g(n) 排序節點。A* 則依 g(n) + h(n) 排序,其中 h 估計到目標的剩餘成本。h = 0 的 A* 就是 Dijkstra。

在網格上該用哪種啟發函式?

4 個方向的移動用曼哈頓距離;8 個方向的移動依斜向成本用切比雪夫或八分位距離;可自由移動則用歐幾里得距離。

什麼是貪婪最佳優先搜尋?

它只依 h(n) 排序節點,忽略已付出的成本。通常很快,但不保證找到最短路徑。

全部編碼教學