减少订单所需仓库数量

3

我正在尝试找出一种高效解决以下问题的算法:

  • w个仓库,它们存储着p种不同数量的产品;
  • 顾客下了一个n个产品的订单。

目标是选择最少的仓库来分配订单

例如:三个仓库的库存分布如下:

                |   Product 1   |   Product 2   |   Product 3   |
|---------------|---------------|---------------|---------------|
| Warehouse 1   |       2       |       5       |       0       |
| Warehouse 2   |       1       |       4       |       4       |
| Warehouse 3   |       3       |       1       |       4       |

现在假设有一份订单,其中包含以下数量:

                |   Product 1   |   Product 2   |   Product 3   |
|---------------|---------------|---------------|---------------|
| Ordered Qty   |       5       |       4       |       1       |

最优解是从仓库1仓库3分配订单。在3个仓库的任何其他更小的子集中都不会更好。

我已经尝试使用暴力方法来解决这个问题,但是对于更多的仓库,算法的性能非常差。我也尝试了一些贪心分配算法,但是如预期的那样,它们无法在许多情况下最小化子订单的数量。是否有其他算法/方法我应该考虑?


2
如果您能高效地解决这个问题,那么您也可以高效地解决 https://en.wikipedia.org/wiki/Set_cover_problem。 (只需考虑所有数量为1或0的特殊情况。)由于那个问题是NP完全问题,因此这个问题应该很难。 - btilly
2
这可以设置为一个相当简单的整数线性规划(ILP),肯定比蛮力更易于管理。 - AirSquid
这是正确的,但对于整数线性规划(ILPs)问题,解决时间取决于参数,相当不可预测。我怀疑在处理几十个仓库和数百种产品时是否实用。 - Cary Swoveland
@CarySwoveland,我不太确定您所说的“可预测”是什么意思。通常更关心解决时间的数量级与答案紧急程度的关系。是否是1秒,1分钟,1小时,还是1天?如果能在几分钟内(达到最优或在容忍误差范围内)解决仓储问题,那似乎就很好了。如果存在某些退化问题,则求解时间可能会有所不同,这是显而易见的。 - AirSquid
@AirSquid,我表达不够清楚。通常情况下,根据变量和约束条件的数量以及使用的软件,人们可以大致估计解决LP问题需要多长时间。相比之下,对于同样的ILP参数,软件可能会在很短的时间内找到最优解,也可能需要数小时或数天才能找到最优解;很难估计解决给定ILP问题需要多长时间。当然,这绝不是不尝试使用ILP公式的理由。 - Cary Swoveland
显示剩余2条评论
2个回答

2
第1部分(另见下面的第2部分)
您的任务看起来像是NP完全问题的Set Cover Problem,因此解决时间呈指数级增长。
我决定(并在C++中实现)自己的解决方案,可能在一种情况下次指数级 - 如果许多仓库子集产生相同数量的产品。换句话说,如果所有仓库子集的指数大小(即2 ^ NumWarehouses)远大于所有仓库子集产生的所有产品计数的所有可能组合的集合。在像您这样的在线竞赛的大多数测试中,这种情况经常发生。如果发生这种情况,那么我的解决方案将在CPU和RAM方面都是亚指数级的。
我使用了Dynamic Programming方法。整个算法可以描述如下:
我们创建一个映射,将每种产品的数量向量作为键,并指向一个三元组:a)前面取到当前产品数量的仓库集合,这是为了恢复精确选择的仓库;b)需要取的最小仓库数量;c)之前取到达此最小仓库数量的仓库。该集合初始化为单个键-0产品向量(0, 0,...,0)。
通过循环遍历所有仓库并执行3.-4.。
迭代映射中的所有当前产品数量(向量)并执行4。
对于迭代的产品向量(在映射中),我们添加迭代仓库的产品数量。这两个向量的总和是映射中的新键,在该键指向的值内,我们将索引添加到集合中,该集合表示已使用的仓库,同时将所需的最小仓库数量和之前使用的仓库设置为-1(未初始化)。
对于映射的每个键,使用递归函数找到所需的最小仓库数量,并找到实现此最小值的先前仓库。如果要给定键迭代Set中的所有仓库,并找到它们的最小值,则可以轻松地完成此操作,然后当前键的最小值将是所有最小值加1的最小值。
遍历映射中所有大于或等于(作为向量)订购产品数量的键。所有这些键都会给出解决方案,但只有其中一些会给出最小解决方案,保存给出所有最小解决方案的键。如果映射中的所有键都小于当前订购向量,则没有可能的解决方案,我们可以使用错误完成程序。
有了最小键,我们将所有已使用的仓库的路径恢复到达到此最小值。这很容易,因为对于映射中的每个键,我们保留最小仓库数量和应该取的先前仓库以实现此最小值。通过“上一个”仓库跳转,我们恢复所需仓库的整个路径。最后输出此找到的最小解决方案。
如上所述,该算法的内存和时间复杂度等于所有仓库子集中可以形成的不同向量乘积的数量。这可能是次指数级的(如果我们很幸运)或者不是次指数级的(如果我们不幸)。
下面是实现上述算法的完整C++代码(由我从头开始实现): 在线尝试!
#include <cstdint>
#include <vector>
#include <tuple>
#include <map>
#include <set>
#include <unordered_map>
#include <functional>
#include <stdexcept>
#include <iostream>
#include <algorithm>

