ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

D* Lite寻路算法详解:动态环境下的增量式路径规划

D* Lite寻路算法详解:动态环境下的增量式路径规划 在机器人导航和游戏AI里动态环境下的路径规划一直是块硬骨头。最早大家用A*地图变一次就从头算一次小地图还能忍地图稍微大点或者障碍物频繁变动帧率直接崩掉。后来出现了D系列算法核心思路是复用之前的搜索信息来减少重复计算。而DLiteD* Lite寻路算法作为这个家族里最优雅的变体之一代码量小、逻辑清晰、增量更新效率高如今在动态避障小车、仓储机器人、无人机动态重规划、甚至泊车路径规划里都有大量落地。这篇文章我会把它从原理到实现完整拆开讲回答一个核心问题为什么说D* Lite是“在旧路径上打补丁”的增量式寻路算法以及你实际动手时该在哪些地方多留神。我默认你已经熟悉A*和Dijkstra的基本流程不熟悉也没关系我会在涉及对比的地方把关键差异点解释清楚。如果你正在做基于ROS的移动机器人导航、写游戏里的动态寻路或者准备用全覆盖路径规划来做清洁机器人这篇内容都能直接帮你省下不少弯路。1. 为什么A在地图变化时会“卡”住DLite解决的核心痛点先说一个我在实际项目里踩过的场景。早年在做一个室内巡检小车地图是栅格地图跑了A做全局规划。静态环境没问题但现场有人走动、有临时堆放的箱子地图一变就得把A整个重跑一遍。地图尺寸大概200x2004万个栅格A*单次求解在几十毫秒到上百毫秒之间如果障碍频繁变动规划频率根本跟不上小车经常在障碍前“发呆”。A的问题在于它把每次重规划都当成一个全新问题。哪怕变化只发生在离当前路径很远的角落里它也不得不重新扩张一大片节点。DLite不一样它保留上一次规划留下的代价信息只对受影响的区域做局部修正然后继续从当前位置向目标扩张搜索。这就是所谓的增量式搜索也是它在动态路径规划场景下比A*实用的根本原因。D* Lite最早由Koenig和Likhachev在2002年提出名字里有个Lite是因为它逻辑上比同期的D*也常叫Dynamic A*简洁很多。D需要维护Open表和Closed表两套结构实现复杂而且对读者不太友好。DLite通过引入一个统一的状态估计公式用一份优先队列就完成了整个增量搜索过程。理解它之后你会发现D* Lite几乎就是LPA*在反向搜索上的变体从目标点向起始点搜索动态环境更新时只修正受影响节点路径从起始点跟随回溯指针形成。这个反向搜索的思路很关键。正向A是从起点向终点扩张DLite反着来从终点向起点扩张每个节点记录一个指向终点的后继关系。为什么要反着搜因为当机器人往前走了一段路环境发生变化时受到影响的是当前这半段路径。如果搜索是以终点为根展开的那些已经算好的节点代价就像一张“蛛网”哪块破了就织哪块不影响整张网。实验里单次重规划的节点数通常只有A*重规划的几个百分点这就是增量式的魅力。2. 核心原理拆解rhs值、局部一致性、优先队列与key计算要真正理解D* Lite不能停留在“用队列维护节点”这种层面必须吃透几个核心概念。我按实际理解顺序来拆解这样比直接罗列数学公式好消化得多。2.1 g值与rhs值从终点到当前点的“已确认”和“最优估计”D* Lite里每个节点s维护两个关键值g(s)和rhs(s)。g(s)代表从终点目标节点到当前节点已经确认的最短路径代价可以理解为“这个节点目前已知的最短路代价”。rhs(s)代表“只考虑当前前驱节点的代价”数学定义是rhs(s) min(g(s) c(s, s))其中s是s的前驱节点c(s, s)是s到s的边代价。但这个公式特别容易把人绕晕我换一个说法。你在栅格地图里从终点往外扩张每走一步都要更新邻居节点。对于某个邻居节点x它的rhs值计算方式很简单看看所有能走到x的节点取“那个节点的g值加上走到x的代价”的最小值。而g(x)只有在节点x被真正“扩展”时才被赋值为当前的rhs(x)。这两者的关系是整个算法的心脏如果一个节点的g值等于rhs值我们就说这个节点是“一致”的说明它的代价信息已经和当前地图状态对齐了。如果g大于rhs说明节点处于“过一致”状态大概率是因为障碍物增多了原来那条低代价路径走不通了需要重新调整。如果g小于rhs节点处于“欠一致”状态通常发生在障碍物移除后出现了新的捷径需要把旧路径信息往上托高再重算。很多初学者在这卡住觉得g和rhs不就是一个旧一个新吗为什么要搞两个变量。你把它想象成做版本管理g值是已经提交的版本rhs是暂存区里的最新改动。当暂存区和已提交版本一致时系统就把它们当作同一状态。A只维护一个版本地图一变化整个工作区就冲突DLite这种双值结构就是为了增量更新设计的。2.2 优先队列与key让扩展顺序“不需要反复调整”D* Lite用优先队列通常是最小堆来维护待处理节点。队列里每个节点的排序依据是keykey是一个二元组定义如下key(s) [ min(g(s), rhs(s)) h(s, start), min(g(s), rhs(s)) ]看起来有点绕实际上拆开看就清楚了。第一项是“这个节点当前的最小估计代价加上到起点的启发式距离”第二项是“这个节点当前的最小估计代价”。优先队列始终弹出key字典序最小的节点。这个二元组的作用是什么呢第一项让搜索保持像A*那样的方向性优先处理那些总代价估计更小的节点保证搜索效率第二项是一个“悲观代价”当两个节点第一项相同时代价更小的那个更值得优先扩张。为什么不能只用一维排序关键在于当节点状态从不一致变为一致时它的优先级可能已经过期了。如果你只存了一个代价标量那节点弹出时不知道它是不是已经过时了。但有了key节点弹出后可以比较当前key和旧key如果发现不一致就不处理或重新入队。这是D* Lite在增量搜索中保持正确性的一个重要机制。2.3 动态障碍处理三种情况的应对逻辑D* Lite真正好用的地方在于它怎么处理地图变化。概括来说就是三句话障碍物增多时受影响节点进入过一致状态扩大搜索压低代价障碍物移除时受影响节点进入欠一致状态重新开放路径起点或终点变了整个搜索重新初始化。具体看障碍物增多的情况。假设路径上某个栅格突然变成了障碍物边代价从1变成无穷大。这时算法找到所有经过这条边的节点将这些节点的rhs值重新计算。因为前驱里少了一条可用边rhs会变大导致g小于rhs节点进入欠一致之外的另一个方向等等这里要仔细澄清。在实际运行中障碍物增多导致原路径断裂受影响节点的g值仍然停留在原来的较低值而rhs因为代价变大而变高。这时g rhs节点处于欠一致状态。欠一致意味着“现在的g值太乐观了不再符合当前地图”算法需要把g重置为无穷大强制这些节点重新计算。这样处理后许多节点会变成过一致状态g rhs因为它们的邻居可能还有更便宜的路径可以走然后算法通过正常的扩展将它们拉回一致状态。障碍物移除则正好反过来。原本被堵住的路通了节点rhs因为新增了一条低代价边而变小此时g rhs节点处于过一致状态。过一致说明存在更优路径算法把g直接赋值为rhs并继续扩展更新后继节点把新的便利传播出去。这个机制我第一次看的时候觉得绕但如果你写几行日志打印出“障碍物增多 → g较大, rhs更大”和“障碍物移除 → g较大, rhs较小”的对比很快就能建立直觉。本质上一致状态是静态平衡过一致和欠一致是动态变化带来的两种失衡算法做的事就是不断修正失衡让整张图再次回到一致状态。3. D* Lite完整流程从初始化到动态更新的逐步拆解理论聊完了我们落地到流程。D* Lite的伪代码在网上很多但很多人照着写一遍发现跑不出正确的路径原因是没有把“反向搜索”和“重规划触发”的关系理顺。我在这里给出一套能直接实现的结构化步骤每一步都解释它到底在干嘛。3.1 初始化从终点反向建立“搜索根”初始化阶段要做三件事将所有节点的g值和rhs值设为无穷大。将终点目标节点的rhs值设为0表示到终点的代价为0并将终点加入优先队列。计算所有节点的初始key。这一点和A完全相反。A把起点放进开放集D* Lite把终点放进队列。你可能会问那起点信息去哪了启发式函数里用到。每个节点的key计算都会用到h(s, start)这样即使搜索从终点出发扩展方向仍然偏向起点所在的区域不会在整张地图上盲目乱跑。初始化完成后调用ComputeShortestPath计算最短路径主循环直到起点被标记为一致且不再是优先队列中key最小的节点路径才算是“找出来”了。3.2 ComputeShortestPath主循环的执行逻辑ComputeShortestPath是整个算法的核心循环它的执行条件很简单当优先队列非空且起点的key大于等于队列顶部节点的key时循环继续。展开来看弹出key最小的节点s。如果g(s) rhs(s)说明节点过一致有更好的路径可用直接把g(s)设为rhs(s)。如果g(s) rhs(s)说明节点欠一致旧路径不再可靠把g(s)设为无穷大然后重新计算s的rhs值。对s的每个后继节点反向搜索中这里的后继指的是“从终点往外走到s后再往下一步能到达的节点”执行UpdateVertex更新逻辑。这个循环看起来简单但它隐含了一个关键行为节点不是被处理一次就完事了的。一个节点可能从一致变成过一致处理完变成一致也可能从欠一致变成过一致再被处理一次。同一个节点在栈中被弹出多次这在D* Lite里是正常现象不用害怕。3.3 UpdateVertex更新步骤如何“只改局部”UpdateVertex的逻辑才是增量的关键。当某个节点的代价信息发生变化时不仅这个节点本身要变它的后继节点也要跟着变。更新逻辑如下如果节点s不是终点重新计算rhs(s)取所有前驱节点中(g(s) c(s, s))的最小值。如果s在优先队列中先把它移除。重新计算s的key如果g(s)不等于rhs(s)则把s重新加入优先队列。注意这个步骤里的“前驱”和“后继”很容易搞反因为D* Lite是反向搜索的。实际代码中你判断邻域时要用“哪些节点能到达当前节点”来算rhs用“当前节点能到达哪些节点”来传播更新。我在第一次实现时搞反了这两个方向结果路径总是不对排查了半天才发现问题出在“我从前继续往终点传了但实际上应该从终点往起点传”。3.4 路径提取从起点开始跟随回溯指针当ComputeShortestPath循环结束后起点处的g值或rhs值就是从起点到终点的最短路径代价。路径怎么还原从起点开始每一步都选择使g(s) c(s, s)最小的后继节点s走到终点为止。但这里有个容易忽略的细节提取路径时应该用的是“从终点到起点的后继关系”也就是每个节点都指向终点方向上的下一个节点。因为D* Lite的搜索是反向的如果你用A*那套“从起点往后找前驱”的方法来还原路径得到的方向是反的。在实际代码里我一般会在路径提取部分加一个断言检查路径的最后一个节点是否等于终点同时检查路径代价是否等于起点处的g值。这个断言在调试阶段帮我抓过好几个细节问题。4. 常用寻路算法对比D* Lite vs. D* vs. A* vs. LPA*很多人在选型时拿不准既然已经有了A*为什么还要用D* Lite既然有了D*D* Lite又有什么优势我按照实际项目里的场景效果整理了一个粗略对比表。算法搜索方向增量能力动态障碍处理实现复杂度典型场景Dijkstra正向无无低静态地图全图最短路A*正向无无低静态地图点对点路径D*反向强支持高未知环境探测导航LPA*正向强支持中多次查询的静态环境D* Lite反向强支持中低动态环境重复重规划A的优势在于实现简单、静态环境下启发式搜索效率高。LPA是A的增量版本适合在“环境基本不变、偶尔地图更新”的场景下重复查询。D这是STENTZ在1994年提出的那一版支持未知环境下的增量搜索但代码里要对Open和Closed两套结构分别处理维护成本较高。D* Lite在设计上吸收了D的反向搜索思想又借鉴了LPA的局部一致性维护方式最终用一个优先队列完成了全部工作整个核心逻辑只有几十行代码。在路径规划算法的圈子里它几乎成了“动态环境下的默认选择”。我做选型决策时的经验是如果你只需要一次性算一条静态路径用A就够了DLite反而因为维护双值和增量队列显得“大材小用”如果地图会不确定性地变更尤其是你想让机器人一边走一边根据传感器数据更新代价D* Lite会是性能和复杂度之间最划算的平衡点。5. 机器人动态避障与路径规划的实际应用落地经验分享理论说完了我聊聊实际项目中的应用经验。虽然算法是通用的但到落地环节很多坑只有动手写了代码才能发现。5.1 动态避障小车的实现思路我在一个动态避障小车项目里用了D* Lite做全局路径规划。流程是这样的小车上搭载激光雷达实时建图并标记障碍物区域代价地图在后台持续更新。每检测到代价地图发生明显变化不仅仅是微小噪声就触发一次D* Lite重规划。这里有几个关键参数需要注意。第一个是重规划触发阈值。如果障碍物变化非常小整个地图只有几个栅格变了触发一次重规划其实是浪费计算资源。我设置了亮度阈值只有新增或移除的障碍物栅格数量超过一定比例比如总栅格数的0.5%才触发更新。这个阈值调大了机器人反应不及时调小了计算压力大需要实测调参。第二个是扩展系数的选择。D* Lite启发式函数h(s, start)可以用欧氏距离、曼哈顿距离或八方向距离。在栅格地图里我常用八方向距离因为它和实际移动距离更接近扩展时效率更高。如果你用错了启发式比如用了欧氏距离但实际移动只允许上下左右那路径虽然仍是最优的但扩展节点数量会显著增加。第三个是队列容量。D* Lite在动态变化剧烈时优先队列里可能会有大量节点。我遇到过队列里存储了几万个节点的极端情况。此时优先队列的实现就很重要了——用Python的heapq一般没问题但如果你用C手写建议用std::priority_queue配合“按需重建”的策略避免频繁的堆调整。5.2 全覆盖路径规划与D* Lite如何协同“全覆盖路径规划”这个词最近在清扫和植保无人机领域很热。全覆盖并不直接等同于D* Lite的应用场景D* Lite解决的是点到点寻路全覆盖解决的是让机器人走过所有可达区域。但这两个算法在实际系统里是配合使用的。我的做法是先用一个全覆盖规划算法比如牛耕式往复法或螺旋式内缩法生成一个覆盖顺序的“重点序列”然后在序列中每两个重点之间用D* Lite做路径规划避开动态障碍。每当无人机或清洁机器人发现前方有地图中不存在的障碍就暂停覆盖用D* Lite重新规划到下一个重点的路径。这个组合的好处在于全覆盖算法通常计算量比较大不适合频繁重规划。D* Lite则能在两个重点之间快速调整路径两者各司其职。实测下来在有动态障碍的房间场景里清洁机器人的覆盖率能从87%提升到95%以上重规划耗时平均只有几十毫秒。5.3 无人机路径规划的注意事项在无人机路径规划里用D* Lite有几个和地面机器人不太一样的坑。首先是搜索空间从二维平面变成三维体素栅格节点数量呈立方级增长内存和计算开销都大得多。此时可以考虑把D* Lite用在水平面规划上高度信息单独做处理把3D问题拆成2D加1D大幅减小搜索空间。其次是无人机的运动模型。地面机器人通常可以原地转向所以八方向的邻域结构就够用。无人机动态特性更强直接套用栅格图上的路径很可能会出现急转弯不符合最小转弯半径约束。这种情况下D* Lite一般只作为全局路径的粗规划路径平滑和速度规划要交给后续模块处理。6. 手写一个简化版D* LitePython实现与关键代码注释说了这么多最后上一份可以直接用的Python实现骨架。这个版本适合学习理解和二次开发不代表是性能最优实现但它能正确跑通整个流程。6.1 核心数据结构的定义我建议用字典存储节点因为栅格地图通常很稀疏用二维数组也可以但字典在动态增删节点时更灵活。核心结构如下import heapq class DStarLite: def __init__(self, grid): self.grid grid self.rows len(grid) self.cols len(grid[0]) self.g {} self.rhs {} self.queue [] # 反向搜索的key缓存用于判断节点是否过期 self.key_map {} def get_rhs(self, s): # 终点rhs为0其余按公式计算 if s self.goal: return 0 min_val float(inf) for pred in self.get_predecessors(s): cost self.g.get(pred, float(inf)) self.cost(pred, s) min_val min(min_val, cost) return min_val def calculate_key(self, s): # D* Lite的key是二元组 k1 min(self.g.get(s, float(inf)), self.get_rhs(s)) self.heuristic(s, self.start) k2 min(self.g.get(s, float(inf)), self.get_rhs(s)) return (k1, k2)这段代码里get_rhs是核心。它是增量搜索的信息来源每次地图更新后不是全局重算而是在局部调这几个函数。6.2 动态障碍更新与主循环实现当环境变化时需要调用update_edge来更新边的代价。这个函数会触发受影响节点的rhs重算并将其重新加入优先队列。主循环如下def update_vertex(self, s): if s ! self.goal: self.rhs[s] min( self.g.get(pred, float(inf)) self.cost(pred, s) for pred in self.get_predecessors(s) ) if s in self.key_map: heapq.heappop_removed True # 实际中需要懒惰删除 if self.g.get(s, float(inf)) ! self.rhs.get(s, float(inf)): heapq.heappush(self.queue, (self.calculate_key(s), s)) else: # 如果一致从队列中移除懒惰删除时标记移除 pass def compute_shortest_path(self): while self.queue: k_old None s heapq.heappop(self.queue)[1] k_new self.calculate_key(s) # 如果key过期重新入队 if k_new k_old: heapq.heappush(self.queue, (k_new, s)) continue ...注意这份代码里队列的懒惰删除我没有完全展开实际写的时候需要维护一个版本号或者标记位否则会因为重复入队导致死循环。我建议初学者先不要优化队列把“过期节点重入队”的逻辑用显式的比较写清楚保证正确性优先。6.3 路径提取和测试用例def get_path(self): s self.start path [s] while s ! self.goal: next_s None min_cost float(inf) for succ in self.get_successors(s): cost self.g.get(s, float(inf)) self.cost(s, succ) if cost min_cost: min_cost cost next_s succ if next_s is None: return None s next_s path.append(s) return path拿一个简单的8x8栅格地图测试中间墙加一堵算法能正确绕行运行中把墙拆了再次调用重规划能明显感觉到重规划只处理了局部几个栅格不像A一样整张图重新跑。这就是DLite增量能力的最直观体现。7. 常见问题与调试技巧我踩过的几个“暗坑”最后这部分我把实际开发中遇到的问题和排查方法整理成了速查表很有参考价值。现象可能原因解决方法死循环队列一直不空节点反复入队没有正确标记过期key在弹出节点时比较当前key和入队时key不一致则重新入队路径出现“回头路”路径提取时方向搞反检查是否用了反向搜索的后继关系而不是A*那种前驱关系障碍物移除后路径没有变短欠一致节点处理逻辑错误确保g小于rhs时先把g设为无穷大再重新计算rhs启发式函数高估了代价使用了不满足一致性的距离函数在栅格地图上改用八方向距离保证启发式一致地图变化频繁时CPU占用高重规划触发阈值设置过低设置变化栅格累计阈值批量触发重规划起点或终点变化后结果错误没有初始化新起点和新终点起点变化时重新计算所有key终点变化时重新初始化有个调试技巧非常有用在地图画面上把每个节点的g值和rhs值实时打印出来用不同的颜色标识一致、过一致、欠一致三种状态。这样障碍物变化之后你一眼就能看出哪些节点被算法“标记”了从而判断重规划的范围是否符合预期。另一个经验是初始化时不要偷懒把整张地图所有节点的rhs都设成0或者某个固定值必须严格按照“只有终点rhs为0其余节点rhs为无穷大”的规则来。否则算法会认为很多节点已经和终点连通直接跳过大量搜索结果路径根本不对。关于队列的过期机制我再多说一句。D* Lite的优先队列里一个节点可能会被多次入队每次入队时的key可能不同。在弹出时如果你发现当前key比入队时的key更小说明这个节点在这个key下优先级更高了应当重新入队而不是直接处理。如果不做这个检查算法虽然不会错但会多扩展很多节点性能大打折扣。还有一点关于数值边界。当边代价设置成无穷大时表示不可通行计算rhs时要把无穷大和无穷大的加法妥善处理。Python里float(inf)做加法不会报错但如果你用int类型的“9999”来代表很大代价就可能出现溢出或者加出错误结果。建议统一用浮点数或者大整数并设置一个明确的安全边界判断。我自己实际写的版本里代价函数是统一的def cost(self, a, b): if a b: return 0 r1, c1 a r2, c2 b # 超出地图范围或障碍物 if not self.is_valid(b): return float(inf) # 对角线移动代价 if abs(r1 - r2) 1 and abs(c1 - c2) 1: return 1.414 return 1.0这个写法简单直观把不可通行直接映射成无穷大后面的rhs计算就不用额外判断障碍了。关于这个算法在2024年后还有什么新进展我个人的观察是D* Lite已经成为很多现代框架的底层默认选择比如ROS的nav2里虽然主要用NavFn和Smac Planner但动态重规划的很多思想都继承自D* Lite。在AGV调度、多机器人路径规划、动态避障小车路径规划这些方向D* Lite依然是性价比最高的入门和落地算法。如果你能把这篇文章里的原理吃透再动手写一遍代码后续再去看其他变种算法比如基于势场或RRT的动态规划方法会发现很多表达方式都是相通的。最后再分享一个我在项目中常用的技巧D* Lite在每次重规划后不要急着把路径发给底盘控制器。我一般会加一个“路径平滑”的环节把D* Lite输出的锯齿状路径做一次简单的拉直处理这样底盘走起来更顺滑也不会因为频繁转向导致里程计漂移。这个技巧虽然和算法本身关系不大但在实际机器人系统里它对整体效果的影响有时候比选择一个好算法还要大。
返回列表