目录
前言
在上一篇文章中,我们初步了解了动态规划的基本思路。这次我将继续分享几道经典的动态规划题目,进一步深入这一解题方法的细节与技巧。希望这篇文章能够帮助大家更好地掌握动态规划✍✍✍
完全平方数
就是给一个整数n
,然后让返回和为这个整数n
的完全平方数的最少数量,所谓完全平方数就是另一个整数的平方
我们直接来梳理动态规划的五部曲
-
确定
dp
数组和下标含义- 可以定义
dp[i]
来表示将整数i
拆分为若干个完全平方数和时的最少完全平方数数量
- 可以定义
-
确定递推公式
其中j*j是一个小于等于i的完全平方数
dp[i] = min(dp[i], dp[i - j * j] + 1) -
dp
数组如何初始化因为i最多完全平方数组合便是由i个1组成,所以可以初始化为i
dp[i] = i -
确定遍历顺序
- 外层循环遍历
i
从 1 到n
,依次计算每个i
的最优解。内层循环枚举每个小于等于i
的完全平方数j
,更新dp[i]
的值
- 外层循环遍历
-
举例推导
dp
数组n = 12
初始化 dp[12] = 12(最坏情况,12个1组成)
dp[12] = min(dp[12], dp[12-11] + 1) = min(12, dp[11] + 1)
dp[12] = min(dp[12], dp[12-22] + 1) = min(dp[12], dp[8] + 1)
dp[12] = min(dp[12], dp[12-3*3] + 1) = min(dp[12], dp[3] + 1)
最终dp[12] 会更新为 3(4 + 4 + 4)
完整代码如下
java
class Solution {
public int numSquares(int n) {
int[] dp = new int[n + 1];//初始化dp数组
for(int i = 0; i <= n; i++){//初始化
dp[i] = i;
}
//遍历1到n,计算每个dp[i]的值
for(int i = 1; i <= n; i++){
for(int j = 1; j * j <= i; j++){//遍历每个小于等于i的完全平方数
dp[i] = Math.min(dp[i], dp[i - j * j] + 1);//递推公式
}
}
return dp[n];
}
}
零钱兑换
这道题是有一个硬币数组coins
,然后的话返回凑成一个所给总金额amount
所需的最少硬币数
直接梳理动态规划的五部曲
-
确定
dp
数组和下标含义- 定义
dp[i]
表示凑成金额i
所需的最少硬币数
- 定义
-
确定递推公式
对于每一个金额 i,如果选择某个硬币 coin,可以得到凑成金额 i - coin 所需的最少硬币数 dp[i - coin]
dp[i]=min(dp[i],dp[i−coin]+1) -
dp
数组如果初始化dp[0] = 0
,因为凑成金额0
不需要任何硬币,其他的dp[i]
初始化为amount+1
,一个大数表示无法凑成也不会影响递推
-
确定遍历顺序
- 外层循环遍历金额从
1
到amount
, 内层循环遍历每种硬币,更新每个dp[i]
的最优解
- 外层循环遍历金额从
-
举例推导
dp
数组coins = [1, 2, 5],amount = 11
对于 i = 1,用硬币 1,dp[1] = min(dp[1], dp[1-1] + 1) = 1
对于 i = 2,用硬币 1 或 2,dp[2] = min(dp[2], dp[2-1] + 1) = min(dp[2], dp[2-2] + 1) = 1
对于 i = 11,尝试用硬币 1、2、5,最终 dp[11] = 3(由硬币 5 + 5 + 1 组成)
完整代码如下
java
class Solution {
public int coinChange(int[] coins, int amount) {
// 定义dp数组
int[] dp = new int[amount + 1];
// 初始化dp数组,dp[0] = 0,其他初始化为一个大数,表示暂时无法凑成
Arrays.fill(dp, amount + 1);
dp[0] = 0;
for(int i = 1; i <= amount; i++){//外层循环遍历金额
for(int coin : coins){//内层循环遍历每个硬币
if(i - coin >= 0){//如果可以使用当前硬币
dp[i] = Math.min(dp[i], dp[i - coin] + 1);
}
}
}
// 返回结果,如果dp[amount]还是初始值,说明无法凑成
return dp[amount] > amount ? -1 : dp[amount];
}
}
单词拆分
这道题就是给一个字符串s
和字符串列表wordDict
作为字典,判断字符串能否利用字典里的词来拼接出来
我们来梳理动态规划五部曲
-
确定
dp
数组和下标含义- 定义布尔型数组
dp
,其中dp[i]
表示字符串s
的前i
个字符s[0:i]
是否可以通过字典中的单词拼接而成
- 定义布尔型数组
-
确定递推公式
对于每一个 i,我们检查从 0 到 i 的每个分割点 j,如果 dp[j] 为 true,且 s[j:i] 在字典 wordDict 中,则 dp[i] = true。
dp[i]=dp[j] 且 s[j:i] 在字典中 -
dp
数组如何初始化dp[0] = true
表示空字符串可以被成功拼接,其他dp[i]
初始化为false
,表示暂时无法拼接
-
确定遍历顺序
- 我们需要遍历从
1
到s.length()
的每个位置i
,并且对于每个i
再遍历j
,检查dp[j]
和s[j:i]
是否在字典中
- 我们需要遍历从
-
举例推导
dp
数组s = "leetcode",wordDict = ["leet", "code"]
初始化 dp[0] = true,因为空字符串总是可以被拼接
对于 i = 4,发现 s[0:4] = "leet" 在字典中,且 dp[0] = true,所以 dp[4] = true
对于 i = 8,发现 s[4:8] = "code" 在字典中,且 dp[4] = true,所以 dp[8] = true
最终,dp[8] = true,表示整个字符串 s 可以被字典中的单词拼接而成
完整代码如下
java
class Solution {
public boolean wordBreak(String s, List<String> wordDict) {
// 定义dp数组,dp[i]表示前i个字符是否可以被字典拼接成
boolean[] dp = new boolean[s.length() + 1];
dp[0] = true;// 初始化dp数组
// 将wordDict转换为HashSet,方便快速查找
Set<String> wordSet = new HashSet<>(wordDict);
for(int i = 0; i <= s.length(); i++){
for(int j = 0; j < i; j++){ // 遍历每一个分割点j
// 检查dp[j]是否为true,且s[j:i]是否在字典中
if(dp[j] && wordSet.contains(s.substring(j, i))){
dp[i] = true;// 更新dp[i]
break;
}
}
}
return dp[s.length()];
}
}
这里可能不易理解的点
- 为什么不用双指针一前一后的方式来解决呢?
- 这种思路的问题在于,它并不会尝试所有可能的分割方式,只会走一条"贪心"路径。也就是说,如果在某个时刻有多种分割方式,它只能选择当前最匹配的单词,而不会回溯去考虑其他可能的分割方式。这种策略在某些情况下无法找到正确的拼接方式
总结
这里单词拆分的递推公式的逻辑大家可能需要多梳理几遍,弄清楚和贪心策略的区别,大家一起加油✊✊ ✊