【4】动态规划运用&三道打家劫舍 LeetCode.198 213 337

198.打家劫舍&动态规划运用

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

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

输入: [2,7,9,3,1]
输出: 12

什么是动态规划

  • 动态规划算法通常用于求解具有某种最优性质的问题
  • 在这类问题中,可能会有许多可行解。每一个解都对应于一个值,我们希望找到具有最优值的解
  • 动态规划算法其基本思想也是将待求解问题分解成若干个子问题,先求解子问题,然后从这些子问题的解得到原问题的解
  • 但如果只是单纯进行问题分解的话,得到的子问题数目太多,有些子问题被重复计算了很多次
  • 如果我们能够保存已解决的子问题的答案,而在需要时再找出已求得的答案,这样就可以避免大量的重复计算,节省时间
  • 我们可以用一个表来记录所有已解的子问题的答案。不管该子问题以后是否被用到,只要它被计算过,就将其结果填入表中。这就是动态规划法的基本思路

动态规划的题目,具有以下三个特点

  • 最优子结构:如果问题的最优解所包含的子问题的解也是最优的,就称该问题具有最优子结构
  • 重叠子问题:即每次产生的子问题不总是新问题,有些子问题可能会反复出现多次。动态规划算法正是利用了该性质,从而获得较高的运算效率。(该性质并不是动态规划适用的必要条件,但是如果没有这条性质,动态规划算法同其他算法相比就不具备优势)。
  • 子问题独立:也即无后效性,某阶段状态一旦确定,就不受这个状态以后决策的影响。也就是说,某状态以后的过程不会影响以前的状态,只与当前状态有关。

备忘录方法(动态规划的变形)
当我们在求解过程中,其实有很多子问题是不会出现的,我们没必要求出所有的子问题的最优解
我们可以为每个子问题建立一个记录项,在第一次计算时做记录,以后直接读取

用动态规划解决该题

解法一: 递归

对于这道题来说,我们首先肯定想到直接用递归做

int rob(vector<int>& nums)
{
     
	return robber(nums,0);
}

int robber(vector<int> &nums,int house)
{
     
	if(nums.size()-house<=0)
		return 0;
    if(nums.size()-house==1)
        return nums[house];
	return max(robber(nums,house+2)+nums[house],robber(nums,house+1));
}

但是这样是指数式的时间复杂度,必然会超时,因此我们考虑用带备忘录的动态规划,在上面的代码中稍微修改一下。

解法二:带备忘录的动态规划

因为对于每次递归,都需要

return max(robber(nums,house+2)+nums[house],robber(nums,house+1));

即如果每次涉及到这一步,都需要再将这个问题的子问题全部重新计算一遍,耗费了大量时间,因此我们可以选择直接将这个max值储存起来,每次需要时再进行调用。
即开一个数组,对于每一个house值

wealth[house]=max(robber(nums,house+2)+nums[house],robber(nums,house+1));

所以有了下面的方法:

int rob(vector<int>& nums)
{
     
	int *wealth=new int[nums.size()];
	for(int i=0;i<nums.size();++i)
		wealth[i]=-1;
	return robber(nums,0,wealth);
}

int robber(vector<int> &nums,int house,int *wealth)
{
     
	if(nums.size()-house<=0)
		return 0;
    if(nums.size()-house==1)
        return nums[house];
    if(wealth[house]!=-1)
    	return wealth[house];
	else
	 	{
     wealth[house]=max(robber(nums,house+2,wealth)+nums[house],robber(nums,house+1,wealth));
	 		return wealth[house];}
}

解法三:自底向上的动态规划

如果我们不用递归呢?直接通过循环可以解决吗?
当然可以。
分析上面的代码,可以得出状态转移方程:

f ( k ) = m a x ( f ( k – 2 ) + x ​ , f ( k – 1 ) ) f(k) = max(f(k – 2) + x​, f(k – 1)) f(k)=max(f(k2)+x,f(k1))

也即要么取第k个房子里的x财物,然后从k+2的房子开始重新考虑,要么不取这个房子里的财物,从k+1开始考虑

所以有:

int rob(vector<int> &nums) {
     
    int p = 0;   //代表考虑偷某个房子之前手里的财物
    int q = 0;   //代表考虑完后手里的财物
    for (int i=0;i<nums.size();++i) {
     
        int temp = q;
        q = max(p + nums[i], q);	//考虑分为两种情况,选择收益更大的一种
        p = temp;
    }
    return q;
}

213.打家劫舍2

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

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

注意,这题和前面不同之处在于这句话:这个地方所有的房屋都围成一圈,这意味着第一个房屋和最后一个房屋是紧挨着的。

也就意味着,第一间房屋和最后一间房屋,最多只能选择一间偷窃。
因此我们可以分为两种情况:

  1. 从第一间房屋开始,到倒数第二间为止
  2. 从第二间房屋开始,到最后一间为止

