这是一套通用路径规划算法教程。不需要先了解无人机巡检项目,也不依赖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。但某一步输出最优,并不自动让整个组合问题最优。
二、用数学语言描述一个规划问题
图问题写成 ,边代价为 。路径 的代价为:
连续空间问题则给出状态集合 、障碍状态 、自由状态 、起点和目标,以及一条连续曲线 。最短几何路径常以弧长为目标:
速度、时间、能耗等目标可能需要额外状态。若只用 ,算法不会凭空理解速度上限或汽车不能横移。
三、配置空间为什么有用
把机器人简化为半径 的圆盘,障碍为中心c、半径 的圆。机器人中心q不发生碰撞的条件是:
于是可以把机器人看成一个点,把障碍扩大到 。这种“把机体大小转换到障碍区域”的方法是配置空间思想的一个简单特例。对于有朝向的长方形或机械臂,障碍区域更复杂,不能总用一个固定圆代替而不讨论保守性。
例:障碍半径1米,机器人半径0.3米,希望额外留0.2米余量,可以按1.5米膨胀半径检查。但必须明确余量是建模参数,不是算法自动推导的真实安全标准。
四、为什么必须检查边
起点 ,终点 ,障碍圆位于原点、半径0.5。两端均安全,直线仍穿过障碍。
用 , 表示线段。令 ,将圆心Q投影到直线:
最近距离是 。如果 ,直接计算点距,避免除零。对每个圆检查距离是否严格大于膨胀半径;本教程把与障碍相切也视为不可通过。
固定步长采样碰撞检测容易理解,但如果采样间距大于细小障碍尺度,会漏检。对当前圆障碍和直线模型,解析投影不需要猜采样密度。
五、完整的几何模块
下面是 geometry.py 的完整代码:
"""2-D disc robot, circular obstacles, and exact straight-segment checks."""from dataclasses import dataclassimport 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:]))
@dataclassclass 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个障碍,时间为 ;保存地图为 。大地图可以增加空间索引,但必须保证不会因裁剪候选障碍而漏掉碰撞。
本教程没有时间维度,也没有运动障碍。两条路径相交不意味着两个机器人一定相撞;是否相撞取决于它们何时经过。车辆、无人机、机械臂的动力学规划也不能只靠这个二维模型解决。
练习:线段为(0,0)→(2,0),圆心为(3,1),最近点在哪里?答案是终点(2,0),距离为 。如果直接使用未经截断的投影,会错误地得到距离1。
深入推导:从求导到三种投影位置
为什么投影参数恰好是点积除长度平方?设 、,展开距离平方:
对t求导:。当线段非零时,,因此驻点就是无限直线上的唯一最小点。令导数为0,即得到正文的投影公式。
但我们允许的t只有[0,1]。凸二次函数在闭区间上的最小值,只可能出现在区间内驻点或边界,所以截断不是经验补丁,而是约束最小化的精确结果。
以A=(0,0)、B=(4,0)为例:
| 障碍中心Q | 未截断t | 最终最近点 | 距离 |
|---|---|---|---|
| (-1,2) | -0.25 | A=(0,0) | |
| (2,3) | 0.5 | (2,0) | 3 |
| (6,1) | 1.5 | B=(4,0) |
只要把这三行和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为 时间、 额外空间。
二、Dijkstra把“层数”换成累计代价
给每个节点一个当前最优距离 。初始 ,其他为无穷大。每次从未处理候选中取g最小的节点,然后尝试更新邻居。
松弛就是比较:
若成立,经过u到v比之前方案便宜,于是更新 g[v] 和 parent[v]。父节点记录的是路径来源,不是图中所有邻居。
三、逐步手算一个例子
有向边为S→A:4、S→B:1、B→A:2、B→G:7、A→G:1。
| 弹出节点 | 该节点g | 松弛后的主要状态 |
|---|---|---|
| S | 0 | A=4,B=1 |
| B | 1 | A改为3,G=8 |
| A | 3 | G改为4 |
| G | 4 | 返回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 dequeimport heapqimport itertoolsimport 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实现复杂度可写为 ;本代码保留旧条目,堆可能达到 大小,更直接的界是 ,简单图中与常见表达同阶。这里不把该界直接套用给允许重复扩展的不一致启发式A*。
练习:能否在入队时永久标记visited?BFS单位边权可以;Dijkstra不能,因为节点在第一次发现后可能收到更小距离。还应测试起点等于终点、零权边、孤立目标和非法负权。
逐条观察堆的内容:为什么旧条目没有被删除
继续使用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=4 | B←S,A←S |
| 扩展B后 | (3,A),(4,A),(8,G) | A改3,G=8 | A←B,G←B |
| 扩展A=3后 | (4,A),(4,G),(8,G) | G改4 | G←A |
| 弹出旧A=4 | (4,G),(8,G) | 不变 | 不变 |
| 弹出G=4 | 返回 | 最优成本4 | G←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个城市和成本矩阵 ,从0出发,其他城市各访问一次,最后回到0,最小化闭合路线成本。城市可以是配送站、加工孔位、观测位置,成本可以是距离或预先计算的旅行时间。
固定起点后,候选顺序有 种。对称TSP的正反方向代价相同,还能消除一部分重复;数量仍随n快速增长。TSP是一个优化问题,并不是某一种单独的算法。
若 ,称对称TSP;单行道、风向或不同方向旅行时间会产生非对称TSP。下面穷举与DP支持非对称有限非负成本;2-opt使用的四边差分仅针对对称矩阵。
二、穷举建立正确性基准
固定城市0,枚举其他城市的排列,在两端加0,再逐条求和。它简单而准确,适合给更复杂实现做小规模对照。
0→1→2→3→00→1→3→2→00→2→1→3→0...逐条求完整路线代价,需要 时间。教学代码限制最多9个城市,防止误把小实例演示用于大任务。
三、Held–Karp动态规划如何复用重复工作
许多排列会遇到同一个子问题:“已经访问集合S,最后停在j,到这里的最小代价是多少?”后面的决策只依赖S和j,不必记住到达它的完整顺序。
定义:
初始化 。最后一步一定从某个 走向j,所以:
最终还要返回0:
这正是最优子结构:如果某个前缀不是对应子问题的最优解,可以把它换成更便宜的前缀,从而改善整条路线。
四、手算一个完整DP结果
使用矩阵:
| 从/到 | 0 | 1 | 2 | 3 |
|---|---|---|---|---|
| 0 | 0 | 10 | 15 | 20 |
| 1 | 10 | 0 | 35 | 25 |
| 2 | 15 | 35 | 0 | 30 |
| 3 | 20 | 25 | 30 | 0 |
单点状态为10、15、20。例如 ,而 。
三个城市全部访问后:
| 终点 | 完整集合DP值 | 返回0后的值 |
|---|---|---|
| 1 | 80 | |
| 2 | 80 | |
| 3 | 95 |
最优值为80,例如0→1→3→2→0。不同最优排列可能同成本,因此测试应优先检查成本和排列合法性,而非只接受一个唯一顺序。
五、位掩码如何表示集合
用整数二进制位表示访问状态。城市j对应第j−1位,因为起点0不在集合中。
001:只访问城市1101:访问城市1和3111:访问城市1、2、3mask & (1 << (j-1))检查j是否在集合;mask ^ bit在确认该位存在后移除j。由于删除一个已置位的bit会得到更小整数,按mask递增计算时,所依赖的状态已经存在。
状态数为 ,每个状态枚举前驱需要 ,总时间 、空间 。这比阶乘好,但仍是指数算法。Python字典本身也有明显开销,不要把16城市教学上限误认为一个普遍性能结论。
六、最近邻与2-opt:速度换取最优性保证
最近邻每次选当前城市到未访问城市中成本最小的一个,复杂度 。它只看眼前,最后可能留下一个昂贵的返回边。
2-opt对现有闭合排列选择两条边 与 ,换成 、,并反转中间子序列。对称成本下,内部边反向不改变代价,因此改变量只需四条边:
若 则接受。非对称成本下,内部反向边的成本也变了,不能套这个差分。实现直接拒绝非对称输入,避免得到错误的改善判断。
正方形边长2,交叉路线0→2→1→3→0长约9.657;一次合适交换得到周长8。对于更复杂实例,2-opt可能停在没有改善交换的局部最优。每轮扫描 对,轮数取决于实例,不应把整个算法无条件写成一次 。
七、完整代码
"""Small exact TSP solvers plus nearest-neighbor and symmetric 2-opt."""import itertoolsimport 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) | 2 | 3 | 101 |
| (101,3) | 3 | 1 | 001 |
| (001,1) | 1 | None,基例 | 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,按 选择扩展节点。
例如两条前缀当前代价分别为4和6,但第一条估计还需10,第二条只需2,A*更愿意探索第二条,因为8小于14。这里g是真实累计代价,h是估计,不能随意混用米和秒。
| 策略 | 优先级 | 行为 |
|---|---|---|
| Dijkstra | g | 从起点按累计代价推进 |
| 贪心最佳优先 | h | 只看离目标似乎多近 |
| A* | g+h | 同时考虑已付出和剩余估计 |
二、可采纳性:不能高估
令 为真实最小剩余代价。可采纳性要求 ,且目标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满足沿边的三角不等式。沿一条路径,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。
| 操作 | 结果 |
|---|---|
| 展开S | A的f=2,B的f=3.5 |
| 先展开A | 得到G成本4 |
| 再展开B | A改善为1.5,需要重新展开 |
| 再次展开A | G改善为3.5 |
| 弹出G | 返回S→B→A→G |
本教程不使用永久closed集合,只比较弹出条目的g快照和best。只要出现严格更小的g,就更新并入堆,因此支持这种重开情况。
五、启发函数怎样从移动规则推导
5.1 四邻域:曼哈顿距离
只允许水平或竖直一步,代价为1时,剩余水平差dx和垂直差dy都需要分别消除:
5.2 八邻域:Octile距离
直走代价1,斜走代价 。先用 步对角消掉共同部分,再走剩余单轴:
例如差值(3,2),两步斜线加一步水平,代价为 。欧氏距离 是较松的下界;曼哈顿距离5高估了允许对角的代价,因此不适用于这里的最优A*。
如果斜走代价也设成1,则相应距离是Chebyshev距离 。启发函数必须匹配实际动作和边权。
5.3 三维26邻域
将三轴差排序为 ,空网格最短距离为:
先三轴一起走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逐段阅读时重点看四处:
- h的检查只保证有限、非负、目标为零;代码不能自动证明h不高估。
- 弹出时检查g快照,跳过旧条目;不是用visited拒绝一切后续改善。
- 在有效条目弹出时才判断目标,不在邻居第一次入堆时结束。
- 仅当新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采用 ,可能更快偏向目标,但通常牺牲精确最优性。不要把它当作普通A只改一个常数却不改变保证。
相关基础见 Hart、Nilsson、Raphael的A*原论文。本篇代码选择支持重开的实现,明确区分可采纳性和一致性。
练习:把本例Octile换成曼哈顿,结果可能仍然正确,是否就证明曼哈顿可采纳?不是。一个实例没出错不能推翻(1,1)差值下2大于 的反例。
用候选前缀理解最优性,而不是背结论
假设最优路径总成本为 。在目标被最优方式处理前,最优路径上至少存在一个已经发现、尚待适当扩展的前缀节点n。在允许正确松弛与重开的条件下,它可以携带对应最优前缀代价 。若h可采纳,则:
假如当前弹出目标的代价C却大于 ,该目标的f=C,而堆中仍有不超过 的候选,这与“弹出最小f”矛盾。这个证明也解释了为什么必须在弹出有效目标条目时结束,而不是在任意一条边第一次碰到目标时结束。
如果错误地永久关闭了本应重开的节点,那个最优前缀候选可能被实现丢掉,证明前提就不再成立。因此“启发函数可采纳”不能脱离具体图搜索实现单独使用。
从一致性推导f沿路径不下降
对边u→v,如果v通过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维状态空间需要约 个格子。一个6关节机械臂的状态不是平面位置,而是6个角度;全量网格很快变大。
RRT不先铺满所有状态,而是从起点长出一棵树,在需要的位置采样并扩展。它也不是高维问题的万能解,距离度量、碰撞检查、窄通道和局部连接器都会影响效果。
二、一次迭代只做五件事
1. Sample:生成随机目标q_rand2. Nearest:找树里离q_rand最近的节点q_near3. Steer:从q_near朝q_rand前进最多η,得到q_new4. CollisionFree:检查整条q_near→q_new5. Add:如果可行,把q_new加入树并记录父节点每个新节点只连向已有节点,所以天然可以维持树。找到能连接目标的节点后,沿父链回溯,就得到起点到目标的路径。
三、Steer公式与手算
本教程二维直线连接器为:
若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.pynodes = [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 Worldfrom 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个节点时一次 ;增长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距离 |
| 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。最后两个对角段共长 ,总长度为 。
第二次失败并没有删除(1,0),也没有把这个节点标记为永久不能扩展。下一次采样换了方向,它仍可能长出一条可行边。RRT探索的是不同连接方向,不是某个节点一失败就没有价值。
最容易混淆的三个“距离”
Nearest比较树节点到随机样本的几何距离;cost是起点沿父链到节点的累计路径长度;“接近目标”比较节点到goal的几何距离。它们不能互换。
一个节点可能几何上离目标很近,但绕了很长的路才到达;基本RRT仍可能通过它首先成功。RRT*之所以增加cost和重接,正是为了改善这种累计路线,而不只是继续靠近目标。
采样尝试数为什么不等于节点数
每次循环都会消耗一次尝试,但新点与已有点近乎重合、扩展边碰撞时不会加入节点。因此通常有“节点数≤尝试数+1”。本实现不把最终goal作为独立树节点追加,所以返回路径的最后一点不必出现在nodes列表中;最后连接已经单独验证。
返回的history记录最佳目标连接的成本,而不是每个节点的cost列表。基本RRT通常只有首解记录;RRT*可能有多个改善记录,尚未找到解的阶段不应画成成本0。
窄通道与概率直觉
若某个有用的采样区域占自由空间概率比例p,独立均匀采样N次至少落入一次的概率为 。这是帮助理解稀少区域难被采到的简化计算,不是RRT成功概率的精确公式:真正连接还取决于当前树、步长、障碍和局部可连接性。
例如p=0.001时,100次至少采到一次的概率只有约9.52%;1000次约63.23%。即使采到了,该扩展也可能被障碍挡住。增加目标偏置还会减少均匀探索尝试,所以参数需要结合场景评估。
Chapter 6: RRT*
一、RRT*多做了哪两件事
RRT以最近邻为父节点,长出来后基本不改。RRT*在加入新节点时,先寻找附近节点中谁能提供更便宜的起点到新点路径;加入后,再检查附近已有节点能否经新点降低总代价。
RRT:最近邻 → 新节点RRT*:附近节点中选更好的父节点 → 新节点 → 尝试改善附近旧节点这里“更好”指从起点出发的累计成本更低,而不是单条连接边更短。
二、选父节点:比较总成本
新点为x,附近候选集合为 ,选择:
例:A离x只有1米,但g(A)=10;B离x2米,g(B)=4。通过A总成本11,通过B总成本6。若边都无碰撞,应选B。
代码先保留已验证的最近邻作为默认父节点,再尝试邻域内更便宜的选择。不能因为某候选累计成本小就跳过它与x之间的碰撞检查。
三、重接线:新节点也能帮助旧节点
新点x加入后,对邻域节点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*的半径形式是:
其中d是空间维度,η是局部连接尺度,γ控制邻域大小。二维时指数为1/2。随着样本增加,半径减小,但附近候选数量在理想均匀条件下仍与 相关。
直觉是:早期稀疏,需要更大范围连接;后期密集,不必每次与全体节点比较。γ需要与空间尺度、维度和自由空间测度相匹配。代码默认γ=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, fieldimport mathimport random
from geometry import path_length
@dataclassclass 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 | 原因 |
|---|---|---|---|
| X | 3 | 3 | 它的父链没变 |
| Y | 8 | 4 | 改为通过X:3+1 |
| Z | 9 | 5 | 仍通过Y:4+1 |
| W | 10 | 6 | 仍通过Z:5+1 |
此处旧前缀成本大于坐标直线距离很正常,因为此前可能绕行。这是一个结构化的教学局部树例子,不是在当前圆障碍场景中实际运行得到的节点日志。
沿这棵子树,每个后代都下降4;代码选择从父节点重新相加而不是统一减4,便于持续保持 cost(child)=cost(parent)+edge_length 这一不变量。
假设Z原本就能安全连到goal,虽然Z的坐标和直接父节点Y都没变,它到goal的总代价仍下降4。因此plan必须重新评估旧connectors,而不能只查看刚加入的新节点是否靠近目标。
选父与重接的循环方向
选父阶段考察 旧节点 → 新节点,计算旧节点已有cost加新边;重接阶段考察 新节点 → 旧节点,计算新节点确定后的cost加新边。两者可能使用同一个near集合,但不能把同一个代价方向公式机械复制过去。
当前几何边是无向欧氏长度,两个方向的边长相同。如果未来有方向相关代价或运动约束,选父与重接的可行性、边长和局部连接都可能不同,需要系统修改;不能只把代价函数换掉而不检查其他假设。
邻域半径和最大步长不是同一件事
η控制从nearest朝样本推进多远;控制新点附近有哪些旧节点参与优化。由于取min,;但nearest仍作为已经验证的保底父节点,哪怕它恰好不在更小的优化半径内。
用二维γ=2、η=1演示缩小趋势(只用于算数,不声明满足任何地图的理论阈值):
| 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-planningpython -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)。两种算法使用完全相同的图、边权和禁止对角切角规则,只改变启发函数。
| 方法 | 路径代价 | 展开次数 |
|---|---|---|
| Dijkstra | 16.899495 | 105 |
| A*+Octile | 16.899495 | 52 |
这是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–Karp | 29.611187 | 当前完整矩阵的精确最优值 |
| 最近邻 | 32.273004 | 比最优值长约8.99% |
| 最近邻+2-opt | 29.611187 | 本例改善到最优,不代表总能做到 |
最优差距可写为 。没有精确最优值或可信下界时,不能自称差距为零。测试同时用2到7城市的非对称随机成本矩阵,将DP结果与穷举对照。
四、实验C:RRT与RRT*的真实输出图
两种算法使用同一10×10平面、同样的两个圆障碍、机器人半径0.15、起终点(1,1)/(9,9)、seed=7、步长0.8和目标偏置0.05。
| 方法 | 路径长度 | 有效树节点 | 使用迭代数 |
|---|---|---|---|
| RRT | 15.731687 | 73 | 115,首条可行路即停 |
| RRT* | 12.280527 | 1201 | 1500,继续改善 |
**这张图用于解释算法行为,不是等计算预算的性能排名。**基本RRT找到第一条就返回,RRT用了更多迭代;迭代数相同也不代表墙钟时间相同,因为每次RRT做的工作更多。
五、怎样公平比较采样算法
先确定比较的是“首次可行解时间”还是“固定预算结束时的最佳成本”。它们是不同指标。对每个算法使用相同场景、机器人大小、碰撞检测和目标条件,并给出若干预先指定的种子,例如0到29。
记录成功率、首次成功耗时、总耗时、最终成本、节点数及检查次数。失败运行不能从成功率里消失;长度统计可以对成功案例单独给出,但必须标清条件。限时实验还要固定硬件、解释器和后台环境。
若要看样本数量的影响,可先固定seed,分别使用500、1000、1500等预算。RRT*保留相同随机序列前缀,有利于观察改善过程;最终评价仍需要多种子。平滑或捷径后处理若只施加给一个算法,会引入额外变量,应该单独报告。
六、测试到底验证了什么
本次24项测试覆盖:
- 几何:线段中间穿障、投影落在端点外、零长度边、膨胀后的相切和边界。
- 图搜索:BFS拒绝非单位权、Dijkstra旧条目、A不一致h下重开、不可达、禁止切角,以及20张固定随机小图的A/Dijkstra对照。
- TSP:DP与穷举对照、排列恢复、2-opt交叉改善、非对称矩阵拒绝和启发式不低于精确最优值。
- 采样:Steer限步、子树cost更新、防环、整条输出路径和树中代价一致性、有限预算失败、种子复现。
测试通过不证明对所有输入永远正确,也不替代理论假设检查。浮点几何用容差比较数值;拓扑关系、编号、访问集合等离散内容则使用精确比较。
七、推荐的动手练习顺序
- 不看代码手写点到线段距离,再用端点外投影和零长度案例测试。
- 用队列实现BFS,再换最小堆实现Dijkstra,解释为什么不能入队就永久关闭。
- 为A*实现4邻域、8邻域两种启发函数,找出错误启发函数的最小反例。
- 手写3城市TSP穷举,再实现位掩码DP,打印一个状态的前驱选择。
- 在RRT中逐条打印采样点、最近邻、扩展点和拒绝原因,先只运行10次。
- 为RRT*构造一棵人工小树,重接中间节点,确认所有后代cost变化。
- 最后再研究PRM、RRT-Connect、Informed RRT*、D* Lite和带动力学约束规划,不急着把所有变体同时塞进代码。
这些扩展方向不是本次已经实现的功能。现在先确保能够从零解释并实现TSP、A*和RRT的核心机制,再考虑更高效的数据结构和复杂运动模型。
新增精读实验:一次只观察一个不变量
学习算法时,先用足够小的输入让状态可以打印和手算。下面提供具体观察任务,不要求修改正式API:可以在个人实验副本或调试器中观察相应变量。
| 算法 | 观察位置 | 每次暂停记录 | 应保持的关系 |
|---|---|---|---|
| Dijkstra/A* | 弹出有效条目后 | node、queued_g、best、parent | queued_g=best[node] |
| TSP DP | 一个(mask,j)算完后 | previous、候选k、最终value | value等于所有合法前驱转移最小值 |
| 2-opt | 接受反转前后 | 原排列、i/j、delta、新成本 | 新旧成本差与delta一致(容差内) |
| RRT | 碰撞检查之后 | sample、nearest、new、是否加入 | 新加入节点的父边完整可行 |
| RRT* | reparent结束后 | 新父、child、后代cost | 每条父边满足累计成本关系,且无环 |
不要只打印最终路径。一个中间状态错误有时会被后续选择偶然掩盖;小实例的逐步观察更容易找到第一次偏离定义的位置。
读测试代码也能学习算法
测试文件已经为每项用例补充中文解释,说明它打算暴露哪一类错误。优先阅读“不一致启发函数必须重开”和“重接后代价传播”两项,它们比空地图能通的正常案例更能检验理解。
修改一处实现之前,先预测哪个测试会失败。例如把A改善条件改成第一次发现后不再更新,带权最短路可能错误;只改RRT父指针不更新后代,节点cost与父边的关系测试会失败。建议只在副本中做这种破坏性学习实验,观察后恢复。
代码注释应如何使用
函数说明给出输入、返回值和前提;循环旁的注释解释状态更新依据;失败分支解释返回值的边界。注释不能替代代码验证:你应能把某条注释对应到一个数学条件或测试用例。
本轮仅扩充说明和注释,未修改算法可执行语句。生成过程检查了去除文档字符串后的Python语法树一致性,并重新执行已有测试;原示例路径与成本仍应与前文一致。
如果这篇文章对你有帮助,欢迎分享给更多人!
部分信息可能已经过时
