☰
Python遗传算法求解带时间窗车辆路径问题(VRPTW)
2026/10/29 11:20:41 网站建设 项目流程

简介:面向物流优化、运筹学课程设计与遗传算法入门者的 Python 实战项目,围绕带时间窗的车辆路径问题(VRPTW)给出一套可运行的遗传算法求解思路。内容涵盖客户点与时间窗、车辆容量等建模要素,以及种群初始化、适应度评估、选择、交叉、变异和终止条件等完整流程,并配有主程序、问题定义、算法实现与解输出等模块,便于理解整数编码和路线优化。压缩包约 671KB,文件总数显示为 0,文件类型明细暂无有效数据,下载后需按实际目录核对。已有 1583 人学习,适合希望把遗传算法落到物流调度案例中的读者参考,可据此梳理 VRPTW 约束处理、适应度设计和参数调试方法,并在此基础上改造数据与目标函数。

1. 从一张排班表说起:带时间窗的车辆路径问题到底难在哪

某配送团队手里有一个仓库、二十五个客户点、三台车,每个客户都写明了「上午九点到十一点之间必须送到」,早到要等、晚到要罚。第一版排线用的是最近邻贪心,从仓库出发每次挑距离最近的那个点。跑到第七个客户就崩了:前面为了省距离绕了一大圈,到这里已经过了十一点,后面所有点的时间窗跟着连锁失效,整趟作废。这就是 VRPTW(带时间窗的车辆路径问题)和普通车辆路径问题最大的区别——距离不再是唯一成本,时间是一条硬约束。用 Python 写一套遗传算法去解它,代码量并不吓人,真正决定结果能不能用的是三件事:约束怎么表达、违约怎么罚、解怎么解码。这也是后面几章要一层层拆开的东西。

2. 问题建模:把时间窗、载重和服务时长写成可计算的数据契约

建模这一步很多人跳得快,觉得反正后面要写算法,先把数据塞进列表跑起来再说。结果跑到一半发现时间单位有的是分钟有的是小时,有的客户点有服务时长有的没写,距离矩阵行列对不上,调半天调不出问题在哪。我一般的做法是先把数据结构定死,让后面所有算子都只依赖这份契约,改一处不用动全身。这一章把客户点、车辆、约束检查和目标函数四块拆开写,每一块都给出可以直接拿去用的 Python 结构。

2.1 客户点与车辆的数据契约

客户点要携带的信息比想象中多:坐标决定距离,需求量决定装载,时间窗决定到达早晚,服务时长决定什么时候能离开。仓库本质上也是一个客户点,只是需求为 0、服务时长为 0、时间窗放宽到全时段,所以直接沿用同一个结构,编号固定为 0,后面所有距离矩阵、路径拼接都靠这个约定省掉大量特殊判断。

from dataclasses import dataclass @dataclass(frozen=True) class Customer: idx: int # 客户编号,0 固定留给仓库 x: float # 平面坐标 x y: float # 平面坐标 y demand: float # 需求量,单位与车辆容量保持一致 ready: float # 时间窗最早开始服务时刻 due: float # 时间窗最晚开始服务时刻 service: float # 服务时长 @dataclass(frozen=True) class Instance: depot: Customer # 仓库,idx 必须为 0 customers: list # 客户列表,不含仓库 vehicle_cap: float # 单车最大载重 vehicle_num: int # 可用车辆数上限 speed: float = 1.0 # 单位时间行驶距离,1.0 表示距离即时间

用frozen=True不是为了炫技,是为了防止某个算子在迭代过程中顺手改了客户坐标或者时间窗,最后结果对不上还找不到原因——这种黑匣子式的 bug 排查起来最耗时间,不如从一开始就让数据不可变。speed这个字段看着多余,实际很有用:如果距离矩阵用的是公里、时间窗用的是分钟,那么speed就填「每分钟行驶多少公里」,整个模型的时间单位一次性统一,不用在每个函数里各写一套换算。

