目录

题目描述

LCR 090. 打家劫舍 II

题意分析

与线性版打家劫舍相比只改了一处:所有房屋围成一圈,第一间和最后一间也是相邻的。仍然不能偷相邻的两家,求最大金额。

环形带来的唯一新增约束就是「首尾不能同时被偷」。除此之外,圈上任何两家的相邻关系与排成一行时完全一致。把这一点看清楚,题目就从「环上的 DP」退化成了「加一条互斥约束的线性 DP」,难度大幅下降。

关键推理是对第一间房做分类讨论:要么不偷第一间,那么最后一间的限制就消失了,可选范围是 nums[1..n-1] 这段线性区间;要么偷第一间,那么最后一间必然不能偷,可选范围是 nums[0..n-2] 这段线性区间。两种情况覆盖了全部合法方案,取较大者即为答案。

注意第二种情况我们说的是「可选范围是 nums[0..n-2]」而非「必须偷第一间」。这是一个容易被质疑的松弛:nums[0..n-2] 上的最优解可能并不包含 nums[0]。但这不影响正确性——它只是把「不偷首也不偷尾」的方案在两次计算里各算了一遍,而这些方案本来就是合法的,重复计算不会引入非法解,也不会漏掉任何解,取 max 后结果依然正确。能主动说清这一点,是本题面试中的关键得分位。

约束:1 ≤ nums.length ≤ 1000 ≤ nums[i] ≤ 1000。长度为 1 是必须特判的边界——此时首尾是同一间房,拆区间会得到 [0, -1] 这种空区间,两边都返回 0,答案会错成 0 而不是 nums[0]。长度为 2 时两段各只有一个元素,自然得到 max(nums[0], nums[1]),无需额外处理。

解法:动态规划递推

核心思路

直接在环上做 DP 会遇到麻烦:递推到最后一间时,能不能偷取决于第一间的选择,而第一间的决策发生在递推的最开头,信息已经丢失。这就是环形结构破坏无后效性的典型表现——首尾之间存在一条「远距离」依赖

常见的补救办法是把「第一间偷没偷」塞进状态,变成二维 DP。但更简洁的观察是:这个额外维度只有两种取值,与其在状态里带着它跑完全程,不如把它提到最外层,枚举两次

于是问题被拆成两个互不相干的线性子问题:在区间 [0, n-2] 上做一次普通打家劫舍,在区间 [1, n-1] 上再做一次,答案取两者最大值。两次调用之间没有任何耦合,环形的麻烦被彻底消除。

线性子问题用滚动变量实现。设扫描到某间房时,f 表示「上一间房不偷时前缀的最大收益」,g 表示「上一间房偷了时前缀的最大收益」。处理当前房屋 x 时:新的「偷了当前」的值必须建立在「上一间不偷」之上,即 g' = f + x;新的「不偷当前」的值可以承接上一间的任意状态,即 f' = max(f, g)。要维持的不变量是:每一轮结束后 fg 分别是「当前这间不偷」与「当前这间偷了」两种情形下的最优前缀收益。区间扫完后返回 max(f, g)

两个变量的初值都是 0,对应「还没处理任何房屋」,此时两种情形收益都为 0,语义自洽。

解题步骤

  • 先特判长度为 1n == 1 时直接返回 nums[0]。这不是可选的优化——环上只有一间房时它不与任何房屋相邻,而拆区间会产生 [0, -1][1, 0] 两个空区间,双双返回 0,答案彻底错误。
  • 拆成两段线性区间各求一次rob(nums, 0, n - 2) 对应「放弃最后一间」,rob(nums, 1, n - 1) 对应「放弃第一间」。两段的并集覆盖了所有合法方案,交集(首尾都不偷的方案)被重复计入但不影响取 max 的结果。
  • 返回两者较大值Math.max(...)。这一步就是对「第一间偷不偷」这个二值维度的枚举。
  • 子过程用两个滚动变量f 是「上一间不偷」的最优值,g 是「上一间偷了」的最优值,初值均为 0。用两个变量而不是数组,是因为线性 DP 只回看一格。
  • 更新顺序必须借助临时变量int ff = Math.max(f, g); 先把新的 f 算好存起来,再执行 g = f + nums[l](此处 f 仍是旧值,正是我们需要的),最后 f = ff。若先赋值 f = Math.max(f, g) 再算 g,右侧用到的就是本轮刚更新的 f,等于允许了相邻同偷。Go 版用 f, g = max(f, g), f+x 的并行赋值天然规避了这个陷阱——右侧全部在赋值前求值。
  • 子过程返回 max(f, g):区间处理完后,最后一间偷或不偷都可能是最优,两者取大。

