目录
【算法笔记】从暴力递归到动态规划(一)
【算法笔记】从暴力递归到动态规划(二)
【算法笔记】从暴力递归到动态规划(三)
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,这就算出了总共的可以砍的次数,然后求出砍死的次数,就是概率。
- 在求砍死的情况时,可以用从左到右的尝试模型,遍历所有情况,累计出砍死的情况即可。
- 所以设计递归函数的时候,需要考虑的参数有:
-
- 还剩多少血量rest
-
- 每次的伤害范围是[0~M],之所以要考虑这个范围,因为还要统计如果在times的时候,如果已经死了,就要累计上后面的所有的可能性。
-
- 还有多少次可以砍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、从暴力递归尝试改成动态规划
- 从暴力递归改为动态规划:
- 思路:
*- 分析可变参数:index和rest,范围都是0N和0aim,所以二维数组dp[N+1][aim+1]即可。
-
- 分析base case:dp[N][0]=0,其他位置都是无效解,用Integer.MAX_VALUE表示。
-
- 分析依赖关系:dp[index][rest]依赖dp[index+1][rest-arr[index]*0]...dp[index+1][rest-arr[index]*num],
- 当前行依赖下一行的位置,所以从下往上,从左往右填写。在初始化的时候,最大值填写最后一行即可。后面的就会计算到。
-
- 分析返回值: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、严格表结构依赖的动态规划方法
- 严格表结构依赖的动态规划方法
- 思路:
*- 分析依赖关系: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、从暴力递归尝试改成动态规划
- 从暴力递归改为动态规划
- 思路:
*- 分析可变参数:pre和rest,范围都是0~N,所以二维数组dp[N+1][N+1]即可。pre从1开始,所以下标为0的行是不用的。
-
- 分析base case:dp[*][0]=1,即第一列为1,因为一个数本身也是一种方法,所以对角线也是1,即dp[pre][pre]=1。
- 因为pre > rest时为0,所以除了对角线和第一列,左下角的其他位置都为0,在java中不需要单独赋值为0。
-
- 分析依赖关系:dp[pre][rest]依赖dp[pre+i][rest-i],i从pre开始,到rest结束,当前位置依赖下面和左边的位置,所以从下往上,从左往右填写。
-
- 分析返回值: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("测试成功");
}
}
后记
个人学习总结笔记,不能保证非常详细,轻喷