Files

10 KiB
Raw Permalink Blame History

tags, create time
tags create time
算法/动态规划
基础DP
选与不选
2026-05-16 19:00

83. 打家劫舍(House Robber)

[!quote] LeetCode 原题 · 中等 你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。

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

  • 输入范围:1 <= nums.length <= 100,0 <= nums[i] <= 400

题面

示例 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 。

思路

第一步:明确"决策"是什么

想象你站在街边,一排房屋在你面前。对每一间房,你只有两个选择——

偷 或者 不偷。

但有一个硬性约束:不能偷相邻的两间房。目标是在这个约束下最大化总金额。

[!question] 💡 思考 1 假设你来到了第 i 间房子,前面已经做出了最优决策。此时你最需要记住的信息是什么?

答案是:到第 i-1 间房为止的最高金额和到第 i-2 间房为止的最高金额。因为对当前房子的决策完全依赖于这两个历史最优值。

第二步:定义状态和转移方程

设 dp[i] 表示偷到第 i 间房子(可选第 i 间)时能拿到的最高金额。注意:dp[i] 并不强制偷第 i 间,它只是"考虑前 i+1 间房子"的最优结果。

对第 i 间房子,你有两个选择:

选择 说明 金额贡献
偷第 i 间 那么第 i-1 间不能偷,金额 = dp[i-2] + nums[i] dp[i-2] + nums[i]
不偷第 i 间 最优就等于盗到第 i-1 间的结果,金额 = dp[i-1] dp[i-1]

取较大者:

dp[i] = max(dp[i-1], dp[i-2] + nums[i])

这就是经典的状态转移方程。核心思想是——「选或不选,取较优」。

flowchart TD
    subgraph "第 i 间房的两种选择"
        A["面对第 i 间房\nnums[i]"] --> B["偷!"]
        A --> C["不偷"]
        B --> D["dp[i-2] + nums[i]\n跳过前一间,加上当前"]
        C --> E["dp[i-1]\n沿用前一步的结果"]
        D --> F["dp[i] = max(D, E)"]
        E --> F
    end

    style A fill:#e7f3ff,stroke:#3b82f6
    style F fill:#dcfce7,stroke:#16a34a

第三步:确定边界条件

边界是解题的地基,需要小心铺设:

情况 dp 值 理由
i = 0(只有一间房) dp[0] = nums[0] 只有一间,直接偷
i = 1(有两间房) dp[1] = max(nums[0], nums[1]) 只能偷一间,选大的那个

有了这两块砖,后面的墙就能一步步垒起来。

第四步:逐步演示

以 [2, 7, 9, 3, 1] 为例:

i nums[i] 选择偷 (dp[i-2]+nums[i]) 选择不偷 (dp[i-1]) dp[i] = max
0 2 — — 2
1 7 — 2 7
2 9 2+9=11 7 11
3 3 7+3=10 11 11
4 1 11+1=12 11 12

最终答案:12。对应方案是偷第 0、2、4 间房(金额 2+9+1=12)。

flowchart LR
    subgraph "dp表逐步填充"
        A["i=0: dp=2\n偷第0间"] --> B["i=1: dp=7\n偷第1间(比偷0间更赚)"]
        B --> C["i=2: dp=11\n偷第2间(2+9=11>7)"]
        C --> D["i=3: dp=11\n不偷第3间(11>10)"]
        D --> E["i=4: dp=12\n偷第4间(11+1=12>11)"]
    end

    style A fill:#dbeafe,stroke:#2563eb
    style E fill:#dcfce7,stroke:#16a34a

[!note] 📝 关键洞察 注意 i=3 时选择不偷——这不是因为你贪心不足,而是因为保留之前的 11 比冒险加 3(需要放弃 9)更划算。动态规划的精髓就在这种局部最优累积成全局最优的过程。

第五步:空间优化

观察状态转移方程:

dp[i] = max(dp[i-1], dp[i-2] + nums[i])

计算 dp[i] 只用到了前两个值,和 81-爬楼梯 的滚动变量优化思路完全一致。所以可以把空间从 O(n) 降到 O(1):

变量 含义 初始值
prev2 dp[i-2] 0("虚拟的前前置状态")
prev1 dp[i-1] 0("虚拟的前置状态")

用一个统一的循环来处理所有情况——让 prev1 始终代表"考虑完前几间房的最优结果",遇到新房子时决定要不要加上它的钱。

