分享

双11购物的凑单问题与财务凑数问题

 小小明代码实体 2022-12-25 发布于广东


📢作者: 小小明-代码实体

📢博客主页:https://blog.csdn.net/as604049322

📢欢迎点赞 👍 收藏 ⭐留言 📝 欢迎讨论!

📢本文链接:https://xxmdmst.blog.csdn.net/article/details/128437285

凑单问题

对于各类凑单问题,最经典的就是淘宝双十一的满减促销活动,比如“满 200 元减 50 元”。假设你的购物车中有 n 个(n>100)想买的商品,希望从里面选几个,在凑够满减条件的前提下,让选出来的商品价格总和最大程度地接近满减条件(200 元),如何编程解决这个问题?

动态规划解决

使用传统的编程思路就是使用动态规划,思路如下:

购物车中有 n 个商品,针对每个商品都决策是否购买。每次决策之后,对应不同的状态集合。用一个二维数组 s t a t e s [ n ] [ x ] states[n][x] states[n][x],来记录每次决策之后所有可达的状态。

python实现代码为:

def double11advance(items_info: list, w: int):
    """
    动态规划解决双11凑单问题
    :param items_info: 每个商品价格
    :param w: 满减条件,比如 200
    :return:
    """
    n = len(items_info)
    # 超过 3 倍就没有薅羊毛的价值了
    states = [[False] * (3 * w + 1) for i in range(n)]
    states[0][0] = True
    states[0][items_info[0]] = True
    for i in range(1, n):
        for j in range(3 * w + 1):
            if states[i - 1][j]:
                # 不购买第i个商品
                states[i][j] = states[i - 1][j]
                # 购买第i个商品
                nw = j + items_info[i]
                if nw <= 3 * w:
                    states[i][nw] = True
    j = w
    while j < 3 * w + 1 and not states[n - 1][j]:
        j += 1
    # j是大于等于 w 的最小值
    if j == 3 * w + 1:
        return  # 没有可行解
    idx = []
    for i in range(n - 1, 0, -1):
        if j - items_info[i] >= 0 and states[i - 1][j - items_info[i]]:
            idx.append(i)
            j -= items_info[i]
    if j != 0:
        idx.append(0)
    return sorted(idx)

假设,我们的购物车中每件商品的价格为:

48, 30, 19, 36, 36, 27, 42, 42, 36, 24,  40, 70, 32

我们执行代码:

import numpy as np
items_info = np.array([48, 30, 19, 36, 36, 27, 42, 42, 36, 24,  40, 70, 32])

idx = double11advance(items_info, 200)
print("选中商品的索引:", idx)
print("选中商品的价格:", items_info[idx])
print("总价格:", sum(items_info[idx]))
选中商品的索引: [1, 4, 7, 8, 9, 12]
选中商品的价格: [30 36 42 36 24 32]
总价格: 200

可以看到程序完美的找到了一组可行解。

除了动态规划,我们还可以使用回溯算法解决,参考代码就不公布了,接下来我们直接使用优化算法解决这个问题。

优化算法解决

在前面的文章《OR-Tools官档中文用法大全(CP、LP、VRP、Flows等)》中的 背包与装箱问题 一章中,我演示了使用SCIP求解器解决该问题。

不过SCIP求解器速度较慢,而且想获取多个可行解实现起来较为麻烦,所以这里我演示使用ortools的cp_model求解器来解决该问题。

cp_model求解器相对于前面的SCIP求解器的缺点在于只能处理整数。

代码如下:

from ortools.sat.python import cp_model
import numpy as np

model = cp_model.CpModel()
items_info = np.array([48, 30, 19, 36, 36, 27, 42, 42, 36, 24,  40, 70, 32])
items = np.arange(items_info.shape[0])
x = [model.NewBoolVar(f'x_{i}') for i in range(len(items_info))]
obj = (x*items_info).sum()
model.Add(obj >=200)
model.Minimize(obj)
solver = cp_model.CpSolver()
status = solver.Solve(model)
result = [bool(solver.Value(i)) for i in x]
print("选中商品的索引:", items[result])
print("选中商品的价格:", items_info[result])
print("总价格:", items_info[result].sum())
选中商品的索引: [ 1  4  7  8  9 12]
选中商品的价格: [30 36 42 36 24 32]
总价格: 200

