简介:面向物流优化、运筹学课程设计与Python算法实践的VRPTW遗传算法项目,聚焦带时间窗车辆路径问题的建模与近似求解,适合具备一定Python基础、希望理解全局优化流程的学习者。项目围绕整数编码、适应度评估、选择、交叉、变异与终止条件展开,并将客户位置、时间窗和车辆容量等问题细节与算法主逻辑、解解析和主程序分层组织,便于对照理解遗传算法如何落地到路径规划场景。压缩包约671KB,文件总数为0且类型明细暂无数据,具体目录构成需下载后确认。目前已有1585人学习下载,具备一定参考热度。读者可从中获得问题建模思路、遗传算子设计参考、求解结果解析方式,以及将启发式算法用于实际物流调度的完整案例,也可据此复现实验并进一步调整种群规模、交叉变异策略与适应度函数,形成自己的VRPTW求解方案。
1. 需求切入:时间窗是硬性约束,不是路线图上的备注
前阵子帮一位同学看他的课程设计。配送数据里,每个客户点除了坐标和需求量,还多了“最早到达”和“最晚到达”两列。他的 Python 脚本把这两列读进来之后就没再用过,最后跑出一张路线图,看起来挺像回事;可一问“哪个客户迟到了几分钟”,代码里根本没有答案。带时间窗的车辆路径问题(VRPTW)最容易在这里出问题:时间窗被当成了字段装饰,遗传算法只在缩短路线,没人检查约束。
这套方案要做的,就是用 Python 写一个能实际跑起来的遗传算法求解器,把容量、时间窗、车辆数三件事同时纳入约束,最终输出可以逐条核对的路线明细。它适合正在做运筹优化方向毕业设计或课程设计的人,也适合手里已有一批带时间窗的配送数据、需要先跑出基线结果的工程同学。
2. 总体架构设计:数据层、算法层与评估层
VRPTW 的代码写不顺,八成是因为一开始就把“读数据、造解、算代价、出报告”四件事搅在同一个文件里。数据层改一个字段,算法层跟着报错;评估层想换一种惩罚项,又得把解码逻辑重写一遍。我的建议是先把三层切干净:数据层只负责把实例文件变成一个不可变的Instance对象,算法层只负责在排列空间里做搜索,评估层只负责把一条染色体翻译成可读的路线并算出代价。
2.1 数据层:客户点、仓库与车辆字段
数据层的核心是一个客户点结构。字段不用多,但每个字段都得能回答一个具体问题:需求量决定装不装得下,最早时间决定要不要等待,最晚时间决定有没有迟到,服务时长决定离开时间。仓库单独约定为编号 0,后面所有“从仓库出发、回仓库”的算术都依赖这个约定,省掉很多判断。
from dataclasses import dataclass @dataclass class Customer: idx: int # 客户编号,0 固定表示仓库 x: float # 横坐标 y: float # 纵坐标 demand: float # 需求量 ready: float # 最早可开始服务时间 due: float # 最晚可开始服务时间 service: float # 服务时长 @dataclass class Instance: name: str capacity: float # 单车容量 vehicle_limit: int # 可用车辆上限 customers: list # 下标即编号,customers[0] 是仓库 def __post_init__(self): assert self.customers[0].idx == 0, "仓库编号必须为 0"这段代码的关键不在dataclass本身,而在__post_init__里的那条断言。实例文件的行顺序偶尔会乱,或者解析时漏掉仓库行,如果不在这里拦住,后面算距离矩阵会出现下标错位,报出来的错会指向一个跟数据毫无关系的行号。通常的做法是在数据层把所有“编号必须连续、仓库必须存在、时间窗必须ready <= due”这类检查一次性做完,让算法层拿到的一定是干净数据。
还有一个细节值得提前定:customers列表用下标访问,而不是用字典按编号查。VRPTW 的评估函数会被调用几十万次,dict查找和list下标在单次调用上看不出差别,放到 300 代乘 100 个个体乘 100 个客户的量级上,就会变成几秒钟的差距。这个优化不复杂,但很值得。
2.2 求解链路:从实例文件到可核对路线
整条链路我一般拆成四步,每一步都有明确的输入和输出,出了错能快速定位是哪一环节的问题。
第一步,解析实例。输入是文本文件,输出是Instance对象和一张对称距离矩阵。距离矩阵用二维list还是numpy数组都行,但建议在数据层就固定下来,不要算法层用一套、评估层用另一套。
第二步,初始化种群。输入是客户数量,输出是一批不重复的客户排列。初始化不能只用纯随机,通常会混入几种构造解:按最早截止时间排序、按最近邻贪心、按极角排序。纯随机种群在前几十代会浪费大量时间在明显不可行的解上,混入构造解能让收敛曲线更早进入有意义的区间。
第三步,迭代搜索。输入是种群,输出是每一代的最优个体。选择、交叉、变异都在排列空间里做,不碰路线切分;解码和适应度计算只在需要打分时调用。这种“搜索空间”和“解空间”分离的做法,是遗传算法处理 VRPTW 时最省心的组织方式。
第四步,输出报告。输入是最优个体,输出是路线明细表、迟到统计和收敛曲线。报告层只读,不改任何状态。这样做的好处是,即使算法层还在调参,报告格式可以先固定下来,答辩时拿出来的表格不会因为参数变化而变形。
提示:如果四步里非要砍掉一步,别砍第四步。很多方案最后说不清楚结果对不对,原因就是没有一份能逐行核对的路线明细表。
2.3 模块边界与目录结构
目录不用花哨,但要能一眼看出哪层归哪层。下面这个结构在我手里跑过好几个配送类课设,改起来不费劲。
vrptw_ga/ ├── data/ │ └── instances/ # 实例文件,一行一个客户 ├── src/ │ ├── instance.py # Customer、Instance、解析与距离矩阵 │ ├── decoder.py # 排列 -> 路线 │ ├── fitness.py # 路线 -> 代价 │ ├── operators.py # 交叉、变异、锦标赛 │ ├── ga.py # 主循环 │ └── report.py # CSV、收敛曲线 └── results/ # 每次运行的输出目录模块边界有三条规矩。第一,instance.py不导入任何算法模块,保证换数据格式时不影响搜索逻辑。第二,decoder.py不计算代价,只负责切分路线,这样调整切分策略时不会误伤适应度函数。第三,ga.py不直接读写文件,所有落盘动作交给report.py,避免搜索过程中因为磁盘写入拖慢迭代。三条规矩听着像形式主义,但真到改需求的时候,能省下不少返工。
3. 遗传算法核心实现:编码、解码与时间窗惩罚
遗传算法解 VRPTW,难点不在“遗传算法”四个字,而在“一条染色体怎么变成若干条带时间窗的路线”。编码选错,交叉算子会不断产生不可行解;解码写错,适应度函数会给出一个看起来很合理、实际完全不对的分数。这一章把编码、解码、适应度和算子四件事按顺序落地。
3.1 编码与解码:客户排列怎么切成路线
编码我选最简单的客户排列:染色体是一个长度为客户数的列表,里面是 1 到 n 的编号,不含仓库。这种编码的好处是交叉变异都容易实现,坏处是“哪些客户坐同一辆车”这个信息被隐式地藏在顺序里,需要解码器按容量切分。
def decode(chrom, inst, dist): """按顺序把客户装进车,装不下就开新车。""" routes, route = [], [] load, t, prev = 0.0, 0.0, 0 for cid in chrom: c = inst.customers[cid] # 容量装不下,结算当前路线,重新从仓库出发 if route and load + c.demand > inst.capacity: routes.append(route) route, load, t, prev = [], 0.0, 0.0, 0 arrive = dist[0][cid] if not route else t + dist[prev][cid] start = max(arrive, c.ready) # 早到就等到时间窗打开 route.append(cid) load += c.demand t = start + c.service prev = cid if route: routes.append(route) return routes解码器只做一件事:从左到右扫描染色体,容量超了就结算当前路线。注意start = max(arrive, c.ready)这一行,它同时处理了“早到等待”和“按时到达”两种情况;迟到的情况不在这里拦截,留给适应度函数去罚。这种“容量硬、时间窗软”的切分方式,是我试过比较稳的一种:容量超了必须开新车,否则解在物理上不成立;时间窗超了不拦截,否则搜索空间会被切得太碎,遗传算法很容易早熟。
参数上,dist必须是包含仓库的完整距离矩阵,inst.customers[0]是仓库,所以dist[0][cid]就是从仓库到客户的行驶时间。这里默认距离和行驶时间同单位,如果实际数据里距离是公里、时间是分钟,需要在读数据时先换算,别把两套单位混进同一个矩阵。
3.2 适应度函数:距离项、时间窗项与车辆数项
适应度函数要同时回答三个问题:跑了多远、迟到多少、用了几辆车。三项加权求和是最直接的做法,难点在权重怎么定。我的习惯是先把距离项系数固定为 1,然后看另外两项的量级:如果迟到总分钟数和总距离在数值上差两个数量级,就把时间窗权重调到能让两者可比的位置。
def evaluate(routes, inst, dist, w_dist=1.0, w_tw=10.0, w_veh=50.0): dist_sum, late_sum = 0.0, 0.0 for route in routes: prev, t = 0, 0.0 for cid in route: c = inst.customers[cid] arrive = t + dist[prev][cid] start = max(arrive, c.ready) late_sum += max(0.0, start - c.due) # 只罚迟到,不罚等待 dist_sum += dist[prev][cid] t, prev = start + c.service, cid dist_sum += dist[prev][0] # 回仓库 cost = w_dist * dist_sum + w_tw * late_sum + w_veh * len(routes) return cost, dist_sum, late_sum, len(routes)这里有一个很多人会踩的坑:把“等待时间”也放进惩罚项。等待本身不违反约束,早到等到时间窗打开是合法行为,罚等待只会让算法倾向于把客户排到更晚的位置,反而增加迟到风险。所以late_sum只累计start - due的正数部分,等待时间为零贡献。
三个权重的含义要在报告里写清楚:w_dist是距离权重,w_tw是每分钟迟到的当量距离,w_veh是每辆车的固定成本。答辩时如果被问“为什么w_tw取 10”,回答不能是“试出来的”,而要给出量级依据:比如实例平均距离在 100 公里量级,迟到 1 分钟相当于多跑 10 公里,这个比例来自业务上对迟到和油耗的换算。权重没有标准答案,但必须有说法。
3.3 交叉与变异:OX 算子与逆转变异
排列编码最怕交叉之后出现重复编号。顺序交叉(OX)能保证子代仍然是合法排列:先随机选一段来自父代 1,剩下的位置按父代 2 的顺序填充,跳过已经出现的编号。
def order_crossover(p1, p2, rng): n = len(p1) a, b = sorted(rng.sample(range(n), 2)) child = [None] * n child[a:b + 1] = p1[a:b + 1] used = set(child[a:b + 1]) fill = [g for g in p2 if g not in used] k = 0 for i in range(n): if child[i] is None: child[i] = fill[k] k += 1 return child变异用逆转变异,而不是简单的交换。逆转变异把染色体上的一段区间整体翻转,对路线问题更友好,因为它改变的是若干客户之间的邻接关系,而不是把两个客户单独拎出来互换。邻接关系在 VRPTW 里直接对应“相邻两个客户之间的行驶时间”,所以逆转变异往往比交换变异更快带来距离下降。
def inversion_mutate(chrom, rng, pm): child = chrom[:] if len(child) > 2 and rng.random() < pm: i, j = sorted(rng.sample(range(len(child)), 2)) child[i:j + 1] = reversed(child[i:j + 1]) return childpm一般取 0.1 到 0.2 之间。太低,种群多样性不够;太高,好的邻接结构会被打散。我通常先用 0.15 跑一版,看收敛曲线在 300 代内有没有明显的二次下降,如果没有,再往下调到 0.1 试试。
3.4 选择与精英保留
选择用锦标赛,规模取 3 就够。规模太大,种群会迅速向少数几个个体靠拢,搜索提前停止;规模太小,选择压力不够,收敛慢。精英保留单独拿出来,每代把适应度最好的两个个体原样复制到下一代,防止交叉变异把当前最优解破坏掉。
import numpy as np def tournament(pop, fitness, k, rng): idxs = rng.sample(range(len(pop)), k) return pop[min(idxs, key=lambda i: fitness[i])] def next_generation(pop, fitness, cfg, rng): order = np.argsort(fitness) new_pop = [pop[i][:] for i in order[:cfg["elite"]]] while len(new_pop) < cfg["pop_size"]: p1 = tournament(pop, fitness, cfg["tour_k"], rng) p2 = tournament(pop, fitness, cfg["tour_k"], rng) child = order_crossover(p1, p2, rng) if rng.random() < cfg["pc"] else p1[:] new_pop.append(inversion_mutate(child, rng, cfg["pm"])) return new_popelite取 2 是一个折中:取 1 保护力度不够,取 5 又容易让种群过早同质化。如果发现收敛曲线在 50 代之后就完全平了,先把elite降到 1、把pm提到 0.2,通常能重新拉开多样性。这些参数没有一次到位的玄学,靠的是每次只动一个、看曲线说话。
4. 工程细节与参数配置:解析、配置与结果输出
算法能跑通只是第一步,真正决定这套方案能不能被拿去参考的,是工程细节:实例文件怎么读、参数怎么记、结果怎么存。这一章把这三件事写清楚,代码可以直接搬进自己的项目。
4.1 实例文件解析与距离矩阵构建
实例文件我用一种很朴素的文本格式:第一行是车辆数和容量,后面每行一个客户,七个字段依次是编号、横坐标、纵坐标、需求量、最早时间、最晚时间、服务时长。仓库固定写在客户列表第一行,编号 0。
# 车辆数 车辆容量 25 200 # 编号 x y 需求量 ready due service 0 35 35 0 0 1000 0 1 41 49 10 0 120 10 2 35 17 7 0 180 10解析函数只做字段转换和排序,不做任何业务判断:
from pathlib import Path def parse_instance(path): path = Path(path) with open(path, encoding="utf-8") as f: rows = [ln.split() for ln in f if ln.strip() and not ln.startswith("#")] vehicle_limit, capacity = int(rows[0][0]), float(rows[0][1]) customers = [] for r in rows[1:]: customers.append(Customer( idx=int(r[0]), x=float(r[1]), y=float(r[2]), demand=float(r[3]), ready=float(r[4]), due=float(r[5]), service=float(r[6]), )) customers.sort(key=lambda c: c.idx) return Instance(name=path.stem, capacity=capacity, vehicle_limit=vehicle_limit, customers=customers)customers.sort(key=lambda c: c.idx)这一行不能省。文件里的客户行顺序不一定严格递增,后面解码器用下标访问客户,排序能保证customers[i].idx == i。距离矩阵构建时用欧氏距离,对称矩阵只算上三角:
import numpy as np def build_distance(customers): n = len(customers) d = np.zeros((n, n), dtype=float) for i in range(n): for j in range(i + 1, n): dx = customers[i].x - customers[j].x dy = customers[i].y - customers[j].y d[i][j] = d[j][i] = float(np.hypot(dx, dy)) return d如果实际数据里距离和行驶时间不一致,比如有拥堵系数,就在build_distance里乘一个系数矩阵,别把它塞进解码器。数据层负责“两点之间到底要花多少时间”,算法层不关心这个时间是算出来的还是查出来的。
4.2 参数表与随机种子
参数集中放在一个字典里,跑实验时连同结果一起落盘。下面这组值在中小规模实例上比较稳,客户数超过 200 时把种群和代数适当放大。
| 参数 | 建议值 | 说明 |
|---|---|---|
pop_size | 100 | 客户数翻倍时可提到 150 |
generations | 300 | 观察收敛曲线后决定是否加长 |
pc | 0.9 | 交叉概率,低于 0.7 搜索会变慢 |
pm | 0.15 | 逆转变异概率,多样性不足时提到 0.2 |
tour_k | 3 | 锦标赛规模,越大选择压力越强 |
elite | 2 | 精英保留个数 |
w_dist | 1.0 | 距离权重,作为基准 |
w_tw | 10.0 | 每分钟迟到的当量距离 |
w_veh | 50.0 | 每辆车的固定成本 |
随机种子必须显式传入,不能依赖全局随机状态。random.Random(seed)和np.random.default_rng(seed)各建一个实例,交叉变异用前者,数值计算用后者。这样同一个种子两次运行的结果才能逐位对齐,调参时才有可比性。种子本身也写进结果目录的config.json,否则过两天回头复现,根本记不住当时用的是哪个值。
4.3 结果输出:路线明细、迟到统计与收敛曲线
结果分三份存:routes.csv记录每条路线每个客户点的到达时间、开始服务时间、迟到分钟数;summary.json记录总距离、总迟到、车辆数、每代最优代价;convergence.png画收敛曲线。前两份是答辩时最常被翻的东西,第三份用来判断参数有没有调到位。
import csv def save_routes(routes, inst, dist, path): with open(path, "w", newline="", encoding="utf-8") as f: w = csv.writer(f) w.writerow(["route", "seq", "customer", "arrive", "start", "late"]) for r_id, route in enumerate(routes, 1): t, prev = 0.0, 0 for pos, cid in enumerate(route, 1): c = inst.customers[cid] arrive = t + dist[prev][cid] start = max(arrive, c.ready) late = max(0.0, start - c.due) w.writerow([r_id, pos, cid, round(arrive, 2), round(start, 2), round(late, 2)]) t, prev = start + c.service, cid这份 CSV 的价值在于“可核对”。任何人拿到它,都能沿着每一行重算到达时间和迟到分钟数,不需要相信代码。收敛曲线则用matplotlib画每代最优代价和平均代价两条线,最优线看趋势,平均线看多样性。两条线贴得太近,说明种群早熟;平均线长期高于最优线很多,说明变异力度可以再大一点。
5. 常见问题排查:收敛停滞、全违约与结果不可复现
遗传算法解 VRPTW 有几个反复出现的问题,现象看起来不一样,根子往往在同一处。这一章按“现象、原因、解决”整理三条最常见的,每条都给出排查顺序。
5.1 收敛曲线早早拉平,路线却明显不好
现象:跑到 40 代左右,最优代价不再下降,画出来的路线绕路明显,有些车只装了两三个客户。
原因通常不是迭代次数不够,而是种群多样性在早期就被压没了。精英保留个数偏多、锦标赛规模偏大、变异概率偏低,三者任意一个都会导致这个问题。另一种可能是初始化种群全是随机排列,前几代的选择压力把少数几个“看起来还行”的个体迅速放大,后面再交叉也跳不出这个局部区域。
解决顺序:先把pm从 0.15 提到 0.25,看曲线有没有二次下降;如果没有,把elite降到 1、tour_k降到 2,重新跑一次;还是平的话,检查初始化是不是只用了纯随机。我的习惯是在初始化里混入按due排序和按最近邻构造的两批个体,各占 20%,剩下的才随机。这一步改动很小,但对早期收敛质量影响很大。
还有一类隐蔽原因:解码器的容量切分太“贪”,总是把当前客户塞进当前车,直到塞不下才开新车。这种切分会让染色体后半段的客户永远挤在最后一辆车里,搜索空间被人为压窄。可以把解码改成“装不下时,以一定概率尝试把当前客户插到已有路线中”,代价是解码变慢,但路线质量通常更好。
5.2 每个客户都算“迟到”,总代价却很低
现象:summary.json里late_sum是一个很大的数,但cost看起来并不高,甚至比一些可行解还低。
原因几乎总是权重比例失衡。w_tw太小,迟到 100 分钟只相当于多跑几公里,算法自然选择“先跑短、再迟到”。另一种可能是evaluate里把迟到量算成了“到达时间减due”,而不是“开始服务时间减due”,早到等待的情况被误判成迟到,数据整体偏大。
解决:先把w_tw调到一个能让“迟到 1 分钟”和“多跑 1 公里”量级可比的值。做法是拿一个可行解跑一遍,看dist_sum和late_sum的数量级,如果距离是四位数、迟到是三位数,w_tw取 10 左右通常够用。然后打印前五个迟到最多的客户,逐个手工核对到达时间:从仓库出发时间加上行驶时间,和 CSV 里的arrive对不上,就是解码器和评估函数的距离矩阵不是同一张。这种“两个矩阵”的问题很常见,尤其在有人图省事在评估函数里重新算了一遍欧氏距离的时候。
5.3 同一个种子跑两次,路线完全不同
现象:config.json里种子没变,两次运行的routes.csv却对不上,最优代价也差不少。
原因有三类。第一类是随机源没有完全固定:random和numpy.random各有一个全局状态,只固定了其中一个,另一个还在变。第二类是种群初始化用了set或dict的遍历顺序,Python 的哈希随机化会让不同进程的遍历顺序不同。第三类是并行评估,多进程返回顺序不确定,虽然每个个体的分数一样,但选择时的平局打破规则依赖顺序。
解决:所有随机数都从显式传入的rng对象里取,不在函数内部调用random.random();初始化时不要用set去重后再遍历,改成排序后的列表;如果用了多进程,给每个任务带上原始下标,回收后按下标排序再进选择。排查时可以先跑两次只做初始化、不做迭代,对比初始种群是否一致,这样能把问题范围缩到最小。
提示:可复现性不是玄学,是工程底线。调参阶段如果结果不可复现,后面所有对比都失去意义。
6. 验收与答辩:用三张表证明你的解没有偷偷违约
验收这套方案,不需要把代码从头读一遍,只需要三张表。第一张是路线表,每条路线列出客户顺序、到达时间、开始服务时间、离开时间;第二张是迟到表,把所有late > 0的客户单独筛出来,按迟到分钟数降序;第三张是收敛表,记录每 10 代的最优代价和平均代价。三张表放在一起,容量、时间窗、车辆数、收敛性四个问题都能回答。
| 验收项 | 检查方式 | 合格线 |
|---|---|---|
| 容量 | 每条路线demand求和 | 不超过capacity |
| 时间窗 | 迟到表为空或总迟到为 0 | 硬约束下应为 0 |
| 车辆数 | 路线条数 | 不超过vehicle_limit |
| 收敛 | 收敛表后 20% 代的降幅 | 降幅小于 1% 视为收敛 |
答辩时最常被追问的两个点,提前准备好说法。第一个是“你的解是不是可行解”,不要只回答“是”,要把迟到表翻出来,指着总迟到为 0 那一行说“每个客户的开始服务时间都落在ready和due之间,这张表可以逐行核对”。第二个是“参数怎么定的”,不要回答“调出来的”,要给出量级依据:种群规模跟客户数的关系、w_tw跟距离量级的换算、变异概率跟收敛曲线的对应。参数没有最优值,但有解释得通的取值路径。
还有一个很实用的技巧:在报告里固定放一版“最差可行解”的对比。拿初始化种群里的第一个可行解,和最终解放在同一张表里,距离、车辆数、迟到三项并排。这样做的好处是,即使最终解不是全局最优,也能看出算法确实在起作用,而不是靠一个碰巧不错的初始排列撑场面。
从那以后我每次交这类代码,都会先打印一张迟到统计表,哪怕它全是 0,也得让我看见那个 0。希望帮到你。
本文还有配套的精品资源,点击获取