当前:LC213 · 打家劫舍 II / House Robber II · 首次出现于 Day 36 · 路径:顶栏「56天打卡」→ 点击 LC 题号 → 逐题动画

LC213 · House Robber II · 动态规划

打家劫舍 II:环拆成两条直线

首尾相邻,最优方案最多偷其中一间。于是「不偷尾」和「不偷首」各跑一遍线性打家劫舍,取较大值。

答案 = max(robRange(nums[0..n-2]), robRange(nums[1..n-1]))。robRange 就是 LC198:每间房在「不偷」和「偷并接上上间」里取大。任何合法方案都不同时含首尾,两类拆分恰好覆盖全部情况。时间 O(n),空间 O(1)。

时间 O(n)空间 O(1)结论先行 · 全文约 6 节
导读

一圈房子围成环,每间有现金,相邻两间不能同时偷。和第 0 间相邻的,除了第 1 间,还有最后一间。问在不触发报警的前提下最多偷多少。

主例 nums = [2, 3, 2]。三间房围一圈:两头的 2 其实相邻。若按直线去做 LC198,会把两头的 2 加起来得到 4,但这在环上非法。合法方案只能偷中间的 3,或者只偷某一头的 2,答案是 3。

直接在环上写状态会把「第 0 间偷没偷」一路背到结尾。本文要回答:为什么放弃一间就能把环剪开,以及主例两条直线为什么都算出 3。

剪环成线

环上多出来的约束只有一句:第 0 间和最后一间不能一起偷。所以最优方案只有三类:偷了首、没偷尾;偷了尾、没偷首;首尾都没偷。第三类同时落在前两类里,不必单开一档。缺的不是新的递推式,而是一次分类:把环剪成两条互不干扰的直线。

情况 A:强制不偷最后一间,问题变成 nums[0..n-2] 上的线性打家劫舍。情况 B:强制不偷第一间,问题变成 nums[1..n-1] 上的线性打家劫舍。两条线各跑一遍 LC198,取较大值。LC198 本身是:走到每一间,比较「不偷它,沿用上一间的最优」和「偷它,接上上间的最优」。

用手走主例。n=3。情况 A 看 [2, 3]。prev、cur 从 0 起:先遇到 2,cur 变成 2;再遇到 3,不偷是 2,偷是 0+3=3,取 3。演示场景里 resultA=3,对应「不偷尾,最多拿中间」。情况 B 看 [3, 2]:先遇到 3,cur=3;再遇到 2,不偷是 3,偷是 0+2=2,取 3。resultB=3。答案 max(3, 3)=3,只偷中间那一间。

若当成直线去做,[2, 3, 2] 会在第三间比较「不偷=3」和「偷=2+2=4」,选出 4。多出来的那 1 就是把两头的 2 加在了一起,而它们在环上相邻。拆环就是为了让这种方案进不了任何一条直线。

只有一间房时,两条切片都会把这间房切掉或切空,必须单独返回 nums[0]。两间房时,A 只看第一间,B 只看第二间,max 就是两者中较大的那个,不会同时偷。房子全是 0,两条线都是 0。这些边界都落在「先特判 n=1,再跑两趟 robRange」上。

分类穷尽合法方案不可能同时含首尾。按「放弃尾」和「放弃首」切开,恰好覆盖全部合法方案;首尾都不偷被两边各算一次,取 max 不会算重出更大的数。
nums = [2,3,2]
2[0]
3[1]
2[2]
A·去尾 = 3
A · 不偷尾 [2,3]:最大 3

Go:两趟线性 DP

solution.goGo
func rob(nums []int) int {
n := len(nums)
if n == 1 { return nums[0] }
return max(robRange(nums[:n-1]), robRange(nums[1:]))
}
func robRange(a []int) int {
pre, cur := 0, 0
for _, x := range a {
pre, cur = cur, max(cur, pre+x)
}
return cur
}

1n=1 必须先返回,否则 [:n-1] 和 [1:] 都会把唯一那间房切掉。

2nums[:n-1] 是不偷尾,nums[1:] 是不偷首。主例分别是 [2,3] 和 [3,2]。

3robRange 里 cur 是「不偷当前」,pre+x 是「偷当前」。主例两条线都得到 3。

总结

环上不能同时偷首尾:去尾、去首各跑一遍 LC198,取 max。主例答案 3。

  • 按直线做会把主例两头的 2 加出非法的 4。
  • 「首尾都不偷」被两类拆分同时覆盖,不必第三趟。
  • 只有一间房时直接返回,不能切。
同族题目
LC198打家劫舍LC337打家劫舍 IIILC918环形子数组的最大和