一、引言
遇到一到动态规划题后,必须先自己思考找到一个暴力递归求解的方法,然后判断尝试过程是否有后效性。若无后效性,才可以套用动态规划题的模板。这里要说明的是,想出问题的暴力尝试方法这一步是最难的同时也是最重要的,而且没有固定的方法,只能通过不断做题才能又快又好的写出暴力解!
无后效性:是指一个递归状态的返回值与怎么到达这个状态的路径无关
二、使用动态规划优化暴力方法
暴力尝试方法一旦写好后,后面的优化过程全是固定的套路。
步骤如下:
当然尝试方法必须是无后效性的。
- 找到递归函数中所有可变参数,它们代表着每次递归时的递归状态,一旦可变参数确定,返回值也就确定了
- 把所有可变参数的组合映射成一张表,一个可变参数就是数组,两个可变参数就是二维矩阵,…
- 在表中找到最终答案,在表中标出。
- 初始化表中一些不需要依赖其他位置的位置的值填好,这就是base case
- 根据暴力尝试方法中的递归方法,把表中非base case的位置填好
- 返回最终答案在表中位置的值。
三、实战演示
【题目】
一行中有N个位置,即1~N。开始时,机器人在其中的M位置上(1 ≤ \leq ≤ M ≤ \leq ≤N),机器人可以左走( ← \gets ←)或者右走( → \to →),但是若机器在1位置时,那么它只能( → \to →);若机器人在N位置,那么它只能( ← \gets ←)。现在规定了机器人必须走K步,最终来到P位置的方法有多少种?
【举例】
N=5,M=2,K=3,P=3
上面的参数代表位置:
1 | 2 | 3 | 4 | 5 |
---|
机器人起始位置为2,必须经过3步最后到达3位置。走的方法有三种。
a.2
→
\to
→ 1,1
→
\to
→ 2,2
→
\to
→ 3
b.2
→
\to
→ 3,3
→
\to
→ 2,2
→
\to
→ 3
c.2
→
\to
→ 3,3
→
\to
→ 4,4
→
\to
→ 3
所以返回方法数3
【解答】
最难且最重要的方法:暴力递归
假设机器人现在在cur位置,还剩rest步没走,那么下一步该怎么走?
- cur==1,下一步只能走到2位置,还剩rest-1步
- cur==N,下一步只能走到N-1位置,还剩rest-1步
- 其余位置的话,下一步可以是cur-1或者cur+1位置,还剩rest-1步
每次尝试怎么算结束?所有步数K用完了就算结束
结束后返回啥呢?如果步数用完了后,停在K位置说明该尝试有效,即找到1种;
否则返回0,即没有找到任何一种方法。
尝试的递归过程如walk方法:
//N:位置为1~N,固定参数
//cur:当前在cur位置,可变参数
//rest:还剩rest步没有走,可变参数
//P:最终目标位置是P,固定参数
//当前在cur位置,走完rest步之后,最后停在P位置的方法数作为返回值
public int walk(int N,int cur,int rest,int P){
if(rest==0)
return cur==P?1:0;
if(cur==1)
return walk(N,2,rest-1,P);
if(cur==N)
return walk(N,N-1,rest-1,P);
return walk(N,cur-1,rest-1,P)+walk(N,cur+1,rest-1,P);
}
至此,我们已经完全找到并写出暴力递归方法。后面的动态规划就可以仿真第二节的套路来写了。
【优化过程】
首先结合walk函数的含义,分析整个递归过程是不是无后效性。这里说明的是99%的问题是无后效性的。但是分析问题的后效性是很重要的。walk函数中有两个固定参数N和P,他们任何时候不会改变,直接忽略他们。只需要关注两个可变参数cur和rest即可。walk(cur,rest)代表当前来到cur位置,还剩rest步的有效方法数量。
比如cur=5,rest=7,walk(5,7)代表当前位置在5,还剩7步,有效方法有多少种。之后可以自己画出树状图,必然会有很多重复的状态。比如walk(4,6)下一步可以是walk(5,5),或者是walk(6,6)的下一步也可以是walk(5,5)。不管是通过什么路径来到walk(5,5),这个值都是不变的,不会应为上一步不同而发生改变,即递归过程是无后效性的。
分析完后效性后,后面的优化过程就与原问题无关了。
【动态规划模板】
- 前提:问题的无后效性得到了证明
- walk函数中可变参数cur和rest一旦确定,返回值也就确定了
- 将cur和rest组合成一张二维表,这张表一定可以装下所有的返回值。表的大小根据cur和rest来考虑,cur含义是当前位置,它只可能在1N之间。rest含义是剩余步数,它只可能在0K之间。谁做行谁做列都行,这张表就记为dp[][],那么walk(cur,rest)的返回值为dp[rest][cur]
- N=7,M=4,K=9,P=5的最终答案,就是dp[9][4]的值
- 递归过程的base case是指问题的规模小到什么程度,就不需要再划分子问题,答案就可以直接得到了。结合暴力递归的方法:
if(rest==0)
return cur==P?1:0;
dp表的第一行就是basecase,根据上面代码可以把dp[0][1~N]手动填好。本题中P=5,所以第一行只有dp[0][5]=1;
/*
base case
**/
dp[0][P]=1;
5. base case之外的情况都是普遍位置了,结合暴力递归的方法:
if(cur==1)
return walk(N,2,rest-1,P);
if(cur==N)
return walk(N,N-1,rest-1,P);
return walk(N,cur-1,rest-1,P)+walk(N,cur+1,rest-1,P);
- 如果cur=1,dp[rest][cur]=dp[rest-1][2](图中A依赖B)
- 如果cur=N,dp[rest][cur]=dp[rest-1][N-1](图中F依赖D)
- 其余位置,dp[rest][cur]=dp[rest-1][cur-1]+dp[rest-1][cur+1](图中E依赖C和D)
1 cur N
- 返回dp[9][4]的值
所以本题动态规划的解法就是把规模N*K的表填好,时间复杂度为O(N*K)。下面就是动态规划算法:
public int ways2(int N,int M,int K,int P){
if(N<2||K<1||M<1||M>N||P<1||P>N)
return 0;
int[][] dp=new int[K+1][N+1];
dp[0][p]=1;
for(int i=1;i<=K;i++){
for(int j=1;j<=N;j++){
if(j==1)
dp[i][j]=dp[i-1][2];
else if(j==N)
dp[i][j]=dp[i-1][N-1];
else
dp[i][j]=dp[i-1][j+1]+dp[i-1][j-1];
}
}
return dp[K][M];
}
下面在介绍动态规划的空间压缩法
它纯粹是为了降低空间复杂度的,它改变不了时间复杂度。
若一个算法的空间复杂度为O(M*N),那么使用空间压缩法后空间复杂度为O(min[M,N])
结合上题来说,空间压缩优化空间复杂度的步骤:
- 生成一个一位数组dp[],它的大小由两个可变参数中范围小的那个参数决定。N=7,K=9,所以dp大小由N决定,dp[i]代表1~N中i位置由多少种方法的值。
int[] dp=new int[N+1];//必须为N+1,因为机器人只能在1~N中活动。
- 初始化dp。由未优化空间时的base case决定,在rest=0时,dp[0][P]=1。这里优化后少了第一维信息,所以优化后的dp是一个复用的数组空间。
dp[P]=1;
- 按照未优化的第5步,写出计算逻辑,由于缺少一维信息,所以势必需要少量的辅助变量。
for(int i=1;i<=K;i++){
int leftup=dp[1];//暂存左上角的信息
for(int j=1;j<=N;j++){
int temp=dp[j];
if(j==1)
dp[j]=dp[j+1];//对应A--B
else if(j==N)
dp[j]=left;//对应F--D
else
dp[j]=left+dp[j+1];//对应E--C、D
leftup=temp;//更新左上角的值
}
}