本文概览:本文从线性 DP 进阶到枚举类 DP:完全平方数与零钱兑换两题合一讲解,dpi 由枚举"最后一步"的候选集合推出,min(dpi - j² + 1) 与 min(dpi - coin + 1) 同源
一、从线性 DP 到枚举类 DP
前面几篇的 DP 题目(爬楼梯、打家劫舍)都是线性 DP:
dp[i]只和前两项有关- 分析手法是"站在 i 往回看,最后一步从哪来"
- 而来路只有一两种(爬 1 步或 2 步;偷或不偷),选或不选就能枚举完
本文的两道题,279. 完全平方数 和 322. 零钱兑换 ,还是"站在 i 往回看最后一步",但情况变了:我们根本不知道最后一步是什么。完全平方数不知道 n 由哪几个平方数组成;零钱兑换不知道最后一枚硬币是哪个面额。来路不止两种,没法简单二选一。
怎么办?不知道就猜,把所有可能的"最后一步"全部枚举一遍 ,每个都算一遍,取最小的。这类 DP 就叫枚举类 :转移要遍历一个候选集合。
两道题合在一篇讲,因为它们是同一个模板:先讲完全平方数(候选要自己生成),再讲零钱兑换(候选题目直接给,且多一个"凑不出"的处理)。
二、例题一:完全平方数
1. 题目

