29. 打家劫舍
例题198:
你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。
给定一个代表每个房屋存放金额的非负整数数组,计算你 不触动警报装置的情况下 ,一夜之内能够偷窃到的最高金额。
也就是只能选择不相邻的偷盗。
如果让控制房屋,j从i开始跳?不一定是跳一个,可以间隔多个。
但是这样不好确定当前偷不偷。但是可以发现,当前这个偷不偷取决于前一个和前两个偷没有,也就是说有个依赖关系,而这个依赖关系就是动态规划的递推由来。
动态规划五部曲
- 确定数组和下标含义
dp[i]是到达第i家时已经偷取的最多金额。 - 确定递推公式
dp[i]是由前一个和前两个被偷与否决定的。
第一种情况:如果偷取的是前两个,也就是前一个不偷,那么第i个就可以偷。
那么dp[i]=dp[i-2]+nums[i];
第二种情况:如果偷取了前一个,那么第i个就不能偷。
这里有一个容易混淆的点:dp[i]=dp[i-1]是考虑偷i-1,而不是必须要偷i-1。
然后考虑dp[i]取最大值,所以dp[i]=Math.max(dp[i-1],dp[i-2]+nums[i]);
- 初始化
因为递推公式从2开始,所以dp[0]和dp[1]需要初始化。
dp[0]=0
dp[1]=Math.max(dp[0],dp[1]) - 确定遍历顺序
由于dp[i]是由dp[i-1]和dp[i-2]递推出来的,所以一定是前向遍历。
代码如下:
时间复杂度O(n),空间复杂度O(n)
class Solution {
public int rob(int[] nums) {
int[] dp=new int[nums.length+1];
dp[0]=0;
dp[1]=nums[0];
for(int i=2;i<=nums.length;i++){
dp[i]=Math.max(dp[i-1],dp[i-2]+nums[i-1]);
}
return dp[nums.length];
}
}
注意,i是第i家住所,但是在数组中是第i-1个位置
也可以用滚动数组优化动态规划的空间复杂度为3个空间。
public int rob(int[] nums) {
int len = nums.length;
if (len == 0) return 0;
else if (len == 1) return nums[0];
else if (len == 2) return Math.max(nums[0],nums[1]);
int[] result = new int[3]; //存放选择的结果
result[0] = nums[0];
result[1] = Math.max(nums[0],nums[1]);
for(int i=2;i<len;i++){
result[2] = Math.max(result[0]+nums[i],result[1]);
result[0] = result[1];
result[1] = result[2];
}
}
30. 打家劫舍||
例题213:
你是一个专业的小偷,计划偷窃沿街的房屋,每间房内都藏有一定的现金。这个地方所有的房屋都 围成一圈 ,这意味着第一个房屋和最后一个房屋是紧挨着的。同时,相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警 。
给定一个代表每个房屋存放金额的非负整数数组,计算你 在不触动警报装置的情况下 ,今晚能够偷窃到的最高金额。
这个题与上一题不一样的地方在于最后一个房屋与第一个相邻成环了。
成环之后考虑三种情况:
①不考虑首尾元素
②不考虑尾巴
③不考虑头元素
而情况②和③都包含了①,所以只考虑②③两种情况就可以。
成环之后还是难了一些的, 不少题解没有把“考虑房间”和“偷房间”说清楚。
这就导致大家会有这样的困惑:情况三怎么就包含了情况一了呢? 本文图中最后一间房不能偷啊,偷了一定不是最优结果。
所以我在本文重点强调了情况一二三是“考虑”的范围,而具体房间偷与不偷交给递推公式去抉择。
代码如下:
时间复杂度O(n),空间复杂度O(1)
class Solution {
public int rob(int[] nums) {
int res=0;
int len=nums.length;
if(len==1) return nums[0];
if(len==0) return 0;
res=Math.max(subNums(nums,0,len-1),subNums(nums,1,len));
return res;
}
public int subNums(int[] nums,int start,int end){
int x=0,y=0,z=0;
for(int i=start;i<end;i++){
z=Math.max(x+nums[i],y);
x=y;
y=z;
}
return z;
}
}
最重要的是把第一个和最后一个不能同时存在,抽象为两个子数组中找最大偷盗金额!
31. 打家劫舍|||
例题337:
小偷又发现了一个新的可行窃的地区。这个地区只有一个入口,我们称之为 root 。
除了 root 之外,每栋房子有且只有一个“父“房子与之相连。一番侦察之后,聪明的小偷意识到“这个地方的所有房屋的排列类似于一棵二叉树”。 如果 两个直接相连的房子在同一天晚上被打劫 ,房屋将自动报警。
给定二叉树的 root 。返回 在不触动警报的情况下 ,小偷能够盗取的最高金额 。
这个题邮电类似于监控那个题,父节点与孩子相连。
这道题目和 198.打家劫舍 ,213.打家劫舍II 也是如出一辙,只不过这个换成了树。
如果对树的遍历不够熟悉的话,那本题就有难度了。
对于树的话,首先就要想到遍历方式,前中后序(深度优先搜索)还是层序遍历(广度优先搜索)。
本题一定是要后序遍历,因为通过递归函数的返回值来做下一步计算。
与198.打家劫舍,213.打家劫舍II一样,关键是要讨论当前节点抢还是不抢。
如果抢了当前节点,两个孩子就不能动,如果没抢当前节点,就可以考虑抢左右孩子(注意这里说的是“考虑”)
如果考虑后序遍历暴力解法会超时,因为计算了root的四个孙子(左右孩子的孩子)为头节点的子树的情况,又计算了root的左右孩子为头结点的子树的情况,计算左右孩子的时候又把孙子计算了一遍。代码如下:
/**
* 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;
* }
* }
*/
//时间复杂度O(n^2),算上递推系统栈的空间。空间复杂度O(log n)算上递推系统栈的空间
class Solution {
public int rob(TreeNode root) {
if(root==null) return 0;
if(root.left==null && root.right==null) return root.val;
//偷父节点
int val1=root.val;
if(root.left!=null) val1+=rob(root.left.left)+rob(root.left.right);
if(root.right!=null) val1+=rob(root.right.left)+rob(root.right.right);
//不偷父节点
int val2=rob(root.left)+rob(root.right);
return Math.max(val1,val2);
}
}
记忆法优化
可以使用一个map把计算结果记录下来,这样如果计算过孙子了,那么计算孩子的时候可以复用孙子的结果。
/**
* 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 {
public int rob(TreeNode root) {
Map<TreeNode,Integer> hmap=new HashMap<>();
return rob1(root,hmap);
}
public int rob1(TreeNode root,Map<TreeNode,Integer> hmap){
if(root==null) return 0;
if(hmap.containsKey(root)) return hmap.get(root);
//偷父节点
int val1=root.val;
if(root.left!=null) val1+=rob1(root.left.left,hmap)+rob1(root.left.right,hmap);
if(root.right!=null) val1+=rob1(root.right.left,hmap)+rob1(root.right.right,hmap);
//不偷父节点
int val2=rob1(root.left,hmap)+rob1(root.right,hmap);
hmap.put(root,Math.max(val1,val2));
return Math.max(val1,val2);
}
}
32. 买卖股票的最佳时机
例题121:
给定一个数组 prices ,它的第 i 个元素 prices[i] 表示一支给定股票第 i 天的价格。
你只能选择 某一天 买入这只股票,并选择在 未来的某一个不同的日子 卖出该股票。设计一个算法来计算你所能获取的最大利润。
返回你可以从这笔交易中获取的最大利润。如果你不能获取任何利润,返回 0 。
这个题做过,应该是在贪心?
如果暴力解的话会超时。
贪心
局部最优推导出整体最优
股票只能买卖一次,所以肯定是先找到最小值买入,再找到最小值后面的最大值卖出。
时间复杂度O(n),空间复杂度O(1)
class Solution {
public int maxProfit(int[] prices) {
int res=0;
int mi=Integer.MAX_VALUE;
for(int i=0;i<prices.length;i++){
mi=Math.min(mi,prices[i]);//记录最低价格
res=Math.max(res,prices[i]-mi);//记录最高收益
}
return res;
}
}
动态规划
- 确定dp数组(dp table)以及下标的含义
dp[i][0] 表示第i天持有股票所得最多现金 ,这里可能有同学疑惑,本题中只能买卖一次,持有股票之后哪还有现金呢?
其实一开始现金是0,那么加入第i天买入股票现金就是 -prices[i], 这是一个负数。
dp[i][1] 表示第i天不持有股票所得最多现金
注意这里说的是“持有”,“持有”不代表就是当天“买入”!也有可能是昨天就买入了,今天保持持有的状态
很多同学把“持有”和“买入”没区分清楚。
在下面递推公式分析中,我会进一步讲解。
- 确定递推公式
如果第i天持有股票即dp[i][0], 那么可以由两个状态推出来
第i-1天就持有股票,那么就保持现状,所得现金就是昨天持有股票的所得现金 即:dp[i - 1][0]
第i天买入股票,所得现金就是买入今天的股票后所得现金即:-prices[i]
那么dp[i][0]应该选所得现金最大的,所以dp[i][0] = max(dp[i - 1][0], -prices[i]);
如果第i天不持有股票即dp[i][1], 也可以由两个状态推出来
第i-1天就不持有股票,那么就保持现状,所得现金就是昨天不持有股票的所得现金 即:dp[i - 1][1]
第i天卖出股票,所得现金就是按照今天股票价格卖出后所得现金即:prices[i] + dp[i - 1][0]
同样dp[i][1]取最大的,dp[i][1] = max(dp[i - 1][1], prices[i] + dp[i - 1][0]);
这样递推公式我们就分析完了
3.dp数组如何初始化
由递推公式 dp[i][0] = max(dp[i - 1][0], -prices[i]); 和 dp[i][1] = max(dp[i - 1][1], prices[i] + dp[i - 1][0]);可以看出
其基础都是要从dp[0][0]和dp[0][1]推导出来。
那么dp[0][0]表示第0天持有股票,此时的持有股票就一定是买入股票了,因为不可能有前一天推出来,所以dp[0][0] -= prices[0];
dp[0][1]表示第0天不持有股票,不持有股票那么现金就是0,所以dp[0][1] = 0;
4.确定遍历顺序
从递推公式可以看出dp[i]都是由dp[i - 1]推导出来的,那么一定是从前向后遍历。
class Solution {
public int maxProfit(int[] prices) {
if (prices == null || prices.length == 0) return 0;
int length = prices.length;
// dp[i][0]代表第i天持有股票的最大收益
// dp[i][1]代表第i天不持有股票的最大收益
int[][] dp = new int[length][2];
int result = 0;
dp[0][0] = -prices[0];
dp[0][1] = 0;
for (int i = 1; i < length; i++) {
dp[i][0] = Math.max(dp[i - 1][0], -prices[i]);
dp[i][1] = Math.max(dp[i - 1][0] + prices[i], dp[i - 1][1]);
}
return dp[length - 1][1];
}
}
33. 第7节总结
-
打家劫舍:只考虑两个相邻的不能一起偷
所以dp[i]就由dp[i-1](偷了上一个不偷这个)和dp[i-2]+nums[i](偷了前两个,前一个不偷,再偷这个)这两个前面节点偷的状态决定。 -
打家劫舍||:头尾相连
巧妙解法:把数组分为两个子数组,分别包含头和尾,找这两个子数组的最大偷盗金额。 -
打家劫舍|||:数组变成二叉树了,所以要遍历二叉树。并且父节点和孩子不能同时偷。
首先判断要后序遍历,然后分两种情况,偷父节点与不偷父节点,分别加金额。 -
股票买卖的最佳时间:所有天数里只能选择一天买入,一天卖出。
如果暴力的话会超时,贪心找到当前最小值,遍历更新最大价值。
动规需要两个状态来判断当前dp[i][0]和dp[i][1]