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

遗传算法求解约束网络流优化问题的Matlab实现

遗传算法求解约束网络流优化问题的Matlab实现 ★ FEATURED ARTICLE
最近帮朋友看一个干线调度系统需求本质上是多个分拨中心的货要按主干线路运到目的地每条线路有最大承载同时还要压单线成本整体预算不能超。把这个需求抽象出来就是一个带多约束的网络流优化问题。我一开始试着用最小费用最大流直接解发现完全吃不住那些额外约束后来换成遗传算法GA在Matlab里硬解效果反而不错。这篇文章就把整个思路、建模和Matlab实现完整拆开讲希望能帮到正在被约束网络流问题折磨的人。1. 网络流约束优化的难点与GA的“用武之地”1.1 经典网络流与实际工程问题的差距经典网络流问题核心约束就两个边容量和节点流量守恒。无论是Ford-Fulkerson、Dinic还是最小费用最大流本质上是利用图的结构性质做增广和调整所以计算效率很高几百上千个节点的网络都能在毫秒级求解。但工程场景里的网络流问题很少这么“单纯”。我做调度系统时遇到的真实约束包括路径跳数不能超过上限、总转运成本必须低于预算、某些节点不能作为流量中转、边上的运输成本随流量非线性增长、甚至要求不同源点到不同汇点的流量保持比例均衡。这些约束中的任何一个被放进经典模型都会破坏图的特殊结构让增广路算法失去理论基础。更麻烦的是这些约束往往不能被简单改写成容量或者费用。比如“路径总跳数不超过8跳”这是一个组合约束它和网络流的边计算完全不在一个层面上。就算你用CPLEX或Gurobi把问题线性化一旦业务规则调整又得重新建模迭代成本很高。我在实际项目里最深的感觉是约束网络流问题的难点不在“流”而在“约束”。约束一多问题就从多项式可解变成NP难精确算法在大规模实例上基本跑不动。1.2 为什么遗传算法适合硬解这类问题遗传算法是我最终选择的方案理由有几点它不依赖目标函数的梯度也不需要凸性假设。边成本函数写成二次、分段甚至离散的费用表GA都能处理因为它只评估“好不好”不关心“怎么求导”。种群并行的搜索方式适合多解区域。网络流约束优化在搜索空间中往往存在很多孤立的可行域单点搜索算法容易被困在某个区域GA可以同时在多个区域里探索。约束处理灵活。等式约束可以通过编码设计来天然满足不等式约束通过修复算子和罚函数组合处理这是解析方法很难做到的。对工程变更友好。网络结构变了只需要改邻接矩阵和候选路径集合不需要改算法主逻辑。但也要泼一盆冷水如果你的问题本质上就是纯最大流或者纯最小费用流别用GA那是杀鸡用牛刀。GA真正有价值的场景是非线性目标 多不等式约束 组合约束叠加在网络流上传统方法要么无法直接求解要么建模成本太高。1.3 一个可复现的约束网络流问题模型为了让这篇文章能落地我定义了一个有代表性的测试模型后面所有代码和实验都围绕它展开。设网络为有向图 (G(V,E))每条边 (e) 有容量 (c_e)目标是确定边流量 (x_e)最小化总成本[ \min Z \sum_{e \in E} \left( a_e x_e b_e x_e^2 \right) ]这里 (a_e) 是单位运输成本(b_e) 是拥塞系数流量越大单位成本越高这是工程中非常常见的拥挤效应。约束条件容量约束(0 \le x_e \le c_e)节点守恒对每个中间节点 (v)(\sum_{e \in in(v)} x_e \sum_{e \in out(v)} x_e)总流量约束源点总流出等于总需求 (F)预算约束(\sum_{e} a_e x_e \le B)路径跳数上限每条运送路径的边数不能超过 (L)前两个是经典网络流约束第4条是典型的资源预算约束第5条是组合约束。第5条用线性规划处理非常别扭但用路径编码的GA来处理非常自然因为候选路径集在建的时候就只保留跳数不超过 (L) 的路径组合约束直接从模型里“消失”了。这一点后面会详细讲。2. 遗传算法求解的三个核心设计编码、约束修复与适应度2.1 编码方式路径流量基因远优于边流量基因遗传算法里最关键的决策就是编码。很多人第一反应是既然问题是求边流量 (x_e)那就把染色体编码成一个向量每个基因代表一条边上的流量不就行了我一开始也这么试过结果很惨。边流量编码最大的坑在于流量守恒是一个强等式约束随机生成的向量几乎不可能满足守恒。比如一个12节点的网络随机给25条边赋值要让所有中间节点流入等于流出概率接近零。就算用修复算子去调整一个边流量变了会影响上下游一条链路需要不断迭代修复交叉变异之后又会破坏等于每次迭代都在做一套网络流修正开销非常大。正确的做法是路径流量编码预先生成一组从源点到汇点的候选路径染色体基因表示每条候选路径上分配的流量值染色体长度等于候选路径数量。这个编码的精妙之处在于只要保证所有基因之和等于总流量 (F)那么节点流量守恒约束自动满足。为什么因为每条候选路径本身就是一条从源到汇的连通路流量沿着这条路走在每个中间节点流入等于流出这是天然的。于是复杂的网络流守恒约束被简化成了一个简单的归一化操作。我经常用管道系统来类比这件事与其去精确控制每个管段的流量边编码不如先铺好一组从水源到目的地的水管候选路径然后只决定每根水管开多大阀门。阀门的调节永远不影响水路连通的拓扑结构这就是路径编码的底气。候选路径集怎么生成中小规模网络直接DFS枚举限制深度不超过跳数上限 (L) 即可。规模大一些用Yen算法求前K短路。注意候选路径集合必须尽量完整否则会漏掉最优路径结构这个坑在第5节会专门讲。2.2 约束处理先修复后惩罚GA里处理约束有两大流派罚函数法和修复算子法。纯罚函数代码简单但在约束网络流这种可行域占比很小的场景里种群中大部分个体都是不可行的算法会在无效区域内游荡很久收敛极慢而且交叉产生的子代可能更加不可行。我的策略是“先修复、后惩罚”的混合方案具体分三层第一层总流量约束在编码层解决。初始化和交叉变异后所有个体都做一次归一化让基因之和等于 (F)这个等式约束就永远不会被违反。第二层容量约束和预算约束用修复算子处理。比如某条边上的实际流量超过了容量就找到经过这条边的路径削减它们的流量并把削减下来的流量转移到不经过这条边、且有剩余容量的路径上。预算超了就按单位成本从高到低削减路径流量补充到成本更低的路径上。修复之后大部分个体都能满足硬约束。第三层对修复后仍然残留的轻微违规再加罚函数兜底。我一般设[ \text{fitness_penalty} \lambda_{\text{cap}} \sum_e \max(0, x_e - c_e) \lambda_{\text{budget}} \max(0, \text{varCost} - B) ]其中 (\lambda_{\text{cap}}) 设得远大于 (\lambda_{\text{budget}})因为容量是“绝对红线”预算则是“软约束”。实际调参时(\lambda_{\text{cap}}) 在100到500之间(\lambda_{\text{budget}}) 在10到50之间效果都还可以。这里有个很实用的心得修复算子的执行顺序影响很大。一定先修容量再修预算。容量一旦突破整个流方案在物理上不可行是硬伤而预算软约束可以留给算法在后续迭代中慢慢找低成本路径如果修复算子干预太多反而会把算法锁死在局部最优。2.3 适应度函数设计适应度是GA唯一的反馈信号。对于成本最小化问题我不用传统那种“把目标函数取倒数”的映射而是直接用锦标赛选择。锦标赛选择的逻辑是从种群中随机抽 (k) 个个体选适应度最好的一个进入下一代。这样只需要一个可以比较的数值不需要把数值映射成“越大越好”。单目标时我的适应度就定义成原始问题的“总成本 惩罚项”[ F_i Z_i \lambda_{\text{cap}} \cdot V_{\text{cap}} \lambda_{\text{budget}} \cdot V_{\text{budget}} ]锦标赛选择时 (F) 越小越好这会同时推动两个目标尽量降低真实成本尽量满足约束。这里有个细节惩罚系数如果太大选择压力会过强种群会在几代之内收敛到少数几个个体失去多样性。如果太小不可行解又会大量存活。我的经验是让惩罚造成的适应度差异占据种群总体差异的30%到50%。可以先用一轮短跑测试观察可行解比例的变化再调整系数。3. Matlab实现主程序、算子与修复逻辑代码拆解3.1 代码文件结构我用Matlab实现了完整流程建议按以下文件组织文件作用main_ga_netflow.m主程序负责参数设置、种群初始化、进化循环buildPathSet.m生成候选路径集合DFS枚举initPop.m初始化种群生成可行的路径流量个体evalFitness.m计算适应度返回总成本和惩罚值repairFlow.m修复算子处理容量和预算违规selectionTournament.m锦标赛选择crossoverArith.m算术交叉mutateReassign.m变异路径间流量重新分配3.2 主程序框架%% main_ga_netflow.m % 基于遗传算法GA求解约束优化网络流问题 %% 清空环境 clear; clc; close all; rng(42); % 固定随机数种子保证实验可复现 %% 1. 输入网络数据 % adjMat(i,j) 表示节点i到节点j是否有边非零值为边编号 % cap(k) 表示第k条边的容量 % a(k), b(k) 表示第k条边的成本系数 % sources, sinks 分别是源点集合和汇点集合 % totalFlow 总需求流量 % maxJump 路径跳数上限 % budgetLimit 预算上限 [adjMat, cap, a, b, sources, sinks, totalFlow, maxJump, budgetLimit] loadNetworkData(); %% 2. 生成候选路径集合 pathSet buildPathSet(adjMat, sources, sinks, maxJump); nPaths size(pathSet, 1); fprintf(候选路径数量: %d\n, nPaths); %% 3. 遗传算法参数 popSize 60; % 种群大小 maxGen 300; % 最大迭代次数 pCross 0.85; % 交叉概率 pMut 0.15; % 变异概率 eliteNum 2; % 精英保留数量 %% 4. 初始化种群 % 每个个体是一个长度为 nPaths 的向量元素表示对应路径上的流量 pop initPop(popSize, nPaths, totalFlow); %% 5. 进化主循环 bestCostHist zeros(maxGen, 1); bestIndiv []; bestCost inf; for gen 1:maxGen % 评估适应度 [costArr, penCap, penBudget] evalFitness(pop, pathSet, cap, a, b, totalFlow, budgetLimit); fitness costArr penCap penBudget; % 记录本代最优 [minFit, bestIdx] min(fitness); if minFit bestCost bestCost minFit; bestIndiv pop(bestIdx, :); end bestCostHist(gen) bestCost; % 精英保留 [~, sortIdx] sort(fitness); elitePool pop(sortIdx(1:eliteNum), :); % 新一代种群 newPop zeros(popSize, nPaths); newPop(1:eliteNum, :) elitePool; % 锦标赛选择生成其余个体 for i (eliteNum1):popSize % 选择两个父本 p1 selectionTournament(pop, fitness, 3); p2 selectionTournament(pop, fitness, 3); % 交叉 if rand pCross [c1, c2] crossoverArith(p1, p2, totalFlow); else c1 p1; c2 p2; end % 变异 if rand pMut c1 mutateReassign(c1, totalFlow); end if rand pMut c2 mutateReassign(c2, totalFlow); end % 归一化保证总流量约束 c1 c1 / sum(c1) * totalFlow; c2 c2 / sum(c2) * totalFlow; % 修复算子 c1 repairFlow(c1, pathSet, cap, a, b, budgetLimit); c2 repairFlow(c2, pathSet, cap, a, b, budgetLimit); newPop(i, :) c1; if i1 popSize newPop(i1, :) c2; end end pop newPop; if mod(gen, 50) 0 fprintf(代数 %d当前最优成本: %.2f\n, gen, bestCost); end end %% 6. 输出结果 fprintf(优化完成最终最优成本: %.2f\n, bestCost); % 可视化收敛曲线 figure; plot(bestCostHist, LineWidth, 2); xlabel(迭代代数); ylabel(历史最优成本); title(GA收敛曲线); grid on;注意主程序里交叉、变异之后各做了一次归一化这是为了让总流量约束在所有个体中始终成立。这个操作虽小但能大幅减少修复算子的工作量。3.3 初始化、交叉和变异算子初始化种群的逻辑很简单给每条候选路径一个随机权重然后缩放让总和等于总流量。function pop initPop(popSize, nPaths, totalFlow) pop zeros(popSize, nPaths); for i 1:popSize w rand(1, nPaths) 0.1; % 加0.1避免出现全零行 pop(i, :) w / sum(w) * totalFlow; end end算术交叉是我在实值编码时最常用的交叉方式它比单点交叉稳定因为子代能继承父本的大部分流量结构function [c1, c2] crossoverArith(p1, p2, totalFlow) alpha 0.4 0.2 * rand; % 随机取0.4到0.6的混合比例 c1 alpha * p1 (1 - alpha) * p2; c2 (1 - alpha) * p1 alpha * p2; % 归一化 c1 c1 / sum(c1) * totalFlow; c2 c2 / sum(c2) * totalFlow; end变异算子做的是路径间流量迁移随机选两条路径把其中一条的一部分流量挪到另一条上去。这样做的好处是总流量不会变不需要额外归一化而且变异范围可控。function c mutateReassign(indiv, totalFlow) c indiv; n length(c); idx1 randi(n); idx2 randi(n); while idx2 idx1 idx2 randi(n); end moveFraction 0.05 0.2 * rand; % 转移5%~25%的流量 delta c(idx1) * moveFraction; c(idx1) c(idx1) - delta; c(idx2) c(idx2) delta; end3.4 修复算子容量和预算违规处理修复算子是整个GA里最工程化的部分也是最容易写出bug的地方。核心逻辑是逐个检查容量违规把超容路径的流量削减掉转给其他路径。function indiv repairFlow(indiv, pathSet, cap, a, b, budgetLimit) indiv indiv(:); nPaths size(pathSet, 1); nEdges length(cap); % 计算每条边上的实际流量 edgeFlow zeros(1, nEdges); for p 1:nPaths path pathSet{p}; for k 1:length(path)-1 eid path(k); % 边的编号 edgeFlow(eid) edgeFlow(eid) indiv(p); end end % 容量修复找到超容边削减经过该边的路径流量 for e 1:nEdges if edgeFlow(e) cap(e) 1e-8 overflow edgeFlow(e) - cap(e); % 找出所有经过该边的路径 pathIdx findPathThroughEdge(pathSet, e); if isempty(pathIdx) continue; end % 按流量比例削减直到不超容 totalOnEdge sum(indiv(pathIdx)); for p pathIdx if overflow 1e-8 break; end cut min(indiv(p), indiv(p) / totalOnEdge * overflow * 0.8); indiv(p) indiv(p) - cut; overflow overflow - cut * (indiv(p) / totalOnEdge); end % 削减下来的流量重新分配给不经过该边的路径 freeIdx setdiff(1:nPaths, pathIdx); if ~isempty(freeIdx) redistribute sum(freeIdx) / sum(pathIdx) * (edgeFlow(e) - cap(e)); % 简化处理随机把流量加回到空闲路径 r freeIdx(randi(length(freeIdx))); indiv(r) indiv(r) min(redistribute, cap(e) * 0.1); end end end % 预算修复如果总可变成本超过预算就削减高成本路径流量 totalCost 0; for p 1:nPaths pathCost 0; path pathSet{p}; for k 1:length(path)-1 eid path(k); pathCost pathCost a(eid); end totalCost totalCost pathCost * indiv(p); end if totalCost budgetLimit % 计算每条路径的单位成本从高到低排序 pathUnitCost zeros(1, nPaths); for p 1:nPaths path pathSet{p}; pcost 0; for k 1:length(path)-1 eid path(k); pcost pcost a(eid); end pathUnitCost(p) pcost; end [~, sortIdx] sort(pathUnitCost, descend); excess totalCost - budgetLimit; for p sortIdx if excess 1e-8 break; end cut min(indiv(p), excess / pathUnitCost(p)); indiv(p) indiv(p) - cut; excess excess - cut * pathUnitCost(p); % 把削减的流量转移到单位成本最低的路径 indiv(sortIdx(end)) indiv(sortIdx(end)) cut; end end % 最后保证非负 indiv(indiv 0) 0; end这个修复函数表达的是核心思想不是能直接跑的所有细节。真正放到工程里我会把“路径集合”预计算成“路径-边矩阵”用矩阵乘法算边流量比循环快一个数量级。在Matlab里sparse矩阵配合edgeFlow pathEdgeMatrix * indiv是标准操作。有一点特别提醒修复算子会显著改造个体如果改造力度太大种群会很快同质化所有个体都被修成相似的样子遗传多样性直接崩掉。所以修复算子不能“修得太完美”每次修复把违规压到允许误差范围内就停剩下的交给罚函数去兜底。3.5 向量化适应度计算适应度评估需要反复计算边流量如果每个个体都循环遍历路径和边300代乘以60个个体每次几百条路径在Matlab里会慢到让人怀疑人生。我的做法是把“路径包含哪些边”这个关系提前建成矩阵% pathEdgeMat: nPaths x nEdges 的稀疏01矩阵 % edgeFlow pathEdgeMat * indiv; % 对所有个体一次算完这样整个种群的边流量计算只需要一次矩阵乘法。适应度评估也从逐个体循环变成了整体计算速度提升非常明显。对于300代以内的进化Matlab跑完只需要几秒钟到几十秒完全在工程可接受范围内。4. 实验设置、收敛表现与参数敏感性4.1 测试算例设计为了验证算法我构造了一个12个节点、25条边的中型网络。网络中有2个源点、2个汇点总需求流量 (F50) 单位预算上限 (B180)路径跳数上限 (L6)。参数值节点数12边数25源点数2汇点数2总需求流量50边容量范围[3, 12]单位成本系数 (a_e)[0.5, 3.0]拥塞系数 (b_e)[0.02, 0.15]预算上限180路径跳数上限6这个规模不算大但约束一旦叠加很多经典算法就失效了。候选路径经过DFS枚举后得到87条。4.2 收敛表现与分析用默认参数跑一轮结果如下第12代左右种群中出现第一个完全满足容量和预算约束的可行个体第80代左右历史最优成本开始明显下降从260左右降到210上下第180代之后基本稳定最终最优成本稳定在178.6左右与传统最小费用流算法对比在不加预算约束和跳数约束的情况下最小费用流可以求出一个基准解成本约164.3。加上跳数约束后最小费用流仍然可以处理通过限制图结构但一旦加入预算约束传统增广路方法直接失效而GA给出的可行解成本为178.6相比基准解高出约8.7%。这个差距是“约束变严格”的代价。8.7%的差距在工程调度中完全可以接受因为GA求解的是传统方法无法直接求解的问题对比基准本身就是参考意义。4.3 参数敏感性实验为了给读者一个直观的调参参考我做了一组对比实验。种群大小取30、60、100三档变异率取0.05、0.15、0.3三档每组跑10次取平均值种群大小变异率平均最优成本平均收敛代数300.05189.4210300.15184.2180600.05182.7170600.15178.6160600.30179.81401000.15177.9120几点解读种群从30增加到60解质量提升明显再增到100提升幅度就很小了。对于这个规模的问题60是一个性价比很高的值。变异率为0.05时收敛偏慢解质量也差一些因为修复算子容易把种群推向同质化缺少变异来补充多样性。0.15到0.3之间差异不大0.15更稳。收敛代数随种群增大而提前因为初始种群中涌现高质量个体的概率更高。另外固定随机数种子这件事极其重要。我在调试阶段有几次没固定rng每次跑出来的结果差异很大一度以为是算法写错了后来才发现是随机性在作怪。最终代码里用rng(42)固定种子复现结果完全一致。4.4 一个容易被忽略的评估指标可行解比例除了最优成本之外我强烈建议每一代都统计一下种群中可行解的比例。这个指标比收敛曲线更能反映约束修复体系的健康度。如果可行解比例一直在20%以下说明修复算子设计有问题或者惩罚系数太小种群大量个体在不可行区域游荡。正常情况应该是前20代可行解比例快速上升后期稳定在80%以上。有一次我把预算惩罚系数调得太小可行解比例一直卡在15%左右算法跑了200代也无法找到满意解。后来把 (\lambda_{\text{budget}}) 从5调到30问题立刻好转。这个指标能帮你快速定位问题出在约束还是搜索。5. 实战中容易踩的坑与后续改进方向5.1 三个最容易翻车的细节第一个坑候选路径集不完整。如果DFS枚举时深度限制太严格或者剪枝逻辑太激进会漏掉必要的路径。漏掉一条低成本关键路径GA再怎么进化都到不了好的解区域。我的验证方法是先用纯最小费用流求一组最优路径检查这些路径是否在候选集里不在就要检查枚举逻辑。第二个坑修复算子过度干预导致多样性崩溃。修复算子每一次都在把不可行个体“拉回正轨”但如果拉得太彻底比如直接把流量全部移到某一条最优路径上那么种群中的所有个体都会变得差不多交叉变得毫无意义。正确的做法是每次修复只处理违规部分保留个体原有的其他结构特征。第三个坑精英保留数量太少。精英保留是防止历史最优丢失的保护机制但在带修复算子的GA里精英个体也可能在交叉后被子代覆盖。如果精英数量只有1个偶尔会被修复造成微小劣化后丢掉。我一般保留2到3个精英并且每次收敛时用局部修复再精修一次精英个体。5.2 从GA到更鲁棒的工程解法如果只是交作业上面的代码够用了。但如果这个算法要上到生产环境我认为至少有三个改进方向值得做自适应变异率当种群多样性下降时自动调高变异率。简单实现就是统计种群中所有个体基因的标准差低于阈值就把 (pMut) 从0.15提高到0.3。引入局部搜索的Memetic算法GA负责全局搜索找到有希望的区域后用贪婪下降法在个体层面再优化。具体做法是对每个精英个体计算每条路径的边际成本把流量从边际成本最高的路径挪到最低的路径反复几次。这个操作成本不高但能显著提升最终解质量。我实测下来memetic版本比纯GA在相同代数下平均降低3%~5%的目标值。多目标扩展如果实际问题里同时要优化“总成本”和“最大边负载率”单目标GA就不够了。这时候可以直接把适应度改造为NSGA-II的非支配排序逻辑染色体编码和修复算子完全不用变改动只集中在选择机制上。这样一套代码基础可以复用到很多场景。5.3 关于算法工程化的一句大实话说句实话学术界很多文章用GA跑优美的小规模算例看起来效果很好但搬到工程现场网络规模一上来路径数量爆炸GA的收敛时间会变得很难看。这时候不建议硬着头皮把GA做大而是先降维用图压缩或聚类把超大网络拆成若干子网络分别求解再用GA做子网络间的流量协调。我在实际项目中就是把全网拆成三个子区域每个子区域用确定性算法求可行流GA只负责区域间的流量比例分配效果出奇地好。另外如果你面对的约束网络流是带时间窗的、带随机需求的那么GA只能算是一个基础框架真正起作用的反而是约束处理和局部搜索算子。这也是为什么我一直强调GA本身不神秘工程价值全在“编码怎么设计、约束怎么修、解怎么精调”这三个环节上。把这三点想清楚剩下的就是调参和耐心了。
阅读完成 · 觉得有帮助?
咨询建站