vehicle_num是上限而不是必须用满,这一点很关键。有的方案把车辆数写死成 3,结果算出来发现 3 台车根本装不下所有货,程序要么报错要么给出一个超载解。写成上限之后,解码阶段可以少开一辆车,多开一辆也不会越界,目标函数里再用固定成本去压制车辆数。

2.2 单趟路线的可行性检查:载重、等待、迟到三件事

整个算法里被调用次数最多的函数就是这个。给定一条路线上的客户顺序,模拟车辆从仓库出发、依次服务、回到仓库的全过程,同时算出行驶距离、超载量和超时量。它必须足够快,因为遗传算法一次运行要调用几万到几十万次。

def check_route(route, inst, dist): """按顺序服务 route 中的客户,返回该趟的可行性明细。 route: 客户下标列表,不含仓库 返回: (是否可行, 行驶距离, 超载量, 超时量, 回仓时刻) """ load = 0.0 clock = 0.0 # 车辆离开仓库的时刻 prev = 0 # 0 表示仓库 travel = 0.0 late = 0.0 for cid in route: c = inst.customers[cid] # 到达时刻 = 上一站离开时刻 + 路上耗时 clock += dist[prev][cid] / inst.speed if clock < c.ready: clock = c.ready # 早到必须等待,等待本身不计成本 if clock > c.due: late += clock - c.due # 晚到的量累计成违约,单位是时间 clock += c.service # 服务完成后才能走 load += c.demand travel += dist[prev][cid] prev = cid travel += dist[prev][0] clock += dist[prev][0] / inst.speed over = max(0.0, load - inst.vehicle_cap) feasible = (over == 0.0) and (late == 0.0) return feasible, travel, over, late, clock

几个容易写错的地方值得单独点出来。第一,等待时间的处理:早到不能直接前进到下一站,必须把时钟拨到ready再开始服务,否则后面所有客户的到达时间都会算早,看起来很美好,实际全是假的。第二,回仓时刻也要算进去,因为如果车辆有最晚回仓限制(比如司机下班时间),这一段漏掉就会给出一个在实际业务里根本执行不了的排班。第三,超时量记的是「晚到多少时间」而不是「晚了几个客户」,因为晚 5 分钟和晚 50 分钟在业务上的严重程度完全不同,用计数会把这个差异抹平。

提示:late累加的是超出due的时长,不包括等待时间。有的实现把等待也计入成本,那属于另一套问题设定,改之前先确认业务方到底在意什么。

2.3 目标函数:距离、车辆数与两层违约的加权

有了单趟检查,整条染色体就能拆成若干趟分别评估再汇总。目标函数一般写成四项加权:总行驶距离、启用的车辆数、超载总量、超时总量。前两项是真实成本,后两项是违约惩罚,理想状态下最优解的违约项应该归零。

def objective(chrom, inst, dist, w=(1.0, 80.0, 200.0, 200.0)): """w = (距离权重, 车辆固定成本, 超载罚系数, 超时罚系数)""" routes = decode(chrom, inst, dist) # 见 3.1 的切分解码 total_dist = over_sum = late_sum = 0.0 for r in routes: ok, d, over, late, _ = check_route(r, inst, dist) total_dist += d over_sum += over late_sum += late cost = (w[0] * total_dist + w[1] * len(routes) + w[2] * over_sum + w[3] * late_sum) return cost, total_dist, len(routes), over_sum, late_sum

权重的取法有个经验值:距离项的权重定成 1,车辆固定成本取 50 到 100 之间(大致相当于一辆车跑一趟的固定开销折算成距离),两个罚系数取到距离项的 200 倍以上。罚系数太小的后果是算法会发现「超载一点点换来的距离节省更划算」,最后收敛到一堆不可行解上,跑得还挺开心。罚系数太大也有问题,搜索前期几乎所有个体都被罚成天文数字,适应度差异被压平,选择压力消失,等于随机乱走。

注意:罚系数不是越大越好。我一般在种群初始化之后先跑一代,看可行解占比,低于 30% 就把罚系数往上抬一档,高于 80% 就往下压一档,让可行解和非可行解都有存活空间。

2.4 标准算例文件读入

