21
0

基础篇:Pulp库求解规划模型方法

2026-05-12
2026-05-13
基础篇:Pulp库求解规划模型方法

前言

PuLP 是 Python 生态中最流行的线性规划与整数规划建模库之一。它将优化问题的数学建模转化为简洁的 Python 代码,底层自动调用 CBC、Gurobi、CPLEX 等求解器进行计算。对于熟悉线性规划理论但希望用代码快速实现的人来说,PuLP 是首选工具。

适用读者

本教程面向已经了解线性规划基本概念的读者。如果你已经知道以下概念,就可以直接开始:

  • 目标函数、决策变量、约束条件

  • 可行域、最优解、紧约束

  • 线性规划与整数规划的区别

本教程不讲解什么是线性规划、单纯形法的数学原理等内容,而是聚焦于如何用 PuLP 把数学模型写成代码

内容结构

章节

内容

核心知识点

第1章

快速开始

五步流程:创建问题 → 定义变量 → 添加约束 → 求解 → 提取结果

第2章

变量与约束详解

连续/整数/二元变量、批量创建、多种约束写法

第3章

求解与结果提取

状态码、变量值、松弛量、影子价格、求解器配置

第4章

常见应用场景

运输、指派、背包、饮食、生产计划 5 个完整示例

第5章

高级特性概览

动态约束生成、约束修改、多目标优化、灵敏度分析

