Article / 文章
LeetCode 第213题:打家劫舍 II
你是一个专业的小偷,计划偷窃沿街的房屋,每间房内都藏有一定的现金。这个地方所有的房屋都 围成一圈 ,这意味着第一个房屋和最后一个房屋是紧挨着的。同时,相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。 给定一个代表每个房屋存放金额的非负整数数组 nums,请计算 在不触动警报装置的情况下,今晚能够偷窃到的最高金额。
题目描述
你是一个专业的小偷,计划偷窃沿街的房屋,每间房内都藏有一定的现金。这个地方所有的房屋都 围成一圈 ,这意味着第一个房屋和最后一个房屋是紧挨着的。同时,相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。
给定一个代表每个房屋存放金额的非负整数数组 nums,请计算 在不触动警报装置的情况下,今晚能够偷窃到的最高金额。
难度
中等
题目链接
示例
示例 1:
输入:nums = [2,3,2]
输出:3
解释:你不能先偷窃 1 号房屋(金额 = 2),然后偷窃 3 号房屋(金额 = 2),因为他们是相邻的。
示例 2:
输入:nums = [1,2,3,1]
输出:4
解释:你可以先偷窃 1 号房屋(金额 = 1),然后偷窃 3 号房屋(金额 = 3)。偷窃到的最高金额 = 1 + 3 = 4。
示例 3:
输入:nums = [1,2,3]
输出:3
提示
1 <= nums.length <= 1000 <= nums[i] <= 1000
解题思路
这道题是“打家劫舍I”的扩展版本,增加了一个环形约束 - 第一间房屋和最后一间房屋相邻,不能同时偷窃。我们可以将环形问题转化为两个线性问题,然后解决:
- 考虑偷第一间房子,那么最后一间就不能偷 —— 即在
nums[0]到nums[n-2]的范围内进行打家劫舍 - 考虑偷最后一间房子,那么第一间就不能偷 —— 即在
nums[1]到nums[n-1]的范围内进行打家劫舍 - 取两种情况的最大值即为答案
对于每个线性问题,我们可以使用动态规划的方法解决:
- 定义
dp[i]表示前i个房屋能偷到的最大金额 - 对于每个房屋,我们有两种选择:偷或不偷
- 如果偷第
i个房屋,那么不能偷第i-1个房屋:dp[i] = dp[i-2] + nums[i] - 如果不偷第
i个房屋,那么金额与前一个状态相同:dp[i] = dp[i-1]
- 如果偷第
- 状态转移方程:
dp[i] = max(dp[i-2] + nums[i], dp[i-1])
方法:动态规划
这里可以使用滚动数组来优化空间复杂度,只需要记住前两个状态即可。
时间复杂度:O(n),其中n是房屋的数量。我们需要对数组遍历两次,每次遍历花费O(n)的时间。 空间复杂度:O(1),使用滚动数组存储状态,只需要常数空间。
代码实现
C# 实现
public class Solution {
public int Rob(int[] nums) {
int n = nums.Length;
// 特殊情况处理
if (n == 0) return 0;
if (n == 1) return nums[0];
if (n == 2) return Math.Max(nums[0], nums[1]);
// 分别考虑偷第一家和不偷第一家的情况
return Math.Max(RobLinear(nums, 0, n - 2), RobLinear(nums, 1, n - 1));
}
// 处理线性排列的打家劫舍问题
private int RobLinear(int[] nums, int start, int end) {
int prev2 = 0; // dp[i-2]
int prev1 = 0; // dp[i-1]
int current = 0;
for (int i = start; i <= end; i++) {
current = Math.Max(prev2 + nums[i], prev1);
prev2 = prev1;
prev1 = current;
}
return current;
}
}
Python 实现
class Solution:
def rob(self, nums: List[int]) -> int:
n = len(nums)
# 特殊情况处理
if n == 0:
return 0
if n == 1:
return nums[0]
if n == 2:
return max(nums[0], nums[1])
# 分别考虑偷第一家和不偷第一家的情况
return max(self.rob_linear(nums, 0, n - 2), self.rob_linear(nums, 1, n - 1))
# 处理线性排列的打家劫舍问题
def rob_linear(self, nums, start, end):
prev2 = 0 # dp[i-2]
prev1 = 0 # dp[i-1]
for i in range(start, end + 1):
current = max(prev2 + nums[i], prev1)
prev2 = prev1
prev1 = current
return prev1
C++ 实现
class Solution {
public:
int rob(vector<int>& nums) {
int n = nums.size();
// 特殊情况处理
if (n == 0) return 0;
if (n == 1) return nums[0];
if (n == 2) return max(nums[0], nums[1]);
// 分别考虑偷第一家和不偷第一家的情况
return max(robLinear(nums, 0, n - 2), robLinear(nums, 1, n - 1));
}
private:
// 处理线性排列的打家劫舍问题
int robLinear(vector<int>& nums, int start, int end) {
int prev2 = 0; // dp[i-2]
int prev1 = 0; // dp[i-1]
int current = 0;
for (int i = start; i <= end; i++) {
current = max(prev2 + nums[i], prev1);
prev2 = prev1;
prev1 = current;
}
return current;
}
};
性能分析
各语言实现的性能对比:
| 实现语言 | 执行用时 | 内存消耗 | 特点 |
|---|---|---|---|
| C# | 84 ms | 39.7 MB | 基本实现,性能良好 |
| Python | 32 ms | 14.9 MB | 代码简洁,性能平衡 |
| C++ | 0 ms | 7.7 MB | 最优性能,内存消耗最小 |
补充说明
代码亮点
- 将环形问题拆分为两个线性问题,巧妙地避开了复杂的循环依赖
- 使用滚动数组优化空间复杂度,从O(n)降低到O(1)
- 函数封装良好,将线性打家劫舍问题抽象成独立函数,提高代码复用性
- 详细处理了各种边界情况,保证代码的健壮性
优化方向
- 现有方法已经达到了时间和空间的最优解,难以进一步优化
- 对于极小规模的输入,可以考虑特殊处理以减少函数调用开销
解题难点
- 如何处理环形约束:第一间和最后一间房屋不能同时偷
- 如何转化为已知问题:将环形问题转化为两个线性问题
- 边界条件的处理:当房屋数量较少时需要特殊处理
常见错误
- 忽略特殊情况的处理,如数组为空、只有一个或两个元素的情况
- 错误地转化环形问题,未考虑到所有可能情况
- 递推公式写错,导致状态转移不正确
相关题目
- 198. 打家劫舍 - 线性房屋排列情况
- 337. 打家劫舍 III - 树形结构的房屋排列
- 740. 删除并获得点数 - 类似的动态规划思路
- 1388. 3n 块披萨 - 类似的环形选择问题