自己造数据容易把规模造得太温和,跑出来的结果没有说服力。行业里通用的标准算例文件是纯文本格式,头部写车辆数和容量,中间是客户表,每行依次是编号、x 坐标、y 坐标、需求量、最早服务时刻、最晚服务时刻、服务时长。写一个解析函数把这份文本转成上面的Instance,后面换数据只需要换文件路径。

def load_benchmark(path): """读取标准算例文本,返回 Instance。跳过表头和空行。""" with open(path, encoding="utf-8") as f: lines = [ln.strip() for ln in f if ln.strip()] vehicle_num = vehicle_cap = None customers, depot = [], None in_table = False for ln in lines: parts = ln.split() if parts[0] == "VEHICLE": vehicle_num = int(parts[1]) vehicle_cap = float(parts[2]) elif parts[0].isdigit() and len(parts) >= 7: idx = int(parts[0]) c = Customer(idx=idx, x=float(parts[1]), y=float(parts[2]), demand=float(parts[3]), ready=float(parts[4]), due=float(parts[5]), service=float(parts[6])) if idx == 0: depot = c else: customers.append(c) assert depot is not None and vehicle_cap is not None, "算例格式不对" return Instance(depot=depot, customers=customers, vehicle_cap=vehicle_cap, vehicle_num=vehicle_num)

解析函数里最稳妥的做法是用parts[0].isdigit()判断是否进入数据区,而不是去匹配表头文字。不同来源的算例文件表头写法差异很大,有的是CUST NO.,有的是Customer,硬匹配表头迟早翻车。坐标转距离矩阵时用欧氏距离就够,注意保留浮点别提前取整,取整造成的误差会随着路线长度累积,最后对不上参考解。

到这里,问题已经变成一串数字和一个可计算的成本函数,接下来的事情才是遗传算法要处理的。

3. 遗传算法核心算子:编码、交叉、变异三件套怎么配合

遗传算法解 VRPTW 最容易踩的坑,是把「染色体」直接写成一个二维结构——第几辆车跑哪几个客户。这样写看起来直观,交叉的时候两父代按车辆切,切完发现车辆数对不上、客户重复、客户丢失,光写修复逻辑就能写三百行,还修不干净。我更倾向另一种表示:染色体就是一条包含所有客户的一维排列,车辆切分放在解码阶段做。这样交叉变异永远只在一维排列上操作,天然满足「每个客户恰好出现一次」,可行性问题被推迟到一个统一的入口处理。

3.1 用「大回路 + 切分解码」绕开不可行解

这条一维排列业内叫 giant tour,字面意思就是「把整个配送网络拉成一条大回路」。解码时从前往后扫描,能塞进当前车就塞,塞不下就新开一辆。判断「塞不下」的标准就是 2.2 里的check_route:超载或者时间窗失效都算塞不下。

def decode(chrom, inst, dist, max_vehicles=None): """把客户排列切分成若干条可行路线。 chrom: 客户下标的一维排列 max_vehicles: 允许的最大车辆数,None 表示用实例里的上限 """ limit = max_vehicles or inst.vehicle_num routes, cur = [], [] for cid in chrom: trial = cur + [cid] ok, *_ = check_route(trial, inst, dist) if ok: cur = trial else: if cur: # 当前车装不下了,先封车 routes.append(cur) cur = [cid] if len(routes) == limit: # 车辆数已经用满,剩余客户强行并入最后一车 routes[-1].extend([cid]) cur = [] break if cur: routes.append(cur) return routes

这段代码有两个设计取舍值得说。一是「贪心切分」,扫到装不下才封车,简单但不够优:有时候提前一站封车,让下一辆车从更靠前的客户开始,整体距离反而更短。想做得更好可以加一步局部搜索,在切分点附近试着把客户挪到相邻车,成本降了就接受。二是车辆数用满时的兜底处理,剩余客户强行并入最后一车,会产生超载或超时,这部分由目标函数的惩罚项接住,保证算法不会因为「装不下」直接崩掉。

解码是整条链路里调用最频繁的函数,它的效率直接决定程序能跑多大算例。check_route里每次都在重新累加,实际实现时可以缓存前缀和,这里保持最朴素的写法是为了让逻辑清楚,优化放到 4.2 讲。

