【动态规划】题目中的「0-1 背包」和「完全背包」的问题
🎒 先看「0-1 背包」(对比理解)
想象你去超市,有一个购物袋(容量有限),面前有几件商品:
商品 | 体积 | 价值 |
---|---|---|
苹果 | 2 | 3 |
香蕉 | 3 | 4 |
橙子 | 4 | 5 |
规则:每种商品只能拿 1 个。
问:在袋子不超重的前提下,怎么拿能让总价值最大?
✅ 这就是 0-1 背包:每个物品 选 or 不选(0 或 1 次)。
🛒 再看「完全背包」
现在换一家店,规则变了:
每种商品可以拿任意多个(只要袋子装得下)!
比如苹果,你可以拿 1 个、2 个、3 个……直到袋子满了。
问题还是:怎么拿能让总价值最大?
✅ 这就是 完全背包:每个物品 可以选无限次。
🔁 关键区别
类型 | 每个物品能选几次? | 例子 |
---|---|---|
0-1 背包 | 最多 1 次 | 打家劫舍(每间房子只能偷/不偷一次) |
完全背包 | 无限次 | 零钱兑换(硬币可以重复用)、完全平方数(4 可以用多次) |
🧩 回到你的代码:为什么是「完全背包」?
问题:用最少的完全平方数(1, 4, 9, 16…)凑出 n
。
- 平方数就像“硬币”:1, 4, 9, …
- 你可以用 多个 4(比如 4+4+4=12)
- 目标不是“最大价值”,而是“最少个数”
所以这是一个 “完全背包求最小数量” 的变种!
💡 类比:你有面值为 1、4、9、16… 的硬币,要凑出金额
n
,问最少用几枚硬币?
→ 这就是经典的 “零钱兑换 II” 的最小硬币数版本,属于完全背包。
📌 完全背包的两个核心特征
-
物品可重复使用
→ 在递归中,选了之后,还能继续选同一个物品
→ 你的代码中:dfs(i, j - i*i) + 1
,i
没变,说明下次还能选i²
-
状态转移时,顺序很重要(DP写法中)
- 0-1 背包:倒序遍历容量(避免重复选)
- 完全背包:正序遍历容量(允许重复选)
但在你的记忆化搜索写法中,这一点体现在:选了之后不减 i
。
🌰 再举个完全背包的例子:零钱兑换
硬币:[1, 3, 4]
目标:6
最少硬币数?
可能方案:
- 3 + 3 → 2 枚 ✅(最优)
- 4 + 1 + 1 → 3 枚
- 1×6 → 6 枚
这里 3 用了两次,所以是完全背包。
对应的递归思路:
min(dfs(用3, 剩3) + 1,dfs(不用3, 用更小的)
)
而且 dfs(用3, 剩3)
里还能继续用 3!
✅ 总结:一句话记住
完全背包 = 物品可以“无限拿”,选了一个之后,下次还能选它。
而你的代码中:
- “物品” = 完全平方数(1, 4, 9…)
- “无限拿” = 递归时
i
不减(dfs(i, j - i*i)
) - “最少个数” = 用
min
而不是max
这就是为什么它属于完全背包问题。
举例
【279. 完全平方数】
给你一个整数 n ,返回 和为 n 的完全平方数的最少数量 。
完全平方数 是一个整数,其值等于另一个整数的平方;换句话说,其值等于一个整数自乘的积。例如,1、4、9 和 16 都是完全平方数,而 3 和 11 不是。
# 写在外面,多个测试数据之间可以共享,减少计算量
@cache # 缓存装饰器,避免重复计算 dfs 的结果(记忆化)
def dfs(i: int, j: int) -> int:if i == 0:return inf if j else 0if j < i * i:return dfs(i - 1, j) # 只能不选return min(dfs(i - 1, j), dfs(i, j - i * i) + 1) # 不选 vs 选class Solution:def numSquares(self, n: int) -> int:return dfs(isqrt(n), n)
我们用刚刚理解的 「完全背包」视角,解读这段代码,确保每一步都清晰、有逻辑、不跳步。
🎯 问题再明确(完全背包视角)
你有无限量的“平方数硬币”:1, 4, 9, 16, 25, …
目标:凑出金额n
要求:用最少的硬币数量
这和「零钱兑换」一模一样,只是硬币面值固定为平方数。
🧠 动态规划的核心:状态定义
我们要定义一个函数,能回答:
“如果我只允许用前 k 种硬币(即 1², 2², …, k²),凑出金额 j,最少需要多少枚?”
在你的代码中,这个函数是:
def dfs(i: int, j: int) -> int:
i
:最大可用的硬币是i²
(相当于“前 i 种硬币”)j
:要凑的目标金额- 返回值:最少硬币数
✅ 所以
dfs(i, j)
= 用 {1², 2², …, i²} 这些硬币(每种无限用),凑出j
的最少个数。
🔁 状态转移:选 or 不选当前硬币?
当前最大硬币是 i²
。面对它,你有两个选择:
✅ 选择 1:不用 i²
这种硬币
- 那就只能用更小的硬币:{1², …, (i-1)²}
- 问题变成:
dfs(i - 1, j)
✅ 选择 2:用一个 i²
硬币
- 花掉
i²
,剩下金额是j - i²
- 关键:因为硬币无限用,下次还能继续用
i²
! - 所以问题变成:
dfs(i, j - i²) + 1
(+1 是因为用了一枚)
🎯 这就是完全背包的标志:选了之后,状态中的“可用硬币范围”不变(还是 i)
(对比 0-1 背包:选了之后要变成i-1
)
然后,我们选这两种方案中更优的(数量更少的):
min( dfs(i-1, j), dfs(i, j - i*i) + 1 )
⚠️ 但要注意前提:能不能选?
你不能选一个比目标还大的硬币!
- 如果
j < i*i
,说明i²
太大了,不能选 - 所以只能走“不选”的分支:
if j < i * i:return dfs(i - 1, j)
🚧 边界条件:递归的终点
什么时候停止递归?
- 当
i == 0
:表示没有硬币可用(因为最小硬币是 1²,i=0 对应 0²=0,没用)- 如果目标
j == 0
:不需要硬币 → 返回 0 - 如果目标
j > 0
:没法凑 → 返回 无穷大(inf),表示“不可能”
- 如果目标
if i == 0:return inf if j else 0
💡
inf
在min()
中会被自动忽略,所以不会影响结果。
🚀 初始调用:从哪里开始?
我们要凑 n
,最大可能用到的硬币是 不超过 n 的最大平方数。
- 比如
n=12
,最大平方数是 9 = 3² - 所以
i
的初始值 =isqrt(n)
(整数平方根)
return dfs(isqrt(n), n)
这样既不会漏掉可能的硬币,也不会考虑无用的大硬币(比如 16>12)。
🧪 完整例子:n = 12
硬币可选:1, 4, 9(因为 isqrt(12)=3)
调用:dfs(3, 12)
dfs(3,12)
├─ 不用9 → dfs(2,12) → 用1,4凑12 → 最少3个(4+4+4)
└─ 用9 → dfs(3,3)+1 → 用1,4,9凑3 → 只能用1(3个)→ 3+1=4
→ min(3,4) = 3 ✅
而 dfs(2,12)
内部:
dfs(2,12) // 用1,4凑12
├─ 不用4 → dfs(1,12) → 12个1 → 12
└─ 用4 → dfs(2,8)+1├─ 用4 → dfs(2,4)+1│ └─ 用4 → dfs(2,0)+1 = 0+1=1└─ 所以 dfs(2,4)=1 → dfs(2,8)=2 → dfs(2,12)=3
完全符合“完全背包”逻辑!
🧩 为什么叫“背包”?
- “背包容量” = 目标金额
j
- “物品” = 平方数硬币
- “物品重量” = 硬币面值(i²)
- “物品价值” = 1(因为我们只关心数量,每个硬币算1个)
- “目标” = 装满背包(凑出 j),且物品总数最少
✅ 最终总结(完全背包视角)
代码元素 | 完全背包含义 |
---|---|
i | 当前考虑的最大硬币面值是 i² |
j | 背包剩余容量(要凑的金额) |
dfs(i-1, j) | 不选 i² 硬币 |
dfs(i, j - i*i) + 1 | 选一个 i² 硬币(还能再选!) |
j < i*i | 硬币太大,不能选 |
i == 0 | 没有硬币了 |
@cache | 避免重复计算相同子问题 |
isqrt(n) | 最大可能用到的硬币 |
它本质上就是一个用递归+记忆化实现的完全背包求最小值问题。