☰
A*算法结合往返式全覆盖路径规划的Matlab实现与优化
2026/10/1 13:03:15 网站建设 项目流程

做全覆盖路径规划这件事,很多人最开始想的都是“怎么让机器人把每个格子都扫一遍”,但真正在网格地图上把结果跑出来以后你会发现,最花时间、最考功夫的往往不是扫地本身,而是怎么从一个覆盖终点快速移动到下一个覆盖起点。这个移动过程就是转移路径规划,也就是 A* 算法真正发挥作用的地方。这篇内容我围绕“A* + 网格环境 + 往返式全覆盖 + Matlab 实现”这条主线,把整个方案的原理、断点提取、衔接顺序优化、工程实现细节和实测数据完整梳理一遍,适合正在做移动机器人覆盖任务、搞栅格路径规划,或者需要交一份 Matlab 仿真代码的读者参考。内容偏工程实践,会有大量可以直接复用的代码思路和参数经验。

1. 全覆盖任务为什么绕不开A*:往返式方案的真实难点

1.1 A*在覆盖任务中的角色定位

往返式全覆盖路径规划,也常被叫做牛耕式覆盖或割草式覆盖,核心思路非常直白:把地图按照一定方向切成一排一排的带状区域,机器人沿着某一行从头扫到尾,然后抬升到下一行的起点,再反方向扫回来,像犁地一样一层一层推进。这种规划方式在很多背景下都成立:小区扫地机器人、农田植保、仓储巡检、船体除锈,本质上都是这个逻辑的变体。

但有个问题很少有人在一开始点明:这个方法默认的前提是地图里没有障碍物,或者障碍物可以被行人沿着行方向“跨过去”。一旦地图里出现墙体、柱子、设备区这些占地面积比较大的障碍物,一整行的覆盖就会被切断,变成一段一段的碎片。每一段都相当于一个独立的线性覆盖任务,机器人完成这一段之后,需要从这一段的末端移动到另一段的某个端头,继续覆盖。这时候你面临的就是一个典型的“点对点最短路径”问题。

这个点对点移动的最短路径,就是 A* 的用武之地。换句话说,A在这个项目里的角色不是“主路径生成器”,而是“覆盖段之间的转移路径优化器”*。主覆盖骨架由往返扫描规则给出,A* 负责把碎片重新缝起来。

很多人做完整个仿真之后会觉得,A* 在这里好像只是跑了个基础寻路,没什么技术含量。但实际调过之后就知道,转移路径的质量直接决定覆盖率、重复率、总航程这三个核心指标。转移走直线看着近,如果中间穿墙,A* 给出的绕行结果比直线长出一截,新手往往想不通为什么规划出来的路径这么“弯”。后面我会详细展开为什么“看着近”和“走得通”在网格地图里是两回事。

1.2 为什么不用BFS或直线连接

既然只是点对点寻路,为什么不用广度优先搜索 BFS?或者干脆两点之间连一条直线?

先说直线连接。在无障碍的空旷地图里,直线确实是最优的。但网格地图的障碍物通常是不规则多边形,直线一旦撞上障碍栅格,这条路线就完全不可用。要在直线被挡住时规划绕行路径,本质上已经进入了寻路算法的领域,绕不过去的。

再说 BFS。BFS 确实能找到最短路径,而且实现比 A* 还简单。但它的致命问题是探索范围太大:BFS 会从起点出发一圈一圈地往外扩,完全不考虑终点在哪一侧,导致在连通性较好的开阔区域里,它会浪费大量时间扩展与目标无关的节点。A* 多了一个启发式函数 h(n),等于在搜索时给每个节点一个“离终点还有多远”的估计,优先扩展那些“已经走得短且预测还能更短”的节点,搜索范围被大幅压缩。在小地图上可能感受不到差别,一旦地图到 200×200 以上,差距会非常明显。

1.3 容易先踩的坑:把A*当成“全覆盖主生成器”

我刚上手这个方向时也犯过一个认知错误:以为 A* 可以用来直接生成覆盖路径,比如把“已覆盖状态”塞进状态空间里,让算法自己去摸索怎么覆盖整张图。理论上不是完全不可行——把每个节点的状态定义成 (当前位置, 已覆盖位图) 之后,确实可以套用 A* 框架来做全覆盖搜索。但实际跑一个 50×50 的地图就明白了:已覆盖位图的组合数量是天文数字,状态空间直接爆炸,内存被撑满还是轻的,大多数情况是连一次收敛都等不到。

