279. 完全平方数

LeetCode 原题链接

先给结论

这道题可以看成零钱兑换:所有不大于 n 的完全平方数 1、4、9、16... 都是可以重复使用的“硬币”,目标是恰好凑出 n,并让使用的数字数量最少。

定义:

dp[x] = 和为 x 的完全平方数的最少数量

如果最后选择的完全平方数是 square,那么在此之前需要先组成 x - square

dp[x] = min(dp[x], dp[x - square] + 1)

依次计算 dp[1]dp[n],最终返回 dp[n]。时间复杂度为 O(n√n),空间复杂度为 O(n)

题目描述

给定正整数 n,返回和为 n 的完全平方数的最少数量。

完全平方数是一个整数的平方,例如 1、4、9、16311 不是完全平方数。

示例 1:

输入:n = 12
输出:3
解释:12 = 4 + 4 + 4

示例 2:

输入:n = 13
输出:2
解释:13 = 4 + 9

问题本质

对于任意 x,答案的最后一个数一定是某个不大于 x 的完全平方数:

1²、2²、3²、...、⌊√x⌋²

枚举最后选择的平方数后,原问题会变成已经计算过的更小子问题:

组成 x 的最少数量
= 组成 x - square 的最少数量 + 当前这个 square

例如计算 dp[13]

最后选 1:dp[12] + 1
最后选 4:dp[9]  + 1
最后选 9:dp[4]  + 1

取三种选择中的最小值:

dp[13] = min(dp[12] + 1, dp[9] + 1, dp[4] + 1)
       = min(4, 2, 2)
       = 2

对应方案是 4 + 9

一、定义 DP 状态

dp[x] 表示组成正整数 x 所需的完全平方数的最少数量

状态必须包含“组成 x”和“数量最少”两个信息。最终答案是:

dp[n]

二、确定状态转移

计算 dp[x] 时,枚举最后一个完全平方数 square = i * i

只要 square <= x,就可以从 x - square 转移过来:

dp[x] = Math.min(dp[x], dp[x - square] + 1);

完整转移方程为:

dp[x] = min(dp[x - i²] + 1),其中 1 <= i² <= x

这里的 + 1 表示选择了当前这个完全平方数

三、初始化

dp[0] = 0

组成数字 0 不需要选择任何数:

dp[0] = 0;

它也是所有转移的起点。例如:

dp[4] 可以由 dp[0] + 1 得到
表示 4 本身就是一个完全平方数

其他状态初始化为 Infinity

const dp = new Array(n + 1).fill(Infinity);
dp[0] = 0;

计算每个状态时要取最小值,因此不能把未知状态初始化为 0,否则 0 会被误认为已经找到的最优答案。

本题也可以初始化为 dp[x] = x,因为任何正整数 x 都可以由 x1 组成。但 Infinity 更直接地表达“尚未计算”。

四、遍历顺序

dp[x] 只依赖更小的 dp[x - square],所以 x 必须从小到大遍历:

for (let x = 1; x <= n; x++) {
    for (let i = 1; i * i <= x; i++) {
        const square = i * i;
        dp[x] = Math.min(dp[x], dp[x - square] + 1);
    }
}

当计算 dp[x] 时,所有 dp[x - square] 都已经计算完成。

五、n = 12 的逐步推演

不大于 12 的完全平方数是:

1、4、9

DP 数组逐步得到:

x可选的最后一个平方数dp[x]一种最优组成
00空集合
1111
2121 + 1
3131 + 1 + 1
41、414
51、424 + 1
61、434 + 1 + 1
71、444 + 1 + 1 + 1
81、424 + 4
91、4、919
101、4、929 + 1
111、4、939 + 1 + 1
121、4、934 + 4 + 4

重点看 dp[12]

最后选 1:dp[11] + 1 = 4
最后选 4:dp[8]  + 1 = 3
最后选 9:dp[3]  + 1 = 4

dp[12] = min(4, 3, 4) = 3

代码实现

JavaScript:动态规划