#define ASSERT(cond) { if (!(cond)) throw std::runtime_error("Assertion (" #cond ") failed at line " + std::to_string(__LINE__) + "!"); }
#define LN { std::cout << "LN " << __LINE__ << std::endl; }

using u16 = uint16_t;
using u32 = uint32_t;
using u64 = uint64_t;

int main() {
    std::vector<std::vector<u32>> warehouses_products = {
        {2, 5, 0},
        {1, 4, 4},
        {3, 1, 4},
    };
    std::vector<u32> order_products = {5, 4, 1};

    size_t const nwares = warehouses_products.size(),
        nprods = warehouses_products.at(0).size();

    ASSERT(order_products.size() == nprods);
    
    std::map<std::vector<u32>, std::tuple<std::set<u16>, u16, u16>> d =
        {{std::vector<u32>(nprods), {{}, 0, u16(-1)}}};

    for (u16 iware = 0; iware < nwares; ++iware) {
        auto const & wprods = warehouses_products[iware];
        ASSERT(wprods.size() == nprods);
        auto dc = d;
        for (auto const & [k, _]: d) {
            auto prods = k;
            for (size_t i = 0; i < wprods.size(); ++i)
                prods[i] += wprods[i];
            dc.insert({prods, {{}, u16(-1), u16(-1)}});
            std::get<0>(dc[prods]).insert(iware);
        }
        d = dc;
    }

    std::function<u16(std::vector<u32> const &)> FindMin =
        [&](auto const & prods) {
            auto & [a, b, c] = d.at(prods);
            if (b != u16(-1))
                return b;
            u16 minv = u16(-1), minw = u16(-1);
            for (auto iware: a) {
                auto const & wprods = warehouses_products[iware];
                auto cprods = prods;
                for (size_t i = 0; i < wprods.size(); ++i)
                    cprods[i] -= wprods[i];
                auto const fmin = FindMin(cprods) + 1;
                if (fmin < minv) {
                    minv = fmin;
                    minw = iware;
                }
            }
            ASSERT(minv != u16(-1) && minw != u16(-1));
            b = minv;
            c = minw;
            return b;
        };

    for (auto const & [k, v]: d)
        FindMin(k);

    std::vector<u32> minp;
    u16 minv = u16(-1);
    for (auto const & [k, v]: d) {
        bool matched = true;
        for (size_t i = 0; i < nprods; ++i)
            if (order_products[i] > k[i]) {
                matched = false;
                break;
            }
        if (!matched)
            continue;
        if (std::get<1>(v) < minv) {
            minv = std::get<1>(v);
            minp = k;
        }
    }

    if (minp.empty()) {
        std::cout << "Can't buy all products!" << std::endl;
        return 0;
    }

    std::vector<u16> answer;
    while (minp != std::vector<u32>(nprods)) {
        auto const & [a, b, c] = d.at(minp);
        answer.push_back(c);
        auto const & wprods = warehouses_products[c];
        for (size_t i = 0; i < wprods.size(); ++i)
            minp[i] -= wprods[i];
    }

    std::sort(answer.begin(), answer.end());

    std::cout << "WareHouses: ";
    for (auto iware: answer)
        std::cout << iware << ", ";
    std::cout << std::endl;
}

