MATLAB实现NSGA-II多目标优化算法:非支配排序与拥挤距离详解
2026/9/9 21:59:35 网站建设 项目流程

简介:MATLAB环境下的NSGA-II算法实现,面向需要解决多目标优化问题的科研与工程人员。资源以中文注释详细拆解了非支配排序遗传算法第二代的完整流程,包括种群初始化、非支配排序、拥挤距离选择、精英保留策略、交叉变异等核心模块,并附带自适应变异、正态分布标准差等改进对比实验,便于理解算法原理与调参思路。资源共20个文件,以11个m源码文件为主,涵盖主程序、遗传算子、非支配排序、目标函数评估等关键函数;另有7个bmp结果图直观展示收敛分布与改进效果,1个xls保存实验数据,1个txt提供运行说明。压缩包整体2.44MB,轻量易部署。目前已有20880人学习下载,口碑验证。通过运行代码可观察算法在特定问题上的帕累托前沿演化,同时中文注释极大降低了上手门槛,适合作为多目标优化入门及二次开发的参考脚本。 先小吐槽一下,标题里的 MTALAB 我默认是按 MATLAB 来读。最近好几个人拿着“MTALAB NSGA2算法”这个关键词来问,说网上讲 NSGA2 概念的 PPT 一堆,但真正能在 MATLAB 里跑出 Pareto 前沿、能直接拿来改的代码特别少。这篇文章就按我自己的实现习惯,把 NSGA-II 从原理到代码,再到调参和踩坑完整讲一遍,并以 ZDT1 测试函数做例子。适合课程作业、论文仿真和工程项目起步参考,不需要额外工具箱,基础版 MATLAB 就能跑。

1. NSGA-II 的核心思路,为什么值得在 MATLAB 里手写一遍

1.1 多目标优化问题的本质:没有唯一最优解

单目标优化求的是一个最小值或最大值,多目标优化不一样。比如设计一个结构,想同时让重量最轻、刚度最大、成本最低,这几个目标往往是冲突的:重量轻了成本可能上去,刚度大了重量也可能上去。最终得到的不该是一个点,而是一组解,这组解里没有一个解能在所有目标上同时碾压其他解。

这里要引出支配关系:如果一个解 A 在所有目标上都不比解 B 差,且至少有一个目标严格优于 B,就说 A 支配 B。所有不被其他解支配的解,构成第一层非支配前沿,也就是 Pareto 前沿。NSGA-II 的目标,就是尽量得到一组分布均匀、覆盖面广的 Pareto 前沿解,而不是只盯着某一个目标。

1.2 两个核心机制:非支配排序和拥挤距离

NSGA-II 名字里的 Non-dominated Sorting Genetic Algorithm,核心就是“非支配排序”。它把种群里的个体按支配关系分成好几层:第一层是当前种群的非支配解,第二层是去掉第一层后剩余个体里的非支配解,以此类推。优化过程中,越靠前的层意味着质量越高,应该优先保留。

但光分层还不够。同一层里可能有几十个个体,到底留谁?如果随便选,最后结果容易挤在 Pareto 前沿的某一小段上。NSGA-II 用拥挤距离来解决:把某个个体前后相邻个体在每个目标上的距离归一化后累加,距离越大说明这个解周围越空,越值得保留。简单说,就是保证解的多样性,别让它们全挤一块。

和第一代 NSGA 比,NSGA-II 最大的改进是去掉了共享参数,引入了拥挤距离和精英保留机制。快速非支配排序的复杂度是 O(MN²),M 是目标数,N 是种群规模,在常见规模下完全能接受。也正因为这两个机制都很清楚,用 MATLAB 从头实现一遍一点也不难。

1.3 为什么选 MATLAB 实现

MATLAB 做这个事最大的优势是矩阵思维。一个种群 N 个个体、每个个体有 dim 个决策变量,直接用一个 N×dim 矩阵存,目标值用一个 N×M 矩阵存,处理起来非常顺手。排序、比较、索引都有一堆现成函数,画图也方便,算完一个 plot 就能看到 Pareto 前沿。

还有一个现实好处:不需要安装额外工具箱。网上关于 MATLAB 工具包的讨论很多,什么下载安装、工具箱激活、版本密钥之类的,搞得很复杂。但手写 NSGA-II 只需要基础 MATLAB 环境,连 Optimization Toolbox 都不需要,核心算法代码自己写了反而更可控。

