A*尋路極限優化


github源碼:https://github.com/boycy815/fastAStar

這幾天在天地會上看到有算法比賽,比的是誰實現的A*尋路速度快,雖然比賽不是那么正規,但是這種展現實力的機會咱也不能落后是不,於是咱也折騰出一個算法提交上去,帖子在此:http://bbs.9ria.com/forum.php?mod=redirect&goto=findpost&ptid=172851&pid=1668442&fromuid=64655

128*128地圖規模下1000個隨機障礙,在我的電腦上一般不會超過1毫秒,只有一些奇葩的情況下會是1毫秒,沒出現過2毫秒的情況。然后我嘗試過5000個隨機障礙,一般不超過2毫秒,偶爾2毫秒,不存在路徑的情況下一般不超過8毫秒。

另外,雖然產生的路徑看起來是8方向的,實際計算的時候是使用4方向展開,再通過簡單的方式合並四個斜方向和同方向路徑,讓路徑盡量看起來自然。

clip_image001[8]合並斜方向 clip_image002[8]合並同方向

鑒於參賽程序寫得過於裝逼,部分同學反映看着累,所以也就有了本文。本文不打算講得太細致,默認讀者已經掌握基本的數據結構並了解了A*搜索的大概流程。不了解A*搜索的請移步:http://www.cnblogs.com/technology/archive/2011/05/26/2058842.html

啟發式搜索

