【算法笔记】从暴力递归到动态规划(一)

目录

【算法笔记】从暴力递归到动态规划(一)
【算法笔记】从暴力递归到动态规划(二)
【算法笔记】从暴力递归到动态规划(三)


1、从暴力递归到动态规划

1.1、 怎样尝试一件事?

  • 1)有经验但是没有方法论?
  • 2)怎么判断一个尝试就是最优尝试?
  • 3)难道尝试这件事真的只能拼天赋?那我咋搞定我的面试?
  • 4)动态规划是啥?好高端的样子,可是我不会啊,和尝试有什么关系?
  • ----> 暴力递归到动态规划的套路:解决面试中的动态规划问题。

1.2、 暴力递归可以优化为动态规划的条件

  • 1)有重复调用同一个子问题的解,这种递归可以优化
  • 2)如果每一个子问题都是不同的解,无法优化也不用优化。

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

  • 1)某一个暴力递归,有解的重复调用,就可以把这个暴力递归优化成动态规划
  • 2)任何动态规划问题,都一定对应着某一个有重复过程的暴力递归
  • 3)但不是所有的暴力递归,都一定对应着动态规划

1.4、 面试题和动态规划的关系

  • 1)解决一个问题,可以有很多的尝试方法。
  • 2)可能在很多的尝试方法中,又有若干个尝试方法有动态规划的方法
  • 3)一个问题,可能有若干个动态规划的解法

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

  • 1)设计暴力递归:重要原则 + 4种常见尝试模型 :重点!
  • 2)分析有没有重复解:套路解决
  • 3)用记忆化搜索 -> 用严格表结构实现动态规划:套路解决
  • 4)看看能否继续优化:套路解决

1.6、 面试中设计暴力递归过程的原则

  • 1)每一个可变参数的类型,一定不要比int类型更复杂
  • 2)原则 1)可以违反,让类型突破到一维线性结构,那必须是单一可变参数
  • 3)如果发现违反了原则 1),但不违反原则 2),只需要做到记忆化搜索即可
  • 4)可变参数的个数,能少则少

1.7、如果知道了面试中设计暴力递归过程的原则,然后呢?

  • 1)一定要逼自己找到不违反原则情况下的暴力尝试
  • 2)如果你找到了暴力尝试,不符合原则,马上放弃!找新的!
  • 3)如果某个题目突破了设计原则,一定极难极难,面试中出现的概率低于 5%

1.8、 常见的4种尝试模型

  • 1)从左到右的尝试模型
  • 2)范围上的尝试模型
  • 3)多样本位置全对应的尝试模型(样本对应模型)
  • 4)寻找业务限制的尝试模型

1.9、 如何分析有没有重复解?

  • 1)列出调用过程,可以直列出前几层(举出具体的例子)
  • 2)有没有重复解,一看便知

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

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

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

  • 1)空间压缩
  • 2)状态简化(优化单位时间复杂度)
  • 3)四边形不等式优化
  • 4)其他优化技巧

2、暴力递归到动态规划的题目(一)

2.1、题目一:机器人走步

  • 题目一:机器人走步
  • 假设有排成一行的N个位置,记为1~N,N 一定大于或等于 2
  • 开始时机器人在其中的M位置上(M 一定是 1~N 中的一个)
  • 如果机器人来到1位置,那么下一步只能往右来到2位置;
  • 如果机器人来到N位置,那么下一步只能往左来到 N-1 位置;
  • 如果机器人来到中间位置,那么下一步可以往左走或者往右走;
  • 规定机器人必须走 K 步,最终能来到P位置(P也是1~N中的一个)的方法有多少种
  • 给定四个参数 N、M、P、K,返回方法数。
2.1.1、暴力递归的方法
  • 暴力递归的方法
  • 思路:
  • 题目的要求是从M出发,走K步,最终到达P的方法数,到达某一个位置后,因为可以来回的走,正向思维的方法就很难穷举。
  • 我们可以反过来,按照规则,将从M出发,走了K步的所有到达的点都罗列出来,最后挑出那些到达了P的方法,这样就不会有遗漏了。
  • 暴力递归的很多题目,都是这种逆向思维的方法,将所有可能的情况都罗列出来,最后挑出符合条件的方法。
java 复制代码
    /**
     * 暴力递归的方法
     * 思路:
     * 题目的要求是从M出发,走K步,最终到达P的方法数,到达某一个位置后,因为可以来回的走,正向思维的方法就很难穷举。
     * 我们可以反过来,按照规则,将从M出发,走了K步的所有到达的点都罗列出来,最后挑出那些到达了P的方法,这样就不会有遗漏了。
     * 暴力递归的很多题目,都是这种逆向思维的方法,将所有可能的情况都罗列出来,最后挑出符合条件的方法。
     */
    public static int robotWalkWays1(int N, int M, int P, int K) {
        // 边界条件检查
        if (N < 2 || M < 1 || M > N || P < 1 || P > N || K < 1) {
            return -1;
        }
        // 递归调用,求出从M点出发,剩余K步,最终到达P的方法数
        return process1(M, K, P, N);
    }

    /**
     * 暴力递归的递归函数
     * 思路:
     * 递归函数的目的是要在所有的方法中挑出到达目标P的方法数,首先要求的就是能遍历到所有的方法,
     * 从某个点cur出发,每次递归都按照规则走一步,然后继续递归,这样就会有两个参数,一个是当前的位置cur,一个是剩余的步数rest。
     * 直到rest为0时,判断cur是否等于P,如果等于,说明刚好走到了目标P,返回1,否则返回0。
     *
     * @param cur  :机器人当前来到的位置是cur,
     * @param rest :机器人还有rest步需要去走,
     * @param P    :最终的目标是P,
     * @param N    :有哪些位置?1~N
     * @return :机器人从cur出发,走过rest步之后,最终停在P的方法数,是多少?
     */
    private static int process1(int cur, int rest, int P, int N) {
        // base case
        if (rest == 0) {
            return cur == P ? 1 : 0;
        }
        // 按照规则继续走
        // 如果当前位置是1,只能往右走
        if (cur == 1) {
            return process1(2, rest - 1, P, N);
        }
        // 如果当前位置是N,只能往左走
        if (cur == N) {
            return process1(N - 1, rest - 1, P, N);
        }
        // 如果当前位置是中间位置,既能往左走,也能往右走,总方法就是两种走法的和
        return process1(cur - 1, rest - 1, P, N) + process1(cur + 1, rest - 1, P, N);
    }
2.1.2、从暴力递归改到利用缓存优化的递归
  • 从暴力递归改到利用缓存优化的递归

  • 能够改为利用缓存优化的递归的条件是在递归的过程中,出现了重复求解的情况,重复解的出现是因为递归的过程中,有很多子问题是重复计算的,所以可以用缓存优化。

  • 本题目中,递归函数中的cur和rest两个变量不断的变化,在不同的分支中会出现cur和rest相同的情况,也就是出现了重复求解,所以可以利用缓存来优化。

  • 思路:

    • 利用缓存优化递归,就是在递归的过程中,将求解的结果缓存起来,后面需要的时候,先检查缓存表,如果有,就直接使用,不用继续求解了。
    • 在上面process1中,递归函数依赖两个变量:cur和rest,cur的范围是从1到N,rest的范围是从0到K。
    • 这样我们就可以用一个二维数组dp[N+1][k+1]来缓存已经计算过的结果,避免重复计算。
    • 因为对于每一组的值,要判断是否已经处理过,我们从process函数中可以知道,处理过以后,会出现1和0两种情况,也就是累加上后有效值是[0...n],n是方法数
    • 所以我们就可以先将dp数组初始化为-1,-1表示还没有计算过,0表示计算过,但是方法数是0,1以上的数表示计算过,方法数是多少。
  • 总结:

    • 一开始不熟悉的时候,从暴力递归先改到利用缓存优化的递归,然后进一步改到动态规划,是一种比较稳妥的方法,
    • 等到熟悉这个过程了以后,就可以直接从暴力递归改到动态规划的方案,省略掉利用缓存优化的递归这一步。
java 复制代码
    /**
     * 从暴力递归改到利用缓存优化的递归
     * 能够改为利用缓存优化的递归的条件是在递归的过程中,出现了重复求解的情况,重复解的出现是因为递归的过程中,有很多子问题是重复计算的,所以可以用缓存优化。
     * 本题目中,递归函数中的cur和rest两个变量不断的变化,在不同的分支中会出现cur和rest相同的情况,也就是出现了重复求解,所以可以利用缓存来优化。
     * <br>
     * 思路:
     * 利用缓存优化递归,就是在递归的过程中,将求解的结果缓存起来,后面需要的时候,先检查缓存表,如果有,就直接使用,不用继续求解了。
     * 在上面process1中,递归函数依赖两个变量:cur和rest,cur的范围是从1到N,rest的范围是从0到K。
     * 这样我们就可以用一个二维数组dp[N+1][k+1]来缓存已经计算过的结果,避免重复计算。
     * 因为对于每一组的值,要判断是否已经处理过,我们从process函数中可以知道,处理过以后,会出现1和0两种情况,也就是累加上后有效值是[0..n],n是方法数
     * 所以我们就可以先将dp数组初始化为-1,-1表示还没有计算过,0表示计算过,但是方法数是0,1以上的数表示计算过,方法数是多少。
     * <br>
     * 总结:
     * 一开始不熟悉的时候,从暴力递归先改到利用缓存优化的递归,然后进一步改到动态规划,是一种比较稳妥的方法,
     * 等到熟悉这个过程了以后,就可以直接从暴力递归改到动态规划的方案,省略掉利用缓存优化的递归这一步。
     */
    public static int robotWalkWays2(int N, int M, int P, int K) {
        // 边界条件检查
        if (N < 2 || M < 1 || M > N || P < 1 || P > N || K < 1) {
            return -1;
        }
        // 定义一个缓存数组
        int[][] dp = new int[N + 1][K + 1];
        // 初始化缓存数组为-1
        for (int i = 0; i <= N; i++) {
            for (int j = 0; j <= K; j++) {
                dp[i][j] = -1;
            }
        }
        //调用利用缓存优化的递归函数
        return process2(M, K, P, N, dp);
    }

    /**
     * 利用缓存优化的递归函数
     */
    private static int process2(int cur, int rest, int P, int N, int[][] dp) {
        // 先检查缓存有没有计算过,如果有,直接返回缓存中的值
        if (dp[cur][rest] != -1) {
            return dp[cur][rest];
        }
        // 继续递归
        if (rest == 0) {
            dp[cur][rest] = cur == P ? 1 : 0;
        } else if (cur == 1) {
            dp[cur][rest] = process2(2, rest - 1, P, N, dp);
        } else if (cur == N) {
            dp[cur][rest] = process2(N - 1, rest - 1, P, N, dp);
        } else {
            dp[cur][rest] = process2(cur - 1, rest - 1, P, N, dp) + process2(cur + 1, rest - 1, P, N, dp);
        }
        return dp[cur][rest];
    }
2.1.3、从利用缓存优化的递归改到动态规划
  • 从利用缓存优化的递归改到动态规划

  • 从利用缓存优化的递归改到动态规划,就是把递归的过程,改成用循环和依赖条件填值的过程,

  • 在这个过程中,最主要的是梳理清楚依赖关系,从而根据依赖关系填写依赖表。

  • 思路:

    • 从process2方法可以看出,递归函数的结果依赖cur和rest两个参数,cur的范围是从1到N,rest的范围是从0到K。
    • 这样我们就可以用两个for循环,用来循环cur和rest,从0到N和0到K,就可以把所有的情况都计算出来。
    • 这样就不用递归了,直接从dp数组中取结果即可,这样最重要的问题就是如何从递归调用改为填写dp表(状态转移方程)。
  • 如何从递归调用改成填写dp表(状态转移方程):梳理填表的方向

    • dp[cur][rest]表的行cur是当前的位置,从1到N,方向不固定,列rest是剩余的步数,从K到0。
    • 先要看递归的截至条件,也就是base case,从上面的递归函数可以看出,base case就是rest == 0,如果cur == aim,则为1,代表dp[P][0] = 1,
    • 再来分析其他条件:
    • 1)如果cur == 1,只能往右走,ans = process2(2, rest - 1, aim, N, dp),cur为2表示在cur=1的下一行的数字,rest -1在表中就是前一列,
    • 合起来这个意思就是:在dp表中,cur为1的数字,依赖的是下一行的前一列的数字,也就是左下角的数字。即dp[1][rest] = dp[2][rest - 1]
    • 2)如果cur == N,只能往左走,process2(N - 1, rest - 1, aim, N, dp),cur为N-1表示在cur=N的上一行的数字,rest -1在表中就是前一列,
    • 合起来的意思就是:在dp表中,cur为N的数字,依赖的是其上一行的前一列的数字,也就是左上角的数字,即所以dp[N][rest] = dp[N - 1][rest - 1]
    • 3)其他位置,往左和往右的和,ans = process2(cur - 1, rest - 1, aim, N, dp) + process2(cur + 1, rest - 1, aim, N, dp),
    • cur-1和rest-1表示上一行的前一列数字,也就是左上角的数字,cur+1和rest -1表示的是下一行的前一个数字,也就是左下角的数字,
    • 合起来的意思就是:对于其他位置的数字,是左上角和左下角的数字的和。即dp[cur][rest] = dp[cur - 1][rest - 1] + dp[cur + 1][rest - 1],
    • 可以按照上面的规则来填充dp表,因为dp表的行代表当前位置,列代表剩余的步数,
    • 所以dp[M][K]就代表了当前在M点,剩余K步的走法数。即dp[M][K]就是最终的结果。
  • 总结:

    • 上面整体总结了如何从一个递归函数求出dp表,即状态转移方程的过程,在动态规划的题目中,一开始就想出状态转移方程是很困难的,我们就可以先写出暴力递归的解法,
    • 然后不断的去优化,改成填充dp表的方法,在这个过程中,慢慢就总结出了状态转移方程。这个过程需要大量的练习才能特别熟悉。
java 复制代码
    /**
     * 从利用缓存优化的递归改到动态规划
     * 从利用缓存优化的递归改到动态规划,就是把递归的过程,改成用循环和依赖条件填值的过程,
     * 在这个过程中,最主要的是梳理清楚依赖关系,从而根据依赖关系填写依赖表。
     * <br>
     * 思路:
     * 从process2方法可以看出,递归函数的结果依赖cur和rest两个参数,cur的范围是从1到N,rest的范围是从0到K。
     * 这样我们就可以用两个for循环,用来循环cur和rest,从0到N和0到K,就可以把所有的情况都计算出来。
     * 这样就不用递归了,直接从dp数组中取结果即可,这样最重要的问题就是如何从递归调用改为填写dp表(状态转移方程)。
     * <br>
     * 如何从递归调用改成填写dp表(状态转移方程):梳理填表的方向
     * dp[cur][rest]表的行cur是当前的位置,从1到N,方向不固定,列rest是剩余的步数,从K到0。
     * 先要看递归的截至条件,也就是base case,从上面的递归函数可以看出,base case就是rest == 0,如果cur == aim,则为1,代表dp[P][0] = 1,
     * 再来分析其他条件:
     * 1)如果cur == 1,只能往右走,ans = process2(2, rest - 1, aim, N, dp),cur为2表示在cur=1的下一行的数字,rest -1在表中就是前一列,
     * 合起来这个意思就是:在dp表中,cur为1的数字,依赖的是下一行的前一列的数字,也就是左下角的数字。即dp[1][rest] = dp[2][rest - 1]
     * 2)如果cur == N,只能往左走,process2(N - 1, rest - 1, aim, N, dp),cur为N-1表示在cur=N的上一行的数字,rest -1在表中就是前一列,
     * 合起来的意思就是:在dp表中,cur为N的数字,依赖的是其上一行的前一列的数字,也就是左上角的数字,即所以dp[N][rest] = dp[N - 1][rest - 1]
     * 3)其他位置,往左和往右的和,ans = process2(cur - 1, rest - 1, aim, N, dp) + process2(cur + 1, rest - 1, aim, N, dp),
     * cur-1和rest-1表示上一行的前一列数字,也就是左上角的数字,cur+1和rest -1表示的是下一行的前一个数字,也就是左下角的数字,
     * 合起来的意思就是:对于其他位置的数字,是左上角和左下角的数字的和。即dp[cur][rest] = dp[cur - 1][rest - 1] + dp[cur + 1][rest - 1],
     * 可以按照上面的规则来填充dp表,因为dp表的行代表当前位置,列代表剩余的步数,
     * 所以dp[M][K]就代表了当前在M点,剩余K步的走法数。即dp[M][K]就是最终的结果。
     * <br>
     * 总结:
     * 上面整体总结了如何从一个递归函数求出dp表,即状态转移方程的过程,在动态规划的题目中,一开始就想出状态转移方程是很困难的,我们就可以先写出暴力递归的解法,
     * 然后不断的去优化,改成填充dp表的方法,在这个过程中,慢慢就总结出了状态转移方程。这个过程需要大量的练习才能特别熟悉。
     */
    public static int robotWalkWays3(int N, int M, int P, int K) {
        if (N < 2 || M < 1 || M > N || P < 1 || P > N || K < 1) {
            return -1;
        }
        // 定义一个缓存表
        int[][] dp = new int[N + 1][K + 1];
        // 根据base case初始化表
        dp[P][0] = 1;
        // 填充dp表
        // 因为rest为1已经从base case计算过了,所以rest从1开始
        for (int rest = 1; rest <= K; rest++) {
            // cur为1的数字,依赖的是下一行的前一列的数字,也就是左下角的数字。即dp[1][rest] = dp[2][rest - 1]
            dp[1][rest] = dp[2][rest - 1];
            // cur为N的数字,依赖的是其上一行的前一列的数字,也就是左上角的数字,即所以dp[N][rest] = dp[N - 1][rest - 1]
            dp[N][rest] = dp[N - 1][rest - 1];
            //对于其他位置的数字,是左上角和左下角的数字的和。即dp[cur][rest] = dp[cur - 1][rest - 1] + dp[cur + 1][rest - 1]
            // 因为cur为1和N的已经计算过了,所以cur从2开始,到N - 1
            for (int cur = 2; cur < N; cur++) {
                dp[cur][rest] = dp[cur - 1][rest - 1] + dp[cur + 1][rest - 1];
            }
        }
        // 返回dp[M][K]
        return dp[M][K];
    }