所以工程上几乎没有人在做静态地图全覆盖时用纯 A* 硬刚。更合理的分工是:

  • 行扫描规则负责生成“覆盖段”;
  • 断点选择策略负责确定“下一段是谁”;
  • A* 负责“怎么走过去”。

这个三层结构是本文整个方案的主干,也是最容易被复用到实际工程里的模式。

2. 网格地图与A*实现细节:邻域、启发式与代价设计

2.1 栅格地图的数据结构与坐标约定

Matlab 里做网格路径规划,地图我一般用逻辑矩阵map(row, col)表示:true表示障碍栅格,false表示自由栅格。注意 Matlab 的矩阵索引是先行后列,行坐标表示向下,列坐标表示向右,左上角是(1,1)。这和常见图像坐标系一致,但和画图时用的plot(x, y)坐标系不同——plot里第一个坐标是横轴,对应列;第二个坐标是纵轴,对应行。如果你直接把map拿来画图,不做坐标转换,会发现整张图是上下颠倒的。

我常用的约定是这样的:

% 地图尺寸 [rows, cols] = size(map); % 逻辑坐标 -> 物理坐标:(r, c) 对应 plot 中的 (c, rows - r + 1) % 物理坐标 -> 逻辑坐标:令 x = c, y = rows - r + 1,则 r = rows - y + 1, c = x

除了基础地图,还要准备两个等尺寸矩阵:

  • covMap:记录哪些自由栅格已经被覆盖,0 未覆盖,1 已覆盖;
  • visitedMap:记录 A* 搜索过程中节点是否已经扩展过,避免重复访问。

这几个矩阵在代码里维度都一样,完全可以用逻辑索引做批量操作,比写双层循环快得多。比如统计覆盖率:

freeCnt = sum(~map(:)); coveredCnt = sum(covMap(:) & ~map(:)); coverage = coveredCnt / freeCnt * 100;

2.2 八邻域与对角线代价

网格环境下,机器人每一步能朝哪些方向移动,直接改变路径长度和灵活性。全覆盖任务里我几乎总是用八邻域而不是四邻域,原因很简单:四邻域下机器人从一个栅格只能走上下左右,路径转向角度只有 90°,走起来很僵硬;八邻域允许斜向移动,转移路径更平滑,覆盖段之间的衔接也更自然。

代价设置上:

  • 水平/垂直移动:代价 1;
  • 对角移动:代价 sqrt(2),也就是 1.414。

这样做的好处是路径长度直接对应真实的物理距离,g(n) 计算出来的数值跟地图上的实际距离是同一个量纲,后面统计总航程、重复率都比较直观。

有一点容易被忽略:八邻域下的“斜穿角”问题。在栅格地图里,如果当前格子和对角目标格子都是自由栅格,但中间的拐角栅格是障碍,直接斜穿过去在视觉上会“擦着墙走”,很多实际机器人并不允许这种运动。处理方式通常有两种:

  • 严格模式:判断斜向移动时,要求相邻的两个正交栅格也必须是自由的,否则禁止该方向移动;
  • 宽松模式:不做额外判断,只要目标栅格自由就走。

全覆盖任务的机器人一般体积比栅格小,或者希望尽可能提高灵活性,我建议用严格模式。这个判断在isValidMove函数里加几行就能实现,不会增加多少计算量,但能避免很多后续路径合法性上的争论。注意四邻域没有斜穿角问题,如果实现的是四邻域版本则无需考虑。

2.3 启发式函数的选择

A* 的搜索效率很大程度上依赖启发式函数 h(n)。它衡量的是“从节点 n 到终点的估计代价”。如果 h(n) 始终小于真实最小代价,A* 保证找到最优解;h(n) 越接近真实值,搜索扩展的节点越少;如果 h(n) 在某些情况下大于真实代价,算法会倾向于走一条看似很近但并非最短的路径,就失去最优性保证。

对于八邻域地图,最合适的启发式是切比雪夫距离:

h(n) = max(|r - goalR|, |c - goalC|) * 1

