想系统提升编程能力、查看更完整的学习路线,欢迎访问 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 = 4 | 1 | 只需一个数(2²) |
| 最小输入 | n = 1 | 1 | 1² = 1 |
| 需要拆分 | n = 12 | 3 | 4+4+4 |
| 两数之和 | n = 13 | 2 | 4+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 = 3 ≠ 7 → 不是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),无需额外数组
- 利用数学定理,直接判断答案区间
面试建议:
- 先用30秒口述BFS思路,表明你理解"最短路径"本质
- 立即提出"用DP优化",写出🏆 解法二(完全背包DP) — 这是最通用、最稳妥的解法
- 如果时间充裕,补充解法三展示数学优化能力
- 强调:工程中优先用DP(解法二),因为通用、易懂、不易出错
- 手动测试边界用例(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中的一个。我可以直接判断:
- n是平方数 → 答案1
- n满足特殊形式 4^k(8m+7) → 答案4
- 能用两个平方数表示 → 答案2
- 其他 → 答案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五步法:
- 定义状态: dp[i] = 凑出金额i的最优值
- 状态转移: dp[i] = min/max(dp[i], dp[i-物品] + 1/价值)
- 初始化: dp[0]=0(或其他边界值),其余为极值(∞或-∞)
- 遍历顺序: 正序!(完全背包特征)
- 返回值: 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
易错点 ⚠️
-
初始化错误:
# ❌ 错误:初始化为0 dp = [0] * (n + 1) # 错!会导致min时总是选0 # ✓ 正确:求最小值时初始化为无穷大 dp = [float('inf')] * (n + 1) dp[0] = 0 -
遍历顺序错误:
# ❌ 错误:逆序遍历(这是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) -
边界判断遗漏:
# ❌ 错误:没判断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. 组合总和IV | Medium | 完全背包(计数) | 求方案数,不是最小值,dp含义改为"方案数" |
| LeetCode 518. 零钱兑换II | Medium | 完全背包(计数) | 注意遍历顺序:先物品再金额(去重) |
| LeetCode 343. 整数拆分 | Medium | DP | 拆分成若干正整数使乘积最大,状态转移类似 |
📝 课后小测
试试这道变体题,不要看答案,自己先想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 学习资料都在这里,后续复习和拓展会更省时间。