279. 完全平方数#
还记得小学时学过的平方数吗?1, 4, 9, 16, 25…这些数有着独特的魅力。今天我们要聊的 LeetCode 279 题就和这些数字有关,它将带我们领略动态规划与数论的完美结合。
🎯 问题本质#
给你一个正整数 n,要求找到最少需要多少个完全平方数相加得到 n。比如:
输入:n = 12
输出:3
解释:12 = 4 + 4 + 4plaintext输入:n = 13
输出:2
解释:13 = 4 + 9plaintext乍看这道题,你可能会想:“要不要把所有可能的组合都试一遍?“但等等,让我们用动态规划的思维来思考这个问题。
问题描述#
题目目标#
给定一个整数 n,返回和为 n 的完全平方数的最少数量。完全平方数指某个整数自乘得到的数,例如 1、4、9、16。
示例 1#
输入: n = 12
输出: 3
拆分示意:
12 = 4 + 4 + 4text说明: 12 最少可以由 3 个完全平方数组成,因此答案是 3。
示例 2#
输入: n = 13
输出: 2
拆分示意:
13 = 4 + 9text说明: 13 可以拆成两个完全平方数之和,所以最优答案是 2。
补充说明#
- 每个完全平方数可以重复使用。
- 题目本质上是在求“最少步数”或“最少物品数”,很适合用动态规划建模。
💡 思维转变:从暴力到动态规划#
想象你正在玩一个数字游戏。要拼出数字n,你每次可以选择一个完全平方数。这不就像是用最少的硬币凑出一定金额吗?这种联想让我们找到了突破口。
🔍 动态规划的设计过程#
让我们一步步构建解决方案:
1. 定义状态#
dp[i] 表示组成数字 i 需要的最少完全平方数个数。这个定义直接对应我们要解决的问题。
2. 寻找状态转移方程#
假设我们现在要求 dp[n],我们可以:
- 先选一个完全平方数 j*j
- 然后问题就变成了求 dp[n - j*j]
- 遍历所有可能的 j,取最小值
所以状态转移方程就呼之欲出了:
dp[i] = min(dp[i], dp[i - j*j] + 1),其中 j*j ≤ i
class Solution {
public int numSquares(int n) {
// dp[i] 表示“组成整数 i 所需的最少完全平方数个数”
int[] dp = new int[n + 1];
// 初始化为最大值,表示当前还未找到可行方案(后续通过取最小值更新)
Arrays.fill(dp, Integer.MAX_VALUE);
// 基础状态:组成 0 不需要任何数字
dp[0] = 0;
// 预处理:先计算所有 <= n 的完全平方数,避免在双循环中重复乘法
int maxSquareRoot = (int)Math.sqrt(n); // n 的最大平方根
int[] squares = new int[maxSquareRoot + 1]; // squares[i] = i*i(下标从 1 开始使用)
for (int i = 1; i <= maxSquareRoot; i++) {
// 记录第 i 个完全平方数
squares[i] = i * i;
}
// 动态规划主过程:从小到大计算每个 i 的最优解
for (int i = 1; i <= n; i++) {
// 枚举所有 <= i 的完全平方数作为“最后一步”
for (int j = 1; squares[j] <= i; j++) {
// 状态转移:dp[i] = min(dp[i], dp[i - square] + 1)
// 含义:先凑出 i - squares[j],再加上这个平方数 squares[j]
dp[i] = Math.min(dp[i], dp[i - squares[j]] + 1);
}
}
// 返回组成 n 的最少完全平方数个数
return dp[n];
}
// 数学方法:四平方和定理
public int numSquaresMath(int n) {
// 情况 1:n 本身是完全平方数,答案直接为 1
if (isSquare(n)) return 1;
// 情况 2:判断是否能表示成两个完全平方数之和
for (int i = 1; i * i <= n; i++) {
// 若 n - i*i 也是完全平方数,则答案为 2
if (isSquare(n - i * i)) return 2;
}
// 根据四平方和定理的推论,先剥离 4 的因子,不影响最终分类
while (n % 4 == 0) n /= 4;
// 若化简后满足 n % 8 == 7,则答案必为 4
if (n % 8 == 7) return 4;
// 其余情况既不是 1、2、4,则答案一定为 3
return 3;
}
private boolean isSquare(int n) {
// 先取整数平方根
int sqrt = (int)Math.sqrt(n);
// 通过平方回验,判断是否是完全平方数
return sqrt * sqrt == n;
}
}
java🎯 代码的精髓#
我们的解法提供了两种思路:动态规划和数学方法。让我们深入理解动态规划解法的每个部分:
-
预处理完全平方数:
- 提前计算所有可能用到的完全平方数
- 避免重复计算,提高效率
-
状态转移的实现:
- 对每个数i,尝试减去每个小于它的完全平方数
- 取所有可能情况的最小值
- 这个过程直观地体现了”选择”的概念
-
初始化的考虑:
- dp[0] = 0 作为基础case
- 其他位置初始化为最大值,方便取最小值
💡 优化的艺术:数学方法#
除了动态规划,这道题还有一个令人惊叹的数学解法 —— 四平方和定理:任何自然数都可以表示为最多四个完全平方数的和。
更进一步,我们可以证明:
- 当n本身是完全平方数时,答案是1
- 当n可以表示为两个完全平方数之和时,答案是2
- 当n = 4^k * (8m + 7)时,答案是4
- 其他情况下,答案是3
🤔 思考题#
如果问题变成:要求所有加数都必须是不同的完全平方数,解法会有什么变化?提示:状态的定义可能需要加入”已使用的最大完全平方数”这个维度。
📝 面试技巧#
在面试中遇到这道题,建议这样展示你的思路:
-
先说明动态规划的思路:
- 定义状态表示的含义
- 推导状态转移方程
- 解释为什么这个方程是正确的
-
然后可以提到优化方向:
- 预处理完全平方数
- 提到数学方法的存在(加分项!)
记住,面试官更关心你的思维过程,而不仅仅是最终的解法。通过清晰地表达你如何一步步构建解决方案,你能展示出自己的问题解决能力。
有人说算法是数学的艺术,这道题完美地诠释了这一点。它告诉我们,有时候一个问题可以有多种解法,而找到这些解法的过程,正是我们提升算法思维的过程。