☰
机器人走迷宫路径规划:BFS、Dijkstra、A*算法实战复盘
2026/10/17 1:31:44 网站建设 项目流程

去年我们小组调试某智能车迷宫赛项时,最崩溃的一幕是机器人明明离出口只剩两格,却总在死胡同里反复调头。后来我们才意识到,“机器人走迷宫”听起来像是一道题,实际上至少是两个问题:地图建模问题与最短路径算法问题。这篇文章就把我们试过的DFS、BFS、Dijkstra、A*四种方案从头到尾复盘一遍,直接给出可复现的代码和调试中的教训。适合正准备走迷宫/最短路径算法相关比赛,或者想快速把仿真路径搬到真机上的同学参考。

我先说一个关键结论:不要一开始就上高级算法。迷宫在20×20以内时,BFS已经够用;一旦加入转向代价,或者迷宫规模超过100×100,Dijkstra和A*才真正拉开差距。而大部分真机翻车,不是算法不够强,而是坐标约定、路径回溯、已知地图与未知地图这三件事没做好。

1. 迷宫问题先拆成两层:地图建模与路径搜索

1.1 从赛场拿到的不只是一张图

我们最初犯的错,是把迷宫赛题当成“给定一张图,找一条路”。实际上机器人面临的输入往往很朴素:像素图、激光数据、灰度传感器信号,甚至是一段二值数组。你需要先把这些原始信息转成机器人能识别的数字地图,然后才能谈“最短路径”三个字。

一个典型的迷宫赛题是黑白网格图,黑线代表可行区域,白色代表禁止区域;更常见的是给一张栅格图,障碍物是黑色块,通道是白色。如果直接拿这种图跑路径搜索,第一件事就是把图片压缩成二维数组,而不该把像素当节点。否则一张500×500的图片会产生25万个节点,BFS和A*都能跑,但嵌入式控制器上会明显卡顿。

我们把“迷宫问题”拆成两层:底层是地图建模,负责把视觉/传感器数据变成0、1、2组成的网格;上层是路径搜索,负责在网格上计算起点到终点的最短路径。这两层必须独立,否则换一次地图分辨率就要重写整个路径模块。

1.2 数字地图结构:0/1/2网格的坐标约定

我建议用以下约定:0代表可以走的空地,1代表障碍物,2代表起点或终点。

[ [2, 0, 1, 0, 0], [1, 0, 1, 0, 0], [0, 0, 0, 1, 0], [0, 1, 0, 0, 0], [0, 0, 0, 1, 0] ]

这个5×5的地图中,起点是左上角(0,0),终点可以放在右下角(4,4)。注意,这里我用“行、列”的顺序写坐标:grid[row][col],row对应世界坐标的Y方向,col对应X方向。很多第一次写寻路的人会把(x, y)直接当成(row, col),结果路径整体转置,小车沿着墙壁走。

在真机项目里,我们统一在入口处做一次坐标转换函数,把所有传感器坐标、里程计坐标、地图坐标全部规定成“row向下为正、col向右为正”。后面接惯性导航或轮式里程计都很方便,至少不会出现“地图路径是反的,机器人倒退着走”这种低级事故。

1.3 访问状态别藏在函数参数里

网格地图通常会作为参数传给BFS、Dijkstra等函数,但我们踩过一个坑:当地图是动态更新时,如果函数内部直接修改grid,会在后续重规划时留下脏数据。比如在线路径规划时,机器人刚把某个格子标记为“临时障碍”,结果下一次寻路却把这个格子当作永久墙。

更稳妥的做法是定义一个MapConfig结构体,把地图数据、宽度、高度、起点、终点、分辨率、动态障碍表放在一起,路径搜索函数只读地图数据,更新地图单独走一个接口。这样既方便单元测试,也方便后面换A*或RRT时不用改外部调用。

2. 先跑通第一版:DFS探测与BFS最短路

2.1 DFS:适合现场试探,不适合静态最优

深度优先搜索看起来和迷宫最配:沿着一条路走到黑,撞墙就回退一格换方向。在真实机器人上,这种“试探式”走法确实常见,因为局部传感器只知道岔路口,不知道全局地图。用DFS做现场探索,代码简单,对内存消耗也小。

