關於尋路演算法的一些思考(3):A*演算法的實現

hf_cherish發表於2015-04-07

概述

剝除程式碼,A* 演算法非常簡單。演算法維護兩個集合:OPEN 集和 CLOSED 集。OPEN 集包含待檢測節點。初始狀態,OPEN集僅包含一個元素:開始位置。CLOSED集包含已檢測節點。初始狀態,CLOSED集為空。從圖形上來看,OPEN集是已訪問區域的邊界,CLOSED集是已訪問區域的內部。每個節點還包含一個指向父節點的指標,以確定追蹤關係。

演算法有一個主迴圈,重複地從OPEN集中取最優節點n(即f值最小的節點)來檢測。如果n是目標節點,那麼演算法結束;否則,將節點n從OPEN集刪除,並新增到CLOSED集中,然後檢視n的所有鄰節點n’。如果鄰節點在CLOSED集,它已被檢測過,則無需再檢測(*);如果鄰節點在OPEN集,它將會被檢測,則無需此時檢測(*);否則,將該鄰節點加入OPEN集,設定其父節點為n,到n’的路徑開銷g(n’) = g(n) + movementcost(n, n’)。

這裡有更詳細的介紹,其中包含互動圖。

(*)這裡我略過了一個小細節。你應當檢查節點的g值,如果新計算得到的路徑開銷比該g值低,那麼要重新開啟該節點(即重新放入OPEN集)。

(**)如果啟發式函式值始終是可信的,這種情況就不應當出現。然而在遊戲中,經常會得到不可信的啟發式函式

請點選這裡 檢視PythonC++實現.

連通性

如果遊戲中起點和終點在圖上根本就不連通,此時A*演算法會耗時很久,因為從起點開始,它需要查探所有的節點,直到它意識到根本沒有可行的路徑。因此,我們可以先確定連通分支,僅當起點和終點在同一個連通分支時,才使用A*演算法。

效能

A*演算法的主迴圈從一個優先順序佇列中讀取節點,分析該節點,然後再向優先順序佇列插入新的節點。演算法還追蹤哪些節點被訪問過。要提高演算法的效能,考慮以下幾方面:

  • 能縮減圖的大小嗎?這能減少需處理的節點數目,包括在最終路徑上和不在最終路徑上的節點。可以考慮用導航網格(navigation meshes)代替網格(grids),還可以考慮分層地圖表示(hierarchical map representations)
  • 能提高啟發式函式的準確性嗎?這可以減少不在最終路徑上的節點數目。啟發式函式值越接近真實路徑長度(不是距離),A*演算法需要檢查的節點就越少。可以考慮用於網格的啟發式函式,也可以考慮用於一般圖形(包括網格)的ALT(A*,路標Landmarks,三角不等式Triangle Inequality)。
  • 能讓優先順序佇列更快嗎?考慮使用其他資料結構來構建優先順序佇列。也可以參考邊緣搜尋的做法,對節點進行批處理。還可以考慮近似排序演算法。
  • 能讓啟發式函式更快嗎?每個open節點都要呼叫啟發式函式,可以考慮快取函式的計算結果,也可以採用內聯呼叫。

關於網格地圖,我有一些建議

原始碼和演示

演示(demos)

這些demos執行於瀏覽器端:

程式碼

如果你用C++,一定要看看Mikko MononenRecast

如果你計劃自己實現圖搜尋,這裡是我的PythonC++實現指南

我收集了一些原始碼連結,但是我還沒有仔細看這些專案,所以也沒有更具體的建議:

集合表示

要表示OPEN集和CLOSED集,你首先能想到什麼?如果你像我一樣,可能就會考慮“陣列”。你也可能想到“連結串列。有很多資料結構都可以用,我們應當依據需要的操作來選擇一個。

對OPEN集主要執行三個操作:主迴圈重複地尋找最優節點,然後刪除它;訪問鄰節點時檢查節點是否在集合中;訪問鄰節點時插入新節點。插入和刪除最優都是優先順序佇列的典型操作。

