Files
interview/16-LeetCode Hot 100/完全平方数.md
yasinshaw 5c1c974e88 docs: 改进LeetCode二叉树题目解题思路
按照改进方案,为以下6个二叉树题目增强了解题思路的详细程度:

1. 二叉树的中序遍历
   - 增加"思路推导"部分,解释递归到迭代的转换
   - 详细说明迭代法的每个步骤
   - 增加执行过程演示和多种解法

2. 二叉树的最大深度
   - 增加"思路推导",对比DFS和BFS
   - 详细解释递归的基准情况
   - 增加多种解法和变体问题

3. 从前序与中序遍历序列构造二叉树
   - 详细解释前序和中序的特点
   - 增加"思路推导",说明如何分治
   - 详细说明切片边界计算

4. 对称二叉树
   - 解释镜像对称的定义
   - 详细说明递归比较的逻辑
   - 增加迭代解法和变体问题

5. 翻转二叉树
   - 解释翻转的定义和过程
   - 详细说明多值赋值的执行顺序
   - 增加多种解法和有趣的故事

6. 路径总和
   - 详细解释路径和叶子节点的定义
   - 说明为什么使用递减而非累加
   - 增加多种解法和变体问题

每个文件都包含:
- 完整的示例和边界条件分析
- 详细的算法流程和图解
- 关键细节说明
- 常见错误分析
- 复杂度分析(详细版)
- 执行过程演示
- 多种解法
- 变体问题
- 总结

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-03-08 21:33:57 +08:00

14 KiB
Raw Blame History

完全平方数 (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

思路推导

暴力解法分析

最直观的思路:尝试所有可能的完全平方数组合。

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

优化后的思路

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
  • 边:从 ii - j*j(如果 j*j <= i
  • 权重:每条边的权重都是 1

目标:从 n0 的最短路径

为什么这样思考?

  • BFS 天然适合求最短路径
  • 可以提前终止(找到 0 就停止)
  • 比动态规划更快(实际运行时间)

时间复杂度O(√n)^hh 是答案

  • 最坏情况h = 4
  • 实际运行:比 O(n√n) 快很多

解题思路

核心思想

动态规划:将问题分解为子问题,从底向上求解。

为什么这样思考?

  1. 最优子结构

    • n 的最优解依赖于 n - j*j 的最优解
    • 子问题重叠,可以重复使用
  2. 无后效性

    • dp[i] 只依赖于比 i 小的值
    • 不依赖于计算路径
  3. 边界明确

    • dp[0] = 00 个完全平方数)
    • dp[1] = 11 = 1

详细算法流程

步骤1初始化 dp 数组

dp = [float('inf')] * (n + 1)
dp[0] = 0  # 边界条件

作用

  • dp[i] 表示和为 i 的完全平方数的最少数量
  • 初始化为无穷大,表示未计算
  • dp[0] = 0 是递归的基础

步骤2填表

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 = 12j 最大为 33² = 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

dp[0] = 0  # 0 可以由 0 个完全平方数组成

原因

  • 0 是递归的终止条件
  • 表示不需要任何完全平方数就能组成 0
  • 类似于数学中的"空和为 0"

细节2为什么初始化为 float('inf')

dp = [float('inf')] * (n + 1)
dp[0] = 0

原因

  • 无穷大表示"未计算"或"不可达"
  • min(inf, x) = x,确保第一次更新正确
  • 避免使用 0 初始化导致 min(0, x) 错误

细节3为什么用 int(i**0.5) + 1

for j in range(1, int(i**0.5) + 1):
    # ...

原因

  • int(i**0.5)√i 的整数部分
  • +1 是因为 range 是左闭右开区间
  • 示例:i = 4int(4**0.5) = 2range(1, 3)[1, 2]

细节4为什么可以保证最优解

dp[i] = min(dp[i - j*j] + 1 for all valid j)

原因

  • 枚举了所有可能的第一个完全平方数 j*j
  • dp[i - j*j] 已经是最优解(归纳假设)
  • min 确保选择了最优的组合
  • 动态规划的"最优子结构"性质

边界条件分析

边界1n = 0

输入n = 0
输出0
解释:
  dp[0] = 0
  0 个完全平方数组成 0

边界2n = 1

输入n = 1
输出1
过程:
  dp[1] = dp[1-1] + 1 = dp[0] + 1 = 1

边界3n 是完全平方数

输入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

边界4n = 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

代码实现

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

错误代码

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
    }
}

正确代码

dp := make([]int, n+1)
for i := range dp {
    dp[i] = math.MaxInt32  // 初始化为最大值
}
dp[0] = 0

原因

  • dp[i] 初始为 0min(0, x) 永远是 0
  • 导致结果错误

错误2循环范围错误

错误代码

for j := 1; j <= int(math.Sqrt(float64(n))); j++ {
    // 错误!用了 n 而不是 i
}

正确代码

for j := 1; j*j <= i; j++ {
    // 正确!用 i
}

原因

  • 应该是 j*j <= i,不是 j*j <= n
  • 用 n 会导致越界或计算错误

错误3忘记边界条件

错误代码

dp := make([]int, n+1)
// 忘记设置 dp[0] = 0

正确代码

dp := make([]int, n+1)
dp[0] = 0  // 边界条件

原因

  • dp[0] = 0 是递归的基础
  • 没有它,dp[i-j*j] 无法正确计算

进阶问题

Q1: 如何优化空间复杂度?

思路:使用贪心算法或数学定理

// 基于拉格朗日四平方定理
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: 如何返回具体的完全平方数组合?

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)