整体代码和测试如下:

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

    /**
     * 暴力递归的方法
     * 思路:
     * 题目的要求是从M出发,走K步,最终到达P的方法数,到达某一个位置后,因为可以来回的走,正向思维的方法就很难穷举。
     * 我们可以反过来,按照规则,将从M出发,走了K步的所有到达的点都罗列出来,最后挑出那些到达了P的方法,这样就不会有遗漏了。
     * 暴力递归的很多题目,都是这种逆向思维的方法,将所有可能的情况都罗列出来,最后挑出符合条件的方法。
     */
    public static int robotWalkWays1(int N, int M, int P, int K) {
        // 边界条件检查
        if (N < 2 || M < 1 || M > N || P < 1 || P > N || K < 1) {
            return -1;
        }
        // 递归调用,求出从M点出发,剩余K步,最终到达P的方法数
        return process1(M, K, P, N);
    }

    /**
     * 暴力递归的递归函数
     * 思路:
     * 递归函数的目的是要在所有的方法中挑出到达目标P的方法数,首先要求的就是能遍历到所有的方法,
     * 从某个点cur出发,每次递归都按照规则走一步,然后继续递归,这样就会有两个参数,一个是当前的位置cur,一个是剩余的步数rest。
     * 直到rest为0时,判断cur是否等于P,如果等于,说明刚好走到了目标P,返回1,否则返回0。
     *
     * @param cur  :机器人当前来到的位置是cur,
     * @param rest :机器人还有rest步需要去走,
     * @param P    :最终的目标是P,
     * @param N    :有哪些位置?1~N
     * @return :机器人从cur出发,走过rest步之后,最终停在P的方法数,是多少?
     */
    private static int process1(int cur, int rest, int P, int N) {
        // base case
        if (rest == 0) {
            return cur == P ? 1 : 0;
        }
        // 按照规则继续走
        // 如果当前位置是1,只能往右走
        if (cur == 1) {
            return process1(2, rest - 1, P, N);
        }
        // 如果当前位置是N,只能往左走
        if (cur == N) {
            return process1(N - 1, rest - 1, P, N);
        }
        // 如果当前位置是中间位置,既能往左走,也能往右走,总方法就是两种走法的和
        return process1(cur - 1, rest - 1, P, N) + process1(cur + 1, rest - 1, P, N);
    }

    /**
     * 从暴力递归改到利用缓存优化的递归
     * 能够改为利用缓存优化的递归的条件是在递归的过程中,出现了重复求解的情况,重复解的出现是因为递归的过程中,有很多子问题是重复计算的,所以可以用缓存优化。
     * 本题目中,递归函数中的cur和rest两个变量不断的变化,在不同的分支中会出现cur和rest相同的情况,也就是出现了重复求解,所以可以利用缓存来优化。
     * <br>
     * 思路:
     * 利用缓存优化递归,就是在递归的过程中,将求解的结果缓存起来,后面需要的时候,先检查缓存表,如果有,就直接使用,不用继续求解了。
     * 在上面process1中,递归函数依赖两个变量:cur和rest,cur的范围是从1到N,rest的范围是从0到K。
     * 这样我们就可以用一个二维数组dp[N+1][k+1]来缓存已经计算过的结果,避免重复计算。
     * 因为对于每一组的值,要判断是否已经处理过,我们从process函数中可以知道,处理过以后,会出现1和0两种情况,也就是累加上后有效值是[0..n],n是方法数
     * 所以我们就可以先将dp数组初始化为-1,-1表示还没有计算过,0表示计算过,但是方法数是0,1以上的数表示计算过,方法数是多少。
     * <br>
     * 总结:
     * 一开始不熟悉的时候,从暴力递归先改到利用缓存优化的递归,然后进一步改到动态规划,是一种比较稳妥的方法,
     * 等到熟悉这个过程了以后,就可以直接从暴力递归改到动态规划的方案,省略掉利用缓存优化的递归这一步。
     */
    public static int robotWalkWays2(int N, int M, int P, int K) {
        // 边界条件检查
        if (N < 2 || M < 1 || M > N || P < 1 || P > N || K < 1) {
            return -1;
        }
        // 定义一个缓存数组
        int[][] dp = new int[N + 1][K + 1];
        // 初始化缓存数组为-1
        for (int i = 0; i <= N; i++) {
            for (int j = 0; j <= K; j++) {
                dp[i][j] = -1;
            }
        }
        //调用利用缓存优化的递归函数
        return process2(M, K, P, N, dp);
    }

    /**
     * 利用缓存优化的递归函数
     */
    private static int process2(int cur, int rest, int P, int N, int[][] dp) {
        // 先检查缓存有没有计算过,如果有,直接返回缓存中的值
        if (dp[cur][rest] != -1) {
            return dp[cur][rest];
        }
        // 继续递归
        if (rest == 0) {
            dp[cur][rest] = cur == P ? 1 : 0;
        } else if (cur == 1) {
            dp[cur][rest] = process2(2, rest - 1, P, N, dp);
        } else if (cur == N) {
            dp[cur][rest] = process2(N - 1, rest - 1, P, N, dp);
        } else {
            dp[cur][rest] = process2(cur - 1, rest - 1, P, N, dp) + process2(cur + 1, rest - 1, P, N, dp);
        }
        return dp[cur][rest];
    }

    /**
     * 从利用缓存优化的递归改到动态规划
     * 从利用缓存优化的递归改到动态规划,就是把递归的过程,改成用循环和依赖条件填值的过程,
     * 在这个过程中,最主要的是梳理清楚依赖关系,从而根据依赖关系填写依赖表。
     * <br>
     * 思路:
     * 从process2方法可以看出,递归函数的结果依赖cur和rest两个参数,cur的范围是从1到N,rest的范围是从0到K。
     * 这样我们就可以用两个for循环,用来循环cur和rest,从0到N和0到K,就可以把所有的情况都计算出来。
     * 这样就不用递归了,直接从dp数组中取结果即可,这样最重要的问题就是如何从递归调用改为填写dp表(状态转移方程)。
     * <br>
     * 如何从递归调用改成填写dp表(状态转移方程):梳理填表的方向
     * dp[cur][rest]表的行cur是当前的位置,从1到N,方向不固定,列rest是剩余的步数,从K到0。
     * 先要看递归的截至条件,也就是base case,从上面的递归函数可以看出,base case就是rest == 0,如果cur == aim,则为1,代表dp[P][0] = 1,
     * 再来分析其他条件:
     * 1)如果cur == 1,只能往右走,ans = process2(2, rest - 1, aim, N, dp),cur为2表示在cur=1的下一行的数字,rest -1在表中就是前一列,
     * 合起来这个意思就是:在dp表中,cur为1的数字,依赖的是下一行的前一列的数字,也就是左下角的数字。即dp[1][rest] = dp[2][rest - 1]
     * 2)如果cur == N,只能往左走,process2(N - 1, rest - 1, aim, N, dp),cur为N-1表示在cur=N的上一行的数字,rest -1在表中就是前一列,
     * 合起来的意思就是:在dp表中,cur为N的数字,依赖的是其上一行的前一列的数字,也就是左上角的数字,即所以dp[N][rest] = dp[N - 1][rest - 1]
     * 3)其他位置,往左和往右的和,ans = process2(cur - 1, rest - 1, aim, N, dp) + process2(cur + 1, rest - 1, aim, N, dp),
     * cur-1和rest-1表示上一行的前一列数字,也就是左上角的数字,cur+1和rest -1表示的是下一行的前一个数字,也就是左下角的数字,
     * 合起来的意思就是:对于其他位置的数字,是左上角和左下角的数字的和。即dp[cur][rest] = dp[cur - 1][rest - 1] + dp[cur + 1][rest - 1],
     * 可以按照上面的规则来填充dp表,因为dp表的行代表当前位置,列代表剩余的步数,
     * 所以dp[M][K]就代表了当前在M点,剩余K步的走法数。即dp[M][K]就是最终的结果。
     * <br>
     * 总结:
     * 上面整体总结了如何从一个递归函数求出dp表,即状态转移方程的过程,在动态规划的题目中,一开始就想出状态转移方程是很困难的,我们就可以先写出暴力递归的解法,
     * 然后不断的去优化,改成填充dp表的方法,在这个过程中,慢慢就总结出了状态转移方程。这个过程需要大量的练习才能特别熟悉。
     */
    public static int robotWalkWays3(int N, int M, int P, int K) {
        if (N < 2 || M < 1 || M > N || P < 1 || P > N || K < 1) {
            return -1;
        }
        // 定义一个缓存表
        int[][] dp = new int[N + 1][K + 1];
        // 根据base case初始化表
        dp[P][0] = 1;
        // 填充dp表
        // 因为rest为1已经从base case计算过了,所以rest从1开始
        for (int rest = 1; rest <= K; rest++) {
            // cur为1的数字,依赖的是下一行的前一列的数字,也就是左下角的数字。即dp[1][rest] = dp[2][rest - 1]
            dp[1][rest] = dp[2][rest - 1];
            // cur为N的数字,依赖的是其上一行的前一列的数字,也就是左上角的数字,即所以dp[N][rest] = dp[N - 1][rest - 1]
            dp[N][rest] = dp[N - 1][rest - 1];
            //对于其他位置的数字,是左上角和左下角的数字的和。即dp[cur][rest] = dp[cur - 1][rest - 1] + dp[cur + 1][rest - 1]
            // 因为cur为1和N的已经计算过了,所以cur从2开始,到N - 1
            for (int cur = 2; cur < N; cur++) {
                dp[cur][rest] = dp[cur - 1][rest - 1] + dp[cur + 1][rest - 1];
            }
        }
        // 返回dp[M][K]
        return dp[M][K];
    }

    public static void main(String[] args) {
        System.out.println(robotWalkWays1(5, 2, 4, 6));
        System.out.println(robotWalkWays2(5, 2, 4, 6));
        System.out.println(robotWalkWays3(5, 2, 4, 6));
    }
}

2.2、题目二:获取纸牌的最大分数

  • 题目二:获取纸牌的最大分数
  • 给定一个整型数组arr,代表数值不同的纸牌排成一条线
  • 玩家A和玩家B依次拿走每张纸牌
  • 规定玩家A先拿,玩家B后拿
  • 但是每个玩家每次只能拿走最左或最右的纸牌
  • 玩家A和玩家B都绝顶聪明
  • 请返回最后获胜者的分数。
2.2.1、暴力递归的方法
  • 暴力递归的方法:

  • 思路:

    • 将所有纸牌arr[L...R]都拿完,返回最大的分数,L和R表示纸牌的左右边界

    • 有A和B两个人,A先拿,B后拿,要获取最大的分数,整体就有两种情况:A作为先手的分数和B作为后手的分数,取两个中的较大值,这样就要有先手和后手两个递归函数。

    • 先手和后手的意思是:先手当前就可以拿,后手是当前对方作为先手先拿,然后轮到自己拿

    • 先手递归函数:先手递归是指先手的人在剩下的排中把最大的给自己,小的给后手

    • 先手在剩下的arr[L...R]中拿纸牌,如果L==R,则只剩下一张牌,那先手直接拿就可以了,返回arr[L],这就是先手的base case,

    • 如果L<R,则先手有两种情况可以拿,

      1. 先手拿走最左边的牌,然后变成后手在arr[L+1...R]中拿纸牌
      1. 先手拿走最右边的牌,然后变成后手在arr[L...R-1]中拿纸牌
    • 这两种情况中,先手会选择两种情况中较大(因为两个都是决定聪明,先手肯定要把最小的留给对方,大的拿给自己)的那个,这就是先手的递归过程。

    • 先拿牌很重要的一点不是简单的拿两边最大的,而是要考虑到后手拿牌的可能性以后拿整体最大的,把小的留给对手,这就是题目中绝顶聪明的意思。

    • 后手递归函数:后手递归是指先手还没有拿的情况下,后手怎么能取到最大值

    • 后手在剩下的arr[L...R]中拿纸牌,如果L==R,则只剩下一张牌,这一张牌先手就拿走了,轮不到自己拿,所以自己的分数为0,返回0,这就是后手的base case,

    • 如果L<R,则后手有两种情况可以拿,

      1. 如果先手拿了L位置,等到自己变成先手就在arr[L+1...R]中拿纸牌
      1. 如果先手拿了R位置,等到自己变成先手就在arr[L...R-1]中拿纸牌
    • 这两种情况中,后手会选择两种情况中较小(因为两个都是决定聪明,对方肯定会把大的拿走,小的留给自己)的那个,这就是后手的递归过程。

  • 总结:

    • 拿牌总是在先手的时候拿的,也就是我们生活中轮到自己了才拿牌,但是因为要考虑到自己的利益最大化,所以分开了先手函数和后手函数,
    • 先手函数的目的就是要在考虑到后手拿牌的情况下自己拿到最大值,后手函数就是在剔除了先手的影响下,自己拿到最大值。
java 复制代码
    /**
     * 暴力递归的方法:
     * 思路:
     * 将所有纸牌arr[L..R]都拿完,返回最大的分数,L和R表示纸牌的左右边界
     * 有A和B两个人,A先拿,B后拿,要获取最大的分数,整体就有两种情况:A作为先手的分数和B作为后手的分数,取两个中的较大值,这样就要有先手和后手两个递归函数。
     * 先手和后手的意思是:先手当前就可以拿,后手是当前对方作为先手先拿,然后轮到自己拿
     * <br>
     * 先手递归函数:先手递归是指先手的人在剩下的排中把最大的给自己,小的给后手
     * 先手在剩下的arr[L..R]中拿纸牌,如果L==R,则只剩下一张牌,那先手直接拿就可以了,返回arr[L],这就是先手的base case,
     * 如果L<R,则先手有两种情况可以拿,
     * 1. 先手拿走最左边的牌,然后变成后手在arr[L+1..R]中拿纸牌
     * 2. 先手拿走最右边的牌,然后变成后手在arr[L..R-1]中拿纸牌
     * 这两种情况中,先手会选择两种情况中较大(因为两个都是决定聪明,先手肯定要把最小的留给对方,大的拿给自己)的那个,这就是先手的递归过程。
     * 先拿牌很重要的一点不是简单的拿两边最大的,而是要考虑到后手拿牌的可能性以后拿整体最大的,把小的留给对手,这就是题目中绝顶聪明的意思。
     * <br>
     * 后手递归函数:后手递归是指先手还没有拿的情况下,后手怎么能取到最大值
     * 后手在剩下的arr[L..R]中拿纸牌,如果L==R,则只剩下一张牌,这一张牌先手就拿走了,轮不到自己拿,所以自己的分数为0,返回0,这就是后手的base case,
     * 如果L<R,则后手有两种情况可以拿,
     * 1. 如果先手拿了L位置,等到自己变成先手就在arr[L+1..R]中拿纸牌
     * 2. 如果先手拿了R位置,等到自己变成先手就在arr[L..R-1]中拿纸牌
     * 这两种情况中,后手会选择两种情况中较小(因为两个都是决定聪明,对方肯定会把大的拿走,小的留给自己)的那个,这就是后手的递归过程。
     * <br>
     * 总结:
     * 拿牌总是在先手的时候拿的,也就是我们生活中轮到自己了才拿牌,但是因为要考虑到自己的利益最大化,所以分开了先手函数和后手函数,
     * 先手函数的目的就是要在考虑到后手拿牌的情况下自己拿到最大值,后手函数就是在剔除了先手的影响下,自己拿到最大值。
     */
    public static int win1(int[] arr) {
        if (arr == null || arr.length == 0) {
            return 0;
        }
        // 获得先手的分数
        int first = first1(arr, 0, arr.length - 1);
        // 获得后手的分数
        int second = second1(arr, 0, arr.length - 1);
        // 返回较大值
        return Math.max(first, second);
    }

    /**
     * 先手递归函数:在剩下的纸牌中arr[L..R]中,先手获得的最好分数返回
     *
     */
    private static int first1(int[] arr, int L, int R) {
        // base case,只有一张牌,先手直接拿,直接返回
        if (L == R) {
            return arr[L];
        }
        // 还有别的牌,分别计算出先手拿最左边和最右边的情况,取较大的那个
        int left = arr[L] + second1(arr, L + 1, R);
        int right = arr[R] + second1(arr, L, R - 1);
        return Math.max(left, right);
    }

    /**
     * 后手递归函数:在剩下的纸牌中arr[L..R]中,后手获得的最好分数返回
     *
     */
    private static int second1(int[] arr, int L, int R) {
        // base case,只有一张牌,先手直接拿,轮不到自己拿,所以自己的分数为0,返回0
        if (L == R) {
            return 0;
        }
        // 还有别的牌,分别计算出先手拿最左边和最右边的情况,取较小的那个
        int left = first1(arr, L + 1, R);
        int right = first1(arr, L, R - 1);
        // 这里一定是后手取较小的那个,因为先手会拿了大的,小的才会给后手
        return Math.min(left, right);
    }
