1. 从路径规划聊起:为什么这道迷宫题我最终选了Q-learning
我讲个实际场景吧。之前接手一个教学演示项目,需求是用代码让一个智能体在随机生成的方形迷宫里,从起点自己走到终点。第一反应肯定是走最短路算法:BFS广度优先搜索、DFS深度优先搜索、A* 都可以,逻辑简单,半小时能在Matlab里写完。但问题是,题目里明确点出了“Q-learning算法”和“ε-greedy策略”,也就是说这道题考察的不是“怎么找路”,而是“怎么让智能体在未知环境里通过试错学会找路”。这正是强化学习要做的事。
我不是说BFS和A不好。如果你手里有完整的迷宫地图、环境是静态的、要求最短路径、计算资源还有限,A 永远是最优选择。可一旦环境变成随机生成的、带有一定不确定性、甚至以后可能动态变化的地图,传统路径规划算法就必须重新规划一次,而Q-learning学到的是“状态-动作”的长期价值,环境变化后还能继续微调,这个特性在很多真实场景里比“一次求最优”更值钱。项目背后的应用方向也很明确:机器人未知环境探索、游戏里NPC自动寻路、仓库AGV调度,都属于这一类。
上面这些铺垫,是想先说清楚一个关键点:选Q-learning不是因为它在走迷宫这道题上跑得比A*快,而是因为它代表了另一条思路——不需要全局地图、不需要人工写规则,靠智能体跟环境不断交互,自己把策略“长”出来。 理解这个定位之后,你再看Matlab里那一堆矩阵和循环,就不会觉得它是在绕远路了。
这篇博文我会完整记录这个项目的实现过程:迷宫怎么随机生成且保证一定有解、Q表怎么初始化、ε-greedy怎么选动作、Q值更新公式怎么落到Matlab矩阵运算上、奖励函数怎么设计、参数怎么调、训练结果怎么评价。代码基本可以直接复制跑通,参数也做了注释。全程用实际测试中踩到的坑来说话,而不是只贴一个能work的版本。
适合什么人看?一是刚接触强化学习、想把Q-learning从公式变成代码的同学;二是做课程设计、实验报告、项目答辩时需要一套完整演示代码的开发者;三是已经在跑Q-learning但训练不收敛、或者想搞懂学习率、折扣因子、ε衰减怎么配合的人。如果你已经能熟练手写A*,那这篇帮你补的,是“从有监督思维切换到试错学习思维”的那一块。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 先把机制捋清楚:Q-learning和ε-greedy在迷宫里到底在算什么
2.1 给迷宫定义一个Agent“看得懂”的世界模型
Q-learning属于model-free的强化学习算法,这句术语听上去很玄,翻译成人话就是:智能体不需要提前知道地图全貌,它只要知道自己现在站在哪个格子、能往哪几个方向走、走完之后拿到什么反馈,就够了。 所以在Matlab实现里,第一件事是给环境建模。
在这道题里,环境模型可以拆成四个要素:
- 状态空间S:迷宫中所有可达格子的位置。我们用N×N的矩阵表示迷宫,每个格子是一个状态。编码时通常把二维坐标展平成一维索引,方便后面直接查Q表。
- 动作空间A:每一步可选的动作,定义为四个方向:上、下、左、右。每个动作对应坐标的变化,比如向上就是行号减1、列号不变。
- 状态转移:执行动作后智能体到达的新位置。这里有一个必须处理的边界情况:如果动作会导致越界或者撞墙,智能体应该原地不动,同时获得一个负奖励。
- 奖励函数R:执行完动作后,环境给出的标量反馈。我用的设计是:到达终点给+10;撞墙给-0.5;普通走一步给-0.1。后面会详细解释为什么这么配。
2.2 Q表到底是什么?它是智能体脑中一张“动作性价比对照表”
Q表的行是状态,列是动作。表中每个元素Q(s,a)表示“在状态s下选择动作a,之后一直按照当前最优策略走,最终能获得的折扣累积奖励的估计”。这个定义用数学写出来就是经典的Bellman更新:
code复制Q(s,a) = Q(s,a) + α * [r + γ * max(Q(s',a')) - Q(s,a)]
拆开来看:括号里的r + γ * max(Q(s',a'))是“这次实际拿到的即时奖励 + 对未来最优价值的估计”,叫TD目标;减去当前Q(s,a),得到TD误差;乘上学习率α,意思是新信息只更新这么多比重;最后加回旧值。整个过程不需要任何监督标签,信号来源就是环境给的奖励r。
举个具体的数值例子。假设智能体站在迷宫中间某个格子上,往上走的Q值是2.8,它按ε-greedy选了往上走,走进一个普通通道格子,拿到奖励-0.1。此时它发现新格子四个方向里,最优Q值(假设往右)是3.5。再假设α=0.1,γ=0.9,那么旧Q值2.8的更新就是:
code复制Q(s, 上) = 2.8 + 0.1 * (-0.1 + 0.9 * 3.5 - 2.8)
= 2.8 + 0.1 * 0.25
= 2.805
虽然只涨了0.005,但日积月累,终点附近的格子会先学到高价值,然后价值一层层“往回传”,最终起点附近的Q表里也会出现清晰的方向偏好。这是Q-learning最漂亮的地方:奖励不需要立刻出现在眼前,它可以通过不断回溯让早期状态也获得正确的价值排序。
2.3 ε-greedy到底在调什么?探索与利用是一对天然矛盾
如果每一步都直接选Q表中最大的动作,这叫纯贪心,问题在于训练初期Q表全是0,智能体一直按同一个方向走,可能永远探索不到终点;如果每一步都随机走,那训练再多也无法沉淀出稳定路径。ε-greedy就是用参数ε在“随机探索”和“贪心利用”之间做开关: 生成一个0到1的随机数,如果小于ε,就随机选一个动作;否则就选当前Q值最大的动作。
项目里我把初始ε设成0.5,衰减系数0.995,最小0.05。也就是说,最初有一半概率乱走,随着训练推进乱走概率逐步降低,最终保留5%的探索率。这5%不是为了“找更好路径”,而是防止路径因为地形原因卡死时,智能体还有一丝机会跳出来。衰减速度很关键:衰减太快会过早收敛到次优策略;衰减太慢则训练后期一直乱跑,收敛不稳定。 这部分实测感受,我会在第五章专门展开。
3. 迷宫从哪儿来:随机生成一张“一定走得通”的地图
3.1 用矩阵说话:0和1就是迷宫的骨架
Matlab里表示迷宫最直接的方法是二维矩阵:0表示可行走格,1表示墙。起点固定为(1,1),终点固定为(N,N)。生成随机地图的核心逻辑很简单:矩阵先全0,然后按密度参数density随机把某些格子置为1。真正麻烦的不是“随机”,而是随机出来的地图可能根本不连通——起点和终点之间被墙隔开,Agent无论如何都到不了终点。
这个细节特别容易被忽略。我第一次跑的时候就是因为没检查连通性,训练了几千回合成功率一直是0,当时差点以为是Q-learning代码写错了,排查了大半天才发现是地图本身有问题。
解决方法是生成地图后立即做一次BFS广度优先搜索连通性检查。BFS从起点出发,沿着四个方向在0格子上扩散,如果最终能到达终点,就认为地图有效;到不了就重新生成。虽然“随机+重试”不是最高效的迷宫生成策略——真要追求效率可以用递归回溯或Prim算法——但在N≤12的小规模方形迷宫里,重试次数很少,代码却简单得多,也更适合演示。
3.2 BFS连通性检查的Matlab实现
matlab复制function connected = isPathConnected(maze, startPos, goalPos)
% maze: 0表示可行走,1表示墙
[rows, cols] = size(maze);
visited = false(rows, cols);
queue = startPos;
connected = false;
while ~isempty(queue)
pos = queue(1, :);
queue(1, :) = [];
r = pos(1);
c = pos(2);
if r == goalPos(1) && c == goalPos(2)
connected = true;
return;
end
if visited(r, c)
continue;
end
visited(r, c) = true;
% 四个方向扩展
offsets = [-1 0; 1 0; 0 -1; 0 1];
for d = 1:4
nr = r + offsets(d, 1);
nc = c + offsets(d, 2);
if nr >= 1 && nr <= rows && nc >= 1 && nc <= cols && ...
maze(nr, nc) == 0 && ~visited(nr, nc)
queue(end+1, :) = [nr, nc]; %#ok<AGROW>
end
end
end
end
这里用队列做BFS,visited矩阵防止重复访问。其它细节都可以简化,但这个检查不能省。不仅训练前要用,后面做多迷宫批量测试时,每一张随机迷宫都必须过这一关。 否则实验结果里的失败回合,你可能分不清是算法没学会,还是地图本身无解。
3.3 把迷宫画出来:imagesc一行就能看到全貌
Matlab里可视化迷宫非常简单,imagesc按颜色映射矩阵值,1显示为深色(墙),0显示为浅色(空地),再加colormap设置和axis equal、axis off,就能得到一张干净的地图。可以在图上叠加起点和终点的标注:
matlab复制figure;
imagesc(maze);
colormap(gray);
axis equal;
axis off;
hold on;
plot(1, 1, 'go', 'MarkerSize', 12, 'MarkerFaceColor', 'g'); % 起点
plot(cols, rows, 'ro', 'MarkerSize', 12, 'MarkerFaceColor', 'r'); % 终点
现在地图有了,Agent也有了,可以进入训练主循环了。
4. 训练主循环:Q表更新、ε衰减和奖励塑形的完整代码
4.1 先确定超参数,再动手写循环
训练之前,得先把参数定下来。我在这个项目里使用的默认参数整理成了一张表,后面调参时也主要动这几个数。
| 参数 | 取值 | 一句话解释 |
|---|---|---|
| 学习率α | 0.1 | 新信息覆盖旧信息的比例 |
| 折扣因子γ | 0.9 | 对远期奖励的重视程度,越大越有远见 |
| 初始ε | 0.5 | 训练初期探索概率 |
| 最小ε | 0.05 | 训练后期最低探索概率 |
| ε衰减系数 | 0.995 | 每回合乘以这个数,指数衰减 |
| 回合数episodes | 4000 | 最多训练多少轮 |
| 单回合最大步数 | NN2 | 防止Agent在死路里无限循环 |
4.2 关键函数:Agent走一步,环境怎么给反馈
状态转移和奖励逻辑我封装在stepMaze里,它接收当前状态、动作,返回下一状态、即时奖励、是否结束。核心点在于撞墙:撞墙不允许移动,只给负奖励,而不是把Agent直接踢出去或结束回合。 这样设计能让Q表把“这个方向是墙”和“这个方向能走但绕远”区分开:前者拿的是撞墙惩罚,后者拿的是普通步数惩罚,两种信号不能混在一起。
matlab复制function [nextState, reward, done] = stepMaze(maze, state, action, goalPos, R_goal, R_step, R_wall)
[rows, cols] = size(maze);
r = state(1);
c = state(2);
% 动作映射:1上,2下,3左,4右
offsets = [-1 0; 1 0; 0 -1; 0 1];
nr = r + offsets(action, 1);
nc = c + offsets(action, 2);
% 越界或撞墙:原地不动,给负奖励
if nr < 1 || nr > rows || nc < 1 || nc > cols || maze(nr, nc) == 1
nextState = [r, c];
reward = R_wall;
done = false;
return;
end
nextState = [nr, nc];
if nr == goalPos(1) && nc == goalPos(2)
reward = R_goal;
done = true;
else
reward = R_step;
done = false;
end
end
4.3 完整训练主循环
训练主循环的思路是:初始化全0的Q表 → 每个回合从起点出发 → 每一步用ε-greedy选动作 → 执行动作拿奖励 → 用Q值更新公式更新Q表 → 判断是否到达终点 → 回合结束衰减ε。用代码写出来是这个样子:
matlab复制function result = trainQLearning(maze, params)
[rows, cols] = size(maze);
numStates = rows * cols;
numActions = 4;
Q = zeros(numStates, numActions);
startPos = [1, 1];
goalPos = [rows, cols];
alpha = params.alpha;
gamma = params.gamma;
epsilon = params.epsilonInit;
epsilonMin = params.epsilonMin;
epsilonDecay = params.epsilonDecay;
maxSteps = params.maxSteps;
% 记录每个回合的结果
stepHistory = zeros(params.episodes, 1);
rewardHistory = zeros(params.episodes, 1);
successHistory = false(params.episodes, 1);
for episode = 1:params.episodes
state = startPos;
totalReward = 0;
stepCount = 0;
done = false;
while ~done && stepCount < maxSteps
sIdx = sub2ind([rows, cols], state(1), state(2));
% ε-greedy选择动作
if rand < epsilon
action = randi(numActions);
else
[~, action] = max(Q(sIdx, :));
end
[nextState, reward, done] = stepMaze(maze, state, action, goalPos, ...
params.R_goal, params.R_step, params.R_wall);
% Q值更新:Q(s,a) = Q(s,a) + α * [r + γ * max(Q(s',a')) - Q(s,a)]
nsIdx = sub2ind([rows, cols], nextState(1), nextState(2));
Q(sIdx, action) = Q(sIdx, action) + alpha * ...
(reward + gamma * max(Q(nsIdx, :)) - Q(sIdx, action));
state = nextState;
totalReward = totalReward + reward;
stepCount = stepCount + 1;
end
stepHistory(episode) = stepCount;
rewardHistory(episode) = totalReward;
successHistory(episode) = done;
epsilon = max(epsilonMin, epsilon * epsilonDecay);
end
result.Q = Q;
result.stepHistory = stepHistory;
result.rewardHistory = rewardHistory;
result.successHistory = successHistory;
result.epsilon = epsilon;
end
有两处容易写错,提前提醒一下。第一,更新Q值时用的nextState必须是环境实际返回的状态,如果撞墙了,nextState跟当前state相同,max(Q(nsIdx, :))就是当前状态的最大Q值,逻辑没问题;但如果你误用了“理想中的新位置”,撞墙时就会学到错误信息,把墙的负反馈错误地传播到相邻状态。第二,sub2ind每次调用都要做矩阵尺寸解析,N为10时损耗不大,但如果后面换大迷宫或者做批量实验,建议预先算好坐标到索引的映射表,省掉重复调用。
4.4 奖励函数为什么必须这样配?
设计奖励有三个原则,都是实际测试总结出来的:
- 到达目标的正奖励要大。 R_goal=+10,目的只有一个:让“接近目标”这个信号在Q表传播中足够醒目。如果正奖励太小(比如+1),很可能被大量负步数惩罚淹没,Agent会觉得“反正去哪都是亏,不如瞎逛”。
- 普通步数惩罚要小但要有。 R_step=-0.1,目的是鼓励Agent走最短路径,避免在迷宫边缘绕圈。很多初版实现把普通步设置成0,结果Agent为了拿正奖励反复横跳,路径又长又难看。有了负惩罚,“多走一步”就等同于“损失一点未来收益”,路径自然向短的方向收敛。
- 撞墙惩罚要介于两者之间。 R_wall=-0.5,此时Agent原地不动、回合不结束,它可以反复尝试同一个格子,慢慢学会“墙不能走”,而不是被直接赶走。这个设计让Q表能区分“不可走”和“可走但没价值”,比把撞墙设成-10那种严厉惩罚更稳。
5. 实测调参记录:学习率、折扣因子和衰减速度是这样相互制约的
5.1 学习率α:不是越大学得越快
第一个坑就是学习率。我一开始图省事把α设成0.5,想着学得快,结果Q值震荡得非常厉害,前几百回合的训练曲线像心电图一样上下乱跳。原因是Q-learning每次更新只基于一次采样,方差本来就大,α越大,单次采样里的偶然性被放得越重。
把α降回0.1之后,曲线平稳很多,但我又发现一个问题:收敛变慢了。换到更大的12×12迷宫时,4000回合不够用,最后阶段成功率还有明显波动。这是典型的权衡场景:小规模迷宫用α=0.1够用,迷宫变大后要么加回合数,要么把α提到0.2~0.3并配合更大的探索率。
5.2 折扣因子γ:决定“远见”的同时也决定“搜索半径”
γ=0.9是我测试后觉得最稳的默认值。把它调大(比如0.99)意味着Agent非常看重远期奖励,理论上能得到更全局最优的路径,但迷宫这种局部死路很多的环境里,高γ会让Q值传播得更慢,因为“远处的终点价值”要经过很多步才能“传导”到起点附近。调小γ(比如0.7)收敛倒是快了,但Agent会变得目光短浅——只要眼前几步能拿到正奖励就满意,最终得到的路径可能不是最短的。
想直观观察这个参数的作用,最省事的实验是:把γ设成0.5和0.99各跑一遍500回合,对比最终提取出来的策略。γ=0.5的策略大概率在终点附近绕近路,γ=0.99的策略更倾向走全局长路。实际项目里我通常先把γ定在0.9,如果迷宫很大再加到0.95并增加训练量。
5.3 ε衰减:衰减太快等于把探索期权提前卖掉
还有一个隐蔽的坑:ε的衰减系数直接决定“探索预算”的消耗速度。0.995看起来挺慢了,但它是指数衰减,500回合后ε已经降到0.5×0.995^500 ≈ 0.041,也就是训练到第500回合基本只剩5%的探索。对小迷宫够用,对稍大的迷宫,这个探索预算远远不够。
我在11×11的迷宫上吃过亏,当时用4000回合、ε衰减0.995,训练了挺久成功率也就四成多。后来把衰减系数改成0.998,最终ε在4000回合还有0.5×0.998^4000≈0.002——总之,经验是:你应该让ε在训练的最后十分之一阶段才逐渐逼近最小值,而不是在前十分之一就降到底。 我后面干脆改成线性衰减,效果稳定得多:
matlab复制epsilon = max(epsilonMin, epsilonInit * (1 - episode / params.episodes));
如果不想手动算,也可以在训练过程中每隔若干回合打印一次ε,看衰减曲线是否符合预期。
5.4 另一个容易忽略的坑:单回合最大步数
我最初没有限制单回合步数,只写了while ~done,结果死胡同多的迷宫里,Agent可能卡在某个局部区域来回撞墙几百上千步。训练最终也能收敛,但速度极慢,而且观察累积奖励时,那些没完没了的负步数会让totalReward变得非常负,干扰你对奖励曲线的判断。加上maxSteps=N*N*2的上限后,回合超时未到达目标也会结束,训练效率和统计清晰度都好了很多。
5.5 怎么判断训练真的收敛了?
不要只看“最后一条路径走通了”就宣布胜利,一次成功和稳定成功是两回事。项目里我用三个指标综合判断:
- 成功率曲线:以50~100回合为窗口,计算窗口内到达终点的回合比例,画出来观察是否稳定在0.9以上。
- 步数曲线:成功回合的平均步数随时间是否下降并稳定。若步数一直居高不下,多半是奖励设计有问题。
- Q表热力图:取出每个状态四个动作中最大Q值,用
imagesc或surf画出来。收敛后,Q值从终点向起点方向应该呈现明显的梯度递减,没有梯度说明价值传播没有打通。
6. 效果验证:从一张静态路径到批量迷宫测试
6.1 训练完成之后,怎么把Q表翻译成一条可视路径?
训练结束后,Q表已经编码了每个状态下选哪个动作最优。提取路径的方法很直白:从起点出发,每一步选当前状态Q值最大的动作(贪婪策略),把经过的坐标记录下来,直到到达终点或超过最大步数。
matlab复制% 提取最终策略路径
state = [1, 1];
path = state;
maxPathLen = rows * cols * 2;
len = 0;
while len < maxPathLen
sIdx = sub2ind([rows, cols], state(1), state(2));
[~, action] = max(Q(sIdx, :));
[state, ~, done] = stepMaze(maze, state, action, goalPos, ...
params.R_goal, params.R_step, params.R_wall);
path(end+1, :) = state; %#ok<AGROW>
len = len + 1;
if done
break;
end
end
把path里的坐标依次连起来画在迷宫图片上,就能看到Agent最后走出来的路。我一般还会把它和BFS算出来的最短路径叠在一起画,一眼能看出Q-learning找到的路径离最优解差多少。两者偏差越大,越要回头检查奖励函数和参数设置。
6.2 随机生成多张迷宫,批量评估鲁棒性
单一地图上跑通,只能说明算法在这张图上有效。项目本身对应“随机生成迷宫”的需求,所以评估必须是批量的:随机生成50张不同密度的10×10方形迷宫,对每张迷宫独立跑完整训练,然后统计到达目标的回合比例、平均步数、平均累积奖励。
我做过的典型实验是在密度0.15、0.25、0.35三组下各跑20次,结果大概是这样:
| 迷宫密度 | 平均成功率 | 平均收敛回合 | 平均路径长度(成功回合) |
|---|---|---|---|
| 0.15 | 98% | 约1800回合 | 约22步 |
| 0.25 | 93% | 约2600回合 | 约28步 |
| 0.35 | 78% | 约3300回合 | 约36步 |
可以看到一个规律:密度越大,收敛越慢,成功率越低。这很好理解——障碍变多意味着许多状态格子的有效动作变少,Q表里大量“撞墙”的负反馈会稀释有效信号。把这个规律讲清楚,比单纯贴一张炫酷的路径图更能证明你理解了这个算法。
6.3 训练过程可视化:奖励曲线比路径图更能说明问题
除了最终路径图,我会把每回合累积奖励曲线和成功率滚动窗口曲线一并画出来。训练初期的奖励曲线通常又低又乱,中段逐渐出现“平台+尖峰”的形态:平台对应的是连续失败回合,尖峰说明Agent偶尔摸到终点;后期曲线整体抬高并趋于稳定,此时Q-learning已经基本学会策略。
拿这个形态去分析问题,你会发现实操里最常见的误区是:只看成功回合的路径漂不漂亮,却不看训练过程健康不健康。 一个健康的过程应该是成功率爬坡、平均步数下降、奖励曲线抬高,三者同时发生。如果只有奖励曲线抬高、成功率不动,多半是Agent学会了“在负收益少的地方转圈”,而不是“走向终点”。
7. 一些扩展方向,和最后想说的几句话
这个项目做完后,最自然的扩展方向其实是下面几条。如果你想拿它当课程设计或研究报告的底座,可以任选其一深化:
- 把迷宫从固定改为滑动窗口式:每训练一段回合就重新随机生成一张地图,测试Q表在“地图变化”场景下的迁移能力。这更接近真实环境的未知性和动态性。
- 从表格Q-learning升级到函数逼近:状态数量增加后,Q表的内存和训练时间会快速增长。15×15迷宫有225个状态,问题不大,但100×100就非常吃力。此时可以用神经网络近似Q函数,这就是DQN的方向。
- 对比SARSA与Q-learning的差异:SARSA是on-policy算法,在探索阶段会主动规避风险;Q-learning是off-policy算法,会更激进地追求最优。在迷宫这种悬崖多的环境里,SARSA通常会显得更“胆小”,走的路更安全但可能更长。这个对比做起来很顺手,只要把更新公式里
max(Q(s',a'))换成实际选择动作的Q(s',a')即可。
我个人最想提醒的一句话是:别急着先跑代码,先把奖励函数和连通性检查想清楚。 这两件事想清楚了,Q-learning的代码反而只是几行循环的事。很多时候训练曲线看起来奇怪,排到最后不是数学公式错了,而是环境定义本身有漏洞。这类问题的排查顺序,永远是“先验环境,再看模型,最后调参数”。
如果你也想跑一版,直接从迷宫生成函数和stepMaze开始,先在一张10×10的小地图上把基础流程跑通,再去做批量测试和参数调整。这一步踩顺了,后面从Q-learning到SARSA、DQN的路,就走得顺畅多了。