Input:

WareHouses Products:
{2, 5, 0},
{1, 4, 4},
{3, 1, 4},

Ordered Products:
{5, 4, 1}

输出:

WareHouses: 0, 2, 

第二部分:
我还实施了完全不同的解决方案。
现在它基于回溯,使用递归函数。
尽管这种解决方案在最坏情况下是指数级的,但经过一点时间后,它会给出接近最优解的解决方案。因此,您只需运行此程序,只要您能承担得起,无论它到目前为止找到了什么,都输出作为近似解决方案。
算法如下:
假设我们还需要购买一些产品。让我们按照尚未采取的所有仓库的所有产品总量的降序对其进行排序。
在循环中,我们从排序后的降序列表中取出每个下一个仓库,但我们只取此排序列表中的前limit(这是固定的给定值)个元素。通过这种方式,我们按照相关性的顺序贪婪地选择仓库,按照剩余待购买产品的数量的顺序。
在仓库被占用后,我们会递归进入当前函数,在其中再次形成一个排序的仓库列表,并取另一个最相关的仓库,换句话说跳转到此算法的1。
在每个函数调用时,如果我们购买了所有产品并且采取的仓库数量小于当前最小值,则输出此解决方案并更新最小值。
因此,上述算法从非常贪婪的行为开始,然后变得越来越慢,同时变得不那么贪婪,更像暴力方法。而且非常好的解决方案已经在第一秒钟出现。
例如,下面我创建了40个随机仓库,每个仓库有40个随机产品数量。这个相当大的任务在第一秒内可能以最优解得到解决。我的意思是,接下来的几分钟运行不会给出更好的解决方案。

在网上尝试!

#include <cstdint>
#include <iomanip>
#include <iostream>
#include <random>
#include <vector>
#include <functional>
#include <chrono>
#include <cmath>

using u8 = uint8_t;
using u16 = uint16_t;
using u32 = uint32_t;
using i32 = int32_t;

double Time() {
    static auto const gtb = std::chrono::high_resolution_clock::now();
    return std::chrono::duration_cast<std::chrono::duration<double>>(
        std::chrono::high_resolution_clock::now() - gtb).count();
}

void Solve(auto const & wps, auto const & ops) {
    size_t const nwares = wps.size(), nprods = ops.size(), max_depth = 1000;
    
    std::vector<u32> prods_left = ops;
    std::vector<std::vector<u16>> sorted_wares_all(max_depth);
    std::vector<std::vector<u32>> prods_copy_all(max_depth);
    std::vector<u16> path;
    std::vector<u8> used(nwares);
    size_t min_wares = size_t(-1);
    
    auto ProdGrow = [&](auto const & prods){
        size_t grow = 0;
        for (size_t i = 0; i < nprods; ++i)
            grow += std::min(prods_left[i], prods[i]);
        return grow;
    };
    
    std::function<void(size_t, size_t, size_t)> Rec = [&](size_t depth, size_t off, size_t lim){
        size_t prods_need = 0;
        for (auto e: prods_left)
            prods_need += e;
        if (prods_need == 0) {
            if (path.size() < min_wares) {
                min_wares = path.size();
                std::cout << std::endl << "Time " << std::setw(4) << std::llround(Time())
                    << " sec, Cnt " << std::setw(3) << path.size() << ":   ";
                auto cpath = path;
                std::sort(cpath.begin(), cpath.end());
                for (auto e: cpath)
                    std::cout << e << ", ";
                std::cout << std::endl << std::flush;
            }
            return;
        }
        auto & sorted_wares = sorted_wares_all.at(depth);
        auto & prods_copy = prods_copy_all.at(depth);
        sorted_wares.clear();
        for (u16 i = off; i < nwares; ++i)
            if (!used[i])
                sorted_wares.push_back(i);
        std::sort(sorted_wares.begin(), sorted_wares.end(),
            [&](auto a, auto b){
                return ProdGrow(wps[a]) > ProdGrow(wps[b]);
            });
        sorted_wares.resize(std::min(lim, sorted_wares.size()));
        for (size_t i = 0; i < sorted_wares.size(); ++i) {
            u16 const iware = sorted_wares[i];
            auto const & wprods = wps[iware];
            prods_copy = prods_left;
            for (size_t j = 0; j < nprods; ++j)
                prods_left[j] -= std::min(prods_left[j], wprods[j]);
            path.push_back(iware);
            used[iware] = 1;
            Rec(depth + 1, iware + 1, lim);
            used[iware] = 0;
            path.pop_back();
            prods_left = prods_copy;
        }
        for (auto e: sorted_wares)
            used[e] = 0;
    };
    
    for (size_t lim = 1; lim <= nwares; ++lim) {
        std::cout << "Limit " << lim << ", " << std::flush;
        Rec(0, 0, lim);
    }
}

