简介:一篇发表于《海军航空工程学院学报》的期刊论文PDF,面向运筹优化、项目管理与智能算法方向的学习者与研究者,聚焦资源约束项目调度问题(RCPSP)的粒子群优化(PSO)求解方法。作者张凯等建立了以最小化项目总工期为目标、包含前后约束与资源约束的数学模型,提出用简单编码规则确定合法活动次序,并界定满足资源约束的最大解空间;同时系统讨论了修复策略与抛弃策略对不可行解的处理,以及惯性权重、认知因子和社会因子对探索与开发平衡的调节。压缩包仅含1个PDF文件,约416KB,体积轻便、便于检索引用,现有103人学习。读者可从中获取RCPSP建模思路、PSO位置与速度更新公式、约束处理技巧及典型项目实例的优化验证过程,对撰写论文或开展算法实现具有直接参考价值。
1. 从一张排不下的甘特图说起
一个 32 个任务的项目,光看关键路径是 47 天,真正让人头疼的是第 3 天:三台设备被两个任务同时占着,谁先上谁后上,最终 makespan 能差出十几二十天。资源约束项目调度问题(Resource-Constrained Project Scheduling Problem)要处理的正是这件事——在优先关系不能破、任一时刻每种资源占用不超过容量的双重约束下,给每个任务定一个开工时间,让整个项目最早收尾。任务规模一过 60,精确的分支定界就跑不动了,粒子群优化算法这类群体智能方法成了工程上更现实的选择。下面是建模、解码、参数到排错的一条完整求解链路,全部代码在 Python 里可直接跑。
2. RCPSP 的建模与粒子位置到任务列表的解码
2.1 描述一个 RCPSP 实例所需的四张数据表
不含时变的资源供给、不含抢占、不含任务拆分,这是标准 RCPSP 的经典假设,也是绝大多数论文和工程实现采用的版本。一个实例抽象出来只有四组数据:任务集合、工期、资源需求、资源容量,外加一组优先关系。
| 符号 | 含义 | 数据形态 |
|---|---|---|
| J = {0,1,…,n-1} | 任务集合,0 和 n-1 是工期为 0 的虚拟起止任务 | 一维数组下标 |
| d[j] | 任务 j 的工期 | 长度为 n 的整型数组 |
| r[j][k] | 任务 j 对第 k 种资源的单位时间需求量 | n × K 的矩阵 |
| R[k] | 第 k 种资源的容量上限 | 长度为 K 的数组 |
| preds[j] | 任务 j 的直接前驱列表 | 邻接表 |
虚拟起点和终点的作用是把"多起点多终点"的网络统一成单源单汇,后续解码和计算下界时少写很多分支判断。优先关系用邻接表而不是矩阵,是因为 n 上千之后矩阵的构造和遍历都开始变慢,而任务的实际前驱数量通常只有个位数。
2.2 目标函数与两条能用来验算的下界
目标是最小化项目总工期 Cmax = max(s[j] + d[j]),约束是每个时刻 t 上满足 sum(r[j][k] : s[j] ≤ t < s[j]+d[j]) ≤ R[k],以及对任意 i ∈ preds[j] 有 s[j] ≥ s[i] + d[i]。
工程上真正有用的是两条下界。第一条是关键路径长度 CP,把优先关系网络按最长路径算一遍;第二条是资源下界 LB_res[k] = ceil(sum(d[j] * r[j][k]) / R[k]),对每种资源各算一个再取最大。求解结果低于下界说明代码有 bug,高于下界太多说明搜索没到位。这两条下界不花什么时间,但能在第一时间拦住"看起来收敛了其实是非法解"的情况。
2.3 连续粒子飞不进排列空间:三种离散化路线
标准粒子群优化算法的位置更新是在连续实数空间里做的,而 RCPSP 的可行解是一串满足优先关系的任务排列。硬把实数位置四舍五入成任务编号,得到的大概率是不可行排列,再补一个修复算子,整条链路就变得又慢又难调。常见做法有三条路线。
| 路线 | 位置含义 | 可行性 | 适合规模 |
|---|---|---|---|
| 随机键 | 每个任务一个 [0,1) 的实数权重 | 解码时天然可行,不用修复 | 中大规模 |
| 优先值排序 | 每个任务一个实数优先级 | 同上,但数值范围不受限 | 中大规模 |
| 交换算子 | 位置是任务排列,速度定义成交换序列 | 必须额外校验优先关系 | 小规模 |
我一般选随机键。它把"排列的可行性"这件事从搜索过程里彻底拿掉,交给一个确定性的解码器去保证,粒子只需要在一个 n 维的单位超立方体里乱飞,边界处理和速度钳制都变得极其简单。
2.4 随机键加串行进度生成机制的解码实现
解码用的是串行进度生成机制(Serial Schedule Generation Scheme,SGS):维护一个"前驱已全部排完"的可调度集合,每次从中挑优先值最小的任务,给它找最早的可行开工时间。一个优先值向量必然对应唯一一个调度方案。
import numpy as np def decode(priority, dur, dem, cap, preds): """把随机键 priority 解码成一个调度方案。 返回 (makespan, start, finish, order)。 priority 越小越先被安排,这条规则决定了搜索的方向。""" n = len(dur) horizon = int(dur.sum()) + 1 # 全串行执行的完工时间就是时间轴上界 profile = np.zeros((horizon, len(cap)), dtype=int) # 每个整数时刻的资源占用 start, finish = np.zeros(n, dtype=int), np.zeros(n, dtype=int) scheduled = np.zeros(n, dtype=bool) order = [] for _ in range(n): # 可调度集合:自身未排,且所有前驱已排完 ready = [j for j in range(n) if not scheduled[j] and all(scheduled[p] for p in preds[j])] if not ready: raise ValueError("优先关系里存在环,实例数据有问题") j = min(ready, key=lambda t: priority[t]) # 随机键到任务顺序的映射 t = max([finish[p] for p in preds[j]], default=0) # 最早可能开工时间 while True: if t + dur[j] > horizon: raise ValueError("时间轴越界,检查工期或容量设置") if np.all(profile[t:t + dur[j]] + dem[j] <= cap): break # 这一段窗口资源够用 t += 1 # 往后顺延一个时间单位 profile[t:t + dur[j]] += dem[j] # 占住资源,留给后面的任务判断 start[j], finish[j] = t, t + dur[j] scheduled[j] = True order.append(j) return int(finish.max()), start, finish, order几个参数的取舍值得说清楚。horizon取所有工期之和加一,保证任何串行排列都有空间放下,代价是资源剖面数组随总工期线性增长,n 上千、工期上百时内存会到几百 MB 量级;如果实例很大,改成按事件时间点的字典存储更稳。min(ready, key=...)这一句是整套编码的心脏,它把连续的实数比较翻译成离散的任务排序,同一组优先值只要相对大小不变,解码结果就完全一致。profile[t:t+dur[j]] + dem[j] <= cap依赖 numpy 的广播,dem[j]是长度为 K 的向量,逐时刻比较后取np.all,语义就是"这段时间窗口内每种资源都不超容"。工期为 0 的虚拟任务会命中空切片,np.all对空数组返回 True,天然排在最早可能时刻,不需要特殊分支。
3. 粒子群优化算法在 RCPSP 上的参数设置与主循环
3.1 速度更新公式里每个参数各管什么
速度更新沿用的是经典形式:v = w·v + c1·r1·(pbest - x) + c2·r2·(gbest - x),然后 x = x + v。放到 RCPSP 的语境里,pbest - x是"我自己历史上排得更好的那个优先序,和现在差多少",gbest - x是"当前全局最好的优先序,我还差多远"。差值本身没有物理意义,它的作用是在某些任务的优先值上拉开或压缩相对次序,从而改变解码出来的任务排列。
| 参数 | 常用取值 | 调大后的效果 | 调小后的效果 |
|---|---|---|---|
| 种群规模 swarm | 30 到 100 | 覆盖面广,单次迭代变慢 | 收敛快,容易早熟 |
| 惯性权重 w | 0.9 线性降到 0.4 | 前期探索强,后期容易震荡 | 收敛快,容易困在局部 |
| 学习因子 c1 | 1.5 到 2.0 | 粒子更相信自己的历史 | 个体记忆被削弱 |
| 学习因子 c2 | 1.5 到 2.0 | 快速向 gbest 靠拢,早熟风险高 | 群体信息利用不足 |
| 速度上限 vmax | 0.2 到 0.3 | 步子大,容易跳过好解 | 后期几乎不动 |
3.2 惯性权重线性递减与速度钳制的配合
随机键的取值范围是 [0,1),两个任务优先值之差最多也就 1,所以速度的量纲必须卡死。我一般把vmax设成 0.25,位置更新后统一 clip 回 [0,1]。如果不做钳制,前几轮 c1、c2 乘以随机数后速度能到 2 以上,粒子会瞬间贴到立方体的某个角上,所有优先值挤在一起,解码出来的任务顺序退化成随机排列,gbest再也带不动群体。
惯性权重从 0.9 线性降到 0.4 是另一条经验线。前期权重高,粒子敢往远处跑,负责把优先序的排列空间铺开;后期权重低,粒子的移动主要被pbest和gbest牵引,在好解附近做细调。这两项要一起配:权重降下去了但vmax还留着 0.25,后期照样跳;vmax压得死了但权重一路保持 0.9,粒子会一直在原地打转。
3.3 主循环实现与评估次数的统计口径
def pso_rcpsp(dur, dem, cap, preds, swarm=40, iters=300, w_start=0.9, w_end=0.4, c1=1.5, c2=1.5, vmax=0.25, seed=1): """RCPSP 的随机键粒子群主循环。 一次 decode 调用记作一次评估,便于和基准库的对比口径对齐。""" rng = np.random.default_rng(seed) n = len(dur) X = rng.random((swarm, n)) # 初始优先值,均匀撒在 [0,1) V = rng.uniform(-vmax, vmax, (swarm, n)) pbest = X.copy() pbest_fit = np.array([decode(X[i], dur, dem, cap, preds)[0] for i in range(swarm)]) gi = int(np.argmin(pbest_fit)) gbest, gbest_fit = pbest[gi].copy(), int(pbest_fit[gi]) history = [] for it in range(iters): w = w_start - (w_start - w_end) * it / max(iters - 1, 1) # 惯性权重线性递减 r1 = rng.random((swarm, n)) r2 = rng.random((swarm, n)) V = w * V + c1 * r1 * (pbest - X) + c2 * r2 * (gbest - X) V = np.clip(V, -vmax, vmax) # 速度钳制 X = np.clip(X + V, 0.0, 1.0) # 位置越界直接回收 for i in range(swarm): f = decode(X[i], dur, dem, cap, preds)[0] if f < pbest_fit[i]: # 个体最优更新 pbest_fit[i], pbest[i] = f, X[i].copy() if f < gbest_fit: # 全局最优更新 gbest_fit, gbest = int(f), X[i].copy() history.append(gbest_fit) return gbest, gbest_fit, history循环里唯一有计算量的动作是decode,其余全是 numpy 的向量化操作,40 个粒子 300 轮跑下来就是一秒出头的事。评估次数要单独记账:如果按基准库的对齐口径比较算法,通常要把它换算成"解码次数",这个值等于(初始化的 swarm 次)+(每轮实际触发解码的 swarm 次 × iters)。很多论文里报的迭代次数差异巨大,实际差距往往出在这里。
3.4 为什么这套编码不需要修复算子
交换算子类的离散化必须写一个校验函数,发现优先关系被破坏就交换回来,否则解码器直接抛环异常。随机键路线把约束完全交给 SGS 里的可调度集合,粒子怎么飞、位置怎么越界、优先值怎么反序,解出来的任务排列一定满足所有前驱关系。少一个修复算子,不只是代码短了几十行,更意味着适应度地形上没有断崖——相邻的粒子位置解码出来的调度也大致相邻,这正是粒子群优化算法能在上面工作的前提条件。
4. 基准算例上的复现与失效模式排查
4.1 用随机算例把整条链路跑通
基准库的算例文件不便于在文章里直接贴出,用一段生成器造同构的随机实例更方便复现。生成的图按索引从小到大连边,天然无环,容量按最大单项需求再加一点余量,保证实例必然有解。
def random_instance(n_real=32, n_res=4, seed=0): """生成一个同构的随机 RCPSP 实例,索引 0 与 n-1 为虚拟起止任务。""" rng = np.random.default_rng(seed) n = n_real + 2 dur = np.zeros(n, dtype=int) dur[1:-1] = rng.integers(1, 11, n_real) # 工期 1 到 10 dem = np.zeros((n, n_res), dtype=int) dem[1:-1] = rng.integers(0, 6, (n_real, n_res)) # 单任务需求 0 到 5 cap = (dem.max(axis=0) + 3).astype(int) # 容量留出并行余量 preds = [[] for _ in range(n)] for j in range(2, n - 1): # 只从更小的索引连过来 for i in range(1, j): if rng.random() < 0.08: preds[j].append(i) for j in range(1, n - 1): # 补齐虚拟起点 if not preds[j]: preds[j].append(0) for j in range(1, n - 1): # 补齐虚拟终点 if not any(j in preds[k] for k in range(1, n)): preds[n - 1].append(j) preds[n - 1] = sorted(set(preds[n - 1])) return dur, dem, cap, preds dur, dem, cap, preds = random_instance(n_real=32, n_res=4, seed=7) gbest, best, history = pso_rcpsp(dur, dem, cap, preds, swarm=40, iters=300, seed=3) print("best makespan =", best) print("first 10 iters:", history[:10]) print("last 5 iters :", history[-5:])调用的时候注意seed有两处,实例的种子和算法的种子分开传。做横向对比实验时,实例种子要固定,算法种子至少要换五组取平均,否则单次随机结果根本说明不了参数优劣。
4.2 输出怎么读:收敛曲线要对着下界看
history打印出来大致是前 10 轮从一个大数字快速下降,二三十轮后进入平台期,最后几十轮几乎不再变化。关键路径长度和资源下界这两个数一定要一起算出来打印在旁边,history的末值和它们的差距才是真正能说明问题的指标。如果末值已经贴着关键路径下界,实例本身资源不紧张,再怎么调c1、c2都不会有提升;如果差着 30% 以上,多半是搜索没铺开,优先考虑加种群规模和迭代次数,而不是急着动学习因子。
多次运行的结果波动幅度也值得记录。同一组参数跑十次,best的最大值和最小值差在 3% 到 8% 之间属于正常;如果某一次明显跑飞,把那个算法种子单独拿出来看它的初始种群,很可能是初始化时随机键全部挤在 [0.4, 0.6] 之间,解码出来的任务顺序几乎没有区分度。
4.3 三类典型失效模式与动作对照
| 现象 | 常见原因 | 建议动作 |
|---|---|---|
| 前 5 轮就锁死不再下降 | 惯性权重降得太快,或vmax太小 | 把w起点提到 0.9,vmax提到 0.3 |
| 末值持续小幅震荡不收敛 | 惯性权重过大,后期仍在探索 | 把w终点降到 0.2 到 0.3 |
| 结果低于关键路径下界 | 解码器资源窗口判断有 bug | 关掉 clip 打印非法调度,逐时刻核对占用 |
| 每轮评估次数对不上 | 有粒子提前跳出解码循环 | 统计decode的实际调用次数而非理论值 |
第二类现象容易被误判成"算法还在探索",实际上平台期之后的震荡几乎全是无效计算。判断办法是把每轮gbest_fit的改善量打出来,连续 50 轮改善量为 0 就可以提前终止,省下的算力拿去做多次重启更划算。
4.4 与遗传算法、优先规则启发式的横向对照
同在随机键编码下,遗传算法靠交叉和变异产生新个体,粒子群优化算法靠速度项共享pbest和gbest的信息。前者搜索的随机性更强,跳出局部的能力好一些;后者收敛速度快,小规模实例上往往用更少的评估次数拿到同样质量的结果。最省事的对照基线是经典优先规则启发式,比如按"最长工期优先"或"最多后继优先"排一个固定任务列表,再套用同一个解码器,它的结果就是这条链路的下限。
5. 给 gbest 接一段交换邻域局部搜索
5.1 在最优粒子附近做优先值交换
主循环跑完之后的gbest是全局最优位置,但粒子群的局部搜索能力弱,末端经常差一点。交换两个任务的优先值再重新解码,是代价最小的一种邻域动作,在随机键编码下它不需要任何可行性校验。
def swap_local_search(priority, dur, dem, cap, preds, tries=200, rng=None): """对最优优先值向量做交换邻域搜索,返回改进后的解。""" rng = rng or np.random.default_rng(0) best = priority.copy() best_fit = decode(best, dur, dem, cap, preds)[0] for _ in range(tries): cand = best.copy() i, j = rng.integers(0, len(cand), 2) if i == j: continue cand[i], cand[j] = cand[j], cand[i] # 交换优先值,等价于调换这两个任务的相对次序 f = decode(cand, dur, dem, cap, preds)[0] if f < best_fit: best, best_fit = cand.copy(), f return best, best_fittries是邻域采样次数,32 个任务的实例给 200 到 2000 都比较合理;任务数上百后,随机交换命中有效改进的概率下降,改成只交换"关键路径上相邻两个任务"的优先值,命中率会高不少。把这段接在主循环后面,用gbest作为输入,通常还能再压掉几个时间单位,而这部分额外的解码次数要记得加进评估总数,否则和别的算法比就不公平了。
5.2 用下界和非法解检测做验证
局部搜索之后拿到的新解,第一件事不是宣告收敛,而是验证两件事:结果不低于关键路径下界,以及整条调度在任意时刻的资源占用都不超容。后者可以在decode里加一个可选的 debug 开关,返回资源剖面矩阵,用np.max(profile, axis=0) <= cap断言一次。开关默认关掉,验证阶段手动打开。这两步做完,随机键加串行进度生成机制的整条链路才算真正可信。把每次迭代的gbest_fit和关键路径下界并排打印,当两者还差 30% 以上时,先别急着调c1、c2——把局部搜索的tries从 200 提到 2000,收敛质量的提升通常比调参数更直接。
本文还有配套的精品资源,点击获取