假設地圖上有一片樹林,坦克需要繞過樹林,走到另一側的軍事基地,在無數條行進路線中,哪條才是最短的?

這是典型的最短尋徑問題,可以使用A*算法求解。A*搜索算法俗稱A星算法,是一個被廣泛應用於路徑優化領域的算法,它的行為的能力基於啟發式代價函數,在游戲的尋路中非常有用。
將地圖表格化
A*算法的第一個步是將地圖表格化,具體來說是用一個大型的二維列表存儲地圖數據。這有點類似於像素畫:

畫中的小狗是由一個個像素方格組成的,方格越小,圖案越平滑。在坦克尋徑問題中,坦克的個頭遠小於地圖,因此我們把坦克作為一個像素,這樣一來,地圖就可以切分為一個個方格,其中S代表坦克的起點,E代表基地:

我們把地圖映射到二維列表上,每一方格都可以用唯一的二元組表示,元組的第一個維度是行號,第二個是列號,起點和終點的坐標分別是(3,2)和(5,7)。“找到坦克的最短路徑”實際是在回答最短路徑需要經過那些方格。
評估函數
A*算法的核心是一個評估函數:F(n)=H(n)+G(n)。
H(n)是距離評估函數,n代表地圖上的某一個方格,H(n)的值是該方格到終點的距離。距離的計算方式有很多,選擇不同的方式,計算的結果也不同:

假設每個方格的邊長都是1,如果用歐幾里德距離S到計算S到E的距離,則:

如果用曼哈頓距離計算,則:

G(n)是從起點移動到n的代價函數,n離起點越遠,付出的代價越高。起點達到n的路線有多條,每條路線的G值可能不同:

坦克從S到T的路線有兩條,S→A→B→C→T和S→D→T,第二條路線更短,付出的代價也更低。假設從一個方格移動到相鄰方格的代價是1,則G(D)=G(A)=1。B的前一步是A,因此G(B)=G(A)+1=2。同理,G(C)=G(B)+1=3。對於G(T)來說,它的值取決於T的上一步,如果路線是S→A→B→C→T,則G(T)=G(C)+1=4;如果路線是S→D→T,則G(T)=G(D)+1=2。值得注意的是,代價函數並不是唯一的,具體如何定義,完全取決於你自己。
某個位置的評估函數F僅僅是將該點的距離估值和代價值加起來。A*搜索的每一個尋徑都會尋找評估值最小的點。
A*搜索的步驟
A*搜索涉及到兩個重要的列表,openList(開放列表,存儲候選節點)和closeList(關閉列表,存儲已經走過的節點)。算法先把起放入openList中,然后重復下面步驟:
1. 遍歷openList,找到F值最小的那個作為當前所在節點,用P表示;
2. 把P加入closeList中,作為已經走過的節點;
3. 探索P周圍相鄰且不在closeList中的每一個節點,記算它們的H值、G值和F值,並把P設置為這些方格的父節點,將這些節點作為待探索節點添加到Q中。當然,如何定義“相鄰”也是你說的算。
4. 如果Q中的節點不在openList中,將其加入到openList。Q中的節點已經存在於openList中,比較這些節點的F值和它們在openList中的F值哪個更小(F越小說明這條路徑越短),如果openList中的F值更小或二者相等,不做任何改變,否則用Q中的節點替換掉openList中的節點。
5. 如果終點在openList中,退出,最短路徑就是從終點開始,沿着父節點移動直至起點;如果openList是空的,退出,此時意味着起點到終點沒有任何路可走。
似乎不那么直觀,我們仍然以坦克移動的例子審視這個過程。
通向基地的最短路線
在游戲開始之氣,先要制定一些游戲規則。
坦克可每一步都可以移動到與之相鄰的八個方格中,我們指定每一個方格的邊長是10,從一個方格移動到相鄰方格的代價是這兩個方格中心點的距離。如此一來,坦克上、下、左、右平移一格所花費的代價是10(這里之所以將邊長定義為10而不是1,目的是為了避免向斜對角移動時產生小數),向斜對角移動的代價是
:

下一步定義相鄰的方格是否能夠探索。如果坦克的相鄰方格是障礙物,那么坦克無法移動到障礙物上,也無法貼着障礙物移動到斜對角的方格

