【算法&数据结构体系篇class18】:暴力递归到记忆化搜索到动态规划

文章探讨了暴力递归何时可以优化及如何转化为动态规划,列举了设计暴力递归的原则和四种尝试模型。同时,阐述了如何分析问题以找到动态规划的解决方案,包括记忆化搜索和动态规划表的构建。最后,提供了两个实例——机器人移动和抽纸牌游戏,展示了暴力递归优化为动态规划的过程。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

一、什么暴力递归可以继续优化?

有重复调用同一个子问题的解,这种递归可以优化

如果每一个子问题都是不同的解,无法优化也不用优化

二、暴力递归和动态规划的关系

某一个暴力递归,有解的重复调用,就可以把这个暴力递归优化成动态规划

任何动态规划问题,都一定对应着某一个有重复过程的暴力递归

但不是所有的暴力递归,都一定对应着动态规划

三、如何找到某个问题的动态规划方式?

1)设计暴力递归:重要原则+4种常见尝试模型!重点!

2)分析有没有重复解:套路解决

3)用记忆化搜索 -> 用严格表结构实现动态规划:套路解决

4)看看能否继续优化:套路解决

四、设计暴力递归过程的原则

1)每一个可变参数的类型,一定不要比int类型更加复杂

2)原则1)可以违反,让类型突破到一维线性结构,那必须是单一可变参数

3)如果发现原则1)被违反,但不违反原则2),只需要做到记忆化搜索即可

4)可变参数的个数,能少则少

五、常见的4种尝试模型

1)从左往右的尝试模型

2)范围上的尝试模型

3)多样本位置全对应的尝试模型

4)寻找业务限制的尝试模型

六、如何分析有没有重复解

列出调用过程,可以只列出前几层

有没有重复解,一看便知

七、暴力递归到动态规划的套路

1)你已经有了一个不违反原则的暴力递归,而且的确存在解的重复调用
2)找到哪些参数的变化会影响返回值,对每一个列出变化范围
3)参数间的所有的组合数量,意味着表大小
4)记忆化搜索的方法就是傻缓存,非常容易得到
5)规定好严格表的大小,分析位置的依赖顺序,然后从基础填写到最终解
6)对于有枚举行为的决策过程,进一步优化

八、动态规划的进一步优化

1)空间压缩

2)状态化简

3)四边形不等式

4)其他优化技巧

九、题目:机器人移动

假设有排成一行的N个位置,记为1~N,N一定大于或等于2
开始时机器人在其中的M位置上(M一定是 1~N中的一个)
如果机器人来到1位置,那么下一步只能往右来到2位置;
如果机器人来到N位置,那么下一步只能往左来到N-1 位置;
如果机器人来到中间位置,那么下一步可以往左走或者往右走;
规定机器人必须走 K 步,最终能来到P位置(P也是1~N中的一个)的方法有多少种
给定四个参数 N、M、K、P,返回方法数。
package class18;

/**
 * 假设有排成一行的N个位置,记为1~N,N 一定大于或等于 2
 * 开始时机器人在其中的M位置上(M 一定是 1~N 中的一个)
 * 如果机器人来到1位置,那么下一步只能往右来到2位置;
 * 如果机器人来到N位置,那么下一步只能往左来到 N-1 位置;
 * 如果机器人来到中间位置,那么下一步可以往左走或者往右走;
 * 规定机器人必须走 K 步,最终能来到P位置(P也是1~N中的一个)的方法有多少种
 * 给定四个参数 N、M(cur)、K(rest)、P(aim),返回方法数。
 */
public class RobotWalk {
    //方法一:暴力递归
    public static int ways1(int N, int start, int aim, int K) {
        //边界判断 N至少要2个 机器人才能移动。 start开始位置和aim目标位置 范围在1-N,K步要大于0
        if (N < 2 || start < 1 || start > N || aim < 1 || aim > N || K < 1) {
            //越界输入 直接返回-1
            return -1;
        }
        //调用递归函数,返回符合的从start移动k步到aim的次数
        return process1(start,K,aim,N);
    }

