想系统提升编程能力、查看更完整的学习路线,欢迎访问 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避免超时 | 性能边界 |
| 全0 | nums = [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),只需常数级额外空间
- 代码简洁,面试中容易写对且不易出错
面试建议:
- 先用30秒口述暴力递归思路(O(2^n)),表明你理解问题本质
- 立即提出"用DP优化",写出解法二(标准DP数组)
- 🏆 重点讲解空间优化:"观察到只需要前两项,用滚动变量优化到O(1)"
- 强调为什么这是最优:时间已达下限,空间进一步优化
- 手动测试边界用例(单间房、两间房),展示严谨性
🎤 面试现场
模拟面试中的完整对话流程,帮你练习"边想边说"。
面试官:请你解决一下这道打家劫舍问题。
你:(审题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五步法:
- 定义状态: dp[i] = 到第i个元素时的最优值
- 状态转移: dp[i] = max/min(选它, 不选它)
- 初始化: dp[0]、dp[1] 直接赋值
- 遍历顺序: 从小到大(i从2到n-1)
- 返回值: 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]
易错点 ⚠️
-
边界初始化错误:
# ❌ 错误:忘记处理 n==1 的情况 dp[1] = max(nums[0], nums[1]) # 数组越界! # ✓ 正确:先判断边界 if n == 1: return nums[0] -
状态转移理解错误:
# ❌ 错误:以为只能隔一个房偷 dp[i] = dp[i-2] + nums[i] # 错!可能不偷i更优 # ✓ 正确:选和不选取最大值 dp[i] = max(dp[i-1], dp[i-2] + nums[i]) -
空间优化时变量更新顺序错误:
# ❌ 错误:先更新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 点体力。在不触发警报的前提下,求最大偷窃金额。
💡 提示(实在想不出来再点开)
加一维状态: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 学习资料都在这里,后续复习和拓展会更省时间。