A*搜索是一種啟發式搜索(可移步:http://baike.baidu.com/view/1237243.htm)。我們通常做尋路的時候,是把起點作為一個樹結構的根節點進行遍歷,直到找到終點。對於樹的遍歷,通常的我們有深度優先遍歷和廣度優先遍歷,這個是數據結構的基礎。但是通過這些方法做尋路往往顯得盲目,有時候眼看終點就在眼前,我們仍然需要先對其他節點展開遍歷。然而啟發式搜索雖然對樹做的也是遍歷,可它對節點展開的順序既不是深度優先也不是廣度優先,而是“估計值優先”。這個估計值是樹中每個節點綁定的一個值,這個值通過啟發式結合節點的一些屬性計算出來。好的啟發式能產生對搜索目標有導向性的估計值,估計值更優的節點在概率上應更接近搜索目標。通過啟發式我們能優化樹的節點展開順序從而更早找到目標節點,減少無謂的節點展開數量,提高效率。

A*的啟發式

A*搜索采用f(n) = g(n) + h(n)作為啟發式,其中g(n)為起點通過某個路徑到達地圖格子n的確定耗損,h(n)為地圖格子n到達終點的估計耗損。我們通常采用的h(n)有曼哈頓式,歐幾里得式等。曼哈頓式為h(n) = dx + dy,其中dx和dy為n點到終點的水平和豎直距離。歐幾里得式為h(n) = sqrt(dx^2 + dy^2),代表n點到終點的歐幾里得距離。這兩個啟發式的特點會在下文中講到。

最佳路徑定理

當任何格子n,其h(n)不大於格子其到終點的最小實際耗損時,A*搜索必然會得到最佳路徑。我們可以這么理解:當一個格子a為最佳路徑上的節點,其當前耗損為i,其到終點的實際耗損為j(假設已經知道),那么最佳路徑的耗損為i+j,那么當h(a)不大於j時,f(a)將不大於i+j,在路徑搜尋過程中,可能存在很多通往終點的路徑,只要其路徑長度不是最佳,那么當到達終點(估計耗損等於實際耗損),其耗損必將大於最佳路徑的耗損,根據“估計值”小先展開的順序,f(a)由於不大於最佳路徑耗損,所以必定會被先展開,同理所有最佳路徑上的格子都會在得到最佳路徑之前被展開,所以此定理就成立了。

高效啟發式原則

h(n)值的大小將影響A*搜索的效率,更大的值將更有效得減少無謂的分支展開,更快得找到終點。其原因是增加啟發式中估計值的權重,可讓啟發式對消耗的路程不敏感而對終點的方向更加敏感。在不得已時不會去展開大方向不對的格子。但是如果h(n)大於b格子到終點的最小路徑損耗,那么尋找出來的路徑就不一定是最短。所以結合最佳路徑定理,從搜索效率和質量上看,h(n)等於n格子到終點的最小實際損耗是最好的。

啟發式選擇

從上面的規律來看,曼哈頓式似乎優於歐幾里得式。曼哈頓式在沒有障礙的時候滿足h(n)等於n格子到終點的最小實際損耗。而歐幾里得式似乎得到的值小了一點。但是通常人們更願意使用歐幾里得式,因為歐幾里得式會優先選擇n格子到終點連線上的展開節點。而曼哈頓式對n格子到終點范圍內的點都一視同仁。

clip_image003[8]如圖曼哈頓式在n點和終點圍城的矩形啟發式值都相同,由於程序的循環順序是固定的,所以會產生這樣的路徑,雖然正確但不符合人的習慣。

clip_image004[8]而歐幾里得式會在如圖的對角線上有更低的啟發式值,所以程序會優先選擇對角線位置的路徑,如果將得到的路徑合並斜方向會得到非常符合人習慣的路徑。雖然曼哈頓式在效果上遜色於歐幾里得式,但是由於曼哈頓式計算簡單,擁有不少良好的性質非常適合優化,並且其效果不好的問題可以在前期預處理地圖的時候解決,故程序中使用曼哈頓式。

地圖預處理

我見不少同學的程序直接在搜索的大循環中拿二維數組來遍歷,於是就看到類似如下的代碼(聽說還是keith peters大師的代碼,真是有損大師威名啊):

var startX:int = Math.max(0, node.x - 1);
var endX:int = Math.min(_grid.numCols - 1, node.x + 1);
var startY:int = Math.max(0, node.y - 1);
var endY:int = Math.min(_grid.numRows - 1, node.y + 1);
for(var i:int = startX; i <= endX; i++)
{
    for(var j:int = startY; j <= endY; j++)
    {
        取出一個測試節點,要求可通過,並且不在open表和close表中
    }
}

//以上是反例,切勿模仿

如果是一個已經固定的地圖,這樣的計算完全可以在地圖生產的時候就完成。通過在每個格子中保存可展開的周邊格子引用,可以在搜索的時候直接取出一個節點可以展開的子節點,而省去判斷邊界,以及是否可通過這種計算。

另外在“啟發式選擇”部分提到的曼哈頓式的展開不美觀問題,也可以通過預處理解決。我們知道上面那段代碼對子節點的展開是按照從左到右,從上到下的順序,如果是一馬平川的地圖,某一個方向的節點總是優先被展開,那么就會產生路徑偏向一個方向這種情況。那我們要做的就是把子節點的展開順序錯開。

clip_image005[8]類似這樣,紅色的格子代表先展開水平方向的節點,后展開豎直方向的節點,白色的反一下。這種先后的控制是通過在預處理階段對不同的格子的子格子加入子節點列表順序不同完成的。在搜索階段程序只需要按照正常順序展開子節點即可。

其實在地圖預處理階段還能對一些可以組成大格子的小格子們進行合並,從而達到減少格子簡化地圖的目的。

clip_image006[8]類似這張地圖,黑色為障礙,左上角為起點,右下角為終點。我們把一些連續一片的格子合並成大矩形,如圖中合並后變成了6個矩形。然后把矩形每個與外界接壤的小格子所對應的外部矩形作為一個子節點。比如棕色矩形有四個小格子總共接壤三個外部矩形,那么棕色矩形就有四個子節點,其中綠色子節點一個占兩位。而耗損的計算,則是根據當前在棕色矩形內的格子位置,以及到達矩形所接壤的格子決定。這種格子合並技術非常適合實際的游戲地圖,只是在那種障礙物較多分散並且隨機性較大,的地圖下發揮不出優勢。另外一個不足的地方是前期合並格子的計算成本較大,后續也難以進行進一步優化。所以程序中並沒有采用這種方法。

去除close表

A*搜索中存在兩種表,open表和close表,這兩個表的概念是從啟發式搜索中引入的,open表保存等待將被展開的格子,這些格子是通過展開他們的格子得到的,我們需要不斷從里面取出估計值最優的節點。close表保存已經被展開過的格子,但是如果里面的格子估計值更新,他需要從close表中刪除重新加入open表。

網上曾有人討論過在A*中close表存在的必要性,一個已經被展開過的格子是否有可能會在進一步搜索過程中產生估計值更小的情況?如果不可能,那么刪除close表至少可以節約插入表的計算成本。

這里有個定理,是個充分不必要命題(不必要是因為我還沒法證明它的必要性):

A*搜索樹中任何一個節點n,如果n的子節點估計值都不小於n的估計值,那么從當它對應的格子從open表中取出時,格子擁有最小的估計值。

也就是說今后在搜索的不會再遇到格子n更小的估計值了。那么簡而言之,估計值不遞減,close表就沒有存在的必要性。

歐幾里得式和曼哈頓式都是不遞減的。所以close表完全沒有存在的必要性。我們只需要在格子內標記其是否被展開過即可。

簡化open表的排序

我們搜索的時候需要每次到open表中取得一個估計值最小的格子。所以對於大多數情況下,open表需要每加入一個數據,就要把數據插入到合適的位置,從而保持open表的有序性。這種情況下,“堆”是最理想的數據結構。但是即使是最理想的數據結構,也要經過一個循環多次比較。

但是如果使用4方向地圖曼哈頓式啟發,情況並非那么復雜,因為在同一時刻數據結構中只可能存在兩種值。因為首先對於一個格子來說估計值只有兩種可能性,一種是增加2,一種是不增加。而從open表中取出的格子的估計值勢必是一個較小值(假設其值是f),那么這個格子產生的子節點被加入open表,為open添加的值種類有f和f+2兩種。只要open表中一直存在值是f的格子,那么open表中必定只有f和f+2兩種值。除非不再有值是f的子節點加入,並且open表中的f值格子消耗完畢,這個時候最小值變成f+2,接下來open表中增加的就是f+2和f+4兩種值。。。

所以無論怎么樣,open表實際上只需要兩個坑位,我們可以通過hash的方法把估計值映射到對應的坑位里。當然同一個坑位有很多估計值相同的格子,這些格子都被放入一個棧中。

clip_image007[8]如圖就是open表的結構。這里之所以用棧存儲相同值的格子是因為當搜索遇到障礙時,展開的節點估計值必定都會是+2,這時可以通過搜索頭回溯尋找最接近的非+2展開點。反之如果使用隊列,那么會造成從頭開始搜的悲劇。

其他一些運算技巧

太零散,問到再說吧。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM