算法训练营 Day38 - 动态规划part07

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 <= numsi <= 400

dpi:考虑下标i(包括i)以内的房屋,最多可以偷窃的金额为dpi

决定dpi的因素就是第i房间偷还是不偷。

如果偷第i房间,那么dpi = dpi - 2 + numsi ,即:第i-1房一定是不考虑的,找出 下标i-2(包括i-2)以内的房屋,最多可以偷窃的金额为dpi-2 加上第i房间偷到的钱。

如果不偷第i房间,那么dpi = dpi - 1,即考 虑i-1房,(注意这里是考虑,并不是一定要偷i-1房,这是很多同学容易混淆的点

然后dpi取最大值,即dpi = max(dpi - 2 + numsi, dpi - 1);

从递推公式dpi = max(dpi - 2 + numsi, dpi - 1);可以看出,递推公式的基础就是dp0 和 dp1

从dpi的定义上来讲,dp0 一定是 nums0,dp1就是nums0和nums1的最大值即:dp1 = max(nums0, nums1);

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 <= numsi <= 1000。

核心思路:化圆为线

由于首尾相连,第一间房最后一间房不能同时偷。因此,我们可以将问题转化为两个 198 题(线性排列):

  1. 不偷最后一间房: 范围是 nums[0 : n-1]

  2. 不偷第一间房: 范围是 nums[1 : n] 最后取两者的最大值。

robRange 函数

这个函数其实就是 198. 打家劫舍 的优化版。

  • 状态定义:

    • prev_max: 代表偷到"前前一家"时的最大金额(相当于 dpi-2)。

    • curr_max: 代表偷到"前一家"时的最大金额(相当于 dpi-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]

为什么这样写最快?

  1. 避免了重复计算: 很多同学会用简单的递归(比如 rob(root.left.left) + ...),那会导致同一个节点被计算多次。而这种"状态回传"的方法,每个节点只会被访问一次,时间复杂度是优化的 O(n)。

  2. 后序遍历的魅力: 通过自底向上的推导,父节点可以直接利用子节点的计算结果。

  3. 空间效率: 只需要递归调用栈的空间,不需要额外的哈希表来记录中间结果。


总结:打家劫舍三部曲

  • 198 题(线性): dpi 依赖于 dpi-1 和 dpi-2

  • 213 题(环形): 拆分为两个线性问题。

  • 337 题(树形): 每个节点返回 [不偷, 偷] 两个状态。

相关推荐
通信小呆呆15 分钟前
当算法有了“五感”:多模态数据融合如何向人体感官协同学习?
人工智能·学习·算法·机器学习·机器人
benben04441 分钟前
强化学习之DQN算法族(基于gymnasium开发)
算法
何以解忧,唯有..2 小时前
Go语言循环语句详解:for、range与循环控制
开发语言·算法·golang
想吃火锅10053 小时前
【leetcode】88.合并两个有序数组js
算法
生成论实验室3 小时前
机器人:一个自主运动的系统
人工智能·算法·语言模型·机器人·自动驾驶·agi·安全架构
Qres8213 小时前
算法复键——树状数组
数据结构·算法
H178535090963 小时前
SolidWorks第四部分_直接实体建模特征9_替换面原理
线性代数·算法·机器学习·3d建模·solidworks
不会就选b3 小时前
算法日常・每日刷题--<二分查找>3
算法
绿算技术4 小时前
Mooncake 与绿算ForinnBase GroundPool如何联手打破推理僵局?
科技·算法·架构