资讯详情

NSGA-III多目标进化算法Matlab实现:中文注释版工程应用解析

📅 2026/10/23 12:31:45 | 华诺云谱 👁 阅读
NSGA-III多目标进化算法Matlab实现:中文注释版工程应用解析
简介NSGA-III是一种面向2到3目标优化的多目标进化算法本资源是在MathWorks开源版本基础上逐行添加中文注释的Matlab实现适合正在学习多目标优化、想读懂算法内部机制的初学者和研究人员。包内共16个文件以14个Matlab脚本为主从主程序、测试函数定义到非支配排序、参考点生成、归一化、关联与选择、变异交叉等核心步骤均有对应实现另附原始下载链接与license说明便于回溯对照。资源仅11KB轻量易用已有5056人学习。作者坦承并未完全吃透代码部分难懂处保留空白或问号注释读者可结合源码和阅读笔记一起排查也可在讨论区交流印证理解。对想从调用工具转向理解NSGA-III原理的读者来说这份带问题标记的中文注释版是很好的起点便于按模块逐段精读。1. 当单目标优化只能给你一个“正确答案”你反而不知道怎么选了我之前独立负责某个调度系统的参数优化时需求方说“成本要低时间要短质量还要过得去”我一度把三个目标加权成单目标去跑遗传算法每次只能拿到一个解换一组权重又得出一个不同的“最优”汇报时根本讲不清哪个方案更合理。后来我把多目标进化算法引入进来一次运行直接得到一整组互不支配的候选方案让需求方在帕累托前沿上挑。NSGA-III正是这类算法里最值得研究的实现之一而这份Matlab版本的最大特点是它在每个关键函数里都写满了中文注释把非支配排序、参考点选择、归一化这些原本像黑匣子的环节全部摊开适合想真正读懂并改成自己工程问题的开发者。无论你是第一次接触多目标进化算法还是已经在用NSGA-II想升级这份带中文注释的工程代码都能直接落地。2. 算法定位与演进为什么有了NSGA-II还要NSGA-III2.1 从拥挤距离到参考点NSGA-II在高维目标下的困境NSGA-II大家应该不陌生它的核心思路是快速非支配排序加上拥挤距离比较算子在2到3个目标上表现非常好。但我第一次把NSGA-II拿去做一个5目标的工业调度优化时问题立刻暴露出来拥挤距离在高维目标空间里的分辨能力急剧下降因为帕累托前沿在高维空间中的分布极度稀疏基于邻居距离的密度估计几乎失效种群会过早聚成一团解集的均匀性和覆盖度都变得很差。这就像几个人在空旷广场上量彼此距离怎么量都差不多根本分不出谁周围更拥挤。NSGA-III要做的事就是用一组事先均匀分布的参考点来替代拥挤距离引导搜索朝向理想点即各目标最优值的组合附近的多个方向同时推进。它并不直接计算密度来淘汰个体而是通过“每个参考点周围有几个个体、哪些个体关联到该参考点”来判断当前进化种群是否在某一块区域过密。目标数量变多时参考点在单位超平面上依然能保持相对均匀这就是它能够扩展到许多个目标的原因。2.2 这份Matlab实现里最重要的四个机制这份代码本身是对NSGA-III标准流程的忠实实现我拆完之后认为它是由四个核心机制撑起来的第一快速非支配排序。把种群中的个体划分层级第一层的个体不被任何个体支配第二层只被第一层支配以此类推。注意快速非支配排序与遗传算法里适应度排序完全是两件事前者比较的是多目标向量之间的支配关系后者通常只看单个数值大小。第二参考点生成。在标准NSGA-III里采用Das-Dennis方法在单位单纯形上生成数量均匀分布的参考点参考点个数一般与种群规模保持接近或者略小于种群规模。参考点的两组关键参数是目标个数M和每个目标方向上的等分份数H我用的这份代码里参考点数量通过组合公式直接算出来这个细节在后面调试时非常重要。第三自适应归一化。每次进化迭代时根据当前种群在目标空间的极值点构造一个超平面把所有个体的目标值归一到同一个范围内。这一步如果不做量纲差异大的目标比如成本和时间的数值完全不在一个数量级会让参考点的关联计算失效种群会被量纲大的目标牵着跑。第四基于参考点的环境选择。在“锦标赛选择”后产生的后代与父代合并先照常做非支配排序再从第一层开始往里填填到某一层时放不下全部个体就在这一层里借助参考点选择“走哪几个丢哪几个”目标是让每个参考点附近都尽量保留一个个体得到分布均匀的帕累托前沿。2.3 面向2目标的定位这份代码为什么仍然适合从低维入门标题里提到“2目标”这个关键词在很多人的印象里NSGA-III是为高维目标设计的拿它做2目标似乎大材小用。实际读代码你会发现NSGA-III在2目标下收敛速度快分布均匀性也好而且因为参考点数量可控运行过程比NSGA-II更容易观察和调试。对于刚开始接触多目标进化算法的开发者从一个2目标的ZDT测试函数入手把参考点机制和种群进化过程一行一行读懂之后再扩展到3目标、5目标会顺畅得多。还有一个实际原因很多教材和公开资料里NSGA-II的源码已经泛滥但NSGA-III的Matlab实现质量参差不齐。这份代码给这个经典算法提供了一个“能跑、能改、注释全”的范本尤其适合作为课程设计或论文验证代码的基线版本。3. 核心模块拆解一个种群从初始化到输出帕累托前沿的完整流程3.1 项目文件结构与初始化入口拿到这份Matlab源码先把目录看清楚。常见的工程结构是几个核心脚本加上一个测试函数文件目录结构大致如下NSGAIII_MATLAB/ ├── NSGAIII.m % 主脚本参数设置 主循环 ├── initialize_pop.m % 种群初始化 ├── non_dominated_sort.m % 快速非支配排序 ├── generate_reference_points.m % 参考点生成 ├── adaptive_normalization.m % 自适应归一化 ├── associate_to_reference.m % 个体-参考点关联 ├── niching_selection.m % 小生境选择环境选择 ├── crossover_mutation.m % SBX交叉 多项式变异 ├── evaluate_objective.m % 目标函数定义默认ZDT1 └── plot_pareto.m % 结果绘图我一般建议先看NSGAIII.m主脚本它把算法流程拉成一条线初始化种群进入最大代数循环循环内部依次执行交叉变异、合并父代子代、非支配排序、归一化、关联参考点、环境选择最后输出best_pareto。主循环的核心段是这样的% 主循环generation从1到max_gen for gen 1 : max_gen % 通过锦标赛选择 交叉变异生成子代种群 offspring crossover_mutation(population, params); % 将父代与子代合并合并后的种群是两倍大小 combined [population, offspring]; % 对合并种群做快速非支配排序得到分层结果 [fronts, ~] non_dominated_sort(combined); % 自适应归一化计算理想点、极值点、超平面截距 normalized_pop adaptive_normalization(combined); % 将每个个体关联到距它最近的参考点 [associate_idx, distance] associate_to_reference(normalized_pop, params.ref_points); % 环境选择从合并种群中挑出pop_size个个体进入下一代 population niching_selection(combined, fronts, associate_idx, distance, params); end这个主循环本身没有复杂语法但每一句背后都是一整个模块。params.ref_points是在主脚本开头用generate_reference_points预生成好的整个进化过程里参考点固定不变这是NSGA-III标准做法的关键——参考点只用于引导个体分布不会随着进化而移动。如果你想让它在特定问题上表现更好可以考虑以参考点作为固定锚、让种群逼近它们这是后话第三章后面几节会逐个模块深入。参数设置部分通常是这样的params.pop_size 100; % 种群大小建议与参考点数量接近 params.max_gen 200; % 最大进化代数 params.num_obj 2; % 目标个数这份代码默认配置为2目标 params.num_var 30; % 决策变量维度ZDT1默认30维 params.crossover_prob 1.0; % 交叉概率 params.mutation_prob 1/30; % 突变概率通常取1/变量维度 params.ref_H 99; % 参考点在各目标方向上的分割份数参数里ref_H最容易被忽略它是Das-Dennis方法里的等分份数H。对2目标问题参考点数量等于H1而对3目标问题公式变成组合数C(HM-1, M-1)。也就是说如果你把目标数改成3但忘了改H参考点数量会突然增加很多种群大小却不变后面的小生境选择行为就会和2目标时完全不同。这是做多目标进化算法调试时最典型的“参数联动”陷阱之一。3.2 快速非支配排序把“支配关系”打成层级多目标里的“支配”是一个严格偏序关系解A支配解B当且仅当A在所有目标上都不比B差并且至少在一个目标上严格优于B。这是整个算法选择压力的来源也是困扰新手最多的地方单目标遗传算法里适应度是一个标量值多目标里没有单一的“适应度”取而代之的是一层一层分出来的等级。这段代码采用经典的两两比较方案function [fronts] non_dominated_sort(pop_obj) % 输入pop_obj: 种群的目标向量矩阵size为[种群大小, 目标个数] % 输出fronts: cell数组fronts{1}是第一层个体的索引集合 pop_size size(pop_obj, 1); S cell(pop_size, 1); % S{i}存储被个体i支配的个体集合 n zeros(pop_size, 1); % n(i)记录支配个体i的个体数量 fronts {}; current_front []; for i 1 : pop_size for j 1 : pop_size if i j continue; end dom_i all(pop_obj(i,:) pop_obj(j,:)) any(pop_obj(i,:) pop_obj(j,:)); dom_j all(pop_obj(j,:) pop_obj(i,:)) any(pop_obj(j,:) pop_obj(i,:)); if dom_i S{i} [S{i}, j]; elseif dom_j n(i) n(i) 1; end end if n(i) 0 current_front [current_front, i]; end end front_no 1; fronts{front_no} current_front; % 逐层剥离处理当前层支配的个体更新计数形成下一层 while ~isempty(current_front) next_front []; for i current_front for j S{i} n(j) n(j) - 1; if n(j) 0 next_front [next_front, j]; end end end front_no front_no 1; current_front next_front; fronts{front_no} current_front; end end这个实现的时间复杂度是O(M * N^2)其中M是目标个数N是种群大小。虽然比基于排序的优化版本慢一些但胜在逻辑直观对阅读代码的开发者来说先把“谁支配谁、第几层、每层有哪些个体”这三个问题看清楚比什么都重要。第一层就是当前种群里的非支配个体集合也就是我们最终要的帕累托前沿候选。当你在这份代码基础上做扩展时注意这里处理的是“最小化问题”。如果某个目标需要最大化常见做法是在计算前对目标值取负号而不是去改这段排序逻辑。我在拿到一批工程测试函数时就曾因为一个目标是“越大越好”直接代入排序代码导致结果完全反向这是低级但常见的错误。3.3 参考点生成与自适应归一化NSGA-III的“均匀性灵魂”参考点生成模块用的是Das-Dennis方法核心思想在2目标下很简单把连接两个轴点的线段等分H份取等分点作为参考点。3目标及以上时扩展为单纯形上的网格点。function [ref_points] generate_reference_points(M, H) % M: 目标个数 % H: 每个目标方向的等分份数 % 输出ref_points: 参考点矩阵行数为组合数C(HM-1, M-1) ref_points []; if M 2 for i 0 : H ref_points [ref_points; i/H, 1 - i/H]; end else % 递归生成单纯形上的均匀网格见Das-Dennis(1998) ref_points recursive_generate(M, H, []); end end参考点有个隐含属性容易被忽略它在目标空间里不需要归一化因为初始参考点的每个坐标分量加在一起等于1相当于落在单位单纯形上。后续归一化操作就是把种群的目标值也映射到同样尺度的空间里去比较。如果把参考点直接生成在原始目标空间会产生问题因为每个目标的数值范围不同参考点与个体的距离计算会被大范围目标主导。归一化模块要处理真正的数值坑。标准流程是先算出理想点也就是每一维目标的最小值将目标值减去理想点得到偏移后的坐标。然后找出极值点构造一个仿射超平面计算截距最后把每个个体目标值除以截距function [norm_pop] adaptive_normalization(pop_obj, ideal_point) % 将每个个体的目标值映射到[0,1]区间附近的单位超平面 % 输入pop_obj: 原始目标值矩阵 % 输入ideal_point: 每个目标维度上的历史最小值集合 % 输出norm_pop: 归一化后的目标值矩阵 % 1. 减去理想点保证所有维度的最优值平移为0 shifted pop_obj - repmat(ideal_point, size(pop_obj,1), 1); % 2. 计算每维目标的最大值用于辅助截距求解 max_values max(shifted, [], 1); % 3. 找到极值点对每个维度i找出使该维目标最小化同时其他维受约束的个体 extreme_points zeros(size(pop_obj,2), size(pop_obj,2)); for i 1 : size(pop_obj,2) % 用ASF增广标量函数找权值方向的极值个体 [~, idx] min(sum(shifted ./ max_values, 2) - shifted(:,i) ./ max_values(i)); extreme_points(i,:) shifted(idx,:); end % 4. 解线性方程组得到截距并做归一化 intercepts extreme_points \ ones(size(pop_obj,2), 1); intercepts 1 ./ intercepts; % 5. 防止除零截距过小时加一个极小量 intercepts(intercepts 1e-6) 1e-6; norm_pop shifted ./ repmat(intercepts, size(shifted,1), 1); end这里最容易翻车的有两处。第一处如果某个目标维度上所有个体取值完全相同max_values有可能为0导致后面shifted ./ max_values出现NaN需要用max(..., eps)保护这是从优化测试函数切换到工程函数时极易触发的问题。第二处求解截距的方程组可能奇异也就是多个极值点落在同一平面内常见做法是捕获矩阵求逆的报错把截距全部置为1直接退回“只做平移”的状态。3.4 关联与小生境选择决定“最后谁能进下一代”个体与参考点关联的过程分两步先计算每个个体到所有参考点的欧氏距离找到最近的一个作为其关联参考点然后统计每个参考点关联了多少个体。小生境选择时优先挑选关联个体数少的参考点确保这些区域至少被覆盖到一个解。这个机制非常像是“一个萝卜一个坑”先保证每个坑里都有一个萝卜坑不够用了再择优填第二个。function [selected] niching_selection(combined, fronts, assoc_idx, dist, params) % 从合并种群中选出pop_size个个体 % fronts: 非支配分层结果 % assoc_idx与dist: 每个个体关联到的参考点编号和距离 selected []; remain params.pop_size; % 按层依次添加个体直到一层无法完全容纳 for i 1 : length(fronts) if numel(fronts{i}) remain selected [selected, fronts{i}]; remain remain - numel(fronts{i}); else % 进入到临界层使用小生境策略 selected [selected, niche_select(fronts{i}, remain, assoc_idx, dist)]; break; end end endniche_select里的逻辑比这个片段复杂一些核心是统计每个参考点当前的关联计数然后迭代执行从关联量最小的参考点集合里挑距离最近的个体进入下一代选中后该参考点的计数加1。重复此过程直到选满为止。这意味着处于临界层的个体能否保留取决于“它所在的参考点是不是冷门区域”。如果一个参考点附近已经有五六个个体了第六个即使这一层里排名靠前也会被淘汰掉以维持解集在空间里的均匀。这个“宁可舍弃局部更优解也要保分布均匀”的设定让NSGA-III在真实工业问题里很有用。我当时做调度方案时最需要的不是一两个尖峰解而是能覆盖多种成本与时间组合的方案给小决策者选分布均匀比单点最优重要得多。4. 运行与改造把ZDT测试函数换成你自己的工程问题4.1 跑通基础示例从2目标ZDT1开始下载这份Matlab代码之后最稳的路线是先原封不动跑一遍。打开NSGAIII.m确认evaluate_objective.m里默认是ZDT1测试函数在Matlab命令窗口直接运行NSGAIII运行结束后会弹出一个二维散点图横纵轴分别是两个目标函数值若干分散的点形成一条向原点凸的稀疏曲线那就是ZDT1理论帕累托前沿的一个近似。如果你的机器配置一般也可以先把params.pop_size降到50params.max_gen降到100先验证整个流程能跑通再逐步恢复参数。关键参数表如下参数默认值调整建议pop_size100与参考点数量相当为宜不要远大于参考点数max_gen200工程场景建议300以上观察收敛性num_obj2改成3时优先调整ref_H参考点数量会剧增num_var30ZDT1为30自定义问题按决策变量数量设置ref_H992目标时参考点数1003目标时参考点数会变成5000多务必同步调整pop_size跑通之后有两个观察点别跳过打开工作区的population变量看最终种群的目标值是不是都集中在帕累托前沿附近再打开fronts{1}数一数第一层个体数量是不是接近pop_size。这两个观察能帮助你快速判断种群是否收敛也能发现参考点参数是否设置合理。4.2 替换目标函数从ZDT到自定义调度问题修改方式是所有二次开发的基础打开evaluate_objective.m这个函数接收决策变量向量x返回一个目标向量f。改成你的工程问题时注意保持目标统一为最小化形式。比如我之前做的一个制造车间调度简化版定义了两个目标总完工时间和总延迟惩罚那么对应函数可以写成function [f] evaluate_objective(x) % x: 决策变量向量例如两道工序的时间参数 % f: 目标向量默认全部是最小化目标 proc_time x(1) * 20 10; % 由决策变量映射出实际加工时间 delay_cost x(2) * 8 2; % 延迟惩罚系数 % 目标1完工时间越小越好 f1 proc_time; % 目标2延迟惩罚越小越好 f2 delay_cost; f [f1, f2]; end这里有个重要细节决策变量的取值范围由params.lower_bound和params.upper_bound控制也就是在NSGAIII.m里设置。修改自定义问题时务必同步调整边界避免决策变量在初始化阶段就越界。还有一点是我自己踩过的evaluate_objective函数的输入x是行向量如果某些Matlab函数返回列向量目标向量的维度会无缘无故变成[1, 2]和[2, 1]两种不同形态后续矩阵运算报错时你会摸不着头脑所以第一行可以统一加一句强制转换。另外如果你的工程问题里混合了“越大越好”的目标比如利润、吞吐量不要直接改排序模块而是在evaluate_objective里对该目标取负值然后在输出结果解读时把符号转回来。这种做法最省事也不容易破坏非支配排序的通用性。4.3 观察结果从目标空间看进化过程跑完算法后除了plot_pareto.m之外我建议手动写一段代码跟踪每一代的最优前沿变化% 记录每代第一层非支配个体的目标值画动态轨迹 best_front_each_gen zeros(max_gen, num_obj); for gen 1 : max_gen fronts non_dominated_sort(population_obj{gen}); first_front population_obj{gen}(fronts{1}, :); % 取第一层中离原点最近的点作为代表 best_front_each_gen(gen, :) min(first_front, [], 1); end plot(best_front_each_gen(:,1), best_front_each_gen(:,2), -o); xlabel(目标1: 完工时间); ylabel(目标2: 延迟惩罚); title(每代最优解收敛轨迹);看这张图的趋势能了解算法收敛速度前几十代目标值下降很快中后期进入精细搜索阶段曲线趋于平缓。如果曲线走到后期还在剧烈波动说明种群多样性保持得太强或者参考点分布和你实际前沿形状差异过大可以适当调大交叉概率或参考点数量再看一轮。调参本身不是玄学就是要在“多样性”和“收敛性”之间找平衡。5. 避坑指南NSGA-III在Matlab里跑偏的六种典型现场5.1 参考点数量暴涨程序突然变慢现象把目标数从2改成3运行时间从几十秒变成几分钟甚至更久且内存占用异常。原因ref_H没有随目标数调整。ref_H99时2目标的参考点数是100个3目标直接变成C(993-1, 3-1) 5050个。种群大小为100时每个个体要做5000多次距离计算关联阶段的计算量直接爆炸。解决3目标问题时建议设置ref_H为12到15之间对应参考点数量在100以内ref_H12时参考点数约91个ref_H15时约136个。一个合理的参考点数量通常在种群大小的0.8到1.2倍之间。对更多目标数还可以改用两层级联参考点生成来避免组合爆炸。5.2 归一化过程报错或出现NaN现象运行到adaptive_normalization时报矩阵奇异错误或最终绘图里出现NaN目标值。原因工程函数替换ZDT之后目标值的数值范围变化很大某些极端情况下所有个体在某一个目标维度上取值完全相同导致归一化里的max_values为0或者极值点方程解不出唯一截距。解决推荐方案是在adaptive_normalization.m里给max_values加一个下限保护用max(max_values, eps)。同时用try...catch捕获求解截距的奇异情况一旦发现矩阵接近奇异就把截距全部设为1让算法退化为“只平移不伸缩”的模式。这笔防御代码成本很低但能避免大量排查时间。5.3 第一层个体比种群还大现象运行结束后统计fronts{1}的个体数发现比pop_size还大或者接近全是第一层。原因目标数很少但问题简单时大量个体互为非支配关系层级结构扁平化第一层几乎容纳全部个体导致环境选择里“临界层选谁”的压力几乎全部落到小生境策略上算法退化为随机挑。解决把目标数量增加到2到3个以上让支配关系更有区分度或者引入离散决策变量增加问题的约束特征。如果问题本身实在简单建议换成更复杂的测试函数验证算法实现是否正确再回到目标问题上调试。5.4 替换目标函数后决策变量维度对不上现象evaluate_objective报错索引超出范围或者结果明显不收敛。原因params.num_var仍保持ZDT1的30维但自定义函数里只读取了x(1)和x(2)得到的结果不会报错只是一大半决策变量完全没参与目标计算算法实际上在做无效搜索。反过来如果自定义函数里读取了x(5)但num_var设置成3则会直接索引越界。解决每次替换目标函数后强制检查三个数字是否一致params.num_var、初始化函数里的随机数维度、目标函数内部读取的最大x(i)下标。这个检查我后来形成了习惯每次改完测试函数先跑一轮弹出结果才放心。5.5 参考点固定但前沿形状不规则结果分布不均匀现象最终帕累托前沿在某个区域密集另一个区域几乎空白。改了pop_size也没改善。原因NSGA-III的参考点是固定的均匀分布它假设帕累托前沿在目标空间里足够规则。如果你的工程问题前沿形状严重不规则比如存在非凸区域或断崖式边界均匀参考点无法贴合前沿形状。解决很自然的做法是改成“自适应参考点”每若干代根据当前前沿形状重新生成参考点或者在关联阶段对参考点做局部微调。我一般先验证问题本身的前沿形状把第一层个体目标值直接绘制成散点图看看是否连续规则再决定是否要修改参考点策略。对于大多数调度、路径规划类问题固定参考点已足够。5.6 Matlab脚本相互调用时函数名冲突现象报错“函数名重复”或调用了错误版本的同名函数。原因Matlab会优先调用当前工作目录下的函数。如果你把这份代码目录加入路径后又放了另一个版本的non_dominated_sort.m在同级目录调用时会随机命中其中之一排查起来很难发现。解决把所有代码文件放在同一个独立目录下用cd切换到这个目录再运行NSGAIII不要使用addpath把多个包含同名函数的目录混入路径。这是我吃过一次亏的教训有一次调试结果莫名其妙半天才发现是路径里有两个不同的排序函数改完顺序后一切恢复正常。6. 二次开发技巧把这份源码改造成自己的“多目标求解器”拿到一份注释齐全的源码最大的价值不是跑通示例而是改成自己的工具。我会从三个层面去做这件事。第一步把测试函数和算法框架解耦。目前evaluate_objective.m是全局函数建议改成回调函数模式在主脚本里定义目标函数句柄传入各模块。改造之后参数集和目标函数都通过一个配置结构体传入以后切换问题只需要改一行配置而不需要去碰算法核心。这种松耦合的设计对后续维护和复现实验都非常关键。第二步加一个“多代前沿指标”的统计模块。单纯看散点图只能靠感觉判断收敛性。建议在每一代记录三项指标第一层个体数量、前沿点到理想点的平均距离、以及相邻解在目标空间的间距方差。前两项反映收敛性第三项反映均匀性。有了这三个指标你在调参时就不再是“看运气”而是能判断某个参数改动的方向。第三步做一个与NSGA-II的对比验证。保留这一份NSGA-III的同时找一套NSGA-II的Matlab实现在同一组测试函数上、同样的种群规模和代数下分别运行多次比较前沿的覆盖范围和均匀性。在多目标领域里算法好坏本质是经验比较多跑几个测试函数比在理论上空谈有意义得多。我在某实验室做课程项目时就是用这种对比方式给某跨平台系统选了合适的多目标优化方案。从那以后我拿到任何进化算法源码都会先强制自己走一遍“跑通示例、替换问题、记录指标、对比基线”四步流程确认四个步骤都完成后才敢把结果拿到评审会上。这个过程帮我避开了很多“算法看似正常但其实在瞎搜”的尴尬情况。希望这份带中文注释的NSGA-III实现也能帮你少踩一些坑把精力花在解决自己实际问题的那一步上。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