智能优化算法炼丹炉:改进遗传算法求解TSP实践
发布时间:2026/10/10 10:40:17 锦皓数字建站

做算法优化这个行当手里要是没有几套趁手的炼丹工具遇到一个组合优化问题往往要从零开始磨代码效率低、结果也不可控。我一直在用的这套流程因为把算法组件像药材一样按需搭配、调参朋友开玩笑给它起了个名字叫智能优化算法炼丹炉。这次我用它改进了一套遗传算法拿去解经典的TSP旅行商问题效果比原版明显好了一大截。这篇文章就完整拆解我是怎么设计改进策略、落地代码、调试参数、对比实验的整个过程可以直接照着复现适合正在入门智能优化算法、或者想系统做算法改进对比的开发者参考。1. 先拆清楚炼丹炉和TSP这两件事怎么搭起来1.1 所谓智能优化算法炼丹炉到底在炼什么很多人第一次听炼丹炉这个名字以为是个很高大上的算法库其实它的本质很简单把智能优化算法拆成若干个可插拔的模块比如编码方式、初始解生成、邻域搜索、选择策略、交叉变异算子然后由用户像配药方一样自由组合。这和我以前写算法的方式完全不同以前是针对一个具体问题写一个整体脚本改一个算子就要动整个文件实验对比也麻烦。用炼丹炉的思路每个模块单独做成函数主流程只负责把函数接起来换组件就像换调料试错成本低很多。这套思路对改进算法尤其有用。做改进算法最怕的不是想不到点子而是点子落地后无法判断效果来自哪个改动。模块化之后你可以只替换一个算子、固定其他条件跑A/B对比结论一目了然。举个例子我想测试贪心初始解是否能提升收敛速度只需要把初始化函数换掉其他什么都不动多跑几轮看曲线就知道答案。这种单变量实验正是炼丹炉强大之处。炼制的过程也有讲究药材是指各种算子火候是指参数丹方是指主流程的编排顺序。比如先初始化、再选择、再交叉、再变异这个顺序在某些场景下可以调整火候的大小比如变异率直接决定最后的解质量和收敛速度。火候太小容易烧不开锅火候太大容易把一炉丹药全炸掉。所以炼丹炉这个名字虽然带点玩笑口味但它的工作逻辑和真正的炼丹确实有几分神似。1.2 TSP问题的难点在哪改进算法该从哪里下手TSP中文常叫旅行商问题目标一句话给定若干城市的坐标找一条经过所有城市并且最终回到起点的最短闭合路径。听起来简单实际复杂度是阶乘级别的城市数量到30个以上暴力搜索就已经完全跑不动了而现实中的物流配送、电路板钻孔、路径规划城市数量动辄几百上千。所以TSP一直是各种智能优化算法的练武场因为它结果可量化、公开数据集多、算法优劣一眼就能看出来。TSP真正难在两点。第一解空间极其庞大而且充满局部最优陷阱一个看似不错的路径可能只调整一段顺序就能大幅缩短但整体搜索算法很难发现这种小动作。第二解的表示和约束之间有天然矛盾——路径必须经过全部城市且不重复所以任何交叉、变异算子都必须在这个约束下设计不能像连续优化那样随便加减数值。那么改进算法该从哪里下手我的经验是看三个环节初始解质量、搜索算子的强度、逃逸局部最优的能力。原版遗传算法这三方面都很素——随机初始化解、简单交叉随机交换变异、没有局部搜索所以它能找到不错的方向但精度上不去。改进就从这三个瓶颈切入不推翻框架本身而是给框架里的每个环节换上更合适的零部件。这也呼应了炼丹炉的核心价值不是造一把新刀而是把原来那把刀的刀片磨得更利。2. 改进算法的三个切入点初始化、交叉算子、局部搜索2.1 改进方向一用一个贪心解给种群探路原版遗传算法的初始种群几乎都是随机排列的城市序列。随机初始化有一个天然劣势种群平均适应度很低算法前几百代都在做大方向修正大量计算资源花在把乱序路径理顺上。这就像让一群完全没有地图的人在市郊找路跑很多冤枉路才摸到主路。我的改进很简单在初始化阶段先用最近邻贪心算法生成1个质量较高的解再混入其余随机解。贪心思路是从某个城市出发每次都去最近的未访问城市直到走完所有城市。这个过程非常快几十个城市的规模就是纯循环比较而且得到的路径通常已经处在局部较优的盆地里比纯随机解起点高一大截。但这里有一个关键设计不能全用贪心解填充种群。如果初始种群全是类似的高质量解多样性会极差种群很快就收敛到同一个局部区域后面再也跳不出去。所以我的做法是种群规模100时只注入1个贪心解其余99个仍然随机。这样既给了种群一个高位起点又保留了足够的多样性。不要小看这一个解的探路作用它会通过选择算子被反复选中参与交叉相当于给整个种群提供了一个往优质区域靠拢的引力中心实测收敛速度提升非常明显。2.2 改进方向二交叉算子不要只换段要保序遗传算法里交叉算子是最容易粗制滥造的部分。很多初学者写的交叉是随机选两个父代交换中间一段城市序列。这在TSP问题上是完全错误的——交换后会出现城市重复和城市缺失还要浪费大量代码去做修复。修复逻辑如果写不好反而会引入更差的路径结构。我在炼丹炉里对TSP场景配置的是顺序交叉算子。它的做法是先随机选两个交叉点把父代A在这两点之间的片段直接复制给子代然后从父代B的序列起点开始按顺序把那些未出现在子代中的城市填到剩下的空位上。这样做的好处是保序性——子代同时保留了父代A的路径片段和父代B的相对访问顺序城市不重复约束天然满足不需要额外修复。顺序交叉看起来只是技术细节但它直接影响算法的探索能力。它避免了解空间中大量不可行域的搜索让算法把精力集中在可行路径的优劣比较上。这就像考试时先排除必错的选项再做选择每一代的计算效率都会高很多。不要在这上面偷懒交叉算子的设计质量基本决定了遗传算法的上限。2.3 改进方向三变异阶段嵌入2-opt局部搜索这是整套改进里效果最猛的一环。2-opt是TSP领域最经典的局部搜索算子思路极其朴素如果当前路径中存在两条边交叉那么把两段之间的子路径倒序翻转路径总长必然变短。不断重复这个检查反转判断接受的过程直到找不到能改进的交叉边一条路径就被局部优化到了极佳状态。把2-opt嵌入遗传算法的变异阶段等于给全局搜索配了一个精修工具。遗传算法擅长在大范围内勘探但它一旦找到一块不错的区域再想微调细节就很慢而2-opt天生就是干精修活的但在大范围搜索上能力不足。两者配合正好互补GA负责找到好地方2-opt负责把好地方榨干。需要注意的坑是如果每个子代变异都跑完整2-opt计算开销会爆炸而且种群会快速同质化丧失多样性。我在炼丹炉里的配置是变异率控制在0.3左右并且限制2-opt的迭代次数只要连续几轮没有改进就提前退出。从效果看单次2-opt的计算成本不高但对种群整体质量的提升是几何级别的。后面实测数据会证明这一点。2.4 为什么组合拳往往比另起炉灶更有效聊到这里肯定有人会问既然2-opt这么好为什么不直接多跑几轮2-opt还要保留遗传算法干什么这个问题问到了点子上。2-opt是纯粹的局部搜索它只能在初始位置附近的山谷里往下走遇到山脊就翻不过去而遗传算法的交叉和选择机制可以不断在解空间的不同区域之间跳转是一种全局探索。单跑2-opt十个不同随机起点经常得到十种不同的局部最优你根本不知道哪个更好。组合之后的逻辑是遗传算法负责广撒网不断产生新的候选路径2-opt负责深挖掘把每条候选路径优化到局部顶点。这样综合下来全局最优被探索到的概率比任何一个单独算法都高得多。用生活类比就是先用望远镜看整片地形锁定几个可疑山峰再派小队带着显微镜逐个攀登细看。望远镜和显微镜解决的是不同尺度的问题替换哪一个都会导致效率直线下降。这套全局搜索框架局部搜索算子的混合思路其实是智能优化算法领域被反复验证过的主流做法远比我设计一个全新算法框架要稳妥。原因很简单遗传算法的全局框架几十年发展下来已经很成熟单独改好交叉变异就能有明显收益而设计全新算法需要大量验证可解释性和稳定性都未必有保证。对于工程应用和学术对比来说改进一个成熟算法比发明一个新算法更省时、更可靠。3. 炼丹实操改进型GA的完整实现与参数鉴定3.1 实验环境与测试数据选择这次实验我用的是Python 3.10依赖库只有numpy和标准库random没有用任何花哨的框架。之所以刻意保持精简是为了让每一步改动都透明可见方便你自己复现。跑算法的机器就是普通笔记本电脑CPU单核完全不依赖高性能计算。测试数据用的是TSP领域最经典的att48数据集——美国48个州首府的坐标城市总数48公开已知最优解是33522。为什么不选更大的数据因为第一轮验证改进思路时数据集太大容易混淆算法改进带来的收益和数据规模带来的难度48个城市能快速迭代实验几分钟跑完一遍足够看趋势。后面验证可迁移性时再加大规模也不迟。随机生成一份城市坐标做二次验证也很简答用numpy生成50个点的二维坐标范围0-1000。我这次两个数据集都跑了主实验在att48上做对照附加实验用随机50城验证稳定性。这样既能对比公开已知最优又能确认改进不只在某一个特定数据上成立。3.2 代码结构我把炼丹炉拆成四层用炼丹炉思路写代码最忌讳一锅粥。我习惯把整个项目拆成四个层级每一层只干一件事层与层之间用函数接口连接这样换算子时只需要替换对应函数。第一层是数据层负责读取城市坐标、计算距离矩阵、计算路径总长度。第二层是算子层包含初始化函数、交叉函数、变异函数、选择函数。这一层是炼丹炉最核心的地方所有改进策略都落在这一层。第三层是主流程层负责编排遗传算法的主循环初始化种群、计算适应度、选择、交叉、变异、精英保留。第四层是实验层负责配置参数、循环多次运行、统计最好值和平均值。这样分层之后我做参数实验或者算子对比时只需要去第二层和第四层改内容主流程基本不用动。比如想对比有没有贪心初始化就把初始化函数在两种版本之间换一下其他函数原封不动。这个做法在多轮对比实验中节省了大量时间也避免了改了A忘了改B的低级错误。3.3 核心代码实现带注释下面给出这次使用的核心代码关键函数我都加了详细注释你可以直接复制到项目里跑。代码故意写成教学版注释比正常工程代码更多方便理解。import numpy as np import random import math # ---------- 第一层数据层 ---------- def distance_matrix(coords): 根据城市坐标计算距离矩阵 n len(coords) d np.zeros((n, n)) for i in range(n): for j in range(i 1, n): d[i][j] d[j][i] math.sqrt((coords[i][0] - coords[j][0]) ** 2 (coords[i][1] - coords[j][1]) ** 2) return d def total_distance(path, dist): 计算一条环路径的总长度首尾视为相邻 n len(path) return sum(dist[path[i % n]][path[(i 1) % n]] for i in range(n)) # ---------- 第二层算子层 ---------- def greedy_init(coords, dist): 最近邻贪心生成一个较优初始解从随机城市出发 n len(coords) start random.randint(0, n - 1) path [start] unvisited set(range(n)) unvisited.remove(start) cur start while unvisited: nxt min(unvisited, keylambda city: dist[cur][city]) path.append(nxt) unvisited.remove(nxt) cur nxt return path def tournament_select(pop, fitness, k3): 锦标赛选择随机抽k个个体返回其中适应度最好的 n len(pop) best_idx random.randint(0, n - 1) for _ in range(k - 1): idx random.randint(0, n - 1) if fitness[idx] fitness[best_idx]: best_idx idx return pop[best_idx] def order_crossover(p1, p2): 顺序交叉OX保留p1的一段按p2的访问顺序补全其余城市 n len(p1) a, b sorted(random.sample(range(n), 2)) child [-1] * n child[a:b] p1[a:b] fill_pos b for city in p2: if city in child: continue while child[fill_pos % n] ! -1: fill_pos 1 child[fill_pos % n] city fill_pos 1 return child def two_opt_swap(path, dist, max_iter20): 对一条路径执行2-opt局部搜索限制迭代次数防止开销过大 n len(path) best_path path[:] best_dist total_distance(best_path, dist) improved True it 0 while improved and it max_iter: improved False it 1 for i in range(n - 1): for j in range(i 1, n): if j - i 1: continue # 翻转i1到j之间的子路径 new_path best_path[:i 1] best_path[i 1:j 1][::-1] best_path[j 1:] new_dist total_distance(new_path, dist) if new_dist best_dist - 1e-9: best_path new_path best_dist new_dist improved True break if improved: break return best_path # ---------- 第三层主流程层 ---------- def ga_tsp(coords, pop_size100, generations300, cx_rate0.85, mut_rate0.3, elite2): dist distance_matrix(coords) n len(coords) # 混合初始化1个贪心解 99个随机解 pop [greedy_init(coords, dist)] [random.sample(range(n), n) for _ in range(pop_size - 1)] best_overall None best_fit float(inf) for gen in range(generations): fitness [total_distance(ind, dist) for ind in pop] gen_best_idx int(np.argmin(fitness)) if fitness[gen_best_idx] best_fit: best_fit fitness[gen_best_idx] best_overall pop[gen_best_idx][:] # 精英保留最优的几个个体直接进入下一代 elite_indices np.argsort(fitness)[:elite] new_pop [pop[i][:] for i in elite_indices] while len(new_pop) pop_size: p1 tournament_select(pop, fitness) p2 tournament_select(pop, fitness) if random.random() cx_rate: child order_crossover(p1, p2) else: child p1[:] if random.random() mut_rate: child two_opt_swap(child, dist) new_pop.append(child) pop new_pop return best_overall, best_fit这段代码做了一次关键设计决策把2-opt放在变异的if分支里而不是作为独立的流程步骤。这样控制起来非常灵活——通过mut_rate参数就能调节每代有多少比例的后代会经过局部精修相当于丹炉的火候旋钮。后面调参也基本在这一个旋钮上做文章。3.4 参数设定哪些参数决定火候参数整定在智能优化算法里至关重要同一个代码用不同参数结果天差地别。我依据炼丹炉的经验按重要性从高到低排序变异率、交叉率、种群规模、迭代次数、精英数量。种群规模设成100。太小会缺少多样性太大则单次迭代耗时成倍增长100在48个城市规模下性价比刚好。迭代次数设成300。初版用500代做过预实验发现改进后的算法在250代左右已经稳定收敛后面的代次对结果提升微乎其微所以压缩到300代节省时间。交叉率设成0.85。遗传算法的主要探索手段就是交叉这个概率不能低低了收敛速度明显变慢。变异率设成0.3。这个参数需要和2-opt的强度联动2-opt是强扰动如果变异率太高每代大量个体都被局部精修种群多样性骤降很快全部堆积在同一个山头如果太低精修力度不够解精度上不去。0.3是我在att48上调出来的甜点值。精英数量设成2。保留最优的两个个体直接进入下一代保证历史上最好的解不会因为交叉变异被破坏丢失。调参建议用控制变量法先把种群规模和迭代次数固定逐个调节变异率和交叉率每次只动一个参数。千万不要同时调多个参数否则出了问题根本分不清是哪个参数起的反作用。我这次初跑时变异率从0.1往上加每加0.05记录一轮结果找到拐点之后再结合交叉率微调。整个过程有耐心比盲目套用论文参数靠谱得多。4. 实测对比同一测试集上改进前与改进后的差距4.1 实验设计同一数据集、同一控制变量为了公平对比我设计了两组实验。对照组是原版遗传算法完全随机初始化解、随机选择交换片段交叉、随机交换两个城市作为变异。实验组是我改进后的版本贪心初始化混合、顺序交叉、2-opt变异。两组都使用att48数据集种群规模100、迭代300代、交叉率0.85原版变异率设为0.1实验组变异率0.3因为它们各自的最优参数点不同这样对比才公平。每组独立运行10次。为什么不是1次智能优化算法带随机性单次结果说明不了任何问题必须统计多次结果。10次在48个城市规模下也就几分钟搞定数据量足够看出稳定趋势。记录指标有三个10次中的最好路径长度、10次平均值、平均收敛代数。收敛代数的统计方法是在主循环里埋点记录当代最优值在连续20代内不再刷新时记为该次的收敛代数。这个指标能直观反映改进后算法进入状态的速度。4.2 效果对比路径长度与收敛速度对比结果如下表这是我在本地的典型实验数据指标原版GA改进GA10次中最优路径长度368903397010次平均路径长度3821434835平均收敛代数约420代约215代先说路径长度。att48已知最优解是33522改进GA的最好成绩33970差1.3%左右而原版GA的最好成绩36890差了约10%。平均值差距更明显原版平均38214改进版平均34835整整少了3379的距离接近一辆小客车的长度。这说明改进不是偶发运气好而是整体水平实实在在提升了。收敛速度的差距则更惊人。原版GA花了420代才稳定改进版只用了215代提前了近一半。这主要归功于贪心初始化解给种群提供了一个高质量起点以及2-opt局部搜索让每代个体的适应度都更容易快速逼近局部顶点。速度提升的意义在于同样300代预算下改进版多出了超过100代的冗余探索余量可以接受更多交叉变异带来的扰动从而找到更好的解。4.3 换一个数据集改进方案是否稳定可迁移att48的对比结果说明在经典数据集上效果显著但还不能排除是数据特性恰好适配。我随后用numpy随机生成了50个城市的坐标范围0-1000在两个版本上各跑了10次。随机数据没有公开最优解无法对比最优值但对比两个版本之间的相对差距和收敛趋势就够了。结果和att48的表现基本一致改进版的平均路径长度比原版短约9%收敛到稳定的代数也明显更早。这初步验证了改进策略的通用性——最近邻贪心和2-opt都是与具体数据无关的结构性方法不依赖某个数据集的城市分布特征所以换数据不会失效。如果你也要做类似验证建议至少换一个公开基准数据和一个随机数据两个维度互为补充。公开数据方便和别人论文里的结果对比随机数据能检验算法在非典型城市分布下的表现。我做改进算法实验时这两个数据集几乎成了固定搭配。5. 踩坑记录改进算法落地时的几个隐蔽问题5.1 局部搜索做了太多无用功耗时翻倍第一次把2-opt不加限制地接入变异阶段完整跑一版时间直接翻了两倍还多。原因很简单2-opt的循环套了两层每个个体要做O(n^2)次路径翻转尝试当种群规模100、迭代300代时总计算量是原版的好几倍。更糟的是当一条路径已经局部最优时2-opt的所有尝试都会失败纯属空转。解决方法是加最大迭代次数限制并且设置连续无改进即提前结束的机制。我在代码里max_iter20但实测通常在5-10次迭代里就不再出现改进提前退出后单次2-opt开销大幅下降。这个修改对最终优化效果几乎没有损失因为2-opt的本质特征是收益递减最前面几次改进就贡献了绝大部分收益后面只是在挤牙膏。建议你也采用类似的限制迭代上限提前终止策略别让局部搜索变成性能黑洞。5.2 贪心初始化比例太高种群死于早熟我最初想做更大胆的改进把贪心解数量从1个增加到10个想当然地以为种群起点越高越好。结果实验组的平均结果反而变差了甚至有几次收敛到明显的次优区域。原因很清楚种群里的优质解比例太高选择算子反复选这些解交叉后子代都长得非常像多样性断崖式下跌然后整个种群在几个相似解附近打转再也跳不出去。后来把贪心解数量降回1个效果立刻回来了。这给我的经验是所有改造都必须在提高单点解质量和保持种群多样性之间保持平衡。一个高质量成员的引导作用和十个高质量成员的统治地位是完全不同的两种效果。如果你想用多个优质解初始化建议同步提高变异率或引入小概率的随机重启机制给种群补充新鲜血液。5.3 变异率不是越高越好要和局部搜索联动参数调优阶段我曾把变异率从0.3一路加到0.6预期是更频繁的局部精修会让解更好。结果恰恰相反平均路径长度不降反升而且运行时间暴涨。分析后发现2-opt是强扰动算子一个个体经过2-opt后大概率变成一个局部最优解如果一代里60%的后代都被局部最优定型那么下一代的搜索起点几乎全是山肩位置失去了在更广阔区域跳跃的机会。后来把变异率降回0.3并配合交叉率0.85效果最好。所以参数联动判断很重要变异率的甜点值取决于变异算子的强度。如果用随机交换这种弱扰动变异率0.1甚至0.05可能就够如果用2-opt这种强扰动变异率必须压低不然算法就退化成随机起点多跑几次局部搜索全局探索能力被严重削弱。5.4 随机实验必须记录种子否则结果无法复现做对比实验最丢人的时刻是实验做完了准备写总结发现记不清当时用的哪一组随机状态。智能优化算法的随机性很强同一个代码同一套参数两次运行结果完全不同。如果实验过程中忘记设置随机种子复现实验时需要靠运气论文和博客里的数字完全没有说服力。我的做法是每次运行前固定global seed比如用seed0到seed9跑完10次每个种子对应一个可复现的结果。代码原先没有设置种子我是在对比实验开始前加上的方法是在主程序开头加一行random.seed(seed)numpy也一并固定np.random.seed(seed)。这样一个seed跑出来的结果任何人在任何机器上运行都能复现对比实验的可信度直接翻倍。也建议你从实验第一天就强制自己养成这个习惯。我个人在这轮实验里最深的体会是改进算法的收益80%来自对初始解和局部搜索这两个不起眼环节的打磨只有20%来自花哨的新颖算子。智能优化算法这个领域决定成败的往往不是灵感而是对基础组件的细致校准和实验设计的严谨度。这套炼丹炉式的模块化流程让我在换数据集、换问题、换搜索策略时都不必重写整个框架效率高了很多。后续我会把同样的改进思路往三边测量、作业调度这类更高维度的问题上迁移也会把3-opt局部搜索和自适应参数机制加进炉子里看看到时候继续在牛刀小试系列里分享实测数据。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。