今天是我们讲解「动态规划专题」中的「背包问题」的第五天。
从本篇开始,我们会完成三道与 完全背包 相关的练习题。会进入比较轻松的「完全背包」复习强化阶段 ~
另外,我在文章结尾处列举了我所整理的关于背包问题的相关题目。
背包问题我会按照编排好的顺序进行讲解(每隔几天更新一篇,确保大家消化)。
你也先可以尝试做做,也欢迎你向我留言补充,你觉得与背包相关的 DP 类型题目 ~
这是 LeetCode 上的「279. 完全平方数」,难度为 Medium。
给定正整数
,找到若干个完全平方数(比如 1, 4, 9, 16, ...)使得它们的和等于
。
你需要让组成和的完全平方数的个数最少。
给你一个整数
,返回和为
的完全平方数的「最少数量」。
「完全平方数」是一个整数,其值等于另一个整数的平方;换句话说,其值等于一个整数自乘的积。
例如,1、4、9 和 16 都是完全平方数,而 3 和 11 不是。
示例 1:
输入:n = 12
输出:3
解释:12 = 4 + 4 + 4
示例 2:
输入:n = 13
输出:2
解释:13 = 4 + 9
提示:
首先「完全平方数」有无限个,但我们要凑成的数字是给定的。
因此我们第一步可以将范围在
内的「完全平方数」预处理出来。
这一步其实就是把所有可能用到的数字先预处理出来。
同时由于题目没有限制我们相同的「完全平方数」只能使用一次。
因此我们的问题转换为:
给定了若干个数字,每个数字可以被使用无限次,求凑出目标值
所需要用到的是最少数字个数是多少。
这显然符合「完全背包」模型。
目前我们学过的两类背包问题(01 背包 & 完全背包)的原始状态定义都是两维:
代表物品编号
代表容量
其中第二维
又有「不超过容量
」和「容量恰好为
」两种定义。
本题要我们求「恰好」凑出
所需要的最少个数。
因此我们可以调整我们的「状态定义」:
为考虑前
个数字,凑出数字总和
所需要用到的最少数字数量。
不失一般性的分析
,对于第
个数字(假设数值为
),我们有如下选择:
,此时有
,此时有
,此时有
...
,此时有
因此我们的状态转移方程为:
当然,能够选择
个数字
的前提是,剩余的数字
也能够被其他「完全平方数」凑出,即
为有意义的值。
代码:
class Solution {
int INF = -1;
public int numSquares(int n) {
// 预处理出所有可能用到的「完全平方数」
List<Integer> list = new ArrayList<>();
int idx = 1;
while (idx * idx <= n) {
list.add(idx * idx);
idx++;
}
// f[i][j] 代表考虑前 i 个物品,凑出 j 所使用到的最小元素个数
int len = list.size();
int[][] f = new int[len][n + 1];
// 处理第一个数的情况
for (int j = 0; j <= n; j++) {
int t = list.get(0);
int k = j / t;
if (k * t == j) { // 只有容量为第一个数的整数倍的才能凑出
f[0][j] = k;
} else { // 其余则为无效值
f[0][j] = INF;
}
}
// 处理剩余数的情况
for (int i = 1; i < len; i++) {
int t = list.get(i);
for (int j = 0; j <= n; j++) {
// 对于不选第 i 个数的情况
f[i][j] = f[i - 1][j];
// 对于选 k 次第 i 个数的情况
for (int k = 1; k * t <= j; k++) {
// 能够选择 k 个 t 的前提是剩余的数字 j - k * t 也能被凑出
if (f[i - 1][j - k * t] != INF) {
f[i][j] = Math.min(f[i][j], f[i - 1][j - k * t] + k);
}
}
}
}
return f[len - 1][n];
}
}
,共有
个状态需要转移,每个状态转移最多遍历
次,因此转移完所有状态复杂度为
。整体复杂度为
。
。
显然朴素版的完全背包进行求解复杂度有点高。
在上一节讲解 完全背包 的时候,我们从「数学」角度来推导为何能够进行一维空间优化。
这次我们还是按照同样的思路再进行一次推导,加强大家对这种优化方式的理解。
从二维的状态转移方程入手进行分析(假设第
个数字为
):
至此,我们得到了最终的状态转移方程:
代码:
class Solution {
int INF = -1;
public int numSquares(int n) {
// 预处理出所有可能用到的「完全平方数」
List<Integer> list = new ArrayList<>();
int idx = 1;
while (idx * idx <= n) {
list.add(idx * idx);
idx++;
}
// f[j] 代表考虑到当前物品为止,凑出 j 所使用到的最小元素个数
int len = list.size();
int[] f = new int[n + 1];
// 处理第一个数的情况
for (int j = 0; j <= n; j++) {
int t = list.get(0);
int k = j / t;
if (k * t == j) { // 只有容量为第一个数的整数倍的才能凑出
f[j] = k;
} else { // 其余则为无效值
f[j] = INF;
}
}
// 处理剩余数的情况
for (int i = 1; i < len; i++) {
int t = list.get(i);
for (int j = t; j <= n; j++) {
// 当不更新 f[j] 的时候,对应了二维表示中的 f[i - 1][j]
// 可以更新 f[j] 的前提是:剩余的 j - k * t 也能够被凑出
// 更新 f[j] 所依赖的 f[j - t] 对应了二维表示中的 f[i - 1][j - k * t]
if (f[j - t] != INF) f[j] = Math.min(f[j], f[j - t] + 1);
}
}
return f[n];
}
}
,共有
个状态需要转移,复杂度为
。整体复杂度为
。
。
今天,我们强化了 上一节 的「完全背包」一维优化方式的遍历顺序推导过程。
又一次的带大家从朴素的二维状态定义出发,逐步推导出一维空间的状态转移方程,并从状态转移方程确定遍历顺序。
可以发现,这道题和我们传统的「完全背包」的状态定义不同:
传统的「完全背包」的状态定义是要我们求最大价值,而本题则是求取得某个价值所需要的最少元素个数。
但模型仍然是对应「每个物品可以选择无限个,每选一个物品会带来相应的价值与成本」的「完全背包」模型。
建议大家结合 上一节 的内容好好体会 ~
这是我们「刷穿 LeetCode」系列文章的第 No.279
篇,系列开始于 2021/01/01,截止于起始日 LeetCode 上共有 1916 道题目,部分是有锁题,我们将先将所有不带锁的题目刷完。
在这个系列文章里面,除了讲解解题思路以外,还会尽可能给出最为简洁的代码。如果涉及通解还会相应的代码模板。
为了方便各位同学能够电脑上进行调试和提交代码,我建立了相关的仓库:https://github.com/SharingSource/LogicStack-LeetCode。
在仓库地址里,你可以看到系列文章的题解链接、系列文章的相应代码、LeetCode 原题链接和其他优选题解。