可以看到 ortools 库得到了与前面动态规划一致的结果。

ortools获取多个可行解

下面我们考虑使用cp_model求解器获取多个可行解,前面我们已经可行解的最小值为200,下面我们可以限制总价格等于200:

from ortools.sat.python import cp_model
import numpy as np


class MyCpSolver(cp_model.CpSolverSolutionCallback):
    def __init__(self, x):
        cp_model.CpSolverSolutionCallback.__init__(self)
        self.x = x
        self.num = 0

    def on_solution_callback(self):
        self.num += 1
        print(f"第{self.num}个解")
        result = [bool(self.Value(i)) for i in self.x]
        print("选中商品的索引:", items[result])
        print("选中商品的价格:", items_info[result])
        print("总价格:", items_info[result].sum())


model = cp_model.CpModel()
items_info = np.array([48, 30, 19, 36, 36, 27, 42, 42, 36, 24,  40, 70, 32])
items = np.arange(items_info.shape[0])
x = [model.NewBoolVar(f'x_{i}') for i in range(len(items_info))]
obj = (x*items_info).sum()
model.Add(obj == 200)
solver = cp_model.CpSolver()
myCpSolver = MyCpSolver(x)
solver.parameters.enumerate_all_solutions = True
status = solver.Solve(model, myCpSolver)
print(solver.StatusName(status))
print("解的个数:", myCpSolver.num)

最终得到了30个可行解:

image-20221225161243648

如此多的可行解是因为36出现了三次,导致可行解的个数也被翻了3倍,实际可行解就只有10个。下面我们改进一下上面代码,让其获取唯一的可行解:

from collections import Counter
from ortools.sat.python import cp_model
import numpy as np


class MyCpSolver(cp_model.CpSolverSolutionCallback):
    def __init__(self, x):
        cp_model.CpSolverSolutionCallback.__init__(self)
        self.x = x
        self.num = 0

    def on_solution_callback(self):
        self.num += 1
        print(f"第{self.num}个解")
        idx = []
        result = []
        for i, xi in enumerate(self.x):
            v = self.Value(xi)
            if v == 0:
                continue
            idx.append(i)
            result.extend([items_info[i]]*v)
        print("选中商品的索引:", idx)
        print("选中商品的价格:", result)
        print("总价格:", sum(result))


arr = [48, 30, 19, 36, 36, 27, 42, 42, 36, 24,  40, 70, 32]

model = cp_model.CpModel()
items_info = []
x = []
for i, (k, v) in enumerate(Counter(arr).items(), 1):
    items_info.append(k)
    x.append(model.NewIntVar(0, v, f"x{i}"))
items_info = np.array(items_info)
obj = (items_info*x).sum()
model.Add(obj == 200)
solver = cp_model.CpSolver()
myCpSolver = MyCpSolver(x)
solver.parameters.enumerate_all_solutions = True
status = solver.Solve(model, myCpSolver)
print(solver.StatusName(status))
print("解的个数:", myCpSolver.num)
第1个解
选中商品的索引: [1, 2, 4, 5, 7]
选中商品的价格: [30, 19, 27, 42, 42, 40]
总价格: 200
第2个解
选中商品的索引: [2, 3, 4, 5, 7]
选中商品的价格: [19, 36, 36, 27, 42, 40]
总价格: 200
第3个解
选中商品的索引: [2, 4, 5, 8]
选中商品的价格: [19, 27, 42, 42, 70]
总价格: 200
第4个解
选中商品的索引: [0, 2, 3, 4, 8]
选中商品的价格: [48, 19, 36, 27, 70]
总价格: 200
第5个解
选中商品的索引: [0, 2, 4, 5, 6, 7]
选中商品的价格: [48, 19, 27, 42, 24, 40]
总价格: 200
第6个解
选中商品的索引: [0, 1, 2, 3, 4, 7]
选中商品的价格: [48, 30, 19, 36, 27, 40]
总价格: 200
第7个解
选中商品的索引: [0, 5, 7, 8]
选中商品的价格: [48, 42, 40, 70]
总价格: 200
第8个解
选中商品的索引: [1, 3, 6, 7, 8]
选中商品的价格: [30, 36, 24, 40, 70]
总价格: 200
第9个解
选中商品的索引: [1, 3, 5, 6, 9]
选中商品的价格: [30, 36, 36, 42, 24, 32]
总价格: 200
第10个解
选中商品的索引: [0, 3, 5, 9]
选中商品的价格: [48, 36, 42, 42, 32]
总价格: 200
OPTIMAL
解的个数: 10

