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

目录

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


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

2.7、题目七:最长回文子序列

  • 题目七:最长回文子序列
  • 给你一个字符串 s ,找出其中最长的回文子序列,并返回该序列的长度。
  • 子序列定义为:不改变剩余字符顺序的情况下,删除某些字符或者不删除任何字符形成的一个序列。
  • 比如:str="a12b3c43def2ghi1kpm"
  • 最长回文子序列为"1234321"或者"123c321",返回7
  • 子序列和子串的区别:子序列不要求连续,子串要求连续
  • 测试链接:https://leetcode.cn/problems/longest-palindromic-subsequence/
2.7.1、基于范围尝试模型的暴力递归
  • 基于范围尝试模型的暴力递归:

  • 思路:

    • 定义一个函数f(str,L,R),表示str[L...R]最长回文子序列长度返回,
    • 在L...R范围上,最长回文子序列有以下几种情况:
    • 1)不以L开头,不以R结尾,那么最长回文子序列长度为f(str,L+1,R-1)
    • 2)以L开头,不以R结尾,那么最长回文子序列长度为f(str,L,R-1)
    • 3)不以L开头,以R结尾,那么最长回文子序列长度为f(str,L+1,R)
    • 4)以L开头,以R结尾,那么最长回文子序列长度为f(str,L+1,R-1)+2(只有在str[L]==str[R]时),否则为0
    • 从上面四种情况中求出最大值,就是整体的最大回文子序列长度
  • 提交时函数名改为:longestPalindromeSubseq,会超时

java 复制代码
    /**
     * 基于范围尝试模型的暴力递归:
     * 思路:
     * 定义一个函数f(str,L,R),表示str[L..R]最长回文子序列长度返回,
     * 在L..R范围上,最长回文子序列有以下几种情况:
     * 1)不以L开头,不以R结尾,那么最长回文子序列长度为f(str,L+1,R-1)
     * 2)以L开头,不以R结尾,那么最长回文子序列长度为f(str,L,R-1)
     * 3)不以L开头,以R结尾,那么最长回文子序列长度为f(str,L+1,R)
     * 4)以L开头,以R结尾,那么最长回文子序列长度为f(str,L+1,R-1)+2(只有在str[L]==str[R]时),否则为0
     * 从上面四种情况中求出最大值,就是整体的最大回文子序列长度
     *
     * <br>
     * 提交时函数名改为:longestPalindromeSubseq,会超时
     */
    public static int longestPalindromeSubseq1(String s) {
        if (s == null || s.length() == 0) {
            return 0;
        }
        char[] charArray = s.toCharArray();
        return f(charArray, 0, charArray.length - 1);
    }

    /**
     * 递归函数:
     * 计算str[L..R]最长回文子序列长度返回
     */
    public static int f(char[] str, int L, int R) {
        // base case1: 1个字符,最长回文子序列长度为1
        if (L == R) {
            return 1;
        }
        // base case2: 2个字符,最长回文子序列长度为2(只有在str[L]==str[R]时),否则为1
        if (L == R - 1) {
            return str[L] == str[R] ? 2 : 1;
        }
        // 递归求出不同情况下的最长回文子序列长度
        // 不以L开头,不以R结尾,那么最长回文子序列长度为f(str,L+1,R-1)
        int p1 = f(str, L + 1, R - 1);
        // 以L开头,不以R结尾,那么最长回文子序列长度为f(str,L,R-1)
        int p2 = f(str, L, R - 1);
        // 不以L开头,以R结尾,那么最长回文子序列长度为f(str,L+1,R)
        int p3 = f(str, L + 1, R);
        // 以L开头,以R结尾,那么最长回文子序列长度为f(str,L+1,R-1)+2(只有在str[L]==str[R]时),否则为0
        int p4 = str[L] == str[R] ? f(str, L + 1, R - 1) + 2 : 0;
        // 从上面四种情况中求出最大值,就是整体的最大回文子序列长度
        return Math.max(Math.max(p1, p2), Math.max(p3, p4));
    }
2.7.2、基于范围尝试模型的动态规划
  • 基于范围尝试模型的动态规划:

  • 思路:

    • 根据暴力递归改成动态规划:
    • 1)根据递归函数f(str,L,R)的含义,有L和R两个变量,范围是0...N-1,我们可以定义一个二维数组dp[N][N],其中dp[L][R]表示str[L...R]最长回文子序列长度返回
    • 2)根据递归函数f(str,L,R)的base case,我们可以初始化dp数组的对角线和对角线的下一条对角线
    • 3)根据递归函数f(str,L,R)的递归关系,str[L][R]依赖其左边,左下角,下边的最大值,我们可以从下往上,从左往右填充dp数组,直到表的右上角
    • 4)根据调用,可知dp[0][N-1]就是我们要求的结果
  • 提交时函数名改为:longestPalindromeSubseq

java 复制代码
    /**
     * 基于范围尝试模型的动态规划:
     * 思路:
     * 根据暴力递归改成动态规划:
     * 1)根据递归函数f(str,L,R)的含义,有L和R两个变量,范围是0..N-1,我们可以定义一个二维数组dp[N][N],其中dp[L][R]表示str[L..R]最长回文子序列长度返回
     * 2)根据递归函数f(str,L,R)的base case,我们可以初始化dp数组的对角线和对角线的下一条对角线
     * 3)根据递归函数f(str,L,R)的递归关系,str[L][R]依赖其左边,左下角,下边的最大值,我们可以从下往上,从左往右填充dp数组,直到表的右上角
     * 4)根据调用,可知dp[0][N-1]就是我们要求的结果
     *
     * <br>
     * 提交时函数名改为:longestPalindromeSubseq
     */
    public static int longestPalindromeSubseq2(String s) {
        if (s == null || s.length() == 0) {
            return 0;
        }
        char[] str = s.toCharArray();
        int N = str.length;
        // 定义缓存表
        int[][] dp = new int[N][N];
        // 根据base case初始化dp数组
        // 由base case1可以初始化一个对角线
        // 由base case2可以初始化对角线的右上边一条对角线
        for (int i = 0; i < N; i++) {
            dp[i][i] = 1;
            if (i + 1 < N) {
                dp[i][i + 1] = str[i] == str[i + 1] ? 2 : 1;
            }
        }

        // 从下往上,从左往右填充dp数组,直到表的右上角
        // 两条对角线已经填好了,所以行从N-3开始
        for (int L = N - 3; L >= 0; L--) {
            // 因为已经填了2个对角线位置,所以列是从L+2开始
            for (int R = L + 2; R < N; R++) {
                // 虽然暴力递归中有4次调用f递归函数,但是通过分析,dp[L][R]依赖其左边,左下角,下边的最大值
                // 所以我们就可以根据依赖的3个格子的值,填充dp[L][R]
                // 能够这样改的依据是左下角的值是上一次填写时填的,肯定不可能大于左边或者下边,最大值肯定在左边和下边里面,不可能在左下里面。
                // 所以最终就少了一次单独依赖左下的调用
                dp[L][R] = Math.max(dp[L][R - 1], dp[L + 1][R]);
                if (str[L] == str[R]) {
                    dp[L][R] = Math.max(dp[L][R], 2 + dp[L + 1][R - 1]);
                }
            }
        }
        return dp[0][N - 1];
    }
2.7.3、基于样本对应模型的动态规划
  • 基于样本对应模型的动态规划:

  • 思路:

    • 一个字符串和其逆序的最长公共子序列就是该字符串的最长回文子序列。
    • 我们可以将字符串s和其逆序s'的最长公共子序列长度作为s的最长回文子序列长度。
    • 样本对应模型的暴力递归和动态规划我们已经在"最长公共子序列"的题目中做过了,所以我们直接用其动态规划的解法即可。
  • 提交时函数名改为:longestPalindromeSubseq

java 复制代码
    /**
     * 基于样本对应模型的动态规划:
     * 思路:
     * 一个字符串和其逆序的最长公共子序列就是该字符串的最长回文子序列。
     * 我们可以将字符串s和其逆序s'的最长公共子序列长度作为s的最长回文子序列长度。
     * 样本对应模型的暴力递归和动态规划我们已经在"最长公共子序列"的题目中做过了,所以我们直接用其动态规划的解法即可。
     *
     * <br>
     * 提交时函数名改为:longestPalindromeSubseq
     */
    public static int longestPalindromeSubseq3(String s) {
        if (s == null || s.length() == 0) {
            return 0;
        }
        if (s.length() == 1) {
            return 1;
        }
        char[] str = s.toCharArray();
        char[] reverse = reverse(str);
        return longestCommonSubsequence(str, reverse);
    }

    public static char[] reverse(char[] str) {
        int N = str.length;
        char[] reverse = new char[str.length];
        for (int i = 0; i < str.length; i++) {
            reverse[--N] = str[i];
        }
        return reverse;
    }

    public static int longestCommonSubsequence(char[] str1, char[] str2) {
        int N = str1.length;
        int M = str2.length;
        int[][] dp = new int[N][M];
        dp[0][0] = str1[0] == str2[0] ? 1 : 0;
        for (int i = 1; i < N; i++) {
            dp[i][0] = str1[i] == str2[0] ? 1 : dp[i - 1][0];
        }
        for (int j = 1; j < M; j++) {
            dp[0][j] = str1[0] == str2[j] ? 1 : dp[0][j - 1];
        }
        for (int i = 1; i < N; i++) {
            for (int j = 1; j < M; j++) {
                dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
                if (str1[i] == str2[j]) {
                    dp[i][j] = Math.max(dp[i][j], dp[i - 1][j - 1] + 1);
                }
            }
        }
        return dp[N - 1][M - 1];
    }

整体代码如下:

java 复制代码
/**
 * 题目七:最长回文子序列
 * 给你一个字符串 s ,找出其中最长的回文子序列,并返回该序列的长度。
 * 子序列定义为:不改变剩余字符顺序的情况下,删除某些字符或者不删除任何字符形成的一个序列。
 * 比如:str="a12b3c43def2ghi1kpm"
 * 最长回文子序列为"1234321"或者"123c321",返回7
 * 子序列和子串的区别:子序列不要求连续,子串要求连续
 * 测试链接:https://leetcode.cn/problems/longest-palindromic-subsequence/
 */
public class Q07_PalindromeSubsequence {
    /**
     * 基于范围尝试模型的暴力递归:
     * 思路:
     * 定义一个函数f(str,L,R),表示str[L..R]最长回文子序列长度返回,
     * 在L..R范围上,最长回文子序列有以下几种情况:
     * 1)不以L开头,不以R结尾,那么最长回文子序列长度为f(str,L+1,R-1)
     * 2)以L开头,不以R结尾,那么最长回文子序列长度为f(str,L,R-1)
     * 3)不以L开头,以R结尾,那么最长回文子序列长度为f(str,L+1,R)
     * 4)以L开头,以R结尾,那么最长回文子序列长度为f(str,L+1,R-1)+2(只有在str[L]==str[R]时),否则为0
     * 从上面四种情况中求出最大值,就是整体的最大回文子序列长度
     *
     * <br>
     * 提交时函数名改为:longestPalindromeSubseq,会超时
     */
    public static int longestPalindromeSubseq1(String s) {
        if (s == null || s.length() == 0) {
            return 0;
        }
        char[] charArray = s.toCharArray();
        return f(charArray, 0, charArray.length - 1);
    }

    /**
     * 递归函数:
     * 计算str[L..R]最长回文子序列长度返回
     */
    public static int f(char[] str, int L, int R) {
        // base case1: 1个字符,最长回文子序列长度为1
        if (L == R) {
            return 1;
        }
        // base case2: 2个字符,最长回文子序列长度为2(只有在str[L]==str[R]时),否则为1
        if (L == R - 1) {
            return str[L] == str[R] ? 2 : 1;
        }
        // 递归求出不同情况下的最长回文子序列长度
        // 不以L开头,不以R结尾,那么最长回文子序列长度为f(str,L+1,R-1)
        int p1 = f(str, L + 1, R - 1);
        // 以L开头,不以R结尾,那么最长回文子序列长度为f(str,L,R-1)
        int p2 = f(str, L, R - 1);
        // 不以L开头,以R结尾,那么最长回文子序列长度为f(str,L+1,R)
        int p3 = f(str, L + 1, R);
        // 以L开头,以R结尾,那么最长回文子序列长度为f(str,L+1,R-1)+2(只有在str[L]==str[R]时),否则为0
        int p4 = str[L] == str[R] ? f(str, L + 1, R - 1) + 2 : 0;
        // 从上面四种情况中求出最大值,就是整体的最大回文子序列长度
        return Math.max(Math.max(p1, p2), Math.max(p3, p4));
    }

    /**
     * 基于范围尝试模型的动态规划:
     * 思路:
     * 根据暴力递归改成动态规划:
     * 1)根据递归函数f(str,L,R)的含义,有L和R两个变量,范围是0..N-1,我们可以定义一个二维数组dp[N][N],其中dp[L][R]表示str[L..R]最长回文子序列长度返回
     * 2)根据递归函数f(str,L,R)的base case,我们可以初始化dp数组的对角线和对角线的下一条对角线
     * 3)根据递归函数f(str,L,R)的递归关系,str[L][R]依赖其左边,左下角,下边的最大值,我们可以从下往上,从左往右填充dp数组,直到表的右上角
     * 4)根据调用,可知dp[0][N-1]就是我们要求的结果
     *
     * <br>
     * 提交时函数名改为:longestPalindromeSubseq
     */
    public static int longestPalindromeSubseq2(String s) {
        if (s == null || s.length() == 0) {
            return 0;
        }
        char[] str = s.toCharArray();
        int N = str.length;
        // 定义缓存表
        int[][] dp = new int[N][N];
        // 根据base case初始化dp数组
        // 由base case1可以初始化一个对角线
        // 由base case2可以初始化对角线的右上边一条对角线
        for (int i = 0; i < N; i++) {
            dp[i][i] = 1;
            if (i + 1 < N) {
                dp[i][i + 1] = str[i] == str[i + 1] ? 2 : 1;
            }
        }

        // 从下往上,从左往右填充dp数组,直到表的右上角
        // 两条对角线已经填好了,所以行从N-3开始
        for (int L = N - 3; L >= 0; L--) {
            // 因为已经填了2个对角线位置,所以列是从L+2开始
            for (int R = L + 2; R < N; R++) {
                // 虽然暴力递归中有4次调用f递归函数,但是通过分析,dp[L][R]依赖其左边,左下角,下边的最大值
                // 所以我们就可以根据依赖的3个格子的值,填充dp[L][R]
                // 能够这样改的依据是左下角的值是上一次填写时填的,肯定不可能大于左边或者下边,最大值肯定在左边和下边里面,不可能在左下里面。
                // 所以最终就少了一次单独依赖左下的调用
                dp[L][R] = Math.max(dp[L][R - 1], dp[L + 1][R]);
                if (str[L] == str[R]) {
                    dp[L][R] = Math.max(dp[L][R], 2 + dp[L + 1][R - 1]);
                }
            }
        }
        return dp[0][N - 1];
    }

    /**
     * 基于样本对应模型的动态规划:
     * 思路:
     * 一个字符串和其逆序的最长公共子序列就是该字符串的最长回文子序列。
     * 我们可以将字符串s和其逆序s'的最长公共子序列长度作为s的最长回文子序列长度。
     * 样本对应模型的暴力递归和动态规划我们已经在"最长公共子序列"的题目中做过了,所以我们直接用其动态规划的解法即可。
     *
     * <br>
     * 提交时函数名改为:longestPalindromeSubseq
     */
    public static int longestPalindromeSubseq3(String s) {
        if (s == null || s.length() == 0) {
            return 0;
        }
        if (s.length() == 1) {
            return 1;
        }
        char[] str = s.toCharArray();
        char[] reverse = reverse(str);
        return longestCommonSubsequence(str, reverse);
    }

    public static char[] reverse(char[] str) {
        int N = str.length;
        char[] reverse = new char[str.length];
        for (int i = 0; i < str.length; i++) {
            reverse[--N] = str[i];
        }
        return reverse;
    }

    public static int longestCommonSubsequence(char[] str1, char[] str2) {
        int N = str1.length;
        int M = str2.length;
        int[][] dp = new int[N][M];
        dp[0][0] = str1[0] == str2[0] ? 1 : 0;
        for (int i = 1; i < N; i++) {
            dp[i][0] = str1[i] == str2[0] ? 1 : dp[i - 1][0];
        }
        for (int j = 1; j < M; j++) {
            dp[0][j] = str1[0] == str2[j] ? 1 : dp[0][j - 1];
        }
        for (int i = 1; i < N; i++) {
            for (int j = 1; j < M; j++) {
                dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
                if (str1[i] == str2[j]) {
                    dp[i][j] = Math.max(dp[i][j], dp[i - 1][j - 1] + 1);
                }
            }
        }
        return dp[N - 1][M - 1];
    }
}