    /**
     *
     * @param cur  机器人当前来到的位置cur
     * @param rest 机器人还有rest步要走
     * @param aim  最终要达到的目标位置aim
     * @param N    一共有N个位置
     */
    public static int process1(int cur, int rest, int aim, int N){
        if(rest == 0){
            //base case:当步数都走完了的时候 判断cur当前位置是否来到aim 是则返回1种,否则就是0
            return cur == aim ? 1: 0;
        }
        //如果还有剩余步数 rest >0 那么判断几种情况的移动
        //当前cur在1位置时,只有向右+1 向左会越界 来到2  步数-1
        if(cur == 1){
            return process1(2,rest-1,aim,N);
        }
        //当前cur在N位置时,只能向左 -1 向右会越界  步数-1
        if(cur == N){
            return process1(N-1, rest - 1,aim,N);
        }
        //如果不在1 N 在中间 那么既可以向左 也可以向右 那么就表示有两种情况 返回就将两者相加
        return process1(cur-1,rest-1,aim,N) + process1(cur+1,rest-1,aim,N);
    }


    //优化方法二:添加缓存表 自顶向下的动态规划  也叫 记忆化搜索
    public static int ways2(int N,int start, int aim, int K){
        //边界判断 N至少要2个 机器人才能移动。 start开始位置和aim目标位置 范围在1-N,K步要大于0
        if (N < 2 || start < 1 || start > N || aim < 1 || aim > N || K < 1) {
            //越界输入 直接返回-1
            return -1;
        }
        //方法一中 只跟cur rest相关 目标节点aim  n位置数 整个过程不影响
        //cur 当前位置范围是 1-N   rest 剩余步数 范围是 0-K
        //定义 二维数组 cur*rest 范围来保存这两个值的全部结果,作为缓存表作用 长度N+1 K+1 dp[0]行不处理 从1-N行处理即可
        int[][] dp = new int[N+1][K+1];
        for(int i = 0;i <= N;i++){
            for(int j = 0; j <= K;j++){
                //初始化赋值-1 表示还没访问到的位置
                dp[i][j] = -1;
            }
        }
        //从start开始位置移动K步 来到aim目标位置 传递dp保存访问结果 减少遍历次数 缓存表
        return process2(start,K,aim,N,dp);
    }

    public static int process2(int cur, int rest, int aim, int N, int[][]dp){
        //如果当前位置cur 剩余步数rest 得到的结果不是-1 就直接返回
        if(dp[cur][rest] != -1){
            //如果cur rest不是-1 说明已经访问过计算过结果了,直接返回 不用再计算
            return dp[cur][rest];
        }
        //定义一个结果集 用来返回
        int ans = 0;
        //情况1 剩余步数为0时
        if(rest == 0){
            //步数走完 当cur位置来到aim目标位置 说明符合题意次数 返回1  否则返回0
            ans = cur == aim ? 1 :0;
        }else if(cur == 1){
            //情况2 如果当前位置在1 还有剩余步数 那么只能向右走来到2 步数-1 赋值给ans
            ans = process2(2,rest-1,aim,N,dp);
        }else if(cur == N){
            //情况3 剩余步数 cur在N 只能往左走
            ans = process2(N-1,rest-1,aim ,N,dp);
        }else{
            //情况4 不在头尾 那么就在中间 可以往左也可以往右
            ans = process2(cur+1,rest-1,aim,N,dp) + process2(cur-1,rest-1,aim,N,dp);
        }
        //赋值完成后,最后返回前 需要进行对缓存表的赋值
        dp[cur][rest] = ans;
        return ans;
    }

    //优化方法三:最终版动态规划 通过前面的递归加缓存表可以得到结果都是存放到一个二维数组
    //而目标就是根据前面的各种情况,填充这张表dp[N+1][K+1],而结果返回的 根据题意要求cur当前位置 走了K步 就是dp[cur][rest]
    public static int ways3(int N,int start, int aim, int K){
        //边界判断 N至少要2个 机器人才能移动。 start开始位置和aim目标位置 范围在1-N,K步要大于0
        if (N < 2 || start < 1 || start > N || aim < 1 || aim > N || K < 1) {
            //越界输入 直接返回-1
            return -1;
        }
        //方法一中 只跟cur rest相关 目标节点aim  n位置数 整个过程不影响
        //cur 当前位置范围是 1-N   rest 剩余步数 范围是 0-K
        //定义 二维数组 cur*rest 范围来保存这两个值的全部结果,作为缓存表作用 长度N+1 K+1 dp[0]行不处理 从1-N行处理即可
        int[][] dp = new int[N+1][K+1];   //默认值都是0
        //情况1 确定base case 当rest为0 时  只有cur==aim 值则为1 否则为0
        dp[aim][0] = 1;      //cur移动位置是行数,cur == aim 就在第aim行 第0列表示 rest没有剩余步数 此时值为1 该列其他位置都是0
        //当前rest剩余步数不为0时 则有多种情况
        for(int rest = 1; rest <= K; rest++) {
            //情况2 当cur位置来到1 时 机器人只能向右移动 来到2 也就是向下一行移动 并且剩余步数-1 向左一列移动 也就是依赖 左下位置的值
            dp[1][rest] = dp[2][rest-1];

            //情况3 当cur位置来到N 时 机器人只能向左移动 来到N-1 也就是向上一行移动 并且剩余步数-1 向左一列移动 也就是依赖 左上位置的值
            dp[N][rest] = dp[N-1][rest-1];

            //情况4 cur 不在1  N  机器人可以向左 向右移动 去到cur-1 cur+1 也就是上下一行的位置 剩余步数-1 左移一列 也就是以来 左下和左上位置的值
            for(int cur = 2; cur < N;cur++){
                dp[cur][rest] = dp[cur-1][rest-1] + dp[cur+1][rest-1];
            }
        }
        //把二维数组填充完成 最后题意要求的位置 就是在start位置到K步的值
        return dp[start][K];
    }

    public static void main(String[] args) {
        System.out.println(ways1(5, 2, 4, 6));
        System.out.println(ways2(5, 2, 4, 6));
        System.out.println(ways3(5, 2, 4, 6));
    }
}

十、题目:抽纸牌

给定一个整型数组arr,代表数值不同的纸牌排成一条线
玩家A和玩家B依次拿走每张纸牌
规定玩家A先拿,玩家B后拿
但是每个玩家每次只能拿走最左或最右的纸牌
玩家A和玩家B都绝顶聪明
请返回最后获胜者的分数。
package class18;

/**
 * 给定一个整型数组arr,代表数值不同的纸牌排成一条线
 * 玩家A和玩家B依次拿走每张纸牌
 * 规定玩家A先拿,玩家B后拿
 * 但是每个玩家每次只能拿走最左或最右的纸牌
 * 玩家A和玩家B都绝顶聪明
 * 请返回最后获胜者的分数。
 */
public class CardsInLine {
    //方法一:暴力递归 根据题意规则来写递归逻辑
    public static int win1(int[] arr) {
        if (arr == null || arr.length == 0) {
            return 0;   //base case 数组没有纸牌了都会被拿走时 为空 就返回0 分数
        }
        //调用 玩家A先拿者函数,返回玩家A的获胜分数
        //从数组开始0-最后一个索引 开始递归
        int first = f1(arr, 0, arr.length-1);
        //调用 玩家B后拿者函数,返回玩家B的获胜分数
        int second = g1(arr, 0, arr.length-1);
        //最后返回分数较大者
        return Math.max(first, second);
    }

    //玩家A先拿者 在数组L-R中获得的最好分数返回
    public static int f1(int[] arr, int L, int R) {
        if (L == R) {
            //base case 如果当前数组只有一个,那么玩家A是先手拿,那就返回当前该位置的分数
            return arr[L];
        }
        //如果不止一个 那么根据题意 可以从最左 最右 取牌
        //情况1:从左边取牌,那么剩下的就是L+1,R的牌 给玩家B后手拿 返回该情况的分数
        int p1 = arr[L] + g1(arr, L + 1, R);
        //情况2: 从右边取牌,那么剩下的就是L,R-1的牌 给玩家B后手拿 返回该情况的分数
        int p2 = arr[R] + g1(arr, L, R - 1);
        //两种情况,根据题意 玩家都是绝顶聪明 就会取两种情况较大值 作为自己的分数
        return Math.max(p1, p2);
    }

