🏘️打家劫舍系列问题:从“小偷的自我修养”到“环形作案的艺术”,一场关于动态规划的灵魂拷问

64 阅读9分钟

🏘️打家劫舍系列问题:从“小偷的自我修养”到“环形作案的艺术”,一场关于动态规划的灵魂拷问


引言:我不是在写代码,我是在策划一场完美的犯罪

你有没有过这样的时刻?

深夜两点,电脑屏幕泛着蓝光,你盯着LeetCode上那道名为 (198. 打家劫舍 - 力扣(LeetCode)) 的题目,心里默默发问:

“我是谁?我在哪?为什么我要去抢房子?而且还是不能连着抢两个?!”

别慌。这不是梦境,也不是精神分裂前兆。

这是每一个算法学习者必经的成年礼——当你第一次面对“打家劫舍”这类披着暴力外衣、实则温柔如水的动态规划题时,你的灵魂就会被轻轻敲打一下。

而今天,我们要做的,不只是解出这道题。

我们要深入小偷的内心世界,理解他为何如此克制(不抢相邻房),如此理性(追求最大收益),甚至如此有艺术感(环形布局都安排上了)。

我们将从最基础的线性抢劫开始,一路升级装备,最终成为能在环形街区自由穿梭的顶级神偷

准备好穿上黑衣、戴上手套了吗?Let’s go!


一、第一幕:新手村任务 —— 线性街道上的“理智型小偷”登场(LeetCode 198)

image.png

🎯 题目描述(翻译成人话版)

有一排房子,每个房子里都有钱。你可以进去拿钱,但有个规矩:

不能连续抢两家!否则警铃大作,警察叔叔五分钟到场!

给你一个数组 nums,代表每家的钱数,请问你怎么抢才能赚得最多还不坐牢?

举个例子:

nums = [2, 7, 9, 3, 1]

怎么选?

  • 抢第0家(2)和第2家(9) → 共11元
  • 再加第4家(1)→ 12元 ✅
  • 但不能同时抢第1家(7)和第2家(9),因为挨着!

所以答案是 12

听起来像不像你在玩《侠盗猎车手》之前先做的教学关卡?


💡 初心萌动:我是该抢这家,还是放它一马?

作为一个刚刚入行的小偷,你站在第一条街上,眼前五栋房子闪闪发光。

你想:“我要不要抢第一家?”
然后脑子突然蹦出一个问题:

“如果我现在抢了这家,会不会影响后面的发财大计?”

恭喜你,你已经触碰到了动态规划的核心思想

我们不需要穷举所有组合(那太慢了),也不靠运气瞎蒙(那是赌徒)。我们要的是——最优子结构 + 状态转移


🔍 动态规划三连问(灵魂拷问版)

  1. 状态是什么?

    dp[i] 表示:从前 i+1 个房子中能抢到的最大金额(即索引 0 到 i)。

  2. 状态怎么转移?

    在第 i 家门口,你有两个选择:

    • ✅ 抢!那你必须放过第 i-1 家,总金额 = dp[i-2] + nums[i]
    • ❌ 不抢!那你前面怎么抢就怎么来,总金额 = dp[i-1]

    所以:

    dp[i] = Math.max(dp[i-2] + nums[i], dp[i-1])
    

    是不是像极了人生的选择题?
    “考研还是工作?”、“表白还是沉默?”、“吃火锅还是减肥?”
    每一次选择,都在为未来铺路。

  3. 边界条件呢?

    • 只有一家?直接抢!dp[0] = nums[0]
    • 两家?挑多的抢!dp[1] = max(nums[0], nums[1])

💻 上代码!让计算机替我去偷!

var rob = function(nums) {
    const n = nums.length;
    if (n === 0) return 0;
    if (n === 1) return nums[0];

    let dp = new Array(n);
    dp[0] = nums[0];
    dp[1] = Math.max(nums[0], nums[1]);

    for (let i = 2; i < n; i++) {
        dp[i] = Math.max(dp[i-2] + nums[i], dp[i-1]);
    }

    return dp[n - 1];
};

跑一遍 [2,7,9,3,1]

inums[i]dp[i] 计算过程结果
02dp[0] = 22
17dp[1] = max(2,7)7
29max(2+9=11, 7)11 ✅
33max(7+3=10, 11)11
41max(11+1=12, 11)12 🎉

最终结果:12元到账,安全撤离!


🚀 进阶操作:空间优化 —— 小偷也要轻装上阵!

上面用了整个 dp 数组,占内存太多。万一这条街有十万栋房子怎么办?内存爆炸!

其实我们发现:每次只用到前两个状态。

于是我们可以用两个变量代替数组,实现“滚动更新”。

var rob = function(nums) {
    const n = nums.length;
    if (n === 0) return 0;
    if (n === 1) return nums[0];

    let prev2 = nums[0];                    // dp[i-2]
    let prev1 = Math.max(nums[0], nums[1]); // dp[i-1]

    for (let i = 2; i < n; i++) {
        const current = Math.max(prev2 + nums[i], prev1);
        prev2 = prev1;
        prev1 = current;
    }

    return prev1;
};

✅ 时间复杂度:O(n)
✅ 空间复杂度:O(1)
🧠 心理感受:感觉自己像个黑客,用最少的资源干最大的事。


二、第二幕:进阶挑战 —— 环形街区的“高智商犯罪”(LeetCode 213)

image.png

⚠️ 场景升级:这条街首尾相连,成了“天罗地网”!

你以为只是普通的一条街?

错!

这是一条环形街区!第一栋房子和最后一栋房子是邻居!

这意味着什么?

❗❗ 你不能同时抢第一家和最后一家!

否则……他们俩会互相报警,形成“闭环告发系统”。