但要注意:八邻域中最小单步代价是 1,而切比雪夫距离计算出来的是“以最小单步代价移动需要的步数”,两者相乘得到的才是符合代价定义的启发值。如果是对角线移动代价统一设为 sqrt(2),情况会复杂一些,此时更稳妥的是使用欧几里得距离:

h(n) = sqrt((r - goalR)^2 + (c - goalC)^2)

欧氏距离在任何邻域定义下都小于等于真实最短路径代价,是最安全的选择。缺点是它比真实代价偏低,搜索扩展量略多一些。如果地图规模不大,直接用欧氏距离写代码最简单、不容易出隐性问题。

我实测过的一种组合是:八邻域、对角代价 sqrt(2)、启发式用切比雪夫距离乘以 sqrt(2)。在多数障碍地图上效果都不错,扩展节点数比欧氏距离少 20%~40%。但如果地图里存在大量长走廊、死胡同,切比雪夫距离可能对某些区域高估,偶尔导致次优路线。所以在工程项目里,我一般默认用欧氏距离,追求效率时才切换成切比雪夫。

启发式函数选择对比:

邻域类型常用启发式是否保证最优搜索范围
四邻域曼哈顿距离是中等
八邻域(正交1、对角1.414)欧氏距离是偏大
八邻域(正交1、对角1.414)切比雪夫×1.414多数时候较小
八邻域(统一步数代价)切比雪夫×单步代价是小

2.4 权重系数与应用扩展

A* 还有一个很常用的变体:加权 A*。把评价函数改成:

f(n) = g(n) + w * h(n)

当 w > 1 时,算法会更激进地向终点方向搜索,扩展节点数量大幅下降,代价是路径可能比最优解长 5%~10%。在覆盖任务里,转移路径多绕几步通常无关紧要,而搜索速度却非常重要,尤其在栅格地图规模大、断点数量多的时候。

我的调参经验是:地图小于 100×100,直接把 w 设为 1,求精确最优;地图到了 200×200 以上,或者 A* 需要在线频繁调用时,w 取 1.1~1.3,速度提升明显,路径质量损失可以接受。

顺便提一个扩展方向:如果机器人不是质点,而是有一定几何尺寸,可以在代价函数里增加“靠近障碍物的额外代价值”,让 A* 自动生成偏向安全区域的路径。公式可以写成:

cost = baseMoveCost + obstaclePenalty + coverageBias

其中obstaclePenalty根据当前栅格周围障碍密度计算,coverageBias后面会讲到,用于控制转移路线尽量走在已覆盖区域,降低整体重复率。

3. 往返式覆盖路径的分段生成与断点提取

3.1 逐行扫描生成覆盖段

往返式全覆盖的第一步,是把地图扫描成覆盖段。我使用的方法是:从上到下逐行遍历地图,在每一行内从左到右扫描。

每行内连续的自由栅格组成一个覆盖段。遇到障碍栅格则终止当前段,开始记录下一段。最终得到一组带状覆盖段,每个段包含:

  • 所在行号;
  • 起始列;
  • 结束列;
  • 左端点坐标和右端点坐标。

伪代码逻辑如下:

function segs = generateScanSegments(map) [rows, cols] = size(map); segs = []; for r = 1:rows c = 1; while c <= cols if map(r, c) c = c + 1; continue; end % 找到连续自由栅格段 cEnd = c; while cEnd <= cols && ~map(r, cEnd) cEnd = cEnd + 1; end cEnd = cEnd - 1; % 记录覆盖段 segs = [segs; struct(... 'row', r, 'startCol', c, 'endCol', cEnd, ... 'left', [r, c], 'right', [r, cEnd], ... 'covered', false)]; c = cEnd + 1; end end end

这个步骤看起来简单,但它其实把“二维全覆盖问题”转换成了“一维线性单元的组合调度问题”,复杂度降低了一个量级,后面的一切衔接优化都建立在这些线段上。

覆盖段左右端点是机器人进入该段的入口候选位置。为什么是两端而不是中间?因为覆盖段是连续直线带状区域,机器人最优的覆盖方式是沿行方向横扫过去。从中间进入意味着得先走到某一端掉头,反而增加路程,所以两端是自然的入口集合。

3.2 断点列表与离线最短路径矩阵

