从暴力递归到动态规划(一)

一、什么是动态规划

很多人一听到动态规划这四个字就浑身发抖,整天抖成筛子,看到高手写的帖子怎么都看不懂,觉得自己和他们好像来自不同的星球,自己费劲心机把一个动态规划搞懂了,结果下个动态规划依旧搞不出来。那么,动态规划是啥呢?简单总结起来就是在调用的过程中如果发现重复的过程,动态规划在算过一次之后把答案记下来,下回再遇到重复过程,直接调,这个行为就叫做动态规划。

举个栗子:

斐波那契数列大家都知道,代码也很简单:

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]);
    }

 

  • 13
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值