1. 问题解析与解题思路
1.1 题目重述
这是一个经典的动态规划问题:假设你是一个专业的小偷,计划偷窃沿街的房屋。每间房内有一定金额的现金(用数组nums表示),但相邻的房屋装有联动报警系统,如果同一晚偷了相邻两间房,就会触发报警。我们的目标是计算在不触发报警的前提下,一晚能偷到的最高金额。
1.2 问题本质分析
这个问题本质上是一个线性动态规划问题,具有以下特点:
- 决策序列:房屋是线性排列的,需要按顺序做出决策
- 状态依赖:当前决策会影响后续决策(偷了当前房屋就不能偷相邻的下一个)
- 最优子结构:全局最优解可以通过子问题的最优解组合得到
1.3 解题思路总览
解决这类问题的核心思路是定义状态和状态转移方程。对于每个房屋i,我们有两种选择:
- 偷第i间房屋:那么就不能偷第i-1间,最大金额为前i-2间的最大金额加上当前房屋的金额
- 不偷第i间房屋:最大金额就是前i-1间的最大金额
因此,状态转移方程为:
dp[i] = max(dp[i-1], dp[i-2] + nums[i])
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 四种解法详细解析
2.1 纯递归解法(回溯思想)
2.1.1 实现原理
纯递归解法是最直观的暴力解法,直接按照状态转移方程进行递归计算。对于每个房屋i,递归计算两种情况的最大值。
java复制class Solution {
public int rob(int[] nums) {
return dfs(nums, nums.length - 1);
}
private int dfs(int[] nums, int i) {
if (i < 0) return 0;
if (i == 0) return nums[0];
return Math.max(dfs(nums, i - 1), dfs(nums, i - 2) + nums[i]);
}
}
2.1.2 复杂度分析
- 时间复杂度:O(2^n),因为每个问题会分解为两个子问题
- 空间复杂度:O(n),递归栈的深度
注意事项:这种方法虽然直观,但由于存在大量重复计算,在LeetCode上会超时,仅适合理解问题本质。
2.2 记忆化递归(自顶向下DP)
2.2.1 优化思路
记忆化递归在纯递归的基础上增加了缓存机制,将已经计算过的结果存储起来,避免重复计算。
java复制import java.util.Arrays;
class Solution {
private int[] memo;
public int rob(int[] nums) {
memo = new int[nums.length];
Arrays.fill(memo, -1);
return dfs(nums, nums.length - 1);
}
private int dfs(int[] nums, int i) {
if (i < 0) return 0;
if (memo[i] != -1) return memo[i];
int ans = Math.max(dfs(nums, i - 1), dfs(nums, i - 2) + nums[i]);
memo[i] = ans;
return ans;
}
}
2.2.2 复杂度分析
- 时间复杂度:O(n),每个子问题只计算一次
- 空间复杂度:O(n),用于存储memo和递归栈
实操心得:记忆化递归是纯递归到动态规划的过渡形式,理解它有助于掌握动态规划的本质。
2.3 动态规划数组(自底向上)
2.3.1 实现原理
自底向上的动态规划通过迭代方式填充DP数组,避免了递归的开销。我们定义dp[i]表示前i+1间房屋能偷到的最大金额。
java复制class Solution {
public int rob(int[] nums) {
int n = nums.length;
if (n == 1) return nums[0];
int[] dp = new int[n];
dp[0] = nums[0];
dp[1] = Math.max(nums[0], nums[1]);
for (int i = 2; i < n; i++) {
dp[i] = Math.max(dp[i - 1], dp[i - 2] + nums[i]);
}
return dp[n - 1];
}
}
2.3.2 边界处理
- 当数组长度为1时,直接返回nums[0]
- 初始化dp[0]和dp[1]需要特殊处理
注意事项:这种写法是面试中最常见的动态规划实现方式,需要熟练掌握。
2.4 空间优化DP(推荐)
2.4.1 优化思路
观察状态转移方程,发现dp[i]只依赖于dp[i-1]和dp[i-2],因此可以用两个变量代替整个DP数组。
java复制class Solution {
public int rob(int[] nums) {
int prev2 = 0; // dp[i-2]
int prev1 = 0; // dp[i-1]
for (int x : nums) {
int cur = Math.max(prev1, prev2 + x);
prev2 = prev1;
prev1 = cur;
}
return prev1;
}
}
2.4.2 变量含义
- prev2:相当于dp[i-2]
- prev1:相当于dp[i-1]
- cur:当前计算的最大值
实操心得:这种写法不仅空间效率高,而且代码简洁,是实际项目中最推荐的实现方式。
3. 示例推演与复杂度对比
3.1 示例推演(以[2,7,9,3,1]为例)
| i | nums[i] | 不偷(dp[i-1]) | 偷(dp[i-2]+nums[i]) | dp[i] |
|---|---|---|---|---|
| 0 | 2 | - | - | 2 |
| 1 | 7 | 2 | 7 | 7 |
| 2 | 9 | 7 | 2+9=11 | 11 |
| 3 | 3 | 11 | 7+3=10 | 11 |
| 4 | 1 | 11 | 11+1=12 | 12 |
最终结果为12,对应偷第1、3、5间房屋。
3.2 各解法性能对比
| 解法 | 时间复杂度 | 空间复杂度 | 适用场景 |
|---|---|---|---|
| 纯递归 | O(2^n) | O(n) | 仅用于理解问题 |
| 记忆化递归 | O(n) | O(n) | 喜欢递归写法时 |
| DP数组 | O(n) | O(n) | 面试常规写法 |
| 空间优化DP | O(n) | O(1) | 实战推荐 |
4. 常见问题与扩展思考
4.1 边界条件处理
在实际编码中,有几个边界条件需要特别注意:
- 空数组情况:应该返回0
- 单元素数组:直接返回该元素
- 双元素数组:返回较大的那个
避坑技巧:在面试中,一定要主动提出并处理这些边界条件,这能体现你的代码严谨性。
4.2 算法选择建议
根据不同的场景选择合适的解法:
- 面试中:推荐使用DP数组写法,便于解释
- 竞赛中:使用空间优化DP,效率更高
- 学习时:从纯递归开始,逐步优化
4.3 问题变种思考
这个问题有几个常见的变种:
- 房屋排成环形(LeetCode 213)
- 房屋是二叉树结构(LeetCode 337)
- 增加其他约束条件(如最多连续偷k间)
理解基础问题的解法后,可以尝试解决这些变种问题来巩固知识。
4.4 调试技巧
在实现动态规划时,可以采用以下调试方法:
- 打印DP表格,验证每个状态的值
- 对于递归解法,添加日志打印递归调用过程
- 使用小规模测试用例手动计算验证
我在实际刷题中发现,对于动态规划问题,先手动计算一个小例子往往能帮助快速发现代码中的问题。比如对于输入[2,7,9,3,1],可以先用纸笔计算出每一步的dp值,再与程序输出对比。
