DAY49:动态规划(十三)打家劫舍+打家劫舍Ⅱ+打家劫舍Ⅲ(树形DP)

198.打家劫舍(初始化注意)

  • 学会”考虑下标i以内的房屋“这种思想
  • 且本题考虑的是下标为i以内的房屋并不是考虑i个房屋!下标为i,意味着i=0的时候也有一座房子
  • 初始化的时候考虑了dp[0]和dp[1],最前面的if就要考虑dp数组没有dp[1]的情况

你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警

给定一个代表每个房屋存放金额的非负整数数组,计算你 不触动警报装置的情况下 ,一夜之内能够偷窃到的最高金额。

示例 1:

输入:[1,2,3,1]
输出:4
解释:偷窃 1 号房屋 (金额 = 1) ,然后偷窃 3 号房屋 (金额 = 3)。
     偷窃到的最高金额 = 1 + 3 = 4

示例 2:

输入:[2,7,9,3,1]
输出:12
解释:偷窃 1 号房屋 (金额 = 2), 偷窃 3 号房屋 (金额 = 9),接着偷窃 5 号房屋 (金额 = 1)。
     偷窃到的最高金额 = 2 + 9 + 1 = 12

提示:

1 <= nums.length <= 100
0 <= nums[i] <= 400

DP数组含义

