一、动态规划
动态规划,就是基于我们写的状态转换方程,将需要用到的子解计算并记录下来,来避免重复计算子解。而这些历史记录,我们得需要一些变量来保存,一般是用一维数组或者二维数组来保存。下面我们先来讲下做动态规划题很重要的三个步骤。
1. 动态规划的三大步骤
步骤一:定义元素的含义非常重要,但是也是不唯一的,一般,你定义的含义不同,状态转换方程也会随之不同。一般是题目问什么,就把dp元素的含义看成什么。
步骤二:找出数组元素之间的关系式,即状态转换方程。如果我们数组定义为dp[n]
,那么在计算dp[n]
的时候,我们是可以利用dp[n-1]
、dp[n-2]
…dp[1]
来推出 dp[n]
的,也就是可以利用历史数据来推出新的元素值,所以我们要找出数组元素之间的关系式,例如 dp[n] = dp[n-1] + dp[n-2]
,这个就是他们的关系式了。而这一步,也是最难的一步,后面会拿几个例子做说明。
找状态转换方程的诀窍:
- 首先,任意选取一个中间状态
i
- 弄清楚状态
i
从哪里来,进一步弄清楚基于dp哪一个状态转换而来
- 有时候有的题比较难,不知道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],现在不理解没关系,后面会有例题帮助理解
- 弄清楚状态
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]
的值,而这,就是所谓的初始值。
找初始值的诀窍:
- 找出正负零界等式。比如: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]是初始值。
- 接着向下推,根据实际意义,看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] = 1
、 dp[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]=0
、dp[1]=1
、dp[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] = 1
、 dp[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 所使用的最少操作数 。你可以对一个单词进行如下三种操作:
- 插入一个字符
- 删除一个字符
- 替换一个字符
举例:
输入: 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));
}
}
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/84717.html