Article / 文章

LeetCode 第213题:打家劫舍 II

你是一个专业的小偷,计划偷窃沿街的房屋,每间房内都藏有一定的现金。这个地方所有的房屋都 围成一圈 ,这意味着第一个房屋和最后一个房屋是紧挨着的。同时,相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。 给定一个代表每个房屋存放金额的非负整数数组 nums,请计算 在不触动警报装置的情况下,今晚能够偷窃到的最高金额。

题目描述

你是一个专业的小偷,计划偷窃沿街的房屋,每间房内都藏有一定的现金。这个地方所有的房屋都 围成一圈 ,这意味着第一个房屋和最后一个房屋是紧挨着的。同时,相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。

给定一个代表每个房屋存放金额的非负整数数组 nums,请计算 在不触动警报装置的情况下,今晚能够偷窃到的最高金额。

难度

中等

题目链接

点击在LeetCode中查看题目

示例

示例 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 <= 100
  • 0 <= nums[i] <= 1000

解题思路

这道题是“打家劫舍I”的扩展版本,增加了一个环形约束 - 第一间房屋和最后一间房屋相邻,不能同时偷窃。我们可以将环形问题转化为两个线性问题,然后解决:

  1. 考虑偷第一间房子,那么最后一间就不能偷 —— 即在nums[0]nums[n-2]的范围内进行打家劫舍
  2. 考虑偷最后一间房子,那么第一间就不能偷 —— 即在nums[1]nums[n-1]的范围内进行打家劫舍
  3. 取两种情况的最大值即为答案

对于每个线性问题,我们可以使用动态规划的方法解决:

  • 定义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 最优性能,内存消耗最小

补充说明

代码亮点

  1. 将环形问题拆分为两个线性问题,巧妙地避开了复杂的循环依赖
  2. 使用滚动数组优化空间复杂度,从O(n)降低到O(1)
  3. 函数封装良好,将线性打家劫舍问题抽象成独立函数,提高代码复用性
  4. 详细处理了各种边界情况,保证代码的健壮性

优化方向

  1. 现有方法已经达到了时间和空间的最优解,难以进一步优化
  2. 对于极小规模的输入,可以考虑特殊处理以减少函数调用开销

解题难点

  1. 如何处理环形约束:第一间和最后一间房屋不能同时偷
  2. 如何转化为已知问题:将环形问题转化为两个线性问题
  3. 边界条件的处理:当房屋数量较少时需要特殊处理

常见错误

  1. 忽略特殊情况的处理,如数组为空、只有一个或两个元素的情况
  2. 错误地转化环形问题,未考虑到所有可能情况
  3. 递推公式写错,导致状态转移不正确

相关题目