一、什么是动态规划
很多人一听到动态规划这四个字就浑身发抖,整天抖成筛子,看到高手写的帖子怎么都看不懂,觉得自己和他们好像来自不同的星球,自己费劲心机把一个动态规划搞懂了,结果下个动态规划依旧搞不出来。那么,动态规划是啥呢?简单总结起来就是在调用的过程中如果发现重复的过程,动态规划在算过一次之后把答案记下来,下回再遇到重复过程,直接调,这个行为就叫做动态规划。
举个栗子:
斐波那契数列大家都知道,代码也很简单:
public static int f(int n){
if (n == 1){
return 1;
}
if (n == 2){
return 1;
}
//f(n) = f(n-1) + f(n - 2)
return f(n - 1) + f(n - 2);
}
相信大家一定都会写,那么这个代码有什么问题呢?
如果你把所有的 f 摊开的话,它会有大量的重复过程。如下图:
上图未画完整,假如我们需要计算第六项f(6),我们发现f(4),f(3),f(2)会被多次进行计算,那么什么是动态规划呢,动态规划就是有一个值我一旦计算过,我把他放在一个表中记录下来,下次再需要计算的时候,我直接从表里面拿值而不去重复计算,这就是动态规划。
题目一
假设有排成一行的N个位置,记为1~N,N 一定大于或等于 2 开始时机器人在其中的M位置上(M 一定是 1~N 中的一个) 如果机器人来到1位置,那么下一步只能往右来到2位置; 如果机器人来到N位置,那么下一步只能往左来到 N-1 位置; 如果机器人来到中间位置,那么下一步可以往左走或者往右走; 规定机器人必须走 K 步,最终能来到P位置(P也是1~N中的一个)的方法有多少种 给定四个参数 N、M、K、P,返回方法数。
分析:
假如有如上7个格子,如果开始位置M 为2 ,其终点P为4,机器人必须走 k 为 4 步,有几种走法,如果走到1就只能往2走。假如说从1到p点需要走8步,那么他就等与从2到p走7步的方法数,因为在1位置只能往2走,同理,从N走到P点走8步相当于从从N-1走7步,因为在N位置只能往N-1方向走,如果不在1和N位置,那么,走到p点的方法数就是往左走的方法数和往右走的方法数之和。根据分析,我们写出该问题的最暴力的写法:
public static int ways1(int N,int M,int P,int K){
if (N < 2 || K < 1 || M < 1 || M > N || P < 1 || P > N){
return 0;
}
return process1(M,K,P,N);
}
/**
*
* @param cur 机器人当前的位置
* @param rest 机器人还有rest 步要走
* @param aim 机器人的最终目标
* @param N 1-N 位置
* @return 机器人从 cur 出发,走过rest之后,最终停在aim处,方法数是多少
*/
private static int process1(int cur, int rest, int aim, int N) {
if (rest == 0){ //如果已经不需要走了,走完了
return cur == aim ? 1 : 0;
}
//如果没返回就说明还有步数需要走
//如果是当前位置是在1的位置,那么就只能往2走
if (cur == 1){
return process1(2,rest - 1,aim,N);
}
//如果当前位置是在N的位置,那么就只能往 N-1走
if (cur == N){
return process1(N - 1,rest - 1,aim,N);
}
//如果都不在,就是在中间,那返回的方法数就是,往左走的方法数和往右走的方法数之和
return process1(cur-1,rest - 1,aim,N ) + process1(cur+1,rest - 1,aim,N);
}
看上面代码,是不是很具有自然智慧,思路很清晰是不是,那我们怎么优化它呢,我们就需要知道谁真正代表process的返回值。我们举个例子,假如从7位置出发,要去13位置结束,有10步可走,我们看这个递归函数式如何调的,首先我们注意到,在递归函数中,后两个参数是完全不变的,所以可想而知,我们最后的返回值和这两个参数没什么关系,前两个参数一但定了,我们的返回值就定了。比如说:
如上图,我从7到13还剩10步,我可以选择从6到13还剩9步,也可以选择从8到13还剩9步。6,9我可以选择到5还剩8步,我也可以选择7还剩8步。8,9我可以选择到7还剩8步,到9还剩8步,看到重复解了吧,我们不管是怎么到大7,8这个状态的,问题是不是从7出发到13,还有8步要走的方法数啊,它跟之前做了什么决策没有关系,返回值一定是一样的。
那么什么样的暴力递归是可以优化的呢?
如上图所示,有重复解的暴力递归是可以优化的,而如果所有的子问题都是不同的,那么动态规划是优化不了那样的过程的。优化后的代码如下:
public static int ways2(int N,int M,int P,int K){
if (N < 2 || K < 1 || M < 1 || M > N || P < 1 || P > N){
return 0;
}
//我们准备一张表来记录走的步
int[][] dp = new int[N+1][K+1];
for (int i = 0; i <= N; i++) {
for (int j = 0; j <= K; j++) {
dp[i][j] = -1;
}
}
//dp[cur][rest] == -1 ===> process(cur,rest)之前没算过
//dp[cur][rest] != -1 ===> process(cur,rest)之前算过 直接返回 dp[cur][rest]
return process2(M,K,P,N,dp);
}
//cur 范围 1 - N;
//rest 范围 0-k;
private static int process2(int cur, int rest, int aim, int N,int[][] dp) {
if (dp[cur][rest] != -1){
return dp[cur][rest];
}else {
//之前没算过
int res = 0;
if (rest == 0){ //如果已经不需要走了,走完了
res = cur == aim ? 1 : 0;
}else if (cur == 1){
res = process2(2,rest - 1,aim,N,dp);
}else if (cur == N){
res = process2(N - 1,rest - 1,aim,N,dp);
}else {
res = process2(cur-1,rest - 1,aim,N,dp) + process2(cur+1,rest - 1,aim,N,dp);
}
dp[cur][rest] = res;
return res;
}
}
如上代码所示,我们添加了一张缓存表dp,如果我们计算过该值,就直接从缓存表中取就行,如果没有计算过该值,就计算,返回之前存入缓存表中,这样就避免的大量的重复计算。它有一个名词,叫从顶向下的动态规划,它的本质就是我作为一张缓存表,我不关心你的位置依赖,你没算过,你就算,如果算过,我就直接把算过的答案给你,是不是很简单。 它还有一个名词叫做记忆化搜索,本质上就是找哪几个参数可以代表返回值,我加一个缓存的方式给你实现记忆化,用空间换时间。
我们再继续分析,缓存表的大小我们知道,cur和rest任意的组合都在这张缓存表中,我们把这张表画一下,假设有 1 2 3 4 5 个位置 N = 5,假设小机器人一开始在 2 的位置,假设他要去 4 位置,以及能走6步,我们看如下张表:
行代表当前位置 cur 列代表 走的步数rest,这张表包括了所有的可能性,所有的返回值都一定能被该表装下,在这个0位置,是永远用不着的,因为题目中的步数是从1开始的,但是我们也生成了,不用就可以了。那我们应该怎么填写这张表呢?,我们回到ways1方法中,先看baseCase是怎么写的, cur == aim ? 1:0 ;在这张表中,在rest = 0时,当cur 和 aim 相等时才是 1 ,其他都是 0,在这个例子中,我们的aim 是 4 所以,只有 rest = 0 cur = 4 的位置 是 1,rest = 0 的其他行都为0,接下来我们看在这张表中,我们最想要的是啥,我们从哪看,当然是从主函数中看呀,process(cur,rest,aim,N); aim 和 N是固定不变的,递归函数和这两个参数没有关系,所以我们看 cur 和 rest ,在该例子中,cur 是 2 ,rest 是6 ,所以我们就知道,在这张表中,我们需要的就是(2,6)这个位置的值,如果能推出整张表的值,我们就把图中画星的位置给用户,就是它想要的答案了,怎么推呢,接下来我们继续看递归函数,if (cur == 1){return process1(2,rest - 1,aim,N);}当cur = 1 时,当rest也等于1时,它依赖于 2 , 0 这个位置,它就是 cur = 1,rest = 1,的左下角位置,其他问号依赖关系同理,根据递归函数,第三行问号依赖于左上角和左下角,这样,我们就可以把这张表给填出来了。最终版本的动态规划他来了,代码如下:
public static int ways3(int N,int M,int P,int K){
if (N < 2 || K < 1 || M < 1 || M > N || P < 1 || P > N){
return 0;
}
//我们准备一张表来记录走的步
int[][] dp = new int[N+1][K+1];
//根据刚刚分析,dp初始化时全是0
//在0列时,只有当cur和aim相等时才为1,所以直接将该为位置变为1
dp[P][0] = 1;
for (int rest = 1; rest <= K; rest++) {
dp[1][rest] = dp[2][rest-1];
for (int cur = 2; cur < N; cur++) {
dp[cur][rest] = dp[cur-1][rest-1]+dp[cur+1][rest-1];
}
dp[N][rest] = dp[N-1][rest-1];
}
return dp[M][K];
}
题目二
给定一个整型数组arr,代表数值不同的纸牌排成一条线 玩家A和玩家B依次拿走每张纸牌 规定玩家A先拿,玩家B后拿 但是每个玩家每次只能拿走最左或最右的纸牌 玩家A和玩家B都绝顶聪明 请返回最后获胜者的分数。
分析:我们拿[50,100,20,10]举例,有一个先手,有一个后手,因为玩家只能拿第一张和最后一张,因为每个玩家都绝顶聪明,他们是都能看见每张牌的,但是只能从前或者从后拿,这个例子中,如果先手拿了50,那么100这张牌就会暴露给后手,那么先手就输了,所以,先手先忍一下拿了10这种牌,而后手没办法只能拿50,先手再拿100先手获得了110的收益,从题目来看,先手只能从前或者从后拿,所以先手会从 从左边拿 + 做为后手的最大收益和 从右边拿 + 作为后手的最大收益中选一个最大的,而后手呢,因为先手先拿,它只能被迫无奈选最小的,代码如下:
public static int win1(int arr[]){
if (arr == null || arr.length == 0){
return 0;
}
int first = f1(arr,0,arr.length - 1);
int second = g1(arr,0,arr.length - 1);
return Math.max(first,second);
}
//先手函数
public static int f1(int[] arr,int L,int R){
if (L == R){
//如果L和R相等说明只剩一张,因为是先手,所以就直接拿了
return arr[L];
}else{
//不等,说明还剩不止一张
//假如先手拿的是左边
//那么先手的收益就是左边的收益加下一轮作为后手的收益
int p1 = arr[L] + g1(arr,L + 1,R);
//假如先手拿的是右边,
//那么先手的收益就是右边加下一轮作为后手的收益
int p2 = arr[R] + g1(arr,L,R-1);
//最后,先手会选一个最大的返回
return Math.max(p1,p2);
}
}
private static int g1(int[] arr, int L, int R) {
if (L == R){
//如果只剩1张牌了,那肯定会被先手拿走,作为后手,什么都拿不到
//所以返回0
return 0;
}else {
//不止一张牌
//如果先手拿的是左边的牌
//对于下回合而言,自己就相当于先手,所以尽自己最大的努力尝试去回去最好的
int p1 = f1(arr,L+1,R);
//如果先手拿的是右边的牌
//作为后手,只能尽全力在下一回合拿最好的牌
int p2 = f1(arr,L,R-1);
//因为是先手先选,两人都绝顶聪明,所以先手会把大的拿走,
//作为后手,只能被迫无奈,拿小的牌
return Math.min(p1,p2);
}
}
对于上述代码中,g函数为什么去min大家一定很疑惑,现在给大家举一个例子:
如上图:根据代码:如果先手拿左边的10, 后手就会在省下的牌中尽力选择最大的,选了 30,如果先手拿右边,那么后手就会从剩下的尽力选最大的 20,但是,因为是先手先选,所以先手肯定会拿30,所以作为后手,因为30被先手拿走了,所以自己就只能拿20,这就是为什么g函数中要选最小的,因为最大的他拿不到。
那这道题能不能优化呢?我们先分析依赖,f(0,7)依赖于g(1,7)和g(0,6),g(1,7)依赖于f(2,7)和f(1,6),等如下图:
怎么知道是这么依赖的呢?我们的递归函数就是这么写的嘛,我们发现了重复值,那么就可以优化,我们可以加两个存储f 和 g 的值的表,fdp和gdp。如果该值不是 -1 就直接从表中拿,如果是,就计算。
public static int win2(int arr[]){
if (arr == null || arr.length == 0){
return 0;
}
int N = arr.length;
int[][] fdp = new int[N][N];
int[][] gdp = new int[N][N];
for (int i = 0; i < N; i++) {
for (int j = 0; j < N; j++) {
fdp[i][j] = -1;
gdp[i][j] = -1;
}
}
int first = f2(arr,0,arr.length - 1,fdp,gdp);
int second = g2(arr,0,arr.length - 1,fdp,gdp);
return Math.max(first,second);
}
//先手函数
public static int f2(int[] arr,int L,int R,int[][] fdp,int[][] gdp){
if (fdp[L][R] != -1){
return fdp[L][R];
}else {
int ans = 0;
if (L == R){
//如果L和R相等说明只剩一张,因为是先手,所以就直接拿了
ans = arr[L];
}else{
//不等,说明还剩不止一张
//假如先手拿的是左边
//那么先手的收益就是左边的收益加下一轮作为后手的收益
int p1 = arr[L] + g2(arr,L+1,R,fdp,gdp);
//假如先手拿的是右边,
//那么先手的收益就是右边加下一轮作为后手的收益
int p2 = arr[R] + g2(arr,L,R - 1,fdp,gdp);
//最后,先手会选一个最大的返回
ans = Math.max(p1,p2);
fdp[L][R] = ans;
}
return ans;
}
}
private static int g2(int[] arr, int L, int R,int[][] fdp,int[][] gdp) {
if (gdp[L][R] != -1){
return gdp[L][R];
}else {
int ans = 0;
if (L!=R){
//不止一张牌
//如果先手拿的是左边的牌
//对于下回合而言,自己就相当于先手,所以尽自己最大的努力尝试去回去最好的
int p1 = f2(arr,L+1,R,fdp,gdp);
//如果先手拿的是右边的牌
//作为后手,只能尽全力在下一回合拿最好的牌
int p2 = f2(arr,L,R -1,fdp,gdp);
//因为是先手先选,两人都绝顶聪明,所以先手会把大的拿走,
//作为后手,只能被迫无奈,拿小的牌
ans = Math.min(p1,p2);
gdp[L][R] = ans;
}
return ans;
}
}
那么,接下来我们该如何继续改动态规划呢?
我们继续举例子:[7,4,16,15,1]
通过观察win1我们可以知道,这是有先手和后手两个递归调用,那么,我们就需要两张表来存储所有的答案。两张表生成之后,我们该怎么填呢,还是看baseCase呗,首先我们先看f表, if(L==R)return arr[L], L== R不就是f表的对角线嘛,值是啥呢,不就是例子中下表对应的值吗,这样fdb的对角线就填好了,而gdb L== R的时候全是0 ,主函数要什么,主函数要两张表 0 到 n-1的值,因为是一个范围的左和一个范围的右,所以这两张表的坐下角都没用,因为左下角是L > R 的部分。接下来,我们找普遍依赖,通过递归函数我们可以知道,fdb表中?它依赖于gdb表中?`中左和下的三角符号,gdb依赖fdb同理,如下图:
由上分析可知,通过上述的依赖分析,我们可以由 7 4 16 15 1 这条对角线推出gdb表中(0,1),(1,2),(2,3),(3,4)这条对角线的值,而由根据gdb的值,可以推出fdb对角线的值,这两个表互相推,最终把这两张表填满。这就是最终的动态规划版本。代码如下:
public static int win3(int[] arr){
if (arr == null || arr.length == 0){
return 0;
}
int N = arr.length;
int[][] fdp = new int[N][N];
int[][] gdp = new int[N][N];
for (int i = 0; i < N; i++) {
fdp[i][i] = arr[i];
}
for (int startCol = 1; startCol < N; startCol++) {
int L = 0;
int R = startCol;
while (R < N){
fdp[L][R] = Math.max(arr[L]+gdp[L+1][R],arr[R]+gdp[L][R-1]);
gdp[L][R] = Math.min(fdp[L+1][R],fdp[L][R-1]);
L++;
R++;
}
}
return Math.max(fdp[0][N-1],gdp[0][N-1]);
}