尋路算法——A*算法

2 尋路算法——A*算法剖析

2.1 A*算法簡介

原文鏈接:http://www.gamedev.net/reference/articles/article2003.asp

原作者文章鏈接:http://www.policyalmanac.org/games/aStarTutorial.htm

搜索區域

假設有人想從A點移動到一牆之隔的B點,如下圖,綠色的是起點A,紅色是終點B,藍色方塊是中間的牆。

1

你首先注意到,搜索區域被我們劃分成了方形網格。像這樣,簡化搜索區域,是尋路的第一步。這一方法把搜索區域簡化成了一個二維數組,數組的每一個元素是網格的一個方塊,方塊被標記爲可通過的和不可通過的。路徑被描述爲從AB我們經過的方塊的集合,一旦路徑被找到,我們的人就從一個方格的中心走向另一個,直到到達目的地。這些點被稱爲“節點”。當你閱讀其他的尋路資料時,你將經常會看到人們討論節點。爲什麼不把他們描述爲方格呢?因爲有可能你的路徑被分割成其他不是方格的結構。他們完全可以是矩形,六角形,或者其他任意形狀。節點能夠被放置在形狀的任意位置-可以在中心,或者沿着邊界,或其他什麼地方。我們使用這種系統,無論如何,因爲它是最簡單的。

開始搜索

正如我們處理上圖網格的方法,一旦搜索區域被轉化爲容易處理的節點,下一步就是去引導一次找到最短路徑的搜索。在A*尋路算法中,我們通過從點A開始,檢查相鄰方格的方式,向外擴展直到找到目標。我們做如下操作開始搜索:

(1) 從點A開始,並且把它作爲待處理點存入一個“開啓列表”。開啓列表就像一張購物清單。儘管現在列表裏只有一個元素,但以後就會多起來。你的路徑可能會通過它包含的方格,也可能不會。基本上,這是一個待檢查方格的列表。

(2) 尋找起點周圍所有可到達或者可通過的方格,跳過有牆,水,或其他無法通過地形的方格。也把他們加入開啓列表。爲所有這些方格保存點A作爲“父方格”。當我們想描述路徑的時候,父方格的資料是十分重要的。後面會解釋它的具體用途。

(3) 從開啓列表中刪除點A,把它加入到一個“關閉列表”,列表中保存所有不需要再次檢查的方格。

在這一點,你應該形成如下圖的結構。在圖中,暗綠色方格是你起始方格的中心。它被用淺藍色描邊,以表示它被加入到關閉列表中了。所有的相鄰格現在都在開啓列表中,它們被用淺綠色描邊。每個方格都有一個灰色指針反指他們的父方格,也就是開始的方格。

2

接着,我們選擇開啓列表中的臨近方格,大致重複前面的過程,如下。但是,哪個方格是我們要選擇的呢?是那個F值最低的。

路徑評分

選擇路徑中經過哪個方格的關鍵是下面這個等式:

F = G+ H,這裏:

* G =從起點A,沿着產生的路徑,移動到網格上指定方格的移動耗費。
* H = 從網格上那個方格移動到終點B的預估移動耗費。這經常被稱爲啓發式的,可能會讓你有點迷惑。這樣叫的原因是因爲它只是個猜測。我們沒辦法事先知道路徑的長度,因爲路上可能存在各種障礙(牆,水,等等)。雖然本文只提供了一種計算H的方法,但是你可以在網上找到很多其他的方法。

我們的路徑是通過反覆遍歷開啓列表並且選擇具有最低F值的方格來生成的。文章將對這個過程做更詳細的描述。首先,我們更深入的看看如何計算這個方程。
正如上面所說,G表示沿路徑從起點到當前點的移動耗費。在這個例子裏,我們令水平或者垂直移動的耗費爲10,對角線方向耗費爲14。我們取這些值是因爲沿對角線的距離是沿水平或垂直移動耗費的的根號2(別怕),或者約1.414倍。爲了簡化,我們用1014近似。比例基本正確,同時我們避免了求根運算和小數。這不是只因爲我們怕麻煩或者不喜歡數學。使用這樣的整數對計算機來說也更快捷。你不就就會發現,如果你不使用這些簡化方法,尋路會變得很慢。

既然我們在計算沿特定路徑通往某個方格的G值,求值的方法就是取它父節點的G值,然後依照它相對父節點是對角線方向或者直角方向(非對角線),分別增加1410。例子中這個方法的需求會變得更多,因爲我們從起點方格以外獲取了不止一個方格。

H值可以用不同的方法估算。我們這裏使用的方法被稱爲曼哈頓方法,它計算從當前格到目的格之間水平和垂直的方格的數量總和,忽略對角線方向。然後把結果乘以10。這被成爲曼哈頓方法是因爲它看起來像計算城市中從一個地方到另外一個地方的街區數,在那裏你不能沿對角線方向穿過街區。很重要的一點,我們忽略了一切障礙物。這是對剩餘距離的一個估算,而非實際值,這也是這一方法被稱爲啓發式的原因。想知道更多?你可以在這裏找到方程和額外的註解。

F的值是GH的和。第一步搜索的結果可以在下面的圖表中看到。FGH的評分被寫在每個方格里。正如在緊挨起始格右側的方格所表示的,F被打印在左上角,G在左下角,H則在右下角。

3

現在我們來看看這些方格。寫字母的方格里,G = 10。這是因爲它只在水平方向偏離起始格一個格距。緊鄰起始格的上方,下方和左邊的方格的G值都等於10。對角線方向的G值是14

H值通過求解到紅色目標格的曼哈頓距離得到,其中只在水平和垂直方向移動,並且忽略中間的牆。用這種方法,起點右側緊鄰的方格離紅色方格有3格距離,H值就是30。這塊方格上方的方格有4格距離(記住,只能在水平和垂直方向移動)H值是40。你大致應該知道如何計算其他方格的H值了~。每個格子的F值,還是簡單的由GH相加得到。

繼續搜索

爲了繼續搜索,我們簡單的從開啓列表中選擇F值最低的方格。然後,對選中的方格做如下處理:

