前两天微信一面,面试官笑眯眯地问:"来过个打家劫舍?"
我心里一紧:这题我会,但别急,咱们得讲出花样来。
动态规划(DP)确实是很多同学的噩梦,但今天咱们不堆术语,就用"偷东西"这个场景,把数组、环形、树形三种打家劫舍一次性讲透。看完这篇,下次面试你再遇到,不仅能写出来,还能讲出面试官想听的"设计思路"。
一、先搞懂:DP 到底在解决什么问题?
很多同学背状态转移方程背得头秃,但没想明白:为什么这题能用 DP?
其实就两个核心特征:
- 重叠子问题:大问题的解法,会反复用到相同的小问题结果。不缓存的话,递归直接爆栈。
- 最优子结构:每一步的"最值",能由子问题的"最值"推导出来。
举个例子:爬楼梯。
爬到第 i 阶的方法数,只取决于 i-1 和 i-2。
dp[i] = dp[i-1] + dp[i-2]
这就是典型的"大问题的最优解,由子问题最优解拼出来"。
打家劫舍,本质也是"选或不选"的最值问题,只是约束条件变了。
二、打家劫舍 I:线性数组,经典开场
题目:一排房子,相邻不能偷,求最大金额。
1️⃣ 状态定义
dp[i] 表示:前 i 个房子,能偷到的最大金额
2️⃣ 状态转移(灵魂所在)
对于第 i 个房子,只有两种选择:
- 偷:那
i-1不能偷 →dp[i-2] + nums[i] - 不偷:那最大金额继承
i-1的结果 →dp[i-1]
取最大值:
dp[i] = Math.max(dp[i-1], dp[i-2] + nums[i])
3️⃣ 边界条件
dp[0] = nums[0] // 只有一个房子,偷就完了
dp[1] = Math.max(nums[0], nums[1]) // 两个房子,选大的
4️⃣ 空间优化(面试加分项)
发现没?dp[i] 只依赖前两个状态,完全可以用两个变量滚动:
function rob(nums) {
if (!nums.length) return 0;
let prev2 = 0, prev1 = 0; // prev2 = dp[i-2], prev1 = dp[i-1]
for (const num of nums) {
const cur = Math.max(prev1, prev2 + num);
prev2 = prev1;
prev1 = cur;
}
return prev1;
}
时间 O(n),空间 O(1),这优化一写,面试官眼神都不一样了。
三、打家劫舍 II:环形数组,多一层约束
题目:房子围成一圈,第一个和最后一个相邻,不能同时偷。
关键思路:拆环为链
既然首尾不能同时选,那咱们分两种情况讨论:
- 不偷第一家:只考虑
nums[1 ~ n-1] - 不偷最后一家:只考虑
nums[0 ~ n-2]
最终答案取两者最大值,完美规避环形约束。
function robRange(nums, start, end) {
let prev2 = 0, prev1 = 0;
for (let i = start; i <= end; i++) {
const cur = Math.max(prev1, prev2 + nums[i]);
prev2 = prev1;
prev1 = cur;
}
return prev1;
}
function rob(nums) {
const n = nums.length;
if (n === 0) return 0;
if (n === 1) return nums[0];
// 情况1:不偷最后一家 | 情况2:不偷第一家
return Math.max(
robRange(nums, 0, n - 2),
robRange(nums, 1, n - 1)
);
}
面试技巧:写之前先跟面试官确认思路:"环形问题我打算拆成两个线性问题处理,您看方向对吗?" —— 沟通分先拿到。
四、打家劫舍 III:树形结构,递归 + 状态机
题目:房子构成二叉树,父子节点不能同时偷。
这题不能用数组下标递推了,得用后序遍历 + 返回状态数组。
核心设计:每个节点返回 [偷, 不偷] 两种状态的最大收益
function rob(root) {
const dfs = (node) => {
if (!node) return [0, 0]; // [rob, notRob]
const left = dfs(node.left);
const right = dfs(node.right);
// 偷当前节点:左右孩子都不能偷
const rob = node.val + left[1] + right[1];
// 不偷当前节点:孩子可偷可不偷,取各自最大值
const notRob = Math.max(left[0], left[1]) + Math.max(right[0], right[1]);
return [rob, notRob];
};
const [robRoot, notRobRoot] = dfs(root);
return Math.max(robRoot, notRobRoot);
}
为什么用后序遍历?
因为父节点的决策,依赖子节点的结果。必须先算完左右子树,才能决定当前节点怎么选 —— 典型的"自底向上"。
状态设计精髓
return [rob, notRob]把两种决策都缓存下来- 父节点根据子节点的两个状态,组合出自己的最优解
- 避免了重复计算,每个节点只遍历一次
五、面试复盘:除了写代码,还要讲清楚这三点
-
为什么能用 DP?
"这题有重叠子问题和最优子结构:每个房子的决策只依赖前一个/子节点的状态,且全局最优解可由局部最优解推导。" -
状态怎么定义的?为什么这样定义?
"我用dp[i]表示前 i 个房子的最大收益,因为问题要求'最值',且当前决策只和前两个状态相关,这样定义能直接套用转移方程。" -
有没有优化空间?
"线性版本可以用滚动变量把空间降到 O(1);树形版本用后序遍历避免重复递归,时间复杂度 O(n)。"
六、最后送大家一个"DP 三板斧"模板
下次遇到最值问题,按这个思路走,稳:
- 定义状态:
dp[xxx]到底表示什么?(一定要用一句话能说清) - 找转移方程:当前状态怎么由之前的状态推导?(画个决策树辅助思考)
- 确定边界 + 优化:初始值是什么?空间能不能压缩?
小提醒:写代码前,先用注释把这三步写出来,再填逻辑。不仅自己思路清晰,面试官看注释也能快速 get 你的思考过程。
写在最后
动态规划真的没有想象中那么玄学。
它就像搭积木:把大问题拆成小问题,把小问题的答案存起来,最后拼出全局最优解。
打家劫舍这三个变种,刚好覆盖了 DP 最常见的三种数据结构:线性、环形、树形。吃透这一道题,相当于掌握了半本 DP 入门手册。
面试不是背书,是展示你"如何思考"。
下次再遇到"偷东西"的题,希望你能笑着对面试官说:
"这个啊,咱们分三种情况聊..."