    //玩家B后拿者 在数组L-R中获得的最好分数返回
    public static int g1(int[] arr, int L, int R) {
        if (L == R) {
            return 0; //base case 如果当前只剩一张,因为这是玩家B后拿者,剩一张肯定是给玩家A先手拿了 所以就返回0
        }
        //如果不止一个,那么就判断情况
        //情况1 先手拿了L位置的牌,那么接着后手玩家B只能从 L-1,R中拿尽量分数最高的 就回到调用f1函数 该函数就是取分数最高的判断
        //相当与此时玩家B来到了玩家A的阶段
        int p1 = f1(arr, L + 1, R);
        //情况2 先手拿了R位置的牌,那么接着后手玩家B只能从 L,R-1中拿尽量分数最高的 就回到调用f1函数 该函数就是取分数最高的判断
        int p2 = f1(arr, L, R - 1);
        //情况分析完后,最后要返回玩家B后拿的最高分数,这里要考虑 取两者情况的较小值,因为f1()先手调用函数是会取最高分数的,而给到后
        //手时是分给后手各种情况中尽量少的分 所以返回两者较小值,这是被迫返回较小 因为题意要求玩家都是绝顶聪明,那么先手肯定是将分数高的给
        //自己,而给后手预留的就是尽量小的分数
        return Math.min(p1, p2);
    }

    //方法二:优化 缓存表  通过暴力递归函数的参数确定一个数组范围来填充结果
    public static int win2(int[] arr){
        if (arr == null || arr.length == 0) {
            return 0;   //base case 数组没有纸牌了都会被拿走时 为空 就返回0 分数
        }
        //根据L R的递归参数 定义一个二维数组 N行N列 N为arr的长度,注意这里是两个递归函数
        //分别定义两个二维数组 分开处理
        int N = arr.length;
        int[][] fmap = new int[N][N];
        int[][] gmap = new int[N][N];
        //初始化-1
        for(int i =0; i < N; i++){
            for(int j = 0; j < N; j++){
                fmap[i][j] = -1;
                gmap[i][j] = -1;
            }
        }
        //调用玩家A先手拿牌返回最好分数
        int first = f2(arr,0,N-1,fmap,gmap);
        //调用玩家B后手拿牌返回最好分数
        int second = g2(arr,0,N-1,fmap,gmap);
        //返回两者较大者
        return Math.max(first,second);
    }

    //玩家A先拿者 在数组L-R中获得的最好分数返回
    public static int f2(int[] arr, int L, int R,int[][]fmap, int[][]gmap){
        if(fmap[L][R] != -1){
            return fmap[L][R];  //base case:对应先拿玩家的二维数组 如果该位置不为-1 说明已经访问计算过 直接返回
        }
        //如果为-1 那么就表示首次计算访问 定义一个结果保存分数
        int ans = 0;
        //如果只有一个牌,先拿玩家肯定就会要这张牌 把值赋给ans
        if(L == R){
            ans = arr[L];
        }else {
            //如果不止一个牌 那么就可以取左边的牌 然后接着让后手玩家取L+1,R区间的牌的最好分数
            int p1 = arr[L] + g2(arr, L+1,R,fmap,gmap);
            //也可以取右边的牌....后手玩家取L,R-1
            int p2 = arr[R] + g2(arr,L,R-1,fmap,gmap);
            //那么就会取两者情况的较大者赋值给ans
            ans = Math.max(p1,p2);
        }
        //最后返回ans,之前先把结果记录到fmap表中 后续就不需要再递归计算 直接取值返回
        fmap[L][R] = ans;
        return ans;
    }

