Java 动态规划 - 力扣 零钱兑换与完全平方数 深度解析
动态规划就是"记笔记做题",看完这篇,秒懂DP
一、从一个故事开始
1.1 爬楼梯的困惑
小明要爬10层楼,每次可以爬1层或2层。他想知道有多少种爬法?
第一次尝试(暴力递归):
java
int climbStairs(int n) {
if (n == 1) return 1;
if (n == 2) return 2;
return climbStairs(n-1) + climbStairs(n-2);
}
小明写完代码,输入10,电脑算了半天。输入50,直接卡死。
为什么这么慢? 因为重复计算太多:
tex
计算climb(10)需要:
climb(9) + climb(8)
计算climb(9)需要:
climb(8) + climb(7)
计算climb(8)需要:
climb(7) + climb(6)
你看,climb(8)被算了2次,climb(7)被算了3次...
越往下,重复越多,指数级增长!
动态规划的解决方案:
用一个数组记录每层楼的答案,算过的就不再算:
java
int climbStairs(int n) {
int[] dp = new int[n + 1];
dp[1] = 1; // 1层:1种方法
dp[2] = 2; // 2层:2种方法
for (int i = 3; i <= n; i++) {
dp[i] = dp[i-1] + dp[i-2]; // 直接查表,不重复计算
}
return dp[n];
}
输入50,瞬间出结果。这就是动态规划的威力。
1.2 动态规划的本质
一句话总结: 把大问题拆成小问题,记住小问题的答案,避免重复计算。
三个核心:
- 拆解:大问题 = 小问题 + 1步操作
- 记录:用数组存储小问题的答案
- 复用:算大问题时,直接查表
类比:
- 暴力递归 = 每次都重新算,像没带计算器的考试
- 动态规划 = 把答案记在草稿纸上,像带了小抄的考试
1.3 什么时候用动态规划?
满足这三个条件,就用DP:
- 能拆成小问题:大问题可以分解
- 小问题会重复:同一个小问题被多次用到
- 最优解依赖小问题:大问题的最优解,由小问题推出
典型场景:
- 最值问题:最少、最多、最长、最短
- 计数问题:有多少种方法
- 存在性问题:能否达成目标
二、动态规划解题四步法
所有DP题都能套这个模板,记住这四步,刷题快人一步。
步骤1:定义状态
核心: 明确dp[i]代表什么
技巧: dp[i]的定义,直接对应"当目标为i时的答案"
举例:
tex
零钱兑换:dp[i] = 凑出i元需要的最少硬币数
爬楼梯:dp[i] = 爬到第i层有多少种方法
完全平方数:dp[i] = 组成i需要的最少完全平方数个数
步骤2:确定初始值
核心: 找到"最简单、不用推导就知道答案"的情况
技巧: 通常是dp[0],表示"目标为0时的答案"
举例:
tex
零钱兑换:dp[0] = 0(凑0元需要0个硬币)
爬楼梯:dp[1] = 1, dp[2] = 2
完全平方数:dp[0] = 0(组成0需要0个)
注意: 其他dp[i]要初始化为"不可能的值"
- 求最小值:初始化为无穷大
- 求最大值:初始化为负无穷
- 求方案数:初始化为0
步骤3:推导递推公式
核心: 找到"大问题和小问题的关系"
万能公式:
tex
dp[i] = 从所有"小问题+1步操作"中,选最优的
举例:
零钱兑换:
tex
要凑i元,可以选一枚硬币coin
凑i元 = 凑(i-coin)元 + 1个硬币
dp[i] = min(dp[i-coin] + 1) // 试所有硬币,取最小
爬楼梯:
tex
要爬到i层,可以从(i-1)层爬1层,或从(i-2)层爬2层
dp[i] = dp[i-1] + dp[i-2]
完全平方数:
tex
要组成i,可以选一个完全平方数j²
组成i = 组成(i-j²) + 1个完全平方数
dp[i] = min(dp[i-j²] + 1) // 试所有j²,取最小
步骤4:处理边界
核心: 避免数组越界、除零、无解
常见边界:
- 循环条件:确保不越界(如coin ≤ i)
- 无解情况:如果dp[目标]还是初始值,说明无解
- 特殊输入:n=0, n=1等
三、经典例题:零钱兑换(LeetCode 322)
3.1 题目
给定硬币面额coins和目标金额amount,计算凑成目标金额所需的最少硬币数。无法凑成返回-1。
示例:
tex
输入:coins = [1,2,5], amount = 11
输出:3
解释:11 = 5 + 5 + 1
3.2 思路分析
问题: 凑11元,最少需要几个硬币?
拆解:
- 凑11元 = 凑10元 + 1个1元硬币
- 或者 = 凑9元 + 1个2元硬币
- 或者 = 凑6元 + 1个5元硬币
- 选最少的那个
递推公式:
dp[11] = min(dp[10]+1, dp[9]+1, dp[6]+1)
关键: 要先知道dp[10]、dp[9]、dp[6],所以从小到大算。
3.3 手动推导(重要)
假设coins = [1,2,5], amount = 11
tex
dp[0] = 0(凑0元需要0个硬币)
dp[1] = ?
试coin=1:dp[1-1] + 1 = dp[0] + 1 = 1
dp[1] = 1
dp[2] = ?
试coin=1:dp[2-1] + 1 = dp[1] + 1 = 2
试coin=2:dp[2-2] + 1 = dp[0] + 1 = 1 ← 最小
dp[2] = 1
dp[3] = ?
试coin=1:dp[3-1] + 1 = dp[2] + 1 = 2
试coin=2:dp[3-2] + 1 = dp[1] + 1 = 2
dp[3] = 2
dp[4] = ?
试coin=1:dp[4-1] + 1 = dp[3] + 1 = 3
试coin=2:dp[4-2] + 1 = dp[2] + 1 = 2 ← 最小
dp[4] = 2
dp[5] = ?
试coin=1:dp[5-1] + 1 = dp[4] + 1 = 3
试coin=2:dp[5-2] + 1 = dp[3] + 1 = 3
试coin=5:dp[5-5] + 1 = dp[0] + 1 = 1 ← 最小
dp[5] = 1
dp[6] = ?
试coin=1:dp[6-1] + 1 = dp[5] + 1 = 2
试coin=2:dp[6-2] + 1 = dp[4] + 1 = 3
试coin=5:dp[6-5] + 1 = dp[1] + 1 = 2
dp[6] = 2
...继续算到dp[11]
dp[11] = ?
试coin=1:dp[11-1] + 1 = dp[10] + 1 = 3
试coin=2:dp[11-2] + 1 = dp[9] + 1 = 4
试coin=5:dp[11-5] + 1 = dp[6] + 1 = 3
dp[11] = 3
答案: 3个硬币(5+5+1)
3.4 完整代码
java
import java.util.Arrays;
class Solution {
public int coinChange(int[] coins, int amount) {
// 步骤1:定义状态
// dp[i] = 凑出i元需要的最少硬币数
int[] dp = new int[amount + 1];
// 步骤2:初始化
// dp[0] = 0(凑0元需要0个)
// 其他设为amount+1(表示暂时凑不出)
Arrays.fill(dp, amount + 1);
dp[0] = 0;
// 遍历所有金额
for (int i = 1; i <= amount; i++) {
// 步骤3:递推公式
// 尝试所有硬币,选最少的
for (int coin : coins) {
// 步骤4:边界判断
if (coin <= i) {
dp[i] = Math.min(dp[i], dp[i - coin] + 1);
}
}
}
// 步骤4:处理无解
return dp[amount] > amount ? -1 : dp[amount];
}
}
时间复杂度: O(amount × coins.length)
空间复杂度: O(amount)
3.5 代码详解
为什么dp数组长度是amount+1?
java
// 因为要用到dp[0]到dp[amount],共amount+1个元素
int[] dp = new int[amount + 1];
为什么初始化为amount+1?
java
// 凑amount元,最多需要amount个1元硬币
// 如果dp[i]还是amount+1,说明凑不出i元
Arrays.fill(dp, amount + 1);
为什么是dp[i-coin]+1?
java
// dp[i-coin]表示"凑出(i-coin)元需要的硬币数"
// 再加1个coin硬币,就能凑出i元
dp[i] = Math.min(dp[i], dp[i - coin] + 1);
为什么要判断coin <= i?
java
// 如果coin > i,比如用5元硬币凑3元,不可能
// 会导致dp[i-coin]数组越界(i-coin < 0)
if (coin <= i) {
// ...
}
为什么最后判断dp[amount] > amount?
java
// 如果dp[amount]还是初始值amount+1,说明无法凑出
// 返回-1表示无解
return dp[amount] > amount ? -1 : dp[amount];
四、经典例题:完全平方数(LeetCode 279)
4.1 题目
给定正整数n,找到最少数量的完全平方数(1, 4, 9, 16, ...)相加,使其和等于n。
示例:
tex
输入:n = 12
输出:3
解释:12 = 4 + 4 + 4
4.2 思路分析
问题: 组成12,最少需要几个完全平方数?
拆解:
- 组成12 = 组成11 + 1个1(1²=1)
- 或者 = 组成8 + 1个4(2²=4)
- 或者 = 组成3 + 1个9(3²=9)
- 选最少的那个
递推公式:
tex
dp[12] = min(dp[11]+1, dp[8]+1, dp[3]+1)
4.3 手动推导
假设n = 12
tex
完全平方数:1, 4, 9, 16, 25...
dp[0] = 0
dp[1] = ?
试j=1,j²=1:dp[1-1] + 1 = dp[0] + 1 = 1
dp[1] = 1
dp[2] = ?
试j=1,j²=1:dp[2-1] + 1 = dp[1] + 1 = 2
dp[2] = 2
dp[3] = ?
试j=1,j²=1:dp[3-1] + 1 = dp[2] + 1 = 3
dp[3] = 3
dp[4] = ?
试j=1,j²=1:dp[4-1] + 1 = dp[3] + 1 = 4
试j=2,j²=4:dp[4-4] + 1 = dp[0] + 1 = 1 ← 最小
dp[4] = 1
dp[5] = ?
试j=1,j²=1:dp[5-1] + 1 = dp[4] + 1 = 2
试j=2,j²=4:dp[5-4] + 1 = dp[1] + 1 = 2
dp[5] = 2
...继续算到dp[12]
dp[8] = ?
试j=1:dp[8-1] + 1 = dp[7] + 1 = 4
试j=2:dp[8-4] + 1 = dp[4] + 1 = 2 ← 最小
dp[8] = 2
dp[12] = ?
试j=1:dp[12-1] + 1 = dp[11] + 1 = 4
试j=2:dp[12-4] + 1 = dp[8] + 1 = 3 ← 最小
试j=3:dp[12-9] + 1 = dp[3] + 1 = 4
dp[12] = 3
答案: 3个完全平方数(4+4+4)
4.4 完整代码
java
class Solution {
public int numSquares(int n) {
// 步骤1:定义状态
// dp[i] = 组成i需要的最少完全平方数个数
int[] dp = new int[n + 1];
// 步骤2:初始化
// dp[0] = 0,其他设为无穷大
Arrays.fill(dp, Integer.MAX_VALUE);
dp[0] = 0;
// 遍历所有数字
for (int i = 1; i <= n; i++) {
// 步骤3:递推公式
// 尝试所有完全平方数j²
for (int j = 1; j * j <= i; j++) {
// 步骤4:边界判断
dp[i] = Math.min(dp[i], dp[i - j * j] + 1);
}
}
return dp[n];
}
}
时间复杂度: O(n√n)
空间复杂度: O(n)
4.5 两道题的对比
零钱兑换和完全平方数,代码几乎一模一样:
| 对比项 | 零钱兑换 | 完全平方数 |
|---|---|---|
| 选择 | 硬币面额(coins数组) | 完全平方数(1,4,9,16...) |
| 递推公式 | dp[i] = min(dp[i-coin] + 1) | dp[i] = min(dp[i-j²] + 1) |
| 无解情况 | 可能无解(返回-1) | 必有解(至少用n个1) |
| 循环条件 | coin ≤ i | j² ≤ i |
核心思想完全相同: 大问题 = 小问题 + 1步操作
五、动态规划通用模板
掌握这个模板,秒杀80%的DP题:
java
public int dp问题(int n) {
// 步骤1:定义状态
// dp[i] = 当目标为i时的答案
int[] dp = new int[n + 1];
// 步骤2:初始化
// dp[0] = 基础情况的答案
dp[0] = 基础答案;
// 其他dp[i] = 初始值
// 求最小值:Integer.MAX_VALUE
// 求最大值:Integer.MIN_VALUE
// 求方案数:0
for (int i = 1; i <= n; i++) {
dp[i] = 初始值;
}
// 遍历所有小问题
for (int i = 1; i <= n; i++) {
// 步骤3:递推公式
// 尝试所有可能的"1步操作"
for (每个可能的选择) {
// 步骤4:边界判断
if (选择合法) {
dp[i] = 更新答案(dp[i], dp[i-选择] + 1);
}
}
}
// 返回答案(注意处理无解情况)
return dp[n];
}
六、常见错误避坑指南
错误1:状态定义错误
java
// 零钱兑换题目
// 错误:dp[i] = 凑出i元的硬币组合数(和题目要求不符)
// 正确:dp[i] = 凑出i元需要的最少硬币数
错误2:初始值设置错误
java
// 错误:求最小值时,初始化为0
int[] dp = new int[n + 1]; // 默认全是0
// 正确:求最小值时,初始化为无穷大
Arrays.fill(dp, Integer.MAX_VALUE);
dp[0] = 0;
错误3:忘记边界判断
java
// 错误:没判断coin <= i,导致数组越界
for (int coin : coins) {
dp[i] = Math.min(dp[i], dp[i - coin] + 1); // 当coin > i时,越界!
}
// 正确:先判断
for (int coin : coins) {
if (coin <= i) {
dp[i] = Math.min(dp[i], dp[i - coin] + 1);
}
}
错误4:递推公式少加1
java
// 错误:忘记+1
dp[i] = Math.min(dp[i], dp[i - coin]); // 少了+1
// 正确:+1表示"当前选择的这一步"
dp[i] = Math.min(dp[i], dp[i - coin] + 1);
错误5:无解情况未处理
java
// 零钱兑换题目
// 错误:直接返回dp[amount],可能返回无穷大
return dp[amount];
// 正确:判断是否无解
return dp[amount] > amount ? -1 : dp[amount];
七、进阶技巧
技巧1:打印DP数组(调试神器)
不确定递推对不对?打印dp数组看看:
java
// 在代码中加入
System.out.println("dp数组:" + Arrays.toString(dp));
// 输出示例(零钱兑换,coins=[1,2,5], amount=11)
// dp数组:[0, 1, 1, 2, 2, 1, 2, 2, 3, 3, 2, 3]
技巧2:空间优化(滚动数组)
如果只依赖前几个状态,可以优化空间:
java
// 爬楼梯(基础版本:O(n)空间)
int[] dp = new int[n + 1];
dp[1] = 1;
dp[2] = 2;
for (int i = 3; i <= n; i++) {
dp[i] = dp[i-1] + dp[i-2];
}
// 爬楼梯(优化版本:O(1)空间)
int prev2 = 1, prev1 = 2;
for (int i = 3; i <= n; i++) {
int curr = prev1 + prev2;
prev2 = prev1;
prev1 = curr;
}
技巧3:记忆化搜索(递归版DP)
有些人更习惯递归思维:
java
// 零钱兑换(记忆化搜索版本)
class Solution {
int[] memo;
public int coinChange(int[] coins, int amount) {
memo = new int[amount + 1];
Arrays.fill(memo, -2); // -2表示未计算
return dp(coins, amount);
}
private int dp(int[] coins, int amount) {
if (amount == 0) return 0;
if (amount < 0) return -1;
// 查缓存
if (memo[amount] != -2) return memo[amount];
// 递推
int res = Integer.MAX_VALUE;
for (int coin : coins) {
int sub = dp(coins, amount - coin);
if (sub == -1) continue;
res = Math.min(res, sub + 1);
}
// 存缓存
memo[amount] = (res == Integer.MAX_VALUE) ? -1 : res;
return memo[amount];
}
}
八、力扣同类题推荐
掌握零钱兑换和完全平方数后,可以刷这些题:
| 题号 | 题目 | 难度 | 核心思路 |
|---|---|---|---|
| 70 | 爬楼梯 | 简单 | dp[i] = dp[i-1] + dp[i-2] |
| 198 | 打家劫舍 | 中等 | dp[i] = max(dp[i-1], dp[i-2]+nums[i]) |
| 300 | 最长递增子序列 | 中等 | dp[i] = max(dp[j]+1) |
| 322 | 零钱兑换 | 中等 | 本文例题 |
| 279 | 完全平方数 | 中等 | 本文例题 |
| 139 | 单词拆分 | 中等 | dp[i] = dp[i-len] && 字典包含 |
| 518 | 零钱兑换II | 中等 | 完全背包问题 |
| 416 | 分割等和子集 | 中等 | 0-1背包问题 |
九、总结
核心要点
-
动态规划 = 记笔记做题
- DP数组 = 笔记本
- 递推公式 = 用笔记算新题
- 避免重复计算 = 查表而不是重算
-
解题四步走
- 定义状态:dp[i]代表什么
- 确定初始值:dp[0]等于多少
- 推导公式:dp[i]怎么算
- 处理边界:避免越界和无解
-
核心公式
texdp[i] = 从所有"小问题+1步操作"中,选最优的 -
零钱兑换和完全平方数
- 代码结构完全一样
- 只是"选择"不同(硬币 vs 完全平方数)
- 理解一个,就能秒杀另一个
记忆口诀
tex
动态规划不难学
拆问题、记答案、推结果
定义状态最关键
初始值、递推式、边界判
零钱兑换和平方数
代码一样思路通
掌握模板刷百题
面试不慌拿offer
学习建议
-
先手动推导,再写代码
- 拿纸笔算dp[0]到dp[n]
- 理解每一步怎么来的
- 再写代码会轻松很多
-
打印dp数组调试
- 不确定对不对,就打印出来看
- 对比手动推导的结果
- 找出哪里算错了
-
对比相似题目
- 零钱兑换和完全平方数一起看
- 找相同点和不同点
- 理解DP的通用性
-
从简单题开始
- 先刷爬楼梯(最简单)
- 再刷零钱兑换(中等)
- 最后刷背包问题(困难)
作者:[识君啊]
不要做API的搬运工,要做原理的探索者!