所有覆盖段的端点合在一起,就构成了断点列表。每个断点有两个身份:它既是一个覆盖段的入口,也是上一个覆盖段的出口。

设计者的核心任务,是在所有断点之间找到一条覆盖顺序,让机器人在执行完所有段后总路程最短。这本质上是一个旅行商问题(TSP),不同的是:

  • 机器人访问的不是任意点,而是“成对的端点”;
  • 访问一个端点后,覆盖段本身必须随之覆盖;
  • 覆盖段的两个端点互相之间关系强绑定。

直接求解 TSP 的精确算法复杂度极高,覆盖段数量一旦超过 20 个就非常吃力。所以工程上用的是近似解法——贪心最近邻 + 局部优化。而贪心最近邻要评估“从当前断点到候选断点的路径代价”,这一步就需要先算出所有断点对之间的最短路径。

断点对之间的最短路径是通过 A* 批量预计算的。算法流程是:

  1. 提取所有覆盖段的左右端点,组成端点列表;
  2. 对每两个端点运行一次 A*,得到最短路径长度;
  3. 把结果填入代价矩阵D(i, j);
  4. 若某对端点间不存在可行路径,D(i, j)记为 Inf,后续选择时自动跳过该组合。

这一步是整个方案中离线计算量最大的部分。端点数 N 个,A* 要跑 N×(N-1)/2 次。地图越大、断点越多,耗时越长。我通常在正式跑覆盖顺序之前,先把代价矩阵算好缓存起来,避免在顺序优化过程中反复调用 A*。

如果觉得全量预计算太耗时,可以只计算“当前断点出发,到剩余所有未覆盖段端点”的行,动态扩展,但要注意维护已计算部分。对于 100×100 的地图,全量预计算大约需要几百毫秒到几秒,完全在可接受范围内。

3.3 可达性预判与孤立区域处理

有一个很隐蔽的问题:某些覆盖段被障碍物完全包围或半包围,从某些起点出发可能到达不了,或者要绕非常远的距离才能到。如果不做可达性预判,覆盖顺序优化时会把这种段排到很靠后的位置,导致机器人最后走了大量重复路却依然覆盖不到。

我在项目里用了一个前置步骤:用 BFS 或 A* 判断每个覆盖段是否与起点所在连通区域相连通。具体做法:

  • 从机器人的初始位置出发,做一次区域生长(BFS 遍历自由栅格);
  • 标记所有可达的自由栅格;
  • 检查每个覆盖段的两个端点在不在可达集合内。

如果某个覆盖段完全不可达,就直接从覆盖列表中剔除,并记录进不可达段。这样覆盖率和“理论上可覆盖率”就能分开统计,后面分析实验结果时才不会混淆算法问题与地图连通性问题。

对可达但代价很高的覆盖段,我建议把它的优先级提高而不是降低:因为它是全图最“难啃”的部分,越到后面越容易被困在局部区域。实测下来,把孤立岛状区提前插入覆盖序列,总航程能减少 10% 以上。

4. 断点连接顺序优化:双端扩展与最近邻策略

4.1 贪心最近邻思路

假设现在机器人在某个断点 A,剩余 N 个未覆盖段。贪心最近邻的策略是:从 A 出发,选择离 A 路径代价最小的那个未覆盖段的任意一个端点作为下一个目标,走过去,然后更新当前位置为该段的对侧端点,再重复这个过程。

这个思路非常直觉,实现也快,但有个明显缺陷:它只考虑“走过去这段路”,不考虑“进入这个段后,段本身要走多长、出来后在哪个位置”。两个距离很近但方向相反的覆盖段,走完一个后发现出口离另一个很远,总路程反而长。所以我在项目里实际用的是双端扩展策略,比纯最近邻效果好很多。

4.2 双端进入优化:选择最佳进入端

双端扩展核心思路是:对每个未覆盖段,分别计算“从当前断点 A 到达该段左端点的最短路径代价”和“从当前断点 A 到达该段右端点的最短路径代价”。然后取两者中较小值,再叠加该段的自身长度,作为加入该段的“实际代价”。

这样做的理由很朴素:同一段可以从左端进,也可以从右端进,进法不同,走出该段后落点不同,直接影响下一跳的起点位置。所以“去一段的距离”和“段自身长度”必须绑定在一起评估。