2. 环境准备与主循环框架

2.1 基础环境要求

建议用 R2016b 及以上版本,因为从这一版开始,脚本文件里可以写局部函数,方便把所有代码放在一个文件里。如果版本更老,就把每个函数单独存成 .m 文件,名称和函数名保持一致。

自定义函数我建议这样组织:

  • evaluate_zdt1.m:计算目标函数
  • non_dominated_sort.m:快速非支配排序
  • crowding_distance.m:计算拥挤距离
  • sbx_crossover.m:模拟二进制交叉
  • poly_mutation.m:多项式变异

主脚本负责初始化、循环和结果展示。这样结构清晰,改起来也方便。

2.2 NSGA-II 主循环逻辑

主循环其实不复杂,每次迭代做这几件事:

  1. 从当前种群中通过锦标赛选择选出父代
  2. 对父代做交叉和变异,生成子代种群
  3. 把父代和子代合并成一个大小为 2N 的种群
  4. 对合并后的种群做非支配排序
  5. 计算每个个体的拥挤距离
  6. 按“非支配层优先、同层拥挤距离大的优先”保留前 N 个个体

这一步“合并父代和子代再筛选”就是精英保留机制。最优秀的解不会在进化中丢掉,这是 NSGA-II 不容易退化的关键原因。

3. 关键代码实现与核心环节拆解

3.1 测试问题:ZDT1

讲代码前先选一个标准的测试函数。ZDT1 是双目标测试问题,决策变量维度可以设成 30,所有变量范围都在 [0,1] 之间。它的目标是:

  • f1(x) = x1
  • f2(x) = g(x) * (1 - sqrt(x1 / g(x)))
  • 其中 g(x) = 1 + 9 * sum(x2 到 xdim) / (dim - 1)

ZDT1 的解析 Pareto 前沿是 f2 = 1 - sqrt(f1),适合用来验证算法写没写对。

下面是目标函数代码:

function fit = evaluate_zdt1(pop, dim) N = size(pop, 1); fit = zeros(N, 2); for i = 1:N x = pop(i, :); g = 1 + 9 * sum(x(2:dim)) / (dim - 1); fit(i, 1) = x(1); fit(i, 2) = g * (1 - sqrt(x(1) / g)); end end

3.2 非支配排序和拥挤距离的实现

快速非支配排序我习惯用“支配计数 + 支配集合”的方式。对每个个体 i,记录有多少个体支配它,以及它支配了哪些个体。先找出所有计数为 0 的个体作为第一层,然后遍历第一层里的每个个体,把它支配集合里那些个体的计数减 1,减到 0 就归入下一层。

实现如下:

function [rank, F] = non_dominated_sort(fit) N = size(fit, 1); S = cell(N, 1); n = zeros(N, 1); for i = 1:N for j = 1:N if i == j continue; end if dominates(fit(i, :), fit(j, :)) S{i}(end + 1) = j; elseif dominates(fit(j, :), fit(i, :)) n(i) = n(i) + 1; end end end rank = zeros(N, 1); F = {}; front = []; for i = 1:N if n(i) == 0 rank(i) = 1; front(end + 1) = i; end end F{1} = front; k = 1; while ~isempty(F{k}) Q = []; for i = F{k} for j = S{i} n(j) = n(j) - 1; if n(j) == 0 rank(j) = k + 1; Q(end + 1) = j; end end end k = k + 1; F{k} = Q; end F(end) = []; end function isDom = dominates(a, b) isDom = all(a <= b) && any(a < b); end

拥挤距离的计算要分目标做。对每一层里的个体,按某个目标值排序,排序后首尾两个个体的距离直接设为无穷大,保证边界解一定能留下来。中间个体的距离用相邻两个个体的目标差除以该目标的最大最小值差,再累加所有目标的贡献。

function cd = crowding_distance(fit, F) n = size(fit, 1); cd = zeros(n, 1); for i = 1:numel(F) inds = F{i}; m = numel(inds); if m <= 2 cd(inds) = inf; continue; end for obj = 1:size(fit, 2) [~, order] = sort(fit(inds, obj)); sorted = inds(order); cd(sorted(1)) = inf; cd(sorted(end)) = inf; fmin = fit(sorted(1), obj); fmax = fit(sorted(end), obj); if fmax == fmin continue; end for k = 2:m - 1 cd(sorted(k)) = cd(sorted(k)) + ... (fit(sorted(k + 1), obj) - fit(sorted(k - 1), obj)) / (fmax - fmin); end end end end

