
简介NSGA-II算法的Matlab实现面向多目标进化计算初学者与科研人员提供一套模块化、易运行的代码骨架。实现中包含非支配排序、拥挤距离赋值、精英策略保存以及遗传操作等关键环节主程序与各功能函数分离逻辑清晰读者可单独调试每个模块也可以替换测试函数研究不同问题下的算法表现。测试函数覆盖二维ZDT1至ZDT6和三维DTLZ1至DTLZ6共十二个典型算例每个算例均附带测试数据文本与fig仿真图便于对照分析Pareto最优解的分布与收敛过程从二维到三维问题难度递进适合循序渐进验证算法扩展能力。压缩包共27个文件其中10个m代码文件负责核心计算10个txt文件提供标准输入数据7个fig文件存储实验结果图像整体仅2.41MB轻量且便于携带。这项工作已被3208人浏览学习适合用于NSGA-II原理验证、毕业设计参考和新型多目标算法的对比测试代码风格简明注释清晰便于二次开发。1. NSGA-II的Matlab实现多目标优化里被验证最多次的框架之一NSGA-II多目标优化框架核心三板斧是非支配排序、拥挤距离与精英策略这套组合在工程和学术场景里被反复验证了十几年。如果你手里有一个问题存在多个互相冲突的目标——比如成本要低、交付要快、可靠性还要高传统加权求和只能给一个解而NSGA-II一次给出一整条Pareto前沿让你在结果里挑最优折中。这篇笔记面向两类人刚接触多目标优化的新手需要一份能跑通全流程的Matlab参考实现以及已跑过代码但被参数和边界坑折腾过的熟手想快速定位问题出在哪个算子。我拆过的项目里凡是涉及路径、调度、参数整定、资源配置的场景NSGA-II基本都能当主力算法用跑通了再换成别的算法也不迟。2. 核心模块拆解非支配排序、拥挤距离与精英策略的协作逻辑2.1 非支配排序到底在排什么多目标优化和单目标最大的区别是没有唯一的“最好”只有“不被别人支配”。个体 p 支配 q意味着 p 在所有目标上都不比 q 差且至少在一个目标上严格优于 q。比如最小化成本的同时最小化延误时间方案 A 成本 100、延误 2 小时方案 B 成本 120、延误 3 小时那 A 直接支配 BB 可以扔了。但 A 成本 100、延误 3 小时B 成本 120、延误 2 小时两者各赢一项互不支配都该保留。非支配排序就是把种群按“谁支配谁”分成一层层的前沿。第一层是所有不被任何个体支配的解第二层是去掉第一层后剩下的不被任何剩余个体支配的解以此类推。层数越靠前解的质量越高。实现时先两两比较 N 个个体给每个个体记录两个信息n_p 表示有多少个体支配它S_p 表示它支配了哪些个体。function [fronts, rank] non_dominated_sort(f) N size(f, 1); M size(f, 2); n_p zeros(N, 1); % 被支配计数 S_p cell(N, 1); % 支配集合 fronts cell(N, 1); % 存放各前沿个体索引 rank zeros(N, 1); for i 1:N for j i1:N if all(f(i,:) f(j,:)) any(f(i,:) f(j,:)) S_p{i} [S_p{i}, j]; n_p(j) n_p(j) 1; elseif all(f(j,:) f(i,:)) any(f(j,:) f(i,:)) S_p{j} [S_p{j}, i]; n_p(i) n_p(i) 1; end end end front_idx 1; for i 1:N if n_p(i) 0 fronts{front_idx} [fronts{front_idx}, i]; rank(i) front_idx; end end while ~isempty(fronts{front_idx}) next_front []; for i fronts{front_idx} for j S_p{i} n_p(j) n_p(j) - 1; if n_p(j) 0 next_front [next_front, j]; rank(j) front_idx 1; end end end front_idx front_idx 1; fronts{front_idx} next_front; end fronts( front_idx:end ) []; end逻辑上整个排序分三步先两两比较得到 n_p 和 S_p再找出 n_p 为 0 的第一层个体然后迭代式地从第一层出发逐个访问它的支配集合被支配计数减到 0 就划入下一层。关键点在于每一层都是“当前剩余个体中的非支配集”而不是全局的。复杂度方面最坏情况是 O(MN²)M 是目标个数N 是种群大小。三个目标以内跑起来没什么压力五目标以上每代排序的开销就明显涨上去了这也是为什么高维问题要换 NSGA-III 而不是硬扛 NSGA-II。2.2 拥挤距离保证Pareto前沿分布均匀的唯一手段非支配排序只解决了“分层”没解决“同一层里留谁”。同一层的个体质量等价但如果随意选前沿会挤在一块覆盖不到两端。拥挤距离做的事很简单对同一非支配层的个体按每个目标函数分别排序给每个个体算它两边邻居的距离边界个体的距离直接设成无穷大。function dist crowding_distance(front, f) n length(front); M size(f, 2); dist zeros(1, n); for m 1:M f_m f(front, m); [f_sorted, idx] sort(f_m); dist(idx(1)) inf; dist(idx(end)) inf; f_range f_sorted(end) - f_sorted(1); for k 2:n-1 if f_range 0 dist(idx(k)) dist(idx(k)) ... (f_sorted(k1) - f_sorted(k-1)) / f_range; end end end end这里有两个容易忽略的设计细节一是边界个体距离设无穷大这保证了前沿的两个端点一定会被保留下来否则每次迭代两端都会被内部个体挤掉前沿越缩越窄二是每个目标单独归一化后再累加距离f_range 除以目标实际取值范围避免量纲差异——比如成本是万元级、时间是分钟级如果不归一化成本目标的间距会完全淹没时间目标的信息。我一般会在排序后把每层的最优个体单独打印出来看一眼如果边界个体频繁消失第一反应就是检查拥挤距离的 inf 赋值是否生效。2.3 精英策略父代子代合并后怎么选下一代单目标遗传算法里精英策略通常是“保留历史最优解”。但在多目标里只保留一个最优解没有意义你得保留一整条有代表性的前沿。NSGA-II 的做法是每代生成子代后把父代 N 个个体和子代 N 个个体合并成 2N 的候选池再对这个池子做一次非支配排序和拥挤距离计算然后从第一层开始依次装填下一代同一层内按拥挤距离从大到小取直到填满 N 个。这样做的价值在于父代里哪怕有个解在前沿上位置很偏、暂时没有在遗传操作中被选中只要它仍然非支配合并后依然有机会被保留。多目标优化里丢失一个折中解比单目标丢失一个最优解代价更大。因为没有“全局最优”兜底丢失的解可能代表某个实际决策偏好比如成本低但时间长的方案在客户只关心成本时就是金矿。精英策略也直接影响收敛速度和稳定性的平衡。合并后重排序会让种群淘汰更保守代价是每代排序的计算量翻倍。工程上为了性能有的人会改成“父代直接保留前k%子代占剩余槽位”这种简化在目标数少时可行但我调试过的几个项目里一旦出现前沿分布崩坏大多是因为这种偷懒省掉了合并重排。3. Matlab代码实战从主循环到选择、交叉与变异算子3.1 工程目录与文件结构拿到 NSGA-II 的 Matlab 代码包先别急着跑把文件结构理一遍更稳妥。一个能长期复用的实现至少应该拆成六个文件每个文件只干一件事后面替换目标函数、调整算子都方便nsga2_matlab/ ├─ main.m % 主循环与全部参数设置 ├─ evaluate_objective.m % 目标函数替换成实际问题即可 ├─ non_dominated_sort.m % 非支配排序 ├─ crowding_distance.m % 拥挤距离计算 ├─ tournament_select.m % 锦标赛选择 └─ genetic_operator.m % SBX交叉 多项式变异我不太建议把所有逻辑写进一个 main.m 里跑通就完事头两版能跑第三版开始改参数、换目标函数时你会发现自己陷进一个黑匣子改一处判断就要通读全文件。3.2 参数初始化每一个值都有它的出处先贴一组用于中等规模问题的默认参数十变量级、两个目标都能用% main.m 参数区 N 50; % 种群大小10变量问题建议 40~100 gen 200; % 最大迭代代数先跑到200观察收敛曲线 pc 0.9; % 交叉概率实数编码常用0.9别低于0.8 n_var 10; % 决策变量个数 pm 1 / n_var; % 变异概率常用 1/变量数 eta_c 20; % SBX扩展指数控制子代离父代的距离 eta_m 20; % 多项式变异分布指数 lb zeros(1, n_var); % 决策变量下界 ub ones(1, n_var); % 决策变量上界N 和 gen 是最容易拍脑袋设的两个数。N 太小前面说过多样性撑不起来N 太大每代排序和评估的耗时线性上涨很多真实工程里评估目标函数要跑仿真一次几秒N 设 200 就意味着一代最坏要跑 400 次仿真。pm1/n_var 来自遗传算法常用的经验规则变量数越多基因越容易被打乱变异概率应随维度升高而降低。eta_c 和 eta_m 这两个指数我后面单独讲它们直接决定子代偏离父代的幅度。3.3 主循环选择、交叉、变异、合并周而复始% 初始化均匀随机生成第一代种群 pop rand(N, n_var) .* (ub - lb) lb; f zeros(N, 2); for i 1:N f(i, :) evaluate_objective(pop(i, :)); end [fronts, rank] non_dominated_sort(f); crowd zeros(N, 1); for k 1:length(fronts) crowd(fronts{k}) crowding_distance(fronts{k}, f); end for t 1:gen % 锦标赛选择选出父代配对池 [parent_idx, ~] tournament_select(rank, crowd, N); % 遗传算子生成子代 [child_pop, child_f] genetic_operator(... pop, parent_idx, f, lb, ub, pc, eta_c, eta_m); % 精英合并父代 子代 2N pop_all [pop; child_pop]; f_all [f; child_f]; % 对合并种群重排 [fronts, rank] non_dominated_sort(f_all); crowd zeros(2*N, 1); for k 1:length(fronts) crowd(fronts{k}) crowding_distance(fronts{k}, f_all); end % 填装下一代从第一层开始取满 N 个 next_pop []; k 1; while length(next_pop) length(fronts{k}) N next_pop [next_pop, fronts{k}]; k k 1; end % 最后一层按拥挤距离降序补足 if length(next_pop) N last fronts{k}; last_crowd crowd(last); [~, idx] sort(last_crowd, descend); next_pop [next_pop, last(idx(1:N-length(next_pop)))]; end pop pop_all(next_pop, :); f f_all(next_pop, :); end注意几个工程细节。首先是 tournament_select 的输入用的是 rank 和 crowd而不是原始目标函数值。非支配层相当于“排序优先级”拥挤距离相当于“同一优先级内的多样度”这两者组合出的虚拟适应度比任何加权评分都更客观。其次是子代种群数量必须等于父代种群数量 N这样才能保证合并后是 2N否则填装下代时“最后一层补足”的分支逻辑会翻车。最后是比赛选择每次从种群中随机抽两个个体选 rank 更靠前或同层拥挤距离更大的那个进配对池tournament size 保持 2 别加大加大会让最优个体被频繁选中种群多样性下滑。3.4 SBX与多项式变异这两个算子决定子代长什么样SBX模拟二进制交叉虽然名字带“二进制”实际处理的是实数编码。它的核心是生成一个随机数 u再映射成扩展系数 λλ 决定子代相对父代的位置。公式里的 η_c 越大λ 越接近 1子代就越贴近父代均值搜索越保守。function [c1, c2] sbx(p1, p2, lb, ub, eta_c) u rand; if u 0.5 lambda (2 * u)^(1 / (eta_c 1)); else lambda (1 / (2 * (1 - u)))^(1 / (eta_c 1)); end c1 0.5 * ((1 lambda) * p1 (1 - lambda) * p2); c2 0.5 * ((1 - lambda) * p1 (1 lambda) * p2); % 越界裁剪直接收紧到边界 c1 min(max(c1, lb), ub); c2 min(max(c2, lb), ub); end多项式变异类似通过随机数 u 计算扰动幅度 delta然后对单个解的分量做扰动function c poly_mutate(p, lb, ub, eta_m) u rand; if u 0.5 delta (2 * u)^(1 / (eta_m 1)) - 1; else delta 1 - (2 * (1 - u))^(1 / (eta_m 1)); end c p delta .* (ub - lb); c min(max(c, lb), ub); end两个算子的细节是同一个坑分母上的 eta_c1 和 eta_m1 一旦漏掉 1指数会大得夸张子代直接飞越整个搜索空间结果全是边界值。我见过不止一次有人在手写版本里把 (2u)^(1/(eta_c1)) 误写成 (2u)^eta_c导致前几代种群就糊到边界上。越界处理用 min/max 裁剪是工程上最省心且稳定的做法不要在这里做复杂的反射或重新采样裁剪后靠变异去恢复多样性即可。4. 参数怎么调才不翻车目标函数封装与六组参数经验值4.1 先把目标函数封装成标准接口拿到代码包后第一件事是换目标函数。NSGA-II 的目标函数接口约定很简单输入一行决策变量 x输出一行目标函数值 f每个目标对应一列。所有目标默认按最小化处理如果你的实际指标是“利润最大化”直接取负号别在排序代码里额外做反转那会引入不必要的混乱。function f evaluate_objective(x) % 输入x 为 1 x n_var 的行向量 % 输出f 为 1 x M 的行向量M 为目标个数 % 示例成本函数与时间函数 cost sum(x.^2) 2; time (x(1) - 1)^2 sum(x(2:end).^2) 1; f [cost, time]; end注意 constraints 的处理。标准 NSGA-II 的排序逻辑不管约束它只看目标函数值。带约束的问题最常见的做法是罚函数法把违反约束的程度乘以一个足够大的惩罚系数加进对应目标值里。工程上我习惯在 evaluate_objective 内部做约束判断违反约束时直接把该目标函数值置为一个非常大的数让它在非支配排序中被自然淘汰。但罚系数不要设成 NaN 或 InfNaN 会污染整个排序链Inf 在边界判断里也可能引发意外。4.2 参数表与调整方向参数建议起点调大/调小的实际影响什么情况动它种群大小 N50N 增大前沿更密但每代评估耗时线性上涨前沿点稀疏、分布断档时加大到 80~100迭代代数 gen200太小收敛不完整前沿离真解远观察前沿每 50 代还在明显移动加到 500交叉概率 pc0.9小于 0.8 时新解产生慢种群容易被旧的局部解占据默认保持 0.9一般不用动变异概率 pm1/n_var过大导致解像随机噪声过小导致早期陷入局部前沿保持 1/n_var变量维度特别低时可放宽SBX指数 eta_c20越大子代越贴近父代探索范围变小前沿覆盖范围差降到 10~15 提升探索变异指数 eta_m20越大变异扰动越小局部细化更好收敛末期前沿粗糙提高到 50 做精细搜索eta_c 和 eta_m 是最值得花时间调的两个参数但我建议不要一开始就调它们。先固定成默认值把 N 和 gen 调到位再说。多目标优化里的参数耦合效应很强N 不够大时你把 eta_c 调得再低也没用种群就那么点个体探索范围再大也覆盖不出完整前沿。反过来 N 太大而 eta_c 过小每一代都像重新初始化收敛速度会拖到让人怀疑人生。4.3 决策变量边界与量纲归一化决策变量边界处理是最容易被忽略的工程障碍。假设你的问题里有三个变量第一个取值范围 [0, 1]第二个 [500, 5000]第三个 [0.01, 0.1]。SBX 和多项式变异的扰动幅度是按变量绝对尺度算的。eta_c20 产生的 λ 变化范围在 0.5 到 2 之间对第一个变量可能扰动 0.5对第二个变量扰动 2500对第三个变量扰动半个数量级。结果就是大尺度变量主导整个寻优过程小尺度变量几乎原地不动。我一般会建议在算法内部统一搜索 [0, 1] 区间评估时才映射到真实范围% 搜索空间统一在 [0,1] x_norm pop(i, :); % 评估前映射到真实边界 x_real lb x_norm .* (ub - lb); f(i, :) evaluate_objective(x_real);这段映射逻辑放在主循环的评估位置而交叉变异仍然对 x_norm 操作。这样所有决策变量在遗传算子眼里都是同一量纲SBX 的扩展系数对所有变量施加相同比例的相对扰动。4.4 从单目标思维里跳出来为什么加权求和不行很多第一次接触 NSGA-II 的人会问我为什么不用权重把多个目标加起来一次跑出一个解在目标之间量纲一致且偏好明确的前提下加权求和确实省事但真实工程里目标往往不可公度——成本是钱时间是小时可靠性是概率。权重怎么定本身就是另一个难题而且加权求和的致命弱点是当Pareto前沿是凹的加权法无论如何组合权重都找不到前沿中间段的解这是运筹学里被反复验证过的结论。NSGA-II 的价值是给了你一整条前沿之后你再根据业务偏好做决策。后面第 6 章我会讲如何从 Paret解集中选出最终的那个解。理解了这个转变你就不会在调试时问出“为什么最优解不唯一”这种问题了。5. 常见问题排查四条血泪踩坑记录5.1 坑一迭代没几代种群全挤成一团现象Pareto 前沿上的点明显聚集在中部两端稀疏甚至没有点种群多样性下降得特别快。原因最常见的是 SBX 交叉的指数公式实现错误。把 (2u)^(1/(eta_c1)) 写成了 (2u)^(eta_c1)λ 要么极大要么极小子代直接跳到父代两边很远的区域前几代看着探索得开但下一代再交叉时这些边界解被排序又淘汰种群迅速萎缩。另一个高频原因是锦标赛选择的 tournament size 被设成大于 2比如取 4 甚至直接取全局最优导致高 rank 个体被反复选中子代全是它们的后代。解决先用最简单的双变量测试函数跑一遍每次迭代把种群分布画出来。交叉算子正确的情况下前 50 代前沿会从散点逐步收拢如果前 10 代就收成一团基本可以断定是 λ 公式写错。重写时按我第 3 章的 sbx 函数对照一遍尤其注意 u 的随机性——每个子代分量要重新生成 u不能一次性用同一个 u 给所有维度。5.2 坑二Pareto前沿两端永远塌陷现象最后求出的前沿只有中间一段两端缺失看起来像被人从两边各砍了一刀。原因拥挤距离计算里忘给边界个体赋 inf。边界个体的拥挤距离如果不设无穷大它们的距离值可能比内部个体还小在精英合并填装下一代时边界点容易被同层的内部点挤掉一代一代累积两端越来越空。解决crowding_distance 函数里对每目标排序后的第一个和最后一个索引直接赋值 inf。还有另一个细节如果某个目标的 f_range 接近 0说明种群在这个目标上几乎没有差异此时该目标的拥挤距离贡献为 0跳过即可不要硬除零。我一般会加一个阈值判断if f_range 1e-12 dist(idx(k)) dist(idx(k)) ... (f_sorted(k1) - f_sorted(k-1)) / f_range; end5.3 坑三目标函数返回 NaN排序结果像随机数现象偶尔迭代到某几代Pareto 前沿上出现完全孤立于整体的点或者所有个体突然都排到第一层怎么看都像是排序逻辑出错。原因目标函数 evaluate_objective 内部出现了除零、log(0)、sqrt(负数) 等情况返回 NaN 或 Inf。非支配排序里任何包含 NaN 的比较结果都是 false导致个体的 n_p 计数统计不准确整个分层逻辑失真。最典型的触发场景是决策变量越界——某个变量经过 SBX 或变异后跑出定义域目标函数里的中间运算直接崩溃。解决三个层面同时设防。第一交叉变异后立刻做边界裁剪代码里已经有的 min/max 必须保留第二evaluate_objective 内部对所有风险运算做防御出现非法值就返回一个超大惩罚数比如 1e10第三主要排错时在评估函数入口加一句断言if any(isnan(x)) error(决策变量包含 NaN); end这三层全上90% 以上的 NaN 翻车都能拦下来。剩下的 10% 是在目标函数内部生成 NaN那只能靠打印 f 矩阵来定位了。5.4 坑四跑了 500 代前沿还是离真实前沿很远现象算法没有崩收敛曲线也在下降但求出的前沿始终没有贴近理论Pareto前沿差着一截距离。原因最常见的是种群初始化范围太小。如果初始种群本来就覆盖不到前沿附近比如真实最优解在 [0.8, 0.9] 区间而初始化时变量只随机生成在 [0.1, 0.3] 范围那 500 代很难靠交叉和变异爬过去。另一个原因是交叉概率 pc 被手动降到了 0.5 以下新解产生速度太慢整个种群陷在初始区域里慢慢磨。解决把初始化改成在完整决策变量边界内均匀随机采样这是最基本的后悔药。其次检查 pc实数编码问题保持 0.9 附近不要因为怕丢失优秀父代而调低——精英合并机制已经在保护父代了交叉率低只会拖慢探索速度。另外我有个习惯每迭代 100 代把种群中 2%~5% 的个体重新随机初始化让一小撮新鲜血液反复扰动种群这招对摆脱局部前沿效果很明显代价是前沿可能会短暂抖动但整体收敛路径会稳健得多。还存在一个隐蔽情况gen500 对于多峰问题可能真的不够。判断方法是看每 50 代前沿变化的幅度如果最近 100 代前沿几乎不移动再加大代数也没有意义反而是上面几个原因之一。6. 验证与进阶用ZDT1测试算例收尾再看最终决策怎么选6.1 用 ZDT1 做回归测试拿到任何 NSGA-II 代码先别急着上你的实际问题用 ZDT1 测试函数跑一遍确认框架本身没毛病。ZDT1 的双目标定义很经典第一目标取第一个变量本身第二目标由其余变量构造理论Pareto前沿是 f2 1 - sqrt(f1)。这样你一眼就能看出算法有没有逼近真实前沿。function f evaluate_objective(x) n length(x); g 1 9 * sum(x(2:n)) / (n - 1); f(1) x(1); f(2) g * (1 - sqrt(x(1) / g)); end跑完后把结果和真实前沿叠画plot(f(:,1), f(:,2), o, MarkerSize, 4); hold on; f1_true 0:0.01:1; f2_true 1 - sqrt(f1_true); plot(f1_true, f2_true, -, LineWidth, 1.5); hold off; legend(NSGA-II结果, 真实前沿);两个目标、30 变量以内ZDT1 跑 200 代结果应该和真实前沿贴合得很好。如果这一步就翻车问题先出在框架别急着怪你的业务模型。6.2 从前沿到最终决策TOPSIS 与偏好选择算法交给你几十个非支配解实际工程还要拍板选一个。我习惯的做法是先看各目标量纲然后对每个解算到理想点的归一化距离或者直接用 TOPSIS确定每个目标的正理想解和负理想解给目标按业务重要性配权重算每个解的相对贴近度取最大者。这一步本质是把第 4 章舍弃的“权重”重新捡回来——区别是现在权重只用于最终决策不参与寻优过程不会影响 Pareto 前沿的完整性。从那以后我每次拿到一个多目标项目都会强制自己走一遍先用 ZDT1 验证框架再换真实目标函数跑完不急着选解先看前沿形状再做 TOPSIS 决策。这套流程帮我挡掉了不少翻车现场希望帮到你。本文还有配套的精品资源点击获取