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

文章介绍了斜率优化的概念,通过实例演示如何将动态规划应用于两个概率问题(醉汉Bob的存活概率和怪兽被砍死的概率)以及一个货币组合问题,展示了如何通过观察相邻位置和优化枚举过程来简化动态规划算法。
摘要由CSDN通过智能技术生成

今天给大家介绍一个动态规划的技巧叫做斜率优化。

斜率优化

那么什么是斜率优化呢?

抽象的讲,如果我上面依赖的值的范围,没有超过我依赖的值的范围,也就是说我上面依赖的区域在我依赖区域的里面,这样的话当我再次去枚举一个依赖范围的时候,我上面的值可以给我某些指导。因为比较抽象,我们就可以认为是观察临近位置法,在斜率优化中,难的题可太多太多了,下面的题在其中只能算是简单题。

小试牛刀

先来个简单题试试刀。

题目一

给定5个参数,N,M,row,col,k 表示在N*M的区域上,醉汉Bob初始在(row,col)位置 Bob一共要迈出k步,且每步都会等概率向上下左右四个方向走一个单位 任何时候Bob只要离开N*M的区域,就直接死亡 返回k步之后,Bob还在N*M的区域的概率

初看上去是不是特像一个数学问题,那就有人要想了,我要不要求什么正太分布啊,肯定不是的,这道题主要就是实验,Bob一旦走出这个区域,Bob就死了,他就回不来了,首先,我们先考虑一共有多少种情况,也就是我们的分母,假设棋盘无限大,Bob一步就上下左右等概率4个方向,所以一共有4的k次方中情况,接下来我们再求出Bob走出k步之后还在区域中,我们收集Bob的一个生存点数,我们把所有的生存的情况收集起来,除以总情况,不就是Bob生存的概率。  

public static double  livePosibility(int row,int col,int k,int N,int M){
        return (double)process(row,col,k,N,M)/Math.pow(4,k);
    }

    public static long process(int row,int col,int rest,int N,int M){
        if (row < 0 || row == N || col < 0 || col == N){
            return 0;
        }
        if (rest == 0){
            return 1;
        }
        long up = process(row - 1,col,rest - 1,N,M);
        long down = process(row + 1,col,rest - 1,N,M);
        long left = process(row ,col - 1,rest - 1,N,M);
        long right = process(row ,col + 1,rest - 1,N,M);

        return up + down + left + right;
    }

初看,这是一个三维的动态规划,但我们看代码是不是觉得有些相似,对,就是之前文章中的象棋跳马问题,所以这边就不过多解释了,就直接把动态规划的代码给大家奉上。

public static double  livePosibility2(int row,int col,int k,int N,int M){
        long[][][] dp = new long[N][M][k+1];
        for (int i = 0; i < N; i++) {
            for (int j = 0; j < M; j++) {
                dp[i][j][0] = 1;
            }
        }
        for (int rest = 1; rest <= k; rest++) {
            for (int r = 0; r < N; r++) {
                for (int c = 0; c < M; c++) {
                    dp[r][c][rest] = peek(dp,N,M,r-1,c,rest - 1);
                    dp[r][c][rest] += peek(dp,N,M,r+1,c,rest - 1);
                    dp[r][c][rest] += peek(dp,N,M,r,c-1,rest - 1);
                    dp[r][c][rest] += peek(dp,N,M,r,c+1,rest - 1);

                }
            }
        }
        return (double)dp[row][col][k]/Math.pow(4,k);
    }

    public static long peek(long[][][] dp,int N,int M,int r,int c,int rest){
        if (r < 0 || r == N ||c < 0||c==N){
            return 0;
        }else {
            return dp[r][c][rest];
        }
    }

难度飙升

题目二

给定3个参数,N,M,K 怪兽有N滴血,等着英雄来砍自己 英雄每一次打击,都会让怪兽流失[0~M]的血量 到底流失多少?每一次在[0~M]上等概率的获得一个值 求K次打击之后,英雄把怪兽砍死的概率

分析:

看上去是一个数学题哈,其实不是,这就是一个编程问题,就是一个尝试问题,一共k次打击,每次打击获取0到M的一个随机值,所以一共有多少种可能性,你第一次砍,可能让他掉0滴血,可能让他掉一滴血,可能让他掉2滴血,最后可能让他掉M滴血,它是不是一个M+1层的展开啊,你第2次砍,也是一个M+1的展开,第三次砍又是一个M+1的展开,所以一共是有(M+1)的k次方种可能性。那把所有分支都列出来,你看看到最后末尾的时候,怪兽如果死了,你就返回1嘛,你收集砍死的情况数。看最后能收集到多少情况数,假设手机到的情况数叫all的话,概率不就是all/(M+1)k次方  嘛。接下来我们就开始写尝试

