背包问题总结

80 阅读10分钟

引言

在计算机科学与运筹学中,背包问题(Knapsack Problem)是一类经典的组合优化问题。它不仅具有深厚的理论价值,还广泛应用于资源分配、投资决策、物流调度等多个现实场景。自20世纪中期被正式提出以来,背包问题一直是算法设计与复杂性理论研究的重要对象。本文将系统介绍背包问题的基本形式、分类、求解方法及其实际应用,并探讨其在现代计算中的意义。

一、什么是背包问题?

背包问题的基本设定如下:给定一个容量为 W 的背包和 n 个物品,每个物品 i 有一个重量 wi​ 和一个价值 vi​。目标是在不超过背包容量的前提下,选择若干物品装入背包,使得所选物品的总价值最大。

这一看似简单的问题背后隐藏着复杂的组合爆炸特性——随着物品数量增加,可能的组合呈指数级增长,因此需要高效的算法策略来应对。

二、背包问题的主要类型

1. 0-1 背包问题

这是最基础的形式,每个物品只能选择“放入”或“不放入”,即取值为 0 或 1。数学表达为:

image.png

0-1 背包问题是 NP 完全问题,意味着目前尚无已知的多项式时间精确解法(除非 P=NP)。

2. 完全背包问题

在此变种中,每种物品可以无限次选取。即对每个物品 i,可以选择任意非负整数个。约束条件变为:

xi​∈Z≥0​

虽然仍属 NP-hard,但动态规划可高效处理。

3. 多重背包问题

每种物品有数量上限 ci​,即最多可选 ci​ 个。这是 0-1 与完全背包的中间形态,更具现实意义(如库存有限的商品)。

4. 多维背包问题

物品不仅有重量,还有体积、成本等多个维度的限制,背包也对应多个容量约束。例如:

image.png 此类问题更贴近实际工程需求,但求解难度显著提升(本文不作探讨😊)

三、求解方法

1. 动态规划(Dynamic Programming)

对于 0-1 背包问题,经典解法是使用二维 DP 表:

定义 dp[i][w] 表示前 i 个物品在容量为 w 时能获得的最大价值。状态转移方程为:

image.png

时间复杂度为 O(nW),空间可通过滚动数组优化至 O(W)。

2. 贪心算法

贪心策略(如按单位重量价值排序)适用于分数背包问题(允许物品分割),此时可得最优解。但在 0-1 背包中,贪心仅能提供近似解,无法保证最优。

3. 分支限界法与回溯

通过系统地枚举所有可能组合,并利用上界剪枝,可在小规模实例中找到精确解。适合用于教学或验证。

4. 近似算法与启发式方法

对于大规模问题,常采用遗传算法、模拟退火、蚁群优化等元启发式方法,在可接受时间内获得高质量近似解。此外,存在多项式时间近似方案(PTAS)用于特定情形。

四、java代码


public class Knapsack {

    /**
     * 1. 0-1 背包问题
     * 每种物品仅有一件,可以选择放或不放。
     */
    public static int zeroOneKnapsack(int[] weights, int[] values, int capacity) {
        int n = weights.length;
        // dp[j] 表示容量为 j 时能获得的最大价值
        int[] dp = new int[capacity + 1];

        for (int i = 0; i < n; i++) {
            // 关键点:容量从大到小遍历(倒序)
            // 防止物品被重复放入
            for (int j = capacity; j >= weights[i]; j--) {
                dp[j] = Math.max(dp[j], dp[j - weights[i]] + values[i]);
            }
        }
        return dp[capacity];
    }

    /**
     * 2. 完全背包问题
     * 每种物品有无限件,可以重复选择。
     */
    public static int completeKnapsack(int[] weights, int[] values, int capacity) {
        int n = weights.length;
        int[] dp = new int[capacity + 1];

        for (int i = 0; i < n; i++) {
            // 关键点:容量从小到大遍历(正序)
            // 允许在已经放入物品 i 的基础上再次放入
            for (int j = weights[i]; j <= capacity; j++) {
                dp[j] = Math.max(dp[j], dp[j - weights[i]] + values[i]);
            }
        }
        return dp[capacity];
    }

