[特殊字符] 第73课:打家劫舍

想系统提升编程能力、查看更完整的学习路线,欢迎访问 AI Compass:https://github.com/tingaicompass/AI-Compass

仓库持续更新刷题题解、Python 基础和 AI 实战内容,适合想高效进阶的你。

📖 第73课:打家劫舍

模块 :动态规划 | 难度 :Medium ⭐⭐⭐
LeetCode 链接 :https://leetcode.cn/problems/house-robber/
前置知识 :第71课《爬楼梯》
预计学习时间:25分钟


🎯 题目描述

你是一个专业的小偷,计划偷窃沿街的房屋。每间房都藏有一定的现金,但有个安全系统会检测:如果两间相邻的房屋在同一晚上被小偷闯入,系统就会自动报警。

给定一个代表每个房屋存放金额的非负整数数组,计算你在不触动警报系统的前提下,一夜之内能够偷窃到的最高金额。

示例:

复制代码
输入:nums = [1,2,3,1]
输出:4
解释:偷窃1号房(金额1)和3号房(金额3),总金额 = 1 + 3 = 4

约束条件:

  • 1 <= nums.length <= 100
  • 0 <= numsi <= 400
  • 不能偷相邻的房屋

🧪 边界用例(面试必考)

用例类型 输入 期望输出 考察点
最小输入 nums = 1 1 只有一间房
两间房 nums = 2,1 2 选较大的
交替最优 nums = 2,7,9,3,1 12 选0+2+4=2+9+1=12
大规模 n=100,全为400 需要DP避免超时 性能边界
全0 nums = 0,0,0 0 特殊值处理

💡 思路引导

生活化比喻

想象你在玩一个"石头跳跃"游戏,站在每块石头上都能捡到金币,但你不能连续踩两块相邻的石头,否则就会掉下去。

🐌 笨办法 :用回溯法枚举所有可能的偷窃组合(偷/不偷第1间,偷/不偷第2间...),时间复杂度 O(2n),100间房就是2100种可能!

🚀 聪明办法 :站在每间房门口,只需要问自己:"如果我已经知道'偷到前一间的最大值'和'偷到前两间的最大值',那当前这间房怎么选收益最大?" 这就是动态规划的核心思想------用已知答案推导新答案。

关键洞察

到第i间房时,有两个选择:偷它或不偷它。偷它就不能偷i-1,不偷就沿用i-1的最大值。取两者最大即可。


🧠 解题思维链

这一节模拟你在面试中"从零开始思考"的过程。

Step 1:理解题目 → 锁定输入输出

  • 输入:整数数组 nums,表示每间房的金额
  • 输出:最大偷窃金额(整数)
  • 限制:不能偷相邻的房屋,即 numsi 和 numsi+1 不能同时偷

Step 2:先想笨办法(暴力法)

用递归枚举每间房"偷"或"不偷"两种选择,求所有合法方案的最大值:

python 复制代码
def rob(i):
    if i < 0: return 0
    return max(rob(i-1), rob(i-2) + nums[i])
  • 时间复杂度:O(2^n) --- 每层分裂成两个分支,指数爆炸
  • 瓶颈在哪:大量重复计算,比如 rob(5) 会被 rob(6) 和 rob(7) 重复计算多次

Step 3:瓶颈分析 → 优化方向

暴力递归中,rob(i) 会被重复计算无数次,但它的结果其实只依赖于 numsi、rob(i-1)、rob(i-2)。

  • 核心问题:重复计算子问题
  • 优化思路:把计算过的结果记录下来,下次遇到直接查表 → 记忆化搜索 or 动态规划

Step 4:选择武器

  • 选用:动态规划(DP)
  • 理由:问题有最优子结构 (当前最优解依赖子问题最优解)和重叠子问题(大量重复计算),是DP的典型特征

🔑 模式识别提示:当题目出现"最大值/最小值"且每一步有"选/不选"的决策时,优先考虑"DP五步法"


🔑 解法一:自顶向下记忆化递归(直觉法)

思路

在暴力递归的基础上,用一个字典(或数组)记录已经计算过的 rob(i),避免重复计算。

图解过程

复制代码
输入:nums = [2, 7, 9, 3, 1]

递归树(带记忆):
           rob(4)
        /         \
    rob(3)       rob(2)+1
   /     \        /      \
rob(2)  rob(1)+3 rob(1)  rob(0)+9
 /  \    /  \      /  \     /  \
