📖 第73课:打家劫舍

3 阅读15分钟

想系统提升编程能力、查看更完整的学习路线,欢迎访问 AI Compass:github.com/tingaicompa… 仓库持续更新刷题题解、Python 基础和 AI 实战内容,适合想高效进阶的你。

📖 第73课:打家劫舍

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


🎯 题目描述

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

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

示例:

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

约束条件:

  • 1 <= nums.length <= 100
  • 0 <= nums[i] <= 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避免超时性能边界
全0nums = [0,0,0]0特殊值处理

💡 思路引导

生活化比喻

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

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

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

关键洞察

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


🧠 解题思维链

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

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

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

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

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

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) 会被重复计算无数次,但它的结果其实只依赖于 nums[i]、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代码

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

复杂度分析

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

优缺点

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

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

优化思路

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

💡 关键想法:dp[i] = 偷到第i间房时的最大金额,由 dp[i-1] 和 dp[i-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代码

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

复杂度分析

  • 时间复杂度:O(n) — 一次遍历
  • 空间复杂度:O(n) — dp数组

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

优化思路

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

💡 关键想法:用滚动变量 prev2、prev1 代替 dp[i-2]、dp[i-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代码

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

复杂度分析

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

🐍 Pythonic 写法

利用 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数组,dp[i]表示到第i间房的最大金额。初始化dp[0]=nums[0],dp[1]=max(nums[0], nums[1])。然后从i=2开始遍历,每次取"不偷当前"和"偷当前"的最大值...

(写完解法二代码)

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

:可以!观察到状态转移只用到 dp[i-1] 和 dp[i-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技巧卡片 🐍

# 技巧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. 定义状态: dp[i] = 到第i个元素时的最优值
    2. 状态转移: dp[i] = max/min(选它, 不选它)
    3. 初始化: dp[0]、dp[1] 直接赋值
    4. 遍历顺序: 从小到大(i从2到n-1)
    5. 返回值: dp[n-1] 或 max(dp)
  • 模板代码:
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. 边界初始化错误:

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

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

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

🏗️ 工程实战(选读)

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

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

🏋️ 举一反三

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

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

📝 课后小测

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

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

💡 提示(实在想不出来再点开)

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

✅ 参考答案
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间房,任意体力下的最大值

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


如果这篇内容对你有帮助,推荐收藏 AI Compass:github.com/tingaicompa… 更多系统化题解、编程基础和 AI 学习资料都在这里,后续复习和拓展会更省时间。