3.3 主循环、交叉和变异

主程序里我用了一个简化版锦标赛选择:随机取两个个体,谁支配谁就选谁;如果互不支配,就随便取第二个。正式项目中这一步建议改成“先看非支配层,层数小的胜出;同层再比拥挤距离”。教学版这样写已经能跑,但别直接套到论文里。

完整主脚本如下:

clear; clc; N = 100; % 种群规模 maxGen = 200; % 进化代数 dim = 30; % 决策变量维数 lb = zeros(1, dim); % 下界 ub = ones(1, dim); % 上界 pc = 0.9; % 交叉概率 pm = 1 / dim; % 变异概率 etaC = 15; % 交叉分布指数 etaM = 20; % 变异分布指数 pop = lb + rand(N, dim) .* (ub - lb); fit = evaluate_zdt1(pop, dim); for gen = 1:maxGen offspring = zeros(N, dim); for i = 1:N p = randi([1 N], 1, 2); if dominates(fit(p(1), :), fit(p(2), :)) p1 = pop(p(1), :); else p1 = pop(p(2), :); end p = randi([1 N], 1, 2); if dominates(fit(p(1), :), fit(p(2), :)) p2 = pop(p(1), :); else p2 = pop(p(2), :); end if rand < pc [offspring(i, :), ~] = sbx_crossover(p1, p2, lb, ub, etaC); else offspring(i, :) = p1; end offspring(i, :) = poly_mutation(offspring(i, :), lb, ub, etaM); end allpop = [pop; offspring]; allfit = [fit; evaluate_zdt1(offspring, dim)]; [~, F] = non_dominated_sort(allfit); cd = crowding_distance(allfit, F); order = []; for k = 1:numel(F) [~, idx] = sort(cd(F{k}), 'descend'); order = [order, F{k}(idx)]; end pop = allpop(order(1:N), :); fit = allfit(order(1:N), :); end [~, F] = non_dominated_sort(fit); pareto = fit(F{1}, :); figure; plot(pareto(:, 1), pareto(:, 2), 'o'); xlabel('f1'); ylabel('f2'); title('NSGA-II on ZDT1'); grid on;

交叉算子用模拟二进制交叉 SBX,它的思路是用一个随机数生成子代与父代之间的分布因子,让子代尽量靠近父代,同时保留一定探索能力。变异用多项式变异,变异幅度由一个分布指数控制。

function [c1, c2] = sbx_crossover(p1, p2, lb, ub, etaC) u = rand(size(p1)); beta = zeros(size(p1)); same = abs(p1 - p2) < 1e-6; beta(same) = 0; idx = ~same & u <= 0.5; beta(idx) = (2 * u(idx)) .^ (1 / (etaC + 1)); idx = ~same & u > 0.5; beta(idx) = (1 ./ (2 * (1 - u(idx)))) .^ (1 / (etaC + 1)); c1 = 0.5 * ((1 + beta) .* p1 + (1 - beta) .* p2); c2 = 0.5 * ((1 - beta) .* p1 + (1 + beta) .* p2); c1 = min(max(c1, lb), ub); c2 = min(max(c2, lb), ub); end
function c = poly_mutation(p, lb, ub, etaM) r = rand(size(p)); c = p; idx = r < 0.5; c(idx) = p(idx) + (ub(idx) - lb(idx)) .* ... ((2 * r(idx)) .^ (1 / (etaM + 1)) - 1); idx = r >= 0.5; c(idx) = p(idx) + (ub(idx) - lb(idx)) .* ... (1 - (2 * (1 - r(idx))) .^ (1 / (etaM + 1))); c = min(max(c, lb), ub); end

运行完主脚本,ZDT1 的前沿点应该大致落在 f2 = 1 - sqrt(f1) 这条曲线上。如果差得很远,优先检查非支配排序和拥挤距离两个函数有没有写错。

4. 参数设置、常见问题与调试技巧

4.1 参数怎么给:不是越大越好

很多新手一上来就把种群规模和迭代次数拉到很大,结果跑半天不出结果。实际上 NSGA-II 的参数有常规范围,够用就行。

