微信一面被问"打家劫舍",我靠这三招反杀面试官

11 阅读5分钟

前两天微信一面,面试官笑眯眯地问:"来过个打家劫舍?"
我心里一紧:这题我会,但别急,咱们得讲出花样来。

动态规划(DP)确实是很多同学的噩梦,但今天咱们不堆术语,就用"偷东西"这个场景,把数组、环形、树形三种打家劫舍一次性讲透。看完这篇,下次面试你再遇到,不仅能写出来,还能讲出面试官想听的"设计思路"。


一、先搞懂:DP 到底在解决什么问题?

很多同学背状态转移方程背得头秃,但没想明白:为什么这题能用 DP?

其实就两个核心特征:

  1. 重叠子问题:大问题的解法,会反复用到相同的小问题结果。不缓存的话,递归直接爆栈。
  2. 最优子结构:每一步的"最值",能由子问题的"最值"推导出来。

举个例子:爬楼梯。
爬到第 i 阶的方法数,只取决于 i-1i-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:环形数组,多一层约束

题目:房子围成一圈,第一个和最后一个相邻,不能同时偷。

关键思路:拆环为链

既然首尾不能同时选,那咱们分两种情况讨论

  1. 不偷第一家:只考虑 nums[1 ~ n-1]
  2. 不偷最后一家:只考虑 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] 把两种决策都缓存下来
  • 父节点根据子节点的两个状态,组合出自己的最优解
  • 避免了重复计算,每个节点只遍历一次

五、面试复盘:除了写代码,还要讲清楚这三点

  1. 为什么能用 DP?
    "这题有重叠子问题和最优子结构:每个房子的决策只依赖前一个/子节点的状态,且全局最优解可由局部最优解推导。"

  2. 状态怎么定义的?为什么这样定义?
    "我用 dp[i] 表示前 i 个房子的最大收益,因为问题要求'最值',且当前决策只和前两个状态相关,这样定义能直接套用转移方程。"

  3. 有没有优化空间?
    "线性版本可以用滚动变量把空间降到 O(1);树形版本用后序遍历避免重复递归,时间复杂度 O(n)。"


六、最后送大家一个"DP 三板斧"模板

下次遇到最值问题,按这个思路走,稳:

  1. 定义状态dp[xxx] 到底表示什么?(一定要用一句话能说清)
  2. 找转移方程:当前状态怎么由之前的状态推导?(画个决策树辅助思考)
  3. 确定边界 + 优化:初始值是什么?空间能不能压缩?

小提醒:写代码前,先用注释把这三步写出来,再填逻辑。不仅自己思路清晰,面试官看注释也能快速 get 你的思考过程。


写在最后

动态规划真的没有想象中那么玄学。
它就像搭积木:把大问题拆成小问题,把小问题的答案存起来,最后拼出全局最优解

打家劫舍这三个变种,刚好覆盖了 DP 最常见的三种数据结构:线性、环形、树形。吃透这一道题,相当于掌握了半本 DP 入门手册。

面试不是背书,是展示你"如何思考"。
下次再遇到"偷东西"的题,希望你能笑着对面试官说:
"这个啊,咱们分三种情况聊..."