16086 字
43 分钟
路径规划算法学习目录:TSP、Dijkstra、A*、RRT与RRT*
2026-10-27

这是一套通用路径规划算法教程。不需要先了解无人机巡检项目,也不依赖ROS、点云、某个数据集或特定业务。示例统一使用小型加权图、二维网格、城市坐标和带圆形障碍的平面。

“TSP、A*、RRT”常被一起讨论,但它们不在同一个问题层次:TSP决定多个地点的访问顺序;Dijkstra和A求给定图中的最短路;RRT及RRT在连续配置空间中构造可行连接。先分清问题,才能正确比较算法。

这里使用名称 RRT(Rapidly-exploring Random Tree,快速扩展随机树),不是网络术语RTT。

一、学习路线与目录#

章节学习重点最终应能独立完成
01 建模与碰撞状态、约束、配置空间、线段距离实现一个可信的碰撞检测函数
02 BFS与Dijkstra松弛、最小堆、最短路性质在加权图上恢复路径并处理无解
03 TSP穷举、位掩码DP、最近邻、2-opt用精确算法验证启发式结果
04 A*g+h、启发函数、重开、一致性写出允许节点重开的A*
05 RRT随机采样、最近邻、限步扩展生成并检查连续空间中的树和路径
06 RRT*选父节点、重接线、子树更新在保持树结构正确的前提下改善路径
07 实验运行、绘图、测试、公平对照解读成功、失败、代价和计算预算

推荐顺序:01→02→04→05→06→07;03相对独立,可以在掌握基础Python后单独阅读。每篇包含公式、逐步例子、代码解释、常见错误和练习答案。代码块从实际测试过的 .py 文件生成,避免正文与附件成为两份不一致的实现。

二、先学会这张比较表#

算法求解对象典型输出何时可以说最优
BFS单位边权图最少边数路径当前图、单位边权
Dijkstra非负边权图最小累计代价路径当前图、非负边权
A*带启发函数的图到目标的路径需满足启发函数及实现条件
TSP穷举/DP多地点闭合访问顺序一个排列和总成本完成精确搜索后
最近邻/2-opt同一TSP问题一个改进的排列通常不保证全局最优
RRT连续空间可行连接树上的一条路径基本RRT不保证最短
RRT*连续空间路径优化随样本改善的路径渐近性质有前提;有限样本不是最优证明

代码采用二维欧氏空间,是为了能画图、手算、检查边界。推广到机械臂时,状态可能是多个关节角;推广到车辆时,还要考虑朝向、转弯半径和动力学,不能直接照搬直线连接器。

三、如何阅读代码#

先写下输入、输出和不变量,再读循环。例如A的不变量是“best保存目前发现的最小g”;RRT的不变量是“每个非根节点只有一个父节点,cost等于沿父链的边长之和”。

读懂一段代码的标准不是能复述每行语法,而是能回答:删掉这一步会触发什么错误?哪一个小例子可以暴露它?第07篇的测试特别覆盖了负权、旧堆条目、不一致启发函数、对角切角、子树代价传播和有限预算失败。

理论扩展入口:LaValle《Planning Algorithms》。各章在对应理论附近给出原论文或作者资料链接;教程的手算例子与代码是独立教学实现。

精读版:如何使用新增注释与演算#

这次补充把“算法是什么”进一步拆成“为什么成立、内存中保存什么、下一步怎样变化”。正文中的完整源码和附件已经加入中文函数说明、关键步骤注释与边界解释;两处代码仍来自同一份文件。

建议每次只学习一个算法,按以下顺序操作:先手算正文的小实例,再对照变量名读代码,最后运行测试并修改一个输入。读代码时可以临时在自己的实验副本中打印状态,但不要把大量调试输出混进正式实现。

不理解的内容应重点读的位置
投影公式为什么要截断01篇“从求导到三种投影位置”
堆中为什么会有两个相同节点02篇“逐条观察堆的内容”
mask、j和parent怎么配合03篇“跟着一个DP状态回溯”
h的下界为何能保证目标最优04篇“用候选前缀理解最优性”
RRT一次迭代到底加不加节点05篇“手动指定采样序列”
重接会影响哪些后代06篇“数值化的子树代价传播”

学习阶段不需要背诵全部代码。能够在纸上模拟两三轮,解释每次更新的依据,比只记住函数名称更有用。





Chapter 1: Modeling, Configuration Space, and Collision Detection#

一、最短路、TSP和运动规划不是一个问题#

给定城市A和B,想在公路网中找最便宜的连接,这是图最短路。如果要求访问十座城市再返回起点,就多了“访问顺序”这个组合问题,是TSP。如果只给障碍物的位置,没有事先铺好的路,还需要在连续空间里寻找连接方式,这是运动规划。

三者可以组合:先用运动规划计算城市之间的可行路径成本,再解TSP。但某一步输出最优,并不自动让整个组合问题最优。

二、用数学语言描述一个规划问题#

图问题写成 G=(V,E)G=(V,E),边代价为 c(u,v)≥0c(u,v)\ge0。路径 P=(v0,…,vk)P=(v_0,\ldots,v_k) 的代价为:

J(P)=∑i=0k−1c(vi,vi+1).J(P)=\sum_{i=0}^{k-1}c(v_i,v_{i+1}).

连续空间问题则给出状态集合 XX、障碍状态 XobsX_{obs}、自由状态 XfreeX_{free}、起点和目标,以及一条连续曲线 σ:[0,1]→Xfree\sigma:[0,1]\to X_{free}。最短几何路径常以弧长为目标:

J(σ)=∫01∥σ′(s)∥ ds.J(\sigma)=\int_0^1\|\sigma'(s)\|\,ds.

速度、时间、能耗等目标可能需要额外状态。若只用 (x,y)(x,y),算法不会凭空理解速度上限或汽车不能横移。

三、配置空间为什么有用#

把机器人简化为半径 rbr_b 的圆盘,障碍为中心c、半径 ror_o 的圆。机器人中心q不发生碰撞的条件是:

∥q−c∥>ro+rb.\|q-c\|>r_o+r_b.

于是可以把机器人看成一个点,把障碍扩大到 ro+rbr_o+r_b。这种“把机体大小转换到障碍区域”的方法是配置空间思想的一个简单特例。对于有朝向的长方形或机械臂,障碍区域更复杂,不能总用一个固定圆代替而不讨论保守性。

例:障碍半径1米,机器人半径0.3米,希望额外留0.2米余量,可以按1.5米膨胀半径检查。但必须明确余量是建模参数,不是算法自动推导的真实安全标准。

四、为什么必须检查边#

起点 A=(−2,0)A=(-2,0),终点 B=(2,0)B=(2,0),障碍圆位于原点、半径0.5。两端均安全,直线仍穿过障碍。

用 X(t)=A+t(B−A)X(t)=A+t(B-A),t∈[0,1]t\in[0,1] 表示线段。令 u=B−Au=B-A,将圆心Q投影到直线:

t0=(Q−A)⋅uu⋅u,t=clip⁡(t0,0,1).t_0=\frac{(Q-A)\cdot u}{u\cdot u},\qquad t=\operatorname{clip}(t_0,0,1).

最近距离是 ∥Q−(A+tu)∥\|Q-(A+tu)\|。如果 A=BA=B,直接计算点距,避免除零。对每个圆检查距离是否严格大于膨胀半径;本教程把与障碍相切也视为不可通过。

固定步长采样碰撞检测容易理解,但如果采样间距大于细小障碍尺度,会漏检。对当前圆障碍和直线模型,解析投影不需要猜采样密度。

五、完整的几何模块#

下面是 geometry.py 的完整代码:

"""2-D disc robot, circular obstacles, and exact straight-segment checks."""
from dataclasses import dataclass
import math
def segment_distance(q, a, b):
"""计算二维点q到闭线段[a,b]的最小欧氏距离。
q、a、b均为同一坐标系中的二元数值序列;返回值与坐标单位一致。
先求无限直线上的正交投影,再将投影参数截断到[0,1]。
本底层函数假定调用者提供合法二维坐标;World负责场景有效性检查。
"""
# u=b-a是线段方向;u·u为长度平方,投影公式的分母。
dx, dy = b[0] - a[0], b[1] - a[1]
squared = dx * dx + dy * dy
# 零长度线段退化为一个点;必须在除法之前单独处理。
if squared == 0:
return math.dist(q, a)
# 令距离平方对t的导数为0,得到无限直线上的最近投影参数。
t = ((q[0] - a[0]) * dx + (q[1] - a[1]) * dy) / squared
# t<0时最近点是a;t>1时最近点是b;只有中间投影才落在线段上。
t = max(0.0, min(1.0, t))
return math.dist(q, (a[0] + t * dx, a[1] + t * dy))
def path_length(path):
"""累加相邻顶点间的欧氏距离,得到折线长度。
这里不判断碰撞,也不自动补返航边;若需要闭合,调用者须显式追加起点。
空列表或单个点没有线段,按求和约定返回0。
"""
# zip(path,path[1:])产生连续线段,避免把非相邻顶点误当成一条边。
return sum(math.dist(a, b) for a, b in zip(path, path[1:]))
@dataclass
class World:
# 状态只描述机器人中心;朝向对圆盘几何无影响,因此不在状态中。
bounds: tuple # xmin, xmax, ymin, ymax
circles: tuple = () # each is (x, y, obstacle_radius)
robot_radius: float = 0.0
def __post_init__(self):
"""dataclass初始化后校验场景,尽早拒绝无效几何配置。
bounds为(xmin,xmax,ymin,ymax),circles中每项为(cx,cy,r)。
所有长度须使用同一单位;二维圆盘机器人必须能放进边界内部。
"""
if len(self.bounds) != 4 or not all(math.isfinite(v) for v in self.bounds):
raise ValueError('Require four finite bounds')
if not math.isfinite(self.robot_radius) or self.robot_radius < 0:
raise ValueError('Robot radius must be finite and nonnegative')
x0, x1, y0, y1 = self.bounds
if min(x1 - x0, y1 - y0) <= 2 * self.robot_radius:
raise ValueError('Bounds must contain the robot interior')
for circle in self.circles:
if len(circle) != 3 or not all(math.isfinite(v) for v in circle) or circle[2] < 0:
raise ValueError('Invalid circle')
def point_free(self, p):
"""判断机器人中心p是否合法;非法坐标也返回False。
将圆障碍半径增加robot_radius,并将矩形边界向内缩同样距离。
当前约定:可以接触外边界,但不能接触障碍;实际应用可另加余量。
"""
if len(p) != 2 or not all(math.isfinite(v) for v in p):
return False
x0, x1, y0, y1 = self.bounds
r = self.robot_radius
# 两个条件缺一不可:整个圆盘须在外边界内,且中心须在膨胀障碍外。
return (x0 + r <= p[0] <= x1 - r and y0 + r <= p[1] <= y1 - r
and all(math.dist(p, (x, y)) > size + r for x, y, size in self.circles))
def edge_free(self, a, b):
# The shrunken rectangular boundary is convex, so endpoint bounds suffice.
"""检查机器人沿直线a→b移动的整个过程,而非只检查端点。
当前运动模型是圆盘平移,不涉及转向、速度和动态障碍。
矩形边界为凸集,端点都在内即可保证整条线段在内。
每个圆都做解析投影检查,因此不依赖沿线采样步长。
"""
return (self.point_free(a) and self.point_free(b)
and all(segment_distance((x, y), a, b) > size + self.robot_radius
for x, y, size in self.circles))