...  ... ...  ... ... ... ... ...
(多次遇到 rob(2)、rob(1) 时直接查表,不再重复计算)

最终:rob(4) = max(rob(3), rob(2)+1) = max(12, 11) = 12

Python代码

python 复制代码
from typing import List


def rob_memo(nums: List[int]) -> int:
    """
    解法一:自顶向下记忆化递归
    思路:用字典缓存已计算的子问题结果
    """
    memo = {}  # 缓存

    def dp(i: int) -> int:
        # 递归终止条件
        if i < 0:
            return 0
        if i in memo:
            return memo[i]  # 查表

        # 状态转移:当前房偷或不偷
        memo[i] = max(dp(i - 1), dp(i - 2) + nums[i])
        return memo[i]

    return dp(len(nums) - 1)


# ✅ 测试
print(rob_memo([1, 2, 3, 1]))  # 期望输出:4
print(rob_memo([2, 7, 9, 3, 1]))  # 期望输出:12
print(rob_memo([2, 1, 1, 2]))  # 期望输出:4

复杂度分析

  • 时间复杂度 😮(n) --- 每个子问题只计算一次,共 n 个子问题
    • 具体地说:如果 n=100,大约需要 100 次计算(对比暴力的 2^100 次!)
  • 空间复杂度😮(n) --- memo字典存储 n 个结果 + 递归栈深度 O(n)

优缺点

  • ✅ 代码自然,和递归思路一致,容易理解
  • ❌ 有递归栈开销,可能栈溢出(虽然题目n<=100不会)

⚡ 解法二:自底向上动态规划(标准DP)

优化思路

既然已经知道递归的方向是从 i=0 推到 i=n-1,那不如反过来,从小到大直接用循环填表,省去递归开销。

💡 关键想法:dpi = 偷到第i间房时的最大金额,由 dpi-1 和 dpi-2 推导而来

图解过程

复制代码
输入:nums = [2, 7, 9, 3, 1]

初始化:
dp[0] = 2 (只有第0间,必须偷)
dp[1] = max(2, 7) = 7 (偷第0或第1间,选大的)

迭代计算:
i=2: dp[2] = max(dp[1], dp[0]+9) = max(7, 2+9) = 11
     └─ 不偷2  └─ 偷2(要跳过1)
i=3: dp[3] = max(dp[2], dp[1]+3) = max(11, 7+3) = 11
i=4: dp[4] = max(dp[3], dp[2]+1) = max(11, 11+1) = 12

最终答案:dp[4] = 12

状态转移图示:

复制代码
房屋:   [ 2,  7,  9,  3,  1]
dp值:   [ 2,  7, 11, 11, 12]
         └──────┘  └──────┘
         选dp[i-1]  选dp[i-2]+nums[i]

Python代码

python 复制代码
def rob_dp(nums: List[int]) -> int:
    """
    解法二:自底向上动态规划
    思路:用数组dp[i]记录到第i间房的最大金额
    """
    n = len(nums)
    if n == 0:
        return 0
    if n == 1:
        return nums[0]

    # 定义状态数组
    dp = [0] * n
    dp[0] = nums[0]  # 只有一间房,偷它
    dp[1] = max(nums[0], nums[1])  # 两间房,偷大的

    # 状态转移
    for i in range(2, n):
        dp[i] = max(dp[i - 1], dp[i - 2] + nums[i])
        # 不偷i      偷i(跳过i-1)

    return dp[n - 1]


# ✅ 测试
print(rob_dp([1, 2, 3, 1]))  # 期望输出:4
print(rob_dp([2, 7, 9, 3, 1]))  # 期望输出:12
print(rob_dp([2, 1, 1, 2]))  # 期望输出:4

复杂度分析

  • 时间复杂度😮(n) --- 一次遍历
  • 空间复杂度😮(n) --- dp数组

🏆 解法三:空间优化DP(最优解)

优化思路

观察状态转移方程 dp[i] = max(dp[i-1], dp[i-2] + nums[i]),发现当前状态只依赖前两个状态,不需要保存整个数组,只需要两个变量!

💡 关键想法:用滚动变量 prev2、prev1 代替 dpi-2、dpi-1

图解过程

复制代码
输入:nums = [2, 7, 9, 3, 1]

初始:prev2=0, prev1=0

i=0: curr = max(0, 0+2) = 2
     更新:prev2=0, prev1=2