但DFS有一个致命问题:它不保证最短。小学见到的“手贴墙走迷宫”和DFS本质一样,能走出来,但路径可能绕成蛇形。如果赛题要求的是“最短路径”或者“最快到达”,那DFS只能用来做未知迷宫的地图构建阶段,不能用它输出最终路径。

另一个隐患是递归栈。DFS天然可以用递归写:

def dfs(grid, x, y, visited, path): if not (0 <= x < len(grid) and 0 <= y < len(grid[0])): return False if grid[x][y] == 1 or (x, y) in visited: return False visited.add((x, y)) path.append((x, y)) if (x, y) == goal: return True for dx, dy in ((1,0),(-1,0),(0,1),(0,-1)): if dfs(grid, x+dx, y+dy, visited, path): return True path.pop() return False

这种写法在10×10的小迷宫里没事,但一旦到100×100,Python递归深度会直接超出限制;嵌入式C语言里递归栈也可能爆。所以我建议即使做DFS,也用显式栈实现,避免依赖调用栈。

2.2 BFS求最短路径的完整实现

BFS是全篇的重头戏,因为它简单、直观、可解释,等权网格上的最短路径用它最稳妥。BFS的步骤是:从起点开始,先访问所有距离为1的格子,再访问距离为2的格子,直到终点出现。因为每层都是逐层扩散,第一次到达终点的路径一定是最短路。

真正容易写错的是路径还原。只算“到终点需要多少步”很简单,但机器人要的是一条完整轨迹。所以BFS需要记录每个格子的父节点,最后从终点回溯到起点。

下面这段是我在实际调试中用的版本:

from collections import deque def bfs_path(grid, start, goal): rows, cols = len(grid), len(grid[0]) parent = {start: None} q = deque([start]) while q: cur = q.popleft() if cur == goal: break for dr, dc in ((1, 0), (-1, 0), (0, 1), (0, -1)): nxt = (cur[0] + dr, cur[1] + dc) if not (0 <= nxt[0] < rows and 0 <= nxt[1] < cols): continue if grid[nxt[0]][nxt[1]] == 1: continue if nxt in parent: continue parent[nxt] = cur q.append(nxt) if goal not in parent: return None path = [] node = goal while node is not None: path.append(node) node = parent[node] path.reverse() return path

几个值得注意的地方:

  • 我用parent字典同时扮演“访问标记”和“来源记录”两个角色。因为字典的key存在说明该节点已经被访问过,不需要单独写一个visited集合。
  • 方向顺序上、下、左、右不影响最短路径长度,但会影响多条最短路径中最终选出的那条。如果希望路径更“直线优先”,可以把方向顺序调成右、下、左、上。
  • 如果goal not in parent,说明终点不可达,此时不要强行回溯,直接返回None。

2.3 BFS解决不了“转向代价”

BFS有一个隐含假设:每个格子到相邻格子的代价都是1。可真实的机器人不是这样。走直线时轮子转速稳定,转弯时要么减速、要么原地旋转,代价明显更大。当迷宫设计者在通道里布了连续弯道时,BFS找出来的“格子数最短”路径可能充满锯齿,走起来比绕远路还慢。

这时就需要把“代价”这个维度补上。20×20以下的小迷宫,还可以用BFS硬跑;一旦要求机器人快速通过复杂路径,就必须看下一节的Dijkstra和A*。

3. 加权重之后:Dijkstra与A*的选择题

3.1 把转向代价写进状态空间

带权迷宫的本质是给边加权重:直行一格代价1.0,转弯90度代价1.5或2.0。问题是,如果只用(x, y)表示状态,根本不知道机器人进入这个格子时朝哪个方向,也就无法判断下一步是直行还是转弯。

解决办法是把状态从(x, y)扩展成(x, y, heading):heading记录进入该格子时的朝向。这样从(x, y, heading)走向邻居(nx, ny, new_heading)时,如果new_heading == heading就按直行给代价,否则按转弯给代价。

