A*算法

翻譯自:http://www.redblobgames.com/pathfinding/a-star/introduction.html  感謝作者

在遊戲中我們常喜愛那個尋找一條從一個點到另一個點的路徑。我們不僅嘗試尋找最短路徑,有時候我們還想把路徑消耗也計算到代價中在尋找路徑。在map圖中,穿過水路很明顯要慢一下,所以,如果可以的話,我們想找一條可以避開水路的路徑。下面是一個交互式的圖表。點擊圖中的某個單元格可以在地板,草叢,沙子,水,牆/樹之間切換。移動blob(起始點)以及“X”(終點)去查看最短路徑。


具體操作見:http://www.redblobgames.com/pathfinding/a-star/introduction.html.


怎樣計算這樣一條路徑呢?A* 算法在遊戲開發中最常用的算法。它是圖搜索算法中遵循相同結構的一種算法。這些算法將地圖市委一個數據結構中的圖結構,然後在圖結構中尋找路徑。如果你之前沒有了解過node-and-edge圖(節點-邊緣圖),這裏有介紹的文章。對於這個文章,圖節點將對應於map地圖。廣度優先搜索是最簡單的圖搜索算法,從這裏開始,我們將沿着我們的方式實現A*算法。

這些算法的關鍵思想是 : 跟蹤一個稱爲前驅(frontier)擴展環。啓動動畫去觀察前驅(frontier)是怎麼擴張的:


這個擴張前驅可以被看做一個停在牆邊的輪廓線;這個過程有時候被稱爲“泛洪”:


怎麼實現這個算法呢?重複這些步驟直到前驅隊列(frontier)爲空:

  1. 從前驅隊列中挑一個位置,並且將其移除出隊列。
  2. 標記這個位置爲 “已訪問”,讓我們之後可以知道不再處理這個位置點。
  3. 根據當前操作位置的鄰居座標點 擴展 前驅隊列。任何我們沒有訪問過的鄰居節點都要添加到前驅隊列中。

讓我們仔細看一下這個過程。我們處理過程中訪問過的單元格瓷磚塊都被打上了序號。一步一步觀察擴張過程:


It’s only ten lines of (Python) code:

實現代碼只有十行(Python):


frontier = Queue()
frontier.put(start)
visited = {}
visited[start] = True

while not frontier.empty():
   current = frontier.get()
   for next in graph.neighbors(current):
      if next not in visited:
         frontier.put(next)
         visited[next] = True


這個循環在這頁的圖搜索算法都是必要的,包括A*算法。但是我們怎麼去尋找最短路徑呢?這個循環事實上並不能構造出這樣的路徑;它只能告訴我們怎樣去遍歷圖中的所有節點。那是因爲廣度優先搜索算法可以被用在很多領域,不僅僅是路徑查找,在這個文章中我將展示廣度優先算法在塔防遊戲中的應用,但是它也可以被用在距離圖,程序地圖生成,以及其他很多領域。這裏我們想用它去尋找路徑,所以讓我們來修改這個循環去跟蹤每個已訪問的位置的來源(came from),將“已訪問”改爲“來源”:


frontier = Queue()
frontier.put(start)
came_from = {}
came_from[start] = None

while not frontier.empty():
   current = frontier.get()
   for next in graph.neighbors(current):
      if next not in came_from:
         frontier.put(next)
         came_from[next] = current

現在 “came_from來源” 對於每個位置點來說都是 來自於哪裏。那是足夠的信息了對於去構造整個路徑。鼠標在地圖中的任何位置上移動,然後觀察下面的箭頭們給你逆向路徑引導到起始位置點。



這段代碼去構造路徑是簡單的:跟着箭頭指示走


current = goal
path = [current]
while current != start:
   current = came_from[current]
   path.append(current)
path.reverse()

That’s the simplest pathfinding algorithm. It works not only on grids as shown here but on any sort of graph structure. In a dungeon, graph locations could be rooms and graph edges the doorways between them. In a platformer, graph locations could be locations and graph edges the possible actions such as move left, move right, jump up, jump down. In general, think of the graph as states and actions that change state. I have more written about map representation here. In the rest of the article I’ll continue using examples with grids, and explore why you might use variants of breadth first search.
那是最簡單的路徑查找算法。它不僅可以在展示的表格上工作,也可以在任何已排序的圖結構上應用。在一個地牢(dungeon)中,圖座標可以被包圍或者修整的在門路之間。在一個平臺上,圖座標可以被定位並且圖可以邊緣化出像左移,右移,跳上,跳下這些可能的活動。通常,可以將圖看做是改變狀態後的狀態或者活動。我在這裏已經寫過一些關於圖的特徵。在剩下的篇幅,我將用表格的例子,探索爲什麼你可能使用廣度優先搜索算法的變種算法。

Early exit  早點退出

We’ve found paths from one location to all other locations. Often we don’t need all the paths; we only need a path from one location to one other location. We can stop expanding the frontier as soon as we’ve found our goal. Drag the X around see how the frontier stops expanding as soon as it reaches the X.

