动态规划
暴力递归之所以暴力是因为存在大量的重复计算,比如一个很经典的问题——斐波那契数列。
public static int fibonacci(int n) { if(n==1) { return 1; } if(n==2) { return 1; } return fibonacci(n-1)+fibonacci(n-2); }
上面的试法,存在大量的重复计算,浪费时间。
由此引出我们的动态规划,之前写了8篇暴力递归的文章,就是为了这个目的。
因为动态规划就是某一类尝试行为的进一步优化,任何一个动态规划的问题都是以某一个暴力尝试过程中优化后的样子。
题目
假设有排成一行的N个位置,记为1~N, N一定大于或等于2
开始时机器人在其中的M位置上(M一定是1~N中的一个)
如果机器人来到1位置,那么下一步只能往右来到2位置;
如果机器人来到N位置,那么下一步只能往左来到N-1位置;
如果机器人来到中间位置,那么下一步可以往左走或者往右走;
规定机器人必须走K步,最终能来到P位置(P也是1~N中的一个)的方法有多少种
给定四个参数N、M、K、P,返回方法数。
题目什么意思呢,我们先自己带入小的样本量,方便我们弄懂题意:
假设总共有7个数,一开始,机器人在数字3上面,要求必须走满3步,最终能来到数字2上面,方法总共有3种,下图分别列出来:
package com.harrison.class13; public class Code02_RobotWalk { // N 只能在1~N范围上移动 固定参数 // cur 当前来到的位置 可变参数 // rest 还剩多少步要走 可变参数 // P 最终要到的位置 固定参数 // 函数含义: // 只能在1~N位置上移动,当前在cur位置上,走完rest步后,停在P位置上的方法有多少种 public static int process1(int N,int cur,int rest,int P) { // 如果没有剩余步数了,并且来到了目的位置,说明之前的移动方式有效 // 如果没有剩余步数了,并且没有来到目的位置,说明之前的移动方式无效 if(rest==0) { return cur==P?1:0; } // if没中,还剩rest步要走 // 如果来到了1位置,没得选,只能往右去2位置 // 后续的过程就是来到2位置,还剩rest-1步 if(cur==1) { return process1(N, 2, rest-1, P); } // 如果来到了N位置,没得选,只能往左去N-1位置 // 后续的过程就是来到N-1位置,还剩rest-1步 if(cur==N) { return process1(N, N-1, rest-1, P); } // 如果还有rest步要走,而当前的cur位置在中间,那么当前这步可以走向左,也可以走向右 // 走向左之后,后续的过程就是,来到cur-1位置上,还剩rest-1步要走 // 走向右之后,后续的过程就是,来到cur+1位置上,还剩rest-1步要走 // 走向左、走向右是截然不同的方法,所以总方法数都要算上 return process1(N, cur-1, rest-1, P)+process1(N, cur+1, rest-1, P); } public static int ways1(int N,int M,int K,int P) { // 参数无效直接返回0 if(M<1 || M>N || P<1 || P>N || K<1 || N<2) { return 0; } // 总共N个位置,从M点出发,还剩K步,返回最终能到达P的方法数 return process1(N, M, K, P); } public static void main(String[] args) { System.out.println(ways1(7, 3, 3, 2)); } }
上述暴力递归肯定也存在大量的重复计算,
如果我们可以做出一个缓存的话,那就不用递归,可以直接从缓存中拿数据来用!
所以,接下来进行优化:
// 把cur和rest的组合,返回的结果,加入到缓存中 public static int process2(int N,int cur,int rest,int P,int[][] dp) { if(dp[cur][rest]!=-1) {// 不等于-1,表示已经算过,直接从缓存中拿值 return dp[cur][rest]; } if(rest==0) { // 返回之前先加缓存,底下都这么干 dp[cur][rest]=cur==P?1:0; return dp[cur][rest]; } if(cur==1) { dp[cur][rest]=process2(N, 2, rest-1, P,dp); return dp[cur][rest]; } if(cur==N) { dp[cur][rest]=process2(N, N-1, rest-1, P,dp); return dp[cur][rest]; } dp[cur][rest]=process2(N, cur-1, rest-1, P,dp)+process2(N, cur+1, rest-1, P,dp); return dp[cur][rest]; } public static int ways2(int N,int M,int K,int P) { if(M<1 || M>N || P<1 || P>N || K<1 || N<2) { return 0; } // 这张二维表可以把递归所有的返回值装下 int[][] dp=new int[N+1][K+1]; for(int row=0; row<=N; row++) { for(int col=0; col<=K; col++) { dp[row][col]=-1;// 表示所有的参数组合都没有算过 // 因为如果算过的话,方法数不可能小于0 } } return process2(N, M, K, P,dp); }
在每次暴力递归之前先在缓存中查看是否已经算过,如果已经算过,就直接从缓存中拿值,可以省去很多重复计算的过程。这就是动态规划,它的另一个名字叫做记忆化搜索。是动态规划中最糙的一种。它不关心状态的依赖,就是一个很傻的缓存,遇到了重复的过程就从缓存中拿结果,如果碰到没算过的过程才去算。
那么经典的动态规划是怎样的呢?
经典的动态规划需要对表进行精细化分析,很明显上述递归过程只有两个可变参数,所以可以看成一个二维表:(在1~7位置上,从2出发,还剩下5步,最终要走到3位置上)
接下来就按我们一开始暴力递归的思路去填这张表:
1)cur是在1~7上,不存在0,所以第0行全画叉;
// 如果没有剩余步数了,并且来到了目的位置,说明之前的移动方式有效 // 如果没有剩余步数了,并且没有来到目的位置,说明之前的移动方式无效 if(rest==0) { return cur==P?1:0; } • 1 • 2 • 3 • 4 • 5
2)cur==1的时候,依赖左下角的位置;
// 如果没有剩余步数了,并且来到了目的位置,说明之前的移动方式有效 // 如果没有剩余步数了,并且没有来到目的位置,说明之前的移动方式无效 if(rest==0) { return cur==P?1:0; }
3)cur==N的时候,依赖左上角的位置;
// 如果来到了N位置,没得选,只能往左去N-1位置 // 后续的过程就是来到N-1位置,还剩rest-1步 if(cur==N) { return process1(N, N-1, rest-1, P); }
4)除此以外,任何一个普遍位置都依赖左上角和左下角的值。
// 如果还有rest步要走,而当前的cur位置在中间,那么当前这步可以走向左,也可以走向右 // 走向左之后,后续的过程就是,来到cur-1位置上,还剩rest-1步要走 // 走向右之后,后续的过程就是,来到cur+1位置上,还剩rest-1步要走 // 走向左、走向右是截然不同的方法,所以总方法数都要算上 return process1(N, cur-1, rest-1, P)+process1(N, cur+1, rest-1, P);
这么看来,改动态规划根本不需要知道原来题意是什么,完全根据暴力递归的思路来改就可以了。也就是说,如果某个人写了一个二维可变参数的递归,那么你就可以只根据他的递归怎么写就可以改出动态规划,都不用关心原题意是什么!
接下来根据上述原则把这个二维表格填好,然后根据主函数的调用方式返回(从2出发,还剩下5步):
附上这道题目两种方法的完整代码:
package com.harrison.class13; public class Code02_RobotWalk { // N 只能在1~N范围上移动 固定参数 // cur 当前来到的位置 可变参数 // rest 还剩多少步要走 可变参数 // P 最终要到的位置 固定参数 // 函数含义: // 只能在1~N位置上移动,当前在cur位置上,走完rest步后,停在P位置上的方法有多少种 public static int process1(int N,int cur,int rest,int P) { // 如果没有剩余步数了,并且来到了目的位置,说明之前的移动方式有效 // 如果没有剩余步数了,并且没有来到目的位置,说明之前的移动方式无效 if(rest==0) { return cur==P?1:0; } // if没中,还剩rest步要走 // 如果来到了1位置,没得选,只能往右去2位置 // 后续的过程就是来到2位置,还剩rest-1步 if(cur==1) { return process1(N, 2, rest-1, P); } // 如果来到了N位置,没得选,只能往左去N-1位置 // 后续的过程就是来到N-1位置,还剩rest-1步 if(cur==N) { return process1(N, N-1, rest-1, P); } // 如果还有rest步要走,而当前的cur位置在中间,那么当前这步可以走向左,也可以走向右 // 走向左之后,后续的过程就是,来到cur-1位置上,还剩rest-1步要走 // 走向右之后,后续的过程就是,来到cur+1位置上,还剩rest-1步要走 // 走向左、走向右是截然不同的方法,所以总方法数都要算上 return process1(N, cur-1, rest-1, P)+process1(N, cur+1, rest-1, P); } public static int ways1(int N,int M,int K,int P) { // 参数无效直接返回0 if(M<1 || M>N || P<1 || P>N || K<1 || N<2) { return 0; } // 总共N个位置,从M点出发,还剩K步,返回最终能到达P的方法数 return process1(N, M, K, P); } // 把cur和rest的组合,返回的结果,加入到缓存中 public static int process2(int N,int cur,int rest,int P,int[][] dp) { if(dp[cur][rest]!=-1) {// 不等于-1,表示已经算过,直接从缓存中拿值 return dp[cur][rest]; } if(rest==0) { // 返回之前先加缓存,底下都这么干 dp[cur][rest]=cur==P?1:0; return dp[cur][rest]; } if(cur==1) { dp[cur][rest]=process2(N, 2, rest-1, P,dp); return dp[cur][rest]; } if(cur==N) { dp[cur][rest]=process2(N, N-1, rest-1, P,dp); return dp[cur][rest]; } dp[cur][rest]=process2(N, cur-1, rest-1, P,dp)+process2(N, cur+1, rest-1, P,dp); return dp[cur][rest]; } public static int ways2(int N,int M,int K,int P) { if(M<1 || M>N || P<1 || P>N || K<1 || N<2) { return 0; } // 这张二维表可以把递归所有的返回值装下 int[][] dp=new int[N+1][K+1]; for(int row=0; row<=N; row++) { for(int col=0; col<=K; col++) { dp[row][col]=-1;// 表示所有的参数组合都没有算过 // 因为如果算过的话,方法数不可能小于0 } } return process2(N, M, K, P,dp); } public static void main(String[] args) { System.out.println(ways1(7, 2, 5, 3)); System.out.println(ways2(7, 2, 5, 3)); } }
最后来点方法论的总结:
暴力递归的分析过程抽象出来就是动态规划的转义方程,任何一个动态规划都是由暴力尝试的尝试的那个种子改过来的。只要可变参数是有限几个,三个可变参数就是一张三维表,两个可变参数就是一张二维表,一个可变参数就是一张一维表。只要能试出由可变参数代表的一个暴力递归,就可以改成动态规划。注意:不是所有暴力递归都能改成动态规划,但是动态规划一定来自一个暴力递归,而暴力递归是跟自然智慧最贴合的,知道怎么拆,所以比编动态转义方程容易。
有些暴力递归改不成动态规划的原因是它没有足够多的重复过程。当然改还是可以改,但是没有必要。
题 -> 找到暴力递归的写法(尝试)-> 分析暴力递归过程中是有重复解的(可变参数不讲究组织就是记忆化搜索,记忆化搜索进行精细化组织就是经典的动态规划)
不是所有暴力递归都能改成动态规划,但是动态规划一定来自一个暴力递归!
不是所有暴力递归都能改成动态规划,但是动态规划一定来自一个暴力递归!
不是所有暴力递归都能改成动态规划,但是动态规划一定来自一个暴力递归!
重要的事情说三遍。