i=1: curr = max(2, 0+7) = 7
     更新:prev2=2, prev1=7

i=2: curr = max(7, 2+9) = 11
     更新:prev2=7, prev1=11

i=3: curr = max(11, 7+3) = 11
     更新:prev2=11, prev1=11

i=4: curr = max(11, 11+1) = 12
     更新:prev2=11, prev1=12

答案:12

Python代码

python 复制代码
def rob_optimized(nums: List[int]) -> int:
    """
    解法三:空间优化DP(最优解)
    思路:用两个变量代替数组,空间降到O(1)
    """
    prev2 = 0  # dp[i-2]
    prev1 = 0  # dp[i-1]

    for num in nums:
        curr = max(prev1, prev2 + num)  # 状态转移
        prev2 = prev1  # 更新前两项
        prev1 = curr

    return prev1


# ✅ 测试
print(rob_optimized([1, 2, 3, 1]))  # 期望输出:4
print(rob_optimized([2, 7, 9, 3, 1]))  # 期望输出:12
print(rob_optimized([2, 1, 1, 2]))  # 期望输出:4

复杂度分析

  • 时间复杂度 😮(n) --- 一次遍历
    • 具体地说:n=100 时,只需100次操作
  • 空间复杂度😮(1) --- 只用3个变量(prev2、prev1、curr)

🐍 Pythonic 写法

利用 Python 的多重赋值,代码可以更简洁:

python 复制代码
def rob_pythonic(nums: List[int]) -> int:
    """Pythonic写法:利用元组解包"""
    prev2, prev1 = 0, 0
    for num in nums:
        prev2, prev1 = prev1, max(prev1, prev2 + num)
    return prev1

⚠️ 面试建议 :先写清晰的解法二展示DP思路,再提解法三展示优化能力,最后补充Pythonic写法展示语言功底。

面试官更看重你的思考过程,而非代码行数。


📊 解法对比

维度 解法一:记忆化递归 解法二:标准DP 🏆 解法三:空间优化DP(最优)
时间复杂度 O(n) O(n) O(n) ← 时间最优
空间复杂度 O(n) O(n) O(1) ← 空间最优
代码难度 中等 简单 简单
面试推荐 ⭐⭐ ⭐⭐⭐ ⭐⭐⭐ ← 首选
适用场景 理解递归思路 标准DP模板学习 面试首选,时空双优

为什么解法三是最优解:

  • 时间 O(n) 已经是理论最优(至少要看一遍所有房屋)
  • 空间优化到 O(1),只需常数级额外空间
  • 代码简洁,面试中容易写对且不易出错

面试建议:

  1. 先用30秒口述暴力递归思路(O(2^n)),表明你理解问题本质
  2. 立即提出"用DP优化",写出解法二(标准DP数组)
  3. 🏆 重点讲解空间优化:"观察到只需要前两项,用滚动变量优化到O(1)"
  4. 强调为什么这是最优:时间已达下限,空间进一步优化
  5. 手动测试边界用例(单间房、两间房),展示严谨性

🎤 面试现场

模拟面试中的完整对话流程,帮你练习"边想边说"。

面试官:请你解决一下这道打家劫舍问题。

:(审题30秒)好的,这道题要求在不偷相邻房屋的前提下,求能偷到的最大金额。让我先想一下...

我的第一个想法是用递归:对每间房选择"偷"或"不偷",递归求最大值。但这样时间复杂度是 O(2^n),会超时。

观察到这是一个典型的动态规划问题 :到第i间房时的最大金额,只依赖于前面的结果。我可以用DP优化到 O(n)。核心状态转移是:dp[i] = max(dp[i-1], dp[i-2] + nums[i])

面试官:很好,请写一下代码。

:(边写边说)我先定义dp数组,dpi表示到第i间房的最大金额。初始化dp0=nums0,dp1=max(nums0, nums1)。然后从i=2开始遍历,每次取"不偷当前"和"偷当前"的最大值...

(写完解法二代码)

面试官:空间能不能优化?

:可以!观察到状态转移只用到 dpi-1 和 dpi-2,不需要保存整个数组。我可以用两个变量 prev1、prev2 滚动更新,空间优化到 O(1)。

(写出解法三代码)

面试官:测试一下?

:用示例 2,7,9,3,1 走一遍:

  • i=0: curr=max(0, 0+2)=2
  • i=1: curr=max(2, 0+7)=7
  • i=2: curr=max(7, 2+9)=11
  • i=3: curr=max(11, 7+3)=11
  • i=4: curr=max(11, 11+1)=12 ✓

