刷题笔记9.动态规划
动态规划
62. 不同路径
62. 不同路径
难度中等1205收藏分享切换为英文接收动态反馈
一个机器人位于一个 m x n
网格的左上角 (起始点在下图中标记为 “Start” )。
机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为 “Finish” )。
问总共有多少条不同的路径?
示例 1:
63. 不同路径 II
机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为“Finish”)。
现在考虑网格中有障碍物。那么从左上角到右下角将会有多少条不同的路径?
class Solution {
public int uniquePathsWithObstacles(int[][] obstacleGrid) {
if (obstacleGrid == null || obstacleGrid.length == 0) {
return 0;
}
// 定义 dp 数组并初始化第 1 行和第 1 列。
int m = obstacleGrid.length, n = obstacleGrid[0].length;
int[][] dp = new int[m][n];
for (int i = 0; i < m && obstacleGrid[i][0] == 0; i++) {
dp[i][0] = 1;
}
for (int j = 0; j < n && obstacleGrid[0][j] == 0; j++) {
dp[0][j] = 1;
}
// 根据状态转移方程 dp[i][j] = dp[i - 1][j] + dp[i][j - 1] 进行递推。
for (int i = 1; i < m; i++) {
for (int j = 1; j < n; j++) {
if (obstacleGrid[i][j] == 0) {
dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
}
}
}
return dp[m - 1][n - 1];
}
}
120. 三角形最小路径和
解法一:递归
Java
class Solution {
public int minimumTotal(List<List<Integer>> triangle) {
return dfs(triangle, 0, 0);
}
private int dfs(List<List<Integer>> triangle, int i, int j) {
if (i == triangle.size()) {
return 0;
}
return Math.min(dfs(triangle, i + 1, j), dfs(triangle, i + 1, j + 1)) + triangle.get(i).get(j);
}
}
暴力搜索会有大量的重复计算,导致 超时,因此在 解法二 中结合记忆化数组进行优化。
解法二:递归 + 记忆化
解法三:动态规划
定义二维 dp 数组,将解法二中「自顶向下的递归」改为「自底向上的递推」。
1、状态定义:
dp[i] [j]dp[i] [j] 表示从点 (i, j)(i,j) 到底边的最小路径和。
2、状态转移:
\(dp[i][j] = min(dp[i + 1] [j], dp[i + 1] [j + 1]) + triangle[i] [j]dp[i] [j]=min(dp[i+1] [j],dp[i+1] [j+1])+triangle[i][j]\)
3、代码实现:
class Solution {
public int minimumTotal(List<List<Integer>> triangle) {
int n = triangle.size();
// dp[i][j] 表示从点 (i, j) 到底边的最小路径和。
int[][] dp = new int[n + 1][n + 1];
// 从三角形的最后一行开始递推。
for (int i = n - 1; i >= 0; i--) {
for (int j = 0; j <= i; j++) {
dp[i][j] = Math.min(dp[i + 1][j], dp[i + 1][j + 1]) + triangle.get(i).get(j);
}
}
return dp[0][0];
}
}
4、空间优化
在上述代码中,我们定义了一个 N 行 N 列 的 dp数组(NN 是三角形的行数)。
但是在实际递推中我们发现,计算 dp[i] [j] 时,只用到了下一行的 \(dp[i + 1] [j]\) 和 \(dp[i + 1] [j + 1]\)
class Solution {
public int minimumTotal(List<List<Integer>> triangle) {
int m = triangle.size();
int[]dp=new int[m+1];
int min=0;
for(int i=m-1;i>=0;i--){
for(int j=0;j<triangle.get(i).size();j++){
dp[j]=Math.min(dp[j],dp[j+1])+triangle.get(i).get(j);
}
}
return dp[0];
}
}
#子序列问题:
需要对「子序列」和「子串」这两个概念进行区分;
子序列(subsequence):子序列并不要求连续,例如:序列 [4, 6, 5] 是 [1, 2, 4, 3, 7, 6, 5] 的一个子序列;
子串(substring、subarray):子串一定是原始字符串的连续子串。
一、子序列不连续
其他「序列 DP」问题
题目 | 题解 | 难度 | 推荐指数 |
---|---|---|---|
354. 俄罗斯套娃信封问题 | LeetCode 题解链接 | 困难 | 🤩🤩🤩🤩🤩 |
368. 最大整除子集 | LeetCode 题解链接 | 中等 | 🤩🤩🤩🤩 |
446. 等差数列划分 II - 子序列 | LeetCode 题解链接 | 困难 | 🤩🤩🤩🤩🤩 |
740. 删除并获得点数 | LeetCode 题解链接 | 中等 | 🤩🤩🤩🤩🤩 |
978. 最长湍流子数组 | LeetCode 题解链接 | 中等 | 🤩🤩🤩 |
1035. 不相交的线 | LeetCode 题解链接 | 中等 | 🤩🤩🤩🤩 |
1143. 最长公共子序列 | LeetCode 题解链接 | 中等 | 🤩🤩🤩🤩 |
1713. 得到子序列的最少操作次数 | LeetCode 题解链接 | 困难 | 🤩🤩🤩🤩🤩 |
1143. 最长公共子序列
解题思路
求两个数组或者字符串的最长公共子序列问题,肯定是要用动态规划的。下面的题解并不难,你肯定能看懂。
- 首先,区分两个概念:子序列可以是不连续的;子数组(子字符串)需要是连续的;
- 另外,动态规划也是有套路的:单个数组或者字符串要用动态规划时,可以把动态规划 dp[i] 定义为 nums[0:i] 中想要求的结果;当两个数组或者字符串要用动态规划时,可以把动态规划定义成两维的 dp[i] [j] ,其含义是在 A[0:i] 与 B[0:j] 之间匹配得到的想要的结果。
1.状态定义
比如对于本题而言,可以定义 dp[i] [j] 表示 text1[0:i-1] 和 text2[0:j-1] 的最长公共子序列。 (注:text1[0:i-1] 表示的是 text1 的 第 0 个元素到第 i - 1 个元素,两端都包含)
之所以 dp[i] [j] 的定义不是 text1[0:i] 和 text2[0:j] ,是为了方便当 i = 0 或者 j = 0 的时候,dp[i] [j]表示的为空字符串和另外一个字符串的匹配,这样 dp[i] [j] 可以初始化为 0.
2.状态转移方程
知道状态定义之后,我们开始写状态转移方程。
-
当 text1[i - 1] == text2[j - 1] 时,说明两个子字符串的最后一位相等,所以最长公共子序列又增加了 1,所以 dp[i][j] = dp[i - 1] [j - 1] + 1;举个例子,比如对于 ac 和 bc 而言,他们的最长公共子序列的长度等于 a 和 b 的最长公共子序列长度 0 + 1 = 1。
-
当 text1[i - 1] != text2[j - 1] 时,说明两个子字符串的最后一位不相等,那么此时的状态 dp[i][j] 应该是 dp[i - 1] [j] 和 dp[i] [j - 1] 的最大值。举个例子,比如对于 ace 和 bc 而言,他们的最长公共子序列的长度等于 ① ace 和 b 的最长公共子序列长度0 与 ② ac 和 bc 的最长公共子序列长度1 的最大值,即 1。
综上状态转移方程为:
dp[i] [j] = dp[i - 1] [j - 1] + 1, 当 text1[i - 1] == text2[j - 1];
dp[i] [j] = max(dp[i - 1] [j], dp[i][j - 1]), 当 text1[i - 1] != text2[j - 1]
3.状态的初始化
初始化就是要看当 i = 0 与 j = 0 时, dp[i][j] 应该取值为多少。
-
当 i = 0 时,dp[0] [j] 表示的是 text1text1 中取空字符串 跟 text2text2 的最长公共子序列,结果肯定为 0.
-
当 j = 0 时,dp[i] [0] 表示的是 text2text2 中取空字符串 跟 text1text1 的最长公共子序列,结果肯定为 0.
综上,当 i = 0 或者 j = 0 时,dp[i][j] 初始化为 0.
4.遍历方向与范围
由于 dp[i] [j] 依赖与 dp[i - 1] [j - 1] , dp[i - 1] [j], dp[i] [j - 1],所以 ii 和 jj 的遍历顺序肯定是从小到大的。
另外,由于当 ii 和 jj 取值为 0 的时候,dp[i] [j] = 0,而 dp 数组本身初始化就是为 0,所以,直接让 ii 和 jj 从 1 开始遍历。遍历的结束应该是字符串的长度为 len(text1) 和 len(text2)。
5.最终返回结果
由于 dp[i][j] 的含义是 text1[0:i-1] 和 text2[0:j-1] 的最长公共子序列。我们最终希望求的是 text1 和 text2 的最长公共子序列。所以需要返回的结果是 i = len(text1) 并且 j = len(text2) 时的 dp[len(text1)] [len(text2)]
class Solution {
public int longestCommonSubsequence(String text1, String text2) {
int len1=text1.length();
int len2=text2.length();
int[][] dp=new int[len1+1][len2+1];
for(int i=1;i<=len1;i++){
for(int j=1;j<=len2;j++){
if(text1.charAt(i-1)==text2.charAt(j-1)){
dp[i][j]=dp[i-1][j-1]+1;
}else{
dp[i][j]=Math.max(dp[i-1][j],dp[i][j-1]);
}
}
}
return dp[len1][len2];
}
}
300. 最长递增子序列
难度中等2073收藏分享切换为英文接收动态反馈
给你一个整数数组 nums
,找到其中最长严格递增子序列的长度。
子序列是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。例如,[3,6,2,7]
是数组 [0,3,1,6,2,2,7]
的子序列。
示例 1:
输入:nums = [10,9,2,5,3,7,101,18]
输出:4
解释:最长递增子序列是 [2,3,7,101],因此长度为 4 。
方法1:动态规划
首先考虑题目问什么,就把什么定义成状态。题目问最长上升子序列的长度,其实可以把「子序列的长度」定义成状态,但是发现「状态转移」不好做。
基于「动态规划」的状态设计需要满足「无后效性」的设计思想,可以将状态定义为「以 nums[i] 结尾 的「上升子序列」的长度」。
1. 定义状态:
dp[i]
表示:以 nums[i]
结尾 的「上升子序列」的长度。注意:这个定义中 nums[i]
必须被选取,且必须是这个子序列的最后一个元素;
2. 状态转移方程:
如果一个较大的数接在较小的数后面,就会形成一个更长的子序列。只要 nums[i] 严格大于在它位置之前的某个数,那么 nums[i] 就可以接在这个数后面形成一个更长的上升子序列。
dp[i] = max(dp[i], dp[j] + 1) for j in [0, i)
3. 初始化:
dp[i] = 1
,1 个字符显然是长度为 11 的上升子序列。
4. 输出:
不能返回最后一个状态值,最后一个状态值只表示以 nums[len - 1]
结尾的「上升子序列」的长度,状态数组 dp
的最大值才是题目要求的结果。
class Solution {
public int lengthOfLIS(int[] nums) {
int[] dp=new int[nums.length];
dp[0]=1;
int max=1;
for(int i=1;i<nums.length;i++){
dp[i]=1;
for(int j=0;j<i;j++){
if(nums[j]<nums[i]){
dp[i]=Math.max(dp[j]+1,dp[i]);
}
}
max=Math.max(dp[i],max);
}
return max;
}
}
解法二:动态规划 + 二分查找
状态设计思想:依然着眼于某个上升子序列的 结尾的元素,如果 已经得到的上升子序列的结尾的数越小,那么遍历的时候后面接上一个数,会有更大的可能构成一个长度更长的上升子序列。既然结尾越小越好,我们可以记录 在长度固定的情况下,结尾最小的那个元素的数值,这样定义以后容易得到「状态转移方程」。
为了与「方法二」的状态定义区分,将状态数组命名为 tail。
1 .定义新状态(特别重要)
tail[i]
表示:长度为 i + 1
的 所有 上升子序列的结尾的最小值
在遍历数组 nums 的过程中,看到一个新数 num,如果这个数 严格 大于有序数组 tail 的最后一个元素,就把 num 放在有序数组 tail 的后面,否则进入第 2 点;
注意:这里的大于是「严格大于」,不包括等于的情况。
- 在有序数组
tail
中查找第 1 个等于大于num
的那个数,试图让它变小;- 如果有序数组
tail
中存在 等于num
的元素,什么都不做,因为以num
结尾的最短的「上升子序列」已经存在; - 如果有序数组
tail
中存在 大于num
的元素,找到第 1 个,让它变小,这样我们就找到了一个 结尾更小的相同长度的上升子序列。
- 如果有序数组
public class Solution {
public int lengthOfLIS(int[] nums) {
int len = nums.length;
if (len <= 1) {
return len;
}
// tail 数组的定义:长度为 i + 1 的上升子序列的末尾最小是几
int[] tail = new int[len];
// 遍历第 1 个数,直接放在有序数组 tail 的开头
tail[0] = nums[0];
// end 表示有序数组 tail 的最后一个已经赋值元素的索引
int end = 0;
for (int i = 1; i < len; i++) {
// 【逻辑 1】比 tail 数组实际有效的末尾的那个元素还大
if (nums[i] > tail[end]) {
// 直接添加在那个元素的后面,所以 end 先加 1
end++;
tail[end] = nums[i];
} else {
// 使用二分查找法,在有序数组 tail 中
// 找到第 1 个大于等于 nums[i] 的元素,尝试让那个元素更小
int left = 0;
int right = end;
while (left < right) {
// 选左中位数不是偶然,而是有原因的,原因请见 LeetCode 第 35 题题解
// int mid = left + (right - left) / 2;
int mid = left + ((right - left) >>> 1);
if (tail[mid] < nums[i]) {
// 中位数肯定不是要找的数,把它写在分支的前面
left = mid + 1;
} else {
right = mid;
}
}
// 走到这里是因为 【逻辑 1】 的反面,因此一定能找到第 1 个大于等于 nums[i] 的元素
// 因此,无需再单独判断
tail[left] = nums[i];
}
}
// 此时 end 是有序数组 tail 最后一个元素的索引
// 题目要求返回的是长度,因此 +1 后返回
end++;
return end;
}
}
1035. 不相交的线
难度中等261收藏分享切换为英文接收动态反馈
在两条独立的水平线上按给定的顺序写下 nums1
和 nums2
中的整数。
现在,可以绘制一些连接两个数字 nums1[i]
和 nums2[j]
的直线,这些直线需要同时满足满足:
nums1[i] == nums2[j]
- 且绘制的直线不与任何其他连线(非水平线)相交。
请注意,连线即使在端点也不能相交:每个数字只能属于一条连线。
以这种方法绘制线条,并返回可以绘制的最大连线数。
示例 1:
输入:nums1 = [1,4,2], nums2 = [1,2,4]
输出:2
解释:可以画出两条不交叉的线,如上图所示。
但无法画出第三条不相交的直线,因为从 nums1[1]=4 到 nums2[2]=4 的直线将与从 nums1[2]=2 到 nums2[1]=2 的直线相交。
===这是一道「最长公共子序列(LCS)」的轻度变形题。
为了让你更好的与「最长公共子序列(LCS)」裸题进行对比,我们使用 s1s1 代指 nums1nums1,s2s2 代指 nums2nums2。
446. 等差数列划分 II - 子序列
难度困难239收藏分享切换为英文接收动态反馈
给你一个整数数组 nums
,返回 nums
中所有 等差子序列 的数目。
如果一个序列中 至少有三个元素 ,并且任意两个相邻元素之差相同,则称该序列为等差序列。
- 例如,
[1, 3, 5, 7, 9]
、[7, 7, 7, 7]
和[3, -1, -5, -9]
都是等差序列。 - 再例如,
[1, 1, 2, 5, 7]
不是等差序列。
数组中的子序列是从数组中删除一些元素(也可能不删除)得到的一个序列。
- 例如,
[2,5,10]
是[1,2,1,***2***,4,1,***5\***,***10***]
的一个子序列。
题目数据保证答案是一个 32-bit 整数。
示例 1:
输入:nums = [2,4,6,8,10]
输出:7
解释:所有的等差子序列为:
[2,4,6]
[4,6,8]
[6,8,10]
[2,4,6,8]
[4,6,8,10]
[2,4,6,8,10]
[2,6,10]
思路
- 「以
nums[i]
结尾」这件事情肯定要定义在状态中; - 题目不要求连续,因此在求每一个状态的时候,就需要 考虑它之前的所有的元素;
- 能不能接上去,看「公差」,因此记录状态的时候,除了要求以
nums[i]
结尾以外,还要记录「公差」,两个整数的差可以有很多很多,因此需要用哈希表记录下来。
到这里为止,每一个 nums[i] 的状态,其实是一张哈希表(键值对),「键」 是 nums[i] 与它前面的每一个元素的「差」,那「值」是什么呢?「值」是以 nums[i] 结尾组成的、公差为某个值的 长度大于等于 22 的等差子序列的个数(就是官方题解中提到的弱等差数列的个数)。
说明:
之前的状态值里有同样的 diff
的时候,说明才可能形成长度大于等于 3 的等差数列,此时记录结果;
对 res += dp[j].get(diff)
; 这一行代码不太理解的朋友,可以回顾一下上面的例子和下面的注释;
注意区分一下 i 和 j;
class Solution {
public int numberOfArithmeticSlices(int[] nums) {
// 弱等差子序列的长度至少为2
int ans = 0, n = nums.length;
HashMap<Long, Integer>[] dp = new HashMap[n];
for(int i = 0; i < n; i++) {
dp[i] = new HashMap<>();
}
for(int i = 0; i < n; i++) {
for(int j = 0; j < i; j++) {
// 首先计算nums[i] 和 nums[j] 之间的差值
long d = 1L * nums[i] - nums[j];
// 获得以nums[j]为结尾,差值为d的弱等差子序列的个数
int cnt = dp[j].getOrDefault(d, 0);
// 所有以nums[j]为结尾,差值为d的弱等差子序列加上nums[i]后长度至少为3,一定是符合题意的一个等差子序列
ans += cnt;
// 以nums[i]结尾,差值为d的弱等差子序列的个数应该加上两部分
// 一部分以nums[j]为结尾,差值为d的弱等差子序列的个数
// 另一部分是nums[j], nums[i]这两个元素构成的弱等差子序列
dp[i].put(d, dp[i].getOrDefault(d, 0) + cnt + 1);
}
}
return ans;
}
}
392. 判断子序列
难度简单561收藏分享切换为英文接收动态反馈
给定字符串 s 和 t ,判断 s 是否为 t 的子序列。
字符串的一个子序列是原始字符串删除一些(也可以不删除)字符而不改变剩余字符相对位置形成的新字符串。(例如,"ace"
是"abcde"
的一个子序列,而"aec"
不是)。
进阶:
如果有大量输入的 S,称作 S1, S2, ... , Sk 其中 k >= 10亿,你需要依次检查它们是否为 T 的子序列。在这种情况下,你会怎样改变代码?
致谢:
特别感谢 @pbrother 添加此问题并且创建所有测试用例。
示例 1:
输入:s = "abc", t = "ahbgdc"
输出:true
双指针
进阶挑战
匹配一串字符需要 O(n) ,n 为 t 的长度。如果有大量输入的 S,称作 S1 , S2 , ... , Sk 其中 k >= 10 亿,你需要依次检查它们是否为 T 的子序列,这时候处理每一个子串都需要扫描一遍 T 是很费时的。
在这种情况下,我们需要在匹配前对 T 做预处理,利用一个二维数组记录每个位置的下一个要匹配的字符的位置,这里的字符是'a' ~ 'z',所以这个数组的大小是 dp[n][26]
,n 为 T 的长度。那么每处理一个子串只需要扫描一遍 Si 即可,因为在数组的帮助下我们对 T 是“跳跃”扫描的。比如下面匹配 "ada" 的例子,只需要“跳跃”三次。
class Solution {
public boolean isSubsequence(String s, String t) {
// 预处理
t = " " + t; // 开头加一个空字符作为匹配入口
int n = t.length();
int[][] dp = new int[n][26]; // 记录每个位置的下一个ch的位置
for (char ch = 0; ch < 26; ch++) {
int p = -1;
for (int i = n - 1; i >= 0; i--) { // 从后往前记录dp
dp[i][ch] = p;
if (t.charAt(i) == ch + 'a') p = i;
}
}
// 匹配
int i = 0;
for (char ch : s.toCharArray()) { // 跳跃遍历
i = dp[i][ch - 'a'];
if (i == -1) return false;
}
return true;
}
}
补充
不加空字符也是可行的,可以从后往前匹配:
class Solution {
public boolean isSubsequence(String s, String t) {
// 预处理
int n = t.length();
int[][] dp = new int[n + 1][26]; // 记录每个位置的上一个ch的位置
for (char ch = 0; ch < 26; ch++) {
int p = -1;
for (int i = 0; i <= n; i++) { // 从前往后记录dp
dp[i][ch] = p;
if (i < t.length() && t.charAt(i) == ch + 'a') p = i;
}
}
// 匹配
int i = n;
for (int j = s.length() - 1; j >= 0; j--) { // 跳跃遍历
i = dp[i][s.charAt(j) - 'a'];
if (i == -1) return false;
}
return true;
}
}
二.连续子串:
674. 最长连续递增序列
难度简单222收藏分享切换为英文接收动态反馈
给定一个未经排序的整数数组,找到最长且 连续递增的子序列,并返回该序列的长度。
连续递增的子序列 可以由两个下标 l
和 r
(l < r
)确定,如果对于每个 l <= i < r
,都有 nums[i] < nums[i + 1]
,那么子序列 [nums[l], nums[l + 1], ..., nums[r - 1], nums[r]]
就是连续递增子序列。
示例 1:
输入:nums = [1,3,5,4,7]
输出:3
解释:最长连续递增序列是 [1,3,5], 长度为3。
尽管 [1,3,5,7] 也是升序的子序列, 但它不是连续的,因为 5 和 7 在原数组里被 4 隔开。
53. 最大子序和
给你一个整数数组 nums ,请你找出一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。
子数组 是数组中的一个连续部分。
示例 1:
输入:nums = [-2,1,-3,4,-1,2,1,-5,4]
输出:6
解释:连续子数组 [4,-1,2,1] 的和最大,为 6 。
动态规划
因此我们只需要求出每个位置的 f(i),然后返回 f 数组中的最大值即可。那么我们如何求 \(f(i)\) 呢?我们可以考虑 \(\textit{nums}[i]\) 单独成为一段还是加入 f(i-1) 对应的那一段,这取决于\(\textit{nums}[i] 和 f(i-1) + \textit{nums}\) 的大小,我们希望获得一个比较大的,于是可以写出这样的动态规划转移方程:
考虑到 f(i)) 只和 f(i-1) 相关只用一个变量 \(\textit{pre}\) 来维护对于当前 f(i) 的 f(i-1) 的值是多少
class Solution {
public int maxSubArray(int[] nums) {
int m=nums.length;
int[] dp=new int[m];
dp[0]=nums[0];
int max=nums[0];
for(int i=1;i<m;i++){
int x=nums[i];
dp[i]=Math.max(dp[i-1]+x,x);
max=Math.max(dp[i],max);
}
return max;
}
}
分治法
public class Solution {
public int maxSubArray(int[] nums) {
int len = nums.length;
if (len == 0) {
return 0;
}
return maxSubArraySum(nums, 0, len - 1);
}
private int maxCrossingSum(int[] nums, int left, int mid, int right) {
// 一定会包含 nums[mid] 这个元素
int sum = 0;
int leftSum = Integer.MIN_VALUE;
// 左半边包含 nums[mid] 元素,最多可以到什么地方
// 走到最边界,看看最值是什么
// 计算以 mid 结尾的最大的子数组的和
for (int i = mid; i >= left; i--) {
sum += nums[i];
if (sum > leftSum) {
leftSum = sum;
}
}
sum = 0;
int rightSum = Integer.MIN_VALUE;
// 右半边不包含 nums[mid] 元素,最多可以到什么地方
// 计算以 mid+1 开始的最大的子数组的和
for (int i = mid + 1; i <= right; i++) {
sum += nums[i];
if (sum > rightSum) {
rightSum = sum;
}
}
return leftSum + rightSum;
}
private int maxSubArraySum(int[] nums, int left, int right) {
if (left == right) {
return nums[left];
}
int mid = left + (right - left) / 2;
return max3(maxSubArraySum(nums, left, mid),
maxSubArraySum(nums, mid + 1, right),
maxCrossingSum(nums, left, mid, right));
}
private int max3(int num1, int num2, int num3) {
return Math.max(num1, Math.max(num2, num3));
}
}
up打印最大子序和序列
class Solution {
public int maxSubArray(int[] nums) {
int ans = nums[0];
// dp[i] = 以nums[i]结尾的子序列的最大和
int[] dp = new int[nums.length];
dp[0] = nums[0];
int maxStart = 0, maxLen = 1;// 记录最大连续子序列的起点和长度
int start =0, len = 1; // 记录连续子序列的起点和长度
for(int i=1; i<nums.length; ++i){
// dp[i] = Math.max(dp[i-1] + nums[i], nums[i]); // 是继续在后面添加,还是另起一个新序列
if(dp[i-1] + nums[i] > nums[i]){ // 后面接上当前元素
dp[i] = dp[i-1] + nums[i];
len++;
}else{ // 另起一个新序列
dp[i] = nums[i];
start = i;
len = 1;
}
if(dp[i] > ans){
maxStart = start;
maxLen = len;
ans = dp[i];
}
}
System.out.println(maxLen);
System.out.println(Arrays.toString(Arrays.copyOfRange(nums, maxStart, maxStart+maxLen)));
return ans;
}
}
918. 环形子数组的最大和
难度中等258收藏分享切换为英文接收动态反馈
给定一个由整数数组 A
表示的环形数组 C
,求 **C**
的非空子数组的最大可能和。
在此处,环形数组意味着数组的末端将会与开头相连呈环状。(形式上,当0 <= i < A.length
时 C[i] = A[i]
,且当 i >= 0
时 C[i+A.length] = C[i]
)
此外,子数组最多只能包含固定缓冲区 A
中的每个元素一次。(形式上,对于子数组 C[i], C[i+1], ..., C[j]
,不存在 i <= k1, k2 <= j
其中 k1 % A.length = k2 % A.length
)
示例 1:
输入:[1,-2,3,-2]
输出:3
解释:从子数组 [3] 得到最大和 3
思路
- 根据上图,本题可以分为两种情况讨论
- 无环:最大和子数组不包含首尾元素
- 有环:最大和子数组包含首尾元素
- 无环状态下,即为53. 最大子序和
- 有环状态下,要使得两端之和最大,必须让中间的子数组最小,即最后有环情况下的最大子数组和为:
sum(nums)-min_
class Solution {
public int maxSubarraySumCircular(int[] nums) {
if(nums.length==1) return nums[0];
int [] dp=new int[nums.length];
int sum=nums[0];
dp[0]=nums[0];
int max=dp[0];
for(int i=1;i<nums.length;i++){
sum=sum+nums[i];
dp[i]=Math.max(nums[i]+dp[i-1],nums[i]);
max=Math.max(max,dp[i]);
}
int min=nums[1];
dp[1]=nums[1];
for(int i=2;i<nums.length-1;i++){
dp[i]=Math.min(nums[i]+dp[i-1],nums[i]);
min=Math.min(min,dp[i]);
}
return Math.max(sum-min,max);
}
}
718. 最长重复子数组
难度中等581收藏分享切换为英文接收动态反馈
给两个整数数组 A
和 B
,返回两个数组中公共的、长度最长的子数组的长度。
示例:
输入:
A: [1,2,3,2,1]
B: [3,2,1,4,7]
输出:3
解释:
长度最长的公共子数组是 [3, 2, 1] 。
1.动态规划
public int findLength(int[] A, int[] B) {
int max = 0;
int[][] dp = new int[A.length + 1][B.length + 1];
for (int i = 1; i <= A.length; i++) {
for (int j = 1; j <= B.length; j++) {
if (A[i - 1] == B[j - 1])
dp[i][j] = dp[i - 1][j - 1] + 1;
else
dp[i][j] = 0;
max = Math.max(max, dp[i][j]);
}
}
return max;
}
2.滚动窗口
class Solution {
public int findLength(int[] A, int[] B) {
int n = A.length, m = B.length;
int ret = 0;
for (int i = 0; i < n; i++) {
int len = Math.min(m, n - i);
int maxlen = maxLength(A, B, i, 0, len);
ret = Math.max(ret, maxlen);
}
for (int i = 0; i < m; i++) {
int len = Math.min(n, m - i);
int maxlen = maxLength(A, B, 0, i, len);
ret = Math.max(ret, maxlen);
}
return ret;
}
public int maxLength(int[] A, int[] B, int addA, int addB, int len) {
int ret = 0, k = 0;
for (int i = 0; i < len; i++) {
if (A[addA + i] == B[addB + i]) {
k++;
} else {
k = 0;
}
ret = Math.max(ret, k);
}
return ret;
}
}
152. 乘积最大子数组
给你一个整数数组 nums ,请你找出数组中乘积最大的连续子数组(该子数组中至少包含一个数字),并返回该子数组所对应的乘积。
示例 1:
输入: [2,3,-2,4]
输出: 6
解释: 子数组 [2,3] 有最大乘积 6。
思路
这个问题很像「力扣」第 53 题:最大子序和,只不过当前这个问题求的是乘积的最大值;
「连续」这个概念很重要,可以参考第 53 题的状态设计,将状态设计为:以 nums[i]结尾的连续子数组的最大值;
类似状态设计的问题还有「力扣」第 300 题:最长上升子序列,「子数组」、「子序列」问题的状态设计的特点是:以 nums[i] 结尾,这是一个经验,可以简化讨论。
提示:以 nums[i] 结尾这件事情很重要,贯穿整个解题过程始终,请大家留意。
再翻译一下就是:「动态规划」通常不关心过程,只关心「阶段结果」,这个「阶段结果」就是我们设计的「状态」。什么算法关心过程呢?「回溯算法」,「回溯算法」需要记录过程,复杂度通常较高。
而将状态定义得更具体,通常来说对于一个问题的解决是满足「无后效性」的。这一点的叙述很理论化,不熟悉朋友可以通过多做相关的问题来理解「无后效性」这个概念。
无后效性是指如果在某个阶段上过程的状态已知,则从此阶段以后过程的发展变化仅与此阶段的状态有关,而与过程在此阶段以前的阶段所经历过的状态无关。利用动态规划方法求解多阶段决策过程问题,过程的状态必须具备无后效性。
这题是求数组中子区间的最大乘积,对于乘法,我们需要注意,负数乘以负数,会变成正数,所以解这题的时候我们需要维护两个变量,当前的最大值,以及最小值,最小值可能为负数,但没准下一步乘以一个负数,当前的最大值就变成最小值,而最小值则变成最大值了。
maxDP[i + 1] = max(maxDP[i] * A[i + 1], A[i + 1],minDP[i] * A[i + 1])
minDP[i + 1] = min(minDP[i] * A[i + 1], A[i + 1],maxDP[i] * A[i + 1])
dp[i + 1] = max(dp[i], maxDP[i + 1])
这里,我们还需要注意元素为0的情况,如果A[i]为0,那么maxDP和minDP都为0,
我们需要从A[i + 1]
重新开始。
class Solution {
public int maxProduct(int[] nums) {
int[] dp_min=new int [nums.length];
int[] dp_max=new int [nums.length];
int MAX=nums[0];
dp_max[0]=nums[0];
dp_min[0]=nums[0];
for(int i=1;i<nums.length;i++){
dp_max[i]=Math.max(Math.max(dp_min[i-1]*nums[i],dp_max[i-1]*nums[i]),nums[i]);
dp_min[i]=Math.min(Math.min(dp_min[i-1]*nums[i],dp_max[i-1]*nums[i]),nums[i]);
MAX=Math.max(MAX,dp_max[i]);
}
return MAX;
}
}
- 令imax为当前最大值,则当前最大值为$ imax = max(imax * nums[i], nums[i])$
- 由于存在负数,那么会导致最大的变最小的,最小的变最大的。因此还需要维护当前最小值imin,\(imin = min(imin * nums[i], nums[i])\)
- 当负数出现时则imax与imin进行交换再进行下一步计算
class Solution {
public int maxProduct(int[] nums) {
int imax=nums[0];
int max=nums[0];
int imin=nums[0];
int tem;
for(int i=1;i<nums.length;i++){
if(nums[i]<0) {
tem=imax;
imax=imin;
imin=tem;
}
imax=Math.max(imax*nums[i],nums[i]);
imin=Math.min(imin*nums[i],nums[i]);
max=Math.max(max,imax);
}
return max;
}
}
1567. 乘积为正数的最长子数组长度
难度中等94收藏分享切换为英文接收动态反馈
给你一个整数数组 nums
,请你求出乘积为正数的最长子数组的长度。
一个数组的子数组是由原数组中零个或者更多个连续数字组成的数组。
请你返回乘积为正数的最长子数组长度。
示例 1:
输入:nums = [1,-2,-3,4]
输出:4
解释:数组本身乘积就是正数,值为 24 。
动态规划
class Solution {
public int getMaxLen(int[] nums) {
int[] dp_min=new int[nums.length];
int[] dp_max=new int[nums.length];
if(nums[0]>0){
dp_max[0]=1;
}
if(nums[0]<0){
dp_min[0]=1;
}
int max=dp_max[0];
for(int i=1;i<nums.length;i++){
if(nums[i]>0){
dp_max[i]=dp_max[i-1]+1;
dp_min[i]=dp_min[i-1]>0?dp_min[i-1]+1:0;
}else if(nums[i]<0){
dp_max[i]=dp_min[i-1]>0?dp_min[i-1]+1:0;
dp_min[i]=dp_max[i-1]+1;
}else{
dp_max[i]=0;
dp_min[i]=0;
}
max=Math.max(max,dp_max[i]);
}
return max;
}
}
413. 等差数列划分
难度中等390收藏分享切换为英文接收动态反馈
如果一个数列 至少有三个元素 ,并且任意两个相邻元素之差相同,则称该数列为等差数列。
- 例如,
[1,3,5,7,9]
、[7,7,7,7]
和[3,-1,-5,-9]
都是等差数列。
给你一个整数数组 nums
,返回数组 nums
中所有为等差数组的 子数组 个数。
子数组 是数组中的一个连续序列。
示例 1:
输入:nums = [1,2,3,4]
输出:3
解释:nums 中有三个子等差数组:[1, 2, 3]、[2, 3, 4] 和 [1,2,3,4] 自身。
class Solution {
/**
* 一次遍历法
*/
public int numberOfArithmeticSlices(int[] nums) {
int length = nums.length;
if (length < 3){
return 0;
}
//前-后 方便后续遍历
int diff = nums[0] - nums[1];
//t = 当前子数组长度 - 2. 比如当前t=1表示子数组长度为3, 这时添加一个元素, t=2, 数组长度为4, 对应的新增的满足条件的数组数也是t个
// (这里很巧妙, 掌握这个规律后续这种增量题都可以这样做)
int t = 0;
int ans = 0;
for (int i = 2; i < length; i++) {
int curDiff = nums[i-1] - nums[i];
if (curDiff == diff){
ans += ++t;
} else {
diff = curDiff;
t = 0;
}
}
return ans;
}
/**
* 动态规划 + 剪枝
* 转态转移方程 j >= i+2
* j == i + 2 && i = 0 时 dp[i][j] = nums[j] - nums[j-1] == nums[j-1] - nums[j-2]
* j == i + 2 && i > 0 时 dp[i][j] = dp[i-1][j] || nums[j] - nums[j-1] == nums[j-1] - nums[j-2]
* j > i + 2 时 dp[i][j] = dp[i][j-1] && nums[j] - nums[j-1] == nums[j-1] - nums[j-2]
*/
public int numberOfArithmeticSlices2(int[] nums) {
int length = nums.length;
if (length < 3){
return 0;
}
boolean[][] dp = new boolean[length-2][length];
int ans = 0;
for (int i = 0; i < length-2; i++) {
for (int j = i + 2; j < length; j++) {
if (j == i + 2){
dp[i][j] = (i > 0 && dp[i - 1][j]) || (nums[j] - nums[j - 1] == nums[j - 1] - nums[j - 2]);
} else{
dp[i][j] = dp[i][j - 1] && (nums[j] - nums[j - 1] == nums[j - 1] - nums[j - 2]);
}
if (dp[i][j]){
ans++;
} else {
//剪枝
break;
}
}
}
return ans;
}
int ans = 0;
/**
* 暴力解法(回溯)
*/
public int numberOfArithmeticSlices1(int[] nums) {
int length = nums.length;
List<Integer> list = new ArrayList<>();
for (int i = 0; i < length - 2; i++) {
//添加第一个元素
list.add(nums[i]);
backtrack(nums, list, i + 1, nums.length);
list.remove(0);
}
return ans;
}
private void backtrack(int[] nums, List<Integer> list, int idx, int length) {
int size = list.size();
if (size >= 3){
ans++;
}
if (idx == length){
return;
}
//添加添加第二个元素(size == 1) 添加第三个及以上元素(nums[i] - list.get(size - 1) == list.get(size - 1) - list.get(size - 2))
if (size == 1 || nums[idx] - list.get(size - 1) == list.get(size - 1) - list.get(size - 2)){
list.add(nums[idx]);
backtrack(nums, list, idx + 1, length);
list.remove(size);
}
}
}
三、编辑距离
四、回文问题
回文子串是要连续的,回文子序列可不是连续的! 回文子串,回文子序列都是动态规划经典题目。
动规五部曲分析如下:
- 确定dp数组(dp table)以及下标的含义
dp[i] [j]:字符串s在[i, j]范围内最长的回文子序列的长度为dp[i] [j]。
- 确定递推公式
在判断回文子串的题目中,关键逻辑就是看s[i]与s[j]是否相同。
如果s[i]与s[j]相同,那么dp[i][j] = dp[i + 1][j - 1] + 2
;
- dp数组如何初始化
首先要考虑当i 和j 相同的情况,从递推公式:dp[i][j] = dp[i + 1][j - 1] + 2
; 可以看出 递推公式是计算不到 i 和j相同时候的情况。
所以需要手动初始化一下,当i与j相同,那么dp[i
][j]一定是等于1的,即:一个字符的回文子序列长度就是1。
其他情况dp[i][j]
初始为0就行,这样递推公式:dp[i][j] = max(dp[i + 1][j], dp[i][j - 1])
; 中dp[i][j]
才不会被初始值覆盖。
- 确定遍历顺序
- 举例推导dp数组
打家劫舍问题:
198. 打家劫舍
你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。
给定一个代表每个房屋存放金额的非负整数数组,计算你 不触动警报装置的情况下 ,一夜之内能够偷窃到的最高金额。
示例 1:
输入:[1,2,3,1]
输出:4
解释:偷窃 1 号房屋 (金额 = 1) ,然后偷窃 3 号房屋 (金额 = 3)。
偷窃到的最高金额 = 1 + 3 = 4 。
子问题
状态空间定义
DP方程
状态定义1:a[i] [2] 0到第i家能偷到的最大金额
a[i] [0]: 不偷这家
a[i] [1]: 偷这家
class Solution {
public int rob(int[] nums) {
if(nums.length==1) return nums[0];
int[][] a=new int[nums.length][2];
a[0][0]=0;
a[0][1]=nums[0];
for(int i=1;i<nums.length;i++){
a[i][0]=Math.max(a[i-1][0],a[i-1][1]);
a[i][1]=a[i-1][0]+nums[i];
}
return Math.max(a[nums.length-1][0],a[nums.length-1][1]);
}
}
状态定义2:a[i] :0....i天 且num[i] 必偷 a[i]= Max(a[i-1],a[i-2]+num[i])
class Solution {
public int rob(int[] nums) {
if(nums.length==1) return nums[0];
int[] a=new int[nums.length];
a[0]=nums[0];
a[1]=Math.max(nums[0],nums[1]);
for(int i=2;i<nums.length;i++){
a[i]=Math.max(a[i-1],a[i-2]+nums[i]);
}
return a[nums.length-1];
}
}
空间优化:滚动数组
class Solution {
public int rob(int[] nums) {
if(nums.length==1) return nums[0];
int max=0;
int pre1=0;
int pre2=0;
for(int i=0;i<nums.length;i++){
max=Math.max(pre1,pre2+nums[i]);
pre2 = pre1;
pre1 = max;
}
return max;
}
}
输出路径
假设给定每间房子的金额向量M = {1, 4, 1, 2, 5, 6, 3},可以计算出DP数组
DP = {1, 4, 4, 6, 9, 12, 12}.
倒序找下标,找到dp[i]-nums[i]第一次出现的值。
public int[] getIndexArray(int[] nums){
int[] dp = new int[nums.length];
int[] index = new int[nums.length];
int i = 0;
dp = rob(nums);
int ind = Arrays.binarySearch(dp, dp[nums.length - 1]);
index[i] = ind + 1;
while(dp[ind] > nums[ind]){
ind = Arrays.binarySearch(dp, dp[ind] - nums[ind]);
index[++i] = ind + 1;
}
return index;
}
213. 打家劫舍 II(首尾相连)
你是一个专业的小偷,计划偷窃沿街的房屋,每间房内都藏有一定的现金。这个地方所有的房屋都 围成一圈 ,这意味着第一个房屋和最后一个房屋是紧挨着的。同时,相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警
示例 1:
输入:nums = [2,3,2]
输出:3
解释:你不能先偷窃 1 号房屋(金额 = 2),然后偷窃 3 号房屋(金额 = 2), 因为他们是相邻的。
环状排列意味着第一个房子和最后一个房子中只能选择一个偷窃,因此可以把此环状排列房间问题约化为两个单排排列房间子问题:
在不偷窃第一个房子的情况下(即 nums[1:]),最大金额是 p_1
在不偷窃最后一个房子的情况下(即 nums[:n-1]),最大金额是 p_2
假设数组 \(\textit{nums}\) 的长度为 n。如果不偷窃最后一间房屋,则偷窃房屋的下标范围是 [0, n-2];如果不偷窃第一间房屋,则偷窃房屋的下标范围是 [1, n-1]。在确定偷窃房屋的下标范围之后,即可用第 198 题的方法解决。对于两段下标范围分别计算可以偷窃到的最高总金额,其中的最大值即为在 n 间房屋中可以偷窃到的最高总金额
。
class Solution {
public int rob(int[] nums) {
int n=nums.length;
if(n==1) return nums[0];
if(n==2) return Math.max(nums[0],nums[1]);
return Math.max(findMax(0,n-2,nums),findMax(1,n-1,nums)) ;
}
public int findMax(int start,int end,int [] nums){
int pre=nums[start];
int cur=Math.max(nums[start],nums[start+1]);
for(int i=start+2;i<=end;i++){
int tem=Math.max(cur,pre+nums[i]);
pre=cur;
cur=tem;
}
return cur;
}
// 1.
public int findMax(int start,int end,int [] nums){
int pre=0;
int cur=0;
for(int i=start;i<=end;i++){
int tem=Math.max(cur,pre+nums[i]);
pre=cur;
cur=tem;
}
return cur;
}
//2.
return Math.max(myRob(Arrays.copyOfRange(nums, 0, nums.length - 1)),
myRob(Arrays.copyOfRange(nums, 1, nums.length)));
private int myRob(int[] nums) {
int pre = 0, cur = 0, tmp;
for(int num : nums) {
tmp = cur;
cur = Math.max(pre + num, cur);
pre = tmp;
}
return cur;
}
//
}
337. 打家劫舍 III(树型动态规划)
如果两个直接相连的房子在同一天晚上被打劫,房屋将自动报警。
计算在不触动警报的情况下,小偷一晚能够盗取的最高金额。
示例 1:
输入: [3,2,3,null,3,null,1]
3
/ \
2 3
\ \
3 1
输出: 7
解释: 小偷一晚能够盗取的最高金额 = 3 + 3 + 1 = 7.
解法一、暴力递归 - 最优子结构
在解法一和解法二中,我们使用爷爷、两个孩子、4 个孙子来说明问题
首先来定义这个问题的状态
爷爷节点获取到最大的偷取的钱数呢
首先要明确相邻的节点不能偷,也就是爷爷选择偷,儿子就不能偷了,但是孙子可以偷
二叉树只有左右两个孩子,一个爷爷最多 2 个儿子,4 个孙子
根据以上条件,我们可以得出单个节点的钱该怎么算
4 个孙子偷的钱 + 爷爷的钱 VS 两个儿子偷的钱 哪个组合钱多,就当做当前节点能偷的最大钱数。这就是动态规划里面的最优子结构
由于是二叉树,这里可以选择计算所有子节点
public int rob(TreeNode root) {
if (root == null) return 0;
int money = root.val;
if (root.left != null) {
money += (rob(root.left.left) + rob(root.left.right));
}
if (root.right != null) {
money += (rob(root.right.left) + rob(root.right.right));
}
return Math.max(money, rob(root.left) + rob(root.right));
}
计算超时。计算孙子节点时也重复计算了儿子节点
解法二、记忆化 - 解决重复子问题
针对解法一种速度太慢的问题,经过分析其实现,我们发现爷爷在计算自己能偷多少钱的时候,同时计算了 4 个孙子能偷多少钱,也计算了 2 个儿子能偷多少钱。这样在儿子当爷爷时,就会产生重复计算一遍孙子节点。
于是乎我们发现了一个动态规划的关键优化点
重复子问题
我们这一步针对重复子问题进行优化,我们在做斐波那契数列时,使用的优化方案是记忆化,但是之前的问题都是使用数组解决的,把每次计算的结果都存起来,下次如果再来计算,就从缓存中取,不再计算了,这样就保证每个数字只计算一次。
由于二叉树不适合拿数组当缓存,我们这次使用哈希表来存储结果,TreeNode 当做 key,能偷的钱当做 value
解法一加上记忆化优化后代码如下:
public int rob(TreeNode root) {
HashMap<TreeNode, Integer> memo = new HashMap<>();
return robInternal(root, memo);
}
public int robInternal(TreeNode root, HashMap<TreeNode, Integer> memo) {
if (root == null) return 0;
if (memo.containsKey(root)) return memo.get(root);
int money = root.val;
if (root.left != null) {
money += (robInternal(root.left.left, memo) + robInternal(root.left.right, memo));
}
if (root.right != null) {
money += (robInternal(root.right.left, memo) + robInternal(root.right.right, memo));
}
int result = Math.max(money, robInternal(root.left, memo) + robInternal(root.right, memo));
memo.put(root, result);
return result;
}
解法三、终极解法
每个节点可选择偷或者不偷两种状态,根据题目意思,相连节点不能一起偷
当前节点选择偷时,那么两个孩子节点就不能选择偷了
当前节点选择不偷时,两个孩子节点只需要拿最多的钱出来就行(两个孩子节点偷不偷没关系)
我们使用一个大小为 2 的数组来表示 int[] res = new int[2]
0 代表不偷,1 代表偷
任何一个节点能偷到的最大钱的状态可以定义为
1当前节点选择不偷:当前节点能偷到的最大钱数 = 左孩子能偷到的钱 + 右孩子能偷到的钱
2当前节点选择偷:当前节点能偷到的最大钱数 = 左孩子选择自己不偷时能得到的钱 + 右孩子选择不偷时能得到的钱 + 当前节点的钱数
root[0] = Math.max(rob(root.left)[0], rob(root.left)[1]) + Math.max(rob(root.right)[0], rob(root.right)[1])
root[1] = rob(root.left)[0] + rob(root.right)[0] + root.val;
/**
* Definition for a binary tree node.
* public class TreeNode {
* int val;
* TreeNode left;
* TreeNode right;
* TreeNode() {}
* TreeNode(int val) { this.val = val; }
* TreeNode(int val, TreeNode left, TreeNode right) {
* this.val = val;
* this.left = left;
* this.right = right;
* }
* }
*/
class Solution {
//res[0]:抢此节点的最大值 res[1]:不抢此节点的最大值
public int rob(TreeNode root) {
int[] res=new int[2];
res= dp(root);
return Math.max(res[0],res[1]);
}
public int[] dp(TreeNode root){
if(root==null) return new int[]{0,0};
int[] left=dp(root.left);
int[] right=dp(root.right);
int choose= left[0] +right[0]+root.val;
//左边抢还是不抢,右边抢还是不抢
int choose_not = Math.max(left[1],left[0])+Math.max(right[1],right[0]);
return new int[]{choose_not,choose};
}
}
740. 删除并获得点数
难度中等487收藏分享切换为英文接收动态反馈
给你一个整数数组 nums
,你可以对它进行一些操作。
每次操作中,选择任意一个 nums[i]
,删除它并获得 nums[i]
的点数。之后,你必须删除 所有 等于 nums[i] - 1
和 nums[i] + 1
的元素。
开始你拥有 0
个点数。返回你能通过这些操作获得的最大点数。
示例 1:
输入:nums = [3,4,2]
输出:6
解释:
删除 4 获得 4 个点数,因此 3 也被删除。
之后,删除 2 获得 2 个点数。总共获得 6 个点数。
打家劫舍动态规划思路
首先,我们先明确一个概念,就是每个位置上的数字是可以在两种前结果之上进行选择的:
- 如果你不删除当前位置的数字,那么你得到就是前一个数字的位置的最优结果。
- 如果你觉得当前的位置数字i需要被删,那么你就会得到i - 2位置的那个最优结果加上当前位置的数字乘以个数。
以上两个结果,你每次取最大的,记录下来,然后答案就是最后那个数字了。
如果你看到现在有点迷糊,那么我们先把数字进行整理一下。
我们在原来的 nums 的基础上构造一个临时的数组 all,这个数组,以元素的值来做下标,下标对应的元素是原来的元素的个数。
nums = [2, 2, 3, 3, 3, 4]
构造后:
all=[0, 0, 2, 3, 1];
就是代表着 2 的个数有两个,3的个数有 3 个,4 的个数有 1 个。
其实这样就可以变成打家劫舍的问题了呗。
我们来看看,打家劫舍的最优子结构的公式
dp[i] = Math.max(dp[i - 1], dp[i - 2] + nums[i]);
再来看看现在对这个问题的最优子结构公式:
dp[i] = Math.max(dp[i - 1], dp[i - 2] + i * all[i]);
class Solution {
public int deleteAndEarn(int[] nums) {
int n=nums.length;
int max=0;
for(int num:nums){
max=Math.max(num,max);
}
int[] index=new int[max+1];
for(int num:nums){
index[num]++;
}
int dp[][]=new int[max+1][2];
for(int i=1;i<=max;i++){
dp[i][0]=Math.max(dp[i-1][0],dp[i-1][1]);
dp[i][1]=dp[i-1][0]+index[i]*i;
}
return Math.max(dp[max][0],dp[max][1]);
}
股票问题:
121. 买卖股票的最佳时机
股票问题一共有六道:买卖股票的最佳时机(1,2,3,4)、含冷冻期、含手续费。本题是第一道,属于入门题目。
- 买卖股票的最佳时机 暴力解法、动态规划(Java)
- 买卖股票的最佳时机 II 暴力搜索、贪心算法、动态规划(Java)
- 买卖股票的最佳时机 III 动态规划(Java)
- 买卖股票的最佳时机 IV 动态规划(「力扣」更新过用例,只有优化空间的版本可以 AC)
- 最佳买卖股票时机含冷冻期 动态规划(Java)
- 买卖股票的最佳时机含手续费 动态规划(Java)
-
用
n
表示股票价格数组的长度; -
用
i
表示第i
天(i
的取值范围是0
到n - 1
); -
用
k
表示允许的最大交易次数; -
用
T[i][k]
表示在第i
天结束时,最多进行k
次交易的情况下可以获得的最大收益一天对应
i = 0
,因此i = -1
表示没有股票交易
T[i][k][0]
表示在第 i
天结束时,最多进行 k
次交易且在进行操作后持有 0
份股票的情况下可以获得的最大收益
T[i][k][1]
表示在第 i
天结束时,最多进行 k
次交易且在进行操作后持有 1
份股票的情况下可以获得的最大收益
基准情况:
T[-1][k][0] = 0, T[-1][k][1] = -Infinity
T[i][0][0] = 0, T[i][0][1] = -Infinity
状态转移方程:
T[i][k][0] = max(T[i - 1][k][0], T[i - 1][k][1] + prices[i])
T[i][k][1] = max(T[i - 1][k][1], T[i - 1][k - 1][0] - prices[i])
解法一
我们只需要遍历价格数组一遍,记录历史最低点,然后在每一天考虑这么一个问题:如果我是在历史最低点买进的,那么我今天卖出能赚多少钱?当考虑完所有天数之时,我们就得到了最好的答案。
T[i][1][0] = max(T[i - 1][1][0], T[i - 1][1][1] + prices[i])
T[i][1][1] = max(T[i - 1][1][1], T[i - 1][0][0] - prices[i]) = max(T[i - 1][1][1], -prices[i])
第二个状态转移方程利用了 T[i][0][0] = 0
。
class Solution {
public int maxProfit(int[] prices) {
int maxprofit=0;
int min=Integer.MAX_VALUE;
for(int i=0;i<prices.length;i++){
min=Math.min(min,prices[i]);
maxprofit=Math.max(maxprofit,prices[i]-min);
}
return maxprofit;
}
}
解法2 动态规划
class Solution {
public int maxProfit(int[] prices) {
int[][] dp = new int[prices.length][2];
dp[0][0]=0;
dp[0][1]=-prices[0];
for(int i=1;i<prices.length;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[prices.length-1][0];
}
}
122. 买卖股票的最佳时机 II
k 为正无穷
如果 k 为正无穷,则 k 和 k - 1 可以看成是相同的,因此有 T[i - 1][k - 1][0] = T[i - 1][k][0] 和 T[i - 1][k - 1][1] = T[i - 1][k][1]。每天仍有两个未知变量:T[i][k][0] 和 T[i][k][1],其中 k 为正无穷,状态转移方程如下:
T[i][k][0] = max(T[i - 1][k][0], T[i - 1][k][1] + prices[i])
T[i][k][1] = max(T[i - 1][k][1], T[i - 1][k - 1][0] - prices[i]) = max(T[i - 1][k][1], T[i - 1][k][0] - prices[i])
第二个状态转移方程利用了 T[i - 1][k - 1][0] = T[i - 1][k][0]
。
class Solution {
public int maxProfit(int[] prices) {
int dp[][]=new int[prices.length][2];
dp[0][0]=0;
dp[0][1]=-prices[0];
for(int i=1;i<prices.length;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[prices.length-1][0];
}
}
123. 买卖股票的最佳时机 III
k =2
情况三和情况一相似,区别之处是,对于情况三,每天有四个未知变量
T[i - 1] [0] [0] —>T[i - 1] [1] [1]—>T[i - 1] [1] [0]—>T[i - 1] [2] [1]—>T[i - 1] [2] [0]
未交易—>买一次股票—>卖一次股票—>买两次股票—>卖两次股票
T[i][2][0] = max(T[i - 1][2][0], T[i - 1][2][1] + prices[i])
T[i][2][1] = max(T[i - 1][2][1], T[i - 1][1][0] - prices[i])
T[i][1][0] = max(T[i - 1][1][0], T[i - 1][1][1] + prices[i])
T[i][1][1] = max(T[i - 1][1][1], T[i - 1][0][0] - prices[i]) = max(T[i - 1][1][1], -prices[i])
class Solution {
public int maxProfit(int[] prices) {
if (prices == null || prices.length == 0) {
return 0;
}
int length = prices.length;
int[][][] dp = new int[length][3][2];
dp[0][1][0] = 0;
dp[0][1][1] = -prices[0];
dp[0][2][0] = 0;
dp[0][2][1] = -prices[0];
for (int i = 1; i < length; i++) {
dp[i][2][0] = Math.max(dp[i - 1][2][0], dp[i - 1][2][1] + prices[i]);
dp[i][2][1] = Math.max(dp[i - 1][2][1], dp[i - 1][1][0] - prices[i]);
dp[i][1][0] = Math.max(dp[i - 1][1][0], dp[i - 1][1][1] + prices[i]);
dp[i][1][1] = Math.max(dp[i - 1][1][1], dp[i - 1][0][0] - prices[i]);
}
return dp[length - 1][2][0];
}
}
188. 买卖股票的最佳时机 IV
情况四:k 为任意值
情况四是最通用的情况,对于每一天需要使用不同的 k 值更新所有的最大收益,对应持有 0 份股票或 1 份股票。如果 k 超过一个临界值,最大收益就不再取决于允许的最大交易次数,而是取决于股票价格数组的长度,因此可以进行优化。那么这个临界值是什么呢?
一个有收益的交易至少需要两天(在前一天买入,在后一天卖出,前提是买入价格低于卖出价格)。如果股票价格数组的长度为 n,则有收益的交易的数量最多为 n / 2(整数除法)。因此 k 的临界值是 n / 2。如果给定的 k 不小于临界值,即 k >= n / 2,则可以将 k 扩展为正无穷,此时问题等价于情况二。
根据状态转移方程,可以写出时间复杂度为 O(nk)和空间复杂度为 O(nk) 的解法。
如果给定的 k 不小于临界值,则使用 时间/2 为k
class Solution {
public int maxProfit(int k, int[] prices) {
if(k==0 || prices==null||prices.length==0) return 0;
int n = Math.min(k,prices.length/2);
int [][][] dp= new int[prices.length][n+1][2];
for(int i=0;i<=n;i++){
dp[0][i][1]=-prices[0];
dp[0][i][0]=0;
}
for(int i=1;i<prices.length;i++){
for(int j=n;j>0;j--){
dp[i][j][0]=Math.max(dp[i-1][j][0],dp[i-1][j][1]+prices[i]);
dp[i][j][1]=Math.max(dp[i-1][j][1],dp[i-1][j-1][0]-prices[i]);
}
}
return dp[prices.length-1][n][0];
}
}
309. 最佳买卖股票时机含冷冻期
有冷却时间,K无穷大
class Solution {
public int maxProfit(int[] prices) {
if(prices==null||prices.length==1) return 0;
int [][] dp=new int[prices.length][2];
dp[0][0]=0;
dp[0][1]=-prices[0];
dp[1][0]=Math.max(dp[0][0],dp[0][1]+prices[1]);
dp[1][1]=Math.max(-prices[0],-prices[1]);
for(int i=2;i<prices.length;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-2][0]-prices[i]);
}
return dp[prices.length-1][0];
}
}
714. 买卖股票的最佳时机含手续费
每次交易-fee
class Solution {
public int maxProfit(int[] prices, int fee) {
int [][] dp=new int[prices.length][2];
dp[0][0]=0;
dp[0][1]=-prices[0];
for(int i=1;i<prices.length;i++){
dp[i][0]=Math.max(dp[i-1][0],dp[i-1][1]+prices[i]-fee);
dp[i][1]=Math.max(dp[i-1][1],dp[i-1][0]-prices[i]);
}
return dp[prices.length-1][0];
}
}
背包问题
322. 零钱兑换
难度中等1617收藏分享切换为英文接收动态反馈
给你一个整数数组 coins
,表示不同面额的硬币;以及一个整数 amount
,表示总金额。
计算并返回可以凑成总金额所需的 最少的硬币个数 。如果没有任何一种硬币组合能组成总金额,返回 -1
。
你可以认为每种硬币的数量是无限的。
示例 1:
输入:coins = [1, 2, 5], amount = 11
输出:3
解释:11 = 5 + 5 + 1
动态规划
思路:分析最优子结构。根据示例 1:
输入: coins = [1, 2, 5], amount = 11
凑成面值为 1111 的最少硬币个数可以由以下三者的最小值得到:
- 凑成面值为 1010 的最少硬币个数 + 面值为
1
的这一枚硬币;
判断金额凑不出的小技巧:先初始化DP table各个元素为amount + 1(代表不可能存在的情况),在遍历时如果金额凑不出则不更新,于是若最后结果仍然是amount + 1,则表示金额凑不出
-
凑成面值为 10 的最少硬币个数 + 面值为
1
的这一枚硬币; -
凑成面值为 9 的最少硬币个数 + 面值为
2
的这一枚硬币; -
凑成面值为 6 的最少硬币个数 + 面值为
5
的这一枚硬币。
可以直接把问题的问法设计成状态。
- 第 1 步:定义「状态」。
dp[i]
:凑齐总价值i
需要的最少硬币个数; - 第 2 步:写出「状态转移方程」。根据对示例 1 的分析
dp[amount] = min(dp[amount], 1 + dp[amount - coins[i]]) for i in [0, len - 1] if coins[i] <= amount
import java.util.Arrays;
public class Solution {
public int coinChange(int[] coins, int amount) {
// 给 0 占位
int[] dp = new int[amount + 1];
// 注意:因为要比较的是最小值,这个不可能的值就得赋值成为一个最大值
Arrays.fill(dp, amount + 1);
// 理解 dp[0] = 0 的合理性,单独一枚硬币如果能够凑出面值,符合最优子结构
dp[0] = 0;
for (int i = 1; i <= amount; i++) {
for (int coin : coins) {
if (i - coin >= 0 && dp[i - coin] != amount + 1) {
dp[i] = Math.min(dp[i], 1 + dp[i - coin]);
}
}
}
if (dp[amount] == amount + 1) {
dp[amount] = -1;
}
return dp[amount];
}
}
518. 零钱兑换 II
难度中等674收藏分享切换为英文接收动态反馈
给你一个整数数组 coins
表示不同面额的硬币,另给一个整数 amount
表示总金额。
请你计算并返回可以凑成总金额的硬币组合数。如果任何硬币组合都无法凑出总金额,返回 0
。
假设每一种面额的硬币有无限个。
题目数据保证结果符合 32 位带符号整数。
示例 1:
输入:amount = 5, coins = [1, 2, 5]
输出:4
解释:有四种方式可以凑成总金额:
5=5
5=2+2+1
5=2+1+1+1
5=1+1+1+1+1
动态规划
解释一下为什么外层要对coins循环:
假设coins = {1, 2, 3},amount = 5。 凑出5的方案有三类:
- 组合必须以硬币1结尾,且不能包含硬币1之后的其他硬币2, 3。假设这类方案数量为x1。
- 组合必须以硬币2结尾,且不能包含硬币2之后的其他硬币3。假设这类方案数量为x2。
- 组合必须以硬币3结尾。假设这类方案数量为x3。
coins套在里面的话必然导致重复,因为题目说了不考虑选取元素的顺序,即112和211是同一个结果
class Solution {
public int change(int amount, int[] coins) {
int[] dp = new int[amount + 1];
dp[0] = 1;
for (int coin : coins) {
for (int i = coin; i <= amount; i++) {
dp[i] += dp[i - coin];
}
}
return dp[amount];
}
}
264. 丑数 II
难度中等791收藏分享切换为英文接收动态反馈
给你一个整数 n
,请你找出并返回第 n
个 丑数 。
丑数 就是只包含质因数 2
、3
和/或 5
的正整数。
示例 1:
输入:n = 10
输出:12
解释:[1, 2, 3, 4, 5, 6, 8, 9, 10, 12] 是由前 10 个丑数组成的序列。
1.优先队列
class Solution {
public int nthUglyNumber(int n) {
// int [] dp=new int [n];
int [] factors={2,3,5};
long cur=1L;
Set<Long> set=new HashSet<>();
Queue<Long> queue =new PriorityQueue<>();
queue.add(1L);
set.add(1L);
for(int i=0;i<n;i++){
cur = queue.poll();
for(int factor:factors){
long tem=factor*cur;
if(!set.contains(tem)){
set.add(tem);
queue.offer(tem);
}
}
}
return (int)cur;
}
}
2.动态规划 (三指针)
我们先模拟手写丑数的过程
1 打头,1 乘 2 1 乘 3 1 乘 5,现在是 {1,2,3,5}
轮到 2,2 乘 2 2 乘 3 2 乘 5,现在是 {1,2,3,4,5,6,10}
手写的过程和采用小顶堆的方法很像,但是怎么做到提前排序呢
小顶堆的方法是先存再排,dp 的方法则是先排再存
我们设 3 个指针 p_2,p_3,p_5
代表的是第几个数的2倍、第几个数 3 倍、第几个数 5 倍
动态方程 dp[i]=min(dp[p_2]*2,dp[p_3]*3,dp[p_5]*5)
小顶堆是一个元素出来然后存 3 个元素
动态规划则是标识 3 个元素,通过比较他们的 2 倍、3 倍、5 倍的大小,来一个一个存
class Solution {
public int nthUglyNumber(int n) {
int[] dp = new int[n + 1];
dp[1] = 1;
int p2 = 1, p3 = 1, p5 = 1;
for (int i = 2; i <= n; i++) {
int num2 = dp[p2] * 2, num3 = dp[p3] * 3, num5 = dp[p5] * 5;
dp[i] = Math.min(Math.min(num2, num3), num5);
if (dp[i] == num2) {
p2++;
}
if (dp[i] == num3) {
p3++;
}
if (dp[i] == num5) {
p5++;
}
}
return dp[n];
}
}
313. 超级丑数
难度中等288收藏分享切换为英文接收动态反馈
超级丑数 是一个正整数,并满足其所有质因数都出现在质数数组 primes
中。
给你一个整数 n
和一个整数数组 primes
,返回第 n
个 超级丑数 。
题目数据保证第 n
个 超级丑数 在 32-bit 带符号整数范围内。
示例 1:
输入:n = 12, primes = [2,7,13,19]
输出:32
解释:给定长度为 4 的质数数组 primes = [2,7,13,19],前 12 个超级丑数序列为:[1,2,4,7,8,13,14,16,19,26,28,32] 。
1.动态规划
public class Solution {
public int nthSuperUglyNumber(int n, int[] primes) {
int pLen = primes.length;
int[] indexes = new int[pLen];
int[] dp = new int[n];
dp[0] = 1;
for (int i = 1; i < n; i++) {
// 因为选最小值,先假设一个最大值
dp[i] = Integer.MAX_VALUE;
for (int j = 0; j < pLen; j++) {
dp[i] = Math.min(dp[i], dp[indexes[j]] * primes[j]);
}
// dp[i] 是之前的哪个丑数乘以对应的 primes[j] 选出来的,给它加 1
for (int j = 0; j < pLen; j++) {
if (dp[i] == dp[indexes[j]] * primes[j]) {
// 注意:这里不止执行一次,例如选出 14 的时候,indexes[j] 2(2*7) 和 7(7*2) 对应的最小丑数下标都要加 1,大家可以打印 indexes 和 dp 的值加以验证
indexes[j]++;
}
}
}
return dp[n - 1];
}
}
1314. 矩阵区域和
难度中等117收藏分享切换为英文接收动态反馈
给你一个 m x n
的矩阵 mat
和一个整数 k
,请你返回一个矩阵 answer
,其中每个 answer[i][j]
是所有满足下述条件的元素 mat[r][c]
的和:
i - k <= r <= i + k,
j - k <= c <= j + k
且(r, c)
在矩阵内。
示例 1:
输入:mat = [[1,2,3],[4,5,6],[7,8,9]], k = 1
输出:[[12,21,16],[27,45,33],[24,39,28]]
前缀和+动态规划
在求前缀和的时候,如果是第0行或者是每行的第一个格子,他们的上一行或者前一个格子是不存在的,直接取值0
当在求第一个图中红色区域的时候,如果红色区域超出了原本的数组的边界,应当取原本数组边界的位置
class Solution {
public int[][] matrixBlockSum(int[][] mat, int K) {
int row = mat.length;
int col = mat[0].length;
int[][] res = new int[row][col];
int[][] dp = new int[row + 1][col + 1];
for (int i = 1; i <= row; i++) {
for (int j = 1; j <= col; j++)
dp[i][j] = mat[i - 1][j - 1] + dp[i][j - 1] + dp[i - 1][j] - dp[i - 1][j - 1];
}
for (int i = 1; i <= row; i++) {
for (int j = 1; j <= col; j++) {
int x0 = Math.max(i - K - 1, 0);
int x1 = Math.min(i + K, row);
int y0 = Math.max(j - K - 1, 0);
int y1 = Math.min(j + K, col);
res[i - 1][j - 1] = dp[x1][y1] - dp[x1][y0] - dp[x0][y1] + dp[x0][y0];
}
}
return res;
}
}