Python实战:从零构建A*寻路算法,并实现动态可视化
最近在整理一些旧项目时,翻到了几年前写的一个迷宫游戏,当时为了给游戏角色添加自动寻路功能,我花了不少时间研究各种路径规划算法。最终,我选择了A算法,因为它既高效又直观,特别适合游戏开发中的寻路场景。今天,我想把这个过程中的核心经验分享出来,不仅仅是代码实现,更重要的是如何理解A算法背后的思想,以及如何用Python将其可视化,让你能亲眼看到算法是如何“思考”的。
如果你对算法感兴趣,或者正在开发需要路径规划功能的应用(比如游戏、机器人导航、物流调度等),这篇文章应该能给你不少启发。我会从最基础的原理讲起,逐步构建完整的代码,最后用Pygame实现一个动态的可视化界面,让你能实时观察算法的搜索过程。
1. A*算法:为什么它比Dijkstra和BFS更聪明?
在开始写代码之前,我们得先搞清楚A*算法到底解决了什么问题。想象一下,你要在一个复杂的迷宫里找到从入口到出口的最短路径,你会怎么做?最笨的方法就是像没头苍蝇一样到处乱撞,直到碰巧找到出口——这其实就是深度优先搜索(DFS)的思路。稍微聪明一点的方法是像水波一样从起点向四周均匀扩散,这就是广度优先搜索(BFS)。BFS能保证找到最短路径,但它的搜索范围会呈指数级增长,效率很低。
Dijkstra算法在BFS的基础上做了改进,它考虑了每个位置的“代价”(比如距离、时间、能耗等),总是优先探索代价最小的位置。这已经很不错了,但Dijkstra仍然像是一个谨慎的探险家,它会不偏不倚地向所有方向探索,即使某个方向明显离目标越来越远。
A*算法的核心洞察在于:既然我们知道目标在哪里,为什么不在探索时稍微“偏心”一点,优先朝着目标方向前进呢?这就是启发式搜索的思想。A*算法在Dijkstra的基础上增加了一个“启发函数”(heuristic function),用来估计从当前点到目标点的剩余代价。这个估计值就像是一个指南针,引导算法朝着最有希望的方向搜索。
1.1 理解A*算法的三个核心函数
A*算法用一个简单的公式来评估每个位置的优先级:
f(n) = g(n) + h(n)
这个公式里的三个函数是理解A*的关键:
- g(n):从起点到当前点n的实际代价。在网格地图中,这通常就是已经走过的步数。
- h(n):从当前点n到目标点的估计代价,这就是“启发函数”。
- f(n):综合代价,算法总是优先探索f值最小的位置。
为了更直观地理解这三个函数的关系,我们来看一个简单的对比:
| 函数 | 含义 | 计算方式 | 特点 |
|---|---|---|---|
| g(n) | 已付出代价 | 实际走过的距离 | 确保路径是最短的 |
| h(n) | 预计剩余代价 | 估计到目标的距离 | 引导搜索方向 |
| f(n) | 总预期代价 | g(n) + h(n) | 决定探索优先级 |
注意:启发函数h(n)必须满足“可采纳性”(admissible)条件,即它不能高估实际代价。如果h(n)总是小于或等于实际代价,A*算法就能保证找到最短路径。常用的启发函数有曼哈顿距离、欧几里得距离等。
1.2 启发函数的选择:曼哈顿距离 vs 欧几里得距离
在网格地图中,我们通常使用两种距离作为启发函数:
曼哈顿距离:只允许上下左右四个方向移动时使用
h(n) = |x1 - x2| + |y1 - y2|
欧几里得距离:允许八个方向(包括对角线)移动时使用
h(n) = √[(x1 - x2)² + (y1 - y2)²]
这两种距离的选择会直接影响算法的行为和效率。让我用一个实际的例子来说明区别:
# 计算两种启发函数的值
def manhattan_distance(start, end):
return abs(start[0] - end[0]) + abs(start[1] - end[1])
def euclidean_distance(start, end):
return ((start[0] - end[0])**2 + (start[1] - end[1])**2)**0.5
# 假设起点在(0,0),终点在(3,4)
start = (0, 0)
end = (3, 4)
print(f"曼哈顿距离: {manhattan_distance(start, end)}") # 输出: 7
print(f"欧几里得距离: {euclidean_distance(start, end)}") # 输出: 5.0
从计算结果可以看出,欧几里得距离通常比曼哈顿距离小,这会让算法更“乐观”,搜索速度更快,但找到的路径可能不是严格最短的(在只允许四个方向移动时)。在实际项目中,我通常根据移动方式选择:如果是网格游戏角色,用曼哈顿距离;如果是自由移动的机器人,用欧几里得距离。
2. 构建A*算法的核心数据结构
理解了原理后,我们开始动手实现。A*算法需要维护几个关键的数据结构,这些结构的设计直接影响算法的效率。
2.1 节点类:存储搜索状态
首先,我们需要一个Node类来表示搜索过程中的每个位置。这个类需要存储位置信息、代价估计,以及用于回溯路径的父节点引用。
class Node:
"""表示搜索过程中的一个节点"""
def __init__(self, position, parent=None):
self.position = position # (x, y)坐标
self.parent = parent # 父节点,用于回溯路径
# 代价估计
self.g = 0 # 从起点到当前节点的实际代价
self.h = 0 # 从当前节点到目标的估计代价
self.f = 0 # 总代价: f = g + h
def __eq__(self, other):
"""重载相等运算符,方便节点比较"""
return self.position == other.position
def __lt__(self, other):
"""重载小于运算符,用于优先队列排序"""
return self.f < other.f
def __repr__(self):
"""节点的字符串表示,便于调试"""
return f"Node({self.position}, g={self.g}, h={self.h}, f={self.f})"
这个Node类有几个设计要点:
- 存储
parent引用是为了在找到目标后能回溯出完整路径 __eq__方法让我们可以直接用==比较两个节点是否在同一位置__lt__方法定义了节点的比较规则,优先队列会根据f值排序
2.2 优先队列:高效获取最小f值节点
A*算法需要频繁地从待探索节点中取出f值最小的节点。如果每次都用线性搜索,时间复杂度会是O(n),当节点很多时会很慢。我们可以用Python的heapq模块实现一个最小堆,这样获取最小值的操作就是O(log n)。
import heapq
class PriorityQueue:
"""基于heapq的优先队列实现"""
def __init__(self):
self.elements = []
def empty(self):
return len(self.elements) == 0
def put(self, item, priority):
heapq.heappush(self.elements, (priority, item))
def get(self):
return heapq.heappop(self.elements)[1]
def __contains__(self, item):
"""检查节点是否在队列中"""
return any(item == element[1] for element in self.elements)
def get_node(self, position):
"""根据位置获取队列中的节点"""
for _, node in self.elements:
if node.position == position:
return node
return None
这个优先队列的实现有几个实用技巧:
- 使用
(priority, item)的元组格式,heapq会自动按priority排序 __contains__方法让我们能用in操作符检查节点是否存在get_node方法可以获取指定位置的节点,用于更新节点信息
2.3 地图表示:网格与障碍物
我们需要一个简单的方式来表示地图。对于网格地图,可以用二维列表,其中0表示可通行,1表示障碍物。
class GridMap:
"""网格地图表示"""
def __init__(self, width, height):
self.width = width
self.height = height
self.grid = [[0 for _ in range(width)] for _ in range(height)]
def set_obstacle(self, x, y):
"""设置障碍物"""
if 0 <= x < self.width and 0 <= y < self.height:
self.grid[y][x] = 1
def is_passable(self, x, y):
"""检查位置是否可通行"""
if 0 <= x < self.width and 0 <= y < self.height:
return self.grid[y][x] == 0
return False
def get_neighbors(self, x, y, allow_diagonal=True):
"""获取相邻的可通行位置"""
neighbors = []
# 四个基本方向
directions = [(0, 1), (1, 0), (0, -1), (-1, 0)]
# 如果允许对角线移动,添加四个斜角方向
if allow_diagonal:
directions += [(1, 1), (1, -1), (-1, 1), (-1, -1)]
for dx, dy in directions:
nx, ny = x + dx, y + dy
if self.is_passable(nx, ny):
# 如果是斜角移动,需要检查是否"卡墙角"
if abs(dx) == 1 and abs(dy) == 1:
if self.is_passable(x, ny) or self.is_passable(nx, y):
neighbors.append((nx, ny))
else:
neighbors.append((nx, ny))
return neighbors
这里有个细节需要注意:当允许对角线移动时,如果相邻的两个正交方向都有障碍物,角色可能会被"卡"在墙角。上面的代码通过检查self.is_passable(x, ny) or self.is_passable(nx, y)来避免这种情况。
3. 完整实现A*算法
有了这些基础组件,现在我们可以实现完整的A*算法了。我会把算法分解成几个清晰的步骤,并添加详细的注释。
3.1 算法主循环
def a_star_search(grid_map, start, end, heuristic_func=None, allow_diagonal=True):
"""
A*寻路算法主函数
参数:
grid_map: GridMap对象,表示地图
start: 起点坐标 (x, y)
end: 终点坐标 (x, y)
heuristic_func: 启发函数,默认为曼哈顿距离
allow_diagonal: 是否允许对角线移动
返回:
path: 找到的路径列表,每个元素是(x, y)坐标
explored: 探索过的所有节点(用于可视化)
"""
# 如果没有指定启发函数,使用曼哈顿距离
if heuristic_func is None:
heuristic_func = lambda a, b: abs(a[0] - b[0]) + abs(a[1] - b[1])
# 创建起点和终点节点
start_node = Node(start)
end_node = Node(end)
# 初始化开放列表和关闭列表
open_list = PriorityQueue()
open_list.put(start_node, start_node.f)
closed_set = set()
# 记录所有探索过的节点(用于可视化)
explored_nodes = []
# 主循环
while not open_list.empty():
# 从开放列表中取出f值最小的节点
current_node = open_list.get()
explored_nodes.append(current_node)
# 如果到达终点,回溯路径
if current_node == end_node:
path = []
while current_node is not None:
path.append(current_node.position)
current_node = current_node.parent
return path[::-1], explored_nodes # 反转路径,从起点到终点
# 将当前节点加入关闭列表
closed_set.add(current_node.position)
# 探索相邻节点
for neighbor_pos in grid_map.get_neighbors(*current_node.position, allow_diagonal):
# 如果邻居已在关闭列表中,跳过
if neighbor_pos in closed_set:
continue
# 创建邻居节点
neighbor_node = Node(neighbor_pos, current_node)
# 计算移动代价(对角线移动代价更高)
dx = abs(neighbor_pos[0] - current_node.position[0])
dy = abs(neighbor_pos[1] - current_node.position[1])
move_cost = 14 if dx == 1 and dy == 1 else 10 # 对角线: 14 ≈ 10√2
# 计算g、h、f值
neighbor_node.g = current_node.g + move_cost
neighbor_node.h = heuristic_func(neighbor_pos, end)
neighbor_node.f = neighbor_node.g + neighbor_node.h
# 检查邻居是否已在开放列表中
existing_node = open_list.get_node(neighbor_pos)
if existing_node is not None:
# 如果新路径的g值更小,更新节点
if neighbor_node.g < existing_node.g:
existing_node.g = neighbor_node.g
existing_node.f = neighbor_node.f
existing_node.parent = current_node
else:
# 新节点,加入开放列表
open_list.put(neighbor_node, neighbor_node.f)
# 没有找到路径
return None, explored_nodes
这个实现有几个关键点需要注意:
-
移动代价的设定:我使用了10作为正交移动的代价,14(约等于10√2)作为对角线移动的代价。这种设定在游戏开发中很常见,因为它保持了距离的比例关系。
-
节点更新的逻辑:当发现到达同一位置的新路径代价更小时,我们需要更新该节点的g值和父节点。这是A*算法正确性的关键。
-
启发函数的选择:我提供了默认的曼哈顿距离,但你可以传入自定义的启发函数。比如在允许对角线移动时,可以使用切比雪夫距离或欧几里得距离。
3.2 路径平滑处理
A*算法找到的路径有时会有不必要的拐弯。我们可以用一个简单的后处理步骤来平滑路径:
def smooth_path(path, grid_map):
"""
平滑路径,移除不必要的中间点
参数:
path: 原始路径
grid_map: 地图对象
返回:
平滑后的路径
"""
if not path or len(path) < 3:
return path
smoothed = [path[0]]
i = 0
while i < len(path) - 1:
j = len(path) - 1
# 从最远的点开始检查,看是否能直接到达
while j > i + 1:
if is_line_of_sight(path[i], path[j], grid_map):
# 如果两点之间没有障碍物,直接连接
smoothed.append(path[j])
i = j
break
j -= 1
else:
# 如果没有找到可直达的点,使用下一个点
smoothed.append(path[i + 1])
i += 1
return smoothed
def is_line_of_sight(start, end, grid_map):
"""
使用Bresenham算法检查两点之间是否有障碍物
"""
x0, y0 = start
x1, y1 = end
dx = abs(x1 - x0)
dy = abs(y1 - y0)
x, y = x0, y0
n = 1 + dx + dy
x_inc = 1 if x1 > x0 else -1
y_inc = 1 if y1 > y0 else -1
err

&spm=1001.2101.3001.5002&articleId=153671504&d=1&t=3&u=056ea0b85ac841809fc7c85abb85ca57)
713

被折叠的 条评论
为什么被折叠?



