LEETCODE 279Medium

完全平方数

每个平方数都能无限次使用,这是完全背包求最小件数: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 所需的最少平方数个数,最后一个用的平方数是 ,那么之前凑出的就是 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 题——完全背包一套模板,三道题共用。