作为一名从ACM竞赛一路走来的算法工程师,背包问题是我在面试新人时必问的经典题目。01背包作为动态规划的入门题型,看似简单却蕴含着深刻的算法思想。我第一次接触这个问题时,也经历了从暴力递归到动态规划的思维跃迁过程。
背包问题的实际应用场景非常广泛,比如在游戏开发中角色装备选择、投资组合优化、资源分配等领域都有重要应用。理解01背包不仅能帮助我们解决这类具体问题,更是掌握动态规划这一重要算法思想的绝佳切入点。
给定n个物品和一个容量为V的背包:
对于初学者来说,最直观的解法就是尝试所有可能的组合。对于每个物品,我们有两种选择:放入或不放入背包。因此,n个物品就有2^n种可能的组合方式。
cpp复制int knapsack(vector<int>& w, vector<int>& v, int V, int index, int currentW) {
if (index == w.size()) return 0;
// 不选当前物品
int notTake = knapsack(w, v, V, index + 1, currentW);
// 选当前物品(前提是能装下)
int take = 0;
if (currentW + w[index] <= V) {
take = v[index] + knapsack(w, v, V, index + 1, currentW + w[index]);
}
return max(take, notTake);
}
这个解法虽然简单直接,但时间复杂度是O(2^n),当n=30时,计算量就超过10亿次,完全无法在实际中使用。
提示:在算法竞赛中,通常n超过20时,指数级算法就不可行了,必须寻找更优解。
动态规划的核心在于"记忆化"和"状态转移"。对于01背包问题,我们可以定义一个二维数组dp:
状态转移方程:
cpp复制int knapsack(vector<int>& w, vector<int>& v, int V) {
int n = w.size();
vector<vector<int>> dp(n + 1, vector<int>(V + 1, 0));
for (int i = 1; i <= n; ++i) {
for (int j = 0; j <= V; ++j) {
if (j >= w[i-1]) {
dp[i][j] = max(dp[i-1][j], dp[i-1][j-w[i-1]] + v[i-1]);
} else {
dp[i][j] = dp[i-1][j];
}
}
}
return dp[n][V];
}
这个解法的时间复杂度是O(nV),空间复杂度也是O(nV)。对于大多数实际问题,这个复杂度已经可以接受了。
观察状态转移方程可以发现,dp[i][...]只依赖于dp[i-1][...],因此我们可以将空间复杂度优化到O(V):
cpp复制int knapsack(vector<int>& w, vector<int>& v, int V) {
int n = w.size();
vector<int> dp(V + 1, 0);
for (int i = 0; i < n; ++i) {
for (int j = V; j >= w[i]; --j) {
dp[j] = max(dp[j], dp[j - w[i]] + v[i]);
}
}
return dp[V];
}
重要细节:内层循环必须从大到小遍历,否则会重复计算同一物品多次,变成完全背包问题。
根据问题要求的不同,初始化的方式也有所区别:
cpp复制int knapsack_exact(vector<int>& w, vector<int>& v, int V) {
vector<int> dp(V + 1, INT_MIN);
dp[0] = 0;
for (int i = 0; i < w.size(); ++i) {
for (int j = V; j >= w[i]; --j) {
if (dp[j - w[i]] != INT_MIN) {
dp[j] = max(dp[j], dp[j - w[i]] + v[i]);
}
}
}
return dp[V] == INT_MIN ? -1 : dp[V];
}
有时候我们需要知道哪些物品被选中了,而不仅仅是最大价值:
cpp复制vector<int> getSelectedItems(vector<int>& w, vector<int>& v, int V) {
int n = w.size();
vector<vector<int>> dp(n + 1, vector<int>(V + 1, 0));
// 常规DP计算
for (int i = 1; i <= n; ++i) {
for (int j = 0; j <= V; ++j) {
if (j >= w[i-1]) {
dp[i][j] = max(dp[i-1][j], dp[i-1][j-w[i-1]] + v[i-1]);
} else {
dp[i][j] = dp[i-1][j];
}
}
}
// 回溯找出选择的物品
vector<int> selected;
int remaining = V;
for (int i = n; i >= 1; --i) {
if (remaining >= w[i-1] && dp[i][remaining] == dp[i-1][remaining-w[i-1]] + v[i-1]) {
selected.push_back(i-1);
remaining -= w[i-1];
}
}
return selected;
}
循环顺序错误:在一维DP实现中,内层循环如果从小到大遍历,会导致物品被多次选择
cpp复制// 错误写法!
for (int j = w[i]; j <= V; ++j) {
dp[j] = max(dp[j], dp[j - w[i]] + v[i]);
}
索引混淆:在二维DP中,物品索引从1开始,但w和v数组从0开始,容易混淆
cpp复制// 错误写法!
dp[i][j] = max(dp[i-1][j], dp[i-1][j-w[i]] + v[i]);
边界条件处理不当:忘记处理j<w[i]的情况
打印DP表:对于小规模问题,打印出整个DP表有助于理解算法执行过程
cpp复制for (auto& row : dp) {
for (int val : row) cout << val << " ";
cout << endl;
}
使用小测试用例:先用手算能验证的小例子测试
code复制例子:
w = [2, 3, 4, 5]
v = [3, 4, 5, 6]
V = 8
预期结果:10 (选第1和第4件物品)
检查初始化:确保DP数组的初始化符合题目要求
假设你有100万元的预算,需要在多个项目间分配。每个项目有预期收益和所需资金,如何选择项目组合使总收益最大?这正是01背包问题的实际应用。
在RPG游戏中,角色有负重限制,需要从众多装备中选择最优组合,平衡防御力、攻击力等属性。
在金融领域,投资者需要在风险承受范围内选择最优的投资组合,01背包的变种可以用于这类问题。
掌握了基础01背包后,可以继续学习:
在实际编程竞赛中,背包问题常常不会以标准形式出现,需要选手识别问题本质并进行相应变形。我建议初学者从LeetCode或洛谷上的背包问题专题开始练习,逐步提高识别和解决变种问题的能力。