Java入门算法(动态规划篇1:初识动规)

Posted Ayingzz

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Java入门算法(动态规划篇1:初识动规)相关的知识,希望对你有一定的参考价值。

本专栏已参加蓄力计划,感谢读者支持❤

往期文章

一. Java入门算法(贪心篇)丨蓄力计划
二. Java入门算法(暴力篇)丨蓄力计划
三. Java入门算法(排序篇)丨蓄力计划
四. Java入门算法(递归篇)丨蓄力计划
五. Java入门算法(双指针篇)丨蓄力计划
六. Java入门算法(数据结构篇)丨蓄力计划
七. Java入门算法(滑动窗口篇)丨蓄力计划
八. Java入门算法(动态规划篇1:初识动规)
九. Java入门算法(动态规划篇2:01背包精讲)


你好,我是Ayingzz,Ayi是我的名字,ing进行时代表我很有动力,zz提醒我按时睡觉 ~


本篇内容

    前言

    在递归篇中,我们用递归求解原问题分解出来的每个子问题,一步步回代最终得到原问题的解。而在计算的过程中,往往存在有很多重复计算的值,导致运算时间的怠慢。动态规划要解决的,就是避免这些重复的计算。

    通常,我们会用表格的形式将某些计算结果记录下来,以供后面的计算直接使用,这个过程即为“填表”,称这个表为“dp表”。在计算表格的某个值时,我们会归纳出一种特定的、具体分析出来的公式,告诉计算机应该根据此公式完成表格的填写,通常称此式子为“状态转移方程”,它规定了之前的结果应该怎样复用并得到新的结果。

    自底向上求解子问题得到原问题的解是动态规划具有的性质,但不是关键。真正关键的地方是子问题的解可不可以重复使用、怎样重复使用(设计状态转移方程),很多程序员都会卡在这里,所以动态规划算法的设计往往比其他入门算法更加困难。


    泰波那契

    LeetCode题目描述:1137. 第 N 个泰波那契数 (Easy)

    • 泰波那契序列 Tn 定义如下:

    T0 = 0,T1 = 1,T2 = 1,Tn+3 = Tn + Tn+1 + Tn+2

    给你整数 n,请返回第 n 个泰波那契数 Tn 的值。
    (n >= 0)

    输入:n = 4
    输出:4
    解释:
    T_3 = 0 + 1 + 1 = 2
    T_4 = 1 + 1 + 2 = 4
    

    纯递归的办法在这就稍显愚笨,因为它的复杂度达到了O(3n) !

    class Solution {
        public int tribonacci(int n) {
            if (n == 0)return 0;
            if (n == 1 || n == 2)return 1;
            return tribonacci(n - 1) + tribonacci(n - 2) + tribonacci(n - 3);
        }
    }
    

    像求解T6时需要用到T5、T4、T3,计算T5需要用到T4、T3、T2,计算T4需要用到T3、T2、T1,但T4其实在T5的求解中已经被计算过一遍了,所以此类递归存在重复的计算,消耗了时间与空间,当数据量达到一定时,重复子问题的规模不可小觑。

    此时可以用dp数组,存放类似T4这些重复计算的值。

    • 时间上的优化:
    class Solution {
        public int tribonacci(int n) {
            if (n < 3) {
                return n == 0 ? 0 : 1;
            }
            int[] dp = new int[n + 1];
            dp[0] = 0;
            dp[1] = dp[2] = 1;
            for (int i = 3; i < n + 1; ++i) {
                dp[i] = dp[i - 1] + dp[i - 2] + dp[i - 3];
            }
            return dp[n];
        }
    }
    

    因为结果不需要保存整一个数组,而每一个Tn也只需要用到前三个数,所以可以将一维dp表降维成为滚动数组,边计算边刷新空间值,此时只需用到3 + 1个存储空间。

    • 继续在空间上进行优化:
    class Solution {
        public int tribonacci(int n) {
            int x = 0, y = 1, z = 1;
            for (int i = 3; i <= n; ++i) {
                int temp = x + y + z;
                x = y;
                y = z;
                z = temp;
            }
            return n == 0 ? 0 : z;
        }
    }
    

    注意:两种方法都需要特判n < 3时的返回结果。


    不同路径

    LeetCode题目描述:62. 不同路径(Medium)

    • 一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为 “Start” )。

    机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为 “Finish” )。问总共有多少条不同的路径?

    在这里插入图片描述
    示例

    输入:m = 3, n = 7
    输出:28
    解释:...
    -------------------
    输入:m = 3, n = 2
    输出:3
    解释:
    从左上角开始,总共有 3 条路径可以到达右下角。
    1. 向右 -> 向下 -> 向下
    2. 向下 -> 向下 -> 向右
    3. 向下 -> 向右 -> 向下
    

    正着推,规定机器人的走位方式(向下和向右)、以及走位不能超过网格的边界,一步一步递归+回溯,可以得出正确答案,但是太慢了!

    反着推,想象这个网格就是一张dp表,每个格子的值代表从出发位置到达这个格子的不同的路径总数,那么状态转移方程应该怎样设计?因为机器人只能向下或者向右走,所以机器人如果要走到dp[m][n]这个格子,那么它只能从dp[m - 1][n]往下走一格或者从dp[m][n - 1]往右走一格。

    所以此处的状态转移就是到达dp[m][n]处的路径总数 等于 到达dp[m - 1][n]处的路径总数 + 到达dp[m][n - 1]处的路径总数。

    还需要注意的是问题的边界条件,因为第一行的格子只能通过向右走到达,固dp表第一行的值恒为1,同理,第一列的格子只能通过向下走到达,dp表第一列的值恒为1。

    到此易得出状态转移方程:

    • dp[ i ][ j ] = 1,( i = 0 或 j = 0 )
    • dp[ i ][ j ] = dp[ i -1][ j ] + dp[ i ][ j - 1],(其他)
    class Solution {
        public int uniquePaths(int m, int n) {
            int[][] dp = new int[m][n];
            for (int i = 0; i < m; ++i) {// 首行
                dp[i][0] = 1;
            }
            for (int i = 0; i < n; ++i) {// 首列
                dp[0][i] = 1;
            }
            for (int i = 1; i < m; ++i) {// 填表
                for (int j = 1; j < n; ++j) {
                    dp[i][j] = dp[i-1][j] + dp[i][j-1];
                }
            }
            return dp[m - 1][n - 1];
        }
    }
    

    打家劫舍

    LeetCode题目描述:198. 打家劫舍(Medium)

    • 你是一个专业的小偷,计划偷窃沿街的房屋,每间房内都藏有一定的现金。
    • 影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。

    给定一个代表每个房屋存放金额的非负整数数组,计算在 不触动警报装置的情况下 ,一夜之内能够偷窃到的最高金额。

    示例

    输入:[2,7,9,3,1]
    输出:12
    解释:
    偷窃 1 号房屋 (金额 = 2), 
    偷窃 3 号房屋 (金额 = 9),
    接着偷窃 5 号房屋 (金额 = 1)。
    偷窃到的最高金额 = 2 + 9 + 1 = 12 。
    

    和上一题相似的点是,我们不会想到会有什么重复的计算,也似乎并不是为了避免重复的计算而在这类题目中使用动态规划,仅仅是从最优子问题出发,通过状态转移将问题规模扩大,每次使用dp表前面的关联值计算当前规模的最优解。最后我们会发现有一个特点在这类题目中十分明显,即最优子问题的解会导致问题的整体最优解。固可用动态规划求解出原问题。

    要设计状态转移方程,首先考虑边界问题,即最小的子问题。当我们要盗窃的房子只有一间时,我们当然要选择这间房子进行偷窃;当我们要盗窃的房子有两间时,我们就要选择金额大的房子进行偷窃。

    那么有三间房子呢?

    当然,聪明的我们会在三间房子间做出比较。因为不能偷相邻的房子,所以要么选择偷第一间房子 + 第三间房子,要么就选择偷第二间房子,然后比较这两种做法的优劣(金额的大小)。易知此时的状态转移为dp[3] = max (dp[3] + dp[1],dp[2]),进而推出题目的状态转移方程:

    • dp[ 0 ] = nums[ 0 ] (只有一间房子)
    • dp[ 1 ] = max ( nums[ 0 ],nums[ 1 ] ) (只有两间房子) ​
    • dp[ i ] = max ( dp[ i − 2 ] + nums[ i ],dp[ i − 1] ) (多于两间房子)

    也可以像第二题一样降成滚动数组,此处不作实现。

    class Solution {
        public int rob(int[] nums) {
            if (nums.length <= 1) {
                return nums.length == 0 ? 0 : nums[0];
            }
    
            int[] dp = new int[nums.length];
    
            // 初始化盗窃开头一间房子的金额
            dp[0] = nums[0];
            
            // 初始化盗窃开头两间房子的最高金额
            dp[1] = Math.max(nums[0],nums[1]);
    
            // dp填表,计算盗窃N间房子的最高金额
            for (int i = 2; i < nums.length; ++i) {
                dp[i] = Math.max(dp[i - 2] + nums[i], dp[i - 1]);
            }
    
            return dp[nums.length - 1];
        }
    }
    

    推荐练习

    70. 爬楼梯(斐波那契数的变形题)

    • 假设你正在爬楼梯。需要 n 阶你才能到达楼顶。
    • 每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?

    213. 打家劫舍 II(房屋头尾相连)

    你是一个专业的小偷,计划偷窃沿街的房屋,每间房内都藏有一定的现金。
    这个地方所有的房屋都 围成一圈 ,这意味着第一个房屋和最后一个房屋是紧挨着的。同时,相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警 。给定一个代表每个房屋存放金额的非负整数数组,计算你 在不触动警报装置的情况下 ,今晚能够偷窃到的最高金额。


    本专栏持续更新,预计7月结束。感谢读者支持❤

    以上是关于Java入门算法(动态规划篇1:初识动规)的主要内容,如果未能解决你的问题,请参考以下文章

    告别动态规划,连刷40道动规算法题,我总结了动规的套路

    分享经典的动态规划问题

    分享经典的动态规划问题

    Java入门算法(动态规划篇2:01背包精讲)

    Java入门算法(动态规划篇2:01背包精讲)

    动态规划入门