2.2.2、从暴力递归改到利用缓存优化的递归
  • 从暴力递归改到利用缓存优化的递归

    • 能够改为利用缓存优化的递归的条件是在递归的过程中,出现了重复求解的情况,重复解的出现是因为递归的过程中,有很多子问题是重复计算的,所以可以用缓存优化。
    • 本题目中,先手和后手的递归函数中,变量就是L和R,在不同 的调用中,会有重复的情况出现,所以可以用缓存优化。
  • 思路:

    • 这个题目中有先手和后手两个递归函数,每个递归函数中又依赖对方的递归函数,所以在缓存优化的过程中,需要用两个二维数组来缓存先手和后手的递归函数的结果。
    • 递归函数中的L和R起始是数组的下标,所以缓存数组的大小为N*N,N为数组的长度。
    • 先手递归函数的缓存数组fmap[N][N],后手递归函数的缓存数组smap[N][N],
    • 其中fmap[i][j]表示在arr[i...j]中,先手获得的最好分数,smap[i][j]表示在arr[i...j]中,后手获得的最好分数。
    • 初始化时,将fmap和smap都初始化为-1,-1表示还没有计算过。
    • 然后修改先手和后手递归函数,在递归的过程中,先判断缓存数组中是否有计算过的结果,如果有,直接返回缓存中的结果,否则计算结果并缓存起来。
java 复制代码
    /**
     * 从暴力递归改到利用缓存优化的递归
     * 能够改为利用缓存优化的递归的条件是在递归的过程中,出现了重复求解的情况,重复解的出现是因为递归的过程中,有很多子问题是重复计算的,所以可以用缓存优化。
     * 本题目中,先手和后手的递归函数中,变量就是L和R,在不同 的调用中,会有重复的情况出现,所以可以用缓存优化。
     * <br>
     * 思路:
     * 这个题目中有先手和后手两个递归函数,每个递归函数中又依赖对方的递归函数,所以在缓存优化的过程中,需要用两个二维数组来缓存先手和后手的递归函数的结果。
     * 递归函数中的L和R起始是数组的下标,所以缓存数组的大小为N*N,N为数组的长度。
     * 先手递归函数的缓存数组fmap[N][N],后手递归函数的缓存数组smap[N][N],
     * 其中fmap[i][j]表示在arr[i..j]中,先手获得的最好分数,smap[i][j]表示在arr[i..j]中,后手获得的最好分数。
     * 初始化时,将fmap和smap都初始化为-1,-1表示还没有计算过。
     * 然后修改先手和后手递归函数,在递归的过程中,先判断缓存数组中是否有计算过的结果,如果有,直接返回缓存中的结果,否则计算结果并缓存起来。
     */
    public static int win2(int[] arr) {
        if (arr == null || arr.length == 0) {
            return 0;
        }
        // 定义并初始化缓存表
        int N = arr.length;
        int[][] fmap = new int[N][N];
        int[][] smap = new int[N][N];
        for (int i = 0; i < N; i++) {
            for (int j = 0; j < N; j++) {
                fmap[i][j] = -1;
                smap[i][j] = -1;
            }
        }
        // 获得先手的分数
        int first = first2(arr, 0, arr.length - 1, fmap, smap);
        // 获得后手的分数
        int second = second2(arr, 0, arr.length - 1, fmap, smap);
        // 返回较大值
        return Math.max(first, second);
    }

    /**
     * 利用缓存优化的先手递归函数
     */
    private static int first2(int[] arr, int L, int R, int[][] fmap, int[][] smap) {
        // 先手缓存表中有,直接返回
        if (fmap[L][R] != -1) {
            return fmap[L][R];
        }
        // base case,只有一张牌,先手直接拿,直接返回
        if (L == R) {
            fmap[L][R] = arr[L];
            return fmap[L][R];
        }
        // 还有别的牌,分别计算出先手拿最左边和最右边的情况,取较大的那个
        int left = arr[L] + second2(arr, L + 1, R, fmap, smap);
        int right = arr[R] + second2(arr, L, R - 1, fmap, smap);
        // 缓存并返回较大值
        fmap[L][R] = Math.max(left, right);
        return fmap[L][R];
    }

    /**
     * 利用缓存优化的后手递归函数
     */
    private static int second2(int[] arr, int L, int R, int[][] fmap, int[][] smap) {
        // 后手缓存表中有,直接返回
        if (smap[L][R] != -1) {
            return smap[L][R];
        }
        // base case,只有一张牌,先手直接拿,轮不到自己拿,所以自己的分数为0,返回0
        if (L == R) {
            smap[L][R] = 0;
            return smap[L][R];
        }
        // 还有别的牌,分别计算出先手拿最左边和最右边的情况,取较小的那个
        int left = first2(arr, L + 1, R, fmap, smap);
        int right = first2(arr, L, R - 1, fmap, smap);
        // 这里一定是后手取较小的那个,因为先手会拿了大的,小的才会给后手
        smap[L][R] = Math.min(left, right);
        return smap[L][R];
    }
2.2.3、从利用缓存优化的递归改到动态规划
  • 从利用缓存优化的递归改到动态规划

    • 从利用缓存优化的递归改到动态规划,就是把递归的过程,改成用循环和依赖条件填值的过程,在这个过程中,最主要的是梳理清楚依赖关系,从而根据依赖关系填写依赖表。
  • 思路:

    • 从上面利用缓存优化的递归方法中,我们用了两个二维数组fmap和smap来缓存先手和后手的递归函数的结果。
    • 我们可以根据依赖关系,用循环和依赖条件填值的过程,来填写这两个二维数组。
    • 首先要看两个递归的截止条件,当L==R时,fmap[i][i]=arr[i],即对角线为arr[i],smap[i][i]=0,即对角线为0。这是base case,
    • 其他位置的依赖关系:
    • fmap[i][j]依赖smap[i+1][j]和smap[i][j-1],即fmap依赖smap的下一个和前一个位置的值。
    • smap[i][j]依赖fmap[i+1][j]和fmap[i][j-1],即smap依赖fmap的下一个和前一个位置的值。
    • 可以看出,两个数组的特定位置都是依赖于另一个数组的前一个和下面一个位置的值。
    • 所以我们在填写fmap和smap的时候,从下到上,从左到右依次填写。填写完成后,返回fmap[0][N-1]和smap[0][N-1]中的较大值,就是先手和后手的递归函数的结果。
    • 因为我们两张表都是取一个范围的左和右(即L<R,不会出现L>R的情况),所以两个表从对角线左下角的位置是用不上的,我们能用到的就是右上角的位置。
    • 根据两个递归函数的base case,我们已经初始化了fmap和smap的对角线,这样就可以根据依赖关系,填写右上角的对角线的值,直到填写到fmap[0][N-1]和smap[0][N-1]。
  • 如何依次填写右上角对角线的值:

    • 在外层循环中,列从1开始,依次到N,
    • 在里层循环中,行从0开始,列从外层循环开始,依次到N-1,每次填写一个位置,就把行和列加1,即从整张表的对角线开始,依次往右上角对角线一条一条的填写。
    • 因为[0][0]已经填过了,从[0][1]开始填写,每次都走其右下角的位置,填写到右上角的位置,就完成了一次填写。
  • 暴力递归到动态规划的改造思路:

    • 1、先根据题目要求,写出暴力递归的求解方法。
    • 2、可以根据一个示例,看一下递归的过程中是否会重复求解,如果会,就可以改成动态规划。
    • 3、根据题目建缓存表,将原来递归的函数改成缓存优化的形式。
    • 4、根据依赖关系,将根据缓存依赖的递归方法改成动态规划的形式。
    • 4.1、根据递归函数的base case,初始化缓存表
    • 4.2、可以根据递归函数的依赖关系,找出依赖表的填值方法,必要时可以画出缓存表,再根据具体示例来让整个过程更清晰。
    • 4.3、根据依赖表的填值方法,填写缓存表,去掉递归的过程,直接根据缓存表的值返回结果。
java 复制代码
    /**
     * 从利用缓存优化的递归改到动态规划
     * 从利用缓存优化的递归改到动态规划,就是把递归的过程,改成用循环和依赖条件填值的过程,在这个过程中,最主要的是梳理清楚依赖关系,从而根据依赖关系填写依赖表。
     * <br>
     * 思路:
     * 从上面利用缓存优化的递归方法中,我们用了两个二维数组fmap和smap来缓存先手和后手的递归函数的结果。
     * 我们可以根据依赖关系,用循环和依赖条件填值的过程,来填写这两个二维数组。
     * 首先要看两个递归的截止条件,当L==R时,fmap[i][i]=arr[i],即对角线为arr[i],smap[i][i]=0,即对角线为0。这是base case,
     * 其他位置的依赖关系:
     * fmap[i][j]依赖smap[i+1][j]和smap[i][j-1],即fmap依赖smap的下一个和前一个位置的值。
     * smap[i][j]依赖fmap[i+1][j]和fmap[i][j-1],即smap依赖fmap的下一个和前一个位置的值。
     * 可以看出,两个数组的特定位置都是依赖于另一个数组的前一个和下面一个位置的值。
     * 所以我们在填写fmap和smap的时候,从下到上,从左到右依次填写。填写完成后,返回fmap[0][N-1]和smap[0][N-1]中的较大值,就是先手和后手的递归函数的结果。
     * 因为我们两张表都是取一个范围的左和右(即L<R,不会出现L>R的情况),所以两个表从对角线左下角的位置是用不上的,我们能用到的就是右上角的位置。
     * 根据两个递归函数的base case,我们已经初始化了fmap和smap的对角线,这样就可以根据依赖关系,填写右上角的对角线的值,直到填写到fmap[0][N-1]和smap[0][N-1]。
     * <br>
     * 如何依次填写右上角对角线的值:
     * 在外层循环中,列从1开始,依次到N,
     * 在里层循环中,行从0开始,列从外层循环开始,依次到N-1,每次填写一个位置,就把行和列加1,即从整张表的对角线开始,依次往右上角对角线一条一条的填写。
     * 因为[0][0]已经填过了,从[0][1]开始填写,每次都走其右下角的位置,填写到右上角的位置,就完成了一次填写。
     * <br>
     * 暴力递归到动态规划的改造思路:
     * 1、先根据题目要求,写出暴力递归的求解方法。
     * 2、可以根据一个示例,看一下递归的过程中是否会重复求解,如果会,就可以改成动态规划。
     * 3、根据题目建缓存表,将原来递归的函数改成缓存优化的形式。
     * 4、根据依赖关系,将根据缓存依赖的递归方法改成动态规划的形式。
     * 4.1、根据递归函数的base case,初始化缓存表
     * 4.2、可以根据递归函数的依赖关系,找出依赖表的填值方法,必要时可以画出缓存表,再根据具体示例来让整个过程更清晰。
     * 4.3、根据依赖表的填值方法,填写缓存表,去掉递归的过程,直接根据缓存表的值返回结果。
     */
    public static int win3(int[] arr) {
        if (arr == null || arr.length == 0) {
            return 0;
        }
        // 定义缓存表
        int N = arr.length;
        int[][] fmap = new int[N][N];
        int[][] smap = new int[N][N];
        // 根据base case,初始化缓存表
        for (int i = 0; i < N; i++) {
            fmap[i][i] = arr[i];
            // smap[i][i] = 0; java中默认值就是0,所以可以不初始化
        }
        // 根据前面的分析,根据对角线填值
        // 在外层循环中,列从1开始,依次到N,
        for (int startCol = 1; startCol < N; startCol++) {
            int L = 0;
            int R = startCol;
            // 在里层循环中,行从0开始,列从外层循环开始,依次到N-1,每次填写一个位置,就把行和列加1,即从整张表的对角线开始,依次往右上角对角线一条一条的填写。
            while (R < N) {
                // 填写fmap[L][R]
                fmap[L][R] = Math.max(arr[L] + smap[L + 1][R], arr[R] + smap[L][R - 1]);
                // 填写smap[L][R]
                smap[L][R] = Math.min(fmap[L + 1][R], fmap[L][R - 1]);
                L++;
                R++;
            }
        }
        // 返回右上角的位置,就是先手和后手的递归函数的结果
        return Math.max(fmap[0][N - 1], smap[0][N - 1]);
    }

整体代码和测试如下:

java 复制代码
/**
 * 题目二:获取纸牌的最大分数
 * 给定一个整型数组arr,代表数值不同的纸牌排成一条线
 * 玩家A和玩家B依次拿走每张纸牌
 * 规定玩家A先拿,玩家B后拿
 * 但是每个玩家每次只能拿走最左或最右的纸牌
 * 玩家A和玩家B都绝顶聪明
 * 请返回最后获胜者的分数。
 */
public class Q02_CardsInLine {

    /**
     * 暴力递归的方法:
     * 思路:
     * 将所有纸牌arr[L..R]都拿完,返回最大的分数,L和R表示纸牌的左右边界
     * 有A和B两个人,A先拿,B后拿,要获取最大的分数,整体就有两种情况:A作为先手的分数和B作为后手的分数,取两个中的较大值,这样就要有先手和后手两个递归函数。
     * 先手和后手的意思是:先手当前就可以拿,后手是当前对方作为先手先拿,然后轮到自己拿
     * <br>
     * 先手递归函数:先手递归是指先手的人在剩下的排中把最大的给自己,小的给后手
     * 先手在剩下的arr[L..R]中拿纸牌,如果L==R,则只剩下一张牌,那先手直接拿就可以了,返回arr[L],这就是先手的base case,
     * 如果L<R,则先手有两种情况可以拿,
     * 1. 先手拿走最左边的牌,然后变成后手在arr[L+1..R]中拿纸牌
     * 2. 先手拿走最右边的牌,然后变成后手在arr[L..R-1]中拿纸牌
     * 这两种情况中,先手会选择两种情况中较大(因为两个都是决定聪明,先手肯定要把最小的留给对方,大的拿给自己)的那个,这就是先手的递归过程。
     * 先拿牌很重要的一点不是简单的拿两边最大的,而是要考虑到后手拿牌的可能性以后拿整体最大的,把小的留给对手,这就是题目中绝顶聪明的意思。
     * <br>
     * 后手递归函数:后手递归是指先手还没有拿的情况下,后手怎么能取到最大值
     * 后手在剩下的arr[L..R]中拿纸牌,如果L==R,则只剩下一张牌,这一张牌先手就拿走了,轮不到自己拿,所以自己的分数为0,返回0,这就是后手的base case,
     * 如果L<R,则后手有两种情况可以拿,
     * 1. 如果先手拿了L位置,等到自己变成先手就在arr[L+1..R]中拿纸牌
     * 2. 如果先手拿了R位置,等到自己变成先手就在arr[L..R-1]中拿纸牌
     * 这两种情况中,后手会选择两种情况中较小(因为两个都是决定聪明,对方肯定会把大的拿走,小的留给自己)的那个,这就是后手的递归过程。
     * <br>
     * 总结:
     * 拿牌总是在先手的时候拿的,也就是我们生活中轮到自己了才拿牌,但是因为要考虑到自己的利益最大化,所以分开了先手函数和后手函数,
     * 先手函数的目的就是要在考虑到后手拿牌的情况下自己拿到最大值,后手函数就是在剔除了先手的影响下,自己拿到最大值。
     */
    public static int win1(int[] arr) {
        if (arr == null || arr.length == 0) {
            return 0;
        }
        // 获得先手的分数
        int first = first1(arr, 0, arr.length - 1);
        // 获得后手的分数
        int second = second1(arr, 0, arr.length - 1);
        // 返回较大值
        return Math.max(first, second);
    }

    /**
     * 先手递归函数:在剩下的纸牌中arr[L..R]中,先手获得的最好分数返回
     *
     */
    private static int first1(int[] arr, int L, int R) {
        // base case,只有一张牌,先手直接拿,直接返回
        if (L == R) {
            return arr[L];
        }
        // 还有别的牌,分别计算出先手拿最左边和最右边的情况,取较大的那个
        int left = arr[L] + second1(arr, L + 1, R);
        int right = arr[R] + second1(arr, L, R - 1);
        return Math.max(left, right);
    }

    /**
     * 后手递归函数:在剩下的纸牌中arr[L..R]中,后手获得的最好分数返回
     *
     */
    private static int second1(int[] arr, int L, int R) {
        // base case,只有一张牌,先手直接拿,轮不到自己拿,所以自己的分数为0,返回0
        if (L == R) {
            return 0;
        }
        // 还有别的牌,分别计算出先手拿最左边和最右边的情况,取较小的那个
        int left = first1(arr, L + 1, R);
        int right = first1(arr, L, R - 1);
        // 这里一定是后手取较小的那个,因为先手会拿了大的,小的才会给后手
        return Math.min(left, right);
    }