public static double right1(int N,int M,int K){
        if (N < 1 || M < 1 ||K < 1){
            return 0;
        }
        long all = process1(K,M,N);
        return (double)((double)all/(double)Math.pow(M+1,K)) ;
    }

    //怪兽还剩N点血
    //每次伤害范围在0~m上
    //还有k次可以砍
    //返回砍死的次数
    private static long process1(int times, int m, int hp) {

        if (times==0){
            return hp <= 0 ? 1 : 0;
        }
        if (hp <= 0){
            return (long) Math.pow(m+1,times);
        }

        long ways = 0;
        for (int i = 0; i <= m; i++) {
            ways += process1(times-1,m,hp - i);
        }
        return ways;
    }

看,尝试是不是很容易就把尝试写出来了,这也不难嘛,别急别急,难的在改动态规划上面,首先,我们还是按之前步骤,先看这是几个可变参数,两个吧,为啥,M不变呀所以,如果真的改动态规划的话,二维表,N是怪兽剩余血量变化范围从0到N,K是剩余几刀,变化范围从0到K。相信大家对从暴力递归的尝试改动态规划已经轻车熟路了。这里有一点值得注意的是,在改动态规划时,hp - i可能会越界,所以需要对其有一点小小的改造,if(hp <= 0)这部分是分析 hp - i 可能会越界后补充的baseCase,在process1中,没有这部分也对,然后我们在根据尝试改动态规划。