World.point_free()检查点是否合法;edge_free()先查端点,再查整条线段。边界是矩形,它是凸集,因此端点都在内就保证线段在内。若把边界换成凹多边形,仅检查端点不再足够。

圆盘机器人还使边界向内缩小r。代码允许圆盘恰好接触外边界,却拒绝与障碍圆相切,这是明确的教学约定;应用中可以统一增加严格余量。

六、先验证几何,再验证算法#

from geometry import World
world = World((-5, 5, -5, 5), ((0, 0, 0.5),))
assert world.point_free((-2, 0))
assert world.point_free((2, 0))
assert not world.edge_free((-2, 0), (2, 0))
assert world.edge_free((-2, 2), (2, 2))

在examples目录运行这段代码,或使用同目录模块导入路径。后续RRT的每一次扩展、RRT*的选父节点和重接线、最后到目标的连接,都调用同一套连续碰撞检查。

七、复杂度与建模边界#

若有m个圆,一次边检查遍历m个障碍,时间为 O(m)O(m);保存地图为 O(m)O(m)。大地图可以增加空间索引,但必须保证不会因裁剪候选障碍而漏掉碰撞。

本教程没有时间维度,也没有运动障碍。两条路径相交不意味着两个机器人一定相撞;是否相撞取决于它们何时经过。车辆、无人机、机械臂的动力学规划也不能只靠这个二维模型解决。

练习:线段为(0,0)→(2,0),圆心为(3,1),最近点在哪里?答案是终点(2,0),距离为 2\sqrt2。如果直接使用未经截断的投影,会错误地得到距离1。

深入推导:从求导到三种投影位置#

为什么投影参数恰好是点积除长度平方?设 w=Q−Aw=Q-A、u=B−Au=B-A,展开距离平方:

F(t)=(w−tu)⋅(w−tu)=w⋅w−2t(w⋅u)+t2(u⋅u).F(t)=(w-tu)\cdot(w-tu)=w\cdot w-2t(w\cdot u)+t^2(u\cdot u).

对t求导:F′(t)=−2w⋅u+2t(u⋅u)F'(t)=-2w\cdot u+2t(u\cdot u)。当线段非零时,F′′(t)=2u⋅u>0F''(t)=2u\cdot u>0,因此驻点就是无限直线上的唯一最小点。令导数为0,即得到正文的投影公式。

但我们允许的t只有[0,1]。凸二次函数在闭区间上的最小值,只可能出现在区间内驻点或边界,所以截断不是经验补丁,而是约束最小化的精确结果。

以A=(0,0)、B=(4,0)为例:

障碍中心Q未截断t最终最近点距离
(-1,2)-0.25A=(0,0)5\sqrt5
(2,3)0.5(2,0)3
(6,1)1.5B=(4,0)5\sqrt5

只要把这三行和A=B的情况测过,就覆盖了公式最重要的分支。注意比较的是距离与半径;如果为了加速使用平方距离,则阈值也必须平方,不能混用。

再区分三种“检查通过”#

点可行,只说明某一状态不在障碍中;边可行,说明选定连接器的整个运动过程可行;路径可行,需要每条边都可行且首尾符合要求。

在当前二维圆盘、静态圆障碍模型里,边是直线,edge_free()可以解析判断。如果换成汽车的弧线,不能把弧线两端传给这个函数就当作检查了整条弧线;那样实际查的是弦,弧线可能经过完全不同的位置。

实现中让所有规划器共享碰撞接口,是为了公平且一致地定义可行性。算法A用真实机体,算法B用无体积点,即使B的路线更短,也不能说明B规划得更好。

调试检查表#

先确认坐标单位一致,再用手算验证点距和线段距;随后验证障碍膨胀、边界内缩和相切约定。最后才运行搜索。若碰撞检测本身错误,搜索效率再好,也只是在错误的可行空间里找路径。



Chapter 2: BFS and Dijkstra#

一、先理解BFS的“层”#

BFS使用先进先出队列。起点在第0层,走一条边能到的节点在第1层,再走一条边到第2层。第一次发现节点时,它的边数已经最少,因此可以立即标记seen,避免重复入队。

但最少边数不等于最小权重。例如S→G一条边成本10,S→A→G两条边成本1+1。BFS若按边数选择会选前者,带权最短路应该选后者。BFS适合单位边权图;权重均为同一正常数时也可先按边数求解。

队列开始:[S]
弹出S,加入第一次看到的邻居:[A, B]
弹出A,加入新邻居:[B, C]
弹出B,继续扩展:[C, ...]

每个节点、每条邻接边只需处理常数次,BFS为 O(V+E)O(V+E) 时间、O(V)O(V) 额外空间。

二、Dijkstra把“层数”换成累计代价#

给每个节点一个当前最优距离 gg。初始 g(S)=0g(S)=0,其他为无穷大。每次从未处理候选中取g最小的节点,然后尝试更新邻居。

松弛就是比较:

g(u)+c(u,v)<g(v).g(u)+c(u,v)<g(v).

若成立,经过u到v比之前方案便宜,于是更新 g[v] 和 parent[v]。父节点记录的是路径来源,不是图中所有邻居。

三、逐步手算一个例子#

有向边为S→A:4、S→B:1、B→A:2、B→G:7、A→G:1。

弹出节点该节点g松弛后的主要状态
S0A=4,B=1
B1A改为3,G=8
A3G改为4
G4返回S→B→A→G

目标第一次被发现时成本为8,但答案是4。因此不能在“第一次把目标放入堆”时结束;应在弹出目标的有效最优条目时结束。

四、为什么非负边权能保证正确#

假设弹出的u还有一条更短路径。沿那条路径找第一个尚未确定的节点y,它的前驱x已经确定。处理x时就会松弛y,因此堆中应该存在一个不大于那条更短路径前缀成本的候选。由于后续边非负,该前缀不会比抵达u的总代价更大,这与u是堆里最小候选矛盾。

负边会破坏“路径越走累计代价不会降低”的关键条件。遇到负权图应选择适合的算法,例如Bellman–Ford,并处理负环;不能继续假装Dijkstra仍然适用。本教程在输入边界直接拒绝负权。

五、Python最小堆如何处理更新#

heapq没有直接提供按节点索引修改优先级的接口。简单实现选择每次改善都压入新条目,保留旧条目;弹出时比较条目中的旧g与当前best,不一致就跳过。

例如先发现A=10,后来发现A=2,堆里可能同时存在两份A。A=10不是错误,只是过期条目。跳过它可以避免多余扩展。

条目用 (优先级, 计数器, g快照, 节点)。计数器避免优先级相同时Python尝试比较无法排序的节点对象。不要为了凑排序而要求所有业务节点必须是字符串。

六、完整代码:BFS与统一最短路引擎#

下面完整模块的 astar()是统一引擎。先忽略h,把它恒设为0,就是本篇的Dijkstra;下一篇A*再解释加入h之后的条件。这里不维护永久closed集合,后续允许节点在更便宜的g出现时重新扩展。

"""BFS and A*/Dijkstra on explicit finite graphs; A* permits reopening."""
from collections import deque
import heapq
import itertools
import math
def check_graph(graph, start, goal):
"""校验显式邻接表:graph[u]是(v,weight)列表。
每个邻居都必须是graph中的键,边权必须有限且非负。
不可达应以缺少边表达,不使用无穷或NaN充当普通权重。
"""
if start not in graph or goal not in graph:
raise ValueError('Unknown start or goal')
for edges in graph.values():
for node, weight in edges:
if node not in graph or not math.isfinite(weight) or weight < 0:
raise ValueError('Edges need known nodes and finite nonnegative weights')
def recover(parent, start, goal):
"""从goal沿parent回溯到start,再反转为正向路径。
仅在搜索已找到目标时调用;此时父链必须确实能回到start。
parent不是图本身,只记录当前最佳路径上每个节点的前驱。
"""
path = [goal]
while path[-1] != start:
path.append(parent[path[-1]])
return list(reversed(path))
def bfs(graph, start, goal):
"""在单位边权图上返回最少边数路径;不可达返回None。
BFS第一次发现节点时,就已用最少边数到达它,因此入队即标记seen。
该性质不适用于一般带权图;此实现明确拒绝非1权重。
"""
check_graph(graph, start, goal)
if any(weight != 1 for edges in graph.values() for _, weight in edges):
raise ValueError('This BFS example requires unit edge weights')
# 队列维护待扩展层;seen在入队时更新,防止同层节点反复发现同一邻居。
queue, parent, seen = deque([start]), {}, {start}
while queue:
# FIFO保证先处理较浅层;不能换成列表pop(),否则会变为深度优先。
node = queue.popleft()
# 只有有效目标条目被弹出时才能结束;第一次发现目标并不一定最优。
if node == goal:
return recover(parent, start, goal)
for neighbor, _ in graph[node]:
if neighbor not in seen:
# 第一次发现即确定最少边数;这一步仅在单位边权BFS中成立。
seen.add(neighbor)
parent[neighbor] = node
queue.append(neighbor)
return None
def astar(graph, start, goal, heuristic):
"""在有限非负权图上搜索,返回(path,cost,expansions)。
heuristic(node)提供从节点到goal的估计剩余代价。
为保证最优,调用者需保证h可采纳;本代码只检查数值格式,不能证明下界。
支持在找到更小g后重新扩展节点,因此也能处理可采纳但不一致的h。
失败返回(None,math.inf,expansions);expansions不含目标和过期条目。
"""
check_graph(graph, start, goal)
# 缓存h以固定本次搜索的估计值;输入须是静态启发函数,而非不断变化的预测。
h = {node: float(heuristic(node)) for node in graph}
if any(not math.isfinite(v) or v < 0 for v in h.values()) or h[goal] != 0:
raise ValueError('Require finite nonnegative h, with h(goal)=0')
# 相同f时用递增整数打破平局,避免比较节点对象本身。
counter = itertools.count()
# 条目依次是(f,平局序号,入堆时g快照,节点);起点g=0,所以f=h(start)。
queue = [(h[start], next(counter), 0.0, start)]
# best只存已发现的节点;未出现的键在松弛时视为无穷远。
best, parent, expansions = {start: 0.0}, {}, 0
while queue:
_, _, queued_g, node = heapq.heappop(queue)
# 更小g出现后旧条目仍留在堆里;用快照判断它是否已经过期。
if queued_g != best[node]:
continue # a cheaper entry superseded this one
# 只有有效目标条目被弹出时才能结束;第一次发现目标并不一定最优。
if node == goal:
return recover(parent, start, goal), queued_g, expansions
# 没有永久closed:如果以后又发现更小g,同一个节点可以重新扩展。
expansions += 1
for neighbor, weight in graph[node]:
# 松弛:起点到node的当前成本,再加node→neighbor的边权。
new_g = queued_g + weight
# 严格改善才改父节点;相等代价无需重入堆,也可避免零权环造成无限更新。
if new_g < best.get(neighbor, math.inf):
best[neighbor], parent[neighbor] = new_g, node
# 压入新条目而非原地改堆:heapq没有按节点直接降低键值的接口。
heapq.heappush(queue, (new_g + h[neighbor], next(counter), new_g, neighbor))
# 有限图的候选耗尽:当前图中目标不可达;不要返回一个未抵达目标的前缀。
return None, math.inf, expansions
def dijkstra(graph, start, goal):
"""把h恒设为0,复用同一松弛引擎,得到Dijkstra。
此时队列优先级f=g;非负边权保证有效条目按最短距离推进。
返回格式与astar一致,便于作为A*的正确性对照。
"""
return astar(graph, start, goal, lambda node: 0.0)
def grid_graph(width, height, blocked, diagonal=True):
"""构造二维整数格点的显式邻接表,作为搜索教学输入。
width、height约定为正整数;blocked保存禁止经过的(x,y)格点。
diagonal=False只走四邻域;True增加代价sqrt(2)的对角移动。
为禁止切角,斜向移动要求两个轴向侧邻格也存在。
此函数会物化整张图;大型地图通常改为按需生成邻居。
"""
# 先创建所有自由节点,再创建边,保证每条邻接边的目标都存在。
graph = {(x, y): [] for x in range(width) for y in range(height) if (x, y) not in blocked}
offsets = list(itertools.product((-1, 0, 1), repeat=2))
for x, y in graph:
for dx, dy in offsets:
if (dx == dy == 0) or (not diagonal and dx != 0 and dy != 0):
continue
target = x + dx, y + dy
if target not in graph:
continue
# Do not let diagonal edges cut across the corners of blocked cells.
# 斜边两侧有障碍就拒绝,防止从障碍接触的角落穿过去。
if dx and dy and ((x + dx, y) not in graph or (x, y + dy) not in graph):
continue
graph[(x, y)].append((target, math.hypot(dx, dy)))
return graph
def octile(a, b):
"""计算无障碍八邻域图的精确距离,供有障碍图作下界。
前提:轴向代价1、对角代价sqrt(2),坐标是同尺度整数网格。
若对角代价或运动规则改变,必须重新推导启发函数。
"""
dx, dy = abs(a[0] - b[0]), abs(a[1] - b[1])
return max(dx, dy) + (math.sqrt(2) - 1) * min(dx, dy)