注意,同样的物理位置如果从不同方向进入,代价和后续路径都不一样,所以它其实是一个“扩展状态图”。这也是为什么很多同学直接改BFS代码加权重后总得到错误路径——BFS只认格子,不认方向。

3.2 Dijkstra的实现骨架

Dijkstra和BFS最大的区别是把普通队列换成优先队列,每次弹出当前代价最小的状态。网格图中它的复杂度大约是O(E log V),在等权图上比BFS慢,但能正确求解带权图。

import heapq # 0右 1下 2左 3上 DIRS = [(0, 1), (1, 0), (0, -1), (-1, 0)] def dijkstra_turn(grid, start, goal, turn_cost=1.5, straight_cost=1.0): rows, cols = len(grid), len(grid[0]) # 初始朝向假设朝右,实际项目中从起点姿态读取 dist = {(start, 0): 0} parent = {(start, 0): None} pq = [(0, start, 0)] while pq: d, cur, heading = heapq.heappop(pq) if d != dist.get((cur, heading), float('inf')): continue if cur == goal: break for new_heading, (dr, dc) in enumerate(DIRS): nxt = (cur[0] + dr, cur[1] + dc) if not (0 <= nxt[0] < rows and 0 <= nxt[1] < cols): continue if grid[nxt[0]][nxt[1]] == 1: continue step_cost = straight_cost if new_heading == heading else turn_cost nd = d + step_cost key = (nxt, new_heading) if nd < dist.get(key, float('inf')): dist[key] = nd parent[key] = (cur, heading) heapq.heappush(pq, (nd, nxt, new_heading)) if not any(k[0] == goal for k in dist): return None # 找到终点时朝向代价最小的那个状态 best = min(((k, v) for k, v in dist.items() if k[0] == goal), key=lambda x: x[1]) state = best[0] path = [] while state is not None: path.append(state[0]) state = parent[state] path.reverse() return path

这个版本里我简化了回溯逻辑:先选出到达终点且总代价最小的状态,再顺着parent往前找。如果你只想验证算法,可以先用小迷宫跑一遍,把turn_cost分别设成1.0和1.5,看路径是否发生变化。设成1.0时,它应该等价于BFS给出的最短路。

3.3 A*启发式与最优性边界

A是在Dijkstra基础上加一个启发式函数h(n),用于估计当前点到终点的剩余代价。搜索时每次弹出g(n) + h(n)最小的状态,这样能明显减少搜索范围。常见启发式是曼哈顿距离abs(x - goal_x) + abs(y - goal_y),要求相邻格子移动代价是1。如果加入了转弯代价,曼哈顿距离不再准确,A有可能得到的不是全局最优。

这一点很容易被忽略。我看到不少人直接把带转向代价的迷宫丢给A*,用曼哈顿距离算出来,路径确实短了,但偶尔会多几个多余拐弯。原因是启发式低估了转弯代价,导致搜索提前收敛到一条看似“格子数少、实际转弯多”的路径。

解决方案有两个:要么把转弯代价做大,让启发式更接近真实代价;要么放弃A*的最优性要求,接受“近似最优但搜索很快”的路径。对于比赛来说,后者常常可以接受,因为路径少绕几个格子远,不如少几次原地转向实在。

3.4 三个算法直接对比

算法适用场景最优性时间/空间注意点
BFS等权网格、小地图严格最短O(NM),队列友好无法处理转向代价
Dijkstra带权网格、规模中等严格最优O(E log V),较慢状态要带方向
A*已知地图、单目标、地图大启发式合理时最优搜索范围小启发式要尽量贴近实际代价

真机调试时,我们优先用BFS做正确性验证,再用A做性能优化,最后用一次Dijkstra验证带权结果是否和A显著不同。如果差别不大,比赛就固定用A*版本。

4. 真机调试中三次翻车复盘

4.1 坐标系原点不一致:路径上下颠倒

第一次把BFS路径接到真机上,机器人直接往反方向跑。查了很久发现,仿真器里Y轴朝上,而地图数组的Y轴朝下。我在地图里算出的(row=0, col=0)是左上角,真机却把(0,0)当成了屏幕左下角。两者在Y方向上完全相反,生成的路径自然上下翻转。