给你一个整数 n,返回和为 n 的完全平方数的最少数量。
完全平方数是一个整数,其值等于另一个整数的平方。例如 1、4、9 和 16 都是完全平方数,而 3 和 11 不是。
例子 :n = 12
12 = 4 + 4 + 4 → 3 个
12 = 9 + 1 + 1 + 1 → 4 个
12 = 1 + 1 + ... + 1 → 12 个(全用 1,最坏情况)
最少数量 = 3
同一个平方数可以重复使用(4 + 4 + 4)。
2. 题目分析
怎么判断这是动态规划问题? 从后往前想:和为 n 的最少数量,和更小规模"和为 m 的最少数量",有没有关系?
站在 n 往回看:最后一个平方数如果是 4,那剩下的部分就是"和为 n - 4 的最少数量",大问题由小问题的答案组合出来;而且一旦"剩余的和"确定了,前面怎么组成的完全不影响后续(无后效性)。加上求的是最优值,标准的 DP 结构。
需要解决哪几个问题?
- 问题一:状态数组 dp\[\] 怎么定义?
- 问题二:候选集合是什么?转移方程怎么写?枚举谁?每个候选怎么转化成子问题?
3. 思路概览
java
public int numSquares(int n) {
int[] dp = new int[n + 1];
dp[0] = 0;
for (int i = 1; i <= n; i++) {
dp[i] = Integer.MAX_VALUE;
for (int j = 1; j * j <= i; j++) {
dp[i] = Math.min(dp[i], dp[i - j * j] + 1);
}
}
return dp[n];
}
思路简要说明:
- 状态定义 :
dp[i]= 和为 i 的完全平方数的最少数量 - 枚举转移 :内层循环 j 枚举"最后一个平方数"是 j²,剩下的是子问题
dp[i - j*j],dp[i] = min(dp[i - j*j] + 1) - 初始条件 :
dp[0] = 0(和为 0 需要 0 个数) - 时间复杂度 O(n·√n):外层 n 个状态,内层最多 √n 个候选
4. 思路详解
第一步:解决状态定义
题目问"和为 n 的最少数量",n 就是规模。把 n 缩小:和为 3 的最少数量、和为 7 的最少数量...形式完全一样。
所以 dp[i] 表示和为 i 的完全平方数的最少数量 ,答案是 dp[n]。
第二步:解决转移方程,枚举最后一个平方数
候选集合先搞清楚 :完全平方数就是 1、4、9、16...即 1²、2²、3²、4²...对于和为 i 的问题,候选只有满足 j*j <= i 的那些(比如 i=12 时,候选是 1、4、9;16 已经超了)。
转移的推导 :站在 i 往回看,最后一个平方数是 j²。既然最后一个确定了,剩下的部分就是"和为 i - j² 的最少数量",正是子问题 dp[i - j²]。加上最后这一个,总共:
dp[i] = dp[i - j*j] + 1
但 j 是不知道的,所以每个候选都试一遍,取最小:
dp[i] = Math.min(dp[i], dp[i - j * j] + 1) (j 从 1 枚举到 j*j <= i)
用 dp12 实际推一遍,候选 1、4、9:
猜最后一个是 9:dp[12] = dp[12-9] + 1 = dp[3] + 1
dp[3] = 1+1+1 → 3 个,所以这条路的总数是 3 + 1 = 4 个(合法,但不是最优)
猜最后一个是 4:dp[12] = dp[12-4] + 1 = dp[8] + 1
dp[8] = 4+4 → 2 个,所以总数是 2 + 1 = 3 个 ✓
猜最后一个是 1:dp[12] = dp[11] + 1
dp[11] = 9+1+1 → 3 个,总数 4 个
三者取最小 → dp[12] = 3(对应 4 + 4 + 4)
注意"猜 9"这条路:它不是非法,只是不最优,dp3 是存在的(3 = 1+1+1)。枚举的意义就是把所有合法来路都比一遍,最优的那个自然浮出来。
初始条件 :dp[0] = 0(和为 0,一个数都不用)。它是一切的起点:dp1 = dp0 + 1 = 1,dp4 = dp0 + 1 = 1...
第三步:代码细节
dp[i] = Integer.MAX_VALUE 放在外层循环里:每算一个新的 dpi,先设成最大值,再让内层的 min 一路压下来。它相当于"还没枚举任何候选时的起始值"。
内层 dp[i - j*j] 永远不会取到 MAX_VALUE :因为 j=1 恒合法(1 ≤ i),而"和为任意正整数"总有解(全用 1),所以 dp[i-1] 一定是真实数值,+1 不会溢出。
j * j <= i 的边界:候选平方数必须不超过 i 本身,超过就无意义(凑不出)。
为什么不能滚动变量优化? 线性 DP 只依赖前两项,两个变量就够;这题枚举可能回溯到很小的下标(dp12 要用 dp3),整张表都得留着。
第四步:完整执行过程
以 n = 12 为例,列出 dp 表(括号里是其中一种最优组成):
dp[0] = 0 (起点)
dp[1] = 1 (1)
dp[2] = 2 (1+1)
dp[3] = 3 (1+1+1)
dp[4] = 1 (4)
dp[5] = 2 (4+1)
dp[6] = 3 (4+1+1)
dp[7] = 4 (4+1+1+1)
dp[8] = 2 (4+4)
dp[9] = 1 (9)
dp[10] = 2 (9+1)
dp[11] = 3 (9+1+1)
dp[12] = 3 (4+4+4)
返回 dp[12] = 3 ✓
可以观察到:dpi 的值在"遇到新的平方数"时会出现跳水(dp4 从预期的 4 跳到 1,dp9 跳到 1),因为枚举到了更大的候选,来路变短了。
复杂度:时间 O(n·√n),空间 O(n)。
三、例题二:零钱兑换
1. 题目

