198. 打家劫舍
你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。
给定一个代表每个房屋存放金额的非负整数数组,计算你 不触动警报装置的情况下 ,一夜之内能够偷窃到的最高金额。
cpp
class Solution {
public:
int rob(vector<int>& nums) {
int n = nums.size();
// 边界情况处理
if(n <= 1) return nums[0];
// 1. 定义 DP 数组
// dp[i] 表示:考虑前 i 个房子(即区间 [0, i-1]),能偷到的最高金额
// 为了代码直观,这里 dp[i] 直接代表第 i 个房子时的状态
vector<int> dp(n, 0);
// 2. 初始化(推导的基础)
dp[0] = nums[0]; // 只有一家,必须偷
dp[1] = max(nums[0], nums[1]); // 有两家,偷钱多的那家(不能都偷)
// 3. 状态转移
// 从第三家开始遍历
for(int i = 2; i < n; i++){
// 递推公式:对于第 i 家,只有两种选择
// 1. 不偷第 i 家:那么最高金额就是偷到前一家 (i-1) 的金额,即 dp[i-1]
// 2. 偷第 i 家:那么第 i-1 家绝对不能偷,最高金额就是偷到前两家 (i-2) 的金额加上当前房子的钱,即 dp[i-2] + nums[i]
dp[i] = max(dp[i-1], dp[i-2] + nums[i]);
}
// 返回考虑完所有房子后的最高金额
return dp[n-1];
}
};
总结
1. 状态转移方程的绝佳抽象
这道题的核心在于"不能偷相邻两家"。
推导出的 dp[i] = max(dp[i-1], dp[i-2] + nums[i]) 完美契合了"选还是不选"的 DP 经典思考方式:
- 选:我就要当前元素,那我就必须承担不能选前一个元素的代价,所以我只能去加上
i-2的最优解。 - 不选:我不要当前元素,那我的最优解就完全继承前一个元素
i-1的最优解。
2. 初始化
很多新手会在 dp 数组前面加一个虚拟节点 dp[0] = 0,然后让 dp[1] = nums[0],这样写虽然能统一循环,但下标极其容易乱。
直接用 dp[0] 代表第 0 个房子,dp[1] 代表第 1 个房子,单独把前两个房子拎出来初始化,下标对应关系清晰。
213. 打家劫舍 II
你是一个专业的小偷,计划偷窃沿街的房屋,每间房内都藏有一定的现金。这个地方所有的房屋都 围成一圈 ,这意味着第一个房屋和最后一个房屋是紧挨着的。同时,相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警 。
给定一个代表每个房屋存放金额的非负整数数组,计算你 在不触动警报装置的情况下,今晚能够偷窃到的最高金额。
cpp
class Solution {
public:
// 辅助函数:处理标准的线性打家劫舍(复用上一题的思路)
int robrange(vector<int>& nums, int start, int end) {
// 如果区间内只剩一个元素,直接返回
if(start == end) return nums[start];
int n = nums.size();
vector<int> dp(n + 1, 0);
// 在指定区间 [start, end] 内进行初始化
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) {
int n = nums.size();
if(n == 1) return nums[0];
// 核心思想:首尾不能同时偷,那就分情况讨论
// 情况一:只考虑区间 [0, n-2],即偷第一家,绝对不偷最后一家
int left = robrange(nums, 0, n - 2);
// 情况二:只考虑区间 [1, n-1],即不偷第一家,考虑偷最后一家
int right = robrange(nums, 1, n - 1);
// 两种情况取最大值,即为全局最优解
return max(left, right);
}
};
总结
1. 核心破局点:去环操作
环形容器在算法题中是个大麻烦,因为这破坏了单向的递推关系。
既然第 0 间房和第 n−1n−1 间房互斥(不能同时抢),那么答案必定存在于以下两种情况之一:
- 抢了第 0 间,那就绝对不能抢第 n−1间 →→ 问题变成求
[0, n-2]的线性最大值。 - 没抢第 0 间,那就可以考虑抢第 n−1 间 →→ 问题变成求
[1, n-1]的线性最大值。
这种"将环形依赖转化为两个互斥的线性区间"的思想,是极其高级的算法思维,以后做链表成环、数组循环移位等问题时都能用得上。
337. 打家劫舍 III
小偷又发现了一个新的可行窃的地区。这个地区只有一个入口,我们称之为
root。除了
root之外,每栋房子有且只有一个"父"房子与之相连。一番侦察之后,聪明的小偷意识到"这个地方的所有房屋的排列类似于一棵二叉树"。 如果 两个直接相连的房子在同一天晚上被打劫 ,房屋将自动报警。给定二叉树的
root。返回 在不触动警报的情况下 ,小偷能够盗取的最高金额 。
cpp
class Solution {
public:
// 辅助函数:返回一个长度为 2 的数组
// dp[0] 表示:不偷当前节点时,能得到的最大金额
// dp[1] 表示:偷当前节点时,能得到的最大金额
vector<int> robTree(TreeNode* root){
// 空节点:偷或不偷都是 0
if(root == NULL) return {0, 0};
// 【核心1:后序遍历】
// 必须先得到左右子树的结果,才能推导当前节点
vector<int> left = robTree(root->left);
vector<int> right = robTree(root->right);
// 【核心2:状态转移】
// 情况一:偷当前节点。那么左右子节点绝对不能偷,只能加上 left[0] 和 right[0]
int val1 = root->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> res = robTree(root);
// 最终根节点,选偷或不偷中收益最大的那个
return max(res[0], res[1]);
}
};
总结
1. 为什么必须用"后序遍历"?
这是树形 DP 的灵魂。动态规划的精髓在于"根据已知推未知"。
在二叉树中,当前节点的状态依赖于它的左右孩子。所以你必须像剥洋葱一样,先剥到最底层(左子树、右子树),把最底层的状态算出来,再一层层往上返回,最后算出根节点。这就是标准的左右中(后序遍历)逻辑。
2. 为什么用一个大小为 2 的数组而不是一个变量?
如果在线性 DP 中,我们用一个 dp[i] 就够了。但在树形结构中,父节点不仅需要知道子节点"最多能偷多少钱",更关键的是需要知道子节点"这笔钱是怎么偷的(有没有偷子节点自己)"。
用 {不偷, 偷} 的数组将状态打包下沉,完美解决了父子节点之间的状态互斥依赖问题。这种设计模式在后续做"监控二叉树"、"股票买卖"等复杂状态机问题时,是万能钥匙。