枚举类DP进阶:完全平方数与零钱兑换

本文概览:本文从线性 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];
}

思路简要说明:

  1. 状态定义dp[i] = 和为 i 的完全平方数的最少数量
  2. 枚举转移 :内层循环 j 枚举"最后一个平方数"是 j²,剩下的是子问题 dp[i - j*j]dp[i] = min(dp[i - j*j] + 1)
  3. 初始条件dp[0] = 0(和为 0 需要 0 个数)
  4. 时间复杂度 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];
}

思路简要说明:

  1. 状态定义dp[i] = 凑成金额 i 的最少硬币个数
  2. 转移方程 :枚举最后一枚硬币的面额 coin,dp[i] = min(dp[i], dp[i - coin] + 1)
  3. 不可达表示 :初始值设为 amount + 1(一个不可能达到的大值),最后 dp[amount] > amount 说明凑不出,返回 -1
  4. 初始条件dp[0] = 0(金额 0 需要 0 枚)
  5. 时间复杂度 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 块的极限情况),所以真实答案永远 ≤ amountamount + 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 = 2dp[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 = 5dp[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 的核心思路

  1. 判断题型:和线性 DP 同一套特征(元素前后有关系 + 求最优值 + 无后效性),但当"最后一步"的来路是一个集合而非一两种时,进入枚举类
  2. 状态定义dp[i] = 规模为 i 时的最优值(最少数量 / 最少枚数)
  3. 转移方程 :枚举最后一步的候选 j,dp[i] = min(dp[i - j] + 1),取所有候选的最小
  4. 候选集合:可能自己生成(平方数),也可能题目给定(coins)
  5. 不可达处理 (零钱兑换新增):哨兵值用 amount + 1 而非 Integer.MAX_VALUE,防 +1 溢出,又保证大于任何真实答案

与线性 DP 一脉相承的地方:分析手法不变,都是"站在目标位置往回看最后一步";前半部分照旧当子问题已知。变的只是"最后一步"从一个二选一,变成了一个小遍历。

相关推荐
小七在进步1 小时前
类和对象(二)
java·开发语言
clz13145211 小时前
风险特征平台架构设计与实现
java·flink
写后端的胖头鱼1 小时前
【高频面试题】面试题里常出现的Bitmap是什么(附源码和多种场景)
java·开发语言·面试·位图·bitmap
huang5791471 小时前
从缓存行角度优化链表遍历:批处理与预取策略
数据结构·链表·缓存
benbenAItalk10 小时前
数字人口播视频的批量生产实践:素材规范、任务编排与质量验收
java·前端·音视频
sunshine22 girl10 小时前
Java学习二 基本语法2, 算术运算符
java·学习
2601_9622186110 小时前
万象生鲜系统业财一体化底层打通技术自动生成经营账单
大数据·数据库·人工智能·python·算法
niucloud-admin10 小时前
JAVA V6 多商户商城 开发文档——插件目录结构
java·开发语言