代码文件:graph_search.py。recover()沿父链倒着走,再反转。若搜索耗尽则返回 None 和无穷代价,不能返回仅含起点的列表伪装成功。

grid_graph()只是把一个网格转成邻接表。8邻域允许直线和斜线移动,但要求斜线两侧的格子都自由,避免从两个障碍角之间穿过去。这个规则明确了图的边,最短路结论只针对这个图。

七、最小调用#

from graph_search import dijkstra
graph = {
'S': [('A', 4), ('B', 1)],
'A': [('G', 1)],
'B': [('A', 2), ('G', 7)],
'G': [],
}
path, cost, expansions = dijkstra(graph, 'S', 'G')
print(path, cost) # ['S', 'B', 'A', 'G'] 4.0

这里 expansions不计最终弹出的目标,也不计被跳过的旧条目。比较不同实现时必须先统一计数口径。

八、复杂度与练习#

邻接表加二叉堆的常见Dijkstra实现复杂度可写为 O((V+E)log⁡V)O((V+E)\log V);本代码保留旧条目,堆可能达到 O(E)O(E) 大小,更直接的界是 O((V+E)log⁡(E+1))O((V+E)\log(E+1)),简单图中与常见表达同阶。这里不把该界直接套用给允许重复扩展的不一致启发式A*。

练习:能否在入队时永久标记visited?BFS单位边权可以;Dijkstra不能,因为节点在第一次发现后可能收到更小距离。还应测试起点等于终点、零权边、孤立目标和非法负权。

延伸阅读:Planning Algorithms,第2章离散规划。

逐条观察堆的内容:为什么旧条目没有被删除#

继续使用S→A:4、S→B:1、B→A:2、B→G:7、A→G:1。本表省略平局序号,并把堆按优先级排序展示;真实heapq列表只保证堆序,不保证整个列表有序。

时刻待处理条目 (g,节点)best变化parent变化
初始化(0,S)S=0空
扩展S后(1,B),(4,A)B=1,A=4B←S,A←S
扩展B后(3,A),(4,A),(8,G)A改3,G=8A←B,G←B
扩展A=3后(4,A),(4,G),(8,G)G改4G←A
弹出旧A=4(4,G),(8,G)不变不变
弹出G=4返回最优成本4G←A←B←S

旧A的g快照为4,而best[A]=3,因此跳过。这里比较两个值是否一致,不是在比较几何坐标,也不是说“节点A永远不能再访问”。

对于Dijkstra的非负权条件,节点第一次以有效最小g弹出后,它的最短距离已经确定。对于允许不一致h的A*,同一个节点可能在未来出现更小g,所以统一引擎保留重开的能力。

三张表各自负责什么#

queue保存等待尝试的候选,允许有过期记录;best保存每个节点当前最小累计成本;parent保存实现这个成本的前驱。不要试图用一个visited集合替代这三者,它不能表示“已发现,但后来还能改善”。

更新时必须同时执行 best[v]=new_g 与 parent[v]=u。只改best不改parent,最终报告的数值可能是4,但恢复出来的路线仍是成本8的旧路线。

起点、目标和零权边的边界#

起点等于目标时直接返回单点路径,代价0;目标不可达时返回None和无穷,这是两个不同状态。零权边在非负图中允许存在。因为代码只在严格更小的成本出现时更新,零权环不会因“相同成本也更新”而让队列永远增长。

思考:如果需要保留所有最短路径,而不是一条,该怎么改?需要为等价最优前驱保存集合,并仔细处理零权环等情况;不能仅把 < 改成 <= 就认为实现完成。



Chapter 3: Traveling Salesperson Problem (TSP)#

一、TSP到底求什么#

给定n个城市和成本矩阵 cijc_{ij},从0出发,其他城市各访问一次,最后回到0,最小化闭合路线成本。城市可以是配送站、加工孔位、观测位置,成本可以是距离或预先计算的旅行时间。

固定起点后,候选顺序有 (n−1)!(n-1)! 种。对称TSP的正反方向代价相同,还能消除一部分重复;数量仍随n快速增长。TSP是一个优化问题,并不是某一种单独的算法。

若 cij=cjic_{ij}=c_{ji},称对称TSP;单行道、风向或不同方向旅行时间会产生非对称TSP。下面穷举与DP支持非对称有限非负成本;2-opt使用的四边差分仅针对对称矩阵。

二、穷举建立正确性基准#

固定城市0,枚举其他城市的排列,在两端加0,再逐条求和。它简单而准确,适合给更复杂实现做小规模对照。

0→1→2→3→0
0→1→3→2→0
0→2→1→3→0
...

逐条求完整路线代价,需要 O(n(n−1)!)O(n(n-1)!) 时间。教学代码限制最多9个城市,防止误把小实例演示用于大任务。

三、Held–Karp动态规划如何复用重复工作#

许多排列会遇到同一个子问题:“已经访问集合S,最后停在j,到这里的最小代价是多少?”后面的决策只依赖S和j,不必记住到达它的完整顺序。

定义:

DP[S,j]=从0出发,恰好访问非起点集合S,结束于j的最小代价.DP[S,j]=\text{从0出发,恰好访问非起点集合S,结束于j的最小代价}.

初始化 DP[{j},j]=c0jDP[\{j\},j]=c_{0j}。最后一步一定从某个 k∈S∖{j}k\in S\setminus\{j\} 走向j,所以:

DP[S,j]=min⁡k∈S∖{j}{DP[S∖{j},k]+ckj}.DP[S,j]=\min_{k\in S\setminus\{j\}}\{DP[S\setminus\{j\},k]+c_{kj}\}.

最终还要返回0:

OPT=min⁡j{DP[{1,…,n−1},j]+cj0}.OPT=\min_j\{DP[\{1,\ldots,n-1\},j]+c_{j0}\}.

这正是最优子结构:如果某个前缀不是对应子问题的最优解,可以把它换成更便宜的前缀,从而改善整条路线。

四、手算一个完整DP结果#

使用矩阵:

从/到0123
00101520
11003525
21535030
32025300

单点状态为10、15、20。例如 DP[{1,2},2]=10+35=45DP[\{1,2\},2]=10+35=45,而 DP[{1,2},1]=15+35=50DP[\{1,2\},1]=15+35=50。

三个城市全部访问后:

终点完整集合DP值返回0后的值
1min⁡(50+35,45+25)=70\min(50+35,45+25)=7080
2min⁡(45+35,35+30)=65\min(45+35,35+30)=6580
3min⁡(50+25,45+30)=75\min(50+25,45+30)=7595

最优值为80,例如0→1→3→2→0。不同最优排列可能同成本,因此测试应优先检查成本和排列合法性,而非只接受一个唯一顺序。

五、位掩码如何表示集合#

用整数二进制位表示访问状态。城市j对应第j−1位,因为起点0不在集合中。

001:只访问城市1
101:访问城市1和3
111:访问城市1、2、3

mask & (1 << (j-1))检查j是否在集合;mask ^ bit在确认该位存在后移除j。由于删除一个已置位的bit会得到更小整数,按mask递增计算时,所依赖的状态已经存在。

状态数为 O(n2n)O(n2^n),每个状态枚举前驱需要 O(n)O(n),总时间 O(n22n)O(n^2 2^n)、空间 O(n2n)O(n2^n)。这比阶乘好,但仍是指数算法。Python字典本身也有明显开销,不要把16城市教学上限误认为一个普遍性能结论。

六、最近邻与2-opt:速度换取最优性保证#

最近邻每次选当前城市到未访问城市中成本最小的一个,复杂度 O(n2)O(n^2)。它只看眼前,最后可能留下一个昂贵的返回边。

2-opt对现有闭合排列选择两条边 (a,b)(a,b) 与 (c,d)(c,d),换成 (a,c)(a,c)、(b,d)(b,d),并反转中间子序列。对称成本下,内部边反向不改变代价,因此改变量只需四条边:

Δ=cac+cbd−cab−ccd.\Delta=c_{ac}+c_{bd}-c_{ab}-c_{cd}.

若 Δ<0\Delta<0 则接受。非对称成本下,内部反向边的成本也变了,不能套这个差分。实现直接拒绝非对称输入,避免得到错误的改善判断。

正方形边长2,交叉路线0→2→1→3→0长约9.657;一次合适交换得到周长8。对于更复杂实例,2-opt可能停在没有改善交换的局部最优。每轮扫描 O(n2)O(n^2) 对,轮数取决于实例,不应把整个算法无条件写成一次 O(n2)O(n^2)。

