动态规划类型题专帖,持续更新,主要JAVA实现,对于一些涉及比较多数据结构的也会用C++~
动态规划(dynamic-programming)大概意思就是先将一件事情分成若干阶段,然后通过阶段之间的转移达到目标。由于转移的方向通常是多个,因此这个时候就需要决策选择具体哪一个转移方向。
动态规划所要解决的事情通常是完成一个具体的目标,而这个目标往往是最优解。且:
- 阶段之间可以进行转移(动态)。
- 达到一个可行解(目标阶段) 需要不断地转移,通过设计合适的转移达到最优解(规划)。
动态规划和查表递归有很多相似的地方,往往递归是比较好想的,因此先复习了一下递归。
例:给定一个字符串,要求用递归方法逆序输出。
直接上代码,可以发现递归的写法是非常简单的,只要找到问题的同子类问题,就很好办了。这个例子可以思考,逆序输出,无非也是一个一个字符输出,那么子问题就是输出字符。然后充分利用栈的先进后出的特性,实现逆序输出。
void dfs(String s,int step) {
if (step>=s.length()) {
return ;
}
dfs(s, step+1);
System.out.print(s.charAt(step));
}
一个问题要使用递归来解决必须有递归终止条件(算法的有穷性),也就是说递归会逐步缩小规模,如上述代码的终止条件。
查表递归
有时递归往往涉及很多重复计算的问题,如不注意,使用递归可能会导致堆栈溢出。因此可以在计算完一个子问题后保存这个子问题的解,在后面如果再遇到相同的子问题,直接返回之前计算的结果即可。
再记录个例子:
泰波那契序列 Tn 定义如下:
T0 = 0, T1 = 1, T2 = 1, 且在 n >= 0 的条件下 Tn+3 = Tn + Tn+1 + Tn+2
给你整数 n,请返回第 n 个泰波那契数 Tn 的值。
这个用递归非常好实现,代码如下:
static int dfs(int n) {
if(n==0)return 0;
if(n<=2)return 1;
return ans=dfs(n-1)+dfs(n-2)+dfs(n-3);
}
如果这样写,时间复杂度是O(3n),可以发现,我们计算f(n-1)用到f(n-3),计算f(n-2)也用到f(n-3),但递归还是会重复计算f(n-3)的值,导致了大量的重复工作,因此优化如下,建立个哈希表存储计算过的值:
//建个哈希表记录
static Map<Integer, Integer> map=new HashMap<>();
static int dfs(int n) {
if(n==0)return 0;
if(n<=2)return 1;
if (map.containsKey(n)) return map.get(n);
int ans=dfs(n-1)+dfs(n-2)+dfs(n-3);
//记忆
map.put(n, ans);
return ans;
}
这样时间复杂度变为O(n)级别。
动态规划
动态规划最重要的两个概念:最优子结构和无后效性。
无后效性决定了是否可使用动态规划来解决。
最优子结构决定了具体如何解决。
动态规划三要素
1.状态定义
动态规划解题的第一步就是定义状态。定义好了状态,就可以画出递归树。状态的定义一般都有特定的套路。 比如上述斐波那契数列就可以用dp[i]表示数列第i项的值。
2.状态转移方程
动态规划中当前阶段的状态往往是上一阶段状态和上一阶段决策的结果。还是斐波那契数列,可以很容易写出转移方程dp[i]=dp[i-1]+dp[i-2]+dp[i-3]
3.枚举状态
即决策,通常加上一些限制枚举
为了降低空间复杂度,可以使用滚动数组的技巧,将dp数组维度压缩。
一条包含字母 A-Z 的消息通过以下映射进行了 编码 :
‘A’ -> 1
‘B’ -> 2
…
‘Z’ -> 26
要解码已编码的消息,所有数字必须基于上述映射的方法,反向映射回字母(可能有多种方法)。
例如,“11106” 可以映射为:
“AAJF” ,将消息分组为 (1 1 10 6)
“KJF” ,将消息分组为 (11 10 6)
注意,消息不能分组为 (1 11 06) ,因为 “06” 不能映射为 “F” ,这是由于 “6” 和 “06” 在映射中并不等价。
给你一个只含数字的非空字符串s,请计算并返回解码方法的总数 。
题目数据保证答案肯定是一个 32 位 的整数。
例:
输入:s = “12”
输出:2
解释:它可以解码为 “AB”(1 2)或者 “L”(12)。
public class 解码方法91 {
//暴力递归,超时
static class Solution0 {
static int dfs(String s,int step,String out) {
//走到末尾了,返回
int ans=0;
if(step>=s.length()) {
//System.out.println(out);
return 1;
}
//当前位是0,直接返回
if (s.charAt(step)=='0') {
return 0;
}
//后一位是0,则必须连起来
if ((step+1)<s.length()&&s.charAt(step+1)=='0') {
ans+=dfs(s, step+2,out+s.charAt(step)+""+s.charAt(step+1)+" ");
}
//后一位不是0,当前位是1,可以组合
else if ((step+1)<s.length()&&
(s.charAt(step)=='1'||(s.charAt(step)=='2'&&s.charAt(step+1)<='6'))) {
ans+=dfs(s, step+1,out+s.charAt(step)+" ");
ans+=dfs(s, step+2,out+s.charAt(step)+""+s.charAt(step+1)+" ");
}
else {
ans+=dfs(s, step+1,out+s.charAt(step)+" ");
}
return ans;
}
public int numDecodings(String s) {
return dfs(s, 0, "");
}
}
//动态规划
static class Solution {
public int numDecodings(String s) {
if (s == null||s.length() == 0) {
return 0;
}
int dp[]=new int[s.length()];
for (int i = 0; i < dp.length; i++) {
dp[i]=0;
}
//dp边界初始化,第一个数字为0,则无法编码
dp[0] = s.charAt(0) != '0' ? 1 : 0;
for (int i = 1; i < s.length(); i++) {
int ge = s.charAt(i)-'0';
int shi = 10*(s.charAt(i-1)-'0')+ge;
if (shi >= 10 && shi <= 26) {
//当i=1,能进来这里说明是直接一步以两个数字组合编码,所以只加一
if(i==1) dp[i]+=1;
//否则就取dp[i-2]
else dp[i] = dp[i - 2];
}
if (ge >= 1 && ge <= 9) {
dp[i] += dp[i - 1];
}
}
return dp[s.length()-1];
}
}
public static void main(String[] args) {
Solution s=new Solution();
System.out.println(s.numDecodings("3"));
}
}
给你一个整数数组 coins ,表示不同面额的硬币;以及一个整数 amount ,表示总金额。计算并返回可以凑成总金额所需的 最少的硬币个数 。如果没有任何一种硬币组合能组成总金额,返回 -1 。
你可以认为每种硬币的数量是无限的。
示例 1:输入:coins = [1, 2, 5], amount = 11
输出:3
解释:11 = 5 + 5 + 1
1.定义状态:dp[i]表示凑成i所需要最少硬币
2.状态转移:先分解问题,可以发现dp[i]一定之前某个状态选择了某个面值的硬币的来的。到底选的哪个呢?选最少那个,因此转移方程如下:
dp[i]=min(dp[i-coins[0]],dp[i-coins[1]],…,dp[i-coins[n]])+1
3.边界条件:注意0即可,因为0元必定不用凑就可以实现,即返回0。
public class 零钱兑换322 {
static class Solution{
public int coinChange(int[] coins, int amount) {
int dp[]=new int[amount+1];
//数组每个数赋予最大值,注意从1开始,因为0不用凑,输出0.
for (int i = 1; i < dp.length; i++) {
dp[i]=Integer.MAX_VALUE;
}
//dp数组初始化,各面值对应的金额初始化为0个金额
for (int i = 0; i < coins.length; i++) {
//防止越界
if(coins[i]<dp.length) {
dp[coins[i]]=0;
}
}
//状态枚举
for (int i = 0; i < dp.length; i++) {
int min=Integer.MAX_VALUE;
for (int j = 0; j < coins.length; j++) {
//防止越界
if (i-coins[j]>=0) {
min=Math.min(min, dp[i-coins[j]]);
}
}
if(min!=Integer.MAX_VALUE) {
dp[i]=min+1;
}
}
return dp[amount]==Integer.MAX_VALUE?-1:dp[amount];
}
}
public static void main(String[] args) {
Solution s=new Solution();
int[] coins= {1, 2,5};
System.out.println(s.coinChange(coins,2));
}
}
链接:https://leetcode-cn.com/problems/best-time-to-buy-and-sell-stock-with-cooldown
相关题目:
121. 买卖股票的最佳时机
122. 买卖股票的最佳时机 II
题目描述:
给定一个整数数组,其中第 i 个元素代表了第 i 天的股票价格 。设计一个算法计算出最大利润。在满足以下约束条件下,你可以尽可能地完成更多的交易(多次买卖一支股票):
1.你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。
2.卖出股票后,你无法在第二天买入股票 (即冷冻期为 1 天)。
示例:
输入: [1,2,3,0,2]
输出: 3
解释: 对应的交易状态为: [买入, 卖出, 冷冻期, 买入, 卖出]
1.首先思考状态:有买,卖,冻结三种,如果这样定义状态会比较复杂,因为买卖都是和手头有无股票相关的,有股票才能卖,没股票才能买,因此另外的状态就是当天有无股票,根据这个思路,定义dp[][i]表示第i天的最大利润。
- dp[0][i]表示第i天操作完后手里是有股票的
- dp[1][i]表示第i天操作完后手里是无股票的
2.其次是状态转移
第i天操作完后有股票,说明当天买入或者本来就有。
如果买入利润就是:
dp[1][i-2] - prices[i](之所以是dp[1],因为手里没股票才能买,没股票状态对应dp[1];下标是i-2,是因为有冻结的限制,买入前至少1天前是卖了)
如果本来有:
利润直接取上一个有股票的状态即dp[0][i-1],即当天什么都不干
第i天操作完后没股票,只能是卖了或者本来就没有
如果卖:
利润就是dp[0][i-1] + prices[i](注意这里用的dp[0],因为有股票才能卖,有股票对应dp[0])
本来就:
没有直接取上一个状态dp[1][i-1],即当天什么都不干
因此转移方程如下:
- dp[0][i] = max(dp[0][i - 1], dp[1][i - 2] - prices[i]);
- dp[1][i] = max(dp[1][i - 1], dp[0][i - 1] + prices[i]);
public class 最佳买卖股票时机含冷冻期309 {
static class Solution {
//定义状态dp[i]表示第i天的最大利润
//有两种状态:
//一是手上有股票(只能通过买来获取股票),记为dp[0][i]
//二是没股票(要么是卖了,要么本来就没有),记为dp[1][i]
public int maxProfit(int[] prices) {
if (prices == null || prices.length <= 1) return 0;
// 定义状态变量
int dp[][]=new int[2][prices.length];
//第0天有股票,只能是买入了
dp[0][0] = -prices[0];
//第1天有股票,说明第0天没买
dp[0][1] = Math.max(dp[0][0], -prices[1]);
//显然第0天没股票,收益0
dp[1][0] = 0;
//第1天没股票,肯定这天卖出去了,那么必定是第0天买入
dp[1][1] = Math.max(0, prices[1] - prices[0]);
for (int i = 2; i < prices.length; i++) {
// 第i天有股票,说明当天买入或者本来就有
// 如果买入利润就是dp[1][i-2] - prices[i], 之所以是i-2,是因为有冻结的限制
// 本来有直接取上一个状态即dp[0][i-1],即当天什么都不干
dp[0][i] = Math.max(dp[0][i - 1], dp[1][i - 2] - prices[i]);
// 第i天没股票,只能是卖了或者本来就没有
// 如果卖利润就是dp[0][i-1] + prices[i],注意这里用的dp[0],因为有股票才能卖
// 本来就没有直接取上一个状态dp[1][i-1],即当天什么都不干
dp[1][i] = Math.max(dp[1][i - 1], dp[0][i - 1] + prices[i]);
}
return Math.max(dp[0][prices.length - 1], dp[1][prices.length - 1]);
}
}
public static void main(String[] args) {
Solution s=new Solution();
int a[]= {1,2,3,0,2};
System.out.println(s.maxProfit(a));
}
}
你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。给定一个代表每个房屋存放金额的非负整数数组,计算你不触动警报装置的情况下,一夜之内能够偷窃到的最高金额。
示例 1:
输入:[1,2,3,1]
输出:4
解释:偷窃 1 号房屋 (金额 = 1) ,然后偷窃 3 号房屋 (金额 = 3)。偷窃到的最高金额 = 1 + 3 = 4 。
示例 2:
输入:[2,7,9,3,1]
输出:12
解释:偷窃 1 号房屋 (金额 = 2), 偷窃 3 号房屋 (金额 = 9),接着偷窃 5 号房屋 (金额 = 1)。偷窃到的最高金额 = 2 + 9 + 1 = 12 。
提示:
1 <= nums.length <= 100
0 <= nums[i] <= 400
状态定义方式1:
每个房间可以选择盗和不盗,若上一个房间盗了,此房间不可盗,因此定义状态如下:dp[j][i]表示盗窃到i号房间(j为0和1,0代表上一个房间没盗)的最大金额
容易得到如下状态转移方程:
- dp[i][0]=max(dp[0][i-1],dp[1][i-1]);
- dp[i][1]=max(dp[0][i-1],dp[0][i-2],dp[1][i-2])+nums[i];
状态定义方式2:
dp[i]表示盗窃1-i房间的最大金额。对于房间i,可以选择盗和不盗,若盗,则i-1号房间不可盗,因此状态转移如下:
- dp[i]=max(dp[i-1],dp[i-2]+nums[i])
public class 打家劫舍198 {
static class Solution {
//方法1************************************************************************
//每个房间可以选择盗和不盗,若上一个房间盗了,此房间不可盗
//定义状态:dp[i][j]表示盗窃到i号房间(j为0和1,0代表上一个房间没盗)的最大金额
//状态转移:
//dp[i][0]=max(dp[i-1][0],dp[i-1][1]);
//dp[i][1]=max(dp[i-1][0],dp[i-2][0],dp[i-2][1])+nums[i-1];
public int rob0(int[] nums) {
int dp[][]=new int[2][nums.length+1];
//边界初始化
dp[0][1]=0; //不盗1号房
dp[1][1]=nums[0]; //盗1号房
for (int i = 2; i < dp[0].length; i++) {
dp[0][i]=Math.max(dp[0][i-1], dp[1][i-1]);
dp[1][i]=Math.max(dp[0][i-1],Math.max(dp[0][i-2],dp[1][i-2]))+nums[i-1];
}
return Math.max(dp[0][nums.length], dp[1][nums.length]);
}
//方法2************************************************************************
//定义状态:dp[i]表示盗窃1-i房间的最大金额
//对于房间i,可以选择盗和不盗,若盗,则i-1号房间不可盗,因此
//状态转移如下:
//dp[i]=max(dp[i-1],dp[i-2]+nums[i])
//采用滚动数组压缩状态
public int rob(int[] nums) {
int ra=0,rb=0,temp=0;
for (int i = 0; i < nums.length; i++) {
temp=Math.max(ra, rb+nums[i]);
rb=ra;
ra=temp;
}
return temp;
}
}
public static void main(String[] args) {
Solution s=new Solution();
int a[]= {1,2,3,1};
System.out.println(s.rob(a));
}
}
给定一个非空字符串 s 和一个包含非空单词的列表 wordDict,判定 s 是否可以被空格拆分为一个或多个在字典中出现的单词。
说明:
拆分时可以重复使用字典中的单词。
你可以假设字典中没有重复的单词。
示例 1:
输入: s = “leetcode”, wordDict = [“leet”, “code”]
输出: true
解释: 返回 true 因为 “leetcode” 可以被拆分成 “leet code”。
示例 2:
输入: s = “applepenapple”, wordDict = [“apple”, “pen”]
输出: true
解释: 返回 true 因为 “applepenapple” 可以被拆分成 “apple pen apple”。注意你可以重复使用字典中的单词。
示例 3:
输入: s = “catsandog”, wordDict = [“cats”, “dog”, “sand”, “and”, “cat”]
输出: false
问题分解可以分解为对子串的匹配,若s[0:i]可以匹配,那么如果此子串下一个词word刚好也能匹配,则说明s[0:i+len(word)]也能匹配。
状态定义:dp[i]表示字符串s的子串s[0:i]可以被匹配
转移方程:dp[i]=dp[i-wordDict[j].length] and (s[i-wordDict[j]:i]==wordDict[j])
这里记录下字符串状态定义的套路:dp[i]表示子串s[0:i]可以…
public class 单词拆分139 {
static class Solution {
//字符串状态定义,一般如下:
//状态定义:dp[i]表示字符串以i结尾的字串可以被匹配
//转移方程:dp[i]=dp[i-wordDict[j].length]and (s[i-wordDict[j]:i]==wordDict[j])
public boolean wordBreak(String s, List<String> wordDict) {
boolean dp[]=new boolean[s.length()+1];//默认初始化都是false
//边界初始化。
dp[0]=true;
for (int i = 1; i < dp.length; i++) {
for (int j = 0; j < wordDict.size(); j++) {
int begin=i-wordDict.get(j).length();
if (begin>=0) {
//注意下标
boolean cmp=s.substring(begin, i).equals(wordDict.get(j));
dp[i]=dp[begin]&&cmp;
//一旦匹配到就退出。
if(dp[i]) {
break;
}
}
}
}
return dp[s.length()];
}
}
public static void main(String[] args) {
Solution s=new Solution();
List<String> t=new LinkedList<>();
t.add("apple");
t.add("pen");
System.out.println(s.wordBreak("applepenapple", t));
}
}
首先是问题转化,其实题目意思就是:对于给定非空数组,能否找到一个子集,其元素之和等于整个数组元素和的一半。显然对于数组元素和是奇数的话,肯定不能找到,直接返回false。那么接下来就好办了。
状态定义:
如果前i个数能否组成sum,取决于前i-1个数能否组成sum或前i-1个数组成的和与sum的差值刚好等于第i个数。所以定义dp[i][j]表示数组前i个数是否能找到和为j的子集。
转移方程:
dp[i][j]=dp[i-1][j] or dp[i-1][j-nums[i]]
可以发现,第i行只与前一行有关,与当前行其他列无关,因此可以压缩一维。
public class 分割等和子集416 {
/*
* 如果前i个数能否组成sum,取决于前i-1个数能否组成sum或前i-1个数组成的和与sum的差值刚好等于第i个数
* 即f(i,sum)=f(i-1,sum) or f(i-1,sum-nums[i])
*/
static class Solution {
public boolean canPartition0(int[] nums) {
int sum=0;
for (int i = 0; i < nums.length; i++) {
sum+=nums[i];
}
if (sum%2!=0) return false;
sum=sum/2;
//表示前i个数能组成sum,发现i行只与i-1行有关,因此可以压缩一维
boolean dp[][]=new boolean[nums.length][sum+1];
//前i个数可以组成0
for (int i = 0; i < dp.length; i++) {
dp[i][0]=true;
}
for (int i = 0; i < nums.length; i++) {
for (int j = 0; j < sum+1 ; j++) {
if(i<1) continue;
if((j - nums[i])>=0)
dp[i][j]=dp[i-1][j]||dp[i-1][j - nums[i]];
else
dp[i][j]=dp[i-1][j];
}
}
return dp[nums.length-1][sum];
}
//压缩一维的写法
public boolean canPartition(int[] nums) {
int sum=0;
for (int i = 0; i < nums.length; i++) {
sum+=nums[i];
}
if (sum%2!=0) return false;
sum=sum/2;
//表示前i个数能组成sum,发现i行只与i-1行有关,因此可以压缩一维
boolean dp[]=new boolean[sum+1];
//前0个数可以组成0
dp[0]=true;
for (int i = 0; i < nums.length; i++) {
for (int j = sum; j >0 ; j--) {
dp[j]=dp[j]||((j - nums[i]) >= 0 && dp[j - nums[i]]);
}
}
return dp[sum];
}
}
public static void main(String[] args) {
Solution s=new Solution();
int a[]= {1,2,2,5};
System.out.println(s.canPartition0(a));
}
}
其实正解是贪心算法,具体看官网。由于一看到数组题就自然想到用dp,然后没想到也能AC。
首先是状态定义:dp[i]表示到达下标i需要的最小步数。
那么dp[i+1]和之前的dp有什么关系呢?以输入为[2,3,0,1,4]为例。
- 因为开始在第0个位置,所以dp[0]=0,不需要跳跃,那么我们将dp数组后面的项全都初始化为一个最大的值,由于第0个位置为2,所以可以跳到第1和第2个位置,因此到达第1和第2个位置最小步数就算1。
- 然后到dp[1],此时dp[1]=1,其可以跳3步,即可以到达第2,3,4个位置,因此也更新这几个位置的值,但是我们知道第2个位置之前可以一步就到达,因此不需要经过此次跳跃(从位置1跳到位置2就多出一步了),其余的就更新,即dp[3]=dp[4]=dp[1]+1
- 经过上面的分析,其实转移方程就出来了
设i为当前所在位置,j为从i+1到nums[i]+i之间的所有取值(因为i位置最多可以往后跳nums[i]),则dp[j]=min(dp[j], dp[i]+1)
class Solution {
public int jump(int[] nums) {
//dp[i]表示到达下标i需要的最小步数
int dp[]=new int[nums.length];
//第一个位置不需要跳跃
dp[0]=0;
//其余位置先初始化一个最大值
for (int i = 1; i < dp.length; i++) {
dp[i]=Integer.MAX_VALUE;
}
for (int i = 0; i < dp.length; i++) {
//在能跳跃范围内整体更新dp
for (int j = i+1; j <= i+nums[i]; j++) {
if(j<dp.length)
dp[j]=Math.min(dp[j], dp[i]+1);
}
}
return dp[nums.length-1];
}
首先思考能否将问题拆分成子问题。首先大家可能想到的是定义dp[i]表示前i个元素的最大连续子序列和,这样定义的一个问题不清楚第i个元素是否包含在连续子序列中,使得转移方程很难想象。因此换个思路,如果定义dp[i]为给定序列以第i个元素结尾(连续序列包含第i个元素)的最大连续子序列和呢?那么转移方程是不是很容易就思考出来了?这样对于dp[i+1]有两种情况:
- 一是引进第i+1项,即dp[i+1]=dp[i]+nums[i+1]
- 二是以第i+1项重新开始,即dp[i+1]=nums[i]
所以转移方程为:dp[i+1]=max(dp[i]+nums[i+1],nums[i+1]),最后用一个变量不断保存中间计算过程的最大值即可。
static class Solution {
public int maxSubArray(int[] nums) {
//如果采用滚动数组,把dp数组定义为一个变量即可,空间复杂度就算O(1)
int dp[]=new int[nums.length];
dp[0]=nums[0];
int max=nums[0];
for (int i = 1; i < dp.length; i++) {
dp[i]=Math.max(dp[i-1]+nums[i], nums[i]);
max=Math.max(max, dp[i]);
}
return max;
}
}