資料結構的選擇不僅依賴於操作,還與每個操作執行的次數有關。成員隸屬測試對每個已訪問節點的每個鄰節點執行一次,插入對每個要考慮的節點執行一次,刪除最優對每個已訪問的節點執行一次。大部分考慮的節點都會被訪問,沒有被訪問的是搜尋空間的邊緣節點。評估各種資料結構下操作的開銷時,我們應當考慮最大邊緣值(F)。

另外還有第四種操作,這種操作相對較少,但仍需要實現。如果當前檢測的節點已經在OPEN集中(經常出現的情況),並且其f值低於OPEN集中原來的值(很少見的情況),那麼需要調整OPEN集中的值。調整操作包括刪除節點(這個節點的f值不是最優的)以及再插入該節點。這兩步操作可以優化為一步移動節點的“增加優先順序”操作(也被稱為“降鍵”)。

我的建議:一般最好的選擇是採用二叉堆。如果有現成的二叉堆庫,直接用就可以了。如果沒有,開始就使用有序陣列或無序陣列,當要求更高的效能時,切換到二叉堆。如果OPEN集中的元素超過10,000個,就要考慮使用更復雜的資料結構,比如桶系統(bucketing system)。

無序陣列或連結串列

最簡單的資料結構就是無序陣列或連結串列了。成員隸屬測試很慢,要掃描整個結構,時間複雜度為O(F)。插入很快,只需追加到結尾,時間複雜度O(1)。查詢最優元素很慢,要掃描整個結構,時間複雜度O(F)。刪除最優元素,採用陣列的時間複雜度是O(F),連結串列是O(1)。“增加優先順序”操作花費O(F)找節點,然後花費O(1)改變其值。

有序陣列

要想刪除最優元素更快,可以維護一個有序陣列。那麼可以做二分查詢,成員隸屬測試就是O(log F)。插入則很慢,要移動所有元素從而給新元素讓出位置,時間複雜度O(F)。查詢最優元素只需取最後一個元素,時間複雜度O(1)。如果我們確保最優元素在陣列末尾,刪除最優元素是O(1)。增加優先順序操作花O(log F)找節點,花O(F)改變其值或位置。

要確保陣列是有序的,以使最優元素在最後。

有序連結串列

有序陣列的插入很慢。如果用連結串列,插入就很快。而成員隸屬測試則很慢,需要掃描連結串列,時間複雜度O(F)。插入只需O(1),但是要用O(F)去找到正確的插入位置。查詢最優元素依然很快,最優元素在鏈尾,時間複雜度O(1)。刪除最優元素也是O(1)。增加優先順序操作花O(F)找節點,花O(1)改變其值或位置。

二叉堆

二叉堆(不要和記憶體堆搞混了)是樹型結構,儲存在陣列中。大部分樹採用指標指向孩子節點,二叉堆則使用下標確定孩子節點。

採用二叉堆時,成員隸屬測試需要掃描整個結構,時間複雜度O(F)。插入和刪除最優元素都是O(log F)。

增加優先順序操作很有技巧性,找節點用O(F),而增大其優先順序竟然只要O(log F)。然而,大部分優先順序佇列庫中都沒有該操作。幸運的是,這個操作不是絕對必要的。因此我建議,除非特別需要,不用考慮該操作。我們可以通過向優先順序佇列插入新元素來代替增加優先順序操作。雖然最終可能一個節點要處理兩次,但是相比實現增加優先順序操作,這個開銷比較少。

C++中,可以使用優先順序佇列(priority_queue)類,這個類沒有增加優先順序方法,也可以使用支援這個操作的Boost庫可變優先順序佇列。Python中使用的是heapq庫

你可以混合使用多種資料結構。採用雜湊表或索引陣列做成員隸屬測試,採用優先順序佇列管理優先順序;詳情請看下文的混合部分

二叉堆的一個變種是d元堆(d-ary heap),其中每個節點的孩子數大於2。插入和增加優先順序操作更快一些,而刪除操作則略慢一點。它們可能有更好的快取效能。

有序跳躍列表(sorted skip lists)

無序連結串列的查詢操作很慢,可以使用跳躍列表加快查詢速度。對於跳躍列表,如果有排序鍵,成員隸屬測試只要O(log F)。如果知道插入位置,同連結串列一樣,跳躍列表的插入也是O(1)。如果排序鍵是f,查詢最優節點很快,是O(1)。刪除一個節點是O(1)。增加優先順序操作包括查詢節點,刪除該節點,再插入該節點。