2.8、题目八:马跳日的走法

  • 题目八:马跳日的走法
  • 想象一个象棋的棋盘,
  • 然后把整个棋盘放入第一象限,棋盘的最左下角是(0,0)位置
  • 那么整个棋盘就是横坐标上9条线、纵坐标上10条线的区域
  • 给你三个 参数 x,y,k
  • 返回"马"从(0,0)位置出发,必须走k步
  • 最后落在(x,y)上的方法数有多少种?
2.8.1、暴力递归的方法
  • 暴力递归的解法
  • 思路:
    • 从(0,0)位置出发,必须走k步,最后落在(x,y)上的方法数有多少种?
    • 将所有走k步的情况都统计出来,返回走到目标位置的方法即可。
    • 马到了一个位置(x,y),有8个方向可以走,所以要统计每一个方向上走的可能性。
    • 递归函数的参数是一个当前位置坐标,剩余步数,目标位置坐标
    • 这里还有一个需要注意的是,放的是第一象限,所以行为0-9,列为0-8,即x的范围是0-9,y的范围是0-8
java 复制代码
    /**
     * 暴力递归的解法
     * 思路:
     * 从(0,0)位置出发,必须走k步,最后落在(x,y)上的方法数有多少种?
     * 将所有走k步的情况都统计出来,返回走到目标位置的方法即可。
     * 马到了一个位置(x,y),有8个方向可以走,所以要统计每一个方向上走的可能性。
     * 递归函数的参数是一个当前位置坐标,剩余步数,目标位置坐标
     * 这里还有一个需要注意的是,放的是第一象限,所以行为0-9,列为0-8,即x的范围是0-9,y的范围是0-8
     */
    public static int horseJump(int a, int b, int k) {
        return process(0, 0, k, a, b);
    }

    /**
     * 递归函数:
     * 计算从(x,y)位置出发,还剩下rest步需要跳,正好跳到a,b的方法数
     */
    public static int process(int x, int y, int rest, int a, int b) {
        // 先判断当前位置是否越界
        if (x < 0 || x > 9 || y < 0 || y > 8) {
            return 0;
        }
        // base case ,剩余位置为0时,判断是否到达目标位置
        if (rest == 0) {
            return (x == a && y == b) ? 1 : 0;
        }
        // 递归计算8个方向上的走法
        int ways = process(x + 2, y + 1, rest - 1, a, b);
        ways += process(x + 1, y + 2, rest - 1, a, b);
        ways += process(x - 1, y + 2, rest - 1, a, b);
        ways += process(x - 2, y + 1, rest - 1, a, b);
        ways += process(x - 2, y - 1, rest - 1, a, b);
        ways += process(x - 1, y - 2, rest - 1, a, b);
        ways += process(x + 1, y - 2, rest - 1, a, b);
        ways += process(x + 2, y - 1, rest - 1, a, b);
        return ways;
    }
2.8.2、动态规划的方法
  • 动态规划的解法

  • 思路:

    • 从暴力递归改为动态规划的解法:
    • 1)跟觉递归函数的动态参数,有三个函数,分别是x,y,rest,所以我们需要定义一个三维数组dp[10][9][k+1]
    • 2)根据递归函数的base case,初始化dp[a][b][0]=1,其他位置都初始化为0
    • 3)根据递归函数的递归关系,其他位置都是根据当前坐标,走剩余的rest步,即rest依赖其前面的rest,所以在填写dp数组,从rest=1开始,填写到rest=k
    • 4)根据调用递归函数返回的方法,返回dp[0][0][k],即从(0,0)位置出发,必须走k步,正好跳到(a,b)的方法数
  • 这里有一个和其他递归函数不同的地方,就是每次走一个位置,都要有大量的边界判断,

    • 因为马只能在棋盘内走,不能越界,所以在填写dp数组时,要判断当前位置是否越界,
    • 如果越界,就直接返回0,不参与计算。这个过程在填表时判断会很多,我们可以抽出一个pick函数,用来做判断,
    • 如果越界,就返回0,否则返回dp[0][0][k],如果不做抽取,填表的判断就会很多,也很容易出错。
    • 从这里我们可以看出,动态规划的解法并不是说不需要调用其他函数,也是可以调用其他函数的,只是不递归调用其他函数而已。
  • 总结:

    • 走马问题问的是从(0,0)位置出发,必须走k步,正好跳到(a,b)的方法数。
    • 通过递归逆序以后,我们就可以看作是从(a,b)位置出发,必须走k步,正好跳到(0,0)的方法数。
    • 所以在三层的dp[0][0][k]数组中,一开始只有dp[a][b][0]=1,其他位置都是0。每次走步,都是依赖下一层,同一层的x和y是不相互依赖的。
    • 最后统计出来的dp[0][0][k],就是从(0,0)位置出发,必须走k步,正好跳到(a,b)的方法数。
java 复制代码
    /**
     * 动态规划的解法
     * 思路:
     * 从暴力递归改为动态规划的解法:
     * 1)跟觉递归函数的动态参数,有三个函数,分别是x,y,rest,所以我们需要定义一个三维数组dp[10][9][k+1]
     * 2)根据递归函数的base case,初始化dp[a][b][0]=1,其他位置都初始化为0
     * 3)根据递归函数的递归关系,其他位置都是根据当前坐标,走剩余的rest步,即rest依赖其前面的rest,所以在填写dp数组,从rest=1开始,填写到rest=k
     * 4)根据调用递归函数返回的方法,返回dp[0][0][k],即从(0,0)位置出发,必须走k步,正好跳到(a,b)的方法数
     * <br>
     * 这里有一个和其他递归函数不同的地方,就是每次走一个位置,都要有大量的边界判断,
     * 因为马只能在棋盘内走,不能越界,所以在填写dp数组时,要判断当前位置是否越界,
     * 如果越界,就直接返回0,不参与计算。这个过程在填表时判断会很多,我们可以抽出一个pick函数,用来做判断,
     * 如果越界,就返回0,否则返回dp[0][0][k],如果不做抽取,填表的判断就会很多,也很容易出错。
     * 从这里我们可以看出,动态规划的解法并不是说不需要调用其他函数,也是可以调用其他函数的,只是不递归调用其他函数而已。
     * <br>
     * 总结:
     * 走马问题问的是从(0,0)位置出发,必须走k步,正好跳到(a,b)的方法数。
     * 通过递归逆序以后,我们就可以看作是从(a,b)位置出发,必须走k步,正好跳到(0,0)的方法数。
     * 所以在三层的dp[0][0][k]数组中,一开始只有dp[a][b][0]=1,其他位置都是0。每次走步,都是依赖下一层,同一层的x和y是不相互依赖的。
     * 最后统计出来的dp[0][0][k],就是从(0,0)位置出发,必须走k步,正好跳到(a,b)的方法数。
     */
    public static int horseJumpDp(int a, int b, int k) {
        int[][][] dp = new int[10][9][k + 1];
        dp[a][b][0] = 1;
        for (int rest = 1; rest <= k; rest++) {
            for (int x = 0; x < 10; x++) {
                for (int y = 0; y < 9; y++) {
                    int ways = pick(dp, x + 2, y + 1, rest - 1);
                    ways += pick(dp, x + 1, y + 2, rest - 1);
                    ways += pick(dp, x - 1, y + 2, rest - 1);
                    ways += pick(dp, x - 2, y + 1, rest - 1);
                    ways += pick(dp, x - 2, y - 1, rest - 1);
                    ways += pick(dp, x - 1, y - 2, rest - 1);
                    ways += pick(dp, x + 1, y - 2, rest - 1);
                    ways += pick(dp, x + 2, y - 1, rest - 1);
                    dp[x][y][rest] = ways;
                }
            }
        }
        return dp[0][0][k];
    }

    public static int pick(int[][][] dp, int x, int y, int rest) {
        if (x < 0 || x > 9 || y < 0 || y > 8) {
            return 0;
        }
        return dp[x][y][rest];
    }

整体代码和测试如下:

java 复制代码
/**
 * 题目八:马跳日的走法
 * 想象一个象棋的棋盘,
 * 然后把整个棋盘放入第一象限,棋盘的最左下角是(0,0)位置
 * 那么整个棋盘就是横坐标上9条线、纵坐标上10条线的区域
 * 给你三个 参数 x,y,k
 * 返回"马"从(0,0)位置出发,必须走k步
 * 最后落在(x,y)上的方法数有多少种?
 */
public class Q08_HorseJump {

    /**
     * 暴力递归的解法
     * 思路:
     * 从(0,0)位置出发,必须走k步,最后落在(x,y)上的方法数有多少种?
     * 将所有走k步的情况都统计出来,返回走到目标位置的方法即可。
     * 马到了一个位置(x,y),有8个方向可以走,所以要统计每一个方向上走的可能性。
     * 递归函数的参数是一个当前位置坐标,剩余步数,目标位置坐标
     * 这里还有一个需要注意的是,放的是第一象限,所以行为0-9,列为0-8,即x的范围是0-9,y的范围是0-8
     */
    public static int horseJump(int a, int b, int k) {
        return process(0, 0, k, a, b);
    }

    /**
     * 递归函数:
     * 计算从(x,y)位置出发,还剩下rest步需要跳,正好跳到a,b的方法数
     */
    public static int process(int x, int y, int rest, int a, int b) {
        // 先判断当前位置是否越界
        if (x < 0 || x > 9 || y < 0 || y > 8) {
            return 0;
        }
        // base case ,剩余位置为0时,判断是否到达目标位置
        if (rest == 0) {
            return (x == a && y == b) ? 1 : 0;
        }
        // 递归计算8个方向上的走法
        int ways = process(x + 2, y + 1, rest - 1, a, b);
        ways += process(x + 1, y + 2, rest - 1, a, b);
        ways += process(x - 1, y + 2, rest - 1, a, b);
        ways += process(x - 2, y + 1, rest - 1, a, b);
        ways += process(x - 2, y - 1, rest - 1, a, b);
        ways += process(x - 1, y - 2, rest - 1, a, b);
        ways += process(x + 1, y - 2, rest - 1, a, b);
        ways += process(x + 2, y - 1, rest - 1, a, b);
        return ways;
    }

    /**
     * 动态规划的解法
     * 思路:
     * 从暴力递归改为动态规划的解法:
     * 1)跟觉递归函数的动态参数,有三个函数,分别是x,y,rest,所以我们需要定义一个三维数组dp[10][9][k+1]
     * 2)根据递归函数的base case,初始化dp[a][b][0]=1,其他位置都初始化为0
     * 3)根据递归函数的递归关系,其他位置都是根据当前坐标,走剩余的rest步,即rest依赖其前面的rest,所以在填写dp数组,从rest=1开始,填写到rest=k
     * 4)根据调用递归函数返回的方法,返回dp[0][0][k],即从(0,0)位置出发,必须走k步,正好跳到(a,b)的方法数
     * <br>
     * 这里有一个和其他递归函数不同的地方,就是每次走一个位置,都要有大量的边界判断,
     * 因为马只能在棋盘内走,不能越界,所以在填写dp数组时,要判断当前位置是否越界,
     * 如果越界,就直接返回0,不参与计算。这个过程在填表时判断会很多,我们可以抽出一个pick函数,用来做判断,
     * 如果越界,就返回0,否则返回dp[0][0][k],如果不做抽取,填表的判断就会很多,也很容易出错。
     * 从这里我们可以看出,动态规划的解法并不是说不需要调用其他函数,也是可以调用其他函数的,只是不递归调用其他函数而已。
     * <br>
     * 总结:
     * 走马问题问的是从(0,0)位置出发,必须走k步,正好跳到(a,b)的方法数。
     * 通过递归逆序以后,我们就可以看作是从(a,b)位置出发,必须走k步,正好跳到(0,0)的方法数。
     * 所以在三层的dp[0][0][k]数组中,一开始只有dp[a][b][0]=1,其他位置都是0。每次走步,都是依赖下一层,同一层的x和y是不相互依赖的。
     * 最后统计出来的dp[0][0][k],就是从(0,0)位置出发,必须走k步,正好跳到(a,b)的方法数。
     */
    public static int horseJumpDp(int a, int b, int k) {
        int[][][] dp = new int[10][9][k + 1];
        dp[a][b][0] = 1;
        for (int rest = 1; rest <= k; rest++) {
            for (int x = 0; x < 10; x++) {
                for (int y = 0; y < 9; y++) {
                    int ways = pick(dp, x + 2, y + 1, rest - 1);
                    ways += pick(dp, x + 1, y + 2, rest - 1);
                    ways += pick(dp, x - 1, y + 2, rest - 1);
                    ways += pick(dp, x - 2, y + 1, rest - 1);
                    ways += pick(dp, x - 2, y - 1, rest - 1);
                    ways += pick(dp, x - 1, y - 2, rest - 1);
                    ways += pick(dp, x + 1, y - 2, rest - 1);
                    ways += pick(dp, x + 2, y - 1, rest - 1);
                    dp[x][y][rest] = ways;
                }
            }
        }
        return dp[0][0][k];
    }

    public static int pick(int[][][] dp, int x, int y, int rest) {
        if (x < 0 || x > 9 || y < 0 || y > 8) {
            return 0;
        }
        return dp[x][y][rest];
    }

    public static void main(String[] args) {
        int x = 7;
        int y = 7;
        int step = 10;
        System.out.println(horseJump(x, y, step));
        System.out.println(horseJumpDp(x, y, step));
    }
}

2.9、题目九:喝咖啡问题

  • 题目九:喝咖啡问题

  • 给定一个数组arr,arr[i]代表第i号咖啡机泡一杯咖啡的时间,

  • 给定一个正数N,表示N个人等着咖啡机泡咖啡,每台咖啡机只能轮流泡咖啡,

  • 只有一台洗咖啡杯的机器,一次只能洗一个杯子,时间耗费a,洗完才能洗下一杯,

  • 每个咖啡杯也可以自己挥发干净,时间耗费b,咖啡杯可以并行挥发,

  • 假设所有人拿到咖啡之后立刻喝干净,

  • 返回从开始等到所有咖啡杯变干净的最短时间

  • 三个参数:int[] arr、int N,int a、int b

  • 题目解析:

  • 问题中,有arr.length台咖啡机,每台泡咖啡的时间不一样,为arr[i],

  • 洗咖啡杯有两种可选,一种是等唯一的一台机器,洗的时间为a,排队也要耗时;另一个选择是可以自然挥发干净,时间为b。

  • 求的是从开始等到所有杯子变干净的最短时间。

2.9.1、利用小根堆的暴力递归
  • 利用小根堆的暴力递归

  • 思路:

    • 整个题目可以分为两部分:
    • 第一部分是泡咖啡,也就是要把N个人分配给arr.length台咖啡机,使得所有人在最短时间内泡完咖啡,返回每个人泡完咖啡的时间点drink[N],
    • 第二部分是洗咖啡杯,因为泡完咖啡就是喝完,所以要根据泡咖啡的drink[N],计算出最短的洗完杯子的时间,
    • 最后就能计算出整个的最短时间。
  • 首先要计算的是每个人泡(喝完)咖啡的时间:

    • 这个过程的本质上就是利用某种算法,将N个人分配给arr.length台咖啡机进行排队,然后计算出每个人的泡完咖啡的时间点drink[N],
    • 在这个过程中,轮到某个人的时候,我们需要挑选出最值得等的机器,从而计算出其泡完咖啡的时间点。
    • 所以我们可以将咖啡机进行封装,记录其可以用的时间点和时长,并将其放入小根堆中,排序原则是根据其可用的时间点和耗时的和来,这样就可以每次将效率高的可用的咖啡机弹出。
    • 通过这样操作以后,对于某个人i,我们就从小根堆中取出一个机器,从而计算出自己的泡完时间,然后再将机器的可用时间更新到自己的泡完时间,重新放入小根堆中排序。
    • 这样就能计算出每个人的泡完时间,因为泡完就是喝完,喝完就要洗,所以此时的drink[N]就是需要洗的时间节点。
  • 在喝完咖啡时间点的基础上,计算出杯子变干净的最短时间:

    • 在上一步中,我们已经将每个人喝完咖啡的时间放到了drink[N]数组中,也就是每个人要洗的开始时间有了,问题就成了drink[n]开始,计算出洗干净的最短时间问题了。
    • 对于每个人,都有两种选择,要么等机器洗,要么自然挥发,这就成了一个选择的问题。
    • 我们可以用递归的方式,分别计算出每个人的洗和挥发的时间,从而计算出最大值,就是所有的洗干净最短的时间了。