我們將找到從一個點到其他位置的所有路徑。事實上我們通常是不需要所有路徑的;我們只需要從一點到另一點的一條路徑就可以了。所以我們可以在查找找到目標節點的時候就可以停止前驅隊列的擴張了。四處拖拽X觀察前驅隊列在找到目標X後就停止擴張的情景。

代碼是簡潔明瞭的:

  1. frontier = Queue()
  2. frontier.put(start)
  3. came_from = {}
  4. came_from[start] = None
  5. while not frontier.empty():
  6.   current = frontier.get()
  7.   if current == goal:
  8.      break          
  9.   for next in graph.neighbors(current):
  10.      if next not in came_from:
  11.         frontier.put(next)
  12.         came_from[next] = current

Movement costs 移動代價

到目前位置,我們都是在假定所有的移動消耗的代價是一樣的。在一些路徑查找場景中在不同類型的移動下小號的代價往往是不同的。例如在城市中,在平原或者沙漠上移動的代價可能只有1點,而在森林或者山上移動卻可能消耗5點。在地圖中,在水上移動消耗可能是在草地上移動消耗代價的10倍。另一個例子是在表格中對角線上移動的代價可能也是比在沿着座標軸方向移動消耗的能量要多一些的。我們想要在尋找路徑的過程中將這些移動消耗也放在考慮的範圍之內。讓我們來比較一下number of steps 和 distance 中從起始位置開始的區別吧:

我們想用Dijkstra算法實現這個目的。它跟廣度優先搜索算法有怎樣的區別呢?我們需要跟蹤移動的消耗,所以我們添加了一個新的變量cost_so_far ,用來跟蹤記錄從起始位置起的總共的移動代價。我們想把移動消耗也考慮進來再評估座標點;轉換常規隊列爲優先隊列。不太明顯的,我們可能訪問一個座標點多次,因爲不同的消耗,所以我們需要修改一點點算法的邏輯。不再是在位置點沒有訪問過就直接添加到前驅隊列中了,現在改爲在到座標點的新的路徑是比之前的路徑更好的情況下才把座標點加入到隊列中。

  1. frontier = PriorityQueue()
  2. frontier.put(start, 0)
  3. came_from = {}
  4. cost_so_far = {}
  5. came_from[start] = None
  6. cost_so_far[start] = 0
  7. while not frontier.empty():
  8.   current = frontier.get()
  9.   if current == goal:
  10.      break
  11.  
  12.   for next in graph.neighbors(current):
  13.      new_cost = cost_so_far[current] + graph.cost(current, next)
  14.      if next not in cost_so_far or new_cost < cost_so_far[next]:
  15.         cost_so_far[next] = new_cost
  16.         priority = new_cost
  17.         frontier.put(next, priority)
  18.         came_from[next] = current

使用優先隊列代替常規的隊列改變了前驅隊列擴張的方式。等高線是看這種結果的一種方式。啓動動畫觀察前驅隊列在森林中擴張要慢些,找到最短路徑是繞着森林而不是直接通過它。

移動消耗不再是1允許我們探索更加有趣的圖,而不僅僅是表格。這裏有個例子,其中移動消耗是基於點跟點之間的距離的來的:

移動消耗也可以被用來去避免或者偏愛某片區域,譬如說靠近敵人或者聯盟區域。

一個實現細節:一個規則的優先級隊列支持插入和移除操作,但是有的一些,像Dijkstra算法,要用到第三種操作,去修改隊列中已經存在的某個元素的優先級。我不需要這個操作,實現筆記頁解釋了爲什麼。

Heuristic search   啓發式搜索

在廣度優先搜索算法和Dijkstra算法中,前驅隊列擴張向所有方向。如果你想要查找一條到所有位置或者許多位置的路徑時,這個算法的擴張方向思想倒是情有可原。然而,一個更常見的案例是去查找一條到一個目標節點的路徑。讓我們使得前驅隊列擴張的方向是朝向目標節點,而不是其他方向。首先,我們定義一個啓發式函數,讓它告訴我們我們距離目標節點的接近程度:

  1. def heuristic(a, b):
  2.   # Manhattan distance on a square grid
  3.   return abs(a.x - b.x) + abs(a.y - b.y)

在Dijkstra算法中我們使用了到起始位置的確切距離作爲進入優先級隊列的比較參數出入隊列。這裏,在貪心搜索算法中,我們將使用到目標節點的評估距離作爲進入優先級隊列的比較參數出入隊列。越接近目標的位置將先被探索到。代碼中同樣使用了優先級隊列,但不是cost_so_far變量了:

  1. frontier = PriorityQueue()
  2. frontier.put(start, 0)
  3. came_from = {}
  4. came_from[start] = None
  5. while not frontier.empty():
  6.   current = frontier.get()
  7.   if current == goal:
  8.      break
  9.  
  10.   for next in graph.neighbors(current):
  11.      if next not in came_from:
  12.         priority = heuristic(goal, next)
  13.         frontier.put(next, priority)
  14.         came_from[next] = current

