完全平方数
每个平方数都能无限次使用,这是完全背包求最小件数:dp[i] = min(dp[i - j²]) + 1。
问题拆解
把 n 拆成若干个完全平方数之和,求最少需要几个。比如 12 = 4 + 4 + 4 用 3 个,13 = 4 + 9 用 2 个。
贪心地每次减去不超过余额的最大平方数是不行的:12 会先取 9,剩 3 只能拆成 1+1+1,共 4 个,输给 4+4+4 的 3 个。局部最优不等于全局最优,这正是该上 DP 的信号。
换个视角:物品是所有不超过 n 的平方数 1, 4, 9, 16, …,每种可以取任意多次,要恰好装满容量 n 且件数最少——完全背包的最小件数版。设 dp[i] 为凑出 i 所需的最少平方数个数,最后一个用的平方数是 j²,那么之前凑出的就是 i - j²:
dp[i] = min(dp[i - j²]) + 1,对所有 j² <= i
和爬楼梯一样是“枚举最后一步”,只是把“方案数求和”换成了“件数取最小”。计数与最优化,往往共用同一棵递推骨架。
两个细节要讲清楚。初值:dp[0] = 0(凑 0 不需要任何数),其余位置设成一个“不可达”的大值,这样没被更新过的状态不会污染 min;实际上由于 1 永远是候选,每个 dp[i] 最终都可达。遍历顺序:完全背包允许同一物品重复使用,所以内外层怎么套都行、正序即可——这与 0/1 背包必须倒序恰成对照;求最小值也不区分排列组合,无需纠结先枚举物品还是先枚举容量。
完全背包求最少件数
按容量从小到大填表,每个容量枚举最后用的平方数。
public int numSquares(int n) {
int[] dp = new int[n + 1];
Arrays.fill(dp, Integer.MAX_VALUE);
dp[0] = 0; // 凑 0 不需要任何平方数
for (int i = 1; i <= n; i++) {
for (int j = 1; j * j <= i; j++) { // 枚举最后一个平方数
dp[i] = Math.min(dp[i], dp[i - j * j] + 1);
}
}
return dp[n];
}
def numSquares(n: int) -> int:
dp = [float("inf")] * (n + 1)
dp[0] = 0 # 凑 0 不需要任何平方数
for i in range(1, n + 1):
j = 1
while j * j <= i: # 枚举最后一个平方数
dp[i] = min(dp[i], dp[i - j * j] + 1)
j += 1
return dp[n]
func numSquares(n int) int {
dp := make([]int, n+1)
for i := 1; i <= n; i++ {
dp[i] = math.MaxInt32
}
// dp[0] = 0:凑 0 不需要任何平方数
for i := 1; i <= n; i++ {
for j := 1; j*j <= i; j++ { // 枚举最后一个平方数
dp[i] = min(dp[i], dp[i-j*j]+1)
}
}
return dp[n]
}
pub fn num_squares(n: i32) -> i32 {
let n = n as usize;
let mut dp = vec![i32::MAX; n + 1];
dp[0] = 0; // 凑 0 不需要任何平方数
for i in 1..=n {
let mut j = 1;
while j * j <= i { // 枚举最后一个平方数
dp[i] = dp[i].min(dp[i - j * j] + 1);
j += 1;
}
}
dp[n]
}
写这类“大值初值”的代码有个隐蔽的坑:如果转移里可能对 MAX_VALUE 做加法,会整数溢出成负数、反而被 min 选中。这道题安全,是因为 j = 1 保证 dp[i - 1] 这条转移路径永远存在,dp 表是从 dp[0] 起一格格实打实填出来的,不会拿大值参与加法后再入表——但换一道可能真的不可达的题(如零钱兑换),就要先判 dp[i - c] != MAX 再转移。Java 版用 Integer.MAX_VALUE 填充没出事,靠的正是这一点。
复杂度
| 指标 | 复杂度 | 原因 |
|---|---|---|
| 时间 | O(n · √n) |
每个容量 i 枚举至多 √i 个平方数 |
| 空间 | O(n) |
一维 dp 表 |
可以迁移的模式
- 物品可无限次使用的凑数问题是完全背包,正序遍历;每件限用一次才需要倒序;
- 求最小值的 DP 用“大值”做不可达初值,
dp[0] = 0是锚点,并小心大值参与加法的溢出; - 贪心反例(12 = 9+1+1+1)值得先在纸上找一找,找到了就死心上 DP。
同样的骨架换成硬币面额就是第 322 题零钱兑换,换成“方案数”就是第 518 题——完全背包一套模板,三道题共用。