nums = [2, 3, 2] 走一遍,n = 3,答案应为 3(首尾都是 2 但相邻,不能同偷,所以只能取中间的 3)。

第一次调用 rob(nums, 0, 1),扫描 [2, 3]。初始 f = 0g = 0。处理 2:ff = max(0, 0) = 0g = 0 + 2 = 2(偷了这间),f = 0(不偷这间)。处理 3:ff = max(0, 2) = 2g = f旧 + 3 = 0 + 3 = 3(偷 3 必须上一间不偷,而上一间不偷的最优值是 0),f = 2。返回 max(2, 3) = 3

第二次调用 rob(nums, 1, 2),扫描 [3, 2]。初始 f = 0g = 0。处理 3:ff = 0g = 0 + 3 = 3f = 0。处理 2:ff = max(0, 3) = 3g = f旧 + 2 = 0 + 2 = 2f = 3。返回 max(3, 2) = 3

两者取大得 3。注意第二次调用里 g = 2 表示「偷最后那间 2」的方案只能得 2,因为它逼着放弃了 3;而 f = 3 表示「不偷最后那间」时可以保留 3。取 max 正确地选出了后者。

再用 nums = [1, 2, 3, 1] 核对:rob(0, 2)[1,2,3] 上得 max(1+3, 2) = 4rob(1, 3)[2,3,1] 上得 max(2+1, 3) = 3,答案 4,与预期一致。若漏掉 n == 1 的特判,nums = [5] 会走进 rob(nums, 0, -1)rob(nums, 1, 0),两个空区间都返回 0,输出 0 而非 5。

代码实现

class Solution {
    public int rob(int[] nums) {
        int n = nums.length;
        // 只有一间房时首尾是同一间,拆区间会得到空区间,必须特判。
        if (n == 1) {
            return nums[0];
        }
        // 左:放弃最后一间;右:放弃第一间。两者覆盖全部合法方案。
        return Math.max(rob(nums, 0, n - 2), rob(nums, 1, n - 1));
    }

    // 在线性区间 [l, r] 上做普通打家劫舍。
    // f:上一间不偷时的最优值;g:上一间偷了时的最优值。
    private int rob(int[] nums, int l, int r) {
        int f = 0, g = 0;
        for (; l <= r; ++l) {
            // 先把新的 f 算出来暂存,右式里的 f 必须还是旧值。
            int ff = Math.max(f, g);
            g = f + nums[l];
            f = ff;
        }
        return Math.max(f, g);
    }
}
func rob(nums []int) int {
    n := len(nums)
    // 只有一间房时首尾是同一间,拆区间会得到空区间,必须特判。
    if n == 1 {
        return nums[0]
    }
    // 左:放弃最后一间;右:放弃第一间。两者覆盖全部合法方案。
    return max(robRange(nums, 0, n-2), robRange(nums, 1, n-1))
}

// 在线性区间 [l, r] 上做普通打家劫舍。
// f:上一间不偷时的最优值;g:上一间偷了时的最优值。
func robRange(nums []int, l, r int) int {
    f, g := 0, 0
    for _, x := range nums[l : r+1] {
        // 并行赋值:右侧全部用旧值求值,天然避免了顺序陷阱。
        f, g = max(f, g), f+x
    }
    return max(f, g)
}

复杂度分析

  • 时间复杂度:$O(n)$,两段区间长度均为 n - 1,各扫一遍,合计不到 2n 次常数操作。
  • 空间复杂度:$O(1)$,只用了 fg 两个滚动变量和若干下标,与输入规模无关。相比线性版的数组写法,这是把「只回看一格」这条性质用到底的结果。

