1. 完全平方数问题解析
这道力扣hot100题目要求我们找到组成给定整数n所需的最少完全平方数数量。乍看简单,实则暗藏玄机。我第一次遇到这个问题时,直觉告诉我应该从最大的平方数开始尝试,但实际操作中发现这种贪心思路并不总是最优解。比如n=12时,最大的平方数是9,但12=9+1+1+1需要4个数,而最优解是12=4+4+4只需要3个数。
这个问题本质上属于完全背包问题的变种。我们可以把每个完全平方数看作一种"物品",其"重量"就是平方数本身,背包容量是n,每种物品可以无限取用。我们的目标是恰好装满背包,且使用的物品数量最少。
2. 动态规划解法详解
2.1 状态定义与转移方程
定义dp[i][j]表示使用前i个完全平方数(即1²,2²,...,i²)组成和为j所需的最少数量。状态转移需要考虑两种情况:
- 不使用i²:dp[i][j] = dp[i-1][j]
- 使用i²:dp[i][j] = dp[i][j-i²] + 1(前提是j≥i²)
取两者中的较小值作为最终结果。这个转移方程体现了动态规划的核心思想——将大问题分解为子问题。
注意:初始化时dp[0][0]=0,表示用0个数组成0需要0个数;dp[0][j]=∞(j>0)表示不可能用0个数组成正数。
2.2 空间优化技巧
二维DP会消耗O(n√n)空间,实际上可以优化为一维数组:
java复制public int numSquares(int n) {
int[] dp = new int[n + 1];
Arrays.fill(dp, Integer.MAX_VALUE);
dp[0] = 0;
for (int i = 1; i * i <= n; i++) {
int square = i * i;
for (int j = square; j <= n; j++) {
if (dp[j - square] != Integer.MAX_VALUE) {
dp[j] = Math.min(dp[j], dp[j - square] + 1);
}
}
}
return dp[n];
}
这个优化版本只使用O(n)空间,外层循环遍历所有可能的平方数,内层循环更新dp数组。
3. 数学解法:四平方和定理
3.1 理论基础
拉格朗日四平方和定理告诉我们:任何正整数都可以表示为不超过4个完全平方数的和。这意味着答案只可能是1、2、3或4。
基于这个定理,我们可以设计更高效的算法:
- 如果n本身就是完全平方数,返回1
- 检查是否能表示为两个平方数之和(即存在a使n-a²也是完全平方数)
- 根据勒让德三平方和定理,如果n=4^k(8m+7),则必须用4个数
- 其他情况用3个数
3.2 实现代码
java复制public int numSquares(int n) {
// 检查是否为完全平方数
if (isSquare(n)) return 1;
// 检查是否能表示为两个平方数之和
for (int i = 1; i * i <= n; i++) {
if (isSquare(n - i * i)) return 2;
}
// 检查是否符合4^k(8m+7)形式
while (n % 4 == 0) n /= 4;
if (n % 8 == 7) return 4;
return 3;
}
private boolean isSquare(int num) {
int sqrt = (int) Math.sqrt(num);
return sqrt * sqrt == num;
}
这个解法时间复杂度为O(√n),远优于动态规划解法。
4. 广度优先搜索解法
4.1 思路解析
将这个问题视为图的最短路径问题:每个数字代表一个节点,如果两个数字相差一个完全平方数,则它们之间有边相连。我们需要找到从0到n的最短路径。
4.2 代码实现
java复制public int numSquares(int n) {
Queue<Integer> queue = new LinkedList<>();
Set<Integer> visited = new HashSet<>();
queue.offer(0);
visited.add(0);
int level = 0;
while (!queue.isEmpty()) {
int size = queue.size();
level++;
for (int i = 0; i < size; i++) {
int current = queue.poll();
for (int j = 1; j * j <= n - current; j++) {
int next = current + j * j;
if (next == n) return level;
if (!visited.contains(next)) {
queue.offer(next);
visited.add(next);
}
}
}
}
return level;
}
BFS解法在n较小时效率很高,但当n很大时可能会消耗较多内存。
5. 性能对比与选择建议
| 方法 | 时间复杂度 | 空间复杂度 | 适用场景 |
|---|---|---|---|
| 动态规划 | O(n√n) | O(n) | 通用解法,易于理解 |
| 数学解法 | O(√n) | O(1) | n较大时最优 |
| BFS | O(n^k) | O(n) | n较小时效率高 |
在实际编程面试中,我建议:
- 首先考虑动态规划解法,因为它思路直接,易于解释
- 如果面试官要求优化,可以提出数学解法
- BFS解法可以作为备选,特别是当问题变形为求具体组合时
6. 常见错误与调试技巧
6.1 初始化错误
初学者常犯的错误是忘记初始化dp[0]=0。这会导致整个算法失效,因为所有状态都依赖于这个基础情况。
6.2 整数溢出
在计算平方数时,要注意ii不要超过整数最大值。可以在循环条件中使用i<=n/i而不是ii<=n来避免。
6.3 边界条件
特别注意n=0和n=1的情况:
- n=0时应该返回0(虽然题目通常n≥1)
- n=1时显然返回1
6.4 记忆化搜索实现要点
如果使用递归+记忆化的方法,要注意:
- 记忆化数组要足够大
- 初始值要设为特殊值(如-1)以区分已计算和未计算状态
- 递归终止条件要全面
7. 实际应用与变种问题
这个问题看似简单,但在实际中有重要应用,比如:
- 密码学中的数字分解
- 图像处理中的像素填充
- 金融中的零钱兑换问题(本质相同)
常见的变种问题包括:
- 输出具体的平方数组合而不仅仅是数量
- 限制使用的平方数的范围(如只能用前m个平方数)
- 每个平方数有不同成本,求最小总成本
我在实际项目中曾用类似思路解决过一个资源分配问题,需要将固定数量的资源分配到多个容器中,每个容器的容量必须是完全平方数。理解这个基础问题的多种解法为后续工作打下了坚实基础。