首先,dp数组是我们求什么,dp数组含义就是什么,因此本题dp数组含义是考虑下标i以内的房屋能偷到的最大金额是dp[i]。(注意是考虑下标i以内房屋而不是就只偷下标i的房屋

递推公式

决定dp[i]的因素就是第i个房间偷还是不偷

  • 如果偷第i房间,那么dp[i] = dp[i - 2] + nums[i] ,即:第i-1房一定是不考虑的(会触发警报),找出 下标i-2(包括i-2)以内的房屋,最多可以偷窃的金额为dp[i-2] 加上第i房间偷到的钱。
  • 如果不偷第i房间,也就相当于只考虑i-1的房间,此时的最大价值就是dp[i-1]

最后dp[i]取最大值,也就是所求的最高金额,递推公式:

dp[i]=max(dp[i-1],dp[i-2]+nums[i]);

初始化

求最大值类型,初始化全部为0,dp[0]意思并不是考虑0个房屋,金额最大就是0,而是考虑下标为0的房屋,金额应该是nums[0]

初始化一般是考虑递推公式数组越界的问题,因此i=0和i=1都需要初始化。dp[0]=0,dp[1]就是偷窃第1个房屋的数值nums[1]。所以dp[1]=max(dp[0],dp[0]+nums[1])

遍历顺序

dp[i] 是根据dp[i - 2]dp[i - 1] 推导出来的,那么一定是从前到后遍历

完整版

  • 一定要注意初始化,并不是所有的初始化都是dp[0]=0,本题dp[i]的含义是考虑下标为i的房子下标0的房子也是有金额的!!因此dp[0]=nums[0]
class Solution {
public:
    int rob(vector<int>& nums) {
        if(nums.size()==0) return 0;
        if(nums.size()==1){
            return nums[0];
        }
        //dp[i]考虑下标i以内的房屋,偷到的最大价值
        vector<int>dp(nums.size()+1,0);
        //初始化
        //初始化问题,dp[0]的意思是考虑下标0的房屋,下标0的房屋就是第一间房屋!
        //dp[0]不能=0,而应该=nums[0]!
        dp[0]=nums[0];
        dp[1]=max(dp[0],nums[1]);
        //递推
        for(int i=2;i<nums.size();i++){
            dp[i]=max(dp[i-1],dp[i-2]+nums[i]);
        }
        //考虑所有房屋,偷到的最大价值
        return dp[nums.size()-1];

    }
};
  • 时间复杂度: O(n)
  • 空间复杂度: O(n)

213.打家劫舍Ⅱ

  • 本题注意DP数组定义成end-start+1的情况,定义成这种的话,DP数组的下标和nums数组的下标需要进行转换!

你是一个专业的小偷,计划偷窃沿街的房屋,每间房内都藏有一定的现金。这个地方所有的房屋都 围成一圈 ,这意味着第一个房屋和最后一个房屋是紧挨着的。同时,相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警

给定一个代表每个房屋存放金额的非负整数数组,计算你 在不触动警报装置的情况下 ,今晚能够偷窃到的最高金额。

示例 1:

输入:nums = [2,3,2]
输出:3
解释:你不能先偷窃 1 号房屋(金额 = 2),然后偷窃 3 号房屋(金额 = 2, 因为他们是相邻的。

示例 2:

输入:nums = [1,2,3,1]
输出:4
解释:你可以先偷窃 1 号房屋(金额 = 1),然后偷窃 3 号房屋(金额 = 3)。
     偷窃到的最高金额 = 1 + 3 = 4

示例 3:

输入:nums = [1,2,3]
输出:3

提示:

  • 1 <= nums.length <= 100
  • 0 <= nums[i] <= 1000

连成环状的数组思路

本题和 打家劫舍 很像,打家劫舍Ⅰ,是给了一个普通数组nums[i],相邻的房间不能偷。

而打家劫舍Ⅱ是把数组连成环,体现在数组里,就是数组第一个元素与最后一个元素相邻,如果选了第一个元素,就不能选最后一个元素

线性数组连成环状,最后一个元素和第一个元素相邻,首尾不能同时选择。实际上,首尾元素不能同时选,可以直接分为三种情况:

  • 首尾两元素只选首元素,范围是0--nums.size()-2
  • 首尾两元素只选尾元素,范围是1--nums.size()-1
  • (首尾元素都不选)

但是实际上,首尾元素都不选的情况,是被包含在只选首元素/只选尾部元素的情况里面的。如下图所示:

首尾元素只选首元素:

在这里插入图片描述
首尾元素都不选:

在这里插入图片描述
第一种情况将首元素和中间部分都考虑了,那么中间部分取得的最优值,实际上已经包含在第一种情况里面了!

因此我们只需要考虑不选首元素不选尾部元素的情况就可以。

由于我们只有这两种情况,其余情况都与打家劫舍Ⅰ相同,我们可以将打家劫舍Ⅰ的部分封装成函数,输入不同的数据范围

函数封装:

  • 写非环形数组的情况,函数输入开始和结束下标
//非环形数组的情况,输入开始和结束下标
int robRange(vector<int>&nums,int start,int end){
    vector<int>dp(nums.size()+1,0);
    //初始化
    dp[start]=nums[start];
    dp[start+1]=max(nums[start],nums[start+1]);//不能偷相邻的,因此直接是两个nums[]对比
    //为了防止start=0的情况需要从start+2开始
    for(int i=start+2;i<=end;i++){
        dp[i]=max(dp[i-1],dp[i-2]+nums[i]);
    }
    return dp[end];
}

主函数

  • 分为两种情况,一种是输入首位不输入末尾,一种是输入末尾不输入首位
  • 这两种情况直接取最大值即可
  • 初始化的时候考虑了dp[0]和dp[1],最前面的if就要考虑dp数组没有dp[1]的情况

最开始的写法

class Solution {
public:
    int rob(vector<int>& nums) {
        //处理没有dp[0]和dp[1]的特殊情况
        if(nums.size()==0) return 0;
        if(nums.size()==1) return nums[0];
        
        int result=0,result1=0,result2=0;
        result1 = robRange(nums,0,nums.size()-2);
        result2 = robRange(nums,1,nums.size()-1);
        result = max(result1,result2);
        return result;
    }
    int robRange(vector<int>&nums,int start,int end){
    	vector<int>dp(end-start+1,0);
    	//初始化
    	dp[start]=nums[start];
    	dp[start+1]=max(nums[start],nums[start+1]);//不能偷相邻的,因此直接是两个nums[]对比
    	//为了防止start=0的情况需要从start+2开始
    	for(int i=start+2;i<=end;i++){
        	dp[i]=max(dp[i-1],dp[i-2]+nums[i]);
    	}
    	return dp[end];
	}
};
debug测试:数组越界

在这里插入图片描述
这种写法,如果我们写成dp数组为end-start+1,那么如果原数组的下标范围i1~nums.size()-1,那么dp数组的长度就是num.size()-1-1+1=nums.size()-1,也就是说下标范围是**0~nums.size()-2**!

因此,在dp数组内部,初始化的时候可以直接用dp[0]和dp[1]。

初始化修改:

//dp数组的内部下标是0--nums.size()-2!
dp[0]=nums[start];
dp[1]=max(nums[start],nums[start+1]);

递推公式修改:

for(int i=2;i<=end-start;i++){//end是nums.size()-1,但是dp[i]最大是dp[nums.size()-2]!
    dp[i]=max(dp[i-1],dp[i-2]+nums[i+start]);
}
return dp[end-start];

写法1:dp数组定义为end-start+1

  • 如果这么定义,那么dp数组下标范围就是0~nums.size()-2
  • 代入1–nums.size()-1的例子试一下
class Solution {
public:
    int rob(vector<int>& nums) {
        //处理没有dp[0]和dp[1]的特殊情况
        if(nums.size()==0) return 0;
        if(nums.size()==1) return nums[0];
        
        int result=0,result1=0,result2=0;
        result1 = robRange(nums,0,nums.size()-2);
        result2 = robRange(nums,1,nums.size()-1);
        result = max(result1,result2);
        return result;
    }
    int robRange(vector<int>&nums,int start,int end){
        if(end==start) return nums[start];
        
    	vector<int>dp(end-start+1,0);
    	//初始化
    	dp[0]=nums[start];
    	dp[1]=max(nums[start],nums[start+1]);//不能偷相邻的,因此直接是两个nums[]对比
    	//为了防止start=0的情况需要从start+2开始
    	for(int i=2;i<=end-start;i++){
        	dp[i]=max(dp[i-1],dp[i-2]+nums[i+start]);
    	}
    	return dp[end-start];
	}
};

写法2:dp数组定义为nums.size()

  • 如果定义成nums.size(),那么和nums保持同一i的维度,不存在下标越界问题。
class Solution {
public:
    int rob(vector<int>& nums) {
        //处理没有dp[0]和dp[1]的特殊情况
        if(nums.size()==0) return 0;
        if(nums.size()==1) return nums[0];
        
        int result=0,result1=0,result2=0;
        result1=robRange(nums,0,nums.size()-2);
        result2 = robRange(nums,1,nums.size()-1);
        result = max(result1,result2);
        return result;
    }
    //非环形数组的情况,输入开始和结束下标
    int robRange(vector<int>&nums,int start,int end){
        if(start==end) return nums[start];
        vector<int>dp(nums.size()+1,0);
        //初始化
        dp[start]=nums[start];
        dp[start+1]=max(nums[start],nums[start+1]);//不能偷相邻的,因此直接是两个nums[]对比
        //为了防止start=0的情况需要从start+2开始
        for(int i=start+2;i<=end;i++){
            dp[i]=max(dp[i-1],dp[i-2]+nums[i]);
        }
        return dp[end];
    }
};

337.打家劫舍Ⅲ(树形DP)

  • 树形DP把DP数组的树形图打印出来,就更方便理解,可以直接看打印DP数组那里

小偷又发现了一个新的可行窃的地区。这个地区只有一个入口,我们称之为 root

除了 root 之外,每栋房子有且只有一个“父“房子与之相连。一番侦察之后,聪明的小偷意识到“这个地方的所有房屋的排列类似于一棵二叉树”。 如果 两个直接相连的房子在同一天晚上被打劫 ,房屋将自动报警。

给定二叉树的 root 。返回 在不触动警报的情况下 ,小偷能够盗取的最高金额

示例 1:

img

输入: root = [3,2,3,null,3,null,1]
输出: 7 
解释: 小偷一晚能够盗取的最高金额 3 + 3 + 1 = 7

示例 2:

img

输入: root = [3,4,5,1,3,null,1]
输出: 9
解释: 小偷一晚能够盗取的最高金额 4 + 5 = 9

提示:

  • 树的节点数在 [1, 10^4] 范围内
  • 0 <= Node.val <= 10^4

思路

本题从环形数组进阶成了二叉树,只要是线相邻在一起,就不能偷。

本题是树形DP的一种,一般的DP是在线性数组/环形数组中进行状态的转移,而树形DP在二叉树中进行状态的转移

本题的暴力解法是记忆化递归,也就是用memo数组来记忆已经遍历过的点,记忆化递归在 343.整数拆分 里面用到过。

DP数组的定义

这是一个二叉树的结构,每个节点也只有两个状态,就是偷和不偷。

我们可以用一个长度为2的一维DP数组来表示当前节点的状态下标为0表示不偷下标为1表示偷

遍历二叉树的过程,我们使用递归去遍历,系统栈里面会保存每一层递归的参数每一层递归(对应每个节点)里面,其实都有一个长度为2的DP数组

因此,当前层的DP数组,就表示当前节点的状态。不需要再去定义每个节点的DP数组。

对于每层的节点,dp[0]表示不偷当前节点所获得的最大金钱dp[1]表示偷当前节点所获得的最大金钱

递归参数与返回值判断

  • 返回值其实就是一维的,长度为2的dp数组
vector<int>robTree(TreeNode* root){
    //终止条件,遇到空节点,直接返回{0,0}数组,相当于初始化
    if(root==null) return vector<int>{0,0};
}

单层递归

  • 偷当前节点:
  • leftdp[0]代表dp数组下标为0的时候对应的数值,下标0代表不偷,leftdp[0]就是左节点不偷情况下(下标为0)最大金额
vector<int>robTree(TreeNode* root){
    //终止条件,遇到空节点,直接返回{0,0}数组,相当于初始化
    if(root==null) return vector<int>{0,0};
    
    //单层递归
    //如果偷当前节点,那么左右孩子都不偷得到的最大金额,left[0]是递归后序遍历得到的
    int value1 = root->val+leftdp[0]+rightdp[0];
}

因为需要得到左孩子不偷,也就是left[0](DP数组含义,left[0]是该状态(不偷)下的金额最大值)的数值,因此必须是后序遍历一层层将DP状态向上返回

  • 不偷节点:
int value2 = max(leftdp[0],leftdp[1])+max(rightdp[0],rightdp[1]);
  • 完整版
vector<int>robTree(TreeNode* root){
    //终止条件,遇到空节点,直接返回{0,0}数组,相当于初始化
    if(root==null) return vector<int>{0,0};
    
    //单层递归
    //左孩子dp数组dp[0]dp[1]取值
    vector<int>leftdp = robTree(root->left);
    vector<int>rightdp = robTree(root->right);//得到左孩子和右孩子,偷与不偷的最大值
    
    //如果偷当前节点,那么左右孩子都不偷得到的最大金额,left[0]是递归后序遍历得到的
    int value1 = root->val+leftdp[0]+rightdp[0];
    //如果不偷当前节点,左右孩子都可能偷,取它们分别的最大值相加
    int value2 = max(leftdp[0],leftdp[1])+max(rightdp[0],rightdp[1]);
    
    //返回值:返回这个节点的DP数组,偷是value1,不偷是value2
    return {value1,value2};
}

打印DP数组

以给出的二叉树为例,树形DP数组如下图所示:

需要注意:偷根节点的时候,左右两边都必须是不偷的状态;但是不偷根节点的话,左右两边可偷可不偷,直接找两边DP数组的最大值相加即可!

在这里插入图片描述

树形DP数组总结

树形DP的特点就是每一层递归都是一个节点每个节点对应一个DP数组!向上返回的是本层节点的DP数组

不同的状态DP数组的下标来表示,下标0表示偷对应的DP值,下标1表示不偷对应的DP值

可以上面打印的DP数组来理解。

完整版

class Solution {
public:
    vector<int>travelsal(TreeNode* root){
        //终止条件
        if(root==nullptr) return vector<int>{0,0};//相当于初始化
        
        //单层递归,左右中,后序遍历
        vector<int>leftdp = travelsal(root->left);
        vector<int>rightdp = travelsal(root->right);
        //中,递推部分
        //value1代表偷,意味着左右都不能偷
        int value1 = root->val+leftdp[1]+rightdp[1];
        //不偷,左右可偷可不偷,选最大值
        int value2 = max(leftdp[0],leftdp[1])+max(rightdp[0],rightdp[1]);
        
        return {value1,value2};
    }
    int rob(TreeNode* root) {
        vector<int>res = travelsal(root);
        //返回的是根节点的{value1,value2},需要再次判断根节点偷不偷
        return max(res[0],res[1]);
    }
};
  • 时间复杂度:O(n),每个节点只遍历了一次
  • 空间复杂度:O(log n),算上递推系统栈的空间

总结

本题属于树形DP,所谓树形DP就是在树上进行递归公式的推导每层递归对应一个节点,每个节点对应一个DP数组DP数组下标0和1代表当前节点的状态dp[0]和dp[1]是当前节点不同状态对应的DP值

本题就是二叉树在动态规划中的应用968.监控二叉树 这道题,是二叉树在贪心中的运用

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值