☰
蒙特卡洛树搜索如何破解多机器人区域覆盖路径规划难题
2026/10/31 14:09:18 网站建设 项目流程

简介:这是一份基于 Python 与蒙特卡洛树搜索(MCTS)的多机器人区域覆盖路径规划实现,包含核心算法与可视化展示,适合学习机器人路径规划、多机器人协作或 MCTS 算法的开发者及研究人员参考。资源压缩包共 6 个文件,以 4 个 Python 脚本为主,辅以 LICENSE 与 README 说明,整体大小仅 19KB,结构简洁,便于快速阅读和运行实验。目前已有 1102 人学习/下载,具备一定参考价值。代码中体现了 MCTS 通过随机采样逐步优化路径,在多机器人场景下规划安全高效的覆盖路线,并考虑机器人之间的任务协调与避碰;同时提供地图绘制与结果可视化功能,帮助直观理解搜索过程和最终覆盖效果。对于想快速上手 MCTS 路径规划或需要课程设计/项目雏形的读者,这份资源能提供较完整的 Python 示例和算法流程参考。

1. 蒙特卡洛树搜索算法与多机器人区域覆盖路径规划:为什么这个组合能治我的巡检漏扫病

多机器人区域覆盖路径规划(CPP)在仓储盘点、智能扫地、农业巡检里是个出了名的“听着简单、落地想哭”的问题:给你一张栅格地图、几台机器人,怎么让它们在规定时间内把地图全部扫完、重复率最低、机器人之间还互不打架?我最早尝试用羊群算法和遗传算法做离线求解,直到在一次巡检项目里把蒙特卡洛树搜索算法(MCTS)搬进在线规划模块,才发现这套组合拳比我想象中可靠得多。这篇文章我会用可复现的 Python 代码,把 MCTS 拆开揉碎,落到多机器人区域覆盖场景里,并把结果可视化出来。同时把跑通它最常遇到的几个坑也一并交代清楚,希望能帮你少走两天弯路。

2. 多机器人区域覆盖为什么需要蒙特卡洛树搜索:状态建模与选型对比

2.1 先从栅格地图说起:状态空间和动作空间怎样定义才能让 MCTS 跑起来

MCTS 本质上是个黑盒优化器,它不关心你的机器人底盘是差速还是麦轮,只关心你的状态转移函数是否确定或带概率。标准做法是先把工作空间栅格化:0 表示自由区域,1 表示障碍物,2 表示已被机器人访问过的区域。那么一个状态就是所有栅格子区域覆盖情况的集合,你可以直接用 NumPy 的二维数组来维护,后续做可视化和碰撞检测都会很高效。

动作空间则定义为每台机器人的决策方向。因为机器人之间存在物理约束,多智能体动作空间通常用联合动作描述,也就是每个机器人各自决策一个方向,组合成一个 N 维向量。这里有个细节:多机器人系统的状态空间比单机器人复杂得多,因为机器人之间互相影响,一台机器人移动后,另一台机器人的路径就会被压缩。因此,我们在定义状态的时候,必须把“所有机器人当前各自的坐标”和“全局访问图”一并保存,否则模拟阶段的回报函数会失真。

import numpy as np import math import random from collections import deque class GridMap: def __init__(self, width, height): self.width = width self.height = height # 0=空闲, 1=障碍, 2=已访问 self.grid = np.zeros((height, width), dtype=np.int8) def set_obstacle(self, x, y): self.grid[y, x] = 1 def in_bounds(self, x, y): return 0 <= x < self.width and 0 <= y < self.height def is_free(self, x, y): return self.in_bounds(x, y) and self.grid[y, x] == 0 def mark_visited(self, x, y): if self.in_bounds(x, y) and self.grid[y, x] == 0: self.grid[y, x] = 2

逻辑说明:GridMap类里的is_free方法排除了边界和障碍物,这是整个模拟阶段的命门。如果 MCTS 选择了四个方向上都无路可走的动作,我们要返回一个“完成度低”的奖励,而不是让代码直接抛异常。另外,mark_visited只允许把空闲区域标记为已访问,防止机器人把障碍物也“访问”了一遍。

