前言
动态规划算法里面最有意思的一个东西之一。动态规划初学肯定会有一定晦涩难懂。如果我们去网上搜索,动态规划的资料,它一开始都是将很多的理论,导致会认为很难,但是这个东西实际上是有套路的。
动态规划的英语是Dynamic Programming(规划),一般来说我们都简称为dp动态规划,简称DP,dp算法就是动态规划的意思
-
求解最优化问题的一种常用策略(什么是最优化问题呢?比如我们要找零钱,硬币个数最少就是最优化,最大连续子序列号,也是最优化问题。在带最字的问题求解中,就有可能用到这个动态规划去解决)
-
通常的使用套路
1.暴力递归(自顶向下,出现了重叠子问题)
2.记忆化搜索(自顶向下)
3.递推(自底向上)
一步一步优化,也就是说我们如果经验不是很足,我们很有可能不会一上来就知道用动态规划的解法去解,很有可能是一步一步的去优化,如果是一步一步的去优化,最后是如何优化到动态规划的这个做法的呢?现先只是去了解,理解不清楚也没关系,后面会反复的讲解这个问题,我们要先大概有一个初步的印象。
一般来说,我们想使用动态规划来解决最优化问题,我们最有可能想到的是暴力递归
,文章后面会深刻体会到这一点,如果暴力递归不行,有很多重复的计算,出现了许多的重叠的问题。这个时候,我们可以采取记忆化搜索
,什么是记忆化搜索,下面会继续学到。
采取记忆化搜索发现还是不行,效率还是不行, 或者说效率虽然提高了,但是还是有一些不必要的地方存在。这个时候需要通过递推解决。其实我们的动态规划最终就是采取**递推
**的方式去做的。
上面的这些东西对刚接触的人都很抽象,不要紧,下面就能够明白这三个概念是什么意思了。我们通过下面一道例题就明白了。
案例实践
找零钱:假设有25分、20分、5分、1分的硬币,现要找给客户41分的零钱,如何办到硬币个数最少?
- 使用贪心策略得到的并非最优解(贪心得到的是5枚硬币:1个25分、3个5分、1个分)
- 如果上面的问题使用的是动态规划,这个时候我们就可以解出来最优的值也就是:2个20分、1个1分。
动态规划最适合用来解决最优化的问题,我们应该如何使用它呢?动态规划使用的熟悉了,我们就会知道:
-
首先要定义状态(状态是原问题,子问题的解),比如定义dp(i)的含义。
-
其次设置初始状态(边界),比如设置dp(0)的值
-
最后确定状态的转移方程,比如确定dp(i)和dp(i-1)的关系
我们最后发现动态规划算法无论咋办都是这个套路,上面的状态啥的,我们先不管,我们先解决一道题,然后再将啥是动态规划。
再使用动态规划前我们都会使用一个假设,我们是首先就来一个dp(n)是凑到n分需要最少硬币的个数
dp(41)=凑到41分的最少硬币个数
dp(20)=凑到20分的最少硬币个数
dp(n)=凑到n分钱的最少硬币个数
我们现在要求dp(41),但是现在还求一个dp(n),那意思是不是我们现在还有求dp(1)、dp(2)、dp(3)、dp(4)这些吗?
答案是的,我们要求这些,那我们为什么要求这些呢?我们先来思考,我们假设现在要凑n分,n可能就是我们最终要的41,也有可能是100或者200都有可能。我们要凑齐n分都是从25分、20分、5分、1分的硬币里面去凑,而dp(n)是凑到最少的
硬币个数。也就是说这个已经是最优解了。我们现在要思考第一个问题,第一个硬币应该怎么去凑呢?我们的第一枚硬币应该有四种选择,假设我们的n是大于25的
假设我们的n是足够的,我们现在四枚硬币选啥都行。假设我第一枚,也就是第一次选择了25分的硬币。我们第一次选择了25分之后,现在还差多少分呢?现在还差n-25分 那现在我们考虑一个问题,d(n-25)是什么意思,它的意思是凑到n-25分之后,需要最少硬币个数.如果说我们第一次选择了25分的硬币,那么dp(n)=dp(n-25)+1,为什么是加1,因为我们选择了一枚硬币。剩下的n-25每要怎么去求,就由后面的dp(n-25)去求就行。
代码建议粘到IDE查看:
package com.lut.dynamic_programming;
public class CoinChange {
/**
* 暴力递归,它是一个自顶向下的调用,什么叫做自定向下,可以理解为较大的数,不断地
* 调用较小的数,较小的数又调用其它更小的数,这个就叫做一个自顶向下的一个调用过程
* 暴力递归就是一个自顶向下的一个调用过程,它的问题是出现了重叠子问题
* <p>
* 什么叫做重叠子问题:问题的规模最开始为n,接下来调用coins(n-5),coins(n-1)...,它
* 的规模就变小了,想当于大问题在调用它的子问题。我们下面的四个都算是n的子问题。
* <p>
* 而且这些子问题是有重复的,也就是很多的子问题进行了重复的计算。出现了重叠子问题的时候
* 我们会想到一个解决的方法就算记忆化搜索,什么叫做记忆化搜索呢?看下面的另一个方法
*/
static int coins1(int n) {
/**每一次选择硬币都有四种选择,我们要选择里面dp(n)最小的,然后dp(n)=dp(n-k)+1
* 所以现在需要通过三个Math.min选择出,四个选择中选出次数最小的,每一次选择出最
* 小后需要+1,也就是dp(n)=dp(n-k)+1。
* 需要注意的是如果硬币等于 25分 or 20分 or 5分 or 1分的时候,直接return 1就行
* 因为上面的这四种情况出现时,只需要一枚硬币,这个是递归的终止条件。
* 需要注意的是,如果硬币是19等特殊数字的时候,n-20会产生一个负数。所以为了
* 使得产生的负数直接取消比较资格,我们需要另一个边界条件。就是如果传入的n小于0
* 直接return一个特别大的数就行,也就是直接return Integer.MAX_VALUE
*
* 下面这个代码存在的问题,下面的这个代码其实是一个递归的写法,它的复杂度是2^n
* 它的调用和斐波那契数列的调用方式很像
* int fib(int n){
* if(n<=2) return 1;
* return fib(n-1)+f(n-2);
* }
* 它里面存在一些重复的计算的调用,斐波那契额数列也存在大量重复的计算
* 比如我们要求5
* fib(5) = fib(4)+fb(3)=fib(3)+fib(2)+fib(2)+f(1)=
* f(2)+f(1)+f(2)+f(2)+f(1)=5
*
* f(4) = fib(3)+f(2)=fib(2)+fib(1)+fib(2)=3
*
* f(10) = f(9)+f(8)=f(8)+f(7)+f(7)+f(6)=f(7)+f(6)+f(6)+f(5)+f(6)+f(5)+f(5)+f(4)=
* f(6)+f(5)+f(5)+f(4)+f(5)+f(4)+f(5)+f(5)+f(4)+f(5)+f(5)+f(4)=
* f(5)+f(4)+f(5)+f(5)+f(4)+f(5)+f(4)+f(5)+f(5)+f(4)+f(5)+f(5)+f(4)=40+15=55
*而我们的这个找硬币的方法同样也存在大量重复的计算
* 假设我们现在传入的n是6,也就是coins(6)
* coins(6)->coins(5)+coins(1)->coins(0)+coins(4)+coins(1)->coins(1)->coins(0)
* 如果n的传入的数字非常大,它存在很多重复的情况。
*
* 因为暴力递归存在很多重复的情况,我们现在要考虑如何去优化它呢。
* 我们刚一开始想使用动态规划,一上来就会想到暴力递归,下面的方法就算暴力递归
*/
if (n < 1) return Integer.MAX_VALUE;
if (n == 25 || n == 20 || n == 5 || n == 1) return 1;
int min1 = Math.min(coins1(n - 25), coins1(n - 20));
int min2 = Math.min(coins1(n - 5), coins1(n - 1));
return Math.min(min1, min2) + 1;
}
static int fib(int n) {
if (n <= 2) return 1;
return fib(n - 1) + fib(n - 2);
}
/**
* 记忆化搜索(自顶向下的调用)
* 记忆化搜索就是把曾经算过的那个值,曾经求解过的那个子问题的解给存起来,这个就叫做记忆化
* 其实它就是缓存,我们可以考虑使用数组来解决这个事情。所谓记忆化就是把出现了的值放到内存
* 里面去
* <p>
* 我们应该如何去实现呢?我们先要引入一个数组,因为是动态思想,这个数组,一般我们把它命名
* dp,问题是这个数组是干嘛用的,这个数组的作用是:假如我们调用了coins(6),它算出来的值是凑
* 齐6分至少要花费最少硬币的个数是多少。一旦我们算出来的值是4枚硬币,这个时候我们就会把
* dp[16]赋值为4。所以dp数组的意思是,如果是dp[16]就代表要凑够16分的话,它所需的最下硬币
* 个数我们把它存起来。就是这么一个道理。
* dp[n]就是凑够n分需要的最小硬币个数。为了能够直接dp[n]所以我们在给这个数组申请内存的时候
* 传入的长度是n+1,因为数组的索引是从0开始的。 长度只有是n+1,才能够直接放n
* <p>
* 这个方法还是存在递归调用,但是每一个dp[n]=只会进入一次递归,有了值就不会再次进行递归取值
* 操作。
* <p>
* 记忆化搜索依然是自定向下调用,还是一个较大的值调用一个较小的值。但是性能相比暴力搜索有较大的
* 提升。
*/
static int coins2(int n) {
if (n < 1) return -1;
/**
* 因为传入n时如果n为19这样的特殊元素,可能会出现19-25=-6这样的数据,我
* 们可以进一步对其进行做处理
*/
int[] dp = new int[n + 1];
int[] faces = {1, 5, 20, 25};
for (int face : faces) {
if (n < face) break;
dp[face] = 1;
}
return coins2(n, dp);
}
static int coins2(int n, int[] dp) {
if (n < 1) return Integer.MAX_VALUE;
// if (n == 1 || n == 5 || n == 20 || n == 25) {//初始化操作
// dp[n] = 1;
// }
if (dp[n] == 0) {
int min1 = Math.min(coins2(n - 25, dp), coins2(n - 20, dp));
int min2 = Math.min(coins2(n - 5, dp), coins2(n - 1, dp));
dp[n] = Math.min(min1, min2) + 1;
} else {
return dp[n];
}
return dp[n];
}
/**
* 上面了做了记忆化搜索,动态规划的最后一步叫做递推。我们只有写到递推这一步才能叫做
* 真正的动态规划,递推和前面的暴力递归和记忆化搜索都是不一样的,它是自底向上的。前
* 面的两步仅仅是前奏,我们刚开始发现能够暴力递归,然后我们发现能够使用记忆化搜索来
* 优化,但是使用了记忆化搜索优化之后,发现还可以优化,那就算什么优化呢?就是递推。
* 通过递推的形式,递推说的广泛一点,就是大家认为的非递归,也就是递推,当然有人也把
* 叫做迭代,但是这里叫做递推比较好。就是一步一步的往前面推,这个叫做递推。
*/
/**
* 递推(自底向上)
* 我们发现前面记忆化搜索的时候,定义了一个dp数组,用于记录搜索过的值。
* 比如我们计算完了凑够n分最少要凑够多少枚硬币计算出来了,这个时候我们
* 就将这个数放在这个数组里面去,dp(n)=凑足n分需要的最少的硬币数目。
* 我们前面是由大到小的调用,从41到往下掉 16 21 26 40 然后这四个
* 数字右会分成4个叉往下掉,一直往下,得到的dp[n]的值是先把d(n-25)的值
* dp(n-20),dp(n-5),dp(n-1)的值算出来。只有将这四个值都算出来了,最终
* 才能知道这个n的值是多少。
* 说白了,现在我们要求凑够41分的硬币个数,要先求出凑够16的硬币最小个数
* 凑够21分的硬币最小个数,凑够36分和凑够40分的最小硬币个数。也就是我们要凑够比较
* 大的硬币的分数,必然要先求出凑够哪些比较小的分需要的硬币个数。小的算出来了,才有办法
* 求出比较大的,所以我们完全有理由相信我们可以由小算到大,因为本来这个式子就可以看得出来
* 肯定要先算出小的。才能算出这个n比较大的
* 所以我们这个dp[]这个数组完全可以由小往大的方向去算。我们通过下面的for循环来测试看
* 这个结果能不能行,for(int i = 1; i <= n; i++){ dp[i] = ;}我们可以先求dp[1]一直到
* dp[n]就可以了。这样就是由小到大的去算。那么我们的方法的返回值应该写啥呢?这个方法我们直接
* 返回一个dp[n]就行了。问题是dp[i]我们要咋求呢?
* 我们知道求dp[i]我们肯定是在dp[i-25],dp[i-20],dp[i-5],dp[i-1]中最小的值拿出来再
* 加1,也就是反着通过不断地i+1、i+5,i+20,i+25来达到i+k=n 问题是我们的i是从1开始的,1-25,
* 1-20...这些数据肯定是不合理的,i等于1的时候1-1好像
* 也没有什么意义。这个时候我们就得去判断,首先来一个int min = Integer.Math_VALUE;然后去判断
* 哪一个合适也就是。现在我们就直接通过比较直接获取最大得值,因为19-25小于0会导致索引越界。所以
* 我们需要在前面加上一个越界判断条件。
*/
static int coins3(int n) {
if (n < 1) return -1;
int[] dp = new int[n + 1];
for (int i = 1; i <= n; i++) {
// int min = Integer.MAX_VALUE;
// if (i >= 1) min = Math.min(dp[i - 1], min);
int min = dp[i - 1];
if (i >= 5) min = Math.min(dp[i - 5], min);
if (i >= 20) min = Math.min(dp[i - 20], min);
if (i >= 25) min = Math.min(dp[i - 25], min);
dp[i] = min + 1;
}
return dp[n];
}
/**
* 为啥这个叫做动态规划?未啥叫做动态规划,它的具体含义是啥,我们先不去了解
* 先通过做题去领悟。
*
* 我们需要知道的是,我们现在不断地给代码做优化,现在上面代码的时间复杂度是o(n)
*/
/**
* 思考题:请输出找零钱的具体方案(最少硬币,具体是使用了哪些面值的硬币)
* 我们应该怎么做呢?我们现在就在递推代码来进行实现。
* 我们现在要凑够n分,它具体使用哪些面值。
* <p>
* 为了充分理解,我们还是先写成前面的代码,
* if (n < 1) return -1;
* int[] dp = new int[n + 1];
* for (int i = 1; i <= n; i++) {
* int min = Integer.MAX_VALUE;
* if (i >= 1) min = Math.min(dp[i - 1], min);
* if (i >= 5) min = Math.min(dp[i - 5], min);
* if (i >= 20) min = Math.min(dp[i - 20], min);
* if (i >= 25) min = Math.min(dp[i - 25], min);
* dp[i] = min + 1;
* }
* return dp[n];
* 首先我们dp[i]是指的凑够i分钱,最少需要多少枚硬币。所以我们就先考虑
* 凑够i-1,i-5,i-20,i-25看一下这些凑够这些分数,哪一个硬币数是最小的
* 把它们最小的值挑出来然后+1,这个1就是指的是最后一次选择了一枚硬币,它
* 可能是1分、5分、20分、25分。
* 我们的关键点就是在进入了那个if判断就可以知道它最后一次选择了哪一枚,
* 举个例子,我们首先得搞清楚,min这个东西肯定是这四个当中的最小值。如果
* 我们这一轮比较中发现dp[i-1],dp[i-5],dp[i-20],dp[i-25]中dp[i-20]是
* 最小的,它是这四个当中最小的,那毫无疑问这个min就算dp[i-20],这就意味着
* 你这一次选择了一枚20分的,也就是最后一次选择了一枚20分。意味着这一次min+1
* 这个1代表,选择了一枚20分的硬币。
* 只有我们这次选的是20,才会去使用dp[i-20]这个值,所以答案就出来了,我们要
* 想知道这一次选择了那一枚,我们只需要看一下这个min它来自于那个if判断,如果我们
* 这个min来自于if (i >= 20) min = Math.min(dp[i - 20], min),也就是一枚20
* 分的,如果min来自于if (i >= 25) min = Math.min(dp[i - 25], min)说明选择
* 的是一枚25分的,我们可以去记录一下。
* 应该如何去记录呢?因为我们这里是一个for循环,肯定是选择了很多次,所以我们可
* 以考虑这么做,int[] faces = new int[dp.length];
* 这个faces[i]是什么意思呢?它的意思代表+1选择了哪一枚,比如假如我们的min是选择
* 的dp[i-20],说白了这一次的加1,代表选择了20分的,才会去考虑i-20凑够i-20需要多少硬币
* 所以这一次选20的话,这个faces[i]=20,它存下来有啥作用呢?我们可以充分利用这个东西了,
* 我们现在改写一下原来的代码
* for(int i=1; i <= n;i++){
* if(n<1) return -1;
* int[] dp = new int[n+1];
* int[] faces = new int[dp.length];
* for(int i=1;i <= n;i++){
* int min = Integer.MAX_VALUE;
* if(i >= 1 && dp[i-1] < min){
* min = dp[i-1];
* faces[i] = 1;
* }
* if(i >= 5 && dp[i-5] < min){
* min = dp[i-5];
* faces[i] = 5;
* }
* if(i >= 20 && dp[i-20] <min){
* min = dp[i-20];
* faces[i] = 20;
* }
* if(i >= 25 && dp[i-25] <min){
* min = dp[i-25];
* faces[i] = 25;
* }
* }
* }
* 改写后的代码,最开始min=Integer.MAX_VALUE。当我们如果发现这个时候i>=1的
* 并且dp[i-1]又是比这个min小,说白了我们发现一个更小的值。所以我们应该把这个值给存起来
* 一旦最小值使用了这个方案,也就是说,这一次,我们是选择了一枚一分的,如果i>=5而且比
* dp[i-5]<min,也就是min小于刚刚算的那个最小值还要小,那说明现在发现的最小值min=dp[i-5],
* 如果我们这一次使用的最小值使用的是dp[i-5]说明这一次要选择必然是5分的。我们现在已经求完
* 前面的,现在我们又发现i>=20,并且dp[i-20]<min,那么我们就让最小值是min=dp[i-20],一旦
* 我们采取的是让min作为我们的最小值,意味着我们最后一次一定是选择的一枚20分的硬币。依次类推
* 如果我们发现dp[i-25]还要更小,我们最小值就采取它,说白了这一次选择的硬币必然是25分的。我们
* 才会考虑i-25分的最小值是多少。
* 理解这个问题的关键就在faces[i]的存储,这个答案就求存来了,这样我们就能够知道每一次
* 拿到的硬币数是谁。求出这个东西之后有啥用,肯定是利用这个faces[]数组去搞事情
*/
static int coins4(int n) {
if (n < 1) return -1;
int[] dp = new int[n + 1];
int[] faces = new int[dp.length];
for (int i = 1; i <= n; i++) {
int min = Integer.MAX_VALUE;
if (i >= 1 && dp[i - 1] < min) {
min = dp[i - 1];
faces[i] = 1;
}
if (i >= 5 && dp[i - 5] < min) {
min = dp[i - 5];
faces[i] = 5;
}
if (i >= 20 && dp[i - 20] < min) {
min = dp[i - 20];
faces[i] = 20;
}
if (i >= 25 && dp[i - 25] < min) {
min = dp[i - 25];
faces[i] = 25;
}
dp[i] = min + 1;
print(faces, i, dp);
}
// print(faces,n);
return dp[n];
}
private static void print(int[] faces, int n, int[] dp) {
System.out.println("凑齐" + n + "分至少需要" + dp[n] + "硬币");
while (n > 0) {
System.out.println("需要" + faces[n] + "分1枚");
n -= faces[n];
}
}
/**
* 这里我们没有具体了解啥是动态规划,只是先理解啥是暴力递归,记忆化搜索,递推
* 最后我们打印了一下这个具体的面值组成。
* <p>
* 下面我们需要给这个找零钱写一个通用的实现,就是可以由别人传面值进来,我们之前
* 的面值都是固定死了的,我们可以搞一个由别人传面值进来,所以这里可以咋做呢?
*/
static int coins5(int n,int[] faces) {
if(n < 1 || faces == null || faces.length == 0) return -1;
int[] dp = new int[n+1];
for (int i = 1; i <= n; i++){
int min = Integer.MAX_VALUE;
for (int face : faces) {
if(i < face || dp[i-face]<0) continue;
min = Math.min(dp[i-face],min);
}
if(min == Integer.MAX_VALUE){
dp[i] = -1;
}else{
dp[i] = min + 1;
}
}
return dp[n];
}
public static void main(String[] args) {
System.out.println(coins5(41,new int[]{5,20,25}));
}
}
难点:
我们现在要思考的是,我们的第一次可能不是选25分,也有可能选择的是20分,如果我们选择了20分的硬币的话
那么dp(n) = dp(n-20)+1。如果第一次选择了5分的硬币,那么dp(n)=dp(n-5)+1。如果第一次选择了1分的
硬币,那么dp(n)=dp(n-1)+1。但是这四种情况都是有可能发生的,因为我第一次选哪个硬币是不确定的。
我们现在要干嘛呢?要求的是dp(n)的最小硬币数。
其实就是这四个值当中的最小值。也就是dp(n) = min{dp(n-25),dp(n-20),dp(n-5),dp(n-1)}+1
所以我们就是从四个中挑出最小的那个。然后+1
我们在四个情况中取出最小的,就算所有中最小的,所以动态规划它能求出最优解,为啥它能求出最优解,是因为
它把所有的情况都考虑到了,为啥贪心不能呢?因为贪心它只看眼前的局部利益,这个就贪心算法的一个问题所在
⚪假设dp(n)是凑到n分需要的最少的硬币个数
⚪如果第一次选择了25分的硬币,那么dp(n)=dp(n-25)+1
⚪如果第一次选择了20分的硬币,那么dp(n)=dp(n-20)+1
⚪如果第一次选择了5分的硬币,那么dp(n)=dp(n-5)+1
⚪如果第一次选择了1分的硬币,那么dp(n)=dp(n-1)+1
⚪所以dp(n)=min{dp(n-25),dp(n-20),dp(n-5),dp(n-1)}+1
分析找零钱案例:
学习完了找零钱,它的解法就是动态规划,现在我们就来学习到底啥是动态规划,我们要学习动态规划,有一个前提我们要知道找零钱最开始是咋做的,我们一开始是进行暴力递归。我们要凑够n分,就看n-1,n-5,n-20,n-25从它们
四个中挑一个最小的,然后再来一个加1。后来我们发现递归的很多子问题中,有很多重复,我们就使用了一个记忆化搜索,但是这个记忆化搜索,仅仅是搞
了一个数组,里面记录着凑够某个面值有的最小硬币个数,就是使用这个dp这个数组,去记录。但是这个记忆化搜索依旧是从上往下的调用的,依然还存在着递归调用。后面我们发现,完全可以通过一个递推的形式,就是自底向上,先求出较小的值,进而再往上推,推出值比较大的,也就是我们发现我们的这个dp数组完全可以从dp[1]开始,到dp[2],dp[3],dp[4],d[5]一直往上面求,所以我们dp[i]的i一直在增
长,所以的话是由小推到大,所以这样的话,我们直接把递归调用给省略掉了。这个过程就可以理解为在执行动态规划的一个过程。
动态规划它究竟是个什么呢?我们来回顾一下,它有一些抽象,但是不难理解。动态规划是求解最优化问题的一种常用的策略,比如我们讲到找零钱兑换,它就是看最少硬币个数多少,最少这个就是一个最优化问题,它通常的套路是啥呢?
我们先来一个暴力递归,然后接下来发现有重复的,这个时候我们来一个记忆化搜索,前面两种都是递归,都是自顶向下的我们后来发现可以自底向上,也就是从小到大使用记忆化搜索,可以去推出这个值。
为啥这个就叫做动态规划呢?这个时候我们就可以利用比较专业的术语来说这一个问题。动态规划中的"动态"可以理解为是"可以变化的状态"
-
定义状态(状态是原问题、子问题的解)
比如dp[i]的定义 -
设置初始状态(边界)
比如设置dp[0]的值
-
确定状态转移方程
比如确定dp(i)和dp(i-1的关系)
动态规划的常规步骤,第一个步骤定义状态,什么是状态,状态就是原问题,子问题的解,比如定义一下dp(i)的含义,这个就叫做定义状态,举个例子,我们之前在讲零钱兑换的时候,我们定义了一个东西——dp(n),dp(n)就是凑到n分最少的
硬币个数,如果我们这里写一个dp(41)就是凑够41需要的最少硬币个数。如果是dp(4)那就是凑够4分所需的最少硬币个数所以我们这里的dp(n)就代表定义状态,这个定义状态的意思就算要得到这个解,因为这个dp(n)就是我们最终想要得到的
一个解,到时候n是41,dp(41)就是我们最终要的这个解。 所以动态规划里面定义状态,这个状态一般来说就是原问题的解,也是子问题的解,这句话啥意思呢?我们现在虽然定义了个dp(n),确实是我们最重要的一个解,当我们的这个n=41的时候,它就是我们想要的解。当规模更小了就变成子问题了,这个时候dp(n-25)就是一个子问题的解,就算凑够n-25分需要的最少硬币数,它就算子问题的解。动态规划一上来就是定义规划,我们定义的这个状态,一般就是这个问题最终的解,也是子问题要的一个解。
定义完这个状态的时候我们接下来要设置一些边界条件,也就是初始状态,比如设置一下dp(0)的值,当然不一定是dp[0],它有可能是dp(1),dp(2),dp(3)这些都是初始值,这些就是设置一些初始状态,那些值是可以直接确定的,哪些子问题的解我们是可以直接马上确定的,我们就给它马上设置一个初始值。
比如以找零钱的代码为例子,我们先搞了一个dp[]数组,也就是状态数组,我们做了一个啥初始化,dp[face]=1,说白了凑够1肯定1枚,凑够5、20、25的话,肯定也是一枚,凑够5和凑够25的话,最少硬币就是一枚。像这种就是初始值,所以初始值就是这个。但是有很多问题里面,就是直接初始化dp[0],这个也叫做边界条件。这个根据条件而定,不同题目的边界条件是不一样的,这个dp[i]的状态定义出来了,我们把这个解是怎样的一个形式定义出来了,初始值也有了,也就是边界条件也有了,接下来就要开始递推了。
开始递推,也就是从dp(0)一直推到dp(i),一直往后推,这个推的过程,专业来说,叫做确定状态转移方程,为啥叫做状态转移方程,前面我们清楚的知道了,dp[i]代表状态,我们会将dp(i)从小推到大,说白了先求出dp(1)的值,然后推出dp(2)的值是什么就相当于从1这个状态转移到2这个状态,我们现在从小求到大,就相当于由以前的状态要推出一个新的状态,由旧的状态,要推导出一个新的状态,所以这个专业来讲,叫做状态转移方程。
简单来说,状态转移方程,就是确定我们dp(i)和dp(i-1)的关系,就是我们知道dp(i-1)怎么知道dp(i),一旦知道了这dp(i)怎么推出dp(i+1),这个叫做状态转移方程,也就是确定哪个递推式,确定那个递推关系。这个就是动态规划的三大步骤。
我们会影响深刻的,定义完状态,有一些初始状态我们要去设置一下,也就是边界条件,这个时候我们就需要一个状态转移方程,看一下然后知道这个dp(i-1)怎么推导出dp(i)呢?怎么由dp(i)推导出dp(i+1),这个就叫做状态转移方程。
动态规划的一个解释:
Dynamic Programming is a method for solving a complex problem by breaking it down into a collection of simpler subProblem,solving each of those subProblems just once,and storing theirsolutions
也就是说动态规划,就是把原问题分解成一系列的更简单的子问题,并且把每一个子问题只解决一次,并且把它的值给存储。
⚪将复杂的问题拆解成若干个简单的子问题
⚪每个子问题仅仅解决一次,并保存它们的解
⚪最后再推导出原问题的解
⚪可以用动态规划来解决的问题,通常具备2个特点
⚪最优子结构(最优化原理):通过求解子问题的最优解,可以获得原问题的最优解(如果符合这个特点,这就叫做最优子结构)
⚪无后效性(动态规划不仅仅要有最优化子结构,还得满足无后效性,必须满足两个条件才能通过动态规划来解决)
- 某阶段的状态一旦确定,则此 后过程的演变不再受此前各状态的决策影响(未来与过去无关)
- 在推导后面阶段的状态时;只关系前面阶段的具体状态值,不关心这个状态是怎么一步一步推导出来的
无后效性(举例)
现在有一个5*5的一个格子,从起点(0,0)走到终点(4,4)一共有多少种走法?只能向右、向下走。
如果我们要使用动态规划解决这个问题,我们首先第一件事就是去定义状态,这个时候我们发现我们现在要定义的状态是2维的了,不再是1维的了,之前只有dp(i),现在我们定义了一个dp(i,j),dp(i,j)的意思是从(0,0)走到(i,j)的走法,也就是dp(i,j),也就是dp(4,4),这个问题既是子问题的解,又是原问题的解。那么初始值是什么,我们定义完状态肯定有一个初始状态的值,我们现在要看一下那一些是初始值,dp(0,0)是1还是0,因为从自己走到自己是1种走法,但是我们关注的重点不是这个,除了dp(0,0),还有哪些值是可以确定的,其实还有dp(i,0)和dp(0,j) 这几种状态都是只有一种走法,所以dp(i,0)=dp(0,j)=1我们现在把水平方向用i表示,我们把竖直方向定义为j。现在问题来了,状态转移方程应该怎么写。
- 假设dp(i,j)是从(0,0)走到(i,j)的走法
- dp(i,0)=dp(0,j)=1
- dp(i,j)=dp(i,j-1)+dp(i-1,j)
(状态转移方程怎么写,也就是我们现在要求(i,j)要从哪个点推导过来,也就是可以从哪些地方走过来,只能向下向右走。它只能从(i-1,j)往右走,或者从(j,j-1)往下走。所以dp(i,j)=dp(i,j-1)+dp(i-1,j),前面的公式代表着,从(0,0)到(i,j)的走法,
说白了就是dp(i,j-1)和dp(i-1,j)的走法之和。无后效性)
无后效性,推导dp(i,j)时只需要用到dp(i,j-1)、dp(i-1,j)的值,不需要关心dp(i,j-1)、dp(i-1,j)的值是怎么求出来的,我们只需要有多少种走法,并不关心如何走的。而且也不影响使用这个值去推导别的值
上面只是解释了什么是无后效性,现在我们要去看一个相反的情况,就是什么是有后效性,只有理解了什么是有后效性,才能去理解什么是**无后效性
**,这个问题比较抽象,我们先来看一个有后效性的例子。还是前面的5*5的一个格子,不过现在可以向左、向右、向上、向下走,并且同一个格子不能走两次,这个问题就有后效性了。