    /**
     * 3. 多重背包问题
     * 每种物品有有限件 (数量数组 counts)。
     * 这里使用二进制优化,将其转化为 0-1 背包问题。
     */
    public static int multipleKnapsack(int[] weights, int[] values, int[] counts, int capacity) {
        // 用于存储拆分后的新物品重量和价值
        java.util.List<Integer> newWeights = new java.util.ArrayList<>();
        java.util.List<Integer> newValues = new java.util.ArrayList<>();

        // 1. 二进制拆分优化
        for (int i = 0; i < weights.length; i++) {
            int count = counts[i];
            // 将数量拆分为 1, 2, 4, 8, ... , remainder
            for (int k = 1; k <= count; k *= 2) {
                newWeights.add(k * weights[i]);
                newValues.add(k * values[i]);
                count -= k;
            }
            // 处理余数
            if (count > 0) {
                newWeights.add(count * weights[i]);
                newValues.add(count * values[i]);
            }
        }

        // 2. 对拆分后的新物品进行 0-1 背包求解
        int[] dp = new int[capacity + 1];
        for (int i = 0; i < newWeights.size(); i++) {
            int w = newWeights.get(i);
            int v = newValues.get(i);
            // 标准 0-1 背包倒序遍历
            for (int j = capacity; j >= w; j--) {
                dp[j] = Math.max(dp[j], dp[j - w] + v);
            }
        }
        return dp[capacity];
    }

    // --- 测试主函数 ---
    public static void main(String[] args) {
        // 共同的测试数据
        int[] weights = {2, 3, 4, 5, 6};   // 物品重量
        int[] values =  {3, 4, 5, 6, 7};   // 物品价值
        int capacity = 7;               // 背包容量

        // 1. 测试 0-1 背包
        int result01 = zeroOneKnapsack(weights, values, capacity);
        System.out.println("0-1背包最大价值: " + result01);

        // 2. 测试 完全背包
        int resultComplete = completeKnapsack(weights, values, capacity);
        System.out.println("完全背包最大价值: " + resultComplete);

        // 3. 测试 多重背包 (假设各物品数量为 )
        int[] counts = {3, 1, 2, 1, 2};
        int resultMultiple = multipleKnapsack(weights, values, counts, capacity);
        System.out.println("多重背包最大价值: " + resultMultiple);
    }
}

说明一下为啥01背包中不能正序遍历

🎯 场景设定

假设我们只有一个物品(物品0):

  • 重量:1
  • 价值:15
  • 背包总容量:3

我们的目标是填满 dpdp 这 4 个格子。


🔄 正序遍历(从小到大)的过程

正序就是:先算容量 1,再算容量 2,最后算容量 3

  1. 初始化: 所有格子默认是 0。 dp = (下标: 0 1 2 3)

  2. 开始填表(j 从 1 到 3)

    • 第一步:计算容量 j=1

      • 现在背包容量是 1,刚好能放下物品0(重量1)。
      • 公式:dp = max(不放, 放)
      •  的逻辑是:现在的价值 = (容量为 0 时的价值) + 物品0的价值
      • 此时,dp 是多少?是 0(初始值)。
      • 所以:dp = 0 + 15 = 15
      • 含义:容量为1时,我能拿价值15的东西。
    • 第二步:计算容量 j=2

      • 现在背包容量是 2,也能放下物品0。
      • 公式:dp = (容量为 1 时的价值) + 物品0的价值
      • 关键点来了:此时 dp 是多少?
        • 我们刚刚在第一步已经把它改成了 15
      • 所以:dp = 15 + 15 = 30
      • 含义:容量为2时,我拿了价值30的东西。*
      • 逻辑漏洞:我是怎么拿到30的?我是基于 dp=15 的基础上又拿了一次物品0。但 dp=15 本身就已经包含了一次物品0了。所以这里我实际上拿了两次物品0。
    • 第三步:计算容量 j=3

      • 公式:dp = (容量为 2 时的价值) + 物品0的价值
      • 此时 dp 是多少?是 30(上一步刚算出来的)。
      • 所以:dp = 30 + 15 = 45
❓ 问题出在哪?

你看,在正序填表时,我们是从左往右填的。

  • 当我们填右边的格子(比如 j=2)时,我们参考了左边格子(j=1)的值。
  • 但左边的格子(j=1)已经被当前这个物品更新过了
  • 这就导致:我们把“已经拿过这个物品的状态”当成了“没拿过这个物品的初始状态”来用

