【算法】动态规划:适合新手的动态规划入门常见题目详解

一、什么是动态规划

1.官方定义

动态规划(英语:Dynamic programming,简称DP)是一种在数学、管理科学、计算机科学、经济学和生物信息学中使用的,通过把原问题分解为相对简单的子问题的方式求解复杂问题的方法。
动态规划背后的基本思想非常简单。大致上,若要解一个给定问题,我们需要解其不同部分(即子问题),再根据子问题的解以得出原问题的解。动态规划往往用于优化递归问题,例如斐波那契数列,如果运用递归的方式来求解会重复计算很多相同的子问题,利用动态规划的思想可以减少计算量。

2.动态规划的特点

看官方的解释大家应该都会比较懵,通俗的来讲,动态规划就是大事化小,小事化无。要解决一个大问题就将大问题拆解成小问题,通过逐个解决这些小问题,最终解决问题。
动态规划可以说是递归方法的一种优化,在递归问题中,我们需要进行大量的重复计算过程,时间复杂度极高。于是就有了将计算过的结果保存起来,每一个子问题只需要计算一次就好。动态规划实际也是使用 空间换时间 的一种做法。于是动态规划问题就具有这样的特点:

  • 问题可以逐步拆解
  • 所有子问题只需解决一次
  • 对子问题进行存储

在解决动态规划问题时,通常就是以下三个步骤:

  1. 状态定义
  2. 初始化
  3. 状态转移方程

如果你还不是很懂这些个名词的意思,那就通俗的讲一下:
状态定义 :在我们使用空间换取时间时,当然要定义一个可以存储每一个子问题的结果的集合,那对于这个集合的每一个元素代表的意义一定要明确。当我们选定存储集代表的意义时,就完成了状态定义。
初始化 :最大的问题逐步分解为小问题,从最小的问题开始解决时往往我们是知道结果的,就需要将最原始的子问题的值初始化出来。
状态转移方程 :通常这也是最难想出来的一部分,我们从小问题逐步解决,状态转移方程就体现了“逐步”这一过程,列出状态转移方程可以经过多次套用由最小的问题逐步解决。

要完全理解还是要通过题目加深印象,接下来由简到难逐步来几个题目帮助理解:

二、题目练练手

1.斐波那契数列

大家都知道斐波那契数列,现在要求输入一个整数n,请你输出斐波那契数列的第n项(从0开始,第0项为
0)。
n<=39
int Fibonacci(int n)
斐波那契数列:
1 1 2 3 5 8 13 21 …

斐波那契数列应该是递归的入门题目了,但使用递归显然耗时太多,今天就换一种解法,使用动态规划来解决。
我们按照三个步骤来:

状态定义:
首先,我们定义数组dp[ ]来存储结果,那么每一处应该存放的数字的含义是什么呢?我们要求的是数列的第n项,那不如就定义数组的第 i 处数据表示的就是斐波那契数列的第 i 项。

初始化:
我们知道斐波那契数列求解的方法是前两项之和,因此在初始化时,至少初始化前两项。题目中表示第 0 项为 0 而第一项就是 1 这就初始化好了前两项。

状态转移方程:
知道第零和第一项,怎么求第二项?当然是加起来,也就是
第二项: dp[2] = dp[1] + dp[0] ;
第三项: dp[3] = dp[2] + dp[1] ;
第四项: dp[4] = dp[3] + dp[2] ;

第n项: dp[n] = dp[n - 1] + dp[n - 2] ;
这就得到了我们的状态转移方程
接下来就是顺理成章的写代码了
代码:

public class Fibonacci {
    public int Fibonacci(int n) {
        if(n == 0)
        	return 0;
        if(n == 1 || n == 2)
        	return 1;
        //因为有第0项,所以在创建时要创建n+1长度
        int[] dp = new int[n + 1];
        //初始化
        dp[0] = 0;
        dp[1] = 1;
        //列出状态转移方程并保存子结果
        for(int i = 2; i < n+1; i++){
            dp[i] = dp[i - 1] + dp[i - 2];
        }
        return dp[n];
    }
}

这样就完成了这道动态规划的入门题目,只要按着步骤来思考,思路就会比较明朗。

2.爬楼梯

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

这道题就比上一题稍微复杂些,整体思路比上一个略微抽象些,但也不难理解。
同样,按照步骤来:
状态定义:
定义dp[ ]数组
这道题计算爬到顶层的方法数,我们就可以规定dp数组的每一个元素就是相应从底楼到达某一层的方法数。
初识化:
从第0层开始,那么到达第0层我们可以初始化为0。而到达第一层的方法数就是只有一种,到达第二层的方法就是两种,也就是可以从底层跳到一层在跳到二层,也可以直接从底层跳到第二层,也就得到初始化:dp[0] = 0; dp[1] = 1; dp[2] = 2;
状态转移方程:
对于到达顶层而言,只有两种方法:一种就是从第n - 1 层跳一个台阶上来,另一种就是从n - 2 层跳两个台阶上来,那么跳到最高层的方法就是跳到 n-1层的方法在加上跳到 n-2 层的方法。而我们定义的状态表达的含义就是从底层跳到某一层的方法数,因此直接使用相加就得出结果。因此就有:dp[n] = dp[n-1] + dp[n-2]通过这个状态转移方程逐步计算出到达每一层的方法数最终就得出最后的结果。
代码:

public int climbStairs(int n) {
        int[] dp = new int[n + 1];
        if(n == 0){
            return 0;
         }
        if(n == 1){
            return 1;
         }
        dp[0] = 0;
        dp[1] = 1;
        dp[2] = 2;
        for(int i = 3; i < n + 1; i++){
            dp[i] = dp[i - 1] + dp[i - 2];
        }
        
        return dp[n];
    }

上面的两个问题是不是好像状态定义也差不多,转移方程也差不多,注意要总结。接下来这道题可是有难度提升喔,状态定义和转移方程都会有所改变。

3.最大连续子数组和

HZ偶尔会拿些专业问题来忽悠那些非计算机专业的同学。今天测试组开完会后,他又发话了:在古老的一维模式识别中,常常需要计算连续子向量的最大和,当向量全为正数的时候,问题很好解决。但是,如果向量中包含负数,是否应该包含某个负数,并期望旁边的正数会弥补它呢?例如:{6,-3,-2,7,-15,1,2,2},连续子向量的最大和为8(从第0个开始,到第3个为止)。给一个数组,返回它的最大连续子序列的和,你会不会被他忽悠住?(子向量的长度至少是1)

这道题相比于前两题难度稍有增加,原因是题目中要求“连续”这就使得状态定义比较抽象。如果把dp[i]定义为第i个数前的最大连续子数组和,那么在列状态转移方程中,后项和前项的关系就很难直接写出来。
状态定义:
因此,在这道题目中,我们没办法直接通过dp[n]的值就得出子数组的和。所以我们不妨设dp[i]表示的是以数组中 i 下标数字为结尾的最大连续子数组的和。在这里我们并不知道以哪个数字为结尾的和就一定是最大的,所以最后要进行对数组dp中数字大小作比较。
初始化:
首先,我们定义一个最大值变量用于存储dp数组中的最大值以便最后返回。接下来就是对dp数组的初始化。对于dp[0]表示的是以0号元素结尾的最大连续子数组的和,当然就是元素0本身。
状态转移方程:
接下来就是书写状态转移方程,当我们求 i = 1 时的dp数组中的值时,如果给定的原始数组 i 处的元素与dp[i - 1]相加是使得总和上升,那毫无疑问dp[1] = dp[0] + nums[0] ,反之如果相加后是使得总和递减,那么就要比较 dp[0] + nums[0] 与 num[0] 谁的值更大,dp[i] 取较大的值。总之一定要保证dp[1]中的数表示的是以nums[i]结尾的最大连续子数组的和。
综上所述,我们可以推出状态转移方程表达式为dp[i] = Math.max(dp[i] + nums[i],nums[i]);

代码:

public static int maxArrSum(int[] nums) {
        int[] dp = new int[nums.length];
        int max = nums[0];
        dp[0] = nums[0];
        for (int i = 1; i < nums.length; i++) {
            dp[i] = Math.max(dp[i - 1] + nums[i], nums[i]);
            if (max < dp[i]) {
                max = dp[i];
            }
        }
        return max;
    }

4. 打家劫舍

  • 你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。给定一个代表每个房屋存放金额的非负整数数组,计算你在不触动警报装置的情况下,够偷窃到的最高金额。

这道题比上面的两道题更抽象些,但还是一步步滤清思路。由于不可以偷两件相邻的房子,怎么样将这种不连续得关系表达清楚呢?
状态定义:
我们可以定义一个二维数组dp[i][j]其中 i 表示第 i 间房子,而 j 表示偷还是不偷这个状态,于是对于 j 就只有两种状态: 0 表示偷 1 表示不偷 例如dp[5][0] 表示不偷第五间房子后的金额,而dp[2][1]表示偷第二间房子的最高金额。
初始化:
对于第一间房子可以偷,也可以不偷。那么就可以初始化为:dp[0][1] = nums[0]),如果不偷,数组本身默认就为0,不需要处理。
状态转移方程:
对于每一个房子都有两个状态,偷、不偷。又因为两间房子不可以挨着,那就有两个方式:
如果这间房子偷,那么对于上间房子就只能不偷,那么就有:dp[i][1] = dp[i-1][0]+nums[i]
如果这间房子不偷,那么上一间房子的状态可以偷,可以不偷,此时比较两种方式的大小,选择最大的作为此次不偷的数值 :dp[i][0] = Math.max(dp[i-1][1],dp[i-1][0]);
代码:

 public int rob(int[] nums) {
//0 不偷 1偷
        int[][] dp = new int[nums.length][2];
        if (nums.length > 0){
            dp[0][1] = nums[0];
        }else {
            return 0;
        }
        for (int i = 1; i <nums.length ; i++) {
            dp[i][1] = dp[i-1][0]+nums[i];
            dp[i][0] = Math.max(dp[i-1][1],dp[i-1][0]);
        }
        return Math.max(dp[nums.length-1][1],dp[nums.length-1][0]);
    }

以上就是简单的动态规划四个入门题目,如果有任何问题欢迎留言指正。

发布了62 篇原创文章 · 获赞 28 · 访问量 6055

猜你喜欢

转载自blog.csdn.net/Moo_Lavender/article/details/103546549