前言
动态规划中比较难的是斜率优化,根据网上大佬传授内功心法,我们完全可以通过观察得到。
一、打怪兽
题目:
* 给定3个参数,N,M,K
* 怪兽有N滴血,等着英雄来砍自己
* 英雄每一次打击,都会让怪兽流失[0~M]的血量
* 到底流失多少?每一次在[0~M]上等概率的获得一个值
* 求K次打击之后,英雄把怪兽砍死的概率
分析:
1、每次可以对怪兽产生【0,M】伤害,因此我们可以得到我们后续递归种所有的可能性为(M+1)^k
2、我们只要算出能把怪兽打死的所有方法数,然后将打死怪兽的方法数 除以 (M+1)^k 即可得到答案
3、然后根据递归版本,先改写一版动态规划版本,进一步观察是否能够进行优化,建设枚举行为(for)
递归代码
public static double killMonster(int K, int M, int N) {
if (K < 1 || M < 1 || N < 1) {
return 0;
}
long all = (long) Math.pow((M + 1), K);
long kill = process(K, M, N);
return (double) ((double) kill / (double) all);
}
/**
* 返回将剩余血量为hp砍死的方法数
*
* @param times 还剩余多少刀可以砍怪兽
* @param M 每次砍怪兽,怪兽掉血范围在[0,M]
* @param hp 怪兽还剩余的血量
* @return
*/
public static long process(int times, int M, int hp) {
//base case
if (times == 0) {
return hp <= 0 ? 1 : 0;
}
//剪枝
if (hp <= 0) {
return (long) Math.pow((M + 1), times);
}
//普遍情况
long ways = 0;
for (int i = 0; i <= M; i++) {
ways += process(times - 1, M, hp - i);
}
return ways;
}
初版动态规划:
public static double dp1(int K, int M, int N) {
if (K < 1 || M < 1 || N < 1) {
return 0;
}
long all = (long) Math.pow((M + 1), K);
long[][] dp = new long[K + 1][N + 1];
dp[0][0] = 1;
for (int times = 0; times <= K; times++) {
dp[times][0] = (long) Math.pow((M + 1), times);
}
for (int times = 1; times <= K; times++) {
for (int hp = 1; hp <= N; hp++) {
//普遍情况
long ways = 0;
for (int i = 0; i <= M; i++) {
if (hp - i >= 0) {
ways += dp[times - 1][hp - i];
} else {
ways += Math.pow((M + 1), times - 1);
}
}
dp[times][hp] = ways;
}
}
long kill = dp[K][N];
return (double) (((double) kill) / ((double) all));
}
观察看到初版动态规划出现三个for循环,继续观察看能否将最里面for循环这个枚举行为简化下,
这里我们需要画二维表,进一步去观察规律
最终版动态规划
public static double dp2(int K, int M, int N) {
if (K < 1 || M < 1 || N < 1) {
return 0;
}
long all = (long) Math.pow((M + 1), K);
long[][] dp = new long[K + 1][N + 1];
dp[0][0] = 1;
for (int times = 0; times <= K; times++) {
dp[times][0] = (long) Math.pow((M + 1), times);
}
for (int times = 1; times <= K; times++) {
for (int hp = 1; hp <= N; hp++) {
//普遍情况
dp[times][hp] = dp[times - 1][hp] + dp[times][hp - 1];
if (hp - M - 1 >= 0) {
dp[times][hp] -= dp[times - 1][hp - M - 1];
} else {
dp[times][hp] -= Math.pow((M + 1), times - 1);
}
}
}
long kill = dp[K][N];
return (double) (((double) kill) / ((double) all));
}
二、最少货币数组成目标数
题目:
* arr是面值数组,其中的值都是正数且没有重复。再给定一个正数aim。
* 每个值都认为是一种面值,且认为张数是无限的。
* 返回组成aim的最少货币数(张数)
分析:
每种货币数不同张数情况下组成目标数,求最小即可
递归代码
public static int minCoins(int[] arr, int aim) {
if (arr == null || arr.length == 0 || aim < 0) {
return 0;
}
return process(arr, 0, aim);
}
/**
* 返回组成剩余面币需要最少的张数
*
* @param arr 面币数组
* @param index 当前面币数组下标
* @param rest 剩余要组成的目标面值数
* @return
*/
public static int process(int[] arr, int index, int rest) {
//base case
if (index == arr.length) {
return rest == 0 ? 0 : Integer.MAX_VALUE;
}
//普遍情况
int ans = Integer.MAX_VALUE;
for (int zhang = 0; rest - arr[index] * zhang >= 0; zhang++) {
int next = process(arr, index + 1, rest - arr[index] * zhang);
if (next != Integer.MAX_VALUE) {
// 需要加上当前的张数
ans = Math.min(ans, next + zhang);
}
}
return ans;
}
初版动态规划
public static int dp1(int[] arr, int aim) {
if (arr == null || arr.length == 0 || aim < 0) {
return 0;
}
int N = arr.length;
int[][] dp = new int[N + 1][aim + 1];
for (int rest = 1; rest <= aim; rest++) {
dp[N][rest] = Integer.MAX_VALUE;
}
for (int index = N - 1; index >= 0; index--) {
for (int rest = 0; rest <= aim; rest++) {
//普遍情况
int ans = Integer.MAX_VALUE;
for (int zhang = 0; rest - arr[index] * zhang >= 0; zhang++) {
int next = dp[index + 1][rest - arr[index] * zhang];
if (next != Integer.MAX_VALUE) {
ans = Math.min(ans, next + zhang);
}
}
dp[index][rest] = ans;
}
}
return dp[0][aim];
}
存在三个for循环,一看就需要去优化,还是和上面大怪兽一样画表格分析,这里直接说下观察的结论
最终动态规划代码
public static int dp2(int[] arr, int aim) {
if (arr == null || arr.length == 0 || aim < 0) {
return 0;
}
int N = arr.length;
int[][] dp = new int[N + 1][aim + 1];
for (int rest = 1; rest <= aim; rest++) {
dp[N][rest] = Integer.MAX_VALUE;
}
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 - arr[index]]需要再加1,图中不好画出来,
//实际上任意目标格子依赖自己底下格子加上递归中的zhang数,
//而左边dp[index][rest - arr[index]]这个格子依赖底下的格子都比目标各自少1,
//所以需要加1
dp[index][rest] = Math.min(dp[index][rest - arr[index]] + 1, dp[index][rest]);
}
}
}
return dp[0][aim];
}
三、 数字拆分
题目
* 给定一个正数n,求n的裂开方法数,
* 规定:后面的数不能比前面的数小
* 比如4的裂开方法有:
* 1+1+1+1、1+1+2、1+3、2+2、4
* 5种,所以返回5
分析
递归如餐规定两个参数,前个拆分的数和剩余的数
递归代码
public static int splitNnum(int n) {
if (n < 0) {
return 0;
}
return process(1, n);
}
/**
* 返回拆分rest个数,共有多少种方法数
*
* @param pre 前一个数
* @param rest 剩余的数需要拆分
* @return
*/
public static int process(int pre, int rest) {
//base case
if (rest == 0) {
return 1;
}
//剪枝
if (pre > rest) {
return 0;
}
//普遍情况
int ways = 0;
for (int i = pre; i <= rest; i++) {
ways += process(i, rest - i);
}
return ways;
}
初版动态规划
public static int dp1(int n) {
if (n < 0) {
return 0;
}
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++) {
//普遍情况
int ways = 0;
for (int i = pre; i <= rest; i++) {
ways += dp[i][rest - i];
}
dp[pre][rest] = ways;
}
}
return dp[1][n];
}
看到了三个for循环,看能否进行优化
普遍位置格子依赖,直接下一个格子和最靠近自己左边的格子
最终动态规划代码
public static int dp2(int n) {
if (n < 0) {
return 0;
}
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];
if (rest - pre >= 0) {
dp[pre][rest] += dp[pre][rest - pre];
}
}
}
return dp[1][n];
}
结论
手稿画图,方便后面自己看思路,如果大家看不明白,最好自己画画图,结合代码理解
斜率优化套路都是在递归改成初版动态规划的时候看是否存在多余的for循环,然后画表格观察,将里面for循环简化调