A* 搜索算法和迷宫最短路径问题
与DFS和BFS相比,前两者适用于小型的数据集和状态空间。而A*搜索算法作为dijkstra算法的变种,使其在处理具有启发式信息的特定的起点到目标点的路径搜索问题具有最佳性能。
在此算法中,引入了代价(cost)的概念:
总代价
=
当前代价
+
预估代价
总代价 = 当前代价 + 预估代价
总代价=当前代价+预估代价
当前代价是指当前路程的代价。例如在下表中,S代表起点,G代表终点,可以进行上下左右移动。如果想要往右移动两格,则代表当前代价为2。
3 | 2 | 3 | 4 | 5 |
---|---|---|---|---|
2 | 1 | 2 | 3 | 4 |
1 | S | 1 | 2 | G |
2 | 1 | 2 | 3 | 4 |
3 | 2 | 3 | 4 | 5 |
预估代价,则是指当前方块到终点的步数。此处的预估不是精确值,可能大于也可能小于。预估代价的计算主要有两种方法:
- 欧式距离(Euclidean distance)。在几何学中,两点之间的最短路径就是直线。因此,可以通过欧式距离公式计算预估代价;
- 曼哈顿距离(Manhattan distance)。正如曼哈顿的街道一样,曼哈顿以网格划分街区,不存在对角线街道。所谓曼哈顿距离,其实就是获得两个位置之间的行数差,并将其与列数差相加而得到。如果所面临的问题不存在对角线移动,则应该优先考虑使用曼哈顿距离实现预估代价的计算。
启发式信息(heuristics)是对问题解决方式的一种直觉。在求解迷宫问题时,启发式信息旨在选取下一次搜索的最佳迷宫位置,最终是为了抵达目标。换句话说,这是一种有根据的猜测,猜测 frontier 上的哪些节点最接近目标位置。如前所述,如果 A* 搜索采用的启发式信息能够生成相对准确的结果且为可接受的(永远不会高估距离),那么 A* 将会得出最短路径。
下面,从构建迷宫开始,进一步阐述A* 算法在解决迷宫最短路径问题的应用。本文涉及代码基于Python 3.7环境编写,已通过测试成功运行。
构建迷宫
首先,编写一个Cell类记录迷宫的各项属性:
class Cell(str, Enum):
EMPTY = "O", # 空白
BLOCKED = "X", # 障碍
START = "S", # 起点
GOAL = "G", # 目的地
PATH = "*" # 途经标记
maze.py
为了能够记录坐标,需要编写如下类:
class MazeLocation(NamedTuple):
row: int
column: int
续maze.py
现在,我们可以创建迷宫类。通过__init__
方法,可以构建一个10x10的迷宫,其中可以设置障碍物的稀疏程度、起始点和目标点的坐标。构建迷宫的代码并不是文章重点:
class Maze:
def __init__(self, rows: int = 10, columns: int = 10, sparseness: float = 0.2,
start: MazeLocation = MazeLocation(0, 0), goal: MazeLocation = MazeLocation(9, 9)) -> None:
"""
初始化迷宫实例。
:param rows: 迷宫的行数。
:param columns: 迷宫的列数。
:param sparseness: 障碍物的稀疏程度,值在 0 到 1 之间,值越大障碍物越多。
:param start: 起始点的位置。
:param goal: 目标点的位置。
"""
self._rows: int = rows # 迷宫的总行数
self._columns: int = columns # 迷宫的总列数
self.start: MazeLocation = start # 迷宫的起始位置
self.goal: MazeLocation = goal # 迷宫的目标位置
# 初始化迷宫网格,所有单元格默认设置为 EMPTY
self._grid: List[List[Cell]] = [[Cell.EMPTY for c in range(columns)]
for r in range(rows)]
# 随机填充障碍物
self._randomly_fill(rows, columns, sparseness)
# 设置起始点和目标点
self._grid[start.row][start.column] = Cell.START
self._grid[goal.row][goal.column] = Cell.GOAL
def _randomly_fill(self, rows: int, columns: int, sparseness: float):
"""
随机填充迷宫中的障碍物。
:param rows: 迷宫的行数。
:param columns: 迷宫的列数。
:param sparseness: 障碍物的稀疏程度。
"""
for row in range(rows):
for column in range(columns):
# 使用 sparseness 决定是否将当前单元格设置为障碍物
if random() < sparseness:
self._grid[row][column] = Cell.BLOCKED
续maze.py
为了能够在控制台直观查看此迷宫,需要重写Maze类的__str__
方法:
def __str__(self) -> str:
output: str = ""
for row in self._grid:
output += "".join([c.value for c in row]) + "\n"
return output
续maze.py
此时,可以编写测试代码:
if __name__ == '__main__':
m = Maze()
print(m)
得到如下迷宫输出:
SOOOOOOXXX
OXXXOOOOOO
XOOOOOOOOO
OXXOOOOOOX
OXXOOXOXOO
OOOOOOXXOX
OOOOXOOOOO
XOXOOOOOOX
XOOOOXXXOO
XXXOOOOXOG
到这里完成了迷宫构建代码的初步编写。此时,仍需继续回到Maze类完成剩余部分代码。为了检查当前位置是否是目标位置,即是否移动到了终点,需要在Maze类编写一个检测方法:
def goal_test(self, ml: MazeLocation) -> bool:
"""
检查给定位置是否是目标位置。
:param ml: 要检查的位置。
:return: 如果给定位置等于目标位置,返回 True;否则返回 False。
"""
return self.goal == ml
续maze.py
同时,需要探测上下左右哪些位置可以移动(不被障碍物遮挡)。在Maze类中编写如下方法:
def successors(self, ml: MazeLocation) -> List[MazeLocation]:
"""
查找从当前位置可以到达的所有有效位置(即不被障碍物阻挡的相邻位置)。
:param ml: 当前的位置。
:return: 从当前位置可以到达的有效位置列表。
"""
locations: List[MazeLocation] = [] # 用于存储所有有效的相邻位置
# 检查下方位置
if ml.row + 1 < self._rows and self._grid[ml.row + 1][ml.column] != Cell.BLOCKED:
locations.append(MazeLocation(ml.row + 1, ml.column))
# 检查上方位置
if ml.row - 1 >= 0 and self._grid[ml.row - 1][ml.column] != Cell.BLOCKED:
locations.append(MazeLocation(ml.row - 1, ml.column))
# 检查右侧位置
if ml.column + 1 < self._columns and self._grid[ml.row][ml.column + 1] != Cell.BLOCKED:
locations.append(MazeLocation(ml.row, ml.column + 1))
# 检查左侧位置
if ml.column - 1 >= 0 and self._grid[ml.row][ml.column - 1] != Cell.BLOCKED:
locations.append(MazeLocation(ml.row, ml.column - 1))
return locations
续maze.py
最后,需要对走过的格子进行路径标记,则对Maze类增加如下方法:
def mark(self, i_path: List[MazeLocation]):
"""
将给定路径中的所有位置标记为路径,并保留起始点和目标点的标记。
:param i_path: 要标记的路径位置列表。
"""
# 将路径上的所有位置标记为路径
for maze_location in i_path:
self._grid[maze_location.row][maze_location.column] = Cell.PATH
# 保持起始点和目标点的标记不变
self._grid[self.start.row][self.start.column] = Cell.START
self._grid[self.goal.row][self.goal.column] = Cell.GOAL
续maze.py
优先队列
为了选出代价最低的位置,A* 搜索使用优先队列作为存储待探索节点的数据结构。该数据结构的本质是一种最小堆,即出队元素总是最小代价的节点。在Python 的标准库中包含了 heappush()函数和 heappop()函数,这些函数将读取一个列表并将其维护为二叉堆。用这些标准库函数构建一个很薄的封装器,即可实现一个优先队列PriorityQueue:
from heapq import heappush, heappop
from typing import List, Generic, TypeVar, Optional
T = TypeVar('T')
class PriorityQueue(Generic[T]):
def __init__(self) -> None:
self._container: List[T] = []
@property
def empty(self) -> bool:
return not self._container
def push(self, item: T) -> None:
heappush(self._container, item)
def pop(self) -> T:
return heappop(self._container)
def __repr__(self) -> str:
return repr(self._container)
generic_search.py
该优先队列保存的是节点信息,因此,可设计一个Node类定义节点:
class Node(Generic[T]):
def __init__(self, state: T, parent: Optional['Node'], cost: float = 0.0, heuristic: float = 0.0) -> None:
"""
初始化节点实例。
:param state: 节点的状态,类型为 T。
:param parent: 节点的父节点,用于追踪路径。根节点的父节点为 None。
:param cost: 从起始节点到当前节点的实际代价。
:param heuristic: 从当前节点到目标节点的启发式估计代价。
"""
self.state: T = state # 节点的状态
self.parent: Optional[Node] = parent # 节点的父节点(如果有的话),根节点的父节点为 None
self.cost: float = cost # 从起始节点到当前节点的实际代价
self.heuristic: float = heuristic # 从当前节点到目标节点的启发式估计代价
def __lt__(self, other: 'Node') -> bool:
"""
比较两个节点的优先级,以便在优先队列中正确排序。
:param other: 另一个节点。
:return: 如果当前节点的总代价(实际代价 + 启发式估计)小于另一个节点的总代价,则返回 True;否则返回 False。
"""
return (self.cost + self.heuristic) < (other.cost + other.heuristic)
续generic_search.py
曼哈顿距离
正如上文提及,曼哈顿距离适合上下左右移动的迷宫问题,此处可依据其特点,编写代码实现:
# 计算曼哈顿距离
def manhattan_distance(goal: MazeLocation) -> Callable[[MazeLocation], float]:
def distance(ml: MazeLocation) -> float:
xdist: int = abs(ml.column - goal.column)
ydist: int = abs(ml.row - goal.row)
return xdist + ydist
return distance
续maze.py
此处嵌套两层方法是因为,goal作为目标是不变的,但是ml作为当前位置随着移动会改变。在后续使用该方法时,通过传入goal确定了目标后,返回的distance方法将作为参数传入A* 搜索算法的代码中,该方法将作为其预估代价的依据。
A* 搜索算法
def astar(initial, goal_test, successors, heuristic):
"""
A* 搜索算法的实现,用于在迷宫中找到从起始位置到目标位置的最短路径。
:param initial: 起始节点的位置。
:param goal_test: 一个函数,用于测试当前节点是否是目标节点。
:param successors: 一个函数,返回当前节点的所有相邻(合法)的后继节点。
:param heuristic: 一个函数,计算从当前节点到目标节点的估计代价(启发式函数,此处为曼哈顿距离计算函数)。
:return: 如果找到路径,返回到达目标节点的最后一个节点;否则返回 None。
"""
# 创建一个优先队列(最小堆),用于存储待探索的节点
frontier = PriorityQueue()
# 将起始节点添加到优先队列中,初始代价为 0,启发式估计为 heuristic(initial)
frontier.push(Node(initial, None, 0.0, heuristic(initial)))
# 记录已探索的节点及其代价,初始化时只包含起始节点
explored = {initial: 0.0}
# 开始探索过程
while not frontier.empty:
# 从优先队列中取出代价最小的节点
current_node = frontier.pop()
current_state = current_node.state
# 检查当前节点是否为目标节点
if goal_test(current_state):
return current_node
# 如果不是目标节点,则探索其所有相邻的合法后继节点
for child in successors(current_state):
# 计算到达相邻节点的代价(当前节点的代价加上一步的代价)
new_cost = current_node.cost + 1
# 如果相邻节点未被探索或找到了一条更优路径,则更新探索信息
if child not in explored or explored[child] > new_cost:
# 更新相邻节点的代价
explored[child] = new_cost
# 将相邻节点添加到优先队列中,计算其代价和启发式估计
frontier.push(Node(child, current_node, new_cost, heuristic(child)))
# 如果优先队列为空且未找到目标节点,返回 None
return None
续generic_search.py
找到最佳路径后,便需要给出路径坐标列表,以便将对应路径置为*号,显式展现移动过程。可通过以下方法实现:
def node_to_path(node: Node[T]) -> List[T]:
path: List[T] = [node.state]
while node.parent is not None:
node = node.parent
path.append(node.state)
path.reverse()
return path
续generic_search.py
测试
使用如下示例代码可以进行测试:
if __name__ == '__main__':
m = Maze()
distance: Callable = manhattan_distance(m.goal)
solution = astar(m.start, m.goal_test, m.successors, distance)
if solution is None:
print("No solution found using A*!")
else:
path = node_to_path(solution)
m.mark(path)
print(m)
续maze.py
运行结果如下所示,其中S表示起点、O表示空白、X表示障碍、*号表示移动路径、G表示目的地:
SOOXXOOOXX
*OOOXOOOOX
**OOOOOOXO
X*OOOOOXOO
O**OOOOOXX
OX**OOOOOO
OOO****OXO
OOOOOX*OOO
OOOXOO*XOO
OOOOOO***G