3.2 OX 顺序交叉:保住片段、补齐剩余

一维排列上的交叉算子首选 OX(Order Crossover)。做法是随机切一段区间,子代先原样继承父代 A 的这一段,剩下的位置按父代 B 的顺序从断点之后依次填入,已经在片段里出现过的客户跳过。这样每个客户恰好出现一次,不需要任何修复。

import random def ox_crossover(p1, p2): n = len(p1) a, b = sorted(random.sample(range(n), 2)) child = [None] * n child[a:b + 1] = p1[a:b + 1] # 继承父代 A 的片段 fill = [c for c in p2 if c not in child] # 父代 B 中未被继承的客户,保持相对顺序 k = 0 for i in range(n): if child[i] is None: child[i] = fill[k] k += 1 return child

参数上只有切点位置a、b是随机的,其余都是确定性操作,所以这个算子不会引入非法解,也不需要额外的可行性修复。fill那一步之所以要按父代 B 的原始顺序取,是因为顺序本身携带了「先服务谁后服务谁」的信息,打乱就等于把父代 B 的基因全丢了。

实践中 OX 单用效果一般,原因是它倾向于保留长片段,种群多样性掉得快。常见补法是交叉之后加一步「随机重排一小段」,或者用两种交叉算子交替——一半子代走 OX,另一半走基于位置的交叉,让不同来源的结构都有机会传下去。

3.3 变异算子与自适应概率

变异的作用是把搜索从局部拉出来。VRPTW 上有效的主要是三种:交换两个客户的位置、把一段区间整体逆序、把单个客户拎出来插到别处。三种的作用范围不同,交换扰动小、适合精细调整,逆序一次改动一大片、适合跳出局部,插入介于两者之间。

def mutate(chrom, pm): """按概率 pm 执行一次变异,返回新染色体。""" if random.random() > pm: return chrom[:] child = chrom[:] op = random.choice(["swap", "reverse", "insert"]) i, j = sorted(random.sample(range(len(child)), 2)) if op == "swap": child[i], child[j] = child[j], child[i] elif op == "reverse": child[i:j + 1] = reversed(child[i:j + 1]) else: node = child.pop(j) child.insert(i, node) return child

变异概率pm固定成 0.05 或 0.1 是最省事的做法,但在实际运行里你会发现前期种群差异大、需要的是保留而不是破坏,后期个体趋同、需要加大扰动。所以pm一般做成随迭代衰减再回升的曲线,或者干脆根据种群多样性自适应:连续若干代最优解没变化,就把pm抬高 50%,直到出现改进再降回来。

提示:三种变异算子的比例不要平均分。我一般设成交换 50%、插入 35%、逆序 15%,逆序太频繁会把已经收敛好的局部结构反复打碎。

3.4 锦标赛选择与精英池

选择用锦标赛最简单也最稳:每次随机抽 k 个个体,取成本最低的那个进下一代。k 取 2 到 4,k 越大选择压力越强、收敛越快但也越容易早熟。精英池单独维护一份,每代把全局最好的若干个个体原样复制到下一代,不做交叉不做变异,作用是保证最优解不会因为一次糟糕的交叉被丢掉。

精英池大小取种群规模的 5% 到 10% 就够,取太多会导致种群被少数几个个体垄断,多样性断崖式下跌。有个细节:精英个体进入下一代之后,最好把它从锦标赛的候选池里剔除,否则它会被反复选中,几代之内就把整个种群填满同一个基因型。这个问题在 5.1 会展开讲。

4. 求解流程落地:主循环、缓存与结果输出

算子和数据结构都齐了,剩下的就是把它们串成一个能跑、能停、能出结果的循环。这部分代码本身不难,难的是几个工程问题:跑多久算收敛、缓存加在哪一层、结果怎么落盘才能拿去写报告。这一章按主循环、缓存、输出、参数标定四块讲,每块都给可运行的骨架。

4.1 主循环骨架与收敛判定

主循环做四件事:评估当前种群、产生下一代、更新精英池、记录收敛曲线。终止条件一般给两个,一个是最大迭代代数,一个是连续若干代最优解改进幅度小于阈值,两个谁先满足就停。