环境说明

  • Python 版本:3.8+

  • PuLP 版本:2.x

  • 默认求解器:CBC(PuLP 内置,无需额外安装)

  • 可视化:Matplotlib(需配置中文字体 SimHei

所有代码示例均可直接复制运行。可视化部分使用 plt.rcParams['font.sans-serif'] = ['SimHei', 'DejaVu Sans'] 配置中文显示。

运行方式

# 安装 PuLP(如未安装)
pip install pulp

# 安装可视化依赖
pip install matplotlib numpy
import pulp  # 导入即可开始建模

学习建议

  1. 先通读第 1 章,掌握五步流程模板

  2. 第 2、3 章是核心基础,建议边读边跑代码

  3. 第 4 章的场景可以直接作为数模论文的模板套用

  4. 第 5 章为进阶内容,按需查阅即可

第1节 快速开始

本节通过一个完整的生产计划问题示例,展示使用 PuLP 求解线性规划的基本流程。

1.1 问题描述

某工厂生产两种产品 A 和 B,每件产品 A 利润为 40 元,每件产品 B 利润为 30 元。生产受到以下资源限制:

资源

产品 A 消耗

产品 B 消耗

总量

原材料(kg)

2

1

100

工时(小时)

1

2

80

目标是确定两种产品的产量,使得总利润最大。

数学模型

maxz=40x1+30x2\max z = 40x_1 + 30x_2
s.t.{2x1+x2100x1+2x280x1,x20\text{s.t.}\begin{cases} 2x_1 + x_2 \leq 100 \\ x_1 + 2x_2 \leq 80 \\ x_1, x_2 \geq 0 \end{cases}

1.2 PuLP 实现

线性规划问题的求解在 PuLP 中遵循固定五步流程:创建问题 → 定义变量 → 添加约束 → 求解 → 提取结果。

import pulp

# 第1步:创建问题实例,pulp.LpMaximize 表示最大化
prob = pulp.LpProblem("生产计划问题", pulp.LpMaximize)

# 第2步:定义决策变量,lowBound=0 表示非负约束
x1 = pulp.LpVariable("产品A产量", lowBound=0, cat="Continuous")
x2 = pulp.LpVariable("产品B产量", lowBound=0, cat="Continuous")

# 第3步:添加目标函数和约束
prob += 40 * x1 + 30 * x2, "总利润"           # 目标函数
prob += 2 * x1 + x2 <= 100, "原材料约束"       # 约束1
prob += x1 + 2 * x2 <= 80, "工时约束"         # 约束2

# 第4步:求解(默认使用 CBC 求解器)
prob.solve()

# 第5步:提取结果
print(f"求解状态: {pulp.LpStatus[prob.status]}")
print(f"最优解: 产品A = {x1.varValue:.2f}, 产品B = {x2.varValue:.2f}")
print(f"最大利润: {pulp.value(prob.objective):.2f} 元")
for name, constraint in prob.constraints.items():
    print(f"约束 [{name}] 松弛量 = {constraint.slack:.2f}")
求解状态: Optimal
最优解: 产品A = 40.00, 产品B = 20.00
最大利润: 2200.00 元
约束 [原材料约束] 松弛量 = -0.00
约束 [工时约束] 松弛量 = -0.00

关键点:

  • LpProblem(name, sense) 创建问题,senseLpMaximizeLpMinimize

  • LpVariable(name, lowBound, upBound, cat) 定义变量,cat 控制变量类型

  • prob += expression, "label" 同时支持添加目标函数和约束,字符串为标签

  • prob.solve() 调用求解器,返回状态码

  • varValue 读取变量值,pulp.value() 读取表达式值

  • constraint.slack 读取约束松弛量(负零表示约束恰好紧)

1.3 结果可视化

对于两个变量的线性规划问题,可以绘制可行域和最优解的几何图示。

import matplotlib.pyplot as plt
import numpy as np

plt.rcParams['font.sans-serif'] = ['SimHei', 'DejaVu Sans']
plt.rcParams['axes.unicode_minus'] = False

# 生成约束边界线
x = np.linspace(0, 60, 200)
y1 = 100 - 2 * x      # 原材料约束: 2x1 + x2 <= 100
y2 = (80 - x) / 2     # 工时约束: x1 + 2x2 <= 80

fig, ax = plt.subplots(figsize=(8, 6))

# 填充可行域
y_feasible = np.minimum(y1, y2)
y_feasible = np.maximum(y_feasible, 0)
ax.fill_between(x, 0, y_feasible, where=(x >= 0) & (x <= 50),
                alpha=0.2, color='skyblue', label='可行域')

# 绘制约束线
ax.plot(x, y1, '--', color='#FF5722', linewidth=2, label='原材料约束 (2x1+x2=100)')
ax.plot(x, y2, '--', color='#2196F3', linewidth=2, label='工时约束 (x1+2x2=80)')

# 标注最优解
opt_x, opt_y = 40, 20
ax.plot(opt_x, opt_y, 'ro', markersize=12, zorder=5)
ax.annotate(f'最优解\n(40, 20)\n利润=2200', xy=(opt_x, opt_y),
            xytext=(opt_x + 5, opt_y + 10), fontsize=11, fontweight='bold',
            arrowprops=dict(arrowstyle='->', color='red'))

# 绘制等利润线
x_obj = np.linspace(0, 60, 100)
for z in [800, 1400, 2200]:
    y_obj = (z - 40 * x_obj) / 30
    mask = (y_obj >= 0) & (y_obj <= 60)
    ax.plot(x_obj[mask], y_obj[mask], color='#4CAF50', alpha=0.4, linewidth=1)
ax.plot([], [], color='#4CAF50', alpha=0.6, label='等利润线')

ax.set_xlim(0, 60)
ax.set_ylim(0, 60)
ax.set_xlabel('产品 A 产量', fontsize=12)
ax.set_ylabel('产品 B 产量', fontsize=12)
ax.set_title('线性规划可行域与最优解', fontsize=14, fontweight='bold')
ax.legend(loc='upper right', fontsize=10)
ax.grid(True, alpha=0.3)
fig.tight_layout()
fig.show()
quickstart_feasible.png

几何视角:

  • 可行域是所有约束不等式交集构成的凸多边形区域

  • 最优解一定出现在可行域的某个顶点上(这是线性规划的基本定理)

  • 等利润线沿梯度方向平移,最后接触可行域的点即为最优解

  • 本例中两个约束都是紧约束(松弛量为 0),最优解恰好是两个约束边界的交点

1.4 代码结构模板

所有 PuLP 求解线性规划的代码都遵循相同的结构模板,后续章节的所有例子都套用此模板:

步骤

代码模式

说明

1. 创建问题

prob = pulp.LpProblem(name, sense)

LpMaximize / LpMinimize

2. 定义变量

x = pulp.LpVariable(name, low, up, cat)

cat: Continuous, Integer, Binary

3. 添加目标

prob += objective_expr, "label"

可省略标签

4. 添加约束

prob += constraint_expr, "label"

支持 <=, >=, ==

5. 求解

prob.solve()

默认 CBC,可指定其他求解器

6. 读结果

x.varValue, pulp.value(obj), c.slack

最优解、目标值、松弛量

1.5 本节小结

  • PuLP 求解线性规划遵循"创建问题 → 定义变量 → 添加约束 → 求解 → 提取结果"五步流程

  • += 运算符同时支持添加目标函数和约束

  • LpStatus[prob.status] 将状态码转为可读字符串

  • 两个变量的问题可在二维平面上绘制可行域,最优解出现在顶点处

  • 约束的 slack 属性告诉我们哪些约束是紧的(松弛量为 0)

第2节 变量与约束详解

PuLP 中的变量和约束定义非常灵活,本节系统讲解各类用法。

2.1 三种变量类型

PuLP 支持三种变量类型,通过 LpVariablecat 参数指定:

类型

参数值

说明

连续变量

"Continuous"(默认)

可取任意实数值

整数变量

"Integer"

仅取整数值

二元变量

"Binary"

仅取 0 或 1

对比示例

同样在 x <= 7.5 约束下最大化 x,三种变量的求解结果不同:

import pulp

# 连续变量
prob_c = pulp.LpProblem("连续变量示例", pulp.LpMaximize)
xc = pulp.LpVariable("连续变量", lowBound=0, upBound=10, cat="Continuous")
prob_c += xc
prob_c += xc <= 7.5
prob_c.solve()
print(f"连续变量: x = {xc.varValue}")     # 7.5

# 整数变量
prob_i = pulp.LpProblem("整数变量示例", pulp.LpMaximize)
xi = pulp.LpVariable("整数变量", lowBound=0, upBound=10, cat="Integer")
prob_i += xi
prob_i += xi <= 7.5
prob_i.solve()
print(f"整数变量:   x = {xi.varValue}")   # 7.0

# 二元变量
prob_b = pulp.LpProblem("二元变量示例", pulp.LpMaximize)
xb = pulp.LpVariable("二元变量", cat="Binary")
prob_b += xb
prob_b.solve()
print(f"二元变量:   x = {xb.varValue}")   # 1.0
连续变量: x = 7.5
整数变量:   x = 7.0
二元变量:   x = 1.0

关键点:

  • 连续变量会取到约束边界的精确值(7.5)

  • 整数变量会自动向下取整(7.0),因为不能超过 7.5

  • 二元变量在最大化目标下取 1(唯一可选的非零值)

  • cat 参数大小写不敏感,但建议用首字母大写形式

ch2_variables.png

2.2 批量创建变量:LpVariable.dicts

当变量数量较多时,逐个定义非常繁琐。LpVariable.dicts 用字典形式一次性创建多个变量:

products = ['A', 'B', 'C']
prices = {'A': 50, 'B': 30, 'C': 40}

prob = pulp.LpProblem("批量变量示例", pulp.LpMaximize)
x = pulp.LpVariable.dicts("产量", products, lowBound=0, cat='Continuous')

# 目标函数:最大化总收入
prob += pulp.lpSum([prices[p] * x[p] for p in products])

# 约束
prob += x['A'] + x['B'] + x['C'] <= 100, "总产能"
prob += x['A'] <= 40, "产品A上限"
prob += x['B'] <= 35, "产品B上限"

prob.solve()

print(f"求解状态: {pulp.LpStatus[prob.status]}")
for p in products:
    print(f"  产品 {p} 产量 = {x[p].varValue:.2f}")
print(f"  总收入 = {pulp.value(prob.objective):.2f}")
求解状态: Optimal
  产品 A 产量 = 40.00
  产品 B 产量 = 0.00
  产品 C 产量 = 60.00
  总收入 = 4400.00

关键点:

  • LpVariable.dicts(name, keys, lowBound, upBound, cat) 返回一个字典,键为 keys 中的每个元素

  • pulp.lpSum() 是 PuLP 的求和函数,支持列表推导式写法

  • 求解器会优先分配高单价产品(A 单价 50,C 单价 40,B 单价 30)

  • 字典键可以是字符串、数字、元组等任意可哈希类型

2.3 约束的多种添加方式

方式 1:+= 运算符(最常用)

prob += expression, "label"

+= 是 PuLP 最简洁的约束添加方式,支持目标函数和约束的统一定义:

prob += 2 * x1 + x2 <= 100, "原材料约束"
prob += x1 + 2 * x2 <= 80,  "工时约束"

方式 2:addConstraint 方法

prob.addConstraint(expression, name)

与方法 1 等价,适合需要条件判断添加约束的场景:

for product in products:
    if product in limited_products:
        prob.addConstraint(x[product] <= limits[product], f"{product}_上限")

方式 3:不等式两边都放表达式

prob += 2 * w + 3 <= 15, "2w+3<=15"

PuLP 支持不等式两边都放表达式,会自动整理为标准形式:

prob = pulp.LpProblem("方式3", pulp.LpMaximize)
w = pulp.LpVariable("w", lowBound=0)
prob += w
prob += 2*w + 3 <= 15, "2w+3<=15"
prob.solve()
print(f"w = {w.varValue}")    # 6.0

方式 4:使用 lpSum 构建复杂约束

items = pulp.LpVariable.dicts("item", [1, 2, 3], lowBound=0, cat='Integer')
prob += pulp.lpSum(items[i] for i in [1, 2, 3]) <= 10, "总和约束"
prob += items[1] >= 2, "item1下限"

lpSum 支持生成器表达式,写法简洁:

items = pulp.LpVariable.dicts("item", [1, 2, 3], lowBound=0, cat='Integer')
prob = pulp.LpProblem("方式4", pulp.LpMaximize)
prob += pulp.lpSum(items[i] for i in [1, 2, 3])
prob += pulp.lpSum(items[i] for i in [1, 2, 3]) <= 10
prob += items[1] >= 2
prob.solve()
print(f"item1={items[1].varValue}, item2={items[2].varValue}, item3={items[3].varValue}")
item1=2.0, item2=8.0, item3=0.0

方式 5:等式约束

除了 <=>=,PuLP 同样支持等式约束 ==

prob += x1 + x2 == 100, "总产量必须等于100"

2.4 变量范围设置

变量定义时可通过 lowBoundupBound 直接指定范围:

v1 = pulp.LpVariable("v1_无界")                    # 无界,默认 lowBound=-inf
v2 = pulp.LpVariable("v2_下界", lowBound=5)        # 下界 5
v3 = pulp.LpVariable("v3_上界", upBound=20)        # 上界 20
v4 = pulp.LpVariable("v4_双侧", lowBound=3, upBound=15)  # 双侧界 [3, 15]

综合示例:

prob = pulp.LpProblem("变量范围示例", pulp.LpMaximize)
v1 = pulp.LpVariable("v1_无界")
v2 = pulp.LpVariable("v2_下界", lowBound=5)
v3 = pulp.LpVariable("v3_上界", upBound=20)
v4 = pulp.LpVariable("v4_双侧", lowBound=3, upBound=15)

prob += v1 + v2 + v3 + v4
prob += v1 <= 10
prob += v2 <= 25
prob += v3 >= 5
prob += v4 <= 20  # 冗余约束(已定义 upBound=15)
prob.solve()

print(f"v1 (无界):     = {v1.varValue:.2f}")   # 10.00
print(f"v2 (low>=5):   = {v2.varValue:.2f}")   # 25.00
print(f"v3 (up<=20):   = {v3.varValue:.2f}")   # 20.00
print(f"v4 (3<=v<=15): = {v4.varValue:.2f}")   # 15.00
v1 (无界):     = 10.00
v2 (low>=5):   = 25.00
v3 (up<=20):   = 20.00
v4 (3<=v<=15): = 15.00

关键点:

  • 默认 lowBound 为负无穷,upBound 为正无穷

  • 变量定义时的边界和后续添加的约束共同生效

  • 冗余约束不影响求解结果,但会增加求解时间

2.5 本节小结

  • 三种变量类型:Continuous(连续)、Integer(整数)、Binary(二元)

  • LpVariable.dicts 批量创建变量,搭配 pulp.lpSum 写目标函数和约束更简洁

  • 约束添加最常用 += 方式,addConstraint 适合条件化添加

  • 支持 <=>=== 三种关系运算符

  • 变量范围在定义时通过 lowBound / upBound 指定

第3节 求解与结果提取

本节讲解如何配置求解器、解读求解状态、提取完整的求解结果,以及理解影子价格。

3.1 求解状态码

prob.solve() 返回求解状态码,通过 pulp.LpStatus 字典可以将其转换为可读字符串。

状态码

名称

含义

0

Not Solved

尚未调用 solve()

1

Optimal

找到最优解

-1

Infeasible

问题不可行(约束矛盾)

-2

Unbounded

问题无界(目标可以无限优化)

-3

Undefined

求解失败或数值异常

各状态示例

import pulp

# Optimal — 找到最优解
prob = pulp.LpProblem("最优示例", pulp.LpMaximize)
x = pulp.LpVariable("x", lowBound=0)
prob += x
prob += x <= 5
prob.solve()
print(f"状态码={prob.status}, 名称={pulp.LpStatus[prob.status]}")
# 状态码=1, 名称=Optimal

# Unbounded — 无界(最大化 x 但无上界)
prob2 = pulp.LpProblem("无界示例", pulp.LpMaximize)
y = pulp.LpVariable("y", lowBound=0)
prob2 += y
prob2.solve()
print(f"状态码={prob2.status}, 名称={pulp.LpStatus[prob2.status]}")
# 状态码=-2, 名称=Unbounded

# Infeasible — 不可行(矛盾约束)
prob3 = pulp.LpProblem("不可行示例", pulp.LpMaximize)
z = pulp.LpVariable("z", lowBound=0)
prob3 += z
prob3 += z <= 3
prob3 += z >= 5
prob3.solve()
print(f"状态码={prob3.status}, 名称={pulp.LpStatus[prob3.status]}")
# 状态码=-1, 名称=Infeasible

关键点:

  • 务必在读取结果前检查状态码,只有 Optimal 时结果才可靠

  • 实际代码中建议用条件判断:if pulp.LpStatus[prob.status] == 'Optimal': ...

3.2 提取求解结果

求解完成后,可通过以下属性提取完整信息:

prob = pulp.LpProblem("完整示例", pulp.LpMaximize)
a = pulp.LpVariable("a", lowBound=0)
b = pulp.LpVariable("b", lowBound=0)
c = pulp.LpVariable("c", lowBound=0)

prob += 3*a + 5*b + 2*c, "目标函数"
prob += a + b + c <= 10, "资源1"
prob += 2*a + b <= 12, "资源2"
prob += b + 2*c <= 14, "资源3"
prob.solve()

# 1. 求解状态
print(f"状态: {pulp.LpStatus[prob.status]}")           # Optimal

# 2. 目标函数值
print(f"目标值: {pulp.value(prob.objective)}")          # 50.0

# 3. 变量值
for var in prob.variables():
    print(f"  {var.name} = {var.varValue}")
#   a = 0.0
#   b = 10.0
#   c = 0.0

# 4. 约束松弛量
for name, constr in prob.constraints.items():
    print(f"  [{name}] 松弛量 = {constr.slack}")
#   [资源1] 松弛量 = -0.0    (紧约束)
#   [资源2] 松弛量 = 2.0     (松弛 2 单位)
#   [资源3] 松弛量 = 4.0     (松弛 4 单位)

关键点:

  • prob.variables() 返回所有变量的迭代器,按字典序排列

  • var.varValue 是变量值,未求解时为 None

  • constr.slack 为约束右端减左端的差值,0 表示紧约束

  • pulp.value(expr) 可计算任意 PuLP 表达式的值

ch3_solving.png

3.3 影子价格(对偶值)

影子价格(constr.pi)表示约束右端增加一个单位时,目标函数的变化量:

for name, constr in prob.constraints.items():
    print(f"  [{name}] 影子价格 = {constr.pi}")
  [资源1] 影子价格 = 5.0000
  [资源2] 影子价格 = -0.0000
  [资源3] 影子价格 = -0.0000

经济含义:资源 1 的影子价格为 5,意味着资源 1 的可用量每增加 1 单位,总利润将增加 5。资源 2 和 3 的影子价格为 0(因为它们有松弛量),增加这些资源不会改善目标值。

验证:

# 资源1从10增加到11
prob2 = pulp.LpProblem("验证", pulp.LpMaximize)
a2 = pulp.LpVariable("a2", lowBound=0)
b2 = pulp.LpVariable("b2", lowBound=0)
c2 = pulp.LpVariable("c2", lowBound=0)
prob2 += 3*a2 + 5*b2 + 2*c2
prob2 += a2 + b2 + c2 <= 11    # 增加 1 单位
prob2 += 2*a2 + b2 <= 12
prob2 += b2 + 2*c2 <= 14
prob2.solve()
print(f"新目标值: {pulp.value(prob2.objective)}")   # 55.0
print(f"差值: {55.0 - 50.0}")                        # 5.0 (等于影子价格)

3.4 求解器配置

PuLP 默认使用内置的 CBC 求解器(开源免费)。可用求解器列表:

# 列出所有已安装的求解器
solvers = pulp.listSolvers(onlyAvailable=True)
print(solvers)  # ['PULP_CBC_CMD', 'HiGHS']

切换求解器

# 使用默认 CBC(不传参数)
prob.solve()

# 显式指定 CBC 并关闭输出
prob.solve(pulp.PULP_CBC_CMD(msg=0))

# 使用 HiGHS 求解器
prob.solve(pulp.HiGHS())

# 使用 Gurobi(需安装 Gurobi 许可证)
# prob.solve(pulp.GUROBI())

常用求解器对比

求解器

类型

需要许可证

说明

PULP_CBC_CMD

开源

PuLP 内置,默认选择

HiGHS

开源

性能优秀,推荐尝试

GUROBI

商业

学术用户可申请免费许可证

CPLEX

商业

IBM 出品,性能强劲

GLPK

开源

轻量级,需单独安装

求解器参数

大多数求解器支持额外参数:

# CBC: 设置时间限制和日志级别
prob.solve(pulp.PULP_CBC_CMD(timeLimit=60, msg=1, threads=4))

# Gurobi: 设置时间限制和 MIP 间隙
prob.solve(pulp.GUROBI(timeLimit=300, mipgap=0.01))

常用参数:

  • msg=0:关闭求解器控制台输出(推荐在脚本中使用)

  • timeLimit:最大求解时间(秒),超时后返回当前最优解

  • mipgap:MIP 问题的可接受间隙,设为 0.01 表示 1% 以内即停止

3.5 本节小结

  • 通过 pulp.LpStatus[prob.status] 检查求解状态,确保为 Optimal

  • prob.variables() 迭代所有变量,var.varValue 读取变量值

  • constr.slack 读取约束松弛量,constr.pi 读取影子价格

  • 影子价格为正表示该约束是紧的,增加右端可以改善目标

  • 默认 CBC 求解器免费可用,也可切换到 HiGHS、Gurobi、CPLEX 等

  • msg=0 关闭求解器输出,timeLimit 设置超时

第4节 常见应用场景

本节给出 5 个经典优化问题的完整 PuLP 实现,每个场景都是可直接运行的代码。

4.1 运输问题

问题:3 个工厂向 4 个市场供货,已知各工厂供应量、各市场需求量及单位运输成本,求最小总运输成本。

市场 A

市场 B

市场 C

市场 D

供应量

工厂 1

2

3

1

4

8

工厂 2

3

2

4

1

5

工厂 3

1

4

3

2

7

需求量

4

8

3

5

import pulp

factories = ['工厂1', '工厂2', '工厂3']
markets = ['市场A', '市场B', '市场C', '市场D']
supply = {'工厂1': 8, '工厂2': 5, '工厂3': 7}
demand = {'市场A': 4, '市场B': 8, '市场C': 3, '市场D': 5}
cost = {
    ('工厂1', '市场A'): 2, ('工厂1', '市场B'): 3, ('工厂1', '市场C'): 1, ('工厂1', '市场D'): 4,
    ('工厂2', '市场A'): 3, ('工厂2', '市场B'): 2, ('工厂2', '市场C'): 4, ('工厂2', '市场D'): 1,
    ('工厂3', '市场A'): 1, ('工厂3', '市场B'): 4, ('工厂3', '市场C'): 3, ('工厂3', '市场D'): 2,
}

prob = pulp.LpProblem("运输问题", pulp.LpMinimize)

# 决策变量:x[f,m] = 从工厂f运往市场m的数量
x = pulp.LpVariable.dicts("运输量", [(f, m) for f in factories for m in markets],
                           lowBound=0, cat='Continuous')

# 目标:最小化总运输成本
prob += pulp.lpSum(cost[f, m] * x[f, m] for f in factories for m in markets)

# 约束:每个工厂的运出量不超过供应量
for f in factories:
    prob += pulp.lpSum(x[f, m] for m in markets) <= supply[f], f"{f}_供应"

# 约束:每个市场的收到量等于需求量
for m in markets:
    prob += pulp.lpSum(x[f, m] for f in factories) == demand[m], f"{m}_需求"

prob.solve(pulp.PULP_CBC_CMD(msg=0))

print(f"最小运输成本: {pulp.value(prob.objective):.2f}")
for f in factories:
    for m in markets:
        if x[f, m].varValue > 0.01:
            print(f"  {f} -> {m}: {x[f, m].varValue:.1f}")
最小运输成本: 36.00
  工厂1 -> 市场B: 5.0
  工厂1 -> 市场C: 3.0
  工厂2 -> 市场B: 3.0
  工厂2 -> 市场D: 2.0
  工厂3 -> 市场A: 4.0
  工厂3 -> 市场D: 3.0

关键点:

  • 使用元组 (f, m) 作为 LpVariable.dicts 的键,方便组织多维变量

  • 供应约束用 <=,需求约束用 ==(必须满足)

  • 用条件判断过滤掉零值,只打印有运输量的路线

4.2 指派问题

问题:4 个工人完成 4 项任务,每人只能做一个任务,每个任务只能由一人完成,求最小总成本。

任务 A

任务 B

任务 C

任务 D

工人 1

9

2

7

8

工人 2

6

4

3

7

工人 3

5

8

1

8

工人 4

7

6

9

4

workers = ['工人1', '工人2', '工人3', '工人4']
tasks = ['任务A', '任务B', '任务C', '任务D']
cost_matrix = {
    ('工人1', '任务A'): 9, ('工人1', '任务B'): 2, ('工人1', '任务C'): 7, ('工人1', '任务D'): 8,
    ('工人2', '任务A'): 6, ('工人2', '任务B'): 4, ('工人2', '任务C'): 3, ('工人2', '任务D'): 7,
    ('工人3', '任务A'): 5, ('工人3', '任务B'): 8, ('工人3', '任务C'): 1, ('工人3', '任务D'): 8,
    ('工人4', '任务A'): 7, ('工人4', '任务B'): 6, ('工人4', '任务C'): 9, ('工人4', '任务D'): 4,
}

prob = pulp.LpProblem("指派问题", pulp.LpMinimize)

# 二元变量:y[w,t] = 1 表示工人w被指派给任务t
y = pulp.LpVariable.dicts("指派", [(w, t) for w in workers for t in tasks], cat='Binary')

# 目标:最小化总成本
prob += pulp.lpSum(cost_matrix[w, t] * y[w, t] for w in workers for t in tasks)

# 约束:每个工人只能做一个任务
for w in workers:
    prob += pulp.lpSum(y[w, t] for t in tasks) == 1, f"{w}_只能做一个任务"

# 约束:每个任务只能被一人做
for t in tasks:
    prob += pulp.lpSum(y[w, t] for w in workers) == 1, f"{t}_只能被一人做"

prob.solve(pulp.PULP_CBC_CMD(msg=0))

print(f"最小总成本: {pulp.value(prob.objective):.0f}")
for w in workers:
    for t in tasks:
        if y[w, t].varValue > 0.5:
            print(f"  {w} -> {t} (成本: {cost_matrix[w, t]})")
最小总成本: 13
  工人1 -> 任务B (成本: 2)
  工人2 -> 任务A (成本: 6)
  工人3 -> 任务C (成本: 1)
  工人4 -> 任务D (成本: 4)

关键点:

  • 指派问题必须用 Binary 变量(0-1 决策)

  • 两个方向的等式约束保证一一对应

  • 这是运输问题的特例(供应量和需求量都为 1)

4.3 背包问题

问题:背包容量为 10,有 5 件物品可选,每件物品有价值与重量,求最大总价值。

物品

价值

重量

物品 1

10

4

物品 2

15

6

物品 3

7

3

物品 4

8

5

物品 5

12

2

items = ['物品1', '物品2', '物品3', '物品4', '物品5']
values = [10, 15, 7, 8, 12]
weights = [4, 6, 3, 5, 2]
capacity = 10

prob = pulp.LpProblem("背包问题", pulp.LpMaximize)

# 二元变量:z[i] = 1 表示选择物品i
z = pulp.LpVariable.dicts("选择", items, cat='Binary')

# 目标:最大化总价值
prob += pulp.lpSum(values[i] * z[items[i]] for i in range(len(items)))

# 约束:总重量不超过容量
prob += pulp.lpSum(weights[i] * z[items[i]] for i in range(len(items))) <= capacity

prob.solve(pulp.PULP_CBC_CMD(msg=0))

print(f"最大价值: {pulp.value(prob.objective):.0f}")
total_weight = sum(weights[i] * z[items[i]].varValue for i in range(len(items)))
print(f"总重量: {total_weight:.0f}/{capacity}")
for i in range(len(items)):
    if z[items[i]].varValue > 0.5:
        print(f"  {items[i]}: 价值={values[i]}, 重量={weights[i]}")
最大价值: 29
总重量: 9/10
  物品1: 价值=10, 重量=4
  物品3: 价值=7, 重量=3
  物品5: 价值=12, 重量=2

关键点:

  • 背包问题是经典的 NP-hard 问题,小规模时 CBC 可以精确求解

  • 选择物品 1、3、5,总价值 29,总重量 9(未装满但已是最优)

  • 如果允许物品分割(分数背包),贪心算法即可求解;但 0-1 背包必须用整数规划

4.4 饮食/营养配比问题

问题:在满足最低营养需求的前提下,选择食物组合使得花费最小。

食物

价格(元)

热量

蛋白质

脂肪

面包

3

200

5

2

牛奶

5

150

8

5

鸡蛋

2

80

6

5

苹果

4

95

0.5

0.3

鸡肉

12

250

30

10

营养需求:热量 ≥ 500,蛋白质 ≥ 30,脂肪 ≤ 40

foods = ['面包', '牛奶', '鸡蛋', '苹果', '鸡肉']
costs = [3, 5, 2, 4, 12]
calories = [200, 150, 80, 95, 250]
protein = [5, 8, 6, 0.5, 30]
fat = [2, 5, 5, 0.3, 10]

min_calories = 500
min_protein = 30
max_fat = 40

prob = pulp.LpProblem("饮食问题", pulp.LpMinimize)

food_vars = pulp.LpVariable.dicts("食物量", foods, lowBound=0, cat='Continuous')

# 目标:最小化总花费
prob += pulp.lpSum(costs[i] * food_vars[foods[i]] for i in range(len(foods)))

# 约束:满足营养需求
prob += pulp.lpSum(calories[i] * food_vars[foods[i]] for i in range(len(foods))) >= min_calories
prob += pulp.lpSum(protein[i] * food_vars[foods[i]] for i in range(len(foods))) >= min_protein
prob += pulp.lpSum(fat[i] * food_vars[foods[i]] for i in range(len(foods))) <= max_fat

prob.solve(pulp.PULP_CBC_CMD(msg=0))

print(f"最小花费: {pulp.value(prob.objective):.2f} 元")
for i in range(len(foods)):
    val = food_vars[foods[i]].varValue
    if val > 0.01:
        print(f"  {foods[i]}: {val:.2f} 份")
最小花费: 11.00 元
  面包: 0.75 份
  鸡蛋: 4.38 份

关键点:

  • 饮食问题同时包含 >=(最低营养)和 <=(最高脂肪)约束

  • 求解器自动选择了性价比最高的食物组合(面包提供热量,鸡蛋提供蛋白质)

  • 实际应用中可以为变量加 upBound 限制单种食物的最大摄入量

4.5 生产计划问题(整数规划)

问题:3 种产品的生产计划,受原材料、工时和市场需求的限制,求最大利润。

产品 A

产品 B

产品 C

利润(元/件)

40

30

50

原材料(kg/件)

2

1

3

工时(小时/件)

1

2

2

资源总量:原材料 100 kg,工时 80 小时。市场需求上限:A ≤ 30,B ≤ 25,C ≤ 20。

products = ['产品A', '产品B', '产品C']
profits = [40, 30, 50]
materials = [2, 1, 3]
labor = [1, 2, 2]
total_material = 100
total_labor = 80

prob = pulp.LpProblem("生产计划(整数)", pulp.LpMaximize)

# 整数变量:产量必须为整数
p = pulp.LpVariable.dicts("产量", products, lowBound=0, cat='Integer')

# 目标:最大化总利润
prob += pulp.lpSum(profits[i] * p[products[i]] for i in range(len(products)))

# 约束
prob += pulp.lpSum(materials[i] * p[products[i]] for i in range(len(products))) <= total_material
prob += pulp.lpSum(labor[i] * p[products[i]] for i in range(len(products))) <= total_labor
prob += p['产品A'] <= 30
prob += p['产品B'] <= 25
prob += p['产品C'] <= 20

prob.solve(pulp.PULP_CBC_CMD(msg=0))

print(f"最大利润: {pulp.value(prob.objective):.0f}")
for i in range(len(products)):
    print(f"  {products[i]}: {int(p[products[i]].varValue)} 件")
最大利润: 2090
  产品A: 30 件
  产品B: 18 件
  产品C: 7 件

关键点:

  • 使用 Integer 变量保证产量为整数(实际生产中不可能生产半件产品)

  • 与第 1 章的连续版本对比,整数约束会略微降低目标值

  • 求解时间会比线性规划稍长(分支定界法)

ch4_applications.png

4.6 本节小结

  • 运输问题:用元组键 LpVariable.dicts 组织二维变量,供应约束 <=,需求约束 ==

  • 指派问题Binary 变量 + 双向等式约束保证一一对应

  • 背包问题Binary 选择变量 + 重量上限约束

  • 饮食问题:混合 >=<= 约束,同时考虑最小营养和最大限制

  • 生产计划Integer 变量保证离散决策,是线性规划到整数规划的过渡

第5节 高级特性概览

本节简要介绍 PuLP 的几个进阶用法,了解这些技巧可以让你的建模更加灵活。

5.1 约束的动态生成

当约束数量较多或需要根据数据动态生成时,用 for 循环遍历是最自然的方式。

排班问题示例

5 个员工排 7 天班,每天至少 2 人,每人最多上 4 天,求最小总人次。

import pulp

employees = ['员工1', '员工2', '员工3', '员工4', '员工5']
days = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
min_per_day = 2
max_days_per_employee = 4

prob = pulp.LpProblem("排班问题", pulp.LpMinimize)

# 二元变量:s[e,d] = 1 表示员工e在日d上班
s = pulp.LpVariable.dicts("排班", [(e, d) for e in employees for d in days], cat='Binary')

# 目标:最小化总上班人次
prob += pulp.lpSum(s[e, d] for e in employees for d in days)

# 约束1:每天至少 min_per_day 人上班(动态生成 7 条约束)
for d in days:
    prob += pulp.lpSum(s[e, d] for e in employees) >= min_per_day, f"{d}_最少人数"

# 约束2:每人最多上 max_days_per_employee 天(动态生成 5 条约束)
for e in employees:
    prob += pulp.lpSum(s[e, d] for d in days) <= max_days_per_employee, f"{e}_最多天数"

prob.solve(pulp.PULP_CBC_CMD(msg=0))

print(f"总上班人次: {pulp.value(prob.objective)}")
for d in days:
    workers_on_duty = [e for e in employees if s[e, d].varValue > 0.5]
    print(f"  {d}: {', '.join(workers_on_duty)}")
总上班人次: 14.0
  周一: 员工3, 员工4
  周二: 员工3, 员工4
  周三: 员工1, 员工3
  周四: 员工2, 员工5
  周五: 员工1, 员工4
  周六: 员工1, 员工4
  周日: 员工1, 员工3

关键点:

  • for 循环批量生成约束是处理大规模问题的标准做法

  • 约束标签用 f-string 嵌入变量名,方便调试时定位

  • 排班问题是整数规划的经典应用,规模大时求解时间会增长

5.2 约束的修改与移除

有时需要反复求解同一模型但修改部分约束(如灵敏度分析),可以直接操作 prob.constraints 字典。

prob = pulp.LpProblem("修改示例", pulp.LpMaximize)
x = pulp.LpVariable("x", lowBound=0)
y = pulp.LpVariable("y", lowBound=0)
prob += 3 * x + 4 * y
prob += x + y <= 10, "资源约束"
prob += x <= 6, "x上限"

prob.solve(pulp.PULP_CBC_CMD(msg=0))
print(f"原始: x={x.varValue}, y={y.varValue}, 目标={pulp.value(prob.objective)}")
# 原始: x=0.0, y=10.0, 目标=40.0

# 修改约束右端值:通过修改 constant 属性
prob.constraints['资源约束'].constant = -12  # <=10 变为 <=12
prob.solve(pulp.PULP_CBC_CMD(msg=0))
print(f"修改后: x={x.varValue}, y={y.varValue}, 目标={pulp.value(prob.objective)}")
# 修改后: x=0.0, y=12.0, 目标=48.0

# 移除约束:从字典中删除
del prob.constraints['x上限']
prob.solve(pulp.PULP_CBC_CMD(msg=0))

# 添加新约束:直接用 +=
prob += x <= 8, "新x上限"
prob.solve(pulp.PULP_CBC_CMD(msg=0))

关键点:

  • prob.constraints 是一个字典,键为约束标签

  • 修改 constant 属性等价于修改约束右端值(注意符号:<=10 对应 constant=-10

  • 删除约束用 del prob.constraints[label]

  • 添加新约束直接用 +=,与初始建模无异

5.3 多目标优化

PuLP 本身不直接支持多目标,但可以通过以下两种常用方法实现:

方法 1:加权法

将多个目标加权合并为单目标:

products = ['产品X', '产品Y']
costs = [10, 8]      # X 成本高
quality = [8, 5]     # X 质量高
resource = [2, 1]    # X 耗资源多
total_resource = 20

w_cost = 0.7      # 成本权重
w_quality = 0.3   # 质量权重
scale = 2.0       # 质量缩放因子,平衡量级

prob = pulp.LpProblem("多目标", pulp.LpMinimize)
x = pulp.LpVariable.dicts("产量", products, lowBound=0, cat='Integer')

# 最小化成本,同时最大化质量(质量前加负号)
prob += pulp.lpSum(
    w_cost * costs[i] * x[products[i]] - w_quality * scale * quality[i] * x[products[i]]
    for i in range(len(products))
)
prob += pulp.lpSum(resource[i] * x[products[i]] for i in range(len(products))) <= total_resource
prob += pulp.lpSum(x[p] for p in products) >= 1  # 避免零解

prob.solve(pulp.PULP_CBC_CMD(msg=0))

不同权重对比:

权重比(成本/质量)

产品 X

产品 Y

总成本

总质量

0.9 / 0.1

0

1

8

5

0.7 / 0.3

1

0

10

8

0.5 / 0.5

10

0

100

80

0.3 / 0.7

0

20

160

100

关键点:

  • 权重变化会显著改变最优解,需要根据实际需求调整

  • scale 因子用于平衡不同目标的量级

  • 需要加最小产量约束避免零解

方法 2:ε-约束法

一个目标为主目标,另一个设为约束下限:

# 最小化成本,同时要求质量 >= 某值
for min_q in [10, 30, 50, 70]:
    prob = pulp.LpProblem(f"eps_{min_q}", pulp.LpMinimize)
    x = pulp.LpVariable.dicts("产量", products, lowBound=0, cat='Integer')
    prob += pulp.lpSum(costs[i] * x[products[i]] for i in range(len(products)))  # 主目标
    prob += pulp.lpSum(resource[i] * x[products[i]] for i in range(len(products))) <= total_resource
    prob += pulp.lpSum(quality[i] * x[products[i]] for i in range(len(products))) >= min_q  # ε-约束
    prob.solve(pulp.PULP_CBC_CMD(msg=0))
质量>=10: 成本=16.0, 实际质量=10.0, X=0, Y=2
质量>=30: 成本=40.0, 实际质量=32.0, X=4, Y=0
质量>=50: 成本=66.0, 实际质量=50.0, X=5, Y=2
质量>=70: 成本=90.0, 实际质量=72.0, X=9, Y=0

关键点:

  • ε-约束法更直观:直接指定质量下限,然后最小化成本

  • 逐步调整 ε 值可以得到 Pareto 前沿

  • 如果 ε 设置过高可能导致不可行

5.4 约束灵敏度分析

灵敏度分析用于研究约束右端值变化对目标函数的影响。

# 逐步改变约束右端值
rhs_values = [8, 9, 10, 11, 12, 13, 14, 15]
for rhs in rhs_values:
    prob = pulp.LpProblem(f"sens_{rhs}", pulp.LpMaximize)
    x = pulp.LpVariable("x", lowBound=0)
    y = pulp.LpVariable("y", lowBound=0)
    prob += 5 * x + 3 * y
    prob += 2 * x + y <= rhs     # 变化的约束
    prob += x + 2 * y <= 8
    prob.solve(pulp.PULP_CBC_CMD(msg=0))
    print(f"右端={rhs}: 目标值={pulp.value(prob.objective):.1f}")
右端=8: 目标值=21.3
右端=9: 目标值=23.7
右端=10: 目标值=26.0
右端=11: 目标值=28.3
右端=12: 目标值=30.7
右端=13: 目标值=33.0
右端=14: 目标值=35.3
右端=15: 目标值=37.7

关键点:

  • 目标值随右端值的变化率等于影子价格(本例约 2.33)

  • 在影子价格的有效范围内,目标值呈线性变化

  • 超出有效范围后,影子价格会发生变化(最优基改变)

ch5_advanced.png

5.5 本节小结

  • 动态生成约束:用 for 循环遍历数据批量添加约束,是处理大规模问题的标准做法

  • 约束修改:通过 prob.constraints 字典可以修改、删除、添加约束,适合灵敏度分析和迭代求解

  • 多目标优化:PuLP 不直接支持多目标,可用加权法或 ε-约束法间接实现

  • 灵敏度分析:逐步改变约束右端值,观察目标函数变化,可验证影子价格的有效性

附录:完整代码获取

本教程所有代码均可通过以下链接下载:

pulp.zip

评论