算法设计---动态规划如何思考?如何学习?有何规律?(进阶)

一、动态规划

动态规划,就是基于我们写的状态转换方程,将需要用到的子解计算记录下来,来避免重复计算子解。而这些历史记录,我们得需要一些变量来保存,一般是用一维数组或者二维数组来保存。下面我们先来讲下做动态规划题很重要的三个步骤。

1. 动态规划的三大步骤

步骤一:定义元素的含义非常重要,但是也是不唯一的,一般,你定义的含义不同,状态转换方程也会随之不同。一般是题目问什么,就把dp元素的含义看成什么。

步骤二:找出数组元素之间的关系式,即状态转换方程。如果我们数组定义为dp[n],那么在计算dp[n]的时候,我们是可以利用dp[n-1]dp[n-2]dp[1]来推出 dp[n]的,也就是可以利用历史数据来推出新的元素值,所以我们要找出数组元素之间的关系式,例如 dp[n] = dp[n-1] + dp[n-2],这个就是他们的关系式了。而这一步,也是最难的一步,后面会拿几个例子做说明。

找状态转换方程的诀窍:

  1. 首先,任意选取一个中间状态i
  2. 弄清楚状态i从哪里来,进一步弄清楚基于dp哪一个状态转换而来
    1. 有时候有的题比较难,不知道dp[i]从哪里来,就找最基础的子结构,也就是dp[0],然后你可以试着理解dp[i-1],dp[i-2]的含义,看看dp[i]是否是基于他们的状态转换而来,同理dp[i][j]看dp[0][0],再观察dp[i-1][j-1],dp[i-1][j], dp[i][j-1],现在不理解没关系,后面会有例题帮助理解
  3. 弄清楚状态i要到哪里去

根据我做题的经验,大部分题是二维的dp,并且大部分情况下,dp[i] [j] 和 dp[i-1] [j]、dp[i]、[j-1]、dp[i-1] [j-1] 肯定存在某种关系。

步骤三:找出初始值。例如dp[n] = dp[n-1] + dp[n-2],我们可以通过 dp[n-1]dp[n-2] 来计算 dp[n],但是,我们得知道初始值啊,例如一直推下去的话,会由 dp[3] = dp[2] + dp[1]。而 dp[2]dp[1] 是不能再分解的了,所以我们必须要能够直接获得 dp[2]dp[1] 的值,而这,就是所谓的初始值

找初始值的诀窍:

  1. 找出正负零界等式。比如:dp[n] = dp[n-1] + dp[n-2],有dp[1] = dp[0] + dp[-1],等式后面出现了下标为-1,按照公式向后推有dp[2] = dp[1] + dp[0],等式后面出现了下标刚好变为0,由负变为非负,所以dp[2] = dp[1] + dp[0]是临界等式,那么dp[1],dp[0]是初始值。
  2. 接着向下推,根据实际意义,看dp[3] = dp[2] + dp[1]是否成立,如果不成立,则dp[2]也是初始值,一直向下推,直到成立。到此,初始值就确定了。

根据我做题的经验,一般一维的dp[n]的初始值为dp[0]、dp[1]、dp[2],而二维的dp[m][n]一般是第一列和第一行是初始值。

有了初始值,并且有了状态转换方程,那么我们就可以得到 dp[n] 的值了,而 dp[n] 的含义是由你来定义的,你想求什么,就定义它是什么,这样,这道题也就解出来了。

补充:有时候,我们在处理dp[n]数组时,存放数据不知道是从下标0开始存放数据,还是从下标1开始存放数据,其实这都是可以的,只是从下标1开始相对于比下标0开始要多浪费一些空间,但是最终都是可以达到目的。


二、习题练习

1.题目一:

问题描述:一只青蛙一次可以跳上1级台阶,也可以跳上2级。求该青蛙跳上一个n级的台阶总共有多少种跳法。

