1. 问题背景与题目解析
"打家劫舍"是力扣(LeetCode)平台上经典的动态规划问题,编号为198题,属于力扣热题100(Hot 100)中的高频面试题。题目描述如下:
假设你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素是相邻的房屋装有相互连通的防盗系统。如果两间相邻的房屋在同一晚上被闯入,系统会自动报警。
给定一个代表每个房屋存放金额的非负整数数组,计算你在不触动警报装置的情况下,能够偷窃到的最高金额。
示例:
输入:[1,2,3,1]
输出:4
解释:偷窃第1号房屋(金额=1),然后偷窃第3号房屋(金额=3),总计4。
输入:[2,7,9,3,1]
输出:12
解释:偷窃第1号房屋(金额=2),偷窃第3号房屋(金额=9),偷窃第5号房屋(金额=1),总计12。
1.1 问题本质分析
这道题本质上是一个约束条件下的最优化问题。我们需要在一系列连续的选项中做出选择(偷或不偷每个房屋),且选择之间存在相互制约关系(不能连续选择相邻项),目标是最大化选择项的总和。
这类问题通常可以通过动态规划(Dynamic Programming, DP)来解决,因为:
- 问题可以分解为相似的子问题
- 子问题之间存在重叠
- 存在最优子结构(全局最优解包含局部最优解)
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 动态规划解法详解
2.1 状态定义与转移方程
我们定义dp[i]表示前i个房屋能偷窃到的最高金额。对于第i个房屋,我们有两种选择:
- 偷窃第i个房屋:那么第i-1个房屋就不能偷窃,此时总金额为dp[i-2] + nums[i]
- 不偷窃第i个房屋:那么总金额保持为dp[i-1]
因此,状态转移方程为:
dp[i] = max(dp[i-1], dp[i-2] + nums[i])
2.2 初始条件处理
对于前两个房屋,我们需要特殊处理:
- dp[0] = nums[0](只有一间房屋时只能偷它)
- dp[1] = max(nums[0], nums[1])(两间房屋时偷金额较大的那间)
2.3 完整算法实现
python复制def rob(nums):
if not nums:
return 0
if len(nums) == 1:
return nums[0]
dp = [0] * len(nums)
dp[0] = nums[0]
dp[1] = max(nums[0], nums[1])
for i in range(2, len(nums)):
dp[i] = max(dp[i-1], dp[i-2] + nums[i])
return dp[-1]
2.4 空间复杂度优化
观察状态转移方程,我们发现dp[i]只依赖于dp[i-1]和dp[i-2],因此可以用两个变量代替整个dp数组,将空间复杂度从O(n)降到O(1):
python复制def rob(nums):
if not nums:
return 0
if len(nums) == 1:
return nums[0]
prev2 = nums[0]
prev1 = max(nums[0], nums[1])
for i in range(2, len(nums)):
current = max(prev1, prev2 + nums[i])
prev2, prev1 = prev1, current
return prev1
3. 算法正确性证明与边界条件
3.1 数学归纳法证明
我们可以用数学归纳法证明这个动态规划解法的正确性:
基础情况:
- 当n=1时,显然正确
- 当n=2时,取最大值,正确
归纳假设:
假设对于所有k < i,dp[k]都是正确的。
归纳步骤:
对于dp[i],根据定义,它要么包含nums[i](此时不能包含nums[i-1]),要么不包含nums[i]。这两种情况分别对应dp[i-2] + nums[i]和dp[i-1],取最大值即可。根据归纳假设,dp[i-1]和dp[i-2]都是正确的,因此dp[i]也是正确的。
3.2 边界条件测试
需要特别注意以下边界情况:
- 空数组:应该返回0
- 单元素数组:返回该元素值
- 两元素数组:返回较大值
- 所有房屋金额相同的情况
- 金额呈递增或递减排列的情况
4. 时间复杂度分析
- 时间复杂度:O(n),只需要一次遍历数组
- 空间复杂度:
- 原始DP解法:O(n)
- 优化后解法:O(1)
5. 变种问题与扩展思考
5.1 环形排列的房屋(力扣213题)
如果房屋排成一个环形,即第一个和最后一个房屋也相邻,该如何解决?
解法思路:
- 分解为两个子问题:
- 不偷第一个房屋,求解nums[1...n-1]
- 不偷最后一个房屋,求解nums[0...n-2]
- 取这两个子问题的最大值
python复制def rob(nums):
if not nums:
return 0
if len(nums) == 1:
return nums[0]
def helper(start, end):
prev2 = 0
prev1 = 0
for i in range(start, end + 1):
current = max(prev1, prev2 + nums[i])
prev2, prev1 = prev1, current
return prev1
return max(helper(0, len(nums)-2), helper(1, len(nums)-1))
5.2 二叉树排列的房屋(力扣337题)
如果房屋排列成二叉树结构,不能同时偷窃直接相连的两个房屋,该如何解决?
解法思路:
对于每个节点,有两种状态:
- 偷当前节点:那么不能偷子节点,但可以偷子节点的子节点
- 不偷当前节点:可以偷子节点
使用后序遍历,返回一个二元组(偷当前节点的最大值,不偷当前节点的最大值)
python复制def rob(root):
def helper(node):
if not node:
return (0, 0)
left = helper(node.left)
right = helper(node.right)
# 偷当前节点
rob_current = node.val + left[1] + right[1]
# 不偷当前节点
not_rob_current = max(left) + max(right)
return (rob_current, not_rob_current)
return max(helper(root))
6. 面试中的常见考察点
在技术面试中,面试官可能会从以下几个角度考察这个问题:
- 能否识别出这是动态规划问题
- 能否正确推导出状态转移方程
- 能否处理边界条件
- 能否进行空间优化
- 能否扩展到变种问题(环形、树形)
- 能否分析时间/空间复杂度
7. 实际应用场景
虽然题目设定是小偷问题,但类似的决策模型可以应用于:
- 资源分配问题:在时间或空间上有冲突约束的资源分配
- 任务调度:不能同时执行有冲突的任务,最大化收益
- 投资选择:某些投资选项之间存在互斥关系
- 基因序列分析:选择不重叠的子序列
8. 解题心得与注意事项
-
动态规划问题的关键在于状态定义和转移方程,要确保状态能够完整描述问题,且转移方程覆盖所有可能性。
-
对于约束条件问题,通常可以通过"选或不选当前元素"的思路来分解问题。
-
空间优化是常见的后续问题,先写出清晰的原始DP解法,再考虑优化。
-
注意边界条件,特别是空输入、单元素、两元素等情况。
-
对于变种问题,思考如何将原问题的解法进行调整或组合。
-
在面试中,建议先解释思路,再写代码,最后测试几个例子。