七、完整代码#

"""Small exact TSP solvers plus nearest-neighbor and symmetric 2-opt."""
import itertools
import math
def validate(cost, limit=None):
"""检查教学TSP的成本矩阵,并返回城市数n。
此版本要求完整方阵、有限非负成本和至少两个城市。
limit限制精确算法的教学规模,不是普遍的性能或正确性阈值。
"""
n = len(cost)
if n < 2 or (limit is not None and n > limit):
raise ValueError('Unsupported teaching instance size')
if any(len(row) != n for row in cost):
raise ValueError('Cost matrix must be square')
if any(not math.isfinite(v) or v < 0 for row in cost for v in row):
raise ValueError('Require finite nonnegative costs')
return n
def tour_cost(cost, order):
"""按order中显式列出的相邻城市求和,不自动补闭合边。
例如[0,1,2,0]才是闭合路线;[0,1,2]的结果不含2→0。
这里假定编号有效,完整排列校验由相关调用者负责。
"""
return sum(cost[a][b] for a, b in zip(order, order[1:]))
def brute_force(cost):
"""固定起点0,枚举其余城市全部排列,返回(路线,最小成本)。
同时适用于对称和非对称矩阵。规模限制为9,适合作为小实例的真值对照。
成本相同时保留先遇到的解;最优路线不一定唯一。
"""
n = validate(cost, limit=9)
best, answer = math.inf, None
# 固定起点0消除循环平移重复;其余城市仍需枚举全部(n-1)!排列。
for perm in itertools.permutations(range(1, n)):
# 最后一个0是真正的返程边,不能省掉,否则解的是开放路线。
order = [0, *perm, 0]
length = tour_cost(cost, order)
if length < best:
best, answer = length, order
return answer, best
def held_karp(cost):
"""用状态压缩动态规划精确求解闭合TSP,返回(路线,最小成本)。
dp[(mask,j)]:从0出发,恰好访问mask包含的非起点城市,停在j的最小成本。
第j-1位代表城市j;起点0不在mask中。parent用于恢复实现最小值的前驱。
支持非对称矩阵,时间O(n²·2ⁿ)、空间O(n·2ⁿ),教学限制为16城市。
"""
n = validate(cost, limit=16)
# Bit j-1 indicates whether non-start node j has been visited.
# 键包含集合和终点;只用终点j会混淆访问过不同城市的子问题。
dp, parent = {}, {}
for j in range(1, n):
# 基例:只访问j时,唯一前缀是0→j,不需要记录中间前驱。
dp[(1 << (j - 1), j)] = cost[0][j]
# mask从小到大:删除一个已置位bit得到更小整数,因此依赖状态已经算过。
for mask in range(1, 1 << (n - 1)):
for j in range(1, n):
# 城市1对应最低位;城市0从不进入集合。
bit = 1 << (j - 1)
# 终点必须属于已访问集合;不满足条件的状态没有意义。
if not mask & bit:
continue
# 此时bit已确认存在,异或才等价于移除j;不能无条件用异或“删除”。
previous = mask ^ bit
# 单城市状态已由基例初始化,不再对空前驱集合求min。
if previous == 0:
continue
# 枚举最后一步k→j;min返回最小成本及实现它的前驱k,平局按编号。
value, k = min((dp[(previous, k)] + cost[k][j], k)
for k in range(1, n) if previous & (1 << (k - 1)))
dp[(mask, j)], parent[(mask, j)] = value, k
# 全1掩码表示所有非起点城市均已访问。
full = (1 << (n - 1)) - 1
# DP前缀还没回到0;这里加返程cost[j][0],然后选择最优最后一城。
value, last = min((dp[(full, j)] + cost[j][0], j) for j in range(1, n))
reverse, mask = [], full
while mask:
reverse.append(last)
# 先读取旧状态的前驱,再删除当前终点的bit;顺序反了会查错状态。
previous_last = parent.get((mask, last))
mask ^= 1 << (last - 1)
# 只剩基例时前驱为None,同时mask归零,下一轮不会再访问None。
last = previous_last
return [0, *reversed(reverse), 0], value
def nearest_neighbor(cost):
"""从城市0开始,每次贪心选择最近的未访问城市,再返回0。
返回合法闭合排列和成本,但不保证最优;可作为2-opt的初始解。
使用城市编号打破相同成本的平局,使结果更容易复现。
"""
n = validate(cost)
order, unused = [0], set(range(1, n))
while unused:
# 贪心只看当前一步:速度快,但没有为最终返程和以后选择保留全局保证。
node = min(unused, key=lambda j: (cost[order[-1]][j], j))
order.append(node)
unused.remove(node)
order.append(0)
return order, tour_cost(cost, order)
def two_opt(cost, order):
"""在对称TSP上反复接受第一个改善交换,返回局部改进路线和成本。
输入order必须从0出发、覆盖所有城市一次并返回0;不会修改调用者列表。
每次反转route[i:j+1],只比较被替换的两条边与新增的两条边。
此四边差分依赖对称性;非对称矩阵的内部反向成本也会改变,因此拒绝。
1e-12用于忽略浮点级微小改善,最终是该容差和邻域下的局部稳定结果。
"""
n = validate(cost)
if any(cost[i][j] != cost[j][i] for i in range(n) for j in range(n)):
raise ValueError('Four-edge 2-opt delta requires a symmetric matrix')
if len(order) != n + 1 or order[0] != 0 or order[-1] != 0 or sorted(order[:-1]) != list(range(n)):
raise ValueError('Require a closed permutation starting at 0')
# 复制输入,避免教学实验中的初始路线被原地改变而无法对照。
route = list(order)
while True:
# 采用first-improvement:发现一处改善就执行,然后从新路线重新扫描。
changed = False
for i in range(1, n - 1):
for j in range(i + 1, n):
# 切断a→b和c→d;反转b…c后,新接a→c和b→d。
a, b, c, d = route[i - 1], route[i], route[j], route[j + 1]
delta = cost[a][c] + cost[b][d] - cost[a][b] - cost[c][d]
# 忽略浮点舍入导致的极小变化;并非宣称1e-12适合任意代价尺度。
if delta < -1e-12:
# j+1使切片包含j;否则只换边的公式与实际反转范围不一致。
route[i:j + 1] = reversed(route[i:j + 1])
changed = True
break
if changed:
break
# 整个2-opt邻域都未发现超过容差的改善:局部稳定,不代表全局最优。
if not changed:
return route, tour_cost(cost, route)

附件:tsp.py。DP的parent记录达到该状态时选中的前驱;恢复路径时必须同时移除当前终点对应的bit,不能只改终点而保留集合。

from tsp import held_karp, nearest_neighbor, two_opt
cost = [[0, 10, 15, 20], [10, 0, 35, 25],
[15, 35, 0, 30], [20, 25, 30, 0]]
print(held_karp(cost)) # 最优成本80,排列可能有等价解
route, length = nearest_neighbor(cost)
print(two_opt(cost, route))

八、与障碍规划、LKH的关系#

成本矩阵可以由直线距离得到,也可以由两点间避障路径长度得到。但“先直线距离排序,再绕障”只优化了一个替代目标,不保证最终避障总长最优。若某些城市对不可达,需要显式处理不可达边;本教学实现要求完整有限矩阵,不用随意的大常数伪装无穷。

LKH属于更复杂的Lin–Kernighan启发式体系,不等同于本篇简单2-opt;遗传算法、蚁群算法也可以用于TSP,但要先用小实例精确解检查结果,再比较速度和解质量。

原始动态规划工作见 Held与Karp:A dynamic programming approach to sequencing problems。

练习:DP可以删除集合只保留 dp[j] 吗?不行,同样结束在j,但已访问集合不同,未来允许访问的城市不同。第二问:2-opt一次变短能证明全局最优吗?不能,它只证明比修改前更好。

跟着一个DP状态回溯:mask和last必须一起变#

仍使用正文四城市矩阵。三个非起点城市分别对应001、010、100。状态 (111,2) 的值是65,因为最便宜前缀是0→1→3→2;这里末尾的2是城市编号,不是二进制位。

它的转移为:

当前状态:(111, 2)
移除城市2:111 XOR 010 = 101
前驱候选:城市1和城市3
经城市1:DP[101,1] + c[1][2] = 45 + 35 = 80
经城市3:DP[101,3] + c[3][2] = 35 + 30 = 65
选择城市3,parent[(111,2)] = 3

最后加上2→0的15,得到闭合成本80。本实现若最终有同成本候选,会按城市编号选择较小的last;下表为说明数据结构,展示末城选2时的一条同样最优的回溯过程,不要求程序平局时也选它。

回溯前 (mask,last)追加到reverse读取的前驱去掉当前城市后的mask
(111,2)23101
(101,3)31001
(001,1)1None,基例000

reverse现在是[2,3,1],反转得到[1,3,2],两边补起点0,恢复0→1→3→2→0。读取parent必须在修改mask之前,否则查询的是另一个子问题。

为什么状态不能再少,为什么又不用存整条路线#

只记j会丢失剩余城市集合:0→1→3与0→2→3都停在3,但未来允许访问的城市不同。反过来,在相同(S,j)下,未来可选择的动作与过去的具体排列无关,过去成本更高的前缀可以被较低成本前缀替代,所以不必为每个排列单独保留状态。

这是动态规划最重要的建模练习:找到“足以决定未来,但没有多余历史”的状态。若以后加入时间窗,抵达时间可能影响未来可行性,原来的(S,j)就未必足够,需要重新建模,而不是机械复用此DP。

2-opt的一次切片究竟改变了哪些边#

设路线为 [0,A,B,C,D,0],选择i=1、j=3,反转 route[1:4]:

旧:0 → A → B → C → D → 0
新:0 → C → B → A → D → 0
删除:0→A、C→D
增加:0→C、A→D
内部:A→B→C 变成 C→B→A

对称矩阵里内部两条边反向代价不变,所以只用四边差分。若是单行路,A→B与B→A不等,内部变化不能忽略。

Python切片右边界不包含端点,因此必须写j+1。本实现每次接受第一处改善后立即退出两层扫描,回到新路线从头检查;这是first-improvement。另一种best-improvement会先看完整个邻域再选最好交换,两者轨迹和耗时可能不同,但都不是全局最优保证。

实验中的三个“最优”措辞#

穷举和Held–Karp完成后,可称当前矩阵的精确最优;2-opt停止,只能称在当前交换邻域和容差下未找到改善;欧氏矩阵的最优顺序,也只是对这个矩阵最优,不等于考虑拥堵、障碍、时间窗后的真实任务最优。



Chapter 4: A* Search#

一、A*比Dijkstra多了什么#

Dijkstra只知道已经付出的代价g。A*再加入一个对剩余代价的估计h,按 f(n)=g(n)+h(n)f(n)=g(n)+h(n) 选择扩展节点。