    /**
     * 从暴力递归改到利用缓存优化的递归
     * 能够改为利用缓存优化的递归的条件是在递归的过程中,出现了重复求解的情况,重复解的出现是因为递归的过程中,有很多子问题是重复计算的,所以可以用缓存优化。
     * 本题目中,先手和后手的递归函数中,变量就是L和R,在不同 的调用中,会有重复的情况出现,所以可以用缓存优化。
     * <br>
     * 思路:
     * 这个题目中有先手和后手两个递归函数,每个递归函数中又依赖对方的递归函数,所以在缓存优化的过程中,需要用两个二维数组来缓存先手和后手的递归函数的结果。
     * 递归函数中的L和R起始是数组的下标,所以缓存数组的大小为N*N,N为数组的长度。
     * 先手递归函数的缓存数组fmap[N][N],后手递归函数的缓存数组smap[N][N],
     * 其中fmap[i][j]表示在arr[i..j]中,先手获得的最好分数,smap[i][j]表示在arr[i..j]中,后手获得的最好分数。
     * 初始化时,将fmap和smap都初始化为-1,-1表示还没有计算过。
     * 然后修改先手和后手递归函数,在递归的过程中,先判断缓存数组中是否有计算过的结果,如果有,直接返回缓存中的结果,否则计算结果并缓存起来。
     */
    public static int win2(int[] arr) {
        if (arr == null || arr.length == 0) {
            return 0;
        }
        // 定义并初始化缓存表
        int N = arr.length;
        int[][] fmap = new int[N][N];
        int[][] smap = new int[N][N];
        for (int i = 0; i < N; i++) {
            for (int j = 0; j < N; j++) {
                fmap[i][j] = -1;
                smap[i][j] = -1;
            }
        }
        // 获得先手的分数
        int first = first2(arr, 0, arr.length - 1, fmap, smap);
        // 获得后手的分数
        int second = second2(arr, 0, arr.length - 1, fmap, smap);
        // 返回较大值
        return Math.max(first, second);
    }

    /**
     * 利用缓存优化的先手递归函数
     */
    private static int first2(int[] arr, int L, int R, int[][] fmap, int[][] smap) {
        // 先手缓存表中有,直接返回
        if (fmap[L][R] != -1) {
            return fmap[L][R];
        }
        // base case,只有一张牌,先手直接拿,直接返回
        if (L == R) {
            fmap[L][R] = arr[L];
            return fmap[L][R];
        }
        // 还有别的牌,分别计算出先手拿最左边和最右边的情况,取较大的那个
        int left = arr[L] + second2(arr, L + 1, R, fmap, smap);
        int right = arr[R] + second2(arr, L, R - 1, fmap, smap);
        // 缓存并返回较大值
        fmap[L][R] = Math.max(left, right);
        return fmap[L][R];
    }

    /**
     * 利用缓存优化的后手递归函数
     */
    private static int second2(int[] arr, int L, int R, int[][] fmap, int[][] smap) {
        // 后手缓存表中有,直接返回
        if (smap[L][R] != -1) {
            return smap[L][R];
        }
        // base case,只有一张牌,先手直接拿,轮不到自己拿,所以自己的分数为0,返回0
        if (L == R) {
            smap[L][R] = 0;
            return smap[L][R];
        }
        // 还有别的牌,分别计算出先手拿最左边和最右边的情况,取较小的那个
        int left = first2(arr, L + 1, R, fmap, smap);
        int right = first2(arr, L, R - 1, fmap, smap);
        // 这里一定是后手取较小的那个,因为先手会拿了大的,小的才会给后手
        smap[L][R] = Math.min(left, right);
        return smap[L][R];
    }

    /**
     * 从利用缓存优化的递归改到动态规划
     * 从利用缓存优化的递归改到动态规划,就是把递归的过程,改成用循环和依赖条件填值的过程,在这个过程中,最主要的是梳理清楚依赖关系,从而根据依赖关系填写依赖表。
     * <br>
     * 思路:
     * 从上面利用缓存优化的递归方法中,我们用了两个二维数组fmap和smap来缓存先手和后手的递归函数的结果。
     * 我们可以根据依赖关系,用循环和依赖条件填值的过程,来填写这两个二维数组。
     * 首先要看两个递归的截止条件,当L==R时,fmap[i][i]=arr[i],即对角线为arr[i],smap[i][i]=0,即对角线为0。这是base case,
     * 其他位置的依赖关系:
     * fmap[i][j]依赖smap[i+1][j]和smap[i][j-1],即fmap依赖smap的下一个和前一个位置的值。
     * smap[i][j]依赖fmap[i+1][j]和fmap[i][j-1],即smap依赖fmap的下一个和前一个位置的值。
     * 可以看出,两个数组的特定位置都是依赖于另一个数组的前一个和下面一个位置的值。
     * 所以我们在填写fmap和smap的时候,从下到上,从左到右依次填写。填写完成后,返回fmap[0][N-1]和smap[0][N-1]中的较大值,就是先手和后手的递归函数的结果。
     * 因为我们两张表都是取一个范围的左和右(即L<R,不会出现L>R的情况),所以两个表从对角线左下角的位置是用不上的,我们能用到的就是右上角的位置。
     * 根据两个递归函数的base case,我们已经初始化了fmap和smap的对角线,这样就可以根据依赖关系,填写右上角的对角线的值,直到填写到fmap[0][N-1]和smap[0][N-1]。
     * <br>
     * 如何依次填写右上角对角线的值:
     * 在外层循环中,列从1开始,依次到N,
     * 在里层循环中,行从0开始,列从外层循环开始,依次到N-1,每次填写一个位置,就把行和列加1,即从整张表的对角线开始,依次往右上角对角线一条一条的填写。
     * 因为[0][0]已经填过了,从[0][1]开始填写,每次都走其右下角的位置,填写到右上角的位置,就完成了一次填写。
     * <br>
     * 暴力递归到动态规划的改造思路:
     * 1、先根据题目要求,写出暴力递归的求解方法。
     * 2、可以根据一个示例,看一下递归的过程中是否会重复求解,如果会,就可以改成动态规划。
     * 3、根据题目建缓存表,将原来递归的函数改成缓存优化的形式。
     * 4、根据依赖关系,将根据缓存依赖的递归方法改成动态规划的形式。
     * 4.1、根据递归函数的base case,初始化缓存表
     * 4.2、可以根据递归函数的依赖关系,找出依赖表的填值方法,必要时可以画出缓存表,再根据具体示例来让整个过程更清晰。
     * 4.3、根据依赖表的填值方法,填写缓存表,去掉递归的过程,直接根据缓存表的值返回结果。
     */
    public static int win3(int[] arr) {
        if (arr == null || arr.length == 0) {
            return 0;
        }
        // 定义缓存表
        int N = arr.length;
        int[][] fmap = new int[N][N];
        int[][] smap = new int[N][N];
        // 根据base case,初始化缓存表
        for (int i = 0; i < N; i++) {
            fmap[i][i] = arr[i];
            // smap[i][i] = 0; java中默认值就是0,所以可以不初始化
        }
        // 根据前面的分析,根据对角线填值
        // 在外层循环中,列从1开始,依次到N,
        for (int startCol = 1; startCol < N; startCol++) {
            int L = 0;
            int R = startCol;
            // 在里层循环中,行从0开始,列从外层循环开始,依次到N-1,每次填写一个位置,就把行和列加1,即从整张表的对角线开始,依次往右上角对角线一条一条的填写。
            while (R < N) {
                // 填写fmap[L][R]
                fmap[L][R] = Math.max(arr[L] + smap[L + 1][R], arr[R] + smap[L][R - 1]);
                // 填写smap[L][R]
                smap[L][R] = Math.min(fmap[L + 1][R], fmap[L][R - 1]);
                L++;
                R++;
            }
        }
        // 返回右上角的位置,就是先手和后手的递归函数的结果
        return Math.max(fmap[0][N - 1], smap[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));

    }

}

2.3、题目三:背包问题

  • 题目三:背包问题
  • 给定一个容量有限的背包和一组物品(每个物品都有特定的重量和价值),
  • 如何选择装入背包的物品,使得背包中物品的总价值最大,同时不超过背包的容量限制。
  • 所有的货,重量和价值,都在w和v数组里,bag背包容量,不能超过这个载重,
  • 返回:不超重的情况下,能够得到的最大价值
2.3.1、暴力递归的方法
  • 暴力递归的方法
  • 思路:
    • 总体思路是列出bag里面能装下的所有情况,然后挑出价值比较大的就可以。
    • 对于这种一堆物品,组合出所有情况的方法就是用一个递归函数,对于每一个物品,都有两种情况,选或者不选。通过这种递归下去,就能递归出所有组合。
    • 至于bag里面跳出能装下的情况,可以将剩余的容量作为一次参数,在每次选了商品的递归中,对应的减少容量,当容量小于0时,就说明超过了背包的容量,返回-1,代表这种情况不行。
    • 当递归到超过商品的数量并且剩余容量大于或者等于0时,就说明所有商品都考虑完了,返回0,代表这种情况的价值为0。递归返回上去,就能求出最大的价值。
java 复制代码
    /**
     * 暴力递归的方法
     * 思路:
     * 总体思路是列出bag里面能装下的所有情况,然后挑出价值比较大的就可以。
     * 对于这种一堆物品,组合出所有情况的方法就是用一个递归函数,对于每一个物品,都有两种情况,选或者不选。通过这种递归下去,就能递归出所有组合。
     * 至于bag里面跳出能装下的情况,可以将剩余的容量作为一次参数,在每次选了商品的递归中,对应的减少容量,当容量小于0时,就说明超过了背包的容量,返回-1,代表这种情况不行。
     * 当递归到超过商品的数量并且剩余容量大于或者等于0时,就说明所有商品都考虑完了,返回0,代表这种情况的价值为0。递归返回上去,就能求出最大的价值。
     */
    public static int maxValue1(int[] w, int[] v, int bag) {
        if (w == null || v == null || w.length != v.length || w.length == 0) {
            return 0;
        }
        return process1(w, v, 0, bag);
    }

    /**
     * 递归函数,返回index位置开始,rest容量下的最大价值
     */
    private static int process1(int[] w, int[] v, int index, int rest) {
        // rest < 0 说明超过了背包的容量,前面的方案不可行,返回-1
        if (rest < 0) {
            return -1;
        }
        // base case ,index == w.length 说明所有商品都考虑完了,当前index位置已经越界,返回0
        // 用这个判断做base case,说明在递归完所有商品后,递归函数还是要多执行一次,虽然空跑了一次,但是写法变简单了
        if (index == w.length) {
            return 0;
        }
        // 求出不要当前货物的后续价值
        int valueNo = process1(w, v, index + 1, rest);
        // 求出要当前货物的最后价值,这里有可能会出现返回-1的情况,要进行判断
        int valueYes = 0;
        int next = process1(w, v, index + 1, rest - w[index]);
        if (next != -1) {
            // 当前的选择有效,更新要的价值
            valueYes = v[index] + next;
        }
        // 比较不要和要的价值,返回较大的那个
        return Math.max(valueNo, valueYes);
    }
2.3.2、从暴力递归改到利用缓存优化的递归
  • 从暴力递归改到利用缓存优化的递归:
    • 能够用缓存优化的条件是在递归的过程中,有重复依赖的解,重复解的出现是因为递归的过程中,有很多子问题是重复计算的,所以可以用缓存优化。
    • 本题目中,递归函数有重复计算后面的子问题,所以可以用缓存优化。
  • 思路:
    • 从暴力递归得出缓存表,主要是看递归函数的变化参数,从变化参数就可以得出缓存表的情况。
    • 上面暴力递归函数process1中,变化的量有两个,一个是index,从[0~N-1],另一个是rest,从0~bag,所以缓存表的大小为(N+1)*(bag+1)。
    • 慢慢熟悉了以后,就直接可以从暴力递归改成动态规划,忽略缓存优化的过程,从而提高解题的效率。
java 复制代码
    /**
     * 从暴力递归改到利用缓存优化的递归:
     * 能够用缓存优化的条件是在递归的过程中,有重复依赖的解,重复解的出现是因为递归的过程中,有很多子问题是重复计算的,所以可以用缓存优化。
     * 本题目中,递归函数有重复计算后面的子问题,所以可以用缓存优化。
     * 思路:
     * 从暴力递归得出缓存表,主要是看递归函数的变化参数,从变化参数就可以得出缓存表的情况。
     * 上面暴力递归函数process1中,变化的量有两个,一个是index,从[0~N-1],另一个是rest,从[0~bag](负数在代码中去掉),所以缓存表的大小为(N+1)*(bag+1)。
     * 慢慢熟悉了以后,就直接可以从暴力递归改成动态规划,忽略缓存优化的过程,从而提高解题的效率。
     */
    public static int maxValue2(int[] w, int[] v, int bag) {
        if (w == null || v == null || w.length != v.length || w.length == 0) {
            return 0;
        }
        // 定义缓存表
        int[][] dp = new int[w.length + 1][bag + 1];
        return process2(w, v, 0, bag, dp);
    }

    /**
     * 含缓存的暴力递归函数:
     * 递归求index位置及其后面的货物,在rest容量下的最大价值。
     */
    private static int process2(int[] w, int[] v, int index, int rest, int[][] dp) {
        // 先判断不可行的情况
        if (rest < 0) {
            return -1;
        }
        // 从表中取缓存
        if (dp[index][rest] != 0) {
            return dp[index][rest];
        }
        // base case ,index == w.length 说明所有商品都考虑完了,当前index位置已经越界,返回0
        if (index == w.length) {
            return 0;
        }
        // 求出不要当前货物的后续价值
        int valueNo = process2(w, v, index + 1, rest, dp);
        // 求出要当前货物的最后价值,这里有可能会出现返回-1的情况,要进行判断
        int valueYes = 0;
        int next = process2(w, v, index + 1, rest - w[index], dp);
        if (next != -1) {
            // 当前的选择有效,更新要的价值
            valueYes = v[index] + next;
        }
        // 比较不要和要的价值,返回较大的那个
        dp[index][rest] = Math.max(valueNo, valueYes);
        return dp[index][rest];
    }
2.3.3、从利用缓存优化的递归改到动态规划
  • 从利用缓存优化的递归改到动态规划

    • 从利用缓存优化的递归改到动态规划,就是把递归的过程,改成用循环和依赖条件填值的过程,在这个过程中,最主要的是梳理清楚依赖关系,从而根据依赖关系填写依赖表。
  • 思路:

    • 从暴力递归函数可以看出,可变参数为index和rest,index从[0 ~ N],rest从[0 ~ bag],负数在代码中去掉,所以依赖表的大小为(N+1)*(bag+1)。
    • 根据递归函数的依赖关系,填写依赖表。
    • 首先是base case,当index为N时,返回0,所以依赖表的最后一行都是0。
    • 其他行的依赖关系为:dp[index][rest] = Math.max(dp[index + 1][rest], dp[index + 1][rest - w[index]] + v[index]);
    • 即当前行都会依赖下一行的值(index +1),列为rest - w[index]和rest,所以从下往上,从左往右填写依赖表。
    • 所以在填表的过程中,行要从N-1开始,到0结束。列从0~bag。依次填写依赖关系
  • 总结:

    • 一开始在不熟悉的时候,需要一点一点的分析依赖关系,填表的顺序,等到熟悉以后,可以直接从暴力递归的代码出发,根据代码逻辑来填写表。
    • 缓存表是根据递归函数变化的参数来的
    • 填写的逻辑就是先base case,确定初始值,
    • 然后根据起始调用(主函数的调用),确定需要的最终值,从而确定从初始值到最终值的填表方向,
    • 最后根据递归函数的边条件,填写其他位置的值。
    • 返回的最终值的下标是根据主函数第一次调用递归函数的参数决定的
java 复制代码
    /**
     * 从利用缓存优化的递归改到动态规划
     * 从利用缓存优化的递归改到动态规划,就是把递归的过程,改成用循环和依赖条件填值的过程,在这个过程中,最主要的是梳理清楚依赖关系,从而根据依赖关系填写依赖表。
     * <br>
     * 思路:
     * 从暴力递归函数可以看出,可变参数为index和rest,index从[0~N],rest从[0~bag](负数在代码中去掉),所以依赖表的大小为(N+1)*(bag+1)。
     * 根据递归函数的依赖关系,填写依赖表。
     * 首先是base case,当index为N时,返回0,所以依赖表的最后一行都是0。
     * 其他行的依赖关系为:dp[index][rest] = Math.max(dp[index + 1][rest], dp[index + 1][rest - w[index]] + v[index]);
     * 即当前行都会依赖下一行的值(index +1),列为rest - w[index]和rest,所以从下往上,从左往右填写依赖表。
     * 所以在填表的过程中,行要从N-1开始,到0结束。列从0~bag。依次填写依赖关系
     * <br>
     * 总结:
     * 一开始在不熟悉的时候,需要一点一点的分析依赖关系,填表的顺序,等到熟悉以后,可以直接从暴力递归的代码出发,根据代码逻辑来填写表。
     * 缓存表是根据递归函数变化的参数来的
     * 填写的逻辑就是先base case,确定初始值,
     * 然后根据起始调用(主函数的调用),确定需要的最终值,从而确定从初始值到最终值的填表方向,
     * 最后根据递归函数的边条件,填写其他位置的值。
     * 返回的最终值的下标是根据主函数第一次调用递归函数的参数决定的
     */
    public static int maxValue3(int[] w, int[] v, int bag) {
        if (w == null || v == null || w.length != v.length || w.length == 0) {
            return 0;
        }
        // 定义缓存表
        int N = w.length;
        int[][] dp = new int[N + 1][bag + 1];
        // java中默认是0,所以base case就不用写了,直接填表
        // 从下往上,从左往右填写依赖表
        for (int index = N - 1; index >= 0; index--) {
            for (int rest = 0; rest <= bag; rest++) {
                int valueNo = dp[index + 1][rest];
                int valueYes = 0;
                int next = rest - w[index] >= 0 ? dp[index + 1][rest - w[index]] : -1;
                if (next != -1) {
                    // 当前的选择有效,更新要的价值
                    valueYes = v[index] + next;
                }
                // 比较不要和要的价值,返回较大的那个
                dp[index][rest] = Math.max(valueNo, valueYes);
            }
        }
        // 返回最终值
        return dp[0][bag];
    }

