Java 动态规划 - 力扣 零钱兑换与完全平方数 深度解析

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步操作
  2. 记录:用数组存储小问题的答案
  3. 复用:算大问题时,直接查表

类比:

  • 暴力递归 = 每次都重新算,像没带计算器的考试
  • 动态规划 = 把答案记在草稿纸上,像带了小抄的考试

1.3 什么时候用动态规划?

满足这三个条件,就用DP:

  1. 能拆成小问题:大问题可以分解
  2. 小问题会重复:同一个小问题被多次用到
  3. 最优解依赖小问题:大问题的最优解,由小问题推出

典型场景:

  • 最值问题:最少、最多、最长、最短
  • 计数问题:有多少种方法
  • 存在性问题:能否达成目标

二、动态规划解题四步法

所有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:处理边界

核心: 避免数组越界、除零、无解

常见边界:

  1. 循环条件:确保不越界(如coin ≤ i)
  2. 无解情况:如果dp[目标]还是初始值,说明无解
  3. 特殊输入: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背包问题

九、总结

核心要点

  1. 动态规划 = 记笔记做题

    • DP数组 = 笔记本
    • 递推公式 = 用笔记算新题
    • 避免重复计算 = 查表而不是重算
  2. 解题四步走

    • 定义状态:dp[i]代表什么
    • 确定初始值:dp[0]等于多少
    • 推导公式:dp[i]怎么算
    • 处理边界:避免越界和无解
  3. 核心公式

    tex 复制代码
    dp[i] = 从所有"小问题+1步操作"中,选最优的
  4. 零钱兑换和完全平方数

    • 代码结构完全一样
    • 只是"选择"不同(硬币 vs 完全平方数)
    • 理解一个,就能秒杀另一个

记忆口诀

tex 复制代码
动态规划不难学
拆问题、记答案、推结果
定义状态最关键
初始值、递推式、边界判
零钱兑换和平方数
代码一样思路通
掌握模板刷百题
面试不慌拿offer

学习建议

  1. 先手动推导,再写代码

    • 拿纸笔算dp[0]到dp[n]
    • 理解每一步怎么来的
    • 再写代码会轻松很多
  2. 打印dp数组调试

    • 不确定对不对,就打印出来看
    • 对比手动推导的结果
    • 找出哪里算错了
  3. 对比相似题目

    • 零钱兑换和完全平方数一起看
    • 找相同点和不同点
    • 理解DP的通用性
  4. 从简单题开始

    • 先刷爬楼梯(最简单)
    • 再刷零钱兑换(中等)
    • 最后刷背包问题(困难)

作者:[识君啊]

不要做API的搬运工,要做原理的探索者!

相关推荐
HoneyMoose1 小时前
Eclipse Temurin JDK 21 ubuntu 安装
java·ubuntu·eclipse
笨蛋不要掉眼泪1 小时前
Sentinel 热点参数限流实战:精准控制秒杀接口的流量洪峰
java·前端·分布式·spring·sentinel
xiaoye-duck1 小时前
《算法题讲解指南:优选算法-滑动窗口》--09长度最小的子数串,10无重复字符的最长字串
c++·算法
蜜獾云1 小时前
Java集合遍历方式详解(for、foreach、iterator、并行流等)
java·windows·python
※DX3906※1 小时前
Java多线程3--设计模式,线程池,定时器
java·开发语言·ide·设计模式·intellij idea
Frostnova丶2 小时前
LeetCode 762 二进制表示中质数个计算置位
算法·leetcode
WZ188104638692 小时前
LeetCode第367题
算法·leetcode
破晓之翼2 小时前
金蝶EAS OpenAPI 开发说明文档
java·经验分享·其他
空空潍2 小时前
Redis点评实战篇-关注推送
java·数据库·redis·缓存