例如两条前缀当前代价分别为4和6,但第一条估计还需10,第二条只需2,A*更愿意探索第二条,因为8小于14。这里g是真实累计代价,h是估计,不能随意混用米和秒。

策略优先级行为
Dijkstrag从起点按累计代价推进
贪心最佳优先h只看离目标似乎多近
A*g+h同时考虑已付出和剩余估计

二、可采纳性:不能高估#

令 h∗(n)h^*(n) 为真实最小剩余代价。可采纳性要求 0≤h(n)≤h∗(n)0\le h(n)\le h^*(n),且目标h为0。

只要尚有更便宜的解未发现,通往它的某个候选前缀的f就不会超过该解总代价。如果A*正确处理代价改善,在弹出有效目标条目时,不会跳过更便宜的解。这个论证依赖估计下界及搜索实现,不能只看到代码有g+h就断言最优。

高估反例:S→A:1、A→G:1、S→G:3,若设h(A)=100、h(G)=0,A*可能先弹出成本3的G,而真实最优为2。

三、一致性:相邻估计不能跳得太离谱#

一致性要求对每条边:

h(u)≤c(u,v)+h(v).h(u)\le c(u,v)+h(v).

相当于h满足沿边的三角不等式。沿一条路径,f不会下降,这使标准图搜索可以在节点以最小有效f弹出后将其永久关闭。

可采纳不必一致。若h不一致,就可能已经扩展了节点A,后来又发现更便宜的g(A)。这时必须允许重开;否则会丢失最优解。

四、一个必须重开的手算例子#

边:S→A:2、S→B:1、B→A:0.5、A→G:2。设置h(S)=3.5、h(A)=0、h(B)=2.5、h(G)=0。

这些值都不高估,但B→A违反一致性,因为2.5大于0.5+0。

操作结果
展开SA的f=2,B的f=3.5
先展开A得到G成本4
再展开BA改善为1.5,需要重新展开
再次展开AG改善为3.5
弹出G返回S→B→A→G

本教程不使用永久closed集合,只比较弹出条目的g快照和best。只要出现严格更小的g,就更新并入堆,因此支持这种重开情况。

五、启发函数怎样从移动规则推导#

5.1 四邻域:曼哈顿距离#

只允许水平或竖直一步,代价为1时,剩余水平差dx和垂直差dy都需要分别消除:

h4=dx+dy.h_4=dx+dy.

5.2 八邻域:Octile距离#

直走代价1,斜走代价 2\sqrt2。先用 min⁡(dx,dy)\min(dx,dy) 步对角消掉共同部分,再走剩余单轴:

h8=max⁡(dx,dy)+(2−1)min⁡(dx,dy).h_8=\max(dx,dy)+(\sqrt2-1)\min(dx,dy).

例如差值(3,2),两步斜线加一步水平,代价为 22+1≈3.8282\sqrt2+1\approx3.828。欧氏距离 13≈3.606\sqrt{13}\approx3.606 是较松的下界;曼哈顿距离5高估了允许对角的代价,因此不适用于这里的最优A*。

如果斜走代价也设成1,则相应距离是Chebyshev距离 max⁡(dx,dy)\max(dx,dy)。启发函数必须匹配实际动作和边权。

5.3 三维26邻域#

将三轴差排序为 a≤b≤ca\le b\le c,空网格最短距离为:

h26=a3+(b−a)2+(c−b).h_{26}=a\sqrt3+(b-a)\sqrt2+(c-b).

先三轴一起走a步,再双轴走b−a步,最后单轴走c−b步。若网格单位对应r米,g和h都应统一转换或都留在网格单位中。

障碍删掉节点或边,不会让实际代价更短,因此空网格距离仍是下界。不过加入传送边、负代价或更便宜的新动作后,这个推理可能失效。

六、实际A*代码#

本函数从 graph_search.py 原样提取。导入、check_graph()、recover()与网格构图代码见第02篇完整模块和 源文件。

def astar(graph, start, goal, heuristic):
"""在有限非负权图上搜索,返回(path,cost,expansions)。
heuristic(node)提供从节点到goal的估计剩余代价。
为保证最优,调用者需保证h可采纳;本代码只检查数值格式,不能证明下界。
支持在找到更小g后重新扩展节点,因此也能处理可采纳但不一致的h。
失败返回(None,math.inf,expansions);expansions不含目标和过期条目。
"""
check_graph(graph, start, goal)
# 缓存h以固定本次搜索的估计值;输入须是静态启发函数,而非不断变化的预测。
h = {node: float(heuristic(node)) for node in graph}
if any(not math.isfinite(v) or v < 0 for v in h.values()) or h[goal] != 0:
raise ValueError('Require finite nonnegative h, with h(goal)=0')
# 相同f时用递增整数打破平局,避免比较节点对象本身。
counter = itertools.count()
# 条目依次是(f,平局序号,入堆时g快照,节点);起点g=0,所以f=h(start)。
queue = [(h[start], next(counter), 0.0, start)]
# best只存已发现的节点;未出现的键在松弛时视为无穷远。
best, parent, expansions = {start: 0.0}, {}, 0
while queue:
_, _, queued_g, node = heapq.heappop(queue)
# 更小g出现后旧条目仍留在堆里;用快照判断它是否已经过期。
if queued_g != best[node]:
continue # a cheaper entry superseded this one
# 只有有效目标条目被弹出时才能结束;第一次发现目标并不一定最优。
if node == goal:
return recover(parent, start, goal), queued_g, expansions
# 没有永久closed:如果以后又发现更小g,同一个节点可以重新扩展。
expansions += 1
for neighbor, weight in graph[node]:
# 松弛:起点到node的当前成本,再加node→neighbor的边权。
new_g = queued_g + weight
# 严格改善才改父节点;相等代价无需重入堆,也可避免零权环造成无限更新。
if new_g < best.get(neighbor, math.inf):
best[neighbor], parent[neighbor] = new_g, node
# 压入新条目而非原地改堆:heapq没有按节点直接降低键值的接口。
heapq.heappush(queue, (new_g + h[neighbor], next(counter), new_g, neighbor))
# 有限图的候选耗尽:当前图中目标不可达;不要返回一个未抵达目标的前缀。
return None, math.inf, expansions

逐段阅读时重点看四处:

  1. h的检查只保证有限、非负、目标为零;代码不能自动证明h不高估。
  2. 弹出时检查g快照,跳过旧条目;不是用visited拒绝一切后续改善。
  3. 在有效条目弹出时才判断目标,不在邻居第一次入堆时结束。
  4. 仅当新g严格改善才更新父节点,避免零代价边不断制造等价循环。

使用方式:

from graph_search import astar, grid_graph, octile
blocked = {(5, y) for y in range(10) if y != 7}
graph = grid_graph(12, 10, blocked)
start, goal = (1, 1), (10, 1)
path, cost, expansions = astar(graph, start, goal, lambda p: octile(p, goal))
print(cost, expansions)

七、最优性和效率不要混淆#

更紧的可采纳估计通常能减少探索,但计算h本身也可能昂贵;平局处理会改变具体展开顺序。h=0就是可靠但不朝目标偏好的基线。

一致启发式下,图搜索的复杂度与Dijkstra类似;允许不一致h并重开的实现可能重复展开节点,不再能无条件套用一次扫描每条边的界。最坏情况中,搜索仍可能很大。

Weighted A采用 g+whg+wh,w>1w>1可能更快偏向目标,但通常牺牲精确最优性。不要把它当作普通A只改一个常数却不改变保证。

相关基础见 Hart、Nilsson、Raphael的A*原论文。本篇代码选择支持重开的实现,明确区分可采纳性和一致性。

练习:把本例Octile换成曼哈顿,结果可能仍然正确,是否就证明曼哈顿可采纳?不是。一个实例没出错不能推翻(1,1)差值下2大于 2\sqrt2 的反例。

用候选前缀理解最优性,而不是背结论#

假设最优路径总成本为 C∗C^*。在目标被最优方式处理前,最优路径上至少存在一个已经发现、尚待适当扩展的前缀节点n。在允许正确松弛与重开的条件下,它可以携带对应最优前缀代价 g∗(n)g^*(n)。若h可采纳,则:

f(n)=g∗(n)+h(n)≤g∗(n)+h∗(n)=C∗.f(n)=g^*(n)+h(n)\le g^*(n)+h^*(n)=C^*.

假如当前弹出目标的代价C却大于 C∗C^*,该目标的f=C,而堆中仍有不超过 C∗C^* 的候选,这与“弹出最小f”矛盾。这个证明也解释了为什么必须在弹出有效目标条目时结束,而不是在任意一条边第一次碰到目标时结束。

如果错误地永久关闭了本应重开的节点,那个最优前缀候选可能被实现丢掉,证明前提就不再成立。因此“启发函数可采纳”不能脱离具体图搜索实现单独使用。

从一致性推导f沿路径不下降#

对边u→v,如果v通过u得到新前缀,则 g(v)=g(u)+c(u,v)g(v)=g(u)+c(u,v)。由一致性:

f(v)=g(u)+c(u,v)+h(v)≥g(u)+h(u)=f(u).f(v)=g(u)+c(u,v)+h(v)\ge g(u)+h(u)=f(u).

这个不等式说明估计总成本不会沿着一条前缀突然降低。沿任意到目标的路径反复应用一致性,并使用h(goal)=0,还能得到h(u)不大于该路径的剩余成本;取最短路径即得可采纳性。反过来不成立,正文B→A的反例就是证据。

用一张表区分四种情况#

h的性质是否允许重开可以怎样理解
h=0可允许,也可使用正确永久关闭策略Dijkstra基线
一致,且目标h=0允许或按标准规则关闭可以保持最短路保证
可采纳但不一致必须正确处理更小g,通常需重开本教程实现支持
可能高估即使重开也不自动最优需要另外的算法条件和分析

启发函数校验只能检查有限性、非负、目标为零;“没有高估”通常需要由问题结构推导,不能简单扫描每个h就验证,除非你已经另外知道真实剩余最短成本。

为什么不能一律用欧氏距离#

若每条边代价至少等于该边几何长度,欧氏直线距离可作为到目标路径长度的下界。如果代价是时间,应使用例如“直线距离÷有效全局速度上界”得到同量纲下界;如果有瞬移边、折扣代价或代价表示与距离无关的业务惩罚,欧氏距离未必可采纳。

还有一个平局细节:本实现的条目是 (f,递增序号,g,节点),同f时按入堆先后处理。另一种实现用较小h打破平局,可能产生不同展开数量或不同等价最短路线。这不意味着其中一个必然错误,比较前应统一边权、启发函数和计数口径。



Chapter 5: RRT#

一、为什么需要采样规划#

二维网格容易实现,但每个维度划分m份时,d维状态空间需要约 mdm^d 个格子。一个6关节机械臂的状态不是平面位置,而是6个角度;全量网格很快变大。

RRT不先铺满所有状态,而是从起点长出一棵树,在需要的位置采样并扩展。它也不是高维问题的万能解,距离度量、碰撞检查、窄通道和局部连接器都会影响效果。

二、一次迭代只做五件事#

