Files

8.4 KiB
Raw Permalink Blame History

tags, create time
tags create time
算法/动态规划
基础DP
完全背包
数学/数论
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

三者取最小即可。

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
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 语言实现

// 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)