Studying-代码随想录训练营day40| 198.打家劫舍、213.打家劫舍II、337.打家劫舍III

第40天,动态规划part07,动态规划经典题型“打家劫舍”(ง •_•)ง,编程语言:C++

目录

198.打家劫舍 

213.打家劫舍II 

337.打家劫舍III 

总结 


198.打家劫舍 

文档讲解:代码随想录打家劫舍

视频讲解:手撕打家劫舍

题目: 198. 打家劫舍 - 力扣(LeetCode)

学习:本题根据题意,对于一个房间来说,我们有偷和不偷两种选择。我们在选择每一间房间偷还是不偷的时候,都是为了得到最大的金钱数。我们从动规五部曲出发进行分析。

1.确定dp数组以及下标的含义:我们可以设置一个一维dp数组,dp[i]表示从0-i个房间,我们能够偷到的最大值。

2.确定递推公式:dp[i]能够由谁得来呢?这取决于我们对i房间是偷还是不偷。如果我们偷i房间,则dp[i]应该是dp[i - 2]+nums[i],意味着隔一个房间不偷加上本房间的财富的价值。如果我们不偷i房间,则dp[i]应该是dp[i - 1],也即不偷这个房间,我们能偷的最大价值就应该是上一个房间能够偷的最大价值。最后我们可以得到递推公式为dp[i] = max(dp[i - 1], dp[i - 2] + nums[i])。

3.初始化dp数组:由递推公式我们可知需要初始化至少两个数,dp[0]和dp[1]。显然dp[0]表示0号房间的价值,dp[0] = nums[0]。dp[1]则不能直接赋为nums[1]因为对于dp[1]来说也可以选择取或者不取nums[1],因此dp[1]应该是max(nums[0], nums[1])。

4.确定遍历顺序,从i = 2开始遍历到最后一个节点。

5.举例dp数组

代码:

//时间复杂度O(n)
//空间复杂度O(n)
class Solution {
public:
    int rob(vector<int>& nums) {
        if(nums.size() == 1) return nums[0];
        //1.确定dp数组以及下标的含义, dp[j]表示偷窃0-i个房间能够得到的最高金额
        vector<int> dp(nums.size(), 0);
        //2.确定递推公式:dp[i] = max(dp[i-1],dp[i - 2] + nums[j]);
        //3.初始化dp数组:
        dp[0] = nums[0];
        dp[1] = max(nums[0], nums[1]);
        //4.确定遍历顺序
        for(int i = 2; i < nums.size(); i++) {
            dp[i] = max(dp[i-1], dp[i - 2] + nums[i]);
        }
        return dp[nums.size() - 1];
    }
};

213.打家劫舍II 

文档讲解:代码随想录打家劫舍II

视频讲解:手撕打家劫舍II

题目:213. 打家劫舍 II - 力扣(LeetCode)

学习:本题与上一题不同之处在于,本题是一个环形数组,头部和尾部是连在一起的,这会导致第一个房间和最后一个房间不能同时取用。

但换言之我们可以分开讨论这两种情况。我们可以先考虑不包含尾元素的情况,也就是仅考虑[0, nums.size() - 2]的这个区间,对于这个区间来说,我们要求得能够得到的最大金额,就和上一题是一样的了,采用的方法也相同。

同理我们可以再考虑不包含头元素的情况,也就是仅考虑[1, nums.size() - 1]这个区间,对于这个区间来说,同样得到最大金额的办法和上一题一样。最后我们再取这两个方法得到的最大值之中的更大的那个即可。

接着我们来考虑,这样会不会导致出现遗漏呢,对于第一种不考虑尾元素的情况,假如得到的最大值的取用中取用了头元素,那即使是考虑了尾元素,我们也不能取;同理对于第二种不考虑头元素的情况,假如得到的最大值的取用中取用了尾元素,那即使考虑了头元素,我们也还是不能取;而第三种情况假如头元素和尾元素都没有取用,那就意味着[1, nums.size() - 2]中我们能够得到最大值,即使加上头元素和尾元素,也无法得到更大的值,因为第一种情况和第二种情况,已经把这种可能性包括了。

