1. 程式人生 > >利用Red Blob遊戲介紹A*算法

利用Red Blob遊戲介紹A*算法

函數 map dijkstra wiki ear star 計算 ood 工作

轉自:http://gad.qq.com/program/translateview/7194337

在遊戲中,我們經常想要找到從一個位置到另一個位置的路徑。我們不只是想要找到最短距離,同時也要考慮旅行時間。如下圖中從星星(起點)到叉號(終點)的最短路徑。

技術分享

為了找到這樣的路徑,我們可以使用一種圖搜索算法,它需要將地圖表示為一張圖。A *算法是圖形搜索的熱門選擇。寬度優先搜索是圖形搜索算法中最簡單的一種,所以讓我們從這個算法開始,慢慢擴展到A*算法。

地圖的表示

研究算法時,首先要了解數據。輸入是什麽?輸出是什麽?

輸入:圖形搜索算法(包括A *)都是以“圖形”為輸入。一個圖就是一組的位置(“點”)和他們之間的連接關系(“邊”)。下面就是A*算法的輸入:

技術分享

A*算法不會在意其他任意的東西,它只能理解這樣的圖。它不會知道某個東西是在室內還是戶外,它是一個房間還是一個門,或者它的面積有多大。它只

能看懂圖中的點和線。它也不會知道上一幅圖和下一幅圖的區別。

技術分享

技術分享

輸出: A *找到的路徑是由圖中節點和邊組成(如上圖)。邊是抽象的數學概念。A *能夠告訴你從一個位置移動到另一個位置的路徑,但不會告訴你怎麽移動。註意:它不知道任何關於房間或門的信息,它只知道圖形中的點和線。你必須自己決定A *返回的圖形路線是從Tile移動到Tile,還是以直線行走或是打開門,或者沿著彎曲的路徑遊泳或跑步。

權衡:對於任意給定的遊戲地圖,都有許多不同的方法來制作尋路圖給A *。上述地圖大多數使用門做為點,如果我們把門道作為邊呢?如果我們使用尋路網格呢?

技術分享

以門道為邊

技術分享

尋路網格

尋路圖並不是必須和你使用的遊戲地圖相同。網格遊戲地圖可以使用非網格尋路圖,反之亦然。圖中的點越少A*運行速度就越快。通常網格在使用時更容易,但會產生大量節點。本文主要講解A*算法,並不包括圖形設計; 有關圖形的更多信息,請參閱我的其他文章。文章剩余部分的講解中,我將使用網格,因為它更容易將概念可視化。

算法

在圖上尋找路徑有很多的算法。我要介紹下面這些:

技術分享

度優先搜索在各個方向上都是相同的。這是一個非常有用的算法,不僅適用於常規路徑查找,還可用於過程圖生成,流域尋路,距離圖和其他類型的地圖分析。

技術分享

Dijkstras算法(也稱為統一成本搜索-UniformCost Search)我們會優先考慮對哪些路徑進行探索,而不是平等地探索所有可能的路徑,它有利於尋找花費代價更小的路徑。我們可以分配較低的代價來鼓勵在道路上移動、更高的代價來避免森林、更高的代價阻止靠近敵人等等。當圖中移動代價是變化時,我們使用這種方式而不是寬度優先搜索。

技術分享

A *是Dijkstra’s算法的變形,為單一目的地做了優化。Dijkstra’s的算法可以找到通往任意位置的路徑; A *是為了找到通往一個位置的路徑。它優先考慮似乎更接近目標的路徑。

我將從最簡單的“寬度優先搜索”開始講解,並一次添加一個功能逐漸將其轉換為A *

寬度優先搜索

所有這些算法的關鍵思想是我們持續跟蹤一個稱為frontier的擴展環。在網格上,這個過程有時被稱為“flood fill”,但這個的技術同樣適用於非網格圖。點擊開始,看看frontier是如何擴展:(點擊查看原文動圖)下面只是一個截圖

技術分享

這個過程我們如何實現呢?重復這些步驟直到frontier為空:

1、從frontier選擇並移除一個位置。

2、將該位置標記為已訪問,以便我們知道不用再處理它。

3、通過查看其鄰居來擴展 frontier。將所有的還沒有訪問過的的鄰居,加入到frontier上。

我們來看看整個過程。Tile會按照我們訪問的順序進行編號。

逐步瀏覽擴展過程:(點擊查看原文動圖)下面只是截圖

技術分享

