198.打家劫舍
你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。
给定一个代表每个房屋存放金额的非负整数数组,计算你 不触动警报装置的情况下 ,一夜之内能够偷窃到的最高金额。
- 示例 1:
- 输入:[1,2,3,1]
- 输出:4
解释:偷窃 1 号房屋 (金额 = 1) ,然后偷窃 3 号房屋 (金额 = 3)。 偷窃到的最高金额 = 1 + 3 = 4 。
- 示例 2:
- 输入:[2,7,9,3,1]
- 输出:12 解释:偷窃 1 号房屋 (金额 = 2), 偷窃 3 号房屋 (金额 = 9),接着偷窃 5 号房屋 (金额 = 1)。 偷窃到的最高金额 = 2 + 9 + 1 = 12 。
提示:
- 0 <= nums.length <= 100
- 0 <= nums[i] <= 400
dp[i]:考虑下标i(包括i)以内的房屋,最多可以偷窃的金额为dp[i]。
决定dp[i]的因素就是第i房间偷还是不偷。
如果偷第i房间,那么dp[i] = dp[i - 2] + nums[i] ,即:第i-1房一定是不考虑的,找出 下标i-2(包括i-2)以内的房屋,最多可以偷窃的金额为dp[i-2] 加上第i房间偷到的钱。
如果不偷第i房间,那么dp[i] = dp[i - 1],即考 虑i-1房,(注意这里是考虑,并不是一定要偷i-1房,这是很多同学容易混淆的点)
然后dp[i]取最大值,即dp[i] = max(dp[i - 2] + nums[i], dp[i - 1]);
从递推公式dp[i] = max(dp[i - 2] + nums[i], dp[i - 1]);可以看出,递推公式的基础就是dp[0] 和 dp[1]
从dp[i]的定义上来讲,dp[0] 一定是 nums[0],dp[1]就是nums[0]和nums[1]的最大值即:dp[1] = max(nums[0], nums[1]);
python
class Solution:
def rob(self, nums: List[int]) -> int:
if len(nums) == 0: # 如果没有房屋,返回0
return 0
if len(nums) == 1: # 如果只有一个房屋,返回其金额
return nums[0]
# 创建一个动态规划数组,用于存储最大金额
dp = [0] * len(nums)
dp[0] = nums[0] # 将dp的第一个元素设置为第一个房屋的金额
dp[1] = max(nums[0], nums[1]) # 将dp的第二个元素设置为第一二个房屋中的金额较大者
# 遍历剩余的房屋
for i in range(2, len(nums)):
# 对于每个房屋,选择抢劫当前房屋和抢劫前一个房屋的最大金额
dp[i] = max(dp[i - 2] + nums[i], dp[i - 1])
return dp[-1] # 返回最后一个房屋中可抢劫的最大金额
213.打家劫舍II
你是一个专业的小偷,计划偷窃沿街的房屋,每间房内都藏有一定的现金。这个地方所有的房屋都 围成一圈 ,这意味着第一个房屋和最后一个房屋是紧挨着的。同时,相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警 。
给定一个代表每个房屋存放金额的非负整数数组,计算你 在不触动警报装置的情况下 ,能够偷窃到的最高金额。
示例 1:
-
输入:nums = [2,3,2]
-
输出:3
-
解释:你不能先偷窃 1 号房屋(金额 = 2),然后偷窃 3 号房屋(金额 = 2), 因为他们是相邻的。
-
示例 2:
-
输入:nums = [1,2,3,1]
-
输出:4
-
解释:你可以先偷窃 1 号房屋(金额 = 1),然后偷窃 3 号房屋(金额 = 3)。偷窃到的最高金额 = 1 + 3 = 4 。
-
示例 3:
-
输入:nums = [0]
-
输出:0
提示:
- 1 <= nums.length <= 100
- 0 <= nums[i] <= 1000。
核心思路:化圆为线
由于首尾相连,第一间房 和最后一间房不能同时偷。因此,我们可以将问题转化为两个 198 题(线性排列):
-
不偷最后一间房: 范围是
nums[0 : n-1] -
不偷第一间房: 范围是
nums[1 : n]最后取两者的最大值。
robRange 函数
这个函数其实就是 198. 打家劫舍 的优化版。
-
状态定义:
-
prev_max: 代表偷到"前前一家"时的最大金额(相当于 dp[i-2])。 -
curr_max: 代表偷到"前一家"时的最大金额(相当于 dp[i-1])。
-
-
转移方程:
curr_max = max(prev_max + nums[i], curr_max)-
偷当前家: 金额 =
prev_max(前前家)+nums[i](当前家)。 -
不偷当前家: 金额 =
curr_max(保持前一家的最大值)。
-
-
滚动更新:
在进入下一轮循环前,用
temp记录旧的curr_max并传给prev_max。这就像一个滑动的窗口,永远只盯着最近的两间房看。
2. 主函数:rob 的三种情况分析
对于环状排列,我们要处理"首尾不能同时偷"的问题。我们可以把所有可能的情况分成三类:
-
情况一: 首尾都不偷。
-
情况二: 偷首,不偷尾。对应的范围是
[0, n-2]。 -
情况三: 偷尾,不偷首。对应的范围是
[1, n-1]。
关键点: 情况二和情况三其实已经包含了 情况一。因为在 [0, n-2] 这个范围里,算法会自动根据金额大小决定"偷不偷首",所以我们只需要对比情况二和情况三,取最大值即可:
# 情况二:考虑第一间,不考虑最后一间
result1 = self.robRange(nums, 0, len(nums) - 2)
# 情况三:考虑最后一间,不考虑第一间
result2 = self.robRange(nums, 1, len(nums) - 1)
python
class Solution:
def rob(self, nums: List[int]) -> int:
if len(nums) == 0:
return 0
if len(nums) == 1:
return nums[0]
result1 = self.robRange(nums, 0, len(nums) - 2) # 情况二
result2 = self.robRange(nums, 1, len(nums) - 1) # 情况三
return max(result1, result2)
# 198.打家劫舍的逻辑
def robRange(self, nums: List[int], start: int, end: int) -> int:
if end == start:
return nums[start]
prev_max = nums[start]
curr_max = max(nums[start], nums[start + 1])
for i in range(start + 2, end + 1):
temp = curr_max
curr_max = max(prev_max + nums[i], curr_max)
prev_max = temp
return curr_max
337.打家劫舍 III
在上次打劫完一条街道之后和一圈房屋后,小偷又发现了一个新的可行窃的地区。这个地区只有一个入口,我们称之为"根"。 除了"根"之外,每栋房子有且只有一个"父"房子与之相连。一番侦察之后,聪明的小偷意识到"这个地方的所有房屋的排列类似于一棵二叉树"。 如果两个直接相连的房子在同一天晚上被打劫,房屋将自动报警。
计算在不触动警报的情况下,小偷一晚能够盗取的最高金额。

