文章目录:
1. 动态规划
动态规划(Dynamic Programming, DP)是一种在数学、计算机科学和经济学中使用的,通过把原问题分解为相对简单的子问题的方式求解复杂问题的方法。它通常用于优化问题。
动态规划直接从定义等方面理解起来,或许会有些晦涩难懂,所以选择直接从题目入手,通过60道动态规划相关例题,由易到难,由浅入深的感受动态规划。
1.1:动态规划的基本步骤
1. 状态表示:
a. 创建一个dp表(通常是一个数组)
b. 填满dp表,dp表的每一个值就是一个状态
c. 定义状态表示的方法:
i. 题目要求
ii. 经验 + 题目要求:
1)以某一个位置 i 作为起始
2)以某一个位置 i 作为结束
iii. 分析问题的过程中,发现了重复子问题
2. 状态转移方程:根据状态表示得出状态转移方程
用之前或之后的状态推导出dp[ i ] 的值
通常:根据最近的一步来划分问题
3. 初始化:保证填表的时候不发生越界
只需要初始化可能发生越界的状态
4. 填表顺序:根据状态转移方程和边界条件,确定子问题的求解顺序
通常是从下到上、从左到右(或称为从小到大)地计算状态值。
5. 返回值
了解了动态规划的一般流程;
那么,现在开始,进入动态规划的第一个模型——斐波那契数列模型
注:以下题目全部取自力扣!!!
2. 斐波那契数列模型例题
2.1:第n个泰波那契数
2.1.1:算法思想
1. 状态表示:
a. 创建一个dp表:本题是一个一维数组
b. 定义状态表示:
i. 本题用到 题目要求:dp[ i ]为第i个泰波那契数
2. 状态转移方程:根据状态表示得出状态转移方程
本题:由题目给出 dp[ i ] = dp[ i-1 ]+dp[ i-2 ]+dp[ i-3 ]
3. 初始化:保证填表的时候不发生越界
本题:dp[ 0 ]:代入方程会产生:dp[ -1 ]、dp[ -2 ]、dp[ -3 ]
dp[ 1 ]:dp[ -1 ]、dp[ -2 ]
dp[ 2 ]:dp[ -2 ]
只需要初始化dp[ 0 ]、dp[ 1 ]、dp[ 2 ]
4. 填表顺序:
为了填写当前状态时,所需的状态已经计算过了
本题:从左到右
5. 返回值:第n个泰波那契数:dp[ n ]
代码实现:
public int tribonacci(int n){
// 创建dp表
//初始化
//填表
//返回值
//处理边界情况
if(n==0) return 0;
if(n==1||n==2) return 1;
int[] dp=new int [n+1];
dp[0]=0;
dp[1]=1;
dp[2]=1;
for(int i=3;i<=n;i++){
dp[i]=dp[i-1]+dp[i-2]+dp[i-3];
}
return dp[n];
//时间复杂度 O(N)
//空间复杂度 O(N)
}
2.1.2:空间优化
方法:滚动数组法:
因为处理过程中实际只用到了四个状态:dp[ i ]、dp[ i-1 ]、dp[ i-2 ]、dp[ i-3 ],所以可以用四个变量代替这四个状态,每一次循环得出dp[ i ] 后,用当前状态覆盖掉原先的状态,再进行下一轮循环
需要注意的是,四个变量之间相互赋值的顺序
滚动数组法的代码实现:
public int tribonacci(int n) {
//处理边界情况
if(n==0) return 0;
if(n==1||n==2) return 1;
int a=0,b=1,c=1,d=0;
for(int i=3;i<=n;i++){
d=a+b+c;
a=b;
b=c;
c=d;
}
return d;
}
注意:滚动数组法只适合当前的斐波那契数列模型和未来的背包问题
2.2:三步问题
这里的1000000007 = 1e9+7,当数据很大时,都会把结果对这个数取模
2.2.1:算法思想
1. 状态表示:
a. 创建一个dp表(数组)
b. 定义状态表示:
i. 本体用到 经验 + 题目要求:
1)以某一个位置 i 作为结束:dp[i]表示到达某位置时,一共有多少种方法
2. 状态转移方程:以i状态最近的状态来划分问题
dp[ i ]:
从i-1位置到达i位置
从i-2位置到达i位置
从i-3位置到达i位置
所以:dp[ i ] = dp[ i-1 ]+dp[ i-2 ]+dp[ i-3 ]
3. 初始化:保证填表的时候不发生越界
只需要初始化dp[ 1 ]、dp[ 2 ]、dp[ 3 ]
4. 填表顺序:
从左到右
5. 返回值:dp[n]
代码实现:
public int wayToStep(int n){
// 创建dp表
//初始化
//填表
//返回值
//取模数
int MOD = (int)1e9+7;
if(n==1||n==2) return n;
if(n==3) return 4;
int[] dp = new int [n+1];
dp[1]=1;
dp[2]=2;
dp[3]=4;
for(int i=4;i<=n;i++){
//每次加法运算都要模 MOD
dp[i]=((dp[i-1]+dp[i-2])%MOD+dp[i-3])%MOD;
}
return dp[n];
}
2.2.2:注意
1. 一般将1e9+7提前定义为MOD
2. 每次加法运算后都要取模
3. 代码优化依旧是使用滚动数组法
2.3:使用最小花费爬楼梯
2.3.1:本题的小误区之楼梯顶在哪里?
**整个数组的下一个位置,才是楼梯顶部
2.3.2:算法思想
1. 状态表示:
a. 创建一个dp表(数组)
b. 定义状态表示:
i. 本体用到 经验 + 题目要求:
1)以某一个位置 i 作为结束:dp[i]表示到达i位置时的最小花费
2. 状态转移方程:以i状态最近的状态来划分问题
dp[ i ]:
先到达 i-1位置,支付 costi-1 的费用 走一步 ——> dp[ i-1 ] + cost[ i-1 ]
先到达 i-2位置,支付 costi-2 的费用 走两步 ——> dp[ i-2 ] + cost[ i-2 ]
所以:dp[ i ] = min(dp[ i-1]+cost[ i-1 ] ,dp[i-2]+cost[ i-2 ])
3. 初始化:保证填表的时候不发生越界
dp[ 0 ] = dp[ 1 ] =0
4. 填表顺序:
从左到右
5. 返回值:dp[n]
代码实现:
public int minCostClimbingStairs(int[] cost) {
// 创建dp表
//初始化
//填表
//返回值
int n = cost.length;
int[] dp = new int [n+1];
for(int i =2;i<=n;i++){
dp[i]=Math.min(dp[i-1]+cost[i-1],dp[i-2]+cost[i-2]);
}
return dp[n];
}
2.3.3:解法二
1. 状态表示:
a. 创建一个dp表(数组)
b. 定义状态表示:
i. 本体用到 经验 + 题目要求:
1)以某一个位置 i 作为起始:dp[i]表示从i位置出发,到达楼顶的最小花费
2. 状态转移方程:以i状态最近的状态来划分问题
dp[ i ]:
支付cost[ i ] 往后走一步 从i+1的位置出发 :dp[i+1]+cost[ i ]
支付cost[ i ] 往后走两步 从i+2的位置出发 :dp[i+2]+cost[ i ]
所以:dp[ i ] = min(dp[ i+1]+cost[ i ] ,dp[i+2]+cost[ i ])
3. 初始化:保证填表的时候不发生越界
dp[n-1] = cost[ n-1 ]
dp[n-2] = cost[ n-2 ]
4. 填表顺序:
从右到左
5. 返回值:min(dp[0],dp[1])
代码实现:
public int minCostClimbingStairs(int[] cost) {
// 创建dp表
//初始化
//填表
//返回值
int n = cost.length;
int[] dp = new int [n+1];
dp[n-1]=cost[n-1];
dp[n-2]=cost[n-2];
for(int i =n-3;i>=0;i--){
dp[i]=Math.min(dp[i+1],dp[i+2])+cost[i];
}
return Math.min(dp[0],dp[1]);
}
2.4:解码方法
2.4.1:算法思想
1. 状态表示:
a. 创建一个dp表(数组)
b. 定义状态表示:
i. 本体用到 经验 + 题目要求:
1)以某一个位置 i 作为结束:dp[i]表示以i为结尾时,解码方法总数
2. 状态转移方程:以i状态最近的状态来划分问题
dp[ i ]:
i位置单独解码:
成功: 1<=a<=9
失败:0
i-1和i 位置结合解码:
成功: 1<=b*10+a<=26
失败:0
所以:dp[ i ] = dp[ i-1 ]+dp[ i-2 ]
注意:如果解码成功才加,不是直接加
3. 初始化:保证填表的时候不发生越界
4. 填表顺序:
从左到右
5. 返回值:dp[n-1]
代码实现:
public int numDecoding(String ss) {
// 创建dp表
//初始化
//填表
//返回值
int n = ss.length();
char[] s=ss.toCharArray();
int[] dp=new int[n];
//初始化第一个位置
if(s[0]!='0')
dp[0]=1;
if(n==1)
return dp[0];//边界情况
///初始化第二个位置
if(s[1]!='0'&&s[2]!='0')
dp[1]+=1;
int t=(s[0]-'0')*10+(s[10]-'0');
if(t>=10&&t<=26)
dp[1]+=1;
for(int i=2;i<n;i++){
//第一种情况
if(s[1]!='0')
dp[i]+=dp[i-1];
//第二种情况
int tt=(s[i-1]-'0')*10+(s[i]-'0');
if(tt>=10&&tt<=26)
dp[i]+=dp[i-2];
}
return dp[n-1];
}
2.4.2:优化
可以发现,初始化部分和循环体内代码结构相似,考虑是否可以合并
处理边界问题和初始化问题的技巧:虚拟节点法
在dp数组中加一个虚拟节点
需要注意的是:虚拟节点的值要保证后面的填表时正确的 and 注意下标的映射关系
这里的虚拟节点dp[ 0 ] 的值需要填1:根据状态转移方程,当我们求dp[ 2 ]时用到了dp[ 0 ],
表示原始字符串第一个位置和第二个位置拼起来可以解码成功,此时需要加dp[ 0 ]的值,
可以看出因为解码成功才需要加dp[ 0 ]的值,所以dp[ 0 ]不可以为0
优化后代码实现:
public int numDecoding2(String ss) {
// 创建dp表
//初始化
//填表
//返回值
int n = ss.length();
char[] s=ss.toCharArray();
int[] dp=new int[n];
dp[0]=1;//保证后续填表是正确的
//注重下标映射的关系
//因为多加了一位 每一位都要减去一位 所以写成1-1 (没有必要 只为看的明白)
if(s[1-1]!='0')
dp[1]=1;
for(int i=2;i<=n;i++){
//第一种情况
if(s[i-1]!='0')
dp[i]+=dp[i-1];
//第二种情况
int tt=(s[i-1-1]-'0')*10+(s[i-1]-'0');
if(tt>=10 && tt<=26)
dp[i]+=dp[i-2];
}
return dp[n];
}