整体代码和测试如下:

java 复制代码
/**
 * 题目三:背包问题
 * 给定一个容量有限的背包和一组物品(每个物品都有特定的重量和价值),
 * 如何选择装入背包的物品,使得背包中物品的总价值最大,同时不超过背包的容量限制。
 * 所有的货,重量和价值,都在w和v数组里,bag背包容量,不能超过这个载重,
 * 返回:不超重的情况下,能够得到的最大价值
 */
public class Q03_Knapsack {

    /**
     * 暴力递归的方法
     * 思路:
     * 总体思路是列出bag里面能装下的所有情况,然后挑出价值比较大的就可以。
     * 对于这种一堆物品,组合出所有情况的方法就是用一个递归函数,对于每一个物品,都有两种情况,选或者不选。通过这种递归下去,就能递归出所有组合。
     * 至于bag里面跳出能装下的情况,可以将剩余的容量作为一次参数,在每次选了商品的递归中,对应的减少容量,当容量小于0时,就说明超过了背包的容量,返回-1,代表这种情况不行。
     * 当递归到超过商品的数量并且剩余容量大于或者等于0时,就说明所有商品都考虑完了,返回0,代表这种情况的价值为0。递归返回上去,就能求出最大的价值。
     */
    public static int maxValue1(int[] w, int[] v, int bag) {
        if (w == null || v == null || w.length != v.length || w.length == 0) {
            return 0;
        }
        return process1(w, v, 0, bag);
    }

    /**
     * 递归函数,返回index位置开始,rest容量下的最大价值
     */
    private static int process1(int[] w, int[] v, int index, int rest) {
        // rest < 0 说明超过了背包的容量,前面的方案不可行,返回-1
        if (rest < 0) {
            return -1;
        }
        // base case ,index == w.length 说明所有商品都考虑完了,当前index位置已经越界,返回0
        // 用这个判断做base case,说明在递归完所有商品后,递归函数还是要多执行一次,虽然空跑了一次,但是写法变简单了
        if (index == w.length) {
            return 0;
        }
        // 求出不要当前货物的后续价值
        int valueNo = process1(w, v, index + 1, rest);
        // 求出要当前货物的最后价值,这里有可能会出现返回-1的情况,要进行判断
        int valueYes = 0;
        int next = process1(w, v, index + 1, rest - w[index]);
        if (next != -1) {
            // 当前的选择有效,更新要的价值
            valueYes = v[index] + next;
        }
        // 比较不要和要的价值,返回较大的那个
        return Math.max(valueNo, valueYes);
    }

    /**
     * 从暴力递归改到利用缓存优化的递归:
     * 能够用缓存优化的条件是在递归的过程中,有重复依赖的解,重复解的出现是因为递归的过程中,有很多子问题是重复计算的,所以可以用缓存优化。
     * 本题目中,递归函数有重复计算后面的子问题,所以可以用缓存优化。
     * 思路:
     * 从暴力递归得出缓存表,主要是看递归函数的变化参数,从变化参数就可以得出缓存表的情况。
     * 上面暴力递归函数process1中,变化的量有两个,一个是index,从[0~N-1],另一个是rest,从[0~bag](负数在代码中去掉),所以缓存表的大小为(N+1)*(bag+1)。
     * 慢慢熟悉了以后,就直接可以从暴力递归改成动态规划,忽略缓存优化的过程,从而提高解题的效率。
     */
    public static int maxValue2(int[] w, int[] v, int bag) {
        if (w == null || v == null || w.length != v.length || w.length == 0) {
            return 0;
        }
        // 定义缓存表
        int[][] dp = new int[w.length + 1][bag + 1];
        return process2(w, v, 0, bag, dp);
    }

    /**
     * 含缓存的暴力递归函数:
     * 递归求index位置及其后面的货物,在rest容量下的最大价值。
     */
    private static int process2(int[] w, int[] v, int index, int rest, int[][] dp) {
        // 先判断不可行的情况
        if (rest < 0) {
            return -1;
        }
        // 从表中取缓存
        if (dp[index][rest] != 0) {
            return dp[index][rest];
        }
        // base case ,index == w.length 说明所有商品都考虑完了,当前index位置已经越界,返回0
        if (index == w.length) {
            return 0;
        }
        // 求出不要当前货物的后续价值
        int valueNo = process2(w, v, index + 1, rest, dp);
        // 求出要当前货物的最后价值,这里有可能会出现返回-1的情况,要进行判断
        int valueYes = 0;
        int next = process2(w, v, index + 1, rest - w[index], dp);
        if (next != -1) {
            // 当前的选择有效,更新要的价值
            valueYes = v[index] + next;
        }
        // 比较不要和要的价值,返回较大的那个
        dp[index][rest] = Math.max(valueNo, valueYes);
        return dp[index][rest];
    }

    /**
     * 从利用缓存优化的递归改到动态规划
     * 从利用缓存优化的递归改到动态规划,就是把递归的过程,改成用循环和依赖条件填值的过程,在这个过程中,最主要的是梳理清楚依赖关系,从而根据依赖关系填写依赖表。
     * <br>
     * 思路:
     * 从暴力递归函数可以看出,可变参数为index和rest,index从[0~N],rest从[0~bag](负数在代码中去掉),所以依赖表的大小为(N+1)*(bag+1)。
     * 根据递归函数的依赖关系,填写依赖表。
     * 首先是base case,当index为N时,返回0,所以依赖表的最后一行都是0。
     * 其他行的依赖关系为:dp[index][rest] = Math.max(dp[index + 1][rest], dp[index + 1][rest - w[index]] + v[index]);
     * 即当前行都会依赖下一行的值(index +1),列为rest - w[index]和rest,所以从下往上,从左往右填写依赖表。
     * 所以在填表的过程中,行要从N-1开始,到0结束。列从0~bag。依次填写依赖关系
     * <br>
     * 总结:
     * 一开始在不熟悉的时候,需要一点一点的分析依赖关系,填表的顺序,等到熟悉以后,可以直接从暴力递归的代码出发,根据代码逻辑来填写表。
     * 缓存表是根据递归函数变化的参数来的
     * 填写的逻辑就是先base case,确定初始值,
     * 然后根据起始调用(主函数的调用),确定需要的最终值,从而确定从初始值到最终值的填表方向,
     * 最后根据递归函数的边条件,填写其他位置的值。
     * 返回的最终值的下标是根据主函数第一次调用递归函数的参数决定的
     */
    public static int maxValue3(int[] w, int[] v, int bag) {
        if (w == null || v == null || w.length != v.length || w.length == 0) {
            return 0;
        }
        // 定义缓存表
        int N = w.length;
        int[][] dp = new int[N + 1][bag + 1];
        // java中默认是0,所以base case就不用写了,直接填表
        // 从下往上,从左往右填写依赖表
        for (int index = N - 1; index >= 0; index--) {
            for (int rest = 0; rest <= bag; rest++) {
                int valueNo = dp[index + 1][rest];
                int valueYes = 0;
                int next = rest - w[index] >= 0 ? dp[index + 1][rest - w[index]] : -1;
                if (next != -1) {
                    // 当前的选择有效,更新要的价值
                    valueYes = v[index] + next;
                }
                // 比较不要和要的价值,返回较大的那个
                dp[index][rest] = Math.max(valueNo, valueYes);
            }
        }
        // 返回最终值
        return dp[0][bag];
    }

    public static void main(String[] args) {
        int[] weights = {3, 2, 4, 7, 3, 1, 7};
        int[] values = {5, 6, 3, 19, 12, 4, 2};
        int bag = 15;
        System.out.println(maxValue1(weights, values, bag));
        System.out.println(maxValue2(weights, values, bag));
        System.out.println(maxValue3(weights, values, bag));
    }
}

2.4、题目四:数字字母转换

  • 题目四:数字字母转换
  • 规定1和A对应、2和B对应、3和C对应...26和Z对应
  • 那么一个数字字符串比如"111"就可以转化为:
  • "AAA"、"KA"和"AK"
  • 给定一个只有数字字符组成的字符串str,返回有多少种转化结果
2.4.1、暴力递归的方法
  • 暴力递归的方法
  • 思路:
    • 整体还是从左往右的模型,但是这个题不是挑选,而是转换,也就是对于某个index的字符,不能不要,只能要,
    • 因为字母有26个,所以要的过程有两种:
      1. 单转,比如1-9,就是A-I
      1. 双转,比如10-26,就是J-Z
    • 所以整体递归的过程中,对于每个位置,都要尝试单转和可能的双转,然后累加起来就是最终的结果
java 复制代码
    /**
     * 暴力递归的方法
     * 思路:
     * 整体还是从左往右的模型,但是这个题不是挑选,而是转换,也就是对于某个index的字符,不能不要,只能要,
     * 因为字母有26个,所以要的过程有两种:
     * 1. 单转,比如1-9,就是A-I
     * 2. 双转,比如10-26,就是J-Z
     * 所以整体递归的过程中,对于每个位置,都要尝试单转和可能的双转,然后累加起来就是最终的结果
     */
    public static int number1(String str) {
        if (str == null || str.length() == 0) {
            return 0;
        }
        return process(str.toCharArray(), 0);
    }

    /**
     * 递归函数,尝试i位置以后的字符有多少种可能性。然后返回
     * str[0..i-1]转化无需过问
     * str[i.....]去转化,返回有多少种转化方法
     * 因为递归本质上会将执行过程逆序,所以先求出后面的,然后加上当前的,一步一步积累上去,就能求出整体的。
     */
    private static int process(char[] charArray, int i) {
        // base case 到头了,说明之前的尝试是对的,能够累积一种情况
        if (i == charArray.length) {
            return 1;
        }
        // i位置为0,因为转换的情况是每个位置必须要,所以0不能单转,说明之前的有问题,这种情况不能做累计,返回0
        // 能返回0,是因为本题目是求的累计和,0不影响结果,如果是求的转换成的字符串,就要返回-1,在上面调用的时候判断
        if (charArray[i] == '0') {
            return 0;
        }
        // 递归单转
        int ways = process(charArray, i + 1);
        // 如果可能,递归双转
        if (i + 1 < charArray.length && (charArray[i] - '0') * 10 + charArray[i + 1] - '0' < 27) {
            ways += process(charArray, i + 2);
        }
        return ways;
    }
2.4.2、从暴力递归转为动态规划的方法
  • 从暴力递归转为动态规划的方法一:
  • 思路:
  • 我们直接从暴力递归转为动态规划,
    • 首先是缓存表dp,因为process递归函数只有一个下标,从0到str.length,所以dp的长度就是str.length+1,即dp表为dp[N+1],
    • 然后是初始值,根据base case,dp[N] = 1,因为到了最后一个位置,只有一种情况,就是不转换,所以dp[N] = 1
    • 然后是普遍位置,根据递归函数的逻辑,普遍位置的计算依赖于i+1和i+2,所以i从N-1开始,依次计算到0,
    • 每次计算的时候,先判断i位置是否为0,如果是0,说明不能单转,直接到下一个尝试,
    • 如果不是0,说明可以单转,ways = dp[i+1],然后判断是否可以双转,如果可以,way += dp[i+2],最后dp[i]=ways
    • 根据第一次调用process的结果,dp[0]就是最终的结果
java 复制代码
    /**
     * 从暴力递归转为动态规划的方法一:
     * 思路:
     * 我们直接从暴力递归转为动态规划,
     * 首先是缓存表dp,因为process递归函数只有一个下标,从0到str.length,所以dp的长度就是str.length+1,即dp表为dp[N+1],
     * 然后是初始值,根据base case,dp[N] = 1,因为到了最后一个位置,只有一种情况,就是不转换,所以dp[N] = 1
     * 然后是普遍位置,根据递归函数的逻辑,普遍位置的计算依赖于i+1和i+2,所以i从N-1开始,依次计算到0,
     * 每次计算的时候,先判断i位置是否为0,如果是0,说明不能单转,直接到下一个尝试,
     * 如果不是0,说明可以单转,ways = dp[i+1],然后判断是否可以双转,如果可以,way += dp[i+2],最后dp[i]=ways
     * 根据第一次调用process的结果,dp[0]就是最终的结果
     */
    public static int number2(String str) {
        if (str == null || str.length() == 0) {
            return 0;
        }
        char[] charArray = str.toCharArray();
        int N = charArray.length;
        //定义和初始化缓存表
        int[] dp = new int[N + 1];
        dp[N] = 1;
        // 从右往左计算dp表
        for (int i = N - 1; i >= 0; i--) {
            if (charArray[i] != '0') {
                dp[i] = dp[i + 1];
                if (i + 1 < charArray.length && (charArray[i] - '0') * 10 + charArray[i + 1] - '0' < 27) {
                    dp[i] += dp[i + 2];
                }
            }
        }
        return dp[0];
    }

整体代码和测试:

java 复制代码
/**
 * 题目四:数字字母转换
 * 规定1和A对应、2和B对应、3和C对应...26和Z对应
 * 那么一个数字字符串比如"111"就可以转化为:
 * "AAA"、"KA"和"AK"
 * 给定一个只有数字字符组成的字符串str,返回有多少种转化结果
 */
public class Q04_ConvertToLetterString {

    /**
     * 暴力递归的方法
     * 思路:
     * 整体还是从左往右的模型,但是这个题不是挑选,而是转换,也就是对于某个index的字符,不能不要,只能要,
     * 因为字母有26个,所以要的过程有两种:
     * 1. 单转,比如1-9,就是A-I
     * 2. 双转,比如10-26,就是J-Z
     * 所以整体递归的过程中,对于每个位置,都要尝试单转和可能的双转,然后累加起来就是最终的结果
     */
    public static int number1(String str) {
        if (str == null || str.length() == 0) {
            return 0;
        }
        return process(str.toCharArray(), 0);
    }

    /**
     * 递归函数,尝试i位置以后的字符有多少种可能性。然后返回
     * str[0..i-1]转化无需过问
     * str[i.....]去转化,返回有多少种转化方法
     * 因为递归本质上会将执行过程逆序,所以先求出后面的,然后加上当前的,一步一步积累上去,就能求出整体的。
     */
    private static int process(char[] charArray, int i) {
        // base case 到头了,说明之前的尝试是对的,能够累积一种情况
        if (i == charArray.length) {
            return 1;
        }
        // i位置为0,因为转换的情况是每个位置必须要,所以0不能单转,说明之前的有问题,这种情况不能做累计,返回0
        // 能返回0,是因为本题目是求的累计和,0不影响结果,如果是求的转换成的字符串,就要返回-1,在上面调用的时候判断
        if (charArray[i] == '0') {
            return 0;
        }
        // 递归单转
        int ways = process(charArray, i + 1);
        // 如果可能,递归双转
        if (i + 1 < charArray.length && (charArray[i] - '0') * 10 + charArray[i + 1] - '0' < 27) {
            ways += process(charArray, i + 2);
        }
        return ways;
    }

    /**
     * 从暴力递归转为动态规划的方法一:
     * 思路:
     * 我们直接从暴力递归转为动态规划,
     * 首先是缓存表dp,因为process递归函数只有一个下标,从0到str.length,所以dp的长度就是str.length+1,即dp表为dp[N+1],
     * 然后是初始值,根据base case,dp[N] = 1,因为到了最后一个位置,只有一种情况,就是不转换,所以dp[N] = 1
     * 然后是普遍位置,根据递归函数的逻辑,普遍位置的计算依赖于i+1和i+2,所以i从N-1开始,依次计算到0,
     * 每次计算的时候,先判断i位置是否为0,如果是0,说明不能单转,直接到下一个尝试,
     * 如果不是0,说明可以单转,ways = dp[i+1],然后判断是否可以双转,如果可以,way += dp[i+2],最后dp[i]=ways
     * 根据第一次调用process的结果,dp[0]就是最终的结果
     */
    public static int number2(String str) {
        if (str == null || str.length() == 0) {
            return 0;
        }
        char[] charArray = str.toCharArray();
        int N = charArray.length;
        //定义和初始化缓存表
        int[] dp = new int[N + 1];
        dp[N] = 1;
        // 从右往左计算dp表
        for (int i = N - 1; i >= 0; i--) {
            if (charArray[i] != '0') {
                dp[i] = dp[i + 1];
                if (i + 1 < charArray.length && (charArray[i] - '0') * 10 + charArray[i + 1] - '0' < 27) {
                    dp[i] += dp[i + 2];
                }
            }
        }
        return dp[0];
    }