然后将两种情况的最大值进行比较,得出的最大值就是最高金额。
把上面基于备忘录的代码稍事修改,对两种情况分别设置备忘录进行储存,有了如下方案:

   int rob(vector<int>& nums)
{
     
	int *wealth1=new int[nums.size()];
    int *wealth2=new int[nums.size()];
    if(nums.size()==1) return nums[0]; 
	for(int i=0;i<nums.size();++i)
		{
     wealth1[i]=-1;
        wealth2[i]=-1;
        }
	return max(robber(nums,1,nums.size(),wealth1),robber(nums,0,nums.size()-1,wealth2));
}

int robber(vector<int> &nums,int house,int end,int *wealth)
{
     
	if(end-house<=0)
		return 0;
    if(end-house==1)
        return nums[house];
    if(wealth[house]!=-1)
    	return wealth[house];
	else
	 {
     
		wealth[house]=max(robber(nums,house+2,end,wealth)+nums[house],robber(nums,house+1,end,wealth));
		return wealth[house];
	}
}

结果如图:可以看出内存消耗较大,但速度很快
通过测试


337.打家劫舍3

在上次打劫完一条街道之后和一圈房屋后,小偷又发现了一个新的可行窃的地区。这个地区只有一个入口,我们称之为“根”。 除了“根”之外,每栋房子有且只有一个“父“房子与之相连。一番侦察之后,聪明的小偷意识到“这个地方的所有房屋的排列类似于一棵二叉树”。 如果两个直接相连的房子在同一天晚上被打劫,房屋将自动报警。

计算在不触动警报的情况下,小偷一晚能够盗取的最高金额。

示例 :

输入: [3,2,3,null,3,null,1]
3
/   \
2   3
/   \
3   1

输出: 7
小偷一晚能够盗取的最高金额 = 3 + 3 + 1 = 7.

换汤不换药,依然是相似的题目,不过采用了二叉树式的排布。

解法一:

首先我想到的是,对各个孩子进行标记。如果小偷偷了某个节点,则该节点的左右孩子就不能偷,因此将两个节点标记为0,表示不可偷。如果没有偷该节点,则将左右孩子标记为1,表示可以偷。然后通过递归解决。

class Solution {
     
public:
    int rob(TreeNode* root)
{
     
	return robber(root,1);
}

int robber(TreeNode* root,int house)
{
     
	if(root==nullptr)
		return 0;
	if(house==1&&root->left==nullptr&&root->right==nullptr)
		return root->val;
	if(house==0&&root->left==nullptr&&root->right==nullptr)
		return 0;

	if(house==0)
		return robber(root->right,1)+robber(root->left,1);
	else return max(root->val+robber(root->right,0)+robber(root->left,0),robber(root->right,1)+robber(root->left,1));
}
};

但是因为没有采用动态规划,算法性能较低,没想到第一次就通过了测试:
在这里插入图片描述但用了2528ms,可以说很恐怖了。

解法二:还是递归

这次我们换个思路,对于某个节点的最大偷窃财富,其实就可以理解为
4个孙子偷的钱 + 爷爷的钱 VS 两个孩子偷的钱 哪个组合钱多,就当做当前节点能偷的最大钱数。这就是动态规划里面的最优子结构

class Solution {
     
public:
    int rob(TreeNode* root)
{
     

	if(root==nullptr)
		return 0;

    int money=root->val;		//爷爷的钱
    
	if(root->left!=nullptr)
		money+=rob(root->left->left)+rob(root->left->right);
	if(root->right!=nullptr)
		money+=rob(root->right->left)+rob(root->right->right);  //四个孙子的钱

	return max(money,rob(root->left)+rob(root->right));  //两个孩子的钱和之前总和的最大值
}
};

在这里插入图片描述可以看出效率并没有提升,但是代码简化了,也为后面的方法做了铺垫。

解法三:带备忘录的动态规划

一脉相承,我们依然采用备忘录来解决重复子问题。但是由于这是一个树形结构,而且每次递归只有根节点的指针,而没有相应的数据,很难用数组进行存储,因此我选择采用map进行存储。

map不熟悉的可以看看 这篇博文,以及这篇

下面直接上代码:

class Solution
{
     
private:
	map<TreeNode*,int> maps;
public:
	int rob(TreeNode* root)
	{
     

	if(!root)
		return 0;

	auto it_find=maps.find(root);  //在map中寻找有没有储存该节点的值
	if(it_find!=maps.end())			//如果it_find没有指向末尾,证明找到了
		return it_find->second; 

	int money=root->val;

	if(root->left!=nullptr)
		money+=rob(root->left->left)+rob(root->left->right);
	if(root->right!=nullptr)
		money+=rob(root->right->left)+rob(root->right->right);

	maps.emplace(root,max(money,rob(root->left)+rob(root->right))); //向map中插入新的节点
	
	return max(money,rob(root->left)+rob(root->right));
	}
};

结果如图:
在这里插入图片描述可见效率提高了100倍。

你可能感兴趣的:(算法,动态规划,数据结构,leetcode)