动态规划(打家劫舍)
文章目录
打家劫舍系列
如果想看背包问题的讲解和练习的同学可以点这里
打家劫舍
不管怎么样,先用暴搜递归出一个答案,后面再去优化时间复杂度。
定义一个递归函数表示前start
个房间偷,可以获得的最大金额数。因为每一次不可以回头偷已经投过的房间,所以要设置start
这个变量,同时start
也表示偷到第几个房间了。并且每一次偷都需要至少隔着一个房间偷。
(递归)TLE
class Solution {
public:
int dfs(vector<int>& nums, int start) {// 返回前start数,可以获得的最大金额数
if (start >= nums.size()) return 0;
int ans = 0;
for (int i = start; i < nums.size(); i ++) {
ans = max(ans, nums[i] + dfs(nums, i + 2));
}
return ans;
}
int rob(vector<int>& nums) {
return dfs(nums, 0);
}
};
因为递归中出现很多的重复计算的递归函数,所以可以将这些重复的子问题的计算结果全部都保存,这样重复的子问题(递归函数)就可以只计算一遍了。这样就大大节省了时间的复杂度。
至于要什么容器来保存递归函数的计算结果并没有特别的规定,一般可以用unordered_map
哈希表或者就是用数组。但是有点要出的是:如果用的是数组的话,初始化数组中的数必须是房子中一定没有的金额数,比如负数-1,因为金额不可能数负数。这样初始化的原因是要和没有计算的结果金额区别。比如说数组中的数全部初始化为0,如果所有的房间的金额数都是0(也就是房子中没放钱)话,if(memo[index] != 0) return memo[index]
就不会被触发,这样的话还是会有可能超时。
(记忆化搜索)
class Solution {
public:
int dfs(vector<int>& nums, int index, int memo[]) {
if (index >= nums.size()) return 0;
if (memo[index] != -1) return memo[index];// 如果是已经出现过的递归函数,就直接调用计算的结果
int ans = 0;
for (int i = index; i < nums.size(); i ++) {
ans = max(ans, nums[i] + dfs(nums, i + 2, memo));
}
memo[index] = ans;// 保存递归函数的计算结果
return ans;
}
int rob(vector<int>& nums) {
int memo[nums.size() + 1];
memset(memo, -1, sizeof memo);
return dfs(nums, 0, memo);
}
};
将记忆化搜索的结果放在一个二维数表中就是动态规划。
1.dp数组的含义
dp[i]
前i个房间中偷钱,最多可以偷dp[i]
。
2.递推公式
分析递推公式的时候,一般是抓住dp[i]
的最后一个值来讨论。即第i个房间是否被偷。
2.1.如果偷第i个房间,那么第i - 1个房间不能偷了,所以dp[i] = dp[i - 2] + nums[i]
2.2.如果不偷第i个房间,那么就可以考虑偷第i - 1个房间,因为要偷最多的金额,所以就要从前i - 1个房间中偷钱,即dp[i] = dp[i - 1]
综上dp[i] = max(dp[i - 1], dp[i - 2] + nums[i])
3.初始化
因为递推公式是dp[i] = max(dp[i - 1], dp[i - 2] + nums[i])
,所以要考虑dp[i - 1]
和dp[i - 2]
,也就是要将dp[0]
和dp[1]
初始化,dp[0]
就是前0个房间中选,只有nums[0]
,所以dp[0] = nums[0]
,dp[1]
是从前1个房间中选,所以是max(nums[0], nums[1])
。
当然如果dp数组可以多出一格,前i个房间中的i表示实际意义的i,而不是nums
数组下标的i。即数组的下标从1开始的话,dp[0]
表示没有房间可以偷,所以初始化为0,dp[1]
是从前1个房间中偷,所以dp[1] = nums[0]
。这样也是可以的。
4.遍历顺序
根据递推公式可以知道,dp[i]
是有dp[i - 1]
和dp[i - 2]
退出的,所以是从前往后循环
5.举例说明
(动规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];
vector<int> dp(nums.size(), 0);
dp[0] = nums[0];
dp[1] = max(nums[0], nums[1]);
for (int i = 2; i < nums.size(); i ++) {
dp[i] = max(dp[i - 2] + nums[i], dp[i - 1]);
}
return dp[nums.size() - 1];
}
};
(动规2)
class Solution {
public:
int rob(vector<int>& nums) {
// 需要特判,数组中没有数,因为后面需要初始化dp[1]
if (nums.size() == 0) return 0;
vector<int> dp(nums.size() + 1, 0);
dp[0] = 0;
dp[1] = nums[0];
for (int i = 2; i <= nums.size(); i ++) {
dp[i] = max(dp[i - 2] + nums[i - 1], dp[i - 1]);
}
return dp[nums.size()];
}
};
因为每一次的递推公式只是使用到了dp[i - 1]
和dp[i - 2]
,所以根本没有必要保存一个数组,只要保存两个变量即可。
(动规 空间优化)
class Solution {
public:
int rob(vector<int>& nums) {
int prev = 0;
int cur = 0;
for (int num : nums) {
int tmp = max(prev + num, cur);
prev = cur;
cur = tmp;
}
return cur;
}
};
打家劫舍Ⅱ
打家劫舍Ⅱ就是在打家劫舍的基础上多加了一个限制条件,**即不仅不能够同时偷相邻两家,而且不能够同时偷第一家和最后一家。**这其实就形成了一个环。但是不防将这个环分割一下,使得第一个房间和最后一个房间不同时在一条地带上。
这样不就变成了打家劫舍那到题目了吗?只要将这道题目做两边,然后去最大值即可。
(记忆化搜索)
class Solution {
public:
int dfs(vector<int>& nums, int index, int memo[]) {
if (index >= nums.size()) return 0;
if (memo[index] != -1) return memo[index];// 如果是已经出现过的递归函数,就直接调用计算的结果
int ans = 0;
for (int i = index; i < nums.size(); i ++) {
ans = max(ans, nums[i] + dfs(nums, i + 2, memo));
}
memo[index] = ans;// 保存递归函数的计算结果
return ans;
}
int rob(vector<int>& nums) {
if (nums.size() == 1) return nums[0];// 只有一间房子的时候需要特判
int memo[nums.size()];
// 将成环的房间分割成两部分,[1, nums.size() - 1]和[0, nums.size() - 2]
memset(memo, -1, sizeof(memo));
int ans1 = dfs(nums, 1, memo);
memset(memo, -1, sizeof(memo));
nums.pop_back();// 去掉数组中最后一个元素
int ans2 = dfs(nums, 0, memo);
return max(ans1, ans2);
}
};
(动规1)
class Solution {
public:
int robRang(vector<int>& nums, int start, int end) {
if (start == end) return nums[start];
vector<int> dp(nums.size(), 0);
dp[start] = nums[start];
dp[start + 1] = max(nums[start], nums[start + 1]);
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() == 1) return nums[0];
int ans1 = robRang(nums, 0, nums.size() - 2);
int ans2 = robRang(nums, 1, nums.size() - 1);
return max(ans1, ans2);
}
};
上面代码的注意事项:第7,8两行dp[start] = nums[start] dp[start + 1] = max(nums[start], nums[start + 1])
的初始化操作和循环中i = start + 2
这两步需要从start
开始而不是dp[0] dp[1]
开始,因为当进行[1, nums.size() - 1]
这一段的时候,dp[1]
才是开头的第一件房间,即dp[1] = nums[1]
,而dp[2] = max(nums[1], nums[2])
。
(动规2)
class Solution {
public:
int robRang(vector<int>& nums, int start, int end) {
if (start == end) return nums[start];
vector<int> dp(nums.size() + 1, 0);
dp[start] = 0;
dp[start + 1] = nums[start];
for (int i = start + 2; i <= end + 1; i ++) {
dp[i] = max(dp[i - 1], dp[i - 2] + nums[i - 1]);
}
return dp[end + 1];
}
int rob(vector<int>& nums) {
if (nums.size() == 1) return nums[0];
int ans1 = robRang(nums, 0, nums.size() - 2);
int ans2 = robRang(nums, 1, nums.size() - 1);
return max(ans1, ans2);
}
};
(动规 空间优化)
class Solution {
public:
int robRang(vector<int>& nums, int start, int end) {
int prev = 0, cur = 0;
for (int i = start; i <= end; i ++) {
int tmp = max(prev + nums[i], cur);
prev = cur;
cur = tmp;
}
return cur;
}
int rob(vector<int>& nums) {
if (nums.size() == 1) return nums[0];
int ans1 = robRang(nums, 0, nums.size() - 2);
int ans2 = robRang(nums, 1, nums.size() - 1);
return max(ans1, ans2);
}
};
打家劫舍Ⅲ
这打家劫舍Ⅱ是将打家劫舍Ⅰ中的房子连成了环形,但是至少房子之间还是相邻的,这打家劫舍Ⅲ中的房子直接整成树形结构了,所以这下就不能用循环解决问题了,但是因为小偷还是不能偷相邻的房子,所以做题的思路还是一样的,还是对当前结点偷或者不偷做讨论。
情况1.如果不偷当前结点,那当前节点的两个孩子结点就都可以偷了
情况2.如果头当前节点,那当前节点的两个孩子结点就不可以再被偷了,要从孩子结点的孩子结点(孙子结点)开始偷了。
首先还是暴力递归。
(暴力递归)
class Solution {
public:
int dfs(TreeNode* root) {
if (root == nullptr) return 0;
// 不抢当前结点的数值
int ans1 = 0;
ans1 += dfs(root->left) + dfs(root->right);
// 抢当前结点的数值
int ans2 = root->val;
if (root->left) {// 判断是否有左孩子
ans2 += dfs(root->left->left) + dfs(root->left->right);
}
if (root->right) {// 判断是否有右孩子
ans2 += dfs(root->right->left) + dfs(root->right->right);
}
return max(ans1, ans2);
}
int rob(TreeNode* root) {
return dfs(root);
}
};
因为有大量的重复计算(计算父结点的时候就已经算过孩子结点和孙子结点了),所以会超时。当时就需要将这些重复结点的结果用一个容器保存起来,这样相同的结点只用计算一次即可。前面说过这个容器可以是数组或者哈希表,这里当然是用哈希表unordered_map<TreeNode*, int>
来保存,因为数组也不能保存树的结点鸭!
(记忆化搜索)
class Solution {
public:
unordered_map<TreeNode*, int> hash;
int dfs(TreeNode* root) {
if (root == nullptr) return 0;
// 这段代码可以省略,因为当root==nullptr的时候会return 0
if (root->left == nullptr && root->right == nullptr) return root->val;
// 如果已经计算过递归函数,就直接调用计算结果
if (hash.find(root) != hash.end()) return hash[root];
// 不抢当前结点的数值
int ans1 = 0;
ans1 += dfs(root->left) + dfs(root->right);
// 抢当前结点的数值
int ans2 = root->val;
if (root->left) {// 判断是否有左孩子
ans2 += dfs(root->left->left) + dfs(root->left->right);
}
if (root->right) {// 判断是否有右孩子
ans2 += dfs(root->right->left) + dfs(root->right->right);
}
hash[root] = max(ans1, ans2);// 记录递归函数的计算结果
return max(ans1, ans2);
}
int rob(TreeNode* root) {
return dfs(root);
}
};
下面才是真正的重头戏:树形DP。以前有很多的动态规划都是通过循环来填充一个多维的数表来实现的,但是因为树形的特殊结构,不可以用循环的方式来填充数组了,而是用递归的方式来填充每一个结点的状态。
1.dp状态的含义
dp[node][j]
,其中[node]
在树形结构中就表示当前的结点,j
表示每一个结点都有两个状态:偷或者不偷。这个在树形结构用一个vector<int> node
表现,node[0]
表示不偷,node[1]
表示偷。
2.递归公式
情况1.如果不偷当前的结点,获得的金额为偷儿子结点及其之后的结点获得的金额。(儿子节点也可以不偷,但是要选取左右孩子中可以获得的最大金额),即max(left[0], left[1]) + max(right[0], right[1])
。
情况2.如果头当前结点,就不可以再头左右孩子结点的数值了,即root->val + left[0] + right[0]
。
3.递归结束条件
当遇到空节点的时候,返回{0, 0}
,因为不能获得任何的钱。
(树形dp)
class Solution {
public:
vector<int> dfs(TreeNode* root) {
if (root == nullptr) return {0, 0};
vector<int> left = dfs(root->left);
vector<int> right = dfs(root->right);
// 不偷当前结点的数值,那么就可以偷下一层的结点
int ans1 = max(left[0], left[1]) + max(right[0], right[1]);
// 偷当前结点的数值,不能偷下一层的结点
int ans2 = root->val + left[0] + right[0];
return vector<int>({ans1, ans2});
}
int rob(TreeNode* root) {
vector<int> ans = dfs(root);
return max(ans[0], ans[1]);
}
};
树形dp的定义状态是有一些奇怪,但是依然是对每一个结点的状态进行选择,这样每一个子问题就可以得到解决,从而最终问题就得以解决。
最后想说:这三道题目做下来,其实都是万变不离其宗,都是当前偷,那么后面一个就不能偷,要从后面的第二个开始偷,当前不偷,那么后面一个就可以偷。而这两个状态就是就是动态规划的关键所在,因为每一个数的状态都是一样的,所以可以将前面的数计算完了之后,自然而然的退出后面的数,这就是动态规划。
我预测一手,如果有打家劫舍Ⅳ,那就是将树的叶子结点和根节点相连,并且相邻的结点不可以被偷,哈哈哈!!!