java 复制代码
    /**
     * 利用小根堆的暴力递归
     * 思路:
     * 整个题目可以分为两部分:
     * 第一部分是泡咖啡,也就是要把N个人分配给arr.length台咖啡机,使得所有人在最短时间内泡完咖啡,返回每个人泡完咖啡的时间点drink[N],
     * 第二部分是洗咖啡杯,因为泡完咖啡就是喝完,所以要根据泡咖啡的drink[N],计算出最短的洗完杯子的时间,
     * 最后就能计算出整个的最短时间。
     * <br>
     * 首先要计算的是每个人泡(喝完)咖啡的时间:
     * 这个过程的本质上就是利用某种算法,将N个人分配给arr.length台咖啡机进行排队,然后计算出每个人的泡完咖啡的时间点drink[N],
     * 在这个过程中,轮到某个人的时候,我们需要挑选出最值得等的机器,从而计算出其泡完咖啡的时间点。
     * 所以我们可以将咖啡机进行封装,记录其可以用的时间点和时长,并将其放入小根堆中,排序原则是根据其可用的时间点和耗时的和来,这样就可以每次将效率高的可用的咖啡机弹出。
     * 通过这样操作以后,对于某个人i,我们就从小根堆中取出一个机器,从而计算出自己的泡完时间,然后再将机器的可用时间更新到自己的泡完时间,重新放入小根堆中排序。
     * 这样就能计算出每个人的泡完时间,因为泡完就是喝完,喝完就要洗,所以此时的drink[N]就是需要洗的时间节点。
     * <br>
     * 在喝完咖啡时间点的基础上,计算出杯子变干净的最短时间:
     * 在上一步中,我们已经将每个人喝完咖啡的时间放到了drink[N]数组中,也就是每个人要洗的开始时间有了,问题就成了drink[n]开始,计算出洗干净的最短时间问题了。
     * 对于每个人,都有两种选择,要么等机器洗,要么自然挥发,这就成了一个选择的问题。
     * 我们可以用递归的方式,分别计算出每个人的洗和挥发的时间,从而计算出最大值,就是所有的洗干净最短的时间了。
     *
     */
    public static int minTime1(int[] arr, int n, int a, int b) {
        // 利用小根堆来分配泡咖啡的机器
        PriorityQueue<Machine> machineQueue = new PriorityQueue<>(new MachineComparator());
        // 初始化小根堆,将所有咖啡机放入小根堆中
        for (int i = 0; i < arr.length; i++) {
            machineQueue.add(new Machine(0, arr[i]));
        }
        // 定义一个数组,用来记录每个人跑完咖啡的时间
        int[] drink = new int[n];
        // 遍历每个人,从小根堆取出可以用的咖啡机,分配给他泡咖啡,然后把完成时间记录在drink中
        for (int i = 0; i < n; i++) {
            // 从小根堆中取出效率最高的咖啡机
            Machine machine = machineQueue.poll();
            // 计算出当前人泡完咖啡的时间点
            drink[i] = machine.timePoint + machine.workTime;
            // 更新咖啡机的可用时间点
            machine.timePoint = drink[i];
            // 将咖啡机重新放入小根堆中排序
            machineQueue.add(machine);
        }
        // 计算出杯子变干净的最短时间
        return bestWashTime(drink, a, b, 0, 0);
    }

    /**
     * 洗咖啡的递归函数
     * 思路:
     * 这就是一个选择模型的递归,求出每一种情况的值,然后取最小值即可,
     * 每一个调到的函数就是index以前的不用考虑,考虑后面变好的最短时间,逆向到index=0时,就是所有的杯子都变干净的最短时间了。
     * 对于每一个递归,都有两种选择,洗还有等风干,分别求出两种选择的值,然后取最小值就是最优解。
     *
     * @param drinks :drinks 所有杯子可以开始洗的时间
     * @param wash   :wash 单杯洗干净的时间(串行)
     * @param air    :air 挥发干净的时间(并行)
     * @param index  :drinks[index.....]都变干净,最早的结束时间(返回)
     * @param free   :free 洗的机器什么时候可用
     */
    private static int bestWashTime(int[] drinks, int wash, int air, int index, int free) {
        // base case,到了最后,返回0
        if (index == drinks.length) {
            return 0;
        }
        // index号杯子举动洗,
        // index洗就是自己可以洗的时间和机器能洗的时间加上洗的时间,还要考虑其他的杯子的情况,之间的最大值
        // 自己洗的截止时间
        int selfWashClean = Math.max(drinks[index], free) + wash;
        // 其他杯子的干净时间
        int restWashClean = bestWashTime(drinks, wash, air, index + 1, selfWashClean);
        // 最终决定洗的时间就是两者的最大值,因为后面也可能是选的自然挥发,所以有可能是并行的,所以不能加起来,只能取最大值
        int p1 = Math.max(selfWashClean, restWashClean);

        // index号杯子自然挥发,
        // 自然挥发的时间就是自己可以挥发的时间加上挥发的时间
        int selfVolatileClean = drinks[index] + air;
        // 其他杯子的干净时间
        int restVolatileClean = bestWashTime(drinks, wash, air, index + 1, free);
        // 最终决定挥发的时间就是两者的最大值
        int p2 = Math.max(selfVolatileClean, restVolatileClean);

        // 返回两种选择的最小值
        return Math.min(p1, p2);
    }
2.9.2、贪心+利用小根堆的暴力递归改成动态规划
  • 贪心+利用小根堆的暴力递归改成动态规划

  • 思路:

    • 从小根堆的递归可以看出,泡咖啡的时间是没有办法优化的,但是洗咖啡的过程是一个递归过程,可以改成动态规划。
    • 分析bestTime递归函数可以知道:
    • 1)有两个可变参数index,free,index从0到N,包含N,因为递归函数中,base case为index==N,返回0,
    • free是不明确的,是一个业务限制模型,不能直观得到范围,但是可以通过计算算出模糊的最大值,我们可以将所有的杯子都作为洗,从而求出其最大值maxFree,
    • 这个时候的缓存表就是dp[N+1][maxFree+1],
    • 2)根据base case,dp[N][i] = 0,因为默认是0,所以就不用再次初始化了。
    • 3)根据递归函数的逻辑,我们可以知道,dp[index][free]依赖于dp[index+1][selfWashClean]和dp[index+1][free],
    • 也就是依赖下一行的值,我们从下往上,从左往右填写表即可。
    • 4)根据第一次调用递归的函数,返回dp[0][0],就是所有杯子都作为洗,从而求出其最小值。
  • 总结:

    • 这个题目比较复杂,所以在做的过程中首先根据贪心算法,求出泡咖啡的最优解,然后在这个基础上根据递归求出洗咖啡的最优解,
    • 同时,这个题目中也出现缓存大小不确定的情况,这个时候,我们就需要根据业务来凑其边界,从而改成动态规划的模型。
java 复制代码
    /**
     * 贪心+利用小根堆的暴力递归改成动态规划
     * 思路:
     * 从小根堆的递归可以看出,泡咖啡的时间是没有办法优化的,但是洗咖啡的过程是一个递归过程,可以改成动态规划。
     * 分析bestTime递归函数可以知道:
     * 1)有两个可变参数index,free,index从0到N,包含N,因为递归函数中,base case为index==N,返回0,
     * free是不明确的,是一个业务限制模型,不能直观得到范围,但是可以通过计算算出模糊的最大值,我们可以将所有的杯子都作为洗,从而求出其最大值maxFree,
     * 这个时候的缓存表就是dp[N+1][maxFree+1],
     * 2)根据base case,dp[N][i] = 0,因为默认是0,所以就不用再次初始化了。
     * 3)根据递归函数的逻辑,我们可以知道,dp[index][free]依赖于dp[index+1][selfWashClean]和dp[index+1][free],
     * 也就是依赖下一行的值,我们从下往上,从左往右填写表即可。
     * 4)根据第一次调用递归的函数,返回dp[0][0],就是所有杯子都作为洗,从而求出其最小值。
     * <br>
     * 总结:
     * 这个题目比较复杂,所以在做的过程中首先根据贪心算法,求出泡咖啡的最优解,然后在这个基础上根据递归求出洗咖啡的最优解,
     * 同时,这个题目中也出现缓存大小不确定的情况,这个时候,我们就需要根据业务来凑其边界,从而改成动态规划的模型。
     */
    public static int minTime2(int[] arr, int n, int a, int b) {
        // 利用小根堆来分配泡咖啡的机器
        PriorityQueue<Machine> machineQueue = new PriorityQueue<>(new MachineComparator());
        // 初始化小根堆,将所有咖啡机放入小根堆中
        for (int i = 0; i < arr.length; i++) {
            machineQueue.add(new Machine(0, arr[i]));
        }
        // 定义一个数组,用来记录每个人跑完咖啡的时间
        int[] drink = new int[n];
        // 遍历每个人,从小根堆取出可以用的咖啡机,分配给他泡咖啡,然后把完成时间记录在drink中
        for (int i = 0; i < n; i++) {
            // 从小根堆中取出效率最高的咖啡机
            Machine machine = machineQueue.poll();
            // 计算出当前人泡完咖啡的时间点
            drink[i] = machine.timePoint + machine.workTime;
            // 更新咖啡机的可用时间点
            machine.timePoint = drink[i];
            // 将咖啡机重新放入小根堆中排序
            machineQueue.add(machine);
        }
        // 计算出杯子变干净的最短时间
        return bestWashTimeDp(drink, a, b);
    }

    public static int bestWashTimeDp(int[] drinks, int wash, int air) {
        int N = drinks.length;
        // 根据业务,计算出free的边界maxFree,
        // 因为每个杯子都可以作为洗,我们计算,肯定是有些杯子要不洗,所以全部洗就是其最大的情况,
        // 这个时候的free就是所有杯子中最晚的那个时间点加上洗的时间
        int maxFree = 0;
        for (int i = 0; i < N; i++) {
            maxFree = Math.max(maxFree, drinks[i]) + wash;
        }
        // 定义缓存表
        int dp[][] = new int[N + 1][maxFree + 1];
        //根据base case,dp[N][i] = 0,因为默认是0,所以就不用再次初始化了
        // 根据递归函数的逻辑,我们可以知道,dp[index][free]依赖于dp[index+1][selfWashClean]和dp[index+1][free],
        // 也就是依赖下一行的值,我们从下往上,从左往右填写表即可。
        for (int index = N - 1; index >= 0; index--) {
            for (int free = 0; free <= maxFree; free++) {
                // index号杯子洗的情况
                int selfWashClean = Math.max(drinks[index], free) + wash;
                // 排除掉不可能出现的情况,防止越界
                if (selfWashClean > maxFree) {
                    break; // 因为后面的也都不用填了
                }
                // index 洗,其他杯子的情况
                int restWashClean = dp[index + 1][selfWashClean];
                // index决定洗的最后结果
                int p1 = Math.max(selfWashClean, restWashClean);

                // index 挥发,其他杯子的情况
                int selfVolatileClean = drinks[index] + air;
                int restVolatileClean = dp[index + 1][free];
                // index决定挥发的最后结果
                int p2 = Math.max(selfVolatileClean, restVolatileClean);
                // 返回两种选择的最小值
                dp[index][free] = Math.min(p1, p2);
            }
        }
        return dp[0][0];
    }

    // 泡咖啡机器封装类
    public static class Machine {
        // 可以使用的时间点
        public int timePoint;
        // 咖啡机泡一杯咖啡的时间
        public int workTime;

        public Machine(int t, int w) {
            timePoint = t;
            workTime = w;
        }
    }

    /**
     * 咖啡机比较器,根据咖啡机可以使用的时间点和泡咖啡时间,排序
     */
    public static class MachineComparator implements Comparator<Machine> {

        @Override
        public int compare(Machine o1, Machine o2) {
            return (o1.timePoint + o1.workTime) - (o2.timePoint + o2.workTime);
        }

    }
2.9.3、枚举所有可能的暴力递归
  • 枚举所有可能的暴力递归
  • 思路:
    • 很慢但是绝对正确,可以作为比较器来使用。
    • 每一个人来了以后,都尝试用每一台咖啡机来给自己做咖啡,然后求出整体时间的最短值。
    • 这是一个递归求解的过程,对于当前递归的时候,如果第kth个人用第i台咖啡机泡咖啡,要递归求出后面人的选择,
    • 到最后一个人后,又递归算出这种选择下的洗咖啡杯的最短时间。返回整体的时间。
    • 枚举所有的可能性以后,最小值就是最优解。
java 复制代码
    /**
     * 枚举所有可能的暴力递归
     * 思路:
     * 很慢但是绝对正确,可以作为比较器来使用。
     * 每一个人来了以后,都尝试用每一台咖啡机来给自己做咖啡,然后求出整体时间的最短值。
     * 这是一个递归求解的过程,对于当前递归的时候,如果第kth个人用第i台咖啡机泡咖啡,要递归求出后面人的选择,
     * 到最后一个人后,又递归算出这种选择下的洗咖啡杯的最短时间。返回整体的时间。
     * 枚举所有的可能性以后,最小值就是最优解。
     *
     */
    public static int rightComparator(int[] arr, int n, int a, int b) {
        // 记录每一个机器的可用时间点(截止时间点)
        int[] times = new int[arr.length];
        // 记录每个人做完咖啡的截止时间点
        int[] drink = new int[n];
        return forceMake(arr, times, 0, drink, n, a, b);
    }

    // 每个人暴力尝试用每一个咖啡机给自己做咖啡
    public static int forceMake(int[] arr, int[] times, int kth, int[] drink, int n, int a, int b) {
        if (kth == n) {
            // 一种选择到头以后,递归求洗咖啡杯的最短时间
            int[] drinkSorted = Arrays.copyOf(drink, kth);
            Arrays.sort(drinkSorted);
            return forceWash(drinkSorted, a, b, 0, 0, 0);
        }
        int time = Integer.MAX_VALUE;
        for (int i = 0; i < arr.length; i++) {
            int work = arr[i];
            int pre = times[i];
            drink[kth] = pre + work;
            times[i] = pre + work;
            // 递归求出当前这种选择的情况下,其他选择的时间的最小值
            time = Math.min(time, forceMake(arr, times, kth + 1, drink, n, a, b));
            // 清理现场
            drink[kth] = 0;
            times[i] = pre;
        }
        return time;
    }

    public static int forceWash(int[] drinks, int a, int b, int index, int washLine, int time) {
        if (index == drinks.length) {
            return time;
        }
        // 选择一:当前index号咖啡杯,选择用洗咖啡机刷干净
        int wash = Math.max(drinks[index], washLine) + a;
        int ans1 = forceWash(drinks, a, b, index + 1, wash, Math.max(wash, time));

        // 选择二:当前index号咖啡杯,选择自然挥发
        int dry = drinks[index] + b;
        int ans2 = forceWash(drinks, a, b, index + 1, washLine, Math.max(dry, time));
        return Math.min(ans1, ans2);
    }

整体代码和测试如下:

java 复制代码
import java.util.Arrays;
import java.util.Comparator;
import java.util.PriorityQueue;

/**
 * 题目九:喝咖啡问题
 * 给定一个数组arr,arr[i]代表第i号咖啡机泡一杯咖啡的时间,
 * 给定一个正数N,表示N个人等着咖啡机泡咖啡,每台咖啡机只能轮流泡咖啡,
 * 只有一台洗咖啡杯的机器,一次只能洗一个杯子,时间耗费a,洗完才能洗下一杯,
 * 每个咖啡杯也可以自己挥发干净,时间耗费b,咖啡杯可以并行挥发,
 * 假设所有人拿到咖啡之后立刻喝干净,
 * 返回从开始等到所有咖啡杯变干净的最短时间
 * 三个参数:int[] arr、int N,int a、int b
 * <br>
 * 题目解析:
 * 问题中,有arr.length台咖啡机,每台泡咖啡的时间不一样,为arr[i],
 * 洗咖啡杯有两种可选,一种是等唯一的一台机器,洗的时间为a,排队也要耗时;另一个选择是可以自然挥发干净,时间为b。
 * 求的是从开始等到所有杯子变干净的最短时间。
 */
public class Q09_Coffee {