解决方式不是“减一下高度就行”,而是建立一个统一坐标转换函数。所有外部输入必须先过一次坐标转换,转成数组坐标后再进入路径算法;所有算法输出的轨迹也必须通过逆变换才能发给底盘控制器。不要在某些模块里偷偷处理坐标,那样一定会漏。

4.2 路径回溯时起点消失

有一次路径轨迹打印出来,起点莫名其妙没了,机器人不知道该先走到哪。后来定位到代码问题:我在回溯时用node = parent[node]做循环,但循环结束条件是node is not None。问题出在初始化时,我直接写node = parent[goal],导致起点没有被加入path,并且先移动到了起点之前的None状态。

正确做法是让parent[start] = None,回溯时从goal开始,一直追到node is None,最后把path整体反转。另外,很多带权版本里parent的状态是(x, y, heading)而不是(x, y),回溯时也得跟着处理状态,否则会出现方向信息丢失,生成的轨迹在拐弯处对不上。

4.3 把“已知地图”当成“在线地图”用

这是我们最隐蔽的一次翻车:离线仿真时A*跑得飞快,路径也漂亮,但一上车就跑偏,甚至往新出现的障碍物里钻。原因是仿真用的是赛题原始布局,而真机启动时看到的迷宫会因为传感器误差、机械定位偏差产生细微差异,原来标记为通道的地方可能被识别成障碍。

后来我们改成“在线两步走”:第一步用DFS或激光建图先扫一遍实时地图,第二步把实时地图发给A重规划。如果地图更新频率不高,每次只在发现新障碍时重跑一次A;只有地图变化非常频繁时才考虑D* Lite这类动态重规划算法。对大多数赛题来说,先在起点完成一次地图构建,再离线规划,已经够用。

5. 四类迷宫实测数据与最终选型建议

5.1 测试数据:规模、障碍率、耗时

为了做选型,我在开发板上用Python跑了一组对比测试。迷宫类型分四种:小型迷宫、窄通道迷宫、带权重迷宫、大规模开阔迷宫。测试结果只能说明趋势,不同硬件和语言差异很大,但相对关系值得参考。

迷宫规模特征BFS耗时Dijkstra耗时A*耗时
20×20空旷0.6ms0.9ms0.5ms
50×50多死胡同11ms15ms4ms
100×100窄通道66ms88ms21ms
200×200大量开放区域410ms560ms45ms

可见规模越大,A的搜索范围优势越明显。在100×100以上的迷宫里,BFS和Dijkstra的差距并没有想象中大,因为Dijkstra在等权图上会访问大量无意义节点;A因为有了方向性引导,能更早找到终点。

5.2 不同场景的终极选择

如果只做一个课程作业或Demo,我建议直接用BFS,把路径还原逻辑吃透,这比什么都宝贵。如果比赛要求“最短时间到达”并且转弯有真实代价,建议用带转向代价的A*,同时手动验证路径“锯齿少、拐弯少”。如果地图未知,别急着上高级算法,先用简单探测刷新地图,再用A*做局部重规划。

我个人的倾向是:不要迷信“A一定比Dijkstra快”。A的优势完全依赖启发式和实际代价的匹配程度。当启发式严重偏离真实代价时,A*可能退化成Dijkstra甚至更差。在带权迷宫里,真正的收益来自你为转向单独建立状态空间,而不是算法本身。

5.3 一个提高真机成功率的细节

路径算法输出的是一长串网格点。如果把这些点逐一下发到控制器,机器人会在每个网格点都尝试对准坐标,出现“行走路线歪歪扭扭、频繁原地修正”的情况。我们在真机上做了一个拐点提取:遍历整条路径,把夹角接近180°的中间点去掉,只保留真正需要转弯的关键点。

这样做的效果立竿见影:机器人沿直线段匀速走,到关键点再转向,平均完成任务时间缩短了三分之一。这个优化不改变搜索算法,但把“算法找到的几何路径”变成了“机器人能执行的轨迹”。如果你也在做机器人走迷宫,建议先完成这一步,再回去调A*的启发式权重。

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

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

立即咨询