参数说明:地图的宽和高直接影响搜索空间。常见做法是先用十厘米分辨率栅格化真实环境,比如 20 米乘以 20 米的空间,栅格化成 200x200 的地图。但 MCTS 在这种规模下跑起来极慢,所以工程上一般会先压缩分辨率,或者用占据栅格地图的稀疏表示来加速。这个后面也会提到。

2.2 MCTS 与遗传算法、强化学习的关键差异:为什么在线规划适合覆盖任务

传统上大家一听到多机器人覆盖率优化,就会想到遗传算法(GA)。但遗传算法很难塞进实时的调度系统,因为它每一轮都要重新计算整个群体的适应度,且对环境变化响应很慢。强化学习则需要大量训练数据,并且地图一旦变化,泛化能力往往要打折扣。蒙特卡洛树搜索(MCTS)则是一种在线规划方案,它不需要预训练,只需要一个模拟器和一个评分函数,就能够随着环境状态的变化实时规划出下一步动作。它天然适合“单一 episode 内快速响应”的任务,也适合我们说的动态区域覆盖。

具体到多机器人的区域覆盖路径规划,MCTS 的随机模拟过程能快速探索到人类直觉容易忽略的宏观调动策略。比如当两台机器人距离过近时,MCTS 会通过模拟期的稀疏性奖励,让其中一台主动绕远路去补充覆盖空白区域。这种“绕路”决策在固定策略或者人工设定优先级时很难设定好,但 MCTS 可以通过大量蒙特卡罗模拟来评估长线收益。

2.3 为什么不用栅格地图上的 A* 或贪心算法直接扫

这个问题几乎每次分享都会被问。A* 路径规划是点对点的最短路径算法,它不会去考虑整体覆盖规划;而贪心算法虽然快,但容易陷入局部最优,比如被 U 型障碍物困住,然后在一小块区域里反复扫。MCTS 的优势在于它通过树搜索和回溯机制,能够在模拟阶段看到“被困住”的坏结果,从而在前期就避开这种动作。换句话说,MCTS 比贪心多了一个“眼光”,它看得到几步甚至几十步之后的回报变化。

3. 用 Python 实现多机器人蒙特卡洛树搜索:可复现的核心代码与参数说明

3.1 最小工程骨架:节点定义和地图边界条件

我们先定义一个 MCTS 节点类,保存状态快照、父节点指针、子节点列表以及累计访问次数与累计回报。注意,这里我说的“状态”是一个广义概念,它包含了地图快照和各机器人位置。但是如果你直接从根节点深拷贝整个地图到每个子节点,内存很快就会爆炸。所以常见做法是节点里只保存“动作序列”和“当前评估值”,回溯时再去更新全局地图,而不是把地图整个存在每个节点里。我给的例子为了让你更容易理解,先用轻量级状态来表示。

class MCTSNode: def __init__(self, state, parent=None): self.state = state self.parent = parent self.children = [] self.visits = 0 self.value = 0.0 class MultiRobotMCTS: def __init__(self, grid: GridMap, robot_positions, c_p=1.414, simulate_steps=100): self.grid = grid self.robots = robot_positions self.c_p = c_p self.simulate_steps = simulate_steps self.directions = [(0, 1), (0, -1), (1, 0), (-1, 0), (0, 0)] def _ucb(self, node): if node.visits == 0: return float('inf') # UCB1 公式:平衡利用与探索 exploit = node.value / node.visits explore = self.c_p * math.sqrt(math.log(node.parent.visits) / node.visits) return exploit + explore def select(self, node): while len(node.children) > 0: node = max(node.children, key=self._ucb) return node def expand(self, node): # 对每个机器人尝试 5 个动作,并剪掉非法动作 free_actions = [] for robot_id, pos in enumerate(self.robots): for dx, dy in self.directions: nx, ny = pos[0] + dx, pos[1] + dy if self.grid.is_free(nx, ny) or self.grid.in_bounds(nx, ny): free_actions.append((robot_id, dx, dy)) if not free_actions: return node # 每次扩展只增加一个新动作,控制分支因子 action = random.choice(free_actions) child = MCTSNode(node.state, parent=node) node.children.append(child) return child def simulate(self, node): # 随机模拟,但偏向未访问的邻近区域 total_visited = np.sum(self.grid.grid == 2) for _ in range(self.simulate_steps): moved = False for robot_id, pos in enumerate(self.robots): random.shuffle(self.directions) for dx, dy in self.directions: nx, ny = pos[0] + dx, pos[1] + dy if self.grid.is_free(nx, ny): self.robots[robot_id] = (nx, ny) self.grid.mark_visited(nx, ny) moved = True break if not moved: break covered = np.sum(self.grid.grid == 2) / (np.sum(self.grid.grid >= 0) - np.sum(self.grid.grid == 1)) return covered def backpropagate(self, node, reward): while node is not None: node.visits += 1 node.value += reward node = node.parent

