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

目录

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


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

2.14、题目十四:Bob的生存概率

  • 题目十四:Bob的生存概率
  • 给定5个参数,N,M,row,col,k
  • 表示在N*M的区域上,醉汉Bob初始位置在(row, col)位置,
  • Bob一共要走k步,且每步都会等概率向上下左右四个方向走一个单位。
  • 任何时候Bob只要离开N*M的区域,就认为Bob已经死亡。
  • 返回k步之后,Bob还在N*M区域的概率。
2.14.1、暴力递归尝试方法
  • 暴力递归尝试方法
  • 思路:
    • 因为计算的是概率,所以需要算两个值,一个是生存的点数,一个是总点数。
    • 对于总点数,走k步,每步都有4种方向,所以总点数就是4^k。调用Math.pow(4, k)即可。
    • 对于生存的点数,和象棋跳马问题是一个套路。
    • 就是从(row, col)位置开始,走k步,还在棋盘中的点数。
    • 设计一个递归函数,每次走一步,步数减去1步,
    • 直到rest为0时,判断当前位置是否还在棋盘中,如果还在棋盘中,就返回1,否则返回0。
    • 在每次递归中,计算上下左右四个方向的生存点数,累加起来就是当前位置的生存点数。
java 复制代码
    /**
     * 暴力递归尝试方法
     * 思路:
     * 因为计算的是概率,所以需要算两个值,一个是生存的点数,一个是总点数。
     * 对于总点数,走k步,每步都有4种方向,所以总点数就是4^k。调用Math.pow(4, k)即可。
     * 对于生存的点数,和象棋跳马问题是一个套路。
     * 就是从(row, col)位置开始,走k步,还在棋盘中的点数。
     * 设计一个递归函数,每次走一步,步数减去1步,
     * 直到rest为0时,判断当前位置是否还在棋盘中,如果还在棋盘中,就返回1,否则返回0。
     * 在每次递归中,计算上下左右四个方向的生存点数,累加起来就是当前位置的生存点数。
     */
    public static double livePossibility1(int row, int col, int k, int N, int M) {
        return (double) process(row, col, k, N, M) / Math.pow(4, k);
    }

    /**
     * 递归函数
     * 目前在row,col位置,还有rest步要走,走完了如果还在棋盘中就获得1个生存点,返回总的生存点数
     */
    public static long process(int row, int col, int rest, int N, int M) {
        // 判断是否已经出了棋盘
        if (row < 0 || row == N || col < 0 || col == M) {
            return 0;
        }
        // 到这里,说明还在棋盘里面,代表没有死
        // base case:走完了所有步数
        if (rest == 0) {
            return 1;
        }
        // 还有步数要走
        long up = process(row - 1, col, rest - 1, N, M);
        long down = process(row + 1, col, rest - 1, N, M);
        long left = process(row, col - 1, rest - 1, N, M);
        long right = process(row, col + 1, rest - 1, N, M);
        return up + down + left + right;
    }
2.14.2、从暴力递归尝试改成动态规划
  • 从暴力递归改到动态规划
  • 思路:
    • 1、根据递归函数参数,有row,col和rest三个变量,所以缓存表为dp[N][M][k+1]
    • 2、根据base case,rest为0的时候,不管什么位置,都是1,所以三维数组的最底层都是1
    • 3、根据依赖关系,上面的层数依赖下面的层,所以从下往上填三维数组
    • 4、根据调用关系,返回dp[row][col][k]
java 复制代码
    /**
     * 从暴力递归改到动态规划
     * 思路:
     * 1、根据递归函数参数,有row,col和rest三个变量,所以缓存表为dp[N][M][k+1]
     * 2、根据base case,rest为0的时候,不管什么位置,都是1,所以三维数组的最底层都是1
     * 3、根据依赖关系,上面的层数依赖下面的层,所以从下往上填三维数组
     * 4、根据调用关系,返回dp[row][col][k]
     */
    public static double livePossibility2(int row, int col, int k, int N, int M) {
        long[][][] dp = new long[N][M][k + 1];
        // 填写base case
        for (int i = 0; i < N; i++) {
            for (int j = 0; j < M; j++) {
                dp[i][j][0] = 1;
            }
        }
        // 从下往上填写三维数组,为了判断方便,抽取pick方法
        for (int rest = 1; rest <= k; rest++) {
            for (int r = 0; r < N; r++) {
                for (int c = 0; c < M; c++) {
                    dp[r][c][rest] = pick(dp, N, M, r - 1, c, rest - 1);
                    dp[r][c][rest] += pick(dp, N, M, r + 1, c, rest - 1);
                    dp[r][c][rest] += pick(dp, N, M, r, c - 1, rest - 1);
                    dp[r][c][rest] += pick(dp, N, M, r, c + 1, rest - 1);
                }
            }
        }
        return (double) dp[row][col][k] / Math.pow(4, k);
    }

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

整体代码如下:

java 复制代码
/**
 * 题目十四:Bob的生存概率
 * 给定5个参数,N,M,row,col,k
 * 表示在N*M的区域上,醉汉Bob初始位置在(row, col)位置,
 * Bob一共要走k步,且每步都会等概率向上下左右四个方向走一个单位。
 * 任何时候Bob只要离开N*M的区域,就认为Bob已经死亡。
 * 返回k步之后,Bob还在N*M区域的概率。
 *
 */
public class Q14_BobDie {

    /**
     * 暴力递归尝试方法
     * 思路:
     * 因为计算的是概率,所以需要算两个值,一个是生存的点数,一个是总点数。
     * 对于总点数,走k步,每步都有4种方向,所以总点数就是4^k。调用Math.pow(4, k)即可。
     * 对于生存的点数,和象棋跳马问题是一个套路。
     * 就是从(row, col)位置开始,走k步,还在棋盘中的点数。
     * 设计一个递归函数,每次走一步,步数减去1步,
     * 直到rest为0时,判断当前位置是否还在棋盘中,如果还在棋盘中,就返回1,否则返回0。
     * 在每次递归中,计算上下左右四个方向的生存点数,累加起来就是当前位置的生存点数。
     */
    public static double livePossibility1(int row, int col, int k, int N, int M) {
        return (double) process(row, col, k, N, M) / Math.pow(4, k);
    }

    /**
     * 递归函数
     * 目前在row,col位置,还有rest步要走,走完了如果还在棋盘中就获得1个生存点,返回总的生存点数
     */
    public static long process(int row, int col, int rest, int N, int M) {
        // 判断是否已经出了棋盘
        if (row < 0 || row == N || col < 0 || col == M) {
            return 0;
        }
        // 到这里,说明还在棋盘里面,代表没有死
        // base case:走完了所有步数
        if (rest == 0) {
            return 1;
        }
        // 还有步数要走
        long up = process(row - 1, col, rest - 1, N, M);
        long down = process(row + 1, col, rest - 1, N, M);
        long left = process(row, col - 1, rest - 1, N, M);
        long right = process(row, col + 1, rest - 1, N, M);
        return up + down + left + right;
    }

    /**
     * 从暴力递归改到动态规划
     * 思路:
     * 1、根据递归函数参数,有row,col和rest三个变量,所以缓存表为dp[N][M][k+1]
     * 2、根据base case,rest为0的时候,不管什么位置,都是1,所以三维数组的最底层都是1
     * 3、根据依赖关系,上面的层数依赖下面的层,所以从下往上填三维数组
     * 4、根据调用关系,返回dp[row][col][k]
     */
    public static double livePossibility2(int row, int col, int k, int N, int M) {
        long[][][] dp = new long[N][M][k + 1];
        // 填写base case
        for (int i = 0; i < N; i++) {
            for (int j = 0; j < M; j++) {
                dp[i][j][0] = 1;
            }
        }
        // 从下往上填写三维数组,为了判断方便,抽取pick方法
        for (int rest = 1; rest <= k; rest++) {
            for (int r = 0; r < N; r++) {
                for (int c = 0; c < M; c++) {
                    dp[r][c][rest] = pick(dp, N, M, r - 1, c, rest - 1);
                    dp[r][c][rest] += pick(dp, N, M, r + 1, c, rest - 1);
                    dp[r][c][rest] += pick(dp, N, M, r, c - 1, rest - 1);
                    dp[r][c][rest] += pick(dp, N, M, r, c + 1, rest - 1);
                }
            }
        }
        return (double) dp[row][col][k] / Math.pow(4, k);
    }

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

    public static void main(String[] args) {
        System.out.println(livePossibility1(6, 6, 10, 50, 50));
        System.out.println(livePossibility2(6, 6, 10, 50, 50));
    }

}

2.15、题目十五:砍怪兽问题

  • 题目十五:砍怪兽问题
  • 给定3个参数,N、M、K
  • 怪兽有N滴血,等着英雄来砍自己
  • 英雄每一次打击,都会让怪兽流失[0~M]的血量
  • 到底流失多少?每一次在[0~M]上等概率的获得一个值
  • 求K次打击之后,英雄把怪兽砍死的概率
2.15.1、暴力递归尝试方法
  • 暴力递归尝试方法

  • 思路:

    • 这个题目难点的地方是每次砍怪兽的伤害是随机的,所以每次砍的伤害是一个随机值,
    • 每次是从[0M]上等概率的获得一个值,这样也可以看成是每次都是在[0M]范围上选择了一个分支,也就是每次都有M+1个分支。
    • 一共有K次,所以总共的情况数就是(M+1)^K,这就算出了总共的可以砍的次数,然后求出砍死的次数,就是概率。
    • 在求砍死的情况时,可以用从左到右的尝试模型,遍历所有情况,累计出砍死的情况即可。
    • 所以设计递归函数的时候,需要考虑的参数有:
      1. 还剩多少血量rest
      1. 每次的伤害范围是[0~M],之所以要考虑这个范围,因为还要统计如果在times的时候,如果已经死了,就要累计上后面的所有的可能性。
      1. 还有多少次可以砍times
  • 总结:

    • 这道题难以理解的地方是每次随机的血量问题,这个并不是每次用random函数去随机一个,而是数学上的可能性,
    • 0,M\]上选一个,就是在M+1个分支中随机选一个,也就是有M+1种可能性,有K次,总共就有(M+1)\^K种情况。

    • 也就是要统计(M+1)^times种情况。要不然会少统计很多。
java 复制代码
    /**
     * 暴力递归尝试方法
     * 思路:
     * 这个题目难点的地方是每次砍怪兽的伤害是随机的,所以每次砍的伤害是一个随机值,
     * 每次是从[0~M]上等概率的获得一个值,这样也可以看成是每次都是在[0~M]范围上选择了一个分支,也就是每次都有M+1个分支。
     * 一共有K次,所以总共的情况数就是(M+1)^K,这就算出了总共的可以砍的次数,然后求出砍死的次数,就是概率。
     * 在求砍死的情况时,可以用从左到右的尝试模型,遍历所有情况,累计出砍死的情况即可。
     * 所以设计递归函数的时候,需要考虑的参数有:
     * 1. 还剩多少血量rest
     * 2. 每次的伤害范围是[0~M],之所以要考虑这个范围,因为还要统计如果在times的时候,如果已经死了,就要累计上后面的所有的可能性。
     * 3. 还有多少次可以砍times
     * <br>
     * 总结:
     * 这道题难以理解的地方是每次随机的血量问题,这个并不是每次用random函数去随机一个,而是数学上的可能性,
     * [0,M]上选一个,就是在M+1个分支中随机选一个,也就是有M+1种可能性,有K次,总共就有(M+1)^K种情况。
     * 然后就是在求砍死的情况的时候,如果次数没有用完,但是怪物已经死了,这个时候不是累计1次,而是要将剩余砍的次数的情况都统计上,
     * 也就是要统计(M+1)^times种情况。要不然会少统计很多。
     */
    public static double killMonster1(int N, int M, int K) {
        if (N < 1 || M < 1 || K < 1) {
            return 0;
        }
        long all = (long) Math.pow(M + 1, K);
        long kill = process(K, N, M);
        return (double) ((double) kill / (double) all);
    }

    /**
     * 递归函数:怪兽还剩hp点血,还有times次可以砍,每次的伤害在[0~M]范围上,返回砍死的情况数!
     */
    public static long process(int times, int rest, int M) {
        // base case1:最后一次砍,死了就累计1次,不死就是0
        if (times == 0) {
            return rest <= 0 ? 1 : 0;
        }
        // base case2,没有砍到最后一刀,已经死了,说明剩余的times次的分支选择都是死的,直接累加即可
        if (rest <= 0) {
            return (long) Math.pow(M + 1, times);
        }
        // 有血有刀,继续砍
        long ways = 0;
        for (int i = 0; i <= M; i++) {
            ways += process(times - 1, rest - i, M);
        }
        return ways;
    }
