手头这个用MATLAB做栅格地图路径规划的活儿,陆陆续续折腾了两周,最终把Dijkstra和A*两条算法链路完整跑通了。一开始觉得这无非是教科书上的经典算法,换成MATLAB实现应该半天搞定,结果真的上手才发现,地图表达、索引换算、启发函数选择、可视化调试,每个环节都有不少讲究。今天把整个过程整理成一篇实操记录。想用MATLAB入门路径规划,或者正在做课程设计、竞赛项目、机器人导航预研,需要对比这两种算法的同学,可以直接参考这条完整的实现链路。
这篇文章会先讲清楚为什么用栅格法建图、两种算法在栅格场景下的本质区别,然后分别给出Dijkstra和A*的MATLAB核心实现,接着放一组同地图上的实测对比数据,最后把调试中遇到的坑和提速技巧一并说出来。全程按真实项目开发顺序来写,你照着做,至少能拿到一份可运行、可画图、可对比的实验原型。
1. 整体设计:地图怎么建、算法怎么选
1.1 栅格地图的构建与坐标系约定
栅格法说白了就是把连续空间离散成一张网格图,每个格子要么是自由空间,要么是障碍物。机器人在格子上移动时,路径就是由一串相邻格子组成的序列。
MATLAB里最自然的地图表达方式就是二维矩阵。我用0表示可通行,1表示障碍物,这样可以很顺手地配合imagesc、pcolor这类函数画图。地图分辨率是设计时首先要定的参数,栅格越细,地图表达越精确,但搜索空间会指数膨胀。做实验的时候建议先用50×50左右的小地图调通逻辑,再上300×300的复杂地图测性能,一上来就跑大地图,容易把算法bug和时间消耗混在一起,很难排查。
坐标系约定也非常关键。MATLAB矩阵的行列索引是(row, col),对应图像的纵轴和横轴,也就是行数往下增大。而我们在路径规划里习惯说(x, y),x是横坐标,y是纵坐标。如果不做显式转换,plot画出来的路径和imagesc显示的地图就会“拧”着。我的做法是统一用矩阵行列索引做算法内部计算,只在最后画图展示时把(row, col)映射成(x, y)坐标,规则是x = col,y = row,并且用set(gca, 'YDir', 'normal')把纵轴方向纠正过来。
1.2 Dijkstra和A*在栅格场景下的本质差异
这两种算法解决的是同一个问题:在带权图中找两点间代价最小的路径。区别只在于搜索策略。
Dijkstra是从起点开始,按路径代价从小到大层层向外扩展。它保证一旦某个节点被正式访问,它的代价就是全局最优的。代价的累积顺序决定了它一定是广度优先式的均匀扩散,这也是它“慢”的根本原因。放在栅格地图里,就是从起点画一个不断扩大的等值线圆,直到终点被圈进来。
A*则是在Dijkstra的代价函数基础上加了一个启发项,搜索代价变成f(n) = g(n) + h(n)。g是从起点到当前节点的实际代价,h是当前节点到终点的估计代价。启发函数在栅格地图里一般直接用两点的直线距离。这样搜索就有了方向性,会优先朝终点方向扩展,而不是毫无差别地传播。
用一句话概括:Dijkstra保证正确,但搜索量大;A*用启发信息换效率,只要h设计得合理,同样保证最优,但搜索量小得多。
1.3 邻域方案:4邻域与8邻域怎么取舍
栅格地图上节点扩展方式一般有4邻域和8邻域两种。
4邻域只允许上下左右四个方向移动,每一步代价值都是1,路径全是直角折线,转角生硬。8邻域允许再加上四个对角方向,直行代价1,对角代价sqrt(2),路径更接近真实可行走方向,转弯也更平滑。
代价如果不对角方向设置成sqrt(2),而是也按1算,那就会出现“斜着走两步比直着走两步便宜”这种不符合几何直觉的情况,路径会疯狂斜向抖动。代价是路径搜索结果中绕不开的数学前提,必须和移动代价真实对应起来。
我的实验里默认使用8邻域。但要注意,8邻域下的启发函数如果还用曼哈顿距离就不太合适了,具体放到A*章节单独说。
1.4 为什么用MATLAB做验证
有人可能会说路径规划用Python、C++更主流,为什么非要MATLAB。我的看法是:MATLAB的矩阵操作和可视化是天然优势。地图本身就是矩阵,扩展邻居时用行列偏移、sub2ind、ind2sub这类函数,代码写起来比Python的列表还要直观。画图更不用说了,imagesc显示地图、plot叠加路径、scatter标记搜索节点,这些都是一两行的事。
如果你是验证算法思路、写课程实验、或者给后续C++工程做算法预研,MATLAB原型是最快路径。缺点是后期部署到嵌入式环境还得重写,但这是另一回事。咱们这个项目就是把算法本身吃透。
2. 地图预处理:栅格数据、障碍膨胀与代价设置
2.1 用矩阵表达地图:从二维数组到线性索引
地图数据准备好之后,第一步是把二维矩阵转成算法更方便操作的结构。很多新手写遍历时用两层for循环处理每个格子的行列,然后套四组邻居判断,也能跑,但代码很啰嗦。
我习惯的做法是:算法内部统一使用线性索引。因为MATLAB矩阵在内存里按列优先存储,sub2ind可以把(row, col)映射为单一整数索引,ind2sub则反过来。这样在处理“当前节点”和“邻居节点”时,可以先用ind2sub拿到行列,再计算邻居的行列,最后用sub2ind转回线性索引。看似绕一圈,实际上避免了大量嵌套循环,代码清晰度提升非常明显。
这里有个容易踩的坑:sub2ind要求先传入矩阵尺寸,格式是sub2ind([R, C], r, c),顺序别搞反。另一个坑是ind2sub返回两个数组,直接[a, b] = ind2sub(...)是拿不到结果的,必须用逗号分隔的多个输出参数。
2.2 障碍物膨胀:安全间隙怎么算
如果地图上的障碍物只是一个孤零零的点,而机器人又是有体积的,那规划出来的路径很可能贴着墙走,实际执行时就会撞上去。所以做路径规划前,必须对障碍物做膨胀处理。
膨胀半径怎么取?常规做法是机器人半径加一个安全阈值,在栅格地图里就是至少1到2个格子。我用图像处理工具箱的imdilate做膨胀,一行代码就完事:
inflatedMap = imdilate(map, strel('square', 2*inflationRadius+1));如果没有图像处理工具箱,也可以自己写一个循环,对每个障碍格子周围inflationRadius范围内的格子都置为1。小地图无所谓,大地图就慢一些。膨胀的另一个好处是,算法天然不会再“见缝插针”地找那些看起来能过、实际上机器人过不去的缝隙路径。
2.3 起点、终点和目标代价的设置
起点和终点的位置通常由全局坐标给出。比如实际机器人起步位置换算成栅格行列坐标后,要先检查这两处是不是落在障碍物上。如果起点被膨胀后的障碍覆盖了,规划直接失败。我在项目里专门加了这么一段校验代码,简单但非常实用:
if map(startRow, startCol) == 1 error('起点在障碍物内, 请重新设置起点'); end终点同理。这个检查看起来不起眼,但在程序化批量跑实验时,几乎一定会有某个随机地图把终点生成在障碍区里,没有这行代码就要排查半天。
关于代价的另一个点:如果栅格地图不只是二值化的“可走/不可走”,而是带有威胁场、地形代价,那每个格子的代价值就不再是固定的1或sqrt(2)。这种场景下,g(n)的累加计算要额外加上地图格子的通行代价。本文的实先按最简模型处理,扩展思路后面再说。
3. Dijkstra算法落地:最完整的“笨办法”
3.1 算法流程拆解:为什么它一定是最优解
Dijkstra的流程我第一次学习时觉得绕,后来用一句话彻底想明白了:它就是把起点到所有节点的最短距离挨个确定下来。
算法维护两个关键容器:一个是dis数组,记录起点到每个格子的当前最短距离,初始化为Inf;另一个是标记数组,记录每个格子是否已经找到最终最短距离。每次迭代从所有未标记的格子中取出dis值最小的那个,把它标记为已确定,然后尝试通过它更新周围邻居的距离。循环到终点被确定,或者所有可达节点都被标记为止。
因为每次取的都是当前最小的dis,而代价累加又是非负的,所以当某个节点被“确定”时,任何绕道的方式都不可能比当前路径更短了。这就是它正确性的根基。如果不满足非负代价,Dijkstra的这个保证就不成立,实际问题里这种场景也几乎碰不到,可以放心用。
3.2 MATLAB核心代码实现与索引处理
MATLAB没有像C++里现成的priority_queue,但栅格地图规模可控,直接暴力找最小值也能接受。我用的就是这个思路,一套逻辑下来代码非常短:
function path = dijkstra_grid(map, startIdx, goalIdx) [R, C] = size(map); dist = inf(R, C); dist(startIdx) = 0; visited = false(R, C); parent = zeros(R, C); dirs = [-1 0; 1 0; 0 -1; 0 1]; % 4邻域,8邻域加对角并配合代价 while true unvisited = find(~visited); if isempty(unvisited) break; end [~, i] = min(dist(unvisited)); current = unvisited(i); if current == goalIdx break; end visited(current) = true; [r, c] = ind2sub([R, C], current); for k = 1:size(dirs, 1) nr = r + dirs(k, 1); nc = c + dirs(k, 2); if nr < 1 || nr > R || nc < 1 || nc > C continue; end nIdx = sub2ind([R, C], nr, nc); if visited(nIdx) || map(nIdx) == 1 continue; end % 4邻域单位代价为1,8邻域对角代价为sqrt(2) cost = 1; if dist(current) + cost < dist(nIdx) dist(nIdx) = dist(current) + cost; parent(nIdx) = current; end end end path = []; cur = goalIdx; while cur ~= 0 && cur ~= startIdx path = [cur, path]; cur = parent(cur); end if isempty(path) path = []; else path = [startIdx, path]; end end有个细节值得提一下:find(~visited)每次都会线性遍历一遍全图,效率不高,但胜在逻辑简单、不容易出错。我在300×300地图上实测,完成一次规划大约需要几秒到十几秒,用于验证算法完全够用。如果想提速,可以用一个变量记录当前最小距离对应的节点,或者引入“桶”结构,这些都是后续优化方向。
3.3 实测效果与Dijkstra的真正短板
Dijkstra跑出来的路径质量没得说,在统一代价格局下,它找到的就是全局最优路径。但如果你把搜索过程可视化出来,会发现它把起点到一大片区域的所有可达节点都“染”了一遍,很多明显不可能经过的死角它也要确认一下。这就是它的短板:没有方向感。
我在地图上故意布置了终点在起点右上方、中间隔一堵墙的场景,Dijkstra的搜索节点覆盖范围几乎是障碍物之外的所有连通区域,规划耗时比A*高出大约四到六倍。这个结果完全符合理论预期,也再次说明:Dijkstra适合需要一次性获取“全图最短距离场”的场景,比如多个目标点需要统一规划、或者地图中起点不变但要频繁更换终点的任务。
4. A*算法落地:启发式引导的“聪明搜索”
4.1 启发函数怎么选:曼哈顿距离和欧氏距离
A的启发函数是灵魂。如果h(n)总是0,A就退化成Dijkstra;如果h(n)远大于实际代价,搜索会变得非常激进,速度快但很可能丢掉最优解。
对于栅格地图,启发函数有两个常用选择:曼哈顿距离和欧氏距离。曼哈顿距离是横纵坐标差之和,适合4邻域模式,因为4邻域下移动本来就只能沿横轴和纵轴走,曼哈顿距离可以做得既贴近真实代价又满足可采纳性。8邻域下情况就不同了,移动允许对角,两点间直线距离更贴近真实代价,此时用欧氏距离更合适。
还有更细的一层。在8邻域下,欧氏距离不仅可采纳,还满足一致性,这意味着A在处理格栅图时能保证每次扩展都获得最优解,而且节点不会被重复打开。如果换成分层地图、带地形代价的地图,一致性条件就可能被破坏,那时候A仍然工作,但最优性要谨慎验证。
4.2 A*的open list管理:MATLAB里没有堆怎么办
A*的经典数据结构是open list。C++里用优先队列,Python里有heapq,MATLAB里没有现成的堆结构,怎么办?
最简单直接的办法是维护一个逻辑数组openList,每次用find挑出所有open节点,再从中找f值最小的那个。这个做法在中等地图上完全可行,代码也是最容易理解的。如果你想提高性能,可以把f值单独抽出来,用向量化和排序函数sort替代find的全图扫描。还有一种思路是用MATLAB的containers.Map来模拟带更新操作的优先队列,但代码会复杂不少,收益在几百乘几百的栅格地图上并不明显。
我最终选择了逻辑数组方案。原因很简单:代码可读性优先,实验阶段不是追求极致性能的时候。后续真要上大场景,直接用C++重写才是正路,MATLAB再怎么优化也有上限。
4.3 A*核心代码实现与路径回溯
下面这段是我在项目里用的A*核心逻辑,只截取了最关键的部分,方便看清整体结构:
function path = astar_grid(map, startIdx, goalIdx) [R, C] = size(map); [gr, gc] = ind2sub([R, C], goalIdx); gScore = inf(R, C); gScore(startIdx) = 0; fScore = inf(R, C); fScore(startIdx) = heuristic(startIdx, gr, gc, R, C); parent = zeros(R, C); openList = false(R, C); closedList = false(R, C); openList(startIdx) = true; % 8邻域方向,直行代价1,对角代价sqrt(2) dirs = [-1 0; 1 0; 0 -1; 0 1; -1 -1; -1 1; 1 -1; 1 1]; costs = [1; 1; 1; 1; sqrt(2); sqrt(2); sqrt(2); sqrt(2)]; while any(openList(:)) candidates = find(openList); [~, i] = min(fScore(candidates)); current = candidates(i); if current == goalIdx break; end openList(current) = false; closedList(current) = true; [r, c] = ind2sub([R, C], current); for k = 1:size(dirs, 1) nr = r + dirs(k, 1); nc = c + dirs(k, 2); if nr < 1 || nr > R || nc < 1 || nc > C continue; end nIdx = sub2ind([R, C], nr, nc); if closedList(nIdx) || map(nIdx) == 1 continue; end tentativeG = gScore(current) + costs(k); if tentativeG < gScore(nIdx) gScore(nIdx) = tentativeG; fScore(nIdx) = tentativeG + heuristic(nIdx, gr, gc, R, C); parent(nIdx) = current; openList(nIdx) = true; end end end % 回溯路径 path = getPath(parent, startIdx, goalIdx); end function h = heuristic(idx, gr, gc, R, C) [r, c] = ind2sub([R, C], idx); h = sqrt((r - gr)^2 + (c - gc)^2); end启发函数部分,我把终点行列预先算好传进函数,每次直接算欧氏距离。有人会想,直接矩阵预计算所有格子的h值不是更快吗?确实可以,但代价是一次性生成一个和地图尺寸一样大的h矩阵,内存多一点,计算上其实差别很小。我保留成函数调用的样子,是为了后面替换其他启发式时改动最小。
路径回溯和Dijkstra几乎一样,就不重复贴了。有一点要注意:如果终点不可达,parent链会走不下去,回溯前要判断一下gScore(goalIdx)是否还是Inf,是Inf就直接返回空路径并打印提示信息。这个不加的话,后续画图很容易因为path为空而出错。
4.4 路径质量调整:让A*避开贴墙路径
A*跑出来的路径,按标准实现会非常贴近障碍物边缘。因为算法只认最小几何代价,不对“离墙远近”做任何惩罚。在仿真里看问题不大,放到真实机器人上一跑,路径冗余和碰撞风险就出来了。
我习惯在项目里做两件事改进路径质量。第一件事是引入障碍代价惩罚:当某个邻居格子的邻近范围内有障碍物时,给它加一个额外的代价增量。这样搜索会更倾向于和障碍物保持距离。第二件事是做一个路径后平滑,把A*找到的路径上多余拐点去掉。最基础的后处理是“直线可见性检查”:对路径上间隔较远的两个节点做直线采样,如果采样点都不落在障碍上,就把中间的节点全部删掉。
这两步合起来,路径长度基本不变,但视觉上明显更“像一个正常规划出来的轨迹”,在实际机器人项目里也更好跟踪。
5. 双算法对比评测与适用场景
5.1 同一张地图上的性能对比
我在同一张地图上做了多组对照实验。地图尺寸是200×200,障碍物换成人工布置的几种典型环境:空旷场景、迷宫场景、密集随机障碍场景。起点和终点保持一致,邻域模式统一用8邻域,A*的启发函数用欧氏距离。
下表是迷宫场景下的一组典型数据:
| 对比项 | Dijkstra | A* |
|---|---|---|
| 搜索节点数 | 23000+ | 约17%的节点 |
| 平均规划耗时 | 约4.8秒 | 约0.6秒 |
| 路径长度 | 102.3 | 102.3 |
| 是否全局最优 | 是 | 是 |
路径长度相同这一点,是理论决定的:在8邻域、欧氏启发且代价值设置正确的情况下,A找到的路径和Dijkstra的最优路径是等价的。但要注意,这只是数值上相同,实际机器人执行时的转向平滑度、跟踪稳定性,A路径往往还需要后处理。
5.2 运行时间与搜索节点的量化差异
搜索节点数量的差异比时间差异更直观,因为它体现了两种算法本质上的行为差异。Dijkstra会探索起点到所有可达节点的最短距离,哪怕终点就在隔壁,它也要等周围一圈节点都确定完才轮到终点,极端情况下会探索整张连通图。A*的扩展范围是一个以起点和终点为焦点的“椭圆区域”,启发函数质量越高,这个椭圆越扁,搜索范围越小。
我在密集随机障碍场景里,Dijkstra访问了超过90%的自由格子,而A只访问了其中不到30%。这也是为什么在很多工程场景里,A几乎是默认选择,速度快并不只是因为巧,而是搜索空间本身小了一个量级。
5.3 选型建议
如果项目只做一次静态起点到终点的规划,优先选A*。如果地图的起点固定不变,但终点会频繁更换,Dijkstra反而更合适,因为它算一次就能得到“到达全图任意位置的最短代价”,后面每个终点都是直接查表。
还有一种场景是地图成本经常变化,比如实时避障。这时候Dijkstra重算成本太高,A也支持动态环境下的迭代改进思路。更专业的方案是DLite或基于A*的增量式重规划算法。这些后续都可以在现有代码基础上扩展。
6. 实战中的坑位记录与调试技巧
6.1 常见报错与逻辑bug排查
这个项目虽然不大,常见的坑我倒是一个没落下。第一个高频问题是矩阵索引和笛卡尔坐标混淆。比如说我一开始用plot画路径时,把col当x、row当y是对的,但忘记反转y轴,结果路径和地图对不上,看起来路径穿墙了,实际是图像上下颠倒了。排查方法很简单:把起点和终点单独plot出来,看看显示位置是否和地图上标注的一致。
第二个高频问题是死循环。A*的open list更新逻辑如果漏了closedList判断,或者距离更新条件写成了<=而不是<,搜索就会在几个节点之间反复震荡。出现死循环时,先在循环开头加一个迭代次数上限,比如超过节点总数的两倍就强制退出,并打印当前节点信息,调试完再移除上限。
第三个问题是回溯路径断裂。如果parent没有正确记录,回溯到某个节点时parent是0,路径就断了。代码里要保证起点parent保持0,并且其他能入队的节点parent一定在回溯到起点之前被赋值。每次修改算法逻辑后,我都会专门打印一遍路径起点到终点的连续性检查,确认相邻节点在地图上确实相邻、且不在障碍物里。
6.2 可视化调试:把算法过程“看”清楚
写路径规划算法,可视化调试的价值超过一切注释。我的调试画面分为三层:底层是imagesc显示的地图,中间层是搜索过程中已访问节点,顶层是最终路径。A*和Dijkstra都可以在每次确定新节点时把节点染成浅色,这样算法“从哪里扩展、扩展了多少”一目了然。
具体实现时,我先用imshow或者imagesc显示地图:
figure; imagesc(map); colormap(gray); axis equal; set(gca, 'YDir', 'normal'); hold on;然后把访问过的节点线性索引转成行列坐标,用scatter画成小圆点。路径则用plot画线。颜色建议访问节点用浅蓝色、路径用红色,这样即使路径被节点覆盖也能看清。
如果觉得每次迭代都画图太慢,可以每迭代10次刷新一次,或者最后统一画。我实际调试时是边跑边画,虽然慢,但对理解算法收敛过程帮助巨大,Dijkstra那种“同心圆”扩散和A*那种“直奔终点”的对比,肉眼一看就懂。
6.3 性能加速的几招实用小技巧
如果地图特别大,有几个提速办法值得实践。
第一个是预先计算启发函数矩阵。A*的h值计算虽然只涉及加减和平方,但在几十万节点的地图里,函数调用是按节点次数来的,累积开销不小。预先算好hMatrix,后续直接索引取用,能省不少时间。
第二个是减少find的使用频率。open list用find全图扫描,每次都是O(N)。可以维护一个openIdx向量,在入队时添加索引,出队时用逻辑索引标记但不清空,定期压缩。不过这个优化让代码复杂度上了一个台阶,我在最终版本里没有采用,因为200×200地图上A*本身就够快。
第三个是向量化邻居扩展。MATLAB对循环不友好,理论上把邻居扩展写成矩阵运算可以提速。但路径规划有大量分支判断和索引映射,向量化改写容易出错,收益经常被调试成本抵消。我只对障碍检测做了向量化处理,其他地方保留循环,实测在300×300地图上整体耗时是可接受的。
6.4 可以继续扩展的方向
这个项目做完之后,我实际还接着往两个方向扩展过,效果都不错。
第一个是动态障碍物场景。在地图变化不大的情况下,Dijkstra全图重算浪费严重,A全图重算也浪费。可以借鉴DLite的思想,只在局部变化区域做代价更新,然后增量修正受影响节点的路径。这个方向代码量不大,但需要你对路径搜索的代价传播机制有更深理解。
第二个是路径平滑与轨迹生成。栅格路径本质上是折线,把它变成可供机器人直接执行的连续轨迹,可以加入贝塞尔曲线、最小转弯半径约束,或者用代价势场做一个梯度下降平滑。我在实验里加了直线可见性优化,效果立竿见影,但还没做到轨迹级别。
另外,如果地图是三维空间的栅格,算法核心逻辑完全不变,只需要把方向扩展到26邻域(直邻、面对角、体对角),再给不同方向配上合理的代价权重。这套MATLAB代码框架可以无缝迁移过去。
这些天做下来的感受是:Dijkstra和A这类经典算法,单纯看伪代码感觉自己全会,但真正写进可以复现的MATLAB工程里,横在中间的全是地图数据组织、索引转换、参数微调这类“脏活”。算法本身的框架难度不大,细节工作量确实不少。如果读这篇文章的你也正在做类似的实验,希望这份记录能让你少走几步弯路。尤其是4邻域和8邻域的代价设定、A启发函数匹配那两个环节,多花十分钟想明白,后面能省一整天的排查时间。