逻辑说明:这里面simulate函数是最关键的部分。我并没有把每个机器人遍历到彻底,而是在每个模拟步里对机器人列表做一次随机动作选择,直到所有机器人周围都是障碍物才终止。这种模拟方式能让覆盖率稳定爬升。covered是当前地图里“已访问格子数”除以“空闲格子数”得到的连续值,作为这一轮模拟的回报。回溯阶段把回报逐层向上累加,后续的select就能依据出一棵“哪条分支更可能得到高覆盖率”的搜索树。

参数说明:c_p我这边经验是 1.414 在黑白棋里很稳,但在多机器人覆盖上建议放大到 10 到 50。因为覆盖回报非常稀疏,如果探索因子太小,树搜索会一直沿着一条已知路径反复利用,导致机器人卡在某个局部区域。simulate_steps是模拟步数,100 步只是起步值,遇到 100x100 大地图时请调大到 1000 以上。

3.2 多机协作的冲突处理与死锁避免:为什么不能直接共享全局网格

我见过很多新手直接把 MCTS 用在全局网格上共享状态,结果就是几台机器人在同一棵树上打架,因为模拟阶段移动 A 机器人的同时,B 机器人的起始位置已经在上一轮被改变了,但树节点的状态没有同步更新。这里我推荐的工程做法是:每台机器人单独维护一棵 MCTS,模拟阶段在一个私有的“模拟环境副本”上并行跑。每次决策期间,机器人之间通过共享内存、Redis 或 socket 同步一次“全局已覆盖图”。这样两个机器人看到的地图是同一个,但它们各自对未来的推演互不干扰。

如果你用 Python 实现并行,最简单的方法是多线程配合 GIL 其实是绕不过去,更好的办法是用 多进程multiprocessing 并给每个子进程传入一份地图副本。覆盖结果通过 Redis 客户端写回主进程,这样会比线程安全得多,也更容易做分布式扩展。在做真机部署时,这个模式可以直接套到 ROS 节点通信上,每个机器人节点跑一个独立的 MCTS 求解器。

3.3 收敛判定与覆盖率统计:什么样的覆盖率才算扫完

在描述“覆盖完成”之前,我们先确定“覆盖率”的量化方法。一般用已覆盖格子数 / 空闲格子总数作为核心指标,但不同的任务权重并不一样:巡检任务会把漏扫率看得比重复覆盖率更重,农业测绘则更关心重叠是否过高。你可以把统计信息封装在CoverageTracker里,让它实时计算总体覆盖率和重复扫描比例。

class CoverageTracker: def __init__(self, grid: GridMap): self.total_free = int(np.sum(grid.grid == 0)) self.revisit_count = 0 def update(self, grid: GridMap): currently_visited = int(np.sum(grid.grid == 2)) coverage_ratio = currently_visited / self.total_free return coverage_ratio def compute_revisit_ratio(self, grid: GridMap, visited_once_mask: np.ndarray): revisit_mask = (grid.grid == 2) & visited_once_mask return int(np.sum(revisit_mask)) / self.total_free if self.total_free > 0 else 0

这里的逻辑说明:update就是算一个简单的比例,而compute_revisit_ratio则需要你额外传入一张visited_once_mask,表示每个格子是否只被访问过一次。如果重复覆盖率太高,说明 MCTS 的奖励设计偏向“原地徘徊”了,需要调大路径不重复的惩罚项。

4. 把覆盖结果可视化出来:Matplotlib 热力图、路径轨迹与动态刷新

4.1 生成静态覆盖热力图:把二维数组变成一眼能看懂的颜色