/**
 * @param {number} n
 * @return {number}
 */
var numSquares = function (n) {
    // dp[x]:组成 x 所需的完全平方数的最少数量
    // 下标覆盖 0 到 n,因此需要 n + 1 个位置
    // 未计算的状态设为 Infinity,后续才能通过 Math.min 更新为有效答案
    // 不能初始化为 0,否则正数也会被误判为“不需要选择任何数”
    const dp = new Array(n + 1).fill(Infinity);

    // 组成 0 不需要任何数字,也是状态转移的起点
    // 例如 x = 4、square = 4 时,dp[4] 可以由 dp[0] + 1 得到 1
    dp[0] = 0;

    // 当前状态只依赖更小的状态,因此从小到大计算
    // 外层的 x 是当前要凑出的目标值,不是选择的平方数
    for (let x = 1; x <= n; x++) {
        // 枚举最后选择的完全平方数 i²
        // i 是平方根,实际选择的数字是 i * i,即 1、4、9、16……
        // 从 1 开始,保证 x - square < x,依赖的是已经算好的状态
        // 使用 <=,才能包含 x 本身就是平方数的情况;也保证剩余值不为负
        for (let i = 1; i * i <= x; i++) {
            const square = i * i;
            // 选择 square 后,还需要凑出 x - square
            // dp[x - square] 是剩余部分的最少数量,+ 1 表示再选一个 square
            // 剩余部分可以已经包含 square,因此同一个平方数可以重复使用
            // 与已有候选取最小值,而不是直接覆盖,才能保留所有选择中的最优解
            // 例如 x = 12、square = 4:dp[8] + 1 = 2 + 1 = 3
            dp[x] = Math.min(dp[x], dp[x - square] + 1);
        }
        // 所有可能的最后一个平方数都已比较完毕,此时 dp[x] 就是最优答案
    }

    // 返回恰好组成 n 的最少数量;因为可以一直选 1,所以一定有解
    return dp[n];
};

代码与思路对照

代码含义
dp[0] = 0空和不需要选择任何完全平方数
x = 1...n按依赖顺序计算所有目标值
i * i <= x枚举不大于当前目标的完全平方数
dp[x - square] + 1在较小问题的最优解后加入当前平方数
Math.min(...)在所有可能的最后一步中选择数量最少的方案

为什么它是完全背包

将完全平方数看成物品:

物品重量:1、4、9、16...
每件物品的代价:1
每件物品可以选择无限次
目标:恰好装满容量 n,并让总代价最小

例如组成 12 时,完全平方数 4 被使用了三次。因此每个平方数不是只能使用一次,而是可以重复选择,符合完全背包模型。

也可以先枚举平方数,再正序枚举目标值:

var numSquares = function (n) {
    // dp[x]:使用当前已经枚举过的平方数,恰好组成 x 的最少数量
    // 尚未凑出的目标先设为 Infinity,组成 0 则需要 0 个数
    const dp = new Array(n + 1).fill(Infinity);
    dp[0] = 0;

    // 外层逐个引入可选平方数,每种平方数都可以使用任意多次
    for (let i = 1; i * i <= n; i++) {
        const square = i * i;

        // 小于 square 的目标装不下当前平方数,所以从 square 开始
        // 正序遍历,允许在本轮重复使用同一个 square
        // 例如 square = 4:先更新 dp[4],再用它更新 dp[8],继而更新 dp[12]
        // 若改为倒序,本轮就无法这样连续使用当前平方数,会变成 0-1 背包
        for (let x = square; x <= n; x++) {
            // dp[x]:保留已有方案;dp[x - square] + 1:再选一个当前平方数
            // 较小目标可能已在本轮更新,所以候选方案允许包含多个 square
            dp[x] = Math.min(dp[x], dp[x - square] + 1);
        }
    }

    // 所有可选平方数都已参与转移,得到目标 n 的最少数量
    return dp[n];
};

两种遍历方式都能得到最少数量:

写法外层循环含义
按目标值计算x = 1...n枚举组成 x 的最后一个平方数
完全背包写法枚举 square逐个加入可以无限使用的平方数

正确性证明

x 从小到大进行归纳。

基础状态

dp[0] = 0 正确,因为组成 0 不需要任何完全平方数。

归纳步骤

假设所有小于 x 的状态都已经表示对应数字的最少数量。

任意组成 x 的合法方案都有最后一个完全平方数 square。删除这个数后,剩余部分之和为 x - square。根据归纳假设,组成剩余部分至少需要 dp[x - square] 个数,所以以 square 结尾的最优方案数量为:

dp[x - square] + 1

算法枚举了所有可能的 square 并取最小值,因此不会遗漏更优方案,得到的 dp[x] 正确。由归纳可知 dp[n] 正确。

复杂度分析

对于每个 x,需要枚举 1⌊√x⌋

  • 时间复杂度:O(n√n)
  • 空间复杂度:O(n)

生成平方数列表需要 O(√n) 空间,但可以像主代码一样在循环中直接计算,从而不额外保存。

替代解法:广度优先搜索

可以把每个剩余值看成图中的节点。从 n 出发,每次减去一个完全平方数:

n → n - 1²、n - 2²、n - 3²...

BFS 第一次到达 0 时的层数,就是使用完全平方数的最少数量。

var numSquares = function (n) {
    // 队列保存“还需要凑出的剩余值”,起点 n 表示尚未选择任何平方数
    const queue = [n];
    // 同一剩余值只需搜索一次:BFS 第一次到达它时,使用的数字数量已经最少
    const visited = new Array(n + 1).fill(false);
    visited[n] = true;

    // 用 head 读取队首,避免反复 shift 移动数组元素
    let head = 0;
    // steps 表示当前尝试使用的平方数数量,每扩展一层就多选择一个
    let steps = 0;

    while (head < queue.length) {
        // 先固定本层待处理的节点数,新入队的节点留到下一层处理
        // 队列前 head 个元素已处理,因此有效长度是 queue.length - head
        const levelSize = queue.length - head;
        steps++;

        for (let count = 0; count < levelSize; count++) {
            const current = queue[head++];

            // 尝试减去每个不超过当前剩余值的平方数,相当于再选择一个数
            for (let i = 1; i * i <= current; i++) {
                const next = current - i * i;

                // 剩余值为 0,说明已经恰好凑出 n
                // BFS 按使用数量从少到多搜索,第一次找到的就是最少数量
                if (next === 0) {
                    return steps;
                }

                if (!visited[next]) {
                    // 入队时就标记,防止同一层的不同路径重复加入这个剩余值
                    visited[next] = true;
                    queue.push(next);
                }
            }
        }
    }
};

BFS 的“层数”对应使用数字的数量,因此天然适合求最少步数。加入 visited 可以避免不同组合反复访问同一个剩余值。

与零钱兑换的对比

对比项完全平方数零钱兑换
可选数字1²、2²、3²...题目给出的硬币面值
每个数字使用次数无限次无限次
目标和恰好为 n金额恰好为 amount
优化目标数字数量最少硬币数量最少
无解情况不存在,始终可以使用 1可能无法凑出

本题一定有答案,因为 1 是完全平方数,最差也可以使用 n1

边界与陷阱

  • dp[x] 初始化为 0:会让未计算状态被误认为不需要任何数字。
  • 忘记 dp[0] = 0:所有状态都会失去有效的转移起点。
  • 循环条件写成 i * i < x:会漏掉 x 本身是完全平方数的情况,应使用 <=
  • 误写成 0-1 背包:平方数可以重复使用;按物品遍历时,容量必须正序。
  • 使用贪心选择最大平方数:局部选择最大值不保证数量最少,例如 12 贪心会得到 9 + 1 + 1 + 1,需要 4 个数,而最优解 4 + 4 + 4 只需 3 个。
  • 使用 Math.sqrt(x) 判断每个候选:可以使用,但循环条件 i * i <= x 更直接,也避免浮点比较。
  • BFS 不记录访问状态:同一个剩余值会由多条路径重复进入队列,造成大量冗余搜索。

