今天给大家介绍一个动态规划的技巧叫做斜率优化。
斜率优化
那么什么是斜率优化呢?
抽象的讲,如果我上面依赖的值的范围,没有超过我依赖的值的范围,也就是说我上面依赖的区域在我依赖区域的里面,这样的话当我再次去枚举一个依赖范围的时候,我上面的值可以给我某些指导。因为比较抽象,我们就可以认为是观察临近位置法,在斜率优化中,难的题可太多太多了,下面的题在其中只能算是简单题。
小试牛刀
先来个简单题试试刀。
题目一
给定5个参数,N,M,row,col,k 表示在N*M的区域上,醉汉Bob初始在(row,col)位置 Bob一共要迈出k步,且每步都会等概率向上下左右四个方向走一个单位 任何时候Bob只要离开N*M的区域,就直接死亡 返回k步之后,Bob还在N*M的区域的概率
初看上去是不是特像一个数学问题,那就有人要想了,我要不要求什么正太分布啊,肯定不是的,这道题主要就是实验,Bob一旦走出这个区域,Bob就死了,他就回不来了,首先,我们先考虑一共有多少种情况,也就是我们的分母,假设棋盘无限大,Bob一步就上下左右等概率4个方向,所以一共有4的k次方中情况,接下来我们再求出Bob走出k步之后还在区域中,我们收集Bob的一个生存点数,我们把所有的生存的情况收集起来,除以总情况,不就是Bob生存的概率。
public static double livePosibility(int row,int col,int k,int N,int M){
return (double)process(row,col,k,N,M)/Math.pow(4,k);
}
public static long process(int row,int col,int rest,int N,int M){
if (row < 0 || row == N || col < 0 || col == N){
return 0;
}
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;
}
初看,这是一个三维的动态规划,但我们看代码是不是觉得有些相似,对,就是之前文章中的象棋跳马问题,所以这边就不过多解释了,就直接把动态规划的代码给大家奉上。
public static double livePosibility2(int row,int col,int k,int N,int M){
long[][][] dp = new long[N][M][k+1];
for (int i = 0; i < N; i++) {
for (int j = 0; j < M; j++) {
dp[i][j][0] = 1;
}
}
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] = peek(dp,N,M,r-1,c,rest - 1);
dp[r][c][rest] += peek(dp,N,M,r+1,c,rest - 1);
dp[r][c][rest] += peek(dp,N,M,r,c-1,rest - 1);
dp[r][c][rest] += peek(dp,N,M,r,c+1,rest - 1);
}
}
}
return (double)dp[row][col][k]/Math.pow(4,k);
}
public static long peek(long[][][] dp,int N,int M,int r,int c,int rest){
if (r < 0 || r == N ||c < 0||c==N){
return 0;
}else {
return dp[r][c][rest];
}
}
难度飙升
题目二
给定3个参数,N,M,K 怪兽有N滴血,等着英雄来砍自己 英雄每一次打击,都会让怪兽流失[0~M]的血量 到底流失多少?每一次在[0~M]上等概率的获得一个值 求K次打击之后,英雄把怪兽砍死的概率
分析:
看上去是一个数学题哈,其实不是,这就是一个编程问题,就是一个尝试问题,一共k次打击,每次打击获取0到M的一个随机值,所以一共有多少种可能性,你第一次砍,可能让他掉0滴血,可能让他掉一滴血,可能让他掉2滴血,最后可能让他掉M滴血,它是不是一个M+1层的展开啊,你第2次砍,也是一个M+1的展开,第三次砍又是一个M+1的展开,所以一共是有(M+1)的k次方种可能性。那把所有分支都列出来,你看看到最后末尾的时候,怪兽如果死了,你就返回1嘛,你收集砍死的情况数。看最后能收集到多少情况数,假设手机到的情况数叫all的话,概率不就是all/(M+1)k次方 嘛。接下来我们就开始写尝试
public static double right1(int N,int M,int K){
if (N < 1 || M < 1 ||K < 1){
return 0;
}
long all = process1(K,M,N);
return (double)((double)all/(double)Math.pow(M+1,K)) ;
}
//怪兽还剩N点血
//每次伤害范围在0~m上
//还有k次可以砍
//返回砍死的次数
private static long process1(int times, int m, int hp) {
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 += process1(times-1,m,hp - i);
}
return ways;
}
看,尝试是不是很容易就把尝试写出来了,这也不难嘛,别急别急,难的在改动态规划上面,首先,我们还是按之前步骤,先看这是几个可变参数,两个吧,为啥,M不变呀所以,如果真的改动态规划的话,二维表,N是怪兽剩余血量变化范围从0到N,K是剩余几刀,变化范围从0到K。相信大家对从暴力递归的尝试改动态规划已经轻车熟路了。这里有一点值得注意的是,在改动态规划时,hp - i可能会越界,所以需要对其有一点小小的改造,if(hp <= 0)这部分是分析 hp - i 可能会越界后补充的baseCase,在process1中,没有这部分也对,然后我们在根据尝试改动态规划。
private static double dp1(int N,int M,int K) {
if (N < 1 || M < 1 ||K < 1){
return 0;
}
long[][] dp = new long[K+1][N+1];
dp[0][0] = 1;
for (int times = 1; times <=K ; times++) {
dp[times][0] = (long) Math.pow(M+1,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 += (long) Math.pow(M+1,times-1);
}
}
dp[times][hp] = ways;
}
}
其中,第一个for循环中,dp[times][0] = (long) Math.pow(M+1,times);是对baseCase的补充,else里面是当前这一刀把怪兽砍死了,所以得减去一刀计算点数。怎么样,是不是感觉有些难了,接下来就改进行斜率优化了。我们继续往下看。我们看这么一个二维表他有一个枚举行为,有了枚举行为我们就要观察周围,有没有什么位置能把这个枚举行为给替掉,如下图:
假设M的范围是0-3,如果我要计算[5][10]这个格子,他就需要依赖伤害为0时的dp[4][10],伤害为1的dp[4][9],伤害为2的dp[4][8]和伤害为3的dp[4][7]这些格子。我们可以这么说,dp[5][10]的值依赖上一行dp[4][10....7]假设还有一个值dp[5][11],它依赖于dp[4][11...8],我们观察一下dp[5][11]是不是就等于dp[5][10]+dp[4][11]-dp[4][7]看这样我们是不是就把枚举行为给省掉了。这就是简单的斜率优化。代码如下。
private static double dp2(int N,int M,int K) {
if (N < 1 || M < 1 ||K < 1){
return 0;
}
long[][] dp = new long[K+1][N+1];
dp[0][0] = 1;
for (int times = 1; times <=K ; times++) {
dp[times][0] = (long) Math.pow(M+1,times);
for (int hp = 1; hp <= N ; hp++) {
dp[times][hp] = dp[times][hp-1] + dp[times-1][hp];
if (hp - 1 - M >= 0 ){
dp[times][hp] -= dp[times-1][hp-1-M];
}else {
dp[times][hp] -= (long) Math.pow(M+1,times-1);
}
}
}
long all = dp[K][N];
return (double)((double)all/(double)Math.pow(M+1,K)) ;
}
题目三
arr是面值数组,其中的值都是正数且没有重复。再给定一个正数aim。 每个值都认为是一种面值,且认为张数是无限的。 返回组成aim的最少货币数
分析:
假设有一个面值数组,要搞定一个aim,要怎么搞定,我们先从0开始,用0张,用1张用2张,用3张等等,然后1位置用0张用1张用2张等等,这样慢慢尝试。我们还是从尝试开始,代码如下。
public static int minCoins(int[]arr,int aim){
return process(arr,0,aim);
}
//arr[index...]面值,每种面值张数自由选择
//搞出rest这么多钱,返回最小张数
private static int process(int[] arr, int index, int rest) {
if (index == arr.length){
return rest == 0 ? 0 : Integer.MAX_VALUE;
}else {
int ans = Integer.MAX_VALUE;
for (int zhang = 0; zhang * arr[index] <= rest; zhang++) {
int next = process(arr,index+1,rest - zhang * arr[index]);
if (next != Integer.MAX_VALUE){
ans = Math.min(ans,next+zhang);
}
}
return ans;
}
}
代码是不是依旧如此简单,但是有一点需要注意的是,在取较小的时候,比较的值必须是有效的,如果不是有效的值,返回给调用者的值就会出问题。尝试有了,接下来就可以改动态规划了,相信大家应该可以直接根据递归尝试改动态规划了,这里就直接把动态规划的版本给大家。
private static int dp1(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 i = 1; i <= aim; i++) {
dp[N][i] = 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; zhang * arr[index] <= rest; zhang++) {
int next = dp[index+1][rest - zhang * arr[index]];
if (next != Integer.MAX_VALUE){
ans = Math.min(ans,next+zhang);
}
}
dp[index][rest] = ans;
}
}
return dp[0][aim];
}
我们发现有枚举行为,我们想要把它替换掉,怎么替,画格子。
假设我需要搞定14元,我的面值是3元,a代表我使用0张之后,剩下的你给我搞定14元所返回的张数,b是我使用一张3元之后,剩下的你给我搞定11元所返回的张数,所以b得+1,往下同理,接下来我们就可以观察了,我们观察画×的位置是怎么算的,它是不是同样这样加过来的,但是画×的部分是b+0,c+1等等,通过观察可以发现,我们用x+1和a 进行pk嘛,谁小选谁
private static int dp2(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 i = 1; i <= aim; i++) {
dp[N][i] = 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] = Math.min(dp[index][rest],dp[index][rest-arr[index]]+1);
}
}
}
return dp[0][aim];
}
题目四
给定一个正数n,求n的裂开方法数, 规定:后面的数不能比前面的数小 比如4的裂开方法有: 1+1+1+1、1+1+2、1+3、2+2、4 5种,所以返回5
分析:
我们还是先从尝试开始,这个递归函数的含义稍微难想一些,我们定义一个递归函数,第一个参数是我上一个拆出来的数,第二个参数是还剩多少数要拆,我们一次尝试,假如说我要拆一个5,我第一次要拆3,那完了,因为后面的数不能比前面的小,我们从1开始依次尝试。
public static int ways1(int num){
if (num <= 0){
return 0;
}
if (num == 1){
return 1;
}
return process(1,num);
}
//上一个拆出来数是pre
//还剩rest个数需要拆
//返回拆出来的方法数
public static int process(int pre,int rest){
if (rest == 0){
return 1;
}
if (pre > rest){
return 0;
}
if (pre == rest){
return 1;
}
int ways = 0;
for (int i = pre; i <= rest ; i++) {
ways += process(i,rest-i);
}
return ways;
}
看代码,很简单是不是,前面一写基础的baseCase,然后我从当前位置开始拆,递归调用,然后我们根据递归改出动态规划版本。
public static int dp1(int num){
if (num < 0){
return 0;
}
if (num == 1){
return 1;
}
int[][] dp = new int[num + 1][num + 1];
for (int pre = 1; pre <= num ; pre++) {
dp[pre][0] = 1;
dp[pre][pre] = 1;
}
for (int pre = num - 1; pre >= 1 ; pre--) {
for (int rest = pre+1; rest <= num ; rest++) {
int ways = 0;
for (int i = pre; i <= rest ; i++) {
ways+=dp[i][rest-i];
}
dp[pre][rest] = ways;
}
}
return dp[1][num];
}
我们发现这里面有一个枚举的调用,然后我们可以进行优化,我们随便找一个点,观察它的依赖关系,然后在找该点周围的点,观察他们的关系,相信大家一定能很容易就看出来,这里就直接把优化后的代码给大家。
public static int dp2(int num){
if (num < 0){
return 0;
}
if (num == 1){
return 1;
}
int[][] dp = new int[num + 1][num + 1];
for (int pre = 1; pre <= num ; pre++) {
dp[pre][0] = 1;
dp[pre][pre] = 1;
}
for (int pre = num - 1; pre >= 1 ; pre--) {
for (int rest = pre+1; rest <= num ; rest++) {
dp[pre][rest] = dp[pre+1][rest];
dp[pre][rest] += dp[pre][rest - pre];
}
}
return dp[1][num];
}