问题描述

一共有 N 种物品,每种物品都有无数个,第 i (i 从 0 开始)种物品的重量为 w[i],价值为 v[i] 。在总重量不超过背包承载上限 W 的情况下,能够装入背包的最大价值是多少?


暴力递归-每种都尝试

与之前的 01背包问题 不同,N 件物品变成了 N 种物品,且每种物品的数量不限。由于每种物品可以取多次,所以这类问题并不好用“从左到右、要或不要”模型来解决。 更自然的解法是在每层递归中对所有物品都尝试取一次,然后返回所有情况中最大的值。

也可以使用“从左到右、要或不要”的模型,只是有一定限制,见后文。

int knapSackUnlimited(vector<int> weight, vector<int> value, int capacity)
{
    if(capacity < 0)
        return 0;
    int maxVal = 0;
    for(int i = 0; i < weight.size(); i++)
    {
        int val = 0;
        if(weight[i] <= capacity)//容易忽略
            val = value[i] + knapSackUnlimited(weight, value, capacity - weight[i]);
        maxVal = maxVal > val ? maxVal : val;
    }
    return maxVal;
}

显然在递归中有枚举行为(for循环),对每一种物品都尝试取一次,然后比较各自的值,最后返回最大值。

注意第 9 行的判断容易忽略,只有当 capacity 足够装下本次选择的物品时,才可以取该物品,否则 maxVal 不变。

另外,因为有了第 9 行的判断,第 3、4 行的基线条件事实上就可以省略,但为了防止 capacity 小于 0 时还进行无意义的枚举,还是最好加上。下面我们将其改进为动态规划。


动态规划1

int knapSackUnlimited(vector<int> weight, vector<int> value, int capacity)
{
    vector<int> dp(capacity + 1, 0);
    for(int c = 0; c <= capacity; c++)
    {
        int maxVal = 0;
        for(int i = 0; i < weight.size(); i++)
        {
            int val = 0;
            if(weight[i] <= c)
                val = value[i] + dp[c - weight[i]];
            maxVal = maxVal > val ? maxVal : val;
        }
        dp[c] = maxVal;
    }
    return dp[capacity];
}

此动态规划甚至不需要特例初始化,第 6~14 行几乎完全同暴力递归代码。


暴力递归-从左到右

int process(vector<int> weight, vector<int> value, int index, int capacity)
{
    if(index >= weight.size() || capacity < 0)
        return 0;
    int ret1 = process(weight, value, index + 1, capacity);//不要此货物
    int ret2 = 0;
    if(weight[index] <= capacity)//当前容量够,才能要此货物
        ret2 = value[index] + process(weight, value, index, capacity - weight[index]);//要此货物
    return std::max(ret1 , ret2);
}
int knapSackUnlimited1(vector<int> weight, vector<int> value, int capacity)
{
    if(weight.size() != value.size())
        return 0;
    return process(weight, value, 0, capacity);
}

对比一下 01背包 的代码:

int process(const vector<int> &weight, const vector<int> &value, int index, int capacity)
{
    if(index >= weight.size() || capacity < 0)//capacity可以为0
        return 0;
    int ret1 = process(weight, value, index + 1, capacity);//不要此货物
    int ret2 = 0;
    if(weight[index] <= capacity)//当前容量够,才能要此货物
        ret2 = value[index] + process(weight, value, index + 1, capacity - weight[index]);//要此货物
    return std::max(ret1 , ret2);
}
int knapSack(vector<int> weight, vector<int> value, int capacity)
{
    if(weight.size() != value.size())
        return 0;
    return process(weight, value, 0, capacity);
}

惊人地发现,这两种问题的代码仅有一处不同,即第 8 行的 index 实参是否加 1

前者的 index 实参不加 1 是因为:当你选择该种商品后,下一次你仍然可以继续选择该商品。 这种方法和前面的“每种都尝试”方法所达到的效果是一致的。


动态规划2

int knapSackUnlimited3(vector<int> weight, vector<int> value, int capacity)
{
    vector<vector<int>> dp(weight.size(), vector<int>(capacity + 1, 0));
    int last = weight.size() - 1;

    //特例初始化
    for(int c = 0; c <= capacity; c++)
    {
        if(weight[last] <= c)
            dp[last][c] = value[last];
    }

    //依次打表
    for(int i = weight.size() - 2; i >= 0; i--)
    {
        for(int c = 0; c <= capacity; c++)
        {
            int ret1 = dp[i+1][c];
            int ret2 = 0;
            if(weight[i] <= c)
                ret2 = value[i] + dp[i][c-weight[i]];
            dp[i][c] = std::max(ret1, ret2);
        }
    }
    return dp[0][capacity];
}

由暴力递归中的递推表达式可知,当前 index 依赖于 index + 1,所以打表必须按 index 从后往前进行 ,故外层 for 循环从 weight.size()-2 开始;capacity 则是后面依赖前面的,所以打表必须按 capacity 从前往后打,故内层 for 循环是从 c = 0 开始的


补充

博主后来发现上述的“每种都尝试”模型并不是完全背包题型的通解。上面的暴力递归中其实存在重复解,比如下面情况:

