题目描述
给定一个正整数 n,返回和为 n 的完全平方数的最少数量。
完全平方数是某个整数的平方,例如 1、4、9 和 16。
例如:
输入:n = 12
输出:3
解释:12 = 4 + 4 + 4。
原始思路:递归枚举
可以把每个完全平方数看作一次选择。选择一个平方数后,递归计算剩余数还需要多少个平方数。核心是对所有候选方案取最小值,而不是让后一次赋值覆盖前一次结果。
伪代码:
函数 dfs(x):
如果 x <= 0:
返回 0
best <- 正无穷
对每个不超过 x 的完全平方数 square:
best <- min(best, 1 + dfs(x - square))
返回 best
对应 Java 代码:
class Solution {
public int dfs(int n) {
if (n <= 0) return 0;
int best = Integer.MAX_VALUE;
for (int j = 1; j <= n; j++) {
if (Math.sqrt(j) * Math.sqrt(j) == j) {
best = Math.min(best, dfs(n - j) + 1);
}
}
return best;
}
public int numSquares(int n) {
return dfs(n);
}
}
这种递归会重复计算相同的剩余值,时间复杂度较高,因此需要缓存状态。
状态定义与转移
定义 dfs(i, j) 为使用前 i 个完全平方数凑出 j 所需的最少数量,其中第 i 个候选平方数为 i*i。
对 i*i 有两种选择:不使用它,转移到 dfs(i - 1, j);使用它,转移到 dfs(i, j - i*i) + 1。使用后仍保留 i,因为完全平方数可以重复使用,例如 12 = 4 + 4 + 4。
dfs(i, j) = min(dfs(i - 1, j), dfs(i, j - i*i) + 1)
当 i = 0 时,j = 0 返回 0,j > 0 表示不可达。不可达状态使用 Integer.MAX_VALUE / 2 表示,避免后续加 1 时整数溢出。
记忆化搜索
伪代码:
函数 dfs(i, j):
如果 i == 0:
如果 j == 0,返回 0
否则返回 INF
如果 i*i > j:
返回 dfs(i - 1, j)
如果 memo[i][j] 已计算:
返回 memo[i][j]
memo[i][j] <- min(dfs(i - 1, j), dfs(i, j - i*i) + 1)
返回 memo[i][j]
对应 Java 代码:
class Solution {
int[][] memo = new int[101][10001];
public int dfs(int i, int j) {
if (i == 0) {
return j == 0 ? 0 : Integer.MAX_VALUE / 2;
}
if (i * i > j) {
return memo[i][j] = dfs(i - 1, j);
}
if (memo[i][j] != 0) {
return memo[i][j];
}
return memo[i][j] = Math.min(
dfs(i - 1, j),
dfs(i, j - i * i) + 1
);
}
public int numSquares(int n) {
return dfs((int) Math.sqrt(n), n);
}
}
易错点
1. 直接覆盖最优结果
尝试多个平方数时必须持续取 Math.min,不能只把当前候选直接赋给结果。
2. 共享成员变量 best
每个状态都应有自己的最优值。把 best 作为成员变量会让子问题覆盖父问题的计算,应使用局部变量或直接写入当前 memo[i][j]。
3. 错误减少 i
使用 i*i 后必须递归到 dfs(i, j - i*i),否则会禁止重复使用当前平方数。
4. 无穷大加一溢出
不能让 Integer.MAX_VALUE 参与 + 1,应使用 Integer.MAX_VALUE / 2 或先判断不可达状态。
建议测试用例
n = 1 -> 1
n = 4 -> 1
n = 12 -> 3
n = 13 -> 2
复杂度分析
- 时间复杂度:
O(n√n),二维状态数量为O(n√n)。 - 空间复杂度:
O(n√n),主要来自二维memo数组。
复盘
本题的关键是明确两个状态维度、保留当前平方数以支持重复使用,并在所有方案中取最小值。最终递推式为:
dfs(i, j) = min(dfs(i - 1, j), dfs(i, j - i*i) + 1)