CP-SAT 简介:用 SAT 的技术做约束编程#
这一章讲什么#
OR-Tools 的 CP-SAT 是 Google 开发的约束编程求解器,它与 Z3 那几章一样,都是把条件写下来让程序去找解的工具,只是它更擅长最优化,例如让利润最大、让距离最短。
这一章看四件事。
CP-SAT 的求解机制(SAT 的技术加上线性松弛)
最基本的用法(
CpModel/new_int_var/add/solve)线性规划的例题(利润最大化)
用
assumptions推理一道逻辑谜题
四件事按顺序展开:先看机制,再看用法,最后用两道题演示。
CP-SAT 是什么#
CP-SAT 是以 SAT(可满足性问题)的技术为基础的约束编程求解器,其做法是把约束转换成 SAT 求解器能够处理的形式。它同时具备两方面的能力,一是搜索,二是优化。搜索负责在可行解之间移动,优化负责在这些解之间比较优劣。
搜索由 SAT 求解器中的那套 CDCL(遇到冲突就学习并剪枝)与 LCG(Lazy Clause Generation,惰性子句生成)技术推进
CP-SAT 还把线性松弛组合了进来,因此也能像 MIP(混合整数规划)求解器那样求目标函数的最大值或最小值
OR-Tools 里早就有 constraint_solver,那是经典的 CP 求解器,以深度优先搜索为主,最优化不是它的强项,因此现在使用 OR-Tools 时应当选 CP-SAT,这是官方推荐的用法。本书的 OrTools 章节一律使用 CP-SAT,不再涉及旧的接口。两者的接口与建模方式都不同,混用会带来额外的工作。
例题:SEND + MORE = MONEY#
这道字母算术在 Z3 那一章已经出现过,这里换用 CP-SAT 再解一次,基本用法的完整流程都包含在这一段代码里。同一个问题换一个求解器,写法的差异也就显现出来。
from ortools.sat.python import cp_model
model = cp_model.CpModel()
s, e, n, d, m, o, r, y = [model.new_int_var(0, 10, name) for name in "sendmory"]
model.add(s != 0)
model.add(m != 0)
model.add_all_different([s, e, n, d, m, o, r, y])
send = s * 1000 + e * 100 + n * 10 + d
more = m * 1000 + o * 100 + r * 10 + e
money = m * 10000 + o * 1000 + n * 100 + e * 10 + y
model.add(send + more == money)
solver = cp_model.CpSolver()
solver.Solve(model)
print(f"{solver.value(send)} + {solver.value(more)} = {solver.value(money)}")
9567 + 1085 = 10652
这段代码在做什么#
下面按调用顺序逐行说明,六个方法构成了 CP-SAT 的基本用法。
CpModel()… 建立一个模型,也就是装约束的容器model.new_int_var(下限, 上限, 名字)… 声明一个整数变量model.add(条件)… 添加约束,!=、==、>=这类比较式原样写就行model.add_all_different([...])… 要求这些变量取值互不相同solver.solve(model)… 交给求解器去求解solver.value(式)… 取出表达式的值
六个方法之外,表达式的写法与 Python 的算术一致,只是对象换成了变量。因此照抄原来的式子通常就能用,遇到不支持的运算再改写成约束。
用 * 和 + 就能构造表达式,因此像 s * 1000 + e * 100 + ... 这样把数位照抄下来即可。要写加权和,也可以用 LinearExpr.weighted_sum(),它把每个变量的系数单独列出来。两种写法得到的表达式相同,选哪一种只取决于可读性。
send = cp_model.LinearExpr.weighted_sum([s, e, n, d], [1000, 100, 10, 1])
more = cp_model.LinearExpr.weighted_sum([m, o, r, e], [1000, 100, 10, 1])
money = cp_model.LinearExpr.weighted_sum([m, o, n, e, y], [10000, 1000, 100, 10, 1])
线性规划#
下面用一道利润最大化的题目演示线性规划,先看标准例子。
要生产产品 A 和 B,需要三种原料 a、b、c,每生产 1 单位所需的原料用量与库存列在下表中;每卖出 1 单位,A 获利 3 万日元,B 获利 2 万日元,请决定使利润最大的生产量。
原料 |
产品 A |
产品 B |
库存 |
|---|---|---|---|
a |
3 |
1 |
9 |
b |
2.5 |
2 |
12.5 |
c |
1 |
2 |
8 |
设生产量分别为 \(x_1\) 和 \(x_2\),这道题就可以写成下面这样。
目标函数
约束条件
CP-SAT 只处理整数,因此表达式中出现 2.5 时,把两边同时乘 2,把它消掉(2.5x1 + 2x2 <= 12.5 → 5x1 + 4x2 <= 25),不等式两边同乘一个正数,条件的含义不变。系数放大之后,求出的 \(x_1, x_2\) 仍是原问题的解,不必再缩小回去。
model.maximize(z) 的含义就是去找使 \(z = 3x_1 + 2x_2\) 取最大值的 \(x_1, x_2\)。minimize() 与之对称,改成求最小值即可。
model = cp_model.CpModel()
x1, x2 = [model.new_int_var(0, 10000, f"x{i}") for i in range(1, 3)]
model.add(3 * x1 + x2 <= 9)
model.add(5 * x1 + 4 * x2 <= 25)
model.add(x1 + 2 * x2 <= 8)
z = 3 * x1 + 2 * x2
model.maximize(z)
solver = cp_model.CpSolver()
solver.Solve(model)
print(f"x1 = {solver.value(x1)}, x2 = {solver.value(x2)}, z = {solver.value(z)}")
x1 = 2, x2 = 3, z = 12
逻辑谜题#
有红、蓝、黄、绿四个箱子,其中若干个箱子里放着苹果(至少有一个,两个、三个也可以)。规则是可以从这些箱子中挑一个,把里面的苹果拿走。
请根据下面的两条线索,选出一定装着苹果的那只箱子。
红箱子里有苹果的话,蓝箱子里也有苹果
绿箱子里没有苹果的话,黄箱子和蓝箱子里都没有苹果
在 CP-SAT 中,用布尔变量加逻辑约束就能把这两句话原样写下来,剩下的推理交给求解器。线索只有两条,组合起来却足以定位到具体的那一只箱子。变量只有四个,穷举也能做完,这里要看的是把条件写成约束的方式。
model = cp_model.CpModel()
red, blue, yellow, green = [model.new_bool_var(name) for name in ['red', 'blue', 'yellow', 'green']]
model.add_bool_or([red, blue, yellow, green]) # 至少有 1 个
model.add_implication(red, blue) # 红 → 蓝
model.add_bool_and(~yellow, ~blue).only_enforce_if(~green) # 没有绿 → 黄和蓝也没有
solver = cp_model.CpSolver()
for box in [red, blue, yellow, green]:
model.add_assumption(~box) # 假设“这个箱子里没有”
res = solver.solve(model)
if res == cp_model.INFEASIBLE: # 矛盾 → 一定放着
print(box)
model.clear_assumptions() # 删掉假设
green
这几行代码在做什么#
new_bool_var()… 建立一个 0/1 的布尔变量add_bool_or([...])… 至少有一个为真(逻辑或)add_implication(a, b)…a成立就要求b(逻辑蕴含)add_bool_and(a, b).only_enforce_if(c)… 只在c成立时要求“a且b”~表示取反only_enforce_if()指定“只在这个条件成立时才有效”
这道谜题要的不是随便找到一个解,而是推理出哪个箱子一定装着苹果。因此对每个箱子,先假设“里面没有”再去求解,如果出现矛盾(INFEASIBLE),就说明这个箱子里一定有苹果。
用
model.add_assumption(条件)添加假设用
clear_assumptions()清除假设(试下一个箱子之前必须调用)
假设会留在模型里;如果漏掉清除,下一个箱子就会带上上一个箱子的条件。
结果落在 green 上。其中的道理是:如果没有绿箱,黄箱和蓝箱也就都没有;再由红→蓝这条线索,红箱也不能有,结果一个苹果都不剩。逐个假设、逐个排除,这个过程与手算一致,只是由求解器完成。
小结#
CP-SAT 靠两部分工作:SAT 技术与线性松弛,用 OR-Tools 就选它
基本流程是
CpModel()→ 变量 →add()→solve()→value()要做最优化,用
model.maximize(式)/model.minimize(式)CP-SAT 只处理整数;表达式里有小数时,两边同乘一个整数把它消掉
用布尔变量配逻辑约束(
add_bool_or、add_implication、only_enforce_if),条件分支也写得出来用
assumptions可以询问“再加上这个条件会不会矛盾”,推理谜题就靠它
下一章将详细介绍 CP-SAT 各种 API 函数的用法。