【左神算法课学习笔记】动态规划

【左神算法课学习笔记】动态规划



动态规划是对暴力递归算法的优化,主要是通过数组记录的方法,优化掉一些重复计算的过程。总结下动态规划的过程:

(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);
}
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值