关键点总结

  • 环形结构的通用破解思路是「找出那条唯一的远距离约束,然后按它的取值枚举成若干个线性子问题」。本题的远距离约束是首尾互斥,取值只有两种,所以跑两遍线性 DP 即可。这个套路在环形数组的最大子数组和、环形不相邻选取等题上反复出现。
  • 两段区间的重叠不是 bug:「首尾都不偷」的方案在两次计算中各出现一次,但重复计入合法解不会破坏取最大值的正确性。面试中主动解释这一点,比只会写代码更能体现对正确性的把握。
  • n == 1 必须特判,因为拆分假设了首尾是两间不同的房。凡是把一个结构拆成子结构来处理,都要先检查「结构小到无法拆分」的退化情形。
  • 滚动变量的更新顺序是手写 DP 的高频坑:Java 必须用临时变量固定住旧值,Go 的并行赋值则天然安全。写之前先问一句「右侧引用的是本轮还是上轮的值」。
  • 两个状态变量的语义要一句话说清f 是上一间不偷、g 是上一间偷了),语义清楚了转移式自然就写对了;语义含糊时才会纠结「到底该用 f 还是 g」。

易错点总结

  • 漏掉 n == 1 的特判nums = [5] 会拆出两个空区间,返回 0 而不是 5。
  • 区间写成 rob(nums, 0, n - 1)rob(nums, 1, n - 1)nums = [2,3,2] 的第一次调用会覆盖整圈,算出 2 + 2 = 4,等于同时偷了相邻的首尾。
  • Java 里先写 f = Math.max(f, g); 再写 g = f + nums[l];nums = [2,3,2] 的第一段会算成 2 + 3 = 5,相邻两间被同时偷。
  • 子过程返回 g 而不是 max(f, g)nums = [1,2,3,1] 的第一段 [1,2,3] 会返回「必须偷最后一个」的 4,看似正确,但在 [3,1] 上会返回 1 而不是 3。
  • 子过程返回 f 而不是 max(f, g)nums = [2] 这类单元素区间会返回 0,因为「不偷最后一间」的分支忽略了唯一的房屋。
  • 两个变量初值设成 nums[l] 之类的具体值nums = [1,2,3,1] 会把第一间重复计入,第一段算出 5 而不是 4。
  • 在环上直接做一维 DP 而不拆分,只在最后判一次首尾冲突nums = [5,1,1,5] 的一维 DP 会得到 10(同时偷首尾),事后再减去某一个变成 5,但正确答案是 max(5+1, 1+5) = 6,事后修补无法还原正确的中间决策。
  • 拆分时误以为「偷第一间」的那一段必须强制包含 nums[0]nums = [1,9,1,9] 若强制偷 nums[0],第一段只能得 1 + 1 = 2,最终答案错成 9 而不是正确的 18。
  • Go 里把并行赋值拆成两行f = max(f, g) 后再 g = f + x,与 Java 的顺序错误同源,nums = [2,3,2] 输出 5。
  • 误以为可以只跑 [1, n-1] 一段nums = [9,1,1,1] 会漏掉「偷第一间」的最优解 9 + 1 = 10,只得到 1 + 1 = 2

相似题目

题目 难度 考察点
213. 打家劫舍 II 中等 与本题完全同题,代码可原样提交
198. 打家劫舍 中等 线性排列无首尾约束,是本题内层子过程的原型
LCR 089. 打家劫舍 中等 与 198 同题,可对照体会「环拆成两段线性」这一步的必要性
337. 打家劫舍 III 中等 结构换成二叉树,改为后序遍历返回「偷/不偷」两个值的树形 DP
918. 环形子数组的最大和 中等 同样是环形拆解,但手法是「总和减去最小子数组和」而非枚举首元素
740. 删除并获得点数 中等 需先把原数组按数值聚合成价值数组,再套线性打家劫舍,考察问题转化
面试题 17.16. 按摩师 简单 线性不相邻选取的最简形态,适合单独练滚动变量的更新顺序
45. 跳跃游戏 II 中等 同为一维决策序列,但最优子结构可用贪心区间覆盖代替 DP