1. 问题背景与核心思路
这道题目描述了一个经典的动态规划问题:假设你是一个专业的小偷,需要在不触发警报的情况下,从一排房屋中偷取最大金额。警报系统的规则是:如果相邻的两间房屋在同一晚被闯入,系统就会报警。
这个问题的核心在于如何做出最优决策。对于每一间房屋,我们有两个选择:
- 偷这间房屋:那么前一间房屋就不能偷,当前最大金额是"前前间房屋的最大金额+当前房屋金额"
- 不偷这间房屋:那么当前最大金额就是"前一间房屋的最大金额"
我们需要在这两个选择中取较大值作为当前的最优解。这就是典型的动态规划思路——将大问题分解为子问题,通过解决子问题来构建最终解。
2. 动态规划解法详解
2.1 状态定义与转移方程
我们可以定义一个dp数组,其中dp[i]表示到第i间房屋时能偷到的最大金额。根据前面的分析,状态转移方程为:
code复制dp[i] = max(dp[i-1], dp[i-2] + nums[i])
这个方程的意思是:到第i间房屋时的最大金额,要么是不偷这间(取dp[i-1]),要么是偷这间(取dp[i-2]+nums[i]),两者取较大值。
2.2 初始条件处理
对于前两间房屋,我们需要特殊处理:
- 只有一间房屋时:dp[0] = nums[0]
- 有两间房屋时:dp[1] = max(nums[0], nums[1])
2.3 空间优化
观察状态转移方程可以发现,dp[i]只依赖于dp[i-1]和dp[i-2],因此我们不需要存储整个dp数组,只需要保存前两个状态即可。这就是代码中使用first和second变量的原因,将空间复杂度从O(n)优化到O(1)。
3. 代码实现解析
让我们详细分析给出的C++解决方案:
cpp复制class Solution {
public:
int rob(vector<int>& nums) {
if (nums.empty()) {
return 0;
}
int size = nums.size();
if (size == 1) {
return nums[0];
}
int first = nums[0], second = max(nums[0], nums[1]);
for (int i = 2; i < size; i++) {
int temp = second;
second = max(first + nums[i], second);
first = temp;
}
return second;
}
};
3.1 边界条件处理
代码首先处理了两个边界情况:
- 空数组:直接返回0
- 只有一间房屋:返回该房屋的金额
3.2 核心循环逻辑
对于超过两间房屋的情况:
- 初始化first为nums[0],second为max(nums[0], nums[1])
- 从第三间房屋开始遍历:
- 保存当前的second到temp(因为后面要更新second)
- 新的second是max(first + nums[i], second)
- 将temp赋给first(相当于前进一步)
3.3 为什么这样设计
这种设计巧妙地利用了动态规划的空间优化技巧。first和second分别代表了dp[i-2]和dp[i-1],通过每次迭代更新这两个变量,我们可以在常数空间内完成计算。
4. 算法复杂度分析
4.1 时间复杂度
算法只需要一次遍历数组,因此时间复杂度是O(n),其中n是数组长度。
4.2 空间复杂度
由于只使用了常数个额外变量,空间复杂度是O(1)。
5. 实际应用与变种问题
5.1 实际问题中的应用
这个算法模型可以应用于许多实际问题:
- 资源分配问题:在不能同时选择相邻资源的情况下最大化收益
- 任务调度:选择不相邻的时间段执行任务以获得最大收益
- 投资决策:在某些限制条件下选择最优的投资组合
5.2 相关变种问题
LeetCode上还有几个这个问题的变种:
- 房屋围成一圈(首尾相连)
- 房屋是二叉树结构
- 每次偷窃后需要跳过k间房屋而不是1间
这些变种问题的核心思路类似,但需要根据具体限制条件调整状态转移方程。
6. 常见错误与调试技巧
6.1 常见错误
- 边界条件处理不全:忘记处理空数组或单元素数组的情况
- 索引越界:在访问nums[i]时没有检查数组长度
- 状态转移方程错误:混淆了dp[i-1]和dp[i-2]的关系
- 空间优化时的变量更新顺序错误:先更新first还是second有讲究
6.2 调试技巧
- 从小例子开始:先手动计算几个小例子,确保理解正确
- 打印中间变量:在循环中打印first和second的值,观察变化
- 对比完整dp数组:可以先实现O(n)空间的版本,再优化到O(1)空间
- 使用断言:添加assert检查不变量
7. 算法优化与扩展思考
7.1 是否可以进一步优化?
当前的算法已经是最优的时空复杂度,但可以考虑:
- 提前终止:如果剩余房屋的金额都是0,可以提前结束循环
- 并行计算:对于超大数组,可以考虑分段计算(但会增加复杂度)
7.2 如何记录具体偷了哪些房屋?
如果需要输出具体偷了哪些房屋而不仅仅是最大金额,我们需要:
- 维护一个额外的数组记录选择
- 在状态转移时记录是否选择了当前房屋
- 最后反向追踪得到具体方案
这会使空间复杂度回到O(n),但仍然是线性时间。
8. 不同语言实现要点
虽然我们分析了C++实现,但算法思想是语言无关的。在其他语言中实现时需要注意:
- Python:注意列表索引从0开始,处理空列表的方式
- Java:数组长度获取方式不同,注意边界检查
- JavaScript:动态类型语言,需要确保数值比较正确
9. 实际编码中的注意事项
- 变量命名:first和second虽然简洁,但在实际项目中可以考虑更描述性的名字,如prevPrev和prev
- 代码注释:虽然算法简单,但添加简要注释说明first和second的含义会更好
- 防御性编程:即使题目保证输入有效,实际项目中还是应该检查输入合法性
- 单元测试:编写测试用例覆盖各种边界情况
10. 学习动态规划的建议
这道题是理解动态规划的绝佳起点。建议学习者:
- 先写出完整的dp数组解法,再优化空间
- 画图辅助理解状态转移过程
- 尝试自己推导状态转移方程
- 从简单问题开始,逐步增加难度
- 多做同类题目,寻找模式
动态规划的关键在于识别子问题和状态转移关系,这需要大量练习来培养直觉。