private static double dp1(int N,int M,int K) {

        if (N < 1 || M < 1 ||K < 1){
            return 0;
        }

        long[][] dp = new long[K+1][N+1];

        dp[0][0] = 1;

        for (int times = 1; times <=K ; times++) {
           dp[times][0] = (long) Math.pow(M+1,times);
            for (int hp = 1; hp <= N ; hp++) {
                long ways = 0;
                for (int i = 0; i <= M; i++) {
                    if (hp - i >= 0){
                        ways += dp[times-1][hp-i];
                    }else {
                        ways += (long) Math.pow(M+1,times-1);
                    }
                }
                dp[times][hp] = ways;
            }
        }

其中,第一个for循环中,dp[times][0] = (long) Math.pow(M+1,times);是对baseCase的补充,else里面是当前这一刀把怪兽砍死了,所以得减去一刀计算点数。怎么样,是不是感觉有些难了,接下来就改进行斜率优化了。我们继续往下看。我们看这么一个二维表他有一个枚举行为,有了枚举行为我们就要观察周围,有没有什么位置能把这个枚举行为给替掉,如下图:

假设M的范围是0-3,如果我要计算[5][10]这个格子,他就需要依赖伤害为0时的dp[4][10],伤害为1的dp[4][9],伤害为2的dp[4][8]和伤害为3的dp[4][7]这些格子。我们可以这么说,dp[5][10]的值依赖上一行dp[4][10....7]假设还有一个值dp[5][11],它依赖于dp[4][11...8],我们观察一下dp[5][11]是不是就等于dp[5][10]+dp[4][11]-dp[4][7]看这样我们是不是就把枚举行为给省掉了。这就是简单的斜率优化。代码如下。

private static double dp2(int N,int M,int K) {

        if (N < 1 || M < 1 ||K < 1){
            return 0;
        }

        long[][] dp = new long[K+1][N+1];

        dp[0][0] = 1;

        for (int times = 1; times <=K ; times++) {
            dp[times][0] = (long) Math.pow(M+1,times);
            for (int hp = 1; hp <= N ; hp++) {

                dp[times][hp] = dp[times][hp-1] + dp[times-1][hp];
                if (hp - 1 - M >= 0 ){
                    dp[times][hp] -= dp[times-1][hp-1-M];
                }else {
                    dp[times][hp] -= (long) Math.pow(M+1,times-1);
                }
            }
        }

        long all = dp[K][N];
        return (double)((double)all/(double)Math.pow(M+1,K)) ;
    }

题目三

arr是面值数组,其中的值都是正数且没有重复。再给定一个正数aim。 每个值都认为是一种面值,且认为张数是无限的。 返回组成aim的最少货币数

分析:

假设有一个面值数组,要搞定一个aim,要怎么搞定,我们先从0开始,用0张,用1张用2张,用3张等等,然后1位置用0张用1张用2张等等,这样慢慢尝试。我们还是从尝试开始,代码如下。

public static int minCoins(int[]arr,int aim){
        return process(arr,0,aim);
    }

    //arr[index...]面值,每种面值张数自由选择
    //搞出rest这么多钱,返回最小张数
    private static int process(int[] arr, int index, int rest) {
        if (index == arr.length){
            return rest == 0 ?  0 : Integer.MAX_VALUE;
        }else {
            int ans = Integer.MAX_VALUE;
            for (int zhang = 0; zhang * arr[index] <= rest; zhang++) {
                int next = process(arr,index+1,rest - zhang * arr[index]);
                if (next != Integer.MAX_VALUE){
                    ans = Math.min(ans,next+zhang);
                }
            }
            return ans;
        }
    }

代码是不是依旧如此简单,但是有一点需要注意的是,在取较小的时候,比较的值必须是有效的,如果不是有效的值,返回给调用者的值就会出问题。尝试有了,接下来就可以改动态规划了,相信大家应该可以直接根据递归尝试改动态规划了,这里就直接把动态规划的版本给大家。

private static int dp1(int[]arr,int aim) {

        if (aim == 0){
            return 0;
        }

        int N = arr.length;

        int[][] dp = new int[N+1][aim+1];

        dp[N][0] = 0;
        for (int i = 1; i <= aim; i++) {
            dp[N][i] = Integer.MAX_VALUE;
        }

        for (int index = N-1; index >= 0; index--) {
            for (int rest = 0; rest <= aim; rest++) {

                int ans = Integer.MAX_VALUE;
                for (int zhang = 0; zhang * arr[index] <= rest; zhang++) {
                    int next = dp[index+1][rest - zhang * arr[index]];
                    if (next != Integer.MAX_VALUE){
                        ans = Math.min(ans,next+zhang);

                    }
                }
                dp[index][rest] =  ans;
            }
        }

        return dp[0][aim];
    }

我们发现有枚举行为,我们想要把它替换掉,怎么替,画格子。

假设我需要搞定14元,我的面值是3元,a代表我使用0张之后,剩下的你给我搞定14元所返回的张数,b是我使用一张3元之后,剩下的你给我搞定11元所返回的张数,所以b得+1,往下同理,接下来我们就可以观察了,我们观察画×的位置是怎么算的,它是不是同样这样加过来的,但是画×的部分是b+0,c+1等等,通过观察可以发现,我们用x+1和a 进行pk嘛,谁小选谁

private static int dp2(int[]arr,int aim) {

        if (aim == 0){
            return 0;
        }

        int N = arr.length;

        int[][] dp = new int[N+1][aim+1];

        dp[N][0] = 0;
        for (int i = 1; i <= aim; i++) {
            dp[N][i] = Integer.MAX_VALUE;
        }

        for (int index = N-1; index >= 0; index--) {
            for (int rest = 0; rest <= aim; rest++) {
                dp[index][rest] = dp[index+1][rest];
                if (rest - arr[index] >=0 && dp[index][rest-arr[index]] != Integer.MAX_VALUE){
                    dp[index][rest] = Math.min(dp[index][rest],dp[index][rest-arr[index]]+1);
                }
            }
        }
        return dp[0][aim];
    }

题目四

给定一个正数n,求n的裂开方法数, 规定:后面的数不能比前面的数小 比如4的裂开方法有: 1+1+1+1、1+1+2、1+3、2+2、4 5种,所以返回5

分析:

我们还是先从尝试开始,这个递归函数的含义稍微难想一些,我们定义一个递归函数,第一个参数是我上一个拆出来的数,第二个参数是还剩多少数要拆,我们一次尝试,假如说我要拆一个5,我第一次要拆3,那完了,因为后面的数不能比前面的小,我们从1开始依次尝试。

public static int ways1(int num){
        if (num <= 0){
            return 0;
        }
        if (num == 1){
            return 1;
        }
        return process(1,num);
    }
    //上一个拆出来数是pre
    //还剩rest个数需要拆
    //返回拆出来的方法数
    public static int process(int pre,int rest){
        if (rest == 0){
            return 1;
        }
        if (pre > rest){
            return 0;
        }

        if (pre == rest){
            return 1;
        }

        int ways = 0;
        for (int i = pre; i <= rest ; i++) {
            ways += process(i,rest-i);
        }
        return ways;
    }

看代码,很简单是不是,前面一写基础的baseCase,然后我从当前位置开始拆,递归调用,然后我们根据递归改出动态规划版本。

public static int dp1(int num){
        if (num < 0){
            return 0;
        }
        if (num == 1){
            return 1;
        }

        int[][] dp = new int[num + 1][num + 1];

        for (int pre = 1; pre <= num ; pre++) {
            dp[pre][0] = 1;
            dp[pre][pre] = 1;
        }

        for (int pre = num - 1; pre >= 1 ; pre--) {
            for (int rest = pre+1; rest <= num ; rest++) {
                int ways = 0;
                for (int i = pre; i <= rest ; i++) {
                    ways+=dp[i][rest-i];
                }
                dp[pre][rest] =  ways;
            }
        }


        return dp[1][num];
    }

我们发现这里面有一个枚举的调用,然后我们可以进行优化,我们随便找一个点,观察它的依赖关系,然后在找该点周围的点,观察他们的关系,相信大家一定能很容易就看出来,这里就直接把优化后的代码给大家。

public static int dp2(int num){
        if (num < 0){
            return 0;
        }
        if (num == 1){
            return 1;
        }

        int[][] dp = new int[num + 1][num + 1];

        for (int pre = 1; pre <= num ; pre++) {
            dp[pre][0] = 1;
            dp[pre][pre] = 1;
        }

        for (int pre = num - 1; pre >= 1 ; pre--) {
            for (int rest = pre+1; rest <= num ; rest++) {
                dp[pre][rest] = dp[pre+1][rest];
                dp[pre][rest] += dp[pre][rest - pre];
            }
        }


        return dp[1][num];
    }

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值