力扣每日一刷Day 20
Practice Day twenty:Leetcode T 322
今天,我们来讲讲背包DP
背包DP(Knapsack Dynamic Programming)
一种经典的动态规划问题,主要解决在有限容量下如何选择物品使得总价值最大。最常见的是0-1背包问题
通常我们会通过遍历的方式穷举所有可能,才能得到最大值,比较好的实现是表格法。
这就是背包DP的所有讲解了。
现在,我们来看看今天的Leetcode
题目条件:无限硬币、硬币面额、目标金额
题目目的:最少硬币达到目标金额
今天我们选用的是力扣不知名老哥的代码
老哥的代码在速度上登峰造极,来到了0ms,空间复杂度也超过96%的人
时间复杂度:O(N∗Amount)
空间复杂度:O(Amount)
先给你们看看这位老哥代码的全貌,事实上我第一眼我就感觉超纲了。不过这并不妨碍我们学新知识。
const auto _ = std::cin.tie(nullptr)->sync_with_stdio(false);
#define LC_HACK
#ifdef LC_HACK
const auto __ = []() {struct ___ {static void _() { std::ofstream("display_runtime.txt") << 0 << '\n'; }};std::atexit(&___::_);return 2;
}();
#endifclass Solution {
public:int coinChange(vector<int>& coins, int amount) {constexpr int max_coins{ 10001 };int dp[max_coins]{ 0 }; fill(dp + 1, dp + amount + 1, max_coins);for (size_t i=0; i < coins.size(); ++i){for (size_t j{ static_cast<size_t>(coins[i]) }; j < amount + 1; ++j){dp[j] = min(dp[j], dp[j - coins[i]] + 1);}}return dp[amount] == max_coins ? -1 : dp[amount];}
};
现在我们来开始着手构建他吧!
1 代码一开始就关闭了同步,这种做法在我们Day 13就已经见过,并详细解释其作用了,但这里还多了一些东西,即tie函数我们需要详细讲解
const auto _ = std::cin.tie(nullptr)->sync_with_stdio(false);
std::cin.tie(nullptr)(这可能会带来一些危害,好奇的小伙伴可以自己查询一下)
默认情况下,
std::cin
和std::cout
是绑定(tie)在一起的。每次调用std::cout
后,会自动刷新缓冲区,并且在调用std::cin
之前也会自动刷新std::cout
的缓冲区。使用
std::cin.tie(nullptr)
可以解除这种绑定,避免不必要的缓冲区刷新,从而提升性能。tie()
是std::ios_base
的一个成员函数。std::cin
是一个std::istream
对象,它继承自std::ios_base
。tie(nullptr)
的作用是将std::cin
解绑(解除与std::cout
的绑定)。它返回的是一个指向
std::ios_base
类型对象的指针。
->sync_with_stdio(false)
C++ 的标准 I/O 流 (
cin
,cout
) 默认与 C 标准库的 I/O 流 (stdin
,stdout
) 同步,以便兼容性。设置为
false
可以关闭这种同步机制,从而显著加快输入输出速度,但代价是不能混用cin/cout
和scanf/printf
。->
是对前面返回的指针使用成员访问运算符;sync_with_stdio(false)
是调用该指针所指向对象的sync_with_stdio
成员函数,并传入参数false
;- 这个函数的作用是关闭 C++ I/O 流和 C 标准 I/O 流之间的同步,从而提高性能。
将返回值赋给一个临时变量const auto _ = ...
_
,确保这条语句不会被编译器优化掉(因为某些编译器可能会忽略没有副作用的语句)。
2 然后开启条件编译控制。不过老实说,我也不太懂为什么要这么做
#define LC_HACK /*定义一个宏LC_HACK*/
#ifdef LC_HACK /*如果LC_HACK被定义,那就开始执行下面的操作*/
这是我下面找到的可能的原因。事实上在看完之后,我仍然认为这个没有必要
1. 区分 “调试 / 开发” 与 “生产 / 发布” 版本
开发阶段可能需要打印调试日志、验证内存泄漏的代码,但发布时这些代码会影响性能或暴露敏感信息。
#define DEBUG // 开发时定义,发布时注释掉
#ifdef DEBUG// 调试代码:打印变量值、执行额外校验cout << "调试:当前变量值=" << x << endl;
#endif
如果直接编译,调试代码会被保留,而条件编译可以一键 “剔除”。
2. 适配不同平台或环境
同一套代码可能需要在 Windows、Linux、移动端等不同平台运行,而不同平台的底层接口可能不同。
例如:
#ifdef _WIN32 // Windows平台宏// Windows特有的文件操作代码
#elif __linux__ // Linux平台宏// Linux特有的文件操作代码
#endif
直接编译会导致某一平台的代码在另一平台报错,条件编译则能针对性地选择适配代码。
3. 临时启用 / 禁用功能模块
开发中可能需要临时测试某个功能(如代码中的LC_HACK
对应的 “程序退出时写文件” 功能),但正式版本不需要。
如果直接编译,要么保留冗余代码,要么手动删除(后续可能还要恢复),而条件编译只需注释 / 取消注释#define LC_HACK
即可切换,更灵活且不易出错。
4. 避免重复定义
头文件中常用#ifndef
防止重复包含(即 “头文件保护”):
#ifndef MY_HEADER_H
#define MY_HEADER_H// 头文件内容(类、函数声明等)
#endif
好的,条件编译的使用条件大家已经有所了解,我们继续回到老哥的代码解析
我们回忆一下, 由于宏被定义,所以执行以下操作
3 然后弄一个程序一启动,就立即执行的匿名lambda函数。关于lambda,这种函数类型我们之前讲过,在Day15就已经完整的讲解过他的作用,此处不再重复讲解
const auto __ = []() {
虽然不写匿名的lambda也可以执行里面的操作,但是写lambda可以让这些操作具备封装性,并减少命名冲突,同时lambda立即执行的特性,也能让人马上知晓:这是一次性的启动初始化操作
好了,让我们看看函数里面有什么
struct ___ {static void _() { std::ofstream("display_runtime.txt") << 0 << '\n'; }};std::atexit(&___::_);return 2;
}();
我嘞个飞天大雷,我就说为什么要弄这个东西:
这段代码的效果是:程序一旦正常结束,就会自动在当前目录生成(或覆盖)display_runtime.txt
文件,并写入0
。
也就是说无论这个代码运行了多久,3ms也好,4ms也好,只要加上这串代码,统一显示为0!真是太见鬼了,这不就是作弊吗!?
我就说怎么他的时间复杂度和空间复杂度和官方解答一样, 怎么会快官方解答这么多!
要不是这里没有表情包,我真想放一个笑哭的表情上去
好吧,作弊就作弊吧,起码让哥几个学到真东西了,我们继续
回调函数
核心特点是被当作参数传递给另一个函数,然后在特定时机被调用执行。简单说,就是 “你定义一个函数,交给别人(其他函数或系统),让别人在需要的时候帮你调用”。
下面展示的代码就是回调函数的一个例子
___::_
/*其中___是我们在lambda中定义的结构体名称,_是结构体中定义的函数名称*/
你只是定义了一个函数_,但是没有调用它,他就不管用,所以我们需要一个函数调用它,这个函数就是atexit()
接下来,我们来讲讲atexit函数的注册。注册的意思如下
类比:
这就像你去电影院看电影,离场前在前台 “注册” 了一个需求:“请在散场时提醒我带伞”。
- 注册:告诉前台你的需求和触发时机(散场时)
- 触发:电影结束(对应程序退出),前台履行约定提醒你(对应调用注册的函数)
std::atexit(&___::_);
- 这是 C++ 标准库函数
atexit
的调用,用于注册一个 "程序退出时的回调函数" &___::_
表示取结构体___
中静态成员函数_()
的地址,作为回调函数- 作用是:当程序正常退出时(无论是从
main
函数返回、调用exit()
函数,还是程序自然结束),系统会自动调用___::_()
函数,执行文件写入操作。 - 执行时机保障:
整个逻辑被包裹在 “立即执行的 lambda 表达式” 中:这确保了std::atexit
的注册操作在程序启动阶段(main
函数执行前) 就完成,保证回调函数在程序生命周期的最后阶段被正确调用。
- 这是 C++ 标准库函数
return 2;
- 这是 lambda 表达式的返回语句,返回值为整数 2
- 由于这个 lambda 表达式是立即执行的(末尾有
()
),其返回值会被赋值给前面的const auto __
变量 - 从代码逻辑看,这个返回值 "2" 没有实际用途,只是为了满足 lambda 表达式需要有返回值的语法要求
看见最后那个#endif了吗,还记得上面我们有#ifdef LC_HACK ,这是开启命令的指令,而#endif就是结束执行的标志。
好了,偷天换日的手法已经被我们学会了,我们接下来学习正经东西,也就是我们真正的背包DP。
4 先给个壳子,看看传入什么参数:coins硬币面额,amount目标金额
int coinChange(vector<int>& coins, int amount) {
5 在此,我们需要定义一个常量,作为初始化我们组合数组的初值,同时,这个初值也会成为我们判断方案是否可行的数值,这在很多涉及到数组的方法中常见。这里我们把他初始化为一个不可达值。
constexpr int max_coins{ 10001 };
我们还没有见过constexpr,来解释一下
constexpr
关键字
表示这是一个编译期常量,意味着它的值在编译阶段就会被确定,而不是在程序运行时计算。这有助于编译器进行优化,同时确保该值在程序运行过程中不会被修改。
现在,我们来讲讲constexpr和const的区别
初始化时机
const int max_coins = 10001;
表示运行时常量,其值在编译期可以不确定,只要在运行时初始化后不再修改即可constexpr int max_coins{10001};
表示编译期常量,其值必须在编译阶段就确定,编译器会在编译时计算并嵌入代码中
适用场景
- 对于数组大小、模板参数等必须在编译期确定的值,只能用
constexpr
- 在后面的代码中,虽然
max_coins
用于初始化数组int dp[max_coins]{0};
,这里用const
也能编译通过(C++ 允许const
整数作为数组大小),但使用constexpr
更明确地表达了 "这是一个编译期确定的常量" 的意图
- 对于数组大小、模板参数等必须在编译期确定的值,只能用
优化潜力
constexpr
常量会被编译器视为 "真正的常量",可能会在编译时直接替换到使用它的地方(类似宏替换,但更安全)- 对于动态规划中的边界判断
dp[amount] == max_coins
,constexpr
可以让编译器生成更高效的比较指令
简单说,在这个场景下,constexpr
比const
更严格地保证了值的常量性,并且明确告诉编译器:"这个值在编译时就已知,可以放心地进行优化"。虽然用const
也能工作,但constexpr
是更合适的选择。
6 然后以数组索引为目标金额,数组存储的值为所用最少的硬币数目,创建数组
int dp[max_coins]{ 0 };
然后顺便初始化0值,因为凑出0块钱只需要0枚硬币
7 现在,我们需要对数组剩余的元素进行初始化
fill(dp + 1, dp + amount + 1, max_coins);
新面孔,让我们来看看他是怎么一回事
fill(起始地址, 结束地址, 要填充的值),把所有数值初始化为我们定义的最大值,一值两用,很合理
8 然后遍历每一种硬币面额。在此之前我们回忆了一下参数,确信是传入了coins数组的
for (size_t i=0; i < coins.size(); ++i){
在这里我们又看到新东西了,size_t
size_t
是 C++ 中一个特殊的无符号整数类型(unsigned integer type),专门用于表示对象的大小、数组的索引或容器的元素数量。
什么用 size_t
而不是 int
?
- 语义更清晰:明确表示 “这是一个长度 / 索引,而非普通整数”
- 避免负数问题:数组索引和大小不可能为负,无符号类型更安全(例如避免
i < -1
这类逻辑错误) - 兼容性更好:与标准库的接口(如
size()
方法,在我们后面的coins.size()有用到)完美匹配,避免类型转换警告
9 然后以当前硬币金额,遍历全部的面额,开始构造完全背包(完全背包的含义:在背包容量是定额的同时,每个物件可以使用无数次),
for (size_t j{ static_cast<size_t>(coins[i]) }; j < amount + 1; ++j){dp[j] = min(dp[j], dp[j - coins[i]] + 1);}}
好吧,一步步拆解吧,新面孔也是不少
just look at this
size_t j{ static_cast<size_t>(coins[i]) }
这行代码的作用是将 coins[i]
的值转换为 size_t
类型,并赋值给变量 j
。这里的关键点包括:
coins[i]
:硬币的面额(例如[1, 2, 5]
),而i
是当前访问的索引。static_cast<size_t>(...)
:使用静态类型转换,将coins[i]
的值显式转换为无符号整数类型size_t
。通常用于确保操作在处理容器大小时不会出现类型不匹配的问题。j
:最终得到的是与coins[i]
值相等的无符号整数。
然后继续看看第2条是怎么回事
static_cast<目标类型>(表达式)
说明:
static_cast
:是 C++ 提供的一种安全、静态检查的类型转换方式。目标类型
:是你希望将表达式转换成的类型,例如int
、double
、size_t
等。表达式
:是要被转换的值或变量。
然后就是说明一下,为什么要用{ }来给 j 进行初始化了。这实际上是一种列表初始化
列表初始化有诸如下面的好处
核心特点
禁止窄化转换(最关键特性)
不允许可能导致数据丢失的隐式转换,编译时会报错:int x{3.14}; // 错误!double转int会丢失小数部分(窄化转换) char c{1000}; // 错误!1000超出char的取值范围(通常-128~127)
这比传统的
=
初始化更安全,能避免意外的数据截断。无歧义
解决了 C++ 中传统()
初始化的 "最令人头疼的解析" 问题:// 传统方式可能被解析为函数声明 int func(); // 函数声明,返回int// 列表初始化明确为对象初始化 int func{10}; // 变量初始化,值为10
统一语法
无论初始化变量、数组、容器还是自定义对象,都可以使用{}
,风格一致,降低学习成本。
接着,我们来看看状态转移方程
dp[j] = min(dp[j], dp[j - coins[i]] + 1);
dp[j]
的含义dp[j]
表示在当前计算状态下,组成金额j
所需要的最少硬币数量。在执行这行代码前,它可能已经存储了不使用当前硬币时的最优解。dp[j - coins[i]] + 1
的含义coins[i]
是当前正在处理的硬币面额j - coins[i]
表示使用 1 枚当前硬币后剩余的金额dp[j - coins[i]]
是组成这个剩余金额所需的最少硬币数+1
表示加上当前使用的这 1 枚硬币
min
函数的作用
比较两种方案的结果并取当前面额 j 更少硬币的组合- 不使用当前硬币的方案(
dp[j]
) - 使用 1 枚当前硬币的方案(
dp[j - coins[i]] + 1
)
最终将更优的结果(更少的硬币数量)赋值给dp[j]
- 不使用当前硬币的方案(
但是这就会出现另一个问题:j - coins[i]这个索引他会小于0吗?
答案是:不会
这是因为我们初始的 j 被设置为了当前处理的硬币面额,所以 j - coins[i]最小就是0!不会出现小于0的情况!
9 当我们所有循环结束后,我们返回答案。
return dp[amount] == max_coins ? -1 : dp[amount];
这也没什么好说的了,如果没有找到best match,就返回一个-1,如果找到了,就返回找到的值。
that‘s all,感谢不知名老哥提供的代码