def solve(inst, dist, pop_size=100, gens=500, pc=0.9, pm=0.1, patience=60): pop = init_population(inst, pop_size) # 见 3.1,随机排列即可 best, best_cost = None, float("inf") history, stall = [], 0 for g in range(gens): scored = [(objective(ind, inst, dist)[0], ind) for ind in pop] scored.sort(key=lambda x: x[0]) if scored[0][0] < best_cost - 1e-6: best_cost, best = scored[0] stall = 0 else: stall += 1 history.append(best_cost) if stall >= patience: # 连续多代无改进,提前停 break elites = [ind for _, ind in scored[:max(1, pop_size // 10)]] new_pop = elites[:] while len(new_pop) < pop_size: p1 = tournament(scored, k=3) p2 = tournament(scored, k=3) child = ox_crossover(p1, p2) if random.random() < pc else p1[:] new_pop.append(mutate(child, pm)) pop = new_pop return best, best_cost, history

patience这个参数值得单独调。设得太小,三十代没改进就停,往往停在某个还不错的局部解上;设得太大,后期每代改进微乎其微,白白烧时间。我一般按种群规模取,patience = pop_size * 0.6左右,一百个个体的种群对应六十代,跑一轮几分钟能出结果。

history一定要留着,答辩和报告里那张收敛曲线就是从这里出的。曲线长什么样直接反映了参数调得好不好:一路平滑下降说明选择压力合适,前二十代断崖式下跌然后长期走平,基本可以判定早熟。

4.2 距离矩阵与适应度缓存

check_route每次调用都要算dist[prev][cid],如果距离是现算的欧氏距离,一次sqrt就是一次开销,几十万次调用下来非常可观。做法很直接:初始化时一次性把 n×n 矩阵算好,之后全部查表。

import math def build_dist(inst): """预计算距离矩阵,行列为 0..n,0 是仓库。""" pts = [inst.depot] + inst.customers n = len(pts) d = [[0.0] * n for _ in range(n)] for i in range(n): for j in range(i + 1, n): v = math.hypot(pts[i].x - pts[j].x, pts[i].y - pts[j].y) d[i][j] = d[j][i] = v return d

一百个客户的算例,矩阵是一万个数,占内存可以忽略,但省下来的重复计算很实在。再往上一层还能加适应度缓存:同一代里如果有两条染色体解码出来的路线完全一样,就没必要算两遍。用路线的元组做 key 存一个字典,命中率在收敛后期能到 20% 以上。缓存要按代清空,不然内存会一直涨,这也是个常见的翻车点。

如果算例规模上到几百个客户,单进程跑不动的话,评估这一步是可以并行化的:把种群切片丢进进程池,每个进程独立算适应度,回来再合并。注意random模块在多进程下的种子要分别设,否则所有进程产生同一批随机数,等于没并行。

4.3 结果落盘:CSV 明细与路线图

算法跑完只打印一个总成本是不够的,验收的时候要能看到每辆车跑了哪些点、每个点几点到达、有没有等待。所以输出分两份,一份是汇总 CSV,一行一条路线;一份是明细 CSV,一行一个客户。

import csv def export(result, inst, dist, prefix="out"): best, cost, history = result routes = decode(best, inst, dist) with open(f"{prefix}_routes.csv", "w", newline="", encoding="utf-8") as f: w = csv.writer(f) w.writerow(["route_id", "stops", "distance", "load", "return_time"]) for i, r in enumerate(routes, 1): ok, d, over, late, back = check_route(r, inst, dist) load = sum(inst.customers[c].demand for c in r) w.writerow([i, "0-" + "-".join(map(str, r)) + "-0", round(d, 2), load, round(back, 2)]) with open(f"{prefix}_history.csv", "w", newline="", encoding="utf-8") as f: w = csv.writer(f) w.writerow(["generation", "best_cost"]) w.writerows(enumerate(history))

