写在前面:以下为个人刷题的随笔小记,用于整理刷题时遇到的问题和思路,请大家理性食用~~
1、二叉树
1.1 基础知识
1.1.1 二叉树的高度和深度的区别:
- 二叉树节点的深度:指从根节点到该节点的最长简单路径边的条数。
- 二叉树节点的高度:指从该节点到叶子节点的最长简单路径边的条数
1.1.2 刷题技巧
求深度可以从上到下去查 所以需要前序遍历(中左右),而高度只能从下到上去查,所以只能后序遍历(左右中)
有的同学一定疑惑,为什么104.二叉树的最大深度 (opens new window)中求的是二叉树的最大深度,也用的是后序遍历。
那是因为代码的逻辑其实是求的根节点的高度,而根节点的高度就是这棵树的最大深度,所以才可以使用后序遍历。
在110. 平衡二叉树判断是否是平衡二叉树的题目中要求结点的高度,所以要使用后序遍历
1.2 模板代码
1.2.1 递归实现二叉树的前中后序遍历
class Solution1 {
public List<Integer> orderTraversal(TreeNode root) {
List<Integer> result = new ArrayList<>();
preorder(root, result);
return result;
}
//前序递归遍历二叉树
public void preorder(TreeNode root, List<Integer> result) {
if (root == null)//递归结束条件
return;
//前序遍历顺序:NLR(根左右)
result.add(root.val);
preorder(root.left, result);
preorder(root.right, result);
}
//中序遍历二叉树
public void inorder(TreeNode root, List<Integer> result) {
if (root == null)//递归结束条件
return;
//中序遍历顺序:LNR(左根右)
inorder(root.left, result);
result.add(root.val);
inorder(root.right, result);
}
//后序遍历二叉树
public void postorder(TreeNode root, List<Integer> result) {
if (root == null)//递归结束条件
return;
//后序遍历顺序:LRN(左右根)
postorder(root.left, result);
postorder(root.right, result);
result.add(root.val);
}
}
1.2.2 非递归实现二叉树的前中后序遍历
class Solution {
//迭代法实现前序遍历:借助栈实现,时间复杂度O(n),空间复杂度O(n)
public List<Integer> preorderTraversal(TreeNode root) {
List<Integer> result = new ArrayList<>();//存放遍历结果
Deque<TreeNode> stack = new ArrayDeque<>();//栈暂存结点信息
TreeNode p = root;//操作指针
while (p != null || !stack.isEmpty()) {
if (p != null) {//当前结点不为空,入栈,向左遍历
result.add(p.val);
stack.push(p);
p = p.left;
} else {//左节点为空,出栈,向右遍历
p = stack.pop();
p = p.right;
}
}
return result;
}
//迭代法实现中序遍历:借助栈实现,时间复杂度O(n),空间复杂度O(n)
public List<Integer> inorderTraversal(TreeNode root) {
List<Integer> result = new ArrayList<>();//存放遍历结果
Deque<TreeNode> stack = new ArrayDeque<>();//栈暂存结点信息
TreeNode p = root;//操作指针
while (p != null || !stack.isEmpty()) {
if (p != null) {//当前结点不为空,不入栈,向左遍历
stack.push(p);
p = p.left;
} else {//左节点为空,出栈,输出当前结点(加入到list中),向右遍历
p = stack.pop();
result.add(p.val);
p = p.right;
}
}
return result;
}
//迭代法实现后序遍历:借助栈实现,时间复杂度O(n),空间复杂度O(n)
public List<Integer> postorderTraversal(TreeNode root) {
List<Integer> result = new ArrayList<>();//存放遍历结果
Deque<TreeNode> stack = new ArrayDeque<>();//栈暂存结点信息
TreeNode p = root;//操作指针
TreeNode r = null;//r用于指向最近访问过的结点
while (p != null || !stack.isEmpty()) {
if (p != null) {//扫描p的所有左子树,并入栈
stack.push(p);
p = p.left;
} else {
p = stack.peek();//获取栈顶元素
if (p.right != null && p.right != r)
p = p.right;
else {
p = stack.pop();
result.add(p.val);
r = p;//r记录最近访问过的结点
p = null;//重置指针
}
}
}
return result;
}
}
1.2.3 BFS(层序遍历)
不记录null版本
public List<List<Integer>> levelOrder(TreeNode root) {
List<List<Integer>> result = new ArrayList<>();//存储遍历结果
Deque<TreeNode> queue = new LinkedList<>();//队列存储当前层的结点
if (root == null)
return result;
queue.offer(root);//将非空的头结点入队
int deep = 0;//树的高度
while (queue.size() != 0) {//队列不为空,说明仍有结点未遍历
int size = queue.size();//当前层的结点的个数
List<Integer> curLayer = new ArrayList<>();
for (int i = 0; i < size; i++) {//遍历当前层的结点
TreeNode p = queue.poll();
curLayer.add(p.val);
if (p.left != null)
queue.offer(p.left);
if (p.right != null)
queue.offer(p.right);
}
deep++;//用于记录树的高度
result.add(curLayer);
}
List<List<Integer>> result1=new ArrayList<>(result.size());
Collections.reverse(result);
return result;
}
记录null版本
public List<TreeNode> traverseBinaryTree(TreeNode root) {
Deque<TreeNode> queue = new LinkedList<>();
List<TreeNode> list = new ArrayList<>();
if (root == null)
return list;
queue.offer(root);
while (queue.size() > 0) {
int size = queue.size();
for (int i = 0; i < size; i++) {
TreeNode p = queue.poll();
list.add(p);
if (p != null) {//当前结点不为空,将其左右结点入队
queue.offer(p.left);
queue.offer(p.right);
}
}
}
return list;
}
1.2.4 DFS(深度遍历)(同二叉树先序遍历)
//递归法:
//DFS(从上往下求深度(
public int maxDepth2(TreeNode root) {
if (root == null)
return 0;
int leftHigh = deepTree(root.left, 1);//左子树的深度
int rightHigh = deepTree(root.right, 1);//右子树的深度
return Math.max(leftHigh, rightHigh);//比较左右子深度
}
public int deepTree(TreeNode p, int high) {
if (p == null)
return high;
int leftHigh = deepTree(p.left, high + 1);
int rightHigh = deepTree(p.right, high + 1);
return Math.max(leftHigh, rightHigh);
}
2、回溯算法
回溯函数本质就是递归函数,指的都是一个函数。
2.1 如何理解回溯法
回溯法解决的问题都可以抽象为树形结构,因为回溯法解决的都是在集合中递归查找子集,集合的大小就构成了树的宽度,递归的深度就构成了树的深度。递归就要有终止条件,所以必然是一棵高度有限的树
(N叉树)。
回溯函数终止条件伪代码如下:
if (终止条件) {
存放结果;
return;
}
- 回溯搜索的遍历过程
2.2 回溯算法模板框架:
void backtracking(参数) {
if (终止条件) {
存放结果;
return;
}
for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
处理节点;
backtracking(路径,选择列表); // 递归
回溯,撤销处理结果
}
}
2.3 递归三部曲
- 确定递归函数参数
- 确定终止条件
- 单层搜索过程
2.4 解题技巧
树枝去重:保证从根到叶子结点的路径都不重复
树层去重:保证同一层的结点不会重复选取,防止重复。
1.组合问题,什么时候需要startIndex?
startIndex来控制for循环的起始位置,保证树枝不重复,如果是要对一个集合来求组合的话,要求不能重复,就需要startIndex。
组合问题(*BackTrace_1题目对应*
):
给定两个整数 n 和 k,返回 1 ... n 中所有可能的 k 个数的组合。
示例: 输入: n = 4, k = 2 输出: [ [2,4], [3,4], [2,3], [1,2], [1,3], [1,4], ]
public void backtracking(int n,int k,int startIndex)
如果是多个集合取组合,各个集合之间相互不影响,那么就不用startIndex。
组合问题(*BackTrace_3题目对应*
):
给定一个仅包含数字 2-9 的字符串,返回所有它能表示的字母组合。
示例:
- 输入:"23"
- 输出:["ad", "ae", "af", "bd", "be", "bf", "cd", "ce", "cf"].
public void letterTrace(String digits, int num)
2.树的元素相同的判断条件
- 情况一:条件元素
可进行原地排序
后再进行操作,可用( i > startIndex && candidates[i] == candidates[i - 1] )进行树层是否重复使用的判断。
例题(*BackTrace_5题目对应*
):给你一个 无重复元素 的整数数组 candidates
和一个目标整数 target
,找出 candidates
中可以使数字和为目标数 target
的 所有 不同组合 ,并以列表形式返回。你可以按 任意顺序 返回这些组合。
candidates
中的 同一个 数字可以 无限制重复被选取 。如果至少一个数字的被选数量不同,则两种组合是不同的。
示例 1:
输入:candidates = [2,3,6,7], target = 7
输出:[[2,2,3],[7]]
解释:
2 和 3 可以形成一组候选,2 + 2 + 3 = 7 。注意 2 可以使用多次。
7 也是一个候选, 7 = 7 。
仅有这两种组合。
示例 2:
输入: candidates = [2,3,5], target = 8
输出: [[2,2,2,2],[2,3,3],[3,5]]
示例 3:
输入: candidates = [2], target = 1
输出: []
//目的:对“数层去重”
if ( i > startIndex && candidates[i] == candidates[i - 1] ) {//需要对数组提前排序!
//i>startIndex说明当前处理元素为第二个,而candidates[i] == candidates[i - 1]说明和前者元素相同,则continue
continue;
}
- 情况二:条件元素
不能进行原题排序操作
,可用哈希表来记录每层元素的使用情况,用(set.contains(key))或(map.getOrDefault( nums[i],0 ) >=1)来判断树层元素是否重复。因为是对每层的树层元素使用情况进行记录,所以要在每次层遍历时新创建一个set。
例题(*BackTrace_10题目对应*
):给你一个整数数组 nums
,找出并返回所有该数组中不同的递增子序列,递增子序列中 至少有两个元素 。你可以按 任意顺序 返回答案。
数组中可能含有重复元素,如出现两个整数相等,也可以视作递增序列的一种特殊情况。
示例 1:
输入:nums = [4,6,7,7]
输出:[[4,6],[4,6,7],[4,6,7,7],[4,7],[4,7,7],[6,7],[6,7,7],[7,7]]
示例 2:
输入:nums = [4,4,3,2,1]
Set<Integer> set = new HashSet<>();// 每层都创建一个set,记录当前树层元素是否重复使用
if (!path.isEmpty() && path.get(path.size() - 1) > nums[i] || set.contains(nums[i]))
// 剪枝:只有一个元素或不满足递增或取到重复元素则跳过当前遍历
continue;
set.add(nums[i]);// 记录这个元素在本层用过了,本层后面不能再用了
/*如1,2,3,1,1,4;第一次遍历到1时set就会记录,当再遍历到后面的1时,要直接跳过,
因为第一次的1的遍历情况会包含后面所有的1的情况,所以不存在漏选的情况*/
3.分割线处理到末尾的判断条件
例题(*BackTrace_6题目对应*
):给你一个字符串 s
,请你将 s
分割成一些子串,使每个子串都是 回文串 。返回 s
所有可能的分割方案。
示例 1:
输入:s = "aab"
输出:[["a","a","b"],["aa","b"]]
示例 2:
输入:s = "a"
输出:[["a"]]
if (startIndex == s.length()) {
}
3、贪心算法
思路:贪心的本质是选择每一阶段的局部最优,从而达到全局最优。
贪心算法一般分为如下四步:
-
- 将问题分解为若干个子问题
- 找出适合的贪心策略
-
- 求解每一个子问题的最优解
-
- 将局部最优解堆叠成全局最优解
题型分析及思路:
- 从两个角度来判断的题型
例题:
根据身高重建队列(GreedyAlogirthm_11)
假设有打乱顺序的一群人站成一个队列,数组 people 表示队列中一些人的属性(不一定按顺序)。每个 people[i] = [hi, ki] 表示第 i 个人的身高为 hi ,前面 正好 有 ki 个身高大于或等于 hi 的人。
请你重新构造并返回输入数组 people 所表示的队列。返回的队列应该格式化为数组 queue ,其中 queue[j] = [hj, kj] 是队列中第 j 个人的属性(queue[0] 是排在队列前面的人)。
示例1:
-
- 输入:people = [[7,0],[4,4],[7,1],[5,0],[6,1],[5,2]]
- 输出:[[5,0],[7,0],[5,2],[6,1],[4,4],[7,1]]
分发糖果(GreedyAlogirthm_9)
老师想给孩子们分发糖果,有 N 个孩子站成了一条直线,老师会根据每个孩子的表现,预先给他们评分。
你需要按照以下要求,帮助老师给这些孩子分发糖果:
-
- 每个孩子至少分配到 1 个糖果。
- 相邻的孩子中,评分高的孩子必须获得更多的糖果。
那么这样下来,老师至少需要准备多少颗糖果呢?
示例 1:
-
- 输入: [1,0,2]
- 输出: 5
- 解释: 你可以分别给这三个孩子分发 2、1、2 颗糖果。
示例 2:
-
- 输入: [1,2,2]
- 输出: 4
- 解释: 你可以分别给这三个孩子分发 1、2、1 颗糖果。第三个孩子只得到 1 颗糖果,这已满足上述两个条件。
对于上述这两种有多个限制条件的题目,不能同时判断,需要固定住一边后再判断另一边,不然会顾此失彼。
如对于根据身高重建队列(GreedyAlogirthm_11)这道题来说,限制条件是要判断相邻孩子的评分大小,依次来满足相邻的孩子中,评分高的孩子必须获得更多的糖果
这一条件,因此要同时考虑左右孩子的评分情况
对于根据身高重建队列(GreedyAlogirthm_11)这道题来说,要同时考虑身高的高度排序和站位的排序。
解法:
根据身高重建队列(GreedyAlogirthm_11):先按身高从大到小排序,再考虑站位次序。
//身高从大到小排(身高相同k小的站前面)
Arrays.sort(people, (a, b) -> {
//a[0]表示身高,a[1]表示位序
if (a[0] == b[0]) return a[1] - b[1];
//后-前:降序,前-后:升序
return b[0] - a[0];
});
分发糖果(GreedyAlogirthm_9):先确定右边评分大于左边的情况,再确定左孩子大于右孩子的情况(从后向前遍历)
// 从左往后,比较右孩子评分大于左孩子的情况
for (int i = 1; i < ratings.length; i++) {
if (ratings[i] > ratings[i - 1]) candyVec[i] = candyVec[i - 1] + 1;//右边孩子评分大,糖果数多一个
}
// 从右往左,比较左孩子评分大于右孩子的情况
for (int i = ratings.length - 2; i >= 0; i--) {
// candyVec[i]是右孩子大于左孩子的情况下取得的值
// candyVec[i+1]+1是左孩子大于右孩子的情况下取得的值
// Math.max(candyVec[i],candyVec[i+1]+1)同时比较左孩子和右孩子的糖果个数,确保同时大于两边的情况
if (ratings[i] > ratings[i + 1]) candyVec[i] = Math.max(candyVec[i], candyVec[i + 1] + 1);
}
- 区间重叠的问题
例题:
用最少数量的箭引爆气球(GreedyAlogirthm_12)
在二维空间中有许多球形的气球。对于每个气球,提供的输入是水平方向上,气球直径的开始和结束坐标。由于它是水平的,所以纵坐标并不重要,因此只要知道开始和结束的横坐标就足够了。开始坐标总是小于结束坐标。
一支弓箭可以沿着 x 轴从不同点完全垂直地射出。在坐标 x 处射出一支箭,若有一个气球的直径的开始和结束坐标为 xstart,xend, 且满足 xstart ≤ x ≤ xend,则该气球会被引爆。可以射出的弓箭的数量没有限制。 弓箭一旦被射出之后,可以无限地前进。我们想找到使得所有气球全部被引爆,所需的弓箭的最小数量。
给你一个数组 points ,其中 points [i] = [xstart,xend] ,返回引爆所有气球所必须射出的最小弓箭数。
示例 1:
-
- 输入:points = [[10,16],[2,8],[1,6],[7,12]]
- 输出:2
- 解释:对于该样例,x = 6 可以射爆 [2,8],[1,6] 两个气球,以及 x = 11 射爆另外两个气球
示例 2:
-
- 输入:points = [[1,2],[3,4],[5,6],[7,8]]
- 输出:4
示例 3:
-
- 输入:points = [[1,2],[2,3],[3,4],[4,5]]
- 输出:2
无重叠区间(GreedyAlogirthm_13)
给定一个区间的集合,找到需要移除区间的最小数量,使剩余区间互不重叠。
注意: 可以认为区间的终点总是大于它的起点。 区间 [1,2] 和 [2,3] 的边界相互“接触”,但没有相互重叠。
示例 1:
-
- 输入: [ [1,2], [2,3], [3,4], [1,3] ]
- 输出: 1
- 解释: 移除 [1,3] 后,剩下的区间没有重叠。
示例 2:
-
- 输入: [ [1,2], [1,2], [1,2] ]
- 输出: 2
- 解释: 你需要移除两个 [1,2] 来使剩下的区间没有重叠。
示例 3:
-
- 输入: [ [1,2], [2,3] ]
- 输出: 0
- 解释: 你不需要移除任何区间,因为它们已经是无重叠的了。
对于上面的两道题来说,都是要判断重叠区间的问题,重叠的情况是指第二个区间的左边界小于第一个区间的右边界,因此需要按照每个区间起点的大小升序排序,然后遍历每个区间,判断两个边界重叠
,然后再更新右边界的大小,继续比较。
//{1,2}, {2, 3}, {3, 4}, {4, 5}就是每个区间的范围
int[][] points = {{1,2}, {2, 3}, {3, 4}, {4, 5}};
//将区间按起点升序排序,若起点相同,则按终点升序排序
Arrays.sort(points, (p1, p2) -> {
//后-前:降序,前-后:升序
if(p1[0]==p2[0]) return p1[1]-p2[1];//等同于p1[1]>p2[1]?1:-1
else return p1[0]-p2[0];//等同于p1[0]>p2[0]?1:-1
});
if (points[i][0] > points[i - 1][1]) {//第二个区间的左边界大于第一个区间的右边界,说明不重叠
}else{//第二个区间的左边界小于第一个区间的右边界,重叠,则要更新第二个区间的右边界大小
points[i][1] = Math.min(points[i][1],[i-1][1]);//更新重叠气球最小右边界
}
4、动态规划
动态规划五部曲:
- 确定dp数组以及下标含义
- 确定递推公式
- dp数组初始化
- 确定遍历顺序
- 举例推导dp数组
简单动态规划:
动态规划解题思路适用于每一步的结果都与上一步的结果相挂钩,每一个状态一定是由上一个状态推到出来的,区分于贪心算法,贪心没有推导,而是通过局部最优来找到全局最优。
比较简单但可以使用动态规划的经典题目就是斐波那契数列和爬楼梯问题。
斐波那契数,通常用 F(n) 表示,形成的序列称为 斐波那契数列 。该数列由 0 和 1 开始,后面的每一项数字都是前面两项数字的和。也就是: F(0) = 0,F(1) = 1 F(n) = F(n - 1) + F(n - 2),其中 n > 1 给你n ,请计算 F(n) 。
示例 1:
- 输入:2
- 输出:1
- 解释:F(2) = F(1) + F(0) = 1 + 0 = 1
假设你正在爬楼梯。需要 n 阶你才能到达楼顶。
每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?
注意:给定 n 是一个正整数。
示例 1:
- 输入: 2
- 输出: 2
- 解释: 有两种方法可以爬到楼顶。
-
- 1 阶 + 1 阶
- 2 阶
给你一个整数数组 cost
,其中 cost[i]
是从楼梯第 i
个台阶向上爬需要支付的费用。一旦你支付此费用,即可选择向上爬一个或者两个台阶。
你可以选择从下标为 0
或下标为 1
的台阶开始爬楼梯。
请你计算并返回达到楼梯顶部的最低花费。
示例 1:
输入:cost = [10,15,20]
输出:15
解释:你将从下标为 1 的台阶开始。
- 支付 15 ,向上爬两个台阶,到达楼梯顶部。
总花费为 15 。
对于斐波那契数列,通过观察每一项的通项,可以看出从第二项开始,后面的每一项的结果都是从前一项的结果推导而来,因此可以使用动态规划,难点在于确定状态转移方程(题目已给出)及dp数组初始化和遍历方法,本题的状态转移方程是dp[i] = dp[i-1] + dp[i-2],dp[i]表示第i个斐波那契数是多少,dp[0] = 0,dp[1] = 1;
对于爬楼梯问题,乍一看可能无法看出当前结果与之前结果之间的关联,无法使用动态规划,但通过子问题拆分分析可以看出,若想爬到当前楼层,只有两种方法,一种是从上一层楼梯爬一层爬到当前层,另一种是从上两层楼梯一次性爬两层爬到当前层,此时可能会疑惑为什么从上两层爬到当前层不能分别各爬一层吗?那是因为分步爬的话会先爬到上一层,再爬一层才到当前层,这种情况包含在上一层爬一层到当前层当中,因此这种方法不算新的方法。因此dp[i] = dp[i-1] + dp[i-2],而dp[i]是指爬到第i层台阶的方法数,dp[0] = 0,dp[1] = 1,dp[2] = 2;
对于使用最小花费爬楼梯问题,容易得出爬到当前楼层也是有两个方向得到的,分别是dp[i-1]和dp[i-2],和爬楼梯的不同点在于爬楼梯问题只需计算爬到某一层台阶的所有方法总和,因此最终结果是由之前两种方法相加:dp[i] = dp[i-1] + dp[i-2],而使用最小花费爬楼梯问题加入了爬楼梯对应的费用,需要计算出最小的费用,则此时不能将两个方法相加,而是要比较两个方法的花费值,取最小的方法,即dp[i] = min{(dp[i-1] + cost[i-1]),(dp[i-1] + cost[i-2])},而dp[i]是指爬到第i层楼梯所需的最小费用,dp[0] = dp[1] = 0;
通过分析上面三道简单的动态规划题目,可以看出动态规划适用于每个状态之间相互影响的问题,难点在于确定状态转移方程的含义及表达式,并进行正确的初始化和确认遍历顺序。而对于要求所有方法之和的问题dp数组是进行相加的,而需要计算花费大小等进行择优的方法,则dp数组需要根据题意进行择优判断处理。
常规动态规划
之前的题目较简单容易想到使用动态规划或状态转移方程容易想到易初始化,而对于有些问题需要根据题意进行判断能否使用动态规划且如何定义状态转移方程及如何初始化。
一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为 “Start” )。
机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为 “Finish” )。
问总共有多少条不同的路径?
一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为“Start” )。
机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为“Finish”)。
现在考虑网格中有障碍物。那么从左上角到右下角将会有多少条不同的路径?
给定一个正整数 n,将其拆分为至少两个正整数的和,并使这些整数的乘积最大化。 返回你可以获得的最大乘积。
示例 1:
- 输入: 10
- 输出: 36
- 解释: 10 = 3 + 3 + 4, 3 × 3 × 4 = 36。
- 说明: 你可以假设 n 不小于 2 且不大于 58。
对于前两个查找路径的问题,刚拿到题目的思路可能是想如何直接算出到达终点的路径数,不会想到此题可以使用动态规划,且难以想到状态转移方程的定义及初始化。将问题细分,可以看出要达到终点需要先到达终点前的左边或上面,然后再向下或向右达到终点,因此有两种方法从终点前到达终点,同理其他位置也类似,那么可以定义状态转移方程dp[i][j]是指从起点到(i,j)的路径数,又因为只能向右或向下走,因此当前位置由两个方向得来,一个是dp[i-1][j],另一个是dp[i][j-1],而要计算的是所有的路径和,因此二者是相加的关系,则dp[i] = dp[i-1][j] + dp[i][j-1]。初始化可以通过分析发现最左边的一列和最上边的一行只有一种方法能到达,因此全部置为1,然后从上到下从左到右依次遍历dp数组即可。而第二个问题思路同第一个问题,只不过是在初始化及遍历dp数组时要判断是否有障碍物的情况,其他处理同题一。
对于整除拆分的题目,刚拿到题目也是很难想到要是用动态规划,通过逐步拆分,发现当前数字可以拆分成两个数字,这两个数字再进行拆分,因此每个状态之间相互依赖,可以使用动态规划,dp[i]是指拆分数字i,得到的最大乘积。dp[i] = max{j*(i-j),j*dp[i-j]},j * (i - j) 是单纯的把整数拆分为两个数相乘,而j * dp[i - j]是拆分成两个以及两个以上的个数相乘。由于需要求最大的乘积,因此需要取二者的最大值。dp[2] = 1。
01背包问题
01背包问题是典型的一类动态规划问题,可以使用暴力解法来遍历所有的组合情况,取最大值即可,但时间复杂度很高,因此进阶使用动态规划来实现。而标准的01背包问题是给定一定大小的背包容量,求能够放入背包容量的最大值。
01背包问题是一个基础的实现思路,其他很多问题都可以转化成01背包问题,重点在于dp数组的含义,比如对于分割子集问题,dp数组表示正好装满背包的最大重量,而对于最后一块石头的重量,dp数组表示最大装满背包的重量,目标和中的dp数组则表示和正好为j的组合个数,因此具体的dp数组含义需要具体问题具体分析。
有n件物品和一个最多能背重量为w 的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品只能用一次,求解将哪些物品装入背包里物品价值总和最大。
dp[i][j]表示从物品0~i中任取装入容量为j的背包中,价值总和最大值。dp[i][j]由两个方向得到,不放入物品或放入物品,分别是dp[i-1][j]和dp[i-1][j-weight[i]],dp[i-1][j]是指不放入物品i,而dp[i-1][j-weight[i]]指放入物品i,而j-weight[i]是指不放入物品i时背包的最大价值,dp[i-1][j-weight[i]]+value[i]即放入物品i后背包的最大价值。又因为要求背包的最大价值,可以选择放或不放,重点在于哪种情况下价值最大,即需要进行判断:dp[i][j] = max(dp[i-1][j],dp[i-1][j-weight[i]]+value[i])。初始化只需判断只放入物品0时背包容量从0到j的最大价值情况,并且dp[i][j]是由之前的状态所得到的,因此需要从前往后遍历,而先遍历背包还是先遍历物品没有要求,不过先遍历物品,后遍历背包容量比较好理解。
通过分析可以看出不放入物品时的背包价值和上一层的大小一致,放入物品时只需判断上一层不放入该物品时的最大背包价值即可,因此当前层只有上一层的情况有关,可以先记录上一层的情况,然后处理更新当前层的情况覆盖上一层的情况,于是可以将二维数组优化成一维数组。一维数组的dp[j]的含义是任取物品0~i放入背包容量为j的最大价值。
与二维dp数组不同的是,一维dp数组只能先遍历物品,后遍历背包容量,且需要从后往前遍历背包的容量,因为当前的状态与之前的状态有关,若从前往后遍历,之前的状态会先更新,无法保留之前的状态,因此需要从后往前遍历。优化后的状态转移方程:dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
给定一个只包含正整数的非空数组。是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。
注意: 每个数组中的元素不会超过 100 数组的大小不会超过 200
示例 1:
- 输入: [1, 5, 11, 5]
- 输出: true
- 解释: 数组可以分割成 [1, 5, 5] 和 [11].
通过分析此题可以得出,要将一个数组分割成两个子集,使得两个子集元素和相等,那么只需找出子集和为sum/2的集合即可,另一半的子集之和一定是sum/2,因此转换成查找哪些数字之和正好满足sum/2,又因为每个数字只能使用一次,因此可以转换成01背包问题,不同点在于物品的重量是数字的大小,物品的价值也是数字的大小,因此此题转化为查找容量为sum/2的背包,从所有数字中任取,判断其装入最大容量为sum/2的背包的最大价值是否恰好等于sum/2(因为价值也是数字本身的大小),即dp[sum/2] == sum/2?;
有一堆石头,每块石头的重量都是正整数。
每一回合,从中选出任意两块石头,然后将它们一起粉碎。假设石头的重量分别为 x 和 y,且 x <= y。那么粉碎的可能结果如下:
如果 x == y,那么两块石头都会被完全粉碎;
如果 x != y,那么重量为 x 的石头将会完全粉碎,而重量为 y 的石头新重量为 y-x。
最后,最多只会剩下一块石头。返回此石头最小的可能重量。如果没有石头剩下,就返回 0。
示例:
- 输入:[2,7,4,1,8,1]
- 输出:1
解释:
- 组合 2 和 4,得到 2,所以数组转化为 [2,7,1,8,1],
- 组合 7 和 8,得到 1,所以数组转化为 [2,1,1,1],
- 组合 2 和 1,得到 1,所以数组转化为 [1,1,1],
- 组合 1 和 1,得到 0,所以数组转化为 [1],这就是最优值。
通过分析此题可以得出,要得出碰撞后的最小重量,因此将石头分成重量尽可能相同的两堆石头,使其进行碰撞,得到的结果就是碰撞后最小的重量,最优情况是两堆石头重量可平分,则碰撞后最小重量为0,若不能完全平分,则求出平分之后石头的最大重量,另一堆石头的重量就是sum - target,使其相减即是最后的结果。dp[j]指容量为j的背包,能装入石头的最大重量。因此此题目标是求装入dp[sum/2]的最大价值,另一堆石头的最大价值就是sum - dp[sum/2],两者想减就是最后的结果:sum - dp[sum/2] - dp[sum/2];
完全背包问题
完全背包问题与01背包问题最大的区别是完全背包问题中所有的物品可重复选取,不限次数,而01背包问题物品只能选取一次,其他条件和目标都一样。而在01背包问题中一维dp数组代表的含义是:dp[j]表示从物品0~i中任取物品(不重复),放入容量为j的背包中的最大价值,为了确保物品只选取一次,必须先遍历物品,后遍历背包容量,且背包容量需从后往前遍历(防止之前的状态被覆盖,导致多次选取物品),而完全背包问题物品可以取无限次,故背包容量从前往后遍历,至于先遍历物品还是先遍历容量,对于完全背包问题来说没有区别,但是不同的顺序会match不同的问题。
完全背包问题分两大类,一类是组合问题,如零钱兑换问题,对于1,2和2,1的结果不进行区分,属于同一种答案,因此只需把1,2情况下考虑进来即可,故这种情况的遍历顺序必须为先遍历物品,后遍历背包。另一类是排列问题,如组合总和IV问题,对于1,2和2,1是不同的结果,需进行区分,故这种情况的遍历顺序必须为先遍历背包,后遍历物品。之所以对于排列问题要先遍历背包,后遍历物品,因为先遍历背包容量,后遍历物品,因为先遍历容量时,会把所有物品都遍历一遍,再次遍历容量会再次遍历所有物品,故会出现(2,1)的情况,也可以这么理解:先遍历物品的时候相当于是先把这个物品放进去了然后再看其他的能不能放进去,所以不会出现逆序,先遍历背包相当于是用每个大小的背包看看把每一个物品都放进去一次再看别的物品能不能放进去,所以可以有逆序。
当然以上完全背包问题的最优解为使用一维dp数组,但仍可以使用二维dp数组求解,但与01背包的二维dp数组的解法来说,完全背包的二维数组初始化方式不同,需要考虑是否可以重复放入物品0,同时dp状态转移方程为:dp[i][j] = dp[i - 1][j] + dp[i][j - coins[i]];
public int change(int amount, int[] coins) {
//初始化dp数组
int[][] dp = new int[coins.length][amount + 1];
for (int i = 0; i < amount + 1; i++) {
if (i >= coins[0] && i % coins[0] == 0)//i % coins[0] == 0是为了确保能够背包容量恰好能重复放入物品0
dp[0][i] = 1;
}
//根据dp[i][]j的含义可知,j为0时的组合方法均为1,即不放入任何硬币
for (int i = 0; i < coins.length; i++) {
dp[i][0] = 1;
}
//先遍历物品,后遍历背包容量
for (int i = 1; i < coins.length; i++) {
for (int j = 0; j < amount + 1; j++) {
if (j < coins[i])
dp[i][j] = dp[i - 1][j];
else//求组合数,即dp相加
dp[i][j] = dp[i - 1][j] + dp[i][j - coins[i]];
}
}
//dp[coins.length - 1][amount]即表示从0~coins.length - 1个硬币中任取任意数量的硬币,恰好组成金额为amount的组合数
return dp[coins.length - 1][amount];
}
同时对于完全背包问题,要分析好拆分步骤后,每一步由哪些状态得来的,找好对应题目的物品的含义和背包的含义,确定好状态转移方程。对于要求最值问题的,通常dp是求max或min,求组合方法数的题目(零钱兑换2、组合总和Ⅳ),dp[j] = dp[j]+ dp[j-weight[i]]。
背包递推公式
问能否能装满背包(或者最多装多少):dp[j] = max(dp[j], dp[j - nums[i]] + nums[i]); ,对应题目如下:
问装满背包有几种方法:dp[j] += dp[j - nums[i]] ,对应题目如下:
- 动态规划:494.目标和(opens new window)
- 动态规划:518. 零钱兑换 II(opens new window)
- 动态规划:377.组合总和Ⅳ(opens new window)
- 动态规划:70. 爬楼梯进阶版(完全背包)(opens new window)
问背包装满最大价值:dp[j] = max(dp[j], dp[j - weight[i]] + value[i]); ,对应题目如下:
问装满背包所有物品的最小个数:dp[j] = min(dp[j - coins[i]] + 1, dp[j]); ,对应题目如下:
股票问题
股票问题最核心的问题是根据条件来判断何时买入和卖出股票,以此来获得利润的最大值,如{1, 3, 2, 8, 4, 9},第一天买入最后一天卖出所获利润(只能买卖一次)为:9-1=8,而每一天的状态可分为四类:当天买入股票、当天卖出股票、已买入股票当天不买也不卖、已卖出股票不买也不卖。因此可以定义一个dp数组:dp[i][j],其中i表示天数,j为状态,易知i的取值范围为0~i-1(闭区间),j的取值范围为0~3(对应四种状态的每一个)。通过分析可以发现,当天买入股票和已买入股票当天不买也不卖这两个状态可以合并为当天持有股票的状态,其中包括当天买入和已买入的情况。同理当天卖出股票和已卖出股票不买也不卖这两个状态也可以合并为当天不持有股票的状态,其中包括当天卖出股票和已卖出股票的情况,至于某一天到底是选择当天买入卖出还是保持之前的状态,这取决于哪个方式所能获得利润最大,这样便可将状态从4种优化成2种。当然对于不同的股票问题状态有不同的定义方法,但是都是以持有还是不持有股票为基础,在此基础上进行延申和拓展。
#买卖股票的最佳时机
动态规划:121.买卖股票的最佳时机,股票只能买卖一次,问最大利润。如{1, 3, 2, 8, 4, 9},第一天买入最后一天卖出所获利润(只能买卖一次)为:9-1=8。
【暴力解法】
由于只能一次买入和一次卖出,因此可以计算出所有情况下买入卖出的最大利润,取出最大值即可。
public int maxProfit2(int[] prices) {
int maxProfit = 0;
for (int i = 0; i < prices.length; i++) {
for (int j = i + 1; j < prices.length; j++) {
//记录当前利润最大值
maxProfit = Math.max(maxProfit, (prices[j] - prices[i]));
}
}
return maxProfit;
}
- 时间复杂度:O(n^2)
- 空间复杂度:O(1)
【动态规划】
- dp[i][0] 表示第i天持有股票所获得的利润。
- dp[i][1] 表示第i天不持有股票所获得的利润。
如果第i天持有股票即dp[i][0], 那么可以由两个状态推出来
- 第i-1天就持有股票,那么就保持现状,所获得的利润就是昨天持有股票的所获得的利润 即:dp[i - 1][0]
- 第i天买入股票,所获得的利润就是买入今天的股票后所获得的利润即:-prices[i] 所以dp[i][0] = max(dp[i - 1][0], -prices[i]);
- 由于只能买卖一次股票,故当天买入股票的利润一定是第一次买股票的情况,之前均未买卖过股票,所获利润为0,则当天买入股票所获利润为0-prices[i]。
如果第i天不持有股票即dp[i][1], 也可以由两个状态推出来
- 第i-1天就不持有股票,那么就保持现状,所获得的利润就是昨天不持有股票所获得的利润 即:dp[i - 1][1]
- 第i天卖出股票,所获得的利润就是按照今天股票佳价格卖出后所获得的利润即:prices[i] + dp[i - 1][0] 所以dp[i][1] = max(dp[i - 1][1], prices[i] + dp[i - 1][0]);
public int maxProfit(int[] prices) {
//初始化dp数组
int[][] dp = new int[prices.length][2];
//dp[0][0]即第0天持有股票的利润,之前无法持有股票,故只能购入股票
dp[0][0] = -prices[0];
//dp[0][1]即第0天不持有股票的利润,而之前没有购入过股票,故为0
dp[0][1] = 0;
//遍历dp数组,从前往后遍历
for (int i = 1; i < prices.length; i++) {
dp[i][0] = Math.max(dp[i - 1][0], -prices[i]);
dp[i][1] = Math.max(dp[i - 1][1], dp[i - 1][0] + prices[i]);
}
//打印dp数组,验证是否满足预期
Arrays.stream(dp).forEach(array -> System.out.println(Arrays.toString(array)));
//dp[prices.length - 1][1]即最后一天不持有股票所获的最大利润
return dp[prices.length - 1][1];
}
- 时间复杂度:O(n)
- 空间复杂度:O(n*2)
#买卖股票的最佳时机II
动态规划:122.买卖股票的最佳时机II可以多次买卖股票,问最大收益。如{7,1,5,3,6,4},在第 1 天(从0开始计算)(股票价格=1) 的时候买入,第 2 天(股票价格=5) 的时候卖出,在第 3 天(股票价格=3)的时候买入,在第 4 天(股票价 =6)的时候卖出,所获利润为4 + 3 = 7.
【贪心解法】
由于股票可以在同一天买入,同一天卖出,没有次数限制,将全局最大利润拆分成局部最大利润,收割每一天的正利润累加,即买卖多次获得利益。
public int maxProfit(int[] prices) {
int balance = 0;
for (int i = 1; i < prices.length; i++) {
//收割每一天的正利润累加,即买卖多次获得利益
balance += Math.max(prices[i] - prices[i - 1], 0);
}
return balance;
}
- 时间复杂度:O(n^2)
- 空间复杂度:O(1)
【动态规划】
dp数组定义:
- dp[i][0] 表示第i天持有股票所获得的利润
- dp[i][1] 表示第i天不持有股票所获得的利润
如果第i天持有股票即dp[i][0], 那么可以由两个状态推出来
- 第i-1天就持有股票,那么就保持现状,所获得的利润就是昨天持有股票的所获得的利润 即:dp[i - 1][0]
- 第i天买入股票,所获得的利润就是昨天不持有股票所获得的利润减去 今天的股票价格 即:dp[i - 1][1] - prices[i]
注意这里和121. 买卖股票的最佳时机唯一不同的地方,就是推导dp[i][0]的时候,第i天买入股票的情况。在上一题中,因为股票全程只能买卖一次,所以如果买入股票,那么第i天持有股票即dp[i][0]一定就是 -prices[i]。而本题,因为一只股票可以买卖多次,所以当第i天买入股票的时候,所持有的现金可能有之前买卖过的利润。故当天买入股票所获的最大利润为:dp[i-1][1] - prices[i],其中dp[i-1][1]是前一天不持有股票时所获的利润(因为可多次买卖,在此次买股票前之前可能进行进行过买卖,有利益了已经)。
public int maxProfit2(int[] prices) {
//初始化dp数组
int[][] dp = new int[prices.length][2];
//dp[0][0]即第0天持有股票的利润,之前无法持有股票,故只能购入股票
dp[0][0] = -prices[0];
//dp[0][1]即第0天不持有股票的利润,而之前没有购入过股票,故为0
dp[0][1] = 0;
//遍历dp数组,从前往后遍历
for (int i = 1; i < prices.length; i++) {
//dp[i - 1][1] - prices[i]表示第i-1天不持有股票时所获的利润再减去第i天购买股票的钱,就是第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]);
}
//打印dp数组,验证是否满足预期
Arrays.stream(dp).forEach(array -> System.out.println(Arrays.toString(array)));
//dp[prices.length - 1][1]即最后一天不持有股票所获的最大利润
return dp[prices.length - 1][1];
}
- 时间复杂度:O(n)
- 空间复杂度:O(n*2)
#买卖股票的最佳时机III
动态规划:123.买卖股票的最佳时机III最多买卖两次,问最大收益。如{3,3,5,0,0,3,1,4},在第 3 天(从0开始计算)(股票价格=0)的时候买入,在第 5 天(股票价格 = 3)的时候卖出,在第 6 天(股票价格 = 1)的时候买入,在第 7 天 (股票价格 = 4)的时候卖出,所能获得的最大利润为(3-0)+(4-1)=6。
【动态规划】
本题与前两题的不同之处在于,第一题是只能买卖一次,状态可以分为持有股票和不持有股票(默认为第一次),而第二题虽然是可以无限次买卖,但是每次买卖之间互不影响,可将多次买卖拆分成多个单次买卖的情况,只不过要在买入之前考虑之前已经通过买卖所获的利润。而本题是最多买卖两次,可以买卖一次,也可以买卖两次,因此需要记录每次持有股票时是第几次持有股票,不持有股票时是第几次不持有股票,故需要四个状态来记录:0-第一次持有股票,1-第一次不持有股票,2-第二次持有股票,3-第二次不持有股票。至于为什么最大的利润是第二次不持有股票时所获得的利润,这是因为第二次不持有股票时所获利润是根据前面第二次持有股票和第一次不持有股票时所获的利润之间通过比较取最大值得来的,覆盖了之前的情况,故最大利润为dp[prices.length - 1][3]。
dp[i][j]中 i表示第i天,j为 [0 - 3] 四个状态,dp[i][j]表示第i天状态j所获得的利润。
达到dp[i][0]状态,有两个具体操作:
- 操作一:第i天买入股票了,那么所获的利润为前一天不持有股票时的利润-当天买股票所花的费用:dp[i][0] = dp[i-1][1] - prices[i]
- 操作二:第i天没有操作,而是沿用前一天买入的状态,即:dp[i][0] = dp[i - 1][0]
dp[i][0] = max(dp[i-1][0] - prices[i], dp[i - 1][0]);
同理dp[i][1]也有两个操作:
- 操作一:第i天卖出股票了,那么所获的利润为前一天持有股票时的利润+当天卖出股票的利润:dp[i][1] = dp[i - 1][0] + prices[i]
- 操作二:第i天没有操作,沿用前一天卖出股票的状态,即:dp[i][1] = dp[i - 1][1]
所以dp[i][1] = max(dp[i - 1][0] + prices[i], dp[i - 1][1])
同理可推出剩下状态部分:
dp[i][2] = max(dp[i - 1][2], dp[i - 1][1] - prices[i]);
dp[i][3] = max(dp[i - 1][3], dp[i - 1][2] + prices[i]);
public int maxProfit(int[] prices) {
//dp数组初始化
int[][] dp = new int[prices.length][4];
//dp[0][0]表示第0天第一次持有股票,即购买股票:dp[0][0] = -prices[0]
dp[0][0] = -prices[0];
//dp[0][1]表示第0天第一次不持有股票,而第一天不持有股票就是不购买股票:dp[0][1] = 0
dp[0][1] = 0;
//dp[0][2]表示第0天第二次持有股票,第二次持有股票是通过第一次买入和卖出股票后来得到的,相当于第0天买入股票后立即卖出,然后再买入一次股票,则dp[0][2] = -prices[0]
dp[0][2] = -prices[0];
//dp[0][3]表示第0天第二次不持有股票,相当于第一次买股票后卖出,然后第二次买股票后再卖出,此时的最大利润肯定为0,故dp[0][3] = 0
dp[0][3] = 0;
//从前往后遍历dp数组
for (int i = 1; i < prices.length; i++) {
//第一次持有股票的最大利润
dp[i][0] = Math.max(dp[i - 1][0], -prices[i]);
//第一次不持有股票的最大利润
dp[i][1] = Math.max(dp[i - 1][1], dp[i - 1][0] + prices[i]);
//第二次持有股票的最大利润
dp[i][2] = Math.max(dp[i - 1][2], dp[i - 1][1] - prices[i]);
//第二次不持有股票的最大利润
dp[i][3] = Math.max(dp[i - 1][3], dp[i - 1][2] + prices[i]);
}
//打印dp数组,验证是否符合预期
Arrays.stream(dp).forEach(array -> System.out.println(Arrays.toString(array)));
//dp[prices.length-1][3]即最后一天第二次不持有股票所获的最大利润,由于第二次不持有股票的最大利润覆盖了第二次持有股票的最大利润的情况,故返回第二次不持有股票的最大利润
return dp[prices.length - 1][3];
}
- 时间复杂度:O(n)
- 空间复杂度:O(n*4)
#买卖股票的最佳时机IV
动态规划:188.买卖股票的最佳时机IV最多买卖k笔交易,问最大收益。如k = 2, prices = [3,2,6,5,0,3],即最多买卖2次。
通过分析本题可知,此题和上一题#买卖股票的最佳时机III相似,只不过上一题是最多买卖2次,而本题至多买卖k次。对于至多买卖两次的情况下,会存在第一次持有和不持有、第二次持有和不持有共四种情况,那么对于至多买卖k次的情况下,会存在第一次持有和不持有、第二次持有和不持有、...... 、第k次持有和不持有的情况,故一共有0~2*k(闭区间)个状态。而将状态具体展开:0-表示第一次持有股票,1-表示第一次不持有股票,2-表示第二次持有股票,3-表示第二次不持有股票,4-表示第三次持有股票,5-表示第三次不持有股票,..... ,通过分析可以看出为偶数时表示持有股票的状态,为奇数时表示不持有股票的状态。因此而持有股票的操作均为max{之前就持有,之前不持有-今天买入股票},而不持有股票的操作均为max{之前就不持有,之前持有+今天卖出股票},故可根据奇偶情况来进行划分处理。
public int maxProfit(int k, int[] prices) {
//定义dp数组
int[][] dp = new int[prices.length][2 * k];//2 * k用于记录0~2*k-1个状态
//初始化dp数组
for (int i = 0; i < 2 * k; i++) {
if (i % 2 == 0)
dp[0][i] = -prices[0];
}
//遍历dp数组
for (int i = 1; i < prices.length; i++) {
for (int j = 0; j < 2 * k; j++) {
//状态为偶数,说明是持有股票的状态
if (j % 2 == 0) {
//当j为0时表示第一次持有股票的情况,这种情况下只能购买当前股票
if (j == 0)
dp[i][j] = Math.max(dp[i - 1][j], -prices[i]);
//当j不为0说明是第j次持有股票(非第一次),则其最大利润为只有不持有股票的最大利润-买入当前股票的价格
else
dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - 1] - prices[i]);
}
//状态为奇数,说明是不持有股票的状态
else {
dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - 1] + prices[i]);
}
}
}
//打印dp数组,验证是否满足预期
Arrays.stream(dp).forEach(array -> System.out.println(Arrays.toString(array)));
//dp[prices.length - 1][2 * k - 1]即最后一天第k次不持有股票所获的最大利润,由于第k次不持有股票的最大利润覆盖了第k次持有股票的最大利润的情况,故返回第k次不持有股票的最大利润
return dp[prices.length - 1][2 * k - 1];
}
- 时间复杂度:O(n)
- 空间复杂度:O(n*2k)
#最佳买卖股票时机含冷冻期
动态规划:309.最佳买卖股票时机含冷冻期可以多次买卖但每次卖出有冷冻期1天。如{1,2,3,0,2},对应交易状态为:[买入, 卖出, 冷冻期, 买入, 卖出]。
相对于动态规划:122.买卖股票的最佳时机II,本题加上了一个冷冻期。常规做法为根据冷冻期设置四个状态进行求解,但个人认为较为复杂,具体解法可根据链接进行查看。主要分析另一种简单的方法,通过分析可以发现本题最主要的区别在于卖出股票后会进入冷冻期,无法买入,那么在冷冻期间其所已经所获的利润是不变的,故只需在下次买入的时候根据前天的状态买入即可,则还是设置两个状态分别表示持有和不持有股票的状态,0-持有股票,1-不持有股票,那么dp[i][0] = max{dp[i-1][0],dp[i-2][1]-prices[i]},dp[i][1] = {dp[i-1][1],dp[i-1][0] + prices[i]},通过分析上面的状态转移方程可以发现,不同点在于当天买入股票时为:dp[i-2][1]-prices[i],对于今天持有,昨天不持有,前天不持有时即[卖出,冷冻期,买入]的情况一定成立,而对于以前持有的状态,那么昨天肯定也持有,故也成立,对于今天才持有,昨天不持有,前天持有(×不可能有这种情况,没有冷冻),故可以用dp[i-2][1]-prices[i]来表示当天买入股票的状态。
public int maxProfit(int[] prices) {
if (prices.length == 1)
return 0;
//初始化dp数组
int[][] dp = new int[prices.length][2];
dp[0][0] = -prices[0];
dp[0][1] = 0;
dp[1][0] = Math.max(-prices[0], -prices[1]);
dp[1][1] = Math.max(0, -prices[0] + prices[1]);
//遍历dp数组,从前往后遍历
for (int i = 2; i < prices.length; i++) {
//今天才持有,昨天不持有,前天不持有(√)
//今天才持有,昨天不持有,前天持有(×不可能有这种情况,没有冷冻)
//以前持有,昨天肯定持有(√)
dp[i][0] = Math.max(dp[i - 1][0], dp[i - 2][1] - prices[i]);
//今天才不持有,昨天持有(√)
//以前就不持有,昨天一定不持有(√)
dp[i][1] = Math.max(dp[i - 1][1], dp[i - 1][0] + prices[i]);
}
//dp[prices.length-1][1]即最后一天不持有股票所获的最大利润,不持有股票的最大利润覆盖了持有股票的最大利润的情况,故返回不持有股票的最大利润
return dp[prices.length - 1][1];
}
- 时间复杂度:O(n)
- 空间复杂度:O(n*2)
#买卖股票的最佳时机含手续费
动态规划:714.买卖股票的最佳时机含手续费可以多次买卖,但每次有手续费。如:{1, 3, 2, 8, 4, 9},free=2,总利润: ((8 - 1) - 2) + ((9 - 4) - 2) = 8。
相对于动态规划:122.买卖股票的最佳时机II,本题也属于可无限次买卖的情况,但不同的时每次卖出股票的时候需要额外的手续费,则只需要在计算卖出操作的时候减去手续费就可以了。其余代码和分析相同。
public int maxProfit(int[] prices, int fee) {
//定义并初始化dp数组
int[][] dp = new int[prices.length][2];
dp[0][0] = -prices[0];
dp[0][1] = 0;
//从前往后遍历dp数组
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] - fee);//dp[i - 1][0] + prices[i] - fee是指卖出股票的情况,还需要支付额外的手续费即减去free
}
//打印dp数组,验证是否满足预期
Arrays.stream(dp).forEach(array -> System.out.println(Arrays.toString(array)));
//dp[prices.length-1][1]即最后一天不持有股票所获的最大利润,不持有股票的最大利润覆盖了持有股票的最大利润的情况,故返回不持有股票的最大利润
return dp[prices.length - 1][1];
}
- 时间复杂度:O(n)
- 空间复杂度:O(n*2)