2.15.2、从暴力递归尝试改成动态规划
  • 根据暴力递归改成的动态规划
  • 思路:
    • 1、根据递归函数参数,M是确定的,变化的只有times[0,K]和dp[0,N]两个,所以可以用一个二维数组dp[K+1][N+1]表示,
    • 2、根据base case1初始化dp数组dp[0][0]=1,base case2初始化dp[times][0]=(M+1)^times(需要在for循环times时初始化)
    • 3、其他的依赖times-1和hp-i,所以是从上往下,从左往右的依赖。在填写的过程中,为了防止dp小于0,要进行判断,然后再赋值。
    • 4、根据调用递归函数的参数,最后的结果为dp[K][N]
java 复制代码
    /**
     * 根据暴力递归改成的动态规划
     * 思路:
     * 1、根据递归函数参数,M是确定的,变化的只有times[0,K]和dp[0,N]两个,所以可以用一个二维数组dp[K+1][N+1]表示,
     * 2、根据base case1初始化dp数组dp[0][0]=1,base case2初始化dp[times][0]=(M+1)^times(需要在for循环times时初始化)
     * 3、其他的依赖times-1和hp-i,所以是从上往下,从左往右的依赖。在填写的过程中,为了防止dp小于0,要进行判断,然后再赋值。
     * 4、根据调用递归函数的参数,最后的结果为dp[K][N]
     */
    public static double killMonster2(int N, int M, int K) {
        if (N < 1 || M < 1 || K < 1) {
            return 0;
        }
        long all = (long) Math.pow(M + 1, K);
        long[][] dp = new long[K + 1][N + 1];
        // base case 1
        dp[0][0] = 1;
        // 从上往下,从左往右填表
        for (int times = 1; times <= K; times++) {
            // base case2:第一列,表示血量为0,代表没到最后一刀,已经死了,累计后面的可能性
            dp[times][0] = (long) Math.pow(M + 1, times);
            // 累计血量
            for (int rest = 1; rest <= N; rest++) {
                long ways = 0;
                // 循环每一个血量,累加起来
                for (int i = 0; i <= M; i++) {
                    // 这里和递归函数有点区别,递归函数可以在下一次递归的时候判断,但是填表涉及到越界的问题,所以要分开判断一下
                    if (rest - i >= 0) {
                        ways += dp[times - 1][rest - i];
                    } else {
                        ways += (long) Math.pow(M + 1, times - 1);
                    }
                }
                dp[times][rest] = ways;
            }
        }
        long kill = dp[K][N];
        return (double) ((double) kill / (double) all);
    }
2.15.3、严格表结构依赖的动态规划方法
  • 严格表结构依赖的动态规划方法
  • 思路:
    • 上面的动态规划中,二维表填表的过程有个枚举行为,我们通过观察,看看能不能把枚举行为替换掉。
    • 观察发现,dp[times][rest]依赖的是dp[times-1][rest-0]到dp[times-1][rest-M],即将上一行的rest-0到rest-M的所有值累加起来,就是dp[times][rest]的值。
    • 和题目十三一样,M是有一定的范围,如果我们直接累计本行前一个计算值(dp[times][rest-1])和上面的值(dp[times-1][rest]),
    • 会多加一个值。这个多加的值是dp[times - 1][rest - (M+1)],即dp[times - 1][rest - 1 - M]
    • 将这个多加的值减去即可。
    • 和题目十三不同的是,题目十三还有个coins[index]的偏移量,这里没有(也可以看成是1),这里的M就是题目十三中的counts[index]。所以展开就是rest - 1 - M。
    • 用严格表机构依赖的方法,就能去掉一个for循环,从而改进效率。
java 复制代码
    /**
     * 严格表结构依赖的动态规划方法
     * 思路:
     * 上面的动态规划中,二维表填表的过程有个枚举行为,我们通过观察,看看能不能把枚举行为替换掉。
     * 观察发现,dp[times][rest]依赖的是dp[times-1][rest-0]到dp[times-1][rest-M],即将上一行的rest-0到rest-M的所有值累加起来,就是dp[times][rest]的值。
     * 和题目十三一样,M是有一定的范围,如果我们直接累计本行前一个计算值(dp[times][rest-1])和上面的值(dp[times-1][rest]),
     * 会多加一个值。这个多加的值是dp[times - 1][rest - (M+1)],即dp[times - 1][rest - 1 - M]
     * 将这个多加的值减去即可。
     * 和题目十三不同的是,题目十三还有个coins[index]的偏移量,这里没有(也可以看成是1),这里的M就是题目十三中的counts[index]。所以展开就是rest - 1 - M。
     * 用严格表机构依赖的方法,就能去掉一个for循环,从而改进效率。
     */
    public static double killMonster3(int N, int M, int K) {
        if (N < 1 || M < 1 || K < 1) {
            return 0;
        }
        long all = (long) Math.pow(M + 1, K);
        long[][] dp = new long[K + 1][N + 1];
        // base case 1
        dp[0][0] = 1;
        // 从上往下,从左往右填表
        for (int times = 1; times <= K; times++) {
            // base case2:第一列,表示血量为0,代表没到最后一刀,已经死了,累计后面的可能性
            dp[times][0] = (long) Math.pow(M + 1, times);
            // 累计血量
            for (int rest = 1; rest <= N; rest++) {
                // 循环每一个血量,累加起来
                for (int i = 0; i <= M; i++) {
                    // 先累加上前一个和上面的值
                    dp[times][rest] = dp[times][rest - 1] + dp[times - 1][rest];
                    // 如果不越界,就减去累加的值
                    if (rest - 1 - M >= 0) {
                        dp[times][rest] -= dp[times - 1][rest - 1 - M];
                    } else {
                        // 要减去的值越界了,需要减去我们用公式计算的部分,因为公式计算的部分每一行都是有的,如果没越界,就会累加到rest-1-M里面,不需要单独算
                        dp[times][rest] -= (long) Math.pow(M + 1, times - 1);
                    }

                }
            }
        }
        long kill = dp[K][N];
        return (double) ((double) kill / (double) all);
    }

整体代码和测试如下:

java 复制代码
/**
 * 题目十五:砍怪兽问题
 * 给定3个参数,N、M、K
 * 怪兽有N滴血,等着英雄来砍自己
 * 英雄每一次打击,都会让怪兽流失[0~M]的血量
 * 到底流失多少?每一次在[0~M]上等概率的获得一个值
 * 求K次打击之后,英雄把怪兽砍死的概率
 */
public class Q15_KillMonster {
    /**
     * 暴力递归尝试方法
     * 思路:
     * 这个题目难点的地方是每次砍怪兽的伤害是随机的,所以每次砍的伤害是一个随机值,
     * 每次是从[0~M]上等概率的获得一个值,这样也可以看成是每次都是在[0~M]范围上选择了一个分支,也就是每次都有M+1个分支。
     * 一共有K次,所以总共的情况数就是(M+1)^K,这就算出了总共的可以砍的次数,然后求出砍死的次数,就是概率。
     * 在求砍死的情况时,可以用从左到右的尝试模型,遍历所有情况,累计出砍死的情况即可。
     * 所以设计递归函数的时候,需要考虑的参数有:
     * 1. 还剩多少血量rest
     * 2. 每次的伤害范围是[0~M],之所以要考虑这个范围,因为还要统计如果在times的时候,如果已经死了,就要累计上后面的所有的可能性。
     * 3. 还有多少次可以砍times
     * <br>
     * 总结:
     * 这道题难以理解的地方是每次随机的血量问题,这个并不是每次用random函数去随机一个,而是数学上的可能性,
     * [0,M]上选一个,就是在M+1个分支中随机选一个,也就是有M+1种可能性,有K次,总共就有(M+1)^K种情况。
     * 然后就是在求砍死的情况的时候,如果次数没有用完,但是怪物已经死了,这个时候不是累计1次,而是要将剩余砍的次数的情况都统计上,
     * 也就是要统计(M+1)^times种情况。要不然会少统计很多。
     */
    public static double killMonster1(int N, int M, int K) {
        if (N < 1 || M < 1 || K < 1) {
            return 0;
        }
        long all = (long) Math.pow(M + 1, K);
        long kill = process(K, N, M);
        return (double) ((double) kill / (double) all);
    }

    /**
     * 递归函数:怪兽还剩hp点血,还有times次可以砍,每次的伤害在[0~M]范围上,返回砍死的情况数!
     */
    public static long process(int times, int rest, int M) {
        // base case1:最后一次砍,死了就累计1次,不死就是0
        if (times == 0) {
            return rest <= 0 ? 1 : 0;
        }
        // base case2,没有砍到最后一刀,已经死了,说明剩余的times次的分支选择都是死的,直接累加即可
        if (rest <= 0) {
            return (long) Math.pow(M + 1, times);
        }
        // 有血有刀,继续砍
        long ways = 0;
        for (int i = 0; i <= M; i++) {
            ways += process(times - 1, rest - i, M);
        }
        return ways;
    }

    /**
     * 根据暴力递归改成的动态规划
     * 思路:
     * 1、根据递归函数参数,M是确定的,变化的只有times[0,K]和dp[0,N]两个,所以可以用一个二维数组dp[K+1][N+1]表示,
     * 2、根据base case1初始化dp数组dp[0][0]=1,base case2初始化dp[times][0]=(M+1)^times(需要在for循环times时初始化)
     * 3、其他的依赖times-1和hp-i,所以是从上往下,从左往右的依赖。在填写的过程中,为了防止dp小于0,要进行判断,然后再赋值。
     * 4、根据调用递归函数的参数,最后的结果为dp[K][N]
     */
    public static double killMonster2(int N, int M, int K) {
        if (N < 1 || M < 1 || K < 1) {
            return 0;
        }
        long all = (long) Math.pow(M + 1, K);
        long[][] dp = new long[K + 1][N + 1];
        // base case 1
        dp[0][0] = 1;
        // 从上往下,从左往右填表
        for (int times = 1; times <= K; times++) {
            // base case2:第一列,表示血量为0,代表没到最后一刀,已经死了,累计后面的可能性
            dp[times][0] = (long) Math.pow(M + 1, times);
            // 累计血量
            for (int rest = 1; rest <= N; rest++) {
                long ways = 0;
                // 循环每一个血量,累加起来
                for (int i = 0; i <= M; i++) {
                    // 这里和递归函数有点区别,递归函数可以在下一次递归的时候判断,但是填表涉及到越界的问题,所以要分开判断一下
                    if (rest - i >= 0) {
                        ways += dp[times - 1][rest - i];
                    } else {
                        ways += (long) Math.pow(M + 1, times - 1);
                    }
                }
                dp[times][rest] = ways;
            }
        }
        long kill = dp[K][N];
        return (double) ((double) kill / (double) all);
    }