可以看到顺利的获取到唯一解。

财务凑数问题

财务凑数问题与前面的问题模型一致,区别在于存在小数,例如从一大批金额中找出能够合并出指定金额的组合。

假设我们要查找的金额列表如下:

7750.0, 50000.0, 94693.0, 89159.18, 59000.0, 19634.94, 27000.0, 37770.17, 55631.64, 23800.0,
20000.0, 20000.0, 20000.0, 72985.45, 48000.0, 48000.0, 58750.0, 22000.0, 11219.61, 45600.0,
90500.0, 84288.0, 930.0, 1352.0, 120.0, 750.0, 22880.0, 45678.0, 49555.0, 17181.54, 1925.0,
1500.0, 83325.0, 500.0, 1298.5, 36936.34, 91933.67, 5205.0, 20195.0, 20550.0, 10600.0, 3200.0,
6400.0, 6900.0, 9900.0, 9750.0, 9600.0, 7200.0, 15208.41, 10550.0, 21077.02, 75437.51, 73515.11,
3140.0, 85128.6, 87095.74, 22806.24, 961.72, 13285.47, 28980.0, 67997.62, 35955.33, 12890.27, 15459.47,
20124.58, 25246.66, 13216.11, 89400.0, 89400.0, 26800.0, 11365.0, 16457.0, 50000.0, 54309.0, 12000.0,
39000.0, 70569.5, 45231.5, 56400.0, 86400.0, 86400.0, 86400.0, 86400.0, 12000.0, 390.0, 2500.0, 38109.79,
5968.63, 14862.6, 45038.91, 63189.17, 80784.86, 37664.87, 4981.44, 50000.0, 50000.0, 32323.01, 567.73, 66056.88, 26400.0

我们需要找到95984的组合。

SCIP求解器直接计算

如果使用SCIP求解器可以直接计算结果,编码如下:

from ortools.linear_solver import pywraplp
import numpy as np

arr = [7750.0, 50000.0, 94693.0, 89159.18, 59000.0, 19634.94, 27000.0, 37770.17, 55631.64, 23800.0,
       20000.0, 20000.0, 20000.0, 72985.45, 48000.0, 48000.0, 58750.0, 22000.0, 11219.61, 45600.0,
       90500.0, 84288.0, 930.0, 1352.0, 120.0, 750.0, 22880.0, 45678.0, 49555.0, 17181.54, 1925.0,
       1500.0, 83325.0, 500.0, 1298.5, 36936.34, 91933.67, 5205.0, 20195.0, 20550.0, 10600.0, 3200.0,
       6400.0, 6900.0, 9900.0, 9750.0, 9600.0, 7200.0, 15208.41, 10550.0, 21077.02, 75437.51, 73515.11,
       3140.0, 85128.6, 87095.74, 22806.24, 961.72, 13285.47, 28980.0, 67997.62, 35955.33, 12890.27, 15459.47,
       20124.58, 25246.66, 13216.11, 89400.0, 89400.0, 26800.0, 11365.0, 16457.0, 50000.0, 54309.0, 12000.0,
       39000.0, 70569.5, 45231.5, 56400.0, 86400.0, 86400.0, 86400.0, 86400.0, 12000.0, 390.0, 2500.0, 38109.79,
       5968.63, 14862.6, 45038.91, 63189.17, 80784.86, 37664.87, 4981.44, 50000.0, 50000.0, 32323.01, 567.73, 66056.88, 26400.0]

