总结
- 打家劫舍 Ⅰ不能偷相邻房间
- 打家劫舍 Ⅱ 不能偷相邻房间,且房间首尾相连
- 打家劫舍 Ⅲ 房间以二叉树形式连接
- 打家劫舍 Ⅳ 小偷最少偷
k
间房屋,返回小偷的最小窃取能力,也就是偷取的最大金额 最小(最小化最大值)
打家劫舍 Ⅰ
你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。
给定一个代表每个房屋存放金额的非负整数数组,计算你 不触动警报装置的情况下 ,一夜之内能够偷窃到的最高金额。
设 dp[i]
为 i
号房间能够偷窃的最高金额。
dp[0] = nums[0];
, dp[1] = max(nums[0], nums[1]);
对于 i
号房间,有两种选择:偷 或者 不偷
- 如果偷,则
dp[i] = dp[i-2] + nums[i];
- 如果不偷,则
dp[i] = dp[i - 1];
- 返回两者的最大值。
完整代码:
class Solution {
public:
int rob(vector<int> &nums)
{
int n = nums.size();
if (n == 1)
return nums[0];
if (n == 2)
return max(nums[0], nums[1]);
vector<int> dp(n, 0);
dp[0] = nums[0];
dp[1] = max(nums[0], nums[1]);
for (int i = 2; i < n; ++i)
{
dp[i] = max(dp[i - 2] + nums[i], dp[i - 1]);
}
//printNums(dp);
return dp[n - 1];
}
};
打家劫舍 Ⅱ
你是一个专业的小偷,计划偷窃沿街的房屋,每间房内都藏有一定的现金。这个地方所有的房屋都 围成一圈 ,这意味着第一个房屋和最后一个房屋是紧挨着的。同时,相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警 。
给定一个代表每个房屋存放金额的非负整数数组,计算你 在不触动警报装置的情况下 ,今晚能够偷窃到的最高金额。
与 打家劫舍 Ⅰ不同之处在于,房屋现在是首尾相连的,因此,偷了第 1 间房屋,就不能偷最后一间房屋,偷了最后一间房屋,就不能偷第一间房屋。
那么第 1 间房屋就有两个状态:偷 或者 不偷,这两种状态会影响到后续递推公式/状态转移方程的推导,所以我们要用两个 dp 数组来存储这两种不同的状态( 打家劫舍 Ⅰ中不存在这样的影响)。
dp[i][0]
:表示第一个房间被偷,以此为基础进行推导得到的i
号房间能够偷窃的最高金额。dp[i][1]
:表示第一个房间不被偷,以此为基础进行推导得到的i
号房间能够偷窃的最高金额。
状态转移公式:
dp[i][0] = max(dp[i - 2][0] + nums[i], dp[i - 1][0]);
dp[i][1] = max(dp[i - 2][1] + nums[i], dp[i - 1][1]);
由于如果偷了第 1 间房屋,就不能偷最后一间房屋了,所以dp[n-1][0]
是没有意义的,第 n-1 也就是最后一间房间的最大金额一定是等于倒数第二间的最大金额,即dp[n-2][0]
,最后只需要比较dp[n-2][0]
和 dp[n-1][1]
的较大值。
完整代码:
class Solution {
public:
int rob(vector<int> &nums)
{
int n = nums.size();
if (n == 1)
return nums[0];
if (n == 2)
return max(nums[0], nums[1]);
vector<vector<int>> dp(n, vector<int>(2, 0));
dp[0][0] = nums[0];
dp[1][0] = nums[0];
dp[0][1] = 0;
dp[1][1] = nums[1];
for (int i = 2; i < n; ++i)
{
dp[i][0] = max(dp[i - 2][0] + nums[i], dp[i - 1][0]);
dp[i][1] = max(dp[i - 2][1] + nums[i], dp[i - 1][1]);
}
//printNums(dp);
return max(dp[n-2][0], dp[n-1][1]);
}
};
另一种版本:
class Solution {
public:
int robRange(vector<int>& nums, int start, int end) {
int n = end - start + 1;
if (n == 1)
return nums[start];
if (n == 2)
return max(nums[start], nums[end]);
vector<int> dp(n, 0);
dp[0] = nums[start];
dp[1] = max(nums[start], nums[start + 1]);
for (int i = 2; i < n; ++i)
{
dp[i] = max(dp[i - 2] + nums[i + start], dp[i - 1]);
}
return dp[n - 1];
}
int rob(vector<int> &nums)
{
int n = nums.size();
if (n == 1)
return nums[0];
if (n == 2)
return max(nums[0], nums[1]);
int res1 = robRange(nums, 0, n - 2);
int res2 = robRange(nums, 1, n - 1);
return max(res1, res2);
}
};
打家劫舍 Ⅲ
小偷又发现了一个新的可行窃的地区。这个地区只有一个入口,我们称之为
root
。除了
root
之外,每栋房子有且只有一个“父“房子与之相连。一番侦察之后,聪明的小偷意识到“这个地方的所有房屋的排列类似于一棵二叉树”。 如果 两个直接相连的房子在同一天晚上被打劫 ,房屋将自动报警。给定二叉树的
root
。返回 在不触动警报的情况下 ,小偷能够盗取的最高金额 。
好了,现在房子的连接方式变成了二叉树形结构。
这道题是我第一次遇到树形结构的动态规划类题目。可以详细记录一下解题过程
错误解法
这里记录一下我第一次想到的错误解法:思路是通过层序遍历得到每一层的节点之和形成一个数组,然后套用 打家劫舍 Ⅰ的解法计算最终结果:
#include <iostream>
#include <vector>
#include <algorithm>
#include <string>
#include <queue>
using namespace std;
// Definition for a binary tree node.
struct TreeNode {
int val;
TreeNode *left;
TreeNode *right;
TreeNode() : val(0), left(nullptr), right(nullptr) {}
TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
};
class Solution
{
public:
void printNums(vector<int> &nums)
{
for (int i = 0; i < nums.size(); ++i)
{
cout << nums[i] << " ";
}
cout << endl;
}
// 从数组构建二叉树
TreeNode* buildTree(const vector<int>& nums) {
if (nums.empty()) return nullptr;
vector<TreeNode*> nodes;
for (int val : nums) {
if (val == -1) {
nodes.push_back(nullptr); // 用 -1 表示空节点
} else {
nodes.push_back(new TreeNode(val));
}
}
int pos = 1;
for (int i = 0; i < nodes.size() && pos < nodes.size(); ++i) {
if (nodes[i]) {
nodes[i]->left = nodes[pos++];
if (pos < nodes.size()) {
nodes[i]->right = nodes[pos++];
}
}
}
return nodes[0];
}
vector<int> bfs(TreeNode* cur) {
vector<int> result;
queue<TreeNode*> myQueue;
myQueue.push(cur);
while (!myQueue.empty()) {
int n = myQueue.size();
int sum = 0;
while (n--) {
TreeNode* current = myQueue.front();
myQueue.pop();
sum += current->val;
if (current->left){
myQueue.push(current->left);
}
if (current->right) {
myQueue.push(current->right);
}
}
result.push_back(sum);
}
printNums(result);
return result;
}
int rob(TreeNode* root) {
vector<int> nums = bfs(root);
int n = nums.size();
if (n == 1)
return nums[0];
if (n == 2)
return max(nums[0], nums[1]);
vector<int> dp(n, 0);
dp[0] = nums[0];
dp[1] = max(nums[0], nums[1]);
for (int i = 2; i < n; ++i)
{
dp[i] = max(dp[i - 2] + nums[i], dp[i - 1]);
}
//printNums(dp);
return dp[n - 1];
}
};
int main()
{
Solution s;
vector<int> nums = {2,1,3,-1,4};
TreeNode* root = s.buildTree(nums);
vector<int> values = s.bfs(root);
int result = s.rob(root);
cout << result << endl;
return 0;
}
为什么这样的解法不对呢,举个例子来看:
2
/ \
1 3
\
4
对于这棵树,层序遍历得到的数组是 [2. 4. 4]
,dp 数组是 [2, 4, 6]
,最终结果是 6,然而正确结果应该是 7,偷窃第二层的 3 以及第三层的 4,他们并不是同一层的两个节点。
打家劫舍 III 的约束是不能同时偷取直接相连的父子节点,而不是简单的相邻层次的节点。后面会进一步解释我的解法错误原因。
暴力解
类似 打家劫舍 Ⅱ 的思想,对于根节点,有两种情况,偷 或者 不偷
- 如果偷根节点,那么接下来只能继续偷根节点的孙子节点了:
int value1 = root->val
+ rob(root->left ? root->left->left : nullptr)
+ rob(root->left ? root->left->right : nullptr)
+ rob(root->right ? root->right->left : nullptr)
+ rob(root->right ? root->right->right : nullptr);
- 如果不偷根节点,那么接下来可以偷根节点的儿子节点。
int value2 = rob(root->left) + rob(root->right);
class Solution {
public:
int rob(TreeNode* root) {
if (!root) return 0;
int value1 = root->val
+ rob(root->left ? root->left->left : nullptr)
+ rob(root->left ? root->left->right : nullptr)
+ rob(root->right ? root->right->left : nullptr)
+ rob(root->right ? root->right->right : nullptr);
int value2 = rob(root->left) + rob(root->right);
return max(value1, value2);
}
};
结果是超时了。
记忆化搜索
在第一种暴力递归的方法中,假设节点结构如下:
第一次我们计算了 A, C, D, G
的价值之和,以及 B, F, E, H
的价值之和,然后当 B, F
为父节点时,我们又需要重新计算 B, F, E ,H
的价值和 C, D, G
的价值,这里面是有重复计算的,通过记忆化搜索可以解决暴力递归中出现的重复子问题。
class Solution {
public:
unordered_map<TreeNode*, int> mp;
int rob(TreeNode* root) {
if (!root) return 0;
if (!root->left && !root->right) return root->val;
if (mp[root]) return mp[root];
int value1 = root->val;
if (root->left)
value1 += rob(root->left->left) + rob(root->left->right);
if (root->right)
value1 += rob(root->right->left) + rob(root->right->right);
int value2 = rob(root->left) + rob(root->right);
mp[root] = max(value1, value2);
return mp[root];
}
};
通过一个 unorder_map
来记录以每个节点为根节点,计算得到的最终结果,来避免重复计算的子问题,此时二叉树的每个节点只需要遍历一次,时间复杂度降低为O(n),自然是不会超时了。
这里我感觉其实已经有了一点动态规划的味道在里面了,接下来继续看树形动态规划的具体解法。
树形动态规划
在 打家劫舍 Ⅱ 我们提到,第 1 间房屋有两个状态:偷 或者 不偷,它的选择会影响到后续的状态,对于这题也是如此,对于当前节点 TreeNode* current
,有 偷 / 不偷 两种状态,用一个大小为 2 的数组记录这两种状态的结果 value1
, value2
:
- 偷当前节点,左右孩子都不能偷:
int value1 = current->val + 左孩子不偷返回的最大值 + 右孩子不偷返回的最大值
。 - 不偷当前节点,左右孩子既可以偷,也可以不偷:
int value2 = max(左孩子偷返回的最大值, 左孩子不偷返回的最大值) + max(右孩子偷返回的最大值, 右孩子不偷返回的最大值)
。 return {value1, value2};
以上就是单层递归的核心逻辑。
class Solution {
public:
vector<int> robTree(TreeNode* root) {
// 该节点为空, 偷与不偷都返回0
if (!root) return {0, 0};
// 计算该节点的左、右孩子的两种状态返回的结果
vector<int> left = robTree(root->left);
vector<int> right = robTree(root->right);
// 0表示偷,1表示不偷
// 偷该节点,左右孩子都不能偷
int value0 = root->val + left[1] + right[1];
// 不偷该节点,左右孩子既可以偷,也可以不偷
int value1 = max(left[0], left[1]) + max(right[0], right[1]);
return {value0, value1};
}
int rob(TreeNode* root) {
if (!root) return 0;
if (!root->left && !root->right) return root->val;
vector<int> result = robTree(root);
return max(result[0], result[1]);
}
};
这里有很重要的一点需要理解,如果当前节点我们不偷的话,对于他的左右孩子(如果存在),我们既可以选择偷,也可以选择不偷,我们要选择其中的更大值,而不是一定要偷它的左右孩子。
这也就是为什么,我写的层序遍历解法会错误,因为层序遍历的逻辑是,当前节点不偷,则它的左右孩子就一定要偷。
打家劫舍 Ⅳ
沿街有一排连续的房屋。每间房屋内都藏有一定的现金。现在有一位小偷计划从这些房屋中窃取现金。
由于相邻的房屋装有相互连通的防盗系统,所以小偷 不会窃取相邻的房屋 。
小偷的 窃取能力 定义为他在窃取过程中能从单间房屋中窃取的 最大金额 。
给你一个整数数组
nums
表示每间房屋存放的现金金额。形式上,从左起第i
间房屋中放有nums[i]
美元。另给你一个整数
k
,表示窃贼将会窃取的 最少 房屋数。小偷总能窃取至少k
间房屋。返回小偷的 最小 窃取能力。
根据题意,小偷最少偷 k
间房屋,返回小偷的最小窃取能力,也就是偷取的最大金额 最小,很容易就想到这是求一个 最小化最大值 的问题,我们在 2439. 最小化数组中的最大值 讲过,这类问题,可以想到的方法有两种:前缀和 以及 二分查找。
但是这道题多了一个条件,小偷至少要偷 k
间房屋。
我们同样用二分查找的来思考,设 mid
表示小偷的窃取能力,也就是窃取单间房屋的最大金额
- 如果此时能偷的房间数目
≥ k
,则对于(mid, nums.max()]
,能偷的房间数一定≥k
; - 如果此时能偷的房间数目
< k
,则对于[nums.min(), mid)
,能偷的房间数一定<k
;
这就是使用二分查找所需要的单调性
设计一个函数 robNum()
,返回 小偷的窃取能力为 maxMoney
时,能偷取的最大房间数 roomNum
:
- 如果
roomNum >=k
,则寻找是否有更小的窃取能力; - 如果
roomNum < k
,则寻找是否有更大的窃取能力;
完整代码:
class Solution {
public:
int rob(vector<int>& nums, int maxNum) {
int n = nums.size();
// dp[i]: 偷取房屋的最大金额不超过maxNum, 最多能偷dp[i]间房屋
vector<int> dp(n, 0);
dp[0] = nums[0] > maxNum ? 0 : 1;
if (n == 1) return dp[0];
if (nums[1] > maxNum) {
dp[1] = dp[0];
} else {
dp[1] = 1;
}
for (int i = 2; i < n; ++i) {
if (nums[i] > maxNum) {
dp[i] = dp[i-1];
} else {
dp[i] = max(dp[i-1], dp[i-2] + 1);
}
}
return dp[n-1];
}
int minCapability(vector<int>& nums, int k) {
int left = *min_element(nums.begin(), nums.end());
int right = *max_element(nums.begin(), nums.end());
while (left <= right) {
int mid = left + (right - left) / 2; // 代表小偷的窃取能力
int temp = rob(nums, mid); // 返回对应窃取能力下,能偷的房间数目
if (temp >= k) {
right = mid - 1;
} else {
left = mid + 1;
}
}
return left;
}
};
空间复杂度:O(n)
还可以使用滚动变量来优化rob
函数的空间复杂度,将 dp
数组优化为常数空间:
class Solution {
public:
int rob(vector<int>& nums, int maxNum) {
int n = nums.size();
// 滚动数组
// dp[0] 第i-2间房屋
// dp[1] 第i-1间房屋
// dp[2] 第i间房屋
vector<int> dp(3, 0);
dp[0] = nums[0] > maxNum ? 0 : 1;
if (n == 1) return dp[0];
dp[1] = nums[1] > maxNum ? dp[0] : 1;
for (int i = 2; i < n; ++i) {
if (nums[i] > maxNum) {
dp[2] = dp[1];
} else {
dp[2] = max(dp[1], dp[0] + 1);
}
dp[0] = dp[1];
dp[1] = dp[2];
}
return dp[1];
}
int minCapability(vector<int>& nums, int k) {
int left = *min_element(nums.begin(), nums.end());
int right = *max_element(nums.begin(), nums.end());
while (left <= right) {
int mid = left + (right - left) / 2; // 代表小偷的窃取能力
int temp = rob(nums, mid); // 返回对应窃取能力下,能偷的房间数目
if (temp >= k) {
right = mid - 1;
} else {
left = mid + 1;
}
}
return left;
}
};
空间复杂度:O(1)
进一步还可以使用二分查找 + 贪心算法来计算:
class Solution {
public:
int rob(vector<int>& nums, int maxNum) {
int n = nums.size();
int count = 0;
for (int i = 0; i < n;) {
if (nums[i] <= maxNum) {
count++;
i += 2; // 跳过相邻的下一个房屋
} else {
i++;
}
}
return count;
}
int minCapability(vector<int>& nums, int k) {
int left = *min_element(nums.begin(), nums.end());
int right = *max_element(nums.begin(), nums.end());
while (left <= right) {
int mid = left + (right - left) / 2; // 代表小偷的窃取能力
int temp = rob(nums, mid); // 返回对应窃取能力下,能偷的房间数目
if (temp >= k) {
right = mid - 1;
} else {
left = mid + 1;
}
}
return left;
}
};
在rob
函数中:从左到右遍历 nums
,只要当前房子可以偷,就立刻偷。
必然成立。那么对于 i
号房间,如果 nums[i] <= maxMoney
:
- 如果偷当前房间,则
dp[i] = dp[i-2] + 1
; - 如果不偷当前房间,则
dp[i] = dp[i-1]
;
根据公式 (4),偷当前房间得到的 dp[i]
(房间数目) 是一定大于不偷房间的,所以小偷面对一间他可以偷的房间,他的最优决策就是偷!
算法复杂度分析:
- 时间复杂度:
贪心算法的单次遍历为 O(n)
。
二分查找的次数为 O(log M)
,其中 M
为数组中的最大值。
总的时间复杂度为 O(n log M)
。
- 空间复杂度:
只使用了常数级别的额外空间,空间复杂度为 O(1)
。