4,把它從開啓列表中刪除,然後添加到關閉列表中。

5,檢查所有相鄰格子。跳過那些已經在關閉列表中的或者不可通過的(有牆,水的地形,或者其他無法通過的地形),把他們添加進開啓列表,如果他們還不在裏面的話。把選中的方格作爲新的方格的父節點。

6,如果某個相鄰格已經在開啓列表裏了,檢查現在的這條路徑是否更好。換句話說,檢查如果我們用新的路徑到達它的話,G值是否會更低一些。如果不是,那就什麼都不做。

另一方面,如果新的G值更低,那就把相鄰方格的父節點改爲目前選中的方格(在上面的圖表中,把箭頭的方向改爲指向這個方格)。最後,重新計算FG的值。如果這看起來不夠清晰,你可以看下面的圖示。

好了,讓我們看看它是怎麼運作的。我們最初的9格方格中,在起點被切換到關閉列表中後,還剩8格留在開啓列表中。這裏面,F值最低的那個是起始格右側緊鄰的格子,它的F值是40。因此我們選擇這一格作爲下一個要處理的方格。在緊隨的圖中,它被用藍色突出顯示。


4

首先,我們把它從開啓列表中取出,放入關閉列表(這就是他被藍色突出顯示的原因)。然後我們檢查相鄰的格子。哦,右側的格子是牆,所以我們略過。左側的格子是起始格。它在關閉列表裏,所以我們也跳過它。其他4格已經在開啓列表裏了,於是我們檢查G值來判定,如果通過這一格到達那裏,路徑是否更好。我們來看選中格子下面的方格。它的G值是14。如果我們從當前格移動到那裏,G值就會等於20(到達當前格的G值是10,移動到上面的格子將使得G值增加10)。因爲G20大於14,所以這不是更好的路徑。如果你看圖,就能理解。與其通過先水平移動一格,再垂直移動一格,還不如直接沿對角線方向移動一格來得簡單。

當我們對已經存在於開啓列表中的4個臨近格重複這一過程的時候,我們發現沒有一條路徑可以通過使用當前格子得到改善,所以我們不做任何改變。既然我們已經檢查過了所有鄰近格,那麼就可以移動到下一格了。

於是我們檢索開啓列表,現在裏面只有7格了,我們仍然選擇其中F值最低的。有趣的是,這次,有兩個格子的數值都是54。我們如何選擇?這並不麻煩。從速度上考慮,選擇最後添加進列表的格子會更快捷。這種導致了尋路過程中,在靠近目標時,優先使用新找到的格子的偏好。但這無關緊要。(對相同數值的不同對待,導致不同版本的A*算法找到等長的不同路徑。)那我們就選擇起始格右下方的格子,如下圖。


5

這次,當我們檢查相鄰格的時候,發現右側是牆,於是略過。上面一格也被略過。我們也略過了牆下面的格子。爲什麼呢?因爲你不能在不穿越牆角的情況下直接到達那個格子。你的確需要先往下走然後到達那一格,按部就班的走過那個拐角 (穿越拐角的規則是可選的。它取決於你的節點是如何放置的) 。這樣一來,就剩下了其他5格。當前格下面的另外兩個格子目前不在開啓列表中,於是我們添加他們,並且把當前格指定爲他們的父節點。其餘3格,兩個已經在關閉列表中(起始格,和當前格上方的格子,在表格中藍色高亮顯示),於是我們略過它們。最後一格,在當前格的左側,將被檢查通過這條路徑,G值是否更低。不必擔心,我們已經準備好檢查開啓列表中的下一格了。

我們重複這個過程,直到目標格被添加進關閉列表,就如在下圖中所看到的。


6

注意,起始格下方格子的父節點已經和前面不同的。之前它的G值是28,並且指向右上方的格子。現在它的G值是20,指向它上方的格子。這在尋路過程中的某處發生,當應用新路徑時,G值經過檢查變得低了-於是父節點被重新指定,GF值被重新計算。儘管這一變化在這個例子中並不重要,在很多場合,這種變化會導致尋路結果的巨大變化。

那麼,我們怎麼確定這條路徑呢?很簡單,從紅色的目標格開始,按箭頭的方向朝父節點移動。這最終會引導你回到起始格,這就是你的路徑!看起來應該像圖中那樣。從起始格A移動到目標格B只是簡單的從每個格子(節點)的中點沿路徑移動到下一個,直到你到達目標點。就這麼簡單。


7

2.2 A*算法總結與深入分析

現在我們把每一步的操作寫在一起:
(1)
把起始格添加到開啓列表。

(2) 重複如下的工作:

a) 尋找開啓列表中F值最低的格子,我們稱它爲當前格。

b) 把它切換到關閉列表。

c) 對相鄰的8格中的每一個?

* 如果它不可通過或者已經在關閉列表中,略過它。反之如下。

* 如果它不在開啓列表中,把它添加進去。把當前格作爲這一格的父節點。記錄這一格的F,G,H值。

* 如果它已經在開啓列表中,用G值爲參考檢查新的路徑是否更好。更低的G值意味着更好的路徑。如果是這樣,就把這一格的父節點改成當前格,並且重新計算這一格的GF值。如果你保持你的開啓列表按F值排序,改變之後你可能需要重新對開啓列表排序。

d) 停止,當你

* 把目標格添加進了關閉列表(註解),這時候路徑被找到,或者

* 沒有找到目標格,開啓列表已經空了。這時候,路徑不存在。

(3) 保存路徑。從目標格開始,沿着每一格的父節點移動直到回到起始格。這就是你的路徑。

(註解:在這篇文章的較早版本中,建議的做法是當目標格(或節點)被加入到開啓列表,而不是關閉列表的時候停止尋路。這麼做會更迅速,而且幾乎總是能找到最短的路徑,但不是絕對的。當從倒數第二個節點到最後一個(目標節點)之間的移動耗費懸殊很大時-例如剛好有一條河穿越兩個節點中間,這時候舊的做法和新的做法就會有顯著不同。)

