动态规划:第 3 节:理解「无后效性」(自己的草稿,内容不严谨,与之前有重复,不用看)

我们在第 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] = 0dp[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)NN 是数组的长度;
  • 空间复杂度:O(N)O(N),状态数组的大小为 2N2N

参考代码 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)NN 是数组的长度;
  • 空间复杂度:O(N)O(N),状态数组的大小为 2(N+1)2(N + 1),记为 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)NN 是数组的长度;
  • 空间复杂度:O(1)O(1),状态数组的大小为 44,常数空间。

总结

「状态」和「状态转移方程」得到以后,这个问题其实就得到了解决,剩下的一些细节的问题在编码的时候只要稍微留意一点就行了。

到这里「重复子问题」、「最优子结构」、「无后效性」我们就都向大家介绍完了。「动态规划」告诉我们可以「自底向上」去考虑一件事情,并且记录下求解问题的中间过程。

「动态规划」问题没有套路,我们只有通过不断地联系,去掌握状态设计的一般方法和技巧,体会上面所说的「动态规划」的基本概念和基本特征。

练习

1、「力扣」第 62 题、第 63 题:不同路径。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章