因此,我们可以分两次得到两种可能性的值,最后得到最大的。

代码:要十分注意起始遍历点和终止遍历点的位置。

//时间复杂度O(n) 2n
//空间复杂度O(n) 
class Solution {
public:
    int robrange(vector<int>&nums, int start, int end) {
        if(start == end) { //nums.size()为2的情况
            return nums[start];
        }
        //1.确定dp数组以及下标含义
        vector<int> dp(nums.size()); 
        //2.确定递推公式:dp[i] = max(dp[i-1],dp[i - 2] + nums[i]);
        //3.初始化dp数组
        dp[start] = nums[start];
        dp[start + 1] = max(nums[start], nums[start + 1]);
        //4.确定遍历顺序
        for(int i = start + 2; i <= end; i++) {
            dp[i] = max(dp[i-1],dp[i - 2] + nums[i]);
        }
        return dp[end];
    }
    int rob(vector<int>& nums) {
        if(nums.size() == 0) return 0;
        if(nums.size() == 1) return nums[0];
        int result1 = robrange(nums, 0, nums.size() - 2); //不考虑尾元素
        int result2 = robrange(nums, 1, nums.size() - 1); //不考虑头元素

        return max(result1, result2);
    }
};

337.打家劫舍III 

文档讲解:代码随想录打家劫舍III

视频讲解:手撕打家劫舍III

题目: 337. 打家劫舍 III - 力扣(LeetCode)

学习: 本题与上两题不同在于,从一个一维数组变成了一颗二叉树,增大了遍历的复杂度,本质还是隔一个搜一个,但需要在动规的过程中加入树的遍历。

我们尝试从动规五部曲进行分析:

1.确定dp数组,我们尝试使用一个map来保存各节点map<Treenode*,int>dp,dp[i].second表示i节点之下能够得到的最大金额。

2.确定递推公式:同样分两种情况,取用当前节点i,dp[i] = i - >val + dp[i->left->left] + dp[i->left->right] + dp[i->right->right] + dp[i->right->left],也就是当前节点的值,加上它的所有孙子的值。如果不取用节点i呢,dp[i] = dp[i->left] + dp[i->right],也就是它的左右孩子的值。

可以发现到这里,在实际使用的时候,我们需要先遍历所有的节点,且确定哪些节点是哪些节点的孙子孩子,这在实际中是很困难的,因此本题的dp数组的设置,与我们常规的dp数组的设置不同,本题的递推公式主要提供了计算的方式。

方法一:暴力搜索

根据递推公式,我们可以采取暴力搜索的方式来得到最大值:

//时间复杂度(n^2)
//空间复杂度O(logn)
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) val1 += rob(root->left->left) + rob(root->left->right); // 跳过root->left,相当于不考虑左孩子了
        if (root->right) val1 += rob(root->right->left) + rob(root->right->right); // 跳过root->right,相当于不考虑右孩子了
        // 不偷父节点
        int val2 = rob(root->left) + rob(root->right); // 考虑root的左右孩子
        return max(val1, val2);
    }
};

方法二:记忆化递推

实际上我们也能够使用上述递推公式的办法,我们可以一边遍历一边存储最大值的方式进行,这样能够在暴力搜索的基础上,降低时间复杂度,减少重复计算:

//时间复杂度O(n)
//空间复杂度O(n)
class Solution {
public:
    unordered_map<TreeNode* , int> umap; // 记录计算过的结果
    int rob(TreeNode* root) {
        if (root == NULL) return 0;
        if (root->left == NULL && root->right == NULL) return root->val;
        if (umap[root]) return umap[root]; // 如果umap里已经有记录则直接返回
        // 偷父节点
        int val1 = root->val;
        if (root->left) val1 += rob(root->left->left) + rob(root->left->right); // 跳过root->left
        if (root->right) val1 += rob(root->right->left) + rob(root->right->right); // 跳过root->right
        // 不偷父节点
        int val2 = rob(root->left) + rob(root->right); // 考虑root的左右孩子
        umap[root] = max(val1, val2); // umap记录一下结果
        return max(val1, val2);
    }
};