再测边界 1:直接返回1 ✓

高频追问

追问 应答策略
"如果房屋是环形的(首尾相邻)怎么办?" 这就是LeetCode 213。需要拆成两个子问题:1)偷第一间,不偷最后一间 2)不偷第一间,偷最后一间,取两者最大值
"如果每间房有不同的偷窃成本呢?" 状态转移改为 dp[i] = max(dp[i-1], dp[i-2] + value[i] - cost[i]),本质不变
"能用贪心吗?" 不能。反例:2,1,1,2,贪心选2+2=4 ✓,但 1,100,1,1,100 贪心会选100+100=200,最优是1+100+1+100=202,需要DP
"这个思路能解决什么其他题?" 股票买卖(LC 121)、删除并获得点数(LC 740)、粉刷房子(LC 256),都是"选/不选"DP模式

🎓 知识点总结

Python技巧卡片 🐍

python 复制代码
# 技巧1:多重赋值优雅更新 --- 一行实现滚动变量
prev2, prev1 = prev1, max(prev1, prev2 + num)
# 等价于:
# temp = max(prev1, prev2 + num)
# prev2 = prev1
# prev1 = temp

# 技巧2:边界处理 --- 提前返回简化代码
if n == 1: return nums[0]
if n == 2: return max(nums[0], nums[1])
# 避免后续判断,代码更清晰

# 技巧3:functools.lru_cache装饰器 --- 自动记忆化
from functools import lru_cache
@lru_cache(maxsize=None)
def rob(i):
    if i < 0: return 0
    return max(rob(i-1), rob(i-2) + nums[i])

💡 底层原理(选读)

为什么DP能优化指数复杂度?

暴力递归的问题在于重复计算。以 rob(5) 为例:

  • rob(6) 会算一次 rob(5)
  • rob(7) 也会算一次 rob(5)
  • rob(8)、rob(9)...都会重复算

DP的本质是记录已知结果,避免重复计算。通过空间换时间,把指数级的重复计算减少到线性。

DP vs 贪心的区别?

  • 贪心:每一步都选局部最优,不需要回头看(如跳跃游戏:每次跳最远)
  • DP:当前决策依赖于之前的多个状态,需要记录历史(如打家劫舍:偷不偷当前房,要看前两间的结果)

判断方法:如果"当前最优"能直接推出"全局最优"→贪心;如果需要比较多个历史状态→DP

算法模式卡片 📐

  • 模式名称:线性DP --- 选/不选决策
  • 适用条件 :
    • 每一步有"选择A"或"选择B"两种互斥决策
    • 当前最优解依赖于前面有限的几个状态(通常是前1-2个)
    • 求最优值(最大/最小)
  • 识别关键词:"不能相邻"、"隔一个"、"选或不选"、"最大收益"
  • DP五步法 :
    1. 定义状态: dpi = 到第i个元素时的最优值
    2. 状态转移: dpi = max/min(选它, 不选它)
    3. 初始化: dp0、dp1 直接赋值
    4. 遍历顺序: 从小到大(i从2到n-1)
    5. 返回值: dpn-1 或 max(dp)
  • 模板代码:
python 复制代码
def linear_dp_select(nums):
    n = len(nums)
    if n == 0: return 0
    if n == 1: return nums[0]

    dp = [0] * n
    dp[0] = nums[0]
    dp[1] = max(nums[0], nums[1])

    for i in range(2, n):
        dp[i] = max(dp[i-1], dp[i-2] + nums[i])
        #        不选i      选i(跳过i-1)

    return dp[n-1]

易错点 ⚠️

  1. 边界初始化错误:

    python 复制代码
    # ❌ 错误:忘记处理 n==1 的情况
    dp[1] = max(nums[0], nums[1])  # 数组越界!
    
    # ✓ 正确:先判断边界
    if n == 1: return nums[0]
  2. 状态转移理解错误:

    python 复制代码
    # ❌ 错误:以为只能隔一个房偷
    dp[i] = dp[i-2] + nums[i]  # 错!可能不偷i更优
    
    # ✓ 正确:选和不选取最大值
    dp[i] = max(dp[i-1], dp[i-2] + nums[i])
  3. 空间优化时变量更新顺序错误:

    python 复制代码
    # ❌ 错误:先更新prev1,再更新prev2
    prev1 = max(prev1, prev2 + num)
    prev2 = prev1  # 错!prev2被错误覆盖
    
    # ✓ 正确:同时更新或用临时变量
    prev2, prev1 = prev1, max(prev1, prev2 + num)

