1. 动态规划经典问题:打家劫舍系列解析
今天我们来拆解算法训练中经典的打家劫舍问题系列,包含三个不同变种:线性排列(198题)、环形排列(213题)和二叉树结构(337题)。这三个问题层层递进,是掌握动态规划思想不可多得的练手案例。
作为算法工程师,这类问题在实际业务中经常变形出现。比如风控系统中的异常交易检测、游戏中的资源最优收集路径等场景,本质上都是类似的决策过程。下面我将结合自己刷题和面试的经验,带大家系统掌握这三个问题的解法套路。
2. 基础版:线性房屋排列(198题)
2.1 问题描述与建模
给定一个非负整数数组nums,表示沿街排列的房屋中的金额。相邻房屋装有联动的报警系统,如果两间相邻的房屋在同一晚被打劫,系统会自动报警。求在不触发警报的情况下,能够盗取的最高金额。
示例:
输入:[2,7,9,3,1]
输出:12(选择第1、3、5号房屋)
2.2 动态规划解法
这是最基础的动态规划问题,定义dp[i]表示前i间房屋能获得的最大金额:
python复制def rob(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], dp[i-2] + nums[i])
return dp[-1]
关键点:
- 状态转移方程:dp[i] = max(不抢当前房屋=dp[i-1], 抢当前房屋=dp[i-2]+nums[i])
- 边界条件处理:当房屋数≤2时的特殊情况
实际编码时可以优化空间复杂度到O(1),只需维护前两个状态即可
2.3 常见错误与调试
- 忘记处理空数组或单元素数组的特殊情况
- 错误地将dp数组初始化为nums的拷贝
- 循环起始位置错误(应从索引2开始)
3. 进阶版:环形房屋排列(213题)
3.1 问题变化与拆解
现在房屋排列成环形,即第一间和最后一间相邻。这意味着:
- 如果抢了第一间,就不能抢最后一间
- 如果不抢第一间,就可以考虑最后一间
因此可以将问题拆解为两个子问题:
- 不考虑最后一间房屋(即nums[0:n-1])
- 不考虑第一间房屋(即nums[1:n])
然后取两者的最大值。
3.2 代码实现
python复制def rob(nums):
def helper(start, end):
prev2 = prev1 = 0
for i in range(start, end+1):
curr = max(prev1, prev2 + nums[i])
prev2, prev1 = prev1, curr
return prev1
n = len(nums)
if n == 1:
return nums[0]
return max(helper(0, n-2), helper(1, n-1))
3.3 性能优化技巧
- 复用基础版的解法函数,避免重复编码
- 使用滚动数组优化空间复杂度
- 提前终止条件:当n≤2时可直接返回结果
4. 高阶版:二叉树房屋排列(337题)
4.1 问题转化与树形DP
现在房屋排列成二叉树结构,直接相连的节点不能同时被打劫。这需要我们在树上进行动态规划。
定义每个节点有两个状态:
- 选中当前节点时的最大值
- 不选中当前节点时的最大值
4.2 后序遍历解法
python复制def rob(root):
def dfs(node):
if not node:
return (0, 0)
left = dfs(node.left)
right = dfs(node.right)
# 选择当前节点:不能选子节点
selected = node.val + left[1] + right[1]
# 不选当前节点:子节点可选可不选
not_selected = max(left) + max(right)
return (selected, not_selected)
return max(dfs(root))
4.3 复杂度分析
- 时间复杂度:O(n),每个节点访问一次
- 空间复杂度:O(h),递归栈的深度(h为树高)
5. 三题对比与总结
| 特征 | 198题(线性) | 213题(环形) | 337题(二叉树) |
|---|---|---|---|
| 数据结构 | 数组 | 环形数组 | 二叉树 |
| DP维度 | 一维 | 一维(拆解) | 二维(节点状态) |
| 关键转移方程 | max(dp[i-1], dp[i-2]+nums[i]) | 拆解为两个线性问题 | max(选当前+不选子节点,不选当前+子节点最优) |
| 空间优化 | 滚动数组 | 同左 | 递归栈 |
6. 实战经验分享
- 调试技巧:对于树形DP,可以先手动计算小树的预期结果,再与程序输出对比
- 边界测试:特别注意空输入、单元素、两个元素等边界情况
- 面试要点:
- 先讲清楚状态定义和转移方程
- 讨论空间优化方案
- 主动分析时间/空间复杂度
- 举一反三:这类问题可以扩展到图结构(如不相邻节点最大和)、资源分配等问题
7. 扩展思考
在实际工程中,这类问题有很多变种:
- 带权重的任务调度(不能同时执行有冲突的任务)
- 广告投放优化(不能在同一用户群体重复投放)
- 服务器资源分配(避免相邻节点过载)
理解这类问题的本质是:在约束条件下做出最优决策。掌握动态规划的核心在于:
- 定义合适的状态
- 找出状态转移关系
- 处理好边界条件
建议在解决这三个问题后,可以尝试LeetCode上的其他DP问题(如股票买卖系列、背包问题等)来巩固这一思想。