不能移動到×所在的方格
探索最短路線
定義了游戲規則后就可以開始移動坦克。
我們定義地圖是一個8×8的小地圖,使用曼哈頓距離作為距離評估函數。以探索起點正上方的方格為例,它的位置是(4,2),到起點的代價是G=10。
對於任意方格到終點的距離,我們不考慮障礙物,僅僅是簡單的根據曼哈頓距離的公式計算。起點到終點的距離:
H(n) = H(4,2) = (|4 - 5| + |2 - 7|) * 10 = 60
這里乘以了系數10,這是由於我們在游戲規則中定義了方格的單位長度是10。
這有點類似於手機導航中的紅色連線,這條連線僅僅連接了車標和終點,並不考慮中間是否有阻礙物:

起點的G值是0,F=G+H=70。在待探索的八個方格中,我們設置從上到下的三個數值分別代表G、H、F,使用一個箭頭指向是它的parent,箭頭的指向不同,G值也可能不同:

將S周圍的方格設置為待探索方格
由於openList是空的,所以把 Q 中的8個待探索節點都放入openList中。此時的openList中,F(4,3) 最小,因此選擇(4,3)作為下一個到達的位置,並把它從openList移至closeList

有八個方格與(4,3)相鄰,其中(3,2)已經在closeList中,將它排除,(5,4)是障礙物,也排除,現在還剩六個,把它們都放入Q中:

將(4,3)相鄰的可探索方格放到Q中
在Q的六個點中,(5,2),(5,3),(4,4),(3,4)是第一次探索,直接加入到openList中;(4,2),(3,3)已經存在於openList中,表示二者曾經被探索過。由於是從(4,3)探索(4,2)和(3,3),因此二者的G值與從S點探索時的G值不同,即GQ(4,2)≠GopenList(4,2),GQ(3,3)≠GopenList(3,3),並且它們的父節點也不同。很明顯,對於從S到(4,2)的兩條路徑來說,S→(4,3) →(4,2)要比S→ (4,2)更長,移動的代價更高,即GQ(4,2)> GopenList(4,2);同理,GQ(3,3)>GopenList(3,3)。此時保留(4,2)和(3,3)在openList中的的數值和箭頭指向:

保持openList中的(4,2)和(3,3)不變
現在,openList中(5,3)和(4,4)的F值都是64,選擇哪個都無所謂,這完全取決你自己制定的選取規則。這里我們用“胡亂選一個”的規則選擇了(4,4)作為下一個目的地。與(4,4)相鄰的八個方格中,四個是障礙物,一個在closeList中,還剩下(5,3),(3,3),(3,4)。根據游戲的規則,坦克無法“貼着障礙物移動到斜對角的方格”,因此(5,3)也要從待探索方格中去掉:

從(4,4)出發,可探索(3,3)和(3,4)
Q中的F(3,3)和F(3,4)都大於OpenList中的F(3,3)和F(3,4),因此保留openList的元素不變:

保留openList的(3,3)和(3,4)
現在,openList的最小F值是F(5,3)=64,而(5,3)並不在Q中,說明對於路徑S→(4,3)→(4,4)的探索失敗了,但這並不妨礙我們從openList中挑選最小值F(5,3)=64。根據游戲規則,(5,3)周圍有4個可供探索的方格:

從(5,3)出發,可探索(4,2), (5,2), (6,2), (6,3)
類似地,Q中的F(4,2)和F(5,2)都小於openList中的F(4,2)和F(5,2),因此保持openList中的元素不變,將Q中的另外兩個元素(6,2)和(6,3)移至openList中:

保持openList中的(4,2)和(5,2)不變,添加(6,2)和(6,2)
在openList中,(4,2)是最佳選擇,而(4,2)並沒有指向(5,3),說明通過S→(4,3)→(5,3)並不能產生最佳路徑。
這個結論不妨礙繼續執行A*搜索,再一次從openList中選擇F值最小的元素(4,2)繼續探索

從(4,2)出發,可探索(3,1),(4,1),(5,1),(5,2)
在這一次探索中,Q中的最小F值F(5,2)=70已經小於openList中的F(5,2)=78,因此用Q中的(5,2)替換openList中的(5,2),這將重新改變(5,2)的評估值和父節點:

用Q中的(5,2)替換openList中的(5,2)
接下來從openLIst中選擇(5,2)作為出發點,它周圍可探索(4,1),(5,1),(6,1),(6,2),(6,3)這5個方格:

