做机器人路径规划、扫地机、无人机仿真,或者单纯是学校课程设计,A星算法(A*)基本都是绕不过去的第一道坎。我最早接触就是在Matlab里跑栅格地图,手动摆障碍、改起点终点,看那条蓝色路径怎么绕出来的。这篇就把完整的Matlab实现思路写一遍,从核心原理、数学公式,到可以直接改的地图与障碍生成、自定义起点终点的完整脚本,最后是常见的坑。适合要跑课设、做毕设,或者刚入门路径规划,需要在Matlab里快速搭一套A*验证环境的朋友。
说句实在话,A这个名字听起来学术味很重,但它的思想特别朴素:人在一个陌生小区里找路,不也会优先挑那些“看起来离出口更近”的方向走吗?A就是把这种直觉量化成公式,再用代码一步步执行。真正动手写下来你会发现,完整实现也就一百行左右,难的不是代码,而是把地图建模、启发函数、邻域选择这些细节理顺。
1. 这个项目到底在解决什么问题:核心需求拆解
1.1 一个最朴素的需求:从A点到B点怎么走
先说个生活场景。早上出门从小区门口到地铁站,你肯定不乐意走最远的直线绕弯,也不会选择一条被早市摊位挡死的路。要的是距离短、没有障碍、走起来顺畅。机器人、游戏里的NPC、自动导引小车本质上都在解决同一个问题:已知地图和障碍,从起点到终点找一条最优或近似最优的路。这就是路径规划。路径规划算法里,A星,也就是A*,是最经典、最常被用作入门的第一课,也是大量工程系统里真正落地使用的基础模块。它用“启发式搜索”这么一种思路,把盲目搜索变成了有方向感的搜索,效果和速度都很直观,也好调试。
那么这个项目在做什么?标题说得很明白:用Matlab实现A星路径规划算法,地图、障碍物、起点、终点都能自己改。拆开看就是三件事:一是把地图离散成栅格,二是把A*算法跑通,三是整个流程可视化,随时改参数看结果。对课程设计、毕业设计、甚至是刚接触移动机器人导航的开发人员来说,这套小系统是很好的试验台。算法本身不复杂,但能让你在最短时间内把路径规划从概念变成可运行的代码。
1.2 A*算法的基本逻辑:f = g + h,两张表走完搜索
A的核心是一句话:每一步都选f值最小的节点去扩展。f代表这个节点“总的预计代价”,等于g加h。g是从起点走到当前节点已经花掉的代价,h是当前节点到终点还需要的预估代价。A每走一步,都会把当前能到达的节点放进open list(待检查列表),把已经检查过的节点放进close list(关闭列表),然后不断从open list里挑f最小的那个节点拿出来,看看它的邻居们能不能用更小代价到达,能就更新,直到把终点从open list里挑出来为止。
理解这个逻辑的关键在h。h是我们“猜”的剩余距离,猜得越准,搜索越有方向,越少走弯路;猜过头了就可能漏掉真正的最短路径。这里有很多可以调整的细节,比如曼哈顿距离、欧氏距离、对角线距离,我在后面专门说。总之,A*能火这么多年,就是因为它在“找到最优”和“搜索够快”之间给了你一个非常直观的调节旋钮。
1.3 为什么要把地图、障碍、起终点都做成可配置
我见过不少同学写路径规划,第一版通常是把地图写死在主脚本里,测试一个场景改一次代码。这种写法不是不行,但验证算法效果、展示结果、跟别人讨论方案的时候非常别扭。想象一下,你要给老师或者同事演示A*在不同障碍布局下的表现,每换一个场景都得去翻矩阵、改数字、重新调试,效率极低。
把地图和起终点作为输入参数抽出来,是这个项目真正的小设计。它的好处有两个:一是你可以在同一份代码上快速做大量实验,观测算法在不同障碍密度、不同起点终点组合下的路径形状、搜索节点数、耗时变化;二是代码结构更接近真实工程——真实系统里地图来自传感器或者地图服务,起点终点来自任务调度器,算法模块永远不应该假设“地图是某个固定矩阵”。所以这个“可配置”的需求,并不是额外加戏,而是一种正确的模块化思路。
2. A星算法核心原理:先把 f = g + h 吃透
2.1 地图建模:栅格地图为什么是最顺手的选择
路径规划里的地图有好几种表示方式。常见的有栅格地图(把空间划成等间距的格子)、拓扑地图(只保留节点和边)、几何地图(用几何形状描述环境轮廓)。A*最常用的就是栅格地图。原因很朴素:矩阵天然就是格子,Matlab里一个二维矩阵直接就是一张图,1表示可通过,0表示障碍,显示出来黑白分明,非常直观。栅格地图的代价就是格子粒度,粒度越细路径越平滑,但计算量越大,这个在真实机器人里要根据车体尺寸和定位精度权衡。作为学习和仿真,用20乘30、50乘50这种规模的地图足够了。
写代码时有个容易踩的坐标坑:Matlab矩阵的索引是行和列,行对应图像里的纵坐标(从上往下递增),列对应横坐标(从左往右递增)。而很多人习惯x和y的笛卡尔坐标,画图时plot(横坐标, 纵坐标)。一旦搞混,路径会显示得扭曲,起点终点也会对不上。我的经验是:在代码内部一律用[row, col]来思考和处理,只在最后可视化时把col当成x、row当成y去plot。
2.2 三种距离函数怎么选:曼哈顿、欧氏、对角线距离对比
启发函数h的选择直接决定搜索效率和路径质量。栅格地图里三种距离最常用,我列个对比表:
| 距离函数 | 公式 | 适合的邻域 | 特点 |
|---|---|---|---|
| 曼哈顿距离 | h = abs(dx) + abs(dy) | 4邻域 | 计算最快,栅格A*最常用;在有对角线移动的地图上会略微低估代价 |
| 欧氏距离 | h = sqrt(dx^2 + dy^2) | 8邻域/任意方向 | 符合几何直觉,但相对于栅格代价偏低,搜索范围偏大 |
| 对角线距离 | h = max(dx, dy) + (sqrt(2)-1) * min(dx, dy) | 8邻域 | 精确匹配对角线移动代价,效果最均衡 |
我个人的默认选择是:如果只允许上下左右走,用曼哈顿距离;如果允许斜着走,用对角线距离。欧氏距离在8邻域里能用,但效率上没优势。还要记住一个原则:h不能大于实际的最小可能代价,否则A不再保证找到最优路径。h越大,搜索越快但越“激进”;h越小,搜索越慢但越“保守”;当h等于0的时候,A就退化成了Dijkstra算法。
2.3 邻域选择:4邻域还是8邻域,这个决定路径形态
地图上从一个格子到另一个格子,每一步能走的方向数是另一个关键设计。4邻域只允许上下左右走,每一步代价设为1,路径必然是直角折线型,适合模拟普通轮式机器人或者只能在巷道里行驶的AGV。8邻域多了四个对角线方向,路径可以斜着走,更接近人走路的习惯,但代价处理要让对角线方向的移动是根号2,否则斜走和直走等代价,路径会显得很怪。
8邻域有一个必须处理的问题:斜着穿墙。如果当前格子右方是墙,下方是墙,右下方却是空地,算法可能直接从右上角穿到右下角,视觉上就是穿过了墙角。解决办法是在允许对角移动前做一次检查:只有当水平方向和垂直方向的两个邻居都可行时,才允许走斜对角。这个检查成本极低,但效果立竿见影。下面在实现代码里我会把我常用的写法放进去。
2.4 open list和close list:数据结构决定搜索效率
A*逻辑上就靠两张表:open list存“还没被最终确定的候选节点”,close list存“已经确定最短路径代价的节点”。每次从open list中取f最小的节点,把它移入close list,再扩展邻居。如果新路径让某个邻居的g更小,就更新它的g、f和父节点。这套机制理解起来不难,但用Matlab实现时有一个性能上的坑:如果每次都用矩阵存储open list,然后min找最小的f,在几百个格子里没问题,但地图到几百乘几百时就会越来越慢。
更快的做法是使用优先队列(最小堆)管理open list,这样取最小值和插入节点的时间复杂度从O(n)降到了O(logn)。Matlab没有内置的堆结构,但可以用containers.Map、自定义类,或者干脆继续用矩阵但多做剪枝。对大部分学习和课设场景,用矩阵加min函数就够用,我在代码里先用最直观的写法,后面再讲怎么优化。
3. Matlab完整实现:从地图生成到路径可视化
3.1 生成地图的三种方式:写死障碍、随机障碍、鼠标画障碍
先把最基本的地图生成做了。我建议把地图初始化写成一个独立脚本或者函数,方便随时换场景。最简单的方式是直接对矩阵赋值:
map = ones(20, 30); % 20行30列,1表示可通行 map(3:4, 8:12) = 0; % 一堵横向墙 map(10:15, 18:20) = 0; % 一堵竖向墙 map(17:18, 6:9) = 0; % 左下角墙 map(7:9, 24:27) = 0; % 右上角墙如果想把边界也封起来,可以直接把map最外圈置0,避免算法跑出地图边界,不过我的代码里本身有边界检查,所以不封也没关系。
需要随机障碍的话,可以按一定概率把格子置0,再结合形态学处理避免障碍太碎。更直观的方式是用Matlab的ginput,鼠标点击画障碍区域:
figure; imagesc(map); colormap(gray); axis image; disp('鼠标左键点击画障碍,右键结束'); while true [x, y, button] = ginput(1); if button ~= 1, break; end row = round(y); col = round(x); if row >= 1 && row <= size(map,1) && col >= 1 && col <= size(map,2) map(row, col) = 0; imagesc(map); colormap(gray); axis image; end end注意这里ginput返回的x是横坐标,对应矩阵的列;y是纵坐标,对应矩阵的行。很多人随手一写就把行列搞反了,然后发现画的障碍和实际位置对不上。
3.2 核心A*函数:主循环、邻居扩展和路径回溯
下面把核心函数写出来。我尽量保持代码短小易读,注释写在关键位置。这个函数支持8邻域、对角线穿墙检查、曼哈顿距离启发函数:
function [path, totalCost] = myAstar(map, start, goal) [rows, cols] = size(map); if map(start(1), start(2)) == 0 || map(goal(1), goal(2)) == 0 error('起点或终点在障碍物里'); end % 8个搜索方向,以及对应移动代价 dirs = [-1 -1; -1 0; -1 1; 0 -1; 0 1; 1 -1; 1 0; 1 1]; moveCost = [sqrt(2) 1 sqrt(2) 1 1 sqrt(2) 1 sqrt(2)]; % 初始化代价表和父节点表 gScore = inf(rows, cols); fScore = inf(rows, cols); cameFrom = cell(rows, cols); gScore(start(1), start(2)) = 0; fScore(start(1), start(2)) = calH(start, goal); % open list:每一行是 [行, 列, f值] openSet = [start(1) start(2) fScore(start(1), start(2))]; closedSet = false(rows, cols); while ~isempty(openSet) % 取f最小的节点 [~, idx] = min(openSet(:, 3)); current = openSet(idx, 1:2); if isequal(current, goal) path = backtrace(cameFrom, start, goal); totalCost = gScore(goal(1), goal(2)); return; end openSet(idx, :) = []; closedSet(current(1), current(2)) = true; % 扩展8个邻居 for k = 1:size(dirs, 1) nr = current(1) + dirs(k, 1); nc = current(2) + dirs(k, 2); if nr < 1 || nr > rows || nc < 1 || nc > cols continue; end if map(nr, nc) == 0 || closedSet(nr, nc) continue; end % 斜向移动穿墙检测 if abs(dirs(k,1)) == 1 && abs(dirs(k,2