拿到 grid 里 0、1、2 的二维数组后,我用 Matplotlib 的imshow和cmap做一步转换。具体做法是把 grid 涂成一幅 RGB 彩色图:障碍物用黑灰色,未访问区域用浅蓝色,已访问区域用绿色。这样可以非常直观地看到覆盖效果,有没有漏扫、有没有过度重复扫,一目了然。

import matplotlib.pyplot as plt import matplotlib.patches as mpatches def draw_coverage(grid: GridMap, robots): fig, ax = plt.subplots(figsize=(10, 10)) # 将整数网格映射为 RGB 颜色 display = np.zeros((grid.height, grid.width, 3)) for y in range(grid.height): for x in range(grid.width): if grid.grid[y, x] == 1: display[y, x] = [0.1, 0.1, 0.1] # 障碍 elif grid.grid[y, x] == 2: display[y, x] = [0.2, 0.6, 0.2] # 已覆盖 else: display[y, x] = [0.2, 0.6, 0.9] # 空闲 ax.imshow(display, origin='lower') # 画机器人当前位置 for i, (x, y) in enumerate(robots): ax.plot(x, y, 'r*', markersize=15) # 绘制图例 legend_patches = [ mpatches.Patch(color='#1a1a1a', label='Obstacle'), mpatches.Patch(color='#33aa33', label='Visited'), mpatches.Patch(color='#3399e6', label='Free'), mpatches.Patch(color='red', label='Robot') ] ax.legend(handles=legend_patches, loc='upper right') ax.set_title('Multi-Robot Coverage Path Planning with MCTS') return fig, ax

逻辑说明:这段代码本质是数据可视化里的“伪彩色映射”,只不过我手动为每个格子指定了 RGB 颜色。这样做的好处是你可以任意定义颜色的含义,比如把“重复覆盖率超过 3 次的格子”标记为黄色,从而快速暴露过度重复覆盖的区域。origin='lower'指的是让地图的 y 轴从下往上增长,更符合笛卡尔坐标系直觉。

参数说明:markersize控制机器人星标大小,一般在巡检图上用 15 到 20 就足够醒目。如果你要出论文图,可以用fig.savefig('coverage.png', dpi=300)保存高分辨率图。

4.2 动态过程可视化与 GIF 导出:一张图看清 MCTS 搜索路径推进过程

只看最终覆盖热力图,你很难判断 MCTS 在中间步骤是否走进了死胡同。因此我一般保留每轮模拟后的地图状态,每隔 10 个迭代步保存一帧图像,最后用 Pillow 合成为 GIF,或者直接在笔记本里用FuncAnimation播放。

from PIL import Image import io def export_progress_gif(grid: GridMap, robots, frames): images = [] for frame in frames: fig, ax = draw_coverage(frame['grid'], frame['robots']) buf = io.BytesIO() fig.savefig(buf, format='png') buf.seek(0) images.append(Image.open(buf).copy()) plt.close(fig) images[0].save('mcts_coverage_progress.gif', save_all=True, append_images=images[1:], duration=200, loop=0)

逻辑说明:动态可视化对调试帮助很大,因为一旦发现某个阶段所有机器人都挤在一个角落里,你马上就能知道是模拟策略设置出了问题。duration=200表示每帧显示 200 毫秒,播放效果柔和。如果某些帧出现空白,多半是画布或者缓冲没有正确关闭,记得在保存完图像后调用plt.close(fig)释放内存。

5. 多机器人区域覆盖里蒙特卡洛树搜索的 5 个常见翻车点:现象、原因与解决

5.1 现象:模拟几十轮覆盖率就是上不去,机器人总在障碍物边缘打转

原因:模拟策略太随机,完全没有考虑未访问区域的分布。MCTS 的模拟阶段如果只是纯随机游走,那它把大量采样时间浪费在已经覆盖过的区域和障碍物墙上。

解决:把模拟阶段的纯随机采样改为“偏向未访问邻居”的加权采样。具体可以统计机器人当前位置上下左右四个邻格里,有多少个未访问格,然后给这些方向更高的被选中概率。我刚才代码里的random.shuffle是最低成本的实现,进阶可以改成按权重采样。