每一个节点都有两种状态:偷 或者 不偷。
我们让递归函数返回一个包含两个值的列表(或元组):[不偷该节点的最大金钱, 偷该节点的最大金钱]。
-
状态
dp[0](不偷当前节点):既然我不偷当前节点,那我的两个孩子(左孩子和右孩子)可以偷,也可以不偷。
所以:
dp[0] = max(左孩子偷, 左孩子不偷) + max(右孩子偷, 右孩子不偷) -
状态
dp[1](偷当前节点):既然我偷了当前节点,那我的两个孩子绝对不能偷。
所以:
dp[1] = 当前节点金额 + 左孩子不偷 + 右孩子不偷
python
# Definition for a binary tree node.
# class TreeNode:
# def __init__(self, val=0, left=None, right=None):
# self.val = val
# self.left = left
# self.right = right
class Solution:
def rob(self, root: Optional[TreeNode]) -> int:
# 调用递归函数,返回 [不偷root的最大值, 偷root的最大值]
result = self.rob_tree(root)
return max(result[0], result[1])
def rob_tree(self, node):
# 递归终止条件:遇到空节点,无论偷不偷都是0
if not node:
return [0, 0]
# 后序遍历:先算左子树,再算右子树
left = self.rob_tree(node.left)
right = self.rob_tree(node.right)
# 情况 1:偷当前节点
# 那么左右孩子都不能偷
val_rob = node.val + left[0] + right[0]
# 情况 2:不偷当前节点
# 那么左右孩子可以偷,也可以不偷,选钱最多的那种组合
val_not_rob = max(left[0], left[1]) + max(right[0], right[1])
return [val_not_rob, val_rob]
为什么这样写最快?
-
避免了重复计算: 很多同学会用简单的递归(比如
rob(root.left.left) + ...),那会导致同一个节点被计算多次。而这种"状态回传"的方法,每个节点只会被访问一次,时间复杂度是优化的 O(n)。 -
后序遍历的魅力: 通过自底向上的推导,父节点可以直接利用子节点的计算结果。
-
空间效率: 只需要递归调用栈的空间,不需要额外的哈希表来记录中间结果。
总结:打家劫舍三部曲
-
198 题(线性): dp[i] 依赖于 dp[i-1] 和 dp[i-2]。
-
213 题(环形): 拆分为两个线性问题。
-
337 题(树形): 每个节点返回
[不偷, 偷]两个状态。