    // 为了测试
    public static void main(String[] args) {
        int N = 30;
        int testTime = 5000000;
        System.out.println("测试开始");
        for (int i = 0; i < testTime; i++) {
            int len = (int) (Math.random() * N);
            String s = randomString(len);
            int ans0 = number1(s);
            int ans1 = number2(s);
            if (ans0 != ans1) {
                System.out.println("测试出错!");
                System.out.println(s);
                System.out.println(ans0);
                System.out.println(ans1);
                break;
            }
        }
        System.out.println("测试结束");
    }

    // 为了测试
    public static String randomString(int len) {
        char[] str = new char[len];
        for (int i = 0; i < len; i++) {
            str[i] = (char) ((int) (Math.random() * 10) + '0');
        }
        return String.valueOf(str);
    }
}

2.5、题目五:贴纸拼词

  • 题目五:贴纸拼词
  • 给定一个字符串target,给定一个字符串类型的数组stickers。
  • stickers里的每一个字符串,代表一张贴纸,你可以把单个字符剪开使用,目的是拼出target来。
  • 返回需要至少多少张贴纸可以完成这个任务。
  • 例子:target= "babac",stickers = {"ba","c","abcd"}
  • 至少需要两张贴纸"ba"和"abcd",因为使用这两张贴纸,把每一个字符单独剪开,含有2个a、2个b、1个c。是可以拼出target的。所以返回2。
  • 测试链接:https://leetcode.cn/problems/stickers-to-spell-word
2.5.1、暴力递归的方法
  • 暴力递归的实现

  • 思路:

    • 题目中每一张贴纸都是无穷数量的,可以重复用,所以在递归函数中,我们可以循环使用每一张贴纸,然后在目标字符target中减去这张贴纸的字符,
    • 得到新的目标字符,然后继续递归调用函数,直到目标字符为空,返回0,代表已经搞定了target。
    • 如果在递归的过程中,target减去当前的贴纸并没有变化,说明这种方法不可行,尝试其他的贴纸,
    • 每次递归中,将当前值和后续的递归的值取最小值,就是在target为当前状态下,最少需要的贴纸数。
    • 如果判断当前target状态下不能做到最小值,说明当前状态无效,返回后续调用的即可,如果当前状态有效,返回后续的最小值 + 1,1就是当前的尝试。
  • 总结:

    • 这种暴力尝试的方法起始就是每一次改变一下target,看看剩下的target能不能用贴纸搞定,变量只有target一个。
    • 因为在递归函数中,每次都要循环所有的贴纸,所以下一个递归的时候也会循环所有的贴纸,故能够枚举出所有贴纸拼接的情况,
    • 在当前target的状态下,选出尝试每一种贴纸的最小值,最后就能统计出所有情况的最小值。
  • 提交的时候函数名称改成:minStickers,会超时

java 复制代码
    /**
     * 暴力递归的实现
     * 思路:
     * 题目中每一张贴纸都是无穷数量的,可以重复用,所以在递归函数中,我们可以循环使用每一张贴纸,然后在目标字符target中减去这张贴纸的字符,
     * 得到新的目标字符,然后继续递归调用函数,直到目标字符为空,返回0,代表已经搞定了target。
     * 如果在递归的过程中,target减去当前的贴纸并没有变化,说明这种方法不可行,尝试其他的贴纸,
     * 每次递归中,将当前值和后续的递归的值取最小值,就是在target为当前状态下,最少需要的贴纸数。
     * 如果判断当前target状态下不能做到最小值,说明当前状态无效,返回后续调用的即可,如果当前状态有效,返回后续的最小值 + 1,1就是当前的尝试。
     * <br>
     * 总结:
     * 这种暴力尝试的方法起始就是每一次改变一下target,看看剩下的target能不能用贴纸搞定,变量只有target一个。
     * 因为在递归函数中,每次都要循环所有的贴纸,所以下一个递归的时候也会循环所有的贴纸,故能够枚举出所有贴纸拼接的情况,
     * 在当前target的状态下,选出尝试每一种贴纸的最小值,最后就能统计出所有情况的最小值。
     * <br>
     * 提交的时候函数名称改成:minStickers,会超时
     */
    public static int minStickers1(String[] stickers, String target) {
        int ans = process1(stickers, target);
        return ans == Integer.MAX_VALUE ? -1 : ans;
    }

    /**
     * 递归函数:返回最少张数
     * 在当前target的状态下,最少需要的贴纸数
     *
     * @param stickers :所有贴纸,每一种贴纸都有无穷张
     * @param target   :目标字符串
     * @return :最少张数
     */
    private static int process1(String[] stickers, String target) {
        // base case
        if (target.length() == 0) {
            return 0;
        }
        // 先定一个最小值,后续的递归中,取最小值,初始值为一个无效值,因为要取最小值,所以不能用-1,
        int min = Integer.MAX_VALUE;
        // 尝试每一张贴纸,看看能不能搞定当前的target,并将剩下的target递归继续调用
        for (String sticker : stickers) {
            // 减去当前贴纸的字符,得到新的目标字符
            String rest = minus(target, sticker);
            // 用了一张贴纸以后,如果没有变化,说明这个贴纸是不能用的,继续尝试下一张即可,如果有变化,递归调用获取最小值
            if (rest.length() != target.length()) {
                // 递归调用函数,得到新的目标字符的最少贴纸数
                min = Math.min(min, process1(stickers, rest));
            }
        }
        // 返回最终的结果,如果是无解,返回最大值(不能直接返回最大值+1,会溢出),如果是有解,返回最小值+1
        // 无解的意思是对于当前的target,在当前函数调用中,没有用到任何一张贴纸。
        return min + (min == Integer.MAX_VALUE ? 0 : 1);
    }

    /**
     * 从s1中减去s2中的字符
     */
    private static String minus(String s1, String s2) {
        char[] str1 = s1.toCharArray();
        char[] str2 = s2.toCharArray();
        int[] count = new int[26];
        for (char cha : str1) {
            count[cha - 'a']++;
        }
        for (char cha : str2) {
            count[cha - 'a']--;
        }
        StringBuilder builder = new StringBuilder();
        for (int i = 0; i < 26; i++) {
            if (count[i] > 0) {
                for (int j = 0; j < count[i]; j++) {
                    builder.append((char) (i + 'a'));
                }
            }
        }
        return builder.toString();
    }
2.5.2、暴力递归的优化
  • 暴力递归的优化

  • 思路:

    • 在递归函数process1中,我们要单独写一个minus方法,用来在target中去掉当前贴纸的字符,
    • minus方法要循环s1和s2两个字符串,时间复杂度比较高。
    • 因为题目中的字符是有限的,只有26个小写字母,我们就可以用数组的下标来表示字符的位置,值来表示数量,这样就能直接索引字符,减少时间复杂度。
  • 提交的时候函数名称改成:minStickers,会超时

java 复制代码
    /**
     * 暴力递归的优化
     * 思路:
     * 在递归函数process1中,我们要单独写一个minus方法,用来在target中去掉当前贴纸的字符,
     * minus方法要循环s1和s2两个字符串,时间复杂度比较高。
     * 因为题目中的字符是有限的,只有26个小写字母,我们就可以用数组的下标来表示字符的位置,值来表示数量,这样就能直接索引字符,减少时间复杂度。
     *
     * <br>
     * 提交的时候函数名称改成:minStickers,会超时
     */
    public static int minStickers2(String[] stickers, String target) {
        int N = stickers.length;
        // 关键优化(用词频表替代贴纸数组)
        int[][] counts = new int[N][26];
        for (int i = 0; i < N; i++) {
            char[] str = stickers[i].toCharArray();
            for (char cha : str) {
                counts[i][cha - 'a']++;
            }
        }
        int ans = process2(counts, target);
        return ans == Integer.MAX_VALUE ? -1 : ans;
    }

    /**
     * 递归函数:返回最少张数
     * 用词频统计的贴纸优化后的递归函数
     *
     * @param stickers :stickers[i] 数组,当初i号贴纸的字符统计 int[][] stickers -> 所有的贴纸
     * @param target   :目标字符串
     * @return :最少张数
     */
    private static int process2(int[][] stickers, String target) {
        if (target.length() == 0) {
            return 0;
        }
        // 先将target也做词频统计
        char[] targetChar = target.toCharArray();
        int[] targetCounts = new int[26];
        for (char cha : targetChar) {
            targetCounts[cha - 'a']++;
        }
        int min = Integer.MAX_VALUE;
        // 尝试每一张贴纸,看看能不能搞定
        for (int i = 0; i < stickers.length; i++) {
            // 尝试第一张贴纸是谁
            int[] sticker = stickers[i];
            // 最关键的优化(重要的剪枝!这一步也是贪心!)
            // 如果当前这张贴纸,包含了目标字符串的第一个字符,才去尝试用这张贴纸
            // 因为在消除的过程中,总会出现将目标字符串的第一个字符消去的情况,所以用只包含目标字符串第一个字符的贴纸去尝试,最终数量是不变的,只是尝试的顺序不一样
            if (sticker[targetChar[0] - 'a'] > 0) {
                StringBuilder builder = new StringBuilder();
                for (int j = 0; j < 26; j++) {
                    if (targetCounts[j] > 0) {
                        int nums = targetCounts[j] - sticker[j];
                        for (int k = 0; k < nums; k++) {
                            builder.append((char) (j + 'a'));
                        }
                    }
                }
                String rest = builder.toString();
                min = Math.min(min, process2(stickers, rest));
            }
        }
        return min + (min == Integer.MAX_VALUE ? 0 : 1);
    }
2.5.3、用动态规划的思想优化
  • 动态规划的优化

    • 用动态规划的方式优化递归函数process2,
    • 用一个HashMap来缓存已经计算过的结果,避免重复计算.
  • 思路:

    • 递归改成动态规划,首先要看有没有重复计算的问题,本题目中在重复尝试剩下的贴纸的时候,会出现重复的情况,所以能够改成动态规划的形式。
    • 因为这个题目比较复杂,可能性非常多,唯一的变量target是一个字符,而且位置不确定,无法简单用一个数组来缓存,所以直接将递归改成非递归的方式很麻烦,直接用缓存的方式优化递归函数,就可以达到效果。
    • 我们用一个HashMap来缓存已经计算过的结果,避免重复计算.key为剩余字符串,value为最少张数.
  • 提交的时候函数名称改成:minStickers

java 复制代码
    /**
     * 动态规划的优化
     * 用动态规划的方式优化递归函数process2,
     * 用一个HashMap来缓存已经计算过的结果,避免重复计算.
     * 思路:
     * 递归改成动态规划,首先要看有没有重复计算的问题,本题目中在重复尝试剩下的贴纸的时候,会出现重复的情况,所以能够改成动态规划的形式。
     * 因为这个题目比较复杂,可能性非常多,唯一的变量target是一个字符,而且位置不确定,无法简单用一个数组来缓存,所以直接将递归改成非递归的方式很麻烦,直接用缓存的方式优化递归函数,就可以达到效果。
     * 我们用一个HashMap来缓存已经计算过的结果,避免重复计算.key为剩余字符串,value为最少张数.
     * <br>
     * 提交的时候函数名称改成:minStickers
     */
    public static int minStickers3(String[] stickers, String target) {
        int N = stickers.length;
        int[][] counts = new int[N][26];
        for (int i = 0; i < N; i++) {
            char[] str = stickers[i].toCharArray();
            for (char cha : str) {
                counts[i][cha - 'a']++;
            }
        }
        HashMap<String, Integer> dp = new HashMap<>();
        dp.put("", 0);
        int ans = process3(counts, target, dp);
        return ans == Integer.MAX_VALUE ? -1 : ans;
    }

    /**
     * 用Map缓存的递归函数
     */
    private static int process3(int[][] stickers, String target, HashMap<String, Integer> dp) {
        if (dp.containsKey(target)) {
            return dp.get(target);
        }
        // 先将target也做词频统计
        char[] targetChar = target.toCharArray();
        int[] targetCounts = new int[26];
        for (char cha : targetChar) {
            targetCounts[cha - 'a']++;
        }
        int min = Integer.MAX_VALUE;
        // 尝试每一张贴纸,看看能不能搞定
        for (int i = 0; i < stickers.length; i++) {
            // 尝试第一张贴纸是谁
            int[] sticker = stickers[i];
            // 最关键的优化(重要的剪枝!这一步也是贪心!)
            // 如果当前这张贴纸,包含了目标字符串的第一个字符,才去尝试用这张贴纸
            // 因为在消除的过程中,总会出现将目标字符串的第一个字符消去的情况,所以用只包含目标字符串第一个字符的贴纸去尝试,最终数量是不变的,只是尝试的顺序不一样
            if (sticker[targetChar[0] - 'a'] > 0) {
                StringBuilder builder = new StringBuilder();
                for (int j = 0; j < 26; j++) {
                    if (targetCounts[j] > 0) {
                        int nums = targetCounts[j] - sticker[j];
                        for (int k = 0; k < nums; k++) {
                            builder.append((char) (j + 'a'));
                        }
                    }
                }
                String rest = builder.toString();
                min = Math.min(min, process3(stickers, rest, dp));
            }
        }
        int ans = min + (min == Integer.MAX_VALUE ? 0 : 1);
        dp.put(target, ans);
        return ans;
    }

整体代码如下:

java 复制代码
import java.util.HashMap;

/**
 * 题目五:贴纸拼词
 * 给定一个字符串target,给定一个字符串类型的数组stickers。
 * stickers里的每一个字符串,代表一张贴纸,你可以把单个字符剪开使用,目的是拼出target来。
 * 返回需要至少多少张贴纸可以完成这个任务。
 * 例子:target= "babac",stickers = {"ba","c","abcd"}
 * 至少需要两张贴纸"ba"和"abcd",因为使用这两张贴纸,把每一个字符单独剪开,含有2个a、2个b、1个c。是可以拼出target的。所以返回2。
 * 测试链接:https://leetcode.cn/problems/stickers-to-spell-word
 */
public class Q05_StickersToSpellWord {

    /**
     * 暴力递归的实现
     * 思路:
     * 题目中每一张贴纸都是无穷数量的,可以重复用,所以在递归函数中,我们可以循环使用每一张贴纸,然后在目标字符target中减去这张贴纸的字符,
     * 得到新的目标字符,然后继续递归调用函数,直到目标字符为空,返回0,代表已经搞定了target。
     * 如果在递归的过程中,target减去当前的贴纸并没有变化,说明这种方法不可行,尝试其他的贴纸,
     * 每次递归中,将当前值和后续的递归的值取最小值,就是在target为当前状态下,最少需要的贴纸数。
     * 如果判断当前target状态下不能做到最小值,说明当前状态无效,返回后续调用的即可,如果当前状态有效,返回后续的最小值 + 1,1就是当前的尝试。
     * <br>
     * 总结:
     * 这种暴力尝试的方法起始就是每一次改变一下target,看看剩下的target能不能用贴纸搞定,变量只有target一个。
     * 因为在递归函数中,每次都要循环所有的贴纸,所以下一个递归的时候也会循环所有的贴纸,故能够枚举出所有贴纸拼接的情况,
     * 在当前target的状态下,选出尝试每一种贴纸的最小值,最后就能统计出所有情况的最小值。
     * <br>
     * 提交的时候函数名称改成:minStickers,会超时
     */
    public static int minStickers1(String[] stickers, String target) {
        int ans = process1(stickers, target);
        return ans == Integer.MAX_VALUE ? -1 : ans;
    }

    /**
     * 递归函数:返回最少张数
     * 在当前target的状态下,最少需要的贴纸数
     *
     * @param stickers :所有贴纸,每一种贴纸都有无穷张
     * @param target   :目标字符串
     * @return :最少张数
     */
    private static int process1(String[] stickers, String target) {
        // base case
        if (target.length() == 0) {
            return 0;
        }
        // 先定一个最小值,后续的递归中,取最小值,初始值为一个无效值,因为要取最小值,所以不能用-1,
        int min = Integer.MAX_VALUE;
        // 尝试每一张贴纸,看看能不能搞定当前的target,并将剩下的target递归继续调用
        for (String sticker : stickers) {
            // 减去当前贴纸的字符,得到新的目标字符
            String rest = minus(target, sticker);
            // 用了一张贴纸以后,如果没有变化,说明这个贴纸是不能用的,继续尝试下一张即可,如果有变化,递归调用获取最小值
            if (rest.length() != target.length()) {
                // 递归调用函数,得到新的目标字符的最少贴纸数
                min = Math.min(min, process1(stickers, rest));
            }
        }
        // 返回最终的结果,如果是无解,返回最大值(不能直接返回最大值+1,会溢出),如果是有解,返回最小值+1
        // 无解的意思是对于当前的target,在当前函数调用中,没有用到任何一张贴纸。
        return min + (min == Integer.MAX_VALUE ? 0 : 1);
    }