🏗️ 工程实战(选读)

这个算法思想在真实项目中的应用,让你知道"学了有什么用"。

  • 场景1:任务调度系统 --- 服务器资源分配时,某些任务不能同时运行(如都需要独占GPU),求最大吞吐量
  • 场景2:投资组合优化 --- 某些股票有关联性(如同行业),不能同时持有,求最大收益组合
  • 场景3:广告位竞价 --- 相邻广告位不能给同一广告主(避免霸屏),求收益最大的广告组合

🏋️ 举一反三

完成本课后,试试这些同类题目来巩固知识:

题目 难度 相关知识点 提示
LeetCode 213. 打家劫舍II Medium 环形DP 拆成两个子问题:含首不含尾 vs 含尾不含首
LeetCode 337. 打家劫舍III Medium 树形DP 在二叉树上做DP,每个节点记录偷/不偷两种状态
LeetCode 740. 删除并获得点数 Medium 线性DP 转化为打家劫舍:值相同的数字看作一间房
LeetCode 256. 粉刷房子 Medium 选择DP 每间房选颜色,相邻不同色,求最小成本

📝 课后小测

试试这道变体题,不要看答案,自己先想5分钟!

题目 :如果每间房有一个"偷窃难度"数组 difficulty[],偷第i间房需要消耗 difficulty[i] 点体力,你总共有 energy 点体力。在不触发警报的前提下,求最大偷窃金额。
💡 提示(实在想不出来再点开)

加一维状态:dpij = 偷到第i间房,消耗j点体力时的最大金额。状态转移时要判断体力是否足够。
✅ 参考答案

python 复制代码
def rob_with_energy(nums: List[int], difficulty: List[int], energy: int) -> int:
    """
    二维DP:dp[i][j] = 偷到第i间房,用了j点体力时的最大金额
    """
    n = len(nums)
    # dp[i][j] 表示前i间房,消耗j体力的最大金额
    dp = [[-1] * (energy + 1) for _ in range(n + 1)]
    dp[0][0] = 0  # 0间房,0体力,0金额

    for i in range(1, n + 1):
        for j in range(energy + 1):
            # 不偷第i间房(下标i-1)
            dp[i][j] = dp[i-1][j]

            # 偷第i间房(需要跳过i-1,体力够)
            if j >= difficulty[i-1] and i >= 2:
                if dp[i-2][j - difficulty[i-1]] != -1:
                    dp[i][j] = max(dp[i][j],
                                   dp[i-2][j - difficulty[i-1]] + nums[i-1])
            elif i == 1 and j >= difficulty[0]:
                dp[i][j] = nums[0]

    return max(dp[n])  # 返回n间房,任意体力下的最大值

核心思路:在原状态 dpi 基础上增加一维"体力消耗",变成二维DP。时间复杂度 O(n * energy),空间可优化到 O(energy)。


如果这篇内容对你有帮助,推荐收藏 AI Compass:https://github.com/tingaicompass/AI-Compass

更多系统化题解、编程基础和 AI 学习资料都在这里,后续复习和拓展会更省时间。

相关推荐
海石10 小时前
1500分的题目,确实有实力,不过还是我略胜一筹
算法·leetcode
海石10 小时前
【记忆化搜索】条条大路通AC,走好适合你的那一条,走到后再考虑走得快
算法·leetcode
Jerry12 小时前
LeetCode 151. 反转字符串中的单词
算法
gugucoding13 小时前
31. 【C语言】堆栈与队列的实现
c语言·开发语言·数据结构·链表
ChaoZiLL14 小时前
我的数据结构3——链表(link list)
数据结构·链表
a11177615 小时前
LM 算法迭代过程动画演示(SLAM)
算法
头茬韭菜15 小时前
Context 的生死抉择:四层压缩、截断算法与 Session Memory
算法·ai
Jerry15 小时前
LeetCode 541. 反转字符串 II
算法
Jerry15 小时前
LeetCode 344. 反转字符串
算法
搞科研的小刘选手16 小时前
【香港大学主办&IEEE出版】第六届计算机视觉、应用与算法国际学术会议(CVAA 2026)
算法·计算机视觉·应用·学术会议