279. 完全平方数 
先给结论
这道题可以看成零钱兑换:所有不大于 n 的完全平方数 1、4、9、16... 都是可以重复使用的“硬币”,目标是恰好凑出 n,并让使用的数字数量最少。
定义:
如果最后选择的完全平方数是 square,那么在此之前需要先组成 x - square:
依次计算 dp[1] 到 dp[n],最终返回 dp[n]。时间复杂度为 O(n√n),空间复杂度为 O(n)。
题目描述
给定正整数 n,返回和为 n 的完全平方数的最少数量。
完全平方数是一个整数的平方,例如 1、4、9、16;3 和 11 不是完全平方数。
示例 1:
示例 2:
问题本质
对于任意 x,答案的最后一个数一定是某个不大于 x 的完全平方数:
枚举最后选择的平方数后,原问题会变成已经计算过的更小子问题:
例如计算 dp[13]:
取三种选择中的最小值:
对应方案是 4 + 9。
一、定义 DP 状态
状态必须包含“组成 x”和“数量最少”两个信息。最终答案是:
二、确定状态转移
计算 dp[x] 时,枚举最后一个完全平方数 square = i * i。
只要 square <= x,就可以从 x - square 转移过来:
完整转移方程为:
这里的 + 1 表示选择了当前这个完全平方数 i²。
三、初始化
dp[0] = 0
组成数字 0 不需要选择任何数:
它也是所有转移的起点。例如:
其他状态初始化为 Infinity
计算每个状态时要取最小值,因此不能把未知状态初始化为 0,否则 0 会被误认为已经找到的最优答案。
本题也可以初始化为 dp[x] = x,因为任何正整数 x 都可以由 x 个 1 组成。但 Infinity 更直接地表达“尚未计算”。
四、遍历顺序
dp[x] 只依赖更小的 dp[x - square],所以 x 必须从小到大遍历:
当计算 dp[x] 时,所有 dp[x - square] 都已经计算完成。
五、n = 12 的逐步推演
不大于 12 的完全平方数是:
DP 数组逐步得到:
重点看 dp[12]:
代码实现
JavaScript:动态规划
代码与思路对照
为什么它是完全背包
将完全平方数看成物品:
例如组成 12 时,完全平方数 4 被使用了三次。因此每个平方数不是只能使用一次,而是可以重复选择,符合完全背包模型。
也可以先枚举平方数,再正序枚举目标值:
两种遍历方式都能得到最少数量:
正确性证明
对 x 从小到大进行归纳。
基础状态
dp[0] = 0 正确,因为组成 0 不需要任何完全平方数。
归纳步骤
假设所有小于 x 的状态都已经表示对应数字的最少数量。
任意组成 x 的合法方案都有最后一个完全平方数 square。删除这个数后,剩余部分之和为 x - square。根据归纳假设,组成剩余部分至少需要 dp[x - square] 个数,所以以 square 结尾的最优方案数量为:
算法枚举了所有可能的 square 并取最小值,因此不会遗漏更优方案,得到的 dp[x] 正确。由归纳可知 dp[n] 正确。
复杂度分析
对于每个 x,需要枚举 1 到 ⌊√x⌋:
- 时间复杂度:
O(n√n); - 空间复杂度:
O(n)。
生成平方数列表需要 O(√n) 空间,但可以像主代码一样在循环中直接计算,从而不额外保存。
替代解法:广度优先搜索
可以把每个剩余值看成图中的节点。从 n 出发,每次减去一个完全平方数:
BFS 第一次到达 0 时的层数,就是使用完全平方数的最少数量。
BFS 的“层数”对应使用数字的数量,因此天然适合求最少步数。加入 visited 可以避免不同组合反复访问同一个剩余值。
与零钱兑换的对比
本题一定有答案,因为 1 是完全平方数,最差也可以使用 n 个 1。
边界与陷阱
- 把
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 把“减去一个平方数”视为一条边,按层寻找从 n 到 0 的最短路径。二者都在解决无权图上的最少步数问题,只是组织状态的方式不同。
为什么问: 检查能否识别不同算法模型背后的共同结构。
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 与数论方案。
刷题后自测
- 为什么
dp[0]必须初始化为0? - 计算
dp[12]时,需要比较哪些候选状态? - 为什么按完全背包写法时,容量必须正序遍历?
- 请用
n = 12说明“优先选择最大平方数”的贪心为什么错误。 - 如何把这道题转换成一张无权图上的最短路径问题?