参数常用范围说明
种群规模 N50 到 200太小容易早熟,太大会让每次迭代都很慢
迭代次数 maxGen100 到 500可以先跑 100 看趋势,再适当增加
交叉概率 pc0.8 到 1.0太高会影响收敛稳定性,太低探索不够
变异概率 pm1 / dim 左右维度越高,变异概率越低
交叉分布指数 etaC10 到 20控制子代离父代的远近
变异分布指数 etaM20 到 100越大变异幅度越小

交叉概率不一定要设成 1,给一点概率保留父代个体,反而能稳定搜索。变异概率如果设得太大,算法很容易退化成随机搜索。

4.2 常见问题速查

我把自己跑 NSGA-II 时踩过的坑整理成了表格,基本都是刚写完代码最容易碰到的问题。

现象可能原因排查思路
报错“数组索引超出范围”非支配排序返回的前沿层数不够,或者 order 长度小于 N检查 non_dominated_sort 返回的 F 是否包含空层,打印 numel(F) 和 numel(order)
目标值出现 NaN 或 Inf目标函数里有除零、开根号负数检查变量是否越界,比如 ZDT1 里 x1 不能为负,变异后要 clamp 到边界
Pareto 前沿点非常少种群太小,或迭代没收敛增加 N 到 100 以上,增加 maxGen;也可能是拥挤距离没把边界个体设为 inf
前沿点集中在一小段拥挤距离计算有误,或者两个目标数量级差异太大对目标值做归一化后再算拥挤距离
结果和解析前沿差很多交叉、变异参数不合适,或选择压力不足先调 etaC 和 etaM,再把简化版锦标赛替换成“先分层再比拥挤距离”的选择方式

其中“数量级差异”是最容易被忽视的。比如目标一是成本,数值在几万量级,目标二是效率,数值在 0 到 1 之间,拥挤距离几乎会被成本目标主导,效率目标对多样性的贡献被淹没。这种情况下建议先对每个目标做最小最大归一化,再计算拥挤距离。

4.3 结果验证:和解析解对比

ZDT1 的好处是知道理论前沿,拿到结果后直接对比:

hold on; f1 = linspace(0, 1, 100); plot(f1, 1 - sqrt(f1), 'k-', 'LineWidth', 1.5); legend('NSGA-II', 'Pareto optimal front');

如果数值点能比较均匀地贴住这条曲线,说明算法的排序、距离、选择逻辑基本没问题。

想观察收敛过程,可以在主循环里把每一代第一前沿存下来,全部结束后用不同颜色画几代的前沿变化,能直观看到解是怎么一步步往外扩的。这个习惯比只盯最终结果有用得多,因为你能看到算法是否过早停滞。

5. 工程落地中的几个额外建议

如果是做工程项目,不是跑个测试函数就结束,后面还有几件事要做。

约束处理可以先从罚函数开始。把违反约束的量加到目标函数里,实现最快,但罚因子不好调。更稳的做法是在个体比较时加入约束支配:两个解都可行,比较目标;一个可行一个不可行,可行解胜出;两个都不可行,违反程度小的胜出。这个思路和 NSGA-II 本身不冲突,把支配判断加一段约束判断就行,推荐优先尝试。

性能优化方面,如果种群规模和迭代次数都很高,MATLAB 的循环会变慢。可以先学会用 profile 分析瓶颈。通常最后瓶颈在非支配排序的 O(MN²) 循环上。这个时候再用数组操作或者 parfor 并行代替两层 for 循环,不要一开始就追求代码向量化,先把逻辑调对。

多个目标想可视化,目标数超过 3 个就没有直观图形了。这时推荐用 Hypervolume 或者 IGD 这类指标做定量比较,配合平行坐标图看每个解在不同目标上的数值分布。我在实际项目里常用的做法是跑 20 次独立实验,记录每次的 Hypervolume 均值和方差,再和别的算法对比,比只贴一张前沿图有说服力得多。

我实践下来最大的体会是:NSGA-II 的框架不复杂,真正影响结果的是非支配排序和拥挤距离这两个细节。边界个体忘记设成 inf、拥挤距离没做归一化、排序后索引弄错,都会让结果看起来“好像差不多”但实际前沿质量很差。所以建议拿到任何一份参考代码,都先用类似 ZDT1 这种有解析前沿的测试函数验证一遍,再替换成自己的目标函数。这样即使后面改了问题,也能快速定位是自己问题模型出错了,还是算法本身的问题。

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

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

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

立即咨询