这是我参与更文挑战的第 11 天,活动详情查看: 更文挑战
线性 DP
我们平时常见的很多 DP 题目,比如最长公共子序列、最长递增子序列等,这类题目实际上都是线性 DP。不过今天我们例子不是这类经典的 DP 题目,而是介绍一些在面试中经常出现的线性 DP 题目。
例子:打劫
题目
【题目】 你是一个专业的小偷,计划去沿街的住户家里偷盗。每间房内都藏有一定的现金,影响你偷盗的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。给定一个代表每个房屋存放金额的非负整数数组,要求你计算不触动警报装置的情况下 ,一夜之内能够偷窃到的最高金额。
输入:nums = [1,2,3,1]
输出:4
解释:偷窃 nums[0] 号房屋 (金额 = 1),然后偷窃 nums[2]号房屋(金额 = 3)。偷窃到的最高金额 = 1 + 3 = 4 。
【分析】 接下来,我们就照着 DP 的 6 步分析法(千万别顺着题意去想要偷那些房间!!)。我们把思维放慢,一步一步分析。
1. 最后一步
就这道题而言,最后一步就是处理第 N-1 个房间(我们假设一共有 N 个房间,并且从 0 开始)。
那么第 N-1 个房间,有两个选项。
偷:如果要偷第 N-1 个房间,那么收益就是处理前 N-3 个房间之后,再偷第 N-1 房间。
不偷:那么只需要处理到第 N-2 个房间,那么收益就是处理前 N-2 个房间之后的收益。
2. 子问题
最后一步的 2 个选项中都有未知项,我们可以将它们展开为子问题:
处理完 [0, ..., N-3] 之后,最大收益是多少?
处理完 [0, ..., N-2] 之后,最大收益是多少?
下面我们可以统一问题的表示:
f(x) 表示处理完 [0, ..., x] 这些房间之后的最高收益。
3. 递推关系
统一问题的表示之后,首先来表示一下最后一步:
f(N-1) = max(f(N-2), f(N-3) + nums[N-1])
这里需要采用替换法,将 N-1 换为 x。可以得到:
f(x) = max(f(x-1), f(x-2) + nums[x])
4. f(x) 的表达
这里 x 表示的是原数组 [0, ..., x] 这个区间范围。由于所有的 x 表示的区间都是从 0 开始的,所以这个区间的起始点信息没有必要保留,因此只需要保存区间端点 x。我们发现:
x 是个整数;
x 的范围刚好是 nums 数组的长度。
尽管 f(x) 可以用哈希来表示,但如果用数组来表达这个函数映射关系,更加直接和高效。因此,我们也用 dp[] 数组来表达 f(x)。并且利用元素 i 表示 x,可以让 i 与 nums 数组的下标对应起来。
5. 初始条件与边界
初始条件:首先我们看“无法计算/越界”的情况:
dp[0] = max(dp[0-1], dp[0-2] + nums[0]); // <-- 越界!
dp[1] = max(dp[1-1], dp[1-2] + nums[1]); // <-- 越界!
dp[2] = max(dp[2-1], dp[2-2] + nums[2]);
我们发现 dp[0], dp[1] 会在计算过程中出现越界,所以需要优先处理这两项。
dp[0]:当只有 nums[0] 可以偷的时候,其值肯定为 max(0, nums[0])。
注意陷阱,有的题可能会给你的带负数值的情况,不要直接写成 nums[0]。
dp[1]:当有 0 号,1 号房间可以偷的时候,由于不能连续偷盗,那么只需要在 0、nums[0]、nums[1] 里面选最大值就可以了。所以 dp[1] = max(0, nums[0], nums[1])。
边界:要保证不能越过数组的边界!
6. 计算顺序
拿到初始条件与边界之后,只需要再多走两步,就知道代码怎么写了。接下来我们开始求解 dp[2], dp[3]。
dp[2] = max(dp[2-1], dp[2-2] + nums[2]);
dp[3] = max(dp[3-1], dp[3-2] + nums[3]);
完整代码
利用前面分析过的初始条件和递推关系,可以写出如下代码:
class Solution
{
public int rob(int[] nums)
{
final int N = nums == null ? 0 : nums.length;
if (N <= 0) {
return 0;
}
int[] dp = new int[N];
dp[0] = Math.max(0, nums[0]);
if (N == 1) {
return dp[0];
}
dp[1] = Math.max(0, Math.max(nums[0], nums[1]));
for (int i = 2; i < N; i++) {
dp[i] = Math.max(dp[i - 1], dp[i - 2] + nums[i]);
}
return dp[N - 1];
}
}
复杂度分析:时间复杂度 O(N),空间复杂度 O(N)。
【小结】通过 6 步分析法,我们很快就搞定来这道经典的 DP 题目。
变形
这道题还有一个小变形
题目
题目:你是一个专业的小偷,计划偷窃沿街的房屋,每间房内都藏有一定的现金。这个地方的所有房屋都围成一圈 ,这意味着第一个房屋和最后一个房屋是紧挨着的。同时,相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警 。给定一个代表每个房屋存放金额的非负整数数组,计算你在不触动警报装置的情况下 ,能够偷窃到的最高金额。
输入:nums = [2,3,2]
输出:3
解释:你不能先偷窃 nums[0] 号房屋(金额 = 2),然后偷窃 nums[2] 号房屋(金额 = 2), 因为他们是相邻的。最大收益是偷取nums[1]=3。
完整代码
class Solution {
// 这里会在[b,e]范围里面,采用例题中的方法来进行操作
private int robHouse(int[] A, int b, int e) {
if (b >= e) {
return 0;
}
// dp[i]表示从[b...i]这个范围里面能够得到的最大收益
int[] dp = new int[e];
dp[b] = Math.max(0, A[b]);
// 如果只有一个元素
if (e - b == 1) {
return dp[b];
}
dp[b+1] = Math.max(0, Math.max(A[b], A[b+1]));
for (int i = b + 2; i < e; i++) {
dp[i] = Math.max(dp[i-1], dp[i-2] + A[i]);
}
return dp[e-1];
}
public int rob(int[] A) {
// 由于这里的房子是首尾相连,那么
// 我们需要考虑两种情况,
// - A[0]不抢: [1...N)
// - A[0]必然抢: 接下来只需要处理[2...N-1)
// 那么原来的数组,实际上就变成了两个数组
// 首先处理特殊情况
if (A == null || A.length == 0) {
return 0;
}
return Math.max(robHouse(A, 1, A.length),
A[0] + robHouse(A, 2, A.length - 1));
}
}
其实就是特殊处理0处的值,将数组变成2部分求解。