方法三:动态规划

本题还有个是将动态规划和递归结合的方法,结合递归三部曲和动规五部曲进行。

1.确定递归函数的参数和返回值:

参数显然为各节点,返回值我们设置为dp值,这里我们要求一个节点 偷与不偷的两个状态所得到的金钱,那么返回值就是一个长度为2的数组。

vector<int> robTree(TreeNode* cur) {

换言之我们设置了一个dp数组,dp[0]表示不取用该点时的最大金钱数,dp[1]表示取用该点时的最大金钱数。为啥要设置0,1两种可能,也是为后面的遍历做准备。

2.确定终止条件:遍历到空节点返回

if (cur == NULL) return vector<int>{0, 0};

3.确定遍历顺序:采用后序遍历

vector<int> left = robTree(cur->left); // 左
vector<int> right = robTree(cur->right); // 右

4.确定递推公式:

int val1 = cur->val + left[0] + right[0]; //意味着取用该点时,最大值为该点的值加上左右孩子不用的情况下最大的值。
int val2 = max(left[0], left[1]) + max(right[0], right[1]); //意味着不取用该点时,能够得到的最大值

5.举例推导dp数组:

代码:最后总结得到代码

//时间复杂度O(n)
//空间复杂度O(logn)
class Solution {
public:
    //动态规划+递归
    //1.确定参数及返回值
    vector<int> traversal(TreeNode* cur) {
        //2.确定终止条件
        if(cur == nullptr) return {0,0};

        //3.确定遍历顺序
        vector<int> left = traversal(cur -> left);
        vector<int> right = traversal(cur -> right);
        
        //4.确定递推公式
        int val1 = cur->val + left[0] + right[0]; //意味着取用该点时,最大值为该点的值加上左右孩子不用的情况下最大的值。
        int val2 = max(left[0], left[1]) + max(right[0], right[1]); //意味着不取用该点时,能够得到的最大值
        //返回数值
        return {val2, val1};
    }
    int rob(TreeNode* root) {
        vector<int> dp = traversal(root);
        return max(dp[0], dp[1]);
    }
};

总结 

打家劫舍题型I,II,III,逐一增加难度。第三题也是树形DP的入门题目,通过本题也能够了解树形DP就是在树上进行递归公式的推导。相较于此前我们使用的一维dp数组和二维dp数组还是有很大去别的,需要理解并反复练习。同时我们在第三题中还是用到了记忆化搜索的方式,实际上记忆化搜索也是实现动态规划的方式之一,其与正常的动态规划的方法的区别在于:原文链接

记忆化搜索:「自顶向下」的解决问题,采用自然的递归方式编写过程,在过程中会保存每个子问题的解(通常保存在一个数组或哈希表中)来避免重复计算。

  • 优点:代码清晰易懂,可以有效的处理一些复杂的状态转移方程。有些状态转移方程是非常复杂的,使用记忆化搜索可以将复杂的状态转移方程拆分成多个子问题,通过递归调用来解决。

  • 缺点:可能会因为递归深度过大而导致栈溢出问题。

递推:「自底向上」的解决问题,采用循环的方式编写过程,在过程中通过保存每个子问题的解(通常保存在一个数组或哈希表中)来避免重复计算。

  • 优点:避免了深度过大问题,不存在栈溢出问题。计算顺序比较明确,易于实现。

  • 缺点:无法处理一些复杂的状态转移方程。有些状态转移方程非常复杂,如果使用递推方法来计算,就会导致代码实现变得非常困难。

  • 29
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值