具体选择公式:

for each segment s in remaining: costLeft = D(A, s.left) + s.length costRight = D(A, s.right) + s.length cost(s) = min(costLeft, costRight) choose s* = argmin cost(s)

进入段后,机器人沿段方向覆盖,从对侧端点出来。如果选择左端进入,那么覆盖完后的当前位置是右端;反之则是左端。这个出口端点同时是下一轮迭代的“当前断点”。

当前断点 A ↓ A* 转移到 s* 的左端/右端 ↓ 沿段覆盖 当前断点 = s* 的对侧端点

用这个策略跑 50×50 地图、30 个覆盖段时,总航程比纯单端最近邻平均能低 8%~15%。尤其在障碍物分布不均匀的地图里,收益更明显。双端的额外开销只是每个候选段多查一次代价矩阵,计算量完全可忽略。

4.3 转移路径上的重复覆盖控制

全覆盖任务里,覆盖率不是唯一指标,重复率同样重要。机器人沿着 A* 规划出的转移路径前进时,有可能路过尚未覆盖的自由区域——这条路径走完之后,那些区域就算被顺路覆盖了。对指标的影响是:机器人实际执行路线中,有一部分路程和主扫描路线重叠,总量变成重复覆盖。

工程处理上有两种思路:

  • 顺路覆盖思路:不主动规避未覆盖区域,把 MACRO 覆盖理所当然地当作转移过程的一部分,路径更短,总耗时也更低,但重复率指标会相对不好看;
  • 严格分离思路:A* 路径尽量贴着已覆盖区域边界走,避免触碰未覆盖自由栅格。

我实测的结论是:全覆盖任务模型里通常取前者。因为“重复率”在实际任务中的定义本来就存在争议——从概率覆盖的角度,机器人只要走过某个栅格,就算覆盖过一次;真正有害的重复是“同一栅格被扫三遍以上”或者“转移路径在已覆盖区域内部来回绕”。顺路覆盖恰好属于“低效率但不高害”的行为。

如果确实需要降低重复率,一个可行技巧是在 A* 代价函数里加上一个覆盖状态偏置项:

cost = cost + coverageBias * (1 - covMap(r, c));

这个偏置让路径计算时更倾向于走已覆盖栅格。coverageBias 我一般设为 0.1~0.3,太高会影响路径长度,太低没效果。只跑一遍覆盖的话,建议设置为 0.1 或干脆不加;需要严格压低重复率时再加。

4.4 失效链与兜底策略

双端扩展也存在退化场景。当一个覆盖段被选入路径后,如果它实际上是死胡同——只有一端能进出,另一端被障碍物完全堵住——那么进得去、出不来,整个流程就断了。典型例子是“L”形走廊尽头的覆盖段。

兜底策略需要实现两种情形:

  • 若某个段从当前断点出发只有一个端点可达(另一个端点 A* 返回 Inf),则强制从可达端点进入,覆盖完后返回进入端点,再原路折返;
  • 若两个端点都不可达,则跳过该段,保留到下一轮外围循环尝试一次。

这套机制在普通矩形地图里触发概率不高,但一旦触发就是“要不要卡死”的区别。加了它之后,整个规划流程的鲁棒性会明显提升。

5. Matlab实现要点与性能优化

5.1 工程结构设计

我推荐将整个工程拆成几个独立的函数模块,这样后期替换算法、调整参数、复现实验结果都很方便:

main.m % 主入口:初始化、调用各模块、输出统计 buildMap.m % 地图生成:随机障碍物或手工绘制 generateScanSegments.m % 扫描段生成 computeCostMatrix.m % 断点间 A* 离线代价矩阵 planCoverageOrder.m % 双端贪心顺序规划 astarPath.m % 单次 A* 寻路 drawResult.m % 可视化:地图、覆盖轨迹、统计信息

这样分层的另一个好处是,你后面想换掉贪心策略,改成动态规划或遗传算法来优化覆盖顺序,只需要替换planCoverageOrder.m一个文件,其他模块完全不用动。

5.2 open list 的两种实现方式

A* 的性能瓶颈主要在 open list 的节点排序上。Matlab 没有现成的优先队列数据结构,常见做法有两种:

  • 直接用结构体数组,每次弹出最小 f 值时用sortrows排序;
  • 手写一个最小二叉堆。

