📖 第74课:完全平方数

3 阅读17分钟

想系统提升编程能力、查看更完整的学习路线,欢迎访问 AI Compass:github.com/tingaicompa… 仓库持续更新刷题题解、Python 基础和 AI 实战内容,适合想高效进阶的你。

📖 第74课:完全平方数

模块:动态规划 | 难度:Medium ⭐⭐ LeetCode 链接:leetcode.cn/problems/pe… 前置知识:第71课《爬楼梯》、第73课《打家劫舍》 预计学习时间:25分钟


🎯 题目描述

给定一个正整数 n,找到若干个完全平方数(如 1、4、9、16...)使得它们的和等于 n。你需要返回和为 n 的完全平方数的最少数量

完全平方数是整数的平方,如 1²=1、2²=4、3²=9...

示例:

输入:n = 12
输出:3
解释:12 = 4 + 4 + 4 (三个平方数)

输入:n = 13
输出:2
解释:13 = 4 + 9 (两个平方数)

约束条件:

  • 1 <= n <= 10⁴
  • 必须使用完全平方数
  • 求最少数量

🧪 边界用例(面试必考)

用例类型输入期望输出考察点
本身是平方数n = 41只需一个数(2²)
最小输入n = 111² = 1
需要拆分n = 1234+4+4
两数之和n = 1324+9
大规模n = 10000需要DP避免超时性能边界

💡 思路引导

生活化比喻

想象你在玩一个"凑硬币"游戏,手里有面值为1²、2²、3²...的特殊硬币(数量无限),要用最少的硬币凑出目标金额n。

🐌 笨办法:用回溯枚举所有可能的组合(第1个选1?4?9?... 第2个选1?4?9?...),时间复杂度爆炸!

🚀 聪明办法:从小到大填表,计算"凑出金额i需要的最少硬币数"。到金额j时,尝试"上一步凑出j-1²,再加1个1²硬币"、"上一步凑出j-2²,再加1个2²硬币"...取最小值。这就是完全背包DP的核心思想。

关键洞察

凑出金额n,可以看作"先凑出n-k²,再加一个k²",遍历所有可能的k,取最小值。


🧠 解题思维链

这一节模拟你在面试中"从零开始思考"的过程。

Step 1:理解题目 → 锁定输入输出

  • 输入:正整数 n
  • 输出:最少的完全平方数个数(整数)
  • 限制:只能用完全平方数(1、4、9、16...),每个数可以重复使用

Step 2:先想笨办法(暴力法)

用BFS或DFS枚举所有拆分方案:

n=12 可以拆成:
- 1+1+1+...+1 (12个1)
- 1+1+...+4 (8个1 + 1个4)
- 4+4+4 (3个4)
...
  • 时间复杂度:O(k^n) — k是可选平方数的数量,每层分裂k个分支
  • 瓶颈在哪:大量重复计算,比如"凑出8"会被"凑出12"、"凑出9"重复计算

Step 3:瓶颈分析 → 优化方向

暴力搜索中,"凑出金额i需要的最少数量"会被重复计算无数次。

  • 核心问题:重复计算子问题"如何凑出 i"
  • 优化思路:记录"凑出每个金额i的最少数量",后续直接查表 → 动态规划

Step 4:选择武器

  • 选用:完全背包DP
  • 理由:
    • 每个平方数可以无限次使用(完全背包特征)
    • 最优值(最少数量)
    • 重叠子问题(大量重复计算)

🔑 模式识别提示:当题目出现"凑成目标"+"每个物品可重复使用"+"求最少/最多",优先考虑"完全背包DP"


🔑 解法一:BFS层序搜索(直觉法)

思路

把问题看作图搜索:从 n 出发,每次减去一个平方数,看最少几步到达0。用BFS保证第一次到达0时就是最短路径。

图解过程

输入:n = 12

BFS搜索树:
        12
    /   |   \
  11    8    3  (减去1²、2²、3²)
 / |\ /|\ /|\
10 ...      ...

层数就是答案:
第1层:[12]
第2层:[11, 8, 3] (减去 1²、2²、3²)
第3层:[10, 7, 4, 7, 4, 2, 2, ...] (继续减)
第4层:发现0 → 返回4

实际上12 = 4+4+4,答案是3
(BFS能找到,但效率低)

Python代码

from collections import deque
import math


