1. 问题背景与核心理解
第一次看到这个题目时,我正坐在星巴克刷着手机上的LeetCode通知。198.打家劫舍——这个标题立刻让我联想到警匪片里的场景,但题目描述却给出了完全不同的设定:
你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,唯一制约你偷窃的限制是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚被闯入,系统会自动报警。
这实际上是个经典的动态规划问题。我曾在某次技术面试中被问到过类似的变种题,当时因为对状态转移理解不够透彻而表现不佳。经过反复练习后,我发现这类问题有几个关键特征:
- 决策具有后效性——当前选择会影响后续选择
- 需要保存中间状态避免重复计算
- 最优解通常由子问题的最优解组合而成
2. 暴力解法与问题分析
2.1 穷举所有可能性
最直观的解法是考虑所有可能的偷窃组合。对于n间房屋,每个房屋有偷/不偷两种选择,但需要满足不能连续偷窃的限制条件。这种解法的时间复杂度是O(2^n),当n=100时计算量会达到惊人的1.26e+30次操作。
python复制def rob_naive(nums):
def helper(index):
if index >= len(nums):
return 0
# 选择偷当前房屋,则必须跳过下一个
steal = nums[index] + helper(index + 2)
# 不偷当前房屋,则考虑下一个
skip = helper(index + 1)
return max(steal, skip)
return helper(0)
2.2 重叠子问题识别
通过递归树分析会发现存在大量重复计算。比如计算helper(3)时可能需要helper(5),而计算helper(2)时又需要helper(5)。这正是动态规划适用的典型场景——具有最优子结构和重叠子问题特性。
3. 动态规划解法详解
3.1 状态定义与转移方程
我们定义dp[i]表示前i间房屋能偷窃到的最高金额。对于第i间房屋,我们有两种选择:
- 偷窃第i间房屋:则不能偷窃第i-1间,总金额为nums[i] + dp[i-2]
- 不偷窃第i间房屋:总金额保持为dp[i-1]
因此状态转移方程为:
dp[i] = max(dp[i-1], nums[i] + dp[i-2])
3.2 基础实现
python复制def rob_dp(nums):
if not nums:
return 0
n = len(nums)
if n == 1:
return nums[0]
dp = [0] * n
dp[0] = nums[0]
dp[1] = max(nums[0], nums[1])
for i in range(2, n):
dp[i] = max(dp[i-1], nums[i] + dp[i-2])
return dp[-1]
这个实现的时间复杂度是O(n),空间复杂度也是O(n)。对于n=100的情况,只需要100次操作即可得到结果,相比暴力解法是质的飞跃。
3.3 空间优化
观察状态转移方程可以发现,dp[i]只依赖于dp[i-1]和dp[i-2],因此可以用两个变量代替整个数组:
python复制def rob_optimized(nums):
prev_max = 0 # dp[i-2]
curr_max = 0 # dp[i-1]
for num in nums:
temp = curr_max
curr_max = max(curr_max, prev_max + num)
prev_max = temp
return curr_max
优化后的空间复杂度降为O(1),这在处理大规模数据时尤为重要。
4. 边界条件与特殊测试用例
4.1 常见边界情况
- 空列表:应返回0
- 单元素列表:直接返回该元素值
- 两间房屋:返回两者中的较大值
- 全部相同金额:结果应为间隔求和
- 金额单调递增:应选择所有奇数位或偶数位房屋
4.2 测试用例设计
python复制test_cases = [
([], 0),
([5], 5),
([2,3,2], 4),
([1,2,3,1], 4),
([2,7,9,3,1], 12),
([100,1,1,100], 200),
([1,3,1,3,100], 103)
]
5. 算法扩展与变种
5.1 环形房屋排列
如果房屋排成环形(即第一间和最后一间相邻),解法需要调整。可以拆解为两个子问题:
- 不偷第一间:问题变为nums[1:]
- 不偷最后一间:问题变为nums[:-1]
取两者的最大值即可:
python复制def rob_circle(nums):
if len(nums) == 1:
return nums[0]
return max(rob_optimized(nums[:-1]), rob_optimized(nums[1:]))
5.2 二叉树房屋排列
当房屋排列形成二叉树结构时(不能同时偷直接相连的父子节点),可以采用树形DP:
python复制def rob_tree(root):
def helper(node):
if not node:
return (0, 0)
left = helper(node.left)
right = helper(node.right)
# 当前节点被偷时的最大值
rob = node.val + left[1] + right[1]
# 当前节点不被偷时的最大值
not_rob = max(left) + max(right)
return (rob, not_rob)
return max(helper(root))
6. 实际应用场景
虽然题目设定是小偷问题,但类似模型可以应用于:
- 资源调度:选择不冲突的任务组合使收益最大
- 投资决策:某些投资选项相互排斥时的最优组合
- 广告投放:不能在同一时段投放竞品广告的收益最大化
- 课程安排:选择不冲突的课程使学分总和最大
7. 常见错误与调试技巧
7.1 典型错误模式
- 初始化错误:忘记处理空列表或单元素情况
- 索引越界:在dp[1]时未检查数组长度
- 状态转移混淆:将max的两个参数写反
- 边界处理不当:环形情况下未考虑所有可能性
7.2 调试建议
- 先手动计算小规模测试用例
- 打印dp数组观察中间状态
- 对特殊输入添加断言检查
- 使用记忆化递归作为验证基准
8. 性能优化进阶
对于超大规模数据(如n>1e5),可以考虑:
- 并行计算:将数组分段处理
- 流式处理:适用于数据流场景
- 近似算法:当不需要精确解时
9. 不同语言实现要点
9.1 Java实现注意事项
java复制public int rob(int[] nums) {
int prevMax = 0;
int currMax = 0;
for (int num : nums) {
int temp = currMax;
currMax = Math.max(currMax, prevMax + num);
prevMax = temp;
}
return currMax;
}
注意Java的数组长度检查和方法命名规范。
9.2 C++实现要点
cpp复制int rob(vector<int>& nums) {
int prev = 0, curr = 0;
for (int num : nums) {
int temp = curr;
curr = max(curr, prev + num);
prev = temp;
}
return curr;
}
注意使用引用避免拷贝,以及max函数的调用方式。
10. 算法复杂度理论分析
从理论角度看,这个问题展示了:
- 如何将指数级问题转化为多项式级
- 状态压缩的空间优化技巧
- 分治思想在DP中的应用
- 问题分解与组合的艺术
理解这些核心思想比单纯记住解法更重要,因为它们可以应用于更广泛的算法问题场景。