我实测过,节点数少于 2000 时,sortrows完全够用,代码也短;但地图超过 100×100、A* 要高频调用时,排序开销占比会非常高,此时二叉堆优势明显。关于二叉堆的实现,我建议直接封装成MinHeap类,里面有push、pop、isEmpty三个方法就够了,不需要写太复杂。

小技巧:为了减少频繁的堆操作,可以在扩展邻居前先查visitedMap,再查当前节点是否已在 open list 里,避免重复入堆。

5.3 矢量化与矩阵预分配

Matlab 最忌讳的是在循环里动态扩充数组,路径规划代码尤其容易踩这个坑。A* 的 open list 虽然看似必须动态增长,但可以用逻辑矩阵 + 节点 ID 的方式来规避形态问题:

  • 给每个栅格一个唯一 ID:id = sub2ind([rows, cols], r, c);
  • 用数组gScore和fScore记录每个栅格对应的代价值,Inf 表示未计算;
  • 用逻辑矩阵visitedMap记录已扩展节点。

这样每一步更新代价都是 O(1) 的矩阵赋值,不需要动态增长。唯一的缺点是每次要从所有未访问节点里挑 f 最小的,这在密集地图上会比较慢。折中方案是维护一个节点 ID 数组,只在扩展时截断,不逐格删除,结合fScore数组批量找出最小值。这部分属于优化细节,有性能需求的读者可以尝试。

map、covMap、gScore、fScore这些矩阵全部在进入循环前预分配好,用Inf填充fScore,比在循环里每次判断ismember快得多。

5.4 可视化技巧

调试全覆盖算法最直观的方式是画出完整路线。我的画法分三层:

  1. 用imagesc(map)显示地形,障碍栅格深色,自由栅格浅色;
  2. 用hold on+plot画出覆盖轨迹,线宽设为 1.2;
  3. 在覆盖段端点上用小圆圈标注断点位置,次序高亮。

如果想实时观察覆盖进度,可以在主循环里每隔几帧调用一次drawnow,配合pause(0.05)。注意不要每次都重绘地图底图,用handle = imshow()获取图像句柄后,循环里只更新轨迹句柄的XData和YData,刷新效率会高很多。

刚开始调试时很容易把整个地图画一遍,路径一长图形卡顿明显,这个细节对体验影响很大。

5.5 实测结果与参数参考

我用 40×30 的地图做了几组实验,障碍物随机生成,占比约 8% 和 30% 两档,每次随机种子不同,各跑 10 次取平均。数据结构为八邻域、对角代价 sqrt(2)、欧氏距离启发式、w=1,硬件为普通笔记本电脑,Matlab 版本 R2023b。

典型结果大致如下:

地图规模障碍占比覆盖段数总航程/栅格转移路程占比覆盖率解算时间
40×308%12~181820~210018%~25%99.4%0.15s
40×3030%20~282150~260035%~42%97.8%0.25s
100×8010%55~80约950022%~30%98.9%2.8s

障碍率升高后,覆盖段被切得更碎,转移路程占比明显上升,这正好印证了前面的判断:碎片的衔接是整个任务的真实瓶颈。覆盖率达不到 100% 的原因是角落存在单栅格宽的死角,A* 和行扫描的栅格粒度决定了这类区域无法处理。如果项目里要求严格 100%,需要额外引入局部螺旋覆盖或转弯半径补偿逻辑,这属于扩展内容,主流场景下不是必选项。

数值给出来供参考,换地图、换障碍率后结果会有浮动,但趋势是一样的。抵达率 100% 完全是可行的,只要地图不存在孤立不可达区域。

写在最后的工程体会

这套“A* 转移 + 往返式覆盖段 + 双端贪心”的结构,我在好几个项目里都用过。最初也是最容易掉进去的坑,就是急着写 A* 寻路,忽略覆盖段的抽象建模。实际上 A* 本身的代码半天就能调通,后面真正的工程量在“段的生成”“可达性预判”“顺序优化”这三块,它们的代码量加起来往往是 A* 主体的好几倍。如果你也在做类似方向,建议把算法调试和实验验证的重心后移,先把覆盖段和断点数据结构设计好,后面的所有优化都是在这张 “骨架”上长肉,会很顺。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询