(1)定义数组元素的含义
问题是要求青蛙跳上 n 级的台阶总共由多少种跳法,那我们就定义 dp[i] 的含义为:跳上一个 i 级的台阶总共有 dp[i] 种跳法。这样,如果我们能够算出 dp[n],不就是我们要求的答案吗?所以第一步定义完成。

(2)状态转换方程
①取中间状态: 取一中间台阶(状态)i
②从哪里来:dp[i]是从跳一级得到或者跳两级得到,跳一级是基于什么状态?是基于从开始到第i-1台阶总共有多少种跳法,而则个含义恰好对应着dp[i-1],同理跳两级对应dp[i-2],而dp[i]是来自于这两种情况,所以总的可能是这两种情况之和,即dp[i] = dp[i-1] + dp[i-2]
③到哪里去:到dp[i+1]或者dp[i+2],然后从dp[i+1]dp[i+2]或者dp[i+3]…一直到dp[n]
所以状态转换方程为:dp[n] = dp[n-1] + dp[n-2]

(3)初始值
①临界式子:dp[2] = dp[1] + dp[0],所以有初始值:dp[1] = 1dp[0] = 0
②往下推:按照状态转换方程:dp[2] = dp[1] + dp[0] = 1 + 0 = 1,而实际上,dp[2] = 2,有连续上两次一阶台阶和一次上二阶台阶2种情况,不符合状态转换方程,所以dp[2]也是初始值,dp[2] = 2。接着向下推,dp[3] = dp[2] + dp[1] = 2 + 1 = 3,实际上,dp[3]确实是3,所以dp[3]不是初始值,后面的也不用继续看了。
最终,求得初始值:dp[0]=0dp[1]=1dp[2]=2

代码如下:

public static int fun(int n) {
        if(n <= 0)
            return 0;
        int[] dp = new int[n+1];    //备忘录
        // 设置初始值
        // dp[0] = 0; 可以不设置,因为dp[0]的情况在上面if条件中处理了,如果if中不带等于,这里可以设置dp[0]=0
        dp[1] = 1;
        dp[2] = 2;
        // 通过状态转换方程计算出 dp[n]
        for(int i = 3; i <= n; i++) {
            dp[i] = dp[i-1] + dp[i-2];
        }
        //
        return dp[n];
    }

2.题目二:

一个机器人位于一个 m x n 网格的左上角 。机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角,问总共有多少条不同的路径?

(1)定义数组元素的含义
dp[i][j]代表从网格左上角(0, 0)位置到达(i, j)位置总共有多少种路径。

(2)状态转换方程
①取中间状态: 取一中间状态dp[i][j]
②从哪里来:dp[i][j](i-1, j)处下移得到或者从(i, j-1)处右移得到,那么dp[i][j]是基于从开始到(i-1, j)处有多少种可能的状态下得到,或者基于从开始到(i, j-1)处有多少总可能的状态下得到,而这恰好对应着dp[i-1][j]dp[i][j-1],所以有dp[i][j] = dp[i-1][j + dp[i][j-1]
③到哪里去:到dp[i+1][j]或者dp[i][j+1]…一直到dp[i-1][j-1]
所以状态转换方程为:dp[m][n] = dp[m-1][j] + dp[m][n-1]

(3)初始值
①临界式子:dp[1][1...n] = dp[0][1...n] + dp[1][0...n-1]dp[1...m][1] = dp[0...m-1][1] + dp[1...m][0]所以有初始值:dp[0][1...n] = 1dp[1...m][0] = 1
②往下推:发现没有其他的初始值了。

代码如下:

public static int fun(int m, int n) {
        if(m==1 && n==1)
            return 0;
        else if(m==1 || n==1)
            return 1;
        int[][] dp = new int[m][n];		//备忘录
        // 设置初始值
        for(int i = 0; i < m; i++) //第一列
            dp[i][0] = 1;
        for(int j = 0; j < n; j++)  //第一行
            dp[0][j] = 1;
        //用状态转换方程计算dp[m-1][n-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];
        }
        //返回dp[m-1][n-1]
        return dp[m-1][n-1];
    }

