简介:基于栅格地图的Dijkstra算法路径规划资源,面向学习MATLAB路径规划与图搜索算法的开发者,解决在栅格化环境中从起点到终点的最短路径求解问题,可应用于机器人导航、游戏AI与GIS分析等场景。资源包共6个文件,以5个.m脚本为主,包含DijkstraPlan、DijkstraSample、Dijkstraguihua等核心程序,配套1张运行效果图,压缩包仅56KB,便于快速下载参考。已有5026人学习浏览。读者通过源码与示例,可理解Dijkstra算法的贪心扩展逻辑、优先队列实现方式,以及栅格地图的障碍物建模、邻居遍历和路径回溯等关键步骤;还能对照地图数据初始化、最短路径更新与终点反推等环节,掌握将算法迁移到实际工程中的完整思路。脚本结构清晰、注释直接,适合在MATLAB中直接运行、修改参数或进一步封装复用。 我不止一次跟做机器人的朋友说,如果让我从所有路径规划算法里挑一个做“看家本领”,那一定是在栅格地图上跑Dijkstra。别急着反驳,A确实快,RRT也确实能处理高维空间,但Dijkstra在栅格地图上有一种“笨拙的可靠”:只要你给它一张完整的图,它就敢跟你保证,找到的那条路一定是代价最小的路。这种“稳”在工程里太宝贵了。
这篇文章我想把整套东西从头到尾捋一遍——怎么把环境变成栅格地图、怎么设计数据结构让Dijkstra跑得动、怎么处理膨胀层和动态障碍物、以及最后一个能跑的C++实现长什么样。适合刚入坑机器人路径规划的学生,也适合那些已经在跑ROS但只想用“最朴素的算法”解决全局规划问题的工程师。
1. 整体思路与方案选型
1.1 栅格地图与Dijkstra“门当户对”的原因
很多人有疑问:Dijkstra不是图搜索算法吗?栅格地图不是网格吗?这俩怎么结合?
其实栅格地图天然就是一张加权图。每一个格子是一个节点,格子与格子之间的相邻关系就是边。四邻域的栅格地图,每个格子最多有四条边;八邻域的话就变成八条。而Dijkstra在图中做的事情是维护一个“从起点到当前节点的最短路径代价”,然后不断从待处理队列里挑代价最小的节点进行松弛操作。
在栅格地图上这有什么好处?好处是格子之间的代价关系极其清晰。每个格子到相邻格子的移动代价是固定的——走直线是1个单位的代价,走对角线如果开了八邻域,可以设成1.414,也就是勾股定理的根号2。这种“代价可计算”的特性让Dijkstra不用像RRT那样靠采样碰运气,也不用像A*那样需要设计一个高质量的启发函数。
我见过不止一个项目把A*用在栅格地图上,发现因为启发函数设计得不好导致路径贴着障碍物走,最后还得加平滑模块。Dijkstra没有这个问题,它就是纯BFS的加权版本,沿着代价等值线“一圈一圈”往外扩展,虽然慢,但扩展过程就是最短路树的生长过程,每一个节点的代价在被确定时就已经是全局最优解。
1.2 全局规划与局部避障的分工设计
实际做机器人导航时,我不建议用Dijkstra直接处理动态障碍物,因为动态环境意味着地图在实时变化,而Dijkstra每次重新规划都是一次全图重搜索,计算量扛不住。
更工程化的做法是分层规划:上游是一个全局规划器,用Dijkstra(或者A*)在静态的栅格地图上算出一条从起点到目标点的全局路径;下游是一个局部规划器,比如DWA(动态窗口法)或TEB,负责在跟随全局路径的同时感知周围动态障碍物,实时调整速度与方向。
这么拆的好处在于职责单一:Dijkstra只负责“在已知地图上找最优”,局部规划器只负责“在实时感知中避障”。全局规划器不需要实时感知,局部规划器不需要全图搜索。这种分工也是ROS里move_base的默认架构,全局路径规划器用navfn或global_planner插件,局部用base_local_planner,各自维护各自的代价地图。
如果你只是在做一个课设级的“动态避障小车”,最省事的做法是:先用Dijkstra出一条全局路径,然后把这条路径离散成一串路标点,小车用纯追踪(Pure Pursuit)去跟踪路标点,碰到临时障碍物时用激光雷达的数据做VFH(向量场直方图)或者简单地让小车停下来绕行,绕过之后再回到最近的全局路标点继续走。这比实时重跑Dijkstra靠谱得多。
1.3 为什么不用A*
绕不开的问题:既然A*比Dijkstra快,为什么题目是Dijkstra?
我的回答是:A的快建立在启发函数足够好的前提下。启发函数太乐观,A会退化成Dijkstra;启发函数太悲观,A*可能找不到最优解。Dijkstra没有这个问题,它是无信息搜索,准确率是“确定性”的——它找的路径是数学意义上的全局最短。在很多比赛和工程场景里,“最优性”比“实时性”更重要,比如喷漆路径规划、泊车路径规划这种离线计算场景,Dijkstra跑出来的平滑最优路径,后期省掉的平滑处理工作量,远超它多花的那点计算时间。
而且在栅格地图规模不算大的场景里(比如100x100的栅格,一万个节点),Dijkstra的性能其实是完全够用的。我用C++写过一版,在普通笔记本上处理100x100的八邻域地图,找一条路径的耗时在10~30毫秒级别,对轮式机器人来说这个速度完全能接受。只有当你的地图到了千米级(比如1000x1000,一百万个节点),Dijkstra的时间开销才会变得不可忽略,这时候才需要认真考虑A*或JPS。
2. 栅格地图构建细节
2.1 占用栅格地图(Occupancy Grid Map)的基本逻辑
做路径规划的第一步不是写搜索算法,而是把环境变成栅格地图。ROS里最常见的表示方式是OccupancyGrid,每个格子存储一个0到100的整数:
- 0:该格子确定是空闲的,机器人可以走
- 100:该格子确定被占据的,机器人不能走
- -1:未知区域,机器人还没有探测到
为什么不用0和1的布尔值?因为激光雷达的数据有噪声,栅格地图需要表达“不确定性”。一次扫描中如果有5个点落在这个格子里,那这个格子大概率是墙;如果有1个点落进去,那可能是噪声。所以占用栅格地图本质上是一个概率模型,用贝叶斯更新不断修正格子的占据概率。这就是为什么SLAM建图后能用激光雷达“看到”墙背后的轮廓——当然栅格地图不会真的看到墙后的东西,它只是把多次扫描的概率累加起来,让不确定的格子逐渐“收敛”到一个确定状态。
自己写建图程序时,一个省事的方案是把栅格地图存成二维数组,0表示可通行,1表示障碍物。但真正要对接机器人实机时,还是要按OccupancyGrid的格式来,因为代价地图层和导航栈都认这个格式。
2.2 膨胀层:把机器人当成“一个点”太危险
做栅格地图时最容易忽略的一个环节是膨胀。很多初学者直接把激光雷达数据塞进栅格地图,然后拿一个“点机器人”去做路径规划,结果路径贴着墙走,实机一跑就撞。
问题出在栅格地图里没有机器人的体积概念。一个10厘米宽的机器人,在栅格分辨率为5厘米的地图里,至少要占掉2个格子的宽度。路径规划如果只考虑中心点所在的格子,机器人转弯时旋转半径会让车体边缘扫到障碍物。
标准解决方案是给障碍物做膨胀(Inflation):
- 对每个障碍物格子,向外扩展若干个格子,扩展区间内的格子被标记为“危险区域”,禁止路径通过
- 膨胀半径至少等于机器人内切圆半径(机器人在原地旋转时车体扫过的最大半径)
- 更精确的做法:把机器人近似成圆形,膨胀半径 = 机器人半径 + 安全余量
一个从实践中总结的经验:膨胀半径别设得刚刚好,最好在理论值基础上多给1~2个格子的余量。因为Dijkstra规划出的路径只是“理论可通行”,实际跟踪时由于PID控制误差、惯性、地面打滑等因素,车体轨迹会偏离理论路径,多出来的余量就是给这些误差留的缓冲。我见过很多小车撞墙,不是算法不行,而是膨胀半径设小了。
2.3 栅格分辨率的取舍
栅格地图的分辨率直接影响两条:路径的精细度和计算量。分辨率越高(栅格越密),地图越精细,但节点数量呈平方级增长,Dijkstra的搜索时间也会暴涨。
我的经验法则是:栅格分辨率大约是机器人直径的1/4到1/2。比如30厘米宽的机器人,用5~10厘米分辨率的栅格比较合适。分辨率设得比机器人直径还粗,那等于让机器人在地图里“穿墙”了,因为能走的通道宽度都不够车身转弯。
室内场景一般用0.05米(5厘米)分辨率比较多,室外大场景用0.1~0.2米。这是一个典型的“效果与性能的权衡”,没有绝对的标准,需要根据实际场景调试。
3. Dijkstra核心实现与代码详解
3.1 数据结构设计
在栅格地图上实现Dijkstra,核心数据结构有三个:
- 地图矩阵:二维数组,存储每个格子是否可通行
- 代价矩阵:二维数组,存储从起点到每个格子的最短代价(初始化成无穷大)
- 优先队列:C++里的priority_queue,用来高效取出当前代价最小的待处理节点
为什么用优先队列而不是普通队列?因为Dijkstra每一轮都要从“所有未访问节点”中选出代价最小的那个。如果遍历整个地图来找最小值,每次的时间复杂度是O(N),N是节点数,整张图就是O(N^2),在100x100地图里就得算一亿次,太慢了。
优先队列的底层是二叉堆,插入和弹出都是O(logN),整体复杂度降到O(NlogN),地图越大收益越明显。
C++里priority_queue默认是大顶堆,取最大值,所以我们需要自定义比较函数让它变成小顶堆。这是自己手写Dijkstra时最容易踩的坑,我见过很多次有人写完了队列pop出来的节点全是代价最大的,debug半天发现是堆序搞反了。
3.2 完整C++代码实现
下面给一个可以直接跑的版本,四邻域版本,地图用0和1表示:
#include <iostream> #include <vector> #include <queue> #include <limits> #include <algorithm> using namespace std; struct Node { int x, y; int cost; // 优先队列需要的是小顶堆,所以这里要反过来比较 bool operator>(const Node& other) const { return cost > other.cost; } }; vector<pair<int, int>> dijkstra(const vector<vector<int>>& grid, pair<int, int> start, pair<int, int> goal) { int rows = grid.size(); int cols = grid[0].size(); // 代价矩阵,初始化为无穷大 vector<vector<int>> cost(rows, vector<int>(cols, numeric_limits<int>::max())); // 父节点矩阵,用于回溯路径 vector<vector<pair<int, int>>> parent(rows, vector<pair<int, int>>(cols, {-1, -1})); // 访问标记 vector<vector<bool>> visited(rows, vector<bool>(cols, false)); // 四邻域方向 int dx[4] = {-1, 1, 0, 0}; int dy[4] = {0, 0, -1, 1}; priority_queue<Node, vector<Node>, greater<Node>> pq; cost[start.first][start.second] = 0; pq.push({start.first, start.second, 0}); while (!pq.empty()) { Node cur = pq.top(); pq.pop(); if (visited[cur.x][cur.y]) continue; visited[cur.x][cur.y] = true; // 到达终点,提前终止 if (cur.x == goal.first && cur.y == goal.second) break; for (int i = 0; i < 4; i++) { int nx = cur.x + dx[i]; int ny = cur.y + dy[i]; // 边界检查 if (nx < 0 || nx >= rows || ny < 0 || ny >= cols) continue; // 障碍物检查 if (grid[nx][ny] == 1) continue; // 已经访问过就不处理 if (visited[nx][ny]) continue; int new_cost = cur.cost + 1; // 四邻域每步代价为1 if (new_cost < cost[nx][ny]) { cost[nx][ny] = new_cost; parent[nx][ny] = {cur.x, cur.y}; pq.push({nx, ny, new_cost}); } } } // 回溯路径 vector<pair<int, int>> path; if (cost[goal.first][goal.second] == numeric_limits<int>::max()) { return path; // 无路径可走 } pair<int, int> cur = goal; while (!(cur.first == start.first && cur.second == start.second)) { path.push_back(cur); cur = parent[cur.first][cur.second]; } path.push_back(start); reverse(path.begin(), path.end()); return path; } int main() { // 示例地图:0表示可通行,1表示障碍物 vector<vector<int>> grid = { {0, 0, 0, 0, 1, 0, 0, 0}, {0, 1, 1, 0, 1, 0, 1, 0}, {0, 0, 0, 0, 0, 0, 1, 0}, {1, 1, 0, 1, 1, 0, 0, 0}, {0, 0, 0, 0, 0, 0, 1, 0}, {0, 1, 0, 1, 0, 0, 0, 1}, {0, 0, 0, 1, 0, 1, 0, 0}, }; auto path = dijkstra(grid, {0, 0}, {6, 7}); if (path.empty()) { cout << "没有找到路径!" << endl; } else { cout << "路径长度: " << path.size() - 1 << endl; for (auto& p : path) { cout << "(" << p.first << ", " << p.second << ") "; } cout << endl; } return 0; }这段代码核心逻辑就三步:从堆里弹最小代价节点、扩展邻居、更新代价并记录父节点。第17行的operator>重载是唯一需要记住的“魔法”——priority_queue默认按最大元素排前面,重载成>才能让它变成小顶堆。如果你不想重载运算符,也可以使用priority_queue<Node, vector<Node>, function<bool(Node, Node)>>加一个lambda表达式指定比较规则。
3.3 八邻域扩展与对角线代价
上面的代码用的是四邻域,也就是机器人只能上下左右走。如果允许机器人斜着走,路径会更短、更自然,但代价计算要改一下:
// 八邻域方向 int dx[8] = {-1, -1, -1, 0, 0, 1, 1, 1}; int dy[8] = {-1, 0, 1, -1, 1, -1, 0, 1}; // 代价计算:对角线是根号2,直线是1 double step_cost = (dx[i] != 0 && dy[i] != 0) ? 1.414 : 1.0;这时候因为出现了浮点数,代价矩阵和Node里的cost都要从int改成double。还有一个细节:开了八邻域后,机器人会“切墙角”。它不会真的撞上去,因为膨胀层的格子已经被标为障碍物了,但如果膨胀半径不够大,斜穿障碍物角点的路径在实机上可能会擦到障碍物边缘。
所以我的建议是:凡是开了八邻域的规划,膨胀半径最少要再加一个栅格。或者干脆别开八邻域,在四邻域路径上用B样条曲线做平滑,效果会更好。
4. 从仿真到实车的完整实操过程
4.1 仿真环境里的部署方法
如果你是ROS用户,最省事的是用move_base框架,把全局规划器换成自己写的Dijkstra插件。具体操作用不到自己重新发明一轮,只要实现nav_core::BaseGlobalPlanner接口,然后在yaml文件里配置一下:
base_global_planner: my_dijkstra_planner/MyDijkstraPlanner但如果你想彻底搞懂整个流程,我建议还是先从纯仿真开始,不用ROS,用Python的matplotlib可视化。步骤很简单:
- 用openCV或者PIL手绘一张二值地图,黑色是障碍物,白色是空地
- 把它读成numpy数组,0和1的二维矩阵
- 对障碍物做膨胀处理,把障碍物周围n个格子的值也置为1
- 把地图、起点、终点作为Dijkstra输入,计算路径
- 用matplotlib把地图和路径画出来,绿色线是搜索结果
这个流程做一遍,你就能直观感受到Dijkstra的“波纹扩散”过程。在起点周围,路径代价等值线像水波一样从起点向外一圈一圈扩散,直到触及终点。这种可视化对理解算法本质帮助巨大,比盯着代码看一小时都管用。
4.2 实车部署的坑与对策
仿真跑通了不代表实车能跑,下面这些坑我基本都踩过:
算力不足导致路径规划卡顿。树莓派这类低算力平台跑Dijkstra,100x100地图勉强能实时,地图再大就得卡。对策是把大图切块处理,或者只在起点附近的小范围内重规划,远端路径沿用上次的结果。
地图坐标系与机器人坐标系没有对齐。路径规划算出的是一串栅格坐标,实车控制程序必须把它转换到以机器人底盘为原点的局部坐标系。这个转换通常是map -> odom -> base_link的TF链,ROS里一条lookupTransform就能拿到,但很多自研代码没有引入TF,导致路径点在小车坐标系里位置偏了几十个格子,小车冲出去直接撞墙。
全局路径与局部避障的衔接不顺畅。我之前实现过一个方案:Dijkstra规划出全局路径后,每隔0.2米取一个路标点,小车用纯追踪跟踪路标点。如果局部感知发现前方有动态障碍物,小车先使用VFH算法找一个不碰撞的方向绕过去,绕行结束后再回到离当前位置最近的全局路标点继续追踪。实测下来,只要膨胀层设置合理,这套方案在一米每秒的速度下表现还是很稳的。
4.3 搜索结果可视化与调试
调试Dijkstra特别依赖可视化。你光看路径结果很难判断“为什么这里绕了一大圈”,但如果你把代价矩阵画出来,一眼就能看出问题——可能是膨胀层把窄道封死了,或者地图本身的连通性判断有误。
我常用的调试流程是:
- 打印起点、终点在栅格地图中的坐标,确认没有落在障碍物里
- 画出整张代价矩阵的热力图,检查从起点到终点是否存在一条“代价逐渐增大”的连续梯度带
- 画出搜索过程中已经访问过的节点,确认优先队列扩展的确实是“从外到内”的等值线
- 最后再画最终的路径
如果第2步就发现代价梯度在某处断裂,说明地图的连通性有问题,需要检查障碍物标记是否把本应联通的区域切断了。
5. 常见问题与排查技巧
5.1 问题速查表
| 现象 | 可能原因 | 排查与对策 |
|---|---|---|
| 程序跑完但路径为空 | 起点或终点在障碍物里 | 打印检查起点终点的栅格值,做越界与碰撞检查 |
| 路径明显绕远 | 优先队列堆序错误 | 检查operator>或lambda比较器的返回值方向 |
| 路径穿墙 | 膨胀半径不够 | 增大膨胀半径,至少覆盖机器人内切圆半径 |
| 规划速度慢 | 地图分辨率太高或开了八邻域 | 降低分辨率,或者改用A*,或者只在局部重规划 |
| 实车转弯撞墙 | 膨胀层没生效 | 确认规划的costmap和感知用的costmap是否同一张 |
| 路径抖动 | 地图中未知区域被当作可通行区域 | 把未知区域(-1)也当作障碍物对待 |
5.2 性能优化的三个实用手段
如果Dijkstra跑大图真的慢,有三个手段按顺序来:
双向Dijkstra。从起点和终点同时开始搜索,两边各扩展一半,相交时路径就找到了。在栅格地图上实测能减少约40%的搜索节点数量。实现上稍微复杂一点,但原理还是一样的。
多分辨率地图。先在一个低分辨率版本的地图上用Dijkstra粗规划一条走廊,再回到高分辨率地图里,只在走廊范围内精规划。这其实就是“分层规划”思想的简单版,在超大场景(比如整个园区)非常有效。
随时序增量搜索。地图变化不大的时候,上一轮的路径大部分仍然有效。把Dijkstra替换成D* Lite,它可以利用上次的搜索结果增量更新,动态环境下性能能提升一个数量级。
5.3 C++实现里最容易被忽视的两个细节
第一个是无穷大的选择。如果你用INT_MAX作为初始代价,做加法和比较时要特别小心——cur.cost + 1可能会溢出。我的习惯是初始代价设成一个比地图中任意实际路径代价都大的数,比如rows * cols * 2,既不会溢出,也不会影响比较。
第二个是提前终止条件。如果只关心起点到终点的最短路径,那当终点被标记为visited时就可以跳出循环了,不必把整张图都搜完。这个优化在节点多的地图上能省掉一大半时间。但要记住:这个优化有一个前提,你是用priority_queue按代价升序处理的,那“终点首次被访问时就是最优解”这个结论才成立。如果是用普通队列做的BFS式处理,就不能随便提前退出。
最后再分享一个实际体会
我在真实项目中回过头来审视Dijkstra,最大的感悟是:这个算法最困难的部分不是搜索过程,而是它前面的地图处理与膨胀参数调优。很多同学把大量精力花在理解堆、松弛、复杂度分析上,结果代码跑出来的路径因为地图建得不对,一样一塌糊涂。我做项目时的习惯是“三分算法,七分地图”,把地图处理到“可信任”的状态,路径规划器随便写都能走得通。
如果你现在正被A*和RRT的各种变体搞得焦头烂额,不妨回头试试这个最基础的Dijkstra。先把一条路径通过栅格地图稳稳当当地规划出来,再去考虑效率和实时性。这个过程中建立的地图表征能力、代价计算直觉、分层规划思维,会让你后面学任何先进的规划算法都事半功倍。
本文还有配套的精品资源,点击获取