    /**
     * 严格表结构依赖的动态规划方法
     * 思路:
     * 上面的动态规划中,二维表填表的过程有个枚举行为,我们通过观察,看看能不能把枚举行为替换掉。
     * 观察发现,dp[times][rest]依赖的是dp[times-1][rest-0]到dp[times-1][rest-M],即将上一行的rest-0到rest-M的所有值累加起来,就是dp[times][rest]的值。
     * 和题目十三一样,M是有一定的范围,如果我们直接累计本行前一个计算值(dp[times][rest-1])和上面的值(dp[times-1][rest]),
     * 会多加一个值。这个多加的值是dp[times - 1][rest - (M+1)],即dp[times - 1][rest - 1 - M]
     * 将这个多加的值减去即可。
     * 和题目十三不同的是,题目十三还有个coins[index]的偏移量,这里没有(也可以看成是1),这里的M就是题目十三中的counts[index]。所以展开就是rest - 1 - M。
     * 用严格表机构依赖的方法,就能去掉一个for循环,从而改进效率。
     */
    public static double killMonster3(int N, int M, int K) {
        if (N < 1 || M < 1 || K < 1) {
            return 0;
        }
        long all = (long) Math.pow(M + 1, K);
        long[][] dp = new long[K + 1][N + 1];
        // base case 1
        dp[0][0] = 1;
        // 从上往下,从左往右填表
        for (int times = 1; times <= K; times++) {
            // base case2:第一列,表示血量为0,代表没到最后一刀,已经死了,累计后面的可能性
            dp[times][0] = (long) Math.pow(M + 1, times);
            // 累计血量
            for (int rest = 1; rest <= N; rest++) {
                // 循环每一个血量,累加起来
                for (int i = 0; i <= M; i++) {
                    // 先累加上前一个和上面的值
                    dp[times][rest] = dp[times][rest - 1] + dp[times - 1][rest];
                    // 如果不越界,就减去累加的值
                    if (rest - 1 - M >= 0) {
                        dp[times][rest] -= dp[times - 1][rest - 1 - M];
                    } else {
                        // 要减去的值越界了,需要减去我们用公式计算的部分,因为公式计算的部分每一行都是有的,如果没越界,就会累加到rest-1-M里面,不需要单独算
                        dp[times][rest] -= (long) Math.pow(M + 1, times - 1);
                    }

                }
            }
        }
        long kill = dp[K][N];
        return (double) ((double) kill / (double) all);
    }

    public static void main(String[] args) {
        int NMax = 30;
        int MMax = 10;
        int KMax = 10;
        int testTime = 2000;
        System.out.println("测试开始");
        for (int i = 0; i < testTime; i++) {
            int N = (int) (Math.random() * NMax);
            int M = (int) (Math.random() * MMax);
            int K = (int) (Math.random() * KMax);
            double ans1 = killMonster1(N, M, K);
            double ans2 = killMonster2(N, M, K);
            double ans3 = killMonster3(N, M, K);
            if (ans1 != ans2 || ans1 != ans3) {
                System.out.println("错误!");
                System.out.printf("N: %d,K: %d,M: %d\n", N, K, M);
                System.out.printf("ans1: %f,ans2: %f,ans3: %f\n", ans1, ans2, ans3);
                break;
            }
        }
        System.out.println("测试结束");
    }
}

2.16、题目十六:最少货币数量

  • 题目十六:最少货币数量
  • arr是面值数组,其中的值都是正数且没有重复。再给定一个正数aim。
  • 每个值都认为是一种面值,且认为张数是无限的。
  • 返回组成aim的最少货币数
  • 比如arr={2,5,10},aim=1002
  • 最少的是用100张10元的货币+1张2元的货币,返回101
  • 这个和题目十二的区别是,题目十二返回的是可能的组合数,这个题要求的是返回的最少货币数量。
2.16.1、暴力递归尝试方法
  • 暴力递归尝试方法:
  • 思路:
    • 从左往右的尝试模型。
    • 每个位置都可以选择用0张、1张、2张...张当前位置的面值,
    • 然后去尝试下一个位置,直到最后一个位置,最后返回最小的那个值即可。
    • 这种尝试方法,就枚举了所有的可能性,然后选出每个位置的最小值,即是最小的货币数量。
java 复制代码
    /**
     * 暴力递归尝试方法:
     * 思路:
     * 从左往右的尝试模型。
     * 每个位置都可以选择用0张、1张、2张...张当前位置的面值,
     * 然后去尝试下一个位置,直到最后一个位置,最后返回最小的那个值即可。
     * 这种尝试方法,就枚举了所有的可能性,然后选出每个位置的最小值,即是最小的货币数量。
     */
    public static int minCoins1(int[] arr, int aim) {
        if (aim == 0) {
            return 0;
        }
        return process(arr, 0, aim);
    }

    /**
     * 递归函数
     * 从index位置开始,搞定rest需要的最少货币数量
     * 拿Integer.MAX_VALUE标记怎么都搞定不了
     *
     * @param arr   :arr[index...]面值,每种面值张数自由选择,
     * @param index :当前位置
     * @param rest  :搞出rest正好这么多钱,返回最小张数
     */
    public static int process(int[] arr, int index, int rest) {
        // base case,到了最后一个,刚好搞定rest,返回0张
        if (index == arr.length) {
            return rest == 0 ? 0 : Integer.MAX_VALUE;
        }
        // 因为最后要最小值,所以无效的解要用最大值来算,这样算出来的才能在取完最小值时生效。
        int ans = Integer.MAX_VALUE;
        // 尝试每一个面值不同的张数
        for (int num = 0; num * arr[index] <= rest; num++) {
            // 递归在当前张数的情况下,下一个位置搞定rest-num*arr[index]的最少货币数量
            int next = process(arr, index + 1, rest - num * arr[index]);
            // 如果下一个位置可以搞定,就更新ans
            if (next != Integer.MAX_VALUE) {
                ans = Math.min(ans, num + next);
            }
        }
        // 返回当前位置的最少货币数量
        return ans;
    }
2.16.2、从暴力递归尝试改成动态规划
  • 从暴力递归改为动态规划:
  • 思路:
    *
    1. 分析可变参数:index和rest,范围都是0N和0aim,所以二维数组dp[N+1][aim+1]即可。
      1. 分析base case:dp[N][0]=0,其他位置都是无效解,用Integer.MAX_VALUE表示。
      1. 分析依赖关系:dp[index][rest]依赖dp[index+1][rest-arr[index]*0]...dp[index+1][rest-arr[index]*num],
    • 当前行依赖下一行的位置,所以从下往上,从左往右填写。在初始化的时候,最大值填写最后一行即可。后面的就会计算到。
      1. 分析返回值:dp[0][aim]。
java 复制代码
    /**
     * 从暴力递归改为动态规划:
     * 思路:
     * 1. 分析可变参数:index和rest,范围都是0~N和0~aim,所以二维数组dp[N+1][aim+1]即可。
     * 2. 分析base case:dp[N][0]=0,其他位置都是无效解,用Integer.MAX_VALUE表示。
     * 3. 分析依赖关系:dp[index][rest]依赖dp[index+1][rest-arr[index]*0]...dp[index+1][rest-arr[index]*num],
     * 当前行依赖下一行的位置,所以从下往上,从左往右填写。在初始化的时候,最大值填写最后一行即可。后面的就会计算到。
     * 4. 分析返回值:dp[0][aim]。
     */
    public static int minCoins2(int[] arr, int aim) {
        if (aim == 0) {
            return 0;
        }
        int N = arr.length;
        int[][] dp = new int[N + 1][aim + 1];
        // 根据base case,初始化dp[N][0]=0,其他位置都是无效解,用Integer.MAX_VALUE表示。
        dp[N][0] = 0;
        for (int i = 1; i <= aim; i++) {
            dp[N][i] = i == 0 ? 0 : Integer.MAX_VALUE;
        }
        // 从下往上,从左往右填写dp数组
        for (int index = N - 1; index >= 0; index--) {
            for (int rest = 0; rest <= aim; rest++) {
                // 因为最后要最小值,所以无效的解要用最大值来算,这样算出来的才能在取完最小值时生效。
                int ans = Integer.MAX_VALUE;
                // 尝试每一个面值不同的张数
                for (int num = 0; num * arr[index] <= rest; num++) {
                    // 递归在当前张数的情况下,下一个位置搞定rest-num*arr[index]的最少货币数量
                    int next = dp[index + 1][rest - num * arr[index]];
                    // 如果下一个位置可以搞定,就更新ans
                    if (next != Integer.MAX_VALUE) {
                        ans = Math.min(ans, num + next);
                    }
                }
                // 更新dp数组
                dp[index][rest] = ans;
            }
        }
        // 返回dp[0][aim]
        return dp[0][aim];
    }
2.16.3、严格表结构依赖的动态规划方法
  • 严格表结构依赖的动态规划方法
  • 思路:
    *
    1. 分析依赖关系:dp[index][rest]依赖dp[index+1][rest-arr[index]*0]...dp[index+1][rest-arr[index]*num],num的范围是0到rest-arr[index]*num>=0,
    • 所以当前的位置,是在下一行的位置的满足rest-arr[index+1]*0...rest-arr[index+1]*num的值加上num的最小值,即dp[index+1][rest-arr[index]*num]+num的值进行比较。
    • 2.对于dp[index][rest]的本行的前一个值dp[index][rest-arr[index]],因为dp[index][rest-arr[index]]已经计算好了它下面的满足1条件的最小值,
    • 那么dp[index][rest]的最小值就应该是dp[index][rest-arr[index]] + 1(加1是因为前面到当前位置有1张arr[index]) 和dp[index + 1][rest]的较小值。
    • 即:Math.min(dp[index][rest-arr[index]] + 1, dp[index][rest])。
    • 当然,要判断是否越界的问题。
    • 通过严格表结构依赖的约束,就能去掉里面的for循环的枚举,从而提升效率。
java 复制代码
    /**
     * 严格表结构依赖的动态规划方法
     * 思路:
     * 1. 分析依赖关系:dp[index][rest]依赖dp[index+1][rest-arr[index]*0]...dp[index+1][rest-arr[index]*num],num的范围是0到rest-arr[index]*num>=0,
     * 所以当前的位置,是在下一行的位置的满足rest-arr[index+1]*0...rest-arr[index+1]*num的值加上num的最小值,即dp[index+1][rest-arr[index]*num]+num的值进行比较。
     * 2.对于dp[index][rest]的本行的前一个值dp[index][rest-arr[index]],因为dp[index][rest-arr[index]]已经计算好了它下面的满足1条件的最小值,
     * 那么dp[index][rest]的最小值就应该是dp[index][rest-arr[index]] + 1(加1是因为前面到当前位置有1张arr[index]) 和dp[index + 1][rest]的较小值。
     * 即:Math.min(dp[index][rest-arr[index]] + 1, dp[index][rest])。
     * 当然,要判断是否越界的问题。
     * 通过严格表结构依赖的约束,就能去掉里面的for循环的枚举,从而提升效率。
     */
    public static int minCoins3(int[] arr, int aim) {
        if (aim == 0) {
            return 0;
        }
        int N = arr.length;
        int[][] dp = new int[N + 1][aim + 1];
        // 初始化最后一行
        dp[N][0] = 0;
        for (int j = 1; j <= aim; j++) {
            dp[N][j] = Integer.MAX_VALUE;
        }
        // 从下往上,从左往右填写dp数组。
        for (int index = N - 1; index >= 0; index--) {
            for (int rest = 0; rest <= aim; rest++) {
                dp[index][rest] = dp[index + 1][rest];
                // 判断越界和有效性
                if (rest - arr[index] >= 0
                        && dp[index][rest - arr[index]] != Integer.MAX_VALUE) {
                    dp[index][rest] = Math.min(dp[index][rest], dp[index][rest - arr[index]] + 1);
                }
            }
        }
        return dp[0][aim];
    }

整体代码和测试如下:

java 复制代码
/**
 * 题目十六:最少货币数量
 * arr是面值数组,其中的值都是正数且没有重复。再给定一个正数aim。
 * 每个值都认为是一种面值,且认为张数是无限的。
 * 返回组成aim的最少货币数
 * 比如arr={2,5,10},aim=1002
 * 最少的是用100张10元的货币+1张2元的货币,返回101
 * 这个和题目十二的区别是,题目十二返回的是可能的组合数,这个题要求的是返回的最少货币数量。
 */
public class Q16_MinCoinsNoLimit {

    /**
     * 暴力递归尝试方法:
     * 思路:
     * 从左往右的尝试模型。
     * 每个位置都可以选择用0张、1张、2张...张当前位置的面值,
     * 然后去尝试下一个位置,直到最后一个位置,最后返回最小的那个值即可。
     * 这种尝试方法,就枚举了所有的可能性,然后选出每个位置的最小值,即是最小的货币数量。
     */
    public static int minCoins1(int[] arr, int aim) {
        if (aim == 0) {
            return 0;
        }
        return process(arr, 0, aim);
    }