给你一个整数数组 coins,表示不同面额的硬币;以及一个整数 amount,表示总金额。计算并返回可以凑成总金额所需的最少的硬币个数 。如果没有任何一种硬币组合能组成总金额,返回 -1。每种硬币的数量是无限的。
例子 :coins = [1, 2, 5],amount = 11
11 = 5 + 5 + 1 → 3 枚
最少个数 = 3
2. 题目分析
判断是 DP 的思考过程和上一题一模一样:凑出金额 amount 的最少硬币数,和凑出更小金额的最少硬币数有没有关系?站在金额 amount 往回看,最后一枚硬币的面额必然是 coins 里的某一个(比如 5),剩下的部分就是"凑出 amount - 5 的最少硬币数",大问题由小问题组合,无后效性,求最优值。同一个结构,直接给结论。
和完全平方数的最大差别 :思路完全一样,唯一差别在候选集合的来源:
- 完全平方数:题目没给候选,候选是自己生成的(1²、2²、3²...枚举 j)
- 零钱兑换 :题目直接给了候选,就是
coins = [1, 2, 5],枚举数组即可
但本题多出一个新问题 :完全平方数全用 1 必然凑得出任何 n;这题 coins 里可能没有 1(比如 coins = 2,amount = 3 就凑不出),要处理"不可达"。
所以需要解决的问题:状态定义、转移方程(结论同上题),外加问题三:凑不出的情况怎么表示?
3. 思路概览
java
public int coinChange(int[] coins, int amount) {
int[] dp = new int[amount + 1];
// 初始化:除dp[0]外,其余设为一个不可达的大值
Arrays.fill(dp, amount + 1);
dp[0] = 0;
// 外层遍历硬币,内层遍历金额
for (int coin : coins) {
for (int i = coin; i <= amount; i++) {
dp[i] = Math.min(dp[i], dp[i - coin] + 1);
}
}
return dp[amount] > amount ? -1 : dp[amount];
}
思路简要说明:
- 状态定义 :
dp[i]= 凑成金额 i 的最少硬币个数 - 转移方程 :枚举最后一枚硬币的面额 coin,
dp[i] = min(dp[i], dp[i - coin] + 1) - 不可达表示 :初始值设为
amount + 1(一个不可能达到的大值),最后dp[amount] > amount说明凑不出,返回 -1 - 初始条件 :
dp[0] = 0(金额 0 需要 0 枚) - 时间复杂度 O(amount × coins.length)
4. 思路详解
第一步:状态定义和转移方程(结论)
状态定义 :dp[i] 表示凑成金额 i 所需的最少硬币个数。
转移方程 :站在金额 i 往回看,最后一枚硬币的面额是 coin(枚举 coins 数组),剩下的部分是子问题 dp[i - coin],加上最后这一枚:
dp[i] = min(dp[i], dp[i - coin] + 1) (coin 遍历 coins 数组)
思路和完全平方数一模一样:不管前面具体怎么凑的,直接当子问题且已知,只决策最后一枚。
第二步:不可达怎么表示?(本题的新问题)
处理方式 :初始值设为一个不可能取到的大值。这里有两个讲究:
为什么不用 Integer.MAX_VALUE? 因为转移方程要 +1:如果 dp[i - coin] 是 MAX_VALUE,+1 会直接溢出成负数,min 就出错了。
为什么用 amount + 1? 凑成金额 amount,最坏最坏也不过用 amount 枚硬币(全用 1 块的极限情况),所以真实答案永远 ≤ amount 。amount + 1 就是一个安全的大值:它足够大(不可能是真实答案),又足够小(+1 不溢出)。就算不可达状态被反复 +1(如 amount+2),仍然 > amount,不影响最终判断。
返回时的判断 :dp[amount] > amount ? -1 : dp[amount],大于哨兵值说明全程没被任何候选更新过,凑不出。
(对比:完全平方数可以直接用 MAX_VALUE 当初始值,因为"全用 1"保证每个 dpi 都可达,MAX_VALUE 永远不会被 +1;这题不可达状态会进入转移方程,必须换安全的哨兵值。)
第三步:循环顺序的细节
java
for (int coin : coins) { // 外层:枚举硬币面额
for (int i = coin; i <= amount; i++) { // 内层:枚举金额
内层从 coin 开始 :金额 i 必须 ≥ coin,否则 i - coin 是负数下标越界。这和完全平方数的 j * j <= i 是同一个边界思想,只是写法换了方向。
外层硬币、内层金额 :这是完全背包的标准写法。对本题来说,外层金额、内层硬币(像完全平方数那样外层 i 内层 j)也是对的,因为我们求的是"最少枚数",不关心硬币的排列顺序,两种遍历都能保证每个状态被所有候选刷过。读者可以自己交换两层试试,结果不变。
第四步:完整执行过程
以 coins = [1, 2, 5],amount = 11 为例。初始:dp = [0, 12, 12, 12, ..., 12](12 = amount+1)。
处理 coin = 1 :每个金额都能用 1 凑出,dp[i] = min(dp[i], dp[i-1]+1):
dp = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]
处理 coin = 2 :dp[i] = min(dp[i], dp[i-2]+1),逐个压:
dp[2] = min(2, dp[0]+1) = 1
dp[3] = min(3, dp[1]+1) = 2
dp[4] = min(4, dp[2]+1) = 2
...
dp[11] = min(11, dp[9]+1) = 6
(此后 dp[i] 呈 ⌈i/2⌉ 的形态)
dp = [0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6]
处理 coin = 5 :dp[i] = min(dp[i], dp[i-5]+1):
dp[5] = min(3, dp[0]+1) = 1
dp[6] = min(3, dp[1]+1) = 2
dp[10] = min(5, dp[5]+1) = 2
dp[11] = min(6, dp[6]+1) = 3 ← 最后的答案在这里刷出来
dp[11] = 3 ≤ 11 → 返回 3 ✓(对应 5 + 5 + 1)
再验证一个凑不出的例子 :coins = [2],amount = 3:
初始:dp = [0, 4, 4, 4]
处理 coin = 2:i 从 2 开始
dp[2] = min(4, dp[0]+1) = 1
dp[3] = min(4, dp[1]+1) = 5 ← dp[1] = 4 不可达,+1 = 5,仍然 > 3
返回时 dp[3] = 5 > 3 → 返回 -1 ✓
注意 dp3 被刷成了 5(amount+2),但依然大于 amount,判断不受影响,这就是哨兵值"足够大又不会溢出"的意义。
复杂度:时间 O(amount × coins.length),空间 O(amount)。
四、两题对照:枚举类 DP 的完整画像
| 维度 | 线性 DP(打家劫舍) | 枚举 DP(本文两题) |
|---|---|---|
| 最后一步的来路 | 固定一两种(偷/不偷) | 候选集合(√i 个平方数 / coins 数组) |
| 转移方程 | dp[i] = max(dp[i-1], dp[i-2]+nums[i]) |
dp[i] = min(dp[i-j²]+1) / min(dp[i-coin]+1),候选要遍历 |
| 代码形态 | 一层循环 | 双层循环(外层状态,内层枚举候选) |
| 空间优化 | 可滚动变量 O(1) | 不行,会回溯到很早的下标 |
| 不可达处理 | 不需要 | 零钱兑换需要哨兵值 |
两题之间的差别:
| 维度 | 完全平方数 | 零钱兑换 |
|---|---|---|
| 候选集合来源 | 自己生成(j²,j 从 1 到 √i) | 题目给定(coins 数组) |
| 是否必然有解 | 是(全用 1) | 否(coins 可能没有 1) |
| 初始值 | MAX_VALUE 也安全 | 必须用 amount+1(防溢出) |
| 返回值 | dpn | 要判断不可达返回 -1 |
共同的心法 :和线性 DP 一样"站在 i 往回看最后一步",但当最后一步的来路不止一两种 时,就把候选全部枚举一遍,每个候选转化成子问题,取最优。前半部分照旧当子问题且已知,直接套,不管前面的 i-j² 或 i-coin 内部是怎么凑的,dp 值就是它的最优结果。
这两题本质上都是完全背包的变形:"物品"可以无限次使用,求装满背包的最少件数。理解了这两题,再看真正的完全背包(求组合数/排列数)就是水到渠成。
五、总结
枚举类 DP 的核心思路:
- 判断题型:和线性 DP 同一套特征(元素前后有关系 + 求最优值 + 无后效性),但当"最后一步"的来路是一个集合而非一两种时,进入枚举类
- 状态定义 :
dp[i]= 规模为 i 时的最优值(最少数量 / 最少枚数) - 转移方程 :枚举最后一步的候选 j,
dp[i] = min(dp[i - j] + 1),取所有候选的最小 - 候选集合:可能自己生成(平方数),也可能题目给定(coins)
- 不可达处理 (零钱兑换新增):哨兵值用
amount + 1而非Integer.MAX_VALUE,防+1溢出,又保证大于任何真实答案
与线性 DP 一脉相承的地方:分析手法不变,都是"站在目标位置往回看最后一步";前半部分照旧当子问题已知。变的只是"最后一步"从一个二选一,变成了一个小遍历。