從(5,2)出發,可探索(4,1),(5,1),(6,1),(6,2),(6,3)
這次openLIst中的最小F值是F(3,3)=70。選擇(3,3)后將會繼續選擇(3,4),此時我們將又一次面對openLIst中有多個最小F值相等的情況:

openList中多個最小F值相等,F(4,1)=F(5,1) = F(6,3)=F(2,4)=F(2,3) =84
無論選擇哪一個,最終都將得到同樣的最短路徑,假設(6,3)是這幾個方格中最后選擇的,則最終的結果:

從終點開始向前遍歷,可以發現A*算法找到的最短路徑是S→(4,3) →(5,3) →(6,3) →(6,4) →(6,5) →(6,6) →E。
可以看出,A*搜索和廣度優先搜索十分類似,二者的候選集相同,它們的主要區別在於,廣度優先搜索的選擇是盲目的,而A*搜索是優先選擇出代價最小的那個,利用啟發的方式,使得每一步都更接近於最優解。
構建數據模型
地圖上的每一個方格都是一個節點,我們將節點信息映射為Node類:
class Node: def __init__(self, x, y, parent, g=0, h=0): self.x = x # 節點的行號 self.y = y # 節點的列號 self.h = h self.g = g self.f = g + h self.parent = parent # 父節點 def get_G(self): ''' 當前節點到起點的代價 :param parent: :return: ''' if self.g != 0: return self.g elif self.parent is None: self.g = 0 # 當前節點在parent的垂直或水平方向 elif self.parent.x == self.x or self.parent.y == self.y: self.g = self.parent.get_G() + 10 # 當前節點在parent的斜對角 else: self.g = self.parent.get_G() + 14 return self.g def get_H(self, end): ''' 節點到終點的距離估值 :param end: 終點坐標(x,y) :return: ''' if self.h == 0: self.h = self.manhattan(self.x, self.y, end[0], end[1]) * 10 return self.h def get_F(self, end): ''' 節點的評估值 :param: end 終點坐標 :return: ''' if self.f == 0: self.f = self.get_G() + self.get_H(end) return self.f def manhattan(self, from_x, from_y, to_x, to_y): ''' 曼哈頓距離 ''' return abs(to_x - from_x) + abs(to_y - from_y)
每個節點都能夠計算出自己的G值、H值和F值。在get_G()中,計算G值需要使用parent.get_G(),這是一種遞歸調用,為了避免遞歸的無用功,如果當前節點的G值已經計算過了,get_G()將直接返回結果。
接下來可以編寫坦克尋徑的代碼,先來看一些基礎結構:
class Tank_way: ''' 使用A*搜索找到坦克的最短移動路徑 ''' def __init__(self, start, end, map2d, obstruction=1): ''' :param start: 起點坐標(x,y) :param end: 終點坐標(x,y) :param map: 地圖 :param obstruction: 障礙物標記 ''' self.start_x, self.start_y = start self.end = end self.map2d = map2d self.openlist = {} self.closelist = {} # 垂直和水平方向的差向量 self.v_hv = [(-1, 0), (0, 1), (1, 0), (0, -1)] # 斜對角的差向量 self.v_diagonal = [(-1, 1), (1, 1), (1, -1), (-1, -1)] self.obstruction = obstruction # 障礙物標記 self.x_edge, self.y_edge = len(map2d), len(map2d[0]) # 地圖邊界 self.answer = None def is_in_map(self, x, y): ''' (x, y)是否中地圖內 ''' return 0 <= x < self.x_edge and 0 <= y < self.y_edge def in_closelist(self, x, y): ''' (x, y) 方格是否在closeList中 ''' return self.closelist.get((x, y)) is not None def upd_openlist(self, node): ''' 用node 替換 openlist中的對應數據 ''' self.openlist[(node.x, node.y)] = node def add_in_openlist(self, node): ''' 將node添加到 openlist ''' self.openlist[(node.x, node.y)] = node def add_in_closelist(self, node): ''' 將node添加到 closelist ''' self.closelist[(node.x, node.y)] = node def pop_min_F(self): ''' 彈出openlist中F值最小的節點 ''' key_min, node_min = None, None for key, node in self.openlist.items(): if node_min is None: key_min, node_min = key, node elif node.get_F(self.end) < node_min.get_F(self.end): key_min, node_min = key, node # 將node_min從openlist中移除 if key_min is not None: self.openlist.pop(key_min) return node_min
我們使用二維列列表存儲地圖上的每一個方格,用1表示障礙物,0表示可走的道路。openList和closeList使用字典代替列表,key是方格的坐標,value是表方格的節點,這將比列表更便於執行中A*搜索中的相關操作。
注意到這里並沒有像5.5.1那樣用一個列表存儲八個方向的差向量,而是將斜對角的向量拆分出來,這樣做的目的是便於應對游戲規則中“無法貼着障礙物移動到斜對角的方格”這一規則。假設某個方格的坐標是(x,y),現在想要移動到左上方的(x’,y’)。能夠移動的前提是(x,y)附近的兩個方格都不是障礙物,可以用(x,y’)和(x’,y)來定位它們:

