动态规划是对暴力递归算法的优化,主要是通过数组记录的方法,优化掉一些重复计算的过程。总结下动态规划的过程:
(1) 抽象出一种“试法”,递归解决问题的方法,很重要
(2) 找到“试法”中的可变参数,规划成数组表,可变参数一般是0维的,有几个可变参数就是几维的表
(3) 找到base case,问题最基础的解,填入数组表中
(4) 根据“试法”中的递归过程,和base case已经填到数组表的值,继续填表
(5) 根据问题给定的参数,找到数组中对应的位置,就是最终的解
然后通过几个例子具体看一下动态规划是怎么玩的。
【例1】机器人达到指定位置方法数
假设有排成一行的 N 个位置,记为 1~N,N 一定大于或等于 2。开始时机器人在其中的 M 位置上(M 一定是 1~N 中的一个),机器人可以往左走或者往右走,如果机器人来到 1 位置, 那么下一步只能往右来到 2 位置;如果机器人来到 N 位置,那么下一步只能往左来到 N-1 位置。规定机器人必须走 K 步,最终能来到 P 位置(P 也一定是 1~N 中的一个)的方法有多少种。给定四个参数 N、M、K、P,返回方法数。
【举例】N=5,M=2,K=3,P=3
上面的参数代表所有位置为 1 2 3 4 5。机器人最开始在 2 位置上,必须经过 3 步,最后到达 3 位置。走的方法只有如下 3 种: 1)从2到1,从1到2,从2到3 2)从2到3,从3到2,从2到3 3)从2到3,从3到4,从4到3
所以返回方法数 3。 N=3,M=1,K=3,P=3
上面的参数代表所有位置为 1 2 3。机器人最开始在 1 位置上,必须经过 3 步,最后到达 3
位置。怎么走也不可能,所以返回方法数 0。
【分析】先抽象出“试法”。机器人当前在i位置,剩余步数为n。i:[1,N] n:[0,K]
递归公式:f(i,n)=f(i-1,n-1)+f(i+1,n-1)
base case: n==0时,f(i,0)=i==P?1:0
,即只有在P位置,才算是一种解法
特殊情况:题目中已经说了,1位置只能往右走,N位置只能往左位置走。
f(1,n)=f(2,n-1)
f(N,n)=f(N-1,n-1)
所以写出暴力递归的解法:
int f(int i,int n,int N,int P) {
if(n==0) return i==P?1:0;
if(i==1) return f(2,n-1,N,P);
if(i==N) return f(N-1,n-1,N,P);
return f(i-1,n-1,N,P)+f(i+1,n-1,N,P);
}
接下来就考虑如何优化成动态规划。因为有两个可变参数,所以要new一个二维表,根据取值范围确定二维表的大小dp[N+1][K+1]
。根据base case,先把dp[i][0]
位置的值填上,只有dp[P][0]
是1,其他位置都是0。填表的过程是和递归过程反着来的,是根据base case的值填表。所以对于要填的值dp[i][n]
,第1列已经填完的情况下,考虑第2列和后面的列怎么填。从递归解法看出,任何一列的值都依赖于前一列的数据,边界的数据单独处理。于是可以写出动态规划版本的解法:
int f(int N,int P,int K,int M) {
//动态规划不是递归,所以就不需要参数里带上可变参数了
//java数组默认初始化值0
int[][] dp=new int[N+1,K+1];
//最终结果 dp[M,K]
for(int i=1;i<=N;i++)
dp[i][0] = i==P?1:0;
for(int j=1;j<=K;j++) {
for(int i=1;i<=N;i++) {
if(i==1)
dp[i][j]=dp[2][j-1];
else if(i==N)
dp[i][j]=dp[N-1][j-1];
else
dp[i][j]=dp[i-1][j-1]+dp[i+1][j-1];
}
}
//填表完成,最终结果从数组取出来
return dp[M][K];
}
这里或许针对杨辉三角形还有另外的优化,我比较笨,就先不琢磨这个了。然后针对暴力递归方法还可以套缓存来降低时间复杂度,避免重复计算。套缓存的暴力递归解法:
//全局变量缓存结果
static Map<String,Integer> cache=new HashMap<>();
int f(int i,int n,int N,int P) {
if(n==0) return i==P?1:0;
//if(i==1) return f(2,n-1,N,P);
if(i==1) return cacheResult(2,n-1,N,P);
//if(i==N) return f(N-1,n-1,N,P);
if(i==N) return cacheResult(N-1,n-1,N,P);
//return f(i-1,n-1,N,P)+f(i+1,n-1,N,P);
return cacheResult(i-1,n-1,N,P)+cacheResult(i+1,n-1,N,P);
}
int cacheResult(int i,int n,int N,int P) {
if(cache.get(""+i+"_"+n)!=null)
return cache.get(""+i+"_"+n);
int res=f(i,n,N,P);
cache.put(""+i+"_"+n,res);
return res;
}
【例2】换钱的最少货币数
给定数组 arr,arr 中所有的值都为正数且不重复。每个值代表一种面值的货币,每种面值
的货币可以使用任意张,再给定一个整数 aim,代表要找的钱数,求组成 aim 的最少货币
数。
【举例】
arr=[5,2,3],aim=20。
4 张 5 元可以组成 20 元,其他的找钱方案都要使用更多张的货币,所以返回 4。
arr=[5,2,3],aim=0。
不用任何货币就可以组成 0 元,返回 0。
arr=[3,5],aim=2。
根本无法组成 2 元,钱不能找开的情况下默认返回-1。
【例2-1】考虑这个问题的变种问题:每种面值的货币只能用一张,求组成aim的货币方案数。
【分析】“试法”:遍历货币数组,当前位置为a,当前组成金额为b。a~[0,arr.length] b~[0,sum(arr)]
递归公式 f(a,b)=f(a+1,b)+f(a+1,b+arr[a])
base case: 当当前位置a=arr.length时,f(a,i)=(i==aim)?1:0
最终结果:f(0,0)
所以可以根据表中第a+1行的值确定第i行的值。动态规划解法:
int f(int a,int b,int[] arr,int aim) {
int sum=sum(arr);
int[][] dp=new int[arr.length+1][sum+1];
//base case
dp[arr.length][aim]=1;
for(int i=arr.length-1;i>=0;i--) {
for(int j=0;j<=sum;j++) {
dp[i][j]=dp[i+1][j]+dp[i+1][j+arr[i]];
}
}
return dp[0][0];
}
int sum(int[] arr) {
int sum=0;
for(int i=0;i<arr.length;i++) {
sum+=arr[i];
}
return sum;
}
【例2-2】变种问题:每张货币只能用一张,求组成aim的最少货币数。
“试法”:遍历货币数组,当前位置为a,已组成的金额b,已使用的货币数c。a~[0,arr.length] b~[0,sum(arr)] c~[0,arr.length]
递归公式:f(a,b,c)=min{f(a+1,b,c),f(a+1,b+arr[a],c+1)}
base case: 当前位置a=arr.length时,f(a,b,c)=b==aim?c:Integer.MAX_VALUE;
每一行的值还可以通过下一行的值得到,而最后一行的值已经确定了,所以可以写动态规划解法。这次数组中存的值是最少使用货币数。
int f(int a,int b,int c,int arr[],int aim) {
int sum=sum(arr);
int[][][] dp=new int[arr.length+1][sum+1][arr.length+1];
//basecase
for(int j=0;j<=sum;j++) {
for(int k=0;k<=arr.length;k++) {
dp[arr.length][j][k] = (j==aim)?k:Integer.MAX_VALUE;
}
}
for(int i=0;i<=arr.length;i++) {
for(int j=0;j<=sum;j++) {
for(int k=0;k<=arr.length;k++) {
dp[i][j][k]=Math.min(dp[i+1][j][k],dp[i+1][j+arr[i]][k+1]);
}
}
}
return dp[0][0][0];
}
【例2】下面回头看例2,即每种货币可以用任意张,求组成aim的最少货币数。
相比上面解法,不同的地方在于货币数的取值范围变成了[0,aim/min(arr)],已组成金额的取值范围变成了[0,aim]以及递归公式需要进行任意张的尝试,其他没有变化(忽略这句话吧,世界一直在变!!!)。f(a,b,c)=min{f(a+1,b,c),f(a+1,b+arr[a],c+1),f(a+1,b+2\*arr[a],c+2),...,f(a+1,b+(aim/arr[a])\*arr[a])}
每张货币最多用[aim/arr[a]]张。(超过了这个张数那么金额肯定大于aim)
int f(int a,int b,int c,int arr[] ,int aim) {
int sum=sum(arr);
int[][][] dp=new int[arr.length+1][aim+1][aim/min(arr)];
//basecase
for(int j=0;j<=aim;j++) {
for(int k=0;k<=arr.length;k++) {
dp[arr.length][j][k] = (j==aim)?k:Integer.MAX_VALUE;
}
}
for(int i=0;i<=arr.length;i++) {
for(int j=0;j<=aim;j++) {
for(int k=0;k<=arr.length;k++) {
int res=Integer.MAX_VALUE;
int n=aim/arr[i];
for(int t=0;t<=n;t++) {
int v;
if(j+t*arr[t]>aim) v=Integer.MAX_VALUE;
else v=dp[i+1][j+t*arr[t]][c+t];
if(v<res) res=v;
}
dp[i][j][k]=res;
}
}
}
return dp[0][0][0];
}
提交这个代码到牛客判题,内存超限。。。
原题要求时间复杂度O(n∗aim),空间复杂度O(n)。这个算法时间空间都超了。。
【例3】排成一条线的纸牌博弈问题
【题目】
给定一个整型数组 arr,代表数值不同的纸牌排成一条线。玩家 A 和玩家 B 依次拿走每张纸 牌,
规定玩家 A 先拿,玩家 B 后拿,但是每个玩家每次只能拿走最左或最右的纸牌,玩家 A 和 玩
家 B 都绝顶聪明。请返回最后获胜者的分数。
【举例】
arr=[1,2,100,4]。
开始时,玩家 A 只能拿走 1 或 4。如果玩家 A 拿走 1,则排列变为[2,100,4],接下来玩家 B
可以拿走 2 或 4,然后继续轮到玩家 A。如果开始时玩家 A 拿走 4,则排列变为[1,2,100],接
下 来玩家 B 可以拿走 1 或 100,然后继续轮到玩家 A。玩家 A 作为绝顶聪明的人不会先拿 4,
因为 拿 4 之后,玩家 B 将拿走 100。所以玩家 A 会先拿 1,让排列变为[2,100,4],接下来玩
家 B 不管 怎么选,100 都会被玩家 A 拿走。玩家 A 会获胜,分数为 101。所以返回 101。
arr=[1,100,2]。
开始时,玩家 A 不管拿 1 还是 2,玩家 B 作为绝顶聪明的人,都会把 100 拿走。玩家 B 会
获胜,分数为 100。所以返回 100。
【分析】博弈问题,抽象出“先手”和“后手”的概念,但是并不是指游戏玩家中的某一方,而是指一种状态的变化。某一方如果当前是先手,拿走纸牌之后就变成了后手。这样,对于游戏玩家任一方,先手操作和后手操作都是一样的。(这里需要琢磨,琢磨,在琢磨……)
“试法”:
(1) 当前为先手,那么比较拿走左边和拿走右边的分数,取较大的;
(2) 当前为后手,那么比较拿走左边和拿走右边的分数,取较小的。(先手会把较大的拿走)
当前区间为[i,j],i<=j,当前分数为s。i~[0,N-1] j[0N-1]
f(i,j,s)=max{s(i+1,j,s+arr[i]), s(i,j-1,s+arr[j])}
s(i,j,s)=min{f(i+1,j,s),f(i,j-1,s)}
base case: 当前为先手,并且只剩一张牌了,那么分数要加上这张牌;当前为后手,并且只剩一张牌了,那么分数不会加上这张牌。
最终结果:max{f(0,arr.length-1,0),s(0,arr.length-1,0)}
递归解法:
int f(int[] arr,int i,int j) {
if(i==j) return arr[i];
return Math.max(arr[i]+s(arr,i+1,j)+arr[j]+s(arr,i,j-1));
}
int s(int[] arr,int i,int j) {
if(i==j) return 0;
return Math.min(f(arr,i+1,j),f(arr,i,j-1));
}
规划两张表,动态规划解法:
int solve(int[] arr) {
int[][] dp_f=new int[arr.length][arr.length];
int[][] dp_s=new int[arr.length][arr.length];
//basecase
for(int i=0;i<arr.length;i++) {
dp_f[i][i]=arr[i];
}
//两张表互相推,而且是以对角线为单位
for(int i=1;i<arr.length;i++) {
for(int j=0;j<arr.length-i;j++) {
dp_f[j][i+j]=Math.max(dp_s[j+1][i+j]+arr[j],dp_s[j][i+j-1]+arr[i+j]);
dp_s[j][i+j]=Math.min(dp_f[j+1][i+j],dp_f[j][i+j-1]);
}
}
return Math.max(dp_f[0][arr.length-1],dp_s[0][arr.length-1]);
}
【例4】象棋中马的跳法
【题目】
请同学们自行搜索或者想象一个象棋的棋盘,然后把整个棋盘放入第一象限,棋盘的最左下
角是(0,0)位置。那么整个棋盘就是横坐标上9条线、纵坐标上10条线的一个区域。给你三个
参数,x,y,k,返回如果“马”从(0,0)位置出发,必须走k步,最后落在(x,y)上的方法数
有多少种?
这个题理解起来比较简单,而且象棋的棋盘也很好的对应了数组表。
“试法”:马的当前位置为(a,b),已经跳了c步。a~[0,8] b~[0,9] c~[0,k]
f(a,b,c)=f(a+1,b+2,c+1)+f(a+1,b-2,c+1)+f(a-1,b+2,c+1)+f(a-1,b-2,c+1)+f(a+2,b+1,c+1)+f(a+2,b-1,c+1)+f(a-2,b+1,c+1)+f(a-2,b-1,c+1)
base case: 已经跳的步数c=k时,f(a,b,c)=(a==x&&b==y)?1:0
。
特殊情况:越界返回0
最终结果:f(0,0,0)
所以根据步数c+1的种数,可以推出步数c的种数。动态规划解法:
int f(int a,int b,int c,int x,int y,int k) {
int H=9,V=10;
int[][][] dp=new int[9][10][k+1];
//basecase
for(int i=0;i<H;i++) {
for(int j=0;j<V;j++) {
dp[i][j][k]=(i==x&&j==y)?1:0;
}
}
for(int z=k-1;z>=0;z--) {
for(int i=0;i<H;i++) {
for(int j=0;j<V;j++) {
int res=0;
res+=checkAndCompute(i+1,j+2,z);
res+=checkAndCompute(i+1,j-2,z);
res+=checkAndCompute(i-1,j+2,z);
res+=checkAndCompute(i-1,j-2,z);
res+=checkAndCompute(i+2,j+1,z);
res+=checkAndCompute(i+2,j-1,z);
res+=checkAndCompute(i-2,j+1,z);
res+=checkAndCompute(i-2,j-1,z);
dp[i][j][z]=res;
}
}
}
return dp[0][0][0];
}
int checkAndCompute(int a,int b,int c,int[][][] dp) {
if(a<0||a>8||b<0||b>9) return 0;
return dp[a][b][c];
}
【例5】Bob的生存概率
给定五个参数n,m,i,j,k。表示在一个N*M的区域,Bob处在(i,j)点,每次Bob等概率的向上、
下、左、右四个方向移动一步,Bob必须走K步。如果走完之后,Bob还停留在这个区域上,
就算Bob存活,否则就算Bob死亡。请求解Bob的生存概率,返回字符串表示分数的方式。
这道题和例4象棋的题目有点类似,Bob在一个固定区域移动,也需要处理越界的情况,也是要求最后生存的移动方法多少种。最后要求Bob的生存概率,走K步那么最后可能的落脚点一共4^K个,生存概率=生存点个数/所有落脚点个数。
“试法”:Bob当前在(a,b)位置,还有c步可走。a~[0,N-1] b~[0,M-1] c~[0,K]
递归公式:f(a,b,c)=f(a-1,b,c-1)+f(a,b-1,c-1)+f(a+1,b,c-1)+f(a,b+1,c-1)
base case:当剩余0步可走,并且当前位置没有越界,则算一种方法。
特殊情况:越界则不会生存。
最终结果:f(I,J,K)
这里左神讲了处理越界的另一种方式,这个问题由于Bob每次只能移动一步,因此上下左右四个方向扩展1个长度,那么就不需要判断是否越界了(因为初始化已经设成0了)。但是还是要注意,这样会对变量的取值造成影响。
所以写出动态规划的解法:
String f(int N,int M,int K,int I,int J) {
//扩展区域简化越界判断
int[][][] dp=new int[N+2][M+2][K+1];
//basecase
for(int i=1;i<=N;i++) {
for(int j=1;j<=M;j++) {
dp[i][j][0]=1;
}
}
for(int k=1;k<=K;k++) {
for(int i=1;i<=N;i++) {
for(int j=1;j<=M;j++) {
dp[i][j][k]=dp[i-1][j][k-1]+dp[i][j-1][k-1]+dp[i+1][j][k-1]+dp[i][j+1][k-1];
}
}
}
int live=dp[I+1][J+1][K];
int total=Math.pow(4,K);
int gcd=gcd(total,live);
StringBuilder sb=new StringBuilder();
sb.append(live/gcd);
sb.append("/");
sb.append(total/gcd);
return sb.toString();
}
int gcd(int m,int n) {
return n==0?m:gcd(n, m%n);
}