    /**
     * 从s1中减去s2中的字符
     */
    private static String minus(String s1, String s2) {
        char[] str1 = s1.toCharArray();
        char[] str2 = s2.toCharArray();
        int[] count = new int[26];
        for (char cha : str1) {
            count[cha - 'a']++;
        }
        for (char cha : str2) {
            count[cha - 'a']--;
        }
        StringBuilder builder = new StringBuilder();
        for (int i = 0; i < 26; i++) {
            if (count[i] > 0) {
                for (int j = 0; j < count[i]; j++) {
                    builder.append((char) (i + 'a'));
                }
            }
        }
        return builder.toString();
    }

    /**
     * 暴力递归的优化
     * 思路:
     * 在递归函数process1中,我们要单独写一个minus方法,用来在target中去掉当前贴纸的字符,
     * minus方法要循环s1和s2两个字符串,时间复杂度比较高。
     * 因为题目中的字符是有限的,只有26个小写字母,我们就可以用数组的下标来表示字符的位置,值来表示数量,这样就能直接索引字符,减少时间复杂度。
     *
     * <br>
     * 提交的时候函数名称改成:minStickers,会超时
     */
    public static int minStickers2(String[] stickers, String target) {
        int N = stickers.length;
        // 关键优化(用词频表替代贴纸数组)
        int[][] counts = new int[N][26];
        for (int i = 0; i < N; i++) {
            char[] str = stickers[i].toCharArray();
            for (char cha : str) {
                counts[i][cha - 'a']++;
            }
        }
        int ans = process2(counts, target);
        return ans == Integer.MAX_VALUE ? -1 : ans;
    }

    /**
     * 递归函数:返回最少张数
     * 用词频统计的贴纸优化后的递归函数
     *
     * @param stickers :stickers[i] 数组,当初i号贴纸的字符统计 int[][] stickers -> 所有的贴纸
     * @param target   :目标字符串
     * @return :最少张数
     */
    private static int process2(int[][] stickers, String target) {
        if (target.length() == 0) {
            return 0;
        }
        // 先将target也做词频统计
        char[] targetChar = target.toCharArray();
        int[] targetCounts = new int[26];
        for (char cha : targetChar) {
            targetCounts[cha - 'a']++;
        }
        int min = Integer.MAX_VALUE;
        // 尝试每一张贴纸,看看能不能搞定
        for (int i = 0; i < stickers.length; i++) {
            // 尝试第一张贴纸是谁
            int[] sticker = stickers[i];
            // 最关键的优化(重要的剪枝!这一步也是贪心!)
            // 如果当前这张贴纸,包含了目标字符串的第一个字符,才去尝试用这张贴纸
            // 因为在消除的过程中,总会出现将目标字符串的第一个字符消去的情况,所以用只包含目标字符串第一个字符的贴纸去尝试,最终数量是不变的,只是尝试的顺序不一样
            if (sticker[targetChar[0] - 'a'] > 0) {
                StringBuilder builder = new StringBuilder();
                for (int j = 0; j < 26; j++) {
                    if (targetCounts[j] > 0) {
                        int nums = targetCounts[j] - sticker[j];
                        for (int k = 0; k < nums; k++) {
                            builder.append((char) (j + 'a'));
                        }
                    }
                }
                String rest = builder.toString();
                min = Math.min(min, process2(stickers, rest));
            }
        }
        return min + (min == Integer.MAX_VALUE ? 0 : 1);
    }

    /**
     * 动态规划的优化
     * 用动态规划的方式优化递归函数process2,
     * 用一个HashMap来缓存已经计算过的结果,避免重复计算.
     * 思路:
     * 递归改成动态规划,首先要看有没有重复计算的问题,本题目中在重复尝试剩下的贴纸的时候,会出现重复的情况,所以能够改成动态规划的形式。
     * 因为这个题目比较复杂,可能性非常多,唯一的变量target是一个字符,而且位置不确定,无法简单用一个数组来缓存,所以直接将递归改成非递归的方式很麻烦,直接用缓存的方式优化递归函数,就可以达到效果。
     * 我们用一个HashMap来缓存已经计算过的结果,避免重复计算.key为剩余字符串,value为最少张数.
     * <br>
     * 提交的时候函数名称改成:minStickers
     */
    public static int minStickers3(String[] stickers, String target) {
        int N = stickers.length;
        int[][] counts = new int[N][26];
        for (int i = 0; i < N; i++) {
            char[] str = stickers[i].toCharArray();
            for (char cha : str) {
                counts[i][cha - 'a']++;
            }
        }
        HashMap<String, Integer> dp = new HashMap<>();
        dp.put("", 0);
        int ans = process3(counts, target, dp);
        return ans == Integer.MAX_VALUE ? -1 : ans;
    }

    /**
     * 用Map缓存的递归函数
     */
    private static int process3(int[][] stickers, String target, HashMap<String, Integer> dp) {
        if (dp.containsKey(target)) {
            return dp.get(target);
        }
        // 先将target也做词频统计
        char[] targetChar = target.toCharArray();
        int[] targetCounts = new int[26];
        for (char cha : targetChar) {
            targetCounts[cha - 'a']++;
        }
        int min = Integer.MAX_VALUE;
        // 尝试每一张贴纸,看看能不能搞定
        for (int i = 0; i < stickers.length; i++) {
            // 尝试第一张贴纸是谁
            int[] sticker = stickers[i];
            // 最关键的优化(重要的剪枝!这一步也是贪心!)
            // 如果当前这张贴纸,包含了目标字符串的第一个字符,才去尝试用这张贴纸
            // 因为在消除的过程中,总会出现将目标字符串的第一个字符消去的情况,所以用只包含目标字符串第一个字符的贴纸去尝试,最终数量是不变的,只是尝试的顺序不一样
            if (sticker[targetChar[0] - 'a'] > 0) {
                StringBuilder builder = new StringBuilder();
                for (int j = 0; j < 26; j++) {
                    if (targetCounts[j] > 0) {
                        int nums = targetCounts[j] - sticker[j];
                        for (int k = 0; k < nums; k++) {
                            builder.append((char) (j + 'a'));
                        }
                    }
                }
                String rest = builder.toString();
                min = Math.min(min, process3(stickers, rest, dp));
            }
        }
        int ans = min + (min == Integer.MAX_VALUE ? 0 : 1);
        dp.put(target, ans);
        return ans;
    }
}

2.6、题目六:最长公共子序列

  • 题目六:最长公共子序列
  • 给定两个字符串 s1 和 s2,返回这两个字符串的最长 公共子序列 的长度。如果不存在 公共子序列 ,返回 0 。
  • 一个字符串的 子序列 是指这样一个新的字符串:它是由原字符串在不改变字符的相对顺序的情况下删除某些字符(也可以不删除任何字符)后组成的新字符串。
  • 例如,"ace" 是 "abcde" 的子序列,但 "aec" 不是 "abcde" 的子序列。
  • 两个字符串的公共子序列是这两个字符串所共同拥有的子序列。
  • 子序列和子串的区别:子序列不要求连续,子串要求连续
  • 测试链接:https://leetcode.cn/problems/longest-common-subsequence/
2.6.1、暴力递归的方法
  • 暴力递归尝试

  • 思路:

    • 两个样本对应模型(一个为行,一个为列),从右到左尝试可能性,这是常用的思路。
  • str1[0...i]和str2[0...j],这个范围上最长公共子序列长度是多少?

    • 可能性分类:
    • a) 最长公共子序列,一定不以str1[i]字符结尾、也一定不以str2[j]字符结尾
    • b) 最长公共子序列,可能以str1[i]字符结尾、但是一定不以str2[j]字符结尾
    • c) 最长公共子序列,一定不以str1[i]字符结尾、但是可能以str2[j]字符结尾
    • d) 最长公共子序列,必须以str1[i]字符结尾、也必须以str2[j]字符结尾
    • 注意:a)、b)、c)、d)并不是完全互斥的,他们可能会有重叠的情况
    • 但是可以肯定,答案不会超过这四种可能性的范围
  • 那么我们分别来看一下,这几种可能性怎么调用后续的递归。

    • a) 最长公共子序列,一定不以str1[i]字符结尾、也一定不以str2[j]字符结尾
    • 如果是这种情况,那么有没有str1[i]和str2[j]就根本不重要了,因为这两个字符一定没用啊
    • 所以砍掉这两个字符,最长公共子序列 = str1[0...i-1]与str2[0...j-1]的最长公共子序列长度(后续递归)
    • b) 最长公共子序列,可能以str1[i]字符结尾、但是一定不以str2[j]字符结尾
    • 如果是这种情况,那么我们可以确定str2[j]一定没有用,要砍掉;但是str1[i]可能有用,所以要保留
    • 所以,最长公共子序列 = str1[0...i]与str2[0...j-1]的最长公共子序列长度(后续递归)
    • c) 最长公共子序列,一定不以str1[i]字符结尾、但是可能以str2[j]字符结尾
    • 跟上面分析过程类似,最长公共子序列 = str1[0...i-1]与str2[0...j]的最长公共子序列长度(后续递归)
    • d) 最长公共子序列,必须以str1[i]字符结尾、也必须以str2[j]字符结尾
    • 同时可以看到,可能性d)存在的条件,一定是在str1[i] == str2[j]的情况下,才成立的
    • 所以,最长公共子序列总长度 = str1[0...i-1]与str2[0...j-1]的最长公共子序列长度(后续递归) + 1(共同的结尾)
    • 综上,四种情况已经穷尽了所有可能性。四种情况中取最大即可
    • 其中b)、c)一定参与最大值的比较,
    • 当str1[i] == str2[j]时,a)一定比d)小,所以d)参与
    • 当str1[i] != str2[j]时,d)压根不存在,所以a)参与
  • 但是再次注意了!

    • a)是:str1[0...i-1]与str2[0...j-1]的最长公共子序列长度
    • b)是:str1[0...i]与str2[0...j-1]的最长公共子序列长度
    • c)是:str1[0...i-1]与str2[0...j]的最长公共子序列长度
    • 情况a)中str1的范围 < 情况b)中str1的范围,情况a)中str2的范围 == 情况b)中str2的范围
    • 所以情况a)不用求也知道,它比不过情况b)啊,因为有一个样本的范围比情况b)小啊!也就是说情况b)的范围包含了情况a)的范围,情况b)的结果也会包含情况a)的结果。
    • 情况a)中str1的范围 == 情况c)中str1的范围,情况a)中str2的范围 < 情况c)中str2的范围
    • 所以情况a)不用求也知道,它比不过情况c)啊,因为有一个样本的范围比情况c)小啊!也就是说情况c)的范围包含了情况a)的范围,情况c)的结果也会包含情况a)的结果。
    • 至此,可以知道,情况a)就是个垃圾,有它没它,都不影响最大值的决策
    • 所以,通过上面的分析,就可以总结出下面两种情况
    • 当str1[i] == str2[j]时,情况b)、情况c)、情况d)中选出最大值
    • 当str1[i] != str2[j]时,情况b)、情况c)中选出最大值
  • 提交时方法名改为:longestCommonSubsequence,会超时

java 复制代码
    /**
     * 暴力递归尝试
     * 思路:
     * 两个样本对应模型(一个为行,一个为列),从右到左尝试可能性,这是常用的思路。
     * <br>
     * str1[0...i]和str2[0...j],这个范围上最长公共子序列长度是多少?
     * 可能性分类:
     * a) 最长公共子序列,一定不以str1[i]字符结尾、也一定不以str2[j]字符结尾
     * b) 最长公共子序列,可能以str1[i]字符结尾、但是一定不以str2[j]字符结尾
     * c) 最长公共子序列,一定不以str1[i]字符结尾、但是可能以str2[j]字符结尾
     * d) 最长公共子序列,必须以str1[i]字符结尾、也必须以str2[j]字符结尾
     * 注意:a)、b)、c)、d)并不是完全互斥的,他们可能会有重叠的情况
     * 但是可以肯定,答案不会超过这四种可能性的范围
     * <br>
     * 那么我们分别来看一下,这几种可能性怎么调用后续的递归。
     * a) 最长公共子序列,一定不以str1[i]字符结尾、也一定不以str2[j]字符结尾
     * 如果是这种情况,那么有没有str1[i]和str2[j]就根本不重要了,因为这两个字符一定没用啊
     * 所以砍掉这两个字符,最长公共子序列 = str1[0...i-1]与str2[0...j-1]的最长公共子序列长度(后续递归)
     * b) 最长公共子序列,可能以str1[i]字符结尾、但是一定不以str2[j]字符结尾
     * 如果是这种情况,那么我们可以确定str2[j]一定没有用,要砍掉;但是str1[i]可能有用,所以要保留
     * 所以,最长公共子序列 = str1[0...i]与str2[0...j-1]的最长公共子序列长度(后续递归)
     * c) 最长公共子序列,一定不以str1[i]字符结尾、但是可能以str2[j]字符结尾
     * 跟上面分析过程类似,最长公共子序列 = str1[0...i-1]与str2[0...j]的最长公共子序列长度(后续递归)
     * d) 最长公共子序列,必须以str1[i]字符结尾、也必须以str2[j]字符结尾
     * 同时可以看到,可能性d)存在的条件,一定是在str1[i] == str2[j]的情况下,才成立的
     * 所以,最长公共子序列总长度 = str1[0...i-1]与str2[0...j-1]的最长公共子序列长度(后续递归) + 1(共同的结尾)
     * 综上,四种情况已经穷尽了所有可能性。四种情况中取最大即可
     * 其中b)、c)一定参与最大值的比较,
     * 当str1[i] == str2[j]时,a)一定比d)小,所以d)参与
     * 当str1[i] != str2[j]时,d)压根不存在,所以a)参与
     * <br>
     * 但是再次注意了!
     * a)是:str1[0...i-1]与str2[0...j-1]的最长公共子序列长度
     * b)是:str1[0...i]与str2[0...j-1]的最长公共子序列长度
     * c)是:str1[0...i-1]与str2[0...j]的最长公共子序列长度
     * 情况a)中str1的范围 < 情况b)中str1的范围,情况a)中str2的范围 == 情况b)中str2的范围
     * 所以情况a)不用求也知道,它比不过情况b)啊,因为有一个样本的范围比情况b)小啊!也就是说情况b)的范围包含了情况a)的范围,情况b)的结果也会包含情况a)的结果。
     * 情况a)中str1的范围 == 情况c)中str1的范围,情况a)中str2的范围 < 情况c)中str2的范围
     * 所以情况a)不用求也知道,它比不过情况c)啊,因为有一个样本的范围比情况c)小啊!也就是说情况c)的范围包含了情况a)的范围,情况c)的结果也会包含情况a)的结果。
     * 至此,可以知道,情况a)就是个垃圾,有它没它,都不影响最大值的决策
     * 所以,通过上面的分析,就可以总结出下面两种情况
     * 当str1[i] == str2[j]时,情况b)、情况c)、情况d)中选出最大值
     * 当str1[i] != str2[j]时,情况b)、情况c)中选出最大值
     * <br>
     * 提交时方法名改为:longestCommonSubsequence,会超时
     */
    public static int longestCommonSubsequence1(String s1, String s2) {
        if (s1 == null || s2 == null || s1.length() == 0 || s2.length() == 0) {
            return 0;
        }
        char[] str1 = s1.toCharArray();
        char[] str2 = s2.toCharArray();
        // 调用从右往左的递归函数
        return process1(str1, str2, str1.length - 1, str2.length - 1);
    }

    /**
     * 递归函数,求str1和str2的最长公共子序列长度
     * 思路:
     * 通过上面的分析,最终的结论如下:
     * 当str1[i] == str2[j]时,情况b)、情况c)、情况d)中选出最大值
     * 当str1[i] != str2[j]时,情况b)、情况c)中选出最大值
     * 这是常用的思路,也是比较容易理解的思路。
     */
    private static int process1(char[] str1, char[] str2, int i, int j) {
        // base case,这里的i和j都有可能到0,所以有三种base case
        if (i == 0 && j == 0) {
            // str1[0..0]和str2[0..0],都只剩一个字符了
            // 那如果字符相等,公共子序列长度就是1,不相等就是0
            // 这显而易见
            // base case1
            return str1[i] == str2[j] ? 1 : 0;
        } else if (i == 0) {
            // 这里的情况为:
            // str1[0...0]和str2[0...j],str1只剩1个字符了,但是str2不只一个字符
            // 因为str1只剩一个字符了,所以str1[0...0]和str2[0...j]公共子序列最多长度为1
            // 如果str1[0] == str2[j],那么此时相等已经找到了!公共子序列长度就是1,也不可能更大了
            // 如果str1[0] != str2[j],只是此时不相等而已,
            // 那么str2[0...j-1]上有没有字符等于str1[0]呢?不知道,所以递归继续找
            // base case2
            if (str1[i] == str2[j]) {
                return 1;
            } else {
                // 一直递归调用,直到str2的下标也变成0
                return process1(str1, str2, i, j - 1);
            }
        } else if (j == 0) {
            // 和上面的else if同理
            // str1[0...i]和str2[0...0],str2只剩1个字符了,但是str1不只一个字符
            // 因为str2只剩一个字符了,所以str1[0...i]和str2[0...0]公共子序列最多长度为1
            // 如果str1[i] == str2[0],那么此时相等已经找到了!公共子序列长度就是1,也不可能更大了
            // 如果str1[i] != str2[0],只是此时不相等而已,
            // 那么str1[0...i-1]上有没有字符等于str2[0]呢?不知道,所以递归继续找
            // base case3
            if (str1[i] == str2[j]) {
                return 1;
            } else {
                return process1(str1, str2, i - 1, j);
            }
        }
        // 到这里,说明i和j都不是0
        // 这里的情况为:
        // str1[0...i]和str2[0...i],str1和str2都不只一个字符
        // 看函数开始之前的注释部分,情况b和情况a是相似的
        // 情况b的,str1[0...i]和str2[0...j-1]
        int bRes = process1(str1, str2, i, j - 1);
        // 情况c的,也就是str1[0..i-1],str2[0..j]
        int cRes = process1(str1, str2, i - 1, j);
        // 然后要看情况d
        // dRes就是可能性d),如果可能性d)存在,即str1[i] == str2[j],那么dRes就求出来,参与pk
        // 如果可能性d)不存在,即str1[i] != str2[j],那么让dRes等于0,然后去参与pk,反正不影响
        int dRes = str1[i] == str2[j] ? (process1(str1, str2, i - 1, j - 1) + 1) : 0;
        // 最后,情况b)、情况c)、情况d)中选出最大值,就是str1[0...i]和str2[0...j]的最长公共子序列长度
        return Math.max(bRes, Math.max(cRes, dRes));
    }
