1. 动态规划经典问题:打家劫舍系列解析
今天我们来拆解算法训练中经典的"打家劫舍"问题系列,包含三个不同变种:基础版(198题)、环形版(213题)和二叉树版(337题)。这三个问题层层递进,是动态规划(DP)应用的绝佳案例。作为算法工程师,我经常在面试中考察这类问题,因为它能很好地检验候选人对DP思想的理解深度。
打家劫舍问题的核心在于:在不触发警报的情况下,如何选择最优的房屋组合使得总收益最大化。警报触发的条件是相邻房屋在同一晚被打劫。三个变种分别对应不同的房屋排列方式——线性排列、环形排列和二叉树排列。我们将从最简单的线性DP开始,逐步深入到需要结合树形DP的复杂场景。
提示:建议按顺序学习这三个问题,理解DP思想如何从一维扩展到二维,再到树形结构。这是掌握动态规划思维的关键路径。
2. 基础版:线性排列房屋(198.打家劫舍)
2.1 问题描述与状态定义
给定一个非负整数数组nums,表示沿街排列的房屋中的金额。作为专业劫匪,你需要在不触动警报的情况下(不能连续打劫相邻房屋),计算今晚能盗取的最高金额。
示例:
输入:[2,7,9,3,1]
输出:12
解释:选择第1、3、5号房屋(2+9+1=12)
状态定义是DP问题的核心。对于这类最值问题,我们通常定义dp[i]为考虑前i个房屋时能获得的最大金额。关键在于理解"考虑"的含义——它不一定包含第i个房屋,只是表示决策进行到了第i个位置。
2.2 状态转移方程推导
对于第i个房屋,我们有两个选择:
- 打劫它:那么不能打劫i-1,最大金额为dp[i-2] + nums[i]
- 不打劫它:最大金额保持为dp[i-1]
因此状态转移方程为:
dp[i] = max(dp[i-1], dp[i-2] + nums[i])
边界条件:
dp[0] = nums[0] (只有一间房时必选)
dp[1] = max(nums[0], nums[1]) (两间房选金额大的)
2.3 空间优化与实现
基础实现需要O(n)空间,但观察发现dp[i]只依赖前两个状态,因此可以优化到O(1)空间:
python复制def rob(nums):
if not nums: return 0
prev1 = prev2 = 0
for num in nums:
curr = max(prev1, prev2 + num)
prev2, prev1 = prev1, curr
return prev1
注意:边界处理很重要。当nums为空时返回0,单元素时返回该元素值。这是面试中常见的考察点。
3. 进阶版:环形排列房屋(213.打家劫舍II)
3.1 环形带来的新约束
房屋现在排列成环形,意味着第一个和最后一个房屋也相邻。这打破了线性DP的假设,我们需要新的处理方式。
关键思路:将环形问题拆解为两个线性问题:
- 不抢第一间房,问题转化为nums[1:]的线性打家劫舍
- 不抢最后一间房,问题转化为nums[:-1]的线性打家劫舍
最终结果是这两种情况的最大值。
3.2 实现细节
python复制def rob(nums):
def rob_range(start, end):
prev1 = prev2 = 0
for i in range(start, end):
curr = max(prev1, prev2 + nums[i])
prev2, prev1 = prev1, curr
return prev1
if not nums: return 0
n = len(nums)
if n == 1: return nums[0]
return max(rob_range(0, n-1), rob_range(1, n))
3.3 常见错误分析
- 直接套用线性解法:忽略首尾相连的特性,导致同时选中首尾房屋
- 边界处理不当:当nums长度为1时需要特殊处理
- 空间优化错误:在拆分两个子问题时重复计算导致空间浪费
4. 高阶版:二叉树排列房屋(337.打家劫舍III)
4.1 树形DP的概念引入
房屋现在排列在二叉树上,不能同时打劫直接相连的父子节点。这需要我们将DP思想扩展到树形结构。
树形DP通常采用后序遍历,因为需要先知道子节点的结果才能计算当前节点的决策。对于每个节点,我们需要记录两个状态:
- 选择当前节点时的最大收益
- 不选择当前节点时的最大收益
4.2 状态定义与转移
定义返回值为一个二元组(select, not_select),表示选择/不选择当前节点时的最大收益。
对于任意节点:
- 若选择当前节点,则不能选择其子节点:
select = node.val + left.not_select + right.not_select - 若不选择当前节点,则可自由选择子节点(选或不选中更大的):
not_select = max(left.select, left.not_select) + max(right.select, right.not_select)
4.3 递归实现与优化
基础递归实现:
python复制def rob(root):
def dfs(node):
if not node: return (0, 0)
left = dfs(node.left)
right = dfs(node.right)
select = node.val + left[1] + right[1]
not_select = max(left[0], left[1]) + max(right[0], right[1])
return (select, not_select)
res = dfs(root)
return max(res[0], res[1])
记忆化优化:对于大规模树结构,可以使用哈希表缓存已计算过的节点结果,避免重复计算。
4.4 迭代实现(后序遍历)
递归可能引发栈溢出,迭代实现更安全:
python复制def rob(root):
if not root: return 0
stack = [(root, False)]
memo = {}
while stack:
node, visited = stack.pop()
if visited:
left = memo.get(node.left, (0, 0))
right = memo.get(node.right, (0, 0))
select = node.val + left[1] + right[1]
not_select = max(left[0], left[1]) + max(right[0], right[1])
memo[node] = (select, not_select)
else:
stack.append((node, True))
if node.right: stack.append((node.right, False))
if node.left: stack.append((node.left, False))
return max(memo[root][0], memo[root][1])
5. 打家劫舍系列总结与扩展
5.1 三种变体的对比分析
| 问题类型 | 数据结构 | DP维度 | 关键技巧 | 时间复杂度 |
|---|---|---|---|---|
| 基础版 | 数组 | 一维 | 状态压缩 | O(n) |
| 环形版 | 环形数组 | 一维 | 问题分解 | O(n) |
| 树形版 | 二叉树 | 树形 | 后序遍历 | O(n) |
5.2 面试中的常见考察点
- 从线性到环形的思维转变能力
- 树形DP的状态定义和转移方程设计
- 边界条件的全面考虑
- 空间复杂度的优化意识
- 递归与迭代的转换能力
5.3 实际应用场景
这类问题看似是"劫匪问题",实则广泛应用于:
- 资源分配优化(如服务器任务调度)
- 投资组合选择(避免相邻风险)
- 路径规划(避开相邻检查点)
- 游戏AI决策(收益最大化)
5.4 进一步挑战
掌握了这三个经典问题后,可以尝试以下变种:
- 房屋之间有更复杂的约束关系(如间隔k个房屋)
- 多维排列的房屋(如矩阵中的房屋)
- 每次打劫有概率触发警报(引入概率DP)
- 需要输出具体的选择方案而不仅是最大金额
在解决这些问题时,我发现动态规划最难的部分不是写出代码,而是准确识别子问题并定义状态。建议从暴力递归开始思考,然后逐步优化到DP解法。对于树形问题,多画图分析节点间的依赖关系非常有助于理解状态转移。