动态规划经典:0-1 背包问题超详细解析(递归 + 非递归 + 路径回溯)C++ 实现
前言
0-1 背包问题是动态规划领域最经典的问题,也是学习动态规划必须掌握的核心题型。本文将基于你编写的 C++ 代码,从递归求解、非递归求解到最优方案路径回溯,一步一步深度解析 0-1 背包的实现原理,包含完整可运行代码、逐行解析、表格打印调试,新手也能彻底吃透!
本文代码特点:
- 包含递归实现(自上而下记忆化搜索)
- 包含非递归实现(自底向上填表,标准动态规划)
- 包含最优方案回溯(输出哪些物品被选中)
- 打印 DP 表格,直观观察状态转移过程
- 数组下标从 1 开始,符合算法教材习惯,更容易理解
一、0-1 背包问题描述
有 n 个物品和一个最大承重为 c 的背包。每个物品:
- 重量:
W[i] - 价值:
V[i] - 每个物品只能选 1 次或不选(0-1 含义)
目标:在不超过背包承重的前提下,装入物品的总价值最大,并输出选择了哪些物品。
示例数据
- 物品数量
n=5 - 背包容量
c=10 - 重量数组
W = [0,2,2,6,5,4] - 价值数组
V = [0,6,3,5,4,6]
二、核心动态规划思想
1. DP 状态定义
m[i][j]:表示前 i 个物品,放入容量为 j 的背包中,能获得的最大价值。
2. 状态转移方程
对第 i 个物品,有两种选择:
- 不选:最大价值 = 前
i-1个物品,容量j的最大值m[i][j] = m[i-1][j] - 选:前提是背包能装下,最大价值 = 当前物品价值 + 前
i-1个物品、剩余容量的最大值m[i][j] = m[i-1][j-W[i]] + V[i]
最终取最大值:
m[i][j] = max(m[i-1][j], m[i-1][j-W[i]] + V[i]);
3. 边界条件
- 没有物品(
i=0):价值为 0 - 背包容量为 0(
j=0):价值为 0
三、完整代码与逐行深度解析
1. 头文件与工具函数
#include<stdio.h>
#include<iostream>
#include<assert.h>
#include<stdlib.h>
#include<string.h>
#include<limits.h>
#include<float.h>
#include<stack>
#include<queue>
#include<vector>
#include<algorithm>
using namespace std;
2. DP 表格打印函数(调试神器)
用于打印二维 DP 数组,直观观察每一步状态转移:
// 打印二维DP表格,行:物品i,列:背包容量j
void Print2Vec(const std::vector<std::vector<int> >& m)
{
int row = m.size();
int col = m[0].size();
printf(" ");
// 打印顶部列号(背包容量)
for (int i = 0; i < col; ++i)
{
printf("%5d", i);
}
printf("\n");
for (int i = 0; i < row; ++i)
{
printf("%3d", i); // 打印行号(物品编号)
for (int j = 0; j < col; ++j)
{
printf("%5d", m[i][j]);
}
printf("\n");
}
printf("\n--------------------------------------\n");
}
四、递归实现(自上而下记忆化搜索)
1. 从尾部切(标准递归,推荐)Knapsack2
从最后一个物品开始,递归向前决策:
// 递归:从尾部切(i从n→1)
int Knapsack2(const int* W, const int* V, int i, int j,
std::vector<std::vector<int> >& m)
{
// 递归出口:只有第1个物品
if (i == 1)
{
m[i][j] = j >= W[i] ? V[i] : 0;
}
// 记忆化:已经计算过,直接返回
else if (m[i][j] > 0)
{
return m[i][j];
}
// 装不下当前物品,只能不选
else if (j < W[i])
{
m[i][j] = Knapsack2(W, V, i - 1, j, m);
}
// 装得下,选或不选取最大值
else
{
int t1 = Knapsack2(W, V, i - 1, j, m); // 不选第i个
int t2 = Knapsack2(W, V, i - 1, j - W[i], m) + V[i]; // 选第i个
m[i][j] = (t1 > t2) ? t1 : t2;
}
return m[i][j];
}
代码解析
- 递归出口:处理到第一个物品,能装就赋值价值
- 记忆化搜索:避免重复计算,提升效率
- 装不下:直接继承上一个物品的结果
- 装得下:比较选与不选的价值,取最大
五、非递归实现(自底向上填表,标准 DP)
这是最常用、最高效的解法,从第一个物品开始,逐步填满 DP 表。
NiceKnapsack2(标准动态规划)
// 非递归:自底向上填表(标准0-1背包实现)
int NiceKnapsack2(const int* W, const int* V, int n, int c,
std::vector<std::vector<int> >& m)
{
// 边界初始化
for (int j = 0; j <= c; ++j) { m[0][j] = 0; } // 0个物品价值为0
for (int i = 0; i <= n; ++i) { m[i][0] = 0; } // 0容量价值为0
// 遍历每个物品
for (int i = 1; i <= n; ++i)
{
// 遍历每个背包容量
for (int j = 1; j <= c; ++j)
{
// 装不下:不选当前物品
if (j < W[i])
{
m[i][j] = m[i - 1][j];
}
// 装得下:max(不选,选)
else
{
m[i][j] = std::max(m[i - 1][j], m[i - 1][j - W[i]] + V[i]);
}
}
}
// 答案:n个物品,c容量的最大价值
return m[n][c];
}
代码解析
- 初始化:第 0 行、第 0 列全部为 0(边界条件)
- 双层循环:
- 外层:遍历所有物品
- 内层:遍历所有背包容量
- 状态转移:严格按照公式,装不下则继承,装得下则取最大值
- 返回值:表格右下角即为答案
六、最优方案回溯(输出选了哪些物品)
计算出最大价值后,反向推导哪些物品被选中:
// 回溯:输出物品选择结果 X[i]=1表示选,0表示不选
void TraceBack2(const std::vector < std::vector<int> >& m, int* W, int n, int c,
std::vector<int>& X)
{
// 从最后一个物品向前回溯
for (int i = n; i > 0; --i)
{
// 价值不同 → 选了第i个物品
if (m[i][c] != m[i - 1][c])
{
X[i] = 1;
c -= W[i]; // 剩余容量减去当前物品重量
}
}
}
解析
- 对比
m[i][c]和m[i-1][c] - 不相等:说明选了第 i 个物品
- 相等:说明没选
- 选了之后,更新剩余背包容量
七、主函数测试
int main()
{
const int n = 5; // 物品数
const int c = 10; // 背包容量
// 下标从1开始,符合教材习惯
int W[n + 1] = { 0,2,2,6,5,4 }; // 重量
int V[n + 1] = { 0,6,3,5,4,6 }; // 价值
// DP表
std::vector<std::vector<int> > m(n + 1, std::vector<int>(c + 1, 0));
// 存储选择结果
std::vector<int> X(n + 1, false);
// 计算最大价值
int maxVal = NiceKnapsack2(W, V, n, c, m);
cout << "最大价值:" << maxVal << endl;
// 打印DP表格
Print2Vec(m);
// 回溯最优路径
TraceBack2(m, W, n, c, X);
// 输出选择结果
for (int i = 1; i <= n; ++i)
{
printf("物品{%d} -> 选择:%d \n", i, X[i]);
}
printf("\n-----------------\n");
return 0;
}
八、运行结果
最大价值:15
0 1 2 3 4 5 6 7 8 9 10
0 0 0 0 0 0 0 0 0 0 0 0
1 0 0 6 6 6 6 6 6 6 6 6
2 0 0 6 6 9 9 9 9 9 9 9
3 0 0 6 6 9 9 9 9 9 11 11
4 0 0 6 6 9 9 9 10 10 11 13
5 0 0 6 6 9 9 12 12 15 15 15
物品1 -> 选择:1
物品2 -> 选择:1
物品3 -> 选择:0
物品4 -> 选择:0
物品5 -> 选择:1
✅ 结果分析:
- 最大价值:15
- 选中物品:1、2、5
- 总重量:2+2+4=8 ≤ 10,总价值:6+3+6=15,最优解!
九、补充:另一种递归与非递归实现
上面的方式时从尾部切入的方法,还有从头切的递归Knapsack1和非递归NiceKnapsack1,思路完全一致,只是遍历方向相反(从 1→n),理解其中一种即可举一反三。
1. 从头切递归实现:Knapsack1
该函数从第一个物品开始,递归向后决策,直到最后一个物品返回结果,同样采用记忆化搜索避免重复计算。
// 递归:从头切(i从1→n)
int Knapsack1(const int* W, const int* V, int i, int j, int n,
std::vector<std::vector<int> >& m)
{
// 递归出口:已经到最后一个物品
if (i == n)
{
m[i][j] = j >= W[i] ? V[i] : 0;
}
// 记忆化:当前状态已经计算过,直接返回结果
else if (m[i][j] > 0)
{
return m[i][j];
}
// 背包容量装不下当前物品,只能不选,递归处理下一个物品
else if (j < W[i])
{
m[i][j] = Knapsack1(W, V, i + 1, j, n, m);
}
// 背包容量装得下当前物品,选与不选取最大值
else // j>=W[i];
{
// 不选第i个物品,直接处理i+1
int t1 = Knapsack1(W, V, i + 1, j, n, m);
// 选第i个物品,剩余容量j-W[i],加上当前物品价值
int t2 = Knapsack1(W, V, i + 1, j - W[i], n, m) + V[i];
if (t1 > t2)
{
m[i][j] = t1;
}
else
{
m[i][j] = t2;
}
}
return m[i][j];
}
代码解析
- 递归出口:
i == n代表处理到最后一个物品,能装下就赋值价值,否则为 0; - 记忆化优化:判断
m[i][j]>0,已经计算过的状态直接返回,大幅提升效率; - 装不下当前物品:
j < W[i],只能不选,递归处理下一个物品i+1; - 装得下当前物品:分别计算选和不选的价值,取最大值赋值给
m[i][j]。
2. 从头切最优路径回溯:TraceBack1
配合 Knapsack1/NiceKnapsack1 使用,反向推导选中的物品,逻辑与 TraceBack2 一致。
// 配合从头切的DP表,回溯最优选择路径
void TraceBack1(const std::vector<std::vector<int> >& m, const int* W, int n, int c,
std::vector<int>& X)//装了哪个
{
for (int i = 1; i < n; ++i)
{
// 价值不相等,说明选了第i个物品
if (m[i][c] != m[i + 1][c])
{
X[i] = 1;
c -= W[i];
}
else
{
X[i] = 0;
}
}
// 处理最后一个物品
if (m[n][c] != 0)
{
X[n] = 1;
}
}
代码解析
- 循环遍历物品
i,对比m[i][c]和m[i+1][c]; - 不相等 → 选中第
i个物品,标记X[i]=1,并减去物品重量; - 最后单独判断最后一个物品,完成路径回溯。
3. 从头切非递归实现:NiceKnapsack1
自底向上填表,从最后一个物品往第一个物品逆序填充 DP 表,每一步都打印 DP 表格,方便调试观察。
// 非递归:从头切(逆序填表,从最后一个物品开始)
int NiceKnapsack1(const int* W, const int* V, int n, int c,
std::vector<std::vector<int> >& m)//非递归
{
// 初始化最后一行(第n个物品)
for (int j = 1; j <= c; ++j)
{
m[n][j] = (j >= W[n]) ? V[n] : 0;
}
// 打印初始化后的DP表
Print2Vec(m);
// 从n-1逆序遍历到1,填充DP表
for (int i = n - 1; i > 0; --i)
{
for (int j = 1; j <= c; ++j)
{
// 装不下,继承下一个物品的结果
if (j < W[i])
m[i][j] = m[i + 1][j];
// 装得下,max(不选,选)
else
{
m[i][j] = std::max(m[i + 1][j], m[i + 1][j - W[i]] + V[i]);
}
}
// 每填充一行打印一次,观察状态转移
Print2Vec(m);
}
// 最终答案:第一个物品,最大容量
return m[1][c];
}
代码解析
- 初始化:先填充 DP 表的最后一行(最后一个物品);
- 逆序循环:
i从n-1遍历到 1,自下而上填充表格; - 状态转移:装不下则继承
i+1的结果,装得下则取最大值; - 打印调试:每填充一行打印一次 DP 表,清晰观察状态变化;
- 返回值:
m[1][c]即为最大价值。
十、0-1 背包核心总结
- 状态定义:
m[i][j]前 i 个物品、容量 j 的最大价值 - 转移方程:
m[i][j] = max(m[i-1][j], m[i-1][j-W[i]]+V[i]); - 实现方式:
- 递归:自上而下,记忆化搜索
- 非递归:自底向上填表,效率更高
- 路径回溯:对比 DP 表,反向推导选中的物品
- 复杂度:时间 O (nc),空间 O (nc)
十一、总结
本文完整实现了 0-1 背包的递归、非递归、路径回溯三大核心功能,代码规范、注释详细、附带表格打印调试,非常适合 C++ 新手学习动态规划。
0-1 背包是动态规划的基石,掌握本题后,可轻松拓展学习:
- 完全背包(物品可重复选)
- 多重背包(物品有数量限制)
- 分组背包
建议大家把代码复制运行,结合打印的 DP 表格一步步调试,彻底理解状态转移的过程!
更多推荐
所有评论(0)