def weighted_choice(self, pos): candidates = [] weights = [] for dx, dy in self.directions: nx, ny = pos[0] + dx, pos[1] + dy if self.grid.is_free(nx, ny): candidates.append((dx, dy)) # 简单启发式:未访问过 + 相对开放区域加分 weight = 1.0 if (nx, ny) not in self.visited_points: weight += 5.0 weights.append(weight) if not candidates: return (0, 0) return random.choices(candidates, weights=weights)[0]

5.2 现象:多机器人相互拥挤、团队串行化,明明可以并行覆盖却变成排队扫

原因:这几乎都是因为模拟阶段没有做机器人之间的空间约束惩罚。如果两台机器人靠得太近,它们的下一步动作空间会严重重叠,导致总体覆盖率上升变慢。

解决:在simulate的每一步回报里加入稀疏性奖励。计算两两机器人之间的欧氏距离,如果小于设定阈值(比如 5 个格子),就在回报上扣 0.2 分。这样做会让 MCTS 主动学习保持间距。

5.3 现象:UCB 公式中的平衡参数非常难调,不是太小就是太大

原因:math.log和visits之间的量纲导致惩罚项和奖励项不在一个数量级。奖励如果是覆盖率,也就是 0 到 1 之间,那么 UCB 的探讨项经常会占到 0.2 以上,能够被搜索发现;但如果奖励是“绝对覆盖格子数”,exploit 是几千,explore 如果还用 1 开头,搜索就纯粹走向了利用。

解决:把所有回报都归一化到 0 到 1 的连续区间,并把c_p初始按sqrt(2) * 最大回报估算,最大回报是 1,那么c_p先设 1.414,然后观察覆盖率曲线,若长时间不变则调大到 10。

5.4 现象:地图里存在较长走廊或凹型障碍时,MCTS 规划路线不完整,有漏扫

原因:MCTS 本身并不理解区域单元分解,它看不到“这个长走廊必须从一头走到另一头才能不漏”。在没有强先验的情况下,树搜索很容易在走廊出口附近反复徘徊,无法下定决心冲出去。

解决:在地图预处理阶段,先把栅格地图做连通域分解。每个连通区域单独跑一遍 MCTS,区域之间的转移顺序再用一个简单的旅行商问题模型来规划。这样能大幅减少搜索空间,也顺带解决了漏扫问题。

5.5 现象:内存爆炸,Python 进程跑一会儿就被 OOM kill 掉

原因:有些方案把每个状态都完整深拷贝到节点里。假如你的地图是 200x200,一棵树存 5 万个节点就要存 20 亿个格子,不炸才怪。

解决:节点只保存增量动作和父节点引用,回溯时再重建状态。或者直接按时序滚动更新,每迭代 100 次就释放掉数据结构里不再使用的深分支节点,可以有效控制内存增长。

6. 参数调优与工程化落地:从验证算法到技术改造的最后一公里

如果你想让这套方案跑在真机系统里,我的建议是先把 MCTS 的回报函数拆成三层:覆盖率、平均移动距离、重叠率。覆盖率解决“是否扫全”,平均移动距离解决“是否高效”,重叠率解决“是否做了无用功”。只有权重分配合理,树搜索才能规划出既省时又不漏扫的路线。

另外一个容易忽略的坑是,覆盖率指标不能只看最终结果,要看覆盖率收敛曲线。我踩过最大的坑就是为了让最终覆盖率达到 98%,把simulate_steps拉到几千,结果每次模拟都要两秒钟,MCTS 的迭代次数反而变少,整体规划时间反而更长。后来我改用早停机制:当覆盖率在 50 轮迭代内提升不超过 0.5% 时,就强制结束搜索,接受当前的覆盖率结果。这样可以有效避免后期无用搜索耗能。

在真机部署时,你不需要真的在每个机器人上跑 Python 脚本,常见的做法是把这个算法跑在 IPC 工控机里,通过 ROS 的move_base接口把 MCTS 给出来的路径点发送给底盘。如果追求更实时,也可以把地图分辨率降低到 0.5 米每格,这样单次规划时间能压到 200 毫秒以内。

我自己的习惯是先跑一个小地图快速观察覆盖率曲线的上升斜率,斜率变平就调大c_p或者更换模拟策略的权重。这套逻辑我用了很长时间,稳定度相当不错。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询