    /**
     * 利用小根堆的暴力递归
     * 思路:
     * 整个题目可以分为两部分:
     * 第一部分是泡咖啡,也就是要把N个人分配给arr.length台咖啡机,使得所有人在最短时间内泡完咖啡,返回每个人泡完咖啡的时间点drink[N],
     * 第二部分是洗咖啡杯,因为泡完咖啡就是喝完,所以要根据泡咖啡的drink[N],计算出最短的洗完杯子的时间,
     * 最后就能计算出整个的最短时间。
     * <br>
     * 首先要计算的是每个人泡(喝完)咖啡的时间:
     * 这个过程的本质上就是利用某种算法,将N个人分配给arr.length台咖啡机进行排队,然后计算出每个人的泡完咖啡的时间点drink[N],
     * 在这个过程中,轮到某个人的时候,我们需要挑选出最值得等的机器,从而计算出其泡完咖啡的时间点。
     * 所以我们可以将咖啡机进行封装,记录其可以用的时间点和时长,并将其放入小根堆中,排序原则是根据其可用的时间点和耗时的和来,这样就可以每次将效率高的可用的咖啡机弹出。
     * 通过这样操作以后,对于某个人i,我们就从小根堆中取出一个机器,从而计算出自己的泡完时间,然后再将机器的可用时间更新到自己的泡完时间,重新放入小根堆中排序。
     * 这样就能计算出每个人的泡完时间,因为泡完就是喝完,喝完就要洗,所以此时的drink[N]就是需要洗的时间节点。
     * <br>
     * 在喝完咖啡时间点的基础上,计算出杯子变干净的最短时间:
     * 在上一步中,我们已经将每个人喝完咖啡的时间放到了drink[N]数组中,也就是每个人要洗的开始时间有了,问题就成了drink[n]开始,计算出洗干净的最短时间问题了。
     * 对于每个人,都有两种选择,要么等机器洗,要么自然挥发,这就成了一个选择的问题。
     * 我们可以用递归的方式,分别计算出每个人的洗和挥发的时间,从而计算出最大值,就是所有的洗干净最短的时间了。
     *
     */
    public static int minTime1(int[] arr, int n, int a, int b) {
        // 利用小根堆来分配泡咖啡的机器
        PriorityQueue<Machine> machineQueue = new PriorityQueue<>(new MachineComparator());
        // 初始化小根堆,将所有咖啡机放入小根堆中
        for (int i = 0; i < arr.length; i++) {
            machineQueue.add(new Machine(0, arr[i]));
        }
        // 定义一个数组,用来记录每个人跑完咖啡的时间
        int[] drink = new int[n];
        // 遍历每个人,从小根堆取出可以用的咖啡机,分配给他泡咖啡,然后把完成时间记录在drink中
        for (int i = 0; i < n; i++) {
            // 从小根堆中取出效率最高的咖啡机
            Machine machine = machineQueue.poll();
            // 计算出当前人泡完咖啡的时间点
            drink[i] = machine.timePoint + machine.workTime;
            // 更新咖啡机的可用时间点
            machine.timePoint = drink[i];
            // 将咖啡机重新放入小根堆中排序
            machineQueue.add(machine);
        }
        // 计算出杯子变干净的最短时间
        return bestWashTime(drink, a, b, 0, 0);
    }

    /**
     * 洗咖啡的递归函数
     * 思路:
     * 这就是一个选择模型的递归,求出每一种情况的值,然后取最小值即可,
     * 每一个调到的函数就是index以前的不用考虑,考虑后面变好的最短时间,逆向到index=0时,就是所有的杯子都变干净的最短时间了。
     * 对于每一个递归,都有两种选择,洗还有等风干,分别求出两种选择的值,然后取最小值就是最优解。
     *
     * @param drinks :drinks 所有杯子可以开始洗的时间
     * @param wash   :wash 单杯洗干净的时间(串行)
     * @param air    :air 挥发干净的时间(并行)
     * @param index  :drinks[index.....]都变干净,最早的结束时间(返回)
     * @param free   :free 洗的机器什么时候可用
     */
    private static int bestWashTime(int[] drinks, int wash, int air, int index, int free) {
        // base case,到了最后,返回0
        if (index == drinks.length) {
            return 0;
        }
        // index号杯子举动洗,
        // index洗就是自己可以洗的时间和机器能洗的时间加上洗的时间,还要考虑其他的杯子的情况,之间的最大值
        // 自己洗的截止时间
        int selfWashClean = Math.max(drinks[index], free) + wash;
        // 其他杯子的干净时间
        int restWashClean = bestWashTime(drinks, wash, air, index + 1, selfWashClean);
        // 最终决定洗的时间就是两者的最大值,因为后面也可能是选的自然挥发,所以有可能是并行的,所以不能加起来,只能取最大值
        int p1 = Math.max(selfWashClean, restWashClean);

        // index号杯子自然挥发,
        // 自然挥发的时间就是自己可以挥发的时间加上挥发的时间
        int selfVolatileClean = drinks[index] + air;
        // 其他杯子的干净时间
        int restVolatileClean = bestWashTime(drinks, wash, air, index + 1, free);
        // 最终决定挥发的时间就是两者的最大值
        int p2 = Math.max(selfVolatileClean, restVolatileClean);

        // 返回两种选择的最小值
        return Math.min(p1, p2);
    }

    /**
     * 贪心+利用小根堆的暴力递归改成动态规划
     * 思路:
     * 从小根堆的递归可以看出,泡咖啡的时间是没有办法优化的,但是洗咖啡的过程是一个递归过程,可以改成动态规划。
     * 分析bestTime递归函数可以知道:
     * 1)有两个可变参数index,free,index从0到N,包含N,因为递归函数中,base case为index==N,返回0,
     * free是不明确的,是一个业务限制模型,不能直观得到范围,但是可以通过计算算出模糊的最大值,我们可以将所有的杯子都作为洗,从而求出其最大值maxFree,
     * 这个时候的缓存表就是dp[N+1][maxFree+1],
     * 2)根据base case,dp[N][i] = 0,因为默认是0,所以就不用再次初始化了。
     * 3)根据递归函数的逻辑,我们可以知道,dp[index][free]依赖于dp[index+1][selfWashClean]和dp[index+1][free],
     * 也就是依赖下一行的值,我们从下往上,从左往右填写表即可。
     * 4)根据第一次调用递归的函数,返回dp[0][0],就是所有杯子都作为洗,从而求出其最小值。
     * <br>
     * 总结:
     * 这个题目比较复杂,所以在做的过程中首先根据贪心算法,求出泡咖啡的最优解,然后在这个基础上根据递归求出洗咖啡的最优解,
     * 同时,这个题目中也出现缓存大小不确定的情况,这个时候,我们就需要根据业务来凑其边界,从而改成动态规划的模型。
     */
    public static int minTime2(int[] arr, int n, int a, int b) {
        // 利用小根堆来分配泡咖啡的机器
        PriorityQueue<Machine> machineQueue = new PriorityQueue<>(new MachineComparator());
        // 初始化小根堆,将所有咖啡机放入小根堆中
        for (int i = 0; i < arr.length; i++) {
            machineQueue.add(new Machine(0, arr[i]));
        }
        // 定义一个数组,用来记录每个人跑完咖啡的时间
        int[] drink = new int[n];
        // 遍历每个人,从小根堆取出可以用的咖啡机,分配给他泡咖啡,然后把完成时间记录在drink中
        for (int i = 0; i < n; i++) {
            // 从小根堆中取出效率最高的咖啡机
            Machine machine = machineQueue.poll();
            // 计算出当前人泡完咖啡的时间点
            drink[i] = machine.timePoint + machine.workTime;
            // 更新咖啡机的可用时间点
            machine.timePoint = drink[i];
            // 将咖啡机重新放入小根堆中排序
            machineQueue.add(machine);
        }
        // 计算出杯子变干净的最短时间
        return bestWashTimeDp(drink, a, b);
    }

    public static int bestWashTimeDp(int[] drinks, int wash, int air) {
        int N = drinks.length;
        // 根据业务,计算出free的边界maxFree,
        // 因为每个杯子都可以作为洗,我们计算,肯定是有些杯子要不洗,所以全部洗就是其最大的情况,
        // 这个时候的free就是所有杯子中最晚的那个时间点加上洗的时间
        int maxFree = 0;
        for (int i = 0; i < N; i++) {
            maxFree = Math.max(maxFree, drinks[i]) + wash;
        }
        // 定义缓存表
        int dp[][] = new int[N + 1][maxFree + 1];
        //根据base case,dp[N][i] = 0,因为默认是0,所以就不用再次初始化了
        // 根据递归函数的逻辑,我们可以知道,dp[index][free]依赖于dp[index+1][selfWashClean]和dp[index+1][free],
        // 也就是依赖下一行的值,我们从下往上,从左往右填写表即可。
        for (int index = N - 1; index >= 0; index--) {
            for (int free = 0; free <= maxFree; free++) {
                // index号杯子洗的情况
                int selfWashClean = Math.max(drinks[index], free) + wash;
                // 排除掉不可能出现的情况,防止越界
                if (selfWashClean > maxFree) {
                    break; // 因为后面的也都不用填了
                }
                // index 洗,其他杯子的情况
                int restWashClean = dp[index + 1][selfWashClean];
                // index决定洗的最后结果
                int p1 = Math.max(selfWashClean, restWashClean);

                // index 挥发,其他杯子的情况
                int selfVolatileClean = drinks[index] + air;
                int restVolatileClean = dp[index + 1][free];
                // index决定挥发的最后结果
                int p2 = Math.max(selfVolatileClean, restVolatileClean);
                // 返回两种选择的最小值
                dp[index][free] = Math.min(p1, p2);
            }
        }
        return dp[0][0];
    }

    // 泡咖啡机器封装类
    public static class Machine {
        // 可以使用的时间点
        public int timePoint;
        // 咖啡机泡一杯咖啡的时间
        public int workTime;

        public Machine(int t, int w) {
            timePoint = t;
            workTime = w;
        }
    }

    /**
     * 咖啡机比较器,根据咖啡机可以使用的时间点和泡咖啡时间,排序
     */
    public static class MachineComparator implements Comparator<Machine> {

        @Override
        public int compare(Machine o1, Machine o2) {
            return (o1.timePoint + o1.workTime) - (o2.timePoint + o2.workTime);
        }

    }

    /**
     * 枚举所有可能的暴力递归
     * 思路:
     * 很慢但是绝对正确,可以作为比较器来使用。
     * 每一个人来了以后,都尝试用每一台咖啡机来给自己做咖啡,然后求出整体时间的最短值。
     * 这是一个递归求解的过程,对于当前递归的时候,如果第kth个人用第i台咖啡机泡咖啡,要递归求出后面人的选择,
     * 到最后一个人后,又递归算出这种选择下的洗咖啡杯的最短时间。返回整体的时间。
     * 枚举所有的可能性以后,最小值就是最优解。
     *
     */
    public static int rightComparator(int[] arr, int n, int a, int b) {
        // 记录每一个机器的可用时间点(截止时间点)
        int[] times = new int[arr.length];
        // 记录每个人做完咖啡的截止时间点
        int[] drink = new int[n];
        return forceMake(arr, times, 0, drink, n, a, b);
    }

    // 每个人暴力尝试用每一个咖啡机给自己做咖啡
    public static int forceMake(int[] arr, int[] times, int kth, int[] drink, int n, int a, int b) {
        if (kth == n) {
            // 一种选择到头以后,递归求洗咖啡杯的最短时间
            int[] drinkSorted = Arrays.copyOf(drink, kth);
            Arrays.sort(drinkSorted);
            return forceWash(drinkSorted, a, b, 0, 0, 0);
        }
        int time = Integer.MAX_VALUE;
        for (int i = 0; i < arr.length; i++) {
            int work = arr[i];
            int pre = times[i];
            drink[kth] = pre + work;
            times[i] = pre + work;
            // 递归求出当前这种选择的情况下,其他选择的时间的最小值
            time = Math.min(time, forceMake(arr, times, kth + 1, drink, n, a, b));
            // 清理现场
            drink[kth] = 0;
            times[i] = pre;
        }
        return time;
    }

    public static int forceWash(int[] drinks, int a, int b, int index, int washLine, int time) {
        if (index == drinks.length) {
            return time;
        }
        // 选择一:当前index号咖啡杯,选择用洗咖啡机刷干净
        int wash = Math.max(drinks[index], washLine) + a;
        int ans1 = forceWash(drinks, a, b, index + 1, wash, Math.max(wash, time));

        // 选择二:当前index号咖啡杯,选择自然挥发
        int dry = drinks[index] + b;
        int ans2 = forceWash(drinks, a, b, index + 1, washLine, Math.max(dry, time));
        return Math.min(ans1, ans2);
    }

    public static void main(String[] args) {
        int len = 10;
        int max = 10;
        int testTime = 100;
        System.out.println("测试开始");
        for (int i = 0; i < testTime; i++) {
            int[] arr = randomArray(len, max);
            int n = (int) (Math.random() * 7) + 1;
            int a = (int) (Math.random() * 7) + 1;
            int b = (int) (Math.random() * 10) + 1;
            int ans1 = rightComparator(arr, n, a, b);
            int ans2 = minTime1(arr, n, a, b);
            int ans3 = minTime2(arr, n, a, b);
            if (ans1 != ans2 || ans2 != ans3) {
                System.out.println("=======出错========");
                printArray(arr);
                System.out.println("n : " + n);
                System.out.println("a : " + a);
                System.out.println("b : " + b);
                System.out.println(ans1 + " , " + ans2 + " , " + ans3);
                break;
            }
        }
        System.out.println("测试结束");

    }

    // for test
    public static int[] randomArray(int len, int max) {
        int[] arr = new int[len];
        for (int i = 0; i < len; i++) {
            arr[i] = (int) (Math.random() * max) + 1;
        }
        return arr;
    }

    // for test
    public static void printArray(int[] arr) {
        System.out.print("arr : ");
        for (int j = 0; j < arr.length; j++) {
            System.out.print(arr[j] + ", ");
        }
        System.out.println();
    }
}

2.10、题目十:路径最小累加和

  • 题目十:路径最小累加和
  • 给定一个二维矩阵m,一个人必须从左上角出发,最后到达右下角,
  • 沿途只可以向下或者向右走,沿途的数字都累加就是距离累加和,
  • 返回最小距离累加和
2.10.1、暴力递归的方法
  • 暴力递归的方法
  • 思路:
    • 从左往右的尝试方法。
    • 我们从左上角出发,每次只能向下或者向右走,所以我们可以用递归的方式来尝试所有的路径,最后求出最小距离累加和。
    • 递归函数的定义:int process(int[][] m, int i, int j)表示目前已经走到(i,j)位置,求出从当前位置到达右下角的最小距离累加和。
    • 递归函数的base case:如果i == row - 1 && j == col - 1,说明我们已经到达了右下角,返回m[i][j]。如果只有一个到了,就走另一个方向。
    • 递归函数的递归过程:我们可以从(i,j)位置出发,向下走或者向右走,所以我们可以递归调用process(m, i + 1, j)和process(m, i, j + 1),最后取小的那个。
java 复制代码
    /**
     * 暴力递归的方法
     * 思路:
     * 从左往右的尝试方法。
     * 我们从左上角出发,每次只能向下或者向右走,所以我们可以用递归的方式来尝试所有的路径,最后求出最小距离累加和。
     * 递归函数的定义:int process(int[][] m, int i, int j)表示目前已经走到(i,j)位置,求出从当前位置到达右下角的最小距离累加和。
     * 递归函数的base case:如果i == row - 1 && j == col - 1,说明我们已经到达了右下角,返回m[i][j]。如果只有一个到了,就走另一个方向。
     * 递归函数的递归过程:我们可以从(i,j)位置出发,向下走或者向右走,所以我们可以递归调用process(m, i + 1, j)和process(m, i, j + 1),最后取小的那个。
     */
    public static int minPathSum1(int[][] m) {
        if (m == null || m.length == 0 || m[0] == null || m[0].length == 0) {
            return 0;
        }
        return process(m, 0, 0);
    }

    /**
     * 递归函数
     */
    private static int process(int[][] m, int i, int j) {
        int row = m.length;
        int col = m[0].length;
        // base case1: 到达最后一个位置
        if (i == row - 1 && j == col - 1) {
            return m[i][j];
        }
        // base case2:到达最后一行,只往右走
        if (i == row - 1) {
            return m[i][j] + process(m, i, j + 1);
        }
        // base case3:到达最后一列,只往下走
        if (j == col - 1) {
            return m[i][j] + process(m, i + 1, j);
        }
        // 既能往右走,也能往下走,取两个中的小值
        return m[i][j] + Math.min(process(m, i, j + 1), process(m, i + 1, j));
    }
2.10.2、暴力递归尝试改为动态规划
  • 暴力递归尝试改为动态规划
  • 思路:
    • 1、根据递归函数,有i和j两个参数,到row-1和col-1,所以缓存表为dp[row][col],
    • 2、根据base case,dp[row-1][col-1]=m[row-1][col-1],然后填写最后一行和最后一列
    • 3、对于其他位置,当前位置依赖于下边和右边的最小值,即dp[i][j]=m[i][j] + Math.min(dp[i+1][j],dp[i][j+1])
    • 4、根据第一次调用函数,返回dp[0][0]
