背包问题是动态规划领域最经典的问题类型之一,其核心思想可以概括为:在给定的容量限制下,从一组物品中选择最优的组合,以达到特定的目标(如最大化价值、最小化成本或计算可行方案数)。这类问题在实际应用中极为广泛,从资源分配到投资组合优化,都能看到它的身影。
每个背包问题都包含三个基本要素:
根据物品选择规则的不同,背包问题主要分为以下几种类型:
01背包是最基础的背包问题类型,也是理解其他变种的基础。其典型描述是:给定一组物品,每个物品有一定的重量和价值,在背包容量限制下,如何选择物品使得总价值最大,且每个物品只能选择一次。
我们使用二维数组dp[i][j]表示状态,其中:
i表示考虑前i个物品j表示当前背包的容量dp[i][j]的值表示在前i个物品中选择,总重量不超过j时的最大价值对于每个物品,我们有两种选择:
dp[i][j] = dp[i-1][j]dp[i][j] = dp[i-1][j-w[i]] + v[i](前提是j ≥ w[i])因此,状态转移方程为:
code复制dp[i][j] = max(dp[i-1][j], dp[i-1][j-w[i]] + v[i])
dp[0][j] = 0:考虑0个物品时,无论背包容量多大,价值都为0dp[i][0] = 0:背包容量为0时,无法装入任何物品,价值为0观察状态转移方程可以发现,当前状态只依赖于上一行的状态,因此可以将二维数组优化为一维数组:
cpp复制vector<int> dp(capacity + 1, 0);
for(int i = 1; i <= n; i++) {
for(int j = capacity; j >= w[i]; j--) {
dp[j] = max(dp[j], dp[j - w[i]] + v[i]);
}
}
注意内层循环需要从大到小遍历,这样可以确保在计算dp[j]时,dp[j-w[i]]仍然是上一轮的值。
有些问题要求背包必须恰好装满,此时状态定义和初始化需要调整:
dp[0][0] = 0:容量为0时,不选任何物品,价值为0dp[0][j] = -∞(或其他表示不可行的标记):容量不为0时,无法通过不选任何物品达到dp[i-1][j-w[i]]可行时才考虑选择当前物品完全背包与01背包的区别在于,每个物品可以被无限次选择。这使得状态转移方程有所不同。
对于完全背包,状态转移方程为:
code复制dp[i][j] = max(dp[i-1][j], dp[i][j-w[i]] + v[i])
与01背包的区别在于,选择当前物品时,不是从i-1行而是从i行转移,因为物品可以被重复选择。
一维数组的实现如下:
cpp复制vector<int> dp(capacity + 1, 0);
for(int i = 1; i <= n; i++) {
for(int j = w[i]; j <= capacity; j++) {
dp[j] = max(dp[j], dp[j - w[i]] + v[i]);
}
}
注意这里内层循环是从小到大遍历,这与01背包相反。
零钱兑换问题是完全背包的典型应用。给定不同面额的硬币和一个总金额,计算可以凑成总金额的最少硬币数。
状态转移方程:
code复制dp[i][j] = min(dp[i-1][j], dp[i][j-coins[i]] + 1)
初始化:
dp[0][0] = 0dp[0][j] = ∞(表示无法凑出)当背包有多个维度的限制时,就形成了多维背包问题。例如,在选择物品时,既要考虑重量限制,又要考虑体积限制。
使用三维数组dp[i][j][k],其中:
i表示考虑前i个物品j表示当前背包的重量限制k表示当前背包的体积限制dp[i][j][k]表示在前i个物品中选择,总重量不超过j且总体积不超过k时的最大价值code复制dp[i][j][k] = max(dp[i-1][j][k], dp[i-1][j-w[i]][k-v[i]] + val[i])
可以优化为二维数组:
cpp复制vector<vector<int>> dp(weight_limit + 1, vector<int>(volume_limit + 1, 0));
for(int i = 1; i <= n; i++) {
for(int j = weight_limit; j >= w[i]; j--) {
for(int k = volume_limit; k >= v[i]; k--) {
dp[j][k] = max(dp[j][k], dp[j-w[i]][k-v[i]] + val[i]);
}
}
}
有些问题不要求计算最大价值,而是计算达到特定目标的方案数。例如,给定一组数字,计算有多少种组合可以得到目标和。
状态转移方程变为累加:
code复制dp[i][j] = dp[i-1][j] + dp[i-1][j-nums[i]]
判断是否存在一种选择方式满足特定条件。例如,分割等和子集问题。
状态定义可以改为布尔值:
code复制dp[i][j] = dp[i-1][j] || dp[i-1][j-nums[i]]
每个物品有固定的选择次数限制。可以通过将每个物品拆分为多个相同物品,转化为01背包问题。
许多看似与背包无关的问题,可以通过适当的转化变为背包问题。关键识别点包括:
dp[0][0]通常初始化为1dp[i][j]的具体含义问题描述:给定一个只包含正整数的非空数组,判断是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。
解法:转化为01背包问题,背包容量为sum/2,看是否能恰好装满。
cpp复制bool canPartition(vector<int>& nums) {
int sum = accumulate(nums.begin(), nums.end(), 0);
if(sum % 2 != 0) return false;
int target = sum / 2, n = nums.size();
vector<bool> dp(target + 1, false);
dp[0] = true;
for(int num : nums) {
for(int j = target; j >= num; j--) {
dp[j] = dp[j] || dp[j - num];
}
}
return dp[target];
}
问题描述:给定不同面额的硬币和一个总金额,计算可以凑成总金额的硬币组合数。
解法:完全背包的方案数问题。
cpp复制int change(int amount, vector<int>& coins) {
vector<int> dp(amount + 1, 0);
dp[0] = 1;
for(int coin : coins) {
for(int j = coin; j <= amount; j++) {
dp[j] += dp[j - coin];
}
}
return dp[amount];
}
问题描述:给定一个二进制字符串数组和两个整数m和n,找出并返回该数组的最大子集的大小,该子集中最多有m个0和n个1。
解法:二维费用的01背包问题。
cpp复制int findMaxForm(vector<string>& strs, int m, int n) {
vector<vector<int>> dp(m + 1, vector<int>(n + 1, 0));
for(string& str : strs) {
int zeros = count(str.begin(), str.end(), '0');
int ones = str.size() - zeros;
for(int i = m; i >= zeros; i--) {
for(int j = n; j >= ones; j--) {
dp[i][j] = max(dp[i][j], dp[i - zeros][j - ones] + 1);
}
}
}
return dp[m][n];
}
在某些特殊情况下,背包问题可以用贪心算法解决。例如,当物品可以分割时(分数背包问题),贪心算法能得到最优解。
对于物品数量较少的情况,可以考虑使用记忆化搜索或状态压缩DP来解决背包问题。
在有限的预算下,选择投资项目组合以最大化收益,这是典型的01背包问题。
在有限的生产能力下,决定生产哪些产品以及生产多少,这类似于多重背包问题。
在有限的时间内选择学习哪些课程以最大化知识获取,可以建模为背包问题。
背包问题是动态规划中最具代表性的问题类型之一。通过本文的系统讲解,我们可以看到,尽管背包问题有多种变体,但它们都遵循相似的模式和解决方法。掌握背包问题的关键在于:
在实际编程竞赛或面试中,遇到背包类问题时,建议按照以下步骤进行:
最后,多练习是掌握背包问题的最佳途径。建议读者尝试LeetCode上的相关题目,从简单到困难逐步提升,以真正掌握这类问题的解决方法。