def num_squares_bfs(n: int) -> int:
    """
    解法一:BFS层序搜索
    思路:从n开始,每次减去一个平方数,层数即为答案
    """
    if n == 0:
        return 0

    # 预处理所有小于n的平方数
    squares = [i * i for i in range(1, int(math.sqrt(n)) + 1)]

    # BFS
    queue = deque([n])
    visited = {n}
    level = 0

    while queue:
        level += 1
        size = len(queue)
        for _ in range(size):
            curr = queue.popleft()
            for sq in squares:
                next_val = curr - sq
                if next_val == 0:
                    return level  # 到达0,返回层数
                if next_val > 0 and next_val not in visited:
                    visited.add(next_val)
                    queue.append(next_val)

    return level


# ✅ 测试
print(num_squares_bfs(12))  # 期望输出:3
print(num_squares_bfs(13))  # 期望输出:2
print(num_squares_bfs(1))   # 期望输出:1

复杂度分析

  • 时间复杂度:O(n * √n) — 最多访问n个状态,每个状态尝试√n个平方数
    • 具体地说:n=10000时,约需10000 * 100 = 10⁶次操作
  • 空间复杂度:O(n) — visited集合和队列

优缺点

  • ✅ 思路直观,BFS保证最优解
  • ❌ 空间消耗大,visited集合可能存储大量状态

⚡ 解法二:动态规划(标准完全背包)

优化思路

用DP直接计算"凑出金额i的最少数量",从小到大填表,避免BFS的冗余搜索。

💡 关键想法:dp[i] = 凑出金额i的最少平方数个数,由 dp[i - k²] + 1 推导而来

图解过程

输入:n = 12
可用平方数:[1, 4, 9] (1², 2², 3²,因为4²=16 > 12)

初始化:
dp[0] = 0 (凑出0不需要任何数)

迭代计算:
dp[1] = dp[1-1²] + 1 = dp[0] + 1 = 1
dp[2] = dp[2-1²] + 1 = dp[1] + 1 = 2
dp[3] = dp[3-1²] + 1 = dp[2] + 1 = 3
dp[4] = min(dp[4-1²]+1, dp[4-2²]+1) = min(dp[3]+1, dp[0]+1) = min(4, 1) = 1
        └─ 用1²       └─ 用2²(一个就够)
dp[5] = min(dp[4]+1, dp[1]+1) = min(2, 2) = 2
...
dp[12] = min(dp[11]+1, dp[8]+1, dp[3]+1) = min(4, 3, 4) = 3
          └─ 用1²    └─ 用2²   └─ 用3²

DP表:
i:  0  1  2  3  4  5  6  7  8  9  10 11 12
dp: 0  1  2  3  1  2  3  4  2  1  2  3  3

状态转移图示:

凑出12:
- 用1²: dp[12] = dp[11] + 1 = 3 + 1 = 4
- 用2²: dp[12] = dp[8] + 1 = 2 + 1 = 3  ← 最优
- 用3²: dp[12] = dp[3] + 1 = 3 + 1 = 4
取最小:3

Python代码

def num_squares_dp(n: int) -> int:
    """
    解法二:动态规划(完全背包)
    思路:dp[i] = 凑出i的最少平方数个数
    """
    # 初始化:dp[0]=0,其余为无穷大
    dp = [float('inf')] * (n + 1)
    dp[0] = 0

    # 预处理平方数列表
    squares = [i * i for i in range(1, int(n ** 0.5) + 1)]

    # 状态转移
    for i in range(1, n + 1):
        for sq in squares:
            if sq > i:
                break  # 平方数太大,跳过
            dp[i] = min(dp[i], dp[i - sq] + 1)
            #        当前最优  用一个sq

    return dp[n]


# ✅ 测试
print(num_squares_dp(12))  # 期望输出:3
print(num_squares_dp(13))  # 期望输出:2
print(num_squares_dp(1))   # 期望输出:1

复杂度分析

  • 时间复杂度:O(n * √n) — 外层循环n次,内层循环√n次
    • 具体地说:n=10000时,约需10000 * 100 = 10⁶次操作
  • 空间复杂度:O(n) — dp数组

🏆 解法三:数学定理优化(最优解)

优化思路

利用四平方和定理(拉格朗日):任何正整数都可以表示为最多4个完全平方数之和。

  • 答案只能是1、2、3、4中的一个
  • 1:n本身是平方数
  • 4:满足 n = 4^k * (8m + 7) 的特殊形式
  • 2或3:其他情况,先判断能否用两个平方数表示,不行就是3

💡 关键想法:不需要DP,直接用数学规律判断

图解过程

输入:n = 12

