简介:面向物流规划与供应链管理者的选址优化资料,以粒子群算法(PSO)为切入点,聚焦仓库或配送中心位置选择这一关键问题,提供了覆盖从模型构建到编码实现的可运行工程案例。压缩包共20个文件,以C++源码和头文件为核心,代码量适中且结构清晰,同时包含可执行程序、工程配置文件与说明文档,总大小仅259KB,轻量便捷,便于快速阅读、调试与二次开发。已有240人学习下载,适合作为物流选址入门学习、算法课程教学演示或小型项目改造的基础范本。代码完整实现了粒子位置更新、速度调整、适应度评估等PSO核心环节,支持围绕运输成本、服务辐射范围、设施容量、交通便利程度等约束条件修改目标函数,从而适配不同区域的选址策略。整个工程在Visual C++环境下可直接编译运行,能够帮助用户直观理解迭代寻优在物流设施布局中的具体应用,也为后续扩展到多中心选址、动态需求分配等实际问题提供了可扩展的研究起点。
1. 物流选址问题的优化本质,为什么非要用 PSO
物流选址看着是地图问题,本质是组合优化里的设施选址问题(Facility Location Problem)。候选点一多,方案数量按组合数爆炸,整数规划在小规模还能硬算,几百个候选点就经常等不起。粒子群算法(PSO)不需要目标函数可导、不怕约束非线性,评估一次候选方案的成本就是算一遍运费矩阵,这让它成为选址问题上最容易被复现的启发式算法之一,网上流传的 pso.rar 代码包也一直不少。骨架大同小异,真正拉开差距的是建模口径、编码方式和约束处理,物流规划、算法工程师和刚开始接触组合优化的新手都能从中拿走各自需要的那一块。下面按这个顺序一次讲透。
2. 把物流选址改写成 PSO 能评估的数学模型
2.1 选址问题的三类建模口径:覆盖、中位与容量约束
选址问题在运筹学里至少有三种常见口径,先分清再写代码,否则后面所有结果都站不住。覆盖模型(covering)要求每个需求点到被启用设施的距离不超过指定半径,目标是最小化设施数量或最大化覆盖需求,典型场景是急救站、消防站这类响应时间敏感的布点。中位问题(p-median)固定设施数量 p,最小化所有需求点到最近设施的加权距离和,适合快递网点这类没容量限制但有数量预算的场景。容量受限设施选址问题(CFLP)在中位基础上加入单个设施的容量上限和固定建设成本,目标函数是建设成本与运输成本之和,这是物流仓储选址最常用的版本,也是 pso.rar 这类代码包默认的建模对象。
遗传算法也能做同样的事,但 PSO 没有交叉变异算子,参数更少,实现成本更低。选址场景评估一次解的代价远高于算子本身的代价,每轮迭代所有粒子共享一次 gbest 信息,信息传递效率在种群类算法里算高的。PSO 对这三种口径都能评估,差异只在目标函数和约束上,后续代码以 CFLP 为例展开。
2.2 目标函数与约束:从运费矩阵到罚函数
CFLP 的输入包括需求点集合、候选点集合、需求向量、候选点容量和固定成本,以及一张需求点到候选点的距离矩阵。物流实践中距离可以来自经纬度直线计算,也可以来自路网算路结果,建模方式一致,只是矩阵数值精度不同。
| 符号 | 含义 |
|---|---|
| a_i | 需求点 i 的需求量,单位与容量一致 |
| b_j | 候选点 j 的容量上限 |
| f_j | 候选点 j 的固定建设成本 |
| d_ij | 需求点 i 到候选点 j 的距离 |
| x_j | 0/1 变量,候选点 j 是否启用 |
| y_ij | 0/1 变量,需求点 i 是否分配给 j |
目标函数写作 min Σ f_j·x_j + ΣΣ a_i·d_ij·y_ij,约束有三条:每个需求点必须分配给一个启用设施;只有 x_j=1 时 y_ij 才允许为 1;分配到 j 的需求总量不超过 b_j。这是标准的混合整数规划,但 PSO 里粒子只负责给出 x,y 由贪心分配决定,容量约束交给罚函数处理。
import numpy as np def evaluate(x, demand, cap, open_cost, dist, lam=1e4): remain = cap.copy() total = float((x * open_cost).sum()) unmet = 0 for i in range(len(demand)): order = np.argsort(dist[i]) # 需求点 i 的候选点按距离升序 for j in order: if x[j] == 1 and remain[j] >= demand[i]: remain[j] -= demand[i] total += demand[i] * dist[i][j] break else: unmet += 1 # 所有启用设施都不满足容量,记违约 total += lam * unmet * dist.max() * demand.max() return total这段 evaluate 是选址问题里最核心的函数。粒子给出的 0/1 数组决定哪些候选点启用,每个需求点按距离从近到远寻找第一个仍有剩余容量的启用设施并分配,分配不出去就按罚项计费。lam 控制违约惩罚强度,一般取正常总成本的 1~10 倍量级;太小会让算法频繁违约换取低建设成本,太大又会让罚项淹没真实目标,粒子全部偏向保守多开设施。这里罚函数直接把容量违约折算成一个很大的距离成本,好处是 evaluate 的返回值仍然与目标函数同量纲,PSO 的速度更新不会因为量纲漂移而失灵。需求量的单位可以是吨、立方米或日均包裹数,只要和容量保持同单位即可;运输成本这里按距离线性累加,实际业务可再乘一个单位运费系数。
2.3 编码方式选择:二进制粒子还是索引粒子
PSO 要移动,先决定解怎么编码。最常见的是二进制编码:粒子维度数等于候选点数,每一位为 1 表示启用该候选点。这种编码天然兼容 PSO 的连续速度更新,通过 sigmoid 转移概率把速度变成 0/1 取值,实现简单且能保持种群多样性。另一种是索引编码,粒子直接保存选中的候选点编号,维度数等于预设设施数量,但问题在于是不是真的需要固定设施数量。物流场景通常不给死数量,而是给预算和容量,设施数量本身就是要优化的变量,索引编码在这种需求下维度得动态调整,麻烦。
对带容量约束的物流选址,二进制编码加贪心分配最稳妥:设施数量由粒子自己决定,容量约束由分配环节承担,目标函数只负责评价结果好坏。二进制编码的代价是解空间随候选点数量指数膨胀,60 个候选点就有 2^60 个组合,但 PSO 不需要遍历全部,它只需要评估函数稳定、信息能通过 pbest 和 gbest 流动。只要 evaluate 写得对,粒子就会慢慢把 1 的位置移到成本更低的组合上。
3. 用 Python 写一个能跑的 PSO 物流选址框架
3.1 数据准备:需求点、备选点与距离矩阵
先用随机数据验证算法逻辑,再替换成真实业务数据。下面生成 300 个需求点、60 个候选点,需求量和容量都拉开量级,避免数值太均匀导致区分度不够。
import numpy as np rng = np.random.default_rng(42) n_dem, n_cand = 300, 60 demand = rng.uniform(20, 80, n_dem) # 每个需求点的日均出货量 cap = rng.uniform(400, 1200, n_cand) # 候选点的容量上限 open_cost = rng.uniform(50000, 150000, n_cand) coord_d = rng.uniform(0, 100, (n_dem, 2)) coord_c = rng.uniform(0, 100, (n_cand, 2)) dist = np.linalg.norm(coord_d[:, None, :] - coord_c[None, :, :], axis=2)距离矩阵用欧氏距离近似,真实项目里建议替换为路网算路矩阵或实际运费矩阵,否则评估结果与实际运输成本的偏差可能到两位数百分比。固定成本一次性计入,运输成本按需求量和距离累加,这两项的量级关系直接影响 PSO 最后给出的设施数量:固定成本占比大,算法倾向少开设施;运输成本占比大,算法倾向把设施铺密一点缩短配送距离。后面调参时如果发现结果设施数明显不对,先检查这两个成本项的量级是否反映真实业务,而不是急着改算法。
3.2 粒子速度与位移更新:标准 PSO 循环落地
标准 PSO 的速度更新公式是 v = w·v + c1·r1·(pbest − x) + c2·r2·(gbest − x),连续版本直接 x = x + v,二进制版本则用 sigmoid 把速度映射成取 1 的概率。下面是完整的二进制 PSO 主循环。
def pso_locate(pop, iters, w_start=0.9, w_end=0.4, c1=1.5, c2=1.5): n = dist.shape[1] X = (rng.random((pop, n)) < 0.3).astype(int) # 初始约 30% 候选点开启 for k in np.where(X.sum(axis=1) == 0)[0]: X[k, rng.integers(0, n)] = 1 # 修复全 0 粒子 V = np.zeros((pop, n)) pbest_x = X.copy() pbest_f = np.array([evaluate(x, demand, cap, open_cost, dist) for x in X]) gi = pbest_f.argmin() gbest_x, gbest_f = pbest_x[gi].copy(), pbest_f[gi] for t in range(iters): w = w_start - (w_start - w_end) * t / iters # 惯性权重线性递减 r1, r2 = rng.random((pop, n)), rng.random((pop, n)) V = w * V + c1 * r1 * (pbest_x - X) + c2 * r2 * (gbest_x - X) V = np.clip(V, -4, 4) # 限速,防止 sigmoid 饱和 prob = 1 / (1 + np.exp(-V)) X = (rng.random((pop, n)) < prob).astype(int) for k in np.where(X.sum(axis=1) == 0)[0]: X[k, rng.integers(0, n)] = 1 F = np.array([evaluate(x, demand, cap, open_cost, dist) for x in X]) better = F < pbest_f pbest_x[better] = X[better] pbest_f[better] = F[better] if F.min() < gbest_f: gbest_f, gbest_x = F.min(), X[F.argmin()].copy() return gbest_x, gbest_f速度上限 clamp 到 ±4 是关键细节:sigmoid 在 |v| 大于 4 时概率已经接近 0 或 1,再大的速度只会让粒子失去随机性。初始开启概率取 0.3,因为物流选址的较优解通常只开少数设施,全 1 初始会让前期迭代浪费在关停设施上。pbest 和 gbest 的更新都基于 evaluate 返回的罚后适应度,罚函数一旦写错,PSO 会非常高效地去优化一个错误目标,这是排错时最先要怀疑的位置。
提示:evaluate 是评估瓶颈,总评估次数等于 pop 乘以 iters。粒子位置没变化时可以直接引用上一轮的适应度,能省下三成以上运行时间。
3.3 容量约束的罚函数参数怎么定
罚函数里 lam 的取值直接决定解的质量。lam 过小时,算法倾向少开设施、超容量分配,evaluate 算出来的成本虚低,最后方案在实际业务里根本跑不通;lam 过大时,任何违约方案都被罚到离谱,粒子会集体选择保守的多开设施方案,搜索多样性受损。常见校准做法是先跑一轮不考虑容量的评估,看正常总成本落在什么量级,再取该量级的 1~10 倍作为 lam。也可以用动态罚函数,前期 lam 小一些让粒子自由探索,后期逐步加大逼迫粒子满足容量约束,最终方案通常比固定 lam 便宜几个百分点。
| lam 设置 | 现象 | 适用阶段 |
|---|---|---|
| 0 | 设施少、超载严重 | 只用于 debug 分配逻辑 |
| 正常成本 1~10 倍 | 设施数与容量基本匹配 | 基准实验 |
| 随迭代增大 | 后期强制可行 | 动态罚函数 |
动态罚函数在迭代后期仍不稳定时,优先检查种群是否过早收敛到同一个方案,而不是继续加大惩罚。可以每隔 20 代打印一次 gbest 适应度和开启设施数,两个数字都在变说明还在搜索,设施数长期不动只有成本抖动,基本就是罚函数权重在拉扯。
4. 参数与算子调优:让 PSO 在选址问题上不早熟
4.1 惯性权重和学习因子的合理区间
惯性权重 w 控制粒子继承上一代速度的比例。w 大时粒子大步探索,w 小时围绕局部精修,选址问题普遍采用 w 从 0.9 线性递减到 0.4 的策略,迭代前期保多样性,后期收敛。c1、c2 分别控制向个体历史最优和全局最优学习的强度,两者都取 1.5 左右是稳妥组合;c1 明显大于 c2 时粒子容易各自为政、收敛慢,c2 太大则种群过早就贴到 gbest 上,多样性流失。种群大小和迭代次数不是越大越好,60 个候选点的场景,种群 40、迭代 200 通常已经能画出平滑收敛曲线。
| 参数 | 常见区间 | 选址场景调整建议 |
|---|---|---|
| 种群 pop | 30~80 | 候选点过百时取 60 以上 |
| 迭代 iters | 100~500 | 以收敛曲线平台期为准 |
| w_start / w_end | 0.9 → 0.4 | 早熟明显时拉长递减周期 |
| c1 / c2 | 1.0~2.0 | c1 略大保持探索,不悬殊 |
| Vmax | 3~6 | 固定 4,经验上最稳定 |
参数之间是联动的,w 减得快就得靠 c2 拉回粒子,c2 太大又会掩盖 w 的探索作用。建议每次只改一个参数,固定随机种子对比收敛曲线,否则很难判断是哪一项在起作用。候选点数量翻倍时,优先加种群而不是加迭代次数,种群多样性对高维二进制空间的影响比迭代轮数更直接。
4.2 离散 PSO 的速度映射与早熟判断
二进制 PSO 的位移不是直接加速度,而是以 sigmoid(v) 为概率把该位翻成 1。速度 v 为正说明 pbest 和 gbest 在这一位都取 1,粒子向 1 靠拢;为负则向 0 靠拢。当所有粒子收敛到同一个 gbest 时,速度会被压到接近 0,翻转概率趋近 0.5,此时粒子在局部反复震荡,很难整体跳出。判断早熟不能只看适应度曲线平不平,还要看 gbest 连续多少迭代没有更新。连续 20 代不更新,可选两种处理:按比例重启部分粒子的位置和速度,或者对 gbest 做局部搜索。重启时保留 gbest 不动,只重置其余粒子的 V 和一个随机位置,种群既能保持多样性又不会丢掉已找到的最优解。
4.3 混合局部搜索:在 gbest 上做位翻转邻域
把局部搜索嵌入 PSO 是选址这类组合优化问题最有效的改进手段之一。每轮迭代结束后对当前 gbest 做随机位翻转,翻转后适应度变好就替换,相当于在已收敛的解周围再做一轮精修。
def local_search(sol, max_try=50): best_f = evaluate(sol, demand, cap, open_cost, dist) best_s = sol.copy() for _ in range(max_try): k = rng.integers(0, len(sol)) cand = sol.copy() cand[k] = 1 - cand[k] # 翻转一个候选点的启用状态 if cand.sum() == 0: continue f2 = evaluate(cand, demand, cap, open_cost, dist) if f2 < best_f: best_f, best_s = f2, cand.copy() return best_s, best_f位翻转邻域每次只改动一个候选点,和 2-opt 的交换思想类似,但实现和评估成本都更低。max_try 取 50~100 时,每次局部搜索只增加几十次 evaluate 调用,相对主循环的 pop×iters 次评估可以忽略。更彻底的版本可以对需求点分配做重分配尝试,但那样要改动贪心分配逻辑,评估耗时成倍上升,先用位翻转即可。实际跑下来,加了这步之后,容量约束紧、可行解稀疏的算例,最终成本通常比裸 PSO 低 5% 以上,而且每个随机种子之间的结果波动明显变小。
5. 结果验证与 pso.rar 这类代码包落地时的三个坑
5.1 小规模算例的穷举基准校验
把需求点缩到 6 个、候选点缩到 8 个,用同一个 evaluate 穷举全部 2^8 个组合,记录最优解和最优适应度。如果 PSO 跑出来的结果比穷举差,而且多次运行都差,优先怀疑 evaluate 里的贪心分配逻辑有 bug,而不是 PSO 参数不对。穷举校验只验证了评估函数一致性,接下来对同一个随机种子跑三次 PSO,确认最优适应度波动在 2% 以内,再换种子做下一组实验。真实数据集没有基准解时,这个方法能帮你确认整套评估链路是可信的。
5.2 解压 rar 后先看这三处
网上流传的 pso.rar 代码包,解压后通常是 MATLAB 或 Python 脚本加一份 Excel 数据。第一处,把数据矩阵替换成自己的需求、容量、距离,重点检查单位是否一致;第二处,看分配子程序,很多老代码在最近候选点容量不足时会直接跳过该需求点而不是继续找次近设施,这会让 evaluate 虚低,结果看起来很美但实际不可行;第三处,验证编码注释,GBK 编码的中文注释在 UTF-8 环境下会乱码甚至直接报语法错,脚本第一行加上# -*- coding: utf-8 -*-再跑。这三处确认完,代码包才算真正落地到你的数据上。
5.3 判读收敛曲线时看三个信号
收敛曲线的横轴是迭代次数,纵轴是 gbest 适应度。曲线平滑但最终值偏高,是探索不足,加大 w_start 或种群;曲线骤降后长期横盘,是早熟,加上 4.3 节的局部搜索;曲线持续震荡不收敛,多半是罚函数 lam 太大导致可行区域被分割得太碎。每次跑完把 gbest 对应的开启设施数、总运输成本、总建设成本分别打出来,对照业务经验看是否合理。先在这三个信号上对号入座,再决定动哪个参数,比盲目调 w 和 c1 高效得多,这也是 pso 选址代码从「能跑」到「敢用」最关键的一步。
本文还有配套的精品资源,点击获取