198. 打家劫舍#
还记得我们在爬楼梯那道题中初识动态规划的优雅吗?今天让我们通过 LeetCode 198 “打家劫舍”,进一步深入理解动态规划的思维方式。这道题不仅考察算法,更考验我们对问题的建模能力。
🏠 问题的本质#
想象你是一位特立独行的小偷,准备沿着一条街偷窃。每间房屋都存放着特定金额的现金,但有一个重要的限制:由于房屋之间有防盗系统,如果偷窃了相邻的房屋就会触发警报。
给你一个数组表示每个房屋的现金数:
输入:[2,7,9,3,1]
输出:12
解释:偷窃 2 + 9 + 1 = 12,这样不会触发相邻房屋的警报plaintext问题描述#
题目目标#
你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都有一定金额的现金,但相邻的两间房屋装有联动报警系统,如果同一晚闯入相邻的两间房,系统就会报警。给定一个代表每间房存放金额的整数数组 nums ,返回在不触发警报的情况下你能够偷窃到的最高金额。
示例 1#
输入: nums = [1,2,3,1]
输出: 4
说明: 把输入和输出放在一起看,会更容易直接抓住题目要求的变化。
🤔 从直觉到算法#
第一步:理解问题的本质#
这个问题乍看像是在玩数字游戏,但仔细想想,它其实在问:如何在一系列数字中选择一些不相邻的数,使得它们的和最大?
让我们用动态规划的思维来解析这个问题。记住一个关键点:动态规划的精髓在于,将大问题分解成小问题,并找到这些问题之间的关联。
第二步:寻找状态转移方程#
站在当前房屋前,我们面临两个选择:
- 偷这间房子:那就不能偷前一间,但可以偷前前一间
- 不偷这间房子:那就可以保持前一间房子的最优结果
这就形成了我们的状态转移方程的雏形。让我们看看如何将这种思维转化为代码:
class Solution {
// 方法一:基础动态规划解法
public int rob(int[] nums) {
// 边界情况:没有房子可偷,收益为 0
if (nums == null || nums.length == 0) return 0;
// 边界情况:只有一间房子,只能偷它
if (nums.length == 1) return nums[0];
// dp[i] 表示偷到第i间房子时能得到的最大金额
int[] dp = new int[nums.length];
// 初始化第 0 间房:只能选择偷第 0 间
dp[0] = nums[0];
// 初始化第 1 间房:在第 0 间和第 1 间中选金额更大的
dp[1] = Math.max(nums[0], nums[1]);
// 从第 2 间房开始进行状态转移
for (int i = 2; i < nums.length; i++) {
// 两种选择取最大值:
// 1) 不偷当前房子:收益为 dp[i-1]
// 2) 偷当前房子:收益为 dp[i-2] + nums[i]
dp[i] = Math.max(dp[i-1], // 不偷当前房子
dp[i-2] + nums[i] // 偷当前房子
);
}
// 最后一个状态就是偷完整条街后的最大收益
return dp[nums.length - 1];
}
// 方法二:空间优化版本
public int robOptimized(int[] nums) {
// 边界情况:空数组时无收益
if (nums == null || nums.length == 0) return 0;
// 边界情况:只有一间房时,直接返回该房金额
if (nums.length == 1) return nums[0];
// 由于 dp[i] 只依赖 dp[i-1] 和 dp[i-2],用滚动变量优化空间
int prev2 = nums[0]; // prev2 表示 dp[i-2]
int prev1 = Math.max(nums[0], nums[1]); // prev1 表示 dp[i-1]
int current = prev1; // current 表示当前计算的 dp[i]
for (int i = 2; i < nums.length; i++) {
// 计算当前最优值:偷或不偷当前房子,取最大收益
current = Math.max(prev1, prev2 + nums[i]);
// 状态前移:旧的 dp[i-1] 变成下一轮的 dp[i-2]
prev2 = prev1;
// 状态前移:当前 dp[i] 变成下一轮的 dp[i-1]
prev1 = current;
}
// 循环结束后,current 为最终最大收益
return current;
}
}
java💡 动态规划的四个关键步骤#
通过这道题,我们可以清晰地看到动态规划问题解决的四个步骤:
1. 定义状态#
在这个问题中,我们定义 dp[i] 表示”偷到第i间房子时能得到的最大金额”。这个定义既包含了我们要求解的目标(最大金额),也隐含了问题的约束(不能偷相邻房屋)。
2. 确定状态转移方程#
dp[i] = max(dp[i-1], // 不偷第i间房子
dp[i-2] + nums[i] // 偷第i间房子
)plaintext这个方程体现了”偷还是不偷”的两个选择。
3. 初始化基础状态#
对于打家劫舍问题:
- dp[0] = nums[0]:只有一间房子时,就偷这间
- dp[1] = max(nums[0], nums[1]):两间房子时,选较大的偷
4. 确定计算顺序#
从左到右遍历房子,逐步构建最优解。这个顺序保证了计算某个状态时,它依赖的状态都已经计算出来了。
🔍 空间优化的艺术#
注意观察状态转移方程,我们发现每次计算只需要用到前两个状态。这就提供了优化空间的机会:不需要存储整个dp数组,只需要三个变量就够了。这种优化在很多动态规划问题中都很常见。
🎯 举一反三#
这个问题的核心思想可以扩展到很多类似场景:
- 打家劫舍 II(房子围成一圈)
- 粉刷房子(每个房子可以刷不同的颜色,相邻不能相同)
- 最大子序列和(可以不连续)
💡 思考题#
如果现在不是简单的偷或不偷,而是每间房子都有一个”报警概率”,如何修改我们的动态规划方程?提示:可能需要考虑期望收益。
📝 面试技巧#
在面试中遇到这类问题,建议这样展示你的思路:
首先,明确问题的核心约束 - 不能偷相邻房屋。然后,用人性化的语言解释选择 - “对于每间房子,我们面临偷或不偷的选择”。最后,逐步推导出状态转移方程,并解释为什么这个方程是正确的。
记住,动态规划不仅是一种解法,更是一种思维方式。它教会我们如何将复杂问题分解成简单的子问题,并通过巧妙的状态设计找到最优解。
动态规划的美,不仅在于它能解决复杂的问题,更在于它能让我们以优雅的方式思考问题。希望这篇文章能帮助你更深入地理解动态规划的精髓!