1. Sample:生成随机目标q_rand
2. Nearest:找树里离q_rand最近的节点q_near
3. Steer:从q_near朝q_rand前进最多η,得到q_new
4. CollisionFree:检查整条q_near→q_new
5. Add:如果可行,把q_new加入树并记录父节点

每个新节点只连向已有节点,所以天然可以维持树。找到能连接目标的节点后,沿父链回溯,就得到起点到目标的路径。

三、Steer公式与手算#

本教程二维直线连接器为:

qnew=qnear+min⁡(1,η∥qrand−qnear∥)(qrand−qnear).q_{new}=q_{near}+\min\left(1,\frac{\eta}{\|q_{rand}-q_{near}\|}\right)(q_{rand}-q_{near}).

若q_near=(0,0)、q_rand=(3,4)、η=2,距离为5,因此新点是(1.2,1.6)。如果目标离得不足η,直接到达采样点;两点相等时直接返回,避免除零。

def steer(source, target, step):
"""从source向target沿直线走不超过step的距离。
调用者保证step为正、两点为合法二维坐标;本函数不检查碰撞。
目标很近就直接到达目标,不能用固定步长越过采样点。
"""
distance = math.dist(source, target)
if distance == 0:
return source
# 比例最多为1,确保目标比步长更近时不会越过目标。
ratio = min(1.0, step / distance)
return tuple(a + ratio * (b - a) for a, b in zip(source, target))

step太小会让树增长慢、节点多;太大在障碍密集处容易整条边被拒绝。但不能简单说“大步长一定跨过窄通道”——连续碰撞检查能拒绝穿障,真正困难还包括采样概率和几何连接。

四、为什么会向未探索区域扩展#

每个已有节点拥有一片“比其他节点更近”的区域,即Voronoi区域。均匀采样更容易落入面积较大的区域,使对应节点更常被选为最近邻。树的边缘节点通常有较大的未覆盖区域,所以自然产生向外探索的倾向。

这就是采样+最近邻组合产生的探索偏置,不需要人为写“优先边缘节点”的规则。背景可读 LaValle关于RRT的作者说明 和 1998年技术报告。

五、目标偏置与正确的终止判断#

本代码以5%概率直接把目标作为采样点,其余均匀随机采样。这样可以增加向目标生长的机会,但保留绕障探索。

如果偏置设为100%,树可能一直往被墙挡住的目标直线推进,失去探索机会;代码要求小于1。偏置不是越大越好,应在相同场景和预算下比较。

距离目标足够近还不能宣布成功。必须检查 q_new→goal 整条边,最终返回路径的最后一个坐标必须确实是goal,不能只停在“附近”。

六、完整实现在哪里,如何逐段阅读#

RRT与RRT*共用 sampling.py 中的 plan(),使用 star=False执行基本RRT。完整模块在第06篇列出;两者共用采样、碰撞和回溯逻辑,避免两份实现逐渐不一致。

基本RRT对应的控制流程是:

# 示意伪代码;可执行版本见sampling.py
nodes = [start]
for _ in range(iteration_budget):
random_target = sample()
nearest = nearest_node(nodes, random_target)
new_point = steer(nearest.point, random_target, step)
if not edge_free(nearest.point, new_point):
continue
new_node = add_node(new_point, parent=nearest)
if close_to_goal(new_point) and edge_free(new_point, goal):
return recover_path(new_node, goal)
return None

这段伪代码展示思路,不包含样例环境和辅助函数定义;下面调用才是可直接运行的版本:

from geometry import World
from sampling import plan
world = World((0, 10, 0, 10), ((5, 5, 1.8), (3, 7, 0.8)), 0.15)
result = plan(world, (1, 1), (9, 9), star=False, seed=7,
iterations=1500, step=0.8, goal_bias=0.05)
print(result['cost'], result['iterations'])
assert result['path'] is not None

本次示例在第115次迭代找到路径。这个数字依赖实现、输入、种子与参数,不是算法固定行为。测试中还包括只允许1次小步扩展的失败案例。

七、返回None究竟表示什么#

对有限预算的RRT,None表示“在本次预算内没找到路径”,不表示已证明空间无路。树可能没有采到窄通道,也可能步长或局部连接器不适合这个状态空间。

在适当采样、可连接性及具有足够间隙的可行解等条件下,经典RRT具有概率完备性质:样本趋于无限时,漏掉可行解的概率趋于零。它不是任意时间预算内的成功承诺,也不是有限算法的不可达证明。

基本RRT通常找到一条就返回,不优化树中的既有连接,因此不保证最短路径。随机种子固定有利于调试;评估效果则需要多个种子,不能只挑成功的一次。

八、实现成本与常见错误#

本教程用线性扫描找最近邻,n个节点时一次 O(n)O(n);增长N次的扫描开销可到 O(N2)O(N^2),另有障碍检查。空间为 O(N)O(N)。KD-tree等索引可能改善低维查询,但高维状态和动态插入仍需具体评估。

常见错误包括:只查新点不查边、把目标附近当成到达、漏掉父节点、零长度方向除零、每次循环重置随机种子、无解时返回最后一个节点伪装成功。

练习:把同样的采样算法用于汽车,Steer还能是任意直线吗?通常不能。车辆具有朝向和转弯约束,必须换成可执行的局部连接方式,甚至把速度纳入状态。

手动指定采样序列:看清一次迭代会不会加节点#

下面是便于手算的人工样本序列,不是seed=7演示日志。设起点S=(0,0)、目标G=(4,0)、步长1;障碍圆中心(2,0)、半径0.4;边界为[-1,5]×[-1,5],点机器人。目标连接阈值也是1。

尝试人工采样点最近树节点Steer输出决策
1(4,0)(0,0)(1,0)边安全,加入
2(4,0)(1,0)(2,0)进入障碍,拒绝,树不变
3(1,2)(1,0)(1,1)边安全,加入
4(2,1)(1,1)(2,1)从障碍上方通过,加入
5(3,1)(2,1)(3,1)加入,但到G距离 2>1\sqrt2>1
6(4,0)(3,1)约(3.7071,0.2929)加入,距G约0.4142,可安全接G

返回路径沿父链为:S→(1,0)→(1,1)→(2,1)→(3,1)→(3.7071,0.2929)→G。最后两个对角段共长 2\sqrt2,总长度为 4+2≈5.41424+\sqrt2\approx5.4142。

第二次失败并没有删除(1,0),也没有把这个节点标记为永久不能扩展。下一次采样换了方向,它仍可能长出一条可行边。RRT探索的是不同连接方向,不是某个节点一失败就没有价值。

最容易混淆的三个“距离”#

Nearest比较树节点到随机样本的几何距离;cost是起点沿父链到节点的累计路径长度;“接近目标”比较节点到goal的几何距离。它们不能互换。

一个节点可能几何上离目标很近,但绕了很长的路才到达;基本RRT仍可能通过它首先成功。RRT*之所以增加cost和重接,正是为了改善这种累计路线,而不只是继续靠近目标。

采样尝试数为什么不等于节点数#

每次循环都会消耗一次尝试,但新点与已有点近乎重合、扩展边碰撞时不会加入节点。因此通常有“节点数≤尝试数+1”。本实现不把最终goal作为独立树节点追加,所以返回路径的最后一点不必出现在nodes列表中;最后连接已经单独验证。

返回的history记录最佳目标连接的成本,而不是每个节点的cost列表。基本RRT通常只有首解记录;RRT*可能有多个改善记录,尚未找到解的阶段不应画成成本0。

窄通道与概率直觉#

若某个有用的采样区域占自由空间概率比例p,独立均匀采样N次至少落入一次的概率为 1−(1−p)N1-(1-p)^N。这是帮助理解稀少区域难被采到的简化计算,不是RRT成功概率的精确公式:真正连接还取决于当前树、步长、障碍和局部可连接性。

例如p=0.001时,100次至少采到一次的概率只有约9.52%;1000次约63.23%。即使采到了,该扩展也可能被障碍挡住。增加目标偏置还会减少均匀探索尝试,所以参数需要结合场景评估。



Chapter 6: RRT*#

一、RRT*多做了哪两件事#

RRT以最近邻为父节点,长出来后基本不改。RRT*在加入新节点时,先寻找附近节点中谁能提供更便宜的起点到新点路径;加入后,再检查附近已有节点能否经新点降低总代价。

RRT:最近邻 → 新节点
RRT*:附近节点中选更好的父节点 → 新节点 → 尝试改善附近旧节点

这里“更好”指从起点出发的累计成本更低,而不是单条连接边更短。

二、选父节点:比较总成本#

新点为x,附近候选集合为 XnearX_{near},选择:

p∗=arg⁡min⁡p∈Xnear,  edge(p,x)  free{g(p)+∥p−x∥}.p^*=\arg\min_{p\in X_{near},\;edge(p,x)\;free} \{g(p)+\|p-x\|\}.

例:A离x只有1米,但g(A)=10;B离x2米,g(B)=4。通过A总成本11,通过B总成本6。若边都无碰撞,应选B。

代码先保留已验证的最近邻作为默认父节点,再尝试邻域内更便宜的选择。不能因为某候选累计成本小就跳过它与x之间的碰撞检查。

三、重接线:新节点也能帮助旧节点#

新点x加入后,对邻域节点y检查:

g(x)+∥x−y∥<g(y).g(x)+\|x-y\|<g(y).

如果新连接无碰撞且不会成环,就把y的父节点改成x。比如g(x)=6,x到y为1,原g(y)=12,则y改善为7。

四、为什么必须更新整棵子树#

假设y还有子节点z,y→z的边长为3。原g(z)=15,y重接后g(y)=7,则g(z)必须改成10。如果只改y的父节点而保留后代旧cost,后续选父节点、重接线和目标比较都会使用错误成本。

def reparent(nodes, child, parent):
"""将child子树挂到parent下面,并重新计算整棵子树的累计cost。
调用者须先确认新边无碰撞、且满足所需的改善条件。
本函数只负责结构与代价维护,不自行做障碍检查,也不要求新cost一定降低。
根节点不能改父节点,任何会导致环的修改都会拒绝。
"""
if child == 0 or is_ancestor(nodes, child, parent):
raise ValueError('Reparenting would disconnect the root or create a cycle')
# 重接必须同时维护父指针和两个父节点的children集合,避免结构不一致。
old_parent = nodes[child].parent
nodes[old_parent].children.remove(child)
nodes[parent].children.add(child)
nodes[child].parent = parent
# Descendants keep their geometric edges, but all root-path costs change.
# 父节点先更新,再把孩子压栈;后代计算cost时其父cost已经是新值。
stack = [child]
while stack:
index = stack.pop()
node = nodes[index]
node.cost = nodes[node.parent].cost + math.dist(node.point, nodes[node.parent].point)
# 排序使遍历顺序稳定;对子树cost的数学结果没有影响。
stack.extend(sorted(node.children))

代码用children集合沿子树遍历,重新从父节点计算每个cost;更新几何连接的同时维护旧父与新父的children。注意重新计算的是累计代价,后代点的空间坐标和几何边并没有变化。