    /**
     * 递归函数
     * 从index位置开始,搞定rest需要的最少货币数量
     * 拿Integer.MAX_VALUE标记怎么都搞定不了
     *
     * @param arr   :arr[index...]面值,每种面值张数自由选择,
     * @param index :当前位置
     * @param rest  :搞出rest正好这么多钱,返回最小张数
     */
    public static int process(int[] arr, int index, int rest) {
        // base case,到了最后一个,刚好搞定rest,返回0张
        if (index == arr.length) {
            return rest == 0 ? 0 : Integer.MAX_VALUE;
        }
        // 因为最后要最小值,所以无效的解要用最大值来算,这样算出来的才能在取完最小值时生效。
        int ans = Integer.MAX_VALUE;
        // 尝试每一个面值不同的张数
        for (int num = 0; num * arr[index] <= rest; num++) {
            // 递归在当前张数的情况下,下一个位置搞定rest-num*arr[index]的最少货币数量
            int next = process(arr, index + 1, rest - num * arr[index]);
            // 如果下一个位置可以搞定,就更新ans
            if (next != Integer.MAX_VALUE) {
                ans = Math.min(ans, num + next);
            }
        }
        // 返回当前位置的最少货币数量
        return ans;
    }

    /**
     * 从暴力递归改为动态规划:
     * 思路:
     * 1. 分析可变参数:index和rest,范围都是0~N和0~aim,所以二维数组dp[N+1][aim+1]即可。
     * 2. 分析base case:dp[N][0]=0,其他位置都是无效解,用Integer.MAX_VALUE表示。
     * 3. 分析依赖关系:dp[index][rest]依赖dp[index+1][rest-arr[index]*0]...dp[index+1][rest-arr[index]*num],
     * 当前行依赖下一行的位置,所以从下往上,从左往右填写。在初始化的时候,最大值填写最后一行即可。后面的就会计算到。
     * 4. 分析返回值:dp[0][aim]。
     */
    public static int minCoins2(int[] arr, int aim) {
        if (aim == 0) {
            return 0;
        }
        int N = arr.length;
        int[][] dp = new int[N + 1][aim + 1];
        // 根据base case,初始化dp[N][0]=0,其他位置都是无效解,用Integer.MAX_VALUE表示。
        dp[N][0] = 0;
        for (int i = 1; i <= aim; i++) {
            dp[N][i] = i == 0 ? 0 : Integer.MAX_VALUE;
        }
        // 从下往上,从左往右填写dp数组
        for (int index = N - 1; index >= 0; index--) {
            for (int rest = 0; rest <= aim; rest++) {
                // 因为最后要最小值,所以无效的解要用最大值来算,这样算出来的才能在取完最小值时生效。
                int ans = Integer.MAX_VALUE;
                // 尝试每一个面值不同的张数
                for (int num = 0; num * arr[index] <= rest; num++) {
                    // 递归在当前张数的情况下,下一个位置搞定rest-num*arr[index]的最少货币数量
                    int next = dp[index + 1][rest - num * arr[index]];
                    // 如果下一个位置可以搞定,就更新ans
                    if (next != Integer.MAX_VALUE) {
                        ans = Math.min(ans, num + next);
                    }
                }
                // 更新dp数组
                dp[index][rest] = ans;
            }
        }
        // 返回dp[0][aim]
        return dp[0][aim];
    }

    /**
     * 严格表结构依赖的动态规划方法
     * 思路:
     * 1. 分析依赖关系:dp[index][rest]依赖dp[index+1][rest-arr[index]*0]...dp[index+1][rest-arr[index]*num],num的范围是0到rest-arr[index]*num>=0,
     * 所以当前的位置,是在下一行的位置的满足rest-arr[index+1]*0...rest-arr[index+1]*num的值加上num的最小值,即dp[index+1][rest-arr[index]*num]+num的值进行比较。
     * 2.对于dp[index][rest]的本行的前一个值dp[index][rest-arr[index]],因为dp[index][rest-arr[index]]已经计算好了它下面的满足1条件的最小值,
     * 那么dp[index][rest]的最小值就应该是dp[index][rest-arr[index]] + 1(加1是因为前面到当前位置有1张arr[index]) 和dp[index + 1][rest]的较小值。
     * 即:Math.min(dp[index][rest-arr[index]] + 1, dp[index][rest])。
     * 当然,要判断是否越界的问题。
     * 通过严格表结构依赖的约束,就能去掉里面的for循环的枚举,从而提升效率。
     */
    public static int minCoins3(int[] arr, int aim) {
        if (aim == 0) {
            return 0;
        }
        int N = arr.length;
        int[][] dp = new int[N + 1][aim + 1];
        // 初始化最后一行
        dp[N][0] = 0;
        for (int j = 1; j <= aim; j++) {
            dp[N][j] = Integer.MAX_VALUE;
        }
        // 从下往上,从左往右填写dp数组。
        for (int index = N - 1; index >= 0; index--) {
            for (int rest = 0; rest <= aim; rest++) {
                dp[index][rest] = dp[index + 1][rest];
                // 判断越界和有效性
                if (rest - arr[index] >= 0
                        && dp[index][rest - arr[index]] != Integer.MAX_VALUE) {
                    dp[index][rest] = Math.min(dp[index][rest], dp[index][rest - arr[index]] + 1);
                }
            }
        }
        return dp[0][aim];
    }