它只有十行(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 *算法而言。但是我們如何找到最短的路徑?事實上這個循環並不能構造出路,它只是告訴了我們如何訪問地圖上的所有事物。這是因為寬度優先搜索的作用不僅僅是用來尋找路徑,我在在另一篇文章中介紹了如何在塔防遊戲中使用這個算法,但它也可以用於地圖測距,程序地圖生成和許多其他事情。在這裏我們使用它來尋找路徑,所以我們對循環進行修改使之能夠從我們訪問過的每個位置追蹤到我們的出發點,並重命名visited為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.append(start) # optional
path.reverse() # optional

這是最簡單的尋路算法。正如上面的展示,它不僅可以在網格上工作,而是對任何形式的圖形結構都有效。在地牢中,圖中的點可以是房間,圖中的邊將各個門口連接起來。在平臺中,圖中的點可以是位置,圖中的邊是可能的動作,例如向左移動,向右移動,向上跳,向下跳。總的來說,將圖視為狀態和改變狀態的動作。我在其他文章中寫了更多的代表性的地圖。在本文的其余部分,我將繼續使用網格圖形作為例子,並探索為什麽你可能需要使用寬度優先搜索的變體。

提前退出

我們已經能夠找到從一個位置到所有其他位置的路徑。但是通常我們並不需要所有的路徑; 我們只需從一個位置到另外一個位置的一條路徑。一旦我們找到目標,我們就可以停止擴大Frontier。在下面的圖中拖動X,看看在到達X時Frontier如何停止擴展。(點擊鏈接進行操作)

技術分享

技術分享

代碼很簡單:

frontier = Queue()
frontier.put(start )
came_from = {}came_from[start] = None
while not frontier.empty():
   current = frontier.get()
   if current == goal: 
      break          
   for next in graph.neighbors(current):
      if next not in came_from:
         frontier.put(next)
         came_from[next] = current

移動成本

目前為止,我們已經找到了基於相同的“成本”的路。在某些尋路方案中,不同的移動方式可能會有不同的成本。例如在文明中,在平原或沙漠上移動可能花費1個移動點,但在森林或丘陵中移動可能花費5個移動點。在本文頂部的地圖上,涉水前進花費的成本是穿越草地的10倍。另一個例子是網格上的對角線運動,它的花費的不只是軸向運動。我們希望尋路算法考慮這些成本。我們來比較從出發點到目的地的步數與它們之間的距離

技術分享

在這裏,我們會使用Dijkstra’s算法(也稱為統一成本搜索)。它與寬度優先搜索的不同是什麽呢?Dijkstra’s算法中我們需要跟蹤運動成本,所以讓我們添加一個新變量cost_so_far來跟蹤從起始位置開始的總運動成本。我們在決定如何評估一個位置時要將運動成本納入考慮,我們把隊列變成一個優先隊列。隱含的是我們可能會多次訪問同一個地點但基於不同成本,所以我們需要輕微的改變一下算法邏輯。如果一個位置從未被訪問過,就把它加入Frontier,如果到達該位置的新路徑優於前面的最佳路徑,更新它在Frontier中的值。

frontier = PriorityQueue()
frontier.put(start, 0)
came_from = {}
cost_so_far = {}
came_from[start] = None
cost_so_far[start] = 0
while not frontier.empty():
   current = frontier.get()
   if current == goal:
      break
   for next in graph.neighbors(current):
      new_cost = cost_so_far[current] + graph.cost(current, next)
      if next not in cost_so_far or new_cost < cost_so_far[next]:
         cost_so_far[next] = new_cost
         priority = new_cost
         frontier.put(next, priority)
         came_from[next] = current

這裏我們使用優先級隊列替代普通隊列來改變Frontier擴展的方式。輪廓線會很好的表明這一點。通過動畫就能看出邊界在通過森林時會擴展的很慢,將會找到一條圍繞森林的最短路徑,而不是穿過它:(點擊查看原文動畫)

技術分享

超過1的運動成本使我們能夠探索更有趣的圖形,而不僅僅是網格。在本文開始介紹的地圖上,運動成本用是基於房間到房間的距離。運動成本也可以用來避免或偏好鄰近敵人或貼近盟友的區域。

實現細節:常規優先級隊列支持插入和刪除操作,但Dijkstra’s算法的一些演示文稿還使用第三個操作來修改已經在優先級隊列中的元素的優先級。我沒有用該操作,原因在實現說明頁面有講述。

啟發式搜索

利用寬度優先搜索和Dijkstra’s算法,邊界可以在所有的方向擴展。如果你嘗試找到到達所有地點或許多地點的路徑,這會是一個合理的選擇。然而,常見的情況是只需要找到到達某一個位置的路徑。這需要讓邊界的擴展朝著目標的方向,而不是向四周擴展。首先,我們將定義一個 heuristic函數,來明確我們與目標之間的距離:

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

在Dijkstra’s算法中,我們使用了距離起點的實際距離的優先級隊列排序。相反,在Greedy BestFirst Search算法中,我們將使用到目標的評估距離作為優先級隊列的排序依據。最接近目標的位置會最先被探索。下面的代碼使用了寬度優先搜索中的優先級隊列,但但沒有使用Dijkstra算法中的cost_so_far方法:

frontier = PriorityQueue()
frontier.put(start, 0)
came_from = {}
came_from[start] = None
while not frontier.empty():
   current = frontier.get()
   if current == goal:
      break 
   for next in graph.neighbors(current):
      if next not in came_from:
         priority = heuristic(goal, next)
         frontier.put(next, priority)
         came_from[next] = current

讓我們看看它的效果如何:

(點擊查看動畫,下面是截圖)

技術分享

哇!!非常神奇,對吧?如果在更為復雜的地圖上會發生什麽?

(點擊查看動畫,下面是截圖)

技術分享

那些路徑並不是最短的。所以當障礙物較少時,這個算法運行得很快快,但得到的路徑並不是很好。我們可以解決這個問題嗎?當然可以。

A *算法

Dijkstra’s算法可以很好的找到最短路徑,但是會在沒有價值的方向上花費時間去探索。GreedyBest First Search 算法只在正確的方向上探索,但它可能找不到最短的路徑。A *算法中同時使用了從起點的實際距離和到目標的估計距離。

代碼與Dijkstra’s的算法非常相似:

frontier = PriorityQueue()
frontier.put(start, 0)
came_from = {}
cost_so_far = {}
came_from[start] = None
cost_so_far[start] = 0
while not frontier.empty():
   current = frontier.get()
   if current == goal:
      break
   for next in graph.neighbors(current):
      new_cost = cost_so_far[current] + graph.cost(current, next)
      if next not in cost_so_far or new_cost < cost_so_far[next]:
         cost_so_far[next] = new_cost
         priority = new_cost + heuristic(goal, next)
         frontier.put(next, priority)
         came_from[next] = current

算法比較:Dijkstra’s算法計算距離起點的距離。Greedy Best-First Search估計到目標點的距離。A *使用這兩個距離的總和。(點擊查看原圖)

技術分享

算法會嘗試下在墻(深色的格子)上打一個洞(點擊鏈接,查看動畫效果)。你會發現探索同一個地區,當Greedy Best-First Search算法找到正確的答案時,A *也能發現它。當Greedy Best-First Search算法得到錯誤的答案(較長的路徑)時,A*還是可以找到正確的答案,就像Dijkstra’s的算法,但比Dijkstra’s的算法探索的少。

A *是二者的完美結合。只要heuristic函數不高估距離,A *就不會使用heuristic函數得到一個近似的答案。它找到一個最佳的路徑,就像Dijkstra’s’s算法一樣。A *使用 heuristic函數重新排序節點,這樣可以讓它更早的遇到目標節點。

好吧...就是這樣!這就是A *算法。

補充

你準備好實現它了嗎?考慮使用現有的庫吧。如果你準備自己實現它,我有配套指南,逐步展示如何在Python,C ++和C#中實現圖形,隊列和尋路算法。

你應該使用哪種算法來在遊戲地圖上查找路徑呢?

如果要找到通往所有位置的路徑,請使用寬度優先搜索或Dijkstra’s算法。運動成本相同時,使用寬度優先搜索; 運動成本不同,則使用Dijkstra’s算法。

如果您想查找到某一個位置的路徑,請使用Greedy BestFirst Search或A *算法。大多數情況下A *會表現更好。所以當你試圖使用Greedy Best First Search算法時,請考慮使用“inadmissible” heuristic的 A* 算法。

如何得到最佳路徑呢?寬度優先搜索和Dijkstra’s算法保證找到基於給定輸入圖的最短路徑。而Greedy BestFirst Search算法做不到。如果heuristic函數得到的距離永遠小於真實距離,則A *算法保證找到最短路徑。隨著heuristic函數距離變小,A *算法逐漸變成Dijkstra’s的算法。隨著heuristic函數距離變得越來越大,A *算法會變成Greedy Best First Search算法。

如何提升算法性能呢?最好的辦法是消除圖中不必要的位置。如果使用網格圖,請參閱。縮小圖形大小對所有的圖形搜索算法都是有益的。此外,盡可能的使用最簡單的算法; 最簡單的隊列,算法的運行速度才會更快。Greedy Best First Search算法通常比Dijkstra’s算法運行速度更快,但它不能產生最佳路徑。A *是大多數尋路問題的好選擇。

如果圖形不是地圖呢?我在這裏用地圖舉例,因為我認為通過使用地圖更容易了解算法的工作原理。實際上,這些圖形搜索算法可以用於任何類型的圖形,而不僅僅是遊戲地圖,並且我已經嘗試以獨立於2d網格的方式呈現算法代碼。地圖上的運動成本成為圖形邊緣上的任意權重。heuristics函數並不能輕易地轉換到任意地圖上,你必須為每種類型的圖形設計對應的heuristics函數。對於平面圖,距離是一個不錯的選擇,這就是我在這裏使用它的原因。

我在這裏寫了很多關於尋路的文章。請記住,圖形搜索只是你需要的一部分。A *算法本身不處理合作運動,移動障礙,地圖變更,危險區域評估,編隊,轉彎半徑,物體大小,動畫,路徑平滑等很多其他話題。

利用Red Blob遊戲介紹A*算法