如果將地圖位置作為跳躍列表的鍵,成員隸屬測試是O(log F);執行過成員隸屬測試後,插入是O(1);查詢最優節點是O(F);刪除一個節點是O(1)。這比無序連結串列要好,因為它的成員隸屬測試更快。

如果將f值作為跳躍列表的鍵,成員隸屬測試是O(F);插入是O(1);查詢最優節點是O(1);刪除一個節點是O(1)。這並不比有序連結串列好。

索引陣列(indexed arrays)

如果節點集合有限,且大小還可以的話,我們可以採用直接索引結構,用一個索引函式i(n)將每個節點n對映到陣列的一個下標。無序陣列和有序陣列的大小都對應於OPEN集的最大尺寸,而索引陣列的陣列大小則始終是max(i(n))。如果函式是密集的(即不存在未使用的索引),max(i(n))是圖中的節點數目。只要地圖是網格結構,函式就很容易是密集的。

假設函式i(n)時間複雜度是O(1),成員隸屬測試就是O(1),因為只需要檢測Array[i(n)]有沒有資料。插入也是O(1),因為只需要設定Array[i(n)]。查詢和刪除最優節點是O(numnodes),因為需要查詢整個資料結構。增加優先順序操作是O(1)。

雜湊表

索引陣列佔用大量記憶體來儲存所有不在OPEN集中的節點。還有一種選擇是使用雜湊表,其中雜湊函式h(n)將每個節點n對映到一個雜湊碼。保持雜湊表大小是N的兩倍,以保證低衝突率。假設h(n)時間複雜度是O(1),成員隸屬測試和插入預期時間複雜度是O(1);刪除最優時間複雜度是O(numnodes),因為需要搜尋整個結構;增加優先順序操作是O(1)。

伸展樹(splay trees)

堆是基於樹的一種結構,它的操作預期時間複雜度是O(log F)。然而問題是,在A*演算法中,常見的操作是,刪除一個低開銷節點(引起O(log F)的操作,因為必須將數值從樹底向上移)後,緊跟著新增多個低開銷節點(引起O(log F)的操作,因為這些值被插入樹底,然後再向上調整到樹根)。這種情況下,預期情況就等價於最壞情況了。如果能找到一種資料結構,使預期情況更好,即使最壞情況沒有變好,A*演算法的效能也可以有所提高。

伸展樹是一種自調整的樹型結構。對樹節點的任何訪問,都會將那個節點向樹頂方向移動,最終呈現“快取”的效果,即不常用節點在底部,從而不會減慢操作。不管伸展樹多大,結果都是這樣,因為操作僅像“快取大小”一樣慢。在A*演算法中,低開銷節點很常用,高開銷節點則不常用,所以可以將那些高開銷節點移到樹底。

採用伸展樹,成員隸屬測試、插入、刪除最優、增加優先順序的預期時間複雜度都是O(log F),最壞情況時間複雜度都是O(F),然而快取使最壞情況通常不會發生。然而如果啟發式函式低估開銷,由於Dijkstra演算法和A*演算法的一些奇特特性,伸展樹可能就不是最好的選擇了。特別地,對於節點n和其鄰節點n’,如果f(n’) >= f(n),那麼所有的插入可能都發生在樹的一側,最終導致樹失衡。我還沒有測試這種情況。

HOT佇列

還有一種資料結構可能比堆要好。通常你可以限制優先順序佇列中值的範圍。給定一個範圍限制,往往會有更好的演算法。例如,任意值排序時間複雜度是O(N log N),但是如果有固定範圍,桶排序或基數排序可以在O(N)時間內完成。

我們可以採用HOT(Heap On Top)佇列,以有效利用f(n’) >= f(n)的情況,其中n’是n的一個鄰節點。我們將刪除f(n) 最小的節點n,然後插入滿足下列情況的鄰節點n’:f(n) <= f(n’) <= f(n) + delta,其中delta <= C,常量C是從一個節點到鄰節點的開銷的最大變化。由於f(n)是OPEN集中的最小f值,且所有插入的節點其f值都小於等於f(n) + delta,因此OPEN集中的所有f值都在0到delta的範圍內。就像桶/基數排序一樣,我們可以維護一些“桶”來對OPEN集中的節點排序。