    // 为了测试
    public static void main(String[] args) {
        int maxLen = 30;
        int maxValue = 30;
        int testTime = 1000000;
        System.out.println("功能测试开始");
        for (int i = 0; i < testTime; i++) {
            int N = (int) (Math.random() * maxLen);
            int[] arr = randomArray(N, maxValue);
            int aim = (int) (Math.random() * maxValue);
            int ans1 = minCoins1(arr, aim);
            int ans2 = minCoins2(arr, aim);
            int ans3 = minCoins3(arr, aim);
            if (ans1 != ans2 || ans1 != ans3) {
                System.out.println("错误!");
                printArray(arr);
                System.out.printf("aim = %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.17、题目十七:整数拆分

  • 题目十七:整数拆分
  • 给定一个正数n,求n的裂开方法数,
  • 规定:后面的数不能比前面的数小
  • 比如4的裂开方法有:
  • 1+1+1+1、1+1+2、1+3、2+2、4
  • 5种,所以返回5
2.17.1、暴力递归尝试方法
  • 暴力递归尝试
    • 思路:
    • 从左到右的尝试模型。
    • 我们可以写一个递归函数,函数中包含两个参数:上一个拆出来的数是pre,还剩rest需要去拆。
    • 在每个递归的时候,从pre开始,一直拆到rest,每个数都进行递归再次拆分,然后计算出整体的方法数即可。
    • 每次递归中从pre到rest的这个过程,枚举了所有的可能情况,这样就相当于在所有的可能组合中,挑出能组成rest的组合数。
java 复制代码
    /**
     * 暴力递归尝试
     * 思路:
     * 从左到右的尝试模型。
     * 我们可以写一个递归函数,函数中包含两个参数:上一个拆出来的数是pre,还剩rest需要去拆。
     * 在每个递归的时候,从pre开始,一直拆到rest,每个数都进行递归再次拆分,然后计算出整体的方法数即可。
     * 每次递归中从pre到rest的这个过程,枚举了所有的可能情况,这样就相当于在所有的可能组合中,挑出能组成rest的组合数。
     */
    public static int ways1(int n) {
        if (n <= 0) {
            return 0;
        }
        if (n == 1) {
            return 1;
        }
        // 从1开始进行拆分
        return process(1, n);
    }

    /**
     * 递归函数,计算从pre开始,拆出rest的方法数。
     *
     * @param pre  :上一个拆出来的数是pre
     * @param rest :还剩rest需要去拆
     * @return :返回拆解的方法数
     */
    public static int process(int pre, int rest) {
        // base case,当rest==0时,说明已经拆完了,返回1种方法。
        if (rest == 0) {
            return 1;
        }
        // 边界判断,代表前面已经拆分完了,到这里已经没有剩余值可以拆分了,
        // 这个不能写在base case后面,因为base case是rest==0的情况,如果后面判断base case,就会少算这种方法。
        if (pre > rest) {
            return 0;
        }
        // 从pre开始进行枚举拆分
        int ways = 0;
        for (int i = pre; i <= rest; i++) {
            ways += process(i, rest - i);
        }
        return ways;
    }
2.17.2、从暴力递归尝试改成动态规划
  • 从暴力递归改为动态规划
  • 思路:
    *
    1. 分析可变参数:pre和rest,范围都是0~N,所以二维数组dp[N+1][N+1]即可。pre从1开始,所以下标为0的行是不用的。
      1. 分析base case:dp[*][0]=1,即第一列为1,因为一个数本身也是一种方法,所以对角线也是1,即dp[pre][pre]=1。
    • 因为pre > rest时为0,所以除了对角线和第一列,左下角的其他位置都为0,在java中不需要单独赋值为0。
      1. 分析依赖关系:dp[pre][rest]依赖dp[pre+i][rest-i],i从pre开始,到rest结束,当前位置依赖下面和左边的位置,所以从下往上,从左往右填写。
      1. 分析返回值:dp[1][n]。
java 复制代码
    /**
     * 从暴力递归改为动态规划
     * 思路:
     * 1. 分析可变参数:pre和rest,范围都是0~N,所以二维数组dp[N+1][N+1]即可。pre从1开始,所以下标为0的行是不用的。
     * 2. 分析base case:dp[*][0]=1,即第一列为1,因为一个数本身也是一种方法,所以对角线也是1,即dp[pre][pre]=1。
     * 因为pre > rest时为0,所以除了对角线和第一列,左下角的其他位置都为0,在java中不需要单独赋值为0。
     * 3. 分析依赖关系:dp[pre][rest]依赖dp[pre+i][rest-i],i从pre开始,到rest结束,当前位置依赖下面和左边的位置,所以从下往上,从左往右填写。
     * 4. 分析返回值:dp[1][n]。
     */
    public static int ways2(int n) {
        if (n <= 0) {
            return 0;
        }
        if (n == 1) {
            return 1;
        }
        int[][] dp = new int[n + 1][n + 1];
        // 分析base case:dp[*][0]=1,即第一列为1,因为一个数本身也是一种方法,所以对角线也是1,即dp[pre][pre]=1。
        for (int pre = 1; pre <= n; pre++) {
            dp[pre][0] = 1;
            dp[pre][pre] = 1;
        }
        // 从下往上,从左往右填右上角的位置
        for (int pre = n - 1; pre >= 1; pre--) {
            for (int rest = pre + 1; rest <= n; rest++) {
                int ways = 0;
                for (int i = pre; i <= rest; i++) {
                    ways += dp[i][rest - i];
                }
                dp[pre][rest] = ways;
            }
        }
        return dp[1][n];
    }
2.17.3、严格表结构依赖的动态规划方法
  • 严格表结构依赖的动态规划方法
  • 思路:
    • 从上面的递归方法可以看出,dp[pre][rest]依赖基于偏移量i的左下角位置,即dp[pre+i][rest-i],i的范围是[pre,rest]。
    • 通过观察dp数组的依赖关系,可以看出,dp[pre][rest]是累加了dp[pre+1][rest]和dp[pre][rest-pre]的结果。
    • 具体我们可以画出一个dp数组的依赖关系图,举例不同的值,就能归纳出上面的结果。
    • 这个纯粹是观察dp数组的依赖关系,总结出的结果。
java 复制代码
    /**
     * 严格表结构依赖的动态规划方法
     * 思路:
     * 从上面的递归方法可以看出,dp[pre][rest]依赖基于偏移量i的左下角位置,即dp[pre+i][rest-i],i的范围是[pre,rest]。
     * 通过观察dp数组的依赖关系,可以看出,dp[pre][rest]是累加了dp[pre+1][rest]和dp[pre][rest-pre]的结果。
     * 具体我们可以画出一个dp数组的依赖关系图,举例不同的值,就能归纳出上面的结果。
     * 这个纯粹是观察dp数组的依赖关系,总结出的结果。
     */
    public static int ways3(int n) {
        if (n <= 0) {
            return 0;
        }
        if (n == 1) {
            return 1;
        }
        int[][] dp = new int[n + 1][n + 1];
        for (int pre = 1; pre <= n; pre++) {
            dp[pre][0] = 1;
            dp[pre][pre] = 1;
        }
        for (int pre = n - 1; pre >= 1; pre--) {
            for (int rest = pre + 1; rest <= n; rest++) {
                dp[pre][rest] = dp[pre + 1][rest];
                dp[pre][rest] += dp[pre][rest - pre];
            }
        }
        return dp[1][n];
    }

整体代码和测试如下:

java 复制代码
/**
 * 题目十七:整数拆分
 * 给定一个正数n,求n的裂开方法数,
 * 规定:后面的数不能比前面的数小
 * 比如4的裂开方法有:
 * 1+1+1+1、1+1+2、1+3、2+2、4
 * 5种,所以返回5
 */
public class Q17_SplitNumber {

    /**
     * 暴力递归尝试
     * 思路:
     * 从左到右的尝试模型。
     * 我们可以写一个递归函数,函数中包含两个参数:上一个拆出来的数是pre,还剩rest需要去拆。
     * 在每个递归的时候,从pre开始,一直拆到rest,每个数都进行递归再次拆分,然后计算出整体的方法数即可。
     * 每次递归中从pre到rest的这个过程,枚举了所有的可能情况,这样就相当于在所有的可能组合中,挑出能组成rest的组合数。
     */
    public static int ways1(int n) {
        if (n <= 0) {
            return 0;
        }
        if (n == 1) {
            return 1;
        }
        // 从1开始进行拆分
        return process(1, n);
    }

    /**
     * 递归函数,计算从pre开始,拆出rest的方法数。
     *
     * @param pre  :上一个拆出来的数是pre
     * @param rest :还剩rest需要去拆
     * @return :返回拆解的方法数
     */
    public static int process(int pre, int rest) {
        // base case,当rest==0时,说明已经拆完了,返回1种方法。
        if (rest == 0) {
            return 1;
        }
        // 边界判断,代表前面已经拆分完了,到这里已经没有剩余值可以拆分了,
        // 这个不能写在base case后面,因为base case是rest==0的情况,如果后面判断base case,就会少算这种方法。
        if (pre > rest) {
            return 0;
        }
        // 从pre开始进行枚举拆分
        int ways = 0;
        for (int i = pre; i <= rest; i++) {
            ways += process(i, rest - i);
        }
        return ways;
    }

    /**
     * 从暴力递归改为动态规划
     * 思路:
     * 1. 分析可变参数:pre和rest,范围都是0~N,所以二维数组dp[N+1][N+1]即可。pre从1开始,所以下标为0的行是不用的。
     * 2. 分析base case:dp[*][0]=1,即第一列为1,因为一个数本身也是一种方法,所以对角线也是1,即dp[pre][pre]=1。
     * 因为pre > rest时为0,所以除了对角线和第一列,左下角的其他位置都为0,在java中不需要单独赋值为0。
     * 3. 分析依赖关系:dp[pre][rest]依赖dp[pre+i][rest-i],i从pre开始,到rest结束,当前位置依赖下面和左边的位置,所以从下往上,从左往右填写。
     * 4. 分析返回值:dp[1][n]。
     */
    public static int ways2(int n) {
        if (n <= 0) {
            return 0;
        }
        if (n == 1) {
            return 1;
        }
        int[][] dp = new int[n + 1][n + 1];
        // 分析base case:dp[*][0]=1,即第一列为1,因为一个数本身也是一种方法,所以对角线也是1,即dp[pre][pre]=1。
        for (int pre = 1; pre <= n; pre++) {
            dp[pre][0] = 1;
            dp[pre][pre] = 1;
        }
        // 从下往上,从左往右填右上角的位置
        for (int pre = n - 1; pre >= 1; pre--) {
            for (int rest = pre + 1; rest <= n; rest++) {
                int ways = 0;
                for (int i = pre; i <= rest; i++) {
                    ways += dp[i][rest - i];
                }
                dp[pre][rest] = ways;
            }
        }
        return dp[1][n];
    }

    /**
     * 严格表结构依赖的动态规划方法
     * 思路:
     * 从上面的递归方法可以看出,dp[pre][rest]依赖基于偏移量i的左下角位置,即dp[pre+i][rest-i],i的范围是[pre,rest]。
     * 通过观察dp数组的依赖关系,可以看出,dp[pre][rest]是累加了dp[pre+1][rest]和dp[pre][rest-pre]的结果。
     * 具体我们可以画出一个dp数组的依赖关系图,举例不同的值,就能归纳出上面的结果。
     * 这个纯粹是观察dp数组的依赖关系,总结出的结果。
     */
    public static int ways3(int n) {
        if (n <= 0) {
            return 0;
        }
        if (n == 1) {
            return 1;
        }
        int[][] dp = new int[n + 1][n + 1];
        for (int pre = 1; pre <= n; pre++) {
            dp[pre][0] = 1;
            dp[pre][pre] = 1;
        }
        for (int pre = n - 1; pre >= 1; pre--) {
            for (int rest = pre + 1; rest <= n; rest++) {
                dp[pre][rest] = dp[pre + 1][rest];
                dp[pre][rest] += dp[pre][rest - pre];
            }
        }
        return dp[1][n];
    }

    public static void main(String[] args) {
        System.out.println("测试开始");
        boolean isSuccess = true;
        for (int i = 0; i < 100; i++) {
            int ans1 = ways1(i);
            int ans2 = ways2(i);
            int ans3 = ways3(i);
            if (ans1 != ans2 || ans2 != ans3) {
                System.out.println("错误");
                isSuccess = false;
                System.out.printf("aim = %d,ans1 = %d,ans2 = %d,ans3 = %d%n", i, ans1, ans2, ans3);
                break;
            }
        }
        if (isSuccess) {
            System.out.println("测试通过");
        }
    }
}

2.18、题目十八:较小的集合累加和

  • 题目十八:较小的集合累加和
  • 给定一个正数数组arr,
  • 请把arr中所有的数分成两个集合,尽量让两个集合的累加和接近
  • 返回最接近的情况下,较小集合的累加和
2.18.1、暴力递归尝试方法
  • 暴力递归尝试
  • 思路:
    • 题目要求拆分后的两个集合的累加和接近,也就是说两个集合都无限接近于整体集合累加和的一半。因为不一定刚好到一半,
    • 所以有可能一个是大于sum/2,一个是小于sum/2。题目要求的是求较小的累加和,也就是要不超过sum/2的那个。
    • 最后,题目就转换成了怎么样组合arr中的数,使得累加和尽量接近sum/2,求的是不能超过sum/2的和。
    • 这就转换成了一个背包问题,可以采用从左往右的尝试模型,
    • 对于每一个数arr[i],都有两种选择:要或者不要。然后算出两种选择中累加和更接近rest的情况,
    • 因为要的是较小集合,也就是要的数量要少,也就是要挑较大的数,所以在挑选时要挑较大的一个。
java 复制代码
    /**
     * 暴力递归尝试
     * 思路:
     * 题目要求拆分后的两个集合的累加和接近,也就是说两个集合都无限接近于整体集合累加和的一半。因为不一定刚好到一半,
     * 所以有可能一个是大于sum/2,一个是小于sum/2。题目要求的是求较小的累加和,也就是要不超过sum/2的那个。
     * 最后,题目就转换成了怎么样组合arr中的数,使得累加和尽量接近sum/2,求的是不能超过sum/2的和。
     * 这就转换成了一个背包问题,可以采用从左往右的尝试模型,
     * 对于每一个数arr[i],都有两种选择:要或者不要。然后算出两种选择中累加和更接近rest的情况,
     * 因为要的是较小集合,也就是要的数量要少,也就是要挑较大的数,所以在挑选时要挑较大的一个。
     */
    public static int smallerSum1(int[] arr) {
        if (arr == null || arr.length < 2) {
            return 0;
        }
        int sum = 0;
        for (int num : arr) {
            sum += num;
        }
        return process(arr, 0, sum / 2);
    }

    /**
     * 递归函数的含义:
     * arr[i...]可以自由选择,请返回累加和尽量接近rest,但不能超过rest的情况下,最接近的累加和是多少?
     */
    public static int process(int[] arr, int i, int rest) {
        // base case
        if (i == arr.length) {
            return 0;
        }
        // 还有数,arr[i]这个数
        // 可能性1,不使用arr[i]
        int p1 = process(arr, i + 1, rest);
        // 可能性2,要使用arr[i],arr[i]小于等于rest的情况下才能使用arr[i]
        int p2 = 0;
        if (arr[i] <= rest) {
            p2 = arr[i] + process(arr, i + 1, rest - arr[i]);
        }
        return Math.max(p1, p2);

    }
2.18.2、从暴力递归尝试改成动态规划
  • 从暴力递归改为动态规划
  • 思路:
    • 1、根据递归函数的参数分析,i的范围是0N,rest的范围是0sum/2,所以dp数组的大小是(N+1)*(sum/2+1)
    • 2、根据递归函数的base case,dp[N][...]都是0,所以dp数组的最后一行都是0
    • 3、根据递归函数的递归过程,dp[i][rest]依赖于dp[i+1][rest]和dp[i+1][rest-arr[i]],所以dp数组的计算顺序是从下到上,从左到右
    • 4、根据递归函数的返回值,dp[0][sum/2]就是最终的结果
java 复制代码
    /**
     * 从暴力递归改为动态规划
     * 思路:
     * 1、根据递归函数的参数分析,i的范围是0~N,rest的范围是0~sum/2,所以dp数组的大小是(N+1)*(sum/2+1)
     * 2、根据递归函数的base case,dp[N][...]都是0,所以dp数组的最后一行都是0
     * 3、根据递归函数的递归过程,dp[i][rest]依赖于dp[i+1][rest]和dp[i+1][rest-arr[i]],所以dp数组的计算顺序是从下到上,从左到右
     * 4、根据递归函数的返回值,dp[0][sum/2]就是最终的结果
     */
    public static int smallerSum2(int[] arr) {
        if (arr == null || arr.length < 2) {
            return 0;
        }
        int sum = 0;
        for (int num : arr) {
            sum += num;
        }
        // 计算sum/2,因为rest的范围是0~sum/2
        sum /= 2;
        int N = arr.length;
        int[][] dp = new int[N + 1][sum + 1];
        // 从下到上,从左到右计算dp数组
        for (int i = N - 1; i >= 0; i--) {
            for (int rest = 0; rest <= sum; rest++) {
                // 可能性1,不使用arr[i]
                int p1 = dp[i + 1][rest];
                // 可能性2,要使用arr[i],arr[i]小于等于rest的情况下才能使用arr[i]
                int p2 = 0;
                if (arr[i] <= rest) {
                    p2 = arr[i] + dp[i + 1][rest - arr[i]];
                }
                dp[i][rest] = Math.max(p1, p2);
            }
        }
        return dp[0][sum];
    }

整体代码和测试如下:

java 复制代码
/**
 * 题目十八:较小的集合累加和
 * 给定一个正数数组arr,
 * 请把arr中所有的数分成两个集合,尽量让两个集合的累加和接近
 * 返回最接近的情况下,较小集合的累加和
 */
public class Q18_SplitSumClosed {
    /**
     * 暴力递归尝试
     * 思路:
     * 题目要求拆分后的两个集合的累加和接近,也就是说两个集合都无限接近于整体集合累加和的一半。因为不一定刚好到一半,
     * 所以有可能一个是大于sum/2,一个是小于sum/2。题目要求的是求较小的累加和,也就是要不超过sum/2的那个。
     * 最后,题目就转换成了怎么样组合arr中的数,使得累加和尽量接近sum/2,求的是不能超过sum/2的和。
     * 这就转换成了一个背包问题,可以采用从左往右的尝试模型,
     * 对于每一个数arr[i],都有两种选择:要或者不要。然后算出两种选择中累加和更接近rest的情况,
     * 因为要的是较小集合,也就是要的数量要少,也就是要挑较大的数,所以在挑选时要挑较大的一个。
     */
    public static int smallerSum1(int[] arr) {
        if (arr == null || arr.length < 2) {
            return 0;
        }
        int sum = 0;
        for (int num : arr) {
            sum += num;
        }
        return process(arr, 0, sum / 2);
    }

    /**
     * 递归函数的含义:
     * arr[i...]可以自由选择,请返回累加和尽量接近rest,但不能超过rest的情况下,最接近的累加和是多少?
     */
    public static int process(int[] arr, int i, int rest) {
        // base case
        if (i == arr.length) {
            return 0;
        }
        // 还有数,arr[i]这个数
        // 可能性1,不使用arr[i]
        int p1 = process(arr, i + 1, rest);
        // 可能性2,要使用arr[i],arr[i]小于等于rest的情况下才能使用arr[i]
        int p2 = 0;
        if (arr[i] <= rest) {
            p2 = arr[i] + process(arr, i + 1, rest - arr[i]);
        }
        return Math.max(p1, p2);

    }

    /**
     * 从暴力递归改为动态规划
     * 思路:
     * 1、根据递归函数的参数分析,i的范围是0~N,rest的范围是0~sum/2,所以dp数组的大小是(N+1)*(sum/2+1)
     * 2、根据递归函数的base case,dp[N][...]都是0,所以dp数组的最后一行都是0
     * 3、根据递归函数的递归过程,dp[i][rest]依赖于dp[i+1][rest]和dp[i+1][rest-arr[i]],所以dp数组的计算顺序是从下到上,从左到右
     * 4、根据递归函数的返回值,dp[0][sum/2]就是最终的结果
     */
    public static int smallerSum2(int[] arr) {
        if (arr == null || arr.length < 2) {
            return 0;
        }
        int sum = 0;
        for (int num : arr) {
            sum += num;
        }
        // 计算sum/2,因为rest的范围是0~sum/2
        sum /= 2;
        int N = arr.length;
        int[][] dp = new int[N + 1][sum + 1];
        // 从下到上,从左到右计算dp数组
        for (int i = N - 1; i >= 0; i--) {
            for (int rest = 0; rest <= sum; rest++) {
                // 可能性1,不使用arr[i]
                int p1 = dp[i + 1][rest];
                // 可能性2,要使用arr[i],arr[i]小于等于rest的情况下才能使用arr[i]
                int p2 = 0;
                if (arr[i] <= rest) {
                    p2 = arr[i] + dp[i + 1][rest - arr[i]];
                }
                dp[i][rest] = Math.max(p1, p2);
            }
        }
        return dp[0][sum];
    }


    public static void main(String[] args) {
        int maxLen = 25;
        int maxValue = 50;
        int testTime = 10000;
        System.out.println("测试开始");
        for (int i = 0; i < testTime; i++) {
            int len = (int) (Math.random() * maxLen);
            int[] arr = randomArray(len, maxValue);
            int ans1 = smallerSum1(arr);
            int ans2 = smallerSum2(arr);
            if (ans1 != ans2) {
                System.out.println("错误!");
                printArray(arr);
                System.out.println(ans1);
                System.out.println(ans2);
                System.out.printf("ans1 = %d,ans2 = %d%n", ans1, ans2);
                break;
            }
        }
        System.out.println("测试结束");
    }


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

    public static void printArray(int[] arr) {
        for (int num : arr) {
            System.out.print(num + " ");
        }
        System.out.println();
    }

}

2.19、题目十九:有个数限制的较小的集合累加和

  • 题目十九:有个数限制的较小的集合累加和
  • 给定一个正数数组arr,请把arr中所有的数分成两个集合
  • 如果arr长度为偶数,两个集合包含数的个数要一样多
  • 如果arr长度为奇数,两个集合包含数的个数必须只差一个
  • 请尽量让两个集合的累加和接近
  • 返回最接近的情况下,较小集合的累加和
2.19.1、暴力递归尝试方法
  • 暴力递归尝试的方法
  • 思路:
    • 相较于题目十八,这道题增加了要求的集合的数量的限制。
    • 所以在递归函数中,要新增一个参数picks,用来表示能够选择的数的数量限制。
    • 对于数量限制picks的情况,就有两种,一种是有偶数个的时候,picks = arr.length / 2,
    • 另一种是有奇数个的时候,有两个数量的集合,一个是picks = arr.length / 2 + 1,一个是picks = arr.length / 2
    • 因为我们算的就是在picks限制的情况下,最大达到一半的集合,所以递归函数本身就不会求出超过一半的累加和。
    • 所以我们在求较小的那个累加和的时候,如果是奇数个数,我们需要取这两个值中累计和较大的那个。这样才能保证拆分的两个集合的累加和接近。
java 复制代码
    /**
     * 暴力递归尝试的方法
     * 思路:
     * 相较于题目十八,这道题增加了要求的集合的数量的限制。
     * 所以在递归函数中,要新增一个参数picks,用来表示能够选择的数的数量限制。
     * 对于数量限制picks的情况,就有两种,一种是有偶数个的时候,picks = arr.length / 2,
     * 另一种是有奇数个的时候,有两个数量的集合,一个是picks = arr.length / 2 + 1,一个是picks = arr.length / 2
     * 因为我们算的就是在picks限制的情况下,最大达到一半的集合,所以递归函数本身就不会求出超过一半的累加和。
     * 所以我们在求较小的那个累加和的时候,如果是奇数个数,我们需要取这两个值中累计和较大的那个。这样才能保证拆分的两个集合的累加和接近。
     */
    public static int smallerSumLimitCount1(int[] arr) {
        if (arr == null || arr.length < 2) {
            return 0;
        }
        int sum = 0;
        for (int num : arr) {
            sum += num;
        }
        if (arr.length % 2 == 0) {
            return process(arr, 0, arr.length / 2, sum / 2);
        } else {
            return Math.max(process(arr, 0, arr.length / 2, sum / 2)
                    , process(arr, 0, arr.length / 2 + 1, sum / 2));
        }
    }

    /**
     * 递归函数的含义:
     * arr[i....]自由选择,挑选的个数一定要是picks个,累加和<=rest, 离rest最近的返回
     * 因为多加了一个参数picks,所以在到了最后一个i的时候,有可能是不符合条件的,所以要用返回-1的标志来说明当前的选择符不符合要求。
     */
    public static int process(int[] arr, int i, int picks, int rest) {
        //  base case, 当i == arr.length时,
        //  如果picks == 0,说明之前的选择已经符合要求,返回0
        //  如果picks != 0,说明之前的选择不符合要求,返回-1
        if (i == arr.length) {
            return picks == 0 ? 0 : -1;
        }
        // 不使用arr[i]这个数
        int p1 = process(arr, i + 1, picks, rest);
        // 就是要使用arr[i]这个数
        int p2 = -1;
        int next = -1;
        // 如果arr[i] <= rest,说明可以使用arr[i]这个数
        if (arr[i] <= rest) {
            next = process(arr, i + 1, picks - 1, rest - arr[i]);
        }
        // next有效的时候,计算新的累加和
        if (next != -1) {
            p2 = arr[i] + next;
        }
        return Math.max(p1, p2);

    }
2.19.2、从暴力递归尝试改成动态规划
  • 暴力递归改为动态规划
  • 思路:
    • 1、根据递归函数,有三个变量,i[0,N]、picks、rest[0,sum/2],picks偶数的时候是arr.length / 2,picks奇数的时候是arr.length / 2 + 1,
    • 整合一下我们可以让picks的范围是[0,(arr.length + 1)/2],这样就包含了偶数和奇数的所有情况。所以我们可以创建一个三维数组dp[N+1][(arr.length + 1)/2 + 1][sum/2 + 1],
    • 2、根据base case,dp[N][0][rest] = 0,其他的位置都初始化为-1。
    • 3、根据递归函数,我们可以从后往前填写dp数组,填写的顺序是:i从N-1到0,picks从0到(arr.length + 1)/2,rest从0到sum/2。
    • 4、根据递归函数,如果是偶数个,返回dp[0][arr.length / 2][sum],
    • 如果是奇数个,返回Math.max(dp[0][arr.length / 2][sum], dp[0][(arr.length / 2) + 1][sum])。
java 复制代码
    /**
     * 暴力递归改为动态规划
     * 思路:
     * 1、根据递归函数,有三个变量,i[0,N]、picks、rest[0,sum/2],picks偶数的时候是arr.length / 2,picks奇数的时候是arr.length / 2 + 1,
     * 整合一下我们可以让picks的范围是[0,(arr.length + 1)/2],这样就包含了偶数和奇数的所有情况。所以我们可以创建一个三维数组dp[N+1][(arr.length + 1)/2 + 1][sum/2 + 1],
     * 2、根据base case,dp[N][0][rest] = 0,其他的位置都初始化为-1。
     * 3、根据递归函数,我们可以从后往前填写dp数组,填写的顺序是:i从N-1到0,picks从0到(arr.length + 1)/2,rest从0到sum/2。
     * 4、根据递归函数,如果是偶数个,返回dp[0][arr.length / 2][sum],
     * 如果是奇数个,返回Math.max(dp[0][arr.length / 2][sum], dp[0][(arr.length / 2) + 1][sum])。
     */
    public static int smallerSumLimitCount2(int[] arr) {
        if (arr == null || arr.length < 2) {
            return 0;
        }
        int sum = 0;
        for (int num : arr) {
            sum += num;
        }
        sum /= 2;
        int N = arr.length;
        int M = (N + 1) / 2;
        int[][][] dp = new int[N + 1][M + 1][sum + 1];
        // 根据base case,dp[N][0][rest] = 0,其他的位置都初始化为-1
        for (int i = 0; i <= N; i++) {
            for (int j = 0; j <= M; j++) {
                for (int k = 0; k <= sum; k++) {
                    dp[i][j][k] = -1;
                }
            }
        }
        // dp[N][0][rest] = 0
        for (int rest = 0; rest <= sum; rest++) {
            dp[N][0][rest] = 0;
        }
        for (int i = N - 1; i >= 0; i--) {
            for (int picks = 0; picks <= M; picks++) {
                for (int rest = 0; rest <= sum; rest++) {
                    int p1 = dp[i + 1][picks][rest];
                    // 就是要使用arr[i]这个数
                    int p2 = -1;
                    int next = -1;
                    if (picks - 1 >= 0 && arr[i] <= rest) {
                        next = dp[i + 1][picks - 1][rest - arr[i]];
                    }
                    if (next != -1) {
                        p2 = arr[i] + next;
                    }
                    dp[i][picks][rest] = Math.max(p1, p2);
                }
            }
        }
        if ((arr.length % 2) == 0) {
            return dp[0][arr.length / 2][sum];
        } else {
            return Math.max(dp[0][arr.length / 2][sum], dp[0][(arr.length / 2) + 1][sum]);
        }
    }
2.19.3、从上往下填表的动态规划的方法
  • 从上往下填表的动态规划的方法
  • 思路:
    • 上面的动态规划我们是从暴力递归改动而来,填表的时候N从下往上进行填表。
    • 往上也有一种方法,N从上往下进行填表,这样的做法在进行初始赋值的时候,要做的比上面的多一点,
    • 基本思路和上面的动态规划是一样的,只是填表的顺序不同。时间和空间复杂度也是和上面一样的。
java 复制代码
    /**
     * 从上往下填表的动态规划的方法
     * 思路:
     * 上面的动态规划我们是从暴力递归改动而来,填表的时候N从下往上进行填表。
     * 往上也有一种方法,N从上往下进行填表,这样的做法在进行初始赋值的时候,要做的比上面的多一点,
     * 基本思路和上面的动态规划是一样的,只是填表的顺序不同。时间和空间复杂度也是和上面一样的。
     */
    public static int smallerSumLimitCount3(int[] arr) {
        if (arr == null || arr.length < 2) {
            return 0;
        }
        int sum = 0;
        for (int num : arr) {
            sum += num;
        }
        sum >>= 1;
        int N = arr.length;
        int M = (arr.length + 1) >> 1;
        int[][][] dp = new int[N][M + 1][sum + 1];
        // 先将所有位置复制为Integer.MIN_VALUE
        for (int i = 0; i < N; i++) {
            for (int j = 0; j <= M; j++) {
                for (int k = 0; k <= sum; k++) {
                    dp[i][j][k] = Integer.MIN_VALUE;
                }
            }
        }
        // 将三维表的picks为0的那一面赋值为0
        for (int i = 0; i < N; i++) {
            for (int k = 0; k <= sum; k++) {
                dp[i][0][k] = 0;
            }
        }
        // 将三维表的N为0,picks为1的那一行根据数组赋值
        for (int k = 0; k <= sum; k++) {
            dp[0][1][k] = arr[0] <= k ? arr[0] : Integer.MIN_VALUE;
        }
        // 从上往下,从左往右进行填表
        for (int i = 1; i < N; i++) {
            for (int j = 1; j <= Math.min(i + 1, M); j++) {
                for (int k = 0; k <= sum; k++) {
                    dp[i][j][k] = dp[i - 1][j][k];
                    if (k - arr[i] >= 0) {
                        dp[i][j][k] = Math.max(dp[i][j][k], dp[i - 1][j - 1][k - arr[i]] + arr[i]);
                    }
                }
            }
        }
        return Math.max(dp[N - 1][M][sum], dp[N - 1][N - M][sum]);
    }

整体代码和测试如下:

java 复制代码
/**
 * 题目十九:有个数限制的较小的集合累加和
 * 给定一个正数数组arr,请把arr中所有的数分成两个集合
 * 如果arr长度为偶数,两个集合包含数的个数要一样多
 * 如果arr长度为奇数,两个集合包含数的个数必须只差一个
 * 请尽量让两个集合的累加和接近
 * 返回最接近的情况下,较小集合的累加和
 */
public class Q19_SplitSumClosedSizeHalf {


    /**
     * 暴力递归尝试的方法
     * 思路:
     * 相较于题目十八,这道题增加了要求的集合的数量的限制。
     * 所以在递归函数中,要新增一个参数picks,用来表示能够选择的数的数量限制。
     * 对于数量限制picks的情况,就有两种,一种是有偶数个的时候,picks = arr.length / 2,
     * 另一种是有奇数个的时候,有两个数量的集合,一个是picks = arr.length / 2 + 1,一个是picks = arr.length / 2
     * 因为我们算的就是在picks限制的情况下,最大达到一半的集合,所以递归函数本身就不会求出超过一半的累加和。
     * 所以我们在求较小的那个累加和的时候,如果是奇数个数,我们需要取这两个值中累计和较大的那个。这样才能保证拆分的两个集合的累加和接近。
     */
    public static int smallerSumLimitCount1(int[] arr) {
        if (arr == null || arr.length < 2) {
            return 0;
        }
        int sum = 0;
        for (int num : arr) {
            sum += num;
        }
        if (arr.length % 2 == 0) {
            return process(arr, 0, arr.length / 2, sum / 2);
        } else {
            return Math.max(process(arr, 0, arr.length / 2, sum / 2)
                    , process(arr, 0, arr.length / 2 + 1, sum / 2));
        }
    }

    /**
     * 递归函数的含义:
     * arr[i....]自由选择,挑选的个数一定要是picks个,累加和<=rest, 离rest最近的返回
     * 因为多加了一个参数picks,所以在到了最后一个i的时候,有可能是不符合条件的,所以要用返回-1的标志来说明当前的选择符不符合要求。
     */
    public static int process(int[] arr, int i, int picks, int rest) {
        //  base case, 当i == arr.length时,
        //  如果picks == 0,说明之前的选择已经符合要求,返回0
        //  如果picks != 0,说明之前的选择不符合要求,返回-1
        if (i == arr.length) {
            return picks == 0 ? 0 : -1;
        }
        // 不使用arr[i]这个数
        int p1 = process(arr, i + 1, picks, rest);
        // 就是要使用arr[i]这个数
        int p2 = -1;
        int next = -1;
        // 如果arr[i] <= rest,说明可以使用arr[i]这个数
        if (arr[i] <= rest) {
            next = process(arr, i + 1, picks - 1, rest - arr[i]);
        }
        // next有效的时候,计算新的累加和
        if (next != -1) {
            p2 = arr[i] + next;
        }
        return Math.max(p1, p2);

    }

    /**
     * 暴力递归改为动态规划
     * 思路:
     * 1、根据递归函数,有三个变量,i[0,N]、picks、rest[0,sum/2],picks偶数的时候是arr.length / 2,picks奇数的时候是arr.length / 2 + 1,
     * 整合一下我们可以让picks的范围是[0,(arr.length + 1)/2],这样就包含了偶数和奇数的所有情况。所以我们可以创建一个三维数组dp[N+1][(arr.length + 1)/2 + 1][sum/2 + 1],
     * 2、根据base case,dp[N][0][rest] = 0,其他的位置都初始化为-1。
     * 3、根据递归函数,我们可以从后往前填写dp数组,填写的顺序是:i从N-1到0,picks从0到(arr.length + 1)/2,rest从0到sum/2。
     * 4、根据递归函数,如果是偶数个,返回dp[0][arr.length / 2][sum],
     * 如果是奇数个,返回Math.max(dp[0][arr.length / 2][sum], dp[0][(arr.length / 2) + 1][sum])。
     */
    public static int smallerSumLimitCount2(int[] arr) {
        if (arr == null || arr.length < 2) {
            return 0;
        }
        int sum = 0;
        for (int num : arr) {
            sum += num;
        }
        sum /= 2;
        int N = arr.length;
        int M = (N + 1) / 2;
        int[][][] dp = new int[N + 1][M + 1][sum + 1];
        // 根据base case,dp[N][0][rest] = 0,其他的位置都初始化为-1
        for (int i = 0; i <= N; i++) {
            for (int j = 0; j <= M; j++) {
                for (int k = 0; k <= sum; k++) {
                    dp[i][j][k] = -1;
                }
            }
        }
        // dp[N][0][rest] = 0
        for (int rest = 0; rest <= sum; rest++) {
            dp[N][0][rest] = 0;
        }
        for (int i = N - 1; i >= 0; i--) {
            for (int picks = 0; picks <= M; picks++) {
                for (int rest = 0; rest <= sum; rest++) {
                    int p1 = dp[i + 1][picks][rest];
                    // 就是要使用arr[i]这个数
                    int p2 = -1;
                    int next = -1;
                    if (picks - 1 >= 0 && arr[i] <= rest) {
                        next = dp[i + 1][picks - 1][rest - arr[i]];
                    }
                    if (next != -1) {
                        p2 = arr[i] + next;
                    }
                    dp[i][picks][rest] = Math.max(p1, p2);
                }
            }
        }
        if ((arr.length % 2) == 0) {
            return dp[0][arr.length / 2][sum];
        } else {
            return Math.max(dp[0][arr.length / 2][sum], dp[0][(arr.length / 2) + 1][sum]);
        }
    }

    /**
     * 从上往下填表的动态规划的方法
     * 思路:
     * 上面的动态规划我们是从暴力递归改动而来,填表的时候N从下往上进行填表。
     * 往上也有一种方法,N从上往下进行填表,这样的做法在进行初始赋值的时候,要做的比上面的多一点,
     * 基本思路和上面的动态规划是一样的,只是填表的顺序不同。时间和空间复杂度也是和上面一样的。
     */
    public static int smallerSumLimitCount3(int[] arr) {
        if (arr == null || arr.length < 2) {
            return 0;
        }
        int sum = 0;
        for (int num : arr) {
            sum += num;
        }
        sum >>= 1;
        int N = arr.length;
        int M = (arr.length + 1) >> 1;
        int[][][] dp = new int[N][M + 1][sum + 1];
        // 先将所有位置复制为Integer.MIN_VALUE
        for (int i = 0; i < N; i++) {
            for (int j = 0; j <= M; j++) {
                for (int k = 0; k <= sum; k++) {
                    dp[i][j][k] = Integer.MIN_VALUE;
                }
            }
        }
        // 将三维表的picks为0的那一面赋值为0
        for (int i = 0; i < N; i++) {
            for (int k = 0; k <= sum; k++) {
                dp[i][0][k] = 0;
            }
        }
        // 将三维表的N为0,picks为1的那一行根据数组赋值
        for (int k = 0; k <= sum; k++) {
            dp[0][1][k] = arr[0] <= k ? arr[0] : Integer.MIN_VALUE;
        }
        // 从上往下,从左往右进行填表
        for (int i = 1; i < N; i++) {
            for (int j = 1; j <= Math.min(i + 1, M); j++) {
                for (int k = 0; k <= sum; k++) {
                    dp[i][j][k] = dp[i - 1][j][k];
                    if (k - arr[i] >= 0) {
                        dp[i][j][k] = Math.max(dp[i][j][k], dp[i - 1][j - 1][k - arr[i]] + arr[i]);
                    }
                }
            }
        }
        return Math.max(dp[N - 1][M][sum], dp[N - 1][N - M][sum]);
    }

    // for test
    public static void main(String[] args) {
        int maxLen = 25;
        int maxValue = 50;
        int testTime = 10000;
        System.out.println("测试开始");
        for (int i = 0; i < testTime; i++) {
            int len = (int) (Math.random() * maxLen);
            int[] arr = randomArray(len, maxValue);
            int ans1 = smallerSumLimitCount1(arr);
            int ans2 = smallerSumLimitCount2(arr);
            int ans3 = smallerSumLimitCount3(arr);
            if (ans1 != ans2 || ans1 != ans3) {
                System.out.println("错误!");
                printArray(arr);
                System.out.printf("ans1 = %d,ans2 = %d,ans3 = %d%n", ans1, ans2, ans3);
                break;
            }
        }
        System.out.println("测试结束");
    }

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

    // for test
    public static void printArray(int[] arr) {
        for (int num : arr) {
            System.out.print(num + " ");
        }
        System.out.println();
    }

}

2.20、题目二十:N皇后问题(不能改为动态规划的递归场景)

  • 题目二十:N皇后问题(不能改为动态规划的递归场景)
  • N皇后问题是指在N*N的棋盘上要摆N个皇后,
  • 要求任何两个皇后不同行、不同列, 也不在同一条斜线上
  • 给定一个整数n,返回n皇后的摆法有多少种。n=1,返回1
  • n=2或3,2皇后和3皇后问题无论怎么摆都不行,返回0
  • n=8,返回92
2.20.1、暴力递归方法
  • 暴力递归方法:

  • 思路:

    • 我们用一个数组record[n]用来记录放置皇后的位置,下标i表示第i行,值record[i]表示第i行皇后的列数。
    • 然后递归尝试在每一行上放皇后,判断是否合法。将合法的方法数累加起来。就是最后的结果。
    • 因为record是一个一维数组,在判断的时候也只会判断i行之前的位置,之前的位置每次都是新的,i行后面的不影响,所以record数组不需要重置。
  • 总结:

    • n皇后问题因为每一个递归都和之前的递归结果无关,所以可以用一个一维数组record[n]来记录之前的结果。
    • 这就意味着递归过程没有重复的解,所以不能进行动态规划的优化。
java 复制代码
    /**
     * 暴力递归方法:
     * 思路:
     * 我们用一个数组record[n]用来记录放置皇后的位置,下标i表示第i行,值record[i]表示第i行皇后的列数。
     * 然后递归尝试在每一行上放皇后,判断是否合法。将合法的方法数累加起来。就是最后的结果。
     * 因为record是一个一维数组,在判断的时候也只会判断i行之前的位置,之前的位置每次都是新的,i行后面的不影响,所以record数组不需要重置。
     * <br>
     * 总结:
     * n皇后问题因为每一个递归都和之前的递归结果无关,所以可以用一个一维数组record[n]来记录之前的结果。
     * 这就意味着递归过程没有重复的解,所以不能进行动态规划的优化。
     */
    public static int num1(int n) {
        if (n < 1) {
            return 0;
        }
        int[] record = new int[n];
        // 从第0行开始尝试
        return process1(0, record, n);
    }

    /**
     * 递归函数,计算从i行开始,摆出n个皇后的方法数。
     *
     * @param i                :当前来到i行,一共是0~N-1行,必须要保证跟之前所有的皇后不打架
     * @param record:record[x] = y 之前的第x行的皇后,放在了y列上
     * @param n                :n皇后问题
     * @return :不关心i以上发生了什么,i.... 后续有多少合法的方法数
     */
    public static int process1(int i, int[] record, int n) {
        // 到了最后一行,因为是从0行开始的,到了n行,说明以前的n个已经好了,是合法的方法,返回1
        if (i == n) {
            return 1;
        }
        int res = 0;
        // i行的皇后,放哪一列呢?j列,每一列都尝试一遍
        for (int j = 0; j < n; j++) {
            // 合法的位置继续递归尝试下一行
            if (isValid(record, i, j)) {
                record[i] = j;
                res += process1(i + 1, record, n);
            }
        }
        return res;
    }

    /**
     * 判断放到row行column列是否合法
     */
    public static boolean isValid(int[] record, int row, int column) {
        // 判断和之前的所有行是否冲突
        for (int k = 0; k < row; k++) {
            // 列相同 || 斜线相同
            if (column == record[k] || Math.abs(record[k] - column) == Math.abs(row - k)) {
                return false;
            }
        }
        return true;
    }
2.20.2、暴力递归常数时间优化
  • 暴力递归常数时间优化:
  • 请不要超过32皇后问题,因为int类型是32位,超过32位的话,会超出int范围,算不了。
  • 思路:
    • 上面的递归方法中,我们用一个一维数组来缓存之前已经放过皇后的位置状态,如果我们用一个int的位来表示之前放过的状态,就能提升常数时间复杂度。从而提升效率。
    • 我们可以用一个int类型的limit变量来表示棋盘的限制,n皇后问题,limit右侧就有n个1,其他位置都是0,在递归的过程中limit不变。
    • 然后我们用三个int变量来表示之前皇后的列影响、左下对角线影响、右下对角线影响。如果这三个变量的某个位置为1,说明该位置不能放皇后。
    • 这样我们每次取出可以放置皇后的最右侧的一个1位置,在当前位置放置皇后,然后更新列影响、左下对角线影响、右下对角线影响。
    • 就能最后计算出所有的合法方法数。
    • 这种优化的时间复杂度和之前的递归是一样的,只是优化了单位时间复杂度,效率也能优化很多。
java 复制代码
    /**
     * 暴力递归常数时间优化:
     * 请不要超过32皇后问题,因为int类型是32位,超过32位的话,会超出int范围,算不了。
     * 思路:
     * 上面的递归方法中,我们用一个一维数组来缓存之前已经放过皇后的位置状态,如果我们用一个int的位来表示之前放过的状态,就能提升常数时间复杂度。从而提升效率。
     * 我们可以用一个int类型的limit变量来表示棋盘的限制,n皇后问题,limit右侧就有n个1,其他位置都是0,在递归的过程中limit不变。
     * 然后我们用三个int变量来表示之前皇后的列影响、左下对角线影响、右下对角线影响。如果这三个变量的某个位置为1,说明该位置不能放皇后。
     * 这样我们每次取出可以放置皇后的最右侧的一个1位置,在当前位置放置皇后,然后更新列影响、左下对角线影响、右下对角线影响。
     * 就能最后计算出所有的合法方法数。
     * 这种优化的时间复杂度和之前的递归是一样的,只是优化了单位时间复杂度,效率也能优化很多。
     */
    public static int num2(int n) {
        // 32以上的,会超出int范围,算不了
        if (n < 1 || n > 32) {
            return 0;
        }
        // 如果你是13皇后问题,limit 最右13个1,其他都是0
        // 因为java中int类型是32位,如果要表示32个1,就是-1,所以我们用-1来表示32个1
        // 对于其他位置,可以将1左移n位,然后减1,就可以得到其他位置都是0,n个1的limit
        int limit = n == 32 ? -1 : (1 << n) - 1;
        return process2(limit, 0, 0, 0);
    }

    /**
     * 递归函数,计算从i行开始,摆出n个皇后的方法数。
     *
     * @param limit       :limit : 限制的位数(不变参数),n皇后问题,limit为右侧n个1,其他都是0,比如7皇后问题的limit为:0....0 1 1 1 1 1 1 1
     * @param colLim      :之前皇后的列影响(某个位置为1,说明该位置不能放皇后):colLim
     * @param leftDiaLim  :之前皇后的左下对角线影响(某个位置为1,说明该位置不能放皇后):leftDiaLim
     * @param rightDiaLim :之前皇后的右下对角线影响(某个位置为1,说明该位置不能放皇后):rightDiaLim
     * @return :有多少合法的方法数
     */
    public static int process2(int limit, int colLim, int leftDiaLim, int rightDiaLim) {
        // 列限制和limit相同,代表所有的位置都放了皇后,是一种合法的方法,返回1
        if (colLim == limit) {
            return 1;
        }
        // 取到可以放皇后的位置
        // pos中所有是1的位置,是你可以去尝试皇后的位置
        int pos = limit & (~(colLim | leftDiaLim | rightDiaLim));
        int mostRightOne = 0;
        int res = 0;
        // 从最右侧的1开始,每次取到最右侧的1,在那个位置放置皇后,然后递归尝试下一个
        while (pos != 0) {
            // 提取出最右侧的1代表的值
            mostRightOne = pos & (~pos + 1);
            // 从pos中减掉,说明这个位置的皇后已经尝试过了,下一个位置继续尝试
            pos = pos - mostRightOne;
            // << 二进制的按位左移,其他位置用0填充
            // >>> 二进制的按位右移,其他位置用0填充
            // >> 二进制的带符号按位右移,其他位置用符号位填充,我们这里不能用符号位来填充,因为负数的符号位为1,会导致错误,所以要用>>>
            res += process2(limit,
                    colLim | mostRightOne,
                    (leftDiaLim | mostRightOne) << 1,
                    (rightDiaLim | mostRightOne) >>> 1);
        }
        return res;
    }

整体代码和测试如下:

java 复制代码
/**
 * 题目二十:N皇后问题(不能改为动态规划的递归场景)
 * N皇后问题是指在N*N的棋盘上要摆N个皇后,
 * 要求任何两个皇后不同行、不同列, 也不在同一条斜线上
 * 给定一个整数n,返回n皇后的摆法有多少种。n=1,返回1
 * n=2或3,2皇后和3皇后问题无论怎么摆都不行,返回0
 * n=8,返回92
 */
public class Q20_NQueens {
    /**
     * 暴力递归方法:
     * 思路:
     * 我们用一个数组record[n]用来记录放置皇后的位置,下标i表示第i行,值record[i]表示第i行皇后的列数。
     * 然后递归尝试在每一行上放皇后,判断是否合法。将合法的方法数累加起来。就是最后的结果。
     * 因为record是一个一维数组,在判断的时候也只会判断i行之前的位置,之前的位置每次都是新的,i行后面的不影响,所以record数组不需要重置。
     * <br>
     * 总结:
     * n皇后问题因为每一个递归都和之前的递归结果无关,所以可以用一个一维数组record[n]来记录之前的结果。
     * 这就意味着递归过程没有重复的解,所以不能进行动态规划的优化。
     */
    public static int num1(int n) {
        if (n < 1) {
            return 0;
        }
        int[] record = new int[n];
        // 从第0行开始尝试
        return process1(0, record, n);
    }

    /**
     * 递归函数,计算从i行开始,摆出n个皇后的方法数。
     *
     * @param i                :当前来到i行,一共是0~N-1行,必须要保证跟之前所有的皇后不打架
     * @param record:record[x] = y 之前的第x行的皇后,放在了y列上
     * @param n                :n皇后问题
     * @return :不关心i以上发生了什么,i.... 后续有多少合法的方法数
     */
    public static int process1(int i, int[] record, int n) {
        // 到了最后一行,因为是从0行开始的,到了n行,说明以前的n个已经好了,是合法的方法,返回1
        if (i == n) {
            return 1;
        }
        int res = 0;
        // i行的皇后,放哪一列呢?j列,每一列都尝试一遍
        for (int j = 0; j < n; j++) {
            // 合法的位置继续递归尝试下一行
            if (isValid(record, i, j)) {
                record[i] = j;
                res += process1(i + 1, record, n);
            }
        }
        return res;
    }

    /**
     * 判断放到row行column列是否合法
     */
    public static boolean isValid(int[] record, int row, int column) {
        // 判断和之前的所有行是否冲突
        for (int k = 0; k < row; k++) {
            // 列相同 || 斜线相同
            if (column == record[k] || Math.abs(record[k] - column) == Math.abs(row - k)) {
                return false;
            }
        }
        return true;
    }

    /**
     * 暴力递归常数时间优化:
     * 请不要超过32皇后问题,因为int类型是32位,超过32位的话,会超出int范围,算不了。
     * 思路:
     * 上面的递归方法中,我们用一个一维数组来缓存之前已经放过皇后的位置状态,如果我们用一个int的位来表示之前放过的状态,就能提升常数时间复杂度。从而提升效率。
     * 我们可以用一个int类型的limit变量来表示棋盘的限制,n皇后问题,limit右侧就有n个1,其他位置都是0,在递归的过程中limit不变。
     * 然后我们用三个int变量来表示之前皇后的列影响、左下对角线影响、右下对角线影响。如果这三个变量的某个位置为1,说明该位置不能放皇后。
     * 这样我们每次取出可以放置皇后的最右侧的一个1位置,在当前位置放置皇后,然后更新列影响、左下对角线影响、右下对角线影响。
     * 就能最后计算出所有的合法方法数。
     * 这种优化的时间复杂度和之前的递归是一样的,只是优化了单位时间复杂度,效率也能优化很多。
     */
    public static int num2(int n) {
        // 32以上的,会超出int范围,算不了
        if (n < 1 || n > 32) {
            return 0;
        }
        // 如果你是13皇后问题,limit 最右13个1,其他都是0
        // 因为java中int类型是32位,如果要表示32个1,就是-1,所以我们用-1来表示32个1
        // 对于其他位置,可以将1左移n位,然后减1,就可以得到其他位置都是0,n个1的limit
        int limit = n == 32 ? -1 : (1 << n) - 1;
        return process2(limit, 0, 0, 0);
    }

    /**
     * 递归函数,计算从i行开始,摆出n个皇后的方法数。
     *
     * @param limit       :limit : 限制的位数(不变参数),n皇后问题,limit为右侧n个1,其他都是0,比如7皇后问题的limit为:0....0 1 1 1 1 1 1 1
     * @param colLim      :之前皇后的列影响(某个位置为1,说明该位置不能放皇后):colLim
     * @param leftDiaLim  :之前皇后的左下对角线影响(某个位置为1,说明该位置不能放皇后):leftDiaLim
     * @param rightDiaLim :之前皇后的右下对角线影响(某个位置为1,说明该位置不能放皇后):rightDiaLim
     * @return :有多少合法的方法数
     */
    public static int process2(int limit, int colLim, int leftDiaLim, int rightDiaLim) {
        // 列限制和limit相同,代表所有的位置都放了皇后,是一种合法的方法,返回1
        if (colLim == limit) {
            return 1;
        }
        // 取到可以放皇后的位置
        // pos中所有是1的位置,是你可以去尝试皇后的位置
        int pos = limit & (~(colLim | leftDiaLim | rightDiaLim));
        int mostRightOne = 0;
        int res = 0;
        // 从最右侧的1开始,每次取到最右侧的1,在那个位置放置皇后,然后递归尝试下一个
        while (pos != 0) {
            // 提取出最右侧的1代表的值
            mostRightOne = pos & (~pos + 1);
            // 从pos中减掉,说明这个位置的皇后已经尝试过了,下一个位置继续尝试
            pos = pos - mostRightOne;
            // << 二进制的按位左移,其他位置用0填充
            // >>> 二进制的按位右移,其他位置用0填充
            // >> 二进制的带符号按位右移,其他位置用符号位填充,我们这里不能用符号位来填充,因为负数的符号位为1,会导致错误,所以要用>>>
            res += process2(limit,
                    colLim | mostRightOne,
                    (leftDiaLim | mostRightOne) << 1,
                    (rightDiaLim | mostRightOne) >>> 1);
        }
        return res;
    }

    public static void main(String[] args) {
        System.out.println("开始测试");
        for (int i = 1; i < 16; i++) {
            long start = System.currentTimeMillis();
            int ans1 = num1(i);
            long num1Cost = System.currentTimeMillis() - start;
            start = System.currentTimeMillis();
            int ans2 = num2(i);
            long num2Cost = System.currentTimeMillis() - start;
            System.out.printf("n = %d,num1 = %d,num2 = %d,num1Cost = %d ms,num2Cost = %d ms%n", i, ans1, ans2, num1Cost, num2Cost);
            if (ans1 != ans2) {
                System.out.println("错误!");
                System.out.printf("n = %d,ans1 = %d,ans2 = %d%n", i, ans1, ans2);
                break;
            }
        }
        System.out.println("测试成功");

    }
}

后记

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

相关推荐
RQ_ghylls36 分钟前
2.excel每3行计算一个均值,将高于均值的单元格设置背景红色
算法·均值算法·word·excel
断剑zou天涯39 分钟前
【算法笔记】从暴力递归到动态规划(一)
java·算法·动态规划
不爱编程爱睡觉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