打家劫舍 II:环拆成两条直线
首尾相邻,最优方案最多偷其中一间。于是「不偷尾」和「不偷首」各跑一遍线性打家劫舍,取较大值。
答案 = max(robRange(nums[0..n-2]), robRange(nums[1..n-1]))。robRange 就是 LC198:每间房在「不偷」和「偷并接上上间」里取大。任何合法方案都不同时含首尾,两类拆分恰好覆盖全部情况。时间 O(n),空间 O(1)。
一圈房子围成环,每间有现金,相邻两间不能同时偷。和第 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 不会算重出更大的数。
Go:两趟线性 DP
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, 0for _, 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。
- 「首尾都不偷」被两类拆分同时覆盖,不必第三趟。
- 只有一间房时直接返回,不能切。