树的不变量是:根无父节点;每个非根节点恰有一个父节点;没有环;父子关系双向一致;每个节点cost等于根到该点的路径长度。若让节点挂到自己的后代上,会产生环,因此显式检查祖先关系。

五、邻域半径为什么随节点数变化#

典型RRT*的半径形式是:

rn=min⁡(η,γ(log⁡nn)1/d).r_n=\min\left(\eta,\gamma\left(\frac{\log n}{n}\right)^{1/d}\right).

其中d是空间维度,η是局部连接尺度,γ控制邻域大小。二维时指数为1/2。随着样本增加,半径减小,但附近候选数量在理想均匀条件下仍与 log⁡n\log n 相关。

直觉是:早期稀疏,需要更大范围连接;后期密集,不必每次与全体节点比较。γ需要与空间尺度、维度和自由空间测度相匹配。代码默认γ=20是当前10×10教学环境的演示参数,不是所有地图的通用正确参数。

原始渐近最优性结论见 Karaman与Frazzoli:Sampling-based Algorithms for Optimal Motion Planning。结论涉及采样、连接尺度、环境与代价等假设,不能把公式抄入任意实现后就宣称得到了完整证明。

六、RRT*为什么不能找到第一条就结束#

如果目标一可达就返回,后续选父节点和重接线没有机会改善解。教学代码在 star=True时继续运行到给定迭代预算,保存所有可直连目标的候选节点,比较它们最新的cost加最后一段边长。

重接线可能改善很早以前就发现的目标候选,所以不能只在“新节点接近目标”时更新答案。实现每次有效扩展后重新评估所有已有目标连接候选。

history仅在最佳可行代价下降时追加记录,因此可以画出阶梯状改善曲线。对于固定输入和同一随机序列前缀,保留历史最好解的成本不会增加;这不等于每次迭代都严格下降。

七、完整RRT/RRT*模块#

下面完整实现依赖第01篇 geometry.py 的 path_length。所有文件放在同一examples目录即可运行。

"""Readable planar RRT/RRT* with linear scans and continuous edge checking."""
from dataclasses import dataclass, field
import math
import random
from geometry import path_length
@dataclass
class Node:
# 节点索引是稳定ID;坐标相同也不等同于同一个父子结构。
point: tuple
# 根的parent为None,其他节点的parent是nodes列表中的整数索引。
parent: object
# cost是从起点沿父链累计的边长,不是节点到目标的距离。
cost: float
# default_factory让每个节点拥有独立集合;不能让所有节点共享同一个可变默认值。
children: set = field(default_factory=set)
def steer(source, target, step):
"""从source向target沿直线走不超过step的距离。
调用者保证step为正、两点为合法二维坐标;本函数不检查碰撞。
目标很近就直接到达目标,不能用固定步长越过采样点。
"""
distance = math.dist(source, target)
if distance == 0:
return source
# 比例最多为1,确保目标比步长更近时不会越过目标。
ratio = min(1.0, step / distance)
return tuple(a + ratio * (b - a) for a, b in zip(source, target))
def is_ancestor(nodes, ancestor, child):
"""沿child的父链判断ancestor是否在链上,包含自身。
用于重接前防环:若child已是新父节点的祖先,就不能反过来挂到它下面。
调用前树结构应有效;此函数不是用来修复已经存在的环。
"""
while child is not None:
if child == ancestor:
return True
child = nodes[child].parent
return False
def reparent(nodes, child, parent):
"""将child子树挂到parent下面,并重新计算整棵子树的累计cost。
调用者须先确认新边无碰撞、且满足所需的改善条件。
本函数只负责结构与代价维护,不自行做障碍检查,也不要求新cost一定降低。
根节点不能改父节点,任何会导致环的修改都会拒绝。
"""
if child == 0 or is_ancestor(nodes, child, parent):
raise ValueError('Reparenting would disconnect the root or create a cycle')
# 重接必须同时维护父指针和两个父节点的children集合,避免结构不一致。
old_parent = nodes[child].parent
nodes[old_parent].children.remove(child)
nodes[parent].children.add(child)
nodes[child].parent = parent
# Descendants keep their geometric edges, but all root-path costs change.
# 父节点先更新,再把孩子压栈;后代计算cost时其父cost已经是新值。
stack = [child]
while stack:
index = stack.pop()
node = nodes[index]
node.cost = nodes[node.parent].cost + math.dist(node.point, nodes[node.parent].point)
# 排序使遍历顺序稳定;对子树cost的数学结果没有影响。
stack.extend(sorted(node.children))
def recover(nodes, index, goal):
"""从一个已确认可连接goal的树节点回溯完整路径。
父链先得到终点到起点的逆序,反转后再追加精确goal。
最后一段边已经由plan验证;这里不以“接近goal”代替实际到达。
"""
points = []
while index is not None:
points.append(nodes[index].point)
index = nodes[index].parent
points.reverse()
# 避免节点恰等于goal时再追加一个相同终点,产生冗余零长度边。
if points[-1] != goal:
points.append(goal)
return points
def plan(world, start, goal, *, star=False, seed=7, iterations=1500,
step=0.8, goal_bias=0.05, gamma=20.0):
"""在World中运行二维RRT或RRT*,返回含路径和树的字典。
start/goal约定为二维坐标元组;碰撞、边界由world负责。
star=False:第一条可行路出现就返回。star=True:运行到预算结束并改善路径。
step是最大扩展长度;goal_bias是直接采样目标的概率;seed控制局部随机序列。
gamma是二维RRT*邻域半径系数,需结合地图尺度理解,不是通用最佳参数。
iterations统计采样尝试次数,包括因重复或碰撞而被拒绝的尝试。
输出path为None表示预算内未找到解,不是无路证明;失败cost为math.inf。
nodes保存树;history仅记录最佳可行总代价严格下降的(迭代,代价)。
"""
if not world.point_free(start) or not world.point_free(goal):
raise ValueError('Invalid start or goal')
if not isinstance(iterations, int) or iterations <= 0:
raise ValueError('Iterations must be a positive integer')
if not all(math.isfinite(v) for v in (step, goal_bias, gamma)) or step <= 0 or gamma <= 0 or not 0 <= goal_bias < 1:
raise ValueError('Require step > 0, gamma > 0, and 0 <= goal_bias < 1')
# 初始化根;树中没有任何节点时,最近邻查询无从谈起。
nodes = [Node(tuple(start), None, 0.0)]
if start == goal:
return {'path': [start], 'cost': 0.0, 'nodes': nodes, 'history': [(0, 0.0)], 'iterations': 0}
# 局部随机生成器只初始化一次;不影响调用者全局随机状态,也不能在循环中反复重置。
rng = random.Random(seed)
x0, x1, y0, y1 = world.bounds
r = world.robot_radius
# connectors存已验证能直连goal的节点索引;几何不变,但累计cost会因重接变化。
connectors, history, best = set(), [], math.inf
# 处理起终点本就能在一个步长内直连的边界情况;仍然检查整条边。
if math.dist(start, goal) <= step and world.edge_free(start, goal):
connectors.add(0)
best = math.dist(start, goal)
history.append((0, best))
if not star:
return {'path': [start, goal], 'cost': best, 'nodes': nodes, 'history': history, 'iterations': 0}
for iteration in range(1, iterations + 1):
# 以goal_bias概率直接采目标;否则在缩小后的边界均匀采样,障碍内采样后由边检查拒绝。
target = goal if rng.random() < goal_bias else (rng.uniform(x0 + r, x1 - r), rng.uniform(y0 + r, y1 - r))
# 教学版线性查最近邻;这是几何距离最近,不代表根路径累计cost最小。
nearest = min(range(len(nodes)), key=lambda i: math.dist(nodes[i].point, target))
new = steer(nodes[nearest].point, target, step)
# 拒绝近乎重复的点与穿障扩展;一次被拒绝的采样仍消耗迭代预算。
if math.dist(new, nodes[nearest].point) < 1e-10 or not world.edge_free(nodes[nearest].point, new):
continue
# 最近邻到new的边已验证,是父节点选择的有效保底,即使near为空也能加入树。
parent = nearest
near = []
if star:
n = len(nodes) + 1
# 二维r_n=min(η,γ√(log n/n));若换成d维,指数应为1/d。
radius = min(step, gamma * math.sqrt(math.log(n) / n))
near = [i for i, node in enumerate(nodes) if math.dist(node.point, new) <= radius]
for i in near:
# 选父比较的是g(i)+边长;不能只比较i到new这一段有多短。
candidate = nodes[i].cost + math.dist(nodes[i].point, new)
current = nodes[parent].cost + math.dist(nodes[parent].point, new)
# 先看是否值得改善,再花计算成本做候选连接的完整碰撞检查。
if candidate < current and world.edge_free(nodes[i].point, new):
parent = i
# 先确定最优父节点再加入;此时near只包含旧节点,不会把自己当候选。
index = len(nodes)
nodes.append(Node(new, parent, nodes[parent].cost + math.dist(nodes[parent].point, new)))
nodes[parent].children.add(index)
# 重接阶段与选父阶段方向相反:尝试让旧节点通过新节点回到起点。
for i in near:
candidate = nodes[index].cost + math.dist(new, nodes[i].point)
if (i != 0 and candidate + 1e-12 < nodes[i].cost
# 即使浮点或未来代价模型变化,也显式阻止把新节点祖先挂到自己下面形成环。
and not is_ancestor(nodes, i, index) and world.edge_free(new, nodes[i].point)):
reparent(nodes, i, index)
# Check the whole last edge, not just distance to the goal.
if math.dist(new, goal) <= step and world.edge_free(new, goal):
connectors.add(index)
if connectors:
# 所有旧连接候选都重新比较,因为它们的祖先可能刚被重接并降低了cost。
end = min(connectors, key=lambda i: (nodes[i].cost + math.dist(nodes[i].point, goal), i))
value = nodes[end].cost + math.dist(nodes[end].point, goal)
# 仅记录严格改善,画图时应理解为阶梯保持,而非每轮都下降。
if value < best:
best = value
history.append((iteration, best))
# 基本RRT首解即停;RRT*继续后续采样,才有机会改善已有路径。
if not star:
path = recover(nodes, end, goal)
return {'path': path, 'cost': path_length(path), 'nodes': nodes, 'history': history, 'iterations': iteration}
# 预算耗尽仍没有可用目标连接:只能说本次没找到,不能说已证明无路。
if not connectors:
return {'path': None, 'cost': math.inf, 'nodes': nodes, 'history': history, 'iterations': iterations}
end = min(connectors, key=lambda i: (nodes[i].cost + math.dist(nodes[i].point, goal), i))
path = recover(nodes, end, goal)
return {'path': path, 'cost': path_length(path), 'nodes': nodes, 'history': history, 'iterations': iterations}

附件:sampling.py。建议先将 star=False 的路径跟一遍,再看 if star 中的邻域选择和后面的near循环。