Step 1:判断是否为平方数
sqrt(12) = 3.46... ≠ 整数 → 不是1

Step 2:判断是否满足 4^k * (8m+7) 形式
12 / 4 = 3 (不能继续除以4)
3 % 8 = 37 → 不是4

Step 3:判断能否用两个平方数表示
枚举 i 从 1 到 sqrt(12):
  i=1: 12-1=11,sqrt(11)≠整数
  i=2: 12-4=8, sqrt(8)≠整数
  i=3: 12-9=3, sqrt(3)≠整数
→ 不能用2个,答案是3

Python代码

def num_squares_math(n: int) -> int:
    """
    解法三:数学定理优化(四平方和定理)
    思路:利用拉格朗日定理,答案只能是1/2/3/4
    """
    import math

    # 判断是否为完全平方数
    def is_perfect_square(x):
        sqrt_x = int(math.sqrt(x))
        return sqrt_x * sqrt_x == x

    # 情况1:n本身是平方数 → 答案1
    if is_perfect_square(n):
        return 1

    # 情况2:判断是否满足 4^k * (8m+7) → 答案4
    temp = n
    while temp % 4 == 0:
        temp //= 4
    if temp % 8 == 7:
        return 4

    # 情况3:判断能否用两个平方数表示 → 答案2
    for i in range(1, int(math.sqrt(n)) + 1):
        if is_perfect_square(n - i * i):
            return 2

    # 其他情况 → 答案3
    return 3


# ✅ 测试
print(num_squares_math(12))  # 期望输出:3
print(num_squares_math(13))  # 期望输出:2
print(num_squares_math(1))   # 期望输出:1

复杂度分析

  • 时间复杂度:O(√n) — 只需枚举到√n判断是否为两数之和
    • 具体地说:n=10000时,只需约100次判断
  • 空间复杂度:O(1) — 只用常数变量

🐍 Pythonic 写法

利用列表推导式简化DP:

def num_squares_pythonic(n: int) -> int:
    """Pythonic写法:列表推导 + 内置函数"""
    dp = [0] + [float('inf')] * n
    squares = [i*i for i in range(1, int(n**0.5) + 1)]

    for i in range(1, n + 1):
        dp[i] = min(dp[i - sq] + 1 for sq in squares if sq <= i)

    return dp[n]

⚠️ 面试建议:先写清晰的解法二展示DP思路,再提解法三展示数学优化,最后补充Pythonic写法展示语言功底。 面试官更看重你的思考过程,而非代码行数。


📊 解法对比

维度解法一:BFS搜索解法二:标准DP🏆 解法三:数学优化(最优)
时间复杂度O(n√n)O(n√n)O(√n) ← 时间最优
空间复杂度O(n)O(n)O(1) ← 空间最优
代码难度中等简单中等(需要数学知识)
面试推荐⭐⭐⭐⭐⭐⭐ ← 展示深度
适用场景理解问题本质通用DP模板,首选数学竞赛/展示优化能力

为什么解法三是最优解:

  • 时间从 O(n√n) 优化到 O(√n),提升100倍!(n=10000时)
  • 空间从 O(n) 优化到 O(1),无需额外数组
  • 利用数学定理,直接判断答案区间

面试建议:

  1. 先用30秒口述BFS思路,表明你理解"最短路径"本质
  2. 立即提出"用DP优化",写出🏆 解法二(完全背包DP) — 这是最通用、最稳妥的解法
  3. 如果时间充裕,补充解法三展示数学优化能力
  4. 强调:工程中优先用DP(解法二),因为通用、易懂、不易出错
  5. 手动测试边界用例(n=1、n=4、n=12)

🎤 面试现场

模拟面试中的完整对话流程,帮你练习"边想边说"。

面试官:请你解决一下完全平方数这道题。

:(审题30秒)好的,这道题要求用最少的完全平方数凑出目标n。让我先想一下...

我的第一个想法是用BFS:从n开始,每次减去一个平方数,层数就是答案。这能保证找到最优解,但时间复杂度是 O(n√n)。

观察到这是一个完全背包DP问题:每个平方数可以无限次使用,求最小数量。我可以用DP优化:dp[i] 表示凑出i的最少数量,状态转移是 dp[i] = min(dp[i-k²] + 1),遍历所有k。

面试官:很好,请写一下代码。

:(边写边说)我先初始化 dp[0]=0,其余为无穷大。然后预处理所有小于等于n的平方数。对每个金额i,尝试减去每个平方数k²,取最小值...