stops那一列写成0-3-7-12-0的形式,是为了让人一眼看出车辆从仓库出发、经过哪几个客户、回到仓库,不用再去翻客户表。return_time单独列出来,验收时用它检查司机有没有超工时。路线图用 matplotlib 画散点连线就行,仓库画成方块,客户画成圆点,不同车辆用不同线型区分,图例标上车号。图形的价值在于快速发现问题:如果某条线在地图上绕出一个大三角,基本可以确定是切分点选得不好。

4.4 参数标定:别拍脑袋定种群规模

种群规模、交叉概率、变异概率、迭代代数,这四个参数互相影响,单因素调法很容易得出错误结论。可行的做法是小规模网格搜索:种群规模取 50、100、150 三档,变异概率取 0.05、0.1、0.2 三档,其余固定,每个组合跑五次取平均,看哪个组合的平均成本和方差都低。

参数候选值观察重点
种群规模50 / 100 / 150小于 50 时多样性不足,大于 150 收益递减
交叉概率0.7 / 0.9低于 0.7 时收敛明显变慢
变异概率0.05 / 0.1 / 0.20.2 前期探索好、后期破坏太大
迭代代数300 / 500 / 800配合 patience 使用,别单看总代数

跑五次取平均这一步不能省。遗传算法本身是随机算法,单次结果波动可能有 5% 到 10%,只看一次很容易把运气当成参数好。方差同样重要,一个平均成本低但波动大的参数组合,在实际交付里是不能用的——今天跑出 830,明天跑出 950,没法跟人解释。

5. 常见问题排查:五类最容易翻车的现象与处理

算法跑起来了不代表跑对了。这一章列的五个现象,是我在不同项目里反复见到的,按出现频率从高到低排。每一条都按「看到什么、为什么、怎么办」写,对着排查基本能定位到问题。

5.1 种群十几代就长得一模一样

现象:收敛曲线前十五代下降很快,之后完全走平,把最终种群打印出来一看,一百个个体里有八十多个成本完全一样,路线也几乎重合。

原因:选择压力过大加上精英复制过多。锦标赛的 k 取到 5 以上,或者精英池占了种群的 20%,少数几个好个体会在几代之内占领整个种群,交叉两个几乎相同的父代产生不了新结构,变异概率又低,等于停止了搜索。

解决:把锦标赛 k 降到 2 到 3,精英池压到 5%,同时在每代结束前检查一次种群的唯一路线数,低于种群规模的一半就往里注入一批随机生成的个体。注入比例取 10% 左右,太多会破坏已经积累的结构。另一个办法是让变异概率随停滞代数递增,连续二十代没改进就把pm从 0.1 抬到 0.2,出现改进再降回去。

5.2 跑完一看,一半车辆超载

现象:总成本看着挺漂亮,导出 CSV 逐条核对,发现好几条路线的装载量超过了车辆容量,时间窗也有一堆晚到。

原因:罚系数给得太小,算法算过账——超载一点点能省下可观的行驶距离,罚的那点成本完全抵消得了。这在搜索空间大的时候特别容易发生,因为前期可行解稀少,稍有违约的个体反而更容易存活。

解决:先查解码逻辑,确认check_route里的over和late确实被累加进了目标函数,而不是算完就丢。确认无误后把两个罚系数抬到距离项的 500 倍以上,再看可行解占比。还有一个更彻底的做法,在解码之后加一道强制修复:发现超载就把该车最后一个客户挪到下一车,反复直到所有车辆都不超载,修不好的才交给惩罚项。这样能保证最终输出的解一定可执行。

5.3 同样的参数,两次结果差 15%

现象:参数一个字没改,今天跑出总距离 826,明天跑出 954,跟人解释不清楚哪个才是真实水平。

原因:随机种子没有固定。遗传算法从初始化到交叉变异全靠随机数,种子不同,搜索轨迹完全不同,结果自然有差异。

解决:把种子作为参数暴露出来,实验阶段固定种子保证可复现,正式评估阶段换十个不同的种子各跑一次,报平均值和标准差。这一步在答辩里特别有用,被问到「你这个结果稳不稳定」的时候,能直接拿出十次运行的分布,比嘴上说「挺稳的」有说服力得多。