这就像是在说:“老板,我刚才已经领了一份工资(dp=15),我现在再干一次活,是不是能再领一份(dp=30)?” —— 这就是重复使用


🆚 对比:倒序(从大到小)会发生什么?

倒序就是:先算容量 3,再算容量 2,最后算容量 1

  1. 初始化dp = 0

  2. 填表(j 从 3 到 1)

    • 第一步:计算 j=3

      • dp = dp + 15
      • 此时 dp 是多少?是 0(因为还没来得及算,还是初始值)。
      • 所以 dp = 0 + 15 = 15
    • 第二步:计算 j=2

      • dp = dp + 15
      • 此时 dp 是多少?是 0(还没算)。
      • 所以 dp = 0 + 15 = 15
    • 第三步:计算 j=1

      • dp = dp + 15
      • dp 是 0。
      • 所以 dp = 15

分析: 因为是倒着填,右边的格子算的时候,左边的格子还是“原始状态”(没拿过这个物品)。所以无论你怎么算,你都是在“原始状态”上加一次物品的价值,这就保证了只拿一次

📌 总结

  • 正序:左边的数据会被先更新。右边计算时用了左边“已经被污染(更新过) ”的数据 -> 重复放入
  • 倒序:右边的数据先算,用的是左边“干净的(未更新) ”数据 -> 只放一次

再说明一下为啥使用集合记忆已经使用的商品也不行

❌ 问题一:破坏了 DP 的“无后效性”原则

动态规划的核心前提之一是 “无后效性”(No Aftereffect) ,意思是:

当前状态只由之前的状态决定,而与“如何到达这个状态”的具体路径无关。

当你引入一个 Set 来记录“哪些物品已经被选过”,你就把路径信息(即选择了哪些物品)编码进了状态里。

  • 在传统的 0-1 背包中,状态是 dp[j] —— 只关心“容量为 j 时的最大价值”。
  • 如果加上集合,状态就变成了 (j, selected_set) —— 这个状态空间会爆炸!
❌ 问题二:状态无法压缩,空间爆炸

假设你有 N=30 个物品,那么所有可能的“已选物品集合”数量是 230≈109 种!

即使你用位掩码(bitmask)优化(比如用一个 int 表示选了哪些物品),状态数也会变成:

状态总数=W×2N状态总数=W×2^{N}

当 W=1000,N=30 时,状态数高达 1000 × 10亿 = 10¹²,根本无法存储或计算。

这比暴力枚举(O(2N))还差!

❌ 问题三:DP 数组无法定义转移方程

在标准 DP 中,我们能写出清晰的转移方程:

dp[j] = max(dp[j], dp[j - w[i]] + v[i])

但如果你的状态包含“已选集合 S”,那么转移方程会变成:

dp[j][S ∪ {i}] = max( dp[j][S ∪ {i}], dp[j - w[i]][S] + v[i] )

这要求你对每一个子集 S 都进行遍历和更新——本质上就是暴力搜索所有子集,时间复杂度回到 O(N2N)O(N⋅2^{N})失去了 DP 的意义。

🆚 对比:什么情况下可以用 Set?

如果你不用动态规划,而是用 DFS + 回溯(暴力搜索) ,那确实可以用一个 Set 或布尔数组 used[] 来记录已选物品:

void dfs(int index, int currentWeight, int currentValue, boolean[] used) {
    if (index == n) {
        // 更新答案
        return;
    }
    // 不选
    dfs(index + 1, currentWeight, currentValue, used);
    // 选(如果没选过且不超重)
    if (!used[index] && currentWeight + w[index] <= W) {
        used[index] = true;
        dfs(index + 1, currentWeight + w[index], currentValue + v[index], used);
        used[index] = false; // 回溯
    }
}

但这只是指数级暴力算法,适用于 N≤20 的小规模问题。对于 N=100、W=1000 的典型背包问题,它会超时。

所以,不要试图在 DP 中用 Set 来防重复——这不是 DP 的设计哲学。DP 的强大之处,恰恰在于它用状态抽象顺序控制,巧妙地绕开了对具体路径的追踪。