    //玩家B后拿者 在数组L-R中获得的最好分数返回
    public static int g2(int[] arr, int L, int R, int[][]fmap, int[][]gmap){
        if(gmap[L][R] != -1){
            return gmap[L][R]; //base case:如果后者玩家表中已经访问计算过,那么就直接返回
        }
        //表为-1 说明首次访问 先定义一个结果
        int ans = 0;
        //按情况判断 如果只剩一张牌 那么这张牌只能给先手玩家取 而当前后手玩家则值为0
        if(L == R){
            ans = 0;
        }else{
            //如果不止一张,那么就是如果前手取L 后手只能从L+1,R取,而L被先手玩家取了 不能累加
            int p1 = f2(arr,L+1,R,fmap,gmap);
            //也可以前手取R  后手就是L,R-1范围取最好分数
            int p2 = f2(arr, L, R-1,fmap,gmap);
            //因为先手玩家聪明绝顶,肯定会选分数高的,然后剩下的会尽量返回较小的给后手 所以取两者情况较小值
            ans = Math.min(p1,p2);
        }
        //最后返回ans 前先给缓存表赋值
        gmap[L][R] = ans;
        return ans;
    }


    //方法三:最终动态规划  从前面找依赖 填充好二维表 最好直接返回
    public static int win3(int[] arr){
        if (arr == null || arr.length == 0) {
            return 0;   //base case 数组没有纸牌了都会被拿走时 为空 就返回0 分数
        }
        //根据L R的递归参数 定义一个二维数组 N行N列 N为arr的长度,注意这里是两个递归函数
        //分别定义两个二维数组 分开处理
        int N = arr.length;
        int[][] fmap = new int[N][N];
        int[][] gmap = new int[N][N];
        //初始化 根据前面情况L == R时,fmap玩家缓存表中值就是arr[L] 也就是二维数组的对角线值依次都是数组对应的下标值
        for(int i = 0; i< N;i++){
            fmap[i][i] = arr[i];
        }
        //而gmap玩家缓存表 L==R时 赋值的时0 数组初始值都是0 所以不用再赋值处理
        //接着判断情况 L <= R 才是正确的边界 左边不会大于右边 二维数组中 左下三角区就不需赋值 只需要关注右上三角区
        //看到如果不止一张牌时,先手玩家当前位置fmap[L][R] 它依赖的是arr[L] 或者arr[R] 以及后手玩家g函数gmap缓存表中 L+1,R  L,R-1 也就是对应gmap[L][R]的下一个位置和左一个位置
        //通过这个规律我们可以这样赋值两个表:
        // 第一列fmap[0][0]是对角线前面已经赋值过,那么再往右第二列,依次再往右下的对角线,进行赋值:
        //来到fmap[0][1] 其依赖的就是arr[L]+对应gmap[0][1]的gmap[1][1]    arr[R]+对应gmap[0][1]的gmap[0][0] 两者的较大值


        //所以根据这个规则 外层循环就是遍历 1-N列 每次都完成对应的对角线位置值,每次就是往右下位置,直到N列越界 就再从N=2开始遍历其右下位置整条对角线
        for(int startCol = 1; startCol < N; startCol++){
            //对角线startCol 0位置前面已经处理 从第一列开始
            //定义该次对角线的索引位置进行移动,每次都是从第一行开始的往右下遍历
            int L = 0;
            int R = startCol;
            while(R < N){
                //越界判断 当R列到N时就越界 而行是肯定不会先与列越界的 因为是在对角线上方,R列到最大N时,L行最多就是N-1行
                //开始给两个缓存表填充值 根据前面分析的规律
                //先手玩家 缓存表 当L!=R是分两者情况取最大值 取L左边的牌值加上后手玩家中L-1,R区间的牌值,     取R则加后手玩家L,R-1的牌
                fmap[L][R] = Math.max(arr[L]+gmap[L+1][R],arr[R]+gmap[L][R-1]);
                gmap[L][R] = Math.min(fmap[L+1][R],fmap[L][R-1]); //后手玩家同理 取前手玩家剩余的牌两者取最小

                //然后接着往右下位置去一一赋值
                L++;
                R++;
            }
        }
        //最后缓存表都填充好数据 ,最后要返回的就是先手玩家0,N-1 已经后手玩家0,N-1的较大值
        return Math.max(fmap[0][N-1],gmap[0][N-1]);
    }

    public static void main(String[] args) {
        int[] arr = { 5, 7, 4, 5, 8, 1, 6, 0, 3, 4, 6, 1, 7 };
        System.out.println(win1(arr));
        System.out.println(win2(arr));
        System.out.println(win3(arr));

    }

}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值