LeetCode 198. 打家劫舍:动态规划入门经典题详解

0 阅读5分钟

作为动态规划领域最经典的入门题目之一,LeetCode 198. 打家劫舍不仅考察对「状态定义」和「递推逻辑」的理解,更能帮我们建立解决“选或不选”类问题的核心思维。今天就带大家一步步拆解这道题,从题目分析到代码实现,吃透每一个细节,新手也能轻松掌握。

一、题目解读:读懂约束,明确目标

题目核心场景很简单:沿街有若干房屋,每间房有一定现金,相邻房屋不能同时偷窃(否则触发警报),给定房屋现金数组nums,求一夜能偷窃到的最高金额。

关键约束拆解:

  • 约束:相邻房屋互斥,选了第i间,就不能选第i-1间;不选第i间,可选择第i-1间(或不选)。

  • 目标:求“最大金额”,属于「最优化问题」——这类问题往往适合用动态规划求解,通过记录中间状态,避免重复计算。

  • 边界情况:数组为空(无房屋)、数组长度为1(只有一间房),需单独处理。

二、动态规划思路:从“状态”到“递推”

动态规划的核心是「定义状态」和「找到递推公式」,我们一步步推导:

1. 定义DP状态

我们定义dp[i]表示:偷窃前i+1间房屋(即下标0到i的房屋),能获得的最高金额。

为什么这么定义?因为每一步的决策(偷或不偷第i间房),都依赖于前一步的结果,用dp[i]记录前i+1间的最优解,能自然衔接后续的递推。

2. 推导递推公式

对于第i间房屋,我们有两种选择:偷,或者不偷。

  • 选择1:偷第i间房。那么第i-1间房不能偷,此时最高金额 = 前i-1间房的最优解(dp[i-2]) + 第i间房的现金(nums[i])。

  • 选择2:不偷第i间房。那么最高金额 = 前i间房的最优解(dp[i-1])(相当于直接继承前i间的最好结果)。

我们要的是“最高金额”,所以取两种选择的最大值,递推公式如下:

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

3. 初始化边界状态

递推公式需要dp[i-2]和dp[i-1],所以我们需要先初始化前两个状态:

  • 当只有1间房(i=0):dp[0] = nums[0](只能偷这一间)。

  • 当有2间房(i=1):dp[1] = max(nums[0], nums[1])(选现金多的那一间偷)。

4. 遍历顺序

从i=2开始,依次遍历到数组末尾(i = size-1),因为每一个dp[i]都依赖于前面的dp[i-1]和dp[i-2],顺序遍历才能保证我们用到的状态都是已经计算好的。

三、代码逐行解析:把思路落地

给定的代码已经完美实现了上述思路,我们逐行拆解,看懂每一步的作用:

function rob(nums: number[]): number {
  // 边界情况1:没有房子,返回0
  if (nums.length === 0) return 0;

  const size = nums.length;
  // 边界情况2:只有一间房,返回该房的现金
  if (size === 1) return nums[0];

  // 初始化dp数组,存储前i+1间房的最高偷窃金额
  const dp: number[] = new Array(size);
  dp[0] = nums[0]; // 前1间房(下标0)的最优解
  dp[1] = Math.max(nums[0], nums[1]); // 前2间房的最优解

  // 递推:从第3间房(i=2)开始,直到最后一间房
  for (let i = 2; i < size; i++) {
    dp[i] = Math.max(dp[i - 2] + nums[i], dp[i - 1]);
  }

  // 返回最后一间房对应的最优解(前size间房的最高金额)
  return dp[size - 1];
};

关键代码说明

  • 边界处理:先判断数组为空和长度为1的情况,避免后续数组越界,也符合实际场景。

  • dp数组初始化:长度和nums一致,确保每一间房都有对应的状态记录。

  • 循环递推:i从2开始,逐一计算每一间房的最优解,最终dp[size-1]就是所有房屋的最高偷窃金额。

四、测试案例:验证思路正确性

我们用两个常见测试案例,看看代码是否能正常运行:

案例1:nums = [1,2,3,1]

推导过程:

  • dp[0] = 1

  • dp[1] = max(1,2) = 2

  • dp[2] = max(dp[0]+3, dp[1]) = max(1+3, 2) = 4

  • dp[3] = max(dp[1]+1, dp[2]) = max(2+1, 4) = 4

返回结果:4(正确,偷窃第1间和第3间,1+3=4)。

案例2:nums = [2,7,9,3,1]

推导过程:

  • dp[0] = 2

  • dp[1] = max(2,7) = 7

  • dp[2] = max(2+9,7) = 11

  • dp[3] = max(7+3,11) = 11

  • dp[4] = max(11+1,11) = 12

返回结果:12(正确,偷窃第1间、第3间、第5间,2+9+1=12)。

五、优化方向:空间复杂度优化

上述代码的空间复杂度是O(n)(用到了长度为n的dp数组),但我们观察递推公式发现:dp[i]只依赖于dp[i-1]和dp[i-2],不需要存储整个数组。

可以用两个变量替代dp数组,将空间复杂度优化到O(1),优化后代码如下(供参考):

function rob(nums: number[]): number {
  if (nums.length === 0) return 0;
  if (nums.length === 1) return nums[0];
  let prevPrev = nums[0]; // 对应dp[i-2]
  let prev = Math.max(nums[0], nums[1]); // 对应dp[i-1]
  for (let i = 2; i < nums.length; i++) {
    const current = Math.max(prevPrev + nums[i], prev);
    prevPrev = prev;
    prev = current;
  }
  return prev;
};

优化思路:用prevPrev记录dp[i-2],prev记录dp[i-1],每次循环更新这两个变量,避免存储整个dp数组,效率更高。

六、总结:掌握动态规划的核心思维

打家劫舍这道题的核心,是「用状态记录中间最优解,用递推公式衔接前后决策」。其实很多动态规划题都是这个思路:

  1. 明确问题的约束和目标(相邻不偷,求最大金额);

  2. 定义合适的状态(dp[i]代表前i+1间房的最优解);

  3. 推导递推公式(偷或不偷的两种选择,取最大值);

  4. 初始化边界,顺序遍历计算;

  5. (可选)优化空间复杂度,去掉不必要的存储。

这道题作为动态规划的入门题,难度适中,但能帮我们建立“最优子结构”和“无后效性”的思维——后续遇到类似的“选或不选”“相邻约束”问题(如打家劫舍II、III),都能沿用这个思路。