如果是从(1, 1)开始存数据,则代码为:

public static int fun(int m, int n) {
        if(m==1 && n==1)
            return 0;
        else if(m==1 || n==1)
            return 1;
        int[][] dp = new int[m+1][n+1];
        // 设置初始值
        for(int i = 0; i < m; i++) //第一列
            dp[i][1] = 1;
        for(int j = 0; j < n; j++)  //第一行
            dp[1][j] = 1;
        //用状态转换方程计算dp[m][n]
        for(int i = 2; i < m; i++) {
            for(int j = 2; j < n; j++)
                dp[i][j] = dp[i-1][j] + dp[i][j-1];
        }
        //返回dp[m][n]
        return dp[m][n];
    }

3.题目三:

给定一个包含非负整数的 m x n 网格,请找出一条从左上角到右下角的路径,使得路径上的数字总和为最小,且每次只能向下或者向右移动一步。

举例:

输入:
arr = [
  [1,3,1],
  [1,5,1],
  [4,2,1]
]
输出: 7
解释: 因为路径 1→3→1→1→1 的总和最小。

(1)定义数组元素的含义
dp[i][j]代表从网格左上角(0, 0)位置到达(i, j)数字和最小

(2)状态转换方程
①取中间状态: 取一中间状态dp[i][j]
②从哪里来:dp[i][j]是从(i-1, j)下移或者从(i, j-1)右移得到的,那么dp[i][j]是基于从开始到(i-1, j)数字和最小,或者基于从开始到(i, j-1)数字和最小的状态下得到,而这恰好对应着dp[i-1][j]dp[i][j-1]dp[i][j]是从这两者中值小的加上(i, j)处的值得到,所以有pd[i][j] = min(pd[i-1][j], pd[i][j-1]) + (i, j)的值
③到哪里去:到dp[i+1][j]或者dp[i][j+1]…一直到dp[m-1][n-1]
所以状态转换方程为:pd[m][n] = min(pd[m-1][n], pd[m][n-1]) + (i, j)的值

(3)初始值
①临界式子:第一行的值都无法右上边的值得到,所以第一行的值都是初始值;第一列的值都无法用左边得到,所以第一列的值都是初始值。
②往下推:发现没有其他的初始值了。

代码如下:

public static int fun(int[][] g, int m, int n) {
        // pd[i][j] = min(pd[i-1][j], pd[i][j-1]) + g[i][j]
        if(m==1 && n==1)
            return g[0][0];
        int[][] pd = new int[m][n];
        //设置初始值
        pd[0][0] = g[0][0];
        for(int i = 1; i < m; i++)
            pd[i][0] = pd[i-1][0] + g[i][0];
        for(int j = 1; j < m; j++)
            pd[0][j] = pd[0][j-1] + g[0][j];
        //用状态转换方程计算pd[m-1][n-1]
        for(int i = 1; i < m; i++) {
            for(int j = 1; j < n; j++)
                pd[i][j] = Math.min(pd[i-1][j], pd[i][j-1]) + g[i][j];
        }
        return pd[m-1][n-1];
    }

    public static void main(String[] args) {
        int[][] g = {
                {1, 3, 1},
                {1, 5, 1},
                {4, 2, 1}
        };
        System.out.println(MyTest.fun(g, 3, 3));
    }

4.题目四:

给定两个单词 word1 和 word2,计算出将 word1 转换成 word2 所使用的最少操作数 。你可以对一个单词进行如下三种操作:

  1. 插入一个字符
  2. 删除一个字符
  3. 替换一个字符

举例:

输入: word1 = "horse", word2 = "ros"
输出: 3
解释: 
horse -> rorse (将 'h' 替换为 'r')
rorse -> rose (删除 'r')
rose -> ros (删除 'e')