java 复制代码
    /**
     * 暴力递归尝试改为动态规划
     * 思路:
     * 1、根据递归函数,有i和j两个参数,到row-1和col-1,所以缓存表为dp[row][col],
     * 2、根据base case,dp[row-1][col-1]=m[row-1][col-1],然后填写最后一行和最后一列
     * 3、对于其他位置,当前位置依赖于下边和右边的最小值,即dp[i][j]=m[i][j] + Math.min(dp[i+1][j],dp[i][j+1])
     * 4、根据第一次调用函数,返回dp[0][0]
     */
    public static int minPathSum2(int[][] m) {
        if (m == null || m.length == 0 || m[0] == null || m[0].length == 0) {
            return 0;
        }
        int row = m.length;
        int col = m[0].length;
        int[][] dp = new int[row][col];
        // base case 1
        dp[row - 1][col - 1] = m[row - 1][col - 1];
        // 最后一行
        for (int i = col - 2; i >= 0; i--) {
            dp[row - 1][i] = m[row - 1][i] + dp[row - 1][i + 1];
        }
        // 最后一列
        for (int i = row - 2; i >= 0; i--) {
            dp[i][col - 1] = m[i][col - 1] + dp[i + 1][col - 1];
        }
        // 其他位置
        for (int i = row - 2; i >= 0; i--) {
            for (int j = col - 2; j >= 0; j--) {
                dp[i][j] = m[i][j] + Math.min(dp[i + 1][j], dp[i][j + 1]);
            }
        }
        return dp[0][0];
    }
2.10.3、直接用动态规划方法
  • 直接用动态规划方法

  • 思路:

    • 题目当然是可以用暴力递归尝试的方法改过来,就是两种方法做递归尝试,最后求出最小值,然后求出最小距离累加和,最后改成动态规划的方式。
    • 但是每次改,都是比较耗费时间的,练习熟练以后,我们还是要尽量优先使用直接用动态规划的方式来思考问题,实在不行的时候再去用暴力递归尝试的方法。
  • 直接用动态规划的思路:

    • 我们定义一个和m一样大小的dp数组,其dp[i][j]表示从dp[0][0]出发,到达i,j位置的最小距离累加和。
    • 对于第一行,我们只能横着走,所以dp[0][j] = dp[0][j - 1] + m[0][j]
    • 对于第一列,我们只能竖着走,所以dp[i][0] = dp[i - 1][0] + m[i][0]
    • 对于其他位置,我们可以从上面或者左边过来,就是选小的,所以dp[i][j] = Math.min(dp[i - 1][j], dp[i][j - 1]) + m[i][j]
    • 最后返回dp[row - 1][col - 1]就是我们要求的结果。
  • 总结,

    • 直接用动态规划的方法和用暴力递归改为动态规划的方法是不一样的,主要是填表的方向不同,用暴力递归改的是从下往上,从右往左填表,
    • 直接用动态规划的是从上往下,从左往右填表。
    • 不同的主要原因是用暴力递归改的时候,是根据自然的递归思路,从而改出来就是从下往上的依赖,直接填表的自然思路是从小往大,
    • 两种方法在思想上是一样的,都是利用先算好的,计算出后面的。
    • 我们在做题目时,一开始不熟悉,用递归尝试的方法,后面熟悉以后,就直接可以用直接动态规划的方法了,因为写递归尝试,毕竟需要浪费时间,
    • 除非一开始想不到直接动态规划的方法,需要先用递归尝试的方法实现,然后再改过来。
    • 但是随着越来越熟悉,这种改过来的方法应该是越来用的越少才对。
java 复制代码
    /**
     * 直接用动态规划方法
     * 思路:
     * 题目当然是可以用暴力递归尝试的方法改过来,就是两种方法做递归尝试,最后求出最小值,然后求出最小距离累加和,最后改成动态规划的方式。
     * 但是每次改,都是比较耗费时间的,练习熟练以后,我们还是要尽量优先使用直接用动态规划的方式来思考问题,实在不行的时候再去用暴力递归尝试的方法。
     * <br>
     * 直接用动态规划的思路:
     * 我们定义一个和m一样大小的dp数组,其dp[i][j]表示从dp[0][0]出发,到达i,j位置的最小距离累加和。
     * 对于第一行,我们只能横着走,所以dp[0][j] = dp[0][j - 1] + m[0][j]
     * 对于第一列,我们只能竖着走,所以dp[i][0] = dp[i - 1][0] + m[i][0]
     * 对于其他位置,我们可以从上面或者左边过来,就是选小的,所以dp[i][j] = Math.min(dp[i - 1][j], dp[i][j - 1]) + m[i][j]
     * 最后返回dp[row - 1][col - 1]就是我们要求的结果。
     * <br>
     * 总结,直接用动态规划的方法和用暴力递归改为动态规划的方法是不一样的,主要是填表的方向不同,用暴力递归改的是从下往上,从右往左填表,
     * 直接用动态规划的是从上往下,从左往右填表。
     * 不同的主要原因是用暴力递归改的时候,是根据自然的递归思路,从而改出来就是从下往上的依赖,直接填表的自然思路是从小往大,
     * 两种方法在思想上是一样的,都是利用先算好的,计算出后面的。
     * 我们在做题目时,一开始不熟悉,用递归尝试的方法,后面熟悉以后,就直接可以用直接动态规划的方法了,因为写递归尝试,毕竟需要浪费时间,
     * 除非一开始想不到直接动态规划的方法,需要先用递归尝试的方法实现,然后再改过来。
     * 但是随着越来越熟悉,这种改过来的方法应该是越来用的越少才对。
     */
    public static int minPathSum3(int[][] m) {
        if (m == null || m.length == 0 || m[0] == null || m[0].length == 0) {
            return 0;
        }
        int row = m.length;
        int col = m[0].length;
        int[][] dp = new int[row][col];
        // 先填写基础位置
        dp[0][0] = m[0][0];
        // 填写第一列,只能往下走
        for (int i = 1; i < row; i++) {
            dp[i][0] = dp[i - 1][0] + m[i][0];
        }
        // 填写第一行,只能往右走
        for (int i = 1; i < col; i++) {
            dp[0][i] = dp[0][i - 1] + m[0][i];
        }
        // 填写其他位置
        for (int i = 1; i < row; i++) {
            for (int j = 1; j < col; j++) {
                dp[i][j] = m[i][j] + Math.min(dp[i - 1][j], dp[i][j - 1]);
            }
        }
        return dp[row - 1][col - 1];
    }
2.10.4、基于动态规划的空间压缩方法
  • 基于动态规划的空间压缩方法

  • 思路:

    • 在上面的动态规划(minPathSum3)中,我们用了一个和m同样大小的数组dp,来缓存之前计算的结果,根据题目可以知道,我们每次都是只依赖前面和上面的结果,
    • 每次都是先从左往右先计算出第一行,然后再从左往右计算第二行的结果,计算完第二行以后,第三行就依赖第二行,和第一行就没有关系了。
    • 对于这种情况,我们就可以只需要一个一维数组dpArr,用来缓存上一行的结果,这样也能计算出结果,同时也能省空间。
    • 比如我们用dpArr数组和m的列的长度一样,这样第1行的结果和之前的计算方法是一样的,
    • 到了第2行,dpArr[0]的位置就是依赖上一行的dpArr[0],即当前dpArr[0]中存的是上一个的结果,所以dpArr[0] = dpArr[0] + m[1][0]即可,
    • dpArr[1]的位置就是依赖上一行的dpArr[1]和dpArr[0],所以dpArr[1] = Math.min(dpArr[1], dpArr[0]) + m[1][1],
    • 同理可以推出第i行第j列的结果:dpArr[0] = dpArr[0] + m[i][0],dpArr[j] = Math.min(dpArr[j - 1], dpArr[j]) + m[i][j],
    • 最后返回dpArr[col - 1]就是我们要求的结果。
  • 结论:

    • 对于一个结果,需要依赖其左边、上边、左上角的结果的这种动态规划,我们可以使用一个一维数组来做空间压缩,
    • 用一维数组记录上次计算的结果,利用自更新的方法,依次计算出每一行的结果,最后返回dpArr[col - 1]就是我们要求的结果。
    • 示例中我们计算是从上到下,从左到右的方式,先填上一行,再填下一行所以我们定义的数组是和列的长度一样的。
    • 其实我们也可以从以行的长度来定义数组,我们就可以从先填第一列,再填写一列的方式,从左到右的方向填数组。具体写法可以自行探索。
java 复制代码
    /**
     * 基于动态规划的空间压缩方法
     * 思路:
     * 在上面的动态规划(minPathSum3)中,我们用了一个和m同样大小的数组dp,来缓存之前计算的结果,根据题目可以知道,我们每次都是只依赖前面和上面的结果,
     * 每次都是先从左往右先计算出第一行,然后再从左往右计算第二行的结果,计算完第二行以后,第三行就依赖第二行,和第一行就没有关系了。
     * 对于这种情况,我们就可以只需要一个一维数组dpArr,用来缓存上一行的结果,这样也能计算出结果,同时也能省空间。
     * 比如我们用dpArr数组和m的列的长度一样,这样第1行的结果和之前的计算方法是一样的,
     * 到了第2行,dpArr[0]的位置就是依赖上一行的dpArr[0],即当前dpArr[0]中存的是上一个的结果,所以dpArr[0] = dpArr[0] + m[1][0]即可,
     * dpArr[1]的位置就是依赖上一行的dpArr[1]和dpArr[0],所以dpArr[1] = Math.min(dpArr[1], dpArr[0]) + m[1][1],
     * 同理可以推出第i行第j列的结果:dpArr[0] = dpArr[0] + m[i][0],dpArr[j] = Math.min(dpArr[j - 1], dpArr[j]) + m[i][j],
     * 最后返回dpArr[col - 1]就是我们要求的结果。
     * <br>
     * 结论:
     * 对于一个结果,需要依赖其左边、上边、左上角的结果的这种动态规划,我们可以使用一个一维数组来做空间压缩,
     * 用一维数组记录上次计算的结果,利用自更新的方法,依次计算出每一行的结果,最后返回dpArr[col - 1]就是我们要求的结果。
     * 示例中我们计算是从上到下,从左到右的方式,先填上一行,再填下一行所以我们定义的数组是和列的长度一样的。
     * 其实我们也可以从以行的长度来定义数组,我们就可以从先填第一列,再填写一列的方式,从左到右的方向填数组。具体写法可以自行探索。
     */
    public static int minPathSum4(int[][] m) {
        if (m == null || m.length == 0 || m[0] == null || m[0].length == 0) {
            return 0;
        }
        int row = m.length;
        int col = m[0].length;
        // 用一个一维数组做缓存
        int[] dpArr = new int[col];
        // 先初始化第一行
        dpArr[0] = m[0][0];
        for (int i = 1; i < col; i++) {
            dpArr[i] = m[0][i] + dpArr[i - 1];
        }
        // 计算其他行
        for (int i = 1; i < row; i++) {
            // 第一列,只依赖上一个位置
            dpArr[0] += m[i][0];
            // 填写第i行的其他列
            for (int j = 1; j < col; j++) {
                dpArr[j] = m[i][j] + Math.min(dpArr[j], dpArr[j - 1]);
            }
        }
        return dpArr[col - 1];
    }

整体代码和测试如下:

java 复制代码
/**
 * 题目十:路径最小累加和
 * 给定一个二维矩阵m,一个人必须从左上角出发,最后到达右下角,
 * 沿途只可以向下或者向右走,沿途的数字都累加就是距离累加和,
 * 返回最小距离累加和
 */
public class Q10_MinPathSum {

    /**
     * 暴力递归的方法
     * 思路:
     * 从左往右的尝试方法。
     * 我们从左上角出发,每次只能向下或者向右走,所以我们可以用递归的方式来尝试所有的路径,最后求出最小距离累加和。
     * 递归函数的定义:int process(int[][] m, int i, int j)表示目前已经走到(i,j)位置,求出从当前位置到达右下角的最小距离累加和。
     * 递归函数的base case:如果i == row - 1 && j == col - 1,说明我们已经到达了右下角,返回m[i][j]。如果只有一个到了,就走另一个方向。
     * 递归函数的递归过程:我们可以从(i,j)位置出发,向下走或者向右走,所以我们可以递归调用process(m, i + 1, j)和process(m, i, j + 1),最后取小的那个。
     */
    public static int minPathSum1(int[][] m) {
        if (m == null || m.length == 0 || m[0] == null || m[0].length == 0) {
            return 0;
        }
        return process(m, 0, 0);
    }

    /**
     * 递归函数
     */
    private static int process(int[][] m, int i, int j) {
        int row = m.length;
        int col = m[0].length;
        // base case1: 到达最后一个位置
        if (i == row - 1 && j == col - 1) {
            return m[i][j];
        }
        // base case2:到达最后一行,只往右走
        if (i == row - 1) {
            return m[i][j] + process(m, i, j + 1);
        }
        // base case3:到达最后一列,只往下走
        if (j == col - 1) {
            return m[i][j] + process(m, i + 1, j);
        }
        // 既能往右走,也能往下走,取两个中的小值
        return m[i][j] + Math.min(process(m, i, j + 1), process(m, i + 1, j));
    }

    /**
     * 暴力递归尝试改为动态规划
     * 思路:
     * 1、根据递归函数,有i和j两个参数,到row-1和col-1,所以缓存表为dp[row][col],
     * 2、根据base case,dp[row-1][col-1]=m[row-1][col-1],然后填写最后一行和最后一列
     * 3、对于其他位置,当前位置依赖于下边和右边的最小值,即dp[i][j]=m[i][j] + Math.min(dp[i+1][j],dp[i][j+1])
     * 4、根据第一次调用函数,返回dp[0][0]
     */
    public static int minPathSum2(int[][] m) {
        if (m == null || m.length == 0 || m[0] == null || m[0].length == 0) {
            return 0;
        }
        int row = m.length;
        int col = m[0].length;
        int[][] dp = new int[row][col];
        // base case 1
        dp[row - 1][col - 1] = m[row - 1][col - 1];
        // 最后一行
        for (int i = col - 2; i >= 0; i--) {
            dp[row - 1][i] = m[row - 1][i] + dp[row - 1][i + 1];
        }
        // 最后一列
        for (int i = row - 2; i >= 0; i--) {
            dp[i][col - 1] = m[i][col - 1] + dp[i + 1][col - 1];
        }
        // 其他位置
        for (int i = row - 2; i >= 0; i--) {
            for (int j = col - 2; j >= 0; j--) {
                dp[i][j] = m[i][j] + Math.min(dp[i + 1][j], dp[i][j + 1]);
            }
        }
        return dp[0][0];
    }

    /**
     * 直接用动态规划方法
     * 思路:
     * 题目当然是可以用暴力递归尝试的方法改过来,就是两种方法做递归尝试,最后求出最小值,然后求出最小距离累加和,最后改成动态规划的方式。
     * 但是每次改,都是比较耗费时间的,练习熟练以后,我们还是要尽量优先使用直接用动态规划的方式来思考问题,实在不行的时候再去用暴力递归尝试的方法。
     * <br>
     * 直接用动态规划的思路:
     * 我们定义一个和m一样大小的dp数组,其dp[i][j]表示从dp[0][0]出发,到达i,j位置的最小距离累加和。
     * 对于第一行,我们只能横着走,所以dp[0][j] = dp[0][j - 1] + m[0][j]
     * 对于第一列,我们只能竖着走,所以dp[i][0] = dp[i - 1][0] + m[i][0]
     * 对于其他位置,我们可以从上面或者左边过来,就是选小的,所以dp[i][j] = Math.min(dp[i - 1][j], dp[i][j - 1]) + m[i][j]
     * 最后返回dp[row - 1][col - 1]就是我们要求的结果。
     * <br>
     * 总结,直接用动态规划的方法和用暴力递归改为动态规划的方法是不一样的,主要是填表的方向不同,用暴力递归改的是从下往上,从右往左填表,
     * 直接用动态规划的是从上往下,从左往右填表。
     * 不同的主要原因是用暴力递归改的时候,是根据自然的递归思路,从而改出来就是从下往上的依赖,直接填表的自然思路是从小往大,
     * 两种方法在思想上是一样的,都是利用先算好的,计算出后面的。
     * 我们在做题目时,一开始不熟悉,用递归尝试的方法,后面熟悉以后,就直接可以用直接动态规划的方法了,因为写递归尝试,毕竟需要浪费时间,
     * 除非一开始想不到直接动态规划的方法,需要先用递归尝试的方法实现,然后再改过来。
     * 但是随着越来越熟悉,这种改过来的方法应该是越来用的越少才对。
     */
    public static int minPathSum3(int[][] m) {
        if (m == null || m.length == 0 || m[0] == null || m[0].length == 0) {
            return 0;
        }
        int row = m.length;
        int col = m[0].length;
        int[][] dp = new int[row][col];
        // 先填写基础位置
        dp[0][0] = m[0][0];
        // 填写第一列,只能往下走
        for (int i = 1; i < row; i++) {
            dp[i][0] = dp[i - 1][0] + m[i][0];
        }
        // 填写第一行,只能往右走
        for (int i = 1; i < col; i++) {
            dp[0][i] = dp[0][i - 1] + m[0][i];
        }
        // 填写其他位置
        for (int i = 1; i < row; i++) {
            for (int j = 1; j < col; j++) {
                dp[i][j] = m[i][j] + Math.min(dp[i - 1][j], dp[i][j - 1]);
            }
        }
        return dp[row - 1][col - 1];
    }