這種方法的好處是,只要知道(x,y)和(x’,y’),就可以判斷是否存在阻擋移動的障礙物,而無需關心(x’,y’)具體在什么方向:

根據這種思路編寫用於尋找待探索節點的方法:
def get_Q(self, P): ''' 找到P周圍可以探索的節點 ''' Q = {} # 將水平或垂直方向的相應方格加入到Q for dir in self.v_hv: x, y = P.x + dir[0], P.y + dir[1] # 如果(x,y)不是障礙物並且不在closelist中,將(x,y)加入到Q if self.is_in_map(x, y) \ and self.map2d[x][y] != self.obstruction \ and not self.in_closelist(x, y): Q[(x, y)] = Node(x, y, P) # 將斜對角的相應方格加入到Q for dir in self.v_diagonal: x, y = P.x + dir[0], P.y + dir[1] # 如果(x,y)不是障礙物,且(x,y)能夠與P聯通,且(x,y)不在closelist中,將(x,y)加入到Q if self.is_in_map(x, y) \ and self.map2d[x][y] != self.obstruction \ and self.map2d[x][P.y] != self.obstruction \ and self.map2d[P.x][y] != self.obstruction \ and not self.in_closelist(x, y): Q[(x, y)] = Node(x, y, P) return Q
實現A*搜索
A*搜索的完整代碼:
class Node: def __init__(self, x, y, parent, g=0, h=0): self.x = x # 節點的行號 self.y = y # 節點的列號 self.h = h self.g = g self.f = g + h self.parent = parent # 父節點 def get_G(self): ''' 當前節點到起點的代價 :param parent: :return: ''' if self.g != 0: return self.g elif self.parent is None: self.g = 0 # 當前節點在parent的垂直或水平方向 elif self.parent.x == self.x or self.parent.y == self.y: self.g = self.parent.get_G() + 10 # 當前節點在parent的斜對角 else: self.g = self.parent.get_G() + 14 return self.g def get_H(self, end): ''' 節點到終點的距離估值 :param end: 終點坐標(x,y) :return: ''' if self.h == 0: self.h = self.manhattan(self.x, self.y, end[0], end[1]) * 10 return self.h def get_F(self, end): ''' 節點的評估值 :param: end 終點坐標 :return: ''' if self.f == 0: self.f = self.get_G() + self.get_H(end) return self.f def manhattan(self, from_x, from_y, to_x, to_y): ''' 曼哈頓距離 ''' return abs(to_x - from_x) + abs(to_y - from_y) class Tank_way: ''' 使用A*搜索找到坦克的最短移動路徑 ''' def __init__(self, start, end, map2d, obstruction=1): ''' :param start: 起點坐標(x,y) :param end: 終點坐標(x,y) :param map: 地圖 :param obstruction: 障礙物標記 ''' self.start_x, self.start_y = start self.end = end self.map2d = map2d self.openlist = {} self.closelist = {} # 垂直和水平方向的差向量 self.v_hv = [(-1, 0), (0, 1), (1, 0), (0, -1)] # 斜對角的差向量 self.v_diagonal = [(-1, 1), (1, 1), (1, -1), (-1, -1)] self.obstruction = obstruction # 障礙物標記 self.x_edge, self.y_edge = len(map2d), len(map2d[0]) # 地圖邊界 self.answer = None def is_in_map(self, x, y): ''' (x, y)是否中地圖內 ''' return 0 <= x < self.x_edge and 0 <= y < self.y_edge def in_closelist(self, x, y): ''' (x, y) 方格是否在closeList中 ''' return self.closelist.get((x, y)) is not None def upd_openlist(self, node): ''' 用node 替換 openlist中的對應數據 ''' self.openlist[(node.x, node.y)] = node def add_in_openlist(self, node): ''' 將node添加到 openlist ''' self.openlist[(node.x, node.y)] = node def add_in_closelist(self, node): ''' 將node添加到 closelist ''' self.closelist[(node.x, node.y)] = node def pop_min_F(self): ''' 彈出openlist中F值最小的節點 ''' key_min, node_min = None, None for key, node in self.openlist.items(): if node_min is None: key_min, node_min = key, node elif node.get_F(self.end) < node_min.get_F(self.end): key_min, node_min = key, node # 將node_min從openlist中移除 if key_min is not None: self.openlist.pop(key_min) return node_min def get_Q(self, P): ''' 找到P周圍可以探索的節點 ''' Q = {} # 將水平或垂直方向的相應方格加入到Q for dir in self.v_hv: x, y = P.x + dir[0], P.y + dir[1] # 如果(x,y)不是障礙物並且不在closelist中,將(x,y)加入到Q if self.is_in_map(x, y) \ and self.map2d[x][y] != self.obstruction \ and not self.in_closelist(x, y): Q[(x, y)] = Node(x, y, P) # 將斜對角的相應方格加入到Q for dir in self.v_diagonal: x, y = P.x + dir[0], P.y + dir[1] # 如果(x,y)不是障礙物,且(x,y)能夠與P聯通,且(x,y)不在closelist中,將(x,y)加入到Q if self.is_in_map(x, y) \ and self.map2d[x][y] != self.obstruction \ and self.map2d[x][P.y] != self.obstruction \ and self.map2d[P.x][y] != self.obstruction \ and not self.in_closelist(x, y): Q[(x, y)] = Node(x, y, P) return Q def a_search(self): while True: # 找到openlist中F值最小的節點作為探索節點 P = self.pop_min_F() # openlist為空,表示沒有通向終點的路 if P is None: break # P加入closelist self.add_in_closelist(P) # P周圍待探索的節點 Q = self.get_Q(P) # Q中沒有任何節點,表示該路徑一定不是最短路徑,重新從openlist中選擇 if Q == {}: continue # 找到了終點, 退出循環 if Q.get(self.end) is not None: self.answer = Node(self.end[0], self.end[1], P) break # Q中的節點與openlist中的比較 for item in Q.items(): (x, y), node_Q = item[0], item[1] node_openlist = self.openlist.get((x, y)) # 如果node_Q不在openlist中,直接將其加入openlist if node_openlist is None: self.add_in_openlist(node_Q) # node_Q的F值比node_openlist更小,則用node_Q替換node_openlist elif node_Q.get_F(self.end) < node_openlist.get_F(self.end): self.upd_openlist(node_Q) def start(self): node_start = Node(self.start_x, self.start_y, None) self.openlist[(self.start_x, self.start_y)] = node_start self.a_search() def paint(self): ''' 打印最短路線 ''' node = self.answer while node is not None: print((node.x, node.y), 'G={0}, H={1}, F={2}'.format(node.g, node.h, node.get_F(self.end))) node = node.parent if __name__ == '__main__': map2d = [[0] * 8 for i in range(8)] map2d[5][4] = 1 map2d[5][5] = 1 map2d[4][5] = 1 map2d[3][5] = 1 map2d[2][5] = 1 start, end = (3, 2), (5, 7) a_way = Tank_way(start, end, map2d) a_way.start() a_way.paint()
運行結果:

代價因子
坦克尋徑的故事並沒有結束,還可以額外考慮游戲中的兩種典型的情況。一種是我們之前定義的“無法貼着障礙物移動到斜對角的方格”並不那么准確,如果障礙物只是占據了單元格的一部分位置,坦克也許可以擠過去:

另一個情況在游戲中更為常見,坦克其實是可以穿過樹林的,只不過在樹林中行進遠遠慢於在大路上行進。這類似於電視中的橋段:大路遠但好走,小路近而難行,至於最終哪個更省力,全靠運氣——也許小路由於剛下過一場雨導致更加難走,克服困難的成本遠大於原計划節省的成本。為了應對這種情況,可以為每個方格添加一個代價因子,一個方格的代價因子越高,移動到這里的代價越大。例如某一點(x,y)的G值是G(x,y)=100,向垂直和水平方向的相鄰方格移動一步的代價是10;左側方格(x1,y1)是樹林,移動因子是2;右側方格(x2,y2)是平地,移動因子是1,此時:

作者:我是8位的

