一、题目描述
给定一个非负整数数组 nums,其中 nums[i] 表示第 i 间房屋存放的金额。相邻房屋装有相互连通的报警系统,如果同一晚上偷窃两间相邻房屋,就会触发警报。
要求计算在不触发警报的情况下,一晚上最多能够获得多少金额。
例如:
输入:nums = [1,2,3,1]
输出:4
可以选择第 1 间和第 3 间房屋,获得 1 + 3 = 4。
再看一个例子:
输入:nums = [2,7,9,3,1]
输出:12
可以选择金额为 2、9 和 1 的三间房屋,最终获得 12。
这道题的关键限制是:相邻房屋不能同时选择。每走到一间新房屋,都需要决定是偷还是不偷,这是一道经典的动态规划问题。
二、为什么使用动态规划
最直接的思路,是枚举每间房屋偷或不偷的所有组合,再从合法方案中选择最大值。但每间房屋都有两种选择,暴力搜索的规模会快速增长。
实际上,当我们考虑第 i 间房屋时,不需要知道前面具体偷了哪些房屋,只需要知道:
-
偷窃前面若干间房屋能够获得的最大金额;
-
如果选择当前房屋,前一间房屋就不能选择。
也就是说,大问题的答案依赖于规模更小的子问题,而且这些子问题会被反复使用,因此适合采用动态规划保存结果。
三、定义动态规划状态
定义:
dp[i] 表示偷窃前 i 间房屋能够获得的最高金额
需要特别注意,i 表示房屋数量,不是 nums 的数组下标。
例如:
-
dp[0]:没有房屋可选时的最高金额; -
dp[1]:只考虑nums[0]时的最高金额; -
dp[2]:考虑nums[0]、nums[1]时的最高金额; -
dp[n]:考虑全部n间房屋时的最高金额。
因此,在计算 dp[i] 时,当前房屋在数组中的下标是 i - 1,对应金额为:
nums[i - 1]
这种定义可以让 dp[0] 自然表示空区间,也能减少边界判断。
四、推导状态转移公式
计算前 i 间房屋的最高金额时,需要决定是否偷第 i 间房屋。
1. 不偷当前房屋
如果不偷第 i 间房屋,那么答案与只考虑前 i - 1 间房屋时相同:
dp[i - 1]
2. 偷当前房屋
如果偷第 i 间房屋,就不能偷与它相邻的第 i - 1 间房屋。因此,只能在前 i - 2 间房屋的最优结果上,加上当前房屋金额:
dp[i - 2] + nums[i - 1]
在两种选择中取最大值,就得到状态转移公式:
dp[i] = Math.max(
dp[i - 1],
dp[i - 2] + nums[i - 1]
);
可以将它记成一句话:
不偷当前房屋,就继承前一间的最优解;偷当前房屋,就使用前两间的最优解加上当前金额。
五、确定初始状态
动态规划必须先确定最小规模问题的答案。
1. 没有房屋
没有房屋可偷,最高金额自然是 0:
dp[0] = 0;
2. 只有一间房屋
房屋金额都是非负整数,因此只有一间房屋时,选择它就是最优方案:
dp[1] = nums[0];
从 i = 2 开始,就可以使用状态转移公式依次计算后续结果。
六、示例完整推演
以数组 [1,2,3,1] 为例,共有 4 间房屋,因此创建长度为 5 的 DP 数组。
1. 初始化
dp[0] = 0
dp[1] = 1
此时:
dp = [0,1,0,0,0]
2. 计算 dp2
当前房屋金额为 nums[1] = 2:
不偷:dp[1] = 1
偷:dp[0] + nums[1] = 0 + 2 = 2
选择较大值:
dp[2] = 2
3. 计算 dp3
当前房屋金额为 nums[2] = 3:
不偷:dp[2] = 2
偷:dp[1] + nums[2] = 1 + 3 = 4
所以:
dp[3] = 4
4. 计算 dp4
当前房屋金额为 nums[3] = 1:
不偷:dp[3] = 4
偷:dp[2] + nums[3] = 2 + 1 = 3
最终:
dp[4] = 4
完整 DP 数组为:
[0,1,2,4,4]
因此答案为 4。
这里还能发现一个规律:dp[i] 不一定表示必须偷第 i 间房屋,而是表示考虑前 i 间房屋后能够获得的最高金额。
七、完整 Java 代码
下面的代码与截图中的一维 DP 数组思路一致:
class Solution {
public int rob(int[] nums) {
int n = nums.length;
// 防御性处理;LeetCode 原题保证数组至少包含一个元素
if (n == 0) {
return 0;
}
// dp[i] 表示偷窃前 i 间房屋能获得的最高金额
int[] dp = new int[n + 1];
// 初始状态
dp[0] = 0;
dp[1] = nums[0];
for (int i = 2; i <= n; i++) {
// 选择一:不偷第 i 间房屋,继承 dp[i - 1]
// 选择二:偷第 i 间房屋,金额为
// dp[i - 2] + nums[i - 1]
dp[i] = Math.max(
dp[i - 1],
dp[i - 2] + nums[i - 1]
);
}
return dp[n];
}
}
八、为什么返回 dpn
数组中共有 n 间房屋,而 dp[i] 表示考虑前 i 间房屋的最优结果,所以考虑全部房屋后的答案就是:
return dp[n];
不能直接返回 dp[n - 1],因为那只表示考虑前 n - 1 间房屋,没有包含最后一间房屋带来的选择。
九、复杂度分析
程序只遍历一次数组,每个状态都在常数时间内完成计算,因此时间复杂度为:
O(n)
使用长度为 n + 1 的 DP 数组保存所有子问题结果,因此空间复杂度为:
O(n)
虽然本题可以进一步使用两个变量将空间复杂度优化为 O(1),但本文严格采用截图中的一维 DP 数组方案,便于理解状态定义和转移过程。