flowchart TD
    subgraph "O(1) 空间优化版本"
        start("遍历每间房") --> decide{"偷还是
不偷?"}
        decide -->|"偷: prev2 +
当前金额"| take["new = prev2 +
nums[i]"]
        decide -->|"不偷:
保持 prev1"| keep["keep = prev1"]
        take --> pick["dp[i] = max(take, keep)"]
        keep --> pick
        pick --> roll["prev2 = prev1<br/>prev1 = new"]
        roll --> check{i < n?}
        check -->|"是"| back["prev2←prev1<br/>prev1←dp[i]"]
        check -->|"否"| endNode["返回 prev1"]
        back --> decide
    end

    style start fill:#dbeafe,stroke:#2563eb
    style endNode fill:#dcfce7,stroke:#16a34a

代码提示

在动手写代码之前,想一想这些关键决策点:

[!question] 💡 思考 1 边界条件可以统一处理吗?还是需要特殊判断 n == 1 的情况?

可以尝试一种更简洁的方式:初始化 prev2 = 0、prev1 = 0,然后对所有房子统一执行 max(prev1, prev2 + num) 的逻辑。这样即使只有一间房也能正确计算,无需额外分支。

[!question] 💡 思考 2 Go 语言中 max 函数怎么写?Go 标准库有没有内置的 max?

Go 1.21+ 的标准库已经内置了泛型 max 函数。如果项目使用旧版本,手动实现也很简单——比较两个整数取较大值即可。

技巧

通用的"选与不选"框架

打家劫舍模型可以抽象为一道通用模式——在一条线上做决策,相邻元素不能同时选取:

  1. 定义状态:dp[i] = 考虑前 i 个元素的最优解
  2. 列转移:偷 vs 不偷 → max(dp[i-1], dp[i-2] + nums[i])
  3. 铺边界:确定前 1~2 个元素的基准值
  4. 滚空间:观察是否只需要常数额外空间

这个框架直接适用于很多变体问题:

变体 变化点 调整方式
House Robber II(环形排列) 首尾房屋相邻 拆成两趟线性 DP
House Robber III(二叉树结构) 树形结构 改为树形 DP,每个节点返回[偷/不偷]两个状态
删除并获得点数 数值连续则冲突 等价于打家劫舍模型(先统计点数)

为什么不需要"看三步"?

有人可能会问:是不是需要考虑 dp[i-3]?比如跳过两间再偷?

不需要!原因是:如果你跳过了两间(不偷 i-1 也不偷 i-2),那一定偷了 i-3。而 dp[i-2] 本身就已经是考虑前 i-2 间的最优解——它要么包含了偷 i-3 的方案,要么不包含。无论哪种,dp[i-2] + nums[i] 都已经覆盖了这个情况。

动态规划的本质不是穷举所有可能,而是证明:某些"看似遗漏"的路径已经被前面的最优子结构涵盖了。

代码

Go 语言实现

版本一:O(n) 空间(清晰直观)

// rob returns the maximum amount you can rob from houses arranged in a row.
// You cannot rob two adjacent houses.
func rob(nums []int) int {
	n := len(nums)
	if n == 0 {
		return 0
	}
	if n == 1 {
		return nums[0]
	}

	// dp[i] 表示考虑前 i+1 间房子(第 0 ~ i 间)能偷到的最高金额
	dp := make([]int, n)

	// 边界条件
	dp[0] = nums[0]
	dp[1] = max(nums[0], nums[1])

	// 自底向上填表
	for i := 2; i < n; i++ {
		// 选或不选:偷当前间 (dp[i-2]+nums[i]) vs 不偷 (dp[i-1])
		dp[i] = max(dp[i-1], dp[i-2]+nums[i])
	}

	return dp[n-1]
}

版本二:O(1) 空间(推荐 ✅)

// rob returns the maximum amount you can rob from houses arranged in a row.
// Space optimized to O(1) using rolling variables.
func rob(nums []int) int {
	prev2 := 0 // 相当于 dp[i-2],初始为"不存在的前置状态"
	prev1 := 0 // 相当于 dp[i-1],初始为"不存在的前置状态"

	for _, num := range nums {
		// 当前最优 = max(不偷这间→prev1, 偷这间→prev2+num)
		cur := max(prev1, prev2+num)
		// 滚动更新
		prev2 = prev1
		prev1 = cur
	}

	return prev1
}

执行过程演示(版本二,nums = [2, 7, 9, 3, 1])

初始: prev2=0, prev1=0

num=2: cur=max(0, 0+2)=2    → prev2=0, prev1=2    ← 偷第0间
num=7: cur=max(2, 0+7)=7    → prev2=2, prev1=7    ← 改偷第1间(更赚)
num=9: cur=max(7, 2+9)=11   → prev2=7, prev1=11   ← 偷第2间(2+9=11)
num=3: cur=max(11, 7+3)=11  → prev2=11, prev1=11  ← 不偷第3间(保留11)
num=1: cur=max(11, 11+1)=12 → prev2=11, prev1=12  ← 偷第4间(11+1=12)

返回 prev1 = 12 ✅

复杂度分析

  • 时间复杂度:O(n) — 遍历一次数组,每次只做常数次操作
  • 空间复杂度:O(n) → O(1) — 通过滚动变量将辅助空间降为常数级

举一反三

这道题是 "线性 DP + 选与不选" 的经典模板,几乎所有后续变体都建立在此之上:

  • 81-爬楼梯 — 同样是线性 DP,转移来自相邻的两个前驱状态
  • House Robber II(LeetCode 213)— 房屋变成环形排列,需要将问题拆成两趟线性 DP(偷首不偷尾 / 偷尾不偷首)
  • House Robber III(LeetCode 337)— 房屋排列成二叉树,改为树形 DP
    1. 链表最大孪生和 — 虽然表面不同,但链表中"删与不删"的选择思路与之相通

[!summary] 📌 本节要点

  1. 状态定义:dp[i] = 考虑前 i+1 间房子的最优解
  2. 状态转移:dp[i] = max(dp[i-1], dp[i-2] + nums[i]) —— 选或不选
  3. 空间优化:滚动变量替代数组,O(n)→O(1),与爬楼梯异曲同工
  4. 通用框架:线性结构上的"相邻不可同时选取"问题均可套用此模式