动态规划
动态规划(dynamic programming):它是把研究的问题分成若干个阶段,且在每一个阶段都要“动态地”做出决策,从而使整个阶段都要取得最优效果。
理解:其实,无非就是利用历史记录,来避免我们的重复计算。
而这些历史记录,我们得需要一些变量来保存,一般是用一维数组或者多维数组来保存。
其实我挺好奇为什么用动态规划这个名字的,所以我花时间找了一下,如果大家想要知道这个名字的由来,可以去看看:动态规划
- 总结版解释:从时间阶段的迭代中,找到当前时间阶段的最优解
适用场景
虽然动态规划主要用于求解以时间划分阶段的动态过程的优化问题,但是一些与时间无关的静态规划(如线性规划、非线性规划),只要人为地引进时间因素,把它视为多阶段决策过程,也可以用动态规划方法方便地求解。
连续多阶段决策且递推。
注意:得是连续的,因为数组是连续的顺序表,你得把数组填满,才能获得答案!
得是递推的,如今的状态是根据以前的状态递推出来的。这里与回溯法不同的是,回溯法以前的状态不一定是最佳状态,回溯法只是将所有可能性都遍历完了;而动态规划以前的状态必须是最佳状态,每一次递推都是从以前的最佳状态开始递推,比较有大局观。第 n 种情况,n 为阶段,为 n 时的结果。
比如说剪绳子问题,它会跟你说给你一根长度为 n 的绳子,那么我们可以考虑一下 n - 1 或者 n - 2 是不是能推出 n。。
最优值:最大、最多等
题目只问最优值,并没有问最优解,因此绝大多数情况下可以考虑使用「动态规划」的方法。
理解:
最优值:集合内元素和的最大值(如 99)
最优解:集合内元素和为最大的集合(如 [90, 9])前后数据有关
关键词:为 n 时的结果,阶段,递推,最
动态规划四步走
1. 明确数组的含义
1.明确数组的含义:建立数组,明确数组元素的含义,数组需要记录不同阶段(节点)的状态,每一个数组元素及其下标都应该对应着一个阶段(节点)的状态;
这种状态编码一般都具有前缀性质,即 考虑了之前编号的数据,即 当前的状态值考虑了(或者说综合了)之前的相关的状态值
这里需要注意,动态规划的状态不是当前第 i 个,而是
前 i 个
累计的状态。
注意:阶段一定要和数组一样是连续的,我们只有填满数组,才能获得答案;
比如说,01背包的背包重量容量状态,我们其实有效的也就那么几个值,容量一个一个增加物品也放不进去,但是我们还是得连续的一个一个向上增加,因为我们这数组是一个连续的表,并不能让我们从中空出几个,并且,空出几个也不利于我们的递推填表,我们需要填满整个表,才能得出我们的答案!
这里要注意的是,我们的动态规划是不同于递归的,递归不需要记录当前阶段的状态就能知道该走哪一步,慢慢走下去,而我们的动态规划是迭代方法,需要不停记录当前阶段的状态,否则可能就找不着东南西北了。
而我们的数组就是记录状态的好东西!!!
一维数组可以记录两个状态,一是下标状态,二是下标对应的数值状态;
二维数组可以记录三个状态,一是一维下标状态,二是二维下标状态,三是下标对应的数值状态;
这里你可以想想01背包问题,二维数组的一维下标记录了当前选择的物品编号(前i个物品分配情况的阶段
,这种编码一般都具有前缀性质,即 考虑了之前编号的数据
),二维下标记录了当前背包总容量(容量递增的阶段),下标对应的数值状态记录了背包总价值。它们三个合起来,我们就能知道不同阶段的不同状态信息,我们就可以递推出所有信息!!!!!理解:其实,数组就是一般的阶段状态记录,我们要选取合适维度的数组来记录我们所拥有的信息,每一个数组元素及其下标都应该对应着一个阶段的状态。
递归,回溯法的状态是记录当前位置的状态,并非当前位置的最优状态,因为没有办法知道当前位置是否为最优,我们只能知道我们当前路线的当前位置的状态,而不知道其他路线的当前位置的状态,并且没有办法保存当前位置的最优状态,毕竟是递归,数据就不断地刷新了。
可以理解成486,只有回溯能力,不能保证回溯的当前位置状态最优。
而动态规划记录的是当前位置的最优状态,动态规划可以知道其他路线的位置最优状态。
它比较有大局观,每次都统筹规划,从最优的状态选择中不断前进!
也就是说,与递归、回溯法不同,回溯法只能知道当前的状态是怎样的,并不能保证当前的状态是最优的,它的优势在于遍历所有可能性;
而动态规划则是每一步都通过规划,保证当前状态最优,它的优势在于统筹规划,每次都是最优的。
2. 制作阶段记录表
2.制作阶段记录表:根据数组建表,填入初始值,利用递推关系式写程序推出其他空表项。
注意:这个是为我们通过初始值和递推关系式写出程序提供可视化条件以及思路,把抽象的东西可视化了,时时刻刻都知道自己要干嘛。
当然,如果脑子里有思路可以忽略。。启发:这个想法其实是由编译原理中的 “编译程序绝大多数时间花在管理表格上” 这句话来的。
因为在编译中,编译的每个阶段所需要的信息多数从表格中读取,产生的中间结果都记录在相应的表格中,可以说整个编译过程就是造表、查表的过程。
也就是说,我们的动态规划算法也可以理解成造表、查表的一个过程。
3. 寻找数组初始值
3.寻找数组初始值:寻找数组元素初始值的出口,这个出口最好写在最前面,我们的结果就是用数组初始值结合下面的递推关系式得出来的;
注意:这个初始值要特别的给出一个出口,因为它们不是被递推出来的。
以免后面设置的初始值越界,比如 数组dp[1]容量为1,结果初始值设置了dp[2]的值,就越界了。
例如:
// 初始值出口
if (n <= 2) {
return n;
}
4. 找出递推关系式
4.找出递推关系式:明确递推范围(即 dp[i]与它后几位有关?
),找出数组元素递推关系式。
注意:可以从
dp[i] = ?
这一数学公式开始推想,寻找我们的递推范围,比如说青蛙跳台阶一次可以跳 1 格或 2 格,所以我们的递推范围为 2,即dp[i]
与dp[i-1]
和dp[i-2]
有关。并且需要注意,我们的初始值已经填入进去了,我们得从没有填数据的位置开始填表。
四步走举例
明确数组的含义
第一步:定义数组元素的含义。
上面说了,我们会用一个数组,来保存历史记录,假设用一维数组 dp[]吧。这个时候有一个非常非常重要的点,就是规定你这个数组元素的含义,即 你的 dp[i] 是代表什么意思?
那么下面我来举个例子吧!
问题描述:一只青蛙一次可以跳上1级台阶,也可以跳上2级。求该青蛙跳上一个n级的台阶总共有多少种跳法。
首先,拿到这个题,我们需要判断用什么方法,要跳上n级的台阶,我们可能需要用到前几级台阶的数据,即 历史记录,所以我们可以用动态规划。
我们需要存储的信息:
- 当前台阶数
- 跳到当前台阶有多少种跳法
所以我们使用一维数组:
- 当前台阶数:数组下标
- 跳到当前台阶有多少种跳法:数组元素值
然后依据上面我说的第一步,建立数组 dp[] ,那么顺理成章我们的 dp[i] 应该规定含义为:跳上一个i级的台阶总共有dp[i]种解法。
那么,求解dp[n]就是我们的任务。
制作阶段记录表
- 根据数组,制表,确定一维表、二维表;
- 填入初始值;
根据递推关系式,写程序推出剩余的空表项。
注意:这里一维表比较简单可能体现不出它的作用,到二维表它就能很方便的将数据可视化了。
此题,由于明确了数组的含义,我们可以确定是一张一维表。
历史记录表:
数组dp | 1 | 2 | 3 | … | n |
---|---|---|---|---|---|
值 |
寻找数组初始值
第二步:找出初始值。
利用我们学过数学归纳法,我们可以知道如果要进行递推,我们需要一个初始值来推出结果值,也就是我们常说的第一张多米诺骨牌。
本题的初始值很容易我们就找出来了,
- 当 n = 1 时,即 只有一级台阶,那么我们的青蛙只用跳一级就可以了,只有一种跳法,dp[1] = 1;
- 当 n = 2 时,即 有两级台阶,我们的青蛙有两种选择,一级一级的跳 和 一次跳两级,dp[2] = 2;
- 当 n = 3 时,即 有三级台阶,我们的青蛙跳一级 + dp[2],或 跳两级 + dp[1],这时候我们就反应过来了,需要进行下一步找出 n 的递推关系式。
历史记录表:
数组dp | 1 | 2 | 3 | … | n |
---|---|---|---|---|---|
值 | 1 | 2 |
找出递推关系式
第三步:找出数组元素之间的关系式。
动态规划有一点类似于数学归纳法的,当我们要计算 dp[n] 时,是可以利用 dp[1]……dp[n-2]、dp[n-1] ,来推出 dp[n] 的,也就是可以利用历史数据来推出新的元素值,所以我们要找出数组元素之间的关系式,例如, dp[i] = dp[i-1] + dp[i-2] ,这个就是它们的递推关系式了。而这一步,也是最难的一步,后面我会讲几种类型的题来说。
当 n = i 时,即 有 i 级台阶,我们的青蛙最后究竟怎么样到达的这第 i 级台阶呢?
因为青蛙的弹跳力有限,只能一次跳一级或者两级,所以我们有两种方式可以到达最后的这第 i 级:
- 从 i-1 处跳一级
- 从 i-2 处跳两级
所以,我们只需要把青蛙跳上 i-1 级台阶 和 i-2 级台阶的跳法加起来,我们就可以得到到达第 i 级的跳法(i≥3),即
\[dp[i] = dp[i-1] + dp[i-2], (i≥3)\]
这样我们知道了初始值dp[1]、dp[2],可以从dp[3]开始递推出4、5、6、...、n。
历史记录表:
数组dp | 1 | 2 | 3 | … | n |
---|---|---|---|---|---|
值 | 1 | 2 | 3 | … |
用程序循环得出后面的空表项。
你看有了初始值,以及数组元素之间的关系式,那么我们就可以像数学归纳法那样递推得到dp[n]的值了,而dp[n]的含义是由你来定义的,你想求什么,就定义它是什么,这样,这道题也就解出来了。
答案:
// 青蛙跳台阶
int f(int n) {
// 特别给初始值的出口
if(n <= 2)
return n;
// 创建数组保存历史数据
int[] dp = new int[n+1];
// 给出初始值
dp[1] = 1;
dp[2] = 2;
// 通过递推关系式来计算出 dp[n]
for(int i = 3; i <= n; i++) {
dp[i] = dp[i-1] + dp[i-2];
}
// 把最终结果返回
return dp[n];
}
状态数组定义技巧
其实,我们的动态规划算法,根据状态数组的定义不同,我们也会有不同的解法,当然,这些解法都是可以得到正确答案的,大家可以按照自己的思维去决定怎么定义状态数组。
下面总结了几种常用的状态数组定义技巧。
以 [i] 结尾(dp[i]表示以元素nums[i]结尾的最优解)
适用场景:要求元素连续、窗口
如 连续子数组的最大和
为何定义最大和 \(dp[i]\) 中必须包含元素 \(nums[i]\) :保证 \(dp[i]\) 递推到 \(dp[i+1]\) 的正确性;如果不包含 \(nums[i]\) ,递推时则不满足题目的 连续子数组 要求。
原理:其实就是限定末尾下标为 i 的那个元素必须包含。
示例操作:dp[i] = Math.max(dp[i - 1], dp[i - 2]) + nums[i]
以 [0, i] 为范围区间(dp[i]表示区间 [0,i] 里的最优解)
适用场景:范围、区间、不连续、一维
原理:不限定下标为 i 的那个元素必须包含,只代表范围。
示例操作:dp[i] = Math.max(dp[i - 1], dp[i - 1] + nums[i])
以 [i][j] 表示完整的当前状态(dp[i][j]表示第 i 次选与不选的状态 j 情况下的最优解)
适用场景:j取值0、1表示两种状态(加入或不加入),背包问题(前i个物品,容量为j,利用i来消除排列组合的重复性(优化中会提到)),二维,比较通用
原理:完整的表明当前状态,方便递推。
示例操作:dp[i][0] = Math.max(dp[i - 1][0], dp[i - 1][1])
和dp[i][1] = dp[i - 1][0] + nums[i]
多种状态数组定义应用
其实,我们的动态规划算法,根据状态数组的定义不同,我们也会有不同的解法,当然,这些解法都是可以得到正确答案的,大家可以按照自己的思维去决定怎么定义状态数组。
面试题 17.16. 按摩师
一个有名的按摩师会收到源源不断的预约请求,每个预约都可以选择接或不接。在每次预约服务之间要有休息时间,因此她不能接受相邻的预约。给定一个预约请求序列,替按摩师找到最优的预约集合(总预约时间最长),返回总的分钟数。
注意:本题相对原题稍作改动
示例 1:
输入: [1,2,3,1]
输出: 4
解释: 选择 1 号预约和 3 号预约,总时长 = 1 + 3 = 4。
示例 2:
输入: [2,7,9,3,1]
输出: 12
解释: 选择 1 号预约、 3 号预约和 5 号预约,总时长 = 2 + 9 + 1 = 12。
示例 3:
输入: [2,1,4,5,3,1,1,3]
输出: 12
解释: 选择 1 号预约、 3 号预约、 5 号预约和 8 号预约,总时长 = 2 + 4 + 3 + 3 = 12。
dp[i]表示以元素nums[i]结尾的最优的预约时长
其实也就是限定下标为 i 的那天接受预约。(下面还有不限定的情况,需要分类讨论,大家可以看看)
class Solution {
public int massage(int[] nums) {
// 感觉动态规划
// 头两个可以单独选出来
if (nums.length == 0) {
return 0;
} else if (nums.length == 1) {
return nums[0];
} else if (nums.length == 2) {
return Math.max(nums[0], nums[1]);
} else if (nums.length == 3) {
return Math.max(nums[0] + nums[2], nums[1]);
}
int[] dp = new int[nums.length];
dp[0] = nums[0];
dp[1] = nums[1];
dp[2] = Math.max(nums[0] + nums[2], nums[1]);
for (int i = 3; i < nums.length; i++) {
dp[i] = Math.max(dp[i - 2], dp[i - 3]) + nums[i];
// dp[i] = Math.max(dp[i], dp[i - 1]);
}
return Math.max(dp[nums.length - 1], dp[nums.length - 2]);
}
}
dp[i]表示区间 [0,i] 里接受预约请求的最大时长
方法二:设计一维状态变量
第 1 步:定义状态
dp[i]
:区间 [0,i]
里接受预约请求的最大时长。
第 2 步:状态转移方程
这个时候因为不限定下标为 i
这一天是否接受预约,因此需要分类讨论:
- 接受预约,那么昨天就一定休息,由于状态
dp[i - 1]
的定义涵盖了下标为i - 1
这一天接收预约的情况,状态只能从下标为i - 2
的状态转移而来:dp[i - 2] + nums[i]
; - 不接受预约,那么昨天可以休息,也可以不休息,状态从下标为
i - 1
的状态转移而来:dp[i - 1]
;
二者取最大值,因此状态转移方程为 dp[i] = max(dp[i - 1], dp[i - 2] + nums[i])
。
第 3 步:思考初始化
看状态转移方程,下标最小到 i - 2
,因此初始化的时候要把 dp[0]
和 dp[1]
算出来,从 dp[2]
开始计算。
dp[0]
:只有 1 天的时候,必须接受预约,因此 dp[0] = nums[0];dp[1]
:头 2 天的时候,由于不能同时接受预约,因此最优值是这两天接受预约时长的最大值dp[1] = max(nums[0], nums[1])
;
第 4 步:思考输出
由于定义的状态有前缀性质,并且对于下标为 i 的这一天也考虑了接受预约与不接受预约的情况,因此输出就是最后一天的状态值。
第 5 步:思考空间优化
看状态转移方程。当前状态只与前两个状态相关,我们只关心最后一天的状态值,因此依然可以使用「滚动变量」的技巧,这个时候滚动起来的就是 3 个变量了。这样的代码依然是丢失了可读性,也存在一定编码错误的风险,请见题解后的「参考代码 5」。
参考代码 2:
public class Solution {
public int massage(int[] nums) {
int len = nums.length;
if (len == 0) {
return 0;
}
if (len == 1) {
return nums[0];
}
// dp[i]:区间 [0, i] 里接受预约请求的最大时长
int[] dp = new int[len];
dp[0] = nums[0];
dp[1] = Math.max(nums[0], nums[1]);
for (int i = 2; i < len; i++) {
// 今天在选与不选中,选择一个最优的
dp[i] = Math.max(dp[i - 1], dp[i - 2] + nums[i]);
}
return dp[len - 1];
}
}
复杂度分析:
- 时间复杂度:\(O(N)\),N 是数组的长度;
- 空间复杂度:\(O(N)\),状态数组的大小为 N,可以优化到 3,请见题解后的「参考代码 5」。
我们看到解决这个问题的复杂程度与如何定义状态是相关的,定义状态的角度没有固定的模式,但有一个方向是可以考虑的,那就是从「状态转移方程」容易得到的角度去考虑如何设计状态。
「状态」和「状态转移方程」得到以后,这个问题其实就得到了解决,剩下的一些细节的问题在编码的时候只要稍微留意一点就行了。
二维数组dp[i][j]
方法一:设计二维状态变量
第 1 步:设计状态
「状态」这个词可以理解为「记录了求解问题到了哪一个阶段」。
由于当前这一天有按摩师有两种选择:
(1)接预约;
(2)不接预约。
但根据题意,今天是否接预约,是受到昨天影响的。为了消除这种影响,我们在状态数组要设置这个维度。
dp[i][0]
表示:区间[0..i]
里接受预约请求,并且下标为i
的这一天不接受预约的最大时长;dp[i][1]
表示:区间[0..i]
里接受预约请求,并且下标为i
的这一天接受预约的最大时长。
说明:这个定义是有前缀性质的,即 当前的状态值考虑了(或者说综合了)之前的相关的状态值,第 2 维保存了当前最优值的决策,这种通过增加维度,消除后效性的操作在「动态规划」问题里是非常常见的。
无后效性的理解:
1、后面的决策不会影响到前面的决策;
2、之前的状态怎么来的并不重要。个人理解:其实就是前后状态彼此独立互不干扰,即 每个状态都能完整的表示当前状态,不受前后状态的干扰。
一般的情况是,只要有约束,就可以增加一个维度消除这种约束带来的影响,再具体一点说,就是把「状态」定义得清楚、准确,「状态转移方程」就容易得到了。
「力扣」的几道股票问题基本都是这个思路,而且设置状态的思想和这道题是完全一致的。
第 2 步:状态转移方程
「状态转移方程」可以理解为「不同阶段之间的联系」。
今天只和昨天的状态相关,依然是分类讨论:
- 今天不接受预约:或者是昨天不接受预约,或者是昨天接受了预约,取二者最大值,即:
dp[i][0] = max(dp[i - 1][0], dp[i - 1][1])
; - 今天接受预约:只需要从昨天不接受预约转移而来,加上今天的时长,即:
dp[i][1] = dp[i - 1][0] + nums[i]
。
第 3 步:考虑初始化
从第 2 天开始,每天的状态值只与前一天有关,因此第 1 天就只好老老实实算了。好在不难判断:dp[0][0] = 0
与 dp[0][1] = nums[0]
;
这里有一种技巧,可以把状态数组多设置一行,这样可以减少对第 1 天的初始化,这样的代码把第 1 天的情况考虑了进去,但编码的时候要注意状态数组下标的设置, 请见题解最后的「参考代码 3」。
第 4 步:考虑输出
由于状态值的定义是前缀性质的,因此最后一天的状态值就考虑了之前所有的天数的情况。按摩师最后一天可以接受预约,也可以不接受预约,取二者最大值。
第 5 步:考虑是否可以优化空间
由于今天只参考昨天的值,可以使用「滚动数组」完成,优化空间以后的代码丢失了一定可读性,也会给编码增加一点点难度,请见题解后的「参考代码 4」。
参考代码 1:
public class Solution {
public int massage(int[] nums) {
int len = nums.length;
if (len == 0) {
return 0;
}
if (len == 1) {
return nums[0];
}
// dp[i][0]:区间 [0, i] 里接受预约请求,并且下标为 i 的这一天不接受预约的最大时长
// dp[i][1]:区间 [0, i] 里接受预约请求,并且下标为 i 的这一天接受预约的最大时长
int[][] dp = new int[len][2];
dp[0][0] = 0;
dp[0][1] = nums[0];
for (int i = 1; i < len; i++) {
dp[i][0] = Math.max(dp[i - 1][0], dp[i - 1][1]);
dp[i][1] = dp[i - 1][0] + nums[i];
}
return Math.max(dp[len - 1][0], dp[len - 1][1]);
}
}
复杂度分析:
- 时间复杂度:\(O(N)\),N 是数组的长度;
- 空间复杂度:\(O(N)\),状态数组的大小为 2N,可以优化到常数级别,请见题解后的「参考代码 4」。
以上是中规中矩的写法。在这里根据问题本身的特点,状态可以不用设置那么具体,就将题目问的设计成状态(题目:每个预约都可以选择接或不接),状态转移方程依然好写。
总结
总结
「动态规划」其实不是什么特别难懂的东西(只是说思想),只是这一类问题刚接触的时候有点不太适应,并且这类问题容易被包装得很过分,而且没有明显的套路,题型多样,所以学习「动态规划」会有一些些吃力,这没有办法,见多了就好。如果是准备面试,不需要掌握特别复杂的「动态规划」问题(当然前提是你没有在简历上说你是算法竞赛高手)。
「动态规划」告诉了我们另一种求解问题的思路。我们学习编程,习惯了自顶向下求解问题(递归),在自顶向下求解问题的过程中,发现了重复子问题,我们再加上缓存。而「动态规划」告诉我们,其实有一类问题我们可以从一个最简单的情况开始考虑,通过逐步递推,每一步都记住当前问题的答案,得到最终问题的答案,即「动态规划」告诉了我们「自底向上」思考问题的思路。
也就是说「动态规划」告诉我们的新的思路是:不是直接针对问题求解,由于我们找到了这个问题最开始的样子,因此后面在求解的过程中,每一步都可以参考之前的结果(在处理最优化问题的时候,叫「最优子结构」),由于之前的结果有重复计算(「重复子问题」),因此必须记录下来。
这种感觉不同于「记忆化递归」,「记忆化递归」是直接面对问题求解,遇到一个问题解决了以后,就记下来,随时可能面对新问题。而「动态规划」由于我们发现了这个问题「最初」的样子,因此每一步参考的以前的结果都是知道的,就像我们去考试,所有的考题我们都见过,并且已经计算出了答案一样,我们只需要参考以前做题的答案,就能得到这一题的答案,这是「状态转移」。应用「最优子结构」是同一回事,即:综合以前计算的结果,直接得到当前的最优值。
「动态规划」的内涵和外延很丰富,不是几句话和几个问题能够理解清楚的,需要我们做一些经典的问题去慢慢理解它,和掌握「动态规划」问题思考的方向。
参考代码 3:根据方法一:状态数组多设置一行,以避免对极端用例进行讨论。
Java
public class Solution {
public int massage(int[] nums) {
int len = nums.length;
// dp 数组多设置一行,相应地定义就要改变,遍历的一些细节也要相应改变
// dp[i][0]:区间 [0, i) 里接受预约请求,并且下标为 i 的这一天不接受预约的最大时长
// dp[i][1]:区间 [0, i) 里接受预约请求,并且下标为 i 的这一天接受预约的最大时长
int[][] dp = new int[len + 1][2];
// 注意:外层循环从 1 到 =len,相对 dp 数组而言,引用到 nums 数组的时候就要 -1
for (int i = 1; i <= len; i++) {
dp[i][0] = Math.max(dp[i - 1][0], dp[i - 1][1]);
dp[i][1] = dp[i - 1][0] + nums[i - 1];
}
return Math.max(dp[len][0], dp[len][1]);
}
}
复杂度分析:
- 时间复杂度:\(O(N)\),N 是数组的长度;
- 空间复杂度:\(O(N)\),状态数组的大小为 \(2(N + 1)\),记为 \(O(N)\)。
参考代码 4:根据方法一,使用「滚动数组」技巧,将空间优化到常数级别
在编码的时候,需要注意,只要访问到 dp 数组的时候,需要对下标 % 2,等价的写法是 & 1。
Java
public class Solution {
public int massage(int[] nums) {
int len = nums.length;
if (len == 0) {
return 0;
}
if (len == 1) {
return nums[0];
}
// dp[i & 1][0]:区间 [0, i] 里接受预约请求,并且下标为 i 的这一天不接受预约的最大时长
// dp[i & 1][1]:区间 [0, i] 里接受预约请求,并且下标为 i 的这一天接受预约的最大时长
int[][] dp = new int[2][2];
dp[0][0] = 0;
dp[0][1] = nums[0];
for (int i = 1; i < len; i++) {
dp[i & 1][0] = Math.max(dp[(i - 1) & 1][0], dp[(i - 1) & 1][1]);
dp[i & 1][1] = dp[(i - 1) & 1][0] + nums[i];
}
return Math.max(dp[(len - 1) & 1][0], dp[(len - 1) & 1][1]);
}
}
复杂度分析:
- 时间复杂度:\(O(N)\),N 是数组的长度;
- 空间复杂度:\(O(1)\),状态数组的大小为 4,常数空间。
参考代码 5:根据方法二,使用 3 个变量滚动完成计算,将空间优化到常数级别。
在实现上可以在取下标的时候对 3 取模。
Java
class Solution {
public int massage(int[] nums) {
int len = nums.length;
if (len == 0) {
return 0;
}
if (len == 1) {
return nums[0];
}
// dp[i % 3]:区间 [0,i] 里接受预约请求的最大时长
int[] dp = new int[3];
dp[0] = nums[0];
dp[1] = Math.max(nums[0], nums[1]);
for (int i = 2; i < len; i++) {
// 今天在选与不选中,选择一个最优的
dp[i % 3] = Math.max(dp[(i - 1) % 3], dp[(i - 2) % 3] + nums[i]);
}
return dp[(len - 1) % 3];
}
}
复杂度分析:
- 时间复杂度:\(O(N)\),N 是数组的长度;
- 空间复杂度:\(O(1)\),状态数组的大小为 3,常数空间。
优化思路
滚动数组
数组是最常用的数据结构之一,现在我们对数组的下标进行特殊处理,使每一次操作仅保留若干有用信息,新的元素不断循环刷新,看上去数组的空间被滚动地利用,此模型我们称其为滚动数组。其主要达到压缩存储的作用,一般常用在DP类题目中。因为DP题目是一个自下而上的扩展过程,我们常常用到是连续的解,而每次用到的只是解集中的最后几个解,所以以滚动数组形式能大大减少内存开支。
类比:滚动数组可以想象成我们的显示屏,对于有很多的数字来说,每次只显示对我们有用的、有限的数字,用完(显示完)就向后移动一位,显示的数量不变。这样可以节省很多空间。
滚动数组是常见的一种空间优化方式。
应用是递推算法,动态规划(其实现方式是递推)。
举个栗子:
斐波那契数列是递推的一个最好的例子,它的递推公式是:
\[fib_n=fib_{n−1}+fib_{n−2}\]
也就是说,我们只需要知道n-1和n-2项就能知道第n项,第n项跟前面的所有项都没关系。
所以我们完全可以只开一个长度为3的数组来完成这个过程。
普通解法
public int fib(int n) {
if (n <= 1) {
return 1;
}
int[] d = new int[n];
d[0] = 1;
d[1] = 1;
for (int i = 2; i < n; i++){
d[i] = d[i - 1] + d[i - 2];
}
return d[n-1];
}
上述方法使用n个空间(近似认为)
但是注意,上面这个循环d[i]只需要解集中的前2个解d[i-1]和d[i-2],为了节约空间我们可以使用滚动数组的方法
滚动数组模优化
滚动数组模优化做法:这种数组的滚动可以通过模运算实现,需要多大的数组滚动,模就为几。
public int fib(int n) {
if (n <= 1) {
return 1;
}
int d[] = new int[3];
d[0] = 1;
d[1] = 1;
for (int i = 2; i < n; i++) {
d[i % 3] = d[(i - 1) % 3] + d[(i - 2) % 3];
}
// 最后 i 进行运算的值一定是等于 n-1 的,那时候即是我们要求的结果。
return d[(n - 1) % 3];
}
滚动数组运算位优化
我们可以将滚动数组进一步优化,将数组分为运算位和结果位,那么我们就可以使用递推关系式,将运算位中的值进行运算得出结果位,再把结果位中运算的值传递给运算位,继续运算。不停的递推。
递推关系式:y = f(x) = x1 + x2 + ...
- 运算位x:负责将递推关系式中的算式进行运算得出结果位,运算位中的每一个元素都要参与运算。
- 结果位y:负责将结果位中的值传递给运算位,再继续进行运算,不停地递推。
也可以进一步简化成这样,
public int fib(int n) {
if (n <= 1) {
return 1;
}
int d[] = new int[3];
d[0] = 1;
d[1] = 1;
for (int i = 2; i < n; i++) {
// 我们将 d[2] 视为结果位
// 那么 d[0] 和 d[1] 即为运算位
d[2] = d[1] + d[0];
// 计算出 d[2] 后,我们如果要进行下一步计算,则需要使用到此时 d[1] 和 d[2] 的值,来计算出下一个(d[3])
// 所以我们将此时的 d[1] 和 d[2] 的值向前移动到运算位
// 也可以理解为,我们要将运算位的元素更换为 d[1] 和 d[2] 的值
d[0] = d[1];
d[1] = d[2];
}
// 在返回时,我们只需要返回结果位的值即可
return d[2];
}
同理,以0/1背包为例,因为其转移只涉及到了dp[i−1]和dp[i]这两维度的状态,所以可以用滚动数组转移。
滚动数组实际是一种节约空间的办法,时间上没什么优势,比如:
一个DP,平常如果需要1000×1000的空间,其实根据DP的特点,能以2×1000的空间解决问题,并且通过滚动,获得和1000×1000一样的效果。
结果去重(顺序性)
面对一些如同背包九讲这样的问题,我们可能会需要对选择的结果集进行去重,比如说 选择物品1和5 与 选择物品5和1,其实是一个相同的结果,我们应该对其进行去重处理。
而我们去重的方式很简单,那就是制定选择规则,赋予我们的选择顺序性。
我们一般采用升序顺序性,我们按照升序顺序来选择我们的元素,比如说 选择元素1和5,我们就只有这一种选择,没有5和1这种选择,因为我们是按照升序来选择的。
一般这种问题涉及到如何消除排列组合的影响,也就是说,当成一个集合使用。
其实如果我们想要使用这种方法不重复、消除排列组合的影响的话,我们可以使其中的选择元素以某种方式顺序排列,这样就能消除重复的影响了。
动态规划问题一般都带有前缀性,像0/1背包问题,它就是一种双前缀性问题,因为不止它的背包容量具有前缀性,它的物品选择也具有前缀性。
它的目的就是为了消除排列组合问题的顺序性,也就是说,我们构造了物品选择编号的一种前缀性,我们就可以决定物品从小到大选择的一种顺序性来避免他们的排列组合重复。
因为我们的动态规划具有当前性,我们只能操作当前的选择当前的节点,所以这会给我们带来一种排列组合的问题,比如说 1和5还有5和1,这是两种选择了。
如果我们希望把他当成一种选择的话,我们就需要消除这种排列组合带来的问题,我们最好的做法就是将当前性与顺序性相结合,我们的选择按某种递增或递减的顺序来决定,这样就可以消除重复的选择。
这种顺序性,我们可以使用以下两种方法来进行实现。
- 第一种方法很简单,就是使用多重循环,也就是说我们增加一种循环来保证我们的选择的一个顺序性,我们使用外层循环来保证我们选择的编号顺序由小到大 (这种方法也就是我所说的多次迭代填表)。
- 第二种方法也很简单,就是使用二维状态数组,也就是说,我们额外使用一个数组下标来表示我们的选择的编号顺序由小到大。
下面我们拿背包问题来举例子:
有 n 个物品和容量为 V 的背包,第 i 件物品的体积为 c[i],价值为 w[i]。现在的目标是确定要将哪些物体放入背包,以保证在体积 不超过背包容量 的前提下,背包内的 总价值最高?
顺序迭代填表(顺序性)
顺序多次迭代填表,顾名思义,我们的表格不是一次性就填好,而是经过多次迭代修改,慢慢地填好。
使用多重循环,也就是说我们增加一种循环来保证我们的选择的一个顺序性,我们使用外层循环来保证我们选择的编号顺序由小到大(这种方法也就是我所说的多次迭代填表)。
for (int c = 0; c < 4; ++c) { // 按照编号升序选择4种硬币
int coin = coins[c];
for (int i = coin; i <= n; ++i) { // 每选择一种就迭代填表
f[i] = (f[i] + f[i - coin]) % MOD;
}
}
或者
for (int c = 0; c < 4; ++c) { // 按照编号升序选择4种硬币
int coin = coins[c];
for (int i = 1; i <= n; ++i) { // 每选择一种就迭代填表
if (i >= coin) {
f[i] = (f[i] + f[i - coin]) % MOD;
}
}
}
顺序二维数组(顺序性)
使用顺序二维状态数组,也就是说,我们额外使用一个数组下标来表示我们的选择的编号顺序由小到大,这样我们就可以使用顺序性来消除重复性。
状态定义:dp[i][j]
为前 i 个物品(物品顺序性)中,容量恰好为 j 时的最大价值。
for (int i = 1; i < 5; i++) {
for (int j = 0; j <= n; j++) {
f[i][j] = (f[i][j] + f[i - 1][j]) % 1000000007;
if (j >= a[i]) {
f[i][j] = (f[i][j] + f[i][j - a[i]]) % 1000000007;
}
}
}
多种结果去重应用
面试题 08.11. 硬币
硬币。给定数量不限的硬币,币值为25分、10分、5分和1分,编写代码计算n分有几种表示法。(结果可能会很大,你需要将结果模上1000000007)
示例1:
输入: n = 5
输出:2
解释: 有两种方式可以凑成总金额:
5=5
5=1+1+1+1+1
示例2:
输入: n = 10
输出:4
解释: 有四种方式可以凑成总金额:
10=10
10=5+5
10=5+1+1+1+1+1
10=1+1+1+1+1+1+1+1+1+1
未去重错误答案
class Solution {
public int waysToChange(int n) {
// 动态规划
if (n <= 1) {
return 1;
}
int[] dp = new int[n + 1];
dp[0] = 1;
for (int i = 1; i < dp.length; i++) {
if (i >= 1) {
dp[i] = (dp[i] + dp[i - 1]) % 1000000007;
}
if (i >= 5) {
dp[i] = (dp[i] + dp[i - 5]) % 1000000007;
}
if (i >= 10) {
dp[i] = (dp[i] + dp[i - 10]) % 1000000007;
}
if (i >= 25) {
dp[i] = (dp[i] + dp[i - 25]) % 1000000007;
}
}
return dp[n];
}
}
代码好像没问题,但是我们求6的硬币情况数时,我们观察一下流程:
前面5种情况数:dp[1,5] = [1,1,1,1,2];
coin = 1:
dp[6] += (dp[6 - coin] => dp[5] => 2);
即拿到coin(1)的情况有两种 :
coin(1,1,1,1,1) + coin(1);
coin(5) + coin(1);
coin = 5:
dp[6] += (dp[6 - coin] => dp[1] => 1);
即拿到coin(5)的情况有一种:
coin(1) + coin(5);
但是事实却是 6 的情况只有两种,(1,1,1,1,1,1)和(1,5)。这里是把(1,5)和(5,1)前后顺序不同的情况重复算了 1 次。因此我们应该去考虑硬币顺序带来的影响。
顺序迭代填表
上面的错误方法我们可以知道,我们应该去消除硬币顺序带来的排列组合的影响。
我们规定硬币编号升序,采用循环顺序多次迭代填表。
class Solution {
public int waysToChange(int n) {
// 动态规划
if (n <= 1) {
return 1;
}
int[] dp = new int[n + 1];
dp[0] = 1;
// 使用顺序性来消除排列组合带来的重复性,使我们的结果集选择唯一(按硬币选择从小到大排列)
for (int i = 1; i < dp.length; i++) {
if (i >= 1) {
dp[i] = (dp[i] + dp[i - 1]) % 1000000007;
}
}
for (int i = 1; i < dp.length; i++) {
if (i >= 5) {
dp[i] = (dp[i] + dp[i - 5]) % 1000000007;
}
}
for (int i = 1; i < dp.length; i++) {
if (i >= 10) {
dp[i] = (dp[i] + dp[i - 10]) % 1000000007;
}
}
for (int i = 1; i < dp.length; i++) {
if (i >= 25) {
dp[i] = (dp[i] + dp[i - 25]) % 1000000007;
}
}
return dp[n];
}
}
整合:
class Solution {
public int waysToChange(int n) {
int[] dp = new int[n + 1];
int[] coins = new int[]{1,5,10,25};
//刚好可以用一个硬币凑成的情况,是一种情况
// while i == coin :
//dp[i] = dp[i - coin] => dp[0]
dp[0] = 1;
/**
* dp方程:dp[i] += dp[i - coin];
*/
for(int coin : coins) {
for(int i = coin; i <= n; i++) {
dp[i] = (dp[i] + dp[i - coin]) % 1000000007;
}
}
return dp[n];
}
}
顺序二维数组
我们使用顺序二维数组,
状态定义:dp[i][v]
为前 i 个物品中,体积恰好为 v 时的最大价值。
我们使用这个i,前 i 个物品,来让我们的结果集升序顺序排列。
class Solution {
public int waysToChange(int n) {
int[] coins = {1,5,10,25};
int[][] dp = new int[4][n + 1];
// 初始化
for (int i = 0; i < n + 1; i++) {
dp[0][i] = 1;
}
// 多次迭代填表
for (int i = 1; i < 4; i++) {
for (int j = 0; j < n + 1; j++){
// dp[i][j]=dp[i-1][j]%1000000007;
// if(j>=coins[i]) dp[i][j]=(dp[i-1][j]+dp[i][j-coins[i]])%1000000007;
dp[i][j] = (dp[i][j] + dp[i-1][j]) % 1000000007;
int coin = coins[i];
if (j >= coin) {
dp[i][j] = (dp[i][j] + dp[i][j - coin]) % 1000000007;
}
}
}
return dp[3][n];
}
}
实例
超级青蛙跳台阶
一个台阶总共有 n 级,超级青蛙有能力一次跳到 n 阶台阶,也可以一次跳 n-1 阶台阶,也可以跳 n-2 阶台阶……也可以跳 1 阶台阶。
问超级青蛙跳到 n 层台阶有多少种跳法?(n<=50)
例如:
输入台阶数:3
输出种类数:4
解释:4 种跳法分别是(1,1,1),(1,2),(2,1),(3)
答案:
这里我是运用了“数学”来得出式子的,为了告诉大家不要拘泥于程序,数学也是一个很有用的工具。
用 Fib(n) 表示超级青蛙🐸跳上 n 阶台阶的跳法数。
如果按照定义,Fib(0)肯定需要为 0,否则没有意义。我们设定 Fib(0) = 1;n = 0 是特殊情况,通过下面的分析会知道,令 Fib(0) = 1 很有好处。
PS:Fib(0)等于几都不影响我们解题,但是会影响我们下面的分析理解。
当 n = 1 时, 只有一种跳法,即 1 阶跳:\(Fib(1) = 1\);
当 n = 2 时, 有两种跳的方式,一阶跳和二阶跳:\(Fib(2) = 2\);
到这里为止,和普通跳台阶是一样的。当 n = 3 时,有三种跳的方式,第一次跳出一阶后,对应 Fib(3-1) 种跳法; 第一次跳出二阶后,对应 Fib(3-2)种跳法;第一次跳出三阶后,只有这一种跳法。
\[Fib(3) = Fib(2) + Fib(1)+ 1 = Fib(2) + Fib(1) + Fib(0) = 4\]当 n = 4 时,有四种方式:第一次跳出一阶,对应 Fib(4-1)种跳法;第一次跳出二阶,对应Fib(4-2)种跳法;第一次跳出三阶,对应 Fib(4-3)种跳法;第一次跳出四阶,只有一种跳法。
\[Fib(4) = Fib(4-1) + Fib(4-2) + Fib(4-3) + 1 = Fib(4-1) + Fib(4-2) + Fib(4-3) + Fib(4-4)\]当 n = n 时,共有 n 种跳的方式:
第一次跳出一阶后,后面还有 Fib(n-1)中跳法;
...
...
...
第一次跳出 n 阶后,后面还有 Fib(n-n)中跳法。
\[Fib(n) = Fib(n-1)+Fib(n-2)+Fib(n-3)+...+Fib(n-n) = Fib(0)+Fib(1)+Fib(2)+...+Fib(n-1)\]
通过上述分析,我们就得到了数列通项公式:
\[Fib(n) = Fib(0)+Fib(1)+Fib(2)+...+ Fib(n-2) + Fib(n-1)\]
因此,有 \[Fib(n-1)=Fib(0)+Fib(1)+Fib(2)+...+Fib(n-2)\]
两式相减得:\[Fib(n)-Fib(n-1) = Fib(n-1)\] \[Fib(n) = 2*Fib(n-1), n >= 3\]
这就是我们需要的递推公式:\[Fib(n) = 2*Fib(n-1), n >= 3\]
public class SY1 {
//自底向上的动态规划 超级青蛙 N 阶跳
static long solution(int number) {
//题目保证 number 最大为 50
long[] Counter=new long[51];
Counter[0] = 1;
Counter[1] = 1;
Counter[2] = 2;
int calculatedIndex = 2;
if(number <= calculatedIndex)
return Counter[number];
if(number > 50) //防止下标越界
number = 50;
for(int i = calculatedIndex + 1; i <= number; i++)
Counter[i] = 2 * Counter[i - 1];
calculatedIndex = number;
return Counter[number];
}
public static void main(String[] args) {
Scanner cin = new Scanner(System.in);
System.out.print(solution(cin.nextInt()));
}
}
程序运行结果:
不同路径
一个机器人位于一个 m x n 网格的左上角(起始点在下图中标记为“Start”)。机器人试图达到网格的右下角(在下图中标记为“Finish”)。
- 机器人每次只能向下或者向右移动一步。
问总共有多少条不同的路径?
例如,上图是一个 3 x 7 的网格。有多少可能的路径?
本题是 leetcode 的 62 号题:https://leetcode-cn.com/problems/unique-paths/
这里为了让大家能明白历史记录表的作用,我举了一道二维表的题。
明确数组的含义
由题可知,我们的目的是从左上角到右下角一共有多少种路径。
那我们就定义 dp[i][j]的含义为:当机器人从左上角走到 (i, j) 这个位置时,一共有 dp[i][j] 种路径。
那么 dp[m-1][n-1] 就是我们要找的答案了。
制作阶段记录表
由于明确了数组的含义,我们可以知道这其实是一张二维表。
0 | 1 | 2 | … | m | |
---|---|---|---|---|---|
0 | |||||
1 | |||||
2 | |||||
… | |||||
n |
寻找数组初始值
这时,看题目的要求限制:机器人每次只能向下或者向右移动一步。
所以我们从左上角开始,向下的那一列(即 第一列) 和 向右的那一行(即 第一行)上面所有的节点,都只有一条路径到那。
因此,初始值如下:
- dp[0][0…n-1] = 1; // 第一行,机器人只能一直往右走
- dp[0…m-1][0] = 1; // 第一列,机器人只能一直往下走
历史记录表:
0 | 1 | 2 | … | m | |
---|---|---|---|---|---|
0 | 1 | 1 | 1 | … | 1 |
1 | 1 | ||||
2 | 1 | ||||
… | … | ||||
n | 1 |
找出递推关系式
这是动态规划四步走中最难的一步,我们从 dp[i][j] = ? 这一数学公式开始推想。
由于机器人只能向下走或者向右走,所以有两种方式到达(i, j):
- 一种是从 (i-1, j) 这个位置走一步到达
- 一种是从 (i, j-1) 这个位置走一步到达
所以我们可以知道,到达 (i, j) 的所有路径为这两种方式的和,可以得出递推关系式:
dp[i][j] = dp[i-1, j] + dp[i, j-1]
历史记录表:
0 | 1 | 2 | … | m | |
---|---|---|---|---|---|
0 | 1 | 1 | 1 | … | 1 |
1 | 1 | 2 | 3 | ||
2 | 1 | 3 | 6 | ||
… | … | ||||
n | 1 |
我们可以利用此递推关系式,写出程序填完整个表项。
在下面代码中,我选择的是逐行填入表格。
答案:
public static int uniquePaths(int m, int n) {
if (m <= 0 || n <= 0) {
return 0;
}
int[][] dp = new int[m][n]; // 地图
// 初始化
for(int i = 0; i < m; i++) {
dp[i][0] = 1;
}
for(int i = 0; i < n; i++) {
dp[0][i] = 1;
}
// 递推 dp[m-1][n-1]
for (int i = 1; i < m; i++) { // 逐行填写空表格
for (int j = 1; j < n; j++) {
dp[i][j] = dp[i-1][j] + dp[i][j-1];
}
}
return dp[m-1][n-1];
}
01背包
问题描述
有n个物品,它们有各自的体积和价值,现有给定容量的背包,如何让背包里装入的物品具有最大的价值总和?
为方便讲解和理解,下面讲述的例子均先用具体的数字代入,即:eg:number=4,capacity=8
i(物品编号) | 1 | 2 | 3 | 4 |
---|---|---|---|---|
w(体积) | 2 | 3 | 4 | 5 |
v(价值) | 3 | 4 | 5 | 6 |
答案:
做到这里,大家应该对动态规划很熟悉了,那么我们就加快速度。
1. 明确数组含义
一眼望去,我们这里的状态需要三个变量来存储:
- 当前商品编号
- 当前背包容量
- 当前背包总价值
所以我们采用二维数组的方法来存取。
2. 制作阶段记录表
状态:v[i][j] 代表当前背包容量 j,前 i 个物品最佳组合对应的价值
- 当前商品编号,前 i 个物品,前 i 个阶段:一维下标 i,代表物品阶段,随着物品越来越多,可选择的越来越多
- 当前背包容量:二维下标 j,代表背包容量阶段,随着背包慢慢变大,能装的越来越多
- 当前背包总价值:数值状态 v[i][j]
3. 寻找数组初始值
V(0,j) = V(i,0) = 0;
4. 找出递推关系式
寻找递推关系式,面对当前商品有两种可能性:
- 包的容量比该商品体积小,装不下,此时的价值与前 i-1 个的价值是一样的,即V(i, j)=V(i - 1, j);
- 还有足够的容量可以装该商品,但装了也不一定达到当前最优价值,所以在装与不装之间选择最优的一个,即V(i, j)= max{V(i - 1, j), V(i - 1, j - w(i)) + v(i)}。
其中V(i-1, j)表示不装,V(i-1, j - w(i)) + v(i) 表示装了第i个商品,背包容量减少 w(i),但价值增加了 v(i);
由此可以得出递推关系式:
- j < w(i) V(i, j)=V(i - 1, j)
- j >= w(i) V(i, j)=max{V(i - 1, j),V(i - 1, j - w(i)) + v(i)}
可以这么理解,如果要到达V(i,j)这一个状态有几种方式?
肯定是两种,第一种是第i件商品没有装进去,第二种是第i件商品装进去了。
没有装进去很好理解,就是V(i - 1, j);
装进去了怎么理解呢?如果装进去第 i 件商品,那么装入之前是什么状态,肯定是V(i - 1, j - w(i))。由于最优性原理(上文讲到),V(i - 1, j - w(i))就是前面决策造成的一种状态,后面的决策就要构成最优策略。两种情况进行比较,得出最优。
然后一行一行的填表:
如,i=1,j=1,w(1)=2,v(1)=3,有j<w(1),故V(1,1)=V(1-1,1)=0;
又如i=1,j=2,w(1)=2,v(1)=3,有j=w(1),故V(1,2)=max{ V(1-1,2),V(1-1,2-w(1))+v(1) }=max{0,0+3}=3;
如此下去,填到最后一个,i=4,j=8,w(4)=5,v(4)=6,有j>w(4),故V(4,8)=max{ V(4-1,8),V(4-1,8-w(4))+v(4) }=max{9,4+6}=10……
所以填完表如下图:
表格填完可知,最优解即是V(number, capacity) = V(4, 8) = 10。
Java
public class LeetCode {
public static void main(String[] args) {
int[] w = { 2 , 3 , 4 , 5 }; //商品的体积2、3、4、5
int[] v = { 3 , 4 , 5 , 6 }; //商品的价值3、4、5、6
Solution solution = new Solution();
System.out.println(solution.bag(8, w, v));
}
}
class Solution {
public int bag(int weight, int[] w, int[] v) {
// 3个参数,二维数组
// bag[i][j] i表示前i个物品最佳组合,j表示当前背包容量
// bag[i][j] 当前背包容量 j,前 i 个物品最佳组合对应的价值
int[][] bag = new int[w.length][weight + 1];
// 设置初始值入口
// 背包容量为0时,价值为0
for (int i = 0; i < bag.length; i++) {
bag[i][0] = 0;
}
// 物品编号为0时,随着背包容量变大就慢慢能装进去了
for (int i = 0; i < bag[0].length; i++) {
if (i < w[0]) {
bag[0][i] = 0;
} else {
bag[0][i] = v[0];
}
}
// 递推公式 bag[i][j]
// 放入第i个物品:bag[i][j] = max(bag[i - 1][j], bag[i][j - w[i]] + v[i])
for (int i = 1; i < bag.length; i++) {
for (int j = 1; j < bag[i].length; j++) {
if (j >= w[i]) {
// 还有足够的容量可以装该商品,但装了也不一定达到当前最优价值,所以在装与不装之间选择最优的一个,即V(i,j)=max{V(i-1,j),V(i-1,j-w(i))+v(i)}。
// 没有装进去很好理解,就是V(i-1,j);装进去了怎么理解呢?如果装进去第i件商品,那么装入之前是什么状态,肯定是V(i-1,j-w(i))。
bag[i][j] = Math.max(bag[i - 1][j], bag[i - 1][j - w[i]] + v[i]);
} else {
// 包的容量比该商品体积小,装不下,此时的价值与前i-1个的价值是一样的,即V(i,j)=V(i-1,j);
bag[i][j] = bag[i - 1][j];
}
}
}
for (int i = 0; i < bag.length; i++) {
for (int j = 0; j < bag[i].length; j++) {
System.out.print(bag[i][j] + " ");
}
System.out.println();
}
return bag[bag.length - 1][bag[0].length - 1];
}
}
答案
为了和之前的动态规划图可以进行对比,尽管只有4个商品,但是我们创建的数组元素由5个。
#include<iostream>
using namespace std;
#include <algorithm>
int main()
{
int w[5] = { 0 , 2 , 3 , 4 , 5 }; //商品的体积2、3、4、5
int v[5] = { 0 , 3 , 4 , 5 , 6 }; //商品的价值3、4、5、6
int bagV = 8; //背包大小
int dp[5][9] = { { 0 } }; //动态规划表
for (int i = 1; i <= 4; i++) {
for (int j = 1; j <= bagV; j++) {
if (j < w[i])
dp[i][j] = dp[i - 1][j];
else
dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - w[i]] + v[i]);
}
}
//动态规划表的输出
for (int i = 0; i < 5; i++) {
for (int j = 0; j < 9; j++) {
cout << dp[i][j] << ' ';
}
cout << endl;
}
return 0;
}
背包问题最优解回溯
通过上面的方法可以求出背包问题的最优解,但还不知道这个最优解由哪些商品组成,故要根据最优解回溯找出解的组成,根据填表的原理可以有如下的寻解方式:
- V(i,j)=V(i-1,j)时,说明没有选择第i 个商品,则回到V(i-1,j);
- V(i,j)=V(i-1,j-w(i))+v(i)时,说明装了第i个商品,该商品是最优解组成的一部分,随后我们得回到装该商品之前,即回到V(i-1,j-w(i));
- 一直遍历到i=0结束为止,所有解的组成都会找到。
就拿上面的例子来说吧:
- 最优解为V(4,8)=10,而V(4,8)!=V(3,8)却有V(4,8)=V(3,8-w(4))+v(4)=V(3,3)+6=4+6=10,所以第4件商品被选中,并且回到V(3,8-w(4))=V(3,3);
- 有V(3,3)=V(2,3)=4,所以第3件商品没被选择,回到V(2,3);
- 而V(2,3)!=V(1,3)却有V(2,3)=V(1,3-w(2))+v(2)=V(1,0)+4=0+4=4,所以第2件商品被选中,并且回到V(1,3-w(2))=V(1,0);
- 有V(1,0)=V(0,0)=0,所以第1件商品没被选择。
代码实现
背包问题最终版详细代码实现如下:
#include<iostream>
using namespace std;
#include <algorithm>
int w[5] = { 0 , 2 , 3 , 4 , 5 }; //商品的体积2、3、4、5
int v[5] = { 0 , 3 , 4 , 5 , 6 }; //商品的价值3、4、5、6
int bagV = 8; //背包大小
int dp[5][9] = { { 0 } }; //动态规划表
int item[5]; //最优解情况
void findMax() { //动态规划
for (int i = 1; i <= 4; i++) {
for (int j = 1; j <= bagV; j++) {
if (j < w[i])
dp[i][j] = dp[i - 1][j];
else
dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - w[i]] + v[i]);
}
}
}
void findWhat(int i, int j) { //最优解情况
if (i >= 0) {
if (dp[i][j] == dp[i - 1][j]) {
item[i] = 0;
findWhat(i - 1, j);
}
else if (j - w[i] >= 0 && dp[i][j] == dp[i - 1][j - w[i]] + v[i]) {
item[i] = 1;
findWhat(i - 1, j - w[i]);
}
}
}
void print() {
for (int i = 0; i < 5; i++) { //动态规划表输出
for (int j = 0; j < 9; j++) {
cout << dp[i][j] << ' ';
}
cout << endl;
}
cout << endl;
for (int i = 0; i < 5; i++) //最优解输出
cout << item[i] << ' ';
cout << endl;
}
int main()
{
findMax();
findWhat(4, 8);
print();
return 0;
}
当然,01背包不止动态规划这一种解决方法,还有分支界限法等,分支界限法可以查看分支界限三步走
注意,这里不能使用滚动数组,因为我们的代码bag[i][j] = Math.max(bag[i - 1][j], bag[i - 1][j - w[i]] + v[i]);
有较大的跳跃性。
KMP算法
没想到吧!!动态规划算法还能应用到KMP算法之中!
巧了,我也没想到,不过拜读了大佬的作品之后,我悟了。
那么,我们试试看用自己的方法做一下!
有限状态机
1. 明确数组的含义
我们的状态需要三个变量来存储,所以我们采用二维数组dp[i][j] = k;:
- 当前状态编号i,即 圆圈
- 当前状态遇到的字符j,即 箭头
- 当前状态遇到某一字符后跳转到什么状态编号k,即 箭头所指向的状态编号
2. 制作阶段记录表
i取决于状态个数,即 模式串pat的字符个数+1
这里以"ABABC"举例
当前状态编号i\当前状态遇到的字符j | A | B | C | ... | Z |
---|---|---|---|---|---|
0 | k = ? | ||||
1 | |||||
2 | |||||
3 | |||||
4 | |||||
5 |
3. 寻找数组初始值
只有遇到匹配的字符我们的状态 0 才能前进为状态 1,遇到其它字符的话还是停留在状态 0。
当前状态编号i\当前状态遇到的字符j | A | B | C | ... | Z |
---|---|---|---|---|---|
0 | k = 1 | 0 | 0 | ... | 0 |
1 | |||||
2 | |||||
3 | |||||
4 | |||||
5 |
4. 找出递推关系式
这一步是最难的,我们怎么递推呢?
KMP算法的精髓就是,前缀覆盖后缀,完全重合以省去遍历过程。
我们可以把状态的操作分为前进和后退两个部分
- 前进:只有遇到匹配的字符才能前进,即
c == pat.charAt(j)
时我们才能dp[j][c] = j + 1;
后退:如果遇到的字符不匹配,那我们就得倒退回最长公共前后缀的前缀的末尾字符下标 影子状态X,即
dp[j][c] = dp[X][c];
所谓影子状态,就是和当前状态具有相同前缀的状态。就是用来方便回溯的,作为当前状态j的一种“特殊的”前驱结点,即 X -> j
这个倒退过程,其实就是后缀覆盖前缀的过程。
箭头A (状态X) 箭头B (状态X+1):dp[X][B]就代表有AB前缀的状态
(状态X) 箭头B:这种组合是一体的,谁也离不开谁。
那么我们的 X 从哪里来呢?
- X 的初始值设置为 0;
- 更新 X 的操作:状态X 更新为状态X 面对状态 j 对应的字符pat.charAt(j)时状态变化的情况,即 更新为下一个状态的影子状态
X = dp[X][pat.charAt(j)];
详情可以看看下面代码的注释,在这里就不过多解释了。
public class KMP {
private int[][] dp;
private String pat;
public KMP(String pat) {
this.pat = pat;
int M = pat.length();
// dp[状态][字符] = 下个状态
dp = new int[M][256];
// base case
dp[0][pat.charAt(0)] = 1;
// 影子状态 X 初始为 0,所谓影子状态,就是和当前状态具有相同的前缀。感觉就是用来方便回溯的
// 你也可以就把它理解为一种满足相同前缀的“不一样”的前驱结指针
int X = 0;
// 当前状态 j 从 1 开始
for (int j = 1; j < M; j++) {
for (int c = 0; c < 256; c++) {
if (pat.charAt(j) == c)
dp[j][c] = j + 1; // 前进
else
dp[j][c] = dp[X][c]; // 后退,状态j 遇到c,不相等,不知道怎么处理,按拥有相同前缀的影子X遇到c时候处理
}
// 更新影子状态,这里更新影子状态是为了下一个状态做铺垫,即 更新为下一个状态的影子
// 其实,也就是相当于当前状态的前驱指针后移,即 pre++
X = dp[X][pat.charAt(j)]; // 影子状态 影子X遇到当前状态j的字符,返回与当前状态具有相同前缀的状态!!!如影随形
// 最长前后缀的前缀下标,方便j找不到相等情况时,后缀覆盖前缀
// 为了防止大家迷糊这里我标明一下进入下一个状态
// j++;
}
}
public int search(String txt) {
int M = pat.length();
int N = txt.length();
// pat 的初始态为 0
int j = 0;
for (int i = 0; i < N; i++) {
// 计算 pat 的下一个状态
j = dp[j][txt.charAt(i)];
// 到达终止态,返回结果
if (j == M) return i - M + 1;
}
// 没到达终止态,匹配失败
return -1;
}
}
剑指 Offer 63. 股票的最大利润
假设把某股票的价格按照时间先后顺序存储在数组中,请问买卖该股票一次可能获得的最大利润是多少?
示例 1:
输入: [7,1,5,3,6,4]
输出: 5
解释: 在第 2 天(股票价格 = 1)的时候买入,在第 5 天(股票价格 = 6)的时候卖出,最大利润 = 6-1 = 5 。
注意利润不能是 7-1 = 6, 因为卖出价格需要大于买入价格。
示例 2:
输入: [7,6,4,3,1]
输出: 0
解释: 在这种情况下, 没有交易完成, 所以最大利润为 0。
动态规划答案
class Solution {
public int maxProfit(int[] prices) {
// 连续多阶段决策
// 动态规划
int len = prices.length;
// 特殊判断
if (len < 2) {
return 0;
}
int[][] dp = new int[len][2];
// dp[i][0] 下标为 i 这天结束的时候,不持股,手上拥有的现金数
// dp[i][1] 下标为 i 这天结束的时候,持股,手上拥有的现金数
// 初始化:不持股显然为 0,持股就需要减去第 1 天(下标为 0)的股价
dp[0][0] = 0;
dp[0][1] = -prices[0];
// 从第 2 天开始遍历
for (int i = 1; i < len; i++) {
dp[i][0] = Math.max(dp[i - 1][0], dp[i - 1][1] + prices[i]);
dp[i][1] = Math.max(dp[i - 1][1], -prices[i]);
}
return dp[len - 1][0];
}
}
一次遍历答案
// 试试一次遍历
class Solution {
public int maxProfit(int[] prices) {
// 我们需要一个变量min来存储以往的最低价格,用 当天价格 - min,就能获得当天的利润,取最大即可
int min = Integer.MAX_VALUE; // 历史最低价
int res = 0; // 最大利润
for (int i = 0; i < prices.length; i++) {
min = Math.min(min, prices[i]); // 取最小历史价格
res = Math.max(res, prices[i] - min); // 取最大利润
}
return res;
}
}
121. 买卖股票的最佳时机
给定一个数组 prices ,它的第 i 个元素 prices[i] 表示一支给定股票第 i 天的价格。
你只能选择 某一天 买入这只股票,并选择在 未来的某一个不同的日子 卖出该股票。设计一个算法来计算你所能获取的最大利润。
返回你可以从这笔交易中获取的最大利润。如果你不能获取任何利润,返回 0 。
示例 1:
输入:[7,1,5,3,6,4]
输出:5
解释:在第 2 天(股票价格 = 1)的时候买入,在第 5 天(股票价格 = 6)的时候卖出,最大利润 = 6-1 = 5 。
注意利润不能是 7-1 = 6, 因为卖出价格需要大于买入价格;同时,你不能在买入前卖出股票。
示例 2:
输入:prices = [7,6,4,3,1]
输出:0
解释:在这种情况下, 没有交易完成, 所以最大利润为 0。
答案
一次遍历
算法
假设给定的数组为:[7, 1, 5, 3, 6, 4]
如果我们在图表上绘制给定数组中的数字,我们将会得到:
我们来假设自己来购买股票。随着时间的推移,每天我们都可以选择出售股票与否。那么,假设在第 i 天,如果我们要在今天卖股票,那么我们能赚多少钱呢?
显然,如果我们真的在买卖股票,我们肯定会想:如果我是在历史最低点买的股票就好了!太好了,在题目中,我们只要用一个变量记录一个历史最低价格 minprice,我们就可以假设自己的股票是在那天买的。那么我们在第 i 天卖出股票能得到的利润就是 prices[i] - minprice。
因此,我们只需要遍历价格数组一遍,记录历史最低点,然后在每一天考虑这么一个问题:如果我是在历史最低点买进的,那么我今天卖出能赚多少钱?当考虑完所有天数之时,我们就得到了最好的答案。
public class Solution {
public int maxProfit(int prices[]) {
int minprice = Integer.MAX_VALUE;
int maxprofit = 0;
for (int i = 0; i < prices.length; i++) {
if (prices[i] < minprice) {
minprice = prices[i];
} else if (prices[i] - minprice > maxprofit) {
maxprofit = prices[i] - minprice;
}
}
return maxprofit;
}
}
复杂度分析
- 时间复杂度:O(n)O(n),只需要遍历一次。
- 空间复杂度:O(1)O(1),只使用了常数个变量。
122. 买卖股票的最佳时机 II
给定一个数组,它的第 i 个元素是一支给定股票第 i 天的价格。
设计一个算法来计算你所能获取的最大利润。你可以尽可能地完成更多的交易(多次买卖一支股票)。
注意:你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。
示例 1:
输入: [7,1,5,3,6,4]
输出: 7
解释: 在第 2 天(股票价格 = 1)的时候买入,在第 3 天(股票价格 = 5)的时候卖出, 这笔交易所能获得利润 = 5-1 = 4 。
随后,在第 4 天(股票价格 = 3)的时候买入,在第 5 天(股票价格 = 6)的时候卖出, 这笔交易所能获得利润 = 6-3 = 3 。
示例 2:
输入: [1,2,3,4,5]
输出: 4
解释: 在第 1 天(股票价格 = 1)的时候买入,在第 5 天 (股票价格 = 5)的时候卖出, 这笔交易所能获得利润 = 5-1 = 4 。
注意你不能在第 1 天和第 2 天接连购买股票,之后再将它们卖出。
因为这样属于同时参与了多笔交易,你必须在再次购买前出售掉之前的股票。
示例 3:
输入: [7,6,4,3,1]
输出: 0
解释: 在这种情况下, 没有交易完成, 所以最大利润为 0。
答案
需要设置一个二维矩阵表示状态。
第 1 步:定义状态
状态 dp[i][j] 定义如下:
dp[i][j] 表示到下标为 i 的这一天,持股状态为 j 时,我们手上拥有的最大现金数。
注意:限定持股状态为 j 是为了方便推导状态转移方程,这样的做法满足 无后效性。
其中:
- 第一维 i 表示下标为 i 的那一天( 具有前缀性质,即 考虑了之前天数的交易 );
- 第二维 j 表示下标为 i 的那一天是持有股票,还是持有现金。这里 0 表示持有现金(cash),1 表示持有股票(stock)。
第 2 步:思考状态转移方程
状态从持有现金(cash)开始,到最后一天我们关心的状态依然是持有现金(cash);
每一天状态可以转移,也可以不动。状态转移用下图表示:
(状态转移方程写在代码中)
说明:
- 由于不限制交易次数,除了最后一天,每一天的状态可能不变化,也可能转移;
- 写代码的时候,可以不用对最后一天单独处理,输出最后一天,状态为 0 的时候的值即可。
第 3 步:确定初始值
起始的时候:
- 如果什么都不做,dp[0][0] = 0;
- 如果持有股票,当前拥有的现金数是当天股价的相反数,即 dp[0][1] = -prices[i];
第 4 步:确定输出值
终止的时候,上面也分析了,输出 dp[len - 1][0],因为一定有 dp[len - 1][0] > dp[len - 1][1]。
class Solution {
public int maxProfit(int[] prices) {
int n = prices.length;
int[][] dp = new int[n][2];
dp[0][0] = 0;
dp[0][1] = -prices[0];
for (int i = 1; i < n; ++i) {
dp[i][0] = Math.max(dp[i - 1][0], dp[i - 1][1] + prices[i]);
dp[i][1] = Math.max(dp[i - 1][1], dp[i - 1][0] - prices[i]);
}
return dp[n - 1][0];
}
}
其他方法:
// 上面的代码可以是可以,但是太臃肿了,我们改进一下
class Solution {
// 其实整个题意就是,给你一个折线图,求所有上升折线的总上升量
public int maxProfit(int[] prices) {
// 利润
int sum = 0;
for (int i = 0; i + 1 < prices.length; i++) {
int up = prices[i + 1] - prices[i]; // 上升量
if (up > 0) { // 如果上升量大于0,那就加上
sum += up;
}
}
return sum;
}
}
123. 买卖股票的最佳时机 III
给定一个数组,它的第 i 个元素是一支给定的股票在第 i 天的价格。
设计一个算法来计算你所能获取的最大利润。你最多可以完成 两笔 交易。
注意:你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。
示例 1:
输入:prices = [3,3,5,0,0,3,1,4]
输出:6
解释:在第 4 天(股票价格 = 0)的时候买入,在第 6 天(股票价格 = 3)的时候卖出,这笔交易所能获得利润 = 3-0 = 3 。
随后,在第 7 天(股票价格 = 1)的时候买入,在第 8 天 (股票价格 = 4)的时候卖出,这笔交易所能获得利润 = 4-1 = 3 。
示例 2:
输入:prices = [1,2,3,4,5]
输出:4
解释:在第 1 天(股票价格 = 1)的时候买入,在第 5 天 (股票价格 = 5)的时候卖出, 这笔交易所能获得利润 = 5-1 = 4 。
注意你不能在第 1 天和第 2 天接连购买股票,之后再将它们卖出。
因为这样属于同时参与了多笔交易,你必须在再次购买前出售掉之前的股票。
示例 3:
输入:prices = [7,6,4,3,1]
输出:0
解释:在这个情况下, 没有交易完成, 所以最大利润为 0。
示例 4:
输入:prices = [1]
输出:0
答案
188. 买卖股票的最佳时机 IV
给定一个整数数组 prices ,它的第 i 个元素 prices[i] 是一支给定的股票在第 i 天的价格。
设计一个算法来计算你所能获取的最大利润。你最多可以完成 k 笔交易。
注意:你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。
示例 1:
输入:k = 2, prices = [2,4,1]
输出:2
解释:在第 1 天 (股票价格 = 2) 的时候买入,在第 2 天 (股票价格 = 4) 的时候卖出,这笔交易所能获得利润 = 4-2 = 2 。
示例 2:
输入:k = 2, prices = [3,2,6,5,0,3]
输出:7
解释:在第 2 天 (股票价格 = 2) 的时候买入,在第 3 天 (股票价格 = 6) 的时候卖出, 这笔交易所能获得利润 = 6-2 = 4 。
随后,在第 5 天 (股票价格 = 0) 的时候买入,在第 6 天 (股票价格 = 3) 的时候卖出, 这笔交易所能获得利润 = 3-0 = 3 。
剑指 Offer 46. 把数字翻译成字符串
给定一个数字,我们按照如下规则把它翻译为字符串:0 翻译成 “a” ,1 翻译成 “b”,……,11 翻译成 “l”,……,25 翻译成 “z”。一个数字可能有多个翻译。请编程实现一个函数,用来计算一个数字有多少种不同的翻译方法。
示例 1:
输入: 12258
输出: 5
解释: 12258有5种不同的翻译,分别是"bccfi", "bwfi", "bczi", "mcfi"和"mzi"
动态规划答案
我们可以看出它的递推范围为2,即 我们的1位字符和2位字符都有可能被翻译。
所以我们要找的递推关系式为:dp[n] = dp[n - 1] + dp[n - 2](这个n - 2不一定能成立。。)
class Solution {
public int translateNum(int num) {
// 回溯法思想:循环层数不确定
// 一个得算
// 1个两个得算
// 2个两个得算
// 3个两个。。。。
// 动态规划思想:数字位数n的阶段,数字位数n-1、n-2的阶段,能不能推出来
String str = String.valueOf(num);
// 状态:前n位数,翻译方法数
int[] dp = new int[str.length() + 1];
// 初始化,一位数
dp[0] = 1;
dp[1] = 1;
for (int i = 2; i < dp.length; i++) {
// (i - 1) + 1 = i
dp[i] += dp[i - 1];
// 如果后两位在10~25之间,那就可以翻译
// (i - 2) + 2 = i
String temp = str.substring(i - 2, i);
if (temp.compareTo("10") >= 0 && temp.compareTo("25") <= 0) {
dp[i] += dp[i - 2];
}
}
return dp[dp.length - 1];
}
}
回溯法
有关回溯法,也可以看看我写的回溯三步走
// 上面可以运行,但是我想试试递归回溯
// 个人感觉这题跟青蛙跳台阶一个风格
class Solution {
public int res = 0;
public int translateNum(int num) {
// 回溯法思想:循环层数不确定
// 一个得算
// 1个两个得算
// 2个两个得算
// 3个两个。。。。
backtrack(String.valueOf(num), 0);
return res;
}
public void backtrack(String str, int index) {
// 这里的递归出口要注意:我们是到顶了再前进一格,这样回退的话就可以取到最后一个元素的边界,这样就好进一步取2位子串
if (index > str.length()) {
res++;
return;
}
backtrack(str, index + 1);
if (index >= 2) {
String temp = str.substring(index - 2, index);
if (temp.compareTo("10") >= 0 && temp.compareTo("25") <= 0) {
backtrack(str, index + 2);
}
}
}
}
剑指 Offer 60. n个骰子的点数
把n个骰子扔在地上,所有骰子朝上一面的点数之和为s。输入n,打印出s的所有可能的值出现的概率。
你需要用一个浮点数数组返回答案,其中第 i 个元素代表这 n 个骰子所能掷出的点数集合中第 i 小的那个的概率。
示例 1:
输入: 1
输出: [0.16667,0.16667,0.16667,0.16667,0.16667,0.16667]
示例 2:
输入: 2
输出: [0.02778,0.05556,0.08333,0.11111,0.13889,0.16667,0.13889,0.11111,0.08333,0.05556,0.02778]
答案
// 11 -> 12 21 -> 22 13 31 -> 14 41 23 32 -> 15 51 24 42 33 -> 16 61 52 25 43 34 -> 17 71 62 26 53 35 440
class Solution {
public double[] dicesProbability(int n) {
// 动态规划
// 状态:前i个骰子,总点数,概率
double[][] dp = new double[n + 1][6 * n + 1];
Arrays.fill(dp[1], 1, 7, 1.0 / 6);
// dp[n - 1][i] = dp[n - 2][i - 1] * 1.0 / 6;
for (int i = 2; i <= n; i++) {
for (int j = i; j <= 6 * i; j++) {
for (int k = 1; k <= 6 && k < j; k++) {
// 前i,点数j = 前i-1,点数j-k
dp[i][j] += dp[i - 1][j - k] * 1.0 / 6;
}
}
}
return Arrays.copyOfRange(dp[n], n, dp[n].length);
}
}
答案优化(滚动数组)
// 大佬写的滚动数组:由于 dp[i] 仅由 dp[i-1] 递推得出,为降低空间复杂度,只建立两个一维数组 dp , tmp 交替前进即可。
class Solution {
public double[] dicesProbability(int n) {
// 动态规划
// 状态:前i个骰子,总点数,概率
double[] pre = new double[6]; // 前数组指针,代表dp[i - 1]
Arrays.fill(pre, 1.0 / 6.0);
for (int i = 2; i <= n; i++) {
// double[] cur = new double[6 * n - n + 1]; // 现数组指针,代表dp[i]
double[] cur = new double[5 * i + 1]; // 与上面等价[6 * n - n + 1]
for (int j = 0; j < pre.length; j++) {
for (int k = 0; k < 6; k++) {
cur[j + k] += pre[j] / 6.0;
}
}
pre = cur; // 前进
}
return pre;
}
}
剑指 Offer 14- I. 剪绳子
给你一根长度为 n 的绳子,请把绳子剪成整数长度的 m 段(m、n都是整数,n>1并且m>1),每段绳子的长度记为 k[0],k[1]...k[m-1] 。请问 k[0]k[1]...*k[m-1] 可能的最大乘积是多少?例如,当绳子的长度是8时,我们把它剪成长度分别为2、3、3的三段,此时得到的最大乘积是18。
示例 1:
输入: 2
输出: 1
解释: 2 = 1 + 1, 1 × 1 = 1
示例 2:
输入: 10
输出: 36
解释: 10 = 3 + 3 + 4, 3 × 3 × 4 = 36
答案
// // 经过多次实验,感觉按3剪比较大
// class Solution {
// public int cuttingRope(int n) {
// if(n <= 3) return n - 1;
// int a = n / 3, b = n % 3;
// // 余0时
// if(b == 0) return (int)Math.pow(3, a);
// // 余1时
// if(b == 1) return (int)Math.pow(3, a - 1) * 4;
// // 余2时
// return (int)Math.pow(3, a) * 2;
// }
// }
// 动态规划
class Solution {
public int cuttingRope(int n) {
// 状态参数:绳子长度、最大乘积
int[] dp = new int[n + 1];
int max = 0;
// 初始化,数组默认初始值为0
dp[2] = 1;
// 填表
for (int i = 3; i < n + 1; i++) { // 填表
for (int cut = 2; cut < i; cut++) { // 以cut为长度剪一刀
// 获取 剪一刀后直接相乘的乘积 和 剪一刀后与原先最大乘积的乘积 两者的最大值
max = Math.max(cut * (i - cut), cut * dp[i - cut]);
// 这个 最大值 和 当前dp[i] 进行比较,取最大值,看看哪一种cut能取到最大
dp[i] = Math.max(dp[i], max);
}
}
return dp[n];
}
}
剑指 Offer 42. 连续子数组的最大和
输入一个整型数组,数组中的一个或连续多个整数组成一个子数组。求所有子数组的和的最大值。
要求时间复杂度为O(n)。
示例1:
输入: nums = [-2,1,-3,4,-1,2,1,-5,4]
输出: 6
解释: 连续子数组 [4,-1,2,1] 的和最大,为 6。
答案
动态规划解析:
状态定义: 设动态规划列表 dp ,dp[i] 代表以元素 nums[i] 为结尾的连续子数组最大和。(即 前缀性质)
为何定义最大和 dp[i] 中必须包含元素 nums[i] :保证 dp[i] 递推到 dp[i+1] 的正确性;如果不包含 nums[i] ,递推时则不满足题目的 连续子数组 要求。
- 转移方程: 若 \(dp[i-1] \leq 0\) ,说明 dp[i - 1] 对 dp[i] 产生负贡献,即 dp[i-1] + nums[i] 还不如 nums[i] 本身大。
- 当 \(dp[i - 1] > 0\) 时:执行 dp[i] = dp[i-1] + nums[i];
- 当 \(dp[i - 1] \leq 0\) 时:执行 dp[i] = nums[i];
初始状态: dp[0] = nums[0],即以 nums[0] 结尾的连续子数组最大和为 nums[0]。
返回值: 返回 dp 列表中的最大值,代表全局最大值。
class Solution {
public int maxSubArray(int[] nums) {
// 试试双指针滑动数组,发现没啥用,不能根据现在的情况判断如何前进后退
// 只能想想别的办法了,一看这是多阶段决策问题,动态规划!
// 两个参数,1.现在的索引号,2.当前最大和
int[] dp = new int[nums.length];
dp[0] = nums[0];
int max = nums[0];
for (int i = 1; i < nums.length; i++) {
// 如果纠结于现在索引号取和不取的最大和,会导致不取的时候子数组不连续,所以该数组状态不行
// dp[i] = Math.max(dp[i - 1], dp[i - 1] + nums[i]);
// 所以我们定义必取现在的索引号,那么会造成一个新的问题,我们的动态规划最大值不一定是最后一个了,所以我们需要存储最大值
dp[i] = Math.max(dp[i - 1] + nums[i], nums[i]);
max = Math.max(dp[i], max);
}
return max;
}
}
当代码敲完的那一刻,是不是就感觉这个二维表也太好看了吧。。。把抽象的东西可视化了,时时刻刻都知道自己要干嘛。