    /**
     * 基于动态规划的空间压缩方法
     * 思路:
     * 在上面的动态规划(minPathSum3)中,我们用了一个和m同样大小的数组dp,来缓存之前计算的结果,根据题目可以知道,我们每次都是只依赖前面和上面的结果,
     * 每次都是先从左往右先计算出第一行,然后再从左往右计算第二行的结果,计算完第二行以后,第三行就依赖第二行,和第一行就没有关系了。
     * 对于这种情况,我们就可以只需要一个一维数组dpArr,用来缓存上一行的结果,这样也能计算出结果,同时也能省空间。
     * 比如我们用dpArr数组和m的列的长度一样,这样第1行的结果和之前的计算方法是一样的,
     * 到了第2行,dpArr[0]的位置就是依赖上一行的dpArr[0],即当前dpArr[0]中存的是上一个的结果,所以dpArr[0] = dpArr[0] + m[1][0]即可,
     * dpArr[1]的位置就是依赖上一行的dpArr[1]和dpArr[0],所以dpArr[1] = Math.min(dpArr[1], dpArr[0]) + m[1][1],
     * 同理可以推出第i行第j列的结果:dpArr[0] = dpArr[0] + m[i][0],dpArr[j] = Math.min(dpArr[j - 1], dpArr[j]) + m[i][j],
     * 最后返回dpArr[col - 1]就是我们要求的结果。
     * <br>
     * 结论:
     * 对于一个结果,需要依赖其左边、上边、左上角的结果的这种动态规划,我们可以使用一个一维数组来做空间压缩,
     * 用一维数组记录上次计算的结果,利用自更新的方法,依次计算出每一行的结果,最后返回dpArr[col - 1]就是我们要求的结果。
     * 示例中我们计算是从上到下,从左到右的方式,先填上一行,再填下一行所以我们定义的数组是和列的长度一样的。
     * 其实我们也可以从以行的长度来定义数组,我们就可以从先填第一列,再填写一列的方式,从左到右的方向填数组。具体写法可以自行探索。
     */
    public static int minPathSum4(int[][] m) {
        if (m == null || m.length == 0 || m[0] == null || m[0].length == 0) {
            return 0;
        }
        int row = m.length;
        int col = m[0].length;
        // 用一个一维数组做缓存
        int[] dpArr = new int[col];
        // 先初始化第一行
        dpArr[0] = m[0][0];
        for (int i = 1; i < col; i++) {
            dpArr[i] = m[0][i] + dpArr[i - 1];
        }
        // 计算其他行
        for (int i = 1; i < row; i++) {
            // 第一列,只依赖上一个位置
            dpArr[0] += m[i][0];
            // 填写第i行的其他列
            for (int j = 1; j < col; j++) {
                dpArr[j] = m[i][j] + Math.min(dpArr[j], dpArr[j - 1]);
            }
        }
        return dpArr[col - 1];
    }

    public static void main(String[] args) {
        // 测试次数
        int testTime = 100000;
        int rowSize = 10;
        int colSize = 10;
        boolean succeed = true;
        for (int i = 0; i < testTime; i++) {
            // 先生成一个随机的数组
            int[][] m = generateRandomMatrix(rowSize, colSize);
            int ans1 = minPathSum1(m);
            int ans2 = minPathSum2(m);
            int ans3 = minPathSum3(m);
            int ans4 = minPathSum4(m);
            if (ans1 != ans2 || ans2 != ans3 || ans3 != ans4) {
                succeed = false;
                System.out.println("原矩阵:");
                printMatrix(m);
                System.out.println("minPathSum1的结果:" + ans1);
                System.out.println("minPathSum2的结果:" + ans2);
                System.out.println("minPathSum3的结果:" + ans3);
                System.out.println("minPathSum4的结果:" + ans4);
                break;
            }
        }
        System.out.println(succeed ? "successful!" : "error!");
    }

    // for test
    public static int[][] generateRandomMatrix(int rowSize, int colSize) {
        if (rowSize < 0 || colSize < 0) {
            return null;
        }
        int[][] result = new int[rowSize][colSize];
        for (int i = 0; i != result.length; i++) {
            for (int j = 0; j != result[0].length; j++) {
                result[i][j] = (int) (Math.random() * 100);
            }
        }
        return result;
    }

    // for test
    public static void printMatrix(int[][] matrix) {
        if (matrix == null || matrix.length == 0 || matrix[0] == null || matrix[0].length == 0) {
            return;
        }
        for (int i = 0; i != matrix.length; i++) {
            for (int j = 0; j != matrix[0].length; j++) {
                System.out.printf("%5d", matrix[i][j]);
            }
            System.out.println();
        }
    }
}

2.11、题目十一:组成货币的方法数(每一张货币都不同)

  • 题目十一:组成货币的方法数(每一张货币都不同)
  • arr是货币数组,其中的值都是正值。再给定一个正数aim。
  • 每个值都认为是一张货币,即便是值相同的货币,也认为是不同的货币。
  • 求组成aim的方法数。
  • 例如:arr = {1,1,1},aim = 2
  • 第0个和第1个、第1个和第2个、第0个和第2个都能组成2,所以一共有3种方法,返回3.
  • 题目解析:
  • 每一张货币都不同的意思是不管是不是面值相同,都可以当成面值不同来使用,也就是在枚举的过程中要分开计算每一张货币。
2.11.1、暴力递归的方法
  • 暴力递归的方法:
  • 思路:
    • 从左往右的尝试模型。
    • 题目中每一张货币都是不同的,所以对于每一个位置index的货币,就有要和不要两种情况,从中选出符合条件的方法数。
    • 就是我们需要的结果。
    • 从左到右的尝试是可以试到所有的方法的,所以我们只需要在数组最后的位置判断是不是达到了目标即可
java 复制代码
    /**
     * 暴力递归的方法:
     * 思路:
     * 从左往右的尝试模型。
     * 题目中每一张货币都是不同的,所以对于每一个位置index的货币,就有要和不要两种情况,从中选出符合条件的方法数。
     * 就是我们需要的结果。
     * 从左到右的尝试是可以试到所有的方法的,所以我们只需要在数组最后的位置判断是不是达到了目标即可
     */
    public static int coinWays1(int[] arr, int aim) {
        if (aim == 0) {
            return 1;
        }
        return process(arr, 0, aim);
    }

    /**
     * 递归函数
     * arr[index....] 组成正好rest这么多的钱,有几种方法
     */
    private static int process(int[] arr, int index, int rest) {
        // 如果rest < 0,说明上上一张不够,加上上一张又超了,这种办法没法达到刚好aim,所以是不行的方法,返回0
        if (rest < 0) {
            return 0;
        }
        // base case:到达最后的位置,判断是不是达到了目标
        if (index == arr.length) {
            return rest == 0 ? 1 : 0;
        }
        // 其他位置,返回要和不要的两种结果和
        return process(arr, index + 1, rest) + process(arr, index + 1, rest - arr[index]);
    }
2.11.2、动态规划的方法
  • 动态规划的方法:
  • 思路:
    • 从左往右的动态规划模型。
    • 我们可以根据暴力递归的递归函数,分析出dp数组的含义:dp[index][rest]的含义是:在arr[index...]的范围上,组成rest这么多的钱,有几种方法。
    • 我们可以根据暴力递归的方法,分析出base case。dp[N][0] = 1;
    • 我们可以根据暴力递归的方法,分析出递归的过程:dp[index][rest] = dp[index + 1][rest] + dp[index + 1][rest - arr[index]];
    • 当然,要考虑数组的rest是否越界的情况。
    • 最后的结果就是dp[0][aim]。
java 复制代码
    /**
     * 动态规划的方法:
     * 思路:
     * 从左往右的动态规划模型。
     * 我们可以根据暴力递归的递归函数,分析出dp数组的含义:dp[index][rest]的含义是:在arr[index....]的范围上,组成rest这么多的钱,有几种方法。
     * 我们可以根据暴力递归的方法,分析出base case。dp[N][0] = 1;
     * 我们可以根据暴力递归的方法,分析出递归的过程:dp[index][rest] = dp[index + 1][rest] + dp[index + 1][rest - arr[index]];
     * 当然,要考虑数组的rest是否越界的情况。
     * 最后的结果就是dp[0][aim]。
     */
    public static int coinWays2(int[] arr, int aim) {
        if (aim == 0) {
            return 1;
        }
        int N = arr.length;
        int[][] dp = new int[N + 1][aim + 1];
        // 根据 base case,填写初始值
        dp[N][0] = 1;
        // 从下往上,从左往右填表
        for (int index = N - 1; index >= 0; index--) {
            for (int rest = 0; rest <= aim; rest++) {
                dp[index][rest] = dp[index + 1][rest] + (rest - arr[index] >= 0 ? dp[index + 1][rest - arr[index]] : 0);
            }
        }
        return dp[0][aim];
    }

整体代码和测试如下:

java 复制代码
/**
 * 题目十一:组成货币的方法数(每一张货币都不同)
 * arr是货币数组,其中的值都是正值。再给定一个正数aim。
 * 每个值都认为是一张货币,即便是值相同的货币,也认为是不同的货币。
 * 求组成aim的方法数。
 * 例如:arr = {1,1,1},aim = 2
 * 第0个和第1个、第1个和第2个、第0个和第2个都能组成2,所以一共有3种方法,返回3.
 * 题目解析:
 * 每一张货币都不同的意思是不管是不是面值相同,都可以当成面值不同来使用,也就是在枚举的过程中要分开计算每一张货币。
 */
public class Q11_CoinsWayEveryPaperDifferent {

    /**
     * 暴力递归的方法:
     * 思路:
     * 从左往右的尝试模型。
     * 题目中每一张货币都是不同的,所以对于每一个位置index的货币,就有要和不要两种情况,从中选出符合条件的方法数。
     * 就是我们需要的结果。
     * 从左到右的尝试是可以试到所有的方法的,所以我们只需要在数组最后的位置判断是不是达到了目标即可
     */
    public static int coinWays1(int[] arr, int aim) {
        if (aim == 0) {
            return 1;
        }
        return process(arr, 0, aim);
    }

    /**
     * 递归函数
     * arr[index....] 组成正好rest这么多的钱,有几种方法
     */
    private static int process(int[] arr, int index, int rest) {
        // 如果rest < 0,说明上上一张不够,加上上一张又超了,这种办法没法达到刚好aim,所以是不行的方法,返回0
        if (rest < 0) {
            return 0;
        }
        // base case:到达最后的位置,判断是不是达到了目标
        if (index == arr.length) {
            return rest == 0 ? 1 : 0;
        }
        // 其他位置,返回要和不要的两种结果和
        return process(arr, index + 1, rest) + process(arr, index + 1, rest - arr[index]);
    }

    /**
     * 动态规划的方法:
     * 思路:
     * 从左往右的动态规划模型。
     * 我们可以根据暴力递归的递归函数,分析出dp数组的含义:dp[index][rest]的含义是:在arr[index....]的范围上,组成rest这么多的钱,有几种方法。
     * 我们可以根据暴力递归的方法,分析出base case。dp[N][0] = 1;
     * 我们可以根据暴力递归的方法,分析出递归的过程:dp[index][rest] = dp[index + 1][rest] + dp[index + 1][rest - arr[index]];
     * 当然,要考虑数组的rest是否越界的情况。
     * 最后的结果就是dp[0][aim]。
     */
    public static int coinWays2(int[] arr, int aim) {
        if (aim == 0) {
            return 1;
        }
        int N = arr.length;
        int[][] dp = new int[N + 1][aim + 1];
        // 根据 base case,填写初始值
        dp[N][0] = 1;
        // 从下往上,从左往右填表
        for (int index = N - 1; index >= 0; index--) {
            for (int rest = 0; rest <= aim; rest++) {
                dp[index][rest] = dp[index + 1][rest] + (rest - arr[index] >= 0 ? dp[index + 1][rest - arr[index]] : 0);
            }
        }
        return dp[0][aim];
    }

    // 为了测试
    public static void main(String[] args) {
        int maxLen = 20;
        int maxValue = 50;
        int testTime = 1000000;
        System.out.println("测试开始");
        for (int i = 0; i < testTime; i++) {
            int[] arr = randomArray(maxLen, maxValue);
            int aim = (int) (Math.random() * maxValue);
            int ans1 = coinWays1(arr, aim);
            int ans2 = coinWays2(arr, aim);
            if (ans1 != ans2) {
                System.out.println("错误!");
                printArray(arr);
                System.out.printf("目标:%d,ans1: %d,ans2: %d\n", aim, ans1, ans2);
                break;
            }
        }
        System.out.println("测试结束");
    }

    // 为了测试
    public static int[] randomArray(int maxLen, int maxValue) {
        int N = (int) (Math.random() * maxLen);
        int[] arr = new int[N];
        for (int i = 0; i < N; i++) {
            arr[i] = (int) (Math.random() * maxValue) + 1;
        }
        return arr;
    }

    // 为了测试
    public static void printArray(int[] arr) {
        for (int i = 0; i < arr.length; i++) {
            System.out.print(arr[i] + " ");
        }
        System.out.println();
    }
}

2.12、题目十二:组成货币的方法数(每一张面值都可以无限使用)

  • 题目十二:组成货币的方法数(每一张面值都可以无限使用)
  • arr是面值数组,其中的值都是正值。再给定一个正数aim。
  • 每个值都认为是一种面值,且认为张数是无限的。
  • 求组成aim的方法数。
  • 例如:arr = {1,2},aim = 4
  • 方法如下:1+1+1+1、1+1+2、2+2,一共就3种方法,返回3.
2.12.1、暴力递归尝试方法
  • 暴力递归尝试方法
  • 思路:
    • 从左往右的尝试模型。
    • 递归函数从index位置出发,aim还剩rest这么多钱,每一个面值都可以选择任意张数,返回方法数。
    • 在尝试的过程中,每个位置的面值,使用张数从0到超过rest,都要尝试一遍,每种张数的都去尝试剩下的钱,这样就枚举了所有的可能性。
    • 最后累计所有的方法。
java 复制代码
    /**
     * 暴力递归尝试方法
     * 思路:
     * 从左往右的尝试模型。
     * 递归函数从index位置出发,aim还剩rest这么多钱,每一个面值都可以选择任意张数,返回方法数。
     * 在尝试的过程中,每个位置的面值,使用张数从0到超过rest,都要尝试一遍,每种张数的都去尝试剩下的钱,这样就枚举了所有的可能性。
     * 最后累计所有的方法。
     */
    public static int coinsWay1(int[] arr, int aim) {
        if (arr == null || arr.length == 0 || aim < 0) {
            return 0;
        }
        return process(arr, 0, aim);
    }

    /**
     * 递归函数
     * arr[index....] 所有的面值,每一个面值都可以任意选择张数,组成正好rest这么多钱,方法数多少?
     */
    private static int process(int[] arr, int index, int rest) {
        // base case: 没钱了,判断是不是打到了目标
        if (index == arr.length) {
            return rest == 0 ? 1 : 0;
        }
        int ways = 0;
        // 尝试不同index的时候,还要尝试从0开始的每一张的可能组成
        for (int num = 0; num * arr[index] <= rest; num++) {
            ways += process(arr, index + 1, rest - (num * arr[index]));
        }
        return ways;
    }
2.12.2、从暴力递归尝试改成动态规划
  • 从暴力递归尝试改成动态规划

  • 思路:

    • 1、分析递归函数参数,有index和rest两个参数,所以dp表为dp[N+1][aim+1],能取到N是因为递归在index+1的时候判断是否越界,
    • 2、分析base case,dp[N][0] = 1,其他位置都是0。
    • 3、分析普遍位置,根据递归函数的实现,当前位置的依赖是下一行的位置,所以从下往上填写。rest从0开始,到aim结束。
    • 4、根据递归函数的调用,返回值为dp[0][aim]
  • 总结:

    • 这个题和之前的不一样的地方是,每个格子要依赖一个for循环,不是直接的依赖其他的格子。在从暴力递归到动态规划的转换的过程中,
    • index很容易分析出来是从下往上,但是rest感觉不出来,因为rest是动态变化的,是要根据for循环结合不同的张数来调整,所以要从0到aim,尝试不同的rest。
    • 动态规划的填表方式可以分为记忆化搜索和严格表结构依赖的填表方式。
    • 记忆化搜索是从递归函数的角度出发,把结果缓存起来,没算过就去算,算过了就直接用缓存的结果。
    • 严格表结构依赖是指严格整理好依赖关系,从简单位置填到复杂位置,比记忆化搜索进一步梳理依赖的关系。从简单位置通过依赖关系填复杂位置。
    • 时间复杂度为:如果没有枚举行为,表结构多大,时间复杂度就是多少。两者是一样的。
