🏘️打家劫舍系列问题:从“小偷的自我修养”到“环形作案的艺术”,一场关于动态规划的灵魂拷问
引言:我不是在写代码,我是在策划一场完美的犯罪
你有没有过这样的时刻?
深夜两点,电脑屏幕泛着蓝光,你盯着LeetCode上那道名为 (198. 打家劫舍 - 力扣(LeetCode)) 的题目,心里默默发问:
“我是谁?我在哪?为什么我要去抢房子?而且还是不能连着抢两个?!”
别慌。这不是梦境,也不是精神分裂前兆。
这是每一个算法学习者必经的成年礼——当你第一次面对“打家劫舍”这类披着暴力外衣、实则温柔如水的动态规划题时,你的灵魂就会被轻轻敲打一下。
而今天,我们要做的,不只是解出这道题。
我们要深入小偷的内心世界,理解他为何如此克制(不抢相邻房),如此理性(追求最大收益),甚至如此有艺术感(环形布局都安排上了)。
我们将从最基础的线性抢劫开始,一路升级装备,最终成为能在环形街区自由穿梭的顶级神偷!
准备好穿上黑衣、戴上手套了吗?Let’s go!
一、第一幕:新手村任务 —— 线性街道上的“理智型小偷”登场(LeetCode 198)
🎯 题目描述(翻译成人话版)
有一排房子,每个房子里都有钱。你可以进去拿钱,但有个规矩:
❗ 不能连续抢两家!否则警铃大作,警察叔叔五分钟到场!
给你一个数组 nums,代表每家的钱数,请问你怎么抢才能赚得最多还不坐牢?
举个例子:
nums = [2, 7, 9, 3, 1]
怎么选?
- 抢第0家(2)和第2家(9) → 共11元
- 再加第4家(1)→ 12元 ✅
- 但不能同时抢第1家(7)和第2家(9),因为挨着!
所以答案是 12。
听起来像不像你在玩《侠盗猎车手》之前先做的教学关卡?
💡 初心萌动:我是该抢这家,还是放它一马?
作为一个刚刚入行的小偷,你站在第一条街上,眼前五栋房子闪闪发光。
你想:“我要不要抢第一家?”
然后脑子突然蹦出一个问题:
“如果我现在抢了这家,会不会影响后面的发财大计?”
恭喜你,你已经触碰到了动态规划的核心思想!
我们不需要穷举所有组合(那太慢了),也不靠运气瞎蒙(那是赌徒)。我们要的是——最优子结构 + 状态转移。
🔍 动态规划三连问(灵魂拷问版)
-
状态是什么?
dp[i]表示:从前i+1个房子中能抢到的最大金额(即索引 0 到 i)。 -
状态怎么转移?
在第
i家门口,你有两个选择:- ✅ 抢!那你必须放过第
i-1家,总金额 =dp[i-2] + nums[i] - ❌ 不抢!那你前面怎么抢就怎么来,总金额 =
dp[i-1]
所以:
dp[i] = Math.max(dp[i-2] + nums[i], dp[i-1])是不是像极了人生的选择题?
“考研还是工作?”、“表白还是沉默?”、“吃火锅还是减肥?”
每一次选择,都在为未来铺路。 - ✅ 抢!那你必须放过第
-
边界条件呢?
- 只有一家?直接抢!
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]:
| i | nums[i] | dp[i] 计算过程 | 结果 |
|---|---|---|---|
| 0 | 2 | dp[0] = 2 | 2 |
| 1 | 7 | dp[1] = max(2,7) | 7 |
| 2 | 9 | max(2+9=11, 7) | 11 ✅ |
| 3 | 3 | max(7+3=10, 11) | 11 |
| 4 | 1 | max(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)
⚠️ 场景升级:这条街首尾相连,成了“天罗地网”!
你以为只是普通的一条街?
错!
这是一条环形街区!第一栋房子和最后一栋房子是邻居!
这意味着什么?
❗❗ 你不能同时抢第一家和最后一家!
否则……他们俩会互相报警,形成“闭环告发系统”。
想象一下:你刚抢完第一家,正得意洋洋,突然听到背后一声怒吼:“兄弟我看见你了!”——原来是隔壁老王(也就是最后一家)报警了。
血亏!
🤔 小偷的哲学思考:如何打破这个“死循环”?
既然不能同时抢首尾,那我们干脆分两种情况讨论:
| 情况 | 是否抢第一家 | 是否抢最后一家 | 能否共存 |
|---|---|---|---|
| A | ✅ | ❌ | ✅ |
| B | ❌ | ✅ | ✅ |
注意:AB不能同时成立,但我们可以在两者之间取最大值!
于是,整个问题被拆解为两个独立的线性问题:
- 不考虑最后一个房子 → 区间
[0, len-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期间不能连放 |
所谓“打家劫舍”,不过是带约束的最大化收益问题的一个生动比喻罢了。
学会它,你不仅能当好小偷,还能当好产品经理、运营、投资经理……
六、结语:愿你永远不做违法之事,但拥有做贼的智慧
这篇文章写到这里,我已经从一个“想抢房子的小偷”,变成了一个“研究最优决策的算法诗人”。
我们走过了一条街,又绕了一个圈。
我们学会了:
- 如何用动态规划做出最优选择
- 如何把复杂问题拆解成简单子问题
- 如何在有限条件下追求最大利益
而这,正是编程的魅力所在。