每日一算:打家劫舍
“打家劫舍” 是动态规划领域的经典入门题,核心矛盾是 “不能偷窃相邻房屋”,目标是找到偷窃的最高金额。本文将从问题本质出发,拆解两种主流解题思路(动态规划、空间优化),并附上 C++ 实现,帮助你理解 “状态转移” 的核心思想。
一、问题重述
题目要求
给定非负整数数组 nums
,每个元素代表一间房屋的现金金额。不能同时偷窃相邻房屋(否则触发警报),计算一夜内能偷窃到的最高金额。
示例理解
- 示例 1:
nums = [1,2,3,1]
可选方案:偷第 1+3 间(1+3=4)或第 2+4 间(2+1=3),最高金额为 4。 - 示例 2:
nums = [2,7,9,3,1]
最优方案:偷第 1+3+5 间(2+9+1=12),最高金额为 12。
二、核心思路:动态规划的 “状态转移”
问题的关键是用历史状态推导当前状态:对于第 i
间房屋,只有两种选择 ——“偷” 或 “不偷”,这两种选择对应不同的前序状态。
状态定义
设 dp[i]
为 “偷窃前 i
间房屋的最高金额”(i
从 0 开始,对应 nums[0]
到 nums[i-1]
)。
状态转移方程
- 不偷第
i
间房屋:最高金额等于偷前i-1
间的最高金额,即dp[i] = dp[i-1]
; - 偷第
i
间房屋:不能偷第i-1
间,最高金额等于偷前i-2
间的金额 + 第i
间的金额,即dp[i] = dp[i-2] + nums[i-1]
(nums[i-1]
是第i
间的金额,因数组索引从 0 开始)。
最终,dp[i]
取两种选择的最大值:
dp[i] = max(dp[i-1], dp[i-2] + nums[i-1])
边界条件
dp[0] = 0
:没有房屋可偷,金额为 0;dp[1] = nums[0]
:只有 1 间房屋,偷它的金额。
三、解题思路与实现
思路 1:标准动态规划(清晰易懂)
按照上述状态定义和转移方程,用数组存储所有中间状态,适合初学者理解。
步骤拆解
- 处理特殊情况(数组长度为 0 或 1);
- 初始化
dp
数组,长度为nums.size() + 1
; - 遍历数组,按状态转移方程计算
dp[i]
; - 返回
dp[nums.size()]
(偷完所有房屋的最高金额)。
C++ 实现代码
#include <iostream>
#include <vector>
#include <algorithm> // 用于 max 函数using namespace std;class Solution {
public:int rob(vector<int>& nums) {int n = nums.size();// 特殊情况:没有房屋或只有1间房屋if (n == 0) return 0;if (n == 1) return nums[0];// dp[i]:偷前i间房屋的最高金额vector<int> dp(n + 1);// 边界条件dp[0] = 0;dp[1] = nums[0];// 遍历计算 dp[2] 到 dp[n]for (int i = 2; i <= n; ++i) {// 状态转移:不偷第i间(dp[i-1]) vs 偷第i间(dp[i-2] + nums[i-1])dp[i] = max(dp[i-1], dp[i-2] + nums[i-1]);}return dp[n];}
};// 测试代码
int main() {Solution sol;// 示例1vector<int> nums1 = {1,2,3,1};cout << "示例1输出:" << sol.rob(nums1) << endl; // 4// 示例2vector<int> nums2 = {2,7,9,3,1};cout << "示例2输出:" << sol.rob(nums2) << endl; // 12// 特殊测试用例:全0vector<int> nums3 = {0,0,0};cout << "全0测试输出:" << sol.rob(nums3) << endl; // 0return 0;
}
思路 2:空间优化(O (1) 空间)
观察状态转移方程,dp[i]
只依赖 dp[i-1]
和 dp[i-2]
,无需存储整个 dp
数组,只需用两个变量记录前两个状态即可,空间复杂度从 O (n) 降至 O (1)。
步骤拆解
- 处理特殊情况(数组长度为 0 或 1);
- 用
prev_prev
记录dp[i-2]
(前两间的最高金额),prev
记录dp[i-1]
(前一间的最高金额); - 遍历数组,计算当前最高金额
curr = max(prev, prev_prev + nums[i])
; - 更新
prev_prev
和prev
,继续遍历; - 返回
prev
(遍历结束后,prev
对应dp[n]
)。
C++ 实现代码
#include <iostream>
#include <vector>
#include <algorithm>using namespace std;class Solution {
public:int rob(vector<int>& nums) {int n = nums.size();if (n == 0) return 0;if (n == 1) return nums[0];// prev_prev:对应 dp[i-2],prev:对应 dp[i-1]int prev_prev = 0; // 初始 dp[0] = 0int prev = nums[0]; // 初始 dp[1] = nums[0]// 遍历从第2间房屋(nums[1])开始for (int i = 1; i < n; ++i) {// 当前最高金额 = max(不偷当前间(prev), 偷当前间(prev_prev + nums[i]))int curr = max(prev, prev_prev + nums[i]);// 更新前两个状态:prev_prev 移到 prev,prev 移到 currprev_prev = prev;prev = curr;}return prev; // 最终 prev 是 dp[n]}
};// 测试代码
int main() {Solution sol;vector<int> nums1 = {1,2,3,1};cout << "示例1输出:" << sol.rob(nums1) << endl; // 4vector<int> nums2 = {2,7,9,3,1};cout << "示例2输出:" << sol.rob(nums2) << endl; // 12// 测试用例:长度为2vector<int> nums3 = {5,10};cout << "长度2测试输出:" << sol.rob(nums3) << endl; // 10return 0;
}
四、复杂度分析
解法 | 时间复杂度 | 空间复杂度 | 适用场景 |
---|---|---|---|
思路 1(标准 DP) | O(n) | O(n) | 初学者理解,需清晰状态 |
思路 2(空间优化) | O(n) | O(1) | 工程实践,追求空间效率 |
- 时间复杂度:两种解法均需遍历一次数组(
n
为数组长度),故为 O (n); - 空间复杂度:标准 DP 用数组存储
n+1
个状态,为 O (n);优化版仅用两个变量,为 O (1)。
五、关键注意事项
- 边界条件处理:必须单独处理
n=0
(空数组)和n=1
(仅 1 间房屋)的情况,避免数组越界; - 状态转移的逻辑:“偷当前房屋” 时,只能加前两间的最高金额(不能加前一间),这是避免相邻偷窃的核心;
- 空间优化的本质:识别 “当前状态仅依赖前两个状态”,用变量替代数组,减少不必要的内存占用。
六、总结
- 两种解法的核心均是 “动态规划的状态转移”,区别仅在于空间存储方式;
- 思路 1 适合理解动态规划的基本流程,思路 2 更适合实际开发(空间效率更高);
- 此类 “选择类” 问题(如 “选或不选”“取或不取”),通常可通过动态规划拆解为子问题,用历史状态推导当前最优解。