Files

254 lines
8.4 KiB
Markdown
Raw Permalink Normal View History

2026-05-17 09:38:35 +08:00
---
tags: [算法/动态规划, 基础DP, 完全背包, 数学/数论]
create time: 2026-05-16 19:00
---
# 84. 完全平方数(Perfect Squares)
> [!quote] LeetCode 原题 · 中等
> 给你一个整数 `n`,返回**和为 n 的完全平方数的最少数量**。
>
> **完全平方数**是一个整数,其值等于另一个整数的平方。例如:1、4、9、16。
>
> - 输入范围:`1 <= n <= 10⁴`
## 题面
**示例 1:**
```
输入:n = 12
输出:3
解释:12 = 4 + 4 + 4
```
**示例 2:**
```
输入:n = 13
输出:2
解释:13 = 4 + 9
```
## 思路
### 第一步:把问题翻译成 DP 语言
> [!question] 💡 思考
> 如果要凑出数字 n,你手里有 {1, 4, 9, 16, ...} 这些面额无限的"硬币"——这和哪类经典模型一致?
没错,这就是一个**「最小硬币数」版本**的完全背包问题:
- **物品集合**:所有不超过 n 的完全平方数 → {1², 2², 3², ..., k²},其中 k² ≤ n
- **每种物品可以无限使用**
- **目标**:选若干个物品使其总和恰好为 n,且物品个数最少
定义状态:
> `dp[i]` = 和为 i 的完全平方数的最少数量
那状态转移呢?想象你已经凑到了 i,最后一步一定是从某个 i - j² 加上一个 j² 过来的:
> `dp[i] = min(dp[i - j*j]) + 1`,对所有满足 j² ≤ i 的 j 取最小值
> [!tip] 🧠 直观理解
> 画个例子:`dp[12]` 考虑最后加入的完全平方数可能是 1, 4, 9:
> - 加 1:`dp[12] = dp[11] + 1`
> - 加 4:`dp[12] = dp[8] + 1`
> - 加 9:`dp[12] = dp[3] + 1`
>
> 三者取最小即可。
```mermaid
flowchart TD
subgraph "dp[12] 的状态转移"
A["dp[12]\n= min(...) + 1"] --> B["从 dp[11] + 1\n(最后加 1)"]
A --> C["从 dp[8] + 1\n(最后加 4)"]
A --> D["从 dp[3] + 1\n(最后加 9)"]
B --> E["dp[11]=4 → ans=5"]
C --> F["dp[8]=2 → ans=3 ✓最优"]
D --> G["dp[3]=3 → ans=4"]
end
style A fill:#e7f3ff,stroke:#3b82f6
style F fill:#dcfce7,stroke:#16a34a
```
### 第二步:确定边界条件
> [!question] 💡 思考
> dp[0] 应该是多少?为什么?
`dp[0] = 0` ——和为 0 不需要任何完全平方数,答案是 0。这是所有状态的"空起点"。没有它,整个递推就没有根基。
### 第三步:自底向上填表
以 `n = 12` 为例完整走一遍——
| i | dp[i] 计算过程 | dp[i] |
|---|--------------|-------|
| 0 | — | **0** |
| 1 | dp[0]+1 | **1** |
| 2 | dp[1]+1 | **2** |
| 3 | dp[2]+1 = dp[0]+1 | **2** |
| 4 | dp[3]+1, dp[0]+1 | **1** ← 本身就是平方数! |
| 5 | dp[4]+1 = dp[1]+1 | **2** |
| 6 | dp[5]+1 = 3, dp[2]+1 = 3 | **3** |
| 7 | dp[6]+1 = dp[3]+1 | **3** |
| 8 | dp[7]+1 = dp[4]+1 | **2** ← 4+4 |
| 9 | dp[8]+1, dp[5]+1, dp[0]+1 | **1** ← 本身是平方数 |
| 10 | dp[9]+1 = dp[6]+1 | **3** |
| 11 | dp[10]+1 = dp[7]+1 | **3** |
| 12 | dp[11]+1 = dp[8]+1 = dp[3]+1 | **3** ← 4+4+4 |
```mermaid
flowchart LR
subgraph "填表示意 n=12"
A["i=0: 0"] --> B["i=1: 1"]
B --> C["i=2: 2"]
C --> D["i=3: 2"]
D --> E["i=4: 1 ★"]
E --> F["i=5: 2"]
F --> G["i=6: 3"]
G --> H["i=7: 3"]
H --> I["i=8: 2 ★"]
I --> J["i=9: 1 ★"]
J --> K["i=10: 3"]
K --> L["i=11: 3"]
L --> M["i=12: 3 ★答案"]
end
style A fill:#f3f4f6,stroke:#6b7280
style E fill:#fef3c7,stroke:#f59e0b
style I fill:#fef3c7,stroke:#f59e0b
style J fill:#fef3c7,stroke:#f59e0b
style M fill:#dcfce7,stroke:#16a34a
```
> 表中 ★ 标记表示该位置本身就是一个完全平方数,直接取 1。
## 代码提示
在动手写代码之前,想一想这些关键决策点:
> [!question] 💡 思考 1
> 内层循环中,j 的范围应该是多少?怎么高效地枚举"所有不超过 i 的完全平方数"?
j 从 1 开始,只要 `j*j ≤ i` 就继续。这样 `dp[i - j*j]` 不会越界。每次循环只用一次乘法和一次比较,效率很高。
> [!question] 💡 思考 2
> Go 中没有内置的 min 对多个值取最小——你会怎么写?
可以先用一个变量 `ans` 初始化为无穷大(比如 `i` 本身,因为最坏情况是全用 1),然后在循环中逐个更新最小值。这样既简洁又避免了引入额外依赖。
## 技巧
### 剪枝:提前识别完全平方数
> [!tip] ⚡ 小优化
> 如果 i 本身就是一个完全平方数(即存在某个 j 使得 j*j = i),那么 `dp[i] = 1`,可以直接跳过内层循环。
这个判断只需一行:`if int(math.Sqrt(float64(i)))*int(math.Sqrt(float64(i))) == i { dp[i] = 1; continue }`。虽然不影响渐近复杂度,但能加速一半以上的填充。
### Lagrange 四平方定理(进阶)
> [!abstract] 🔢 数论彩蛋:Lagrange 四平方定理
> **任意正整数都可以表示为不超过 4 个完全平方数之和。**
>
> 这意味着答案永远 ∈ {1, 2, 3, 4}。配合 Legendre 三平方定理,可以做到 O(√n) 时间直接求解:
>
> 1. 先消去因子 4(反复除以 4)
> 2. 检查余数是否为 `4ᵏ(8m+7)`——如果是,答案就是 4
> 3. 否则尝试能否拆成两个平方数之和——能则是 2
> 4. 其余情况都是 3
这个数学解法速度极快,但在面试中可能不容易当场推导出来。掌握 DP 方法已足够通过绝大多数场景。
| 方法 | 时间 | 空间 | 评价 |
|------|------|------|------|
| DP 填表(推荐) | O(n√n) | O(n) | 通用易懂,面试首选 |
| BFS | O(n√n) | O(n) | 第一层到达的答案必是最优,同样高效 |
| 数学法(四平方定理) | O(√n) | O(1) | 最快,但需记忆定理 |
## 代码
### Go 语言实现
```go
// numSquares returns the minimum number of perfect squares that sum to n.
func numSquares(n int) int {
// dp[i] = 和为 i 的完全平方数的最少数量
dp := make([]int, n+1)
for i := 1; i <= n; i++ {
// 初始化:最坏情况是全用 1 来凑
dp[i] = i
// 尝试每一种可能的最后一步 j*j
for j := 1; j*j <= i; j++ {
sum := dp[i-j*j] + 1
if sum < dp[i] {
dp[i] = sum
}
}
}
return dp[n]
}
```
> [!summary] 💡 代码说明
> - **外层循环**:从左到右依次计算 dp[1], dp[2], ..., dp[n],保证每个子问题在用到时已经被解决
> - **内层循环**:枚举所有可能的 j(1, 2, 3, ...),直到 j² > i 为止。每次考虑"最后一步加了 j²"这个选择
> - **初始值 dp[i] = i**:最坏情况是用 i 个 1 来凑(如 7 = 1+1+1+1+1+1+1)。内层循环一定能找到更优解
### 执行过程演示(n = 12)
```
i=1: j=1: dp[0]+1=1 → dp[1]=1 ← 1=1
i=2: j=1: dp[1]+1=2 → dp[2]=2 ← 2=1+1
i=3: j=1: dp[2]+1=3 → dp[3]=3 ← 3=1+1+1 (j=2时 4>3,停止)
i=4: j=1: dp[3]+1=4
j=2: dp[0]+1=1 → dp[4]=1 ← 4=4 ★
i=5: j=1: dp[4]+1=2
j=2: dp[1]+1=2 → dp[5]=2 ← 5=4+1
i=6: j=1: dp[5]+1=3
j=2: dp[2]+1=3 → dp[6]=3 ← 6=4+1+1
i=7: j=1: dp[6]+1=4
j=2: dp[3]+1=4 → dp[7]=4 ← 7=4+1+1+1
i=8: j=1: dp[7]+1=5
j=2: dp[4]+1=2 → dp[8]=2 ← 8=4+4 ★
i=9: j=1: dp[8]+1=3
j=2: dp[5]+1=3
j=3: dp[0]+1=1 → dp[9]=1 ← 9=9 ★
i=10: j=1: dp[9]+1=2
j=2: dp[6]+1=4
j=3: dp[1]+1=2 → dp[10]=2 ← 10=9+1
i=11: j=1: dp[10]+1=3
j=2: dp[7]+1=5
j=3: dp[2]+1=3 → dp[11]=3 ← 11=9+1+1
i=12: j=1: dp[11]+1=4
j=2: dp[8]+1=3 → dp[12]=3 ← 12=4+4+4
答案:dp[12] = 3 ✅
```
### 复杂度分析
- **时间复杂度:O(n√n)** — 外层循环 n 次,内层循环最多 √n 次,总操作量约为 Σ(√i) ≈ (2/3)n^(3/2)
- **空间复杂度:O(n)** — dp 数组占用 n+1 个整型空间
## 举一反三
这道题是 **线性 DP + 枚举最后一项** 的典型范式,同时也是「完全背包」思想的第一道落地应用:
- [[81-爬楼梯]] — 同样是线性 DP,但这里的"选择"不是固定两种,而是从一组可选值中选
- [[83-打家劫舍]] — 同样是逐状态递推,但约束不同(不能相邻 vs 无约束)
- LeetCode 322. 零钱兑换 — 完全背包的标准题,模板几乎一模一样(只是面额换成 coin value)
> [!summary] 📌 本节要点
> 1. 定义 dp[i] = 和为 i 的完全平方数最少个数
> 2. 状态转移:dp[i] = min(dp[i - j²]) + 1,枚举所有 j² ≤ i
> 3. 边界 dp[0] = 0,初始值 dp[i] = i(全用 1)
> 4. 复杂度 O(n√n),可以用 Lagrange 四平方定理做到 O(√n)