(写完解法二代码)

面试官:还有更快的方法吗?

:有!可以利用拉格朗日四平方和定理:任何正整数最多用4个平方数表示。答案只能是1、2、3、4中的一个。我可以直接判断:

  1. n是平方数 → 答案1
  2. n满足特殊形式 4^k(8m+7) → 答案4
  3. 能用两个平方数表示 → 答案2
  4. 其他 → 答案3

时间复杂度降到 O(√n),空间 O(1)!

面试官:测试一下?

:用示例 n=12 走一遍:

  • sqrt(12) ≈ 3.46,不是平方数
  • 12/4=3,3%8=3≠7,不满足特殊形式
  • 枚举:12-1²=11(非平方数)、12-2²=8(非平方数)、12-3²=3(非平方数)
  • 不能用2个,答案是3 ✓

再测 n=13:

  • 13-2²=9=3²,能用两个,答案是2 ✓

高频追问

追问应答策略
"为什么最多4个平方数就够?"这是拉格朗日在1770年证明的四平方和定理。证明很复杂,但工程中可以直接用这个结论优化算法
"DP解法能优化空间吗?"不能。因为dp[i]可能依赖任意dp[i-k²],无法用滚动数组(不像打家劫舍只依赖前两项)
"如果n很大(10⁸)怎么办?"DP的O(n√n)会超时,必须用数学解法(O(√n))。或者用BFS + 剪枝(如双向BFS)
"这个思路能解决什么其他题?"零钱兑换(LC 322)、分割等和子集(LC 416)、组合总和(LC 39),都是完全背包/0-1背包DP

🎓 知识点总结

Python技巧卡片 🐍

# 技巧1:列表推导生成平方数 — 一行搞定
squares = [i*i for i in range(1, int(n**0.5) + 1)]

# 技巧2:math.isqrt精确整数平方根(Python 3.8+)
import math
sqrt_n = math.isqrt(n)  # 比 int(math.sqrt(n)) 更准确
is_square = sqrt_n * sqrt_n == n

# 技巧3:生成器表达式节省内存 — 大数据时用
dp[i] = min(dp[i-sq] + 1 for sq in squares if sq <= i)
# 比先过滤再min更省内存

💡 底层原理(选读)

完全背包 vs 0-1背包的区别?

  • 0-1背包:每个物品只能用一次(如打家劫舍,每间房只能偷一次)

    for i in range(n):
        for j in range(capacity, weight[i]-1, -1):  # 逆序!
            dp[j] = max(dp[j], dp[j-weight[i]] + value[i])
    
  • 完全背包:每个物品可以用无限次(如零钱兑换、完全平方数)

    for coin in coins:
        for j in range(coin, amount+1):  # 正序!
            dp[j] = min(dp[j], dp[j-coin] + 1)
    

关键区别:

  • 0-1背包逆序遍历,保证每个物品只用一次
  • 完全背包正序遍历,允许同一物品多次使用

为什么四平方和定理能用?

拉格朗日证明:任何正整数 n 都能表示为 a² + b² + c² + d² (a,b,c,d ≥ 0)。 进一步:

  • 需要4个的充要条件:n = 4^k * (8m + 7)
  • 其他情况:1个(n是平方数)、2个(n = a² + b²)或3个

这个定理把问题从"求最少数量"简化为"判断属于哪一类",时间复杂度大幅降低。

算法模式卡片 📐

  • 模式名称:完全背包DP
  • 适用条件:
    • 有一组物品,每个物品可以无限次使用
    • 求装满背包的最优值(最大/最小)
    • 目标是凑成某个值(金额、容量、数量等)
  • 识别关键词:"凑成"、"组合"、"无限次使用"、"最少数量"、"最多价值"
  • DP五步法:
    1. 定义状态: dp[i] = 凑出金额i的最优值
    2. 状态转移: dp[i] = min/max(dp[i], dp[i-物品] + 1/价值)
    3. 初始化: dp[0]=0(或其他边界值),其余为极值(∞或-∞)
    4. 遍历顺序: 正序!(完全背包特征)
    5. 返回值: dp[目标金额]
  • 模板代码:
def complete_knapsack(items, target):
    """完全背包通用模板"""
    dp = [float('inf')] * (target + 1)
    dp[0] = 0  # 凑出0的最少数量是0

    for item in items:
        for j in range(item, target + 1):  # 正序!
            dp[j] = min(dp[j], dp[j - item] + 1)

    return dp[target] if dp[target] != float('inf') else -1