items_info = np.array(arr)
items = np.arange(items_info.shape[0])
solver = pywraplp.Solver.CreateSolver('SCIP')
x = [solver.BoolVar(f'x_{i}') for i in range(len(items_info))]
obj = (x*items_info).sum()
solver.Add(obj >= 95984)
solver.Minimize(obj)
status = solver.Solve()
print("总重量:", obj.solution_value())
result = np.frompyfunc(lambda x: x.solution_value(), 1, 1)(x).astype(bool)
print("选中商品的索引:", items[result])
print("选中商品的价值:", items_info[result])
print("总价值:", items_info[result].sum())
总重量: 95984.3
选中商品的索引: [22 24 33 34 38 40 41 44 58 61]
选中商品的价值: [  930.     120.     500.    1298.5  20195.   10600.    3200.    9900.
 13285.47 35955.33]
总价值: 95984.3

不过这并不是真正的最优解,如果我们把约束设置为必须为目标值:

solver = pywraplp.Solver.CreateSolver('SCIP')
x = [solver.BoolVar(f'x_{i}') for i in range(len(items_info))]
obj = (x*items_info).sum()
solver.Add(obj == 95984)
status = solver.Solve()
print("总重量:", obj.solution_value())
result = np.frompyfunc(lambda x: x.solution_value(), 1, 1)(x).astype(bool)
print("选中商品的索引:", items[result])
print("选中商品的价值:", items_info[result])
print("总价值:", items_info[result].sum())
总重量: 95984.0
选中商品的索引: [ 5 18 25 30 38 39 43 45 53 57 84 97]
选中商品的价值: [19634.94 11219.61   750.    1925.   20195.   20550.    6900.    9750.
  3140.     961.72   390.     567.73]
总价值: 95984.0

可惜耗时接近10秒。

cp_model求解器

cp_model求解器只能处理整数,为了能够处理小数,我们可以将其乘以100后转换为整数:

from ortools.sat.python import cp_model
import numpy as np

arr = [7750.0, 50000.0, 94693.0, 89159.18, 59000.0, 19634.94, 27000.0, 37770.17, 55631.64, 23800.0,
       20000.0, 20000.0, 20000.0, 72985.45, 48000.0, 48000.0, 58750.0, 22000.0, 11219.61, 45600.0,
       90500.0, 84288.0, 930.0, 1352.0, 120.0, 750.0, 22880.0, 45678.0, 49555.0, 17181.54, 1925.0,
       1500.0, 83325.0, 500.0, 1298.5, 36936.34, 91933.67, 5205.0, 20195.0, 20550.0, 10600.0, 3200.0,
       6400.0, 6900.0, 9900.0, 9750.0, 9600.0, 7200.0, 15208.41, 10550.0, 21077.02, 75437.51, 73515.11,
       3140.0, 85128.6, 87095.74, 22806.24, 961.72, 13285.47, 28980.0, 67997.62, 35955.33, 12890.27, 15459.47,
       20124.58, 25246.66, 13216.11, 89400.0, 89400.0, 26800.0, 11365.0, 16457.0, 50000.0, 54309.0, 12000.0,
       39000.0, 70569.5, 45231.5, 56400.0, 86400.0, 86400.0, 86400.0, 86400.0, 12000.0, 390.0, 2500.0, 38109.79,
       5968.63, 14862.6, 45038.91, 63189.17, 80784.86, 37664.87, 4981.44, 50000.0, 50000.0, 32323.01, 567.73, 66056.88, 26400.0]