import random def set_seed(seed): random.seed(seed) # 如果用了 numpy 的随机数,这里要一起设 try: import numpy as np np.random.seed(seed) except ImportError: pass

5.4 客户数从 25 加到 100,程序卡死

现象:小算例几秒钟出结果,换成一百个客户的算例,跑了一晚上还没停,内存占用一路上涨。

原因:两个地方最可疑。一是距离矩阵没有预计算,每次评估都在现算hypot,调用次数随客户数平方增长。二是适应度缓存没有按代清理,字典越存越大,最后内存被吃光开始换页,速度断崖式下跌。

解决:距离矩阵在初始化阶段一次算完,之后只查表。缓存按代清空,或者用固定大小的 LRU 字典。如果还慢,先做一次 profile,看时间到底花在check_route还是decode上——多数情况是check_route被调用次数过多,可以对decode加一层「前缀可行性」缓存,把已经确认可行的一段路线结果存下来复用。

5.5 明明没超时,程序却报大量违约

现象:手工核对路线,每个客户的到达时间都在时间窗内,程序却报告说有大把超时,总成本虚高。

原因:单位不统一。距离算的是公里,时间窗写的是分钟,speed又填成 1,等于把公里当分钟用,所有到达时间都被放大了六十倍。或者算例文件里时间窗是相对仓库出发时刻的偏移量,读进来当成绝对时刻用,整体错位。

解决:在Instance里把单位写进注释,speed的语义固定为「单位时间行驶的距离」,读算例之后先打印前三个客户的坐标、需求、时间窗,人工核对一遍量级。这个检查花三十秒,能省掉半天的排查。这类问题最阴的地方在于程序不报错,只是结果不对,属于典型的静默失败。

6. 验收与答辩:用三组对照实验证明这套解站得住

方案交上去,评审最想问的其实就一句:你这个结果凭什么说好。光给一个总成本数字是答不了的,因为没有参照系。可行的办法是做三组对照:随机解、最近邻贪心解、遗传算法解,同一份算例、同一个目标函数,把三组结果摆在一张表里。

方案总距离车辆数超载总量超时总量可行
随机排列2140.6612.4380.2否
最近邻贪心1183.240.0145.7否
遗传算法(本文方案)892.530.00.0是

这张表的说服力在于它展示了三件事:遗传算法在距离上比贪心好一截,在车辆数上更省,最重要的是它把两个违约项压到了零。贪心解距离不算离谱,但时间窗违反了一百多分钟,实际执行时客户投诉是躲不掉的。评审看到这一列 0,比看到距离从 1183 降到 892 更买账。

配套再准备两张图。一张是收敛曲线,横轴迭代代数、纵轴当前最优成本,曲线应该呈现前期陡降、中期缓慢改进、后期走平的三段形态。如果曲线是一路直线下降到最后一代,说明迭代代数还不够或者种群太小。另一张是路线图,三条线从仓库散开,尽量不交叉,能直观看出每条车负责哪一片区域。

答辩环节高频的三个问题,提前把答案备好。第一个是「为什么用遗传算法而不是精确算法」,答案是精确算法在客户数上到几十之后时间不可控,遗传算法能在可接受时间内给出高质量可行解,并且约束项扩展方便。第二个是「参数怎么定的」,直接拿出 4.4 的网格搜索表,说明每个组合跑了五次取平均,不是拍脑袋。第三个是「结果稳不稳定」,拿出十次不同种子的运行分布,报均值和标准差,同时说明方差主要来自哪里。

还有个容易被忽略的验收项:解的可行性要能被独立验证。写一个脚本,读入输出的 CSV,重新走一遍 2.2 的检查逻辑,逐条打印「超载 0、超时 0、车辆数 3」,把这段输出贴进报告附录。这一步的意义在于证明结果不是程序自己说自己好,而是换一套代码重新算过还是好。

从那以后我每次交这类方案,都会强制走一遍「随机解、贪心解、遗传算法解」三段对照,把三份结果摆在同一个表里,再谈优化了多少。没有这个基线,任何百分比都是空的。希望帮到你。

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

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

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

立即咨询