(1)定义数组元素的含义
dp[i][j]含义:当字符串 word1 的长度为 i,字符串 word2 的长度为 j 时,将 word1 转化为 word2 所使用的最少操作次数为 dp[i] [j]

(2)状态转换方程
①取中间状态: 取一中间状态dp[i][j]
②从哪里来:
像这个题就是偏难的,依据题意一下子不知道dp[i][j]是从哪个状态来的,那么我们看dp[0][0]:代表word1的前1个字符转换为word2的前1个字符所需要的最少次数。很任意看出来,最基础的子结构是word1和word2的每一个字符比较,那么下一个结构dp[0][1], dp[1][0], dp[1][1]应该是基于最基础的子结构(状态)dp[0][0]实现,那么对应dp[i][j]可以进行如下分析:

1. 新增的字符word[i]和word[j]相同:dp[i][j]基于word1的前i-1个字符和word2的前j-1个字符处理好的情况下,新增的字符相等,不需要进行操作。即 dp[i][j] = dp[i-1][j-1], 比如:word1="ab", word2="ab"
2. 新增的字符不相等:
	1. 需要替换:dp[i][j]基于word1的前i-1个字符和word2的前j-1个字符处理好的情况下,新增的字符相等,需要进行替换操作,则 dp[i][j] = dp[i-1][j-1] + 1, 比如:word1="abc", word2="abb"
	2. 需要删除:不需要多的word[i]字符,dp[i][j]基于word1的前i-1个字符和word2的前j个字符处理好的情况下,删除word[i],则dp[i][j] = dp[i-1][j] + 1, 比如:word1="abbb", word2="abb"
	3. 需要插入:需要在word[i+1]处添加字符,但是dp[i][j]的状态是基于word1的前i个字符和word2的前j-1个字符处理好的情况下,则dp[i][j] = dp[i-1][j] + 1, 比如:word1="ab", word2="abb"

最后,dp[i][j]的值上面4种情况中最小的那个,因为dp[i-1][j-1] + 1肯定比dp[i-1][j-1]大,所以只需要取dp[i-1][j-1], dp[i-1][j]+1, dp[i][j-1]+1中最小的。
③到哪里去:一直到dp[m-1][n-1],所以状态转换方程为:pd[m][n] = min(pd[m-1][n], pd[m][n-1]) + (i, j)的值

(3)初始值
①临界式子:第一行的值都无法右上边的值得到,所以第一行的值都是初始值;第一列的值都无法用左边得到,所以第一列的值都是初始值。
②往下推:发现没有其他的初始值了。

代码如下:

public class MyTest {
    public static int fun(String word1, String word2) {
        int m = word1.length();
        int n = word2.length();
        if (m < 0 || n <0)
            return 0;
        int[][] dp = new int[m][n];
        //设置初始值
        if (word1.charAt(0) == word2.charAt(0))
            dp[0][0] = 0;
        else
            dp[0][0] = 1;
        //设置第一列的初始值
        for (int i = 1; i < m; i++)
            dp[i][0] = dp[i-1][0] + 1;
        //设置第一行的初始值
        for (int j = 1; j < n; j++)
            dp[0][j] = dp[0][j-1] + 1;

        //根据状态转换方程计算dp[m-1][n-1]
        for (int i = 1; i < m; i++) {
            for (int j = 1; j < n; j++)
                dp[i][j] = Math.min(Math.min(dp[i-1][j], dp[i][j-1]) + 1, dp[i-1][j-1]);
        }
        for (int i = 0; i < m; i++) {
            for (int j = 0; j < n; j++) {
                System.out.print(dp[i][j] + " ");
            }
            System.out.println();
        }
        return dp[m-1][n-1];
    }

    public static void main(String[] args) {
        String word1 = "horse", word2 = "ros";
        System.out.println(MyTest.fun(word1, word2));
    }
}
  • 4
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

ElegantCodingWH

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值