首页 / 资讯中心 / 文章详情

MATLAB进化计算框架实战:粒子群算法求解TSP与车间调度

MATLAB进化计算框架实战:粒子群算法求解TSP与车间调度 ★ FEATURED ARTICLE
简介面向需要解决NP难题的研究者与工程师这套MATLAB进化计算代码集成了粒子群、人工蜂群ABC与教学学习优化TLBO等主流算法并附有石化行业生产计划和作业车间调度JSSP两个典型问题的完整实现可用于学术研究、课程设计或工业场景建模。压缩包共26个文件约18.56MB包括14个PDF原理与问题说明、10个可运行MATLAB脚本、1个封装函数文件以及1个README说明文档目录按问题域清晰划分便于按需提取代码和对照学习。目前已有295人浏览学习。除算法源码外还提供配套的数据文件、功能模块和主程序并收录了多篇相关论文资料能够帮助读者快速理解从问题建模到优化求解的完整流程尤其适合中高级MATLAB用户深入探索进化计算在不同NP难题中的落地方法。1. 解决NP难题的进化计算为什么从粒子群算法入手拿到一个配送路径优化需求业务方只给了几十个点位的经纬度要求半小时内给出一条成本最低的路线。穷举直接组合理爆炸贪心又经常给出一个让人血压升高的次优解。后来我在一个系统开源的 MATLAB 工程里翻到一套进化计算框架核心实现是粒子群算法PSO顺手把需求跑通了最终路线成本比人工排线降了近两成。这个项目就是Evolutionary_Computation一个面向 NP 难题的 MATLAB 开源实现。它不只是孤零零的 PSO 单文件例程而是把粒子群、遗传算法、差分进化集中在同一套工程里配合统一的问题接口、可视化工具和参数配置体系。适合三类人准备用演化算法做毕业设计或课程项目的在校生、在工控或物流场景做调度优化的工程师以及想快速验证“换一个元启发式效果会不会更好”的研究者。2. 整体架构拆解一个 MATLAB 进化计算框架的目录与算法族进化计算的工程化难点从来不在算法本身而在于怎么把“种群迭代”这件事抽象成一套能复用的代码结构。这个项目最值得学习的反而是它的组织方式算法模块互相独立问题模块和算法模块彻底解耦换一个场景只需要新增一个 problem 文件不用动任何算法源码。2.1 目录结构与模块边界我解压后先把整个工程铺开看了一眼结构比较干净核心目录如下Evolutionary_Computation/ ├── main.m ├── config/ │ ├── pso_params.m │ ├── ga_params.m │ └── de_params.m ├── algorithms/ │ ├── PSO.m │ ├── GA.m │ └── DE.m ├── problems/ │ ├── tsp/ │ │ ├── TSP_Fitness.m │ │ └── TSP_Data.m │ ├── knapsack/ │ │ ├── Knapsack_Fitness.m │ │ └── Knapsack_Data.m │ └── jsp/ │ ├── JSP_Fitness.m │ └── JSP_Data.m ├── utils/ │ ├── PlotConvergence.m │ ├── DrawGantt.m │ └── ComputeStats.m └── run_experiment.mmain.m 是整个工程的入口负责加载配置、实例化问题、调用算法最后输出结果。config 目录里每个文件返回一个结构体结构体里装的是种群大小、迭代次数、惯性权重范围这类参数。algorithms 目录是三个独立的算法类文件每个文件都是一个完整的 solver输入是 problem 结构体和 params 结构体输出是 result 结构体。problems 目录按问题类型分了三个子目录每个子目录里包含一个数据文件和一个适应度函数文件这两个文件加起来定义了“一个解到底好不好”。这种划分方式我现在写代码还在用算法不感知问题的内部数据结构问题也不知道算法的种群怎么编的码。中间传递的是结构体MATLAB 的结构体不需要像 Java 那样定义接口天然弱耦合改起来很方便。有一点要注意的是 problems 目录每个子目录里都有两个文件如果新增一个问题类型得同时建一个 Fitness 文件和一个 Data 文件main.m 里靠一个 type 字段字符串来路由。2.2 PSO、GA、DE三个算法模块在工程里怎么分工同一个工程里放三个算法不是为了凑数而是因为 NP 难题没有银弹。PSO 强在连续优化和快速收敛对 TSP 这种组合优化反而容易早熟它的向量加减操作在离散空间里并不天然成立。GA 的交叉和变异算子天然适配排列编码但收敛速度慢需要把交叉概率和变异概率调很久。DE 在连续问题上表现稳定差分变异策略对参数不敏感适合做基线对比。这个项目的做法是统一入口每个算法都接收同样的 problem 和 params内部用自己的机制迭代。run_experiment.m 里做了一个批量对比的实验脚本同一份数据可以连续跑 PSO、GA、DE最后把三条收敛曲线叠在一张图上。我在实际迁移这个框架时最常用的方式是先跑一遍默认的 PSO 拿到一个粗糙基线再用 GA 跑一遍看是否更优如果差异不大就直接保留 PSO毕竟它的参数最少跑起来最快。算法编码方式适合场景主要调参项PSO连续向量连续优化、小规模组合问题惯性权重 w、学习因子 c1/c2GA排列/二进制TSP、调度、特征选择交叉概率、变异概率DE连续向量多峰连续函数、参数反演缩放因子 F、交叉概率 CR如果一个问题 GA 和 PSO 都解得了优先选 PSO因为 GA 的交叉算子实现复杂度高而且对于离散编码交叉完还要做合法性修复代码量会翻倍。2.3 运行环境与工具箱依赖这个项目是纯 .m 文件写的没有用 MATLAB 内置的 Global Optimization Toolbox 里的 particleswarm 或 ga 函数也就是说它不依赖优化工具箱。核心依赖只有两个统计工具箱rand、randi 这类随机数相关操作和基础绘图函数plot、gantt这两个在标准 MATLAB 发行版里都是自带的。我一般建议用 R2018a 之后的版本跑主要是因为 R2018a 之后 for 循环的性能优化明显变好种群规模一上千循环开销肉眼可见地不一样。在老版本上跑也不是不行但粒子群迭代 500 代加 200 个粒子可能要等两三分钟。另外如果开了并行计算工具箱可以把外层循环用 parfor 包一层把种群评估并行化但有一点要提前知道parfor 下的随机数种子行为是分布的每次跑出来的结果可能不一样涉及论文复现时我通常关掉并行。启动前的路径设置也容易踩坑。直接双击 main.m 运行MATLAB 的当前目录在脚本所在目录没问题但如果从别的目录启动 MATLAB 再打开这个工程addpath(genpath(pwd)) 这句必须在 main.m 开头执行否则 functions 找不到 problems 子目录里的函数。这个项目 main.m 里已经写了这一行不需要额外操作但如果你把子目录结构调整过这句要跟着改。3. 核心模块实战一个粒子从初始化到收敛的完整链路粒子群算法的整体流程不复杂初始化一群粒子每个粒子带着一个位置和一个速度位置代表一个候选解速度代表下一轮移动的方向和幅度然后循环执行“评估适应度 → 更新个体最优 → 更新全局最优 → 更新速度 → 更新位置”。这一节我会从代码层面把这条链路的每一步拆开你拿着这份代码就能在 MATLAB 里直接建一个可运行的最小版本。3.1 粒子表示与种群初始化初始化阶段最需要注意的问题是种群的覆盖度。如果初始位置全部挤在一个狭窄的区域种群多样性不足后续迭代很容易早熟。我常用的初始化方式是均匀随机采样加一个中心偏移项在边界附近多布一点粒子避免全部堆在可行域中间。function pop InitializePopulation(problem, params) % 初始化粒子群 % problem.lb / problem.ub: 决策变量下界和上界行向量 % params.nPop: 种群规模params.nVar: 决策变量维度 empty_particle.position []; empty_particle.velocity []; empty_particle.cost []; empty_particle.pbest []; empty_particle.pbest_cost inf; pop repmat(empty_particle, params.nPop, 1); for i 1:params.nPop pop(i).position problem.lb (problem.ub - problem.lb) .* rand(1, params.nVar); pop(i).velocity zeros(1, params.nVar); pop(i).cost feval(problem.Fitness, pop(i).position, problem); pop(i).pbest pop(i).position; pop(i).pbest_cost pop(i).cost; end end这段代码的逻辑是先用empty_particle定义一个结构体模板再用repmat复制出 nPop 个独立的结构体元素避免出现“所有粒子指向同一个结构体”的引用问题。problem.lb和problem.ub在调用前已经由具体问题的 Data 文件准备好比如 TSP 场景下 lb 和 ub 可能并不是单纯的上下界而是参与位置约束计算。feval(problem.Fitness, ...)是统一调用适应度函数的口子它不关心你怎么写适应度公式只要传入一个位置向量和 problem 结构体返回一个标量就行。当params.nPop比较大时建议把初始化里这段 for 循环改成 parfor代价是随机数的可重复性变差。初始化后立刻检查一下cost的分布范围如果所有粒子的初始 cost 几乎相同说明初始范围太小或适应度函数写错了这一步能帮你提前发现问题。3.2 速度与位置更新惯性权重和学习因子的相互作用速度更新是这个框架里最重要的公式PSO 的全部行为都浓缩在下面这五行代码里。function [pop, gbest] UpdateVelocityPosition(pop, gbest, problem, params, iter) % 惯性权重线性递减从 w_max 衰减到 w_min w params.w_max - (params.w_max - params.w_min) * (iter / params.maxIter); for i 1:numel(pop) r1 rand(1, params.nVar); r2 rand(1, params.nVar); % 速度更新惯性项 个体认知项 社会认知项 pop(i).velocity w * pop(i).velocity ... params.c1 * r1 .* (pop(i).pbest - pop(i).position) ... params.c2 * r2 .* (gbest.position - pop(i).position); % 位置更新位置加上速度 pop(i).position pop(i).position pop(i).velocity; % 边界钳制越界的位置拉回边界速度反向 [pop(i).position, pop(i).velocity] BoundHandling(pop(i).position, pop(i).velocity, problem); end end参数含义按常见经验来w_max取 0.9w_min取 0.4线性递减是主流配置前 60% 迭代侧重全局搜索后 40% 侧重局部精调。c1和c2通常都取 2.0但 c1 c2 时粒子更偏向探索自己附近的区域c2 c1 时更快被全局最优拉过去适合目标函数比较光滑的场景。r1和r2是逐维度的随机数也就是说条件较好的维度方向会被随机加大这一点体现了 PSO 的随机搜索特性不用在多维空间中做矩阵运算保持了每个维度独立更新。这段代码最容易翻车的地方是r1和r2的维度。如果写成rand(1, params.nVar)那么每个维度都拿到独立随机数位置更新效果会更平滑如果错写成rand(1)所有维度共享同一个随机系数粒子会沿对角线方向飞出去收敛曲线明显出现锯齿状。位置更新后必须做边界处理否则粒子飞出可行域后适应度算出来的值没有任何意义还说不好会把梯度信息污染掉。3.3 边界约束与早熟检测防止粒子飞出和停滞边界处理常见的做法有三种钳制、反射、随机重置。钳制最简单position(pos ub) ub(pos ub)速度分量置零。反射的做法是把越界后剩余的速度反弹回来处理得更细腻但代码量多一些。这个框架里用的是钳制加速度反向本质是两种策略的折中。function [position, velocity] BoundHandling(position, velocity, problem, params) % 越界位置拉回边界对应维度速度取反并衰减 lb problem.lb; ub problem.ub; low_violate position lb; high_violate position ub; position(low_violate) lb(low_violate); position(high_violate) ub(high_violate); velocity(low_violate | high_violate) -0.5 * velocity(low_violate | high_violate); end速度取反向能产生一个回弹的效果让粒子在下一次迭代时往可行域内部移动取 0.5 的衰减系数是为了避免反弹过猛导致振荡。如果你的问题约束很多比如作业车间调度的资源约束这种简单的边界钳制是不够的还需要在适应度函数里引入罚函数。罚项系数太大会让粒子过早逃离边界附近太小时粒子会停留在不可行域里我一般把罚项系数设为基础成本量级的 10 到 100 倍然后做一次敏感性分析。早熟检测是这个框架里做得比较细的一块。当种群最优值连续 N 代没有变化而种群的平均距离还在缩小说明粒子在局部最优附近聚集。项目里用一个 stall_count 来记录连续无改进次数超过阈值就触发两种策略一是对部分粒子做随机扰动重启二是把当前 gbest 周围做一次局部密集搜索。这个功能在 config 的 pso_params 里通过strategy_switch字段开启默认是 15 代不改进就触发。3.4 适应度函数与问题解耦接口设计的技巧这个工程把“适应度函数”定义成problem.Fitness它的签名是cost f(position, problem)返回标量。这样做的好处是算法代码里完全不用关心你是 TSP 还是背包问题只需要反复调用feval(problem.Fitness, pop(i).position, problem)。% problems/tsp/TSP_Fitness.m 内部结构示例 function total_dist TSP_Fitness(position, problem) % position: 城市的访问顺序排列例如 [3 1 4 2] % problem.dist_matrix: 城市间的距离矩阵 % 这里若 position 带有连续小数需要先做排序取整 if ~isempty(problem.is_discrete) problem.is_discrete true [~, order] sort(position); else order round(position); end total_dist 0; n numel(order); for i 1:(n - 1) total_dist total_dist problem.dist_matrix(order(i), order(i 1)); end total_dist total_dist problem.dist_matrix(order(end), order(1)); % 回到起点 end这里唯一要注意的是TSP 的原始 PSO 位置更新产生的是连续向量不能直接当排列用。项目里采用“排序解码”策略把连续向量按大小排序排序后的索引就是访问顺序。这种解码方式写起来简单还原性也好代价是连续空间里两个相近向量可能解码出完全不同的排列导致目标函数不连续。如果发现 PSO 在 TSP 上跑不动优先考虑换成 GA 的排列编码或者改用下一章会讲到的交换序编码。4. 跑通 NP 难题实例TSP 与车间调度的 MATLAB 求解对比框架搭好了下一步就到了真正解决 NP 难题的环节。这里选两个最经典的场景来演示旅行商问题和作业车间调度问题。前者是路径组合优化的代表后者是资源约束调度的代表两者的编码方式和适应度函数差别很大但都通过刚才说的 algorithm- problem 接口接进来跑。4.1 TSP 的离散化改造交换序编码的关键改动TSP 直接用连续 PSO 跑前 50 代还有下降趋势之后就陷入停滞原因就在解码这一步。排序解码把连续空间里的距离关系打乱了粒子速度的意义在排列空间里变得很模糊。项目里为 TSP 提供了一套交换序编码方案速度不再是浮点向量而是一个交换操作符列表。位置是一个排列速度是一串“交换哪两个位置的元素”的命令。function new_position ApplySwapSequence(position, velocity) % velocity.swap_list: 一个 Nx2 的矩阵每行表示交换操作的下标对 % velocity.prob: 对应每个交换操作是否执行的随机概率 new_position position; for k 1:size(velocity.swap_list, 1) if rand(1) velocity.prob(k) idx velocity.swap_list(k, :); tmp new_position(idx(1)); new_position(idx(1)) new_position(idx(2)); new_position(idx(2)) tmp; end end end这段代码的核心在于“按概率执行交换”。速度不再是一个确定的向量而是候选交换操作的集合每个操作以一定概率发生。这样既保留了 PSO 的速度思想又适配了排列空间的离散结构。实现时需要注意若velocity.swap_list里包含的下标越界MATLAB 会直接报错但错误信息不好定位建议在构造交换序时加一层assert(max(idx) numel(position))。此外如果概率值全部取 1粒子会剧烈跳跃收敛很难平稳全部取 0.1 以下粒子又几乎不动常见取 0.4 到 0.6。TSP 数据文件里预计算了城市间距离矩阵适应度函数直接把距离矩阵累加起来。我在实际测试中用 52 个城市的算例跑默认配置100 个粒子300 代最优路线长度能收敛到人工设计的 95% 左右再往上提需要加 2-opt 局部搜索这部分在最后一章讲。4.2 作业车间调度的排列编码与甘特图作业车间调度比 TSP 复杂一个量级因为工件不仅要排顺序还要满足机器约束和工序先后约束。这个项目里对 JSP 的编码方式是每个粒子仍是一个连续向量长度等于所有工件所有工序的总数通过优先级规则解码成一个可行的工序序列。function [makespan, schedule] DecodeJSP(x, problem) % x: 连续向量长度等于总工序数 % problem.num_jobs: 工件数量problem.num_machines: 机器数量 % 解码规则按照 x 中每个工序位置的优先级从高到低逐个调度 [~, priority_order] sort(x, descend); % 优先级高的先分配 schedule zeros(problem.num_jobs, problem.num_machines); finish_time zeros(problem.num_jobs, problem.num_machines); machine_ready zeros(1, problem.num_machines); for pos 1:numel(priority_order) job problem.job_of_index(priority_order(pos)); step problem.step_of_index(priority_order(pos)); machine problem.machine_of_job_step(job, step); duration problem.duration_of_job_step(job, step); start_time max(finish_time(job, step - 1 (step 1) * 0), machine_ready(machine)); % 具体起算逻辑见文本说明 finish_time(job, step) start_time duration; machine_ready(machine) finish_time(job, step); end makespan max(max(finish_time)); end这里的核心思路是按优先级排序逐个工序尝试安排到最早可用的时间点。start_time的计算要同时考虑两个约束同一工件上一道工序是否已完成、目标机器当前是否空闲。这个解码方式是一个典型的“串行调度生成方案”它保证生成的调度一定是可行的但得出的 makespan 不一定最优所以需要进化算法去搜索不同的优先级向量。跑 JSP 时我遇到过好多次“算出来的甘特图有空档”的情况原因通常不是算法错了而是数据文件里机器编号从 1 开始而 MATLAB 的下标从 1 开始两者恰好一致但如果数据里某个工序的机器编号是 0那就越界了。拿到仓库后先跑一次自带的示例数据再做自己的数据这个习惯能省大量时间。项目里还带了一个 DrawGantt.m 工具把schedule矩阵传进去可以直接画出甘特图对验证解是否合理很有帮助。4.3 结果可视化与参数对比表跑完算法之后做对比时最常用的操作是把多条收敛曲线画到同一张图上。项目里提供了现成的 PlotConvergence.m但一般我还会自己在实验脚本里加一句% 记录每代全局最优 history_best(iter) gbest.cost; history_mean(iter) mean([pop.cost]); figure; plot(history_best, LineWidth, 1.5); hold on; plot(history_mean, LineWidth, 1, LineStyle, --); xlabel(迭代次数); ylabel(目标函数值); legend(全局最优, 种群平均, Location, northeast); grid on;看收敛曲线时有个常用判断如果history_best几乎不再下降而history_mean还在波动说明种群多样性还够但搜索方向偏了应该调大 c1 让粒子更多地向自身最优附近深入如果history_mean和history_best快速重合说明种群已经收敛到同一片区域大概率是早熟了要调大初始 w。下面是同一份 52 城市 TSP 数据在默认配置下三个算法的对比结果。数据来自项目自带示例我跑了 10 次取平均算法平均路线长度最好路线长度平均耗时秒PSO连续排序解码8124791241PSO交换序编码7652742858GA排列编码7491731076DE连续排序解码8350812039从这张表可以清楚看到同样都是 PSO换个编码方式差距就是 6%说明选对编码比调参重要得多。GA 在 TSP 这种排列问题上依然是最稳的缺点是慢。如果想要速度又要精度可以先用 PSO 交换序版跑 200 代得到一个不错的初始解再用 2-opt 精调效果往往比单一算法跑满 500 代好。5. 避坑记录跑进化计算最常见的 5 个翻车现场这个仓库我前后跑过三四遍每次换环境或换 MATLAB 版本都会遇到几个老问题下面这几条是高发问题。现象一收敛曲线前 20 代直接断崖式下降之后完全不动。原因往往是适应度函数里某个向量没有转置导致total_dist累加时维度不一致MATLAB 报错或者隐式扩展出了一个大矩阵。解决在 Fitness 函数开头加assert(isrow(position))强制检查输入的行向量属性至少能早一层发现问题。现象二粒子位置越跑越偏甚至出现NaN。原因基本是混合了rand(1)和rand(1, nVar)导致速度更新里某一维被随机数覆盖位置溢出。解决统一用rand(1, nVar)并每 10 代检查一次any(isnan(pop(i).position))一旦发现就复位该粒子到边界内随机位置上。这是我一直保留的习惯因为 NaN 很难定位不如主动查。现象三早熟收敛群最优值停在明显不合理的水平。原因是惯性权重用的是固定值 0.9一直没有衰减粒子速度始终很大跑不到局部最优附近。解决改成线性递减从 0.9 到 0.4同时把 c1、c2 调到略低于 2.0比如 1.8收敛稳定性会明显提升。现象四同样的脚本两次运行结果完全不一样。原因是随机数种子没设或并行池里的 worker 随机流不同步。解决在 main.m 开头设置rng(2024)确定性的结果有利于复现写专利申请或论文时尤其重要如果开了 parfor需用RandStream设置每个 worker 的独立子流或者简单粗暴一点实验时关闭并行。现象五新增问题数据后main.m 报“Undefined function”。原因是新问题子目录没添加到 MATLAB 的搜索路径或者直接双击运行 .m 时当前目录在别的文件夹。解决addpath(genpath(pwd))这行必须放在 main.m 的第一行而且每次调整目录结构后重新执行一遍 pur 清除路径缓存。6. 二次开发把 PSO 改造成带局部搜索的混合算法引擎仓库本身已经能解决不少问题但真正让它发挥价值的是把它改造成一个混合算法基座。我在实际项目中做得最多的一件事就是在 PSO 迭代到中后段时把当前的全局最优gbest传到局部搜索算子做进一步精调比如 TSP 里的 2-opt 局部边交换。改造成本不高效果却很显著52 城市 TSP 的最终解能再优化 3% 到 4%。改造的第一步是写一个干净的 solver 接口。我会把主循环抽成一个叫RunHybridPSO.m的独立函数不依赖 main.m 里的全局变量。这样后续可以直接在命令行批量调用配合脚本跑参数网格搜索不用每次修改配置都打开 main.m。function result RunHybridPSO(problem, params) % 在标准 PSO 主循环里每隔 5 代对 gbest 做一次局部搜索 for iter 1:params.maxIter % ... 标准 PSO 更新速度、位置、pbest、gbest ... if mod(iter, 5) 0 ... isfield(problem, local_search) ~isempty(problem.local_search) gbest.position feval(problem.local_search, gbest.position, problem); gbest.cost feval(problem.Fitness, gbest.position, problem); end end end加局部搜索时最忌“全量无脑接入”。我最早在这个框架里加 2-opt 时对每一代所有粒子都做一次局部搜索运行时间从 40 秒涨到 6 分钟收益却几乎没有。改成只对gbest每 5 代做一次之后时间成本才和精度提升取得平衡。从那以后我每次给进化计算加算子都先跑一遍“间隔代数与最终精度”的关系曲线再决定要不要全量接入。局部搜索算子本身的实现不算复杂核心是对gbest的路线任意选两段翻转后比较大小如果新路线更短就替换。如果你要接别的局部搜索比如模拟退火只要把它也写成一个“输入 position 和 problem输出新 position”的函数就能复用上面这套逻辑。后端接好之后验证方法建议做 10 次独立重复实验记录目标函数均值、标准差和运行时长和原始 PSO 做对比用 t 检验判断提升是否显著。这套验证流程是我做改造后必走的惯例因为加算子有时只是“恰好在该问题上有效”多跑几遍才能确认不是随机扰动。这个仓库设计的巧妙之处在于它把“算法”和“问题”拆成了两个维度你改任何一个维度都不影响另外一个。想做新算法就照 PSO.m 的骨架复制一份改更新公式想解决新领域问题就在 problems 目录下新建子目录写自己的 Data 和 Fitness。我把这套结构迁移到过调度排产和路径优化两个场景都只改了 problems 层算法层一次没动过。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站