简介:这份资源面向运筹优化、智能制造与生产排程方向的学习者与开发者,聚焦作业车间调度问题(Job Shop Scheduling, JSP)这一经典NP-hard难题,用遗传算法给出可运行的Python求解方案。JSP要求在满足工序先后顺序与机器约束的前提下,为N个作业、M台机器安排加工排序,使性能指标最优,其思路可迁移到港口码头、机场航班、汽车流水线等调度场景。压缩包共4个文件,以3个py源码与1个md说明文档为主,整体约4KB,体量轻巧,便于快速阅读与二次修改。源码将遗传算法主流程、调度逻辑与实例数据分离组织,读者可据此理解编码方式、适应度计算、选择交叉变异等关键环节,并直接替换算例验证效果。目前已有4409人学习下载,适合作为课程设计、毕业设计或算法入门的参考实现。
1. 车间调度这潭水有多深:从一份遗传算法 Python 源码说起
如果你在制造业信息化或者运筹优化方向待过,一定听过“车间调度”这四个字。它听起来像是个排产表格的事,但真落到代码里,NP-hard 的复杂度能把人折磨到怀疑人生。我手里这份基于遗传算法求解车间调度问题的 Python 源码,就是专门用来啃这块硬骨头的。它解决的是经典作业车间调度问题(Job Shop Scheduling Problem,JSP):有若干工件、若干机器,每个工件要按特定顺序经过多台机器加工,怎么安排加工顺序能让总完工时间最短。这份源码适合两类人:一是正在做生产排程系统、需要算法内核的开发者;二是运筹优化或智能制造方向的学生,想拿一份能跑通、能改参数的完整实现来练手。它不依赖商业求解器,纯 Python 加基础科学计算库就能跑,拿到手改改数据文件就能看到甘特图输出。
2. 项目骨架与算法内核:遗传算法怎么套进调度问题
2.1 目录结构与模块职责
拿到源码包,先别急着跑main.py。我习惯先把目录扫一遍,搞清楚每个文件在算法流程里扮演什么角色。这份源码的结构很典型,没有过度工程化,适合二次开发时快速定位改动点:
ga_jsp/ ├── main.py # 入口:读数据、跑算法、输出结果 ├── config.py # 算法参数集中配置 ├── data/ │ └── ft06.txt # 经典 6x6 算例数据 ├── core/ │ ├── chromosome.py # 染色体编码与解码 │ ├── population.py # 种群初始化与统计 │ ├── fitness.py # 适应度计算(最大完工时间) │ ├── selection.py # 选择算子 │ ├── crossover.py # 交叉算子 │ └── mutation.py # 变异算子 ├── utils/ │ ├── decoder.py # 基于工序的编码解码器 │ └── gantt.py # 甘特图绘制 └── output/ └── result.csv # 最优调度方案输出这个划分方式的好处是:算法逻辑和问题数据解耦。core/里全是通用遗传操作,utils/decoder.py才是和 JSP 强相关的部分。你如果想换成柔性作业车间(FJSP),主要改decoder.py和fitness.py,遗传算子基本不用动。
2.2 染色体编码:基于工序的编码方式
JSP 的编码方式直接决定算法能不能收敛。这份源码用的是“基于工序的编码”(Operation-Based Encoding),也是文献里最稳的一种。思路是:染色体长度等于总工序数,每个基因位用工件号表示,该工件第几次出现就代表它的第几道工序。
# core/chromosome.py import random class Chromosome: def __init__(self, job_count, operation_counts): """ job_count: 工件数量 operation_counts: 每个工件的工序数列表,如 [6,6,6,6,6,6] """ self.job_count = job_count self.operation_counts = operation_counts self.total_ops = sum(operation_counts) self.genes = [] # 染色体基因序列 self.fitness = None # 适应度值(最大完工时间) self.schedule = None # 解码后的调度方案 def random_init(self): """随机初始化:每个工件按其工序数重复出现""" genes = [] for job_id, op_num in enumerate(self.operation_counts): genes.extend([job_id] * op_num) random.shuffle(genes) self.genes = genes return self这段代码的关键在random_init:先把所有工件按工序数展开成一个列表,再打乱。比如 3 个工件各 2 道工序,初始基因可能是[0,1,0,2,1,2]。解码时从左到右扫描,遇到 0 就是工件 0 的第 1 道工序,再遇到 0 就是工件 0 的第 2 道工序。这种编码天然保证任何排列都是合法解,不会产生无效调度,省掉了修复算子的麻烦。
2.3 解码与适应度:从基因到甘特图
编码只是第一步,真正决定调度优劣的是解码逻辑。utils/decoder.py里的解码器要模拟机器加工过程,算出每道工序的开始时间和结束时间,最终得到最大完工时间(Makespan)作为适应度。
# utils/decoder.py def decode(chromosome, processing_time, machine_order): """ chromosome: 基因序列,如 [0,1,0,2,1,2] processing_time: 加工时间矩阵,processing_time[job][op] = 时间 machine_order: 机器顺序矩阵,machine_order[job][op] = 机器号 返回: (makespan, schedule) """ job_op_index = [0] * chromosome.job_count # 每个工件当前加工到第几道工序 machine_free_time = {} # 每台机器的空闲时间 job_free_time = [0] * chromosome.job_count # 每个工件的可用时间 schedule = [] for job_id in chromosome.genes: op_idx = job_op_index[job_id] machine = machine_order[job_id][op_idx] duration = processing_time[job_id][op_idx] # 工序开始时间 = max(工件可用时间, 机器可用时间) start = max(job_free_time[job_id], machine_free_time.get(machine, 0)) end = start + duration job_free_time[job_id] = end machine_free_time[machine] = end job_op_index[job_id] += 1 schedule.append({ 'job': job_id, 'op': op_idx, 'machine': machine, 'start': start, 'end': end }) makespan = max(job_free_time) return makespan, schedule解码的核心就一行:start = max(job_free_time[job_id], machine_free_time.get(machine, 0))。它表达的是:一道工序必须等工件上一道工序完成,并且目标机器空闲,才能开始。这个逻辑简单但容易写错——我见过有人把两个条件写成加法,结果 Makespan 直接翻倍。job_op_index数组用来追踪每个工件当前该加工第几道工序,因为基因里同一个工件会出现多次,必须按出现顺序对应工序号。
2.4 遗传算子:选择、交叉与变异
选择算子用的是锦标赛选择(Tournament Selection),实现简单且不易早熟。交叉算子针对工序编码做了特殊处理,不能简单单点交叉,否则会出现非法个体。源码里用的是“基于工件的交叉”(Job-Based Crossover,JBX):随机选一组工件,父代 1 中这些工件的基因保持位置不变,父代 2 中剩余工件的基因按顺序填入空位。
# core/crossover.py import random def job_based_crossover(parent1, parent2): """基于工件的交叉:保持部分工件位置,其余按序填充""" job_count = parent1.job_count # 随机选择要保留的工件集合 selected_jobs = set(random.sample(range(job_count), k=job_count // 2)) child_genes = [None] * len(parent1.genes) # 父代1中属于选中工件的基因保留原位 for i, gene in enumerate(parent1.genes): if gene in selected_jobs: child_genes[i] = gene # 父代2中不属于选中工件的基因按顺序填入空位 fill_genes = [g for g in parent2.genes if g not in selected_jobs] fill_idx = 0 for i in range(len(child_genes)): if child_genes[i] is None: child_genes[i] = fill_genes[fill_idx] fill_idx += 1 child = parent1.__class__(job_count, parent1.operation_counts) child.genes = child_genes return child变异算子用的是交换变异:随机选两个位置,交换基因值。因为工序编码的合法性由“每个工件出现次数固定”保证,交换两个不同工件的基因不会破坏合法性。但如果交换的是同一个工件的两个基因,等于没变,所以源码里加了个判断,确保交换的两个位置基因值不同。
# core/mutation.py import random def swap_mutation(chromosome, mutation_rate=0.1): """交换变异:随机选两个不同工件的位置交换""" if random.random() > mutation_rate: return chromosome genes = chromosome.genes[:] n = len(genes) for _ in range(10): # 最多尝试10次找到不同工件的两个位置 i, j = random.sample(range(n), 2) if genes[i] != genes[j]: genes[i], genes[j] = genes[j], genes[i] break chromosome.genes = genes return chromosome这三个算子配合起来,种群多样性保持得不错。我实测跑 ft06 算例(6 工件 6 机器),种群规模 100、迭代 200 代,基本能在 50 代内收敛到最优解 55。
3. 跑起来才算数:环境配置与完整运行流程
3.1 依赖安装与数据格式
这份源码的依赖很轻,不需要装 CPLEX 或 Gurobi 这类重型求解器。Python 3.8 以上就行,核心依赖只有三个:
pip install numpy matplotlib pandasnumpy用于矩阵运算,matplotlib画甘特图,pandas导出结果 CSV。数据文件放在data/目录下,格式是经典 JSP 算例的文本格式。以 ft06 为例,第一行是工件数和机器数,后面每行是一个工件的加工信息,按“机器号 加工时间”成对出现:
6 6 2 1 0 3 1 6 3 7 5 3 4 6 1 8 2 5 4 10 5 10 0 10 3 4 2 5 3 4 5 8 0 9 1 1 4 7 1 5 0 5 2 5 3 3 4 8 5 9 2 9 1 3 4 5 5 4 0 3 3 1 1 3 3 3 5 9 0 10 4 4 2 1每行的数字对含义是:第 1 对表示该工件第 1 道工序在哪台机器、加工多久;第 2 对表示第 2 道工序,以此类推。读数据的时候要注意,机器号是从 0 开始还是从 1 开始,这份源码统一按 0 起始处理,如果你的数据是 1 起始,在main.py里减 1 就行。
3.2 参数配置与启动命令
config.py把所有可调参数集中在一起,改起来不用翻遍整个项目:
# config.py POP_SIZE = 100 # 种群规模 MAX_GEN = 200 # 最大迭代代数 CROSSOVER_RATE = 0.8 # 交叉概率 MUTATION_RATE = 0.1 # 变异概率 TOURNAMENT_SIZE = 3 # 锦标赛选择规模 ELITE_SIZE = 2 # 精英保留个数 DATA_FILE = 'data/ft06.txt' OUTPUT_DIR = 'output/'启动就一行命令:
python main.py --data data/ft06.txt --pop 100 --gen 200main.py里用argparse解析命令行参数,不传就用config.py的默认值。运行过程中每 20 代会打印一次当前最优 Makespan,跑完后在output/下生成result.csv和gantt.png。result.csv里每行是一道工序的记录,包含工件号、工序号、机器号、开始时间、结束时间,可以直接导入 Excel 做排产分析。
3.3 结果验证:甘特图与收敛曲线
跑完之后别只看终端输出的数字,一定要打开甘特图看一眼。utils/gantt.py用 matplotlib 画水平条形图,每台机器一行,每个工件一种颜色,条形的起止位置就是工序的开始和结束时间。如果甘特图里出现同一台机器上两个条形重叠,说明解码逻辑有 bug,调度方案不可行。
# utils/gantt.py import matplotlib.pyplot as plt def plot_gantt(schedule, machine_count, save_path='output/gantt.png'): fig, ax = plt.subplots(figsize=(12, 6)) colors = plt.cm.tab10.colors for task in schedule: ax.barh(task['machine'], task['end'] - task['start'], left=task['start'], color=colors[task['job'] % 10], edgecolor='black', height=0.6) ax.text(task['start'] + (task['end'] - task['start']) / 2, task['machine'], f"J{task['job']}", ha='center', va='center', fontsize=8) ax.set_yticks(range(machine_count)) ax.set_yticklabels([f"M{i}" for i in range(machine_count)]) ax.set_xlabel("Time") ax.set_title("Job Shop Schedule Gantt Chart") plt.tight_layout() plt.savefig(save_path, dpi=150) plt.close()这段代码里ax.barh的left参数是工序开始时间,宽度是end - start。colors[task['job'] % 10]保证同一个工件的工序颜色一致,方便肉眼追踪。如果甘特图正常,再去看收敛曲线——源码里没自带画收敛曲线的功能,我一般会在main.py的迭代循环里加一行记录每代最优值,跑完用 matplotlib 画出来。正常情况曲线应该在前几十代快速下降,然后趋于平稳。如果一直震荡不收敛,多半是变异率太高或者种群太小。
4. 避坑与排查:那些年我踩过的调度算法坑
4.1 现象:算法跑了几百代,Makespan 纹丝不动
原因:种群初始化太单一。如果所有个体都是完全随机生成的,在工序数较多时,初始种群可能集中在某个局部区域,交叉变异很难跳出去。另一个可能是精英保留策略写错了,把最差个体当精英留了下来。
解决:在population.py里加一个启发式初始化,比如用“最短加工时间优先”规则生成一部分个体,剩下的再随机生成。精英保留一定要在排序后取前ELITE_SIZE个,别取反了。我一般会在每代打印一下种群适应度的最小值和平均值,如果两者长期相等,说明种群多样性已经没了。
4.2 现象:甘特图上同一台机器出现时间重叠
原因:解码时机器可用时间更新逻辑有误。常见错误是只更新了当前工序所在机器的可用时间,忘了更新工件的可用时间;或者用了min而不是max来计算开始时间。
解决:回到decoder.py,检查start = max(job_free_time[job_id], machine_free_time.get(machine, 0))这一行。另外确认machine_free_time的 key 是机器号,别把工件号当机器号传进去了。调试时可以在每道工序解码后打印job_free_time和machine_free_time,看是否单调递增。
4.3 现象:交叉后产生非法个体,解码直接报错
原因:用了单点交叉或多点交叉,导致某个工件的基因数量不等于它的工序数。比如工件 0 本来该出现 6 次,交叉后变成 5 次或 7 次,解码时job_op_index越界。
解决:JSP 的工序编码必须用保序交叉,比如 JBX 或 POX(Precedence Operation Crossover)。检查crossover.py里是否严格保证了“每个工件的基因总数不变”。一个快速验证方法是:交叉后统计每个工件在子代中出现的次数,和operation_counts对比,不一致就说明交叉算子有问题。
4.4 现象:程序跑得特别慢,迭代一次要好几秒
原因:适应度计算里用了 Python 原生循环逐基因解码,没有做任何缓存或向量化。当种群规模 200、基因长度 100 以上时,每代要解码 200 次,每次解码 100 步,累计两万次操作,纯 Python 确实吃力。
解决:两个方向。一是用numpy把加工时间和机器顺序矩阵预加载成数组,减少字典查找;二是把解码器用numba的@jit装饰一下,不改逻辑就能提速 10 倍以上。如果不想引入新依赖,至少把machine_free_time从字典改成列表,用机器号做索引,字典的哈希开销在热循环里很可观。
4.5 现象:换了一个算例,结果比已知最优解差很多
原因:参数没有随问题规模调整。ft06 这种小算例,种群 50、迭代 100 就够了;但换成 ft20(20 工件 5 机器)或 la 系列的大算例,种群 50 根本不够,算法还没探索到好解就收敛了。
解决:问题规模翻倍时,种群规模和迭代代数至少翻倍。我一般按POP_SIZE = max(100, total_ops * 2)来设,MAX_GEN不低于 500。另外大算例要适当提高变异率到 0.15 左右,防止早熟。如果换了算例后结果波动很大,跑 10 次取平均值和最优值一起看,单次结果说明不了问题。
5. 二次开发:从 JSP 到 FJSP 的改造思路
这份源码最值钱的地方不是它能解 ft06,而是它的架构留好了扩展口子。如果你想把它改成柔性作业车间调度(FJSP),也就是每道工序可以在多台机器上加工、加工时间不同,核心改动集中在三个文件。
第一步,改数据格式。FJSP 的算例每道工序要给出可选机器列表和对应加工时间。我一般把data/下的文件改成每行“工序可选机器数 机器1 时间1 机器2 时间2 …”的格式,然后在main.py的读数据函数里解析成嵌套列表。
第二步,改染色体编码。JSP 的工序编码只记录了工序顺序,FJSP 还需要记录每道工序选了哪台机器。常见做法是双染色体:一条工序染色体(和现在一样),一条机器染色体(长度等于总工序数,每个基因位是机器号)。解码时两条染色体配合使用。
# 改造后的 FJSP 染色体示例 class FJSPChromosome: def __init__(self, job_count, operation_counts, machine_options): self.os = [] # 工序染色体,同 JSP self.ms = [] # 机器染色体,每个基因位是机器号 self.machine_options = machine_options # 每道工序的可选机器 def random_init(self): # 工序部分同 JSP os_genes = [] for job_id, op_num in enumerate(self.operation_counts): os_genes.extend([job_id] * op_num) random.shuffle(os_genes) self.os = os_genes # 机器部分:每道工序从可选机器中随机选一台 self.ms = [] for job_id in self.os: op_idx = self._get_op_index(job_id) options = self.machine_options[job_id][op_idx] self.ms.append(random.choice(options)) return self第三步,改解码器。JSP 解码时机器是固定的,FJSP 解码时要从机器染色体里取机器号,并且加工时间也要根据选中的机器去查表。改动量不大,但要注意机器染色体的交叉变异也要同步设计——机器部分可以用均匀交叉,变异就是随机换一台可选机器。
验证改造是否成功,我有个笨办法但很管用:先用 FJSP 的退化算例(每道工序只有一台可选机器)跑一遍,结果应该和 JSP 版本完全一致。如果对不上,说明解码逻辑改出问题了。确认退化算例没问题后,再上真正的 FJSP 算例,对比文献里的最优解,差距在 5% 以内就算合格。
从那以后我每次改调度算法的编码或解码逻辑,都强制先用退化算例跑一遍回归测试,确认基础逻辑没被改坏,再上复杂算例。这个习惯帮我省了至少三次通宵排查的时间。希望这份源码和上面的改造思路能帮到你,拿到手先跑通 ft06,再按自己的车间数据改格式,一步步来,别一上来就啃大算例。
本文还有配套的精品资源,点击获取