2.6.2、动态规划的方法
  • 动态规划的方法

    • 由前面的暴力递归改为动态规划
  • 思路:

    • 上面的递归函数process1中,i和j的范围都是0到str1.length-1(N)和0到str2.length-1(M),所以,我们可以用一个二维数组dp[N][M]来做缓存表。
    • 根据上面的三种base case,可以得出以下的初始化:
    • dp[0][0] = str1[0] == str2[0] ? 1 : 0;
    • 第0行的值为dp[0][j] = str1[0] == str2[j] ? 1 : dp[0][j-1];
    • 第0列的值为dp[i][0] = str1[i] == str2[0] ? 1 : dp[i-1][0];
    • 其他位置从行和列都是从1开始,到最大值,然后取三种情况的最大值,
    • 根据第一次调用递归函数,返回值为dp[N-1][M-1]
  • 总结:

    • 这道题目最主要的难点是两个样本对应模型(一个为行,一个为列)的尝试问题,一般用的是从右往左尝试的办法。
    • 然后就是要分析清楚尝试的可能性,最后提炼出尝试的递归函数。
    • 至于从暴力递归到动态规划的改造,就是很简单的事情,按照改造步骤直接写就行。
  • 提交时方法名改为:longestCommonSubsequence

java 复制代码
    /**
     * 动态规划的方法
     * 由前面的暴力递归改为动态规划
     * 思路:
     * 上面的递归函数process1中,i和j的范围都是0到str1.length-1(N)和0到str2.length-1(M),所以,我们可以用一个二维数组dp[N][M]来做缓存表。
     * 根据上面的三种base case,可以得出以下的初始化:
     * dp[0][0] = str1[0] == str2[0] ? 1 : 0;
     * 第0行的值为dp[0][j] = str1[0] == str2[j] ? 1 : dp[0][j-1];
     * 第0列的值为dp[i][0] = str1[i] == str2[0] ? 1 : dp[i-1][0];
     * 其他位置从行和列都是从1开始,到最大值,然后取三种情况的最大值,
     * 根据第一次调用递归函数,返回值为dp[N-1][M-1]
     * <br>
     * 总结:
     * 这道题目最主要的难点是两个样本对应模型(一个为行,一个为列)的尝试问题,一般用的是从右往左尝试的办法。
     * 然后就是要分析清楚尝试的可能性,最后提炼出尝试的递归函数。
     * 至于从暴力递归到动态规划的改造,就是很简单的事情,按照改造步骤直接写就行。
     * <br>
     * 提交时方法名改为:longestCommonSubsequence
     */
    public static int longestCommonSubsequence2(String s1, String s2) {
        if (s1 == null || s2 == null || s1.length() == 0 || s2.length() == 0) {
            return 0;
        }
        char[] str1 = s1.toCharArray();
        char[] str2 = s2.toCharArray();
        int N = str1.length;
        int M = str2.length;
        // 缓存数组
        int[][] dp = new int[N][M];
        // 根据base case做初始化
        // base case1
        dp[0][0] = str1[0] == str2[0] ? 1 : 0;
        // base case2
        for (int j = 1; j < M; j++) {
            dp[0][j] = str1[0] == str2[j] ? 1 : dp[0][j - 1];
        }
        // base case3
        for (int i = 1; i < N; i++) {
            dp[i][0] = str1[i] == str2[0] ? 1 : dp[i - 1][0];
        }
        // 填充其他位置的值
        for (int i = 1; i < N; i++) {
            for (int j = 1; j < M; j++) {
                int bRes = dp[i][j - 1];
                int cRes = dp[i - 1][j];
                int dRes = str1[i] == str2[j] ? (dp[i - 1][j - 1] + 1) : 0;
                dp[i][j] = Math.max(bRes, Math.max(cRes, dRes));
            }
        }
        // 返回值
        return dp[N - 1][M - 1];
    }

整体代码如下:

java 复制代码
/**
 * 题目六:最长公共子序列
 * 给定两个字符串 s1 和 s2,返回这两个字符串的最长 公共子序列 的长度。如果不存在 公共子序列 ,返回 0 。
 * 一个字符串的 子序列 是指这样一个新的字符串:它是由原字符串在不改变字符的相对顺序的情况下删除某些字符(也可以不删除任何字符)后组成的新字符串。
 * 例如,"ace" 是 "abcde" 的子序列,但 "aec" 不是 "abcde" 的子序列。
 * 两个字符串的公共子序列是这两个字符串所共同拥有的子序列。
 * 子序列和子串的区别:子序列不要求连续,子串要求连续
 * 测试链接:https://leetcode.cn/problems/longest-common-subsequence/
 */
public class Q06_LongestCommonSubsequence {
    /**
     * 暴力递归尝试
     * 思路:
     * 两个样本对应模型(一个为行,一个为列),从右到左尝试可能性,这是常用的思路。
     * <br>
     * str1[0...i]和str2[0...j],这个范围上最长公共子序列长度是多少?
     * 可能性分类:
     * a) 最长公共子序列,一定不以str1[i]字符结尾、也一定不以str2[j]字符结尾
     * b) 最长公共子序列,可能以str1[i]字符结尾、但是一定不以str2[j]字符结尾
     * c) 最长公共子序列,一定不以str1[i]字符结尾、但是可能以str2[j]字符结尾
     * d) 最长公共子序列,必须以str1[i]字符结尾、也必须以str2[j]字符结尾
     * 注意:a)、b)、c)、d)并不是完全互斥的,他们可能会有重叠的情况
     * 但是可以肯定,答案不会超过这四种可能性的范围
     * <br>
     * 那么我们分别来看一下,这几种可能性怎么调用后续的递归。
     * a) 最长公共子序列,一定不以str1[i]字符结尾、也一定不以str2[j]字符结尾
     * 如果是这种情况,那么有没有str1[i]和str2[j]就根本不重要了,因为这两个字符一定没用啊
     * 所以砍掉这两个字符,最长公共子序列 = str1[0...i-1]与str2[0...j-1]的最长公共子序列长度(后续递归)
     * b) 最长公共子序列,可能以str1[i]字符结尾、但是一定不以str2[j]字符结尾
     * 如果是这种情况,那么我们可以确定str2[j]一定没有用,要砍掉;但是str1[i]可能有用,所以要保留
     * 所以,最长公共子序列 = str1[0...i]与str2[0...j-1]的最长公共子序列长度(后续递归)
     * c) 最长公共子序列,一定不以str1[i]字符结尾、但是可能以str2[j]字符结尾
     * 跟上面分析过程类似,最长公共子序列 = str1[0...i-1]与str2[0...j]的最长公共子序列长度(后续递归)
     * d) 最长公共子序列,必须以str1[i]字符结尾、也必须以str2[j]字符结尾
     * 同时可以看到,可能性d)存在的条件,一定是在str1[i] == str2[j]的情况下,才成立的
     * 所以,最长公共子序列总长度 = str1[0...i-1]与str2[0...j-1]的最长公共子序列长度(后续递归) + 1(共同的结尾)
     * 综上,四种情况已经穷尽了所有可能性。四种情况中取最大即可
     * 其中b)、c)一定参与最大值的比较,
     * 当str1[i] == str2[j]时,a)一定比d)小,所以d)参与
     * 当str1[i] != str2[j]时,d)压根不存在,所以a)参与
     * <br>
     * 但是再次注意了!
     * a)是:str1[0...i-1]与str2[0...j-1]的最长公共子序列长度
     * b)是:str1[0...i]与str2[0...j-1]的最长公共子序列长度
     * c)是:str1[0...i-1]与str2[0...j]的最长公共子序列长度
     * 情况a)中str1的范围 < 情况b)中str1的范围,情况a)中str2的范围 == 情况b)中str2的范围
     * 所以情况a)不用求也知道,它比不过情况b)啊,因为有一个样本的范围比情况b)小啊!也就是说情况b)的范围包含了情况a)的范围,情况b)的结果也会包含情况a)的结果。
     * 情况a)中str1的范围 == 情况c)中str1的范围,情况a)中str2的范围 < 情况c)中str2的范围
     * 所以情况a)不用求也知道,它比不过情况c)啊,因为有一个样本的范围比情况c)小啊!也就是说情况c)的范围包含了情况a)的范围,情况c)的结果也会包含情况a)的结果。
     * 至此,可以知道,情况a)就是个垃圾,有它没它,都不影响最大值的决策
     * 所以,通过上面的分析,就可以总结出下面两种情况
     * 当str1[i] == str2[j]时,情况b)、情况c)、情况d)中选出最大值
     * 当str1[i] != str2[j]时,情况b)、情况c)中选出最大值
     * <br>
     * 提交时方法名改为:longestCommonSubsequence,会超时
     */
    public static int longestCommonSubsequence1(String s1, String s2) {
        if (s1 == null || s2 == null || s1.length() == 0 || s2.length() == 0) {
            return 0;
        }
        char[] str1 = s1.toCharArray();
        char[] str2 = s2.toCharArray();
        // 调用从右往左的递归函数
        return process1(str1, str2, str1.length - 1, str2.length - 1);
    }

    /**
     * 递归函数,求str1和str2的最长公共子序列长度
     * 思路:
     * 通过上面的分析,最终的结论如下:
     * 当str1[i] == str2[j]时,情况b)、情况c)、情况d)中选出最大值
     * 当str1[i] != str2[j]时,情况b)、情况c)中选出最大值
     * 这是常用的思路,也是比较容易理解的思路。
     */
    private static int process1(char[] str1, char[] str2, int i, int j) {
        // base case,这里的i和j都有可能到0,所以有三种base case
        if (i == 0 && j == 0) {
            // str1[0..0]和str2[0..0],都只剩一个字符了
            // 那如果字符相等,公共子序列长度就是1,不相等就是0
            // 这显而易见
            // base case1
            return str1[i] == str2[j] ? 1 : 0;
        } else if (i == 0) {
            // 这里的情况为:
            // str1[0...0]和str2[0...j],str1只剩1个字符了,但是str2不只一个字符
            // 因为str1只剩一个字符了,所以str1[0...0]和str2[0...j]公共子序列最多长度为1
            // 如果str1[0] == str2[j],那么此时相等已经找到了!公共子序列长度就是1,也不可能更大了
            // 如果str1[0] != str2[j],只是此时不相等而已,
            // 那么str2[0...j-1]上有没有字符等于str1[0]呢?不知道,所以递归继续找
            // base case2
            if (str1[i] == str2[j]) {
                return 1;
            } else {
                // 一直递归调用,直到str2的下标也变成0
                return process1(str1, str2, i, j - 1);
            }
        } else if (j == 0) {
            // 和上面的else if同理
            // str1[0...i]和str2[0...0],str2只剩1个字符了,但是str1不只一个字符
            // 因为str2只剩一个字符了,所以str1[0...i]和str2[0...0]公共子序列最多长度为1
            // 如果str1[i] == str2[0],那么此时相等已经找到了!公共子序列长度就是1,也不可能更大了
            // 如果str1[i] != str2[0],只是此时不相等而已,
            // 那么str1[0...i-1]上有没有字符等于str2[0]呢?不知道,所以递归继续找
            // base case3
            if (str1[i] == str2[j]) {
                return 1;
            } else {
                return process1(str1, str2, i - 1, j);
            }
        }
        // 到这里,说明i和j都不是0
        // 这里的情况为:
        // str1[0...i]和str2[0...i],str1和str2都不只一个字符
        // 看函数开始之前的注释部分,情况b和情况a是相似的
        // 情况b的,str1[0...i]和str2[0...j-1]
        int bRes = process1(str1, str2, i, j - 1);
        // 情况c的,也就是str1[0..i-1],str2[0..j]
        int cRes = process1(str1, str2, i - 1, j);
        // 然后要看情况d
        // dRes就是可能性d),如果可能性d)存在,即str1[i] == str2[j],那么dRes就求出来,参与pk
        // 如果可能性d)不存在,即str1[i] != str2[j],那么让dRes等于0,然后去参与pk,反正不影响
        int dRes = str1[i] == str2[j] ? (process1(str1, str2, i - 1, j - 1) + 1) : 0;
        // 最后,情况b)、情况c)、情况d)中选出最大值,就是str1[0...i]和str2[0...j]的最长公共子序列长度
        return Math.max(bRes, Math.max(cRes, dRes));
    }

    /**
     * 动态规划的方法
     * 由前面的暴力递归改为动态规划
     * 思路:
     * 上面的递归函数process1中,i和j的范围都是0到str1.length-1(N)和0到str2.length-1(M),所以,我们可以用一个二维数组dp[N][M]来做缓存表。
     * 根据上面的三种base case,可以得出以下的初始化:
     * dp[0][0] = str1[0] == str2[0] ? 1 : 0;
     * 第0行的值为dp[0][j] = str1[0] == str2[j] ? 1 : dp[0][j-1];
     * 第0列的值为dp[i][0] = str1[i] == str2[0] ? 1 : dp[i-1][0];
     * 其他位置从行和列都是从1开始,到最大值,然后取三种情况的最大值,
     * 根据第一次调用递归函数,返回值为dp[N-1][M-1]
     * <br>
     * 总结:
     * 这道题目最主要的难点是两个样本对应模型(一个为行,一个为列)的尝试问题,一般用的是从右往左尝试的办法。
     * 然后就是要分析清楚尝试的可能性,最后提炼出尝试的递归函数。
     * 至于从暴力递归到动态规划的改造,就是很简单的事情,按照改造步骤直接写就行。
     * <br>
     * 提交时方法名改为:longestCommonSubsequence
     */
    public static int longestCommonSubsequence2(String s1, String s2) {
        if (s1 == null || s2 == null || s1.length() == 0 || s2.length() == 0) {
            return 0;
        }
        char[] str1 = s1.toCharArray();
        char[] str2 = s2.toCharArray();
        int N = str1.length;
        int M = str2.length;
        // 缓存数组
        int[][] dp = new int[N][M];
        // 根据base case做初始化
        // base case1
        dp[0][0] = str1[0] == str2[0] ? 1 : 0;
        // base case2
        for (int j = 1; j < M; j++) {
            dp[0][j] = str1[0] == str2[j] ? 1 : dp[0][j - 1];
        }
        // base case3
        for (int i = 1; i < N; i++) {
            dp[i][0] = str1[i] == str2[0] ? 1 : dp[i - 1][0];
        }
        // 填充其他位置的值
        for (int i = 1; i < N; i++) {
            for (int j = 1; j < M; j++) {
                int bRes = dp[i][j - 1];
                int cRes = dp[i - 1][j];
                int dRes = str1[i] == str2[j] ? (dp[i - 1][j - 1] + 1) : 0;
                dp[i][j] = Math.max(bRes, Math.max(cRes, dRes));
            }
        }
        // 返回值
        return dp[N - 1][M - 1];
    }
}

后记

个人学习总结笔记,不能保证非常详细,轻喷

相关推荐
不爱编程爱睡觉42 分钟前
代码随想录算法训练营第二十八天 | 动态规划算法基础、 LeetCode509. 斐波那契数、70. 爬楼梯、746. 使用最小花费爬楼梯
算法·leetcode·动态规划·代码随想录
Ace_317508877643 分钟前
微店平台关键字搜索接口深度解析:从 Token 动态生成到多维度数据挖掘
java·前端·javascript
yyt3630458411 小时前
Maven 命令构建成功但 IDEA 构建失败原因解析
java·maven·intellij-idea
krafft1 小时前
从零入门 Spring AI,详细拆解 ChatClient 调用流程和 Advisor 底层原理,小白可入!
java·spring·ai
j***82701 小时前
Spring 中集成Hibernate
java·spring·hibernate
g***96901 小时前
springboot设置多环境配置文件
java·spring boot·后端
Jtti1 小时前
PHP项目缓存占用硬盘过大?目录清理与优化
java·缓存·php
这张生成的图像能检测吗1 小时前
(论文速读)多任务深度学习框架下基于Lamb波的多损伤数据集构建与量化算法
人工智能·深度学习·算法·数据集·结构健康监测
未若君雅裁1 小时前
JVM基础总结
java·jvm·java-ee