想象一下:你刚抢完第一家,正得意洋洋,突然听到背后一声怒吼:“兄弟我看见你了!”——原来是隔壁老王(也就是最后一家)报警了。

血亏!


🤔 小偷的哲学思考:如何打破这个“死循环”?

既然不能同时抢首尾,那我们干脆分两种情况讨论:

情况是否抢第一家是否抢最后一家能否共存
A
B

注意:AB不能同时成立,但我们可以在两者之间取最大值!

于是,整个问题被拆解为两个独立的线性问题

  1. 不考虑最后一个房子 → 区间 [0, len-2]
  2. 不考虑第一个房子 → 区间 [1, len-1]

分别求最大值,再取两者的最大值即可。

👉 这就是传说中的“分类讨论法”,也是面试官最爱看的思维拆解能力。


💬 小偷内心OS:

“我不贪,我可以放弃起点,也可以放弃终点。但我一定要拿到中间的最大值。”

多么悲壮而理性的犯罪美学啊。


💻 实现代码(带注释版,适合抄作业)

var rob = function(nums) {
    const n = nums.length;

    // 特殊情况处理
    if (n === 0) return 0;
    if (n === 1) return nums[0];
    if (n === 2) return Math.max(nums[0], nums[1]);

    // helper函数:解决标准线性打家劫舍问题
    const robRange = (start, end) => {
        let prev2 = nums[start];
        let prev1 = Math.max(nums[start], nums[start + 1]);

        for (let i = start + 2; i <= end; i++) {
            const current = Math.max(prev2 + nums[i], prev1);
            prev2 = prev1;
            prev1 = current;
        }
        return prev1;
    };

    // 情况1:不抢最后一个(即抢0 ~ n-2)
    const case1 = robRange(0, n - 2);

    // 情况2:不抢第一个(即抢1 ~ n-1)
    const case2 = robRange(1, n - 1);

    // 返回两种情况的最大值
    return Math.max(case1, case2);
};

本人所写题解在此: leetcode.cn/problems/ho…

🧪 测试一把:[2,3,2]

  • 情况1:抢 [2,3] → 最大值是 3
  • 情况2:抢 [3,2] → 最大值是 3
  • 结果:max(3,3)=3

正确!不能同时抢第一个2和最后一个2。


🧩 关键技巧总结

技巧说明
分治思想把环形拆成两个线性问题
复用逻辑写一个 robRange 函数避免重复代码
边界清晰明确两个区间的起止位置
返回最大值最终答案是两种策略的最优解

三、第三幕:高手之路 —— 打家劫舍通用解题框架(可复用模板)

经过前面两轮实战,我们可以提炼出一套通杀所有“打家劫舍类问题”的万能公式


🛠️ 【打家劫舍·祖传秘方】动态规划四步走

第一步:定义状态(State Definition)

dp[i] = 前 i+1 个元素中能获得的最大收益

关键是要明确“前缀含义”和“是否包含当前项”。

第二步:写出状态转移方程(Transition Function)

dp[i] = max(dp[i-2] + nums[i], dp[i-1])

记住口诀:

“抢当前,就得跳过前一个;不抢当前,就继承之前的成果。”

第三步:初始化边界(Base Case)
  • dp[0] = nums[0]
  • dp[1] = max(nums[0], nums[1])

小心数组越界!特别是长度为1或2的时候。

第四步:空间优化(Space Optimization)

prev2, prev1 替代整个数组,节省空间。

适用于所有只依赖前两项的问题(比如斐波那契、股票买卖等)。


🔄 变种适配指南

问题类型如何变形
环形排列拆分为两个线性问题
树形结构(LeetCode 337)使用递归 + 记忆化,状态为 (root, robbed)
相邻k个不能抢扩展为滑动窗口最大值问题
房子带权重或时间限制加维度 → 变成二维DP

掌握这套框架,你就不再是“做题家”,而是“设计题的人”。


四、番外篇:那些年我们一起踩过的坑 😭

作为一名资深“虚拟小偷”,我在刷这类题时也走过不少弯路。以下是真实血泪史分享:

❌ 错误1:试图用贪心算法解决

“每次都选最大的那个不行吗?”

❌ 不行!反例:[2,1,1,2]
贪心可能选中间两个1,但实际上应该选两边的2。

贪心只看眼前,DP才看得长远。

❌ 错误2:环形问题直接套公式

“我把 dp[0] 和 dp[n-1] 单独判断一下就行了吧?”

❌ 不行!状态之间耦合严重,必须拆分成独立子问题。

❌ 错误3:忘记边界处理

nums.length == 1 时,如果不单独处理,robRange(1, n-1) 会越界!

面试官最喜欢在这种地方埋雷。

✅ 正确姿势:先写暴力 → 再改DP → 最后优化空间

循序渐进,稳扎稳打,才是王道。


五、彩蛋环节:现实意义?当然有!

你说这是虚构的抢劫问题?

Nonono!

看看这些实际应用场景:

真实场景对应模型
股票买卖(冷冻期)类似“不能连续交易”
广告投放排期不能连续两天推同类广告
项目资源分配相邻任务冲突需跳过
游戏技能释放CD期间不能连放

所谓“打家劫舍”,不过是带约束的最大化收益问题的一个生动比喻罢了。

学会它,你不仅能当好小偷,还能当好产品经理、运营、投资经理……


六、结语:愿你永远不做违法之事,但拥有做贼的智慧

这篇文章写到这里,我已经从一个“想抢房子的小偷”,变成了一个“研究最优决策的算法诗人”。

我们走过了一条街,又绕了一个圈。

我们学会了:

  • 如何用动态规划做出最优选择
  • 如何把复杂问题拆解成简单子问题
  • 如何在有限条件下追求最大利益

而这,正是编程的魅力所在。