第四章 Dijkstra和A*尋路演算法
尋路
尋路希望ai中的角色能夠計算一條從當前位置到目標位置合適的路徑,並且這條路徑能夠儘可能的合理和短。
在我們的ai模型中,尋路在決策和移動之間。遊戲中大多數尋路的實現是基於a星演算法,但是它不能直接使用關卡資料工作,需要轉換成特別的資料結構:有向非負權重圖。除了a星演算法本章也將介紹最短路徑演算法,它是一個更簡單版本的a星演算法,但是更常用於決策。
4.1 尋路圖
圖是一個數學概念,有兩種元素組成:節點,通常由點或者圈表示,在遊戲關卡中表示一個區域;線:連線兩個節點,表示可通過。
權重圖在圖的基礎上,在每條線上加了一個數字值,在數學圖論中被稱作權重,而在遊戲中它通常被稱作花費(cost)。
尋路圖中的花費(cost)通常代表時間或者距離。不過也可能是時間和距離的混合或者其他因素。
區域的節點
我們期望測量每個區域連線之間的距離或者時間,所以我們以每個區域的中心作為節點。如果區域很大,那麼兩個區域之間的距離就會更大,對應花費也更大。
非負約束: 在遊戲中,兩個區域之間的花費一般不會出現負數的情況,所以對於權重我們限制為非負值。
4.1.3有向權重圖
在遊戲開發中,很多情況下我們從一個區域A能夠進入區域B,反過來並不一定成立,或者從A到B和從B到A的花費並不相同。所以使用有向圖假設連線只有一個方向。
4.1.5 描述(Representation)
在尋路演算法中圖結構如下所示:
class Graph:
# 返回所有以傳入點為起點的連線
def getConnections(fromNode)
class Connection:
def getCost()
def getFromNode()
def getToNode()
根據不同的需求,Connection裡邊的cost可以是一個確定的值,也可以在需要獲取的時候才計算。
上邊並沒有node的結構,我們通常以一個數字索引表示。
4.2 最短路徑演算法(Dijkstra)
Dijkstra演算法最初被設計出來並不是作為遊戲中的尋路使用,而是為了解決數學圖論中的一個問題,被稱作“最短路徑”。
遊戲中的尋路有一個起始點和目標點,最短路徑演算法被設計找出從起始點到任意其他位置的最短路徑,而目標點也在結果中。所以如果我們只想找出起始點到目標點的最短路徑,這個演算法會丟棄許多其他目標點的路徑而導致浪費。
由於這個問題,我們不會在主要的尋路演算法中使用它。而它更常用於戰術解析(在第六章戰術和策略ai中)
4.2.1 解決的問題
給出一個有向非負權重圖和圖中的兩個節點(分別作為起始點和目標點),計算出所有可以從起始點到目標點總路徑發費最少的一條路徑。
可能有多個權重在最小值的路徑,我們只需要其中任意一條,並且不在乎是那條。
路徑由一組連線組成。
4.2.2 演算法介紹
通俗的說,Dijkstra從開始點向它的連線線擴散。隨著它向更遠的節點擴散,它記錄來自的方向(想象在黑板上畫箭頭表示回到起點的路徑)。最終將抵達目標點並根據箭頭返回到開始點來生成完整的路徑。由於Dijkstra調節擴散的程序,它保證箭頭總能以最短路徑指回開始點。
用更多細節來描述。Dijkstra迭代執行。每一次迭代它考慮圖中的一個節點並且跟隨以它擴散的連線。第一次迭代考慮的是起始點。在連續的迭代中它選取一個節點執行後邊我們討論的演算法。我們稱每次迭代的節點為“當前節點”。
執行當前節點
迭代期間,Dijkstra考慮每一個以當前節點出去的連線。針對每一條連線找出結束節點並存儲它到目前為止路徑的總花費(我們成為“cost-so-far”)。
第一次迭代,開始點是當前節點,對應連線的結束節點的cost-so-far為連線花費。下圖展示了第一次迭代之後的情況。
第一次迭代之後,每一條連線的結束節點的cost-so-far是連線的花費加上當前節點的cost-so-far,如下圖示:
在演算法的實現上,第一次和之後的遍歷沒有區別,通過設定開始節點的cost-so-far為0,我們可以用同一塊程式碼執行所有遍歷。
節點列表
演算法將所有遇到過的節點放置在兩個列表中:open和closed。在open列表中記錄了所有已經遇到過但是還沒有作為當前節點遍歷過的的節點。已經遍歷過的節點放置在closed列表中。開始的時候,open列表只有一個開始節點,closed列表為空。
每一次迭代,演算法從open列表中選擇cost-so-far最小的節點。然後作為當前節點執行演算法,並從open列表中移除該節點放入closed列表中。
但是也有一個不同,我們考慮當前節點的每一條連線的結束節點並不一定都是未發現節點。也有可能處於open列表或者closed列表中,這是處理它們會有一些不同。
計算open和closed列表節點的cost-so-far
如果我們在遍歷中計算的連線結束點已經存在與open或者closed列表中,那麼這個點已經有了cost-so-far值和抵達該點的連線記錄。這時候是否重新修改記錄資訊就要依據新的cost-so-far是否比原值小。
需要更新節點資訊的話,這個節點需要被放置於open列表中,如果它已經在closed列表中,需要從中移除。
嚴格的說,Dijkstra演算法不會對一個在closed列表的節點找出一個更好的路徑,所以如果節點在closed列表中我們不必再做cost-so-far比較。然而A星演算法並不是這樣,無論是否在closed列表中,我們都要做比較判定。
下圖展示了open節點的資訊更新,新的路徑,通過節點C更快速所以節點D的資訊進行了更新:
演算法結束
標準的Dijkstra演算法在open列表為空時結束,即已經考慮了從開始節點延伸的所有節點,並且它們都處於了closed列表中。
對於尋路,我們只關心是否抵達目標節點。然而,只有在目標節點在open列表中cost-so-far最小的時候(即執行完目標點要放入closed列表中時)才應該結束。考慮到上圖,如果目標點時D,在對A點進行操作時,D點已經被放置於open列表中,但是實際上當前路徑A-D並不是最近路徑。在執行到C點,此時處於open列表的有E,F,D,這個時候D才找到了最短路徑。
在實際應用中,這個規則經常被打破,第一次發現目標節點的路徑常常是最短路徑,即使不是最短的通常也不會差距太大。基於這種情況,很對開發者實現的尋路演算法中一旦遇到目標節點,便立即結束尋路,而不是直到它從open列表中被選取執行的時候才作為結束。
檢索路徑
最後一個階段是檢索路徑。我們衝目標點開始,查詢它的連線找到上一個開始節點,再往回遍歷最終回到了開始點,能夠得到一個從目標點到開始點的最短路徑,顛倒改路徑可以得到最終結果。
4.2.3 虛擬碼
def pathfindDijkstra(graph, start, end):
# 這個結構儲存了已經執行過的節點資訊
struct NodeRecord:
node
connection
costSoFar
# 初始化開始節點記錄
startRecord = new NodeRecord()
startRecord.node = start
startRecord.connection = None
startRecord.costSoFar = 0
# 初始化open和closed列表
open = PathfindingList()
open += startRecord
closed = PathfindingList()
while length(open) > 0:
current = open.smallestElement()
if current.node == goal: break
connections = graph.getConnections(current)
for connection in connections:
endNode = connection.getToNode()
endNodeCost = current.costSoFar + connection.getCost()
# 已經在closed列表中,不再考慮
if closed.contains(endNode): continue
else if open.contains(endNode):
endNodeRecord = open.find(endNode)
# 不需要更新資料
if endNodeRecord.cost <= endNodeCost:
continue
else:
endNodeRecord = new NodeRecord()
endNodeRecord.node = endNode
endNodeRecord.cost = endNodeCost
endNodeRecord.connection = connection
if not open.contains(endNode):
open += endNodeRecord
open -= current
closed += current
if current.node != goal: # 未發現目標點
return None
else:
path = []
while current.node != start:
path += current.connection
current = current.connection.getFromNode()
return reverse(path)
class Connection:
cost
fromNode
toNode
def getCost(): return cost
def getFromNode(): return fromNode
def getToNode(): return toNode
4.2.6 弱點
Dijkstar演算法無差別的遍歷當前最短路徑,對於查詢起始點到任意點的最短路徑該演算法很有效,但是對於點對點的路徑查詢很浪費。
4.3 A*
遊戲中的尋路同義與A*演算法。A *演算法很容易實現,有效並且有很多優化的能力。過去10年提及過的每一個尋路系統都將一些A星演算法的變種作為它的關鍵演算法,而且A星演算法在尋路之外也有很好的應用。第五章我們將看到A星應用於計劃角色複雜的一系列動作。
A星被設計用於解決點對點尋路而不是用來解決圖論中的最短路徑問題。它可以被擴充套件來解決更復雜的問題,但是最終總是返回一個從起始點到目標點的一條路徑。
4.3.1 解決的問題
A星解決的問題和最短路徑尋路演算法一致。
4.3.2 演算法解析
總的來說,A星演算法和Dijkstra一樣的方式工作,但是不是在open列表中選取最小的cost-so-far節點,而是選取以更可能短距離到達目標點的節點。“更可能”通過一個啟發式方法來操作。如果這個啟發式方法很精確,那麼A星演算法就會很有效,如果很糟糕,那麼演算法的效能將會比Dijkstra更差。
處理當前節點
在一次迭代中,A星對以當前節點開始的每一條連線,找到對應結束點並存儲它的cost-so-far和這條連線,到這裡和Dikstra一致。此外,節點多儲存了一個值:從開始節點通過該節點到目標點的總預估花費(稱作estimated-total-cost)。這個預估值是兩個值的和:cost-so-far和當前點到目標點的花費。
這個預估值被稱作節點的啟發值(heuristic value),為非負數。生成啟發值是A星演算法的關鍵點。
下圖展示了A*演算法中啟發值的計算:
節點列表
和Dijkstra一樣,演算法也是用了open列表儲存已看到但未處理的節點,closed列表儲存已處理節點。不同之處是,每次從open列表中選取最小預估總髮費的節點作為當前節點。
如果當前節點有更小的預估總髮費,就意味著它有相對更小的cost-so-far和到目標點的預估值。如果預估是準確的,則更靠近目標點的節點優先考慮,縮小搜尋的範圍到一個更有利的區域。
計算open和closed節點的cost-so-far
和Dijkstra一樣,我們在遍歷中會修改open和closed節點的記錄值,計算出節點cost-so-far的值,如果節點記錄已經存在並且儲存的cost-so-far大於最新值,就更新它。
和Dijkstra不一樣的是,A星演算法會對已經在closed列表的節點發現更好的路線(即更小的總預估花費),此時需要更新此節點記錄並移至open列表中,這樣當下次該節點重新作為當前節點時,會重新計算對應連線的結束點的值。
如下圖示:
演算法結束
在很多實現中,A星演算法會在發現open列表裡預估總花費節點為目標節點時結束。
但是基於上邊我們討論到的,即使節點有了最小預估總值,在之後的迭代中還是可能會更新這個值,所以這種結束方法並不能保證能夠找到最短路徑。
為了保證我們能夠獲取到最短路徑,我們需要保持和Dijkstra一樣,只有在open列表中cost-so-far最小的節點為目標節點時才結束演算法。但是這些額外的檢測會使演算法運算更長而降低了A星演算法的效能。
A星實現理論上不會得到最優結果,不過我們可以通過啟發函式控制。依賴於啟發函式的選擇,我們可以做到獲取最優解或者獲得次優解但是運算速度更快。之後會討論到啟發函式的影響。
由於A星演算法經常只得到次優結果(flirts with sub-optimal results),很多A星實現中在發現目標節點時就結束而不再確保是open列表的最小值。
4.3.3 虛擬碼
def pathfindAStar(graph, start, end, heuristic):
struct NodeRecord:
node
connection
costSoFar
estimatedTotalCost
startRecord = new NodeRecord()
startRecord.node = start
startRecord.connection = None
startRecord.costSoFar = 0
startRecord.estimatedTotalCost = heuristic.estimate(start)
open = PathfindingList()
open += startRecord
closed = PathfindingList()
while length(open) > 0:
# 找到open列表的最小預估總值
current = open.smallestElement()
if current.node == goal: break
connections = graph.getConnections(current)
for connection in connections:
endNode = connection.getToNode()
endNodeCost = current.costSoFar + connection.getCost()
if closed.contains(endNode):
endNodeRecord = closed.find(endNode)
if endNodeRecord.costSoFar <= endNodeCost:
continue
# 節點更新了記錄值,要重新移到open列表中
closed -= endNodeRecord
# 通過這種方式獲取結束節點到目標點的預估值
endNodeHeuristic = endNodeRecord.cost - endNodeRecord.costSoFar
else if open.contains(endNode):
endNodeRecord = open.find(endNode)
if endNodeRecord.costSoFar <= endNodeCost:
continue
endNodeHeuristic = endNodeRecord.cost - endNodeRecord.costSoFar
else: # 處於未訪問狀態,加入到open列表中
endNodeRecord = new NodeRecord()
endNodeRecord.node = endNode
# 使用啟發函式計算當前節點到目標點的預估值
endNodeHeuristic = heuristic.estimate(endNode)
endNodeRecord.cost = endNodeCost
endNodeRecord.connection = connection
endNodeRecord.estimatedTotalCost = endNodeCost + endNodeHeuristic
if not open.contains(endNode):
open += endNodeRecord
open -= current
closed += current
if current.Node != goal: # 未找到有效路線
return None
else:
path = []
while current.node != start:
path += current.connection
current = current.connection.getFromNode()
return reverse(path)
4.3.4 資料結構和介面
open和closed儲存列表結構的實現略…
啟發函式
啟發方法通常作為一個函式討論,它也實現成一個函式。在上邊的虛擬碼中我們實現為一個物件,它儲存了當前的目標節點:
class golaNode:
def Heuristic(goal): goalNode = goal
def estimate(node)
尋路時使用方式如下:
pathfindAStar(graph, start, end, new Heuristic(end))
啟發方法速度
啟發方法在迴圈中處於最低點(called at the lowest point),因為它需要進行預估,可能需要執行一些演算法計算。如果比較複雜的話,會拖慢整個尋路演算法執行。
一些情況下可能允許通過建立一個啟發值的查詢表來優化,但是大多數情況下大量的點對點組合會導致表變得巨大無比。
對尋路中的啟發函式進行優化是很有必要的,之後會對此進行探討。
4.3.7 節點陣列A星(Node Array A*)
節點陣列A*是一個比普通A星演算法更快的長鬆A星演算法。
使用一個節點陣列
我們可以做一個權衡增加記憶體使用來提高執行速度。在演算法開始之前,我們建立一個數組用來儲存圖中每一個節點的記錄。節點被標記為一個連續的數字,我們不需要再在兩個列表中查詢節點的資訊,直接可以在節點陣列中找到。
判斷一個節點是否在open或closed列表中
在最初的演算法中,需要查詢兩個列表,檢查節點是否已經在裡邊,這會導致速度變慢,為了解決找出節點在哪個列表的問題,我們可以在節點記錄中加入一個額外資訊,它表示當前節點處於那個狀態(unvisited, open, closed),如下示:
struct NodeRecord:
node
conneection
costSoFar
estimatedTotalCost
category # 一個數字指示不同狀態
closed列表不再需要
由於我們提前建立了所有節點的記錄,而closed列表的唯一作用是為了判斷節點是否在裡邊(處於closed狀態),根據上邊的修改closed列表不再需要。
open列表的實現
我們仍需要判斷所有open列表中花費最小的節點,所以仍然需要一個用於排序的open列表存在。
大圖的一個變種
如果圖非常大,那麼提前建立所有節點會造成很大的浪費。我們可以將節點陣列改為檢點陣列指標,只有當發現節點的時候,才會建立節點記錄物件,然後將它存入節點指標陣列,如果在查詢這個陣列時發現對應記錄是空指標,說明該節點是一個未發現的狀態。
4.3.8 選擇啟發方法
啟發散發越精確,A星迭代的越少,執行得就越快。如果可以有一個完美的啟發演算法(總是能夠獲得兩個節點最小路徑的距離),A星將直接獲得最優解:演算法只需要P次迭代,P是最短路徑的路線數。
不幸的是,最優啟發方法本身就是尋路演算法要解決的問題,所以實際上啟發方法在很少情況下會是準確的。
對於不完美的啟發演算法,它預估的高或低會使A星演算法表現得明顯不一樣。
低估的啟發方法
如果啟發方法太低,即預估低於真實的路徑長度,A*演算法將花費更多的執行次數。因為預估總髮費會偏向於cost-so-far值,所以A星演算法在open列表中偏向於選擇更靠近開始點的節點,而不是更靠近目標點。
如果啟發方法在任何情況下都低估時,A星演算法將產生一個最好的路線結果。和Dijkstra演算法產生一樣的路線。最極端的情況,啟發方法每次都返回0,則A*將變成Dijkstra演算法。如果啟發方法一旦高估過,那麼就不再能保證。
在大多數效果最重要的應用中,確保啟發方法低估是很重要的。大多數學術和商業的尋路文章,精度很重要,所以低估的啟發方法比比皆是。這個情況也會影響到遊戲開發者。在應用中,不要完全抵制高估的期發方法,遊戲不是需要最優精度,而是需要更可信的結果。
高估的啟發方法
如果啟發方法太高,那麼將高估真實的路徑長度,A星演算法可能不會返回最好路線。演算法將傾向於生成更少路徑點的路線,即使花費更大。
總預估花費將偏向於啟發方法。A星演算法將更少的關注cost-so-far的值並且偏向於距目標更少距離的節點。這將更快的搜尋到目標點,但是更可能錯過最好的路線。
高估的啟發方法通常被稱作“不允許的啟發”,不是說不能夠使用它,而是演算法通常不會獲得最短路線。高估如果幾乎很完美的話可以使A星演算法執行的更快,因為它傾向更快抵達目標。如果只是輕微的高估,通常也會返回最好路線。
歐幾里得距離(Euclidean Distance)
在通常以考慮距離的尋路問題中,一個常見的啟發方法是歐幾里得距離,它保證低估於真實值。它指的是兩個節點之間的直線距離,不考慮是否會通過牆或障礙物。
下圖所示在一個室內場景的歐幾里得距離示例,由於直線距離是兩點最短距離,所以歐幾里得距離總是低估或者精確。
在室外中,有更少的障礙來限制移動,歐幾里得距離會更加精確並提供更快的尋路。
下圖展示了基於瓦片的室內和室外關卡視覺化的尋路情況,使用歐幾里得距離啟發方法,室內效能很差,而室外遍歷的節點更少,新能更好。
叢集啟發方法(Cluster Heuristic)
叢集啟發方法通過將一組節點作為一個集合來工作。在同一個集合的節點代表關卡中一個聯通的區域。叢集可以通過本書後邊提到的圖聚類(graph clustering)演算法自動生成。通常是手工或者通過關卡設計產生。
需要一個查詢表來儲存每一對叢集的最短距離。這是一個離線運算。選擇一個足夠小的群集集合這樣演算法會在一個合理的時間幀完成並且儲存在在一個合理的記憶體大小中。
當該啟發方法在遊戲中,如果開始和結束節點在同一個叢集中,直接使用歐幾里得距離(或者其他方式)產生結果。否則預估值直接在查詢表中獲取。如下圖所示,圖中的每一個連線的兩個方向有同樣的花費:
這裡有一個問題,由於同一群集裡的所有節點有相同的預估值,A星演算法不能夠找到通過一個叢集的最好路線。當演算法移動到下一個叢集之前當前叢集節點可能機會要完全迭代一遍。
如果每個叢集的大小很小,那麼他就不是一個問題,啟發方法的精確性會很好。另一方面查詢表也會變得很大(因為相應的叢集數量會很多)。
如果每個叢集的大小太大,那個將有一個很差的效能表現,一個更簡單的啟發方法會是個更好的選擇。
A星中的填充模式
下圖展示了在基於瓦片的室內關卡A星演算法使用不同啟發方法的查詢表現,null意味著啟發方法每次都返回0:
這是一個資訊和搜尋權衡的一個很好的示例。
如果啟發方法更加複雜並且對遊戲關卡特殊定製,那麼A星演算法搜尋的更少。啟發方法提供極限的資訊進行極限擴充套件:完全精確的預估,將產生A星最優效能。
另一方面,歐幾里得距離提供了更少的資訊,只知道兩點間的距離,這些資訊仍然需要演算法做更多的搜尋。
而返回0的啟發方法沒有任何資訊,需要最多的搜尋。
在室內地圖例子中,有大量障礙物,歐幾里得距離不是表現真實距離的最好方法。在室外地圖中,如下圖所示,有更少的障礙,此時歐幾里得距離更加的精確和快速,而叢集啟發方法並不能提升效能(甚至降低效能):
啟發方法的質量
製作一個啟發方法更像是一門藝術而不是科學。它的意義被ai開發者低估了。在我們的經驗中,很多開發者只是使用一個歐幾里得距離方法而不考慮最佳方法。
選取一個最佳啟發方法的得體方式是視覺化演算法的迭代過程,可以在遊戲中顯示或者在測試之後顯示輸出結果。這樣可以發現我們原本認為有效的啟發方法一些弱點。