返回结果包含最终path、cost、nodes、改善history及实际迭代次数。失败时path为None,cost为无穷;不能把这个无穷写进严格JSON当作正常结果。演示脚本只在两种算法都找到有效路径后输出结果文件。

八、复杂度及没有宣称的内容#

这是一份偏重可读性的实现:最近邻和邻域都线性扫描;重接一个节点可能更新很大的后代子树;目标候选也重新扫描。因此不应把优化实现或论文中的复杂度界原样贴到这份代码上。极端情况下,重复重接和子树更新可能显著超过基础RRT的扫描成本。

RRT*的“渐近最优”不是“1500次采样后必然最优”。图中路径看起来更短,也不能代替最优性证明。在某个有限预算、某个种子下,基本RRT甚至可能碰巧找到更短路径;应比较多次实验分布,而不是把名称中的星号当作每次胜出的保证。

练习:A是B的祖先,能把A的父节点设成B吗?不能,会成环。第二问:重接后终点候选自己的父节点没变,为什么总代价仍可能改变?因为它的祖先被重接了,根到它的整个前缀变短。

数值化的子树代价传播#

设旧树存在一条绕行前缀,到Y=(2,0)的累计cost为8;Y的子节点Z=(3,0)沿长度1的边连接,因此cost(Z)=9;Z的子节点W=(3,1)cost为10。

现在X=(1,0)通过另一条可行前缀到达,cost(X)=3,且X→Y长度1、无碰撞。重接前后为:

节点旧cost新cost原因
X33它的父链没变
Y84改为通过X:3+1
Z95仍通过Y:4+1
W106仍通过Z:5+1

此处旧前缀成本大于坐标直线距离很正常,因为此前可能绕行。这是一个结构化的教学局部树例子,不是在当前圆障碍场景中实际运行得到的节点日志。

沿这棵子树,每个后代都下降4;代码选择从父节点重新相加而不是统一减4,便于持续保持 cost(child)=cost(parent)+edge_length 这一不变量。

假设Z原本就能安全连到goal,虽然Z的坐标和直接父节点Y都没变,它到goal的总代价仍下降4。因此plan必须重新评估旧connectors,而不能只查看刚加入的新节点是否靠近目标。

选父与重接的循环方向#

选父阶段考察 旧节点 → 新节点,计算旧节点已有cost加新边;重接阶段考察 新节点 → 旧节点,计算新节点确定后的cost加新边。两者可能使用同一个near集合,但不能把同一个代价方向公式机械复制过去。

当前几何边是无向欧氏长度,两个方向的边长相同。如果未来有方向相关代价或运动约束,选父与重接的可行性、边长和局部连接都可能不同,需要系统修改;不能只把代价函数换掉而不检查其他假设。

邻域半径和最大步长不是同一件事#

η控制从nearest朝样本推进多远;rnr_n控制新点附近有哪些旧节点参与优化。由于取min,rn≤ηr_n\le\eta;但nearest仍作为已经验证的保底父节点,哪怕它恰好不在更小的优化半径内。

用二维γ=2、η=1演示缩小趋势(只用于算数,不声明满足任何地图的理论阈值):

n2log⁡n/n2\sqrt{\log n/n}最终 rnr_n
10约0.960约0.960
100约0.429约0.429
1000约0.166约0.166

代码中的n是加入候选后的树节点数,不是采样尝试数。大量碰撞拒绝时,两者会差很多。半径缩小可减少候选,但当前Python实现仍需线性扫描全体节点来找near,不能因此宣称查询已经变成对数复杂度。

哪些操作需要重新检查碰撞#

nearest→new需要查;更优候选父节点→new需要查;new→被重接旧节点需要查;目标连接需要查。后代已有边几何没变且场景静态,传播cost时不用重新查这些边。若环境随时间改变,这个复用假设就失效,当前算法没有处理动态地图。

练习:为什么 children=set() 不能直接作为所有节点共享的默认集合?一个节点添加孩子可能污染其他节点。dataclass使用 field(default_factory=set),为每个实例独立创建集合。



Chapter 7: Experiments and Validation#

一、运行环境与命令#

所有新代码位于 posts/UAV_Path_Planning/examples/,仅依赖Python标准库,要求Python 3.8或更新版本。无需无人机项目、ROS、LKH可执行文件或点云。

在博客内容仓库根目录执行:

python posts/UAV_Path_Planning/examples/demo.py --output output/path-planning
python -m unittest discover -s posts/UAV_Path_Planning/examples -p "test_*.py" -v

二、实验A:A*与Dijkstra#

使用12×10的八邻域网格,x=5处设置一堵墙,y=7留缺口。起点(1,1),终点(10,1)。两种算法使用完全相同的图、边权和禁止对角切角规则,只改变启发函数。

方法路径代价展开次数
Dijkstra16.899495105
A*+Octile16.89949552

这是2026-10-27运行当前代码的记录。两者成本相同,A在这个实例中减少了展开。不应据此说所有实例A都快一倍,也不能把展开数量直接等同于秒数。当前计数不包含目标节点及跳过的旧堆条目。

可做两个变体:将h设为0,确认结果回到Dijkstra;封死墙的缺口,确认返回None。再人为使用高估h,观察一次正确结果并不能证明启发函数可采纳。

三、实验B:TSP精确解与启发式#

城市坐标为:

[(5, 2), (6, 0), (1, 8), (1, 5), (9, 0), (8, 3), (0, 1), (6, 6)]

边成本为欧氏距离。实际结果:

方法闭合路线成本对该实例的含义
Held–Karp29.611187当前完整矩阵的精确最优值
最近邻32.273004比最优值长约8.99%
最近邻+2-opt29.611187本例改善到最优,不代表总能做到

最优差距可写为 100%×(J−J∗)/J∗100\%\times(J-J^*)/J^*。没有精确最优值或可信下界时,不能自称差距为零。测试同时用2到7城市的非对称随机成本矩阵,将DP结果与穷举对照。

四、实验C:RRT与RRT*的真实输出图#

两种算法使用同一10×10平面、同样的两个圆障碍、机器人半径0.15、起终点(1,1)/(9,9)、seed=7、步长0.8和目标偏置0.05。

方法路径长度有效树节点使用迭代数
RRT15.73168773115,首条可行路即停
RRT*12.28052712011500,继续改善

**这张图用于解释算法行为,不是等计算预算的性能排名。**基本RRT找到第一条就返回,RRT用了更多迭代;迭代数相同也不代表墙钟时间相同,因为每次RRT做的工作更多。

五、怎样公平比较采样算法#

先确定比较的是“首次可行解时间”还是“固定预算结束时的最佳成本”。它们是不同指标。对每个算法使用相同场景、机器人大小、碰撞检测和目标条件,并给出若干预先指定的种子,例如0到29。

记录成功率、首次成功耗时、总耗时、最终成本、节点数及检查次数。失败运行不能从成功率里消失;长度统计可以对成功案例单独给出,但必须标清条件。限时实验还要固定硬件、解释器和后台环境。

若要看样本数量的影响,可先固定seed,分别使用500、1000、1500等预算。RRT*保留相同随机序列前缀,有利于观察改善过程;最终评价仍需要多种子。平滑或捷径后处理若只施加给一个算法,会引入额外变量,应该单独报告。

六、测试到底验证了什么#

本次24项测试覆盖:

  • 几何:线段中间穿障、投影落在端点外、零长度边、膨胀后的相切和边界。
  • 图搜索:BFS拒绝非单位权、Dijkstra旧条目、A不一致h下重开、不可达、禁止切角,以及20张固定随机小图的A/Dijkstra对照。
  • TSP:DP与穷举对照、排列恢复、2-opt交叉改善、非对称矩阵拒绝和启发式不低于精确最优值。
  • 采样:Steer限步、子树cost更新、防环、整条输出路径和树中代价一致性、有限预算失败、种子复现。

测试通过不证明对所有输入永远正确,也不替代理论假设检查。浮点几何用容差比较数值;拓扑关系、编号、访问集合等离散内容则使用精确比较。

七、推荐的动手练习顺序#

  1. 不看代码手写点到线段距离,再用端点外投影和零长度案例测试。
  2. 用队列实现BFS,再换最小堆实现Dijkstra,解释为什么不能入队就永久关闭。
  3. 为A*实现4邻域、8邻域两种启发函数,找出错误启发函数的最小反例。
  4. 手写3城市TSP穷举,再实现位掩码DP,打印一个状态的前驱选择。
  5. 在RRT中逐条打印采样点、最近邻、扩展点和拒绝原因,先只运行10次。
  6. 为RRT*构造一棵人工小树,重接中间节点,确认所有后代cost变化。
  7. 最后再研究PRM、RRT-Connect、Informed RRT*、D* Lite和带动力学约束规划,不急着把所有变体同时塞进代码。

这些扩展方向不是本次已经实现的功能。现在先确保能够从零解释并实现TSP、A*和RRT的核心机制,再考虑更高效的数据结构和复杂运动模型。

新增精读实验:一次只观察一个不变量#

学习算法时,先用足够小的输入让状态可以打印和手算。下面提供具体观察任务,不要求修改正式API:可以在个人实验副本或调试器中观察相应变量。

算法观察位置每次暂停记录应保持的关系
Dijkstra/A*弹出有效条目后node、queued_g、best、parentqueued_g=best[node]
TSP DP一个(mask,j)算完后previous、候选k、最终valuevalue等于所有合法前驱转移最小值
2-opt接受反转前后原排列、i/j、delta、新成本新旧成本差与delta一致(容差内)
RRT碰撞检查之后sample、nearest、new、是否加入新加入节点的父边完整可行
RRT*reparent结束后新父、child、后代cost每条父边满足累计成本关系,且无环

不要只打印最终路径。一个中间状态错误有时会被后续选择偶然掩盖;小实例的逐步观察更容易找到第一次偏离定义的位置。

读测试代码也能学习算法#

测试文件已经为每项用例补充中文解释,说明它打算暴露哪一类错误。优先阅读“不一致启发函数必须重开”和“重接后代价传播”两项,它们比空地图能通的正常案例更能检验理解。

修改一处实现之前,先预测哪个测试会失败。例如把A改善条件改成第一次发现后不再更新,带权最短路可能错误;只改RRT父指针不更新后代,节点cost与父边的关系测试会失败。建议只在副本中做这种破坏性学习实验,观察后恢复。

代码注释应如何使用#

函数说明给出输入、返回值和前提;循环旁的注释解释状态更新依据;失败分支解释返回值的边界。注释不能替代代码验证:你应能把某条注释对应到一个数学条件或测试用例。

本轮仅扩充说明和注释,未修改算法可执行语句。生成过程检查了去除文档字符串后的Python语法树一致性,并重新执行已有测试;原示例路径与成本仍应与前文一致。


分享

如果这篇文章对你有帮助,欢迎分享给更多人!

路径规划算法学习目录:TSP、Dijkstra、A*、RRT与RRT*
https://minikou.cloud/posts/uav_path_planning/
作者
minikou
发布于
2026-10-27
许可协议
Unlicensed

部分信息可能已经过时

目录