用K個桶,可以將任何O(N)花費降低到其平均值O(N/K)。HOT佇列中,最前面的桶是一個二叉堆,其他的桶都是無序陣列。對於最前面的桶,成員隸屬測試預期時間複雜度是O(F/K),插入和刪除最優時間複雜度是O(log (F/K))。對於其他桶,成員隸屬測試時間複雜度是O(F/K),插入是O(1),而刪除最優元素永遠不會發生。如果最前面的桶空了,就要將下一個桶從無序陣列轉換為二叉堆。事實證明,這一操作(”heapify”)執行時間是O(F/K)。增加優先順序操作最好是處理為O(F/K)的刪除和隨後O(log (F/K))或O(1)的插入。

事實上,A*演算法中,大部分放入OPEN集的節點都不需要。HOT佇列結構脫穎而出,因為它只堆化(heapified)需要的元素(開銷不大),不需要的節點都在O(1)時間內被插入。唯一大於O(1)的操作是從堆中刪除節點,其時間複雜度也僅是O(log (F/K))。

此外,如果C小的話,可以設定K = C,那麼最小的桶甚至不需要是堆,因為一個桶中的所有節點f值都相同。插入和刪除最優都是O(1)!有個人報告說,當OPEN集最多有800個節點時,HOT佇列和堆一樣快;當最多有1500個節點時,HOT佇列比堆快20%。我預測隨著節點數的增加,HOT佇列會越來越快。

HOT佇列的一個簡單變種是兩級佇列:將好節點放到一種資料結果(堆或陣列),將壞節點放入另一種資料結構(陣列或連結串列)。因為大部分放入OPEN集的節點都是壞節點,所以它們從不被檢測,而且將它們放入大陣列也沒什麼壞處。

資料結構比較

要記住,我們不僅要確定漸近(”大O”)行為,也要找一個低常數。要說為什麼,我們考慮一個O(log F)的演算法和一個O(F)的演算法,其中F是堆中的元素個數。那麼在你的機器上,第一個演算法的實現可能執行10,000 * log(F)秒,而第二個演算法的實現可能執行2 * F秒。如果F = 256,那麼第一個是80,000秒,而第二個僅需512秒。這種情況下,“更快的”演算法用時更長。僅當F > 200,000時演算法才開始變得更快。

你不能只比較兩個演算法,還需要比較那些演算法的實現,還需要知道資料的大致規模。上述例子中,當F > 200,000,第一種實現更快。但如果在遊戲中,F始終低於30,000,第二種實現更好。

沒有一種基本資料結構是完全令人滿意的。無序的陣列或連結串列,插入代價很低,但成員隸屬測試和刪除代價則很高。有序的陣列或連結串列,成員隸屬測試代價稍微低些,刪除代價很低,而插入代價則很高。二叉堆插入和刪除代價稍微低些,成員隸屬測試代價則很高。伸展樹的所有操作代價都稍微低些。HOT佇列插入代價低,刪除代價相當低,成員隸屬測試稍微低些。索引陣列,成員隸屬測試和插入代價都很低,但刪除代價異常高,而且也會佔用大量記憶體。雜湊表效能同索引陣列差不多,不過佔用記憶體通常要少得多,而且刪除代價僅僅是高,而不是異常高。

參閱Lee Killough的優先順序佇列頁面,獲得更多有關更先進的優先順序佇列的論文和實現。

混合表示

要獲得更好的效能,你會採用混合資料結構。我的A*程式碼中,用一個索引陣列實現O(1)的成員隸屬測試,用一個二叉堆實現O(log F)的插入和刪除最優元素。至於增加優先順序,我用索引陣列在O(1)時間內測試我是否真的需要改變優先順序(通過在索引陣列中儲存g值),偶爾地,如果真的需要增加優先順序,我採用二叉堆實現O(F)的增加優先順序操作。你也可以用索引陣列儲存每個節點在堆中的位置;這樣的話,增加優先順序操作時間複雜度是O(log F)。