題外話

離題一下,值得一提的是,當你在網上或者相關論壇看到關於A*的不同的探討,你有時會看到一些被當作A*算法的代碼而實際上他們不是。要使用A*,必須包含上面討論的所有元素——特定的開啓和關閉列表,用F,GH作路徑評價。有很多其他的尋路算法,但他們並不是A*A*被認爲是他們當中最好的。Bryan Stout在這篇文章後面的參考文檔中論述了一部分,包括他們的一些優點和缺點。有時候特定的場合其他算法會更好,但你必須很明確你在作什麼。

實現的註解
現在你已經明白了基本原理,寫你的程序的時候還得考慮一些額外的東西。下面這些材料中的一些引用了我用C++Blitz Basic寫的程序,但對其他語言寫的代碼同樣有效。

 

1. 其他單位(避免碰撞)

如果你恰好看了我的例子代碼,你會發現它完全忽略了其他單位。我的尋路者事實上可以相互穿越。取決於具體的遊戲,這也許可以,也許不行。如果你打算考慮其他單位,希望他們能互相繞過,我建議你只考慮靜止或那些在計算路徑時臨近當前單位的單位,把它們當前的位置標誌爲可通過的。對於臨近的運動着的單位,你可以通過懲罰它們各自路徑上的節點,來鼓勵這些尋路者找到不同的路徑(更多的描述見#2).

如果你選擇了把其他正在移動並且遠離當前尋路單位的那些單位考慮在內,你將需要實現一種方法及時預測在何時何地碰撞可能會發生,以便恰當的避免。否則你極有可能得到一條怪異的路徑,單位突然轉彎試圖避免和一個已經不存在的單位發生碰撞。

當然,你也需要寫一些碰撞檢測的代碼,因爲無論計算的時候路徑有多完美,它也會因時間而改變。當碰撞發生時,一個單位必須尋找一條新路徑,或者,如果另一個單位正在移動並且不是正面碰撞,在繼續沿當前路徑移動之前,等待那個單位離開。這些提示大概可以讓你開始了。如果你想了解更多,這裏有些你可能會覺得有用的鏈接:

* 自治角色的指導行爲:Craig Reynold在指導能力上的工作和尋路有些不同,但是它可以和尋路整合從而形成更完整的移動和碰撞檢測系統。
*
電腦遊戲中的長短距指導:指導和尋路方面著作的一個有趣的考察。這是一個pdf文件。
*
協同單位移動:一個兩部分系列文章的第一篇,內容是關於編隊和基於分組的移動,作者是帝國時代(Age of Empires)的設計者Dave Pottinger.
*
實現協同移動:Dave Pottinger文章系列的第二篇。

2. 不同的地形損耗

在這個教程和我附帶的程序中,地形只能是二者之——可通過的和不可通過的。但是你可能會需要一些可通過的地形,但是移動耗費更高——沼澤,小山,地牢的樓梯,等等。這些都是可通過但是比平坦的開闊地移動耗費更高的地形。類似的,道路應該比自然地形移動耗費更低。

這個問題很容易解決,只要在計算任何地形的G值的時候增加地形損耗就可以了。簡單的給它增加一些額外的損耗就可以了。由於A*算法已經按照尋找最低耗費的路徑來設計,所以很容易處理這種情況。在我提供的這個簡單的例子裏,地形只有可通過和不可通過兩種,A*會找到最短,最直接的路徑。但是在地形耗費不同的場合,耗費最低的路徑也許會包含很長的移動距離——就像沿着路繞過沼澤而不是直接穿過它。

一種需額外考慮的情況是被專家稱之爲“influence mapping”的東西(暫譯爲影響映射圖)。就像上面描述的不同地形耗費一樣,你可以創建一格額外的分數系統,並把它應用到尋路的AI中。假設你有一張有大批尋路者的地圖,他們都要通過某個山區。每次電腦生成一條通過那個關口的路徑,它就會變得更擁擠。如果你願意,你可以創建一個影響映射圖對有大量屠殺事件的格子施以不利影響。這會讓計算機更傾向安全些的路徑,並且幫助它避免總是僅僅因爲路徑短(但可能更危險)而持續把隊伍和尋路者送到某一特定路徑。

另一個可能的應用是懲罰周圍移動單位路徑上的節點。A*的一個底限是,當一羣單位同時試圖尋路到接近的地點,這通常會導致路徑交疊。以爲一個或者多個單位都試圖走相同或者近似的路徑到達目的地。對其他單位已經認領了的節點增加一些懲罰會有助於你在一定程度上分離路徑,降低碰撞的可能性。然而,如果有必要,不要把那些節點看成不可通過的,因爲你仍然希望多個單位能夠一字縱隊通過擁擠的出口。同時,你只能懲罰那些臨近單位的路徑,而不是所有路徑,否則你就會得到奇怪的躲避行爲例如單位躲避路徑上其他已經不在那裏的單位。還有,你應該只懲罰路徑當前節點和隨後的節點,而不應處理已經走過並甩在身後的節點。

3. 處理未知區域

你是否玩過這樣的PC遊戲,電腦總是知道哪條路是正確的,即使它還沒有偵察過地圖?對於遊戲,尋路太好會顯得不真實。幸運的是,這是一格可以輕易解決的問題。

答案就是爲每個不同的玩家和電腦(每個玩家,而不是每個單位--那樣的話會耗費大量的內存)創建一個獨立的“knownWalkability”數組,每個數組包含玩家已經探索過的區域,以及被當作可通過區域的其他區域,直到被證實。用這種方法,單位會在路的死端徘徊並且導致錯誤的選擇直到他們在周圍找到路。一旦地圖被探索了,尋路就像往常那樣進行。

4. 平滑路徑

儘管A*提供了最短,最低代價的路徑,它無法自動提供看起來平滑的路徑。看一下我們的例子最終形成的路徑(在圖7)。最初的一步是起始格的右下方,如果這一步是直接往下的話,路徑不是會更平滑一些嗎?

有幾種方法來解決這個問題。當計算路徑的時候可以對改變方向的格子施加不利影響,對G值增加額外的數值。也可以換種方法,你可以在路徑計算完之後沿着它跑一遍,找那些用相鄰格替換會讓路徑看起來更平滑的地方。想知道完整的結果,查看Toward More RealisticPathfinding,一篇(免費,但是需要註冊)Marco Pinter發表在Gamasutra.com的文章。

5. 非方形搜索區域

在我們的例子裏,我們使用簡單的2D方形圖。你可以不使用這種方式,可以使用不規則形狀的區域。想想冒險棋的遊戲,和遊戲中那些國家。你可以設計一個像那樣的尋路關卡。爲此,你可能需要建立一個國家相鄰關係的表格,和從一個國家移動到另一個的G值;你也需要估算H值的方法。其他的事情就和例子中完全一樣了。當你需要向開啓列表中添加新元素的時候,不需使用相鄰的格子,取而代之的是從表格中尋找相鄰的國家。

類似的,你可以爲一張確定的地形圖創建路徑點系統,路徑點一般是路上,或者地牢通道的轉折點。作爲遊戲設計者,你可以預設這些路徑點。兩個路徑點被認爲是相鄰的如果他們之間的直線上沒有障礙的話。在冒險棋的例子裏,你可以保存這些相鄰信息在某個表格裏,當需要在開啓列表中添加元素的時候使用它。然後你就可以記錄關聯的G值(可能使用兩點間的直線距離),H值(可以使用到目標點的直線距離),其他都按原先的做就可以了。Amit Patel 寫了其他方法的摘要。另一個在非方形區域搜索RPG地圖的例子,查看我的文章Two-Tiered A* Pathfinding

6. 一些速度方面的提示

當你開發你自己的A*程序,或者改寫我的,你會發現尋路佔據了大量的CPU時間,尤其是在大地圖上有大量對象在尋路的時候。如果你閱讀過網上的其他材料,你會明白,即使是開發了星際爭霸或帝國時代的專家,這也無可奈何。如果你覺得尋路太過緩慢,這裏有一些建議也許有效:

* 使用更小的地圖或者更少的尋路者。

* 不要同時給多個對象尋路。取而代之的是把他們加入一個隊列,把尋路過程分散在幾個遊戲週期中。如果你的遊戲以40週期每秒的速度運行,沒人能察覺。但是當大量尋路者計算自己路徑的時候,他們會發覺遊戲速度突然變慢。

* 儘量使用更大的地圖網格。這降低了尋路中搜索的總網格數。如果你有志氣,你可以設計兩個或者更多尋路系統以便使用在不同場合,取決於路徑的長度。這也正是專業人士的做法,用大的區域計算長的路徑,然後在接近目標的時候切換到使用小格子/區域的精細尋路。如果你對這個觀點感興趣,查閱我的文章Two-Tiered A* Pathfinding

* 使用路徑點系統計算長路徑,或者預先計算好路徑並加入到遊戲中。

* 預處理你的地圖,表明地圖中哪些區域是不可到達的。我把這些區域稱作孤島。事實上,他們可以是島嶼或其他被牆壁包圍等無法到達的任意區域。A*的下限是,當你告訴它要尋找通往那些區域的路徑時,它會搜索整個地圖,直到所有可到達的方格/節點都被通過開啓列表和關閉列表的計算。這會浪費大量的CPU時間。可以通過預先確定這些區域(比如通過flood-fill或類似的方法)來避免這種情況的發生,用某些種類的數組記錄這些信息,在開始尋路前檢查它。

* 在一個擁擠的類似迷宮的場合,把不能連通的節點看作死端。這些區域可以在地圖編輯器中預先手動指定,或者如果你有雄心壯志,開發一個自動識別這些區域的算法。給定死端的所有節點可以被賦予一個唯一的標誌數字。然後你就可以在尋路過程中安全的忽略所有死端,只有當起點或者終點恰好在死端的某個節點的時候才需要考慮它們。

7. 維護開啓列表

這是A*尋路算法最重要的組成部分。每次你訪問開啓列表,你都需要尋找F值最低的方格。有幾種不同的方法實現這一點。你可以把路徑元素隨意保存,當需要尋找F值最低的元素的時候,遍歷開啓列表。這很簡單,但是太慢了,尤其是對長路徑來說。這可以通過維護一格排好序的列表來改善,每次尋找F值最低的方格只需要選取列表的首元素。當我自己實現的時候,這種方法是我的首選。在小地圖中這種方法工作得很好,但它並不是最快的解決方案。更苛求速度的A*程序員使用叫做二叉堆的方法,這也是我在代碼中使用的方法。憑我的經驗,這種方法在大多數場合會快23倍,並且在長路經上速度呈幾何級數提升(10倍以上速度)。如果你想了解更多關於二叉堆的內容,查閱我的文章,Using Binary Heaps in A*Pathfinding

另一個可能的瓶頸是你在多次尋路之間清除和保存你的數據結構的方法。我個人更傾向把所有東西都存儲在數組裏面。雖然節點可以以面向對象的風格被動態的產生,記錄和保存,我發現創建和刪除對象所增加的大量時間,以及多餘的管理層次減慢的整個過程的速度。但是,如果你使用數組,你需要在調用之間清理數據。這中情形你想做的最後一件事就是在尋路調用之後花點時間把一切歸零,尤其是你的地圖很大的時候。

我通過使用一個叫做whichList(x,y)的二維數組避免這種開銷,數組的每個元素表明了節點在開啓列表還是在關閉列表中。嘗試尋路之後,我沒有清零這個數組。取而代之的是,我在新的尋路中重置onClosedListonOpenList的數值,每次尋路兩個都+5或者類似其他數值。這種方法,算法可以安全的跳過前面尋路留下的髒數據。我還在數組中儲存了諸如F,GH的值。這樣一來,我只需簡單的重寫任何已經存在的值而無需被清除數組的操作干擾。將數據存儲在多維數組中需要更多內存,所以這裏需要權衡利弊。最後,你應該使用你最得心應手的方法。

8. Dijkstra的算法

儘管A*被認爲是通常最好的尋路算法(看前面的題外話”),還是有一種另外的算法有它的可取之處-Dijkstra算法。Dijkstra算法和A*本質是相同的,只有一點不同,就是Dijkstra算法沒有啓發式(H值總是0)由於沒有啓發式,它在各個方向上平均搜索。正如你所預料,由於Dijkstra算法在找到目標前通常會探索更大的區域,所以一般會比A*更慢一些。

那麼爲什麼要使用這種算法呢?因爲有時候我們並不知道目標的位置。比如說你有一個資源採集單位,需要獲取某種類型的資源若干。它可能知道幾個資源區域,但是它想去最近的那個。這種情況,Dijkstra算法就比A*更適合,因爲我們不知道哪個更近。用A*,我們唯一的選擇是依次對每個目標尋路並計算距離,然後選擇最近的路徑。我們尋找的目標可能會有不計其數的位置,我們只想找其中最近的,而我們並不知道它在哪裏,或者不知道哪個是最近的。

進一步的閱讀

好,現在你對一些進一步的觀點有了初步認識。這時,我建議你研究我的源代碼。包裏面包含兩個版本,一個是用C++寫的,另一個用Blitz Basic。順便說一句,兩個版本都註釋詳盡,容易閱讀,這裏是鏈接。

* 例子代碼: A* Pathfinder (2D) Version 1.9

如果你既不用C++也不用Blitz Basic,C++版本里有兩個小的可執行文件。Blitz Basic可以在從BlitzBasic網站免費下載的Blitz Basic 3D(不是Blitz Plus)演示版上運行。Ben O'Neill提供一個聯機演示可以在這裏找到。你也該看看以下的網頁。讀了這篇教程後,他們應該變得容易理解多了。

* Amit A* 頁面:這是由Amit Patel製作,被廣泛引用的頁面,如果你沒有事先讀這篇文章,可能會有點難以理解。值得一看。尤其要看Amit關於這個問題的自己的看法。

* Smart Moves:智能尋路:Bryan Stout發表在Gamasutra.com的這篇文章需要註冊才能閱讀。註冊是免費的而且比起這篇文章和網站的其他資源,是非常物有所值的。BryanDelphi寫的程序幫助我學習A*,也是我的A*代碼的靈感之源。它還描述了A*的幾種變化。

* 地形分析:這是一格高階,但是有趣的話題,Dave Pottinge撰寫,Ensemble Studios的專家。這傢伙參與了帝國時代和君王時代的開發。別指望看懂這裏所有的東西,但是這是篇有趣的文章也許會讓你產生自己的想法。它包含一些對mip-mappinginfluence mapping以及其他一些高級AI/尋路觀點。對"flood filling"的討論使我有了我自己的死端孤島的代碼的靈感,這些包含在我Blitz版本的代碼中。其他一些值得一看的網站:

* aiGuru: Pathfinding
* Game AI Resource: Pathfinding
* GameDev.net: Pathfinding

2.3 A*算法中使用二叉堆

遊戲AI之旅——A*尋路中使用二叉堆。原文鏈接:http://www.policyalmanac.org/games/binaryHeaps.htm

這篇文章是文章“A* Pathfinding forBeginners.”的補充。在讀這篇文章之前,你應該先讀那一篇文章,或者對A*做透徹的理解。

A*算法中最緩慢的部分就是在開啓列表中尋找F值最低的節點或者方格。取決於地圖的大小,你可能有十幾,成百甚至上千的節點需要在某個時候使用A*搜索。無需多講,反覆搜索這麼大的列表會嚴重拖慢整個過程。然而,這些時間在極大程度上受你存儲列表的方式影響。

有序和無序的開啓列表。最簡單的方法就是順序存儲每個節點,然後每次需要提取最低耗費元素的時候都遍歷整個列表。這提供可快速的插入速度,但是移除速度可能是最慢的,因爲你需要檢查每個元素才能夠確定哪個纔是F值最低的。

通常你可以保持你列表處於有序狀態來提升效率。這花費了稍微多一點的預處理時間,因爲你每次插入新元素都必須把他們放在恰當的位置。不過移除元素倒是很快。你只要移除第一個元素就可以了,它一定是F值最低的。有很多方法可以保持你的數據有序(選擇排序,冒泡排序,快速排序,等等)並且你可以用你最熟悉的搜索引擎找到這方面的文章。不過我們至少可以先提幾種想法。最簡單的方法可能是,當你需要添加新元素的時候,從列表開始的地方,依次比較每個元素的F值和要插入的F值的大小。一旦找到一個相等或者更高的F值,你就可以把新元素插入到列表中那個元素的前面。取決於你使用的計算機於亞,使用class或者struct實現的鏈表可能是不錯的方法。這種方法可以通過保持列表中所有元素的平均值來得到改進,使用這個平均值來決定是從頭(如上所說)還是從尾開始處理。總的說來,比平均F值低的新元素將被從頭開始處理,而比平均F值高的則從末尾開始。這種方法可以節省一半的時間。複雜一些,但是更快的方法是把這一想法提高到新的層次使用快速排序,它基本上是從比較新元素和列表中間元素的F值開始。如果新元素的F值低,你接着把它和1/4處元素進行比較,如果還是更低你就比較它和1/8處的元素,如此這般,不斷的折半你的列表並且比較,直到找到合適的位置。這個描述很簡單,你可能會想到網上尋找快速排序的更多資料。這比至此描述的任何方法都快。

二叉堆

二叉堆和剛纔說的快速排序很像,經常被那些苛求A*速度的人使用。根據我的經驗,二叉堆平均提高尋路速度2-3倍,對於包含大量節點的地圖(也就是說100×100節點或者更多)效果更明顯。友情提醒,然而二叉堆很難處理,除非你使用含有大量節點的地圖,速度至關重要,否則不值得爲它頭痛。文章其他的部分深入說明了二叉堆和它在A*算法中的用途。如果你對我的文章存有疑惑,在文章末尾進一步閱讀的小節中提供了更多的觀點。仍然有興趣?好,我們繼續。。。

在有序列表中,每個元素都按照由低到高或由高到低的順序保存在恰當的位置。這很有用,但是還不夠。事實上,我們並不關心數字127是否比128在更低的位置上。我們只是想讓F值最低的元素能放在列表頂端以便容易訪問。列表的其他部分即使是混亂的也不必在意。列表的其他部分只有在我們需要另一個F值最低的元素的時候,纔有必要保持有序。基本上,我們真正需要的是一個,確切的說,是個二叉堆。二叉堆是一組元素,其中最大或者最小(取決於需要)的元素在堆頂端。既然我們要尋找F值最小的元素,我們就把它放在堆頂端。這個元素有兩個子節點,每個的F值等於,或者略高於這個元素。每個子節點又有兩個子節點,他們又有和他們相等或略高的子節點。。。依次類推。這裏是一個堆可能的樣子:

注意,F值最低的元素(10)在最頂端,第二低的元素(20)是它的一個子節點。可是,其後就沒有任何疑問了。在這個特定的二叉堆裏,第三低的元素是24,它離堆頂有兩步的距離,它比30小,但是30卻在左側離堆頂一步之遙的地方。簡單的堆放,其他的元素在堆的哪個位置並不重要,每個單獨的元素只需要和它的父節點相等或者更高,而和它的兩個子節點相比,更低或者相等,這就可以了。這些條件在這裏都完全符合,所以這是個有效的二叉堆。

很好,你可能會想,這的確有趣,但是如何把它付諸實施呢?嗯,關於二叉堆的一個有趣的事實是,你可以簡單的把它存儲在一個一維數組中。在這個數組中,堆頂端的元素應該是數組的第一個元素(是下標

1而不是0)。兩個子節點會在23的位置。這兩個節點的4個子節點應該在47的位置。

總的來說,任何元素的兩個子節點可以通過把當前元素的位置乘以2(得到第一個子節點)和乘21(得到第二個子節點)來得到。就這樣,例如堆中第三個元素(數值是20)的兩個子節點,可以在位置2*3 = 62*3 +1 = 7這兩個位置找到。那兩個位置上的數字非別是3024,當你查看堆的時候就能理解。

你其實不必要知道這些,除了表明堆中沒有斷層之外知道這些沒有任何價值。7個元素,就完整的填滿了一個三層堆的每一層。然而這並不是必要的。爲了讓我們的堆有效,我們只需要填充最底層之上的每一行。最底層自身可以是任意數值的元素,同時,新的元素按照從左到右的順序添加。這篇文章描述的方法就是這樣做的,所以你不必多慮。

往堆中添加新元素

當我們實際在尋路算法中使用二叉堆的時候,還需要考慮更多,但是現在我們只是學習一下如何使用二叉堆。我跳過這部分以便更容易理解基本的東西。我會在文章後面的部分給出處理這一切的完整公式,但瞭解這些細節仍然十分重要。大致的,爲了往堆裏添加元素,我們把它放在數組的末尾。然後和它在當前位置/2 處的父節點比較,分數部分被圓整。如果新元素的F值更低,我們就交換這兩個元素。然後我們比較這個元素和它的新父節點,在(當前位置)/2 ,小數部分圓整,的地方。如果它的F值更低,我們再次交換。我們重複這個過程直到這個元素不再比它的父節點低,或者這個元素已經到達頂端,處於數組的位置1

我們來看如何把一個F值爲17的元素添加到已經存在的堆中。我們的堆裏現在有7個元素,新元素將被添加到第8個位置。這就是堆看起來的樣子,新元素被加了下劃線。

10 30 20 34 38 30 24 17

接下來我們比較它和它的父節點,在 8/2 也就是 4的位置上。位置4當前元素的F值是34。既然1734低,我們交換兩元素的位置。現在我們的堆看起來是這樣的:

10 30 20 17 38 30 24 34

然後我們把它和新的父節點比較。因爲我們在位置4,我們就把它和 4/2 = 2 這個位置上的元素比較。那個元素的F值是30。因爲1730低,我們再次交換,現在堆看起來是這樣的:

10 17 20 30 38 30 24 34

接着我們比較它和新的父節點。現在我們在第二個位置,我們把它和 2/2 = 1,也就是堆頂端的比較。這次,17不比10更低,我們停止,堆保持成現在的樣子。

從堆中刪除元素

從堆中刪除元素是個類似的過程,但是差不多是反過來的。首先,我們刪除位置1的元素,現在它空了。然後,我們取堆的最後一個元素,移動到位置1。在堆中,這是結束的條件。以前的末元素被加了下劃線。

34 17 20 30 38 30 24

然後我們比較它和兩個子節點,它們分別在位置(當前位置*2)(當前位置* 2 + 1)。如果它比兩個子節點的F值都低,就保持原位。反之,就把它和較低的子節點交換。那麼,在這裏,該元素的兩個子節點的位置在 1 * 2 = 2 1*2 + 1 = 3。顯然,34不比任何一個子節點低,所以我們把它和較低的子節點,也就是17,交換。結果看起來是這樣:

17 34 20 30 38 30 24

接着我們把它和新的子節點比較,它們在 2*2 = 4,和2*2 + 1 = 5的位置上。它不比任何一個子節點低,所以我們把它和較低的一個子節點交換(位置4上的30)。現在是這樣:

17 30 20 34 38 30 24

最後一次,我們比較它和新的子節點。照例,子節點在位置 4*2 = 84*2+1 = 9的位置上。但是那些位置上並沒有元素,因爲列表沒那麼長。我們已經到達了堆的底端,所以我們停下來。

二叉堆爲什麼這麼快?

現在你知道了堆基本的插入和刪除方法,你應該明白爲什麼它比其他方法,比如說插入排序更快。假設你有個有1000個節點的開啓列表,在一格有很多節點的相當大的地圖上,這不是不可能(記住,即使是100×100的地圖,上面也有10,000個節點)。如果你使用插入排序,從起點開始,到找到新元素恰當的位置,在把新元素插入之前,平均需要做500次比較。

使用二叉堆,你從底端開始,可能只要13次比較就能把新元素插入到正確的位置。你還需要9次比較用來從開啓列表中移除一個元素,同時保持堆仍然有序。在A*中,你通常每次只需要移除一個元素(F值最低的元素),在任意位置添加05個新節點(就像主文章裏描述的2D尋路)。這總共花費的時間大約是同樣數量節點進行插入排序的1%。差別隨你地圖的增大(也就是節點更多)呈幾何增長。地圖越小,就越沒優勢,這也是爲什麼你的地圖和節點越少,二叉堆的價值就越低的原因。順便,使用二叉堆並不意味着你的尋路算法會快100倍。在下面還講了一些棘手的問題。額外的,A*不僅僅是爲開啓列表排序。然而,根據我的經驗,用二叉堆在大部分場合可以提高23倍的速度,更長的路徑,速度提高的更多。

創建開啓列表數組

現在我們瞭解了二叉堆,那麼如何使用它呢?首先要做的是構造我們的一維數組。爲此,我們先要確定它的大小。一般來說,列表大小不會超過地圖上的節點總數(在最壞的情況下,我們搜索整個地圖尋找路徑)。在一個方形二維地圖中,就如我的主文章中描述的,我們的節點不超過地圖寬 × 地圖高。那麼我們的一維數組就是那個大小。在這個例子裏,我們叫這個數組 openList()堆最頂端的元素被存儲在openList(1),第二個元素在openList(2),依此類推。

使用指針

現在我們有了正確大小的數組,幾乎可以開始用來尋路了。不過,在進一步的添加或者刪除操作之前,我們再看看原始的堆。

現在,它只是個F值的列表,而且已經被正確安排。但是我們忽略了一個重要的元素。是的,我們有一系列的F值按順序保存在堆裏,但是我們沒有他們代表哪一格的任何線索。基本上,我們只是知道10是堆中最低的F值。但那指的是那個格子?爲了解決這個問題,我們必須改變數組中元素的值。我們不儲存排序好的F值,取而代之的是保存關聯到地圖網格的唯一標誌。我的方法是爲每個新加入堆的元素創建一個唯一ID叫做squaresChecked。每次往開啓列表中添加新元素,我們給squaresChecked增加1,並把它作爲列表中新元素的唯一ID。第一個添加進列表的是#1,第二個是#2,依此類推。最後,我們把具體的F值存儲在單獨的一維數組中,我把它叫做 Dcost()和開啓列表相同,我們把它的大小定爲(地圖寬 x 地圖高)。我們同時存儲節點的xy座標在類似的一維數組中,叫做 openX() openY()。看起來就像下面的圖:

儘管這看起來有點複雜,但它和前面講的堆是相同的。只是儲存的信息更多了。

#5元素,有最低的F10,仍然在堆頂,在一維數組的第一列。不過現在我們在堆裏存儲它的唯一ID 5,而不是它的F值。換句話說,openList(1) = 5。這個唯一數值用來查找元素的F值,以及地圖xy座標。這個元素的F值是Fcost(5) = 10x座標是openX(5) = 12y座標是openY(5) = 22。頂端的元素有兩個子節點,數值是26,他們的F值是3020,分別存儲在opneList()23的位置,等等。基本上,我們的堆和以前相同,只是多了一些關於元素在地圖上的位置,F值是多少,等等的信息。

在堆中添加新元素(第二部分)

好,我們實際的把這種技術用在A*尋路的開啓列表排序中。我們使用的技術和先前描述的大體相同。

我們添加到開啓列表中的第一個元素,一般是起始節點,被分配了一個數值是1的唯一ID,然後放入開啓列表的#1位置,也就是 openList(1) = 1。我們還跟蹤開啓列表中元素的數量,現在也是1。我們把它保存在名爲numberOfOpenListItems的變量裏。

當我們往開啓列表中添加新元素的時候,首先我們計算GHF值,就如在主文章中所述。然後按照前面講的方法把他們添加進開啓列表。首先我們給新元素分配一格唯一 ID號,也就是squaresChecked變量的用途。每次我們添加一個新節點,就給這個變量加1,然後把它的數值分配給新節點。然後給numberOfOpenListItems1。然後把它添加到開啓列表的底部,所有這些可以翻譯成:

squaresChecked = squaresChecked +1

numberOfOpenListItems =numberOfOpenListItems+1

openList(numberOfOpenListItems) =squaresChecked

然後我們依次把它和父節點比較直到它到達正確的位置。這是這些操作的代碼:

m = numberOfOpenListItems

While m <> 1 ;

While item hasn't bubbled to the top(m=1);

Check if child is <= parent. If so,swap them.
If
Fcost(openList(m)) <=Fcost(openList(m/2))Then
temp = openList(m/2)

openList(m/2)= openList(m)

openList(m) = temp

m= m/2

Else

Exit ;exit the while/wend loop

End If

Wend

從堆中刪除元素(第二部分)

無疑,我們不能只建立堆,當不需要的時候,我們也要從堆中刪除元素。特別的,在A*尋路中,我們在檢查和切換到關閉列表之後,從堆頂需要刪除F值最低的元素。如前所述,你從把末元素移動到堆頂開始,然後把堆中的元素總數減1。僞代碼是這樣:

openList(1) = openList(numberOfOpenListItems)

numberOfOpenListItems =numberOfOpenListItems – 1

接着我們需要依次比較它和兩個子節點的數值。如果它的F值更高,我們就把它和更低F值的子節點交換。然後我們把它和新的子節點比較(看它是否更低)。如果它的F值比兩個子節點更高,我們把它和較低的一個交換。我們重複這個過程直到找到它的正確位置,這可能會一直持續到堆底,但並不是完全必要。僞代碼看起來是這樣:

v = 1;

Repeat the following until the item sinksto its proper spot in the binary heap.

Repeat

u = v

If 2*u+1 <= numberOfOpenListItems ;

if both children exist; Select the lowestof the two children.

If Fcost(openList(u)) >=Fcost(openList(2*u)) then v = 2*u ;SEE NOTE BELOW

If Fcost(openList(v)) >=Fcost(openList(2*u+1))then v = 2*u+1 ;SEE NOTE BELOW

Else If 2*u <= numberOfOpenListItems ;

if only child #1 exists; Check if the Fcost is greater than the child

If Fcost(openList(u)) >=Fcost(openList(2*u))then v = 2*u

End If

If u <> v then ;

If parent's F > one or both of itschildren, swap them

temp = openList(u)

openList(u)= openList(v)

openList(v) = temp

Else

Exit ;if item <= both children, exitrepeat/forever loop

End if

Forever ;Repeat forever

請注意兩行代碼中粗體(紅色)uv的數值。在第二行,你應該使用 v而不是u,這不是很顯而易見。這確保了你把它和較低的子節點交換。如果做錯會造成不完整的堆,完全打亂你的尋路。

對開啓列表的元素重排序

就如在主文章中描述的,有時候你會發現現有的開啓列表中的元素會改變。這種情況發生的時候,我們不必要取出這個元素重新來過。只要從當前位置開始,用它新的(更低的)F值和它的父節點比較。如果它的F值低到足以替換它的父節點,你就把它替換掉(不然你就會得到一個錯誤的堆,一切都完了)。一般,你使用和在堆中添加新元素的小節中相同的代碼。不幸的是,因爲你的數據是在一個龐大,無序的堆裏,你需要遍歷整個堆查找先有開啓列表中的元素。你主要要查找由openX(openList()) openY(openList())獲取的確切座標的格子,找到之後,你就可以從那一點開始,像往常那樣做必要的比較和交換。

最後的註解

好了,我希望你仍然能讀懂,沒有被搞昏頭。如果你不着急,並且希望在自己的尋路算法中使用二叉堆,那麼這就是我的建議。首先,把二叉堆放一放。把注意力放在你的A*尋路算法上,使用簡單點的排序算法,保證算法正常工作沒有bug。一開始你並不需要它很快速,你只需要它能工作;其次,在你把二叉堆添加到代碼中之前,試着把二叉堆寫成獨立的功能,用適當的方法添加和刪除元素。確保你寫的程序中,可以看到整個過程中每一步的操作,也許可以把結果打印在屏幕上,如果你願意。你還得包含一些中途退出的代碼,以便在必要的時候結束整個流程。如果二叉堆寫的不對,你很容易就會陷入到無限循環中。一旦你確信兩個程序都運行無誤,備份他們,然後開始把他們結合起來。除非你比我還聰明,否則一開始你難免遇到麻煩。輸出都是些錯誤信息,或者看到尋路者因爲bug走出各種怪異的方向。不過最終你會搞定一切。

2.4 A*分層尋路

在我的主題A* Path finding for Beginners中中,我概述了A*算法,說明了如何創建一個通用的尋路功能。然而僅創建一個尋路功能,用途是很有限的。考慮如下的RPG場景,一個劍士想找到繞過旁邊牆壁的路:

對於這類地圖,你可以用不同的方式,密度來放置節點。在這個例子中,我們使用高密度網格,如下:

在這個圖中,白色節點是可通過的。沒有節點的地方是不可通過的。這個例子還有一些不同的地方。在這個例子中,你可以穿越不可通過區域的拐角。同時,方格也不再是方格。因爲這是一個投影視角的例子,我們在Y(垂直方向)擁有X(水平方向)兩倍的節點。這使得正確的投影路徑成爲可能。如你所見,用這麼密集的節點網絡,我們不僅能尋路繞過旁邊的牆壁,還能在牆壁和旁邊的桶之間找到路徑。我們密集的網格也使得繞過底部不可通過的火把成爲可能。這總的說來是精確尋路

好,在短距離上,那的確很酷,但是如果我們要穿越整個地圖進行尋路,該怎麼辦?使用像這麼密的網格,輕易就會讓你在一條路徑上使用A*的搜索節點的數量達到10,000+。這幾乎會讓任何PC失去響應。那麼我們來看另一種選擇。創建一個低密度的網格,就像下面這樣,如何?


在這個例子裏,節點在等邊菱形的中心。菱形之間通過性的數據沿邊界存儲在同一個數組中,在圖片上用小紅點表示。要從一個圖素移動到它周圍8個圖素上,相鄰的圖素必須是可通過的,並且路徑沒有被擋住。

這個節點網格的密度是先前的72分之一。原來我們需要面對10,000+的節點,而現在,取而代之的是1/72原來的數量,或者說,少於200個。我們的電腦可以輕易處理這個。穿越地圖尋路的問題迎刃而解。

合而爲一

那麼,我們用哪種方法呢?兩者!在給定的搜索中,你得先在地圖範圍內,用宏觀尋路的方法。然後切換到微觀尋路,尋找從當前位置到路徑上兩個宏觀節點以外的路徑。每次你進入一個宏觀菱形方格,你應該用微觀尋路算法尋路到兩個宏觀節點之前。這導致浪費了每次微觀尋路的後半截路徑,但是你有必要這樣做以便路徑看起來足夠好,並且這也不是很嚴重的浪費,因爲你的微觀尋路算法只計算了相關的一小段路徑。一旦你到達了距離目標2-3宏觀節點的地方,你應該完全切換到微觀尋路算法,完成到最終位置的尋路。用這種方法,你將得到更接近現實的快速穿越地圖的尋路以及能穿越角落,木桶等等就像最開始那個微觀尋路例子的效果。有夠酷,哈!

如果你真的有野心,你可以開發3種尋路器,如果你的地圖實在太大,一個工作在真正的宏觀層次,一個在中間層,一個在微觀層。專家已經爲像帝國時代(Age of Empires)這樣的遊戲設計了這些。這僅僅取決於你的需要。

發佈了22 篇原創文章 · 獲贊 5 · 訪問量 6萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章