我们在第 1 节向大家介绍过「无后效性」的两层含义:
-
在推导后面阶段的状态的时候,我们只关心前面阶段的状态值,不关心这个状态是怎么一步一步推导出来的。
-
某阶段状态一旦确定,就不受之后阶段的决策影响。
下面我们就通过具体的例子向大家进行说明。
这道问题是经典的「力扣」第 198 题:打家劫舍。题目只问最优值,并没有问最优解,因此绝大多数情况下可以考虑使用「动态规划」的方法。
如果我们直接将问题的问法定义成状态,会发现,当前这个房子「偷」和「不偷」会影响到后面的房子「偷」与「不偷」。
一般的情况是,只要有约束,就可以增加一个维度消除这种约束带来的影响,还是上一节和大家介绍的方法:把「状态」定义得清楚、准确,「状态转移方程」就容易得到了。
第 1 步:设计状态
「状态」这个词可以理解为「记录了求解问题到了哪一个阶段」。
由于当前这一个房屋是否有两种选择:(1)偷;(2)不偷。
dp[i][0]
表示:考虑区间 [0,i]
,并且下标为 i
的这个房间偷,能够偷窃到的最高金额;
dp[i][1]
表示:考虑区间 [0,i]
,并且下标为 i
的这个房间不偷,能够偷窃到的最高金额。
说明:这个定义是有前缀性质的,即当前的状态值考虑了(或者说综合了)之前的相关的状态值,第 2 维保存了当前最优值的决策,这种通过增加维度,消除后效性的操作在「动态规划」问题里是非常常见的。
强调:
无后效性的理解:1、后面的决策不会影响到前面的决策; 2、之前的状态怎么来的并不重要。
再联系状态的定义:状态是一个概括的值,这个值是怎么来的,并不记录。因为状态定义更细致,后面的决策才不会影响到前面的决策。
第 2 步:状态转移方程
「状态转移方程」可以理解为「不同阶段之间的联系」。
今天只和昨天的状态相关,依然是分类讨论:
- 下标为
i
的房屋不偷:或者是上一间不偷,或者是上一间偷,取二者最大值,即:dp[i][0] = max(dp[i - 1][0], dp[i - 1][1])
; - 下标为
i
的房屋偷:只需要从上一间不偷,这一间偷,即:dp[i][1] = dp[i - 1][0] + nums[i]
。
第 3 步:考虑初始化
从第 2 天开始,每天的状态值只与前一天有关,因此第 1 天就只好老老实实算了。好在不难判断:dp[0][0] = 0
与 dp[0][1] = nums[0]
;
这里有一种技巧,可以把状态数组多设置一行,这样可以减少对第 1 天的初始化,这样的代码把第 1 天的情况考虑了进去,但编码的时候要注意状态数组下标的设置, 请见题解最后的「参考代码 3」。
第 4 步:考虑输出
由于状态值的定义是前缀性质的,因此最后一天的状态值就考虑了之前所有的天数的情况。下标为 len - 1
这个房屋可以偷,也可以不偷,取二者最大值。
参考代码 1:
Java 代码:
public class Solution {
public int rob(int[] nums) {
int len = nums.length;
if (len == 0) {
return 0;
}
if (len == 1) {
return nums[0];
}
// dp[i][0]:考虑区间 [0, i] ,并且下标为 i 的这个房屋不偷
// dp[i][1]:考虑区间 [0, i] ,并且下标为 i 的这个房屋偷
int[][] dp = new int[len][2];
dp[0][0] = 0;
dp[0][1] = nums[0];
for (int i = 1; i < len; i++) {
dp[i][0] = Math.max(dp[i - 1][0], dp[i - 1][1]);
dp[i][1] = dp[i - 1][0] + nums[i];
}
return Math.max(dp[len - 1][0], dp[len - 1][1]);
}
public static void main(String[] args) {
Solution solution = new Solution();
// int[] nums = {1, 2, 3, 1};
// int[] nums = {2, 7, 9, 3, 1};
int[] nums = {2, 1, 4, 5, 3, 1, 1, 3};
int res = solution.rob(nums);
System.out.println(res);
}
}
复杂度分析:
- 时间复杂度: O ( N ) O(N) O(N), N N N 是数组的长度;
- 空间复杂度: O ( N ) O(N) O(N),状态数组的大小为 2 N 2N 2N。
参考代码 2:根据方法一:状态数组多设置一行,以避免对极端用例进行讨论。
Java 代码:
public class Solution {
public int rob(int[] nums) {
int len = nums.length;
int[][] dp = new int[len + 1][2];
// 注意:外层循环从 1 到 =len,相对 dp 数组而言,引用到 nums 数组的时候就要 -1
for (int i = 1; i <= len; i++) {
dp[i][0] = Math.max(dp[i - 1][0], dp[i - 1][1]);
dp[i][1] = dp[i - 1][0] + nums[i - 1];
}
return Math.max(dp[len][0], dp[len][1]);
}
}
复杂度分析:
- 时间复杂度: O ( N ) O(N) O(N), N N N 是数组的长度;
- 空间复杂度: O ( N ) O(N) O(N),状态数组的大小为 2 ( N + 1 ) 2(N + 1) 2(N+1),记为 O ( N ) O(N) O(N)。
第 5 步:考虑是否可以状态压缩
由于我们只关心最后一个状态值。并且
dp[i]
只参考了 dp[i - 1]
的值,状态可以压缩,可以使用「滚动数组」完成。
值得说明的是:状态压缩的代码丢失了一定可读性,也会给编码增加一点点难度。
参考代码 3:使用「滚动数组」技巧,将空间优化到常数级别
在编码的时候,需要注意,只要访问到 dp
数组的时候,需要对下标 % 2
,等价的写法是 & 1
。
Java 代码:
public class Solution {
public int rob(int[] nums) {
int len = nums.length;
if (len == 0) {
return 0;
}
if (len == 1) {
return nums[0];
}
int[][] dp = new int[2][2];
dp[0][0] = 0;
dp[0][1] = nums[0];
for (int i = 1; i < len; i++) {
dp[i & 1][0] = Math.max(dp[(i - 1) & 1][0], dp[(i - 1) & 1][1]);
dp[i & 1][1] = dp[(i - 1) & 1][0] + nums[i];
}
return Math.max(dp[(len - 1) & 1][0], dp[(len - 1) & 1][1]);
}
}
复杂度分析:
- 时间复杂度: O ( N ) O(N) O(N), N N N 是数组的长度;
- 空间复杂度: O ( 1 ) O(1) O(1),状态数组的大小为 4 4 4,常数空间。
总结
「状态」和「状态转移方程」得到以后,这个问题其实就得到了解决,剩下的一些细节的问题在编码的时候只要稍微留意一点就行了。
到这里「重复子问题」、「最优子结构」、「无后效性」我们就都向大家介绍完了。「动态规划」告诉我们可以「自底向上」去考虑一件事情,并且记录下求解问题的中间过程。
「动态规划」问题没有套路,我们只有通过不断地联系,去掌握状态设计的一般方法和技巧,体会上面所说的「动态规划」的基本概念和基本特征。
练习
1、「力扣」第 62 题、第 63 题:不同路径。