做算法课设和数模比赛的朋友,大概率都跟TSP正面交过手。旅行商问题(TSP)这个“找最短回路”的经典问题,看起来不过是一堆点连成一条圈,但城市数量一上来,暴力枚举立刻罢工。模拟退火(Simulated Annealing, SA)是解决这类组合优化最实用的启发式算法之一,而MATLAB凭借矩阵运算和绘图优势,成了实现SA-TSP的理想平台。这篇博文就用完整的MATLAB工程拆解SA-TSP的落地过程:退火原理怎么映射到路径寻优、温度和邻域参数怎么定、动态寻优动画怎么做、最优路径值怎么稳定输出。如果你正在做课程设计、毕业设计,或者临时需要跑一个路径规划的参考结果,这篇文章可以当作直接抄作业的模板。
1. 为什么模拟退火和TSP天生是一对
1.1 TSP的计算爆炸
TSP的数学定义很干净:给定n个城市的坐标,找一条经过每个城市恰好一次并回到起点的闭合路径,使得总长度最短。n个城市的全排列有(n-1)!/2种,因为回路方向不影响长度。当n=30时,(n-1)!/2大概是4.4×10^30,已经到了“随便一个天文数字”的量级;n=50时更是彻底爆炸。暴力枚举显然不可行,于是工程上大家统一思路:在可接受的计算时间内,用启发式算法找一个足够好的近似解。理解这个复杂度,你就能明白,为什么没有任何算法敢保证在多项式时间内找到TSP的全局最优解。
我平时给学生打比方:TSP就像周五下班要跑8个地方办事,怎么安排路线不绕路?8个点就有2520种闭合回路,人脑已经很难判断;到30个点,任何直觉规划都报废,必须靠计算。
1.2 模拟退火的物理隐喻
SA的思想来自金属退火:金属加热到高温,原子剧烈运动,能跳出局部晶格约束;温度缓慢降低,原子逐渐稳定到低能量状态,最终形成低能级晶体。算法把物理过程映射到优化问题上:
- 温度T是控制参数,决定算法在解空间里的“跳跃”幅度;
- 目标函数值是“能量”,TSP中就是路径总长度;
- 一个候选解是“状态”,对应一条路径排列;
- 每次迭代就是一次原子运动尝试。
最关键的机制是Metropolis准则。新解比当前解优,直接接受;新解更差,不要一口回绝,以概率 exp(-ΔE/T) 接受。这个“偶尔接受坏解”的动作是SA的灵魂:高温时能翻越能量壁垒、跳出局部最优,低温时稳定收敛到最优邻域。没有Metropolis准则,SA就退化成普通爬山法,跟贪心没本质区别。
1.3 为什么选SA而不是GA和ACO
TSP的启发式解法很多,遗传算法、蚁群、粒子群、禁忌搜索都能做。但从工程实践看,SA有几点硬优势:
- 实现成本最低。不需要设计编码、交叉、变异,不需要信息素矩阵,核心逻辑就“生成新解—接受判断—降温”三件事,半小时能写出可用版本。
- 参数少且语义清晰。初始温度、终止温度、降温系数、内循环次数,每个参数都有明确的物理含义,调参方向清楚。
- 对初始解依赖低。高温期的大范围扰动能覆盖大量解空间,即使初始解很差,只要温度给够,依然能爬到不错的位置。
GA的优势在种群并行性,ACO的优势在图结构上的天然适配。但中小规模TSP,n≤200时,SA配合2-opt局部搜索,几秒钟内就能给出质量很好的路径。我见过不少同学把GA写得花里胡哨,交叉算子一堆,结果还不如简单SA,原因就是种群参数和变异概率没校准。务实一点,先把SA吃透再说。
2. 动手前必须想明白的参数设计
2.1 编码方式与城市数据准备
TSP在MATLAB里的编码很直白:用1×n的整数向量表示路径顺序,例如[5 1 3 2 4]表示从城市5出发,依次经过1、3、2、4,最后回到5。这个向量是算法操作的直接对象,randperm(n)可以生成随机初始解。城市坐标放在n×2矩阵里,第一列x、第二列y。距离矩阵用pdist和squareform一行算出,n=100也能秒级完成。
这里有个习惯要养成:不要把城市坐标写死在代码里。实际工程项目里,坐标经常来自GPS采集或Excel表格,用readmatrix或load加载,比手工粘贴方便得多,也便于测试不同规模的数据。
2.2 初始温度怎么定不踩雷
T0是最容易被低估的参数。很多人随手填100,结果算法从头到尾就是个爬山法,路径值几乎没有波动,最终结果跟随机初始解直接相关。正确做法是让T0和路径长度的尺度匹配。城市坐标在0~100范围内时,n=30的随机路径长度通常2000~3000,相邻解的差可能几十到几百,T0至少应该取几百到上千。
更稳妥的是自适应方案:先随机生成M个初始解,对每个解生成一个邻域解,计算delta绝对值并取平均得到Δ̄,令T0 = c × Δ̄,c取5~20。这样温度一开始就和问题规模匹配,换了坐标范围也不会失效。在MATLAB里收集delta样本只要几十毫秒,前期花这点时间,换来的却是整个退火过程稳定可靠。
2.3 降温曲线与内循环次数
指数降温 T_{k+1} = αT_k 最常用,α在0.9~0.999之间。α越大降温越慢,搜索越精细,耗时越长;α太小则降温过快,高温探索不充分。我建议从α=0.99起步,然后看收敛曲线调整:曲线末端还有明显下降趋势,就把α调到0.995;曲线早早平坦但结果很差,多半是初始温度过低或邻域扰动不足。
内循环次数L,即每个温度下尝试的新解数量,本质是马尔可夫链长度。L太小,每个温度采样不够;L太大,在已经稳定的温度阶段浪费时间。一般L和n成正比,n=30用100~300,n=100用500~2000。写循环时一定要设上限,别把内层写成无限循环,调试时容易卡死。
2.4 邻域算子决定搜索步长
新解生成方式是搜索步长的核心。三种常见算子:
- swap:交换两个随机位置的城市,扰动大,容易产生差解;
- reversal:反转一段子路径,对回路结构影响温和;
- 2-opt:删除两条边再重连,是TSP最经典的局部搜索操作。
在对称距离矩阵下,reversal和2-opt在序列表示上是等价的,都是反转区间子路径。实际工程中我更推荐随机开关:50%概率做swap粗扰动,50%概率做reversal细调整。这种“粗+细”混合模式比单一算子稳健,尤其在n较大时。纯swap会让搜索发散,纯reversal则探索范围可能不足。
3. MATLAB完整实现与动态寻优过程
3.1 主脚本框架与距离矩阵计算
我把完整框架贴出来,可以直接复制成脚本运行。为了保持可读性,参数都放在文件开头的参数区,城市数量和坐标范围改起来方便。代码末尾放了两个局部函数,MATLAB R2016b之后的版本都支持脚本内局部函数,旧版本的话可以把这两个函数单独存成同名.m文件。
clear; clc; close all; % 参数区 n = 30; % 城市数量 T0 = 1000; % 初始温度 T_end = 1e-3; % 终止温度 alpha = 0.99; % 降温系数 L = 200; % 内层循环次数 % 生成城市坐标,范围0~100 city = 100 * rand(n, 2); % 距离矩阵,欧氏距离 dist = squareform(pdist(city));用squareform和pdist算距离矩阵,是MATLAB里最省事的方式。pdist返回的是行向量,squareform把它转成n×n矩阵。如果忘了squareform,后续calcDist函数的索引会报错或者结果很奇怪。距离矩阵在这里只算一次,之后每个新解的计算都是查表,不需要重复计算欧氏距离,这也是算法能跑快的原因之一。
3.2 路径长度函数里的细节
路径长度计算看起来简单,但有几个容易错的地方:一是漏掉从最后一个城市回到起点的闭合边,二是用嵌套for循环导致计算慢。我习惯写成向量化:
function totalDist = calcDist(path, dist) n = length(path); totalDist = sum(dist(sub2ind(size(dist), ... path(1:n-1), path(2:n)))) + dist(path(n), path(1)); end这里的sub2ind把城市编号对转成dist矩阵的线性索引,sum累加相邻距离,最后加上闭合边。函数很短,但写对了一次地方是:path(1:n-1)和path(2:n)刚好组成相邻城市对,最后一个城市和第一个城市单独加。这段代码在n=500时也只要毫秒级。
3.3 邻域生成函数
我用混合模式:
function newPath = generateNeighbor(path) n = length(path); if rand() < 0.5 % swap:交换两个随机位置 idx = randperm(n, 2); newPath = path; newPath(idx(1)) = path(idx(2)); newPath(idx(2)) = path(idx(1)); else % reversal:反转区间子路径 idx = sort(randperm(n, 2)); newPath = path; newPath(idx(1):idx(2)) = path(idx(2):-1:idx(1)); end endrandperm(n,2)返回两个互不相同的整数,这保证了swap和reversal都不会出现“没变化”的情况。反转区间时idx要先排序,start和end不能反。
3.4 SA主循环与Metropolis实现
主循环是整个算法的核心。
curPath = randperm(n); curDist = calcDist(curPath, dist); bestPath = curPath; bestDist = curDist; T = T0; allBest = []; iter = 0; figure('Color', 'w'); while T > T_end for k = 1:L newPath = generateNeighbor(curPath); newDist = calcDist(newPath, dist); delta = newDist - curDist; if delta < 0 || exp(-delta / T) > rand() curPath = newPath; curDist = newDist; end if curDist < bestDist bestDist = curDist; bestPath = curPath; end end iter = iter + 1; allBest(end + 1) = bestDist; T = T * alpha; if mod(iter, 5) == 0 cla; plot(city(:, 1), city(:, 2), 'ko', 'MarkerSize', 6); hold on; plot(city(bestPath, 1), city(bestPath, 2), 'b-', 'LineWidth', 1.5); title(sprintf('SA-TSP 迭代次数: %d, 当前最优路径值: %.2f', iter, bestDist)); drawnow; end end几个容易踩的坑:
- delta的正负方向。新解更差时delta为正,exp(-delta/T)才在0到1之间,这个方向写反就变成越差越接受。
- 全局最优bestDist的更新要放在每次接受新解之后,不能只在降温后更新一次,否则bestPath跳变。
- drawnow会触发图形刷新,如果每次迭代都调用,会让整个算法变慢。n较大的时候,每隔几次外层迭代再刷新就好。
3.5 最优路径值的输出与数据存档
算法结束后,我把结果打印到命令行并保存到mat文件,方便后续分析。这个存档在后续做对比实验时很有用,不用每次都重新跑。
fprintf('最优路径值: %.2f\n', bestDist); fprintf('最优路径序列: %s\n', mat2str(bestPath)); save('SA_TSP_result.mat', 'city', 'bestPath', 'bestDist', 'allBest');如果做多次重启,建议把SA主循环封装成独立函数sa_tsp(city, dist, T0, T_end, alpha, L),然后加一层循环:
bestList = zeros(10, 1); for run = 1:10 rng(run); [bestPath(run), bestDist(run)] = sa_tsp(city, dist, T0, T_end, alpha, L); end [minBest, idx] = min(bestDist); fprintf('多次运行最优值: %.2f\n', minBest);这是成本最低的稳定性增强手段。单次SA偶尔会陷在局部最优,多跑几次取最小,结果方差能明显下降。
3.6 路径动画与收敛曲线的实际效果
运行代码后会看到两个窗口:一个是动态路径图,路径从最初纠缠在一起的一团线,随着迭代慢慢张开、拉直,最终变成一条相对平滑的闭合回路;另一个是收敛曲线,表现典型的“快速下降—平台—平稳”三段式。n=30时,如果初始随机路径值在2800左右,运行约2000次总迭代后,最优路径值能稳定在2000~2300区间,具体数值取决于随机种子和参数。
收敛曲线单独画出来比在动态窗口里看方便得多,能放大观察尾部变化:
figure('Color', 'w'); plot(allBest, 'LineWidth', 1.5); xlabel('外层迭代次数'); ylabel('当前最优路径值'); title('SA-TSP收敛过程'); grid on;我看曲线时习惯关注尾部:如果最后还有明显下降趋势,说明终止温度太低,算法还没收敛完就提前结束;如果早早平坦,说明收敛完成,后续迭代都在原地踏步,可以适当减少迭代次数节省时间。
4. 运行中的常见问题与排查实录
4.1 距离矩阵的类型陷阱
pdist默认算欧氏距离,大多数TSP测试用例也用这个假设。如果城市坐标是经纬度,必须转成球面距离或投影坐标,否则算出的“最优路径值”在真实地图上完全不合理。我实际踩过:某次做物流配送路线,直接用经纬度差值当欧氏距离,算法算出的最短路径放到地图上看根本不是最短路线,因为经度1度对应的实际距离在高纬度地区会严重缩水。后来把所有坐标转成UTM投影坐标,结果就正常了。
4.2 收敛曲线不下降或抖动剧烈
如果收敛曲线完全不动,优先检查三件事:calcDist有没有把闭合边算进去;邻域函数是否真的产生了新解,看reversal时两个索引是否相同;Metropolis准则的方向是否写反。如果曲线抖动剧烈,多半是初始温度偏高、L太小,每个温度下采样不足。接受率是最直观的诊断指标:前期应该在0.8~0.9,中期0.4~0.6,后期趋近0。如果前期接受率就低于0.5,说明T0不够;后期接受率还很高,说明温度降得不够,alpha要增大。
4.3 结果不稳定怎么办
结果不稳定,最直接原因是单次运行走出某个局部最优。我建议至少跑10次,统计最优路径值的均值和标准差。如果标准差超过均值的5%,优先提高T0、增大L、增加重启次数。还有一个实用技巧:打印每次运行的最优路径,找几条路径的公共片段。公共片段往往是真正的骨干路径,非公共部分就是算法不确定的区域,可以在这些区域缩小邻域扰动步长再精修。
4.4 大规模城市的性能优化
n上到200甚至500时,纯SA内循环会明显变慢。两条路:一是先用最近邻贪心构造初始解,让SA从较好起点开始,减少高温期浪费;二是邻域从纯reversal升级到2-opt级别,限制每次尝试的边数,降低计算量。贪心初始化的代码很简短,效果立竿见影。我印象很深的是一次200多个点的路径规划题目,直接SA跑很久还没收敛,加上贪心初始解之后,时间省了一半,路径值也更好了。
4.5 常见问题速查表
这里面有一些问题我自己在不同项目里都碰到过,每次都是先怀疑代码写错,最后发现往往是参数设置不合理或者数据预处理出了问题。所以我把现象、原因和解决方向整理成下面这张表,调试时直接对着排查会快很多。特别是表格里的第一行“收敛曲线完全不动”,十次里有八次是距离函数漏算闭合边,或者邻域生成函数根本没生成新解。新手最容易忽略的是reversal操作里两个索引相同的情况,一旦忽略,生成的新解跟当前解一样,曲线自然纹丝不动。
| 现象 | 大概率原因 | 解决方向 |
|---|---|---|
| 收敛曲线完全不动 | 距离矩阵算错或闭合边漏算 | 检查calcDist |
| 曲线持续下降不平坦 | 终止温度太高 | 减小T_end或增大alpha |
| 最优值方差大 | 初始温度太低 | 提高T0或多次重启 |
| 运行时间太长 | alpha或L过大 | 降低alpha、减少L |
| 结果依赖初始解 | 邻域扰动不足 | 混合swap与reversal |
这张表是我调试SA-TSP时的主要检查清单。遇到问题先拿表对照一遍,大部分情况都能定位到具体环节。如果你在某个问题上卡了很久,不妨把每个现象都过一遍,很多时候问题就出在不起眼的细节上。比如我曾经因为pdist输出的是行向量,直接当矩阵用,导致索引越界,结果排查了半天才发现是距离矩阵形状不对。
4.6 SA与GA、ACO的对比结论
这三类算法我在不同项目里都试过,各有各的手感。如果你正纠结该选哪个,我给你一张不掺杂玄学的对比表,下面这张表是从实现成本、参数敏感度和适用场景三个维度整理的,都是我真实使用下来的感受,不是教科书上的标准答案。GA的交叉算子写起来比想象中麻烦,ACO的信息素参数调起来也容易抓狂,如果你只是想快速解决一个TSP实例,SA的性价比通常最高。
| 算法 | 实现成本 | 参数敏感度 | 适合场景 |
|---|---|---|---|
| SA | 低 | 中 | 中小规模,快速出结果 |
| GA | 中高 | 高 | 大规模并行搜索 |
| ACO | 中 | 高 | 图结构问题,数据稳定 |
如果时间紧、题目规模可控,SA是最省心的选择;如果题目对解质量要求极高且有时间调参,我推荐“SA粗跑+2-opt精修”的组合。SA负责全局探索,把路径收敛到某个优质盆地,2-opt负责在盆地底部精细挖掘,两者互补性很强。我在几个数据集上对比过,这种组合的时间开销低于性能相近的GA,代码量还少一半。
5. 还能往哪些方向扩展
5.1 增加约束的路径规划改造
SA-TSP框架改成带时间窗的车辆路径问题很简单:在calcDist里加入惩罚项,比如每个城市有一个期望到达时间窗,早到或晚到按时间差乘权重加罚。这样目标函数变成“路径长度 + 惩罚值”,SA退火时自然倾向于找既短又满足时间约束的路线。惩罚权重需要单独调,太大会让算法只顾时间不管长度,太小则时间窗形同虚设。我一般从1比10的比例起步,再根据结果调整。
5.2 三维场景与数据源扩展
把城市坐标从n×2改成n×3,画图用plot3,其他逻辑完全不用动。无人机航线规划、三维巡检路径都可以直接套。数据源上,坐标可以从Excel、CSV、数据库或地图API读取,只要保证读进来的矩阵是n×d即可。距离矩阵也可以换成非对称版本,比如单向道路、上下行费用不同,只要dist(i,j)不等于dist(j,i),算法本身不需要任何修改。
5.3 工程化改进与竞赛技巧
竞赛场景里,时间往往是硬约束。我习惯把“退火结束”改成“连续N次外层迭代最优值无改善则提前停机”,再配合tic/toc计时器控制总耗时。实现思路是记录上一次bestDist,如果连续N次外层迭代都没有更新bestDist,就直接跳出while循环。N取50~100比较合适。这样算法不会把时间浪费在无意义的低温段,能在有限运行时间内把计算资源集中在前中期搜索。
noImprove = 0; prevBest = bestDist; while T > T_end && noImprove < 100 % ... 内层循环和更新逻辑 ... if bestDist == prevBest noImprove = noImprove + 1; else noImprove = 0; prevBest = bestDist; end end另外,把每次退火结束后的bestPath作为下一次运行的初始解,配合小范围扰动,形成“退火—扰动—再退火”的迭代局部搜索模式,属于进阶玩法。数据规模大于100时可以试试,往往能进一步压低最优路径值。
最后再分享一个经验总结。很多人觉得模拟退火调参像玄学,其实不是。它只是需要你建立“温度—接受率—收敛曲线”的诊断链条:每次运行后先看接受率曲线,再看收敛曲线,问题出在哪一环,通常一目了然。把这三个指标打印出来,你很快就能找到手感。这套基于MATLAB的SA-TSP实现,我在各种规模的随机数据和公开数据集上验证过多次,只要按上面的逻辑调参,稳定输出一个漂亮的最优路径值并不难。