java 复制代码
    /**
     * 从暴力递归尝试改成动态规划
     * 思路:
     * 1、分析递归函数参数,有index和rest两个参数,所以dp表为dp[N+1][aim+1],能取到N是因为递归在index+1的时候判断是否越界,
     * 2、分析base case,dp[N][0] = 1,其他位置都是0。
     * 3、分析普遍位置,根据递归函数的实现,当前位置的依赖是下一行的位置,所以从下往上填写。rest从0开始,到aim结束。
     * 4、根据递归函数的调用,返回值为dp[0][aim]
     * <br>
     * 总结:
     * 这个题和之前的不一样的地方是,每个格子要依赖一个for循环,不是直接的依赖其他的格子。在从暴力递归到动态规划的转换的过程中,
     * index很容易分析出来是从下往上,但是rest感觉不出来,因为rest是动态变化的,是要根据for循环结合不同的张数来调整,所以要从0到aim,尝试不同的rest。
     * 动态规划的填表方式可以分为记忆化搜索和严格表结构依赖的填表方式。
     * 记忆化搜索是从递归函数的角度出发,把结果缓存起来,没算过就去算,算过了就直接用缓存的结果。
     * 严格表结构依赖是指严格整理好依赖关系,从简单位置填到复杂位置,比记忆化搜索进一步梳理依赖的关系。从简单位置通过依赖关系填复杂位置。
     * 时间复杂度为:如果没有枚举行为,表结构多大,时间复杂度就是多少。两者是一样的。
     */
    public static int coinsWay2(int[] arr, int aim) {
        if (arr == null || arr.length == 0 || aim < 0) {
            return 0;
        }
        int N = arr.length;
        int[][] dp = new int[N + 1][aim + 1];
        dp[N][0] = 1;
        // 从下往上填表
        for (int index = N - 1; index >= 0; index--) {
            for (int rest = 0; rest <= aim; rest++) {
                // 根据每一个index的面值,尝试不同的张数
                int ways = 0;
                for (int num = 0; num * arr[index] <= rest; num++) {
                    // 因为有for循环的条件判断,就不需要判断后面的越界问题了
                    ways += dp[index + 1][rest - (num * arr[index])];
                }
                dp[index][rest] = ways;
            }
        }
        return dp[0][aim];
    }
2.12.3、严格表结构依赖的动态规划方法
  • 严格表结构依赖的动态规划方法

  • 思路:

    • 上面从暴力递归改成的动态规划中,每个格子最后还要依赖一个for循环,时间复杂度比较高,我们通过严格表结构的方法对其进行进一步的分析,
    • 从根据张数循环的for循环中可以看出,每个格子的依赖是下一行的格子,列是rest - (num * arr[index]),即越是后面的rest,比较依赖前面的格子。
    • 即将前面满足rest - (num * arr[index]) >= 0的格子的结果累加起来,就是当前格子的结果。
    • 每个位置都是如此,都是累加下一行前面的格子的和,假设index2位置是和所求位置同一行的前面的一个位置,
    • 那么index2位置就已经累计了下一行前面的所有结果(index2肯定大于rest - (num * arr[index])),
    • 我们可以将index2设置为rest - arr[index],即只计算1张,当前位置的下边就是dp[index+1][rest],即加了0张arr[index],
    • 所以当前位置只需要下边位置的结果累加上rest位置就是需要的结果。这就是根据表结构分析出来的结果,
    • 即原来的for循环可以去掉,改为:
    • dp[index][rest] = dp[index + 1][rest] + dp[index][rest - arr[index]];
    • 当然需要判断rest - arr[index]是否越界。
  • 总结:

    • 通过对表结构的分析,可以分析出依赖关系,进一步优化。
java 复制代码
    /**
     * 严格表结构依赖的动态规划方法
     * 思路:
     * 上面从暴力递归改成的动态规划中,每个格子最后还要依赖一个for循环,时间复杂度比较高,我们通过严格表结构的方法对其进行进一步的分析,
     * 从根据张数循环的for循环中可以看出,每个格子的依赖是下一行的格子,列是rest - (num * arr[index]),即越是后面的rest,比较依赖前面的格子。
     * 即将前面满足rest - (num * arr[index]) >= 0的格子的结果累加起来,就是当前格子的结果。
     * 每个位置都是如此,都是累加下一行前面的格子的和,假设index2位置是和所求位置同一行的前面的一个位置,
     * 那么index2位置就已经累计了下一行前面的所有结果(index2肯定大于rest - (num * arr[index])),
     * 我们可以将index2设置为rest - arr[index],即只计算1张,当前位置的下边就是dp[index+1][rest],即加了0张arr[index],
     * 所以当前位置只需要下边位置的结果累加上rest位置就是需要的结果。这就是根据表结构分析出来的结果,
     * 即原来的for循环可以去掉,改为:
     * dp[index][rest] = dp[index + 1][rest] + dp[index][rest - arr[index]];
     * 当然需要判断rest - arr[index]是否越界。
     * <br>
     * 总结:
     * 通过对表结构的分析,可以分析出依赖关系,进一步优化。
     */
    public static int coinsWay3(int[] arr, int aim) {
        if (arr == null || arr.length == 0 || aim < 0) {
            return 0;
        }
        int N = arr.length;
        int[][] dp = new int[N + 1][aim + 1];
        dp[N][0] = 1;
        // 从下往上填表
        for (int index = N - 1; index >= 0; index--) {
            for (int rest = 0; rest <= aim; rest++) {
                // 先将当前值设置为下面的值(即使用了0张当前面值的货币)
                dp[index][rest] = dp[index + 1][rest];
                // 如果前面的计算了1张的位置没有越界,直接累加上同行的前一个位置即可
                if (rest - arr[index] >= 0) {
                    dp[index][rest] += dp[index][rest - arr[index]];
                }
            }
        }
        return dp[0][aim];
    }

整体代码和测试如下:

java 复制代码
/**
 * 题目十二:组成货币的方法数(每一张面值都可以无限使用)
 * arr是面值数组,其中的值都是正值。再给定一个正数aim。
 * 每个值都认为是一种面值,且认为张数是无限的。
 * 求组成aim的方法数。
 * 例如:arr = {1,2},aim = 4
 * 方法如下:1+1+1+1、1+1+2、2+2,一共就3种方法,返回3.
 */
public class Q12_CoinsWayNoLimit {

    /**
     * 暴力递归尝试方法
     * 思路:
     * 从左往右的尝试模型。
     * 递归函数从index位置出发,aim还剩rest这么多钱,每一个面值都可以选择任意张数,返回方法数。
     * 在尝试的过程中,每个位置的面值,使用张数从0到超过rest,都要尝试一遍,每种张数的都去尝试剩下的钱,这样就枚举了所有的可能性。
     * 最后累计所有的方法。
     */
    public static int coinsWay1(int[] arr, int aim) {
        if (arr == null || arr.length == 0 || aim < 0) {
            return 0;
        }
        return process(arr, 0, aim);
    }

    /**
     * 递归函数
     * arr[index....] 所有的面值,每一个面值都可以任意选择张数,组成正好rest这么多钱,方法数多少?
     */
    private static int process(int[] arr, int index, int rest) {
        // base case: 没钱了,判断是不是打到了目标
        if (index == arr.length) {
            return rest == 0 ? 1 : 0;
        }
        int ways = 0;
        // 尝试不同index的时候,还要尝试从0开始的每一张的可能组成
        for (int num = 0; num * arr[index] <= rest; num++) {
            ways += process(arr, index + 1, rest - (num * arr[index]));
        }
        return ways;
    }

    /**
     * 从暴力递归尝试改成动态规划
     * 思路:
     * 1、分析递归函数参数,有index和rest两个参数,所以dp表为dp[N+1][aim+1],能取到N是因为递归在index+1的时候判断是否越界,
     * 2、分析base case,dp[N][0] = 1,其他位置都是0。
     * 3、分析普遍位置,根据递归函数的实现,当前位置的依赖是下一行的位置,所以从下往上填写。rest从0开始,到aim结束。
     * 4、根据递归函数的调用,返回值为dp[0][aim]
     * <br>
     * 总结:
     * 这个题和之前的不一样的地方是,每个格子要依赖一个for循环,不是直接的依赖其他的格子。在从暴力递归到动态规划的转换的过程中,
     * index很容易分析出来是从下往上,但是rest感觉不出来,因为rest是动态变化的,是要根据for循环结合不同的张数来调整,所以要从0到aim,尝试不同的rest。
     * 动态规划的填表方式可以分为记忆化搜索和严格表结构依赖的填表方式。
     * 记忆化搜索是从递归函数的角度出发,把结果缓存起来,没算过就去算,算过了就直接用缓存的结果。
     * 严格表结构依赖是指严格整理好依赖关系,从简单位置填到复杂位置,比记忆化搜索进一步梳理依赖的关系。从简单位置通过依赖关系填复杂位置。
     * 时间复杂度为:如果没有枚举行为,表结构多大,时间复杂度就是多少。两者是一样的。
     */
    public static int coinsWay2(int[] arr, int aim) {
        if (arr == null || arr.length == 0 || aim < 0) {
            return 0;
        }
        int N = arr.length;
        int[][] dp = new int[N + 1][aim + 1];
        dp[N][0] = 1;
        // 从下往上填表
        for (int index = N - 1; index >= 0; index--) {
            for (int rest = 0; rest <= aim; rest++) {
                // 根据每一个index的面值,尝试不同的张数
                int ways = 0;
                for (int num = 0; num * arr[index] <= rest; num++) {
                    // 因为有for循环的条件判断,就不需要判断后面的越界问题了
                    ways += dp[index + 1][rest - (num * arr[index])];
                }
                dp[index][rest] = ways;
            }
        }
        return dp[0][aim];
    }

    /**
     * 严格表结构依赖的动态规划方法
     * 思路:
     * 上面从暴力递归改成的动态规划中,每个格子最后还要依赖一个for循环,时间复杂度比较高,我们通过严格表结构的方法对其进行进一步的分析,
     * 从根据张数循环的for循环中可以看出,每个格子的依赖是下一行的格子,列是rest - (num * arr[index]),即越是后面的rest,比较依赖前面的格子。
     * 即将前面满足rest - (num * arr[index]) >= 0的格子的结果累加起来,就是当前格子的结果。
     * 每个位置都是如此,都是累加下一行前面的格子的和,假设index2位置是和所求位置同一行的前面的一个位置,
     * 那么index2位置就已经累计了下一行前面的所有结果(index2肯定大于rest - (num * arr[index])),
     * 我们可以将index2设置为rest - arr[index],即只计算1张,当前位置的下边就是dp[index+1][rest],即加了0张arr[index],
     * 所以当前位置只需要下边位置的结果累加上rest位置就是需要的结果。这就是根据表结构分析出来的结果,
     * 即原来的for循环可以去掉,改为:
     * dp[index][rest] = dp[index + 1][rest] + dp[index][rest - arr[index]];
     * 当然需要判断rest - arr[index]是否越界。
     * <br>
     * 总结:
     * 通过对表结构的分析,可以分析出依赖关系,进一步优化。
     */
    public static int coinsWay3(int[] arr, int aim) {
        if (arr == null || arr.length == 0 || aim < 0) {
            return 0;
        }
        int N = arr.length;
        int[][] dp = new int[N + 1][aim + 1];
        dp[N][0] = 1;
        // 从下往上填表
        for (int index = N - 1; index >= 0; index--) {
            for (int rest = 0; rest <= aim; rest++) {
                // 先将当前值设置为下面的值(即使用了0张当前面值的货币)
                dp[index][rest] = dp[index + 1][rest];
                // 如果前面的计算了1张的位置没有越界,直接累加上同行的前一个位置即可
                if (rest - arr[index] >= 0) {
                    dp[index][rest] += dp[index][rest - arr[index]];
                }
            }
        }
        return dp[0][aim];
    }

    // 为了测试
    public static void main(String[] args) {
        int maxLen = 20;
        int maxValue = 30;
        int testTime = 1000000;
        System.out.println("测试开始");
        for (int i = 0; i < testTime; i++) {
            int[] arr = randomArray(maxLen, maxValue);
            int aim = (int) (Math.random() * maxValue);
            int ans1 = coinsWay1(arr, aim);
            int ans2 = coinsWay2(arr, aim);
            int ans3 = coinsWay3(arr, aim);
            if (ans1 != ans2 || ans1 != ans3) {
                System.out.println("错误!");
                printArray(arr);
                System.out.printf("目标:%d,ans1: %d,ans2: %d,ans3: %d\n", aim, ans1, ans2, ans3);
                break;
            }
        }
        System.out.println("测试结束");
    }

    // 为了测试
    public static int[] randomArray(int maxLen, int maxValue) {
        int N = (int) (Math.random() * maxLen);
        int[] arr = new int[N];
        boolean[] has = new boolean[maxValue + 1];
        for (int i = 0; i < N; i++) {
            do {
                arr[i] = (int) (Math.random() * maxValue) + 1;
            } while (has[arr[i]]);
            has[arr[i]] = true;
        }
        return arr;
    }

    // 为了测试
    public static void printArray(int[] arr) {
        for (int i = 0; i < arr.length; i++) {
            System.out.print(arr[i] + " ");
        }
        System.out.println();
    }
}

2.13、题目十三:组成货币的方法数(相同面值有固定张数限制)

  • 题目十三:组成货币的方法数(相同面值有固定张数限制)
  • arr是货币数组,其中的值都是正值。再给定一个正数aim。
  • 每个值都认为是一张货币,认为值相同的货币没有任何不同,
  • 求组成aim的方法数。
  • 例如:arr = {1,2,1,1,2,1,2},aim = 4
  • 方法如下:1+1+1+1、1+1+2、2+2,一共就3种方法,返回3.
  • 本题目和题目十二不同的是每一个面值的货币不是无限使用的,而是有固定的张数。
  • 和题目十一不一样的是,每一个面值的货币是一样的,所以也就没有了用了一个,再用同一个面值就是不同的情况,
  • 所以在求解的过程中,就不能用题目十一的方法,直接从左往右尝试求解,因为那样肯定会多算一些情况。
  • 可以将货币数组转换为面值数组和张数数组,这样就可以用从左往右的尝试模型,再综合张数限制来求解了。

基础的封装结构如下:

java 复制代码
    /**
     * 货币的封装类,将题目中arr中的货币,拆分成不同的面值和对应张数,两个数组相同的index代表同一个货币的面值和张数
     */
    public static class CoinInfo {
        // 货币的面值数组
        private int[] coins;
        // 货币的张数数组
        private int[] counts;

        public CoinInfo(int[] coins, int[] counts) {
            this.coins = coins;
            this.counts = counts;
        }
    }

    public static CoinInfo getCoinInfo(int[] arr) {
        if (arr == null || arr.length == 0) {
            return new CoinInfo(new int[0], new int[0]);
        }
        Map<Integer, Integer> coinMap = new HashMap<>();
        for (int value : arr) {
            if (!coinMap.containsKey(value)) {
                coinMap.put(value, 1);
            } else {
                coinMap.put(value, coinMap.get(value) + 1);
            }
        }
        int N = coinMap.size();
        int[] coins = new int[N];
        int[] counts = new int[N];
        int index = 0;
        for (Map.Entry<Integer, Integer> entry : coinMap.entrySet()) {
            coins[index] = entry.getKey();
            counts[index] = entry.getValue();
            index++;
        }
        return new CoinInfo(coins, counts);
    }
2.13.1、暴力递归尝试方法
  • 暴力递归尝试方法
  • 思路:
    • 从左往右的尝试模型,相比于题目十二,除了要判断rest是否超出,还要判断张数的限制。
java 复制代码
    /**
     * 暴力递归尝试方法
     * 思路:
     * 从左往右的尝试模型,相比于题目十二,除了要判断rest是否超出,还要判断张数的限制。
     */
    public static int coinsWay1(int[] arr, int aim) {
        if (arr == null || arr.length == 0 || aim < 0) {
            return 0;
        }
        CoinInfo info = getCoinInfo(arr);
        return process(info.coins, info.counts, 0, aim);
    }

    /**
     *
     * @param coins         :面值数组,正数且去重
     * @param counts        :每种面值对应的张数
     * @param index         :尝试的货币位置
     * @param rest:还需要组成的金额
     */
    private static int process(int[] coins, int[] counts, int index, int rest) {
        // base case
        if (index == coins.length) {
            return rest == 0 ? 1 : 0;
        }
        int ways = 0;
        // 尝试不同的张数
        for (int num = 0; num <= counts[index] && num * coins[index] <= rest; num++) {
            ways += process(coins, counts, index + 1, rest - num * coins[index]);
        }
        return ways;
    }
2.13.2、从暴力递归尝试改成动态规划
  • 暴力递归改成动态规划
  • 思路:
    • 1、根据递归函数,有两个可变参数,index[0,N]和rest[0,aim],所以缓存表为dp[N+1][aim+1]
    • 2、根据base case,dp[N][0]=1;
    • 3、根据依赖关系,行数上依赖下面的行,列数依赖前面的列,所以填表从下到上,从左到右。填写每个格子的时候,要循环不同的张数。
    • 4、根据调用关系,返回dp[0][aim]的值