第 1 次取 ① 号货物;
第 2 次取 ② 号货物;
第 3 次取 ① 号货物;结束

即取了 1 个 ② 号货物和 2 个 ① 号货物,再看下面这种情况:

第 1 次取 ① 号货物;
第 2 次取 ① 号货物;
第 3 次取 ② 号货物;结束

也是取了 1 个 ② 号货物和 2 个 ① 号货物。这两种情况是相同的,所以返回的最大值也相同。而由于题目要求的是返回所有情况中的最大价值,所以这两者返回谁都无所谓,答案依然正确。

但如果将题目修改为:要求背包必须恰好装满总价值为 K 的货物,一共有多少种装法?那结果就不一样了。假设 1 个 ② 号货物和 2 个 ① 号货物的总价值刚好为 K ,则上面的两种情况实际上只能算作一种取法,而非两种 ,只是取得货物的顺序不同而已。

综上,如果完全背包类型的题目要求取最值,则可以不去重;如果要求取方法数,则必须去重

本题去重版本的暴力递归代码如下:

int knapSackUnlimited4(vector<int> weight, vector<int> value, int capacity, int index)
{
    if(capacity < 0 || index >= weight.size())//capacity<0可以省去
        return 0;
    int max = 0;
    for(int i = 0; i * weight[index] <= capacity; i++)
    {
        int ret = knapSackUnlimited4(weight, value, capacity - weight[index] * i, index + 1);
        max = max > (i * value[index] + ret) ? max : (i * value[index] + ret);
    }
    return max;
}

上面代码的思想是:集中处理每种物品的所有取法 ,比如 ① 号物品在 不超过 capacity 的情况下 可以分别只取 1 次、2 次、3 次…取完后继续统计 ② 号物品的取法,② 号物品在 不超过 capacity(取完①号货物后) 的情况下 也只取 1 次、2 次、3 次…这样就不会出现重复情况。动态规划代码如下:

int knapSackUnlimited(vector<int> weight, vector<int> value, int capacity)
{
    vector<vector<int>> dp(capacity + 1, vector<int>(weight.size(), 0));
    for(int c = 0; c <= capacity; c++)//从上往下填表
    {
        for(int index = weight.size() - 1; index >= 0; index--)//从右往左填表
        {
            int max = 0;
            for(int i = 0; i * weight[index] <= c; i++)
            {
                int ret = index+1 > weight.size() ? 0 : dp[c-weight[index]*i][index+1];
                max = max > (i * value[index] + ret) ? max : (i * value[index] + ret);
            }
            dp[c][index] = max;
        }
    }
    return dp[capacity][0];
}

由于每个格子依赖的是右上方的格子,所以填表时需要从上到下,从右到左进行。

那么如果要求背包必须恰好装满总价值为 K 的货物,一共有多少种装法呢?这个问题参见动态规划-硬币系列2


总结

  • 完全背包可以选择“从左到右”模型,也可以选择“每一种都尝试”模型。但前者在某些时候会有一定限制,具体参见杀死那只怪兽贴纸拼词

  • 完全背包类型的题目如果要求取最值,则可以不去重;如果要求取方法数,则必须去重。

  • 实际上,就暴力递归而言,“从左到右”模型远优于“每一种都尝试模型”,因为前者的递归分支仅有两个,而后者则可能达数十个(for循环)。要知道递归分支可比单纯的 for 循环带来的的时间消耗要大得多! 我们写一个测试代码就能够看出差别:

    int main()
    {
        vector<int> value  = {21,43,12,22,12,53,21,34,1};
        vector<int> weight = {14,23,54,33,11,3,7,24,2};
        int capacity = 50;
        // 测量knapSackUnlimited2函数的运行时间
        auto start1 = std::chrono::high_resolution_clock::now();
        knapSackUnlimited(weight, value, capacity);//从左到右模型
        auto end1 = std::chrono::high_resolution_clock::now();
        auto duration1 = std::chrono::duration_cast<std::chrono::microseconds>(end1 - start1);
        // 测量knapSackUnlimited函数的运行时间
        auto start2 = std::chrono::high_resolution_clock::now();
        knapSackUnlimited1(weight, value, capacity);//每一种都尝试模型
        auto end2 = std::chrono::high_resolution_clock::now();
        auto duration2 = std::chrono::duration_cast<std::chrono::microseconds>(end2 - start2);
        // 输出运行时间
        std::cout << "knapSackUnlimited运行时间: " << duration1.count() << " 微秒" << std::endl;
        std::cout << "knapSackUnlimited1运行时间: " << duration2.count() << " 微秒" << std::endl;
    }
    

    运行结果如下:

    knapSackUnlimited运行时间: 1071 微秒
    knapSackUnlimited1运行时间: 5275539 微秒
    

    差距还是非常明显的。但这只是对暴力递归而言,改成动态规划后二者都是双层 for 循环,时间复杂度并没有差别。个人认为“每一种都尝试”更好理解,但“从左到右”模型更优雅美观。

文章作者: 极简
版权声明: 本站所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 后端技术分享
数据结构与算法
喜欢就支持一下吧