遊戲互動迴圈

互動式(特別是實時)遊戲的需求可能會影響計算最優路徑的能力。相比最好的答案,可能更重要的是答案。儘管如此,其他所有事都還是一樣的,更短的路徑總是更好的。

通常來說,計算接近起點的路徑比計算接近目標的路徑更為重要。立即開始原理:即使沿一個次優路徑,也要儘快移動單元,然後再計算更好的路徑。實時遊戲中,A*的時延往往比吞吐量更重要。

單元要麼跟隨直覺(簡單地移動),要麼聽從大腦(預先計算路徑)。除非大腦跟他們說不要那樣做,單元都將跟隨直覺行事。(這個方法在大自然中有應用,而且Rodney Brook在機器人架構中也用到了。)我們不是一次性計算所有的路徑,而是每一個或兩個或三個遊戲迴圈,計算一次路徑。此後單元按照直覺開始移動(可能僅僅朝目標沿直線運動),然後重新開始迴圈,再為單元計算路徑。這種方法可以攤平尋路開銷,而不是一次性全做完。

提前退出

通常A*演算法找到目標節點後退出主迴圈,但也可能提前退出,那麼得到的是一段區域性路徑。在找到目標節點前任何時刻退出,演算法返回到OPEN集當前最優節點的路徑。這個節點最有可能達到目標,因此這是一個合理的去處。

與提前退出類似的情況包括:已經檢測了一定數量的節點;A*演算法已經執行了數毫秒;或者檢查的節點距開始位置已經有一段距離。當使用路徑拼接時,較之完整路徑,拼接路徑的限制應當更小。

可中斷演算法

如果基本上不需要尋路服務,或者用於儲存OPEN集和CLOSED集的資料結構很小,一種可選的方案是:儲存演算法的狀態,退出遊戲迴圈,然後再回到A*演算法退出的地方繼續。

群體移動

路徑請求的到達不是均勻分佈的。實時戰略遊戲中,一個常見的情況是,玩家選擇多個單元,並命令它們向同一個目標移動。這造成了尋路系統的高負載。

這時,為某個單元找到的路徑很有可能對其他單元也有用。那麼一種想法是,可以找從單元中心點到目標中心點的路徑P,然後對所有單元都應用這條路徑的大部分,僅僅將前10步和後10步路,替換成為每個單元所找的路。單元i獲得的路徑是:從開始位置到P[10]的路徑,接著是P[10..len(P)-10]的共享路徑,再接著是從P[len(P)-10]到終點的路徑。

另請參閱:將路徑放到一塊叫做路徑拼接,這部分在這些筆記的另一個模組中有介紹。

為每個單元找的路很短(大概平均10個步驟),長的路都是共享的。大部分路徑只要找一次,然後在所有單元中共享。但如果所有單元都沿同樣的路徑移動,可能無法給使用者留下深刻印象。為了改善系統的這種表象,讓單元沿稍稍不同的路走。一種方法是,單元選擇鄰近位置,自行修改路徑。

另一種方法是,讓單元意識到其它單元的存在(可能隨機選一個“領頭”單元,或者選那個最清楚現狀的單元),並只為領頭單元尋找一條路徑,然後採用叢集演算法,將單元作為一個群體移動。

有些A*變種,可以處理移動目標,或者對目標理解逐漸加深的情況。其中一些適用於處理多個單元向相同目標移動的情況,它們將A*反過來用(找從目標到單元的路徑)。

改進

如果地圖上基本沒有障礙物,取而代之,增加了地形變化的開銷,那麼可以降低實際地形開銷,來計算初始路徑。例如,如果草原開銷是1,丘陵開銷是2,大山開銷是3,那麼A*會為了避免在大山上走1步,考慮在草原走3步。而如果假設草原開銷是1,丘陵是1.1,大山是1.2,那麼A*在計算最初的路徑時,就不會花很多時間去避免走大山,尋路就更快。(這與精確啟發式函式的成效近似。)知道一條路徑後,單元就立刻開始移動,遊戲迴圈可以繼續。當備用CPU可用時,用真實的移動開銷計算一個更好的路徑。

相關文章