java 复制代码
    /**
     * 暴力递归改成动态规划
     * 思路:
     * 1、根据递归函数,有两个可变参数,index[0,N]和rest[0,aim],所以缓存表为dp[N+1][aim+1]
     * 2、根据base case,dp[N][0]=1;
     * 3、根据依赖关系,行数上依赖下面的行,列数依赖前面的列,所以填表从下到上,从左到右。填写每个格子的时候,要循环不同的张数。
     * 4、根据调用关系,返回dp[0][aim]的值
     */
    public static int coinsWay2(int[] arr, int aim) {
        if (arr == null || arr.length == 0 || aim < 0) {
            return 0;
        }
        CoinInfo info = getCoinInfo(arr);
        int[] coins = info.coins;
        int[] counts = info.counts;
        int N = coins.length;
        int[][] dp = new int[N + 1][aim + 1];
        dp[N][0] = 1;
        // 从下到上,从左到右填表
        for (int index = N - 1; index >= 0; index--) {
            for (int rest = 0; rest <= aim; rest++) {
                int ways = 0;
                // 尝试不同的张数
                for (int num = 0; num <= counts[index] && num * coins[index] <= rest; num++) {
                    ways += dp[index + 1][rest - num * coins[index]];
                }
                dp[index][rest] = ways;
            }
        }
        return dp[0][aim];
    }
2.13.3、严格表结构依赖的动态规划方法
  • 严格表结构依赖的动态规划方法

  • 思路:

    • 这个题目和题目十二的区别在于,每一个面值的货币不是无限使用的,而是有固定的张数。
    • 我们在题目十二求解的过程中分析出来,对于当前位置index,其依赖本行的rest - coins[index]位置index2和其下面的位置[index+1][rest]。
    • 因为当前位置的前面位置index2也依赖下一行的位置,所以index2已经累加了前面的结果,所以直接用index2的累加当前位置的下一行的位置即可。
    • 但是本题目中,每一个面值都是有数量限制的,假设当前的货币限额为2张,那么rest就依赖下一行的d,c,b(从小到大)三个位置,
    • 对于index位置,我们依赖的下一行的是c,b,a(从小到大,a就是当前位置的下一行的位置)这三个位置,如果直接用rest的累加起,那就多算了一个d的位置,
    • 所以要想办法把d的位置给减去,所以关键的点就是如何剔除掉不需要的累加位置。
  • 如何剔除多余的位置:

    • 从上面的分析可知,我们需要计算出d的位置坐标,在计算index2的时候,就张数从0到counts[index],
    • 我们在计算当前位置的时候,下面的rest位置为第0个,原来上面的counts[index] - 1的位置就成了现在的最后一个位置counts[index],
    • 所以我们需要减去的位置的坐标用当前位置来算的话,就是counts[index] + 1的坐标,再累计上面值的偏移,就是
    • rest - coins[index] * (counts[index] + 1)
    • 所以在前面题目的基础上,减去dp[index + 1][rest - coins[index] * (counts[index] + 1)]的位置即可。
    • 当然,要判断是否越界。
  • 总结:

    • 这类题目因为rest有可能会减到负值,而我们缓存表是从0开始的正值,所以必须要用代码判断的方式将逻辑上的负值的区域剪切掉。
    • 所以在累加的时候,都要判断是否越界,只计算不越界的区域。
    • 因为累加是从左到右进行的,所以每个位置都减去了重复计算的那个,也就不会累计到后面。
java 复制代码
    /**
     * 严格表结构依赖的动态规划方法
     * 思路:
     * 这个题目和题目十二的区别在于,每一个面值的货币不是无限使用的,而是有固定的张数。
     * 我们在题目十二求解的过程中分析出来,对于当前位置index,其依赖本行的rest - coins[index]位置index2和其下面的位置[index+1][rest]。
     * 因为当前位置的前面位置index2也依赖下一行的位置,所以index2已经累加了前面的结果,所以直接用index2的累加当前位置的下一行的位置即可。
     * 但是本题目中,每一个面值都是有数量限制的,假设当前的货币限额为2张,那么rest就依赖下一行的d,c,b(从小到大)三个位置,
     * 对于index位置,我们依赖的下一行的是c,b,a(从小到大,a就是当前位置的下一行的位置)这三个位置,如果直接用rest的累加起,那就多算了一个d的位置,
     * 所以要想办法把d的位置给减去,所以关键的点就是如何剔除掉不需要的累加位置。
     * <br>
     * 如何剔除多余的位置:
     * 从上面的分析可知,我们需要计算出d的位置坐标,在计算index2的时候,就张数从0到counts[index],
     * 我们在计算当前位置的时候,下面的rest位置为第0个,原来上面的counts[index] - 1的位置就成了现在的最后一个位置counts[index],
     * 所以我们需要减去的位置的坐标用当前位置来算的话,就是counts[index] + 1的坐标,再累计上面值的偏移,就是
     * rest - coins[index] * (counts[index] + 1)
     * 所以在前面题目的基础上,减去dp[index + 1][rest - coins[index] * (counts[index] + 1)]的位置即可。
     * 当然,要判断是否越界。
     * <br>
     * 总结:
     * 这类题目因为rest有可能会减到负值,而我们缓存表是从0开始的正值,所以必须要用代码判断的方式将逻辑上的负值的区域剪切掉。
     * 所以在累加的时候,都要判断是否越界,只计算不越界的区域。
     * 因为累加是从左到右进行的,所以每个位置都减去了重复计算的那个,也就不会累计到后面。
     */
    public static int coinsWay3(int[] arr, int aim) {
        if (arr == null || arr.length == 0 || aim < 0) {
            return 0;
        }
        CoinInfo info = getCoinInfo(arr);
        int[] coins = info.coins;
        int[] counts = info.counts;
        int N = coins.length;
        int[][] dp = new int[N + 1][aim + 1];
        dp[N][0] = 1;
        // 从下到上,从左到右填表
        for (int index = N - 1; index >= 0; index--) {
            for (int rest = 0; rest <= aim; rest++) {
                // 先将其等于下一个位置
                dp[index][rest] = dp[index + 1][rest];
                // 不越界的话累加上本行的前一个已经累加好的位置
                if (rest - coins[index] >= 0) {
                    dp[index][rest] += dp[index][rest - coins[index]];
                }
                // 不越界的话,减掉重复计算的值
                if (rest - coins[index] * (counts[index] + 1) >= 0) {
                    dp[index][rest] -= dp[index + 1][rest - coins[index] * (counts[index] + 1)];
                }
            }
        }
        return dp[0][aim];
    }

整体代码和测试如下:

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

/**
 * 题目十三:组成货币的方法数(相同面值有固定张数限制)
 * arr是货币数组,其中的值都是正值。再给定一个正数aim。
 * 每个值都认为是一张货币,认为值相同的货币没有任何不同,
 * 求组成aim的方法数。
 * 例如:arr = {1,2,1,1,2,1,2},aim = 4
 * 方法如下:1+1+1+1、1+1+2、2+2,一共就3种方法,返回3.
 * <br>
 * 本题目和题目十二不同的是每一个面值的货币不是无限使用的,而是有固定的张数。
 * 和题目十一不一样的是,每一个面值的货币是一样的,所以也就没有了用了一个,再用同一个面值就是不同的情况,
 * 所以在求解的过程中,就不能用题目十一的方法,直接从左往右尝试求解,因为那样肯定会多算一些情况。
 * 可以将货币数组转换为面值数组和张数数组,这样就可以用从左往右的尝试模型,再综合张数限制来求解了。
 */
public class Q13_CoinsWaySameValueSamePaper {

    /**
     * 货币的封装类,将题目中arr中的货币,拆分成不同的面值和对应张数,两个数组相同的index代表同一个货币的面值和张数
     */
    public static class CoinInfo {
        // 货币的面值数组
        private int[] coins;
        // 货币的张数数组
        private int[] counts;

        public CoinInfo(int[] coins, int[] counts) {
            this.coins = coins;
            this.counts = counts;
        }
    }

    public static CoinInfo getCoinInfo(int[] arr) {
        if (arr == null || arr.length == 0) {
            return new CoinInfo(new int[0], new int[0]);
        }
        Map<Integer, Integer> coinMap = new HashMap<>();
        for (int value : arr) {
            if (!coinMap.containsKey(value)) {
                coinMap.put(value, 1);
            } else {
                coinMap.put(value, coinMap.get(value) + 1);
            }
        }
        int N = coinMap.size();
        int[] coins = new int[N];
        int[] counts = new int[N];
        int index = 0;
        for (Map.Entry<Integer, Integer> entry : coinMap.entrySet()) {
            coins[index] = entry.getKey();
            counts[index] = entry.getValue();
            index++;
        }
        return new CoinInfo(coins, counts);
    }

    /**
     * 暴力递归尝试方法
     * 思路:
     * 从左往右的尝试模型,相比于题目十二,除了要判断rest是否超出,还要判断张数的限制。
     */
    public static int coinsWay1(int[] arr, int aim) {
        if (arr == null || arr.length == 0 || aim < 0) {
            return 0;
        }
        CoinInfo info = getCoinInfo(arr);
        return process(info.coins, info.counts, 0, aim);
    }

    /**
     *
     * @param coins         :面值数组,正数且去重
     * @param counts        :每种面值对应的张数
     * @param index         :尝试的货币位置
     * @param rest:还需要组成的金额
     */
    private static int process(int[] coins, int[] counts, int index, int rest) {
        // base case
        if (index == coins.length) {
            return rest == 0 ? 1 : 0;
        }
        int ways = 0;
        // 尝试不同的张数
        for (int num = 0; num <= counts[index] && num * coins[index] <= rest; num++) {
            ways += process(coins, counts, index + 1, rest - num * coins[index]);
        }
        return ways;
    }

    /**
     * 暴力递归改成动态规划
     * 思路:
     * 1、根据递归函数,有两个可变参数,index[0,N]和rest[0,aim],所以缓存表为dp[N+1][aim+1]
     * 2、根据base case,dp[N][0]=1;
     * 3、根据依赖关系,行数上依赖下面的行,列数依赖前面的列,所以填表从下到上,从左到右。填写每个格子的时候,要循环不同的张数。
     * 4、根据调用关系,返回dp[0][aim]的值
     */
    public static int coinsWay2(int[] arr, int aim) {
        if (arr == null || arr.length == 0 || aim < 0) {
            return 0;
        }
        CoinInfo info = getCoinInfo(arr);
        int[] coins = info.coins;
        int[] counts = info.counts;
        int N = coins.length;
        int[][] dp = new int[N + 1][aim + 1];
        dp[N][0] = 1;
        // 从下到上,从左到右填表
        for (int index = N - 1; index >= 0; index--) {
            for (int rest = 0; rest <= aim; rest++) {
                int ways = 0;
                // 尝试不同的张数
                for (int num = 0; num <= counts[index] && num * coins[index] <= rest; num++) {
                    ways += dp[index + 1][rest - num * coins[index]];
                }
                dp[index][rest] = ways;
            }
        }
        return dp[0][aim];
    }

    /**
     * 严格表结构依赖的动态规划方法
     * 思路:
     * 这个题目和题目十二的区别在于,每一个面值的货币不是无限使用的,而是有固定的张数。
     * 我们在题目十二求解的过程中分析出来,对于当前位置index,其依赖本行的rest - coins[index]位置index2和其下面的位置[index+1][rest]。
     * 因为当前位置的前面位置index2也依赖下一行的位置,所以index2已经累加了前面的结果,所以直接用index2的累加当前位置的下一行的位置即可。
     * 但是本题目中,每一个面值都是有数量限制的,假设当前的货币限额为2张,那么rest就依赖下一行的d,c,b(从小到大)三个位置,
     * 对于index位置,我们依赖的下一行的是c,b,a(从小到大,a就是当前位置的下一行的位置)这三个位置,如果直接用rest的累加起,那就多算了一个d的位置,
     * 所以要想办法把d的位置给减去,所以关键的点就是如何剔除掉不需要的累加位置。
     * <br>
     * 如何剔除多余的位置:
     * 从上面的分析可知,我们需要计算出d的位置坐标,在计算index2的时候,就张数从0到counts[index],
     * 我们在计算当前位置的时候,下面的rest位置为第0个,原来上面的counts[index] - 1的位置就成了现在的最后一个位置counts[index],
     * 所以我们需要减去的位置的坐标用当前位置来算的话,就是counts[index] + 1的坐标,再累计上面值的偏移,就是
     * rest - coins[index] * (counts[index] + 1)
     * 所以在前面题目的基础上,减去dp[index + 1][rest - coins[index] * (counts[index] + 1)]的位置即可。
     * 当然,要判断是否越界。
     * <br>
     * 总结:
     * 这类题目因为rest有可能会减到负值,而我们缓存表是从0开始的正值,所以必须要用代码判断的方式将逻辑上的负值的区域剪切掉。
     * 所以在累加的时候,都要判断是否越界,只计算不越界的区域。
     * 因为累加是从左到右进行的,所以每个位置都减去了重复计算的那个,也就不会累计到后面。
     */
    public static int coinsWay3(int[] arr, int aim) {
        if (arr == null || arr.length == 0 || aim < 0) {
            return 0;
        }
        CoinInfo info = getCoinInfo(arr);
        int[] coins = info.coins;
        int[] counts = info.counts;
        int N = coins.length;
        int[][] dp = new int[N + 1][aim + 1];
        dp[N][0] = 1;
        // 从下到上,从左到右填表
        for (int index = N - 1; index >= 0; index--) {
            for (int rest = 0; rest <= aim; rest++) {
                // 先将其等于下一个位置
                dp[index][rest] = dp[index + 1][rest];
                // 不越界的话累加上本行的前一个已经累加好的位置
                if (rest - coins[index] >= 0) {
                    dp[index][rest] += dp[index][rest - coins[index]];
                }
                // 不越界的话,减掉重复计算的值
                if (rest - coins[index] * (counts[index] + 1) >= 0) {
                    dp[index][rest] -= dp[index + 1][rest - coins[index] * (counts[index] + 1)];
                }
            }
        }
        return dp[0][aim];
    }

    // 为了测试
    public static void main(String[] args) {
        int maxLen = 30;
        int maxValue = 50;
        int testTime = 1000000;
        System.out.println("测试开始");
        for (int i = 0; i < testTime; i++) {
            int[] arr = randomArray(maxLen, maxValue);
            int aim = (int) (Math.random() * maxValue);
            int ans1 = coinsWay1(arr, aim);
            int ans2 = coinsWay2(arr, aim);
            int ans3 = coinsWay3(arr, aim);
            if (ans1 != ans2 || ans1 != ans3) {
                System.out.println("错误!");
                printArray(arr);
                System.out.printf("目标:%d,ans1: %d,ans2: %d,ans3: %d\n", aim, ans1, ans2, ans3);
                break;
            }
        }
        System.out.println("测试结束");
    }

    // 为了测试
    public static int[] randomArray(int maxLen, int maxValue) {
        int N = (int) (Math.random() * maxLen);
        int[] arr = new int[N];
        for (int i = 0; i < N; i++) {
            arr[i] = (int) (Math.random() * maxValue) + 1;
        }
        return arr;
    }

    // 为了测试
    public static void printArray(int[] arr) {
        for (int i = 0; i < arr.length; i++) {
            System.out.print(arr[i] + " ");
        }
        System.out.println();
    }


}

后记

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

相关推荐
RTC老炮1 小时前
webrtc降噪-SpeechProbabilityEstimator类源码分析与算法原理
算法·webrtc
WWZZ20251 小时前
快速上手大模型:深度学习9(池化层、卷积神经网络1)
人工智能·深度学习·神经网络·算法·机器人·大模型·具身智能
Boop_wu1 小时前
[Java EE] 多线程编程初阶
java·jvm·算法
缺点内向2 小时前
Java: 在 Excel 中插入、提取或删除文本框
java·开发语言·excel
一 乐2 小时前
英语学习激励|基于java+vue的英语学习交流平台系统小程序(源码+数据库+文档)
java·前端·数据库·vue.js·学习·小程序
cpp_25012 小时前
P1765 手机
数据结构·c++·算法·题解·洛谷
老华带你飞2 小时前
个人健康系统|健康管理|基于java+Android+微信小程序的个人健康系统设计与实现(源码+数据库+文档)
android·java·vue.js·微信小程序·论文·毕设·个人健康系统
JIngJaneIL2 小时前
停车场管理|停车预约管理|基于Springboot+的停车场管理系统设计与实现(源码+数据库+文档)
java·数据库·vue.js·spring boot·论文·notepad++·停车场管理|
未到结局,焉知生死2 小时前
PAT每日三题11-20
c++·算法