# 完全平方数 (Perfect Squares) LeetCode 279. Medium ## 题目描述 给你一个整数 `n`,返回和为 `n` 的完全平方数的最少数量。 **完全平方数**:一个整数等于其平方的整数,如 1, 4, 9, 16 等。 **示例 1**: ``` 输入:n = 12 输出:3 解释:12 = 4 + 4 + 4 ``` **示例 2**: ``` 输入:n = 13 输出:2 解释:13 = 4 + 9 ``` **示例 3**: ``` 输入:n = 1 输出:1 解释:1 = 1 ``` ## 思路推导 ### 暴力解法分析 **最直观的思路**:尝试所有可能的完全平方数组合。 ```python def numSquares(n): if int(n**0.5)**2 == n: return 1 # 尝试两个数的和 for i in range(1, int(n**0.5) + 1): if int((n - i*i)**0.5)**2 == n - i*i: return 2 # 尝试三个数的和 # ...(代码会非常复杂) # 根据拉格朗日四平方定理,最多需要 4 个数 return 4 ``` **时间复杂度**:难以估计,取决于实现 - 最坏情况可能需要枚举所有组合 - 代码复杂,难以维护 **空间复杂度**:O(1) **问题分析**: 1. 实现复杂:需要处理多种情况 2. 难以理解:代码逻辑不清晰 3. 难以扩展:无法处理变体问题 ### 优化思考 - 第一步:动态规划 **观察**:问题具有最优子结构 **关键问题**:如何定义子问题? **定义**:`dp[i]` = 和为 `i` 的完全平方数的最少数量 **状态转移方程**: ``` dp[i] = min(dp[i - j*j] + 1) for all j where j*j <= i ``` **为什么这样思考?** - 如果我们选择了 `j*j`,那么问题变成 `i - j*j` - `dp[i - j*j]` 是已知的子问题 - 我们只需要枚举所有可能的 `j` **优化后的思路**: ```python dp = [0] * (n + 1) for i in range(1, n + 1): dp[i] = min(dp[i - j*j] + 1 for j in range(1, int(i**0.5) + 1)) ``` **时间复杂度**:O(n√n) - 外层循环:O(n) - 内层循环:O(√n) - 总计:O(n) × O(√n) = O(n√n) **空间复杂度**:O(n) - dp 数组:O(n) ### 优化思考 - 第二步:数学优化(拉格朗日四平方定理) **定理**:任何正整数都可以表示为最多 4 个完全平方数的和。 **推论**: 1. 如果 `n` 是完全平方数,返回 1 2. 如果 `n` 可以表示为两个完全平方数的和,返回 2 3. 如果 `n` 满足特定条件(勒让德三平方定理),返回 3 4. 否则返回 4 **为什么这样思考?** - 数学定理可以快速判断 - 避免动态规划的高复杂度 - 时间复杂度可以降到 O(√n) ### 优化思考 - 第三步:BFS 最短路径 **观察**:问题可以转化为图的最短路径问题 **建模**: - 节点:数字 `i` - 边:从 `i` 到 `i - j*j`(如果 `j*j <= i`) - 权重:每条边的权重都是 1 **目标**:从 `n` 到 `0` 的最短路径 **为什么这样思考?** - BFS 天然适合求最短路径 - 可以提前终止(找到 0 就停止) - 比动态规划更快(实际运行时间) **时间复杂度**:O(√n)^h,h 是答案 - 最坏情况:h = 4 - 实际运行:比 O(n√n) 快很多 ## 解题思路 ### 核心思想 **动态规划**:将问题分解为子问题,从底向上求解。 **为什么这样思考?** 1. **最优子结构**: - `n` 的最优解依赖于 `n - j*j` 的最优解 - 子问题重叠,可以重复使用 2. **无后效性**: - `dp[i]` 只依赖于比 `i` 小的值 - 不依赖于计算路径 3. **边界明确**: - `dp[0] = 0`(0 个完全平方数) - `dp[1] = 1`(1 = 1) ### 详细算法流程 **步骤1:初始化 dp 数组** ```python dp = [float('inf')] * (n + 1) dp[0] = 0 # 边界条件 ``` **作用**: - `dp[i]` 表示和为 `i` 的完全平方数的最少数量 - 初始化为无穷大,表示未计算 - `dp[0] = 0` 是递归的基础 **步骤2:填表** ```python for i in range(1, n + 1): # 尝试所有可能的完全平方数 j*j for j in range(1, int(i**0.5) + 1): dp[i] = min(dp[i], dp[i - j*j] + 1) ``` **关键点详解**: 1. **为什么外层循环从 1 到 n?** - 从小到大计算,确保 `dp[i - j*j]` 已经计算过 - `i - j*j < i`,所以已经计算过 2. **为什么内层循环从 1 到 √i?** - `j*j` 必须小于等于 `i` - 最大的 `j` 是 `√i` - 示例:`i = 12`,`j` 最大为 3(3² = 9 ≤ 12) 3. **为什么是 `dp[i - j*j] + 1`?** - `dp[i - j*j]` 是和为 `i - j*j` 的最少数量 - 加上 1 表示再加上 `j*j` - 示例:`dp[12] = dp[12 - 4] + 1 = dp[8] + 1` **示例**: ``` n = 12 i=1: dp[1] = dp[1-1] + 1 = dp[0] + 1 = 1 i=2: dp[2] = dp[2-1] + 1 = dp[1] + 1 = 2 i=3: dp[3] = dp[3-1] + 1 = dp[2] + 1 = 3 i=4: dp[4] = dp[4-4] + 1 = dp[0] + 1 = 1 i=5: dp[5] = min(dp[5-1]+1, dp[5-4]+1) = min(dp[4]+1, dp[1]+1) = min(2, 2) = 2 ... i=12: dp[12] = min(dp[12-1]+1, dp[12-4]+1, dp[12-9]+1) = min(dp[11]+1, dp[8]+1, dp[3]+1) = min(3, 2, 4) = 2 ``` ### 关键细节说明 **细节1:为什么 `dp[0] = 0`?** ```python dp[0] = 0 # 0 可以由 0 个完全平方数组成 ``` **原因**: - 0 是递归的终止条件 - 表示不需要任何完全平方数就能组成 0 - 类似于数学中的"空和为 0" **细节2:为什么初始化为 `float('inf')`?** ```python dp = [float('inf')] * (n + 1) dp[0] = 0 ``` **原因**: - 无穷大表示"未计算"或"不可达" - `min(inf, x) = x`,确保第一次更新正确 - 避免使用 0 初始化导致 `min(0, x)` 错误 **细节3:为什么用 `int(i**0.5) + 1`?** ```python for j in range(1, int(i**0.5) + 1): # ... ``` **原因**: - `int(i**0.5)` 是 `√i` 的整数部分 - `+1` 是因为 `range` 是左闭右开区间 - 示例:`i = 4`,`int(4**0.5) = 2`,`range(1, 3)` → `[1, 2]` ✓ **细节4:为什么可以保证最优解?** ```python dp[i] = min(dp[i - j*j] + 1 for all valid j) ``` **原因**: - 枚举了所有可能的第一个完全平方数 `j*j` - `dp[i - j*j]` 已经是最优解(归纳假设) - `min` 确保选择了最优的组合 - 动态规划的"最优子结构"性质 ### 边界条件分析 **边界1:n = 0** ``` 输入:n = 0 输出:0 解释: dp[0] = 0 0 个完全平方数组成 0 ``` **边界2:n = 1** ``` 输入:n = 1 输出:1 过程: dp[1] = dp[1-1] + 1 = dp[0] + 1 = 1 ``` **边界3:n 是完全平方数** ``` 输入:n = 9 输出:1 过程: dp[9] = min(dp[9-1]+1, dp[9-4]+1, dp[9-9]+1) = min(dp[8]+1, dp[5]+1, dp[0]+1) = min(3, 3, 1) = 1 ``` **边界4:n = 12** ``` 输入:n = 12 输出:3 过程: dp[12] = min(dp[12-1]+1, dp[12-4]+1, dp[12-9]+1) = min(dp[11]+1, dp[8]+1, dp[3]+1) = min(4, 3, 4) = 3 验证:12 = 4 + 4 + 4 ✓ ``` ### 复杂度分析(详细版) **时间复杂度**: ``` - 外层循环:O(n),从 1 到 n - 内层循环:O(√n),从 1 到 √i - 总计:O(n) × O(√n) = O(n√n) 为什么是 O(n√n)? - 对于每个 i,最多有 √i 个 j - √i ≤ √n - 总操作次数 ≈ n × √n = n√n ``` **空间复杂度**: ``` - dp 数组:O(n) - 其他变量:O(1) - 总计:O(n) ``` --- ## 图解过程 ``` n = 12 初始化:dp = [0, inf, inf, inf, inf, inf, inf, inf, inf, inf, inf, inf, inf] i=1: j=1 dp[1] = dp[1-1] + 1 = dp[0] + 1 = 1 dp = [0, 1, inf, inf, inf, inf, inf, inf, inf, inf, inf, inf, inf] i=2: j=1 dp[2] = dp[2-1] + 1 = dp[1] + 1 = 2 dp = [0, 1, 2, inf, inf, inf, inf, inf, inf, inf, inf, inf, inf] i=3: j=1 dp[3] = dp[3-1] + 1 = dp[2] + 1 = 3 dp = [0, 1, 2, 3, inf, inf, inf, inf, inf, inf, inf, inf, inf] i=4: j=1,2 dp[4] = min(dp[4-1]+1, dp[4-4]+1) = min(dp[3]+1, dp[0]+1) = min(4, 1) = 1 dp = [0, 1, 2, 3, 1, inf, inf, inf, inf, inf, inf, inf, inf] i=5: j=1,2 dp[5] = min(dp[5-1]+1, dp[5-4]+1) = min(dp[4]+1, dp[1]+1) = min(2, 2) = 2 dp = [0, 1, 2, 3, 1, 2, inf, inf, inf, inf, inf, inf, inf] i=6: j=1,2 dp[6] = min(dp[6-1]+1, dp[6-4]+1) = min(dp[5]+1, dp[2]+1) = min(3, 3) = 3 dp = [0, 1, 2, 3, 1, 2, 3, inf, inf, inf, inf, inf, inf] i=7: j=1,2 dp[7] = min(dp[7-1]+1, dp[7-4]+1) = min(dp[6]+1, dp[3]+1) = min(4, 4) = 4 dp = [0, 1, 2, 3, 1, 2, 3, 4, inf, inf, inf, inf, inf] i=8: j=1,2 dp[8] = min(dp[8-1]+1, dp[8-4]+1) = min(dp[7]+1, dp[4]+1) = min(5, 2) = 2 dp = [0, 1, 2, 3, 1, 2, 3, 4, 2, inf, inf, inf, inf] i=9: j=1,2,3 dp[9] = min(dp[9-1]+1, dp[9-4]+1, dp[9-9]+1) = min(dp[8]+1, dp[5]+1, dp[0]+1) = min(3, 3, 1) = 1 dp = [0, 1, 2, 3, 1, 2, 3, 4, 2, 1, inf, inf, inf] i=10: j=1,2,3 dp[10] = min(dp[10-1]+1, dp[10-4]+1, dp[10-9]+1) = min(dp[9]+1, dp[6]+1, dp[1]+1) = min(2, 4, 2) = 2 dp = [0, 1, 2, 3, 1, 2, 3, 4, 2, 1, 2, inf, inf] i=11: j=1,2,3 dp[11] = min(dp[11-1]+1, dp[11-4]+1, dp[11-9]+1) = min(dp[10]+1, dp[7]+1, dp[2]+1) = min(3, 5, 3) = 3 dp = [0, 1, 2, 3, 1, 2, 3, 4, 2, 1, 2, 3, inf] i=12: j=1,2,3 dp[12] = min(dp[12-1]+1, dp[12-4]+1, dp[12-9]+1) = min(dp[11]+1, dp[8]+1, dp[3]+1) = min(4, 3, 4) = 3 dp = [0, 1, 2, 3, 1, 2, 3, 4, 2, 1, 2, 3, 3] 结果:dp[12] = 3 ``` --- ## 代码实现 ```go func numSquares(n int) int { dp := make([]int, n+1) // 初始化为最大值 for i := range dp { dp[i] = math.MaxInt32 } dp[0] = 0 // 填表 for i := 1; i <= n; i++ { for j := 1; j*j <= i; j++ { if dp[i-j*j]+1 < dp[i] { dp[i] = dp[i-j*j] + 1 } } } return dp[n] } ``` **关键点**: 1. `dp[0] = 0` 是边界条件 2. 从小到大计算,确保子问题已解决 3. 枚举所有可能的完全平方数 --- ## 执行过程演示 **输入**:n = 12 ``` 初始化:dp = [0, 2147483647, 2147483647, 2147483647, 2147483647, 2147483647, 2147483647, 2147483647, 2147483647, 2147483647, 2147483647, 2147483647, 2147483647] i=1: j=1 dp[1] = min(2147483647, dp[0]+1) = 1 i=2: j=1 dp[2] = min(2147483647, dp[1]+1) = 2 i=3: j=1 dp[3] = min(2147483647, dp[2]+1) = 3 i=4: j=1,2 j=1: dp[4] = min(2147483647, dp[3]+1) = 4 j=2: dp[4] = min(4, dp[0]+1) = 1 i=5: j=1,2 j=1: dp[5] = min(2147483647, dp[4]+1) = 2 j=2: dp[5] = min(2, dp[1]+1) = 2 i=6: j=1,2 j=1: dp[6] = min(2147483647, dp[5]+1) = 3 j=2: dp[6] = min(3, dp[2]+1) = 3 i=7: j=1,2 j=1: dp[7] = min(2147483647, dp[6]+1) = 4 j=2: dp[7] = min(4, dp[3]+1) = 4 i=8: j=1,2 j=1: dp[8] = min(2147483647, dp[7]+1) = 5 j=2: dp[8] = min(5, dp[4]+1) = 2 i=9: j=1,2,3 j=1: dp[9] = min(2147483647, dp[8]+1) = 3 j=2: dp[9] = min(3, dp[5]+1) = 3 j=3: dp[9] = min(3, dp[0]+1) = 1 i=10: j=1,2,3 j=1: dp[10] = min(2147483647, dp[9]+1) = 2 j=2: dp[10] = min(2, dp[6]+1) = 2 j=3: dp[10] = min(2, dp[1]+1) = 2 i=11: j=1,2,3 j=1: dp[11] = min(2147483647, dp[10]+1) = 3 j=2: dp[11] = min(3, dp[7]+1) = 3 j=3: dp[11] = min(3, dp[2]+1) = 3 i=12: j=1,2,3 j=1: dp[12] = min(2147483647, dp[11]+1) = 4 j=2: dp[12] = min(4, dp[8]+1) = 3 j=3: dp[12] = min(3, dp[3]+1) = 3 结果:dp[12] = 3 ``` --- ## 常见错误 ### 错误1:初始化为 0 ❌ **错误代码**: ```go dp := make([]int, n+1) // dp 默认为 0 for i := 1; i <= n; i++ { for j := 1; j*j <= i; j++ { dp[i] = min(dp[i], dp[i-j*j]+1) // 错误!dp[i] 初始为 0 } } ``` ✅ **正确代码**: ```go dp := make([]int, n+1) for i := range dp { dp[i] = math.MaxInt32 // 初始化为最大值 } dp[0] = 0 ``` **原因**: - `dp[i]` 初始为 0,`min(0, x)` 永远是 0 - 导致结果错误 --- ### 错误2:循环范围错误 ❌ **错误代码**: ```go for j := 1; j <= int(math.Sqrt(float64(n))); j++ { // 错误!用了 n 而不是 i } ``` ✅ **正确代码**: ```go for j := 1; j*j <= i; j++ { // 正确!用 i } ``` **原因**: - 应该是 `j*j <= i`,不是 `j*j <= n` - 用 n 会导致越界或计算错误 --- ### 错误3:忘记边界条件 ❌ **错误代码**: ```go dp := make([]int, n+1) // 忘记设置 dp[0] = 0 ``` ✅ **正确代码**: ```go dp := make([]int, n+1) dp[0] = 0 // 边界条件 ``` **原因**: - `dp[0] = 0` 是递归的基础 - 没有它,`dp[i-j*j]` 无法正确计算 --- ## 进阶问题 ### Q1: 如何优化空间复杂度? **思路**:使用贪心算法或数学定理 ```go // 基于拉格朗日四平方定理 func numSquaresOptimized(n int) int { // 检查是否为完全平方数 if isPerfectSquare(n) { return 1 } // 检查是否可以表示为两个完全平方数的和 for i := 1; i*i <= n; i++ { if isPerfectSquare(n - i*i) { return 2 } } // 检查是否满足勒让德三平方定理 // n != 4^a * (8b + 7) if n == 4*n { return 4 } return 3 } func isPerfectSquare(n int) bool { sqrt := int(math.Sqrt(float64(n))) return sqrt*sqrt == n } ``` **时间复杂度**:O(√n) --- ### Q2: 如何返回具体的完全平方数组合? ```go func numSquaresWithSolution(n int) ([]int, int) { dp := make([]int, n+1) prev := make([]int, n+1) // 记录前一个状态 for i := range dp { dp[i] = math.MaxInt32 } dp[0] = 0 for i := 1; i <= n; i++ { for j := 1; j*j <= i; j++ { if dp[i-j*j]+1 < dp[i] { dp[i] = dp[i-j*j] + 1 prev[i] = j * j // 记录选择的完全平方数 } } } // 回溯构建解 solution := []int{} curr := n for curr > 0 { square := prev[curr] solution = append(solution, square) curr -= square } return solution, dp[n] } ``` --- ## P7 加分项 ### 深度理解 - **最优子结构**:问题的最优解包含子问题的最优解 - **重叠子问题**:子问题会被重复计算,动态规划避免重复 - **数学定理**:拉格朗日四平方定理可以优化算法 ### 实战扩展 - **背包问题**:完全背包的变种 - **最短路径**:可以转化为 BFS 问题 - **业务场景**:货币兑换、资源分配 ### 变形题目 1. 最少硬币数量(硬币面值不同) 2. 完全平方数的所有组合 3. 限制完全平方数的使用次数 --- ## 总结 **核心要点**: 1. **动态规划**:从底向上,逐步求解 2. **状态转移**:`dp[i] = min(dp[i - j*j] + 1)` 3. **边界条件**:`dp[0] = 0` **易错点**: - 初始化错误(0 vs MaxInt32) - 循环范围错误(n vs i) - 忘记边界条件 **最优解法**:动态规划,时间 O(n√n),空间 O(n)