model = cp_model.CpModel()
items_info = (np.array(arr)*100).astype(int)
items = np.arange(items_info.shape[0])
x = [model.NewBoolVar(f'x_{i}') for i in range(len(items_info))]
obj = (x*items_info).sum()
model.Add(obj == 95984*100)
solver = cp_model.CpSolver()
status = solver.Solve(model)
print(solver.StatusName(status))
result = [bool(solver.Value(i)) for i in x]
print("选中商品的索引:", items[result])
print("选中商品的价格:", items_info[result]/100)
print("总价格:", items_info[result].sum()/100)
OPTIMAL
选中商品的索引: [ 0 23 24 41 42 47 53 70 71 75]
选中商品的价格: [ 7750.  1352.   120.  3200.  6400.  7200.  3140. 11365. 16457. 39000.]
总价格: 95984.0

获取多个可行解

可以看到财务的金额数据存在大量重复,所以必须先进行计数处理,最终代码为:

from collections import Counter
from ortools.sat.python import cp_model
import numpy as np


class MyCpSolver(cp_model.CpSolverSolutionCallback):
    def __init__(self, x):
        cp_model.CpSolverSolutionCallback.__init__(self)
        self.x = x
        self.num = 0

    def on_solution_callback(self):
        self.num += 1
        print(f"第{self.num}个解")
        idx = []
        result = []
        for i, xi in enumerate(self.x):
            v = self.Value(xi)
            if v == 0:
                continue
            idx.append(i)
            result.extend([items_info[i]/100]*v)
        print("选中商品的索引:", idx)
        print("选中商品的价格:", result)
        print("总价格:", sum(result))


arr = [7750.0, 50000.0, 94693.0, 89159.18, 59000.0, 19634.94, 27000.0, 37770.17, 55631.64, 23800.0,
       20000.0, 20000.0, 20000.0, 72985.45, 48000.0, 48000.0, 58750.0, 22000.0, 11219.61, 45600.0,
       90500.0, 84288.0, 930.0, 1352.0, 120.0, 750.0, 22880.0, 45678.0, 49555.0, 17181.54, 1925.0,
       1500.0, 83325.0, 500.0, 1298.5, 36936.34, 91933.67, 5205.0, 20195.0, 20550.0, 10600.0, 3200.0,
       6400.0, 6900.0, 9900.0, 9750.0, 9600.0, 7200.0, 15208.41, 10550.0, 21077.02, 75437.51, 73515.11,
       3140.0, 85128.6, 87095.74, 22806.24, 961.72, 13285.47, 28980.0, 67997.62, 35955.33, 12890.27, 15459.47,
       20124.58, 25246.66, 13216.11, 89400.0, 89400.0, 26800.0, 11365.0, 16457.0, 50000.0, 54309.0, 12000.0,
       39000.0, 70569.5, 45231.5, 56400.0, 86400.0, 86400.0, 86400.0, 86400.0, 12000.0, 390.0, 2500.0, 38109.79,
       5968.63, 14862.6, 45038.91, 63189.17, 80784.86, 37664.87, 4981.44, 50000.0, 50000.0, 32323.01, 567.73, 66056.88, 26400.0]

model = cp_model.CpModel()
items_info = []
x = []
for i, (k, v) in enumerate(Counter(arr).items(), 1):
    items_info.append(k)
    x.append(model.NewIntVar(0, v, f"x{i}"))
items_info = (np.array(items_info)*100).astype(int)
obj = (items_info*x).sum()
model.Add(obj == 95984*100)
solver = cp_model.CpSolver()
myCpSolver = MyCpSolver(x)
solver.parameters.enumerate_all_solutions = True
status = solver.Solve(model, myCpSolver)
print(solver.StatusName(status))
print("解的个数:", myCpSolver.num)

最终再经过一小时的等待后,并未找出全部的可行解,程序还在运行中,1小时找到一千多个可行解:

image-20221225185130100

为了避免计算时间过长,我们可以设置最大执行时间,例如设置30秒:

solver.parameters.max_time_in_seconds = 30

可以看到30秒内能够找到45个解:

image-20221225185604004

    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多