易错点 ⚠️

  1. 初始化错误:

    # ❌ 错误:初始化为0
    dp = [0] * (n + 1)  # 错!会导致min时总是选0
    
    # ✓ 正确:求最小值时初始化为无穷大
    dp = [float('inf')] * (n + 1)
    dp[0] = 0
    
  2. 遍历顺序错误:

    # ❌ 错误:逆序遍历(这是0-1背包)
    for j in range(target, item-1, -1):
        dp[j] = min(dp[j], dp[j-item] + 1)
    # 结果:每个item只能用一次!
    
    # ✓ 正确:正序遍历(完全背包)
    for j in range(item, target+1):
        dp[j] = min(dp[j], dp[j-item] + 1)
    
  3. 边界判断遗漏:

    # ❌ 错误:没判断sq > i
    for sq in squares:
        dp[i] = min(dp[i], dp[i-sq] + 1)  # 可能i-sq<0越界!
    
    # ✓ 正确:加判断或用range限制
    for sq in squares:
        if sq <= i:
            dp[i] = min(dp[i], dp[i-sq] + 1)
    

🏗️ 工程实战(选读)

这个算法思想在真实项目中的应用,让你知道"学了有什么用"。

  • 场景1:找零系统 — 超市收银,给定硬币面值,求最少硬币数找零(零钱兑换问题)
  • 场景2:资源分配 — 云服务器有1核、2核、4核实例,凑出N核资源的最少实例数
  • 场景3:数据压缩 — 用固定长度的编码块(如4字节、8字节)拼接文件,求最少块数

🏋️ 举一反三

完成本课后,试试这些同类题目来巩固知识:

题目难度相关知识点提示
LeetCode 322. 零钱兑换Medium完全背包和本题几乎一样,只是把"平方数"换成"硬币面值"
LeetCode 377. 组合总和IVMedium完全背包(计数)求方案数,不是最小值,dp含义改为"方案数"
LeetCode 518. 零钱兑换IIMedium完全背包(计数)注意遍历顺序:先物品再金额(去重)
LeetCode 343. 整数拆分MediumDP拆分成若干正整数使乘积最大,状态转移类似

📝 课后小测

试试这道变体题,不要看答案,自己先想5分钟!

题目:给定一个正整数 n,求用最多k个完全平方数凑出n的方案数(如果无法凑出返回0)。例如 n=4, k=2,答案是2(方案:1+1+1+1被k=2限制排除,只有2²和1²+1²+1²+1²不可行,实际只有4=2²一种在k=1内,4=1²+1²+1²+1²需要4个...重新理解:k=2时,4可以是2²(1个)或1²+1²+1²+1²(4个,超出),所以...更正题目:求最少平方数个数不超过k时,有多少种拆分方案)

💡 提示(实在想不出来再点开)

定义 dp[i][j] = 用恰好j个平方数凑出i的方案数。状态转移:dp[i][j] = sum(dp[i-k²][j-1])。

✅ 参考答案
def count_square_sums(n: int, k: int) -> int:
    """
    二维DP:dp[i][j] = 用恰好j个平方数凑出i的方案数
    最终答案:sum(dp[n][1] + dp[n][2] + ... + dp[n][k])
    """
    # dp[i][j] 表示凑出i,用j个平方数的方案数
    dp = [[0] * (k + 1) for _ in range(n + 1)]
    dp[0][0] = 1  # 凑出0,用0个数,1种方案

    # 预处理平方数
    squares = [i*i for i in range(1, int(n**0.5) + 1)]

    for i in range(1, n + 1):
        for j in range(1, k + 1):
            for sq in squares:
                if sq > i:
                    break
                dp[i][j] += dp[i - sq][j - 1]
                # 用一个sq,剩下的用j-1个凑出i-sq

    # 返回用1~k个数的所有方案数之和
    return sum(dp[n][j] for j in range(1, k + 1))


# 测试
print(count_square_sums(4, 1))  # 1种(只有2²)
print(count_square_sums(4, 4))  # 多种(1+1+1+1, 1+1+1+1等排列,需考虑组合/排列)

核心思路:增加一维"使用的平方数个数",变成二维DP。注意这里求的是组合数(不考虑顺序),如果是排列数,遍历顺序要调整。


如果这篇内容对你有帮助,推荐收藏 AI Compass:github.com/tingaicompa… 更多系统化题解、编程基础和 AI 学习资料都在这里,后续复习和拓展会更省时间。