面试官递进追问

1. dp[x] 表示什么?

表示组成 x 所需的完全平方数的最少数量。状态必须强调“恰好组成”和“最少数量”。

为什么问: 状态定义决定转移是否正确。回答时不要只说“dp 保存答案”。

2. 状态转移为什么要加 1

dp[x - square] 已经组成剩余部分,当前又选择了一个 square,所以数量需要加一。

为什么问: 检查是否理解转移中的每一项,而不是只背公式。

3. 为什么 x 要从小到大遍历?

因为 dp[x] 依赖 dp[x - square],后者的下标一定小于 x。正序遍历能保证依赖状态已经计算完成。

为什么问: 检查是否理解 DP 的依赖方向。

4. 为什么这是完全背包,而不是 0-1 背包?

同一个完全平方数可以重复使用,例如 12 = 4 + 4 + 4。0-1 背包中的每件物品只能选择一次,不符合题意。

为什么问: 检查能否从“元素是否可重复使用”识别背包模型。

5. 为什么不能贪心选择不超过剩余值的最大平方数?

局部最大的平方数不一定属于全局最优方案。12 贪心选择 9 后需要三个 1,共 4 个;选择三个 4 只需 3 个。

为什么问: 检查能否给出反例,而不是只说“贪心不行”。

6. 动态规划和 BFS 有什么关系?

DP 按数值从小到大计算最短距离;BFS 把“减去一个平方数”视为一条边,按层寻找从 n0 的最短路径。二者都在解决无权图上的最少步数问题,只是组织状态的方式不同。

为什么问: 检查能否识别不同算法模型背后的共同结构。

7. 复杂度为什么是 O(n√n)

共有 n 个状态,每个状态最多枚举 √n 个完全平方数,因此上界为 O(n√n)

为什么问: 检查是否能按“状态数 × 每个状态的转移数”分析 DP。

8. 能否得到比 O(n√n) 更好的理论解法?

可以利用拉格朗日四平方和定理与勒让德三平方和定理,把答案限制在 1~4 并进行数学判断。但这种方法依赖数论定理,动态规划更通用,也更容易迁移到零钱兑换等问题。

为什么问: 检查是否了解问题的特殊数学性质,并能在专用解法与通用解法之间取舍。

常见错误回答

  • “枚举平方数即可”:没有说明子问题、状态转移和为什么取最小值。
  • “这是背包题”:没有指出每个平方数可重复使用,所以是完全背包。
  • “优先选最大的平方数”:忽略了 n = 12 的反例。
  • “时间复杂度是 O(n²)”:没有注意每个状态只枚举到 √x
  • “BFS 更快”:两种方法都可能访问大量状态,应结合实现、剪枝和输入规模分析,不能只凭算法名称判断。

可迁移总结

  • 核心关键词:最少数量、完全背包、最后一步、状态依赖、最短路径。
  • 一句话本质:枚举答案的最后一个平方数,将组成 x 转化为组成更小的 x - square
  • 因果链:生成平方数 → 正序计算较小状态 → 枚举最后一步 → 取最小值 → 得到 dp[n]
  • 可以迁移到零钱兑换、单词拆分、最少操作次数和无权图最短路径问题。
  • 1 分钟回答:说明 dp[x]、转移公式和 O(n√n) 复杂度。
  • 3 分钟回答:补充完全背包视角、n = 12 推演与贪心反例。
  • 10 分钟回答:写出代码和正确性证明,并比较 DP、BFS 与数论方案。

刷题后自测

  1. 为什么 dp[0] 必须初始化为 0
  2. 计算 dp[12] 时,需要比较哪些候选状态?
  3. 为什么按完全背包写法时,容量必须正序遍历?
  4. 请用 n = 12 说明“优先选择最大平方数”的贪心为什么错误。
  5. 如何把这道题转换成一张无权图上的最短路径问题?