int main() {
    size_t const nwares = 40, nprods = 40;
    std::mt19937_64 rng{std::random_device{}()};
    std::vector<std::vector<u32>> wps(nwares);
    for (size_t i = 0; i < nwares; ++i) {
        wps[i].resize(nprods);
        for (size_t j = 0; j < nprods; ++j)
            wps[i][j] = rng() % 90 + 10;
    }
    std::vector<u32> ops;
    for (size_t i = 0; i < nprods; ++i)
        ops.push_back(rng() % (nwares * 20));
    Solve(wps, ops);
}

输出:

Limit 1, Limit 2, Limit 3, Limit 4,
Time    0 sec, Cnt  13:   6, 8, 12, 13, 29, 31, 32, 33, 34, 36, 37, 38, 39,
Limit 5,
Time    0 sec, Cnt  12:   6, 8, 12, 13, 28, 29, 31, 32, 36, 37, 38, 39,
Limit 6, Limit 7,
Time    0 sec, Cnt  11:   6, 8, 12, 13, 19, 26, 31, 32, 33, 36, 39,
Limit 8, Limit 9, Limit 10, Limit 11, Limit 12, Limit 13, Limit 14, Limit 15,

1
如果您想选择ILP路线,您可以制定以下计划:

model

其中,w表示仓库数量,p表示产品数量,n_j表示订购的产品j的数量,C_ij表示仓库i中存储的产品j的数量。因此,决策是选择仓库ix_i = 1)还是不选择(x_i = 0)。

使用谷歌的ortools和开源的CBC求解器,可以在Python中实现如下:

import numpy as np
from ortools.linear_solver import pywraplp


# Some test data, replace with your own.
p = 50
w = 1000
n = np.random.randint(0, 10, p)
C = np.random.randint(0, 5, (w, p))

solver = pywraplp.Solver("model", pywraplp.Solver.CBC_MIXED_INTEGER_PROGRAMMING)
x = [solver.BoolVar(f"x[{i}]") for i in range(w)]
    
for j in range(p):
    solver.Add(C[:, j] @ x >= n[j])
    
solver.Minimize(sum(x))

这个公式可以在几秒钟到一分钟内解决最多一千个仓库的实例。较小的实例由于(我希望)显而易见的原因可以更快地解决。
以下是输出的解决方案和一些统计数据:
assert solver.Solve() is not None
    
print("Solution:")
print(f"assigned = {[i + 1 for i in range(len(x)) if x[i].solution_value()]}")
print(f"     obj = {solver.Objective().Value()}")
print(f"    time = {solver.WallTime() / 1000}s")

不错的解决方案!我认为它非常快(几秒钟),只是因为您的解决方案仅输出了4个结果仓库。这是因为您请求的产品太少,每种产品最多只有10个。如果您将 n = np.random.randint(0, 10, p) 更改为 n = np.random.randint(0, 100, p),那么最终答案将有超过30个仓库,即使在几个小时内也无法完成此计算。 - Arty
@Arty 刚刚完成了,经过我机器上测试的几个不同样本平均在约30秒左右解决了(每个仓库大约有30-35个),这确实是一种有效的方法,但肯定存在着一定的局限性。 - Nelewout

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接