讓我們看一下它工作的有多漂亮哇:

Wow!很神奇,對不對?但是在更復雜的圖中會怎樣呢?

那些路徑不是最短的了。所以說當又不多的障礙時這個算法跑起來挺快的,但是路徑卻不是最好的了。我們可以改進它麼?答案當然是Yes咯。

The A* algorithm  A*算法

Dijkstra算法在查找最短路徑方面確實工作的很棒,但是它會在沒有希望的方向上浪費時間。貪心搜索算法是都在看起來有希望的方向上了,但是它卻有可能找不到最短路徑。A*算法用了他們兩個共同的優點,起始位置到當前位置的距離以及到目標節點的評估距離值。

讓我們看一下Dijkstra算法以及貪心搜索算法的等高線圖來感受一下吧。在Dijkstra算法中,我們從起始點爲中心開始朝着目標前進。我們不確定目標在哪裏,所以不得不去檢查所有方向上的所有節點。在貪心搜索算法中,我們從終點爲中心出發尋找目標。我們知道目標在哪裏,所以只要朝着目標移動,就是好的。

A*結合兩者的優點。等高線不再是顯示距離起始點或者終點的距離了,在A*算法中等高線表現出了路徑的長度。內部區域擁有着最短路徑。A*算法從內部區域開始探索,只有在它找不到路徑的情況下才會向外擴張。嘗試拖拽一些牆壁,然後觀察A*怎樣跳出最內部區域去查找路徑的。

代碼跟Dijkstra算法是非常相似的:

  1. frontier = PriorityQueue()
  2. frontier.put(start, 0)
  3. came_from = {}
  4. cost_so_far = {}
  5. came_from[start] = None
  6. cost_so_far[start] = 0
  7. while not frontier.empty():
  8. current = frontier.get()
  9. if current == goal:
  10. break
  11. for next in graph.neighbors(current):
  12. new_cost = cost_so_far[current] + graph.cost(current, next)
  13. if next not in cost_so_far or new_cost < cost_so_far[next]:
  14. cost_so_far[next] = new_cost
  15. priority = new_cost + heuristic(goal, next)
  16. frontier.put(next, priority)
  17. came_from[next] = current

比較算法們:

你可以看到A*算法嘗試去儘可能的保持在相同的評估路徑長度上。爲什麼它會比Dijkstra算法快呢?兩個算法探索的都是相同的座標。然後,啓發式函數讓我們以不同的順序訪問位置,這樣會是的我們會更早的遇到目標節點。

蒽。。。那就是它。那就是我們說的A*算法。

在一個遊戲地圖中,我們應該使用哪一個搜索路徑算法呢?

  • 如果你想要從或者到所有的位置,就用廣度優先搜索或者Dijkstra算法吧。如果消耗代價都是相同的,那就選擇用廣度優先算法;如果移動消耗是變化的那就用Dijkstra算法吧。

  • 如果你想要找到一個目標位置的路徑,就用貪心搜索算法或者A*。大多數情況下就直接用A*吧。當你想要嘗試使用貪心搜索算法時,考慮下用帶有“允許範圍內的啓發”的A*算法。

最佳路徑是什麼鬼?廣度優先搜索算法跟Dijkstra算法保證在給定的輸入圖中找到一條最短路徑。貪心搜索算法不是。如果啓發式函數不比真實的距離大的情況下,A*算法可以保證找到一條最短路徑。當啓發式函數足夠小,A*算法就變成Dijkstra算法了。當啓發式函數值變大時,A*算法就變成了貪心搜索算法了。

性能怎麼樣呢?最好是消除在你的圖中的非必須的位置節點。如果用表格的話,看這裏。消除圖的大小可以幫助所有圖搜索算法。那之後,用你可以用的最簡單的算法;簡單的隊列運行起來特別快喲。貪心搜索算法一般比Dijkstra算法要運行的快,但是不產生最優路徑。A*算法是大多數路徑搜索算法中最好的選擇。

用在不是圖結構的地方效果如何?我使用圖是因爲我認爲在圖上運行A*算法是最容易理解的。然而,這些圖搜索算法也可以用於任何排序的圖中,而不僅僅是遊戲地圖中,我曾經嘗試呈現這個算法的代碼在2d表格中。地圖中的移動消耗在圖邊緣上表現爲很明顯的權重值。啓發式函數對於帶權圖是不需要翻譯的容易理解;你不得不設計一個啓發式函數對於每一種圖。對於平面圖,距離是個很好的選擇,所以那也是我曾經用到時的選擇。

我有大量的文章在這裏關於路徑查找方面的。記得 圖搜索僅僅是一部分你需要的。A*並不能處理像協作移動,移動過障礙物,地圖改變,評估危險區域,編隊,轉彎,對象尺寸,動畫,路徑平滑,或者大量其他的主題。


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章